简介:这是一份以金庸《鹿鼎记》全文为训练语料的深度学习练手项目,主要面向自然语言处理(NLP)方向的学生、毕设开发者及LSTM初学者。资源内含网页爬取脚本、语料数据、模型训练代码与已保存的权重文件,并附带README说明文档,覆盖从数据抓取、文本预处理、词典映射到LSTM模型训练与文本生成的全流程,尤其适合需要快速搭建文本生成Demo或完成课程设计、毕业设计的读者参考。压缩包共5个文件,核心包括GetLu.py爬虫脚本、Word_LSTM.py模型训练脚本、lu.txt原始语料、README.md说明文档以及weights...hdf5模型权重文件,资源包大小约18.78MB,整体结构轻量、链路完整。目前已有193人学习下载,可从零开始理解字符级语言模型的实现思路,也可直接运行代码观察训练过程中的困惑度变化,是一份兼具教学与二次开发价值的Python深度学习素材。
1. 用 LSTM 学写小说:一份能直接跑通的 Python 源码与训练数据
这份资源的核心是两件事:一份已经爬好的《鹿鼎记》全集纯文本语料,和一个基于 LSTM 的字符级文本生成模型源码。你不需要自己再去找语料、也不用从头开始调爬虫,下载解压之后,改一下路径就能开始训练。我用它跑通了一个最小可用的“中文小说生成器”,生成结果虽然谈不上文笔,但已经能看到原文的句式和用词痕迹。适合谁?正在做 NLP 课程设计、毕设,或者刚入门序列模型、想找一份能落地复现的 LSTM Python 源码的开发者。它不是一个理论讲解包,是一个能立刻跑起来的项目。
2. 从爬虫到语料:GetLu.py 抓取《鹿鼎记》全本的实现细节
2.1 爬虫思路:先用 requests 拿目录,再逐章抓正文
GetLu.py做的事情非常直接:先请求小说目录页,解析出每一章的链接,再逐个请求章节页,把正文提取出来。这种两步爬取是文本类站点最常见的结构,几乎不需要过度的反爬手段,只要控制好请求频率就行。
import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } # 目录页,实际项目中需要替换为目标站点地址 chapter_url = "https://example.com/ludingji/" resp = requests.get(chapter_url, headers=headers, timeout=10) soup = BeautifulSoup(resp.text, "html.parser") # 找到所有章节链接,文本和 href 一起保存 chapter_links = [(a.text.strip(), a.get("href")) for a in soup.select("div.articlelist a")] print(chapter_links[:5])这里用BeautifulSoup定位目录列表,select的选择器在项目中已经写好了,如果你换成别的站点,第一件事就是打印前几条链接确认是否抓对。User-Agent是伪造成浏览器请求的常用手段,不加的话部分站点会直接拒绝连接。timeout=10是为了防止某个章节卡死导致整个爬虫停摆。
拿到链接列表后,遍历请求每一章正文,把文本累加到内存里。需要注意,目录页的链接可能是相对路径,需要和域名拼接成完整地址,否则请求会 404。这一步在项目里已经处理过了,但如果你自己改造,最容易漏的就是这个。
2.2 数据清洗与落盘:为什么只存正文、不存 HTML 标签
章节页里除了正文,还有页头和页脚的导航链接、广告位、版权声明。直接resp.text拿下来的是带标签的 HTML 源码,不能直接当语料。需要定位正文所在的标签容器,只提取其中的纯文本。
content = soup.select_one("div#content") # 常见正文容器 if content: text = content.get_text("\n", strip=True) # 去掉乱入的空格和换行 text = text.replace("\u3000", "").replace(" ", "") all_text.append(text) # 全部抓完后一次性写入 with open("data/lu.txt", "w", encoding="utf-8") as f: f.write("".join(all_text))get_text的strip=True会去掉每行首尾的空白符,但中文小说里的全角空格\u3000和 HTML 实体 必须单独处理,否则后面生成词典时会多出很多无意义的字符。存入data/lu.txt这一步为什么重要?因为训练阶段只读这个纯文本文件,把抓取和训练解耦,以后换语料(比如换《笑傲江湖》)只要保证这个文件是 UTF-8 纯文本即可。
2.3 语料规模:全集 100 万字符,为什么只取前 5 万
项目正文里明确写了:“全集共 100 万个字符,硬件有限,只取前 5 万个字符”。这是字符级 LSTM 非常典型的一种取舍策略。100 万字符按每个字符生成一个训练样本、序列长度 40 来算,样本量接近百万,模型训练一轮的时间会非常久,如果机器没有独立显卡,跑一晚上可能都看不到 loss 下降。
只取前 5 万字符后,样本量骤降到几万个,CPU 上几分钟就能跑一轮。这种方式能快速验证“代码能不能跑通、流程对不对”,等确认无误后再扩大到全量。注意这里说的是“前 5 万”,不是随机抽 5 万,目的是保持文本的时间顺序。小说是有前后文依赖的,如果随机抽取,序列之间的上下文逻辑就断了,LSTM 学不到连贯的语义。
3. 字符级 LSTM 的原理与数据处理:为什么把小说拆成 40 字符的句子
3.1 字符级 vs 词级:中文文本生成该选哪个
处理中文文本生成,首先要选粒度。词级模型需要先分词,常见 jieba 分词虽然成熟,但分词结果会影响上游特征,而且词典动辄几万词,Embedding 矩阵大,训练更慢。字符级模型把每个汉字当成一个独立 token,中文常用字也就三五千个,词典非常小。
这个项目选择字符级是合理的,因为目标只是“学写小说”,不追求语义级别的精准,只要模型记住字与字之间的转移概率就够了。字符级模型的缺点是生成速度慢,每次只吐一个字,但好处是代码极简,不需要引入分词工具,也不存在 OOV(未登录词)问题。对于课程设计或毕设来说,字符级更可控,调参方便,效果也容易解释。
3.2 词典构建与整数映射:排序去重后的稳定编码
LSTM 不能直接吃汉字,需要先把字符转成整数索引。这里的做法是:取语料里出现过的所有字符,排序去重,再建立双向映射表。排序这一步很关键,如果用set去重后再转 list,顺序是不固定的,每次运行生成的编号不同,后续加载保存的权重时就会错位。
text = open("data/lu.txt", encoding="utf-8").read()[:50000] chars = sorted(list(set(text))) # 排序去重 print("字符总数:", len(chars)) char_to_idx = {ch: idx for idx, ch in enumerate(chars)} idx_to_char = {idx: ch for ch, idx in char_to_idx.items()} # 把文本转成整数序列 text_idx = [char_to_idx[ch] for ch in text]排序去重这个动作不是可选项,而是必选项。如果不排序,同一份文本在两个环境跑出的映射表不一样,训练好的权重文件就没法复用。char_to_idx和idx_to_char两个字典必须保持一一对应,这是后面所有数据处理的基础。打印出字符总数能帮你感知语料规模,5 万个字符通常只有两三千个不重复字符,这个量级对 LSTM 来说非常友好。
3.3 序列切分:固定长度 40 的滑动窗口与时间顺序
生成输入输出对的方式是:用前 40 个字符预测第 41 个字符,接着向后滑动一个字符,用第 2 到第 41 个字符预测第 42 个字符,以此类推。这就是常见的滑动窗口。项目正文里说的“每个句子长度取 40 个字符为一句话”就是指maxlen=40。
maxlen = 40 step = 1 X = [] y = [] for i in range(0, len(text_idx) - maxlen, step): X.append(text_idx[i : i + maxlen]) y.append(text_idx[i + maxlen]) # 转成 numpy 数组 import numpy as np X = np.array(X) y = np.array(y) print(X.shape, y.shape)为什么 step 设为 1 而不是 40?step 为 1 时样本重叠度高,能最大限度利用有限语料,5 万字符能切出近 5 万个训练样本。代价是样本之间存在大量冗余,训练时会稍慢。如果你的显存或内存吃紧,可以把步长改为 2 或 3,样本量减半,效果损失通常不明显。注意这里X的每一行都是按原文顺序排列的,训练时即便对批量样本做 shuffle,单个样本内部的时间顺序也没有被破坏,这是序列模型的基本前提。
4. Word_LSTM.py 训练全流程:从模型定义到断点续训
4.1 模型结构:Embedding + LSTM + Dense 的常见配置
项目里没有单独贴出模型结构,但根据权重文件和常规做法,这套代码用的应该是 Keras 的 Sequential 模型。字符级文本生成的标准配置是:Embedding 层把整数索引映射成稠密向量,LSTM 层学习序列依赖,Dense 层输出每个字符的概率分布。下面这个结构是我按资源文件的命名规律反推并验证过的,可以直接套用。
from keras.models import Sequential from keras.layers import Embedding, LSTM, Dense vocab_size = len(chars) # 词典大小 model = Sequential() model.add(Embedding(vocab_size, 256, input_length=maxlen)) model.add(LSTM(256, dropout=0.2, recurrent_dropout=0.2)) model.add(Dense(vocab_size, activation="softmax")) model.compile(loss="categorical_crossentropy", optimizer="adam") model.summary()Embedding的输出维度设为 256,表示每个字符用一个 256 维向量表示。LSTM的单元数也是 256,这个容量对于 5 万字符的小语料已经足够。dropout和recurrent_dropout都设 0.2,是为了防止模型死记硬背训练语料,导致生成时只会复读原文。Dense层的输出维度等于词典大小,softmax给出每个字符作为下一个字符的概率。损失函数用categorical_crossentropy,因为这是多分类问题,每个位置有 vocab_size 种可能。
4.2 训练参数:batch_size、epochs、checkpoint
训练阶段最值得注意的不是模型本身,而是断点续训机制。资源里的weights-improvement-50-2.1862.hdf5文件说明训练使用了 Keras 的ModelCheckpoint回调,每轮结束后把权重存一次,文件名里带上轮次和 loss。
from keras.callbacks import ModelCheckpoint checkpoint = ModelCheckpoint( "weights-improvement-{epoch:02d}-{loss:.4f}.hdf5", monitor="loss", verbose=1, save_best_only=False, ) model.fit( X, y_one_hot, batch_size=128, epochs=100, callbacks=[checkpoint], )ModelCheckpoint的save_best_only=False表示每一轮都保存,磁盘开销大约每份几 MB,可以接受。文件名模板中{epoch:02d}是两位数的轮次,{loss:.4f}是四位小数的 loss,这样就能从文件名直接看出训练到第几轮、损失是多少。如果你的磁盘空间紧张,可以改成save_best_only=True,但那样就看不到中间过程了,我建议先全量保存,等确认训练稳定后再只留最好的权重。
注意y需要做 one-hot 编码,Keras 的categorical_crossentropy不接受整数标签,可以用to_categorical转换。这一步容易漏,很多人的训练代码报错就是在这里。5 万字符切出的样本量接近 5 万,to_categorical后矩阵会变大不少,但还在可接受范围内。
4.3 损失曲线与权重文件:weights-improvement-50-2.1862.hdf5 说明什么
这个权重文件的名字透露了两个关键信息:模型训练到了第 50 轮,当时的 loss 是 2.1862。对于字符级小说生成来说,loss 在 2.0 左右说明模型已经学到了很多字符共现规律,但不是完美拟合。一个完全随机猜测的模型,如果词典有 3000 个字符,loss 大约在 ln(3000) ≈ 8.0。降到 2.18 意味着预测置信度已经相当高。
拿到这份权重后,你可以直接加载它去做文本生成,也可以把它作为初始权重继续训练。如果继续训练,建议把初始学习率调低,比如用Adam的默认学习率 0.001,再跑几十轮。加载权重用model.load_weights("weights-improvement-50-2.1862.hdf5"),注意必须先构建好完全相同的模型结构,否则会报维度不匹配。
5. 避坑指南:LSTM 文本生成最容易翻车的五个坑
5.1 现象:loss 不降反升
原因:最常见的是学习率太大或者数据切分时打乱了文本顺序。学习率过大会导致 loss 震荡甚至发散;打乱顺序会让模型试图学习不存在的上下文,loss 自然降不下去。
解决:先把优化器换成Adam,保持默认学习率。然后检查X的构建过程,确保range(0, len(text_idx) - maxlen, step)是从 0 开始顺序滑动的,不要在任何环节引入random.shuffle作用于原始序列。如果还是不行,把 LSTM 单元数从 256 降到 128,减少模型容量,先验证流程正确再加大。
5.2 现象:生成内容全是重复的“的”和“了”
原因:序列长度太短,模型只能记住最近几个字的搭配,高频虚词自然成为最保守的选择。生成温度太低也会让概率分布过于尖锐,模型倾向输出最高频字符。
解决:把maxlen从 40 提高到 60 或 80,给模型更多上下文。生成采样时把temperature调到 0.5 到 0.8 之间,不要用argmax直接选最大概率字符。另外检查训练语料里是否混入了大量无意义短句,如果strip=True把段内换行全删光了,模型会丢失段落结构,更容易陷入高频词循环。
5.3 现象:训练到一半内存溢出
原因:滑动窗口步长为 1 时,样本数约等于字符数。如果直接处理全量 100 万字符并做 one-hot,中间矩阵会撑爆内存。项目只取前 5 万字符,就是为了避开这个问题。
解决:不要一次性对全量语料做to_categorical,改用sparse_categorical_crossentropy损失函数,y保持整数标签,Keras 内部会做稀疏计算。或者用model.fit_generator,每次读取一批语料切一批样本,避免全量载入内存。
5.4 现象:中文乱码
原因:Windows 下默认编码是 GBK,而lu.txt是按 UTF-8 保存的。用默认方式open("data/lu.txt")读取时,Python 会用 GBK 解码,碰到某些汉字直接写入\ufffd替换符或抛异常。
解决:读取和写入都显式指定encoding="utf-8",最好还加上errors="ignore"。代码里写成open("data/lu.txt", encoding="utf-8", errors="ignore")就不会因为个别脏字符中断。保存权重文件时无所谓,因为 hdf5 内部自带编码信息,主要是文本文件要注意。
5.5 现象:模型生成速度极慢
原因:字符级模型一次只能生成一个字符,要不断把上一轮输出拼到输入里再预测,循环几千次才能生成一段文字。如果没有用 GPU,CPU 上每步都要跑一次前向计算。
解决:生成时控制长度,比如先只生成 200 字符验证效果。或者改小 LSTM 单元数到 128,生成速度会快一倍,效果损失在这类任务上不明显。如果要做长文本生成,可以考虑逐段重新用model.predict批量处理,而不是每次只预测一个字符后立刻拼接再单独预测。
6. 让生成结果有点“鹿鼎记味”:采样温度与种子文本的调参技巧
6.1 温度参数 temperature 是怎么影响生成结果的
训练结束后,模型输出的并不是直接可读的文本,而是一个概率向量。如果每次取最大概率的字符,结果会非常刻板,经常出现“说你呢你你你”这种重复。采样时引入温度参数,对概率分布做一次“锐化”或“平滑”处理,是控制随机性的关键。
def sample(preds, temperature=0.5): preds = np.asarray(preds).astype("float64") preds = np.log(preds + 1e-7) / temperature exp_preds = np.exp(preds) preds = exp_preds / np.sum(exp_preds) return np.random.choice(len(preds), p=preds)温度小于 1 时,概率分布差距被放大,模型表现得“自信”,生成内容更保守、更像原文;温度大于 1 时,分布被拉平,生成内容更跳跃,甚至出现不通顺的字词组合。对于《鹿鼎记》这种半文半白的小说,我试下来 0.5 到 0.7 最合适,既有武侠小说的语感,又不会完全复刻原文。np.log(preds + 1e-7)里的1e-7是防止log(0)的微小保护值,这属于血泪经验,不加的话遇到零概率字符会直接 NaN。
6.2 用角色名做种子文本,生成特定人物语气
加载好权重后,给模型一个起始字符串,比如“韦小宝笑道”,模型会顺着这个开头往下续写。好的做法是选取语料中真实存在的句子片段,这样初始状态更贴合训练分布。
seed = "韦小宝笑道" start_idx = [char_to_idx[ch] for ch in seed] generated = list(seed) for _ in range(200): pad = [0] * (maxlen - len(start_idx)) + start_idx[-maxlen:] x = np.array(pad).reshape(1, maxlen) preds = model.predict(x, verbose=0)[0] next_idx = sample(preds, temperature=0.6) next_char = idx_to_char[next_idx] generated.append(next_char) start_idx.append(next_idx) print("".join(generated))这里把种子字符串转成索引,不足maxlen的部分用 0 填充,超过的部分只取最后maxlen个字符。填充值 0 不一定对应真正的前置字符,但 LSTM 对序列开头天然不敏感,影响不大。如果你想要更贴近原文的起始状态,可以每次都从随机位置截取一段原文作为种子,效果更稳定。
6.3 验证生成质量的三个非客观指标
除了肉眼读一遍,我习惯从三个角度量化生成结果。第一,字符覆盖率:生成文本里不同字符数占词典的比例,太低说明模型退化成了高频词复读机;第二,局部重复率:统计连续出现三次以上的标点或虚词占比;第三,上下文召回:拿生成文本里一段连原文都没有的句子,看它是否符合中文主谓结构。这三个指标都不需要额外工具,写几行 Python 就能算,能帮你快速判断温度参数是否调过头。
从那以后,我每次跑 LSTM 文本生成项目,都会强制走一遍这三步:确认数据是顺序切分的、检查文件编码是 UTF-8、最后用 0.6 左右的温度采样看一遍输出效果。这三关过完,基本就不会翻车了。希望帮到你。
本文还有配套的精品资源,点击获取