资讯详情

诗歌接龙:用Python实现古诗续写的NLP项目全解析

📅 2026/10/25 13:27:57 | 华诺云谱 👁 阅读
诗歌接龙:用Python实现古诗续写的NLP项目全解析
简介面向NLP入门与Python实践者的诗歌接龙项目完整代码包聚焦中文分词、词性标注、拼音转换、押韵规则匹配及网络爬虫等核心环节适合希望通过完整实战案例理解自然语言处理流程的学生与开发者。压缩包共17个文件主要包含Python源码、jieba/pypinyin等库的依赖说明、预训练数据文件、可执行程序、txt说明文档及docx详细设计文档其中docx对项目背景、模块设计与调用流程做了系统说明整体大小仅5.86MB轻量便于快速部署。已有496人学习浏览。项目中除基础爬虫与文本处理外还提供了基于规则与统计方法的接龙算法实现并附有项目目标、实现过程与使用方法能帮助读者从分词、音韵匹配到算法调优获得完整实战闭环适合作为课程设计或NLP入门练习的参考。1. 诗歌接龙是什么一个让Python写诗的下一个字的NLP项目“诗歌接龙”听起来像语文课上的游戏但落到程序里它其实是NLP自然语言处理里最典型的序列生成任务用户给出一句诗或半句诗程序需要预测下一个字甚至把后面的诗续完。用Python实现这样一个项目意味着你要走完中文语料清洗、序列建模、概率采样、结果评估的完整链路。它的难度比文本分类高一档但比从头做对话机器人低很多特别适合用来检验你对Python字符串处理、数据结构和简单模型的掌握程度。你做出来的东西也不是那种跑完loss就扔掉的玩具——它能把“床前明月光”续成“地上鞋两双”也能在调好参数后生成像模像样的五言句。这个项目适合谁刚学完Python基础、想找带文本场景的项目练手的人或者已经在用n-gram做关键词提取、想跨进生成式NLP的人。接下来我按自己实际做过的思路把这条路上的选型、代码、参数和坑一次讲清。2. 先想清楚接龙的数学逻辑从n-gram到序列模型的选型思路2.1 诗歌接龙不是在写诗是在预测下一个字很多人第一次做诗歌接龙脑子里想的是“让AI学会作诗”于是直接上GPT。但真正的第一步是把问题简化成条件概率给定前文“床前明月”下一个字是“光”的概率是多少如果模型能算出候选字在给定上下文下的概率分布那么接龙就变成一个采样问题——按概率分布挑一个字然后把它拼到输入末尾继续预测下一个字。这个思维决定了技术路线。诗歌接龙的输入输出都是字符序列本质是语言模型Language Model。语言模型做的事就是最大化序列概率 P(text) P(w1) * P(w2|w1) * ... * P(wN|w1..wN-1)。而接龙只要计算 P(下一个字 | 当前已生成序列)所以训练的样本就是“给定前面N个字预测第N1个字”。这个视角统一了所有后续做法n-gram里的转移概率表、LSTM里的softmax输出、Transformer里的自回归解码全部是在求这个条件分布。这里要强调一个容易被新手搞混的点接龙其实不需要先分词。中文诗歌很多时候一个字就是一个语义单元按“字”做语言模型比按“词”做更稳。词表就是全部出现过的字几百个字就够用训练快、可控性强。按词的话古诗里大量典故、人名、地名会切成碎片反而学不好。我一般默认用字符级建模除非要做主题分类才考虑词级特征。2.2 数据准备把古诗库变成模型能吃的序列样本数据是诗歌接龙的命根子。常见做法是用公开的古诗数据集里面包含唐诗、宋词、诗经等文本。拿到的原始数据通常是这种格式[{author: 李白, title: 静夜思, paragraphs: [床前明月光, 疑是地上霜]}]也有的是纯文本一行一首诗或描写段落后跟诗句。第一步是把所有诗句抽出来去掉作者、标题、注释。注意古诗里会有繁体字、异体字比如“裏”和“里”“閒”和“闲”。我一般会统一转成简体做法是用opencc库pip install opencc-python-reimplemented然后import opencc cc opencc.OpenCC(t2s) # 繁体转简体 text cc.convert(raw_text)转完之后还要做清洗去掉空格、换行、标点保留句号逗号也行但接龙目标是诗句通常去掉标点让模型自己学去掉长度小于4或大于12的行因为五言绝句一般是5或7个字一句太短的行多半残缺太长的可能是散文混入。清洗后的语料长这样床前明月光 疑是地上霜 举头望明月 低头思故乡有了这个纯文本列表就可以构造训练样本了。如果是n-gram需要统计“前N-1个字 - 下一个字”的计数如果是LSTM需要把每句诗转成整数序列再用滑动窗口切出输入和标签。这一步是整个项目的地基没做好后面模型再强也白搭。2.3 选型对照n-gram、LSTM与Transformer该用哪个搞清楚任务本质后选型就清晰了。常见做法是分三档n-gram字符级简单到用Python字典就能实现。它统计“前面n-1个字”这个状态出现时后面跟的每个字的次数然后算概率。优点是训练只要一次遍历语料完全没有GPU需求缺点是只能记住连续n个字内的关系n设大了矩阵稀疏爆炸n设小了生成出来的句子前后逻辑差。对诗歌接龙这种短文本n3或4已经能生成语法通顺但内容随机的小诗。LSTM/GRU能捕捉远距离依赖比如律诗里上下句的语义呼应。训练需要神经网络框架我一般用PyTorch因为动态图方便写采样逻辑。LSTM接收前文序列的embedding最后一个时间步的输出通过全连接层映射到字典大小的向量softmax后就是下一个字的概率。它比n-gram学到的泛化能力强你输入一句不在语料里的“半句诗”LSTM也能接出个差不多的下句。缺点是训练时间受序列长度和数据量影响字符级词表小的情况下CPU也能跑但效率低还是建议装个CUDA版PyTorch。Transformer/预训练模型如果你手里有百G语料或者想直接用中文GPT-2那效果肯定最好但这不是普通“python实现NLP项目”该走的路。预训练模型像是拿电锯切豆腐模型下载几个G、显存要十几G而诗歌接龙本身数据量不大几百KB古诗就够。除非你后来要做风格迁移、多轮对诗否则用Transformer从头训是在给自己找罪受。我自己的建议路线是先花半小时用n-gram跑通整个“输入-生成-输出”流程感受一下概率采样的效果再花两小时把同样的数据喂给LSTM对比质量差异。两条路写一遍你对序列生成的理解会深很多。下面的章节我就按这个顺序展开。3. 用纯Python实现第一个可用的诗歌接龙字符级n-gram与beam search3.1 编码与计数构建转移概率表n-gram的实现核心是计数器。我以“三元语法”为例也就是n3用前两个字预测下一个字。你需要一个字典key是长度为2的字符串或元组value是另一个字典记录每个下一个字的出现次数。import os import random from collections import defaultdict, Counter def load_lines(path): lines [] with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if len(line) 5 and len(line) 7: # 只保留五言或七言 lines.append(line) return lines def build_trigram(lines): # key: 前两个字组成的字符串, value: Counter(下一个字 - 次数) model defaultdict(Counter) for line in lines: # 在开头加两个特殊字符让首字母也有前文 padded ^^ line $ for i in range(len(padded) - 2): context padded[i:i2] next_char padded[i2] model[context][next_char] 1 return model这段代码做了两件关键的事。第一给每一行首尾加了特殊字符^表示句首$表示句尾。这样生成的时候模型知道如何开始和结束。第二用defaultdict(Counter)构建两层字典外层的key是“前两个字”内层的key是“下一个字”。统计完成后你随时可以查model[明月][光]得到“明月”后面跟“光”的次数。注意我过滤了长度在5到7之间的行。严格来说五言是5字七言是7字但古诗里也有四言和杂言过滤掉能减少噪声。如果你想让模型也能生成七言和五言混合就不要死卡长度可以在构造样本时把句子按逗号再分割逐句加入。下一步是把这个计数模型转成概率模型。因为要用到随机采样我直接保留计数生成时再除以总数或者用random.choices按权重采样。后者更省事。3.2 beam search采样让接龙不只会重复有了模型最朴素的生成是贪心每次选概率最大的字。但这样会陷入循环比如“床前明月光”之后永远接“疑是地上霜”因为训练语料里这个组合频率高。更糟糕的是如果模型只学到高频搭配你就会得到“床前明月光床前明月光”的复读机。解决手法是用beam search束搜索每一步维护K个候选序列每个候选序列按累计log概率排序下一轮对每个候选继续展开所有可能的字再取前K个。这样生成的质量远好于贪心又比纯随机稳定。def beam_search(model, prefix, beam_width5, max_len10): # prefix: 用户输入的字符串至少2个字符因为n3 start prefix[-2:] # 取后两个字作为初始context candidates [(start, prefix, 0.0)] # (context, full_text, log_prob) for _ in range(max_len - len(prefix)): new_candidates [] for ctx, full, lp in candidates: counts model.get(ctx) if not counts: continue # 没有见过这个context放弃该分支 total sum(counts.values()) for ch, cnt in counts.items(): prob cnt / total new_lp lp math.log(prob) new_full full ch new_ctx ctx[1] ch new_candidates.append((new_ctx, new_full, new_lp)) # 只保留分数最高的beam_size个 new_candidates.sort(keylambda x: x[2], reverseTrue) candidates new_candidates[:beam_width] # 若所有候选都断了提前结束 if not candidates: break # 返回分数最高的完整文本 candidates.sort(keylambda x: x[2], reverseTrue) return candidates[0][1]这个函数每步对当前所有候选展开所有可能的下一个字计算累计对数概率然后截断到beam_width。对数概率避免了很多个小概率相乘导致的浮点下溢这是做beam search必须养成的习惯。参数beam_width越大生成的多样性越好但耗时线性增加。对诗歌接龙5到10就够。3.3 三个关键参数n、温度、beam size怎么设n-gram里的n决定记忆力长度。n2时很容易生成无厘头的顺口溜n4时生成的句子更像原诗片段但数据稀疏非常明显很多context没见过导致生成中断。我实测下来n3是性价比最高的。如果你语料量大到几万首可以试n4但要把未登录词处理做好——比如context没见过时回退到较低阶的n-gram这叫backoff。beam width不能太大太大容易让所有候选都来自同一高频分支反而失去多样性。这里有个反直觉点beam search的目标函数是“序列整体概率最高”这意味着它会优先选语料里出现过的整句而不是有创意的句子。所以如果你想看到模型“创作”新诗而不是背诵原诗需要在采样时引入温度参数。温度temperature是软max里的缩放系数。在生成时我们把概率换成p_i exp(log(p_i)/T)再归一化。T1时分布变平缓低概率字也有机会被选中T1时分布变陡峭趋向贪心。我用n-gram时一般不在beam search里加温度因为beam search本身就是取top温度会影响排序。正确做法是想要质量高、像古诗用beam searchT1想要花样多、能“作诗”改用random sample并按温度重新加权概率。两种路径我都保留在代码里随场景切换。另外一个容易忽略的参数是最大生成长度。诗歌接龙通常续3到4个字就能构成完整一句因为用户输入半句或者想续下一句。我的经验是如果用户输入的是“床前明月”最大长度设为10让它生成通顺的七言如果用户输入的是完整“床前明月光”再接龙就是出下一句最大长度设为12也够。长度太长会让模型变散文失去诗的节奏感。到这里你已经有一个不开GPU也能跑的诗歌接龙脚本。它会读古诗文件记住所有三字结合接着用户输入的半句诗返回一个续句。如果你想继续提高生成质量下一步就该上深度学习了。4. 把接龙升到深度学习用LSTM做更自然的续句4.1 构造训练数据滑动窗口生成特征与标签深度学习的训练数据不再是计数表而是整数序列。先把所有诗句拼接成一个长序列或者按句切割。我倾向于按句切割但为了学习上下文关系也可以把整首诗作为一个序列中间用分隔符连接。这里提供一种简洁的按句切法def build_dataset(lines, seq_len5): # 建立字-id映射 chars sorted(set(.join(lines))) char2id {c: i for i, c in enumerate(chars)} id2char {i: c for c, i in char2id.items()} X, y [], [] for line in lines: ids [char2id[c] for c in line] # 用前seq_len个字预测后面所有字 for i in range(len(line) - seq_len): X.append(ids[i:iseq_len]) y.append(ids[iseq_len]) return X, y, char2id, id2char这段代码的核心是滑动窗口。比如句子“床前明月光”转成ids后seq_len3时[床,前,明]预测月[前,明,月]预测光。这个做法让一条五言诗句产生两个训练样本。注意我没有把整首诗不分句来滑窗因为古诗的上下句之间有语义呼应但接龙通常只延续半句跨句训练反而会让模型去记忆下一句开头降低泛化性。如果你要玩藏头诗、要续一整首那再改成跨句滑窗。seq_len是模型看到的“上文长度”。n-gram里的n是固定的3LSTM里的seq_len可以灵活调。对七言诗seq_len设为7能覆盖完整的一句但我发现seq_len5到6已经足够因为接龙时用户给的前缀通常不会太长而且LSTM内部有隐状态理论上能记住更长信息。设太长会加大训练负担还会让模型过于依赖前文的精确顺序。4.2 搭建一个可训练的中文诗歌LSTM模型这里用PyTorch搭一个两层LSTM。词表大小就是刚才的char2id长度一般几百到一千。embedding维度我选128隐藏单元选256。下面是完整模型定义import torch import torch.nn as nn class PoemLSTM(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) self.fc nn.Linear(hidden_dim, vocab_size) def forward(self, x): # x: (batch, seq_len) emb self.embedding(x) # (batch, seq_len, embed_dim) out, _ self.lstm(emb) # (batch, seq_len, hidden_dim) last out[:, -1, :] # 取最后一个时间步 logits self.fc(last) # (batch, vocab_size) return logits这里有几个关键点batch_firstTrue让输入形状变成(batch, seq_len, feature)这是PyTorch LSTM的习惯性写法out[:, -1, :]只取序列最后一个时间步的输出因为预测目标就是最后一个字之后的字。如果你用双向LSTM这里要拼接前后两个方向但古诗接龙只能看上文所以用单向LSTM更合理。训练时用交叉熵损失但注意别把标签做成one-hot直接用nn.CrossEntropyLoss()配合整数标签就行。优化器我常用Adam学习率设0.001。下面是训练循环的骨架def train_model(model, X, y, epochs30, batch_size128, lr0.001): optimizer torch.optim.Adam(model.parameters(), lrlr) loss_fn nn.CrossEntropyLoss() dataset torch.utils.data.TensorDataset( torch.tensor(X, dtypetorch.long), torch.tensor(y, dtypetorch.long) ) loader torch.utils.data.DataLoader(dataset, batch_sizebatch_size, shuffleTrue) for epoch in range(epochs): total_loss 0 for xb, yb in loader: model.zero_grad() logits model(xb) loss loss_fn(logits, yb) loss.backward() optimizer.step() total_loss loss.item() if (epoch 1) % 5 0: print(fepoch {epoch1}, loss {total_loss/len(loader):.4f})训练结束后保存model.state_dict()方便下次加载。注意embedding层会把每个字映射成一个向量LSTM学的是“在这种向量排列下下一个字通常是什么”。如果你的语料只有几千首过拟合会很明显所以训练到loss降到一定值后就得停止不要追求完美。4.3 训练与采样loss降到多少才够用训练loss不是越低越好。我拿全唐诗五万多首做实验字符级词表大约3000字在两层LSTM 256隐藏单元下loss约在1.8左右就能稳定生成通顺句子降到1.5以下时模型开始倾向于复读原句生成创新性下降。这个规律挺反直觉的——很多NLP项目追求loss越低越好但生成任务里loss太低等于记住了训练集。采样时深度学习模型不像n-gram有硬性的context匹配失败问题因为它对任意输入都能给出softmax分布。但正因如此没有temperature调节的话它更容易生成高概率的常见字。我常用temperature0.8生成既能保住通顺度又有一定创新。下面是采样代码def sample(model, prefix, char2id, id2char, seq_len, max_len20, temperature0.8): model.eval() # 把prefix补到seq_len长度 chars list(prefix) for _ in range(max_len - len(prefix)): # 取最后seq_len个字不够则左侧补起始符这里用第一个字的id补 context chars[-(seq_len-1):] if len(chars) seq_len-1 else chars if len(context) seq_len: pad [char2id[chars[0]]] * (seq_len - len(context)) context_ids pad [char2id[c] for c in context] else: context_ids [char2id[c] for c in context[-seq_len:]] # 确保长度正确 context_ids context_ids[-seq_len:] x torch.tensor([context_ids], dtypetorch.long) with torch.no_grad(): logits model(x)[0] # 温度采样 logits logits / temperature probs torch.softmax(logits, dim-1) next_id torch.multinomial(probs, 1).item() chars.append(id2char[next_id]) return .join(chars)采样时每次只取最后seq_len个字作为输入然后逐个生成。注意我用了torch.multinomial而不是argmax这是生成任务的关键——纯argmax会生成大概率但无聊的句子multinomial按概率随机抽配合temperature可以得到多样且合理的输出。如果你想让输出更像格律诗可以在采样后加一个大体检测检查最后一个字的韵脚或者检查每句字数。但这些都算是后处理放在进阶技巧里展开。5. 诗歌接龙避坑指南数据、生成与环境的四个典型翻车点5.1 古诗库的编码坑繁体、异体和不完整的标点现象模型生成的句子全是简体字但训练语料里的“里”和“裏”被当成两个不同字导致“故里”这个常见词被拆散。更严重的是有的古诗库直接从古文献扫描OCR出来混有“丨”“丶”等生僻符号会拉高词表大小浪费训练空间。原因数据来源不是为机器学习准备的原始文本包含繁体、异体和噪声符号。我一开始偷懒直接用原始语料跑结果词表里冒出3000多个罕见的收字模型把所有精力用在区分这些低频字上生成质量大打折扣。解决训练前统一做三步先用opencc转简体再手动清掉非汉字字符最后过滤掉长度异常的行。这一步没法全自动需要定期抽查样本。血的教训是别相信任何“已经清理好”的古诗下载包自己跑一遍清洗才放心。5.2 训练时的loss不降学习率与序列长度现象LSTM训练50个epochloss一直在3.0附近徘徊生成的全是“之乎者也”一类的高频虚词。原因最常见的是学习率太大导致loss震荡或者学习率太小导致模型卡在较差局部解。另一个隐藏原因是序列长度太短比如seq_len2模型根本学不到语义。还有一个容易忽略的没有加embedding的初始化或者输入id从0开始但字典里有0填充导致padding干扰。解决先把学习率调到0.0005batch_size调小一点观察loss是否有下降趋势。如果序列长度小于5先改成5。如果还是不动检查输入数据是否真的被正确塑形用print(X[0], y[0])看一眼前后对应关系。我遇到过把X和y错位一行的情况模型等于在猜随机数loss当然不降。5.3 生成内容全是“的”“了”温度太低与词频熵现象无论用户输入什么生成的下一句都是“春风得意马蹄疾”这种高频诗句或者反复输出“的”“了”这种虚词。原因古诗语料里“之”“人”“风”等字出现频率极高模型学到的先验分布被这些高频词主导。如果temperature设得太低比如0.2softmax输出几乎等同于argmax模型就只会选这些高频词。beam search的宽度如果设得太大也会导致候选全是高频组合看起来反而单一。解决把temperature调到0.81.2多试几次。另外可以在采样时对高频虚词做降权比如把“的”“了”“之”的logits减去一个大常数。这个方法虽然粗暴但很有效。还有一个技巧如果使用beam search从5个候选中挑一个“不是最顺口”的反而更有诗意。5.4 环境依赖numpy版本和Python 3.8的兼容问题现象代码在一台机器上跑得好好的换到另一台装了Python 3.8的机器上import torch直接报错或者numpy操作报AttributeError: module numpy has no attribute float。原因PyTorch版本和numpy版本不匹配老版本numpy里np.float这种别名在numpy 1.24之后被移除了。很多诗歌接龙项目demo写于两三年前里面用了np.float、np.int在新环境里直接崩。解决给项目写一个requirements.txt锁定版本。我常用的组合是python3.8.18、numpy1.23.5、torch2.0.1。不要一上来就装numpy 2.x除非你确认代码里没用旧别名。还有一个更稳做法用from numpy import float这种地方全改成float或者干脆pip install numpy1.23.5一步到位。环境问题虽然不大但能卡你半天这真不是玄学。6. 进阶技巧从单一接龙到藏头诗与风格控制当你的接龙模型能稳定续出通顺句子时下一步可以让输出更“好玩”或者更“可控”。这里我给你三个我实际用过、效果明显的小改造。第一个是藏头诗生成。用户指定四个字比如“山河无恙”要求每句开头分别是这四个字。实现思路叫约束beam search在beam search扩展候选时对于每一句的第一个字符只允许候选等于指定字。具体来说你可以在生成前设置一个forced_chars列表第i句生成时将beam search的每一步查询改为当生成的字符位置是句首时直接过滤掉不是目标字的候选。这样做需要注意被强制选择的字如果在模型里概率很低整句的log概率会比其他候选低于是beam search可能把整个分支剪掉。解决方法是放宽剪枝对被强制的字乘上一个放行权重或者干脆直接不让概率参与排序只让后面的词决定排序。这种方法虽然简单但能让输出每句都以指定字开头实现成本不到20行代码。第二个是风格控制。你想让模型生成“李白一点”还是“杜甫一点”可以在训练数据上下功夫把作者信息作为条件在输入序列最前面加一个“风格标记”。比如对李白的诗输入为“[李白]床前明月光”对杜甫的诗输入为“[杜甫]国破山河在”。训练时模型会把风格标记当作前文的一部分生成时你也把“[李白]”拼在输入前缀前面。这样同一个模型能感知不同诗人的用词习惯算是很轻量的conditional language model。如果你不想重新训练也可以在采样后做规则过滤比如检测是否含“剑”“酒”“月”——但规则永远不如数据条件自然。第三个是韵律校验。古诗接龙最让人头疼的是最后一句的韵脚。你可以准备一个韵表在生成最后一个字时只采样压韵母的字。做法很简单把logits中不符合当前韵部的位置全部置为负无穷然后重新softmax。注意必须先确定本首诗压什么韵通常是第一句的最后一个字决定。如果用户输入的是“床前明月光”最后生成的字必须压“ang”韵于是“霜”“乡”“望”“裳”成为候选。这个校验可以放在温度采样之后也可以直接把采样空间限制在韵部内。我的习惯是先限制韵部再采样这样既快又自然。我自己做过一版用藏头诗LSTM的生成器翻车的地方在于强制字常常是“之”“以”这类虚词导致整句变成“山之高兮水之长兮”虽然压韵但不口语。后来我把强制字出现的概率阈值放宽甚至在候选为空时允许放弃该句改用次优句才算稳定下来。这是血泪经验约束越多生成质量越容易崩需要给模型留一条“破例”的后路。这个项目做到最后你会发现诗歌接龙的核心价值不在于“写诗有多像”而在于你对语言模型生成机制的完整理解——从计数到神经网络从贪心到beam search从过拟合到温度调节。这些经验会直接迁移到其他生成任务比如切词、评论生成、甚至简单对话。希望我的这些踩坑记录和参数习惯能帮到你少走点弯路。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑