☰
TensorFlow 2.0 RNN唐诗生成实战:从语料到写诗
2026/10/1 13:11:05 网站建设 项目流程

简介:这份资源面向深度学习与自然语言处理方向的初学者及课程实践者,围绕循环神经网络LSTM构建唐诗生成模型,解决从零搭建文本生成项目的入门难题。压缩包共12个文件,约20.18MB,包含6个Python源码文件、2个文本数据文件,以及模型结构图、答辩幻灯片、训练权重和实验报告PDF,覆盖数据加载、模型定义、训练与评估的完整流程。实验以“日、红、山、夜、湖、海、月”等词作为起始词生成诗句,帮助读者理解序列建模与文本生成的核心思路。目前已有1537人学习下载。读者可获得可直接运行的代码工程、配套答辩PPT与实验报告,既能对照代码逐行理解LSTM实现细节,也能借助报告梳理实验目的与结果分析,适合课程设计、毕业设计或答辩展示场景使用。

1. RNN唐诗写作:用TensorFlow 2.0把「深度学习实战」跑成自己的东西

很多人第一次接触 RNN 循环神经网络,都是被「用深度学习写唐诗」这个场景吸引的。它不像图像识别那样需要显卡堆料,也不像大模型微调那样动辄几十 G 显存,一台普通笔记本、一个 TensorFlow 2.0 环境,就能把「深度学习实战」这四个字从课本拽到终端里。这个标题真正要解决的问题是:给定一批唐诗语料,怎么用 RNN 学出「平仄、押韵、意象搭配」的隐式规律,再让模型一个字一个字地生成新句子。适合谁?适合刚学完深度学习基础、想找一个能跑通、能改参数、能看见输出变化的实战项目的人;也适合想理解「序列建模到底在学什么」的从业者。它不承诺写出李白级别的诗,但能让你把 embedding、RNN、softmax、温度采样这条链路完整走一遍,并且每一步都能自己调。

2. 唐诗语料怎么变成 RNN 能吃的张量:从原始文本到 tf.data 管道

2.1 为什么选字符级建模而不是词级

唐诗的难点在于:总字数有限,常用字集中,但组合方式极多。如果按词切分,分词器本身就会引入误差,而且唐诗里单字成词的情况太普遍,词表反而不好控制。字符级建模把每个汉字当成一个 token,词表大小通常在一万以内,embedding 维度设 128 或 256 就够用。常见做法是:先统计语料中所有出现过的汉字,按频次排序,保留前 N 个,剩下的统一映射到<unk>。这样做的代价是序列变长,但唐诗本身五言、七言居多,序列长度可控,RNN 完全吃得下。

我一般会先把语料整理成一行一首的纯文本,标点统一成中文全角,去掉注释和标题。然后做三件事:建立字表、把每首诗转成 id 序列、用 padding 对齐到固定长度。注意,padding 不要用 0 直接混进去,最好单独留一个<pad>id,并在 loss 里 mask 掉,否则模型会学会「预测 padding」,生成时容易输出空白。

2.2 用 TensorFlow 2.0 构建 tf.data 输入管道

TensorFlow 2.0 的tf.data是处理序列数据的标准入口。下面这段代码把「一行一首诗」的文本文件转成(input_seq, target_seq)对,其中 target 是 input 向右移一位。逻辑说明:先按字符切分,再查表转 id,最后用window做滑动窗口。参数说明:seq_len控制单次输入长度,唐诗建议 32 到 48;batch_size在 64 到 128 之间,显存小就降到 32;buffer_size设为语料行数即可,不用太大。

import tensorflow as tf # 读取语料,每行一首诗 with open("tangshi.txt", "r", encoding="utf-8") as f: lines = [line.strip() for line in f if line.strip()] # 建立字表 chars = sorted(set("".join(lines))) char2idx = {c: i + 1 for i, c in enumerate(chars)} # 0 留给 padding idx2char = {i: c for c, i in char2idx.items()} vocab_size = len(char2idx) + 1 # 转 id 序列 sequences = [[char2idx[c] for c in line] for line in lines] # 统一长度,短的补 0,长的截断 seq_len = 40 padded = tf.keras.preprocessing.sequence.pad_sequences( sequences, maxlen=seq_len, padding="post", truncating="post", value=0 ) # 构造输入和目标:目标为输入右移一位 dataset = tf.data.Dataset.from_tensor_slices((padded[:, :-1], padded[:, 1:])) dataset = dataset.shuffle(10000).batch(64).prefetch(tf.data.AUTOTUNE)

这段代码跑完,你可以打印vocab_size看看字表规模,一般唐诗选集在 6000 到 9000 之间。如果超过一万,说明语料里混入了太多生僻字或异体字,建议先做一轮清洗。pad_sequences的value=0对应 padding id,后面在 loss 里要忽略掉。

2.3 语料清洗的三个硬指标

第一,去掉长度小于 10 个字的残句,这些多半是标题或注释混进来了。第二,统一标点,把英文逗号句号替换成中文全角,否则字表里会多出几十个无意义符号。第三,检查重复行,重复率超过 5% 说明语料有大量复制粘贴,会导致模型过拟合到某几首。我一般会写一个简单的统计脚本,输出总行数、平均长度、字表大小、重复行数,四个数字都正常了再进模型。

3. 用 TensorFlow 2.0 搭一个能写诗的 RNN:Embedding + GRU + Dense 的最小实现

3.1 模型结构选型:SimpleRNN、LSTM 还是 GRU

TensorFlow 2.0 里tf.keras.layers.RNN家族有三个常用成员:SimpleRNN、LSTM、GRU。SimpleRNN 最容易出现梯度消失,写出来的诗容易重复同一句;LSTM 参数最多,收敛稳但训练慢;GRU 是折中方案,参数量比 LSTM 少三分之一,效果在短序列上几乎持平。唐诗生成这个场景,序列长度 40 左右,GRU 是性价比最高的选择。如果你用 SimpleRNN 跑,建议把学习率降到 1e-3 以下,否则 loss 会震荡。

层数方面,单层 GRU 加 256 隐藏单元已经能出像样的句子。想再提升,可以堆两层,但第二层要加return_sequences=True,并且第一层要加dropout。注意,dropout 不要设太大,0.2 到 0.3 之间,太大模型会学不动,生成结果偏保守。

3.2 完整模型代码与参数说明

下面这个模型是「embedding → GRU → GRU → Dense → softmax」的结构。逻辑说明:embedding 把 id 映射成稠密向量,GRU 逐时间步处理,最后一层 Dense 输出每个字在词表上的概率。参数说明:embedding_dim=256是常见起点,语料小于 5 万行可以降到 128;gru_units=256对应隐藏状态维度;dropout=0.2防止过拟合;learning_rate=1e-3配合 Adam 优化器。

import tensorflow as tf vocab_size = 8000 # 按实际字表调整 embedding_dim = 256 gru_units = 256 model = tf.keras.Sequential([ tf.keras.layers.Embedding(vocab_size, embedding_dim, mask_zero=True), tf.keras.layers.GRU(gru_units, return_sequences=True, dropout=0.2), tf.keras.layers.GRU(gru_units, return_sequences=True, dropout=0.2), tf.keras.layers.Dense(vocab_size, activation="softmax") ]) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False), metrics=["accuracy"] ) model.summary()

mask_zero=True很关键,它让 embedding 层自动忽略 padding 位置,后面的 GRU 和 loss 都会继承这个 mask。如果你忘了加,模型会把 padding 当成正常字符学,生成时开头容易输出一串空白。from_logits=False是因为 Dense 已经带了 softmax,如果去掉 softmax 改成from_logits=True,数值稳定性会更好,但这里为了直观先保留。

3.3 训练循环与回调:什么时候该停

训练用model.fit就行,但有几个细节。第一,epochs先设 50,看 loss 曲线,如果 20 轮后还在明显下降,可以加到 100。第二,加EarlyStopping,监控loss,patience=5,防止过拟合。第三,加ModelCheckpoint,只保存验证 loss 最低的权重,后面生成时加载这个权重,不要用最后一轮的。

callbacks = [ tf.keras.callbacks.EarlyStopping(monitor="loss", patience=5, restore_best_weights=True), tf.keras.callbacks.ModelCheckpoint("best_model.h5", monitor="loss", save_best_only=True) ] history = model.fit(dataset, epochs=50, callbacks=callbacks)

训练时观察 loss:如果从 8 降到 3 左右,说明模型已经学到字频和常见搭配;降到 2 以下,生成的诗会开始有「像诗」的感觉,但也会出现重复。如果 loss 降到 1 以下,大概率过拟合了,生成结果会大量照抄训练集。我一般会在 loss 降到 2.5 到 3.5 之间停下来,这个区间的生成结果最有「创作感」。

4. 生成阶段:温度采样、重复惩罚与「写诗」的玄学调参

4.1 温度参数到底在调什么

模型输出的是每个位置的概率分布,直接取 argmax 会得到最保守的句子,读起来像顺口溜。温度参数T用来缩放 logits:T越小,分布越尖锐,生成越保守;T越大,分布越平坦,生成越随机。唐诗生成的经验值:T=0.6到0.8之间比较平衡,T=1.0会开始出现不搭的意象,T=1.5以上基本是乱码。我一般会同时跑三个温度,各生成十首,人工挑一挑,再决定最终用哪个。

import numpy as np def generate(model, start_char, char2idx, idx2char, length=40, temperature=0.7): input_seq = [char2idx.get(start_char, 0)] generated = [] for _ in range(length): input_tensor = tf.expand_dims(input_seq, 0) predictions = model(input_tensor)[0, -1, :] / temperature predicted_id = tf.random.categorical(tf.expand_dims(predictions, 0), num_samples=1)[0, 0].numpy() input_seq.append(predicted_id) generated.append(idx2char.get(predicted_id, "")) return "".join(generated)

tf.random.categorical按概率采样,比 argmax 灵活。input_seq只保留最近的部分也可以,但 GRU 状态会累积,短序列影响不大。注意,如果生成的 id 是 0(padding),直接跳过,不要输出。

4.2 重复惩罚:让模型别老写「春风」

RNN 生成有个通病:同一个字或同一个意象反复出现。解决办法是在 logits 上做重复惩罚:对已经生成过的 token,把对应 logit 减去一个惩罚值。惩罚值设 1.0 到 2.0 之间,太大句子会不通顺。这个技巧在 TensorFlow 里手动实现就行,不需要改模型结构。

def generate_with_penalty(model, start_char, char2idx, idx2char, length=40, temperature=0.7, penalty=1.5): input_seq = [char2idx.get(start_char, 0)] generated_ids = [] for _ in range(length): input_tensor = tf.expand_dims(input_seq, 0) logits = model(input_tensor)[0, -1, :] / temperature for gid in set(generated_ids): logits = tf.tensor_scatter_nd_sub(logits, [[gid]], penalty) predicted_id = tf.random.categorical(tf.expand_dims(logits, 0), num_samples=1)[0, 0].numpy() input_seq.append(predicted_id) generated_ids.append(predicted_id) return "".join(idx2char.get(i, "") for i in generated_ids)

tensor_scatter_nd_sub在指定位置减去惩罚值。注意,惩罚只对已经生成过的 id 生效,不要对 padding 做惩罚。如果发现句子开始出现生僻字,把penalty降到 1.0 以下。

4.3 起始字符的选择与「藏头诗」玩法

起始字符决定生成方向。用「春」开头,模型会往「春风吹又生」这类意象走;用「山」开头,容易出「山高水长」。如果你想做藏头诗,就把每句的第一个字固定,逐句生成。注意,每句生成完后,把上一句的结尾作为下一句的起始,这样句子之间会有连贯性。我试过用「月」开头生成五言,十次里有三次能出「月照寒江雪」这种像样的句子,剩下的七次需要手动挑。

5. 避坑与排查:RNN 写诗翻车的五个血泪现场

5.1 现象:生成结果全是「的的的的的」——原因:padding 没 mask

这是最常见的翻车。模型把 padding id 当成高频字符学,因为 padding 在序列里占比很大。解决:embedding 层加mask_zero=True,loss 用SparseCategoricalCrossentropy并确保 mask 传递下去。如果已经训练完了,加载权重后重新生成,不要重新训练。

5.2 现象:loss 降到 1.0 以下,生成的诗和训练集一模一样——原因:过拟合

语料太少或模型太大。解决:先看语料行数,少于 5000 行就减层,把两层 GRU 改成一层,gru_units从 256 降到 128。再加 dropout 到 0.3。如果还不行,用EarlyStopping在 loss 降到 2.5 左右就停。

5.3 现象:训练时 loss 震荡,不下降——原因:学习率太大或 batch 太小

Adam 默认 1e-3,如果 batch 小于 32,梯度噪声大,loss 会跳。解决:把 batch 加到 64 或 128,学习率降到 5e-4。如果还震荡,检查语料里有没有空行或超长行,超长行截断到 40 后,如果只剩几个字,直接丢掉。

5.4 现象:生成时速度极慢,一首诗要几十秒——原因:逐字符调用模型,没有缓存状态

RNN 每次调用都重新计算整个序列,长度 40 时还好,但如果做藏头诗连续生成,时间会累积。解决:用tf.function装饰生成函数,或者手动维护 GRU 状态,每次只输入上一个字符。TensorFlow 2.0 里可以用model.layers[1].reset_states()配合stateful=True,但要注意 batch 维度。

5.5 现象:换了一台机器,加载模型报错——原因:TensorFlow 版本不一致

TensorFlow 2.0 到 2.15 之间,tf.keras的保存格式有变化。解决:训练和生成用同一个环境,保存时用model.save("model.keras")而不是.h5,加载时用tf.keras.models.load_model。如果跨版本,导出权重再重新搭结构加载。

6. 把 RNN 写诗推到「能看」:三个进阶技巧与我的固定习惯

第一个技巧是「预训练 embedding」。如果你手头有更大的中文语料,比如新闻或小说,可以先训练一个字符级 embedding,再迁移到唐诗模型。TensorFlow 2.0 里把 embedding 层设为trainable=False先冻结几轮,再解冻微调。这样字表覆盖更全,生僻字也能有合理向量。第二个技巧是「双模型对比」。同时训一个 LSTM 和一个 GRU,生成时各跑十首,人工挑。GRU 通常更「敢写」,LSTM 更「稳」,两者混着用能覆盖不同风格。第三个技巧是「后处理过滤」。生成结果里如果出现连续三个相同字符,或者出现训练集里没见过的标点组合,直接丢弃重生成。这个过滤规则写起来简单,但能挡掉八成废句。

我自己的固定习惯是:每次训练完,先不急着调温度,而是用T=0.7生成 20 首,打印出来,用笔圈出「像诗」的句子,统计比例。如果比例低于 20%,说明模型还没学好,回去检查语料和 loss;如果高于 40%,说明可以开始调温度和惩罚了。这个习惯帮我省了很多「盲目调参」的时间。另外,我从不追求 loss 降到最低,因为写诗这件事,太准了反而无趣。希望帮到你。

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

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

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

立即咨询