Gráficos de puntos comparativos o dumbbell en {ggplot2}

24/4/2026

visualización de datos ggplot2 gráficos

Los gráficos de puntos comparativos, también conocidos como dumbbell o de mancuernas, son un tipo de visualización que muestra el cambio en los valores de un mismo grupo en dos momentos distintos. Cada valor se representa con un círculo, ambos conectados por una línea, donde la posición representa la cifra y su distancia representa la brecha. El objetivo de estos gráficos es comparar el cambio del valor.

Si bien los gráficos de barras también permiten comparar valores, el gráfico dumbell hace más explícita la comparación al eliminar otros elementos distractores y enfocar la atención en la distancia entre los puntos.

Obtener los datos

En este tutorial vamos a construir este tipo de gráfico usando datos reales de delincuencia en Chile para comparar la tasa de cuatro tipos de delitos entre 2018 y 2024.

Los datos de delincuencia están disponibles en el repositorio delincuencia_chile, que procesa las cifras oficiales del Centro de Estudios y Análisis del Delito (CEAD) y además genera un dashboard interactivo con los resultados.

Puedes descargar los datos en formato Parquet en el siguiente enlace:

Cargar los datos

Cargamos los datos usando read_parquet() del paquete {arrow}:

library(arrow)

# cargar directamente desde internet
# delitos <- read_parquet("https://github.com/bastianolea/delincuencia_chile/raw/main/datos_procesados/cead_delincuencia_chile.parquet")

# cargar archivo descargado
delitos <- read_parquet("cead_delincuencia_chile.parquet")

head(delitos)
   comuna cut_comuna   region cut_region      fecha                 delito
1 Iquique       1101 Tarapacá          1 2018-01-01             Homicidios
2 Iquique       1101 Tarapacá          1 2018-01-01             Femicidios
3 Iquique       1101 Tarapacá          1 2018-01-01            Violaciones
4 Iquique       1101 Tarapacá          1 2018-01-01        Abusos sexuales
5 Iquique       1101 Tarapacá          1 2018-01-01        Acosos sexuales
6 Iquique       1101 Tarapacá          1 2018-01-01 Otros delitos sexuales
  delito_n
1        3
2        0
3        0
4       10
5        0
6        0

Los datos tienen una fila por cada tipo de delito, zona geográfica y período, con una columna delito_n que indica la cantidad de casos.

Preparación de los datos

Para comparar cambios de determinados delitos entre dos años a nivel nacional, necesitamos realizar algunos procesos sobre la data.

Contar delitos por año

Los datos vienen desagregados por zona geográfica y período. Para este tutorial necesitamos totales a nivel nacional, así que agrupamos por año y tipo de delito con group_by() para sumar todos los registros por año. Necesitamos la fecha en años, pero la columna fecha tiene la fecha en formato texto con el año, mes y día, así que usamos la función year() de {lubridate} para extraer el año desde la columna de fecha, y ahí sí podemos hacer group_by() seguido de summarize() para obtener el conteo total por cada combinación de año y delito.

library(dplyr)
library(lubridate)

# contar delitos por año
delitos_conteo <- delitos |>
  mutate(año = year(fecha)) |> # convertir fechas a años
  group_by(año, delito) |>
  summarize(delitos = sum(delito_n)) |> # sumar delitos por año y tipo de delito
  mutate(delitos = as.integer(delitos)) |> 
  ungroup()

Recordemos que el ungroup() al final es importante para evitar que el agrupamiento afecte las operaciones siguientes.

Revisemos cómo quedan los datos:

delitos_conteo |>
  filter(año == 2024) |>
  arrange(-delitos) |> 
  head()
# A tibble: 6 × 3
    año delito                                        delitos
  <dbl> <fct>                                           <int>
1  2024 Amenazas                                       154037
2  2024 Violencia intrafamiliar                        141433
3  2024 Hurtos                                         124902
4  2024 Daños                                          123062
5  2024 Consumo de alcohol y drogas en la vía pública  104931
6  2024 Robos con violencia o intimidación              69545

Seleccionar delitos y años

Seleccionamos los tipos de delito que nos interesan comparar, y los años que queremos comparar. Este paso definirá la información que representaremos en el gráfico!

delitos_filtro <- delitos_conteo |>
  # delitos seleccionados
  filter(delito %in% c("Delitos asociados a armas", 
                       "Robo por sorpresa", 
                       "Robo de vehículo motorizado",
                       "Hurtos",
                       "Robo en lugar habitado", 
                       "Robos con violencia o intimidación")) |>
  # años a comparar
  filter(año %in% c(2018, 2024))

delitos_filtro
# A tibble: 12 × 3
     año delito                             delitos
   <dbl> <fct>                                <int>
 1  2018 Delitos asociados a armas            17893
 2  2018 Hurtos                              172014
 3  2018 Robo de vehículo motorizado          24285
 4  2018 Robo en lugar habitado               58431
 5  2018 Robo por sorpresa                    34194
 6  2018 Robos con violencia o intimidación   73538
 7  2024 Delitos asociados a armas            29061
 8  2024 Hurtos                              124902
 9  2024 Robo de vehículo motorizado          28671
10  2024 Robo en lugar habitado               43436
11  2024 Robo por sorpresa                    40481
12  2024 Robos con violencia o intimidación   69545

Calcular tasa de delitos por habitante

Si bien podemos simplemente comparar los delitos entre 2018 y 2024, sería incorrecto debido a que ignora las diferencias de población entre ambos años. Al comparar cantidad total de delitos, un delito que parece aumentar puede estar simplemente reflejando un aumento de habitantes. La solución es calcular una tasa que ajuste la cantidad de delitos a la población existente en cada año.

Para eso necesitamos datos de población para los años que filtremos. Podemos obtener la población anual desde las proyecciones de población del Instituto Nacional de Estadísticas..

Cargamos los datos con read_xlsx() del paquete {readxl} y luego limpiamos los nombres de columnas con clean_names() del paquete {janitor}. Como los datos vienen por edad y fecha, agrupamos los datos y sumamos las poblaciones para obtener la población nacional en cada fecha:

library(readxl)
library(janitor)

# cargar datos
poblacion <- read_xlsx("estimaciones-y-proyecciones-de-población-1992-2070_base-2024_base-de-datos.xlsx")

# colapsar cifras por años
poblacion_suma <- poblacion |>
  clean_names() |>
  filter(nivel == "PAÍS") |> 
  # sumar por fecha
  group_by(nivel, fecha) |>
  summarize(poblacion = sum(poblacion)) |>
  ungroup()

Luego filtramos las fechas que necesitamos, y convertimos los valores a fecha y luego a años para que coincidan con los datos de delincuencia:

poblacion_filtro <- poblacion_suma |>
  filter(fecha %in% c("1/1/2018", "1/1/2024")) |> # filtrar fechas
  mutate(fecha = dmy(fecha), # convertir texto a fecha
         año = year(fecha)) |> # convertir fecha a años
  select(-nivel, -fecha)

poblacion_filtro
# A tibble: 2 × 2
  poblacion   año
      <dbl> <dbl>
1  18600071  2018
2  19832867  2024

Con los datos de población listos, hacemos un left_join por año para agregar la población a la tabla de delitos. Como estamos uniendo por año, cada fila de delitos recibe automáticamente la población correspondiente a ese año.

# agregar población a cada año
delitos_poblacion <- delitos_filtro |>
  left_join(poblacion_filtro, 
            join_by(año))

delitos_poblacion
# A tibble: 12 × 4
     año delito                             delitos poblacion
   <dbl> <fct>                                <int>     <dbl>
 1  2018 Delitos asociados a armas            17893  18600071
 2  2018 Hurtos                              172014  18600071
 3  2018 Robo de vehículo motorizado          24285  18600071
 4  2018 Robo en lugar habitado               58431  18600071
 5  2018 Robo por sorpresa                    34194  18600071
 6  2018 Robos con violencia o intimidación   73538  18600071
 7  2024 Delitos asociados a armas            29061  19832867
 8  2024 Hurtos                              124902  19832867
 9  2024 Robo de vehículo motorizado          28671  19832867
10  2024 Robo en lugar habitado               43436  19832867
11  2024 Robo por sorpresa                    40481  19832867
12  2024 Robos con violencia o intimidación   69545  19832867

Ahora calculamos la tasa: dividimos el conteo de delitos por la población y multiplicamos por 1.000. Así, un valor de 3,5 significaría que ocurrieron 3,5 casos de ese tipo de delito por cada 1.000 habitantes en Chile.

# calcular tasa
delitos_tasa <- delitos_poblacion |>
  mutate(tasa = delitos / poblacion * 1000)

delitos_tasa
# A tibble: 12 × 5
     año delito                             delitos poblacion  tasa
   <dbl> <fct>                                <int>     <dbl> <dbl>
 1  2018 Delitos asociados a armas            17893  18600071 0.962
 2  2018 Hurtos                              172014  18600071 9.25 
 3  2018 Robo de vehículo motorizado          24285  18600071 1.31 
 4  2018 Robo en lugar habitado               58431  18600071 3.14 
 5  2018 Robo por sorpresa                    34194  18600071 1.84 
 6  2018 Robos con violencia o intimidación   73538  18600071 3.95 
 7  2024 Delitos asociados a armas            29061  19832867 1.47 
 8  2024 Hurtos                              124902  19832867 6.30 
 9  2024 Robo de vehículo motorizado          28671  19832867 1.45 
10  2024 Robo en lugar habitado               43436  19832867 2.19 
11  2024 Robo por sorpresa                    40481  19832867 2.04 
12  2024 Robos con violencia o intimidación   69545  19832867 3.51 

Clasificar la variación

Ciertos aspectos de la visualización necesitan variables extra relacionadas a los datos existentes. En nuestro caso, serviría calcular una variable que indique si cada delito subió, bajó o se mantuvo igual. Por ejemplo:

delitos_tasa |> 
  filter(delito == "Hurtos") |> 
  select(delito, año, tasa)
# A tibble: 2 × 3
  delito   año  tasa
  <fct>  <dbl> <dbl>
1 Hurtos  2018  9.25
2 Hurtos  2024  6.30

Vemos que el valor del segundo año es inferior al del primer año, o sea que los delitos de hurtos bajaron. Creemos una variable que indique esto!

La función lag() nos entrega el valor de la observación anterior a cada fila, por lo que nos sirve para hacer comparaciones y así saber si un valor es mayor o menor al anterior.

Por ejemplo:

delitos_tasa |> 
  filter(delito == "Hurtos") |> 
  select(delito, año, tasa) |> 
  arrange(año) |> 
  # obtener el valor del año anterior
  mutate(tasa_anterior = lag(tasa)) |> 
  slice_max(año)
# A tibble: 1 × 4
  delito   año  tasa tasa_anterior
  <fct>  <dbl> <dbl>         <dbl>
1 Hurtos  2024  6.30          9.25

Primero ordenamos los datos por tipo de delito y año, de modo que dentro de cada delito, el año quede de menor a mayor. Este orden es fundamental para que lag() funcione correctamente. Luego agrupamos por delito para que el cálculo se realice por separado en cada delito, y usamos lag(tasa) para obtener la tasa del año anterior en cada fila. Luego, con case_when() clasificamos cada observación en la variable cambio según si la tasa subió, bajó o se mantuvo igual, por medio de una simple comparación.

delitos_tasa_clasif <- delitos_tasa |>
  arrange(delito, año) |> 
  group_by(delito) |>
  # define si sube o baja respecto al año anterior
  mutate(cambio = case_when(tasa > lag(tasa) ~ "sube",
                            tasa < lag(tasa) ~ "baja",
                            tasa == lag(tasa) ~ "igual")
  )

delitos_tasa_clasif |> 
  select(delito, año, tasa, cambio)
# A tibble: 12 × 4
# Groups:   delito [6]
   delito                               año  tasa cambio
   <fct>                              <dbl> <dbl> <chr> 
 1 Delitos asociados a armas           2018 0.962 <NA>  
 2 Delitos asociados a armas           2024 1.47  sube  
 3 Hurtos                              2018 9.25  <NA>  
 4 Hurtos                              2024 6.30  baja  
 5 Robo de vehículo motorizado         2018 1.31  <NA>  
 6 Robo de vehículo motorizado         2024 1.45  sube  
 7 Robo en lugar habitado              2018 3.14  <NA>  
 8 Robo en lugar habitado              2024 2.19  baja  
 9 Robo por sorpresa                   2018 1.84  <NA>  
10 Robo por sorpresa                   2024 2.04  sube  
11 Robos con violencia o intimidación  2018 3.95  <NA>  
12 Robos con violencia o intimidación  2024 3.51  baja  

Las filas de 2018 quedan con NA en cambio, porque no hay un año anterior con el que comparar, así que procedemos a rellenar con el valor que sí tiene información dentro del mismo grupo usando fill() de {tidyr}. Al especificar .direction = "up" en fill(), la función propaga el valor de 2024 hacia arriba para rellenar el NA de 2018. Así ambas filas de cada delito quedan con la misma etiqueta de variación, que usaremos para colorear el gráfico.

library(tidyr)

delitos_tasa_clasif <- delitos_tasa_clasif |> 
  # rellenar valores del primer año
  group_by(delito) |>
  fill(cambio, .direction = "up")

delitos_tasa_clasif |> 
  select(delito, año, tasa, cambio)
# A tibble: 12 × 4
# Groups:   delito [6]
   delito                               año  tasa cambio
   <fct>                              <dbl> <dbl> <chr> 
 1 Delitos asociados a armas           2018 0.962 sube  
 2 Delitos asociados a armas           2024 1.47  sube  
 3 Hurtos                              2018 9.25  baja  
 4 Hurtos                              2024 6.30  baja  
 5 Robo de vehículo motorizado         2018 1.31  sube  
 6 Robo de vehículo motorizado         2024 1.45  sube  
 7 Robo en lugar habitado              2018 3.14  baja  
 8 Robo en lugar habitado              2024 2.19  baja  
 9 Robo por sorpresa                   2018 1.84  sube  
10 Robo por sorpresa                   2024 2.04  sube  
11 Robos con violencia o intimidación  2018 3.95  baja  
12 Robos con violencia o intimidación  2024 3.51  baja  

También vamos a calcular la variable tipo para establecer la posición de cada valor: si el valor de un año corresponde al mayor o al menor entre los dos años disponibles. Como los datos están agrupados, lo hacemos simplemente preguntando si el valor es el mayor, o si es el menor.

delitos_tasa_clasif <- delitos_tasa_clasif |> 
  group_by(delito) |>
  # define si el valor es el mayor o el menor
  mutate(tipo = case_when(tasa == max(tasa) ~ "mayor",
                          tasa == min(tasa) ~ "menor")
  ) |> 
  ungroup()

delitos_tasa_clasif |> 
  select(delito, año, tasa, tipo)
# A tibble: 12 × 4
   delito                               año  tasa tipo 
   <fct>                              <dbl> <dbl> <chr>
 1 Delitos asociados a armas           2018 0.962 menor
 2 Delitos asociados a armas           2024 1.47  mayor
 3 Hurtos                              2018 9.25  mayor
 4 Hurtos                              2024 6.30  menor
 5 Robo de vehículo motorizado         2018 1.31  menor
 6 Robo de vehículo motorizado         2024 1.45  mayor
 7 Robo en lugar habitado              2018 3.14  mayor
 8 Robo en lugar habitado              2024 2.19  menor
 9 Robo por sorpresa                   2018 1.84  menor
10 Robo por sorpresa                   2024 2.04  mayor
11 Robos con violencia o intimidación  2018 3.95  mayor
12 Robos con violencia o intimidación  2024 3.51  menor

Esta es una forma simple de hacerlo, que ignora la posiblidad de casos donde los valores no cambien, pero por ahora es suficiente.

Visualización

Con los datos procesados, podemos pasar a la visualización!

Gráfico de barras apiladas

La visualización más básica para comparar dos o más grupos es un gráfico de barras. En este caso queremos mostrar barras por delito, pero también múltiples barras por cada delito (los años), así que mapeamos la variable año al relleno de color (fill) de las barras, y agregando position = position_dodge() a la capa de columnas (geom_col()) hacemos que las barras aparezcan una al lado de la otra. En este caso, la variable año viene como numérica, así que usamos as.factor(año) para que {ggplot2} entienda los números como categorías discretas y no como variables continuas.

Empezamos con un gráfico básico:

library(ggplot2)
library(scales)

delitos_tasa_clasif |>
  ggplot() +
  aes(x = delito, y = tasa, 
      fill = as.factor(año)) +
  geom_col(
    width = 0.7,
    position = position_dodge()
  )

Una vez que confirmamos que el gráfico básico funciona como se espera, mejoramos los detalles estéticos del mismo:

  • Agregamos textos sobre las barras con geom_col()
  • Aplicamos una paleta de colores para el relleno de las barras con scale_fill_manual()
  • Ajustamos el espaciado de las escalas y sus textos
  • Agregamos textos de título, subtítulo, fuente y títulos de ejes
  • Aplicamos un tema de colores en theme_minimal() y ajustes a los elementos específicos del tema en theme()
delitos_tasa_clasif |>
  ggplot() +
  aes(x = delito, y = tasa, 
      fill = as.factor(año)) +
  # barras 
  geom_col(
    width = 0.7,
    color = "#F8F2E7", linewidth = 1.1,
    position = position_dodge() # lado a lado
  ) +
  # textos sobre las barras
  geom_text(
    aes(
      label = label_number(decimal.mark = ",")(tasa)
    ),
    color = "#49392A", size = 3, alpha = .7,
    position = position_dodge(width = 0.7),
    vjust = -0.4
  ) +
  # paleta de colores
  scale_fill_manual(values = c("2018" = "#06BB96", "2024" = "#F1856A")) +
  # expandir espacio vertical para etiqueta de texto
  scale_y_continuous(expand = expansion(c(0, 0.1))) +
  # cortar líneas de texto muy largas
  scale_x_discrete(labels = scales::label_wrap(20)) +
  # textos
  labs(fill = "Años", y = "Tasa de delitos por cada 1.000 habitantes", x = NULL,
       title = "Comparación de tasas de delitos",
       subtitle = "Estadísticas oficiales de delitos en Chile, 2018 y 2024",
       caption = "Fuente: Centro de Estudios y Análisis del Delito (CEAD)") +
  # temas
  theme_minimal(base_family = "Atkinson Hyperlegible",
                ink = "#49392A", paper = "#F8F2E7") +
  theme(panel.grid.major.x = element_blank(),
        axis.text.x = element_text(face = "bold"),
        legend.position = "top",
        plot.title = element_text(face = "bold"))

La comparación entre valores funciona, pero ahora veremos una forma distinta de hacerlo.

Gráfico dumbbell

Para hacer un gráfico de puntos comparados o dumbell necesitamos expresar los valores como puntos en vez de barras, y unir los puntos con una línea.

delitos_tasa_clasif |>
  ggplot() +
  aes(y = delito, x = tasa, 
      color = as.factor(año)) +
  geom_line(
    aes(group = delito)
  ) +
  geom_point()

El argumento aes(group = delito) en geom_line() le dice a {ggplot2} que dibuje una línea por cada tipo de delito, conectando sus dos puntos.

Ahora podemos aplicar más detalle a la visualización:

  • Paleta de colores con scale_color_manual()
  • Textos al lado de cada punto con geom_text(), con su posición dependiendo de si cada cifra es la menor (a la izquierda) o menor (a la derecha)
  • Aumentar la escala horizontal para dar espacio a los textos que agregamos, con expansion() dentro de scale_x_continuous()
  • Aplicar un tema de colores en theme_minimal() y ajustar a los elementos específicos del tema en theme()
library(glue)

delitos_tasa_clasif |>
  ggplot() +
  aes(y = delito, x = tasa,
      color = as.factor(año)) +
  # línea que conecta puntos de delitos
  geom_line(
    aes(group = delito),
    color = "#49392A", alpha = 0.7,
    linewidth = .8,
    show.legend = FALSE
  ) +
  geom_point(size = 5) +
  # texto con tasas al lado de cada círculo
  geom_label(
    aes(
      label = label_number(decimal.mark = ",")(tasa),
      hjust = if_else(tipo == "menor", 1.4, -0.4)
    ),
    size = 3, linewidth = 0, show.legend = FALSE
  ) +
  # texto debajo del último año
  geom_text(
    data = ~filter(.x, año == max(año)),
    aes(label = glue("{cambio} en {año}")),
    vjust = 2.7, color = "#49392A", alpha = 0.7, size = 3,
    show.legend = FALSE
  ) +
  # paleta de colores
  scale_color_manual(values = c("2018" = "#06BB96", "2024" = "#F1856A")) +
  # expansión de eje horizontal
  scale_x_continuous(expand = expansion(c(0.2, 0.2))) +
  # cortar textos largos del eje vertical
  scale_y_discrete(labels = scales::label_wrap(23)) +
  # textos
  labs(y = NULL, x = "Tasa de delitos por cada 1.000 habitantes", color = "Años",
       title = "Comparación de tasas de delitos",
       subtitle = "Estadísticas oficiales de delitos en Chile, 2018 y 2024",
       caption = "Fuente: Centro de Estudios y Análisis del Delito (CEAD)") +
  # temas
  theme_classic(base_family = "Atkinson Hyperlegible",
                ink = "#49392A", paper = "#F8F2E7") +
  theme(panel.grid.major = element_line(linewidth = .2, color = "#E3DCD1"),
        legend.position = "top",
        plot.title = element_text(face = "bold"),
        axis.text.y = element_text(face = "bold", color = "#49392A", size = 10))

Para cada tipo de delito, vemos que el punto de la izquierda es el menor valor y el de la derecha el mayor, mientras que la línea muestra la brecha.

Puedes ver una versión de este mismo gráfico en mi aplicación web de estadísticas de delincuencia en Chile, donde puedes elegir interactivamente los años a comparar y los delitos.

Fecha de publicación:
April 24, 2026
Extensión:
15 minute read, 3042 words
Categorías:
Tutoriales
Tags:
visualización de datos ggplot2 gráficos
Ver también:
Aplicación: Visualizador de densidad de población urbana en comunas de Chile
Extensiones recomendadas para mejorar tus gráficos de `{ggplot2}`
App: Suicidios en Chile (2017-2024) desde una perspectiva de género