2026/10/7 12:46:09

RNN唐诗写作实战:用TensorFlow 2.0与LSTM实现字符级文本生成

RNN唐诗写作实战:用TensorFlow 2.0与LSTM实现字符级文本生成 简介面向深度学习和NLP初学者一套基于TensorFlow 2.0与LSTM的RNN唐诗写作实战资源以循环神经网络实现指定开头词日、红、山、夜、湖、海、月等的唐诗自动生成。压缩包共12个文件包含6个Python脚本模型定义、训练、评估、工具与设置、2个文本说明、1个pdf实验报告、1个pptx答辩PPT另有预训练权重best_model.h5和模型结构图整体约20.18MB目录结构清晰。目前已有1537人学习下载。通过逐行阅读代码与实验报告可掌握RNN文本生成的完整流程数据预处理、LSTM建模、训练调参与采样生成预训练模型开箱即用能直接生成以指定字开头的高质量诗句。附带的答辩PPT和实验报告还能帮助快速梳理项目逻辑、降低复现门槛适合课程设计、毕业设计或NLP入门项目参考。1. RNN唐诗写作一次把「背诗」变成「写诗」的深度学习实战一个周末就能跑通的深度学习实战项目唐诗生成绝对是性价比最高的一档。别人还在 MNIST 上调 acc你已经能把「床前明月光」喂进循环神经网络让它自己攒出五言绝句哪怕水平像打油诗那种「模型真在写诗」的震动也是实实在在的。这个项目标题里的 RNN 唐诗写作目标就是把全唐诗当语料用 TensorFlow 2.0 搭一个字符级语言模型逐字预测下一个汉字最终从一句种子文本开始凑出一首能读出新意的诗。它适合刚啃完深度学习基础、想动手做点有味道的实战项目的人也适合已经被图像分类套牢、想换个序列任务练手感的一线工程师。2. 为什么选 RNN从「记忆」到「格律」文本生成绕不开的结构2.1 RNN 和 LSTM 在文本生成里的核心位置RNN 循环神经网络最朴素的价值是它处理的是「有顺序的数据」。唐诗的字词依赖强「白日依山尽」后面跟着「黄河入海流」不是随机的——前面五个字决定后面五个字的走向和格律。普通全连接网络面对这种序列是无能为力的因为它把每个输入当独立样本CNN 能抓窗口内的局部关系但窗口一长就抓不住跨度大的依赖。RNN 把一个隐藏状态沿着时间步传下去等于给模型配了一个「随身记事本」看到哪句就记到哪句这个记事本就是网络在做深度学习时区别于其他结构的核心记忆机制。不过经典 RNN 有个众所周知的老毛病梯度在时间步上反复相乘后会消失或爆炸导致长距离信息根本记不住。所以真正落地的 RNN 文本生成几乎都换成 LSTM 或 GRU。LSTM 用输入门、遗忘门、输出门去控制记事本里哪些该写、哪些该擦在训练时更容易把「前面五个字的平仄」一路传到后面。唐诗生成这类任务句子长度一般不超过二十个字LSTM 完全够用甚至 GRU 也能打参数还更少。标题是 RNN 唐诗写作实现里用的是 LSTM这一点不矛盾——LSTM 本身就是 RNN 的一种门控改进面试或写文档时别把这两个名字说反了。2.2 字符级模型 vs 词语级模型唐诗该怎么切文本生成有两种常见的建模粒度字符级和词语级。英文那边词语级常见因为它有天然空格分词中文分词是额外的前置步骤还容易引入错误传播。更关键的是唐诗的格律美感在字与字的组合关系里「青山」「绿水」拆成「青」「山」「绿」「水」反而给了模型更大的拼装自由。字符级模型直接以汉字为单位做预测词汇表大小只有几千比词语级动不动几万词的输出维度小一个量级训练压力小得多。我做这个项目时选的是字符级原因有三个。第一全唐诗收诗五万多首按字符统计大概几十万到上百万字语料规模对字符级模型完全够第二字符级可以天然生成不在训练集里的五言或七言组合模型更像在「创作」而不是「背诗」第三字符级模型做 batch 时不用考虑句子长短对齐的问题代码简单踩坑少。代价是生成的句子偶尔会出现不通顺的相邻字这个靠后面的采样温度和多轮抽签去缓解。2.3 用 TensorFlow 2.0 的原因与模型选型对比选择 TensorFlow 2.0 不是因为它比 PyTorch 强而是因为它在生产部署、tf.data 数据管道和 Keras 高层 API 之间平衡得不错。对文本生成这种小模型用tf.keras.Sequential搭一个 Embedding LSTM Dense 只需要十几行代码训练循环要么用model.fit要么自定义tf.GradientTape看每一步的采样输出。2.0 的 Eager Execution 默认开启中间变量随手就能打印排查数据管道和 shape 不匹配的 bug 比 1.x 时代舒服得多。对比之下PyTorch 的写法也接近但 TensorFlow 2.0 在数据集管道上更顺手tf.data的 shuffle 和 batch 对序列样本处理很透明。深度学习框架的争论没完没了但对这个项目选哪个都不影响核心原理我先按 TensorFlow 2.0 讲你如果已经装了 PyTorch 或 PaddlePaddle把模型层名换掉就行思路完全一致。3. 用 TensorFlow 2.0 跑通唐诗生成的最小闭环3.1 数据预处理把唐诗切成语料并建立字符字典第一步不是搭模型而是把语料处理成「输入序列 - 下一个字符」的训练样本。我一般从网上的全唐诗文本开始常见格式是每行一首诗长这样静夜思:床前明月光疑是地上霜举头望明月低头思故乡 登鹳雀楼:白日依山尽黄河入海流欲穷千里目更上一层楼注意很多文本里诗名和诗句之间用冒号分隔诗句内部没有空格、没有标点或只有句号。深度学习的失败有一半是数据预处理埋下的雷所以这里要分三步走读文件、按行清洗、按字符映射。import numpy as np import tensorflow as tf # 读入全唐诗文本每行格式为“标题:诗句”部分行可能没有冒号 # 这里统一只取冒号后的部分并过滤掉标题、作者、卷数等噪音 text_path poem.txt lines [] with open(text_path, encodingutf-8) as f: for line in f: line line.strip() if not line: continue if : in line: poem line.split(:, 1)[1] else: poem line # 只保留 8~64 字之间的行太短的可能是残句 # 太长的可能是赋或歌行和绝句律诗的格律差别太大 if 8 len(poem) 64: lines.append(poem) corpus .join(lines) print(有效诗句总字符数:, len(corpus)) # 每个字符一个索引构成字符级字典 chars sorted(set(corpus)) char2idx {c: i for i, c in enumerate(chars)} idx2char {i: c for i, c in enumerate(chars)} vocab_size len(chars) print(字符表大小:, vocab_size)逻辑说明过滤行长度是很有必要的全唐诗里混着「卷一百六十八」这类目录行和作者行不去掉会让模型学着输出「李白」当诗句。切分冒号时用split(:, 1)只切第一处防止诗名里也带冒号。字数边界我习惯用 8 到 64把七言绝句的整首二十八字留在这个范围内同时丢掉可能不完整的残句。字符字典建好后下一步是把语料切成定长输入样本。序列长度seq_length是第一个要花心思调的超参数它代表模型每次能看到的上下文长度。对五言诗单句五字、两句十字seq_length20基本能覆盖一整句到两句的上下文对七言诗seq_length28会更有优势。训练时其实可以两个长度都试先拿 20 跑通再说。seq_length 20 # 每个样本的输入字符数 # 滑动窗口切样本前 seq_length 个字符作为输入下一个字符作为标签 sequences [] targets [] for i in range(len(corpus) - seq_length): in_seq corpus[i:i seq_length] out_char corpus[i seq_length] sequences.append([char2idx[c] for c in in_seq]) targets.append(char2idx[out_char]) sequences np.array(sequences) targets np.array(targets) print(训练样本数:, sequences.shape[0])参数说明这里滑动步长是 1样本之间大量重叠属于字符级语言模型的常规操作。虽然样本数看起来膨胀到上百万但每个样本长度只有 20 个字训练并不慢。如果你内存吃紧可以把步长换成 2 或 3样本数直接降一半只是模型能看到的连续切面会变稀。字符字典和样本切分是最容易产生 shape 不一致的地方跑下一步前先打印sequences.shape[1] seq_length和targets.shape[0] sequences.shape[0]。3.2 模型搭建Embedding LSTM Dense 的三层结构模型设计遵循文本生成的标配结构Embedding 把字符索引变成稠密向量两层 LSTM 做时序特征提取Dense 输出每个字符的得分。这个结构读起来很像一个分类器——在词汇表大小个候选字符里做分类——但它的本质是「条件概率分布」给定前面一串字预测下一个字的概率分布。model tf.keras.Sequential([ # 嵌入层把字符索引映射为 128 维向量嵌入维度控制在 128~256 之间 tf.keras.layers.Embedding(vocab_size, 128), # 第一层 LSTM 返回完整序列给第二层继续提取时序特征 tf.keras.layers.LSTM(256, return_sequencesTrue), # 第二层 LSTM 只返回最后时间步的输出因为最终只需预测一个字符 tf.keras.layers.LSTM(256), # 输出层不加 softmax训练时用 from_logitsTrue 更稳 tf.keras.layers.Dense(vocab_size) ]) model.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), losstf.keras.losses.SparseCategoricalCrossentropy(from_logitsTrue), ) model.summary()逻辑说明Embedding 的输入是形状为(batch, seq_length)的整数张量输出形状变成(batch, seq_length, 128)每个字符被表示成一个 128 维连续向量。第一层 LSTM 的return_sequencesTrue让它在每个时间步都输出隐藏状态这样第二层 LSTM 能继续从头到尾处理整个序列第二层return_sequencesFalse只输出最后一个时间步的隐藏状态。最后的 Dense 层把隐藏状态映射到词汇表维度每个位置的值代表对应字符的 logit。参数说明LSTM 单元数 256 是文本生成里常见的中间值太小拟合能力不够太大在 CPU 上跑不动。Embedding 维度 128 和 LSTM 单元数 256 不需要严格绑定但你如果同时翻倍它们训练时间会近似平方上涨。这里没有用 Dropout因为字符级生成任务很小两层 LSTM 的 256 维足够扛得住加了反而容易欠拟合如果你语料更大或想更强地防过拟合可以在每个 LSTM 层加dropout0.2但训练 loss 会降得明显变慢。3.3 训练与采样从 loss 下降到逐字蹦出诗句训练可以走model.fit但我更喜欢在训练循环里每个 epoch 结束后直接生成一段样诗这样能实时感受到模型从乱码到人话的过程。TensorFlow 2.0 下可以用自定义训练循环也可以直接用fit加callbacks这里给一个偏向实战的最小自定义循环版本。# 构造 tf.data 数据集并切 batch batch_size 128 dataset tf.data.Dataset.from_tensor_slices((sequences, targets)) dataset dataset.shuffle(10000).batch(batch_size, drop_remainderTrue) # 自定义采样函数temperature 控制随机程度 def generate_text(seed, length32, temperature0.8): generated seed for _ in range(length): # 取种子最后 seq_length 个字符作为输入 in_seq generated[-seq_length:] # 字符索引不足 seq_length 时做左侧填充 if len(in_seq) seq_length: in_seq in_seq.rjust(seq_length, 春) idx_seq [char2idx[c] for c in in_seq] input_tensor tf.expand_dims(np.array(idx_seq), 0) logits model(input_tensor)[0] / temperature pred tf.random.categorical(logits, num_samples1)[-1, 0].numpy() generated idx2char[pred] return generated epochs 30 for epoch in range(epochs): total_loss 0 for x_batch, y_batch in dataset: with tf.GradientTape() as tape: logits model(x_batch, trainingTrue) loss tf.keras.losses.sparse_categorical_crossentropy( y_batch, logits, from_logitsTrue ) loss tf.reduce_mean(loss) grads tape.gradient(loss, model.trainable_variables) model.optimizer.apply_gradients(zip(grads, model.trainable_variables)) total_loss loss.numpy() print(fEpoch {epoch1}, Loss: {total_loss / len(dataset):.4f}) seed 床前明月光 print(样例:, generate_text(seed, 28, temperature0.8))逻辑说明tf.GradientTape是 TensorFlow 2.0 Eager 模式下的关键工具它把前向计算的梯度记录下来再调用tape.gradient得到所有可训练变量的梯度最后用model.optimizer.apply_gradients手动更新。这个循环和model.fit本质上一致但多了每轮生成样诗的观察点。loss 从最初的ln(vocab_size)开始降降到 1.5 以下时生成的诗开始有可读的局部片段。采样参数说明temperature是采样时的一个缩放系数logits 除以 temperature 后再进入随机采样。temperature 小于 1 时概率分布更尖锐模型更倾向于选概率最高的字生成变得更保守大于 1 时分布更平滑随机性变强更容易蹦出冷门字。tf.random.categorical按概率分布抽取一个字符索引[-1, 0]是从返回张量里取最后一个采样结果。seed 取「床前明月光」时模型会接在它后面续写这正是训练时学到的上下文关系。4. 参数调优与常见避坑指南4.1 必须盯住的两个训练指标唐诗生成这个任务看 loss 绝对值不如看它的下降曲线。刚开始 loss 会从词汇表的自然对数附近往下掉第一轮就能掉到 4 以下后面每轮降幅越来越小。如果看到 loss 在 3.5 左右徘徊很久问题多半出在序列长度或 LSTM 单元数上而不是模型结构错了。生成质量比 loss 更能说明问题loss 停在 2.0 看起来不错但采样出来全是一句里重复三遍「月」模型明显没学到多样化表达。4.2 避坑 1loss 卡在词汇表对数值附近不动现象第一次训练loss 始终在ln(vocab_size)附近震荡比如词汇表 2000loss 就一直在 7.5 上下生成结果是完全不相关的单字。原因最常见的是标签错位。查看targets是否对应sequences的后一位时发现某个样本的输入和标签来自不同行另一个常见原因是char2idx里混入了\n或空格模型在学预测换行而不是汉字。解决写三行断言快速自检。检查len(corpus) sum(len(line) for line in lines)检查标签范围max(targets) vocab_size把第一个样本的输入和标签打印出来肉眼对比。如果这些都对再把学习率从 0.001 加到 0.002 试十轮过大的学习率在这种小模型上反而很容易让 loss 卡死。4.3 避坑 2生成结果全是「。」或「之」这样的高频字现象训练了二十轮loss 降得不错但生成的字符串几乎每两个字就蹦一个「。」或者大量输出「之乎者也」整段读起来是「白日之山。」这种鬼样子。原因字符分布极不均衡。全唐诗里句号出现频率远高于普通汉字模型发现无脑预测句号能让 loss 降得快就走了捷径。这个问题在字符级生成里非常典型属于概率分布的天然偏斜。解决三种手段按顺序试。第一训练前统计字符频率适当删掉连续重复的句号比如把「。。。」替换成「。」让素材更干净第二采样时不直接用model(input_tensor)[0]而是手动屏蔽高频字比如把句号对应的 logit 乘 0.3降低它的被选概率第三调高 temperature 到 0.9 以上让概率分布更平坦模型更容易选中低频字。前两种立竿见影第三种要慢慢试。4.4 避坑 3训练速度慢到像死循环占用时间全在等待现象LSTM 单元数和序列长度都拉满50 个 epoch 跑了一晚上每轮还要几分钟人坐在屏幕前等得怀疑人生。原因字符级语言模型的样本数量巨大滑动窗口切出来的样本比语料字符数还多每个 epoch 都等于把整个语料扫一遍。LSTM 是串行计算无法像 CNN 那样高度并行单元数翻倍就是实打实的时间翻倍。解决一组推荐参数组合是 Embedding128、LSTM128、seq_length20、batch_size128先跑通验证流程再逐步加量。训练前确认 TensorFlow 2.0 真的在用 GPU——tf.config.list_physical_devices(GPU)返回空列表时代码就是在 CPU 上硬跑。还有一个容易忽略的加速点把数据预处理一次性算完存成.npy不要在训练循环里重复做字符串到索引的转换。4.5 避坑 4环境配置翻车CUDA 和 TensorFlow 2.0 版本互相踩现象按教程装完 TensorFlow 2.0 GPU 版import tensorflow就报Could not load dynamic library libcudnn.so.8甚至 CPU 版跑起来后一动 GPU 就崩。原因深度学习环境配置是玄学重灾区。TensorFlow 2.0 对 CUDA 和 cuDNN 版本绑定很死系统里已经装了其他深度学习的 CUDA 库版本一冲突就炸。尤其是 Ubunt 22.04 这种新系统自带的 gcc 和 CUDA 版本跟 TF 2.0 的编译环境完全对不上。解决我现在的做法是直接用 Anaconda 创建独立环境用 conda 装指定版本的 cudatoolkit 和 cudnn让 TensorFlow 2.0 用环境里这套而不是系统全局的。命令大致是conda create -n tf2 python3.7然后conda install cudatoolkit10.1 cudnn7.6最后pip install tensorflow-gpu2.0.0。装完别急着训先跑一行tf.constant(1) tf.constant(1)能过再进项目。5. 从「能生成」到「像首诗」温度采样、押韵校验与人工评估模型能续写字串之后真正的分水岭在于怎么把它采出来的序列变成能见人的诗。这个环节靠的不是网络再训一轮而是采样策略和规则后的微调。温度采样是这里最值得反复试的技巧。同一套模型temperature 设为 0.6 时生成的诗平实但常常押韵设为 1.0 时句子通顺度下降但偶尔会出现「云破月来花弄影」级别的意外之喜。我的习惯是先用 0.8 生成一批再用 1.1 生成一批最后混合挑选。代码里已经写了/ temperature这一步如果你发现生成结果过于保守全是「山」「水」「月」的排列组合就把它往 1.2 调。押韵校验可以用最简单的规则先定你要押的韵脚比如「ing」「an」「u」然后检查每句最后一个字是否落在常见韵部表里。常见做法是准备一个韵脚表文本每行一个韵部生成后查最后五个字是否命中同韵部。这个校验不经过任何模型纯粹是硬规则但它能快速筛掉一大半不合格的候选诗。格律方面可以轻量验证五言还是七言把生成文本按句号切分每段字数是否等于五或七的倍数不是就丢弃。我在这个环节通常不追求绝对工整因为唐诗的平仄规律比押韵复杂得多硬套格律会把生成的诗搅得死板。人工评估的快速方法是一次生成十首打印到文本文件里第二天再看。隔一夜之后能明显分辨哪几首只是「字词通顺」哪几首真的有「诗意」。你自己的诗感大概率比 loss 曲线更可靠。有个血泪经验不要看着终端里一行行蹦字就觉得效果不错那些字串单独看很容易被大脑自动补齐成通顺句子等复制出来细读才发现全是破绽。把生成结果放到独立的文本阅读器里关闭任何自动预测和打字联想再看才是真实水平。我最早跑通这个项目时唯一遗憾是没早点做采样策略这一层白白多训了十几轮模型以为效果差是训练不足。其实模型早就记住了格律只是采样温度太低、频率偏斜没处理让输出显得呆。后来把温度采样和韵脚过滤加进去同样的模型权重生成质量直接上一个台阶。这个方向做完你会对流式序列、概率分布和文本生成的基本套路都有了一层手感做聊天机器人或评论生成时很多参数直接能迁移。希望帮到你。本文还有配套的精品资源点击获取