Python爬虫+语义分析+MATLAB:股票评论情绪与涨跌关系分析

发布时间:2026/10/30 6:56:31
Python爬虫+语义分析+MATLAB:股票评论情绪与涨跌关系分析

简介:围绕股票评论与股价涨跌关系展开的实战资料包,面向具备Python与MATLAB基础、希望练习网络数据采集、情感分析与统计建模的学习者。内容以东方财富评论抓取为入口,串联百度AI语义分析、评论情感标注、时间序列整理及涨跌关联探索,适合课程设计、毕设选题或数据分析练手。压缩包共10个文件,约1.71MB,包含4个png图表、2个xlsx数据表、2个py脚本、1个m脚本和1个csv评论数据,分别对应结果可视化、情感与日期数据、爬虫及分析代码、MATLAB处理和原始评论样本,字段可支撑按时间与情感维度展开统计。已有6465人学习,说明该案例具备一定参考热度。读者可据此复现从评论采集到情感打分、再到图表呈现的完整流程,并借鉴脚本组织与数据字段设计,理解公众情绪与市场波动之间可能存在的联系。

1. 三个工具串起来的一件事:从股票评论情绪到涨跌关系

我最初的想法很朴素:把某只票讨论区的评论全扒下来,看看大家情绪好的时候,第二天是不是真涨。这个项目就是这条想法的最小实现——Python 爬虫负责抓股票评论,调云厂商的语义分析接口给每条评论打分,再把分数和行情一起丢进 MATLAB 做时间轴对齐和相关性检验,最后看股票涨跌和评论情绪到底有没有关系。三件工具各管一段,中间靠 CSV 交接,谁都不用迁就谁的运行环境。它适合两类人:一类是手上有课程设计或小论文要做,需要一条能完整跑通的链路;另一类是平时写 Python,但建模和画图更愿意在 MATLAB 里做的开发者。它不承诺预测涨跌,只承诺把「评论情绪」变成一列能参与计算的数。

2. 工程骨架与数据契约:Python 采集层和 MATLAB 分析层怎么交接

这个项目最容易翻车的地方不是算法,是交接。爬虫在 Python 里跑,分析在 MATLAB 里跑,两边的数据类型、时间格式、编码习惯全不一样。如果中间那层 CSV 没定死格式,你会在一堆'2023/3/5'和'2023-03-05 00:00:00'之间反复折腾。所以我先把目录和数据契约定下来,再写任何业务代码。

2.1 三层职责与目录骨架

整个工程按「采集 → 打分 → 建模」切成三层,每层只认上层的产物,不反向依赖。目录长这样:

stock-sentiment/ ├── crawler/ │ ├── session.py # 会话、请求头、重试封装 │ ├── list_parser.py # 列表页字段解析 │ └── run_crawl.py # 增量爬取入口,写 raw_comments.csv ├── nlp/ │ ├── auth.py # access_token 获取与本地缓存 │ ├── scorer.py # 单条打分 + 批量限速 │ └── build_features.py # 按日聚合情绪指标 -> daily_sentiment.csv ├── data/ │ ├── raw_comments.csv # 原始评论,只追加不改写 │ ├── daily_sentiment.csv # 日频情绪指标 │ ├── price_daily.csv # 行情数据,手工或用别的脚本准备 │ └── crawl_state.json # 每只票的断点游标 └── matlab/ ├── load_and_align.m # 读入三份 CSV 并对齐时间轴 ├── rolling_corr.m # 滚动相关 └── regress_check.m # 滞后检验与回归

crawler层唯一的产出是raw_comments.csv,它只负责「把原文和发布时间稳稳地拿回来」,不做任何清洗和判断。nlp层读它,补上情绪分,聚合成日频指标。matlab层只读daily_sentiment.csv和price_daily.csv,完全不知道评论长什么样。这种切法有个实际好处:接口限流、token 过期这类问题全部被关在nlp层,MATLAB 那边永远不会因为网络抖动跑不出图;反过来说,改模型参数也不用重跑爬虫,省下来的时间够你多试十几组窗口。

我一般会把data/当唯一事实来源,中间过程文件都写在这里而不是内存里。原因是这类实验经常要反复调聚合口径——比如情绪均值用中位数还是均值、样本量少于 5 条的交易日要不要保留——每次都重爬重打分,成本高得离谱。

2.2 三份数据文件的数据契约

字段定死,是后面所有代码能对上号的前提。下面这三张表是我最终用的版本:

文件字段类型说明
raw_comments.csvcomment_idstring平台内唯一 ID,去重主键,别用内容哈希
stock_codestring标的代码,统一补零到 6 位
publish_timedatetime评论发布时间,精确到秒,格式%Y-%m-%d %H:%M:%S
contenttext原文,已压缩空白字符
crawl_timedatetime采集时间,只用于排查,不参与建模
daily_sentiment.csvtrade_datedate日期,格式%Y-%m-%d
stock_codestring标的代码
n_commentsint当日有效评论条数,是样本量也是权重
sent_meanfloat情绪均值,取值 0~1
sent_stdfloat情绪标准差,用来衡量分歧度
price_daily.csvtrade_date / stock_codedate / string主键
open/high/low/closefloat前复权价
pct_chgfloat当日涨跌幅,单位 %
volumefloat成交量

有三点值得单独说。第一,comment_id必须是平台侧的 ID,不要图省事拿md5(content)当主键——同一个人复制粘贴同一句话发两次很常见,内容哈希会把两条真实样本吞掉一条,样本量本来就小,这种损耗很致命。第二,crawl_time一定要留。我踩过一次坑:某段时间抓下来的评论时间全是采集当天,靠crawl_time和publish_time的差值分布才定位到是解析字段取错了,如果没有这一列,这个 bug 会一直藏到建模阶段。第三,sent_std不是凑数的字段,情绪分歧度在有些标的上比均值更有解释力,留着几乎不占成本。

提示:CSV 统一用utf-8-sig编码写。MATLAB 的readtable对带 BOM 的 UTF-8 识别比纯 UTF-8 稳,反过来 Python 读带 BOM 的文件加encoding='utf-8-sig'就行,两头都不用额外处理。

为什么不直接上数据库?因为这个项目的规模撑死几十万行评论,CSV 完全够用,而且 MATLAB 读 CSV 的门槛比配数据库驱动低太多。等到你要同时跑几十只票、每天增量更新,再考虑换成 SQLite 也不迟,那时表结构已经是现在这个样子了,迁移只是换个读写函数。

2.3 环境与依赖

Python 侧装在 3.8 以上就行,很多教学环境还停在 3.8,没必要为了语法糖把门槛抬高。依赖很少:

# 采集与分析依赖 pip install requests pandas lxml python-dateutil

requests负责 HTTP,pandas负责日频聚合,lxml在解析 HTML 列表页时比内置解析器快一个量级,python-dateutil主要用来兜住各种奇怪的时间字符串。不要装selenium,这个项目的目标是列表页接口,用不上浏览器。

MATLAB 侧建议 R2019b 及以上版本,因为retime重采样到指定时间向量的用法在早期版本里不太稳定。整个分析只用基础 MATLAB,不需要额外的工具箱:readtable、table2timetable、retime、synchronize、corr、fitlm这些都在核心里。

注意:fitlm属于统计与机器学习工具箱。如果你的 MATLAB 授权里没有,把回归那段换成自己写的最小二乘(X'*X)\(X'*y)也能跑,只是少了 p 值和置信区间,先用相关系数和滞后表看结论也够。

3. 爬虫模块拆解:请求封装、时间游标翻页和断点续爬

采集层看着简单,实际上问题最集中:目标站点改一次字段、加一层风控,你的脚本就全废。所以这层代码我写得比较保守——所有网络请求走同一个入口,所有翻页靠时间游标,所有落盘都是追加写,任何一步失败都不会把已有数据搞脏。

3.1 会话、请求头与重试封装

先把 HTTP 这层收口。所有请求都从build_session()出来的会话发,好处是连接复用、请求头统一、异常处理只有一处:

# crawler/session.py import time import random import requests from requests.adapters import HTTPAdapter HEADERS = { # 用浏览器 UA,默认的 python-requests UA 在多数站点会被直接拦 "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", "Referer": "https://{目标站点域名}/", # 按实际站点填 } def build_session(retries=3): s = requests.Session() s.headers.update(HEADERS) adapter = HTTPAdapter(max_retries=retries) s.mount("https://", adapter) s.mount("http://", adapter) return s def get_json(session, url, params=None, tries=3, backoff=(1.0, 2.5)): """统一 GET 入口:超时、退避、异常都在这处理,返回 dict 或 None""" for i in range(tries): try: r = session.get(url, params=params, timeout=8) if r.status_code == 200: return r.json() # 403/429 大概率是被限了,退避后重试,指数加长 if r.status_code in (403, 429): time.sleep(random.uniform(*backoff) * (i + 1)) continue return None except requests.RequestException: time.sleep(random.uniform(*backoff)) return None

timeout=8必须显式给,不给的话连接到半死不活的节点时线程会挂很久,你在终端前面只会看到光标闪,什么日志都没有。max_retries交给HTTPAdapter处理的是连接级失败,get_json里那层循环处理的是状态码级失败,两者不重复。退避用random.uniform而不是固定sleep,是为了避免多个标的并发时请求节奏完全同步,那种规律性在对方日志里一眼就能看出来。

3.2 列表页解析与时间游标翻页

评论列表接口通常按时间倒序返回,这就带来一个选择:用页码翻,还是用时间游标翻?页码翻页在增量采集场景下会错位——你今天爬到第 3 页,明天新增了 200 条评论,原来的第 3 页内容整体后移,你会重复抓一大截。用时间游标就没这个问题:每次记住「已经抓到哪个时间点」,下次从这个时间点继续往前推。

# crawler/list_parser.py import re import time import random from datetime import datetime from session import get_json def parse_items(items, stock_code): """把接口返回的 items 转成统一的行结构,顺手清洗空白字符""" rows = [] for item in items: content = re.sub(r"\s+", " ", item.get("content") or "").strip() if not content: continue rows.append({ "comment_id": str(item["id"]), "stock_code": stock_code, "publish_ts": int(item["publish_ts"]), # 保留原始时间戳用于游标 "publish_time": datetime.fromtimestamp( int(item["publish_ts"])).strftime("%Y-%m-%d %H:%M:%S"), "content": content, }) return rows def crawl_stock(session, stock_code, since_ts, page_size=50, max_pages=200): """从 since_ts 往前翻,返回 (行列表, 本次最老时间戳)""" url = "https://{目标站点域名}/api/comment/list" out, cursor, pages = [], int(time.time()), 0 while pages < max_pages: payload = get_json(session, url, params={ "code": stock_code, "size": page_size, "before": cursor, # 拉取早于该时间戳的评论 }) if not payload: break items = payload.get("data", {}).get("list", []) if not items: break rows = parse_items(items, stock_code) out.extend(rows) oldest = min(r["publish_ts"] for r in rows) if oldest <= since_ts: # 已经越过上次的断点,可以收工 break cursor = oldest - 1 # 下一页从最老那条再往前一条开始 pages += 1 time.sleep(random.uniform(0.8, 1.6)) # 限速,别把对方打崩 return out, cursor

参数上,page_size别贪大,50 是比较稳的值,超过 100 很多接口会直接返回空。max_pages是保险丝,防止某次接口行为异常导致死循环,200 页乘以 50 条已经是一万条,单只票单日远到不了这个量。before这个参数名各家不同,有的叫cursor、有的叫lastId,但语义都是「从这个位置往前」。

3.3 去重、断点续爬与落盘

最后一块是持久化。原则只有一条:追加写、不改写。已经落盘的评论哪怕后来发现字段有问题,也宁可标记而不是覆盖,否则你没法对比两次采集的差异。

# crawler/run_crawl.py import os import csv import json FIELDS = ["comment_id", "stock_code", "publish_time", "content", "crawl_time"] def load_state(path="data/crawl_state.json"): if not os.path.exists(path): return {} with open(path, encoding="utf-8") as f: return json.load(f) def save_state(state, path="data/crawl_state.json"): with open(path, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) def load_seen_ids(path="data/raw_comments.csv"): """把已有 comment_id 装进内存,几十万条也就几十 MB""" if not os.path.exists(path): return set() with open(path, newline="", encoding="utf-8-sig") as f: return {r["comment_id"] for r in csv.DictReader(f)} def append_rows(rows, path="data/raw_comments.csv"): if not rows: return 0 new_file = not os.path.exists(path) with open(path, "a", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=FIELDS, extrasaction="ignore") if new_file: writer.writeheader() writer.writerows(rows) return len(rows)

load_seen_ids把去重放在内存里做,而不是每条都去查文件,原因是纯文本查询太慢,几万条下去能明显感觉到卡顿。extrasaction="ignore"是为了让上游多带出来的publish_ts这类字段不会直接抛异常——parse_items里保留它是为了算游标,落盘时不需要。

跑起来的主循环大致是这样:读 state 拿到每只票的since_ts,没有就拿一个起始日期;爬完把新的最老时间戳写回 state;用seen集合过滤掉重复 ID;追加写文件。这套结构的好处是随时可以Ctrl+C中断,下次启动从断点继续,不会重复也不会漏。

4. 从评论到曲线:语义打分和 MATLAB 对齐建模

这一层是整个项目的核心,也是最容易被误用的地方。很多人拿到情绪分就直接和涨跌幅做一次corr,看到 0.1 或者 -0.05 就下结论说「没关系」。实际上从原始评论到一条能用的日度情绪曲线,中间至少有两步必须做对:一是打分口径要连续、二是时间轴必须严格对齐。

4.1 token 缓存与单条打分

云厂商的语义分析接口基本都是 OAuth2 那套:先用 API Key 和 Secret 换一个access_token,再拿 token 调业务接口。token 一般有几十天有效期,但绝不能每次跑脚本都重新申请——申请次数多了会被限,我见过直接返回鉴权失败还查不出原因的。

# nlp/auth.py import time import requests _TOKEN = {"value": None, "expire_at": 0.0} def get_token(api_key, secret_key, host): """进程内缓存 token,提前 5 分钟刷新,避免边界过期""" now = time.time() if _TOKEN["value"] and _TOKEN["expire_at"] > now + 300: return _TOKEN["value"] r = requests.post(f"{host}/oauth/2.0/token", params={ "grant_type": "client_credentials", "client_id": api_key, "client_secret": secret_key, }, timeout=8) r.raise_for_status() data = r.json() _TOKEN["value"] = data["access_token"] # expires_in 单位是秒,一般 30 天左右;这里再减 300 秒做安全边界 _TOKEN["expire_at"] = now + int(data.get("expires_in", 2592000)) - 300 return _TOKEN["value"]

单条打分函数要注意两点:文本长度上限和返回结构。多数接口对输入有 1000~2000 字符的限制,超长会被截断甚至直接报错,所以先切一刀;返回里通常给的是positive_prob、negative_prob、confidence和sentiment四个字段,其中sentiment是 0/1/2 三档标签。

# nlp/scorer.py import json import time import requests def score_one(text, token, host, timeout=6, max_len=1000): """单条打分,失败重试 3 次,全部失败返回 None""" body = json.dumps({"text": text[:max_len]}, ensure_ascii=False) url = f"{host}/rpc/2.0/nlp/v1/sentiment_classify?access_token={token}" for _ in range(3): try: r = requests.post(url, data=body.encode("utf-8"), headers={"Content-Type": "application/json"}, timeout=timeout) j = r.json() items = j.get("items") or [] if not items: return None it = items[0] return { "positive_prob": it.get("positive_prob"), "negative_prob": it.get("negative_prob"), "confidence": it.get("confidence"), "sentiment": it.get("sentiment"), } except Exception: time.sleep(1.2) # 网络抖动或返回非 JSON,等一下再来 return None

这里有个关键取舍:后面建模用的是1 - negative_prob,不是sentiment标签。原因很直接——三档标签做完日度平均,结果只可能是 0、0.33、0.66、1 这几个离散值,样本一少就完全没有区分度;negative_prob是连续值,能保留「有点负面」和「非常负面」的差别。confidence是模型对这个判断的置信度,不是情绪强度,别拿它当分用。

4.2 批量打分与限速

接口的 QPS 通常很低,普遍在 1~2 次每秒。并发开大只会换来一堆限流错误码,反而更慢。我一般用两个线程,配合每 20 条一次的短暂停顿:

# nlp/scorer.py(续) from concurrent.futures import ThreadPoolExecutor def score_all(rows, token, host, workers=2): """并发打分,注意 QPS 限制,workers 不要超过 2""" results = [] with ThreadPoolExecutor(max_workers=workers) as ex: futures = {ex.submit(score_one, r["content"], token, host): r for r in rows} for i, fut in enumerate(futures, 1): row = futures[fut] s = fut.result() if s is None: # 打分为空的行直接丢弃,别用 0 填充 continue row.update(s) results.append(row) if i % 20 == 0: time.sleep(0.5) return results

打分为空的行直接丢弃,不要用均值或者 0 填充。用 0 填充等于人为制造了一批「极度负面」的样本,日度均值会被系统性拉低;用均值填充则是把噪声抹平,看起来数据完整了,实际上把分歧度这个信号也一起抹掉了。宁可样本少一点,也要保证每一条都是有真实打分的。

4.3 日度情绪指标聚合

聚合这一步决定了后面能看到什么样的曲线。按自然日聚合,还是按交易日聚合?我的做法是先按自然日聚合,聚合时用D频率,因为周末也有人在讨论,这些讨论在下一个交易日开盘前会集中体现,扔了可惜。

# nlp/build_features.py import pandas as pd def to_daily(df, freq="D"): df = df.dropna(subset=["positive_prob", "negative_prob"]).copy() df["publish_time"] = pd.to_datetime(df["publish_time"]) # 用 1 - 负向概率当情绪分,比三档标签连续得多 df["sent"] = 1.0 - df["negative_prob"].astype(float) g = (df.set_index("publish_time") .groupby("stock_code")["sent"] .resample(freq)) out = pd.DataFrame({ "n_comments": g.size(), "sent_mean": g.mean(), "sent_std": g.std(), # 分歧度,样本不足 2 条时为 NaN }).reset_index() # 样本量太小的日子不参与建模,否则一条评论就能把均值拉满 out = out[out["n_comments"] >= 5] return out.sort_values(["stock_code", "publish_time"])

n_comments >= 5这条过滤线不是拍脑袋定的,是试出来的:低于 5 条的交易日,情绪均值的方差大得吓人,一条情绪极端的评论就能把整天拉到 1.0 或者 0.0,这种点在回归里就是典型的杠杆点。如果你的标的讨论量本身就很少,把线降到 3 也可以,但要在结论里说明样本量约束——这是做这类实验最基本的诚实。

4.4 MATLAB 侧对齐与滚动相关

到这一步,Python 的工作结束了,交给 MATLAB。第一件事是把三份 CSV 读进来,并且统一时间类型。这里最常见的错误是readtable把日期读成了 cell 数组或者字符串,后面corr算出来全是 NaN,排查半天。

% matlab/load_and_align.m % 读情绪表,显式指定日期格式,避免被读成 cell optS = detectImportOptions('data/daily_sentiment.csv'); optS.VariableNamingRule = 'preserve'; sent = readtable('data/daily_sentiment.csv', optS); sent.Properties.VariableNames{'publish_time'} = 'trade_date'; sent.trade_date = datetime(sent.trade_date, 'InputFormat', 'yyyy-MM-dd'); sent = sortrows(sent, 'trade_date'); S = table2timetable(sent(:, {'sent_mean','sent_std','n_comments'}), ... 'RowTimes', 'trade_date'); % 读行情表 price = readtable('data/price_daily.csv'); price.trade_date = datetime(price.trade_date, 'InputFormat', 'yyyy-MM-dd'); price = sortrows(price, 'trade_date'); P = table2timetable(price(:, {'close','pct_chg','volume'}), ... 'RowTimes', 'trade_date'); % 关键一步:情绪按交易日历重采样,用最近一次可得值 % 'previous' 表示把周末的情绪带到下一个交易日 S_trade = retime(S, P.Properties.RowTimes, 'previous'); tt = synchronize(P, S_trade, 'intersection'); tt = rmmissing(tt); % 丢掉缺数据的交易日 fprintf('对齐后样本数: %d\n', height(tt));

synchronize用intersection而不是union,是为了保证每一行同时有价格和情绪,不然回归里会混进一堆缺失值。rmmissing之后一定要打印样本数——如果只剩几十行,说明对齐逻辑有问题,别急着往下跑相关。

滚动相关是这套分析里最直观的工具。全样本一个相关系数太粗糙,市场状态一变就被平均掉了;用 20 个交易日的窗口滚一遍,能看出情绪和涨跌的关系本身是不是稳定的。

% matlab/rolling_corr.m win = 20; % 约一个月 r = NaN(height(tt), 1); for k = win:height(tt) seg = tt(k-win+1:k, :); seg = seg(~isnan(seg.sent_mean) & ~isnan(seg.pct_chg), :); if height(seg) < win * 0.8 % 有效样本不足八成,跳过 continue; end c = corrcoef(seg.sent_mean, seg.pct_chg); r(k) = c(1, 2); end tt.roll_corr = r; figure; plot(tt.Properties.RowTimes, tt.roll_corr, 'LineWidth', 1.2); yline(0, '--'); ylabel('20日滚动相关系数'); xlabel('交易日'); title('评论情绪与当日涨跌幅的滚动相关');

接下来是滞后检验,这一步决定你能不能说「领先」两个字。做法很简单:把情绪序列整体平移 k 天,再和涨跌幅算相关系数,k 从 -5 到 5 扫一遍。注意 Spearman 比 Pearson 稳,情绪分分布偏态明显,用秩相关不容易被极端值带偏。

% matlab/regress_check.m maxLag = 5; res = zeros(2*maxLag+1, 2); idx = 0; for lag = -maxLag:maxLag idx = idx + 1; x = tt.sent_mean; y = tt.pct_chg; if lag > 0 % lag > 0:情绪领先 lag 天,用 T-lag 的情绪对 T 的涨跌 x = [NaN(lag,1); x(1:end-lag)]; elseif lag < 0 x = [x(-lag+1:end); NaN(-lag,1)]; end ok = ~isnan(x) & ~isnan(y); res(idx, :) = [lag, corr(x(ok), y(ok), 'Type', 'Spearman')]; end disp(array2table(res, 'VariableNames', {'lag','spearman_r'})); % 朴素回归:情绪均值 + 分歧度 + 评论量(取对数压缩量纲) mdl = fitlm(tt, 'pct_chg ~ sent_mean + sent_std + log(n_comments)'); disp(mdl)
lag 取值含义结论怎么读
+1 ~ +5情绪领先涨跌若显著且为正,说明讨论热度确实有前置信息
0同日相关大概率是涨跌带动情绪,方向相反
-1 ~ -5情绪滞后涨跌若显著,基本可以判定是「涨了才来夸」

说实话,这类实验跑出来的同日相关系数通常是正的且明显,滞后项反而弱,这恰恰说明评论情绪更像是行情的影子而不是先行指标。把这张滞后表打出来,比任何模型都更能说明问题。

5. 排查清单:这套链路里最容易翻车的五个点

链路一长,报错信息就越不可信。下面这五条是我在这套结构里真实遇到过的,每一条都按「现象 → 原因 → 解决」写清楚,你照着对号入座能省不少时间。

5.1 采集侧的两个坑:时间戳和去重

现象一:抓下来的评论,publish_time全是当天日期。

不只是「看着不对」,而是所有评论时间都挤在采集那几个小时里,日度聚合后只有一两天有数据。原因通常有两个:一是接口返回里本身有两个时间字段,一个是「发布」一个是「最后回复」,字段名很像,取错了;二是列表页渲染时把时间做成了「3小时前」「昨天」这种相对描述,你的解析直接把它当绝对时间塞进datetime,解析器悄悄给了个默认值。解决方式是加一道断言:publish_time不可能晚于crawl_time,也不可能早于标的上市日期,落盘前先assert一下,几十万条里混进异常值会直接把时间轴搞歪。

现象二:跑第二遍,raw_comments.csv里出现大量重复行。

去重失效。除了前面说的「别用内容哈希当主键」,还有一个隐蔽原因:load_seen_ids读文件时用的是csv.DictReader,如果文件是带 BOM 的,第一行的列名会变成\ufeffcomment_id,r["comment_id"]直接抛 KeyError,被上层try一包就静默跳过了,结果seen集合是空的,全量重复写入。解决很简单,读的时候统一encoding='utf-8-sig',并且不要用裸except包住去重逻辑。

5.2 打分侧的两个坑:分值和并发

现象三:情绪分大量堆在同一个数值上,画出来是一条锯齿状的折线。

如果你用的是三档标签算均值,一天 9 条评论里 6 条中性 3 条正面,均值就是 0.33,再来一天还是 0.33。这就是离散标签的天然缺陷,不是接口的问题。改用1 - negative_prob之后,同一天的均值可能落在 0.42、0.47 这种位置,日度曲线才有波动。另外还要检查文本是不是被清洗成空字符串了——re.sub(r"\s+", " ", ...)之后如果只剩标点或者表情,接口会返回空items,这些行必须丢掉而不是记 0.5。

现象四:跑几百条之后开始大量返回失败,日志里一串鉴权或者频率相关的错误码。

两个原因分开看。如果是鉴权类错误,多半是 token 缓存的时间边界没留够,刚好在过期瞬间请求;get_token里提前 300 秒刷新就是干这个的。如果是频率类错误,那就是workers开太大了。云厂商的 NLP 接口 QPS 普遍限制在个位数,ThreadPoolExecutor(max_workers=8)看上去很爽,实际是在给自己制造重试风暴。降到 2,配合每 20 条停 0.5 秒,整体吞吐反而更高。

5.3 分析侧的一个坑:未来函数

现象五:MATLAB 里算出来相关系数 0.8 以上,回归 R² 高得离谱,兴奋了半小时。

这种「好得不像真的」的结果,九成是未来函数。最常见的形态是:评论的时间戳精确到秒,你在做日度聚合时把当天 23:59 的评论也算进了「当日情绪」,然后拿它去解释当天 15:00 就已经收盘的涨跌幅——这等于用收盘后才知道的信息预测收盘价。另一种形态是行情数据用了后复权价,而情绪数据的时间区间没对齐,两边其实错位了几天。

解决方式是把时间边界卡死:当日情绪指标只允许使用 T 日 15:00 之前的评论,或者干脆整体滞后一天,用 T-1 日的情绪去解释 T 日的涨跌。改完之后相关系数通常会掉到 0.1 附近,这才是这类数据该有的水平。看到高相关系数先别高兴,先去检查时间戳的分布直方图,看看有没有集中在收盘后的尖峰。

6. 二次开发:把一次性实验改成可复用的指标管线

这套代码跑通之后,最自然的下一步不是调模型,而是把它从「一次性实验」改成「每天能自动产出指标」的管线。我自己的做法是在run_crawl.py外面套一层调度,每天收盘后固定时间跑一次增量采集和增量打分——注意是增量,crawl_state.json里存着每只票的断点,raw_comments.csv里存着已有的comment_id,这两个文件配合起来,每天实际新增的评论可能只有几百条,几秒钟就跑完了。

改造时按这个顺序动,风险最小:

改造项改哪里验证方式
多标的并行run_crawl.py主循环外层加标的列表单票跑通后加第二只,看 state 文件是否互相覆盖
情绪词典兜底scorer.py里接口返回 None 时走本地词表断网跑一遍,确认不会整批丢数据
增量聚合build_features.py只重算最近 N 天全量重算和增量结果对比,差异应该为零

第二项值得展开说。接口总有挂的时候,一旦某天打分全返回 None,那天的n_comments就是 0,整条曲线断一个口子。加一个极简的正负词表做兜底,命中数相减除以命中总数,精度差但至少不会断。这个兜底我只在接口失败时启用,平时还是走接口——两种口径混用会让不同日期的分数不可比,这点必须清楚。

验证方法上,我强烈建议先做历史回放:把过去三个月的评论按天切出来,模拟每天跑一次增量,然后和一次性全量跑出来的daily_sentiment.csv逐行比对。只要发现某几天的sent_mean对不上,就说明增量逻辑里有状态没清干净。这个回放测试我一般会写成一个断言脚本,跑一次十几秒,但能挡住绝大多数重构引入的问题。

从那以后我每次做这类相关性实验,都强制先把样本量曲线和滞后表打出来看一眼,再决定要不要调模型——因为绝大多数「模型不 work」的问题,其实在数据那一层就已经注定了。希望帮到你。

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