别再死记硬背N-Gram公式了!用Python从零实现一个能‘打分’的句子生成器
自然语言处理(NLP)听起来高深莫测?其实它的核心思想往往简单得令人惊讶。想象一下,当你和朋友聊天时,对方刚说完"今天天气真",你脑海中会本能地预测下一个词可能是"好"而不是"香蕉"——这种预测能力正是N-Gram模型的本质。本文将带你用Python从零构建一个会"创作"还能"打分"的N-Gram引擎,让抽象的语言模型理论变成看得见摸得着的代码实践。
1. 拆解N-Gram:为什么说它是语言预测的"直觉大师"
N-Gram模型的核心在于一个简单却深刻的观察:词语的出现不是随机的,而是受前面词语影响的。就像我们不会说"喝汽车"而会说"开汽车",语言中存在隐形的搭配规则。这种规则不需要理解语义,纯粹基于统计规律:
- Unigram(1元):只考虑单词本身频率(如"的"出现概率高)
- Bigram(2元):考虑前一个词的影响("苹果"后出现"手机"的概率>"香蕉")
- Trigram(3元):扩展到前两个词的上下文("人工"+"智能"后很可能接"技术")
# 直观理解Bigram概率计算 corpus = "我喜欢苹果 苹果很甜".split() # 分词后的语料 bigrams = [("我","喜欢"), ("喜欢","苹果"), ("苹果","苹果"), ("苹果","很"), ("很","甜")] # 计算P(苹果|喜欢) = "喜欢 苹果"出现次数 / "喜欢"出现次数 p_apple_given_like = bigrams.count(("喜欢","苹果")) / corpus.count("喜欢") print(f"P(苹果|喜欢) = {p_apple_given_like}") # 输出1.0这个例子揭示了一个关键现象:N值越大,模型对上下文的捕捉越精细,但数据稀疏问题也越严重。比如Trigram需要更多训练数据才能获得可靠统计。
2. 工程蓝图:构建会"创作"的N-Gram引擎
让我们设计一个具有完整生命周期的N-Gram系统:
语料预处理层
- 文本清洗(去除标点、统一大小写)
- 分词处理(英文按空格,中文需分词器)
- 添加起始/结束标记(
<s>和</s>)
模型训练层
- 统计词频(建立词库)
- 计算N-Gram条件概率
- 平滑处理(应对未见词组合)
应用功能层
- 句子生成(基于概率采样)
- 通顺度打分(计算句子概率)
- 交互式测试(输入前缀补全句子)
class NGramGenerator: def __init__(self, n=2): self.n = n self.ngrams = defaultdict(Counter) self.start_token = "<s>" self.end_token = "</s>" def train(self, corpus): for sentence in corpus: tokens = [self.start_token] + sentence.split() + [self.end_token] for i in range(len(tokens)-self.n+1): context = tuple(tokens[i:i+self.n-1]) next_word = tokens[i+self.n-1] self.ngrams[context][next_word] += 1关键细节:
defaultdict(Counter)这种嵌套数据结构能高效存储层级化的N-Gram统计量。例如ngrams[("我","喜欢")]["苹果"]=3表示"我喜欢苹果"出现3次。
3. 概率的艺术:从统计表到句子生成
单纯的统计表只是冰冷的数字,如何让它"活"起来?核心在于加权随机采样——让高频组合有更高选中概率,同时保留一定的随机性:
def generate_sentence(self, max_len=20): current = (self.start_token,) result = [] for _ in range(max_len): next_word = random.choices( list(self.ngrams[current].keys()), weights=list(self.ngrams[current].values()) )[0] if next_word == self.end_token: break result.append(next_word) current = tuple(list(current[1:]) + [next_word]) if self.n > 1 else () return " ".join(result)实际运行示例(训练《红楼梦》语料后):
生成结果1: "老太太笑道 这个丫头" 生成结果2: "宝玉听了 不觉滴下泪来" 生成结果3: "凤姐儿忙问道 你瞧瞧这个"为什么需要平滑技术?当遇到未见过的新词组合时,直接概率为零会导致模型失效。加一平滑(Laplace)是最简单的解决方案:
def get_probability(self, context, word): total = sum(self.ngrams[context].values()) + len(self.vocab) # 加词汇表大小 count = self.ngrams[context].get(word, 0) + 1 # 加一平滑 return count / total4. 给句子"打分":量化语言流畅度的秘密
判断"今天天气真好"比"天气好今天真"更通顺,N-Gram通过计算句子概率实现这点。采用对数概率避免数值下溢:
def score_sentence(self, sentence): tokens = [self.start_token] + sentence.split() + [self.end_token] log_prob = 0.0 for i in range(len(tokens)-self.n+1): context = tuple(tokens[i:i+self.n-1]) next_word = tokens[i+self.n-1] prob = self.get_probability(context, next_word) log_prob += math.log(prob) if prob > 0 else -float('inf') return log_prob测试对比(数值越小越好):
"人工智能改变世界"得分: -12.34 "世界改变工智能力人"得分: -48.725. 实战优化:让玩具模型变身实用工具
基础版存在三个明显缺陷:
- 内存效率低:全量存储N-Gram表
- 生成质量不稳定:可能陷入重复循环
- 长文本失效:概率连乘导致数值爆炸
解决方案:
- 采用Trie树压缩存储
- 引入温度参数控制随机性
- 使用束搜索(Beam Search)优化生成
def beam_search_generate(self, beam_width=3, max_len=15): beams = [([self.start_token], 0.0)] # (tokens, score) for _ in range(max_len): new_beams = [] for tokens, score in beams: context = tuple(tokens[-(self.n-1):]) if self.n > 1 else () for next_word, prob in self.ngrams[context].items(): new_score = score + math.log(prob) new_beams.append((tokens + [next_word], new_score)) # 保留top-k beams = sorted(new_beams, key=lambda x: x[1], reverse=True)[:beam_width] return " ".join(beams[0][0][1:-1]) # 去除起止标记优化后的生成示例:
原始随机生成: "的 的 的 的 的" # 陷入重复 束搜索生成: "昨夜西风凋碧树 独上高楼" # 保持连贯性6. 超越基础:现代NLP中的N-Gram变体
虽然深度学习崛起,但N-Gram思想仍在进化:
- 缓存模型(Cache LM):混合近期词语的局部统计
- 类别N-Gram:先对词语聚类(如"北京→[城市]")
- 神经N-Gram:用神经网络预测条件概率
# 混合模型示例(结合Bigram和缓存) class CachedNGram: def __init__(self, base_ngram, cache_weight=0.3): self.base = base_ngram self.cache = [] self.cache_weight = cache_weight def update_cache(self, word): self.cache.append(word) if len(self.cache) > 10: # 缓存窗口 self.cache.pop(0) def get_probability(self, context, word): base_prob = self.base.get_probability(context, word) cache_prob = self.cache.count(word) / len(self.cache) if self.cache else 0 return (1-self.cache_weight)*base_prob + self.cache_weight*cache_prob这种混合模型能捕捉"最近常提某话题"的语言现象,比如聊天机器人会更倾向于重复用户刚用过的词汇。