简介:这份资源面向深度学习与自然语言处理方向的初学者及课程设计者,围绕循环神经网络LSTM构建唐诗生成模型,解决从零搭建文本生成项目的实战需求。包内共12个文件,以6个Python脚本为核心,涵盖数据处理、模型定义、训练与评估全流程;另含训练好的h5权重文件、诗歌语料txt、模型结构图png、答辩pptx与实验报告pdf,压缩包约20.18MB,结构紧凑便于直接复现。实验要求以“日、红、山、夜、湖、海、月”等词作为起始词生成诗句,可帮助读者理解序列建模、词嵌入与采样策略。已有1537人学习下载,适合希望快速跑通RNN唐诗写作、并借助答辩材料与实验报告完成课程汇报的读者参考。
1. RNN写唐诗:为什么你的模型总在“床前明月光”后面接“疑是地上霜”
用RNN做唐诗生成,最让人抓狂的不是模型不收敛,而是它永远在“床前明月光”后面接“疑是地上霜”。你喂了全唐诗,调了学习率,换了优化器,它还是像个背诗机器,只会复读训练集里的高频句子。这不是模型笨,是你没搞懂RNN在序列生成任务里的“记忆惯性”——它倾向于复制局部n-gram模式,而不是真正学会平仄、押韵和意象组合。
这个方向适合两类人:一是刚学完深度学习基础、想找个能跑通又有文化味的项目练手;二是已经做过文本分类、想进阶到序列生成任务的工程师。TensorFlow 2.0把动态图、Keras接口和训练循环都简化了,用tf.keras搭建一个字符级RNN唐诗生成器,代码量不大,但里面藏着数据预处理、序列切分、状态保持、温度采样这些关键细节。搞定了这些,你就能让模型写出“秋风生渭水,落叶满长安”这种像样的句子,而不是永远在“举头望明月”后面接“低头思故乡”。
2. 用TensorFlow 2.0搭一个字符级RNN:从全唐诗到可训练数据集
2.1 为什么选字符级而不是词级
唐诗的词汇量不大,但用词级建模会遇到两个问题:一是分词工具对古诗不友好,“白日依山尽”可能被切成“白日/依山/尽”,丢失了字与字之间的韵律关系;二是词表外问题严重,唐诗里大量生僻字和专有名词,词级模型遇到没见过的词只能吐UNK。字符级建模把每个汉字当成一个token,全唐诗常用字也就六千左右,词表可控,而且能学到“平仄平仄平平仄”这种字符级的节奏模式。
TensorFlow 2.0里用tf.keras.layers.Embedding做字符嵌入,维度不用太大,128或256就够了。嵌入层后面接tf.keras.layers.GRU或LSTM,GRU参数少一点,训练快,对中小规模数据集更友好。输出层用tf.keras.layers.Dense映射回词表大小,配合softmax得到每个位置的概率分布。
2.2 数据清洗与序列切分
全唐诗原始数据里混着标题、作者、注释和标点,直接拿来训练会让模型学会写“作者:李白”这种废话。清洗步骤分三步:先按行读取,过滤掉包含“卷”、“作者”、“朝代”等关键词的行;再用正则去掉括号注释和多余空格;最后只保留汉字和常用标点(逗号、句号),其他字符全部丢弃。
import re import tensorflow as tf def clean_poem(line): # 去掉标题、作者、注释等非正文内容 line = re.sub(r'[((].*?[))]', '', line) # 只保留汉字和中文标点 line = re.sub(r'[^\u4e00-\u9fa5,。!?]', '', line) return line.strip() def load_dataset(path): poems = [] with open(path, 'r', encoding='utf-8') as f: for line in f: line = clean_poem(line) if len(line) >= 10: # 过滤太短的残句 poems.append(line) return poems poems = load_dataset('quantangshi.txt') print(f'清洗后共 {len(poems)} 首,示例:{poems[0]}')清洗逻辑说明:re.sub(r'[((].*?[))]', '', line)用非贪婪匹配去掉括号注释;[^\u4e00-\u9fa5,。!?]保留汉字和四个常用标点,其他全删。长度阈值设10,是因为五言绝句至少20字,太短的可能是残句或标题残留。
2.3 构建词表与序列映射
字符级建模需要两个映射:字到ID、ID到字。用tf.keras.preprocessing.text.Tokenizer或者手写一个都行,手写更可控。
def build_vocab(poems): chars = sorted(set(''.join(poems))) char2idx = {c: i for i, c in enumerate(chars)} idx2char = {i: c for c, i in char2idx.items()} return char2idx, idx2char char2idx, idx2char = build_vocab(poems) vocab_size = len(char2idx) print(f'词表大小:{vocab_size}')词表大小一般在5000到7000之间。如果超过8000,说明数据里混入了太多生僻字或噪声,需要回头检查清洗步骤。
2.4 用tf.data构建训练批次
序列生成任务的输入和输出是错开一位的:输入是“床前明月”,输出是“前明月光”。用tf.data.Dataset做滑动窗口切分,再配合batch和prefetch,训练效率比手动喂数据高很多。
SEQ_LEN = 32 BATCH_SIZE = 64 def make_sequences(poems, char2idx, seq_len): inputs, targets = [], [] for poem in poems: ids = [char2idx[c] for c in poem if c in char2idx] for i in range(0, len(ids) - seq_len, 1): inputs.append(ids[i:i+seq_len]) targets.append(ids[i+1:i+seq_len+1]) return inputs, targets inputs, targets = make_sequences(poems, char2idx, SEQ_LEN) dataset = tf.data.Dataset.from_tensor_slices((inputs, targets)) dataset = dataset.shuffle(10000).batch(BATCH_SIZE, drop_remainder=True) dataset = dataset.prefetch(tf.data.AUTOTUNE)SEQ_LEN=32是个经验值:太短学不到长距离依赖,太长显存吃紧且梯度容易消失。drop_remainder=True保证每个批次形状一致,避免最后一个不完整批次导致RNN状态维度报错。prefetch让数据加载和模型计算重叠,GPU利用率能提升20%以上。
3. 模型定义与训练循环:GRU、状态保持和梯度裁剪
3.1 用tf.keras定义字符级GRU模型
TensorFlow 2.0推荐用子类化tf.keras.Model或者函数式API。字符级RNN不需要太深,一层Embedding + 一层GRU + 一层Dense就够了。GRU的return_sequences=True让每个时间步都有输出,stateful=False在训练时更简单,因为批次之间不需要保持状态。
class PoemRNN(tf.keras.Model): def __init__(self, vocab_size, embed_dim=256, rnn_units=512): super().__init__() self.embedding = tf.keras.layers.Embedding(vocab_size, embed_dim) self.gru = tf.keras.layers.GRU( rnn_units, return_sequences=True, return_state=True, recurrent_initializer='glorot_uniform' ) self.fc = tf.keras.layers.Dense(vocab_size) def call(self, x, states=None, training=False): x = self.embedding(x) if states is None: output, state = self.gru(x) else: output, state = self.gru(x, initial_state=states) logits = self.fc(output) return logits, state model = PoemRNN(vocab_size)embed_dim=256和rnn_units=512是平衡参数量和表达能力的常用配置。如果显存不够,把rnn_units降到256,效果不会差太多。recurrent_initializer='glorot_uniform'比默认的orthogonal在字符级任务上收敛更稳。
3.2 损失函数与优化器选择
序列生成用tf.keras.losses.SparseCategoricalCrossentropy,因为目标标签是整数ID而不是one-hot。from_logits=True让损失函数内部做softmax,数值更稳定。优化器用Adam,学习率设1e-3,训练中期如果loss震荡,降到5e-4。
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3) loss_fn = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True) @tf.function def train_step(x, y, states=None): with tf.GradientTape() as tape: logits, states = model(x, states, training=True) loss = loss_fn(y, logits) grads = tape.gradient(loss, model.trainable_variables) # 梯度裁剪,防止RNN梯度爆炸 grads, _ = tf.clip_by_global_norm(grads, 5.0) optimizer.apply_gradients(zip(grads, model.trainable_variables)) return loss, statestf.clip_by_global_norm(grads, 5.0)是RNN训练的标配。梯度爆炸在字符级RNN里很常见,表现为loss突然变成NaN。裁剪阈值5.0是经验值,太小会拖慢收敛,太大起不到保护作用。
3.3 训练循环与检查点保存
训练循环里每100步打印一次loss,每5个epoch保存一次权重。用tf.train.Checkpoint管理模型和优化器状态,方便中断后恢复。
import os checkpoint_dir = './training_checkpoints' os.makedirs(checkpoint_dir, exist_ok=True) checkpoint_prefix = os.path.join(checkpoint_dir, 'ckpt') checkpoint = tf.train.Checkpoint(optimizer=optimizer, model=model) EPOCHS = 30 for epoch in range(EPOCHS): total_loss = 0 num_batches = 0 for batch_x, batch_y in dataset: loss, _ = train_step(batch_x, batch_y) total_loss += loss.numpy() num_batches += 1 if num_batches % 100 == 0: print(f'Epoch {epoch+1}, Step {num_batches}, Loss: {loss.numpy():.4f}') avg_loss = total_loss / num_batches print(f'Epoch {epoch+1} 完成,平均Loss: {avg_loss:.4f}') if (epoch + 1) % 5 == 0: checkpoint.save(file_prefix=checkpoint_prefix)训练30个epoch在单卡上大概需要2到4小时,取决于数据量和GPU型号。如果loss降到1.5以下,生成的句子已经有点样子了;降到1.0以下,平仄和押韵会明显改善。
4. 生成阶段:温度采样、重复惩罚和“玄学”调参
4.1 用温度参数控制生成多样性
训练好的模型输出的是每个位置的概率分布,直接取argmax会得到最保守的句子,而且容易陷入重复循环。温度采样把logits除以温度系数T,再softmax。T=1.0是原始分布,T<1.0让分布更尖锐(更保守),T>1.0让分布更平坦(更随机)。
def generate_poem(model, char2idx, idx2char, start_str, length=50, temperature=0.8): input_ids = [char2idx[c] for c in start_str if c in char2idx] input_tensor = tf.expand_dims(input_ids, 0) states = None generated = list(start_str) for _ in range(length): logits, states = model(input_tensor, states, training=False) logits = logits[:, -1, :] / temperature probs = tf.nn.softmax(logits, axis=-1) next_id = tf.random.categorical(tf.math.log(probs), num_samples=1)[0, 0].numpy() next_char = idx2char[next_id] generated.append(next_char) input_tensor = tf.expand_dims([next_id], 0) return ''.join(generated)temperature=0.8是我试过比较平衡的值:太低(0.5)会写出“春风吹又生”这种高频句,太高(1.2)会出现乱码和生造词。tf.random.categorical从概率分布里采样,比argmax灵活得多。
4.2 重复惩罚与n-gram阻断
即使有温度采样,模型还是可能连续输出“明月明月明月”。加一个重复惩罚:对已经出现过的字符,在logits上减去一个惩罚项。更粗暴但有效的方法是n-gram阻断:如果当前生成的2-gram或3-gram在已生成序列里出现过,直接把对应字符的概率置零。
def apply_repetition_penalty(logits, generated_ids, penalty=1.5): for token_id in set(generated_ids): logits[0, token_id] /= penalty return logitspenalty=1.5对唐诗生成比较合适,再大容易破坏语义连贯性。这个操作在softmax之前做,注意不要改到原始logits张量。
4.3 用不同起始词测试模型
生成阶段最直观的验证方法是换不同的起始词,看模型能不能接出风格一致的句子。比如“秋风”开头应该接出萧瑟的意象,“春江”开头应该接出明快的意象。如果模型对任何起始词都输出同一套句子,说明训练不充分或者温度太低。
for start in ['秋风', '春江', '明月', '青山']: poem = generate_poem(model, char2idx, idx2char, start, length=30, temperature=0.8) print(f'起始词:{start}') print(poem) print('---')这个测试能快速暴露模型是“学会了”还是“背会了”。如果“明月”后面永远接“几时有”,那就是过拟合到训练集的高频模式了。
5. 避坑与排查:RNN唐诗生成里最容易翻车的5个地方
5.1 现象:Loss降到0.5以下,但生成的句子全是训练集原句
原因:模型参数量太大或者训练epoch太多,RNN记住了整个训练集,而不是学到字符级规律。字符级RNN在5000词表、512隐藏单元下,参数量大概在300万左右,全唐诗有5万多首,过拟合风险很高。
解决:加Dropout。在Embedding后面和GRU后面各加一层tf.keras.layers.Dropout(0.3)。如果已经过拟合,减少rnn_units到256,或者把训练数据砍掉一半。另一个办法是加L2正则化,在GRU的kernel_regularizer里设tf.keras.regularizers.l2(1e-5)。
5.2 现象:训练到一半loss突然变成NaN
原因:梯度爆炸。RNN的BPTT算法在长序列上连乘梯度,很容易超过浮点数范围。即使加了梯度裁剪,如果裁剪阈值设得太大(比如10.0),还是可能炸。
解决:把clip_by_global_norm的阈值降到1.0到5.0之间。同时检查学习率,Adam的1e-3在RNN上偏大,换成5e-4或者3e-4。如果还是NaN,在Embedding后面加tf.keras.layers.LayerNormalization()。
5.3 现象:生成的句子没有标点,或者标点位置完全错乱
原因:清洗阶段把标点当成了普通字符,但标点在训练数据里的分布和汉字不一样。逗号和句号的出现频率远高于其他字符,模型会倾向于在任意位置插入标点。
解决:把标点单独处理。一种做法是在损失函数里给标点位置更高的权重,另一种是在生成阶段用规则后处理:每7个字或14个字强制插入逗号或句号。更优雅的做法是训练两个模型,一个生成汉字,一个预测标点位置。
5.4 现象:模型只输出五言,写不出七言
原因:训练数据里五言绝句和律诗占多数,七言比例低。字符级RNN没有显式的“句长”概念,它只是按概率生成下一个字符,所以会偏向高频长度。
解决:在数据预处理阶段按句长分层采样,保证五言和七言的比例接近1:1。或者在生成阶段用length参数控制总长度,但这样治标不治本。根本办法是在输入里加一个“句长标记”,比如用特殊字符表示“这是五言”或“这是七言”。
5.5 现象:换一台机器加载检查点报错,提示变量形状不匹配
原因:tf.train.Checkpoint保存的是变量名和形状,如果在新机器上重新定义了模型结构,但vocab_size或rnn_units变了,加载就会失败。
解决:把模型超参数(vocab_size、embed_dim、rnn_units)存成JSON,加载时先读JSON再建模型。检查点目录里同时保存char2idx和idx2char的pickle文件,保证词表一致。如果只是推理,用model.save('poem_model')保存完整模型,用tf.keras.models.load_model加载,不依赖检查点。
6. 让生成质量再上一个台阶:束搜索与平仄约束的工程化技巧
温度采样能出好句子,但稳定性不够。如果你想把RNN唐诗生成用到实际场景里,比如给文创产品做辅助创作,束搜索(Beam Search)比随机采样靠谱得多。束搜索在每个时间步保留top-k个候选序列,最后选整体概率最高的那个。k一般取3到5,太大生成结果会趋同,太小又退化成贪心搜索。
def beam_search(model, char2idx, idx2char, start_str, length=30, beam_width=3): sequences = [[list(start_str), 0.0, None]] for _ in range(length): all_candidates = [] for seq, score, states in sequences: input_ids = [char2idx[c] for c in seq[-1:]] input_tensor = tf.expand_dims(input_ids, 0) logits, new_states = model(input_tensor, states, training=False) logits = logits[:, -1, :] probs = tf.nn.softmax(logits, axis=-1).numpy()[0] top_k = np.argsort(probs)[-beam_width:] for k in top_k: candidate = [seq + [idx2char[k]], score - np.log(probs[k]), new_states] all_candidates.append(candidate) sequences = sorted(all_candidates, key=lambda x: x[1])[:beam_width] return ''.join(sequences[0][0])束搜索的代价是计算量翻倍,但生成质量提升明显。我一般先用温度采样快速验证模型状态,确认loss正常后再用束搜索出最终结果。
平仄约束是另一个提升方向。唐诗的平仄规律可以用规则编码:五言句的平仄格式有四种基本型,七言句也有四种。在生成阶段,对每个位置根据平仄规则限制候选字符集合。具体做法是维护一个“平声字表”和“仄声字表”,在softmax之后把不符合平仄的字符概率置零。这个操作会降低生成多样性,但能显著提升“像唐诗”的程度。
def apply_tonal_constraint(logits, position, pattern): # pattern: 'P'表示平声,'Z'表示仄声 if pattern[position] == 'P': for idx, char in idx2char.items(): if char in ze_sheng_set: logits[0, idx] = -1e9 else: for idx, char in idx2char.items(): if char in ping_sheng_set: logits[0, idx] = -1e9 return logits平声字表和仄声字表可以从《平水韵》里整理,常用字大概两千个。这个约束在生成阶段加,不影响训练。实测下来,加了平仄约束后,生成的句子在韵律上明显更顺,但代价是有些位置会生成比较生僻的字。
最后说一个我踩过的坑:不要用训练集里的句子做起始词去评估模型。训练集里的句子模型见过,接出来的东西可能是记忆而不是泛化。我一般从《全唐诗》里随机抽20首没参与训练的句子,取前四个字做起始词,人工评估生成结果的流畅度和意象合理性。这个习惯帮我省了很多“后悔药”——有几次loss曲线很漂亮,但一测泛化就露馅了。
希望帮到你。
本文还有配套的精品资源,点击获取