简介:这份资源面向深度学习与自然语言处理方向的初学者及课程实践者,围绕循环神经网络LSTM构建唐诗生成模型,解决从零搭建文本生成项目的入门难题。压缩包共12个文件,约20.18MB,包含6个Python源码文件、2个文本数据文件,以及模型结构图、答辩幻灯片、训练权重文件和实验报告PDF,覆盖数据加载、模型定义、训练与评估的完整流程。实验以“日、红、山、夜、湖、海、月”等词作为起始词生成诗句,帮助读者理解序列建模与词向量输入输出的关键环节。已有1537人学习下载,说明该案例在同类课程中具有较高参考价值。读者可获得可直接运行的代码框架、训练好的模型文件、结构清晰的实验报告与答辩演示材料,既能对照代码逐行理解RNN与LSTM的实现细节,也能借助报告梳理实验目的、参数设置与结果分析,适合作为课程设计、毕业设计或NLP入门练手项目。
1. 用 RNN 写唐诗:一个能跑通的深度学习实战入口
很多人学完深度学习课程,CNN 分类做过、MNIST 跑过,但一到序列数据就发懵。唐诗生成恰好是一个极佳的练手场景:数据量不大、任务直观、效果肉眼可见,而且它逼着你真正理解 RNN 的隐藏状态到底在干什么。这个标题指向的核心,是用 TensorFlow 2.0 搭建一个字符级 RNN 语言模型,喂给它《全唐诗》,让它学会平仄、押韵和句式结构,最终能生成像模像样的五言、七言绝句。适合已经装好深度学习环境、会写基本 Python、想找一个完整项目把 RNN 从理论落到代码的人。读完你应该能独立完成数据预处理、模型搭建、训练调参和生成采样全流程,并且知道每一步为什么这么做、哪里容易翻车。
2. 数据准备与字符级建模:把唐诗变成 RNN 能吃的张量
2.1 为什么选字符级而不是词级
做唐诗生成,第一个决策就是建模粒度。词级模型需要先分词,而古汉语分词本身就是一个没有标准答案的问题——“白日依山尽”切成“白日/依/山/尽”还是“白/日/依/山/尽”,不同分词器给出的结果不一样,引入的噪声会直接传导到生成质量上。字符级建模则完全绕开这个坑:每个汉字就是一个 token,不需要任何分词工具,也不会因为分词边界错误导致模型学到错误的搭配关系。
代价是序列变长。一首五言绝句 20 个字,词级可能只有 8~10 个 token,字符级就是 20 个。序列翻倍意味着训练时 BPTT(沿时间反向传播)的展开步数更多,梯度消失的风险更大。但唐诗篇幅短,五言绝句 20 字、七言律诗 56 字,即使字符级建模,序列长度也完全在可接受范围内。常见做法是把每首诗截断或填充到固定长度,比如统一取 64 个字符,不够的补零,超出的截断。
另一个实际考量是词表大小。字符级词表通常几千到一万出头,而词级词表动辄几万甚至十几万。词表小意味着 embedding 层参数少、训练快、不容易过拟合。对于唐诗这种总字数有限的语料,字符级几乎是默认选择。
2.2 数据清洗与构建词表的完整脚本
拿到《全唐诗》原始文本后,不能直接丢给模型。里面混杂着标点、注释、异体字、空白行,还有大量非五言七言的杂言诗。我一般会先做一轮过滤,只保留五言和七言绝句/律诗,然后去掉标点和注释行。
import re import numpy as np def load_and_clean_poems(filepath): """读取唐诗文件,清洗并筛选五言/七言诗句""" with open(filepath, 'r', encoding='utf-8') as f: raw_lines = f.readlines() poems = [] for line in raw_lines: line = line.strip() # 跳过空行、标题行(通常含"卷"或作者标记) if not line or '卷' in line or '作者' in line: continue # 只保留纯汉字行 if not re.match(r'^[\u4e00-\u9fff]+$', line): continue # 五言(5字)或七言(7字)才保留 if len(line) in (5, 7): poems.append(line) # 按连续行组合成完整诗篇(这里简化处理,每4行组成一首) full_poems = [] for i in range(0, len(poems) - 3, 4): poem = ''.join(poems[i:i+4]) if len(poem) in (20, 28): # 五绝20字,七绝28字 full_poems.append(poem) return full_poems def build_vocab(poems): """构建字符到索引的映射""" all_text = ''.join(poems) chars = sorted(set(all_text)) # 保留0给padding char2idx = {ch: i+1 for i, ch in enumerate(chars)} idx2char = {i+1: ch for i, ch in enumerate(chars)} char2idx['<PAD>'] = 0 idx2char[0] = '<PAD>' return char2idx, idx2char def encode_poems(poems, char2idx, max_len=64): """将诗句转为固定长度的索引序列""" X = np.zeros((len(poems), max_len), dtype=np.int32) for i, poem in enumerate(poems): for j, ch in enumerate(poem[:max_len]): X[i, j] = char2idx.get(ch, 0) return X这段代码做了三件事:过滤非诗句行、按四行组诗、构建字符映射表。max_len=64是一个安全值,五绝 20 字、七绝 28 字都远小于它,留足空间给后续可能加入的更长诗体。char2idx从 1 开始编号,0 专门留给 padding,这样 embedding 层可以用mask_zero=True自动忽略填充位。
参数方面,max_len不建议设得太大。有次我图省事设成 128,结果训练时显存直接爆了,batch_size 被迫降到 16,训练速度慢了一倍多。后来改回 64,batch_size 能开到 64,一个 epoch 从 40 分钟降到 8 分钟。序列长度对显存的影响是平方级的,这是 RNN 类模型的血泪经验。
2.3 输入输出对的构造逻辑
语言模型的训练目标是“给定前 n 个字符,预测第 n+1 个字符”。所以输入是X[:, :-1],标签是X[:, 1:],两者错开一位。这个错位操作看起来简单,但新手最容易在这里翻车——如果忘记错位,模型学到的是“复制输入”,loss 会降得很快但生成时全是重复字符。
def create_training_pairs(X): """构造输入和标签:输入去掉最后一位,标签去掉第一位""" X_input = X[:, :-1] # 形状 (batch, max_len-1) Y_target = X[:, 1:] # 形状 (batch, max_len-1) return X_input, Y_target注意Y_target不需要 one-hot 编码,因为后面用SparseCategoricalCrossentropy损失函数,它直接接受整数标签。这比 one-hot 省内存,尤其是词表上万的时候,one-hot 矩阵会大到离谱。
3. TensorFlow 2.0 搭建 RNN 模型:Embedding + GRU + Dense 的三层结构
3.1 为什么用 GRU 而不是朴素 RNN 或 LSTM
朴素 RNN 的梯度消失问题在序列超过 10 步后就非常严重,唐诗 20~28 个字,用朴素 RNN 训练出来的模型基本只能记住前 5 个字,后面全是乱码。LSTM 通过门控机制解决了梯度消失,但参数多、训练慢。GRU 是 LSTM 的简化版,把遗忘门和输入门合并成更新门,参数少了约三分之一,效果在大多数序列任务上和 LSTM 相当。
对于唐诗生成这个任务,我实测过三种结构:朴素 RNN 的 loss 降到 2.8 就降不动了,生成的诗句毫无结构;LSTM 能降到 1.5 左右,生成质量不错;GRU 也能降到 1.6 附近,但训练时间比 LSTM 少 20% 左右。所以 GRU 是性价比最高的选择。当然如果你追求极致效果,可以堆两层 LSTM,但过拟合风险也跟着上来。
3.2 模型定义的完整代码与参数说明
import tensorflow as tf from tensorflow.keras import layers, Model class PoetryRNN(Model): def __init__(self, vocab_size, embedding_dim=256, rnn_units=512): super(PoetryRNN, self).__init__() self.embedding = layers.Embedding( vocab_size, embedding_dim, mask_zero=True ) self.gru = layers.GRU( rnn_units, return_sequences=True, return_state=True, recurrent_initializer='glorot_uniform' ) self.dense = layers.Dense(vocab_size) def call(self, inputs, states=None, return_state=False): x = self.embedding(inputs) if states is None: states = self.gru.get_initial_state(x) x, states = self.gru(x, initial_state=states) x = self.dense(x) if return_state: return x, states return xembedding_dim=256是我反复试出来的值。设 128 时模型欠拟合,生成的诗句经常出现重复字;设 512 时参数量翻倍,训练一个 epoch 要 15 分钟以上,而且验证集 loss 下降不明显。256 是一个平衡点,既能表达足够的字符语义,又不至于太慢。
rnn_units=512决定了隐藏状态的维度。这个值越大,模型记忆能力越强,但超过 1024 后收益递减明显。512 对于唐诗这种短序列任务已经绰绰有余。return_sequences=True是必须的,因为每个时间步都要输出预测;return_state=True是为了在生成阶段能传递隐藏状态,实现逐字续写。
recurrent_initializer='glorot_uniform'这个初始化方法比默认的orthogonal在短序列上收敛更快,这是我踩过坑之后换的。用默认初始化时,前几个 epoch 的 loss 几乎不降,换成 glorot 后立刻正常下降。
3.3 损失函数、优化器与训练循环
def loss_function(labels, logits): """稀疏交叉熵损失,labels是整数索引""" return tf.keras.losses.sparse_categorical_crossentropy( labels, logits, from_logits=True ) optimizer = tf.keras.optimizers.Adam(learning_rate=0.001) @tf.function def train_step(inputs, targets): with tf.GradientTape() as tape: logits = model(inputs) loss = loss_function(targets, logits) grads = tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) return loss学习率设 0.001 是 Adam 的经典默认值,但在 RNN 上有时会震荡。如果发现 loss 曲线上下跳动厉害,可以降到 0.0005。@tf.function装饰器把训练步骤编译成图模式,速度能提升 2~3 倍,这是 TensorFlow 2.0 相比 1.x 最实用的改进之一。
训练循环里有一个容易忽略的点:每个 epoch 结束后要重置隐藏状态。因为不同 batch 之间没有时序关系,如果带着上一个 batch 的隐藏状态继续训练,模型会学到跨 batch 的虚假依赖。常见做法是在每个 batch 开始时调用model.gru.reset_states(),或者像上面代码那样每次传入states=None让模型自动初始化。
4. 训练调参与生成采样:让模型写出像样的诗句
4.1 批次大小、epoch 数与早停策略
批次大小直接受显存限制。在 8GB 显存的显卡上,max_len=64、rnn_units=512的配置下,batch_size 最大能开到 64。再大就会 OOM。如果显存只有 4GB,建议把rnn_units降到 256,batch_size 保持 32。
epoch 数不是越多越好。唐诗数据集通常 2~4 万首,batch_size=64 时一个 epoch 大概 300~600 个 step。我一般训练 50 个 epoch 左右,观察验证集 loss。如果连续 5 个 epoch 验证 loss 不降反升,就说明过拟合了,该停。没有验证集的话,看训练 loss 降到 1.2 以下基本就可以停了,再训下去生成的诗句会越来越像训练集里的原句,失去泛化能力。
4.2 温度参数与采样策略
生成阶段最关键的参数是温度(temperature)。它控制概率分布的平滑程度:温度越低,模型越保守,倾向于选概率最高的字;温度越高,输出越随机,但也越容易出乱码。
def generate_poem(model, char2idx, idx2char, start_string, num_generate=20, temperature=0.8): """根据起始字符生成指定长度的诗句""" input_eval = [char2idx.get(ch, 0) for ch in start_string] input_eval = tf.expand_dims(input_eval, 0) text_generated = [] states = None for _ in range(num_generate): predictions, states = model(input_eval, states=states, return_state=True) predictions = predictions[:, -1, :] # 取最后一个时间步 predictions = predictions / temperature predicted_id = tf.random.categorical(predictions, num_samples=1)[-1, 0].numpy() input_eval = tf.expand_dims([predicted_id], 0) text_generated.append(idx2char[predicted_id]) return start_string + ''.join(text_generated)温度设 0.8 是我试出来比较平衡的值。设 0.5 时生成的诗句过于保守,经常出现“春风吹又生”这种高频套话;设 1.2 时会出现大量不存在的汉字组合,读起来像乱码。0.8 左右既能保证句式通顺,又有一定的多样性。
另一个技巧是 top-k 采样:只从概率最高的 k 个字符中随机选,而不是从全部词表中选。这能有效避免低概率的奇怪字符被选中。k 一般设 10~20,配合温度 0.8 使用效果最好。
4.3 用困惑度评估生成质量
除了肉眼看生成结果,还可以用困惑度(perplexity)来量化评估。困惑度是交叉熵损失的指数,越低说明模型对数据的预测越准确。
def calculate_perplexity(model, dataset): """计算模型在给定数据集上的困惑度""" total_loss = 0 total_steps = 0 for inputs, targets in dataset: logits = model(inputs) loss = loss_function(targets, logits) total_loss += tf.reduce_mean(loss).numpy() total_steps += 1 avg_loss = total_loss / total_steps return np.exp(avg_loss)唐诗生成任务上,训练充分的 GRU 模型困惑度通常在 15~25 之间。如果困惑度超过 50,说明模型欠拟合,需要增加训练轮数或调大rnn_units;如果低于 10,大概率过拟合了,生成的诗句会大量照搬训练集。
5. 避坑与排查:训练唐诗生成模型时最容易翻车的五个地方
5.1 生成结果全是重复字
现象:模型生成的诗句像“春春春春春春春春”,或者“风风风风风风风风”,完全没有变化。
原因:最常见的原因是训练不充分,模型还没学会字符之间的转移规律,只能反复输出概率最高的那个字。另一个可能是温度设得太低,比如 0.1,模型每次都选概率最高的字符,而训练初期概率分布很尖锐,导致重复。
解决:先检查训练 loss 是否降到了 1.5 以下,如果没降到就继续训练。如果 loss 已经很低但还重复,把温度调到 0.8~1.0 试试。还有一个隐蔽原因是输入输出没有错位,模型学到了“复制输入”,这种情况 loss 会异常低(低于 0.5),但生成全是重复。检查X_input = X[:, :-1]和Y_target = X[:, 1:]是否写对。
5.2 训练 loss 震荡不下降
现象:loss 曲线像心电图一样上下跳动,几个 epoch 过去均值几乎没变。
原因:学习率太大是最常见的。Adam 默认 0.001 在 RNN 上有时偏大,尤其是rnn_units设得大的时候。另一个可能是 batch_size 太小,梯度估计噪声大。
解决:把学习率降到 0.0005 或 0.0003,观察 loss 是否变得平滑。如果显存允许,把 batch_size 从 32 提到 64 或 128。还有一个容易忽略的点是梯度裁剪——RNN 的梯度容易爆炸,加上tf.clip_by_norm(grads, 5.0)能有效稳定训练。
5.3 显存溢出(OOM)
现象:训练开始几秒后报ResourceExhaustedError,提示显存不足。
原因:max_len设得太大、rnn_units太大、batch_size 太大,三者任意一个超标都会 OOM。RNN 的显存占用和序列长度是平方关系,max_len从 64 提到 128,显存占用翻四倍。
解决:按优先级依次降低max_len、rnn_units、batch_size。唐诗五绝 20 字、七绝 28 字,max_len=48就够用了,没必要设 128。如果还 OOM,把rnn_units从 512 降到 256,效果损失不大但显存省一半。
5.4 生成的诗句不押韵
现象:单句读起来通顺,但整首诗不押韵,第二句和第四句的尾字韵母不同。
原因:字符级 RNN 只学到了局部字符转移规律,没有显式建模押韵约束。押韵是一个全局约束,需要模型在生成第四句时还记得第二句的尾字韵母,这对隐藏状态的记忆能力要求很高。
解决:最直接的办法是增大rnn_units,从 512 提到 1024,让隐藏状态能记住更多信息。另一个技巧是在训练数据里把押韵的诗放在一起,让模型更容易学到韵脚模式。还可以在生成阶段加后处理:生成第四句时,限制尾字必须从第二句尾字的同韵字中选。这不是纯端到端方案,但效果立竿见影。
5.5 模型只输出高频字
现象:生成的诗句里反复出现“风”“月”“花”“春”这几个字,其他字几乎不出现。
原因:训练数据里这些字的频率天然就高,模型学会了偷懒——只要输出高频字就能获得较低的 loss,没有动力学到多样化的表达。
解决:在损失函数里给低频字加权,让模型对低频字的预测错误惩罚更大。具体做法是计算每个字的频率,然后weight = 1 / (freq + 1e-6),归一化后传给sparse_categorical_crossentropy的sample_weight参数。另一个办法是生成时用 top-k 采样,k 设 20 左右,强制模型从更多候选字里选。
6. 从唐诗到通用序列生成:迁移到其他任务的三个关键改动
训练完唐诗模型后,这套 RNN 框架其实可以迁移到很多序列生成任务上,比如对联生成、歌词创作、代码补全。但直接套用效果往往不好,需要做三个关键改动。
第一个改动是调整序列长度和词表。唐诗max_len=64够用,但代码补全可能需要 256 甚至 512。词表方面,代码的 token 不只是汉字,还有英文关键字、符号、缩进,需要重新设计 tokenizer。我一般用 Byte-Pair Encoding(BPE)替代字符级建模,这样既能控制词表大小,又能处理未登录词。
第二个改动是堆叠多层 RNN。唐诗任务单层 GRU 就够了,但更复杂的序列任务需要更深的结构。TensorFlow 2.0 里可以用layers.StackedRNNCells或者直接堆叠多个 GRU 层。注意每层之间要加Dropout,否则过拟合会非常严重。我通常设dropout=0.2,再高会影响收敛。
第三个改动是引入注意力机制。RNN 的隐藏状态容量有限,序列一长就记不住前面的信息。加一个layers.Attention层能让模型直接关注到关键位置。在 TensorFlow 2.0 里可以用layers.Attention()配合 query-value 结构实现,也可以直接用Transformer替代 RNN——但那是另一个话题了。
| 任务类型 | 推荐序列长度 | 推荐单元数 | 是否需要注意力 |
|---|---|---|---|
| 唐诗生成 | 48~64 | 512 | 否 |
| 对联生成 | 32~48 | 256 | 否 |
| 歌词创作 | 128~256 | 512 | 建议加 |
| 代码补全 | 256~512 | 1024 | 必须加 |
最后说一个我自己的习惯:每次训练完模型,我都会用同一组起始字符(比如“春”“月”“山”)各生成 10 首诗,人工扫一遍。如果 10 首里有 3 首以上读起来像人写的,这个模型就值得保留;如果只有 1 首能看,说明还有调参空间。这个土办法比看 loss 曲线直观得多,也是我判断模型能不能用的最后一道关。希望帮到你。
本文还有配套的精品资源,点击获取