简介:这是CCF-BDCI 2018年汽车行业用户观点主题及情感识别挑战赛第7名解决方案的完整Python项目,面向机器学习、自然语言处理方向的竞赛选手和求职开发者,可用来学习如何从用户评论中识别主题和情感倾向。压缩包共39个文件,以31个Python脚本和2个Jupyter Notebook为主,涵盖数据预处理、tokenization切词、模型训练与预测、stacking集成,以及基于ELMo的train_elmo.py等完整流程,另有配置文件、说明文档和模型示意图,整体仅1.12MB,目录结构清晰,便于快速定位代码。目前已有964人浏览学习,适合作为赛题复现和工程参考。这份方案完整展示了从文本清洗、TF-IDF/词向量构造到LSTM、ELMo表示与多模型融合的实践路径,同时提供了具体的预处理脚本、模型配置和打包环节,能够帮助读者深入理解汽车领域评论分析赛题的建模思路与实际工程落地细节,并可迁移到其他文本分类场景中复用。 2018年CCF BDCI的“汽车行业用户观点主题及情感识别挑战赛”,说白了就是用Python处理一批车主评论:判断每句话在聊哪些主题(动力、空间、油耗、内饰……),再对每个主题给出正面或负面的情感判断。我们团队最后拿到第7名。这个成绩谈不上惊艳,但整套技术路线——文本清洗、分词、特征工程、深度学习模型、概率融合、后处理阈值搜索——非常适合作为中文短文本多标签情感分析的一份完整实践参考。如果你正准备参加类似的NLP分类比赛,或者工作中要做用户评论舆情分析,这篇复盘可以直接照着落地。
1. 先搞清楚赛题要我们做什么
1.1 任务本质是“主题+情感”的多标签分类
很多新手看到“主题及情感识别”这种赛题名称,容易把它当成两个独立任务分开做,这其实会绕远路。从数据标注形式来看,每一条汽车评论都被打上了多个“主题-情感”组合标签。比如“这车外观漂亮,空间大,就是动力肉,油耗高”,这句话同时包含了外观-正、空间-正、动力-负、油耗-负四个标签。
所以正确的建模方式是:把所有组合标签展开成一个多标签分类问题。假设主题有9个,情感分正负两类,最终输出就是18维的标签向量,每个维度表示该标签是否成立。之所以不选择序列标注或者关系抽取,是因为汽车评论文本本身很短,口语化严重,实体和观点在句子里的边界非常模糊,强行做结构抽取反而容易引入标注噪声。多标签分类简单直接,对短文本的覆盖能力和稳定性都更好。
1.2 方案分层和选型依据
我当时的方案没有走任何冷门路线,就是典型的三层结构:
第一层,TF-IDF加线性模型,用于快速验证数据处理流程和标签定义是否正确。 第二层,TextCNN和BiLSTM+Attention两个深度学习模型,分别做五折交叉验证,输出各标签预测概率。 第三层,对两个模型的概率做加权融合,再按类别搜索最优阈值,并加一些基于标签共现关系的后处理规则。
选这两个深度学习模型的原因很现实:训练样本也就几千条,属于典型的小样本短文本分类场景,BERT当时虽然已经出现,但在算力和调参成本上不适合大规模实验。而TextCNN擅长捕捉局部n-gram特征,BiLSTM+Attention擅长捕捉跨位置的转折和依赖关系,两者的错误相关性很低,融合之后能拿到稳定的提升。做竞赛选择模型,不必追求结构多新,关键是模型之间要有足够的差异性。
2. 数据预处理:一半时间都花在这
2.1 文本清洗的四步流程
汽车评论文本特别杂,品牌名、车型名、英文、数字、表情、网络用语全都有。我的清洗流程固定成四步:
第一步,HTML解码,去掉URL、@用户、话题标签。 第二步,全角转半角,繁体转简体。 第三步,连续重复标点归一化,把“!!!”压成“!”,表情符号换成统一占位符。 第四步,数字和英文字母统一小写,但保留“1.5T”“2.0L”“95号”这类车型动力参数。
这里有个特别容易踩的坑:不要觉得数字和英文没用就直接删掉。“1.5T”“2.0L”这类字符串是判断“动力”主题的关键线索。我第一次清洗时把所有数字删了,结果“油耗”主题的F1掉了将近1个点,后来单独写规则把型号参数保护住才恢复。文本清洗这件事,宁可多留一些特征,也不要在第一步就做过头。
2.2 分词、词典与停用词的细节
分词用的是jieba,但不是直接默认分词,而是先加载自定义汽车领域词典。词典里除了常见的车型和品牌名,我还把训练集里出现的高频评论词都提取进去,比如“推背感”“顿挫”“异响”“风噪”“胎噪”“方向盘虚位”这类词,默认词典里经常被切碎,影响后续词向量的学习。
停用词处理也要讲分寸。哈工大停用词表我用了,但“不”“没”“没有”这类否定词必须保留,因为它们直接决定情感极性。另外像“但是”“不过”“虽然”这类转折词也不能删,转折之后的内容往往是用户真正想表达的观点,情感权重比前面部分更大。删除停用词的目的是降噪,不是把句子的逻辑关系也删掉。
2.3 样本分布与交叉验证折分
这个赛题的样本分布很不均匀。正面评论多,负面少;“外观”“性价比”这类主题样本多,“安全”“操控”这类主题样本少。如果随机划分验证集,小类目很可能在某几折里直接消失,验证分数波动会非常大。
我的处理是用多标签分层采样的方式切五折,保证每一折里每个标签的正负比例和全量数据基本一致。这个操作不复杂,但直接决定了后续所有调参结论是否可靠。如果验证集的分布和线上不一致,后面不管是调阈值还是调权重,都等于在噪声上做文章。
3. 特征与模型:从baseline到主模型
3.1 TF-IDF加SVM的快速验证
不急着上深度学习,先把baseline跑通。我用的是TF-IDF特征加LinearSVC,OneVsRest策略训练18个二分类器。TF-IDF做了字级别和词级别两个粒度,拼在一起之后再用SVM分类。字级别特征的好处是能兜住分词错误和未登录词,词级别特征保留语义信息。
TF-IDF参数设置上,ngram_range取(1,2),min_df设2,sublinear_tf打开。SVM在小样本高维稀疏特征上通常比LR略好,但SVM不好出概率,所以后面换成带L2正则的LogisticRegression,方便做融合。传统baseline在这个任务上大概能跑到0.6几的宏F1。如果连这个分数都没达到,基本可以断定是数据清洗或标签拼接出了问题,不需要急着调深度学习。
3.2 主模型选型:TextCNN与BiLSTM+Attention
TextCNN的配置很常规:100维词向量初始化embedding,卷积核大小取1到5,每个尺寸128个filter,全局max-pooling,dropout设0.5,最后一层接18维输出,用BCEWithLogitsLoss计算损失。
BiLSTM+Attention则把隐藏层设为128维,双向拼接后过一层注意力,对每个时间步的隐状态做加权求和,再接全连接输出。这个模型能抓“虽然动力肉,但是省油”这种跨位置的转折关系,恰好补上TextCNN的短板。两个模型在特征提取方式上有本质差异,融合才有价值。如果用两个结构几乎一样的模型做融合,效果不会比单模型好多少。
3.3 词向量与手工特征的配合
词向量是我们用比赛数据自己训练的,用的是gensim里的Word2Vec,维度100,窗口5,min_count设1,采用Skip-gram。为什么不直接用网上公开的中文词向量?因为“推背感”“顿挫”“胎噪”这类汽车领域词汇,在通用语料里几乎没有靠谱的表示,必须用领域语料重新训练。
embedding层在训练时还要注意设置策略。我的做法是先用固定embedding跑几个epoch,等模型基本收敛后再放开微调,学习率调小一些。如果一上来就放开维度,在样本量不足的前提下特别容易过拟合。另外,模型输入里我还拼了一组手工特征:评论长度、感叹号数量、否定词数量、正负情感词典命中数。这部分带来的提升很小,但这类小细节攒多了,就是名次差距。
4. 训练、融合与后处理:把分数抠出来
4.1 五折训练和早停的实操细节
每个模型都做五折交叉验证,每折训练流程包括:Adam优化器,学习率1e-3;batch_size设32,输入长度max_len设64;验证集F1连续3轮不上升就早停,并恢复最优权重;学习率在验证指标不升时乘以0.5做衰减。
有个细节值得单独强调:训练损失是BCEWithLogitsLoss,而评估指标是macro-F1,两者并不完全同步。所以我每个epoch除了记录loss,还会在验证集上计算一版macro-F1,并按F1来决定是否保存模型和早停。如果只看loss,经常会出现loss还在下降、F1已经开始退化的情况,尤其在训练后期。训练参数汇总如下:
| 参数 | TextCNN | BiLSTM+Attention |
|---|---|---|
| 词向量维度 | 100 | 100 |
| 隐藏层维度 | 无 | 128(双向) |
| 卷积核尺寸 | 1,2,3,4,5 | 无 |
| dropout | 0.5 | 0.5 |
| batch_size | 32 | 32 |
| max_len | 64 | 64 |
| 学习率 | 1e-3 | 1e-3 |
| 早停轮数 | 3 | 3 |
4.2 概率融合:加权平均比stacking稳
融合策略我试过三种:概率平均、加权平均、stacking。概率平均就是把两个模型的18维输出概率直接取平均;加权平均是在验证集上做网格搜索,给两个模型分配不同权重;stacking则是把两个模型的概率当特征,再训练一个LR做最终分类。
实测结果是加权平均效果最好,我在验证集上搜索后采用TextCNN权重0.6、BiLSTM+Attention权重0.4,融合后比单模型最好成绩高出约0.015个宏F1。stacking在小样本、只有两个基模型的情况下很容易过拟合,meta-learner学到的往往是验证集噪声,并不推荐。融合前要先确认两个单模型各自的验证F1确实收敛了,如果有个模型明显较弱,权重不要一刀切,用搜索结果说话。
4.3 后处理:阈值、全零输出与标签相关性
模型输出的概率不能直接用0.5当阈值,因为不同标签的概率分布差异很大。我的做法是在每一折的验证集上,对每个标签分别搜索0.25到0.75范围内的最优阈值,然后把五折结果平均,作为最终测试阈值。核心代码逻辑是这样的:
from sklearn.metrics import f1_score import numpy as np best_thresholds = [] for i in range(n_labels): scores = val_probs[:, i] y_true = val_labels[:, i] best_t, best_f1 = 0.5, 0.0 for t in np.arange(0.25, 0.75, 0.01): pred = (scores >= t).astype(int) f1 = f1_score(y_true, pred, zero_division=0) if f1 > best_f1: best_t, best_f1 = t, f1 best_thresholds.append(best_t)如果某个标签的验证样本太少,搜索出来的阈值可能过拟合,这时就把它拉回0.5,或者对五折阈值做截尾平均处理。
另外,多标签输出的一个典型问题是全零预测。某条样本所有标签的概率都低于阈值时,直接输出空集合会损失分数。我增加了兜底逻辑:取概率最高的前两个标签作为输出,前提是最高概率大于0.3;否则仍然输出空集合。后者常出现在那些表达明显但模型没把握的样本上,与其猜错不如放弃。
标签共现关系也可以用来修正。汽车评论里“空间”和“舒适”经常同时出现,“动力”和“操控”高度相关。我在验证集上统计共现概率,当模型预测出“动力-正”但“操控-正”的概率排在前五且超过0.2时,就把它补齐。反向操作也可以:某个标签总是伴随另一个标签出现,但当前样本没预测出来,按统计概率做强补或弱化。这种规则每加一条都要在验证集上确认有正向收益,贪多反而会过拟合。
5. 常见问题与踩坑记录
5.1 数据泄露让验证分虚高
多标签分类最容易犯的错是:交叉验证随机打乱样本时,没有考虑同一来源数据可能同时出现在训练集和验证集里。测试集和训练集在数据分布上高度相似,如果不去重,验证分数会比线上高出一大截。
我当时的做法是:对评论文本做精确去重和近似去重。精确去重很好理解,近似去重用的是shingles相似度,把评论切成n-gram片段集合,再算Jaccard相似度,超过0.8就视为重复。这一步做完,验证集分数和线上的差距明显缩小。以后做任何NLP比赛,数据清洗阶段就要把去重放进流程,别等到调完模型才发现分数虚高。
5.2 长尾标签的伪标签策略
“安全”“操控”这类主题的标注样本很少,深度学习模型很容易欠拟合。我当时尝试了伪标签:先用全量训练数据训练一个TextCNN,对测试集做预测;挑选置信度超过0.9的测试样本加入训练集。这里有个细节,置信度要按标签逐个判断,而不是整条样本判断。因为一条文本可能包含了多个主题,部分标签置信度高、部分标签置信度低,整条打标签会引入噪声。
伪标签样本也不是加进去就不管了,我只让它参与前几个epoch的训练,后面几轮再剔除。如果全程混在一起训练,模型会逐渐强化自己的错误预测,产生记忆偏差。这个策略给长尾标签宏F1带来了五六个点的提升,但对高频标签几乎没有帮助。如果你想尝试,建议每一轮先预测、再动态筛选置信度最高的那部分样本加入,效果更稳。
5.3 阈值搜索别在单折上做
按类别调阈值能在验证集上提升F1,但如果只在一折验证集上搜索,然后直接用到全量测试集,很容易过拟合到那一折的噪声上。我当时是五折每折都搜一遍阈值,然后对五折结果取平均。同时限制阈值的搜索范围,只允许在0.25到0.7之间浮动,避免出现某个类别阈值被调到极端值的情况。
这个细节看似不起眼,实际上对最终名次是有影响的。类别阈值越贴合验证集真实分布,宏F1就越稳。如果某个类别的五折阈值方差特别大,说明这个类别的样本量太少或者特征不稳定,这时候不要硬用搜索出来的阈值,回到0.5附近反而更安全。
再说点题外话。第7名的成绩放到今天的NLP环境里其实不算什么,但2018年那会儿没有大规模可用的预训练模型,大家能拼的就是工程细节和对数据的敏感度。我每次复盘都在想,如果当时把伪标签做得更精细一点,或者更早开始尝试用简单方式引入外部语料的词向量,排名应该还能再往前挤一挤。竞赛就是这样,高分从来不是靠某一个“神奇模型”砸出来的,而是靠把数据、特征、模型、融合、后处理每一个环节都往前推进一小步。这篇复盘的每一步,都是我或者队友在验证集上反复对比后留下来的结论,照着走至少能避开我们踩过的那些坑。
本文还有配套的精品资源,点击获取