Dentro desse artigo vamos explorar diferentes técnicas para análise de dados categóricos, para entender de forma lúdica vamos vestir nossas bandanas e nos transportar para o mundo de Naruto (que eu particularmente gosto muito). Vamos seguir a seguinte linha de raciocínio: Imagine que você seja um sensei na vila de konoha acompanhando seus alunos no exame chunin e deseja saber qual o estilo de luta que é mais popular entre os shinobis de cada aldeia para traçar estratégias junto com os mesmos. Vamos lá?
Indíce
- O inicio de tudo - Análise de correspondência
- Definindo nosso cenário básico - Importando bibliotecas
- Carregando nossos dados para análise
- Visualizando-os com uma tabela de contingência
- Calculando os perfis de linha e coluna - Qual é o estilo da aldeia?
- Teste QUI-Quadrado - Isso é uma coincidência ou não?
- Resíduos padronizados - Quem está fora do esperado?
- Decomposição SVD - Reduzindo o mundo em duas dimensões
- Mapa de Correspondência (Biplot) - O mapa final do mundo do Naruto
- Interpretação final
- Biblioteca Prince - Otimizando o tempo
- Conclusão
O inicio de tudo - Análise de correspondência
Para isso vamos conhecer nossas armas: A análise de correspondência simples é uma técnica estatística multivariada utilizada para explorar e visualizar relações entre as variáveis categóricas em tabelas de contingência. Para compreender melhor esse conceito é bom entender uma categoria como um conceito que representa grupos não numéricos, ou seja, se você estiver agrupando coisas que são diferentes de grupos numéricos então você está usando categorias.
Dentro do nosso cenário, vamos imaginar que recebemos o relatório com os resultados feito pelos pesquisadores da aldeia e vamos começar a analisar esses dados:
Definindo nosso cenário básico - Importando bibliotecas
Nesse primeiro momento vamos garantir que todas as nossas bibliotecas estão importadas e vamos configurar o estilo do nosso gráfico afim de garantir um aspecto mais visivelmente agradável.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import chi2.contigency
sns.set_style('whitegrid')
plt.rcParams.udate((
'font.family': 'sans-serif',
'font.size': 11,
'axes.titlesize': 15,
'axes.titleweight': 'bold',
'axes.labelsize': 12
))
Carregando nossos dados para análise
Nessa parte da análise vamos carregar o relatório que nos foi enviado para que possamos visualizar melhor em gráficos e ter uma noção melhor de quais são as aldeias1 comparados com os tipos de estilo de luta2.
1: Nesse relatório as aldeias são apresentadas como linhas na tabela
2: Nesse relatório os estilos de luta são apresentados como colunas na tabela
dados = pd.read_csv('tabela_contigencia_naruto.csv', index_col=0)
Agora que carregamos nosso relatório, vamos visualizar os dados de forma básica para termos uma ideia geral do cenário:
print('Forma da tabela', dados.shape)
data
Visualizando-os com uma tabela de contingência
Aqui temos mais uma de nossas armas: A tabela de contingência nos ajuda a cruzar duas categorias relacionadas — que no nosso caso são aldeias e estilos de luta —
Utilizaremos um heatmap (mapa de calor) para visualizar os dados brutos de uma forma mais intuitiva. Nele podemos visualizar através de tons, onde quanto mais escuro mais shinobis existem naquela combinação.
plt.figure(figsize=(10,6))
sns.heatmap(
dados,
annot=True,
fmt='d',
cmap='TlordRd',
linewidth=0.5,
linecolor='#cccccc',
cbar_kws={'label': 'Quantidade de Shinobis'})
plt.title('Tabela de Contingência - Dados Brutos')
plt.xlabel('Estilo de Luta')
plt.ylabel('Aldeia')
plt.tight_layout()
plt.show()
Calculando os perfis de linha e coluna - Qual é o estilo da aldeia?
Agora que podemos visualizar nosso dado de formas diferentes vamos começar a nos perguntar o que esse dado precisa nos dizer como pode ser visto abaixo:
- Olhar para o perfil de linha é como perguntar: "De todos os shinobis da vila de Konoha, qual a porcentagem de cada estilo?", ou seja, nós dividismo cada valor pela soma da linha, essa informação nos aproxima do que seria o perfil de combate geral em cada aldeia.
- Já olhar para o perfil de coluna nos diz justamente o contrário: "De todos os que preferem Taijutsu, quantos são de cada aldeia?", ou seja, olhamos para o perfil de um estilo específico visto por cada aldeia.
Para calcular o perfil de linha vamos realizar o calculo dos dados:
row_profile = dados.div(dados.sum(axis=1), axis=0)
print('Perfil de linha (proporção por aldeia)')
print(row_profile.round(3))
print('\nSoma por linha (deve ser 1.0):', row_profile.sum(axis=1).values)
Já para o perfil de coluna faremos o contrário como foi explicado:
col_profile = dados.div(dados.sum(axis=0), axis=1)
print('Perfil de coluna (proporção por estilo)')
print(col_profile.round(3))
print('\nSoma por coluna (deve ser 1.0):', col_profile.sum(axis=0).values)
Visualizando o perfil de linha de maneira estruturada
Perfeito! Agora que entendemos o conceito e conseguimos visualizar nossos numeros brutos dos perfis, vamos visualiza-los em um gráfico:
CORES_ALDEIAS = {
'Konoha': '#F25C54',
'Suna': '#F2A83A',
'Kiri': '#3AA3F2',
'Ame': '#8E5CF2'
}
fig, ax = plt.subplots(figsize=(10,6))
x = np.arange(len(dados.colums))
width = 0.2
for idx, aldeia in enumerate(dados.index):
ax.bar(x + idx * width, row_profile.loc[aldeia] * 100,
width, label=aldeia, color=CORES_ALDEIAS[aldeia],
edgecolor='white', linewidth=0.8)
ax.set_title('Perfil de Linha - "Perfil de combate" de cada aldeia')
ax.set_xlabel('Estilo de luta')
ax.set_ylabel('Proporção (%)')
ax.set_xticks(x + width * 1.5)
ax.set_ylim(0, 42)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f'{v:.0f}%'))
plt.tight_layout()
plt.show()
Teste QUI-Quadrado - Isso é uma coincidência ou não?
Um dos aspectos mais importantes em uma análise de dados é questionar nossa base com carater investigativo, dentro do nosso cenário devemos nos perguntar se há mesmo uma relação entre aldeias e estilos de luta ou se estamos atribuindo causalidade3 para dados correlacionados4.
3: Causalidade refere-se a uma relação de causa e efeito entre duas informações, onde o efeito de uma influencia diretamente o efeito da segunda.
4: Já a correlação mostra uma associação entre duas informações, mas não implica causalidade.
Para nos auxiliar nessa investigação vamos utilizar mais uma de nossas armas ninjas: O teste qui-quadrado, ele nos ajuda a fazer exatamente essa verificação onde se o p-valor for menor que 0,05 podemos dizer então que não há coincidência e que existe uma associação real entre as informações analisadas.
- p < 0,05 - Existe associação significativa (não é coincidência)
- p > 0,05 - Não há evidência suficiente de associação
Para informações mais detalhadas sobre o assunto, [leia o artigo](https://docs-scipy-org.translate.goog/doc/scipy-1.17.0/reference/generated/scipy.stats.chisquare.html?_x_tr_sl=en&_x_tr_tl=pt&_x_tr_hl=pt&_x_tr_pto=tc](https://docs-scipy-org.translate.goog/doc/scipy-1.17.0/reference/generated/scipy.stats.chisquare.html?_x_tr_sl=en&_x_tr_tl=pt&_x_tr_hl=pt&_x_tr_pto=tc )
chi2, p_value, dof, expected = chi2_contingency(dados)
print('Teste Qui-Quadrado')
print(f'Chi-Quadrado : {chi2:.4f}')
print(f'P-valor : {p_value:.4f}')
print(f'Graus de lib.: {dof}')
print()
if p_value < 0.05:
print('Resultado: p < 0,05 - Existe associação')
else:
print('Resultado: p > 0,05 - Não existe associação')
print('Frequência Esperada (Se não houvesse associação)')
expected_df = pd.DataFrame(expected, index=dados.index, columns=dados.columns)
print(expected_df.round(2))
Resíduos padronizados - Quem está fora do esperado?
Após nossa análise investigativa temos a certeza que estamos no caminho certo! Sem a associação entre a aldeia e o estilo, as propoções seriam quase iguais em todos os lugares.
Para continuar nossa análise devemos questionar novamente nossa base de dados e entender quem está fora do esperado. Para isso vamos conhecer mais uma de nossas armas ninjas: Os resíduos padronizados nos proporcionam uma medição dessa diferença: O que vemos de fato x O que veríamos se tudo fosse igual ou parecido.
Afim de calcular essa informação seguimos a formula de resíduo onde resíduo = (observado - esperado) / esperado (raiz quadrada de esperado).
n = dados.sum().sum()
row_sums = dados.sum(axis=1)
col_sums = dados.sum(axis=0)
residuals = pd.DataFrame(index=dados.index), columns=dados.columns, dtype=float)
for i in dados.index:
for j in dados.columns:
expected_ij = (row_sums[i] * col_sums[j]) / n
residuals.loc[i, j] = dados.loc[i, j] - expected_ij) / np.sqrt(expected_ij)
print('Resíduos Padronizados)
print(residuals.round(3))
Agora com nossos números brutos definidos, podemos visualiza-los em gráfico onde:
- Valor positivo (azul): Mais do que o esperado, associação forte.
- Valor negativo (vermelho): Menos do que o esperado. Fórmula: resíduo = (observado - esperado) / esperado(raiz quadrada de esperado)
plt.figure(figsize=(10,6))
sns.heatmap(residuals, annot=True, fmt='.2f', cmap='RdBu_r', center=0,
linewidths=0.5, linecolor='#cccccc',
cbar_ks={'label': 'Resíduo Padronizado'},
annot_kws={'size': 16, 'weight': 'bold'},
vmin=-1.8, vmax=1.8)
plt.title('Resíduos Padronizados\nAzul= Acima do esperado | Vermelho = Abaixo do esperado')
plt.xlabel('Estilo de Luta')
plt.ylabel('Aldeia')
plt.tight_layout()
plt.show()
Decomposição SVD - Reduzindo o mundo em duas dimensões
Vamos imaginar o mapa do mundo de naruto:
Podemos observar nossa amostra com mil aldeias e mil estilos de luta. Dentro desse cenário é impossível de visualizar, concorda? Para isso a decomposição SVD ajuda a pegar todas essa informação e comprimi-la em duas dimensões, como se você fosse um cartógrafo e decidisse criar um mapa mais simplificado que ainda captura informações importantes.
Não perde tudo, só os detalhes menos relevantes. É como usar o sharingan: você foca no que importa.
Em termos mais técnicos: A SVD (Singular Value Decomposition) é o coração matemático da CA (Correspondency Analysis). Para ela temos algumas etapas necessárias afim de aplica-la efetivamente:
- 1. Normalizar a matriz
- 2. Calcular a matriz
- 3. Aplicar SVD para obter as coordenadas
Primeiro vamos normalizá-la:
P = dados.values / n
print('Matriz de Proporções (P)')
print(pd.DataFrame(P, index=dados.index, columns=dados.columns).round(3))
print(f'Soma total (deveser 1.0): {P.sum():.1f}')
Em seguida calculamos a matriz Z e massas:
row_mass = P.sum(axis=1) # massa das linhas
col_mass = P.sum(axis=0) # massa das colunas
print ('Massa das linhas (aldeias):')
for aldeia, massa in zip(dados.index, row_mass):
print(f'{aldeia}: {massa:.3f}')
print ('Massa das colunas (estilos):')
for estilo, massa in zip(dados.colums, col_mass):
print(f' {estilo}: {massa:.3f}')
# Matriz Z (resíduos padronizados pela massa)
Z = (P - np.outer(row_mass, col_mass)) / np.sqrt(np.outer(row_mass, col_mass))
print('Matriz Z (entrada SVD)')
print(pd.DataFrame(Z, index=dados.index, columns=dados.columns).round(4))
E por fim aplicamos a SVD de fato:
U,s, Vt = np.linalg.svd(Z, full_matrices=False)
print('Valores Singulares (s)')
for i, val in enumetate (s):
print(f' s[{i}] = {val:.4f}')
# Coordenadas nas duas primeiras dimensões
row_coords = U[:, :2] * s[:2]
col_coords = Vt.T[:, :2] * s[:2]
# Inércia explicada por dimensão
inertia = s**2
explained_var = inertia / inertia.sum() * 100
print('Inércia explicada por dimensão')
for i, var in enumerate(explained_var):
print(f' Dimensão {i+1}: {var:.2f}%')
print(f'\n Acumulada (Dim 1 + 2): {explained_var[0] + explained_var[1]:.2f}%')
print('Coordenadas das aldeias')
coords_aldeias = pd.DataFrame(row_coords, index=dados.columns, columns=['Dim ', 'Dim 2'])
print(coords_aldeias.round(4))
print('Coordenadas dos estilos')
coords_estilos = pd.DataFrame(col_coords, index=dados.columns, columns=['Dim 1', 'Dim 2'])
print(coords_estilos.round(4))
Mapa de Correspondência (Biplot) - O mapa final do mundo do Naruto
Finalmente nos aproximamos ao final de nossa análise afim de podermos traçar as melhores estratégias para nossos queridos alunos!
Pontos próximos = relação forte.
Se Konoha e Taijutsu estão do mesmo lado do mapa, significa que a Konoha é conhecida por Taijutsu. Faz sentido, né? Rock Lee, Guy-Sensei, todos são de Konoha e são mestres de Taijutsu.
Pontos longe = relação fraca.
Se Kiri fica do outro lado, significa que os shinobis de Kiri não são tão voltados para Taijutsu.
A origem do mapa (centro) é o ponto neutro, sem associação forte com nada.
# maior residuo padronizado positivo
associacoes = {}
for aldeia in dados.index:
estilo_mais_forte = residuals.loc[aldeia].idxmax()
associacoes[aldeia] = estilo_mais_forte
CORES_ESTILOS = {}
for aldeia, estilo in associacoes.items():
CORES_ESTILOS[estilo] = CORES_ALDEIAS[aldeia]
for estilo in dados.columns:
if estilo in dados.columns:
CORES_ESTILOS[estilo] = '#999999'
print(' Associações por resíduo mais forte: ')
for aldeia, estilo in associacoes.items():
print(f' {aldeia} - {estilo} (resíduo: {residuals.loc[aldeia, estilo]:.2f})')
fig, ax = plt.subplots(figsize=(10,8))
# Linhas de referência
ax.axhline(0, color='#aaaaaa', linewidth=0.8, linestyle='--')
ax.axvline(0, color='#aaaaaa', linewidth=0.8, linestyle='--')
# Linhas conectando ao centro
for i in range(len(dados.index)):
ax.plot([0, row_coords[i, 0]], [0, row_coords[i,1]],
color=CORES_ALDEIAS[dados.columns[i]], linewidth=1.5, alpha=0.4)
# Aldeias (círculos)
for i, aldeia in enumerate(dados.index):
ax.scatter(row_coords[i,0], row_coords[i,1],
s=100, c= CORES_ALDEIAS[aldeia], edgecolors='black',
linewidth=1.5, zorder=5)
ax.annotate(aldeia, (row_coords[i,0], row_coords[i,1]),
textcoords='offset points', xytext=(12,9),
fontsize=13, fontweight='bold', color=CORES_ALDEIAS[aldeia])
# Estilos (quadrados)
for i, estilo in enumerate(dados.columns):
ax.scatter(row_coords[i,0], row_coords[i,1],
s=100, c=CORES_ALDEIAS[aldeia], edgecolors='black',
linewidth=1.5, zorder=5)
ax.annotate(aldeia,(row_coords[i,0], row_coords[i,1]),
textcoords='offset points', xytext=(12,9),
fontsize=13, fontweight='bold', color=CORES_ALDEIAS[aldeia])
# Legenda das associações
legend_handles = []
for aldeia, estilo in associacoes.items():
legend_handles.append(
plt.Line2D([0], [0], marker='o), color='w',
markerfacecolor=CORES_ALDEIAS[aldeia], markersize=12,
label=f'{aldeia} ● {estilo}', linestyle='None')
)
legend_handles.append(
plt.Line2D([0], [0], marker='o', color='w', markerfacecolor='#cccccc',
markersize=10, label='● = Aldeia', linestyle='None')
)
legend_handles.append(
plt.Line2D([0], [0], marker='s', color='w', markerfacecolor='#cccccc',
markersize=10, label='■ = Estilo', linestyle='None')
)
ax.legend(handles=legend_handles, loc='lower right', fontsize=10,
framealpha=0.95, title='Associações', title_fontsize=11)
ax.set_title('Mapa de Correspondência (Biplot)\nCores iguais = aldeia e estilo mais associados')
ax.set_xlabel(f'Dimensão 1 ({explained_var[0]:.1f}% da inércia)')
ax.set_ylabel(f'Dimensão 2 ({explained_var[1]:.1f}% da inércia)')
ax.set_title('Mapa de Correspondência (Biplot)\nCores iguais = aldeia e estilo mais associados')
ax.set_xlabel(f'Dimensão 1 ({explained_var[0]:.1f}% da inércia)')
ax.set_ylabel(f'Dimensão 2 ({explained_var[1]:.1f}% da inércia)')
Interpretação final
No final do mapa, você veria Konoha bem perto de Taijutsu ( Rock Lee e Guy), Kiri perto de Senjutsu (por exemplo), e assim por diante.
Cada cluster conta uma história sobre quem se parece com quem no mundo dos shinobis.
É basicamente isso. A Análise de Correspondência é um mapa que mostra quais categorias se parecem entre si, sem precisar olhar para uma tabela cheia de números!
Biblioteca Prince - Otimizando o tempo
Durante toda a análise, calculamos manualmente cada etapa: a matriz de proporções, as massas, a matriz Z, a decomposição SVD e as coordenadas.
Isso foi muito importante para entender o que acontece por baixo dos panos, como se você fosse um Sensei explicando cada movimento de uma técnica de luta aos seus Genins.
Mas imagina que agora você precisa executar a técnica o mais rápido possível para uma missão (Ou, talvez... A vila esteja sendo atacada e precisam saber com quem podem contar de cada Aldeia). É exatamente isso que a biblioteca PRINCE oferece, ela encapsula toda essa matemática em poucas linhas de código, assim como o Naruto não precisa pensar em cada passo do Rasenshuriken depois que já dominou a técnica.
O Prince entrega tudo pronto: coordenadas das aldeias, coordenadas dos estilos, inércia explicada e contribuições de cada ponto. É a diferença entre um genin aprendendo Taijutsu passo a passo e um jounin executando a técnica em um segundo.
A única desvantagem que percebo é que, assim como usar um jutsu poderoso sem entender sua origem, você perde a visibilidade do que acontece internamente. Por isso, entender a implementação manual primeiro é fundamental antes de partir para algo mais avançado como o Prince.
Conclusão
Obrigada por ler até aqui! :)
Fontes:
- https://docs-scipy-org.translate.goog/doc/scipy-1.17.0/reference/generated/scipy.stats.chisquare.html?_x_tr_sl=en&_x_tr_tl=pt&_x_tr_hl=pt&_x_tr_pto=tc
- https://pt.khanacademy.org/math/statistics-probability/analyzing-categorical-data
- https://maxhalford.github.io/prince/ca/
- https://naruto.fandom.com/pt-br/wiki/Geografia
Para ter acesso a todo código da análise sinta-se a vontade para se basear no repósitorio: https://github.com/surocham/ca_naruto
Agradecimento especial para @cherryramatis que me ajudou revisando este artigo. 💗💗💗























Top comments (2)
MUITO criativa na didática, simplesmente incrível aprender sobre dados com você
Muito bacana sua análise