O Menor GPT do Mundo – Criando um pequeno modelo de linguagem com n-gram
Postado originalmente em 18 de setembro de 2024
O futuro marcará novembro de 2022 como a data em que o ChatGPT veio ao mundo. Conhecemos então o poder dos modelos GPT e, desde então, discussões sobre “Inteligência Artificial”, ética em algoritmos, e até onde máquinas podem suplantar humanos têm sido frequentes. Mas todo o avanço recente que temos experimentado em modelos de geração de linguagem por algoritmos (como o ChatGPT ou Gemini) foi construído utilizando décadas de conhecimento acumulado.
O que são n-grams?
Um dos primeiros tipos de modelos para se tentar modelar a linguagem foi o n-gram. Este tipo de modelo é bem simples comparado aos atuais modelos de LLMs (Large-Language Models, ou Grandes Modelos de Linguagem), mas dá um bom ponto de partida para entender como as atuais arquiteturas de modelos de linguagem foram construídas ao longo do tempo. Apesar de hoje em dia este modelo não ser usado especificamente em chatbots, o modelo ainda é usado em, por exemplo, sugestões de próximas palavras quando digitamos algo em celulares.
A ideia de um modelo n-gram é bastante simples. A pergunta é: “Dada uma sequência de palavras, quais são as probabilidades da próxima palavra ser X?”, onde X é uma palavra específica. Por exemplo, imagine que temos o seguinte texto: “Havia uma casa bonita. A casa era grande. A casa era colorida. A casa era aconchegante. Todos amavam a casa”. Dado este texto, podemos fazer a seguinte pergunta: “Qual a probabilidade de que ‘colorida’ apareça após de ‘A casa era’?”. Ou seja:
$$p(\text{colorida} | \text{a casa era}) = ?$$
E a solução, neste caso, é simples: Podemos apenas contar quantas vezes a palavra “colorida” aparece após de “a casa era” em nosso texto. Como temos “a casa era colorida” uma vez para três vezes que “a casa era” aparece, temos $p(\text{colorida} | \text{a casa era}) = \frac{1}{3}$.
Neste exemplo, usamos a frase “a casa era” como uma string fixa. Podemos generalizar este conceito para estimar a probabilidade não de uma palavra dada uma frase, mas de uma palavra dada outra palavra, construindo a sequência em cadeia. Temos então $p(\text{colorida}|\text{a casa era})=p(\text{a}) p(\text{casa}|\text{a}) p(\text{era}|\text{casa}) p(\text{colorida}|\text{era})$.
Genericamente, $$p(w)=p(w_{1}) p(w_{2}|w_{1})…p(w_{n}|w_{n-1})$$onde $w$ é uma sequência inteira de palavras, e $w_n$ é uma palavra dessa sequência.
Resumindo, para definir a probabilidade de que uma palavra A apareça após outra palavra B, precisamos simplesmente contar quantas vezes a palavra A aparece após B, e quantas vezes a palavra B aparece em geral. Isto é:
$$p(w_{A}|w_{B})=\frac{C(w_{B}w_{A})}{C(w_{B})}$$
onde $C(w_i)$ é a contagem da palavra $w_i$.
Dadas essas simples definições, é fácil gerar texto. Dado um conjunto de textos suficientemente grande, teremos várias probabilidades para quais palavras aparecem após outras. Tudo que temos que fazer é começar com uma palavra inicial, e então tomar a palavra mais provável para aparecer após aquela, e outra após aquela, e assim sucessivamente. O nome “n-gram” significa justamente que dado uma unidade de texto que chamamos de gram (que pode ser uma letra, palavra, frase, etc. Neste texto usaremos palavras como grams), escolhemos $n$ destes grams para “prever” o próximo gram.
Programando nosso modelo
Utilizaremos Python para construir nosso modelo. Vamos começar com um exemplo simples. Nosso corpo de texto será:
texto = "Era uma vez um rei que vivia em um castelo. O castelo era muito grande e tinha várias torres."
A primeira coisa que precisamos fazer é limpar o texto. Não queremos que pontuações estranhas apareçam no meio do texto. Além disso, vamos separar cada palavra por espaços, ou quebras de linha.
import re
def preprocessar_texto(texto):
delimitadores = '[ ]|[\r]|[\n]'
doc = re.split(delimitadores, texto)
return [i for i in doc if i != ""]
Agora criamos nosso modelo n-gram. Vamos primeiro colocar o código e então explicarei passo-a-passo:
def criar_modelo_ngram(documento, n):
transicoes = {}
for i in range(len(documento) - n + 1):
ngram = tuple(documento[i:i+n-1])
proxima_palavra = documento[i+n-1]
if ngram not in transicoes:
transicoes[ngram] = []
transicoes[ngram].append(proxima_palavra)
return transicoes
Primeiro, criamos um dicionário vazio chamado “transicoes”. Em seguida, vamos iterar para cada palavra do nosso texto, parando antes de chegar ao final do documento. Subtraímos $n$ para garantir que sempre poderemos verificar, para cada palavra, $n-1$ palavras anteriores àquela e $1$ palavra seguinte. Em seguida, para cada palavra, iremos criar uma variável “ngram” que separa o texto no que nos interessa: As palavras que vem exatamente antes e depois do nosso gram. Tomamos a próxima palavra em separado, e criamos um if para checar se aquele gram já está no dicionário. Se não, criamos. Depois, apenas adicionamos a próxima palavra para aquele n-gram.
Se você rodar esta função com o texto pré-processado, verá o seguinte:
> criar_modelo_ngram(preprocessar_texto(texto), 2)
{('era',): ['uma', 'muito'],
('uma',): ['vez'],
('vez',): ['um'],
('um',): ['rei', 'castelo'],
('rei',): ['que'],
('que',): ['vivia'],
('vivia',): ['em'],
('em',): ['um'],
('castelo',): ['o', 'era'],
('o',): ['castelo'],
('muito',): ['grande'],
('grande',): ['e'],
('e',): ['tinha'],
('tinha',): ['várias'],
('várias',): ['torres']}
É fácil ver que o modelo mapeia cada palavra à quais palavras seguem a esta.
Agora, só falta criar a função que gera nosso texto:
from collections import Counter
import random
def gerar_texto(documento, n, frase_inicial, n_max_palavras):
inicio = preprocessar_texto(frase_inicial)
atual = tuple(inicio[-(n-1):])
resultado = inicio
while len(resultado)<n_max_palavras:
possiveis_proximas_palavras = documento.get(atual, [])
if not possiveis_proximas_palavras:
break
contagem = Counter(possiveis_proximas_palavras)
total = sum(contagem.values())
probabilidades = {palavra: cont/total for palavra, cont in contagem.items()}
prob_max = max(probabilidades.values())
mais_provavel = [palavra for palavra, prob in probabilidades.items() if prob == prob_max]
proxima_palavra = random.choice(mais_provavel)
resultado.append(proxima_palavra)
atual = tuple(resultado[-(n-1):])
return " ".join(resultado)
Vamos explicar sucintamente o que esta função faz. Primeiro, estabelecemos uma palavra de início para gerar nosso texto (que pode também ser uma frase). Estabelecemos a variável “atual” que será usada para geração das palavras futuras, e “resultado” onde será guardado o texto final. Iremos iterar até um número máximo de palavras, e nessa iteração iremos, primeiramente, checar as possiveis palavras a seguir dada nossa palavra atual. Caso a palavra atual não tenha palavras candidatas à próxima, paramos o texto. Então contamos a frequência das possíveis próximas palavras, checando também a soma das frequências, e criamos um dicionário de probabilidades. Em seguida, iremos selecionar a palavra com probabilidade máxima. Adicionamos uma escolha aleatória para caso duas ou mais palavras tenham exatamente a mesma probabilidade, e nesse caso escolhemos uma ao acaso. Adicionamos a palavra selecionada ao resultado, atualizamos a próxima palavra, e iteramos novamente.
Testamos nosso modelo e obtemos o seguinte:
> t = criar_modelo_ngram(preprocessar_texto(texto), 2)
> gerar_texto(documento=t, n=2, frase_inicial="era uma vez", n_max_palavras=20)
"era uma vez um rei que vivia em um rei que vivia em um castelo o castelo era uma vez"
Temos nosso modelo! Se parece familiar, talvez você já tenha visto algo parecido no gerador de texto do seu celular, que usa exatamente n-gram.
Se o texto gerado parece ruim, não é por menos. Este modelo pode em muito ser melhorado, porém a explicação foge do escopo deste texto. Você pode acessar aqui o código final. Em primeiro lugar, podemos aumentar nosso corpo de texto. Como um exemplo, tomei os três livros da trilogia O Senhor dos Anéis como documento para nosso corpo de texto. Além disso, adicionei uma pequena modificação: o modelo tem uma tendência a repetir a mesma frase seguidas vezes. Com um contador, é possível prevenir isso. Também modifiquei os delimitadores, para garantir que pontuações façam partes do texto. Por questões de estética, a primeira letra de cada frase será setada como maiúscula. O seguinte foi o resultado:
> dic = criar_modelo_ngram(preprocessar_texto(sda), 2)
> ngram_model().gerar_texto(documento=t, n=2, frase_inicial="Havia")
'Havia uma consoante longa estrada passaram como coisas de valfenda teria plantas mais seca a canção que passavam de tristeza — vamos ficar aqui onde estão pesados do perigo desnecessário é quase tão bem fechadas qualquer coisa foi realmente achou fácil contra o tamanho medo de gandalf pegou-o em sua garganta e começou e todas aquelas barbas ou até lá fora colocado na mão cair no tesouro permaneça sobre seus pais das palavras precipitadas a escuridão onde morei mas ele mas os olhos quando tropeçava ao piquenique? — E em quando erguendo à sua revelia e robustas alguns pareciam constrangidos'
Ainda assim, vemos um texto um tanto confuso e sem sentido. Não se desanime, pois se você conseguiu compreender este modelo, deu o primeiro passo para entender modelos de linguagem. Apesar dos modelos atuais serem muito mais complexos em sua arquitetura, ainda utilizam algumas ideias que foram primeiro criadas no contexto de n-gram. Este é apenas o começo!
E lembre-se que neste blog há outros textos sobre modelos de linguagem, então se está interessado, explore um pouco mais o site!
Referências
- Shannon, Claude Elwood. “A mathematical theory of communication.” The Bell system technical journal 27.3 (1948): 379-423.
- Shannon, Claude E. “The redundancy of English.” Cybernetics; Transactions of the 7th Conference, New York: Josiah Macy, Jr. Foundation. 1951.