资讯动态

torchtext实战:TREC问题分类与TextCNN模型实现

发布时间:2026/10/16 17:17:10 来源:尧图企业网站定制
开头做 NLP 任务绕不开文本预处理而 torchtext 就是 PyTorch 官方提供的文本处理利器。这次我用它做了一个非常经典的单标签多分类任务——TREC 问题分类Question Classification把一个问题文本自动归类到预定义的语义类别里。TREC 是一个规模不大但信息量十足的英文短文本数据集特别适合用来学习 torchtext 从数据加载、词表构建到模型训练的全套流程。这篇文章会把整个实践过程拆开揉碎从数据集结构、torchtext 的 Field 和 BucketIterator 设计理念到 TextRNN / TextCNN 的选型对比再到我实际踩过的坑一步步讲清楚。适合两类读者一类是刚接触 torchtext、想直接跑通一个文本分类 demo 的新手另一类是用过一些深度学习框架、但对 torchtext 的底层机制还比较模糊的同学。看完你能直接照着代码复现还能避开我在调试阶段浪费掉的那些时间。1. 先搞清楚TREC 任务到底在分类什么1.1 单标签多分类的含义先说数据层面。TREC 数据集里的每一条样本是一个英文问句同时带一个类别标签标签总共有 6 个大类类别代码含义示例ABBR缩写、简称What does NATO stand for ?DESC描述、定义What is a trombone ?ENTY实体What is the world s longest river ?HUM人类Who is the president of France ?LOC地点Where is the Valley of the Kings ?NUM数字、数量How many people live in China ?注意这里的“单标签多分类”每个样本只属于一个类别但候选类别有两个以上本质是 6 分类问题。它和多标签分类一个样本同时属于多个类别是两码事。许多教程里说 TREC 有 50 类细分类那是另一个粒度本文先用 6 大类做等流程跑通后想往细粒度扩展也很容易只需要改标签集合就行。为什么选 TREC 作为 torchtext 的入门任务第一数据量小训练集大约 5400 多条测试集 500 条模型跑得飞快第二文本是短问句不需要考虑长文本分段等复杂处理可以集中精力理解 torchtext 的核心机制第三类别相对均匀不会因为样本不平衡导致模型崩得莫名其妙方便验证代码正确性。1.2 文本分类的基本处理链路任何一个文本分类任务落到代码层面都是同一条链路原始文本 → 分词 → 词表映射 → 数字序列 → 模型打分 → 输出类别。torchtext 的价值在于把这套链路的前半段原始文本到数字序列封装成了可复用的组件。你要是没用过 torchtext通常会自己写一堆 for 循环做分词、建字典、padding、转 tensor每个项目都要重写一遍还容易出错。而 torchtext 把分词规则、是否转小写、未知词处理、padding 索引这些都集中到了 Field 这个对象里定义一次之后训练、验证、推理都能复用同一套规则数据维度不会对不上。理解了这个背景再看下面的代码就不会晕了。2. torchtext 核心设计思路Field 和 BucketIterator2.1 Field一个把文本变成张量的“加工流水线”在 torchtext 0.9 之前的经典 API 里最常见的两个组件是Field和LabelField。Field负责处理输入文本LabelField专门处理标签它其实继承自Field只是默认把dtype设置为torch.long并且默认unk_tokenNone因为标签里不应该有“未知”这种东西。我定义的字段如下from torchtext.legacy.data import Field, LabelField, TabularDataset, BucketIterator TEXT Field( tokenizespacy, tokenizer_languageen_core_web_sm, lowerTrue, batch_firstTrue, include_lengthsFalse ) LABEL LabelField()你可能注意到我用了torchtext.legacy这个路径。原因后面会专门说0.9 之后官方把Field标记为 legacy新代码推荐用别的方案但经典 API 依然能用而且网上大量旧教程还是这套写法。为了保证代码能跑我统一采用torchtext.legacy路径。这里每个参数都有讲究tokenizespacy告诉 Field 使用 spaCy 的英文分词器。TREC 句子很短用简单split()也能凑合但 spaCy 能把 “St. Louis” 这样的人名地名拆得更合理还能处理s、标点等 token。需要额外安装spacy和英文模型python -m spacy download en_core_web_sm。不想引入 spaCy 依赖的话可以传tokenizestr.split。lowerTrue统一转小写减小词表。对于 TREC 问句来说句子开头大写基本没有类别区分度转小写几乎不损失信息。batch_firstTrue让 batch 张量变成(batch_size, seq_len)而不是(seq_len, batch_size)。这个我会在 LSTM 输入时详细解释先记住这个选项能让你在写模型时减少很多维度困惑。include_lengthsFalse暂时不返回每条样本的真实长度。如果为True迭代数据的text会变成一个(tensor, lengths)元组。我们在用 LSTM 或需要做长度 mask 时再打开它。定义完字段后真正有意思的机制才刚开始Field.preprocess会在迭代时自动调用分词build_vocab会把词表构建好numericalize会把 token 序列映射成数字序列。也就是说你在训练循环里拿到的batch.text已经是torch.Tensor背后的预处理流水线全被封装进了这几个小对象里。2.2 BucketIterator按长度分桶的优雅实现BucketIterator是我觉得 torchtext 老 API 里最有价值的设计。它解决的问题是batch 内做了 padding 之后如果一批里句子长度差异巨大短句会被大量 padding 包着白白浪费内存和算力。想象一下一个班 30 人做试卷大家交卷时间差异很大但必须等所有人都交完才能讲题大部分时间都耗在等那几个写得慢的人身上了。BucketIterator 的思路是把长度差不多的样本先分到同一个桶里再在桶内随机抽样本组成 batch这样每个 batch 内部的 padding 量就很小训练速度快不少。train_iterator, test_iterator BucketIterator.splits( (train_data, test_data), batch_sizes(32, 64), sort_keylambda x: len(x.text), sort_within_batchTrue, devicedevice )关键参数的作用sort_keylambda x: len(x.text)按 text 字段的 token 数量排序样本。sort_within_batchTrue在一个 batch 内部也按长度降序排列这是为了配合pack_padded_sequence使用的。如果用 TextCNN这个参数影响不大如果用 LSTM 且打算用 PackedSequence这个必须为True否则pack_padded_sequence会报错或者结果错乱。batch_sizes训练和验证的 batch 不一致也没问题验证时数据少可以调大 batch。不过BucketIterator有一个要注意的特性它默认会在每个 epoch 内打乱样本顺序通过shuffleTrue默认开启但打乱是以“桶”为单位的。所以你训练时拿到的 batch 顺序每个 epoch 都不一样这正是我们想要的但验证/测试时如果也 shuffle那指标每次跑都不一样所以评估时要设置shuffleFalse或者像我这样单独用test_iterator。2.3 新版本 API 的迁移思考讲完老 API必须聊聊现状。torchtext 0.9 之后Field、LabelField、TabularDataset这些被移到了torchtext.legacy官方推荐的新方案是用torchtext.data.utils.get_tokenizer搭配torchtext.vocab.build_vocab_from_iterator再配合 PyTorch 原生DataLoader完成 batch 和 shuffle。对初学者而言我其实更推荐先用 legacy API它开箱即用处理小数据非常方便各种教程和开源代码也多能让你先把注意力放在“任务怎么建模”上。等熟悉了文本处理的每个环节再切换到新 API 也不迟因为新 API 本质上就是把Field里封装的东西拆成一个个函数让你自己组合负责。二者改造成本不算高核心就是替换数据读取和 batch 构建这两段。3. 完整实操从数据加载到模型训练3.1 加载数据并构建词表TREC 数据集的公开格式常见的是 csv 形式text,label What is the capital of France ?,LOC Who is the CEO of Apple ?,HUMtorchtext 提供了TabularDataset按列读取数据一行代码就能加载train_data, test_data TabularDataset.splits( path./data/trec, traintrain.csv, testtest.csv, formatcsv, fields[(text, TEXT), (label, LABEL)] )fields参数里的元组顺序必须和 csv 列顺序严格一致。如果 csv 的列顺序是label,text这里也要倒过来否则 label 会被读成文本、文本被读成标签后果是 vocab 里全是标签词模型彻底学不到东西。我推荐拿到数据后先打印一条样本看一眼print(vars(train_data[0])) # {text: [What, is, the, capital, of, France, ?], label: LOC}确认分词结果和标签都正确再往下走。接下来是词表构建vectors None # 如果想用预训练词向量把下面这行注释打开 # from torchtext.vocab import GloVe # vectors GloVe(name6B, dim100) TEXT.build_vocab(train_data, max_size25000, min_freq1, vectorsvectors) LABEL.build_vocab(train_data) print(词表大小:, len(TEXT.vocab)) print(标签映射:, LABEL.vocab.stoi)TEXT.build_vocab这一步做了什么它遍历训练数据也可以额外传入验证集保证验证集里的词也能映射到索引统计词频按照频率从高到低给每个 token 分配索引。缺省情况下索引 0 是unk未知词索引 1 是padpadding这两个特殊 token 不需要你手动加。LABEL.build_vocab同理构建从类别名到数字索引的映射输出类似{ABBR: 0, DESC: 1, ENTY: 2, HUM: 3, LOC: 4, NUM: 5}。训练循环里batch.label就是对应的torch.long张量。3.2 为什么短文本适合 TextCNN模型层面我在这个任务上对比过两个常用结构TextRNN 和 TextCNN。TREC 样本平均长度不到 10 个 token是个典型的短文本任务。短文本的类别判定往往依赖句子里的关键短语比如what、how many、where、who这类疑问词及其搭配而不是长距离的上下文依赖。TextCNN 的思路是一条句子经过嵌入层后变成(batch, seq_len, embed_dim)的二维矩阵再用不同宽度比如 3、4、5的一维卷积核去扫过这个矩阵抓取连续 n-gram 的局部特征然后做全局最大池化最终拼接所有卷积核的输出。TextRNN我指的是单层双向 LSTM也能用但在这个任务上收益不如 CNN 明显而且训练更慢、对短文本容易记住位置噪声。所以我的建议是TREC 这种短文本分类默认从 TextCNN 开始如果你的数据是长文本或者需要捕捉长距离语义再上 RNN 或 Transformer 不迟。3.3 TextCNN 模型实现下面是我在这个项目中使用的 TextCNN 实现收敛快、调参省心import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_class, num_filters100, filter_sizes[2, 3, 4], dropout0.5, pad_idx1): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idxpad_idx) self.convs nn.ModuleList([ nn.Conv2d(1, num_filters, (filter_size, embed_dim)) for filter_size in filter_sizes ]) self.fc nn.Linear(len(filter_sizes) * num_filters, num_class) self.dropout nn.Dropout(dropout) def forward(self, text): # text: (batch, seq_len) embedded self.embedding(text) # (batch, seq_len, embed_dim) embedded embedded.unsqueeze(1) # (batch, 1, seq_len, embed_dim) conved [F.relu(conv(embedded)).squeeze(3) for conv in self.convs] # 每个元素 (batch, num_filters, seq_len - filter_size 1) pooled [F.max_pool1d(c, c.size(2)).squeeze(2) for c in conved] # 每个元素 (batch, num_filters) cat torch.cat(pooled, dim1) # (batch, len(filter_sizes) * num_filters) return self.fc(self.dropout(cat))几点解释卷积核尺寸是(filter_size, embed_dim)这里的embed_dim不是卷积的通道维度而是把整个词向量维度直接当作卷积核的宽度所以输出特征图在embed_dim这个维度上会变成 1这就是squeeze(3)的原因。初次看 TextCNN 代码的人很容易在这里卡住多写几遍就理解了。padding_idx1对应词表里的pad。embedding 层会对 padding 位置输出全零向量这保证了 padding 不会引入噪声。训练和推理时pad_idx必须和Field里默认的pad索引一致不要自己瞎猜打印一下TEXT.vocab.stoi[pad]最靠谱。三种卷积核宽度 2、3、4 对应 bigram、trigram、4-gram 特征覆盖了 TREC 问句中最常见的短语模式。全局最大池化的意思是对每个卷积核产生的特征序列取最大值相当于判断“这句话里是否出现了某种关键模式”跟句子出现在什么位置无关。这对短文本分类非常合适。如果想把模型换成 TextRNN核心代码大概是class TextRNN(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_class, pad_idx, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idxpad_idx) self.lstm nn.LSTM(embed_dim, hidden_dim, num_layers1, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(hidden_dim * 2, num_class) self.dropout nn.Dropout(dropout) def forward(self, text): embedded self.embedding(text) outputs, (hidden, cell) self.lstm(embedded) hidden torch.cat((hidden[-2], hidden[-1]), dim1) return self.fc(self.dropout(hidden))双向 LSTM 的hidden[-2]是正向最后一层最后一个时间步的 hiddenhidden[-1]是反向的拼接后作为整个序列的表示。这里用了batch_firstTrue所以输入输出形状都是(batch, seq_len, embed_dim)。3.4 训练循环的细节训练过程本身不复杂但为了让指标真实可信我做了三件事使用验证集这里直接用测试集评估、记录每个 epoch 的准确率、加入 early stopping。核心代码如下import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model TextCNN(len(TEXT.vocab), embed_dim100, num_classlen(LABEL.vocab), pad_idxTEXT.vocab.stoi[pad]) model model.to(device) optimizer optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() def train_epoch(model, iterator, optimizer, criterion): epoch_loss, epoch_acc 0, 0 model.train() for batch in iterator: optimizer.zero_grad() text batch.text # (batch, seq_len) label batch.label # (batch,) predictions model(text) loss criterion(predictions, label) acc (predictions.argmax(dim1) label).float().mean() loss.backward() optimizer.step() epoch_loss loss.item() epoch_acc acc.item() return epoch_loss / len(iterator), epoch_acc / len(iterator)这里没有任何额外操作是因为Field已经把数据变成了能直接喂给模型的张量。你唯一要记住的是batch.text和batch.label的名字和你在fields里定义的键一致。迭代逻辑for epoch in range(15): train_loss, train_acc train_epoch(model, train_iterator, optimizer, criterion) test_loss, test_acc evaluate_epoch(model, test_iterator, criterion) print(fEpoch {epoch1:02d} | Train Loss: {train_loss:.3f} | Train Acc: {train_acc*100:.2f}% | Test Acc: {test_acc*100:.2f}%) if test_acc best_acc: best_acc test_acc torch.save(model.state_dict(), best_model.pt)用预训练词向量时TEXT.build_vocab里传了vectors后需要在模型初始化之后把词向量复制进 embeddingmodel.embedding.weight.data.uniform_(-0.1, 0.1) model.embedding.weight.data[0] torch.zeros(embed_dim) # unk model.embedding.weight.data[1] torch.zeros(embed_dim) # pad model.embedding.weight.data[2:] TEXT.vocab.vectors[2:]如果不做最后一行你会在每个 epoch 都看到 loss 几乎不降因为 embedding 层完全没有用到预训练信息。这个操作是把建好的 GloVe 词向量按索引逐行拷贝到模型的 embedding 矩阵里索引对齐是build_vocab内部已经保证好的。在这个配置下TextCNN 用预训练词向量很容易在测试集上拿到 90% 以上的准确率不使用预训练词向量也有 86% 左右。如果你头几次实验只到 80% 上下大概率是训练轮数太少或者标签字段定义错了别急着改模型。4. 实操中的高频坑点与排除实录4.1 数据集下载与路径问题很多教程会让你直接通过某个 URL 下载 TREC 数据但网络环境一变下载很容易失败。最稳妥的做法是把 csv 文件手动保存到本地再用绝对路径或相对路径读取。我遇到过的问题是用TabularDataset.splits时path参数指向的是目录train/test参数拼出具体文件名所以目录必须存在且文件名拼写准确。路径错了它不会报“文件不存在”而是抛一个“Failed to split data”的异常看起来特别吓人其实只是路径问题。另外TREC 原始数据格式是label 问句两列用 tab 分隔有的镜像给的是带引号的 csv。如果原始列数或者分隔符不对TabularDataset解析也会出错。我的建议是读取后立刻print(vars(train_data[0]))确认字段有没有被正确拆分。这一步能排除掉 80% 的加载问题。4.2 LSTM 与 PackedSequence 的维度坑如果你坚持用 TextRNN务必注意维度。nn.LSTM默认batch_firstFalse输入形状是(seq_len, batch, embed_dim)。如果你在Field里设置了batch_firstTrue那 LSTM 也要设置batch_firstTrue两边不一致时不会立刻报错但训练时 loss 会波动异常很难定位。还有一个隐藏问题BucktIterator 按长度降序排列 batch 内样本时如果你的Field设置了include_lengthsTrue你可能想用pack_padded_sequence节省运算。这时需要把每个 batch 的真实长度作为第二个参数传进去而且长度必须是降序的。直接用batch.text对应的长度张量是可行的但长度 0 的样本理论上不会出现会让pack_padded_sequence报错。TREC 这种短文本任务里PackedSequence 带来的加速并不明显我用一次之后果断放弃了大多数场景直接self.lstm(embedded)就完事模型照样收敛。4.3 推理阶段 batch 大小造成的隐性 bug训练时BucketIterator会对样本进行长度排序和分桶所以你在model.eval()下拿到的第一个 batch 往往不是数据集的原始顺序。做推理时如果只想对单条文本预测必须自行走一遍预处理流程from torchtext.data.utils import get_tokenizer tokenizer get_tokenizer(spacy, languageen_core_web_sm) def predict(text): tokens tokenizer(text.lower()) ids [TEXT.vocab.stoi.get(t, TEXT.vocab.stoi[unk]) for t in tokens] ids_tensor torch.tensor([ids]).to(device) # (1, seq_len) model.eval() with torch.no_grad(): logits model(ids_tensor) pred_idx logits.argmax(dim1).item() return LABEL.vocab.itos[pred_idx]注意这里我用TEXT.vocab.stoi.get(t, ...)手动处理了未知词因为Field的numericalize只会在迭代数据集时自动调用推理阶段不会帮你做这一步。这是一个很容易被忽视的细节训练数据里没见过的词一旦在预测时出现如果你不指定未知词索引get()会直接返回默认 0恰好也是unk的索引看似没问题但如果你用的预训练词向量把 0 位初始化成全零那未知词向量就是零向量模型对这个词的信息完全失忆——对短文本来说一个关键未知词可能直接改变预测结果。4.4 torchtext 版本兼容Field 去哪了这是我在新环境里踩过最大的坑。装好最新版 torchtext 后from torchtext.data import Field直接报ImportError因为 0.9 之后Field被整体迁移到了torchtext.legacy.data。解决办法有两个继续用 legacyfrom torchtext.legacy.data import Field, LabelField功能完整只是官方不再增加新特性。迁移到新 API用get_tokenizer和build_vocab_from_iterator自己写 collate 函数。灵活性更高但代码量会多出不少。我的建议是如果你只是想快速验证模型思路直接用 legacy 是最快的代码改动最小如果要写一个长期维护的生产级代码尽量用新 API毕竟 legacy 终有一天会被彻底移除。万一你看到别人的代码用的是老 API而你本地是 0.9 以上版本别慌把torchtext.data前缀改成torchtext.legacy.data大概率就直接能跑。5. 效果优化与后续扩展5.1 词向量和数据增强的影响在 TREC 上预训练词向量带来的提升非常明显。我用随机初始化的 100 维词向量时测试准确率在 86% 左右换成同一个 100 维的公开预训练词向量后同样的模型和超参数直接跳到 90% 以上。原因很简单TREC 训练集只有 5000 多条很多词只出现过一次随机初始化的 embedding 根本学不出可靠的语义预训练词向量则把这些词映射到了相对合理的语义空间等于把外部的大规模语料知识迁移了过来。数据增强在 TREC 上也值得一试。短文本可以做同义词替换比如把what替换成which在部分模板里不改变问句意图、随机删除虚词、回译比如把英文问句翻译成中文再翻回来会得到大量语义相近但表达迥异的问句。不过因为 TREC 问句本身高度模板化增强带来的收益主要集中在原本就少见的表达上对整体准确率的提升幅度有限大概在 1 到 2 个百分点。5.2 细分类和多标签变体这个项目做的是 6 大类TREC 还有一个 50 细分类的版本比如LOC:country、NUM:date、HUM:individual等。从 6 类变成 50 类模型代码一行都不用改只需要换数据集标签集合然后记得把输出维度num_class改成 50。如果你遇到类别数量差距变大的情况建议加一个class_weight到CrossEntropyLoss避免数量少的细分类完全被忽略。如果你想把任务改成多标签分类比如“一个问题同时属于 LOC 和 NUM”这种场景可以自己构造多热标签把模型的输出层从 6 维变成 6 个二分类输出损失函数从CrossEntropyLoss换成BCEWithLogitsLoss。torchtext 里的LabelField支持is_target参数但多标签通常需要每个标签一个字段或者直接放弃LabelField在自定义的 collate 函数里拼接标签向量。这块算是一个不错的动手扩展方向。结尾最后说点实际操作中的体会。这个项目我从开始搭到跑出首个 90% 以上的准确率最耗时间的不是模型本身而是处理各种数据管道上的隐性 bug字段顺序写反、版本不兼容、推理阶段忘了归一化文本、padding 索引不一致。建议你照着本文把代码跑通之后再花一下午做三件事关掉预训练词向量看准确率降到多少、把 TextCNN 换成 TextRNN 对比效果、写一个针对任意新问句的预测函数。三件事做完你对 torchtext 的理解会从“会用”变成“能改”。如果你在复现过程中遇到本文没提到的问题先打印出vars(batch)检查张量形状再检查字段和模型的维度是否对齐这两步能解决大多数异常。祝你在 torchtext 的坑里少待一会儿早点跑出你自己的模型。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑