基于RNN的古体诗生成器实战:从数据清洗到模型调参
发布时间:2026/10/13 23:34:42 锦皓数字建站

1. 从一句诗开始这个生成器到底解决什么问题1.1 很多人都问我为什么不用更潮的模型最近朋友圈里又刮起了一股AI写诗的风各种号称大模型的平台随便输入两句话就能生成一首律诗。但说实话这类工具跑出来的东西要么是辞藻堆砌的空壳要么平仄完全不对念起来像被人掐着脖子。我决定自己动手做一个古体诗生成器用的还是被很多人说过时的RNN框架是TensorFlow 2.0。有人可能会问现在Transformer这么流行为什么不用它我的想法很简单——古体诗生成是一个序列到序列的字符级任务数据量小、词汇表窄、依赖长距离上下文。RNN里的LSTM恰恰擅长捕捉这种几十个字范围内的时序关系而且训练成本低普通笔记本GPU就能跑。这个项目不是为了赶时髦而是为了把深度学习中序列建模这件事掰开揉碎讲清楚。1.2 生成器的核心玩法字对字预测所谓古体诗生成器本质就是一个字符级语言模型给定前面的若干个字预测下一个字是什么。比如模型看到床前明月就要尽量预测出光。等到它能连续预测几百步你就可以自己给一句开头让模型把后面的诗接下去。这个目标听起来简单但里面藏着的难点一点都不少古体诗讲究字对、押韵、意象连贯而且五言、七言的句子长度固定。要让模型学会这些规律一个关键前提是——数据够干净模型结构够简单训练过程够温柔。我写这篇文章就是想把我从数据清洗到模型训练再到生成策略的全过程记录下来尤其那些踩过的坑能给想动手练手的人省下几个周末。1.3 这篇文到底适合谁如果你是深度学习的初学者刚学完Python和Keras的基本操作这篇能带你跑通一个完整的文本生成项目。如果你已经会训练图像分类模型但对RNN、序列数据的处理比较陌生这篇能帮你建立起序列建模的直观认识。就算你只是对AI写诗感兴趣也可以直接跳到后半部分看看生成效果和调参技巧。我写完整个项目大概花了两周业余时间模型训练总时长不到半天成本几乎可以忽略。所以别担心门槛跟着思路走你也能搞出一个能写诗的小模型。2. 语料清洗的坑比模型还多公开诗词库如何变成训练集2.1 数据来源与最低要求做文本生成最怕的是垃圾进、垃圾出。我一开始从网上找了一份公开的古诗词语料库里面杂七杂八收了几十万首看起来量大管饱但直接丢进模型训练出来的东西基本是灾难。要先想清楚我们需要什么样的诗词第一必须严格限制在古诗的范畴词、曲、现代打油诗都先放一边因为格律完全不一样第二最好以五言和七言为主四言、六言、杂言诗数量少且格式特殊容易干扰模型学习固定节奏第三诗句总长度要适中一首五言绝句20个字七言律诗56个字超过60个字的可以暂时排除。我最后保留下来大约8万首比较规整的古体诗折算成字符数大概是500万到600万字。这个规模对字符级RNN来说不算大但恰好够用——它既能保证模型见过足够多的字词搭配又不会因为数据太庞杂导致训练时间失控。2.2 清洗规则的具体细节清洗这一步我反复折腾了很久列出几条最重要的规则第一去重。公开语料里同一首诗可能被收录了三四次格式略有差异。我用正文的MD5做粗去重再把正文中所有非中文字符删除后做一次细去重。这个操作直接让数据量缩掉了将近四成。第二过滤异体字和生僻字。古体诗里经常会用到已经很少出现的字比如閒峯之类。直接删掉吧多少有点可惜保留吧模型会被大量低频字符带偏。我的处理方式是先把所有字符做频率统计保留出现次数前2500的字其余统一替换成一个特殊的UNK标记。这保证了字表不会太大也能让模型学会处理生僻字的兜底逻辑。第三统一标点。原语料里的标点非常乱全角半角、中文英文混用。我干脆把所有标点删除只保留汉字一首诗用换行符分隔诗句和诗句之间用空格占位符模拟停顿。你可能觉得删掉标点会影响模型对断句的学习但经过实验字符级模型完全可以自己学会在完整句子的边界处输出换行不会因为少了逗号句号就胡来。2.3 字表设计与向量化的隐性约束字表是整个项目的语言字典。我用一个简单的collections.Counter统计所有字符的出现次数然后按频率从高到低排列生成一个char2id和id2char的映射表。这里有一个容易被忽略的问题字表里要不要加入空格和换行我的答案是必须加。空格代表一句之内的停顿换行代表句子的结束这两个字符在生成阶段至关重要。如果字表里没有换行标记模型就不知道怎么结束一首诗只会一路输出到底没法形成五言四句或七言八句的结构。字表确定后每一首诗就变成了一个整数序列。比如床前明月光会变成类似[231, 15, 47, 89, 212]的ID序列。模型输入一个定长的窗口输出下一个字符的ID。2.4 序列切分的滑动窗口细节把整首诗变成训练样本最常用的方式是滑动窗口切分。我设定sequence_length 32也就是说用前32个字符去预测第33个字符然后窗口向后滑动一个字符再用第2到第33个字符预测第34个字符依次类推。一首五言绝句只有20多个字去掉换行和空格也就30个字符左右刚好能凑成几个窗口。但这里有个问题窗口会跨过一首诗的结尾把下一首诗的开头也拉进来模型就会学到两首诗之间可以无缝拼接生成的文本会出现莫名其妙的穿越。我的解决办法是诗与诗之间用一个特殊的|endoftext|分隔符隔开当滑动窗口碰到这个分隔符时就强制重置开始新一轮切分。这个细节很多人不做但实测下来它对生成文本的完整性影响极大。3. 模型搭建三层结构如何把文字变成向量再变回文字3.1 Embedding层让模型自己找字的语义坐标模型的第一层是一个Embedding层它的作用是把离散的字符ID映射成稠密的向量。举例来说字表大小是3000Embedding维度设为128那么每个字符都会变成一个128维的浮点向量。为什么要这么做因为字符之间本来没有数值大小关系床是120前是80不能直接拿120和80做算术。Embedding层相当于让模型自己去学习每一个字在多维空间里的语义坐标语义相近的字比如山和峰会落在相近的区域模型学习后续模式就容易很多。Embedding层还有一个隐性优势它天然包含了对字频的感知。高频字会被放到相对平滑的向量空间低频字则会被挤压到边缘地带。在训练过程中低频字的梯度更新频率低容易被冻住这也是为什么我前面坚持用UNK替换过少的生僻字。3.2 LSTM层记忆跨过几十个字的上下文中间层我用了LSTM。LSTM全称是长短期记忆网络它相比普通RNN最大的改进是引入了一个细胞状态通道像一条传送带贯穿整个序列。每一步都可以选择往传送带上写入新的信息或者擦除旧的信息。你可能会好奇为什么LSTM能记住几十个字之前的山而不被中间的字干扰秘密在于三个门控遗忘门决定要不要忘掉旧状态输入门决定新信息写入多少输出门决定当前时刻的隐藏状态对外输出多少。这三个门都是由当前输入和上一时刻的隐藏状态共同计算得出的。对于古体诗来说最长依赖不过是一联内上下句的呼应比如白日依山尽黄河入海流白日和黄河跨了大约10个字。LSTM处理这种长度的依赖绰绰有余。我选了128个LSTM单元也就是隐藏状态维度128。这个数字不大但搭配字表大小3000和Embedding维度128已经能形成足够强的表达能力。3.3 Dense输出层与损失函数搭建逻辑LSTM输出的隐藏状态需要经过一个全连接层才能映射回字表大小为3000的分类概率。全连接层的权重矩阵形状是(128, 3000)这一步的计算量比前面几层加一起都大但现代框架处理起来还是很快的。输出层之后接一个Softmax把3000个实数变成3000个概率值概率最大的字符就是模型预测的下一个字符。损失函数我用的标准交叉熵目标是把正确字符对应的概率不断拉高。这里有个值得注意的小细节我不对LSTM返回的每个时间步都做预测只取最后一个时间步的输出做预测因为训练样本就是前32个字预测第33个字模型结构要和这个设定完全对齐。如果你想一次性预测多个后续字符就该改用return_sequencesTrue但那个需求在这个项目里没有必要。3.4 参数量与内存的粗略盘算搭建模型之前我很粗略地估算过参数量。Embedding层的参数量是3000 * 128 384000个LSTM层的参数量大约是4 * (输入维度 隐藏维度) * 隐藏维度也就是4 * (128 128) * 128 131072个再加上偏置项大概3万。全连接层最大是128 * 3000 384000个。全部加起来大概90万个参数。90万参数对现代深度学习来说算是非常轻量了一张几年前的独立显卡都能把模型塞进显存且绰绰有余。就算只用CPU训练每个Epoch大约也就十几分钟完全在可接受范围内。这也再次说明小项目不要一上来就堆大模型先把简单的结构跑通效果不满意再扩规模。import tensorflow as tf def build_model(vocab_size, embedding_dim128, lstm_units128, sequence_length32): model tf.keras.Sequential([ tf.keras.layers.Embedding(vocab_size, embedding_dim, input_lengthsequence_length), tf.keras.layers.LSTM(lstm_units, return_sequencesFalse), tf.keras.layers.Dense(vocab_size, activationsoftmax) ]) return model model build_model(vocab_size3000) model.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), losssparse_categorical_crossentropy ) print(model.summary())4. 训练调参全记录loss曲线从哪一刻开始出诗味4.1 第一个Epoch通常是胡言乱语训练刚开始的时候模型完全是在瞎蒙。我用的优化器是Adam初始学习率0.001。第一个Epoch结束后loss才降到5.7左右。你可能对这个数值没什么概念简单说如果模型完全随机预测3000个字里的一个交叉熵会是log(3000) ≈ 8.0所以5.7意味着模型已经学到了一点规律但生成效果依旧像坏掉的复读机。我当时做了一次早期测试给模型输入春眠不觉四个字它输出的后续是处处处处处处。这个结果特别经典——模型发现了春眠不觉晓这首诗里处处结构高频但又不知道该怎么变化就疯狂复制。随着训练推进loss降到3.0以下时这种复读现象才会慢慢减少。4.2 学习率与优化器的拉扯调参过程中我发现学习率0.001在前期很合适loss下降明显但到后期大概训练到8个Epoch左右loss曲线开始出现小幅振荡训练集上甚至偶尔不降反升。这时候我做了两个调整。第一把学习率降到0.0005第二给Adam加了一个梯度裁剪clipnorm1.0。梯度裁剪对RNN来说特别重要因为LSTM在长序列上的梯度很容易出现极端大或极端小的值裁剪一下能防止个别Batch把已经学好的权重彻底冲乱。我也没有用什么特别复杂的衰减策略只是每5个Epoch手动把学习率减半。这个粗暴的做法在项目后期非常好用loss从3.0往下到2.4的过程全靠这种手动衰减一点点磨出来的。4.3 Batch Size与序列长度的纠缠Batch Size我试过64、128和256。在小数据集上64的效果最好理由是RNN每个时间步的梯度本身就比较跳跃Batch Size太小会导致噪声太大模型容易在几个高频繁词之间反复横跳Batch Size太大又会平均掉短句和长句之间的差异让模型学不到韵律变化。64在训练速度和稳定性之间找到了一个平衡点。序列长度同样值得反复试验。一开始我用24模型生成的句子偏短经常在一句之内就断掉后来改成32明显变得更连贯。改成48之后效果没有继续变好反而训练时间长了将近一倍生成的句子偶尔还会忘掉开头吟咏的主题。最终定格在32。4.4 验证集上看到的背诵风险文本生成模型很容易陷入背诵而不是创作如果训练充分过头模型会把训练语料里的原句原封不动地输出。我留出了大约5%的数据作为验证集每个Epoch结束后观察验证集上的loss。如果训练loss持续下降、验证loss开始回升就说明模型开始背诵了。我在第12个Epoch左右观察到了这个现象。解决办法不是提前停止这么简单而是返回去看生成结果——如果模型生成的固定搭配频繁出现比如长江东流去这种被反复复用的句子我会在前面的语料清洗阶段就把某些过度重复的模板句直接删掉。数据层面的干预往往比调参更有效。Epoch 1/15 - loss: 5.71 - val_loss: 5.02 Epoch 3/15 - loss: 4.15 - val_loss: 3.88 Epoch 6/15 - loss: 3.42 - val_loss: 3.35 Epoch 10/15 - loss: 2.86 - val_loss: 2.98 Epoch 12/15 - loss: 2.61 - val_loss: 2.935. 生成诗句的旋钮温度、Top-k和束搜索如何决定诗的性格5.1 为什么不直接取概率最大的字符模型训练好之后下一步是写生成代码。最容易犯的错误是每次选概率最大的字符——如果真这么干生成结果会很快陷入无限循环。原因也很直观模型在单个字符的位置上并不存在一个绝对最优解一旦选中某个高频字符前面的隐藏状态会把这个选择记录进去下一个位置的最优解又倾向于同样的字符循环就跑起来了。我展示一个真实的贪心生成输出白日依山尽尽尽尽尽...。这就是典型的一路取最大概率导致钻进死胡同。为了让生成结果有变化、有意外、也有美感必须引入随机采样。但随机不是乱来需要在可读性和多样性之间找到平衡点。5.2 温度参数调法从保守到发疯温度参数Temperature是一种对Softmax输出进行改造的手段。具体做法是把模型输出的logits除以一个温度值再送入Softmax。温度越低概率分布越尖锐高概率字符更容易被选中生成结果保守、稳定、重复率高温度越高概率分布越平坦低概率字符也有更多机会被选中生成结果多样、跳跃、但容易语法崩坏。我实际做了一批温度0.4、0.7、1.0的对比生成。温度0.4的时候每句都结构工整但内容空洞温度0.7的时候偶尔出现让人眼前一亮的句子温度1.0的时候会出现大量生造的词组比如月落星稀人独立还能看风急天高猿啸哀就变成了风急天高花满楼这种让人哭笑不得的搭配。最终我把温度设为0.75既保留了足够的随机性又不至于让句子散架。def sample_with_temperature(logits, temperature0.75): logits logits / temperature probs tf.nn.softmax(logits).numpy() return np.random.choice(len(probs), pprobs)5.3 Top-k与Top-p如何压制叠字怪癖温度参数能控制胆量但控制不了跑题倾向。模型特别喜欢在句首输出春风明月青山这类高频起兴词连续几句都以相同意象开头读起来就很单调。我的解决方案是Top-k过滤每一步只从概率最大的前50个字符里采样把那些极低概率的干扰项直接排除在外。Top-k的作用相当于给模型划定一个候选区让采样不至于从3000个字的全局范围里乱抽。Top-p同理也叫核采样它从累积概率达到0.9的最小字符集合里采样这个方案的自适应性更强。实测下来Top-k50配合温度0.75效果最稳定。选50而不是20的原因是古体诗用字丰富候选区太小会让句子过早撞上常见模板选50而不是100则能挡住大量生僻字和噪声字符。5.4 束搜索让生成结果稳而不呆如果想让模型自动续写成完整的四句一个更高级的做法是束搜索Beam Search。它每一步保留概率最高的多个候选序列最后从中选整体概率最优的而不是一步一决策。但直接套用束搜索在诗歌生成上有个副作用它倾向于选择整体概率最高的路径而这个路径往往是语义最安全的路径结果是重复和套路化。所以我的建议是如果只看一两句的局部续写用随机采样更有灵气如果要整篇生成可以在束搜索的得分函数里加入一个多样性惩罚项鼓励不同句子之间用词有所差异。6. 翻车现场与救火方案六种经典病句及其根源6.1 重复循环与叠字轰炸最常遇到的病句就是山山山山或者花开花落花开花。这类问题通常出在采样策略太贪心或温度太低。调整温度到0.7以上配合Top-k截断能解决大半。如果还是循环那么问题可能出在训练数据——某个高频词组在语料里大量出现模型对这个模式产生了过拟合。我会在清洗阶段统计最高频的五十个三字组合把语料里包含它们的句子部分删除让模型不再死盯着这些搭配。6.2 主题漂移上一句还在咏雪下一句开始写雨模型经常出现前半句写冬天、后半句写夏天的情况。这跟LSTM的长距离遗忘有关也可能跟训练数据中很多诗篇本身包含了四季交错的意象有关。我的处理方法是延长输入上下文把序列长度从24提升到32让模型在生成后面几个字时仍记得开头的意象。同时可以在生成时给模型一个更明确的开头提示比如输入雪落群山而不是雪一个字这样模型等于被锁定在一个主题范围内续写。6.3 平仄完全不搭严格分类的话平仄是古体诗最核心的格律之一。但字符级模型从字面上很难自动学会平仄的交替规律因为训练数据里没有直接标注平仄信息。我做过一个折中方案把每个字的平仄编码成单独的输入特征和字符ID一起喂给模型。具体做法是在Embedding之后把平仄特征向量拼接上去让LSTM能同时看到这个字和这个字是平声还是仄声两组信息。这个改动能让生成句子的平仄贴合度明显提升但代价是模型结构稍微复杂一点训练时间也变长。6.4 乱用换行与断句如果不做换行标记的处理模型经常一口气生成六七个分句完全不知道在哪里收尾。我在序列切分阶段用|endoftext|分隔诗与诗同时保留换行符作为字符模型才有机会学会每五或七个字输出一个换行的规则。但即使这样偶尔还是会出现七言变成六言再接一个空格再继续的怪句子。这是因为模型把空格当成了一种弱停顿符这在语义上是可以接受的如果你严格要求字数工整后处理阶段可以写一个脚本按空格和换行切分手动修正不齐的断句。6.5 意象单薄全是高频词模型特别喜欢春风明月天涯故人这些高频意象导致生成的诗千篇一律。要解决这个除了调整采样参数还可以从语料层面刻意增加意象的多样性——比如在数据清洗时对那些高频起兴词固定场景词的高度模板化句子多删一些。我在清洗时加了一个简单策略统计所有五言句的前二字组合如果某个组合出现次数超过全体的0.5%就把这些句子按比例剔除。这样可以强制模型去探索更丰富的起兴方式。6.6 预训练思路不能解决问题的部分网上有人尝试用大模型预训练再在小规模古诗语料上微调效果确实不错。但我们在本地上没有那么大算力所以更实际的方案是先训练一个相对通用的中文语言模型如果条件允许再在古诗语料上微调。如果没有这个条件直接在8万首诗的规模上从零训练RNN也已经能得到不错的结果。我个人的体会是从零训练到一个能看得过去的水平关键在于数据去重可控采样这两件事它们对最终结果的影响可以占到八成。模型结构本身反而不是瓶颈。7. 几次实验后的个人体会与项目扩展方向7.1 诗歌感的涌现点loss值2.5附近训练过程中有一个非常奇妙的节点当验证集loss降到大约2.5时生成的诗句开始具备一种模糊的诗歌感——它可能是病句但读起来有韵律、有画面。2.5这个数字对应的其实是平均每个字符有约12个合理的候选这给了模型足够的多样性让它不像机器。我建议你在训练时盯住这个水位线不要一味追求loss越低越好低过2.0之后诗句会变得异常保守和套话化反而不有趣。7.2 后续还能怎么玩这个项目的扩展空间比我想象中大得多。最简单的是改成词牌生成——只要把训练语料换成词模型结构不变序列长度稍微调到64就能生成虞美人浣溪沙格式的词。还可以做藏头诗生成拿到一个用户指定的开头字先用这些字约束模型输出的第一个字再用随机采样接续后面的内容本质上就是把生成过程的候选集做一次条件过滤。更有意思的是跨句debate让两个分别用不同温度采样的模型轮番续写一句来自保守模型一句来自激进模型生成的文本常常会出现意想不到的意象碰撞。这种玩法已经超出了技术正确的范畴更像是用代码在探索语言的边界。7.3 写给想复现的人几句大实话如果你准备照着这个思路自己搞一个我能给的最实用的建议有三条第一清洗数据的时间一定要给够别急着跑模型第二先在小规模数据上跑通完整链路再上全量数据省下的调试时间是以小时计的第三生成阶段的采样代码要尽早写因为它能帮你快速判断模型训练方向对不对不要等到训练全部结束才动手。古体诗生成器这个项目技术栈并不高深但它把自然语言处理中序列建模、字符表示、随机采样这些最核心的概念全部串了起来。如果你也能动手跑通我保证你对RNN的理解会比读十篇教程都扎实。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。