☰
RNN唐诗写作实战:用TensorFlow 2.0与LSTM实现字符级文本生成
2026/10/7 12:46:04 网站建设 项目流程

简介:面向深度学习和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, encoding="utf-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_length=20基本能覆盖一整句到两句的上下文;对七言诗,seq_length=28会更有优势。训练时其实可以两个长度都试,先拿 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_sequences=True), # 第二层 LSTM 只返回最后时间步的输出,因为最终只需预测一个字符 tf.keras.layers.LSTM(256), # 输出层不加 softmax,训练时用 from_logits=True 更稳 tf.keras.layers.Dense(vocab_size) ]) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True), ) model.summary()

逻辑说明:Embedding 的输入是形状为(batch, seq_length)的整数张量,输出形状变成(batch, seq_length, 128),每个字符被表示成一个 128 维连续向量。第一层 LSTM 的return_sequences=True让它在每个时间步都输出隐藏状态,这样第二层 LSTM 能继续从头到尾处理整个序列;第二层return_sequences=False只输出最后一个时间步的隐藏状态。最后的 Dense 层把隐藏状态映射到词汇表维度,每个位置的值代表对应字符的 logit。

参数说明:LSTM 单元数 256 是文本生成里常见的中间值,太小拟合能力不够,太大在 CPU 上跑不动。Embedding 维度 128 和 LSTM 单元数 256 不需要严格绑定,但你如果同时翻倍它们,训练时间会近似平方上涨。这里没有用 Dropout,因为字符级生成任务很小,两层 LSTM 的 256 维足够扛得住,加了反而容易欠拟合;如果你语料更大或想更强地防过拟合,可以在每个 LSTM 层加dropout=0.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_remainder=True) # 自定义采样函数:temperature 控制随机程度 def generate_text(seed, length=32, temperature=0.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_samples=1)[-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, training=True) loss = tf.keras.losses.sparse_categorical_crossentropy( y_batch, logits, from_logits=True ) 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(f"Epoch {epoch+1}, Loss: {total_loss / len(dataset):.4f}") seed = "床前明月光" print("样例:", generate_text(seed, 28, temperature=0.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 避坑 1:loss 卡在词汇表对数值附近不动

现象:第一次训练,loss 始终在ln(vocab_size)附近震荡,比如词汇表 2000,loss 就一直在 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 那样高度并行,单元数翻倍就是实打实的时间翻倍。

解决:一组推荐参数组合是 Embedding=128、LSTM=128、seq_length=20、batch_size=128,先跑通验证流程,再逐步加量。训练前确认 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 python=3.7,然后conda install cudatoolkit=10.1 cudnn=7.6,最后pip install tensorflow-gpu==2.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 曲线更可靠。有个血泪经验:不要看着终端里一行行蹦字就觉得效果不错,那些字串单独看很容易被大脑自动补齐成通顺句子,等复制出来细读才发现全是破绽。把生成结果放到独立的文本阅读器里,关闭任何自动预测和打字联想,再看才是真实水平。

我最早跑通这个项目时,唯一遗憾是没早点做采样策略这一层,白白多训了十几轮模型,以为效果差是训练不足。其实模型早就记住了格律,只是采样温度太低、频率偏斜没处理,让输出显得呆。后来把温度采样和韵脚过滤加进去,同样的模型权重生成质量直接上一个台阶。这个方向做完,你会对流式序列、概率分布和文本生成的基本套路都有了一层手感,做聊天机器人或评论生成时很多参数直接能迁移。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询