简介面向希望入门自然语言处理与深度学习文本生成的开发者该资源用RNN构建语言模型实现周杰伦风格歌词的自动生成也支持更换诗歌等语料训练出相应风格。压缩包共3个文件包含2个Python脚本和1个txt语料整体仅72KB轻量易用。已有1933人学习下载。资源完整呈现文本生成流程从数据清洗、建立词汇表、序列填充与编码到嵌入层、RNN层、全连接层搭建以及交叉熵损失与Adam优化策略配套的辅助函数模块、主程序脚本和原始歌词文本可让读者直接运行并调试理解RNN隐藏状态与门控机制在序列建模中的作用是课程实验或个人项目的有益参考。1. RNN 实现文本生成先跑通“记住上下文”再谈效果RNN 实现文本生成本质上解决的是一个序列预测问题给模型一段历史文本让它根据前面出现过的字符推断下一个字符。原理说起来就三步——读序列、维护隐状态、输出下一个字符的概率分布但真正落地时数据怎么切、序列怎么构造、采样温度设多少都会把你引向完全不同的结果。上手阶段最适合拿 1MB 左右的小说语料做字符级生成数据噪声少显存压力也小。这篇笔记适合想本地跑通一个最小完整生成系统的人新手能照着把代码跑起来熟手可以重点看训练稳定性和采样部分。我会尽量把每一步拆到可复现也把我实际踩过的一些坑标出来。2. 数据预处理把语料切成字符序列文本生成项目里最容易翻车的地方往往不在模型而在数据。模型拿到手的必须是数字张量而且输入和输出的关系要对齐否则训练根本跑不起来。这一章解决从原始文本到可训练样本的转换问题。2.1 字符级与词级拿不准就先选字符级文本生成有两大常用粒度按词切分和按字符切分。按词切分要先接分词器中文场景还有分词误差扩散到下游的风险词表动辄几万生成单位是整词句子结构更容易把控。按字符切分则简单得多词表就是语料里出现过的全部汉字和标点通常在几千规模模型每一步只预测一个字符。字符级最大好处是词表小、模型轻、训练快而且每个字符都是独立决策采样随机性更可控。缺点也明显纯字符模型很难捕捉词级别的长程语义生成文本在长句上容易出现“句法漂移”。所以我的建议是第一版直接用字符级把全流程跑通等想提升生成质量时再迭代到词级。一上来就做词级等于同时面对分词器的坑和模型调参的坑排查时会很被动。2.2 构建字符映射表清洗比想象中重要拿到原始文本后第一步不是急着建映射而是先做一致性清洗。文本里混杂的换行符、连续空格和全角半角标点都会成为词表中的独立字符。极端情况下同一个词会因为换行位置不同而对应两个不同的空白字符白白占掉词表位置还会干扰训练。def load_text(path): with open(path, r, encodingutf-8) as f: text f.read() # 统一空白把换行符和连续空格都折叠成单个空格 text .join(text.split()) return text text load_text(sample.txt) chars sorted(set(text)) char_to_id {c: i for i, c in enumerate(chars)} id_to_char {i: c for c, i in char_to_id.items()} print(文本长度:, len(text)) print(字符表大小:, len(chars))读入后第一个要检查的指纹就是“字符表大小”。如果字符表里出现大量你从没见过的空白符或特殊符号说明清洗没有生效。char_to_id负责把字符转索引id_to_char负责把索引还原成字符这两个字典在训练和生成阶段都要用建议紧挨着定义避免后面几百行代码里来回找。2.3 滑动窗口构造样本输入和输出错开一位模型不能直接吃整篇文本它需要固定长度的序列作为训练样本。常见做法是用长度为 64 或 128 的滑动窗口在文本上平移切分。窗口太小学不到长距离依赖窗口太大样本数变少、训练时间上升、显存占用变大。这里选取 64 作为初始值对 1MB 级语料是个稳妥起点。seq_len 64 step 32 samples [] for i in range(0, len(text) - seq_len - 1, step): input_seq text[i: i seq_len] target_seq text[i 1: i seq_len 1] samples.append((input_seq, target_seq)) print(样本数:, len(samples))关键点在于target_seq等于input_seq整体向右平移一个字符。这样的话模型看到位置 k 的输入字符目标却是位置 k1 的字符RNN 才能学到“预测下一个字符”。step是滑动步长这里取 32小于窗口长度 64相邻样本会有一半重叠重叠让同一个字符在不同上下文偏移下反复出现相当于变相扩增数据。如果语料很大也可以把step设成等于seq_len样本无重叠训练省时间代价是数据多样性略降。2.4 构造批数据固定长度张量才是模型要的样本已经是一个个字符串对接下来要转成模型能吃的整数张量。常见做法是每次随机抽取一个 batch把字符串序列转换成索引序列再包成 PyTorch 张量。import random import torch def make_batch(samples, batch_size128): random.shuffle(samples) for i in range(0, len(samples), batch_size): batch samples[i: i batch_size] x [[char_to_id[c] for c in input_seq] for input_seq, _ in batch] y [[char_to_id[c] for c in target_seq] for _, target_seq in batch] yield torch.tensor(x), torch.tensor(y)这里batch_size取 128配合 64 的序列长度单卡显存压力很小。随机打乱是为了避免相邻样本一起进入同一个 batch否则模型容易沿着文本顺序“背答案”而不是学到泛化规律。如果你的语料有几十 MB可以把batch_size提到 256 甚至 512但要注意后续模型训练时显存是否吃得消。3. 搭建字符级 RNN从 Embedding 到输出概率模型这一层不需要堆花哨结构一个两层 LSTM 加一个输出层就足够跑通。LSTM 是 RNN 家族里最常用的成员它在原始 RNN 的基础上加了门控机制能更好地抑制梯度消失对文本这种长序列更友好。这里先讲清楚输入输出形状再给完整模型定义。3.1 输入表示用 Embedding 而不是 One-Hot严格来说字符级模型可以直接用 one-hot 向量作为输入。词表 5000 就表示每个字符是维度 5000 的向量只有当前字符位置是 1其他都是 0。这种做法数学上没问题但不经济绝大部分维度都浪费在 0 上而且输入矩阵会非常稀疏。常见做法是加一个 Embedding 层它本质上是一张可学习的查表矩阵行数是词表大小列数是嵌入维度。每来一个字符索引就从表里取一行稠密向量作为模型输入。词表只有几千时Embedding 参数量完全可控它也是后续序列建模任务最通用的数据表示。所以选型建议很明确用 Embedding不用 one-hot两者效果差不多但前者更省内存、训练更快。3.2 模型结构定义两层 LSTM 加全连接输出下面是一个可以直接复用的 PyTorch 实现。建议先照抄跑通再根据任务调参。import torch.nn as nn class CharRNN(nn.Module): def __init__(self, vocab_size, hidden_size128, num_layers2): super().__init__() self.hidden_size hidden_size self.num_layers num_layers self.embedding nn.Embedding(vocab_size, hidden_size) self.lstm nn.LSTM(hidden_size, hidden_size, num_layersnum_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, hiddenNone): emb self.embedding(x) # (batch, seq_len, hidden_size) output, hidden self.lstm(emb, hidden) # output 同维度 logits self.fc(output) # (batch, seq_len, vocab_size) return logits, hiddenvocab_size就是字符表大小代码里对应len(chars)。hidden_size控制隐状态维度128 到 256 是文本生成比较常见的中位区间太小记不住上下文太大在小语料上容易过拟合。num_layers是 LSTM 层数两层比一层更容易捕获稍微长一点的依赖但三层以上对数据量要求骤增1MB 级别的语料不建议超过两层。batch_firstTrue让输入张量第一维是 batch这个参数很多人容易漏建议固定成 True避免前后维度理解混乱。模型最终输出的是每个位置、每个字符的一个原始得分也就是 logits。它的形状是(batch, seq_len, vocab_size)。训练时要把它和每个位置的真实字符索引对齐因为这里返回的是整个序列每个位置的预测而不是只返回最后一个位置的预测。3.3 前向传播验证维度对不上就先别训刚写完模型别急着训练先跑一次前向传播确认输入输出形状符合预期。这一步能帮你提前发现绝大多数的维度错位问题。model CharRNN(vocab_sizelen(chars), hidden_size128, num_layers2) x torch.randint(0, len(chars), (4, 64)) # 模拟 4 条长度为 64 的样本 logits, hidden model(x) print(logits.shape) # 期望输出 torch.Size([4, 64, len(chars)])如果形状不是(4, 64, vocab_size)说明模型定义里有维度不一致的地方。检查batch_first设置和fc层输入维度是否等于hidden_size。确认无误后再把模型的 forward 和训练循环接起来。4. 训练稳定性梯度裁剪和损失曲线才是重头戏文本生成训练和普通分类有个明显区别分类任务只看最后一个输出文本生成要计算整个序列每个位置的损失。交叉熵损失天然支持维度展平后对全序列计算这个特性可以直接拿来用。但真正让模型训得稳的是梯度裁剪和损失观察这两件事。4.1 损失函数与优化器基线损失函数直接用 PyTorch 的CrossEntropyLoss。注意一个常见误区不能直接把(batch, seq_len, vocab_size)的 logits 和(batch, seq_len)的标签丢进去前者需要先展平成二维。criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience2 ) def train_epoch(model, samples, batch_size128): model.train() total_loss 0 batches 0 for x, y in make_batch(samples, batch_size): optimizer.zero_grad() logits, _ model(x) loss criterion(logits.view(-1, len(chars)), y.view(-1)) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() batches 1 return total_loss / batcheslogits.view(-1, len(chars))把(batch, seq_len, vocab_size)压成(batch*seq_len, vocab_size)y.view(-1)对应压成(batch*seq_len,)这样每个字符位置都参与损失计算。优化器选 Adam 是比较稳的基线选择它对学习率不那么敏感适合小规模实验。初始学习率 0.001 是字符级文本生成里最常用的起点偏大或偏小后面讲。4.2 梯度裁剪RNN 训练的安全带RNN 反向传播时梯度会沿着时间步连乘。一旦某个位置的权重偏大梯度就会被指数级放大造成梯度爆炸。典型症状是训练过程中 loss 突然变成 NaN或者从某个 epoch 开始剧烈跳动。梯度裁剪的作用就是给所有参数的梯度范数设一个上限超过就等比缩放。nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)max_norm是梯度范数上限常规取值在 1.0 到 5.0 之间。我一般会把max_norm和 loss 表现挂钩loss 容易跳 NaN 就调到 1.0 甚至 0.5loss 平稳下降就可以维持 5.0给模型更大的更新空间。梯度裁剪必须在backward()之后、step()之前调用顺序写错等于没做保护。4.3 观察损失曲线而不是关心打印次数很多同学训练时只关心打印了多少个 epoch却不看 loss 的走势。文本生成项目的最佳实践是先用小语料加少量 epoch 快速跑通确认 loss 在持续下降再决定要不要扩展数据规模。这里给一个带简单早停的训练循环best_loss float(inf) for epoch in range(15): avg_loss train_epoch(model, samples) scheduler.step(avg_loss) if avg_loss best_loss: best_loss avg_loss torch.save(model.state_dict(), best_model.pt) print(fepoch{epoch1} loss{avg_loss:.4f} flr{optimizer.param_groups[0][lr]:.5f}) if avg_loss best_loss * 1.1: print(loss 连续上升考虑调低学习率或提前停止) breakbest_model.pt保存的是当前验证表现最好的模型参数防止后续训练过拟合后丢失最佳版本。ReduceLROnPlateau会在 loss 连续多个 epoch 不下降时自动把学习率减半factor0.5表示减半patience2表示容忍两个 epoch 不降再动作。这个调度器比固定衰减更省心因为你不必手工判断“现在该不该降学习率”。5. 避坑排查文本生成最常见的四个翻车现场这一章全部来自血泪经验。文本生成本身可用性波动大同一个模型、不同参数生成结果可能天差地别。下面按“现象 → 原因 → 解决”列四个高频问题。5.1 loss 从第一步就不动甚至微涨刚开始训练时模型输出接近均匀分布交叉熵初始值应该在log(vocab_size)附近。比如词表大小 5000初始 loss 约 8.5。如果你看到的初始 loss 明显低于这个值说明模型一开始就偏向某个字符往往是数据分布出了问题如果 loss 完全不动也先别急着调模型架构。原因通常有三类一是数据里混入了异常字符导致某些字符在词表里存在但样本里几乎没有二是学习率太高每一步更新都跳过最优区域loss 在小范围内震荡三是 batch 太小随机波动盖过了真实下降趋势。解决顺序是先打印初始 loss 和len(chars)验证初始 loss 接近log(len(chars))再把学习率从 0.001 降到 0.0005固定随机种子重跑一轮如果数据量只有几十 KB把batch_size提到 256观察两轮 epoch 是否开始下降。5.2 生成文本从头到尾重复同一组短语这是文本生成最常见的翻车现场。模型学到了一个低风险策略总是输出高频字符或高频短语组合因为这样损失最小。如果你用argmax采样也就是每次都取概率最大的字符一旦某个高频词出现后面每一步都在强化前文结果就是“的的的”或者“我不能我不能我不能”这种死循环。解决方法是放弃argmax改用温度采样。温度参数控制概率分布的尖锐程度它会让采样在“保守”和“发散”之间取得平衡。下面这段代码可以直接用在推理阶段def sample_from_logits(logits, temperature0.8): if temperature 0: return torch.argmax(logits, dim-1).item() scaled logits / temperature probs torch.softmax(scaled, dim-1) return torch.multinomial(probs, 1).item()temperature0.8是文本生成里比较常用的起点。温度越低概率分布越尖锐生成越保守温度越高分布越平缓随机性越大。如果你发现模型已经陷入重复先把温度调到 1.0 甚至 1.2看输出是否恢复多样性再逐步回落。5.3 生成结果里出现模型没见过的字符按理说模型只会从id_to_char里选字符不该出现未知符号。一旦出现基本都是索引错位问题。原因在于字符表没对齐训练阶段用的char_to_id和生成阶段用的id_to_char来自不同的chars集合。比如你训练时用了清洗后的文本生成时却拿原始文本重新建了字符表两个表顺序不同同一个索引对应不同字符。解决方式是加一道强校验在训练前和生成前各跑一次assert len(char_to_id) len(id_to_char) missing set(id_to_char.keys()) - set(char_to_id.values()) assert not missing, f字符表不一致: {missing}同时检查解码逻辑确认用的是id_to_char[token_index]而不是直接把索引当成字符输出。这个错误排查起来很隐蔽因为错误字符只在生成长文本的中后段出现靠肉眼扫一眼很难发现。5.4 训练时显存或内存溢出RNN 需要沿时间步展开保存每个时刻的隐状态显存开销和序列长度近似成线性。当你把seq_len加到 128 或 256再配合稍大的batch_size溢出非常正常。解决方法是先降序列长度再降 batch。把seq_len从 128 降到 64然后看占用再不行把batch_size从 128 降到 32。如果数据量很大又不想牺牲样本数量用梯度累积来模拟更大的 batchaccumulation_steps 4 optimizer.zero_grad() for step, (x, y) in enumerate(make_batch(samples, batch_size32)): logits, _ model(x) loss criterion(logits.view(-1, len(chars)), y.view(-1)) / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() optimizer.zero_grad()这里把每个小 batch 的 loss 除以accumulation_steps是为了让累计梯度总量接近直接跑一个大小为 128 的 batch。梯度裁剪仍然只在实际更新前调用一次位置不能错。这个小技巧能让小显存卡也训大语料。6. 温度采样与生成验证落地前的最后一步6.1 生成主循环把 hidden 传下去才会有“记忆”训练结束后模型要进入推理模式。生成时你给模型一个开头字符串它会逐个字符往后续写。这个过程中最容易犯的错误是每步都把hidden重置为 None那样模型就彻底失去了上下文记忆。def generate(model, start_str, length200, temperature0.8): model.eval() with torch.no_grad(): input_ids [char_to_id[c] for c in start_str] result list(start_str) hidden None for _ in range(length): x torch.tensor([input_ids]) logits, hidden model(x, hidden) last_logits logits[0, -1] # 只取最后一个位置的输出 next_id sample_from_logits(last_logits, temperature) result.append(id_to_char[next_id]) input_ids input_ids[1:] [next_id] return .join(result)每次把上一轮的hidden传回模型是 RNN 生成的核心习惯。input_ids input_ids[1:] [next_id]则是维护一个固定长度为 64 的窗口新生成的字符会顶掉窗口最前面的旧字符。这里的length是你要生成的字符数200 个字符足够肉眼评估模型质量。6.2 采样参数与序列长度怎么配合温度参数是文本生成里最“玄学”的一块不同任务最舒服的温度差别很大。我自己在字符级模型上积累的经验是写诗和一些要求结构工整的文本温度落在 0.4 到 0.6 之间效果好普通故事续写0.7 到 0.9 更自然如果你想看模型“放飞”可以试 1.2 以上但这时候文本通常开始跑偏。序列长度对生成质量的影响同样明显。seq_len64时模型只在 64 个字符的窗口内保持上下文生成长文时会忘记前面讲过的主角名称。把seq_len提到 128模型记得更远但训练显存也会相应上涨。如果训练阶段用 64 能训稳我建议保留 64 训练、推理时只扩展长度而不是硬把训练序列拉长。6.3 生成质量的三条验证习惯文本生成没有统一指标但可以用低成本手段做质量把关。第一个习惯是固定随机种子同一模型同一个开头跑 5 遍。如果 5 遍输出高度相似说明温度偏低模型陷入保守如果 5 遍输出完全没共同点说明温度偏高模型在胡写。第二个习惯是检查重复片段。把生成文本切成 10 字符一组的块如果连续出现两组完全相同的块说明模型进入了循环状态。这时把温度调高 0.1 到 0.2 再生成一次通常能打破循环。第三个习惯是测试开头约束力。用训练集里出现过的一句话开头生成 100 个字符再换一个完全随机的中文字符串开头。前者要能看到模型沿袭语料风格后者则暴露模型的兜底能力。两者生成质量差异过大说明模型对训练数据记忆过深可能过拟合。最后想说一个习惯温度那条路我自己在字符级生成上栽过好几次总想用 0.2 的低温度让模型显得“聪明”结果出来的全是高频词循环。后来我强迫自己接受一套流程——每次训练完不直接看一次生成结果而是把温度从 0.2、0.5、0.8、1.0 各生成一条短文本放在一起对比用重复率和人工粗读选效果最好的档位。从那以后我每次做文本生成都会强制走一遍这套采样检查先把模型调顺了再谈换更大语料和更深网络这套流程至少帮我减少了一半返工时间。希望帮到你。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站