
简介这是一份面向计算机专业毕业设计的聊天机器人情绪检测项目包覆盖后端Seq2seq/LSTM/Attention机制、TensorFlow2.0Keras实现以及前端HTML、Vue、Ajax网页交互能在对话过程中调用抑郁分类模型初步判断用户情绪状况。压缩包共45个文件大小约66.81MB主要包含Python训练/推理脚本、Jupyter Notebook分步演示、h5/pkl格式的预训练模型与词表数据、HTML页面、配置文件及说明文档目录按模型、数据、前端和工具模块组织便于按需查阅和复用。除了基础聊天功能还提供带Attention与不带Attention的推理对比、文本预处理、词向量训练、抑郁检测模型等子模块并附带训练好的LSTM权重和词表映射表可直接加载运行或在此基础上二次开发。目前已有191人学习/浏览适合智能对话与情感分析相关课题的本科生参考架构、复用代码或作为实验对比与项目演示基础。1. Seq2seqLSTMAttention 的聊天机器人情绪检测这个毕设项目到底在做什么今天又被老板骂了。把这行字敲进终端聊天机器人回一句别太往心里去你已经做得很好了屏幕同时打出情绪检测结果sad置信度 0.83。基于 Seq2seq 框架 LSTM Attention 的聊天机器人情绪检测系统做的就两件事一是用 Seq2seq 生成自然回复二是对用户输入做情绪检测判断聊天对象的情绪状况。它不是 QQ 机器人那种接平台 API 的外壳工程而是把文本生成与文本分类两个核心任务放进一条深度学习链路里打通。适合毕业设计想完整走一遍数据、训练、推理、评估链路的人。下面按选型逻辑、数据、代码、调试到答辩验证的顺序展开代码用 PyTorch照着敲就能跑。2. 为什么是 Seq2seqLSTMAttention从对话生成到情绪检测的选型逻辑2.1 Seq2seq 为什么是生成式聊天机器人的地基Seq2seq 严格来说不是一个模型而是一种序列到序列的框架输入一个 token 序列输出另一个 token 序列。聊天机器人的交互过程正好落在这个框架里——用户消息是输入序列机器人回复是输出序列所以基于 LSTM 的生成式聊天机器人十份里有九份的骨架都是编码器-解码器。编码器把用户消息逐词读入压缩成一组隐状态解码器从起始符sos开始逐词生成直到输出结束符eos。这种先读完再写的结构既贴近人类组织语言的直觉也方便做情绪检测因为编码器读到的内容就是后续分类特征的来源。这里有一个值得在毕设报告里写清楚的设计权衡最原始的 seq2seq 只把编码器的最后一个隐状态传给解码器当作整句话的摘要。消息一长这个向量就成了信息瓶颈后半句的内容很容易被前半句冲淡。Attention 的引入就是为了消除这个瓶颈它让解码器在生成每一个词的时候都能按权重回看编码器全部位置的隐状态。换句话说Attention 不改变 seq2seq 的骨架而是把编码器到解码器的信息通道从一条窄路换成了一张可检索的表。答辩时老师如果问你和普通 LSTM 的本质区别是什么顺着这个逻辑答就能站住。很多人第一次接触 seq2seq 是从笔记博客看起的检索时也常和lstm模型代码attention 实现这些关键词一起出现。这类笔记帮你建立概念没问题但真正落地时会遇到数据预处理、mask、teacher forcing 这些笔记里一笔带过的东西后面第 3 章逐个解决。选型阶段只需要记住一个结论生成式闲聊机器人seq2seq 框架是性价比最高的起点没有之一。2.2 LSTM 在文本序列里的职责embedding、hidden 与层数怎么定LSTM 在这里是编码器和解码器共同的骨干网络。它的门控机制——遗忘门、输入门、输出门——让信息可以选择性地跨时间步流动这是它比普通 RNN 更适合文本的原因用户消息里我和很难过中间可能隔着十几个词LSTM 仍能在解码阶段用上很难过的信息。很多人最先接触 LSTM 是在时间序列预测场景看过不少 lstm 时间序列预测 python 的教程然后以为可以直接把这套思路搬到文本上。方向没问题但有两个关键差异必须改第一文本要先分词并映射成离散 id不能像时间序列那样直接喂连续数值第二输出目标不是回归值而是下一个词在整个词表上的概率分布也就是 softmax 分类。参数上我给一个足够毕设起步的配置embedding 维度与 hidden 维度按 1:2 来比如 embedding 128、hidden 256层数 1 层起步最多 2 层dropout 0.3。这个配置在 10 万到 20 万对的中文闲聊语料上单张消费级显卡就能跑出能看的回复。hidden 维度不建议一上来就 512 以上参数量上去了语料不够时模型会变成复读机第 5 章会专门讲这个现象。如果想让编码器更强可以改成双向 LSTM前向读我很难过后向读难过我信息更全但双向会让最后一个隐状态维度翻倍解码器要做对应拼接处理容易出错毕设求稳可以先不追求双向。2.3 Attention 进场Bahdanau 与 Luong 怎么选上下文向量怎么算Attention 的核心操作一句话就能说清对编码器的所有隐状态做加权求和得到当前时刻的上下文向量 context。关键在于权重怎么算。解码器在第 t 步先有一个隐状态 h_t把它当作 query编码器每个位置 i 的隐状态 h_i 当作 key 和 value。两种主流打分方式Bahdanau 加性注意力用一个小网络计算 score v^T tanh(W·h_t U·h_i)表达能力更强Luong 乘性注意力直接算点积或双线性 h_t^T·W·h_i计算更快。对比项Bahdanau 加性注意力Luong 乘性注意力打分方式单隐层 MLP 映射成标量点积或双线性计算量较大多一个 MLP较小可视化权重分布直观适合答辩展示同样可画热力图适用场景中小语料、毕设起步追求速度、大规模毕设我推荐 Bahdanau理由不是它一定更好而是更容易解释。加性注意力算出的 score 经 softmax 后就是一个源序列长度上的概率分布画成热力图时你能清楚看到生成这个词时模型在看用户消息的哪几个词这个图放进答辩 PPT 比任何文字描述都有说服力。实现层面社区里常说的 a generic attention module for a decoder in seq2seq pytorch 其实就是把这段逻辑封装成一个独立的 nn.Module输入 decoder 的隐状态、encoder 的全部输出和 mask输出 context 和权重。独立成模块的好处是后面换 Luong 或换多头注意力只动模块内部不用改编码器和解码器主体。数值上有一个容易被忽略的点score 在送入 softmax 之前padding 位置必须被 mask 掉。做法是把 padding 位置的 score 替换成 -1e9 量级的极小值softmax 之后这些位置权重趋近于 0。不做这一步模型会莫名其妙地关注一堆 pad 符号生成质量明显下降情绪检测的均值池化也会被污染。这个坑第 5 章会再展开。2.4 情绪检测模块接在哪一层共享编码器还是独立编码器情绪检测本质是文本分类输入用户消息输出情绪标签。既然聊天机器人已经有一个编码器在读取用户消息最省的做法是让情绪分类和回复生成共享这一个编码器。用户消息只过一次编码器词向量学习同时服务两个任务参数不翻倍训练时间也可控。这是典型的多任务学习结构工程上叫共享底部、双头输出底部是 embedding 加 LSTM 编码器左侧头是带 Attention 的解码器负责生成右侧头是 mean pooling 加 MLP 负责分类。但共享不是免费的。生成任务希望编码器保留词序和细节分类任务希望编码器提炼全局语义两者对特征的要求有冲突。缓解办法有三个一是分类头用 mean pooling 而不是最后一个隐状态让分类特征来自整个句子的平均语义而不是被末尾词主导二是两个 loss 加权合并时给分类 loss 一个可调权重通常取 0.5 到 1.0三是如果训练后发现共享编码器互相拖累、生成质量明显下降就退回独立编码器代价是参数量和训练时间翻倍对毕设来说一般没必要。第 4 章会给完整代码和训练策略。3. 从中文语料到训练落地数据预处理与核心 lstm 模型代码3.1 语料选型闲聊对话数据与情绪标注从哪来聊天机器人部分需要的是用户消息-机器人回复配对的语料。常见做法是直接用公开的中文闲聊语料比如小黄鸡语料、青云语料、豆瓣多轮对话语料这类数据量大但噪声也不小很多回复是表情、感叹词甚至广告。情绪检测部分需要的是句子-情绪标签语料常见来源是中文情感或情绪分类数据集标签体系有的是三分类正向、负向、中性有的是六分类happy、sad、angry、fear、surprise、neutral。一个实际的难点是同时带对话配对和情绪标注两份信息的语料很少。我一般的做法是两套语料并行训练生成任务从闲聊语料里取消息与回复的配对情绪任务从情绪语料里取句子与标签的配对两个任务共享同一个编码器各自算 loss 再相加。数据量上毕设不用追求百万级10 万对闲聊语料加 1 万条左右情绪语料就足够把链路跑通。要注意情绪语料的类别分布neutral 常常占七成以上这种不平衡会影响 sad、angry 这些少数类的召回等第 5 章讲类别权重时会对症处理。3.2 预处理三步jieba 分词、词表构建、padding 与 mask中文文本不能像英文那样按空格切词第一步先分词。jieba 是上手最快的中文分词库虽然比不上预训练模型的分词器但对 LSTM 这个量级的毕设项目完全够用。词表构建时把出现频次低于 min_freq 的词丢弃统一映射到unk这样能压住词表规模减少生僻词对 embedding 的干扰。import jieba from collections import Counter PAD, SOS, EOS, UNK 0, 1, 2, 3 def tokenize(text): return jieba.lcut(text) def build_vocab(all_texts, min_freq2, max_vocab30000): word_count Counter() for text in all_texts: word_count.update(tokenize(text)) words sorted( [w for w, c in word_count.items() if c min_freq], keylambda w: word_count[w], reverseTrue )[:max_vocab] vocab [pad, sos, eos, unk] words word2idx {w: i for i, w in enumerate(vocab)} return word2idx, vocab这里把pad固定为索引 0后面所有 mask 和 embedding 的padding_idx都依赖这个约定。min_freq建议设 2 到 3太小词表会膨胀太大低频词全变unk导致回复内容贫乏max_vocab设 30000 对中文闲聊语料是合理的上限词表再大训练速度和显存都会吃紧。词表建好之后每条句子要编码成 id 序列并保证长度一致才能拼 batch。长度这一步我习惯按句子真实长度截断到 max_len 再补eos而不是先 padding 再截断顺序反了会让截断后的句子结尾漏掉结束符。def encode(text, word2idx, max_len40): tokens tokenize(text)[:max_len - 2] ids [SOS] [word2idx.get(w, UNK) for w in tokens] ids ids[:max_len - 1] [EOS] return ids def collate_fn(batch): src_ids, tgt_ids zip(*batch) src_len [len(s) for s in src_ids] tgt_len [len(t) for t in tgt_ids] max_src, max_tgt max(src_len), max(tgt_len) src_padded torch.zeros(len(batch), max_src, dtypetorch.long) tgt_padded torch.zeros(len(batch), max_tgt, dtypetorch.long) for i, (s, t) in enumerate(zip(src_ids, tgt_ids)): src_padded[i, :len(s)] torch.tensor(s) tgt_padded[i, :len(t)] torch.tensor(t) return src_padded, torch.tensor(src_len), tgt_padded, torch.tensor(tgt_len)collate_fn里生成src_len是给pack_padded_sequence用的这个函数会跳过 padding 位置的无效计算省显存也加速。mask src_padded ! PAD在模型 forward 里实时算就可以不用显式存一份。记住 padding 索引必须是 0 且和word2idx约定一致后面 Attention 和情绪分类的 mask 都靠它。3.3 编码器、Attention、解码器三段核心模型代码模型部分拆成三个模块写职责清晰调试时能单独验证每一段。编码器是单层 LSTM输入用户消息输出每个位置的隐状态和最后一个时间步的隐状态、细胞状态。class EncoderLSTM(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim256, num_layers1, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, num_layers, batch_firstTrue, dropoutdropout) self.dropout nn.Dropout(dropout) def forward(self, src, src_len): embedded self.dropout(self.embedding(src)) # [B, T, D] packed nn.utils.rnn.pack_padded_sequence( embedded, src_len.cpu(), batch_firstTrue, enforce_sortedFalse) outputs, (hidden, cell) self.lstm(packed) outputs, _ nn.utils.rnn.pad_packed_sequence( outputs, batch_firstTrue) # [B, T, H] return outputs, hidden, cellpack_padded_sequence有个隐蔽要求src_len必须放在 CPU 上传 CUDA tensor 会直接报错所以代码里写了src_len.cpu()。enforce_sortedFalse允许 batch 里的句子不按长度排序省去每次 DataLoader 重新排序的麻烦。编码器输出outputs的 shape 是[B, T, H]这是 Attention 要用的可检索表。Attention 模块是这一段里最值得反复看的部分。class BahdanauAttention(nn.Module): def __init__(self, hidden_dim): super().__init__() self.Wa nn.Linear(hidden_dim, hidden_dim) self.Ua nn.Linear(hidden_dim, hidden_dim) self.va nn.Linear(hidden_dim, 1) def forward(self, decoder_hidden, encoder_outputs, mask): # decoder_hidden: [B, H]取解码器最后一层的隐状态 # encoder_outputs: [B, T, H] score self.va(torch.tanh( self.Wa(encoder_outputs) self.Ua(decoder_hidden).unsqueeze(1) )) # [B, T, 1] score score.squeeze(-1) # [B, T] score score.masked_fill(mask 0, -1e9) weights torch.softmax(score, dim-1) # [B, T] context torch.bmm( weights.unsqueeze(1), encoder_outputs ).squeeze(1) # [B, H] return context, weights这段代码的要点是masked_fill必须用mask 0而不是mask 1方向反了会把有效位置全部屏蔽。-1e9要足够小softmax 之后对应的权重才会趋近于 0写成-1e3在某些情况下残留权重仍然明显。weights.unsqueeze(1)把权重变成[B, 1, T]与[B, T, H]的 encoder_outputs 做 batch 矩阵乘法得到[B, 1, H]再压成[B, H]。解码器把 Attention 的 context 拼到当前词的 embedding 上一起喂进 LSTM再把 LSTM 输出与 context 拼接后映射到词表。class DecoderLSTM(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim256, num_layers1, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim hidden_dim, hidden_dim, num_layers, batch_firstTrue, dropoutdropout) self.attention BahdanauAttention(hidden_dim) self.fc_out nn.Linear(hidden_dim * 2, vocab_size) self.dropout nn.Dropout(dropout) def forward(self, token, prev_hidden, prev_cell, encoder_outputs, mask): embedded self.dropout(self.embedding(token)) # [B, 1, D] context, weights self.attention( prev_hidden[-1], encoder_outputs, mask) lstm_input torch.cat( [embedded, context.unsqueeze(1)], dim-1) # [B, 1, DH] output, (hidden, cell) self.lstm(lstm_input, (prev_hidden, prev_cell)) logits self.fc_out(torch.cat([output.squeeze(1), context], dim-1)) # [B, V] return logits, hidden, cell, weightsprev_hidden[-1]取的是解码器最后一层 LSTM 的隐状态跨度是[num_layers, B, H]取最后一层才是[B, H]这个索引写错会在维度上静默报错调试时要留意。lstm的输入维度是embed_dim hidden_dim因为 context 拼进了每个时间步的输入这是 Attention 版 seq2seq 与朴素版最直观的代码差异。最后用一个 Seq2Seq 包装类把编码器和解码器串起来同时实现 teacher forcing。class Seq2Seq(nn.Module): def __init__(self, encoder, decoder): super().__init__() self.encoder encoder self.decoder decoder def forward(self, src, src_len, tgt, teacher_forcing_ratio0.5): batch_size, tgt_len tgt.shape vocab_size self.decoder.fc_out.out_features mask src ! 0 encoder_outputs, hidden, cell self.encoder(src, src_len) outputs torch.zeros(batch_size, tgt_len - 1, vocab_size, devicesrc.device) input_token tgt[:, 0].unsqueeze(1) # sos for t in range(tgt_len - 1): logits, hidden, cell, _ self.decoder( input_token, hidden, cell, encoder_outputs, mask) outputs[:, t] logits teacher tgt[:, t 1].unsqueeze(1) sampled logits.argmax(dim-1) input_token teacher if random.random() teacher_forcing_ratio \ else sampled return outputsteacher_forcing_ratio0.5的意思是每个解码步有一半概率用真实目标词作为下一步输入另一半概率用模型自己的预测。这个比例是训练稳定性与推理一致性之间的折中训练初期建议 1.0 让模型先学会模仿往后逐步衰减到 0.3 到 0.5完全用真实词训练会导致推理时错误累积也就是第 5 章要讲的复读机问题。3.4 训练循环与超参数teacher forcing、梯度裁剪与学习率训练循环本身不复杂真正决定成败的是几个超参数和两个习惯梯度裁剪必须加验证集必须留。聊天机器人这类自回归模型梯度爆炸是常态不裁剪的话 loss 会突然变成 NaN。optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss(ignore_index0) # 忽略 pad for epoch in range(epochs): model.train() for batch in train_loader: src, src_len, tgt, tgt_len batch logits model(src, src_len, tgt, teacher_forcing_ratio0.5) loss criterion(logits.reshape(-1, vocab_size), tgt[:, 1:].reshape(-1)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step()ignore_index0让 pad 位置不参与损失计算否则模型会被训练成不断输出 pad。tgt[:, 1:]对齐解码器输出因为解码器从sos开始预测目标序列要去掉开头的sos。学习率 1e-3 是 Adam 在 LSTM 文本任务上的常见起点如果 loss 震荡不降先降到 5e-4不要一上来就用学习率调度器反而掩盖了真实问题。超参数建议值说明embedding 维度128中文小词表够用hidden 维度256单层 LSTMbatch size64按显存下调到 32学习率1e-3 起不收敛就降到 5e-4teacher forcing1.0 衰减到 0.5防止推理误差累积梯度裁剪1.0防梯度爆炸最大句长40截断超长句控制显存训练到第 3 到 5 个 epoch 时loss 通常会明显下降生成出来的句子开始有语义。如果这个时候验证集 loss 回升而训练 loss 还在降就是过拟合信号第 5 章会讲 early stopping 和 dropout 的调整手法。这一章先保证代码能完整跑通一遍情绪检测头的接入放到下一章。4. 情绪检测接入方案标签体系、共享情绪头与联合训练4.1 情绪标签怎么定三分类还是六分类情绪检测面向判断用户聊天时的情绪状况这个目标标签粒度不能太粗。三分类正向、负向、中性标注简单、任务容易但在答辩演示时没有区分度sad和angry都属于负向用户明明生气了系统却只报一个negative说服力不够。六分类happy、sad、angry、fear、surprise、neutral是心理学基础情绪划分的常见版本公开语料相对好找混淆矩阵画出来信息量也大。方案标签优点缺点三分类positive / neutral / negative标注成本低类间好分演示粒度粗缺乏解释力六分类happy / sad / angry / fear / surprise / neutral细粒度答辩展示效果好类别不平衡更严重我一般直接选六分类。代价是要处理类别不平衡尤其是 neutral 占比常超过一半。解决办法是给损失函数传class_weight让少数类比如 fear、surprise的样本在 loss 中占更高权重这个参数在 PyTorch 的CrossEntropyLoss里原生支持。如果你找到的语料是四分类或七分类也不需要强改保持语料原始标签训练报告里说明标签体系来源即可。4.2 共享编码器的情绪头mean pooling 与 MLP 分类器实现情绪头接在编码器输出之上输入是encoder_outputs和 mask输出是六个类别的 logits。为了不让句子末尾词主导分类结果用 mean pooling 把所有非 padding 位置的隐状态取平均。class EmotionClassifier(nn.Module): def __init__(self, hidden_dim, num_classes6, dropout0.3): super().__init__() self.fc1 nn.Linear(hidden_dim, 128) self.fc2 nn.Linear(128, num_classes) self.dropout nn.Dropout(dropout) def forward(self, encoder_outputs, mask): # encoder_outputs: [B, T, H]mask: [B, T] lengths mask.sum(dim1, keepdimTrue).clamp(min1) pooled (encoder_outputs * mask.unsqueeze(-1)).sum(dim1) / lengths h torch.relu(self.fc1(pooled)) return self.fc2(self.dropout(h))mask.sum(dim1)算出每个句子真实长度clamp(min1)防止全 padding 的极端样本除零。encoder_outputs * mask.unsqueeze(-1)把 padding 位置的隐状态清零然后再求和这样 pad 不会污染均值。用 mean pooling 而不是最后一个隐状态是因为分类任务更看重全句语义而末尾词经常是的了这类无情绪词。4.3 联合训练生成 loss 与情绪 loss 怎么加权联合训练时每个 batch 同时喂对话数据和情绪数据。对话数据走 Seq2Seq 的生成损失情绪数据走情绪头的分类损失两者相加后统一回传。为了让情绪头看到编码器输出需要让 Seq2Seq 的 forward 把encoder_outputs也返回或者在外部单独调用一次model.encoder后者更灵活情绪 batch 和对话 batch 可以任意混排。criterion_gen nn.CrossEntropyLoss(ignore_index0) criterion_emo nn.CrossEntropyLoss(weightclass_weight) lambda_emo 0.8 for batch in loader: if batch[type] chat: src, src_len, tgt, tgt_len batch[data] logits model(src, src_len, tgt, teacher_forcing_ratio0.5) loss criterion_gen(logits.reshape(-1, vocab_size), tgt[:, 1:].reshape(-1)) else: src, src_len, emo_label batch[data] encoder_outputs, _, _ model.encoder(src, src_len) mask src ! 0 emo_logits emotion_head(encoder_outputs, mask) loss lambda_emo * criterion_emo(emo_logits, emo_label) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_( list(model.parameters()) list(emotion_head.parameters()), 1.0) optimizer.step()lambda_emo的取值决定了两个任务的博弈。取 1.0 时情绪 loss 和生成 loss 平权生成质量可能下降取太小情绪检测学不出来。我一般从 0.8 起步观察生成 loss 不回升就让lambda_emo保持一旦发现回复质量明显变差降到 0.5。注意梯度裁剪要覆盖model和emotion_head的全部参数漏掉情绪头会导致它单独梯度爆炸。4.4 评估口径准确率、F1、混淆矩阵之外还要看什么情绪检测的评估用sklearn的分类报告就够重点看 macro F1 而不是只看准确率。原因很简单neutral 占比高时模型全预测 neutral 也能有 60% 以上的准确率但 sad、angry 几乎全错这对于监测用户情绪这个目标毫无意义。打印混淆矩阵逐类看 recallangry 的 recall 尤其重要漏掉愤怒比误报悲伤后果严重得多。指标作用关注点准确率整体正确率会被中性类拉高不能单独用macro F1各类 F1 平均少数类表现差时明显下滑各类 recall单类召回angry、sad 优先看混淆矩阵类间混淆检查 sad/angry/fear 是否互相串还要注意评估数据与训练数据同分布。情绪语料和闲聊语料来自不同来源如果拿闲聊语料里的句子去测情绪模型分词风格和用词习惯不匹配分数会虚低。正确做法是单独留出 1000 条左右的情绪语料做测试集不参与训练。聊天机器人本身的自动评估是另一个话题BLEU 对闲聊生成基本没有区分度我习惯用固定的 20 条黄金测试问句人工打分这个第 6 章会详细说。5. 踩坑记录不收敛、重复回复、情绪误判的五条排查路径这一章的每一条都是我在类似项目里实际踩过或者帮别人排查过的按现象、原因、解决三段式记录排查时可以直接对照。5.1 loss 变成 NaN 或一直不降梯度爆炸与学习率玄学现象训练几十步后 loss 变成 nan或者 loss 卡在一个值附近完全不动生成出来的全是unk。原因LSTM 自回归模型梯度爆炸是头号嫌疑人尤其当序列长度超过 20 且没有梯度裁剪时其次是学习率 1e-3 对某些 batch 来说太大参数更新一步就越过了合理区域还有一个隐蔽原因是 embedding 层没有写padding_idx0导致 pad 位置也在更新词向量反向传播把噪声放大。解决先给所有参数加clip_grad_norm_(1.0)这是保命操作再把学习率降到 5e-4 重跑。如果 loss 还是 nan检查 embedding 构造函数是否写了padding_idx0以及src_len是否传入了pack_padded_sequence后没转 CPU。学习率这块我基本靠经验调先 1e-3 试十个 epoch不降就减半这是最土但最稳的办法。5.2 解码器复读机exposure bias 与采样温度现象训练完输入你好模型回复哈哈哈或者不知道不知道不知道。原因训练时一直用 teacher forcing解码器每一步都拿到真实目标词它从没见过自己的错误输出推理时第一步错后面全错这是典型的 exposure bias。另一个原因是语料里高频的无意义回复太多模型学到走捷径。如果用的是贪婪解码每次取 argmax问题会更明显argmax 会让模型锁死在概率最高的几个词上。解决推理时不用 argmax改用带温度的采样温度越高分布越平滑低温度则接近 argmax。我常用的配置是 temperature0.7 配合 top-k20先缩放到 top-k 内再重新归一化采样代码很短def sampled_decode_step(logits, temperature0.7, top_k20): logits logits / temperature top_k_logits, top_k_idx logits.topk(top_k) probs torch.softmax(top_k_logits, dim-1) token top_k_idx.gather(-1, torch.multinomial(probs, 1)) return token这段代码在解码循环里替换掉logits.argmax即可。temperature 调参的规律是回复平淡就降到 0.5 到 0.6 让分布更尖锐回复太跳脱就回到 0.8 以上。复读机问题如果依然存在下一步加 repetition penalty对已经生成过的词在 logits 上减一个固定值。5.3 Attention 热力图翻车padding 位置的 mask 没传对现象画 attention 热力图时高权重集中在句尾一堆 pad 上或者是情绪检测的准确率怎么调都上不去。原因mask 方向写反是最常见的。很多人写score.masked_fill(mask 1, -1e9)结果把有效词全屏蔽了模型只能去关注 pad。另一个原因是mask和encoder_outputs不在同一个设备上masked_fill直接报 device mismatch还有一个是pack_padded_sequence的src_len是 CUDA tensor报错信息不明显。解决统一设定mask src ! 0attention 里固定写masked_fill(mask 0, -1e9)把这两行复制到所有用到 mask 的地方不要重写。设备问题在构造 batch 时就处理src_len在进pack_padded_sequence前用.cpu()。检查方法很简单打印weights.sum(dim-1)是否都约等于 1再打印每个位置的权重分布pad 位置权重应当接近 0。5.4 情绪检测在短句上误判长度归一化与类别权重现象嗯嗯好的这种短回复被识别成 sad而哈哈却判成 angry。原因短句 token 太少mean pooling 的结果被一两个词主导词向量里好和嗯的语义表达不稳定另一个原因是训练语料里短句数量少、标注质量差模型对短句的特征空间没有学到足够的判别边界。还有一个技术性问题如果一个句子在 batch 里整条都是 paddingsum / len会出现除零或得到无意义的向量。解决pooling 时对lengths.clamp(min1)做长度归一化单独收集一批短回复嗯嗯好的哈哈我去做人工补充标注把这些样本放进情绪训练集类别层面给少数类加class_weight让模型不把一切都推向 neutral。做完这三个动作短句误判通常能明显改善。如果还想再进一步可以对短句做数据增强比如把哈哈扩展成哈哈哈哈哈再标注一次。5.5 显存不够与过早过拟合梯度累积、early stopping 与 dropout现象batch 设 128 直接 CUDA out of memory或者训练集 loss 降到 2.0 但验证集 loss 一直往上涨。原因LSTM 的显存峰值由 batch 大小乘最大句长决定pack_padded_sequence只能省 padding 位置的算力省不了 embedding 和输出层那个[B, T, V]的大矩阵过拟合则是因为语料规模小但模型参数多尤其 hidden 维度设到 512 以上时。解决显存不够就把 batch 降到 32 或 64然后用梯度累积模拟大 batch每 4 个 step 做一次参数更新过拟合优先看验证 loss设一个 patience5 的 early stopping验证 loss 连续 5 个 epoch 不降就停dropout 提到 0.5hidden 降回 256。这里要提醒一下验证 loss 必须在验证集上算而且验证集和训练集来源一致拿另外一套语料当验证集会得到误导性的曲线。6. 答辩前最后一步交互测试、Attention 可视化与三个加分改进6.1 一条命令的交互测试入口训练完的模型需要一个能当场演示的入口不要每次都在训练脚本里改代码。写一个独立的chat.py加载模型和词表进入 while 循环读终端输入同时输出回复和情绪标签。def chat(): model.eval() while True: text input(你 ) if text in {quit, exit}: break ids encode(text, word2idx) src torch.tensor([ids]).to(device) src_len torch.tensor([len(ids)]) with torch.no_grad(): encoder_outputs, hidden, cell model.encoder(src, src_len) mask src ! 0 reply_ids, attn_weights greedy_decode( model.decoder, hidden, cell, encoder_outputs, mask) emo_logits emotion_head(encoder_outputs, mask) reply .join(vocab[i] for i in reply_ids).replace(sos, ).replace(eos, ) emo_idx emo_logits.argmax(dim-1).item() conf torch.softmax(emo_logits, dim-1).max().item() print(f机器人 {reply}) print(f情绪 {emotion_labels[emo_idx]} 置信度 {conf:.2f})演示时把每轮输入输出追加到 CSV 文件答辩时直接展示日志比现场打字更有说服力。greedy_decode可以替换成带 temperature 的采样版逻辑就是沿预测结果循环喂回解码器直到输出eos或达到最大长度。6.2 Attention 可视图让老师一眼看懂模型在看哪里解码的每个时间步都会返回weights把[tgt_len, src_len]的权重矩阵画成热力图横轴是源句子的词纵轴是回复的每个词颜色越亮表示关注越强。一张用户输入的难过与回复安慰对齐的图比任何文字说明都直观。import matplotlib.pyplot as plt def plot_attention(weights, src_tokens, tgt_tokens, pathattn.png): fig, ax plt.subplots(figsize(8, 6)) im ax.imshow(weights, cmapBlues) ax.set_xticks(range(len(src_tokens))) ax.set_xticklabels(src_tokens, rotation45) ax.set_yticks(range(len(tgt_tokens))) ax.set_yticklabels(tgt_tokens) plt.colorbar(im) plt.tight_layout() plt.savefig(path) plt.close()画图前要过滤掉 padding 位置的权重否则图上右侧会有一列亮的竖条显得模型在关注空气。挑两三个典型例子放进答辩 PPT一个正常问答、一个带有明显情绪的句子、一个错误案例错误案例反而能展示你对模型边界的理解。6.3 三个低成本加分改进到这里基础的 Seq2seqLSTMAttention 加情绪检测已经完整跑通。如果想在论文里多写一章内容三个改进方向按性价比排序第一把贪婪解码换成 beam searchbeam 宽度取 3 到 5加长度归一化惩罚回复质量会有肉眼可见的提升代码改动量不大第二做情绪条件生成把情绪标签的 embedding 拼进解码器的每一步输入让机器人生成回复时知道用户当前的情绪这样情绪检测和聊天机器人就不再是两个独立模块而是真正产生了交互第三把编码器换成小型预训练模型的蒸馏版保持 LSTM 解码器不变这样论文里能写上预训练语义特征与序列生成的结合前提是你能接受训练时间增加和显存占用上升。这三个方向每一个都能单独扩出两到三页的实验分析和对比足够把毕设分量撑起来。我自己的习惯是答辩前固定跑一遍 20 条黄金测试问句把输出日志留档防止演示现场模型抽风翻车——这个习惯救过我两次。希望帮到你。本文还有配套的精品资源点击获取