ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

THUCNews中文文本分类实战:从数据预处理到BERT微调全流程

THUCNews中文文本分类实战:从数据预处理到BERT微调全流程 简介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(), keylambda 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, encodingutf-8) as f: text f.read().strip() tokens tokenizer.encode(text, add_special_tokensFalse) lengths.append(len(tokens)) lengths np.array(lengths) print(f平均长度: {lengths.mean():.0f}) print(f95分位: {np.percentile(lengths, 95):.0f}) print(f99分位: {np.percentile(lengths, 99):.0f}) print(f超过512的比例: {(lengths 512).mean() * 100:.1f}%)这里用bert-base-chinese的 tokenizer 做分词统计 token 数量而不是字符数。参数说明add_special_tokensFalse是为了排除[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_size0.1, random_state42, stratifyall_labels ) # 再从训练集切出验证集 train_files, val_files, train_labels, val_labels train_test_split( train_files, train_labels, test_size0.1, random_state42, stratifytrain_labels ) print(f训练集: {len(train_files)}, 验证集: {len(val_files)}, 测试集: {len(test_files)})stratifyall_labels保证每个类别在切分后的比例一致避免某个小类在验证集里一个样本都没有。random_state42是固定种子你可以改成任意整数但一旦定了就不要变。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_length128, paddingmax_length, truncationTrue, return_tensorspt ) print(encoded[input_ids].shape) # torch.Size([1, 128]) print(tokenizer.convert_ids_to_tokens(encoded[input_ids][0]))参数说明max_length128是短文本分类的常用值THUCNews 新闻较长实际训练时建议设成 256 或 512。paddingmax_length会把所有序列补齐到统一长度方便批量训练。truncationTrue表示超长截断。return_tensorspt返回 PyTorch 张量。3.3 构建 Dataset 和 DataLoaderPyTorch 的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_len256): 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, encodingutf-8) as f: text f.read().strip() text clean_text(text) encoding self.tokenizer( text, max_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) return { input_ids: encoding[input_ids].squeeze(0), attention_mask: encoding[attention_mask].squeeze(0), label: torch.tensor(self.label2id[self.labels[idx]], dtypetorch.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_size32, shuffleTrue, num_workers4)batch_size32是 BERT base 在 8GB 显存下的常见选择如果显存不够就降到 16。num_workers4加快数据加载但 Windows 上可能要设成 0。shuffleTrue只在训练集用验证集和测试集不要打乱。4. 模型选型与训练从 TextCNN 到 BERT 的取舍4.1 传统基线TF-IDF 线性分类器在上深度学习之前先跑一个 TF-IDF 逻辑回归的基线。这不是浪费时间而是给你一个下限参考。如果 BERT 只比 TF-IDF 高两三个点那可能不值得上大模型。TF-IDF 的max_features设成 50000 到 100000ngram_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, encodingutf-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_features50000, ngram_range(1, 2))), (clf, LogisticRegression(max_iter1000, C1.0, class_weightbalanced)) ]) pipeline.fit(train_texts, train_labels) val_preds pipeline.predict(val_texts) print(classification_report(val_labels, val_preds))class_weightbalanced自动处理类别不平衡C1.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_labelsnum_labels ).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 ) criterion nn.CrossEntropyLoss()weight_decay0.01是 BERT 微调的标配防止过拟合。get_linear_schedule_with_warmup实现学习率线性衰减warmup 步数占总步数的 10%。CrossEntropyLoss默认不做类别加权如果类别不平衡严重可以传入weight参数。4.3 训练循环与验证集监控训练循环里每个 epoch 跑完在验证集上算一次准确率和 F1保存最好的模型。不要只看 lossloss 下降不代表 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_maskattention_mask, labelslabels) loss outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.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_maskattention_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, averagemacro) print(fEpoch {epoch1}, 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_norm1.0)防止梯度爆炸这在 BERT 微调里很重要。averagemacro计算宏平均 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_size8累积 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_norm1.0)。如果还是 NaN检查一下输入里有没有空文本空文本经过 tokenizer 后只有[CLS]和[SEP]可能导致异常。在__getitem__里加一个判断空文本直接跳过或者用默认文本替代。6. 进阶技巧用对抗训练和模型融合再压榨几个点6.1 FGM 对抗训练在 THUCNews 上的效果对抗训练的核心思想是在 embedding 上加扰动让模型对微小变化更鲁棒。FGMFast Gradient Method是最简单的一种实现起来不到 20 行代码。我在 THUCNews 上试过F1 能涨 0.5 到 1 个点训练时间增加约 30%。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) for batch in train_loader: # 正常前向反向 outputs model(input_ids, attention_maskattention_mask, labelslabels) loss outputs.loss loss.backward() # 对抗训练 fgm.attack() outputs_adv model(input_ids, attention_maskattention_mask, labelslabels) loss_adv outputs_adv.loss loss_adv.backward() fgm.restore() optimizer.step() scheduler.step() optimizer.zero_grad()epsilon1.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_filters128): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) 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, dim1) 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 万篇里去重后大概会少几千篇对训练影响不大但测试结果会真实很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表