ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于LSTM的古诗词自动生成:从模型训练到Web系统实现全攻略

基于LSTM的古诗词自动生成:从模型训练到Web系统实现全攻略

每年这个季节,都会有人拿着同一个题目来问:基于LSTM的古诗词自动生成算法与系统实现,能不能做?我的回答基本始终如一——能做,而且这个题目在NLP入门项目里算得上性价比极高。它本质是训练一个语言模型,让模型学会“从一个字猜到下一个字”的统计规律:你给它“床前明月”,它要能接出“光”;你给它“春江”,它要能续成一首完整的长诗。更关键的是,它不止停留在算法脚本层面,还能包成一个Web系统,后端挂训练好的LSTM模型,前端留一个输入框和展示区。如果你正在做课程设计或毕业设计,这篇文章基本就是一份可以直接照做的完整操作清单。

我见过不少同学在这类题目上翻车,翻法还很一致:模型代码跑通了,系统页面也做出来了,但一演示就露馅。要么生成结果驴唇不对马嘴,要么现场模型加载卡死,要么老师问一句“为什么选LSTM而不是Transformer”就答不上来。其实这些问题都不是技术难点,而是整个项目的设计顺序乱了。这篇我就按自己带做这类项目的完整流程讲一遍,从数据预处理、模型训练、生成策略到Flask封装,再到答辩前必须准备的东西,尽量写成能直接抄作业的格式。

1. 项目整体设计与思路拆解

很多人的第一反应是打开编辑器就写网络结构,训完模型才发现数据脏得没法看,或者做完算法不知道怎么变成系统。我更建议反过来:先想清楚交付物是什么,再想清楚模型怎么出诗,最后才是写代码。

这个项目标准的交付物通常有两块。第一块是算法部分,包括数据预处理脚本、模型训练脚本、生成脚本;第二块是系统部分,一个能跑起来的Web应用。明确了交付物之后,再去看模型选型、生成策略、前后端怎么接,所有问题都会变得非常具体。

1.1 古诗词自动生成到底在解决什么问题

从本质上讲,这就是一个“猜字游戏”。模型手里捏着一本概率统计册子,你输入几个字符,它就按条件概率选出下一个最可能的字符,再把新字符拼到末尾继续猜,直到达到目标长度。用语言模型的话说,就是要最大化整个序列的联合概率,拆开看就是每个位置上“当前字符在前文条件下出现的概率”的连乘积。

古诗有个天然优势:五言七言通常只有二三十个字符,这个长度范围内的前后文依赖,恰好是LSTM最有把握的区域。LSTM全称长短时记忆网络,可以把它理解成一个带了多扇门的记忆管家:输入门决定当前看到的信息要不要写进记忆,遗忘门决定旧记忆要不要倒掉,输出门决定此刻放出哪些信息。相比普通循环神经网络,LSTM能更好规避梯度消失问题,不会因为句子长了就把开头几个字忘干净。写诗需要的就是这种前后呼应、跨句照应的能力,所以它作为主力模型完全够用。

1.2 为什么课程设计场景下优先选LSTM

我先把常见候选模型做个对比。

模型参数规模训练难度长距离依赖课程设计友好度
普通RNN小低弱,梯度消失明显一般,效果较差
LSTM / GRU中低较强高
Transformer大高强低,调参成本高

有人会问,现在主流不是都转Transformer了吗?答案是可以用,但在这个题目里性价比不划算。课程设计周期通常两到四周,语料也就几万首古诗,Transformer需要更大数据量、更久的训练时间,在只有普通显卡甚至纯CPU的环境下,很容易变成调参黑洞。LSTM训练快,逻辑直观,导师容易看懂,生成效果在古诗这种短文本上也不差。拿课程设计的心态做项目,选LSTM不是技术落后,是性价比最优解。老师如果追问“为什么不试试Transformer”,你只需要回答“规划里把Transformer作为对比实验的候选方向”,这个预留空间会让方案显得更有深度。

1.3 算法模块和系统模块怎么衔接

最省事的做法是训练完模型后,把模型文件丢给Flask后端加载,后端暴露一个生成接口,前端页面通过Ajax调用这个接口,拿到结果后填充到页面上。这样算法和系统在代码层面只有一层接口关系,两边各改各的,不互相绑架。

很多人容易犯的一个错误是:把生成逻辑直接写死在训练脚本里,到做系统时又复制粘贴一遍。正确做法是独立抽出一个生成器模块,训练脚本里用来调试采样的函数,Web后端也调同一个文件。这样你在Notebook里调出来的温度参数、生成效果,和浏览器里演示的完全一致,答辩时不管从哪个入口展示都不会出意外。

2. 数据准备与模型训练要点

数据预处理花两三个小时,换回来的是整个模型的上限。这一点怎么强调都不过分。古诗生成任务中,模型学到什么程度基本由语料质量决定,网络结构反而影响相对小。

2.1 数据预处理直接决定生成质量

可用的开源古诗词语料并不难找,常见做法是收集几千首到几万首诗,每首诗占一行,保存成UTF-8编码的txt文件。这里有几个细节需要专门处理。

第一,编码必须统一,否则会出现乱码和未登录字。第二,繁体转简体,除非你明确想做古风效果,一般课程设计都统一成简体。第三,要去掉诗题、作者、注释这些干扰信息,保留纯诗句。第四,也是容易被忽略的一点,字符集大小必须控制。把所有出现过的字符统计一遍,保留频次前3000到5000的字符,剩下统一映射到<UNK>。为什么是这个范围?常用汉字量级本来就在几千这个区间,字表太小模型能选的候选字太少,生成内容会显得僵硬;字表太大,比如直接几万字全保留,模型要学的东西太多,很容易欠拟合,训练也慢。

建字表的写法很简单,扫描全文用Counter统计频次,按编号建立两个字典:char_to_idx和idx_to_char。然后按滑动窗口切训练样本,sequence_length取20就很稳。窗口太短学不到上下文,窗口太长训练集膨胀,有效信息反而变稀疏。每个窗口前20个字当输入,第21个字当标签,移动一步又生成一个新样本。

2.2 LSTM网络结构怎么定

入门配置一般是这样:Embedding输入维度等于字符表大小、输出维度128,接一层128个隐藏单元的LSTM并返回序列,中间插一个Dropout层防止过拟合,再接第二层128单元LSTM,最后接全连接层输出每个字符的概率,激活函数用softmax。

Embedding层负责把一个字符的编号映射成稠密向量。比起one-hot编码,它更省内存,而且能从语料里学到字符之间的相似关系,比如“春”“秋”这种意象相关的字在向量空间里会更接近。两个LSTM叠加是为了捕捉更抽象的上下文,但如果语料规模不大,单层LSTM也不会差太多。隐藏单元数取128是很多人验证过的折中值:太小欠拟合,生成内容很平;太大训练慢,小语料上还容易过拟合。Dropout层放在LSTM中间,能让模型不过分依赖某一个时间步的特征,对防止模型把训练集整首诗背下来很有效。

2.3 训练参数和训练中的判断标准

训练配置直接给一套亲测稳的:

  • 损失函数:sparse_categorical_crossentropy
  • 优化器:Adam,初始学习率0.001
  • batch_size:64到128
  • epoch:先设50,配早停回调

训练时不要傻等50轮跑完,一定加回调。用ModelCheckpoint按验证集loss保存最优权重,用EarlyStopping设patience为5,验证loss连续不降就自动停。损失一般会在前几轮快速下降,后面慢慢收敛。如果训练loss反弹,说明学习率偏大,调到0.0005再训。

这里有一个常见反面现象:训练loss降得很漂亮,生成结果却像在拼接训练集原文。这就是过拟合。三个解决方向:增加语料规模、加大Dropout比例、减小隐藏单元数。课程设计里轻度过拟合不是致命伤,因为演示时给一个前缀,能生成一句通顺有诗意的句子就能交差,但你要心里清楚这个现象的出现位置和原因,被老师问到时能说出一二。

3. 实操过程与核心环节实现

3.1 环境准备:框架和依赖怎么选

框架推荐TensorFlow/Keras。原因很实际:课程设计报告和代码需要被人快速看懂,Keras高层API十几行就能交代清楚模型结构,用底层API写出来一屏都放不下。PyTorch当然也很好,但如果导师团队熟悉的不是它,Keras的阅读成本更低。环境依赖大概就是:

tensorflow==2.13.0 flask==2.3.2 numpy==1.24.3

训练硬件方面,几万首古诗的数据量,CPU也能跑,GPU则快很多。我的建议是训练阶段有卡则用卡,没卡就把语料缩小到八九千首,同样能训出能用的模型。不要一次性把全部数据加载进内存,用批次喂给模型的方式更稳,内存占用也更低。

3.2 可复用的LSTM训练脚本:一份能跑通的最小实现

下面给出一个直接可跑的最小实现,为便于阅读省略了部分异常处理,整体结构是完整的。

import numpy as np from collections import Counter import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, LSTM, Dense, Dropout from tensorflow.keras.optimizers import Adam # 1. 读取语料,每行一首诗 with open("poems.txt", "r", encoding="utf-8") as f: lines = [line.strip() for line in f if len(line.strip()) > 0] text = "\n".join(lines) # 2. 统计字符频次,控制词表大小 chars = Counter(text) vocab = ["<UNK>"] + [c for c, _ in chars.most_common(4000)] char_to_idx = {c: i for i, c in enumerate(vocab)} idx_to_char = {i: c for i, c in enumerate(vocab)} VOCAB_SIZE = len(vocab) # 3. 滑动窗口构造训练样本 seq_len = 20 sentences, next_chars = [], [] for i in range(len(text) - seq_len): sentences.append(text[i: i + seq_len]) next_chars.append(text[i + seq_len]) X = np.array([[char_to_idx.get(c, 0) for c in s] for s in sentences]) y = np.array([char_to_idx.get(c, 0) for c in next_chars]) # 4. 定义LSTM模型 model = Sequential([ Embedding(VOCAB_SIZE, 128, input_length=seq_len), LSTM(128, return_sequences=True), Dropout(0.2), LSTM(128), Dense(VOCAB_SIZE, activation="softmax") ]) model.compile( loss="sparse_categorical_crossentropy", optimizer=Adam(0.001), metrics=["accuracy"] ) # 5. 训练并保存 model.fit(X, y, batch_size=128, epochs=30, validation_split=0.1) model.save("poem_lstm.keras")

这段代码有两个细节值得单独说。第一个是<UNK>的处理:所有不在词表里的字符统一归到0号,保证X和y的维度始终对齐。第二个是X必须是整数类型,Embedding层不接受浮点输入。第一次跑没有GPU也不要紧,一万首诗的语料大概训二三十分钟,数据量再砍一半就更快了。

3.3 生成策略:采样比模型本身更影响作品质量

模型输出的是每个候选字的概率分布。怎么从这个分布里选字,直接决定成品是“像诗”还是“像乱码”。最粗暴的是贪心采样,每次都取概率最大的字符,但这样容易陷入重复循环,生成一两句之后开始原地打转。

常用方案是带temperature的随机采样。它的核心逻辑很直观:温度低,概率分布被拉尖,模型更保守;温度高,分布变平,选字更大胆。古诗生成的经验温度在0.6到0.9之间,低于0.5容易重复,高于1.2语义基本就碎了。

def sample_with_temperature(probs, temperature=0.8): probs = probs.astype(np.float64) log_probs = np.log(probs + 1e-6) / temperature exp_probs = np.exp(log_probs) probs = exp_probs / np.sum(exp_probs) return np.random.choice(len(probs), p=probs)

实际项目中,我会在这个基础上叠一个top-k过滤和重复惩罚。top-k只保留概率最高的前20到50个候选字再归一化,可以避免罕见字突然跳出来;重复惩罚是对已经出现过的字符的概率除以一个大于1的系数,能明显减轻死循环。把这些策略封装成一个统一的生成函数:

def generate_poem(model, prefix, char_to_idx, idx_to_char, seq_len=20, length=30, temperature=0.8, top_k=30, repeat_penalty=1.1): current = prefix for _ in range(length): indices = [char_to_idx.get(c, 0) for c in current[-seq_len:]] indices = [0] * (seq_len - len(indices)) + indices x = np.array([indices], dtype=np.int32) probs = model.predict(x, verbose=0)[0] for c in set(current): if c in char_to_idx: probs[char_to_idx[c]] /= repeat_penalty top_indices = np.argsort(probs)[-top_k:] top_probs = probs[top_indices].astype(np.float64) log_probs = np.log(top_probs + 1e-6) / temperature exp_probs = np.exp(log_probs) top_probs = exp_probs / np.sum(exp_probs) chosen = np.random.choice(top_indices, p=top_probs) current += idx_to_char.get(int(chosen), "") return current

如果对比效果:温度0.2时,输出常是“春江春水春江流”这类单调重复;温度0.8能保持语义连贯又有点变化;温度1.5则会出现“春江清影夜啼乌”这种破碎感较强的句子。把这三档对比放一张表里,课程设计报告里就有了一小节“生成策略分析”的素材。

3.4 Web系统封装:Flask加一个页面就够了

模型训练好之后,系统部分用一个轻量Web应用就够。Flask后端加载模型,暴露一个/generate接口,前端输入前缀,Ajax请求后端,把生成结果渲染到页面上。

from flask import Flask, request, jsonify from tensorflow.keras.models import load_model app = Flask(__name__) model = load_model("poem_lstm.keras") @app.route("/generate", methods=["POST"]) def generate(): data = request.get_json(force=True) prefix = data.get("prefix", "春江") temperature = float(data.get("temperature", 0.8)) length = int(data.get("length", 30)) result = generate_poem( model, prefix, char_to_idx, idx_to_char, length=length, temperature=temperature ) return jsonify({"poem": result}) if __name__ == "__main__": app.run(port=5000)

前端不需要花哨,一个输入框、一个温度下拉框、一个生成按钮加一个结果展示区域就够了。页面里调接口的核心代码也很短:

async function generatePoem() { const resp = await fetch('/generate', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ prefix: document.getElementById('prefix').value, temperature: parseFloat(document.getElementById('temp').value), length: 30 }) }); const data = await resp.json(); document.getElementById('result').textContent = data.poem; }

有一个很容易被忽略的坑:模型加载之后,预测时千万别开着训练模式。Keras默认predict就是推理,不会反向传播,但如果你在代码里手滑调用了训练相关接口,内存会越跑越高。模型文件放在项目固定路径下,Flask启动时加载一次,不要在每次请求里重复load。

4. 常见问题与排查技巧实录

4.1 训练loss一直不降怎么排查

先看数据,这是最高频原因。语料里如果混着半角符号、乱码、异体字,模型容易被带偏。处理办法是打印词表前50个高频字符,一眼就能看到有没有奇怪符号,有就清洗。排除了数据问题再看学习率,Adam默认0.001多数情况没问题,但如果loss在某个值附近反复震荡,把学习率调成0.0005或0.0003。最后确认模型结构,Dense层输出维度是否等于VOCAB_SIZE,Embedding输入长度和样本序列长度是否一致。按这三个位置顺序查,基本能解决九成问题。

4.2 生成结果重复、绕圈圈怎么办

这个坑我踩过比较多次。模型短时记忆有限,加上贪心采样,很容易在几个字之间循环。解决方案有个优先级:先把temperature提上去,0.8起步,放弃贪心;再加top-k过滤,K取30,收窄候选范围;如果还重复,就在采样时对已经出现过的字符做重复惩罚;最后是限制生成长度,古诗在20到40字之间收尾,别让死循环无限跑下去。这个环节建议把采样逻辑封装成独立函数,所有生成调用都走它,后面想调参数只改一个文件,省心很多。

4.3 生成的诗不符合格律、韵脚乱怎么办

要明确一点:纯LSTM学到的是字面统计规律,不是平仄格律规则,所以出律是正常现象。缓解办法分两个层次。第一层在数据端,语料优先选标准的五言七言绝句,把词、杂言、长短句过滤掉,模型学到的句式结构更整齐,出律概率明显下降。第二层在生成端,加一个后处理函数:按五言七言截断或补齐每句字数,句尾如果指定韵脚,从韵部字表里替换或重新采样。这种“模型负责语义,规则负责格律”的混合思路,是实际工程项目里会用的做法,写进报告里反而能体现你的思考深度。

4.4 Web端模型加载慢、接口卡顿怎么优化

Keras保存的模型文件从几十到几百MB不等,答辩现场启动时间长会很尴尬。三个应对措施:第一,模型启动时一次性加载进内存,不要在每个请求里重新加载;第二,Flask启动前做预热,先打一次模型生成再对外服务;第三,如果模型实在太大,把词表降到3000、Embedding降到64,体积能缩小一截,生成效果不会差太多。对于课程设计而言,完全不需要走到量化部署那一步,别在这个环节消耗时间。

4.5 答辩演示前必须处理的三个细节

第一,把几个生成结果提前离线跑好,存成截图或文本,即使现场网络抽风也有得展示。第二,手动准备两三个演示前缀,不要现场打一堆奇怪的字符,五言就输“春风”,七言就输“远上寒山”,这类常见前缀模型大多见过,生成质量更稳定。第三,模型文件路径写清楚,绝对路径或项目内固定相对路径都行,不要在答辩时因为路径不对而当场报错。这些细节看着琐碎,但每年真的有人摔在同一处。

5. 最后分享几点实操体会

5.1 语料和报告怎么写更划算

关于语料,永远记住“宁缺毋滥”。网上很多合集里混着注释、作者名、错字,花两三个小时清洗,比多调三层模型结构更值得。报告部分也不用堆砌训练曲线,把数据预处理流程、模型结构说明和示意图、训练loss下降曲线、不同temperature生成对比、系统页面截图放进去,结构就很完整了。我还建议放一个“失败案例”小节,把生成效果最差的作品贴进去分析原因。导师会觉得你是真理解了这个流程,而不是只会跑通代码。

5.2 扩展方向和一个自查习惯

如果做完基础版还有余力,有三个方向可以选:接一个韵脚字表做押韵控制,把LSTM和GRU做一组对比实验,或者把前端界面升级成多主题风格。无论选哪个,投入产出比都很高,答辩印象分会明显不一样。我自己的习惯是,写完训练脚本后一定拿同一组前缀把整个生成流程完整跑一遍,再交给Web端接。不要小看这个自查动作,它能避开演示时几乎所有低级问题。这套流程走完,你训练出的第一首完整古诗,也足够成为一份挺有成就感的课设成果。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进