---
title: "PCA e Clustering no pré-processamento"
subtitle: "IPS e votação municipal"
author: "M.Sc. Renato Barreira"
lang: pt-BR
format:
html:
theme: cosmo
toc: true
toc-depth: 2
number-sections: true
code-fold: true
code-tools: true
code-overflow: wrap
embed-resources: true
fig-width: 8
fig-height: 4.8
include-in-header:
text: |
<style>
body { font-size: 17px; color: #223344; }
h1, h2, h3 { color: #163e55; }
pre.sourceCode { border-left: 3px solid #26859b; }
.cell-output-display { overflow-x: auto; }
table { font-size: 0.91em; }
.quarto-title .subtitle { color: #536575; }
</style>
execute:
echo: true
warning: true
message: false
error: false
params:
ips_csv: "IPS Brasil 2026 - Tabela de Dados_Brasil.csv"
eleicao_csv: "presidente_2_turno_2026_por_municipio.csv"
arvores: 500
semente: 2026
---
```{r}
#| label: setup
#| include: false
if (!exists("params")) params = list(
ips_csv = "IPS Brasil 2026 - Tabela de Dados_Brasil.csv",
eleicao_csv = "presidente_2_turno_2026_por_municipio(1).csv",
arvores = 500L, semente = 2026L)
options(width = 90, digits = 6, scipen = 999)
stopifnot(requireNamespace("ranger", quietly = TRUE),
requireNamespace("stringi", quietly = TRUE))
set.seed(params$semente)
```
```{r}
#| label: formato
#| include: false
knitr::opts_chunk$set(dpi = 140, fig.width = 8, fig.height = 4.8)
fnum = function(x, d = 2) formatC(x, format = "f", digits = d,
decimal.mark = ",", big.mark = ".")
pal = c("#177f96", "#c75a35", "#64744d", "#77608f")
tab = function(x, digits = 2) knitr::kable(x, digits = digits, row.names = FALSE)
```
# 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.
::: {.callout-tip}
## Como 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.
:::
# 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.
```{r}
#| label: leitura
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)
```
```{r}
#| label: correspondencia
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"]])
```
```{r}
#| label: atributos
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)
```
# 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”.
```{r}
#| label: preprocessamento
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)))
}
```
# 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.
```{r}
#| include: false
#| label: modelos
rmse = function(real, previsto) sqrt(mean((real - previsto)^2))
metricas = function(real, previsto) c(
RMSE = rmse(real, previsto), MAE = mean(abs(real - previsto)),
R2 = 1 - sum((real - previsto)^2) / sum((real - mean(real))^2))
prever_rf = function(x_tr, y_tr, x_te, semente) {
fit = ranger::ranger(x = as.data.frame(x_tr), y = y_tr,
num.trees = params$arvores, mtry = max(1L, floor(sqrt(ncol(x_tr)))),
min.node.size = 5, seed = semente, num.threads = 2,
importance = "none", write.forest = TRUE, oob.error = FALSE)
predict(fit, data = as.data.frame(x_te), num.threads = 2)$predictions
}
prever_lm = function(x_tr, y_tr, x_te) {
x_tr = as.data.frame(x_tr); x_te = as.data.frame(x_te)
names(x_tr) = names(x_te) = paste0("x", seq_len(ncol(x_tr)))
fit = lm(y ~ ., data = data.frame(y = y_tr, x_tr))
unname(predict(fit, newdata = x_te))
}
# Cache invalidado quando mudam os dados, parâmetros, funções ou ambiente.
knitr::opts_chunk$set(cache.extra = digest::digest(list(
X, y, base$uf, params, lapply(list(aprender, aplicar, imputar, distancias,
prever_rf, prever_lm), body), R.version.string,
as.character(packageVersion("ranger")))))
```
```{r}
#| include: false
#| label: validacao
#| cache: true
set.seed(params$semente)
fold_aleatorio = sample(rep(1:5, length.out = nrow(X)))
fold_uf = match(base$uf, sort(unique(base$uf)))
# Objeto global apenas para exploração descritiva e demonstração de vazamento.
# Nunca alimenta as três representações da comparação principal.
global = aprender(X, params$semente)
global_pca = aplicar(global, X)$pca90
avaliar = function(fold, nome) {
modelos = c("media", "original", "pca90", "original_dist", "pca_global")
pred = matrix(NA_real_, nrow(X), length(modelos), dimnames = list(NULL, modelos))
pred_lm = pred[, c("original", "pca90", "original_dist"), drop = FALSE]
diagnostico = list()
for (f in sort(unique(fold))) {
te = which(fold == f); tr = which(fold != f)
stopifnot(length(intersect(tr, te)) == 0L)
if (nome == "UF") stopifnot(!any(base$uf[tr] %in% base$uf[te]))
pp = aprender(X[tr, , drop = FALSE], params$semente + f)
x_tr = aplicar(pp, X[tr, , drop = FALSE])
x_te = aplicar(pp, X[te, , drop = FALSE])
pred[te, "media"] = mean(y[tr])
for (m in c("original", "pca90", "original_dist")) {
pred[te, m] = prever_rf(x_tr[[m]], y[tr], x_te[[m]], params$semente + f)
pred_lm[te, m] = prever_lm(x_tr[[m]], y[tr], x_te[[m]])
}
# Pipeline deliberadamente inadequado: PCA conhece X do teste.
pred[te, "pca_global"] = prever_rf(global_pca[tr, , drop = FALSE], y[tr],
global_pca[te, , drop = FALSE], params$semente + f)
diagnostico[[as.character(f)]] = data.frame(
fold = f, n_treino = length(tr), n_teste = length(te), PCs = pp$q,
mudanca_centro = mean(abs(pp$centro - global$centro)))
}
stopifnot(all(is.finite(pred)), all(is.finite(pred_lm)))
list(pred = pred, pred_lm = pred_lm,
diagnostico = do.call(rbind, diagnostico))
}
res_aleatorio = avaliar(fold_aleatorio, "Aleatória")
res_uf = avaliar(fold_uf, "UF")
```
```{r}
#| include: false
#| label: resultados
#| code-fold: true
#| code-summary: "Cálculo das métricas e dos erros por UF"
rotulos = c(media = "Média do treino", original = "12 componentes",
pca90 = "PCA: 90% da variância", original_dist = "12 componentes + distâncias",
pca_global = "PCA global (inadequada)")
tabela_metricas = function(resultado, validacao, algoritmo = "RF") {
pred = if (algoritmo == "RF") resultado$pred else resultado$pred_lm
do.call(rbind, lapply(colnames(pred), function(m) data.frame(
Validacao = validacao, Modelo = if (m == "media") "Referência" else algoritmo,
Representacao = unname(rotulos[m]),
t(metricas(y, pred[, m])), row.names = NULL, check.names = FALSE)))
}
tab_rf = rbind(tabela_metricas(res_aleatorio, "Aleatória (5 partes)"),
tabela_metricas(res_uf, "Uma UF de fora"))
principal = tab_rf[!grepl("inadequada", tab_rf$Representacao), ]
tab_lm = rbind(tabela_metricas(res_aleatorio, "Aleatória (5 partes)", "OLS"),
tabela_metricas(res_uf, "Uma UF de fora", "OLS"))
vazamento = do.call(rbind, lapply(c("Aleatória", "UF"), function(tipo) {
obj = if (tipo == "Aleatória") res_aleatorio else res_uf
seguro = rmse(y, obj$pred[, "pca90"])
global_err = rmse(y, obj$pred[, "pca_global"])
data.frame(Validacao = tipo, PCA_no_treino = seguro, PCA_global = global_err,
Diferenca_global_menos_treino = global_err - seguro)
}))
por_uf = do.call(rbind, lapply(sort(unique(base$uf)), function(u) {
i = which(base$uf == u)
data.frame(UF = u, N = length(i),
Original = rmse(y[i], res_uf$pred[i, "original"]),
PCA = rmse(y[i], res_uf$pred[i, "pca90"]),
Distancias = rmse(y[i], res_uf$pred[i, "original_dist"]))
}))
```
```{r}
#| include: false
#| label: tabela-principal
tab(principal, digits = 3)
melhor_observado = function(validacao) {
z = principal[principal$Validacao == validacao & principal$Modelo == "RF", ]
z$Representacao[which.min(z$RMSE)]
}
```
```{r}
#| label: tabela-resumida
#| echo: false
tab(principal[c("Validacao", "Representacao", "RMSE", "MAE", "R2")], digits = 3)
```
```{r}
#| echo: false
#| label: grafico-principal
#| fig-cap: "Erro preditivo das representações, com previsões fora do treinamento."
nomes = c("12 componentes", "PCA: 90% da variância", "12 componentes + distâncias")
erros = sapply(unique(principal$Validacao), function(v) {
t = principal[principal$Validacao == v, ]
t$RMSE[match(nomes, t$Representacao)]
})
par(mar = c(5, 4, 2, 1), mgp = c(2.5, .7, 0))
barplot(t(erros), beside = TRUE, col = pal[1:2], border = NA,
names.arg = c("Originais", "PCA 90%", "Originais + distâncias"),
ylab = "RMSE (pontos percentuais)", ylim = c(0, max(erros) * 1.25))
legend("topleft", legend = colnames(erros), fill = pal[1:2], bty = "n", cex = .85)
```
A menor RMSE entre as representações com Random Forest foi obtida por **`r melhor_observado("Aleatória (5 partes)")`** na validação aleatória e **`r melhor_observado("Uma UF de fora")`** na validação por UF. Preservar variância no PCA não garante preservar informação útil para prever votos.
# 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;
::: {.callout-caution}
## Pré-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."
:::
::: {.callout-important}
## Renato, você usou IA?
IA foi usada pra traduzir o texto para português do artigo e para algumas seções de vibe code.
:::
# Referências
- Jain, A. K. (2010). Data clustering: 50 years beyond K-means. *Pattern Recognition Letters*, 31(8), 651–666. [DOI](https://doi.org/10.1016/j.patrec.2009.09.011).
- Jolliffe, I. T.; Cadima, J. (2016). Principal component analysis: a review and recent developments. *Philosophical Transactions of the Royal Society A*, 374, 20150202. [DOI](https://doi.org/10.1098/rsta.2015.0202).
- 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](https://doi.org/10.1111/rssb.12537).
- Roberts, D. R. et al. (2017). Cross-validation strategies for data with temporal, spatial, hierarchical, or phylogenetic structure. *Ecography*, 40(8), 913–929. [DOI](https://doi.org/10.1111/ecog.02881).
- Wilm, M. et al. (2026). *Índice de Progresso Social Brasil 2026: relatório metodológico*. Belém: Imazon. [Relatório](https://ipsbrasil.org.br/relatorios/download/IPSBrasil2026_RMETODOLOGICO_PT_DIGITAL.pdf).