ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

股票评论情绪分析与涨跌关系:Python爬虫到MATLAB实践

股票评论情绪分析与涨跌关系:Python爬虫到MATLAB实践

简介:围绕股票评论情感与股价涨跌关系搭建的实战型分析资料,面向具备Python与MATLAB基础、希望练习文本挖掘和市场情绪研究的学生及量化爱好者。包内共10个文件,以4张PNG图表、2个XLSX表格、2个PY脚本、1个M脚本和1份CSV评论数据组成,压缩包约1.71MB,分别承担结果可视化、情感与日期数据存储、评论抓取分析、时间序列处理及原始评论承载等用途。已有6465人学习下载。资料把爬虫采集、百度AI语义分析、情感标注、MATLAB统计建模与图表呈现串联成完整链路,读者可据此复现从评论获取到涨跌关联验证的流程,理解情感倾向分布、时间序列对照和可视化表达,也可借鉴脚本组织方式,观察评论情绪与价格波动的对应关系。包内脚本与表格便于按步骤检查数据清洗、情感标注和统计结果是否一致,适合作为课程设计、市场情绪研究或数据分析练习的参考案例。

1. 需求先立住:为什么要把股票评论的情绪和股票涨跌放进同一张表

用 Python 爬虫把股票评论抓下来、接一层语义分析打出情绪分、再交给 MATLAB 计算情绪与股票涨跌的关系,这个组合听起来像三门课硬拼在一起,真正做起来首先会卡住的地方是统计口径。评论在收盘后还能发,情绪却要对应到某个交易日的某根 K 线上,错一天,相关系数就能从 0.05 变成 0.4。这套方案要解决的是把非结构化的股评文本变成可对齐、可复现、能画图的数值序列,适合正在做金融文本分析方向毕业设计或课程设计的人,也适合想给自选股加一个舆情观察指标的从业者。下面的结构从需求拆到验收,每一步都给到能直接改参数就能跑的程度。

2. 总体设计:四段式管道与三张核心表的职责划分

先明确一条原则:不要让一个脚本干所有事情。很多初版方案写到最后是一个两千行的main.py,抓取、调用接口、存 CSV、画图全在里面,跑到第三周自己都说不清哪个字段是原始数据、哪个字段是加工结果。合理的做法是拆成四段,每段只认输入文件和输出文件,段与段之间用固定格式的文件或表交接,这样任何一段崩了都能单独重跑,不用从头再来一遍。

2.1 采集端用 Python、分析端用 MATLAB 的分工理由

采集和文本处理放 Python,是因为请求库、解析库、编码处理这些东西在 Python 里最省事,评论里混着全角符号、表情、乱码,清洗逻辑用 Python 写两三行就能搞定。分析放 MATLAB,理由更实际:交易日历、时间序列对齐、分位数分组、显著性检验、出图,这些在 MATLAB 里是几行函数的事,而且答辩时那种带坐标轴标注的分组柱状图和滞后相关系数曲线,比 Python 里折腾半天的默认样式更能说明问题。

代价是多了一次数据搬运,所以必须约定好交接格式。我一般让 Python 侧统一输出 UTF-8 的 CSV,列名全小写、日期统一YYYY-MM-DD、缺失值留空而不是填 0 或NA字符串。这三条约定看起来琐碎,但只要有一条没做到,MATLAB 的readtable就会把整列读成字符串,后面所有计算静默出错,这类问题就是纯玄学,能查一整天。

2.2 四段式管道的输入输出与数据契约

每一段的边界写清楚,是整个方案能不能被复现的基础。采集段的输入是股票代码和页数上限,输出是原始评论表;清洗段的输入是原始表,输出是带交易日归属的干净评论;语义段的输入是干净评论,输出是带情绪概率和标签的评论;分析段的输入是情绪打标表与行情日线表,输出是日频情绪指标、相关系数、分组统计和图片。

阶段主要工具输入输出产物失败后的重跑成本
采集Python requests + lxml股票代码、页码区间comment_raw表低,有断点续爬
清洗Python 正则comment_rawcomment_clean表低,纯本地计算
语义打分情感分析接口 + 本地缓存comment_cleancomment_scored表 / CSV中,受接口配额限制
分析建模MATLABcomment_scored.csv、quote_daily.csv指标表、图、检验结果极低,秒级

把这张表贴在方案文档的第一页,评审或导师问「你这个数据从哪来、怎么到图上的」,直接指这张表就行,比口头解释十遍管用。

2.3 三张表的字段设计:原始评论、行情日线、情绪打标

表结构定得粗糙,后面补救的成本极高。原始评论表必须有稳定的唯一键,否则重跑一次就多一份重复数据,情绪均值会被刷屏贴直接带偏。这里用社区帖子 ID 做主键,配合「股票代码 + 发帖时间」做辅助索引。行情日线表只保留分析真正要用的列,多余的字段带着只会让 join 出问题。

-- 原始评论表:只做追加,不改历史 CREATE TABLE comment_raw ( post_id TEXT NOT NULL, -- 社区帖子唯一 ID,天然去重键 stock_code TEXT NOT NULL, publish_time TEXT NOT NULL, -- 统一成 YYYY-MM-DD HH:MM content TEXT NOT NULL, -- 标题 + 摘要,截断到 800 字 author_hash TEXT, -- 用户名做哈希,避免存个人信息 crawl_time TEXT NOT NULL, PRIMARY KEY (post_id, stock_code) ); -- 行情日线表:由客户端导出的 CSV 导入,只留必要列 CREATE TABLE quote_daily ( stock_code TEXT NOT NULL, trade_date TEXT NOT NULL, -- YYYY-MM-DD open REAL, high REAL, low REAL, close REAL, pre_close REAL, volume REAL, PRIMARY KEY (stock_code, trade_date) ); -- 情绪打标表:语义分析的落点,一行一条评论 CREATE TABLE comment_scored ( post_id TEXT NOT NULL, stock_code TEXT NOT NULL, trade_date TEXT NOT NULL, -- 归属交易日,15:00 之后算下一交易日 positive_prob REAL, negative_prob REAL, label TEXT, -- bull / bear / neutral score REAL, -- positive_prob - negative_prob PRIMARY KEY (post_id, stock_code) );

trade_date这一列是整张表的灵魂。它不等于发帖日期,而是「这条评论在哪个交易日之前能被市场看到」。规则很简单:交易日 15:00 之前发的归当天,15:00 之后、周末、节假日的统一归下一个交易日。这条规则一旦定下来就写进清洗脚本,不要中途改,改了之后前面跑的相关性全部作废。

2.4 工程目录与执行顺序

目录结构不需要多漂亮,但要让别人一眼看出先跑哪个脚本。数字前缀是最省事的方式,运行顺序即文件顺序,新加一步就插在中间编号。

stock_sentiment/ ├── 01_crawl.py # 采集:写 comment_raw,带 checkpoint ├── 02_clean.py # 清洗:去重、去广告、算 trade_date ├── 03_sentiment.py # 语义:调接口,写 comment_scored ├── 04_export.py # 导出:生成 comment_scored.csv ├── matlab/ │ ├── load_and_align.m # 聚合 + 对齐 + 算情绪指标 │ ├── corr_test.m # 相关性与分组检验 │ └── plot_figs.m # 出图 ├── data/ │ ├── raw/ # 原始 HTML 快照,出问题时的后悔药 │ └── out/ # CSV 与图片 └── config.yaml # 股票列表、页数上限、阈值全放这里

把阈值、股票代码、页数上限这些会反复调的东西塞进config.yaml,是为了避免「改了阈值忘了改哪一行」的情况。血泪经验是:同一个阈值在三个脚本里各写一遍,最后你只会记得改其中两个。

3. Python 侧全链路:从评论抓取到情绪标签落库

这一段是整个方案里最容易出问题的部分,因为外部环境不由你控制。页面结构会变、接口会限流、网络会抖,所以每一步都要按「假设它会失败」来写。下面四小节按执行顺序走,代码里的路径和选择器都是占位,换成自己目标页面即可。

3.1 列表页分页与正文抽取

列表页一般支持按发帖时间倒序,这个排序方式对增量抓取非常关键:只要记录住上次抓到的最后一页,下次从那里继续,就不用每次全量重扫。请求头里带上Accept-Language和完整的User-Agent,很多站点对缺这两项的请求直接返回空列表,你会以为是选择器写错了,实际是根本没给内容。

import time, random, requests from bs4 import BeautifulSoup BASE = "https://example-forum.test/stock/{code}/list" # 占位域名,换成目标站点 HEADERS = { "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36"), "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_page(session, code, page, pagesize=40, timeout=10): """抓列表页。page 从 1 开始,按发帖时间倒序,便于增量续爬""" params = {"page": page, "size": pagesize, "sort": "ctime_desc"} resp = session.get(BASE.format(code=code), params=params, headers=HEADERS, timeout=timeout) resp.raise_for_status() return resp.text def parse_list(html, code): """把列表页拆成结构化行。选择器随站点改,改这一处就够""" soup = BeautifulSoup(html, "lxml") rows = [] for li in soup.select("div.post-list > div.post-item"): title = li.select_one("a.post-title") body = li.select_one("div.post-abstract") meta = li.select_one("span.post-time") if title is None or meta is None: # 置顶广告位没有时间,直接跳过 continue text = title.get_text(" ", strip=True) if body is not None: text += " " + body.get_text(" ", strip=True) rows.append({ "post_id": li.get("data-id"), "stock_code": code, "publish_time": meta.get_text(strip=True), "content": text[:800], # 截断,防止超长贴撑爆接口 }) return rows

content截断到 800 字是有意为之。评论区的长贴经常是复制粘贴的研报片段,对情绪判断没有增量信息,但会把单次请求的体量顶上去,接口报错率明显升高。截断之后既保住了标题里的核心态度,又让请求体保持在很小的一段。post_id为空的行直接丢弃,没有唯一键的数据进库只会制造麻烦。

3.2 限流、重试与断点续爬

请求频率是最容易踩坑的地方,而这个坑的坑人之处在于它不会立刻报错,往往跑到两千条之后才开始大面积超时,前面的数据看起来一切正常。解决办法是把「状态」落盘:每抓完一页就把页码写进一张状态表,程序中断后从上次的页码继续,而不是从第一页重来。

import sqlite3, time, random def crawl(code, max_page=200, db_path="data/out/stock_comment.db"): session = requests.Session() conn = sqlite3.connect(db_path) cur = conn.cursor() cur.execute("""CREATE TABLE IF NOT EXISTS crawl_state( stock_code TEXT PRIMARY KEY, last_page INTEGER, updated_at TEXT)""") row = cur.execute("SELECT last_page FROM crawl_state WHERE stock_code=?", (code,)).fetchone() start = (row[0] if row else 0) + 1 for page in range(start, max_page + 1): html = None for attempt in range(4): try: html = fetch_page(session, code, page) break except Exception as e: wait = (2 ** attempt) + random.random() # 1s / 2s / 4s 指数退避 print(f"page={page} try={attempt} err={e} sleep={wait:.1f}s") time.sleep(wait) if html is None: print(f"page={page} 连续失败,写 checkpoint 后退出") break rows = parse_list(html, code) if not rows: # 空页视为到底,正常收工 break cur.executemany("""INSERT OR IGNORE INTO comment_raw (post_id, stock_code, publish_time, content, crawl_time) VALUES (?,?,?,?,datetime('now'))""", [(r["post_id"], r["stock_code"], r["publish_time"], r["content"]) for r in rows]) cur.execute("""INSERT INTO crawl_state(stock_code, last_page, updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(stock_code) DO UPDATE SET last_page=excluded.last_page, updated_at=excluded.updated_at""", (code, page)) conn.commit() time.sleep(1.5 + random.random() * 1.5) # 主限流,别省这一行 conn.close()

这套写法里有三个参数值得单独解释。attempt循环给 4 次机会,是因为单次网络抖动很常见,但连续四次都失败基本说明被封了或者页面结构变了,继续重试没意义。退避时间用2 ** attempt加一点随机抖动,抖动是为了避免多个代码并行抓不同股票时请求节奏完全同步。页间休眠 1.5 到 3 秒看着慢,但一次全量抓取本来就要跑半小时以上,为了提速把这个值调到 0.2,通常第二天就会发现一半数据是空的。

提示:先拿一只股票、只抓 3 页做小样本验证,确认字段、时间格式、去重都正常,再放开页数上限跑全量。全量跑完才发现时间格式有三种,那种返工很难受。

3.3 情感接口的批量调用与本地缓存

语义打分这一步的瓶颈不在算法,在配额和网络。同一条评论重跑一次就多消耗一次调用额度,做实验时反复调阈值、改分组,一天之内很容易把日配额耗光。所以必须在本地做一层文本哈希缓存,命中缓存就直接返回,完全不打网络。

import hashlib, time, requests TOKEN_URL = "https://aip.baidubce.com/oauth/2.0/token" SENTI_URL = "https://aip.baidubce.com/rpc/2.0/nlp/v1/sentiment_classify" def get_token(ak, sk): """拿 access_token,有效期很长,一个进程取一次即可""" r = requests.post(TOKEN_URL, params={ "grant_type": "client_credentials", "client_id": ak, "client_secret": sk}, timeout=10) r.raise_for_status() return r.json()["access_token"] def score_one(token, text, cache): """单条打分,命中缓存直接返回,避免重复消耗配额""" key = hashlib.md5(text.encode("utf-8")).hexdigest() if key in cache: return cache[key] r = requests.post(SENTI_URL, params={"access_token": token}, data=text.encode("utf-8"), # 直接发 bytes,别二次编码 timeout=10) r.raise_for_status() js = r.json() item = (js.get("items") or [{}])[0] # 防御性取值,字段以官方文档为准 out = { "sentiment": item.get("sentiment"), # 0 负向 / 1 中性 / 2 正向 "confidence": item.get("confidence", 0.0), "positive_prob": item.get("positive_prob", 0.0), "negative_prob": item.get("negative_prob", 0.0), } cache[key] = out time.sleep(0.12) # 控制 QPS,一把梭的结果就是一串超时 return out

data=text.encode("utf-8")这一行的坑值得单独说:不少接口要求请求体就是原始文本,如果你图省事用json=把字符串包一层发过去,服务端拿到的会是带引号的 JSON 字符串,返回结果可能正常但概率分布明显偏中性。这类问题不会报错,只是结果慢慢变得没什么区分度,属于典型的黑匣子式故障。items用防御性取值也是同理,接口在某些输入下不返回items,直接下标取会抛异常,让整个批处理停在中间。

批处理时按 500 条一批落盘,批与批之间打印进度和当前缓存命中率。命中率爬升到 90% 以上就说明缓存生效了,后面再调阈值重跑,基本是秒级的事了。

3.4 阈值标定与人工抽检

接口给的是概率,分析需要的是标签。直接把positive_prob > 0.5当看多是最省事的做法,但会让大量模棱两可的帖子被算成看多,情绪均值整体偏高。更稳的做法是设一个双阈值:正向概率过 0.65 才算看多,负向概率过 0.65 才算看空,中间地带一律中性。

-- 打标并落表:score 用净差值,范围 [-1, 1],便于后面聚合 INSERT INTO comment_scored (post_id, stock_code, trade_date, positive_prob, negative_prob, label, score) SELECT c.post_id, c.stock_code, c.trade_date, s.positive_prob, s.negative_prob, CASE WHEN s.positive_prob >= 0.65 THEN 'bull' WHEN s.negative_prob >= 0.65 THEN 'bear' ELSE 'neutral' END, s.positive_prob - s.negative_prob FROM comment_clean c JOIN sentiment_cache s ON s.text_hash = c.text_hash;

阈值不是拍脑袋定完就走。抽 200 条评论人工标一遍,跟模型标签对齐,算一个准确率和一个混淆矩阵。通常通用模型在股评语料上的准确率在七成上下,中性类的召回最高、看空类的准确率最低,因为「抄底」「加仓」这种词模型容易判成正向,而在高位接盘的语境里其实是负向。把这个结论写进方案文档,比声称准确率九成要有说服力得多,也是答辩时最容易被追问、最好回答的一块。

4. MATLAB 侧:按日聚合、滞后对齐与涨跌关系检验

Python 那边交出两张 CSV,一张是带情绪标签的评论表,一张是行情日线表。MATLAB 这边的全部工作可以概括成三件事:把评论按交易日压成一条日频指标,把这个指标平移一天去对应次日收益,然后看两者到底有没有稳定的关系。顺序不能反,先聚合再对齐,如果反过来先对齐再聚合,很容易把不同交易日的评论混在一起。

4.1 数据导入与按交易日聚合

导入阶段最容易出问题的是类型推断。日期列被读成字符串,后面所有join都会静默失败或者报一个看不懂的错。所以导入时显式指定类型,别依赖自动推断。

% 显式指定日期列类型,避免 readtable 把日期读成字符串 opts = detectImportOptions('comment_scored.csv', 'Encoding', 'UTF-8'); opts.VariableTypes{'trade_date'} = 'datetime'; opts.VariableNamingRule = 'preserve'; C = readtable('comment_scored.csv', opts); % 把标签转成 0/1 指示变量,方便聚合 C.isBull = double(C.label == "bull"); C.isBear = double(C.label == "bear"); % 按交易日聚合:条数、看多数、看空数 G = groupsummary(C, 'trade_date', 'sum', {'isBull', 'isBear'}); G.n_total = G.GroupCount; G.senti_index = (G.sum_isBull - G.sum_isBear) ./ max(G.n_total, 1); % [-1, 1] G = renamevars(G, 'trade_date', 'date');

senti_index的定义是「看多占比减看空占比」,中性帖子不进分子,但进分母。这个口径的好处是它同时反映方向和市场分歧程度:指数接近 0 可能是多空对半,也可能是全场没人说话。所以后面做样本筛选时,n_total这个列不能丢,至少要保证当天评论数不低于 20 条,否则一天三条评论算出来的情绪指数噪声大到没有意义。

4.2 滞后对齐:把未来函数掐死在这一步

这一步决定了整个方案的可信度。当天收盘后发的评论,不可能影响当天已经走完的 K 线,如果把这类评论算进当天情绪去解释当天涨跌,相关系数会被这个「未来函数」抬得虚高。正确的做法是把 T-1 日的情绪对应到 T 日的收益率,也就是情绪永远领先收益一天。

% 行情表:算对数收益率,避免百分比口径来回换算 qopts = detectImportOptions('quote_daily.csv', 'Encoding', 'UTF-8'); qopts.VariableTypes{'trade_date'} = 'datetime'; Q = readtable('quote_daily.csv', qopts); Q = sortrows(Q, 'trade_date'); Q.ret = [NaN; diff(log(Q.close))]; % 内连接:只保留同时有评论和行情的交易日 T = innerjoin(G, Q, 'Keys', 'trade_date'); T = sortrows(T, 'trade_date'); % 情绪整体后移一天:T-1 的情绪 对 T 的收益 T.senti_lag1 = [NaN; T.senti_index(1:end-1)]; % 有效样本:情绪和收益都存在,且当天评论量够 valid = ~isnan(T.senti_lag1) & ~isnan(T.ret) & T.n_total >= 20; fprintf('有效交易日 %d 天,覆盖 %s 至 %s\n', sum(valid), ... datestr(min(T.trade_date(valid))), datestr(max(T.trade_date(valid))));

这里用innerjoin而不是outerjoin,是因为后面所有统计都要求两边同时存在,用外连接会引入一堆 NaN 行,corr默认按成对样本处理,交界处的 NaN 会悄悄改变样本量。显式用内连接,样本量是多少一眼可见。

另外还有一个隐含假设需要写进文档:情绪只影响次日,不考虑三天后、一周后的滞后效应。为了说明这个假设不是随便定的,可以顺手算一下滞后 0 到 5 天的相关系数序列,做一张衰减曲线,通常滞后 1 天最强,3 天之后基本掉到噪声区间。这张曲线本身就是答辩里很好的一个图。

4.3 相关性与分位分组检验

相关系数只是起点,不能当结论。收益率分布厚尾明显,Pearson 相关容易被几根大阳线主导,所以主指标用 Spearman 秩相关,同时把 Pearson 也报出来做对照,两个差异过大就说明有极端值在起作用。

x = T.senti_lag1(valid); y = T.ret(valid); [rS, pS] = corr(x, y, 'Type', 'Spearman'); [rP, pP] = corr(x, y, 'Type', 'Pearson'); fprintf('Spearman r=%.3f p=%.4f | Pearson r=%.3f p=%.4f\n', rS, pS, rP, pP); % 按情绪分位分成五组,看各组次日平均收益是否单调 edges = prctile(x, [0 20 40 60 80 100]); edges(1) = -Inf; edges(end) = Inf; % 防止边界值落不进组 grp = discretize(x, edges); stat = groupsummary(y, grp, {'mean', 'median', 'std'}); % 最看多组 vs 最看空组,做两样本 t 检验 [h, pval, ci] = ttest2(y(grp == 5), y(grp == 1)); fprintf('Q5 vs Q1 均值差=%.4f%% p=%.4f\n', ... (stat.mean_y(5) - stat.mean_y(1)) * 100, pval);

分组的意义在于它能暴露关系是否单调。如果 Q1 到 Q5 的次日平均收益是单调递增的,说明情绪越乐观、次日表现越好;如果是两头高中间低的 U 型,那相关系数接近 0 但其实有结构,这时候单看一个数字就会得出「没关系」的错误结论。五组的分法比三组更能看出形状,但每组样本会变少,有效交易日不足 250 天的时候建议退回到三组。

discretize的边界处理必须加上-Inf和Inf,否则恰好等于 20% 分位点的那一天会返回 NaN,被静默排除,样本量悄悄少几个。

4.4 结果怎么读:把相关性说成「弱但稳」而不是「能预测」

跑完之后大概率会拿到一个绝对值不大但显著的相关系数,比如 -0.06 或者 0.08。这个量级在一堆金融文本分析的工作里很常见,它的正确解读是:情绪和涨跌之间存在统计上可检测的弱关联,但远远不足以支撑交易决策。把它说成「可以预测涨跌」,答辩现场只要被问一句「那你这个策略扣完成本还剩多少」就答不上来。

更能站住的说法是三个层面:方向对不对(符号是否符合经济学直觉,看多情绪对应次日正收益)、稳不稳(分年度或分股票池重跑,符号是否一致)、什么时候失效(极端行情、停牌前后、财报窗口期,关系是否消失)。第三点尤其值得做,把样本切成「有大涨大跌的交易日」和「平静交易日」,往往会发现情绪指标在剧烈波动的时候解释力更强,这个发现比一个孤零零的相关系数有说服力得多。

出图建议控制在三张以内:按分位分组的次日平均收益柱状图(带误差棒)、滞后 0 到 5 天的相关系数衰减曲线、情绪指数与收盘价的归一化双轴折线。图多了反而分散注意力,评审记不住。

5. 避坑与排查:五个最容易把结论带偏的点

下面这几条都是跑通之后才会暴露、并且一旦出错不会报错只会让结果变得「好看得不真实」的问题。按现象、原因、解决三段写,遇到的时候直接对号入座。

5.1 相关系数高得离谱:时间对齐引入了未来函数

现象:第一次跑出 Spearman 相关 0.4 以上,分组单调得非常漂亮,兴奋完一算发现不太对劲。

原因:情绪指标用的是当天全部评论,包括收盘后发的,而收益用的是当天收盘价。评论在盘后发酵,等于提前知道了当天结果再回头解释它,这在时间序列里是最典型的未来函数。

解决:在清洗阶段就给每条评论定trade_date,交易日 15:00 之后、周末、节假日的评论统统一律归到下一个交易日;在 MATLAB 侧再做一次整体后移,用 T-1 情绪对 T 收益。两处都做,等于加了一道保险。改完之后相关系数通常会掉到 0.1 以内,这个数字才是真实的。

5.2 「抄底」被判成正向:通用模型不懂股评黑话

现象:人工抽检时发现一批明显看空的帖子被标成中性甚至看多,且集中在某几类固定表达上。

原因:通用情感模型是在通用语料上训练的,对「套牢」「接盘」「割肉」这类词的极性判断偏向中性,对「抄底」「加仓」在特定语境下的反讽完全没有识别能力。

解决:在接口结果之上叠一层规则修正表,把高频黑话词手动标极性,命中就覆盖模型标签,同时把「除非…否则」「真的是…吗」这类反讽句式标成待人工复核。修正表控制在 50 个词以内,多了维护不动。人工抽 200 条算准确率并写进文档,比声称模型多强要可靠。

5.3 一条评论复制几十遍:单用户把情绪带偏

现象:某几天情绪指数突然极端,点进去看发现同一个账号连发几十条内容完全一样的帖子。

原因:社区里存在大量重复发帖和转贴,按评论条数做分母时,一个活跃账号就能主导当天的情绪均值。

解决:入库时按「文本哈希 + 股票代码」去重,只保留最早一条;聚合时再加一层用户权重,同一用户同一天对同一股票最多计 3 条。做完这两步,情绪指数会明显变平滑,但同时更能反映多数人的看法。

5.4 停牌与交易日历不一致:join 之后一片 NaN

现象:MATLAB 里innerjoin之后行数比预期少一大截,画出来的图断断续续。

原因:评论表的日期是自然日,行情表是交易日,两者直接连接会把周末和停牌期全部丢掉;停牌期间评论照发,但这些评论对应的次日收益根本不存在。

解决:以行情表的交易日作为唯一基准日历,把情绪指标reindex到这张日历上,停牌日的情绪标记为缺失而不是补 0。补 0 等于人为制造了「中性情绪」,会在相关性里稀释真实信号。缺失就是缺失,corr的成对删除会自动处理。

5.5 接口配额半夜耗尽:批量调用没有退避

现象:跑批到一半开始连续返回错误,第二天早上发现只有前 30% 的评论有情绪分,后面的全是空。

原因:批量循环里没有任何等待,QPS 直接顶到上限,接口开始限流,而脚本里的raise_for_status让整个批次直接中断。

解决:单条请求之间固定间隔,遇到限流错误码时按指数退避重试,单次失败超过三次就跳过并记入失败清单,跑完再单独补。同时把结果按 500 条一批落盘,中断之后从最后一个成功批次继续。补跑脚本要单独写一个,别混在主流程里。

6. 验收与答辩:把「关系」讲成一个可复现的口径

验收这一关考的主要是口径一致性,而不只是模型多准。评审或导师会挑几个数字问是怎么算出来的,如果两次回答不一致,前面做得再多也会被扣分。我一般的做法是先定一份六项验收清单,跑完逐项打勾,任何一项不通过就不往下走。

验收项通过标准怎么快速自检
数据完整性目标股票近 12 个月交易日覆盖率 ≥ 90%交易日数对比行情表行数
去重有效性重复文本占比 < 2%按文本哈希分组计数
时间对齐抽查 20 条盘后评论,trade_date全部后移手写 SQL 查 15:00 后的记录
打标质量人工抽检 200 条,准确率 ≥ 70%混淆矩阵写进文档
统计口径相关性与分组检验样本量一致打印sum(valid)
可复现性清空输出目录后重跑,结果差异 < 1%对比两次的相关系数

答辩环节建议只讲三张图,其余留在附录。第一张讲管道:从评论到情绪指标再到日频对齐,一张图说清数据怎么流动。第二张讲分组结果:五个分位组的次日平均收益柱状图带误差棒,单调性一眼可见,同时把 t 检验的 p 值标在图上。第三张讲衰减:滞后 0 到 5 天的相关系数曲线,说明为什么选滞后一天。三张图讲完,「关系存在但很弱、而且集中在一到两天内」这个结论就自然成立了,比堆十张图有效。

被问到「这个能不能用来炒股」的时候,标准答案是明确说不能,然后给出原因:样本期短、交易成本未计、极端行情下关系失效。承认边界比强行拔高更容易拿到分。反倒是有一次某同学被追问为什么相关系数只有 0.07,他把分组柱状图放出来说「整体相关性弱,但两端分组的差异是显著的,说明信号集中在情绪极端的那部分样本上」,这一句直接把问题变成了亮点。

从那以后我每次做这类文本与行情对齐的项目,都强制先把trade_date的归属规则写成一段文字贴在方案第一页,跑完再抽 20 条手工核一遍,宁可多花半小时,也不想在答辩前夜发现所有图都要重画。希望这套拆法能帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进