简介:THUCNews中文文本分类数据集面向自然语言处理与机器学习方向的研究者、学生及算法工程师,提供84万篇新闻文档、覆盖时政、财经、体育、科技、教育等14个类别的中文语料,可用于文本分类模型的训练、验证与算法对比。资源包共46个文件,以27个Python脚本和6个Shell脚本为主,辅以4个TSV数据文件、4个JSON标签映射、3个TXT停用词与说明文档,以及LICENSE和README,压缩包约3.93MB,目录涵盖数据预处理、词典构建、BERT微调与蒸馏、FastText训练及多分类器集成等模块。已有938人学习下载。读者可据此复现从数据准备、特征提取到模型训练与评估的完整流程,并在此基础上尝试词嵌入、TF-IDF、预训练语言模型等不同方案,适合作为中文文本分类任务的实践参考与基准测试平台。
1. 84万篇新闻、14个类:THUCNews中文文本分类数据集到底怎么用
如果你正在找一个能直接跑中文文本分类的数据集,THUCNews 大概率会出现在候选清单里。它包含约 84 万篇新闻文档,覆盖 14 个类别,从体育、财经到科技、娱乐都有,规模够大、类别清晰、文本质量在中文新闻语料里属于第一梯队。我第一次用它是在一个舆情分类项目里,当时试过几个小数据集,模型还没收敛就过拟合了,换成 THUCNews 之后 F1 直接涨了十几个点。这篇文章不讲空话,我会把从数据获取、预处理、模型选型到训练调参的完整路径拆开,每一步都给出可复现的代码和参数说明。适合两类人:刚接触中文文本分类、需要一个靠谱数据集练手的新手;以及正在做新闻分类、情感分析、主题识别,想找一个工业级语料做 baseline 的工程师。读完你至少能跑通一个 14 分类的完整 pipeline,知道坑在哪、参数怎么调。
2. 先搞清楚 THUCNews 的底子:14 类怎么分、84 万篇怎么用
2.1 类别分布与典型样本
THUCNews 的 14 个类别分别是:体育、财经、房产、家居、教育、科技、时尚、时政、游戏、娱乐、彩票、星座、股票、社会。这个划分不是随便定的,它对应的是新闻门户的频道结构,所以类别之间有一定的语义重叠——比如“股票”和“财经”、“彩票”和“社会”边界并不总是清晰。实际用的时候,你会发现“股票”类里很多文章讲的是大盘走势,而“财经”类更偏向宏观经济政策,但两者交叉不少。
每个类别的样本量并不完全均衡,体育、娱乐、财经这类大类的文档数明显多于星座、彩票。如果你直接拿全量数据训练,模型会偏向大类,小类召回率上不去。常见做法是对小类做欠采样或者对大类的损失加权。我一般会先统计一下类别分布,再决定要不要做重采样。
import os from collections import Counter # 假设数据按类别存放在不同文件夹下 data_dir = "THUCNews" label_counts = {} for label in os.listdir(data_dir): label_path = os.path.join(data_dir, label) if os.path.isdir(label_path): # 每个类别一个文件夹,文件夹内是 txt 文件 files = [f for f in os.listdir(label_path) if f.endswith(".txt")] label_counts[label] = len(files) # 打印类别分布 for label, count in sorted(label_counts.items(), key=lambda x: -x[1]): print(f"{label}: {count}") # 计算不平衡比例 max_count = max(label_counts.values()) min_count = min(label_counts.values()) print(f"最大类/最小类 = {max_count / min_count:.2f}")这段代码遍历每个类别文件夹,统计文件数量。参数说明:data_dir是数据集根目录,每个子文件夹名就是类别标签。输出结果能让你直观看到哪些类需要特殊处理。如果最大类是最小类的 5 倍以上,建议在训练时用WeightedRandomSampler或者给损失函数加class_weight。
2.2 文本长度分布与截断策略
新闻文档的长度差异很大,短的几十个字,长的上千字。THUCNews 里的文档平均长度在 500 到 800 字之间,但尾部拖得很长。做分类时,如果直接用 BERT 这类模型,最大输入长度 512 个 token 是硬限制,超出的部分必须截断。截断策略直接影响效果:只取前 512 个 token 可能丢掉结尾的关键信息,取头尾拼接又可能破坏语义连贯性。
我的经验是,先统计一下 token 长度分布,看看 95 分位数在哪里。如果 95% 的文档都在 512 以内,那就直接截断尾部;如果超过 512 的占比很高,考虑用滑动窗口或者层次化模型。对于 THUCNews,大部分文档在 512 token 以内,所以直接截断到 512 是安全的。
from transformers import BertTokenizer import numpy as np tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") lengths = [] # 抽样统计,避免全量跑太慢 sample_files = [] for label in os.listdir(data_dir): label_path = os.path.join(data_dir, label) if os.path.isdir(label_path): files = [f for f in os.listdir(label_path) if f.endswith(".txt")][:100] sample_files.extend([os.path.join(label_path, f) for f in files]) for file_path in sample_files: with open(file_path, "r", encoding="utf-8") as f: text = f.read().strip() tokens = tokenizer.encode(text, add_special_tokens=False) lengths.append(len(tokens)) lengths = np.array(lengths) print(f"平均长度: {lengths.mean():.0f}") print(f"95分位: {np.percentile(lengths, 95):.0f}") print(f"99分位: {np.percentile(lengths, 99):.0f}") print(f"超过512的比例: {(lengths > 512).mean() * 100:.1f}%")这里用bert-base-chinese的 tokenizer 做分词,统计 token 数量而不是字符数。参数说明:add_special_tokens=False是为了排除[CLS]和[SEP],只算正文长度。如果超过 512 的比例低于 5%,直接截断没问题;如果高于 10%,就要考虑其他策略了。
2.3 训练集/验证集/测试集的划分方式
THUCNews 官方没有给出固定的划分,你需要自己切分。常见做法是按 8:1:1 或者 7:1:2 划分。注意两点:第一,划分前要打乱数据,避免同一类别的文档集中在一起;第二,如果做多轮实验,固定随机种子,保证每次划分一致,否则结果没法对比。
我一般会先把所有文件路径和标签读成一个列表,用sklearn.model_selection.train_test_split切分,然后再把训练集切出一部分做验证集。代码里要设置random_state,这样别人复现你的实验时能得到同样的划分。
import random from sklearn.model_selection import train_test_split all_files = [] all_labels = [] for label in os.listdir(data_dir): label_path = os.path.join(data_dir, label) if os.path.isdir(label_path): for f in os.listdir(label_path): if f.endswith(".txt"): all_files.append(os.path.join(label_path, f)) all_labels.append(label) # 先切出测试集 train_files, test_files, train_labels, test_labels = train_test_split( all_files, all_labels, test_size=0.1, random_state=42, stratify=all_labels ) # 再从训练集切出验证集 train_files, val_files, train_labels, val_labels = train_test_split( train_files, train_labels, test_size=0.1, random_state=42, stratify=train_labels ) print(f"训练集: {len(train_files)}, 验证集: {len(val_files)}, 测试集: {len(test_files)}")stratify=all_labels保证每个类别在切分后的比例一致,避免某个小类在验证集里一个样本都没有。random_state=42是固定种子,你可以改成任意整数,但一旦定了就不要变。
3. 从原始文本到模型输入:预处理与特征工程怎么做
3.1 清洗规则与停用词处理
THUCNews 的文本已经比较干净,但仍有少量噪声:HTML 残留标签、多余空格、特殊符号。清洗时不要过度,比如把数字全部去掉、把英文全部转小写,这些操作在新闻分类里可能反而有害——数字和英文往往是关键特征。我一般只做三件事:去掉 HTML 标签、合并连续空白字符、去掉首尾空格。
停用词方面,中文新闻里“的”、“了”、“在”这些词确实高频,但在 BERT 这类预训练模型里,停用词不需要手动去除,模型自己会学到注意力权重。如果你用的是 TF-IDF + 传统分类器,那停用词表就有必要了。常见的中文停用词表有哈工大停用词表、百度停用词表,选一个就行,不用纠结。
import re def clean_text(text): # 去掉 HTML 标签 text = re.sub(r"<[^>]+>", "", text) # 合并连续空白字符 text = re.sub(r"\s+", " ", text) # 去掉首尾空格 text = text.strip() return text # 示例 raw = "<p>今天 股市 大涨</p>" print(clean_text(raw)) # 输出: 今天 股市 大涨re.sub(r"<[^>]+>", "", text)匹配所有尖括号包裹的内容并删除。\s+匹配任意空白字符序列,替换成单个空格。这两条规则足够应付 THUCNews 里的噪声,不需要更复杂的清洗。
3.2 用 BERT tokenizer 做子词切分
BERT 的中文 tokenizer 是按字切分的,每个汉字对应一个 token,英文单词会被切成子词。这个特性意味着你不需要先做分词再喂给模型,直接把原始文本传进去就行。但要注意,tokenizer 会自动添加[CLS]和[SEP],并且有最大长度限制。
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") text = "今天股市大涨,科技股领涨" encoded = tokenizer( text, max_length=128, padding="max_length", truncation=True, return_tensors="pt" ) print(encoded["input_ids"].shape) # torch.Size([1, 128]) print(tokenizer.convert_ids_to_tokens(encoded["input_ids"][0]))参数说明:max_length=128是短文本分类的常用值,THUCNews 新闻较长,实际训练时建议设成 256 或 512。padding="max_length"会把所有序列补齐到统一长度,方便批量训练。truncation=True表示超长截断。return_tensors="pt"返回 PyTorch 张量。
3.3 构建 Dataset 和 DataLoader
PyTorch 的Dataset类需要实现__len__和__getitem__。在__getitem__里读文件、清洗、tokenize,返回 input_ids、attention_mask 和 label。注意 label 要转成整数,可以用一个字典做映射。
import torch from torch.utils.data import Dataset, DataLoader class NewsDataset(Dataset): def __init__(self, file_paths, labels, tokenizer, label2id, max_len=256): self.file_paths = file_paths self.labels = labels self.tokenizer = tokenizer self.label2id = label2id self.max_len = max_len def __len__(self): return len(self.file_paths) def __getitem__(self, idx): with open(self.file_paths[idx], "r", encoding="utf-8") as f: text = f.read().strip() text = clean_text(text) encoding = self.tokenizer( text, max_length=self.max_len, padding="max_length", truncation=True, return_tensors="pt" ) return { "input_ids": encoding["input_ids"].squeeze(0), "attention_mask": encoding["attention_mask"].squeeze(0), "label": torch.tensor(self.label2id[self.labels[idx]], dtype=torch.long) } # 构建 label2id unique_labels = sorted(list(set(all_labels))) label2id = {label: i for i, label in enumerate(unique_labels)} train_dataset = NewsDataset(train_files, train_labels, tokenizer, label2id) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4)batch_size=32是 BERT base 在 8GB 显存下的常见选择,如果显存不够就降到 16。num_workers=4加快数据加载,但 Windows 上可能要设成 0。shuffle=True只在训练集用,验证集和测试集不要打乱。
4. 模型选型与训练:从 TextCNN 到 BERT 的取舍
4.1 传统基线:TF-IDF + 线性分类器
在上深度学习之前,先跑一个 TF-IDF + 逻辑回归的基线。这不是浪费时间,而是给你一个下限参考。如果 BERT 只比 TF-IDF 高两三个点,那可能不值得上大模型。TF-IDF 的max_features设成 50000 到 100000,ngram_range用 (1,2) 捕捉二元词组。
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.metrics import classification_report # 读取所有文本 def load_texts(file_paths): texts = [] for fp in file_paths: with open(fp, "r", encoding="utf-8") as f: texts.append(clean_text(f.read().strip())) return texts train_texts = load_texts(train_files) val_texts = load_texts(val_files) pipeline = Pipeline([ ("tfidf", TfidfVectorizer(max_features=50000, ngram_range=(1, 2))), ("clf", LogisticRegression(max_iter=1000, C=1.0, class_weight="balanced")) ]) pipeline.fit(train_texts, train_labels) val_preds = pipeline.predict(val_texts) print(classification_report(val_labels, val_preds))class_weight="balanced"自动处理类别不平衡,C=1.0是正则化强度的倒数,值越小正则化越强。这个基线在 THUCNews 上通常能到 85% 左右的准确率,如果 BERT 能到 95%,那提升就很明显。
4.2 BERT 微调的关键参数
用bert-base-chinese做微调,核心参数就几个:学习率、batch size、epoch 数、warmup 比例。学习率我一般设 2e-5 到 5e-5,太大容易发散,太小收敛慢。batch size 在显存允许下尽量大,32 或 64。epoch 数 3 到 5 足够,再多就过拟合了。warmup 比例设 0.1,让学习率在前 10% 的步数里线性增长。
from transformers import BertForSequenceClassification, AdamW, get_linear_schedule_with_warmup import torch.nn as nn device = torch.device("cuda" if torch.cuda.is_available() else "cpu") num_labels = len(unique_labels) model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=num_labels ).to(device) optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) epochs = 3 total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps ) criterion = nn.CrossEntropyLoss()weight_decay=0.01是 BERT 微调的标配,防止过拟合。get_linear_schedule_with_warmup实现学习率线性衰减,warmup 步数占总步数的 10%。CrossEntropyLoss默认不做类别加权,如果类别不平衡严重,可以传入weight参数。
4.3 训练循环与验证集监控
训练循环里每个 epoch 跑完在验证集上算一次准确率和 F1,保存最好的模型。不要只看 loss,loss 下降不代表 F1 上升。早停策略:如果连续两个 epoch 验证集 F1 没提升,就停。
from sklearn.metrics import f1_score best_f1 = 0.0 patience = 2 no_improve = 0 for epoch in range(epochs): model.train() total_loss = 0 for batch in train_loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["label"].to(device) optimizer.zero_grad() outputs = model(input_ids, attention_mask=attention_mask, labels=labels) loss = outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() total_loss += loss.item() # 验证 model.eval() val_preds = [] val_trues = [] with torch.no_grad(): for batch in val_loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["label"].to(device) outputs = model(input_ids, attention_mask=attention_mask) preds = torch.argmax(outputs.logits, dim=-1) val_preds.extend(preds.cpu().numpy()) val_trues.extend(labels.cpu().numpy()) val_f1 = f1_score(val_trues, val_preds, average="macro") print(f"Epoch {epoch+1}, Loss: {total_loss/len(train_loader):.4f}, Val F1: {val_f1:.4f}") if val_f1 > best_f1: best_f1 = val_f1 torch.save(model.state_dict(), "best_model.pt") no_improve = 0 else: no_improve += 1 if no_improve >= patience: print("Early stopping") breakclip_grad_norm_(max_norm=1.0)防止梯度爆炸,这在 BERT 微调里很重要。average="macro"计算宏平均 F1,每个类别权重相同,能反映小类的表现。如果只看准确率,大类会掩盖小类的问题。
5. 避坑与排查:THUCNews 实战中容易翻车的 5 个点
5.1 现象:训练 loss 正常下降,但验证集 F1 始终在 20% 左右
原因:标签映射错了。THUCNews 的文件夹名是中文,如果你用os.listdir的顺序直接映射成 0 到 13,不同机器上的顺序可能不一样,导致标签和模型输出对不上。解决:显式构建label2id字典,用sorted保证顺序一致,并且在保存模型时把label2id一起存下来。
5.2 现象:BERT 微调时显存溢出,batch size 降到 8 还是 OOM
原因:max_length设得太大,比如 512,加上 batch size 8,显存占用仍然很高。解决:先把max_length降到 256,如果效果下降不明显就保持;或者用梯度累积,batch_size=8累积 4 步等效于 32。另外,num_workers设太大会占用额外显存,设成 2 或 4 就行。
5.3 现象:小类(如星座、彩票)的召回率极低,几乎为 0
原因:类别不平衡,模型倾向于预测大类。解决:在CrossEntropyLoss里传入weight参数,权重设为类别频率的倒数;或者在DataLoader里用WeightedRandomSampler过采样小类。注意过采样不要太过,否则小类过拟合。
5.4 现象:TF-IDF 基线跑出来准确率 90%,BERT 只有 92%,提升不明显
原因:THUCNews 的类别区分度本身很高,很多类别靠关键词就能分对。解决:检查一下测试集里有没有重复文档或者近似重复,如果有,说明数据泄漏了。另外,试试用bert-base-chinese的最后一层[CLS]向量做特征,接一个 SVM,有时候比直接微调效果更好。
5.5 现象:训练时 loss 震荡严重,有时突然变成 NaN
原因:学习率太大,或者没有做梯度裁剪。解决:把学习率从 5e-5 降到 2e-5,加上clip_grad_norm_(max_norm=1.0)。如果还是 NaN,检查一下输入里有没有空文本,空文本经过 tokenizer 后只有[CLS]和[SEP],可能导致异常。在__getitem__里加一个判断,空文本直接跳过或者用默认文本替代。
6. 进阶技巧:用对抗训练和模型融合再压榨几个点
6.1 FGM 对抗训练在 THUCNews 上的效果
对抗训练的核心思想是在 embedding 上加扰动,让模型对微小变化更鲁棒。FGM(Fast Gradient Method)是最简单的一种,实现起来不到 20 行代码。我在 THUCNews 上试过,F1 能涨 0.5 到 1 个点,训练时间增加约 30%。
class FGM: def __init__(self, model, epsilon=1.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) for batch in train_loader: # 正常前向反向 outputs = model(input_ids, attention_mask=attention_mask, labels=labels) loss = outputs.loss loss.backward() # 对抗训练 fgm.attack() outputs_adv = model(input_ids, attention_mask=attention_mask, labels=labels) loss_adv = outputs_adv.loss loss_adv.backward() fgm.restore() optimizer.step() scheduler.step() optimizer.zero_grad()epsilon=1.0是扰动幅度,太大反而掉点,0.5 到 1.0 之间比较稳。attack里只对 embedding 层加扰动,其他层不动。注意optimizer.zero_grad()要放在最后,因为对抗训练做了两次反向传播。
6.2 多模型融合的简单做法
单模型到 95% 之后,再想提升就得靠融合。最简单的做法是训三个不同随机种子的 BERT,推理时把 logits 平均。如果嫌麻烦,也可以训一个 BERT 和一个 TextCNN,两者结构差异大,融合效果更好。TextCNN 用nn.Embedding从头训,kernel_sizes设 (2,3,4),每个尺寸 128 个卷积核。
import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, kernel_sizes=[2,3,4], num_filters=128): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (k, embed_dim)) for k in kernel_sizes ]) self.dropout = nn.Dropout(0.5) self.fc = nn.Linear(num_filters * len(kernel_sizes), num_classes) def forward(self, x): x = self.embedding(x) # (B, L, D) x = x.unsqueeze(1) # (B, 1, L, D) 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)融合时把 BERT 和 TextCNN 的 softmax 输出按 0.7:0.3 加权平均,权重在验证集上调。我试过这个组合,比单 BERT 高 1.2 个点。但要注意,融合的收益随着单模型变强而递减,如果单模型已经 97% 了,融合可能只涨 0.3 个点,投入产出比就不高了。
6.3 一个我踩过的坑:测试集泄漏
有一次我报告了一个 98.5% 的准确率,结果被同事发现测试集里有和训练集完全相同的文档。THUCNews 是从新闻网站爬的,同一篇新闻可能被多个频道转载,标题和正文几乎一样。如果你随机划分,这些重复文档会同时出现在训练集和测试集里,导致虚高。解决办法:在划分之前先做去重,用 SimHash 或者简单的 MD5 对正文做哈希,重复的只保留一篇。这个步骤花不了多少时间,但能避免你得出错误结论。
我现在养成的习惯是,拿到任何数据集先跑一遍去重,再开始训练。THUCNews 的 84 万篇里,去重后大概会少几千篇,对训练影响不大,但测试结果会真实很多。希望帮到你。
本文还有配套的精品资源,点击获取