PCA e Clustering no pré-processamento

IPS e votação municipal

Autor

M.Sc. Renato Barreira

1 Pergunta e dados

RQ1: PCA e agrupamento melhoram a previsão da votação municipal a partir do IPS?

Foram usados os 12 componentes do IPS e o percentual de votos válidos de Lula. A base integrada reúne 5.570 unidades municipais; cada município recebe o mesmo peso.

A correspondência usa nome e UF, com 14 ajustes explícitos para recuperar o código IBGE. Boa Esperança do Norte aparece apenas no arquivo eleitoral e fica fora da análise.

DicaComo isso se relaciona com a minha tese?

Um modelo pode aprender a prever votos em cenários de extrapolação. O desafio científico é compreender o que sustenta essa previsão — e até onde podemos confiar nela. E isso perpassa, também, falar sobre o pré-processamento.

2 Preparação dos dados

O código confere as contagens eleitorais e os percentuais, integra as bases e seleciona os indicadores. Os blocos podem ser abertos para consulta.

Código
ler_csv = function(caminho) {
  if (!file.exists(caminho)) stop("Arquivo não encontrado: ", caminho)
  read.csv(caminho, fileEncoding = "UTF-8-BOM", check.names = FALSE,
           colClasses = "character", na.strings = c("", "NA"))
}
numero = function(x) {
  y = suppressWarnings(as.numeric(x))
  if (any(!is.na(x) & is.na(y))) stop("Valor numérico inválido na importação.")
  y
}
ips = ler_csv(params$ips_csv)
eleicao = ler_csv(params$eleicao_csv)
names(ips) = trimws(names(ips))
ips[-c(1, 2)] = lapply(ips[-c(1, 2)], numero)
campos_numericos = c("eleicao", "turno", "votos_lula", "percentual_lula",
  "votos_flavio_bolsonaro", "percentual_flavio_bolsonaro",
  "votos_computados_candidatos", "brancos", "nulos", "eleitorado",
  "comparecimento", "abstencao")
eleicao[campos_numericos] = lapply(eleicao[campos_numericos], numero)
stopifnot(all(eleicao$eleicao == 2026), all(eleicao$turno == 2),
          all(eleicao$cargo == "presidente"),
          all(grepl("^[0-9]{7}$", eleicao$codigo_ibge)),
          !anyDuplicated(eleicao$codigo_ibge),
          !anyNA(eleicao[campos_numericos]))
contagens = c("votos_lula", "votos_flavio_bolsonaro", "brancos", "nulos",
               "votos_computados_candidatos", "comparecimento", "abstencao", "eleitorado")
stopifnot(all(as.matrix(eleicao[contagens]) >= 0),
          all(as.matrix(eleicao[contagens]) %% 1 == 0),
          all(eleicao$votos_computados_candidatos > 0))
checagens = data.frame(
  Identidade = c("Lula + Flávio = votos dos candidatos",
                "Votos dos candidatos + brancos + nulos = comparecimento",
                "Comparecimento + abstenção = eleitorado"),
  Inconsistencias = c(
    sum(with(eleicao, votos_lula + votos_flavio_bolsonaro != votos_computados_candidatos)),
    sum(with(eleicao, votos_computados_candidatos + brancos + nulos != comparecimento)),
    sum(with(eleicao, comparecimento + abstencao != eleitorado))))
stopifnot(all(checagens$Inconsistencias == 0))
eleicao$y = with(eleicao, 100 * votos_lula / votos_computados_candidatos)
erro_percentual = max(abs(eleicao$y - eleicao$percentual_lula))
stopifnot(erro_percentual <= 0.000051,
  max(abs(100 - eleicao$y - eleicao$percentual_flavio_bolsonaro)) <= 0.000051)
faltantes = sort(colSums(is.na(ips)), decreasing = TRUE)
Código
chave = function(nome, uf) {
  nome = toupper(stringi::stri_trans_general(trimws(nome), "Latin-ASCII"))
  paste(gsub("[^A-Z0-9]", "", nome), toupper(trimws(uf)), sep = "|")
}
ips$chave = chave(ips$Município, ips$UF)
eleicao$chave = chave(eleicao$municipio, eleicao$uf)
stopifnot(!anyDuplicated(ips$chave), !anyDuplicated(eleicao$chave))
indice = match(ips$chave, eleicao$chave)
n_exatos = sum(!is.na(indice))

#Correspondências revistas explicitamente. Nenhuma junção aproximada automática.
ajustes = data.frame(
  nome_ips = c("Munhoz de Melo", "Dona Euzébia", "Amparo do São Francisco",
    "São Tomé das Letras", "Gracho Cardoso", "Arês", "Bom Jesus de Goiás",
    "Açu", "Alvorada D'Oeste", "São Luiz do Paraitinga", "Espigão D'Oeste",
    "Januário Cicco", "Santo Antônio de Leverger", "Eldorado do Carajás"),
  uf = c("PR", "MG", "SE", "MG", "SE", "RN", "GO", "RN", "RO", "SP",
         "RO", "RN", "MT", "PA"),
  codigo_ibge = c("4116307", "3122900", "2800100", "3165206", "2802601",
    "2401206", "5203500", "2400208", "1100346", "3550001", "1100098",
    "2405306", "5107800", "1502954"))
a = match(chave(ajustes$nome_ips, ajustes$uf), ips$chave)
b = match(ajustes$codigo_ibge, eleicao$codigo_ibge)
stopifnot(!anyNA(a), !anyNA(b), all(is.na(indice[a])),
          all(ips$UF[a] == eleicao$uf[b]))
indice[a] = b
ajustes$nome_eleicao = eleicao$municipio[b]
sem_ips = eleicao[!seq_len(nrow(eleicao)) %in% indice,
                  c("codigo_ibge", "municipio", "uf")]
stopifnot(!anyNA(indice), !anyDuplicated(indice))
base = data.frame(codigo_ibge = eleicao$codigo_ibge[indice],
                   municipio = ips$Município, uf = ips$UF,
                   y = eleicao$y[indice],
                   votos_validos = eleicao$votos_computados_candidatos[indice],
                   ips_geral = ips[["Índice de Progresso Social"]])
Código
componentes = c("Nutrição e Cuidados Médicos Básicos", "Água e Saneamento",
  "Moradia", "Segurança Pessoal", "Acesso ao Conhecimento Básico",
  "Acesso à Informação e Comunicação", "Saúde e Bem-estar",
  "Qualidade do Meio Ambiente", "Direitos Individuais",
  "Liberdades Individuais e de Escolha", "Inclusão Social",
  "Acesso à Educação Superior")
X = as.matrix(ips[componentes])
colnames(X) = sprintf("C%02d", seq_along(componentes))
# Ordenação explícita para tornar a partição reproduzível.
ordem = order(base$codigo_ibge)
base = base[ordem, ]; X = X[ordem, , drop = FALSE]
rownames(base) = NULL; rownames(X) = NULL
y = base$y
stopifnot(nrow(X) == nrow(base), ncol(X) == 12L,
          !anyDuplicated(base$codigo_ibge), all(y >= 0 & y <= 100),
          all(X >= 0 & X <= 100, na.rm = TRUE))
resumo_atributos = data.frame(Codigo = colnames(X), Componente = componentes,
  Media = colMeans(X, na.rm = TRUE), DP = apply(X, 2, sd, na.rm = TRUE),
  Ausentes = colSums(is.na(X)), row.names = NULL)

3 Pré-processamento

Comparamos três representações:

Versão Atributos usados
Original 12 componentes do IPS
PCA Componentes principais que preservam pelo menos 90% da variância do treino
K-means 12 indicadores padronizados e distâncias aos três centroides

A padronização evita que diferenças de dispersão determinem sozinhas as distâncias. O PCA comprime os indicadores; o k-means acrescenta informações de proximidade a perfis municipais (Jolliffe e Cadima, 2016; Jain, 2010).

A Padronização feita foi: subtração da média e divisão pelo desvio-padrão do treinamento. Assim, as diferenças passam a ser medidas nessa unidade comum. Isso reduz o domínio dos indicadores naturalmente mais dispersos sobre o cálculo das distâncias.

Todas as regras são calculadas no treinamento e reaplicadas ao teste. Isso inclui médias, desvios-padrão, PCA e centroides. Os 12 indicadores estão completos; a imputação prevista na função não modifica este recorte. O IPS já passou por tratamento próprio: avaliamos transformações adicionais dos escores publicados.

OU SEJA: i) Primeiro, os indicadores foram padronizados para tornar suas diferenças comparáveis. ii) Depois, foi testadas duas formas de pré-processamento: o PCA (Análise de Componentes Principais), que resume os indicadores em menos variáveis, e o k-means, que acrescenta a distância de cada município a três perfis sociais. iii) Por fim, comparam-se essas versões com os indicadores originais para verificar se ajudam a prever a votação. Todas essas transformações foram ajustadas apenas nos dados de treinamento.”

O PCA procura combinações lineares dos indicadores que concentrem a maior variância possível.

Quando dois indicadores são fortemente correlacionados, eles variam conjuntamente. Isso significa que os municípios ocupam uma região comum nos dois indicadores.

Por exemplo, se saneamento e moradia aumentam quase juntos, grande parte da variação pode ser descrita por uma combinação que represente esse aumento conjunto. Então o que o PCA está perguntando é “quantas variáveis eu preciso para explicar 90% da variância?” sem olhar pra variável alvo.

E o K-Means, identifica três grupos de municípios com indicadores semelhantes. Cada grupo tem um centro, que representa seu perfil médio nos indicadores, o K-Means pergunta “este município está próximo do perfil 1 e distante do perfil 3”.

Código
imputar = function(x, medianas) {
  x = as.matrix(x)
  for (j in seq_len(ncol(x))) x[is.na(x[, j]), j] = medianas[j]
  x
}
distancias = function(x, centros) {
  d = matrix(vapply(seq_len(nrow(centros)), function(j)
    sqrt(rowSums(sweep(x, 2, centros[j, ], "-")^2)), numeric(nrow(x))),
    nrow = nrow(x), ncol = nrow(centros))
  colnames(d) = paste0("dist", seq_len(ncol(d)))
  d
}
aprender = function(x_treino, semente, limiar = .90, k = 3L) {
  med = apply(x_treino, 2, median, na.rm = TRUE)
  if (any(!is.finite(med))) stop("Atributo inteiramente ausente no treino.")
  x = imputar(x_treino, med)
  dp = apply(x, 2, sd)
  manter = is.finite(dp) & dp > 0
  x = x[, manter, drop = FALSE]
  centro = colMeans(x); escala = dp[manter]
  z = scale(x, center = centro, scale = escala)
  pca = prcomp(z, center = FALSE, scale. = FALSE)
  acumulada = cumsum(pca$sdev^2) / sum(pca$sdev^2)
  q = which(acumulada >= limiar)[1]
  set.seed(semente)
  km = kmeans(z, centers = k, nstart = 30, iter.max = 200, algorithm = "Lloyd")
  stopifnot(km$iter <= 200)
  list(medianas = med, manter = manter, centro = centro, escala = escala,
       pca = pca, q = q, acumulada = acumulada, centros = km$centers)
}
aplicar = function(objeto, novos) {
  x = imputar(novos, objeto$medianas)[, objeto$manter, drop = FALSE]
  z = scale(x, center = objeto$centro, scale = objeto$escala)
  pc = predict(objeto$pca, newdata = z)
  list(original = x, pca90 = pc[, seq_len(objeto$q), drop = FALSE],
       original_dist = cbind(z, distancias(z, objeto$centros)))
}

4 Comparação e resultado

Foi usada Random Forest com 500 árvores e configurações fixas. O RF combina várias árvores de decisão. Nesse modelo, cada árvore estima a porcentagem de votos, e a previsão final é a média dessas estimativas. Foi avaliada com cinco partes aleatórias (validação cruzada com cinco partes) e, separadamente, deixando uma UF inteira de fora. O segundo esquema verifica transferência entre estados; não é um teste de autocorrelação espacial.

O MSE (Erro Quadrático Médio) é média dos erros elevados ao quadrado. Penaliza mais os erros grandes. Quanto menor, melhor.

E MAE (Erro Absolut Médio) média do tamanho dos erros, ignorando o sinal. Um MAE de 5 pontos percentuais significa que as previsões se afastam, em média, 5 pontos dos valores reais.

RMSE e MAE menores indicam previsões melhores, em pontos percentuais. As escolhas de 90% de variância e três grupos foram fixadas previamente.

Validacao Representacao RMSE MAE R2
Aleatória (5 partes) Média do treino 18.651 16.258 0.000
Aleatória (5 partes) 12 componentes 12.321 9.711 0.563
Aleatória (5 partes) PCA: 90% da variância 12.925 10.168 0.520
Aleatória (5 partes) 12 componentes + distâncias 12.470 9.823 0.553
Uma UF de fora Média do treino 19.470 17.052 -0.090
Uma UF de fora 12 componentes 14.556 11.665 0.391
Uma UF de fora PCA: 90% da variância 14.729 11.812 0.376
Uma UF de fora 12 componentes + distâncias 14.594 11.669 0.387

Erro preditivo das representações, com previsões fora do treinamento.

A menor RMSE entre as representações com Random Forest foi obtida por 12 componentes na validação aleatória e 12 componentes na validação por UF. Preservar variância no PCA não garante preservar informação útil para prever votos.

5 Conclusões

  • Uma representação mais elaborada dos dados não garante uma previsão melhor;
  • O PCA e o agrupamento continuam sendo ferramentas úteis, mas seus benefícios dependem da tarefa, dos dados e do modelo;
CuidadoPré-Processamento não é ciência freestyle.

Mas como validar então?

“The piles are driven down from above into the swamp, but not down to any natural or “given” base; and if we stop driving the piles deeper, it is not because we have reached firm ground. We simply stop when we are satisfied that the piles are firm enough to carry the structure, at least for the time being.”

ImportanteRenato, você usou IA?

IA foi usada pra traduzir o texto para português do artigo e para algumas seções de vibe code.

6 Referências

  • Jain, A. K. (2010). Data clustering: 50 years beyond K-means. Pattern Recognition Letters, 31(8), 651–666. DOI.
  • Jolliffe, I. T.; Cadima, J. (2016). Principal component analysis: a review and recent developments. Philosophical Transactions of the Royal Society A, 374, 20150202. DOI.
  • Moscovich, A.; Rosset, S. (2022). On the cross-validation bias due to unsupervised preprocessing. Journal of the Royal Statistical Society: Series B, 84(4), 1474–1502. DOI.
  • Roberts, D. R. et al. (2017). Cross-validation strategies for data with temporal, spatial, hierarchical, or phylogenetic structure. Ecography, 40(8), 913–929. DOI.
  • Wilm, M. et al. (2026). Índice de Progresso Social Brasil 2026: relatório metodológico. Belém: Imazon. Relatório.