使用Python NLTK实现Kneser-Ney平滑处理三元组

8
我正在尝试使用Python NLTK和Kneser-Ney平滑算法来平滑一组n-gram概率。不幸的是,整个文档非常简略。
我要做的是:将文本解析为三元组列表。从列表中创建一个FreqDist,然后使用该FreqDist计算KN平滑分布。
我相当确定结果完全错误。当我总结各个概率时,得到的结果远远超过1。请看这段代码示例:
import nltk

ngrams = nltk.trigrams("What a piece of work is man! how noble in reason! how infinite in faculty! in \
form and moving how express and admirable! in action how like an angel! in apprehension how like a god! \
the beauty of the world, the paragon of animals!")

freq_dist = nltk.FreqDist(ngrams)
kneser_ney = nltk.KneserNeyProbDist(freq_dist)
prob_sum = 0
for i in kneser_ney.samples():
    prob_sum += kneser_ney.prob(i)
print(prob_sum)

输出结果为"41.51696428571428"。根据语料库的大小,该值会无限增长。这使得prob()返回的不是概率分布。
从NLTK代码来看,我认为该实现存在问题。也许我不理解代码应该如何使用。如果是这种情况,您能给我一些提示吗?如果不是,请问您知道任何可用的Python实现吗?我真的不想自己实现。

这是一个对数概率。因此,math.exp(41.51696428571428) = 1.0729722613480671e+18,概率非常小。当它增长时,KN平滑也会变大,这意味着概率更小。但也可能是NLTK实现有问题,请向https://github.com/nltk/nltk/issues报告问题。 - alvas
我不明白你的意思。1.0729722613480671e+18并不是非常小,而实际上是一个极大的数字。41.51696428571428是KneserNeyProbDist返回的所有概率之和,应该接近于1.0。 - Janek Bevendorff
哇,我误读了 e 的 +/- 符号 =) 请在 Github 上报告此问题。 - alvas
3个回答

11

我认为你误解了Kneser-Ney计算的内容。

引用自 维基百科:

规范化常数 λwi-1 的值被精心选择,使条件概率 pKN(wi|wi-1) 的总和等于1。

当然,我们在讨论双字母词组,但是对于高阶模型也是同样的原则。基本上,这段引文的意思是,在固定的上下文 wi-1(或者更多上下文用于高阶模型)下,所有 wi 的概率必须加起来等于1。当你把所有样本的概率加起来时,你包括了多个上下文,这就是为什么最终会得到一个大于1的“概率”的原因。如果你保持上下文不变,就像下面的代码示例中一样,你最终得到的数字是<= 1。



    from nltk.util import ngrams
    from nltk.corpus import gutenberg
# 使用 NLTK 提供的 ngrams() 函数生成由三元组组成的 generator gut_ngrams = (ngram for sent in gutenberg.sents() for ngram in ngrams(sent, 3, pad_left=True, pad_right=True, right_pad_symbol='EOS', left_pad_symbol="BOS"))
# 统计三元组出现的频率 freq_dist = nltk.FreqDist(gut_ngrams)
# 计算 Kneser-Ney 平滑概率分布 kneser_ney = nltk.KneserNeyProbDist(freq_dist)
# 找到以 "I" 和 "confess" 开头的三元组,计算它们的概率并求和 prob_sum = 0 for i in kneser_ney.samples(): if i[0] == "I" and i[1] == "confess": prob_sum += kneser_ney.prob(i) print("{0}:{1}".format(i, kneser_ney.prob(i))) print(prob_sum)

基于NLTK Gutenberg子集的输出如下。



    (u'I', u'confess', u'.--'):0.00657894736842
    (u'I', u'confess', u'what'):0.00657894736842
    (u'I', u'confess', u'myself'):0.00657894736842
    (u'I', u'confess', u'also'):0.00657894736842
    (u'I', u'confess', u'there'):0.00657894736842
    (u'I', u'confess', u',"'):0.0328947368421
    (u'I', u'confess', u'that'):0.164473684211
    (u'I', u'confess', u'"--'):0.00657894736842
    (u'I', u'confess', u'it'):0.0328947368421
    (u'I', u'confess', u';'):0.00657894736842
    (u'I', u'confess', u','):0.269736842105
    (u'I', u'confess', u'I'):0.164473684211
    (u'I', u'confess', u'unto'):0.00657894736842
    (u'I', u'confess', u'is'):0.00657894736842
    0.723684210526

这个和为0.72的原因是概率只计算了语料库中以"I"作为第一个单词,"confess"作为第二个单词的三元组。剩下的0.28概率被保留给了在语料库中不跟随"I"和"confess"的wi。这就是平滑的全部意义,将一些概率从出现在语料库中的ngrams重新分配到那些不会得到0概率ngrams的地方。
同时,这行代码:


    ngrams = nltk.trigrams("What a piece of work is man! how noble in reason! how infinite in faculty! in \
    form and moving how express and admirable! in action how like an angel! in apprehension how like a god! \
    the beauty of the world, the paragon of animals!")

计算字符三元组?我认为需要对其进行分词以计算单词三元组。


5

Kneser-Ney是一种比较复杂的平滑方法,只有少数软件包能够正确实现。对于不同平滑技术的综述,可以参考Goodman和Chen。目前我所知道的没有Python实现,但如果您只需要概率等信息,可以尝试使用SRILM

  • 样本中可能会出现一些未在训练数据中出现过的单词(也称为Out-Of-Vocabulary (OOV)单词),如果处理不当,可能会破坏您得到的概率值。这可能导致得到异常大且无效的概率值。

2
感谢您的回答。在上面的代码中,我所做的就是将所有学习样本的概率相加。因此,我根本没有检查任何未知样本的概率。未知样本的概率处理方式也很奇怪。如果我搜索一个未经训练的(w1,w2,w3),那么它会检查是否已知。如果不知道,它会为(w2,w3)提供平滑概率。如果这也是未知的,则会给出0.0。因此,在几乎所有情况下,当检查未学习的三元组时,您会得到0.0,这是荒谬的,因为我不需要对其进行平滑处理。 - Janek Bevendorff
1
当我有一个n-gram语料库时,它们的总概率之和应该为一(+/-数值误差)。否则我的n-gram分布不是概率分布。当我取出这个分布并平滑它时,我想重新分配一些概率来解释样本误差,并为“未见过”的事件留出一些空间。因此,当我对平滑后的概率求和时,我期望得到一个值为1.0-x,其中0 <= x <= 1是未见过的n-gram部分。 - Janek Bevendorff
1
@alvas 那不是 KN。这是 Goodman 和 Chen 修改后的 KN。KenLM 没有实现任何平滑除了修改过的 KN。 - user3639557
@JanekBevendorff 嗯。每个ngram都有自己的概率,所以我觉得期望所有ngram的概率之和等于一是无效的。唯一可以保证等于一的是,对于一个固定的上下文,你将右侧所有可能的补全词的概率求和: \sum_{x\in vocab} P(x|context) = 1。 - user3639557
FreqDist仍然总和为1,但经过平滑处理后不再是这样。SimpleGoodTuringProbDist平滑效果更好,但最终结果低于1。KenLM看起来很有趣,我会看看能做些什么。 - Janek Bevendorff
显示剩余3条评论

3

回答你的另一个问题:

除此之外:你知道任何有效的Python实现吗?

我刚刚完成了一个Kneser-Ney在Python中的实现。 代码在这里; README中也有报告。 如有疑问,请写信给我。


虽然这个链接可能回答了问题,但最好在此处包含答案的基本部分并提供参考链接。如果链接页面更改,仅有链接的答案可能会失效。- 来自审查 - Andy
我只是在回答这个问题:“你知道有哪些可用的Python实现吗?” - Giovanni Rescia
除了对0-gram进行迭代但什么都不做以及保持一张包含前一个单词集合和后一个单词集合的映射表,训练后唯一感兴趣的是集合的大小,这段代码看起来还算不错。谢谢分享。 - Mr.WorshipMe
此外,似乎您忽略了提供的折扣参数...如果没有提供,则会搜索最小化困惑度的参数,但如果提供了一个,则不使用它...而是使用一些特定的计算方法,这与未提供时使用的方法不同...这看起来很奇怪。 - Mr.WorshipMe
@Mr.WorshipMe 关于折扣参数,您是正确的,文档并不完全正确。实际上,参数D是一个标志:如果将其设置为None(默认值),则D将按照我使用算法的论文中计算。如果不是None,算法将尝试几个值,并使用给出更好结果的那个值。也就是说,折扣参数回答了这个问题:“您想计算D还是尝试不同的值并选择更好的一个?”感谢您的评论,我将修改脚本以避免这种混淆。 - Giovanni Rescia
关于0-gram计数和“不对它们进行任何操作”,当您使用unigram时,它非常有用,0-gram(由()表示)计算单词的数量。关于保留令牌的前一个和后一个单词,这对我进行调试很有帮助;但是我同意只保留集合大小会更好。如果有其他评论,请随时提出。 - Giovanni Rescia

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接