尧图精选

TensorFlow 2.0实战:用RNN生成唐诗,从数据到采样全流程

🕒 发布时间:2026/10/1 5:28:03 📁 来源:尧图网络
简介这份资源面向深度学习与自然语言处理方向的初学者及课程实践者围绕循环神经网络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 个字词级可能只有 810 个 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, encodingutf-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:i4]) 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: i1 for i, ch in enumerate(chars)} idx2char {i1: ch for i, ch in enumerate(chars)} char2idx[PAD] 0 idx2char[0] PAD return char2idx, idx2char def encode_poems(poems, char2idx, max_len64): 将诗句转为固定长度的索引序列 X np.zeros((len(poems), max_len), dtypenp.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_len64是一个安全值五绝 20 字、七绝 28 字都远小于它留足空间给后续可能加入的更长诗体。char2idx从 1 开始编号0 专门留给 padding这样 embedding 层可以用mask_zeroTrue自动忽略填充位。参数方面max_len不建议设得太大。有次我图省事设成 128结果训练时显存直接爆了batch_size 被迫降到 16训练速度慢了一倍多。后来改回 64batch_size 能开到 64一个 epoch 从 40 分钟降到 8 分钟。序列长度对显存的影响是平方级的这是 RNN 类模型的血泪经验。2.3 输入输出对的构造逻辑语言模型的训练目标是“给定前 n 个字符预测第 n1 个字符”。所以输入是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 步后就非常严重唐诗 2028 个字用朴素 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_dim256, rnn_units512): super(PoetryRNN, self).__init__() self.embedding layers.Embedding( vocab_size, embedding_dim, mask_zeroTrue ) self.gru layers.GRU( rnn_units, return_sequencesTrue, return_stateTrue, recurrent_initializerglorot_uniform ) self.dense layers.Dense(vocab_size) def call(self, inputs, statesNone, return_stateFalse): x self.embedding(inputs) if states is None: states self.gru.get_initial_state(x) x, states self.gru(x, initial_statestates) x self.dense(x) if return_state: return x, states return xembedding_dim256是我反复试出来的值。设 128 时模型欠拟合生成的诗句经常出现重复字设 512 时参数量翻倍训练一个 epoch 要 15 分钟以上而且验证集 loss 下降不明显。256 是一个平衡点既能表达足够的字符语义又不至于太慢。rnn_units512决定了隐藏状态的维度。这个值越大模型记忆能力越强但超过 1024 后收益递减明显。512 对于唐诗这种短序列任务已经绰绰有余。return_sequencesTrue是必须的因为每个时间步都要输出预测return_stateTrue是为了在生成阶段能传递隐藏状态实现逐字续写。recurrent_initializerglorot_uniform这个初始化方法比默认的orthogonal在短序列上收敛更快这是我踩过坑之后换的。用默认初始化时前几个 epoch 的 loss 几乎不降换成 glorot 后立刻正常下降。3.3 损失函数、优化器与训练循环def loss_function(labels, logits): 稀疏交叉熵损失labels是整数索引 return tf.keras.losses.sparse_categorical_crossentropy( labels, logits, from_logitsTrue ) optimizer tf.keras.optimizers.Adam(learning_rate0.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装饰器把训练步骤编译成图模式速度能提升 23 倍这是 TensorFlow 2.0 相比 1.x 最实用的改进之一。训练循环里有一个容易忽略的点每个 epoch 结束后要重置隐藏状态。因为不同 batch 之间没有时序关系如果带着上一个 batch 的隐藏状态继续训练模型会学到跨 batch 的虚假依赖。常见做法是在每个 batch 开始时调用model.gru.reset_states()或者像上面代码那样每次传入statesNone让模型自动初始化。4. 训练调参与生成采样让模型写出像样的诗句4.1 批次大小、epoch 数与早停策略批次大小直接受显存限制。在 8GB 显存的显卡上max_len64、rnn_units512的配置下batch_size 最大能开到 64。再大就会 OOM。如果显存只有 4GB建议把rnn_units降到 256batch_size 保持 32。epoch 数不是越多越好。唐诗数据集通常 24 万首batch_size64 时一个 epoch 大概 300600 个 step。我一般训练 50 个 epoch 左右观察验证集 loss。如果连续 5 个 epoch 验证 loss 不降反升就说明过拟合了该停。没有验证集的话看训练 loss 降到 1.2 以下基本就可以停了再训下去生成的诗句会越来越像训练集里的原句失去泛化能力。4.2 温度参数与采样策略生成阶段最关键的参数是温度temperature。它控制概率分布的平滑程度温度越低模型越保守倾向于选概率最高的字温度越高输出越随机但也越容易出乱码。def generate_poem(model, char2idx, idx2char, start_string, num_generate20, temperature0.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, statesstates, return_stateTrue) predictions predictions[:, -1, :] # 取最后一个时间步 predictions predictions / temperature predicted_id tf.random.categorical(predictions, num_samples1)[-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 一般设 1020配合温度 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 模型困惑度通常在 1525 之间。如果困惑度超过 50说明模型欠拟合需要增加训练轮数或调大rnn_units如果低于 10大概率过拟合了生成的诗句会大量照搬训练集。5. 避坑与排查训练唐诗生成模型时最容易翻车的五个地方5.1 生成结果全是重复字现象模型生成的诗句像“春春春春春春春春”或者“风风风风风风风风”完全没有变化。原因最常见的原因是训练不充分模型还没学会字符之间的转移规律只能反复输出概率最高的那个字。另一个可能是温度设得太低比如 0.1模型每次都选概率最高的字符而训练初期概率分布很尖锐导致重复。解决先检查训练 loss 是否降到了 1.5 以下如果没降到就继续训练。如果 loss 已经很低但还重复把温度调到 0.81.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_len48就够用了没必要设 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_len64够用但代码补全可能需要 256 甚至 512。词表方面代码的 token 不只是汉字还有英文关键字、符号、缩进需要重新设计 tokenizer。我一般用 Byte-Pair EncodingBPE替代字符级建模这样既能控制词表大小又能处理未登录词。第二个改动是堆叠多层 RNN。唐诗任务单层 GRU 就够了但更复杂的序列任务需要更深的结构。TensorFlow 2.0 里可以用layers.StackedRNNCells或者直接堆叠多个 GRU 层。注意每层之间要加Dropout否则过拟合会非常严重。我通常设dropout0.2再高会影响收敛。第三个改动是引入注意力机制。RNN 的隐藏状态容量有限序列一长就记不住前面的信息。加一个layers.Attention层能让模型直接关注到关键位置。在 TensorFlow 2.0 里可以用layers.Attention()配合 query-value 结构实现也可以直接用Transformer替代 RNN——但那是另一个话题了。任务类型推荐序列长度推荐单元数是否需要注意力唐诗生成4864512否对联生成3248256否歌词创作128256512建议加代码补全2565121024必须加最后说一个我自己的习惯每次训练完模型我都会用同一组起始字符比如“春”“月”“山”各生成 10 首诗人工扫一遍。如果 10 首里有 3 首以上读起来像人写的这个模型就值得保留如果只有 1 首能看说明还有调参空间。这个土办法比看 loss 曲线直观得多也是我判断模型能不能用的最后一道关。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →