PyTorch实现LSTM字符级语言模型:从数据预处理到困惑度评估 第一次把任务五跑通的时候我盯着终端输出的perplexity数字看了好几秒254.38。这个数字算好还是坏当时的模型已经能在我提供的几百KB文本上学到部分词序续写出来的句子虽然偶尔蹦出几个不靠谱的字符但整体已经有“像人话”的腔调。这个任务就是典型的基于神经网络的语言模型配合PyTorch从零实现一个最小可用的模型把数据预处理、mini-batch切分、模型训练、困惑度评估、文本生成整个链路走完。如果你正在完成NLP入门路线里的任务五或者只是想彻底搞懂语言模型到底是怎么训练的这篇记录应该能帮到你。我的做法是用尽可能简单的LSTM搭一个字符级语言模型在普通笔记本上就能跑先把原理和流程打通再去谈Transformer和各种花式解码。下面不会堆砌复杂技巧只讲我确认过有效的方案和踩过的坑。1. 语言模型任务到底在考什么从预测下一个词到困惑度1.1 一句“剩下的话”就把任务说清楚了语言模型做的事一句话就能讲完给定一段前文预测下一个词或者下一个字符出现的概率。数学上写成条件概率 (P(w_t | w_1, ..., w_{t-1}))整句话的概率就是这些条件概率连乘起来。举个例子“我今天早上喝了一杯”这句话里模型应该给“咖啡”比较高的概率给“桌子”很低的概率。表面上看只是在做填空但实际上模型要学会词与词之间的搭配、句法结构甚至一部分生活常识。所以这个任务和文本分类有本质区别分类任务有明确标签而语言模型没有外部标签它自己就是自己的标签——把输入整体向后移一位前一个位置是输入后一个位置是输出。任务五之所以被放在序列标注之后是因为语言模型几乎是所有NLP上层任务的底座。机器翻译、文本生成、对话系统、语音识别里的语言模型组件核心都是同一个东西。先把这一步搞明白后面看什么结构都会顺很多。1.2 神经语言模型与N-gram为什么非要用网络在神经网络普及之前主流做法是N-gram语言模型也就是统计前n-1个词的共现频次。二元模型看前一个词三元模型看前两个词。它的问题非常直接数据稀疏。假设词表是一万个词三元组合理论上有一万亿种可能语料里实际覆盖到的只是极小一部分。遇到没见过的组合只能靠平滑技术硬凑一点概率凑出来的数基本没有解释力。神经网络语言模型解决了这个问题的路子不同。它并不直接统计组合次数而是给每个词学一个稠密的向量表示再用网络结构判断这些向量组合起来之后哪个词最可能出现在下一位。两个词即使没有一起出现在训练集里只要它们的表示向量接近模型就能借助上下文信息做出类似的预测。这叫做分布式表示带来的泛化能力也是为什么任务五一定要用神经网络而不是自己拼一个trigram统计器。对比项N-gram 语言模型神经网络语言模型核心参数组合频次Embedding 向量 网络权重稀疏问题严重需要平滑通过表示共享缓解长距离依赖基本无循环/注意力结构可以建模实现成本简单偏高但PyTorch可承受1.3 PPL 是怎么算的初期最容易迷糊的指标Perplexity中文叫困惑度是语言模型最常用的评估指标。它的公式很简单PPL exp(交叉熵损失)。这里的交叉熵损失是每个token的平均负对数似然不是整句话的loss。直观理解PPL有个好办法假设一个词表大小为V的随机模型每个位置都在V个词里均匀抽取它的PPL就等于V。模型PPLK时可以粗略理解为模型在每个位置都觉得K个候选词“都有可能是下一个”无法进一步区分。PPL从V往下降的整个过程就是模型不断收窄“可能的下一个词”这个候选集合的过程。所以初学阶段最容易犯的错是把Loss和PPL当成两个独立的指标。它们只是相差一个exp本质是一个东西。训练日志里看到val loss是2.4顺手算一下exp(2.4)PPL大约是11说明模型已经在相当小的候选集合里做选择了。2. 动手前的选型数据、词表和模型架构怎么定2.1 语料选择字符级还是词级决定了你的工作量和效果任务五通常的设定是给你一段纯文本语料中文英文都可以。很多同学的第一个纠结是到底做成字符级还是词级。我的建议是第一版先用字符级。理由非常现实中文分完词之后词表动辄几万embedding矩阵先吃掉一大块显存模型训练收敛速度也会明显变慢。字符级的词表通常只有两三千个字符训练飞快而且不会遇到OOV问题——再奇怪的词也由常用字组成每个字符都在词表里。字符级不是没有缺点生成的文本可能在词语颗粒度上不够自然会出现“单个字都对整体读起来怪”的情况。但任务五的目标是跑通流程、理解原理这个代价完全可以接受。等你想做更认真的消融实验再换成BPE或者词级也不迟改动主要集中在前处理阶段。我自己当时用的是一个小型中文语料大概几十万字语料内容不算太复杂但也足够让模型学到常见的搭配和标点习惯。这样的数据量配上字符级词表普通CPU跑二十个epoch也不会太慢。2.2 mini-batch 的连续切分逻辑语言模型和分类任务最不一样的地方这是任务五和前面文本分类任务差别最大的一个环节。分类任务里每个样本是独立的shuffle没有问题语言模型里文本是连续序列如果直接按句子随机切分再打乱语序就乱了模型学不到真正的上下文依赖。常规做法是先把整段语料转成token id的一维数组再按batch_size把序列横切成batch_size段连续的子序列每个子序列独立滑窗。我贴一段当时用的代码import torch def build_batches(text_ids, batch_size, seq_len): # 去掉末尾多出来的部分 n (len(text_ids) - 1) // batch_size text_ids text_ids[:n * batch_size 1] # 输入是 ids[:-1]目标是 ids[1:]天然错开一位 data text_ids[:-1].view(batch_size, -1) target text_ids[1:].view(batch_size, -1) num_steps data.size(1) // seq_len batches [] for i in range(num_steps): x data[:, i * seq_len: (i 1) * seq_len] y target[:, i * seq_len: (i 1) * seq_len] batches.append((x, y)) return batches这样每个batch的同一行其实来自语料的不同连续段落行与行之间没有交叉。重点在于batch内部的sample不是随机打乱的而是严格按照原文顺序切出来的连续片段。这也是LSTM语言模型训练里能保持跨step状态连续的原因。2.3 为什么入门阶段用 LSTM 而不是 Transformer我见过不少同学一上来就直接上Transformer最后在中等体量的语料上痛苦调参。任务五的核心目标是完成一次完整的训练流水线LSTM反而是更合适的选择。LSTM不需要位置编码因为循环结构天然按时间步展开它的参数量相比Transformer小很多过拟合压力低在几万字到几十万字的语料上LSTM通常能稳定收敛。Transformer擅长的是超大规模数据和超长序列在入门任务里容易遇到训练不稳、收敛慢、显存不足这些问题。我知道很多人会觉得直接用Transformer才“先进”。但语言模型的关键不在用哪个结构而在于你能否把数据流和训练逻辑理解透。LSTM的网络结构更直观一个字符进去和隐状态混合产生新的隐状态并输出预测。把这条链路吃透了后面迁移到Transformer只是换掉特征提取器而已训练流程完全复用。3. 基于 PyTorch 的 LSTM 语言模型实现全过程3.1 模型结构Embedding、LSTM、Linear 三层怎么搭模型结构非常简单核心就是三个组件Embedding把所有字符id映射成稠密向量LSTM沿时间步提取上下文信息最后接一个Linear层把隐状态映射回词表大小的logits。import torch import torch.nn as nn class LSTMLanguageModel(nn.Module): def __init__(self, vocab_size, emb_size128, hidden_size256, num_layers2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, emb_size) self.lstm nn.LSTM(emb_size, hidden_size, num_layers, batch_firstTrue, dropoutdropout) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, hiddenNone): emb self.dropout(self.embedding(x)) out, hidden self.lstm(emb, hidden) out self.dropout(out) logits self.fc(out) return logits, hiddenembedding之所以要接dropout是为了防止模型把某些字符id“记住”而不是学会泛化。hidden_size选256在中小数据上够用选512会变慢但不一定显著变好。num_layers2是实践里性价比比较高的选择单层有时欠拟合三层以上在入门数据集上容易过拟合。3.2 训练循环损失计算、梯度裁剪、学习率训练代码最长但每个部分都不复杂。关键是搞清楚CrossEntropyLoss的输入形状模型输出是(B, T, V)target是(B, T)需要把logits压成(BT, V)target压成(BT)然后丢给损失函数。import math import torch.nn as nn device torch.device(cuda if torch.cuda.is_available() else cpu) model LSTMLanguageModel(len(vocab)).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) def evaluate(model, batches, device): model.eval() total_loss 0.0 total_tokens 0 with torch.no_grad(): for x, y in batches: x, y x.to(device), y.to(device) logits, _ model(x) loss criterion(logits.reshape(-1, logits.size(-1)), y.reshape(-1)) total_loss loss.item() * y.numel() total_tokens y.numel() return total_loss / total_tokens for epoch in range(20): model.train() epoch_loss 0.0 for step, (x, y) in enumerate(batches): x, y x.to(device), y.to(device) optimizer.zero_grad() logits, _ model(x) loss criterion(logits.reshape(-1, logits.size(-1)), y.reshape(-1)) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() epoch_loss loss.item() val_loss evaluate(model, val_batches, device) val_ppl math.exp(val_loss) print(fepoch {epoch:02d}, train loss {epoch_loss / len(batches):.3f}, val ppl {val_ppl:.2f})梯度裁剪放在loss.backward()之后、optimizer.step()之前这是标准位置别写反了。lr0.001配合Adam是我在多数语料上都能稳定收敛的起点实验过程中可以按情况微调。3.3 评估与生成交叉熵换成 PPL让模型开口说话评估阶段常见的错误是忘记model.eval()和torch.no_grad()。dropout在评估时如果不关闭PPL会偏高且反复波动不关梯度计算会导致显存浪费在更大模型上甚至会OOM。验证集的loss算出来后直接用math.exp转成PPL。接下来最有意思的一步是让模型生成文本给一个起始字符让它一个字符一个字符往下续写。import numpy as np def sample(model, vocab, start_ids, length60, temperature0.8, top_k10): model.eval() ids list(start_ids) hidden None with torch.no_grad(): for _ in range(length): x torch.tensor([ids[-1]], dtypetorch.long, devicedevice).view(1, 1) logits, hidden model(x, hidden) logits logits[0, -1, :] / temperature if top_k is not None: values, _ torch.topk(logits, top_k) logits[logits values.min()] float(-inf) probs torch.softmax(logits, dim-1).cpu().numpy() next_id int(np.random.choice(len(vocab), pprobs)) ids.append(next_id) return .join(vocab.idx2char[i] for i in ids)这里每次只输入最后一个字符hidden状态持续往后传模型就记住了整段前文的信息。写的时候要注意如果你把整个生成序列都作为输入喂进去模型会重复看到之后的字符训练时没有这种泄露生成结果也会骗人。4. 实际训练里绕不开的四个坑亲测有效的调参记录4.1 梯度爆炸PPL 突然跳成几千先别慌语言模型是RNN最经典的场景也是最容易出现梯度爆炸的场景。原因在于梯度沿时间步展开时会反复乘权重矩阵一旦某个方向的范数大于1梯度就会指数级放大。我的第一版代码没有加clip_grad_norm_训练到第三个epoch时某个batch的loss直接变成nan然后整个模型崩掉。后来打印梯度范数才发现某些step的梯度已经到几十甚至上百Adam在这种状态下根本来不及救场。解决办法就是那句已经快被说烂的代码nn.utils.clip_grad_norm_(model.parameters(), 5.0)。别小看这一行它把梯度范数限制住之后训练曲线立刻稳定下来。如果你发现clip之后PPL还是跳可以把max_norm从5.0降到1.0试试大概率能救回来。4.2 Loss 不降词表稀疏和初始化问题很多人在语言模型上的第一个“翻车现场”其实是预期不对。字符级词表如果有两千个字符模型未训练时均匀输出的交叉熵接近ln(2000)大约7.6。这个数字看起来很大但它就是初始状态每轮降到3到4已经说明模型在学了。如果loss长时间停留在初始值附近才是真有问题。我的排查顺序是先看target有没有错位。如果你把y写成了x而不是向后错一位模型会学到“照抄上一个字符”这条捷径训练loss会非常低但生成文本就是复读机。这个坑很隐蔽因为loss低会给人“训练成功”的错觉。再往下看词表处理。中文文本里空格和标点出现频率极高模型早期会倾向于输出这些高频字符导致生成内容看起来“全是标点符号”。这不是bug是模型在按频率做最稳的猜测训练轮次够了自然改善。4.3 序列长度和 batch 大小的平衡语言模型里batch_size和seq_len的调整逻辑跟分类任务不太一样。batch_size越大一次梯度里包含的独立文本段越多梯度越平滑但显存消耗也越大。seq_len决定模型能看到多远的上下文越长对长依赖建模越好但BPTT的梯度路径也会越长优化难度上升。我的经验是batch32、seq_len32起步在中等数据上稳定跑通。如果loss震荡剧烈、PPL忽高忽低优先把batch调大到64如果生成结果明显只看得到短距离搭配、稍长的结构就乱再把seq_len调成50或64同时把学习率适当降到0.0005。另外可以试着把上一个batch的hidden状态传给下一个batch的起点这样模型能在更长的文本跨度上保持记忆训练出的结果更连贯。不过入门阶段每个batch重置hidden也能跑区别要到长文本生成时才明显。4.4 复现随机种子和确定性模式任务五虽然不用提交严格的实验报告但复现实验会省很多事。PyTorch里设置随机种子的标准姿势是import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False即便这样每次训练结果的PPL仍可能有细微浮动。尤其是CPU和GPU、以及不同PyTorch版本之间的浮点运算顺序不同结果不会严格一致。我踩过的一个坑是因为两次实验PPL差了1.5就以为代码有问题反复重装环境折腾了半天后来发现只是设备差异导致的正常波动。固定seed的目的是让同一环境下主要结论可复现不是追求逐位相同。5. 跑完任务五之后我对语言模型的几点新认识5.1 PPL 数值背后的直觉30、100、300 分别意味着什么很多教程会给一个PPL值然后说“越低越好”但到底多低算好新手完全没有概念。我用自己的训练结果整理了一个粗略对照PPL 数值直观含义30 左右每个位置平均只在30个候选字符里犹豫文本整体已经很通顺100 左右模型掌握常见搭配但长距离结构经常跑偏300 左右接近乱猜生成内容基本没法连续阅读必须强调PPL只能在同一个语料、同一个词表设置下进行比较。不同分词方式、不同字符集得出的PPL没有可比性。字符级中文模型的PPL天然会比词级英文模型低很多因为你预测的是字符而不是词语候选空间小得多。5.2 采样生成的小技巧temperature 与 Top-K生成文本时如果直接取概率最高的字符几乎必然会陷入无限循环。高频标点或常见字一旦拿到最高概率就会一直复读自己。我的经验是temperature和Top-K要配合使用。temperature调低到0.5-0.8概率分布变尖锐生成结果更保守、更通顺但太低也会重复temperature大于1会让分布变平坦文本更跳跃有时能跳出生硬的重复。Top-K的作用更简单粗暴每步只保留概率最高的K个候选把长尾字符全部屏蔽。K10通常效果不错K太小会缺乏多样性K太大会引入大量噪声。我通常用temperature0.8 top_k10这个组合作为起点然后根据生成样本的肉眼观感微调。5.3 从 LSTM 到 Transformer 的迁移思路跑完LSTM版本后如果你想往更现代的架构走路径很清晰把LSTM层替换成带causal mask的自注意力层加上positional embedding输出层保持不变。PyTorch里可以直接用nn.TransformerEncoderLayer传入一个上三角为负无穷的mask就能实现每个位置只看自己和左侧位置的自回归限制。实际操作中会发现Transformer在相同数据量下不一定比LSTM更好。它对学习率调度更敏感常用做法是加warmup和cosine decay对数据进行重复epoch训练时也更容易过拟合。任务五阶段我的建议是先让LSTM流水线稳定跑出PPL已经足够交付主要学习目标。之后要想扩展再去踩Transformer的坑也不迟。写到这里我想起训练过程中最让我有感觉的一个瞬间我把自己写的一段话删掉后半段让训练好的字符级模型续写它给出了一个和我原意不同但在语法上完全成立的结尾。那一刻我意识到语言模型并不是在背词典它在大量文本中归纳出了词和词之间的隐性搭配规则。任务五跑完之后建议你也拿模型去续写几句自己熟悉的文字那种直观感受比盯着PPL数字更能帮你理解语言模型到底学到了什么。