N-gram language models with nltk.lm

NLTK · Expert · 15 min

N-gram LMs are the conceptual ancestor of today's large language models and still teach smoothing, sparsity and evaluation.

A language model assigns probabilities to word sequences. An n-gram model approximates P(word | history) using only the previous n-1 words, estimated from counts.

nltk.lm structures this:

  • padded_everygram_pipeline(n, sentences) pads each sentence with <s> and </s> and produces all 1..n-grams for training plus a flat vocabulary stream.
  • Model classes: MLE (raw relative frequencies), Laplace (add-one), Lidstone(gamma), KneserNeyInterpolated and WittenBellInterpolated.
  • lm.fit(train, vocab), then lm.score(word, context), lm.logscore (base 2), lm.generate(n, text_seed=..., random_seed=...).

Sparsity is the core problem. MLE gives probability 0 to any unseen n-gram, so a single new bigram makes a test sentence impossible and perplexity infinite. Smoothing moves some probability mass to unseen events: Laplace does it crudely; Kneser-Ney, which also accounts for how many contexts a word appears in, is the classic best performer.

The Vocabulary maps rare or unseen words to <UNK> (controlled by unk_cutoff), which keeps out-of-vocabulary words from breaking scoring.

Perplexity, lm.perplexity(test_ngrams), is 2 to the power of the average negative log2 probability: lower is better and roughly means "how many choices the model is confused between". Compare perplexities only between models with the same vocabulary.

Example

from nltk.lm import MLE, KneserNeyInterpolated, Laplace
from nltk.lm.preprocessing import padded_everygram_pipeline
from nltk.util import bigrams, pad_sequence

corpus = [s.split() for s in ["the cat sat on the mat", "the dog sat on the log",
                              "the cat saw the dog", "a dog sat on a mat"]]
models = {}
for name, cls in [("mle", MLE), ("laplace", Laplace), ("kn", KneserNeyInterpolated)]:
    train, vocab = padded_everygram_pipeline(2, corpus)
    models[name] = cls(2)
    models[name].fit(train, vocab)

mle = models["mle"]
print(mle.counts[["the"]]["cat"], round(mle.score("cat", ["the"]), 3))   # 2 0.333
print(mle.score("log", ["a"]))                                          # 0.0 unseen

test = list(bigrams(pad_sequence("a cat sat on the log".split(), 2,
                                 pad_left=True, left_pad_symbol="<s>",
                                 pad_right=True, right_pad_symbol="</s>")))
for name, m in models.items():
    print(name, round(m.perplexity(test), 2))
# mle inf   (one unseen bigram ruins it)
# laplace 7.04
# kn 4.09
print(mle.generate(5, text_seed=["the"], random_seed=3))   # e.g. ['cat', 'saw', 'the', 'dog', 'sat']

Remember

  • N-gram LMs estimate P(word | previous n-1 words) from counts
  • padded_everygram_pipeline pads sentences and builds training n-grams
  • MLE gives zero to unseen n-grams; Laplace and Kneser-Ney smooth
  • Perplexity (lower is better) compares models on the same vocabulary

Watch out for

  • Comparing perplexities of models trained with different vocabularies or UNK handling.

Check yourself

  1. An MLE bigram model reports infinite perplexity on a test sentence. Why?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.nltk.org/api/nltk.lm.html

Builds on

Unlocks

Nothing builds on this yet.