简介:面向机器学习课程设计与期末大作业场景,这套基于BERT与朴素贝叶斯算法的新闻文本分类项目,提供了从数据预处理、特征工程到模型训练与评估的完整解决方案。资源共收录23个文件,包括8个ipynb交互式分析脚本、3个txt结果日志、2个csv数据集、2个split_dataset与split_testset切分目录,以及实验报告docx、依赖说明md和1个Python脚本,压缩包整体约67.39MB。其中BERT训练脚本与朴素贝叶斯对照实验并列,清晰展示深度模型与传统算法的效果差异。已有542人学习下载,适合希望快速复现高分项目、理解文本分类全流程的本科生与入门开发者。相较于其他零散代码,本包额外提供新闻预处理步骤、数据加载脚本dataload.py及result_bayes.txt与result_bert.txt结果文件,便于验证实验结论,撰写实验报告或课程设计文档时可直接引用。
1. 机器学习大作业里的新闻文本分类:双模型方案才是省力答案
期末选题表里躺着“新闻文本分类”那一刻,多数人的第一反应是朴素贝叶斯——因为课堂上就讲了这个,代码也好抄。但交过作业的都知道,只交一个朴素贝叶斯,报告没有任何对比实验,老师问起来也答不出“为什么不试试深度学习”。这个压缩包给的是两条腿走路的方案:朴素贝叶斯当基线和解释性素材,BERT做精度冲刺,数据集和源码都配齐了,属于拿到手能直接跑的机器学习大作业套路。适合正在做课程设计、期末大作业、或者想在生产里快速搭一个文本分类基线的人。不适合一点不想读代码、指望双击就出结果的人——任何分类项目都要先搞懂数据格式和训练入口,这不是一个能靠直觉绕过去的工具包。
2. BERT 和朴素贝叶斯双轨选型:为什么这条路线比单模型稳
2.1 朴素贝叶斯:老算法在大作业里承担的角色不光是凑数
朴素贝叶斯在新闻文本分类里用的最多的是 MultinomialNB,配合 TF-IDF 特征。它的核心是贝叶斯定理加一个很强的条件独立性假设——每个词在给定类别下独立出现。新闻文本里“国际”“财经”“体育”这种类别词分布差异很大,条件独立性假设虽然粗略,但在短文本上往往比很多线性模型还稳,因为它把每个词当作独立证据,不会被词组间的共线关系干扰。
在大作业场景里,朴素贝叶斯承担的其实是三个任务:给 BERT 的结果当基线、在报告里写出“经典方法 vs 深度方法”的对比、以及当机器跑不动大规模预训练模型时保住基本盘。用 scikit-learn 跑一个 TF-IDF + MultinomialNB,核心代码量很小,但参数有三个值得动:
alpha:拉普拉斯平滑系数,默认 1.0。新闻分类里如果某个词在某个类别从没出现过,平滑系数能避免概率算成 0。我一般会在 0.1 到 1.0 之间试两个值。fit_prior:是否学习类别的先验概率。新闻类别如果本身不均匀(比如“体育”样本量是“国际”的两倍),保留先验会更合理,设为True。ngram_range:TF-IDF 向量化时是否用(1, 2)二元词。加了二元词准确率通常会涨一两个点,代价是特征维度暴涨。
一个常见误用是把 CountVectorizer 的min_df设得太小。新闻数据里大量只出现一两次的生僻词,进特征矩阵后全是稀疏噪音。我一般会设min_df=2,也就是至少在两个文档里出现过的词才保留,跑出来的模型更干净。
2.2 BERT:把预训练模型当特征提取器,而不是黑匣子
BERT 在新闻文本分类里的做法很标准:用bert-base-chinese做特征编码,取[CLS]位置的输出向量,再接一个全连接分类头。[CLS]这个位置是 Transformer 编码器最后层对整个序列的总结向量,隐含了全文的信息,接一个 Linear 层做分类是 HuggingFace 里最常见的下游任务结构。
为什么说它不是黑匣子?因为你需要理解两个关键参数:
max_len:输入序列的最大长度。新闻文本动辄几百上千字,BERT 的 self-attention 计算复杂度是序列长度的平方,塞满 512 个 token 不仅慢,而且长文本后半段的 attention 分布早就稀疏了。处理新闻这种场景,max_len=128或256基本够用,标题加前几个段落已经把信息集中在前面了。batch_size:预训练模型微调时对显存非常敏感。16 的 batch size 在 6G 显存的卡上跑bert-base-chinese(12 层、768 维、约 1.1 亿参数)刚好擦边,再大就 OOM。
在选型上,BERT 负责的是“把准确率往上推”这件事。同一个新闻分类数据集,TF-IDF + 朴素贝叶斯能到 90% 上下的话,BERT 微调后能到 95% 以上。差距主要来自两个方面:一是 BERT 的上下文建模能处理一词多义,比如“苹果”在“苹果公司发布新品”和“苹果削皮后容易氧化”里的含义完全不同,朴素贝叶斯把同一个词当成同一个特征;二是预训练阶段已经学到了大规模中文语料的语法和语义知识,即使新闻数据只有几千条,也能借到外部知识。
2.3 两条路怎么在同一个项目里分工协作
我拿到一个文本分类项目,第一件事永远是跑一个朴素贝叶斯基线,第二件事才是决定要不要上 BERT。这不是因为我保守,而是因为基线模型能告诉你数据集本身难不难。如果 TF-IDF 特征下朴素贝叶斯准确率不到 85%,那大概率是数据切分有问题、标签噪音大、或者类别严重不均衡——这些问题不解决,直接上 BERT 也只是让模型把噪音记住,而不是学到真正的模式。
我整理一下在这个新闻分类项目里两条路的参数边界,可以保存下来对照:
| 环节 | 朴素贝叶斯路线 | BERT 路线 |
|---|---|---|
| 特征输入 | jieba 分词 + TF-IDF | BertTokenizer 分字 |
| 模型容量 | 几十万维稀疏特征 | 1.1 亿参数稠密向量 |
| 训练资源 | CPU 几分钟 | GPU 几十分钟到几小时 |
| 典型准确率 | 85%-90% 区间 | 95% 以上区间 |
| 报告价值 | 基线、可解释、对比 | 精度冲刺、体现深度 |
| 主要风险 | 词表噪音、未登录词 | 显存溢出、过拟合 |
这个对比表不是定死的。如果数据集特别大,比如十几万条新闻,朴素贝叶斯和 BERT 的差距可能缩到 3 个点以内,这时要不要花几小时跑 BERT 就看你报告需要什么。如果数据集只有两三千条,BERT 很容易过拟合,训练集 99%、测试集 90% 的情况很常见,需要加重 Dropout 或者减小学习率。机器学习实战那套 scikit-learn 套路在中小型数据集上的表现,往往会颠覆你“深度学习一定更强”的直觉。
3. 把压缩包跑起来:从解压到第一次看到分类报告
3.1 目录结构:先搞清楚哪些文件需要你动手改
拿到压缩包第一件事不是看代码,而是看目录结构。这个项目典型的布局是这样的:
news_classifier/ ├── data/ │ ├── 财经/ # 每个类别一个文件夹,内部是 .txt 新闻正文 │ ├── 体育/ │ ├── 娱乐/ │ └── 科技/ ├── src/ │ ├── train_nb.py # 朴素贝叶斯训练入口 │ ├── train_bert.py # BERT 微调训练入口 │ └── predict.py # 加载模型推理 ├── output/ # 模型权重和报告输出 └── README.md这种“每个类别一个文件夹”的数据组织方式是最常见的新闻分类数据集格式,解压后一眼就能确认数据量。我建议你坐下来数一下每个文件夹里的文件数量,然后用wc -l看一眼新闻平均长度——这两个数字决定了后面所有参数怎么设。
# 统计每个类别的样本数量和平均长度 for dir in data/*/; do count=$(ls "$dir" | wc -l) avg_len=$(cat "$dir"/*.txt | awk '{sum+=length($0)} END {print sum/NR}') echo "$dir: $count 篇, 平均长度 $avg_len 字符" done注意看avg_len。如果平均长度在 300 字符以下,BERT 的max_len设 128 就够了;如果到了 1000 以上,max_len就得提到 256,否则后文被截断会损失信息。这一步不做,后面跑出来的准确率差两个点你都找不到原因。
3.2 环境准备:torch、transformers 与 sklearn 的最小匹配
这个项目依赖三个核心库:scikit-learn负责朴素贝叶斯和评估指标,transformers负责加载 BERT 模型和分词器,torch是 BERT 的后端。版本匹配是这类项目最容易翻车的地方,transformers 4.x 和 torch 1.10 以上的组合比较稳定,我自己一般会新建一个虚拟环境,避免和系统里的旧版本打架。
python -m venv venv source venv/bin/activate pip install scikit-learn jieba torch transformers这里有个细节:transformers会顺便装tokenizers和numpy的依赖,但不会主动装torch,所以要显式指定。如果显卡是 NVIDIA 的,建议提前装好 CUDA 版的 torch,否则训练时device会检测成 CPU,BERT 跑起来慢到你怀疑人生。检查环境是否就绪,可以跑一段很短的验证代码:
import torch import transformers from sklearn.naive_bayes import MultinomialNB print("torch:", torch.__version__) print("cuda available:", torch.cuda.is_available()) print("transformers:", transformers.__version__)如果cuda available输出False,后面 BERT 训练默认用 CPU,一个 epoch 可能要跑几十分钟,这是项目里最大的坑之一。解决办法是在第 5 章避坑里展开,这里先记住:BERT 路线默认认为你有可用 GPU。
3.3 跑通训练入口:两个脚本的入口参数怎么调
环境就绪后,先跑朴素贝叶斯。这个脚本不依赖 GPU,两三分钟就能看到结果,能帮你验证数据读取和标签映射有没有问题。
python src/train_nb.py \ --data_dir data \ --output_dir output \ --ngram_range 1 2 \ --min_df 2脚本内部的核心逻辑是一个 jieba 分词 + TF-IDF + MultinomialNB 的流水线。分词这一步对中文特别关键,直接拿原文做 TF-IDF,每个汉字都是独立特征,“机器学习”会被拆成“机器”“学习”两个互不相关的特征。加了 jieba 分词后,机器学习才会作为一个整体特征出现,这也是朴素贝叶斯在中文本上表现差异最大的一个处理。
import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.pipeline import make_pipeline def tokenize(text: str) -> list: # 自定义分词函数,交给 TfidfVectorizer 的 tokenizer 参数 return [w for w in jieba.cut(text) if w.strip()] pipeline = make_pipeline( TfidfVectorizer(tokenizer=tokenize, ngram_range=(1, 2), min_df=2), MultinomialNB(alpha=0.3) ) pipeline.fit(train_texts, train_labels)这段代码里TfidfVectorizer接收tokenizer参数直接用 jieba 分词结果建词表,ngram_range=(1, 2)同时保留单个词和相邻双词的特征,MultinomialNB(alpha=0.3)是平滑系数。make_pipeline把向量化和分类器包装成一个整体,后面predict时只需调用同一个 pipeline 对象,不需要手动对文本做向量化。
朴素贝叶斯跑通后,再跑 BERT:
python src/train_bert.py \ --data_dir data \ --output_dir output/bert_model \ --max_len 128 \ --batch_size 16 \ --epochs 3 \ --learning_rate 2e-5BERT 的训练脚本比朴素贝叶斯长得多,核心结构是BertForSequenceClassification加一个训练循环。这里有一个值得注意的参数:learning_rate用 2e-5 而不是常见的 0.01,这不是笔误,BERT 微调时预训练权重已经收敛得很好了,用大学习率会把学到的特征冲掉。epochs设 3 也是经验值,新闻分类场景下 3 轮训练已经足够,再多就会过拟合。
4. 把项目变成自己的:换数据集、改类别、设计对比实验
4.1 数据格式:把不整齐的文件夹转成结构化 CSV
拿到项目后如果你打算换自己的数据集,或者想调整类别体系,第一步是把“文件夹里散落的 txt”切分成结构化数据。我一般会写一个转换脚本,统一输出成两列 CSV,后面无论喂给 sklearn 还是 HuggingFace 数据集都方便。这个步骤的核心价值是:在做任何模型实验前,把数据验证逻辑固定下来,避免每次训练前都要检查十几个文件夹。
import os import csv import random data_dir = "data" output_csv = "news_processed.csv" label_map = {"财经": 0, "体育": 1, "娱乐": 2, "科技": 3} rows = [] for label, label_id in label_map.items(): folder = os.path.join(data_dir, label) for filename in os.listdir(folder): filepath = os.path.join(folder, filename) with open(filepath, "r", encoding="utf-8") as f: # 新闻文本可能有多行,读完后统一去换行符 content = f.read().replace("\n", " ").strip() if content: # 跳过空文件 rows.append((content, label_id)) random.seed(42) random.shuffle(rows) with open(output_csv, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["text", "label"]) writer.writerows(rows)这里有两个容易踩的细节。第一,读取 txt 时encoding必须显式指定为utf-8,很多 Windows 上生成的新闻数据是 GBK 编码,不指定的情况下默认编码在某些环境里会直接抛UnicodeDecodeError。第二,random.seed必须在 shuffle 前固定,否则每次跑出来的数据顺序都不一样,后面模型对比时无法判断准确率差异到底是模型带来的还是数据切分带来的。
4.2 分层抽样与种子:让你的实验可复现
从 CSV 读回来以后,划分训练集和测试集这一步要用分层抽样。新闻分类的类别分布通常不均,如果直接随机切,有可能测试集里某个类别的样本特别少,导致准确率波动巨大。train_test_split的stratify参数就是干这个的。
import pandas as pd from sklearn.model_selection import train_test_split df = pd.read_csv("news_processed.csv") X_train, X_test, y_train, y_test = train_test_split( df["text"], df["label"], test_size=0.2, stratify=df["label"], random_state=42 )stratify=df["label"]会让训练集和测试集里的类别比例保持与原全集一致。random_state=42是固定随机种子,任何可复现实验的第一个要求。如果你做完实验换了随机种子重新跑,准确率从 91% 变成 89%,那不是算法的错,是数据切分变了。两个模型做对比实验时,必须保证它们用的是同一份训练集和测试集,否则报告里的对比表格完全没有意义。
4.3 评估指标:别只盯着 accuracy
新闻分类大作业里最容易拿高分但最常被忽略的环节是评估。很多人只输出一个accuracy就结束了,但老师想看到的是分类报告和混淆矩阵,这两个东西能直观展示模型在每个类别上的表现。classification_report里的 precision、recall、f1-score 是按类别计算的,宏平均和加权平均的区别也要能讲清楚。
from sklearn.metrics import classification_report, confusion_matrix import seaborn as sns import matplotlib.pyplot as plt y_pred_nb = nb_pipeline.predict(X_test) print(classification_report(y_test, y_pred_nb, target_names=["财经", "体育", "娱乐", "科技"])) cm = confusion_matrix(y_test, y_pred_nb) sns.heatmap(cm, annot=True, fmt="d", cmap="Blues", xticklabels=["财经", "体育", "娱乐", "科技"], yticklabels=["财经", "体育", "娱乐", "科技"]) plt.savefig("output/confusion_matrix_nb.png")在新闻分类里,准确率相同但混淆矩阵不同,说明的问题完全不一样。比如“娱乐”和“科技”经常混淆,因为科技新闻里会出现大量人名和公司名;而“体育”类别因为术语独特,通常 precision 很高。报告里如果能写一句“体育类 precision 高,但科技类被误分为娱乐的比例较大,推测原因是科技新闻中包含了艺人代言、影视剧推送等内容”,这就是一个 80 分报告和一个 95 分报告的差距。混淆矩阵正是帮你发现这个规律的。
5. 避坑记录:这个新闻分类项目里最容易翻车的四个问题
5.1 中文乱码:UnicodeDecodeError 或训练出满屏乱码
现象:读取 txt 时报错UnicodeDecodeError: 'gbk' codec can't decode byte 0x...,或者训练完成后预测结果全是同一个类别。
原因:新闻数据集来源复杂,有的 txt 是 UTF-8 保存,有的是 GBK 保存,混在一个文件夹里。Python 的open()默认编码跟随系统,Windows 中文系统下默认 GBK,读 UTF-8 文件就会报错。乱码反向问题也常见——用 UTF-8 读 GBK 文件,不报错但内容全乱,模型得到的是无意义的字符序列。
解决:所有读取文件的地方,显式指定encoding="utf-8",并在读取后做一次异常兜底。我一般会在数据加载函数里写一个编码探测:
def read_text(filepath: str) -> str: for enc in ("utf-8", "gbk", "gb18030"): try: with open(filepath, "r", encoding=enc) as f: return f.read() except UnicodeDecodeError: continue return ""gb18030是 GBK 的超集,覆盖了更多生僻字符。这样即使一个文件夹里混着不同编码,也能全部读出来,不至于训练数据量忽大忽小。从那以后我每次处理中文语料,第一步永远是统计read_text返回空字符串的文件数量,超过 5 个就说明数据本身有问题。
5.2 BERT 训练时显存溢出:CUDA out of memory
现象:跑train_bert.py到第一个 batch 时直接报CUDA out of memory,或者训练了几个 step 之后才炸掉。
原因:bert-base-chinese约 1.1 亿参数,每个 batch 要同时保存所有层的中间激活值。max_len=128和batch_size=16在 6G 显存上已经非常极限,如果后台还开着浏览器或 Jupyter,显存直接被占满。
解决:先确认train_bert.py里有没有把模型和梯度都放到同一个设备上,然后按顺序调参。优先降batch_size到 8,如果还炸就把max_len降到 128 以下,最后再考虑用梯度累积。torch.cuda.empty_cache()只能在 Python 层面释放缓存,对显存碎片没有帮助,真正的解决路径是缩小 batch 对应的计算量。
# 失败的梯度累积替代方案 trainer = Trainer( model=model, args=TrainingArguments( per_device_train_batch_size=8, gradient_accumulation_steps=2, # 实际等效 batch_size=16 fp16=True, # 半精度训练,显存几乎减半 ), )fp16=True用半精度浮点数训练,显存占用接近减半,但前提是你的显卡支持。RTX 20 系及以上基本都支持,老一点的卡可能不认。gradient_accumulation_steps=2的含义是每 2 个 step 才做一次参数更新,等效于 batch_size=16,但显存只占用 8 的规模。
5.3 损失不下降:BERT 训练几个 epoch 后准确率纹丝不动
现象:训练 loss 从 0.7 降到 0.3 后就不再变化,验证集准确率停留在 85% 左右,比朴素贝叶斯高不了多少。
原因:一种情况是学习率太大,BERT 预训练权重被破坏,模型在乱学;另一种情况是新闻类别本身有重叠,比如科技类新闻里大量出现娱乐内容,单靠文本前 128 个 token 不足以区分。我见过最离谱的一次,是标签映射写错了——数据预处理里label_map把“科技”和“娱乐”的编号调反了,模型学了半天,loss 低是因为在记噪音。
解决:先用 20 条样本做一次过拟合测试,如果 loss 能降到接近 0,说明模型和数据管道没问题;如果 20 条都降不下去,先检查标签映射和数据读取。这种排查顺序能帮你区分是模型问题还是数据问题。
# 用极小数据集验证管道是否通畅 mini_train = X_train[:20] mini_labels = y_train[:20] model = BertForSequenceClassification.from_pretrained("bert-base-chinese", num_labels=4) training_args = TrainingArguments(output_dir="./debug", learning_rate=2e-5, num_train_epochs=10) # 训练完毕后看 training_loss 是否趋近于 020 条样本 10 个 epoch,只要数据管道没毛病,模型一定能完美记住这 20 条,loss 降到 0.01 以下。如果这步都不通过,后面所有实验都是徒劳。
5.4 测试集信息泄露:报告里准确率高得离谱
现象:BERT 在测试集上准确率 99%,远超正常水平,但我检查数据时发现训练集和测试集里有完全相同的新闻。
原因:原始文件夹切分时没有做去重。新闻爬虫经常把同一篇稿子放在多个类别文件夹里,或者同一篇新闻被稍加修改后重复出现。如果不做去重直接划分,训练集和测试集共享了大量相同文本,模型相当于开卷考试,测试准确率高但没有实际意义。
解决:在数据预处理阶段,对所有新闻正文求哈希值,按哈希值去重后再划分数据集。这一步对报告的说服力至关重要,因为老师一旦抽查测试集里某条新闻并发现它和训练集重复,整份报告的可信度就清零了。
import hashlib df["hash"] = df["text"].apply(lambda x: hashlib.md5(x.encode("utf-8")).hexdigest()) df = df.drop_duplicates(subset="hash", keep="first")md5在这里不是加密用途,只是快速判断文本是否完全相同的指纹。去重后你会发现训练集样本量掉一两成,这是正常的,准确率也会恢复到合理区间。
6. 交付前最后一步:用一个独立推理脚本证明模型真的能用
大作业交付前,我最怕的翻车不是训练跑不出来,而是答辩现场换了一台机器,你发现自己只能在 Jupyter 里跑训练,没有一个干净的单文件推理脚本。训练代码里充满了数据加载、标签映射、评估逻辑,混在一起 200 行,现场根本来不及解释。我后来养成一个习惯:任何分类项目,训练完必须写一个独立的predict.py,从硬盘加载模型权重,输入一段字符串,直接输出类别,整个过程不超过 10 秒。
import torch from transformers import BertTokenizer, BertForSequenceClassification label_map = {0: "财经", 1: "体育", 2: "娱乐", 3: "科技"} # 加载分词器和模型,注意路径必须指向保存权重的目录 tokenizer = BertTokenizer.from_pretrained("output/bert_model") model = BertForSequenceClassification.from_pretrained("output/bert_model") model.eval() def predict(text: str) -> str: inputs = tokenizer(text, truncation=True, max_length=128, return_tensors="pt") with torch.no_grad(): logits = model(**inputs).logits pred_id = torch.argmax(logits, dim=-1).item() return label_map[pred_id] # 测试样例 print(predict("国足今晚在客场以2比0战胜对手,晋级下一轮预选赛")) print(predict("某科技公司发布新一代智能手机,搭载自研处理器"))这段代码里有三个关键点。第一,model.eval()必须调用,关闭 Dropout 和 BatchNorm 的训练行为,否则同一条新闻每次预测结果都可能不同。第二,with torch.no_grad()关闭梯度计算,推理时少占内存且更快。第三,truncation=True和max_length=128必须和训练时保持一致,否则该截断的没截断,预测结果会和训练指标对不上。
这个推理脚本同时也是报告里的“模型应用展示”章节素材。老师问“这个模型能干嘛”,你直接现场敲两行命令,输入「某球队主场大胜」,输出「体育」;再输入「某厂商发布旗舰手机」,输出「科技」。效果比任何流程图都直观。我也遇到过 BERT 权重文件和脚本代码不在同一路径的场景,写脚本时用了硬编码的相对路径,换目录跑就报FileNotFoundError。所以现在交付时我会强制走一遍:把output/bert_model文件夹和predict.py拷贝到一台干净机器,从头跑一次推理,确认路径没有依赖当前目录结构。从那以后,这个大作业项目我再也没在答辩现场翻过车。希望帮到你。
本文还有配套的精品资源,点击获取