# Natural Language Toolkit: Language Models
#
# Copyright (C) 2001-2024 NLTK Project
# Authors: Ilia Kurenkov <ilia.kurenkov@gmail.com>
# URL: <https://www.nltk.org/
# For license information, see LICENSE.TXT
"""
NLTK Language Modeling Module.
------------------------------

Currently this module covers only ngram language models, but it should be easy
to extend to neural models.


Preparing Data
==============

Before we train our ngram models it is necessary to make sure the data we put in
them is in the right format.
Let's say we have a text that is a list of sentences, where each sentence is
a list of strings. For simplicity we just consider a text consisting of
characters instead of words.

    >>> text = [['a', 'b', 'c'], ['a', 'c', 'd', 'c', 'e', 'f']]

If we want to train a bigram model, we need to turn this text into bigrams.
Here's what the first sentence of our text would look like if we use a function
from NLTK for this.

    >>> from nltk.util import bigrams
    >>> list(bigrams(text[0]))
    [('a', 'b'), ('b', 'c')]

Notice how "b" occurs both as the first and second member of different bigrams
but "a" and "c" don't? Wouldn't it be nice to somehow indicate how often sentences
start with "a" and end with "c"?
A standard way to deal with this is to add special "padding" symbols to the
sentence before splitting it into ngrams.
Fortunately, NLTK also has a function for that, let's see what it does to the
first sentence.

    >>> from nltk.util import pad_sequence
    >>> list(pad_sequence(text[0],
    ... pad_left=True,
    ... left_pad_symbol="<s>",
    ... pad_right=True,
    ... right_pad_symbol="</s>",
    ... n=2))
    ['<s>', 'a', 'b', 'c', '</s>']

Note the `n` argument, that tells the function we need padding for bigrams.
Now, passing all these parameters every time is tedious and in most cases they
can be safely assumed as defaults anyway.
Thus our module provides a convenience function that has all these arguments
already set while the other arguments remain the same as for `pad_sequence`.

    >>> from nltk.lm.preprocessing import pad_both_ends
    >>> list(pad_both_ends(text[0], n=2))
    ['<s>', 'a', 'b', 'c', '</s>']

Combining the two parts discussed so far we get the following preparation steps
for one sentence.

    >>> list(bigrams(pad_both_ends(text[0], n=2)))
    [('<s>', 'a'), ('a', 'b'), ('b', 'c'), ('c', '</s>')]

To make our model more robust we could also train it on unigrams (single words)
as well as bigrams, its main source of information.
NLTK once again helpfully provides a function called `everygrams`.
While not the most efficient, it is conceptually simple.


    >>> from nltk.util import everygrams
    >>> padded_bigrams = list(pad_both_ends(text[0], n=2))
    >>> list(everygrams(padded_bigrams, max_len=2))
    [('<s>',), ('<s>', 'a'), ('a',), ('a', 'b'), ('b',), ('b', 'c'), ('c',), ('c', '</s>'), ('</s>',)]

We are almost ready to start counting ngrams, just one more step left.
During training and evaluation our model will rely on a vocabulary that
defines which words are "known" to the model.
To create this vocabulary we need to pad our sentences (just like for counting
ngrams) and then combine the sentences into one flat stream of words.

    >>> from nltk.lm.preprocessing import flatten
    >>> list(flatten(pad_both_ends(sent, n=2) for sent in text))
    ['<s>', 'a', 'b', 'c', '</s>', '<s>', 'a', 'c', 'd', 'c', 'e', 'f', '</s>']

In most cases we want to use the same text as the source for both vocabulary
and ngram counts.
Now that we understand what this means for our preprocessing, we can simply import
a function that does everything for us.

    >>> from nltk.lm.preprocessing import padded_everygram_pipeline
    >>> train, vocab = padded_everygram_pipeline(2, text)

So as to avoid re-creating the text in memory, both `train` and `vocab` are lazy
iterators. They are evaluated on demand at training time.


Training
========
Having prepared our data we are ready to start training a model.
As a simple example, let us train a Maximum Likelihood Estimator (MLE).
We only need to specify the highest ngram order to instantiate it.

    >>> from nltk.lm import MLE
    >>> lm = MLE(2)

This automatically creates an empty vocabulary...

    >>> len(lm.vocab)
    0

... which gets filled as we fit the model.

    >>> lm.fit(train, vocab)
    >>> print(lm.vocab)
    <Vocabulary with cutoff=1 unk_label='<UNK>' and 9 items>
    >>> len(lm.vocab)
    9

The vocabulary helps us handle words that have not occurred during training.

    >>> lm.vocab.lookup(text[0])
    ('a', 'b', 'c')
    >>> lm.vocab.lookup(["aliens", "from", "Mars"])
    ('<UNK>', '<UNK>', '<UNK>')

Moreover, in some cases we want to ignore words that we did see during training
but that didn't occur frequently enough, to provide us useful information.
You can tell the vocabulary to ignore such words.
To find out how that works, check out the docs for the `Vocabulary` class.


Using a Trained Model
=====================
When it comes to ngram models the training boils down to counting up the ngrams
from the training corpus.

    >>> print(lm.counts)
    <NgramCounter with 2 ngram orders and 24 ngrams>

This provides a convenient interface to access counts for unigrams...

    >>> lm.counts['a']
    2

...and bigrams (in this case "a b")

    >>> lm.counts[['a']]['b']
    1

And so on. However, the real purpose of training a language model is to have it
score how probable words are in certain contexts.
This being MLE, the model returns the item's relative frequency as its score.

    >>> lm.score("a")
    0.15384615384615385

Items that are not seen during training are mapped to the vocabulary's
"unknown label" token. This is "<UNK>" by default.

    >>> lm.score("<UNK>") == lm.score("aliens")
    True

Here's how you get the score for a word given some preceding context.
For example we want to know what is the chance that "b" is preceded by "a".

    >>> lm.score("b", ["a"])
    0.5

To avoid underflow when working with many small score values it makes sense to
take their logarithm.
For convenience this can be done with the `logscore` method.

    >>> lm.logscore("a")
    -2.700439718141092

Building on this method, we can also evaluate our model's cross-entropy and
perplexity with respect to sequences of ngrams.

    >>> test = [('a', 'b'), ('c', 'd')]
    >>> lm.entropy(test)
    1.292481250360578
    >>> lm.perplexity(test)
    2.449489742783178

It is advisable to preprocess your test text exactly the same way as you did
the training text.

One cool feature of ngram models is that they can be used to generate text.

    >>> lm.generate(1, random_seed=3)
    '<s>'
    >>> lm.generate(5, random_seed=3)
    ['<s>', 'a', 'b', 'c', 'd']

Provide `random_seed` if you want to consistently reproduce the same text all
other things being equal. Here we are using it to test the examples.

You can also condition your generation on some preceding text with the `context`
argument.

    >>> lm.generate(5, text_seed=['c'], random_seed=3)
    ['</s>', 'c', 'd', 'c', 'd']

Note that an ngram model is restricted in how much preceding context it can
take into account. For example, a trigram model can only condition its output
on 2 preceding words. If you pass in a 4-word context, the first two words
will be ignored.
"""

from nltk.lm.counter import NgramCounter
from nltk.lm.models import (
    MLE,
    AbsoluteDiscountingInterpolated,
    KneserNeyInterpolated,
    Laplace,
    Lidstone,
    StupidBackoff,
    WittenBellInterpolated,
)
from nltk.lm.vocabulary import Vocabulary

__all__ = [
    "Vocabulary",
    "NgramCounter",
    "MLE",
    "Lidstone",
    "Laplace",
    "WittenBellInterpolated",
    "KneserNeyInterpolated",
    "AbsoluteDiscountingInterpolated",
    "StupidBackoff",
]
