简介:面向Python编程与自然语言处理爱好者,这份资源以诗歌接龙为切入点,完整展示了如何实现从诗词抓取、文本预处理到诗句生成的整个流程,涵盖分词、词性标注、语义分析、拼音转换及接龙算法等核心环节,适合作为NLP入门和项目实战的练习素材。压缩包共17个文件,总大小5.86MB,其中.py源代码支撑核心逻辑,.docx和.txt为详细的项目说明,.dat与.pk保存处理后的数据或模型,xml和iml属于工程配置,文件构成完整,便于按模块研读。目前已有496人学习下载,项目完整演示了从网页抓取诗词数据、借助pypinyin进行音韵匹配、再通过基于规则或统计方法生成接龙诗句的流程,并灵活使用jieba分词等常见NLP库。读者可以获得可运行的诗歌接龙程序、详尽的说明文档以及关键算法的实现思路,进而理解从数据采集、文本处理到算法落地的完整工程链条,也能学习如何构建拼音映射、设计接龙规则,对提升Python编程和数据处理能力很有帮助。
1. 诗歌接龙不是续写,而是三重约束下的 Python NLP 落地
能把项目名直接写进压缩包标题的,多半是练手作品,但“python实现NLP项目-诗歌接龙”这类题目是少数学完之后仍然值得拿回来重做的。表面需求只有一句:用户给上半句,程序续下半句;真要跑通,你会同时撞上语料清洗、序列生成和格律校验三座山。这类 Python NLP 项目很适合走完整的数据-建模-落地闭环:新手能看懂从检索基线到字符级生成模型的变化,熟手则可以在押韵限制、平仄校验上做出一组有价值的实验。下面按我自己的实现路径拆开讲,代码路径基本可以直接复现。
2. 诗歌接龙的数据工程:语料清洗与格律标签
2.1 从原始语料切出“上一句→下一句”训练对
接龙语料并不能直接拿诗库喂模型。最典型的坑是原始文本里混着诗题、作者、注释,一行里面既有标点又有空格;更隐蔽的是“接龙”这个动作本身有两种语义:一种是严格按字头接,下一句必须以上一句的最后一个字开头;另一种是语义续写,比如“床前明月光”接“疑是地上霜”。这两种行为差异很大,我一般会在数据阶段就分开打标签,而不是让模型自己悟。
import json, re from collections import defaultdict def load_poems(path: str) -> list[list[str]]: data = json.load(open(path, encoding="utf-8")) poems = [] for item in data: raw = item.get("content", "") lines = [] for line in raw.strip().splitlines(): line = re.sub(r"[,。;!?、\s]", "", line) # 只保留五言和七言,过滤掉题目与注释 if re.fullmatch(r"[\u4e00-\u9fff]+", line) and len(line) in (5, 7): lines.append(line) if len(lines) >= 4: poems.append(lines[:4]) return poems这里有两个参数需要留意。一个是len(line) in (5, 7),古诗也有六言和杂言,如果语料里混着词,这个过滤会让数据大量丢失;处理词牌时应该放宽长度,并额外保存词牌名作条件。另一个是lines[:4],只取前四句是为了保证结构完整,避免读到一个残句当完整样本。我见过有人把每首诗的全部句子拆开,结果下一句经常跨首跨阕,模型学到的其实是断句而不是接龙。
接下来构建训练对。我把两种接龙方式都生成出来:
def build_pairs(poems: list[list[str]]) -> list[tuple[str, str, str]]: by_head = defaultdict(list) for poem in poems: for line in poem: by_head[line[0]].append(line) pairs = [] for poem in poems: for line in poem: # 字头接龙:目标句以源句末字开头 for tgt in by_head[line[-1]][:8]: if tgt != line: pairs.append((line, tgt, "char_jielong")) for poem in poems: for src, tgt in zip(poem, poem[1:]): pairs.append((src, tgt, "semantic")) return pairsby_head是“首字索引”,查一次就能拿到所有以某字开头的真诗句。:8是每个源句最多取 8 个候选,避免某些高频字头生成上千条冗余数据。semantic那条是同一首诗内的相邻句,让模型在字头约束失效的时候还能靠语义续写兜底。两种数据混训时,我会在 batch 里把 mode 也拼进特征,推理时再告诉模型当前走哪种路线。
2.2 给训练对打上格律标签:平仄、韵部、字数
模型生成一句诗之后,最难的不是判断“像不像诗”,而是判断“押不押韵、平仄对不对”。押韵判断不能直接拿汉字,要先把每个字转成拼音,再去掉声调取韵母,最后映射到韵部。我用的是新韵的宽韵划分,不是平水韵;如果你要严格按古韵做,只需要替换韵部映射表,代码骨架不变。
def rhyme_group(final: str) -> str: # final 是不带声调的韵母 v = { "a": "麻", "ia": "麻", "ua": "麻", "o": "波", "e": "波", "uo": "波", "ie": "皆", "üe": "皆", "ai": "开", "uai": "开", "ei": "微", "ui": "微", "ao": "豪", "iao": "豪", "ou": "尤", "iu": "尤", "an": "寒", "ian": "寒", "uan": "寒", "üan": "寒", "en": "痕", "in": "痕", "un": "痕", "ün": "痕", "ang": "唐", "iang": "唐", "uang": "唐", "eng": "庚", "ing": "庚", "ong": "东", "iong": "东", "u": "姑", "i": "齐", "ü": "齐", "er": "支", } return v.get(final, "其他")这张表是按现代普通话的八呼十五韵压缩的。比如eng和ing我归到同一个“庚”部,这在很多韵书里是两类;这样处理是为了宽韵匹配,接龙更容易出候选。真正部署前你要决定一个原则:追求押韵准确率就分细,追求候选数量就分宽。
from pypinyin import pinyin, Style def encode_line(line: str) -> tuple[str, str, int]: info = [] for py in pinyin(line, style=Style.TONE3, errors="ignore"): py = py[0] base = py[:-1] if py[-1].isdigit() else py tone = int(py[-1]) if py[-1].isdigit() else 0 info.append((base, tone)) tones = [t for _, t in info] # 一二声算平,三四声和轻声算仄 pz = "".join("平" if t in (1, 2) else "仄" for t in tones) last_rhyme = rhyme_group(info[-1][0]) return pz, last_rhyme, len(line)这里有个很现实的问题:pinyin默认返回的是现代读音的第一个声调,而古诗词里有大量多音字和特殊读法。这一版先把简单流程跑通,多音字的坑在第 5 章单独处理。errors="ignore"会让生僻字直接跳过,代价是平仄序列长度变短,后面模型训练时要用 OOV 标记补位。
2.3 划分训练/验证集合:别让同一首诗出现在两边
接龙数据和普通文本分类不同,同一首诗的四句话之间天然存在强相关。如果训练集里出现了“床前明月光”,验证集里又出现“疑是地上霜”,模型相当于已经见过答案,验证分数会虚高。正确做法是按诗分组切分,而不是按样本切分。
from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) poem_ids = [pair_poem_id(p) for p in pairs] train_idx, val_idx = next(gss.split(pairs, groups=poem_ids)) train_pairs = [pairs[i] for i in train_idx] val_pairs = [pairs[i] for i in val_idx]GroupShuffleSplit的groups我传的是每一条样本所属的诗 id。这种方式切分出来,验证集里出现的诗不会在训练集里以任何形式漏出。你还可以做得更严:字头接龙样本的目标句来自另一首诗,那首诗的其余句子也不该出现在训练集。我在工程里把这种诗级去重写成了单独的过滤步骤,比分组切分更彻底。
3. 接龙模型的两种路线:检索基线与字符级序列生成
3.1 先用检索做基线:接龙索引与它的瓶颈
先别急着上神经网络。这个项目最值得先做的一个基座,是以“首字索引”为核心的检索。它的逻辑非常朴素:用户句子最后一个字是“光”,就去诗库里找所有以“光”开头的真实诗句,随机抽一句返回。这能保证两件事:首字一定对上,句子本身也是真实古诗。
class JielongIndex: def __init__(self, lines: list[str]): self.head_index = defaultdict(list) for line in lines: self.head_index[line[0]].append(line) def retrieve(self, line: str, topk: int = 5, shuffle: bool = True) -> list[str]: candidates = self.head_index.get(line[-1], []) if shuffle: random.shuffle(candidates) return candidates[:topk]topk控制返回几个候选;shuffle=True是默认推荐,因为索引里的句子按语料顺序排列,不做随机的话每次结果都偏向同一个作者风格。检索基线的瓶颈你也一眼能看出来:它只会返回原作,无法针对用户的整句话做语义衔接。“今人不见古时月”的末字是“月”,检索可能返回“月落乌啼霜满天”,平仄和押韵都对,但和“古时月”毫无意象关系。这个基线用来做系统自检、评估后续模型有没有进步,非常有用。
3.2 字符级GRU生成:条件序列到序列的搭建要点
检索基线之后,我用字符级 GRU 做了生成模型。选 GRU 是因为它在语料量几千首的规模下足够稳,训练速度远快于 Transformer,而且字符级输入天然解决了词表爆炸的问题。模型结构是 encoder-decoder,编码器读入用户的整句话,解码器逐字生成接句。
我在输入侧做了一个关键设计:把约束信息拼进 source。训练时 source 变成:
用户原句 + [SEP] + 目标句首字 + 目标句尾字韵部 + 目标句长度推理时目标句首字取用户句子末字,韵部取用户句子末字所属韵部,长度默认在 5 和 7 之间选一个更接近用户原句长度的值。这样模型不是盲目续写,而是明确知道要生成“以某字开头、押某韵、固定多少字”的句子。
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4) for batch in loader: src, tgt, cue = batch logits = model(src, tgt, cue) # teacher forcing 训练 loss = F.cross_entropy( logits[:, :-1].reshape(-1, vocab_size), tgt[:, 1:].reshape(-1), ignore_index=vocab.pad_id ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()cue就是首字、韵部和长度组成的条件向量。ignore_index=vocab.pad_id保证 padding 位置不参与 loss 计算。clip_grad_norm_的max_norm=1.0是个安全参数,GRU 训练到后期很容易梯度爆炸,不加这一行,你可能训到第 10 个 epoch loss 突然变成 NaN。学习率3e-4适合这种小规模字符级模型,如果数据量只有几千首,可以再降到1e-4并加长训练轮数。
训练完成后,解码阶段的采样参数同样关键。我常用的默认值是temperature=0.9、top_k=20。temperature 太高会生成一堆乱字,太低会反复出现“一”“不”“人”这类高频字;top_k 通常保持在 15 到 30 之间,它主要防尾部概率里的生僻字突然冒出。
4. 把押韵与平仄从评分变成解码约束
4.1 韵部判定与押韵候选字表
很多生成模型是先把句子生成出来,再去检查押韵,不合格就重新生成。这样浪费算力,而且十次里能中三四次就算运气好。更可靠的做法是提前把尾字限制在目标韵部的候选字集合里,解码到最后一个字符时只允许这个集合内的字出现。
def build_rhyme_vocab(lines: list[str], vocab) -> dict[str, list[int]]: rhyme_chars = defaultdict(list) for line in lines: r = rhyme_group(line[-1]) if line[-1] not in rhyme_chars[r]: rhyme_chars[r].append(line[-1]) for r, chars in rhyme_chars.items(): rhyme_chars[r] = [vocab.stoi[c] for c in chars if c in vocab.stoi] return rhyme_chars这里我用真实诗库的尾字来构建候选,而不是用全量汉字表。原因很实际:一个字即使属于某韵,如果它从没出现在诗句末尾,放进候选只会让模型生成一个不像诗的字。候选字数量也会影响解码质量,一个韵部有几百个候选是正常的,但如果超过一千,我会按字频截断,只保留最高频的 200 个。
4.2 平仄校验与句式模板匹配
平仄校验的核心是把每个字变成“平”或“仄”,再去跟目标模板做模糊匹配。古诗讲究“一三五不论、二四六分明”,意思是七言句第 1、3、5 字可以放宽,第 2、4、6 字必须严格;五言句则是第 1、3 字放宽,第 2、4 字严格。
| 诗句长度 | 必须严格的位置 | 可放宽的位置 |
|---|---|---|
| 五言 | 第 2、4 字 | 第 1、3 字 |
| 七言 | 第 2、4、6 字 | 第 1、3、5 字 |
我在实现里做了两个不同的平仄函数:一个严格匹配全部位置,一个按上表放宽。重排候选时用放宽版,人工评估时才看严格版,否则系统会显得过于苛刻,把大量语义不错的句子误杀。
def pz_score(line: str, target_pattern: str | None) -> float | None: if target_pattern is None: return None pz = get_pz(line) # 七言可放宽位置是第1、3、5字;五言是第1、3字 flexible = {0, 2, 4} if len(line) == 7 else {0, 2} hit = 0 for i in range(len(line)): if len(target_pattern) <= i: break if i in flexible: hit += 1 elif pz[i] == target_pattern[i]: hit += 1 return hit / len(line)target_pattern从哪来?我的做法是取用户原句的平仄,按照律诗“对”的规则取反作为目标。比如用户句是“平平仄仄平”,目标句的平仄就应为“仄仄平平仄”。这个规则并不永远成立,接龙游戏里用户可能随便给一句现代话,所以我把pz_score当成加权评分的一项,而不是硬性条件。
4.3 约束解码与重排:生成一批,剩下一句
把押韵限制真正塞进解码器,代码并不复杂。核心是维护一个 mask,在特定解码位置截断 logits:
def decode_with_constraint(model, state, first_id, rhyme_ids, max_len): output = [first_id] for step in range(1, max_len): logits = model.decode_step(state, output[-1]) if step == max_len - 1: # 最后一步只能在韵部候选字里选 logits = mask_to_ids(logits, rhyme_ids) else: logits[output[-1]] -= 1.5 # 简单重复惩罚 logits = logits / 0.9 probs = F.softmax(logits, dim=-1) output.append(torch.multinomial(probs, 1).item()) return outputfirst_id在严格字头接龙时传入用户原句末字的 id;rhyme_ids来自上一步构建的韵部候选表。mask_to_ids的做法是把不在候选集合里的 logits 全部设为负无穷,这样 softmax 之后概率自然归零。最后一步只允许韵部候选字出现,这一步就是整个系统押韵准确率能超过 90% 的原因。
生成多个候选后,还需要一个重排函数。我的评分是三项加权:语言模型概率占 0.5,押韵命中占 0.3,平仄匹配占 0.2。如果你做的是严格律诗接龙,建议把押韵权重提到 0.6,平仄保持 0.2。权重的直觉是:模型概率高代表句子通顺,押韵和平仄决定它像不像诗。
5. 接龙项目的5个高频坑:现象、原因、解决
5.1 押韵检查把“白”“屋”全判错:古入声字在现代拼音里的跳跃
现象:模型生成的句子末字是“白”,押韵检查判断它和“斋”同属“开”韵,很顺畅地通过了;但读起来总有一股现代味,跟真正的古诗格律不在一个体系里。
原因:现代普通话里没有入声字了,古入声字被派进四声,其中“白”“国”“竹”“菊”这类进了平声。项目如果按新韵做,这些字判平没问题;但如果目标是仿古诗,这些字在平水韵里全是仄。很多初学者第一次跑通就栽在这。
解决:维护一张古入声字表,在get_pz里做优先判断。只要字符命中表,强制返回仄。这不是稀疏处理,常用入声字也就几百个,够用了。
RU_SHENG = {"白", "石", "竹", "菊", "惜", "昨", "织", "折", "德", "屋"} def get_pz(line: str) -> str: pz_list = [] for ch in line: if ch in RU_SHENG: pz_list.append("仄") continue # 正常拼音声调判断 ...5.2 模型翻车式重复同一个尾字
现象:解码输出的 5 条候选里,有 3 条都以“风”结尾,甚至整句结构都大同小异;top_k 从 20 调到 5 之后更严重。
原因:小模型在字符级生成时很容易陷入局部循环,尤其当训练语料里某个韵部的尾字分布过于集中。你设了 top_k,但如果 top_k 太小,高频字永远把低频字挤掉。
解决:解码时加一个可调的重复惩罚,把已经生成过的字对应的 logits 降低。算法上只对当前句内出现过的字惩罚,不要全局惩罚,否则训练时见过的高频字永远低概率。惩罚值 1.2 到 1.8 之间相对安全,低于 1 基本没效果,超过 2 会让句子变得支离破碎。
5.3 训练集和验证集同诗泄漏:指标好得离谱
现象:验证集押韵准确率 96%,平仄准确率 90%,但模型实际生成的新句子一塌糊涂;换一批人工写的句子测试,分数直接掉到 40%。
原因:切分数据时直接random.shuffle(pairs),同一首诗的相邻句被分进训练集和验证集。模型见过验证集里的目标句,得分自然虚高。
解决:用第 2.3 节的GroupShuffleSplit,按诗 id 切分。更严格一点,要把字头接龙的目标句所在的那首完整诗也从训练集剔除。这个坑我几乎每个项目都会强调一次,因为它是“看起来调好了、实际一测就废”的头号元凶。
5.4 多音字让平仄计算时好时坏
现象:“斜”在“远上寒山石径斜”里读 xiá,平仄是平;pypinyin 默认返回 xié,结果算成仄。同类的还有“看”“思”“教”等。
原因:pinyin默认按词频返回第一个读音,而古诗存在大量的历史读音。单纯靠拼音库不能解决,因为读音取决于上下文和时代。
解决:给校验模块加一个人工维护的POEM_READING表,命中优先。这张表不用求全,覆盖训练语料中频率最高的 20 个常用多音字即可,我实际维护下来大约 40 个字就覆盖了大部分样本。对没覆盖到的字,在重排阶段把多个读音都算一遍,取分数最高的那个结果,不直接判错。
POEM_READING = { "斜": "xia2", "教": "jiao1", "思": "si1", }5.5 用户原句末字是“的”“了”:接龙索引直接查空
现象:用户输入“春风又绿江南岸的”,末字是“的”,检索索引返回空,生成模型也因为训练语料极少出现“的”字开头的诗句而开始胡编。
原因:接龙规则要求目标句以源句末字开头,但当末字是虚词时,古诗语料里根本没有符合字头约束的真实样本。这是一个典型的边界输入。
解决:做一次词性或常用虚词判断。当末字命中{"的", "了", "着", "呢", "吗"}时,放弃字头约束,改用倒数第二个实词作为接龙字;如果整句都是虚词,就退回成“语义续写”模式,只押韵、不锁首字。这个 fallback 写起来只要十行,但直接影响用户体验,也是我踩过最尴尬的线上问题。
6. 接龙接口与评测:路由、三件套验证和一个使用习惯
6.1 一个最小 FastAPI 路由与 CLI 调用
模型训练完,最重要的事情是把它变成一个可调用的接口。我用 FastAPI 封装了一个路由,请求参数包括用户句子、接龙模式、返回候选数,以及可选的韵部覆盖。韵部默认自动取用户句子末字,但接口层保留覆盖能力,方便后面做“指定韵部写诗”的功能。
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class JielongRequest(BaseModel): line: str mode: str = "char_jielong" n: int = 5 rhyme: str | None = None @app.post("/jielong") def jielong(req: JielongRequest): rhyme = req.rhyme or rhyme_group(req.line[-1]) candidates = engine.generate( req.line, mode=req.mode, n=req.n, rhyme=rhyme, ) return {"candidates": candidates, "rhyme": rhyme}调用方式用 curl 即可验证:
curl -X POST http://127.0.0.1:8000/jielong \ -H "Content-Type: application/json" \ -d '{"line": "江上清风", "n": 3}'我在线上部署时会把engine对象做成全局单例,预热模型权重,避免每个请求都重新加载。字符级 GRU 模型体量小,CPU 上单条推理也就几十毫秒,完全不需要 GPU。
6.2 三件套评测:韵部命中率、平仄准确率与人工盲评
自动评测我固定用三个指标:首字命中率、韵部命中率、平仄放宽匹配率。每个指标单独看都有欺骗性,放在一起能快速判断一次迭代是否有效。
| 指标 | 计算方式 | 及格线 |
|---|---|---|
| 首字命中率 | 目标句首字是否等于源句末字 | 99% |
| 韵部命中率 | 目标句尾字是否落在指定韵部 | 95% |
| 平仄匹配率 | 放宽规则下与目标模板的匹配比例 | 80% |
这三个指标之外,我还会定期做一轮人工盲评:从生成结果和真实古诗里各取同等数量,让同事按“像古诗”和“语义连贯”两个维度打分。自动指标能抓工程问题,人工指标才决定这个接龙系统能不能真的拿出手。盲评时注意不要让人知道哪些是模型生成,否则打分会有先入为主的偏见。
我自己的使用习惯是:每次迭代训练完,固定用五句不同风格的源句生成候选,打印出来逐条看。模型可能会在自动指标上全绿,但一眼望去五条全是“东风万里”式的套话,这种问题只有人眼能发现。收到反馈后,我通常会把重复惩罚调大、把重排权重调偏,再跑一轮。这个习惯帮我避免了很多“指标好看、实际没法用”的版本,也希望帮到你。
本文还有配套的精品资源,点击获取