简介围绕股票评论与股价涨跌关系展开的实战资料包面向具备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:%Scontenttext原文已压缩空白字符crawl_timedatetime采集时间只用于排查不参与建模daily_sentiment.csvtrade_datedate日期格式%Y-%m-%dstock_codestring标的代码n_commentsint当日有效评论条数是样本量也是权重sent_meanfloat情绪均值取值 0~1sent_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 的文件加encodingutf-8-sig就行两头都不用额外处理。为什么不直接上数据库因为这个项目的规模撑死几十万行评论CSV 完全够用而且 MATLAB 读 CSV 的门槛比配数据库驱动低太多。等到你要同时跑几十只票、每天增量更新再考虑换成 SQLite 也不迟那时表结构已经是现在这个样子了迁移只是换个读写函数。2.3 环境与依赖Python 侧装在 3.8 以上就行很多教学环境还停在 3.8没必要为了语法糖把门槛抬高。依赖很少# 采集与分析依赖 pip install requests pandas lxml python-dateutilrequests负责 HTTPpandas负责日频聚合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;q0.9, Referer: https://{目标站点域名}/, # 按实际站点填 } def build_session(retries3): s requests.Session() s.headers.update(HEADERS) adapter HTTPAdapter(max_retriesretries) s.mount(https://, adapter) s.mount(http://, adapter) return s def get_json(session, url, paramsNone, tries3, backoff(1.0, 2.5)): 统一 GET 入口超时、退避、异常都在这处理返回 dict 或 None for i in range(tries): try: r session.get(url, paramsparams, timeout8) 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 Nonetimeout8必须显式给不给的话连接到半死不活的节点时线程会挂很久你在终端前面只会看到光标闪什么日志都没有。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_size50, max_pages200): 从 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(pathdata/crawl_state.json): if not os.path.exists(path): return {} with open(path, encodingutf-8) as f: return json.load(f) def save_state(state, pathdata/crawl_state.json): with open(path, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2) def load_seen_ids(pathdata/raw_comments.csv): 把已有 comment_id 装进内存几十万条也就几十 MB if not os.path.exists(path): return set() with open(path, newline, encodingutf-8-sig) as f: return {r[comment_id] for r in csv.DictReader(f)} def append_rows(rows, pathdata/raw_comments.csv): if not rows: return 0 new_file not os.path.exists(path) with open(path, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesFIELDS, extrasactionignore) if new_file: writer.writeheader() writer.writerows(rows) return len(rows)load_seen_ids把去重放在内存里做而不是每条都去查文件原因是纯文本查询太慢几万条下去能明显感觉到卡顿。extrasactionignore是为了让上游多带出来的publish_ts这类字段不会直接抛异常——parse_items里保留它是为了算游标落盘时不需要。跑起来的主循环大致是这样读 state 拿到每只票的since_ts没有就拿一个起始日期爬完把新的最老时间戳写回 state用seen集合过滤掉重复 ID追加写文件。这套结构的好处是随时可以CtrlC中断下次启动从断点继续不会重复也不会漏。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, }, timeout8) 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, timeout6, max_len1000): 单条打分失败重试 3 次全部失败返回 None body json.dumps({text: text[:max_len]}, ensure_asciiFalse) url f{host}/rpc/2.0/nlp/v1/sentiment_classify?access_token{token} for _ in range(3): try: r requests.post(url, databody.encode(utf-8), headers{Content-Type: application/json}, timeouttimeout) 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, workers2): 并发打分注意 QPS 限制workers 不要超过 2 results [] with ThreadPoolExecutor(max_workersworkers) 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, freqD): 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-win1: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*maxLag1, 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(-lag1: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_idr[comment_id]直接抛 KeyError被上层try一包就静默跳过了结果seen集合是空的全量重复写入。解决很简单读的时候统一encodingutf-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_workers8)看上去很爽实际是在给自己制造重试风暴。降到 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」的问题其实在数据那一层就已经注定了。希望帮到你。本文还有配套的精品资源点击获取