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

文章详情

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

NLP实践demo全家桶:七个方向一条完整学习路径

NLP实践demo全家桶:七个方向一条完整学习路径 简介一份覆盖文本分类、对话机器人、Transformer、GPT实现、图神经网络GNN、对抗训练、摘要抽取等关键方向的NLP综合实践demo面向有一定Python基础、希望系统接触主流NLP模型与训练技巧的学习者和开发者。压缩包共211个文件以82个Python脚本为核心涵盖模型实现、训练与评估流程附带48个pyc缓存、32个txt说明、10个Markdown笔记以及pdf文档、pt模型权重、csv数据、json配置和ipynb示例整体约80MB目录分层清晰便于按模块查找。目前已有263人学习下载。代码中不仅包含CNN/RNN/BERT文本分类和seq2seq对话机器人流程还具体演示GPT文本生成、GNN语义解析、对抗鲁棒性训练、知识蒸馏、变分自编码器生成、快速文本检索以及中文医疗QA等实用场景从数据准备、模型搭建到效果验证均有可运行脚本适合作为课程设计、算法对比或个人项目起步的参考能帮助读者快速将前沿NLP方法落地到具体任务。1. NLP实践demo全家桶七个方向一条能跑通的学习路径文本分类、对话机器人、Transformer、GPT、图神经网络、对抗训练、摘要抽取七个名词摆在一起新手很容易当成七座山。我带人转自然语言处理NLP时发现大部分人卡住的原因不是某个模型难懂而是每个方向都停在「看过原理」的层面从没亲手跑通一个最小项目。一个包含上述模块的NLP实践demo价值恰恰是把七个方向串成一条完整工程链路让你用一到两周把每个点都落到代码上。它适合刚入行想攒项目经验的人也适合工作两三年但没系统碰过这些模块的工程师照着补盲。下面我按自己落地时最顺的顺序拆开讲。2. 文本分类与对抗训练FGM扰动加在哪一层才有用2.1 为什么先用TextCNN做基线而不是直接上BERT文本分类是整个demo里最像「登门槛」的任务网上大部分分类实战都会直接用BERT微调但我不建议你一上来就这么干。BERT微调一次少说要半小时如果只是为了验证对抗训练的有效性反馈链路太长翻车了都说不清是数据问题还是模型问题。我一般会先用TextCNN做基线。这个选择有三个理由一是训练极快中文新闻分类数据集比如THUCNews你也可以用任意一批新闻标题自己造在CPU上十几分钟就能跑完一轮可以快速调参二是TextCNN对词序和局部ngram特征敏感加对抗训练后准确率提升肉眼可见通常能涨一到两个点三是它结构简单梯度传播路径短很容易观察扰动对embedding层的影响。基线网络就是经典的「embedding - 三个尺寸卷积核并行 - 全局池化 - 全连接输出」参数集中在embedding层。这一步跑通后再把backbone换成BERT对比对抗训练在两套模型上的增益差异。你自然会发现BERT本身鲁棒性已经不错再加FGM提升有限但损失曲线会更稳定验证集波动更小。2.2 FGM实现先backward再attack再forward对抗训练不是让你换模型而是在训练过程中给embedding的梯度方向加一个微小扰动让模型见过「被攻击过的样本」后变得更稳。最常见的实现是FGMFast Gradient Method核心代码就一个类class FGM: def __init__(self, model, epsilon0.5): self.model model self.eps epsilon self.emb_backup {} def attack(self): # 只在embedding层加扰动其他层保持原样 for name, param in self.model.named_parameters(): if param.requires_grad and embedding in name and param.grad is not None: self.emb_backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0 and not torch.isnan(norm): # 扰动方向取归一化梯度幅度由epsilon控制 param.data.add_(self.eps * param.grad / norm) def restore(self): # 每步结束后必须恢复原始embedding for name, param in self.model.named_parameters(): if name in self.emb_backup: param.data self.emb_backup[name] self.emb_backup.clear()训练循环里要严格按「先正常backward拿到梯度再attack再对扰动后的样本backward最后restore再更新参数」的顺序执行fgm FGM(model, epsilon0.5) for batch in train_loader: output model(batch) loss loss_fn(output, batch[labels]) loss.backward() # 第一次反向传播拿到原始梯度 fgm.attack() # 在embedding上叠加扰动 loss_adv loss_fn(model(batch), batch[labels]) loss_adv.backward() # 第二次反向传播梯度累加到原有梯度上 fgm.restore() # 恢复embedding防止污染下一个batch optimizer.step() optimizer.zero_grad()这里有两个关键点。第一attack之前必须先backward一次否则param.grad是None扰动方向根本算不出来。第二扰动只加在embedding权重上而不是输入tensor上因为我们要迷惑的是模型对词向量的解释方式不是把输入改成一个不存在于词表里的向量。epsilon的取值有讲究。中文BERT做分类时我习惯取1.0左右因为词向量空间范围较大扰动小了没效果TextCNN这种浅层模型取0.5就够太大会把语义向量完全推开loss暴涨。如果发现loss在对抗训练后震荡剧烈可以把optimizer的梯度裁剪从默认值改成1e-3能压住大部分噪声。2.3 在BERT上做对抗训练要改什么参数把FGM从TextCNN换到BERT上不是改个模型名那么简单有三处参数要动。epsilon要调小到0.5甚至0.3。BERT的词向量经过多层Transformer交互微小的扰动就能被放大到深层特征里用TextCNN那套大扰动会导致验证集F1断崖式下跌。BERT的embedding层包括token、position、segment三种向量默认的FGM实现会把它们一起扰动。position embedding记录位置信息segment embedding区分上下句这两类被污染后模型基本就废了。我一般会在attack方法里加一个过滤条件只扰动参数名包含word_embeddings的层不碰position和segment。如果你的显存够大更推荐PGDProjected Gradient Descent。它是FGM的多步迭代版本先在扰动边界内走K步一般K3每步幅度alpha取epsilon的四分之一最后统一更新梯度。PGD对抗性强很多但训练时间大概是FGM的三倍。demo阶段先用FGM验证流程写论文刷点时再换PGD或者FreeLB这个顺序不会浪费你的时间。3. 手写Transformer到最小GPT实现掩码、位置编码与采样参数3.1 手写Transformer的最小可运行版本网上讲Transformer模型结构的文章很多但真正动手写过的人才知道好多细节是看文章永远看不明白的比如mask的广播维度、多头拆分后维度的对应关系。我的建议是永远不要直接调别人封装好的TransformerEncoder先手写一个最小可运行版本哪怕只跑通一个batch也好。手写顺序我固定是缩放点积注意力 - 多头拆分 - 位置编码 - 前馈网络 - 残差和LayerNorm。其中最核心、最容易出错的就是多头注意力里的维度操作class MultiHeadAttention(nn.Module): def __init__(self, d_model128, n_heads4): super().__init__() self.n_heads n_heads self.d_k d_model // n_heads self.q nn.Linear(d_model, d_model) self.k nn.Linear(d_model, d_model) self.v nn.Linear(d_model, d_model) def forward(self, x, maskNone): B, L, _ x.shape # 一次性投影到d_model维度再拆成多头分别计算 q self.q(x).view(B, L, self.n_heads, self.d_k).transpose(1, 2) k self.k(x).view(B, L, self.n_heads, self.d_k).transpose(1, 2) v self.v(x).view(B, L, self.n_heads, self.d_k).transpose(1, 2) scores torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores scores.masked_fill(mask 0, -1e9) attn torch.softmax(scores, dim-1) out torch.matmul(attn, v) return out.transpose(1, 2).reshape(B, L, -1)几个参数必须说清楚。d_model是输入向量维度n_heads切分后每个头拿d_model / n_heads维特征所以n_heads必须能整除d_model128配4个head是常见组合。除以sqrt(self.d_k)是防止点积过大把softmax推进饱和区这是Transformer原论文里的关键细节少了它训练初期loss很难降下去。mask的shape一般是(B, 1, L, L)经过广播后能匹配(B, n_heads, L, L)的scores写成mask 0而不是mask 1容易出错很多人的坑就埋在布尔掩码的方向上。想快速验证你手写的Transformer没写错最简单的方法是让它预测正弦数据给模型输入sin函数的前64个点让它预测下一个点。这个任务对序列建模能力非常敏感mask写错、位置编码写错都会导致loss卡住。跑通了再上文本语料省的到时候分不清是代码问题还是数据问题。3.2 从Transformer改成GPT三层关键差异GPT和原始Transformer的本质区别就三条只有decoder部分没有encoder因此也没有cross-attention注意力掩码从上三角全可见变成了严格的下三角因果掩码当前token只能看到自己以及之前的token位置编码换成可学习参数且全部层都用pre-normLayerNorm放在子层之前。因果掩码是实现自回归生成最关键的一步def build_causal_mask(seq_len): # 下三角为True右上三角为False return torch.tril(torch.ones(seq_len, seq_len)).bool()在MultiHeadAttention里拿这个掩码去遮scores矩阵让未来位置的注意力权重变成负无穷softmax后自然趋近于0。这个掩码在训练和推理时必须保持一致否则训练时模型偷看了未来的token推理时又没有未来token可看生成质量会差一大截。自回归训练里还有个必踩坑点输入和标签要做一次shift错位。训练时输入是第1到L-1个token标签是第2到L个token模型要预测的是每个位置的“下一个token”inputs tokens[:, :-1] # 去掉最后一个token labels tokens[:, 1:] # 去掉第一个token作为预测目标 logits gpt(inputs) # (B, L-1, vocab_size) loss F.cross_entropy(logits.reshape(-1, vocab_size), labels.reshape(-1))如果你不shift模型就是在预测“当前位置的token”而自回归模型根本没见过当前位置的未来信息loss和生成逻辑会互相矛盾效果极其别扭。3.3 生成采样参数temperature、top_k、top_p怎么配GPT模型训练完还要配一套采样策略否则生成出来的文本就是典型的复读机。文本生成采样的核心参数有三个我用下面这张表来控制参数取值范围经验值作用temperature0.5 ~ 1.50.7控制softmax分布的尖锐程度越低越确定越高越发散top_k20 ~ 10040只在概率最高的前K个token里采样过滤掉罕见低频词top_p0.8 ~ 0.950.9按累积概率截断动态保留最核心的一批token具体实现时可以先做top_k截断再做top_p截断最后用重归一化后的概率分布里做多项式采样。如果你发现中文对话生成里大量出现“嗯”“啊”“然后”这类无意义填充词把top_k调到20以下比加大temperature更有效。如果出现重复片段很多实现里会加一个惩罚项对已出现的token的概率乘以一个小于1的系数repeat_penalty但我一般建议先调temperature而不是直接上惩罚因为惩罚系数调大会让生成变得生硬前后语气不连贯。另外一个容易被忽略的性能问题demo版GPT生成时没有实现KV Cache每生成一个新token都要把前面所有token重新算一遍注意力。生成长度超过50个token时明显变慢但这属于正常现象先把正确性保证好性能优化留到后面。4. 摘要抽取与对话机器人两条绕不开的落地路径4.1 抽取式摘要TextRank的句子排序实现摘要抽取是NLP实践demo里一个“性价比极高”的模块因为它不需要训练任何模型用无监督的TextRank就能得到一个效果还能接受的基线。TextRank的思想和PageRank一样把每个句子当成一个节点句子之间的相似度当成边的权重然后迭代计算每个句子的“重要性”最后取权重最高的top_k个句子作为摘要。import re import jieba import numpy as np def textrank_summarize(text, top_k3, d0.85, max_iter200): sentences [s.strip() for s in re.split(r[。!?], text) if len(s.strip()) 5] words [set(jieba.cut(s)) for s in sentences] n len(sentences) if n top_k: return sentences # 句子相似度矩阵用词集合的Jaccard系数近似 sim np.zeros((n, n)) for i in range(n): for j in range(n): if i j: continue union len(words[i] | words[j]) if union 0: sim[i][j] len(words[i] words[j]) / union # PageRank迭代 scores np.ones(n) / n for _ in range(max_iter): prev scores.copy() for i in range(n): total 0.0 for j in range(n): if j ! i and sim[j].sum() 0: total sim[j][i] / sim[j].sum() * prev[j] scores[i] (1 - d) d * total top_idx scores.argsort()[-top_k:][::-1] return [sentences[i] for i in sorted(top_idx)]这个实现里有两个参数要注意。阻尼系数d0.85是PageRank论文里的经典取值含义是“随机跳转”的概率d太大收敛变慢d太小句子重要性趋于平均效果反而更差。max_iter给到200次基本已经稳定加大到500提升很小但耗时翻倍。top_k的取值我一般按全文句数的20%~30%来定比如一段20句话的新闻抽3句比较合适。代价是抽取式摘要的天花板比较低它只能从原文里挑句子不能生成原文里没有的表述。如果你要求摘要语言更凝练、能自动合并信息就要往生成式摘要走也就是用第3章那个GPT模型对“原文摘要”语料做微调。但生成式摘要有个致命问题是幻觉模型可能生成原文里不存在的事实demo阶段用抽取式打底更稳。4.2 检索式对话召回阈值与兜底策略对话机器人这块很多人一上来就想微调一个GPT。我建议你先把检索式方案搭起来因为大部分业务场景里高频问题就那几十个把问答库做到位检索式对话就已经能解决80%的需求。最简做法是把问答库里的问题转成TF-IDF向量然后对用户输入的问题做同样的向量化用余弦相似度找最相近的标准问题返回对应答案from sklearn.feature_extraction.text import TfidfVectorizer # 离线阶段给所有标准问句建索引 questions [今天天气怎么样, 如何退款, 怎么绑定手机号, ...] answers [今天多云转晴出门带伞, 进入订单页点击申请退款, 在设置页选择账号绑定, ...] vectorizer TfidfVectorizer(analyzerchar, ngram_range(1, 2)) Q_vec vectorizer.fit_transform(questions) def reply(query, threshold0.55): q_vec vectorizer.transform([query]) sims (Q_vec q_vec.T).toarray().flatten() # 一次矩阵乘得到所有相似度 best_idx sims.argmax() if sims[best_idx] threshold: return 换个说法再试一次这个问题还不在我掌握范围内 return answers[best_idx]用字符级ngram而不是整词分词是为了容忍用户打字错别字和口语化表达ngram_range取(1,2)能在泛化能力和精确度之间取一个平衡。threshold这个值要仔细调设置太高用户问法稍微变一点就被拒答设置太低答非所问的情况频繁发生体验更差。我一般用0.55到0.6之间拒绝率大概在15%左右。核心原则是宁可拒绝不要瞎聊。4.3 生成式对话为什么先别急着微调GPT检索式对话跑通之后很多人会想接生成式对话把前面训练的GPT模型拿来微调。这一步不是不行但有几个前置工作要先做完第一对话语料清理成本远高于普通文本语气词、表情符、错别字多到离谱你还想控制安全边界这一步没有捷径第二GPT生成对话很容易绕进重复和空洞的回应你要在采样参数和训练数据质量上下功夫第三没有好的评估手段你根本不知道这次微调比上次好在哪里。我见过好几个人在这上面翻车微调三天后模型回复的质量反而比检索式还差。比较务实的路径是先跑检索式对话上线顶着同时收集真实用户的高频问题不断扩充问答库等语料攒到一定规模再上生成式并且把生成式作为检索失败时的兜底而不是唯一回复来源。这样组合出来的对话机器人效果稳定调试成本也低。5. 图神经网络GNN使用与NLP里的高频避坑记录5.1 文本为什么要建模成图词共现图的优势文本天然是序列把它强行建模成图听起来有点反直觉但图结构能表达词与词之间的全局共现关系。GNN图神经网络在NLP里最常见的落地方式有两种一种是文档-词二部图把每篇文档作为一个节点文档里出现的词作为另一类节点二者之间连边用于文本分类另一种是词共现图在一个滑动窗口内共同出现的词彼此连边用于关键词抽取和关系挖掘。词共现图的构造直接决定上边界窗口太小边太稀疏信息传不出去窗口太大每个词都能跟一堆不相关词连上图就跟噪点一样没法看。中文场景我一般取窗口5到8边权用共现次数最后做一个归一化。对比TransformerGCN不需要位置编码相等稀疏的位置被当成“跳一跳可达”所以在长文本分类场景里文本图GCN往往比TextCNN更稳定但赶不上BERT这类大规模预训练模型。5.2 用PyG跑通一个文本图分类的最小demoPyGPyTorch Geometric是图神经网络最常用的框架用它的GCNConv两行就能定义一个GCN分类器import torch import torch.nn.functional as F from torch_geometric.nn import GCNConv from torch_geometric.data import Data class TextGCN(torch.nn.Module): def __init__(self, in_dim, hidden64, num_classes10): super().__init__() self.conv1 GCNConv(in_dim, hidden) self.conv2 GCNConv(hidden, num_classes) def forward(self, x, edge_index): x self.conv1(x, edge_index).relu() x F.dropout(x, p0.5, trainingself.training) return self.conv2(x, edge_index) # 节点特征每个词用预训练word2vec向量文档节点用文档内所有词向量均值 x torch.randn(num_nodes, 128) # 实际用真实词向量填充 edge_index torch.tensor([[doc_id, doc_id, word_id, ...], [word_id, other_word_id, doc_id, ...]], dtypetorch.long) data Data(xx, edge_indexedge_index, ylabels)这里最容易错的是edge_index。它必须是两行、每一列是一条边的source和target而且要自己保证无向边的双向都加进去。GCN的归一化依赖邻接矩阵的对称性只加单向边会让信息流方向不完整模型效果莫名其妙地差一半。另外务必在训练前加一行断言edge_index.max() num_nodes索引越界会让loss变成NaN。模型训练参数我习惯固定成hidden64、dropout0.5、lr0.001、weight_decay5e-4、epoch200。图数据量不大时CPU就能跑显存方面完全不用焦虑和Transformer不是一个量级的资源消耗。5.3 GNN在NLP里的五个高频翻车点标压着「避坑」实际是这些年带人和自己踩过的烂坑记录每条都是标准的现象到原因再到解决。第一个坑训练loss一直下降验证集指标纹丝不动。这通常是GCN层数太多导致的过平滑层数超过3层后所有节点特征会趋向一致判别力消失。解决方式是把层数压回2到3层或者换用GAT、GraphSAGE这类能保留局部特征的变体。第二个坑loss突然变成NaN。最常见原因是edge_index越界访问了不存在的节点编号。先用断言确认索引范围再去查节点特征里有没有NaN这两个检查顺序不要反。第三个坑每篇文档单独建图训练时每个batch里的图不连通消息传递根本没有跨图发生模型退化成词向量平均。正确的做法是构建一个全局大图每个batch做子图采样保证每个子图内部是连通的。第四个坑文档节点用BERT句向量初始化词节点用word2vec初始化两类特征空间不对齐。GCN的卷积操作是对邻居特征做加权求和不同向量空间硬拼在一起最后全靠后几层强行拉齐效果远不如统一使用word2vec或者统一使用BERT词向量。第五个坑全图训练显存溢出。几十万条边的图直接喂进GCN显存扛不住。用torch_geometric.loader.NeighborSampler做邻居采样或者直接上Cluster-GCNbatch_size调到256左右显存压力会小很多。6. 把demo当验证平台一套可复用的实验记录与进阶技巧6.1 三行代码统一实验日志demo跑通后最容易被忽略的是实验日志。我今天调一个参数明天改一个随机种子隔一周回来看损失曲线根本想不起当时设了什么。我习惯在每个训练循环结束前固定打印一条记录print(f{exp_name:20s} | epoch{epoch:03d} | loss{loss:.4f} | ff1{f1:.4f} | acc{acc:.4f} | time{elapsed:.1f}s)然后维护一张对比表随手把每次实验的关键指标填进去模型配置分类F1摘要ROUGE-L单次推理耗时参数量TextCNN 无对抗0.821-2ms3.2MTextCNN FGM0.837-3ms3.2MGPT small-0.3145ms20.1M有了这张表哪个模块改动有没有收益一眼就能看出来面试或写技术文章时也拿得出数据比“感觉效果好一点”有说服力得多。6.2 三个值得继续投入的进阶方向如果你想在这个demo基础上再往上走我推荐的第一个方向是位置编码改进把原始正弦位置编码换成ALiBi或RoPE不需要改动太多代码就能明显提升长序列外推能力。第二个方向是图网络变体替换把第5章的GCN换成GraphSAGE样本效率和表达能力都会上一个台阶。第三个方向是把对抗训练从FGM升级到AWP或FreeLB这两个方法在NLP榜单上被验证效果比FGM稳定得多但训练成本也更高适合你已经把整套流程稳固后再碰。回头看我自己的学习路径最深刻的教训是把太多时间花在了“读代码”而不是“跑代码”上。第一次实现GPT时我对着网上各种讲解看了三天以为自己懂了结果动手训练时loss不降最后发现是causal mask的维度写错成二维广播时没有匹配到batch维度。如果我一开始就写一个极小的测试用例去验证维度半小时就能定位问题。后来我养成了一个习惯每加一个模块先写一个只跑一个batch的最小用例确认shape对、loss能降再往大模型上扩展。这套习惯让后续的对抗训练和GNN实验顺利了很多希望帮到你。本文还有配套的精品资源点击获取
返回列表