新闻文本分类实践:朴素贝叶斯与BERT软投票集成全解析
简介一套已获老师指导并通过的机器学习课程设计项目聚焦基于BERT与朴素贝叶斯算法的新闻文本分类任务包含完整源码和数据集适合需要完成期末大作业或课程设计的本科生也适合刚接触NLP的初学者参考完整实现流程。压缩包共23个文件大小约67.39MB核心包含8个Jupyter Notebook分别用于数据预处理、BERT模型训练、朴素贝叶斯分类及结果预测等环节另附Python脚本、训练/测试数据集、实验报告和说明文档方便对照复现。目前已有542人学习下载。资源提供了两种算法从数据拆分到结果对比的完整流程并包含过程日志、分类结果文本等中间输出可帮助理解不同模型在新闻分类上的表现差异。即使是刚接触机器学习的新手也能按步骤复现实验并用于课程汇报大幅节省整理数据和调通环境的时间。1. 新闻文本分类双路线BERT 和朴素贝叶斯为什么能同框做机器学习大作业的人多半都栽过新闻分类这个题单用朴素贝叶斯调参调到吐也就 88% 上下的准确率单上 BERT 呢训练时间翻好几倍还动不动显存不够。这个项目给了一条绕过单模型短板的路线——TF-IDF 加多项式朴素贝叶斯做基线预训练 BERT 做主力最后用软投票把两边的概率叠起来。整套源码和数据集是打包好的从数据清洗到分类报告全都有。实测在自带的 10 类新闻数据集上测试集准确率能稳定爬过 95 分线。适合正在做机器学习大作业、课程设计或毕设的人也适合想在同一批数据上对比朴素贝叶斯和 BERT 各自天花板在哪的从业者。2. 数据准备新闻清洗、分词、标签映射的三个关键动作2.1 数据集结构先摸清格式、类别和长度分布拿到项目里的数据集先别急着训练。我一般会先跑一段 pandas 把文件读进来确认三件事字段结构、类别数量、文本长度分布。新闻分类数据集最常见的组织方式是 tsv 或 csv一列标签一列正文标签多数是中文比如财经、科技、体育、娱乐、时政、房产、汽车、教育、游戏、家居这一类具体到项目里是 10 类。先看 shape再看 value_counts能暴露不少隐患是不是某个类别只有几百条是不是标签文本里有不可见空格。这两点在朴素贝叶斯那边不致命但对 BERT 的 loss 影响很大后面讲避坑的时候会再提到。探查代码照抄就行import pandas as pd # 读取 tsv 格式新闻数据两列label、text df pd.read_csv(news_dataset.tsv, sep\t, headerNone, names[label, text]) print(df.shape) print(df[label].value_counts()) # 文本长度分布决定 BERT 的 max_len 后续怎么设 lengths df[text].str.len() print(lengths.describe(percentiles[0.5, 0.8, 0.9, 0.99]))sep\t 对应 tsv 文件如果是 csv 就把分隔符改成逗号headerNone 加 names 是防止文件第一行没有表头却默认被当成列名。lengths.describe 那步很关键BERT 的 max_len 不要拍脑袋定要看训练集文本的 90 分位长度。比如分位值显示 90% 的新闻在 90 到 160 字之间那 max_len 取 128 或 256 就是个有依据的决定而不是随便填的。2.2 文本清洗与分词正则、停用词和 jieba 的参数爬下来的新闻正文通常带着 HTML 残留、网页签名、电话号码和一堆半角标点。这些噪声对朴素贝叶斯的伤害最直接词表被无意义 token 撑大TF-IDF 权重的分母被污染对 BERT 没那么致命但也会拖慢训练占掉 max_len 的宝贵空间。所以清洗这步不能省。我在这个项目里的清洗函数是这么写的import re import jieba # 项目里自带一份 stopwords.txt也可以自己积累常见中文停用词 stopwords set() for line in open(stopwords.txt, encodingutf-8): stopwords.add(line.strip()) def clean_text(text): if not isinstance(text, str): return text re.sub(r[^], , text) # 去 HTML 标签 text re.sub(rhttps?://\S|www\.\S, , text) # 去 URL text re.sub(r\d, , text) # 去纯数字 text re.sub(r[^\u4e00-\u9fa5A-Za-z], , text) # 只留中英文和常规字符 return text def tokenize(text): return [w for w in jieba.lcut(clean_text(text)) if w not in stopwords and len(w) 1]len(w) 1 把单字过滤掉像“的、是、在、了”这类单字留着只会稀释条件概率新闻分类任务里把这些单字切掉基本不影响语义判断。jieba.lcut 默认是精确模式严格复现时不要额外开 HMM否则一些生造的人名会被切出奇怪组合。要注意的一点是如果数据里存在大量空格或换行残留必须先处理空白字符再交给 jieba否则分词质量会明显下滑。这套清洗函数同时服务于两个模型BERT 是字级切分不需要 jieba但 clean_text 里的去 HTML、去 URL 对 BERT 同样有效朴素贝叶斯则必须走完整的 tokenize把中文切成词再进 TF-IDF。2.3 标签映射和数据集划分stratify 防类别漂移朴素贝叶斯和 BERT 必须在同一个 label2id 上做标签映射这是集成阶段最容易翻车的地方。项目里的做法是把中文标签排序后转成整数 id训练集和测试集都走同一张映射表。这一步不统一后面软投票时两个模型的概率列根本对不上报错都看不懂。from sklearn.model_selection import train_test_split labels df[label].str.strip() label2id {label: i for i, label in enumerate(sorted(labels.unique()))} id2label {i: label for label, i in label2id.items()} df[label_id] labels.map(label2id) # stratify 保证测试集类别比例与全集一致 X df[text].values y df[label_id].values X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) print({id2label[i]: (y_train i).sum() for i in range(10)})stratifyy 可以按标签比例做分层抽样避免测试集里财经类特别多、恰好模型在财经上又表现好导致分数虚胖。random_state 固定成 42是为了后续两个模型的对比结果可复现。当类别分布略有不平衡时更不要直接 sample 或 shuffle 后乱切那会让某个比例小的类别在测试集里彻底消失macro F1 直接崩。3. 朴素贝叶斯实现TF-IDF 特征 多项式模型参数与踩坑3.1 为什么先上朴素贝叶斯它贡献的不只是分数这个项目先把朴素贝叶斯跑通一个完整基线有两个务实理由。第一新闻文本是超高维、稀疏的词袋特征朴素贝叶斯的条件独立性假设在这种场景下反而被“平均化”了实际表现比很多线性模型更稳多项式变体对词频的建模又天然契合 TF-IDF。第二一份 2 万维 TF-IDF 矩阵朴素贝叶斯全量训练只要几十秒你换清洗策略、换特征参数、重跑评估的代价几乎为零。这对课程项目特别重要因为你花很少的时间就能验证整套数据清洗、标签映射、评估代码都没问题然后再把这套干净的管线无缝交给 BERT。还有一个长远理由朴素贝叶斯输出的后验概率、BERT softmax 输出的概率分布两者天然可以做软投票。如果这里图省事换成 SVM 或逻辑回归集成时概率校准就多出一层麻烦。3.2 特征工程TF-IDF 参数怎么设直接拿原始文本过 TfidfVectorizer 是不可行的。中文不做分词的话词表会变成一堆单字和二字碎片指标很难看。正确做法是把上面的 tokenize 函数作为 tokenizer 参数传进去。下面是我在这套数据上常用的参数组合from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer( tokenizertokenize, max_features20000, ngram_range(1, 2), min_df2, sublinear_tfTrue ) X_train_tfidf vectorizer.fit_transform(X_train) X_test_tfidf vectorizer.transform(X_test) print(X_train_tfidf.shape)max_features20000 把词表压缩到 2 万保留的是 TF-IDF 权重最高的那部分既控维度又滤噪ngram_range(1, 2) 同时保留单词和相邻两词组合像“人工智能”“市场监管”这类双字词不会被拆散。min_df2 表示词至少要在 2 篇文档里出现才入词表单篇出现的错别字、人名会被过滤掉。sublinear_tfTrue 对词频做 1log(tf) 变换削弱高频词对权重的垄断这个设置对新闻文本的效果很明显。测试集必须只调 transform不能 fit_transform。词表只能由训练集决定测试集一旦参与构建词表相当于信息泄漏分类报告会虚高答辩时被老师一问就露馅。3.3 训练与评估MultinomialNB 的参数和可读报告from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import accuracy_score, classification_report nb_model MultinomialNB(alpha0.5, fit_priorTrue) nb_model.fit(X_train_tfidf, y_train) y_pred_nb nb_model.predict(X_test_tfidf) y_proba_nb nb_model.predict_proba(X_test_tfidf) print(NB acc:, accuracy_score(y_test, y_pred_nb)) print(classification_report(y_test, y_pred_nb, target_names[id2label[i] for i in range(10)]))alpha 是拉普拉斯平滑系数。alpha 越大平滑越强对罕见词的惩罚越小alpha0.5 在这套数据上比默认的 1.0 略好因为 TF-IDF 特征已偏连续值适当削弱平滑反而更贴合真实分布。fit_priorTrue 表示从训练集学习类别先验 P(c)如果数据类别不平衡这个参数不要关。打印 classification_report 时我一般更看 macro avg 这一行很多课程评分用 macro F1 而不是准确率比 accuracy 严格得多。项目里朴素贝叶斯单模型在测试集上大约在 86% ~ 89% 之间浮动具体取决于随机种子和清洗强度。4. BERT 微调实现tokenizer、训练参数与推理流程4.1 选哪个中文预训练模型中文 BERT 常用两个来源原版 bert-base-chinese 和哈工大讯飞的全词掩蔽版本 hfl/chinese-bert-wwm。这个项目用的是 bert-base-chinese理由很实在HuggingFace 直接能拉不依赖额外配置对 10 类新闻短文本全词掩蔽带来的增益有限。如果你要在无网络环境复现就在有网机器上先拉到本地把权重目录拷过去from_pretrained 传本地路径一样能加载。模型本体用 BertForSequenceClassificationnum_labels10类别数直接对接 label2id 的规模。这一步如果 num_labels 和标签数量对不上后面 loss 会表现得很诡异这个坑我放在下一章专门讲。4.2 构造 tokenizer 输入padding、truncation、max_lenfrom transformers import BertTokenizer, BertForSequenceClassification tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels10 ) model.train() MAX_LEN 128 def encode(texts): return tokenizer( list(texts), paddingmax_length, truncationTrue, max_lengthMAX_LEN, return_tensorspt )tokenizer 返回的 dict 里有 input_ids、token_type_ids、attention_mask 三个键。paddingmax_length 会把短文本补齐到 MAX_LENtruncationTrue 超长就截断两者共同保证同 batch 的张量形状一致。MAX_LEN 取 128 的依据在第 2.1 节已经看过绝大多数新闻正文的长度分位点在 100 到 200 字之间BERT 按字切分128 个 token 能覆盖大部分样本的高频区间如果某条新闻被截得严重可以换成 256 试一遍代价是训练时间翻一倍。input_ids 里的 0 对应 [PAD]attention_mask 里相同位置是 0模型计算注意力时能自动跳过。这套输入不复杂但漏掉 attention_mask 是新手最常见的报错点。4.3 训练循环learning rate、batch size、epochs 怎么配这里用 PyTorch 手动写训练循环刻意不用 Trainer因为大作业答辩时老师大概率会问你在循环里改过哪些参数。手动写一遍每个参数你都能解释为什么这样设。import torch from torch.utils.data import DataLoader, TensorDataset train_enc encode(X_train.tolist()) train_labels torch.tensor(y_train, dtypetorch.long) train_ds TensorDataset(train_enc[input_ids], train_enc[attention_mask], train_labels) train_loader DataLoader(train_ds, batch_size32, shuffleTrue) optimizer torch.optim.AdamW(model.parameters(), lr2e-5) total_steps len(train_loader) * 3 from transformers import get_linear_schedule_with_warmup scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) for epoch in range(3): for step, batch in enumerate(train_loader): input_ids, attention_mask, labels batch outputs model(input_idsinput_ids, attention_maskattention_mask, labelslabels) loss outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() if step % 100 0: print(fepoch {epoch} step {step} loss {loss.item():.4f})lr2e-5 是中文 BERT 微调的稳妥起点调到 5e-5 也能训练loss 前期降得快但后期容易震荡集成时概率分布反而没那么稳。batch_size32 是 16G 显存能舒服跑的量如果只有 8G 显存改成 16并在循环里加梯度累积两步效果等价于 batch 32。clip_grad_norm_ 设 1.0 是 BERT 微调的基本操作不裁剪的话边训练边盯 loss偶尔会看到尖峰然后整个评估分数掉 2 个点。warmup 步数设为总步数的 10%前几百步用小学习率热身避免训练开局就踩进坏区域。epochs 取 3 就好再往上练BERT 会开始记住训练集里的表述习惯验证分数反而可能回落 0.5 个点。训练结束后保存模型和 tokenizer之后加载就不需要再联网model.save_pretrained(./bert_news) tokenizer.save_pretrained(./bert_news)5. 对比、集成与避坑软投票权重和五个高频报错排查5.1 结果对比准确率、macro F1、单条推理耗时先看两个单模型的差距再决定集成策略。下表是我在这套 10 类新闻数据上跑出来的大致结果只做参考换随机种子会有小幅波动模型测试集准确率macro F1单条推理耗时TF-IDF MultinomialNB0.8760.8711ms CPUBERT fine-tuned0.9520.949~15ms GPU软投票集成0.9570.955~20ms GPUBERT 比朴素贝叶斯高出八个点但集成不是白加在易混淆类别上比如娱乐和体育、科技和财经两个模型的错误往往不重叠。软投票能把两边概率都偏低、但朴素贝叶斯略占优的样本救回来。推理耗时也要注意朴素贝叶斯在 CPU 上是毫秒级BERT 必须有 GPU 才谈得上 15ms如果要做一个实时 demo纯 CPU 环境里朴素贝叶斯反而是保底方案。5.2 软投票怎么实现概率加权而不是标签投票硬投票让两个模型各投一票出现分歧时只能随机信息损失很大。软投票把概率加权求和实现只有几行但对最终分数提升很有效。项目里给了一个调权重的小技巧先在验证集上把朴素贝叶斯权重从 0.1 扫到 0.4步长 0.05剩下的权重给 BERT取准确率最高的组合。结果通常落在 NB 0.2 ~ 0.3 / BERT 0.7 ~ 0.8 区间。import numpy as np # y_proba_nb 来自 NB 的 predict_probabert_proba 来自 forward 的 logits softmax bert_pred [] model.eval() for batch in test_loader: with torch.no_grad(): outputs model(input_idsbatch[0], attention_maskbatch[1]) proba torch.softmax(outputs.logits, dim-1).numpy() bert_pred.append(proba) bert_proba np.concatenate(bert_pred, axis0) w_nb, w_bert 0.3, 0.7 final_proba w_nb * y_proba_nb w_bert * bert_proba final_pred final_proba.argmax(axis1) print(ensemble acc:, accuracy_score(y_test, final_pred))两个概率分布必须在同一个类别顺序上对齐这就是前面一直强调 label2id 统一的原因。还有一个细节朴素贝叶斯的 predict_proba 是直接算出来的后验概率数值上比 BERT 的 softmax 更有区分度所以给它的权重反而要低一些用来平衡 BERT 在易混类上的过度自信。5.3 避坑五个高频报错与排查第一条BERT 训练 loss 卡在 0.3 左右不动。现象是前 200 步 loss 降得很快然后卡住准确率上不去。最常见原因是 num_labels 和 label2id 对不上数据里有 10 个类label2id 却生成了 11 个 id或者某个 label_id 超过了 num_labels模型输出维度错位。排查方法很简单打印 y 的 min、max 和 len(set(y))确认整数范围确实在 0~9 内。另一个常见原因是 MAX_LEN 设太小大部分新闻被截成残句语义信息丢失loss 自然下不去。解决按文本长度分布定 MAX_LEN不要拍脑袋。第二条朴素贝叶斯准确率只有 70% 多。现象是没清洗没分词直接 vectorizer跑出来惨不忍睹。原因是词表里全是标点和停用词TF-IDF 权重被网页噪声污染。解决用 2.2 节的 clean_text 加 tokenize 走完整清洗再把 min_df 设成 2。如果补上清洗还是低检查标签里是不是有空格比如“财经 ”和“财经”被当成两个类测试集上自然来回错。第三条集成时形状对不上。现象是 np.concatenate 报维度错误或 final_proba 长度和测试集不一致。原因是 BERT 推理时 DataLoader 忘了关 shuffle预测顺序被打乱或者朴素贝叶斯那边拿的是另一份 train_test_split 结果。解决推理阶段 DataLoader 强制 shuffleFalse两个模型都用同一个 X_test 数组保险起见按样本 id 对齐再算指标。第四条显存 OOM 或训练特别慢。现象是 8G 显存直接 CUDA out of memory。原因是 max_len 太大、batch_size 太大或者 GPU 是多人共享的。解决batch_size 降到 8~16MAX_LEN 降到 64然后加梯度累积准确率损失基本可忽略再不行就在每个 epoch 后调用 torch.cuda.empty_cache() 清一次缓存。第五条tokenizer 返回的内容模型不认。现象是 forward 报 TypeError说收到了不认识的 keyword argument。原因是把 tokenizer 输出 dict 里的键拆开传错了或者漏了 attention_mask。解决最简单的方式是 outputs model(**inputs, labelslabels)让模型自己按参数名解析inputs 直接用 encode 的返回值不要手动拼。6. 从 85 分到 95 分验证手法与两个压分技巧6.1 小样本冒烟测试先跑通再跑全我拿到任何新数据集永远先取 500 条训练、200 条测试跑一遍全流程确认 tokenizer、标签映射、DataLoader 三层都不出错再开全量训练。全量 BERT 要跑 20 到 40 分钟冒烟测试只要几分钟能拦下 90% 的低级错误。NB 那边看 TF-IDF shape 是否符合预期BERT 那边看 loss 是否在下降两条路都通了再放全量。df_smoke df.sample(700, random_state42).reset_index(dropTrue) X_smoke df_smoke[text].values y_smoke df_smoke[label_id].values # 两个模型都拿这份数据完整跑一遍训练和评估确认管线通了再上全量6.2 压分技巧一是易混淆类别的人工规则后处理从混淆矩阵看最容易互混的是娱乐和体育、科技和财经。常见做法是给这两对类别加一个关键词补偿规则如果 BERT 给娱乐和体育的概率都在 0.3 上下而文本里出现“转会、进球、比分”这类体育强信号词就把体育概率上调 0.05。这个技巧写进大作业报告很加分因为答辩老师能看到你不只是在调参而是真正做了误差分析。6.3 压分技巧二是置信度阈值另一个有效做法是拒绝预测低置信度样本只统计置信度大于 0.8 的测试样本准确率能到 96.5% 以上但覆盖度会下降 3% 左右。这招对答辩很实用前提是你主动说明分数是靠阈值提上来的而不是删数据作弊。confidence final_proba.max(axis1) mask confidence 0.8 acc_high_conf accuracy_score(y_test[mask], final_pred[mask]) cover_rate mask.mean() print(f高置信度准确率: {acc_high_conf:.4f}, 覆盖度: {cover_rate:.3f})从那以后我每次做文本分类项目都会强制走一遍小样本冒烟、类别分布检查、集成前概率对齐三步做完再谈调参希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →