Pular para conteúdo

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.

  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 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.