学习NLP的第14天。前面13天我把分词、词向量、朴素贝叶斯文本分类、简单序列标注都过了一遍,今天终于把进度条推到了语言模型的训练与预测。写完训练脚本、跑完一个demo、再看生成结果时,我突然意识到这可能是整个NLP里最接近“让机器开口说人话”的一步。
这篇文章不搞长篇理论推导,就是想记录一下我今天看完“语言模型(Language Model, LM)”四个字之后都做了什么:语言模型在自己的任务里怎么定义、为什么说它是自监督的、训练一条流水线要处理哪些数据细节、损失函数和困惑度该怎么读,以及最后生成文本时贪心、束搜索、采样这几种策略到底差在哪。同时把我踩到的几个坑摊开讲——这些坑大多不是代码报错,而是“loss明明在降,生成却像复读机”这类想不通的问题。
适合谁看?刚学到NLP中段、准备开始做生成任务的同学;想弄清楚“训练—评估—预测”完整链路的同学;还有那些跟几天前的我一样,以为训练LM就是跑通某个模型的人。
1. 为什么语言模型敢说“学会了中文语法”:任务定义与核心直觉
1.1 一句话定义:在猜下一个词
语言模型的任务一句话就能说清:给定一段已经出现过的上文,预测下一个词是什么。
比如输入“今天天气真”,模型要在“好”“不错”“糟糕”“适合睡觉”这些候选里给出一个概率分布。看起来很简单,难就难在它要把上文的语序、搭配、语义甚至常识都揉进这个分布里。你今天输入“我今天中午吃的是”——大概率下一项是“饭”“面”“火锅”而不是“飞机”;这背后不是什么显规则,而是模型从海量文本里统计出来的模式。
这个任务还有一个让初学者兴奋的特性:它不需要人工标注。语料天然自带标签,把一句话截成“上文→下一个词”的样本对就行。所以语言模型也被叫做自监督学习,数据本身就是监督信号。
1.2 链式法则:一个句子的概率怎么拆
要理解语言模型为什么“顺”,必须看懂一条公式:一个句子的概率,可以拆成每个词在当前上文条件下出现的概率的连乘。
P(今天天气很好) = P(今天) × P(天气 | 今天) × P(很 | 今天天气) × P(好 | 今天天气很)
每个乘积项都只是“给定前文,预测下一个词”。这样一来,最底层的模型只需要学会一件事——P(下一个词 | 上文),而把整句概率自然而然地拼出来。你生成文本时,从 开始,每吃进一个新词,就再预测下一个词,一直滚到 或最大长度为止。
这也是为什么后面所有解码策略:贪心、束搜索、采样,本质都是在“每一步的选择”上做文章。语言模型的训练目标,就是让真实句子算出来的总概率尽量大,也就是让每一步预测都尽量准。
1.3 学什么才算学会:要的是分布,不是死记硬背
不少人会把“模型学会了”理解成“模型记住了”。实际上语言模型记住的是条件概率分布,而不是词序列本身。
训练时它见过的句子,在测试时大概率不会原样出现,真正衡量它学会与否的标准是:面对没见过的上文,它能不能把可能性按“人话”的概率摊开。比如它应该给“小明昨天买了一本____”后面的“书”更高的概率,给“桌子”低一点,给“跑”几乎为零;这不是靠背,是靠词与词的共现规律统计出来的。
这个直觉很关键,后面评估困惑度、判断过拟合的时候,都是围绕“分布学得好不好”展开的。
2. 从n-gram到LSTM:我为什么选了循环神经网络这条路线
2.1 统计派:n-gram的计数逻辑与致命伤
最早的LM思路很朴素。n-gram认为一个词只跟前 n-1 个词有关,然后去语料里数次数,算条件概率。
比如 bigram(n=2)就是只看前一个词:P(天气|今天) ≈ count(今天 天气) / count(今天)。这个做法实现简单,也能跑出效果,但它有两个硬伤。
第一个是数据稀疏。语料再大,也不可能覆盖所有词串。如果训练集里没见过“紫色 的 心情”,那这个三元组的概率就是0,整句概率也可能变成0,只能靠平滑技巧硬拉。第二个是窗口太短。只看前面一两个词,根本没法捕捉“杭州 的 某个 小镇 里 那家 面馆 的 老板 说”这种长距离依赖。今天你回头看,n-gram更像是一个用来建立“条件概率”直觉的脚手架,而不是真正能生成流畅长句的方案。
2.2 神经派:Embedding加循环就是记忆
神经网络LM的思路完全不同。它不直接数次数,而是把每个词映射成一个向量,再用网络结构去压缩和记忆上文信息。
我这次用的是LSTM。它的核心思想是:在处理每个词时,内部维护一个“隐状态”h_t,这个状态相当于对之前所有信息的一个压缩记忆,然后基于当前词和这个隐状态来预测下一个词。LSTM内部专门设计了门控机制,让信息能传得更远,比简单RNN更能记住长距离的依赖。
模型结构其实非常简洁:Embedding把词id变成向量,LSTM按顺序读过整段上文并更新隐状态,最后接一个Linear层把隐状态映射回词表大小的logits。PyTorch里写一个两层LSTM作为LM,核心代码不超过30行:
import torch import torch.nn as nn class LstmLM(nn.Module): def __init__(self, vocab_size, emb_size=256, hidden=512, num_layers=2, pad_id=0): super().__init__() self.embedding = nn.Embedding(vocab_size, emb_size, padding_idx=pad_id) self.lstm = nn.LSTM(emb_size, hidden, num_layers, batch_first=True) self.fc = nn.Linear(hidden, vocab_size) def forward(self, x): emb = self.embedding(x) # (B, S, E) out, _ = self.lstm(emb) # (B, S, H) logits = self.fc(out) # (B, S, V) return logits这里没有在输出层加softmax,因为后面用CrossEntropyLoss时它内部会做log_softmax,直接喂logits就行。
2.3 选型对比:LSTM在入门阶段的性价比
我知道现在很多人一上来就提Transformer,但作为第14天的学习者,我建议没必要急着堆大模型。我把几个方案放在一起比过:
| 方案 | 捕捉长距离能力 | 训练成本 | 入门理解难度 | 适合场景 |
|---|---|---|---|---|
| n-gram | 很弱,只看前n-1个词 | 极低 | 低,利于建立概率直觉 | 基线模型、小规模任务 |
| LSTM | 中等,有门控记忆 | 低到中 | 中等,循环概念稍抽象 | 中短文本生成、入门实践 |
| Transformer | 强,注意力全连接 | 高 | 较高,注意力机制需要消化 | 大规模预训练、长文本生成 |
对当下的学习目标来说,LSTM足够让我把“训练—评估—预测”整条链路跑通,而且跑一轮很快,调试方便。理解LSTM的隐状态如何一步步携带上文信息,对后面理解Transformer里的Token、Position、Attention也有无缝衔接的帮助。
3. 把语料塞进模型前要做的事:一条完整的训练流水线
3.1 语料来源与清洗:占据我今天一半时间的环节
今天我用的是自己攒的一个中文纯文本文档集,不是公开的大规模数据集,主要是一些短篇文本拼接而成,规模大概几百万字。对训练一个两层LSTM来说,这个量级够用,至少能把训练流程完整跑起来。
最耗时的工作在清洗。我写了一个Python脚本,依次做了几件事:统一全角半角标点、去除HTML残留标记和无关字符、把连续多个换行合并成段落边界、过滤掉长度不足20个字的碎片。这些步骤看起来琐碎,但直接影响词表质量:如果标点符号一会儿全角一会儿半角,同一个词会被拆成两种形态,词表白白涨了一截,模型还要浪费容量去学这种没意义的差异。
3.2 分词、词表与特殊标记:中文处理有个绕不开的选择
清洗完文本,接下来是分词。英文天然按空格切,中文不行。我这次用的是按字切分加子词合并的思路,而不是直接做整词分词。
原因是:整词分词会产生词表爆炸,动辄几十万词;而且遇到没见过的词直接变成 ,生成质量会断崖式下跌。按字切分则把词表控制在几千左右,即便遇到生词,也能按字组合。中文里单字本身就携带语义,按字切分配合后面的Embedding学习,对短文本生成任务完全够用。
词表构建完成后,要加入几个特殊标记: 用于批次内对齐, 表示句子开始, 表示句子结束, 表示未登录词。我用一个简单的Python字典完成id映射,把词频低于阈值的词全部替换成 ,防止低频词把词表撑爆。
3.3 滑窗造样本:把长文本切成一摞“上文→目标”对
数据准备阶段,我用的方法是滑窗。给定一个窗口长度S=32,我在文本上以stride=16滑动,切出很多长度为32的片段,每个片段作为上文,再整体右移一位作为预测目标。
举个例子,如果窗口内容是“你今天中午吃饭了”,那么输入序列是“你今天中午吃饭”,目标序列是“今天中午吃饭了”——也就是每个位置的标签都是当前位置的下一个词。这样一条长文本能切出大量训练样本,重叠窗口还能让模型覆盖更多中间位置的上下文,不至于只在固定断点上学。
构造批次时还有个容易忽略的点:每个batch内的序列长度可能不一样,需要padding对齐。我把 的id设成0,后面在损失函数里通过ignore_index把它屏蔽掉,这样模型就不会浪费时间学习“预测 ”这件毫无意义的事。
4. 训练与评估:loss下降别急着高兴,要看困惑度和生成样例
4.1 训练循环里的关键设置:损失、优化器、梯度裁剪
训练部分的代码逻辑其实很直白,就是标准的知识:前向传播拿logits,算交叉熵损失,反向传播更新梯度。我用的参数是embedding维度256、LSTM隐层维度512、两层LSTM、batch size为64、Adam优化器、初始学习率1e-3。
唯一值得展开说的是loss那一行。logits形状是(B, S, V),target形状是(B, S),直接把两个都reshape成二维,再用CrossEntropyLoss计算。同时设置ignore_index=pad_id,让 位置不参与损失计算。训练循环里还加了梯度裁剪,防止长序列反向传播时梯度爆炸:
import torch import torch.nn as nn import math loss_fn = nn.CrossEntropyLoss(ignore_index=0) # 0是pad_id optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(20): model.train() total_loss = 0.0 for batch in train_loader: x = batch["input_ids"].to(device) y = batch["target_ids"].to(device) logits = model(x) loss = loss_fn(logits.reshape(-1, vocab_size), y.reshape(-1)) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss += loss.item() avg_loss = total_loss / len(train_loader) ppl = math.exp(avg_loss) print(f"epoch {epoch:02d} | loss {avg_loss:.4f} | ppl {ppl:.2f}")4.2 困惑度怎么算,又该怎么读
上面代码里的ppl是通过loss直接算出来的:困惑度Perplexity = exp(交叉熵损失)。如果模型在预测下一个词时平均在K个候选词之间犹豫,那ppl大约就是K。
比如ppl=25,意思差不多是:每一步模型的有效候选范围大约有25个词。对训练初期的模型来说ppl几百很正常,因为它在几千个词里瞎猜;训练结束后我的模型在验证集上ppl能压到120左右,虽然离“说得像人话”还很远,但至少说明已经学会把概率集中在合理的候选区间。
读ppl有两个分寸。第一个,ppl下降确实能反映“学会”了,但它不是唯一指标,也不等于生成质量。第二个,如果把训练集和验证集混在一起评估,ppl虚低也没有意义,所以样本划分必须干净。
4.3 怎么判断“真学会”而不是“背下来”
今天最让我警醒的一个现象是:loss明明在降,ppl也不错,但生成的句子几乎是在复读训练语料里的原句。
原因很直接:语料规模小、模型容量相对大,模型走了捷径——按上文直接找到训练集里最接近的原句片段,然后“背诵”出来。它没有真正泛化出组合语言的能力。
我用了两个方法交叉判断。第一个是保留一个固定输入“今天天气真”,每个epoch结束后用贪心解码跑一遍,看生成结果。如果每轮都在换内容,说明模型在稳定学习;如果固定输出某一句原话,就要警惕过拟合。第二个是在验证集上随机挑几段话,对比模型给出的Top 5候选词和语料里的全局词频表。如果模型预测的最高概率词永远是最常见的“的”“了”“是”,而不是合语境的词,说明它只学到了词频,没学到语法结构。
5. 预测生成时的三重解码策略:贪心、束搜索与采样
5.1 贪心生成:最省事,也最容易变成复读机
训练结束,模型手里的王牌是一张词表大小的概率分布。预测阶段要做的就是:给定上文,每步挑一个词,把新词拼到上文里,再重复这个过程。
最简单的贪心策略是每步直接取argmax,也就是挑概率最高的那个词。代码写起来很舒服,但跑一遍就知道缺点:容易陷入重复。真实语言里,当前概率最高的词往往是“的”“了”“很”这种高频结构词,模型又不会主动回头检查自己说过的内容,结果就是“他很高兴很高兴很高兴”循环。
这种重复的本质,是模型在局部最优上打转。它每一步都选当下最“安全”的词,却没有任何机制考虑“这句话整体下去会不会顺”。这就引出束搜索。
5.2 束搜索:保留多条候选路径再择优
束搜索的思路是“不要一步定生死”。每一步不是只保留一个候选,而是保留K个累积概率最高的前缀序列,等生成结束后再选整体得分最高的那条。
参数beam_size=5的意思就是同时维护5条候选路径。每生成一步,每一条路径都会扩展出词表大小个候选,然后按累积log概率排序,只保留Top K。这样模型可以先把“他 很高兴”和“他 觉得 不错”都留着,多走几步再淘汰不合理的分支。
束搜索确实能明显减少重复。但它也有代价:beam_size调得越大,生成越偏向“安全而平庸”的文本,有时候反而更死板;而且如果模型本身训得不好,束搜索会把一个错误句式一路放大,5条路径全是同一种毛病。
5.3 采样与temperature、top_p:在随机性和流畅度之间走钢丝
采样和前面两种都不同:它不取最高概率,而是按概率分布随机抽一个词。每句生成结果都会不一样,这是我喜欢它的一点。
但有随机就得控制。直接按原始分布采样,低频的错词也可能被抽中,句子容易突然跑偏。所以我加了两个调节旋钮。
temperature是一个缩放参数:logits除以T后再做softmax。T小于1时分布变尖锐,更接近贪心;T大于1时分布变平缓,更随机。我试下来T=0.8到1.0之间对中文短句生成比较顺。
top_p则是核采样:把所有候选按概率从高到低排序,只保留累积概率达到p的那一批,把长尾的罕见词直接砍掉。两者可以配合使用,先按temperature调整分布,再做top_p筛选,最后采样:
def sample_next_token(logits, temperature=1.0, top_p=0.9): logits = logits / temperature probs = torch.softmax(logits, dim=-1) sorted_probs, sorted_idx = torch.sort(probs, descending=True) cumsum = torch.cumsum(sorted_probs, dim=-1) # 删除累积概率超过top_p的长尾项,至少保留一个候选 remove_mask = cumsum - sorted_probs > top_p sorted_probs[remove_mask] = 0.0 sorted_probs = sorted_probs / sorted_probs.sum() chosen = torch.multinomial(sorted_probs, 1) return sorted_idx[chosen].item()跑生成的时候别忘了设停止条件:要么遇到 ,要么达到最大长度max_len。否则模型很容易在一句话的中间被截断,于是无限续写,产生一段没有结尾的废话。
6. 第14天结束前踩过的五个坑,每一个都让我改了半天
6.1 损失函数里没屏蔽 :模型疯狂学怎么预测无意义符号
第一次训练时我把 也当普通标签算进loss。结果模型相当长一段时间里在努力让 位置的概率分布更合理,那完全是无意义的。发现验证集ppl一直下不去,才想起CrossEntropyLoss的ignore_index参数。加上之后,loss立刻降了一截。
这个坑特别容易在“刚看懂逻辑但没动手写过”的时候踩。记得检查target里padding位置的标签值,然后在损失函数里把它排除掉,否则模型白白浪费大量容量。
6.2 小语料配套大模型:生成的每句都是训练集原话
前面提到过,语料太小而模型不小的结果就是过拟合。我今天第一次跑完20个epoch,生成“今天天气真”的开头,模型直接接了一整段训练语料里出现过的原文,一字不差。
拖线回去检查valid loss,发现它从第8个epoch开始就在回升;而train loss还在降,这就是过拟合的标准信号。处理办法有两个:扩大语料是根本,短期可以降低隐层维度、减少层数、增大dropout、更早停止迭代。我加了0.3的dropout并把epoch从20提到12附近观察早停,这个问题才被压住。
6.3 整词分词的OOV:字词没见过的生词直接变成
刚开始我用整词分词时,遇到文本里没见过的词,模型一律看成 ,生成时一旦某个位置必须选新词,大概率就吐出 ,整个句子直接崩坏。
后来我看了一些公开项目的做法,换成了子词级别处理,也就是把词拆到更细的粒度。中文场景下,按字+少量常见双字词组合是一种省事又稳的选择。词表少了两个数量级,几乎不再出现 。
6.4 训练到一半loss变成NaN:循环网络常见的梯度爆炸
长序列训练到第几个batch,loss突然变成NaN,后面全乱了。原因是LSTM在长序列上反向传播时梯度有可能指数级增长,也就是梯度爆炸。
解决办法是clip_grad_norm_:在optimizer.step之前把梯度的范数裁剪到一个阈值,我用的是5.0。这个操作等于给梯度一个“安全限速器”,可以容忍偶尔很大的梯度,但防止它破坏参数。加上之后训练稳定很多。
6.5 解码时不设 和最大长度:生成过程停不下来
最后一次踩坑是在解码环节。我先输入“今天天气真”,然后循环调用模型预测下一个词。问题是模型从来没有输入过 ,而且我没有给任何停止条件,结果是模型一直在扩展,生成了一长串根本停不下来的句子。
后面对照公开代码才发现:训练时每个句子序列开头都加了 ,解码时也应该以 开头;同时生成循环要设置max_len,比如最多生成64个token,超过就强制截断。否则一旦模型在语料里没学好 的时机,输出就成了无底洞。
6.6 踩坑之后的一点心得
这些坑放在一起看,其实指向同一个道理:语言模型的训练与预测是一条完整链路,数据清洗、词表设计、损失屏蔽、梯度稳定、解码终止,任何一环出问题,最后都会以“生成结果不对劲”的形式暴露出来。
我后来把整条流程固定成这样:先准备干净语料,再构建子词词表,用滑窗造样本;训练时主动屏蔽 、裁剪梯度、盯验证集loss;预测时用 开头并设置max_len,生成阶段选top_p采样并搭配0.8到1.0的temperature。这套流程现在跑起来很顺。
说到底,第14天的收获不是学会了某一个公式,而是终于把“模型训练出来然后生成一句话”这个看似平常的闭环亲手打通了。下一步我想试试给LSTM加上注意力机制,看看长文本生成到底能改善多少;到时候再写一篇笔记出来。