开题:为什么我用GRU做英文诗歌生成,而不是直接用LSTM或Transformer
前段时候帮一个学弟把毕设课题定在了英文诗歌生成上,模型框架选了GRU。这个方向听起来很文艺,但本质上是典型的时间序列生成任务,属于深度学习在自然语言处理里的经典应用。选GRU不是因为名字好听,而是它在效果、训练成本和实现复杂度之间,性价比非常均衡。如果你正在纠结毕设选型,或者想做一个能跑起来、能演示、能讲清楚原理的深度学习项目,这套基于GRU的诗歌生成系统是非常稳妥的起点。
先把这个项目一句话说清楚:用大量英文诗歌文本训练一个字符级的循环神经网络,模型学到诗歌中字母、单词、标点和换行的内在规律之后,给定一个起始词或字母,就能一个字符一个字符地"续写"出风格接近的英文诗。它解决的问题很直接——如何让机器理解文本的序列依赖关系,并在此基础上完成有结构的创作。
这篇文章不只贴代码,我会把数据预处理、模型结构、训练调参、采样生成这几个环节全部拆开讲,把每一个设计选择背后的原因说清楚,还会把我在实际调试中踩过的坑、答辩时被问到的坑点一并整理出来。项目不大,但五脏俱全,做完它你对RNN系列模型、文本生成流程、PyTorch动手能力都会有质的提升。
1. 项目整体设计与思路拆解
1.1 为什么是GRU,它到底好在哪
先回答一个常见疑问:既然是诗歌生成,为什么不直接用LSTM?或者干脆上Transformer?
要理解这个选择,得先看任务本身的特点。诗歌生成属于典型的序列生成任务,模型需要根据前文预测下一个字符。在这个场景下,文本长度通常在几十到几百个字符之间,依赖关系以短中期为主——一句话内部的语法依赖、相邻行的词语搭配、押韵词的呼应,这些核心关系并没有长到需要跨越上千个时间步才能捕捉。
GRU在这个距离尺度上表现得非常可靠。它和LSTM同属门控循环网络,但结构更精简,只有两个门(更新门和重置门),参数比LSTM少了约四分之一。在数据量不大、算力有限的毕设环境下,这意味着训练更快、过拟合风险更低、调参更容易。更关键的是,GRU在中等长度序列上的效果和LSTM几乎没有差别,但代码实现和梯度传播都更简单,讲原理的时候也更好向答辩老师解释清楚。
Transformer当然也能做,而且做长文本生成的效果通常更好。但它是另一个量级的东西:需要位置编码、多头注意力、LayerNorm、更大的显存、更多的训练数据,以及对学习率和Dropout更敏感的超参调整。用Transformer做诗歌生成,除非你有充足的数据和算力,否则很容易陷入"模型很大、效果很差"的尴尬。对于一个毕设项目来说,用最合适的工具做出完整闭环,远比堆一个豪华模型更有价值。
1.2 系统整体架构
整个系统可以拆成四个模块:数据层、模型层、训练层和生成层。我用一张逻辑图来展开它们的关系:
- 数据层:收集英文诗歌文本,清洗噪音,构建字符表,把每首诗切成长度为
seq_len的字符序列。 - 模型层:字符嵌入层 + 多层GRU + 全连接输出层,输出每个位置上所有候选字符的概率分布。
- 训练层:用交叉熵损失衡量预测误差,通过时间反向传播更新模型参数。
- 生成层:从起始文本出发,用训练好的模型逐步预测下一个字符,重复采样直到满足终止条件。
这四个模块之间是单向依赖的,数据层产出的词表和序列格式直接决定模型层的输入输出维度,训练层的损失函数设计决定模型能否学到有效特征,生成层的采样策略则影响最终诗歌的质量和多样性。
1.3 项目适合谁做,能做到什么程度
这个项目的定位非常适合两类人:一是深度学习方向但还没有完整做过NLP项目的本科生,做完能完整掌握RNN系列模型的前向传播、反向传播、序列建模和文本生成全流程;二是需要快速出成果、且成果要可演示的研究生,因为生成效果直观,随便输入一个词就能得到一首诗,展示性非常强。
难度上,如果一切顺利,从零搭建到训练出能用的模型,大约需要3到5天;如果涉及调参优化和效果打磨,整体一周到一个半月很正常。数据量不需要很大,几千首诗就足够出效果,因为字符级模型对数据量的需求远低于词级模型。
2. 数据准备与预处理:诗歌能不能学好,六成功底在这
2.1 数据集选择与清洗
做诗歌生成,数据源不算难找。公开的英文诗歌语料、开源的诗集文本、甚至直接从诗歌网站上抓取的内容都可以用。我的建议是优先找已经整理好格式的纯文本语料,比如每首诗以标题开头、正文按行分列的格式,这样可以省掉大量清洗时间。
拿到原始文本后,第一件事就是清洗。常见的污染源包括:HTML标签残留、乱码字符、多余的空格和空行、非诗歌的说明性文字。清洗策略我建议按这个优先级处理:
- 移除所有非ASCII字符(英文诗歌正常只包含字母、数字、常见标点和换行)。
- 将所有空白字符统一为普通空格,并压缩连续空格。
- 保留换行符,因为诗歌的行结构本身是语义的一部分,不能像普通文本那样把换行全删掉。
- 过滤掉长度异常的文本块(比如单行超过120个字符的,大概率不是诗歌正文而是散文注释)。
- 统一字母为小写,降低词表大小,减少模型学拼写变体的负担。
清洗完的数据需要切成两份:训练集和验证集。验证集不要太大,几百首诗就够,用于监控训练过程中是否过拟合。注意切分时要按诗为单位切,不能把同一首诗一部分放训练集一部分放验证集,否则验证集就失去意义了。
2.2 字符级 vs 词级,为什么我选了字符级
这是这个项目最重要的一个设计决策。英文诗歌生成可以选择按词为单位建模,也可以按字符为单位建模,两者各有优劣。
- 词级模型的输出空间是词表大小,通常至少几千到几万。优点是一步能生成一个有意义的词,语法上更通顺;缺点是词表太大导致训练慢,而且遇到生僻词或新词(OOV)就无能为力。
- 字符级模型的输出空间只包含字母、数字、标点和少量控制符,通常不超过100个字符。优点是词表小、训练快、永远不会遇到OOV,而且天生能学到单词内部的拼写规律;缺点是生成速度是逐字符的,且需要更多时间步才能积累出足够的长程依赖。
诗歌生成我用字符级,原因很实际:诗歌本身对词汇新颖度要求高,生僻词和自造词在诗歌中反而是特色,词级模型的词表限制会扼杀这种可能性;同时,英文诗歌的韵律和押韵本质上是"音素模式",而音素在字符层面就有很强的规律性,比如以"ing"结尾的词成群出现、以"ight"结尾的词常被用来押韵。字符级模型能更敏锐地捕捉这些模式。
2.3 文本序列化与训练数据构造
数据清洗完成后,进入序列化环节。步骤是这样的:
- 遍历全部语料,统计每个字符出现的次数,构建"字符→索引"和"索引→字符"两个映射表。
- 设定序列长度
seq_len,我试下来常用的值是 40 到 80。这个值决定了每个训练样本包含多少个字符。 - 将语料切成等长的输入段,每个输入段
x是字符序列的前seq_len-1个字符,对应的目标序列y是后seq_len-1个字符,整体错开一位。
举个例子:假设seq_len=5,文本是"hello world",那么一个训练样本是x="hello",y="ello "。模型看到h时要去预测e,看到he时要预测l,这样逐步推进的学习过程,就是RNN序列建模的核心形态。
有一个容易被忽略的技术细节:训练样本之间是有重叠的。切分时我采用滑动窗口而不是固定分块,窗口每移动一步就产生一个新样本。这样做的好处是数据利用率极高,几千首诗可以轻松切出几十万个训练样本,对RNN这种数据饥饿型模型非常友好。
3. 核心模型实现:GRU网络的PyTorch完整搭建
3.1 网络结构逐层拆解
模型结构是经典的"Embedding + RNN + Linear"三段式,但每一层都有值得细说的设计考量。
Embedding层:把每个字符的索引(一个整数)映射为稠密向量。比如字符索引为 5,embedding矩阵的形状是[vocab_size, embed_dim],取第 5 行就得到这个字符的向量表示。这一层是端到端可训练的,模型会在训练中自动调整每个字符的向量方向,让语义相近的字符(比如元音字母们、常见标点们)在向量空间中靠近。
GRU层:核心计算单元。GRU内部的数学逻辑可以这么理解——它维护一个隐藏状态h_t,每输入一个新字符的向量x_t,就结合上一个隐藏状态h_{t-1},计算出当前步的隐藏状态h_t。这里面两个关键的"门"起了决定性作用:
- 重置门
r_t:决定"我之前看到的历史中,有多少需要被忘记"。类似你做阅读理解时发现上一句是废话,选择性地忽略它。 - 更新门
z_t:决定"多少旧信息要保留,多少新信息要加入"。相当于新旧知识的配比调节旋钮。
这两个门配合,使GRU能有选择地记住文本的长期依赖——比如诗歌前半段提到的某个意象,到后半段以押韵词形式复现时,GRU仍然可以维持相关信息。
全连接输出层:GRU层输出的隐藏状态,映射到词表大小上,得到每个字符的得分(logits),再经过softmax变成概率分布。
3.2 为什么参数这么设计
我用的是embed_dim=128、hidden_size=256、num_layers=2。这个参数组合不是随便定的,背后有明确的权衡:
embed_dim=128:考虑到字符级建模的输入空间本来就小(约90个字符),128维的向量表达能力已经足够充裕。再大比如512维,不仅参数暴增,还可能过拟合。hidden_size=256:这是"效果"和"速度"的平衡点。诗歌这种长度适中的文本,256维的隐藏状态够用了。太大容易记住训练集中的原文片段,生成时变成"复读机";太小则表达能力不足,生成内容语义混乱。num_layers=2:一层GRU是"看当前字符,结合上文推测下文";两层GRU则上一层提取局部模式,下一层组合成更高层的语义。三层的收益在这个数据规模下非常有限,训练还明显变慢,所以我最终定两层。
来算一下模型总参数量,这组数据答辩时经常被问到。假设词表大小V、嵌入维度E=128、隐藏维度H=256:
- Embedding层参数 =
V × E,V约为95(含字母、数字、标点和填充符),就是 95×128 = 12160。 - GRU层的参数公式是
3 × (H × H + H × E + H × 2)。为什么是3?因为GRU内部有三个门控权重矩阵(重置门、更新门和候选隐藏状态),每个矩阵对应H×H + H×E + H×2个参数。代入计算:3 × (256×256 + 256×128 + 256×2) = 3 × 98560 = 295680。两层GRU合计约59万。 - 输出层参数 =
H × V = 256 × 95 = 24320。
总参数量大约45万(单层GRU版本)到60万(双层GRU版本)。这是一个非常轻量级的模型,单张普通显卡甚至CPU都能在几分钟内完成一个epoch的训练。这也是选GRU做毕设的一大优势——你不需要昂贵的硬件,连Colab免费版都绰绰有余。
3.3 PyTorch核心代码实现
下面给出可直接运行的模型定义代码。这个实现经过我在实际项目中的调整,去掉了不必要的装饰性代码,保留了训练和推理都需要的关键部分:
import torch import torch.nn as nn class CharGRU(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_size=256, num_layers=2, dropout=0.2): super().__init__() self.hidden_size = hidden_size self.num_layers = num_layers # 字符嵌入:查表得到每个字符的稠密向量 self.embedding = nn.Embedding(vocab_size, embed_dim) # GRU核心网络 self.gru = nn.GRU(embed_dim, hidden_size, num_layers, batch_first=True, dropout=dropout) # 输出层:隐藏状态 -> 词表概率分布 self.fc = nn.Linear(hidden_size, vocab_size) def forward(self, x, hidden=None): # x shape: [batch, seq_len] embeds = self.embedding(x) # [batch, seq_len, embed_dim] output, hidden = self.gru(embeds, hidden) # output: [batch, seq_len, hidden] logits = self.fc(output) # [batch, seq_len, vocab] return logits, hidden def init_hidden(self, batch_size, device): # 初始化隐藏状态为全零 return torch.zeros(self.num_layers, batch_size, self.hidden_size, device=device)训练循环的核心逻辑如下。注意几个工程细节:数据转换成PyTorch张量后要放到正确设备上、损失函数要忽略填充位置、反向传播前要清空梯度。完整训练过程我放到下一节讲:
def train_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0 for x, y in dataloader: x, y = x.to(device), y.to(device) optimizer.zero_grad() logits, _ = model(x) # logits: [batch, seq_len, vocab] # y: [batch, seq_len] loss = criterion(logits.view(-1, logits.size(-1)), y.view(-1)) loss.backward() # 梯度裁剪是RNN训练的保命手段,下一章详细讲 nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)4. 训练策略与关键调参:让模型真正"学进去"
4.1 损失函数与优化器选型
训练目标非常明确:让模型在每一个时间步上,给真实的下一个字符分配更高的概率。这个目标用交叉熵损失来实现,nn.CrossEntropyLoss内部已经包含了softmax计算,所以模型输出层直接给logits就行。
优化器我选择Adam,初始学习率1e-3。Adam的优势是不同参数的更新步长会自适应调整,对RNN这类梯度尺度变化剧烈的网络有很好的稳定性。相比SGD,Adam在前期收敛速度快得多,对学习率的敏感度也低,非常适合项目时间有限的场景。
这里有个很多人忽略的细节:交叉熵损失中view(-1)这个操作,看起来只是把三维张量展平,实际作用是把[batch, seq_len, vocab]的logits和[batch, seq_len]的目标,一一对齐成二维问题,让每个位置上的字符预测都能独立计算损失并参与梯度回传。
4.2 梯度裁剪:RNN训练最关键的保命手段
RNN训练中最常见的崩溃场景是:训练几轮后loss突然变成NaN,或者梯度爆炸导致模型参数变成极大值。根源在于RNN的时间反向传播涉及对隐藏状态的长链求导,当序列较长且权重矩阵的最大特征值大于1时,梯度会指数级放大。
解决手段非常直接——梯度裁剪。把梯度的模长限制在一个阈值(常用1.0或5.0)以内,超过就等比例缩放。这相当于给更新加了一道保险丝,保证参数不可能因为一次极端梯度而彻底崩溃。我强烈建议所有做RNN系列项目的同学,都把clip_grad_norm_这行代码当成标配写进训练循环,不要等出问题再去查。
4.3 学习率调整与早停策略
训练过程中loss会像心跳一样有波动,但整体趋势应该平稳下降。我习惯监控验证集loss,并采用学习率衰减策略:每连续三轮验证集loss不下降,就把学习率乘以0.5。这比固定小学习率从头跑到尾高效得多,前期用大学习率快速收敛,后期用小学习率精细打磨。
早停也很重要。设定一个 patience = 5 轮,即连续5轮验证集loss没有刷新最低值,就停止训练并恢复到历史最佳参数。RNN训练后期很容易过拟合——训练集loss还在掉,验证集loss已经反弹,这时候继续训练只会让模型变成"背诗机器",生成的内容多处重复原文片段。
我在实际训练中常用的配置参数汇总如下,供参考:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 序列长度 seq_len | 40 | 超过60训练明显变慢,低于25学不到长程依赖 |
| 批大小 batch_size | 64 | 数据量不大时,64到128均可 |
| 初始学习率 | 1e-3 | Adam优化器的标准起点 |
| 梯度裁剪阈值 | 1.0 | 防止梯度爆炸 |
| 学习率衰减 | 验证集loss连续3轮不降则×0.5 | 配合patience使用 |
| 早停patience | 5轮 | 验证集loss连续5轮不降则停止 |
训练总轮数不必死板设定,我在3000到5000首诗、约30万样本的数据集上,通常20到30轮就到最佳状态,每轮耗时几十秒到两分钟不等(取决于硬件),完全可以接受。
5. 生成效果优化:从"能出诗"到"出好诗"的关键技巧
5.1 温度采样:平衡创意与通顺的调节阀
训练完成后,生成环节的采样策略直接决定诗歌质量。最粗暴的方法是每次都取概率最大的字符,也就是贪心搜索。但贪心生成的文本很快陷入重复循环——"the" 后面接概率最高的往往是 "and","and" 后面又回到 "the",读起来味同嚼蜡。
解决方法是引入温度参数temperature。它在softmax前对logits做缩放:logits除以temperature后再做softmax。温度越低(比如0.5),概率分布越尖锐,生成内容越保守、通顺;温度越高(比如1.5),概率分布越平坦,生成越多样、更大胆,但错误和混乱也会增多。
经验上,诗歌生成用 0.8 到 1.2 区间效果最好。低于0.8容易乏味,高于1.2容易词不达意。我最终通常选 0.9 作为一个稳妥的起点,再根据具体题材微调。代码实现很简单:
def sample_from_logits(logits, temperature=1.0): # 缩放logits,控制概率分布的"尖锐"程度 scaled_logits = logits / temperature probs = torch.softmax(scaled_logits, dim=-1) # 从分布中采样而不是取argmax return torch.multinomial(probs, num_samples=1).item()核心技术点在于torch.multinomial:它按照概率分布随机抽取一个字符,而不是简单地取最大概率。"随机性"是诗歌有灵气的来源,完全确定性的生成永远写不出真正的诗歌。
5.2 Top-K采样与重复惩罚
温度采样解决了"过于保守"的问题,但高温下模型可能会在低概率区域乱逛,生成毫无意义的字符组合。Top-K采样是有效补充:只保留概率最高的K个字符(比如K=10),其他字符直接清零再归一化。这样既保留了随机性,又排除了那些明显不合理的字符,生成质量显著提升。
还有一个实用技巧是重复惩罚。生成时会维护一个"最近N个字符"的滑动窗口,如果候选字符已经在窗口中出现过,就在它的logits上减去一个惩罚值(常见实现是把logits乘以一个小于1的系数,或者直接减一个固定值)。这样做能有效抑制"a a a a a"式的循环崩溃。需要小心的是惩罚力度不能太大,否则会逼迫模型绕开正常词汇,破坏语法连贯性,我通常把惩罚系数设在0.5到1.0之间。
5.3 诗歌结构的韵律控制
这是整个项目中"最体现诗歌特性"的部分,也是很多人做诗歌生成时完全忽略的环节。英文诗歌的"诗感"有很大一部分来自韵脚、行尾词和节奏,但纯字符级GRU并不能保证生成结果押韵。
我的做法是在生成阶段引入韵脚强制:确定好每行需要押韵的韵脚词后,在采样到这个位置时,只从"以该韵脚音素结尾的候选字符集"中选取。实现思路是预先构建一个韵脚词典(比如-ight组、-ing组、-ation组),在生成每行最后一个词时检查候选中是否有能匹配韵脚的词,有则优先选择。
另一种更轻量的做法是模板引导:手动指定每行末尾的韵脚词,然后让模型生成每行去掉结尾词后的主干部分。这个方法实现起来最简单,效果立竿见影,特别适合答辩演示——因为每首生成的诗歌都严格押韵,听起来就像"真的诗"。
6. 常见问题与排查技巧实录
6.1 训练不收敛与梯度异常
现象一:loss 从一开始就在 4.5 左右几乎不动。如果你的词表大小为95,logits随机初始化时,97个类别均匀随机猜测的理论交叉熵正好是ln(95) ≈ 4.55。所以loss卡在4.5说明模型没有学到任何东西。
排查思路:先检查数据加载和标签对齐是否正确——把训练循环改成每隔一步打印x和y的内容,人工看几个样本是否"错开一位"。这是最常见的数据bug来源。然后检查学习率是否过大或过小。我遇到过一次学习率用了0.1导致loss剧烈震荡,降到0.001后立刻稳定收敛。
现象二:训练到某一步loss突然变成NaN。几乎可以断定是梯度爆炸。检查方式是在训练循环里加上torch.isnan(model.parameters())的背景,再配合clip_grad_norm_(model.parameters(), 0.5)解决。如果梯度裁剪阈值已经很低还是爆炸,就需要缩小学习率并检查数据中是否有极端异常值(比如超长连续空格)。
6.2 生成效果差:全是重复和乱码
生成内容大量重复,说明采样策略太保守或重复惩罚缺失。先检查温度是否过低,如果 temperature=0.5 生成的文本几乎必然陷入循环;再检查是否忘了加Top-K采样和重复惩罚。
生成内容杂乱无章、语法混乱,则可能有几个原因:训练轮数不够、模型容量不足(hidden_size太小)、或数据清洗不彻底导致模型学到了大量噪音。还有一个隐蔽问题——序列长度选得太短,如果 seq_len=10,模型根本没有足够的上下文来形成语法结构,生成的文字自然会逐渐混乱。
6.3 过拟合:模型变成"复读机"
验证集loss先降后升几乎必然是过拟合的特征。处理策略按优先级排列:增大Dropout、减小hidden_size、增加训练数据量、提前早停。我在实验中发现,num_layers=2且dropout=0.3时,比dropout=0.1时生成内容的原创度明显更高,重复率显著下降。
还有一种特殊的"伪过拟合":训练数据中某位诗人的作品占比过高,模型会大量模仿该诗人的用词习惯。解决办法是清洗数据时对诗人进行平衡采样,或者干脆每位诗人最多保留固定数量的诗篇。
6.4 生成速度太慢怎么优化
字符级模型生成1000个字符就需要做1000次前向传播,确实不快。但这在毕设场景完全够用——生成一首诗只需要几十到上百个字符,耗时通常不到一秒。如果因为演示需要批量生成多首诗,可以利用PyTorch的batch推理同时生成多首,速度提升明显。训练阶段也可以在数据加载时用DataLoader的num_workers参数启用多进程读取,减少IO瓶颈。
7. 项目扩展方向与答辩亮点建议
如果想让这个项目在答辩或作品集中更出彩,可以在现有系统上做几个低成本、高回报的扩展:
双向GRU融合:把编码方向改为同时从前往后和从后往前读取文本,然后把两个方向的隐藏状态拼接。这样做能捕捉更多上下文信息,代价是参数约翻倍,但对诗歌这类需要在"读到后面时回头回顾前文"的场景非常有效。
注意力机制增强:在GRU解码时引入注意力,让模型在生成每一个字符时"回看"输入文本中最重要的部分。这是RNN系列项目中最容易讲出深度的一个扩展点,答辩时可以从"为什么长序列中固定长度向量不够用"切入,解释注意力机制如何解决信息瓶颈。
多模型对比实验:这是我认为性价比最高的扩展。用同样的数据分别训练GRU、LSTM和一个简化版Transformer,在相同条件下对比生成质量、训练速度和困惑度指标。这样的对比实验可以直接形成论文的一章,答辩时也会让老师觉得你的工作有系统性的思考。
中文诗歌方向:如果做英文诗歌不过瘾,可以迁移到中文古诗生成。字符级模型天然适应中文,只需把词表换成常用汉字集,处理一下拼音押韵的逻辑即可。这个方向的展示效果和文化属性都很强,很多老师会被吸引。
最后分享一点实际体会
做完这个项目后我最大的感受是:GRU诗歌生成看似小巧,但背后牵扯到的时间序列建模、梯度稳定性、采样策略设计、文本生成评估这些知识点,都是深度学习NLP方向的基石。它教会我的不只是一套代码流程,而是一种"如何透过表面效果去理解模型设计动机"的思维方式。
如果你正打算复现这个项目,我的建议是:先别急着调优美效果,把端到端的数据流、训练流、生成流跑通了,让模型输出第一个字符开始,再逐步加温度采样、Top-K、重复惩罚这些优化。你会发现每一次优化都对应着一个可解释的问题,而解决这些问题过程中的理解深度,才是这个项目对你最大的回报。