自然语言处理(NLP)之n-gram从原理到实战

自然语言处理(NLP)之n-gram从原理到实战

内容概要:

  • n-gram都能做什么

  • n-gram的数学原理

  • n-gram应用在基因测序疾病筛查中的python实战

  • n-gram应用在输入法预测中的python实战

一、n-gram都能做什么

n-gram是自然语言处理(NLP)中的一种基础而强大的语言模型,它将文本分割为连续的N个单元(字/词)序列,认为一个词的出现概率只与前面有限的n-1个词相关。根据n的不同取值,常见的有:

类型 序列大小 应用场景 示例(文本"自然语言处理")
Unigram 一元模型1-gram 词频统计 ["自然", "语言", "处理"]
Bigram 二元模型2-gram 基础预测 ["自然 语言", "语言 处理"]
Trigram 三元模型3-gram 上下文建模 ["自然 语言 处理"]
Skip-gram 可变模型s-gram 语义分析 "自然"+"处理"(跳过"语言")

这种语言模型是一种基于统计的(而不是基于深度学习的)自然语言处理基础模型,其核心思想是通过分析连续N个词(或字符)的序列概率来进行语言规律建模。

1.1典型的应用场景

(一)文本生成

n-gram可用于生成连贯的文本,这个特性使得它可以用于

自动补全: 预测用户可能输入的下一个词;

诗歌生成: 基于n-gram概率生成符合语言模式的诗句;

对话系统: 生成自然流畅的回复。

(二) 信息检索

查询扩展: 识别查询词的相关n-gram扩展搜索;

文档排名: 使用n-gram匹配度改进搜索结果排序;

拼写校正: 基于n-gram概率检测和纠正拼写错误。

(三) 语音识别

语言模型: 结合声学模型提高识别准确率;

发音变异处理: 处理口语中的连读和缩略形式。

(四) 机器翻译

翻译模型: 评估候选翻译的流畅性;

对齐模型: 识别源语言和目标语言的短语对应关系。

(五)其他应用

作者识别: 不同作者的n-gram使用模式具有特征性;

垃圾邮件过滤: 垃圾邮件常包含特定n-gram组合;

生物信息学: 分析DNA/蛋白质序列中的模式。

1.2和TF-IDF的对比

前文我们讲述了TF-IDF在信息检索和文本挖掘中的应用,那么和之相比有何特点呢?

以下是N-gram与TF-IDF在应用领域、技术原理及优缺点方面的详细对比分析:

一、技术原理对比

维度 N-gram TF-IDF
核心思想 基于马尔可夫假设,用连续N个词的共现概率建模语言规律 基于词频统计和逆文档频率,衡量词在文档中的重要性
数据依赖 依赖局部上下文窗口的序列关系 依赖全局文档集合的统计分布

二、应用领域对比

  1. 重叠应用领域

信息检索

N-gram:查询扩展、拼写校正(如Google搜索的"Did you mean"拼写自动纠正)

TF-IDF:文档排序、关键词提取(如Elasticsearch的默认评分机制)

文本分类

N-gram:捕捉短语级特征(如垃圾邮件识别中的"win money"组合)

TF-IDF:突出主题关键词(如新闻分类中的领域术语)

  1. 独特应用领域
技术 独特应用场景
N-gram 语音识别(解码候选排序)、机器翻译(流畅性评估)、文本生成(自动补全)
TF-IDF 搜索引擎索引构建、文档摘要生成、特征降维(如LSI潜在语义索引)

三、优缺点深度对比

  1. 优势对比
N-gram优势 TF-IDF优势
① 捕捉局部语义关系(如俚语"‌Let 这个 人 out of 这个 bag") ① 计算简单高效,适合大规模数据集
② 支持序列预测任务(如输入法联想) ② 无需标注数据,无监督特征提取
③ 可处理变长语言单元(如跨词组合) ③ 直观解释性强(权重=重要性量化)
  1. 局限性对比
N-gram缺陷 TF-IDF缺陷
① 数据稀疏问题(高阶n-gram需平滑处理) ① 忽略词序("狗咬人"vs"人咬狗"得分相同)
② 上下文窗口有限(难以建模长距离依赖) ② 无法捕捉同义词/多义词语义关系
③ 存储开销大(n≥3时模型体积指数增长) ③ 对低频噪声词敏感(需停用词过滤)

四、技术融合与演进趋势

混合模型实践:

N-gram+TF-IDF:在搜索引擎中,先用TF-IDF筛选候选文档,再用N-gram优化排序(如BM25F算法)

深度学习增强:

BERT等模型可视为"广义N-gram"(注意力机制替代固定窗口,这部分内容本人将在后续专门课程里详细讲解)

TF-IDF常用于预训练模型的特征初始化

领域适应性差异:

N-gram:在语音、生成式任务中仍不可替代(需实时性)

TF-IDF:在结构化文档处理(法律/医疗文本)中保持优势

五、选择建议

flowchart TD A[任务需求] --> B{是否需要序列建模?} B -->|是| C[N-gram优先] B -->|否| D[TF-IDF优先] C --> E[考虑平滑方案] D --> F[结合停用词表] E & F --> G[评估资源限制] G -->|大数据| H[分布式计算优化] G -->|小数据| I[特征组合增强]

注:当前工业界趋势是两者作为基础模块嵌入深度学习管道(如TF-IDF向量输入LSTM,或N-gram特征辅助Transformer训练)。

二、n-gram的数学原理

以下内容可能很枯燥,我尽量用通俗的方式来讲解,实在提不起兴趣的也可以看个大概,不必细究

2.1马尔可夫假设

任何AI使用的技术都离不开数学。n-gram的数学基础就是马尔可夫假设(Markov Assumption),由俄罗斯数学家安德雷·马尔可夫于1913年提出,其核心思想是:未来状态仅依赖于当前状态,与过去状态完全无关。也就是说系统下一状态的概率仅依赖于当前状态,和历史状态无关。数学表达式为:

<math xmlns="http://www.w3.org/1998/Math/MathML"> P ( X t + 1 ∣ X t , X t − 1 , ... , X 1 ) = P ( X t + 1 ∣ X t ) P\left(X_{t+1} \mid X_{t}, X_{t-1}, \ldots, X_{1}\right)=P\left(X_{t+1} \mid X_{t}\right) </math>P(Xt+1∣ Xt,Xt−1,...,X1)=P(Xt+1∣ Xt)

这一性质称为‌马尔可夫性。由于后来扩展了这一概念,引入了历史状态(多阶),因此标准马尔可夫又被称作一阶马尔可夫链。

要理解这一公式所表达的数学语言,让我们先回顾一下概率论的知识。

P (A∩B) 联合概率:定义为事件A和事件B同时发生的概率。若事件独立,联合概率可简化为P(A∩B)=P(A)×P(B)(乘法公式)‌

P(A∣B)条件概率:定义为在事件B 已发生的条件下,事件A发生的概率。

<math xmlns="http://www.w3.org/1998/Math/MathML"> P ( A ∣ B ) = P ( A ∩ B ) P ( B ) ( 要求 P ( B ) > 0 ) P(A \mid B)=\frac{P(A \cap B)}{P(B)} \quad \text { (要求 } P(B)>0 \text{) } </math>P(A∣ B)=P(B)P(A∩ B) (要求P(B)>0 )

事件B已经发生的条件下,事件A发生的概率等于事件AB同时发生的概率除以事件B发生的概率。

条件概率通过联合概率P(A∩B) 和边缘概率P(B) 的比值,反映事件A与B的关联性‌。

以掷骰子为例,其样本空间为{1、2、3、4、5、6},那么事件A(掷出4)的概率是1/6。

我们加入一个事件B(掷出偶数),我们计算一下事件B已经发生的情况下,事件A的概率

在事件B发生了这个条件下,样本空间就缩为{2、4、6},因此事件A(掷出4)的概率是1/3。

用上述条件概率公式计算如下:AB事件联合概率是1/6,B事件的概率是1/2,相除就是1/3。

条件概率是贝叶斯定理的基础,用于更新先验概率为后验概率,而贝叶斯是AI分类模型的又一有力工具,本人将专门撰文详述。

理解了条件概率、联合概率的含义之后,我们再来看一阶马尔可夫假设的公式就清晰了,明天发生的事情的概率只取决于今天发生的事件概率这一条件,而和历史上的其他事件概率无关,用在天气预报预测中就是只有今天才决定明天的天气,明天的天气和昨天、前天等历史天气无关。

2.2 N阶马尔可夫

那么推广开,N阶马尔科夫假设的数学公式如下

<math xmlns="http://www.w3.org/1998/Math/MathML"> P ( X t + 1 ∣ X t , X t − 1 , ... , X 1 ) = P ( X t + 1 ∣ X t , ... , X t − n + 1 ) P\left(X_{t+1} \mid X_{t}, X_{t-1}, \ldots, X_{1}\right)=P\left(X_{t+1} \mid X_{t}, \ldots, X_{t-n+1}\right) </math>P(Xt+1∣ Xt,Xt−1,...,X1)=P(Xt+1∣ Xt,...,Xt−n+1)

对比一阶公式可知,预测下个值的概率需要N个历史状态的概率。N阶马尔可夫链在建模复杂系统时具有更强的表达能力,但同时也面临计算成本高和易过拟合的问题。

对于n阶马尔可夫链,状态空间大小从一阶的S(状态数)增长到S^n。例如:

一阶模型:状态空间S={s₁,s₂,...,sₙ}

二阶模型:状态空间变为S×S={(s₁,s₁),(s₁,s₂),...,(sₙ,sₙ)}

n阶模型:状态空间为S的n次方组合

计算参数数量爆炸增加‌:

一阶转移矩阵维度:N×N(N为状态数)

n阶转移矩阵维度:N^n × N

例如:天气模型(3状态)的二阶模型需要3²×3=27个参数,而三阶需要3³×3=81个

N阶马尔可夫链的计算成本和过拟合风险主要源于:

‌状态空间和参数数量的指数增长‌(数学本质)

‌数据需求与模型复杂度的不匹配‌(实际应用限制)

‌高维参数空间的优化困难‌(训练挑战)

在实际应用中,需要根据具体问题在模型复杂度和计算资源之间取得平衡,通常建议从低阶模型开始,逐步验证是否需要增加阶数。

上面提到的矩阵计算、张量、贝叶斯等AI常用数学工具后续会在本人的相关专题文章中做详细讲解。

2.3链式法则及平滑

链式法则(Chain Rule)是概率论和自然语言处理中的核心工具,用于分解联合概率分布。由联合概率的定义可知,多个事件同时发生的概率等于每个事件概率的连乘,其数学表达式为

<math xmlns="http://www.w3.org/1998/Math/MathML"> P ( w 1 , w 2 , ... , w n ) = ∏ i = 1 n P ( w i ∣ w 1 , ... , w i − 1 ) P\left(w_{1}, w_{2}, \ldots, w_{n}\right)=\prod_{i=1}^{n} P\left(w_{i} \mid w_{1}, \ldots, w_{i-1}\right) </math>P(w1,w2,...,wn)=∏i=1nP(wi∣ w1,...,wi−1)

链式法则通过条件概率的连乘,将联合概率 分解为一系列条件概率 的乘积。每一步的条件概率 P(wi∣w1,...,wi−1)表示在已知前 i−1个事件(如单词)时,第 i个事件发生的概率。

示例:对于句子"我爱NLP",其联合概率可分解为: P(我,爱,NLP)=P(我)*P(爱|我)*P(NLP|我,爱)

链式法则显式建模了序列中元素的依赖关系。例如,在语言模型中,当前单词的概率依赖于上文。生成序列时,链式法则对应"自左向右"的生成过程,每一步基于历史信息预测下一个元素。

那么可以编程代码实现的数学推导就是,从条件概率定义出发:

P(W1,...,Wn)=P(W1)*P(W2|W1)P(W3|W1,W2)...*P(Wn|W1,...,Wn−1)

每一步通过条件概率定义 P(A∩B)=P(A)*P(B|A)递归计算展开。

例如一个三元模型(Trigram):P(这个,人,聪明)≈P(这个)*P(人|这个)*P(聪明|这个,人), 其中:

P(这个)为"这个"在语料中的初始频率。

P(人|这个)统计"这个"后接"人"的频率。

P(聪明|这个,人)统计"这个 人"后接"聪明"的频率。

n-gram模型、神经语言模型(如RNN、Transformer)均基于链式法则来创建句子概率模型。在机器翻译中,解码阶段通过链式法则生成目标语言词序列。在语音识别中,联合概率分解为声学模型和语言模型的组合。

这种分解虽然简化了计算,但是也带了一些问题,典型的是长序列问题:随着序列长度增加,条件概率 P(wi∣w1,...,wi−1) 的历史窗口可能过长,导致计算困难。N-gram模型可以通过马尔可夫假设(仅依赖前 n−1个词)简化计算;在神经网络模型(作者后续将撰文详述)中是通过隐状态压缩历史信息来解决类似问题的。

另外一个是稀疏性问题:直接估计长上下文的条件概率可能因数据稀疏而不准确,需引入平滑技术或分布式表示。

由链式法则得知联合概率是一系列条件概率的连乘集,那么在NLP里就有可能出现一种情况,如果一个词不在训练时的语料库里(未登录词(Unseen Words)),那么涉及这个词的所有联合概率都是0,另外,如果这个词在语料库中的概率非常低,那么涉及此词的联合概率也将会很低,同样也会产生因为数据稀疏导致计算失真。

Laplace平滑(Laplace Smoothing),又称加一平滑(Add-One Smoothing),是解决自然语言处理中零概率问题(Zero-Probability Problem)的经典方法,适用于N-gram语言模型。其核心思想是对所有可能的N-gram计数加1,避免未登录词(Unseen Words)或上下文组合的概率为零。

给定一个Bigram模型(2-gram),Laplace平滑的条件概率公式为:

<math xmlns="http://www.w3.org/1998/Math/MathML"> P l a p ( w n ∣ w n − 1 ) = count ⁡ ( w n − 1 , w n ) + 1 count ⁡ ( w n − 1 ) + V P_{\text {lap }}\left(w_{n} \mid w_{n-1}\right)=\frac{\operatorname{count}\left(w_{n-1}, w_{n}\right)+1}{\operatorname{count}\left(w_{n-1}\right)+V} </math>P lap(wn∣ wn−1)=count(wn−1)+Vcount(wn−1,wn)+1

分子:count(Wn−1,Wn)+1,观测到的Bigram (Wn−1,Wn) 计数加1,确保未出现的组合概率不为零。

分母:count(Wn−1)+V,count(Wn−1)是上文 Wn−1的出现次数。V是词汇表大小(唯一词的数量),用于归一化。

推广到N-gram:

<math xmlns="http://www.w3.org/1998/Math/MathML"> P l a p ( w n ∣ w n − k , ... , w n − 1 ) = count ⁡ ( w n − k , ... , w n ) + 1 count ⁡ ( w n − k , ... , w n − 1 ) + V P_{\text {lap }}\left(w_{n} \mid w_{n-k},\ldots,w_{n-1}\right)=\frac{\operatorname{count}\left(w_{n-k},\ldots, w_{n}\right)+1}{\operatorname{count}\left(w_{n-k},\ldots,w_{n-1}\right)+V} </math>P lap(wn∣ wn−k,...,wn−1)=count(wn−k,...,wn−1)+Vcount(wn−k,...,wn)+1

假设语料中:

"狗 叫" 出现3次(count(狗,叫)=3),"狗" 共出现5次(count(狗)=5),词汇表大小 V=1000。

未平滑概率:

P(叫|狗)=3/5=0.6

Laplace平滑后:

Plap(叫|狗)=(3+1)/(5+1000)≈0.00398

未出现的组合(如 "狗 说话")概率为:

Plap(说话|狗)=(0+1)/(5+1000)≈0.000995

Laplace平滑无需复杂计算,适合小规模数据或快速原型开发,保证了所有可能事件均有非零概率,适合生成任务。

但是它的缺点也很明显,过度平滑(Oversmoothing)对高频事件概率削减过多(如分母中的V 较大时,概率趋近于均匀分布)。尤其当词汇表V 远大于实际观测数据时(如专业领域术语库)。事实上数据稀疏性未根治,仅缓解零概率,未解决长尾分布问题。所谓长尾分布问题是指数据分布中少数高频类别(头部)与大量低频类别(尾部)共存的现象,其核心特征是"头部集中、尾部分散"‌。这种分布广泛存在于自然语言处理、计算机视觉、推荐系统等领域,例如词频统计中少数高频词占据大部分文本,而大量低频词构成长尾‌。

Laplace平滑是加K平滑(Add-K Smoothing)的特例(K=1),在理解其数学本质后,可灵活调整参数K以适应不同任务需求。

更高级的优化方法包括:

Good-Turing估计:根据出现频率的频率重新分配概率。

Kneser-Ney平滑:区分"延续概率"与"独立频率",解决高频词泛化问题。

回退(Backoff)与插值(Interpolation):结合不同阶N-gram信息。

这些方法本人将在后续的AI算法调参原理文章中再专门讲述。

2.4 N-gram模型

一、概率换频率

由前文知,自然语言处理中,根据链式法则一个句子W的概率可以表示为:

<math xmlns="http://www.w3.org/1998/Math/MathML"> P ( w 1 , w 2 , ... , w n ) = ∏ i = 1 n P ( w i ∣ w 1 , ... , w i − 1 ) P\left(w_{1}, w_{2}, \ldots, w_{n}\right)=\prod_{i=1}^{n} P\left(w_{i} \mid w_{1}, \ldots, w_{i-1}\right) </math>P(w1,w2,...,wn)=∏i=1nP(wi∣ w1,...,wi−1)

而N-gram模型引入马尔可夫假设,认为一个词的出现概率仅依赖于它前面的N-1个词:

<math xmlns="http://www.w3.org/1998/Math/MathML"> P ( w n ∣ w 1 , ... , w n − 1 ) ≈ P ( w n ∣ w n − n + 1 , ... , w n − 1 ) P\left(w_{n} \mid w_{1}, \ldots, w_{n-1}\right)≈P\left(w_{n} \mid w_{n-n+1}, \ldots, w_{n-1}\right) </math>P(wn∣ w1,...,wn−1)≈P(wn∣ wn−n+1, ...,wn−1)

对于N-gram模型,把条件概率的计算更改成词汇出现在语料库中的相对频率来估计:

<math xmlns="http://www.w3.org/1998/Math/MathML"> P ( w n ∣ w 1 , w 2 , ... , w n − 1 ) = count ⁡ ( w 1 , w 2 , ... , w n ) count ⁡ ( w 1 , w 2 , ... , w n − 1 ) P\left(w_{n} \mid w_{1}, w_{2}, \ldots, w_{n-1}\right)=\frac{\operatorname{count}\left(w_{1}, w_{2}, \ldots, w_{n}\right)}{\operatorname{count}\left(w_{1}, w_{2}, \ldots, w_{n-1}\right)} </math>P(wn∣ w1,w2,...,wn−1)=count(w1,w2,...,wn−1)count(w1,w2,...,wn)

该公式用于计算n-gram语言模型中,给定前 n−1 个词 (w1,w2,...,wn−1) 时,第 n 个词 wn 出现的条件概率。

分子:序列 (w1,w2,...,wn在语料库中的出现次数(即n-gram的频次)。

分母:前缀序列 (w1,w2,...,wn−1) 的出现次数(即(n-1)-gram的频次)。

直观意义:在已知前文的情况下,下一个词出现的概率由其历史共同出现的频率决定。

n-gram:连续的n个词组成的序列(如bigram是2个词,trigram是3个词)。

count(·):语料库中特定词序列的统计频次。

示例:

语料库中 "I love you" 出现100次,"I love" 出现120次,则:

P(you∣I, love)=100/120≈0.83

这就是概率换频率

二、模型的数学构建过程

  1. 模型参数化

一个N-gram模型可以表示为一个参数集合:θ = {θ_{wₙ|wₙ₋ₙ₊₁,...,wₙ₋₁}},其中每个θ对应一个条件概率

  1. 概率计算示例

考虑句子"我爱NLP"的trigram概率: P("我爱NLP") = P("我")×P("爱"|"我")×P("NLP"|"我爱")

具体计算: P("爱"|"我") = count("我爱") / count("我") P("NLP"|"我爱") = count("我爱NLP") / count("我爱")

  1. 对数概率空间

为避免数值下溢,实际计算常使用对数概率: log P(W) = Σ log P(wₙ|wₙ₋ₙ₊₁,...,wₙ₋₁)

虽然神经网络语言模型(LSTM、Transformer等)已成为主流,但N-gram仍与之有数学联系:

注意力机制可以看作是一种软化的N-gram模型,神经网络的embedding层可以视为连续空间中的N-gram表示

许多神经语言模型仍使用N-gram特征作为补充。

N-gram模型的数学原理虽然基础,但深入理解这些原理对于掌握现代自然语言处理技术至关重要,特别是在资源受限的场景下,N-gram模型因其高效性仍然具有实用价值。

三、基因测序疾病筛查

3.1需求分析

地球上的所有细胞生物的遗传信息载体是DNA-脱氧核苷酸(含碱基、脱氧核糖和磷酸)通过磷酸二酯键连接成双螺旋结构。而基因是DNA中具有遗传效应的片段,通过碱基序列编码功能产物(如蛋白质)。‌

所有细胞生物的基因碱基对种类是相同的,均为腺嘌呤(A)、鸟嘌呤(G)、胞嘧啶(C)、胸腺嘧啶(T)四种碱基对,这些AGCT的排列构成了生物的基因序列,某些疾病有固定的碱基对序列特征,从而测得这些序列特征就可以诊断这些疾病。

人类基因组由约‌30亿个碱基对‌组成(单倍体23条染色体),精确值为31.6亿对‌,虽然技术上已经可以测出这些序列来,但是从序列中查找判断出来这些疾病特征非人力肉眼可以完成,就需要计算机AI技术来完成这项工作。

也就是说需要从已测序的基因碱基(人类大概有30亿对)对中识别出来,就需要N-gram这样的算法

假设某种疾病的碱基对特征串为

target_features = {"ATG", "CGA", "GAT", "TCG"}窗口大小是3,就是Trigram 三元

病人的基因测序为test_sequence,代码中我们用随机生成长度是1000的AGCT来模拟,

3.2代码实现

3.2.1把基因序列拆成三元的窗口数据

python 复制代码
def generate_ngrams(sequence, n=3):
    """生成n-gram集合    
    Args:
        sequence: 输入序列字符串
        n: n-gram的长度        
    Returns:
        set: 包含所有唯一n-gram的集合
    """
    return {sequence[i:i+n] for i in range(len(sequence)-n+1)}

待检测的基因序列是由AGCT组成的长长的字符串,首先就要滑动窗口的方式提取所有长度为3的连续子序列,并使用集合来自动处理重复项,这是n-gram生成算法的核心部分。

  • sequence[i:i+n] - 这是Python的切片操作,从字符串sequence中提取从索引i开始的长度为n的子串。

  • for i in range(len(sequence)-n+1) - 这个循环确定了所有可能的起始索引,len(sequence) 是序列的总长度,-n+1 是为了确保切片不会超出序列边界

  • 外层的花括号 {} 表示这是一个集合推导式,会创建一个集合(set),集合会自动去除重复的n-gram,集合中的元素是无序的

举个例子,如果有一个序列"ATGCG"并且n=3:

当i=0时,sequence[0:3]="ATG"

当i=1时,sequence[1:4]="TGC"

当i=2时,sequence[2:5]="GCG"

循环结束,因为len(sequence)-n+1=5-3+1=3

所以结果集合包含:{"ATG","TGC","GCG"}

3.2.2检测计算方法

python 复制代码
def check_disease_sequence(sequence, target_ngrams, min_consecutive=3):
    """检测连续3+个3-gram匹配    
    Args:
        sequence: 待检测序列
        target_ngrams: 目标特征集合
        min_consecutive: 最小连续匹配数
    Returns:
        tuple: (所有3-gram集合, 是否检测到连续匹配, 匹配片段位置信息)
    """
    # 从目标集合获取n-gram长度
    n = len(next(iter(target_ngrams)))  
    # 生成待测序列的所有n-gram
    all_ngrams = generate_ngrams(sequence, n)    
    # 检测连续匹配
    current_streak = 0 # 当前连续序列的长度初始为0
    max_streak = 0 # 最大连续序列的长度初始为0
    match_positions = [] # 匹配片段位置信息初始为空
    start_pos = -1 # 匹配片段的起始位置初始为-1
    # 遍历序列,i 为序列的索引,从0开始
    for i in range(len(sequence)-n+1):
        # 获取当前n-gram
        ngram = sequence[i:i+n]
        if ngram in target_ngrams:
            # 如果匹配上了,当前连续序列长度加1
            current_streak += 1 
            if current_streak == 1:
                # 如果当前连续序列长度为1,则将起始位置更新为当前索引
                start_pos = i 
            if current_streak >= min_consecutive:
                # 如果当前连续序列长度大于等于最小连续长度,
                # 则将结束位置更新为当前索引加上n
                end_pos = i + n 
                # 将匹配片段位置信息添加到列表中
                match_positions.append((start_pos, end_pos))
            if current_streak > max_streak:
                max_streak = current_streak # 更新最大连续长度
        else:
            # 如果当前n-gram没有匹配目标ngram,
            # 则将当前连续序列长度重置为0,继续滑动窗口
            current_streak = 0    
    # 判断是否存在连续匹配
    has_consecutive = max_streak >= min_consecutive
    # 返回所有ngram和是否满足连续匹配条件
    return all_ngrams, has_consecutive, match_positions 

n=len(next(iter(target_ngrams)))

这行代码的python讲述如下,其余参看代码注释

1、target_ngrams-这是一个包含目标n-gram的集合(set)

2、iter(target_ngrams)-创建一个迭代器,用于遍历集合中的元素

3、next(iter(target_ngrams))-获取迭代器中的下一个(也就是第一个)元素

4、len(next(iter(target_ngrams)))-计算这个元素(字符串)的长度,将这个长度赋值给变量n

举个例子,如果target_ngrams是{"ATG","CGA","GAT","TCG"}:

iter(target_ngrams)创建一个迭代器

next(iter(target_ngrams))返回集合中的任意一个元素,比如"ATG"

len("ATG")返回3,所以n的值为3

这种方法的巧妙之处在于:

不需要显式指定n-gram的长度,而是从目标集合中自动推断,因为集合中的所有元素都具有相同的长度(都是n-gram),所以只需要获取其中一个的长度即可,使用next()和iter()的组合可以高效地获取集合中的任意一个元素,而不需要将其转换为列表,这是一种动态获取n-gram长度的技术,使函数更加灵活,可以处理任意长度的n-gram,不需要用户额外指定长度参数。整行代码是非常pythonic的。

3.2.3运行检测

模拟生成待检测的基因序列

python 复制代码
def generate_random_sequence(length=1000):
    """生成指定长度的随机ATGC序列    
    Args:
        length: 序列长度,默认为1000        
    Returns:
        str: 随机生成的ATGC序列
    """
    bases = ['A', 'T', 'G', 'C']
    return ''.join(random.choices(bases, k=length))

运行

python 复制代码
if __name__ == "__main__":
    # 示例使用
    test_sequence = generate_random_sequence(1000)
    target_features = {"ATG", "CGA", "GAT", "TCG"}    
    # 执行检测
    ngrams, has_match, positions = check_disease_sequence(test_sequence, target_features)
    # 输出结果
    print("随机生成的序列:", test_sequence)
    print("所有3-gram集合:", ngrams)
    print("是否检测到连续匹配:", has_match)
    if positions:
        print("匹配位置:", positions)

运行结果如下

text 复制代码
随机生成的序列: CGCTGCGACCCAGAGGGCAGTATTCGTTCCGCTTATTCGTTCCCGTGCCTGAAAGTACATCACAAATTAGTTAACGTCAGAGGCTGCCGGCTGTCATGGAAATCGTTAAGCCACAAGAGGGAGCAACTTTTTTTGGAGTAGACGGACTCCTTTCTGTGGCGCTCGATCCCCTATTAGACACACATAGGCTACTCGGTTGCAACCATTCACGAGTAAACACTTGGAGGATGTTAGCTTTTCGGGCGCCTGAATACCCGCCGCGGGCCTCAAATCGAAGTGCTTGACGTGCGCGCATGAAGGATAGCTGGAACGATTTTTGACTTTATGAGGCTGTGGGGATGCACTTCAGTGCCTATCCTGCGCCTCGCCCGGTCTTAAACCCACCACGCATGCCAGAAGGCGGACCTCGACGCAGCTTATTCTCCGGGTGGTGCTATGTCCGGGCCCACTAAAAAGACTGATAGTCTCGGCTTTCACAGACAGCGGTTCTCCGAGCCTGCCGTGCACCCTGTGCGCCATGCACATGTAGAAGCGTTCCGTTACGGGTCTGGTGTCTCACCCTCGTAGACAAATTCTTGGGCACTCGATCATAAAAGGGCCATACACGTCCTGCTCTCGCACGAAAGTCTCAAAGTATGGTACTTGGCTTTACACATAATCCGCATAACTTGGCTCAAACTCGTCTATTTTTACGTCCATCGAGAGAGGTCAACGACCTTATTCTAGCTCCGCAGAGGCTTCACTACTCCAAAACTGAACCCGCATAGCAATGCCACTGAAACCAGCGAGGATCCACAGCAGAAGCAAACCGTCCGAGACAGAAGACTCCGAGATGCTCGCGTTCTTAGACATGTTATCGCATTCTGGCCTAGTACCAAAACAAATGACCCTGCGGCTAAGTTAACCTCAGGATGGAGGCATGTATTGGTCAGTAAGATACGTCCACAATCCAATTACATTGGGCGCACCGTTACCTGTCGGTGCAGGAACTAGTTTGTAT
所有3-gram集合: {'AAC', 'GAC', 'CAA', 'GGT', 'ATC', 'GGA', 'GTC', 'CGT', 'TGC', 'CTT', 'AAA', 'GCC', 'TAA', 'TAT', 'AGC', 'AAT', 'ATG', 'CAT', 'TCT', 'CAG', 'GCG', 'AAG', 'TAC', 'TAG', 'CCC', 'AGT', 'ACG', 'TGG', 'TGA', 'TGT', 'CCT', 'GAT', 'ACC', 'TTC', 'GAA', 'ACT', 'AGG', 'CGC', 'CGA', 'GGG', 'TTA', 'TTT', 'GCA', 'ACA', 'TTG', 'CTC', 'ATT', 'GTT', 'GGC', 'CTG', 'ATA', 'TCC', 'CGG', 'GTA', 'CCG', 'TCA', 'GTG', 'GCT', 'AGA', 'CAC', 'TCG', 'CTA', 'CCA', 'GAG'}
是否检测到连续匹配: True
匹配位置: [(162, 167), (583, 588)]

四、输入法预测

在上面的基因序列检测应用中,我们使用的是n-gram的"滑动窗口"的特性,是相对较简单的应用。我们再考虑一个需求,特别是引入建立模型、训练模型的思路及机制来更好地理解AI建模来解决问题的过程。

4.1需求分析

我们使用过各种输入法,搜狗输入法、QQ输入法等,为了加快输入速度,它们都有候选词功能,也就是说根据用户输入的几个字,来预测出将要输入哪些字,给出候选选项。这就是个典型的"预测"AI需求,今天我们就用n-gram来实现它。

在我的《信息检索及文本挖掘之TF-IDF从原理到实战》一文中,我们使用了民法典作为语料库(corpus)做了练习,本文继续用它来做我们的需求

  • 建立一个基于n-gram的模型

  • 用中国民法典来训练这个模型

  • 用户输入两个词,空格分隔

  • 模型预测出下一个词的候选词(至多3个),并给出计算过程及结果

  • 模型预测出下两个词的候选词(至多3个),并给出计算过程及结果

  • 用户输入exit退出

4.2代码实现

4.2.1建立模型

封装一个python类

python 复制代码
class NGramModel:
    def __init__(self, n: int = 3):
        """
        初始化 N-Gram 模型
        :param n: n-gram 的大小,默认为 3 (trigram)
        """
        self.n = n
        self.ngram_counts  = defaultdict(int)  # 存储 n-gram 的计数
        self.context_counts  = defaultdict(int)  # 存储上下文 (n-1 gram) 的计数
        self.vocab  = set()  # 词汇表

在这个模型里定义初始化方法,初始化几个自用计数器参数供训练及预测时使用(详见训练方法里用法)

在AI领域里,我们经常听说过【模型】、【大模型】、【大语言模型(LLM)】这样的词汇,像什么GPT-4、DeepSeek、豆包、智脑、Qwen3这样的大模型不一而足,其过程都是离不开模型的定义和训练。我们现在做的NGramModel也是基于同样的原理,只不过我们只用了n-gram一种算法而已。

4.2.2训练过程

NGramModel模型中,我们填一个训练方法

python 复制代码
    def train(self, corpus: List[str]) -> None:
        """
        训练 N-Gram 模型
        :param corpus: 语料库,每个元素是一个字符串,包含空格分隔的词
        """
        # 遍历语料库中的每个句子
        for sentence in corpus:
            # 将句子按空格分割成词列表
            tokens = sentence.split()
            # 将当前句子的所有词添加到词汇表集合中
            self.vocab.update(tokens)
            # 为句子添加边界标记:
            # 在开头添加n-1个开始标记`<s>`,在结尾添加1个结束标记</s>
            padded_tokens = ['`<s>`'] * (self.n - 1) + tokens + ['</s>']
            # 滑动窗口遍历处理后的词序列
            for i in range(len(padded_tokens) - self.n + 1):
                # 提取当前窗口的n个连续词作为n-gram
                ngram = tuple(padded_tokens[i:i + self.n])
                # 提取当前n-gram的前n-1个词作为context
                context = tuple(padded_tokens[i:i + self.n - 1])
                # 增加当前n-gram的计数
                self.ngram_counts[ngram] += 1
                # 增加当前context的计数
                self.context_counts[context] += 1

AI模型训练是通过海量数据驱动(本例就是民法典语料库),利用算法(本例就是n-gram)调整模型内部参数(本例就是ngram_counts\context_counts两个计数器参数),使其逐步学习数据中的规律,最终具备特定任务处理能力的过程。‌

关键行详细说明

1、tokens = sentence.split()

将输入的句子按空格分割成单词列表,例如:"智能 输入法" → ["智能", "输入法"]

2、self.vocab.update(tokens)

将当前句子的所有单词添加到词汇表集合中,使用集合自动去重

3、padded_tokens = ['<s>'] * (self.n - 1) + tokens + ['']

对于n-gram模型,预测每个词需要n-1个词的上下文,句子开头的词没有足够的上下文词,添加<s>标记确保每个词都有完整的上下文,避免概率计算时出现不一致的上下文长度。添加n-1个<s>标记(对于n-gram模型)确保第一个真实词有完整的n-1个词的上下文。在实际输入预测时,用户开始输入时也没有历史上下文。<s>标记模拟了这种"空上下文"的起始状态。

例如trigram模型中:

P(第一个词) = P(词1 | <s>, <s>)

P(第二个词) = P(词2 | <s>, 词1)

再举一例:

原始句子:"A B C",处理后:"<s> <s> A B C ",这样"A"的预测基于两个<s>标记,"B"的预测基于<s>和"A",依此类推。

4、for i in range(len(padded_tokens) - self.n + 1)

计算滑动窗口的遍历范围,确保窗口能完整覆盖n个词

5、ngram = tuple(padded_tokens[i:i + self.n])

提取当前窗口的n个词作为n-gram,使用tuple作为字典键(因为tuple可哈希)

6、context = tuple(padded_tokens[i:i + self.n - 1])

提取n-gram的前n-1个词作为context,用于计算条件概率P(word|context)

7、计数参数更新

self.ngram_counts[ngram] += 1:记录该n-gram出现的次数

self.context_counts[context] += 1:记录该context出现的次数

这两个计数将用于计算条件概率,训练结束后,就可以直接使用这两个参数存储的数据进行预测计算了。

通过这个训练过程,模型会学习到:

词汇表(所有出现过的词)

各种n-gram模式的出现频率

各种上下文模式的出现频率

这些统计信息将在后续的概率计算和预测中使用。

例如,要计算"P(编程|我爱)"(在"我爱"之后出现"编程"的概率),会使用公式:P(编程|我爱)=count(我,爱,编程)/count(我,爱)

其中:

count(我,爱,编程)存储在ngram_counts中

count(我,爱)存储在context_counts中

4.2.3预测算法

NGramModel模型中,我们先填一个预测方法要使用的计算概率的方法

python 复制代码
    def _calculate_probability(self, ngram: Tuple[str]) -> float:
        """
        计算 n-gram 的概率 (最大似然估计)
        P(w_n | w_1, w_2, ..., w_{n-1}) = count(w_1, w_2, ..., w_n) / count(w_1, w_2, ..., w_{n-1})
        :param ngram: 要计算概率的 n-gram
        :return: 概率值
        """
        context = ngram[:-1]
        ngram_count = self.ngram_counts.get(ngram,  0)
        context_count = self.context_counts.get(context,  0)
        if context_count == 0:
            return 0.0 
        return ngram_count / context_count

这里的似然估计就是前文2.4里讲过的概率换频率的计算逻辑。

预测方法如下

python 复制代码
def predict_next_words(self, context: str, num_words: int = 2) -> List[Tuple[str, float]]:
    """
    预测接下来的词及其概率
    :param context: 上下文字符串,包含空格分隔的词
    :param num_words: 要预测的词的数量 (1或2)
    :return: 可能的下一词及其概率的列表,按概率降序排列
    """
    # 检查预测词数参数是否合法
    if num_words not in (1, 2):
        raise ValueError("num_words 只能是 1 或 2")
    # 将输入上下文分割成词列表
    context_tokens = context.split()
    # 验证上下文长度是否符合n-gram模型要求
    if len(context_tokens) != self.n - 1:
        raise ValueError(f"上下文长度应为 {self.n-1},但得到 {len(context_tokens)}")
    # 将上下文转换为元组(用于字典查找)
    context_tuple = tuple(context_tokens)
    # 单词预测模式
    if num_words == 1:
        predictions = []
        # 遍历词汇表中的每个词
        for word in self.vocab:
            # 构建n-gram(上下文+候选词)
            ngram = context_tuple + (word,)
            # 计算该n-gram的概率
            probability = self._calculate_probability(ngram)
            # 只保留概率大于0的预测
            if probability > 0:
                predictions.append((word, probability))
        # 按概率降序排序
        predictions.sort(key=lambda x: -x[1])
        return predictions 
    # 双词预测模式
    else:
        two_word_predictions = []
        # 首先预测所有可能的第一个词
        first_word_predictions = self.predict_next_words(context, num_words=1)
        # 对每个可能的第一个词
        for first_word, first_prob in first_word_predictions:
            # 构建新的上下文(去掉最老的词,加入第一个预测词)
            new_context = context_tuple[1:] + (first_word,)
            # 预测第二个词
            second_word_predictions = self.predict_next_words("  ".join(new_context), num_words=1)
            # 组合双词预测结果
            for second_word, second_prob in second_word_predictions:
                # 计算联合概率(链式法则)
                joint_prob = first_prob * second_prob
                two_word_predictions.append(
                    (f"{first_word} {second_word}", joint_prob)
                )
        # 按联合概率降序排序
        two_word_predictions.sort(key=lambda x: -x[1])
        return two_word_predictions

关键行详细说明

  • 参数验证:确保num_words只能是1或2,检查上下文词数是否等于n-1

  • 单词预测:遍历词汇表中的每个词,构建n-gram并计算概率,过滤并排序结果

  • 双词预测:使用链式法则:P(w1,w2|context) = P(w1|context) * P(w2|context[1:],w1),先预测第一个词,再基于第一个词预测第二个词,计算联合概率并组合结果

  • 结果排序:所有预测结果都按概率降序排列,确保最可能的预测排在前面

4.2.4运行

回顾4.1需求分析,有个需求是要给出解释计算过程,来帮助读者用户理解这个模型是如何工作的

python 复制代码
    def explain_probability(self, context: str, word_sequence: str) -> Optional[str]:
        """
        解释概率计算逻辑
        :param context: 上下文
        :param word_sequence: 要解释的词序列 (1或2个词)
        :return: 概率计算的解释字符串,如果无法计算则返回None
        """
        words = word_sequence.split()
        if len(words) not in (1, 2):
            return None
        context_tokens = context.split()
        if len(context_tokens) != self.n - 1:
            return None
        context_tuple = tuple(context_tokens)
        if len(words) == 1:
            # 单个词的概率解释
            ngram = context_tuple + (words[0],)
            ngram_count = self.ngram_counts.get(ngram,  0)
            context_count = self.context_counts.get(context_tuple,  0)
            if context_count == 0:
                return None 
            explanation = (
                f"P({words[0]}|{','.join(context_tuple)}) = "
                f"count({','.join(ngram)}) / count({','.join(context_tuple)}) = "
                f"{ngram_count} / {context_count} = "
                f"{ngram_count/context_count:.3f}"
            )
            return explanation
        else:
            # 两个词的概率解释
            # P(w1,w2|context) = P(w1|context) * P(w2|context[1:],w1)
            first_ngram = context_tuple + (words[0],)
            first_ngram_count = self.ngram_counts.get(first_ngram,  0)
            context_count = self.context_counts.get(context_tuple,  0)
            if context_count == 0:
                return None
            second_context = context_tuple[1:] + (words[0],)
            second_ngram = second_context + (words[1],)
            second_ngram_count = self.ngram_counts.get(second_ngram,  0)
            second_context_count = self.context_counts.get(second_context,  0)
            if second_context_count == 0:
                return None
            first_prob = first_ngram_count / context_count
            second_prob = second_ngram_count / second_context_count
            joint_prob = first_prob * second_prob
            explanation = (
                f"P({words[0]} {words[1]}|{','.join(context_tuple)}) = "
                f"P({words[0]}|{','.join(context_tuple)}) * P({words[1]}|{','.join(second_context)}) = "
                f"[count({','.join(first_ngram)})/{','.join(context_tuple)}] * "
                f"[count({','.join(second_ngram)})/{','.join(second_context)}] = "
                f"[{first_ngram_count}/{context_count}] * [{second_ngram_count}/{second_context_count}] = "
                f"{first_prob:.3f} * {second_prob:.3f} = {joint_prob:.3f}"
            )
            return explanation 

这个方法根据用户的输入和模型预测出来的值来完整地解释计算预测过程

实现交互方法的代码

python 复制代码
def read_docx(file_path):
    """
    读取 Word 文档,并返回句子列表,每个句子内的词用空格分隔
    args:
        file_path: Word 文档的路径
    return:
        list: 句子列表,每个句子内的词用空格分隔
    """
    try:
        import re
        import jieba
        doc = Document(file_path)
        # 将所有段落文本连接在一起
        full_text = ' '.join([para.text for para in doc.paragraphs])        
        # 按句子分割(中文句号、问号、感叹号以及英文句号、问号、感叹号和空格)
        sentences = re.split(r'[。!?.!? ]', full_text)        
        # 过滤掉空句子并进行分词处理
        corpus = []
        for sentence in sentences:
            sentence = sentence.strip()
            if sentence:
                # 使用jieba进行中文分词
                words = list(jieba.cut(sentence))
                # 过滤掉空字符串和空白字符
                words = [word.strip() for word in words if word.strip()]
                # 将分词后的词用空格连接
                if words:  # 只添加非空句子
                    corpus.append(' '.join(words))        
        return corpus
    except KeyError:
        print(f"警告:无法读取文件 {file_path},可能是损坏的 Word 文件。")
        return []

def interactive_demo():
    doc = read_docx("E:\\练习项目\\练习项目\\vscode项目\\NLP\\docx\\第三编合同.docx")
    if doc and isinstance(doc, list):
        corpus = doc
    else:
        corpus = [
            "智能 输入法 好 发展",
            "智能 输入法 技术 发展",
            "智能 输入法 技术 发展",                
            "2025年 的 最新 进展",
            "混合 模型 效果 更好"
        ]
    for sentence in corpus:
        print((sentence))
    mode- = NGramModel(n=3)
    model.train(corpus)    
    print("NGram 模型训练完成。输入前两个词,模型将预测下一个可能的词或两个词。")
    print("输入 'exit' 退出程序。")
    #print("词汇表:", " ".join(model.get_vocabulary()))    
    while True:
        user_input = input("\n请输入前两个词(用空格分隔): ").strip()
        if user_input.lower()  == 'exit':
            break        
        try:
            # 预测下一个词
            print("\n预测下一个词:")
            predictions = model.predict_next_words(user_input,  num_words=1)
            if predictions:
                for word, prob in predictions[:3]:  # 显示前3个预测结果
                    explanation = model.explain_probability(user_input,  word)
                    print(f"{word}: {prob:.3f}")
                    print(f"  计算方式: {explanation}")
            else:
                print("没有找到预测结果。可能是上下文不在训练数据中。")

            # 预测下两个词
            print("\n预测下两个词:")
            predictions = model.predict_next_words(user_input,  num_words=2)
            if predictions:
                for words, prob in predictions[:3]:  # 显示前3个预测结果
                    explanation = model.explain_probability(user_input,  words)
                    print(f"{words}: {prob:.3f}")
                    print(f"  计算方式: {explanation}")
            else:
                print("没有找到预测结果。可能是上下文不在训练数据中。")                
        except ValueError as e:
            print(f"错误: {e}")

if __name__ == "__main__":
    # 运行交互式演示
    print("\n运行交互式演示...")
    interactive_demo()

运行结果如下

text 复制代码
NGram 模型训练完成。输入前两个词,模型将预测下一个可能的词或两个词。
输入 'exit' 退出程序。
请输入前两个词(用空格分隔): 管理 事务
预测下一个词:
不: 0.250
不: 0.250
  计算方式: P(不|管理,事务) = count(管理,事务,不) / count(管理,事务) = 2 / 8 = 0.250
经: 0.125
经: 0.125
  计算方式: P(经|管理,事务) = count(管理,事务,经) / count(管理,事务) = 1 / 8 = 0.125
取得: 0.125
  计算方式: P(取得|管理,事务) = count(管理,事务,取得) / count(管理,事务) = 1 / 8 = 0.125

预测下两个词:
经 受益人: 0.125
  计算方式: P(经 受益人|管理,事务) = P(经|管理,事务) * P(受益人|事务,经) = [count(管理,事务,经)/管理,事务] * [count(事务,经,受益人)/事务,经] = [1/8] * [1/1] = 0.125 * 1.000 = 0.125
取得 的: 0.125
  计算方式: P(取得 的|管理,事务) = P(取得|管理,事务) * P(的|事务,取得) = [count(管理,事务,取得)/管理,事务] * [count(事务,取得,的)/事务,取得] = [1/8] * [2/2] = 0.125 * 1.000 = 0.125
受到 损失: 0.125
  计算方式: P(受到 损失|管理,事务) = P(受到|管理,事务) * P(损失|事务,受到) = [count(管理,事务,受到)/管理,事务] * [count(事务,受到,损失)/事务,受到] = [1/8] * [1/1] = 0.125 * 1.000 = 0.125
请输入前两个词(用空格分隔): exit

作为统计语言模型的经典方法,n-gram通过局部词序列的共现频率建模语言概率分布,在自然语言处理发展初期(20世纪90年代至2010年代)曾是机器翻译、语音识别和文本生成的核心技术。其优势在于计算高效、可解释性强,但受限于数据稀疏性和长距离依赖问题。

当前,随着深度学习兴起,n-gram在主流任务中已被Transformer等神经模型取代,但仍保留三大应用场景:

轻量级系统:嵌入式设备或低算力环境中的实时预测(如手机输入法);

数据预处理:辅助神经模型训练(如词汇剪枝、特征提取);

低资源语言:小语种或领域特定语料的基线模型。

未来,n-gram可能以混合模型形式(如与神经网络的概率插值)继续服务于特定领域,或在可解释性要求高的场景中发挥余热。其核心思想(局部模式统计)仍对语言学研究和模型优化具有启发意义。

相关推荐
CoovallyAIHub5 小时前
基于YOLO集成模型的无人机多光谱风电部件缺陷检测
深度学习·算法·计算机视觉
CoovallyAIHub5 小时前
几十个像素的小目标,为何难倒无人机?LCW-YOLO让无人机小目标检测不再卡顿
深度学习·算法·计算机视觉
怀旧,5 小时前
【C++】19. 封装红⿊树实现set和map
linux·c++·算法
往事随风去5 小时前
Redis的内存淘汰策略(Eviction Policies)有哪些?
redis·后端·算法
神里流~霜灭5 小时前
(C++)数据结构初阶(顺序表的实现)
linux·c语言·数据结构·c++·算法·顺序表·单链表
一只乔哇噻6 小时前
java后端工程师进修ing(研一版 || day41)
java·开发语言·学习·算法
愚润求学6 小时前
【贪心算法】day7
c++·算法·leetcode·贪心算法
要开心吖ZSH6 小时前
软件设计师备考-(十六)数据结构及算法应用(重要)
java·数据结构·算法·软考·软件设计师
带娃的IT创业者6 小时前
如何开发一个教育性质的多线程密码猜测演示器
网络·python·算法