Pontificia Universidad Javeriana Cali
Maestría en Ciencia de Datos

1 Planteamiento

¿Qué comunas de Cali concentran las salidas y llegadas de los viajes encuestados y cómo cambia su distribución entre bicicleta, moto y automóvil? El caso utiliza una encuesta de 2015 y cartografía urbana. La unidad de análisis es el viaje registrado. Una misma persona puede estar representada en más de un viaje.

El video del curso indica cargar la Hoja1, consultar Convenciones (2), agregar los viajes por comuna y asociar esos conteos a los polígonos. El informe sigue el mismo orden. Primero presenta los cuatro mapas de origen y luego los cuatro de destino. Se añade una matriz para identificar los pares que explican parte del patrón de salidas y llegadas. Video del caso, 1:38–3:20.

2 Datos y criterios de inclusión

  • Encuesta. EncuestaOrigenDestino.xlsx, hoja Hoja1. Los campos comuna origen y comuna destino identifican los extremos y TIPO DE VEHÍCULO identifica la categoría registrada. La hoja Convenciones (2) documenta los códigos 1 = bicicleta, 2 = moto y 3 = automóvil.
  • Cartografía. cali/Comunas.shp y sus archivos complementarios. La capa contiene 22 polígonos o multipolígonos identificados por el atributo comuna. Se conserva el sistema proyectado en metros declarado por el archivo .prj de la capa.
  • Universo de los ocho mapas. Se conservan los viajes cuyos dos extremos tienen un código de comuna presente en la cartografía. Esta regla operacionaliza el foco urbano y da la misma base a los mapas de origen y destino. El análisis general incorpora todos los tipos de vehículo del universo, incluidos los registros sin código de vehículo. Los tres análisis por vehículo usan el mismo criterio espacial.
# Utilizar el ZIP original junto al Rmd o la carpeta Casos ya extraída.
archivo_excel <- "EncuestaOrigenDestino.xlsx"
archivo_shape <- file.path("cali", "Comunas.shp")
raiz <- params$carpeta_datos

if (!file.exists(file.path(raiz, archivo_excel)) ||
    !file.exists(file.path(raiz, archivo_shape))) {
  if (!file.exists(params$archivo_zip)) {
    stop("Ubique Casos.zip junto al Rmd o indique su ruta en archivo_zip.")
  }
  temporal <- tempfile("encuesta_od_")
  dir.create(temporal)
  contenido <- utils::unzip(params$archivo_zip, list = TRUE)$Name
  entradas <- contenido[contenido == paste0("Casos/", archivo_excel) |
    grepl("^Casos/cali/Comunas[.](shp|shx|dbf|prj)$", contenido)]
  stopifnot(length(entradas) == 5L)
  utils::unzip(params$archivo_zip, files = entradas, exdir = temporal)
  raiz <- file.path(temporal, "Casos")
}

ruta_excel <- file.path(raiz, archivo_excel)
campos <- c("ID ESTACIÓN", "TIPO DE VEHÍCULO", "comuna origen",
            "comuna destino", "Intracomuna")
encabezado <- names(read_excel(ruta_excel, sheet = "Hoja1", n_max = 0,
                               .name_repair = "minimal"))
stopifnot(all(campos %in% encabezado))
tipos_col <- ifelse(encabezado %in% campos, "text", "skip")
viajes_excel <- read_excel(ruta_excel, sheet = "Hoja1", col_types = tipos_col)

# Verificar los códigos en el diccionario incluido en el libro.
diccionario <- read_excel(
  ruta_excel, sheet = "Convenciones (2)", range = "F24:G33",
  col_names = c("codigo", "vehiculo"), col_types = c("numeric", "text")
)
stopifnot(
  identical(diccionario$codigo[1:3], c(1, 2, 3)),
  identical(
    tolower(iconv(trimws(diccionario$vehiculo[1:3]), to = "ASCII//TRANSLIT")),
    c("bicicleta", "moto", "automovil")
  )
)

comunas <- st_read(file.path(raiz, archivo_shape), quiet = TRUE) |>
  transmute(comuna = as.integer(comuna)) |>
  arrange(comuna)
stopifnot(
  identical(comunas$comuna, 1:22), !is.na(st_crs(comunas)),
  !st_is_longlat(comunas), all(st_is_valid(comunas)),
  !any(st_is_empty(comunas))
)
# La cartografía define los códigos válidos. Se preservan los valores
# originales para distinguir Fuera de Cali, código 0 y datos faltantes.
codigo_comuna <- function(x) {
  codigo <- suppressWarnings(as.integer(ifelse(grepl("^[0-9]{1,2}$", x),
                                                x, NA_character_)))
  codigo[!codigo %in% comunas$comuna] <- NA_integer_
  codigo
}

viajes <- viajes_excel |>
  filter(!is.na(`ID ESTACIÓN`)) |>
  transmute(
    origen_original = trimws(`comuna origen`),
    destino_original = trimws(`comuna destino`),
    origen = codigo_comuna(origen_original),
    destino = codigo_comuna(destino_original),
    vehiculo = suppressWarnings(as.integer(`TIPO DE VEHÍCULO`)),
    intra_excel = trimws(Intracomuna),
    cobertura = case_when(
      !is.na(origen) & !is.na(destino) ~ "Ambos extremos",
      !is.na(origen) ~ "Solo origen",
      !is.na(destino) ~ "Solo destino",
      TRUE ~ "Ningún extremo"
    )
  )

urbanos <- viajes |> filter(cobertura == "Ambos extremos")
categorias <- c("Ambos extremos", "Solo origen", "Solo destino", "Ningún extremo")
cobertura <- viajes |>
  mutate(cobertura = factor(cobertura, levels = categorias)) |>
  count(cobertura, name = "n", .drop = FALSE) |>
  mutate(proporcion = n / nrow(viajes))

fuera <- c(sum(viajes$origen_original == "Fuera de Cali", na.rm = TRUE),
           sum(viajes$destino_original == "Fuera de Cali", na.rm = TRUE))
cero <- c(sum(viajes$origen_original == "0", na.rm = TRUE),
          sum(viajes$destino_original == "0", na.rm = TRUE))
sin_comuna <- c(sum(is.na(viajes$origen)), sum(is.na(viajes$destino)))
stopifnot(nrow(urbanos) > 0, all(fuera + cero == sin_comuna))

tabla(
  transmute(cobertura, `Extremos con comuna válida` = cobertura,
            Viajes = fmt_n(n), `% de la hoja` = fmt_pct(proporcion)),
  "Tabla 1. Cobertura cartográfica de los registros de la encuesta."
)
Tabla 1. Cobertura cartográfica de los registros de la encuesta.
Extremos con comuna válida Viajes % de la hoja
Ambos extremos 22.151 63,2 %
Solo origen 5.925 16,9 %
Solo destino 5.715 16,3 %
Ningún extremo 1.263 3,6 %

De 35.054 viajes, 22.151 (63,2 %) tienen ambos extremos en las 22 comunas. Entre los extremos excluidos aparecen 6.306 orígenes y 6.400 destinos marcados como «Fuera de Cali», además de 672 orígenes y 788 destinos con código 0. El código 0 carece de polígono en la capa y se conserva como categoría distinta.

3 Agregación y unión a la cartografía

Se agrupa por comuna de origen y de destino por separado. Los totales se unen a la capa por la llave numérica comuna. Se usa sf para leer y manipular la capa vectorial, y ggplot2 para los mapas. La unión izquierda conserva las 22 geometrías y asigna cero cuando una combinación de comuna, extremo y grupo carece de viajes. En cada grupo, la suma de los orígenes y la de los destinos deben coincidir con el número de viajes del grupo. La geometría ya está definida por el shapefile y los códigos de comuna permiten vincular el Excel directamente con sus atributos. Carga de shapes en R · Modelos y estructuras de datos espaciales.

grupos <- c("General", "Bicicleta", "Moto", "Automóvil")
codigos <- c(NA_integer_, 1L, 2L, 3L)
extremos <- c("origen", "destino")

muestra_por_grupo <- lapply(seq_along(grupos), function(i) {
  if (i == 1L) urbanos else filter(urbanos, vehiculo == codigos[i])
})
names(muestra_por_grupo) <- grupos
n_grupo <- vapply(muestra_por_grupo, nrow, integer(1))

# Una sola rutina produce los conteos y la unión para los ocho mapas.
agregar_comunas <- function(datos, extremo) {
  conteo <- datos |>
    count(comuna = .data[[extremo]], name = "viajes")
  cartografia <- comunas |>
    left_join(conteo, by = "comuna") |>
    mutate(viajes = coalesce(viajes, 0L))
  stopifnot(nrow(cartografia) == nrow(comunas),
            sum(cartografia$viajes) == nrow(datos))
  cartografia
}
mapas <- lapply(muestra_por_grupo, function(datos) {
  setNames(lapply(extremos, function(extremo) agregar_comunas(datos, extremo)),
           extremos)
})

3.1 Cómo leer los mapas

Cada par de mapas del mismo grupo comparte una escala de conteos absolutos entre origen y destino. Cada grupo usa su propia escala para distinguir las comunas incluso cuando el número de viajes es pequeño, como en bicicleta. Para comparar el peso de una comuna entre vehículos, se usan las participaciones dentro de cada grupo en la Tabla 2. Los números dentro de los polígonos identifican la comuna. La flecha señala el norte de la cuadrícula del sistema proyectado.

# Los puntos interiores se usan exclusivamente para colocar las etiquetas.
etiquetas <- st_point_on_surface(st_geometry(comunas)) |> st_coordinates()
etiquetas <- data.frame(comuna = comunas$comuna, x = etiquetas[, "X"],
                        y = etiquetas[, "Y"])
limites <- st_bbox(comunas)
maximo <- vapply(mapas, function(x) {
  max(x$origen$viajes, x$destino$viajes)
}, numeric(1))
paso_escala <- c(1000, 50, 500, 500)
tope_escala <- ceiling(maximo / paso_escala) * paso_escala
paletas <- list(
  c("#edf3fa", "#91bed8", "#103e68"),
  c("#e8f4ee", "#75bda0", "#07544b"),
  c("#fdf0db", "#eca55c", "#933c0e"),
  c("#f1eaf7", "#b99cd5", "#543478")
)

dibujar_mapa <- function(i, extremo, numero) {
  grupo <- grupos[i]
  cartografia <- mapas[[grupo]][[extremo]]
  textos <- left_join(etiquetas, st_drop_geometry(cartografia), by = "comuna")
  textos$color <- ifelse(textos$viajes > 0.53 * tope_escala[i], "white", "#172f42")
  cortes <- seq(0, tope_escala[i], by = paso_escala[i])
  x_barra <- limites[["xmin"]] + 350
  y_barra <- limites[["ymin"]] + 250
  x_norte <- limites[["xmax"]] - 300
  y_norte <- limites[["ymax"]] - 2500

  ggplot(cartografia) +
    geom_sf(aes(fill = viajes), color = "#577184", linewidth = .28) +
    geom_text(data = textos, aes(x, y, label = sprintf("%02d", comuna),
                                 color = color), size = 3.1, fontface = "bold") +
    scale_color_identity() +
    annotate("segment", x = x_barra, xend = x_barra + 2000,
             y = y_barra, yend = y_barra, linewidth = .5, colour = "#345267") +
    annotate("text", x = x_barra + 1000, y = y_barra + 500, label = "2 km",
             size = 2.8, color = "#345267") +
    annotate("segment", x = x_norte, xend = x_norte, y = y_norte,
             yend = y_norte + 950, colour = "#345267",
             arrow = grid::arrow(type = "closed", length = grid::unit(.13, "cm"))) +
    annotate("text", x = x_norte, y = y_norte + 1450, label = "N",
             size = 3, colour = "#345267", fontface = "bold") +
    scale_fill_gradientn(
      colours = paletas[[i]], limits = c(0, tope_escala[i]), breaks = cortes,
      labels = fmt_n,
      guide = guide_colorbar(title.position = "top", title.hjust = .5,
                             barwidth = grid::unit(4.3, "cm"),
                             barheight = grid::unit(.25, "cm"))
    ) +
    coord_sf(datum = NA, expand = FALSE,
             xlim = unname(limites[c("xmin", "xmax")]) + c(-550, 550),
             ylim = unname(limites[c("ymin", "ymax")]) + c(-600, 600)) +
    labs(title = paste0(numero, "  ·  ", grupo),
         subtitle = paste0(fmt_n(n_grupo[i]), " viajes analizados"),
         fill = "Viajes registrados por comuna") +
    theme_void(base_size = 12) +
    theme(
      plot.title = element_text(face = "bold", colour = "#173447", size = 15),
      plot.subtitle = element_text(colour = "#486579", size = 10,
                                   margin = margin(b = 5)),
      plot.title.position = "plot",
      plot.margin = margin(8, 10, 5, 10),
      legend.position = "bottom",
      legend.title = element_text(size = 8.5, colour = "#28475c"),
      legend.text = element_text(size = 8, colour = "#28475c")
    )
}

dibujar_panel <- function(extremo, primero) {
  grid::grid.newpage()
  grid::pushViewport(grid::viewport(layout = grid::grid.layout(2, 2)))
  for (i in seq_along(grupos)) {
    posicion <- grid::viewport(layout.pos.row = (i + 1L) %/% 2L,
                                layout.pos.col = (i - 1L) %% 2L + 1L)
    print(dibujar_mapa(i, extremo, primero + i - 1L), vp = posicion,
          newpage = FALSE)
  }
  grid::popViewport()
}

4 Comunas de origen

dibujar_panel("origen", 1L)
Cuatro mapas de origen en Cali para el total, bicicleta, moto y automóvil.

Mapas 1–4. Salidas por comuna en el total y por tipo de vehículo. Fuente: Encuesta Origen–Destino de 2015 y cartografía del curso.

El origen general se concentra en C02 (2.250) / C19 (2.241) / C17 (1.762). Entre las comunas 2 y 19 hay apenas 9 viajes de diferencia. La comuna 2 encabeza las salidas en bicicleta y moto, mientras que la comuna 19 encabeza las salidas en automóvil. La comuna 17 aparece entre las tres principales en bicicleta y moto. En automóvil el tercer lugar corresponde a la comuna 3.

5 Comunas de destino

dibujar_panel("destino", 5L)
Cuatro mapas de destino en Cali para el total, bicicleta, moto y automóvil.

Mapas 5–8. Llegadas por comuna en el total y por tipo de vehículo. Fuente: Encuesta Origen–Destino de 2015 y cartografía del curso.

Las comunas 2, 3 y 19 son los tres primeros destinos en el total y en los tres vehículos. En el total, las comunas 2 y 3 reúnen 6.660 llegadas (30,1 %). Su participación conjunta se mantiene cerca del 30 % en bicicleta (29,7 %), moto (29,8 %) y automóvil (30,4 %). Así, las diferencias en volumen entre vehículos conviven con una concentración similar en esos dos destinos.

lideres <- data.frame(
  Grupo = grupos, Viajes = fmt_n(n_grupo),
  Orígenes = vapply(grupos, top_comuna, character(1),
                     extremo = "origen", con_pct = TRUE),
  Destinos = vapply(grupos, top_comuna, character(1),
                     extremo = "destino", con_pct = TRUE)
)
tabla(lideres, "Tabla 2. Tres comunas principales por grupo y extremo. El porcentaje se calcula sobre los viajes de cada grupo.")
Tabla 2. Tres comunas principales por grupo y extremo. El porcentaje se calcula sobre los viajes de cada grupo.
Grupo Viajes Orígenes Destinos
General 22.151 C02 (2.250 · 10,2 %) / C19 (2.241 · 10,1 %) / C17 (1.762 · 8,0 %) C02 (3.773 · 17,0 %) / C03 (2.887 · 13,0 %) / C19 (2.441 · 11,0 %)
Bicicleta 1.223 C02 (122 · 10,0 %) / C19 (113 · 9,2 %) / C17 (103 · 8,4 %) C02 (194 · 15,9 %) / C03 (169 · 13,8 %) / C19 (131 · 10,7 %)
Moto 10.307 C02 (1.063 · 10,3 %) / C19 (995 · 9,7 %) / C17 (853 · 8,3 %) C02 (1.734 · 16,8 %) / C03 (1.333 · 12,9 %) / C19 (1.152 · 11,2 %)
Automóvil 8.605 C19 (923 · 10,7 %) / C02 (860 · 10,0 %) / C03 (679 · 7,9 %) C02 (1.518 · 17,6 %) / C03 (1.098 · 12,8 %) / C19 (926 · 10,8 %)

Moto (10.307) y automóvil (8.605) son las dos categorías más numerosas dentro de los viajes analizados. Los tres vehículos solicitados suman 20.135 viajes. La diferencia de 2.016 registros corresponde a otras categorías y a registros sin código, incluidos en el mapa general.

6 Lectura conjunta de orígenes y destinos

6.1 Pares con mayor frecuencia

Los ocho mapas muestran totales por comuna. La matriz cruza los dos extremos del mismo registro para identificar relaciones dirigidas. Se conservan los viajes intracomuna en la diagonal y se excluyen de la clasificación de pares entre comunas.

# Las 22 categorías de ambos factores preservan las celdas con cero.
matriz_od <- xtabs(
  ~ factor(origen, levels = 1:22) + factor(destino, levels = 1:22),
  data = urbanos
)
stopifnot(sum(matriz_od) == nrow(urbanos))
for (extremo in extremos) {
  marginal <- if (extremo == "origen") rowSums(matriz_od) else colSums(matriz_od)
  stopifnot(all(as.integer(marginal) == mapas$General[[extremo]]$viajes))
}
intra <- sum(diag(matriz_od))
inter <- sum(matriz_od) - intra
intra_cero <- sum(viajes$intra_excel == "1" &
                   viajes$origen_original == "0" &
                   viajes$destino_original == "0", na.rm = TRUE)
stopifnot(sum(viajes$intra_excel == "1", na.rm = TRUE) == intra + intra_cero)

pares <- function(datos) {
  datos |>
    filter(origen != destino) |>
    count(origen, destino, name = "viajes") |>
    arrange(desc(viajes), origen, destino)
}
pares_general <- pares(urbanos)
pares_bici <- pares(muestra_por_grupo$Bicicleta)
celdas <- as.data.frame(matriz_od)
names(celdas) <- c("origen", "destino", "viajes")
comuna_eje <- function(x) sprintf("%02d", as.integer(x))
ggplot(celdas, aes(destino, origen)) +
  geom_tile(aes(fill = viajes), color = "white", linewidth = .2) +
  geom_tile(data = filter(celdas, origen == destino), fill = NA,
            color = "#4b5667", linewidth = .37) +
  scale_x_discrete(labels = comuna_eje, expand = c(0, 0)) +
  scale_y_discrete(limits = as.character(22:1), labels = comuna_eje,
                   expand = c(0, 0)) +
  scale_fill_gradientn(colours = c("#f6f8fa", "#b8dbda", "#33898d", "#123e54"),
                       trans = "sqrt", limits = c(0, ceiling(max(celdas$viajes) / 50) * 50),
                       breaks = c(0, 25, 100, 250, 500),
                       labels = fmt_n) +
  coord_equal() +
  labs(x = "Comuna de destino", y = "Comuna de origen", fill = "Viajes") +
  theme_minimal(base_size = 11) +
  theme(panel.grid = element_blank(),
        axis.title = element_text(color = "#28475c", face = "bold"),
        axis.text = element_text(color = "#28475c"),
        legend.position = "right")
Matriz de calor origen destino con 22 comunas por eje y la diagonal delineada.

Figura 1. Matriz dirigida de viajes dentro de las 22 comunas. Los bordes oscuros marcan la diagonal de viajes intracomuna.

tabla(
  pares_general |> slice_head(n = 5) |>
    transmute(Origen = sprintf("C%02d", origen),
              Destino = sprintf("C%02d", destino),
              Viajes = fmt_n(viajes),
              `% de viajes entre comunas` = fmt_pct(viajes / inter)),
  "Tabla 3. Cinco pares dirigidos con mayor frecuencia entre comunas."
)
Tabla 3. Cinco pares dirigidos con mayor frecuencia entre comunas.
Origen Destino Viajes % de viajes entre comunas
C19 C02 492 2,4 %
C17 C02 340 1,6 %
C02 C19 306 1,5 %
C17 C22 294 1,4 %
C18 C02 256 1,2 %

El par 19 → 2 es el más frecuente entre comunas (492 viajes), frente a 306 en el sentido inverso. Esta asimetría corresponde a los registros de la encuesta. La comuna 2 también reúne 496 viajes intracomuna, la cifra más alta de la diagonal. En conjunto, 1.484 viajes (6,7 %) empiezan y terminan en la misma comuna. El indicador Intracomuna del Excel registra 22 casos adicionales en que ambos códigos son 0. Se excluyen al carecer de una comuna cartografiable.

6.2 Saldos y sensibilidad al filtro

saldos <- data.frame(
  comuna = comunas$comuna,
  salidas = as.integer(rowSums(matriz_od)),
  llegadas = as.integer(colSums(matriz_od))
) |>
  mutate(saldo = llegadas - salidas)
stopifnot(sum(saldos$saldo) == 0)

ampliado <- lapply(extremos, function(extremo) {
  datos <- filter(viajes, !is.na(.data[[extremo]]))
  agregar_comunas(datos, extremo) |>
    st_drop_geometry() |>
    arrange(desc(viajes), comuna)
})
names(ampliado) <- extremos
stopifnot(ampliado$origen$comuna[1] == ranking("General", "origen")$comuna[1],
          ampliado$destino$comuna[1] == ranking("General", "destino")$comuna[1])

tabla(
  saldos |> arrange(desc(saldo), comuna) |> slice(c(1:3, 20:22)) |>
    transmute(Comuna = sprintf("C%02d", comuna),
              Salidas = fmt_n(salidas), Llegadas = fmt_n(llegadas),
              `Saldo (llegadas − salidas)` = fmt_n(saldo)),
  "Tabla 4. Tres mayores y tres menores saldos de viajes observados."
)
Tabla 4. Tres mayores y tres menores saldos de viajes observados.
Comuna Salidas Llegadas Saldo (llegadas − salidas)
C02 2.250 3.773 1.523
C03 1.610 2.887 1.277
C22 985 1.561 576
C13 1.031 560 -471
C15 974 481 -493
C18 1.208 526 -682

Las comunas 2 y 3 registran los mayores saldos positivos. La comuna 18 tiene el mayor saldo negativo. El saldo muestra qué extremo aparece más veces en cada comuna durante la encuesta. La comuna 2 conserva el primer lugar en ambos extremos al admitir viajes con una sola comuna válida. Con esa regla alternativa registra 2.968 salidas de 28.076 y 4.810 llegadas de 27.866. Los dos totales son distintos porque esa comprobación usa un universo por extremo.

7 Conclusiones

  1. La comuna 2 concentra más salidas y llegadas. En el origen, las comunas 2 y 19 tienen volúmenes muy próximos. En el destino, las comunas 2 y 3 concentran casi un tercio de los viajes con dos extremos cartografiables. La comuna 3 asciende al segundo lugar en llegadas pese a ocupar el cuarto en salidas.
  2. El vehículo cambia la jerarquía de los orígenes más que la de los destinos. La comuna 19 lidera las salidas en automóvil, mientras que la comuna 2 lidera en bicicleta y moto. Las tres primeras comunas de destino se mantienen en los tres modos.
  3. La matriz delimita dónde revisar la conectividad ciclista. En bicicleta, 19 → 2 reúne 22 viajes observados. 18 → 3 y 2 → 3 registran 21 viajes cada uno. Estos pares orientan una revisión de la continuidad y seguridad de la red ciclista entre las comunas involucradas. La encuesta identifica los extremos del viaje y para ubicar los tramos harían falta las rutas o información complementaria de red.

Alcance. Las cifras describen la encuesta de interceptación de 2015 a partir de conteos sin expansión. La muestra depende de la cobertura del levantamiento, sus estaciones y los viajes con extremos codificados. Los mapas representan volúmenes observados por comuna. Para estimar demanda actual o evaluar intervenciones se requerirían datos recientes, ponderación muestral cuando corresponda y evidencia de la red de transporte.

8 Reproducibilidad y fuentes

El informe se genera desde el archivo .Rmd en RStudio con Knit → Knit to HTML. Se requiere Pandoc (incluido en RStudio) y los paquetes rmarkdown, knitr, readxl, dplyr, sf y ggplot2. Se puede guardar el Casos.zip original junto al informe o usar la carpeta extraída Casos/. Para rutas diferentes se pueden cambiar los parámetros del encabezado. El HTML es autocontenido. Incluye los mapas, las tablas y los bloques de código desplegables, y presenta únicamente resultados agregados.

Versiones del entorno de ejecución
Tabla 5. Versiones de R y de los paquetes utilizados.
Componente Versión
R 4.5.2
rmarkdown 2.30
knitr 1.51
readxl 1.4.5
dplyr 1.2.0
sf 1.1.2
ggplot2 4.0.2