ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用NLTK实现n-gram古诗生成:从环境配置到模型构建

用NLTK实现n-gram古诗生成:从环境配置到模型构建 1. 环境准备从零开始装好nltk顺便解决“下载慢”这个老毛病先说个特别实际的背景。很多人想学nltk卡住的第一关从来不是代码逻辑而是三个字装不上。尤其是nltk在安装语料库的时候要走国外服务器那个速度属实磨人我在两年前第一次跑nltk.download()的时候差点以为电脑死机了。所以这一节我会把环境、安装、语料下载都讲透你照着走一遍后面写代码就是顺水推舟的事。1.1 Python环境与虚拟环境nltk是一个纯Python库对Python的版本要求不苛刻3.7以上基本都能跑得顺利。如果你是从零开始我个人建议直接用Python 3.10或3.11的稳定版别追新也别抱着2.7不放前者容易遇到个别第三方库没跟上后者直接就是时代的眼泪。环境上强烈建议开一个虚拟环境别把nltk直接装到系统Python里。原因是nltk的依赖里面有时会牵扯到regex、tqdm这些库的版本变动如果你机器上同时跑着其他项目很容易出现“今天装了个nltk明天另一个项目莫名其妙起不来了”的情况。我自己用的就是Python自带的venv不需要额外装conda流程就是标准的四步mkdir ngram-writer cd ngram-writer python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate装好虚拟环境之后用pip安装nltkpip 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个词是什么。拿n2的bigram来说模型只看前一个词然后问在这些历史文本里紧跟在“明月”后面的词有哪些各出现过多少次然后把出现频率当成概率。比如在你的语料里“明月”后面跟过“光”5次、跟过“几时有”1次那生成的时候就有六分之五的概率输出“光”。n3的trigram更“贪心”一点它看前两个词。比如语料里出现“床前明月”之后总是跟“光”那模型就会学到一个很强的一一对应关系。n越大生成的文本越长读起来越连贯但问题也随之而来这就是后面的稀疏性。2.2 稀疏性n-gram最头疼的死穴继续说上面的例子。如果你用trigram统计“窗前明月”后面是什么词但你的语料里根本没有“窗前明月”这个组合那怎么办概率就是0模型就卡住了。这就是n-gram的稀疏性问题。n越大组合数呈指数级增长而语料是有限的大量组合的计数都是0。解决方案有两种一种是回退如果trigram没有数据就退到bigram再没有就退到unigram另一种是平滑给那些零计数的组合一个很小的概率不要一棒子打死。在古诗生成的场景里我的做法更实用用回退策略在代码层面表现为defaultdict加try-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, encodingutf-8) as f: lines [line.strip() for line in f if line.strip()] return lines poems load_poems(poems.txt)这一步要注意编码问题。Windows下默认的gbk编码经常会让open直接报错所以我一直习惯显式指定encodingutf-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有一个好处是自带max、samples这些扩展方法统计完可以直接看最高频的组合调试的时候很方便。我的统计函数长这样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_words, max_len20): 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, weightsweights, k1)[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[i1], padded[i2] 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_len14): 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, weightsweights, k1)[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, temperature1.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, weightsnorm_probs, k1)[0]温度调到0.8时生成出来的诗句大多很“正经”像模像样调到1.5时偶尔会出现“明月照黄河”这种无厘头混搭但作为实验也蛮好玩。实际写项目的时候建议默认0.9左右兼顾稳定性和多样性。5. 常见问题与排查技巧实录讲完整个流程最后这一节把我在实操中踩过的坑整理成一份速查表。这些问题单看代码很难发现基本都是要实际跑一遍才会暴露出来的。5.1 常见报错速查表报错信息可能原因解决方法LookupError: 403 Forbiddennltk在尝试下载语料但网络不通按1.2节的方法手动下载语料包并指定nltk.data.pathUnicodeDecodeError: gbk codec cant decode byteopen默认用了系统编码读取中文文件给open加encodingutf-8KeyError: /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而是你对文本的观察方式。数据是死的模型是死的但你对生成结果的“审美”会一步步带着你优化下去。
返回列表