news 2026/9/13 18:23:15

自动写诗实战:从马尔可夫链到语言模型的格律约束生成

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
自动写诗实战:从马尔可夫链到语言模型的格律约束生成

简介:一份以自动写诗为核心的人工智能实验资源包,面向自然语言处理初学者、深度学习开发者及相关课程学员,涵盖数据准备、模型训练到效果评估的完整流程。压缩包共含十八个文件,包括六个Python编译缓存文件、四个源代码脚本、两份实验文档、两份文本数据、一份演示文稿,以及模型、数据和日志文件各一个,整体大小约23.83MB。已有169人学习下载,适合作为课程设计或自学项目的参考。通过源码可了解循环神经网络、长短期记忆网络、Transformer等模型在诗词生成中的实际应用;文档则详细说明实验步骤、参数调整与BLEU、ROUGE等评价指标,PPT便于答辩或课堂展示。附带的数据文件可直接用于训练,帮助读者快速复现实验并深入理解人工智能在艺术创作中的技术实现。

1. 从「自动写诗.rar」说起:先解决约束,再谈生成

「自动写诗.rar」这个文件名透露的信息比表面多:它打包的不是在线 API,而是一个能本地解压、命令行直接运行的项目。这类包里通常是一份古诗语料、一段生成脚本,运气好再配一个预训练模型权重。把脚本跑通不难,难在让生成结果「像诗」——自动写诗的本质是带约束的文本生成:除了语句通顺,还得满足句数、字数、押韵甚至平仄这些硬规则,而通用文本生成模型恰恰不擅长硬约束。下文按我实际做这类项目的顺序展开:先立住两条技术路线,再给一个不依赖 GPU 的最小实现,然后讲预训练模型怎么用,最后落到韵律校验和评估这些决定能否落地的细节。

2. 自动写诗的两种路线:从转移概率到语言模型

2.1 先拆解「生成什么」:格律约束的工程表示

自动写诗和通用文本生成的分水岭在约束。以最常见的七言绝句为例,一次完整的生成要同时满足四类条件:恰好四句;每句七字;第二、四句末字押同一韵部,第一句末字可押可不押;相邻句在关键位置平仄相对。单独看每一条都不难,合在一起就变成了组合约束问题,因为生成器每吐一个字,都可能让后面的选择空间收窄甚至走到死路。

工程上通常把这四类约束拆成可控的表示。句数是生成器的外层循环次数,字数是单句生成的截断长度,押韵和平仄则作为后验校验器挂在生成结束后。我在做这类工具时,会先列一张约束表,把「规则是什么」和「代码里怎么表示」对应起来,避免在写生成逻辑时把约束散落得到处都是。

约束类型规则内容工程表示
句数绝句固定四句外层 for 循环 4 次
字数五言 5 字、七言 7 字单句字符数截断
押韵二、四句末字同韵,首句可押韵部映射表 + 尾字校验
平仄相邻句相应位置声调交替声调标注 + 按位交替校验

这张表的价值在于:无论底层用哪种生成算法,约束接口都能保持一致。后面从马尔可夫链换到预训练语言模型,改的只是「下一字概率从哪来」,规则校验层基本可以原样复用。

2.2 统计式路线:一阶马尔可夫链为什么仍是合格起点

先看最早在 CPU 上就能跑通的做法。一阶马尔可夫链把写诗看成「接着上一个字选下一个字」的随机过程,核心是一个转移表 T,其中 T[a][b] 表示语料中字符 a 后面出现字符 b 的次数。生成时从起始字出发,按转移概率采样下一字,直到凑满一句。

这条路线的好处很实际:语料要求低,几千首绝句就能构建像样的转移表;生成在毫秒级完成,不需要 GPU;每一步的概率来源可以打印出来,排查「为什么会写出这个字」非常直观。代价同样明显——模型只看前一个字,句子内部局部通顺,但前后句之间几乎没有语义关联,生成多句后容易陷入「你方唱罢我登场」的车轱辘循环。

针对语义弱的问题,常见的升级是二阶马尔可夫链,把键从「当前字」换成「当前二字」。转移表从 T[a][b] 变成 T[ab][c],数据量需求指数上升,但局部语感会明显改善。自动写诗类小工具里,一阶和二阶混合使用的占大多数,因为对绝句这种短句场景,七言句内真正需要建模的上下文也就三四个字。

2.3 神经式路线:语言模型把「像诗」编码进参数

统计方法的问题在于它不理解「意象」。当人类读到「孤舟」「寒江」时,会自动联想到萧瑟的冬景,但马尔可夫链只当它们是普通字符,两个词之间的搭配完全靠语料计数维持。

预训练语言模型改变了这一点。以自回归语言模型为例,它通过在海量文本上学习 P(c_n | c_1, c_2, …, c_{n-1}),把「在什么语境下出现什么词」的规律压进模型参数。用于自动写诗时,通常会在古诗语料上继续训练,让模型记住五言七言的节奏和韵部搭配,这就是常说的「微调」。

神经式路线虽然语义质量高,但引入了两个新问题:模型体积和推理耗时决定它不能轻量分发;采样策略直接影响生成质量——同一个模型,采样温度设在 0.8 还是 1.2,产出的风格差异巨大。后文第四部分会专门展开参数调节,这里先把结论放出来:统计式保底,神经式提质,两者共用一套韵脚校验接口,是自动写诗工程里最稳妥的组合方式。

3. 不依赖 GPU 的自动写诗最小实现:马尔可夫链生成绝句

这一章给一个完整的、能跑的最小实现。语言用 Python 3,除标准库外不依赖任何第三方包——这也是很多「自动写诗.rar」解压后的实际形态:一个脚本、一份语料、一段说明,就够了。

3.1 语料清洗:把原始文本变成句子池

语料是生成的根基。无论从哪个渠道收集古诗,拿到的文本几乎都是整首排列,夹杂标点、作者名、注释。第一步是把文本切分成独立句子,并按长度归类,因为五言句和七言句的转移表应该分开建。

import re from collections import defaultdict def load_sentences(path, max_len=20): sentences = [] with open(path, "r", encoding="utf-8") as f: text = f.read() # 按常见句读标点切分,中文古诗以逗号句号为主 parts = re.split(r"[,。!?;\n]", text) for part in parts: s = re.sub(r"[^\u4e00-\u9fff]", "", part) if 4 < len(s) <= max_len: # 过滤太短和太长的残句 sentences.append(s) return sentences corpus = load_sentences("poems.txt") print(f"句子总数: {len(corpus)}")

这段代码干了三件事:按标点和换行切句,把所有非汉字字符剔除,最后滤掉长度异常的行。4 < len(s)的过滤条件会把「床前明月光」这类五言句留下,同时排除单个字或过长的散文式片段。实际操作中,语料质量比数量更重要,宁可保留三千句干净的,也不要混入一万句带噪声的。

3.2 构建转移表:带温度的加权采样

切好句后构建一阶转移表。为了后续生成可控,我在采样时加入 temperature 参数:温度越低,候选字集中在高频项上,生成越保守;温度越高,低频字被选中的概率越大,句子越有「意外感」。

import random from collections import defaultdict def build_transition(sentences, order=1): trans = defaultdict(lambda: defaultdict(int)) for s in sentences: for i in range(len(s) - order): key = s[i:i + order] nxt = s[i + order] trans[key][nxt] += 1 return trans def sample_next(trans, key, temperature=1.0): counter = trans.get(key) if not counter: return None # 对频次做温度变换,再按权重随机 weights = [cnt ** (1.0 / temperature) for cnt in counter.values()] chars = list(counter.keys()) return random.choices(chars, weights=weights, k=1)[0] def generate_line(trans, start, length=7, temperature=1.0): chars = list(start) key = start[-2:] if len(start) >= 2 else start while len(chars) < length: nxt = sample_next(trans, key, temperature) if nxt is None: break chars.append(nxt) key = nxt # 一阶模型:新键就是刚生成的字符 return "".join(chars[:length])

build_transitionorder=1表示一阶模型,键是单个字符;把order改成 2 就是二阶模型。sample_next中的温度处理是这类小工具里最值得调的参数:设 0.6 时高频字占绝对优势,设 1.4 时会频繁冒出不常见的字。如果同时维护一阶和二阶两张表,可以先用二阶采样,没命中时回退到一阶,这种多退少补策略在实战中很常用。

3.3 韵脚校验:让最后一句落回同一韵部

马尔可夫链对韵脚完全无感,生成到句末时可能随便收尾。我的做法是先生成一句完整的候选,再校验末字是否落在目标韵部,不通过就重新生成,设置最大重试次数防止死循环。韵部判断用简化的韵母映射。

# 把常见韵母归并成宽韵部,覆盖大多数普通话押韵场景 RHYME_GROUP = { "an": "an", "ian": "an", "uan": "an", "ang": "ang", "iang": "ang", "uang": "ang", "a": "a", "ia": "a", "ua": "a", "ou": "ou", "iu": "ou", } def get_final(ch): # 取尾字拼音的韵母部分;实际工程中可用 pypinyin,或维护一张常用字字典 return final_map.get(ch, "") def rhyme_group(ch): final = get_final(ch) return RHYME_GROUP.get(final) def check_rhyme(sentence, target_group): last_char = sentence[-1] return rhyme_group(last_char) == target_group

这段代码把韵部判断收敛到一个表里,避免在生成主流程里写死具体韵母。需要说明的是,这里走的是「宽韵」路线,只要普通话读音相近就视为押韵;如果追求严格的平水韵,韵部表会膨胀到三十个以上,但那时马尔可夫链生成器会因为数据稀疏导致重试率过高,实践中反而不可用。

3.4 主流程:生成一首完整的四句绝句

最后把语料、转移表、韵脚校验串起来。首句由命令行传入的主题字开头,后三句逐句生成,第二、四句做韵脚约束,第三句不强制押韵但要求不与已有句子完全重复。

def generate_poem(trans, first_char, rhyme_choice=None, max_trials=100): first = generate_line(trans, first_char, length=7) # 未指定韵部时,沿用首句末字的韵部作为全诗韵脚 rhyme = rhyme_choice or rhyme_group(first[-1]) lines = [first] for need_rhyme in [True, False, True]: # 对应第二、三、四句 line = None last = lines[-1][-2:] # 取上句末二字作起始参考 for _ in range(max_trials): cand = generate_line(trans, random.choice(last), length=7) if (not need_rhyme) or check_rhyme(cand, rhyme): if cand not in lines: line = cand break if line is None: # 兜底:重试耗尽时放弃韵脚,至少要保证输出完整 line = generate_line(trans, last, length=7) lines.append(line) return "\n".join(lines) if __name__ == "__main__": print(generate_poem(trans, "春", rhyme_choice="an"))

主流程里有一个值得注意的设计:每一句的起始字符取上一句末二字之一,这能让相邻句在字面上产生少许承接感,缓解马尔可夫链各句割裂的问题。下面是这个最小实现的核心参数清单,按建议值和效果说明整理:

参数位置建议值效果
orderbuild_transition1 或 2阶数越高越依赖语料规模
temperaturesample_next0.8 ~ 1.2低值平稳,高值跳脱
max_trialsgenerate_poem50 ~ 200约束严时需加大
句子长度generate_line5 或 7五言七言切换

到这里,一个不依赖 GPU 的自动写诗工具已经成型。运行python write_poem.py就能看到输出,性能瓶颈基本只出现在重试次数上。

4. 用预训练语言模型把诗写通:微调与采样控制

马尔可夫链的瓶颈在上限,不在实现。当语料质量足够高、你想让生成结果真正具备「诗意」时,就该切到预训练语言模型。这一章讲的不是从零训练一个模型,而是站在已有中文预训练权重的基础上做继续训练和推理控制。

4.1 微调的本质:让模型重新学习格律统计

直接拿通用语言模型做自动写诗,最典型的问题是内容偏现代、句子长度失控、完全不押韵。原因在于预训练语料以新闻、百科、社区文本为主,模型权重里「古诗该怎么写」的统计信息占比太低。微调的目的,是把模型在通用语料上学到的语言能力迁移到古诗领域,让它重新记住五言七言的节奏密度和韵部使用频次。

我一般会在古诗语料上做自回归语言建模,也就是让模型根据前文预测下一个字符。数据准备阶段,把每首诗按句切分,句与句之间用换行符连接,模型只负责学会「下一个字是什么」:

from transformers import AutoTokenizer, AutoModelForCausalLM, Trainer, TrainingArguments # 示意:换成你本地准备好的中文 GPT-2 权重目录 tokenizer = AutoTokenizer.from_pretrained("path/to/local_chinese_gpt2_checkpoint") model = AutoModelForCausalLM.from_pretrained("path/to/local_chinese_gpt2_checkpoint") def encode(texts, block_size=128): for t in texts: ids = tokenizer.encode(t) for i in range(0, len(ids) - block_size, block_size): yield ids[i:i + block_size] args = TrainingArguments( output_dir="./poet-ft", num_train_epochs=5, per_device_train_batch_size=4, learning_rate=5e-5, save_strategy="epoch", ) # DataCollator 会把 input_ids 同时作为 label,完成语言建模训练 trainer = Trainer(model=model, args=args, train_dataset=encoded_dataset) trainer.train()

这段示意代码里值得说明的是两个细节:block_size=128表示按 128 个 token 切块,对绝句这种短文本绰绰有余;learning_rate=5e-5比通用预训练小一个量级,目的是微调权重而不是破坏原有语言能力。Trainer在语言建模任务上会自动把输入同时作为标签,无需手动构造 label。

训练的另一个常见陷阱是过拟合。古诗语料通常只有几万首,模型参数动辄上亿,训练两三轮后 loss 会快速下降,但生成结果可能出现整句照搬训练集的现象。我一般会把验证集单独留出,每轮结束后跑几条生成样例做人工观察,比只看 loss 曲线可靠得多。

4.2 生成参数怎么调:temperature、top-k、top-p 与重复惩罚

微调之后,质量的下一个关键变量在推理阶段。同一个 checkpoint,采样策略不同,效果天差地别。下面是自动写诗场景下的参数经验值:

参数作用建议初值调高/调低效果
temperature控制概率分布锐度0.9低则保守重复,高则散乱
top_k只从概率最高的 k 个词中采样50调高增加多样性
top_p按累积概率截断候选集0.9调低更稳定,调高更多变
repetition_penalty惩罚已出现过的 token1.1过高会导致语句别扭

这四者不是并列关系而是叠加关系:先按 top-k 截断,再按 top-p 做累积概率过滤,最后乘上重复惩罚重新归一化。对七言绝句这种短文本,temperature=0.9top_p=0.9repetition_penalty=1.15是我常用的组合,既保留了意象跳跃的空间,又不会出现大段回环。

用 transformers 的generate方法,参数直接传入:

inputs = tokenizer("春风", return_tensors="pt") output = model.generate( inputs.input_ids, max_new_tokens=28, # 四句七言共 28 字 do_sample=True, temperature=0.9, top_p=0.9, repetition_penalty=1.15, num_return_sequences=16, # 一次生成多首再筛选 )

num_return_sequences=16是实践中的关键技巧:与其调一轮就期望拿到好诗,不如一次生成 16 份候选,再用规则做后筛选。这不是偷懒,而是基于「解码是随机的,规则是确定的」这一分工逻辑。

4.3 约束解码:用 logit 掩码强制韵脚

微调模型虽然比马尔可夫链更懂格律,但也不能保证句末落韵。想保证 100% 押韵,需要在解码时干预概率分布,这就是约束解码。方法是在采样前把 logits 中不合法的词位改成负无穷,让它们不可能被选中。

def mask_logits_for_rhyme(logits, tokenizer, target_group): # 属于 target_group 的 token id 集合,由韵部映射表预先计算 ok_ids = tokens_in_rhyme_group(target_group, tokenizer) mask = torch.full_like(logits, float("-inf")) mask[:, ok_ids] = 0 return logits + mask

这个函数在生成句子最后一个字时调用:先算出模型对下一个字的原始 logits,再把不属于目标韵部的 token 全部掩掉,最后做 softmax 采样。因为掩码是加性的,不会破坏模型本身的概率分布形状,只会把非法选项清零。

约束解码的代价是速度:每生成一句都要额外查询一次韵部映射,批次内还要逐句处理。对自动写诗这种低吞吐场景完全可接受,真正要注意的是「掩码集合不能为空」——若目标韵部在词表中找不到任何 token,整句生成会报错。

提示:掩码集合为空会让整句生成直接报错,上线前务必对目标韵部做一次词表覆盖率检查,并加一层「空集合回退到普通采样」的兜底逻辑。

5. 韵律校验与可用性打磨:让自动写诗从「能生成」到「能交付」

5.1 三条硬指标验收一批生成结果

批量生成只能证明「能跑」,能不能交付要看指标。我给自动写诗工具配置过三条自动验收规则:押韵率、字数合规率、句内重复率。

押韵率统计所有句末字中落在预期韵部的比例,低于 50% 说明解码端的韵律约束没生效;字数合规率检查每句字符数是否等于设定值,七言诗中单句多一字少一字都算不合格;句内重复率计算单句内重复字符占比,超过 30% 直接判废。三条规则挨个过滤一轮,16 首候选通常能剩下 5 首左右可进入人工挑选。

5.2 三个高频翻车点与对应排查

最常见的三个问题按排查优先级排列:第一,生成不押韵,优先检查韵部映射表是否过严,宽韵表里ianan归并后命中率会明显上升;第二,车轱辘话反复出现,检查repetition_penalty,低于 1.0 时模型倾向于重复已出现的 token,建议设在 1.1 到 1.2 之间;第三,字数忽长忽短,既看max_new_tokens是否给足,也看语料里有没有混入过长残句——数据清洗时遗漏的一句残句,会在解码时被放大成十几处格式错误。

5.3 收尾技巧:打包成带参数的命令行工具

最后一步把流程固化。我用 argparse 封装一个入口脚本,让使用者不需要理解内部算法细节:--theme指定主题字开头,--rhyme指定韵部,--style切换马尔可夫链或模型推理,--lines控制句数。打包成 .rar 分发时保持「一个入口脚本、一个语料目录、一个 README」的结构,运行时直接执行python write_poem.py --theme 春 --rhyme an --style markov,输出四句诗后自动附带一份校验报告,标出每句的押韵状态和字数状态。运行完一轮生成,先把结果丢进 5.1 的校验脚本,过了线的再进入人工挑选环节,这一步能省下大量逐首检查的时间。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 18:23:13

人脸检测与情绪识别实战:dlib关键点+Keras七分类解析

简介&#xff1a;这套基于dlib与Keras的人脸检测及情绪识别项目&#xff0c;提供完整源代码和配套视频教程&#xff0c;面向计算机视觉初学者和深度学习开发者&#xff0c;主要解决人脸定位、特征点提取与表情分类的工程落地问题。包内共29个文件、约318MB&#xff0c;除了核心…

作者头像 李华