U5.4 Comparando os Modelos
As três páginas anteriores ajustaram a LDA, a NMF e o BERTopic um de cada vez. Esta página os coloca lado a lado nos mesmos posts, com o mesmo número de tópicos e duas sementes para cada, resume as diferenças numa tabela e termina com as armadilhas que pegam a maioria dos projetos de modelagem de tópicos.
- O que é um tópico?palavras que andam juntas ou documentos que andam juntos
- LDAalocação latente de Dirichlet: uma história de como as palavras são escritas
- NMFfatoração de matrizes não negativas: um documento como soma de partes
- BERTopicembeddings, redução, agrupamento, descrição
- Comparar e escolheros três lado a lado; as armadilhas
Preparação: o que esta página usa das anteriores
Esta página usa tudo o que foi construído nas três anteriores. Se você começar por aqui, rode este bloco antes: ele reconstrói os posts, as matrizes de contagens e de TF-IDF, as funções de coerência e o modelo BERTopic da U5.3, com a etapa 5. Leva um ou dois minutos numa CPU, quase tudo nos embeddings.
import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer
def top_words(components, vocab, n=4):
return [' '.join(vocab[row.argsort()[::-1][:n]]) for row in components]
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
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()
from sklearn.decomposition import LatentDirichletAllocation
from sklearn.decomposition import NMF
from sklearn.feature_extraction.text import TfidfVectorizer
tv = TfidfVectorizer(**settings)
T = tv.fit_transform(docs) # mesmo vocabulário de X, pesos TF-IDF
B = CountVectorizer(stop_words='english', token_pattern=r'(?u)\b[a-zA-Z]{3,}\b',
binary=True).fit(docs) # referência: qual post contém qual palavra
Xb = B.transform(docs).tocsc()
col = B.vocabulary_
D = Xb.shape[0]
def n_docs(*ws): # número de posts que contêm todas as palavras
hit = np.ones(D, dtype=bool)
for w in ws:
hit &= Xb[:, col[w]].toarray().ravel() > 0
return int(hit.sum())
from itertools import combinations
def npmi(w1, w2):
p1, p2, p12 = n_docs(w1) / D, n_docs(w2) / D, n_docs(w1, w2) / D
return -1.0 if p12 == 0 else np.log(p12 / (p1 * p2)) / -np.log(p12)
def coherence(topics, n=10): # NPMI média nos pares das top-n palavras de cada tópico
return np.mean([np.mean([npmi(a, b) for a, b in combinations(t.split()[:n], 2)])
for t in topics])
def diversity(topics, n=10): # fração de palavras distintas nas top-n de todos os tópicos
ws = [w for t in topics for w in t.split()[:n]]
return len(set(ws)) / len(ws)
from sklearn.cluster import KMeans
from sklearn.metrics.pairwise import cosine_similarity
from sentence_transformers import SentenceTransformer
encoder = SentenceTransformer('all-MiniLM-L6-v2')
from sklearn.neighbors import NearestNeighbors
E = encoder.encode(docs, show_progress_bar=False) # etapa 1: um vetor de 384 dimensões por post
from umap import UMAP
umap_model = UMAP(n_neighbors=15, n_components=5, min_dist=0.0, metric='cosine',
random_state=42) # padrões do BERTopic, mais uma semente
Z = umap_model.fit_transform(E) # etapa 2: 384 -> 5 dimensões
from hdbscan import HDBSCAN
hdbscan_model = HDBSCAN(min_cluster_size=10, metric='euclidean',
cluster_selection_method='eom', prediction_data=True)
labels = hdbscan_model.fit_predict(Z) # etapa 3: regiões densas; -1 = outlier
cv_topics = CountVectorizer(stop_words='english', token_pattern=r'(?u)\b[a-zA-Z]{3,}\b')
from bertopic import BERTopic
topic_model = BERTopic(embedding_model=encoder, umap_model=umap_model,
hdbscan_model=hdbscan_model, vectorizer_model=cv_topics)
topics = np.array(topic_model.fit_transform(docs, embeddings=E)[0]) # reaproveita os vetores da etapa 1
from bertopic.representation import KeyBERTInspired
topic_model.update_topics(docs, vectorizer_model=cv_topics, representation_model=KeyBERTInspired())
LDA vs NMF vs BERTopic, medidos
Etapa 5. Os mesmos 2.155 posts, o mesmo número de tópicos para os três (24, o número que o BERTopic encontrou), duas sementes para cada. As pontuações são as da página da NMF, mais a homogeneidade dos tópicos em relação aos fóruns (homogeneity_score: 1,0 quando todo tópico contém posts de um único fórum; ela não penaliza dividir um fórum em vários tópicos). Para o BERTopic, os outliers ficam fora da homogeneidade:
from sklearn.metrics import homogeneity_score
rows, assign = [], {}
n_topics = int(topics.max()) + 1
for seed in [42, 43]:
lda_k = LatentDirichletAllocation(n_components=n_topics, random_state=seed).fit(X)
nmf_k = NMF(n_components=n_topics, init='nndsvda', random_state=seed, max_iter=500).fit(T)
tm = BERTopic(embedding_model=encoder, vectorizer_model=cv_topics,
umap_model=UMAP(n_neighbors=15, n_components=5, min_dist=0.0,
metric='cosine', random_state=seed),
hdbscan_model=HDBSCAN(min_cluster_size=10, prediction_data=True))
bt_label = np.array(tm.fit_transform(docs, embeddings=E)[0])
for name, lab, tops in [
('LDA', lda_k.transform(X).argmax(axis=1), top_words(lda_k.components_, vocab, 10)),
('NMF', nmf_k.transform(T).argmax(axis=1), top_words(nmf_k.components_, vocab, 10)),
('BERTopic', bt_label, [' '.join(w for w, _ in tm.get_topic(t))
for t in range(bt_label.max() + 1)])]:
assign[name, seed] = lab
inside = lab >= 0
rows.append(dict(model=name, seed=seed, topics=len(tops), outliers=f"{1 - inside.mean():.0%}",
coherence=round(coherence(tops), 3), diversity=round(diversity(tops), 2),
homogeneity=round(homogeneity_score(y[inside], lab[inside]), 3)))
print(pd.DataFrame(rows).to_string(index=False))
for name in ['LDA', 'NMF', 'BERTopic']:
print(f"{name:9s} seed 42 vs seed 43, ARI {adjusted_rand_score(assign[name, 42], assign[name, 43]):.3f}")
model seed topics outliers coherence diversity homogeneity
LDA 42 24 0% 0.265 0.72 0.526
NMF 42 24 0% 0.391 0.92 0.646
BERTopic 42 24 20% 0.251 0.88 0.927
LDA 43 24 0% 0.276 0.70 0.574
NMF 43 24 0% 0.407 0.91 0.644
BERTopic 43 16 10% 0.295 0.82 0.662
LDA seed 42 vs seed 43, ARI 0.164
NMF seed 42 vs seed 43, ARI 0.736
BERTopic seed 42 vs seed 43, ARI 0.324
Como ler:
- Homogeneidade. Com a semente 42, os tópicos do BERTopic são de longe os mais puros (0,927 contra 0,646 da NMF e 0,526 da LDA), mas isso é medido nos 80% de posts que ele manteve, enquanto a NMF e a LDA precisam colocar todo post em algum tópico; a seção de armadilhas mede o que acontece quando os outliers do BERTopic são forçados de volta. Com a semente 43, o BERTopic encontra 16 grupos mais amplos e a sua homogeneidade cai para 0,662, o nível da NMF.
- Coerência e diversidade favorecem a NMF (0,391 e 0,407; 0,92 e 0,91). A NPMI recompensa palavras que coocorrem em muitos posts, justamente as coocorrências que a NMF tenta reconstruir; as palavras do BERTopic são escolhidas por serem distintivas de um grupo, o que traz palavras mais raras (nomes de programas, pessoas, lugares). Uma pontuação de coerência mede um aspecto da qualidade, não a verdade.
- Estabilidade. Nenhum modelo dá os mesmos tópicos com outra semente. As atribuições da LDA mudam mais (ARI de 0,164 entre as duas rodadas) e as da NMF menos (0,736, graças ao início por SVD). As do BERTopic mudam de um jeito mais visível: a semente do UMAP muda até o número de tópicos, 24 contra 16 (ARI de 0,324).
- Custo (fora da tabela, porque os tempos variam de máquina para máquina): a etapa de embeddings domina o custo do BERTopic, alguns segundos por mil documentos curtos numa GPU e dezenas de segundos numa CPU; depois disso, UMAP e HDBSCAN levam segundos. A LDA e a NMF levam segundos numa CPU e não precisam baixar nenhum modelo.
A comparação geral:
| LDA | NMF | BERTopic | |
|---|---|---|---|
| Um tópico é | uma distribuição sobre palavras | uma linha não negativa de \(H\) | um grupo de documentos, nomeado pelo c-TF-IDF |
| Um documento é | uma mistura \(\theta_d\) | um vetor de pesos \(W_d\) | um tópico ou −1 (há também uma distribuição) |
| Número de tópicos | fixado de antemão | fixado de antemão | encontrado pelo HDBSCAN (pode ser reduzido depois) |
| Documentos curtos | fraca (contagens de menos) | fraca | forte: embeddings não precisam de palavras em comum |
| Outliers | nenhum: toda palavra pertence a um tópico | nenhum | explícitos (tópico −1) |
| Dependência da semente | forte (início aleatório) | menor (início por SVD) | sim (UMAP), até o número de tópicos |
| Interpretabilidade | probabilidades, uma história generativa clara | partes que se somam, fáceis de ler | palavras legíveis; ajustáveis depois do ajuste |
| Custo | barato, CPU | o mais barato, CPU | modelo de embeddings; uma GPU ajuda |
Armadilhas
Stop words na representação. Visto na passo 5: sem uma lista de stop words, o BERTopic nomeia tópicos com the, of, to; e, mesmo com a lista do inglês, os tópicos da LDA nos posts mantiveram palavras corriqueiras como don, think, like, just. Uma lista na língua errada não ajuda: neste corpus, o tópico 6 do BERTopic é nomeado por van, het, een, palavras funcionais do holandês vindas de um punhado de posts escritos em holandês, que a lista do inglês não conhece. Outros tópicos-lixo clássicos são assinaturas, rodapés de e-mail e modelos de listas de discussão: texto que se repete palavra por palavra e forma grupos muito densos.
Outliers demais. 20% dos posts no tópico −1 é comum. O reduce_outliers os reatribui ao tópico mais próximo, usando o c-TF-IDF do documento ou o seu embedding:
for strategy, extra in [('c-tf-idf', {}), ('embeddings', {'embeddings': E})]:
new = np.array(topic_model.reduce_outliers(docs, topics, strategy=strategy, **extra))
print(f"{strategy:10s} outliers {(new == -1).sum():>3} homogeneity on all posts "
f"{homogeneity_score(y, new):.3f}")
c-tf-idf outliers 0 homogeneity on all posts 0.822
embeddings outliers 0 homogeneity on all posts 0.882
Agora todo post tem um tópico. A homogeneidade, medida em todos os 2.155 posts, cai de 0,927 (nos posts que o BERTopic manteve) para 0,822 ou 0,882, conforme a estratégia. Ainda fica bem acima da LDA e da NMF na tabela. Os outliers eram os posts ambíguos; forçá-los para dentro dos tópicos deixa os tópicos menos puros: uma troca, não uma correção grátis. Depois de reatribuir, chame topic_model.update_topics(docs, topics=new) para que as palavras dos tópicos sejam recalculadas com os novos membros. Um argumento threshold mantém como outliers os documentos que não são parecidos o bastante com nenhum tópico.
A granularidade dos tópicos é um degrau, não um botão giratório. min_cluster_size (chamado min_topic_size no BERTopic) decide o número de tópicos, mas não de forma suave:
for mcs in [5, 10, 15, 25]:
lab = HDBSCAN(min_cluster_size=mcs, metric='euclidean', cluster_selection_method='eom').fit_predict(Z)
print(f"min_cluster_size={mcs:<3} -> {lab.max() + 1:>3} topics, {(lab == -1).mean():.0%} outliers")
min_cluster_size=5 -> 96 topics, 28% outliers
min_cluster_size=10 -> 24 topics, 20% outliers
min_cluster_size=15 -> 5 topics, 0% outliers
min_cluster_size=25 -> 3 topics, 0% outliers
De 10 para 15, o modelo passa de 24 tópicos para 5, porque a regra de "excesso de massa" do HDBSCAN passa a preferir os grupos-pais grandes aos seus filhos. Varra alguns valores e leia os tópicos. Para ter menos tópicos, mais amplos, sem mudar o agrupamento, topic_model.reduce_topics(docs, nr_topics=...) funde os tópicos mais parecidos depois do ajuste (o lab faz isso); BERTopic(nr_topics=...) faz isso durante o ajuste.
Reprodutibilidade. Três fontes de aleatoriedade: o UMAP (fixe random_state, o que também o deixa single-thread e mais lento), os embeddings (idênticos no mesmo hardware e nas mesmas versões de bibliotecas, mas não bit a bit entre CPU e GPU) e, para a LDA e a NMF, o ponto de partida (random_state). Os embeddings merecem um teste. Some a eles um ruído pequeno demais para se ver, mantenha todas as sementes e rode as etapas 2 e 3 de novo:
print(f"original : {labels.max() + 1} topics, {(labels == -1).mean():.0%} outliers")
for noise_seed in [0, 1, 2]:
rng = np.random.default_rng(noise_seed)
E_noisy = E + rng.normal(0, 1e-6, size=E.shape).astype(np.float32) # mudanças de ~0,000001
Z_noisy = UMAP(n_neighbors=15, n_components=5, min_dist=0.0, metric='cosine',
random_state=42).fit_transform(E_noisy) # mesma semente da etapa 2
lab_noisy = HDBSCAN(min_cluster_size=10, metric='euclidean',
cluster_selection_method='eom').fit_predict(Z_noisy)
print(f"noise, seed {noise_seed}: {lab_noisy.max() + 1} topics, {(lab_noisy == -1).mean():.0%} outliers,"
f" ARI with the original {adjusted_rand_score(labels, lab_noisy):.3f}")
original : 24 topics, 20% outliers
noise, seed 0: 24 topics, 19% outliers, ARI with the original 0.908
noise, seed 1: 24 topics, 20% outliers, ARI with the original 0.910
noise, seed 2: 3 topics, 0% outliers, ARI with the original 0.514
Mudanças de cerca de um milionésimo, da ordem das diferenças entre calcular os embeddings numa CPU ou numa GPU, mudam um pouco a partição duas vezes (ARI de 0,908 e 0,910) e, na terceira, colapsam os 24 tópicos em 3. min_cluster_size=10 está na beira do degrau medido acima: o menor empurrão pode jogar o HDBSCAN para o outro lado. Fixar sementes torna uma rodada repetível; não torna os tópicos verdadeiros. Relate os tópicos que sobrevivem a várias sementes e a pequenas perturbações (a tabela acima mostrou duas sementes do UMAP dando 24 e 16 tópicos), prefira configurações longe de um degrau (o lab usa min_cluster_size=20 por isso) e salve o modelo ajustado (topic_model.save(...)) quando outras pessoas precisarem ver exatamente os mesmos tópicos.
A língua do modelo de embeddings. O all-MiniLM-L6-v2 foi treinado em inglês. Para corpora em português ou misturados, use um modelo multilíngue como o paraphrase-multilingual-MiniLM-L12-v2 (ou BERTopic(language='multilingual'), que escolhe um) junto com uma lista de stop words em português no vetorizador.
Próxima: o lab repete a aula inteira em cinco outros fóruns.