资讯动态

语言模型实战:从n-gram到LSTM,手把手训练你的第一个NLP模型

发布时间:2026/10/15 21:33:54 来源:尧图企业网站定制
终于写到语言模型了。在NLP这条学习路线上分词、词向量、文本分类一路刷下来总感觉知识点是散的直到第13天把注意力放回语言模型才突然觉得有一条主线把所有内容串了起来。这个节点值得单独记一篇语言模型既是最基础的NLP任务也是预训练时代的核心底座。搞懂它后面看机器翻译、文本生成、对话系统都会顺畅很多。这篇文章不是学院派讲义而是站在实操角度把我学语言模型时的理解、动手跑通实验的过程、踩过的坑以及它和当前主流NLP之间的关系一次讲透。适合刚开始学NLP、对“语言模型到底是什么”还比较模糊以及想动手训练一个小模型验证理解的读者。1. 先把核心问题聊明白语言模型到底在算什么1.1 用概率描述“一句话顺不顺”语言模型的核心任务学术界用一句话概括计算一段词序列出现的概率也就是 P(w1, w2, …, wn)。这句话听起来简单但把它掰开揉碎就能理解语言模型为什么能成为NLP的基石。先看日常例子。“我今天中午吃了拉面”和“拉面今天中午我吃了”这两句话前者通顺后者别扭。语言模型要做的就是给前者更高的概率给后者更低的概率。它不需要知道语法规则的具体定义而是从大量文本中统计“哪种词搭配更常见”用概率分布隐式地学习语言规律。这种思路和人类学语言的方式很像听得多、看得多自然知道哪种说法“顺耳”。计算联合概率 P(w1, w2, …, wn) 不能直接统计所有组合因为词序列的组合空间是爆炸的。解决办法是使用链式法则P(w1, …, wn) P(w1) × P(w2|w1) × P(w3|w1, w2) × … × P(wn|w1, …, wn-1)。模型的核心就变成了已知前文预测下一个词的条件概率。这个“根据上文预测下文”的框架贯穿了所有语言模型的实现形式从最早的统计模型到后来的神经网络都没有跳出这个基本盘。1.2 为什么说它是NLP的通用底座把“计算一句话概率”的能力稍微变形一下就能解锁各种NLP任务。机器翻译可以理解为给定中文原文找一段英文文本使得 P(英文) × P(中文|英文) 最大其中 P(英文) 就是英文语言模型在给候选译文打分。语音识别同理音频特征之外还要靠语言模型判断“这一串发音对应哪句话更合理”。文本生成就更直白了逐个词采样每走一步都是在调用语言模型的预测能力。我在学习过程中最大的感受是之前接触的词向量、RNN等知识在语言模型里第一次有了“目的地”。词的分布式表示解决的是“如何表达词”而语言模型解决的是“如何用词来表达”。后者需要前者作为输入基础又反过来训练出更好的词表示——当年预训练词向量的经典方法本质上就是在训练一个浅层语言模型只不过中间产物被拿去复用了。想清楚这一层关联对整个NLP技术栈的认知会清晰很多。2. 演进路线从n-gram到神经网络的两次跨越2.1 老派做法n-gram统计语言模型早期语言模型走的是纯统计路线。因为直接计算 P(wn|w1, …, wn-1) 需要看全历史数据量根本不够研究者做了马尔可夫假设当前词只依赖前面 n-1 个词而不依赖更早的全部内容。这样条件概率变成 P(wn|wn-1, wn-2, …, wn-n1)也就是 n-gram 模型。我手动推过一个二元模型bigram的例子这样比较好理解。语料只有一句话“我 爱 学习 自然 语言”。要计算 P(学习|爱)就是在语料里数“爱”后面跟着“学习”的次数除以“爱”出现的总次数。本例中结果为1。三元模型就是看前面的两个词。原理朴素但问题也很明显首先是数据稀疏。语料里没有出现过的n元组合概率直接为0整句话概率就归零了。为了解决这个问题需要做平滑处理比如加1平滑、Kneser-Ney平滑等把一部分概率分给未见组合。其次是上下文窗口有限顶多往前看3到5个词长距离依赖基本靠不住。还有维度爆炸n和词表一旦变大需要统计的组合数量呈指数增长存储和计算都扛不住。现在回头看n-gram在思路上是“用频率近似概率”简单有效但天花板非常低。2.2 神经网络登场从稀疏向量到分布式表示神经网络语言模型的出现解决了n-gram最头疼的两个问题泛化和长依赖。它的关键变化在于用低维稠密的embedding向量代替one-hot稀疏表示。每个词不再是一个孤立的id而是一个向量语义相近的词在向量空间中距离也近。模型通过非线性变换学习“当前词的特征 历史上下文特征”到“下一个词分布”的映射这样即使某个组合没在训练集中出现过只要组成它的词语义相近模型也能给出合理判断。这就是分布式表示的威力把“记忆具体序列”变成了“学习抽象规律”。后来的RNN和LSTM模型进一步把“固定窗口”改成了“变长记忆”。LSTM通过门控机制选择性地记住和遗忘信息理论上能把很远位置的信息传过来。我第一次跑LSTM语言模型时印象最深的是它真的能从开头几句话的信息影响到很后面词的生成这是n-gram和普通前馈网络完全做不到的。这一阶段的价值在于语言模型从“统计计数”跨入了“表示学习”模型内部的向量表示成为可复用的资产。2.3 Transformer时代自注意力与预训练范式再往后就是Transformer的天下了。它摒弃了RNN的循环结构用自注意力机制让任意两个位置的词直接建立联系。对语言模型而言这意味着两点提升一是长距离依赖能力拉满。注意力权重可以动态决定“关注哪些历史词”模型不再受限于循环网络的路径长度训练时还能高度并行大幅提升了可训练的数据量级。二是语言模型的角色从“任务中的一个模块”变成了“整个NLP系统的底座”。业界很快发现先把语言模型放在海量文本上做自监督预训练再针对具体任务做微调效果全面碾压从零训练任务模型。这就是后来各种大规模预训练模型的技术路线。教科书上经常把这段演进讲成技术迭代我看下来其实可以更直白地理解n-gram是“死记硬背”RNN是“带着记忆逐个读”Transformer是“整篇文本任意位置随时查”。每一步都是在突破前一步的表达能力上限。3. 实操用PyTorch从零训练一个小型语言模型3.1 先定目标和数据准备理论看再多不动手跑一遍都不能算理解。我自己搭建了一个极简的LSTM字符级语言模型character-level language model用字符作为基本单位。这样做的好处是词表很小几十个字符文本不需要复杂分词训练速度快方便快速验证对语言模型整个流程的理解。数据我选了一小段古文文本当作实验语料。为什么选古文因为古文用字精炼字符级别建模时序列规律性比现代白话文更明显模型很容易学到“之乎者也”的用法反馈直观。训练数据预处理第一步是把所有字符映射成idimport torch import torch.nn as nn text open(corpus.txt, encodingutf-8).read() chars sorted(list(set(text))) idx_to_char {i: ch for i, ch in enumerate(chars)} char_to_idx {ch: i for i, ch in enumerate(chars)} vocab_size len(chars) # 把全文变成id序列 data [char_to_idx[ch] for ch in text] print(字符数:, len(data), | 词表大小:, vocab_size)字符集大概几十个序列长度几万个字符作为一次快速的验证实验完全够了。如果换成词级别的模型还需要考虑分词、未登录词、词表截断等问题那会分散学习注意力字符级正好回避了这些干扰。3.2 模型结构怎么搭我的模型只有四层Embedding层、两层LSTM、一个全连接输出层。输入是当前字符的id序列输出是下一个字符的概率分布。核心代码class CharLSTM(nn.Module): def __init__(self, vocab_size, embed_size128, hidden_size256, num_layers2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.lstm nn.LSTM(embed_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, hidden): emb self.embedding(x) # (batch, seq_len, embed_size) out, hidden self.lstm(emb, hidden) logits self.fc(out) # (batch, seq_len, vocab_size) return logits, hidden参数选择上我直接用了embed_size128、hidden_size256。这个规模对字符级任务来说绰绰有余embedding维度太低则表达力不足太高则训练变慢hidden_size同理。两层LSTM比一层能捕捉到更复杂的抽象特征又不至于像更深模型那样难收敛。输出层每个位置都会产生一个 vocab_size 维的logits向量表示下一个字符的各候选概率。这里有个容易忽略的点LSTM的初始隐藏状态一般初始化为零。如果想做更长的文本生成可以在不同批次之间传递隐藏状态保持连续性但在训练的常规batch采样中我直接每个batch重新初始化这样实现简单且训练稳定。3.3 训练逻辑与关键细节训练语言模型的核心监督信号是“当前字符预测下一个字符”。我把长文本切成定长序列每个序列的输入是前seq_len个字符标签是后移一位的同样长度序列。切分方式上有两种选择一种是把文本切成不重叠的块另一种是滑动窗口方式。我用的是滑动窗口式采样每步挪动半个序列长度这样同一个字符可以在不同的上下文位置出现多次数据利用率高。损失函数直接用CrossEntropyLoss它为每个位置计算真实字符与预测分布的交叉熵然后取平均。优化器选了Adam学习率0.001。训练循环def train(model, data, epochs30, lr0.001, seq_len64, batch_size128): optimizer torch.optim.Adam(model.parameters(), lrlr) criterion nn.CrossEntropyLoss() model.train() for epoch in range(epochs): total_loss 0 # 滑动窗口切出训练样本 inputs [] targets [] for i in range(0, len(data) - seq_len - 1, seq_len // 2): seq data[i:i seq_len] target data[i 1:i seq_len 1] inputs.append(seq) targets.append(target) inputs torch.tensor(inputs) targets torch.tensor(targets) for step in range(0, len(inputs), batch_size): x inputs[step:step batch_size] y targets[step:step batch_size] optimizer.zero_grad() logits, _ model(x, None) loss criterion(logits.reshape(-1, vocab_size), y.reshape(-1)) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss loss.item() * x.size(0) print(fepoch {epoch1} loss {total_loss / len(inputs):.4f})梯度裁剪这一行值得多说一句。RNN系模型训练非常容易梯度爆炸因为循环结构让梯度在时间维度上不断连乘数值稍大就会失控。clip_grad_norm_把梯度的全局范数限制在5.0以内我实测不加上这一行时训练到中间阶段经常出现loss突刺加完之后整体稳定很多。3.4 评估指标困惑度Perplexity怎么读训练语言模型最常用的评估指标是困惑度PerplexityPPL。它的公式是 PPL exp(交叉熵损失)本质上表示模型对下一词的“平均候选数”。如果说模型预测下一词的分布很均匀比如要从10个词里猜一个困惑度就是10如果模型已经学到规律只在2个词之间犹豫困惑度就接近2。数值越低越好。我这次训练的模型最后epoch的交叉熵损失大概在1.05PPL约等于2.86。这意味着模型对下一个字符的平均“犹豫范围”不到3个字符对于一个字符表只有几十种的场景来说算是不错的结果。直观验证方式是让模型自己生成一段文本我用前20个字符作为起始条件逐个采样下一个字符再把它拼进上下文继续预测。第一次看到生成的句子虽然不完全是原句但连词成句的感觉已经像模像样时那种对原理的“通了”的感觉比任何指标都更有说服力。提示困惑度只反映模型在训练分布上的预测能力不能等同于文本生成质量。两个模型即使PPL相同生成内容的流畅度和多样性也可能差异很大。做项目时把它当监控指标用不要当唯一标准。4. 我实际踩过的坑和排查经验4.1 损失不下降先从这三处找原因第一次跑的时候模型loss在高位死活下不去怀疑是代码有问题。排查过程让我整理出一套优先级清单。第一检查学习率。学习率过高会导致loss震荡过低则收敛极慢。我先是0.001蹦蹦跳跳降到0.0005后明显平滑。第二检查数据预处理。当时有一版代码把字符映射字典建完后训练数据里出现了未知字符却仍在映射表里导致模型一直在“背诵”噪声。第三检查初始化。LSTM的权重初始化如果不合理梯度传播会出问题PyTorch默认初始化一般没问题但我在自定义网络时习惯再检查一遍。一个更隐蔽的坑是用滑动窗口采样时如果步长等于窗口长度样本之间完全独立batch内缺少上下文连贯性模型学得慢步长过小样本高度重叠训练慢且容易过拟合。我后来用seq_len的一半作为步长两者平衡效果好很多。4.2 困惑度低但生成还是“傻”目标和场景的错位困惑度降下来了生成结果却经常出现两种尴尬一是重复循环模型会卡在某个词或者短语上无限复读二是局部通顺但整体跑题生成到后面已经完全偏离最初的语义主题。原因是语言模型训练目标是最小化平均交叉熵而不是“生成一段让人满意的文本”。采样时如果每次取概率最高的词贪心解码会陷入局部最优的重复模式如果取概率最高的top-k候选做随机采样有一定概率走到错误分支。解决办法是在解码阶段引入Beam Search或者温度采样。Beam Search是保持多个候选序列每步扩展时保留概率最高的若干条最后选择整体概率最高的一条。我简单实现过4-beam版本重复问题大幅减轻但生成表达的多样性会降低。温度采样是把logits先除以温度系数T再算softmax。T接近0时相当于贪心T大于1时分布变平缓随机性变强。我用T0.8生成的效果最自然。这也是语言模型学习里很重要的一课训练和推理的最优策略并不相同需要分别对待。4.3 别一开始就上大规模预训练模型很多人学到这里会直接拿预训练模型过来做实验加载的时候显存爆了或者推理慢得让人想摔键盘。其实本地资源有限时从来不用一次到位。我在自己的设备上跑模型时习惯遵循这个顺序先用字符级小模型在短文本上验证思路再换到词级别模型看语义效果最后才考虑用规模更小的预训练模型做适配。每一步都有清晰产出排查范围也被控制在最小范围内。预训练模型不是不能用而是要明确“验证理解”和“刷效果”是两个阶段资源投入完全不同。5. 语言模型学完之后下一步看什么5.1 从语言模型到具体任务微调与提示学完基本语言模型之后最容易衔接的方向就是理解预训练模型的用法。训练好的语言模型只是一个“文本概率引擎”要让它在某个具体任务上发挥作用常规做法的迁移路径是微调把模型输出层替换成任务需要的结构用少量标注数据把模型调整到任务状态。这个过程中模型在预训练阶段学到的基础语言知识被完整保留新任务只需要让模型学会“用已有的能力做决策”。后来的Prompt范式则更进一步不修改模型任何参数而是把任务描述写成文本提示让语言模型直接生成答案。它把“训练一个新能力的成本”压缩成了“设计一段文本的成本”效果在某些场景下甚至不输微调。理解这一点之后对语言模型“什么都能干”的现象就会有更本质的认识——所有任务本质上都被转换成了“根据上下文预测下一个词”的格式。5.2 能力边界和当前研究方向的启发站在第13天的位置往后看值得关注的是语言模型目前的三条演进线索上下文窗口的扩展、生成可控性的提升、以及推理能力的增强。上下文窗口决定模型能一次性“看到”多长的信息生成可控性解决重复和不一致的问题推理能力则是语言模型从“会说话”走向“会思考”的关键一环。我个人的学习建议是不要在一开始就追踪所有新模型和新训练技巧先把最基础的原理、训练流程、评估方法吃透。之后碰到任何新模型都能用“它在改什么、保留什么、为什么这样改”这套分析框架快速定位那时候你就不是在被技术推着走了而是有了自己的判断坐标。5.3 一个小建议给学习过程建立实验笔记最后分享一个对我帮助很大的习惯从第10天开始我给自己的每个小实验都建立一份固定格式的笔记包括实验目标、数据规模、模型结构、超参数、训练曲线截图和采样样例。语言模型的一大特点是“结果好不好只能靠生成文本的体感来判断”笔记里的历史生成结果就像一份成长曲线。完全相同的结构不同学习率跑出来的生成质量差异亲眼见过一次比记住十遍理论都有用。后面越学越深这份笔记会变成你个人经验库的核心资产。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑