paquetes <- c("readxl", "dplyr", "tidyr", "ggplot2", "knitr", "kableExtra")
faltan <- paquetes[!(paquetes %in% installed.packages()[, "Package"])]
if (length(faltan) > 0) install.packages(faltan)
library(readxl)
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 la validación cruzada.
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:
formulas <- list(
"S1: edad" = precio_m ~ edad,
"S2: kilometraje" = precio_m ~ km_mil,
"S3: transmisión" = precio_m ~ transmision
)
correr_cv <- function(semilla, R = 10, K = 10) {
set.seed(semilla)
n <- nrow(datos)
resultados <- data.frame()
for (r in 1:R) {
grupos <- sample(rep(1:K, length.out = n))
for (k in 1:K) {
train <- datos[grupos != k, ]
test <- datos[grupos == k, ]
fila <- data.frame(rep = r, fold = k,
Mediana = mean(abs(test$precio_m - median(train$precio_m))))
for (nombre in names(formulas)) {
m <- lm(formulas[[nombre]], data = train)
fila[[nombre]] <- mean(abs(test$precio_m - predict(m, newdata = test)))
}
resultados <- rbind(resultados, fila)
}
}
resultados
}
cv <- correr_cv(semilla = 2026)
mae_cv <- cv %>%
select(-rep, -fold) %>%
pivot_longer(everything(), names_to = "Modelo", values_to = "MAE") %>%
group_by(Modelo) %>%
summarise(MAE_promedio = mean(MAE), DE = sd(MAE), Minimo = min(MAE), Maximo = max(MAE)) %>%
mutate(Error_relativo = MAE_promedio / median(datos$precio_m),
Mejora_vs_mediana = 1 - MAE_promedio / MAE_promedio[Modelo == "Mediana"]) %>%
arrange(MAE_promedio)
kable(mae_cv, digits = 3,
caption = "MAE en validación cruzada 10 x 10 (millones de pesos). Error relativo = MAE / precio mediano") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Modelo | MAE_promedio | DE | Minimo | Maximo | Error_relativo | Mejora_vs_mediana |
|---|---|---|---|---|---|---|
| S1: edad | 4.390 | 0.590 | 3.182 | 6.098 | 0.105 | 0.702 |
| S2: kilometraje | 7.208 | 1.235 | 4.813 | 10.575 | 0.172 | 0.510 |
| S3: transmisión | 14.400 | 1.475 | 10.245 | 18.416 | 0.343 | 0.021 |
| Mediana | 14.714 | 1.761 | 11.057 | 20.573 | 0.350 | 0.000 |
cv %>%
select(-rep, -fold) %>%
pivot_longer(everything(), names_to = "Modelo", values_to = "MAE") %>%
mutate(Modelo = factor(Modelo, levels = rev(mae_cv$Modelo))) %>%
ggplot(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 <- t(sapply(semillas, function(s) colMeans(correr_cv(s, R = 5)[, -(1:2)])))
sens <- data.frame(Semilla = semillas, sens, check.names = FALSE)
kable(sens, digits = 3, caption = "MAE promedio con distintas semillas (millones de pesos)") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Semilla | Mediana | S1: edad | S2: kilometraje | S3: transmisión |
|---|---|---|---|---|
| 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$`S1: edad` < sens$`S2: kilometraje` &
sens$`S2: kilometraje` < sens$`S3: transmisión` &
sens$`S3: transmisión` < sens$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.
También calculé el error con validación cruzada dejando uno por fuera (LOOCV). En regresión lineal no hace falta reajustar el modelo n veces: el residuo de cada observación cuando se deja por fuera es ei / (1 - hii), donde hii es su apalancamiento.
mae_loo <- function(m) mean(abs(residuals(m) / (1 - hatvalues(m))))
y <- datos$precio_m
mae_loo_mediana <- mean(sapply(1:length(y), function(i) abs(y[i] - median(y[-i]))))
data.frame(
Modelo = c("S1: edad", "S2: kilometraje", "S3: transmisión", "Mediana"),
MAE_LOOCV = c(mae_loo(s1), mae_loo(s2), mae_loo(s3), mae_loo_mediana),
MAE_CV_10x10 = c(mae_s1, mae_s2, mae_s3, mae_med)
) %>%
kable(digits = 3, caption = "Comparación entre LOOCV y validación cruzada 10 x 10") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Modelo | MAE_LOOCV | MAE_CV_10x10 |
|---|---|---|
| S1: edad | 4.391 | 4.390 |
| S2: kilometraje | 7.212 | 7.208 |
| S3: transmisión | 14.403 | 14.400 |
| Mediana | 14.705 | 14.714 |
Los dos métodos dan prácticamente el mismo resultado.
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.