简介:基于搜狗新闻语料库的中文文本分类实践资料包,面向人工智能、计算机等相关专业学生与开发者,适合课程设计、毕业设计或入门进阶。项目完整覆盖传统机器学习与预训练模型两类方法,包含数据加载、语料切分、特征构建、模型训练与评估等环节,源码经过测试运行成功,并获导师认可,评审分达95分。资源共15个文件,以6个脚本为主,辅以4张训练过程与词云结果图、2个文本文件(含停用词表)、说明文档与配置文件等,整体仅208KB,目前已有79人学习浏览。代码结构清晰,可直接运行,便于对比不同模型效果,也可基于已有工程扩展新的分类任务或模型结构,无论是完成课设作业还是搭建文本分类基线,都能提供扎实参考。
1. 拿搜狗新闻语料库做中文文本分类,为什么一直到现在都没过时
文本分类是中文 NLP 里最常被拿来练手、也最容易被做砸的方向。搜狗新闻语料库作为这批资料里的核心数据源,规模够大、类别明确、噪声可控,拿它跑通传统机器学习与预训练模型两条路线,能让你一口气把分词、特征工程、TF-IDF、线性分类器、BERT 微调、显存优化、评价指标这些零散知识点串成一条完整链路。本文的读者定位很直接:想从零做起中文文本分类、又不想只停留在跑通 demo 阶段的开发者和学生。你会一步步拿到可复现的完整方案,并看到两类方法在真实新闻语料上的性能边界、参数敏感点和工程落地细节。
2. 搜狗新闻语料库的数据形态:下载、解压、解析与清洗
2.1 语料库的真实格式:不是 csv,也不是一行一条
搜狗新闻语料库在下载后压缩包内部通常是一批以日期命名的文本文件,结构是混合的 HTML 标签与正文混排。常见字段包括文档 ID、标题、正文、URL 链接、发布时间等,但不同来源的版本字段名并不一致,有的是<url>标签,有的是docid开头,有的干脆是 tab 分隔的纯文本。如果直接pd.read_csv去读,大概率得到一堆乱码和解析异常。
先不要急着写模型。第一步是把原始数据读进来,摸清字段结构。我一般会先做一次最小化探查,而不是一次性写完整清洗脚本。
import gzip import re from pathlib import Path # 以常见 gz 压缩文本为例,先读一个文件看结构 p = Path("data/sogou_news_2012/sogou_news_2012_00.gz") with gzip.open(p, "rt", encoding="utf-8", errors="ignore") as f: for i, line in enumerate(f): if i >= 3: break print(line[:300]) print("---")这段代码的作用是解压并打印前几行原文,errors="ignore"可以避免个别坏字符直接中断读取。注意这里用的是rt模式,即以文本方式读取 gzip 文件,否则读出来的是 bytes,后续正则匹配还需要再解码一次。
日志输出后你会看到类似<doc><url>...</url><docid>...</docid><title>...</title><content>...</content></doc>的结构。确认字段名后再做解析,这比盲目写正则要可靠得多。
2.2 解析与清洗:正则抽取、噪声过滤与最少保留字段
不同版本的搜狗语料字段虽有差异,但<title>和<content>这两个字段始终存在。做文本分类时标题往往信息密度远高于正文,而有些新闻的正文是一大段被转义的 HTML 字符串。最常见的做法是按下述脚本解析并拼接标题与正文。
import re from html import unescape DOC_PATTERN = re.compile( r"<doc>(.*?)</doc>", re.S ) TITLE_PATTERN = re.compile(r"<title>(.*?)</title>", re.S) CONTENT_PATTERN = re.compile(r"<content>(.*?)</content>", re.S) def parse_doc(raw: str): title = TITLE_PATTERN.search(raw) content = CONTENT_PATTERN.search(raw) title_text = title.group(1).strip() if title else "" content_text = content.group(1).strip() if content else "" # 去除 HTML 标签、反转义、合并空白 text = "".join([title_text, "。", content_text]) text = re.sub(r"<[^>]+>", "", text) text = unescape(text) text = re.sub(r"\s+", " ", text).strip() return text这里有一个易被忽视的细节:re.S标志必须加上,否则.不会匹配换行符,遇到正文跨行的情况会直接截断。去除 HTML 标签放在反转义之前,顺序反过来会导致&lt;反转义后变成原始标签再被过滤,结果一样但多消耗一次正则,没必要。
拼接标题和正文时用「。 」做分隔,这一步看似不起眼,却直接影响后续 TF-IDF 特征中的 n-gram 效果,尤其是 bigram 跨句子边界会产生无意义特征。
2.3 类别标签从哪来:目录名、URL 还是文件名
搜狗新闻语料库的历史版本里,有的版本类别信息在压缩包目录结构里,有的在 URL 路径中,有的在<docid>前缀中。我遇到过的最常见情况是:以文件夹划分类别,比如C000008/代表汽车、C000014/代表军事。这种情况下标签直接从父目录名取就好。
但有一种更容易翻车的情况:同一篇新闻在 URL 字段里的频道名和目录分类并不一致,比如 URL 里写着体育频道、目录却分到了社会新闻。这种情况下优先以目录为准,因为目录是语料整理时的归属标签,URL 只是来源记录。
处理完标签后记得做一次类别分布统计,搜狗新闻语料既然是学术用途,整体类别相对均衡,但部分版本会把「军事」「体育」「娱乐」「财经」等类别做合并或删减,统计后发现不均衡再决定要不要下采样。下采样策略优先保留训练集里的均衡性,而不是在全部数据上做,否则评估集的分布会失真。
3. 传统机器学习路线:从分词到 TF-IDF,再到线性分类器
3.1 特征工程:jieba 分词、停用词与 TF-IDF 的配合
传统机器学习做中文文本分类,绕不开分词。虽然 TF-IDF 本身不要求必须分词,但中文句子如果不分词,字符级 n-gram 会带来极高的特征维度和大量无意义共现。最常用的方案是jieba分词 +TfidfVectorizer。
import jieba def tokenize_for_tfidf(text: str) -> list: # 关闭并行,保证结果可复现 jieba.setLogLevel(60) words = jieba.lcut(text) # 过滤纯空白和单字(可选,视任务而定) return [w.strip() for w in words if w.strip() and len(w.strip()) > 1]这里的len(w.strip()) > 1是一道经典取舍。搜狗新闻里大量单字是「的、了、是、在」这类虚词,直接过滤可以显著降低特征维度。但注意,有些有效类别词就是单字,比如「房」「股」,所以这个阈值只适用于新闻分类这类词粒度偏长的场景,换到短文本分类或情感分析时建议保留单字。jieba.setLogLevel(60)的作用是关掉 jieba 默认的日志输出,避免在跑批量处理时终端被刷屏。并行加载词典在数据量不大时反而拖慢速度,所以我习惯关掉。
from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( tokenizer=tokenize_for_tfidf, ngram_range=(1, 2), max_features=200000, min_df=5, max_df=0.8, sublinear_tf=True, ) X_train_tfidf = vectorizer.fit_transform(train_texts) X_val_tfidf = vectorizer.transform(val_texts)参数说明:ngram_range=(1, 2)是新闻分类里最容易见效的设定,单独 unigram 容易漏掉「新能源汽车」「疫情防控」这类词组;max_features=200000限制特征总量,防止向量化阶段内存溢出;min_df=5过滤掉只在不超过 5 篇文档里出现的词,这些词大概率是噪声;max_df=0.8过滤掉在 80% 以上文档都出现的词,这类词对区分类别没有贡献。sublinear_tf=True是个容易忽略的参数,它把原始词频替换为1 + log(tf),能显著平滑高频词的主导地位。在搜狗新闻这种长文本场景下,这个词频压缩操作经常带来 2~3 个百分点的 F1 提升,值得加上。
3.2 多分类器对比:LinearSVC、朴素贝叶斯与逻辑回归哪个先用
文本分类的惯例是先跑一个简单模型定基线,再逐步升级。高维稀疏 TF-IDF 特征下,LinearSVC几乎是效果和速度兼顾的默认选项。
from sklearn.svm import LinearSVC from sklearn.metrics import classification_report clf = LinearSVC(C=1.0, class_weight="balanced", max_iter=5000) clf.fit(X_train_tfidf, train_labels) y_pred = clf.predict(X_val_tfidf) print(classification_report(val_labels, y_pred, digits=4))C=1.0是默认值,但在搜狗新闻这种样本量较大的任务上,我通常会试着把 C 调到 0.5~2.0 之间的几个值,看验证集宏平均 F1 是否继续上涨。class_weight="balanced"用于处理类别不均衡,如果你在 2.3 节已经做了完整的下采样,这里可以不设。max_iter=5000是防不收敛的保险丝,搜狗新闻做 TF-IDF 后特征维度通常十几万,默认 1000 次迭代偶尔报告不收敛,直接调大比反复重启流程要省时间。
多分类器对比时不要把 LinearSVC 当成唯一候选。朴素贝叶斯在这个任务上有惊喜,因为新闻文本的词分布其实比较符合多项分布假设,但效果略低于 LinearSVC。逻辑回归的表现为第三,但它的概率输出对后续解释有帮助。所以我的固定做法是:先跑 LogisticRegression 看概率校准,再跑 LinearSVC 冲指标。
3.3 搜狗新闻场景下的评估指标:宏平均 F1 比准确率更有意义
搜狗新闻语料库默认类别数在 10 个左右,部分版本类别数量可能达到 20 个以上。类别多了以后,准确率这个指标会严重失真。假设 20 个类别里 3 个类别占了 60% 样本,分类器只需要把这 3 类学好,整体准确率可以到 75% 以上,但剩下 17 类的效果惨不忍睹。
因此评估时一定要看macro-F1或weighted-F1。宏平均 F1 对每个类别独立计算后取平均,小类别的表现和大类别同等权重,最能暴露分类器偷懒的问题。
除了指标,务必打印混淆矩阵观察具体哪些类别互相混淆。新闻分类的经典混淆对是「军事」与「国际」、 「体育」与「娱乐」、「财经」与「房产」,如果混淆集中在语义本来就接近的类别上,说明特征还有区分空间;如果出现随机性混淆,比如「军事」和「宠物」频繁互认,那问题多半出在数据标签或预处理环节。
4. 预训练模型路线:用中文 RoBERTa 微调搜狗新闻分类器
4.1 传统机器学习的天花板与 BERT 系模型带来的转机
TF-IDF + 线性分类器在搜狗新闻上能做到多好取决于版本,但普遍有一个共同瓶颈:它无法理解词序和上下文关系。「苹果」在新闻里到底是水果还是手机品牌,单靠 TF-IDF 只能依赖上下文的共现统计,不能真正建模语义。
预训练模型解决的是这个问题。以hfl/chinese-roberta-wwm-ext为代表的中文预训练模型在新闻语料上做微调,可以让分类器捕获「利好」「暴跌」「稳中有进」这类需要语境理解的信号。代价是训练和推理成本显著上升。
这一节不打算讲 BERT 的原理细节,那会写成长篇论文。这里只聚焦一件事:如何基于 transformers 库把中文 RoBERTa 在搜狗新闻分类任务上跑通,以及跑通后怎么压榨最后的几个百分点。
4.2 最小可复现的微调脚本:transformers 实现开箱即用
请先保证transformers>=4.20、torch已安装,并且有可用的 N 卡 GPU。以下脚本是微调的最小子集,去掉了早停和动态学习率调度等装饰,保留主干以方便理解每一步作用。
import torch from datasets import Dataset from transformers import ( AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer, ) model_name = "hfl/chinese-roberta-wwm-ext" tokenizer = AutoTokenizer.from_pretrained(model_name) def tokenize_fn(examples): return tokenizer( examples["text"], truncation=True, max_length=256, padding="max_length", ) # train_df 需包含 text 和 label 两列,label 为整数 train_dataset = Dataset.from_pandas(train_df[["text", "label"]]) val_dataset = Dataset.from_pandas(val_df[["text", "label"]]) train_dataset = train_dataset.map(tokenize_fn, batched=True) val_dataset = val_dataset.map(tokenize_fn, batched=True) model = AutoModelForSequenceClassification.from_pretrained( model_name, num_labels=NUM_CLASSES ) training_args = TrainingArguments( output_dir="./roberta_sogou", evaluation_strategy="epoch", save_strategy="epoch", learning_rate=2e-5, per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=3, weight_decay=0.01, load_best_model_at_end=True, metric_for_best_model="f1", ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=val_dataset, tokenizer=tokenizer, ) trainer.train()这段代码的关键点集中在三个位置:max_length=256是一个性价比很高的折中选择,搜狗新闻正文长度巨大,超过 512 的文档很多,但把整篇塞进模型完全不划算,真正决定类别的信号一般集中在前 200 个字内;load_best_model_at_end=True与metric_for_best_model="f1"配合,会在训练结束后自动加载验证集上 F1 最优的 checkpoint,而不是最后一个 epoch 的权重;weight_decay=0.01是 AdamW 的常规设定,不加虽然也能收敛,但加了可以有效降低验证集波动。
这里没有自定义compute_metrics,是因为metric_for_best_model="f1"需要一个已经注册的指标,transformers 内置的 F1 计算可以直接用。如果要输出更精细的宏平均 F1,需要自己写一个compute_metrics函数,后文会给出。
4.3 显存不够时的三个务实妥协
搜狗新闻做预训练微调最常见的不是效果问题,而是 OOM。per_device_train_batch_size=16在 12GB 显存上跑chinese-roberta-wwm-ext,如果max_length=256基本能撑住,但超过 256 或批次再加大就会直接崩。三个妥协方案如下。
第一个是降低批次并梯度累积。per_device_train_batch_size=4加上gradient_accumulation_steps=4等效于 batch size 16,但显存占用只有原来的四分之一不到。代价是训练时间变长,因为每一步都要额外累积梯度。
第二个是打开fp16=True。混合精度训练在 N 卡上能够省掉约一半显存,同时训练速度显著提升。代价是极少数算子在 fp16 下数值不稳定,如果训练 loss 突然变成 NaN,先把fp16关掉排查,不要急着调学习率。
第三个是把max_length从 256 砍到 128。搜狗新闻标题本身信息量就够高,把文本强制截到 128 只保留标题加正文开头,F1 损失通常在 1 个百分点以内,但训练速度提升近一倍。这个方案适合第一次跑通流程时使用。
5. 做搜狗新闻分类最容易翻车的 7 个坑:现象、原因与解决
5.1 标签泄漏:验证集分数虚高却不自知
现象:传统机器学习跑出 98% 的宏平均 F1,上线或换数据后掉到 85%。
原因:清洗阶段把 URL 里的频道名、docid 里的类别前缀拼进了正文特征,模型实际是拿标签本身在训练。搜狗语料有些源文件把类别缩写放在 URL 路径中,清洗时正则一不小心就把它带进文本。
解决:清洗阶段把 URL、docid、日期全部丢弃,只保留 title 和 content 字段。然后在训练前做一次冒烟测试:随便取 10 条验证集样本,人工看向量化后的特征里有没有出现类别路径字符。
5.2 文本截断位置不合理导致语义残缺
现象:预训练模型效果反而不如 TF-IDF。
原因:tokenizer没设置truncation=True,或设置错误地把正文前 200 字丢弃、留下末尾,而新闻的核心信息往往在开头。
解决:统一用truncation=True,同时检查 tokenizer 的max_length。搜狗新闻场景直接截开头即可,不需要做首尾拼接这类高级操作。
5.3 标签映射不一致:训练集与验证集类别编号错位
现象:训练 loss 正常下降,验证阶段分类报告全是 0,或者训练完预测结果整体偏移一个类别。
原因:训练集和验证集的label字段是 pandas 类别编码离散后的结果,切分后分别做astype("category"),会导致两个集合的类别映射表不同。
解决:先在整个数据集上做LabelEncoder或 pandas 的astype("category"),再切分训练与验证集,并用同一个编码器映射。这条坑在传统机器学习里不常见,因为 sklearn 的分类器内部会重新对齐;但 transformers 的 Trainer 对 label 数值非常敏感,错一个位置全部错位。
5.4 分词器与预处理不一致导致训练推理效果不一致
现象:离线 eval 分数正常,推理阶段对同一条文本给出不同预测。
原因:传统机器学习路线里,TfidfVectorizer在训练时用自定义分词器,推理时却直接把文本传给 vectorizer,而 vectorizer 默认分词器是空格切分;预训练路线里,训练时用了padding="max_length",推理时改成padding=True,虽然不影响结果,但会给不同长度的输入造成略微不同的 padding mask。
解决:把TfidfVectorizer保存到本地,推理时加载同一个向量器对象。预训练模型推理时保持与训练完全一致的tokenizer配置,包括max_length与padding策略。
5.5 样本不均衡下只看整体准确率
现象:多数类准确率 99%,少数类全错,整体准确率依然好看。
原因:搜狗语料库的类别数量在不同版本中差异很大,部分版本类别不均衡明显。
解决:强制打印每类的 precision、recall、F1,并对混淆矩阵做可视化检查。如果业务对少数类更敏感,可以配置class_weight="balanced"(传统机器学习)或在 loss 函数中传入类别权重(预训练模型)。
5.6 长文本被截断后类别混淆加剧
现象:在国际新闻、军事新闻这类正文依赖上下文的类别上,预训练模型效果波动很大。
原因:新闻标题本身已经包含强烈的类别信号,但正文里的大量背景信息被截断后,某些依赖后文关键实体的样本会变成歧义样本。
解决:不要盲目拉长max_length,先用 128 跑一轮看哪些样本被模型预测错,再针对这些样本统计正文关键信息的位置分布。多数情况下 256 已经是性价比上限。
5.7 随机种子未固定导致结果不可复现
现象:同一个脚本跑两次,F1 波动 0.5 个百分点以上。
原因:Trainer内部的随机性来自 dropout、数据顺序 shuffle、PyTorch 的采样器,传统机器学习路线里则是LinearSVC的随机初始化逻辑。
解决:在脚本开头固定random.seed、numpy.random.seed、torch.manual_seed,并在TrainingArguments里设置seed=42。搜狗新闻数据量不大,固定种子后两次实验结果的差异应完全消失。
这类问题在实操中最容易忽略,因为看起来「模型没问题、数据没问题」,但一改环境或重启进程结果就不一样。排查时先固定所有随机源,再谈调参。
6. 交付层面的最后一公里:保存模型、推理脚本与混淆矩阵验证
训练完成后,模型文件本身只是过程产物。真正让项目可以交付的是两样东西:可复用的推理脚本和可解释的验证报告。
传统机器学习路线的保存方式很简单:用joblib同时保存向量器和分类器两个对象。这里有一个易错点,joblib保存的TfidfVectorizer在加载时依赖 jieba 分词器函数,如果tokenize_for_tfidf这个函数在推理脚本中没有保持同名,加载就会报错。
预训练模型的保存更严格。trainer.save_model()会保存模型权重、配置和词表,但不会保存 tokenizer 的自定义设置。推理时重新加载 tokenizer 后务必核对max_length与padding。
最后的验证环节,我会写一个几十行的脚本,用验证集生成混淆矩阵,并随机打印 20 条预测错误的样本。这一步不是走过场,它能帮你发现两类问题:标签噪声(语料库里确实有错标样本)和语义边界样本(比如把「无人机」归属到「社会」还是「军事」的争议)。当错误样本中大部分属于后者,说明这个分类任务的标注质量边界已经到顶,不用再纠缠模型结构。
搜狗新闻分类走到这里,你已经同时熟悉了传统特征工程与预训练微调两条路线。真要说一条最重要的工作习惯,那就是:每次调整数据或模型后,固定随机种子、保留同一份验证集、记录宏平均 F1,任何改动都要能讲清楚是数据变了、特征变了还是模型变了导致的变化。这套方法论跨项目可用,希望帮到你。
本文还有配套的精品资源,点击获取