El caso estima el precio que pide un vendedor por un Mazda 2 usado en OLX. La base 1 reúne los anuncios, con el precio en pesos colombianos, y la pregunta es cuánto de las diferencias de precio se explica con características del carro que cualquier comprador ve en el anuncio. La respuesta es el precio anunciado.
Ese precio es el que pide el vendedor, no el que paga el comprador. Las conclusiones valen, por tanto, para precios de anuncio y no para precios de venta.
library(readxl) # leer el archivo de Excel
library(ggplot2) # construir los gráficos
library(plotly) # volver interactivos los gráficos
datos = read_excel("base1_mazdacol.xlsx")
dim(datos)
## [1] 314 11
names(datos)
## [1] "precio" "kilometraje" "transmision"
## [4] "modelo" "color" "ciudad"
## [7] "departamento" "web-scraper-order" "web-scraper-start-url"
## [10] "link" "link-href"
La base trae 314 anuncios y 11 columnas. Siete describen el carro (precio, kilometraje, transmisión, modelo, color, ciudad y departamento). Las otras cuatro son rastro de la descarga automática, con el orden de descarga, los enlaces y el texto del anuncio, y no entran al análisis.
Se trabaja con la base completa, sin excluir registros, y con las características del carro expresadas en unidades legibles.
datos$precio_mill = datos$precio / 1000000 # precio en millones de pesos
datos$antiguedad = 2022 - datos$modelo # años de uso (anuncios descargados en septiembre de 2022)
datos$km_mil = datos$kilometraje / 1000 # kilometraje en miles de km
datos$transmision = factor(datos$transmision, levels = c("Automática", "Mecánica"))
# Región: los tres mercados más grandes y el resto del país
datos$region = ifelse(datos$departamento %in% c("Bogotá", "Antioquia", "ValledelCauca"), datos$departamento, "Resto")
datos$region = factor(datos$region, levels = c("Bogotá", "Antioquia", "ValledelCauca", "Resto"),
labels = c("Bogotá", "Antioquia", "Valle del Cauca", "Resto del país"))
# Color: los tres más comunes y el resto agrupado
datos$color_g = ifelse(datos$color %in% c("Gris", "Blanco", "Rojo"), datos$color, "Otro")
datos$color_g = factor(datos$color_g, levels = c("Gris", "Blanco", "Rojo", "Otro"))
# Texto que aparece al pasar el cursor sobre cada anuncio en los gráficos
datos$anuncio = paste0("Modelo ", datos$modelo, ", ", datos$transmision, ", ", datos$ciudad)
| Variable | Tipo | Qué mide |
|---|---|---|
| precio_mill | Cuantitativa (respuesta) | Precio anunciado, en millones de pesos |
| antiguedad | Cuantitativa | Años del carro: 2022 menos el año del modelo |
| km_mil | Cuantitativa | Kilómetros recorridos, en miles |
| transmision | Categórica binaria | Automática o mecánica |
| region | Categórica | Bogotá, Antioquia, Valle del Cauca o resto del país |
| color_g | Categórica | Gris, blanco, rojo u otro |
La antigüedad sustituye al año del modelo porque se interpreta mejor. Se mide contra 2022, ya que la marca de tiempo de la columna de orden de descarga indica que los anuncios se bajaron el 12 de septiembre de ese año, de modo que un modelo 2018 tiene 4 años. La región y el color se agrupan porque la base trae 20 departamentos y 14 colores, varios con uno o dos anuncios, y con tan pocos casos no se puede estimar nada estable para cada uno.
Faltantes y duplicados
colSums(is.na(datos[, c("precio", "kilometraje", "transmision", "modelo", "color", "departamento")]))
## precio kilometraje transmision modelo color departamento
## 0 0 0 0 0 0
sum(duplicated(datos)) # filas exactamente iguales
## [1] 0
caracteristicas = c("precio", "kilometraje", "modelo", "transmision", "color", "ciudad")
sum(duplicated(datos[, caracteristicas])) # anuncios con las mismas características del carro
## [1] 1
No hay datos faltantes ni filas idénticas. Solo 1 par de anuncios comparte todas las características del carro, aunque con títulos y fechas de publicación distintos. Podrían ser el mismo carro publicado dos veces; la base no lo confirma y se conservan ambos.
Valores atípicos
Se aplica la regla de la caja, que marca como atípico el valor que supera el tercer cuartil en más de 1,5 veces el rango intercuartílico.
ggplotly(
ggplot(datos, aes(x = "", y = precio_mill)) +
geom_boxplot(fill = "lightblue") +
labs(title = "Figura 1. Boxplot del precio anunciado", x = "", y = "Precio (millones de pesos)") +
theme_minimal()
)
ggplotly(
ggplot(datos, aes(x = "", y = km_mil)) +
geom_boxplot(fill = "moccasin") +
labs(title = "Figura 2. Boxplot del kilometraje", x = "", y = "Kilometraje (miles de km)") +
theme_minimal()
)
# Límite superior de la regla de la caja: Q3 + 1,5 x rango intercuartílico
lim_precio = quantile(datos$precio_mill, 0.75) + 1.5 * IQR(datos$precio_mill)
lim_km = quantile(datos$km_mil, 0.75) + 1.5 * IQR(datos$km_mil)
datos[datos$precio_mill > lim_precio, c("precio_mill", "modelo", "kilometraje", "transmision")]
| precio_mill | modelo | kilometraje | transmision |
|---|---|---|---|
| 119.9 | 2021 | 29000 | Automática |
| 159.9 | 2022 | 0 | Automática |
| 169.8 | 2019 | 36026 | Automática |
| 107.5 | 2021 | 17909 | Automática |
datos[datos$km_mil > lim_km, c("precio_mill", "modelo", "kilometraje")]
| precio_mill | modelo | kilometraje |
|---|---|---|
| 29.9 | 1995 | 280000 |
Hay 4 precios atípicos, todos de modelos 2019 a 2022 anunciados por encima de 105 millones, y 1 kilometraje atípico: un modelo 1995 con 280.000 km. La regla no marca otros valores que también llaman la atención, como un carro de 2022 a 6,8 millones y 9 carros de más de diez años con menos de 1.000 km. Pueden ser errores de digitación o carros casi sin uso, y la base no permite distinguirlos.
Se conservan todos los anuncios. Son lo que se publicó, y los datos no ofrecen un criterio para separar errores de casos reales; descartar los extremos sería una decisión sin respaldo. La consecuencia es que estos anuncios pueden inflar el error de los modelos, y eso se tiene presente al leer los resultados.
Variables cuantitativas
cuantitativas = c("precio_mill", "antiguedad", "km_mil")
summary(datos[, cuantitativas])
## precio_mill antiguedad km_mil
## Min. : 6.80 Min. : 0.000 Min. : 0.00
## 1st Qu.: 35.00 1st Qu.: 4.000 1st Qu.: 39.12
## Median : 42.00 Median : 7.000 Median : 79.00
## Mean : 50.47 Mean : 7.131 Mean : 75.00
## 3rd Qu.: 63.00 3rd Qu.:11.000 3rd Qu.:107.38
## Max. :169.80 Max. :27.000 Max. :280.00
sapply(datos[, cuantitativas], sd) # desviación estándar
## precio_mill antiguedad km_mil
## 20.541624 4.324552 46.232983
El anuncio típico es un Mazda 2 de 7 años y 79 mil kilómetros, publicado en 42,0 millones (medianas). El promedio, 50,5 millones, queda por encima porque unos pocos anuncios muy caros lo arrastran, y por eso la mediana es la medida más fiel de lo típico.
# Líneas verticales de la mediana y el promedio
lineas = data.frame(medida = c("Mediana", "Promedio"),
valor = c(median(datos$precio_mill), mean(datos$precio_mill)))
ggplotly(
ggplot(datos, aes(x = precio_mill)) +
geom_histogram(binwidth = 5, boundary = 0, fill = "steelblue", color = "white") +
geom_vline(data = lineas, aes(xintercept = valor, color = medida), linewidth = 1) +
scale_color_manual(values = c("Mediana" = "red", "Promedio" = "darkgreen")) +
labs(title = "Figura 3. ¿Cuánto piden por un Mazda 2?", x = "Precio anunciado (millones de pesos)",
y = "Número de anuncios", color = "") +
theme_minimal()
)
El 49 % de los anuncios cuesta entre 30 y 45 millones. A la derecha hay una cola larga: el 16 % supera los 70 millones y 4 pasan de 100.
Entre 45 y 50 millones hay solo 2 anuncios, así que el histograma muestra dos masas separadas. La Figura 6 muestra de dónde sale ese hueco: los carros de hasta 6 años tienen una mediana de 66 millones y los de 7 años o más, de 36.
ggplotly(
ggplot(datos, aes(x = antiguedad)) +
geom_histogram(binwidth = 1, fill = "lightseagreen", color = "white") +
labs(title = "Figura 4. Antigüedad de los carros", x = "Antigüedad (años)", y = "Número de anuncios") +
theme_minimal()
)
ggplotly(
ggplot(datos, aes(x = km_mil)) +
geom_histogram(binwidth = 10, boundary = 0, fill = "darkorange", color = "white") +
labs(title = "Figura 5. Kilometraje de los carros", x = "Kilometraje (miles de km)", y = "Número de anuncios") +
theme_minimal()
)
Casi todos los carros tienen entre 0 y 15 años, y solo 1 pasa de ahí (el modelo 1995). El kilometraje está más repartido: la mitad de los anuncios queda entre 39 mil y 107 mil km, y la barra pegada a cero reúne carros publicados con casi ningún recorrido.
Variables categóricas
lapply(datos[, c("transmision", "region", "color_g")], table) # número de anuncios por categoría
## $transmision
##
## Automática Mecánica
## 174 140
##
## $region
##
## Bogotá Antioquia Valle del Cauca Resto del país
## 51 83 55 125
##
## $color_g
##
## Gris Blanco Rojo Otro
## 105 76 44 89
La transmisión se reparte casi por mitades, con 174 automáticos y 140 mecánicos. Bogotá, Antioquia y el Valle del Cauca reúnen el 60 % de los anuncios, y el gris, con 105, es el color más frecuente.
Variables cuantitativas: dispersión y correlación
cor(datos[, cuantitativas])
## precio_mill antiguedad km_mil
## precio_mill 1.0000000 -0.8295419 -0.6543672
## antiguedad -0.8295419 1.0000000 0.7436836
## km_mil -0.6543672 0.7436836 1.0000000
ggplotly(
ggplot(datos, aes(x = antiguedad, y = precio_mill, text = anuncio)) +
geom_point(color = "steelblue", alpha = 0.7, size = 2) +
labs(title = paste("Figura 6. Precio y antigüedad. Correlación:", fmt(cor(datos$precio_mill, datos$antiguedad), 2)),
x = "Antigüedad (años)", y = "Precio (millones de pesos)") +
theme_minimal()
)
ggplotly(
ggplot(datos, aes(x = km_mil, y = precio_mill, text = anuncio)) +
geom_point(color = "darkorange", alpha = 0.7, size = 2) +
labs(title = paste("Figura 7. Precio y kilometraje. Correlación:", fmt(cor(datos$precio_mill, datos$km_mil), 2)),
x = "Kilometraje (miles de km)", y = "Precio (millones de pesos)") +
theme_minimal()
)
El precio baja a medida que el carro es más viejo y más recorrido. Con la antigüedad la correlación es de -0,83 y la nube forma una franja estrecha de pendiente negativa. Con el kilometraje es de -0,65 y los puntos se dispersan más. Los cuatro anuncios de más de 100 millones, todos modelos 2019 a 2022, quedan por encima de la franja, y el modelo 1995 queda aislado a la derecha en ambos gráficos.
La Figura 6 también explica el hueco del histograma de precios. A partir de los 7 años casi ningún anuncio pasa de 47,5 millones, y hasta los 6 casi ninguno baja de ahí: solo 5 de los 314 anuncios son excepciones.
La antigüedad y el kilometraje también están muy relacionados entre sí (0,74), como cabía esperar: un carro más viejo ha tenido más tiempo para recorrer kilómetros. Es probable que cada una repita en parte la información de la otra.
Variables categóricas: comparación entre grupos
ggplotly(
ggplot(datos, aes(x = transmision, y = precio_mill)) +
geom_boxplot(fill = "lightblue") +
labs(title = "Figura 8. Precio según la transmisión", x = "", y = "Precio (millones de pesos)") +
theme_minimal()
)
ggplotly(
ggplot(datos, aes(x = region, y = precio_mill)) +
geom_boxplot(fill = "lightyellow") +
labs(title = "Figura 9. Precio según la región", x = "", y = "Precio (millones de pesos)") +
theme_minimal()
)
ggplotly(
ggplot(datos, aes(x = color_g, y = precio_mill)) +
geom_boxplot(fill = "mistyrose") +
labs(title = "Figura 10. Precio según el color", x = "", y = "Precio (millones de pesos)") +
theme_minimal()
)
# Mediana del precio y de la antigüedad en cada grupo
por_trans = aggregate(cbind(precio_mill, antiguedad) ~ transmision, data = datos, FUN = median)
por_region = aggregate(cbind(precio_mill, antiguedad) ~ region, data = datos, FUN = median)
por_color = aggregate(cbind(precio_mill, antiguedad) ~ color_g, data = datos, FUN = median)
por_trans
| transmision | precio_mill | antiguedad |
|---|---|---|
| Automática | 56.95 | 6 |
| Mecánica | 38.50 | 9 |
por_region
| region | precio_mill | antiguedad |
|---|---|---|
| Bogotá | 40.0 | 8 |
| Antioquia | 42.0 | 8 |
| Valle del Cauca | 56.5 | 5 |
| Resto del país | 41.5 | 8 |
por_color
| color_g | precio_mill | antiguedad |
|---|---|---|
| Gris | 42.0 | 8 |
| Blanco | 50.0 | 6 |
| Rojo | 57.4 | 5 |
| Otro | 37.5 | 9 |
En las tres comparaciones los grupos que se anuncian más caros son también los más nuevos, como muestra la antigüedad mediana de cada tabla.
La antigüedad aparece detrás de casi todas las diferencias entre grupos. Eso la convierte en la primera candidata a predictor y obliga a leer con cautela cualquier diferencia entre categorías, porque puede ser la edad con otro nombre.
Se proponen tres modelos con un solo predictor cada uno, estimados
por mínimos cuadrados con lm:
simple_edad = lm(precio_mill ~ antiguedad, data = datos)
simple_km = lm(precio_mill ~ km_mil, data = datos)
simple_trans = lm(precio_mill ~ transmision, data = datos)
summary(simple_edad)
##
## Call:
## lm(formula = precio_mill ~ antiguedad, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -71.764 -4.601 -0.772 3.182 103.057
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 78.5643 1.2518 62.76 <2e-16 ***
## antiguedad -3.9403 0.1502 -26.24 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 11.49 on 312 degrees of freedom
## Multiple R-squared: 0.6881, Adjusted R-squared: 0.6871
## F-statistic: 688.4 on 1 and 312 DF, p-value: < 2.2e-16
summary(simple_km)
##
## Call:
## lm(formula = precio_mill ~ km_mil, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -60.471 -6.915 -0.489 6.727 108.002
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 72.27177 1.67502 43.15 <2e-16 ***
## km_mil -0.29074 0.01902 -15.29 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 15.56 on 312 degrees of freedom
## Multiple R-squared: 0.4282, Adjusted R-squared: 0.4264
## F-statistic: 233.6 on 1 and 312 DF, p-value: < 2.2e-16
summary(simple_trans)
##
## Call:
## lm(formula = precio_mill ~ transmision, data = datos)
##
## Residuals:
## Min 1Q Median 3Q Max
## -36.737 -14.044 -3.787 13.263 113.756
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 56.044 1.486 37.705 < 2e-16 ***
## transmisionMecánica -12.506 2.226 -5.618 4.28e-08 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 19.61 on 312 degrees of freedom
## Multiple R-squared: 0.09187, Adjusted R-squared: 0.08896
## F-statistic: 31.56 on 1 and 312 DF, p-value: 4.276e-08
ggplotly(
ggplot(datos, aes(x = antiguedad, y = precio_mill)) +
geom_point(color = "grey60", alpha = 0.7, size = 2) +
geom_line(aes(y = predict(simple_edad)), color = "red", linewidth = 1) +
labs(title = "Figura 11. Modelo 1: precio según la antigüedad", x = "Antigüedad (años)",
y = "Precio (millones de pesos)") +
theme_minimal()
)
ggplotly(
ggplot(datos, aes(x = km_mil, y = precio_mill)) +
geom_point(color = "grey60", alpha = 0.7, size = 2) +
geom_line(aes(y = predict(simple_km)), color = "red", linewidth = 1) +
labs(title = "Figura 12. Modelo 2: precio según el kilometraje", x = "Kilometraje (miles de km)",
y = "Precio (millones de pesos)") +
theme_minimal()
)
# Predicción del modelo para cada tipo de transmisión
pred_trans = data.frame(transmision = c("Automática", "Mecánica"))
pred_trans$precio_mill = predict(simple_trans, pred_trans)
ggplotly(
ggplot(datos, aes(x = transmision, y = precio_mill)) +
geom_boxplot(fill = "lightblue") +
geom_point(data = pred_trans, color = "red", shape = 18, size = 5) +
labs(title = "Figura 13. Modelo 3: precio según la transmisión", x = "", y = "Precio (millones de pesos)") +
theme_minimal()
)
En la Figura 13 los rombos rojos son las predicciones del modelo y coinciden con el promedio de cada grupo: con un predictor binario, la regresión reproduce las dos medias.
modelos = list("1. Antigüedad" = simple_edad, "2. Kilometraje" = simple_km, "3. Transmisión" = simple_trans)
# De cada modelo se extraen el intercepto, la pendiente, el R² y el valor p de la pendiente
resumen = data.frame(
Intercepto = sapply(modelos, function(m) coef(m)[[1]]),
Pendiente = sapply(modelos, function(m) coef(m)[[2]]),
R2 = sapply(modelos, function(m) summary(m)$r.squared),
Valor_p = sapply(modelos, function(m) summary(m)$coefficients[2, 4]))
data.frame(round(resumen[, 1:3], 3), Valor_p = format.pval(resumen$Valor_p, eps = 0.001))
| Intercepto | Pendiente | R2 | Valor_p | |
|---|---|---|---|---|
| 1. Antigüedad | 78.564 | -3.940 | 0.688 | < 0.001 |
| 2. Kilometraje | 72.272 | -0.291 | 0.428 | < 0.001 |
| 3. Transmisión | 56.044 | -12.506 | 0.092 | < 0.001 |
Modelo 1, antigüedad. Un carro de 0 años se anuncia en promedio en 78,6 millones, y cada año de antigüedad resta 3,94 millones. El R² es 0,688: la antigüedad explica el 68,8 % de la variación del precio entre anuncios.
Modelo 2, kilometraje. Un carro con 0 km se anuncia en promedio en 72,3 millones, y cada 10.000 km adicionales restan unos 2,9 millones. El R² es 0,428, bastante menor que el de la antigüedad.
Modelo 3, transmisión. El intercepto es el promedio de la categoría de referencia, la transmisión automática: 56,0 millones. La pendiente es la diferencia con los mecánicos, que se anuncian 12,5 millones por debajo en promedio. El R² es 0,092: la transmisión sola dice poco del precio.
Significancia estadística. En los tres modelos el valor p de la pendiente es menor que 0,001, de modo que se rechaza que la pendiente sea cero con 314 anuncios. Que una relación sea significativa no la hace útil: la transmisión supera ese umbral y explica menos del 10 % de la variación.
Asociación y causalidad. Los coeficientes describen asociaciones, no efectos. La transmisión lo ilustra. El modelo asigna 12,5 millones menos a un mecánico, pero cambiar la caja de un carro no tiene por qué mover su precio en esa cantidad, porque los mecánicos de la base son además 3 años más viejos y al menos una parte de la diferencia es edad. El kilometraje se confunde de la misma forma con los años. Una lectura causal exigiría comparar carros iguales salvo en la característica de interés, y unos anuncios no permiten hacerlo.
El R² mide el ajuste sobre los datos con que se estimó el modelo.
Para juzgar la predicción sobre carros nuevos se usa validación cruzada
con 5 partes: los 314 anuncios se asignan al azar a 5 grupos y, en cada
vuelta, los tres modelos se reestiman con lm en 4 grupos y
se evalúan en el quinto. Todos los modelos comparten las mismas
particiones. La métrica es el MAE, el error absoluto medio en millones
de pesos, y la referencia es predecir siempre la mediana del precio de
entrenamiento. Un modelo que no mejora esa referencia no aporta.
set.seed(2026)
parte = sample(rep(1:5, length.out = nrow(datos))) # a qué parte queda asignado cada anuncio
table(parte)
## parte
## 1 2 3 4 5
## 63 63 63 63 62
# Aquí se guardan las predicciones de cada anuncio: una columna por modelo
pred = matrix(NA, nrow = nrow(datos), ncol = 4, dimnames = list(NULL, c("mediana", "edad", "km", "trans")))
for (k in 1:5) {
entrena = datos[parte != k, ] # 4 partes para estimar
prueba = datos[parte == k, ] # 1 parte para evaluar
pred[parte == k, "mediana"] = median(entrena$precio_mill)
pred[parte == k, "edad"] = predict(lm(precio_mill ~ antiguedad, data = entrena), prueba)
pred[parte == k, "km"] = predict(lm(precio_mill ~ km_mil, data = entrena), prueba)
pred[parte == k, "trans"] = predict(lm(precio_mill ~ transmision, data = entrena), prueba)
}
# MAE de cada columna: promedio del error en valor absoluto, en millones de pesos
mae = colMeans(abs(datos$precio_mill - pred))
comparacion = data.frame(MAE_millones = round(mae, 2), R2 = round(c(NA, resumen$R2), 3),
row.names = c("Mediana (referencia)", rownames(resumen)))
comparacion
| MAE_millones | R2 | |
|---|---|---|
| Mediana (referencia) | 15.87 | NA |
| 1. Antigüedad | 5.81 | 0.688 |
| 2. Kilometraje | 10.06 | 0.428 |
| 3. Transmisión | 15.47 | 0.092 |
grafico_mae = data.frame(Modelo = factor(rownames(comparacion), levels = rev(rownames(comparacion))),
MAE = comparacion$MAE_millones)
ggplotly(
ggplot(grafico_mae, aes(x = Modelo, y = MAE)) +
geom_col(fill = c("grey80", "steelblue", "grey60", "grey60")) +
coord_flip() +
labs(title = "Figura 14. Error promedio en anuncios que el modelo no vio", x = "",
y = "MAE (millones de pesos)") +
theme_minimal()
)
Predecir siempre la mediana da un MAE de 15,87 millones. El modelo de antigüedad lo reduce a 5,81, el 37 % del error de la referencia; el de kilometraje lo deja en 10,06 y el de transmisión en 15,47, casi sin mejora.
error_edad = abs(datos$precio_mill - pred[, "edad"])
mean(error_edad < 5) # proporción de anuncios con error menor a 5 millones
## [1] 0.6273885
median(error_edad) # la mitad de los errores es menor que esta cifra
## [1] 3.713019
Con el modelo de antigüedad, el 63 % de los anuncios tiene un error menor a 5 millones y la mitad, menor a 3,7. El MAE queda por encima de esa mediana porque unos pocos anuncios atípicos tienen errores muy grandes.
Se elige el modelo de antigüedad por tres razones.
El modelo tiene un límite. La recta se ajustó con carros de 0 a 15 años, que son casi toda la base. Extrapolada al modelo 1995, de 27 años, predice -27,8 millones (Figura 11), un precio imposible. Debe usarse dentro del rango observado, y su forma recta es una simplificación que el Punto 2 retoma con más variables.