尧图精选

字符级LSTM古诗词生成实战:从数据清洗到Flask部署全解析

🕒 发布时间:2026/10/2 18:49:54 📁 来源:尧图网络
简介面向自然语言处理与诗歌生成方向的学习者这份LSTM古诗自动生成系统覆盖建模、训练到Web展示全流程。针对RNN长序列梯度缺陷采用LSTM优化使用sparse_categorical_crossentropy损失函数和Adam(lr0.002)训练可生成五言律诗、七言绝句与藏头诗。资源共40个文件压缩包81.76MB包含Python源码、Keras模型文件checkpoint、data、index、meta、前端页面及配置说明结构清晰便于复现。已有1874人学习下载。借助该资源可直接运行算法也可参考模型设计、损失函数选择与Flask集成方式适合NLP课程设计、毕业设计或古诗生成算法对比实验。1. 古诗词生成不是随机拼字LSTM 如何在字符级别上学会平仄与意象我最初接到古诗词生成这个需求时第一反应是拿现成的模板拼句子但效果非常打油诗。换成 LSTM 之后才发现古诗生成本质上是一个字符级序列预测问题模型看到的不是词语而是连续字符流。它从全唐诗里学到的不是词库而是「平仄交替」「对仗工整」「意象搭配」这些藏在字符序列里的统计规律。这份资源是一个完整的 LSTM 古诗生成系统包含数据处理、模型训练、采样生成和 Flask 网页展示。适合正在学序列建模、想做一个能跑起来的 NLP 实战项目的开发者也适合想把模型快速包装成 demo 给客户看的人。读完你会明白为什么字符级 LSTM 比 n-gram 更合适以及部署时那些让人头大的坑。2. 数据准备与字符编码从全唐诗到可训练的序列样本2.1 语料来源与清洗为什么只保留五言和七言网上流传的《全唐诗》文本通常夹杂着作者、词牌名、注释、标点甚至还有繁体异体字。直接丢给模型训练字符集会膨胀到上万而且噪音会让 LSTM 去学那些无意义的标点符号。我拿到语料后第一件事就是过滤出五言绝句和七言绝句。绝句本身结构严谨每首只有四句句子长度固定非常适合作为定长序列的训练数据。五言和七言分开训练的话生成时更容易控制格式。清洗步骤其实很机械先按行读取去掉包含「诗」「卷」「作者」等关键字的行然后保留正文行用正则去掉所有非中文字符遇到空缺的句子就跳过整首。下面是资源包里的清洗脚本核心部分import re def clean_poem(raw_text): # 只保留中文字符和句读符号 text re.sub(r[^\u4e00-\u9fa5。], , raw_text) # 按句读切分五言绝句应该是4句每句5字七言每句7字 lines [l for l in re.split(r[。], text) if l] if len(lines) ! 4: return None if all(len(l) 5 for l in lines): return (五言, lines) if all(len(l) 7 for l in lines): return (七言, lines) return None这段代码做的事情很简单过滤后把一首诗切成四句然后判断长度是否一致。之所以不直接按字数过滤是因为古诗词里有「偷声」和「减字」的现象同一词牌字数也可能不同。但我选用绝句是因为它的格式最规整不用额外处理变体。清洗完的语料保存为两个文件poem_five.txt和poem_seven.txt每行一首诗句与句之间用空格分隔。这一步决定了下游字符集的大小。清洗后去重我大概保留了 4 万首左右的五言和 3 万首七言字符集在 3000 左右。如果语料太少LSTM 学不到平仄模式太多又会引入大量生僻字导致最终生成的诗歌里频频出现「爨」「龘」这种字交给用户完全是灾难。2.2 构造训练样本定长序列切分与字符映射表LSTM 需要一个固定长度的时间步。古诗不是每个字都独立后一个字依赖于前面的上下文所以我把每首诗拼接成一个大字符串然后用滑动窗口切出「输入序列」和「目标序列」。每个样本包含seq_len个输入字符以及后移一位的相同长度目标字符也就是input[i] - target[i] input[i1]。字符映射表构建要注意几个细节必须包含三个特殊 token ——PAD用于填充、S用于句首、E用于句末。其中S和E很关键因为生成时我们需要一个信号来启动和终止。我用word2idx和idx2word两个字典保存映射关系。class PoetryDataset(Dataset): def __init__(self, poems, seq_len64, char2idxNone, idx2charNone): self.seq_len seq_len self.char2idx char2idx or {} self.idx2char idx2char or [] # 把所有诗句合成一个长序列 all_text S S.join(poems) E self.indices [self.char2idx.get(c, self.char2idx[PAD]) for c in all_text] def __len__(self): return len(self.indices) - self.seq_len - 1 def __getitem__(self, i): x torch.tensor(self.indices[i : i self.seq_len], dtypetorch.long) y torch.tensor(self.indices[i 1 : i self.seq_len 1], dtypetorch.long) return x, yseq_len我取 64足够覆盖一首七言绝句加上标点28 个字的两倍长度。这样模型在训练时能看到超过一首诗的上文有助于学习跨句的承接关系。如果你发现生成的诗句频繁出现「上句不接下句」可以尝试把seq_len增加到 128代价是训练时间变长。这里有个容易忽视的问题字符S和E如果在多个样本中频繁出现模型可能学会「看到S就输出S」的偷懒策略。所以我统计字符频率后把S和E的频率权重调低了一些具体做法是在损失函数里加一个 class weight后面训练章节会讲到。2.3 数据加载器实现PyTorch Dataset 与批处理细节直接返回长度不等的序列会拖慢训练所以我在__getitem__里固定返回seq_len长度的片段。PyTorch 的DataLoader会自动把 batch 里的样本堆叠成(batch, seq_len)的张量但需要确保collate_fn不额外做 padding因为我们每个样本长度已经一致。资源里提供了一个padding_collate它唯一的职责是检查输入输出长度一致。其实默认的 collate 就够用但如果你的seq_len不是固定的就要自己写。这里我习惯设置pin_memoryTrue在 GPU 训练时能明显减少 CPU 到 GPU 的拷贝耗时。train_loader DataLoader(dataset, batch_size64, shuffleTrue, pin_memoryTrue, num_workers4)num_workers我设为 4在 Windows 上如果报错就改为 0。注意多进程加载时word2idx字典必须作为全局变量或者通过dataset构造时传入不然每个 worker 会重新构建一份映射导致索引错位。数据准备的最后一步是把char2idx和idx2char保存成 json模型训练完还要用它们把输出转回汉字。忘了保存这一步后面一切生成都无法进行。3. 模型设计与训练参数三层 LSTM 加 dropout 的效果差异3.1 网络结构选型为什么用字符级 LSTM 而不是 word2vec有人问为什么不用预训练的词向量。古诗词的字义高度依赖语境比如「春」在不同诗里可能代表生机也可能代表愁绪用静态词向量很容易丢失这种多义性。字符级 LSTM 不依赖分词质量每个汉字是一个独立输入模型自己学习「春」和「秋」的搭配关系。另外汉字本身就有平仄属性LSTM 的隐状态可以把这个信息编码到序列的长期依赖里。网络结构我采用三层 LSTM。单层 LSTM 对「前一句的末尾平仄」影响「后一句开头」这种远距离依赖无能为力。三层堆叠后第一层捕捉基本字词搭配第二层学习短语节奏第三层整合成句子的语义和格律。hidden_size设 256过大会导致参数量膨胀且容易过拟合。每层之间加dropout0.3只在层间生效不在时间步之间生效。class PoetryLSTM(nn.Module): def __init__(self, vocab_size, embedding_dim128, hidden_size256, num_layers3, dropout0.3, seq_len64): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim) self.lstm nn.LSTM(embedding_dim, hidden_size, num_layers, dropoutdropout, batch_firstTrue) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, hiddenNone): emb self.embedding(x) # (batch, seq_len, emb) out, hidden self.lstm(emb, hidden) # out: (batch, seq_len, hidden) out self.fc(out) # (batch, seq_len, vocab_size) return out, hidden这里batch_firstTrue意味着输入维度是(batch, seq_len, embedding_dim)Dataloader 出来的张量正好是这个形状。最后一层fc使用权重共享我让fc.weight与embedding.weight共享参数这样可以减少参数量而且效果表明它能在输出层复用输入的语义表征让生成的句子更「像」训练集里的用词。代码里加一行self.fc.weight self.embedding.weight即可但注意 PyTorch 要求两个 weight 形状一致所以vocab_size必须与 embedding 的最后一维一致。3.2 损失函数与优化器配置交叉熵、Adam 与学习率衰减损失函数用CrossEntropyLoss它的输入是(batch * seq_len, vocab_size)的 logitstarget 是(batch * seq_len)的索引。我们需要把模型输出的前两维合并。另一个关键点是加入字符频率权重高频字「之」「不」出现的概率大如果给它们过高的权重生成的诗会趋于平淡。我使用sklearn的compute_class_weight来算每个字符的逆频率然后传给CrossEntropyLoss。loss_fn nn.CrossEntropyLoss(ignore_indexchar2idx[PAD], weightclass_weight_tensor)优化器用 Adam初始学习率lr1e-3每 5 个 epoch 按lr * 0.5衰减。LSTM 对学习率很敏感过大会导致 loss 震荡过小则训练缓慢。我观察到 1e-3 对三层的结构是安全的如果 loss 在最后几个 epoch 突然升高就说明学习率没降下来。训练循环里有一个很重要的习惯每 10 个 epoch 就用当前模型生成一首诗对比不同阶段生成质量。只看 loss 下降是不够的loss 低可能只是模型学会了输出高频词。生成样例能直观反映模型是否开始形成格律这个观察比任何指标都靠谱。3.3 训练循环与模型保存每隔 N 个 epoch 生成一首诗来观察def train(model, loader, optimizer, loss_fn, epochs, device): model.train() for epoch in range(epochs): total_loss 0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() out, _ model(x) loss loss_fn(out.view(-1, out.size(-1)), y.view(-1)) loss.backward() # 梯度裁剪防止长序列训练时梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() if epoch % 10 0: print(fepoch {epoch}, loss {total_loss/len(loader):.4f}) generate_sample(model, char2idx, idx2char, device)梯度裁剪的max_norm5.0是我调出来的一个折中。太小会让模型训练变慢太大则失效。在 LSTM 序列任务里这个值几乎必设否则训练到第 30 个 epoch 时 loss 可能突然变成nan。保存模型时不要只保存model.state_dict()一定把char2idx、idx2char、seq_len这些配置打包进同一个 checkpoint这样才能在部署时无缝恢复。我用.pth.tar格式本质是一个字典。资源包里的checkpoint/目录给出了训练 50 个 epoch 后的完整文件和日志你可以直接加载来做推理也可以自己从头训练。4. 生成策略与后处理温度采样、平仄校验和意象过滤4.1 温度参数对随机性的控制从贪心到 top-p训练好模型后生成有两种常用策略贪心解码和采样。贪心每次选概率最大的字符结果可能陷入重复循环。我采用带温度系数的多项式采样公式是P(w) exp(z_i / T) / sum(exp(z_j / T))。温度T1时保持原分布T越低越保守越高越随机。实践下来T0.8生成的句子在通顺和创意之间最平衡。仅仅调温度还不够有时会出现「的」「之」这类停用词被反复采样。我加了一个top_p累积概率过滤只从累积概率超过p0.9的最小候选集里采样其余低概率字符直接丢弃。这个操作能减少生僻字和重复字。def sample_from_logits(logits, temperature0.8, top_p0.9): logits logits / temperature probs torch.softmax(logits, dim-1) sorted_probs, sorted_idx torch.sort(probs, descendingTrue) cumsum torch.cumsum(sorted_probs, dim-1) mask cumsum - sorted_probs top_p sorted_probs[mask] 0 normalized sorted_probs / sorted_probs.sum() idx torch.multinomial(normalized, 1).item() return sorted_idx[idx].item()注意top_p过滤要在温度缩放之后做顺序反了会影响概率分布形状。我在代码里加了这个注释因为很多开源实现把顺序搞反导致top_p形同虚设。4.2 平仄与押韵的规则校验生成后的硬约束修正LSTM 学到的平仄是概率性的并不能保证完全合规。我准备了一份平水韵表把每个汉字标成平声或仄声。生成四句之后先检查每句内部的平仄交替是否符合基本规则。比如五言绝句的常见格式是「仄仄平平仄平平仄仄平」如果检测到连续三个平声或三个仄声就标记为不合格。解决方式不是重新生成整首而是做局部替换。我提取不合格位置的上下文把该位置作为待选字重新用模型预测该位置的字符但强制候选字符满足平仄要求。这一步相当于把模型当成一个填空器而不是从头采样。资源里postprocess.py实现了这个功能替换时还考虑了韵脚第二句和第四句的最后一个字必须在同一韵部。这里有一个细节平仄校验必须在生成完整四句后进行而不是逐句生成时进行。因为模型在生成第二句时并不知道第四句的韵脚如果你逐句硬控最后可能韵脚冲突。我的做法是先快速生成一批候选诗然后按「平仄正确率 押韵数量」排序选综合分最高的那首。4.3 标题生成与五言/七言格式控制用户输入一个关键词比如「春」系统需要生成一首以「春」为主题的绝句。我的做法是输入序列用S春作为起始让模型从主题字开始扩散。但这样直接生成容易让第一句的主题词被遗忘所以我把它变成条件在生成第一句时强制第一个字符为「春」后续字符从模型中采样。其后每句的起始字符由上一句的语义推断不做额外限制。格式控制则靠生成时的长度约束。五言诗每句必须 5 字七言每句 7 字。我在逐字采样时维护一个计数器当句子长度达到目标时强制输出句读符号「」或「。」然后开始下一句。为了避免模型反复输出句读我把句读符号从采样候选集里临时剔除只在强制位置添加。def generate_poem(model, prefix, seq_len, max_len, device): model.eval() with torch.no_grad(): input_ids [char2idx[c] for c in (S prefix)] for _ in range(max_len): x torch.tensor([input_ids[-seq_len:]], devicedevice) out, _ model(x) logits out[0, -1, :] next_id sample_from_logits(logits) if idx2char[next_id] in 。: next_id sample_from_logits(logits, top_p0.7) input_ids.append(next_id) return .join(idx2char[i] for i in input_ids)这个循环里当采样到标点时我会重新采样一次并把top_p调低到 0.7。这样能避免模型在句子中间过早结束。真正的句读由外部强制插入也就是说整个生成过程不依赖模型输出句读符号。这样做让生成结果更规整。5. Flask 系统实现与部署接口设计、线程安全与五个常见坑5.1 后端接口与前端交互一次请求生成一首诗把训练好的模型包装成 Flask 服务本质上就是加载 checkpoint然后在 POST 请求里调用生成函数。我用一个全局变量_MODEL保存模型实例在应用启动时预热。接口设计如下from flask import Flask, request, jsonify import torch app Flask(__name__) _model None def load_model(): global _model checkpoint torch.load(checkpoint/20240501.pth.tar, map_locationcpu) char2idx checkpoint[char2idx] idx2char checkpoint[idx2char] _model PoetryLSTM(vocab_sizelen(char2idx), seq_lencheckpoint[seq_len]) _model.load_state_dict(checkpoint[model_state_dict]) _model.eval() return char2idx, idx2char, _model app.route(/api/generate, methods[POST]) def generate(): data request.get_json() theme data.get(theme, 春) style data.get(style, 五言) # 五言或七言 # 生成主逻辑省略 poem do_generate(theme, style) return jsonify({poem: poem, theme: theme, style: style}) if __name__ __main__: load_model() app.run(host0.0.0.0, port5000)load_model在app.run之前执行确保第一个请求到达时模型已在内存中。实际部署时我一般用 gunicorn 启动设置 4 个 worker。但要注意每个 worker 都会加载一份模型副本内存消耗翻倍如果服务器只有 2G 内存最好改成单 worker 多线程模式或者用torch.jit.script把模型序列化为 TorchScript推理速度也能提升 20% 左右。前端那边我提供了一个非常简单的index.html只有一个输入框、一个下拉框和结果区域用 fetch 调用接口。没有用 Vue 或 React因为目标用户只是需要看个效果没必要增加打包构建的复杂度。前端代码里没有坑主要问题都出在后端的并发和兼容性上。5.2 模型加载与线程安全不要在 request 里初始化模型最容易犯的错误是在请求处理函数里加载模型。这样每次请求都会读取磁盘、重建图结构响应时间会飙到好几秒而且高并发时内存不断增长。正确的做法是把模型加载到全局变量并且只加载一次。还有一个隐性问题PyTorch 模型在eval模式下多次调用forward是线程安全的吗严格说如果没有任何共享的可变状态是安全的。但我在采样函数里用了torch.multinomial它会维护一个全局的随机数生成器。多线程同时调用时理论上会争夺全局 RNG 的状态导致生成结果不稳定甚至报错。解决方法是每个请求使用独立的 RNG 状态from torch import manual_seed import random, time def do_generate(*args): seed int(time.time() * 1000) % (2**32) torch.manual_seed(seed) random.seed(seed) # 后续采样操作都是线程独立的了 poem generate_poem(...) return poem因为我在生成函数里没有用torch.Generator来显式控制抽样所以通过手动设置全局种子来隔离线程间的 RNG 冲突。这是我在压测时翻车后总结出来的。5.3 常见问题与避坑从版本兼容到路径编码的五个记录这一节记录了我在实际部署和用户反馈中遇到频率最高的 5 个问题每条都按「现象 → 原因 → 解决」列出。第一个坑加载 checkpoint 时RuntimeError: Unsupported weight type。原因是训练时用了torch.save(model, ...)保存的整个对象而部署环境的 PyTorch 版本不一致。解决用state_dict保存加载时用load_state_dict并且不要包含优化器状态除非你想断点续训。第二个坑Requests 并发一多返回的诗只有一两行。原因是 Flask 自带的单 worker 是串行处理但 gunicorn 多 worker 时每个 worker 都有独立的模型副本char2idx却可能在加载时被 python 的copy-on-write机制共享某些 worker 的映射表不完整。解决确保每个 worker 启动时都执行完整的load_model()不要依赖父进程的全局变量。第三个坑中文乱码。Flask 返回 JSON 默认 ASCII 编码汉字会变成\u6625前端不好展示。解决app.config[JSON_AS_ASCII] False。第四个坑生成的诗歌中出现「」这种空格或不可见字符。原因是原始语料清洗不干净留下了全角空格。解决在数据清洗阶段增加re.sub(r\s, , text)并在字符映射表里排除空字符。第五个坑用户输入的主题词超出词汇表。比如输入「火星」「火」在古汉语有但「星」不在模型无法处理。解决在接口层做字符级过滤对不在char2idx里的字符用PAD替换或者提示用户更换关键词。我选择提示避免生成无意义内容。6. 进阶验证与调优技巧从困惑度到人工评分6.1 用困惑度判断模型是否过拟合模型训练完后除了看 loss我还计算验证集的困惑度perplexity。困惑度是exp(loss)表示模型对下一个字符的平均不确定性。如果训练 loss 持续下降但验证的困惑度上升基本可以判断过拟合。字符级模型在词汇只有几千的情况下困惑度降到 2.5 左右就比较理想代表平均候选字符只有 2.5 个这已达到常用词的确定性输出水平。例如在「春眠不觉晓」后面模型给「晓」分配的候选概率集中在「明」「春」等字困惑度很低。6.2 人工评分表从格律、意境、通顺度三个维度打分自动指标永远不能替代人的审美。我设计了一个简单的人工评分表邀请 10 位读者对生成的 20 首诗打分每个维度 5 分最后取平均。格律分看平仄和押韵意境分看意象是否统一通顺度分看句子是否像人话。这个表的优点是评分者不需要是中文专业每条都有具体说明。资源里附带了scoring_template.xlsx可以自行复用。6.3 调优实验温度与 seq_len 的影响我用控制变量法对比了几组参数。seq_len32时生成的句子前后关联弱经常出现「前半句是山后半句是水」的断裂感seq_len128时诗句变得流畅但训练时间增加 40%且重复度略高。最终折中取 64 效果最好。温度方面T0.6时生成的诗过于保守每次都是「风吹柳絮飞」这类常见搭配T0.9时出现「石上流泉咽」这种稍微新奇的表达但偶尔会有不通顺的句子。我现在固定T0.8再配合top_p过滤。从那次之后我每次调参都会在训练日志里记录 temp、top_p 和人工评分的均值而不是只看 loss。现在我把这套验证流程固化到项目里每次新数据进来都会强制走一遍「训练 → 困惑度检查 → 人工抽样 → 调整温度」的闭环。如果你要用这份资源做二次开发建议保留这个习惯。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →