ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于LSTM的古诗词自动生成实战:从数据清洗到系统部署

基于LSTM的古诗词自动生成实战:从数据清洗到系统部署 简介这是一套面向自然语言处理学习者与算法工程师的古诗词自动生成完整工程。资源基于LSTM改进传统RNN梯度缺陷采用sparse_categorical_crossentropy损失函数与Adam(lr0.002)优化器训练模型并借助Flask搭建Web界面输入提示词即可生成五言律诗、七言绝句及藏头诗兼顾研究复现与系统落地。压缩包共40个文件以7个Python脚本模型训练、诗歌生成、Web入口为核心附带模型权重、checkpoint、HTML/JS页面、XML配置及readme说明整体81.76MB。已有1874人学习。读者可拿到完整可运行的LSTM古诗生成源码、训练好的模型参数、前后端交互代码与依赖清单便于直接复现或据此调整网络结构与生成策略。1. 古诗词自动生成为什么偏偏是 LSTM 而不是 GPT让模型写一首“床前明月光”式的五言绝句很多人的第一反应是上大模型。但如果你只想在本地用 CPU 跑通一个能出诗的完整系统LSTM 反而是性价比最高的选择——它训练快、显存要求低、生成逻辑完全可控而且诗词这种短序列、强韵律、固定格式的文本恰好是 LSTM 擅长的场景。本文要讲的就是这样一套完整的落地路径从数据准备、LSTM 模型搭建、训练调参到生成时的平仄与押韵控制最后封装成一个可交互的古诗词生成系统。这套方案适合谁适合有 Python 基础、想认真做完一个 NLP 小系统而非只跑通 demo 的开发者也适合需要快速产出课程设计或实验原型的学生。读完你会得到一份能直接照着敲的完整代码路径以及我在训练和部署过程中踩过的具体坑。2. 把古诗变成 LSTM 能学的东西数据清洗与序列化2.1 选数据集与清洗规则不是所有古诗都适合直接喂给模型训练古诗生成器数据源通常有两个方向一是全唐诗、全宋词之类的公开语料库二是网上爬取的诗词合集。无论哪种清洗都是第一步而且清洗规则直接决定模型能学到什么。我一般会按这几个规则过滤# 示例清洗脚本的核心过滤逻辑伪代码 1. 只保留五言绝句、五言律诗、七言绝句、七言律诗 2. 剔除含有生僻字超过全诗字数 10% 的诗句 3. 剔除标题含“无题”“佚名”等无法归类的样本 4. 统一标点为全角去除多余空白实际处理时我会统计每首诗的字符数分布因为五言诗通常是 20 字不含标点七言是 28 字。清洗后我会做成一个纯文本文件每行一首诗格式为“诗句\t标题”。这里有个关键点标题信息不是必须的但如果后续想做主题控制比如生成“送别”主题的诗标题就得保留。清洗完成后统计一下字符表大小——通常古诗词语料的去重字符数在 20004000 之间。如果超过 4000建议把出现频率低于 5 次的字符统一映射为UNK否则模型会花大量参数去学习几乎用不到的冷僻字。2.2 序列化策略字符级输入为什么比词级更适合古诗词古诗词生成的常用做法是字符级建模而不是词级。原因很简单诗词的韵律和格律建立在单字层面平仄、押韵、对仗都是字粒度的约束分词反而会破坏这种结构。而且古诗语料规模通常不大词级建模会带来严重的数据稀疏。字符级序列化的过程是这样的# character_level_sequence.py # 构建字符到索引的映射以及索引到字符的反向映射 char_to_idx {ch: i for i, ch in enumerate(vocab)} idx_to_char {i: ch for ch, i in char_to_idx.items()} # 将每首诗转为索引序列 def poem_to_indices(poem_text): return [char_to_idx.get(ch, char_to_idx[UNK]) for ch in poem_text] # 构建训练样本滑动窗口切分输入与目标 sequence_length 20 # 五言绝句长度不含标点 input_chars poem_indices[:-1] # 输入前 19 个字 target_chars poem_indices[1:] # 目标后 19 个字即每个位置的下一个字这里有个容易忽略的细节我故意把标点从序列里剔除了。原因有两点第一标点会占用序列长度模型需要额外学习标点的位置分布规律这对韵律学习没有帮助第二生成阶段我们需要自己控制断句位置五言在第五个字后断句七言在第七个字后断句让模型自己生成标点反而容易错位。滑动窗口切分时我一般设置sequence_length 20对应五言绝句。如果你混合了五言和七言需要分成两个数据集分别训练或者统一 padding 到 28 个字符。混合训练的代价是模型会学到“两种格式混杂”的分布生成质量通常不如分开训练。这也是为什么业界做古诗生成系统时几乎都是按格式单独训练模型。2.3 嵌入层与数据加载把索引变成模型能理解的向量序列化之后数据加载的常规做法是用 PyTorch 的Dataset和DataLoader。这里有一个关键参数batch_size。古诗语料本身不大几万首诗也就百万字符级别batch_size设 64 或 128 即可太大反而容易让模型在早期就过拟合。# dataset_loader.py from torch.utils.data import Dataset, DataLoader class PoemDataset(Dataset): def __init__(self, sequences): self.sequences sequences def __len__(self): return len(self.sequences) def __getitem__(self, idx): seq self.sequences[idx] x seq[:-1] # 输入序列 y seq[1:] # 目标序列错一位对齐 return x, y dataset PoemDataset(all_sequences) dataloader DataLoader(dataset, batch_size64, shuffleTrue)注意__getitem__返回的是x和y其中x经过嵌入层转换为向量后进入 LSTMy则直接作为交叉熵损失的目标。这里没有做 one-hot而是用嵌入层nn.Embedding嵌入维度通常取 128 或 256。字符表 3000 左右、嵌入维度 128嵌入层参数量约 38 万这对 LSTM 来说是很合理的规模。3. LSTM 模型结构设计从公式到 PyTorch 实现3.1 LSTM 单元的核心计算三个门控怎么决定下一个字LSTM 能记住长距离依赖靠的是三个门控遗忘门、输入门、输出门。在古诗生成场景下这个机制对应的直觉是模型需要在“床前明月光”的“光”字位置回忆起开头已经给出的韵脚比如押“ang”韵同时根据当前字决定下一个字的候选集合。三个门的计算如下f_t sigmoid(W_f · [h_{t-1}, x_t] b_f) # 遗忘门决定保留多少旧记忆 i_t sigmoid(W_i · [h_{t-1}, x_t] b_i) # 输入门决定写入多少新信息 o_t sigmoid(W_o · [h_{t-1}, x_t] b_o) # 输出门决定输出多少记忆 c_t f_t * c_{t-1} i_t * tanh(W_c · [h_{t-1}, x_t] b_c) # 更新细胞状态 h_t o_t * tanh(c_t) # 更新隐藏状态对古诗生成而言遗忘门的关键作用是让模型在生成一句诗的后半段时仍然保留前一句末尾韵脚的信息。如果没有这个门控模型在长序列生成时很容易“忘了”开头押的韵导致一诗中前两句押韵、后两句跑韵。我用 PyTorch 实现一个单层 LSTM 时不会手动写这些公式——nn.LSTM已经封装好了。但理解公式对调参很重要比如dropout只能加在多层 LSTM 的层间不能在单层 LSTM 上用因为单层没有“层间”概念。很多人在这里报错原因就是单层 LSTM 传了dropout0.5。3.2 模型结构Embedding LSTM 全连接输出层完整的生成模型结构很简洁嵌入层把字符索引映射为向量LSTM 层处理序列全连接层把隐藏状态映射回字符表大小的概率分布。# lstm_poem_model.py import torch import torch.nn as nn class PoemLSTM(nn.Module): def __init__(self, vocab_size, embed_size128, hidden_size256, num_layers2): super(PoemLSTM, self).__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.lstm nn.LSTM( input_sizeembed_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropout0.3 if num_layers 1 else 0.0 ) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, hiddenNone): embeds self.embedding(x) # [batch, seq_len, embed_size] lstm_out, hidden self.lstm(embeds, hidden) output self.fc(lstm_out) # [batch, seq_len, vocab_size] return output, hidden def init_hidden(self, batch_size, device): # 初始化两层 LSTM 的隐状态和细胞状态 return (torch.zeros(2, batch_size, 256, devicedevice), torch.zeros(2, batch_size, 256, devicedevice))这里有几个参数值得展开说明hidden_size256是单层隐藏单元的宽度。对古诗词这种短文本128 就能出效果256 会更稳再往上512提升有限且训练时间翻倍。num_layers2用两层 LSTM 可以让模型在一层中学习字的组合模式在第二层中学习句间的衔接规律。batch_firstTrue让输入形状为[batch, seq_len, embed_size]符合直觉且不容易出错。全连接层self.fc把每个时间步的隐藏状态映射到vocab_size维也就是下一个字的概率分布。这里没有接 softmax因为在计算交叉熵损失时nn.CrossEntropyLoss内部会做 softmax提前做反而会导致数值精度问题和梯度不稳定。3.3 训练循环交叉熵损失与梯度裁剪的必要性训练循环是整套代码里最需要小心的部分。古诗生成的标签是“下一个字符”所以损失函数用交叉熵但要注意CrossEntropyLoss的输入形状要求预测值必须是[batch * seq_len, vocab_size]目标值必须是[batch * seq_len]的一维张量。# train_loop.py import torch.nn as nn import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model PoemLSTM(vocab_sizelen(vocab)).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) for epoch in range(30): for batch_x, batch_y in dataloader: batch_x batch_x.to(device) # [batch, seq_len] batch_y batch_y.to(device) # [batch, seq_len] output, _ model(batch_x) # [batch, seq_len, vocab_size] loss criterion( output.reshape(-1, output.size(-1)), batch_y.reshape(-1) ) optimizer.zero_grad() loss.backward() # 梯度裁剪防止长序列训练中梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() if epoch % 5 0: print(fEpoch {epoch}, Loss: {loss.item():.4f})梯度裁剪clip_grad_norm_这一个细节在古诗生成里尤其重要。因为 LSTM 在长序列上展开的步数多梯度很容易在反向传播时爆炸表现为 loss 突然变成nan或剧增。我把max_norm设为 1.0经验值是 0.55.0 之间都有人用1.0 是默认安全值。训练轮数epoch不要死板语料规模在 5 万首以上时 2030 轮就能收敛小语料几千首可能 10 轮就过拟合了。判断过拟合的方式是观察生成效果——如果训练 loss 在下降但生成的诗词开始“背原文”那就是过拟合信号需要提前停止或增大 dropout。4. 训练策略与生成算法让模型真的“会写诗”而不是“背诗”4.1 训练策略teacher forcing 与计划采样古诗生成训练里最常见的策略是 teacher forcing训练时每个时间步的输入都是真实的上一个字而不是模型自己预测的字。这能加速收敛但会带来一个严重问题——曝光偏差训练时模型从不犯错生成时一旦某个字偏差错误会一路累积。解决曝光偏差的常用做法是计划采样scheduled sampling。具体实现是训练过程中以一定概率用模型上一时间步的预测输出替代真实输入。概率随训练轮数从 1.0 线性降到 0.5 左右让模型逐渐适应自己的错误。# scheduled_sampling.py sampling_prob max(0.5, 1.0 - epoch / total_epochs) # 概率随训练递减 for t in range(seq_len): if t 0 and random.random() sampling_prob: # 用上一时间步的预测结果作为当前输入 _, prev_idx torch.max(output[:, t-1, :], dim1) input_t prev_idx.unsqueeze(1) else: input_t batch_x[:, t].unsqueeze(1) output_t, hidden model(input_t, hidden)不过在实际项目中我发现对古诗这种格式固定的短文本teacher forcing 在 20 字序列上的曝光偏差没有想象中严重。计划采样更适合机器翻译等长序列任务。如果你遇到的主要问题是“训练得很好但生成很烂”可以先试计划采样如果生成的诗已经通顺但缺乏新意那问题不在训练策略而在采样算法。4.2 生成算法贪心搜索、随机采样与温度参数训练完之后生成方式决定了最终输出质量。最常见的三种方式贪心搜索每一步取概率最大的字符。缺点很明显——生成的诗词会趋同而且可能陷入重复循环因为最高概率的字符往往是训练集中频繁出现的常用字。随机采样按概率分布随机选字解决趋同问题但容易产生不通顺的字。温度采样temperature sampling在 softmax 之前除以温度系数控制概率分布的尖锐程度。# temperature_sampling.py def generate_poem(model, start_char, length20, temperature0.8): model.eval() device next(model.parameters()).device hidden model.init_hidden(1, device) input_idx torch.tensor([[char_to_idx[start_char]]], devicedevice) result [start_char] with torch.no_grad(): for _ in range(length - 1): output, hidden model(input_idx, hidden) # 温度采样logits 除以 temperature logits output[0, -1, :] / temperature probs torch.softmax(logits, dim0) next_idx torch.multinomial(probs, 1).item() result.append(idx_to_char[next_idx]) input_idx torch.tensor([[next_idx]], devicedevice) return .join(result)温度参数的调节经验temperature0.5时生成的诗更严谨、更接近训练集的用词习惯但创新性弱temperature1.0时自由度高但容易出不通顺的句子temperature0.8是我试验下来平衡性最好的值。需要注意的是温度不是越高越好——超过 1.5 后生成文本基本接近随机字符序列没有任何诗词感。4.3 平仄与押韵控制基于规则的生成后处理纯 LSTM 生成的诗词在韵律上经常翻车。常见的缺陷是单句内部通顺但整首诗不押韵或者平仄不协调。要解决这个问题常见做法是在生成时叠加规则约束而不是指望模型自己学会格律。我的方案是分段生成先确定韵脚。比如要求押“an”韵就在韵脚位置固定从“an”韵字集合中候选其他位置自由生成。# rhyme_control.py # 韵脚字表示例仅包含常用韵字 rhyme_dict { an: [山, 间, 还, 关, 颜, 寒, 年], ang: [光, 霜, 乡, 长, 香, 芳], i: [衣, 知, 时, 思, 诗, 期] } def generate_with_rhyme(model, start_char, rhymean, temperature0.8): result [start_char] input_idx torch.tensor([[char_to_idx[start_char]]]) hidden model.init_hidden(1, device) # 五言绝句的押韵位置第 1、2、4 句末尾索引 4, 9, 19 rhyme_positions {4, 9, 19} candidates [char_to_idx[c] for c in rhyme_dict[rhyme]] with torch.no_grad(): for pos in range(19): output, hidden model(input_idx, hidden) logits output[0, -1, :] / temperature if pos in rhyme_positions: # 押韵位置只从韵脚字表里采样 rhyme_logits logits[candidates] probs torch.softmax(rhyme_logits, dim0) next_idx candidates[torch.multinomial(probs, 1).item()] else: probs torch.softmax(logits, dim0) next_idx torch.multinomial(probs, 1).item() result.append(idx_to_char[next_idx]) input_idx torch.tensor([[next_idx]]) return .join(result)这种“LSTM 生成 规则约束”的混合方案是古诗词生成系统里最可靠的做法。纯端到端学习平仄不是做不到而是需要几倍的数据量和更大的模型对大多数项目来说不划算。韵脚字表的构建需要一定诗词知识但网上有现成的平水韵字表可以下载整理。5. 避坑指南古诗生成训练最常见的 5 个翻车现场5.1 现象loss 降到 0.1 以下但生成的全是训练集中的原句原因模型“背”住了训练数据。这不是模型笨而是参数量相对于语料量过大或者训练轮数过多。解决增大 dropout 到 0.5、降低hidden_size、减少num_layers到 1以及提前停止训练。另一个技巧是故意的——把少量高重复样本从训练集移除比如完全相同的诗句只保留一份。5.2 现象训练中 loss 突然变成 nan原因梯度爆炸或学习率过大。解决先加clip_grad_norm_值为 1.0如果仍出现 nan把学习率从0.001降到0.0005。还有一个隐蔽原因是嵌入层输出了 NaN——检查输入序列里有没有超出字符表索引的越界值char_to_idx.get(ch, unk_id)的默认值处理如果忘了写越界索引就会在嵌入层产生问题。5.3 现象生成的诗单句通顺但句与句之间毫无逻辑关系原因sequence_length太短模型没有学到句间依赖。五言绝句的 20 个字符对 LSTM 来说并不长但如果切分时把每首诗拆成了多段句子边界会被打断。解决确保切分时窗口完整覆盖整首诗不要在诗中间切断。另外可以考虑num_layers2或hidden_size256给模型更多容量去建模句间关系。5.4 现象生成的七言诗只有前两句押韵后两句完全不押韵原因韵脚信息在 28 个字符的序列中衰减得太快模型“忘记”了开头的韵脚字。解决不依赖模型记韵脚而是像 4.3 节那样用规则强制押韵。这是最直接的修复路径。如果不想引入规则可以尝试在输入序列头部拼接一个“韵脚提示符”比如[an] 床前明月光…让模型学会“看到韵脚提示就按这个韵脚生成”。5.5 现象生成了不在字符表里的字或UNK原因训练时未出现在语料中、或出现频率低于阈值被映射为UNK的字被采样到了。解决生成时对UNK的 logit 做屏蔽即将该位置的概率强制置零后再采样。这是最后一步兜底防止输出乱码。提示前 4 个问题几乎每个做古诗生成的人都会遇到至少一个。我自己的经验是最容易忽视的是 5.3——数据切分破坏了诗歌边界这个错误在 loss 上几乎看不出来只有生成效果会暴露。6. 系统实现从模型到可交互的古诗生成应用6.1 系统架构模型推理服务与 Web 界面模型训练好之后需要封装成可交互的系统。我会用一个轻量级的架构PyTorch 模型作为推理核心Flask 提供 HTTP 接口前端用一个简单的 HTML 页面展示生成结果。之所以选 Flask 而不是 FastAPI是因为这个场景需要的就是简单的路由和一个模型实例常驻内存Flask 更轻、依赖更少。系统有三个核心接口生成五言诗、生成七言诗、指定韵脚生成。这三个接口共用同一个模型实例只是传入的起始字、诗词长度和韵脚约束不同。模型在 Flask 启动时加载一次后续请求复用同一份内存。# flask_server.py from flask import Flask, request, jsonify, render_template import torch app Flask(__name__) model None device torch.device(cuda if torch.cuda.is_available() else cpu) def load_model(): global model model PoemLSTM(vocab_sizelen(vocab)) model.load_state_dict(torch.load(poem_model.pt, map_locationdevice)) model.to(device) model.eval() app.route(/generate, methods[POST]) def generate(): data request.get_json() start_char data.get(start_char, 春) poem_type data.get(type, wuyan) # wuyan: 五言, qiyan: 七言 rhyme data.get(rhyme, ) # 可选韵脚 length 20 if poem_type wuyan else 28 poem generate_with_rhyme(model, start_char, rhyme, length) return jsonify({poem: poem}) app.route(/) def index(): return render_template(index.html) if __name__ __main__: load_model() app.run(host0.0.0.0, port5000)这里的模型加载用了load_state_dict而不是重新训练。torch.load加载时要注意map_locationdevice如果你在 CPU 机器上加载 GPU 训练的模型不指定这个参数会直接报错。模型文件我通常只保存state_dict而不是整个模型对象因为前者更轻量且在不同版本的 PyTorch 之间兼容性更好。6.2 模型导出与推理优化省掉每次推理的重复计算有个细节值得注意在 Flask 应用里我不建议在每次请求时动态重建模型或重新加载权重。正确做法是在load_model()中加载一次全局复用。这能省掉大段时间开销——模型加载通常需要 12 秒而单次推理只要几十毫秒。除了加载优化推理时还可以关闭梯度计算以省内存# inference_optimization.py with torch.no_grad(): output, hidden model(input_idx, hidden)我在生成接口里已经用了with torch.no_grad()这是因为推理阶段不需要计算梯度关闭后不仅省显存还能把单次推理速度提升 20% 左右。如果你需要更高并发还可以把模型转成 TorchScript 或用 ONNX 导出但单用户、低并发的场景下没有必要。6.3 前端交互最小可用的生成页面前端我通常写一个单页 HTML包含起始字输入、诗体选择、韵脚下拉框和生成按钮。之所以不做复杂的框架集成是因为这个系统核心价值在模型和生成质量前端只是展示入口。静态页面通过 Flask 的render_template直接渲染不需要额外的前后端分离。!-- templates/index.html -- !DOCTYPE html html head meta charsetUTF-8 title古诗词生成系统/title /head body h1古诗词自动生成/h1 div label起始字/label input typetext idstart_char value春 maxlength1 label诗体/label select idpoem_type option valuewuyan五言绝句/option option valueqiyan七言绝句/option /select label韵脚/label select idrhyme option value不指定/option option valueanan 韵/option option valueangang 韵/option option valueii 韵/option /select button onclickgeneratePoem()生成/button /div div idpoem_output stylemargin-top: 20px; font-size: 20px;/div script async function generatePoem() { const data { start_char: document.getElementById(start_char).value, type: document.getElementById(poem_type).value, rhyme: document.getElementById(rhyme).value }; const resp await fetch(/generate, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify(data) }); const result await resp.json(); const poem result.poem; // 按每 5 个或 7 个字换行展示 const step data.type wuyan ? 5 : 7; let html ; for (let i 0; i poem.length; i step) { html poem.slice(i, i step) br; } document.getElementById(poem_output).innerHTML html; } /script /body /html这里没有引入前端框架是刻意为之。一是减少系统依赖二是这段逻辑本身足够简单原生 JavaScript 几十行就能完成。如果后续要做批量生成或用户管理再考虑引入 Vue 或 React 也不迟。6.4 验证生成质量从人工评估到量化指标系统上线后怎么判断它“写得好不好”人工打分当然是最终标准但你总不可能生成几百首后逐首读。我通常会同时跑两个量化指标句内通顺度用困惑度perplexityPPL近似句间连贯度用统计重复 n-gram 的比例来度量。PPL 越低说明模型在测试集上的信心越高但 PPL 低不代表诗好——它只说明模型对自己的预测有把握。更实用的验证方法是固定 20 个起始字让模型分别以不同温度生成 5 首诗然后人工抽查。重点看三类问题是否押韵、是否有重复用字、是否有语义断裂。我自己使用时的经验是temperature0.8时大约 60% 的诗能通过基本质检——押韵正确、无乱码、无重复字超过 3 个。这个比例在学术研究里不算高但对一个本地部署的小型系统来说已经可以接受。希望这套从数据清洗到系统部署的路径能帮到你至少能让你少走我走过的那些弯路——尤其是数据切分和梯度裁剪这两个环节值得你多花一点时间。本文还有配套的精品资源点击获取
返回列表