news 2026/9/15 4:03:35

Python古诗生成器实战:从语料清洗到n-gram建模与API集成

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python古诗生成器实战:从语料清洗到n-gram建模与API集成

简介:这份资源是一套基于Python的古诗生成器完整源码,并集成前端展示界面,适合文学爱好者、编程学习者以及对AI文本生成感兴趣的开发者。项目将后端生成算法与网页交互设计相结合,既能体验古诗创作,也能作为学习Python与前后端协作的实践范例。压缩包共43个文件,约10.85MB,包含7个Python脚本(负责数据加载、模型训练、生成评估等核心逻辑),以及CSS、JavaScript、XML配置、字体、图片和说明文档等辅助文件,目录组织清晰,便于阅读和二次开发。目前已有322人学习。通过该资源,读者可以了解古诗生成的基本流程、模型调用方式以及前端如何与后端接口衔接,还可参考其工程结构来拓展其他文本生成项目,兼具文化趣味与技术参考价值。

1. 基于Python的古诗生成器,先拆清这条生成链路

有几位同事第一次把古诗生成器跑通,以为后面的工作只剩调参。结果把网页一接,前端点“生成”,返回来一句顺的都没有——不是模型不行,是整条链路上每个环节各干各的。古诗生成器从来不是一个模型的事:底层要把几万首古诗清洗成统一格式,建立字符表和统计模型;中间要把采样过程做成可控参数,让结果在“太俗”和“太野”之间平衡;上层还要有一个 HTTP 接口把生成结果交给前端渲染。这条链路每一段都能用 Python 的标准库加少量第三方库解决,最后整理成源码后,别人从拉取到跑通只需要一个入口命令。下面把这条链路完整走一遍,前端集成会用单独一章讲清楚接口与页面怎么对接。

2. 古诗生成器的源码模块划分:从语料清洗到字表构建

多数古诗生成器源码的第一版问题不在模型,而在数据格式不统一。流传较广的唐诗三百首 JSON 语料,一首诗通常有titleauthorparagraphs三个字段,前两个字段里混杂着标题和作者名,一点也不能进模型。paragraphs虽然是一首诗的正文字符串数组,但不同来源的语料对换行、空白、引号的处理完全不同。如果直接把原文喂给后续的字符级模型,空格和引号会占掉大量字典位置,真正有区分度的汉字反而被稀释。所以源码的第一个模块必须是清洗,并且清洗要与建模完全解耦,下次换任何语料,清洗函数仍然可以原样复用。

2.1 用标准库清洗诗句:json + re 两步搞定

清洗只做两件事:把空白全部去掉,把非汉字内容全部丢掉,只保留全角逗号、句号、感叹号和问号。用标准库json读取文件,用re做过滤,不需要引入 jieba、pandas 这类重依赖。写的时候拆成两个函数,clean_poem_text只处理单条字符串,load_poems负责从文件里取数并过滤短文本,职责清楚。

# preprocess.py import json import re from pathlib import Path def clean_poem_text(text: str) -> str: """把一首诗的原始正文清洗成连续汉字,标点统一保留。""" # 先去掉换行、空格和制表符 text = re.sub(r'\s+', '', text) # 只保留汉字和四种全角标点,其余字符全部移除 text = re.sub(r'[^\u4e00-\u9fff,。!?]', '', text) return text def load_poems(filepath: str, min_len: int = 10) -> list[str]: """读取 JSON 数组格式的语料,返回清洗后的诗句列表。""" data = json.loads(Path(filepath).read_text(encoding='utf-8')) poems = [] for item in data: # 兼容 paragraphs 与 content 两种常见字段命名 lines = item.get('paragraphs') or item.get('content') or '' if isinstance(lines, list): lines = ''.join(lines) poem = clean_poem_text(lines) # 过滤残句,少于 10 个汉字的一律丢弃 if len(poem) >= min_len: poems.append(poem) return poems

第一段正则去掉所有空白符,第二段把字符集合限定在汉字和断句标点内,英文、数字、引号、括号在第二段被边缘化。min_len参数的目的是过滤掉那些只有一句的残诗,以及从页面上抓下来的空内容。最小长度取 10,恰好对应五言绝句的一半,过小的记录多半是标题误读。

2.2 字表构建:保留可逆映射,过滤低频字符

后续生成模型需要把字符映射成索引,同时索引也能反向映射回字符。用collections.Counter统计全语料的字符频次,低于min_freq的字符不进入字表。这个词频不能设得过高,古诗语料规模通常在几十万到几百万字之间,top 3000 的汉字已经能覆盖大部分常用表达,但生僻字和异体字往往只出现一两次,留它们在字表里会污染转移矩阵,让采样器偶发输出毫无关联的怪字。

# char_table.py from collections import Counter class CharTable: """字表类:维护字符与索引的双向映射,并过滤低频字符。""" def __init__(self, corpus: str, min_freq: int = 2): # 先统计全语料的字符频次 counter = Counter(corpus) # 只保留出现次数不低于 min_freq 的字符 self.chars = [ch for ch, cnt in counter.items() if cnt >= min_freq] # 排序保证相同语料每次构建的索引顺序一致 self.chars.sort() self._char2idx = {ch: idx for idx, ch in enumerate(self.chars)} self._idx2char = {idx: ch for ch, idx in self._char2idx.items()} def __len__(self) -> int: return len(self.chars) def char_to_index(self, ch: str) -> int: return self._char2idx[ch] def index_to_char(self, idx: int) -> str: return self._idx2char[idx]

min_freq=2是我常用的起点,因为单次出现的字符无法形成可靠统计,训练阶段拿到也学不到稳定分布。sort()这行容易被漏掉,却很重要:字典的遍历顺序在 Python 3.7 后虽然保持插入序,但Counter结果受语料顺序影响,一旦调整语料顺序,索引就会变化,模型落盘后再加载可能对不上。

2.3 源码模块归属与依赖选型

项目目录里把语料、模型、接口、前端静态文件分开,依赖方向是从上往下单向调用。preprocess不依赖模型和接口,markov只接收清洗好的字符串列表,api只调用模型和采样函数。这种划分让单测和调试都不必启动 Flask 服务。

poem_generator/ ├── corpus/ # 原始语料,只读 ├── generator/ │ ├── __init__.py │ ├── preprocess.py # 清洗与加载语料 │ ├── char_table.py # 字表构建与索引映射 │ ├── markov.py # n-gram 模型 │ ├── sampler.py # 采样策略与生成循环 │ └── api.py # Flask HTTP 接口 ├── static/ │ ├── index.html # 前端页面 │ └── app.js # fetch 调用封装 ├── train.py # 训练入口:清洗 -> 建表 -> 建模 └── requirements.txt

依赖库的选型不要追求大而全。下表是每个环节我在做的事,以及什么情况下可以继续减配:

环节推荐方案何时可以再省
语料解析标准库 json、re语料已经是清洗后的 txt 时
字表统计collections.Counter语料小时直接 list.count
模型保存pickle每次启动重新训练也可接受时
HTTP 服务Flask + flask-cors只用命令行生成时
前端页面原生 HTML + JavaScript已经接入 Vue 时替换 static 目录

目录刻意保留static/而不是把 HTML 写死在 Flask 模板里。这样换成 Vue 或 React 时,后端代码一行不用改,只需要让页面请求同一个/api/generate接口。

提示:训练入口train.py里应该打印最终语料规模和字表大小,这两个数字是判断后续生成效果的第一依据。语料只有几百首时别急着调参数,问题多半出在数据量不够。

3. 用 Python 写生成引擎:n-gram 建模与采样参数

模型选型决定后续排错的方向。一上来直接训练 LSTM 或 Transformer,单机 CPU 上要等几分钟到几十分钟,调参周期被拉得很长,而且网络模型输出的是概率分布,调试时很难一眼看出“为什么这里生成了这个字”。更稳妥的路径是先用 n-gram 把整条链路跑通,把采样策略吃透,再决定要不要换神经网络。n-gram 的优点是训练快、参数透明、坏结果能追到具体的前缀状态。古诗五言七言为主,局部语境对下一个字的影响最强,二元组已经足够产生“像话”的句子,升级到三元组可以再改善一点连贯性,但会引入更严重的稀疏问题。

3.1 二元组转移:统计“前一个字后面接什么字”

模型的职责只有一件事:记录每个字符后面出现过哪些字符,以及各自出现了多少次。用defaultdict(Counter)来存转移频次,字典的键是当前字符,值是一个 Counter,键是后继字符,值是出现次数。

# markov.py from collections import defaultdict, Counter import pickle class BigramModel: """字符级二元组模型,维护每个字的后继字符频次表。""" def __init__(self): self.transitions = defaultdict(Counter) def fit(self, poems: list[str]) -> None: """把清洗好的诗逐首送入模型。""" for poem in poems: # 相邻字符构成一个转移对:前一字 -> 后一字 for prev_char, next_char in zip(poem, poem[1:]): self.transitions[prev_char][next_char] += 1 def candidates(self, prev_char: str, top_k: int) -> list: """返回前 k 个候选字符及归一化概率。""" counter = self.transitions.get(prev_char, Counter()) total = sum(counter.values()) if total == 0: return [] return [(ch, cnt / total) for ch, cnt in counter.most_common(top_k)] def save(self, path: str) -> None: """保存转移表,启动时直接加载,省去重复训练。""" with open(path, 'wb') as fp: pickle.dump(dict(self.transitions), fp)

zip(poem, poem[1:])是生成相邻对的标准写法,第一轮迭代取出poem[0]poem[1],第二轮取出poem[1]poem[2]。标点字符也参与了建模,这样模型能学到句号和逗号的出现位置,后续切分诗句时不需要依赖外部规则。candidates方法返回的是已归一化的概率,采样器拿到这些概率后可以通过温度参数调整分布的尖锐程度。

3.2 top-k 与 temperature 参数:调输出风格的开关

采样器里最常用的两个控制参数是ktemperaturek限制每一步只从前概率最高的几个字符里选,值越小输出越收敛;temperature通过指数缩放改变概率差异的明显程度。温度小于 1 时,高频字符的概率被进一步放大,生成结果偏向常搭配;温度大于 1 时,概率分布被拉平,低频字符也获得出场机会。

# sampler.py import random def top_k_sample( candidates: list, k: int = 3, temperature: float = 0.8, default_char: str = '春', ) -> str: """从候选列表中做 top-k 采样,并按温度缩放概率。""" if not candidates: return default_char # 只保留概率最高的前 k 个候选 top = candidates[:k] # 温度指数大于 1 让分布变平,小于 1 让分布变陡 weights = [score ** (1.0 / temperature) for _, score in top] total = sum(weights) weights = [w / total for w in weights] # 按缩放后的权重做随机选择 r = random.random() acc = 0.0 for (ch, _), w in zip(top, weights): acc += w if r <= acc: return ch return top[-1][0]

candidates传入时是(字符, 概率)的列表,top_k_sample内部先取前 k 个,再对概率做温度缩放,最后按权重累积随机落在某个字符上。default_char是当候选列表为空时的兜底值,在工程上必不可少,避免生成过程中直接抛异常中断。

不同k值对输出风格的影响非常直接:

top_k 取值输出特征调试时建议
1几乎固定为最高频搭配,文字千篇一律基本不用于最终效果
3保守但自然,常见搭配为主作为五言绝句的默认值
10随机性明显,偶有惊喜但容易散语料质量高时可以尝试

生成循环把模型和采样器串起来。以length=20、五言四句为例,首字由外部传入,后续每个字都由前一字决定:

def generate_poem( model: BigramModel, seed: str, length: int = 20, k: int = 3, temperature: float = 0.8, ) -> str: """从首字开始,逐字生成指定长度的字符序列。""" out = [seed] prev = seed for _ in range(length - 1): cands = model.candidates(prev, top_k=k) nxt = top_k_sample(cands, k=k, temperature=temperature, default_char='云') out.append(nxt) prev = nxt return ''.join(out)

这段代码写完后先不要接前端,直接在命令行里跑几次,观察同一首字在不同ktemperature下的差异。如果生成结果连续出现“山山山”这种重复,优先把温度调低到 0.5 再试。

3.3 五言绝句格式还原与未登录字回退

生成结果是连续字符串,需要按字数切回诗行。切分逻辑放在显示层而不是生成层,避免格式要求耦合进模型。五言四句就是每 5 个字一行、共 4 行:

def format_poem(text: str, line_len: int = 5, line_count: int = 4) -> str: """把连续字符串切成古诗格式,默认输出五言绝句。""" lines = [] for i in range(line_count): start = i * line_len lines.append(text[start:start + line_len]) return '\n'.join(lines)

切分函数只解决排版问题,不保证押韵和平仄。真正诗律相关的约束必须放进生成循环里,比如要求每行末尾落在押韵字上,那就要在采样时对某些位置做候选重排。另一个需要兜底的是首字字表:generate_poem直接接收外部传入的seed,但这个字未必出现在语料中。常见做法是从语料中统计每首诗的首字符频次,生成一个候选起点表:

def get_seed_chars(poems: list[str], top_n: int = 20) -> list[str]: """统计每首诗首字符的频次,返回高频起首字。""" counter = Counter(poem[0] for poem in poems if poem) return [ch for ch, _ in counter.most_common(top_n)]

这里的逻辑是:一首诗的第一个字符往往决定了整首诗的倾向,统计高频起点相当于给生成器内置了一个风格先验。后续如果遇到字表中不存在的起首字,回退到get_seed_chars的输出里随机挑一个。

4. 前端集成:把生成器做成 Flask HTTP 接口并让 JS 调用

生成器的核心代码就绪后,前端集成要解决的问题是双方如何约定通信方式。浏览器里的 JavaScript 无法直接 import Python 模块,最自然的方式是让 Python 进程常驻,暴露一个 HTTP 接口,前端用fetch调用,参数和结果都走 JSON。前端集成不等于写死页面,重点是确定请求结构、响应结构和错误处理方式。接口设计得干净,前端换成任何框架都不会影响后端。

4.1 POST /api/generate 接口的输入校验与响应结构

接口用POST,路径取名/api/generate,语义清晰。请求体是一个 JSON,默认包含seedlengthktemperature四个字段。后端要做两件事:参数裁剪和结果封装。参数裁剪防止用户传负数、超长字符串或空值把生成循环带崩。

# api.py from flask import Flask, request, jsonify from flask_cors import CORS from generator.markov import BigramModel from generator.sampler import generate_poem app = Flask(__name__) # 开发阶段允许跨域,避免前端单独起服务时被浏览器拦截 CORS(app) model = None @app.route('/api/generate', methods=['POST']) def api_generate(): payload = request.get_json(force=True) # 对输入参数做边界约束 seed = str(payload.get('seed', '春'))[:1] length = max(4, min(80, int(payload.get('length', 20)))) k = max(1, min(10, int(payload.get('k', 3)))) temperature = max(0.3, min(2.0, float(payload.get('temperature', 0.8)))) # 调用生成函数,返回结构化 JSON result = generate_poem( model, seed=seed, length=length, k=k, temperature=temperature ) return jsonify({ 'poem': result, 'seed': seed, 'length': length, 'k': k, 'temperature': temperature, })

force=True的作用是即使前端漏传Content-Type头也能读入 JSON 请求体,对脚本调用方更宽容。seed只截取第一个字符,因为生成循环只依赖单一前置字符,多传的字没有意义。length限制在 4 到 80 之间,既能容纳五言绝句,也能生成七言律诗。接口返回时把实际生效的参数原样带回去,前端拿到后可以判断服务端是否按请求执行。

模型不能放在路由函数里重复加载,否则每次请求都要读盘。标准做法是启动时加载一次,放进模块全局变量:

if __name__ == '__main__': model = load_model_from_disk() app.run(host='127.0.0.1', port=5000, debug=False)

debug=False是为了防止调试服务器暴露更多信息,也避免 debugger 与前端页面抢进程资源。

4.2 前端 HTML 与 fetch 调用,不引入构建工具

前端页面只需要三个输入控件和一个展示区,用原生 HTML 写完全足够。刻意不引入 Vue 或 React 的原因是这类单页演示工具的核心在交互反馈,不在组件化,且原生写法便于读者一眼看懂数据流。页面结构如下:

<!doctype html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>古诗生成器演示</title> </head> <body> <label>首字 <input id="seed" type="text" value="春"></label> <label>总字数 <input id="length" type="number" value="20"></label> <label>温度 <input id="temperature" type="number" step="0.1" value="0.8"></label> <button id="run">生成</button> <pre id="output"></pre> <script src="app.js"></script> </body> </html>

pre标签保留换行和空格,诗句按format_poem切分后直接放进textContent即可。温度参数放在页面上,是因为它是最直观的交互入口,调动它能看到输出风格变化;k参数刻意不暴露,它属于调优参数,放页面上反而增加干扰。

// app.js document.getElementById('run').addEventListener('click', async () => { const seed = document.getElementById('seed').value || '春'; const length = parseInt(document.getElementById('length').value, 10) || 20; const temperature = parseFloat(document.getElementById('temperature').value) || 0.8; // 发起 POST 请求,字段名与后端 api.py 对齐 const resp = await fetch('/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ seed, length, k: 3, temperature }) }); const data = await resp.json(); // 把后端返回的诗句放进展示区 document.getElementById('output').textContent = data.poem; });

前端字段名必须和后端payload.get里的键名完全一致,差一个字母后端就拿默认值。k在这里写死为 3,只透传后端允许它变化。如果发现页面返回 500,先打开浏览器开发者工具的 Network 面板看请求体格式,再决定是前端问题还是后端问题。

4.3 curl 先于页面验证接口是否调通

在浏览器里点按钮之前,用curl直接访问接口,可以过滤掉前端代码的干扰,把问题定位在后端或模型层。命令如下:

curl -s -X POST http://127.0.0.1:5000/api/generate \ -H 'Content-Type: application/json' \ -d '{"seed": "月", "length": 20, "k": 3, "temperature": 0.8}'

返回结果应该是一个包含poem字段的 JSON 字符串。观察两个点:HTTP 状态码是否为 200,poem字段是否是非空字符串。若返回 500,去 Flask 控制台看堆栈,多数情况是模型没加载成功或seed字不在字表中。curl 通了以后再去页面,前端调试成本会大幅降低。

常见的接口行为对比如下表:

集成方式请求方式适合场景调试成本
原生 JS + fetchPOST /api/generate单页演示最低
Vue/React 项目同一接口已有前端工程需要配置代理
命令行脚本requests.post批量生成测试

如果前端是单独起的 dev server,需要把请求代理到 5000 端口,或者保留CORS(app)允许跨域。生产部署时把static/交给 Flask 托管,就不存在跨域问题。

5. 生成质量验证与三个可埋进源码的调优项

生成器跑通只是起点,判断生成结果“像不像样”需要一个可重复的验证脚本。这个脚本独立于服务和页面,纯命令行输出,几十秒内就能得到统计结果。最简单的量化指标是字符级去重比例,用len(set(poem)) / len(poem)计算,数值过低说明生成文本频繁重复同一个字,过高则说明用词过于离散。古诗本身习惯有较多重复意象,去重比例低于 0.5 基本可以判定模型没有学到有效搭配,高了则说明语料内容过于跳跃。

def repeat_ratio(poem: str) -> float: """字符级去重比例,衡量生成文本的重复度。""" if not poem: return 0.0 return len(set(poem)) / len(poem) def batch_validate(model, seeds, n: int = 20): """固定种子列表批量生成,输出平均去重比例。""" samples = [] for seed in seeds: for _ in range(n): samples.append(generate_poem(model, seed=seed, length=20)) ratios = [repeat_ratio(p) for p in samples] print(f'平均去重比: {sum(ratios) / len(ratios):.2f}') print(f'最低去重比: {min(ratios):.2f}')

批量脚本跑完会落在两个数值上,后续所有调参都拿这两个值作为基准。低于 0.5 时优先调大k,或者回看语料的诗体结构。

三个可以埋进源码的调优项。第一项是双温度生成,首句用低温度保住起步的流畅度,后续句子用稍高温度增加变化,在generate_poem内部按步数切换温度即可。第二项是把断句标点纳入模型训练,清洗时保留句号逗号,生成后按标点位置切分而不是死板地每五字切一行,这样能自动适配五言七言。第三项是语料按诗体分开建模,五言和七言的节奏差异很大,混在一个模型里会让转移矩阵被两种风格拉扯,分别训练后用接口参数选择模型,每类的生成质量都会更稳定。

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

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

做网站都有什么功能?保姆级建站教程避坑指南

做网站都有什么功能?保姆级建站教程避坑指南 模板网站太丑,后台改个颜色都要找开发,这种痛谁懂?很多老板拿着手机看同行做得花里胡哨,自己那个站却像上世纪90年代产物,想改又不敢动,怕一搞就崩。 今天这篇 保姆级建站教程 ,不整虚的,直接拆解 做网站都有什么功能…

作者头像 李华
网站建设 2026/9/15 4:00:53

Keil5 .pack文件安装失败的根源与七步精准修复

1. 问题本质与真实场景还原&#xff1a;这不是“安装失败”&#xff0c;而是Keil MDK-ARM生态里的权限、路径与信任链断裂你点开Keil uVision5&#xff0c;打开Pack Installer&#xff0c;选中STM32F4xx_DFP或ARM Compiler 6.x的.pack文件&#xff0c;双击——弹窗提示“Instal…

作者头像 李华
网站建设 2026/9/15 4:00:02

Linux设备驱动开发实战:从字符设备到设备树全流程解析

作为一个常年泡在嵌入式Linux开发一线的工程师&#xff0c;我经常遇到刚转过来的同事问我同一个问题&#xff1a;“驱动到底该怎么写&#xff1f;网上教程一堆&#xff0c;为什么一到自己的板子上就起不来&#xff1f;”说实话&#xff0c;Linux设备驱动开发这个领域&#xff0…

作者头像 李华
网站建设 2026/9/15 3:58:57

企业级AI Agent落地实战:从单体到多Agent协作与生产加固

“AI Agent 企业应用”这个话题&#xff0c;这两年几乎每个做后端和架构的同行都绕不开。我也算是在这个方向上从零到一完整趟过一遍水的人&#xff0c;从最初只会调大模型接口的聊天机器人&#xff0c;到后面真正把 Agent 推进企业业务里跑审批、查数据、处理工单&#xff0c;…

作者头像 李华
网站建设 2026/9/15 3:54:45

做网站都有什么功能:揭秘性能优化背后的避坑指南

做网站都有什么功能:揭秘性能优化背后的避坑指南 找建站公司最怕什么?不是界面丑,而是被坑高价后,网站慢得像蜗牛,流量还没来就被用户关掉了。很多老板以为“做网站”就是找个美工画几张图,结果上线才发现,加载速度卡在半路,百度收录了也不给排名,钱白花了一半。这背后核心问题往往不在设计,而在 性能优化…

作者头像 李华
网站建设 2026/9/15 3:54:35

Linux WiFi驱动开发实战:从cfg80211框架到ARM平台适配全攻略

搞WiFi驱动这件事&#xff0c;说难不难&#xff0c;说简单也真不简单。我最近在一块ARM板子上适配新的WiFi模块&#xff0c;把Linux WiFi设备驱动开发从框架学习到实际调通的完整流程又重新走了一遍。这中间涉及的东西特别杂&#xff0c;从cfg80211/mac80211框架的理解、设备树…

作者头像 李华