La pregunta. En el taller de EDA encontramos que la inseguridad en Bogotá se ve distinta según la lente: las denuncias por habitante se concentran en el centro comercial y de mayores ingresos, mientras que la percepción de inseguridad es más alta en la periferia, donde hay más pobreza y más problemas visibles en el barrio. Aquí convertimos esa segunda lente en un número (PCA) y la cruzamos con la primera (K-Means) para ver qué tipos de UPZ aparecen y dónde están.

1. Nuestro indicador

¿Qué queremos medir y por qué? Queremos medir la inseguridad vivida en el entorno del barrio: qué tanto sienten miedo los habitantes y qué tan expuestos están a focos de deterioro cerca de su vivienda. En el EDA la percepción de inseguridad no siguió a las denuncias (rho = −0,32 frente a la tasa), pero sí a la cercanía a expendios de droga (0,67), a basureros (0,61) y, más débilmente, a lotes oscuros. Esas cuatro variables parecen describir un mismo fenómeno que el conteo de denuncias no captura. Un valor alto en el índice significa una UPZ donde más hogares se sienten inseguros y viven cerca de ollas, basureros y lotes baldíos u oscuros; un valor bajo, un entorno percibido como seguro y sin esos focos.

# 1. Cuatro variables de la Encuesta Multipropósito que miden el MISMO concepto:
#    la inseguridad que se vive en el entorno del barrio.
vars_indice <- c("pct_percibe_inseguridad",   # % que percibe inseguridad en su barrio
                 "pct_cerca_ollas",           # % de hogares cerca de expendios de droga
                 "pct_cerca_basureros",       # % de hogares cerca de basureros
                 "pct_cerca_lotes_oscuros")   # % de hogares cerca de lotes baldíos u oscuros

# 2. Orientación: las cuatro ya crecen con el concepto (más % = entorno más inseguro),
#    así que ninguna se multiplica por −1. Son porcentajes sin colas extremas: no hace falta log1p().
X <- upz_ok |> select(all_of(vars_indice))

# 3. Estandarizar y revisar si vale la pena el PCA.
Z <- scale(X)
round(cor(Z), 2)
##                         pct_percibe_inseguridad pct_cerca_ollas
## pct_percibe_inseguridad                    1.00            0.65
## pct_cerca_ollas                            0.65            1.00
## pct_cerca_basureros                        0.51            0.74
## pct_cerca_lotes_oscuros                    0.33            0.69
##                         pct_cerca_basureros pct_cerca_lotes_oscuros
## pct_percibe_inseguridad                0.51                    0.33
## pct_cerca_ollas                        0.74                    0.69
## pct_cerca_basureros                    1.00                    0.65
## pct_cerca_lotes_oscuros                0.65                    1.00
valor_kmo <- kmo(Z)
valor_kmo
## [1] 0.74
# 4. El PCA, con PC1 orientado para que alto = más inseguridad vivida.
pca <- orientar_pc1(prcomp(Z))
pct_pc1 <- 100 * pca$sdev[1]^2 / sum(pca$sdev^2)
grafico_cargas(pca, titulo = "Las ollas y los basureros pesan más que la propia percepción")

# 5. El índice de 0 a 100 y las UPZ de los extremos.
upz_ok <- upz_ok |> mutate(indice = rescale(pca$x[, 1], to = c(0, 100)))
grafico_ranking(upz_ok, "indice",
                titulo = "El entorno más inseguro está en Usme, Ciudad Bolívar y el centro de abastos",
                subtitulo = "Índice de inseguridad vivida en el barrio (0–100)")

¿Qué nos dice el índice? Las cuatro variables están correlacionadas en positivo (entre 0,33 y 0,74) y el KMO es de 0,74, un valor aceptable: comparten suficiente información para resumirlas. PC1 guarda el 70,1 % de la varianza y su autovalor (2,80) es el único mayor que 1; además, las cuatro cargas tienen el mismo signo, así que PC1 funciona como un «más es peor» y sirve como índice. Las variables que más pesan son la cercanía a ollas (30,7 %) y a basureros (27,5 %); la percepción en sí es la que menos aporta (18,9 %), porque es la que menos se correlaciona con los lotes oscuros. Es decir, el índice dice más del entorno físico y social del barrio que de la opinión de sus habitantes.

En la parte alta del ranking aparecen Corabastos (100), La Sabana, Comuneros, Las Cruces, Monte Blanco y El Tesoro: zonas de abastos y del centro deteriorado junto con UPZ periféricas de Usme y Ciudad Bolívar. En la parte baja están Casa Blanca Suba, El Refugio, Chicó Lago, Usaquén y La Esmeralda, en el norte y el corredor oriental. El índice repite el hallazgo del EDA: la inseguridad vivida sigue a la pobreza, no a las denuncias.

1.1 Los gráficos que respaldan el índice

Los dos gráficos anteriores muestran el resultado. Estos cinco muestran el camino, en el mismo orden de la guía de PCA: mirar las variables antes de resumirlas, comprobar que se mueven juntas, ver cuánta información guarda cada componente, ubicar UPZ y variables en un mismo plano y revisar cómo se reparte el índice.

# Etiquetas legibles, colores y tema para los gráficos de apoyo. Usamos nombres propios
# (pca_...) para no pisar ningún objeto de herramientas.R.
etiquetas_indice <- c(pct_percibe_inseguridad = "Percibe inseguridad (%)",
                      pct_cerca_ollas         = "Cerca de expendios de droga (%)",
                      pct_cerca_basureros     = "Cerca de basureros (%)",
                      pct_cerca_lotes_oscuros = "Cerca de lotes baldíos u oscuros (%)")

pca_morado   <- "#351A4F"
pca_magenta  <- "#D9006C"
pca_turquesa <- "#168C86"

tema_pca <- theme_minimal(base_size = 12) +
  theme(plot.title.position = "plot",
        plot.title = element_text(face = "bold", size = 15, hjust = 0),
        plot.subtitle = element_text(size = 10.5, color = "grey35", hjust = 0),
        panel.grid.minor = element_blank(),
        plot.caption = element_text(hjust = 0, color = "grey45", size = 8.5),
        plot.margin = margin(10, 20, 10, 10))

fuente_pca <- paste0("Fuente: Encuesta Multipropósito 2021, ", nrow(upz_ok),
                     " UPZ con 5.000 habitantes o más. Cálculos propios.")

Mirar antes de resumir: las distribuciones

¿Las cuatro variables están en la misma escala y tienen formas parecidas?

# ESTADÍSTICA: la asimetría de cada variable. 0 = simétrica; > 1,5 o < −1,5 = cola larga.
skew_pca <- function(x) mean((x - mean(x))^3) / sd(x)^3

asimetria_indice <- upz_ok |>
  summarise(across(all_of(vars_indice), skew_pca)) |>
  pivot_longer(everything(), names_to = "variable", values_to = "skew")
n_sesgadas <- sum(abs(asimetria_indice$skew) > 1.5)

# VISUAL: un histograma por variable; en magenta las que tengan cola larga.
upz_ok |>
  select(all_of(vars_indice)) |>
  pivot_longer(everything(), names_to = "variable", values_to = "valor") |>
  left_join(asimetria_indice, by = "variable") |>
  mutate(panel = paste0(etiquetas_indice[variable], "\nasimetría = ", num(skew, 2)),
         sesgada = abs(skew) > 1.5) |>
  ggplot(aes(valor, fill = sesgada)) +
  geom_histogram(bins = 20, color = "white", linewidth = 0.2) +
  facet_wrap(~ panel, scales = "free", ncol = 2) +
  scale_fill_manual(values = c(`FALSE` = pca_morado, `TRUE` = pca_magenta), guide = "none") +
  labs(title = if (n_sesgadas == 0) "Cuatro porcentajes sin colas extremas: no hace falta transformarlos"
               else "Cuatro porcentajes, pero no todos tienen la misma forma",
       subtitle = "Distribución de cada variable del índice. En magenta, las más asimétricas (|asimetría| > 1,5)",
       x = NULL, y = "N.º de UPZ", caption = fuente_pca) +
  tema_pca +
  theme(strip.text = element_text(face = "bold", size = 9.5))

¿Qué muestra? Las cuatro variables son porcentajes de hogares o personas, así que hablan en la misma unidad. Su asimetría va de -0,56 a 1,78: 1 de ellas supera(n) el umbral de 1,5, así que conviene leer el índice sabiendo que unas pocas UPZ muy altas pesan más en esas variables. Aun así las estandarizamos, porque sus rangos y dispersiones son distintos.

¿Tiene sentido el PCA? El mapa de calor de correlaciones

¿Las cuatro variables se mueven juntas?

# ESTADÍSTICA: la misma matriz de Pearson que se imprime arriba con round(cor(Z), 2).
R_indice <- cor(Z)
dimnames(R_indice) <- list(etiquetas_indice[rownames(R_indice)], etiquetas_indice[colnames(R_indice)])
orden_cor <- colnames(R_indice)

# VISUAL: la dibujamos como mapa de calor (solo un triángulo: la matriz es simétrica).
as_tibble(R_indice, rownames = "var1") |>
  pivot_longer(-var1, names_to = "var2", values_to = "r") |>
  mutate(var1 = factor(var1, levels = orden_cor), var2 = factor(var2, levels = orden_cor)) |>
  filter(as.integer(var1) < as.integer(var2)) |>
  ggplot(aes(var2, fct_rev(var1), fill = r)) +
  geom_tile(color = "white", linewidth = 1) +
  geom_text(aes(label = num(r, 2)), size = 4.2, fontface = "bold", color = "grey15") +
  scale_fill_gradient2(low = pca_magenta, mid = "white", high = pca_turquesa,
                       midpoint = 0, limits = c(-1, 1), name = "Correlación\nde Pearson") +
  scale_x_discrete(position = "top", labels = label_wrap(16)) +
  scale_y_discrete(labels = label_wrap(22)) +
  labs(title = "Las cuatro variables se correlacionan en positivo",
       subtitle = "Correlación entre cada par de variables estandarizadas",
       x = NULL, y = NULL, caption = fuente_pca) +
  tema_pca +
  theme(panel.grid = element_blank())

¿Qué muestra? Todas las celdas son turquesa: donde más hogares viven cerca de ollas, también hay más basureros, más lotes oscuros y más percepción de inseguridad. La correlación más alta es de 0,74 y la más baja de 0,33. Es la versión visual del KMO de 0,74: hay información compartida que vale la pena resumir.

Scree plot: ¿cuánta información guarda cada componente?

# sdev^2 son los autovalores; divididos por su suma dan la proporción de varianza.
varianza_pca <- tibble(componente = paste0("PC", seq_along(pca$sdev)),
                       autovalor  = pca$sdev^2,
                       pct        = 100 * autovalor / sum(autovalor),
                       acumulado  = cumsum(pct))

varianza_pca |>
  mutate(across(c(autovalor, pct, acumulado), ~ num(.x, 2))) |>
  kbl(col.names = c("Componente", "Autovalor", "% de varianza", "% acumulado"), align = "lrrr") |>
  kable_styling(bootstrap_options = c("hover", "condensed"), full_width = FALSE) |>
  row_spec(0, bold = TRUE, color = "white", background = pca_morado)
Componente Autovalor % de varianza % acumulado
PC1 2,80 70,11 70,11
PC2 0,69 17,15 87,27
PC3 0,31 7,80 95,07
PC4 0,20 4,93 100,00
# Barras = % de cada componente; línea = % acumulado.
ggplot(varianza_pca, aes(x = fct_inorder(componente))) +
  geom_col(aes(y = pct), fill = c(pca_magenta, rep(pca_morado, nrow(varianza_pca) - 1)), width = 0.65) +
  geom_text(aes(y = pct, label = paste0(num(pct, 1), " %")), vjust = -0.5, size = 3.6) +
  geom_line(aes(y = acumulado, group = 1), color = pca_turquesa, linewidth = 0.9) +
  geom_point(aes(y = acumulado), color = pca_turquesa, size = 2.4) +
  annotate("text", x = 2.5, y = min(varianza_pca$acumulado[2] + 9, 103), label = "% acumulado",
           color = pca_turquesa, fontface = "bold", size = 3.6) +
  scale_y_continuous(limits = c(0, 108), breaks = seq(0, 100, 20)) +
  labs(title = paste0("Un solo componente guarda el ", num(pct_pc1, 1), " % de la información"),
       subtitle = "Porcentaje de la varianza total explicada por cada componente principal",
       x = "Componente principal", y = "% de varianza explicada", caption = fuente_pca) +
  tema_pca

¿Qué muestra? Después de PC1 hay un codo claro: el segundo componente solo aporta el 17,2 % y su autovalor (0,69) queda por debajo de 1, es decir, guarda menos información que una sola de las variables originales. Por eso nos quedamos con PC1 como índice y usamos PC2 solo para leer el biplot.

Biplot: UPZ y variables en un mismo plano

Los puntos son las UPZ, ubicadas según su PC1 y su PC2; las flechas son las cuatro variables. Puntos cercanos son UPZ parecidas, y cada flecha apunta hacia donde crece su variable.

# Capa 1 · las UPZ. PC1 ya viene orientado por orientar_pc1(): a la derecha = más inseguridad vivida.
puntos_bi <- tibble(upz_nombre = upz_ok$upz_nombre, pc1 = pca$x[, 1], pc2 = pca$x[, 2],
                    indice = upz_ok$indice)

# UPZ para etiquetar: los extremos del índice y los extremos de PC2.
destacar_bi <- bind_rows(slice_max(puntos_bi, indice, n = 5, with_ties = FALSE),
                         slice_min(puntos_bi, indice, n = 4, with_ties = FALSE),
                         slice_max(puntos_bi, pc2, n = 2, with_ties = FALSE),
                         slice_min(puntos_bi, pc2, n = 2, with_ties = FALSE)) |>
  distinct(upz_nombre, .keep_all = TRUE)

# Capa 2 · las flechas: las cargas, alargadas para que se vean del tamaño de la nube de puntos
# (la escala no cambia la dirección, solo el largo).
cargas_pca <- as_tibble(pca$rotation[, 1:2], rownames = "variable") |>
  mutate(nombre = etiquetas_indice[variable])
escala_bi  <- 0.75 * max(abs(puntos_bi$pc1)) / max(sqrt(cargas_pca$PC1^2 + cargas_pca$PC2^2))
flechas_bi <- cargas_pca |> mutate(x = PC1 * escala_bi, y = PC2 * escala_bi)

# Etiquetas que no se enciman si ggrepel está instalado; si no, geom_text() normal.
con_repel <- requireNamespace("ggrepel", quietly = TRUE)
capa_upz_bi <- if (con_repel) {
  ggrepel::geom_text_repel(data = destacar_bi, aes(label = upz_nombre), size = 3.2,
                           color = pca_magenta, fontface = "bold", seed = 1)
} else {
  geom_text(data = destacar_bi, aes(label = upz_nombre), size = 3.2, color = pca_magenta,
            fontface = "bold", vjust = -0.9, check_overlap = TRUE)
}
capa_flechas_bi <- if (con_repel) {
  ggrepel::geom_label_repel(data = flechas_bi, aes(x = x * 1.1, y = y * 1.1, label = nombre),
                            size = 3, color = pca_turquesa, fill = "#FFFFFFDD", label.size = 0,
                            fontface = "bold", seed = 2, min.segment.length = Inf)
} else {
  geom_label(data = flechas_bi, aes(x = x * 1.1, y = y * 1.1, label = nombre), size = 3,
             color = pca_turquesa, fill = "#FFFFFFDD", label.size = 0, fontface = "bold")
}

ggplot(puntos_bi, aes(pc1, pc2)) +
  geom_hline(yintercept = 0, color = "grey75") +
  geom_vline(xintercept = 0, color = "grey75") +
  geom_point(color = pca_morado, alpha = 0.35, size = 1.8) +
  geom_point(data = destacar_bi, color = pca_magenta, size = 2.6) +
  capa_upz_bi +
  geom_segment(data = flechas_bi, aes(x = 0, y = 0, xend = x, yend = y),
               arrow = arrow(length = unit(0.22, "cm")), color = pca_turquesa, linewidth = 0.9) +
  capa_flechas_bi +
  labs(title = "A la derecha, más inseguridad vivida: las cuatro flechas apuntan hacia allá",
       subtitle = "UPZ (puntos) y variables (flechas) en el plano PC1–PC2",
       x = paste0("PC1 · inseguridad vivida en el barrio (", num(varianza_pca$pct[1], 1), " %)"),
       y = paste0("PC2 (", num(varianza_pca$pct[2], 1), " %)"),
       caption = fuente_pca) +
  tema_pca

¿Qué muestra? Las cuatro flechas apuntan hacia la derecha, que es lo que permite leer PC1 como un «más es peor»: cuanto más a la derecha está una UPZ, más inseguro es su entorno. Las UPZ del extremo derecho son las mismas que encabezan el ranking, y las del extremo izquierdo, las que lo cierran. El eje vertical es un contraste secundario (17,2 % de la varianza): separa las UPZ donde pesa más «percibe inseguridad (%)» (carga 0,79) de aquellas donde pesa más «cerca de lotes baldíos u oscuros (%)» (carga -0,59). Su signo es arbitrario y no entra en el índice.

¿Cómo se reparte el índice?

mediana_indice <- median(upz_ok$indice)

ggplot(upz_ok, aes(indice)) +
  geom_histogram(binwidth = 5, boundary = 0, fill = pca_morado, color = "white") +
  geom_vline(xintercept = mediana_indice, color = pca_magenta, linetype = "dashed", linewidth = 0.9) +
  annotate("text", x = mediana_indice, y = Inf, vjust = 1.6, hjust = -0.08, color = pca_magenta,
           fontface = "bold", size = 3.6, label = paste0("Mediana: ", num(mediana_indice, 1))) +
  scale_x_continuous(breaks = seq(0, 100, 20)) +
  labs(title = paste0("La mitad de las UPZ tiene un índice menor que ", num(mediana_indice, 1)),
       subtitle = paste0("Distribución del índice de inseguridad vivida en el barrio, ", nrow(upz_ok), " UPZ"),
       x = "Índice (0 = entorno más seguro de la muestra, 100 = más inseguro)",
       y = "N.º de UPZ", caption = fuente_pca) +
  tema_pca

¿Qué muestra? La mediana del índice es 34,9 y solo 12 de las 104 UPZ llegan a 60 o más. El índice es relativo: 100 es la UPZ con el entorno más inseguro de esta muestra (Corabastos), no un máximo absoluto, y 0 no significa ausencia de problemas. Si cambian las variables o las UPZ incluidas, cambia el índice.

2. Nuestro K-Means

¿Por qué segmentar? El índice nos dice cuánto miedo y deterioro hay, pero no qué tipo de inseguridad acompaña a cada UPZ. Dos UPZ con el mismo índice pueden vivir realidades opuestas: una con mucho hurto en la calle por la población flotante, otra residencial y tranquila. Por eso cruzamos el índice con tres tasas de denuncias, la otra lente del EDA:

  • tasa_hurto_personas: el robo en la calle, que es el 52,4 % de las denuncias y marca la centralidad.
  • tasa_homicidios: la violencia más grave, que el hurto no refleja.
  • tasa_violencia_intrafamiliar: la violencia dentro del hogar, que ocurre donde vive la gente y no donde transita.

Usamos el índice y no sus cuatro variables para que el tema del entorno no pese cuatro veces en las distancias. Las tres tasas tienen colas largas, así que se transforman con log1p().

# 1. El índice + 3 tasas de denuncias de otros temas.
vars_segmento <- c("indice", "tasa_hurto_personas", "tasa_homicidios", "tasa_violencia_intrafamiliar")

# 2. log1p() a las tasas con cola larga y estandarizar.
Z_seg <- upz_ok |>
  select(all_of(vars_segmento)) |>
  mutate(across(c(tasa_hurto_personas, tasa_homicidios, tasa_violencia_intrafamiliar), log1p)) |>
  scale()

# 3. ¿Cuántos grupos? Codo y silhouette.
tabla_k <- evaluar_k(Z_seg)

sil <- function(k) num(tabla_k$silhouette[tabla_k$k == k], 2)
K <- 4

# 4. El K-Means.
set.seed(2026)
km <- kmeans(Z_seg, centers = K, nstart = 50)
upz_ok <- upz_ok |> mutate(segmento = factor(km$cluster))
# 5. El perfil de cada grupo (en valores reales) y 6. los nombres.
#    K-Means solo entrega números del 1 al 4, y qué número le toca a cada grupo depende del azar.
#    Por eso escribimos como reglas la lectura del perfil (como en la guía 02): los nombres
#    quedan pegados al grupo correcto aunque cambie la numeración.
centros <- as_tibble(km$centers) |> mutate(cluster = row_number())
quedan  <- centros$cluster
elegir  <- function(variable, f) {          # f = which.max o which.min
  g <- quedan[f(centros[[variable]][quedan])]
  quedan <<- setdiff(quedan, g)
  g
}
nombres <- c()
nombres[elegir("tasa_violencia_intrafamiliar", which.max)] <- "Epicentro crítico"
nombres[elegir("tasa_hurto_personas", which.max)]          <- "Centralidades del hurto"
nombres[elegir("indice", which.max)]                       <- "Periferia del miedo"
nombres[quedan]                                            <- "Residencial consolidada"

# Este orden también define los colores en los mapas (morado, magenta, turquesa, amarillo).
orden_segmentos <- c("Periferia del miedo", "Epicentro crítico",
                     "Residencial consolidada", "Centralidades del hurto")
upz_ok <- upz_ok |>
  mutate(segmento_nombre = factor(nombres[km$cluster], levels = orden_segmentos))

grafico_perfil(upz_ok, vars_segmento, grupo = "segmento_nombre",
               titulo = "Cuatro inseguridades: el miedo en la periferia, el hurto en el centro")

ejemplos(upz_ok)
Segmento UPZ más pobladas
Periferia del miedo Tibabuyes, El Rincon, Bosa Central, Bosa Occidental
Epicentro crítico La Sabana, Las Cruces, Las Nieves, Puente Aranda
Residencial consolidada Suba, Garces Navas, Castilla, Timiza
Centralidades del hurto Restrepo, Los Alcazares, Chico Lago, Galerias
# Un resumen para citar los valores en el texto: v("Periferia del miedo", "n")
resumen <- upz_ok |>
  group_by(segmento_nombre) |>
  summarise(n = n(), pob = sum(poblacion) / 1e6,
            indice = median(indice), hurto = median(tasa_hurto_personas),
            homicidios = median(tasa_homicidios), vif = median(tasa_violencia_intrafamiliar),
            percepcion = median(pct_percibe_inseguridad), pobreza = median(pct_pobreza),
            .groups = "drop") |>
  mutate(segmento_nombre = as.character(segmento_nombre))
v <- function(s, col, d = 0) num(resumen[[col]][resumen$segmento_nombre == s], d)

¿Qué grupos aparecieron? El codo se aplana después de K = 4 y la silhouette es parecida entre K = 3 (0,36) y K = 4 (0,35), y baja con 5 o más grupos (0,29 con K = 5). Las métricas no deciden solas: elegimos K = 4 porque con tres grupos el pequeño núcleo del centro, donde todas las tasas son extremas, se mezcla con las centralidades comerciales, y son realidades que piden respuestas distintas. Una silhouette cercana a 0,35 indica grupos reconocibles pero con bordes difusos.

  • Periferia del miedo (40 UPZ, 4,0 millones de habitantes). El índice más alto después del epicentro (mediana 51) y la percepción de inseguridad más alta (75 %), pero con muy pocas denuncias de hurto (48 por 10.000). Lo que sí se denuncia es la violencia intrafamiliar (22 por 10.000). Es el miedo que no llega a la denuncia de la calle. Ejemplos: Tibabuyes, El Rincón, Bosa Central, Gran Yomasa, Comuneros.
  • Epicentro crítico (6 UPZ). El único grupo donde todo está alto a la vez: índice (76), hurto (852), homicidios (4,9) y violencia intrafamiliar (63). Es el centro deteriorado y comercial: La Sabana, Las Cruces, Las Nieves, Sagrado Corazón, Zona Industrial y Puente Aranda.
  • Residencial consolidada (48 UPZ, el grupo más grande). Índice bajo (25), tasas moderadas o bajas y la menor violencia intrafamiliar (12). Son barrios de clase media del norte y el occidente: Suba, Castilla, Garcés Navas, Timiza, Fontibón.
  • Centralidades del hurto (10 UPZ). Entorno percibido como seguro (índice
    1. pero con mucho hurto a personas (458 por 10.000) y la pobreza más baja. Son zonas de oficinas, universidades y comercio con mucha población flotante: Chicó Lago, Chapinero, Teusaquillo, Galerías, Los Alcázares, Restrepo.

2.1 Los gráficos que respaldan la segmentación

Los gráficos anteriores muestran el resultado. Estos cinco muestran el camino, en el mismo orden de la guía de K-Means: mirar las variables antes de agrupar, comprobar si otro método llega a los mismos grupos, ver si un mismo índice esconde realidades distintas, identificar qué separa a los segmentos y revisar qué tan bien encaja cada UPZ en el suyo.

# Etiquetas legibles, colores y funciones para los gráficos de apoyo. Usamos nombres propios
# (..._seg) para no pisar ningún objeto de herramientas.R. Reutilizamos tema_pca, skew_pca y
# los colores pca_... que definimos en la sección 1.1.
etiquetas_seg <- c(indice                       = "Índice de inseguridad vivida (0–100)",
                   tasa_hurto_personas          = "Hurto a personas ×10 mil hab.",
                   tasa_homicidios              = "Homicidios ×10 mil hab.",
                   tasa_violencia_intrafamiliar = "Violencia intrafamiliar ×10 mil hab.")
tasas_seg <- setdiff(vars_segmento, "indice")   # las tres variables que pasaron por log1p()

# Un color fijo por segmento, en el mismo orden de los mapas (morado, magenta, turquesa, amarillo).
colores_seg <- setNames(c(pca_morado, pca_magenta, pca_turquesa, "#E3A21A"), orden_segmentos)

fuente_seg <- paste0("Fuente: Encuesta Multipropósito 2021 y denuncias de enero a julio de 2023, ",
                     nrow(upz_ok), " UPZ con 5.000 habitantes o más. Cálculos propios.")

# Silhouette de cada UPZ, calculada a mano: a = distancia promedio a las UPZ de su propio grupo,
# b = distancia promedio al grupo vecino más cercano. (b − a) / max(a, b) va de −1 a 1.
silueta_upz <- function(grupos, distancias) {
  D <- as.matrix(distancias)
  sapply(seq_along(grupos), function(i) {
    propios <- setdiff(which(grupos == grupos[i]), i)
    if (length(propios) == 0) return(0)
    otros <- grupos != grupos[i]
    a <- mean(D[i, propios])
    b <- min(tapply(D[i, otros], grupos[otros], mean))
    (b - a) / max(a, b)
  })
}

# K-medoides necesita el paquete cluster; si no está instalado, esa parte se omite.
con_cluster <- requireNamespace("cluster", quietly = TRUE)

Mirar antes de agrupar: las colas largas

¿Hay variables con valores extremos que puedan dominar las distancias?

# ESTADÍSTICA: la asimetría de cada variable, antes y después de log1p().
asimetria_seg <- tibble(
  variable = vars_segmento,
  original = map_dbl(vars_segmento, ~ skew_pca(upz_ok[[.x]])),
  con_log  = map_dbl(vars_segmento, ~ if (.x %in% tasas_seg) skew_pca(log1p(upz_ok[[.x]])) else NA_real_)
)
asim_tasas <- asimetria_seg |> filter(variable %in% tasas_seg)

asimetria_seg |>
  transmute(Variable = etiquetas_seg[variable],
            `Asimetría original` = num(original, 2),
            `Asimetría con log1p()` = if_else(is.na(con_log), "—", num(con_log, 2)),
            `¿Se transformó?` = if_else(variable %in% tasas_seg, "Sí, con log1p()", "No")) |>
  kbl(align = "lrrl") |>
  kable_styling(bootstrap_options = c("hover", "condensed"), full_width = FALSE) |>
  row_spec(0, bold = TRUE, color = "white", background = pca_morado)
Variable Asimetría original Asimetría con log1p() ¿Se transformó?
Índice de inseguridad vivida (0–100) 0,51 — No
Hurto a personas ×10 mil hab. 3,15 0,75 Sí, con log1p()
Homicidios ×10 mil hab. 2,62 1,10 Sí, con log1p()
Violencia intrafamiliar ×10 mil hab. 5,56 1,34 Sí, con log1p()
# VISUAL: antes (arriba) y después (abajo) de log1p(), para las tres tasas de denuncias.
bind_rows(
  upz_ok |> select(all_of(tasas_seg)) |> mutate(escala = "1. Original"),
  upz_ok |> select(all_of(tasas_seg)) |> mutate(across(all_of(tasas_seg), log1p),
                                                 escala = "2. Con log1p()")
) |>
  pivot_longer(-escala, names_to = "variable", values_to = "valor") |>
  mutate(panel = paste0(etiquetas_seg[variable], "\n", escala)) |>
  ggplot(aes(valor, fill = escala)) +
  geom_histogram(bins = 20, color = "white", linewidth = 0.2) +
  # Un panel por variable y escala; scales = "free" deja que cada panel tenga su propio eje x.
  facet_wrap(~ panel, scales = "free", ncol = 3, dir = "v") +
  scale_fill_manual(values = c(pca_magenta, pca_turquesa), guide = "none") +
  labs(title = "El logaritmo acorta las colas largas de las tres tasas de denuncias",
       subtitle = "Distribución antes (arriba, magenta) y después (abajo, turquesa) de transformar",
       x = NULL, y = "N.º de UPZ", caption = fuente_seg) +
  tema_pca +
  theme(strip.text = element_text(face = "bold", size = 9.5))

¿Qué muestra? En su escala original, las tres tasas tienen asimetrías de 2,62 a 5,56 (3 de las tres superan el umbral de 1,5 que usamos en el PCA): la mayoría de las UPZ tiene tasas bajas y unas pocas, las del centro, tienen tasas varias veces mayores. Como K-Means mide distancias, esas pocas UPZ habrían definido los grupos por sí solas. Con log1p() las asimetrías quedan entre 0,75 y 1,34. El índice (0,51) no se transforma: ya está en una escala de 0 a 100 sin cola extrema.

¿Y si usamos K-medoides?

K-Means usa como centro el promedio del grupo, un punto que no corresponde a ninguna UPZ. Su primo, K-medoides (PAM), usa como centro una UPZ real, el medoide, y es menos sensible a los valores extremos. ¿Llega a los mismos grupos?

# pam(): K-medoides con el mismo K y los mismos datos estandarizados.
pam_seg <- cluster::pam(Z_seg, k = K)

# table(): cruza las dos clasificaciones (filas = segmento de K-Means, columnas = grupo de PAM).
cruce_pam <- table(segmento = upz_ok$segmento_nombre, pam = pam_seg$clustering)

# Coincidencia: para cada grupo de PAM, contamos las UPZ del segmento que más se repite en él.
coincidencia_pam <- sum(apply(cruce_pam, 2, max)) / nrow(upz_ok)

# Los medoides: la UPZ REAL que está en el centro de cada grupo de PAM.
medoides_seg   <- upz_ok[pam_seg$id.med, ]
n_seg_medoides <- n_distinct(medoides_seg$segmento_nombre)

tibble(`Grupo PAM` = seq_len(K),
       `Medoide (UPZ que representa al grupo)` = paste0(medoides_seg$upz_nombre, " (", medoides_seg$localidad, ")"),
       `UPZ en el grupo` = as.integer(table(pam_seg$clustering)),
       `Segmento de K-Means de esa UPZ` = as.character(medoides_seg$segmento_nombre)) |>
  kbl(align = "llrl") |>
  kable_styling(bootstrap_options = c("hover", "condensed"), full_width = FALSE) |>
  row_spec(0, bold = TRUE, color = "white", background = pca_turquesa)
Grupo PAM Medoide (UPZ que representa al grupo) UPZ en el grupo Segmento de K-Means de esa UPZ
1 Restrepo (Antonio Nariño) 16 Centralidades del hurto
2 Minuto De Dios (Engativá) 33 Residencial consolidada
3 San Jose De Bavaria (Suba) 24 Residencial consolidada
4 Ismael Perdomo (Ciudad Bolívar) 31 Periferia del miedo

¿Qué muestra? El 85 % de las UPZ queda en el grupo equivalente con los dos métodos, y los 4 medoides caen en 3 de los 4 segmentos: K-medoides no separa todos los grupos igual que K-Means, así que los segmentos que comparten medoide deben leerse como los de frontera más difusa. La tabla además deja una UPZ concreta que representa a cada grupo, útil para comunicar los resultados. Nos quedamos con K-Means porque es el método de la guía y sus centros permiten leer el perfil promedio de cada segmento.

¿Mismo índice = misma realidad?

¿Los segmentos son solo «índice alto» e «índice bajo», o se traslapan?

medianas_seg <- upz_ok |>
  group_by(segmento_nombre) |>
  summarise(indice = median(indice), .groups = "drop")

# La misma franja de índice de la tabla de la sección 3 (33 a 36,5).
banda_seg   <- upz_ok |> filter(between(indice, 33, 36.5))
n_seg_banda <- n_distinct(banda_seg$segmento_nombre)

ggplot(upz_ok, aes(indice, fct_rev(segmento_nombre), color = segmento_nombre)) +
  annotate("rect", xmin = 33, xmax = 36.5, ymin = 0.4, ymax = K + 0.6,
           fill = "grey88", color = "grey30", linetype = "dashed", linewidth = 0.3) +
  # position_jitter(): desplaza un poco cada punto en vertical para que no se tapen.
  geom_point(position = position_jitter(height = 0.2, width = 0, seed = 1), alpha = 0.45, size = 1.7) +
  geom_point(data = medianas_seg, shape = 21, fill = "white", size = 5, stroke = 1.8) +
  geom_text(data = medianas_seg, aes(label = num(indice, 0)), vjust = -1.3, fontface = "bold", size = 3.8) +
  annotate("text", x = 38, y = K + 0.5, hjust = 0, vjust = 1, size = 3.3, color = "grey20",
           fontface = "bold", label = "Mismo índice,\nsegmentos distintos") +
  scale_color_manual(values = colores_seg, guide = "none") +
  scale_x_continuous(limits = c(0, 100), breaks = seq(0, 100, 20)) +
  labs(title = "Los rangos del índice se traslapan: el número solo no separa los segmentos",
       subtitle = "Índice por segmento (círculo = mediana; cada punto, una UPZ; franja = índice entre 33 y 36,5)",
       x = "Índice de inseguridad vivida en el barrio (0–100)", y = NULL, caption = fuente_seg) +
  tema_pca +
  theme(axis.text.y = element_text(face = "bold", size = 11))

¿Qué muestra? Las medianas del índice sí ordenan los segmentos (de 25 a 76), pero las nubes de puntos se cruzan. En la franja marcada, con un índice entre 33 y 36,5, hay 18 UPZ que pertenecen a 4 segmentos distintos: son las de la tabla de la sección 3. Si los grupos fueran solo «índice alto» e «índice bajo», K-Means no habría agregado nada; lo que los separa está en las denuncias.

Lo que el índice no ve

¿Qué indicadores separan a los segmentos?

# Las tres tasas de la segmentación y la pobreza (que no entró al K-Means), en valores reales.
upz_ok |>
  group_by(segmento_nombre) |>
  summarise(`Hurto a personas ×10 mil hab. (mediana)` = median(tasa_hurto_personas),
            `Homicidios ×10 mil hab. (mediana)` = median(tasa_homicidios),
            `Violencia intrafamiliar ×10 mil hab. (mediana)` = median(tasa_violencia_intrafamiliar),
            `% de personas en pobreza (mediana)` = median(pct_pobreza),
            .groups = "drop") |>
  pivot_longer(-segmento_nombre, names_to = "indicador", values_to = "valor") |>
  mutate(indicador = fct_inorder(indicador),
         etiqueta = map_chr(valor, ~ num(.x, if (.x >= 100) 0 else 1))) |>
  ggplot(aes(valor, fct_rev(segmento_nombre), color = segmento_nombre)) +
  geom_segment(aes(x = 0, xend = valor, yend = fct_rev(segmento_nombre)), linewidth = 0.9) +
  geom_point(size = 3.2) +
  geom_text(aes(label = paste0("   ", etiqueta)), hjust = 0, size = 3.2, show.legend = FALSE) +
  facet_wrap(~ indicador, scales = "free_x", ncol = 2) +
  scale_x_continuous(expand = expansion(mult = c(0, 0.28))) +
  scale_color_manual(values = colores_seg, guide = "none") +
  labs(title = "Lo que el índice no ve: hurto, homicidios y violencia en el hogar",
       subtitle = "Valor típico (mediana) de cada segmento. La pobreza no entró en el K-Means: sirve para describir",
       x = NULL, y = NULL, caption = fuente_seg) +
  tema_pca +
  theme(strip.text = element_text(face = "bold", hjust = 0), panel.grid.major.y = element_blank())

¿Qué muestra? El hurto a personas es lo que más separa a los grupos: la mediana del segmento más alto es 18 veces la del más bajo (852 frente a 48 denuncias por 10.000 habitantes). La violencia intrafamiliar va en otra dirección: en la periferia del miedo la mediana es 22 y en la residencial consolidada, 12. La pobreza, que no se usó para agrupar, confirma la lectura del EDA: 6,7 % en la periferia del miedo frente a 0,9 % en las centralidades del hurto. Las tasas son denuncias de siete meses (enero a julio de 2023) por residente, no delitos ocurridos.

¿Qué tan bien encaja cada UPZ? El gráfico de silhouette

# Un valor de silhouette por UPZ, ordenado dentro de cada segmento.
sil_df <- tibble(upz_nombre = upz_ok$upz_nombre,
                 segmento   = upz_ok$segmento_nombre,
                 ancho      = silueta_upz(km$cluster, dist(Z_seg))) |>
  arrange(segmento, desc(ancho)) |>
  mutate(posicion = row_number())

sil_media   <- mean(sil_df$ancho)
sil_por_seg <- sil_df |> group_by(segmento) |> summarise(media = mean(ancho), .groups = "drop")
frontera    <- sil_df |> slice_min(ancho, n = 3, with_ties = FALSE)   # las 3 UPZ que peor encajan

ggplot(sil_df, aes(posicion, ancho, fill = segmento)) +
  geom_col(width = 1) +
  geom_hline(yintercept = sil_media, linetype = "dashed", color = "grey25") +
  scale_fill_manual(values = colores_seg, name = NULL) +
  labs(title = "La mayoría de las UPZ encaja en su segmento, pero hay UPZ «de frontera»",
       subtitle = paste0("Silhouette de cada UPZ (barras bajo cero = más cerca de otro segmento) · promedio = ",
                         num(sil_media, 2)),
       x = "UPZ, ordenadas dentro de cada segmento", y = "Silhouette", caption = fuente_seg) +
  tema_pca +
  theme(axis.text.x = element_blank(), panel.grid.major.x = element_blank(), legend.position = "bottom")

¿Qué muestra? La silhouette promedio es 0,35 y 3 de las 104 UPZ (3 %) quedan bajo cero, es decir, más cerca de otro segmento que del suyo. El segmento más compacto es «Residencial consolidada» (promedio 0,39) y el de bordes más difusos, «Epicentro crítico» (0,14). Las UPZ que peor encajan son Puente Aranda (Epicentro crítico, -0,15); Las Nieves (Epicentro crítico, -0,11); Apogeo (Periferia del miedo, -0,02): son UPZ de transición entre dos tipos. Que existan es normal: los segmentos son tipos ideales, no cajas cerradas, y por eso una UPZ cercana al borde no debería tratarse como un caso típico de su grupo.

3. Nuestros mapas

¿Dónde pasa lo que encontramos? Si el índice sigue a la pobreza, esperamos verlo alto en el sur y el suroriente (Usme, Ciudad Bolívar, San Cristóbal, Bosa) y bajo en el norte y el oriente (Chapinero, Usaquén, Teusaquillo). Los segmentos deberían separar ese sur de un centro expandido donde se concentra el hurto.

mapa_continuo(upz_ok, "indice",
              titulo = "El barrio más inseguro está en el sur y en abastos",
              subtitulo = "Índice de inseguridad vivida en el barrio (0–100)")

¿Qué patrón muestra? El mapa dibuja un gradiente norte–sur. Los valores más altos forman un arco en el sur y el suroriente: Usme (mediana por localidad cercana a 63), San Cristóbal y Ciudad Bolívar (alrededor de 50), con focos aislados en Corabastos y en La Sabana, en el centro. Los más bajos se concentran en el norte y el borde oriental: Teusaquillo, Suba (Niza, La Floresta, Casa Blanca), Usaquén y Chapinero. El occidente (Engativá, Fontibón, Kennedy) queda en valores intermedios.

mapa_categorico(upz_ok, "segmento_nombre",
                titulo = "El miedo rodea la ciudad; el hurto se concentra en el centro",
                subtitulo = "Segmentos de UPZ según inseguridad vivida y tasas de denuncia")

# Versión interactiva: zoom y clic sobre cada UPZ.
mapa_interactivo(upz_ok, "segmento_nombre")
# ¿Hay UPZ con un índice parecido que quedaron en segmentos distintos?
upz_ok |>
  filter(between(indice, 33, 36.5)) |>
  arrange(indice) |>
  transmute(UPZ = upz_nombre, Localidad = localidad, `Índice` = num(indice, 1),
            `Hurto a personas ×10 mil` = num(tasa_hurto_personas, 0), Segmento = segmento_nombre) |>
  kbl(align = "llrrl") |>
  kable_styling(bootstrap_options = c("hover", "condensed"), full_width = FALSE) |>
  row_spec(0, bold = TRUE, color = "white", background = morado)
UPZ Localidad Índice Hurto a personas ×10 mil Segmento
Toberin Usaquén 33,5 79 Residencial consolidada
San Jose Rafael Uribe Uribe 33,8 109 Residencial consolidada
La Flora Usme 33,9 29 Periferia del miedo
Sosiego San Cristóbal 34,1 92 Periferia del miedo
Suba Suba 34,1 70 Residencial consolidada
Sagrado Corazon Santa Fe 34,1 851 Epicentro crítico
Las Nieves Santa Fe 34,1 1.488 Epicentro crítico
Gran Britalia Kennedy 34,2 86 Periferia del miedo
Los Alcazares Barrios Unidos 34,2 467 Centralidades del hurto
San Cristobal Norte Usaquén 34,3 48 Periferia del miedo
La Macarena Santa Fe 35,5 250 Centralidades del hurto
Castilla Kennedy 35,5 99 Residencial consolidada
Bavaria Kennedy 35,5 149 Residencial consolidada
Minuto De Dios Engativá 35,6 101 Residencial consolidada
San Blas San Cristóbal 35,8 32 Periferia del miedo
Verbenal Usaquén 36,1 29 Periferia del miedo
Garces Navas Engativá 36,1 79 Residencial consolidada
Marco Fidel Suarez Rafael Uribe Uribe 36,3 81 Periferia del miedo

¿Dónde está cada segmento? La periferia del miedo forma un anillo en el borde sur y occidental: Usme, Ciudad Bolívar, San Cristóbal, Bosa y el sur de Kennedy, más Tibabuyes y El Rincón en Suba. El epicentro crítico es una mancha compacta en el centro (Santa Fe, Los Mártires, Puente Aranda) y las centralidades del hurto lo rodean hacia el norte por Chapinero, Teusaquillo y Barrios Unidos. La residencial consolidada ocupa el resto del norte y el occidente.

La tabla muestra lo que el índice solo no mostraba. Con un índice casi idéntico (entre 33 y 36), Las Nieves y Sagrado Corazón están en el epicentro, Los Alcázares y La Macarena son centralidades del hurto, Suba y Castilla son residenciales y San Cristóbal Norte o San Blas pertenecen a la periferia del miedo. Sus tasas de hurto van de unas 30 a casi 1.500 denuncias por 10.000 habitantes. El mismo número describe inseguridades muy distintas.

3.1 Los mapas que respaldan el territorio

Los mapas anteriores muestran el resultado. Estos cinco apartados muestran el camino, en el mismo orden de la guía de mapas: ver cada segmento por separado, comprobar si las localidades son homogéneas, explorar UPZ por UPZ, desconfiar del área y buscar parecidos entre zonas lejanas.

# Objetos de apoyo para los mapas. Usamos nombres propios (..._map) para no pisar ningún objeto
# de herramientas.R, y reutilizamos tema_pca, los colores pca_..., colores_seg y fuente_seg.

# 1. La llave de unión: el código de la UPZ, reducido a su número ("UPZ85", "085" y 85 -> "85").
llave_map <- function(x) {
  d <- str_extract(as.character(x), "[0-9]+")
  ifelse(is.na(d), NA_character_, as.character(as.integer(d)))
}
# Busca la columna del código de UPZ sin depender de cómo se llame en cada tabla.
columna_llave_map <- function(tabla) {
  nombres  <- setdiff(names(tabla), attr(tabla, "sf_column"))
  normal   <- gsub("[^A-Z0-9]", "", toupper(nombres))
  posibles <- c("UPZCOD", "CODUPZ", "CODIGOUPZ", "UPZCODIGO", "UPLCODIGO", "CODIGOUPL", "CODUPZ1", "UPZ")
  hallada  <- posibles[posibles %in% normal][1]
  if (is.na(hallada)) stop("No encuentro la columna con el código de la UPZ en una de las tablas.")
  nombres[match(hallada, normal)]
}
llave_shp_map <- llave_map(upz_shp[[columna_llave_map(upz_shp)]])
llave_ok_map  <- llave_map(upz_ok[[columna_llave_map(upz_ok)]])
llave_upz_map <- llave_map(upz[[columna_llave_map(upz)]])

# 2. La unión se hace DESDE la geometría: se conservan TODOS los polígonos, aunque no tengan datos.
#    Así las UPZ que no entran al análisis aparecen en el mapa (en gris) en vez de desaparecer.
geometria_map <- sf::st_geometry(upz_shp)
if (is.na(sf::st_crs(geometria_map))) geometria_map <- sf::st_set_crs(geometria_map, 4326)
geometria_map <- sf::st_transform(sf::st_make_valid(geometria_map), 4326)   # grados: lo que espera leaflet

fila_ok_map <- match(llave_shp_map, llave_ok_map)
mapa_upz <- sf::st_sf(upz_ok[fila_ok_map, ], geometry = geometria_map)
mapa_upz$con_datos  <- !is.na(fila_ok_map)
# Nombre de cada polígono, también para las UPZ que quedaron por fuera de upz_ok.
mapa_upz$nombre_map <- upz$upz_nombre[match(llave_shp_map, llave_upz_map)]
mapa_upz$nombre_map <- ifelse(is.na(mapa_upz$nombre_map), "UPZ sin datos", mapa_upz$nombre_map)

# 3. Para medir áreas y distancias pasamos a un sistema en metros (EPSG:3116, MAGNA-SIRGAS Bogotá).
geometria_m_map <- sf::st_transform(geometria_map, 3116)
puntos_m_map    <- sf::st_point_on_surface(geometria_m_map)      # un punto dentro de cada UPZ
mapa_upz$area_km2 <- as.numeric(sf::st_area(geometria_m_map)) / 1e6

# Distancia de cada UPZ a la Plaza de Bolívar, el mismo punto de referencia que usamos en el EDA.
centro_map <- sf::st_transform(sf::st_sfc(sf::st_point(c(-74.0760, 4.5981)), crs = 4326), 3116)
mapa_upz$dist_centro_km <- as.numeric(sf::st_distance(puntos_m_map, centro_map)) / 1000

# 4. Estilo de los mapas: sin ejes ni cuadrícula (la latitud y la longitud no aportan al mensaje).
gris_map  <- "#D9D6DD"
tema_mapa_apoyo <- tema_pca +
  theme(axis.text = element_blank(), axis.title = element_blank(), panel.grid = element_blank())

# Encuadre: el borde de las UPZ. Sin él, la localidad rural de Sumapaz obligaría a encoger la ciudad.
caja_map     <- sf::st_bbox(mapa_upz)
encuadre_map <- coord_sf(xlim = c(caja_map[["xmin"]], caja_map[["xmax"]]),
                         ylim = c(caja_map[["ymin"]], caja_map[["ymax"]]))

# El mapa interactivo de dos capas necesita estos tres paquetes; si falta alguno, se omite.
con_leaflet <- all(sapply(c("leaflet", "htmltools", "htmlwidgets"), requireNamespace, quietly = TRUE))

Un mapa por segmento

Un mapa con cuatro colores es difícil de leer de una vez. ¿Dónde está cada segmento cuando se mira solo?

# "Pequeños múltiplos": un panel por segmento. La capa gris de fondo NO tiene la columna
# segmento_nombre, así que facet_wrap() la repite en todos los paneles; la de color sí se reparte.
fondo_map     <- sf::st_sf(geometry = sf::st_geometry(mapa_upz))
con_datos_map <- mapa_upz[mapa_upz$con_datos, ]

ggplot() +
  geom_sf(data = fondo_map, fill = "#EEECF1", color = "white", linewidth = 0.1) +
  geom_sf(data = con_datos_map, aes(fill = segmento_nombre), color = "white", linewidth = 0.1) +
  geom_sf(data = loc_shp, fill = NA, color = "grey45", linewidth = 0.25) +
  facet_wrap(~ segmento_nombre, ncol = 4) +
  scale_fill_manual(values = colores_seg, guide = "none") +
  encuadre_map +
  labs(title = "Dónde está cada inseguridad",
       subtitle = "UPZ de cada segmento (en color) sobre el resto de la ciudad (gris). Líneas: localidades",
       caption = fuente_seg) +
  tema_mapa_apoyo +
  theme(strip.text = element_text(face = "bold", size = 10.5))

¿Qué muestra? Cada segmento tiene su territorio, y la distancia al centro histórico ayuda a entender por qué es diferente. La UPZ típica del epicentro crítico está a 2,1 km de la Plaza de Bolívar; la de las centralidades del hurto, a 5,0 km; la de la residencial consolidada, a 10,0 km, y la de la periferia del miedo, a 10,0 km (medianas). Las dos lentes tampoco dependen igual de esa distancia: la correlación de Spearman entre la distancia al centro y la tasa de hurto a personas es de -0,44, y con el índice de inseguridad vivida, de -0,09. Es el mismo contraste del EDA, ahora medido sobre el mapa.

Una localidad, varias inseguridades

¿Las localidades son homogéneas?

# Proporción de UPZ de cada segmento dentro de cada localidad.
composicion_loc <- upz_ok |>
  count(localidad, segmento_nombre) |>
  group_by(localidad) |>
  mutate(pct = n / sum(n), n_seg = n_distinct(segmento_nombre), n_upz = sum(n)) |>
  ungroup() |>
  mutate(etiqueta = paste0(localidad, " (", n_upz, " UPZ)"))

varios_loc <- composicion_loc |> distinct(localidad, n_seg, n_upz)
n_mezcla_loc <- sum(varios_loc$n_seg >= 2)

# Orden: de menor a mayor proporción de UPZ en la periferia del miedo.
orden_loc <- composicion_loc |>
  group_by(etiqueta) |>
  summarise(periferia = sum(pct[segmento_nombre == "Periferia del miedo"]),
            central   = sum(pct[segmento_nombre == "Centralidades del hurto"]), .groups = "drop") |>
  arrange(periferia, -central) |>
  pull(etiqueta)

ggplot(composicion_loc, aes(pct, factor(etiqueta, levels = orden_loc), fill = segmento_nombre)) +
  # position = "fill" no hace falta porque pct ya suma 1 en cada localidad.
  geom_col(width = 0.8, color = "white", linewidth = 0.2) +
  scale_fill_manual(values = colores_seg, name = NULL) +
  scale_x_continuous(labels = label_percent(), expand = c(0, 0)) +
  labs(title = paste0(n_mezcla_loc, " de las ", nrow(varios_loc), " localidades mezclan dos o más segmentos"),
       subtitle = "Composición de las UPZ de cada localidad según su segmento",
       x = "% de las UPZ de la localidad", y = NULL, caption = fuente_seg) +
  tema_pca +
  theme(legend.position = "top", legend.text = element_text(size = 9.5),
        panel.grid.major.y = element_blank()) +
  guides(fill = guide_legend(nrow = 2))

¿Qué muestra? 13 de las 19 localidades tienen UPZ de dos o más segmentos, y las más mezcladas (Chapinero, Engativá, Santa Fe, Suba) reúnen 3 de los 4. Solo Bosa, Ciudad Bolívar, San Cristóbal, Tunjuelito, Usme tiene(n) todas sus UPZ en un mismo segmento. Por eso una política de seguridad diseñada «por localidad» se queda corta: dentro de una misma alcaldía local conviven barrios donde el problema es el hurto en la calle y barrios donde el problema es el entorno. La unidad útil para decidir es la UPZ y su segmento. (Entre paréntesis va el número de UPZ: en las localidades con pocas UPZ, cada una pesa mucho en el porcentaje.)

Mapa interactivo de dos capas

Los mapas estáticos sirven para contar una historia; el interactivo, para explorar la propia. El mapa de arriba muestra los segmentos. Este agrega una segunda capa con el índice (selector arriba a la derecha) y una ficha por UPZ con las dos lentes: lo que se vive en el barrio y lo que se denuncia.

# Paletas de leaflet: funciones que convierten un valor en un color.
# colorNumeric() para el índice (secuencial) y colorFactor() para los segmentos (categórica).
pal_indice_map <- leaflet::colorNumeric(c("#F7F1F9", "#C9B3D8", "#8A5FA8", pca_morado),
                                        domain = c(0, 100), na.color = gris_map)
pal_seg_map <- leaflet::colorFactor(unname(colores_seg), levels = orden_segmentos, na.color = gris_map)

# Texto de la ventana emergente (popup) de cada UPZ, en HTML.
mapa_upz$ficha <- ifelse(
  !mapa_upz$con_datos,
  paste0("<b>", htmltools::htmlEscape(mapa_upz$nombre_map), "</b><br>",
         "Sin datos: no entra en el análisis (menos de 5.000 habitantes)"),
  paste0("<b>", htmltools::htmlEscape(mapa_upz$nombre_map), "</b> (",
         htmltools::htmlEscape(mapa_upz$localidad), ")<br>",
         "Segmento: <b>", mapa_upz$segmento_nombre, "</b><br>",
         "Índice de inseguridad vivida: <b>", num(mapa_upz$indice, 1), "</b><br>",
         "Percibe inseguridad: ", num(mapa_upz$pct_percibe_inseguridad, 1), " %<br>",
         "Pobreza: ", num(mapa_upz$pct_pobreza, 1), " %<br>",
         "Hurto a personas ×10 mil: ", num(mapa_upz$tasa_hurto_personas, 0), "<br>",
         "Homicidios ×10 mil: ", num(mapa_upz$tasa_homicidios, 1), "<br>",
         "Violencia intrafamiliar ×10 mil: ", num(mapa_upz$tasa_violencia_intrafamiliar, 0), "<br>",
         "Población: ", num(mapa_upz$poblacion))
)

capa_indice_map <- "Índice de inseguridad vivida"
capa_seg_map    <- "Segmentos (K-Means)"
resalte_map     <- leaflet::highlightOptions(weight = 2, color = "#222222", bringToFront = TRUE)

leaflet_dos_capas <- leaflet::leaflet(mapa_upz, options = leaflet::leafletOptions(minZoom = 10)) |>
  # addProviderTiles(): el mapa base. Usamos el gris de Esri, que abre sin clave desde el disco.
  leaflet::addProviderTiles(leaflet::providers$Esri.WorldGrayCanvas) |>
  # addPolygons(): un polígono por UPZ. group = nombre de la capa (para el control de capas).
  leaflet::addPolygons(group = capa_indice_map,
                       fillColor = ~ pal_indice_map(indice), fillOpacity = 0.85, color = "white", weight = 0.6,
                       popup = ~ ficha, label = ~ nombre_map, highlightOptions = resalte_map) |>
  leaflet::addPolygons(group = capa_seg_map,
                       fillColor = ~ pal_seg_map(segmento_nombre), fillOpacity = 0.85, color = "white",
                       weight = 0.6, popup = ~ ficha, label = ~ nombre_map, highlightOptions = resalte_map) |>
  # addLegend(): una leyenda por capa.
  leaflet::addLegend(pal = pal_indice_map, values = c(0, 100), title = "Índice (0–100)",
                     position = "bottomleft", group = capa_indice_map) |>
  leaflet::addLegend(pal = pal_seg_map, values = factor(orden_segmentos, levels = orden_segmentos),
                     title = "Segmento", position = "bottomleft", group = capa_seg_map) |>
  # addLayersControl(): el selector de capas. baseGroups = solo una visible a la vez.
  leaflet::addLayersControl(baseGroups = c(capa_indice_map, capa_seg_map),
                            options = leaflet::layersControlOptions(collapsed = FALSE)) |>
  # onRender(): leaflet no oculta solas las leyendas de las capas base, así que con un poco de
  # JavaScript mostramos solo la leyenda de la capa que esté activa.
  htmlwidgets::onRender("function(el, x) {
    var map = this;
    function mostrar(nombre) {
      el.querySelectorAll('.info.legend').forEach(function(l) {
        var esIndice = l.textContent.indexOf('Índice') >= 0;
        l.style.display = (nombre.indexOf('Índice') === 0) === esIndice ? '' : 'none';
      });
    }
    mostrar('Índice');
    map.on('baselayerchange', function(e) { mostrar(e.name); });
  }")

leaflet_dos_capas

¿Cómo leerlo? Al cambiar de capa se ve el hallazgo central del taller: zonas del mismo tono morado en el índice quedan en colores distintos en los segmentos. Las UPZ en gris no entran al análisis.

La trampa del área: UPZ grandes no son más gente

Un mapa coroplético engaña de una forma sutil: el ojo pesa el área, no la población.

peso_seg <- sf::st_drop_geometry(con_datos_map) |>
  group_by(segmento_nombre) |>
  summarise(`% del territorio` = sum(area_km2),
            `% de las UPZ` = n(),
            `% de la población` = sum(poblacion), .groups = "drop") |>
  # cada columna se convierte en % de su total: así comparamos peso en área, UPZ y gente
  mutate(across(-segmento_nombre, ~ 100 * .x / sum(.x)))

mayor_area_seg <- peso_seg |> slice_max(`% del territorio`, n = 1, with_ties = FALSE)

peso_seg |>
  pivot_longer(-segmento_nombre, names_to = "medida", values_to = "pct") |>
  mutate(medida = factor(medida, levels = c("% del territorio", "% de las UPZ", "% de la población"))) |>
  ggplot(aes(pct, fct_rev(segmento_nombre), fill = segmento_nombre)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = paste0(num(pct, 0), " %")), hjust = -0.15, size = 3.3) +
  facet_wrap(~ medida) +
  scale_fill_manual(values = colores_seg, guide = "none") +
  scale_x_continuous(expand = expansion(mult = c(0, 0.25)), labels = function(x) paste0(x, " %")) +
  labs(title = paste0("«", mayor_area_seg$segmento_nombre, "» ocupa el ",
                      num(mayor_area_seg$`% del territorio`, 0), " % del mapa y reúne el ",
                      num(mayor_area_seg$`% de la población`, 0), " % de la población"),
       subtitle = "Peso de cada segmento según territorio, número de UPZ y población residente",
       x = NULL, y = NULL, caption = fuente_seg) +
  tema_pca +
  theme(strip.text = element_text(face = "bold", size = 11), axis.text.y = element_text(face = "bold"),
        panel.grid.major.y = element_blank())

¿Qué muestra? El epicentro crítico cubre el 4 % del territorio analizado y allí vive el 1 % de la población; la periferia del miedo ocupa el 42 % del mapa y concentra el 51 % de la gente. Las centralidades del hurto pesan 8 % en área y 4 % en residentes, pero esa cifra se queda corta: el denominador cuenta a quienes viven allí, no a quienes llegan cada día a trabajar, estudiar o comprar. Al leer el mapa de segmentos hay que recordar que el color grande no es gente: es territorio.

Una similitud inesperada: lejos en el mapa, cerca en los datos

¿Qué tan separadas pueden estar dos UPZ del mismo segmento?

# Para cada segmento, el par de UPZ más alejadas entre sí (distancia en línea recta entre sus puntos interiores).
puntos_ok_map <- puntos_m_map[mapa_upz$con_datos]
D_map <- matrix(as.numeric(sf::st_distance(puntos_ok_map)), nrow = length(puntos_ok_map)) / 1000
datos_ok_map <- sf::st_drop_geometry(con_datos_map)

lejanas_map <- map_dfr(orden_segmentos, function(s) {
  idx <- which(datos_ok_map$segmento_nombre == s)
  if (length(idx) < 2) return(NULL)
  sub <- D_map[idx, idx]
  par <- which(sub == max(sub), arr.ind = TRUE)[1, ]
  a <- idx[par[1]]; b <- idx[par[2]]
  tibble(segmento = s,
         upz_a = paste0(datos_ok_map$upz_nombre[a], " (", datos_ok_map$localidad[a], ")"),
         upz_b = paste0(datos_ok_map$upz_nombre[b], " (", datos_ok_map$localidad[b], ")"),
         indice_a = datos_ok_map$indice[a], indice_b = datos_ok_map$indice[b],
         km = max(sub))
})

lejanas_map |>
  transmute(Segmento = segmento, `UPZ A` = upz_a, `UPZ B` = upz_b,
            `Índice A` = num(indice_a, 1), `Índice B` = num(indice_b, 1),
            `Distancia (km)` = num(km, 1)) |>
  kbl(align = "lllrrr") |>
  kable_styling(bootstrap_options = c("hover", "condensed"), full_width = FALSE) |>
  row_spec(0, bold = TRUE, color = "white", background = pca_morado) |>
  column_spec(1, bold = TRUE, color = "white", background = unname(colores_seg[lejanas_map$segmento]))
Segmento UPZ A UPZ B Índice A Índice B Distancia (km)
Periferia del miedo Ciudad Usme (Usme) Verbenal (Usaquén) 59,0 36,1 32,0
Epicentro crítico Puente Aranda (Puente Aranda) Las Cruces (Santa Fe) 75,8 81,4 6,0
Residencial consolidada San Jose De Bavaria (Suba) San Jose (Rafael Uribe Uribe) 16,9 33,8 21,6
Centralidades del hurto La Academia (Suba) Restrepo (Antonio Nariño) 16,9 31,4 23,0

¿Qué muestra? El par más alejado es el de «Periferia del miedo»: Ciudad Usme (Usme) y Verbenal (Usaquén) están a 32,0 km en línea recta y, aun así, el algoritmo las pone en el mismo segmento. K-Means no conoce el mapa: agrupa por el índice y las tres tasas, no por la ubicación. Que UPZ de extremos opuestos de la ciudad terminen juntas indica que se parecen más entre ellas que a sus propias vecinas, y que los segmentos no son un efecto de la cercanía. Es el complemento de la tabla anterior: allá, UPZ con el mismo índice quedaban en segmentos distintos; aquí, UPZ muy lejanas quedan en el mismo.

4. Conclusiones

Volvamos a la pregunta del inicio. La inseguridad en Bogotá sí cambia según la lente con la que se mire, y este taller permite decir cómo. La inseguridad que se vive en el barrio se puede resumir en un solo número, pero ese número cuenta la mitad de la historia: al cruzarlo con las denuncias aparecen cuatro tipos de UPZ, cada uno con un problema distinto y con un territorio propio.

Tres hallazgos

  1. La inseguridad vivida es, sobre todo, deterioro del entorno. Las cuatro variables del índice se mueven juntas (KMO = 0,74) y un solo componente guarda el 70,1 % de su información; el segundo aporta apenas el 17,2 % y su autovalor queda por debajo de 1. Dentro de ese índice pesan más la cercanía a ollas (30,7 %) y a basureros (27,5 %) que la percepción misma (18,9 %). Sobre el mapa dibuja un gradiente norte–sur: alto en Usme, Ciudad Bolívar y San Cristóbal, con focos en Corabastos y La Sabana, y bajo en Usaquén, Chapinero y Teusaquillo (gráfico de cargas, scree plot y mapa del índice).
  2. El miedo y las denuncias de hurto están en lugares distintos. En la periferia del miedo viven 4,0 millones de personas, el 51 % de la población analizada. Allí la percepción de inseguridad es la más alta (75 %) y el hurto a personas casi no se denuncia (48 por 10.000 habitantes); lo que sí llega a la denuncia es la violencia intrafamiliar (22 por 10.000, frente a 12 en la residencial consolidada). En las centralidades del hurto pasa lo contrario: el entorno se percibe como seguro (índice 26) y el hurto llega a 458 por 10.000. El hurto a personas es la variable que más separa a los grupos: la mediana del segmento más alto es 18 veces la del más bajo. La distancia lo confirma: la UPZ típica del epicentro crítico está a 2,1 km de la Plaza de Bolívar y la de la periferia del miedo, a 10,0 km (perfil de segmentos, gráfico de contrastes y mapas por segmento).
  3. Ni el índice ni la localidad bastan para decidir: la unidad útil es la UPZ con su segmento. Con un índice casi idéntico (entre 33 y 36,5) hay 18 UPZ repartidas en 4 segmentos, con tasas de hurto que van de unas 30 a casi 1.500 denuncias por 10.000 habitantes. Tampoco sirve agregar por localidad: 13 de las 19 mezclan dos o más segmentos. Y la cercanía no explica los grupos, porque UPZ separadas por 32,0 km quedan en el mismo segmento: se parecen más entre ellas que a sus vecinas (tabla de la sección 3, composición por localidad y tabla de UPZ lejanas).

Una cautela sobre los grupos. Los cuatro segmentos son tipos reconocibles, no cajas cerradas. La silhouette promedio es 0,35 y 3 de las 104 UPZ quedan más cerca de otro segmento que del suyo, entre ellas Puente Aranda, Las Nieves, Apogeo. Aun así, la estructura no depende del método: K-medoides deja al 85 % de las UPZ en el grupo equivalente. Una UPZ de frontera debe revisarse caso por caso antes de tratarla como típica de su grupo.

Una limitación. Las dos lentes no se midieron igual ni al mismo tiempo. El índice viene de la Encuesta Multipropósito de 2021, que en 32 UPZ se reporta agrupada con zonas vecinas; por eso varias UPZ comparten el mismo valor (por ejemplo, Las Nieves y Sagrado Corazón) y no son mediciones independientes. Las tasas son denuncias de enero a julio de 2023, no delitos ocurridos, y se calculan por residente. Eso sesga la comparación en dos direcciones: el subregistro puede ser mayor en la periferia, de modo que la «periferia del miedo» podría tener más hurto del que muestran los datos, y en las centralidades la tasa se infla porque el denominador no cuenta a quienes llegan cada día a trabajar, estudiar o comprar. Además, el índice es relativo a estas 104 UPZ: 100 es la más insegura de la muestra, no un máximo absoluto. Lo que mostramos son asociaciones entre territorio, entorno y denuncias, no causas.