Pregunta de negocio: ¿qué está pasando en el sector construcción, qué señales anticipan el futuro y cómo debería reaccionar una empresa proveedora de concreto y cemento?
La construcción es uno de los sectores más procíclicos de la economía colombiana: amplifica los ciclos porque depende de crédito (tasas de interés), de la confianza de los hogares, de los subsidios de vivienda y de la inversión pública en infraestructura. Además, mueve una cadena larga de proveedores (cemento, concreto, acero, ladrillo, transporte), por lo que sus variables funcionan como termómetro adelantado de la actividad económica.
Entre 2023 y 2024 el sector atravesó una fase de contracción asociada, entre otros factores, a tasas de interés altas (la tasa de política del Banco de la República llegó a 13,25% en 2023) y a cambios en los programas de subsidio a la vivienda. Desde finales de 2023 el Banco de la República inició un ciclo gradual de recortes de tasas, lo que típicamente favorece la reactivación con algunos meses de rezago.
Concretos del Valle S.A.S. es una empresa mediana con sede en Cali que:
Sus decisiones críticas dependen de anticipar la demanda: cuánto cemento comprar a sus proveedores, cuántos camiones mezcladores (mixers) y turnos programar, cuánto crédito otorgar a clientes y cuándo invertir en capacidad.
¿Por qué este sector y esta empresa? Porque la base de datos contiene tres indicadores mensuales que cubren toda la cadena de valor de la empresa: lo que se planea construir (licencias), lo que se produce para obras formales (concreto) y lo que efectivamente demanda el mercado (despachos de cemento). Eso permite pasar de los datos a decisiones operativas concretas.
| Variable | Acrónimo | Unidad | Rol en el análisis |
|---|---|---|---|
| Despachos de cemento | DECEM | Toneladas | Demanda efectiva del mercado (variable a pronosticar) |
| Producción de concreto premezclado | CONCRETO | Metros cúbicos | Producto núcleo de la empresa; ligado a obra formal |
| Licencias de construcción | LICC | Área aprobada (m²) | Indicador de pipeline: proyectos que se construirán en los próximos meses |
Relación esperada. Se espera una relación positiva entre las tres: cuando se aprueban más licencias, meses después se vierte más concreto y se despacha más cemento. Por eso las licencias deberían comportarse como un indicador adelantado o, al menos, coincidente. Se espera también que el concreto sea más sensible que el cemento al ciclo de edificaciones formales, porque el cemento empacado incluye autoconstrucción, remodelaciones y obras civiles.
Decisión empresarial que se apoya en esta información. El plan de compras de cemento y la programación de flota y turnos del primer trimestre de 2026, además de la decisión de invertir o no en capacidad adicional de premezclado durante 2026.
auto.arima), variables de
intervención para choques identificados y efecto de Semana Santa;
diagnóstico de residuos, validación cruzada con origen
móvil y pronóstico de enero a marzo de 2026 con intervalos de
confianza.desc <- lapply(series, function(x) stl(log(x), s.window = 13, robust = TRUE))
tabla_comp <- do.call(rbind, lapply(vars, function(v) {
s <- desc[[v]]$time.series
tr <- exp(s[, "trend"])
data.frame(fecha = base$FECHA, variable = v,
valor = as.numeric(series[[v]]),
tendencia = as.numeric(tr),
yoy_tend = c(rep(NA, 12), 100 * (tr[-(1:12)] / tr[1:(length(tr) - 12)] - 1)),
yoy_acum = as.numeric(yoy12(series[[v]])),
estacional = 100 * (exp(as.numeric(s[, "seasonal"])) - 1),
irregular = as.numeric(s[, "remainder"]))
}))
tabla_comp$variable <- factor(tabla_comp$variable, levels = vars)ggplot(tabla_comp, aes(fecha)) +
geom_line(aes(y = valor, color = variable), alpha = .45) +
geom_line(aes(y = tendencia, color = variable), linewidth = 1.1) +
facet_wrap(~variable, ncol = 1, scales = "free_y") +
scale_color_manual(values = col3, guide = "none") +
scale_y_continuous(labels = function(x) fmt(x)) +
labs(title = "Series originales (línea clara) y su tendencia STL (línea gruesa)",
subtitle = "Enero 2012 – diciembre 2025", x = NULL, y = NULL) + temaggplot(subset(tabla_comp, !is.na(yoy_tend)), aes(fecha, yoy_tend, color = variable)) +
annotate("rect", xmin = as.Date("2020-03-01"), xmax = as.Date("2021-06-01"),
ymin = -Inf, ymax = Inf, fill = "grey85", alpha = .5) +
annotate("text", x = as.Date("2020-10-15"), y = 38, label = "Pandemia y\nparo nacional", size = 3.2, color = "grey30") +
geom_hline(yintercept = 0, linetype = "dashed") +
geom_line(linewidth = 1) +
scale_color_manual(values = col3) +
labs(title = "Crecimiento anual de la tendencia (YoY, %)",
subtitle = "Por encima de 0 la variable crece; por debajo, se contrae",
x = NULL, y = "%", color = NULL) + temaanual <- sapply(series, function(x) {
a <- aggregate(x, FUN = sum)
round(100 * (a / stats::lag(a, -1) - 1), 1)
})
anual <- data.frame(`Año` = 2013:2025, anual, check.names = FALSE)
kable(anual, caption = "Crecimiento anual del total del año (%)", align = "c")| Año | DECEM | CONCRETO | LICC |
|---|---|---|---|
| 2013 | 3.5 | 7.8 | 15.9 |
| 2014 | 10.2 | 7.0 | 2.4 |
| 2015 | 7.0 | 6.5 | 24.6 |
| 2016 | -5.5 | -8.7 | -20.3 |
| 2017 | -1.0 | -10.2 | -5.8 |
| 2018 | 0.2 | -3.3 | -5.9 |
| 2019 | 4.2 | 3.0 | 19.6 |
| 2020 | -10.2 | -27.4 | -29.0 |
| 2021 | 16.1 | 17.3 | 35.0 |
| 2022 | 3.6 | 36.3 | 29.9 |
| 2023 | -4.5 | -0.1 | -21.9 |
| 2024 | -5.0 | -6.9 | -23.8 |
| 2025 | 5.4 | -3.6 | 6.6 |
ult <- subset(tabla_comp, fecha >= as.Date("2025-01-01") & format(fecha, "%m") %in% c("03","06","09","12"))
rec <- reshape(ult[, c("fecha", "variable", "yoy_tend")], idvar = "fecha",
timevar = "variable", direction = "wide")
rec2 <- reshape(ult[, c("fecha", "variable", "yoy_acum")], idvar = "fecha",
timevar = "variable", direction = "wide")
out <- data.frame(Mes = paste0(mes_es[as.integer(format(rec$fecha, "%m"))], "-", format(rec$fecha, "%Y")),
`DECEM tend.` = round(rec[, 2], 1), `DECEM acum.12m` = round(rec2[, 2], 1),
`CONCRETO tend.` = round(rec[, 3], 1), `CONCRETO acum.12m` = round(rec2[, 3], 1),
`LICC tend.` = round(rec[, 4], 1), `LICC acum.12m` = round(rec2[, 4], 1),
check.names = FALSE)
kable(out, caption = "2025: YoY de la tendencia vs. crecimiento del acumulado 12 meses (%)", align = "c")| Mes | DECEM tend. | DECEM acum.12m | CONCRETO tend. | CONCRETO acum.12m | LICC tend. | LICC acum.12m |
|---|---|---|---|---|---|---|
| Mar-2025 | -0.5 | -2.8 | -6.5 | -6.5 | 3.4 | -12.3 |
| Jun-2025 | 2.9 | -1.8 | -4.4 | -7.1 | 2.9 | -7.3 |
| Sep-2025 | 6.1 | 1.8 | -2.0 | -5.3 | -3.8 | -2.7 |
| Dic-2025 | 8.2 | 5.4 | 0.3 | -3.6 | -11.8 | 6.6 |
Lectura de la tendencia.
est <- aggregate(estacional ~ variable + mes,
data = transform(tabla_comp, mes = as.integer(format(fecha, "%m"))), FUN = mean)
est$mes_lab <- factor(mes_es[est$mes], levels = mes_es)
ggplot(est, aes(mes_lab, estacional, fill = estacional > 0)) +
geom_col() + geom_hline(yintercept = 0) +
facet_wrap(~variable, ncol = 3) +
scale_fill_manual(values = c("TRUE" = "#2E75B6", "FALSE" = "#C00000"), guide = "none") +
labs(title = "Factor estacional promedio por mes",
subtitle = "% por encima o por debajo de un mes típico (promedio 2012–2025)",
x = NULL, y = "%") + tema +
theme(axis.text.x = element_text(angle = 90, vjust = .5, size = 8))tab_est <- reshape(est[, c("variable", "mes_lab", "estacional")], idvar = "mes_lab",
timevar = "variable", direction = "wide")
names(tab_est) <- c("Mes", vars)
tab_est[, vars] <- round(tab_est[, vars], 1)
kable(tab_est, caption = "Factores estacionales (%)", align = "c", row.names = FALSE)| Mes | DECEM | CONCRETO | LICC |
|---|---|---|---|
| Ene | -10.5 | -16.1 | -12.9 |
| Feb | -3.0 | 0.3 | -1.5 |
| Mar | 4.5 | 6.1 | -7.4 |
| Abr | -2.7 | -2.1 | -1.7 |
| May | 0.4 | 4.1 | 2.6 |
| Jun | -4.3 | -1.2 | -5.5 |
| Jul | 3.1 | 2.1 | -1.9 |
| Ago | 3.4 | 4.0 | -1.0 |
| Sep | 3.9 | 5.0 | 7.7 |
| Oct | 5.5 | 5.7 | -2.1 |
| Nov | 2.6 | 0.4 | 3.6 |
| Dic | -1.4 | -5.7 | 25.9 |
Lectura de la estacionalidad.
Utilidad para la empresa: planear mantenimientos de plantas y vacaciones del personal en enero y diciembre, y reforzar flota, turnos e inventario de agosto a octubre. Para leer bien un dato mensual, siempre hay que compararlo con el mismo mes del año anterior (no con el mes previo), porque la estacionalidad es fuerte.
irr <- do.call(rbind, lapply(vars, function(v) {
d <- subset(tabla_comp, variable == v)
d$z <- d$irregular / sd(d$irregular)
d
}))
atip <- subset(irr, abs(z) > 2.5)
ggplot(irr, aes(fecha, z, color = variable)) +
geom_hline(yintercept = c(-2.5, 2.5), linetype = "dotted", color = "grey40") +
geom_line(show.legend = FALSE) +
geom_point(data = atip, size = 2.3, color = "black") +
geom_text(data = subset(atip, format(fecha, "%Y-%m") != "2020-05"),
aes(label = format(fecha, "%Y-%m")), size = 2.8, hjust = -0.15, color = "black") +
facet_wrap(~variable, ncol = 1, scales = "free_y") +
scale_color_manual(values = col3) +
labs(title = "Componente irregular estandarizado",
subtitle = "Puntos fuera de ±2,5 desviaciones estándar = choques atípicos",
x = NULL, y = "Desviaciones estándar") + tema| Fecha | Variable | Desv. est. | Posible explicación económica |
|---|---|---|---|
| 2020-03 | DECEM | -3.25 | Cuarentena nacional por COVID-19 (desde el 25 de marzo de 2020) y reapertura gradual de obras |
| 2020-04 | DECEM | -11.37 | Cuarentena nacional por COVID-19 (desde el 25 de marzo de 2020) y reapertura gradual de obras |
| 2020-05 | DECEM | -3.28 | Cuarentena nacional por COVID-19 (desde el 25 de marzo de 2020) y reapertura gradual de obras |
| 2020-04 | CONCRETO | -12.32 | Cuarentena nacional por COVID-19 (desde el 25 de marzo de 2020) y reapertura gradual de obras |
| 2020-05 | CONCRETO | -2.67 | Cuarentena nacional por COVID-19 (desde el 25 de marzo de 2020) y reapertura gradual de obras |
| 2015-12 | LICC | 3.08 | Concentración de aprobaciones al cierre del año; posibles radicaciones anticipadas a cambios normativos o tarifarios |
| 2019-12 | LICC | 3.12 | Concentración de aprobaciones al cierre del año; posibles radicaciones anticipadas a cambios normativos o tarifarios |
| 2020-04 | LICC | -7.49 | Cuarentena nacional por COVID-19 (desde el 25 de marzo de 2020) y reapertura gradual de obras |
| 2020-05 | LICC | -2.67 | Cuarentena nacional por COVID-19 (desde el 25 de marzo de 2020) y reapertura gradual de obras |
| 2022-07 | LICC | 2.58 | Aprobaciones inusualmente altas; posible efecto de proyectos grandes o del cierre de gobierno |
| 2023-12 | LICC | 2.94 | Concentración de aprobaciones al cierre del año; posibles radicaciones anticipadas a cambios normativos o tarifarios |
Lectura del irregular. El choque dominante es la pandemia: en abril de 2020 los despachos de cemento y el concreto cayeron más de 11 desviaciones estándar por debajo de lo normal, algo que ningún patrón histórico podía anticipar. Hay también choques más pequeños que aparecen al modelar los despachos (sección 6): el paro camionero de junio–julio de 2016 y el paro nacional con bloqueos viales de mayo de 2021, que interrumpieron la logística de insumos y la entrega en obra (Cali fue uno de los epicentros). La lección para la empresa es que el mayor riesgo de corto plazo no viene del ciclo sino de choques logísticos y sociales, y que estos se corrigen rápido: la serie vuelve a su tendencia en uno o dos meses.
yoyraw <- sapply(series, function(x) 100 * diff(log(x), 12))
cor_tab <- round(cor(yoyraw), 2)
kable(cor_tab, caption = "Correlación de las variaciones anuales mensuales (2013–2025)")| DECEM | CONCRETO | LICC | |
|---|---|---|---|
| DECEM | 1.00 | 0.92 | 0.69 |
| CONCRETO | 0.92 | 1.00 | 0.68 |
| LICC | 0.69 | 0.68 | 1.00 |
g <- ts(sapply(series, yoy12), start = c(2012, 1), frequency = 12)
cc_par <- function(a, b, ini, fin, par, per) {
cc <- ccf(window(g[, a], start = c(ini, 1), end = c(fin, 12)),
window(g[, b], start = c(ini, 1), end = c(fin, 12)), lag.max = 12, plot = FALSE)
data.frame(rezago = round(cc$lag * 12), r = as.numeric(cc$acf), par = par, periodo = per)
}
ccdf <- rbind(cc_par("LICC", "DECEM", 2014, 2019, "Licencias vs. cemento", "2014–2019"),
cc_par("LICC", "DECEM", 2022, 2025, "Licencias vs. cemento", "2022–2025"),
cc_par("LICC", "CONCRETO", 2014, 2019, "Licencias vs. concreto", "2014–2019"),
cc_par("LICC", "CONCRETO", 2022, 2025, "Licencias vs. concreto", "2022–2025"))
ggplot(ccdf, aes(rezago, r, fill = periodo)) +
geom_col(position = "dodge") +
geom_vline(xintercept = 0, linetype = "dotted") +
facet_wrap(~par, ncol = 2) +
scale_fill_manual(values = c("#A5A5A5", "#548235")) +
labs(title = "¿Las licencias anticipan al cemento y al concreto?",
subtitle = "Correlación cruzada del crecimiento acumulado 12 meses (excluye pandemia)\nRezago negativo = las licencias se mueven antes",
x = "Rezago (meses)", y = "Correlación", fill = NULL) + temar_lc_m6 <- with(ccdf, r[par == "Licencias vs. concreto" & periodo == "2022–2025" & rezago == -6])
r_lc_p6 <- with(ccdf, r[par == "Licencias vs. concreto" & periodo == "2022–2025" & rezago == 6])div <- subset(tabla_comp, fecha >= as.Date("2022-01-01"))
ggplot(div, aes(fecha, yoy_acum, color = variable)) +
geom_hline(yintercept = 0, linetype = "dashed") +
geom_line(linewidth = 1.1) +
scale_color_manual(values = col3) +
labs(title = "La recuperación de 2025 es desigual",
subtitle = "Crecimiento del acumulado de 12 meses (%)", x = NULL, y = "%", color = NULL) + tema¿Qué muestran las tres juntas?
Veredicto: señales mixtas con sesgo positivo. La historia del sector muestra que las salidas de ciclos bajistas anteriores empezaron de forma parecida: en 2018 el cemento dejó de caer (0,2%) mientras el concreto todavía caía (-3,3%), y tras la pandemia el concreto tuvo su mayor rebote en 2022, un año después que el cemento. Como las licencias se adelantan al concreto, la debilidad de noviembre y diciembre de 2025 es la principal alerta para el premezclado en el primer semestre de 2026: si las licencias se recuperan en los primeros meses del año, el concreto debería seguir al cemento; si no, la recuperación quedaría concentrada en obras civiles y en el canal de ferreterías.
Se elige DECEM para pronosticar porque es la medida más directa de la demanda que enfrenta la empresa, es la menos ruidosa de las tres y es la variable que guía el plan de compras de cemento.
Un modelo ARIMA requiere una serie estacionaria, es decir, sin tendencia y con media y varianza estables. Se revisa la serie en logaritmos.
y <- series$DECEM
ly <- log(y)
adf <- adf.test(ly)
kps <- kpss.test(ly)
kable(data.frame(
Prueba = c("ADF (H0: raíz unitaria)", "KPSS (H0: estacionaria)", "ndiffs (diferencias regulares sugeridas)", "nsdiffs (diferencias estacionales sugeridas)"),
Resultado = c(paste("p =", round(adf$p.value, 3)), paste("p =", round(kps$p.value, 3)),
ndiffs(ly), nsdiffs(ly))), caption = "Pruebas sobre log(DECEM)")| Prueba | Resultado |
|---|---|
| ADF (H0: raíz unitaria) | p = 0.01 |
| KPSS (H0: estacionaria) | p = 0.01 |
| ndiffs (diferencias regulares sugeridas) | 1 |
| nsdiffs (diferencias estacionales sugeridas) | 0 |
ggtsdisplay(diff(diff(ly), 12), main = "log(DECEM) con diferencia regular y estacional", theme = tema)Las pruebas ADF y KPSS dan señales contradictorias (algo frecuente en
series con quiebres como la pandemia), pero KPSS rechaza estacionariedad
y ndiffs sugiere una diferencia regular (d
= 1). Con la diferencia regular y la estacional, la serie queda centrada
en cero y los rezagos significativos de la ACF en 1 y 12 indican
componentes de media móvil regular y estacional.
Antes de estimar, se incorporan como regresores externos los choques identificados en la extracción de señales. Si no se incluyen, el modelo “aprende” la pandemia como si fuera un comportamiento normal y sus pronósticos y bandas de incertidumbre se distorsionan.
tt <- time(y)
ao <- function(anio, mes) as.numeric(abs(tt - (anio + (mes - 1) / 12)) < 1e-6)
X <- ts(cbind(
AO_2016_07 = ao(2016, 7), # paro camionero
AO_2020_03 = ao(2020, 3), # inicio de cuarentena
AO_2020_04 = ao(2020, 4), # cierre total de obras
AO_2020_05 = ao(2020, 5), # reapertura parcial
AO_2021_05 = ao(2021, 5), # paro nacional y bloqueos
SEMANA_SANTA = as.numeric(easter(y)) # 1 en el mes con Semana Santa
), start = c(2012, 1), frequency = 12)
modelo <- auto.arima(y, lambda = 0, xreg = X, stepwise = FALSE, approximation = FALSE)
modelo## Series: y
## Regression with ARIMA(0,1,1)(2,1,2)[12] errors
## Box Cox transformation: lambda= 0
##
## Coefficients:
## ma1 sar1 sar2 sma1 sma2 AO_2016_07 AO_2020_03
## -0.6309 0.8080 -0.4803 -1.5862 0.7199 -0.1350 -0.3920
## s.e. 0.0621 0.1694 0.0939 0.2194 0.2205 0.0319 0.0316
## AO_2020_04 AO_2020_05 AO_2021_05 SEMANA_SANTA
## -1.4012 -0.4125 -0.3044 -0.1194
## s.e. 0.0324 0.0332 0.0321 0.0116
##
## sigma^2 = 0.001397: log likelihood = 282.14
## AIC=-540.28 AICc=-538.08 BIC=-503.75
auto.arima explora de forma exhaustiva
(stepwise = FALSE) las combinaciones de órdenes y elige la
de menor AICc (criterio que premia el buen ajuste pero
castiga el exceso de parámetros). lambda = 0 indica que se
modela el logaritmo y que el pronóstico se devuelve automáticamente en
toneladas.
Modelo seleccionado: regresión con errores SARIMA(0,1,1)(2,1,2)[12] sobre el logaritmo de los despachos.
m_sin <- auto.arima(y, lambda = 0, stepwise = FALSE, approximation = FALSE)
m_air <- Arima(y, order = c(0, 1, 1), seasonal = c(0, 1, 1), xreg = X, lambda = 0)
lb <- function(m, k) Box.test(residuals(m), lag = 24, type = "Ljung-Box", fitdf = k)$p.value
cand <- data.frame(
Modelo = c("SARIMA(0,1,1)(2,1,2) + intervenciones (auto)",
"SARIMA(0,1,1)(0,1,1) + intervenciones (airline)",
paste0(sub("Box Cox transformation.*", "", as.character(m_sin)), " sin intervenciones (auto)")),
AICc = round(c(modelo$aicc, m_air$aicc, m_sin$aicc), 1),
`Desv. est. residuos (%)` = round(100 * sqrt(c(modelo$sigma2, m_air$sigma2, m_sin$sigma2)), 1),
`Ljung-Box p` = round(c(lb(modelo, 4), lb(m_air, 2), lb(m_sin, 3)), 3),
`Jarque-Bera p` = round(sapply(list(modelo, m_air, m_sin), function(m) jarque.bera.test(residuals(m))$p.value), 3),
check.names = FALSE)
kable(cand, caption = "Candidatos (AICc solo es comparable entre modelos con las mismas diferencias)")| Modelo | AICc | Desv. est. residuos (%) | Ljung-Box p | Jarque-Bera p |
|---|---|---|---|---|
| SARIMA(0,1,1)(2,1,2) + intervenciones (auto) | -538.1 | 3.7 | 0.743 | 0.638 |
| SARIMA(0,1,1)(0,1,1) + intervenciones (airline) | -525.4 | 3.9 | 0.006 | 0.560 |
| ARIMA(1,1,1)(1,0,0)[12] sin intervenciones (auto) | -199.1 | 13.1 | 0.995 | 0.000 |
El modelo sin intervenciones no deja autocorrelación, pero sus residuos tienen una desviación estándar más de tres veces mayor y no son normales (Jarque-Bera con p < 0,05): la pandemia “contamina” toda la estimación y sus intervalos de confianza serían poco confiables. El modelo airline, más sencillo, deja autocorrelación en los residuos (Ljung-Box con p < 0,05), es decir, deja información sin aprovechar. El modelo automático con intervenciones es el único que cumple los dos supuestos a la vez y tiene el menor AICc, y la validación cruzada (sección 7) confirma que su precisión es igual o mejor.
Un buen modelo debe dejar residuos que se comporten como ruido blanco: sin autocorrelación, con media cero y varianza constante, idealmente con distribución normal para que los intervalos de confianza sean confiables.
res <- residuals(modelo)
diag <- data.frame(
Prueba = c("Ljung-Box, 24 rezagos (H0: no autocorrelación)",
"Jarque-Bera (H0: normalidad)", "Shapiro-Wilk (H0: normalidad)",
"Media de los residuos"),
Resultado = c(paste("p =", round(Box.test(res, 24, "Ljung-Box", fitdf = 4)$p.value, 3)),
paste("p =", round(jarque.bera.test(res)$p.value, 3)),
paste("p =", round(shapiro.test(res)$p.value, 3)),
round(mean(res), 4)),
`Conclusión` = c("No hay autocorrelación: el modelo capturó la dinámica",
"No se rechaza normalidad", "No se rechaza normalidad",
"Prácticamente cero: sin sesgo"))
kable(diag, caption = "Pruebas sobre los residuos")| Prueba | Resultado | Conclusión |
|---|---|---|
| Ljung-Box, 24 rezagos (H0: no autocorrelación) | p = 0.743 | No hay autocorrelación: el modelo capturó la dinámica |
| Jarque-Bera (H0: normalidad) | p = 0.638 | No se rechaza normalidad |
| Shapiro-Wilk (H0: normalidad) | p = 0.558 | No se rechaza normalidad |
| Media de los residuos | -0.0008 | Prácticamente cero: sin sesgo |
Todas las raíces inversas están dentro del círculo unitario, por lo que el modelo es estacionario e invertible (estable). Los residuos no muestran autocorrelación ni se alejan de la normalidad: el modelo es válido para pronosticar.
Para medir la capacidad predictiva real se simula lo que habría pasado si se hubiera usado el modelo en el pasado: desde diciembre de 2021 hasta noviembre de 2025 (48 orígenes), en cada mes se estima el modelo solo con los datos disponibles hasta ese momento y se pronostican los siguientes 1, 2 y 3 meses. Se compara con modelos de referencia: suavizamiento exponencial (ETS), un ARIMA sin intervenciones y el naive estacional (el mismo mes del año anterior), que es la regla mínima que un modelo debe superar. Se evalúa sobre el periodo pospandemia porque se parece más a las condiciones de 2026.
especif <- list(
"SARIMA auto + interv." = function(yy, xx) Arima(yy, order = c(0,1,1), seasonal = c(2,1,2), xreg = xx, lambda = 0),
"SARIMA airline + interv." = function(yy, xx) Arima(yy, order = c(0,1,1), seasonal = c(0,1,1), xreg = xx, lambda = 0),
"ARIMA sin interv." = function(yy, xx) Arima(yy, order = arimaorder(m_sin)[1:3], seasonal = arimaorder(m_sin)[4:6], lambda = 0))
H <- 3; n <- length(y); origenes <- which(time(y) >= 2021 + 11/12 - 1e-6 & time(y) <= 2025 + 10/12 + 1e-6)
resultados <- list()
for (k in origenes) {
yy <- ts(y[1:k], start = c(2012, 1), frequency = 12)
xx <- X[1:k, , drop = FALSE]
hh <- min(H, n - k); xf <- X[(k + 1):(k + hh), , drop = FALSE]; real <- y[(k + 1):(k + hh)]
pr <- list()
for (s in names(especif)) {
m <- especif[[s]](yy, xx)
pr[[s]] <- if (grepl("sin interv", s)) forecast(m, h = hh)$mean else forecast(m, xreg = xf, h = hh)$mean
}
pr[["ETS"]] <- forecast(ets(yy, model = "ANA"), h = hh)$mean
pr[["Naive estacional"]] <- snaive(yy, h = hh)$mean
for (s in names(pr)) resultados[[length(resultados) + 1]] <-
data.frame(modelo = s, h = 1:hh, real = real, pron = as.numeric(pr[[s]]))
}
R <- do.call(rbind, resultados); R$e <- R$real - R$pron
met <- do.call(rbind, lapply(split(R, list(R$modelo, R$h)), function(d)
data.frame(Modelo = d$modelo[1], h = d$h[1], RMSE = sqrt(mean(d$e^2)),
MAE = mean(abs(d$e)), MAPE = 100 * mean(abs(d$e / d$real)))))
met <- met[order(met$h, met$MAPE), ]
kable(transform(met, RMSE = fmt(RMSE), MAE = fmt(MAE), MAPE = round(MAPE, 2)), row.names = FALSE,
caption = "Métricas de error por horizonte (toneladas y %)")| Modelo | h | RMSE | MAE | MAPE |
|---|---|---|---|---|
| SARIMA auto + interv. | 1 | 45.423 | 37.284 | 3.48 |
| SARIMA airline + interv. | 1 | 46.197 | 37.265 | 3.49 |
| ETS | 1 | 64.364 | 50.077 | 4.68 |
| Naive estacional | 1 | 87.110 | 67.137 | 6.25 |
| ARIMA sin interv. | 1 | 81.648 | 66.608 | 6.26 |
| SARIMA airline + interv. | 2 | 45.141 | 35.709 | 3.32 |
| SARIMA auto + interv. | 2 | 46.361 | 37.832 | 3.51 |
| ETS | 2 | 62.740 | 49.065 | 4.55 |
| ARIMA sin interv. | 2 | 82.044 | 65.725 | 6.09 |
| Naive estacional | 2 | 87.955 | 68.029 | 6.33 |
| SARIMA airline + interv. | 3 | 48.283 | 37.315 | 3.48 |
| SARIMA auto + interv. | 3 | 49.888 | 39.660 | 3.70 |
| ETS | 3 | 64.287 | 49.459 | 4.57 |
| Naive estacional | 3 | 88.812 | 68.906 | 6.42 |
| ARIMA sin interv. | 3 | 85.594 | 69.751 | 6.47 |
ggplot(met, aes(factor(h), MAPE, fill = reorder(Modelo, MAPE))) +
geom_col(position = "dodge") +
scale_fill_manual(values = c("#1F4E79", "#5B9BD5", "#A5A5A5", "#F4B183", "#C00000")) +
labs(title = "Error porcentual medio (MAPE) en validación cruzada",
subtitle = "Pronósticos a 1, 2 y 3 meses; 48 orígenes (dic-2021 a nov-2025)",
x = "Horizonte (meses adelante)", y = "MAPE (%)", fill = NULL) + temamape_sel <- met$MAPE[met$Modelo == "SARIMA auto + interv."]
mape_nv <- met$MAPE[met$Modelo == "Naive estacional"]
mae_sel <- met$MAE[met$Modelo == "SARIMA auto + interv."]Cómo leer las métricas:
Conclusión de la validación. El modelo seleccionado reduce el error del naive estacional casi a la mitad (de cerca de 6,3% a 3,6%) y supera al ETS y al ARIMA sin intervenciones. Está prácticamente empatado con el modelo airline, pero ese modelo no pasa el diagnóstico de residuos; ante igual precisión, se prefiere el que cumple los supuestos.
Xf <- matrix(0, 3, ncol(X), dimnames = list(NULL, colnames(X)))
Xf[, "SEMANA_SANTA"] <- 0 # Semana Santa 2026 cae en abril (Jueves y Viernes Santo: 2 y 3 de abril)
pron <- forecast(modelo, xreg = Xf, h = 3, level = c(80, 95))
prev <- as.numeric(window(y, start = c(2025, 1), end = c(2025, 3)))
tabp <- data.frame(
Mes = c("Enero 2026", "Febrero 2026", "Marzo 2026"),
`Pronóstico (t)` = fmt(pron$mean),
`IC 80%` = paste(fmt(pron$lower[, 1]), "–", fmt(pron$upper[, 1])),
`IC 95%` = paste(fmt(pron$lower[, 2]), "–", fmt(pron$upper[, 2])),
`Mismo mes 2025 (t)` = fmt(prev),
`Var. anual (%)` = round(100 * (as.numeric(pron$mean) / prev - 1), 1),
check.names = FALSE)
kable(tabp, caption = "Pronóstico puntual e intervalos de confianza", align = "lrccrc")| Mes | Pronóstico (t) | IC 80% | IC 95% | Mismo mes 2025 (t) | Var. anual (%) |
|---|---|---|---|---|---|
| Enero 2026 | 1.007.350 | 960.037 – 1.056.995 | 935.897 – 1.084.258 | 889.614 | 13.2 |
| Febrero 2026 | 1.076.511 | 1.022.706 – 1.133.147 | 995.321 – 1.164.325 | 981.553 | 9.7 |
| Marzo 2026 | 1.186.487 | 1.123.829 – 1.252.639 | 1.092.010 – 1.289.138 | 1.084.450 | 9.4 |
hist_df <- data.frame(fecha = base$FECHA, y = as.numeric(y))
hist_df <- subset(hist_df, fecha >= as.Date("2022-01-01"))
fut <- data.frame(fecha = seq(as.Date("2026-01-01"), by = "month", length.out = 3),
y = as.numeric(pron$mean),
lo80 = pron$lower[, 1], hi80 = pron$upper[, 1],
lo95 = pron$lower[, 2], hi95 = pron$upper[, 2])
ajuste <- data.frame(fecha = base$FECHA, f = as.numeric(fitted(modelo)))
ajuste <- subset(ajuste, fecha >= as.Date("2022-01-01"))
ggplot() +
geom_ribbon(data = fut, aes(fecha, ymin = lo95, ymax = hi95), fill = "#9DC3E6", alpha = .5) +
geom_ribbon(data = fut, aes(fecha, ymin = lo80, ymax = hi80), fill = "#2E75B6", alpha = .5) +
geom_line(data = ajuste, aes(fecha, f), color = "grey55", linetype = "dashed") +
geom_line(data = hist_df, aes(fecha, y), color = "#1F4E79", linewidth = 1) +
geom_line(data = rbind(tail(hist_df, 1), fut[, c("fecha", "y")]), aes(fecha, y),
color = "#C00000", linewidth = 1.1) +
geom_point(data = fut, aes(fecha, y), color = "#C00000", size = 2.5) +
scale_y_continuous(labels = function(x) fmt(x)) +
labs(title = "Despachos de cemento: histórico y pronóstico enero–marzo 2026",
subtitle = "Rojo: pronóstico puntual · bandas: intervalos de 80% y 95% · gris: ajuste del modelo",
x = NULL, y = "Toneladas") + temaInterpretación económica.
Riesgos del sector
Oportunidades
Decisiones concretas para Concretos del Valle S.A.S.
| Área | Decisión | Sustento en el análisis |
|---|---|---|
| Compras | Escalonar compras de cemento para el primer trimestre de 2026 en torno a un crecimiento del ~10% anual, con cupos flexibles hasta el límite superior del intervalo de 80% | Pronóstico SARIMA e intervalos |
| Comercial | Reforzar el canal de ferreterías y contratistas de obras civiles; ofrecer paquetes a constructoras con proyectos licenciados en 2025 | Divergencia cemento vs. concreto |
| Operaciones | Programar mantenimientos en enero; asegurar flota adicional por alquiler (no compra) para marzo y el segundo semestre | Estacionalidad y efecto de Semana Santa |
| Finanzas | Condicionar la inversión en una nueva planta o en mixers propios a dos trimestres seguidos de crecimiento positivo del concreto y de las licencias | Señales mixtas; decisiones reversibles primero |
| Riesgo | Plan de contingencia logística (inventario de seguridad, rutas alternas) ante paros | Componente irregular |
Indicadores a monitorear mensualmente
LICC_CALI): confirmación del pipeline.CONCRETO):
señal de que la recuperación llega a la obra formal.CEM_V): mercado regional directo de la
empresa.ICC) como determinantes de la demanda de vivienda.¿Quién usa esta información y para qué? El gerente de compras, para el volumen de cemento contratado; el director de operaciones, para la flota, los turnos y los mantenimientos; el director comercial, para priorizar canales; y el director financiero y la junta directiva, para decidir si se aprueba o se aplaza la inversión en capacidad.