多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

NLP实践Demo全拆解:手写Transformer、GPT与GNN实现及避坑指南

NLP实践Demo全拆解:手写Transformer、GPT与GNN实现及避坑指南 简介面向自然语言处理初学者与进阶开发者的综合实践示例集中覆盖文本分类、对话机器人、Transformer、GPT实现、图神经网络GNN、对抗训练、摘要抽取等关键技术并延伸至知识蒸馏、变分自编码器、快速文本检索与中文医疗问答等11个主题模块。压缩包共211个文件以82个源代码文件为主线辅以缓存、文本说明、Markdown笔记、PDF参考文档、模型权重、数据集和配置文件整体约80MB便于直接阅读、运行与对照实验。代码脉络涵盖从基础模型到前沿应用的完整链路适合需要动手实践并快速了解主流NLP技术的学习者也可作为课程设计或毕业设计的参考素材。目前已有263人学习浏览通过研读源码与配套资料可以掌握各类模型的构建方式、训练调参思路并借鉴中文医疗问答等垂直场景的落地方法。1. 一套 NLP 实践 demo 的含金量把 Transformer、GPT 和 GNN 串成能跑的代码这套 NLP 实践 demo 的价值不在算法新而在于它把文本分类、对话机器人、Transformer 手写实现、GPT 生成、图神经网络 GNN、对抗训练、摘要抽取、知识蒸馏、VAE 这些散落的模块收进了一个仓库。仓库里那几个带时间戳的 csv 是各模块跑实验时落盘的中间结果可以直接当语料或比对基线用对正在做课程设计、准备毕设、或者刚读完 transformer 模型详解想动手写代码的人来说省掉的是从零攒环境、凑数据、对形状的时间。下面按 Transformer 主线 → 下游任务 → 进阶模块 → 避坑的顺序拆开每个模块给出可抄的代码骨架和参数设置最后聊怎么用最小样本快速验收整个仓库。2. Transformer 与 GPT 实现手写多头注意力再控制生成链路Transformer 的核心不是 BERT 和 GPT 那些动辄上亿参数的预训练权重而是注意力机制本身。这份 demo 里的 Transformer 模块大概率是手写 encoder-decoder 结构GPT 部分则是在 decoder 基础上换成因果掩码和自回归生成。所以看代码的合理顺序是多头注意力 → 位置编码 → block 堆叠 → GPT 生成循环。2.1 多头注意力形状是对齐的关键先看最常用的实现和 demo 里常见写法保持一致import torch import torch.nn as nn class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads, dropout0.1): super().__init__() assert d_model % n_heads 0 # 必须整除否则形状拼不回去 self.d_model d_model self.n_heads n_heads self.d_k d_model // n_heads # 每个 head 的维度 self.w_q nn.Linear(d_model, d_model) self.w_k nn.Linear(d_model, d_model) self.w_v nn.Linear(d_model, d_model) self.fc nn.Linear(d_model, d_model) self.dropout nn.Dropout(dropout) def forward(self, x, maskNone): bsz, seq_len, _ x.shape Q self.w_q(x).view(bsz, seq_len, self.n_heads, self.d_k).transpose(1, 2) K self.w_k(x).view(bsz, seq_len, self.n_heads, self.d_k).transpose(1, 2) V self.w_v(x).view(bsz, seq_len, self.n_heads, self.d_k).transpose(1, 2) scores torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5) if mask is not None: scores scores.masked_fill(mask 0, -1e9) # padding 位置屏蔽 attn torch.softmax(scores, dim-1) attn self.dropout(attn) out torch.matmul(attn, V) out out.transpose(1, 2).contiguous().view(bsz, seq_len, self.d_model) return self.fc(out)这里最容易写错的就是形状。view 把最后一维切成 n_heads 份再用 transpose(1, 2) 把 head 维度挪到 batch 后面matmul 时 Q 和 K 的最后两维是 seq_len 和 d_k点积结果变成 (bsz, n_heads, seq_len, seq_len)。除以 d_k 的平方根是缩放点积不除的话 softmax 输入方差偏大梯度会提前饱和。d_model 常见取 512n_heads 取 8这样每个 head 分到 64 维。mask 的写法容易翻车最典型的是用 0 去乘而不是 masked_fill。填 0 的问题在于 softmax 之后 padding 位置仍然有非零权重模型会把无效 token 的信息也聚合进去。你训练时发现 loss 卡住不下去可以先排查这里。另一个细节mask 的形状要能广播成 (bsz, n_heads, seq_len, seq_len)很多人的 mask 少了 head 维度运行时直接报 shape mismatch。2.2 位置编码max_len 多留 20% 余量Transformer 没有顺序感位置编码是唯一的顺序信号。demo 里一般用正弦位置编码def position_encoding(max_len, d_model): pe torch.zeros(max_len, d_model) pos torch.arange(0, max_len).unsqueeze(1).float() div torch.exp(torch.arange(0, d_model, 2).float() * (-(torch.log(torch.tensor(10000.0)) / d_model))) pe[:, 0::2] torch.sin(pos * div) # 偶数维度用 sin pe[:, 1::2] torch.cos(pos * div) # 奇数维度用 cos return pe.unsqueeze(0) # (1, max_len, d_model)偶数维度 sin、奇数维度 cos 是原论文的设计。指数部分写成 pos / 10000^(2i/d_model) 也一样用 exp 和 log 只是数值稳定性更好。得到 pe 后直接加到 token embedding 上是相加不是 concat。实际使用中 max_len 建议留 20% 余量。比如你计划处理 128 长度的序列位置编码直接开到 256。原因是推理阶段一旦遇到比训练时更长的序列pe 越界直接报 index out of range而重训模型成本很高。demo 里跑文本分类没报错、但 GPT 生成长文本时报错十有八九是这里的问题。2.3 GPT 的自回归生成链路温度、top-k、top-p 怎么配合GPT 与普通 Transformer decoder 的区别在因果掩码每个位置只能看到前面的 token训练目标是 next token prediction。推理时逐 token 生成控制质量靠三个采样参数def generate(model, tokenizer, prompt, max_new_tokens100, temperature0.8, top_k50, top_p0.9): model.eval() ids tokenizer.encode(prompt, return_tensorspt) with torch.no_grad(): for _ in range(max_new_tokens): logits model(ids)[0, -1, :] / temperature # 温度缩放 if top_k 0: v, _ torch.topk(logits, top_k) logits[logits v.min()] -float(inf) # 只留概率最高的 k 个 if top_p 1.0: sorted_logits, idx torch.sort(logits, descendingTrue) cumprob torch.cumsum(torch.softmax(sorted_logits, dim-1), dim-1) mask cumprob top_p sorted_logits[mask] -float(inf) logits torch.zeros_like(logits).scatter(0, idx, sorted_logits) probs torch.softmax(logits, dim-1) # 截断后必须重新归一化 next_id torch.multinomial(probs, num_samples1) ids torch.cat([ids, next_id.unsqueeze(0)], dim-1) return tokenizer.decode(ids[0])temperature 越小分布越尖锐0.7 到 0.9 适合对话和摘要1.0 以上适合创意写作。top_k 只保留概率最高的 k 个候选50 是 GPT-2 时代的常用起点。top_p 是累计概率阈值0.9 表示只从覆盖 90% 概率质量的候选中采样。三者不互斥线上系统一般同时开 top_k 和 top_p。有个细节截断后必须再做一次 softmax否则概率和不等于 1multinomial 采样会偏向低概率词生成结果看起来像乱码。如果只调一个参数先调 temperature。生成太散就降到 0.7太保守就提到 1.0。注意 model(ids) 对完整序列逐位置计算推理开销随长度平方增长。demo 规模无所谓扩展到大模型时要用 kv-cache只算最后一个位置的 attention。3. 文本分类、摘要抽取与 GNN文本表示的三条消费路径Transformer 解决的是「文本怎么表示」文本分类、摘要抽取和 GNN 则是把表示接到具体任务上。这一章三个模块的路线差异很大分类走预训练微调摘要走抽取式排序GNN 把文本关系建成图再做节点分类。理解路线差异比抄代码更重要。3.1 文本分类BERT 微调是性价比最高的路线demo 里的文本分类大概率给了不止一个模型常见是 CNN、RNN、BERT 三选二。CNN 用卷积核捕捉 n-gram 特征RNN 在长序列上有顺序优势但训练慢BERT 直接借预训练权重微调。如果只跑一个优先 BERT代码量最少效果通常也最好from transformers import BertTokenizer, BertForSequenceClassification tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels4 ) def encode(texts, labelsNone): enc tokenizer( texts, paddingmax_length, # 短样本补齐到 max_length truncationTrue, # 超长样本截断 max_length128, return_tensorspt ) if labels is not None: enc[labels] torch.tensor(labels) return encmax_length 选 128 的原因大部分短文本分类的有效信息在前 64 个 token 内128 已经是安全余量。超过 256 会明显增加显存和推理时间收益却几乎没有。padding 和 truncation 必须同时开否则 batch 内序列长度不一致模型内部拼接直接报错。labels 只在训练时传推理时不传否则模型会多算一份 loss。训练循环里有个容易被忽略的点batch size 只有 8 或 16 时梯度噪声偏大可以累积 4 个 batch 再更新一次optimizer torch.optim.AdamW(model.parameters(), lr2e-5, weight_decay0.01) for step, batch in enumerate(train_loader): loss model(**batch).loss loss.backward() if (step 1) % 4 0: optimizer.step() optimizer.zero_grad()2e-5 是 BERT 微调的安全学习率区间调到 2e-4 大概率看到一个现象训练 loss 第一个 step 就掉得很低验证集指标反而差。这是因为预训练权重被破坏得太快典型的灾难性遗忘。weight_decay 对 BERT 有效但注意 AdamW 的 weight_decay 只该作用在非 bias 和非 LayerNorm 参数上对全参数生效会拖慢收敛。3.2 摘要抽取Lead-3 是底线TextRank 是进阶摘要模块走的是抽取式而不是生成式。抽取式从原文挑若干句拼成摘要不产生幻觉缺点是句子原封不动搬来。生成式摘要模型复杂、数据要求高demo 规模下没必要硬上。第一步一定先跑 Lead-3 基线新闻类文本直接取前三句。这个简单基线在新闻场景经常能打赢很多模型因为新闻导语已经包含最核心信息。拿它当最低标准你的抽取算法打不过 Lead-3说明问题不在模型在句子打分环节。然后是打分排序常见做法是基于 TextRank 思想做句子相似度排序import numpy as np from sklearn.metrics.pairwise import cosine_similarity import jieba def extract_summary(sents, vocab, top_n3): sent_vecs [] for s in sents: words set(jieba.lcut(s)) # 中文先分词 sent_vecs.append(np.array([1.0 if w in words else 0.0 for w in vocab])) sim cosine_similarity(sent_vecs) rank sim.sum(axis1) # 与全篇句子的总相似度 idx np.argsort(-rank)[:top_n] # 取得分最高的 top_n 句 return [sents[i] for i in sorted(idx)] # 按原文顺序输出sim.sum 相当于每个句子与其它句子的总相似度得分高的句子通常是整篇文章的信息枢纽。vocab 必须用整篇文档构建不能只用当前句子停用词表也得挂上否则「的」「了」「是」会主导相似度抽出来的全是带废话的句子。想要更好的效果加一个位置先验首段句子权重乘 1.2末段乘 0.8。对新闻和报告类文本这个先验的收益比调相似度公式大。3.3 快速文本检索与 GNN粗排、精排再到关系建模快速文本检索模块通常分两层粗排用倒排索引从大量文本里快速筛出候选精排用向量相似度给候选排序。倒排索引解决「从 10 万篇里快速找 100 篇」的问题向量相似度解决「100 篇里哪篇最贴近查询」。没有粗排直接全量算向量延迟会高两个数量级。粗排可以用 TF-IDF 向量加余弦相似度预先把文档向量归一化这样查询时余弦退化成点积线性扫描也够快。GNN 图神经网络在这个 demo 里的角色适合放在检索之后文档集合里每个句子是一个节点句子相似度超过阈值就建一条边然后在图上做节点分类。GCN 层的最小实现是这样class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.w nn.Linear(in_dim, out_dim) def forward(self, x, adj): deg adj.sum(dim-1).clamp(min1) # 度矩阵clamp 防止孤立节点除零 adj_norm adj / deg.unsqueeze(-1) # 行归一化 return F.relu(self.w(adj_norm x)) # 聚合邻居特征行归一化的意思是每个节点只取邻居特征的平均值避免度数高的节点特征被放大。deg 必须 clamp 到 1否则孤立节点的度是 0归一化除零后整条 loss 全是 NaN。GCN 层数不要超过三层层数越深节点特征越趋同分类边界越模糊这就是 GNN 里常说的 oversmoothing 问题。和文本检索结合时可以把粗排召回的候选文档建成子图用 GCN 做一次相关性精排比单纯向量相似度能多抓一些间接关联。4. 对话机器人与中文医疗 QA生成式、抽取式与进阶训练技巧对话和 QA 底层共享一套东西上下文编码、相关性判断、解码生成。demo 里这两个模块分别代表生成式和抽取式两条路线对话机器人偏生成式输出是模型「写」出来的医疗 QA 偏抽取式答案是从给定材料里「挑」出来的。搞清楚分界线代码才不会看串行。4.1 对话机器人的 seq2seq 结构与解码策略对话机器人的核心是 encoder-decoderencoder 读用户输入decoder 逐 token 生成回复。demo 规模下训练数据可能只有几万条对话对直接上 BART 或 T5 容易过拟合更稳的是用小规模 Transformer seq2seq 从零训练或者微调一个小号中文 BART。训练时一个关键点是 label 的 shift 对齐。decoder 输入是回复去掉最后一个 token训练目标是预测去掉第一个 token 后的句子# decoder_input_ids: [BOS, w1, w2, w3] # shift_labels: [w1, w2, w3, EOS] decoder_input_ids labels[:, :-1].contiguous() shift_labels labels[:, 1:].contiguous() loss F.cross_entropy( logits.view(-1, vocab_size), shift_labels.view(-1), ignore_indextokenizer.pad_token_id # padding 位置不参与 loss )ignore_index 设为 pad_token_id 是必须的否则 padding 位置也参与 loss 计算模型会花大量梯度去学习「预测出 pad」直接拉低生成质量。对话生成最常见的失败是回复总是很短或重复解码时把 top_k 降到 30 以下、top_p 降到 0.85 左右能缓解。如果仍然重复检查数据里是不是混了大量「嗯」「好的」这类单 token 回复这类样本会严重拉偏生成分布。4.2 抽取式医疗 QA答案必须在材料里医疗 QA 做成抽取式的概率很高因为医学答案要求准确性生成式幻觉代价太大。抽取式 QA 的任务是给定问题和候选片段预测答案在片段中的起始和结束位置。用 BERT 系模型就是加两个分类头from transformers import BertForQuestionAnswering, BertTokenizer model BertForQuestionAnswering.from_pretrained(bert-base-chinese) tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def answer(question, context): enc tokenizer( question, context, return_tensorspt, truncationTrue, max_length256 ) out model(**enc) start torch.argmax(out.start_logits) end torch.argmax(out.end_logits) 1 if start end: # 模型认为答案不存在 return return tokenizer.decode(enc[input_ids][0][start:end])start_logits 和 end_logits 是两个独立向量模型先预测开始位置再预测结束位置。start end 说明模型认为答案不在材料里直接返回空串这个判断必须有否则会出现结束位置在开始位置之前的乱答。医疗场景的坑在专业术语分词通用 BERT 分词器会把「阿司匹林」这类词切碎导致位置预测不稳定。缓解办法是换用带医疗领域词表的模型或在训练时用自定义分词器重新预训练 embedding。评估用 EM 和 F1 两个指标。EM 是预测与标准答案完全一致的比例F1 是 token 级重合度。优化时优先拉 F1因为 EM 对边界太苛刻容易过拟合训练集。对抗训练加在这个模块上很自然医疗文本里错别字、术语改写都会影响位置预测对 embedding 加扰动可以提升鲁棒性def fgsm_attack(model, batch, eta0.5): input_ids, attn_mask, start_pos batch emb model.bert.embeddings.word_embeddings(input_ids) emb.requires_grad True loss F.cross_entropy( model(inputs_embedsemb, attention_maskattn_mask).start_logits, start_pos ) grad torch.autograd.grad(loss, emb)[0] emb_adv emb eta * grad.sign() # 沿梯度方向加扰动 return model(inputs_embedsemb_adv.detach(), attention_maskattn_mask)eta 是扰动幅度一般取 0.3 到 1.0。注意扰动必须加在 embedding 上而不是 input_ids 上否则 token 被改成别的词语义完全变了。训练时先用干净样本算 loss再用扰动样本算一次 loss两个 loss 相加更新梯度模型对输入的小变化就不那么敏感。4.3 知识蒸馏与 VAE压缩模型与多样生成知识蒸馏和 VAE 在这个 demo 里可以看成对话系统的两翼蒸馏负责把大模型压成小模型方便部署VAE 负责让回复更多样。蒸馏的核心是让小模型学大模型的软标签。温度 T 控制软标签平滑度loss 是学生与教师的 KL 散度加上真实标签的交叉熵def distill_loss(student_logits, teacher_logits, labels, T3.0, alpha0.7): kl F.kl_div( F.log_softmax(student_logits / T, dim-1), F.softmax(teacher_logits / T, dim-1), reductionbatchmean ) * (T * T) # 温度缩放补偿梯度 ce F.cross_entropy(student_logits, labels) return alpha * kl (1 - alpha) * ceT 的取值一般 2 到 5。T 越大软标签越平滑小模型学到的类间关系越丰富T 太小就退化成普通交叉熵。乘 T*T 是把梯度缩放补偿回来否则温度缩放后 KL 项的梯度被压得太小。alpha 控制两部分的权重0.7 意味着软标签为主硬标签为辅。VAE 在 NLP 里解决的是生成多样性问题。标准自回归模型倾向于输出高频安全回复VAE 引入隐变量 z 打破这种确定性让同一个输入可以生成不同回复。reparameterization 技巧解决采样不可导的问题def reparameterize(mu, logvar): std torch.exp(0.5 * logvar) eps torch.randn_like(std) # 从标准正态分布采样噪声 return mu eps * std # 通过线性变换让采样可导KL 散度把隐变量分布拉向标准正态避免模型把 z 退化成无关噪声。训练 VAE 有个经典问题KL 项太强会让生成结果忽略 z回复退化成普通语言模型输出KL 太弱 z 又学不到有效信息。常见做法是 KL annealing训练初期 KL 权重从 0 逐步升到 1前 3000 步只让模型学会重构之后再逐步放开 KL 约束。5. 避坑与排查NLP 实验反复翻车的 6 个现场这个 demo 覆盖模块多每个模块都有各自的隐藏坑。下面按现象 → 原因 → 解决的方式列出我反复踩过的几个问题有些是跑 demo 必现有些是改参数后才会触发。5.1 attention mask 用了 0 而不是 -1e9loss 掉不下去现象多头注意力模块训练 loss 卡在某个值附近下降很慢验证指标低于随机猜测一点点。原因mask 用scores * mask把 padding 位置乘成 0softmax 后这些位置仍然有非零权重无效 token 的信息混进了表征。解决改成scores.masked_fill(mask 0, -1e9)让 padding 位置在 softmax 前变成负无穷softmax 后权重归零。跑完这一步 loss 通常会有肉眼可见的下降。5.2 位置编码 max_len 不够生成长文本直接 index out of range现象GPT 生成超过某长度时崩溃报错在pe[:, pos]位置训练时完全正常。原因位置编码矩阵只在训练长度内初始化推理序列一旦超出就访问越界。解决构建位置编码时把 max_len 设为训练长度的 1.2 到 1.5 倍代价只是多几 MB 显存。这个坑在 demo 里很容易踩因为训练样本都是短对话推理时随手输了一段长文本就炸。5.3 BERT 微调学习率太高loss 暴跌但验证指标反而差现象第一个 epoch 训练 loss 降到 0.1 以下验证集准确率却只有 50%甚至比随机还低。原因学习率用到 2e-4 以上预训练权重被破坏得太快模型记住了训练集噪声属于灾难性遗忘。解决BERT 系模型学习率固定在 1e-5 到 3e-5 之间batch 小就取下限batch 大可以取上限。如果是从零训练 word embedding 再微调学习率可以放宽到 5e-5但不要超过 1e-4。5.4 top-k 截断后没重新归一化生成文本像乱码现象GPT 生成结果里出现概率极低的无意义 token句子不通顺甚至出现 UNK。原因top-k 或 top-p 把部分 logits 截成 -inf 后直接喂给 multinomial没有再做 softmax概率和不为 1采样分布被扭曲。解决截断操作之后必须重新执行一次torch.softmax(logits, dim-1)。多写这一行代码生成质量立刻回到正常水平。5.5 对抗训练扰动加在 input_ids 上梯度全零现象对抗训练 loss 一直不下降模型的梯度 norm 为 0。原因input_ids 是整数张量扰动是浮点数加进去之后梯度无法回传到整数索引emb.requires_grad设了也没用。解决扰动全部作用在 embedding 上通过model(inputs_embedsemb)传入而不是加在 input_ids 上。embedding 层的梯度是连续的扰动才有意义。另外扰动之后要 detach 再算对抗 loss否则对抗样本本身的梯度会干扰参数更新。5.6 QA 模型 start 位置在 end 之后输出乱答现象抽取式医疗 QA 给出答案的开始位置在结束位置之后解码出来是一段乱序文本。原因没有检查 start 和 end 的相对关系argmax各自独立取最大值没有联合约束。解决预测后判断start end成立则返回空串或对 start 和 end 做动态规划取满足 start end 的最大概率组合。这个处理在代码里只有一行但能挡住 QA 模块至少 10% 的无效输出。6. 从零验证这份 demo20 条样本走通全流程拿到仓库别急着看实现先做冒烟测试。我的习惯是准备 20 条样本快速验证每个模块能不能跑通、指标合不合理再决定值不值得花时间深读。先造一份最小数据集。文本分类准备 20 条短文本覆盖 demo 里的全部类别摘要准备 3 篇新闻每篇拆成 5 到 8 句对话准备 10 组对话对医疗 QA 准备 10 个问题加对应材料答案标注好 start 和 end 位置。把这些整理成 tsv 或 json 格式替换掉仓库默认数据路径。跑通顺序按依赖关系来。先跑文本分类因为它代码最独立能验证环境、数据管道和训练循环是否正常。分类能跑通说明 PyTorch 和 transformers 版本没问题再看摘要、对话、QA 和 GNN。每个模块只训 1 到 2 个 epoch验证 loss 在下降、指标有个合理基线就行不追求收敛。文本分类验证集准确率超过 60%摘要通过 Lead-3 基线验证数据切分正确QA 的 F1 超过 0.5这些说明模块链路是通的。有个值得试的技巧直接把仓库里带时间戳的 csv 文件作为分类或检索的正样本入口里面大概率是之前跑过的中间结果。用真实中间产物当语料比手造数据更容易暴露数据管道里的隐藏问题。跑通过一轮之后再逐个模块换数据和参数比如把分类的 max_length 从 128 改到 256看显存和耗时变化是否符合预期把对抗训练的 eta 从 0.5 改到 1.0 看鲁棒性增益。从那以后我每拆一个新 demo第一件事不是看论文配图而是先跑通最小验证集这套流程虽然笨但能帮你把「我以为懂了」和「真的懂了」区分开。希望帮到你。本文还有配套的精品资源点击获取
返回列表