← Voltar pra playlist

Misturas Gaussianas e Detecção de Anomalia: Clustering com Formato

Aula 11a e 11b. Continuação direta do post de K-means: e se os grupos não forem redondos?

Onde o K-means capenga de novo

O professor gera um dataset de propósito: dois grupos esticados e girados (multiplicando os pontos por uma matriz de rotação), mais um terceiro grupo bem menor e afastado dos outros dois. K-means, mesmo com uma inicialização boa (centros escolhidos na mão, perto do lugar certo), tem dificuldade: como ele só enxerga "distância até um centro", ele tende a cortar os grupos alongados em pedaços mais redondos do que realmente são, porque a fronteira dele entre dois grupos é sempre uma reta perpendicular à linha entre os centros, não uma elipse.

Misturas gaussianas: cada grupo vira uma elipse, não um ponto

from sklearn.mixture import GaussianMixture
gm = GaussianMixture(n_components=3, n_init=10, random_state=42)
gm.fit(X)

A diferença central pro K-means: em vez de guardar só um centro por grupo, uma mistura gaussiana guarda uma distribuição normal inteira por grupo, com sua própria média e matriz de covariância (que captura o formato, o quanto o grupo é alongado e em qual direção). O modelo completo é uma soma ponderada de KK gaussianas:

p(x)=k=1KπkN(xμk,Σk)p(\mathbf{x}) = \sum_{k=1}^{K} \pi_k \, \mathcal{N}(\mathbf{x} \mid \boldsymbol{\mu}_k, \boldsymbol{\Sigma}_k)

onde πk\pi_k é o peso (a fração de dados que pertence àquele grupo). O Bishop deriva o ajuste desse modelo via um algoritmo chamado EM (esperança-maximização), que alterna dois passos até convergir:

  • Passo E (esperança): pra cada ponto, calcula a responsabilidade γ(znk)\gamma(z_{nk}) de cada componente, a probabilidade (via Bayes) de que aquele ponto tenha vindo daquele grupo específico, dado onde as gaussianas estão agora. Diferente do K-means, que atribui cada ponto a um só grupo, o EM atribui uma responsabilidade fracionária a todos os grupos (um ponto na fronteira entre dois grupos pode ter 60% de responsabilidade num e 40% no outro).
  • Passo M (maximização): recalcula média, covariância e peso de cada gaussiana, usando essas responsabilidades como peso. Um ponto com responsabilidade 0.9 pro grupo 1 conta quase inteiro pra média e covariância do grupo 1, já um com responsabilidade 0.5/0.5 conta meio a meio pros dois.

Isso é literalmente a versão "macia" do K-means: troca "cada ponto pertence a exatamente um grupo" por "cada ponto pertence um pouco a cada grupo", e troca "grupo é só uma média" por "grupo é uma média mais um formato".

Saída: pesos encontrados, [0.40, 0.21, 0.39] (bate com a proporção real dos três grupos gerados). Convergiu em só 4 iterações.

Interativo: as elipses se ajustando

Reconstrução minha do algoritmo EM (nos mesmos 380 pontos, 3 grupos, dois deles esticados). Clica em "Passo EM" e acompanha as elipses (cada uma o contorno de 1 desvio padrão daquela gaussiana) girarem e se esticarem até encaixar no formato real dos dados:

Repara que os pesos mostrados abaixo do gráfico se aproximam de 0.40, 0.21, 0.39 conforme você clica em "Passo EM" repetidas vezes, e as elipses vão de círculos genéricos (a inicialização) pra formas alongadas que seguem a direção real dos grupos.

Detecção de anomalia de graça

Uma vantagem de ter um modelo de densidade (não só um agrupamento): dá pra perguntar "quão provável é este ponto, dado o modelo?" e marcar os menos prováveis como anomalia.

densities = gm.score_samples(X)
density_threshold = np.percentile(densities, 2)
anomalies = X[densities < density_threshold]

Os pontos com densidade abaixo do percentil 2 (os 2% menos prováveis) viram candidatos a anomalia, e capturam exatamente o pequeno grupo isolado que o professor colocou de propósito longe dos outros dois.

Quantos grupos usar? BIC, AIC, e um jeito mais esperto

gm.bic(X), gm.aic(X)

Saída: BIC = 8189.73, AIC = 8102.51.

Os dois são critérios de informação: medem o quão bem o modelo explica o dado, com uma penalidade por complexidade (mais grupos = mais parâmetros = penalidade maior), pra evitar escolher "quanto mais grupo, melhor" só porque mais grupos sempre ajusta melhor. Rodando pra K=1K=1 até 99 e plotando os dois contra KK, o formato da curva aponta pro número de grupos que equilibra ajuste e simplicidade.

Tem um jeito ainda mais direto: BayesianGaussianMixture recebe um número generoso de componentes (10, nesse caso) e poda sozinho os que não são necessários, zerando o peso deles:

from sklearn.mixture import BayesianGaussianMixture
bgm = BayesianGaussianMixture(n_components=10, n_init=10, random_state=42)
bgm.fit(X)
print(np.round(bgm.weights_, 2))

Saída: [0.4, 0.21, 0.39, 0, 0, 0, 0, 0, 0, 0]. Sete dos dez componentes zeraram sozinhos, sobrando os três de verdade, sem eu precisar escanear KK manualmente.

O Bishop avisa de um problema técnico que vale conhecer: se uma gaussiana "colapsar" bem em cima de um único ponto de dado, a variância dela pode ir a zero e a verossimilhança do modelo vai a infinito, uma singularidade, não um bom ajuste. Implementações de verdade (como a do scikit-learn) evitam isso na prática com salvaguardas numéricas, mas é um lembrete de que "achar o máximo da verossimilhança" nem sempre é um problema tão bem-comportado quanto parece.

Trocando de assunto: outros jeitos de achar anomalia

aula11b monta um cenário mais direto pra detecção de anomalia: 980 pontos "normais" (3 grupos bem comportados) mais 20 pontos espalhados aleatoriamente pelo espaço (as anomalias de verdade), e compara três detectores diferentes, cada um com o contamination padrão (0.1, ou seja "assuma que 10% do dado é anomalia") e depois ajustado via Optuna:

DetectorF1 (anomalia), padrãoF1 (anomalia), ajustado
IsolationForest≈ 0.270.68
LocalOutlierFactor≈ 0.270.79
OneClassSVM≈ 0.240.64

O contamination=0.1 padrão manda cada detector marcar 10% do dado como anomalia (98 pontos), mas só 20 dos 1000 pontos (2%) são anomalias de verdade. Forçar o modelo a encontrar 5 vezes mais anomalias do que realmente existem garante um monte de falso positivo, por isso o F1 baixo em todos os três antes do ajuste. Depois que o Optuna busca o contamination certo (perto de 0.02, o valor real) e outros hiperparâmetros de cada modelo, os três melhoram bastante, e o LocalOutlierFactor (que decide "anômalo" comparando a densidade local de um ponto com a dos vizinhos, o mesmo tipo de raciocínio por densidade que o DBSCAN usa) sai na frente.

A lição prática, batendo com o resto da aula: contamination não é um detalhe cosmético, é a peça mais importante do ajuste, porque ele diz ao modelo quantas anomalias procurar. Sem saber (ou estimar bem) essa fração de antemão, qualquer um desses três detectores erra o alvo.

Fechando

O que eu já sabiaO que essa aula assentou
K-means agrupa por distância até um centroMisturas gaussianas agrupam por densidade e formato, com responsabilidade fracionária em vez de atribuição rígida
DBSCAN acha anomalia como "quem não é núcleo de nada"Densidade de modelo (GMM) e densidade local (LOF) são outros dois jeitos válidos de definir "anomalia", cada um com seu próprio viés
Hiperparâmetro importaPra detecção de anomalia especificamente, contamination é o hiperparâmetro que mais importa, porque a maioria dos algoritmos precisa saber de antemão quanto de anomalia procurar

Aplicação Prática

Uso a mesma ideia de densidade da seção de detecção de anomalia via GMM, mas comparo dois cortes de percentil diferentes no mesmo dataset de 3 grupos, pra ver o quanto a escolha do corte muda quantos pontos viram "anomalia".

for percentil in [1, 2, 5, 10]:
    threshold = np.percentile(densities, percentil)
    n_anomalias = (densities < threshold).sum()
    print(percentil, n_anomalias)

Saída (1250 pontos no total, os mesmos 750+250 gerados na aula): com corte em 1%, 13 pontos marcados. Com 2% (o valor usado no notebook), 25 pontos. Com 5%, 63 pontos. Com 10%, 125 pontos.

O número de "anomalias" encontradas escala praticamente linear com o percentil escolhido, porque é literalmente assim que um corte de percentil funciona: ele sempre acha exatamente aquela fração do dado, não importa se existe uma anomalia real ali ou não. É o mesmo ponto que a comparação de detectores da aula acabou de mostrar de outro jeito: definir "quanto" procurar é uma escolha que muda o resultado tanto quanto o algoritmo em si.