资讯动态

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

发布时间:2026/9/15 4:03:39 来源:尧图企业网站定制
简介这份资源是一套基于Python的古诗生成器完整源码并集成前端展示界面适合文学爱好者、编程学习者以及对AI文本生成感兴趣的开发者。项目将后端生成算法与网页交互设计相结合既能体验古诗创作也能作为学习Python与前后端协作的实践范例。压缩包共43个文件约10.85MB包含7个Python脚本负责数据加载、模型训练、生成评估等核心逻辑以及CSS、JavaScript、XML配置、字体、图片和说明文档等辅助文件目录组织清晰便于阅读和二次开发。目前已有322人学习。通过该资源读者可以了解古诗生成的基本流程、模型调用方式以及前端如何与后端接口衔接还可参考其工程结构来拓展其他文本生成项目兼具文化趣味与技术参考价值。1. 基于Python的古诗生成器先拆清这条生成链路有几位同事第一次把古诗生成器跑通以为后面的工作只剩调参。结果把网页一接前端点“生成”返回来一句顺的都没有——不是模型不行是整条链路上每个环节各干各的。古诗生成器从来不是一个模型的事底层要把几万首古诗清洗成统一格式建立字符表和统计模型中间要把采样过程做成可控参数让结果在“太俗”和“太野”之间平衡上层还要有一个 HTTP 接口把生成结果交给前端渲染。这条链路每一段都能用 Python 的标准库加少量第三方库解决最后整理成源码后别人从拉取到跑通只需要一个入口命令。下面把这条链路完整走一遍前端集成会用单独一章讲清楚接口与页面怎么对接。2. 古诗生成器的源码模块划分从语料清洗到字表构建多数古诗生成器源码的第一版问题不在模型而在数据格式不统一。流传较广的唐诗三百首 JSON 语料一首诗通常有title、author、paragraphs三个字段前两个字段里混杂着标题和作者名一点也不能进模型。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(encodingutf-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_freq2是我常用的起点因为单次出现的字符无法形成可靠统计训练阶段拿到也学不到稳定分布。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 参数调输出风格的开关采样器里最常用的两个控制参数是k和temperature。k限制每一步只从前概率最高的几个字符里选值越小输出越收敛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随机性明显偶有惊喜但容易散语料质量高时可以尝试生成循环把模型和采样器串起来。以length20、五言四句为例首字由外部传入后续每个字都由前一字决定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_kk) nxt top_k_sample(cands, kk, temperaturetemperature, default_char云) out.append(nxt) prev nxt return .join(out)这段代码写完后先不要接前端直接在命令行里跑几次观察同一首字在不同k和temperature下的差异。如果生成结果连续出现“山山山”这种重复优先把温度调低到 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默认包含seed、length、k、temperature四个字段。后端要做两件事参数裁剪和结果封装。参数裁剪防止用户传负数、超长字符串或空值把生成循环带崩。# 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(forceTrue) # 对输入参数做边界约束 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, seedseed, lengthlength, kk, temperaturetemperature ) return jsonify({ poem: result, seed: seed, length: length, k: k, temperature: temperature, })forceTrue的作用是即使前端漏传Content-Type头也能读入 JSON 请求体对脚本调用方更宽容。seed只截取第一个字符因为生成循环只依赖单一前置字符多传的字没有意义。length限制在 4 到 80 之间既能容纳五言绝句也能生成七言律诗。接口返回时把实际生效的参数原样带回去前端拿到后可以判断服务端是否按请求执行。模型不能放在路由函数里重复加载否则每次请求都要读盘。标准做法是启动时加载一次放进模块全局变量if __name__ __main__: model load_model_from_disk() app.run(host127.0.0.1, port5000, debugFalse)debugFalse是为了防止调试服务器暴露更多信息也避免 debugger 与前端页面抢进程资源。4.2 前端 HTML 与 fetch 调用不引入构建工具前端页面只需要三个输入控件和一个展示区用原生 HTML 写完全足够。刻意不引入 Vue 或 React 的原因是这类单页演示工具的核心在交互反馈不在组件化且原生写法便于读者一眼看懂数据流。页面结构如下!doctype html html langzh-CN head meta charsetutf-8 title古诗生成器演示/title /head body label首字 input idseed typetext value春/label label总字数 input idlength typenumber value20/label label温度 input idtemperature typenumber step0.1 value0.8/label button idrun生成/button pre idoutput/pre script srcapp.js/script /body /htmlpre标签保留换行和空格诗句按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 状态码是否为 200poem字段是否是非空字符串。若返回 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, seedseed, length20)) 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内部按步数切换温度即可。第二项是把断句标点纳入模型训练清洗时保留句号逗号生成后按标点位置切分而不是死板地每五字切一行这样能自动适配五言七言。第三项是语料按诗体分开建模五言和七言的节奏差异很大混在一个模型里会让转移矩阵被两种风格拉扯分别训练后用接口参数选择模型每类的生成质量都会更稳定。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价