简介面向Python机器学习初学者的中文文本分类项目源码包聚焦字符级卷积神经网络与循环神经网络在中文语料上的实践适合想快速掌握TensorFlow文本分类全流程的读者。压缩包内共18个文件以Python脚本为主涵盖模型构建、训练、预测、数据预处理等完整代码另附4张网络结构图与训练曲线图、说明文档及运行环境依赖清单整体仅410KB轻量易部署。目前已有170人学习/下载。项目自带数据切分与聚合脚本可从原始分类数据中自动生成训练集50000条、验证集5000条、测试集10000条的标准数据集数据预处理模块还封装了词表构建、字符级表示、类别映射等常用函数省去重复处理时间。源码同时提供CNN与RNN两套模型及独立训练入口并配有架构图和准确率损失曲线读者可对照理解两类网络在中文文本分类中的差异也能基于现有代码调整参数或替换数据集快速复现实验并扩展自己的分类任务。1. 中文文本分类为什么偏偏要 CNN 和 RNN 一起用中文文本分类是个老问题但很多初学者第一次跑通“Python 基于 CNN 和 RNN 实现的中文文本分类”时最容易掉进一个误区以为 CNN 和 RNN 是二选一选了 textCNN 就不再碰 LSTM。实际上把卷积神经网络和循环神经网络接到同一个模型里让 CNN 去抓局部关键词组合让 RNN 去抓上下文顺序对中文这种没有天然空格分隔、一词多义严重的语言来说效果往往比单独用任何一个都好。这个项目源码加数据集的组合正好是入门深度学习文本分类最完整的一条链路从原始中文语料到分词、词表构建、padding再到模型训练和评估。适合刚学完 PyTorch 基础、想找一个完整项目练手的读者也适合要做舆情分类、评论情感打标这类任务、需要一个可靠基线模型的工程师。本文就把这条链路拆开讲透告诉你每一步怎么落、参数怎么调、坑在哪里。2. CNN 和 RNN 在中文文本里的分工一个抓词一个抓顺序2.1 textCNN 的工作原理卷积核在词向量序列上滑动中文文本要进卷积神经网络第一步是把句子转成矩阵。假设一句话包含 n 个词每个词查词表得到 embedding 向量维度是 d那么这句话就变成一个形状为 [n, d] 的矩阵。textCNN 的卷积核不是二维图像上的方形卷积核而是高度为 h、宽度为 d 的卷积核。高度 h 表示每次看连续的 h 个词宽度 d 必须和 embedding 维度一致这样卷积核才能完整覆盖每个词的向量。例如 h3 的卷积核就是每次看连续的 3 个词对这 3 个词的向量做加权组合提取一个 n-gram 特征。conv_input embedded.unsqueeze(1) # [batch, 1, seq_len, embed_dim] conv nn.Conv2d(in_channels1, out_channelsnum_filters, kernel_size(filter_size, embed_dim)) conv_out conv(conv_input) # [batch, num_filters, conv_seq_len, 1] conv_out conv_out.squeeze(3)这段代码是 textCNN 的核心。unsqueeze(1)是在第 1 维插入一个通道维度因为 PyTorch 的 Conv2d 要求输入是四维 [batch, channel, height, width]。kernel_size(filter_size, embed_dim)中的filter_size就是卷积核覆盖的连续词数embed_dim必须和词向量维度相同否则维度对不上直接报错。卷积后得到的conv_seq_len等于seq_len - filter_size 1。卷积之后紧跟最大池化作用是从每个卷积核产生的特征图中取出最大的那个值表示“这个 n-gram 特征在整句话里最强烈的信号是什么”。多个卷积核并行比如同时用 2、3、4 三种 filter_size每种 128 个卷积核就得到 384 维的特征向量再接全连接层分类。这就是 textCNN 的完整逻辑它不关心词的先后顺序只关心哪些词的组合出现在句子里。pooled F.max_pool1d(conv_out, conv_out.size(2)).squeeze(2)max_pool1d的池化窗口是整个序列长度所以无论句子多长只要出现过这个 n-gram 特征就能被捕捉到。这也是 CNN 在文本分类里的优势训练速度快、参数少、对短文本的效果特别好。但它的代价是丢失了词序信息比如“不”和“好”的顺序被卷积核同时看到时“不好”和“好不”可能被混为一谈。2.2 BiLSTM 的补充词序和上下文依赖靠它来兜底循环神经网络处理文本的方式完全不同。RNN 按时间步逐个读入词向量每一步都维护一个隐藏状态这个隐藏状态相当于模型对“到目前为止读到的内容”的压缩记忆。但普通的 RNN 有梯度消失问题序列一长前面的信息就传不过来所以实际工程里几乎不用原始 RNN而是用 LSTM 或 GRU。LSTM 通过输入门、遗忘门、输出门机制决定哪些旧信息要保留、哪些新信息要写入这让它能把一句话里前后呼应的信息记下来。lstm nn.LSTM(input_sizeembed_dim, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalTrue) lstm_out, (h_n, c_n) lstm(embedded)这段代码里的bidirectionalTrue意味着用双向 LSTM一个 LSTM 从左往右读句子另一个从右往左读然后把两个方向的输出拼起来。这么做对中文特别重要因为中文的语义依赖经常是后文决定前文的比如“他跑了”和“他跑了业务”同一个“跑”字在不同语境下含义完全不同。双向 LSTM 能让每个位置的输出同时包含它左边和右边的信息。h_n是最后一层的隐藏状态形状为 [num_layers * num_directions, batch, hidden_size]。项目里通常取最后一层的两个方向隐藏状态拼接起来作为整个句子的语义表示。但这有个取舍如果取lstm_out在最后一个时间步的输出就只用了右向的最终信息和左向的最终信息中间位置的强特征可能被淹没如果对lstm_out做全局平均池化或最大池化就能把每个位置的输出都利用上。常见做法是把双向的最后一个隐藏状态拼接再接全连接层这样得到的向量同时包含句首和句尾的信息对整句分类来说已经够用。2.3 两个分支融合为什么 concat 优于相加或取平均把 CNN 分支和 RNN 分支接起来时最简单的做法就是把两边输出的特征向量直接拼起来得到一个更高维的向量再接一个全连接层做分类。有人会问为什么不把两个向量相加或者取平均原因在于CNN 提取的特征偏重局部组合模式RNN 提取的特征偏重全局顺序依赖两者所在的特征空间差异很大直接相加相当于强迫两个分布在同一个坐标系里对齐反而互相干扰。concat 只是把两个特征空间拼在一起让后面的全连接层自己去学怎么组合这些维度灵活性更高。combined torch.cat((cnn_pooled, rnn_hidden), dim1) # [batch, cnn_dim rnn_dim] logits fc(dropout(combined))这里的cnn_pooled是 textCNN 经过池化后的输出rnn_hidden是 BiLSTM 最后隐藏状态拼接后的结果。dropout加在全连接层前面一般设 0.3 到 0.5 之间。fc的输出维度是类别数。从项目的实际效果来看融合模型在中文新闻分类、情感二分类这类任务上准确率通常比单独 textCNN 高 1 到 2 个百分点但训练时间几乎是 textCNN 的 3 倍。所以如果你的任务对延迟敏感或者数据量很小单独跑 textCNN 也是个能接受的基线数据量过万、文本长度超过 20 个词时融合模型的优势才会真正体现出来。3. 数据准备中文文本进模型前必须过的四道关3.1 原始数据集格式与标签处理先搞清分类目标这个项目附带的数据集解压后常见的组织方式是一个 data 目录下面按类别分子目录每个类别目录里放若干 txt 文件每行是一篇文章或一条评论或者一个 csv 文件有 text 和 label 两列。先别急着写模型第一步是搞清楚数据集的规模、类别分布、文本长度分布。用 pandas 读进来后第一件事是看 label 的分布因为中文分类数据集经常有类别极度不平衡的问题比如“科技”类文章是“体育”类的 5 倍这直接影响后面的评估方式和模型收敛行为。import pandas as pd df pd.read_csv(data/train.csv, sep\t) print(df[label].value_counts()) df[text_len] df[text].apply(lambda x: len(x)) print(df[text_len].describe())value_counts()告诉你每类有多少条样本如果发现某个类别只有几十条后面训练时就要考虑类别加权或者干脆去掉这个类别。describe()看文本长度分布主要关注中位数和 75 分位这两个值决定你在做 padding 时把max_len设成多少。如果文本长度的中位数是 30但 90 分位是 300说明有少量超长文本粗暴截断到 300 会损失大部分样本的信息截断到 50 又会让长文本的尾部信息全部丢失。正确做法是画一个长度分布直方图找一个能覆盖 90% 以上样本的长度作为max_len剩下的 10% 截断。不同类别的文本长度差异也值得留意。新闻标题类数据普遍很短可能 20 到 40 个字影评、商品评论则动辄几百字。如果混合训练短文本被 padding 到和长文本一样长大量 padding 位是没意义的这不仅是算力浪费还会让模型把 attention 权重分配到无效位置。所以实际处理时我会把max_len定为 128 或 200既覆盖大部分短文本也保留长文本的核心内容。对超长文本直接取前 128 个字符通常效果尚可如果任务依赖长距离语义比如判断一篇长文章的整体情感倾向可以用“头尾截取法”即取前 64 个字加后 64 个字拼接效果往往比只取前 128 个字好。3.2 分词与词表构建jieba 不是唯一选择但默认用它中文和英文最大的区别是没有空格所以分词是绕不开的一步。这个项目默认用 jieba 分词因为它是纯 Python 实现、安装方便、对绝大多数领域语料有不错的表现。分词前先做一个简单的清洗去掉 HTML 标签、把全角字符转半角、过滤掉非中文字符和英文字母等。清洗规则不要写得太狠比如把数字全部删掉会损失“iPhone 15 值得买吗”这种含数字的语义比较好的做法是把英文和数字保留为独立词元让模型自己学它们的重要性。import jieba def tokenize(text): text text.lower() text re.sub(r.*?, , text) words jieba.lcut(text) words [w for w in words if w.strip()] return wordsjieba.lcut返回一个分词后的列表比jieba.cut更适合直接做后续处理。去掉空字符是为了避免把空格、换行符当成词。这里有个常见的分工有些项目不提前过滤停用词的、了、是、在等而是让模型自己学因为对深度学习模型来说停用词并非完全没有信息量它们在特定上下文里能帮助判断句式但保留全部停用词会拉长序列长度而且高频词会让 word2vec 或 embedding 层的更新过于集中。我的做法是先不过滤停用词把词表建出来看词频排序如果前 20 个高频词全是“的、了、是、在、我、你”之类的虚词再考虑加一个 100 词左右的停用词表过滤如果数据集本身是短文本虚词占比不高就不过滤。词表构建是数据准备的核心环节。把分词后的列表统计词频按词频降序排列保留出现次数超过某个阈值比如 2 次的词形成一个词表。词表里要预留三个特殊符号PAD占位符、UNK代表未见过的词、CLS作为句子开头标记如果用 BERT 风格的输入则必须有textCNN 和 BiLSTM 一般用不到。词表的开头三个位置固定给这三个符号from collections import Counter word_counter Counter() for words in tokenized_texts: word_counter.update(words) vocab {PAD: 0, UNK: 1} for word, freq in word_counter.most_common(vocab_size - 2): vocab[word] len(vocab)vocab_size是词表上限一般设 20000 到 50000。设得太小很多低频词变成UNK模型对专有名词和生僻词完全失去辨识力设得太大词表尾部大量只出现一两次的词会让 embedding 矩阵稀疏且占用显存。一个常用的经验是统计词频后设置最小出现次数为 2把所有出现次数大于等于 2 的词都保留看最终数量再决定要不要截断到 50000。3.3 序列 padding 与 batch 划分一个被低估的准确率影响因素词表构建完成后每一条文本都要从词列表转成索引列表再做 padding 到统一长度。这是整个数据管线里最容易出错的环节。如果代码里忘了对max_len以上的文本做截断或者只截断了没有限制长度就会导致一个 batch 内不同样本的序列长度不一致PyTorch 的nn.DataParallel会直接报错或者更隐蔽的是你用了pack_padded_sequence却没正确传递长度信息模型能跑但结果全错。def encode_and_pad(words, vocab, max_len): ids [vocab.get(w, vocab[UNK]) for w in words] if len(ids) max_len: ids ids[:max_len] else: ids ids [vocab[PAD]] * (max_len - len(ids)) return idsvocab.get(w, vocab[UNK])是关键未登录词统一映射到UNK的索引 1而不是直接报错或跳过。截断放在前面padding 放在后面顺序不能反。特别要注意截断策略是只保留前面还是头和尾都保留不同任务结果差别很大。短文本分类任务用前截断就行长文本情感分析任务使用头尾拼接的效果我在 3.1 里讲过。padding 的位置也有讲究textCNN 和 BiLSTM 默认都把 padding 放在尾部但如果用 Transformer 的 attention 机制就需要显式构造 attention mask 来屏蔽 padding 位否则 padding 位参与计算会污染向量表示。train_dataset TensorDataset(torch.tensor(X_train), torch.tensor(y_train)) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue)shuffleTrue在训练时必须开否则每个 epoch 内样本顺序固定模型会学到 batch 的顺序信息导致验证集准确率虚高。batch_size的选择取决于显存和序列长度一般 32 到 128。如果训练过程中显存溢出优先调小 batch_size而不是调小max_len因为后者直接影响模型效果。数据准备好之后记得把训练集、验证集、测试集按 8:1:1 或 7:2:1 划分。划分时用train_test_split的stratify参数保证每个子集里类别分布一致。这一点对不平衡数据集尤其重要如果不分层抽样小类可能在验证集里一条也没有导致评估结果完全失真。4. 模型搭建与训练从定义网络到跑通第一个 epoch4.1 把 CNN 和 RNN 拼成一个完整模型类定义融合模型的 PyTorch 类时我习惯把 embedding、CNN 分支、RNN 分支、全连接层作为子模块前向传播里依次调用。类的初始化接收几个关键参数vocab_size、embed_dim、num_filters、filter_sizes、hidden_size、num_layers、num_classes、dropout。这些参数在训练脚本里统一传入方便做实验对比。class HybridCNNRNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_filters, filter_sizes, hidden_size, num_layers, num_classes, dropout): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.cnn_convs nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) self.lstm nn.LSTM(embed_dim, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue) self.fc nn.Linear(len(filter_sizes) * num_filters hidden_size * 2, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): embedded self.embedding(x) # [batch, seq_len, embed_dim] embedded self.dropout(embedded) # CNN 分支 conv_input embedded.unsqueeze(1) cnn_outs [] for conv in self.cnn_convs: conv_out conv(conv_input).squeeze(3) pooled F.max_pool1d(conv_out, conv_out.size(2)).squeeze(2) cnn_outs.append(pooled) cnn_vec torch.cat(cnn_outs, dim1) # RNN 分支 lstm_out, (h_n, c_n) self.lstm(embedded) rnn_vec torch.cat((h_n[-2], h_n[-1]), dim1) combined torch.cat((cnn_vec, rnn_vec), dim1) combined self.dropout(combined) logits self.fc(combined) return logitspadding_idx0在nn.Embedding里的作用是索引为 0 的位置对应的向量在训练中始终为 0不参与梯度更新。这样 padding 位不产生任何信息避免把无意义的向量也丢进 LSTM 去干扰隐藏状态这是一定要写的参数。CNN 分支里每个 filter_size 对应一个卷积层输出都做最大池化最后把不同 filter_size 的结果拼起来。RNN 分支取h_n[-2]和h_n[-1]因为bidirectionalTrue时最后一层有两个方向的隐藏状态h_n的形状是[num_layers * 2, batch, hidden_size]倒数第二个是反向的最终隐藏状态倒数第一个是正向的最终隐藏状态两者拼接正好表示整句的上下文汇总。self.fc的输入维度是len(filter_sizes) * num_filters hidden_size * 2其中hidden_size * 2是因为双向 LSTM 拼接了两个方向的输出。很多新手在这里算错维度导致全连接层输入输出不匹配报错。建议在定义模型后先打印一层输出的形状确认没算错再往后走。4.2 训练循环交叉熵、优化器与一个值得记录的细节训练循环本身不复杂但有几个细节决定了这个项目跑出来的结果能不能复现。损失函数用交叉熵nn.CrossEntropyLoss()内部已经包含了 softmax 操作所以模型的最后一层不需要再接nn.Softmax直接输出 logits 就行。优化器用 Adam学习率从 0.001 起步配合ReduceLROnPlateau调度器当验证集损失连续两个 epoch 不下降时把学习率缩小 10 倍。optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience2 ) criterion nn.CrossEntropyLoss() for epoch in range(epochs): model.train() total_loss 0 for batch_x, batch_y in train_loader: batch_x, batch_y batch_x.to(device), batch_y.to(device) optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() # 验证阶段省略 scheduler.step(val_acc)clip_grad_norm_是一个关键细节。RNN 分支在长序列训练时非常容易梯度爆炸表现为 loss 突然变成 NaN 或者跳到天文数字。梯度裁剪把整个模型的梯度范数限制在 5.0 以内超过就等比缩放是防止这种情况最省事的手段。optimizer.zero_grad()放在前向传播之前否则梯度会跨 batch 累加这个错误不会报错但会让模型完全训不动。训练过程中要同时记录训练集 loss 和验证集准确率。一个经验是验证集准确率在前 5 个 epoch 内如果纹丝不动先检查数据预处理和模型输出维度不要急着调超参数验证集能涨但很慢才是调学习率和模型结构的时候。每个 epoch 结束时打印日志print(fEpoch {epoch1}/{epochs} | Loss: {total_loss/len(train_loader):.4f} | Val Acc: {val_acc:.4f})4.3 必调的四个超参数embed_dim、filter_sizes、hidden_size、dropout超参数是整个项目“玄学”浓度最高的地方但并不意味着没有规律可循。先说embed_dim也就是词向量维度。100 到 300 是常见范围维度太小词义表达不够维度太大在小数据集上容易过拟合。这个项目数据集通常在几万条到几十万条之间200 是一个稳妥的中间值。如果显存紧张降到 100 也能勉强用但准确率一般会掉 1 到 2 个点。filter_sizes决定 CNN 分支能捕捉多长的 n-gram。对中文来说2、3、4 是覆盖度最广的选择因为中文的惯用搭配和固定搭配以两字词和四字成语为主。如果你处理的是口语化评论可以试试 1、2、3因为口语里单个字和双字词的信息量更大。num_filters每个 filter_size 对应 100 到 256 个卷积核太大容易过拟合且训练变慢太小特征不够。一个判断方法是设了 128 个卷积核训练完后看 max pooling 输出的非零比例如果大部分都是 0说明卷积核太多可以减半再试。hidden_size控制 LSTM 隐藏状态的容量常见 128 或 256。num_layers控制在 1 到 2 层不要超过 3 层因为 LSTM 每多一层参数量翻倍训练时间大幅增加而文本分类不是阅读理解不需要太深的语义层级。dropout放 0.3 到 0.5 之间0.5 是保险值。如果你的训练集很小几千条dropout 不但不能防过拟合反而会让模型欠拟合此时调小到 0.2 或者干脆去掉 dropout 层配合 L2 正则更有效。model HybridCNNRNN( vocab_sizelen(vocab), embed_dim200, num_filters128, filter_sizes[2, 3, 4], hidden_size128, num_layers1, num_classeslen(label_to_idx), dropout0.4 )这里label_to_idx是类别到索引的映射在数据准备阶段就建好训练和推理阶段必须用同一个映射表。很多人训练完保存模型推理时却忘了保存映射表导致同样的文本预测结果错位。常见做法是把映射表存成 json 文件和模型权重放在同一个目录推理时一起加载。5. 踩坑复盘中文文本分类最常见的六个坑5.1 数据集解压乱码和编码问题现象、原因、解决现象用 pandas 读入 CSV 后打印出来全是乱码或者训练时 loss 不降准确率一直在 10% 左右徘徊。原因Windows 环境下保存的中文文本多为 GBK 或 GB18030 编码而 pandas 和 open 默认用 UTF-8 解码导致读到一堆错乱的字符模型等于在噪声上训练。解决读取文本时显式指定编码先尝试用utf-8解码抛异常时回退到gbk。def read_text(file_path): for enc in [utf-8, gbk, gb18030]: try: with open(file_path, r, encodingenc) as f: return f.read() except UnicodeDecodeError: continue raise ValueError(f无法解码文件: {file_path})5.2 jieba 分词把标点当词导致词表被垃圾占满现象词表建出来后数量超过预期而且高频词里大量是“”“。”“”等标点符号模型准确率明显偏低。原因jieba 的lcut默认保留标点和空格清洗阶段没有过滤掉。标点被当成词后占据了词表大量位置同时模型学到了“句号后一定是结束”这类无用规律。解决分词前用正则把标点替换成空格或者在分词后再次过滤长度为一且只包含标点的词。words [w for w in jieba.lcut(re.sub(r[。、\s], , text)) if w.strip()]注意不要把所有长度为一的词都过滤掉因为“好”“坏”“不”这些单字词在情感分类里信息量极大过滤掉它们等于自废武功。5.3 词表里没有留 推理时遇到生词直接 KeyError现象训练一切正常推理阶段输入一条新文本程序直接崩了报KeyError: xxxxx。原因编码函数里用了vocab[word]而不是vocab.get(word, vocab[UNK])。训练集里出现的词在推理时未必都有一旦遇到未登录词字典访问直接抛异常。解决把所有词典访问全部改成.get()写法同时保证UNK在词表里并且索引不为 0。这条我在 3.2 节代码里已经写了但很多人写进项目时会因为“图省事”丢掉它这里再强调一遍。5.4 模型训练速度慢到无法接受batch_size 和序列长度是元凶现象跑一个 epoch 要几十分钟显卡利用率却只有 10%。原因max_len设得过大比如 500而数据集的文本平均长度只有 30大量计算浪费在 padding 位上。LSTM 是按时间步展开的序列越长计算量呈线性增长padded 长度如果变成 500实际计算量是必要计算量的 10 倍以上。解决先统计文本长度分布把max_len压到覆盖 90% 样本的长度同时检查batch_size太小会导致 GPU 利用率上不去。另外一个隐蔽的优化是启用torch.backends.cudnn.benchmark True让 cuDNN 自动选择最优卷积算法。torch.backends.cudnn.benchmark True这个开关只影响卷积层的性能不影响数值结果加上它几乎没成本。5.5 类别不均衡导致准确率高但 F1 值极低现象准确率 95%但查看混淆矩阵发现占 90% 的类别全部正确剩下 10% 的类别几乎全部被预测成大类。原因数据集类别分布严重失衡模型只要把所有样本都预测成大类准确率就有 90%但小类的召回率是 0。解决训练时给交叉熵加weight参数权重设置按类别样本数反比。或者更简单一点评估时同时看 macro-F1而不仅是 accuracy。from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight(balanced, classesnp.unique(y_train), yy_train) class_weights torch.tensor(class_weights, dtypetorch.float).to(device) criterion nn.CrossEntropyLoss(weightclass_weights)compute_class_weight的balanced模式会自动计算每个类别的权重样本数少的类别权重高样本数多的类别权重低不需要手动算。加上这个参数后模型的训练目标从“尽量猜对大样本”变成“尽量均衡地猜对所有类”小类的 F1 值通常能提升 10 到 20 个百分点。5.6 模型保存与推理脱节少存了映射表等于白训现象模型训练完保存load 进推理脚本结果预测结果驴唇不对马嘴甚至维度对不上直接报错。原因训练脚本里model.state_dict()保存的是模型权重它依赖词表的顺序——词表第几个位置对应的 embedding 是哪一行是在训练时固定下来的。推理脚本如果重新构建词表顺序和训练时不同同一段文本查词表得到完全不同的索引序列预测自然全错。解决把词表、label_to_idx映射和模型权重一起保存。PyTorch 官方推荐用torch.save打包成一个字典torch.save({ model_state_dict: model.state_dict(), vocab: vocab, label_to_idx: label_to_idx, max_len: max_len, model_config: config, }, model/checkpoint.pt)推理时这样加载checkpoint torch.load(model/checkpoint.pt, map_locationcpu) model.load_state_dict(checkpoint[model_state_dict]) vocab checkpoint[vocab]我把这个文件命名为checkpoint.pt而不是model.pth因为里面不只是权重还有一堆配套元数据。这样推理脚本不依赖训练脚本里的任何全局变量换一台机器也能直接跑起来。这算是最有价值的一条踩坑记录完整项目上线时如果在这一步翻车所有训练时间都白费了。6. 用混淆矩阵和错误分析挖掘模型优化方向模型训练完毕验证集准确率看起来不错但离真正上线还差一步理解模型哪里对、哪里错以及有没有低成本优化的空间。直接看验证集准确率只是给自己一个定心丸真正决定这个项目能不能被信任的是混淆矩阵和人工错误分析。我习惯把最终保存的 checkpoint 加载回来在测试集上跑一遍生成混淆矩阵。from sklearn.metrics import confusion_matrix, classification_report preds [] labels_all [] model.eval() with torch.no_grad(): for batch_x, batch_y in test_loader: batch_x batch_x.to(device) logits model(batch_x) pred torch.argmax(logits, dim1).cpu().numpy() preds.extend(pred) labels_all.extend(batch_y.numpy()) print(classification_report(labels_all, preds, target_nameslist(label_to_idx.keys()))) cm confusion_matrix(labels_all, preds)classification_report会输出每个类别的 precision、recall、F1一眼就能看出哪些类别容易被混淆。比如“体育”和“娱乐”经常互相误判通常是因为这两个类别的文本里有大量人名和活动名词CNN 捕捉的组合特征高度重叠。cm是混淆矩阵行代表真实类别列代表预测类别cm[i][j]表示真实类别 i 被预测成 j 的次数适合定位高频误判对。拿到混淆矩阵后下一步是抽样检查被分错的样本。从测试集里挑出标签是“体育”但模型预测成“娱乐”的文本逐条读看是文本本身模棱两可还是模型漏掉了关键信息。一类常见情况是文本里同时出现了“篮球比赛”和“明星演唱会”两个实体但模型的注意力被“演唱会”吸引导致判错。这时可以考虑在数据预处理阶段加入领域词典把“篮球”“足球”“赛事”等词统一映射为“体育类实体”标签减少模型被无关词干扰的几率。如果错误样本里有大量被截断的长文本说明max_len设短了或者截断策略不对改成 3.1 节提到的头尾拼接法再试一次。还有一个很实用的验证技巧对比融合模型和单独 textCNN、单独 BiLSTM 在同一个测试集上的表现。def train_single(model_class, ...): # 复用训练主流程只更换模型类 return val_acc, test_f1用同一个数据管线和训练流程分别跑三个模型对比测试集上的 macro-F1。如果融合模型没有明显优势那就要反思是不是参数没有对齐——比如 textCNN 的num_filters设了 128BiLSTM 的hidden_size设了 128但融合模型的这两个分支只有 64导致融合模型容量不足反而打不过单模型。这个对比不只是在验证模型好坏也是在验证超参数是否公平。我在本地跑实验的时候经常发现融合模型比单独 BiLSTM 慢一倍但准确率只高 0.5 个百分点如果你的任务对推理速度敏感这可能不是一个划算的交易。记住一个习惯每做完一次实验把模型配置、准确率、F1、训练时间记录到一个文本文件里标上日期。这个项目我最初跑的时候只记了最终准确率隔了三天再回来调参已经记不清上一组参数是什么了所有的对比都失去了意义。从此之后我每跑一个实验都强迫自己写一行配置到日志里这个习惯比任何技巧都值钱。希望这份笔记能帮你在这条路上少踩几个坑也祝你跑通之后能在这个框架上做出自己的改进。本文还有配套的精品资源点击获取