Pular para conteúdo

U5.3 BERTopic

O BERTopic fica com a segunda resposta de O que é um tópico?: um tópico é um grupo de documentos. Esta página primeiro mede por que isso ajuda, em documentos curtos, onde as contagens de palavras acabam; depois roda os quatro passos do BERTopic uma de cada vez nos 2.155 posts, confere que a classe BERTopic faz exatamente o mesmo em uma chamada e ajusta as palavras dos tópicos no fim.

  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 U5.2 NMF. Se você começar por aqui, rode este bloco antes: ele reconstrói os dois corpora e a matriz TF-IDF dos posts. O BERTopic precisa de pip install bertopic (no Colab também).

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
]

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 NMF
from sklearn.feature_extraction.text import TfidfVectorizer

tv = TfidfVectorizer(**settings)
T = tv.fit_transform(docs)                                  # mesmo vocabulário de X, pesos TF-IDF

Documentos curtos: onde as contagens de palavras acabam

Etapa 4. A LDA e a NMF só enxergam um documento pelas contagens de palavras. Um tweet, o título de uma avaliação ou o assunto de um chamado têm de cinco a vinte palavras, então dois documentos sobre o mesmo assunto muitas vezes não compartilham nenhuma. Para medir quanto isso importa, corte cada post do corpus nas primeiras \(n\) palavras e ajuste os dois modelos de novo (script da figura):

Concordância com os fóruns (ARI) em função do número de palavras mantidas por post: a NMF vai de 0,01 com 5 palavras e 0,12 com 20 palavras a 0,77 com os posts inteiros; a LDA de 0,00 a 0,03 com 20 palavras e 0,54 com os posts inteiros; abaixo de umas 40 palavras, as duas ficam perto de zero

Com as primeiras 20 palavras, os dois modelos ficam perto do acaso: ARI de 0,12 para a NMF e 0,03 para a LDA. Um sentence embedding não precisa de palavras compartilhadas, porque foi treinado em quantidades enormes de texto para pôr perto frases de mesmo significado:

from sklearn.cluster import KMeans
from sklearn.metrics.pairwise import cosine_similarity
from sentence_transformers import SentenceTransformer

pair = ["I want my money back", "please process a refund"]
print(f"TF-IDF cosine:    {cosine_similarity(TfidfVectorizer().fit_transform(pair))[0, 1]:.2f}")
encoder = SentenceTransformer('all-MiniLM-L6-v2')
print(f"embedding cosine: {cosine_similarity(encoder.encode(pair))[0, 1]:.2f}")

short = [' '.join(text.split()[:20]) for text in docs]           # só as primeiras 20 palavras
T_short = TfidfVectorizer(**{**settings, 'min_df': 2}).fit_transform(short)
nmf_short = NMF(n_components=4, init='nndsvda', max_iter=500,
                random_state=0).fit_transform(T_short).argmax(axis=1)
E_short = encoder.encode(short, show_progress_bar=False)
km_short = KMeans(n_clusters=4, n_init=10, random_state=0).fit_predict(E_short)
print(f"first 20 words only -> ARI  NMF on TF-IDF {adjusted_rand_score(y, nmf_short):.3f}"
      f"   k-means on embeddings {adjusted_rand_score(y, km_short):.3f}")
TF-IDF cosine:    0.00
embedding cosine: 0.60
first 20 words only -> ARI  NMF on TF-IDF 0.115   k-means on embeddings 0.582

Os dois pedidos de reembolso não compartilham nenhuma palavra, então os vetores TF-IDF são ortogonais (cosseno 0); já os embeddings têm cosseno de 0,60. Nos posts de 20 palavras, um k-means simples sobre os embeddings chega a um ARI de 0,582, cinco vezes o da NMF. Essa é a ideia por trás do BERTopic: primeiro pôr os documentos num espaço em que distância reflete significado, depois procurar grupos.

BERTopic: tópicos como grupos de documentos

O BERTopic (Grootendorst, 2022) troca a história generativa por uma geométrica: gerar embeddings dos documentos para que significado parecido vire proximidade no espaço, achar as regiões densas e descrever cada região pelas suas palavras. É uma cadeia de quatro passos independentes; cada um é tema de uma aula que você já viu:

Esta seção roda os passos um de cada vez nos 2.155 posts, inspeciona a saída de cada uma e depois mostra que o BERTopic faz exatamente o mesmo em uma chamada. Fora deste site, instale-o com pip install bertopic (no Colab também); ele traz junto sentence-transformers, umap-learn e hdbscan.

Passo 1 — embeddings

from sklearn.neighbors import NearestNeighbors

E = encoder.encode(docs, show_progress_bar=False)       # etapa 1: um vetor de 384 dimensões por post
print(E.shape)

def same_forum_neighbors(V, metric, k=10):             # fração dos k posts mais próximos de cada post
    nn = NearestNeighbors(n_neighbors=k + 1, metric=metric).fit(V)   # que são do mesmo fórum
    idx = nn.kneighbors(V, return_distance=False)[:, 1:]
    return (y[idx] == y[:, None]).mean()

print(f"10 nearest neighbors in the same forum:  TF-IDF {same_forum_neighbors(T, 'cosine'):.3f}"
      f"   embeddings {same_forum_neighbors(E, 'cosine'):.3f}")
(2155, 384)
10 nearest neighbors in the same forum:  TF-IDF 0.832   embeddings 0.942

all-MiniLM-L6-v2 é um sentence-transformer pequeno (Reimers & Gurevych, 2019): 384 dimensões, rápido em CPU, treinado em inglês. Ele lê só as primeiras 256 subpalavras de cada post, o que basta aqui. Uma verificação rápida de que o espaço é útil antes de agrupar nele: para cada post, tome os 10 posts mais próximos e conte quantos são do mesmo fórum. Com vetores TF-IDF, 83,2%; com embeddings, 94,2%.

Passo 2 — reduzir com UMAP

O agrupamento por densidade sofre em 384 dimensões: as distâncias se concentram e todo ponto parece mais ou menos igualmente longe de todos os outros (a maldição da dimensionalidade). O BERTopic primeiro comprime os embeddings com o UMAP (McInnes, Healy & Melville, 2018), que preserva a vizinhança de cada ponto:

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
print(Z.shape)
print(f"10 nearest neighbors in the same forum after UMAP: {same_forum_neighbors(Z, 'euclidean'):.3f}")
(2155, 5)
10 nearest neighbors in the same forum after UMAP: 0.954

Cinco dimensões, não duas: o objetivo é agrupar, não desenhar; duas dimensões espremeriam demais. min_dist=0.0 deixa o UMAP juntar bem os pontos parecidos, que é o que um agrupador por densidade quer. As vizinhanças sobreviveram à compressão (95,4% dos vizinhos do mesmo fórum, um pouco mais que em 384 dimensões). O UMAP é estocástico; random_state o torna repetível, um ponto ao qual as armadilhas voltam.

Passo 3 — agrupar com HDBSCAN; outliers são o tópico −1

O HDBSCAN (Campello, Moulavi & Sander, 2013) encontra regiões densas de qualquer forma e densidade, com um botão principal, min_cluster_size. Duas propriedades dele importam aqui: o número de grupos sai dos dados e ele não força todo ponto a entrar num grupo (pontos em regiões esparsas recebem o rótulo −1). O BERTopic usa o pacote hdbscan, o mesmo algoritmo do sklearn.cluster.HDBSCAN da aula de agrupamento, em outra implementação:

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
n_clusters = labels.max() + 1
print(n_clusters, 'clusters |', (labels == -1).sum(), f'outliers ({(labels == -1).mean():.1%})')

tab = pd.crosstab(labels, np.array(forums)[y], rownames=['cluster'], colnames=['forum'])
share = (tab.max(axis=1) / tab.sum(axis=1)).drop(-1)   # fração do fórum majoritário em cada grupo
print(f"clusters whose majority forum holds at least 90% of the posts: {(share >= 0.9).sum()} of {n_clusters}")
print(tab.loc[[-1, 0, 1, 2, 3]])
24 clusters | 431 outliers (20.0%)
clusters whose majority forum holds at least 90% of the posts: 21 of 24
forum    comp.graphics  rec.sport.hockey  sci.space  soc.religion.christian
cluster
-1                 197                 7        221                       6
 0                   0               516          4                       0
 1                   0                 3         10                     554
 2                  13                 0          1                       2
 3                  11                 0          1                       0

24 grupos (não 4) e 431 posts (20%) deixados de fora como outliers. Os grupos são puros: em 21 dos 24, pelo menos 90% dos posts vêm de um único fórum. Hóquei e religião formam, cada um, um grupo grande (516 e 554 posts), enquanto computação gráfica e espaço se partem em muitos grupos pequenos, que é o que eles são: formatos de arquivo, placas de vídeo, processamento de imagens, a Lua, o ônibus espacial... A maior parte dos outliers também são posts de computação gráfica e de espaço, os dois fóruns de conteúdo mais variado. Nem \(k\) nem o número de outliers foram fixados em lugar nenhum: os dois decorrem de min_cluster_size=10 e da densidade dos dados.

O simulador abaixo roda o mesmo algoritmo numa nuvem 2-D de brinquedo: um grupo grande e homogêneo, duas regiões feitas de lóbulos pequenos, um grupo médio e ruído espalhado. Mova min_cluster_size um valor por vez e observe os gráficos da direita: o número de tópicos cai aos poucos?

Não cai: o número fica parado por vários valores e depois despenca de uma vez; a fração de outliers salta junto. As armadilhas mostram o mesmo degrau nos posts: 24 tópicos com min_cluster_size=10 e 5 com 15.

Passo 4 — descrever cada grupo com c-TF-IDF

Um grupo é uma lista de documentos; pessoas precisam de algumas palavras. O BERTopic junta todos os documentos de um grupo num único pseudodocumento longo e aplica aos pseudodocumentos um TF-IDF por classe (class-based TF-IDF):

\[ \text{c-TF-IDF}(t, c) = \frac{n_{t,c}}{\sum_{t'} n_{t',c}} \times \log\!\Big(1 + \frac{A}{f_t}\Big) \]

em que \(n_{t,c}\) é a contagem da palavra \(t\) no grupo \(c\), o primeiro fator é a sua frequência dentro do grupo, \(f_t\) é a contagem de \(t\) em todos os grupos e \(A\) é o número médio de palavras por grupo. É o TF-IDF que você conhece, com o grupo no papel do documento: uma palavra pontua alto quando é frequente neste grupo e rara nos outros.

À mão, tome as nove frases de tema único do corpus feito à mão como três grupos:

classes = {'sports': docs_toy[0:3], 'space': docs_toy[3:6], 'cooking': docs_toy[6:9]}
joined = [' '.join(d) for d in classes.values()]         # um pseudodocumento por grupo
cv_c = CountVectorizer(stop_words='english')
C = cv_c.fit_transform(joined).toarray()                 # grupos x palavras: contagens
words_c = list(cv_c.get_feature_names_out())
size = C.sum(axis=1)                                     # palavras por grupo
A = int(size.mean())                                     # média de palavras por grupo (truncada)
print('words per cluster:', size, '  A =', A)

rows = []
for w in ['team', 'game', 'launch', 'moon', 'bread']:
    j = words_c.index(w)
    f = C[:, j].sum()                                    # frequência de w em todos os grupos
    idf = np.log(1 + A / f)
    rows.append([w, *C[:, j], f, round(idf, 3), *(C[:, j] / size * idf).round(3)])
print(pd.DataFrame(rows, columns=['word', 'n_sports', 'n_space', 'n_cooking', 'f', 'idf',
                                  'sports', 'space', 'cooking']).to_string(index=False))
words per cluster: [16 13 14]   A = 14
  word  n_sports  n_space  n_cooking  f   idf  sports  space  cooking
  team         3        0          0  3 1.735   0.325   0.00    0.000
  game         2        0          0  2 2.079   0.260   0.00    0.000
launch         0        2          0  2 2.079   0.000   0.32    0.000
  moon         0        2          0  2 2.079   0.000   0.32    0.000
 bread         0        0          3  3 1.735   0.000   0.00    0.372

O grupo de esportes tem 16 palavras, o de espaço 13 e o de culinária 14, então \(A = \lfloor 43/3 \rfloor = 14\) (o BERTopic trunca a média para um inteiro). team aparece 3 vezes, todas em esportes: \(\text{idf} = \log(1 + 14/3) = 1{,}735\); em esportes, a pontuação é \(3/16 \times 1{,}735 = 0{,}325\). game aparece só duas vezes, então o idf é maior (\(\log 8 = 2{,}079\)), mas a frequência é menor: \(2/16 \times 2{,}079 = 0{,}260\). O transformador do próprio BERTopic dá a mesma matriz:

from bertopic.vectorizers import ClassTfidfTransformer

ctfidf = ClassTfidfTransformer().fit_transform(C)                   # a classe do próprio BERTopic
by_hand = C / size[:, None] * np.log(1 + A / C.sum(axis=0))
print("BERTopic == by hand:", np.allclose(ctfidf, by_hand))
print(top_words(ctfidf, np.array(words_c)))
BERTopic == by hand: True
['team game goal won', 'orbit launch satellite moon', 'bread bake oven hot']

Agora a mesma conta nos 24 grupos dos posts, mais o "grupo" −1 dos outliers, que o BERTopic também descreve. Antes de contar, o BERTopic troca quebras de linha e tabulações por espaços; o código faz o mesmo:

frame = pd.DataFrame({'doc': docs, 'cluster': labels})
per_cluster = frame.groupby('cluster')['doc'].apply(' '.join)        # -1 também é um "grupo"
cleaned = [t.replace('\n', ' ').replace('\t', ' ') for t in per_cluster]   # como o BERTopic faz
cv_topics = CountVectorizer(stop_words='english', token_pattern=r'(?u)\b[a-zA-Z]{3,}\b')
W_ctfidf = ClassTfidfTransformer().fit_transform(cv_topics.fit_transform(cleaned)).toarray()
hand_words = dict(zip(per_cluster.index, top_words(W_ctfidf, cv_topics.get_feature_names_out(), 5)))
sizes = frame['cluster'].value_counts()
for c in [-1] + list(sizes.drop(-1).index[:5]):                     # os outliers, depois os 5 maiores
    print(f"cluster {c:>2}  {sizes[c]:>4} posts  {' '.join(hand_words[c].split()[:4])}")
cluster -1   431 posts  space jpeg launch image
cluster  1   567 posts  god jesus people church
cluster  0   520 posts  team game hockey play
cluster 15    66 posts  image graphics package processing
cluster  9    55 posts  card mode vesa vga
cluster 23    52 posts  moon lunar prize money

Os outliers também ganham uma descrição (space, jpeg, launch, image: sobras dos dois fóruns mais variados). O HDBSCAN numera os grupos sem ordem especial: o grupo 1 é religião, o 15 processamento de imagens, o 23 a Lua.

Os quatro passos em uma chamada

O BERTopic encadeia os mesmos quatro passos. Passe a ele os mesmos objetos UMAP e HDBSCAN e o mesmo vetorizador; entregue também os embeddings do passo 1, para que ele não os calcule de novo:

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
print("same partition as the stages above (ARI):", round(adjusted_rand_score(labels, topics), 3))
same_words = all(' '.join(w for w, _ in topic_model.get_topic(t)[:5])
                 == hand_words[pd.Series(labels[topics == t]).mode()[0]] for t in set(topics))
print("same top-5 words as by hand, for every topic:", same_words)
info = topic_model.get_topic_info()
print(info[['Topic', 'Count', 'Name']].head(8).to_string(index=False))
same partition as the stages above (ARI): 1.0
same top-5 words as by hand, for every topic: True
 Topic  Count                                Name
    -1    431          -1_space_jpeg_launch_image
     0    567           0_god_jesus_people_church
     1    520             1_team_game_hockey_play
     2     66 2_image_graphics_package_processing
     3     55                3_card_mode_vesa_vga
     4     52            4_moon_lunar_prize_money
     5     43      5_space_nasa_shuttle_astronaut
     6     41                 6_van_het_orbit_een

ARI 1,0: a mesma partição dos posts, só renumerada por tamanho (o tópico 0 é o maior, o grupo 1 do HDBSCAN). As palavras do topo de cada tópico são as calculadas à mão no passo 4 (comparadas nas 5 primeiras: mais abaixo na lista, palavras com pontuações empatadas podem sair em outra ordem); cada Name junta as quatro primeiras. Sem embeddings=E, o fit_transform geraria ele mesmo os embeddings com o embedding_model; sem umap_model e hdbscan_model, usaria essas mesmas configurações, mas sem semente: cada rodada poderia dar tópicos diferentes.

Um modelo ajustado oferece mais que a tabela: get_topic(t) dá as palavras do topo com as pontuações, get_representative_docs(t) alguns documentos típicos, find_topics("rocket engines") busca tópicos pelo significado, transform(new_docs) atribui documentos novos aos tópicos existentes; visualize_topics(), visualize_barchart() ou visualize_documents() desenham gráficos interativos em Plotly. O lab usa vários deles.

Passo 5 — ajustar a representação, não os grupos

As palavras que nomeiam um tópico são calculadas depois do agrupamento, então podem ser trocadas sem mexer nos grupos. Duas comparações no tópico 2 (processamento de imagens):

before = [w for w, _ in topic_model.get_topic(2)]
raw = BERTopic(embedding_model=encoder, umap_model=umap_model, hdbscan_model=hdbscan_model)
raw.fit(docs, embeddings=E)                                         # vetorizador padrão: sem lista de stop words
print('default vectorizer:', ' '.join(w for w, _ in raw.get_topic(2)))
print('stop words removed:', ' '.join(before))

from bertopic.representation import KeyBERTInspired
topic_model.update_topics(docs, vectorizer_model=cv_topics, representation_model=KeyBERTInspired())
print('KeyBERTInspired   :', ' '.join(w for w, _ in topic_model.get_topic(2)))
print("assignments unchanged:", (np.array(topic_model.topics_) == topics).all())
default vectorizer: image and for of to it or the graphics with
stop words removed: image graphics package processing software data analysis user program version
KeyBERTInspired   : graphical graphics rtrace plots tools interactive plotting software adobe tool
assignments unchanged: True

Com o vetorizador padrão do BERTopic, as palavras do topo de um tópico são and, for, of, to, the: palavras frequentes em todo lugar ainda ganham na frequência dentro de um grupo grande. Uma lista de stop words resolve isso (é por isso que vectorizer_model foi passado desde o início). O KeyBERTInspired vai além: reordena as palavras candidatas pelo cosseno entre o embedding de cada palavra e os documentos típicos do tópico, favorecendo palavras que capturam o sentido (graphical, plots, tools) sobre palavras apenas frequentes. O update_topics mudou as palavras e nada mais: as atribuições post-tópico são idênticas.

Referências

  • Grootendorst, M. "BERTopic: Neural Topic Modeling with a Class-based TF-IDF Procedure." arXiv (2022). arXiv:2203.05794
  • Reimers, N.; Gurevych, I. "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks." EMNLP-IJCNLP (2019) — os sentence-transformers por trás do passo 1. arXiv:1908.10084
  • McInnes, L.; Healy, J.; Melville, J. "UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction." arXiv (2018). arXiv:1802.03426
  • Campello, R. J. G. B.; Moulavi, D.; Sander, J. "Density-Based Clustering Based on Hierarchical Density Estimates." PAKDD (2013), LNCS 7819: 160–172 — o HDBSCAN. DOI
  • BERTopic — a documentação da biblioteca.

Próxima: U5.4 Comparando os Modelos, os três modelos lado a lado e as armadilhas.