ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python+PyTorch实战:基于Transformer的单轮对话模型

Python+PyTorch实战:基于Transformer的单轮对话模型 简介这是一份基于Transformer架构的单轮对话聊天机器人完整工程面向NLP初学者、深度学习爱好者及需完成相关课设/毕设的高校学生提供可直接运行的Python源码、配套数据集、预训练模型与项目说明。压缩包共22个文件大小约83.68MB核心内容包括5个Python脚本模型导出、推理对话等、YAML/yml配置、中英文src/trg平行语料、SentencePiece词表、TensorFlow模型pb及proto定义以及NeurST和LightSeq的源码压缩包覆盖从数据处理、模型训练到加速推理的主要环节。目前已有517人浏览学习。资源内置小模型demo运行对话脚本即可快速体验效果同时附有清晰的安装环境说明与项目说明文档便于在此基础上扩大词表、训练Transformer-big模型。适合用于毕业设计、课程设计或科研实践前的快速原型验证。1. 单轮对话本该是规则脚本Transformer 为什么值得一试打开任何一个技术交流群总有人问“聊天机器人能不能用 Python 做”。等把需求拆开十有八九是单轮对话用户说一句机器回一句不需要记上下文。这种任务早期用 if-else 匹配关键词、正则套模板就能跑但效果有多僵硬做过客服机器人的都知道——换个说法就失配。Transformer 模型的出现把这个局面改变了它不等关键词直接把整句话编码成语义向量再从向量里生成回答泛化能力比规则脚本高一个量级。这篇文章我按自己搭过的一个最小可复现方案来讲从语料清洗、词表构建、模型搭建到训练推理最后落到参数和踩坑全程用 Python 和 PyTorch适合想动手跑通一个真正能聊天的模型的从业者。2. 单轮任务为什么用 Transformer数据形态、编码思路与预处理2.1 单轮对话的数据长什么样问答对是核心单轮对话的数据形态很简单一组一组平行的问句和答句。常见开源资源有 DailyDialog、中文语料里的小黄鸡对话集但更常见的是业务方自己沉淀的客服问答记录。一个标准的 CSV 文件两列question和response每行一组。别小看这个结构它决定了后面所有代码的写法——问题进 Encoder答案进 Decoder训练时模型学着把question映射到response。我见过不少人一上来就找“聊天数据集”其实手里的客服工单、产品 FAQ、甚至是微信聊天记录导出来清洗一下就是不错的语料。关键质量标准有三条问答是否配对、回答是否完整、是否有大量重复模板。宁可要 5 万组干净的数据也不要 50 万组充满“嗯嗯”“好的呢”的噪声。2.2 数据清洗正则表达式决定词表质量的下限清洗这一步直接决定了训练出的模型像不像人话。我一般按这个顺序处理import re import pandas as pd def clean_text(text): # 去掉 URL、HTML 标签、多余空白 text re.sub(r[^], , text) text re.sub(rhttps?://\S|www\.\S, , text) # 统一全角半角中文场景下全角标点会影响分词 text text.replace(, ,).replace(。, .).replace(, ?) # 只保留中文、英文、数字和常见标点 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9,\.?!], , text) # 压缩连续标点和空格 text re.sub(r([,\.?!])\1, r\1, text) return text.strip() df pd.read_csv(dialogs.csv) df[question] df[question].apply(clean_text) df[response] df[response].apply(clean_text) # 去掉空行和过短的回答 df df[df[response].str.len() 2] df df.drop_duplicates(subset[question], keepfirst)逻辑说明清洗不是越狠越好。把英文全删了模型就彻底不认识英文单词标点全删了decoder 生成时连停顿都不会。这套正则保留了中英文和基本标点同时把连续感叹号这种噪声压缩掉。drop_duplicates按 question 去重是因为单轮对话里同一个问题对应多条不同回答会把模型训成“和稀泥”每条输入学一个平均输出结果什么也学不准。参数说明str.len() 2这个阈值可以按数据规模调。如果你的语料大量是“好的”“嗯”这个过滤要提到 3 或 4否则训练时模型倾向于输出短回答——因为高频回答都是短句loss 下降最快模型很快就躺平了。2.3 中文分词与词表构建给每个 token 一个身份中文没有天然空格必须先分词。我用 jieba 做粗粒度切分再按词频截断词表。这里有个容易翻车的细节词表太小会大量出现unk词表太大则 embedding 矩阵占据大量显存且低频词学不到表示。实践里 1 万到 3 万是个常见区间。import jieba from collections import Counter def tokenize(text): return [w for w in jieba.cut(text) if w.strip()] # 统计词频 counter Counter() for q, r in zip(df[question], df[response]): counter.update(tokenize(q)) counter.update(tokenize(r)) # 过滤低频词构建词表 min_freq 3 vocab {pad: 0, bos: 1, eos: 2, unk: 3} idx 4 for word, freq in counter.most_common(): if freq min_freq: break vocab[word] idx idx 1 vocab_size len(vocab) # 保存供训练和推理共用 import json with open(vocab.json, w, encodingutf-8) as f: json.dump(vocab, f, ensure_asciiFalse)逻辑说明bos和eos是 Transformer 解码阶段的两个关键标记。Decoder 生成时以bos开头预测到eos就停止pad用于 batch 内对齐补长后面做 attention mask 时要专门屏蔽它。unk处理训练语料里没出现过的新词。min_freq 3的意思是出现次数低于 3 的词全部归入unk这一招能显著缩小词表、抑制低频噪声词。2.4 序列化与 Dataset 封装padding 和 mask 要一起想分词后每条样本长度不一必须 padding 到相同长度才能进 batch。这个max_len不是拍脑袋定的先看一下语料长度分布取 95 分位数而不是直接定 64。对话里偶尔出现长句硬截断会丢失语义但全保留又拉长训练时间。import torch from torch.utils.data import Dataset max_len 32 # 根据语料分布调整 class DialogDataset(Dataset): def __init__(self, df, vocab, max_len): self.data [] for q, r in zip(df[question], df[response]): q_ids [vocab.get(w, vocab[unk]) for w in tokenize(q)][:max_len-2] r_ids [vocab.get(w, vocab[unk]) for w in tokenize(r)][:max_len-1] # encoder 输入question 直接编码 src [vocab[bos]] q_ids [vocab[eos]] # decoder 输入response 右移一位开头补 bos tgt [vocab[bos]] r_ids # decoder 标签response 原始序列 eos label r_ids [vocab[eos]] self.data.append((src, tgt, label)) def __len__(self): return len(self.data) def __getitem__(self, idx): src, tgt, label self.data[idx] return (torch.tensor(src), torch.tensor(tgt), torch.tensor(label))逻辑说明src是完整的问句tgt是答案去掉最后一个 token、开头加boslabel是答案在末尾补eos。训练时模型输入tgt预测label这叫 teacher forcing。右边移位是 Transformer 解码器的硬性要求——当前位置的预测不能看到未来位置的 token否则等于开卷考试模型根本学不会生成。后面在模型代码里还会配一个因果掩码把未来位置遮住。3. 搭建 Transformer 训练骨架Encoder-Decoder 结构与因果掩码落地3.1 为什么不用 LSTM 做单轮对话Transformer 好在哪里LSTM 做 Seq2Seq 有个绕不过去的坎信息从第一个词传到最后一个词中间要经过很多时间步梯度消失导致长距离依赖根本学不牢。单轮对话虽然句子不长但用户的表达非常口语化、省略多意图往往藏在前半句而回答的关键词在后半句LSTM 很难把这两段隔空对齐。Transformer 的 self-attention 让任意两个位置直接计算相关性词与词之间的距离从“沿着时序走 N 步”变成了“一跳直达”。同时并行度高训练速度快。代价是参数量更大、对数据量和调参更敏感——这恰恰是这篇文章要帮你解决的问题。3.2 最小可用模型Embedding 位置编码 PyTorch 原生 Transformer不要自己从零手写 multi-head attentionPyTorch 的nn.Transformer已经封装好了 Encoder-Decoder关键是搞清楚每个 mask 参数怎么填。一个常见的翻车点是只记得做tgt_mask因果掩码忘了src_key_padding_maskpadding 掩码最后模型把所有pad也当成了语义的一部分。import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len500): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len).unsqueeze(1).float() div_term torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) self.register_buffer(pe, pe.unsqueeze(0)) def forward(self, x): return x self.pe[:, :x.size(1)] class DialoGPT(nn.Module): def __init__(self, vocab_size, d_model256, nhead8, num_layers3, max_len32): super().__init__() self.embedding nn.Embedding(vocab_size, d_model) self.pos_encoder PositionalEncoding(d_model, max_len) self.transformer nn.Transformer( d_modeld_model, nheadnhead, num_encoder_layersnum_layers, num_decoder_layersnum_layers, dim_feedforward512, dropout0.1, batch_firstFalse # 保持 (seq_len, batch, feature) 默认格式 ) self.output_proj nn.Linear(d_model, vocab_size) self.d_model d_model self.pad_idx 0 def forward(self, src, tgt): # src/tgt: (seq_len, batch) src_emb self.pos_encoder(self.embedding(src) * math.sqrt(self.d_model)) tgt_emb self.pos_encoder(self.embedding(tgt) * math.sqrt(self.d_model)) # 因果掩码decoder 当前位置只能看前面的位置 tgt_mask self.transformer.generate_square_subsequent_mask(tgt.size(0)) # padding 掩码把 pad 位置标记为 True src_key_padding_mask (src self.pad_idx).transpose(0, 1) # (batch, src_len) tgt_key_padding_mask (tgt self.pad_idx).transpose(0, 1) # (batch, tgt_len) output self.transformer( src_emb, tgt_emb, tgt_masktgt_mask, src_key_padding_masksrc_key_padding_mask, tgt_key_padding_masktgt_key_padding_mask, memory_key_padding_masksrc_key_padding_mask ) return self.output_proj(output) # (tgt_len, batch, vocab_size)逻辑说明tgt_mask是一个上三角全 True 的矩阵generate_square_subsequent_mask直接生成不需要手写。src_key_padding_mask和tgt_key_padding_mask是把序列里所有pad的位置标记出来让 attention 忽略它们。memory_key_padding_mask也很重要——decoder 在 attend encoder 的输出时同样不能看到 src 里的 padding 部分。这三个 mask 缺一个模型行为都会有明显劣化。参数说明d_model256对中小规模语料几万到几十万组是性价比很高的选择。nhead8要求 d_model 能被整除256/832。层数num_layers3起步数据量大再往上加到 6 层。dim_feedforward512是 FFN 中间层维度通常取 d_model 的 2~4 倍。整体参数量大概在 300 万到 500 万之间单张 8GB 显存的显卡就能训练。3.3 训练循环里的关键细节损失函数与梯度裁剪损失函数用交叉熵但要专门屏蔽 padding 位置不让模型学“预测pad”这种无意义任务。实现手法是ignore_index参数。def train_one_epoch(model, dataloader, optimizer, criterion, clip1.0): model.train() total_loss 0 for batch in dataloader: src, tgt, label [x.transpose(0, 1) for x in batch] # 转为 (seq_len, batch) # 移入设备 src, tgt, label src.to(device), tgt.to(device), label.to(device) logits model(src, tgt) # (tgt_len, batch, vocab_size) loss criterion( logits.view(-1, logits.size(-1)), label.view(-1), ignore_indexmodel.pad_idx # 屏蔽 pad ) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)参数说明clip1.0是梯度裁剪阈值。Transformer 训练早期梯度范数波动很大不裁剪很容易出现 loss 突然变 NaN。ignore_indexmodel.pad_idx是这里最重要的一个参数如果不设置pad位置也会反向传播模型会花大量精力学习“预测 padding”而真实回答的学习被稀释。4. 让模型开口说话训练超参、teacher forcing 与生成解码策略4.1 超参组合怎么定warmup 与学习率Transformer 对学习率极其敏感经验值是1e-4左右配合 warmup。warmup 的意义是让模型先用小学习率把 embedding 和 attention 的初步表示稳定下来再逐步加大学习率避免开局就震荡。PyTorch 里可以直接用get_cosine_schedule_with_warmup。from torch.optim import AdamW from transformers import get_cosine_schedule_with_warmup epochs 30 warmup_steps 2000 total_steps len(dataloader) * epochs optimizer AdamW(model.parameters(), lr1e-4, weight_decay0.01) scheduler get_cosine_schedule_with_warmup( optimizer, num_warmup_stepswarmup_steps, num_training_stepstotal_steps )逻辑说明warmup_steps不是固定值它应该跟数据集大小挂钩。一个经验法则是 warmup 占总训练步数的 5%~10%。如果数据集很小1万组以下warmup 步数要相应减少否则刚起步就结束了。weight_decay0.01是 AdamW 的常规配置对防止过拟合有实际帮助。关于训练步数单轮对话任务一般不需要训到 loss 完全收敛。我通常看验证集上的困惑度perplexity或者直接抽几条测试句子看生成质量。模型在验证 loss 开始回升之前就停下来那个 checkpoint 往往泛化最好。4.2 生成策略贪心搜索、Beam Search 与 temperature训练完成后生成阶段和训练阶段有一个关键差异推理时没有标准答案decoder 每一步只能靠上一步的输出作为下一步输入这叫自回归生成。最朴素的策略是贪心——每一步取概率最大的 token但这容易出现复读机和空洞回答。我通常用带 temperature 的采样在多样性和平稳性之间取平衡。def generate(model, src_tokens, max_gen_len32, temperature0.8, top_k20): model.eval() with torch.no_grad(): src torch.tensor(src_tokens).unsqueeze(1).to(device) # (seq_len, 1) # 编码一次之后 decoder 每一步复用 src_emb model.pos_encoder(model.embedding(src) * math.sqrt(model.d_model)) memory model.transformer.encoder( src_emb, src_key_padding_mask(src model.pad_idx).transpose(0, 1) ) # decoder 输入从 bos 开始 tgt_tokens [vocab[bos]] for _ in range(max_gen_len): tgt torch.tensor(tgt_tokens).unsqueeze(1).to(device) tgt_emb model.pos_encoder(model.embedding(tgt) * math.sqrt(model.d_model)) tgt_mask model.transformer.generate_square_subsequent_mask(tgt.size(0)) output model.transformer.decoder( tgt_emb, memory, tgt_masktgt_mask, memory_key_padding_mask(src model.pad_idx).transpose(0, 1) ) logits model.output_proj(output[-1, 0]) # 最后一个位置的预测 # 温度采样先缩放 logits 再取 softmax logits logits / temperature probs torch.softmax(logits, dim-1) # top-k 过滤只保留概率最高的 k 个候选 top_k_probs, top_k_indices torch.topk(probs, top_k) next_token top_k_indices[torch.multinomial(top_k_probs, 1)] tgt_tokens.append(next_token.item()) if next_token.item() vocab[eos]: break # 转回文本 id2word {i: w for w, i in vocab.items()} words [id2word[t] for t in tgt_tokens[1:-1]] return .join(words).replace( , )参数说明temperature0.8小于 1让分布更尖锐、生成更保守大于 1 则更随机。单轮对话场景我推荐 0.7~0.9 这个区间低于 0.5 几乎退化成贪心搜索输出千篇一律。top_k20是把候选收窄到前 20 个 token配合 temperature 一起用能有效防止模型偶尔蹦出一个概率极低的错词。这套生成逻辑有个值得注意的细节memory只编码一次所有 decoder 步骤共享它如果每步都重新编码 src推理会慢好几倍。tgt_mask每次都要重新生成因为 decoder 的输入长度在增长。4.3 训练效果怎么判断loss 降到多少算“能聊”这里要泼一盆冷水loss 绝对值本身参考价值不大因为它取决于词表大小和语言难度。更可靠的判断方式是准备 20~30 条训练时没见过的固定测试问句每轮训练结束跑一次生成直接看人话不像人话。这叫人工评估虽然不优雅但比任何指标都真实。一般训练到中后期会出现一个明显拐点输出从“词凑在一起但没逻辑”变成“结构完整、语义相关”这个拐点往往在验证 loss 开始平台期前后。5. 单轮对话训练中 5 个高频坑从 loss 不降说到复读机问题5.1 loss 一直不降模型输出全是unk现象训练了十几个 epochloss 在 8 以上徘徊生成结果几乎全是“未知词”。原因词表构建和分词不一致。最常见的是训练时用 jieba 分词构建词表但保存模型后推理时用了不同的分词方式导致同一个词被切碎成不同 token。另一种可能是min_freq设得太高大量词被归入unk模型根本没学到足够多的有效词。解决训练和推理共用一个分词函数不要两个文件各写一份。词表构建完打印几个高频词确认不是乱码。同时降低min_freq到 2 或 3观察unk占比经验值是验证集上unk不超过 2%超过就得调整。5.2 loss 下降但输出是乱序词长短句结构都不对现象loss正常从 7 降到 4但生成“你吃饭了吗”这种长度的句子时词对但顺序颠三倒四。原因大概率是max_len设得太短长句被截断模型没见过完整的句法结构。还有一种情况是位置编码的max_len参数覆盖了训练输入长度——比如训练时句子最长 32但推理时输入 64位置编码没有对应位置的向量。解决统计语料长度分布后把max_len设在 95 分位数以上。位置编码的max_len要大于训练和推理中可能出现的最长序列我习惯统一设 256不再纠结。5.3 模型成了复读机无论问什么都回同一句现象训练得很“成功”但测试时所有问题都回答“我不知道”“你说什么”或者直接重复用户的话。原因语料分布严重偏斜——你的数据集里某几个回答占了 30% 以上模型学到的全局最优策略就是输出高频回答因为这样 loss 最低。这在对话任务里叫“安全回答”陷阱。解决训练时对回答做频率平滑或者干脆按回答频率进行欠采样。更务实的做法是清洗语料时做一层模板去重把“嗯嗯”“好的”这类万能回复占比压到 10% 以内。如果清洗不动可以在 decoder 生成时加一个惩罚重复高频回答时手动调低它的概率。5.4 显存不够batch size 调小后 loss 反而变差现象8GB 显存跑不动 batch size 32调到 8 后 loss 波动巨大、收敛变慢。原因batch size 太小导致梯度噪声大Transformer 对这个问题尤其敏感。但直接加大 batch 又爆显存这是一个资源权衡问题。解决梯度累积。每步只前向计算一个 micro-batch积累若干步的梯度再统一更新。实现很简单在训练循环里判断step % accumulation_steps 0时才调用optimizer.step()。我常用 4 个 micro-batch 累积等效出一个大 batch。注意学习率也要相应调整等效 batch 变大后 lr 可以适当提高。5.5 生成时 decoder 崩溃输出长达几百个 token 的pad现象推理时模型没有在eos处停止而是一直输出pad直到达到max_gen_len。原因训练时pad被ignore_index屏蔽了模型从未学习过“识别pad并停止”的模式。推理时没有标准答案强制截断模型就放飞了。解决训练时不要完全屏蔽pad——可以让模型看到一小部分带pad的样本学会把这些位置输出为“停止”。更稳妥的方式是在推理时连续生成 2 个eos或者超过 3 个pad就强制终止。我倾向后者不污染训练目标。6. 从能跑到能用的最后一公里评估指标、量化加速与部署建议模型训练完不要急着接业务。先用一套粗糙但有效的评估手段验证质量准备 50 条没见过的测试问句人工打分“相关/不相关/胡说”计算一个准确率。这个数据能当 baseline 和后续版本对比。也可以用 BLEU 看和参考回答的重合度但单轮对话本身答案不唯一BLEU 分数仅供参考不要作为唯一标准。部署层面模型本身不大300~500M 参数CPU 也能跑但生成速度堪忧。用 PyTorch 的量化接口可以把模型压缩到原来的四分之一大小推理速度和显存占用都会有明显改善。如果对速度要求高可以导出 ONNX 后用 ONNX Runtime 跑单条推理延迟能压到 100ms 以内。import torch model.eval() # 动态量化适合对话这种以推理为主的场景 quantized_model torch.quantization.quantize_dynamic( model, {torch.nn.Linear}, dtypetorch.qint8 ) # 保存量化后的模型 torch.save({ model_state: quantized_model.state_dict(), vocab: vocab }, model_quantized.pt)参数说明quantize_dynamic只针对nn.Linear层生效Embedding 和 LayerNorm 保持浮点。对话生成是访存密集型任务量化能减少权重内存带宽实测在 CPU 上能提速 1.5~2 倍。如果显存充足不用量化直接上 GPU 跑也挺好。从单轮往多轮扩展时一个务实的做法不是在模型结构上大改而是在数据层面给输入拼接历史把前两轮对话压缩成“用户: …… 助手: …… 用户: 当前问题”作为新的 query再训一版。这个方案比直接改模型结构稳得多也适合在现有代码上迭代。我最早做的时候直接上了完整的多轮模型结构结果数据量和训练复杂度双双失控回过头来还是数据拼接最实在。做这个项目最深的体会是单轮对话的效果瓶颈不在模型结构而在数据清洗和生成策略。我在这上面翻过车——用一份没清洗的客服工单训了一个模型loss 降到 3.5但它学会了在回答里带客服工号。从那以后我每次训练前都会先抽样看 100 条清洗后的语料这个习惯帮我省了很多无效训练时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表