简介:面向金融数据分析、量化投资研究及自然语言处理入门人群,这套Python工程以A股市场为对象,实现完整的股市情感分析流程。项目避开传统财务指标,直接从互联网股票评论中抽取投资者情绪,结合标注语料训练情感分类模型,再构建情绪指数并与大盘走势对照,特别适合国内非有效市场环境下的投资者情绪研究。压缩包共12个文件,大小约24.71MB,其中包括4个Python脚本、2个CSV数据文件、2个txt情感语料、2张PNG结果图,另有说明文档和原始评论数据压缩包,文件类型覆盖数据处理、模型训练、指标计算和结果可视化等环节,目录结构便于按阶段学习。该资源已有1437人浏览学习。项目内置上证指数历史数据和正负情感标注文本,代码按model_ml.py、compute_sent_idx.py、plot_sent_idx.py顺序运行即可复现完整流程:先完成情感分类模型训练与评估,再生成投资者情绪指数,最后绘制情绪指数与大盘走势对比图,形成从文本建模到市场启发的可执行样例。配套README对脚本用途、运行顺序和依赖关系给出说明,便于读者迁移到其他股票市场,或进一步扩展新闻、公告等多源文本特征,用于课程设计、量化策略预研等场景。
1. A股情感分析在分析什么:不是预测涨跌,而是给市场情绪量体温
很多人以为股市情感分析是“预测明天涨跌”,这是对它的最大误解。每天开盘前我扫一遍股吧、雪球和新闻标题,心里会有“情绪偏冷”或“热度很高”的感觉,情感分析就是把这种玄学感觉变成一条可以回测、可以复用的情绪曲线。它的输入是公告、帖子、新闻标题等A股文本,输出是当天市场的乐观/悲观程度,以及分歧度等衍生指标。真正能稳定落地的用法,是把情绪指标当作过滤因子,给已有的选股或择时策略做加减仓信号。这个方向适合想做舆情因子验证的量化爱好者、写研究结论的从业者,以及想给交易加一道“情绪保险”的个人投资者。项目里把数据集和脚本配在一起,拿到就能跑通全流程。
2. 数据集先行:A股舆情数据怎么组织、怎么采集、怎么落盘
2.1 一个能直接跑的数据集该有哪些字段
情感分析的数据集不是“一堆文本”就行,而是要能支撑后面的时间对齐和分组统计。我把数据组织成下面的结构,读取时用 pandas 一行就能加载,打分、聚合、回测全链路都不需要再改字段名。
| 字段 | 类型 | 示例 | 作用 |
|---|---|---|---|
| code | str | 600519 | 标的代码,用于分股票聚合 |
| datetime | datetime | 2024-01-05 09:31:00 | 发布时间,用于和行情对齐 |
| source | str | guba / news / xueqiu | 文本来源,便于排查数据质量 |
| text | str | 茅台新高,短期继续持有 | 参与打分的原文 |
| label | int | 1 / -1 / 0 | 人工标注的情感倾向,训练分类器用,可缺省 |
label 列允许空值,因为词典法和 SnowNLP 都不需要标注数据,只有想用 BERT 微调的时候才需要人工标注几千条。数据文件统一放 data 目录,UTF-8 编码,避免 Windows 下打开乱码。如果拿到的 CSV 缺 code 或 datetime 这两列,后续没法按股票、按天聚合,等于白做。加载代码很简单:
import pandas as pd df = pd.read_csv( "data/guba_posts.csv", parse_dates=["datetime"], dtype={"code": str}, ) print(df.shape) print(df["source"].value_counts())这里把 datetime 解析成 pandas 的 datetime64 类型,后面按天聚合才能直接用 resample。code 用 str 读入,因为 A 股代码带前导零(比如 000001 是平安银行),读成 int 会丢掉前缀。source 列建议保留,不同渠道的文本统计口径不一样:股吧里“情绪”是散户噪声,公告里更多是公司基本面陈述,混在一起算会互相稀释。入门阶段单只股票 500 条帖子就能画出情绪曲线的形状,做全市场因子至少要覆盖沪深 300 成分股,每天增量几百到几千条都正常。
2.2 没有现成数据时:用爬虫抓股吧评论的最小方案
项目自带数据集可以直接跑,但想用在自己关注的股票上,还是要自己扩充数据。常见做法是抓东方财富股吧,门槛低、不需要登录,页面结构相对固定。下面这个脚本抓一只股票最近 N 页的帖子标题,标题里已经包含大部分情绪表达,够做入门实验。
import time import requests from bs4 import BeautifulSoup HOST = "https://guba.eastmoney.com" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36" } def fetch_guba(stock_code="600519", max_pages=10): posts = [] for page in range(1, max_pages + 1): url = f"{HOST}/list,{stock_code}_{page}.html" resp = requests.get(url, headers=HEADERS, timeout=10) soup = BeautifulSoup(resp.text, "html.parser") for item in soup.select("div.articleh"): title_node = item.select_one("span.l3 a") time_node = item.select_one("span.l6") if title_node is None or time_node is None: continue title = title_node.get_text(strip=True) pub_time = time_node.get_text(strip=True) posts.append({ "code": stock_code, "datetime": pub_time, "source": "guba", "text": title, }) time.sleep(2) # 没有爬虫协议约束时,保守限速 return posts if __name__ == "__main__": data = fetch_guba("600519", max_pages=5) print(f"抓取 {len(data)} 条帖子")选span.l3 a和span.l6是因为股吧列表页里标题和发布时间分别在这两个 class 下。页面改版时选择器失效,抓出来是空列表,直接打开页面 F12 确认一下 class 名就行。timeout 设 10 秒,页面慢时快速失败,而不是卡死整个采集任务。sleep(2) 是给服务器的基本礼貌,同时也是反爬的底线,不建议去掉,抓几千条数据不差这几秒。
2.3 清洗与落盘:把脏文本变成可计算的干净字段
抓下来的文本还带着 HTML 残留和噪声,进打分之前要做一轮清洗。清洗函数不长,但能过滤掉大部分干扰。注意清洗只动排版噪声,不动领域词和否定词。
import re def clean_text(raw: str) -> str: raw = re.sub(r"<[^>]+>", "", raw) # 去掉 HTML 标签 raw = re.sub(r"http\S+", "", raw) # 去掉 URL raw = re.sub(r"@\w+", "", raw) # 去掉 @提及 raw = re.sub(r"\s+", " ", raw) # 合并连续空白 return raw.strip() df["text_clean"] = df["text"].apply(clean_text) df.to_csv("data/guba_posts_clean.csv", index=False, encoding="utf-8")很多入门教程会把“不”“没”这类词加进停用词表,在 A 股情感分析里这是典型的翻车操作:“不看好”被清洗成“看好”,情绪直接反向。领域词和否定词的处理要放在打分环节,清洗环节只做格式规整。落盘统一用 UTF-8 加 index=False,避免多一列没用的索引。清洗完顺手看一眼 text_clean 的长度分布,如果出现大量空文本,多半是抓到了页面框架而不是帖子内容,要回头检查选择器。
3. 打分方法选型:词典法、SnowNLP 与 BERT 怎么选、怎么配
3.1 词典法:先跑通规则打分,理解情绪的直觉
打分是情感分析的核心。词典法是最容易理解、调试成本最低的一种:准备正面词表和负面词表,对句子里的词做匹配,正面 +1、负面 -1,再叠加否定词和程度词。下面的函数用 jieba 切词后逐词扫描,适合股吧帖子这种短标题场景。
import jieba pos_words = {"涨停", "上涨", "拉升", "创新高", "突破", "利好", "增持", "买入", "加仓", "超预期"} neg_words = {"跌停", "下跌", "跳水", "破位", "利空", "减持", "卖出", "套牢", "暴雷", "亏损"} negate_words = {"不", "没有", "未", "并非"} degree_words = {"非常": 2.0, "极度": 3.0, "明显": 1.8, "稍微": 0.8} def rule_sentiment(text: str) -> float: words = list(jieba.cut(text)) score = 0.0 negate = False degree = 1.0 i = 0 while i < len(words): w = words[i] if w in negate_words: negate = True elif w in degree_words: degree = degree_words[w] elif w in pos_words or w in neg_words: base = 1.0 if w in pos_words else -1.0 if negate: base = -base score += base * degree negate = False # 消费完否定/程度状态 degree = 1.0 i += 1 return score print(rule_sentiment("业绩超预期,继续加仓")) # 预期输出正分 print(rule_sentiment("高开低走,今天不乐观")) # 输出负分或零分否定词和程度词只作用于下一个情感词,遇到非情感词时不重置状态,所以“非常不看好”里“不”先置位、“非常”放大倍数,最终负分被放大。这个简化处理在短标题上表现不错,因为帖子标题一般不超过 30 个字,跨句干扰少;如果打分对象是长篇公告,建议按句切分后逐句打分再汇总,否则一句里的否定词会污染下一句。最先扩充的是 pos_words 和 neg_words,A 股黑话要自己往里补,比如“关灯吃面”“核按钮”“天地板”,不加入就永远算中性。
3.2 SnowNLP:默认模型的分数为什么“偏购物风”,怎么掰回来
词典法的天花板是词表覆盖,“看着像负面其实是段子”的句子会失灵。这时可以用 SnowNLP,开箱即用的中文情感分析库。但它的默认模型是用购物评论训练的,直接拿来做 A 股有明显的“购物风”:把“跌停板打开”判成正面也不奇怪。看下默认行为:
from snownlp import SnowNLP text = "今天跌停板打开了,下午还要不要接" s = SnowNLP(text) print(s.sentiments) # 0~1,越接近1越积极s.sentiments 输出 0 到 1 的概率值,0.5 以上偏积极。直接用默认模型,金融语境下精度很难看,所以要拿自己的语料重新训练。训练格式很简单:两个纯文本文件,每行一条样本,正面语料放 pos.txt,负面语料放 neg.txt。
from snownlp import sentiment # data/pos.txt 示例行:业绩超预期,继续持有 # data/neg.txt 示例行:高开低走,明天先减仓 sentiment.train("data/pos.txt", "data/neg.txt") sentiment.save("data/a_share_sentiment.marshal")train 方法读入两个文件后训练一个朴素贝叶斯分类器,save 把模型序列化到指定路径。SnowNLP 默认从安装目录的 sentiment 目录加载模型,想长期生效就把生成的 marshal 覆盖过去;只想在当前进程用,save 到 data 目录后重新导入即可。样本质量比数量重要,我一般每类 2000 条起步,宁少而干净,也不要从网上乱抓无标注文本凑数。建议从清洗后的数据里留出 20% 不参与训练,训练完跑一遍验证集,看正负两个方向的准确率,哪个方向低就补哪个方向的语料。
3.3 什么时候值得上 BERT 微调
词典法和 SnowNLP 解决的是“快速跑通”,对精度要求高、手里有标注数据时,才轮到预训练模型。中文场景从 bert-base-chinese 起步就够了,下面是基于 transformers 的最小骨架。
from transformers import AutoTokenizer, AutoModelForSequenceClassification base = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(base) model = AutoModelForSequenceClassification.from_pretrained(base, num_labels=2)把清洗后的 DataFrame 转成 Dataset,对 text_clean 分词时设置 max_length=64。A 股文本普遍短,超过 64 个字的大多是复制粘贴的公告全文,截断不心疼。训练参数里 learning_rate 用 2e-5,batch_size 按显存选 8 或 16,epochs 设 3 轮。BERT 的优势是能理解“利好出尽是利空”这类反讽句式,代价是标注成本和推理时间。我的判断标准很简单:没有 2000 条以上高质量标注数据,老老实实用词典法;有了数据且词典法在验证集上明显拉胯,再花时间调 BERT。全市场每天几千条帖子,CPU 上批量推理也能接受,到几十万条量级才需要考虑换轻量模型。
4. 情绪指标计算避坑指南:清洗、聚合与时间对齐
4.1 清洗阶段的三类坑:停用词误伤、谐音黑话与否定反转
先看最常见的坑。网上很多情感分析教程用的停用词表把“不”“没”“别”全删了,这套做法在 A 股语境下是灾难。“不看好”清洗后剩“看好”,情绪从负翻正,后面所有统计全跟着错。我的处理原则是:清洗阶段只动排版噪声,词典阶段再管否定词,分词时不要把“减持”“做空”这类词从自定义词典里漏掉。
第二个坑是谐音和黑话。“割韭菜”“关灯吃面”“绿得发慌”在通用情感词典里完全中性,但股民语境里全是负向。解决方式是往 pos_words 和 neg_words 里手动补充,再用 jieba.add_word 注册,否则词典法切词时会把“关灯吃面”切成“关灯”和“吃面”,两个字面词都无法匹配。实测里“绿”字特别误导,通用模型里“绿”是环保色、正面色,A 股里“绿了”就是跌,必须做领域覆盖。
第三个坑跟标点有关。感叹号和问号会改变情绪强度,“还能涨?”和“还能涨。”指向完全不同。清洗时把标点统一成半角,但不要删问号,问号对情绪有增量信息。另外注意保留数字和百分号,“涨 5%”里的 5% 是情绪强度的锚点,直接删掉会让“涨 5%”和“涨 50%”打成同一个分。
4.2 聚合阶段:平均分为什么掩盖极端情绪
单条文本打完分,要按股票按天聚合成情绪指标。很多人直接对当天所有 score 取平均,这是第二个陷阱:两条“+5 分”和两条“-5 分”的帖子平均下来是 0,市场分歧极大却显示情绪中性。我一般同时算三个指标:情绪比率(正向占比)、平均情绪分、分歧度。下面是聚合函数。
def aggregate_daily(df): df = df.copy() df["trade_date"] = df["datetime"].dt.date grouped = df.groupby(["code", "trade_date"]).agg( pos_n=("score", lambda x: (x > 0).sum()), # 正向帖子数 neg_n=("score", lambda x: (x < 0).sum()), # 负向帖子数 avg_score=("score", "mean"), volume=("score", "count"), ).reset_index() total = grouped["pos_n"] + grouped["neg_n"] grouped["sentiment_ratio"] = grouped["pos_n"] / (total + 1e-6) grouped["dispersion"] = 1 - abs(grouped["sentiment_ratio"] - 0.5) * 2 return groupedsentiment_ratio 在 0 到 1 之间,0.5 说明正负各半;dispersion 同样在 0 到 1,越接近 1 方向越统一,接近 0 分歧极大。这两列信息量比 avg_score 大得多。avg_score 会被少数极端帖子拉偏,sentiment_ratio 看的是占比,dispersion 看的是分歧。实际使用中三个都保留,跑回测时才知道哪个指标在当前标的上更有效。还有一个常见误用:拿 volume 直接当热度参与交易信号,它只代表讨论量不代表情绪方向,讨论量暴增但正负均衡说明市场在吵架,不是看多。
4.3 时间对齐:情绪到底归到哪个交易日
对齐是情感分析和行情联动的关键,也是最容易被忽略的。股吧帖子的时间戳是“发布时间”,不是“生效时间”。晚上 11 点发的看多帖,对第二天开盘的影响显然大于对当天收盘的影响;盘中 10 点发的利空,则作用于当天的剩余交易时段。我按下面的规则归类:9:30 到 15:00 之间的帖子归当天交易日,15:00 之后的归下一个交易日。代码里做一次日期偏移:
df = df.copy() df["trade_date"] = df["datetime"].dt.date after_hours = df["datetime"].dt.hour >= 15 # 15点之后的消息归属到下一个交易日 df.loc[after_hours, "trade_date"] = ( df.loc[after_hours, "datetime"] + pd.Timedelta(days=1) ).dt.date这样处理后,再用 aggregate_daily 得到的“当日情绪”就是 T 日盘前能拿到的完整情绪,和 T 日行情做相关分析才不会串味。另一个对齐坑是停牌和涨跌停。股票停牌时没有收盘价,但情绪数据还在生成,必须跳过或单独标记,否则收益率算出来是错的。交易日历别用自然日对齐,A 股节假日多,直接导入假期列表或交易日历库,否则周四发帖被归到周五,而周五休市,整个序列就歪了。
5. 用数据验证情绪因子:相关、分组与回测的边界
5.1 最诚实的检验:当日情绪 vs 次日收益
分数算出来不代表能用,先做最诚实的检验:T 日情绪能不能解释 T+1 日收益。方向不要搞反,拿 T 日情绪去和 T 日收益做相关,因为情绪在当天交易过程中产生,两者因果方向天然纠缠,显著是假象。正确做法是把收盘价 shift 一天,用 T+1 日的收益作为标签。
daily = aggregate_daily(df) # 得到 code, trade_date, sentiment_ratio price = pd.read_csv("data/close_price.csv", parse_dates=["trade_date"]) merged = daily.merge(price, on=["code", "trade_date"], how="inner") merged = merged.sort_values(["code", "trade_date"]) # 次日收益:close 相对前一日的变化 merged["ret_next"] = merged.groupby("code")["close"].pct_change().shift(-1) result = [] for code, sub in merged.groupby("code"): if len(sub) >= 30: # 样本太少,相关系数没有置信度 c = sub["sentiment_ratio"].corr(sub["ret_next"]) result.append({"code": code, "corr": c}) corr_df = pd.DataFrame(result) print(corr_df["corr"].describe())先按股票过滤再算皮尔逊相关系数,至少 30 个交易日的日频数据才能给出像样的统计量,几天的数据没有置信度。如果相关系数绝对值普遍小于 0.02,说明这个情绪指标对这批股票基本没用,别硬编故事。更标准的做法是算 IC:每个交易日做一次横截面相关,再对时间序列取平均,这样可以避免时间序列自相关带来的虚高,不会算就先按上面的组内相关跑,结论方向一致。
注意:相关性高不代表因果。“涨了所以大家发帖看多”和“发帖看多所以涨”都包含在这个相关系数里,策略上只能当作统计规律使用。
5.2 分组单调性检验:正情绪组的次日收益是不是真的更高
相关系数只衡量线性关系,情绪这种因子往往按分位更典型。把每天的情绪比率按横截面分成 5 组,统计每组的次日平均收益。如果最高情绪组的收益明显高于最低组,就有分组效应;如果五组收益差不多,说明情绪里没有稳定信息。
merged["q"] = merged.groupby("trade_date")["sentiment_ratio"].transform( lambda x: pd.qcut(x, 5, labels=False, duplicates="drop") ) panel = merged.groupby("q")["ret_next"].mean() print(panel)这里按交易日横截面分组,而不是把全部时间混在一起分位,因为大盘牛熊会整体抬高或压低收益,混在一起会把大盘效应错当成情绪效应。输出五组收益后,关注单调性而不是只看最高组:第一组到第五组逐步上升,或者两头极端、中间平缓,都算有结构;如果中间组比两头还高,多半是噪声,建议放弃这个指标。分组结果受极端值影响大,可以把 ret_next 先做缩尾处理(上下 1% 分位截断)再分组,防止一只妖股把整组均值拉飞。
5.3 回测时躲开两个隐蔽的坑
第一是前视偏差。情绪分是用当天全天帖子算出来的,如果策略在 15:00 收盘后打分、第二天开盘执行,数据是干净的;如果盘中 11 点就打分,那只能算“半日情绪”,用当日全天收益验证就是用了尚不存在的数据。时间对齐那步没做好,回测收益会整体虚高,这也是很多人回测猛如虎、实盘亏成狗的主要原因之一。
第二是幸存者偏差。回测股票池不要用今天的指数成分股列表去回测过去三年,当时因为暴雷退市、被 ST 的股票早就被剔除了,只用幸存者的样本会让情绪因子看起来比实际更有效。做历史回测时,按当时时点的成分股名单快照建股票池,这步没有捷径,只能去查历史成分调整记录。
情绪因子单独拿来择时,收益通常不稳定,更常见的定位是“过滤器”:情绪比率低于 0.35 时减少仓位,高于 0.65 时允许加仓,配合其他趋势因子使用。这是情感分析最务实的实盘用法,组合里它负责控制风险敞口,不负责产生超额收益。
6. 落地到交易流程:每天收盘后自动跑的情绪监控脚本
项目的最终形态不是一个孤立的打分脚本,而是一个每天收盘后自动执行的小流程。我一般拆成四段:增量抓取当日帖子,对每条文本打分,按股票聚合成情绪指标,输出异动清单和情绪曲线。主干代码长这样:
# daily_pipeline.py 省略异常处理后的主干 # 1. 抓取股票池的当日帖子 posts = fetch_posts(stock_pool, only_today=True) # 2. 打分:规则法与自定义模型选一种 for p in posts: p["score"] = rule_sentiment(p["text"]) # 3. 聚合 daily = aggregate_daily(pd.DataFrame(posts)) # 4. 输出情绪异动股票 alert = daily[daily["sentiment_ratio"] > 0.7] alert.to_csv("output/alert.csv", index=False)盘后跑用系统定时任务就行,Windows 下是“任务计划程序”,Linux 下是 crontab,每天 15:30 执行一次。情绪曲线的平滑参数我一般用指数加权,alpha 取 0.3,太小太钝、太大太躁。实盘里我的纪律是:情绪指标只做加减仓的条件判断,不做仓位的主要依据;连续一周情绪比率低于 0.35 时,默认降低一成仓位,等指标回升再说。
我踩过最深的坑是早期拿当天情绪去预测当天收益,回测曲线漂亮到不敢相信,后来发现是时间和信息重叠造成的假象。改成次日收益后再看,相关系数缩水一大半。那之后养成一个习惯:任何情绪数据上线前,先跑一遍 5.1 的相关性检验,相关系数和样本量都过线,才敢进策略。希望帮到你。
本文还有配套的精品资源,点击获取