ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

搜狗新闻语料库中文文本分类:从TF-IDF到RoBERTa完整实战

搜狗新闻语料库中文文本分类:从TF-IDF到RoBERTa完整实战 简介这份资源围绕搜狗新闻语料库展开中文文本分类实践面向计算机、人工智能、通信工程等专业的在校学生与教师也适合作为毕业设计、课程设计或项目立项的参考案例。内容同时覆盖传统机器学习与预训练模型两条技术路线帮助读者理解从语料加载、分词处理到模型训练与评估的完整流程。压缩包共15个文件约208KB包含6个Python脚本、4张训练过程与词云可视化图片、2个文本文件以及说明文档等脚本按数据加载、语料划分、模型训练等模块拆分结构清晰便于按需查阅与二次修改。目前已有79人学习关注。读者可获得可运行的分类项目源码、配套文档与实验图表并在此基础上调整模型或替换数据集用于自身课题与作业实践。1. 搜狗新闻语料库做中文文本分类从 TF-IDF 到 RoBERTa 的完整落地路线手里有一份搜狗新闻语料库想把它跑成一个能用的中文文本分类器这件事听起来简单真正动手才会发现坑比想象中多。语料是 GBK 编码、类别不均衡、标题和正文混在一起传统机器学习方法几行代码就能出个 baseline但准确率卡在 90% 上不去换成预训练模型显存又不够训练一轮要几个小时。这篇笔记就是把我自己从零跑通这套流程的经验完整拆开先用 TF-IDF 线性模型快速验证数据质量再用 RoBERTa 中文预训练模型把效果拉满中间穿插数据清洗、参数调优和踩坑记录。适合已经了解 Python 和 sklearn 基础、想系统做一遍中文文本分类的工程师也适合拿这个数据集做课程项目或毕设的同学。搜狗新闻语料库是国内中文文本分类最常用的公开数据集之一包含多个大类体育、财经、科技、娱乐等每类有若干篇新闻文档。它的价值在于数据量适中、类别清晰、真实场景分布非常适合用来对比传统机器学习方法和预训练模型的效果差异。但原始数据是分散的 txt 文件编码不统一直接拿来训练会翻车。下面按实际落地顺序从数据准备到模型部署逐层展开。2. 搜狗新闻语料库的获取、清洗与格式统一2.1 语料目录结构与编码问题搜狗新闻语料库常见做法是从公开镜像下载解压后一般得到按类别命名的文件夹每个文件夹下是若干 txt 文件。文件编码多数是 GBK 或 GB2312少数是 UTF-8直接open()读取会报UnicodeDecodeError。我一般先写一个探测脚本遍历所有文件尝试用 GBK 解码失败再试 UTF-8统计编码分布。import os from collections import Counter def detect_encoding(filepath): 尝试常见中文编码返回第一个成功的 for enc in [gbk, gb18030, utf-8]: try: with open(filepath, r, encodingenc) as f: f.read(2000) # 只读前2000字符探测 return enc except (UnicodeDecodeError, UnicodeError): continue return unknown data_dir ./sogou_news enc_counter Counter() for root, dirs, files in os.walk(data_dir): for fn in files: if fn.endswith(.txt): enc detect_encoding(os.path.join(root, fn)) enc_counter[enc] 1 print(enc_counter)这段代码的逻辑是对每个 txt 文件依次尝试 GBK、GB18030、UTF-8 解码读到内容就认为编码正确。gb18030是 GBK 的超集放在 GBK 之后作为兜底。参数上f.read(2000)只读前 2000 字符避免大文件拖慢探测速度。如果统计结果里unknown占比超过 5%说明有二进制文件混入需要单独排查。2.2 统一转成 UTF-8 并合并成单文件探测完编码后把所有文件统一转成 UTF-8并且合并成一个带标签的 CSV 或 JSONL。这样做的好处是后续用 pandas 或 datasets 加载都方便也避免训练时反复读小文件拖慢 IO。import csv def convert_and_merge(data_dir, output_csv): with open(output_csv, w, encodingutf-8, newline) as fout: writer csv.writer(fout) writer.writerow([label, text]) for label in os.listdir(data_dir): label_dir os.path.join(data_dir, label) if not os.path.isdir(label_dir): continue for fn in os.listdir(label_dir): if not fn.endswith(.txt): continue fp os.path.join(label_dir, fn) enc detect_encoding(fp) if enc unknown: continue with open(fp, r, encodingenc, errorsignore) as fin: text fin.read().strip() # 去掉换行和多余空白保留正文 text .join(text.split()) if len(text) 10: # 过滤空文档 continue writer.writerow([label, text]) convert_and_merge(./sogou_news, ./sogou_merged.csv)关键参数说明errorsignore在解码时跳过无法识别的字节避免个别脏字符导致整个文件失败len(text) 10过滤掉空文档和只有标题的短文本 .join(text.split())把换行、制表符统一成空格减少后续分词干扰。合并后可以用pandas.read_csv快速查看类别分布import pandas as pd df pd.read_csv(./sogou_merged.csv) print(df[label].value_counts()) print(df[text].str.len().describe())如果发现某类样本数远少于其他类比如只有几百条要么做数据增强要么在训练时用类别权重。这一步不做后面模型会严重偏向多数类。3. 传统机器学习方法TF-IDF 线性模型的快速 baseline3.1 分词与 TF-IDF 特征提取中文文本分类绕不开分词。传统方法里jieba 是最稳的选择速度快、词典全。我一般用 jieba 的精确模式去掉停用词和单字然后送进TfidfVectorizer。import jieba from sklearn.feature_extraction.text import TfidfVectorizer # 加载停用词 with open(./stopwords.txt, r, encodingutf-8) as f: stopwords set([line.strip() for line in f]) def tokenize(text): words jieba.lcut(text) return [w for w in words if w not in stopwords and len(w) 1] vectorizer TfidfVectorizer( tokenizertokenize, max_features50000, # 保留最高频的5万个词 ngram_range(1, 2), # 考虑一元和二元词组 min_df3, # 至少出现在3篇文档中 max_df0.8, # 出现在超过80%文档中的词丢弃 sublinear_tfTrue # 用1log(tf)平滑 ) X vectorizer.fit_transform(df[text]) y df[label]参数逐个解释max_features50000控制特征维度太大容易过拟合太小丢信息ngram_range(1,2)引入二元词组能捕捉“人工智能”“股票市场”这类固定搭配min_df3过滤低频噪声词max_df0.8去掉“的”“了”这类几乎每篇都出现的词sublinear_tfTrue对词频做对数平滑避免长文档主导权重。这套参数在搜狗语料上跑出来维度大概 3 到 5 万内存占用可控。3.2 用 LinearSVC 和朴素贝叶斯做对比实验特征有了接下来选分类器。传统方法里LinearSVC 和 MultinomialNB 是中文文本分类的两大主力。LinearSVC 对高维稀疏特征友好朴素贝叶斯训练极快但假设特征独立实际效果略低。from sklearn.model_selection import train_test_split from sklearn.svm import LinearSVC from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import classification_report X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) # LinearSVC svc LinearSVC(C1.0, max_iter2000) svc.fit(X_train, y_train) y_pred_svc svc.predict(X_test) print(LinearSVC:) print(classification_report(y_test, y_pred_svc)) # MultinomialNB nb MultinomialNB(alpha0.1) nb.fit(X_train, y_train) y_pred_nb nb.predict(X_test) print(MultinomialNB:) print(classification_report(y_test, y_pred_nb))stratifyy保证训练集和测试集类别比例一致避免某类全跑测试集里。C1.0是 SVM 的正则化参数调大容易过拟合调小欠拟合一般从 0.1 到 10 之间网格搜索。alpha0.1是朴素贝叶斯的平滑系数太小会零概率太大则平滑过度。在搜狗语料上LinearSVC 通常比朴素贝叶斯高 2 到 4 个百分点训练时间多几倍但仍在分钟级。跑完这两个模型你会得到一个 90% 左右的准确率 baseline。如果低于 85%先回去检查数据清洗和分词而不是急着换模型。传统方法的上限就在这里想再往上走必须上预训练模型。4. 预训练模型方案RoBERTa 中文模型的微调与显存优化4.1 用 HuggingFace 加载中文 RoBERTa 并构建 Dataset预训练模型这块中文场景下 RoBERTa-wwm-ext 系列是首选它用全词掩码训练对中文语义捕捉比原版 BERT 更好。HuggingFace 的transformers库加载起来最方便。from transformers import BertTokenizer, BertForSequenceClassification from torch.utils.data import Dataset, DataLoader import torch model_name hfl/chinese-roberta-wwm-ext tokenizer BertTokenizer.from_pretrained(model_name) class NewsDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len256): self.texts texts self.labels labels self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): encoding self.tokenizer( self.texts[idx], max_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) return { input_ids: encoding[input_ids].squeeze(), attention_mask: encoding[attention_mask].squeeze(), labels: torch.tensor(self.labels[idx], dtypetorch.long) }max_len256是权衡显存和信息的折中值新闻正文一般 500 到 1000 字截断到 256 个 token 会丢尾部信息但显存占用减半。如果显卡显存 8G 以上可以调到 512。paddingmax_length统一长度方便 batch 训练truncationTrue超长截断。标签需要提前转成整数索引用LabelEncoder或手动映射。4.2 训练参数设置与混合精度加速微调 RoBERTa 的关键在超参。学习率太大灾难性遗忘太小收敛慢。我一般用 2e-5 到 5e-5batch size 16 或 32训练 3 到 5 轮。from transformers import AdamW, get_linear_schedule_with_warmup from torch.cuda.amp import autocast, GradScaler device torch.device(cuda if torch.cuda.is_available() else cpu) model BertForSequenceClassification.from_pretrained( model_name, num_labelslen(label2id) ).to(device) optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) epochs 3 total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) scaler GradScaler() for epoch in range(epochs): model.train() for batch in train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) optimizer.zero_grad() with autocast(): outputs model(input_ids, attention_maskattention_mask, labelslabels) loss outputs.loss scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() scheduler.step()weight_decay0.01是 AdamW 的标准正则化防止过拟合。num_warmup_steps设为总步数的 10%让学习率从 0 线性升到 2e-5 再衰减训练更稳。autocast和GradScaler是混合精度训练能把显存占用降 30% 到 40%速度提升 20% 左右8G 显存也能跑 batch size 16。如果显存还是不够用梯度累积每 4 个 batch 才optimizer.step()一次等效 batch size 翻 4 倍。训练完在测试集上评估RoBERTa 通常能比 LinearSVC 高 5 到 8 个百分点达到 95% 以上。代价是训练时间从几分钟变成几十分钟到几小时取决于显卡。5. 避坑与排查中文文本分类里最容易翻车的 5 个地方5.1 标签泄漏测试集混进训练集现象模型在测试集上准确率 99%上线后效果暴跌。原因合并数据时没打乱或者按文件顺序切分导致同一篇新闻的标题和正文分别进了训练集和测试集。解决切分前先df.sample(frac1, random_state42)打乱再用train_test_split分层切分。如果数据里有同一事件的多个报道最好按事件去重。5.2 分词器与预训练模型不匹配现象RoBERTa 微调后效果还不如 TF-IDF。原因用了 BERT 的 tokenizer 加载 RoBERTa 模型词表对不上token 全错。解决BertTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext)必须和模型名一致。另外中文 RoBERTa 的 tokenizer 会自动做全词掩码不要自己再切词。5.3 显存溢出max_len 设太大现象训练到一半报CUDA out of memory。原因max_len512加上 batch size 32显存直接爆。解决先把max_len降到 256batch size 降到 16开启混合精度。还不够就用梯度累积或者换用小一点的模型如chinese-bert-wwm-ext。5.4 类别不均衡导致模型偏向多数类现象多数类 F1 0.98少数类 F1 0.3。原因搜狗语料里体育、财经类样本远多于其他类。解决训练时给CrossEntropyLoss加weight参数权重设为类别频率的倒数或者在 DataLoader 里用WeightedRandomSampler过采样少数类。不要简单复制少数类样本容易过拟合。5.5 学习率太大导致灾难性遗忘现象训练 loss 震荡不降验证集准确率比预训练模型直接推理还低。原因学习率用了 1e-3把预训练学到的语义全冲掉了。解决微调学习率控制在 2e-5 到 5e-5用 warmup 让前 10% 步数慢慢升上去。如果还是不稳先冻结底层 6 层只训练顶层和分类头再解冻全量微调。6. 进阶技巧用对抗训练和模型融合再涨两个点6.1 FGM 对抗训练在中文分类里的实现RoBERTa 微调到 95% 以后想再往上走对抗训练是最划算的手段。FGMFast Gradient Method在 embedding 层加扰动让模型对输入微小变化更鲁棒。实现起来只多十几行代码。class FGM: def __init__(self, model, epsilon1.0): self.model model self.epsilon epsilon self.backup {} def attack(self): for name, param in self.model.named_parameters(): if param.requires_grad and embedding in name: self.backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0: r_at self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data self.backup[name] self.backup {} # 训练循环里插入 fgm FGM(model) with autocast(): outputs model(input_ids, attention_maskattention_mask, labelslabels) loss outputs.loss scaler.scale(loss).backward() fgm.attack() with autocast(): outputs_adv model(input_ids, attention_maskattention_mask, labelslabels) loss_adv outputs_adv.loss scaler.scale(loss_adv).backward() fgm.restore() scaler.step(optimizer) scaler.update()epsilon1.0是扰动幅度太大训练不稳太小没效果一般 0.5 到 1.0 之间调。attack()只在 embedding 层加扰动因为那里参数少、扰动影响大。restore()必须在下一次前向之前调用否则参数被污染。加上 FGM 后训练时间增加约 30%准确率通常能涨 1 到 2 个点。6.2 多模型投票融合与置信度加权单模型到瓶颈后融合是另一个稳定涨点的手段。我一般训三个模型RoBERTa、BERT-wwm、ERNIE然后对预测概率做加权平均。权重按各模型在验证集上的 F1 分配F1 高的权重大。模型验证集 F1融合权重RoBERTa-wwm-ext0.9560.4BERT-wwm-ext0.9480.3ERNIE-1.00.9510.3融合时把三个模型的 softmax 输出按权重相加取 argmax。注意三个模型的标签映射必须一致否则投票结果错乱。如果某个模型对某类特别强可以按类别设权重但实现复杂度高一般全局权重就够。6.3 我自己的习惯先跑通再优化这套流程我跑过不下十遍最大的教训是不要一上来就上 RoBERTa。先用 TF-IDF LinearSVC 花半小时跑通全流程确认数据没问题、评估指标合理再换预训练模型。否则数据里的编码错误、标签错位、类别不均衡会在预训练模型上放大成更难排查的问题。另一个习惯是每次实验都固定随机种子torch.manual_seed(42)、np.random.seed(42)、random.seed(42)三件套不然结果不可复现调参全靠玄学。最后验证集一定要留够我一般用 8:1:1 切分测试集只在最后评估用一次避免过拟合测试集。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进