Pular para conteúdo

U5.1 LDA

A LDA (Latent Dirichlet Allocation, alocação latente de Dirichlet) fica com a primeira resposta de O que é um tópico?: um tópico é uma distribuição sobre palavras e um documento é uma mistura de tópicos. Esta página conta a sua história à mão, mostra o que a priori de Dirichlet controla, confere num corpus sintético que o ajuste recupera os tópicos verdadeiros e depois a roda nas onze frases escritas à mão e em 2.155 posts reais de fóruns. Todo número sai do código ao lado.

  1. O que é um tópico?palavras que andam juntas ou documentos que andam juntos
  2. LDAalocação latente de Dirichlet: uma história de como as palavras são escritas
  3. NMFfatoração de matrizes não negativas: um documento como soma de partes
  4. BERTopicembeddings, redução, agrupamento, descrição
  5. Comparar e escolheros três lado a lado; as armadilhas
Preparação: o que esta página usa das anteriores

Esta página continua a visão geral. Se você começar por aqui, rode este bloco antes: ele reconstrói o corpus escrito à mão.

import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer

docs_toy = [
    "the team won the game with a late goal",                 # esportes
    "fans cheered when the team scored a goal",
    "the coach said the game was a fair win for the team",
    "the rocket launch put a satellite in orbit",             # espaço
    "nasa delayed the rocket launch to the moon",
    "the satellite reached orbit around the moon",
    "bake the bread in a hot oven",                           # culinária
    "knead the dough and bake the bread slowly",
    "fresh bread from the oven smells great",
    "the team watched the rocket launch before the game",     # esportes + espaço
    "astronauts ate fresh bread on the moon",                 # espaço + culinária
]
cv_toy = CountVectorizer(stop_words='english')
X_toy = cv_toy.fit_transform(docs_toy)
words_toy = cv_toy.get_feature_names_out()

A história generativa

Etapa 2. A Latent Dirichlet Allocation (Blei, Ng & Jordan, 2003) imagina que cada documento foi escrito por um processo aleatório:

  1. cada tópico \(k\) é uma distribuição de probabilidade \(\varphi_k\) sobre o vocabulário;
  2. cada documento \(d\) tem sua própria mistura de tópicos \(\theta_d\), por exemplo \((0{,}8;\ 0{,}2)\);
  3. para cada posição de palavra do documento, sorteie um tópico \(z\) de \(\theta_d\) e depois sorteie a palavra de \(\varphi_z\).

É uma história de como as palavras foram parar ali, não uma afirmação de que as pessoas escrevem assim. A ordem das palavras não entra nela; é por isso que a LDA só precisa das contagens bag-of-words. A história fixa a probabilidade de cada palavra no documento \(d\):

\[ p(w \mid d) = \sum_k \theta_{d,k}\, \varphi_{k,w} \]

À mão, com dois tópicos sobre seis palavras e um documento que é 80% esportes: \(p(\textit{team}) = 0{,}8 \times 0{,}40 + 0{,}2 \times 0{,}05 = 0{,}33\) e \(p(\textit{rocket}) = 0{,}8 \times 0{,}05 + 0{,}2 \times 0{,}40 = 0{,}12\). O código faz o mesmo para cada palavra e depois escreve um documento de 12 palavras seguindo a história:

vocab = np.array(['team', 'goal', 'game', 'rocket', 'orbit', 'moon'])
phi = np.array([[0.40, 0.30, 0.20, 0.05, 0.00, 0.05],     # tópico 0: "esportes"
                [0.05, 0.00, 0.05, 0.40, 0.30, 0.20]])    # tópico 1: "espaço"
theta = np.array([0.8, 0.2])                              # este documento: 80% esportes, 20% espaço

p_word = theta @ phi                                      # p(w|d) = soma em k de theta_k * phi_kw
print('  '.join(f"{w} {p:.2f}" for w, p in zip(vocab, p_word)))

rng = np.random.default_rng(0)
z = rng.choice(2, size=12, p=theta)                       # um tópico para cada posição de palavra...
words = [rng.choice(vocab, p=phi[k]) for k in z]          # ...depois uma palavra sorteada desse tópico
print(z)
print(' '.join(words))
team 0.33  goal 0.24  game 0.17  rocket 0.12  orbit 0.06  moon 0.08
[0 0 0 0 1 1 0 0 0 1 1 0]
game team game team moon orbit team goal team rocket orbit goal

Dois tópicos como gráficos de barras sobre seis palavras (esportes: team 0,40, goal 0,30, game 0,20; espaço: rocket 0,40, orbit 0,30, moon 0,20) e a distribuição de palavras do documento, 0,8 vez o primeiro mais 0,2 vez o segundo: team 0,33, goal 0,24, game 0,17, rocket 0,12, orbit 0,06, moon 0,08

Quatro das doze posições sortearam o tópico 1: são justamente as posições de moon, orbit, rocket, orbit. O documento é quase todo esportes, com um pouco de espaço, como diz \(\theta\) (script da figura).

A priori de Dirichlet: quão misturado é um documento?

O "Dirichlet" do nome é a distribuição da qual as misturas \(\theta_d\) são sorteadas. Seu parâmetro \(\alpha\) controla quão misturado é um documento típico:

rng = np.random.default_rng(1)
for alpha in [0.1, 1.0, 10.0]:
    thetas = rng.dirichlet([alpha] * 3, size=2000)        # misturas de tópicos de 2000 documentos
    print(f"alpha={alpha:<4}  one document: {thetas[0].round(2)}   "
          f"average largest share: {thetas.max(axis=1).mean():.2f}")
alpha=0.1   one document: [0. 1. 0.]   average largest share: 0.89
alpha=1.0   one document: [0.01 0.65 0.34]   average largest share: 0.62
alpha=10.0  one document: [0.18 0.46 0.36]   average largest share: 0.42

Misturas de 400 documentos sobre três tópicos desenhadas dentro de um triângulo cujos vértices são os tópicos puros: com alfa 0,1 os pontos se acumulam nos vértices (maior fatia 0,89 em média), com alfa 1 se espalham pelo triângulo (0,62), com alfa 10 se aglomeram no centro (0,42)

Com \(\alpha = 0{,}1\), um documento dá, em média, 89% das palavras a um único tópico; com \(\alpha = 10\), o maior tópico fica com só 42%, pouco acima do \(1/3\) de uma mistura perfeita (script da figura). Uma segunda Dirichlet, de parâmetro \(\eta\), faz o mesmo com as palavras de cada tópico. No scikit-learn elas são doc_topic_prior e topic_word_prior de LatentDirichletAllocation, ambas \(1/k\) por padrão, o que favorece documentos e tópicos concentrados em poucos itens.

Experimente abaixo: arraste a mistura \(\theta\) do documento dentro do triângulo e veja as doze posições mudarem de cor uma a uma; depois ponha \(\alpha\) em 0,1 e em 10 e aperte sortear θ da Dirichlet(α) algumas vezes. Os três tópicos usam o \(\varphi\) do corpus sintético da próxima seção (0,35, 0,30 e 0,25 nas suas três palavras).

Com \(\alpha\) pequeno quase todo documento sorteado sai de uma cor só; com \(\alpha\) grande todo documento mistura os três tópicos. As barras são \(p(w \mid d)\): as doze palavras oscilam em torno delas.

Ajustando a LDA: a história de trás para a frente

Ajustar a LDA inverte a história: só a partir das palavras observadas, estimar os tópicos \(\varphi\) e as misturas \(\theta\). O scikit-learn faz isso com inferência variacional, uma otimização iterativa que parte de um ponto aleatório. Para ver se funciona, gere um corpus com a própria história (assim os tópicos verdadeiros são conhecidos) e peça à LDA que os encontre:

from sklearn.decomposition import LatentDirichletAllocation

vocab9 = ['team', 'goal', 'game', 'rocket', 'orbit', 'moon', 'bread', 'oven', 'bake']
phi_true = np.full((3, 9), 0.10 / 6)                      # um pouco de cada palavra...
for k in range(3):
    phi_true[k, 3 * k:3 * k + 3] = [0.35, 0.30, 0.25]     # ...e 90% nas suas três palavras
rng = np.random.default_rng(0)
theta_true = rng.dirichlet([0.3] * 3, size=300)           # 300 documentos
X_syn = np.array([rng.multinomial(30, t @ phi_true) for t in theta_true])   # 30 palavras cada

lda_syn = LatentDirichletAllocation(n_components=3, random_state=0).fit(X_syn)
phi_hat = lda_syn.components_ / lda_syn.components_.sum(axis=1, keepdims=True)
order = [int(np.argmax(phi_hat[:, 3 * k])) for k in range(3)]   # alinha os tópicos com a verdade
print(pd.DataFrame(phi_hat[order], columns=vocab9).round(2))
theta_hat = lda_syn.transform(X_syn)[:, order]
print("correlation of true and estimated theta, per topic:",
      [round(float(np.corrcoef(theta_true[:, k], theta_hat[:, k])[0, 1]), 3) for k in range(3)])
   team  goal  game  rocket  orbit  moon  bread  oven  bake
0  0.35  0.31  0.25    0.02   0.01  0.02   0.01  0.02  0.02
1  0.01  0.01  0.02    0.36   0.31  0.26   0.01  0.01  0.01
2  0.01  0.01  0.01    0.01   0.01  0.02   0.34  0.32  0.26
correlation of true and estimated theta, per topic: [0.976, 0.974, 0.973]

components_ tem uma linha por tópico; dividida pela soma, a linha é o \(\varphi_k\) estimado. Os tópicos verdadeiros põem 0,35, 0,30 e 0,25 nas suas três palavras; a LDA os recupera com erro de 0,01. As misturas também voltam: a fatia estimada de cada tópico tem correlação de 0,97 com a verdadeira. Os tópicos saem numa ordem arbitrária (o tópico "2" de uma rodada pode ser o tópico "0" de outra); por isso o código primeiro os alinha com a verdade.

Aquele corpus tinha 9.000 palavras. O escrito à mão tem 53:

def top_words(components, vocab, n=4):
    return [' '.join(vocab[row.argsort()[::-1][:n]]) for row in components]

lda_toy = LatentDirichletAllocation(n_components=3, random_state=0).fit(X_toy)
print(top_words(lda_toy.components_, words_toy))
print(lda_toy.transform(X_toy).round(2)[[0, 3, 6, 9]])

def clean(labels):            # docs 0-2, 3-5, 6-8 cada um num tópico; três tópicos diferentes
    return all(len(set(labels[i:i + 3])) == 1 for i in (0, 3, 6)) and len(set(labels[:9])) == 3

n_clean = sum(clean(LatentDirichletAllocation(n_components=3, random_state=s)
                    .fit(X_toy).transform(X_toy).argmax(axis=1)) for s in range(20))
print("seeds with a clean split:", n_clean, "of 20")
['bread fresh game oven', 'team goal game orbit', 'rocket launch bread delayed']
[[0.06 0.88 0.06]
 [0.07 0.08 0.86]
 [0.86 0.07 0.07]
 [0.06 0.07 0.87]]
seeds with a clean split: 1 of 20

Com a semente 0, a LDA mistura os temas: bread e game dividem um tópico, orbit fica com team. Cada documento ainda é atribuído com confiança a um tópico (0,86 a 0,88), mas os tópicos não são os três temas. Em 20 sementes, só uma separa de forma limpa as nove frases de tema único. Não há nada de errado com o código: com 53 palavras, as contagens trazem pouca evidência e a otimização se acomoda onde o ponto de partida aleatório a levar. A LDA precisa de muitos documentos e de palavras suficientes em cada um; no corpus sintético ela tinha as duas coisas.

LDA em posts reais

O corpus real: posts de quatro fóruns da clássica coleção 20 Newsgroups, sem cabeçalhos, sem respostas citadas e sem a maior parte das assinaturas, descartados os posts com menos de 20 palavras. O fórum de cada post fica guardado à parte, em y, só para conferir os tópicos depois:

from sklearn.datasets import fetch_20newsgroups

forums = ['comp.graphics', 'rec.sport.hockey', 'sci.space', 'soc.religion.christian']
ng = fetch_20newsgroups(subset='train', categories=forums, random_state=0,
                        remove=('headers', 'footers', 'quotes'))   # só o corpo do texto
long_enough = np.array([len(text.split()) >= 20 for text in ng.data])
docs = [text for text, keep in zip(ng.data, long_enough) if keep]
y = ng.target[long_enough]                                         # o fórum: usado só para conferir
print(len(ng.data), '->', len(docs), 'posts with at least 20 words')
print(pd.Series(np.array(forums)[y]).value_counts())
2376 -> 2155 posts with at least 20 words
soc.religion.christian    565
sci.space                 535
rec.sport.hockey          534
comp.graphics             521
Name: count, dtype: int64
from sklearn.metrics import adjusted_rand_score

settings = dict(stop_words='english', min_df=5, max_df=0.5,
                token_pattern=r'(?u)\b[a-zA-Z]{3,}\b')             # palavras de 3+ letras, sem números
cv = CountVectorizer(**settings)
X = cv.fit_transform(docs)
vocab = cv.get_feature_names_out()
print(X.shape)

lda = LatentDirichletAllocation(n_components=4, random_state=0).fit(X)
print(*top_words(lda.components_, vocab, 8), sep='\n')
lda_label = lda.transform(X).argmax(axis=1)       # cada post vai para o seu maior tópico
print(f"ARI with the forums: {adjusted_rand_score(y, lda_label):.3f}")
(2155, 5725)
jpeg don pts like think play period gif
god space people think just jesus time like
team game hockey season games nhl play year
image edu data graphics space available software program
ARI with the forums: 0.456

O vocabulário guarda palavras de três letras ou mais que aparecem em pelo menos 5 posts e em no máximo metade deles. Hóquei sai limpo (team game hockey season nhl). Os outros três tópicos saem borrados: religião e computação gráfica dividem, cada uma, o seu tópico com space; um terceiro tópico é feito de palavras como jpeg, pts, period, gif, formatos de arquivo misturados com tabelas de placar de hóquei. Para comparar os tópicos com os fóruns, cada post vai para o seu maior tópico e o resultado é comparado aos fóruns pelo índice de Rand ajustado (1 = partições idênticas, 0 = não melhor que o acaso): 0,456.

Referências

  • Blei, D. M.; Ng, A. Y.; Jordan, M. I. "Latent Dirichlet Allocation." JMLR 3 (2003): 993–1022 — a história generativa e a sua inferência variacional. texto completo

Próxima: U5.2 NMF, os mesmos tópicos por fatoração de matrizes.