新闻文本分类这事儿,很多刚开始碰NLP的朋友都会把它当成第一道正式练习题。去年我在某云赛事平台上完整跑过一场新闻文本分类赛,从最朴素的 TF-IDF 加逻辑回归,一路做到 BERT 微调加模型融合,前后折腾了差不多两周。回过头看,这个赛题覆盖的面特别全:文本表示、分类模型、调参策略、交叉验证、融合技巧全都能练到,而且数据规模不大,单卡就能跑,特别适合用来建立完整的 NLP 实战流程。
这篇就把我当时的学习实践过程完整复盘一遍。不光是贴代码,更多是想说清楚每一步为什么这么选、踩过哪些坑、分数卡住时该怎么排查。如果你正打算拿这种比赛入门 NLP,或者已经在跑但分数上不去,这篇应该能帮你省下不少试错的时间。
1. 先把赛题拆明白再动手
1.1 赛题到底在考什么
这类新闻文本分类赛,任务描述通常很简单:给一段脱敏后的新闻文本,预测它属于哪个类别。别小看这句话,它背后藏着好几个关键信息。
先说说“脱敏”。为了保证比赛公平,官方一般不会直接给你可读的中文新闻原文,而是把每个词映射成一个数字编号,你拿到手的是一串用空格分隔的整数序列。这带来的直接影响是:分词、停用词、小写化这些常规文本预处理全都没法做,你面对的本质上是“词编号序列分类”问题。这条限制反而让思路变得干净——所有的特征和模型,都得建立在词编号的基础上。
再说“新闻文本”。新闻语料的特点是信息密度高、篇幅相对短、用词规范。这决定了文本长度分布比较集中,绝大多数样本在几十到几百个词之间,长尾很少。和你平时在开源社区看到的影评、商品评论分类任务比起来,新闻分类不需要处理超长文本依赖,所以很多针对长文档的模型(比如分层注意力网络)在这里属于杀鸡用牛刀。
最后是“分类”。这场赛事我记得有十多个类别,覆盖科技、体育、财经、娱乐、社会新闻这些常规板块。类别多带来两个实际问题:一是类别之间样本量天然不均衡,有些类别样本特别多,有些特别少;二是有些类别本身区分度不高,比如“社会”和“其他”这种边界模糊的类别,模型很容易搞混。这两个问题会直接决定你后面要不要做类别重加权、要不要针对少数类调阈值,都是拿分的关键。
1.2 评估指标:为什么大家都盯着 macro F1
比赛公告里最容易被新手忽略的就是评估指标。这类赛事基本都用 macro F1,也就是先算每个类别的 F1,再对所有类别取算术平均。这里面有个很反直觉的点:不是整体准确率高,macro F1 就高。
我举个例子。假设一共 14 个类,其中有一个类样本占比只有 2%,模型直接放弃这个类,全部预测成别的类。从准确率看可能还有 98% 以上,非常漂亮。但 macro F1 算的是每个类 F1 的平均值,那个被放弃的类别 F1 是 0,直接把整体 F1 拉低了一大截。
所以从一开始就要建立这个意识:这个比赛不是比“谁预测对的样本多”,而是比“谁在每个类别上都尽量均衡地强”。很多新手后期分数上不去,不是模型不行,而是策略上过度偏向高频类,少数类的表现被牺牲了。想通这一点,后面做类别权重、做数据增强、做阈值调整,心里就有谱了。
1.3 数据探索要先看哪几样东西
拿到数据先别急着训练,花半天时间把数据摸一遍,比盲目堆模型划算得多。我一般按这个顺序看:
标签分布一定要画出来。哪个类多、哪个类少、少到什么程度,直接决定你的 baseline 应该参考什么分数。如果某个类只占总样本的 1%,你心里就要有数,这个类最终 F1 大概率是全场最低,后续优化要重点关注它。
文本长度分布也要看。新闻文本多数不长,但一定有长尾。这里要判断一下:是把所有文本截断到某个固定长度,还是按长度分组处理。如果你后面要上 BERT,这个长度分布直接决定 max_len 的设置,设太短丢信息,设太长浪费显存和训练时间。
重复样本和噪声也要查。有些赛题数据里会有重复样本,训练集和测试集之间也可能存在同源样本。处理不及时,轻则浪费时间训练,重则出现数据泄漏,测试集分数虚高,一上榜单就被打回原形。这种事我在很多比赛里都见过,查重复这一步千万别省。
2. 第一个能上分的方案:TF-IDF 加逻辑回归
2.1 为什么先做词袋模型,而不是直接上深度学习
很多人一上来就抱着 BERT 跑,结果发现既没有足够的 GPU 显存,又搞不清楚训练多长时间,最后跑出来的效果还不如传统方法。我的建议永远是:先把词袋模型跑通,拿到一个靠谱的 baseline,再谈要不要升级模型。
词袋模型的核心思路很简单:把一段文本表示成词频向量。但直接数词频有个问题,像“新闻”“记者”“报道”这种高频词,几乎所有类里都出现,区分能力很弱。TF-IDF 就是给这种词降权用的,公式不复杂:TF 是词在文本中出现的次数,IDF 是 log(总文档数 / 包含该词的文档数 + 1)。如果一个词只在少数类里出现,IDF 就大,它能帮你把“这个词是这个小众类的标志”这个信号放大。
用脱敏后的词编号序列也同样适用,只不过“词”变成了“编号”。你可以把每条文本的编号序列拼成一个长字符串,直接用 TF-IDF 提取特征。这里的核心是 n-gram 设置,一般用 (1,2) 就够,也就是单词特征加相邻词对特征。相邻词对在新闻分类里很关键,因为很多类别的典型表达是两三个词的组合,比如金融类的“下跌 幅度”、体育类的“进球 得分”,单看一个词往往不够。
2.2 一套能直接抄的 baseline 代码
我建议大家都手动跑一遍这套流程,别直接复制,理解每一步在干什么。核心代码不长,scikit-learn 就能搞定。
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import f1_score # text是脱敏后的词编号序列,比如 "123 45 678 910" vectorizer = TfidfVectorizer( min_df=2, max_features=100000, ngram_range=(1, 2), ) X = vectorizer.fit_transform(train_texts) y = train_labels X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.1, random_state=42, stratify=y ) model = LogisticRegression(C=1.0, max_iter=200, solver="liblinear") model.fit(X_train, y_train) val_pred = model.predict(X_val) print(f1_score(y_val, val_pred, average="macro"))几个参数值得说。min_df=2 是过滤掉只在一条样本里出现的词,这类词基本是噪音,留着只会增大特征维度。max_features=100000 是控制特征总量,防止内存爆炸。C 是逻辑回归的正则化强度,默认 1.0 一般够用,但如果你发现验证集分数比训练集低很多,可以试着调小 C 加强正则化。
这套方案不用调什么参数,跑完 macro F1 通常能到 0.90 以上。具体数值取决于数据划分,但记住这个量级就行。它给你建立了第一个标准:后面所有深度模型,都得先超过这个分数才值得继续投入时间。
2.3 这个阶段最值得琢磨的事
跑通 baseline 之后,不要急着换模型。先回去看混淆矩阵,找出模型最容易搞混的是哪几个类。我当时遇到的情况是“科技”和“互联网”两个类互相误判特别严重,原因也好理解,新闻内容交叉太多,一篇讲智能硬件的稿子,你说它是科技还是互联网,人看了都犹豫,机器更难分清。这类信息比你盯着 F1 数字看半天有用得多,它告诉你后续优化的方向在哪:要么补充特征,要么换模型,要么针对易混类做专门的样本挑选。
还有一个容易被忽略的点:去查一下训练集和测试集的数据分布是否一致。有些比赛的测试集来自不同时间段,新闻热点已经变了,导致某个类的用词风格和训练集差异很大。这种情况用任何模型都难救,唯一的应对手段是训练时尽量保留特征的泛化性,不要过度依赖高频词的强信号。
3. 深度学习进阶:从 FastText 到 TextCNN
3.1 FastText:用五分钟拿到第二个分数
词袋模型的瓶颈在于完全丢失了词的顺序信息。FastText 解决了一部分问题,它在词向量的基础上,把整句话的向量加起来做平均,然后用一个线性分类器输出类别。你可能会觉得这跟词袋很像,但关键区别在于它用的是词嵌入,而不是稀疏的词频特征,而且它还支持词级别的 n-gram,能捕捉一部分局部词序信息。
FastText 的好处是训练极快,CPU 上几分钟就能跑完一轮。我当时用它就是为了验证一件事:用分布式词表示代替稀疏特征,分数能提升多少。结果大概比 TF-IDF 高 0.5 到 1 个点,不多,但这个实验非常有价值,它说明这类数据的任务难度主要不在词序,而在于语义表示。
代码基本上就是调库,但参数值得逐个看一遍:
import fasttext # 训练数据格式:每行 "__label__科技 123 45 678 910" model = fasttext.train_supervised( "train.txt", lr=0.5, epoch=20, wordNgrams=2, minCount=1, dim=100, loss="softmax", )wordNgrams=2 是让模型同时学习相邻词对,dim=100 是词向量维度,你可能会想是不是越大越好,实际不是。新闻文本本身不长,100 维足够用了,再大只增加训练时间,对最终 F1 帮助很小。epoch 别设太小,FastText 收敛比较慢,我建议至少 15 轮以上再看结果。
FastText 还有一招很实用:训练完可以看每个类的最重要 n-gram 权重,相当于用最简单的方式做了一次“模型可解释性”,帮你快速验证模型学到的特征是否符合直觉。
3.2 TextCNN:把卷积搬进文本处理
TextCNN 是我在这个比赛里第一次真正感受到“深度学习调参”乐趣的模型。它的原理不复杂:把词嵌入矩阵当成图像,用多个不同尺寸的卷积核同时扫描文本,每个尺寸的卷积核负责捕捉不同长度的局部模式。尺寸为 2 的核看相邻两个词的组合,尺寸为 3 和 4 的核看三个词、四个词组成的短语。
用生活化的类比来说,卷积核就像不同长度的“滑动窗口”,每个窗口在文本上扫过去,看有没有触发它关心的模式。窗口长度为 3 的卷积核会关注“人工智能 发展 迅速”这类三连词组合,窗口长度为 4 的核则会关注更长的表达。所有窗口扫完后,取最大值合并成一个向量,再丢进全连接层分类。
TextCNN 和 BERT 这种庞然大物比起来非常轻量,训练速度快,而且在小规模文本分类任务上效果很好。我当时标准的配置是:卷积核尺寸 2/3/4,每种尺寸 256 个卷积核,词向量维度 200,dropout 设为 0.5。
一个特别容易翻车的地方是优化器选择。新闻文本分类这种任务,Adam 几乎总是比 SGD 好用,学习率设在 0.001,但要注意配合学习率衰减,不然末期训练会一直震荡,验证集分数上上下下不稳。还有一个经验:embedding 层的词向量要不要微调,我建议一开始就微调,不要用预训练固定向量。因为脱敏数据里的词编号和预训练词向量根本对不上,你不微调等于完全没学到信息。
3.3 深度学习模型的三条实战纪律
跑深度学习模型,我总结出三条纪律,每一条都是拿分数换来的。
第一条是固定随机种子。新闻文本分类数据量不算大,模型初始化对结果影响非常大。同一个模型,同一个超参,两次训练可能差 0.5 个点。你不固定种子,连自己都分不清指标变化是因为调参有效还是运气好。所有模型训练前,把 Python 的 random、NumPy、PyTorch 的种子全固定住,这是底线操作。
第二条是少用 epoch,多用早停。深度模型在几万条样本上,一般 3 到 5 个 epoch 就能收敛。很多人训练时习惯设 20 个 epoch,结果到第 8 轮就开始过拟合,验证集 F1 持续下滑。我后面的做法是配合早停:每个 epoch 结束看验证集 F1,连续 2 轮不涨就停掉,然后回滚到最佳 epoch 的模型。
第三条是控制 batch size 和梯度累积的关系。如果你的 GPU 显存不够大,batch size 就只能设小,比如 16。但 batch size 太小会导致梯度噪声大,训练不稳。解决办法是梯度累积:每 4 个小 batch 的梯度累加后再更新一次参数,等效于 batch size 为 64。这个技巧在 Transformer 系列模型上尤其常用,后面跑 BERT 时还会用到。
4. BERT 微调:预训练模型的降维打击
4.1 为什么 BERT 对短文本分类这么强
到了这个阶段,你已经有了一个能用的深度模型,分数大概在 0.93 到 0.94 附近。再往上走,拼的就是模型对语义理解的深度了。TextCNN 只能捕捉局部 n-gram 组合,但碰到那种“前面说好话,后面转折表达相反意思”的新闻标题,它是完全没办法的。BERT 这类预训练语言模型能处理这种复杂情况,因为它用大规模语料预训练过,对词汇之间的长距离依赖、上下文歧义、语义反转都有先验的理解。
具体到新闻文本分类场景,BERT 的做法是:把整段文本序列送进模型,每个词编码时同时看左右两边的上下文,这样“苹果”在科技新闻里会被理解成公司,在生鲜新闻里会被理解成水果。序列最前面加上一个特殊的符号,叫 CLS,它最后对应的向量被视为整段文本的语义表示,直接接一个分类层就能输出类别概率。这个结构天生适合分类任务,你基本不需要改模型结构,只需要微调最后的分类层和底层参数。
有人问,BERT 这么多层,为什么微调还是有效?道理跟“一个已经读过很多书的人,只需要简单培训就能适应新工作”类似。它的底层已经学到了通用语法和语义知识,你给它看几万条新闻的样本,它只需要微调高层,就能快速适配新闻分类这个具体任务。
4.2 我最推荐的微调配置
基于实战经验,我给自己定了一套默认配置,基本不用大变就能在新闻文本分类上拿到不错的结果:
- 预训练模型选中文 BERT base,如果你的显存够,Roberta 系列一般会再高一点
- max_len 设为 64 或 128,具体看你的文本长度分布中位数,短文本任务设 128 已经非常富裕
- batch size 16 或 32,用梯度累积补足
- 学习率 2e-5 到 3e-5,BERT 微调最忌讳用大学习率
- 训练 3 到 5 个 epoch,配合早停
- 使用 FP16 混合精度训练,速度和显存都能优化
这套配置跑下来,macro F1 一般能在 TextCNN 的基础上再涨 1 到 2 个点,到 0.95 以上是很常见的。我用 Hugging Face 的 Trainer 来做微调,自己定义好数据集和评估函数,训练代码很短,跑起来也省心。
有个细节很多新手不知道:学习率是最影响 BERT 微调效果的超参。你设 2e-5 和设 1e-4,验证集分数能差一个点以上。如果你发现模型在训练集上收敛特别快但验证集不动,大概率就是学习率设大了,它的参数已经朝训练集过度拟合的方向跑偏了。还有,要根据 batch size 调整学习率,batch size 越大,学习率可以相应调大一些,比如 batch 32 时设 3e-5,batch 16 时设 2e-5。
4.3 数据增强与伪标签:没有更多数据就自己造
BET 微调模型对数据量的需求明显比 TextCNN 大。几万条样本够用,但如果你想从 0.95 再往上突破,数据量就是瓶颈。这时候有两个手段可以试。
第一个是文本回译增强。把新闻文本翻译成另一种语言,再翻译回中文,得到语义相同但表达不同的新样本。这个手段在可读文本上效果好,但脱敏数据用不了,因为你拿到的本来就是词编号序列。如果你的赛题是提供可读文本,这个方案值得一试。训练数据的多样性提升后,模型泛化能力会明显增强,但要注意别把增强样本做得太离谱,语义偏差太大的数据反而会伤害模型。
第二个是伪标签。用当前最优模型对测试集做预测,挑出置信度最高的那批样本,把它们当作额外的训练数据,再训练一轮。这里面的分寸很难拿捏,如果置信度阈值设低了,错误标签会污染训练集。我建议保守一点,只选概率大于 0.9 的样本,而且每轮最多加一小批,训练完继续挑新的,迭代几轮。一个替代方案是把伪标签样本在损失函数里的权重降低,比如设到 0.5,这样即使个别标签是错的,影响也有限。
5. 交叉验证与模型融合,冲刺阶段的核心武器
5.1 单次划分不靠谱,5 折交叉验证才是标准答案
比赛打到后面,你会发现一个很头疼的问题:每次跑出来的验证集分数都不一样,有的方案看着验证集涨了 0.1,提交上去反而跌了。原因很简单,你只切了一次训练验证集,这一次划分的运气成分很大。新闻数据集样本分布本来就不均匀,某些类样本少,切到验证集里的更少,指标自然波动剧烈。
标准做法是 5 折交叉验证。把训练数据分成 5 份,每次用 4 份训练、1 份验证,轮流 5 次,得到 5 个模型。这样每个样本都能当一次验证数据,训练也更充分。最终你拿到的不是单次分数,而是 5 次分数的平均,这个平均值比任何单次划分都更接近真实水平。
交叉验证还有一个巨大好处:它能直接用来做融合。每个折的模型可以对测试集预测一次,得到 5 份测试集预测结果,取平均就是最终的预测概率。这个过程也叫 OOF 预测,效果通常比单独一个模型的预测稳定很多,因为 5 个模型学到的侧重点不同,平均之后能抵消一部分各自的偏差。
5.2 模型融合的核心逻辑:观点不同才能互补
模型融合不是简单地把分数高的模型加进来就一定更好,核心在于模型的差异性。你拿 5 个结构一样、数据一样、种子一样的 BERT 模型来融合,等于投票给同一个千篇一律的观点,收益几乎为零。真正有效的融合,是让不同模型的错误尽量不重叠。
我那次比赛的融合组合是:BERT 系列模型作为主力,加一个 TextCNN 作辅助。原理在于 BERT 擅长捕捉长距离语义,TextCNN 擅长捕捉局部关键词组合,两者在特征层面天然互补。融合时我给 BERT 更高的权重,因为它的单独表现更好,但不至于完全忽略 TextCNN 的意见。最简单的做法是概率加权平均:
final_prob = 0.7 * bert_prob + 0.3 * textcnn_prob权重不需要太精细,可以从 0.6/0.4 开始,在验证集上试几组。如果你有 3 个模型,也可以先两两融合,观察哪个组合增益最大,再把增益大的组合作为新的“基模型”继续融合。还有一点提醒:融合的提升幅度通常是递减的,从 0.95 到 0.96 容易,从 0.96 到 0.965 就是苦苦挣扎了,这个阶段别把时间都耗在融合权重上,性价比不高。
5.3 从 0.95 到 0.97 的最后一个窍门
到冲刺阶段,我做过一件当时觉得不起眼、后来发现很有用的事:针对易混淆类别做阈值调整。模型默认是取概率最大的类作为预测结果,但在某些易混对上,比如科技和互联网,模型给出的概率其实很接近,比如 0.4 和 0.35。这种情况下硬选最大概率,运气成分很大。
我的做法是在验证集上专门分析这些易混对的预测概率分布,找到分界点,然后微调判定规则。比如某个阈值下,把互联网类判定成科技类的错误特别多,那就适当提高科技类的判定权重。这个操作听起来很人工,但在比赛里确实管用,而且每一步都有验证集数据支撑,不是凭空瞎调。只是要小心别过度拟合验证集,所有阈值调整都要回到交叉验证的整体结果里确认。
另一个窍门是集成的多样性再挖掘。我当时把同一个 BERT 模型分别用 64 和 128 的 max_len 训练了两个版本,长版本能保留更多上下文信息,短版本更聚焦重点区域,两者预测结构差异还挺明显的,融合起来效果比想象中好。这种“留长度差异”的融合思路,在很多比赛里都适用,值得记在小本子上。
6. 实战复盘:我踩过的坑和总结的经验
6.1 训练不收敛、过拟合、显存溢出,一次说清
训练中常见的几个问题,我把排查思路整理出来,方便你直接对照查。
训练 loss 不下降,先看学习率。学习率设太大会导致 loss 震荡甚至爆炸,设太小则下降慢得像蜗牛。文本分类任务里,非预训练模型从 0.001 开始调,BERT 从 2e-5 开始调,一般没错。
过拟合的表现是训练集 F1 持续上升,验证集却停止增长甚至回落。应对手段按优先级排序:加 dropout、早停、减少模型容量、增加数据增强。不要一上来就用正则化系数硬拉,先让模型别学得那么满。
显存溢出分两种。一种是报错 OOM,直接把 batch size 调小,或者开梯度累积。另一种是没报错但训练特别慢,这种大概率是显存碎片化,可以把数据长度统一截断到更短的程度,或者开启混精度训练。新闻文本任务 max_len 设到 64 完全够用,很多人白白设成 256,白白浪费三倍显存和时间。
6.2 那些让人欲哭无泪的隐蔽坑
显存溢出、过拟合这类问题至少报错明显,最怕的是那种不报错、分数也正常,但结果被污染的情况。
数据泄漏是我见过最隐蔽的坑。有人发现验证集分数高得离谱,激动得上榜单一看,直接被判无效。原因就是测试集和训练集存在大量同源样本,当你把相似样本切到验证集时,模型等于“提前见过答案”。所有比赛开始前,把训练集和测试集合在一起查重,把重合样本处理掉,这步无论如何不能省。
还有一个隐蔽问题是用错分词器。脱敏数据本来没有分词问题,但如果你换用了可读文本的赛题,分词器会对结果产生很大影响。中文文本用英文分词器切出来的结果是灾难性的,那等于把语义切碎了再扔给模型,模型再强也学不到东西。
多折交叉验证结果差异特别大,也值得注意。这种情况说明数据分布不够稳定,或者某个类的样本实在太少。解决办法是检查是不是存在类别样本数少于 50 的类,如果是,这类样本在不同折里分配不均,结果自然忽高忽低。你可以考虑用分层抽样强制保证每个折里各类比例一致,或者干脆多做几折,比如 10 折,让少数类有更多机会出现在验证集里。
6.3 给准备上路的朋友的一点时间规划
最后说下时间安排。如果你是从零开始,我给个参考节奏:第一天把所有数据处理流程跑通,建立基线;第二天到第三天跑出 FastText 和 TextCNN,把深度模型的训练流程稳定下来;第四天到第六天集中调 BERT 微调,期间可以并行跑交叉验证;最后两天做融合和阈值调整,冲刺提交。这个节奏不是死的,但如果你的模型还没跑通 baseline 就直接掉进 BERT 调参的坑里,大概率会浪费大量时间。
我的个人体会是,这类比赛的分数提升遵循一个规律:70% 的收益来自对数据和赛题的深入理解,真正来自模型结构的创新其实很少。你不需要发明什么新模型,把 TF-IDF、TextCNN、BERT 这些常规武器吃透,每一步都搞清楚为什么有效、为什么失效,这个学习过程本身的价值,比最终那张榜单上的排名要大得多。
如果你正准备参加类似的 NLP 分类赛,记住我说的那个最不起眼的建议:先把种子固定住,把数据查重做干净,把训练和验证流程标准化。这些基础工作看起来很无聊,但它们决定了你后面每一次调参、每一次对比,到底是真的在进步,还是在一片混乱里原地打转。新闻文本分类是 NLP 入门最好的练兵场,跑通一遍,你对整个领域的理解会和只看教程时完全不一样。