☰
THUCNews中文文本分类实战:从数据加载到BERT微调全流程
2026/10/8 5:00:22 网站建设 项目流程

简介:THUCNews中文文本分类数据集面向自然语言处理与机器学习方向的研究者、学生及算法工程师,用于中文新闻文本分类任务的训练、验证与算法对比。数据集包含84万篇新闻文档,覆盖时政、财经、体育、科技、教育等14个类别,类别多样、领域跨度大,适合检验模型在不同领域间的区分能力与泛化性能。资源包共46个文件,以27个Python脚本和6个Shell脚本为主,另含4个tsv数据文件、4个json标签与配置、3个txt文本及license、README等说明文档,压缩包约3.93MB,目录中可见数据预处理、词典构建、BERT微调与蒸馏、fasttext训练及模型定义等模块,便于直接复现多_TextClassifier相关实验。目前已有938人学习下载。借助该资源,读者可完成从数据准备、特征提取到多模型训练与评估的完整流程,并在此基础上尝试词嵌入、TF-IDF、预训练语言模型等方案,用于文本分类算法的优化与创新。

1. 84 万篇新闻、14 个类目:THUCNews 到底适合谁上手

如果你正在找一份中文文本分类的练手数据,又不想在爬虫和清洗上先耗掉一周,THUCNews 大概率会出现在你的候选清单里。它包含约 84 万篇新闻文档,覆盖 14 个类目,是中文文本分类里被反复引用的经典数据集之一。我第一次用它是在做一个标题党识别的小任务,当时最大的感受是:类目清晰、单篇长度适中、噪声比想象中低,拿来跑通一条从分词到模型评估的完整链路非常合适。

它适合三类人:一是刚接触中文 NLP、想跑通第一个分类 pipeline 的新手;二是要对比不同模型(FastText、TextCNN、BERT 微调)在中文长文本上表现的人;三是需要一个稳定基线来做迁移或蒸馏实验的工程师。不适合的是追求极端细粒度标签、或者需要多标签场景的人——它是单标签、14 类的设定。下面我按自己实际跑过的顺序,把这份数据集从加载到出结果讲清楚。

2. 先搞懂 THUCNews 的目录结构与标签体系

2.1 14 个类目分别是什么,长尾分布有多严重

THUCNews 的 14 个类目通常包括:体育、财经、房产、家居、教育、科技、时尚、时政、游戏、娱乐、社会、股票、彩票、星座。不同来源的打包版本在类目命名和数量上可能略有差异,拿到手第一件事是核对目录名,而不是直接假设。

真正影响训练的是分布。新闻语料天然不均衡:体育、娱乐、财经这类体量大,星座、彩票这类体量小。如果不做处理,模型会明显偏向大类。我一般会先统计每个类目的文档数,画一个柱状图,心里有数之后再决定是否做重采样或类别加权。

import os from collections import Counter root = "THUCNews" # 解压后的根目录,下面按类目分子文件夹 counter = Counter() for label in os.listdir(root): sub = os.path.join(root, label) if os.path.isdir(sub): n = len([f for f in os.listdir(sub) if f.endswith(".txt")]) counter[label] = n total = sum(counter.values()) for label, n in counter.most_common(): print(f"{label}\t{n}\t{n/total:.2%}") print("总计:", total)

这段代码做的是最基础的类目盘点。root指向解压目录,脚本假设每个类目是一个子文件夹、每篇文档是一个.txt。counter.most_common()按数量降序输出,方便你一眼看出哪些是长尾类。参数上唯一要改的就是root;如果你的版本是单文件加标签列的格式,这段要换成读 CSV 再groupby。

提示:先跑统计再动手,能省掉后面「为什么模型只预测体育」的排查时间。

2.2 单篇文档的格式与长度分布

THUCNews 的文档一般是纯文本,正文里可能带标题行、来源、时间等元信息,也可能只有正文。长度上,新闻正文从几十字到上千字都有。做分类前建议统计一下 token 长度分布,决定截断长度。

import jieba import numpy as np lengths = [] sample_dir = os.path.join(root, "体育") for fname in os.listdir(sample_dir)[:2000]: with open(os.path.join(sample_dir, fname), encoding="utf-8") as f: text = f.read().strip() lengths.append(len(list(jieba.cut(text)))) print("分词后长度 中位数:", np.median(lengths)) print("90 分位:", np.percentile(lengths, 90)) print("95 分位:", np.percentile(lengths, 95))

这里用jieba分词后统计词数,比按字符数更贴近模型实际输入。np.percentile给出 90/95 分位,通常我会把截断长度设在 95 分位附近,比如 400 到 600 之间,既覆盖大多数样本,又不至于让 padding 浪费太多算力。注意jieba.cut首次调用会加载词典,稍慢,属正常现象。

3. 把原始文本变成模型能吃的训练样本

3.1 划分训练/验证/测试集并固定随机种子

很多人直接拿全量数据训练再随机切分,结果每次评估波动很大。正确做法是先固定切分,再在训练集上做任何拟合操作(比如构建词表)。我一般按 8:1:1 切,并用固定种子保证可复现。

import random from sklearn.model_selection import train_test_split random.seed(42) samples = [] # [(text, label_id), ...] label2id = {label: i for i, label in enumerate(sorted(counter.keys()))} for label in counter: sub = os.path.join(root, label) for fname in os.listdir(sub): if not fname.endswith(".txt"): continue with open(os.path.join(sub, fname), encoding="utf-8") as f: text = f.read().strip() if text: samples.append((text, label2id[label])) train, temp = train_test_split(samples, test_size=0.2, random_state=42, stratify=[s[1] for s in samples]) val, test = train_test_split(temp, test_size=0.5, random_state=42, stratify=[s[1] for s in temp]) print(len(train), len(val), len(test))

关键点是stratify,它保证每个类目在三个集合里的比例一致,长尾类不会在验证集里消失。random_state=42是习惯用法,换成别的固定值也行,但一旦定了就别改,否则前后实验没法比。label2id用sorted保证映射稳定,避免不同机器上标签顺序不一致。

3.2 构建词表:min_freq 和 max_size 怎么定

词表是中文分类里最容易被忽视的一环。太小会大量 OOV,太大会让 embedding 参数爆炸。我的经验是:先统计词频,min_freq设 2 到 3,max_size控制在 3 万到 5 万之间。

from collections import Counter as C word_freq = C() for text, _ in train: word_freq.update(jieba.cut(text)) min_freq, max_size = 2, 40000 vocab = ["<pad>", "<unk>"] + [w for w, c in word_freq.most_common(max_size) if c >= min_freq] word2id = {w: i for i, w in enumerate(vocab)} print("词表大小:", len(vocab))

<pad>和<unk>必须占前两位,索引 0 和 1,后面 padding 和未知词都靠它们。min_freq=2过滤掉只出现一次的词,能显著减小词表且几乎不损失信息。max_size是上限,实际大小由min_freq和语料共同决定。注意词表只能从训练集构建,验证集和测试集出现的新词一律走<unk>,这是避免数据泄漏的基本纪律。

3.3 用 Dataset 和 DataLoader 组装批次

到这一步文本已经能转成 id 序列,接下来封装成 PyTorch 的 Dataset,方便批处理和 shuffle。

import torch from torch.utils.data import Dataset, DataLoader MAX_LEN = 500 class NewsDataset(Dataset): def __init__(self, samples, word2id, max_len=MAX_LEN): self.samples = samples self.word2id = word2id self.max_len = max_len def __len__(self): return len(self.samples) def __getitem__(self, idx): text, label = self.samples[idx] ids = [self.word2id.get(w, 1) for w in jieba.cut(text)][:self.max_len] ids += [0] * (self.max_len - len(ids)) return torch.tensor(ids), torch.tensor(label) train_loader = DataLoader(NewsDataset(train, word2id), batch_size=64, shuffle=True) val_loader = DataLoader(NewsDataset(val, word2id), batch_size=64)

word2id.get(w, 1)里的 1 就是<unk>的索引,未知词统一映射过去。截断用[:max_len],不足补 0。batch_size=64是我在单卡上比较稳的值,显存紧张就降到 32。训练集shuffle=True,验证集不需要。这套封装不依赖具体模型,后面换 TextCNN 还是 BERT 都能复用。

4. 从 FastText 到 BERT:三个基线怎么选、怎么跑

4.1 FastText 基线:十分钟拿到第一个准确率

想快速知道这份数据「好不好分」,FastText 是最省事的选择。它训练快、对分词不敏感,适合当基线。

import fasttext with open("train.txt", "w", encoding="utf-8") as f: for text, label in train: f.write(f"__label__{label} {' '.join(jieba.cut(text))}\n") model = fasttext.train_supervised("train.txt", epoch=10, lr=0.5, wordNgrams=2) print(model.test("train.txt"))

FastText 要求__label__前缀,标签和文本之间用空格分隔。wordNgrams=2引入二元词组特征,对新闻这种正式文本通常有 1 到 2 个点的提升。epoch=10、lr=0.5是常用起点。它的输出是 (样本数, 精确率, 召回率),多分类下精确率和召回率相等,直接看这个数就行。基线一般能到 90% 上下,如果明显低于这个,先检查标签映射和文本编码。

4.2 TextCNN:小算力下的性价比之选

TextCNN 结构简单,在中文长文本上表现稳定,是我最常推荐给新手的深度模型。

import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_class=14, kernel_sizes=(2,3,4), num_filters=128): super().__init__() self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([nn.Conv1d(embed_dim, num_filters, k) for k in kernel_sizes]) self.dropout = nn.Dropout(0.5) self.fc = nn.Linear(num_filters * len(kernel_sizes), num_class) def forward(self, x): x = self.embed(x).transpose(1, 2) # [B, E, L] x = [torch.relu(c(x)).max(dim=2)[0] for c in self.convs] x = self.dropout(torch.cat(x, dim=1)) return self.fc(x)

padding_idx=0让 padding 不参与梯度更新。transpose(1,2)把[B, L, E]转成 Conv1d 需要的[B, E, L]。多个卷积核尺寸并行提取不同范围的 n-gram 特征,再全局最大池化。dropout=0.5是防过拟合的常规设置,数据量大时可以降到 0.3。训练时用交叉熵损失、Adam 优化器,学习率 1e-3,通常 3 到 5 个 epoch 就能收敛。

4.3 BERT 微调:把准确率再往上推

如果算力允许,BERT 微调能把准确率再提几个点。用 HuggingFace 的接口最省事。

from transformers import BertTokenizer, BertForSequenceClassification, Trainer, TrainingArguments tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") model = BertForSequenceClassification.from_pretrained("bert-base-chinese", num_labels=14) def encode(examples): return tokenizer(examples["text"], truncation=True, padding="max_length", max_length=256) # 假设 train_ds / val_ds 是 datasets.Dataset 对象 train_ds = train_ds.map(encode, batched=True) val_ds = val_ds.map(encode, batched=True) args = TrainingArguments(output_dir="out", per_device_train_batch_size=32, num_train_epochs=3, learning_rate=2e-5, evaluation_strategy="epoch") Trainer(model=model, args=args, train_dataset=train_ds, eval_dataset=val_ds).train()

max_length=256是速度和效果的折中,新闻正文较长,设 512 更准但显存翻倍。learning_rate=2e-5是 BERT 微调的经典值,太大容易发散。num_train_epochs=3对 84 万数据通常够用,再多容易过拟合。注意 BERT 用的是字级别 tokenizer,不需要 jieba,这跟前面两个基线不同,别把分词结果喂进去。

5. 训练过程中最容易翻车的几个地方

5.1 现象:验证集准确率远低于训练集。原因:数据泄漏或词表污染。解决:词表只从训练集构建,切分在构建词表之前完成,检查是否有重复文档跨集合。

5.2 现象:模型几乎只预测体育和娱乐。原因:类别不均衡,损失被大类主导。解决:用WeightedRandomSampler做重采样,或在交叉熵里传weight参数,权重取类别频率的倒数。

5.3 现象:loss 变成 nan。原因:学习率过大或出现了空序列。解决:把学习率降到 1e-3 以下,检查__getitem__里是否有全 padding 的样本,空文本要在预处理阶段就过滤掉。

5.4 现象:FastText 报标签格式错误。原因:标签里带了空格或特殊字符。解决:确认__label__后紧跟标签 id,标签和文本之间只有一个空格,文本内部的分词结果用空格连接。

5.5 现象:BERT 微调显存溢出。原因:max_length太大或 batch 太大。解决:先把max_length降到 128 试跑,确认能跑通再逐步加;同时开梯度累积模拟大 batch。

6. 让 THUCNews 真正为你所用:迁移与验证的两个技巧

跑通基线只是开始,真正有价值的是把它当成验证自己想法的试验台。我常用的第一个技巧是「先小后大」:从每个类目抽 2000 篇组成一个约 2.8 万篇的子集,所有新想法先在这个子集上验证,跑得快、反馈快,确认有效再上全量。这个习惯帮我省掉了大量无效的等待时间。

第二个技巧是做一个「混淆对」分析。14 个类目里,财经和股票、娱乐和时尚、社会和时政这几对天然容易混。把验证集上的混淆矩阵导出来,重点看这几对的错分样本,往往能发现标注边界模糊或者关键词重叠的问题。针对性地补充特征或调整标签定义,比盲目调参有效得多。

from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt cm = confusion_matrix(y_true, y_pred) sns.heatmap(cm, annot=False, cmap="Blues") plt.xlabel("预测"); plt.ylabel("真实") plt.savefig("confusion.png", dpi=150)

y_true和y_pred从验证集推理结果里取。热力图上颜色越深说明错分越多,重点看非对角线的深色块。这一步不需要复杂代码,但能直接告诉你下一步该往哪使劲。

我自己的习惯是:任何新模型上线前,先在 THUCNews 上跑一遍,跟历史基线对齐,再去看业务数据。这份数据集不是终点,而是一把尺子。用它量清楚模型的真实水平,比在业务数据上反复试错要踏实得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询