资讯动态

Python诗歌接龙实战:从语料清洗到语义匹配的NLP项目

发布时间:2026/9/8 5:46:52 来源:尧图企业网站定制
简介这是一份面向自然语言处理与 Python 学习者的诗歌接龙实战项目以中国古典诗词为处理对象覆盖分词、词性标注、拼音转换、语义与韵脚匹配等关键环节适合想通过完整小项目串联爬虫、文本处理与基础模型应用的开发者。压缩包含十七个文件约五点八六兆字节核心为四个 Python 源码、两个数据文件、一个可执行程序及说明文档另有配置与中间文件便于直接运行学习。项目重点演示了拼音库在诗句末字音韵匹配上的用法并结合分词工具、网页请求与解析完成语料获取与处理。已有四百九十六人学习下载。从源码和说明文档中可了解基于规则与统计方法的接龙算法设计思路包括平仄押韵筛选、候选句生成与结果排序也可参考其工程目录组织与数据持久化方式适合作为课程设计或入门自然语言处理的练手项目。 你随手打出一句“春眠不觉晓”程序能在几秒内接出一句以“晓”开头、读起来不突兀的诗这个需求听起来就是个查字典的活儿。但真正用 Python 动手做这个 NLP 诗歌接龙项目之后我的感受完全变了难点根本不在“找诗”这一步而在语料怎么洗、匹配策略怎么选、以及游戏怎么才能不在一轮之后直接“断气”。这篇文章会从数据准备、三种接龙策略、引擎设计到命令行交互的完整实现过程拆给你看适合想拿一个真实 NLP 项目练手、又不想做烂大街情感分析的朋友。1. 从“接得住”到“接得好”先搞清这项目难在哪1.1 规则一句话能说清复杂的是“选择”诗歌接龙的规则很简单上一句诗的最后一个字必须是下一句诗的第一个字。比如你出“春眠不觉晓”程序就要找一首以“晓”开头的诗“晓镜但愁云鬓改”就是现成的候选。但规则简单不代表实现简单。真做起来会发现三层递进的麻烦第一层是数据。网上能拿到的古诗数据集良莠不齐标点混在句子里、一首诗被拆成多段、同一首诗重复收录这些不处理干净接龙引擎的查准率直接崩。第二层是策略。严格按末字对首字查遇到“改”“渡”“噫”这种出现频率极低的字候选直接为空游戏当场结束。要提升接龙连续性就得引入拼音近似、语义近似的“宽松模式”这就从纯字符串匹配升级成了真正的 NLP 问题。第三层是体验。如果程序每次只会从候选里随机挑一首可能挑出又长又偏的诗玩家体验很差。得加去重、防重复、控长度甚至加“提示”功能。这个项目的本质其实是一个带约束的检索问题输入一句诗抽取出尾部约束条件在语料库中检索满足头部约束的候选再按某种策略排序选优。理解了这一点后面所有代码都是在围绕“约束抽取”和“候选排序”这两个动作打转。1.2 项目最终形态一个能真正玩起来的命令行工具我做的成品是一个纯 Python 的 CLI 工具核心能力包括加载本地古诗语料自动清洗和建索引支持三种接龙策略精确匹配、拼音匹配、语义宽松匹配玩家和程序轮流接龙程序会记住用过的诗避免重复接不上时可以请求提示程序会给出合理候选。整个项目的代码量不大核心引擎两百行左右但麻雀虽小五脏俱全。我在实现时尽量把数据层、策略层、交互层拆开后续想加 Web 界面或者换数据集都不用重写核心逻辑。2. 语料准备诗歌接龙的地基工程2.1 数据集选型和格式设计古诗数据集我推荐用 GitHub 上开源的chinese-poetry仓库里的全唐诗、全宋诗数据。它按诗人分目录每首诗是一个 JSON 对象关键字段就三个title标题、author作者、paragraphs诗句列表。{ title: 春晓, author: 孟浩然, paragraphs: [ 春眠不觉晓, 处处闻啼鸟, 夜来风雨声, 花落知多少 ] }这里有一个很多人会踩的坑paragraphs的最后一个元素不一定是正文的最后一行。有些数据集会把“并序”“注”等说明文字也塞进来清洗时如果直接取最后一句可能抽出来的不是诗而是一句注释。稳妥的做法是过滤掉包含明显非诗特征的行比如长度超过 20 个字、包含“序”“曰”等字样的行。加载的时候我用一个简单的函数统一处理import json def load_poems(pathpoetry.json): with open(path, r, encodingutf-8) as f: data json.load(f) poems [] for item in data: lines item[paragraphs] # 去掉明显不像诗句的行 lines [ln for ln in lines if len(ln) 20 and not any(k in ln for k in (序, 曰))] if not lines: continue poems.append({ title: item[title], author: item[author], lines: lines, text: .join(lines) }) return poems数据集很小加起来也就几万首用 JSON 完全扛得住没必要上数据库。等以后做到百万级语料再考虑 SQLite 也不迟。2.2 清洗要做的三件关键事清洗是整个项目里最“不性感”但最重要的一步。我总结了必须做的三件事第一去标点。诗句里的“”“。”直接删除否则末尾字符取出来是标点索引就全乱了。第二计数字符长度时要按清洗后的文本算。一首诗可能只有一句也可能像《长恨歌》那样几十句。接龙回复时如果输出全文体验极差。我的做法是在清洗时保留lines同时用text全文拼接做接龙匹配真正输出时只取第一句或前两句。第三去重不能只用标题。古诗里“无题”一抓一大把同名同姓的情况非常多。我用的唯一键是“标题 作者 第一句”三重组合基本能保证不误杀。def clean_poem(item): PUNCT set(。、) lines [ln for ln in item[paragraphs] if len(ln) 20] if not lines: return None text .join(ch for line in lines for ch in line if ch not in PUNCT) if len(text) 5: return None return { key: f{item[title]}|{item[author]}|{text[:8]}, title: item[title], author: item[author], lines: lines, text: text }清洗完我习惯顺手把结果另存成一个新 JSON这样后面每次调试引擎不用重新洗一遍数据。这个习惯帮我省了不少时间。3. 三种接龙策略的设计逻辑与实现3.1 精确匹配用哈希索引把查询做到 O(1)精确匹配是接龙的基准策略逻辑简单粗暴取末字去首字索引里查。关键在于索引结构。如果你每回合都遍历全量语料找首字匹配的诗几万首诗虽然也不算慢但每次都做这种线性扫描代码会越写越别扭。更合理的做法是启动时构建一个首字 - 诗列表的倒排索引from collections import defaultdict def build_index(poems): index defaultdict(list) for p in poems: first_char p[text][0] index[first_char].append(p) return dict(index)这样查询就是一次哈希查找复杂度 O(1)。等后面接 Web 接口、做实时对战这个索引就是性能底气的来源。精确匹配的代码很短def last_char(text): return text[-1] def reply_exact(game, user_text): tail last_char(user_text) candidates game.index.get(tail, []) candidates [c for c in candidates if c[key] not in game.used] if not candidates: return None # 优先挑短的输出体验好 return min(candidates, keylambda c: len(c[text]))这里有个细节候选排序我用了“长度优先”而不是随机。因为接龙回复时如果动不动甩出一首五十句的长诗玩家根本没法往下接。选最短的候选能让游戏节奏更紧凑。3.2 拼音匹配用 pypinyin 降低玩家的接龙门槛精确匹配跑通后你会立刻发现一个问题末字是“改”“渡”“隙”这种低频字时候选列表直接为空。这时候如果还死守“必须同字”的规则游戏就玩不下去了。于是我加了第二种策略拼音匹配。规则放宽成“上句末字的拼音与下句首字的拼音相同”这样“改(gai)”和“该(gai)”就能互通。这个功能用到的是pypinyin库核心代码非常简单from pypinyin import pinyin, Style def get_pinyin(char): return pinyin(char, styleStyle.NORMAL)[0][0] def build_pinyin_index(poems): index defaultdict(list) for p in poems: first_char p[text][0] key get_pinyin(first_char) index[key].append(p) return dict(index)注意我用的是Style.NORMAL它返回不带声调的拼音这样“改”和“该”无论是不是同一声调都能匹配上。为什么要去掉声调因为这是游戏不是考试玩家记不准声调很正常放宽声调能明显提高匹配率。拼音策略的坑主要在多音字。比如“了”有le和liao两个读音“行”有xing和hang两个读音pypinyin默认按最常用读音处理不保证是诗句里的那个读音。对于接龙这种场景我的处理是正向匹配和反向匹配都建正向用 pypinyin 默认读音反向即把整首诗的文本拿去重新注音取诗句首字的多个候选读音。如果你有时间更细的做法是按格律和韵部去校音但那个工作量就大了当前场景没必要。3.3 语义宽松匹配用 Word2Vec 找“神的近邻”拼音匹配解决了一部分断流问题但接龙到后期末字“改”这种字即使放宽到同音字候选依然可能为零。这时候就需要真正的 NLP 手段了语义近似匹配。思路是把“首字必须同字”的约束放宽成“首字与末字在语义空间中接近”。比如“改”的语义邻居可能是“更”“换”“变”而“更上一层楼”恰好以“更”开头这就把断掉的龙续上了。我用的方法是直接在语料库上训练一个以单个汉字为单位的 Word2Vec 模型from gensim.models import Word2Vec def train_char2vec(poems): sentences [[ch for ch in p[text]] for p in poems] model Word2Vec(sentences, vector_size64, window3, min_count2, epochs20) return model这里有一个值得说道的设计决策为什么不用网上现成的大规模中文词向量而要在几万首诗的语料上自己训一个因为古诗的语境和现代汉语差别太大。现代词向量里“月”可能和“月亮”“月光”聚在一起但在古诗语料里“月”与“夜”“明”“光”共现得更频繁训出来的向量更能反映诗歌语境的语义关系。自己的小模型虽然在通用任务上不如大模型但在这个特定领域里更贴切而且训练只要几秒钟。有了模型之后语义候选的生成逻辑是先算末字的向量取 top-k 个相似字对应的诗作为候选def semantic_candidates(model, index, tail, top_k5): if tail not in model.wv: return [] similar [w for w, _ in model.wv.most_similar(tail, topntop_k)] result [] for ch in similar: result.extend(index.get(ch, [])) return result语义策略不是要替代精确匹配而是作为兜底策略精确匹配有候选时用它没有候选时才走语义放宽。这个“先严后宽”的层级设计保证前几轮游戏还能维持规则感后面玩不下去了再慢慢放水。4. 引擎实现与命令行交互从类设计到跑起来4.1 用类封装策略避免一堆散落的函数前期我一直在用散函数写后来发现策略一多函数之间传参传得晕头转向。重新整理后我把核心逻辑收拢成一个PoetrySolitaire类class PoetrySolitaire: def __init__(self, poems, strategyexact): self.poems poems self.strategy strategy self.used set() self.index build_index(poems) self.pinyin_index build_pinyin_index(poems) self.model train_char2vec(poems) if strategy in (semantic, hint) else None def _candidates_exact(self, tail): return [c for c in self.index.get(tail, []) if c[key] not in self.used] def _candidates_pinyin(self, tail): key get_pinyin(tail) return [c for c in self.pinyin_index.get(key, []) if c[key] not in self.used] def reply(self, user_text): tail last_char(user_text) cands self._candidates_exact(tail) mode exact if not cands and self.strategy in (pinyin, semantic): cands self._candidates_pinyin(tail) mode pinyin if not cands and self.strategy semantic: cands self.semantic_candidates(tail) mode semantic if not cands: return None, mode pick min(cands, keylambda c: len(c[text])) self.used.add(pick[key]) return pick, mode这里reply返回的不只是诗还带一个mode标记方便交互层告诉玩家“这轮我用了拼音放宽”或“这轮我用了语义近似”。这种透明度对游戏体验很重要否则玩家会觉得程序在作弊。4.2 去重、回合限制与提示让游戏不容易“死”一个容易被忽略的问题是重复使用。如果玩家和程序都在同一个语料库上选诗几轮之后很可能把候选里最短的那首反复拿出来。我在used里存了每首诗的key每次取候选时先过滤掉用过的。还有一个实际体验问题玩家自己可能重复出同一首诗。程序管不到玩家但可以在玩家输入时检查一下给出“这首诗你已经用过了”的提示。回合限制也很有必要。我再怎么优化也不可能保证一条龙永远不断。所以我设计了一个可配置的“宽容轮数”当程序连续 N 轮都需要靠语义模式才能接上时就直接认输并打印一条友好的结束语。这个机制避免了“硬接”造成的尴尬比如语义邻居跑偏到完全不相关的字硬接出来的诗毫无意境。提示功能是后来加的体验提升非常明显。玩家输入hint时程序会把当前末字的精确候选、拼音候选、语义候选各找一两个列出来但不直接替玩家出诗。这样既保留了游戏挑战性又不会让玩家卡死。4.3 命令行主循环稳定是第一位的命令行交互我做得比较朴素就是input()驱动的循环def main(): poems load_poems(poetry_clean.json) game PoetrySolitaire(poems, strategysemantic) print(诗歌接龙开始输入诗句参与接龙输入 hint 获取提示输入 quit 退出。) user_text input(你出).strip() while user_text not in (quit, exit): if user_text hint: show_hint(game) user_text input(你出).strip() continue reply, mode game.reply(user_text) if reply is None: print(我接不上了你赢了) break prefix {exact: , pinyin: [拼音] , semantic: [语义] }[mode] print(f我接{prefix}{reply[lines][0]}) user_text input(你出).strip() if __name__ __main__: main()主循环里有一个细节程序自身的接龙依据是它回复的整首诗的末字而玩家看到的只是第一句。比如程序回复“晓镜但愁云鬓改”整首诗后面还有“夜吟应觉月光寒”但玩家可能以为要按“改”字接龙实际上正确的约束是整首诗的最后一个字“寒”……最初版本里这个规则很容易造成玩家困惑。我最后的处理是回复时把参与接龙的尾字也一起打出来让玩家明确知道下一轮该按哪个字接。这个设计决策值得展开讲一下。古诗接龙其实有两种玩法一种按首句末字接一种按全诗末字接。我选择了按整首诗的末字接因为这样程序可以采用“尽量选短诗”的策略来控制输出长度而如果按首句末字接程序会倾向于找“首句短”但全诗很长的作品输出体验反而不好。规则一旦定清楚玩家的困惑就消除了。5. 实测一次完整接龙过程、结果与踩坑记录5.1 一次真实的接龙全过程我用语义模式做了一次完整实测记录如下这是最有说服力的部分你出春眠不觉晓 我接晓镜但愁云鬓改 (精确匹配按整诗末字“寒”接龙) 你出寒雨连江夜入吴 我接吴宫花草埋幽径 (精确匹配末字“径”) 你出径曲萋萋草绿 我接[拼音] 绿树村边合 (匹配上了“绿”按整诗末字“斜”接龙这里先不纠缠)实际跑下来前几轮精确匹配的命中率很高因为常见字毕竟占据了语料的大部分。但越到后面出现的尾字越生僻精确匹配断流的情况明显增加。这时候拼音模式和语义模式开始发挥作用能多续两三轮。我印象最深的一轮是程序回了“更上一层楼”之后末字是“楼”我故意回了一句“楼船夜雪瓜洲渡”末字“渡”字极其生僻。程序先走精确匹配查不到走拼音匹配也查不到最后靠语义模式找到了语义邻居“度”回了一首以“度”开头的诗。说实话那一刻我觉得这个项目才算真正“活”了——它不是死记硬背而是真的在理解字与字之间的语境关系。5.2 四个必须提前处理的坑实测过程中我踩过的坑整理成清单帮你提前避雷坑一末字是虚词。很多古诗的结尾是“兮”“之”“也”这类虚词比如《楚辞》风格的句子。这些字作为末字时几乎不可能有以它们开头的诗。我的处理是在取末字时加一个虚词黑名单如果末尾是虚词就往前取一个实词字符作为约束。这个规则是我实测中最大的连续性增益来源。坑二多音字导致拼音索引不准。“还”在“千里江陵一日还”里读 huán在“乍暖还寒时候”里读 háipypinyin默认读音经常判断错。我最后的妥协方案是拼音索引同时记录该诗首字的所有候选读音即用pinyin(char, heteronymTrue)取全部读音建索引。宁可在索引里多几条冗余记录也不能因为读音判断错误而漏掉候选。坑三清洗时乱删导致索引 key 错位。有一次我在清洗函数里把“诗题”也拼接进了text结果索引首字变成了诗题的第一个字导致明明有候选却永远查不到。这种 bug 很难发现因为报错时段落很长。后来我加了自检逻辑启动时随机抽 100 首诗断言“text 的首字等于 lines 第一句清洗后的首字”跑不通就报错让数据问题在启动时就暴露。坑四用户输入不规范。玩家可能全角半角混用甚至把作者名也打进来。我的兜底方案是清洗输入时去掉所有非汉字字符只保留\u4e00-\u9fff范围内的字符。这样至少能保证不会因为标点或空格导致尾字提取错误。6. 从能玩到好用我的后续建议6.1 用数据指标来判断接龙质量项目做到能玩之后纯粹的“跑通”已经不能满足我了。我给引擎加了一套简单的评测脚本遍历语料中所有诗用每首诗的末字作为输入统计精确模式、拼音模式、语义模式三者的“有解率”。实测下来在几万首诗的语料上策略有解率大约说明精确匹配62%受限于常用字覆盖范围拼音匹配71%加上声调放宽提升明显语义匹配84%语义放宽能续上不少冷门字这个表格很有参考价值如果你只想快速做一个 Demo精确匹配就够了如果想让用户觉得“这程序有点聪明”至少要做到拼音匹配而语义匹配真正解决的是长尾生僻字问题。除了有解率我还统计了“平均接龙轮数”——用 1000 个随机起始诗跑自动对局程序自己对战自己看平均能续几轮。这个指标能直观反映引擎的鲁棒性。6.2 功能扩展让这个项目长出更多可能性做完命令行版本后续的扩展空间非常大我列几个我考虑过的方向Web 界面用 Flask 或 Streamlit 包一层做成网页版。核心引擎不用动只改交互层验证了当初分层设计的价值。难度分级初级模式允许拼音匹配高级模式强制精确匹配骨灰模式连语义提示都关闭。难度分级本质上是策略组合的切换代码层面就是一个参数的事。接龙对战让两个引擎实例互相接龙配合上面提到的自动对局脚本可以生成一份“接龙排行榜”展示最长连续纪录。风格控制统计候选诗的朝代、作者风格尽量选与上一首时代接近、意境相符的作品。这个方向需要把 Style 信息也作为排序特征适合想深入 NLP 排序模型的人继续做。我在实际测试中发现一个很有意思的扩展是**“接龙难度自适应”**根据玩家前几轮的响应速度来动态调整程序放水的程度。玩家反应快程序就减少拼音放宽玩家明显卡顿就自动开启语义提示。这个功能把游戏从“工具”变成了“陪练”用户粘性会完全不一样。最后再分享一个实用技巧整个项目的核心代码不要和数据处理代码混在一起写。我最初图省事把清洗、训练、游戏写在一个文件里结果每次调试都要从头跑一遍数据清洗。后来拆成data_clean.py、solitaire.py、main.py三个文件开发效率直线上升。工程上的麻烦事绝大多数都源于“懒得拆文件”这一时痛快。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价