Variable respuesta: precio anunciado (en pesos colombianos) de un Mazda 2 publicado en OLX.
Quién lo usaría: una persona que quiere publicar su Mazda 2 o evaluar si el precio de un anuncio es razonable.
Aclaración importante: el dato es el precio anunciado, no el precio efectivo de venta. Los anuncios no permiten conocer el valor real de la transacción, que normalmente se negocia.
if (!file.exists("base1_mazdacol.xlsx")) {
}
crudo <- as.data.frame( read_excel("C:/Users/nicol/Downloads/base1_mazdacol.xlsx"))
vars <- c("precio", "kilometraje", "transmision", "modelo", "color", "ciudad", "departamento")
crudo <- crudo[, vars]
cat("Registros originales:", nrow(crudo), "\n")
## Registros originales: 314
str(crudo)
## 'data.frame': 314 obs. of 7 variables:
## $ precio : num 34000000 26400000 42500000 91000000 35000000 67000000 45000000 31800000 66900000 27500000 ...
## $ kilometraje : num 140000 112000 102222 1000 134500 ...
## $ transmision : chr "Automática" "Automática" "Automática" "Automática" ...
## $ modelo : num 2008 2012 2013 2022 2012 ...
## $ color : chr "Gris" "Gris" "Gris" "Gris" ...
## $ ciudad : chr "Medellín" "Medellín" "Itagüí" "Envigado" ...
## $ departamento: chr "Antioquia" "Antioquia" "Antioquia" "Antioquia" ...
colSums(is.na(crudo))
## precio kilometraje transmision modelo color ciudad
## 0 0 0 0 0 0
## departamento
## 0
Las variables de interés no tienen valores faltantes, así que no hay que imputar ni eliminar registros por este motivo.
Un anuncio duplicado es aquel que repite todas sus características (precio, kilometraje, transmisión, modelo, color y ciudad). Mantenerlo contaría dos veces el mismo carro.
vars_dup <- c("precio", "kilometraje", "transmision", "modelo", "color", "ciudad")
n_dup <- sum(duplicated(crudo[, vars_dup]))
cat("Duplicados encontrados:", n_dup, "\n")
## Duplicados encontrados: 1
datos1 <- crudo[!duplicated(crudo[, vars_dup]), ]
Antes de aplicar criterios estadísticos, reviso valores que son imposibles o muy improbables para un Mazda 2.
# 1. Precio sospechosamente bajo para un modelo reciente
datos1[datos1$precio < 10e6, c("precio", "modelo", "kilometraje")]
## precio modelo kilometraje
## 280 6800000 2022 17200
# 2. Modelo anterior al lanzamiento del Mazda 2 (2002)
datos1[datos1$modelo < 2002, c("precio", "modelo", "kilometraje")]
## precio modelo kilometraje
## 189 29900000 1995 280000
# 3. Carros de modelos anteriores a 2020 con menos de 1.000 km
datos1[datos1$modelo < 2020 & datos1$kilometraje < 1000, c("precio", "modelo", "kilometraje")]
## precio modelo kilometraje
## 17 36000000 2011 183
## 105 22000000 2008 136
## 168 30000000 2008 174
## 180 36000000 2012 92
## 185 30000000 2008 133
## 196 61000000 2019 54
## 203 28000000 2009 0
## 228 37900000 2013 116
## 236 32900000 2010 114
## 248 66000000 2019 51
## 272 34800000 2011 99
## 298 29000000 2008 129
## 304 63000000 2019 35
Reglas de exclusión y su justificación:
Los carros nuevos (modelo 2020 o más) con kilometraje cercano a cero sí se conservan, porque son consistentes.
excluir <- datos1$precio < 10e6 |
datos1$modelo < 2002 |
(datos1$modelo < 2020 & datos1$kilometraje < 1000)
datos <- datos1[!excluir, ]
datos$transmision <- factor(datos$transmision, levels = c("Mecánica", "Automática"))
datos$auto <- as.integer(datos$transmision == "Automática")
rownames(datos) <- NULL
kable(data.frame(Paso = c("Originales", "Sin duplicados", "Base final"),
Registros = c(nrow(crudo), nrow(datos1), nrow(datos))))
| Paso | Registros |
|---|---|
| Originales | 314 |
| Sin duplicados | 313 |
| Base final | 298 |
Con la base final de 298 registros se hace el resto del análisis. Los valores extremos que quedan (carros muy nuevos y caros) son consistentes con el mercado, por eso no se eliminan.
desc <- t(sapply(datos[, c("precio", "kilometraje", "modelo")], function(x)
c(Media = mean(x), Mediana = median(x), DE = sd(x), Mínimo = min(x), Máximo = max(x))))
kable(round(desc, 0), caption = "Estadísticas de precio, kilometraje y modelo")
| Media | Mediana | DE | Mínimo | Máximo | |
|---|---|---|---|---|---|
| precio | 51280201 | 42700000 | 20472704 | 22000000 | 169800000 |
| kilometraje | 78020 | 81378 | 42735 | 0 | 200000 |
| modelo | 2015 | 2015 | 4 | 2007 | 2022 |
table(datos$transmision)
##
## Mecánica Automática
## 131 167
round(prop.table(table(datos$transmision)) * 100, 1)
##
## Mecánica Automática
## 44 56
sort(table(datos$departamento), decreasing = TRUE)[1:6]
##
## Antioquia ValledelCauca Bogotá Caldas Santander
## 81 51 49 22 20
## Atlántico
## 13
El precio mediano es de 42.7 millones. La media (51.3 millones) es mayor que la mediana, señal de asimetría hacia la derecha por los carros nuevos y caros.
ggplot(datos, aes(x = precio / 1e6)) +
geom_histogram(bins = 25, fill = "steelblue", color = "white") +
labs(title = "Distribución del precio anunciado", x = "Precio (millones de pesos)", y = "Anuncios")
ggplot(datos, aes(x = kilometraje / 1000)) +
geom_histogram(bins = 25, fill = "darkorange", color = "white") +
labs(title = "Distribución del kilometraje", x = "Kilometraje (miles de km)", y = "Anuncios")
ggplot(datos, aes(x = factor(modelo))) +
geom_bar(fill = "seagreen") +
labs(title = "Anuncios por año del modelo", x = "Modelo", y = "Anuncios") +
theme(axis.text.x = element_text(angle = 90, vjust = 0.5))
ggplot(datos, aes(x = modelo, y = precio / 1e6)) +
geom_point(alpha = 0.6) +
geom_smooth(method = "lm", formula = y ~ x, se = FALSE, color = "red") +
labs(title = "Precio según el año del modelo", x = "Modelo", y = "Precio (millones)")
ggplot(datos, aes(x = kilometraje / 1000, y = precio / 1e6)) +
geom_point(alpha = 0.6) +
geom_smooth(method = "lm", formula = y ~ x, se = FALSE, color = "red") +
labs(title = "Precio según el kilometraje", x = "Kilometraje (miles de km)", y = "Precio (millones)")
kable(round(cor(datos[, c("precio", "modelo", "kilometraje")]), 2),
caption = "Matriz de correlación")
| precio | modelo | kilometraje | |
|---|---|---|---|
| precio | 1.00 | 0.86 | -0.80 |
| modelo | 0.86 | 1.00 | -0.87 |
| kilometraje | -0.80 | -0.87 | 1.00 |
ggplot(datos, aes(x = transmision, y = precio / 1e6, fill = transmision)) +
geom_boxplot(show.legend = FALSE) +
labs(title = "Precio según la transmisión", x = NULL, y = "Precio (millones)")
tabla_trans <- data.frame(
Transmisión = levels(datos$transmision),
n = as.vector(table(datos$transmision)),
Media = round(tapply(datos$precio, datos$transmision, mean) / 1e6, 1),
Mediana = round(tapply(datos$precio, datos$transmision, median) / 1e6, 1))
kable(tabla_trans, row.names = FALSE, caption = "Precio por transmisión (millones de pesos)")
| Transmisión | n | Media | Mediana |
|---|---|---|---|
| Mecánica | 131 | 44 | 39.0 |
| Automática | 167 | 57 | 57.9 |
t.test(precio ~ transmision, data = datos)
##
## Welch Two Sample t-test
##
## data: precio by transmision
## t = -6.0902, df = 261.09, p-value = 4.015e-09
## alternative hypothesis: true difference in means between group Mecánica and group Automática is not equal to 0
## 95 percent confidence interval:
## -17088158 -8738047
## sample estimates:
## mean in group Mecánica mean in group Automática
## 44043664 56956767
Los carros automáticos se anuncian más caros en promedio. Pero esta diferencia puede estar mezclada con la edad: si los automáticos tienden a ser más nuevos, parte de la diferencia viene del año y no de la transmisión. Esto lo revisa el modelo múltiple.
Cada modelo usa un solo predictor:
mA <- lm(precio ~ modelo, data = datos)
mB <- lm(precio ~ kilometraje, data = datos)
mC <- lm(precio ~ auto, data = datos)
summary(mA)
##
## Call:
## lm(formula = precio ~ modelo, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -42406500 -4705916 -855682 2969493 101593500
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -8.614e+09 2.953e+08 -29.17 <2e-16 ***
## modelo 4.300e+06 1.465e+05 29.34 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 10370000 on 296 degrees of freedom
## Multiple R-squared: 0.7442, Adjusted R-squared: 0.7433
## F-statistic: 861 on 1 and 296 DF, p-value: < 2.2e-16
summary(mB)
##
## Call:
## lm(formula = precio ~ kilometraje, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -47428444 -6869878 -977426 5148454 102503390
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 8.104e+07 1.498e+06 54.09 <2e-16 ***
## kilometraje -3.814e+02 1.685e+01 -22.64 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 12410000 on 296 degrees of freedom
## Multiple R-squared: 0.6338, Adjusted R-squared: 0.6326
## F-statistic: 512.4 on 1 and 296 DF, p-value: < 2.2e-16
summary(mC)
##
## Call:
## lm(formula = precio ~ auto, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -33456767 -13803491 -2543664 12893836 112843233
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 44043664 1701350 25.887 < 2e-16 ***
## auto 12913102 2272707 5.682 3.19e-08 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 19470000 on 296 degrees of freedom
## Multiple R-squared: 0.09834, Adjusted R-squared: 0.09529
## F-statistic: 32.28 on 1 and 296 DF, p-value: 3.188e-08
extrae <- function(m, nombre) {
s <- summary(m)
data.frame(Modelo = nombre,
Intercepto = round(unname(coef(m)[1])),
Pendiente = round(unname(coef(m)[2]), 2),
`p valor` = signif(s$coefficients[2, 4], 3),
R2 = round(s$r.squared, 3),
check.names = FALSE)
}
kable(rbind(extrae(mA, "A: modelo"), extrae(mB, "B: kilometraje"), extrae(mC, "C: automática")),
row.names = FALSE, caption = "Resumen de los modelos simples")
| Modelo | Intercepto | Pendiente | p valor | R2 |
|---|---|---|---|---|
| A: modelo | -8613729345 | 4300116.8 | 0 | 0.744 |
| B: kilometraje | 81036774 | -381.4 | 0 | 0.634 |
| C: automática | 44043664 | 12913102.4 | 0 | 0.098 |
Se usa validación cruzada de 10 pliegues con pliegues fijos (los mismos se reutilizarán en el Punto 2). La métrica es el MAE (error absoluto medio, en pesos), fácil de interpretar. La referencia sencilla es predecir siempre la mediana del entrenamiento.
set.seed(2026)
# 10 pliegues aleatorios; cada elemento de la lista guarda los índices de prueba
folds <- split(sample(nrow(datos)), rep(1:10, length.out = nrow(datos)))
cv_simple <- function(formula) {
sapply(folds, function(idx) {
fit <- lm(formula, data = datos[-idx, ])
pred <- predict(fit, newdata = datos[idx, ])
mean(abs(datos$precio[idx] - pred))
})
}
mediana_mae <- sapply(folds, function(idx)
mean(abs(datos$precio[idx] - median(datos$precio[-idx]))))
mae_A <- cv_simple(precio ~ modelo)
mae_B <- cv_simple(precio ~ kilometraje)
mae_C <- cv_simple(precio ~ auto)
comparacion <- data.frame(
Modelo = c("Mediana (referencia)", "A: año del modelo", "B: kilometraje", "C: transmisión automática"),
R2 = round(c(NA, summary(mA)$r.squared, summary(mB)$r.squared, summary(mC)$r.squared), 3),
`MAE (millones)` = round(c(mean(mediana_mae), mean(mae_A), mean(mae_B), mean(mae_C)) / 1e6, 2),
`Mejora vs. mediana (%)` = round((1 - c(mean(mediana_mae), mean(mae_A), mean(mae_B), mean(mae_C)) /
mean(mediana_mae)) * 100, 1),
check.names = FALSE)
kable(comparacion, caption = "Desempeño con validación cruzada de 10 pliegues")
| Modelo | R2 | MAE (millones) | Mejora vs. mediana (%) |
|---|---|---|---|
| Mediana (referencia) | NA | 15.91 | 0.0 |
| A: año del modelo | 0.744 | 5.62 | 64.7 |
| B: kilometraje | 0.634 | 8.04 | 49.5 |
| C: transmisión automática | 0.098 | 15.25 | 4.1 |
cv_long <- data.frame(
Modelo = rep(c("Mediana", "A: año", "B: km", "C: automática"), each = length(folds)),
MAE = c(mediana_mae, mae_A, mae_B, mae_C) / 1e6)
ggplot(cv_long, aes(x = Modelo, y = MAE, fill = Modelo)) +
geom_boxplot(show.legend = FALSE) +
labs(title = "MAE por pliegue", y = "MAE (millones de pesos)", x = NULL)
El modelo A (año del modelo) es el mejor: tiene el menor MAE (5.62 millones), supera claramente la referencia de la mediana (15.9 millones), su signo es coherente (más nuevo, más caro) y es muy fácil de explicar.
Reviso los supuestos sobre el modelo A (año del modelo), que es el que mejor predice.
par(mfrow = c(2, 2))
plot(mA)
par(mfrow = c(1, 1))
bp_A <- bptest(mA)$p.value # varianza constante
sh_A <- shapiro.test(residuals(mA))$p.value # normalidad
cook_A <- cooks.distance(mA)
umbral_A <- 4 / nrow(datos)
n_cook_A <- sum(cook_A > umbral_A)
plot(cook_A, type = "h", main = "Distancia de Cook (modelo A)", ylab = "Distancia de Cook")
abline(h = umbral_A, col = "red")
Si la varianza no es constante, el error de predicción es distinto según el precio del carro y los intervalos de predicción dejan de ser confiables. Si los errores no son normales, los p-valores e intervalos son aproximados, aunque la recta estimada cambia poco.
El precio es positivo y asimétrico, y los errores tienden a crecer con el precio. Por eso pruebo el logaritmo de la respuesta: con él los efectos pasan a ser porcentuales, que es más natural en carros.
mA_log <- lm(log(precio) ~ modelo, data = datos)
summary(mA_log)
##
## Call:
## lm(formula = log(precio) ~ modelo, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.93807 -0.06059 0.00608 0.06482 0.94618
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -1.457e+02 3.940e+00 -36.98 <2e-16 ***
## modelo 8.109e-02 1.955e-03 41.47 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.1384 on 296 degrees of freedom
## Multiple R-squared: 0.8532, Adjusted R-squared: 0.8527
## F-statistic: 1720 on 1 and 296 DF, p-value: < 2.2e-16
par(mfrow = c(2, 2)); plot(mA_log); par(mfrow = c(1, 1))
# MAE en pesos: la predicción se devuelve a la escala original con exp()
mae_A_log <- sapply(folds, function(idx) {
fit <- lm(log(precio) ~ modelo, data = datos[-idx, ])
pred <- exp(predict(fit, newdata = datos[idx, ]))
mean(abs(datos$precio[idx] - pred))
})
kable(data.frame(Modelo = c("A original", "A con log(precio)"),
`MAE (millones)` = round(c(mean(mae_A), mean(mae_A_log)) / 1e6, 2),
check.names = FALSE),
caption = "Comparación en pesos")
| Modelo | MAE (millones) |
|---|---|
| A original | 5.62 |
| A con log(precio) | 4.80 |
En el modelo con logaritmo, cada año más nuevo cambia el precio en 8.4 %. El MAE en pesos pasa de 5.62 a 4.8 millones, así que la transformación reduce el error y la adopto para el modelo simple. No comparo el R² de los dos modelos porque están en escalas distintas.
esc_A <- data.frame(modelo = c(2012, 2018))
usa_log_A <- mean(mae_A_log) < mean(mae_A)
if (usa_log_A) {
pred_A <- exp(predict(mA_log, newdata = esc_A, interval = "prediction", level = 0.95))
} else {
pred_A <- predict(mA, newdata = esc_A, interval = "prediction", level = 0.95)
}
kable(data.frame(Escenario = c("Mazda 2 modelo 2012", "Mazda 2 modelo 2018"),
`Precio estimado (millones)` = round(pred_A[, "fit"] / 1e6, 1),
`Límite inferior 95 %` = round(pred_A[, "lwr"] / 1e6, 1),
`Límite superior 95 %` = round(pred_A[, "upr"] / 1e6, 1),
check.names = FALSE))
| Escenario | Precio estimado (millones) | Límite inferior 95 % | Límite superior 95 % |
|---|---|---|---|
| Mazda 2 modelo 2012 | 37.4 | 28.4 | 49.1 |
| Mazda 2 modelo 2018 | 60.8 | 46.3 | 79.9 |
El intervalo de predicción indica entre qué valores caería el precio anunciado de un carro concreto con ese año. El MAE de validación cruzada del modelo A es 5.62 millones, cerca de 13 % del precio mediano, y mejora 65 % frente a predecir siempre la mediana.
Hay 20 departamentos y varios tienen entre 1 y 3 anuncios, muy pocos para estimar un coeficiente confiable. Por eso agrupo con una regla fija (no aprendida de los datos): Antioquia, Valle del Cauca, Bogotá y Otros. La categoría de referencia es Antioquia (la más numerosa): cada coeficiente de región se lee como la diferencia frente a un carro igual anunciado en Antioquia.
datos$region <- ifelse(datos$departamento == "Antioquia", "Antioquia",
ifelse(datos$departamento == "ValledelCauca", "Valle del Cauca",
ifelse(datos$departamento == "Bogotá", "Bogotá", "Otros")))
datos$region <- relevel(factor(datos$region), ref = "Antioquia")
table(datos$region)
##
## Antioquia Bogotá Otros Valle del Cauca
## 81 49 117 51
Usa los mismos 10 pliegues del Punto 1. Si se pide
usar_step, la selección de variables se hace dentro
de cada entrenamiento, nunca con los datos de prueba. Si la
respuesta es log(precio), las predicciones se devuelven a
pesos con exp() antes de calcular el MAE,
para que sea comparable con los demás modelos.
cv_mae <- function(formula, data, folds, usar_step = FALSE, log_y = FALSE) {
sapply(folds, function(idx) {
train <- data[-idx, ]
test <- data[idx, ]
fit <- do.call("lm", list(formula = formula, data = train)) # do.call: necesario para que step() funcione dentro de la función
if (usar_step) fit <- step(fit, trace = 0)
pred <- predict(fit, newdata = test)
if (log_y) pred <- exp(pred)
mean(abs(test$precio - pred))
})
}
f1 <- precio ~ modelo + kilometraje + auto
f2 <- precio ~ modelo + kilometraje + auto + region
m1 <- lm(f1, data = datos)
m2 <- lm(f2, data = datos)
summary(m1)
##
## Call:
## lm(formula = f1, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -36539843 -3961827 -789478 3125135 99377362
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -6.729e+09 5.923e+08 -11.362 < 2e-16 ***
## modelo 3.367e+06 2.930e+05 11.491 < 2e-16 ***
## kilometraje -8.694e+01 2.792e+01 -3.113 0.00203 **
## auto 5.092e+06 1.194e+06 4.264 2.71e-05 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 9955000 on 294 degrees of freedom
## Multiple R-squared: 0.7659, Adjusted R-squared: 0.7636
## F-statistic: 320.7 on 3 and 294 DF, p-value: < 2.2e-16
summary(m2)
##
## Call:
## lm(formula = f2, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -38793897 -4199898 -534177 3314069 96450526
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -6.525e+09 5.975e+08 -10.920 < 2e-16 ***
## modelo 3.266e+06 2.957e+05 11.047 < 2e-16 ***
## kilometraje -9.417e+01 2.808e+01 -3.353 0.000904 ***
## auto 4.614e+06 1.216e+06 3.794 0.000180 ***
## regionBogotá -2.553e+06 1.809e+06 -1.412 0.159061
## regionOtros -1.198e+06 1.467e+06 -0.817 0.414762
## regionValle del Cauca 2.823e+06 1.788e+06 1.579 0.115435
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 9866000 on 291 degrees of freedom
## Multiple R-squared: 0.7724, Adjusted R-squared: 0.7678
## F-statistic: 164.6 on 6 and 291 DF, p-value: < 2.2e-16
mae_m1 <- cv_mae(f1, datos, folds)
mae_m2 <- cv_mae(f2, datos, folds)
kable(data.frame(
Modelo = c("M1: año + km + transmisión", "M2: M1 + región"),
`R2 ajustado` = round(c(summary(m1)$adj.r.squared, summary(m2)$adj.r.squared), 3),
AIC = round(c(AIC(m1), AIC(m2)), 1),
`MAE (millones)` = round(c(mean(mae_m1), mean(mae_m2)) / 1e6, 2),
check.names = FALSE), caption = "Modelos múltiples (MAE con validación cruzada)")
| Modelo | R2 ajustado | AIC | MAE (millones) |
|---|---|---|---|
| M1: año + km + transmisión | 0.764 | 10455.4 | 5.36 |
| M2: M1 + región | 0.768 | 10453.0 | 5.46 |
Los coeficientes se leen manteniendo lo demás constante. En M1, cada año más nuevo suma 3.366.985 pesos al precio (en el modelo simple eran 4.300.117), cada 1.000 km restan 86.936 pesos (antes 381.396) y un automático cuesta 5.091.845 pesos más que un mecánico (antes 12.913.102). Los coeficientes cambian respecto a los del modelo simple porque año, kilometraje y transmisión están relacionados entre sí; el año y el kilometraje, además, miden casi lo mismo (la edad del carro).
El R² ajustado es 0.764 en M1 y 0.768 en M2; se usa el ajustado porque descuenta las variables que no aportan. De los tres coeficientes de región, 0 son significativos al 5 %. Todos son asociaciones, no efectos causales.
El step agrega o quita variables buscando el menor AIC
(mejor equilibrio entre ajuste y simplicidad).
m2_step <- step(m2, direction = "both", trace = 0)
m2_step$anova # pasos dados por el step (vacío si no quitó ni agregó nada)
## Step Df Deviance Resid. Df Resid. Dev AIC
## 1 NA NA 291 2.832639e+16 9605.272
formula(m2_step)
## precio ~ modelo + kilometraje + auto + region
# Evaluación honesta: el step se repite dentro de cada pliegue de entrenamiento
mae_m2_step <- cv_mae(f2, datos, folds, usar_step = TRUE)
mean(mae_m2_step) / 1e6
## [1] 5.475808
El primer step muestra qué variables entran o salen. En
este caso no eliminó ninguna: el AIC indica que quitar variables no
mejora el equilibrio entre ajuste y simplicidad. El segundo cálculo
repite el step dentro de cada entrenamiento y es el que se
usa para comparar modelos.
par(mfrow = c(2, 2))
plot(m2_step)
par(mfrow = c(1, 1))
bptest(m2_step) # varianza constante
##
## studentized Breusch-Pagan test
##
## data: m2_step
## BP = 15.039, df = 6, p-value = 0.01995
shapiro.test(residuals(m2_step)) # normalidad
##
## Shapiro-Wilk normality test
##
## data: residuals(m2_step)
## W = 0.64052, p-value < 2.2e-16
vif(m2_step) # multicolinealidad
## GVIF Df GVIF^(1/(2*Df))
## modelo 4.498734 1 2.121022
## kilometraje 4.394778 1 2.096373
## auto 1.115636 1 1.056236
## region 1.110441 3 1.017613
dwtest(m2_step) # independencia
##
## Durbin-Watson test
##
## data: m2_step
## DW = 1.4571, p-value = 5.327e-07
## alternative hypothesis: true autocorrelation is greater than 0
cook <- cooks.distance(m2_step)
umbral <- 4 / nrow(datos)
plot(cook, type = "h", main = "Distancia de Cook", ylab = "Distancia de Cook")
abline(h = umbral, col = "red")
sum(cook > umbral) # cuántos superan el umbral
## [1] 7
max(cook) # el más influyente
## [1] 0.3274549
datos[which(cook > umbral), c("precio", "modelo", "kilometraje", "auto")]
## precio modelo kilometraje auto
## 29 119900000 2021 29000 1
## 120 28900000 2017 12345 0
## 259 159900000 2022 0 1
## 260 169800000 2019 36026 1
## 271 35000000 2007 148000 0
## 284 25800000 2019 129000 1
## 296 107500000 2021 17909 1
Qué implica cada supuesto si no se cumple:
| Supuesto | Qué mirar | Consecuencia si falla |
|---|---|---|
| Linealidad | Línea roja de “Residuos vs. ajustados” casi plana | Se subestiman o sobrestiman sistemáticamente ciertos tipos de carro |
| Varianza constante | Forma de embudo; p de Breusch-Pagan < 0,05 | El error es mayor en carros caros; los intervalos de predicción quedan mal calibrados |
| Normalidad | Puntos del Q-Q lejos de la diagonal; p de Shapiro < 0,05 | Los intervalos y p-valores son aproximados; las predicciones puntuales cambian poco |
| Multicolinealidad | VIF mayor a 5 (preocupa más allá de 10) | Coeficientes inestables (año y km se “pisan”), pero la predicción casi no se afecta |
| Independencia | Durbin-Watson cerca de 2 | Aquí no es serie de tiempo; cada anuncio es de un vendedor distinto |
| Influyentes | Puntos sobre la línea roja de Cook | Unos pocos anuncios pueden mover los coeficientes; revisar si son errores o carros especiales |
En el modelo elegido por el step, Breusch-Pagan da p = 0.02 (la varianza no es constante) y Shapiro-Wilk da p = 1.4^{-24} (los errores no son normales). El VIF más alto es 4.5, por debajo de 5, pero cercano si año y kilometraje se parecen mucho. Durbin-Watson vale 1.46 y 7 anuncios superan el umbral de Cook.
El precio es positivo y asimétrico, y el error crece con el precio.
Con log(precio) los efectos pasan a ser porcentuales, lo
que tiene sentido en carros: un año más de antigüedad resta un
porcentaje del precio, no una cantidad fija de pesos.
f_log <- log(precio) ~ modelo + kilometraje + auto + region
m_log <- lm(f_log, data = datos)
summary(m_log)
##
## Call:
## lm(formula = f_log, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.85581 -0.05965 -0.00004 0.06346 0.89052
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -1.143e+02 7.976e+00 -14.325 < 2e-16 ***
## modelo 6.552e-02 3.947e-03 16.601 < 2e-16 ***
## kilometraje -1.483e-06 3.749e-07 -3.957 9.55e-05 ***
## auto 6.047e-02 1.624e-02 3.724 0.000235 ***
## regionBogotá -4.037e-02 2.414e-02 -1.672 0.095569 .
## regionOtros -9.021e-03 1.958e-02 -0.461 0.645360
## regionValle del Cauca 2.161e-02 2.387e-02 0.905 0.366041
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.1317 on 291 degrees of freedom
## Multiple R-squared: 0.8693, Adjusted R-squared: 0.8666
## F-statistic: 322.5 on 6 and 291 DF, p-value: < 2.2e-16
m_log_step <- step(m_log, trace = 0)
formula(m_log_step)
## log(precio) ~ modelo + kilometraje + auto
# MAE en PESOS (no en escala log), con step dentro de cada pliegue
mae_log_step <- cv_mae(f_log, datos, folds, usar_step = TRUE, log_y = TRUE)
mean(mae_log_step) / 1e6
## [1] 4.607655
# AIC comparable: se suma el jacobiano 2*sum(log(y)) para pasar a la escala original
aic_log_ajustado <- AIC(m_log) + 2 * sum(log(datos$precio))
c(AIC_original = AIC(m2), AIC_log_ajustado = aic_log_ajustado)
## AIC_original AIC_log_ajustado
## 10452.96 10186.50
par(mfrow = c(2, 2)); plot(m_log_step); par(mfrow = c(1, 1))
bptest(m_log_step)
##
## studentized Breusch-Pagan test
##
## data: m_log_step
## BP = 4.0617, df = 3, p-value = 0.2549
shapiro.test(residuals(m_log_step))
##
## Shapiro-Wilk normality test
##
## data: residuals(m_log_step)
## W = 0.80122, p-value < 2.2e-16
En el modelo con logaritmo, un coeficiente b equivale a
un cambio de (exp(b) − 1) × 100 % en el precio:
round((exp(coef(m_log_step)) - 1) * 100, 2) # % de cambio en el precio por cada unidad de la variable
## (Intercept) modelo kilometraje auto
## -100.00 6.90 0.00 6.72
El R² del modelo con logaritmo no se compara con el del modelo original porque están en escalas distintas; la comparación válida es el MAE en pesos y el AIC ajustado. La transformación se justifica si el MAE en pesos baja y los residuos mejoran.
maes <- c(mean(mediana_mae), mean(mae_A), mean(mae_B), mean(mae_C),
mean(mae_m1), mean(mae_m2), mean(mae_m2_step), mean(mae_log_step))
tabla_final <- data.frame(
Modelo = c("Referencia: mediana", "A: año", "B: kilometraje", "C: transmisión",
"M1: año + km + transmisión", "M2: M1 + región", "M2 con step",
"M2 con log(precio) + step"),
`MAE (millones)` = round(maes / 1e6, 2),
`Mejora vs. mediana (%)` = round((1 - maes / maes[1]) * 100, 1),
check.names = FALSE)
kable(tabla_final[order(tabla_final$`MAE (millones)`), ], row.names = FALSE,
caption = "Todos los modelos con los mismos 10 pliegues")
| Modelo | MAE (millones) | Mejora vs. mediana (%) |
|---|---|---|
| M2 con log(precio) + step | 4.61 | 71.0 |
| M1: año + km + transmisión | 5.36 | 66.3 |
| M2: M1 + región | 5.46 | 65.7 |
| M2 con step | 5.48 | 65.6 |
| A: año | 5.62 | 64.7 |
| B: kilometraje | 8.04 | 49.5 |
| C: transmisión | 15.25 | 4.1 |
| Referencia: mediana | 15.91 | 0.0 |
ggplot(tabla_final, aes(x = reorder(Modelo, -`MAE (millones)`), y = `MAE (millones)`)) +
geom_col(fill = "steelblue") +
geom_text(aes(label = `MAE (millones)`), hjust = -0.15) +
coord_flip() + expand_limits(y = max(tabla_final$`MAE (millones)`) * 1.1) +
labs(title = "Error promedio de cada modelo", x = NULL, y = "MAE (millones de pesos)")
Entre los modelos múltiples elijo el de menor MAE en pesos: el modelo con log(precio). Si un modelo mejora muy poco a otro más simple, conviene el simple por interpretabilidad; aquí lo comparo con el mejor modelo simple (A) en la tabla.
Se eligen carros realistas: combinaciones de año y kilometraje coherentes con lo que hay en la base (un carro de 2019 con 35.000 km es normal; uno de 2008 con 5.000 km no lo sería).
usa_log <- mean(mae_log_step) < mean(mae_m2_step) # se elige el de menor MAE en pesos
mejor <- if (usa_log) m_log_step else m2_step
escenarios <- data.frame(
modelo = c(2013, 2019),
kilometraje = c(95000, 35000),
auto = c(0, 1),
region = factor(c("Valle del Cauca", "Antioquia"), levels = levels(datos$region)))
pred_esc <- predict(mejor, newdata = escenarios, interval = "prediction", level = 0.95)
if (usa_log) pred_esc <- exp(pred_esc)
kable(data.frame(
Escenario = c("1: Mazda 2 modelo 2013, 95.000 km, mecánico, Valle del Cauca",
"2: Mazda 2 modelo 2019, 35.000 km, automático, Antioquia"),
`Precio estimado (millones)` = round(pred_esc[, "fit"] / 1e6, 1),
`Límite inferior 95 %` = round(pred_esc[, "lwr"] / 1e6, 1),
`Límite superior 95 %` = round(pred_esc[, "upr"] / 1e6, 1),
check.names = FALSE), caption = "Predicciones con intervalo de predicción del 95 %")
| Escenario | Precio estimado (millones) | Límite inferior 95 % | Límite superior 95 % |
|---|---|---|---|
| 1: Mazda 2 modelo 2013, 95.000 km, mecánico, Valle del Cauca | 39.3 | 30.3 | 51.1 |
| 2: Mazda 2 modelo 2019, 35.000 km, automático, Antioquia | 68.0 | 52.4 | 88.3 |
El intervalo de predicción indica entre qué valores caería el precio anunciado de un carro concreto con esas características, no el promedio de todos los carros; por eso es más ancho que un intervalo de confianza.
En todos los casos el modelo es una referencia para negociar, no un avalúo oficial.
El modelo elegido (con log(precio)) se equivoca en promedio unos 4.6 millones de pesos, cerca del 11 % del precio mediano (42.7 millones). Es 71 % mejor que decir siempre “el precio típico” (la mediana).
Para un carro de 40 millones, equivale a un error de unos 4 o 5 millones. Es aceptable como orientación (saber si un precio es razonable o claramente alto), pero no lo es para fijar un precio de compra exacto: una diferencia de ese tamaño pesa en una negociación. Los intervalos de los escenarios lo confirman: tienen un ancho de 21 y 36 millones.
| Si el modelo… | Vendedor | Comprador |
|---|---|---|
| Sobreestima (dice más de lo real) | Pone un precio muy alto, tarda más en vender o pierde interesados | Cree que un anuncio caro es “normal” y paga de más |
| Subestima (dice menos de lo real) | Regala parte del valor del carro | Descarta un buen negocio o hace una oferta que el vendedor rechaza |
Como el error es mayor en carros caros, el riesgo crece justo en las operaciones de más dinero.
Recomendación: usarlo como herramienta de orientación, no como decisor. Sirve para detectar anuncios fuera de lo normal y para tener un punto de partida, siempre acompañado de la inspección del carro y de la negociación.
Información adicional que mejoraría el modelo: precios reales de venta (no solo anunciados), versión y equipamiento, estado mecánico y de carrocería, número de dueños y fecha del anuncio para ajustar por el tiempo.