用NLTK实现n-gram古诗生成:从环境配置到模型构建
2026/9/21 2:05:51 网站建设 项目流程

1. 环境准备:从零开始装好nltk,顺便解决“下载慢”这个老毛病

先说个特别实际的背景。很多人想学nltk,卡住的第一关从来不是代码逻辑,而是三个字:装不上。尤其是nltk在安装语料库的时候要走国外服务器,那个速度属实磨人,我在两年前第一次跑nltk.download()的时候,差点以为电脑死机了。所以这一节我会把环境、安装、语料下载都讲透,你照着走一遍,后面写代码就是顺水推舟的事。

1.1 Python环境与虚拟环境

nltk是一个纯Python库,对Python的版本要求不苛刻,3.7以上基本都能跑得顺利。如果你是从零开始,我个人建议直接用Python 3.10或3.11的稳定版,别追新,也别抱着2.7不放,前者容易遇到个别第三方库没跟上,后者直接就是时代的眼泪。

环境上强烈建议开一个虚拟环境,别把nltk直接装到系统Python里。原因是nltk的依赖里面有时会牵扯到regextqdm这些库的版本变动,如果你机器上同时跑着其他项目,很容易出现“今天装了个nltk,明天另一个项目莫名其妙起不来了”的情况。我自己用的就是Python自带的venv,不需要额外装conda,流程就是标准的四步:

mkdir ngram-writer cd ngram-writer python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate

装好虚拟环境之后,用pip安装nltk:

pip install nltk

如果你之前就用过nltk,想升级到新版本再写这篇文章的代码,可以加个--upgrade参数:

pip install --upgrade nltk

装完之后验证一下版本,确保没有装成空壳:

import nltk print(nltk.__version__)

能输出版本号,说明第一步已经稳了。

1.2 nltk语料库下载慢的三种解法

接下来就是无数人卡住的环节:下载语料。nltk本身是库,但像punkt分词器、stopwords停用词表、brown语料库这些数据,都要通过nltk.download()在线拉取。问题是它默认从raw.githubusercontent.com下载,那个速度在国内网络环境下真的会让人怀疑人生。

我实验过三种解法,各有各的使用场景。

第一种是改nltk.download()的下载源,直接指定国内镜像。这个思路和pip换源一样,只不过nltk的下载函数留了一个download_dir参数,意思是你可以把语料库放到任意路径,然后每次使用前指定这个路径。操作是先把语料数据手动下载到一个本地文件夹,之后在代码里统一指向它。这个方式比较适合离线环境,或者多人协作时大家共享一份语料。

第二种是使用代理,如果你本地网络有可用的代理工具,可以在下载前设置环境变量。但这个方法不方便展开聊,我只提醒一句:如果你的网络本身没有特殊需求,不要为了下载语料去额外折腾,直接用第三种。

第三种是我最常用的,也是最推荐的:去nltk官方的GitHub仓库,找到nltk_data这个项目,然后手动下载需要的语料包,再解压到本地路径。比如我需要punkt这个分词器,就单独下载punkt.zip,解压后放进一个叫tokenizers的目录里,最后把这个目录的父路径传给nltk即可。这样做的好处是一次性下载,永久使用,不需要每次跑程序都等网络。

我实测下来,punkt压缩包也就几MB,手动下载基本秒完成,远比在Python里干等靠谱。下载完之后,在代码里加上:

nltk.data.path.append("/你的本地路径/nltk_data")

这段代码的作用是告诉nltk:“嘿,我这里有现成的语料,别去外面下载了。”之后无论你执行word_tokenize还是nltk.pos_tag,都会优先从这个目录读取。

2. n-gram原理:5分钟搞懂模型在干什么

很多教程上来就甩公式,然后让你抄一段代码跑通,跑完还是懵的。这篇文章我想换一种方式,先从“人是怎么写句子”这个角度切入,把n-gram的底裤扒干净,你再看后面的代码就会觉得处处都是理所当然。

2.1 从条件概率说起,但不吓唬人

想象你在玩一个“猜下一个词”的游戏。我说前半句:“床前明月”,你大概率会接“光”。为什么?因为你在唐诗里见过太多次“床前明月光”这个搭配了。n-gram的核心逻辑就是这个:基于前n-1个词,预测第n个词是什么。

拿n=2的bigram来说,模型只看前一个词,然后问:在这些历史文本里,紧跟在“明月”后面的词有哪些?各出现过多少次?然后把出现频率当成概率。比如在你的语料里,“明月”后面跟过“光”5次、跟过“几时有”1次,那生成的时候就有六分之五的概率输出“光”。

n=3的trigram更“贪心”一点,它看前两个词。比如语料里出现“床前明月”之后,总是跟“光”,那模型就会学到一个很强的一一对应关系。n越大,生成的文本越长,读起来越连贯,但问题也随之而来,这就是后面的稀疏性。

2.2 稀疏性:n-gram最头疼的死穴

继续说上面的例子。如果你用trigram,统计“窗前明月”后面是什么词,但你的语料里根本没有“窗前明月”这个组合,那怎么办?概率就是0,模型就卡住了。

这就是n-gram的稀疏性问题。n越大,组合数呈指数级增长,而语料是有限的,大量组合的计数都是0。解决方案有两种:一种是回退,如果trigram没有数据,就退到bigram,再没有就退到unigram;另一种是平滑,给那些零计数的组合一个很小的概率,不要一棒子打死。

在古诗生成的场景里,我的做法更实用,用回退策略,在代码层面表现为defaultdicttry-except。因为古诗的用词重复度高,bigram基本就能覆盖大多数情况,trigram算是一个加分项。

2.3 为什么古诗场景适合n-gram

古诗,尤其是五言绝句和七言绝句,本质上是高度模板化的文本。格律固定、意象重复、词汇量相对小,这些特点对n-gram模型来说简直就是量身定做的训练集。

你想想,现代汉语的自由文本千变万化,用bigram去生成一句话很容易出现“味如嚼蜡”的随机感。但古诗不同,它的词与词之间的搭配非常稳定:名词加形容词、动词加名词,套路几乎是刻在基因里的。所以哪怕你的语料里只有几百首唐诗,用bigram也能生成像模像样的句子。

这也是我把案例选成古诗的原因。不是说n-gram只能写古诗,而是古诗是新手最容易获得成就感的领域。等你把这一套跑通,换成语料改成歌词生成、台词生成,也就是把输入文件换一换的事。

3. 五步搭建文本生成器:从语料到成品

这一节就是整篇文章的重头戏了。我会按照真正的实操顺序,从准备语料、清洗文本、统计n-gram、构建转移概率,到实现采样生成,每一步都会讲清楚“做了什么、为什么这么做、还能怎么改”。

先说一个整体的流程图感:语料 -> 清洗与分词 -> 统计n-gram -> 构建概率表 -> 随机采样生成文本。这五步对应到代码,其实不到100行就能完成。

3.1 第一步:准备语料并做预处理

语料是n-gram的地基。你用什么样的文本训练,模型就生成什么风格的文本。想生成正经的古诗,就别拿“你干嘛呢哈哈哈”这种短句去喂模型,它学出来的全是废话。

我在本地放了一个poems.txt,内容是一行一首五言绝句或者七言绝句。格式很简单,每行一首诗,空格分隔,不要有多余的标点。第一次实验我收集了大约500首唐诗,数据量不大,但已经足够教出个能看的bigram模型。

加载语料和清洗的代码如下:

from nltk.tokenize import word_tokenize import re def load_poems(file_path): with open(file_path, "r", encoding="utf-8") as f: lines = [line.strip() for line in f if line.strip()] return lines poems = load_poems("poems.txt")

这一步要注意编码问题。Windows下默认的gbk编码经常会让open直接报错,所以我一直习惯显式指定encoding="utf-8"。另外你可能会看到某些古诗文本里混入了全角空格、稀有符号,建议在清洗时用正则一把梭:

def clean_text(text): text = re.sub(r"[\s\u3000]+", "", text) # 去掉所有空白和全角空格 text = re.sub(r"[,。!?、;:]", "", text) # 去掉常见中文标点 return text

为什么不直接保留标点?因为古诗的断句是很规则的,标点反而会干扰词级别的统计。我们原本就希望模型学到“明月光”这种纯字的搭配,标点只会把连续文本切得七零八落。

3.2 第二步:把语料切成token序列

nltk的分词工具word_tokenize对英文效果很好,对中文就有点水土不服。中文没有空格,word_tokenize其实是按字符去切分的,所以一个词会被切成单个字,这样也不是不行,但统计出来的n-gram会退化成“字符级别的n-gram”,生成出来的东西缺少词汇层面的连贯性。

针对古诗这种特殊文本,我的做法是退一步,直接用list(text)把字符串转成字符列表。这听上去好像很“原始”,但对古诗这种语言密度极高的文本来说,字符就是天然的最小编码单位。

看这段代码:

def tokenize_poem(line): line = clean_text(line) return list(line) # 每个汉字作为一个token

为了让训练语料更规整,我还会在每首诗的首尾加上特殊的开始和结束标记:

def padded_tokens(line): return ["<s>"] + tokenize_poem(line) + ["</s>"]

加这两个标记的意义在后面会体现出来。生成文本的时候,模型先从<s>开始,一直采样到</s>结束,这样就能保证每首“诗”有明确的起止点,不会生成到一半就断掉。

3.3 第三步:统计n-gram频次

现在进入核心环节:统计n-gram的出现频率。我先从bigram开始,它的统计逻辑是遍历整个token序列,把每相邻的两个词作为一个二元组,然后给计数器加一。

注意这里要用nltk.probability.FreqDist,或者直接使用Python自带的defaultdict也可以。nltk的FreqDist有一个好处是自带maxsamples这些扩展方法,统计完可以直接看最高频的组合,调试的时候很方便。

我的统计函数长这样:

from collections import defaultdict def build_bigram_model(tokens_list): model = defaultdict(list) for tokens in tokens_list: for i in range(len(tokens) - 1): current_word = tokens[i] next_word = tokens[i + 1] model[current_word].append(next_word) return model

这个model非常直观:它是一个字典,key是当前词,value是一个列表,列表里是所有出现在它后面的词。由于我们只统计了每个词后面跟着哪些词,没有统计具体出现多少次,所以这里的value是个多集的列表。

为什么用列表而不是用带计数的字典?因为后面做随机采样的时候,random.choice(list)可以直接从列表中随机选一个元素,这个元素被选中的概率天然和它在列表中出现的次数成正比。通俗点说,一个词出现了5次,它被随机选到的概率就是出现1次的词的5倍,这正符合我们的需求。

3.4 第四步:构建转移概率表

用列表虽然简单,但如果你想把模型保存下来,或者需要计算固定的概率值,最好还是显式构建一张转移概率表。先统计频次,再归一化:

from nltk.probability import FreqDist def build_bigram_probability(tokens_list): model = defaultdict(FreqDist) for tokens in tokens_list: for i in range(len(tokens) - 1): w1, w2 = tokens[i], tokens[i + 1] model[w1][w2] += 1 for w1 in model: total = sum(model[w1].values()) for w2 in model[w1]: model[w1][w2] = model[w1][w2] / total return model

这样一张表里,model["明月"]["光"]就是一个0到1之间的小数,代表在“明月”后面接“光”的概率。

平时调试时我会用一行代码检查高频转移:

print(model["明月"].most_common(5))

输出大致是这样:[('光', 0.8), ('几时', 0.1), ('何', 0.05), ('落', 0.05)]。看到这个结果,你就能直观理解模型到底学到了什么。

3.5 第五步:随机采样,生成文本

最后一步,也是最有意思的一步:根据转移概率表生成新文本。从<s>开始,依据当前词找到转移的候选词列表,加权随机选一个,直到选到</s>或者生成了预设的最大长度。

生成函数如下:

import random def generate_text(model, start_word="<s>", max_len=20): result = [] current_word = start_word for _ in range(max_len): if current_word not in model: break next_candidates = list(model[current_word].keys()) weights = list(model[current_word].values()) current_word = random.choices(next_candidates, weights=weights, k=1)[0] if current_word == "</s>": break result.append(current_word) return "".join(result)

这里用了random.choices而不是random.choice,原因就是我们想在采样时直接按权重抽取,而不是只做均匀随机。random.choices支持传入权重列表,用起来非常顺手。

每次运行生成的文本都会不一样,因为采样过程自带随机性。这不是bug,这是特性。同样的模型,你可以跑出十首风格相似但内容各异的“诗”。

4. 古诗生成案例:用700首唐诗训练trigram模型

上面的五步走完,你已经有一个基础版生成器了。但直接用上面那个函数去生成古诗,效果大概率是“词都认识,连起来不像人话”。所以这一节我们做一个更进一档的实践:用trigram模型、加长语料、加局部随机策略,让它生成能“装”一下的五言诗。

4.1 训练语料的扩展与处理

我最终的训练集收集了700首五言绝句和七言绝句,全部按“一行一首”的格式放在poems.txt。为了达到更好的效果,我还做了一步额外处理:句子内部保留停顿信息。

什么意思?传统格式是一行一首诗,但七言诗内部结构其实是“四三”或“二二三”,五言诗则是“二三”。为了让trigram学习到更细的节奏搭配,我直接把每首诗拆成两个半句,按半句储存。例如:“床前明月光” -> “床前明月” + “光”。这样训练出来的模型会更容易学到“前两个字加后五个字”的韵律。

具体拆分代码如下:

def split_half(line): if len(line) == 5: return [line[:2], line[2:]] if len(line) == 7: return [line[:2], line[2:5], line[5:]] return [line]

每次读取一首诗,先做清洗,再按半句拆分,然后每一行半句作为一条训练数据投入n-gram统计。这样做的好处是,trigram模型能学到“床前明月”后面跟“光”的这种互文关系,统计上更密集,生成的句子也有更明确的节奏感。

4.2 训练trigram模型并生成七言

bigram和trigram的统计代码差别不大,多一层循环而已:

def build_trigram_model(tokens_list): model = defaultdict(FreqDist) for tokens in tokens_list: padded = ["<s>"] + tokens + ["</s>"] for i in range(len(padded) - 2): w1, w2, w3 = padded[i], padded[i+1], padded[i+2] model[(w1, w2)][w3] += 1 for pair in model: total = sum(model[pair].values()) for w3 in model[pair]: model[pair][w3] = model[pair][w3] / total return model

这里key从单个词变成了二元组(w1, w2),value是FreqDist,记录的是第三词及其概率。这种结构看起来很朴素,但实际预测能力比bigram强很多,尤其适合七言诗这种“前四后三”的固定句式。

生成的时候,每次要看前两个词才能决定下一个词,所以初始化时要手动把起始词<s>和第一个真正的字一起传进去。但古诗没有固定开头词,怎么办?我是这样处理的:从所有训练过的二元组里面随机挑一个作为种子,然后再用trigram往后接:

def generate_poem_trigram(model, max_len=14): seed_pair = random.choice(list(model.keys())) # 随机选一个起始二元组 w1, w2 = seed_pair result = [w1, w2] while len(result) < max_len and w2 != "</s>": candidates = model.get((w1, w2), None) if not candidates: break next_words = list(candidates.keys()) weights = list(candidates.values()) w3 = random.choices(next_words, weights=weights, k=1)[0] if w3 == "</s>": break result.append(w3) w1, w2 = w2, w3 return "".join(result)

注意一个坑:seed_pair可能是一个以</s>开头的二元组,这样一开始就会进入死循环。我在实践中加了过滤条件,只选第一个元素是<s>的二元组作为种子:

seed_pair = random.choice([p for p in model.keys() if p[0] == "<s>"])

这会多花一点时间,但只要模型训练数据不过小,基本上瞬间能完成。

4.3 温度参数:让生成结果“稳中带皮”

直接按概率采样,很容易出现某个高频组合反复出现,生成出来的诗千篇一律。为了让结果更有变化,我引入了一个“温度参数”来调整概率分布的尖锐程度。温度小于1会让高频词更容易被选中,生成更稳定;温度大于1会让概率分布更平缓,生成更有惊喜但也更可能出怪句。

实现方式是在权重计算时对概率取对数再除温度,最后做softmax归一化:

def weighted_choice_with_temperature(candidates, probs, temperature=1.0): import math log_probs = [math.log(p + 1e-9) / temperature for p in probs] exp_probs = [math.exp(lp) for lp in log_probs] total = sum(exp_probs) norm_probs = [ep / total for ep in exp_probs] return random.choices(candidates, weights=norm_probs, k=1)[0]

温度调到0.8时,生成出来的诗句大多很“正经”,像模像样;调到1.5时,偶尔会出现“明月照黄河”这种无厘头混搭,但作为实验也蛮好玩。实际写项目的时候,建议默认0.9左右,兼顾稳定性和多样性。

5. 常见问题与排查技巧实录

讲完整个流程,最后这一节把我在实操中踩过的坑整理成一份速查表。这些问题单看代码很难发现,基本都是要实际跑一遍才会暴露出来的。

5.1 常见报错速查表

报错信息可能原因解决方法
LookupError: 403 Forbiddennltk在尝试下载语料但网络不通按1.2节的方法手动下载语料包并指定nltk.data.path
UnicodeDecodeError: 'gbk' codec can't decode byteopen默认用了系统编码读取中文文件openencoding="utf-8"
KeyError: '</s>'语料中没有</s>标记,或模型里没有这个key确认padded_tokens已正确处理,且生成时做了存在性判断
NameError: name 'FreqDist' is not defined没导入nltk.probability.FreqDist在文件顶部加from nltk.probability import FreqDist
生成结果全是复读机语料量太少或n值太大增大语料,或把trigram降级成bigram;也可调低温度让分布更集中
TypeError: 'join' requires strresult列表里混入了非字符串元素确认token都是字符串,且"".join前做了类型检查

5.2 三个提升生成质量的小技巧

第一个技巧:语料不是越大越好,而是越干净越好。我在初版实验时,语料里混进去几首随意写的打油诗,导致模型生成结果里出现了一些和唐诗风格完全不搭的用词。清洗语料比你想象的更重要,宁可数量少一点,也要保证每一行都是你想要的风格。

第二个技巧:要是觉得结果不够“诗”,可以加一个韵脚约束。方法是在生成到最后一个字时,强制从候选字里选一个与期望韵脚同韵的字。实现也不复杂,先生成完整句子,如果末字不在期望韵部,就重新采样,直到满足条件。这个技巧能让生成的古诗“听起来”更像诗,尤其适合做展示项目。

第三个技巧:如果想让脚本更职业化,可以把训练好的模型保存到本地,下次生成就不用重新训练了。用pickle或者json序列化都行。json.dump对人类可读,但我个人更喜欢pickle,因为字节码加载速度更快,尤其在trigram模型词典比较大的时候。

我在这次实操中还发现了一个很有意思的现象:trigram模型“背诗”的能力比“写诗”的能力强。具体来说,如果训练集里恰好有“床前明月光”这句,那么模型在生成时很可能把整句原样复制出来。这不是模型在作弊,而是因为n-gram本质上是记忆加重组。想让它更像“创作”,办法有两个:一个是把训练语料里完全相同的半句去重,另一个是用bigram加温度采样。

6. 写在最后的经验总结

这套n-gram文本生成器,我从第一次写完到现在已经迭代过好几版。最初的版本只有不到50行代码,效果惨不忍睹,生成出来的“诗”基本是随机字的排列组合;后来逐步加了trigram、温度参数、半句拆分这些细节,才慢慢有点“能看”。

我个人最推荐的做法是,先把bigram版本跑通一次,亲眼看看那种“每个词都对、连起来莫名其妙”的效果,再去升级到trigram。这个过程能让你对n-gram的机制有很直观的认识。你对n-gram的n值从1变到3,生成结果的连贯性肉眼可见地提升,这种手感是看任何教程都学不来的。

另外,如果你想让这个项目继续深挖,有两个方向值得玩味。一个是用nltk里自带的nltk.lm模块,它封装了更完整的语言模型接口,包括平滑和回退,适合正式项目。另一个是把语料换成歌词或者台词,你会惊讶于n-gram这种“古董模型”在不同文体上的适应性。说到底,这个项目的核心不是nltk,也不是n-gram,而是你对文本的观察方式。数据是死的,模型是死的,但你对生成结果的“审美”会一步步带着你优化下去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询