python神经网络编程入门(二十七)——RNN IMBD搭建情感分类器与基础训练
引言菜都切好了开火烧菜前两章一直在备菜第 12 章把文字变成整数第 13 章把长短不一的影评装进(50000,500)(50000, 500)(50000,500)的统一模具。数据洗得干干净净词表、批次、掩码都备好了。可光有食材摆着成不了菜——得生火、下锅、翻炒。这一章就是把食材真正倒进锅里搭起一张最简单的情感分类器让模型把一条影评读一遍最后吐出一个 0 到 1 之间的打分越接近 1 越像好评。先证明这套代码能学会再在真正的数据上跑起来。本章目标拼出Embedding → GRU → Linear(1) → Sigmoid的完整网络用 200 条小样本验证代码正确过拟合 实现没写错在 8000 条影评上正式训练 25 轮看懂损失下降、准确率爬升以及后期的过拟合信号。一、三件套查字典、通读全文、拍板打分先看整个网络长什么样。一条影评从整数序列进来要经过三层Embedding查表(B,S,E)词ID→向量GRU沿时间读(B,S,H)最后一步 h_TLinear(1)打分Sigmoid0~1好感度一条影评 → 一个 0~1 的打分这三层各有各的活都能用生活里的动作对上号Embedding查字典整数 ID 只是词在词表里的编号编号本身没有含义。查表那一步把编号换成一段稠密向量——就像碰到生词去翻字典翻到的是这个词的意思。第 11 章讲过这一段向量是能学习的训练后语义相近的词会靠得近。GRU通读全文记重点第 9 章的主角。它一个词一个词地读手里捏着一份记忆每读一个词就更新一次记忆。读到最后一个词记忆里就浓缩了整篇影评的要点。Linear(1) Sigmoid拍板打分把最后那份记忆压缩成一个数字再 sigmoid 压到 0~1 之间当作好评概率。整条路用一行公式说清楚y^σ(W hTb)\hat{y} \sigma\big(W\, h_T b\big)y^​σ(WhT​b)其中hTh_ThT​是 GRU 读完最后一个词后的记忆W,bW, bW,b是最后一层线性变换的参数σ\sigmaσ是 Sigmoid。hTh_ThT​就是第 9 章里那个浓缩了全文的隐藏状态。二、骨架代码三层拼起来把上面这张图翻译成代码寥寥十几行importtorch.nnasnnclassSentimentGRU(nn.Module):def__init__(self,vocab5002,embed64,hidden128):super().__init__()self.embnn.Embedding(vocab,embed,padding_idx0)# 查字典self.grunn.GRU(embed,hidden,batch_firstTrue)# 通读self.fcnn.Linear(hidden,1)# 打分defforward(self,x):# x: (B, S) 整数矩阵eself.emb(x)# (B, S, E) 查表成向量_,hself.gru(e)# h: (1, B, H) 最后一步记忆returnself.fc(h[-1]).squeeze(-1)# (B,) 未压缩的打分有几个细节值得停下来看vocab5002词表大小。第 12 章留了 5000 个高频词加上PAD0、UNK1一共 5002 个。padding_idx0告诉 Embedding编号 0 是填充位。这样 PAD 会被查成全零向量等于什么都没读也就不会污染 GRU 的记忆——这是第 13 章掩码思想在读序列这里的落地。第 13 章掩码主要拦的是逐词预测的损失分类任务只取最后一步记忆打分PAD 用零向量挡住即可不用再单独算掩码。h[-1]GRU 返回的hhh形状是(1,B,H)(1, B, H)(1,B,H)第 0 维是层数这里只有 1 层h[-1]取的是最后一层、最后一个时间步的隐藏状态也就是通读完的记忆。GRU 内部到底怎么通读用一段最直白的循环讲比看张量拼起来更清楚htorch.zeros(hidden)# 记忆清零开始读forword_vecinreview_vecs:# 一个词一个词地读hgru_step(h,word_vec)# 读一个词更新一次记忆scoresigmoid(linear(h))# 读完用最后的记忆拍板gru_step就是第 9 章那一整套更新门、重置门、候选记忆的公式——代码里写成一行但心里要装着它是在逐词翻新记忆。三、先拿 200 条试刀小样本过拟合代码写完了怎么知道没写错先拿一小撮数据试。这是最划算的验错法挑 200 条影评让模型反复背。如果代码是对的200 条很快就能背下来——损失一路掉到接近 0这就是过拟合反而说明实现正确。反过来如果 200 条都学不动说明前向或反向有 bug再大的数据也白搭。训练用的损失函数是二分类交叉熵BCEL−1N∑i1N[ yilog⁡y^i(1−yi)log⁡(1−y^i) ]\mathcal{L} -\tfrac{1}{N}\sum_{i1}^{N}\Big[\,y_i\log\hat{y}_i (1-y_i)\log(1-\hat{y}_i)\,\Big]L−N1​i1∑N​[yi​logy^​i​(1−yi​)log(1−y^​i​)]模型还没学会、瞎猜时损失会停在−ln⁡12ln⁡2≈0.693-\ln\tfrac12 \ln 2 \approx 0.693−ln21​ln2≈0.693——这是随机猜测的天然底线后面对比有没有进步就看它。torch.manual_seed(42);np.random.seed(42)idxnp.random.choice(25000,200,replaceFalse)# 随机抽 200 条modelSentimentGRU(vocab5002,embed32,hidden32)opttorch.optim.Adam(model.parameters(),lr1e-2)lossfnn.BCEWithLogitsLoss()forepinrange(20):forstinrange(0,200,32):x,ypack_batch(idx[st:st32])# 取一批填充掩码lolossf(model(x),y)opt.zero_grad();lo.backward();opt.step()跑 20 轮损失曲线长这样前几轮的真实数字训练轮0246810损失0.69960.51210.24260.16660.13590.0830从0.6996一路掉到0.0830——200 条班子基本被背下来了。这证明前向、反向、损失、更新这一整套链路是通的。可以放心上大菜了。四、全量开火洗牌、切分、训练小样本只是验刀接下来在真正的数据上训练。这里藏着一个极其容易踩的坑这份数据是按标签排好序的——前一半是好评标签 1、后一半是差评标签 0。如果直接拿前 8000 条训练、紧挨着的 2000 条当验证验证集里就会全是同一类标签测出来的准确率毫无意义要么虚高、要么虚低。所以第一步必须打乱顺序再随机切分all_idxnp.arange(25000)np.random.shuffle(all_idx)# 先洗牌把好评差评打散train_idxall_idx[:8000]# 训练8000 条val_idxall_idx[8000:10000]# 验证2000 条然后挂上 Adam 优化器用 1e-3 的学习率正式训练 25 轮。每轮结束后在验证集上测一次准确率。真实运行输出挑几轮展示epoch 0: train_loss ≈ 0.6944 | val_acc ≈ 0.5020 epoch 5: train_loss ≈ 0.5955 | val_acc ≈ 0.5200 epoch 10: train_loss ≈ 0.3958 | val_acc ≈ 0.5990 epoch 15: train_loss ≈ 0.1851 | val_acc ≈ 0.6855 epoch 20: train_loss ≈ 0.0931 | val_acc ≈ 0.7350 epoch 24: train_loss ≈ 0.0542 | val_acc ≈ 0.7305把起止的关键数字收在一张表里一眼看清幅度指标第 0 轮第 24 轮变化训练损失0.69440.0542↓ 0.640验证准确率50.2%73.05%↑ 22.9%训练损失从0.6944降到0.0542稳稳离开了0.693的随机基线模型确实在读懂好评差评验证准确率从50.2%一路爬到峰值73.85%第 21 轮到第 24 轮微回落到73.05%。练完 25 轮再拉那两条真实影评看打分一开头的模型和现在的模型判若两人idx13 label1 score0.991 | i enjoyed the night ... one of the better movies of the summer idx12529 label0 score0.003 | i had some ... for the movie since it had a nice star cast ...含great的好评拿到0.991含terrible的差评只有0.003——这次不仅对而且非常自信。不过曲线里藏着一个要留意的信号第 21 轮之后训练损失还在往下掉0.085 → 0.054验证准确率却不涨反微降73.85% → 73.05%。这就是标准的过拟合模型开始把训练集一字不差地背下来对没见过的数据却帮不上忙。训练损失越低并不代表越好——这正是第 16 章引入 Dropout 等正则化手段的理由。五、常见坑与自查不洗牌直接切分数据前半全是好评、后半全是差评懒得洗牌会让验证集变成单一种类准确率失真。先shuffle再切分这一步不能省。忘记padding_idx0不告诉 Embedding 谁是填充位PAD 也会被当成普通词参与计算GRU 的空气也读了记忆被污染。取错隐藏状态GRU返回的第二个值是(1,B,H)(1, B, H)(1,B,H)不取h[-1]而直接拿去喂线性层维度对不上会报错或取到非最后一步的状态。评估时忘了切eval()模式训练循环里顺手加的 Dropout 在评估时也必须关掉这里没有 Dropout但养成model.eval()的习惯第 16 章会用上。用nn.BCELoss而不是BCEWithLogitsLoss前者要先把 logit 过 Sigmoid数值上更易不稳定后者把 Sigmoid 融进损失里更稳妥代码里用的就是它。小结与预告这一章把前面备好的数据真正喂进了网络走通了读一遍 → 打个分的完整链路三层骨架Embedding查字典→GRU通读记忆→Linear(1)Sigmoid打分一条影评变成一个 0~1 的打分小样本验刀200 条上损失0.70 → 0.08证明代码没写错洗牌教训数据按标签排序必须先shuffle再切分否则验证集失真全量 25 轮训练损失0.694 → 0.054验证准确率50% → 73.85%第 21 轮峰值此后损失继续降、验证走平真实验到了过拟合。本章的核心数据一张小看板收尾小样本 200 条0.70 → 0.08损失背下全部训练损失0.694 → 0.05425 轮下降 0.640验证准确率50% → 73.85%第 21 轮峰值后走平随机基线0.693 / 50%没学会的分界线路已经通了接下来就是怎么让模型变聪明——第 15 章把 RNN、GRU、LSTM 三大模型拉到同一张桌子上比个高下看谁收敛最快、谁最终精度最高。下一篇二十八RNN vs LSTM vs GRU 三模型横向对比实验