☰
股票评论情感分析:百度AI+MATLAB涨跌关系流水线
2026/10/30 7:01:55 网站建设 项目流程

简介:围绕股票评论与股价涨跌关系搭建的分析资料包,面向金融数据分析、文本挖掘与量化研究初学者及课程设计者,重点解决评论采集、情感标注和涨跌关联验证三类问题。包内共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 接口,requests+Session就能拿到结构化数据,没必要上浏览器自动化。真遇到接口加密或者强渲染,再考虑无头浏览器,但那是兜底方案,不是首选——慢、内存高、跑一晚上崩一次,血泪经验。

语义判断交给百度AI 的情感倾向分析接口,是权衡后的选择。自己训一个情感分类模型听着体面,但要先标几千条样本,股票评论里的黑话(“割肉”“站岗”“一字板”)通用词典根本覆盖不了,标完效果还不一定比通用接口好。调接口的代价是额度和 QPS 限制,以及通用模型对股市语境的钝感,这两点在后面的聚合环节要用阈值和抽样校验补回来。

MATLAB 这一环最容易被误用。常见的翻车做法是在 Python 里用 pandas 把统计全算完,再把结果导成 CSV 让 MATLAB 画个图——答辩老师一问“你这个滞后相关怎么算的”,代码里翻不出来。既然课题指定了 MATLAB,就把数据导入、对齐、相关分析、回归、画图都放在 MATLAB 里做,Python 只负责到“日度情绪指数”这一层。

2.3 数据口径先定死:交易日历、时间对齐、去重

口径不定,后面全是解释不了的数字。这三条我一般要求在写第一行爬虫代码之前就写进文档。

第一是交易日历。评论是 7×24 小时产生的,行情只有交易日有。周末和节假日的评论必须有个明确归属:要么并入下一个交易日,要么并入前一个交易日,二选一,全篇统一。我的习惯是并入下一个交易日,理由是周末的讨论更可能影响开盘情绪。

第二是时间对齐,也是最容易出前视偏差的地方。如果拿 T 日 15:00 之前的评论去解释 T 日的涨跌,逻辑上是拿结果解释结果。稳妥的做法是:用 T 日收盘后到 T+1 日收盘前的评论,对应 T+1 日的收益率;或者把评论窗口切成“盘前”“盘中”“盘后”三段分别看。方案里选一种写死,别混着用。

第三是去重。同一条评论可能因为翻页重叠被抓两次,也可能是水军复制粘贴。去重分两层:内容指纹(对正文做哈希)解决重复抓取,用户维度限权(同一用户同一股票每日最多计 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_size=8): """构造带重试策略的会话,避免单次网络抖动中断整批任务""" session = requests.Session() retry = Retry( total=3, # 最多重试 3 次 backoff_factor=0.8, # 退避系数:0.8s, 1.6s, 3.2s status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET"], ) adapter = HTTPAdapter(max_retries=retry, pool_maxsize=pool_size, pool_connections=pool_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_sleep=1.2, max_sleep=2.8, timeout=10): """单页抓取:成功返回 JSON,失败返回 None 并记录,交给上层决定是否重排""" try: resp = session.get(url, params=params, timeout=timeout) 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 None

pool_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, qps=2.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, }, timeout=10) 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", errors="ignore") self._throttle() try: resp = requests.post( self.API_URL, params={"access_token": self._get_token()}, data=text.encode("utf-8"), headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=10, ) 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 None

MAX_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_threshold=0.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, key=lambda 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 交付的是两个 CSV:daily_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 日,收益率在第 t+k 日,k 从 0 试到 5,看哪一阶的相关系数最稳定。

maxLag = 5; rho = nan(maxLag+1, 1); pval = nan(maxLag+1, 1); for k = 0:maxLag s = D.sentiment_index(1:end-k); r = D.ret(1+k:end); [rho(k+1), pval(k+1)] = corr(s, r, 'Type', 'Spearman', 'Rows', 'complete'); end T = table((0:maxLag)', rho, pval, 'VariableNames', {'lag','spearman_rho','p_value'}); disp(T)

用 Spearman 而不是 Pearson,是因为情绪指数和收益率都可能有厚尾和异常值,秩相关对离群点不敏感。k=0是同期相关,k>0才是“情绪领先收益”的方向。如果只有k=0显著、k>=1全是噪声,结论就只能写到“同期相关”,不能写成“领先指标”,这是答辩时最容易被追问的点。

再补一个回归,控制一下评论量的影响——有些时候情绪指数的波动其实是评论量在驱动,不控制会高估情绪的作用。

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 这种情况,标准回答是“统计上显著,但解释力有限,只能说明情绪和收益存在同期弱关联,不足以支撑交易策略”。把这句话提前写进讲稿,比现场组织语言稳得多。

从那以后我每次带人做这类课题,都会要求在写第一行爬虫代码之前先填一张口径确认表——评论归哪一天、涨跌用哪个口径、滞后看几阶、样本怎么剔。填不出来就先别写代码,写出来也是返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询