简介面向PyTorch与NLP初学者资源包提供了基于TextCNN的中文文本分类与情感分析完整实现涵盖数据预处理、模型构建、训练评估与推理全流程。包内共16个文件以4个Python脚本模型构建、数据加载、训练主程序为核心辅以3个TSV数据文件与1个CSV文件作为训练/验证/测试集另有若干工程配置文件整体仅4.7MB轻量且便于直接运行调试。项目采用jieba分词与词嵌入方式将中文转为数字表示通过多尺寸卷积核提取n-gram特征并配合最大池化与全连接层完成情感二分类。资源目录将数据、模型与训练逻辑分离结构清晰适合作为课程设计或入门实践参考。目前已有1350人学习下载按说明配置PyTorch、jieba等依赖后即可快速跑通实验并可进一步对照准确率、F1等指标调优模型与超参数。1. Pytorch TextCNN做中文情感分析为什么2025年还在用它中文情感分析的需求一直没冷却过电商评论、外卖评分、客服工单、社交平台舆情太多业务需要快速知道“用户这句话是正面还是负面”。拿到一批标注好的中文文本先别急着上BERT或者LLM做意图识别多数时候一个轻量的TextCNN就能把准确率顶到接近下游可用线而且训练时间从半小时砍到几分钟。TextCNN不是模型里的新面孔但它在短文本场景下仍然是最难被替代的基线之一训练快、占显存小、行为可解释标注数据只有几千条也能稳定收敛。标题里强调“完整代码数据可直接运行”本质上是指这一套流程——数据清洗、分词、建词表、搭模型、训练、评估、落盘——每一步都有标准做法。这篇就是按这个流程把代码和参数掰开讲。2. 中文文本分类的数据预处理分词、padding与Label编码2.1 数据格式选择为什么csv比txt更适合当输入文本分类项目的第一步是把数据整理成统一结构。常见做法是把标注好的语料放在csv文件里三个字段就够了label表示情感标签text表示原始文本id可留可不留但保留有利于排查坏样本。用csv而不用纯txt的原因很实际csv天然按行对齐一个样本一条记录后续做多分类时标签列可以不只放“正/负”还可以扩展成“中性/愤怒/惊喜”等多粒度标注改动成本为零。下面这个代码块是一个可以直接落地的读取与样本统计逻辑import pandas as pd df pd.read_csv(train.csv, encodingutf-8) # 兼容常见两种列名写法 if label not in df.columns: df.rename(columns{sentiment: label}, inplaceTrue) print(df[label].value_counts()) label2id {label: idx for idx, label in enumerate(df[label].unique())} id2label {idx: label for label, idx in label2id.items()} df[label_id] df[label].map(label2id)value_counts()用来确认类别分布如果正负样本比接近1:1可以直接训否则要在后文提到的损失函数或采样策略上做调整。label2id把中文标签映射为整数这一步决定了后面损失函数计算的是几分类问题。2.2 中文分词与文本清洗jieba和正则的边界中文分类和英文最大的差异在分词。英文按空格切中文必须分词或按字切否则“很不好”和“不很好”会被拆成完全不同的token串。当前TextCNN实现多数用jieba做粗分词再配合正则过滤噪声字符。清洗规则需要根据业务来源来定爬来的评论可能有HTML实体、用户、URL这些对情感判断帮助有限但对词表大小影响很大。代码可以这么写import re import jieba def clean_and_tokenize(text: str, max_len: int 128) - list[str]: # 统一换行符、去除URL text text.replace(\n, ).strip() text re.sub(rhttps?://\S, , text) # 去手机号、邮箱、连续标点 text re.sub(r\d{11}, , text) text re.sub(r[^\u4e00-\u9fffA-Za-z0-9。、], , text) # 全角转半角 text text.translate(str.maketrans(。, ,.!?)) # jieba返回生成器这里转列表 tokens [t for t in jieba.cut(text) if t.strip() and t not in STOPWORDS] return tokens[:max_len]正则表达式保留中英文和基本标点\d{11}删除手机号避免模型把数字当成分类特征。注意jiejba分词后要过滤纯空格和停用词常见的停用词表可以在GitHub上直接搜“中文停用词”获得也可以自己基于高频无意义词构造。有一个经验点对情感分析任务“不”“没”“别”这类否定词绝不能进停用词表否定了它们模型基本废了。2.3 构造Dataset与DataLoadermax_len、padding与batch分完词之后要建词表然后把每条样本转成等长向量。等长的实现原理是取一个固定长度max_len超过截断不足补0。词表构建常用torchtext或collections.Counter手写推荐手写逻辑透明且不引入额外版本依赖问题。核心是下面这段from collections import Counter from torch.utils.data import Dataset, DataLoader import torch def build_vocab(tokenized_texts, min_freq1): counter Counter() for tokens in tokenized_texts: counter.update(tokens) # 0留给pad1留给unknown vocab {pad: 0, unk: 1} for word, freq in counter.items(): if freq min_freq: vocab[word] len(vocab) return vocab class SentimentDataset(Dataset): def __init__(self, tokenized_texts, labels, vocab, max_len128): self.data [(self.encode(seq, vocab, max_len), l) for seq, l in zip(tokenized_texts, labels)] def encode(self, tokens, vocab, max_len): ids [vocab.get(t, 1) for t in tokens[:max_len]] if len(ids) max_len: ids [0] * (max_len - len(ids)) return torch.tensor(ids, dtypetorch.long) def __len__(self): return len(self.data) def __getitem__(self, i): return self.data[i] def collate_fn(batch): inputs torch.stack([x[0] for x in batch]) labels torch.tensor([x[1] for x in batch], dtypetorch.long) return inputs, labels这里面两个细节最容易出错一是unk占位符的id必须固定否则验证阶段遇到词表外词会错位二是截断策略对情感分析场景开头和结尾往往比中间信息量大——开头是观点总起结尾是总结性评价。如果max_len设得不够优先保头和保尾的截断方式值得一试比如tokens[:max_len//2] tokens[-(max_len//2):]。collate_fn手动stack是因为每条样本都已在Dataset里做了padding不需要再去动态pad。3. 用Pytorch从零搭建TextCNN模型Embedding与多尺寸卷积核3.1 TextCNN的结构设计卷积核尺寸分别捕捉什么TextCNN的核心假设是短语决定情感极性。一个卷积核覆盖kernel_size个连续词本质是抓n-gramkernel_size2抓二元词组比如“难吃”kernel_size3抓三元词组比如“非常好吃”kernel_size4则更偏短语级模式。这就是为什么TextCNN要在多个尺寸上并行做卷积然后把结果拼接起来而不是只用一个卷积核。整体前向流程是词向量矩阵 → 多尺寸一维卷积 → ReLU → 全局最大池化 → 拼接 → Dropout → 全连接输出logits。关键设计有二一是Embedding层可以随机初始化也可以加载预训练向量两种做法在调优后会对比二是nn.Conv1d的输入形状是(batch, embed_dim, seq_len)卷积核在时间维度上滑动通道维度对应词向量维度这与CV里的Conv2d直觉略有差异。3.2 TextCNN的PyTorch实现与shape推导以下是一个可以直接跑的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_filters, filter_sizes, num_classes, dropout0.5, max_len128): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(in_channelsembed_dim, out_channelsnum_filters, kernel_sizesize) for size in filter_sizes ]) self.fc nn.Linear(len(filter_sizes) * num_filters, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x: (batch, max_len) emb self.embedding(x) # (batch, max_len, embed_dim) emb emb.transpose(1, 2) # (batch, embed_dim, max_len) pooled [] for conv in self.convs: c conv(emb) # (batch, num_filters, max_len - kernel_size 1) c F.relu(c) p F.max_pool1d(c, c.size(2)) # (batch, num_filters, 1) pooled.append(p.squeeze(2)) cat torch.cat(pooled, dim1) # (batch, num_filters * len(filter_sizes)) out self.fc(self.dropout(cat)) return out形状推导值得逐行核对输入x是(batch_size, max_len)的token id矩阵。经过nn.Embedding变成(batch_size, max_len, embed_dim)。转置后成为(batch_size, embed_dim, seq_len)这才是Conv1d期望的布局。每个卷积的输出长度是max_len - kernel_size 1经过max_pool1d会把整个时间维度压成单个最大值因此每个卷积核最终只输出num_filters个数。全连接层的输入维度是len(filter_sizes) * num_filters这里的乘数是卷积核尺寸的种类数不是卷积核总量容易搞混。3.3 为什幺选max-pooling而不是average-pooling这里有一个几乎每次面试都会被问到的点max_pooling保留的是整个序列里最强烈的信号。对情感分析而言“太棒了”出现在句子末尾而前面全是客观描述时max-pooling能定位到“棒”这个强特征。average-pooling会把大量平淡词汇的特征拉进来稀释语义。当然max-pooling的代价是只保留一个最大值、丢失位置信息所以TextCNN在长文本里效果衰减很快这也是它适合短文本的原因之一。写模型时padding_idx0不能省略否则模型会去学习pad符号的向量白白浪费参数还容易过拟合。filter_sizes推荐从[2, 3, 4]起步num_filters从128调起。4. 训练与调参Pytorch训练循环、早停与模型保存4.1 训练框架搭建优化器、损失函数与评估指标训练代码的骨架在文本分类任务里高度统一但要跑出稳定结果有几个细节必须处理好。第一个是优化器AdamW比Adam多了权重衰减解耦配合weight_decay能明显限制过拟合。第二是损失函数CrossEntropyLoss自带softmax同时接受原始logits不要在模型里先过softmax再进loss。第三是类不平衡如果负样本占比大给CrossEntropyLoss传weight参数按类别样本量倒数归一。from torch.optim import AdamW from sklearn.metrics import accuracy_score, f1_score def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss, all_preds, all_labels 0, [], [] for inputs, labels in dataloader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() logits model(inputs) loss criterion(logits, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() all_preds.extend(torch.argmax(logits, dim1).cpu().tolist()) all_labels.extend(labels.cpu().tolist()) return total_loss / len(dataloader), accuracy_score(all_labels, all_preds), f1_score(all_labels, all_preds, averagemacro)clip_grad_norm_这一行的作用是梯度裁剪。Embedding层的梯度波动比卷积层大尤其在加载预训练词向量时偶尔一个大梯度会让整条loss曲线陡增clip到1.0或0.5可以显著提升稳定性。训练结束后打印f1_score(averagemacro)类别不均衡时F1比accuracy可信得多。4.2 必调参数表learning_rate、batch_size、num_filters与dropout训练TextCNN时调参优先级最高的是下面几个参数按影响从大到小排列参数推荐范围调参现象说明learning_rate1e-3 ~ 1e-4超过1e-3容易震荡低于5e-5收敛太慢batch_size32 ~ 128显存允许下优先64起步num_filters100 ~ 256调大提升效果但显存线性增长dropout0.3 ~ 0.6数据量小于1万时用0.5以上防过拟合embedding_dim100 ~ 300用字向量或词向量时取对应维度learning_rate的选择与优化器强相关。AdamW的默认lr是1e-3但在小数据集上经常降到5e-4更稳。一个实际判断标准是观察前100个batch的loss如果loss在下降但偶尔突刺说明lr偏大如果下降曲线太平滑且幅度小可以调到1e-3再试。训练过程中每轮跑完做一次验证如果验证loss连续三轮不降立刻回滚到上一轮的模型参数而不是继续训下去。4.3 早停策略与完整训练流程早停early stopping的判定可以基于验证集F1或loss推荐基于loss因为F1在epoch数少时波动较大容易误判。保存模型时只保存state_dict和vocab、label2id不要用torch.save(model)保存整个对象后续升级代码时兼容性差得多。best_loss float(inf) best_state None for epoch in range(10): train_loss, train_acc, train_f1 train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc, val_f1 evaluate(model, val_loader, criterion, device) print(fepoch {epoch} | train_loss {train_loss:.4f} | val_loss {val_loss:.4f} | val_f1 {val_f1:.4f}) if val_loss best_loss: best_loss val_loss best_state {k: v.cpu().clone() for k, v in model.state_dict().items()} model.load_state_dict(best_state) torch.save({ model_state_dict: best_state, vocab: vocab, label2id: label2id, config: { embed_dim: embed_dim, num_filters: num_filters, filter_sizes: filter_sizes, max_len: max_len, } }, textcnn_sentiment.pt) print(saved best model, val_loss%.4f % best_loss)参数说明best_state需要clone到CPU再存否则在后端继续训练时GPU显存被这些历史权重占用。config字段是必须的推理阶段加载模型时要根据它重新实例化TextCNN否则vocab_size对不上直接报错。早停的轮数上限建议设为总epoch的1/3比如计划训20轮连续5轮不降就停。5. 模型评估与坏例分析让准确率停在“能上线”而不是“跑通”5.1 用混淆矩阵定位模型盲区Accuracy和F1只能告诉你整体水平没法告诉你模型错在哪。拿到validation结果后第一件事是画混淆矩阵按“真实类别 × 预测类别”做交叉统计。情感分析里最常见的偏差是模型把负面样本预测成正面原因往往是训练的负面样本中有大量反讽表达词面是褒义词但真实情感是负的。from sklearn.metrics import confusion_matrix import numpy as np def confusion_report(model, dataloader, id2label, device): model.eval() y_true, y_pred [], [] with torch.no_grad(): for inputs, labels in dataloader: inputs inputs.to(device) logits model(inputs) y_pred.extend(torch.argmax(logits, dim1).cpu().tolist()) y_true.extend(labels.tolist()) cm confusion_matrix(y_true, y_pred) for i, true_label in enumerate(id2label.values()): for j, pred_label in enumerate(id2label.values()): if cm[i][j] 3 and i ! j: print(f真实{true_label} 被预测为 {pred_label}: {cm[i][j]} 条) return cm这个输出的价值在于直接告诉你错得最集中的那对类别组合。然后从数据集里挑出这些被分错的样本逐条看原始文本判断是标注质量问题还是模型特征学习偏了。标注错误常见到几乎每个项目都有一条标反的数据抵得上十条正常数据因为模型为了拟合它学到的特征非常反直觉。5.2 短文本与网络口语TextCNN在真实评论上的数据增益技巧真实场景里的中文评论和公开数据集的规范文本差距很大大量叠词“哈哈哈哈”、标点连续使用“”、网络新词“绝绝子”。处理这些口语化文本的一个技巧是加入字符级特征即同时训练词级别和字级别的两种序列分别过TextCNN后做特征拼接。# 同一条样本输出两个并行分支 词序列: [这家, 店, 的, 服务, 非常, 好] 字序列: [这, 家, 店, 的, 服, 务, 非, 常, 好]字级别的优势在于天然解决未登录词问题——“绝绝子”在词表里可能没有但“绝”“子”都在字表里。字级别的向量维度可以比词级别低一些比如词用embed_dim200字用embed_dim100两个分支的输出拼接后接全连接。这个方案在微博评论、B站评论这类短文本上提升明显而分词器新增词汇维护成本几乎为零。5.3 嵌入层选型随机初始化、word2vec与动态微调模型的最后一块拼图是Embedding初始化的策略。随机初始化处理不了低频词学出来的向量语义空间没有结构。预训练词向量能给你的是语义先验“好吃”和“美味”在向量空间里距离更近这让模型在少量标注数据下也能把相似语义的样本聚在一起。推荐下载中文预训练词向量用gensim加载后构造权重矩阵、赋给Embedding层。加载时需要注意词表对齐import gensim from torch import nn def load_pretrained_embedding(vocab: dict, w2v_path: str, embed_dim: int) - nn.Embedding: w2v gensim.models.KeyedVectors.load_word2vec_format(w2v_path, binaryFalse) embedding nn.Embedding(len(vocab), embed_dim, padding_idx0) init torch.randn(len(vocab), embed_dim) * 0.1 hit 0 for word, idx in vocab.items(): if word in w2v: init[idx] torch.tensor(w2v[word], dtypetorch.float32) hit 1 embedding.weight.data init print(f词向量命中率: {hit}/{len(vocab)} {hit / len(vocab):.2%}) return embedding命中率低于80%时说明vocab里的词多半是低频新词或切出来的碎片这时加载预训练向量的收益有限不如保留随机初始化并加大min_freq过滤掉低频词。预训练向量要不要冻结取决于标注数据量数据量超过1万条建议解冻微调数据量只有几千条冻结可以防过拟合。这个选择可以直接影响最终F1的1到3个百分点。如果环境受限、部署目标是边缘设备甚至pytorch fpga这类低资源场景随机初始化加字级特征往往比强制加载大词表更务实。本文还有配套的精品资源点击获取