☰
基于字级N-gram与规则约束的Python诗歌接龙实现
2026/11/2 4:13:04 网站建设 项目流程

简介:这是一份面向NLP入门与实战学习者的Python诗歌接龙项目压缩包,围绕诗词文本处理、拼音转换与接龙算法展开,适合希望把分词、词性标注、音韵匹配等知识落到代码中的开发者参考。包内共17个文件,以4个Python源码文件及pk、dat数据与模型文件为核心,辅以xml配置、docx说明文档和可执行程序,压缩包约5.86MB,便于按模块查看实现与运行路径。该压缩包已有499人学习下载。读者可参考数据获取、文本预处理、末字拼音匹配、分词及候选诗句筛选等环节的代码实现,并借助说明文档理解项目目标、功能模块与使用方法;对复现NLP小项目、梳理依赖关系和数据加载流程也有直接帮助。其中规则与统计结合的接龙思路、韵脚筛选逻辑以及数据保存与加载方式,可为课程设计、项目复现和NLP流程练习提供较完整的参考样本。

1. 诗歌接龙不是让模型胡诌,而是给约束下的续写排个序

有位做文创的朋友丢来半句「春风又绿江南岸」,问能不能让程序自动往下接,条件很直白:字数对、押韵、别把唐诗的气质接成现代口语。这类需求听着像大模型一句话的事,真放进小工具里就会发现两个问题——生成结果不可控,韵脚全靠运气;接口调用有延迟,离线场景直接哑火。我后来用的是一套 Python 写的 NLP 项目,名字就叫诗歌接龙,输入一句诗,按字级语言模型做候选生成,再用平仄和韵部规则重新排序,全程本地跑、每一步都能打印中间分。它适合三类人:做课程设计但不想交一份调用 API 的壳子的学生,想把接龙塞进小程序或内容后台的开发者,以及刚摸 NLP、需要一个完整「数据—模型—解码—评估」闭环练手的人。下面按我拆包复现的顺序写,从洗语料一直写到调参。

2. 语料清洗到样本构建:把 JSON 诗集洗成字级 trigram 表

解压之后目录通常很朴素:一个 data 放 JSON,源码几个 py 文件散在根目录,依赖清单可能压根没有。我拆包第一件事不是点运行,而是把 JSON 打开看前三条记录。接龙这类任务对语料异常敏感,脏一句能带出十句复读,很多人调参调到怀疑人生,最后发现是数据里混进了序文和诗题。

2.1 先定建模单位:整首、句对还是字序列

一条记录里常见的字段是标题、作者、正文段落列表,正文段落一行就是一句。接龙要的是「给一句、续下一句」,所以训练样本的最小单位必须是句对,而不是整首诗。整首当一条会带来一个隐性麻烦:模型学到的是首与首之间的松散关联,一旦脱离第一句的上下文,后面的句子失去约束,句尾韵脚基本靠掷骰子。

第二件事是把「诗」和「非诗」分开。语料里往往混着诗题、小序、注释、联句残片,这些行的长度分布和正常诗句差得很远。我的处理是按字数过滤,五言留 5 字、七言留 7 字,其余先丢掉;如果要做词或者长短句,就得放宽到 3~9 并按标点断句,规则完全不同,别混着来。

第三件事是去重。同一首诗在多个文件里重复出现是常态,不去重的话高频句会在模型里被反复加权,生成时第一个蹦出来的永远是那几句。去重键要用「上一句 + 下一句」这样的整对,而不是按单句去重——同一句在不同上下文里出现是合法信号,砍掉反而损失信息。

2.2 清洗脚本:归一化、校验、配对三步走

下面这段是清洗入口,负责把一行原始文本压成纯汉字串并做长度校验:

# clean_corpus.py import json, re HAN = re.compile(r"[\u4e00-\u9fff]") # 只保留汉字,标点、空格、拉丁字母全部剔除 PUNCT = ",。!?;:、“”‘’《》()…—·" def normalize(line: str) -> str: """去掉标点和空白,返回纯汉字串""" line = line.strip() for ch in PUNCT: line = line.replace(ch, "") return "".join(HAN.findall(line)) def is_valid(line: str, allowed_len=(5, 7)) -> bool: """长度合规 + 排除极端重复行""" if len(line) not in allowed_len: return False if len(set(line)) <= 2: # 「深深深深深」这类行直接丢 return False return True

normalize里用正则兜底而不是逐个字符判断,是为了顺手清掉全角空格、零宽字符这些肉眼看不见的噪声;allowed_len是这一层最重要的参数,决定你后面能接几言,五言改七言只需要动这个元组,但模型得重训。is_valid里那个「不同字数不超过 2」的判断看着粗暴,实际能过滤掉相当一部分残句和占位符。

配对和落盘:

import hashlib, pickle def build_pairs(poems, allowed_len=(5, 7)): pairs = [] for p in poems: lines = [normalize(l) for l in p.get("paragraphs", [])] lines = [l for l in lines if is_valid(l, allowed_len)] # 相邻两句构成一条样本,隔句不成对 for a, b in zip(lines, lines[1:]): pairs.append((a, b)) return pairs def dedup(pairs): seen, out = set(), [] for a, b in pairs: key = hashlib.md5((a + b).encode("utf-8")).hexdigest() if key in seen: continue seen.add(key) out.append((a, b)) return out if __name__ == "__main__": raw = json.load(open("data/poems.json", encoding="utf-8")) pairs = dedup(build_pairs(raw)) pickle.dump(pairs, open("data/pairs.pkl", "wb")) print(f"样本对 {len(pairs)},首句去重后 {len({a for a, _ in pairs})}")

这里zip(lines, lines[1:])是关键,它保证每一对都是原文里真出现过的上下句关系,而不是随机拼两句。最后的 print 我一般会盯着看:如果样本对数量比去重后的首句数多出好几倍,说明语料里同一句被大量复用,得回头查来源是不是把同一本书切了好几份。

2.3 字级 trigram 训练与回退结构

建模选字级不选词级,理由在古诗上很实在:词的边界不稳定,「长安」是一个词还是两个词,不同分词器给不同答案;字级没有这个问题,代价只是序列更长、上下文更稀。稀就稀,靠回退解决。

from collections import defaultdict, Counter import pickle def train_ngram(pairs, n=3): model = defaultdict(Counter) for a, b in pairs: seq = "<s>" * (n - 1) + b + "</s>" # 每句独立训练,用起始符补齐上下文 for i in range(n - 1, len(seq)): ctx = seq[i - n + 1:i] model[ctx][seq[i]] += 1 return model model = train_ngram(pickle.load(open("data/pairs.pkl", "rb")), n=3) model = {k: v for k, v in model.items() if sum(v.values()) >= 2} # 只出现一次的上文直接截断 pickle.dump(dict(model), open("data/trigram.pkl", "wb"))

n=3是我在几万首规模语料上的常用值:n=2 太容易退化成「见字接字」,n=4 的上下文覆盖率又掉得厉害,生成时大量落到兜底分支。那个「出现次数小于 2 就截断」的过滤别省,它能把模型体积压掉一大截,同时减少解码时那些只出现一次、纯粹是语料噪声的分支。训练只用了下一句的正文,没用上一句,是因为接龙解码时的上下文由用户输入现场提供,训练阶段把它并进上下文反而会让模型只认语料原配。

3. 接龙解码器:beam search 与句尾韵脚的双向约束

有了概率表不等于能接出像样的句子。真正拉开差距的是解码策略——同一份模型,贪心和 beam 出来的东西完全两个水平。

3.1 贪心解码为什么会翻车

最朴素的写法是每步取概率最高的字,走满 5 或 7 步收工。跑十条里至少三条是废的,问题出在句尾:前四个字都挑的最优解,到第五个字发现可选集合里没有一个能押上韵,这时候只能硬接,接出来的句子读着像另一个语种。句子是一个整体,局部最优堆不出全局可读。

beam search 的思路是保留多条半成品,到句尾再统一用规则筛。代价是计算量翻几倍,但在七言这个长度上完全可接受——步数只有 7,beam 开到 8 也就几千次打分。

3.2 打分函数:log 概率加一级回退

import math def logprob(model, ctx, ch, alpha=0.4): """字级 log 概率,上下文缺失时回退到低一阶""" if ctx in model and model[ctx].get(ch): c = model[ctx] return math.log((c[ch] + alpha) / (sum(c.values()) + alpha * len(c))) if ctx[1:] in model: # 丢掉最早的一个字,回退一阶 c = model[ctx[1:]] return math.log((c[ch] + alpha) / (sum(c.values()) + alpha * len(c))) - 0.8 return -12.0 # 两级都查不到,给一个足够低的兜底分

alpha是加性平滑系数,取 0.4 是我试出来的平衡点:太小则未登录字拿到的分数过低、模型死守高频字,太大则低频组合被抬得过高、句子开始乱跳。回退那一支额外减 0.8,是人为的折扣,保证能用高阶就别用低阶,-12.0是兜底,必须足够低,否则未登录词会被当成宝。

3.3 beam search 主体

from collections import Counter def beam_decode(model, prefix, max_len=7, beam=8, branch=20, rep_penalty=1.2): ctx = "<s><s>" + prefix[-2:] # 用户输入的最后两个字作为起始上下文 beams = [("", 0.0, Counter())] for _ in range(max_len): cand = [] for text, sc, used in beams: cur = (ctx + text)[-2:] # 滑动窗口取最近两字 for ch, _cnt in model.get(cur, {}).most_common(branch): if ch == "</s>": continue # 重复字按出现次数线性扣分,压复读 score = sc + logprob(model, cur, ch) - rep_penalty * used[ch] cand.append((text + ch, score, used + Counter(ch))) if not cand: break cand.sort(key=lambda x: -x[1]) beams = cand[:beam] # 只留前 beam 条,其余丢弃 return sorted([(t, s) for t, s, _ in beams if len(t) == max_len], key=lambda x: -x[1])

beam=8和branch=20是两个要一起看的参数:branch控制每个上下文展开多少个候选字,太小会把韵脚字提前剪掉,太大则候选里塞满噪声;beam控制每步保留多少条半成品。七言场景下 8×20 的组合单句耗时通常在几十毫秒量级,够实时用。rep_penalty对古诗这种短句要设得保守,1.2 左右比较稳,调到 2 以上句子会开始刻意回避常用字,读着别扭。

3.4 句尾约束:先锁韵脚,再倒推整句

纯靠语言模型押韵成功率大概三成,剩下的全靠规则找补。我的做法是把押韵做成后置筛选,而不是塞进解码循环里——循环里加权会让模型为了押韵牺牲整句通顺,后置筛选则可以只对句尾这一个位置动手。

def rerank(cands, rhyme_set, weight=3.0): """在句尾命中韵部时给固定加分,其余位置不动""" out = [] for text, sc in cands: bonus = weight if text[-1] in rhyme_set else 0.0 out.append((text, sc + bonus)) return sorted(out, key=lambda x: -x[1])

weight=3.0这个数没什么理论依据,纯经验:低于 2 压不住语言模型的偏好,高过 5 就会开始出现「韵脚对了但整句不成话」的候选。判断标准很土——接三十条,人工数一下有几条能读通且押上韵,权重就往那个方向调。

多轮接龙在外层再包一层循环,把刚生成的句子塞回 prefix:

def chain(model, seed, rounds=4, rhyme=None): from rhyme import rhyme_set_of, guess_rhyme lines = [seed] rhyme = rhyme or guess_rhyme(seed) # 首句韵脚决定全篇韵部 rs = rhyme_set_of(rhyme) for _ in range(rounds): cands = beam_decode(model, lines[-1], max_len=len(seed)) cands = rerank(cands, rs) if not cands: break lines.append(cands[0][0]) return lines

max_len=len(seed)保证每句和首句字数齐整,这是接龙最容易被忽略的硬约束——很多实现只顾押韵,生成的句子五言七言混着来,读一眼就出戏。guess_rhyme取首句末字的韵部,用户也可以显式指定,做藏头类玩法时这一项必须开放出来。

4. 平仄与押韵落代码:三张表加一个打分函数

规则层是这类项目最容易写成玄学的地方。我见过不少实现把平仄表硬编码成一个几百字的字典,改一个韵部要翻半天。比较稳的做法是把规则拆成三样东西:字到平仄的映射、字到韵部的映射、句式模板,各管各的。

4.1 平仄映射与入声补丁

现代普通话的四声和古诗的平仄不是一一对应,但近似度够用:一二声归平,三四声归仄。真正麻烦的是入声字,它们在普通话里散进了四声,其中一批今读阴平、阳平的入声字会被判成平声,而这批字里偏偏有「白、石、竹、出、国、一」这种高频字,误判一次整句平仄就歪了。

from pypinyin import pinyin, Style # 高频入声白名单,命中强制判仄,不求全,够压住常见误判即可 RU_SHENG = set("白 石 竹 出 国 一 十 百 尺 客 别 接 节 结 雪 血 学 落 阁 觉 角 屋 独 读 福 忽 菊 哭") def ping_ze(ch: str): if ch in RU_SHENG: return "仄" py = pinyin(ch, style=Style.TONE3, heteronym=False, errors="ignore") if not py or not py[0]: return None # 查不到就返回 None,不参与打分 tone = py[0][0][-1] if tone in "12": return "平" if tone in "34": return "仄" return None # 轻声、儿化一律视为未知

heteronym=False让多音字只取默认读音,这是有意的取舍:打开多音字模式会返回一组读音,你得自己挑,挑错的概率不比默认读音低多少。真正的解法是在句式打分阶段允许None存在,未知字直接跳过不计,而不是瞎猜一个。

4.2 韵部表:拼音韵母只是起点

直接拿拼音韵母当韵部,接出来的东西会「听着像押韵但不对味」。「东」和「冬」在古韵里是两个韵部,普通话里完全同音;反过来「支」和「微」普通话差得远,古韵里关系很近。做课程设计级别的 demo,拼音韵母够用;要它读着像诗,就得往里收一层。

RHYME_MAP = { "a": "a", "ia": "a", "ua": "a", "o": "o", "uo": "o", "e": "e", "ie": "ie", "üe": "ie", "i": "i", "ü": "i", "u": "u", "ai": "ai", "uai": "ai", "ei": "ei", "ui": "ei", "ao": "ao", "iao": "ao", "ou": "ou", "iu": "ou", "an": "an", "ian": "an", "uan": "an", "üan": "an", "en": "en", "in": "en", "un": "en", "ün": "en", "ang": "ang", "iang": "ang", "uang": "ang", "eng": "eng", "ing": "eng", "ong": "ong", "iong": "ong", } def rhyme_group(ch: str): finals = pinyin(ch, style=Style.FINALS, heteronym=False, errors="ignore") if not finals or not finals[0]: return None f = finals[0][0] return RHYME_MAP.get(f, f[-1] if f else None) def rhyme_set_of(group: str): return {ch for ch in ALL_CHARS if rhyme_group(ch) == group}

rhyme_set_of需要一份全字表,实际实现里我一般从语料里扫一遍所有出现过的字建缓存,几万字的规模一次建好存 pickle,别每次生成都现扫。RHYME_MAP这张表的合并粒度是可以调的,粒度粗则押韵成功率高但味道淡,粒度细则反过来,这个取舍要按用途定——做娱乐向小程序就往粗了合并,做教学演示就细一点。

4.3 句式模板与平仄吻合度

五言七言各有四种基本句式,平起仄起、首句入韵与否交叉。模板不用背全,写两张小表足够:

句长模板(○平 ●仄 ◎可平可仄)说明
五言●●○○●仄起不入韵,最常见
五言○○●●○平起入韵
七言◎●○○●●○平起入韵
七言◎○◎●●○○仄起入韵

真正落地不用追求全合,追求「关键位置合」。传统说法是一三五不论、二四六分明,换成索引就是从 0 开始数,七言的 0、2、4 位可以放宽,五言的 0、2 位可以放宽:

def pattern_score(line: str, pattern: str, flexible=(0, 2, 4)) -> float: hit = total = 0 for i, ch in enumerate(line): if i in flexible: continue # 一三五不论,不参与评分 pz = ping_ze(ch) if pz is None: continue # 未知字不计入分母 total += 1 hit += (pz == pattern[i]) return hit / total if total else 0.5

flexible这个元组是相对句长来的,五言要传(0, 2),传错了会把关键位置放过去。返回 0.5 而不是 0,是给「全是未知字」的极端情况一个中性分,避免把候选一棍子打死。

4.4 综合打分:把四个信号揉成一个数

最后一步是把语言模型分、押韵、平仄吻合、重复惩罚合成一个总分。权重没有标准答案,但有一个大致区间:

信号建议权重说明
语言模型 log 概率1.0(基准)不缩放,作为主项
句尾押韵2.5~3.0只在句尾位置生效
平仄吻合度1.0~1.5乘以 pattern_score,全合才拿满
单字重复惩罚1.0~1.5每个字按出现次数线性扣
def final_score(text, lm_score, rs, pattern, w_rhyme=2.8, w_tone=1.2): s = lm_score s += w_rhyme * (text[-1] in rs) s += w_tone * pattern_score(text, pattern) return s

我一般把平仄权重压在押韵之下,原因很实际:平仄错了普通读者看不出来,韵脚错了张口就能听出来。先保证听得舒服,再谈格律正确,顺序反了做出来的东西没人愿意用。

5. 避坑与排查:五条让接龙出戏的记录

5.1 生成侧的三次翻车

现象一:同一句在四轮接龙里出现两次以上,或者整段像卡带。原因不是模型坏了,是低阶回退被高频组合占满,解码每步都落回同一个上下文,前一步选了 A,下一步又回到能选 A 的状态。解法是在 beam 里加状态去重,同时把rep_penalty从 1.0 提到 1.2 以上,并且在多轮循环里显式记录已生成句子的句尾韵脚,把整句级别的重复也扣一次分。只扣单字重复不够,重复的往往是整句。

现象二:韵脚全对,但每句都读不通。我把押韵权重从 2.5 一路调到 6 想提高命中率,结果命中率上去了,可读性崩了。原因是权重过高时,解码器会优先牺牲语言模型分去凑韵,等于用规则覆盖了统计。正确做法是押韵只在句尾生效、权重封顶 3.0,命中率上不去就改从「先定韵脚字、再倒推整句」的方向做,别一味加权重。

现象三:句数一多就卡,内存往上飙。beam 里每条半成品都带着完整的 Counter 和拼接后的字符串,跑到第七八轮时对象数量翻倍。解法是把打分改成增量式——每步只算新加那个字的分数,历史分随 beam 一起传递,不要每次从头重算;同时在每轮结束把已经不可能翻盘的候选(分差超过阈值)剪掉。

5.2 数据与环境侧的两个坑

现象四:平仄判断大面积出错,且错得很有规律。查下来是多音字和入声字两类。多音字靠heteronym=False取默认读音只能缓解,真正高频的几十个字(「看」「望」「思」这类)建议单独维护一张读音覆盖表。入声字则必须上白名单,靠普通话声调永远判不对,这是规则层绕不过去的补丁。

现象五:Windows 下运行直接抛 UnicodeDecodeError,或者输出全是问号。原因是 JSON 存的是 UTF-8,而默认打开编码跟着系统走。所有open()一律显式写encoding="utf-8",包括读配置和写日志;控制台输出乱码则在脚本入口加一行重设标准输出编码。这个坑不致命但特别费时间,我第一次遇到时排查了半小时才想到是编码而不是数据。

提示:清洗阶段一定要把「过滤掉了多少行、剩下多少对」打印出来。数字异常时先怀疑数据,别怀疑模型。

6. 质量校验与调参:怎么判断接得好不好

调参最怕没有标尺。我一般先跑四个自动指标,跑完再人工抽三十条看,两者对不上就以人工为准。

指标计算方式参考区间
韵脚命中率生成句末字落在目标韵部的比例85% 以上
整句重复率多轮结果里重复句占比5% 以下
长度合规率字数等于首句的比例100%
平均对数概率每条生成句的 lm 分均值相对值,只看趋势

校验脚本很短,关键是批量跑而不是单条看:

def evaluate(model, seeds, rounds=4): stat = {"rhyme": 0, "total": 0, "dup": 0, "len_ok": 0} for seed in seeds: lines = chain(model, seed, rounds=rounds) rs = rhyme_set_of(guess_rhyme(seed)) for ln in lines[1:]: stat["total"] += 1 stat["rhyme"] += ln[-1] in rs stat["len_ok"] += len(ln) == len(seed) stat["dup"] += len(lines) - len(set(lines)) n = stat["total"] return {k: (v / n if k != "dup" else v / n) for k, v in stat.items()}

真正有效的调参手法只有两个。第一个是先锁韵脚再倒推:从目标韵部里挑几个高频字作为句尾候选,让解码器以它为终点反向补全,比正向生成后筛选的命中率高一大截,代价是要多写一层反向打分。第二个是限制单字在整首诗里的总出现次数,同一个字超过两次就强扣分,这一条对消除「一首诗里三个‘风’」这类廉价感特别管用,比调重复惩罚系数见效快。

我现在的习惯是每次改完权重,先跑 50 条种子看四个指标有没有一起变差,只要有一项明显掉就回滚,绝不靠单条样例的观感做决定——单条好看太容易了,随便换个随机种子就能碰上一条像样的。这套流程第一次帮我拦住了一次「押韵命中率 95%、可读性全无」的调参,从那以后我每次动权重都强制走一遍指标对比,再人工抽检。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询