金融新闻情感分类:从句向量到集成蒸馏的完整实践 做金融新闻情感分类最容易栽的地方不是模型不够大而是先入为主地把通用情绪词典套上去然后被“增持”“超预期”这些词反复打脸。我年初处理过一批来自某财经资讯平台的新闻文本任务是判断每一条对市场情绪是利多、利空还是中性。试通用词典只有六成出头准确率直接上大模型效果还行但线上推理成本完全扛不住。最后我按Python SentenceTransformer OLS 集成学习 模型蒸馏这条路跑通了一套完整流程从特征、基线、集成到压缩每一环都有明确目的。这篇就把整个实现过程、关键代码和踩过的坑完整拆一遍。1. 为什么金融新闻不能“无脑上大模型”也不能“无脑套词典”1.1 金融语境下的情绪词和通用情绪词完全不是一回事金融新闻里“下调评级”通常看成利空但如果是某家公司基本面早就被市场过度悲观定价也可能反而形成“利空出尽”的利好。“增持”大概率利好“回购”却未必是利好——还得看回购用途是注销股本还是股权激励。通用情绪词典会把“风险”“下跌”直接判负但“风险可控”其实是中性偏正“下跌收敛”甚至可能转多。这类任务错一个词就错一整条新闻模型必须从整句甚至段落语义里去判断方向这就是为什么第一步要先做文本向量化而不是对着词表做匹配。更麻烦的是金融新闻里密集出现数字和主体机构名“同比增长15%”“低于市场预期”“剥离亏损资产”。单纯关键词打分根本抓不到“预期差”这种金融里最核心的情绪来源。预期差、业绩拐点、资金流向都需要模型理解上下文关系而预训练句向量正好把这种关系保存成空间位置。我做过一个小验证“公司宣布大额回购”和“公司宣布大额减持”的余弦相似度只有0.6左右方向确实被拉开了“超预期”和“略超预期”的相似度明显更高说明句向量对程度的刻画也有一定分辨力。1.2 为什么最终选择“句向量回归集成蒸馏”四步链路这条链路的价值在于每一步都留下可验证的中间产物。SentenceTransformer负责把文本变成稠密向量低维、稳定、适合作为后续回归和树模型的输入OLS负责给效果兜底一个小时内部署出一个可解释的线性基线集成学习负责把单模型对噪声标签的过拟合压下来模型蒸馏最后把多个模型的行为压缩成一个小网络解决线上推理速度和内存问题。对比另一种常见做法——直接精调BERT模型做三分类——有两个现实问题。一是金融标注样本通常只有几千到两万的量级精调大模型很容易过拟合二是线下实验效果好不代表能上线一条新闻几毫秒和几百毫秒的差距会直接决定系统能否承受实时流量。SentenceTransformer生成的离线特征可以反复复用对实验迭代非常友好这也就给了OLS、集成学习和蒸馏足够的发挥空间。1.3 整条数据流向和各个产物的角色每条样本从原始文本经过清洗得到clean_text再编码成embedding标签只用来监督下游分类器不参与句向量的无监督编码。后续所有分类器的输入都是embedding每个模型独立训练集成层只读取各模型的输出概率蒸馏阶段再把集成的概率作为教师信号。环节主要处理产物后续用途原始文本HTML清洗、去URL、规范化数字符号clean_textSentenceTransformer编码文本编码多语言句向量模型编码768维embeddingOLS、逻辑回归、随机森林的输入分类训练每个模型独立拟合标签单模型预测概率集成层输入集成预测加权软投票集成概率蒸馏教师信号模型蒸馏小网络模仿集成概率学生模型线上部署这样设计的好处是每一步都能单独验收。如果后面蒸馏效果不佳可以逐段排查是教师概率不好、学生容量不够还是温度没调好而不是一头扎进黑盒里。2. 金融新闻语料准备标签怎么打、文本怎么洗、数据怎么切2.1 语料规模与三分类标签设计我这里用的是整理好的两万条左右中文金融新闻覆盖公司公告摘要、市场快讯、研报观点等栏目。标签定为三分类利多、利空、中性。打标签方式分两层先用行业关键词规则生成候选结果再由标注人员复核有争议的样本。规则只适合粗筛因为“回购股份注销”和“回购用于股权激励”对股价的影响方向完全不同必须靠人工判断上下文。样本分布大致是中性50%、利多25%、利空25%。这个比例和真实财经资讯分布比较接近但也意味着一个只猜中性的模型准确率也能到50%左右。所以后续评估不能只看准确率宏F1才是更有意义的主指标尤其是利空样本的召回率直接影响舆情风险监控的质量。2.2 金融文本清洗的三个特殊细节通用清洗流程通常会去数字和百分号在金融任务里这反而是灾难。“增长15%”和“增长5%”虽然都是利好但力度差异很大模型如果丢掉数字就分不清“超预期”和“略超预期”。我的做法是保留数字、百分号、小数点和正负号只清掉URL、多余空白、HTML标签这些纯噪声。第二点是不要对金融文本做激进的去停用词。“公司”“市场”“报告”这类词在普通NLP任务里可能是噪声在金融新闻里却是语义主体“公司回购”本身就承载利好属性。第三点是中文不需要额外分词SentenceTransformer自带的分词器能处理强行用外部词典分词反而可能把“超/预期”“利/空”拆成碎片。2.3 用时间顺序切分别用随机切分金融新闻有天然的时间依赖。如果随机划分训练集和验证集模型会在训练阶段“看到”未来事件相关的措辞验证分数会明显虚高一上真实数据就现原形。我按新闻发布时间排序前80%做训练后20%做验证并且确保训练集同时覆盖上涨、下跌、震荡三个阶段的口吻避免模型只见过单一行情下的新闻表达。如果数据量更大建议把最近一个月单独留作盲测集连调参都不碰它。这样得到的准确率、宏F1、各类别召回率才是能对外承诺的数字。我这一版就是这么做的后面表格里的数字全部来自盲测集而不是训练集或验证集。3. SentenceTransformer生成金融句向量关键不是模型多大而是怎么用3.1 为什么用预训练多语言句向量模型而不是自己训表示模型SentenceTransformer的核心价值是让文本拥有“语义位置”语义相近的句子在向量空间里距离更近。金融文本虽然有术语体系但底层语法和通用语料差距没有想象中大直接用预训练的多语言MiniLM类型模型就能得到足够区分的向量。我在这批新闻上做了个快速验证“高于市场预期”和“超预期”向量距离很近“低于市场预期”则落在明显更远的区域这说明预训练模型已经具备金融方向的语义感知。如果想把性能再往上提可以在金融语料上对SentenceTransformer做对比学习微调但需要构造高质量的正负样本对成本不低。我的经验是先把零样本向量跑通等基线、集成、蒸馏整条链路都稳定了再考虑要不要微调不要一上来就启动微调。3.2 编码参数和归一化编码阶段有三个参数值得注意最大序列长度、批大小和向量归一化。新闻文本一般不会太长我把最大长度设为128超过的做截断批大小设为64是为了在显存和速度之间取平衡。最重要的一步是normalize_embeddingsTrue把向量归一化到单位长度这样后续OLS和逻辑回归对特征尺度更敏感的问题就缓解了。from sentence_transformers import SentenceTransformer import numpy as np model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) texts df[clean_text].tolist() embeddings model.encode( texts, batch_size64, show_progress_barTrue, normalize_embeddingsTrue, convert_to_numpyTrue, ) np.save(financial_news_embeddings.npy, embeddings)归一化会丢失向量模长信息也就是“整个句子信息量大小”这个维度。我对比过保留模长和归一化两种方案在OLS和集成任务里保留模长带来的提升并不稳定反而会增加阈值调参难度所以最终统一使用归一化后的嵌入向量。3.3 长新闻怎么编码单条新闻如果超过128个token直接截断可能丢掉结论末尾的关键信息。可行的替代方案是按句子拆分每句编码后做平均池化但对两万条样本来说计算量会翻好几倍。更实际的折中办法是保留前512个token做一次整体编码因为财经快讯和公告摘要的结论通常出现在开头。我实测下来平均池化比直接截断在利空召回率上能提升1到2个点但推理时间增加约60%对线上任务不划算。如果语料以长文本为主我建议先用截断版本跑通流程再评估是否值得上池化不要在早期就为这点收益增加复杂性。4. OLS做情感分类为什么先上一个“又老又简单”的回归模型4.1 把三分类问题改造成回归问题OLS全称普通最小二乘法常规用途是回归。我们可以把三分类标签映射成连续数字利空0、中性1、利多2然后让OLS拟合这个连续目标。预测时得到一个连续得分接近0判利空、接近1判中性、接近2判利多。操作上等于在高维句向量空间里找一条线性方向让利空到利多的情绪主方向与向量点积方向对齐这跟线性判别分析的思想很接近。import statsmodels.api as sm label_map {利空: 0, 中性: 1, 利多: 2} X embeddings y df[label].map(label_map).values.astype(float) X_ols sm.add_constant(X) ols_model sm.OLS(y, X_ols).fit() pred_y ols_model.predict(X_ols)使用statsmodels而不是scikit-learn里的LinearRegression是因为它能直接输出系数摘要和p值。高维特征下p值的参考意义有限但系数正负能帮你快速发现哪些维度在驱动情绪方向这对特征诊断挺有价值。4.2 阈值不能默认0.5和1.5必须扫描预测值0.5和1.5只是初始阈值。由于标签映射不一定落在合适的线性区间直接用这两个固定阈值宏F1会低不少。我在验证集上扫描两个阈值t1在0.2到0.7之间、t2在1.2到1.8之间以宏F1为目标选出最优对。这样一个简单基线就在盲测集拿到了准确率0.72、宏F1 0.66如果固定阈值大概只有0.70和0.62。阈值扫描不是玄学它实际上在补偿线性回归对离散标签的“刻度偏差”。OLS不知道0和1之间的差距一定等于1和2之间的差距它只会最小化平方误差所以阈值必须由数据重新估算。4.3 OLS基线有什么实际意义OLS的意义不在于刷分而在于三点。第一是它几乎零超参数不需要调参作为集成成员不会引入额外方差。第二是它的系数可解释能帮你判断特征是否有效——如果OLS在验证集上的表现非常差那多半是句向量和标签之间的关系根本不是线性的后续再上复杂模型前最好先检查特征质量。第三是它给所有模型提供了一个最低容忍线如果随机森林或蒸馏模型连OLS都打不过问题大概率出在特征或数据上而不是模型不够强。5. 集成学习多个模型一起投票把单模型的方差压下来5.1 为什么金融情感任务特别适合集成金融新闻标签噪声高单个模型很容易对有争议的样本“过度自信”。比如一条“公司拟回购股份用于员工持股计划”的新闻标注人员可能给出完全不同的判断分类器学到的就是某种随机选择。多个模型在不同算法偏置下投票恰好能把这种随机性平均掉。舆情监控场景里比起单模型准确率更怕的是“今天准、明天抽风”集成之后稳定性有明显提升。另外集成不需要每个成员都是顶尖模型。OLS只能拿0.66宏F1逻辑回归大概0.68随机森林能到0.70三个加到一起就能到0.72左右。这种“三个臭皮匠”效应在金融NLP任务里特别明显因为标签噪声让每个单模型都只能学到一部分真实信号。5.2 三个成员模型怎么搭配我用了三个分类器OLS、带L2正则的逻辑回归、随机森林。OLS和逻辑回归都是线性模型但目标函数不同随机森林是非线性模型能捕捉句向量维度之间的交互。它们共享同一个SentenceTransformer嵌入互相独立训练。实验下来线性成员对集成贡献稳定树模型负责补上非线性边界。搭配上有一个建议不要放两个树模型。随机森林和梯度提升树的输出相关性通常很高放进集成里对提升帮助不大还明显拖慢训练和推理。选一个线性、一个正则化线性、一个非线性多样性就够用了。5.3 加权软投票的实现软投票比硬投票更稳。每个成员输出三个类别的概率按权重相加取概率最大的类别作为最终预测。权重怎么定我在验证集上算每个成员的宏F1归一化后作为初始权重再微调。最终权重大致是OLS占0.25、逻辑回归占0.35、随机森林占0.40。import numpy as np from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier class OLSClassifier: def __init__(self): self.model None def fit(self, X, y): import statsmodels.api as sm self.model sm.OLS(y, sm.add_constant(X)).fit() def predict_proba(self, X): y_hat self.model.predict(sm.add_constant(X)) proba np.zeros((len(y_hat), 3)) for i in range(3): proba[:, i] 1 - np.abs(y_hat - i) proba proba / proba.sum(axis1, keepdimsTrue) return proba base_models [ (ols, OLSClassifier(), 0.25), (lr, LogisticRegression(max_iter2000), 0.35), (rf, RandomForestClassifier(n_estimators200, random_state42), 0.40), ] ensemble_proba np.zeros((len(X_val), 3)) for _, clf, w in base_models: clf.fit(X_train, y_train) ensemble_proba w * clf.predict_proba(X_val) ensemble_pred ensemble_proba.argmax(axis1)OLS本身没有predict_proba我把它的连续预测值按照“距离哪个整数越近概率越大”的方式转成三列伪概率再归一化。这个包装不算优雅但能让线性模型参与软投票实际效果和硬投票差不太多。5.4 集成阶段常见的两个问题第一个问题是成员之间相关性太高。逻辑回归和OLS共享线性假设数据一样特征一样输出相关性通常很高所以集成效果有限。想提升多样性可以给不同成员喂不同的嵌入维度或者用不同长度的新闻片段。第二个问题是权重需要靠验证集调但千万别用测试集调。我的习惯是把验证集再切一半一半用来选阈值和集成权重另一半只用来做最终效果核对。6. 模型蒸馏把集成的“慢而准”压缩成“快而准”的单模型6.1 教师网络和学生网络怎么设计蒸馏的核心思路是训练一个复杂的教师模型产生软标签再让一个结构简单的学生模型去模仿教师的行为。这里的教师就是加权软投票之后的集成输出它给每条新闻输出一组“利多多少、中性多少、利空多少”的概率分布。学生模型是一个两层MLP输入768维句向量隐藏层256维ReLU激活加Dropout输出3个logits。为什么是MLP而不是再套一个SentenceTransformer因为金融新闻的文本向量化仍然需要预训练模型完成学生只需要学会“把向量映射成情感概率”。集成模型有线性、有树结构难以直接部署而一个两层MLP既是单模型又能近似集成的决策面。6.2 温度参数和损失函数教师输出的概率分布往往太尖锐接近0和1直接让学生学等于把硬标签又学了一遍失去了蒸馏的意义。把教师的logits除以温度T分布会变平滑学生就能学到类间的相对关系。我扫描过T1、2、3、5这组数据上T3最合适。学生训练的损失由两部分组成真实标签交叉熵 教师软标签KL散度。两者按比例相加。我常设KL损失的权重为0.3并乘上T平方来平衡梯度尺度。import torch import torch.nn as nn class StudentNet(nn.Module): def __init__(self, input_dim768, hidden256, num_classes3): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, hidden), nn.ReLU(), nn.Dropout(0.2), nn.Linear(hidden, num_classes), ) def forward(self, x): return self.net(x) T 3.0 def distill_loss(student_logits, hard_labels, teacher_logits): ce nn.CrossEntropyLoss()(student_logits, hard_labels) teacher_soft torch.softmax(teacher_logits / T, dim-1) kl nn.KLDivLoss(reductionbatchmean)( torch.log_softmax(student_logits / T, dim-1), teacher_soft, ) return 0.7 * ce 0.3 * kl * T * T训练时用验证集生成一次教师软标签并缓存学生每个epoch复用同一批软标签能省不少时间。6.3 蒸馏后的效果和速度对比方案准确率宏F1单条推理耗时OLS基线0.720.660.2ms三层集成0.760.723.5ms蒸馏学生0.750.710.3ms蒸馏学生的准确率和宏F1只比集成低1个点推理耗时却降低了一个数量级。对线上舆情系统来说单条0.3ms意味着可以在实时流上逐条判断而集成模型的3.5ms虽然也不算慢但三个模型的内存占用和运维成本明显更高。这也是我把蒸馏作为最后一步的核心原因集成负责把准确率带上去蒸馏负责把工程成本降回来。6.4 什么时候不建议蒸馏如果你的业务场景本身就是离线批量分析比如每天收盘后批量处理几万条新闻那集成模型直接跑完全撑得住不必多此一举做蒸馏。蒸馏适合的是在线实时判断、嵌入式设备、低延迟接口这类对推理开销敏感的场景。先想清楚部署约束再决定要不要做这一步。7. 实际跑下来最容易翻车的几个地方7.1 别被准确率骗了宏F1才是“安全线”我第一版模型盲测准确率0.75看起来不错但打开混淆矩阵一看利空召回的样本被大量中性和利多吃掉。原因就是训练集里中性样本占了一半模型只要把不确定的都判成中性准确率也不会低。金融舆情监控最怕漏报利空所以后来我把主指标定为宏F1并且单独盯利空类别的召回率。建议你也先看混淆矩阵再看单类别的精确率和召回率最后才看总体准确率。7.2 OLS的标签编码和阈值必须一起调标签编码方式改变OLS系数和预测值刻度都会变但阈值扫描能把这层影响吸收掉。反过来也一样如果先定好阈值再折腾标签编码很容易把一个简单问题调复杂。我的建议是标签编码只做一次后续一律只扫阈值。另外要防OLS在预测时超出0到2的范围实务里可以把预测结果裁剪到[-0.5, 2.5]避免极端样本干扰阈值。7.3 蒸馏温度不是一个静态参数温度T虽然按全局最优设为3但要注意训练后期软标签的作用会下降。我试过固定T3跑到底学生后期会在硬标签和软标签之间轻微震荡改成每5个epoch让KL权重从0.3衰减到0.1之后验证集F1又回升了0.3个点。这个细节很容易被忽略但它决定了蒸馏收益的极限。另外如果学生网络容量太小比如隐藏层只有32即使T调得再好也学不到教师的行为。可以先用隐藏层128或256起步观察蒸馏损失是否收敛再决定是否降维。7.4 代码和数据怎么复用整个项目我按照“数据清洗、向量缓存、模型训练、蒸馏部署”四段来组织。嵌入向量是第一个需要缓存的大文件两万条新闻生成的向量保存为.npy后续实验不用重新编码省下大量时间。所有模型训练固定随机种子集成和蒸馏结果才能稳定复现。复现时最容易被忽略的是环境版本差异尤其是sentence-transformers和torch的版本会影响编码结果。建议在项目里锁定版本并把嵌入缓存文件和版本号一起存档。这样就算换了机器也能确保下游复用同一批向量。我这次跑下来最大的体会是不要一上来就堆复杂模型。SentenceTransformer把文本变成向量OLS能在一小时内给出可解释基线集成负责抹平单模型的不稳定蒸馏只做最后一道压缩。每一步拿到的中间产物都能单独检查出了问题也容易定位。如果你也在做金融舆情分类或类似的文本情感任务建议按这条链路完整走一遍先别急着找更大的模型。