ARTICLE DETAIL

资讯详情

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

从零复现LSTM写诗项目:期末大作业实战指南

从零复现LSTM写诗项目:期末大作业实战指南 简介这份资源是一套基于LSTM的中文诗歌生成Python项目源码面向计算机、人工智能等相关专业的在校学生及自学者可用于期末大作业、课程设计、毕业设计或项目立项演示。项目在原开源方案基础上做了优化与bug修复重点针对中文诗歌生成附带训练数据与说明文档适合具备一定Python基础、希望理解序列生成模型实践的读者。压缩包共10个文件包含4个py源码文件模型定义、训练与采样脚本、5个txt数据文件中文诗歌及多语种语料和1个md说明文档整体约4.76MB结构清晰便于按模块阅读与调试。目前已有155人学习。读者可据此掌握LSTM文本生成的数据预处理、模型搭建、训练与采样全流程并能在源码基础上修改以扩展其他生成任务同时获得一份可直接运行、经过测试的完整项目参考。1. 从零复现一个 LSTM 写诗项目期末大作业到底该交什么每年期末总有一批人卡在「基于 LSTM 生成诗歌」这个大作业上。题目看着简单——给模型喂几万首古诗让它学会写诗——但真正动手时问题全冒出来了数据从哪来、中文怎么编码、模型写几层、训练多久、生成的句子为什么全是「明月」「清风」来回倒腾。我带过几届学生的课程设计也自己用 LSTM 做过文本生成踩过的坑比写过的代码还多。这篇笔记不讲空泛的深度学习概论只讲一件事如果你手上只有一份「LSTM 生成诗歌」的题目要求怎么从环境搭建、数据准备、模型定义、训练调参一路做到能生成像样的五言绝句并且把文档说明写清楚。适合正在做期末大作业的学生也适合想用 LSTM 练手文本生成的 Python 入门者。全程用 Python PyTorch代码可以直接抄参数我会逐个解释为什么这么设。2. 数据准备古诗语料怎么清洗成 LSTM 能吃的格式2.1 语料来源与格式选择做诗歌生成第一步不是写模型是搞数据。常见做法是找一份公开的古诗数据集通常是 JSON 或 TXT 格式每首诗包含标题、作者、正文。我一般会先把所有诗统一成「纯正文 换行分隔」的格式因为 LSTM 学的是字符序列标题和作者名会引入噪声。假设你拿到的原始数据是 JSON 数组每条形如{title: 静夜思, author: 李白, paragraphs: [床前明月光疑是地上霜。, 举头望明月低头思故乡。]}。清洗目标是得到一个纯文本文件每行一首诗只保留正文去掉标点里的书名号、括号等非诗句符号。import json import re def clean_poems(raw_path, out_path): with open(raw_path, r, encodingutf-8) as f: data json.load(f) cleaned [] for item in data: # 只取正文段落跳过标题和作者 paragraphs item.get(paragraphs, []) if not paragraphs: continue # 合并段落去掉空白 text .join(paragraphs).strip() # 只保留中文、逗号、句号、问号、感叹号 text re.sub(r[^\u4e00-\u9fa5。], , text) # 过滤太短或太长的诗 if 20 len(text) 120: cleaned.append(text) with open(out_path, w, encodingutf-8) as f: for line in cleaned: f.write(line \n) print(f清洗完成共 {len(cleaned)} 首诗) clean_poems(poems_raw.json, poems_clean.txt)这段代码的逻辑很直接读 JSON、拼段落、正则过滤非中文字符、按长度筛掉异常样本。参数上长度下限 20 是为了排除残句上限 120 是避免超长排律把序列撑爆。实际跑下来五万多首原始数据通常能留下三万到四万首足够训练一个字符级 LSTM。提示如果你的数据源是 TXT 且每行已经是一首诗跳过 JSON 解析部分直接做正则清洗即可。2.2 构建字符字典与序列样本LSTM 不能直接吃汉字得先建一个「字符 → 整数 ID」的映射表。这里有个关键决策是按字建模还是按词建模。按词需要分词工具引入额外依赖而且古诗里很多词是临时组合的分词反而破坏韵律。我一般选按字建模字典小、实现简单、生成时也不会出现分词边界错误。from collections import Counter import numpy as np def build_vocab(text_path): with open(text_path, r, encodingutf-8) as f: lines [line.strip() for line in f if line.strip()] # 统计所有字符频率 all_chars .join(lines) counter Counter(all_chars) # 按频率排序保留出现次数 2 的字符 chars [c for c, n in counter.most_common() if n 2] # 特殊标记pad 填充unk 未知字符 chars [pad, unk] chars char2idx {c: i for i, c in enumerate(chars)} idx2char {i: c for c, i in char2idx.items()} print(f字典大小: {len(chars)}) return lines, char2idx, idx2char def make_sequences(lines, char2idx, seq_len48): X, Y [], [] for line in lines: # 转成 ID 序列 ids [char2idx.get(c, char2idx[unk]) for c in line] # 滑动窗口切分 for i in range(0, len(ids) - seq_len): X.append(ids[i:iseq_len]) Y.append(ids[i1:iseq_len1]) return np.array(X), np.array(Y) lines, char2idx, idx2char build_vocab(poems_clean.txt) X, Y make_sequences(lines, char2idx, seq_len48) print(f样本数: {X.shape[0]}, 序列长度: {X.shape[1]})这里有几个参数值得说清楚。seq_len48是我反复试出来的太短比如 16模型学不到完整句式太长比如 128训练慢且容易过拟合48 大约能覆盖两到三句五言诗对期末作业的计算资源比较友好。字典过滤掉出现次数少于 2 的字符是为了减少unk的比例否则生僻字太多会影响生成质量。滑动窗口的步长我设的是 1也就是每个位置都切一个样本这样数据利用率最高代价是样本量大、训练 epoch 时间变长。注意X和Y是错开一位的Y是X的下一个字符。这是语言模型的标准做法——给定前 48 个字预测第 49 个。3. LSTM 模型定义几层、多少隐藏单元、要不要 dropout3.1 模型结构设计与选型理由字符级诗歌生成模型不需要太深。我见过有人堆三四层 LSTM结果训练半天不收敛生成的全是乱码。常见做法是一层 Embedding 一到两层 LSTM 一层全连接。Embedding 把字符 ID 映射成稠密向量LSTM 捕捉序列依赖全连接把隐藏状态映射回字典大小的 logits。为什么选 LSTM 而不是普通 RNN因为古诗的韵律依赖跨度比较大比如「床前明月光」和「疑是地上霜」之间的押韵关系普通 RNN 的梯度消失问题会让它记不住。LSTM 的门控机制能缓解这个问题。至于要不要用 GRU其实效果差不多但 LSTM 更经典文档和教程多期末作业写报告时好引用。import torch import torch.nn as nn class PoemLSTM(nn.Module): def __init__(self, vocab_size, embed_dim256, hidden_dim512, num_layers2, dropout0.3): super(PoemLSTM, self).__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( input_sizeembed_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0 ) self.fc nn.Linear(hidden_dim, vocab_size) self.dropout nn.Dropout(dropout) def forward(self, x, hiddenNone): # x: (batch, seq_len) emb self.embedding(x) # (batch, seq_len, embed_dim) out, hidden self.lstm(emb, hidden) # out: (batch, seq_len, hidden_dim) out self.dropout(out) logits self.fc(out) # (batch, seq_len, vocab_size) return logits, hidden参数逐个解释。embed_dim256是字符嵌入维度字典大概六千到八千字256 维足够表达字符间的语义关系再大容易过拟合。hidden_dim512是 LSTM 隐藏状态维度这个值直接决定模型容量512 在单卡上跑得动生成质量也够看。num_layers2是两层 LSTM 堆叠第一层学局部搭配第二层学跨句韵律。dropout0.3只在两层之间生效防止过拟合。注意padding_idx0对应pad让填充符不参与梯度计算。提示如果你显卡显存小于 6GB把hidden_dim降到 256batch_size降到 64否则训练时容易 OOM。3.2 训练循环与损失函数训练部分的核心是交叉熵损失。因为每个时间步都在预测下一个字符所以要把输出展平成(batch * seq_len, vocab_size)标签展平成(batch * seq_len,)。优化器我选 Adam学习率 1e-3这是文本生成的常用起点。from torch.utils.data import DataLoader, TensorDataset def train_model(X, Y, char2idx, epochs30, batch_size128, lr1e-3): device torch.device(cuda if torch.cuda.is_available() else cpu) vocab_size len(char2idx) dataset TensorDataset(torch.LongTensor(X), torch.LongTensor(Y)) loader DataLoader(dataset, batch_sizebatch_size, shuffleTrue) model PoemLSTM(vocab_size).to(device) criterion nn.CrossEntropyLoss(ignore_index0) # 忽略 pad optimizer torch.optim.Adam(model.parameters(), lrlr) for epoch in range(epochs): model.train() total_loss 0 for bx, by in loader: bx, by bx.to(device), by.to(device) optimizer.zero_grad() logits, _ model(bx) # 展平 loss criterion(logits.reshape(-1, vocab_size), by.reshape(-1)) loss.backward() # 梯度裁剪防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() avg_loss total_loss / len(loader) print(fEpoch {epoch1}/{epochs}, Loss: {avg_loss:.4f}) torch.save(model.state_dict(), poem_lstm.pth) return model model train_model(X, Y, char2idx, epochs30)ignore_index0让pad不产生损失因为填充位置本来就没有预测意义。梯度裁剪max_norm5.0是 LSTM 训练的标配不加的话偶尔会遇到 loss 突然变 NaN这就是典型的梯度爆炸。batch_size128在 8GB 显存上比较稳如果显存不够就减半。30 个 epoch 是我在四万首诗上的经验值通常到 20 轮左右 loss 就降到 2.0 以下生成的诗已经能看出五言结构了。注意训练时如果 loss 降到 1.5 以下还在降别高兴太早可能是过拟合了。验证方法是留出 500 首诗不参与训练每隔几个 epoch 生成一次看看效果。4. 生成与调参温度、重复惩罚和断句怎么控制4.1 文本生成的核心参数模型训练完生成阶段才是真正考验调参的地方。最基础的生成方式是贪心搜索——每个位置选概率最大的字符。但这样生成的诗会非常死板经常陷入「明月明月明月」的循环。常见做法是引入温度参数temperature和重复惩罚。温度的作用是调整概率分布的平滑程度。温度趋近 0模型只选最高概率的字符输出确定但单调温度升高低概率字符也有机会被选中输出更多样但可能不通顺。我一般把温度设在 0.7 到 1.0 之间0.8 是个不错的平衡点。import torch.nn.functional as F def generate_poem(model, char2idx, idx2char, start_text明月, max_len48, temperature0.8, devicecpu): model.eval() chars [c for c in start_text] input_ids torch.LongTensor([[char2idx.get(c, char2idx[unk]) for c in chars]]).to(device) hidden None with torch.no_grad(): for _ in range(max_len): logits, hidden model(input_ids[:, -1:], hidden) logits logits[:, -1, :] / temperature # 重复惩罚降低已出现字符的概率 for c in set(chars): if c in char2idx: logits[0, char2idx[c]] / 1.2 probs F.softmax(logits, dim-1) next_id torch.multinomial(probs, num_samples1).item() next_char idx2char[next_id] if next_char pad: break chars.append(next_char) input_ids torch.cat([input_ids, torch.LongTensor([[next_id]]).to(device)], dim1) return .join(chars) poem generate_poem(model, char2idx, idx2char, start_text明月, temperature0.8) print(poem)这段代码里温度除法在 softmax 之前做temperature0.8意味着稍微锐化分布。重复惩罚是我自己加的把已经出现过的字符 logit 除以 1.2降低它再次被选中的概率能有效缓解循环问题。torch.multinomial按概率采样而不是直接取 argmax这是保证多样性的关键。max_len48对应训练时的序列长度生成到这个长度就停。提示如果生成结果还是重复把重复惩罚的除数从 1.2 调到 1.5或者把温度提到 1.0。但温度太高会出现生造词需要自己权衡。4.2 断句与格式后处理LSTM 生成的是连续字符流但诗是要断句的。五言诗每句五个字逗号句号交替。我一般会在生成后做一次简单的格式整理按标点切分每五个字加逗号两句一组加句号。如果模型自己生成了标点就以模型生成的为准只做微调。def format_poem(text): # 去掉起始的提示词重复 text text.replace(unk, ).replace(pad, ) # 如果模型没生成标点按五言切分 if not in text and 。 not in text: chars list(text) lines [] for i in range(0, len(chars), 5): line .join(chars[i:i5]) if len(line) 5: lines.append(line) # 两句一组 result for i, line in enumerate(lines): result line if i % 2 0: result else: result 。 return result return text print(format_poem(poem))这个后处理函数处理两种情况模型自己生成了标点就直接用没生成标点就按五言切分并补上逗号句号。实际跑下来训练充分的模型通常能自己学会在第五个字后生成逗号但偶尔会漏后处理能兜底。注意replace(unk, )是去掉未知字符避免生成结果里出现奇怪符号。5. 避坑与排查训练不收敛、生成重复、显存爆炸的 5 个血泪教训5.1 现象loss 一直不降卡在 5.0 以上原因通常是学习率太大或者数据没对齐。我遇到过学习率设成 0.01loss 直接震荡不降。解决方法是把学习率降到 1e-3 甚至 5e-4同时检查X和Y是不是错开一位。另一个常见原因是字典里unk太多模型学不到有效信息回去检查清洗步骤把出现次数阈值从 2 降到 1 试试。5.2 现象生成的诗全是「明月」「清风」循环这是重复惩罚没加或者温度太低。LSTM 在字符级生成时很容易陷入局部循环因为「明月」后面接「明月」在训练数据里可能确实出现过。解决办法是加重复惩罚把已出现字符的 logit 除以 1.2 到 1.5同时把温度提到 0.8 以上。如果还不行检查训练数据里是不是某几个词频率过高适当下采样。5.3 现象训练到一半 loss 变成 NaN梯度爆炸的典型表现。LSTM 的梯度在长序列上容易累积不加裁剪就会爆。解决方法是加torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)这行代码几乎是我所有 LSTM 项目的标配。另外检查输入里有没有异常长的序列seq_len别超过 128。5.4 现象显存不够batch_size 降到 16 还 OOM模型太大或者序列太长。hidden_dim512、num_layers2、seq_len48、batch_size128这套配置大概占 4GB 显存。如果卡小优先降hidden_dim到 256再降batch_size到 64。别一上来就降seq_len序列太短模型学不到完整句式生成质量会明显下降。5.5 现象生成的诗读起来像乱码没有五言结构训练轮数不够或者数据量太少。字符级 LSTM 要学会五言节奏至少需要两万首诗和 20 个 epoch。如果数据只有几千首模型记不住句式。解决办法是扩充数据或者把seq_len降到 24让模型专注学短句。另外检查字典里标点符号有没有保留逗号句号对学断句很重要清洗时别把它们过滤掉。6. 进阶技巧用验证集早停和权重衰减把生成质量再提一档训练到后面你会发现 loss 还在降但生成质量反而变差了——这就是过拟合。模型开始死记硬背训练样本而不是学规律。我一般会从训练数据里切出 5% 做验证集每个 epoch 结束后在验证集上算 loss如果连续 3 个 epoch 验证 loss 不降就停止训练。这叫早停能省不少时间也能防止过拟合。def train_with_early_stop(X_train, Y_train, X_val, Y_val, char2idx, patience3, epochs50): device torch.device(cuda if torch.cuda.is_available() else cpu) vocab_size len(char2idx) train_loader DataLoader(TensorDataset(torch.LongTensor(X_train), torch.LongTensor(Y_train)), batch_size128, shuffleTrue) val_loader DataLoader(TensorDataset(torch.LongTensor(X_val), torch.LongTensor(Y_val)), batch_size128, shuffleFalse) model PoemLSTM(vocab_size).to(device) criterion nn.CrossEntropyLoss(ignore_index0) # 权重衰减L2 正则 optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-5) best_val_loss float(inf) wait 0 for epoch in range(epochs): model.train() for bx, by in train_loader: bx, by bx.to(device), by.to(device) optimizer.zero_grad() logits, _ model(bx) loss criterion(logits.reshape(-1, vocab_size), by.reshape(-1)) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() # 验证 model.eval() val_loss 0 with torch.no_grad(): for bx, by in val_loader: bx, by bx.to(device), by.to(device) logits, _ model(bx) val_loss criterion(logits.reshape(-1, vocab_size), by.reshape(-1)).item() val_loss / len(val_loader) print(fEpoch {epoch1}, Val Loss: {val_loss:.4f}) if val_loss best_val_loss: best_val_loss val_loss wait 0 torch.save(model.state_dict(), best_model.pth) else: wait 1 if wait patience: print(f早停于 epoch {epoch1}) break return model这里加了两个东西验证集和权重衰减。weight_decay1e-5是 L2 正则让权重不要太大间接防止过拟合。patience3是早停耐心值验证 loss 连续 3 轮不降就停。实际跑下来这套配置通常在第 15 到 25 个 epoch 触发早停比固定跑 30 轮效果更好生成的诗也更通顺。还有一个技巧是学习率衰减。训练初期用 1e-3 快速下降后期降到 1e-4 精细调整。PyTorch 里可以用torch.optim.lr_scheduler.StepLR每 10 个 epoch 把学习率乘 0.5。这个不是必须的但加上之后生成质量确实有提升尤其是押韵的准确率。最后说个我自己的习惯每次训练完我会用同一组起始词比如「春风」「秋月」「山水」生成十首诗人工看一遍挑出最好的三首放进文档说明里。期末大作业的评分老师不会跑你的代码他们看的是文档和生成样例。样例质量直接决定印象分。我一般会把温度 0.7、0.8、0.9 各生成一组对比展示参数对输出的影响这样文档说明会显得很扎实。希望帮到你。本文还有配套的精品资源点击获取
返回列表