15  Análise de agrupamentos

Neste exemplo, aplicaremos o procedimento de análise de agrupamentos proposto na Seção 9.3.2. O objetivo é ilustrar como a combinação de métodos hierárquicos e não hierárquicos pode levar a uma solução de agrupamento robusta e interpretável.

Utilizaremos o conjunto de dados USArrests, que contém estatísticas de crimes para cada um dos 50 estados dos EUA em 1973. As variáveis são:

Nosso objetivo é agrupar os estados com base em seus perfis de criminalidade e urbanização.

15.1 Preparação dos Dados

O primeiro passo em qualquer análise de agrupamento baseada em distância é a padronização dos dados. As variáveis no nosso conjunto de dados têm escalas muito diferentes (Assault varia na casa das centenas, enquanto Murder varia na casa das dezenas). Se não padronizarmos, a variável Assault dominará o cálculo da distância, e o agrupamento será baseado quase inteiramente nela.

Padronizamos as variáveis para que tenham média 0 e desvio padrão 1.

Código
import pandas as pd
from sklearn.preprocessing import StandardScaler
import statsmodels.api as sm
import matplotlib.pyplot as plt
from scipy.cluster.hierarchy import dendrogram, linkage
from scipy.cluster.hierarchy import fcluster
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
import seaborn as sns
from tabulate import tabulate

# Carregando o conjunto de dados
data = pd.read_csv('../../dados/USArrests.csv', index_col='rownames')

# Separando os dados e os nomes dos estados
X = data.values
states = data.index

# Padronizando os dados
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

# Criando um DataFrame com os dados padronizados para facilitar a manipulação
X_scaled_df = pd.DataFrame(X_scaled, index=states, columns=data.columns)

print(tabulate(X_scaled_df.head(), headers='keys', tablefmt='pipe'))
Tabela 15.1: Cabeçalho dos dados padronizados
| rownames   |    Murder |   Assault |   UrbanPop |        Rape |
|:-----------|----------:|----------:|-----------:|------------:|
| Alabama    | 1.25518   |  0.790787 |  -0.526195 | -0.00345116 |
| Alaska     | 0.513019  |  1.11806  |  -1.22407  |  2.50942    |
| Arizona    | 0.0723607 |  1.49382  |   1.00912  |  1.05347    |
| Arkansas   | 0.234708  |  0.233212 |  -1.08449  | -0.186794   |
| California | 0.281093  |  1.27564  |   1.77678  |  2.08881    |

15.1.1 Análise Descritiva

Antes de iniciar o agrupamento, é sempre útil explorar a distribuição das variáveis. A figura abaixo mostra os histogramas para cada uma das quatro variáveis do conjunto de dados.

Código
fig, axes = plt.subplots(2, 2, figsize=(7, 5))

sns.histplot(data['Murder'], ax=axes[0, 0], kde=True)
axes[0, 0].set_title('Assassinatos')

sns.histplot(data['Assault'], ax=axes[0, 1], kde=True)
axes[0, 1].set_title('Agressões')

sns.histplot(data['UrbanPop'], ax=axes[1, 0], kde=True)
axes[1, 0].set_title('População Urbana (%)')

sns.histplot(data['Rape'], ax=axes[1, 1], kde=True)
axes[1, 1].set_title('Estupros')

plt.tight_layout(rect=[0, 0.03, 1, 0.95])
plt.show()
Figura 15.1: Distribuição das variáveis do dataset USArrests.

Observamos que as variáveis de crime (Murder, Assault, Rape) parecem ter uma leve assimetria à direita, com a maioria dos estados concentrados em valores mais baixos. A variável UrbanPop tem uma distribuição mais simétrica, quase uniforme, indicando uma boa variedade nos níveis de urbanização entre os estados.

Além dos histogramas, podemos visualizar a matriz de correlação entre as variáveis para entender suas relações lineares.

Código
corr_matrix = data.corr()
plt.figure(figsize=(7, 5))
sns.heatmap(corr_matrix, annot=True, cmap='Greys', fmt='.2f')
plt.show()
Figura 15.2: Matriz de Correlação entre as variáveis.

A matriz de correlação na Figura 20.1 mostra, como esperado, uma forte correlação positiva entre as três variáveis de crime (Murder, Assault, Rape). UrbanPop tem uma correlação positiva mais fraca com as outras variáveis, sugerindo que o efeito da urbanização no aumento da criminalidade geral é moderado.

15.2 Agrupamento Hierárquico e Escolha de K

Agora, aplicamos o agrupamento hierárquico aglomerativo usando o método de Ward, que busca minimizar a variância dentro dos grupos a cada fusão. Em seguida, plotamos o dendrograma para nos ajudar a decidir o número ideal de grupos, \(K\).

Código
# Realizando o agrupamento hierárquico com o método de Ward
linked = linkage(X_scaled, method='ward')

# Plotando o dendrograma
plt.figure(figsize=(7, 5))
dendrogram(linked,
           orientation='top',
           labels=states,
           distance_sort='descending',
           show_leaf_counts=True,
           color_threshold=5.2)
plt.xlabel('Estados')
plt.ylabel('Distância de Ward')
plt.axhline(y=5.2, color='r', linestyle='-.', label="Corte 1 (K=4)")
plt.axhline(y=10.5, color='grey', linestyle='--', label="Corte 2 (K=2)")
plt.legend(loc="upper left")
plt.show()
Figura 15.3: Dendrograma para o conjunto de dados USArrests usando o método de Ward.

Analisando o Figura 15.3, procuramos por um corte que cruze o maior espaço vertical possível. Vemos duas opções razoáveis, indicadas pelas linhas tracejadas. O “Corte 2” (cinza) sugere uma partição em \(K=2\) grupos, separando os estados em dois grandes blocos. O “Corte 1” (vermelho), mais abaixo, sugere uma partição mais granular de \(K=4\) grupos. Uma solução com 4 grupos nos dará um entendimento mais detalhado dos perfis dos estados. Portanto, iniciaremos a análise com \(K=4\) e, ao final deste exemplo, exploraremos a solução mais simples com \(K=2\) para fins de comparação.

Para verificar a robustez dessa escolha, podemos comparar o resultado com o de outro método de ligação, como a ligação completa.

Código
linked_complete = linkage(X_scaled, method='complete')

plt.figure(figsize=(7, 5))
dendrogram(linked_complete,
           orientation='top',
           labels=states,
           distance_sort='descending',
           show_leaf_counts=True)
plt.xlabel('Estados')
plt.ylabel('Distância')
plt.show()
Figura 15.4: Dendrograma para o conjunto de dados USArrests usando o método de Ligação Completa.

O dendrograma de ligação completa também sugere uma partição de 2 ou 4 grupos como as mais sensatas.

15.3 K-Médias com Centroides Hierárquicos

Seguindo o nosso procedimento, agora usaremos o resultado do agrupamento hierárquico para informar o algoritmo K-médias.

  1. Obtemos as 4 partições (grupos) do método de Ward.
  2. Calculamos o centroide (média) de cada um desses 4 grupos.
  3. Executamos o K-médias com \(K=4\), usando os centroides calculados como pontos de partida.

Isso ajuda o K-médias a evitar mínimos locais e a convergir para uma solução mais estável e significativa.

Código
# 1. Obter os 4 grupos do modelo hierárquico
k = 4
hierarchical_grupos = fcluster(linked, k, criterion='maxclust')

# Adicionar ao DataFrame para calcular os centroides
X_scaled_df['hierarchical_grupo'] = hierarchical_grupos

# 2. Calcular os centroides iniciais
initial_centroids = X_scaled_df.groupby('hierarchical_grupo').mean().values

# 3. Executar o K-médias com os centroides iniciais
kmeans = KMeans(n_clusters=k, init=initial_centroids, n_init=1, random_state=42)
kmeans.fit(X_scaled_df.drop('hierarchical_grupo', axis=1))

# Obter os grupos finais
final_grupos = kmeans.labels_

# Adicionar os grupos finais ao DataFrame original (não padronizado)
data['grupo'] = final_grupos

15.4 Interpretação e Visualização dos Grupos

Com os grupos finais definidos, o passo mais importante é a interpretação. Calculamos a média de cada variável para cada grupo para criar um “perfil”.

Código
# Calcular as médias por grupo
grupo_profile = data.groupby('grupo').mean()
print(tabulate(grupo_profile, headers='keys', tablefmt='pipe'))
Tabela 15.2: Perfil dos grupos: médias das variáveis para cada grupo.
|   grupo |   Murder |   Assault |   UrbanPop |    Rape |
|--------:|---------:|----------:|-----------:|--------:|
|       0 | 13.9375  |  243.625  |    53.75   | 21.4125 |
|       1 | 10.9667  |  264      |    76.5    | 33.6083 |
|       2 |  3.6     |   78.5385 |    52.0769 | 12.1769 |
|       3 |  5.85294 |  141.176  |    73.6471 | 19.3353 |

A Tabela 15.2 nos permite caracterizar cada grupo:

  • Grupo 0 (Estados Perigosos): Este grupo tem os maiores índices de assassinatos e índices também altos de agressões e estupros. A população urbana é uma das mais baixas. Podemos nomeá-lo “Estados Violentos e Rurais”.

  • Grupo 1 (Estados Urbanizados e Perigosos): Este grupo tem alta urbanização e níveis de criminalidade também muito altos, especialmente quanto a estupros e agressões. Um bom nome seria “Grandes Centros Urbanos Perigosos”.

  • Grupo 2 (Estados Seguros e Rurais): Este grupo se destaca bastante dos anteriores. Apresenta os menores índices em todas as categorias de crime. A população urbana também é a mais baixa. Inclui estados como Dakota do Norte, Vermont e Iowa. Poderíamos chamá-lo de “Estados Seguros e Rurais”.

  • Grupo 3 (Estados Intermediários): Este grupo é formado por estados urbanizados com menores índices de criminalidade, quando comparados aos grupos 0 e 1. Nele, nenhum extremo se destaca. Podemos chamá-lo de “Estados na Média”.

Para visualizar a separação, usamos a Análise de Componentes Principais (ACP) para reduzir a dimensionalidade dos dados para 2D e plotamos os estados, colorindo-os por grupo.

15.4.1 Interpretando os Componentes Principais

Os eixos (componentes principais) são combinações lineares das variáveis originais. Podemos inspecionar os pesos (loadings) de cada variável para interpretar o significado de cada componente.

Código
# Reduzindo a dimensionalidade com ACP
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_scaled)

# Variância explicada
explained_variance = pca.explained_variance_ratio_

# Loadings
loadings = pca.components_.T
loadings_df = pd.DataFrame(loadings, columns=['PC1', 'PC2'], index=data.columns[:-1])
print(tabulate(loadings_df, headers='keys', tablefmt='pipe'))
Tabela 15.3: Cargas (loadings) dos componentes principais.
|          |      PC1 |       PC2 |
|:---------|---------:|----------:|
| Murder   | 0.535899 | -0.418181 |
| Assault  | 0.583184 | -0.187986 |
| UrbanPop | 0.278191 |  0.872806 |
| Rape     | 0.543432 |  0.167319 |

A tabela Tabela 15.3 mostra as cargas das variáveis nos dois primeiros fatores.

O primeiro componente (PC1) explica aproximadamente 62% da variância total, enquanto o segundo (PC2) explica cerca de 25%. Juntos, eles capturam 87% da informação original, o que é excelente para uma visualização 2D.

  • Componente Principal 1 (PC1): Todas as quatro variáveis têm cargas positivas, com destaque para as variáveis associadas à criminalidade. Isso significa que ele representa uma medida geral de “Criminalidade”.
  • Componente Principal 2 (PC2): Este componente mostra um contraste. Ele tem uma carga positiva forte para UrbanPop e uma carga negativa para Murder. Isso significa que PC2 separa estados urbanizados com menor índice de assassinatos (scores altos) de estados rurais com mais assassinatos (scores baixos).

Com essa interpretação, podemos agora visualizar os grupos de forma mais informativa.

Código
# Criando um DataFrame para o plot
pca_df = pd.DataFrame(data=X_pca, columns=['PC1', 'PC2'])
pca_df['grupo'] = final_grupos
pca_df['state'] = states

# Plotando
plt.figure(figsize=(7, 5))
sns.scatterplot(x='PC1', y='PC2', hue='grupo', data=pca_df, palette='viridis', s=100)

# Adicionando os nomes dos estados ao gráfico
for i in range(pca_df.shape[0]):
    plt.text(x=pca_df.PC1[i]+0.05, y=pca_df.PC2[i], s=pca_df.state[i],
             fontdict=dict(color='black',size=8))

plt.title('Grupos de Estados dos EUA (Visualização com ACP)')
plt.xlabel(f'Componente Principal 1 ({explained_variance[0]:.1%})')
plt.ylabel(f'Componente Principal 2 ({explained_variance[1]:.1%})')
plt.legend(title='Grupo')
plt.grid(True)
plt.show()
Figura 15.5: Visualização dos grupos no espaço dos dois primeiros componentes principais.

O gráfico na Figura 15.5 mostra uma separação clara dos grupos. O primeiro componente principal (PC1, eixo horizontal) efetivamente separa os estados com base na “Criminalidade Geral”, com os grupos mais violentos (0 e 1) à direita e os mais seguros (2 e 3) à esquerda. O segundo componente principal (PC2, eixo vertical) está relacionado à “Urbanização”, posicionando os grupos mais urbanizados (1 e 3) na parte superior e os mais rurais (0 e 2) na parte inferior. A análise combinada forneceu uma partição clara e interpretável dos estados dos EUA com base em seus dados sociais de 1973.

15.5 Comparação com K=2 Grupos

Como vimos no dendrograma, uma solução com \(K=2\) também é uma escolha justificável e representa a divisão de mais alto nível nos dados. Vamos repetir a etapa final do nosso procedimento para \(K=2\) e analisar o resultado.

Código
# 1. Obter os 2 grupos do modelo hierárquico
k = 2
hierarchical_grupos_k2 = fcluster(linked, k, criterion='maxclust')

# Adicionar ao DataFrame para calcular os centroides
X_scaled_df['hierarchical_grupo_k2'] = hierarchical_grupos_k2
initial_centroids_k2 = X_scaled_df.drop(columns="hierarchical_grupo").groupby('hierarchical_grupo_k2').mean().values

# 2. Executar K-médias
kmeans_k2 = KMeans(n_clusters=k, init=initial_centroids_k2, n_init=1, random_state=42)
kmeans_k2.fit(X_scaled_df.drop(['hierarchical_grupo', 'hierarchical_grupo_k2'], axis=1))
final_grupos_k2 = kmeans_k2.labels_

# 3. Calcular e exibir o perfil
data['grupo_k2'] = final_grupos_k2
grupo_profile_k2 = data.groupby('grupo_k2').mean().drop('grupo', axis=1)
print(tabulate(grupo_profile_k2, headers='keys', tablefmt='pipe'))
Tabela 15.4: Perfil dos grupos para a solução com K=2.
|   grupo_k2 |   Murder |   Assault |   UrbanPop |    Rape |
|-----------:|---------:|----------:|-----------:|--------:|
|          0 |   12.165 |   255.25  |    68.4    | 29.165  |
|          1 |    4.87  |   114.433 |    63.6333 | 15.9433 |

Com \(K=2\), a partição é bem mais simples:

  • Grupo 0: Agrega os estados que possuem níveis de criminalidade e urbanização mais elevados.
  • Grupo 1: Engloba os estados mais seguros e rurais.

Essa divisão é útil para uma visão macro, mas perde a granularidade que a solução com 4 grupos nos proporcionou, como a distinção entre os estados “intermediários” e os “grandes centros urbanos”. A visualização no espaço dos componentes principais ilustra isso claramente.

Código
pca_df['grupo_k2'] = final_grupos_k2

plt.figure(figsize=(7, 5))
sns.scatterplot(x='PC1', y='PC2', hue='grupo_k2', data=pca_df, palette='viridis', s=100)

# Adicionando os nomes dos estados ao gráfico
for i in range(pca_df.shape[0]):
    plt.text(x=pca_df.PC1[i]+0.05, y=pca_df.PC2[i], s=pca_df.state[i],
             fontdict=dict(color='black',size=8))

plt.title('Grupos de Estados dos EUA (K=2, Visualização com ACP)')
plt.xlabel(f'Componente Principal 1 ({explained_variance[0]:.1%})')
plt.ylabel(f'Componente Principal 2 ({explained_variance[1]:.1%})')
plt.legend(title='Grupo')
plt.grid(True)
plt.show()
Figura 15.6: Visualização dos grupos (K=2) no espaço dos componentes principais.