Pontificia Universidad Javeriana Cali
Maestría en Ciencia de Datos
¿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.
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.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.# 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."
)
| 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.
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)
})
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()
}
dibujar_panel("origen", 1L)
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.
dibujar_panel("destino", 5L)
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.")
| 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.
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")
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."
)
| 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.
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."
)
| 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.
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.
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.
| 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 |
Hoja1 y Convenciones (2). Archivo de
trabajo del curso. Cartografía Comunas.shp suministrada en
el mismo paquete.