做金融新闻的情感分类,很多人第一反应是上大模型,但真正落到业务里你会发现,推理延迟、显存占用、可解释性这些问题一样都躲不开。这篇项目记录我用了另一套组合拳:SentenceTransformer做语义向量,OLS做可解释基线,随机森林和XGBoost做集成,最后再用模型蒸馏把集成模型压缩成轻量推理服务。整套流程跑下来,准确率能逼近大模型蒸馏方案,推理速度却快了一个数量级,而且每个环节都可以单独替换。适合刚入门NLP分类、想从传统机器学习往深度语义过渡,或者正在为线上部署发愁的工程师参考。
1. 项目思路与技术选型
1.1 金融文本情感分类难在哪
金融新闻和电商评论完全是两种文本。电商里“这手机续航太顶了”基本就是正面,金融文本里“该股高位放量”既可能是提醒风险,也可能是趋势确认;“央行维持利率不变”听起来中性,但市场解读可能是利好也可能是利空。直接做词频统计或者跑个朴素贝叶斯,效果往往很差,因为金融语言的信息密度高、语义依赖强,同一个词在不同语境下极性完全相反。
另一个麻烦是数据不平衡。财经新闻里“中性”语句占比很高,真正强烈看多和看空的文本少,如果直接用原始分布训练,模型很容易把所有样本都预测成中性,准确率虚高但毫无用处。所以这个项目从数据层面就做了分层采样,并且把情感强度也作为特征引入,而不是只分正负中性三分类。
还有一个隐性问题:领域词汇的表示。通用预训练模型对“降准”“逆回购”“商誉减值”这类金融术语的表征不一定准,所以我在SentenceTransformer的基础上额外拼接了金融词典匹配特征,相当于用传统特征给深度语义做补充。后面你会发现,这个补充对OLS和集成模型都有明显增益。
1.2 为什么不用暴力微调大模型
做这个项目之前我测过直接用中文RoBERTa微调,效果确实好,但问题也很现实:一张训练卡要跑几个小时,模型文件几百兆,线上用GPU部署成本太高。部门里还有一堆规则服务和老系统,不可能为了一个分类任务专门搭一套深度学习推理框架。
所以我的目标很明确:找一个能落地、可解释、性能不差的方案。SentenceTransformer负责把文本变成稠密向量,这一步非常关键,它把非结构化的文本映射成数值特征后,下游就可以用任何经典机器学习模型。OLS虽然简单,但它能告诉我们每个特征对情感的线性贡献,方便跟业务解释;集成学习则负责捕捉非线性关系,把准确率拉上去;最后的蒸馏是为了让线上模型足够轻,同时保留集成模型学到的知识。
这个组合还有一个隐性优势:每一层都是解耦的。向量化可以单独更新,分类器可以单独重训练,蒸馏模型可以单独部署。只要语义表示不换,上游怎么迭代都不影响下游。实际维护起来非常省心。
2. 数据准备与预处理
2.1 数据来源与结构
项目数据来自公开的金融新闻语料,我整理后保留了三个字段:标题、正文摘要、人工标注的情感标签。标签一共三档:正面、中性、负面,同时附带一个0到1的置信度,用于表示标注者对这个标签的把握。
数据量方面,训练集我用了2.8万条,验证集3500条,测试集3500条。分布上我刻意做了平衡:正负中每种大概各占三分之一,避免模型被中性样本带偏。这里有个很容易踩的坑:原始语料里中性样本可能占60%以上,直接切分会导致验证集和测试集也是偏的,那你评估出来的F1就不可信。所以我用的是分层抽样,shuffle前先按标签分组,每组内部再随机取。
还需要构造一个“时间切分”的测试集,因为金融文本有时间漂移问题。训练集用的是2023年之前的新闻,测试集用2023年上半年的新闻。这样能看出模型在稍微未来一点的数据上泛化能力如何。事实证明,时间切分下的效果比随机切分低2到3个百分点,这个信息对业务决策非常重要。
2.2 文本预处理哪些该做哪些不该做
预处理在这个项目里我控制得比较克制,因为SentenceTransformer自带分词器,过度清洗反而会破坏语义。
我做了三件事。第一,去HTML标签和乱码;第二,把数字统一替换成[NUM]占位符,因为“涨了3%”和“涨了300%”表达的市场含义完全不同,但模型如果看到具体数字,容易过拟合到某些阈值上;第三,标点符号做了规范化,全角转半角,连续感叹号压缩成一个。
我没有做的是:停用词过滤、词形还原、去除语气词。原因很简单:金融文本里的“但是”“然而”是转折信号,对于情感极性判断很重要;而“不”这类否定词更是情感分类的关键信息,一旦被当停用词滤掉,语义就会反转。很多新手在这步翻车,总想着按通用NLP流程走,结果把关键信号洗没了。
预处理完之后,我会把每条文本拼接成标题 + [SEP] + 摘要的形式,这样语义向量可以同时编码标题的概括信息和摘要的细节信息。实测下来比只用标题或只用摘要效果好很多。
2.3 金融词典特征与标签映射
除了深度语义向量,我还构造了一组领域特征。我整理了一个金融情感词典,包含大约3000个词,分成看多词、看空词、中性词三类。比如“涨停”“增持”“盈利预增”是看多;“暴雷”“减持”“债务违约”是看空;“公告”“董事会”“披露”是中性。
对于每条文本,我计算三个特征:看多词在文本中出现的次数占比、看空词出现次数占比、以及看多词与看空词的位置关系(比如看空词出现在看多词之前,可能表示转折)。这三个特征和SentenceTransformer向量拼接后,维度大概不到400,对OLS和树模型来说都是可以接受的。
标签映射方面,我把原始的人工标签映射成数值:负面为0,中性为1,正面为2。但OLS对这种整数标签做回归时,会强行把输出当作连续值,效果一般。所以我实际训练时改用两阶段的思路:先让OLS预测一个连续的情感得分,然后在这个得分上做阈值划分。这样既保留了OLS的可解释性,又不会让分类问题被回归问题绑架。
注意:如果你直接用OLS拟合整数标签,预测值可能是1.8或者0.4,这种数值没有概率含义。把它当分类用之前,一定要先在验证集上找最佳阈值,而不是拍脑袋用1.5。
3. 核心模型实现
3.1 SentenceTransformer生成句向量
我用的是paraphrase-multilingual-MiniLM-L12-v2,这是一个多语言模型,对中文支持还可以,向量维度384维,速度也够快。如果你追求更高精度,可以换multilingual-e5-large,但维度会到1024,后续模型训练成本会明显上升。
生成句向量的代码其实很简单,但有几个细节要处理:
from sentence_transformers import SentenceTransformer import numpy as np model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2') def encode_texts(texts, batch_size=64): embeddings = model.encode( texts, batch_size=batch_size, show_progress_bar=True, convert_to_numpy=True, normalize_embeddings=True ) return embeddingsnormalize_embeddings=True这一步我建议开着,因为后续用余弦相似度或者用欧氏距离时,归一化后的向量更稳定。如果你不归一化,向量模长差异会影响OLS回归的系数解释。
另一个细节是batch_size的选择。SentenceTransformer内部有padding机制,太小的batch会浪费算力,太大的batch又容易让显存爆掉。我实测下来,在16G显存的卡上batch_size设64比较稳,CPU上则建议16或32。
编码完的向量我会存成npy文件,避免每次训练都重新编码。这个习惯非常重要,因为编码这个环节是最耗时的,但模型训练其实只需要向量和标签,没必要每次重来一遍。
3.2 OLS模型:线性回归做情感得分的可解释基线
OLS是普通最小二乘回归,在统计建模里很经典。用它做情感分类,本质是把情感得分当成连续变量回归,然后用阈值划分。
我的做法是先训练一个OLS模型,输入是384维句向量加上3维词典特征,输出是情感得分。训练代码:
import statsmodels.api as sm X_train = np.hstack([train_embeddings, train_lexicon_features]) X_train_sm = sm.add_constant(X_train) ols_model = sm.OLS(train_scores, X_train_sm).fit() print(ols_model.summary())这里train_scores不是整数标签,而是经过平滑的情感强度值。具体地,我把正面记作2.0,中性1.0,负面0.0,然后用标注置信度去做软化:如果一条正面新闻的置信度只有0.6,那么它的得分就是1.8而不是2.0。这样处理能让OLS学到“模糊的正面”和“明确的正面”之间的差异。
OLS有一个天然优势:可以输出每个特征的系数和p值。你可以在summary()里看到哪些维度对情感影响显著。不过由于句向量维度比较高,用全部384维做OLS会出现多重共线性,系数解释起来会有点困难。所以我额外跑了一个Lasso(L1正则化的线性回归)来对比特征稀疏性,发现Lasso选出来的特征集中在少数核心语义维度上,这对后续模型蒸馏时的特征筛选也有参考价值。
在验证集上,OLS的准确率能达到72%左右,F1大约0.70。这个成绩比随机猜测好很多,但它最大的价值是给后续集成模型提供了一个对照基线。如果集成模型还不如OLS,说明特征或者训练过程出了问题,需要回头排查。
3.3 集成学习:随机森林与XGBoost的投票机制
集成部分是整个项目的效果支柱。我在SentenceTransformer向量和词典特征上直接训练了随机森林和XGBoost两个分类器,然后用软投票融合。
随机森林我重点调了三个参数:n_estimators=500、max_depth=16、min_samples_leaf=4。金融文本特征维度不高,但噪声多,太深的树容易过拟合,太浅又欠拟合。min_samples_leaf=4能强制每个叶子至少包含4个样本,避免模型记住个别新闻里的特殊措辞。
XGBoost我用的是learning_rate=0.05,n_estimators=600,max_depth=6,subsample=0.8,colsample_bytree=0.8。这里的关键是学习率调低、迭代次数调大,配合早停。我在early_stopping_rounds设了50轮,用验证集AUC作为早停指标。
训练完两个模型后,我用验证集概率输出做加权投票。权重不是拍脑袋定的,而是用网格搜索在验证集上找最优组合。搜索范围是XGB权重从0.4到0.7,步长0.05,随机森林权重等于1减这个值。最终结果大概在0.55对0.45,说明两个模型各有侧重,随机森林对词典特征更敏感,XGBoost对语义向量更敏感。
投票代码如下:
from sklearn.ensemble import VotingClassifier from sklearn.ensemble import RandomForestClassifier from xgboost import XGBClassifier rf = RandomForestClassifier( n_estimators=500, max_depth=16, min_samples_leaf=4, n_jobs=-1, random_state=42 ) xgb = XGBClassifier( learning_rate=0.05, n_estimators=600, max_depth=6, subsample=0.8, colsample_bytree=0.8, eval_metric='mlogloss', early_stopping_rounds=50, random_state=42 ) ensemble = VotingClassifier( estimators=[('rf', rf), ('xgb', xgb)], voting='soft', weights=[0.45, 0.55] ) ensemble.fit(X_train, y_train)软投票的优势在于它保留了类别概率的不确定性。如果随机森林对正面给出0.6的概率,XGBoost给出0.4,折中后可能更接近真实分布。你也可以用硬投票,但硬投票在概率接近时会频繁跳变,线上稳定性不如软投票。
集成模型的验证准确率到了81%,比OLS涨了9个百分点,F1到了0.80。测试集(时间切分)准确率78%,这说明模型有一定泛化能力,但时间漂移带来的衰减确实存在。
4. 模型蒸馏:把集成模型压成轻量推理模型
4.1 蒸馏到底在做什么
蒸馏的出发点很简单:一个庞大的集成模型虽然效果不错,但线上要同时跑随机森林和XGBoost,还要加载384维向量接口,延迟和内存都有压力。蒸馏是用一个小模型去学大模型的“行为”,而不是直接学原始标签。
这里的大模型扮演的是“教师”角色,它输出的是对每个类别的概率分布。这个分布比硬标签信息量更大,举个例子:一条新闻的真实标签是“负面”,教师模型可能给负面0.7、中性0.25、正面0.05。0.25这个数字并不是噪声,它告诉小模型“这条文本有一丝中性的倾向”,这种软标签能提供更平滑的学习信号。
小模型学的是教师模型的软标签,同时也会兼顾一部分硬标签。通常会在损失函数里加一个权重参数,让软标签和硬标签按比例混合。这样做的好处是让小模型既能继承教师的知识,又不会偏离真实答案。
4.2 蒸馏模型的结构与代码实现
蒸馏的小模型我给了一个非常简单的结构:输入是384维句向量,经过两个全连接层,最后输出3分类概率。所有中间层都设在128维,激活函数用ReLU,最后一层不加激活直接输出logits,再用softmax得到概率。
训练时用的损失函数是KL散度,计算小模型预测分布和教师模型软标签之间的差异,同时加一个交叉熵损失来锚定硬标签。温度T是一个超参数,它会把概率分布变得更平滑或更尖锐。我在这个项目中尝试了T=2和T=5,最终验证集上T=2更好。
核心训练代码:
import torch import torch.nn as nn import torch.nn.functional as F class DistillModel(nn.Module): def __init__(self, input_dim=387, hidden_dim=128, num_classes=3): super().__init__() self.fc1 = nn.Linear(input_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, hidden_dim) self.fc3 = nn.Linear(hidden_dim, num_classes) self.dropout = nn.Dropout(0.2) def forward(self, x): x = F.relu(self.fc1(x)) x = self.dropout(x) x = F.relu(self.fc2(x)) x = self.dropout(x) return self.fc3(x) def distill_loss(student_logits, teacher_probs, hard_labels, T, alpha): student_probs = F.log_softmax(student_logits / T, dim=-1) kd_loss = F.kl_div(student_probs, teacher_probs, reduction='batchmean') * (T * T) ce_loss = F.cross_entropy(student_logits, hard_labels) return alpha * kd_loss + (1 - alpha) * ce_loss注意到kd_loss乘了T * T,这是因为KL散度在温度缩放后的梯度会变小,乘上T的平方可以补偿,让梯度幅度稳定。这个细节非常关键,我第一次训的时候忘了乘,结果小模型怎么都拟合不了教师分布,准确率比硬训练还低。
teacher_probs是教师集成模型对每个训练样本输出的软标签概率。在开始蒸馏前,我会先把所有样本的教师概率算好并缓存起来,训练时直接读取,省去重复推理的时间。这也是项目工程化的一个小技巧。
4.3 蒸馏后的部署效果对比
蒸馏模型参数量大约9万个,模型文件不到4MB,而随机森林加XGBoost单个模型文件就有几十上百MB。线上推理时,蒸馏模型单条文本预测时间在CPU上约0.3毫秒,集成模型需要约2毫秒,如果不做缓存和批处理差距更明显。
效果方面,我在测试集上对比了几组数字:
| 模型 | 准确率 | F1 | 模型大小 | CPU单条耗时 |
|---|---|---|---|---|
| OLS | 72.4% | 0.70 | 0.5MB | 0.05ms |
| 随机森林 | 78.9% | 0.77 | 35MB | 0.8ms |
| XGBoost | 80.2% | 0.79 | 28MB | 1.1ms |
| 集成投票 | 81.6% | 0.80 | 63MB | 2.0ms |
| 蒸馏小模型 | 80.4% | 0.79 | 3.8MB | 0.3ms |
蒸馏模型相比集成模型准确率只低了1个百分点,但模型小了15倍以上,速度反而更快。如果业务容忍度允许,这个替换非常划算。
需要提醒的是,蒸馏模型的效果高度依赖教师模型的质量。如果教师模型本身只有72%的准确率,蒸馏出来的小模型基本不可能超过这个水平。所以在蒸馏前,一定要先确保教师模型的性能达标,否则就是在蒸馏噪声。
5. 常见问题与排查实录
5.1 句向量维度能不能压缩
384维对OLS来说其实有点高,直接跑OLS会出现很多系数不显著的问题,虽然预测影响不大,但可解释性变差。我试过用PCA把维度降到64,效果会掉一点点,但系数显著性明显改善。如果你对可解释性要求高,可以尝试PCA降维后再喂给OLS。对于随机森林和XGBoost,维度高一点问题不大,它们本身就能做特征选择。但对于蒸馏模型,维度太高会带来参数量上升,进而增加过拟合风险。所以我在蒸馏模型输入里也加了PCA降维选项,最终实际测试下来,降不降维对蒸馏效果影响不大,因为模型有dropout和正则项。
5.2 OLS回归输出越界怎么处理
OLS的输出是连续值,可能大于2或者小于0。这并不表示模型出错,只是线性模型在边界外没有约束。处理方法分两种:一种是把输出值clip到[0,2]范围内,另一种是用logistic回归替代OLS。我试过把OLS输出做一个sigmoid映射到(0,1),效果差别不大。但从业务角度,OLS输出的原始值可以直接当“情感强度”来用,比如0.8和1.4虽然都归为中性,但明显1.4的文本更偏正面一些。所以我不建议强行clip,而是保留原始值,只在分类决策时用阈值。
5.3 集成模型在验证集上过拟合
随机森林和XGBoost都很容易过拟合,尤其当你把prompt里的长文本拆成句子再抽特征时。我的解决方案是增加正则项:随机森林限制树深度和叶子节点最小样本数,XGBoost调高gamma和lambda。另外,把早停用起来,不要只看训练准确率。如果验证集准确率开始下降而训练集还在上升,基本就是过拟合信号。我还会对比训练集和验证集的F1差异,如果差异超过5个百分点,就说明模型开始记忆数据而不是学习规律。
5.4 蒸馏温度T到底怎么选
T越小,软标签越接近硬标签,优化目标越像普通训练;T越大,软标签越平滑,小模型更容易学到类别之间的相似关系。金融情感分类只有三个类,类间关系不像ImageNet那样有一千类那么复杂,所以温度不需要太高。我试了T=1、2、3、5、10,结果T=2最好,T=5时模型开始把正面和中性混在一起,因为标签平滑过度了。我的建议是先在验证集上做个网格搜索,范围2到5,不用盲目追求大温度。
6. 实操心得与扩展方向
6.1 我踩过的几个坑
最让我记忆深刻的是缓存问题。一开始我没有缓存SentenceTransformer编码结果,每次调参都重新编码一遍2.8万条新闻,一次要等十几分钟,一天下来极其浪费时间。后来我把编码结果、教师模型的软标签全部提前算好,存成npz和pt文件,后面所有模型训练几乎都是秒级加载。这个改动直接把项目周期缩短了一半以上。
另一个坑是文本长度。SentenceTransformer默认最大序列长度是128或256,金融新闻标题加摘要很容易超长。我一开始没做截断,结果后面所有的预测都退化成只用前面部分语义,准确率掉了4个点。后来我按字符数做了自适应截断,保留标题全量,摘要只取前256个字符,效果恢复明显。
最后一个是标签置信度的使用。最开始我把置信度当成小事,直接取整数标签训练。后来用了软标签做OLS回归,模型残差明显下降。这也说明在数据标注质量不稳定的场景下,显式建模标注者的不确定性很有价值。
6.2 后续还能怎么扩展
这个项目的思路完全可以迁移到其他短文本分类任务,比如投诉工单分类、舆情预警、公告风险识别。只需要把SentenceTransformer换成对应领域的微调模型,词典特征换成领域词表,其余流程基本可以复用。
如果你追求更高准确率,可以考虑两步走:先用这个蒸馏模型做初筛,把高置信度样本直接输出,低置信度样本再送入大模型复核。实际线上部署时,大约80%的请求可以被轻量模型处理,只有20%需要大模型兜底,整体成本可控。
还能做的优化包括:用增量学习定期用新数据微调蒸馏模型;把教师集成模型改成更好的单模型比如大模型蒸馏出的高精度模型;或者用对抗验证来监测线上特征分布漂移,当语义分布与训练集差异过大时,触发模型重训练告警。
我个人在实际操作中的体会是,这套方案最有价值的不是某个模型本身,而是把“深度语义表示”和“传统机器学习可解释性”揉到了一起。你既能让业务理解规则,又能让模型学到复杂模式,最后还能用蒸馏把它塞进小服务里。做工程,有时候比单纯刷准确率更有意思。