ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于LSTM的字符级文本生成实战:用Python训练《鹿鼎记》续写模型

基于LSTM的字符级文本生成实战:用Python训练《鹿鼎记》续写模型 简介面向自然语言处理初学者与深度学习相关专业学生一套基于金庸小说《鹿鼎记》语料的字符级LSTM文本生成项目完整覆盖数据爬取、清洗、排序去重、词典整数映射、定长切分和模型训练全流程适合作为课程设计、毕业设计或入门文本生成的落地参考。压缩包共5个文件以Python源码数据爬取与LSTM训练脚本、Markdown说明文档、TXT语料数据和HDF5模型权重为主整体大小18.78MB结构简洁便于快速上手。已有192人学习下载。除代码和文档外还附有训练好的模型权重可直接加载继续训练或随机生成文本爬虫脚本保留从金庸网抓取《鹿鼎记》每一章的完整实现便于理解数据来源与预处理细节。项目代码经测试运行成功支持在基础较好的情况下二次修改扩展作者也提供私聊远程教学可解决运行环境与参数调优问题。1. 用LSTM学写《鹿鼎记》小说这份Python数据集与源码到底能做什么字符级LSTM写小说听起来像是深度学习里的玄学——但这份基于金庸《鹿鼎记》全本约100万字符、实际取前5万字符训练的数据集与Python源码确实跑通了一条爬取语料→清洗映射→训练LSTM→生成续写文本的完整链路。文件包里包含爬虫脚本GetLu.py、训练脚本Word_LSTM.py、已训练50轮的权重文件weights-improvement-50-2.1862.hdf5和原始文本data/lu.txt。你要是想入门NLP中的文本生成方向或者做课程设计、毕设里的AI续写小说演示模块这套代码就是现成的作业底子。它不是那种玩具级demo而是从金庸网实际抓数据训练出来的——每40个字符切一句话做训练样本语料顺序严格保持原文的时间序列最终目标是用模型在字符级别上学会鹿鼎记的用词口气。适合计算机相关专业的学生、刚接触LSTM的开发者也适合想快速找个能跑通的文本生成项目来改一改的人。2. 数据从哪儿来GetLu.py爬虫与lu.txt的预处理链路2.1 爬金庸网目录requests抓章节列表再逐章下载先看数据采集这一层。GetLu.py的核心逻辑是先用requests请求金庸网的目录页解析出《鹿鼎记》每一章的链接再逐章请求正文并写入data/lu.txt。整体流程很直接没有用Scrapy单文件脚本搞定。import requests from bs4 import BeautifulSoup base_url https://www.jinyongwang.com/lu/ def get_chapter_urls(): resp requests.get(base_url, headers{User-Agent: Mozilla/5.0}) resp.encoding gbk # 老站大多是GBK编码 soup BeautifulSoup(resp.text, html.parser) urls [] for a in soup.select(ul.list li a): # 目录页每章的链接 href a.get(href) if href and href.endswith(.html): urls.append(base_url href) return urls def crawl_chapters(urls): for i, url in enumerate(urls): r requests.get(url, headers{User-Agent: Mozilla/5.0}) r.encoding gbk soup BeautifulSoup(r.text, html.parser) content soup.select_one(div.content).get_text().strip() with open(data/lu.txt, a, encodingutf-8) as f: f.write(content \n\n) print(f第{i1}章完成, 累计字符: {len(content)})这段代码有个关键点resp.encoding gbk。金庸网这类老站点字符集是GBK不是UTF-8不强制指定编码的话解析正文会出一堆乱码后续训练等于喂了脏数据。ul.list li a这个CSS选择器是目录页每章链接的容器不同的站点结构不一样如果换站爬就得改成对应的选择器。headers里带User-Agent是为了避免被站点拒绝很多老站在无UA时会返回403。写文件用的UTF-8编码与后续训练脚本保持一致else的话会出现编码错乱读不回来。拿到全部章节后data/lu.txt被拼接成了一个大文件。这里值得注意项目正文说全集约100万个字符但训练时只取前5万个。原因很实际——字符级LSTM的训练复杂度跟序列长度强相关100万字符用CPU跑一轮要几十分钟甚至几个小时5万字符只需要一两分钟一轮迭代快、调参方便。等模型结构验证没问题了再拉满全量数据做最终训练。2.2 取前5万字符为什么截断而不是重新采样训练的第一步不是直接进模型而是先做字符级别的前处理。项目里有一个关键操作只取lu.txt的前50000个字符。这背后其实是为了保证语料是连续文本而不是抽取片段——LSTM在处理字符序列时依赖上下文只有保留原始顺序模型才能学到韦小宝说话的前后逻辑。with open(data/lu.txt, r, encodingutf-8) as f: text f.read()[:50000] # 硬件有限,只取前5万个字符 # 统计所有出现过的字符 chars sorted(set(text)) char_to_idx {ch: i for i, ch in enumerate(chars)} idx_to_char {i: ch for ch, i in char_to_idx.items()} vocab_size len(chars) print(f语料长度: {len(text)}, 词典大小: {vocab_size})set(text)得到全文中所有不重复字符排序后Dictionary就建好了。char_to_idx负责把字符转成整数下标idx_to_char负责把预测结果转回文字。这里是全量字符映射词表通常是几百个汉字加标点比如鹿鼎记前5万字符大概会包含3000多个不同的字这个量级对LSTM的Embedding层来说非常轻量。很多新手在文本生成里犯的错是用tokenizer.fit_on_texts先分词再转序列但中文小说用全字符切分就行——词表大了Embedding矩阵内存暴涨训练变慢而且分词误差会一路传到生成阶段。字符级映射的好处是OOV问题不存在了任何字只要在词典里都有对应id生成时不管多生僻的字模型都能按概率采样出来。2.3 按40个字符切句构造输入序列与时间顺序标签映射表建好后就是切句子了。每个样本是40个字符的输入序列第41个字符作为标签。41个字符滑窗扫过全文生成训练数据。这里保持了原文的时间顺序——维度上就是文本从前往后滑动每一行的输入x是前40个字输出y是第41个字。import numpy as np seq_length 40 step 1 # 步长为1,窗口滑动构造样本 sentences [] next_chars [] for i in range(0, len(text) - seq_length, step): sentences.append(text[i:i seq_length]) next_chars.append(text[i seq_length]) n_samples len(sentences) print(f样本总量: {n_samples}) # 向量化: (样本数, 序列长度) - one-hot (样本数, 序列长度, 词典大小) X np.zeros((n_samples, seq_length, vocab_size), dtypenp.bool_) y np.zeros((n_samples, vocab_size), dtypenp.bool_) for i, sentence in enumerate(sentences): for t, char in enumerate(sentence): X[i, t, char_to_idx[char]] 1 y[i, char_to_idx[next_chars[i]]] 1 X X.reshape(n_samples, seq_length, vocab_size)步长为1意味着从第0个字符滑到第40个字符得到一个样本再从第1个字符滑到第41个得到下一个样本。5万字符减去40大约能出49960个样本。one-hot编码后每个字符变成一个vocab_size维向量比如词表大小是3000那X的shape就是(49960, 40, 3000)这个用np.bool_存内存还扛得住但要换float64直接爆内存。数据维度的设计直接对应Keras里LSTM层的输入要求(batch_size, timesteps, input_dim)。timesteps是40正好对应每个样本40个字符的序列长度input_dim是词表大小。如果不想用one-hot也可以用Embedding层直接吃整数索引但那要换输入格式。这套代码用的是one-hot 稠密LSTM简单直接对新手友好。3. Word_LSTM.py模型结构Embedding、LSTM层与损失函数的选型3.1 用Keras Sequential搭模型LSTM单元数与Dropout的设定整个训练脚本的核心就是Word_LSTM.py。模型结构非常清楚一个Embedding层接一个LSTM层再接Dropout和Dense输出层。用Keras的Sequential堆起来。from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout, Embedding model Sequential() model.add(Embedding(vocab_size, 128, input_lengthseq_length)) # 嵌入维度128 # 需要把输入从 (batch, 40) reshape 成 (batch, 40, embed_dim) 才能进LSTM # Embedding层输出自动是 (batch, 40, 128), LSTM默认返回最后时间步 model.add(LSTM(256, return_sequencesFalse)) model.add(Dropout(0.2)) # 缓解过拟合, 训练时随机丢弃20%神经元 model.add(Dense(vocab_size, activationsoftmax)) model.compile( losscategorical_crossentropy, optimizeradam, metrics[accuracy] )模型里Embedding维度128LSTM单元数256Dropout比率0.2。Embedding层把每个字符的one-hot向量映射到128维稠密向量这比直接用one-hot喂LSTM省参数量。LSTM的return_sequencesFalse表示只返回最后一个时间步的输出——因为我们的任务是预测第41个字符只需要最后一个时间步的隐状态就够了。Dense层的神经元个数等于词表大小softmax把输出变成每个字符出现的概率分布。损失函数用的categorical_crossentropy配合one-hot的y标签。如果用整数标签应该改成sparse_categorical_crossentropy。这里既然y是按(n_samples, vocab_size)的one-hot形式构造的就得用categorical版本。注意这俩用错了模型照样能train但loss和acc的含义会偏新手容易在这翻车。优化器选adam是常规操作文本生成这类稀疏梯度场景adam比SGD收敛稳。3.2 batch_size与epochs选择50轮权重从loss 2.1862看训练变化训练阶段的参数直接决定了权重文件的质量。项目给出的已训练权重是第50轮的checkpointloss已经降到2.1862。这个数字放在字符级语言模型里是什么水平字符级预测的交叉熵在2.0左右已经算不错——因为每次是从3000多个字符里挑1个随机猜的loss是log(3000)≈8.0从8.0降到2.18说明模型已经从纯随机进化到能预测出通顺的中文了。from tensorflow.keras.callbacks import ModelCheckpoint checkpoint ModelCheckpoint( weights-improvement-{epoch:02d}-{loss:.4f}.hdf5, monitorloss, verbose1, save_best_onlyFalse, # 每轮都保存, 便于观察变化 modemin ) batch_size 128 epochs 50 history model.fit( X, y, batch_sizebatch_size, epochsepochs, callbacks[checkpoint] )batch_size选128跑50轮。5万字符语料生成大约49960个样本除以128等于每轮390个batch左右。CPU训练下约百秒一轮GPU几十秒就搞定。这里save_best_onlyFalse意味着50轮会有50个文件从最早loss八九个、到中间四五个、最后两个多都有方便对比每轮生成质量。磁盘空间不足的话改成save_best_onlyTrue只留loss最低的那份。一个细节ModelCheckpoint用monitorloss而不是val_loss因为这里没有划分验证集——数据量本来就不大随机划分验证集会打乱句子的时间顺序得不偿失。判断模型好坏直接看训练loss下降趋势。如果loss震荡不降优先调低学习率或者把Embedding维度从128减到64。3.3 预测时的温度采样从概率分布到可读文本的最后一公里训练完模型的输出是每个字符的概率分布接一个argmax就是贪心搜索——每次取概率最大的字符。但这样生成出来的文本会陷入重复因为一旦某个字的概率稍高后面所有输出都偏向那条路径这是很多新手做文本生成AI陷入死循环的根源。项目里实际可用的做法是采样加一个温度参数控制随机性。def sample(preds, temperature0.5): preds np.asarray(preds).astype(float64) preds np.log(preds) / temperature exp_preds np.exp(preds) preds exp_preds / np.sum(exp_preds) probs np.random.multinomial(1, preds, 1) return np.argmax(probs)温度低比如0.2时概率分布变尖锐基本等价于argmax生成文本保守但前后一致温度高比如1.2时分布变平缓随机性大能出现新奇的词但容易语句不通。项目训练出的loss在2.18附近用0.4到0.6之间的温度生成效果最好——既有原文那种小说的语气又不会重复套话。我自己写生成脚本时会加上np.random.seed(42)固定随机种子保证每次抽样结果可复现。4. 从字符到小说训练执行、权重加载与生成流程的完整串联4.1 逐行拆解训练命令从启动脚本到checkpoint落盘跑起训练前先确认目录结构和依赖。项目解压后核心就5个文件ML_LSTM-master文件夹代码根目录、weights-improvement-50-2.1862.hdf5预训练权重、GetLu.py爬虫、Word_LSTM.py训练、data/lu.txt语料。如果不想自己爬数据直接坐上data/lu.txt和Word_LSTM.py就够了。pip install tensorflow requests beautifulsoup4 numpy python GetLu.py # 步骤一: 抓取鹿鼎记全文生成data/lu.txt python Word_LSTM.py # 步骤二: 训练模型并保存hdf5权重Word_LSTM.py跑完当前目录会多出一批weights-improvement-*.hdf5文件每个对应一轮训练的结果。50轮后44MB左右的权重文件就是最终的模型。用预训练权重测试生成时需要注意hdf5里的模型结构必须与当前代码一致——层数一样、维度一样否则load_weights会报维度不匹配的错误。4.2 加载hdf5权重重新构建模型再load_weights很多人拿到权重文件就以为直接model.load_weights能跑但前提是模型的层结构和训练时完全一致。稳妥做法是把训练脚本里模型定义的那段复制过来先compile再加载权重。from tensorflow.keras.models import load_model # 方式一: 整个模型都在hdf5里(如果训练时用model.save保存) # model load_model(weights-improvement-50-2.1862.hdf5) # 方式二: 只加载权重(如果hdf5是由ModelCheckpoint产生) model Sequential() model.add(Embedding(vocab_size, 128, input_lengthseq_length)) model.add(LSTM(256, return_sequencesFalse)) model.add(Dropout(0.2)) model.add(Dense(vocab_size, activationsoftmax)) model.compile(losscategorical_crossentropy, optimizeradam) model.load_weights(weights-improvement-50-2.1862.hdf5)ModelCheckpoint保存的hdf5默认只存权重不含网络结构。加载时你必须先把一模一样的模型架构重新写一遍再挂载权重。如果保存时用的是save_best_onlyTrue且文件里同时包含优化器状态还需要先compile再load。这段代码就是保底写法——不管hdf5里有没有结构先手动定义结构就绝对不出错。4.3 生成首段文本给定40字种子续写鹿鼎记权重加载成功后生成阶段需要一个长度刚好40字符的种子文本。可以从lu.txt里任意截一段原文也可以手打40个字。模型把种子字符逐字编码成下标序列reshape成(1, 40)喂进去拿输出的概率分布采样出一个字符再把种子往后移一格丢掉首字符、拼上预测字符重复这个过程。import random seed_text 韦小宝回到扬州, 来到丽春院门口, 想起当年之事, 心中一阵感慨。[:40] for i in range(100): # 续写100个字符 encoded [char_to_idx[ch] for ch in seed_text[-40:]] encoded np.array(encoded).reshape(1, 40) preds model.predict(encoded, verbose0)[0] next_idx sample(preds, temperature0.5) next_char idx_to_char[next_idx] seed_text seed_text next_char print(seed_text)100个字符大约两三个短句cpu预测很快几十毫秒一个字符。注意每次预测只能生成一个字符然后要把这个字符拼到种子里重新取最后40字再预测——不能一次性输出整段因为每生成一个新字符下一轮预测的上下文就变了。这个循环是字符级文本生成的标准姿势。5. 避坑指南文本生成项目的五处常见翻车点5.1 Loss高得像没学one-hot矩阵把内存撑爆了现象代码在构造X和y时直接报MemoryError或者训练时loss一直大于7不下降。原因X如果用float64存5万字符、词表3000、40序列长度算下来是49960×40×3000×8字节约48GB内存普通电脑根本扛不住。另一个原因是学习率太大导致loss震荡不收敛。解决X和y都用dtypenp.bool_或np.uint8内存直接降到1/8。训练时把batch_size从128往下降或者改用Embedding层直接吃整数索引X的存储量从(样本数, 40, 词表)缩到(样本数, 40)。如果loss不降把adam的学习率从默认0.001改成0.0001试一轮。5.2 生成的全是的了么啊温度参数设太低现象生成文本单看每个字都正常但连起来全是虚词的了么啊吧循环没有任何实际内容。原因温度低于0.3时概率分布被压得极尖锐模型每次都选最高概率字符。在中文语境里虚词出现频率天然最高LSTM学到的其实就是这些高频字的概率优势。argmax下就暴露了——所有句子变成虚词排列组合。解决把温度调到0.5到0.9之间。温度高一些能让低频实词有机会被采样到。如果调高温度后语句混乱说明模型没训练到位回去加epochs或者扩大语料。5.3 爬虫拿到空目录金庸网的列表结构变了现象GetLu.py运行完data/lu.txt是空的或者只有零星几章。打印目录页内容发现urls列表为空。原因老站改版后目录的CSS选择器变化。站点负责人换了页面模板原来ul.list li a匹配不到任何链接。另一个可能是网站做了UA校验requests没带浏览器headers被拒。解决先打印响应文本确认有没有拿到内容再检查实际HTML用Chrome开发者工具复制新的选择器。如果是不带headers被拒给requests的headers参数加上User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)。换选择器后多跑几轮确认每章正文真的写进lu.txt再继续。5.4 加载权重报错维度对不上或结构不匹配现象model.load_weights报错提示shapes (128, 256) and (300, 256) are incompatible或者直接说unable to open HDF5 file。原因训练时词表里可能比当前代码多几十个字符导致Embedding矩阵维度不一样hdf5文件是ModelCheckpoint产出但代码改了层结构文件被移动后路径找不到。解决打开hdf5文件看权重维度h5py.File(weights-improvement-50-2.1862.hdf5)打印keys。确认embedding/embeddings:0的shape是(batch, 128)。如果词表不一致只能重新训练或者把lu.txt剪成与训练时完全一致的字符集。检查文件路径大小写Linux下weights-improvement-50-2.1862.hdf5和Weights...是两个文件。5.5 前5万字符全是目录和说明数据质量直接影响模型水平现象训练loss降到2.5就不动了生成的句子前言不搭后语像是语料被切碎过。原因lu.txt的前5万个字符里混着网站的导航栏文字、章节目录、乱码等噪声。金庸网有些页面正文区域会带上作者介绍或者站点版权信息爬虫把那些也写进去了。截断取前5万字时恰好把这些噪声学进去了。解决打开lu.txt肉眼检查前2000个字符拖到末尾看看边界。如果开头有噪声把截断位置往后挪几千字——从正文第一章开始截。更彻底的是在爬虫里就做正文清洗只保留正文div里的p标签内容剔除上一章下一章等导航词。6. 让生成的文本更像鹿鼎记温度、种子文本与采样策略的调优训练loss在2.18的模型生成质量的上限取决于推理策略。我在实测里发现把温度从0.5改成0.7时韦小宝的台词里会冒出奶奶的他妈的这种原文口语化的脏字语料里这些词频率不低但训练时被平均掉了高温抽样给了它们重新出现的概率。反过来温度0.3时生成文本稳定但平庸读两行就头大。所以调生成质量第一个动作是调温度而不是回去加训模型。除了温度种子文本的长度也值得玩味。固定40字时种子字符必须是完整句子不能从半句话中间截——否则前几个生成字符会顺着残缺语法跑偏。我习惯把种子设成60字喂给模型时还是只取最后40字但截断点会落在上一个句号后面。这样模型看到的上下文语义完整生成起点是干净的。生成长度每满500字就重新从原文随机取种子可以避免长文本跑飞后在某个虚词循环里出不来。采样策略上有个小技巧可以提升多样性在sample函数里加一个重复惩罚对已经出现的字符降低概率。实现是采样前扫一遍最近20个生成字符把它们的logits统一减个常数比如1.5再进softmax——效果是模型不太会连续两三个字重复生成节奏更像人写的。这个技巧不改变训练权重完全在生成阶段实现算是纯血的经验调参。字符输出后要留意一个细节生成的文本里可能混入换行符和空格因为训练语料lu.txt里章节之间本来就有\n\n。打印结果时先用replace(\n, )清掉不然50个字符的续写输出里空行占了一半看着像故障。清理后再按每行30个字符手动断句展示观感好很多。我从这个项目里学到的最大一课是文本生成的瓶颈往往不在模型深度而在语料质量和采样策略。同样的权重文件有人生成得像模像样有人生成的狗屁不通差异全在种子文本怎么截、温度怎么调、重复惩罚加不加。从那以后我每次跑文本生成任务都强制把温度扫描做一遍——从0.3到0.9每个档位生成20条样本人工对比省下大量调模型的冤枉时间。这份代码跑通了爬虫到生成的完整链路坑也在上面标清楚了希望帮到你。本文还有配套的精品资源点击获取
返回列表