简介:这份资源面向具备一定深度学习基础、希望动手实践序列生成模型的开发者与学习者,核心是用Keras框架搭建RNN与LSTM网络,实现古诗的自动创作。压缩包共8个文件,以4个Python脚本为主体,分别承担模型定义、数据预处理与配置管理等职责,另附poetry.txt语料、README说明文档、LICENSE授权文件及.gitignore等辅助内容,整体约3.86MB,结构紧凑、开箱即可运行。目前已有602人学习下载,说明该案例在入门序列建模人群中具有一定参考价值。读者可从中获得一套完整的古诗生成训练代码,理解LSTM如何捕捉诗句的时序依赖与韵律特征,并借鉴作者在数据编码、模型搭建与训练调参中踩过的坑,适合作为自然语言处理入门练手或课程设计参考。
1. 从一份 Keras 源码说起:RNN+LSTM 怎么把古诗“接”下去
前阵子有个做 NLP 的朋友丢给我一个压缩包,说里面是用 Keras 写的 RNN+LSTM 古诗生成模型,问我能不能跑起来看看效果。我解压之后发现,这东西比想象中完整——不是那种只丢一个model.py的半成品,而是从数据预处理、词向量映射、模型搭建到训练和生成都齐了。它解决的核心问题很具体:给定一句五言或七言的开头,让模型一个字一个字往下续,续出来的东西要像古诗,不是随机汉字堆砌。适合谁呢?刚学完 LSTM 理论想找个能跑通的完整项目练手的,或者想拿它当模板改成对联生成、歌词生成、文言文续写的,都能直接用。我花了一个下午把它跑通,又花了一个晚上把里面的坑踩了一遍,下面把整个过程拆开讲。
2. 项目结构拆解:数据、模型、训练脚本各管什么
2.1 目录布局与文件职责
拿到源码包,第一步别急着python train.py,先看清楚每个文件干什么。这个项目的结构是典型的“小项目三件套”加一个数据目录,我按实际解压后的样子列一下:
poetry_generator/ ├── data/ │ └── poems.txt # 原始古诗语料,一行一首 ├── model.py # 模型定义,RNN+LSTM 堆叠 ├── train.py # 训练入口,读数据、建词典、跑 epoch ├── generate.py # 加载权重,给定首句续写 ├── config.py # 超参数集中管理 └── checkpoints/ # 训练过程中保存的 .h5 权重config.py这个文件容易被忽略,但它决定了你后面改模型时会不会到处翻代码。常见做法是把序列长度、embedding 维度、LSTM 单元数、batch size、学习率全放这里。我一般会先打开它,因为后面调参基本只动这一个文件。
# config.py 超参数集中配置 SEQ_LEN = 20 # 输入序列长度,即用前20个字预测第21个 EMBEDDING_DIM = 128 # 字向量维度 HIDDEN_UNITS = 256 # LSTM 隐藏层单元数 BATCH_SIZE = 64 EPOCHS = 50 LEARNING_RATE = 0.001 VOCAB_PATH = 'checkpoints/vocab.pkl' # 词典映射保存路径 MODEL_PATH = 'checkpoints/model.h5' # 模型权重保存路径这里SEQ_LEN=20意味着模型每次看 20 个字,预测下一个字。古诗五言一句才 5 个字,20 的窗口能覆盖四句,上下文够用。HIDDEN_UNITS=256是 LSTM 层的输出维度,太小会欠拟合,太大在 CPU 上跑得你想砸电脑,256 是个折中值。
2.2 语料格式与词典构建逻辑
data/poems.txt的格式很关键,它决定了预处理代码怎么写。这个项目里是一行一首诗,标点符号保留,因为标点也是节奏的一部分。预处理阶段要做的事:把所有字收集起来建一个“字到 id”的映射表,同时保留一个“id 到字”的反向表,生成的时候要用。
# train.py 中构建词典的核心片段 def build_vocab(text): chars = sorted(set(text)) # 去重并排序,保证每次运行结果一致 char2id = {ch: i for i, ch in enumerate(chars)} id2char = {i: ch for ch, i in char2id.items()} return char2id, id2char with open('data/poems.txt', 'r', encoding='utf-8') as f: corpus = f.read() char2id, id2char = build_vocab(corpus) vocab_size = len(char2id) print(f'词典大小: {vocab_size}') # 一般几千到一万出头sorted(set(text))这一步别小看。如果你用set直接转 list,每次运行顺序可能不一样,导致保存的词典和下次加载的对不上,生成出来全是乱码。这是血泪经验,我第一次跑就翻车在这里。词典大小通常几千,常用汉字加标点,超过一万说明语料里混了生僻字或者编码有问题。
2.3 序列样本的构造方式
有了词典,接下来要把长文本切成“输入-标签”对。滑动窗口是最直接的做法:从第 0 个字开始取 20 个作为输入,第 20 个字作为标签;然后窗口往后挪一位,取 1 到 20 作为输入,第 21 个字作为标签,以此类推。
# 构造训练样本:滑动窗口切分 import numpy as np def make_sequences(corpus, char2id, seq_len): X, y = [], [] for i in range(len(corpus) - seq_len): seq_in = corpus[i:i + seq_len] seq_out = corpus[i + seq_len] X.append([char2id[ch] for ch in seq_in]) y.append(char2id[seq_out]) X = np.array(X) y = np.array(y) return X, ylen(corpus) - seq_len保证最后一段不会越界。假设语料有 10 万字,那就能造出约 10 万个样本,每个样本是长度 20 的整数序列。这个量级在 CPU 上跑一个 epoch 大概几分钟,GPU 上几十秒。如果内存吃紧,可以改用生成器分批喂数据,但小项目直接np.array也扛得住。
3. 模型搭建与训练:RNN+LSTM 到底怎么堆
3.1 模型结构逐层说明
这个项目的模型不算复杂,但层次清晰:Embedding 层把整数 id 转成稠密向量,LSTM 层负责捕捉时序依赖,最后接一个全连接层输出每个字的概率。有人会问为什么用 LSTM 而不是普通 RNN,原因很直接——普通 RNN 在长序列上梯度消失严重,20 个字的窗口虽然不算特别长,但 LSTM 的门控机制能更稳地记住前面的信息。
# model.py 模型定义 from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, LSTM, Dense, Dropout from config import EMBEDDING_DIM, HIDDEN_UNITS, SEQ_LEN def build_model(vocab_size): model = Sequential() # 输入是长度为 SEQ_LEN 的整数序列 model.add(Embedding(input_dim=vocab_size, output_dim=EMBEDDING_DIM, input_length=SEQ_LEN)) # LSTM 层,return_sequences=False 表示只取最后一个时间步的输出 model.add(LSTM(HIDDEN_UNITS, return_sequences=False)) model.add(Dropout(0.3)) # 防止过拟合 # 输出层:vocab_size 个神经元,softmax 转概率 model.add(Dense(vocab_size, activation='softmax')) model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy']) return modelEmbedding的input_dim必须等于词典大小,output_dim是每个字映射成多少维向量,128 是常见起点。LSTM的return_sequences=False表示只把最后一个时间步的隐藏状态传给下一层,因为我们只需要预测下一个字,不需要每个时间步都输出。Dropout(0.3)在训练时随机丢弃 30% 的神经元,推理时关闭。输出层神经元数等于词典大小,softmax保证所有字的概率加起来为 1。
3.2 训练循环与回调配置
训练脚本要做的事:加载语料、建词典、构造样本、把标签转成 one-hot、喂给模型。这里有个细节,categorical_crossentropy要求标签是 one-hot 格式,所以要用to_categorical转一下。
# train.py 训练主流程 from tensorflow.keras.utils import to_categorical from tensorflow.keras.callbacks import ModelCheckpoint from model import build_model from config import * with open('data/poems.txt', 'r', encoding='utf-8') as f: corpus = f.read() char2id, id2char = build_vocab(corpus) vocab_size = len(char2id) X, y = make_sequences(corpus, char2id, SEQ_LEN) y_onehot = to_categorical(y, num_classes=vocab_size) model = build_model(vocab_size) # 每个 epoch 保存一次权重,只保留最好的 checkpoint = ModelCheckpoint(MODEL_PATH, monitor='loss', save_best_only=True, verbose=1) model.fit(X, y_onehot, batch_size=BATCH_SIZE, epochs=EPOCHS, callbacks=[checkpoint])ModelCheckpoint的save_best_only=True很实用,它只保留 loss 最低的那份权重,避免最后几个 epoch 过拟合把好模型覆盖掉。batch_size=64是内存和收敛速度的平衡点,显存够可以调到 128。训练时你会看到 loss 从 6 点多慢慢降到 2 左右,再往下就比较难了,因为古诗的多样性摆在那里。
3.3 生成阶段的温度参数
训练完加载权重,给一个开头,让模型一个字一个字往外蹦。这里有个关键参数叫“温度”,控制生成的随机性。温度低(比如 0.5)会偏向高概率的字,生成结果保守但通顺;温度高(比如 1.2)会引入更多随机性,可能出惊喜也可能出乱码。
# generate.py 生成逻辑 import numpy as np from tensorflow.keras.models import load_model from config import * model = load_model(MODEL_PATH) char2id, id2char = load_vocab(VOCAB_PATH) # 加载训练时保存的词典 def sample(preds, temperature=1.0): preds = np.asarray(preds).astype('float64') preds = np.log(preds + 1e-8) / temperature # 加小量防止 log(0) exp_preds = np.exp(preds) preds = exp_preds / np.sum(exp_preds) return np.random.choice(len(preds), p=preds) def generate(seed, length=50, temperature=0.8): result = seed for _ in range(length): x = np.array([[char2id.get(ch, 0) for ch in result[-SEQ_LEN:]]]) preds = model.predict(x, verbose=0)[0] next_id = sample(preds, temperature) result += id2char[next_id] return result print(generate('春风吹又生', length=40, temperature=0.8))np.log(preds) / temperature是温度采样的标准写法,温度越低分布越尖锐。char2id.get(ch, 0)处理没见过的字,映射到 0 号 id,虽然不完美但不会崩。result[-SEQ_LEN:]保证每次只取最近 20 个字作为输入,和训练时一致。
4. 避坑与排查:跑不起来先看这几条
4.1 词典顺序不一致导致生成乱码
现象:训练时 loss 正常下降,但生成出来全是“的的的的”或者完全不相关的字。原因:训练和生成两次运行之间,词典的构建顺序变了。set转 list 的顺序在不同 Python 进程里可能不同。解决:训练完把char2id和id2char用pickle存下来,生成时直接加载,不要重新构建。
import pickle with open(VOCAB_PATH, 'wb') as f: pickle.dump((char2id, id2char), f)4.2 序列长度与输入维度不匹配
现象:model.predict报错,说 expected shape 和 got shape 对不上。原因:生成时传入的序列长度不是SEQ_LEN,比如开头只有 3 个字,直接喂进去就崩了。解决:生成前先把 seed 补齐到SEQ_LEN,不够的用空格或者重复字符填充,或者从 seed 里取最后SEQ_LEN个。
4.3 显存溢出与 batch size 调整
现象:训练一开始就报 OOM,或者跑几个 batch 后卡死。原因:batch_size太大,或者HIDDEN_UNITS设得过高。解决:先把batch_size降到 32 甚至 16,HIDDEN_UNITS从 256 降到 128 试试。CPU 训练的话,把EPOCHS调小先验证流程能跑通。
4.4 过拟合导致生成内容重复
现象:训练 loss 降到 1 以下,但生成的诗句翻来覆去就那几句。原因:模型把训练语料背下来了,没有学到泛化规律。解决:加大Dropout比例到 0.4 或 0.5,或者减少HIDDEN_UNITS,也可以早停——在 loss 不再明显下降时就停。
4.5 中文编码问题
现象:读文件时报UnicodeDecodeError。原因:语料文件不是 UTF-8 编码,可能是 GBK 或 GB2312。解决:用open(..., encoding='utf-8')明确指定,如果还报错就用chardet检测实际编码再转。
5. 二次开发:从古诗到对联、歌词的改造思路
5.1 换语料就能换风格
这个项目最值钱的地方不是模型本身,而是整套流程。你把data/poems.txt换成对联语料,模型照样能跑,生成的就是对联。换成歌词,生成的就是歌词。关键在语料格式要统一:一行一条,标点保留。我试过用同一套代码换了一份宋词语料,只改了SEQ_LEN从 20 到 30,因为词的长短句更复杂,需要更长的上下文。
5.2 加一层注意力机制
LSTM 对长序列的记忆还是有限,如果你想生成更长的文本,可以在 LSTM 后面加一个Attention层。Keras 里可以用tensorflow.keras.layers.Attention,但要注意输入输出的维度对齐。常见做法是让 LSTMreturn_sequences=True,然后把所有时间步的输出和最后一个时间步做注意力加权。
from tensorflow.keras.layers import Attention, GlobalAveragePooling1D # 在 LSTM 后加注意力 model.add(LSTM(HIDDEN_UNITS, return_sequences=True)) model.add(Attention()) # 自动计算时间步权重 model.add(GlobalAveragePooling1D()) # 把加权后的序列压成一个向量5.3 用预训练词向量替换 Embedding
如果语料够大,可以先用word2vec或BERT训练字向量,然后把权重加载到Embedding层,设trainable=False冻结。这样在小语料上也能有不错的泛化。我一般会先跑一遍随机初始化的版本作为 baseline,再换预训练向量对比 loss 下降速度。
5.4 部署成 API 服务
训练完的模型可以用Flask或FastAPI包一层 HTTP 接口,输入首句返回续写结果。注意加载模型要在服务启动时做一次,不要每次请求都load_model,否则延迟高得没法用。
from flask import Flask, request, jsonify app = Flask(__name__) model = load_model(MODEL_PATH) # 启动时加载一次 @app.route('/generate', methods=['POST']) def api_generate(): seed = request.json.get('seed', '春风吹又生') result = generate(seed, length=40, temperature=0.8) return jsonify({'result': result})5.5 验证改造是否有效的笨办法
改完模型或者换了语料,怎么知道变好了还是变差了?我的习惯是固定 10 个 seed,每个 seed 生成 5 次,人工看通顺度和多样性。通顺度看有没有明显语法错误,多样性看 5 次结果是不是雷同。如果 10 个 seed 里有 7 个以上能读出像样的句子,说明改造有效。别只看 loss,loss 低不代表生成质量高,这是我在这个项目上踩过的最大的坑。
从那以后我每次改完模型,都强制走一遍“固定 seed 人工评估”的流程,不再只盯着 loss 曲线。希望这个拆解能帮你把这份 Keras 源码顺利跑起来,少走点我走过的弯路。
本文还有配套的精品资源,点击获取