news 2026/11/6 5:28:25

Python古诗词接龙:检索、打分与N-gram兜底实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python古诗词接龙:检索、打分与N-gram兜底实践

简介:这份项目包面向希望用Python完成自然语言处理实战的初中级开发者,围绕“诗歌接龙”场景整合文本分词、拼音转换、韵脚匹配与接龙算法等内容,可用于课程设计、练手项目或NLP流程入门。压缩包共17个文件,约5.86MB,包含4个py源代码文件、4个xml配置/数据文件,以及pk、dat模型数据、docx说明文档、txt与exe等辅助文件,源码、数据和运行材料相对齐全。已有499人学习下载,说明该案例具备一定参考价值。读者可结合说明文档梳理项目背景与功能模块,阅读源码理解pypinyin、jieba等工具在音韵匹配和文本处理中的用法,并观察基于规则或统计方法的接龙实现思路,从而把NLP基础知识落到可运行的小项目里。

1. 诗歌接龙这个需求,用 Python 做起来比想象中讲究

一个做课件工具的朋友把压缩包丢过来,需求一句话:输入一句诗,自动往下接三句。我第一反应是这也太简单了,python 里把古诗词切成句子,建个「首字 → 句子」的字典,拿上一句的最后一个字去查不就行了。真跑起来才发现,NLP 里最难的一环从来不是「能不能查到」,而是「查到的像不像」。严格按字接,三步之内必断;放宽到按韵接,又接出一堆前言不搭后语的东西。这个诗歌接龙项目把整条链路拆成了预处理、索引、检索、生成、打分五段,每段都留了可调参数,属于「跑通十分钟、调好一个月」的类型。下面按我拆包的顺序,从目录结构一路讲到怎么把它改成藏头诗生成器。

2. 项目骨架与技术选型:先看它凭什么只用 Python 就能跑

2.1 解开压缩包后看到的目录结构

先在干净目录里解压,别直接扔进你那个装了几十个依赖的老环境,后面出问题不好定位。解开后顶层目录大致是这样:

poem_relay/ ├── app/ │ ├── __init__.py │ ├── engine.py # 接龙主流程,对外唯一入口 │ ├── matcher.py # 检索层,负责「按字/音/韵」找候选 │ ├── generator.py # 字符级 N-gram,检索不到时的兜底 │ ├── scorer.py # 候选打分与重排 │ ├── phonology.py # 平仄、韵部、同音字 │ └── cli.py # 命令行入口 ├── scripts/ │ ├── normalize.py # 原始语料清洗成 jsonl │ └── build_index.py # jsonl 灌进 sqlite 并建索引 ├── data/ │ ├── raw/ # 放原始 txt,自己往里塞 │ └── index/ # 生成物:lines.jsonl、poems.db ├── configs/ │ └── default.yaml ├── tests/ └── requirements.txt

这个分层不是摆样子,它对应一条非常明确的数据流:data/raw里的原始文本被normalize.py洗成一行一句的lines.jsonl,build_index.py再把它连同算好的韵部、平仄串一起灌进poems.db。运行期engine.py只碰poems.db,完全不碰原始文本。好处是原始语料你可以随时换、随时加,重建索引就行,运行侧代码一行不用改。

data/index/是生成物,不该进版本库。我第一次拆的时候没注意,lines.jsonl有几十兆,poems.db更大,git 一提交就后悔了。建议解压完第一件事就是看一眼有没有.gitignore,没有就自己补上data/index/和__pycache__/。

技术选型上,这个项目刻意保持得很轻:语料存储用 sqlite(标准库自带,不用起服务,单文件好分发),拼音和平仄用pypinyin,HTTP 服务用FastAPI,配置用PyYAML。没有 transformers,没有 torch,也没有向量库。这个选择在小体量接龙场景下是对的——你要的是「一定接得上、接得像」,不是「生成从没见过的新句子」。真要上大模型,那是最后一章改造的事。

2.2 依赖清单与首次启动

requirements.txt里的核心就四个:

pypinyin fastapi uvicorn pyyaml

装依赖时我一般先建虚拟环境再装,避免跟系统 Python 打架:

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt

这里不写死版本号是有意的,pypinyin的Style.FINALS在不同版本上对ü的输出格式略有差别,锁死版本反而容易在你机器上装不上。真遇到韵部算错,先怀疑版本差异,再看代码。

启动顺序是固定的,不能跳步:先清洗语料,再建索引,最后才跑 CLI。因为engine.py启动时会直接sqlite3.connect("data/index/poems.db"),库文件不存在就直接抛异常,报错信息还不一定清楚。

python scripts/normalize.py # data/raw/*.txt -> data/index/lines.jsonl python scripts/build_index.py # lines.jsonl -> data/index/poems.db python -m app.cli --start "春眠不觉晓" --steps 3

正常跑通的话,终端会连着打印起句和接出来的三句。如果只打印了起句就结束,说明检索层一条候选都没找到,先跳到第 5 章看命中率那一节。如果直接报no such table: lines,那是索引没建成功,回上一步看build_index.py的输出。

2.3 语料从哪来、索引怎么落盘

项目本身不带语料,data/raw/是空的,这点得说清楚,很多人解压完发现跑不起来就是因为这个。常见做法是找一份公开的古诗词合集文本,按「一行一首」或者「一行一句」的格式丢进去,一行一首还是多行一首都不影响,normalize.py里做了兼容,它只按标点切句,不关心原始换行。

清洗逻辑在scripts/normalize.py里,核心三步:去噪、切句、过滤。

# scripts/normalize.py(节选) import re from pathlib import Path NOISE_RE = re.compile(r"[\u200b-\u200f\ufeff\u3000\s]+") SENT_SPLIT_RE = re.compile(r"[,。!?;:、,.!?;:]") HANZI_RE = re.compile(r"^[\u4e00-\u9fff]+$") def iter_lines(raw_text: str): raw_text = NOISE_RE.sub("", raw_text) for seg in SENT_SPLIT_RE.split(raw_text): seg = seg.strip() # 只留纯汉字片段,拼音注释、序号、书名号全在这一步被丢掉 if not HANZI_RE.match(seg): continue # 以五言、七言为主,但允许四言到十六言的宽区间,别一刀切死 if not (4 <= len(seg) <= 16): continue yield seg

NOISE_RE里那几个 Unicode 区间是有讲究的:\ufeff是 BOM,\u200b-\u200f是零宽字符,\u3000是全角空格。这些东西肉眼看不见,但会让seg[0]拿到一个非汉字,整个接龙就断在第一步。SENT_SPLIT_RE里中文标点和英文标点都列了,因为语料来源往往混杂。

长度区间4 <= len(seg) <= 16是经验值。限得太严(比如只留五言七言)会丢掉大量词和古风句子,候选池一小,接龙三步就断;放得太宽又会混进大段散文,接出来的东西语感全无。我一般先用这个宽区间跑一遍,看看库里有多少行,再决定要不要收紧。

建索引这边用 sqlite,表结构很朴素,但每一列都有用途:

# scripts/build_index.py(节选) conn.execute(""" CREATE TABLE lines( id INTEGER PRIMARY KEY, text TEXT NOT NULL, head TEXT NOT NULL, -- 首字,接龙入口 tail TEXT NOT NULL, -- 尾字,接龙出口 pinyin TEXT, -- 首字拼音,同音回退用 rhyme TEXT, -- 尾字韵部,押韵判断用 pattern TEXT, -- 整句平仄串,如 PPZZP title TEXT, author TEXT )""") conn.execute("CREATE INDEX idx_head ON lines(head)") conn.execute("CREATE INDEX idx_tail ON lines(tail)") conn.execute("CREATE INDEX idx_pinyin ON lines(pinyin)") conn.execute("CREATE INDEX idx_rhyme ON lines(rhyme)")

head和pinyin两个索引是给检索层用的,rhyme索引是给「按韵回退」和打分用的,pattern使用频率最低,但做平仄校验时不必再算一遍拼音。索引建完记得VACUUM一次,sqlite 删删改改之后文件会虚胖,VACUUM能把体积压回去三成左右。

3. 核心链路拆解:一句诗从进入到接出下一句,中间过了几道手

3.1 预处理:清洗、切句、抽尾字与韵部

预处理除了切句,还有一件容易被忽略的事:把每个字的读音信息提前算好存起来。这一步放在建索引阶段而不是运行期,是因为pypinyin单次调用虽然快,但一句诗七八个字、候选池上万句,运行期现算会把响应时间从毫秒级拖到秒级。

音韵模块单独放在app/phonology.py,两个函数加两张表。第一张是韵母到韵部的归并表:

# app/phonology.py(节选) from functools import lru_cache from pypinyin import lazy_pinyin, Style RHYME_GROUPS = { "a": "a", "ia": "a", "ua": "a", "o": "o", "e": "o", "uo": "o", "ie": "o", "ve": "o", "ai": "ai", "uai": "ai", "ei": "ei", "ui": "ei", "ao": "ao", "iao": "ao", "ou": "ou", "iu": "ou", "an": "an", "ian": "an", "uan": "an", "van": "an", "en": "en", "in": "en", "un": "en", "vn": "en", "ang": "ang", "iang": "ang", "uang": "ang", "eng": "eng", "ing": "eng", "ong": "eng", "iong": "eng", } @lru_cache(maxsize=20000) def rhyme_of(char: str) -> str: finals = lazy_pinyin(char, style=Style.FINALS, strict=True)[0] finals = finals.replace("ü", "v").replace("u:", "v") return RHYME_GROUPS.get(finals, finals)

lru_cache那行是关键。汉字总共几千个,缓存命中率极高,加了之后建索引的时间能砍掉一半以上。strict=True是必须的,不加的话y、w开头的音节不会被还原成i、u,月和夜这种本该同韵的字会被判成不同韵部。

第二张表是平仄判定,用声调数字做粗判:

@lru_cache(maxsize=20000) def tone_of(char: str) -> str: """P=平 Z=仄 ?=存疑(非汉字或取音失败)""" if not ("\u4e00" <= char <= "\u9fff"): return "?" py = lazy_pinyin(char, style=Style.TONE3, neutral_tone_with_five=True)[0] digit = py[-1] if digit in "12": return "P" if digit in "345": return "Z" return "?"

一二声归平、三四声归仄,这是最粗的规则,古音里的入声字早就分不出去了。所以项目里平仄只作为打分的加分项,不作为硬约束——把它当硬约束,候选池会瞬间塌缩到个位数。返回?而不是抛异常,是因为总有生僻字取不到音,这时候宁可不加分也别让整个流程挂掉。

3.2 检索层:两张倒排表撑起「字接字」

检索层在app/matcher.py,对外就三个方法,对应三级回退。第一级最直接:

# app/matcher.py(节选) class Matcher: def __init__(self, db_path: str): # check_same_thread=False 是为 FastAPI 多线程准备的,后面第 5 章会讲坑 self.conn = sqlite3.connect(db_path, check_same_thread=False) self.conn.row_factory = sqlite3.Row self._lock = threading.Lock() def by_head(self, char: str, limit: int = 40): with self._lock: cur = self.conn.execute( "SELECT * FROM lines WHERE head = ? ORDER BY RANDOM() LIMIT ?", (char, limit), ) rows = cur.fetchall() return [self._to_cand(r, "exact") for r in rows]

ORDER BY RANDOM() LIMIT ?这一句看着随意,其实决定了接龙体感。如果按 id 排序取前 40 条,同样的输入永远得到同样的输出,多接几次就腻了。随机取样保证同一个起句每次接出来的结果有差异,但差异又限定在 40 条这个池子里,不会跑飞。

第二级是同音回退。古诗词里以字接字的天然概率其实不高,尤其是尾字是「风」「中」「人」这类高频字时,候选多到溢出;而尾字是「醉」「瑟」这类字时,严格按首字查经常一条都没有。

def by_pinyin(self, char: str, limit: int = 40): py = lazy_pinyin(char, style=Style.NORMAL)[0] with self._lock: cur = self.conn.execute( "SELECT * FROM lines WHERE pinyin = ? ORDER BY RANDOM() LIMIT ?", (py, limit), ) rows = cur.fetchall() return [self._to_cand(r, "homophone") for r in rows]

用Style.NORMAL取不带声调的拼音,这样「舟」可以接到「州」「周」开头的句子。回退层级用一个matched_by字段标出来,后面打分时同级回退要扣分——同音接上总比同韵接上更像回事。

第三级是同韵回退,问的是「上一句尾字的韵部,有哪些句子的尾字也在这个韵部」:

def by_rhyme(self, rhyme: str, limit: int = 40): with self._lock: cur = self.conn.execute( "SELECT * FROM lines WHERE rhyme = ? ORDER BY RANDOM() LIMIT ?", (rhyme, limit), ) rows = cur.fetchall() return [self._to_cand(r, "rhyme") for r in rows]

注意这里查的是尾字韵部,不是首字。意思是「接不上字,那我至少让新句子跟上一句押韵」,读起来还有点诗的骨架。三级回退的顺序不能乱:字 → 音 → 韵 → 生成,每降一级,风格一致性就差一档,但接得上的概率高一大截。

3.3 生成层:字符级 N-gram 兜住检索不到的情况

三级回退全空的时候(语料小、生僻尾字、或者你只灌了几百首诗),就得靠生成层。app/generator.py里是一个字符级 N-gram,默认三元:

# app/generator.py(节选) class CharNGram: def __init__(self, n: int = 3): self.n = n self.model = defaultdict(Counter) def fit(self, lines): n = self.n for line in lines: padded = "^" * (n - 1) + line + "$" for i in range(len(padded) - n + 1): gram = padded[i:i + n - 1] nxt = padded[i + n - 1] self.model[gram][nxt] += 1 return self def sample_next(self, prefix: str, temperature: float = 1.0): gram = prefix[-(self.n - 1):] counter = self.model.get(gram) if not counter: return None chars = list(counter) # 温度越低越保守,越高越发散;1/temperature 是常见的退火写法 weights = [counter[c] ** (1.0 / max(temperature, 0.05)) for c in chars] return random.choices(chars, weights=weights, k=1)[0] def gen_line(self, start: str, max_len: int = 7, temperature: float = 1.1): out = start while len(out) < max_len: nxt = self.sample_next(out, temperature) if nxt is None or nxt == "$": break out += nxt return out

几个细节值得说。句首句尾补^和$是标准做法,不补的话模型永远学不会「一句诗怎么开头、怎么收尾」,生成出来的句子会一直延伸下去。temperature通过** (1/temperature)作用在计数上,温度取 1.0 是原始分布,取 0.7 会明显更保守、更像原句,取 1.5 就容易出怪句子。默认给 1.1 是个折中值。

生成层只在检索全空时启用,而且生成出来的句子还要过一遍打分层,分数太低会被丢弃、直接结束接龙。这是有意的设计:宁可在第三句停下,也别硬凑一句读不通的。我在自己的环境里试过把生成阈值调低,结果接龙能接十句,但第七句开始基本是乱码,血泪经验。

3.4 打分重排:把「像诗」拆成可计算的几项

候选拿到手是一堆无序的句子,得排个序。app/scorer.py里的权重用一个 dataclass 装,方便配置覆盖:

# app/scorer.py(节选) @dataclass class Weights: link: float = 3.0 # 首字严格等于上一句尾字 rhyme: float = 2.0 # 与上一句尾字同韵部 tone: float = 1.0 # 首字与上一句尾字平仄相对 length: float = 0.8 # 与上一句字数接近 repeat: float = -6.0 # 命中已用过的句子,重罚 overlap: float = -0.4 # 与最近两句的字重叠,轻罚 def score(cand, prev: str, used: list[str], w: Weights) -> float: s = 0.0 if cand.head == prev[-1]: s += w.link if cand.rhyme == rhyme_of(prev[-1]): s += w.rhyme if tone_of(cand.head) != tone_of(prev[-1]) and "?" not in (tone_of(cand.head), tone_of(prev[-1])): s += w.tone if abs(len(cand.text) - len(prev)) <= 1: s += w.length if cand.text in used: s += w.repeat recent = set("".join(used[-2:])) s += w.overlap * len(set(cand.text) & recent) return s

repeat给到 -6.0 是有原因的:link才 3.0,重复惩罚必须远大于任何正向加分,否则一条既接得上又押韵的重复句照样能排第一,你会看到同一句诗反复出现。overlap用最近两句的字做交集,是为了压住「用字雷同」——不压的话,接出来的四句可能全在「月、风、花、夜」这几个字里打转。

重排完不是直接取第一名,而是在 top-5 里随机取一个:

ranked = sorted(cands, key=lambda c: score(c, prev, used, w), reverse=True) top = [c for c in ranked[:5] if c.text not in used] or ranked[:1] pick = random.choice(top)

这个 top-k 随机采样是整个项目里最便宜也最有效的一个技巧。取 top-1 会变成确定性输出,娱乐性归零;取 top-20 又会经常抽到明显不搭的句子。5 是试出来的手感值。

4. 引擎接口与可调参数:怎么把它当库用、当服务用

4.1 引擎入口:relay 方法签名与返回结构

app/engine.py是整个项目对外唯一需要关心的类,构造只要一个 db 路径:

# app/engine.py(节选) class PoemRelay: def __init__(self, db_path: str, weights: Weights | None = None, gen_temperature: float = 1.1): self.matcher = Matcher(db_path) self.weights = weights or Weights() self.gen = CharNGram(n=3) self.gen_temperature = gen_temperature self._gen_ready = False def relay(self, start: str, steps: int = 3, allow_generate: bool = True) -> list[str]: used = [start.strip()] for _ in range(steps): prev = used[-1] cands = self.matcher.by_head(prev[-1]) \ or self.matcher.by_pinyin(prev[-1]) \ or self.matcher.by_rhyme(rhyme_of(prev[-1])) if not cands and allow_generate: cands = self._fallback_generate(prev) if not cands: break ranked = sorted(cands, key=lambda c: score(c, prev, used, self.weights), reverse=True) top = [c for c in ranked[:5] if c.text not in used] or ranked[:1] used.append(random.choice(top).text) return used

返回的是一个list[str],第一个元素就是你传进去的起句,后面是接出来的句子。这样设计的好处是调用方不用额外拼装。如果中途接不下去,列表长度会小于steps + 1,不会抛异常——调用方需要自己判断长度,别假设一定接满。

allow_generate这个开关很实用。做演示、做课件的时候我会关掉它,保证输出全部来自真实语料,可溯源;做娱乐向应用时再打开,让接龙尽量接满。

4.2 CLI 与 HTTP 两种跑法

命令行入口在app/cli.py,参数不多但够用:

# app/cli.py(节选) def main(): ap = argparse.ArgumentParser(description="古诗词接龙") ap.add_argument("--start", required=True, help="起始诗句") ap.add_argument("--steps", type=int, default=3, help="往下接几句") ap.add_argument("--db", default="data/index/poems.db") ap.add_argument("--no-generate", action="store_true", help="关闭 N-gram 兜底") ap.add_argument("--seed", type=int, default=None, help="固定随机种子,便于复现") args = ap.parse_args() if args.seed is not None: random.seed(args.seed) engine = PoemRelay(args.db) for i, line in enumerate(engine.relay(args.start, args.steps, not args.no_generate)): print(("起句:" if i == 0 else f"第{i}句:") + line)

--seed是我自己加的一行,项目原始版本没有。理由很实际:调权重的时候如果每次结果都变,你根本判断不了改动是好是坏。固定种子之后,同一个起句在同一个种子下的输出稳定,改Weights里的某个值再跑一遍,差异就一目了然。这个习惯值得从第一天就养成。

当服务用就三行,FastAPI直接挂:

# app/server.py from fastapi import FastAPI from pydantic import BaseModel from app.engine import PoemRelay app = FastAPI(title="poem-relay") engine = PoemRelay("data/index/poems.db") class RelayReq(BaseModel): start: str steps: int = 3 allow_generate: bool = True @app.post("/relay") def relay(req: RelayReq): lines = engine.relay(req.start, req.steps, req.allow_generate) return {"lines": lines, "count": len(lines)}

启动命令是uvicorn app.server:app --host 0.0.0.0 --port 8000。engine在模块级初始化,全局共用一个连接,所以Matcher里必须有那把锁——这一点在第 5 章会展开讲,很多人第一次并发压测就栽在这儿。

4.3 配置项:动哪几个开关效果变化最明显

configs/default.yaml里的参数按影响程度排,我个人经验是这样的:

参数位置调大的效果建议区间
weights.link打分更倾向严格按字接,接不上的概率上升2.0 ~ 4.0
weights.rhyme打分更倾向押韵,语义连贯度可能下降1.0 ~ 3.0
weights.repeat打分重复句更难被选中,必须为较大负值-10 ~ -4
generator.n生成阶数越高越像原句,泛化越差2 ~ 4
generator.temperature生成越高越发散,越低越保守0.7 ~ 1.5

第一个该动的是repeat,不是link。很多人的直觉是「接不上就降低 link」,结果降完之后接上了,但接出来的是刚用过的那句。先把重复惩罚拉满,再谈别的。第二个该动的是generator.n:如果你的语料只有几万句,n=3 已经能明显看出过拟合,n=2 反而更稳。

配置加载部分很简单,yaml.safe_load读进来之后覆盖Weights的字段:

import yaml from app.scorer import Weights with open("configs/default.yaml", encoding="utf-8") as f: cfg = yaml.safe_load(f) w = Weights(**cfg.get("weights", {})) engine = PoemRelay(cfg["engine"]["db"], weights=w)

注意Weights(**...)这里,yaml 里的键名必须跟 dataclass 字段名完全一致,多一个键就TypeError。我第一次改配置时手滑加了个rhyme_bonus,报错信息是unexpected keyword argument,对着 yaml 找了五分钟才反应过来。

5. 排查与避坑:跑不通、接不上、接出来像废话

5.1 数据与编码类问题

现象:CLI 能跑,起句正常打印,但一句都接不上,relay返回的列表长度永远是 1。

原因:语料里混进了 BOM 或零宽字符。最常见的是从 Windows 环境拷过来的 txt,第一行开头带\ufeff;另一种是网页复制时带进来的\u200b。这类字符被HANZI_RE过滤掉了,但如果它们在字符串中间,切句之后seg[0]拿到的可能不是你以为的那个字。

解决:读文件时统一用encoding="utf-8-sig",它会把 BOM 自动吃掉;NOISE_RE里显式列出\ufeff和\u200b-\u200f区间。验证方法很土但有效:建完索引后跑一句 SQL,SELECT COUNT(*) FROM lines WHERE LENGTH(head) != 1,正常应该返回 0。返回非 0 就是有脏字符混进了首字列。

现象:索引建完,lines表里行数比原始文本里的句子数少了一大截。

原因:HANZI_RE用的是^[\u4e00-\u9fff]+$,这个区间不含扩展区的生僻字,也不含异体字。语料里带校勘符号、注音括号、或者把「裏」写成「里」的版本,都会被滤掉。

解决:先统计被滤掉的比例。加一行日志,把HANZI_RE不匹配的片段打到data/index/reject.log里,抽前 200 行看看是什么。如果只是零星符号,忽略;如果整段整段被滤,说明语料格式跟预期不符,得回去调整SENT_SPLIT_RE,而不是放宽汉字区间。

5.2 依赖与运行环境类问题

现象:pypinyin装上了,但同一句话每次建索引算出来的韵部不一样,或者「月」和「夜」被判成不同韵。

原因:Style.FINALS的strict参数行为在不同版本间有差异。不加strict=True时,拼音的声母还原规则不同,ü的输出可能是ü、v或u:三种形式之一。

解决:代码里统一做一次归一化,.replace("ü", "v").replace("u:", "v")这两句别省。更稳的做法是在build_index.py开头打印一次pypinyin.__version__和几个测试字的输出,把环境差异暴露在构建阶段,而不是等到运行期才发现韵部全错。

现象:FastAPI 服务单请求正常,两个人同时请求就报SQLite objects created in a thread can only be used in that same thread。

原因:sqlite3.connect默认check_same_thread=True,连接只能在创建它的线程里用。而 FastAPI 的同步路由跑在线程池里,每次请求可能落到不同线程。

解决:两个方向。一是check_same_thread=False加一把threading.Lock包住所有查询(项目里就是这么做的),简单但串行;二是每次请求新开连接,成本略高但并发更真。语料量在十万级的时候前者完全够用,别过度设计。

现象:跑着跑着 Python 进程内存飙到几个 G,机器开始卡。

原因:normalize.py里把整个lines.jsonl读成一个 list 再处理,或者engine.py把全表SELECT *拉进内存排序。句子数上百万的时候,光 Python 字符串对象就能吃掉几个 G。

解决:流式读取,for line in open(...)逐行处理,写 jsonl 也用追加模式。排序只在候选集(几十到几百条)里做,绝不全局排序。sqlite 那边的LIMIT一定要带,别图省事写fetchall()不带限制。

5.3 效果类问题:接得上但不像诗

现象:能接满四句,但读起来像词语接龙,完全没有诗的语感。

原因:Weights里link权重过高,压过了rhyme和tone。系统在拼命保证「首字接得上」,代价是放弃了所有其他约束。

解决:把link从 3.0 降到 2.0,rhyme提到 2.5,同时打开tone。然后固定--seed跑 20 个起句,人工看一遍。我的经验是:rhyme只要略高于link,输出就会有明显变化,因为押韵带来的「像诗感」比字面接龙强得多。

现象:接出来的句子跟上一句毫无关系,情绪、场景全断。

原因:第三级「按韵回退」命中太多,matched_by="rhyme"的候选跟上一句只是押韵,语义上完全无关。项目里对回退层级没有区分扣分。

解决:在打分函数开头按matched_by加一个层级惩罚,exact加 0,homophone减 0.5,rhyme减 1.5,generated减 3.0。这样只有在字接不上时才会退到押韵,而且一旦有更好的候选就能压过去。这条改动很小,但对输出质量的影响是肉眼可见的。

现象:同一句诗在一次接龙里出现两次,或者连续几次调用结果高度雷同。

原因:repeat惩罚不够狠,或者候选池太小导致top-5里去重后只剩一两条。

解决:repeat给到 -6.0 以下,并且在top里做一次硬过滤,重复句直接不进候选。同时检查候选池大小:如果by_head返回经常小于 5 条,说明语料太少或索引没建全,这时候调权重是治标。

6. 二次开发:把接龙引擎改成藏头诗、对联和风格化生成

接龙只是这个引擎最基础的一种用法,把「约束」和「打分」拆开看,它能做的事情不少。藏头诗是最容易改的一个,因为约束从「尾字接首字」变成了「指定每一句的首字」:

def gen_acrostic(self, chars: str, per_line: int = 7) -> list[str]: out, prev = [], None for ch in chars: cands = self.matcher.by_head(ch, limit=200) if not cands: cands = [self.gen.gen_line(ch, max_len=per_line)] cands = [self._to_cand(t) for t in cands] if prev is not None: cands = sorted(cands, key=lambda c: score(c, prev, out, self.weights), reverse=True) pick = cands[0] out.append(pick.text) prev = pick.text return out

跟接龙的区别就两处:候选来源从「上一句尾字」换成「你指定的字」,并且因为首字被锁死了,link这一项打分的意义就没了,实际起作用的是rhyme、tone、length和overlap。想让藏头诗更有整体感,overlap得调成正数——让四句共享一些意象字,读起来才像一首诗而不是四句拼贴。

对联方向要更复杂一点,核心是「对仗」。接龙只要求字数一致、平仄相对,对联还要求词性对齐。做法是在建索引阶段多存一列词性串,用jieba.posseg逐字打标,然后把score扩展成位置敏感的版本:

def pair_score(a_pattern: str, b_pattern: str, pos_a: str, pos_b: str) -> float: s = 0.0 if len(a_pattern) != len(b_pattern): return -99.0 # 字数不等直接判死 for pa, pb, ta, tb in zip(pos_a, pos_b, a_pattern, b_pattern): if pa == pb: # 词性相同,加分 s += 0.6 if ta != tb and "?" not in (ta, tb): # 平仄相对,加分 s += 1.0 return s / len(a_pattern)

这段代码要跑起来还需要在索引里补词性列,工程量比藏头诗大。我的建议是先只做「字数相等 + 平仄相对」这两条,出来效果已经能唬人,再补词性。一上来就追词性对齐,容易卡在jieba对古汉语分词效果一般这件事上,翻车概率很高。

再往上一步是风格化。项目自带的 N-gram 只能模仿字面搭配,模仿不了风格。想让接出来的句子偏某个朝代、某个体裁,最直接的路径是把generator.py换成一个小的字符级 LSTM 或者微调过的轻量语言模型,接口保持gen_line(start, max_len, temperature)不变,上层代码一行不用动。这个替换点设计得很干净,也是我拆这个项目时最欣赏的一处:生成层是可插拔的,检索和打分完全不依赖它内部怎么实现。

改造完必须验证,不然你根本不知道是变好了还是只是变了个样。我一般跑一个四项指标的小评估:

def eval_relay(engine, seeds: list[str], steps: int = 4) -> dict: total = rhyme_ok = dup = 0 seen = set() for seed in seeds: lines = engine.relay(seed, steps) total += max(len(lines) - 1, 1) for a, b in zip(lines, lines[1:]): if rhyme_of(a[-1]) == rhyme_of(b[-1]): rhyme_ok += 1 for ln in lines[1:]: if ln in seen: dup += 1 seen.add(ln) return { "接满率": sum(len(engine.relay(s, steps)) - 1 for s in seeds) / (len(seeds) * steps), "韵脚一致率": rhyme_ok / total, <p> <a href="https://download.csdn.net/download/weixin_45204213/12011750" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 5:28:17

基于SharpPcap的C# ARP局域网设备发现与IP冲突检测

简介&#xff1a;面向使用 C# 进行局域网运维与网络监测的开发者和网管人员&#xff0c;这份资源围绕抓取 ARP 数据包来识别在线网络设备&#xff0c;并整理出存在 IP 冲突的设备列表&#xff0c;适合需要实现设备发现、冲突排查或底层抓包入门的中级读者参考。压缩包共 36 个文…

作者头像 李华
网站建设 2026/11/6 5:26:54

实验室管理系统源码包启动联调与毕业设计验收避坑指南

简介&#xff1a;这套实验室管理系统面向计算机相关专业正在准备毕业设计、课程设计或期末大作业的学生&#xff0c;也可供需要完整项目实战练习的开发者参考。项目采用Java语言与Spring Boot后端框架&#xff0c;数据库选用MySQL&#xff0c;开发工具为IntelliJ IDEA&#xff…

作者头像 李华
网站建设 2026/11/6 5:24:30

Docker 容器|Flink 消费 Kafka!自定义网络

接上一篇 bitnami/kafka 镜像踩坑&#xff01; 很多同学搭完 Kafka&#xff0c;启动 Flink 容器死活连不上&#x1f62d; 核心坑&#xff1a;容器之间不能写localhost&#xff01;&#xff01; Flink 和 Kafka 放在同一个 docker 自定义网络&#xff0c;直接用服务名通信&am…

作者头像 李华
网站建设 2026/11/6 5:23:24

【大数据毕设项目】基于MLP神经网络的居民高血压风险预测与可视化分析系统\基于机器学习的居民体检糖尿病及心血管疾病风险预测系统

文章目录 一、项目开发背景意义 二、项目开发技术 三、项目开发内容 四、项目展示 五、项目相关代码 六、最后 一、项目开发背景意义 系统围绕健康指标分析与风险预测两大核心目标&#xff0c;构建了涵盖数据管理、可视化分析与智能预测的综合服务平台。系统集成了多源异…

作者头像 李华
网站建设 2026/11/6 5:23:05

BI 报表使用率低的五个原因与排查清单

项目交付时一切正常&#xff0c;半年后系统却没人用了——这是 BI 实施里很常见的现象。多数不是模型或可视化的问题&#xff0c;而是分发和使用体验的问题。下面整理成一份排查清单&#xff0c;方便在上线前后自查。排查项 1&#xff1a;访问路径过长现象&#xff1a;业务同事…

作者头像 李华
网站建设 2026/11/6 5:22:46

LangChain学习:理解构建模块与核心组件

上一篇通过天气查询示例&#xff0c;跑通了一个能够调用工具的 Agent&#xff0c;对“用户提问、模型选择工具、程序执行函数、模型生成回答”的过程有了初步认识。接下来&#xff0c;我想进一步弄清这些功能背后的组件分工&#xff1a;模型怎样接收消息&#xff0c;提示词如何…

作者头像 李华