NLP实战(1)从零构建TextCNN文本分类器:PyTorch实现与调优

📅 2026/6/20 0:41:16
NLP实战(1)从零构建TextCNN文本分类器:PyTorch实现与调优
1. 为什么选择TextCNN做文本分类我第一次接触TextCNN是在处理新闻标题分类任务时。当时试过传统的机器学习方法效果总是不尽如人意直到发现了这个既简单又高效的模型。TextCNN最大的优势在于它能自动捕捉文本中的局部特征比如短语级别的语义信息这点在文本分类中特别重要。你可能听说过CNN在图像处理中的成功但它在文本上的应用同样惊艳。想象一下我们把一句话的每个词向量排成一行就像把像素排成图像一样。不同尺寸的卷积核就像不同大小的语义扫描器2x5的核可以捕捉两个词组成的短语特征4x5的核则能识别四个词的语义片段。实际项目中我对比过几种模型TextCNN在短文本分类上的表现往往比RNN更快更好。特别是在新闻分类这种任务上关键信息经常集中在某些短语中比如股市大涨之于财经类新闻这正是TextCNN擅长处理的。有次我处理一个10万条的新闻数据集TextCNN只用了20分钟训练就达到了92%的准确率而LSTM花了近1小时才达到89%。2. 环境准备与数据加载2.1 安装必要的库建议使用conda创建一个新环境避免包冲突。这是我常用的配置conda create -n textcnn python3.8 conda activate textcnn pip install torch1.12.1 torchtext0.13.1 pandas tqdm我习惯用Jupyter Notebook做实验可以实时看到数据处理效果。安装完成后先导入这些基础模块import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader import pandas as pd from tqdm import tqdm2.2 准备THUCNews数据集THUCNews是中文新闻分类的经典数据集包含10个类别。我处理数据时遇到过几个坑编码问题原始文件可能是GBK编码需要用encodinggb18030打开数据清洗需要去除特殊字符和多余空格文本截断设置合理的max_len太长浪费计算资源太短丢失信息这是我改进后的数据加载代码class NewsDataset(Dataset): def __init__(self, file_path, word2idx, max_len32): self.all_text [] self.all_label [] self.word2idx word2idx self.max_len max_len with open(file_path, r, encodinggb18030) as f: for line in f: label, text line.strip().split(\t) # 清洗特殊字符 text .join([c for c in text if \u4e00 c \u9fa5 or c.isalnum()]) self.all_text.append(text) self.all_label.append(label) def __getitem__(self, index): text self.all_text[index][:self.max_len] label int(self.all_label[index]) # 转换为索引序列 text_idx [self.word2idx.get(c, 1) for c in text] # 1是UNK的索引 # 填充到固定长度 text_idx text_idx [0] * (self.max_len - len(text_idx)) return torch.tensor(text_idx), torch.tensor(label)3. 构建TextCNN模型3.1 理解模型架构TextCNN的核心是多尺寸卷积核并行工作。我画了个更直观的结构图来说明输入文本 - 词嵌入层 - 并行的三个卷积层(2,3,4-gram) - 最大池化 - 拼接 - 全连接分类每个卷积块处理不同长度的n-gram特征2-gram捕捉短语对如科技 创新3-gram识别短句如人工智能 技术4-gram理解更长片段3.2 PyTorch实现细节这是我优化后的实现增加了BatchNorm和Dropoutclass ConvBlock(nn.Module): def __init__(self, kernel_size, embed_dim, max_len, out_channels): super().__init__() self.conv nn.Conv2d( in_channels1, out_channelsout_channels, kernel_size(kernel_size, embed_dim) ) self.bn nn.BatchNorm2d(out_channels) self.act nn.ReLU() self.pool nn.MaxPool1d(kernel_sizemax_len - kernel_size 1) def forward(self, x): # x形状: [batch, 1, max_len, embed_dim] x self.conv(x) # [batch, out_channels, seq_len, 1] x self.bn(x) x self.act(x) x x.squeeze(-1) # 移除最后一个维度 x self.pool(x) # [batch, out_channels, 1] return x.squeeze(-1) # [batch, out_channels] class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, max_len, num_classes, hidden_dim128): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # 三个不同尺度的卷积块 self.block1 ConvBlock(2, embed_dim, max_len, hidden_dim) self.block2 ConvBlock(3, embed_dim, max_len, hidden_dim) self.block3 ConvBlock(4, embed_dim, max_len, hidden_dim) self.dropout nn.Dropout(0.5) self.classifier nn.Linear(hidden_dim * 3, num_classes) def forward(self, x): # x形状: [batch, max_len] x self.embedding(x) # [batch, max_len, embed_dim] x x.unsqueeze(1) # 增加通道维度 [batch, 1, max_len, embed_dim] # 并行卷积 f1 self.block1(x) f2 self.block2(x) f3 self.block3(x) # 特征拼接 features torch.cat([f1, f2, f3], dim1) features self.dropout(features) return self.classifier(features)关键改进点添加BatchNorm加速收敛使用Dropout防止过拟合更清晰的维度注释可配置的隐藏层维度4. 模型训练与调优4.1 训练循环实现训练时我发现学习率和batch_size对结果影响很大。这是我调整后的训练代码def train(model, train_loader, val_loader, epochs10, lr1e-3): device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lrlr) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, max, patience2) best_acc 0 for epoch in range(epochs): model.train() total_loss 0 progress tqdm(train_loader, descfEpoch {epoch1}) for inputs, labels in progress: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() progress.set_postfix({loss: loss.item()}) # 验证集评估 val_acc evaluate(model, val_loader) scheduler.step(val_acc) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt) print(fEpoch {epoch1}: loss{total_loss/len(train_loader):.4f}, val_acc{val_acc:.4f})4.2 关键调参技巧经过多次实验我总结了这些经验学习率从3e-4开始尝试配合ReduceLROnPlateau词向量维度中文建议100-300维卷积核数量128-256之间效果较好Dropout率0.3-0.5防止过拟合文本长度新闻标题建议20-30正文可适当延长这是我常用的参数组合config { vocab_size: 50000, # 词表大小 embed_dim: 200, # 词向量维度 max_len: 32, # 文本最大长度 hidden_dim: 128, # 卷积核数量 dropout: 0.5, # dropout概率 lr: 3e-4, # 初始学习率 batch_size: 64 # 批大小 }5. 模型评估与优化5.1 评估指标实现除了准确率我还会计算F1值和混淆矩阵from sklearn.metrics import classification_report def evaluate(model, data_loader): model.eval() all_preds [] all_labels [] with torch.no_grad(): for inputs, labels in data_loader: inputs inputs.to(device) outputs model(inputs) preds torch.argmax(outputs, dim1).cpu() all_preds.extend(preds.numpy()) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds)) return accuracy_score(all_labels, all_preds)5.2 性能优化方向当准确率遇到瓶颈时可以尝试使用预训练词向量加载中文Word2Vec或GloVe增加模型深度堆叠多个卷积层注意力机制在卷积后添加注意力层模型融合结合TextCNN和BiLSTM的优势数据增强回译、同义词替换等方法预训练词向量加载示例def load_pretrained_embeddings(word2idx, embed_dim300): # 假设有预训练的词向量文件 pretrained {} with open(sgns.zhihu.bigram, r, encodingutf-8) as f: for line in f: items line.split() word items[0] vector list(map(float, items[1:])) pretrained[word] vector # 初始化嵌入矩阵 matrix np.random.randn(len(word2idx), embed_dim) for word, idx in word2idx.items(): if word in pretrained: matrix[idx] pretrained[word] return torch.FloatTensor(matrix)6. 完整项目实践建议在实际部署TextCNN时我建议采用这样的项目结构textcnn-project/ ├── data/ # 存放数据集 ├── models/ # 模型定义 │ ├── textcnn.py ├── utils/ # 工具函数 │ ├── data_loader.py │ ├── evaluator.py ├── config.py # 参数配置 ├── train.py # 训练脚本 └── predict.py # 预测脚本预测接口示例class Predictor: def __init__(self, model_path, word2idx_path): self.word2idx torch.load(word2idx_path) self.model TextCNN(len(self.word2idx), 200, 32, 10) self.model.load_state_dict(torch.load(model_path)) self.model.eval() def predict(self, text): # 文本预处理 text self._preprocess(text) text_idx [self.word2idx.get(c, 1) for c in text] text_idx text_idx [0] * (32 - len(text_idx)) # 预测 with torch.no_grad(): inputs torch.LongTensor(text_idx).unsqueeze(0) outputs self.model(inputs) prob torch.softmax(outputs, dim1) return prob.numpy()处理中文文本时建议先进行分词。可以尝试结巴分词import jieba def chinese_segment(text): return .join(jieba.cut(text))