☰
PyTorch实现语言模型实战:从LSTM数据构造到文本生成
2026/10/22 9:51:47 网站建设 项目流程

NLP入门这一关,很多教程把语言模型讲得很轻巧:给一串字,预测下一个字。可真正动手用PyTorch实现“NLP-Beginner 任务五:基于神经网络的语言模型”的时候,数据构造、隐层状态、loss对齐、生成解码,每一环都可能让新人在原地卡上几个小时。这个任务本质上是让你从零实现一个可训练的字符级或词级语言模型,跑通训练曲线,能生成像模像样的文本,同时真正理解RNN这类序列模型是怎么工作的。如果你是刚学完Python和PyTorch基础、准备跨进NLP大门的新人,或者正在刷类似练习,这篇文章基本按我的实操顺序把重点盘了一遍:任务拆解、数据处理、模型搭建、训练评估、踩坑记录。读完你至少能拿到一套可以复现的代码框架,而不是一堆飘在空中的概念。

1. 任务拆解:语言模型到底在学什么

1.1 一个语言模型的本质是条件概率

这个任务的核心并不是“写一个生成文本的模型”,而是训练一个能估计条件概率的模型:给定前文 (w_1, w_2, ..., w_{t-1}),预测下一个符号 (w_t) 的概率分布。公式写出来就是 (P(w_t | w_1, ..., w_{t-1}))。所谓生成文本,只是把这个条件概率不断套娃:先用一批初始字符作为种子,预测下一个字符,再把刚预测出来的字符拼回输入,继续预测下下一个字符。

这里有个很容易绕晕的点:训练和生成用的是完全不同的输入来源。训练时用的是真实文本中的“前文-目标”对,模型每个时间步看到的都是真实且正确的历史,这叫 teacher forcing,相当于教练全程扶着骑车。而生成时,模型看到的是自己上一轮输出的内容,错一个字符后面可能全错。所以初学者常会遇到“训练loss挺低,生成效果却一般”的情况,这不一定是模型坏了,而是两种模式下误差累积的差异。

这个任务和普通分类任务最大的区别在于:分类是单点预测,而语言模型要对一个漫长的序列做逐位置预测。每个位置的预测都依赖前面的上下文,所以数据不能像图像那样随便打乱,序列之间的顺序关系必须保留。这也是为什么后面数据处理环节要特别小心。

1.2 技术路线怎么选:LSTM还是Transformer

任务没限定必须用什么模型,但大多数情况下,学习目标是用神经网络替代传统的N元语法模型。N元语法的思路是只看前面n-1个词,参数空间爆炸,而且看不到更长的依赖关系。神经网络语言模型则把所有历史压缩进一个固定维度的向量,也就是把“前文”变成“记忆”,用向量之间的相似度来共享相似上下文的统计信息。

至于选LSTM还是Transformer,我建议入门阶段优先把LSTM或GRU跑通。原因是LSTM的结构对“序列建模”这件事解释得非常直白:它有一条细胞状态横穿整个时间轴,用输入门、遗忘门、输出门来控制信息的读写。你可以把它想象成一个记忆力特别好的读书笔记,一边读新内容一边决定哪些旧信息要忘掉、哪些要写进长期记忆。这种结构很适合用来理解“隐状态是怎么传递的”。Transformer虽然效果更强,可一旦涉及attention mask、positional encoding、KV cache这些细节,就会把注意力从核心任务上带偏。

用PyTorch实现时,我的路线是先搭一个embedding + LSTM + Linear的三段式模型,跑通之后再考虑换GRU、加深层数、甚至换成Transformer编码器。任务五的收获重点在于“能解释每个模块在干什么”,而不是把SOTA模型堆上去。

2. 数据处理:把原始文本变成能训练的样子

2.1 词级还是字符级,先别急着站队

这个任务最大的一个决策点是建模粒度。词级语言模型更接近人类对语言单位的直观感受,但词表往往有几万到几十万,低频词处理、未登录词、padding都会增加代码复杂度。字符级语言模型的词表只有几十到几百,训练速度快,不需要处理复杂的tokenization,而且在中文语料上,字和字之间的搭配信息已经足够让模型生成看起来通顺的片段。

我的建议是:如果你只是想快速把流程跑通,先做字符级。字符级代码简单,loss下降肉眼可见,生成时也不会有太多OOV问题。等字符级版本稳定了,再去升级成词级,体验一下PAD mask、UNK这些词级模型绕不开的细节。

这里给一个对比参考:

对比项词级字符级
词表大小通常1万以上通常50~500
训练速度慢,embedding矩阵大快,embedding矩阵小
未登录词问题需要UNK处理几乎没有
序列长度较短,信息密度高较长,依赖窗口更大
生成质量语义更完整词形可能碎,但灵活

词级模型里的UNK处理值得多说一句:最常见的做法是把词频低于某个阈值的token替换成<unk>,并保证训练和测试时用同一个词表和同一个替换规则。如果不处理,测试时遇到没有见过的词会导致预测非常不稳定。

2.2 构造训练样本的完整流程

数据处理是整个任务里最容易出错、也最容易被忽略的部分。我的流程是这样的:读取原始文本,清洗噪声,按字符或分词结果转成id序列,再把id序列切成长度为seq_len的样本块,最后用DataLoader包装成batch。

直接给一套可以用的字符级代码:

import torch from torch.utils.data import Dataset def read_text(file_path): with open(file_path, encoding="utf-8") as f: text = f.read() # 统一换行:这里简单把空白符换成空格,再按需清洗 text = text.replace("\u3000", " ").replace("\n", "").strip() return text def build_vocab(text): chars = sorted(set(text)) stoi = {ch: i for i, ch in enumerate(chars)} itos = {i: ch for i, ch in enumerate(chars)} return stoi, itos class TextDataset(Dataset): def __init__(self, ids, seq_len): # 保留最后一个位置作为预测目标,所以整体长度先减1 n = len(ids) - 1 n = n - n % seq_len self.x = torch.tensor(ids[:n]).view(-1, seq_len) self.y = torch.tensor(ids[1:n + 1]).view(-1, seq_len) def __len__(self): return self.x.shape[0] def __getitem__(self, idx): return self.x[idx], self.y[idx]

这里有一个设计取舍:我没有用滑窗,而是把文本切割成不重叠的长度为seq_len的块。这样每个样本之间互不重叠,计算效率高,DataLoader可以安全地shuffle样本块而不破坏块内部的时间连续性。如果你用滑窗,样本数量会膨胀seq_len倍,训练速度会明显变慢,而且大量重叠样本对收敛的贡献有限。

切成块之后,输入和目标的对应关系是:输入是ids[i : i + seq_len],目标则是ids[i + 1 : i + 1 + seq_len],也就是把整个序列往后错一位。这个错位操作看起来很简单,但经常写错,比如输入取到n,目标却取到n+1,导致最后一个token没有对应目标。上面代码里先做n = len(ids) - 1,就是为了保证输入和目标长度一致。

2.3 词级模型必须面对的问题:padding和mask

如果你后续想尝试词级模型,句子长度通常不一致,需要padding到相同长度。最常见的方式是在词表里加入一个<pad>标记,把所有短句统一补到一个批次内的最大长度。但padding会带来一个非常隐蔽的问题:<pad>位置不包含任何语义信息,如果loss把这些位置也计算进去,会拉低有效loss,让模型浪费时间学习“预测pad”。

所以必须用一个mask把有效token位置和pad位置区分开。做法是:

mask = (target != pad_idx).float() loss = (loss * mask).sum() / mask.sum()

这里loss是每个token位置的交叉熵损失。只对mask为1的位置求和取平均,就不会被padding干扰。还有一个更优雅的方案是使用PyTorch的pack_padded_sequence,但那个API对新手不太友好,而且容易在batch维度上犯迷糊。我的建议是先把mask方案写清楚,体验过“为什么需要mask”之后,再考虑packed sequence的优化。

处理中文文本时还要注意编码问题。Windows下经常遇到GBK编码的文本,读取时建议显式指定encoding="utf-8",否则会报乱码或解码错误。另外,如果语料里包含大量空格、制表符、特殊符号,最好清洗时统一处理,避免vocab里冒出一大堆只出现一两次的奇怪字符,导致模型花精力去拟合噪声。

3. 模型构建:用LSTM搭一个能跑的语言模型

3.1 三段式结构:Embedding + LSTM + Linear

语言模型的经典实现是三段式:先把token id映射成稠密向量,再用LSTM消化序列信息,最后通过一个线性层把隐状态对应到词表大小的分数。这三段各司其职:Embedding把离散符号变成模型可以操作的连续向量,LSTM负责把历史信息压缩进隐状态,Linear负责把隐状态“翻译”成下一个token的候选得分。

用代码写出来非常短:

import torch.nn as nn class CharLM(nn.Module): def __init__(self, vocab_size, emb_dim=128, hidden_size=256, num_layers=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, emb_dim) self.lstm = nn.LSTM( emb_dim, hidden_size, num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0 ) self.fc = nn.Linear(hidden_size, vocab_size) def forward(self, x, state=None): emb = self.embedding(x) out, state = self.lstm(emb, state) logits = self.fc(out) return logits, state

有几个细节容易踩坑。第一,nn.LSTM的dropout参数必须在num_layers > 1时才生效,如果只有一个隐藏层还硬传dropout,会直接报错。第二,我设置了batch_first=True,这意味着输入形状是(batch_size, seq_len, emb_dim),而不是PyTorch默认的(seq_len, batch_size, emb_dim)。两个都能用,但新手最怕的就是自己设了batch_first=True,写前向传播时又忘记,把维度搞反。第三,state参数允许我们在外部控制初始隐状态,后面讲到跨batch状态传递时会用到。

Embedding层看起来像一张查找表,但它真正厉害的地方在于学习出语义相关的向量表示。训练好的embedding里,“猫”和“狗”的向量距离会比“猫”和“汽车”近得多。这也是神经网络语言模型比N元语法有优势的底层原因。

3.2 参数到底怎么定,给一组可复现的配置

模型参数不是一个玄学问题,尤其是在入门阶段。我常用的配置如下:

参数字符级推荐值说明
seq_len32一次看的上下文长度
batch_size64显存有限就调小
emb_dim128字符级不需要太大
hidden_size256隐状态维度
num_layers2层数越深越容易过拟合
dropout0.5只在非最后一层生效
learning_rate1e-3Adam常用起始值
梯度裁剪阈值5.0防止梯度爆炸

这个配置在几十万字的字符级语料上表现是稳定且能收敛的。如果数据量很小,比如只有几万字,我建议把hidden_size降到128、num_layers降到1,否则很容易出现训练loss一直降、验证loss却飙高的过拟合现象。数据量越大,模型复杂度才能跟着上去。

关于hidden_size和emb_dim的关系,并没有必须遵守的公式。hidden_size代表模型记住上下文的能力上限,emb_dim代表每个符号的表示丰富度。按经验,hidden_size取emb_dim的1到2倍是个平衡点,太大收益递减,还拖慢训练。

3.3 损失函数:为什么不用手动加Softmax

这个任务的标准损失函数是交叉熵。PyTorch里的nn.CrossEntropyLoss是一个非常特殊的组合:它在内部把log_softmax和nn.NLLLoss合在了一起,所以你喂给它的必须是未经过Softmax的logits,而不是概率值。

为什么这么设计?因为直接在数值上计算log(softmax(logits))会涉及到指数运算,容易溢出或损失精度。PyTorch内部用log-sum-exp技巧做数值稳定,同时省掉了一次前向的Softmax计算,速度更快。

前向传播时,LSTM的输出形状是(batch_size, seq_len, hidden_size),而nn.CrossEntropyLoss期望输入形状是(N, C),其中C是类别数,也就是vocab_size。所以需要把输出reshape成二维:

logits, _ = model(x) B, T, V = logits.shape loss = criterion(logits.reshape(B * T, V), y.reshape(B * T))

target的形状也必须是一维的(B * T,),每个位置都是0到vocab_size-1之间的整数。这个reshape操作看似机械,但新手经常在这里报shape不匹配的错。出现这种错误时,最高效的排查方式是打印出logits.shape和target.shape,一眼就能看出问题在哪。

4. 训练过程:让模型一步步学会预测

4.1 训练循环的关键细节

整个训练循环并不复杂,难点在于理解每个步骤为什么存在。直接看代码:

import torch.optim as optim from torch.utils.data import DataLoader def train_epoch(model, loader, criterion, optimizer, clip=5.0): model.train() total_loss = 0.0 total_tokens = 0 for x, y in loader: optimizer.zero_grad() logits, _ = model(x) loss = criterion(logits.reshape(-1, logits.shape[-1]), y.reshape(-1)) loss.backward() # 梯度裁剪:防止LSTM训练时梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() total_loss += loss.item() * y.numel() total_tokens += y.numel() return total_loss / total_tokens

我来说几个重点。第一,optimizer.zero_grad()必须放在每次backward之前,因为PyTorch默认会累积梯度,不清理的话梯度会被叠加,loss曲线会像过山车一样震荡。第二,y.numel()统计的是这个batch里所有token数量,用它做加权平均,比简单地对每个batch的loss取平均更准确,因为最后一个batch可能不是满的。

第三,也是最容易忽略的:如果你不想让模型记住训练文本里所有片段的顺序,DataLoader里的shuffle=True很重要。切片式Dataset的好处在这里体现得很明显,因为每个样本块是独立且完整的一段上下文,shuffle样本块不会破坏块内部的序列结构,还能防止模型学到“第3个batch后面一定是第4个batch”这种假规律。

关于隐状态,我的建议是入门阶段先不要搞跨batch传递。每一次forward传入state=None,让LSTM内部自动初始化为全零向量即可。这样每个样本都是从零开始记忆,虽然理论上无法学习超过seq_len长度的依赖,但对任务五来说完全够用。如果你想尝试让hidden state跨batch延续,必须使用state.detach()截断反向传播的计算图,否则训练到第二个batch时,backward会一路回溯到第一个batch的开始,显存直接爆炸。

4.2 衡量模型好坏的标准:Loss转Perplexity

训练过程中我们盯着loss,但要跟别人交流或判断模型质量时,更常用的指标是困惑度Perplexity(PPL)。计算方式极其简单:

ppl = torch.exp(loss)

如果loss是2.5,PPL大约是12.2;loss降到1.0,PPL就是2.72。PPL可以粗略理解为“模型在每个位置平均认为有多少个合理候选项”。如果词表有1000个字符,而模型完全瞎猜,PPL就是1000;如果模型学得不错,PPL会降到10以内,说明它通常把概率集中在少数几个合理的候选上。

我在字符级中文语料上跑这个任务时,loss大概从5.0左右起步,十几个epoch后能降到1.2到2.0之间,对应PPL大约3到7。这个水平已经能生成看起来有点章法的文本了:字与字之间的搭配大体合理,但整句逻辑不能细究。如果你是词级模型,PPL数值会大得多,几十到几百都很正常,不要拿字符级的PPL去要求它。

需要注意的是,loss和PPL是“训练视角”的指标,它们衡量的是模型预测能力和确定性,而不是文本可读性。一个PPL很低的模型,生成出来的内容可能是一个词反复重复,因为“重复”在概率上很安全。所以在评估模型时,千万不要只看曲线,一定要实际跑几段生成结果。

4.3 训练中常见的翻车现场

我在这个任务上见过的典型问题,基本是这三类。

第一类是不收敛。loss一直在高位震荡,或者干脆变成NaN。最常见的原因是学习率太大。字符级任务用Adam,learning rate从1e-3起步是比较稳的,超过5e-3就容易炸。另一个重要原因是梯度爆炸,LSTM在长序列上梯度范数可能变得非常大,导致参数更新一步跨到天边去。解决方案就是我们前面写的梯度裁剪clip_grad_norm_,这是训练RNN的标配动作。

第二类是过拟合。训练loss降得很好,但验证loss不降,甚至回升。小语料上这种情况非常常见。解决方向是降低模型复杂度,把hidden_size调小,dropout调高,或者增加数据量。切记不要在过拟合时硬加更多层LSTM,那样只会雪上加霜。

第三类是batch维度错乱。很多人会把batch_first记错,导致模型接收的输入形状变成(seq_len, batch)。排查方法很简单,在forward里打印一下每一步的形状。形状错误通常会在第一次forward就暴露,不会等到训练后期,所以这类问题其实最好修。

5. 评估与实验:PPL不是终点,生成结果才算数

5.1 写一个文本生成函数,观察模型真实水平

训练结束后,我最喜欢做的一件事就是写个generate函数,用种子文本让模型自己续写。生成时的核心是:输入保持长度为seq_len,每次预测完一个字符,就把这个字符拼到输入末尾,同时丢弃输入最前面的一个字符。

def generate(model, seed, stoi, itos, seq_len=32, num_chars=50, temperature=0.8): model.eval() # 种子长度不足时补齐,超过时截断 seed = seed[-seq_len:] inputs = torch.tensor([stoi[c] for c in seed]).unsqueeze(0) # (1, seq_len) generated = list(seed) with torch.no_grad(): for _ in range(num_chars): logits, _ = model(inputs) logits = logits[0, -1, :] / temperature probs = torch.softmax(logits, dim=-1) idx = torch.multinomial(probs, num_samples=1).item() generated.append(itos[idx]) # 丢掉最左边一个字符,把新字符追加到末尾 next_input = torch.tensor([[idx]]) inputs = torch.cat([inputs[:, 1:], next_input], dim=1) return "".join(generated)

这里的temperature是生成质量的关键旋钮。temperature越低,分布越尖锐,模型更倾向于选最高概率的字符,生成结果偏向保守和重复;temperature越高,分布越平滑,随机性增强,结果可能更有创意,但也更容易语无伦次。我通常从0.8开始试,如果发现生成内容不断循环,就往低调;如果内容杂乱无章,就往高调。

在我一个小语料实验里,用“春天来了”作为种子,模型生成的片段大概是“春天来了,桃花开了,小溪的水流声也缓缓传来”。这句话看着还行,但实际更多时候生成的句子会有重复字或者半通不通的组合,比如“我想想想起那个”这种。这都很正常,说明模型学到的是局部搭配习惯,而不是真正的世界知识和逻辑。

5.2 动手做几组对比实验,理解模型变化

跑通之后,别急着收工。我建议做几组简单的对照实验,这是这个任务最有价值的部分。我自己尝试过这三种配置:

模型配置相对训练时间验证集PPL生成观感
LSTM单层,hidden=1281x约7.2能成句,重复较多
LSTM双层,hidden=2562.1x约4.8句子更顺,搭配范围更广
GRU双层,hidden=2561.8x约5.1收敛略快,效果接近

注意这些数字只是我手头某份中文语料上的相对趋势,不是通用结论。通过这个对比,你能直观感受到一个重要的原则:更深的网络确实能学到更复杂的模式,但训练时间会显著增加,收益却边际递减。GRU相比LSTM参数更少、训练更快,效果上差距不大。这个认识对以后选型很有帮助。

如果你的任务要求写报告,这组实验就是最好的素材。不要只贴一张训练曲线图,要把“模型结构变化带来什么影响”解释出来,这比一个好看的PPL数字更能体现你理解了序列模型。

5.3 常见问题排查速查表

把我在这个任务里踩过和见别人踩过的问题整理成一张表,方便你遇到时快速对照。

现象可能原因处理方式
loss是NaN学习率太大;文本里有异常字符调低lr,检查清洗规则
生成内容不断重复同一个字/词temperature太低;hidden太大导致过拟合调高temperature,加入dropout
训练loss不下降输入和目标错位;没有梯度裁剪检查Dataset的x/y对齐,加clip_grad_norm_
显存随训练无限增长隐状态跨batch没有detach在传给下一个batch前对state做detach()
验证PPL远高于训练PPL模型过大、数据太少减小hidden_size,增大dropout
生成结果出现词表外的token编码或者vocab不一致确保训练和生成的stoi/itos同一份
DataLoader输出形状不对忘记batch_first=True打印x.shape逐一核对

这张表里最想强调的还是“注意对齐”和“注意detach”。这两个错误在RNN项目中出现的频率远比你想象得高。每当你怀疑“是模型问题还是代码问题”时,先打印输入输出形状,再检查loss在第一个batch上是否合理。前者可以排除数据流问题,后者可以确认模型没有结构性缺陷。

6. 后续的扩展方向与个人踩坑心得

这个任务跑通之后,你可以顺着几条路继续深入。一个自然的方向是把字符级换成词级,体验BPE、padding、mask这些在工程中更常碰到的处理。另一个方向是把LSTM换成Transformer encoder,用causal attention来实现语言模型,虽然代码更复杂,但能帮你彻底理解现在大模型的基本单元。还有一个方向是加入细粒度的评估,比如计算生成文本的重复率、多样性,而不只是看PPL。

我个人做这类项目时,有一个很深的体会:你不需要一次就把所有东西做到完美,但每次改动一定要能解释动机。比如改成双层LSTM之前,先想清楚“当前模型是欠拟合还是过拟合”;调整temperature之前,先判断“生成的重复是因为分布太尖还是模型容量太小”。用一个个小实验把假设验证掉,比盲目堆参数有用得多。

最后再分享一个我自己早期忽略的细节:训练过程中一定要定期保存中间模型,最好每个epoch都存一次model.state_dict()。你以为自己在做调参,结果一次断电或者一次参数调崩,几个小时就白跑了。加上这行代码,会让整个实验流程从容很多。这个任务不难,但它是你第一次完整地从头实现“序列的预测”,把这条链路彻底吃透,后面接触任何生成模型都会轻松不少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询