El ozono O₃ es un gas incoloro formado por tres átomos de oxígeno que no es emitido directamente en el aire, sino que se forma a través de reacciones químicas entre las emisiones de origen natural y de origen humano de óxidos de nitrógeno (NOx) y compuestos orgánicos volátiles en presencia de la luz solar. Los cuales se mezclan como una sopa aguada en el ambiente, o el aire exterior, y cuando interactúan con la luz solar, se forma el ozono. (Air texas, S.f). Por su parte, existen dos tipos de ozono, el ozono estratosférico, conocido como la capa de ozono y el troposférico, el cual aunque es menos concentrado que el estratosférico tiene severos efectos sobre la salud y el bienestar humano como irritar el sistema respiratorio, agravar el asma y las enfermedades pulmonares crónicas, reducir la función pulmonar y disminuir la esperanza de vida. (cenapred, 2019).
Para el desarrollo de este gas nocivo el tiempo desempeña un papel importante ya que generalmente presenta mayores concentraciones en los días cálidos donde hay poca presencia de humedad y viento. Precisamente, estos factores son los que hacen a Cali una ciudad donde su calidad del aire se puede ver perjudicada por la presencia de este compuesto. Dada esta situación, el presente trabajo busca analizar el comportamiento diario del ozono en el periodo junio- noviembre del año 2018, permitiendo analizar el comportamiento del gas en la temporada de alta radiación solar (julio-agosto) y la época lluviosa (octubre-noviembre).
Lo anterior, mediante un modelo ARIMA de la serie diaria de concentración media de O₃ (µg/m³) que permita analizar las diferencias encontradas en el transcurso de este periodo de tiempo. Adicionalmente, a partir del modelo ajustado se realizo un pronóstico de 24 observaciones correspondientes al mes de noviembre de 2018, con el fin de anticipar el comportamiento de las concentraciones de O₃ a finalizando el año.
library(readxl)
library(xts)
library(fpp2)
library(tseries)
library(ggplot2)
library(dplyr)
library(lubridate)
library(gridExtra)
library(knitr)
library(kableExtra)
library(plotly)
library(tidyr)
library(moments)
library(dplyr)
library(DT)
data_raw <- read_excel("DataSeries.xlsx")
colnames(data_raw) <- c("fecha", "o3", "vel_viento", "dir_viento",
"temperatura", "humedad", "radiacion", "lluvia")
data_diario <- data_raw %>%
mutate(fecha = as.POSIXct(fecha),
fecha_dia = as.Date(fecha)) %>%
filter(month(fecha) >= 6 & month(fecha) <= 11) %>%
mutate(o3_interp = na.approx(o3, na.rm = FALSE)) %>%
group_by(fecha_dia) %>%
summarise(
horas_validas = sum(!is.na(o3_interp)),
o3_medio = ifelse(horas_validas >= 18, mean(o3_interp, na.rm = TRUE), NA),
o3_max = ifelse(horas_validas >= 18, max(o3_interp, na.rm = TRUE), NA),
o3_min = ifelse(horas_validas >= 18, min(o3_interp, na.rm = TRUE), NA),
rad_media = mean(radiacion, na.rm = TRUE),
temp_media = mean(temperatura, na.rm = TRUE),
.groups = "drop"
) %>%
filter(!is.na(o3_medio))
serie_ts <- ts(data_diario$o3_medio, frequency = 7)
n_total <- length(serie_ts)
n_train <- n_total - 24
ventana_train <- head(serie_ts, n_train)
ventana_test <- tail(serie_ts, 24)Los datos utilizados en el presente estudio corresponden a registros horarios de concentración de ozono troposférico (O₃) y variables meteorológicas (velocidad y dirección del viento, temperatura, humedad relativa, radiación solar y precipitación), correspondientes al año 2018, para la ciudad de Cali. La base de datos fue proporcionada por el docente del curso Gestión de Datos de la Universidad del Valle, como parte del material de trabajo para el desarrollo del taller sobre series de tiempo.
La base de datos original contiene registros horarios durante el año 2018 para las siguientes variables:
| Variable | Unidad | Descripción |
|---|---|---|
| Fecha/Hora | HH:MM | Marca temporal horaria |
| O₃ | µg/m³ | Concentración horaria de ozono |
| Vel. viento | m/s | Magnitud del viento horario |
| Dir. viento | ° | Orientación del viento horario |
| Temperatura | °C | Temperatura ambiente horaria |
| Humedad | % | Humedad relativa del aire |
| Radiación | W/m² | Irradiancia solar horaria |
| Lluvia | mm | Precipitación horaria |
El ozono troposférico (O₃) fue seleccionado como variable de estudio debido a su relevancia ambiental y sanitaria, especialmente en ciudades con alta radiación solar como Cali, donde las condiciones climáticas favorecen su formación fotoquímica. Además, la serie presenta un patrón estacional claramente diferenciado entre las temporadas seca y lluviosa, lo que permite analizar su comportamiento dinámico mediante modelos de series de tiempo. La disponibilidad de datos horarios completos con bajo porcentaje de valores faltantes garantiza la construcción de una serie diaria robusta, adecuada para el ajuste de un modelo ARIMA.
A partir de los registros horarios se construyó una serie diaria de concentración media de O₃ (µg/m³), siguiendo los siguientes pasos:
Imputación de datos faltantes: Los valores
horarios faltantes de O₃ fueron tratados mediante interpolación
lineal (na.approx), dado que el porcentaje de
datos faltantes fue inferior al 2.3% en todos los meses
analizados.
Agregación diaria: Se calculó el promedio diario de O₃ incluyendo únicamente aquellos días con al menos 18 horas válidas de medición (75% del total), con el fin de garantizar la representatividad del dato diario.
Selección del período de estudio: El análisis se centró en el período junio–noviembre de 2018 (183 días), el cual incluye un ciclo completo de transición desde la estación seca de mitad de año hasta la estación lluviosa de fin de año en Cali.
Frecuencia temporal: La serie diaria resultante
fue transformada en un objeto de serie de tiempo con frecuencia
semanal (frequency = 7), para capturar el patrón
de comportamiento de los días de la semana.
Grupo de entrenamiento el modelo fue entrenado con un total de 159 observaciones que corresponde al periodo comprendido entre el 1 de junio de 2018 hasta el 6 de noviembre de este mismo año.
Test Para el testeo o prueba del modelo se hizo uso de los datos correspondientes a los 24 días faltantes de noviembre.
Para modelar la serie de tiempo se utilizó el modelo ARIMA (AutoRegressive Integrated Moving Average), el cual combina tres componentes: autorregresión (AR), integración (I) y media móvil (MA). La estructura general del modelo ARIMA (p, d, q) se expresa como:
\[y_t = c + \phi_1 y_{t-1} + \cdots + \phi_p y_{t-p} + \theta_1 \varepsilon_{t-1} + \cdots + \theta_q \varepsilon_{t-q} + \varepsilon_t\] Donde:
Para la identificación del modelo se analizaron las funciones de autocorrelación (ACF) y autocorrelación parcial (PACF) de la serie, así como la prueba de raíz unitaria de Dickey-Fuller aumentada (ADF) para verificar la condición de estacionariedad. Dado que la serie original no era estacionaria, se aplicó una diferenciación de primer orden, tras la cual se confirmó la estacionariedad de la serie diferenciada.
A partir del análisis de la ACF y la PACF de la serie diferenciada se identificaron los órdenes \(p\) y \(q\) del modelo.
Se propusieron varios modelos candidatos, los cuales fueron
comparados mediante los criterios de información de Akaike
(AIC), Akaike corregido (AICc) y
Bayesiano (BIC), seleccionando el modelo con el menor
valor en dichos criterios. Este proceso fue validado mediante la función
auto.arima() de R.
El modelo seleccionado fue el ARIMA(0,1,1), cuya ecuación es:
\[\Delta O_{3,t} = \varepsilon_t - \theta_1 \varepsilon_{t-1}\]
Donde:
Esta expresión equivale a decir que el cambio diario en la concentración de O₃ se explica únicamente por el error del día anterior, sin componente autorregresiva.
Posteriormente se realizó el diagnóstico de residuales mediante el análisis gráfico de la serie de residuales, la ACF de los residuales, el histograma y el gráfico Q-Q, con el fin de verificar que los residuales se comportaran como ruido blanco y siguieran una distribución aproximadamente normal. Finalmente, se evaluó la exactitud del modelo mediante las métricas ME, RMSE, MAE, MPE, MAPE, MASE y el coeficiente de Theil’s U, y se generó el pronóstico de los 24 días del conjunto de prueba comparándolo con los valores reales observados.
datos_faltantes <- data_raw %>%
mutate(fecha = as.POSIXct(fecha),
mes = month(fecha, label = TRUE, abbr = FALSE)) %>%
filter(month(fecha) >= 6 & month(fecha) <= 11) %>%
group_by(mes) %>%
summarise(
`Horas totales` = n(),
`Horas NA` = sum(is.na(o3)),
`Horas válidas` = sum(!is.na(o3)),
`% Faltantes` = round(sum(is.na(o3)) / n() * 100, 1)
)
kable(datos_faltantes,
caption = "Datos faltantes de O₃ por mes — Junio–Noviembre 2018",
align = "c",
format = "html") %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed", "responsive"),
full_width = FALSE,
font_size = 14,
position = "center"
) %>%
row_spec(0,
background = "#0B1F3A",
color = "white",
bold = TRUE,
extra_css = "border-bottom: 3px solid #0B1F3A;") %>%
column_spec(1, bold = TRUE, width = "12em") %>%
column_spec(2:4, width = "10em") %>%
column_spec(5, width = "10em",
background = ifelse(datos_faltantes$`% Faltantes` > 1.8,
"#FFF3CD", "white")) %>%
row_spec(which.max(datos_faltantes$`% Faltantes`),
background = "#FFF3CD", bold = TRUE) %>%
row_spec(which.min(datos_faltantes$`% Faltantes`),
background = "#D6EAF8")| mes | Horas totales | Horas NA | Horas válidas | % Faltantes |
|---|---|---|---|---|
| junio | 720 | 5 | 715 | 0.7 |
| julio | 744 | 5 | 739 | 0.7 |
| agosto | 744 | 17 | 727 | 2.3 |
| septiembre | 720 | 12 | 708 | 1.7 |
| octubre | 744 | 10 | 734 | 1.3 |
| noviembre | 720 | 0 | 720 | 0.0 |
Como se observa en la tabla anterior, el porcentaje de datos faltantes a nivel horario es bajo en todos los meses (entre 0.0% y 2.3%), siendo agosto el mes con mayor proporción de vacíos (2.3%) y noviembre el único mes sin registros faltantes (0.0%). Dado que estos porcentajes son reducidos, la interpolación lineal aplicada no compromete la representatividad de la serie diaria construida.
resumen_o3 <- data_diario %>%
summarise(
`N días` = n(),
`Media (µg/m³)` = round(mean(o3_medio), 2),
`Mediana (µg/m³)` = round(median(o3_medio), 2),
`DS (µg/m³)` = round(sd(o3_medio), 2),
`CV (%)` = round(sd(o3_medio) / mean(o3_medio) * 100, 2),
`Mínimo (µg/m³)` = round(min(o3_medio), 2),
`Máximo (µg/m³)` = round(max(o3_medio), 2),
`Asimetría` = round(skewness(o3_medio), 3),
`Curtosis` = round(kurtosis(o3_medio) - 3, 3)
) %>%
pivot_longer(everything(), names_to = "Estadístico", values_to = "Valor")
kable(resumen_o3,
caption = "Estadísticas descriptivas de la concentración media diaria de O₃ — Junio-Noviembre 2018",
align = "c",
format = "html") %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed", "responsive"),
full_width = FALSE,
font_size = 14,
position = "center"
) %>%
row_spec(0,
background = "#0B1F3A",
color = "white",
bold = TRUE,
extra_css = "border-bottom: 3px solid #0B1F3A;") %>%
column_spec(1, bold = TRUE, width = "14em") %>%
column_spec(2, width = "12em") %>%
row_spec(which(resumen_o3$Estadístico %in% c("Media (µg/m³)", "Mediana (µg/m³)")),
background = "#D6EAF8") %>%
row_spec(which(resumen_o3$Estadístico == "CV (%)"),
background = "#FFF3CD") %>%
row_spec(which(resumen_o3$Estadístico %in% c("Asimetría", "Curtosis")),
background = "#F5EEF8")| Estadístico | Valor |
|---|---|
| N días | 183.000 |
| Media (µg/m³) | 31.630 |
| Mediana (µg/m³) | 31.830 |
| DS (µg/m³) | 7.710 |
| CV (%) | 24.360 |
| Mínimo (µg/m³) | 13.070 |
| Máximo (µg/m³) | 50.060 |
| Asimetría | -0.104 |
| Curtosis | -0.501 |
Durante 2018 se reportó un promedio de concentración diaria de O₃ en Cali de 31.63 µg/m³ (mediana de 31.83 µg/m³) para el periodo junio-noviembre con una desviación estándar de 7.71 µg/m³ y un coeficiente de variación de 24.4% que indica una dispersión moderada alrededor de la media. El valor mínimo fue 13.07 µg/m³, y el máximo 50.06 µg/m³; ambos fueron muy inferiores al límite de referencia de la OMS (100 µg/m³ para la media diaria), por lo que no hubo en todo el período de estudio episodios críticos para la salud pública. El coeficiente de asimetría (-0.10), cercano a cero, describe una distribución casi simétrica, mientras que la curtosis (-0.50) indica que la distribución es un poco más plana que la normal (platicúrtica), sin colas pesadas ni valores extremos que influencien.
meses_es <- c("junio", "julio", "agosto", "septiembre", "octubre", "noviembre")
resumen_mensual <- data_diario %>%
mutate(mes_num = month(fecha_dia)) %>%
filter(mes_num >= 6 & mes_num <= 11) %>%
mutate(mes = factor(mes_num,
levels = 6:11,
labels = meses_es)) %>%
group_by(mes) %>%
summarise(
Días = n(),
Media = round(mean(o3_medio), 2),
`Desv. Est.` = round(sd(o3_medio), 2),
Mínimo = round(min(o3_medio), 2),
Máximo = round(max(o3_medio), 2),
.groups = "drop"
)
kable(resumen_mensual,
caption = "Estadísticas descriptivas mensuales de O₃ medio diario (µg/m³) — Junio-Noviembre 2018",
align = "c",
format = "html") %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed", "responsive"),
full_width = FALSE,
font_size = 14,
position = "center"
) %>%
row_spec(0,
background = "#0B1F3A",
color = "white",
bold = TRUE,
extra_css = "border-bottom: 3px solid #0B1F3A;") %>%
column_spec(1, bold = TRUE, width = "12em") %>%
column_spec(2:6, width = "9em") %>%
row_spec(which.max(resumen_mensual$Media),
background = "#D6EAF8", bold = TRUE) %>%
row_spec(which.min(resumen_mensual$Media),
background = "#FDEBD0") %>%
row_spec(which.max(resumen_mensual$`Desv. Est.`),
background = "#FFF3CD") %>%
row_spec(which.min(resumen_mensual$`Desv. Est.`),
background = "#D5F5E3")| mes | Días | Media | Desv. Est. | Mínimo | Máximo |
|---|---|---|---|---|---|
| junio | 30 | 28.56 | 5.41 | 20.47 | 43.28 |
| julio | 31 | 33.06 | 5.66 | 17.60 | 41.39 |
| agosto | 31 | 36.78 | 5.62 | 27.23 | 49.49 |
| septiembre | 30 | 38.88 | 5.09 | 25.93 | 50.06 |
| octubre | 31 | 28.86 | 7.38 | 13.07 | 40.64 |
| noviembre | 30 | 23.52 | 4.94 | 13.17 | 32.79 |
Se observa una clara evolución estacional a escala mensual: la concentración promedio de O₃ aumenta a partir de junio (28,56 µg/m³), alcanza su máximo en septiembre (38,88 µg/m³) y disminuye drásticamente en octubre (28,86 µg/m³) y noviembre (23,52 µg/m³).
Este comportamiento es consistente con la fotoquímica del ozono: durante los meses de la temporada seca (agosto-septiembre), debido a la alta radiación solar y las temperaturas elevadas características de la ciudad de Cali en este período. Por el contrario, el inicio de la temporada de lluvias en octubre-noviembre trae consigo un incremento en la nubosidad y una reducción de la radiación solar, lo que limita la producción fotoquímica y disminuye las concentraciones de O₃.
colores_pastel <- c(
"jun" = "#A8D5BA",
"jul" = "#FADADD",
"ago" = "#FDEBD0",
"sept" = "#FCF3CF",
"oct" = "#D6EAF8",
"nov" = "#E8DAEF"
)
boxplot_data <- data_diario %>%
mutate(
mes_num = month(fecha_dia),
mes_label = factor(mes_num,
levels = 6:11,
labels = c("jun", "jul", "ago", "sept", "oct", "nov"))
)
p_boxplot <- plot_ly()
for (m in c("jun", "jul", "ago", "sept", "oct", "nov")) {
datos_mes <- boxplot_data %>% filter(mes_label == m)
if (nrow(datos_mes) > 0) {
p_boxplot <- p_boxplot %>%
add_trace(
data = datos_mes,
x = ~mes_label,
y = ~o3_medio,
type = "box",
name = m,
legendgroup = m,
boxpoints = "suspectedoutliers",
pointpos = 0,
jitter = 0.2,
marker = list(
size = 7,
opacity = 0.8,
color = "black",
symbol = "circle",
line = list(color = "white", width = 0.5)
),
line = list(
width = 2,
color = "black"
),
fillcolor = colores_pastel[m],
whiskerwidth = 0.5,
notched = FALSE,
hovertemplate = paste(
"<b>Mes:</b> %{x}<br>",
"<b>O₃:</b> %{y:.2f} µg/m³",
"<extra></extra>"
),
showlegend = FALSE
)
}
}
p_boxplot %>%
layout(
title = list(
text = "Distribución mensual de O₃ medio diario — Cali, Jun-Nov 2018",
font = list(size = 18, color = "#0B1F3A", family = "Cambria")
),
annotations = list(
list(
x = 0.5,
y = 1.02,
text = "Los puntos negros muestran valores atípicos del mes",
showarrow = FALSE,
font = list(size = 13, color = "#7F8C8D"),
xref = "paper",
yref = "paper",
xanchor = "center"
)
),
xaxis = list(
title = "Mes",
categoryorder = "array",
categoryarray = c("jun", "jul", "ago", "sept", "oct", "nov"),
gridcolor = "#ECF0F1",
tickfont = list(size = 13)
),
yaxis = list(
title = "O₃ medio diario (µg/m³)",
zeroline = FALSE,
gridcolor = "#ECF0F1",
tickfont = list(size = 13),
range = c(10, 55)
),
showlegend = FALSE,
hovermode = "x unified",
plot_bgcolor = "#FFFFFF",
paper_bgcolor = "#FFFFFF",
margin = list(t = 70, b = 60, l = 70, r = 30),
font = list(family = "Cambria")
)Los boxplots mensuales revelan un patrón estacional claro en las concentraciones de O₃. Agosto y septiembre presentan las concentraciones más altas y la menor variabilidad, reflejando condiciones fotoquímicas estables durante la temporada seca. Octubre destaca por su alta variabilidad, capturando la transición climática entre estaciones. Noviembre muestra las concentraciones más bajas, consistente con el inicio de la temporada de lluvias. La presencia de valores atípicos en algunos meses refleja episodios climáticos puntuales. En conjunto, estos resultados confirman que la estacionalidad climática es el factor más determinante en el comportamiento del ozono en Cali.
meses_num <- 6:11
dias <- 1:30
grid_completo <- expand.grid(
mes_num = meses_num,
dia = dias,
stringsAsFactors = FALSE
)
heatmap_data <- data_diario %>%
mutate(
mes_num = month(fecha_dia),
mes_label = month(fecha_dia, label = TRUE, abbr = TRUE),
dia = day(fecha_dia),
label = paste(
"<b>Fecha:</b>", format(fecha_dia, "%d/%m/%Y"), "<br>",
"<b>O₃:</b>", round(o3_medio, 2), "µg/m³<br>",
"<b>Temp:</b>", round(temp_media, 1), "°C"
)
) %>%
right_join(grid_completo, by = c("mes_num", "dia")) %>%
mutate(
mes_label = factor(mes_num,
levels = 6:11,
labels = c("jun", "jul", "ago", "sept", "oct", "nov"))
) %>%
arrange(mes_num, dia)
colores_o3 <- list(
c(0, "#D5F5E3"),
c(0.3, "#A9DFBF"),
c(0.5, "#81C784"),
c(0.7, "#66BB6A"),
c(0.85, "#4CAF50"),
c(1, "#2E7D32")
)
colores_temp <- list(
c(0, "#313695"),
c(0.25, "#4575B4"),
c(0.45, "#74ADD1"),
c(0.5, "#FEE090"),
c(0.65, "#FDC086"),
c(0.85, "#F46D43"),
c(1, "#D73027")
)
fig_o3 <- plot_ly(
data = heatmap_data,
x = ~dia,
y = ~mes_label,
z = ~o3_medio,
type = "heatmap",
colorscale = colores_o3,
zmin = 10,
zmax = 55,
text = ~ifelse(is.na(o3_medio), "", round(o3_medio, 0)),
textfont = list(color = "#000000", size = 8),
texttemplate = "%{text}",
hovertemplate = ~ifelse(is.na(o3_medio),
paste("<b>Fecha:</b>", dia, "/", mes_label, "<br><b>Sin datos</b>"),
label),
colorbar = list(
title = list(text = "O₃ (µg/m³)", font = list(size = 10)),
len = 0.4,
y = 0.75,
yanchor = "middle"
)
) %>%
layout(
title = list(text = "O₃", font = list(size = 14)),
xaxis = list(title = "", showticklabels = FALSE, gridcolor = "white"),
yaxis = list(
title = "Mes",
gridcolor = "white",
categoryorder = "array",
categoryarray = c("jun", "jul", "ago", "sept", "oct", "nov")
),
plot_bgcolor = "#FFFFFF",
margin = list(t = 20, b = 10)
)
fig_temp2 <- plot_ly(
data = heatmap_data,
x = ~dia,
y = ~mes_label,
z = ~temp_media,
type = "heatmap",
colorscale = colores_temp,
zmin = 22,
zmax = 28,
text = ~ifelse(is.na(temp_media), "", paste0(round(temp_media, 1))),
textfont = list(color = "#000000", size = 8),
texttemplate = "%{text}",
hovertemplate = ~ifelse(is.na(temp_media),
paste("<b>Fecha:</b>", dia, "/", mes_label, "<br><b>Sin datos</b>"),
label),
colorbar = list(
title = list(text = "Temp. (°C)", font = list(size = 10)),
len = 0.4,
y = 0.25,
yanchor = "middle"
)
) %>%
layout(
title = list(text = "Temperatura", font = list(size = 14)),
xaxis = list(title = "Día del mes", gridcolor = "white"),
yaxis = list(
title = "Mes",
gridcolor = "white",
categoryorder = "array",
categoryarray = c("jun", "jul", "ago", "sept", "oct", "nov")
),
plot_bgcolor = "#FFFFFF",
margin = list(t = 20, b = 10)
)
fig_subplot <- subplot(
fig_o3,
fig_temp2,
nrows = 2,
shareX = TRUE,
shareY = TRUE,
titleX = TRUE,
titleY = TRUE,
margin = 0.08
) %>%
layout(
title = list(
text = "Comparación: O₃ y Temperatura media diaria",
font = list(size = 18, color = "#0B1F3A", family = "Cambria"),
y = 0.98
),
annotations = list(
list(
x = 0.5,
y = 1.09,
text = "Cali, junio - noviembre 2018",
showarrow = FALSE,
font = list(size = 14, color = "#7F8C8D"),
xref = "paper",
yref = "paper",
xanchor = "center"
)
),
plot_bgcolor = "#FFFFFF",
paper_bgcolor = "#FFFFFF",
margin = list(l = 60, r = 80, t = 80, b = 60)
)
fig_subplotLa comparación de los mapas de calor de O₃ y temperatura revela una relación positiva consistente entre ambas variables. Agosto y septiembre presentan los tonos más cálidos (temperaturas > 26°C) y las concentraciones más altas de O₃ (> 40 µg/m³), confirmando que las condiciones de la temporada seca favorecen la fotoquímica. Noviembre, por el contrario, muestra los tonos más fríos y las concentraciones más bajas. Octubre destaca por su variabilidad en ambos mapas, reflejando la transición climática entre estaciones. Esta coincidencia de patrones respalda el mecanismo fotoquímico descrito en la introducción y confirma que la temperatura es un factor determinante en el comportamiento del ozono en Cali.
media_o3 <- mean(data_diario$o3_medio, na.rm = TRUE)
mediana_o3 <- median(data_diario$o3_medio, na.rm = TRUE)
densidad <- density(data_diario$o3_medio, na.rm = TRUE)
max_densidad <- max(densidad$y)
fig <- plot_ly()
fig <- fig %>% add_histogram(
data = data_diario,
x = ~o3_medio,
nbinsx = 20,
histnorm = "probability density",
marker = list(
color = "#3498DB",
line = list(color = "white", width = 1)
),
name = "Histograma",
opacity = 0.85,
hovertemplate = paste(
"<b>O₃ medio:</b> %{x:.2f} µg/m³<br>",
"<b>Densidad:</b> %{y:.3f}",
"<extra></extra>"
),
yaxis = "y"
)
fig <- fig %>% add_lines(
x = densidad$x,
y = densidad$y,
name = "Densidad",
line = list(color = "#D4A5A5", width = 3),
yaxis = "y",
hovertemplate = paste(
"<b>O₃ medio:</b> %{x:.2f} µg/m³<br>",
"<b>Densidad:</b> %{y:.3f}",
"<extra></extra>"
)
)
fig <- fig %>% layout(
title = list(
text = "Distribución de la concentración media diaria de O₃",
font = list(size = 16, color = "#0B1F3A", family = "Cambria")
),
subtitle = list(
text = "Cali, junio - noviembre 2018",
font = list(size = 13, color = "#7F8C8D")
),
xaxis = list(
title = "O₃ medio diario (µg/m³)",
zeroline = FALSE,
range = c(10, 55)
),
yaxis = list(
title = "Densidad",
zeroline = FALSE,
showgrid = TRUE
),
bargap = 0.1,
legend = list(
orientation = "h",
y = -0.15,
x = 0.5,
xanchor = "center"
),
plot_bgcolor = "#FFFFFF",
paper_bgcolor = "#FFFFFF",
shapes = list(
list(
type = "line",
x0 = media_o3, x1 = media_o3,
y0 = 0, y1 = max_densidad * 1.1,
line = list(dash = "dash", color = "#B5A6D4", width = 2)
),
list(
type = "line",
x0 = mediana_o3, x1 = mediana_o3,
y0 = 0, y1 = max_densidad * 1.1,
line = list(dash = "dot", color = "#555555", width = 2)
)
),
annotations = list(
list(
x = media_o3 + 0.8,
y = max_densidad * 0.9,
text = paste("Media:", round(media_o3, 1), "µg/m³"),
showarrow = FALSE,
font = list(color = "#111111", size = 11)
),
list(
x = mediana_o3 + 0.8,
y = max_densidad * 0.82,
text = paste("Mediana:", round(mediana_o3, 1), "µg/m³"),
showarrow = FALSE,
font = list(color = "#555555", size = 11)
)
)
)
figEl gráfico muestra cómo se distribuyeron las concentraciones diarias de ozono (O₃) en Cali durante junio-noviembre de 2018. La mayoría de los días registraron valores entre 25 y 40 µg/m³, con un promedio de 31.63 µg/m³.
La forma de la curva es bastante simétrica, lo que significa que no hubo días con concentraciones extremadamente altas o bajas que se salieran de lo normal. El valor más bajo fue de 13 µg/m³ y el más alto de 50 µg/m³. En general, el comportamiento del ozono fue estable y sin sobresaltos, con variaciones graduales típicas de un contaminante que depende de factores como el clima y la radiación solar.
cor_temp <- cor(data_diario$temp_media, data_diario$o3_medio, use = "complete.obs")
fig <- plot_ly(
data = data_diario,
x = ~temp_media,
y = ~o3_medio,
type = "scatter",
mode = "markers",
marker = list(
color = "#D6EAF8",
size = 10,
opacity = 0.7,
line = list(color = "#85C1E9", width = 1)
),
text = ~paste(
"<b>Fecha:</b>", format(fecha_dia, "%d/%m/%Y"), "<br>",
"<b>Temperatura:</b>", round(temp_media, 1), "°C<br>",
"<b>O₃:</b>", round(o3_medio, 2), "µg/m³"
),
hoverinfo = "text"
) %>%
add_lines(
x = ~temp_media,
y = ~fitted(lm(o3_medio ~ temp_media)),
line = list(color = "#D4A5A5", width = 3),
name = "Tendencia",
hoverinfo = "skip"
) %>%
layout(
title = list(
text = "Relación entre temperatura media diaria y O₃ medio diario",
font = list(size = 16, color = "#0B1F3A", family = "Cambria")
),
annotations = list(
list(
x = 0.02,
y = 0.95,
text = paste("Correlación: r =", round(cor_temp, 3)),
showarrow = FALSE,
font = list(color = "#D4A5A5", size = 13),
xref = "paper",
yref = "paper",
xanchor = "left"
),
list(
x = 0.5,
y = 1.0,
text = "Cali, junio - noviembre 2018",
showarrow = FALSE,
font = list(size = 13, color = "#7F8C8D"),
xref = "paper",
yref = "paper",
xanchor = "center"
)
),
xaxis = list(
title = "Temperatura media diaria (°C)",
zeroline = FALSE,
gridcolor = "#ECF0F1"
),
yaxis = list(
title = "O₃ medio diario (µg/m³)",
zeroline = FALSE,
gridcolor = "#ECF0F1"
),
showlegend = FALSE,
plot_bgcolor = "#FFFFFF",
paper_bgcolor = "#FFFFFF",
hovermode = "closest"
)
figEl gráfico de dispersión muestra la relación entre la temperatura media diaria y la concentración de O₃. Se observa una correlación positiva moderada (r = 0.54), lo que significa que a mayor temperatura, mayor concentración de ozono.
Este comportamiento confirma el mecanismo fotoquímico descrito en la introducción: las altas temperaturas aceleran las reacciones químicas que forman el ozono en Cali, durante los meses de la temporada seca (agosto-septiembre), las temperaturas más altas (por encima de 24°C) coinciden con las concentraciones más elevadas de O₃.
cor_rad <- cor(data_diario$rad_media, data_diario$o3_medio, use = "complete.obs")
fig <- plot_ly(
data = data_diario,
x = ~rad_media,
y = ~o3_medio,
type = "scatter",
mode = "markers",
marker = list(
color = "#D5F5E3",
size = 10,
opacity = 0.7,
line = list(color = "#81C784", width = 1)
),
text = ~paste(
"<b>Fecha:</b>", format(fecha_dia, "%d/%m/%Y"), "<br>",
"<b>Radiación:</b>", round(rad_media, 1), "W/m²<br>",
"<b>O₃:</b>", round(o3_medio, 2), "µg/m³"
),
hoverinfo = "text"
) %>%
add_lines(
x = ~rad_media,
y = ~fitted(lm(o3_medio ~ rad_media)),
line = list(color = "#D4A5A5", width = 3),
name = "Tendencia",
hoverinfo = "skip"
) %>%
layout(
title = list(
text = "Relación entre radiación solar media diaria y O₃ medio diario",
font = list(size = 16, color = "#0B1F3A", family = "Cambria")
),
annotations = list(
list(
x = 0.02,
y = 0.95,
text = paste("Correlación: r =", round(cor_rad, 3)),
showarrow = FALSE,
font = list(color = "#D4A5A5", size = 13),
xref = "paper",
yref = "paper",
xanchor = "left"
),
list(
x = 0.5,
y = 1.0,
text = "Cali, junio - noviembre 2018",
showarrow = FALSE,
font = list(size = 13, color = "#7F8C8D"),
xref = "paper",
yref = "paper",
xanchor = "center"
)
),
xaxis = list(
title = "Radiación solar media diaria (W/m²)",
zeroline = FALSE,
gridcolor = "#ECF0F1"
),
yaxis = list(
title = "O₃ medio diario (µg/m³)",
zeroline = FALSE,
gridcolor = "#ECF0F1"
),
showlegend = FALSE,
plot_bgcolor = "#FFFFFF",
paper_bgcolor = "#FFFFFF",
hovermode = "closest"
)
figEl gráfico de dispersión muestra la relación entre la radiación solar media diaria y la concentración de O₃. Se observa una correlación positiva moderada (r = 0.45), lo que indica que a mayor radiación solar, mayor concentración de ozono.
En Cali, durante el período de estudio, los días con mayor radiación solar (especialmente en septiembre) coincidieron con las concentraciones más altas de O₃. Por el contrario, en noviembre, la menor radiación solar asociada al inicio de la temporada de lluvias contribuyó a la disminución de las concentraciones del contaminante.
ciclo_diurno <- data_raw %>%
mutate(
fecha = as.POSIXct(fecha),
hora = hour(fecha)
) %>%
filter(month(fecha) >= 6 & month(fecha) <= 11) %>%
group_by(hora) %>%
summarise(
o3_promedio = mean(o3, na.rm = TRUE),
o3_mediana = median(o3, na.rm = TRUE),
o3_sd = sd(o3, na.rm = TRUE),
o3_min = min(o3, na.rm = TRUE),
o3_max = max(o3, na.rm = TRUE),
n = n()
)
plot_ly() %>%
add_ribbons(
x = ~ciclo_diurno$hora,
ymin = ~ciclo_diurno$o3_promedio - ciclo_diurno$o3_sd,
ymax = ~ciclo_diurno$o3_promedio + ciclo_diurno$o3_sd,
name = "±1 DS",
color = I("rgba(214, 234, 248, 0.5)"),
line = list(color = "rgba(214, 234, 248, 0)"),
hoverinfo = "skip"
) %>%
add_lines(
x = ~ciclo_diurno$hora,
y = ~ciclo_diurno$o3_promedio,
name = "O₃ promedio",
line = list(color = "#D4A5A5", width = 3),
hovertemplate = paste(
"<b>Hora:</b> %{x}:00<br>",
"<b>O₃ promedio:</b> %{y:.2f} µg/m³",
"<extra></extra>"
)
) %>%
add_markers(
x = ~ciclo_diurno$hora,
y = ~ciclo_diurno$o3_mediana,
name = "Mediana",
marker = list(
color = "#66BB6A",
size = 8,
line = list(color = "#2E7D32", width = 1)
),
hovertemplate = paste(
"<b>Hora:</b> %{x}:00<br>",
"<b>Mediana:</b> %{y:.2f} µg/m³",
"<extra></extra>"
)
) %>%
layout(
title = list(
text = "Ciclo diurno promedio de O₃ en Cali",
font = list(size = 18, color = "#0B1F3A", family = "Cambria")
),
annotations = list(
list(
x = 0.5,
y = 1.02,
text = "Junio - noviembre 2018 | Banda azul: ±1 desviación estándar",
showarrow = FALSE,
font = list(size = 12, color = "#7F8C8D"),
xref = "paper",
yref = "paper",
xanchor = "center"
),
list(
x = which.max(ciclo_diurno$o3_promedio) - 1,
y = max(ciclo_diurno$o3_promedio) + 2,
text = paste0("☀️ Pico: ", round(max(ciclo_diurno$o3_promedio), 1), " µg/m³"),
showarrow = TRUE,
arrowhead = 2,
arrowsize = 1,
arrowcolor = "#D73027",
font = list(color = "#D73027", size = 11, face = "bold"),
ax = 0,
ay = -35
),
list(
x = which.min(ciclo_diurno$o3_promedio) - 1,
y = min(ciclo_diurno$o3_promedio) - 2,
text = paste0("🐓 Mínimo: ", round(min(ciclo_diurno$o3_promedio), 1), " µg/m³"),
showarrow = TRUE,
arrowhead = 2,
arrowsize = 1,
arrowcolor = "#313695",
font = list(color = "#313695", size = 11, face = "bold"),
ax = 0,
ay = 35
)
),
xaxis = list(
title = "Hora del día",
tickmode = "linear",
tick0 = 0,
dtick = 2,
gridcolor = "#ECF0F1",
zeroline = FALSE,
range = c(-0.5, 23.5)
),
yaxis = list(
title = "O₃ (µg/m³)",
gridcolor = "#ECF0F1",
zeroline = FALSE
),
legend = list(
orientation = "h",
y = -0.12,
x = 0.5,
xanchor = "center",
font = list(size = 11)
),
plot_bgcolor = "#FFFFFF",
paper_bgcolor = "#FFFFFF",
margin = list(t = 75, b = 60, l = 60, r = 30),
hovermode = "x unified"
)El ciclo diurno muestra el comportamiento promedio del O₃ a lo largo del día durante el período junio-noviembre 2018. Se observa un patrón característico de los contaminantes fotoquímicos:
Mínimo en la madrugada (5:00 - 7:00 AM): Las concentraciones de O₃ alcanzan sus valores más bajos (≈ 10-11 µg/m³) debido a la destrucción nocturna del ozono y la ausencia de radiación solar para su formación.
Incremento progresivo (8:00 AM - 1:00 PM): A medida que aumenta la radiación solar y el tráfico vehicular incrementa, las concentraciones comienzan a elevarse.
Pico máximo (2:00 - 4:00 PM): Las concentraciones alcanzan su punto más alto (≈ 77.8 µg/m³) cuando la radiación solar es máxima y la fotoquímica es más intensa. Este es el período crítico para la exposición a ozono.
Descenso (5:00 PM en adelante): Con la disminución de la radiación solar, las concentraciones descienden gradualmente.
La banda azul (±1 desviación estándar) muestra la variabilidad alrededor del promedio, siendo más amplia durante las horas de la tarde cuando las condiciones climáticas pueden variar más.
ciclo_mensual <- data_raw %>%
mutate(
fecha = as.POSIXct(fecha, format = "%Y-%m-%d %H:%M:%S"),
hora = hour(fecha),
mes_num = month(fecha),
mes = factor(mes_num,
levels = 6:11,
labels = c("Jun","Jul","Aug","Sep","Oct","Nov"))
) %>%
filter(mes_num >= 6 & mes_num <= 11) %>%
group_by(mes, hora) %>%
summarise(
o3_promedio = mean(o3, na.rm = TRUE),
o3_sd = sd(o3, na.rm = TRUE),
.groups = "drop"
)
colores_mes <- c(
"Jun" = "#E74C3C",
"Jul" = "#F39C12",
"Aug" = "#F1C40F",
"Sep" = "#2ECC71",
"Oct" = "#3498DB",
"Nov" = "#9B59B6"
)
fig <- plot_ly()
for (m in c("Jun", "Jul", "Aug", "Sep", "Oct", "Nov")) {
datos <- ciclo_mensual %>% filter(mes == m)
if (nrow(datos) > 0) {
fig <- fig %>%
add_trace(
data = datos,
x = ~hora,
y = ~o3_promedio - o3_sd,
type = "scatter",
mode = "lines",
line = list(color = "rgba(0,0,0,0)"),
showlegend = FALSE,
hoverinfo = "skip"
) %>%
add_trace(
data = datos,
x = ~hora,
y = ~o3_promedio + o3_sd,
type = "scatter",
mode = "lines",
line = list(color = "rgba(0,0,0,0)"),
fill = "tonexty",
fillcolor = paste0(substr(colores_mes[m], 1, 7), "40"),
showlegend = FALSE,
hoverinfo = "skip"
) %>%
add_trace(
data = datos,
x = ~hora,
y = ~o3_promedio,
type = "scatter",
mode = "lines+markers",
name = m,
line = list(color = colores_mes[m], width = 3),
marker = list(color = colores_mes[m], size = 8),
hovertemplate = paste0(
"<b>", m, "</b><br>",
"Hora: %{x}:00<br>",
"O₃: %{y:.1f} µg/m³",
"<extra></extra>"
)
)
}
}
fig %>%
layout(
title = list(
text = "Ciclo diurno de O₃ por mes — Cali, Jun-Nov 2018",
font = list(size = 20, color = "#0B1F3A", family = "Cambria")
),
annotations = list(
list(
x = 0.5,
y = 1.02,
text = "La banda sombreada muestra ±1 desviación estándar",
showarrow = FALSE,
font = list(size = 12, color = "#7F8C8D"),
xref = "paper",
yref = "paper",
xanchor = "center"
)
),
xaxis = list(
title = "Hora del día",
tickmode = "linear",
tick0 = 0,
dtick = 4,
gridcolor = "#ECF0F1",
zeroline = FALSE,
range = c(-0.5, 23.5)
),
yaxis = list(
title = "O₃ promedio (µg/m³)",
gridcolor = "#ECF0F1",
zeroline = FALSE
),
legend = list(
title = list(text = "Mes"),
orientation = "h",
y = -0.12,
x = 0.5,
xanchor = "center",
traceorder = "normal"
),
plot_bgcolor = "#FFFFFF",
paper_bgcolor = "#FFFFFF",
margin = list(t = 80, b = 70, l = 60, r = 30),
hovermode = "x unified"
)El ciclo diurno por mes muestra el comportamiento horario promedio del O₃ para cada mes del período junio-noviembre 2018. Se observa un patrón consistente con la fotoquímica del ozono:
Pico máximo entre las 2:00 y 4:00 PM: Todos los meses presentan su concentración más alta en la tarde, cuando la radiación solar es máxima. Septiembre destaca con el pico más alto (> 50 µg/m³), mientras que noviembre presenta el más bajo (~ 30 µg/m³).
Mínimo al amanecer (5:00 - 7:00 AM): Las concentraciones más bajas ocurren en la madrugada, con valores entre 5 y 10 µg/m³, debido a la destrucción nocturna del ozono.
Rango de variación: La diferencia entre el pico máximo y el mínimo matutino es mayor en septiembre (≈ 45 µg/m³) y menor en noviembre (≈ 20 µg/m³), reflejando la intensidad de la actividad fotoquímica.
Estacionalidad: Los meses de la temporada seca (agosto y septiembre) presentan curvas más altas y pronunciadas, mientras que octubre y noviembre (transición a lluvias) muestran curvas más bajas y aplanadas.
La banda sombreada muestra la variabilidad de cada mes. Septiembre tiene la banda más estrecha, lo que significa que los días de ese mes se comportaron de manera similar entre sí, reflejando condiciones climáticas estables.
plot_ly(
data = data_diario,
x = ~fecha_dia,
y = ~o3_medio,
type = "scatter",
mode = "lines"
) %>%
layout(
title = "Serie diaria de O₃ en Cali",
xaxis = list(title = "Fecha"),
yaxis = list(title = "Concentración media diaria de O₃")
)El gráfico anterior presenta la evolución temporal de las concentraciones medias diarias de ozono (O₃) registradas en la ciudad de Cali durante el período comprendido entre junio y noviembre del año 2018. En este, se observa que la serie presenta oscilaciones considerables y cambios en su nivel a lo largo del tiempo. En los primeros meses se evidencia una tendencia creciente que alcanza sus valores máximos hacia la mitad del período de estudio, posteriormente en el mes de septiembre se observa una disminución progresiva, sin embargo, finalizando la serie se observa una recuperación parcial. La serie no presenta una media constante a lo largo del tiempo, dado que se evidencia una marcada variabilidad diaria, con valores que fluctúan aproximadamente entre 13 y 50 unidades, evidencia que sugiere que la serie no es estacionaria.
La conclusión visual anterior es confirmada mediante la aplicación de la prueba Dickey-Fuller aumentada (ADF), la cual indica la presencia de una raíz unitaria y la necesidad de aplicar un proceso de diferenciación para estabilizar la serie antes de llevar acabo el modelo ARIMA.
adf_orig <- adf.test(ventana_train)
resultado_adf <- data.frame(
Prueba = "ADF",
Estadistico = round(adf_orig$statistic, 4),
`Valor p` = round(adf_orig$p.value, 4),
Decision = ifelse(
adf_orig$p.value < 0.05,
"Rechazar H₀",
"No rechazar H₀"
),
Interpretacion = ifelse(
adf_orig$p.value < 0.05,
"Serie estacionaria",
"Serie no estacionaria"
)
)
resultado_adf %>%
kable(
caption = "Resultados de la prueba ADF para la serie Original de O3",
align = c("c", "c", "c", "c", "c")
) %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE
)| Prueba | Estadistico | Valor.p | Decision | Interpretacion | |
|---|---|---|---|---|---|
| Dickey-Fuller | ADF | -2.3478 | 0.4313 | No rechazar H₀ | Serie no estacionaria |
De esta manera, dado que el valor-p obtenido (0.4313) es mayor al nivel de significancia del 5%, la hipótesis nula no se rechaza. Por tanto, no existe evidencia estadística suficiente para afirmar de que la serie de O3 es estacionaria en el periodo analizado, sugiriendo cambios de la media a lo largo del tiempo y afirmando la importancia de aplicar una diferenciación para lograr la estacionariedad de la serie.
grid.arrange(
ggAcf(ventana_train, lag.max = 60) +
ggtitle("ACF — O3 medio diario") +
theme_minimal(),
ggPacf(ventana_train, lag.max = 60) +
ggtitle("PACF — O3 medio diario") +
theme_minimal(),
nrow = 1
)Precisamente, la función de autocorrelación (ACF) presenta un comportamiento característico de una serie no estacionaria, al tener valores elevados en los primeros retardos y una disminución gradual de las autocorrelaciones mediante aumenta el número de rezagos. Del mismo modo, presenta varias autocorrelaciones que superan las bandas de confianza, sugieriendo una fuerte dependencia temporal entre las observaciones.
Por su parte, la función de autocorrelación parcial (PACF) presenta un pico significativo en el primer retardo y algunos coeficientes adicionales de menor magnitud. Sin embargo, la mayoría de los siguientes retardos se encuentran dentro de las bandas de confianza, sugiriendo que la dependencia temporal se concentra principalmente en los primeros rezagos de la serie.
De esta manera, los resultados obtenidos a partir del análisis visual de la serie junto con el comportamiento de las funciones de autocorrelación (ACF) y autocorrelación parcial (PACF), sugieren que la serie original no cumple el supuesto de estacionariedad. Debido a esto, se requiere aplicar una diferenciación de primer orden para estabilizar su media y por medio de una nueva aplicación de la prueba Dickey-Fuller aumentada (ADF) verificar el cumplimiento del supuesto de estacionariedad requerido para la estimación de un modelo ARIMA.
serie_diff <- diff(ventana_train) %>% na.omit()
adf_diff <- adf.test(serie_diff)
resultado_adf_diff <- data.frame(
Serie = "Serie diferenciada (d = 1)",
Estadistico = round(adf_diff$statistic, 4),
`Valor p` = round(adf_diff$p.value, 4),
Decision = ifelse(
adf_diff$p.value < 0.05,
"Rechazar H₀",
"No rechazar H₀"
),
Interpretacion = ifelse(
adf_diff$p.value < 0.05,
"Serie estacionaria",
"Serie no estacionaria"
)
)
resultado_adf_diff %>%
kable(
caption = "Resultados de la prueba ADF para la serie diferenciada de O3",
align = c("c", "c", "c", "c", "c")
) %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE
)| Serie | Estadistico | Valor.p | Decision | Interpretacion | |
|---|---|---|---|---|---|
| Dickey-Fuller | Serie diferenciada (d = 1) | -7.3293 | 0.01 | Rechazar H₀ | Serie estacionaria |
Al aplicar la diferenciación de la serie, se obtiene un valor-p de 0.01, menor al nivel de significancia del 5%. De esta manera se rechaza la hipótesis nula de presencia de raíz unitaria y se concluye que existe la evidencia suficiente para afirmar que la serie diferenciada es estacionaria. Este resultado indica que la diferenciación logró estabilizar la media de la serie, cumpliendo el supuesto de estacionariedad.
datos_diff <- data.frame(
fecha = data_diario$fecha_dia[2:n_train],
dO3 = as.numeric(serie_diff)
)
plot_ly(
datos_diff,
x = ~fecha,
y = ~dO3,
type = "scatter",
mode = "lines",
hovertemplate = paste(
"<b>Fecha:</b> %{x}<br>",
"<b>ΔO₃:</b> %{y:.2f} µg/m³",
"<extra></extra>"
)
) %>%
layout(
title = "Serie diferenciada de O₃ (d = 1)",
xaxis = list(title = "Fecha"),
yaxis = list(title = "ΔO₃ (µg/m³)"),
showlegend = FALSE,
shapes = list(
list(
type = "line",
x0 = min(datos_diff$fecha),
x1 = max(datos_diff$fecha),
y0 = 0,
y1 = 0,
line = list(dash = "dash", color = "gray")
)
)
)Se evidencia que una diferenciación de primer orden es suficiente para alcanzar la estacionariedad requerida. Ya que, a diferencia de la serie original, la serie diferenciada presenta oscilaciones alrededor de un valor cercano a cero, sin la existencia de tendencias marcadas de aumento o disminución de las concentraciones de esta molécula entre junio y noviembre del año 2018. Indicando que efectivamente los cambios sistemáticos de la serie original fueron eliminados mediante la diferenciación.
Asimismo, la dispersión de los datos se mantiene relativamente constante a lo largo de los 6 meses. Los picos positivos y negativos observados en el gráfico corresponden a variaciones puntuales entre días consecutivos y no a cambios estructurales en el comportamiento de la serie.
Desde una perspectiva ambiental, este comportamiento puede estar asociado a la influencia de factores meteorológicos. De acuerdo con la Agencia de Protección Ambiental de los Estados Unidos (EPA), la formación de ozono se ve más favorecida por condiciones soleadas y de altas temperaturas que por la lluvia o temperaturas bajas. De esta manera, las variaciones obtenidas, podrían estar relacionadas a los cambios climáticos diarios en la ciudad de Cali durante el periodo de estudio.
grid.arrange(
ggAcf(serie_diff, lag.max = 40) +
ggtitle("ACF — ΔO₃ (diferenciada)") + theme_minimal(),
ggPacf(serie_diff, lag.max = 40) +
ggtitle("PACF — ΔO₃ (diferenciada)") + theme_minimal(),
nrow = 1
)Al analizar conjuntamente ambas funciones, se observa que la serie diferenciada ya no presenta el decrecimiento lento de la serie original. Por el contrario, las autocorrelaciones se reducen rápidamente y la mayoría de los coeficientes permanecen dentro de las bandas de confianza.
Por su parte, en el gráfico del ACF se muestran picos significativos que sobrepasan las bandas de confianza en los primeros lags, particularme, los más notorios parecen estar alrededor de los lags 7, 14, 21, 28, sugiriendo un patrón estacional semanal.
Asimismo, el gráfico del PACF presenta marcados cortes y picos significativos especialmente en los primeros lags y luego en los lags estacionales, del mismo modo, se evidencia una disminución más pronunciada que en los primeros lags del ACF.
A partir del análisis de las funciones de autocorrelación (ACF) y autocorrelación parcial (PACF) de la serie diferenciada, se identificaron patrones que permitieron proponer diferentes modelos ARIMA como candidatos.
En la ACF, el primer rezago presenta una autocorrelación significativa y negativa que excede las bandas de confianza. A partir del segundo rezago, la mayoría de los coeficientes se encuentran dentro de ellas, presentando únicamente algunos pequeños picos aislados alrededor de los rezagos 7, 14 y 21. Este comportamiento, caracterizado por un corte abrupto después del primer rezago, es consistente con la presencia de una componente de media móvil de orden uno, sugiriendo un valor q = 1.
Por su parte, la PACF muestra un coeficiente negativo significativo en el primer rezago, cercano a -0.4, mientras que los rezagos 2 y 3 presentan valores de menor magnitud que aún resultan importantes. La disminución gradual de los coeficientes a medida que aumentan los rezagos sugiere la existencia de una componente autorregresiva de bajo orden, particularmente AR(1) o AR(2), lo que indica valores potenciales de p = 1 o p = 2.
Con base en estos resultados, se seleccionaron como modelos candidatos ARIMA(0,1,1), ARIMA(1,1,0), ARIMA(1,1,1), ARIMA(2,1,1) y ARIMA(1,1,2). Adicionalmente, se consideró el modelo sugerido por la función auto.arima() como referencia automática para comparar los resultados obtenidos mediante la identificación gráfica.
Finalmente, la selección del modelo se realizó mediante la comparación de los criterios de información, en particular el AICc (Akaike Information Criterion corregido), de este modo, se seleccionó el modelo que presenta el menor valor en este indicador, dado que proporciona el mejor equilibrio entre calidad de ajuste y complejidad del modelo.
modelo_A <- Arima(ventana_train, order = c(0, 1, 1))
modelo_B <- Arima(ventana_train, order = c(1, 1, 0))
modelo_C <- Arima(ventana_train, order = c(1, 1, 1))
modelo_D <- Arima(ventana_train, order = c(2, 1, 1))
modelo_E <- Arima(ventana_train, order = c(1, 1, 2))
modelo_auto <- auto.arima(ventana_train)
lista_modelos <- list(
modelo_A,
modelo_B,
modelo_C,
modelo_D,
modelo_E,
modelo_auto
)
nombres_modelos <- c(
"ARIMA(0,1,1)",
"ARIMA(1,1,0)",
"ARIMA(1,1,1)",
"ARIMA(2,1,1)",
"ARIMA(1,1,2)",
paste0(
"auto.arima → ARIMA(",
modelo_auto$arma[1], ",",
modelo_auto$arma[6], ",",
modelo_auto$arma[2], ")"
)
)
tabla_modelos <- data.frame(
Modelo = nombres_modelos,
AIC = round(sapply(lista_modelos, AIC), 2),
AICc = round(sapply(lista_modelos, function(x) x$aicc), 2),
BIC = round(sapply(lista_modelos, BIC), 2),
LogLik = round(sapply(lista_modelos, logLik), 2)
)
fila_mejor <- which.min(tabla_modelos$AICc[1:5])
tabla_modelos <- tabla_modelos %>%
arrange(AICc)
mejor_modelo <- nombres_modelos[fila_mejor]
fila_resaltada <- which(tabla_modelos$Modelo == mejor_modelo)
kable(
tabla_modelos,
caption = "Comparación de modelos ARIMA candidatos y modelo auto.arima()",
align = "c"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE
) %>%
row_spec(
fila_resaltada,
background = "#D6EAF8",
bold = TRUE
)| Modelo | AIC | AICc | BIC | LogLik |
|---|---|---|---|---|
| ARIMA(0,1,1) | 978.60 | 978.68 | 984.73 | -487.30 |
| auto.arima → ARIMA(0,1,1) | 978.60 | 978.68 | 984.73 | -487.30 |
| ARIMA(1,1,1) | 979.33 | 979.48 | 988.51 | -486.66 |
| ARIMA(2,1,1) | 981.31 | 981.57 | 993.56 | -486.65 |
| ARIMA(1,1,2) | 981.31 | 981.57 | 993.56 | -486.65 |
| ARIMA(1,1,0) | 1002.56 | 1002.64 | 1008.68 | -499.28 |
De esta manera, el modelo ARIMA(0,1,1) obtuvo un valor de 978.60 en el criterio del AIC y en el criterio de AICc un valor de 978.68, los valores más bajos entre todos modelos en ambos criterios, lo que lo hace el mejor modelo en términos de equilibrio entre ajuste y simplicidad.
En cuanto al criterio de Información Bayesiano (BIC) que permite comparar varios modelos y seleccionar aquel que logra un buen ajuste utilizando la menor cantidad de parámetros, nuevamente el modelo ARIMA(0,1,1) obtiene el menor valor de 984.73 reforzando que es el modelo más simple y eficiente. Por su parte, se evidencia que efectivamente los modelos con más parámetros son descartados inmediatamente por el BIC, como pasa con el modelo (2,1,1) y (1,1,2).
Sin embargo, en cuanto al criterio de LogLik los modelos ARIMA(2,1,1) y ARIMA(1,1,2) presentan los valores más altos, indicando que ajustan mejor los datos al modelo, no obstante, esto no compensa los parámetros adicionales anteriores que en estos dos modelos no son cumplidos, por su parte el modelo ARIMA(1,1,0) presenta un bajo desempeño al tener el mayor valor de LogLik.
De esta manera el modelo escogido es el modelo ARIMA(0,1,1), decisión que es respaldada por el resultado de la función auto.arima, la cual llega a la misma conclusión. Este resultado es coherente con el análisis previo de la ACF de la serie diferenciada, donde únicamente el rezago 1 resultó significativo, indicando que un solo término de media móvil es suficiente para capturar la dinámica de la serie de O₃.
aicc_vals <- sapply(lista_modelos, function(m) m$aicc)
modelo_final <- lista_modelos[[which.min(aicc_vals)]]Por su parte, el gráfico de residuales ubicado en la parte de arriba, ilustra que los residuales oscilan alrededor de cero sin mostrar tendencia ni patrones sistemáticos, lo que indica que el modelo capturó adecuadamente la estructura de la serie. Del mismo modo, en la parte izquierda inferior se encuentra el ACF de residuales, el cual muestra que la mayoría de los rezagos se encuentran dentro de las bandas de confianza, con excepción de algunos picos leves en rezagos alrededor de 14 y 21, dado esto, se concluye que los residuales se comportan como ruido blanco, condición necesaria para validar el modelo. Por último, en la parte inferior derecha el histograma indica que los residuales siguen una distribución aproximadamente normal y simétrica alrededor de cero, con la curva teórica normal ajustando bien al histograma, este comportamiento sustenta el supuesto de normalidad de los errores, lo que valida las inferencias realizadas a partir del modelo ARIMA(0,1,1).
resid <- residuals(modelo_final)
lb <- Box.test(resid, lag = 13, type = "Ljung-Box")
jb <- tseries::jarque.bera.test(resid)
arch <- Box.test(resid^2, lag = 13, type = "Ljung-Box")
pruebas_df <- data.frame(
Prueba = c("Ljung-Box (independencia)", "Jarque-Bera (normalidad)",
"ARCH (homocedasticidad)"),
Hipótesis = c("H₀: residuales son ruido blanco",
"H₀: residuales son normales",
"H₀: varianza es constante"),
Estadístico = round(c(lb$statistic, jb$statistic, arch$statistic), 4),
`Valor-p` = round(c(lb$p.value, jb$p.value, arch$p.value), 4),
Conclusión = c(
ifelse(lb$p.value > 0.05, "No se rechaza H₀ ✓", "Se rechaza H₀ ✗"),
ifelse(jb$p.value > 0.05, "No se rechaza H₀ ✓", "Se rechaza H₀ ✗"),
ifelse(arch$p.value > 0.05, "No se rechaza H₀ ✓", "Se rechaza H₀ ✗")
)
)
kable(pruebas_df,
caption = "Pruebas de diagnóstico sobre los residuales del modelo ARIMA",
align = "c") %>%
kable_styling(bootstrap_options = c("striped", "hover"),
full_width = FALSE) %>%
column_spec(5, bold = TRUE,
color = ifelse(grepl("✓", pruebas_df$Conclusión),
"#155724", "#721c24"))| Prueba | Hipótesis | Estadístico | Valor.p | Conclusión |
|---|---|---|---|---|
| Ljung-Box (independencia) | H₀: residuales son ruido blanco | 9.2771 | 0.7517 | No se rechaza H₀ ✓ |
| Jarque-Bera (normalidad) | H₀: residuales son normales | 0.2745 | 0.8717 | No se rechaza H₀ ✓ |
| ARCH (homocedasticidad) | H₀: varianza es constante | 9.3899 | 0.7429 | No se rechaza H₀ ✓ |
Para confirmar que el modelo ARIMA(0,1,1) es el adecuado para la serie de O₃, se realizaron tres pruebas de diagnóstico que se evidencian en la tabla anterior. En primer lugar, al obtener un valor p de 0.7517 en la prueba Ljung-Box la hipótesis nula no es rechazada, indicando que los residuales son independientes entre sí y no presentan autocorrelación. Así mismo, permite identificar que el modelo capturó correctamente la estructura de dependencia temporal de la serie.
Posteriormente, en la prueba Jarque-Bera se obtuvo un valor p de 0.8717, indicando que no se rechaza la hipótesis nula y confirmando que los residuales siguen una distribución aproximadamente normal. Esto valida los intervalos de confianza del pronóstico.
Finalmente, se realizó la prueba de homocedasticidad (ARCH), en la cual no se rechaza la hipótesis nula de presencia de varianza constante al obtener un valor p de 0.7429, sugiriendo, que el modelo logra explicar adecuadamente los cambios de la serie.
De esta manera y como se evidencia en la tabla, los residuales cumplen los tres supuestos fundamentales, independencia, normalidad y homocedasticidad, lo que valida el modelo ARIMA(0,1,1) como una representación adecuada de la serie diaria de O₃ en la ciudad de Cali durante el periodo junio–noviembre 2018.
resid_df <- data.frame(res = as.numeric(resid))
p_qq <- ggplot(resid_df, aes(sample = res)) +
stat_qq(color = "#3498DB", alpha = 0.6, size = 1.5) +
stat_qq_line(color = "#2C3E50", linewidth = 1) +
labs(title = "Q-Q plot de residuales",
x = "Cuantiles teóricos", y = "Cuantiles muestrales") +
theme_minimal(base_size = 12) +
theme(plot.title = element_text(face = "bold"))
p_hist <- ggplot(resid_df, aes(x = res)) +
geom_histogram(aes(y = after_stat(density)), bins = 30,
fill = "#3498DB", color = "white", alpha = 0.7) +
geom_density(color = "#2C3E50", linewidth = 1) +
stat_function(fun = dnorm,
args = list(mean = mean(resid_df$res), sd = sd(resid_df$res)),
color = "#E74C3C", linewidth = 1, linetype = "dashed") +
labs(title = "Histograma de residuales",
subtitle = "Azul oscuro: densidad empírica | Rojo: N(0,σ²) teórica",
x = "Residuales", y = "Densidad") +
theme_minimal(base_size = 12) +
theme(plot.title = element_text(face = "bold"),
plot.subtitle = element_text(color = "gray50", size = 10))
grid.arrange(p_qq, p_hist, nrow = 1)Como se muestra en el gráfico Q-Q, los puntos se alinean muy cerca de la línea diagonal rosada en la parte central, lo que indica que la mayoría de los días del periodo junio–noviembre 2018 presentaron concentraciones de O₃ con errores que siguen un comportamiento normal. Las leves desviaciones en los extremos corresponden a días atípicos, como días con muy alta concentración por alta radiación solar, o días de concentración inusualmente baja durante las épocas de lluvia.
Por su parte el histograma muestra que la distribución de los residuales es simétrica y centrada en cero, siguiendo de cerca tanto la densidad empírica (línea rosada) como la curva normal teórica (línea verde punteada). La leve cola hacia la derecha refleja que ocasionalmente el modelo subestimó la concentración de O₃, lo cual es esperable en días de alta radiación solar donde la producción fotoquímica del contaminante es más intensa de lo que el modelo anticipa.
acc_train <- accuracy(modelo_final)
pron_test <- forecast(modelo_final, h = 24)
acc_test <- accuracy(pron_test, ventana_test)
kable(round(rbind(
`Entrenamiento` = acc_train[1, ],
`Prueba (24 días)` = acc_test[2, ]), 4),
caption = "Métricas de exactitud del modelo ARIMA(0,1,1)",
align = "c") %>%
kable_styling(bootstrap_options = c("striped", "hover"),
full_width = FALSE)| ME | RMSE | MAE | MPE | MAPE | MASE | ACF1 | Theil’s U | |
|---|---|---|---|---|---|---|---|---|
| Entrenamiento | 0.0541 | 5.2592 | 4.1682 | -2.3547 | 13.6035 | 0.6842 | 0.0613 | 0.0541 |
| Prueba (24 días) | -2.7519 | 5.3883 | 4.3695 | -17.4434 | 22.9770 | 0.7172 | 0.1621 | 0.9618 |
El modelo ARIMA(0,1,1) muestra un buen ajuste durante el periodo de entrenamiento, con un error promedio cercano a cero y un MAPE de 13.60%, lo que indica que en promedio el modelo se equivoca en aproximadamente el 14% del valor real, lo cual es aceptable para una la variabilidad de las condiciones de la ciudad.
En el grupo de prueba los valores evidencian que la precisión se redujo, con un MAPE de 22.97%, es decir, el modelo se aleja en promedio un 23% del valor real. Sin embargo, el MAE y el RMSE se mantienen similares entre entrenamiento y prueba, indicando que el modelo es estable y no está sobre ajustado a los datos de entrenamiento. De esta manera, el modelo logra capturar el comportamiento promedio de la serie, aunque con dificultades para reproducir las variaciones diarias del contaminante.
Mediante este modelo, se realizó el pronóstico de la concentración de O3 en el aire de la ciudad de Cali a partir del 7 de noviembre hasta el 30 del mismo mes del año 2018.
modelo_train <- Arima(ventana_train, order = c(0, 1, 1))
h <- 24
pron <- forecast(modelo_train, h = h, level = c(80, 95))
fechas_test <- tail(data_diario$fecha_dia, 24)
hist_plot <- data_diario %>%
filter(fecha_dia < min(fechas_test)) %>%
tail(60)
pron_df <- data.frame(
Fecha = fechas_test,
Pronóstico = round(as.numeric(pron$mean), 2),
`IC 80% Inf` = round(as.numeric(pron$lower[, 1]), 2),
`IC 80% Sup` = round(as.numeric(pron$upper[, 1]), 2),
`IC 95% Inf` = round(as.numeric(pron$lower[, 2]), 2),
`IC 95% Sup` = round(as.numeric(pron$upper[, 2]), 2),
check.names = FALSE
)
p_pron <- ggplot() +
geom_line(data = hist_plot,
aes(x = fecha_dia, y = o3_medio, color = "Histórico"),
linewidth = 0.8) +
geom_ribbon(data = pron_df,
aes(x = Fecha, ymin = `IC 95% Inf`, ymax = `IC 95% Sup`),
fill = "#3498DB", alpha = 0.20) +
geom_ribbon(data = pron_df,
aes(x = Fecha, ymin = `IC 80% Inf`, ymax = `IC 80% Sup`),
fill = "#3498DB", alpha = 0.40) +
geom_line(data = pron_df,
aes(x = Fecha, y = Pronóstico, color = "Pronóstico"),
linewidth = 1.2) +
scale_color_manual(
values = c("Histórico" = "#2C3E50",
"Pronóstico" = "#E74C3C")
) +
labs(
title = "Pronóstico de O₃ medio diario — 24 días (Nov 7–30 2018)",
subtitle = "Banda clara: IC 95% | Banda oscura: IC 80%",
x = "Fecha", y = "O₃ (µg/m³)", color = ""
) +
theme_minimal(base_size = 12) +
theme(plot.title = element_text(face = "bold"),
plot.subtitle = element_text(color = "gray50"),
legend.position = "bottom")
ggplotly(p_pron) %>%
layout(hovermode = "x unified")El pronóstico mediante el modelo ARIMA(0,1,1) para el periodo del 7 al 30 de noviembre de 2018 muestra, en primer lugar, que la serie en el transcurso del periodo seleccionado presenta una tendencia decreciente durante las semanas anteriores a las fechas pronosticadas, con concentraciones de O₃ que descienden desde valores cercanos a 45-50 µg/m³ a principios de octubre hasta alrededor de 15 µg/m³ hacia finales del mismo mes, lo cual, puede ser debido a la reducción de la radiación solar propia de los últimos meses del año, lo cual disminuye las concentraciones del contaminante en el aire.
Por su parte, el pronóstico ilustrado mediante la línea rosada se mantiene aproximadamente constante alrededor de los 24-25 µg/m³ durante los 24 días proyectados, lo cual es un comportamiento esperado y característico del modelo ARIMA(0,1,1), que tiende a proyectar el último nivel observado de la serie como valor futuro sin capturar las oscilaciones diarias.
Los intervalos del 80% y del 95% se van ampliando con respecto avanza el horizonte de pronóstico, mostrando que como es de esperarse para cualquier modelo de series de tiempo, la incertidumbre aumenta con el tiempo. De esta manera, aunque el pronóstico lineal que el modelo arrojó contrasta con la variabilidad observada en el periodo, esto no representa un error del modelo sino una limitación propia de su estructura, siendo consistente con las métricas de exactitud obtenidas en la sección anterior.
Los valores numéricos detallados del pronóstico se presentan en la tabla anterior, siendo consistentes con lo observado en el gráfico y confirmando que las concentraciones pronosticadas de O₃ para finales de noviembre de 2018 se mantienen dentro del rango histórico de la serie.
De esta manera, se construye un gráfico que permita comparar los valores reales con los obtenidos del pronóstico del modelo.
comparacion_df <- data.frame(
Fecha = fechas_test,
Pronóstico = round(as.numeric(pron$mean), 2),
Real = as.numeric(tail(data_diario$o3_medio, 24))
)
p_comp <- ggplot(comparacion_df, aes(x = Fecha)) +
geom_line(aes(y = Real, color = "Real"),
linewidth = 0.9) +
geom_line(aes(y = Pronóstico, color = "Pronóstico"),
linewidth = 0.9, linetype = "dashed") +
geom_point(aes(y = Real, color = "Real"),
size = 2) +
geom_point(aes(y = Pronóstico, color = "Pronóstico"),
size = 2) +
scale_color_manual(
values = c("Real" = "#2C3E50",
"Pronóstico" = "#E74C3C")
) +
labs(
title = "Comparación entre valores reales y pronosticados",
subtitle = "O₃ medio diario — Nov 7–30 2018",
x = "Fecha",
y = "O₃ (µg/m³)",
color = ""
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold"),
plot.subtitle = element_text(color = "gray50"),
legend.position = "bottom"
)
ggplotly(p_comp) %>%
layout(hovermode = "x unified")De este, se evidencia que el pronóstico del modelo ARIMA(0,1,1) se mantiene constante alrededor de los 25 µg/m³, mientras que los valores reales oscilan entre aproximadamente 13 y 32 µg/m³. El modelo logra aproximarse a los valores reales en los días donde las concentraciones se encuentran cerca del promedio, sin embargo, no logra capturar los picos ni los valores mínimos de la serie. Este comportamiento es esperado dado que el modelo ARIMA(0,1,1) está diseñado para capturar la tendencia general de la serie y no las variaciones diarias.
comparacion_df <- data.frame(
Fecha = fechas_test,
Pronóstico = as.numeric(pron$mean),
Real = as.numeric(tail(data_diario$o3_medio, 24))
)
comparacion_df$Error <- comparacion_df$Real - comparacion_df$Pronóstico
resumen_errores <- data.frame(
Métrica = c("Error Medio (ME)", "Error Absoluto Medio (MAE)",
"Raíz Error Cuadrático (RMSE)", "Error Porcentual Medio (MAPE)"),
Valor = c(
round(mean(comparacion_df$Error, na.rm = TRUE), 4),
round(mean(abs(comparacion_df$Error), na.rm = TRUE), 4),
round(sqrt(mean(comparacion_df$Error^2, na.rm = TRUE)), 4),
round(mean(abs(comparacion_df$Error) / abs(comparacion_df$Real), na.rm = TRUE) * 100, 4)
)
)
resumen_errores %>%
kable(caption = "Resumen de errores del pronóstico",
col.names = c("Métrica", "Valor"),
align = "c") %>%
kable_styling(bootstrap_options = c("striped", "hover"),
full_width = FALSE)| Métrica | Valor |
|---|---|
| Error Medio (ME) | -2.7519 |
| Error Absoluto Medio (MAE) | 4.3695 |
| Raíz Error Cuadrático (RMSE) | 5.3883 |
| Error Porcentual Medio (MAPE) | 22.9770 |
Para sustentar el gráfico comparativo se analizó la tabla de resumen de errores en la cual se obtiene un Error Medio (ME) de -2.75 lo cual muestra que el modelo tiende a sobreestimar ligeramente las concentraciones reales de O₃, es decir, en promedio predice valores 2.75 µg/m³ por encima de lo que en el escenario real ocurre.
Así mismo, el modelo se aleja en promedio entre 4 y 5 unidades de los valores reales, lo cual es razonable considerando que la serie oscila en un rango de aproximadamente 20 µg/m³. Finalmente, el Error Porcentual Medio (MAPE) de 22.98% indica que el error promedio del modelo equivale a aproximadamente el 23% del valor real, lo que representa una precisión moderada.
El análisis de la serie de tiempo de concentración media diaria de ozono troposférico (O₃) en la ciudad de Cali durante el periodo junio–noviembre de 2018 permitió identificar y ajustar un modelo ARIMA(0,1,1) como la representación ideal para el comportamiento de este compuesto contaminante. Este resultado fue consistente tanto con el análisis visual de las funciones ACF y PACF como con la selección automática mediante auto.arima(), lo que reforzó el proceso de identificación.
En cuanto al comportamiento de la serie, se evidenció que se presentaron mayores concentraciones de O₃ en los meses de julio y agosto, los cuales normalmente en Cali corresponden a la temporada seca, donde la alta radiación solar y las altas temperaturas favorecen las reacciones fotoquímicas que producen el ozono. Por el contrario, hacia octubre y noviembre, meses correspondientes a la temporada de lluvias en el Valle del Cauca, las concentraciones disminuyeron considerablemente, puesto que las condiciones del clima frío reducen la radiación disponible para la formación del contaminante y favorecen su dispersión. Este comportamiento es normal y coherente con el que presenta la ciudad, la cual, presenta notorias alternancias entre temporadas secas y lluviosas que determina en gran medida la calidad del aire y así mismo presenta grandes efectos en la salud de los ciudadanos.
Por su parte, el modelo obtuvo buenos resultados en las métricas de exactitud del grupo de entrenamiento, en el cual obtuvo un MAPE de 13.60% y un Theil’s U de 0.054, lo que indicó una buena capacidad de ajuste. Sin embargo, en el conjunto de prueba el MAPE aumentó a 22.97% y el Theil’s U a 0.96, evidenciando que el modelo se hace menos preciso al pronosticar fuera del periodo de entrenamiento, lo cual puede deberse a que las condiciones atmosféricas de finales de noviembre en Cali son diferentes de las predominantes durante el periodo de entrenamiento.
Finalmente, aunque el modelo ARIMA(0,1,1) logró capturar adecuadamente el nivel promedio de la serie y resulta útil para anticipar tendencias generales de la concentración de O₃ en Cali, no logra reproducir los cambios diarios del contaminante. Para mejorar la capacidad predictiva en futuros análisis, se recomienda explorar modelos que incorporen más de una variable meteorológica como la radiación solar, la temperatura y la precipitación, dado que estas tienen una influencia directa sobre la formación y dispersión del ozono troposférico en una ciudad como Cali, cuya ubicación en el Valle del Cauca y su clima tropical la hacen especialmente susceptible a las variaciones estacionales en la calidad del aire.
Capital Area Council of Governments. (s/f). El ozono troposférico - air central Texas. Spanish. Recuperado el 25 de junio de 2026, de https://aircentraltexas.org/es/calidad-del-aire/ozono-troposf%C3%A9rico
Centro Nacional de Prevención de Desastres. (s/f). El ozono como contaminante del aire y riesgo para la salud. gob.mx. Recuperado el 25 de junio de 2026, de https://www.gob.mx/cenapred/articulos/el-ozono-como-contaminante-del-aire-y-riesgo-para-la-salud
Us Epa, O. (2016, mayo 6). Trends in ozone adjusted for weather conditions. US EPA. https://www.epa.gov/air-trends/trends-ozone-adjusted-weather-conditions?utm_source=chatgpt.com