简介: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", encoding="utf-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(), key=lambda 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", encoding="utf-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_size=0.2, random_state=42, stratify=labels ) # 临时集再对半分成验证和测试,各占 10% X_val, X_test, y_val, y_test = train_test_split( X_tmp, y_tmp, test_size=0.5, random_state=42, stratify=y_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", encoding="utf-8") as f: json.dump({"label2id": label2id, "id2label": id2label}, f, ensure_ascii=False, indent=2) 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_len=MAX_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_idx=0让 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], dtype=torch.long), torch.tensor(self.labels[idx], dtype=torch.long) train_loader = DataLoader(NewsDataset(train_ids, y_train_ids), batch_size=64, shuffle=True) val_loader = DataLoader(NewsDataset(val_ids, y_val_ids), batch_size=64, shuffle=False) test_loader = DataLoader(NewsDataset(test_ids, y_test_ids), batch_size=64, shuffle=False)batch_size=64是常见起点,显存不够就降到 32 或 16。shuffle只在训练集开,验证和测试必须关,否则评估结果没有可比性。到这一步,数据就已经是模型能直接吃的形式了。
4. 训练与评估:把 84 万篇跑成一个可复现的基线
4.1 模型选型与最小可跑结构
THUCNews 上常见的基线有 TextCNN、BiLSTM、BERT 微调。如果只是想验证数据管线通不通,TextCNN 最快,几分钟就能出一个不差的准确率。下面是一个极简 TextCNN,embedding 维度 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_idx=0) 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, dim=1) x = self.dropout(x) return self.fc(x)padding_idx=0让 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(), lr=1e-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(dim=1) correct += (pred == y).sum().item() total += y.size(0) acc = correct / total print(f"epoch {epoch}, val_acc={acc:.4f}") if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "best_model.pt")lr=1e-3对 TextCNN 通常合适,太大容易震荡,太小收敛慢。epoch=5是起步值,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(dim=1) all_preds.extend(pred.cpu().tolist()) all_labels.extend(y.cpu().tolist()) print(classification_report(all_labels, all_preds, target_names=label_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_idx=0必须在 embedding 层显式指定。
5.5 现象:读取文件时报 UnicodeDecodeError
原因:少数文件不是 UTF-8 编码,可能是 GBK 或 GB18030。解决:读取时加errors="ignore"或先尝试 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_labels=14).to(device) # 用 tokenizer 重新编码,注意 truncation 和 padding def encode_bert(texts, max_len=512): return tokenizer( texts, max_length=max_len, truncation=True, padding="max_length", return_tensors="pt" ) optimizer = AdamW(model.parameters(), lr=2e-5)truncation=True保证超长文本被截断,padding="max_length"统一长度。AdamW的权重衰减比普通 Adam 更稳,适合微调。训练循环和 TextCNN 类似,但 batch_size 通常要降到 16 或 32,因为 BERT 显存占用大得多。
验证方法上,我习惯在测试集上同时跑 TextCNN 和 BERT,对比 macro-F1 和混淆矩阵。如果 BERT 提升不明显,可能是数据本身噪声大,或者类目边界模糊,这时候清洗数据比换模型更有效。还有一个技巧是先用 THUCNews 做预训练或继续预训练,再在下游任务微调,但对大多数场景,直接微调已经够用。
从那以后我每次拿到新数据集,都强制先跑一遍统计脚本和标签映射检查,再进训练。这个习惯帮我省掉了无数次“为什么指标不对”的返工。希望帮到你。
本文还有配套的精品资源,点击获取