
简介一套以金庸《鹿鼎记》为语料、借助LSTM循环神经网络实现小说文本生成的完整Python项目面向自然语言处理入门者、毕设与课设学生也适合对文本生成感兴趣的开发者。项目包含网页爬虫、数据清洗、语料预处理、字符级LSTM训练与文本生成流程核心代码已跑通并附带训练好的权重文件可直接运行复现风格化文本输出。资源共5个文件涵盖Python源码、说明文档、语料txt与模型权重hdf5压缩包约18.78MB目录简洁便于按爬取—预处理—训练—生成拆解学习。目前已有193人浏览学习。除了完整代码与数据集还提供训练好的LSTM权重读者无需从零训练即可体验效果文档中附有运行说明遇到问题也可联系作者远程教学适合作为课程设计、毕业设计或LSTM文本生成实验的参考基线。1. 用 LSTM 学写小说为什么《鹿鼎记》是最划算的练手语料把一份《鹿鼎记》txt 丢进 LSTM 训练脚本让模型学完再输出一段几百字的“小说续写”这件事的门槛比大多数人想的低得多核心模型代码不到一百行一台不带独显的笔记本 CPU 也能跑完整个训练过程。这个练手项目要解决的是文本生成里最基础的字符级语言模型问题——以《鹿鼎记》为数据集预测每个位置的下一个汉字让网络慢慢学到金庸笔下的遣词习惯和对话节奏。它适合想从零接触 LSTM、又不想一上来就套大规模 Transformer 的 Python 新手也适合需要快速验证“文本生成到底能不能学出风格”的从业者。唯一的心理预期要先摆正LSTM 写小说本质是概率游戏产出的是“很像鹿鼎记的话”不是“金庸本人会写的剧情”。2. 先把《鹿鼎记》变成 LSTM 能吃的数字序列语料清洗与数据切分LSTM 和所有神经网络一样只吃数字不吃汉字。所以整个项目的第一个落地关卡是把小说原文转成整数 ID再切成长短一致的滑窗样本。这一步不涉及任何训练却决定了后面八成以上的问题——字符映射错一个生成的文本就会是乱码标点被误删句子就会挤成一团目录行没清理模型可能学会在每段开头输出“第 X 回”。我平时做这类项目会先把数据管道单独写成 Python 脚本跑通了再碰模型。2.1 数据准备从 txt 原文到字符级 token 映射中文场景下做语言模型第一选择是先定 token 粒度。常见做法是三种按词切分、按字符切分、按子词切分。按词切分要先引入分词工具jieba 这类库虽然快但会在“鹿鼎记”这种专有名词上产生碎片而且词典外的词会被切成生硬片段错误会顺着训练一路传播到生成结果。按字符切分就简单直接把“韦”“小”“宝”当作三个独立字符标点符号也作为普通字符保留。字符级模型的词汇表通常只有两三千不需要维护外部词表也没有 OOV 问题。这种做法和 LSTM 时间序列预测 python 项目里的滑窗思想其实是一回事时间序列预测用过去 N 个数值预测下一个数值字符级语言模型用过去 N 个字符 ID 预测下一个字符 ID。区别只在于数值是连续量、字符是离散量后者需要先经过 embedding 层变成向量。# -*- coding: utf-8 -*- import re def load_and_clean(path): with open(path, r, encodingutf-8-sig) as f: text f.read() text text.replace(\r, ) # 压缩多余空白保留中文标点 text re.sub(r[ \t], , text) text re.sub(r\n{3,}, \n\n, text) lines [line.strip() for line in text.split(\n)] # 去掉“第X回”这类回目标题避免污染正文预测 lines [line for line in lines if not re.match(r^第[一二三四五六七八九十百千0-9]回$, line)] return \n.join(lines).strip()逻辑说明utf-8-sig编码会自动剥掉文件开头的 BOM 头少踩一个隐藏坑压缩连续空行是为了避免模型花大量概率去预测“回车换行”这种无效目标删除回目标题行是因为生成小说时我们关心的是正文语句目录和回目会引入固定模式的噪声。注意正则只删除了整行都是“第 X 回”的情况像“第一回 纵横钩党清流祸”这种标题也会被命中正好一并清理。def build_char_map(text): chars sorted(set(text)) char2idx {c: i for i, c in enumerate(chars)} idx2char {i: c for c, i in char2idx.items()} return char2idx, idx2char, len(chars)参数说明set(text)去重后得到的字符集合包含汉字、中文标点、英文字母和数字。排序是为了让字符顺序稳定同一个文本重复加载时能得到相同的 ID 映射否则训练和生成时映射不一致模型输出就是乱码。这一小步就是“模型黑匣子”问题的最早来源模型本身没写错错的是前后两次加载数据时 ID 对不上。2.2 构造输入序列与标签滑窗切出“前文-后一字”样本字符级语言模型的监督信号很简单给定前 n 个字符预测第 n1 个字符。实现方式是用一个固定长度的滑窗在整本小说上每次滑动一个字符切出大量训练样本。每个样本由两段组成窗口前 seq_len 个字符作为输入 X窗口最后一个字符作为标签 y。这里要理解一个关键设计LSTM 每一步都接收一个字符但要预测的是序列末尾的下一个字符所以训练时标签只有一个而不是每个位置一个。def make_sequences(text, char2idx, seq_len64, stride1): ids [char2idx[c] for c in text] X, y [], [] for start in range(0, len(ids) - seq_len, stride): window ids[start:start seq_len 1] X.append(window[:-1]) y.append(window[-1]) return X, y逻辑说明stride1表示窗口每次只往后挪一个字语料里几乎每个位置都贡献一条样本数据利用率最高。stride4时样本量降到四分之一训练变快但会丢掉一部分上下文粒度。第一次做这个项目建议先用 stride1 跑通确认模型能过拟合再考虑要不要降采样。X的形状是(样本数, seq_len)y的形状是(样本数,)后面直接塞进 PyTorch 的TensorDataset就能用。参数说明seq_len64时模型最多只能看到前面 64 个字符的上下文大概不到两三句话seq_len128能记住更长的人物关系但 LSTM 在反向传播时要展开更多时间步训练时间和内存占用都会上升。建议先用 64 跑通全流程再生成对比文本观察长句逻辑是否改善。如果语料是数百万字的全本《鹿鼎记》样本量会非常大内存不够时可以把make_sequences改成生成器按批次产出而不是一次性 list 堆积。还有一个常被忽略的细节生成阶段的输入前缀也必须走到同一份char2idx。很多人训练用utf-8-sig读文件生成时用普通utf-8读用户输入结果第一个字符被解析成\ufeff模型就会莫名其妙地在开头吐一个不可见字符。这种情况在 loss 上看不出异常只能通过检查 ID 映射表的前几个字符来排查。3. 搭一个能学文风的 LSTM 模型结构选型与核心超参数数据管道就绪后进入模型部分。很多人到这里会直接陷入“调参玄学”但其实字符级 LSTM 的结构非常固定Embedding 层把字符 ID 变成向量LSTM 层把向量序列编码成隐藏状态全连接层把最后一个隐藏状态映射成下一个字符的概率分布。理解这三层的分工比记住某个具体参数更重要。3.1 为什么是 Embedding LSTM Dense把三层结构拆开看字符 ID 本身是一串整数比如“韦”的 ID 是 129。如果直接把 129 作为 LSTM 输入模型会学到“129 比 128 大”这种毫无意义的数值关系。Embedding 层的作用是把每个 ID 映射成一个固定维度的稠密向量向量的每个维度都参与学习模型会自动调整这些向量让语义相近的字符在向量空间里靠得更近。这一步是字符级模型能学出风格的基础。LSTM 层是核心。它每一步接收一个字符向量并维护一个隐藏状态把从开头到当前字符的信息压缩进状态里。LSTM 内部有输入门、遗忘门、输出门三个门控能选择性地记住或遗忘信息。在小说生成里这种机制直接对应“记住前文出现过的专有名词和对话背景”比普通 RNN 在长文本上稳定得多。PyTorch 里的nn.LSTM默认就带门控不需要手工实现。import torch import torch.nn as nn class CharLSTM(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim256, num_layers2, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim) self.lstm nn.LSTM(embed_dim, hidden_dim, num_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0) self.fc nn.Linear(hidden_dim, vocab_size) def forward(self, x, hiddenNone): emb self.embedding(x) # (batch, seq_len, embed_dim) out, hidden self.lstm(emb, hidden) # out: (batch, seq_len, hidden_dim) logits self.fc(out[:, -1, :]) # 取最后一个时间步 return logits, hidden逻辑说明batch_firstTrue让输入形状符合习惯的(batch, seq_len)从DataLoader取数据时不用转置。out[:, -1, :]取的是 LSTM 在最后一个字符处的输出因为我们的任务是预测下一个字符只关心读完整个窗口后的状态。返回值里的hidden是给生成阶段用的推理时要从上一步把它传回网络模型才能“记得”刚才生成到哪了。参数说明embed_dim128对两三千词的词汇表够用太小向量表达能力弱太大训练变慢收益有限hidden_dim256是中等容量可以在 CPU 上完成任务num_layers2比单层多一层非线性抽象对对话风格这种中等复杂度的特征拟合更好。dropout 只在层数大于 1 时生效单层 LSTM 加 dropout 会把输入直接丢掉一部分反而伤害拟合。为什么不直接用 GRU 或者换成 TransformerGRU 参数少、训练快但在同等维度下LSTM 对长距离上下文的记忆力更强这个项目标题既然指定了 LSTM就按 LSTM 做扎实。Transformer 当然效果更好但训练时需要更大数据量和显存等 LSTM 版本跑通后作为进阶对比更合理。3.2 训练与生成阶段都要对齐的几个必调参数参数选择没有绝对标准但可以从经验区间起步再按训练曲线调整。下面这张表是我在这个项目上常用的初始值适合 CPU 训练的全本语料场景。参数推荐区间作用与影响seq_len64~128模型可见上下文长度越大越能学出长句逻辑但显存和耗时线性上涨embed_dim96~256字符向量的表达能力下限过低学不出字形关联hidden_dim128~512LSTM 记忆容量过大容易过拟合训练集num_layers1~3层数越多抽象能力越强2 层是字符级任务甜点位dropout0.2~0.4抑制过拟合单层时设 0learning_rate3e-4~1e-3Adam 优化器常见区间过大 loss 震荡过小收敛慢batch_size64~256影响收敛速度和内存CPU 上建议从 128 起步我的习惯是先跑一个小模型试水embed_dim96hidden_dim128seq_len64训练一个 epoch 看 loss 有没有下降趋势。如果 loss 纹丝不动多半是数据管道有 bug而不是参数不够大。确认数据没问题后再逐步放大hidden_dim和seq_len。这和做 LSTM 时间序列预测 python 项目时的流程一致先小后大先验证再放大。训练时还有一个极其容易漏掉的细节如果每次迭代都把上一批的hidden直接传给下一批梯度会沿着整个训练历史反向传播显存会持续累积最终 OOM。正确做法是把hidden从计算图中摘出来或者干脆每批都从零状态开始。我在 4.1 节的训练循环里直接重设了隐藏状态这是最简单的保底方案。4. 跑通训练与采样生成把模型输出变成能读的小说片段模型结构定下来后最直接的目标是让训练循环跑起来然后写一个采样函数把模型的预测概率变成一段可读的文本。这一章是最容易产生“我是不是训练错了”怀疑的地方因为 loss 下降和文本质量之间不是线性关系。我建议按先训练后采样、采样结果再反推训练状态的顺序来排查。4.1 训练主循环与 checkpoint 保存loss 不降先看哪里训练主循环的核心组成是 CrossEntropyLoss 和 Adam。需要注意nn.CrossEntropyLoss内部已经包含 softmax 计算所以模型最后一层nn.Linear输出 logits 后不需要再手动接 softmax直接丢给损失函数即可。from torch.utils.data import TensorDataset, DataLoader from torch import tensor, nn, optim def train(model, X, y, epochs20, lr3e-4, batch_size128, devicecpu): ds TensorDataset(tensor(X), tensor(y)) dl DataLoader(ds, batch_sizebatch_size, shuffleTrue) model.to(device) loss_fn nn.CrossEntropyLoss() opt optim.Adam(model.parameters(), lrlr) for ep in range(epochs): total 0 for xb, yb in dl: xb, yb xb.to(device), yb.to(device) opt.zero_grad() logits, _ model(xb) loss loss_fn(logits, yb) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) opt.step() total loss.item() * xb.size(0) avg total / len(ds) print(fepoch {ep:02d} | loss {avg:.4f}) torch.save(model.state_dict(), fchar_lstm_ep{ep:02d}.pt)逻辑说明shuffleTrue是必须项。字符级样本之间本来就有高度重叠不 shuffle 的话LSTM 会学到“上一个样本的开头是下一个样本的结尾”这种虚假位置关系生成阶段会莫名其妙复读。clip_grad_norm_是文本生成里的保命函数LSTM 展开步数多梯度很容易爆炸成 NaN把梯度模长限制在 1.0 以下后大多数 loss 突变为 NaN 的情况都能避免。参数说明epochs20在 CPU 上跑全本语料可能需要较长时间可以先设 3 个 epoch 观察曲线。loss 初始值大约等于log(vocab_size)词汇表三千字时初始 loss 在 8 左右训练到 2.5 以下生成的句子会开始像样到 2.0 以下能明显看出对话结构和常用句式。如果 loss 降到 1.5 以下基本进入过拟合区间文本会偏向复读训练集中的高频片段。checkpoint 保存策略上我一般同时保存模型的state_dict和优化器的state_dict分别存成两个文件。只存模型权重可以加载做推理但如果训练中断想继续调 lr没有优化器状态就丢了 Adam 的自适应累计量等于没有后悔药。跑长任务前把恢复逻辑写进脚本开头比事后哀嚎省时间。4.2 采样生成temperature 是唯一要反复试的生成参数训练完成后生成阶段的入口是一个sample函数给模型一段前缀文本它逐个预测下一个字符再把预测到的字符拼回输入继续预测下一个。这里面最容易出效果差异的参数是temperature它在 softmax 之前对 logits 做缩放控制输出分布的平滑程度。def sample(model, char2idx, idx2char, prefix, seq_len, gen_len200, temperature0.8, devicecpu): model.eval() ids [char2idx[c] for c in prefix[-seq_len:]] hidden None with torch.no_grad(): for _ in range(gen_len): x tensor([ids[-seq_len:]]) .to(device) logits, hidden model(x, hidden) logits logits[-1] / temperature probs torch.softmax(logits, dim-1) nxt torch.multinomial(probs, 1).item() ids.append(nxt) return .join(idx2char[i] for i in ids)逻辑说明logits / temperature的物理意义很好理解。temperature 小于 1 时logits 被放大softmax 输出更接近 one-hot模型倾向选最高概率字符结果是稳定但容易复读temperature 大于 1 时logits 被压平概率分布更均匀模型会更敢选低概率字符句子更有新鲜感但语法容易崩。multinomial(probs, 1)是带随机的采样不是argmax这样才能让同一前缀每次生成不同内容。参数说明小说生成里 temperature 在 0.8 到 1.0 之间最稳。0.5 以下几乎必然复读1.2 以上经常冒出不通顺的句子。gen_len200是生成长度想测试长篇连贯性可以调到 1000但要接受后半段主题漂移——LSTM 的固定长度隐状态本来就装不下整本小说的长期依赖这是模型的物理边界。如果生成的文本从第 30 个字开始重复前 10 个字先不要怪模型把 temperature 调高再试一次。如果调高后变成乱码再回头检查hidden_dim是否太小。这个顺序能帮你快速区分“采样参数问题”和“模型容量问题”。5. 避坑/常见问题字符级文本生成的 5 个翻车现场这些坑我基本都踩过一遍而且很多是数据侧的问题伪装成模型问题。先处理数据再动模型参数能省下大量无效训练时间。1. 现象loss 正常下降生成文本却每隔几个字重复“韦小宝韦小宝韦小宝”。原因temperature 过低高概率字符被反复选中模型退化成高频词复读机也可能是hidden_dim过大导致模型只记住了高频字没有学上下文。另一个隐蔽来源是训练时shuffleFalse样本顺序高度相关模型学到虚假位置规律。解决先把 temperature 调到 0.9 左右用multinomial采样再确认训练循环里DataLoader开了shuffleTrue。两步都做了还复读才考虑缩小hidden_dim或加大seq_len让模型看更长的上文。2. 现象loss 一直卡在初始值附近约 log(vocab_size)词汇表三千字时约 8不下降。原因字符 ID 映射不一致。训练时用utf-8-sig读取生成时用utf-8读取BOM 字符进入采样序列或者数据清洗后混入了大量空白行模型一直在预测空字符。解决统一用同一个读取函数打印char2idx前十个字符确认没有\ufeff和空串清洗函数在训练前和生成前各调一次。这一步检查 5 分钟比多训练十个 epoch 有效。3. 现象loss 能降到 2.0 以下但生成的句子没有标点整段字挤成一坨。原因清洗阶段误删了中文标点。很多人用re.sub(r[^\u4e00-\u9fa5], , text)做清洗这一行把“。”全删了模型没见过标点自然学不会输出标点。解决清洗时保留中文标点白名单比如。“”和换行符。生成后检查输出如果一百字里一个标点都没有基本是数据侧问题不需要动模型。4. 现象训练第一个 batch 就显存不足或训练中途显存不断上涨直到 OOM。原因LSTM 的展开基数大约是 batch_size × seq_len × hidden_dim三者同时调大很容易超限更隐蔽的是在训练循环里把上一轮的hidden传给了下一轮梯度图跨 batch 累积。解决优先把 batch_size 降到 32、seq_len 降到 64确认能跑通再逐步加每次迭代后重设hidden None。显存上涨不是正常现象出现就停。5. 现象生成的段落开头总是“第X回”或者高频词集中在句首句尾。原因清洗时没删回目标题行模型学到“看到段落边界就输出回目标题”的虚假规律也可能是只取了语料头部几万字训练风格没覆盖全书。解决在load_and_clean里加回目标题过滤把整本语料分批读取不要只截头部。检查方式很简单看清洗后文本的前一百行还有没有目录结构用眼睛过一遍比任何指标都直观。需要注意的是现象和现象之间经常连锁出现。比如复读和标点缺失同时发生不要一次性调三个参数每次只改一个变量否则永远不知道是哪个动作救了模型。6. 进阶验证用困惑度、重复率和人工读感给小说质量打分训练结束不能只看 loss还缺三个可量化的验证维度。第一个是困惑度计算公式是exp(loss)比如 loss2.0 时困惑度约 7.4含义是模型对下一个字符平均有 7 个左右候选字。困惑度越低越好但字符级任务里降到 2 以下容易进入复读区要结合其他指标一起看。第二个是重复率统计生成文本中连续 8 个字符的片段在全文出现的次数重复率超过 30% 基本就是复读机现场。第三个是人工读感检查生成文本里有没有“韦小宝笑道”“说道”这类高频句式以及前后句是否保持同一位叙述角色。这三个维度缺一不可模型写出的每个字符概率再标准组合起来也可能会让读者一眼看出是机器写的。import math from collections import Counter def ppl_from_loss(loss): return math.exp(loss) def repeat_rate(text, n8): grams Counter(text[i:in] for i in range(len(text)-n)) repeated sum(v - 1 for v in grams.values() if v 1) return repeated / max(1, len(text) - n)逻辑说明repeat_rate用的是 n-gram 频率统计“重复片段出现第二次及以上”的次数占总窗口数的比例。这个指标专门抓“连续文本复读”比整体困惑度更贴近读者感受。参数说明n 取 8 是因为中文短句常有四到六字重复小于 8 会把正常的“韦小宝”三字重复误判成病句。进阶方向上LSTM 的隐藏状态是固定长度向量处理长文时上下文窗口受seq_len限制。想继续压榨质量可以把 LSTM 最后一层输出接一个线性注意力层让模型动态聚焦前文关键位置或者直接换一个两层小 Transformer 做对比实验同样数据量下学得更快但显存开销会明显上升。我个人的教训是最早我坚信 temperature 越低越稳定用 0.4 跑了一整夜第二天看到满屏复读才明白采样策略的权重比模型结构更大。后来把 temperature 调到 1.0打开训练数据的 shuffle生成的文本才恢复成“像小说的话”。这个方向值不值得投入答案是值得——LSTM 加字符级语言模型是目前理解文本生成原理、调试概率采样手感最便宜的路径跑通后再迁移到 Transformer很多直觉都是通用的。希望帮到你。本文还有配套的精品资源点击获取