Uma das formas de avaliar um modelo de linguagem é calculando a sua perplexidade.
Dado um conjunto de testes com $m$ frases ($s_1, s_2, ..., s_m$), um modelo é bom se ele atribui probabilidade alta às frases do conjunto de testes, em outras palavras, se ele consegue prever as frases.
Vamos utilizar um dataset com os romances de Machado de Assis. O código abaixo lê o arquivo e extrai o conteúdo textual das obras, excluindo os nomes dos capítulos.
filepath = "machadodeassiscorpus.txt"
text = ""
with open(filepath) as corpus:
for line in corpus:
if not (line.startswith("#") or line.startswith("%")):
text += line
text = text.lower()
Os primeiros 100 caracteres do dataset são:
print(text[:100])
naquele dia, — já lá vão dez anos! — o dr. félix levantou-se tarde, abriu a janela e cumprimentou
As probabilidades são calculadas usando tokens do texto. Para quebrar o texto em tokens utilizaremos a biblioteca NLTK.
from nltk.tokenize import word_tokenize
tokens = word_tokenize(text)
Os 50 primeiros tokens são:
print(tokens[:50])
['naquele', 'dia', ',', '—', 'já', 'lá', 'vão', 'dez', 'anos', '!', '—', 'o', 'dr.', 'félix', 'levantou-se', 'tarde', ',', 'abriu', 'a', 'janela', 'e', 'cumprimentou', 'o', 'sol', '.', 'o', 'dia', 'estava', 'esplêndido', ';', 'uma', 'fresca', 'bafagem', 'do', 'mar', 'vinha', 'quebrar', 'um', 'pouco', 'os', 'ardores', 'do', 'estio', ';', 'algumas', 'raras', 'nuvenzinhas', 'brancas', ',', 'finas']
Precisamos agrupar os tokens em frases para calcular as probabilidades. Nesse caso, consideraremos o fim de uma frase quando o token for ".", "?" ou "!".
sentences = []
temp_sentence = []
for token in tokens:
temp_sentence.append(token)
if token in ".!?":
sentences.append(temp_sentence)
temp_sentence = []
Vejamos as duas primeiras frases retornadas:
print(" ".join(sentences[0]))
print(" ".join(sentences[1]))
naquele dia , — já lá vão dez anos ! — o dr. félix levantou-se tarde , abriu a janela e cumprimentou o sol .
Para calcular a probabilidade que o modelo atribui a todo conjunto de testes multiplicaremos as probabilidades atribuídas a cada frase:
\begin{equation*} P(teste) = \prod_{i=1}^{m}p(s_i) \end{equation*}O valor calculado rapidamente se torna muito pequeno, podendo causar problemas de underflow. Por isso, usaremos o $\log_2$ da probabilidade. Como $\log (a*b) = \log a + \log b$, temos:
\begin{equation*} \log_2 P(teste) = \sum_{i=1}^{m}\log_2 p(s_i) \end{equation*}Primeiramente vamos utilizar unigramas. Com isso, a probabilidade de um token $w_i$ é calculada assim:
\begin{equation*} P(w_i | w_1 w_2 ... w_{i-1}) = P(w_i) \end{equation*}Dessa forma, precisamos contar a ocorrência de cada token no dataset.
unigrams_count = {}
for token in tokens:
unigrams_count[token] = unigrams_count.get(token, 0) + 1
Podemos ver no resultado da contagem, por exemplo, quantas vezes a palavra "dia" e "mulher" aparecem.
print("Contagem da palavra 'dia': {}".format(unigrams_count["dia"]))
print("Contagem da palavra 'mulher': {}".format(unigrams_count["mulher"]))
Contagem da palavra 'dia': 776 Contagem da palavra 'mulher': 380
A probabilidade de uma frase é a multiplicação das probabilidades dos seus tokens. Nesse caso, se a frase tem $n$ tokens, temos:
\begin{equation*} P(s) = P(w_1) * P(w_2) * ... * P(w_n) \end{equation*}Entretanto, dependendo da frase, esse valor já pode sofrer com problemas de underflow. Por isso, vamos usar o $\log_2$ nesses cálculos.
\begin{equation*} \log_2 P(s) = \log_2 P(w_1) + \log_2 P(w_2) + ... + \log_2 P(w_n) \end{equation*}Para calcular a probabilidade de uma frase definiremos uma função. Ela vai receber a frase, os tokens e as contagens como parâmetros.
As contagens podem ser computadas através dos tokens, mas, por questões de desempenho, vamos passar para função as contagens que já temos.
from math import log2
def sentence_unigram_prob(sentence, tokens, counts):
total = len(tokens)
prob = 0
for token in sentence:
token_prob = counts[token]/total
prob += log2(token_prob)
return prob
Testando com frases do dataset:
print("Log_2 da probabilidade da frase '{}': {}".format(" ".join(sentences[0]),
sentence_unigram_prob(sentences[0], tokens, unigrams_count)))
print("Log_2 da probabilidade da frase '{}': {}".format(" ".join(sentences[1]),
sentence_unigram_prob(sentences[1], tokens, unigrams_count)))
Log_2 da probabilidade da frase 'naquele dia , — já lá vão dez anos !': -94.47081343852943 Log_2 da probabilidade da frase '— o dr. félix levantou-se tarde , abriu a janela e cumprimentou o sol .': -136.40681288913103
Agora vamos calcular o $\log_2$ da probabilidade que o modelo atribui ao conjunto de testes:
unigram_prob = 0
for sentence in sentences:
sentence_prob = sentence_unigram_prob(sentence, tokens, unigrams_count)
unigram_prob += sentence_prob
print(unigram_prob)
-5982709.342760975
Um dataset com muitas frases tende a possuir uma probabilidade menor que um com poucas, pois mais valores de probabilidade são multiplicados. Por isso, para normalizar o resultado, vamos tirar uma média dividindo o resultado pelo número de tokens.
total_tokens = len(tokens)
mean = unigram_prob/total_tokens
print(mean)
-9.498097815888574
Para finalizar, vamos elevar 2 ao valor negativo da média. Assim, a perplexidade será um número positivo e quanto menor ela for, maior é a probabilidade atribuída pelo modelo.
\begin{equation*} Perplexidade = 2^{-\frac{1}{M}\sum_{i=1}^{m}\log_2 p(s_i)} \end{equation*}perplexity = 2**(-1*(mean))
print(perplexity)
723.123281725287
O modelo unigrama tende a ser ruim em prever probabilidades, pois cada token é avaliado individualmente, sem considerar tokens anteriores. Espera-se que um modelo bigrama ou trigrama tenham desempenho melhores respectivamente. Assim, vamos calcular a perplexidade usando bigramas.
O primeiro passo é fazer a contagem dos bigramas:
bigrams_count = {}
for i in range(1, len(tokens)):
pair = (tokens[i-1], tokens[i])
bigrams_count[pair] = bigrams_count.get(pair, 0) + 1
Podemos ver no resultado da contagem, por exemplo, quantas vezes os pares ("o", "dia") e ("a", "mulher") aparecem
print("Contagem do par ('o', 'dia'): {}".format(bigrams_count[("o", "dia")]))
print("Contagem da par ('a', 'mulher'): {}".format(bigrams_count[("a", "mulher")]))
Contagem do par ('o', 'dia'): 62 Contagem da par ('a', 'mulher'): 139
Utilizando bigramas, a probabilidade de um token $w_i$ é calculada assim:
\begin{equation*} P(w_i | w_1 w_2 ... w_{i-1}) = P(w_i | w_{i-1}) \end{equation*}Com isso, a probabilidade de uma frase com $n$ tokens é:
\begin{equation*} P(s) = P(w_1) * P(w_2 | w_1) * P(w_3 | w_2) ... * P(w_n | w_{n-1}) \end{equation*}Sendo
\begin{equation*} P(b | a) = \frac{count(a, b)}{count(b)} \end{equation*}Calcularemos o $\log_2$ das probabilidades usando a função a seguir:
def sentence_bigram_prob(sentence, tokens, unigram_counts, bigram_counts):
total = len(tokens)
prob = log2(unigram_counts[tokens[0]]/total)
for i in range(1, len(sentence)):
pair = (tokens[i-1], tokens[i])
token_prob = bigram_counts[pair]/unigram_counts[tokens[i-1]]
prob += log2(token_prob)
return prob
Testando com o dataset:
print("Log_2 da probabilidade da frase '{}': {}".format(" ".join(sentences[0]),
sentence_bigram_prob(sentences[0], tokens, unigrams_count, bigrams_count)))
print("Log_2 da probabilidade da frase '{}': {}".format(" ".join(sentences[1]),
sentence_bigram_prob(sentences[1], tokens, unigrams_count, bigrams_count)))
Log_2 da probabilidade da frase 'naquele dia , — já lá vão dez anos !': -55.62808216458857 Log_2 da probabilidade da frase '— o dr. félix levantou-se tarde , abriu a janela e cumprimentou o sol .': -83.96472522394838
Agora vamos calcular o $\log_2$ da probabilidade que o modelo bigrama atribui ao conjunto de testes:
bigram_prob = 0
for sentence in sentences:
sentence_prob = sentence_bigram_prob(sentence, tokens, unigrams_count, bigrams_count)
bigram_prob += sentence_prob
print(bigram_prob)
-3664774.817399596
Finalizando o cálculo da perplexidade, temos:
mean = bigram_prob/total_tokens
perplexity = 2**(-1*(mean))
print(perplexity)
56.421179878762686
Como esperado, a perplexidade para bigramas é menor, mostrando que esse modelo é melhor que o unigrama.