ARTICLE DETAIL

资讯详情

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

PyTorch新闻文本分类实战:从TextCNN到RoBERTa微调

PyTorch新闻文本分类实战:从TextCNN到RoBERTa微调 简介基于PyTorch的新闻文本分类系统完整工程包面向计算机专业毕业设计、课程设计及NLP文本分类实践者帮助解决新闻语料自动归类问题支持从零搭建完整的分类流程。资源共449个文件压缩包总大小238.29MB包含357个pth格式的预训练权重、8个Python源码文件、3个7z压缩包分别存放模型、词向量和数据集另有TextCNN结构图等可视化文件与必要的技术文档模型训练记录和备份文件也一并保留。项目覆盖文本预处理、特征工程、TextCNN模型构建、训练与性能评估等环节并附带标注语料库和预训练参数代码经多轮测试验证可直接用于课程设计、综合实验或二次开发。成果在导师指导下完成并通过学术评审目前已有55人学习浏览资源按数据、模型、词向量等模块分区整理适合希望系统掌握新闻文本分类与PyTorch工程实现的读者学习参考。1. 新闻文本分类这套资源不是调包是能改参数跑通的完整工程做新闻自动打标的需求很常见运营要内容入库、舆情要监控、发稿要自动归类和排重。真上手才发现调通一个 PyTorch 新闻文本分类系统远比想象中磨人。网上代码片段一堆但要么缺数据集要么预训练模型下载下来加载报错要么代码只写了模型没写训练流程根本无法落地。这套资源把三件事凑齐了可直接运行的源码、规范的新闻数据集、开箱即用的中文预训练模型。路线也铺得比较全从 TextCNN、BiLSTM 这两个轻量级基线一路做到 RoBERTa 微调适合要复现实验的 NLP 从业者也适合拿来做课程设计和毕设的学生。它的价值不在某个单点技术有多新而是把数据清洗、模型训练、避坑细节、导出部署这条完整链路串通了照着跑一遍就能在本地复现结果。2. PyTorch 环境与数据流水线从原始 txt 到 DataLoader 的三个关键封装2.1 环境搭建版本匹配是玄学装错 PyTorch 白忙半天新闻分类这套代码对 PyTorch 版本没有怪癖要求但安装方式得选对。项目默认目标环境是 CUDA 11.8如果你机器上nvidia-smi显示的驱动比较新直接用 cu118 对应的 wheel 就行。我一般会单独建一个 conda 环境避免把其他项目搞坏conda create -n newscls python3.9 conda activate newscls pip install torch2.1.0 --index-url https://download.pytorch.org/whl/cu118如果机器没有 NVIDIA 显卡或者驱动版本太老不支持 CUDA 11.8就把最后一行换成 CPU 版pip install torch2.1.0 --index-url https://download.pytorch.org/whl/cpu环境匹配这件事上踩过不少人翻车。装完先别急着跑训练打开 Python 确认一下 torch 能不能看到设备import torch print(torch.__version__) print(torch.cuda.is_available())torch.cuda.is_available()返回 False 的时候99.9% 是 torch 的 wheel 和驱动 CUDAdriver 版本对不上不是代码的问题。安装 CPU 版不算白装——TextCNN 和小批量数据在 CPU 上也能完成调试只有大规模训练需要 GPU。依赖里还要注意 transformers 版本。中文 RoBERTa 预训练模型要求 transformers 4.0建议直接装 4.30 系这个版本对AutoModelForSequenceClassification的加载逻辑比较友好。分词、模型结构、配置文件三件套的兼容性在最新版上偶尔会发小脾气4.36 之后对某些 checkpoint 的加载策略有调整老工程反而容易报 key 不匹配。2.2 Dataset 类的封装把新闻文本和标签变成可迭代样本数据流水线的第一个关键封装是 Dataset。文本分类的原始数据通常是 CSV 或 JSON 行常见字段是text新闻正文、label类别名这套资源里已按 10 类新闻做好映射类别包括科技、财经、体育、娱乐、时政等。import json import torch from torch.utils.data import Dataset LABEL2ID { 科技: 0, 财经: 1, 体育: 2, 娱乐: 3, 时政: 4, 社会: 5, 教育: 6, 健康: 7, 军事: 8, 旅游: 9 } class NewsDataset(Dataset): def __init__(self, file_path, label2idNone): self.samples [] with open(file_path, r, encodingutf-8) as f: for line in f: obj json.loads(line.strip()) # 清洗去掉HTML标签、多余空白 text obj[text].replace(p, ).replace(/p, ) text .join(text.split()) self.samples.append((text, label2id[obj[label]])) def __len__(self): return len(self.samples) def __getitem__(self, idx): text, label self.samples[idx] return text, label这个封装把「读文件」和「取样本」解耦了训练和验证共用一份代码。清洗操作放在__init__里是有意的运行时只需从内存列表取值避免了每个 epoch 重复读盘和重复清洗的开销。如果新闻正文里有链接、特殊符号可以在这里一并处理。需要注意清洗规则不要输出到代码库后就不管了新闻语料里经常出现「记者 xx 报道」这类统一后缀如果要做高精度实验这类噪音应该在预处理里单独过滤而不是留给模型去学习。2.3 collate_fn 与词表构建变长 batch 怎么处理PyTorch 的 DataLoader 默认要求 batch 内每个样本形状一致而新闻文本长度天然有差异。绕开这个问题的方法是自定义collate_fn把 batch 内的 token id 序列补齐到等长。from torch.nn.utils.rnn import pad_sequence def collate_fn(batch, word2id, max_len128): texts, labels zip(*batch) token_ids [] for text in texts: ids [word2id.get(w, word2id[unk]) for w in text.split()][:max_len] ids ids [word2id[pad]] * (max_len - len(ids)) # 截断右侧padding token_ids.append(torch.tensor(ids, dtypetorch.long)) labels torch.tensor(labels, dtypetorch.long) return torch.stack(token_ids), labels参数说明max_len128表示单条新闻最多保留前 128 个 token超过丢弃word2id是词表映射需在训练前由训练集构建pad的索引固定为 0这对接下来的 Embedding 层很重要——padding token 的向量在训练中不应该参与梯度计算。词表构建我一般单独写一个脚本统计训练集词频并过滤出现次数小于 2 的词避免把低频噪音也塞进 embedding 矩阵。新闻文本和通用语料不太一样专业名词密度高词表建议做到 5 万8 万规模小于 3 万会损失不少信息太大则 embedding 矩阵占显存。3. TextCNN 与 BiLSTM 两套基线网络结构代码与超参对比3.1 TextCNN用卷积扫 n-gram 特征训练快、效果够用TextCNN 是文本分类里性价比最高的基线核心思想是用多个尺寸的卷积核来捕捉相邻词的组合特征。对新闻这种长度可控、局部语义重要的文本它通常能打到 90% 以上的准确率且训练比 Transformer 快一个数量级。import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim128, num_filters256, filter_sizes(2, 3, 4), num_classes10, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x): # x: (batch, max_len) emb self.embedding(x) # (batch, max_len, embed_dim) emb emb.unsqueeze(1) # (batch, 1, max_len, embed_dim) conv_out [torch.relu(conv(emb)).squeeze(-1) for conv in self.convs] pooled [torch.max_pool1d(c, c.size(2)).squeeze(2) for c in conv_out] cat torch.cat(pooled, dim1) return self.fc(self.dropout(cat))代码逻辑不复杂embedding把 token id 映射成 128 维向量卷积核高度分别为 2、3、4对应 2-gram、3-gram、4-gram 的局部窗口max_pool1d保留每个卷积通道最显著的特征最后把三组特征拼起来接全连接层。padding_idx0要写这样 CNN 窗口滑到 padding 区域时不会产生有意义的梯度。超参上的经验num_filters256是性能和显存的平衡点小数据集 128 就够超过 512 收益很小只增加训练时间dropout0.5是经过大量实验验证的默认值过拟合明显可以提到 0.6。embedding 维度 128 对新闻任务够用除非数据量达到百万级否则没必要上 300。3.2 BiLSTM对长句子更友好但训练速度明显更慢BiLSTM 的思路是把句子从头到尾和从尾到头各读一遍然后把两个方向的隐状态拼接捕捉长距离依赖。新闻正文的句子经常跨越多行逻辑关系可能散布在几十个 token 之外这时候 BiLSTM 比 TextCNN 有优势代价是训练时间成倍增加。class BiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_size128, num_layers2, num_classes10, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_size * 2, num_classes) def forward(self, x): emb self.embedding(x) # (batch, max_len, embed_dim) output, (h_n, c_n) self.lstm(emb) # output: (batch, max_len, hidden*2) # 取最后一个非padding时刻的hidden作为句子向量 batch_size x.size(0) last_hidden h_n[-2:].permute(1, 0, 2).reshape(batch_size, -1) return self.fc(self.dropout(last_hidden))bidirectionalTrue会让最终输出维度变成hidden_size * 2。取h_n的最后两层、拼双向代表模型对整句的最终编码。只取最后时刻会丢失中间信息但新闻分类对局部特征依赖较大这个操作带来的信息损失在可接受范围内。如果想用中间信息更充分的池化方式可以用output.mean(dim1)做全局平均。项目代码默认用 mean 池化实测在某些数据集上比取最后时刻稳定因为 mean 池化不会因为某一位置的 padding 或长尾词把特征带偏。这个池化选择是个可调参数不是玄学可以两版都跑一遍。3.3 超参对比与选择不同数据量下怎么改模型embedding 维度隐藏层/卷积核参数量10类一个 epoch 耗时1万条建议数据量TextCNN128filters256size(2,3,4)约 260 万30 秒左右5 万条以内BiLSTM128hidden128layers2约 350 万2 分钟以上5 万条以上RoBERTa76812 层 Transformer约 1.02 亿15 分钟以上标注数据越少越值得用选哪套先看数据量和训练资源。只有 1 万条标注新闻TextCNN 是最务实的选择训练快迭代实验成本低如果你有 5 万条以上且希望模型对长正文理解更好BiLSTM 的上限更高标注数据不足 1 万条或者需要处理完全不同领域的新闻直接跳到第 4 章用预训练模型做微调基线模型的随机 embedding 学不出足够的语义。训练时还有个易被忽略的配置类别权重。新闻数据集的类别天然不均衡财经类可能只有体育类的三分之一。在损失函数里加入torch.Tensor([...])这样的类别权重或者用WeightedRandomSampler重采样效果差异肉眼可见。默认参数已经设置了一个基于训练集频率反比的权重改数据时记得重新计算。4. RoBERTa 中文预训练模型微调把通用语言模型接进分类头4.1 为什么不直接用词向量预训练模型的收益和代价Word2Vec 和 GloVe 这类静态词向量有一个硬伤一个词只有一个固定向量无法区分「苹果」在科技新闻和水果新闻里的不同语义。中文 RoBERTa 用 Transformer 编码器通过多层注意力把每个 token 的表示放到上下文里动态生成同样是「苹果」在「苹果发布新手机」和「苹果价格上涨」中向量完全不同这是新闻分类最需要的语义建模能力静态词向量给不了。代价是显存和训练时间RoBERTa 至少 1 亿参数在普通 GPU 上不可能像 TextCNN 那样几十秒跑一个 epoch所以实践中常用的是「先小学习率全量微调」而不是「冻结特征做分类头训练」。4.2 用 transformers 加载中文 RoBERTatokenizer 与模型初始化transformers 库帮我们把加载过程简化到了三行。资源里默认用的 checkpoint 是hfl/rbt3这是哈工大讯飞联合发布的 RoBERTa-wwm-ext 系列的小型号只有 3 层 Transformer显存占用小中文效果远好于 BERT-base 的 12 层原版适合在普通显卡上完整体验微调流程。如果显存足够16G 以上可以换成hfl/chinese-roberta-wwm-ext12 层效果更强。from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer AutoTokenizer.from_pretrained(hfl/rbt3) model AutoModelForSequenceClassification.from_pretrained( hfl/rbt3, num_labels10 )AutoModelForSequenceClassification会自动在预训练模型顶层加一个分类头num_labels10对应新闻类别数。使用时我们不需要单独写 tokenizer 的词典——from_pretrained会把配置文件、分词器、权重一次加载齐。模型加载后先打印一下参数量确认设备分配total_params sum(p.numel() for p in model.parameters()) trainable_params sum(p.numel() for p in model.parameters() if p.requires_grad) print(ftotal: {total_params}, trainable: {trainable_params})到这里有坑要注意AutoTokenizer的max_length默认 512。新闻正文通常远超这个长度直接截断会丢掉关键信息。第 5 章会专门展开 headtail 的截断策略这里先记住普通截断对预训练模型的伤害比对 CNN 更大因为 Transformer 的注意力计算对位置敏感。4.3 微调训练学习率、batchsize 与类别不均衡处理微调预训练模型的训练循环和基线模型最大的差别在学习率BERT 族模型微调的标准初始学习率是 2e-5比 CNN 的 1e-3 小两个数量级。用 AdamW 优化器配合 linear schedule 做预热前 10% 步数线性升温到目标学习率之后衰减到 0。from transformers import AdamW, get_linear_schedule_with_warmup optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) total_steps len(train_dataloader) * num_epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps )batch size 的选择在预训练模型这里很关键。hfl/rbt3的输入经过 tokenizer 后每条大概 2000 token显存占用不小8G 显存的卡只能塞下每 batch 812 条。想要大 batch 却又放不下时用梯度累积模拟大 batchaccumulation_steps 4 for step, (batch_x, batch_y) in enumerate(train_dataloader): outputs model(input_idsbatch_x, labelsbatch_y) loss outputs.loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() scheduler.step() optimizer.zero_grad()loss / accumulation_steps是关键动作它会保证真实梯度的大小和大 batch 训练接近直接累加不除会导致梯度爆炸。学习率 2e-5、warmup 10%、weight_decay 0.01 这三个参数在新闻分类上基本不用动改数据规模时优先调 epoch 数和早停而不是动学习率。训练过程里倒是要盯着 loss 曲线预训练模型的 loss 下降曲线比 CNN 平滑得多如果 loss 出现大幅振荡大概率是 batch size 太大导致梯度步长过大这时候把 lr 降到 1e-5 比调 schedule 更有效。5. 实战避坑新闻分类里最容易翻车的六个细节5.1 准确率虚高类别不均衡让模型变成「猜谜高手」现象训练完打印准确率 93%看起来收获很好但看具体类别的分类报告却发现军事类召回率只有 11%所有军事新闻几乎都被分到了科技类。原因新闻数据集中科技类样本量是军事类的 20 倍模型学到的最优策略是全部猜科技类因为这样损失最小。准确率只是整体指标被大头类别主导掩盖了小类别的完全失效。解决训练前先统计类别分布用sklearn.metrics.classification_report检查宏平均 F1 而不是只盯准确率。训练时改用加权损失或者WeightedRandomSampler重采样让每个类别的有效样本量大致相当。我习惯的做法是两者结合加权损失对小数据集更稳重采样在大数据集上训练更快。5.2 长新闻截断丢了结论关键信息藏在后 500 个 token现象用 max_len128 训练 TextCNN验证集 F1 卡在 0.78 上不去调大 max_len 到 256时间翻倍效果反而掉到 0.75。原因新闻正文的叙事结构是「倒金字塔」前几句是导语后半部分是细节展开和数据支撑。截断到 128 保住了开头但很多类别的区分特征藏在后文。而简单增加到 256 会把大量 padding 和无关背景加进来噪声盖过了信号。解决用 headtail 拼接策略取前 96 个 token 和后 32 个 token 拼起来中间部分扔掉。既保留了导语又留住了结尾关键信息长度还是 128。def smart_truncate(tokens, max_len128, head_ratio0.75): head_len int(max_len * head_ratio) if len(tokens) max_len: return tokens return tokens[:head_len] tokens[-(max_len - head_len):]5.3 OOM新闻语料喂爆显存现象训练 RoBERTa 时第一个 batch 正常第二个 batch 报CUDA out of memory。原因新闻正文 token 化后远长于普通文本batch 内存的是变长序列 padding 后的矩阵batch 内最长的一条接近 2000 token其他所有样本都被 pad 到这么长显存浪费严重。解决先做长度分布统计把超过 99 分位数的超长新闻单独截断让 batch 内长度集中在有效范围。再配合梯度累积第 4 章的 4 步累积方案把有效 batch size 维持住。还有个细节tokenizer要传return_tensorspt前先把文本按长度排序再分批可以减少 padding 浪费。5.4 下载预训练模型失败checkpoint 加载一致性现象from_pretrained(hfl/rbt3)一直卡在两个多小时最终网络超时加载模型时偶尔报 unexpected key。原因预训练模型权重几百 MB默认下载源在境外网络不稳定时断点续传不可靠。.bin文件下载不完整或者格式是 pytorch_model.bin 铁定不兼容的旧版加载时自然报 key 不匹配。解决设置国内镜像源把环境变量指向 hf-mirrorexport HF_ENDPOINThttps://hf-mirror.com这样会把下载路径改到国内镜像速度稳定很多。模型加载完成后跑一次前向传播确认输出形状正常再进入训练。从那以后我搭预训练模型环境第一步都是固定 HF_ENDPOINT避免后面训练到一半才发现权重有问题。5.5 分词粒度不一致同一个词在训练和预测时切成不同 token现象训练时输入「新能源汽车」tokenizer 切成「新/能/源/汽/车」预测时同样的文本切成「新能源/汽车」两条样本给出的预测概率完全不同。原因transformers 的 tokenizer 带了缓存机制不同 batch 的 padding 策略和环境变量变化导致 vocab index 映射错位。这类问题隐蔽性极高排查时却发现模型和 tokenizer 都没动过。解决tokenizer 初始化后固定加载一次并保存到本地tokenizer.save_pretrained(./tokenizer_cache/)推理时统一从./tokenizer_cache/目录加载不重新走from_pretrained。同时注意 DataLoader 的shuffleTrue只影响训练集验证和推理阶段必须shuffleFalse否则预测顺序和标签顺序对不上混淆矩阵全部乱套。5.6 训练 loss 下降但验证集不降学习率调度失配现象训练 loss 从 2.0 一路降到 0.3验证集 F1 却从 0.8 开始不升反降模型显然过拟合但提前停了也救不回来。原因如果一开始就把学习率设置为 1e-4对 BERT 族来说偏大前几个 step 就把预训练权重冲乱了后面再怎么训练也找不回原来语义空间。BERT 族预训练参数是经过海量语料调整好的高维空间学习率太大会把位置向量、注意力权重冲到随机值附近整个模型的容量被浪费掉。解决小学习率 早停。学习率固定 2e-5每 2 个 epoch 跑一次验证集连续 3 次验证 F1 无提升就终止训练并恢复到验证集 best 模型的 checkpoint。best_f1 0.0 patience 0 for epoch in range(num_epochs): train_one_epoch() val_f1 evaluate() if val_f1 best_f1: best_f1 val_f1 torch.save(model.state_dict(), best_model.pt) patience 0 else: patience 1 if patience 3: print(early stop) break6. 验证与落地从混淆矩阵到 ONNX 导出的收尾技巧6.1 用分类报告和混淆矩阵确认模型真的能用训练结束后最该看的不是终端打印的 loss 数字而是分类报告。代码和 sklearn 配合很简单from sklearn.metrics import classification_report, confusion_matrix import numpy as np # 预测全部验证集 all_preds, all_labels [], [] with torch.no_grad(): for batch_x, batch_y in val_dataloader: logits model(batch_x) preds torch.argmax(logits, dim-1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(batch_y.numpy()) print(classification_report(all_labels, all_preds, digits4)) conf confusion_matrix(all_labels, all_preds)分类报告的macro avg F1是最可靠的单一指标它无视样本量差异把每个类别看成一个独立任务。混淆矩阵我会额外关注那些对角线之外的高值——比如社会类和时政类互相误判很常见因为它们的标题和导语措辞高度重叠。如果这两个类混淆率高说明数据集的类别定义本身有模糊地带该做的不是改模型而是重新审视标注规范把边界样本归到更合理的类别里。6.2 把模型导出 ONNX推理环境没有 PyTorch 也能跑工程交付的时候很多时候目标机器不允许安装 PyTorch或者只有 CPU 环境。把模型导出成 ONNX 格式能让它跑在任何支持 ONNX Runtime 的推理服务里。TextCNN 和 BiLSTM 导出都很顺畅RoBERTa 也能导出但需要注意动态轴配置。dummy_input torch.randint(0, 1000, (1, 128), dtypetorch.long) model.eval() torch.onnx.export( model, dummy_input, news_classifier.onnx, input_names[input_ids], output_names[logits], dynamic_axes{input_ids: {0: batch_size, 1: seq_len}, logits: {0: batch_size}}, opset_version11 )关键参数dynamic_axes声明 batch 维度和 seq_len 维度在推理时才确定否则导出的模型会被固定成 batch1、seq128真实请求长度不是 128 时会直接报错。BiLSTM 导出时如果用了 pack_padded_sequence需要先去掉这个逻辑记忆处理在 ONNX 下支持不好。op接口版本建议 11兼容onnxruntime 1.10 以上从 1.10 到 1.17 全系。验证导出的模型和 PyTorch 原模型的输出是否一致import onnxruntime as ort ort_session ort.InferenceSession(news_classifier.onnx) onnx_input dummy_input.numpy() ort_out ort_session.run([logits], {input_ids: onnx_input})[0] torch_out model(dummy_input).detach().numpy() print(np.max(np.abs(ort_out - torch_out))) # 小于 1e-5 则正常差值在 1e-5 量级内说明导出精度损失可控可以放心部署。6.3 端到端验证一段新新闻文本的预测流程模型落地前的最后一步是用一段没见过的新闻验证完整链路——从原始文本到预测类别中间经过 tokenizer、模型推理、结果映射。def predict(text, tokenizer, model, max_len128): tokens tokenizer( text, max_lengthmax_len, paddingmax_length, truncationTrue, return_tensorspt ) with torch.no_grad(): logits model(**tokens).logits probs torch.softmax(logits, dim-1) pred_id torch.argmax(probs, dim-1).item() return ID2LABEL[pred_id], probs[0][pred_id].item() # 测试样例 news 公司今日发布财报全年营收同比增长23%主要得益于海外市场扩张。 label, conf predict(news, tokenizer, model) print(f预测类别: {label}, 置信度: {conf:.4f})这里有个工程细节paddingmax_length会让所有输入都 pad 到 128推理时对单挑样本没问题但如果在服务端批量预测会浪费计算资源。服务化时应该改成paddinglongest让同一批请求按 batch 内最长的补而不是固定最大值。我对每个要上线的分类模型都会强制跑一遍这个端到端验证把输出概率分布打出来看最大值和第二名的差距——如果差距小于 0.05说明模型对这个样本没有把握需要在业务侧处理这种低置信度情况而不是盲目采信结果。从那以后我每次做文本分类项目都先把类别分布统计、headtail 截断、HF 镜像源这三个检查做完再谈训练。跳过任意一步后面都会用特别耗时的 debug 还回来。希望帮到你。本文还有配套的精品资源点击获取
返回列表