Tutorial: lectura, unión y EDA básico del cuaderno penitenciario

Trabajo paso a paso con tidyverse y lectura archivo por archivo

1 Propósito del tutorial

Este documento está pensado como material de clase. La idea no es construir la solución más corta ni la más automatizada, sino hacer visible cada decisión analítica.

Trabajaremos con las tablas del Cuaderno Mensual Estadístico Penitenciario (enero de 2026) que contienen la variable entidad_centro, y lo haremos con cuatro reglas pedagógicas:

  1. Usaremos únicamente tidyverse.
  2. Leeremos los archivos uno por uno.
  3. Uniremos las tablas paso a paso, de forma explícita.
  4. Cerraremos con un EDA breve para empezar a formular preguntas sustantivas.
Idea central

En un proyecto real, después de entender bien el flujo, probablemente automatizaríamos varias partes. En este tutorial haremos lo contrario: repetiremos tareas a propósito para que cada paso sea visible y discutible.

2 Preparación del entorno

Lo primero es cargar tidyverse. En este curso lo usaremos como ecosistema principal para leer, transformar, unir, resumir y graficar datos.

library(tidyverse)

También definimos la carpeta donde están los CSV descargados. Como este .qmd vive dentro de reports/, para llegar a data/raw/ debemos subir un nivel y luego entrar a la carpeta de datos.

ruta_datos <- "../data/raw"
ruta_salida <- "../data/processed"
Sobre las rutas

Una de las fuentes más comunes de error al iniciar en R es usar una ruta equivocada. Aquí usamos rutas relativas, no absolutas. Eso hace que el proyecto sea más portable: otra persona puede abrir la carpeta en otra computadora y el documento seguirá funcionando.

3 ¿Qué archivos vamos a usar?

En la parte previa del proyecto se excluyeron varios archivos de centros federales específicos. Aquí trabajaremos solo con las tablas que sí quedaron en data/raw/ y que, además, contienen entidad_centro.

Por claridad, dejamos un listado manual de los archivos que leeremos en este tutorial:

archivos_tutorial <- c(
  "disc_fue_sjur_sexo_ent_ene26.csv",
  "extranj_fuer_sjur_ent_ene26.csv",
  "extranj_idioma_ent_ene26.csv",
  "extranj_origen_ent_ene26.csv",
  "fue_sjur_sex_ent.csv",
  "gpo_edad_ent_ene26.csv",
  "ind_fue_sjur_sexo_ent_ene26.csv",
  "ind_getn_ent_ene26.csv",
  "ind_leng_ent_ene26.csv",
  "ing_eg_sex_ent_ene26.csv",
  "lgbtttiqm_ent_ene26.csv",
  "lgbtttiqm_fue_sjur_ent_ene26.csv",
  "may_fue_sjur_sexo_ent_ene26.csv",
  "may_gedad_ent_ene26.csv",
  "mujeres_hijos_fuer_sjur_ent_ene26.csv",
  "mujeres_hijos_gedad_ent_fijo_ene26.csv",
  "niv_esc_ent_ene26.csv",
  "pmimp_fue_sjur_sexo_ent_ene26.csv",
  "sobrepob_ent_ene26.csv"
)

tibble(archivo = archivos_tutorial)
Decisión metodológica

No estamos usando todos los CSV del cuaderno. Estamos usando únicamente los que comparten una unidad territorial identificable por entidad_centro. Esa decisión reduce ruido y hace más razonable la unión entre tablas.

4 Paso 1. Leer cada archivo, uno por uno

Vamos a leer los CSV explícitamente, uno por uno. Esta es una excelente práctica para el aula porque obliga a que el alumnado:

  • vea el nombre de cada archivo;
  • piense qué representa cada tabla;
  • note cuándo varias tablas comparten llaves;
  • observe que no todos los temas del cuaderno miden exactamente lo mismo.

En todos los casos fijaremos dos columnas clave:

  • entidad_centro como texto;
  • fecha como fecha.

Eso evita problemas posteriores al unir tablas.

disc_fue_sjur <- read_csv(
  file.path(ruta_datos, "disc_fue_sjur_sexo_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

extranj_fuer_sjur <- read_csv(
  file.path(ruta_datos, "extranj_fuer_sjur_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

extranj_idioma <- read_csv(
  file.path(ruta_datos, "extranj_idioma_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

extranj_origen <- read_csv(
  file.path(ruta_datos, "extranj_origen_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

fue_sjur <- read_csv(
  file.path(ruta_datos, "fue_sjur_sex_ent.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

gpo_edad <- read_csv(
  file.path(ruta_datos, "gpo_edad_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

ind_fue_sjur <- read_csv(
  file.path(ruta_datos, "ind_fue_sjur_sexo_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

ind_getn <- read_csv(
  file.path(ruta_datos, "ind_getn_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

ind_leng <- read_csv(
  file.path(ruta_datos, "ind_leng_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

ing_eg <- read_csv(
  file.path(ruta_datos, "ing_eg_sex_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

lgbtttiqm <- read_csv(
  file.path(ruta_datos, "lgbtttiqm_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

lgbtttiqm_fue_sjur <- read_csv(
  file.path(ruta_datos, "lgbtttiqm_fue_sjur_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

may_fue_sjur <- read_csv(
  file.path(ruta_datos, "may_fue_sjur_sexo_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

may_gedad <- read_csv(
  file.path(ruta_datos, "may_gedad_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

mujeres_hijos_fuer_sjur <- read_csv(
  file.path(ruta_datos, "mujeres_hijos_fuer_sjur_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

mujeres_hijos_gedad <- read_csv(
  file.path(ruta_datos, "mujeres_hijos_gedad_ent_fijo_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

niv_esc <- read_csv(
  file.path(ruta_datos, "niv_esc_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

pmimp_fue_sjur <- read_csv(
  file.path(ruta_datos, "pmimp_fue_sjur_sexo_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)

sobrepob <- read_csv(
  file.path(ruta_datos, "sobrepob_ent_ene26.csv"),
  col_types = cols(
    entidad_centro = col_character(),
    fecha = col_date()
  ),
  show_col_types = FALSE
)
¿Por qué esta parte se ve repetitiva?

Porque la repetición aquí enseña. En una primera etapa de aprendizaje conviene que el alumnado vea con claridad qué objeto corresponde a qué archivo. Más adelante sí conviene automatizar.

5 Paso 2. Inspeccionar las tablas

Antes de unir datos, siempre conviene inspeccionarlos. No queremos asumir que todas las tablas tienen la misma estructura solo porque vienen del mismo portal.

5.1 2.1 Ver una muestra de algunas tablas

fue_sjur |> slice_head(n = 5)
sobrepob |> slice_head(n = 5)
gpo_edad |> slice_head(n = 5)

5.2 2.2 Construir un inventario básico

Ahora construiremos una tabla-resumen que nos diga cuántas filas y columnas tiene cada objeto leído.

tablas_crudas <- list(
  disc_fue_sjur = disc_fue_sjur,
  extranj_fuer_sjur = extranj_fuer_sjur,
  extranj_idioma = extranj_idioma,
  extranj_origen = extranj_origen,
  fue_sjur = fue_sjur,
  gpo_edad = gpo_edad,
  ind_fue_sjur = ind_fue_sjur,
  ind_getn = ind_getn,
  ind_leng = ind_leng,
  ing_eg = ing_eg,
  lgbtttiqm = lgbtttiqm,
  lgbtttiqm_fue_sjur = lgbtttiqm_fue_sjur,
  may_fue_sjur = may_fue_sjur,
  may_gedad = may_gedad,
  mujeres_hijos_fuer_sjur = mujeres_hijos_fuer_sjur,
  mujeres_hijos_gedad = mujeres_hijos_gedad,
  niv_esc = niv_esc,
  pmimp_fue_sjur = pmimp_fue_sjur,
  sobrepob = sobrepob
)

inventario <- tibble(
  tabla = names(tablas_crudas),
  filas = map_int(tablas_crudas, nrow),
  columnas = map_int(tablas_crudas, ncol)
)

inventario

5.3 2.3 Verificar llaves comunes

Una pregunta básica antes de unir es: ¿qué variables tienen todas las tablas en común?

variables_comunes <- reduce(map(tablas_crudas, names), intersect)

tibble(variable_comun = variables_comunes)

En este caso, las dos llaves verdaderamente compartidas son:

  • entidad_centro
  • fecha
Lectura sustantiva de las llaves

entidad_centro identifica la entidad federativa o centro reportado. fecha indica el corte temporal. Si todas las tablas tienen exactamente una fila por combinación de esas dos variables, entonces es razonable pensar en una unión horizontal.

5.4 2.4 Comprobar que cada tabla tenga una fila por entidad y fecha

Este chequeo es importantísimo. Si una tabla tuviera varias filas por la misma combinación de entidad_centro y fecha, entonces un full_join() podría duplicar registros.

diagnostico_llaves <- tibble(
  tabla = names(tablas_crudas),
  filas_originales = map_int(tablas_crudas, nrow),
  filas_distintas_llave = map_int(
    tablas_crudas,
    ~ .x |>
      distinct(entidad_centro, fecha) |>
      nrow()
  )
) |>
  mutate(llave_unica = filas_originales == filas_distintas_llave)

diagnostico_llaves
Regla práctica

Nunca hagas un join solo porque “parece” que las tablas empatan. Primero revisa si la llave es realmente única dentro de cada archivo.

6 Paso 3. Renombrar columnas antes de unir

Si unimos las tablas tal como están, muchas columnas se llamarían total, porcentaje, hombres, mujeres, etc. Eso vuelve la base confusa.

La solución más transparente es agregar un prefijo a todas las variables que no son llaves.

Las llaves se conservan igual:

  • entidad_centro
  • fecha

Todo lo demás se renombra para recordar de qué tabla vino.

disc_fue_sjur <- disc_fue_sjur |>
  rename_with(~ paste0("disc_fue_sjur_", .x), .cols = -c(entidad_centro, fecha))

extranj_fuer_sjur <- extranj_fuer_sjur |>
  rename_with(~ paste0("extranj_fuer_sjur_", .x), .cols = -c(entidad_centro, fecha))

extranj_idioma <- extranj_idioma |>
  rename_with(~ paste0("extranj_idioma_", .x), .cols = -c(entidad_centro, fecha))

extranj_origen <- extranj_origen |>
  rename_with(~ paste0("extranj_origen_", .x), .cols = -c(entidad_centro, fecha))

fue_sjur <- fue_sjur |>
  rename_with(~ paste0("fue_sjur_", .x), .cols = -c(entidad_centro, fecha))

gpo_edad <- gpo_edad |>
  rename_with(~ paste0("gpo_edad_", .x), .cols = -c(entidad_centro, fecha))

ind_fue_sjur <- ind_fue_sjur |>
  rename_with(~ paste0("ind_fue_sjur_", .x), .cols = -c(entidad_centro, fecha))

ind_getn <- ind_getn |>
  rename_with(~ paste0("ind_getn_", .x), .cols = -c(entidad_centro, fecha))

ind_leng <- ind_leng |>
  rename_with(~ paste0("ind_leng_", .x), .cols = -c(entidad_centro, fecha))

ing_eg <- ing_eg |>
  rename_with(~ paste0("ing_eg_", .x), .cols = -c(entidad_centro, fecha))

lgbtttiqm <- lgbtttiqm |>
  rename_with(~ paste0("lgbtttiqm_", .x), .cols = -c(entidad_centro, fecha))

lgbtttiqm_fue_sjur <- lgbtttiqm_fue_sjur |>
  rename_with(~ paste0("lgbtttiqm_fue_sjur_", .x), .cols = -c(entidad_centro, fecha))

may_fue_sjur <- may_fue_sjur |>
  rename_with(~ paste0("may_fue_sjur_", .x), .cols = -c(entidad_centro, fecha))

may_gedad <- may_gedad |>
  rename_with(~ paste0("may_gedad_", .x), .cols = -c(entidad_centro, fecha))

mujeres_hijos_fuer_sjur <- mujeres_hijos_fuer_sjur |>
  rename_with(~ paste0("mujeres_hijos_fuer_sjur_", .x), .cols = -c(entidad_centro, fecha))

mujeres_hijos_gedad <- mujeres_hijos_gedad |>
  rename_with(~ paste0("mujeres_hijos_gedad_", .x), .cols = -c(entidad_centro, fecha))

niv_esc <- niv_esc |>
  rename_with(~ paste0("niv_esc_", .x), .cols = -c(entidad_centro, fecha))

pmimp_fue_sjur <- pmimp_fue_sjur |>
  rename_with(~ paste0("pmimp_fue_sjur_", .x), .cols = -c(entidad_centro, fecha))

sobrepob <- sobrepob |>
  rename_with(~ paste0("sobrepob_", .x), .cols = -c(entidad_centro, fecha))
Ventaja de prefijar

Cuando más adelante veas una columna como sobrepob_poblacion, sabrás inmediatamente que proviene de la tabla de espacios y sobrepoblación. Esa trazabilidad es muy valiosa al documentar un análisis.

7 Paso 4. Unir las tablas paso a paso

Ahora sí hacemos la unión horizontal. Usaremos full_join() porque queremos conservar todos los registros presentes en cualquiera de las tablas.

La llave será la misma en todos los casos:

llave_union <- c("entidad_centro", "fecha")
llave_union
[1] "entidad_centro" "fecha"         

7.1 4.1 Construir la base maestra

base_unida <- fue_sjur |>
  full_join(sobrepob, by = llave_union) |>
  full_join(ing_eg, by = llave_union) |>
  full_join(gpo_edad, by = llave_union) |>
  full_join(niv_esc, by = llave_union) |>
  full_join(disc_fue_sjur, by = llave_union) |>
  full_join(pmimp_fue_sjur, by = llave_union) |>
  full_join(extranj_fuer_sjur, by = llave_union) |>
  full_join(extranj_idioma, by = llave_union) |>
  full_join(extranj_origen, by = llave_union) |>
  full_join(lgbtttiqm, by = llave_union) |>
  full_join(lgbtttiqm_fue_sjur, by = llave_union) |>
  full_join(may_fue_sjur, by = llave_union) |>
  full_join(may_gedad, by = llave_union) |>
  full_join(mujeres_hijos_fuer_sjur, by = llave_union) |>
  full_join(mujeres_hijos_gedad, by = llave_union) |>
  full_join(ind_fue_sjur, by = llave_union) |>
  full_join(ind_getn, by = llave_union) |>
  full_join(ind_leng, by = llave_union)

7.2 4.2 Revisar dimensiones de la base final

tibble(
  filas = nrow(base_unida),
  columnas = ncol(base_unida)
)

7.3 4.3 Revisar las primeras columnas

base_unida |>
  select(1:20) |>
  slice_head(n = 5)
Interpretación

Una base unida no sustituye la lectura sustantiva de cada tabla original. La unión facilita el análisis integrado, pero también puede ocultar diferencias conceptuales entre módulos temáticos. Por eso conviene conservar siempre los archivos originales y documentar cómo se construyó la base maestra.

8 Paso 5. Crear una base analítica simple para el EDA

La base maestra tiene muchísimas columnas. Para explorarla visualmente conviene derivar una tabla más compacta con indicadores claros.

Aquí elegiremos algunas variables que resultan intuitivas para una primera exploración:

  • población total privada de la libertad;
  • espacios disponibles;
  • porcentaje de sobrepoblación;
  • ingresos y egresos;
  • población de 60 años o más;
  • población de 18 a 24 años.
base_eda <- base_unida |>
  transmute(
    entidad_centro,
    fecha,
    poblacion_total = fue_sjur_total,
    espacios = sobrepob_espacios,
    sobrepoblacion_relativa = sobrepob_sobrepoblacion_relativa_prct,
    ingresos = ing_eg_ingresos_total,
    egresos = ing_eg_egresos_total,
    adultos_mayores = may_gedad_total,
    poblacion_18_24 = gpo_edad_18_24_anios
  ) |>
  mutate(
    balance_flujo = ingresos - egresos,
    porcentaje_adultos_mayores = adultos_mayores / poblacion_total * 100
  )

9 Paso 5.1 Indicadores para explorar el tema de prisión preventiva oficiosa

Uno de los temas más importantes en el análisis penitenciario es la distinción entre personas procesadas y sentenciadas.

En términos muy generales:

  • una persona procesada todavía no cuenta con sentencia;
  • una persona sentenciada ya recibió una resolución condenatoria.

Esa distinción no equivale automáticamente a “prisión preventiva oficiosa”, pero sí abre una ventana analítica importante. Si en una entidad observamos una proporción alta de población procesada, eso puede ser una señal de interés para estudiar con más cuidado el uso de la prisión preventiva, los tiempos del proceso penal y la composición jurídica de la población penitenciaria.

Precaución analítica

Este tutorial no demuestra causalmente la existencia ni la intensidad de la prisión preventiva oficiosa. Lo que sí hace es construir indicadores descriptivos que pueden orientar preguntas de investigación.

La tabla fue_sjur es especialmente útil para esto porque ya contiene, por entidad y fecha:

  • fuero_comun_procesadas
  • fuero_comun_sentenciadas
  • fuero_federal_procesadas
  • fuero_federal_sentenciadas
  • totales por fuero y total general

Construiremos tres tipos de indicadores:

  1. la diferencia entre procesadas/os y sentenciadas/os;
  2. la razón procesadas/os entre sentenciadas/os;
  3. el porcentaje procesado sobre el total.
indicadores_procesadas <- fue_sjur |>
  transmute(
    entidad_centro,
    fecha,
    fc_procesadas = fue_sjur_fuero_comun_procesadas,
    fc_sentenciadas = fue_sjur_fuero_comun_sentenciadas,
    ff_procesadas = fue_sjur_fuero_federal_procesadas,
    ff_sentenciadas = fue_sjur_fuero_federal_sentenciadas,
    total_personas = fue_sjur_total
  ) |>
  mutate(
    fc_diferencia_procesadas_sentenciadas = fc_procesadas - fc_sentenciadas,
    ff_diferencia_procesadas_sentenciadas = ff_procesadas - ff_sentenciadas,
    total_procesadas = fc_procesadas + ff_procesadas,
    total_sentenciadas = fc_sentenciadas + ff_sentenciadas,
    diferencia_total_procesadas_sentenciadas = total_procesadas - total_sentenciadas,
    razon_fc_procesadas_sentenciadas = if_else(
      fc_sentenciadas > 0,
      fc_procesadas / fc_sentenciadas,
      NA_real_
    ),
    razon_ff_procesadas_sentenciadas = if_else(
      ff_sentenciadas > 0,
      ff_procesadas / ff_sentenciadas,
      NA_real_
    ),
    razon_total_procesadas_sentenciadas = if_else(
      total_sentenciadas > 0,
      total_procesadas / total_sentenciadas,
      NA_real_
    ),
    porcentaje_total_procesadas = total_procesadas / total_personas * 100
  )

indicadores_procesadas |>
  slice_head(n = 5)
Cómo leer estos indicadores

Si la razón es igual a 1, entonces hay la misma cantidad de procesadas/os y sentenciadas/os. Si la razón es mayor a 1, hay más personas procesadas que sentenciadas. Si la diferencia es positiva, ocurre lo mismo, pero expresado en números absolutos y no relativos.

9.1 5.1.1 Entidades con mayor porcentaje de población procesada

indicadores_procesadas |>
  arrange(desc(porcentaje_total_procesadas)) |>
  select(
    entidad_centro,
    total_procesadas,
    total_sentenciadas,
    porcentaje_total_procesadas,
    razon_total_procesadas_sentenciadas
  ) |>
  slice_head(n = 10)

9.2 5.1.2 Gráfico: porcentaje total de personas procesadas

indicadores_procesadas |>
  arrange(desc(porcentaje_total_procesadas)) |>
  mutate(entidad_centro = factor(entidad_centro, levels = unique(entidad_centro))) |>
  ggplot(aes(x = entidad_centro, y = porcentaje_total_procesadas)) +
  geom_col(fill = "#9C6644") +
  coord_flip() +
  labs(
    title = "Porcentaje de población procesada sobre el total",
    subtitle = "Indicador descriptivo útil para explorar el tema de prisión preventiva",
    x = NULL,
    y = "Porcentaje procesado"
  ) +
  theme_minimal(base_size = 12)

9.3 5.1.3 Gráfico: razón entre procesadas/os y sentenciadas/os

indicadores_procesadas |>
  arrange(desc(razon_total_procesadas_sentenciadas)) |>
  mutate(entidad_centro = factor(entidad_centro, levels = unique(entidad_centro))) |>
  ggplot(aes(x = entidad_centro, y = razon_total_procesadas_sentenciadas)) +
  geom_col(fill = "#A4133C") +
  geom_hline(yintercept = 1, linetype = "dashed", color = "#1D3557") +
  coord_flip() +
  labs(
    title = "Razón entre población procesada y sentenciada",
    subtitle = "La línea punteada marca el punto donde ambas poblaciones son iguales",
    x = NULL,
    y = "Razón procesadas/os sobre sentenciadas/os"
  ) +
  theme_minimal(base_size = 12)

9.4 5.1.4 Comparación entre fuero común y fuero federal

Este paso es importante porque el fenómeno puede tener perfiles distintos por fuero. Una entidad puede mostrar una razón alta en fuero común y una mucho más baja en fuero federal, o viceversa.

indicadores_procesadas |>
  select(
    entidad_centro,
    razon_fc_procesadas_sentenciadas,
    razon_ff_procesadas_sentenciadas
  ) |>
  pivot_longer(
    cols = c(
      razon_fc_procesadas_sentenciadas,
      razon_ff_procesadas_sentenciadas
    ),
    names_to = "fuero",
    values_to = "razon_procesadas_sentenciadas"
  ) |>
  mutate(
    fuero = recode(
      fuero,
      razon_fc_procesadas_sentenciadas = "Fuero común",
      razon_ff_procesadas_sentenciadas = "Fuero federal"
    )
  ) |>
  ggplot(aes(x = razon_procesadas_sentenciadas, y = entidad_centro, color = fuero)) +
  geom_point(size = 2.2, alpha = 0.8) +
  labs(
    title = "Razón procesadas/os-sentenciadas/os por fuero",
    subtitle = "Comparación descriptiva entre fuero común y fuero federal",
    x = "Razón",
    y = NULL,
    color = NULL
  ) +
  theme_minimal(base_size = 12)

9.5 5.1.5 Integrar estos indicadores a la base de EDA

Como estos indicadores son analíticamente muy útiles, los agregamos a la base de exploración.

base_eda <- base_eda |>
  left_join(
    indicadores_procesadas |>
      select(
        entidad_centro,
        fecha,
        total_procesadas,
        total_sentenciadas,
        diferencia_total_procesadas_sentenciadas,
        razon_total_procesadas_sentenciadas,
        porcentaje_total_procesadas
      ),
    by = c("entidad_centro", "fecha")
  )

10 Paso 6. EDA breve

La idea del EDA no es demostrar una tesis definitiva, sino detectar patrones, anomalías y preguntas que valga la pena investigar después.

10.1 6.1 Tabla de entidades con mayor población penitenciaria

base_eda |>
  arrange(desc(poblacion_total)) |>
  select(entidad_centro, poblacion_total, espacios, sobrepoblacion_relativa) |>
  slice_head(n = 10)

10.2 6.2 Gráfico de barras: población total por entidad

base_eda |>
  arrange(desc(poblacion_total)) |>
  mutate(entidad_centro = factor(entidad_centro, levels = unique(entidad_centro))) |>
  ggplot(aes(x = entidad_centro, y = poblacion_total)) +
  geom_col(fill = "#2C6E49") +
  coord_flip() +
  labs(
    title = "Población privada de la libertad por entidad o centro",
    subtitle = "Corte de enero de 2026",
    x = NULL,
    y = "Población total"
  ) +
  theme_minimal(base_size = 12)

10.3 6.3 Dispersión: población total y sobrepoblación relativa

base_eda |>
  ggplot(aes(x = poblacion_total, y = sobrepoblacion_relativa)) +
  geom_point(color = "#BC4749", size = 2, alpha = 0.8) +
  geom_smooth(method = "lm", se = FALSE, color = "#1D3557") +
  labs(
    title = "Población total y sobrepoblación relativa",
    subtitle = "Cada punto representa una entidad o centro",
    x = "Población total",
    y = "Sobrepoblación relativa (%)"
  ) +
  theme_minimal(base_size = 12)

Cómo leer este gráfico

Si los puntos tendieran claramente hacia arriba, podríamos pensar que las entidades con más población penitenciaria también registran mayor sobrepoblación relativa. Si no aparece un patrón claro, eso sugiere que el tamaño absoluto y el hacinamiento relativo no son exactamente la misma historia.

10.4 6.4 Balance de flujo: ingresos menos egresos

base_eda |>
  arrange(balance_flujo) |>
  mutate(entidad_centro = factor(entidad_centro, levels = unique(entidad_centro))) |>
  ggplot(aes(x = entidad_centro, y = balance_flujo, fill = balance_flujo > 0)) +
  geom_col(show.legend = FALSE) +
  coord_flip() +
  scale_fill_manual(values = c("#6C757D", "#D62828")) +
  labs(
    title = "Balance neto del flujo penitenciario",
    subtitle = "Ingresos menos egresos durante el mes",
    x = NULL,
    y = "Balance neto"
  ) +
  theme_minimal(base_size = 12)

10.5 6.5 Población adulta mayor como porcentaje del total

base_eda |>
  arrange(desc(porcentaje_adultos_mayores)) |>
  select(entidad_centro, poblacion_total, adultos_mayores, porcentaje_adultos_mayores) |>
  slice_head(n = 10)
base_eda |>
  ggplot(aes(x = porcentaje_adultos_mayores)) +
  geom_histogram(binwidth = 0.5, fill = "#457B9D", color = "white") +
  labs(
    title = "Distribución del porcentaje de adultos mayores",
    x = "Porcentaje de adultos mayores",
    y = "Número de entidades o centros"
  ) +
  theme_minimal(base_size = 12)

11 Paso 7. Guardar una salida analítica

Una buena práctica es guardar la base que construimos para no repetir el proceso manual cada vez.

write_csv(base_unida, file.path(ruta_salida, "tutorial_base_unida_entidad_centro.csv"))
write_csv(base_eda, file.path(ruta_salida, "tutorial_base_eda_entidad_centro.csv"))

Podemos confirmar que los archivos quedaron escritos:

list.files(ruta_salida)
[1] "entidad_centro_inventory.csv"          
[2] "entidad_centro_joined.rds"             
[3] "entidad_centro_stacked.csv"            
[4] "join_key_diagnostic.csv"               
[5] "tutorial_base_eda_entidad_centro.csv"  
[6] "tutorial_base_unida_entidad_centro.csv"

12 Reflexiones finales

Este tutorial deja varias lecciones metodológicas importantes:

  1. Leer archivo por archivo obliga a entender qué mide cada tabla.
  2. Verificar llaves antes de unir evita errores serios.
  3. Prefijar columnas hace la base final mucho más legible.
  4. La distinción entre población procesada y sentenciada permite construir indicadores útiles para discutir temas como prisión preventiva.
  5. Un EDA breve sirve para detectar patrones y también para descubrir problemas de datos.
Ejercicios sugeridos para el alumnado
  1. Repite el EDA usando solo las 10 entidades con mayor población penitenciaria.
  2. Calcula la razón poblacion_total / espacios y compárala con sobrepoblacion_relativa.
  3. Construye un gráfico que relacione poblacion_18_24 con poblacion_total.
  4. Elige una tabla temática, vuelve al CSV original y escribe una interpretación sustantiva de lo que mide.