
简介围绕股票评论与股价涨跌关系搭建的分析资料包面向金融数据分析、文本挖掘与量化研究初学者及课程设计者重点解决评论采集、情感标注和涨跌关联验证三类问题。包内共10个文件以4张png可视化图表、2个xlsx结果表、2个py脚本、1个m脚本和1份csv评论数据为主压缩包约1.71MB分别承载爬虫采集、百度AI语义分析、MATLAB时间序列处理与图表呈现等环节。已有6465人学习说明该案例在股票评论情绪分析方向具有一定参考价值。读者可据此复现从评论抓取、情感倾向判定到股价走势对照的完整链路复用其中的爬虫与情感分析代码并借助MATLAB脚本和图表结果理解评论情绪分布与股票涨跌之间的潜在联系适合用作数据分析练习、课程项目或市场情绪研究的基础素材。1. 从 20 万条股票评论说起这套三段式流水线到底交付什么一位做量化方向毕设的同学拿着 20 万条股票评论来找我爬虫跑通了百度AI 也打完分了MATLAB 里一算情绪和当日涨跌的相关系数 0.04答辩时被问“这到底算有效还是无效”当场卡住。问题不在算法而在口径评论归属于哪一天、涨跌用收盘价还是收益率、滞后几期看这三件事没定死后面所有数字都是玄学。这套方案把“爬虫抓股票评论 → 百度AI 语义分析 → MATLAB 数据处理 → 股票涨跌与评论关系”拆成一条可复现的流水线每一步的输入输出、失败重试、验收标准都写清楚。它不承诺预测涨跌只承诺把“评论情绪和涨跌之间到底有没有稳定关系”这件事算明白。适合要交一份完整毕设或课设的人也适合想把这套分析流程搬到自己课题里的从业者。2. 需求拆解与技术选型采集、语义、统计三个环节的职责边界2.1 从一句研究问题拆成四个可交付模块“评论能不能解释涨跌”是研究问题不是工程任务。工程上必须把它翻译成四个能验收的模块每个模块有明确的输入、输出和落地物否则做到一半就会陷进“数据好像有了但不知道对不对”的状态。模块输入输出落地物采集股票代码列表、起止日期评论文本、发布时间、点赞数、作者标识stock_comment表语义单条评论文本极性、置信度、正负概率comment_sentiment表聚合逐条打分结果股票 × 交易日的情绪指数daily_sentiment.csv统计情绪指数 日行情相关系数、滞后阶数、回归系数图表与结论段这张表是我带人做这类课题时第一张要填的表。填不出来的部分就是后面会返工的部分。比如“作者标识”这一列如果一开始没留后面想按用户去重、想排除水军就得重爬一遍。四个模块之间是单向依赖采集脏了语义再准也白搭语义的阈值没定聚合出来的指数就是噪声聚合的日期口径和行情对不上MATLAB 里 join 完样本少一半。所以顺序不能乱也不能跳步。2.2 为什么是 Python 抓、AI 判、MATLAB 算采集用 Python是因为评论列表基本走 JSON 接口requestsSession就能拿到结构化数据没必要上浏览器自动化。真遇到接口加密或者强渲染再考虑无头浏览器但那是兜底方案不是首选——慢、内存高、跑一晚上崩一次血泪经验。语义判断交给百度AI 的情感倾向分析接口是权衡后的选择。自己训一个情感分类模型听着体面但要先标几千条样本股票评论里的黑话“割肉”“站岗”“一字板”通用词典根本覆盖不了标完效果还不一定比通用接口好。调接口的代价是额度和 QPS 限制以及通用模型对股市语境的钝感这两点在后面的聚合环节要用阈值和抽样校验补回来。MATLAB 这一环最容易被误用。常见的翻车做法是在 Python 里用 pandas 把统计全算完再把结果导成 CSV 让 MATLAB 画个图——答辩老师一问“你这个滞后相关怎么算的”代码里翻不出来。既然课题指定了 MATLAB就把数据导入、对齐、相关分析、回归、画图都放在 MATLAB 里做Python 只负责到“日度情绪指数”这一层。2.3 数据口径先定死交易日历、时间对齐、去重口径不定后面全是解释不了的数字。这三条我一般要求在写第一行爬虫代码之前就写进文档。第一是交易日历。评论是 7×24 小时产生的行情只有交易日有。周末和节假日的评论必须有个明确归属要么并入下一个交易日要么并入前一个交易日二选一全篇统一。我的习惯是并入下一个交易日理由是周末的讨论更可能影响开盘情绪。第二是时间对齐也是最容易出前视偏差的地方。如果拿 T 日 15:00 之前的评论去解释 T 日的涨跌逻辑上是拿结果解释结果。稳妥的做法是用 T 日收盘后到 T1 日收盘前的评论对应 T1 日的收益率或者把评论窗口切成“盘前”“盘中”“盘后”三段分别看。方案里选一种写死别混着用。第三是去重。同一条评论可能因为翻页重叠被抓两次也可能是水军复制粘贴。去重分两层内容指纹对正文做哈希解决重复抓取用户维度限权同一用户同一股票每日最多计 N 条解决刷屏。这两层不做某只股票某天的评论量能翻三倍情绪指数直接被带偏。3. 评论采集落地字段表、请求节奏与断点续爬3.1 表结构设计与字段取舍存储用 SQLite 就够了毕设规模几十万条完全撑得住答辩演示也方便真要迁移到 MySQL 改个连接串的事。建表时把去重约束直接写进 schema比在代码里判断可靠。-- 评论主表去重靠唯一约束不靠代码逻辑 CREATE TABLE stock_comment ( id INTEGER PRIMARY KEY AUTOINCREMENT, stock_code TEXT NOT NULL, -- 股票代码如 600XXX comment_id TEXT NOT NULL, -- 站内评论唯一 ID content TEXT NOT NULL, -- 正文入库前做空白归一 content_hash TEXT NOT NULL, -- 正文 MD5用于跨页去重 author_hash TEXT, -- 作者标识的哈希不存原始昵称 publish_ts INTEGER NOT NULL, -- 发布时间戳秒绝对时间 trade_date TEXT, -- 归属交易日聚合时回填 like_count INTEGER DEFAULT 0, crawl_time TEXT, UNIQUE(stock_code, comment_id) ); CREATE INDEX idx_date_code ON stock_comment(trade_date, stock_code); CREATE INDEX idx_hash ON stock_comment(content_hash);几个字段值得单独说。publish_ts存整型时间戳而不是字符串是为了后面做窗口切分和排序时不用反复解析。content_hash和comment_id是两套去重逻辑前者防跨页重复后者防同一评论被重复入库。author_hash存哈希不存原始昵称既够用又不涉及个人信息。trade_date是冗余字段采集时可以先留空聚合前统一回填避免在爬虫里混入交易日历逻辑。3.2 请求层封装与采集节奏控制采集崩掉的原因通常不是被封而是没做重试和退避——网络抖一下整批任务就断了。请求层要封成一个带重试、带超时、带节奏控制的 Session。import random import time import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(pool_size8): 构造带重试策略的会话避免单次网络抖动中断整批任务 session requests.Session() retry Retry( total3, # 最多重试 3 次 backoff_factor0.8, # 退避系数0.8s, 1.6s, 3.2s status_forcelist[429, 500, 502, 503, 504], allowed_methods[GET], ) adapter HTTPAdapter(max_retriesretry, pool_maxsizepool_size, pool_connectionspool_size) session.mount(https://, adapter) session.mount(http://, adapter) session.headers.update({ User-Agent: Mozilla/5.0 (compatible; ResearchCrawler/1.0), Accept: application/json, text/plain, */*, Referer: https://example.com/, # 替换为目标站点的实际来源页 }) return session def fetch_page(session, url, params, min_sleep1.2, max_sleep2.8, timeout10): 单页抓取成功返回 JSON失败返回 None 并记录交给上层决定是否重排 try: resp session.get(url, paramsparams, timeouttimeout) except requests.RequestException as exc: print(f[NET] {params.get(page)} - {exc}) return None finally: # 无论成败都sleep保持整体请求曲线平滑 time.sleep(random.uniform(min_sleep, max_sleep)) if resp.status_code ! 200: print(f[HTTP] {resp.status_code} page{params.get(page)}) return None try: return resp.json() except ValueError: print(f[JSON] 解析失败 page{params.get(page)}) return Nonepool_maxsize控制在 8 以内配合min_sleep、max_sleep的随机区间整体 QPS 大致落在 0.3 到 0.8 之间。这个节奏不是为了“防封”而是为了让采集过程本身可持续——跑一整晚不断线比短时间冲量重要得多。Retry里把 429 也纳入重试是因为限流响应往往带Retry-After退避之后能自动恢复。fetch_page返回None而不是抛异常是刻意的设计采集任务里单页失败是常态上层用“失败页队列”统一收尾重跑比让异常冒泡打断整批任务好处理。3.3 增量采集与断点续爬历史回补和每日增量要分开跑。历史回补按股票 × 时间段切任务每日增量按水位线拉取。水位线存一张小表记录每只股票最后一次成功采集的发布时间戳。CREATE TABLE crawl_cursor ( stock_code TEXT PRIMARY KEY, last_publish_ts INTEGER, -- 已入库评论的最大时间戳 last_page INTEGER, -- 当前未完成分页的页码完成后置 0 updated_at TEXT );断点续爬的关键是“页级断点”而不是“任务级断点”。一只股票有 500 页评论跑到第 380 页崩了如果只记录“这只股票没跑完”重跑就得从第 1 页开始前面 379 页全做重复请求。正确做法是每成功写入一页就更新last_page重启时从这个页码继续。还有一个细节翻页过程中新评论会不断插入导致页码漂移、老评论被挤到下一页。解决办法是用发布时间戳做游标before_ts参数而不是纯页码翻页。接口如果不支持时间游标就在翻页时记录本页最早的时间戳下一页抓完后校验时间是否单调递减不单调就回退重抓。4. 从评论到情绪分百度AI 语义分析的封装与 MATLAB 统计建模4.1 情感接口封装令牌、限流与重试百度AI 的情感倾向分析走的是 access_token 鉴权token 有有效期不能每次请求都去换。封装成一个类内部缓存 token同时用最小调用间隔控制 QPS。import time import requests class SentimentClient: TOKEN_URL https://aip.baidubce.com/oauth/2.0/token API_URL https://aip.baidubce.com/rpc/2.0/nlp/v1/sentiment_classify MAX_BYTES 2000 # 接口对文本长度的限制按字节算 def __init__(self, ak, sk, qps2.0): self.ak, self.sk ak, sk self._token None self._expire_at 0.0 self._min_interval 1.0 / max(qps, 0.1) self._last_call 0.0 def _get_token(self): 带缓存的 token 获取提前 5 分钟过期避免边界失效 if self._token and time.time() self._expire_at - 300: return self._token resp requests.post(self.TOKEN_URL, params{ grant_type: client_credentials, client_id: self.ak, client_secret: self.sk, }, timeout10) data resp.json() self._token data[access_token] self._expire_at time.time() int(data.get(expires_in, 2592000)) return self._token def _throttle(self): 最小间隔控制保证整体 QPS 不超过配置值 wait self._min_interval - (time.time() - self._last_call) if wait 0: time.sleep(wait) self._last_call time.time() def analyze(self, text): 返回 (polarity, confidence, pos_prob, neg_prob)失败返回 None raw text.encode(utf-8) if len(raw) self.MAX_BYTES: # 超长截断保留句末 text raw[: self.MAX_BYTES].decode(utf-8, errorsignore) self._throttle() try: resp requests.post( self.API_URL, params{access_token: self._get_token()}, datatext.encode(utf-8), headers{Content-Type: application/x-www-form-urlencoded}, timeout10, ) item resp.json()[items][0] return (item[sentiment], item[confidence], item[positive_prob], item[negative_prob]) except Exception as exc: print(f[AI] 打分失败: {exc}) return NoneMAX_BYTES按字节而不是字符算中文一个字三字节2000 字节大约 660 个汉字超过就截断。analyze返回四元组而不是单个标签是为了后面聚合时能加权、能过滤低置信度样本。打分失败返回None由调度层把这条评论重新入队重试三次仍失败就标记为failed不要静默丢弃——静默丢弃会让某些日期的样本量莫名变少后面统计时根本查不出来。4.2 情绪分聚合从逐条打分到日度指数接口返回的sentiment是 0/1/2 三分类负向、中性、正向直接拿来算平均没有信息量。要转成连续值sent_score positive_prob - negative_prob取值范围落在 [-1, 1]中性样本自然接近 0。聚合时不能简单算术平均否则一条高赞热评和一条无人问津的评论权重一样。用点赞数做权重但要压一下长尾避免大 V 绑架整日情绪。import math def to_score(sentiment, confidence, pos_prob, neg_prob, conf_threshold0.6): 把接口返回转成 [-1,1] 的连续分低置信度直接压成 0 if confidence is None or confidence conf_threshold: return 0.0 if sentiment 1: # 中性 return 0.0 return round(pos_prob - neg_prob, 4) def aggregate_daily(rows): 按 股票 × 交易日 聚合权重 log1p(点赞) 1 buckets {} for r in rows: key (r[stock_code], r[trade_date]) w math.log1p(max(r.get(like_count) or 0, 0)) 1.0 buckets.setdefault(key, []).append((w, r[sent_score], r[confidence])) result [] for (code, trade_date), pairs in buckets.items(): w_sum sum(w for w, _, _ in pairs) idx sum(w * s for w, s, _ in pairs) / w_sum result.append({ stock_code: code, trade_date: trade_date, sentiment_index: round(idx, 6), comment_cnt: len(pairs), avg_confidence: round(sum(c for _, _, c in pairs) / len(pairs), 4), }) return sorted(result, keylambda x: (x[stock_code], x[trade_date]))log1p的作用是把“1000 赞”和“10 赞”的权重差距从 100 倍压到 3 倍左右既保留热度信息又不让个别爆款评论决定整天的情绪值。conf_threshold设 0.6 是个经验值低于这个值的样本统一归零相当于把“模型也拿不准”的评论当中性处理。这个阈值要在报告里写明答辩时被问到直接翻出来。聚合完还有一步去极值对每只股票的sentiment_index序列做 1% 和 99% 分位的缩尾处理winsorize把异常日拉回边界。不做这一步某个被水军刷屏的交易日会变成一个孤立的尖峰相关系数直接被它带跑。4.3 MATLAB 侧导入、对齐与滞后相关Python 交付的是两个 CSVdaily_sentiment.csv股票代码、交易日、情绪指数、评论数和daily_quote.csv股票代码、交易日、开高低收、成交量。MATLAB 这边从读表开始全程不碰 Python。% 读入并统一日期类型避免字符串比较导致的错配 S readtable(daily_sentiment.csv, TextType, string); Q readtable(daily_quote.csv, TextType, string); S.trade_date datetime(S.trade_date, InputFormat, yyyy-MM-dd); Q.trade_date datetime(Q.trade_date, InputFormat, yyyy-MM-dd); % 内连接只保留两边都有记录的交易日 D innerjoin(S, Q, Keys, {stock_code, trade_date}); D sortrows(D, {stock_code, trade_date}); % 对数收益率比收盘价差分更稳 D.ret nan(height(D), 1); codes unique(D.stock_code); for i 1:numel(codes) idx D.stock_code codes(i); sub D.close(idx); D.ret(idx) [NaN; diff(log(sub))]; end D rmmissing(D(:, {stock_code,trade_date,sentiment_index,ret,comment_cnt})); fprintf(对齐后样本量: %d, 覆盖交易日: %d\n, height(D), numel(unique(D.trade_date)));innerjoin之后一定要打印样本量和对齐率。如果原始情绪表有 800 行、行情表有 900 行join 完只剩 400 行说明日期口径出了问题——大概率是周末评论没有正确归并或者停牌日被当成了缺失。这个数字是最早能发现问题的信号不要跳过。滞后相关是这类课题的核心。逻辑是情绪指数在第 t 日收益率在第 tk 日k 从 0 试到 5看哪一阶的相关系数最稳定。maxLag 5; rho nan(maxLag1, 1); pval nan(maxLag1, 1); for k 0:maxLag s D.sentiment_index(1:end-k); r D.ret(1k:end); [rho(k1), pval(k1)] corr(s, r, Type, Spearman, Rows, complete); end T table((0:maxLag), rho, pval, VariableNames, {lag,spearman_rho,p_value}); disp(T)用 Spearman 而不是 Pearson是因为情绪指数和收益率都可能有厚尾和异常值秩相关对离群点不敏感。k0是同期相关k0才是“情绪领先收益”的方向。如果只有k0显著、k1全是噪声结论就只能写到“同期相关”不能写成“领先指标”这是答辩时最容易被追问的点。再补一个回归控制一下评论量的影响——有些时候情绪指数的波动其实是评论量在驱动不控制会高估情绪的作用。D.cnt_z zscore(log1p(D.comment_cnt)); % 评论量取对数后标准化 mdl fitlm(D, ret ~ sentiment_index cnt_z, RobustOpts, on); disp(mdl)RobustOpts打开异方差稳健标准误金融收益率序列基本都有这个问题不开的话 p 值会偏小、结论偏乐观。4.4 结论的表述边界算出来之后怎么写结论比怎么算更重要。三条线要守住。第一区分“统计显著”和“经济意义”。相关系数 0.05 在几千个样本下很容易显著但 0.05 的相关性能解释的方差只有 0.25%拿来做任何决策都不够。报告里要同时给系数和置信区间别只给星号。第二不要说“预测”。在没有做样本外检验、没有做滚动窗口验证的情况下所有结论都只能叫“相关”或“同期关联”。想往预测上写至少要做训练集/测试集切分把样本外 R² 拿出来。第三交代样本期和剔除规则。哪段时间的行情、剔除了哪些停牌和 ST 股票、评论数少于多少条的交易日被丢弃这些都要写进方法章节。评审最容易问的就是“你这个结论换个时间段还成立吗”有剔除记录才能回答。5. 常见问题与避坑五个最容易让数据白跑的坑5.1 评论时间全是“3 分钟前”现象入库之后trade_date大量为空或者所有评论的日期都等于采集当天。原因列表页给的是相对时间采集时直接把文本存进去了或者接口返回的是毫秒时间戳代码按秒解析日期直接飞到几万年后。解决优先找接口里的绝对时间戳字段这是最可靠的。只能拿到相对时间的在采集那一刻用“当前时间减去偏移量”回推并且立刻落库不要先存文本等以后再解析——隔一天再解析“3 分钟前”已经失去参照点。解析完抽 20 条和页面显示对一遍这一步花不了十分钟。5.2 情感接口返回一大片中性和零分现象打分结果里中性占比超过 70%日度情绪指数几乎是一条直线方差小得没法做相关。原因股票评论普遍很短三五个字加一堆表情通用模型判不出倾向或者文本里有大量拼音缩写和谐音黑话模型词表里根本没有。解决入库前先清洗去掉重复标点和纯表情太短的评论少于 6 个汉字考虑丢弃或与同一用户相邻评论拼接把confidence阈值从 0.6 往下调一档试试同时抽 200 条人工标注算准确率。准确率低于 70% 就说明这套语义方案在当前语料上不成立得换思路或者缩窄研究范围别硬着头皮往下算。5.3 join 完样本少了一半现象情绪表 800 行行情表 900 行innerjoin之后只剩 400 行覆盖率不到 50%。原因周末和节假日的评论没有归并到交易日停牌日行情缺失两边日期格式一个是datetime一个是字符串。解决建一张交易日历表所有评论先按“下一个交易日”归并再做 join停牌日单独标记要么用前值填充要么剔除并在报告里写明剔除了多少日期类型在读表时就统一成datetime不要留到 join 时才发现。覆盖率低于 90% 就先别做统计回去修口径。5.4 相关系数 0.8方向还是反的现象情绪指数和收盘价的相关性高得离谱但回归系数符号和直觉相反。原因两条序列都是带趋势的水平值没有做平稳化时间趋势把相关性撑起来了属于典型伪相关。另外用收盘价而不是收益率价格水平本身的量级会把情绪指数的变化淹没。解决一律用对数收益率必要时做一阶差分跑之前先做单位根检验非平稳就差分到平稳滞后相关的阶数不要一次试到 10 阶以上多重比较会把偶然显著放大。5.5 同一条评论被算了三次现象某只股票某天评论数突然翻倍情绪指数出现孤立尖峰。原因翻页过程中新评论插入导致重复抓取水军账号复制粘贴同一段话转发内容被当成原创评论。解决入库时用content_hash唯一索引挡住重复内容用(stock_code, comment_id)挡住同一评论重复入库聚合时按author_hash限权同一用户同一股票每天最多计 3 条聚合后对情绪指数做缩尾。三件事都做尖峰基本消失。6. 验收与答辩怎么证明这套流水线不是算了个寂寞验收不是“代码能跑通”而是每个环节都有可核对的数字。我一般让做这套课题的人交一张自检表答辩前自己先过一遍。验收项判定标准检查方式采集完整性目标区间内每日评论数缺失率低于 5%按日 count 对比目标量级去重有效性重复内容占比低于 1%统计content_hash重复行数打分成功率接口成功返回比例不低于 99%扫日志里的失败计数时间对齐率join 后保留率不低于 90%打印前后行数结论稳健性换一个时间窗口系数符号不变前后两段样本各跑一次表格里最后一行最容易被忽略也最能在答辩时救场。评审问“你这个结论稳不稳”直接把前后两段样本的结果摆出来比任何解释都有说服力。答辩陈述的顺序也有讲究先讲口径再讲结果。开口先说清楚评论怎么归日、收益率怎么算、滞后几阶、剔除了哪些样本然后再放相关系数和回归表。反过来先放结论评审一定会从头追问口径节奏就乱了。还有一个具体技巧准备一张“显著但很弱”的解释卡。相关系数 0.05、p 值 0.01 这种情况标准回答是“统计上显著但解释力有限只能说明情绪和收益存在同期弱关联不足以支撑交易策略”。把这句话提前写进讲稿比现场组织语言稳得多。从那以后我每次带人做这类课题都会要求在写第一行爬虫代码之前先填一张口径确认表——评论归哪一天、涨跌用哪个口径、滞后看几阶、样本怎么剔。填不出来就先别写代码写出来也是返工。希望帮到你。本文还有配套的精品资源点击获取