paquetes <- c("readxl", "MASS", "car", "lmtest", "dplyr", "tidyr", "ggplot2", "knitr", "kableExtra")
faltan <- paquetes[!(paquetes %in% installed.packages()[, "Package"])]
if (length(faltan) > 0) install.packages(faltan)
library(readxl)
library(MASS) # stepAIC (se carga antes de dplyr para no tapar select)
library(car) # vif
library(lmtest) # bptest, dwtest
library(dplyr)
library(tidyr)
library(ggplot2)
library(knitr)
library(kableExtra)
theme_set(theme_minimal(base_size = 12))
# para escribir los valores p en el texto
valor_p <- function(p) ifelse(p < 0.001, "p < 0.001", paste("p =", round(p, 3)))Elegí la base 1, que contiene 314 anuncios de Mazda2 publicados en OLX Colombia. La pregunta que quiero responder es qué tan bien se puede estimar el precio anunciado de un Mazda2 usado a partir de sus características (año del modelo, kilometraje y tipo de transmisión, principalmente).
Este tipo de estimación le sirve a quien va a vender su carro y necesita fijar un precio de publicación, a un comprador que quiere saber si un anuncio está caro frente a carros parecidos, o a una compraventa que recibe un carro en parte de pago y quiere una primera referencia antes del peritaje.
Hay que tener presente desde el inicio que la variable es el precio que pide el vendedor, no el precio al que finalmente se vende el carro. Los anuncios no dicen nada sobre la negociación, así que todo lo que se concluya aplica al precio de publicación.
## [1] 314 12
mazda %>%
select(precio, kilometraje, transmision, modelo, color, ciudad, departamento) %>%
head() %>%
kable(caption = "Primeras filas de la base") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| precio | kilometraje | transmision | modelo | color | ciudad | departamento |
|---|---|---|---|---|---|---|
| 34000000 | 140000 | Automática | 2008 | Gris | Medellín | Antioquia |
| 26400000 | 112000 | Automática | 2012 | Gris | Medellín | Antioquia |
| 42500000 | 102222 | Automática | 2013 | Gris | Itagüí | Antioquia |
| 91000000 | 1000 | Automática | 2022 | Gris | Envigado | Antioquia |
| 35000000 | 134500 | Automática | 2012 | Blanco | Medellín | Antioquia |
| 67000000 | 61997 | Automática | 2017 | Gris | Medellín | Antioquia |
# fecha de extracción (la columna web-scraper-order empieza con un timestamp)
marca <- as.numeric(sub("-.*", "", mazda$`web-scraper-order`))
fecha_extraccion <- as.Date(as.POSIXct(min(marca), origin = "1970-01-01"))
fecha_extraccion## [1] "2022-09-12"
La base tiene 314 filas. Las variables útiles para el análisis son:
precio: precio anunciado en pesos (variable respuesta).
Lo trabajo en millones para que los coeficientes sean fáciles de
leer.kilometraje: kilómetros reportados por el
vendedor.transmision: Automática o Mecánica (categórica
binaria).modelo: año del modelo. Como los datos se extrajeron en
septiembre de 2022 (2022-09-12), calculé la edad del carro como 2022
menos el año del modelo.color, ciudad y departamento:
categóricas con muchos niveles.link: aunque se llama así, contiene el texto del
anuncio (precio, año, km, título y barrio). No se usa como predictor,
pero sirve para revisar la calidad de los datos.Las columnas web-scraper-order,
web-scraper-start-url y link-href son
información del proceso de extracción.
vars <- c("precio", "kilometraje", "transmision", "modelo", "color", "ciudad", "departamento")
colSums(is.na(mazda[, vars]))## precio kilometraje transmision modelo color ciudad
## 0 0 0 0 0 0
## departamento
## 0
## [1] 1
## [1] 0
No hay valores faltantes y solo aparece 1 anuncio duplicado. Sin embargo, al leer el texto de los anuncios encontré problemas que no se ven en las columnas numéricas:
Los casos 1 a 4 se eliminan. Los casos 5 y 6 se corrigen, porque la información correcta se puede recuperar del mismo anuncio.
mazda$duplicado <- duplicated(mazda[, vars])
mazda <- mazda %>%
mutate(titulo = tolower(link),
otro_modelo = grepl("cx-?[0-9]|mazda ?3|mazda ?6|estacas|stepway|camioneta mazda", titulo),
salvamento = grepl("salvamen", titulo),
km_en_miles = modelo <= 2020 & kilometraje > 0 & kilometraje < 1000,
motivo = case_when(
duplicado ~ "Duplicado",
otro_modelo ~ "No es un Mazda2",
salvamento ~ "Salvamento",
precio < 15000000 ~ "Precio imposible",
modelo <= 2020 & kilometraje == 0 ~ "Km = 0 en carro usado",
TRUE ~ "Se conserva"
),
anuncio = substr(sub("^[^k]*km", "", link), 1, 60))
table(mazda$motivo)##
## Duplicado Km = 0 en carro usado No es un Mazda2
## 1 1 10
## Precio imposible Salvamento Se conserva
## 1 2 299
mazda %>%
filter(motivo != "Se conserva") %>%
transmute(id, precio_millones = precio / 1e6, modelo, kilometraje, motivo, anuncio) %>%
kable(caption = "Anuncios eliminados") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE) %>%
scroll_box(height = "300px")| id | precio_millones | modelo | kilometraje | motivo | anuncio |
|---|---|---|---|---|---|
| 2 | 26.4 | 2012 | 112000 | No es un Mazda2 | Vencambio Renault STEPWAY automático RECIBO TWINGO KIA PICAN |
| 9 | 66.9 | 2022 | 7900 | No es un Mazda2 | VENCAMBIO RENAULT STEPWAY INTENS AUTOMÁTICO 2022 RECIBO KIA |
| 30 | 119.9 | 2021 | 29000 | No es un Mazda2 | Vencambio mazda cx30 modelo 2021 grand touring lx refull 4×2 |
| 80 | 36.0 | 2011 | 117600 | No es un Mazda2 | Mazda 3 AllNew 2011. 2 litros. Full equipo.Los Alpes, Barran |
| 105 | 22.0 | 2008 | 136 | Salvamento | HERMOSO MAZDA 2 EN VETA PARA USO PERSONAL (SALVAMENTO)Los Pi |
| 106 | 23.5 | 2008 | 136000 | Salvamento | MAZDA 2 - 2008 SALVAMENTOMarsella Aloha Sector Sur, Kennedy2 |
| 113 | 22.0 | 2008 | 136 | Duplicado | Vendo hermoso Mazda 2 modelo 2008 (SALVAMENMarsella Aloha Se |
| 189 | 29.9 | 1995 | 280000 | No es un Mazda2 | Mazda Estacas 4 x 2, buen estado gas gasolinaÁrea Urbana, Ch |
| 203 | 28.0 | 2009 | 0 | Km = 0 en carro usado | Se vende Mazda 2Remanso, Villavicencio16 ago |
| 255 | 32.0 | 2007 | 115000 | No es un Mazda2 | Vehículo Mazda 3 hatchback 115.000 Km 2 dueño, Excelente es |
| 271 | 159.9 | 2022 | 0 | No es un Mazda2 | Mazda Cx5 Grand Touring 4*2 Plata | 2023Ciudad Campestre, Ca |
| 273 | 169.8 | 2019 | 36026 | No es un Mazda2 | MAZDA CX-9 2019 cl 2.5 4x4 7 psj Blindaje 2Prados del Lim |
| 280 | 6.8 | 2022 | 17200 | Precio imposible | Se vende hermoso Mazda 2 turing como nuevoLa Playa, Cali28 a |
| 285 | 35.0 | 2007 | 148000 | No es un Mazda2 | Mazda 6 blindaje 2 plus modelo 2007 mecánico full equipo,co |
| 312 | 107.5 | 2021 | 17909 | No es un Mazda2 | Venta Hermosa Camioneta Mazda Touring 2.o ATSan Pedro, CaliA |
mazda %>%
filter(motivo == "Se conserva", km_en_miles) %>%
transmute(id, modelo, km_reportado = kilometraje, km_corregido = kilometraje * 1000, anuncio) %>%
kable(caption = "Kilometrajes escritos en miles (se multiplican por 1.000)") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| id | modelo | km_reportado | km_corregido | anuncio |
|---|---|---|---|---|
| 17 | 2011 | 183 | 183000 | Mazda 2 1.5HM8La Mota, Medellín22 ago |
| 168 | 2008 | 174 | 174000 | SE VENDE Mazda 2La Ceja, Antioquia21 ago |
| 180 | 2012 | 92 | 92000 | hermoso carro Mazda 2 modelo 2012Aipe, Huilahace 6 días |
| 185 | 2008 | 133 | 133000 | MAZDA 2 MODELO 2008Yopal, Casanarehace 4 días |
| 196 | 2019 | 54 | 54000 | Mazda 2 prime sedanMira Rio II Etapa, Neiva02 sept |
| 228 | 2013 | 116 | 116000 | Vendo lindo Mazda 2 automáticoBavaria, Pereira29 ago |
| 236 | 2010 | 114 | 114000 | SE VENDE CARRO MAZDA 2 AUTOMÁTICOPaz Del Río, PereiraAyer |
| 248 | 2019 | 51 | 51000 | MAZDA 2 SEDAN GRAND TOURINGCentro, Bucaramanga04 sept |
| 272 | 2011 | 99 | 99000 | Mazda 2 mecanico 2011 Mecanico full equipo.Cañaverales - Los |
| 298 | 2008 | 129 | 129000 | Mazda 2 - 2008 automático en cueroEl Sena, Calihace 6 días |
| 304 | 2019 | 35 | 35000 | MAZDA 2 TOURING, modelo 2019. Mecánico, ÚNICO DUEÑOLili, Cal |
Con esto construí la base de trabajo y las variables del análisis:
datos <- mazda %>%
filter(motivo == "Se conserva") %>%
mutate(
kilometraje = ifelse(km_en_miles, kilometraje * 1000, kilometraje),
modelo = ifelse(id == 299, 2009, modelo),
precio_m = precio / 1e6, # precio en millones de pesos
edad = 2022 - modelo, # años de antigüedad
km_mil = kilometraje / 1000, # miles de km
transmision = factor(ifelse(grepl("^Autom", transmision), "Automática", "Mecánica"),
levels = c("Mecánica", "Automática")),
generacion = factor(ifelse(modelo >= 2016, "Nueva (2016 en adelante)", "Anterior (2008 a 2015)"),
levels = c("Anterior (2008 a 2015)", "Nueva (2016 en adelante)")),
region = case_when(
departamento == "Antioquia" ~ "Antioquia",
grepl("^Bogot", departamento) | departamento == "Cundinamarca" ~ "Bogotá y Cundinamarca",
grepl("Valle", departamento) ~ "Valle del Cauca",
departamento %in% c("Caldas", "Risaralda") | grepl("^Quind", departamento) ~ "Eje Cafetero",
TRUE ~ "Otros departamentos"
),
color_g = ifelse(color %in% c("Gris", "Blanco", "Rojo"), color, "Otro")
)
n_final <- nrow(datos)
n_final## [1] 299
Quedan 299 anuncios (se eliminaron 15). Además de las variables originales creé:
generacion: el Mazda2 cambió de generación en Colombia
con el modelo 2016, que llegó al país en 2015 con la tecnología Skyactiv
y un diseño nuevo (Autocosmos,
2015). Por eso separé los carros anteriores y posteriores a
2016.region y color_g: agrupan departamentos y
colores para no tener categorías con uno o dos anuncios.# regla de Tukey sobre toda la base
q_precio <- quantile(datos$precio_m, c(0.25, 0.75))
lim_inf <- q_precio[1] - 1.5 * IQR(datos$precio_m)
lim_sup <- q_precio[2] + 1.5 * IQR(datos$precio_m)
n_atip_global <- sum(datos$precio_m < lim_inf | datos$precio_m > lim_sup)
n_atip_km <- sum(datos$km_mil > quantile(datos$km_mil, 0.75) + 1.5 * IQR(datos$km_mil))
# la misma regla, pero dentro de cada año del modelo
datos <- datos %>%
group_by(modelo) %>%
mutate(atipico_anio = precio_m < quantile(precio_m, 0.25) - 1.5 * IQR(precio_m) |
precio_m > quantile(precio_m, 0.75) + 1.5 * IQR(precio_m)) %>%
ungroup()
ggplot(datos, aes(x = factor(modelo), y = precio_m, fill = generacion)) +
geom_boxplot(outlier.shape = 21, outlier.size = 2.5, outlier.fill = "white") +
scale_fill_manual(values = c("steelblue", "darkorange")) +
labs(title = "Precio anunciado según el año del modelo",
x = "Año del modelo", y = "Precio (millones de pesos)", fill = "Generación") +
theme(legend.position = "top")datos %>%
filter(atipico_anio) %>%
select(id, modelo, precio_m, km_mil, transmision) %>%
kable(digits = 1, caption = "Precios atípicos dentro de su año (regla de 1,5 veces el RIC)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| id | modelo | precio_m | km_mil | transmision |
|---|---|---|---|---|
| 10 | 2012 | 27.5 | 116.9 | Mecánica |
| 77 | 2011 | 22.0 | 102.0 | Mecánica |
| 78 | 2016 | 47.0 | 80.0 | Mecánica |
| 123 | 2017 | 28.9 | 12.3 | Mecánica |
| 179 | 2015 | 50.0 | 33.0 | Mecánica |
| 274 | 2017 | 72.0 | 81.0 | Automática |
| 295 | 2012 | 29.0 | 145.0 | Mecánica |
| 299 | 2009 | 25.8 | 129.0 | Automática |
| 306 | 2010 | 26.0 | 176.0 | Mecánica |
n_atip_anio <- sum(datos$atipico_anio)
n_atip_mec <- sum(datos$atipico_anio & datos$transmision == "Mecánica")Si se mira el precio en toda la base, no hay atípicos con la regla de 1,5 veces el rango intercuartílico (0 casos; el límite superior es 104.2 millones). Tampoco en el kilometraje (0 casos). Los carros más caros son sencillamente los más nuevos.
Como el precio depende tanto del año, tiene más sentido buscar atípicos dentro de cada año. Así aparecen 9 anuncios (los círculos blancos del gráfico), de los cuales 7 son mecánicos y varios tienen más kilómetros de lo normal para su año. Es decir, la mayoría tiene una razón para estar por debajo de los demás. El caso más raro es el id 123: un 2017 Touring con “12.345 km” (parece un número de relleno) anunciado a 28,9 millones, casi la mitad de lo que piden por otros 2017.
Decidí no eliminar ninguno de estos anuncios. No hay evidencia de que sean errores, y quitar datos solo porque se alejan del promedio haría que el modelo pareciera mejor de lo que es. Su efecto se revisa más adelante, en las verificaciones adicionales del Punto 2 (sección 2.6).
num <- datos %>% select(precio_m, edad, km_mil)
resumen <- data.frame(
Media = sapply(num, mean),
Mediana = sapply(num, median),
DE = sapply(num, sd),
Minimo = sapply(num, min),
Q1 = sapply(num, quantile, 0.25),
Q3 = sapply(num, quantile, 0.75),
Maximo = sapply(num, max)
)
resumen$CV <- resumen$DE / resumen$Media
rownames(resumen) <- c("Precio (millones)", "Edad (años)", "Kilometraje (miles)")
kable(resumen, digits = 2, caption = "Estadísticas descriptivas de las variables cuantitativas") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Media | Mediana | DE | Minimo | Q1 | Q3 | Maximo | CV | |
|---|---|---|---|---|---|---|---|---|
| Precio (millones) | 50.04 | 42 | 17.47 | 22 | 35.50 | 63 | 98.5 | 0.35 |
| Edad (años) | 7.06 | 7 | 4.06 | 0 | 4.00 | 10 | 14.0 | 0.57 |
| Kilometraje (miles) | 79.30 | 82 | 42.69 | 0 | 46.75 | 110 | 200.0 | 0.54 |
bind_rows(
datos %>% count(categoria = as.character(transmision)) %>% mutate(variable = "Transmisión"),
datos %>% count(categoria = as.character(generacion)) %>% mutate(variable = "Generación"),
datos %>% count(categoria = region) %>% mutate(variable = "Región"),
datos %>% count(categoria = color_g) %>% mutate(variable = "Color")
) %>%
group_by(variable) %>%
mutate(porcentaje = round(100 * n / sum(n), 1)) %>%
ungroup() %>%
select(variable, categoria, n, porcentaje) %>%
kable(caption = "Frecuencias de las variables categóricas") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| variable | categoria | n | porcentaje |
|---|---|---|---|
| Transmisión | Automática | 164 | 54.8 |
| Transmisión | Mecánica | 135 | 45.2 |
| Generación | Anterior (2008 a 2015) | 167 | 55.9 |
| Generación | Nueva (2016 en adelante) | 132 | 44.1 |
| Región | Antioquia | 80 | 26.8 |
| Región | Bogotá y Cundinamarca | 55 | 18.4 |
| Región | Eje Cafetero | 43 | 14.4 |
| Región | Otros departamentos | 71 | 23.7 |
| Región | Valle del Cauca | 50 | 16.7 |
| Color | Blanco | 76 | 25.4 |
| Color | Gris | 101 | 33.8 |
| Color | Otro | 79 | 26.4 |
| Color | Rojo | 43 | 14.4 |
datos %>%
select(`Precio (millones)` = precio_m, `Edad (años)` = edad, `Kilometraje (miles)` = km_mil) %>%
pivot_longer(everything(), names_to = "variable", values_to = "valor") %>%
ggplot(aes(x = valor)) +
geom_histogram(bins = 20, fill = "steelblue", color = "white") +
facet_wrap(~ variable, scales = "free") +
labs(title = "Distribución de las variables cuantitativas", x = NULL, y = "Número de anuncios")Algunas cosas que se ven en los datos:
datos %>%
select(precio_m, generacion, `Edad (años)` = edad, `Kilometraje (miles de km)` = km_mil) %>%
pivot_longer(c(`Edad (años)`, `Kilometraje (miles de km)`), names_to = "predictor", values_to = "valor") %>%
ggplot(aes(x = valor, y = precio_m)) +
geom_point(aes(color = generacion), alpha = 0.6) +
geom_smooth(method = "lm", formula = y ~ x, se = FALSE, color = "black", linetype = "dashed") +
geom_smooth(method = "loess", formula = y ~ x, se = FALSE, color = "red") +
facet_wrap(~ predictor, scales = "free_x") +
scale_color_manual(values = c("steelblue", "darkorange")) +
labs(title = "Precio frente a edad y kilometraje",
subtitle = "Línea punteada: recta de regresión. Línea roja: suavizado loess",
x = NULL, y = "Precio (millones de pesos)", color = "Generación") +
theme(legend.position = "top")kable(cor(num), digits = 3, caption = "Correlación de Pearson") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| precio_m | edad | km_mil | |
|---|---|---|---|
| precio_m | 1.000 | -0.945 | -0.851 |
| edad | -0.945 | 1.000 | 0.878 |
| km_mil | -0.851 | 0.878 | 1.000 |
kable(cor(num, method = "spearman"), digits = 3, caption = "Correlación de Spearman") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| precio_m | edad | km_mil | |
|---|---|---|---|
| precio_m | 1.000 | -0.959 | -0.858 |
| edad | -0.959 | 1.000 | 0.880 |
| km_mil | -0.858 | 0.880 | 1.000 |
##
## Pearson's product-moment correlation
##
## data: datos$precio_m and datos$edad
## t = -49.767, df = 297, p-value < 0.00000000000000022
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
## -0.9559168 -0.9313433
## sample estimates:
## cor
## -0.944947
##
## Pearson's product-moment correlation
##
## data: datos$precio_m and datos$km_mil
## t = -27.878, df = 297, p-value < 0.00000000000000022
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
## -0.8791970 -0.8158831
## sample estimates:
## cor
## -0.8505944
r_edad <- cor(datos$precio_m, datos$edad)
r_km <- cor(datos$precio_m, datos$km_mil)
r_edad_km <- cor(datos$edad, datos$km_mil)La edad tiene una correlación de -0.945 con el precio, una relación negativa muy fuerte. Aun así, la curva loess muestra que la caída no es una línea recta: en pesos, el carro pierde más valor en los primeros años que en los últimos. Esto sugiere que más adelante convendría probar una transformación (por ejemplo, el logaritmo del precio).
El kilometraje también se relaciona negativamente con el precio (r = -0.851), pero está muy correlacionado con la edad (r = 0.878): los carros más viejos han rodado más. Por eso parte de la relación entre precio y kilometraje es en realidad el efecto de la edad. La correlación de Spearman da resultados parecidos, de modo que estas conclusiones no dependen de unos pocos valores extremos.
datos %>%
transmute(precio_m,
`Transmisión` = as.character(transmision),
`Generación` = as.character(generacion),
`Región` = region,
Color = color_g) %>%
pivot_longer(-precio_m, names_to = "variable", values_to = "categoria") %>%
ggplot(aes(x = categoria, y = precio_m)) +
geom_boxplot(fill = "lightblue") +
facet_wrap(~ variable, scales = "free_x", ncol = 2) +
labs(title = "Precio por grupos", x = NULL, y = "Precio (millones de pesos)") +
theme(axis.text.x = element_text(angle = 20, hjust = 1))datos %>%
group_by(transmision) %>%
summarise(n = n(), mediana = median(precio_m), media = mean(precio_m), de = sd(precio_m),
edad_promedio = mean(edad)) %>%
kable(digits = 1, caption = "Precio y edad según la transmisión") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| transmision | n | mediana | media | de | edad_promedio |
|---|---|---|---|---|---|
| Mecánica | 135 | 39 | 44.2 | 12.4 | 7.9 |
| Automática | 164 | 57 | 54.9 | 19.5 | 6.3 |
prueba_t <- t.test(precio_m ~ transmision, data = datos)
prueba_w <- wilcox.test(precio_m ~ transmision, data = datos)
prueba_gen <- wilcox.test(precio_m ~ generacion, data = datos)
prueba_region <- kruskal.test(precio_m ~ region, data = datos)
prueba_color <- kruskal.test(precio_m ~ color_g, data = datos)
data.frame(
Variable = c("Transmisión", "Transmisión", "Generación", "Región", "Color"),
Prueba = c("t de Welch", "Wilcoxon", "Wilcoxon", "Kruskal-Wallis", "Kruskal-Wallis"),
p_valor = c(prueba_t$p.value, prueba_w$p.value, prueba_gen$p.value,
prueba_region$p.value, prueba_color$p.value)
) %>%
mutate(p_valor = format.pval(p_valor, digits = 3, eps = 0.001)) %>%
kable(caption = "Pruebas de diferencia entre grupos") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Variable | Prueba | p_valor |
|---|---|---|
| Transmisión | t de Welch | < 0.001 |
| Transmisión | Wilcoxon | < 0.001 |
| Generación | Wilcoxon | < 0.001 |
| Región | Kruskal-Wallis | 0.67455 |
| Color | Kruskal-Wallis | 0.00146 |
datos %>%
group_by(modelo) %>%
summarise(prop_auto = mean(transmision == "Automática")) %>%
ggplot(aes(x = modelo, y = prop_auto)) +
geom_col(fill = "darkorange") +
scale_x_continuous(breaks = 2008:2022) +
scale_y_continuous(labels = function(x) paste0(100 * x, "%"), limits = c(0, 1)) +
labs(title = "Proporción de carros automáticos por año del modelo", x = "Año del modelo", y = NULL)Los automáticos tienen un precio mediano de 57 millones y los mecánicos de 39 millones, y la diferencia es significativa (p < 0.001 con Wilcoxon). Pero hay que tener cuidado al interpretarla: los automáticos son en promedio más nuevos (6.3 años frente a 7.9), y el último gráfico muestra que la proporción de automáticos crece en los modelos recientes. Una parte de la diferencia de precio se debe a la edad y no a la caja.
La generación nueva tiene un precio mediano 1.8 veces el de la anterior (p < 0.001), aunque también son carros más nuevos. Para la región (p = 0.675) y el color (p = 0.001) las diferencias son menores y tampoco tienen en cuenta la edad, así que no las tomo como efectos propios de esas variables.
Planteo tres modelos, cada uno con un predictor distinto:
s1 <- lm(precio_m ~ edad, data = datos)
s2 <- lm(precio_m ~ km_mil, data = datos)
s3 <- lm(precio_m ~ transmision, data = datos)
summary(s1)##
## Call:
## lm(formula = precio_m ~ edad, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -29.5168 -3.8350 -0.1478 3.4487 19.7469
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 78.75309 0.66524 118.38 <0.0000000000000002 ***
## edad -4.06725 0.08173 -49.77 <0.0000000000000002 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 5.725 on 297 degrees of freedom
## Multiple R-squared: 0.8929, Adjusted R-squared: 0.8926
## F-statistic: 2477 on 1 and 297 DF, p-value: < 0.00000000000000022
##
## Call:
## lm(formula = precio_m ~ km_mil, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -44.436 -6.485 -0.553 5.498 24.733
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 77.63175 1.12374 69.08 <0.0000000000000002 ***
## km_mil -0.34798 0.01248 -27.88 <0.0000000000000002 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 9.199 on 297 degrees of freedom
## Multiple R-squared: 0.7235, Adjusted R-squared: 0.7226
## F-statistic: 777.2 on 1 and 297 DF, p-value: < 0.00000000000000022
##
## Call:
## lm(formula = precio_m ~ transmision, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -29.064 -12.769 -4.174 13.136 43.636
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 44.174 1.434 30.806 < 0.0000000000000002 ***
## transmisionAutomática 10.690 1.936 5.521 0.0000000736 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 16.66 on 297 degrees of freedom
## Multiple R-squared: 0.09308, Adjusted R-squared: 0.09002
## F-statistic: 30.48 on 1 and 297 DF, p-value: 0.0000000736
modelos <- list(s1, s2, s3)
tabla_simples <- data.frame(
Modelo = c("S1: precio ~ edad", "S2: precio ~ km", "S3: precio ~ transmisión"),
Intercepto = sapply(modelos, function(m) coef(m)[1]),
Pendiente = sapply(modelos, function(m) coef(m)[2]),
IC95_inf = sapply(modelos, function(m) confint(m)[2, 1]),
IC95_sup = sapply(modelos, function(m) confint(m)[2, 2]),
p_valor = sapply(modelos, function(m) format.pval(summary(m)$coefficients[2, 4], digits = 3, eps = 0.001)),
R2 = sapply(modelos, function(m) summary(m)$r.squared),
Error_residual = sapply(modelos, function(m) summary(m)$sigma)
)
kable(tabla_simples, digits = 3, caption = "Resumen de los modelos simples (precio en millones)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Modelo | Intercepto | Pendiente | IC95_inf | IC95_sup | p_valor | R2 | Error_residual | |
|---|---|---|---|---|---|---|---|---|
| edad | S1: precio ~ edad | 78.753 | -4.067 | -4.228 | -3.906 | <0.001 | 0.893 | 5.725 |
| km_mil | S2: precio ~ km | 77.632 | -0.348 | -0.373 | -0.323 | <0.001 | 0.724 | 9.199 |
| transmisionAutomática | S3: precio ~ transmisión | 44.174 | 10.690 | 6.879 | 14.500 | <0.001 | 0.093 | 16.661 |
S1 (edad). El intercepto, 78.8 millones, es el precio estimado de un carro con edad 0, es decir, un modelo 2022. La pendiente indica que cada año adicional de antigüedad se asocia, en promedio, con un precio anunciado 4.07 millones menor. La edad sola explica el 89.3 % de la variación del precio y el coeficiente es claramente significativo (p < 0.001).
S2 (kilometraje). El intercepto (77.6 millones) sería el precio de un carro sin recorrido. Por cada 10.000 km adicionales el precio anunciado es, en promedio, 3.48 millones menor (p < 0.001). Su R² es de 72.4 %, bastante menor que el de la edad.
S3 (transmisión). El intercepto (44.2 millones) es el precio promedio de los carros mecánicos, que son la categoría de referencia. El coeficiente indica que los automáticos se anuncian en promedio 10.69 millones más caros (p < 0.001). La diferencia es significativa, pero el R² es apenas 9.3 %: la transmisión por sí sola explica muy poco del precio.
Los tres coeficientes son significativos al 5 %, pero eso no quiere decir que los tres modelos sean igual de útiles. La significancia solo indica que la relación probablemente no es cero. Para predecir importa más cuánto se equivoca el modelo, que es lo que se evalúa en la sección 1.6.
Asociación no es causalidad. Estos coeficientes describen cómo se relaciona el precio con cada variable en estos anuncios, no cuánto cambiaría el precio de un carro si cambiara solo esa característica. El kilometraje es un buen ejemplo: en S2 cada 10.000 km se asocian con 3.48 millones menos, pero si en el mismo modelo se incluye también la edad, ese valor baja a 0.38 millones. La mayor parte de lo que S2 le atribuye al kilometraje es en realidad la edad del carro. Lo mismo pasa con la transmisión, porque los automáticos tienden a ser más nuevos. Además hay variables que no están en la base (estado del carro, versión, número de dueños, accidentes) y que también afectan el precio. Con datos observacionales como estos solo puedo hablar de asociaciones.
Antes de comparar los modelos reviso los residuos de S1, que es el de mayor R²:
data.frame(edad = datos$edad, residuo = residuals(s1)) %>%
ggplot(aes(x = factor(edad), y = residuo)) +
geom_hline(yintercept = 0, color = "gray40") +
geom_boxplot(fill = "lightblue") +
labs(title = "Residuos de S1 según la edad del carro",
x = "Edad (años)", y = "Residuo (millones)")Los residuos no están centrados en cero para todas las edades. El modelo subestima los carros del año y los más viejos (12 a 14 años) y sobreestima los de 7 a 10 años (modelos 2012 a 2015), lo que confirma que la relación es curva. Entre los 6 y los 7 años hay además un salto brusco, justo donde cambia la generación del Mazda2. La dispersión también es mayor en los carros de 0 a 5 años que en los viejos. Son problemas que se trabajarán en el Punto 2 con modelos múltiples y una transformación de la respuesta.
Para comparar los modelos por su capacidad de predecir carros que no se usaron para estimarlos, uso validación cruzada de 10 particiones repetida 10 veces. En cada repetición los datos se dividen al azar en 10 grupos; el modelo se estima con 9 y se evalúa con el grupo restante. Esto da 100 estimaciones del error por modelo. Algunas condiciones:
crear_particiones <- function(semilla, R = 10, K = 10) {
set.seed(semilla)
sapply(1:R, function(r) sample(rep(1:K, length.out = nrow(datos))))
}
# estima el modelo con 9 grupos y predice el grupo restante, para todas las particiones
cv_modelo <- function(formula, particiones, log = FALSE, paso_aic = FALSE) {
f <- as.formula(formula)
salida <- list()
for (r in 1:ncol(particiones)) {
for (k in 1:max(particiones)) {
train <- datos[particiones[, r] != k, ]
test <- datos[particiones[, r] == k, ]
m <- do.call("lm", list(formula = f, data = train))
if (paso_aic) m <- stepAIC(m, direction = "both", trace = FALSE) # selección dentro del entrenamiento
p <- predict(m, newdata = test, interval = "prediction", level = 0.95)
if (log) p <- exp(p) # regreso a millones de pesos
salida[[length(salida) + 1]] <- data.frame(
rep = r, fold = k, id = test$id, real = test$precio_m,
pred = p[, "fit"], inf = p[, "lwr"], sup = p[, "upr"],
variables = paste(attr(terms(m), "term.labels"), collapse = " + "))
}
}
do.call(rbind, salida)
}
cv_mediana <- function(particiones) {
salida <- list()
for (r in 1:ncol(particiones)) {
for (k in 1:max(particiones)) {
train <- datos[particiones[, r] != k, ]
test <- datos[particiones[, r] == k, ]
salida[[length(salida) + 1]] <- data.frame(
rep = r, fold = k, id = test$id, real = test$precio_m,
pred = median(train$precio_m), inf = NA, sup = NA, variables = "mediana")
}
}
do.call(rbind, salida)
}
mae_por_particion <- function(res) {
res %>%
group_by(rep, fold) %>%
summarise(mae = mean(abs(real - pred)), .groups = "drop") %>%
pull(mae)
}
resumen_cv <- function(lista) {
data.frame(
Modelo = names(lista),
MAE = sapply(lista, function(x) mean(mae_por_particion(x))),
DE_MAE = sapply(lista, function(x) sd(mae_por_particion(x))),
RMSE = sapply(lista, function(x) sqrt(mean((x$real - x$pred)^2))),
MAPE = sapply(lista, function(x) 100 * mean(abs(x$real - x$pred) / x$real)),
Cobertura_IP95 = sapply(lista, function(x) 100 * mean(x$real >= x$inf & x$real <= x$sup)),
row.names = NULL
) %>%
mutate(Mejora_vs_mediana = 100 * (1 - MAE / MAE[Modelo == "Mediana"])) %>%
arrange(MAE)
}particiones <- crear_particiones(2026)
cv_res <- list(
"Mediana" = cv_mediana(particiones),
"S1: edad" = cv_modelo("precio_m ~ edad", particiones),
"S2: kilometraje" = cv_modelo("precio_m ~ km_mil", particiones),
"S3: transmisión" = cv_modelo("precio_m ~ transmision", particiones)
)
tabla_cv_simples <- resumen_cv(cv_res)
kable(tabla_cv_simples, digits = 2,
caption = "Validación cruzada 10 x 10 de los modelos simples (errores en millones de pesos; MAPE, cobertura y mejora en %)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Modelo | MAE | DE_MAE | RMSE | MAPE | Cobertura_IP95 | Mejora_vs_mediana |
|---|---|---|---|---|---|---|
| S1: edad | 4.39 | 0.59 | 5.75 | 9.66 | 96.32 | 70.17 |
| S2: kilometraje | 7.21 | 1.23 | 9.24 | 16.18 | 94.98 | 51.01 |
| S3: transmisión | 14.40 | 1.48 | 16.71 | 31.32 | 96.89 | 2.13 |
| Mediana | 14.71 | 1.76 | 19.12 | 26.79 | NA | 0.00 |
mae_largo <- bind_rows(lapply(names(cv_res), function(nm) {
data.frame(Modelo = nm, MAE = mae_por_particion(cv_res[[nm]]))
}))
mae_largo$Modelo <- factor(mae_largo$Modelo, levels = rev(tabla_cv_simples$Modelo))
ggplot(mae_largo, aes(x = MAE, y = Modelo)) +
geom_boxplot(fill = "lightblue") +
labs(title = "MAE en cada una de las 100 particiones",
x = "MAE (millones de pesos)", y = NULL)Predecir siempre la mediana tiene un error promedio de 14.71 millones. S1 lo reduce a 4.39 millones, una mejora de 70 %, y su error equivale al 10.5 % del precio mediano. S2 queda en 7.21 millones. S3 apenas mejora la referencia (14.4 millones) y solo le gana a la mediana en el 63 % de las particiones.
Comparando partición por partición, S1 tiene menor error que S2 en el 99 % de los casos, así que la diferencia entre ambos no depende de cómo se dividieron los datos.
Para comprobar que el resultado no depende de la semilla, repetí la validación cruzada con cinco semillas distintas (5 repeticiones de 10 particiones cada una):
semillas <- c(1, 42, 123, 777, 2024)
sens_simples <- data.frame()
for (s in semillas) {
part_s <- crear_particiones(s, R = 5)
sens_simples <- rbind(sens_simples, data.frame(
Semilla = s,
Mediana = mean(mae_por_particion(cv_mediana(part_s))),
S1 = mean(mae_por_particion(cv_modelo("precio_m ~ edad", part_s))),
S2 = mean(mae_por_particion(cv_modelo("precio_m ~ km_mil", part_s))),
S3 = mean(mae_por_particion(cv_modelo("precio_m ~ transmision", part_s)))
))
}
kable(sens_simples, digits = 3, caption = "MAE promedio con distintas semillas (millones de pesos)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Semilla | Mediana | S1 | S2 | S3 |
|---|---|---|---|---|
| 1 | 14.712 | 4.385 | 7.218 | 14.436 |
| 42 | 14.711 | 4.392 | 7.215 | 14.423 |
| 123 | 14.713 | 4.393 | 7.211 | 14.402 |
| 777 | 14.698 | 4.394 | 7.203 | 14.408 |
| 2024 | 14.706 | 4.396 | 7.217 | 14.398 |
orden_igual <- all(sens_simples$S1 < sens_simples$S2 & sens_simples$S2 < sens_simples$S3 &
sens_simples$S3 < sens_simples$Mediana)Con todas las semillas el orden es el mismo: S1, luego S2, luego S3 y por último la mediana. Los valores cambian solo en el segundo decimal.
El mejor modelo simple es S1 (precio en función de la edad):
Su principal limitación es que supone que el carro pierde la misma cantidad de pesos cada año, cuando los datos muestran una caída más fuerte en los primeros años y un salto entre las dos generaciones. Además, un error promedio de 4.4 millones todavía es alto para fijar el precio de un carro concreto. Por eso en el Punto 2 se combinan varias variables y se prueba una transformación del precio.
Propongo dos modelos múltiples:
datos <- datos %>%
mutate(region = relevel(factor(region), ref = "Bogotá y Cundinamarca"),
color_g = relevel(factor(color_g), ref = "Gris"))
levels(datos$transmision)## [1] "Mecánica" "Automática"
## [1] "Anterior (2008 a 2015)" "Nueva (2016 en adelante)"
## [1] "Bogotá y Cundinamarca" "Antioquia" "Eje Cafetero"
## [4] "Otros departamentos" "Valle del Cauca"
## [1] "Gris" "Blanco" "Otro" "Rojo"
m1 <- lm(precio_m ~ edad + km_mil + transmision, data = datos)
m2 <- lm(precio_m ~ edad + km_mil + transmision + generacion + region + color_g, data = datos)
tabla_coef <- function(m) {
s <- summary(m)$coefficients
ic <- confint(m)
data.frame(Termino = rownames(s), Estimacion = s[, 1], Error_est = s[, 2], t = s[, 3],
p_valor = format.pval(s[, 4], digits = 3, eps = 0.001),
IC95_inf = ic[, 1], IC95_sup = ic[, 2], row.names = NULL)
}
kable(tabla_coef(m1), digits = 3, caption = "M1: coeficientes (precio en millones)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Termino | Estimacion | Error_est | t | p_valor | IC95_inf | IC95_sup |
|---|---|---|---|---|---|---|
| (Intercept) | 76.106 | 0.786 | 96.821 | <0.001 | 74.560 | 77.653 |
| edad | -3.617 | 0.157 | -23.009 | <0.001 | -3.927 | -3.308 |
| km_mil | -0.037 | 0.015 | -2.507 | 0.0127 | -0.067 | -0.008 |
| transmisionAutomática | 4.428 | 0.623 | 7.108 | <0.001 | 3.202 | 5.654 |
kable(tabla_coef(m2), digits = 3, caption = "M2: coeficientes (precio en millones)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Termino | Estimacion | Error_est | t | p_valor | IC95_inf | IC95_sup |
|---|---|---|---|---|---|---|
| (Intercept) | 66.107 | 1.697 | 38.956 | < 0.001 | 62.767 | 69.447 |
| edad | -2.758 | 0.191 | -14.441 | < 0.001 | -3.134 | -2.382 |
| km_mil | -0.038 | 0.014 | -2.719 | 0.00694 | -0.065 | -0.010 |
| transmisionAutomática | 3.868 | 0.591 | 6.548 | < 0.001 | 2.705 | 5.030 |
| generacionNueva (2016 en adelante) | 8.356 | 1.179 | 7.090 | < 0.001 | 6.037 | 10.676 |
| regionAntioquia | 2.540 | 0.875 | 2.903 | 0.00398 | 0.818 | 4.262 |
| regionEje Cafetero | 2.167 | 1.011 | 2.144 | 0.03287 | 0.178 | 4.155 |
| regionOtros departamentos | 0.524 | 0.890 | 0.588 | 0.55696 | -1.229 | 2.276 |
| regionValle del Cauca | 0.681 | 0.963 | 0.708 | 0.47979 | -1.214 | 2.576 |
| color_gBlanco | -0.654 | 0.756 | -0.865 | 0.38753 | -2.142 | 0.834 |
| color_gOtro | -1.068 | 0.751 | -1.422 | 0.15613 | -2.547 | 0.410 |
| color_gRojo | -1.309 | 0.915 | -1.431 | 0.15365 | -3.109 | 0.492 |
M1. Manteniendo constantes el kilometraje y la transmisión, cada año adicional de antigüedad se asocia con un precio 3.62 millones menor. Lo más interesante es lo que pasa con el kilometraje: en S2 cada 10.000 km se asociaban con 3.48 millones menos, pero en M1, a igual edad, solo con 0.37 millones menos (p = 0.013). Algo parecido ocurre con la transmisión: la diferencia a favor de los automáticos baja de 10.69 millones en S3 a 4.43 millones en M1. Esto confirma lo que se veía en el Punto 1: buena parte de lo que los modelos simples le atribuían al kilometraje y a la transmisión era en realidad el efecto de la edad. El R² ajustado de M1 es 0.909.
M2. A igual edad, kilometraje y transmisión, un carro de la generación nueva se anuncia 8.36 millones más caro (p < 0.001). Frente a Bogotá y Cundinamarca, los carros de Antioquia se anuncian 2.54 millones más caros (p = 0.004). Ningún color se diferencia significativamente del gris. El R² ajustado sube a 0.924. El R² ajustado se usa en lugar del R² normal porque penaliza agregar variables que no aportan.
Ahora aplico la selección paso a paso con AIC sobre M2, en ambas direcciones (quitar y agregar variables):
## Stepwise Model Path
## Analysis of Deviance Table
##
## Initial Model:
## precio_m ~ edad + km_mil + transmision + generacion + region +
## color_g
##
## Final Model:
## precio_m ~ edad + km_mil + transmision + generacion + region
##
##
## Step Df Deviance Resid. Df Resid. Dev AIC
## 1 287 6683.419 952.9755
## 2 - color_g 3 69.78065 290 6753.200 950.0811
kable(tabla_coef(m2_aic), digits = 3, caption = "M2-AIC: modelo elegido por el AIC") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Termino | Estimacion | Error_est | t | p_valor | IC95_inf | IC95_sup |
|---|---|---|---|---|---|---|
| (Intercept) | 65.649 | 1.665 | 39.431 | < 0.001 | 62.372 | 68.926 |
| edad | -2.758 | 0.190 | -14.481 | < 0.001 | -3.133 | -2.383 |
| km_mil | -0.040 | 0.014 | -2.892 | 0.00412 | -0.067 | -0.013 |
| transmisionAutomática | 3.951 | 0.587 | 6.736 | < 0.001 | 2.796 | 5.105 |
| generacionNueva (2016 en adelante) | 8.165 | 1.164 | 7.014 | < 0.001 | 5.874 | 10.456 |
| regionAntioquia | 2.726 | 0.863 | 3.161 | 0.00174 | 1.029 | 4.424 |
| regionEje Cafetero | 2.071 | 1.005 | 2.061 | 0.04020 | 0.093 | 4.048 |
| regionOtros departamentos | 0.484 | 0.877 | 0.552 | 0.58132 | -1.242 | 2.210 |
| regionValle del Cauca | 0.695 | 0.954 | 0.729 | 0.46686 | -1.183 | 2.573 |
comparacion_lineal <- data.frame(
Modelo = c("M1", "M2", "M2-AIC"),
Parametros = sapply(list(m1, m2, m2_aic), function(m) length(coef(m))),
R2 = sapply(list(m1, m2, m2_aic), function(m) summary(m)$r.squared),
R2_ajustado = sapply(list(m1, m2, m2_aic), function(m) summary(m)$adj.r.squared),
AIC = sapply(list(m1, m2, m2_aic), AIC),
Error_residual = sapply(list(m1, m2, m2_aic), sigma)
)
kable(comparacion_lineal, digits = 3, caption = "Comparación de los modelos lineales (mismas observaciones y misma escala)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Modelo | Parametros | R2 | R2_ajustado | AIC | Error_residual |
|---|---|---|---|---|---|
| M1 | 4 | 0.910 | 0.909 | 1847.083 | 5.258 |
| M2 | 12 | 0.926 | 0.924 | 1803.501 | 4.826 |
| M2-AIC | 9 | 0.926 | 0.924 | 1800.606 | 4.826 |
nombres_var <- c(edad = "edad", km_mil = "kilometraje", transmision = "transmisión",
generacion = "generación", region = "región", color_g = "color")
eliminadas <- setdiff(attr(terms(m2), "term.labels"), attr(terms(m2_aic), "term.labels"))
texto_eliminadas <- ifelse(length(eliminadas) == 0, "ninguna variable",
paste(nombres_var[eliminadas], collapse = " y "))El procedimiento elimina color y el AIC baja de 1803.5 a 1800.6. Los coeficientes de las demás variables prácticamente no cambian, lo que indica que la variable eliminada no estaba alterando las otras relaciones. El AIC mantiene la región aunque no todas sus categorías sean significativas, porque su criterio es distinto al de los valores p: conserva una variable si mejora el ajuste lo suficiente para compensar los parámetros que agrega. En la validación cruzada esta selección se repite dentro de cada partición de entrenamiento, para que la evaluación no use información de los datos de prueba.
bp_lin <- bptest(m2_aic)
sw_lin <- shapiro.test(residuals(m2_aic))
dw_lin <- dwtest(m2_aic) # en el orden en que se extrajeron los anuncios
tabla_pruebas <- function(bp, sw, dw) {
data.frame(
Supuesto = c("Varianza constante", "Normalidad de los errores", "Independencia"),
Prueba = c("Breusch-Pagan", "Shapiro-Wilk", "Durbin-Watson"),
Estadistico = c(bp$statistic, sw$statistic, dw$statistic),
p_valor = format.pval(c(bp$p.value, sw$p.value, dw$p.value), digits = 3, eps = 0.001),
Se_rechaza_al_5 = ifelse(c(bp$p.value, sw$p.value, dw$p.value) < 0.05, "Sí", "No")
)
}
kable(tabla_pruebas(bp_lin, sw_lin, dw_lin), digits = 3, caption = "Pruebas de supuestos para M2-AIC") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Supuesto | Prueba | Estadistico | p_valor | Se_rechaza_al_5 | |
|---|---|---|---|---|---|
| BP | Varianza constante | Breusch-Pagan | 27.431 | <0.001 | Sí |
| W | Normalidad de los errores | Shapiro-Wilk | 0.929 | <0.001 | Sí |
| DW | Independencia | Durbin-Watson | 1.960 | 0.286 | No |
## GVIF Df GVIF^(1/(2*Df))
## edad 7.642023 1 2.764421
## km_mil 4.461156 1 2.112145
## transmision 1.093836 1 1.045866
## generacion 4.289332 1 2.071070
## region 1.154382 4 1.018108
cook_lin <- cooks.distance(m2_aic)
data.frame(id = datos$id, cook = cook_lin) %>%
ggplot(aes(x = id, y = cook)) +
geom_col(fill = "steelblue") +
geom_hline(yintercept = 4 / n_final, linetype = "dashed", color = "red") +
labs(title = "Distancia de Cook de M2-AIC", subtitle = "Línea roja: 4/n",
x = "id del anuncio", y = "Distancia de Cook")En resumen, el modelo lineal tiene problemas de forma funcional y de varianza que se pueden corregir con una transformación.
Hay dos razones para transformar el precio con logaritmo:
Además, más adelante compruebo con la validación cruzada que la transformación realmente reduce el error en pesos.
Estimé los mismos modelos con log(precio) y apliqué otra vez la selección por AIC:
m1_log <- lm(log(precio_m) ~ edad + km_mil + transmision, data = datos)
m2_log <- lm(log(precio_m) ~ edad + km_mil + transmision + generacion + region + color_g, data = datos)
m2_log_aic <- stepAIC(m2_log, direction = "both", trace = FALSE)
m3_log <- lm(log(precio_m) ~ edad + km_mil + transmision + generacion, data = datos)
attr(terms(m2_log_aic), "term.labels")## [1] "edad" "km_mil" "transmision" "generacion" "region"
tabla_coef_log <- function(m) {
t <- tabla_coef(m)
t$Efecto_pct <- ifelse(t$Termino == "(Intercept)", NA, 100 * (exp(t$Estimacion) - 1))
t
}
kable(tabla_coef_log(m3_log), digits = 4,
caption = "M3-log: coeficientes en escala logarítmica y efecto en % sobre el precio") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Termino | Estimacion | Error_est | t | p_valor | IC95_inf | IC95_sup | Efecto_pct |
|---|---|---|---|---|---|---|---|
| (Intercept) | 4.1850 | 0.0260 | 160.7707 | <0.001 | 4.1337 | 4.2362 | NA |
| edad | -0.0562 | 0.0032 | -17.4084 | <0.001 | -0.0626 | -0.0499 | -5.4675 |
| km_mil | -0.0005 | 0.0002 | -2.0779 | 0.0386 | -0.0010 | 0.0000 | -0.0489 |
| transmisionAutomática | 0.0593 | 0.0099 | 6.0059 | <0.001 | 0.0399 | 0.0787 | 6.1085 |
| generacionNueva (2016 en adelante) | 0.1655 | 0.0195 | 8.4740 | <0.001 | 0.1271 | 0.2039 | 17.9991 |
En escala logarítmica, el AIC conserva estas variables: edad, kilometraje, transmisión, generación, región. Es decir, elimina el color, igual que antes, pero mantiene la región. También estimé M3-log, que es igual pero sin la región. Es más sencillo de usar, porque solo pide datos que cualquier dueño conoce de su carro, y no hay una razón clara para que un mismo Mazda2 valga distinto según la ciudad. En la sección 2.5 se compara si la región realmente mejora la predicción.
Interpretación de M3-log. Como la respuesta está en logaritmo, cada coeficiente se convierte en un cambio porcentual con la fórmula 100 x (exp(β) - 1):
Como indica el enunciado, no comparo el R² ni el AIC del modelo logarítmico con los del modelo lineal, porque la respuesta está en otra escala (logaritmo del precio frente a precio). La comparación justa entre los dos es el MAE en pesos, que se calcula más abajo con validación cruzada.
corte <- which(sort(unique(datos$modelo)) == 2016) - 0.5
bind_rows(
data.frame(modelo = datos$modelo, residuo = residuals(m1_log), Modelo = "M1-log (sin generación)"),
data.frame(modelo = datos$modelo, residuo = residuals(m3_log), Modelo = "M3-log (con generación)")
) %>%
ggplot(aes(x = factor(modelo), y = residuo)) +
geom_hline(yintercept = 0, color = "gray40") +
geom_vline(xintercept = corte, linetype = "dashed", color = "darkorange") +
geom_boxplot(fill = "lightblue") +
facet_wrap(~ Modelo, ncol = 1) +
labs(title = "Residuos por año del modelo",
subtitle = "Línea naranja: llegada de la generación nueva (modelo 2016)",
x = "Año del modelo", y = "Residuo (escala log)")Este gráfico muestra por qué es importante la variable de generación. Sin ella (panel de arriba), el modelo subestima los carros 2016 a 2018 y sobreestima los 2011 a 2015, con un salto justo en el cambio de generación. Con ella (panel de abajo), los residuos quedan alrededor de cero en todos los años.
bp_log <- bptest(m3_log)
sw_log <- shapiro.test(residuals(m3_log))
dw_log <- dwtest(m3_log)
kable(tabla_pruebas(bp_log, sw_log, dw_log), digits = 3, caption = "Pruebas de supuestos para M3-log") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Supuesto | Prueba | Estadistico | p_valor | Se_rechaza_al_5 | |
|---|---|---|---|---|---|
| BP | Varianza constante | Breusch-Pagan | 10.422 | 0.0339 | Sí |
| W | Normalidad de los errores | Shapiro-Wilk | 0.848 | <0.001 | Sí |
| DW | Independencia | Durbin-Watson | 1.904 | 0.1975 | No |
## edad km_mil transmision generacion
## 7.397329 4.351257 1.042872 4.064125
influencia <- data.frame(id = datos$id, modelo = datos$modelo, precio_m = datos$precio_m,
km_mil = datos$km_mil, transmision = datos$transmision,
residuo_est = rstudent(m3_log), apalancamiento = hatvalues(m3_log),
cook = cooks.distance(m3_log))
ggplot(influencia, aes(x = apalancamiento, y = residuo_est)) +
geom_point(aes(size = cook), color = "steelblue", alpha = 0.6) +
geom_hline(yintercept = c(-3, 3), linetype = "dashed") +
geom_text(data = filter(influencia, abs(residuo_est) > 3), aes(label = id), size = 3.5, vjust = -1) +
labs(title = "Residuos estudentizados frente a apalancamiento (M3-log)",
x = "Apalancamiento", y = "Residuo estudentizado", size = "Distancia de Cook")influencia %>%
arrange(desc(cook)) %>%
head(5) %>%
kable(digits = 3, caption = "Anuncios más influyentes en M3-log") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| id | modelo | precio_m | km_mil | transmision | residuo_est | apalancamiento | cook | |
|---|---|---|---|---|---|---|---|---|
| 116 | 123 | 2017 | 28.9 | 12.345 | Mecánica | -9.860 | 0.035 | 0.528 |
| 74 | 77 | 2011 | 22.0 | 102.000 | Mecánica | -5.383 | 0.011 | 0.059 |
| 292 | 306 | 2010 | 26.0 | 176.000 | Mecánica | -2.040 | 0.032 | 0.027 |
| 281 | 295 | 2012 | 29.0 | 145.000 | Mecánica | -2.256 | 0.021 | 0.021 |
| 285 | 299 | 2009 | 25.8 | 129.000 | Automática | -2.443 | 0.017 | 0.021 |
Para comparar con el modelo original, las predicciones de los modelos logarítmicos se devuelven a millones de pesos con la función exponencial, y el MAE se calcula en esa escala. Todos los modelos múltiples se evalúan con las mismas 100 particiones del Punto 1, y la selección por AIC se repite dentro de cada partición de entrenamiento:
f_m1 <- "precio_m ~ edad + km_mil + transmision"
f_m2 <- "precio_m ~ edad + km_mil + transmision + generacion + region + color_g"
f_m3 <- "precio_m ~ edad + km_mil + transmision + generacion"
a_log <- function(f) sub("precio_m", "log(precio_m)", f) # misma fórmula con log(precio)
cv_res[["M1: lineal"]] <- cv_modelo(f_m1, particiones)
cv_res[["M2: lineal"]] <- cv_modelo(f_m2, particiones)
cv_res[["M2-AIC: lineal"]] <- cv_modelo(f_m2, particiones, paso_aic = TRUE)
cv_res[["M1-log"]] <- cv_modelo(a_log(f_m1), particiones, log = TRUE)
cv_res[["M2-log"]] <- cv_modelo(a_log(f_m2), particiones, log = TRUE)
cv_res[["M2-log-AIC"]] <- cv_modelo(a_log(f_m2), particiones, log = TRUE, paso_aic = TRUE)
cv_res[["M3-log"]] <- cv_modelo(a_log(f_m3), particiones, log = TRUE)
tabla_cv <- resumen_cv(cv_res)
tabla_cv %>%
filter(Modelo %in% c("M2-AIC: lineal", "M2-log-AIC", "M3-log")) %>%
kable(digits = 2, caption = "Modelo lineal frente a modelos transformados (errores en millones de pesos)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Modelo | MAE | DE_MAE | RMSE | MAPE | Cobertura_IP95 | Mejora_vs_mediana |
|---|---|---|---|---|---|---|
| M2-log-AIC | 2.87 | 0.51 | 4.24 | 5.84 | 96.02 | 80.50 |
| M3-log | 2.88 | 0.52 | 4.25 | 5.88 | 96.02 | 80.45 |
| M2-AIC: lineal | 3.50 | 0.57 | 4.91 | 7.46 | 94.95 | 76.18 |
mae_m2aic <- mae_de(tabla_cv, "M2-AIC: lineal")
mae_m2logaic <- mae_de(tabla_cv, "M2-log-AIC")
mae_m3 <- mae_de(tabla_cv, "M3-log")Con la transformación, el MAE baja de 3.5 millones (M2-AIC lineal) a 2.87 millones (M2-log-AIC), una reducción de 18 %. M3-log, sin la región, tiene un MAE de 2.88 millones.
kable(tabla_cv, digits = 2,
caption = "Validación cruzada 10 x 10 con las mismas particiones (errores en millones de pesos; MAPE, cobertura y mejora en %)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE) %>%
row_spec(which(tabla_cv$Modelo == "M3-log"), bold = TRUE, background = "#fde9d9")| Modelo | MAE | DE_MAE | RMSE | MAPE | Cobertura_IP95 | Mejora_vs_mediana |
|---|---|---|---|---|---|---|
| M2-log | 2.86 | 0.51 | 4.22 | 5.81 | 95.85 | 80.59 |
| M2-log-AIC | 2.87 | 0.51 | 4.24 | 5.84 | 96.02 | 80.50 |
| M3-log | 2.88 | 0.52 | 4.25 | 5.88 | 96.02 | 80.45 |
| M1-log | 3.48 | 0.51 | 4.62 | 7.13 | 97.29 | 76.38 |
| M2-AIC: lineal | 3.50 | 0.57 | 4.91 | 7.46 | 94.95 | 76.18 |
| M2: lineal | 3.56 | 0.56 | 4.94 | 7.58 | 95.55 | 75.77 |
| M1: lineal | 4.09 | 0.55 | 5.30 | 9.09 | 95.99 | 72.20 |
| S1: edad | 4.39 | 0.59 | 5.75 | 9.66 | 96.32 | 70.17 |
| S2: kilometraje | 7.21 | 1.23 | 9.24 | 16.18 | 94.98 | 51.01 |
| S3: transmisión | 14.40 | 1.48 | 16.71 | 31.32 | 96.89 | 2.13 |
| Mediana | 14.71 | 1.76 | 19.12 | 26.79 | NA | 0.00 |
tabla_cv %>%
mutate(Modelo = factor(Modelo, levels = rev(Modelo)),
Tipo = case_when(Modelo == "M3-log" ~ "Modelo elegido",
Modelo == "Mediana" ~ "Referencia",
TRUE ~ "Otros")) %>%
ggplot(aes(x = MAE, y = Modelo, fill = Tipo)) +
geom_col() +
geom_text(aes(label = round(MAE, 2)), hjust = -0.2, size = 3.5) +
scale_fill_manual(values = c("Modelo elegido" = "darkorange", "Referencia" = "gray60", "Otros" = "steelblue")) +
scale_x_continuous(limits = c(0, max(tabla_cv$MAE) * 1.12)) +
labs(title = "Error promedio de predicción de cada modelo",
subtitle = "MAE en validación cruzada, millones de pesos",
x = "MAE (millones de pesos)", y = NULL, fill = NULL) +
theme(legend.position = "top")Lo que muestra la comparación:
Modelo elegido: M3-log, es decir, log(precio) en función de la edad, el kilometraje, la transmisión y la generación. Tiene un error promedio de 2.88 millones (5.9 % del precio), frente a 14.71 millones de la referencia. Además usa solo variables que cualquier vendedor conoce de su carro, sus coeficientes tienen una lectura sencilla en porcentajes y el patrón de depreciación tiene sentido.
Planteo dos casos realistas, dentro del rango de los datos:
escenarios <- data.frame(
caso = c("1. Vendedora particular", "2. Compraventa (retoma)"),
modelo = c(2015, 2019),
km_mil = c(85, 45),
transmision = factor(c("Mecánica", "Automática"), levels = levels(datos$transmision))
)
escenarios$edad <- 2022 - escenarios$modelo
escenarios$generacion <- factor(ifelse(escenarios$modelo >= 2016, "Nueva (2016 en adelante)", "Anterior (2008 a 2015)"),
levels = levels(datos$generacion))
pred_esc <- exp(predict(m3_log, newdata = escenarios, interval = "prediction", level = 0.95))
comparables_med <- sapply(1:2, function(i) median(datos$precio_m[datos$modelo == escenarios$modelo[i] &
datos$transmision == escenarios$transmision[i]]))
comparables_n <- sapply(1:2, function(i) sum(datos$modelo == escenarios$modelo[i] &
datos$transmision == escenarios$transmision[i]))
data.frame(
Caso = escenarios$caso,
Modelo = escenarios$modelo,
Km = escenarios$km_mil * 1000,
Transmision = escenarios$transmision,
Prediccion = pred_esc[, "fit"],
IP95_inf = pred_esc[, "lwr"],
IP95_sup = pred_esc[, "upr"],
Mediana_comparables = comparables_med,
N_comparables = comparables_n
) %>%
kable(digits = 1, caption = "Predicciones de M3-log en millones de pesos con intervalo de predicción del 95 %") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Caso | Modelo | Km | Transmision | Prediccion | IP95_inf | IP95_sup | Mediana_comparables | N_comparables |
|---|---|---|---|---|---|---|---|---|
|
2015 | 85000 | Mecánica | 42.5 | 36.0 | 50.1 | 43.9 | 11 |
| 2. Compraventa (retoma) | 2019 | 45000 | Automática | 68.0 | 57.7 | 80.1 | 70.0 | 12 |
# contraste: mismo carro, un año de diferencia entre generaciones
contraste <- data.frame(modelo = c(2015, 2016), km_mil = 70,
transmision = factor("Automática", levels = levels(datos$transmision)))
contraste$edad <- 2022 - contraste$modelo
contraste$generacion <- factor(ifelse(contraste$modelo >= 2016, "Nueva (2016 en adelante)", "Anterior (2008 a 2015)"),
levels = levels(datos$generacion))
pred_contraste <- exp(predict(m3_log, newdata = contraste))
pred_contraste## 1 2
## 45.44114 56.72142
Caso 1. Una vendedora particular quiere publicar su Mazda2 2015 mecánico con 85.000 km. El modelo estima un precio de publicación de 42.5 millones, con un intervalo del 95 % entre 36 y 50.1 millones. En la base hay 11 anuncios de 2015 mecánicos, con una mediana de 43.9 millones, así que la predicción es coherente con el mercado. Si ella quiere vender rápido, le conviene publicar cerca del centro del intervalo. Si el carro está en muy buen estado y tiene cómo demostrarlo, puede ubicarse en la parte alta. Por encima de 50.1 millones estaría pidiendo más que lo que piden casi todos los vendedores de carros parecidos.
Caso 2. Una compraventa recibe en parte de pago un Mazda2 2019 automático con 45.000 km. El modelo estima un precio anunciado de 68 millones, con un intervalo entre 57.7 y 80.1 millones (los 12 anuncios de 2019 automáticos tienen una mediana de 70 millones). Como el modelo estima precios de anuncio y no de venta, para la compraventa esa cifra es más bien un techo de reventa: a eso tiene que descontarle su margen, el alistamiento y lo que encuentre en el peritaje. Una oferta prudente estaría por debajo del límite inferior del intervalo.
Contraste entre generaciones. Un Mazda2 automático 2015 con 70.000 km se estima en 45.4 millones, y el mismo carro modelo 2016 en 56.7 millones. Un solo año de diferencia representa 11.3 millones, porque el 2016 ya es de la generación nueva. Un modelo sin esa variable no podría ver esa diferencia.
En esta sección reviso si las conclusiones cambian con otra división al azar de los datos o con otras decisiones de limpieza, y si los intervalos de predicción funcionan.
sens_multiples <- data.frame()
for (s in semillas) {
part_s <- crear_particiones(s, R = 5)
sens_multiples <- rbind(sens_multiples, data.frame(
Semilla = s,
Mediana = sens_simples$Mediana[sens_simples$Semilla == s],
S1 = sens_simples$S1[sens_simples$Semilla == s],
M2_AIC_lineal = mean(mae_por_particion(cv_modelo(f_m2, part_s, paso_aic = TRUE))),
M3_log = mean(mae_por_particion(cv_modelo(a_log(f_m3), part_s, log = TRUE)))
))
}
kable(sens_multiples, digits = 3, caption = "MAE con cinco semillas distintas (millones de pesos)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Semilla | Mediana | S1 | M2_AIC_lineal | M3_log |
|---|---|---|---|---|
| 1 | 14.712 | 4.385 | 3.488 | 2.863 |
| 42 | 14.711 | 4.392 | 3.489 | 2.871 |
| 123 | 14.713 | 4.393 | 3.514 | 2.869 |
| 777 | 14.698 | 4.394 | 3.479 | 2.875 |
| 2024 | 14.706 | 4.396 | 3.495 | 2.864 |
orden_multiples <- all(sens_multiples$M3_log < sens_multiples$M2_AIC_lineal &
sens_multiples$M2_AIC_lineal < sens_multiples$S1 &
sens_multiples$S1 < sens_multiples$Mediana)Con las cinco semillas, el MAE de M3-log va de 2.86 a 2.88 millones. El orden entre modelos es siempre el mismo: M3-log, M2-AIC lineal, S1 y la mediana.
crudos <- mazda %>%
mutate(precio_m = precio / 1e6,
edad = 2022 - modelo,
km_mil = kilometraje / 1000,
transmision = factor(ifelse(grepl("^Autom", transmision), "Automática", "Mecánica"),
levels = c("Mecánica", "Automática")),
generacion = factor(ifelse(modelo >= 2016, "Nueva (2016 en adelante)", "Anterior (2008 a 2015)"),
levels = c("Anterior (2008 a 2015)", "Nueva (2016 en adelante)")))
bases <- list(
"Base limpia (principal)" = datos,
"Excluyendo los km corregidos" = datos %>% filter(!km_en_miles),
"Excluyendo residuos estudentizados > 3" = datos[abs(influencia$residuo_est) <= 3, ],
"Base original sin limpiar" = crudos
)
limpieza <- data.frame()
for (nombre in names(bases)) {
m <- lm(log(precio_m) ~ edad + km_mil + transmision + generacion, data = bases[[nombre]])
limpieza <- rbind(limpieza, data.frame(
Base = nombre, n = nrow(bases[[nombre]]),
Edad_pct = ef(coef(m)[2]), Km10_pct = ef(10 * coef(m)[3]),
Automatica_pct = ef(coef(m)[4]), Generacion_pct = ef(coef(m)[5]),
Error_residual = sigma(m), R2 = summary(m)$r.squared, row.names = NULL))
}
kable(limpieza, digits = 3, caption = "M3-log estimado con distintas versiones de la base") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Base | n | Edad_pct | Km10_pct | Automatica_pct | Generacion_pct | Error_residual | R2 |
|---|---|---|---|---|---|---|---|
| Base limpia (principal) | 299 | -5.468 | -0.488 | 6.108 | 17.999 | 0.083 | 0.940 |
| Excluyendo los km corregidos | 288 | -5.438 | -0.559 | 6.285 | 17.870 | 0.084 | 0.939 |
| Excluyendo residuos estudentizados > 3 | 296 | -5.078 | -0.794 | 4.936 | 19.486 | 0.066 | 0.961 |
| Base original sin limpiar | 314 | -4.676 | -0.166 | 7.793 | 26.701 | 0.193 | 0.747 |
Corregir o excluir los kilometrajes escritos en miles da casi los mismos resultados, así que esa decisión no cambia nada importante. Excluir los atípicos extremos reduce el error del modelo, pero no cambia las conclusiones. En cambio, no limpiar la base sí importa: con los 314 anuncios originales el error residual es 2.3 veces mayor y el efecto de la generación se infla, porque las camionetas CX-5 y CX-9 de 160 a 170 millones quedan clasificadas como Mazda2 de la generación nueva.
oof <- cv_res[["M3-log"]] %>%
left_join(datos %>% select(id, generacion, transmision, region, edad), by = "id") %>%
mutate(rango_edad = cut(edad, breaks = c(-Inf, 2, 6, 10, Inf),
labels = c("0 a 2 años", "3 a 6 años", "7 a 10 años", "11 a 14 años")))
por_segmento <- function(variable) {
oof %>%
group_by(Grupo = as.character(.data[[variable]])) %>%
summarise(Predicciones = n(),
MAE = mean(abs(real - pred)),
MAPE = 100 * mean(abs(real - pred) / real),
Cobertura_IP95 = 100 * mean(real >= inf & real <= sup),
Sesgo = mean(pred - real)) %>%
mutate(Variable = variable) %>%
select(Variable, everything())
}
segmentos <- bind_rows(por_segmento("generacion"), por_segmento("transmision"),
por_segmento("rango_edad"), por_segmento("region"))
kable(segmentos, digits = 2, caption = "Desempeño de M3-log por segmento (predicciones fuera de muestra)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Variable | Grupo | Predicciones | MAE | MAPE | Cobertura_IP95 | Sesgo |
|---|---|---|---|---|---|---|
| generacion | Anterior (2008 a 2015) | 1670 | 1.89 | 5.46 | 97.01 | -0.06 |
| generacion | Nueva (2016 en adelante) | 1320 | 4.12 | 6.40 | 94.77 | -0.35 |
| transmision | Automática | 1640 | 3.03 | 5.23 | 96.46 | -0.43 |
| transmision | Mecánica | 1350 | 2.69 | 6.66 | 95.48 | 0.10 |
| rango_edad | 0 a 2 años | 500 | 5.23 | 6.62 | 94.60 | -0.89 |
| rango_edad | 11 a 14 años | 730 | 2.01 | 6.48 | 95.89 | -0.21 |
| rango_edad | 3 a 6 años | 820 | 3.45 | 6.27 | 94.88 | -0.02 |
| rango_edad | 7 a 10 años | 940 | 1.80 | 4.67 | 97.87 | 0.06 |
| region | Antioquia | 800 | 3.32 | 6.24 | 92.88 | -1.33 |
| region | Bogotá y Cundinamarca | 550 | 3.25 | 7.29 | 98.18 | 0.95 |
| region | Eje Cafetero | 430 | 2.27 | 4.67 | 100.00 | -0.93 |
| region | Otros departamentos | 710 | 2.22 | 4.84 | 98.59 | 0.34 |
| region | Valle del Cauca | 500 | 3.22 | 6.24 | 91.60 | 0.28 |
sesgo_ant <- segmentos$Sesgo[segmentos$Grupo == "Antioquia"]
sesgo_bog <- segmentos$Sesgo[segmentos$Grupo == "Bogotá y Cundinamarca"]oof %>%
group_by(id, generacion) %>%
summarise(real = first(real), pred = mean(pred), inf = mean(inf), sup = mean(sup), .groups = "drop") %>%
ggplot(aes(x = pred, y = real, color = generacion)) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed") +
geom_errorbar(aes(xmin = inf, xmax = sup), width = 0, alpha = 0.2, orientation = "y") +
geom_point(alpha = 0.7) +
scale_color_manual(values = c("steelblue", "darkorange")) +
labs(title = "Precio real frente a predicción fuera de muestra (M3-log)",
subtitle = "Barras horizontales: intervalo de predicción del 95 %",
x = "Predicción (millones de pesos)", y = "Precio anunciado (millones de pesos)", color = "Generación") +
theme(legend.position = "top")El error relativo es parecido entre segmentos (el MAPE va de 4.7 % a 7.3 %) y la cobertura de los intervalos va de 91.6 % a 100 %. En pesos, el error es mayor en los carros nuevos, porque son más caros y porque en ellos pesa más la versión, que el modelo no conoce. Por región aparece un sesgo moderado: el modelo tiende a subestimar los precios de Antioquia (-1.33 millones en promedio) y a sobreestimar los de Bogotá y Cundinamarca (0.95 millones). Es coherente con lo que encontró el AIC, pero es pequeño frente al error total. Si el modelo se fuera a usar en una sola ciudad, convendría recalibrarlo con datos de esa ciudad.
data.frame(
Verificacion = c("Cinco semillas de validación cruzada", "Versiones de la limpieza",
"Cobertura del intervalo del 95 %"),
Resultado = c(
paste0("MAE de M3-log entre ", round(min(sens_multiples$M3_log), 2), " y ", round(max(sens_multiples$M3_log), 2),
" millones; orden de los modelos ", ifelse(orden_multiples, "igual en todas", "casi igual")),
paste0("Corregir los km o quitar los atípicos no cambia las conclusiones; sin limpieza el error se multiplica por ",
round(veces_error, 1)),
paste0(round(cob_m3, 1), " % fuera de muestra; entre ", round(min(segmentos$Cobertura_IP95)), " % y ",
round(max(segmentos$Cobertura_IP95)), " % por segmento")
)
) %>%
kable(caption = "Resumen de las verificaciones") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Verificacion | Resultado |
|---|---|
| Cinco semillas de validación cruzada | MAE de M3-log entre 2.86 y 2.88 millones; orden de los modelos igual en todas |
| Versiones de la limpieza | Corregir los km o quitar los atípicos no cambia las conclusiones; sin limpieza el error se multiplica por 2.3 |
| Cobertura del intervalo del 95 % | 96 % fuera de muestra; entre 92 % y 100 % por segmento |
En promedio, el modelo se equivoca en 2.88 millones, que equivale al 5.9 % del precio. La mitad de los errores son menores a 1.98 millones. El intervalo del 95 % tiene un ancho promedio de más o menos 17 %: para un carro de 60 millones, el rango iría aproximadamente de 50 a 70 millones.
Para fijar un precio de publicación o para decidir si vale la pena mirar un anuncio, este error me parece aceptable: es 5.1 veces menor que el de la referencia y los intervalos están bien calibrados. Como valor definitivo para una compra no es suficiente, porque una diferencia de 3 a 5 millones es mucha plata para cualquiera de las dos partes. Ahí el modelo debe acompañarse de una revisión del carro.
En la validación cruzada el modelo sobreestima en el 47 % de los casos, así que no tiene un sesgo sistemático hacia arriba ni hacia abajo.
Sí recomiendo aplicarlo, pero como herramienta de referencia y de alerta, no como precio final, y con tres condiciones:
Para mejorarlo necesitaría precios de venta reales y no solo de anuncio, la versión del carro (Prime, Touring o Grand Touring), que hoy no está en la base, la fecha de publicación y los días que duró el anuncio, información sobre el estado del carro y su historial, y anuncios de otras plataformas para tener una muestra más representativa.
## R version 4.6.1 (2026-06-24)
## Platform: aarch64-apple-darwin23
## Running under: macOS Tahoe 26.5.2
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
##
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
##
## time zone: America/Bogota
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] kableExtra_1.4.1 knitr_1.52 ggplot2_4.0.3 tidyr_1.3.2
## [5] dplyr_1.2.1 lmtest_0.9-40 zoo_1.9-0 car_3.1-5
## [9] carData_3.0-6 MASS_7.3-66 readxl_1.6.0
##
## loaded via a namespace (and not attached):
## [1] sass_0.4.10 generics_0.1.4 xml2_1.6.0 stringi_1.8.9
## [5] lattice_0.22-9 digest_0.6.39 magrittr_2.0.5 evaluate_1.0.5
## [9] grid_4.6.1 RColorBrewer_1.1-3 fastmap_1.2.0 Matrix_1.7-5
## [13] cellranger_1.1.0 jsonlite_2.0.0 Formula_1.2-6 mgcv_1.9-4
## [17] purrr_1.2.2 viridisLite_0.4.3 scales_1.4.0 textshaping_1.0.5
## [21] jquerylib_0.1.4 abind_1.4-8 cli_3.6.6 rlang_1.3.0
## [25] splines_4.6.1 withr_3.0.3 cachem_1.1.0 yaml_2.3.12
## [29] otel_0.2.0 tools_4.6.1 vctrs_0.7.3 R6_2.6.1
## [33] stats4_4.6.1 lifecycle_1.0.5 stringr_1.6.0 pkgconfig_2.0.3
## [37] pillar_1.11.1 bslib_0.12.0 gtable_0.3.6 glue_1.8.1
## [41] systemfonts_1.3.2 xfun_0.60 tibble_3.3.1 tidyselect_1.2.1
## [45] rstudioapi_0.19.0 farver_2.1.2 nlme_3.1-169 htmltools_0.5.9
## [49] labeling_0.4.3 rmarkdown_2.32 svglite_2.2.2 compiler_4.6.1
## [53] S7_0.2.2