
简介THUCNews中文文本分类数据集面向自然语言处理与机器学习方向的研究者、学生及算法工程师提供84万篇新闻文档、覆盖时政、财经、体育、科技、教育等14个类别的中文语料可用于文本分类模型的训练、验证与算法对比。资源包共46个文件以Python脚本为主辅以Shell运行脚本、TSV数据文件、JSON标签映射与配置文件以及说明文档和许可证压缩包约3.93MB结构清晰便于快速搭建实验流程。内容涵盖数据预处理、词典构建、BERT微调与蒸馏、FastText训练及多分类器集成测试等模块读者可据此复现从数据准备到模型评估的完整链路并尝试词嵌入、TF-IDF、预训练语言模型等不同方案理解多类别新闻分类中的特征提取与泛化问题。目前已有938人学习下载适合希望系统实践中文文本分类的中高级学习者参考。1. 84 万篇新闻、14 个类目这份中文文本分类数据集到底能跑出什么结果如果你最近在调中文文本分类的模型大概率会遇到一个尴尬THUCNews 这个名字听了无数遍真到要下载、解压、切分、喂给模型的时候反而卡在“它到底长什么样、标签怎么对应、要不要自己再清洗”上。THUCNews 中文文本分类数据集就是为解决这类中文多分类任务准备的约 84 万篇新闻文档覆盖 14 个类目从体育、财经到科技、娱乐都有适合拿来做基线复现、模型对比、预训练微调也适合新手把“分词—向量化—训练—评估”这条链路完整走一遍。它不挑框架PyTorch、TensorFlow、PaddleNLP 都能接真正麻烦的是数据组织方式和几个容易翻车的细节。这篇就把我拆包、清洗、训练、排错的全过程摊开讲让你拿到手就能跑而不是对着压缩包发呆。2. 先看清数据长什么样14 类标签、文件组织与切分逻辑2.1 目录结构与标签映射THUCNews 原始形态通常是一个按类目分文件夹的语料库每个类目一个子目录里面是若干.txt文件一个文件对应一篇新闻。常见做法是先把所有文件读成(文本, 标签)的列表再统一做切分。这里第一个坑就是类目文件夹名不一定是最终标签有的版本用中文名有的用拼音缩写必须先固定一份映射表否则训练时标签错位准确率会莫名其妙卡在 1/14 附近。我一般会先跑一段统计脚本把每个类目的文件数、平均长度、最长最短文本打出来确认没有空文件或异常超长文本。下面这段代码就是干这个的逻辑很直白遍历目录、累计计数、输出分布。import os from collections import defaultdict # 数据集根目录下面按类目分子文件夹 root THUCNews stats defaultdict(lambda: {count: 0, chars: 0, max_len: 0, min_len: 10**9}) for label in os.listdir(root): label_dir os.path.join(root, label) if not os.path.isdir(label_dir): continue for fname in os.listdir(label_dir): fpath os.path.join(label_dir, fname) try: with open(fpath, r, encodingutf-8) as f: text f.read().strip() except UnicodeDecodeError: # 少数文件可能是 gbk直接跳过并记录 print(编码异常:, fpath) continue if not text: continue n len(text) stats[label][count] 1 stats[label][chars] n stats[label][max_len] max(stats[label][max_len], n) stats[label][min_len] min(stats[label][min_len], n) for label, s in sorted(stats.items(), keylambda x: -x[1][count]): avg s[chars] / s[count] if s[count] else 0 print(f{label}: 篇数{s[count]}, 平均长度{avg:.1f}, 最长{s[max_len]}, 最短{s[min_len]})这段脚本的关键参数就两个root指向解压后的根目录encoding优先用utf-8。如果打印出某个类目篇数明显偏少或者最短长度为 0说明数据里有脏文件得先清掉再进下一步。平均长度这个值很重要它直接决定你后面max_length设 128 还是 512——设太小会截断长新闻设太大显存吃不消。2.2 训练/验证/测试切分与类别均衡84 万篇听起来多但 14 个类目并不是完全均分有的类目可能多出几万篇。直接按 8:1:1 随机切分通常够用但如果做严谨对比实验建议分层采样保证每个类目在三个集合里的比例一致。常见做法是用sklearn的train_test_split加stratify参数先切出训练集和临时集再从临时集切验证和测试。import os import random from sklearn.model_selection import train_test_split def load_dataset(root): texts, labels [], [] for label in sorted(os.listdir(root)): label_dir os.path.join(root, label) if not os.path.isdir(label_dir): continue for fname in os.listdir(label_dir): with open(os.path.join(label_dir, fname), r, encodingutf-8) as f: text f.read().strip() if text: texts.append(text) labels.append(label) return texts, labels texts, labels load_dataset(THUCNews) # 先切 80% 训练20% 临时 X_train, X_tmp, y_train, y_tmp train_test_split( texts, labels, test_size0.2, random_state42, stratifylabels ) # 临时集再对半分成验证和测试各占 10% X_val, X_test, y_val, y_test train_test_split( X_tmp, y_tmp, test_size0.5, random_state42, stratifyy_tmp ) print(len(X_train), len(X_val), len(X_test))random_state固定住是为了可复现stratify保证类别比例。这里有个血泪经验如果你先切完再去做文本清洗比如去 HTML 标签、去特殊符号一定要保证清洗逻辑对三个集合完全一致否则验证集分布和训练集不一致指标会虚高。我一般把清洗函数写成纯函数三个集合都调同一个。2.3 标签编码与持久化模型不认字符串标签得转成整数。别小看这一步标签映射如果每次训练重新生成顺序可能变导致保存的模型和推理时的 id 对不上。正确做法是把label2id和id2label存成 JSON训练和推理共用同一份。import json label_list sorted(set(labels)) label2id {label: i for i, label in enumerate(label_list)} id2label {i: label for label, i in label2id.items()} with open(label_map.json, w, encodingutf-8) as f: json.dump({label2id: label2id, id2label: id2label}, f, ensure_asciiFalse, indent2) y_train_ids [label2id[y] for y in y_train] y_val_ids [label2id[y] for y in y_val] y_test_ids [label2id[y] for y in y_test]sorted是为了让映射稳定不依赖文件系统遍历顺序。存成 JSON 后后面无论换什么模型、换什么机器只要加载这份映射标签就不会错位。这个习惯能帮你省掉很多“为什么推理结果全是同一类”的排查时间。3. 从原始文本到模型输入分词、截断与 DataLoader 落地3.1 中文分词与词表构建中文不像英文有天然空格常见做法有两种一是用jieba分词后按词建词表二是直接用字符级建模。THUCNews 这种新闻语料词级效果通常略好但词表会大很多。我一般先用jieba切词统计词频保留出现次数大于 2 的词其余归到unk。import jieba from collections import Counter def tokenize(text): return list(jieba.cut(text)) # 只统计训练集避免验证/测试信息泄漏 counter Counter() for text in X_train: counter.update(tokenize(text)) # 保留高频词低频归入 unk vocab {pad: 0, unk: 1} for word, freq in counter.most_common(): if freq 2: continue if word not in vocab: vocab[word] len(vocab) print(词表大小:, len(vocab))freq 2这个阈值可以调语料大就设 2 到 3语料小就设 1。词表太大不仅占内存还会让 embedding 层参数爆炸。注意这里只用训练集统计词频验证集和测试集的词如果不在词表里统一映射到unk这是避免信息泄漏的基本操作。3.2 序列截断与 padding 策略新闻长度差异很大短的一两百字长的几千字。常见做法是设一个max_length比如 256超出的截断不足的补pad。截断时建议保留头部因为新闻导语通常在前几段信息密度最高。如果做严谨实验可以对比“截头”和“截尾”的效果但大多数场景截头够用。MAX_LEN 256 def encode(text, vocab, max_lenMAX_LEN): tokens tokenize(text) ids [vocab.get(w, vocab[unk]) for w in tokens] if len(ids) max_len: ids ids[:max_len] # 截断保留前 max_len 个词 else: ids ids [vocab[pad]] * (max_len - len(ids)) return ids train_ids [encode(t, vocab) for t in X_train] val_ids [encode(t, vocab) for t in X_val] test_ids [encode(t, vocab) for t in X_test]MAX_LEN是核心参数。设 128 训练快但可能丢信息设 512 覆盖全但显存吃紧。我的经验是先用统计脚本看 95 分位长度再取略大于它的值。padding 用 0 是因为pad的 id 就是 0后面在模型里用padding_idx0让 embedding 忽略它。3.3 用 Dataset 和 DataLoader 组装批次手写 batch 容易出错用 PyTorch 的Dataset和DataLoader最稳。注意collate_fn里要把 list 转成 tensor并且标签用long类型。import torch from torch.utils.data import Dataset, DataLoader class NewsDataset(Dataset): def __init__(self, ids, labels): self.ids ids self.labels labels def __len__(self): return len(self.ids) def __getitem__(self, idx): return torch.tensor(self.ids[idx], dtypetorch.long), torch.tensor(self.labels[idx], dtypetorch.long) train_loader DataLoader(NewsDataset(train_ids, y_train_ids), batch_size64, shuffleTrue) val_loader DataLoader(NewsDataset(val_ids, y_val_ids), batch_size64, shuffleFalse) test_loader DataLoader(NewsDataset(test_ids, y_test_ids), batch_size64, shuffleFalse)batch_size64是常见起点显存不够就降到 32 或 16。shuffle只在训练集开验证和测试必须关否则评估结果没有可比性。到这一步数据就已经是模型能直接吃的形式了。4. 训练与评估把 84 万篇跑成一个可复现的基线4.1 模型选型与最小可跑结构THUCNews 上常见的基线有 TextCNN、BiLSTM、BERT 微调。如果只是想验证数据管线通不通TextCNN 最快几分钟就能出一个不差的准确率。下面是一个极简 TextCNNembedding 维度 128卷积核用 3、4、5 三种尺寸。import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv2d(1, 128, (k, embed_dim)) for k in (3, 4, 5) ]) self.dropout nn.Dropout(0.5) self.fc nn.Linear(128 * 3, num_classes) def forward(self, x): # x: [batch, seq_len] x self.embedding(x) # [batch, seq_len, embed_dim] x x.unsqueeze(1) # [batch, 1, seq_len, embed_dim] x [F.relu(conv(x)).squeeze(3) for conv in self.convs] x [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] x torch.cat(x, dim1) x self.dropout(x) return self.fc(x)padding_idx0让 padding 不参与梯度Dropout(0.5)是防过拟合的常规手段。卷积核尺寸 3、4、5 分别捕捉不同长度的 n-gram 特征这是 TextCNN 的经典配置。类别数就是 14词表大小用前面统计的len(vocab)。4.2 训练循环与关键超参训练循环里要盯三个东西loss 是否下降、验证集准确率是否提升、有没有过拟合。常见做法是每轮跑完验证集保存验证集最好的模型。device torch.device(cuda if torch.cuda.is_available() else cpu) model TextCNN(len(vocab), 128, 14).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() best_acc 0.0 for epoch in range(5): model.train() for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() model.eval() correct, total 0, 0 with torch.no_grad(): for x, y in val_loader: x, y x.to(device), y.to(device) pred model(x).argmax(dim1) correct (pred y).sum().item() total y.size(0) acc correct / total print(fepoch {epoch}, val_acc{acc:.4f}) if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pt)lr1e-3对 TextCNN 通常合适太大容易震荡太小收敛慢。epoch5是起步值84 万篇数据量大一般 3 到 5 轮就能看出趋势。如果验证集准确率远低于训练集说明过拟合可以加大 dropout 或加 L2 正则。4.3 评估指标与混淆矩阵准确率在类别不均衡时不够用建议同时看 macro-F1 和混淆矩阵。混淆矩阵能告诉你哪些类目容易被混比如体育和娱乐有时会互相误判。from sklearn.metrics import classification_report, confusion_matrix model.eval() all_preds, all_labels [], [] with torch.no_grad(): for x, y in test_loader: x, y x.to(device), y.to(device) pred model(x).argmax(dim1) all_preds.extend(pred.cpu().tolist()) all_labels.extend(y.cpu().tolist()) print(classification_report(all_labels, all_preds, target_nameslabel_list)) print(confusion_matrix(all_labels, all_preds))target_names用前面保存的label_list这样报告里显示的是中文类目名不是数字。混淆矩阵里如果某两个类目交叉值特别高说明特征区分度不够可以考虑加更多训练数据或换更强的预训练模型。5. 避坑与排查这 5 个问题我几乎每次都能遇到5.1 现象准确率卡在 7% 左右loss 不降原因标签映射错位模型学到的是随机标签14 类随机猜就是 7% 左右。解决检查label2id是否在训练和评估时一致确认y_train_ids和label_list对应关系没变。把label_map.json加载回来重新验证一遍。5.2 现象训练时 loss 正常下降验证集准确率一直不涨原因训练集和验证集分布不一致常见于先切分后清洗或者验证集里混入了训练集文本。解决把清洗函数统一切分前先做一次全量去重确保同一篇新闻不会同时出现在训练和验证里。5.3 现象显存溢出batch_size 降到 8 还是报错原因MAX_LEN设太大或者词表太大导致 embedding 层参数过多。解决先看 95 分位文本长度把MAX_LEN降到合理值词表方面低频词归unk别把 84 万篇里所有词都塞进去。5.4 现象推理时所有文本都预测成同一类原因模型保存时只存了state_dict但推理时词表和标签映射没对齐或者padding_idx没设对。解决把词表、标签映射、模型结构配置一起保存推理时按同一份配置加载。padding_idx0必须在 embedding 层显式指定。5.5 现象读取文件时报 UnicodeDecodeError原因少数文件不是 UTF-8 编码可能是 GBK 或 GB18030。解决读取时加errorsignore或先尝试 UTF-8失败再试 GBK。更稳妥的做法是统一转码一遍把整个数据集转成 UTF-8 再进管线。6. 进阶技巧用预训练模型把准确率再抬一截TextCNN 跑通之后如果想冲更高指标最直接的路是换 BERT 类预训练模型。THUCNews 这种规模用bert-base-chinese微调通常能把准确率从 90% 出头抬到 95% 以上。但这里有几个参数和习惯必须注意否则容易白跑。首先是max_length。BERT 的输入上限是 512但新闻平均长度可能超过这个值截断策略要重新评估。我一般会统计 90 分位长度如果超过 512就只保留头部 512 个 token因为新闻导语信息最密集。其次是学习率BERT 微调常用 2e-5 到 5e-5比 TextCNN 小一个数量级太大会把预训练权重冲垮。from transformers import BertTokenizer, BertForSequenceClassification, AdamW tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertForSequenceClassification.from_pretrained(bert-base-chinese, num_labels14).to(device) # 用 tokenizer 重新编码注意 truncation 和 padding def encode_bert(texts, max_len512): return tokenizer( texts, max_lengthmax_len, truncationTrue, paddingmax_length, return_tensorspt ) optimizer AdamW(model.parameters(), lr2e-5)truncationTrue保证超长文本被截断paddingmax_length统一长度。AdamW的权重衰减比普通 Adam 更稳适合微调。训练循环和 TextCNN 类似但 batch_size 通常要降到 16 或 32因为 BERT 显存占用大得多。验证方法上我习惯在测试集上同时跑 TextCNN 和 BERT对比 macro-F1 和混淆矩阵。如果 BERT 提升不明显可能是数据本身噪声大或者类目边界模糊这时候清洗数据比换模型更有效。还有一个技巧是先用 THUCNews 做预训练或继续预训练再在下游任务微调但对大多数场景直接微调已经够用。从那以后我每次拿到新数据集都强制先跑一遍统计脚本和标签映射检查再进训练。这个习惯帮我省掉了无数次“为什么指标不对”的返工。希望帮到你。本文还有配套的精品资源点击获取