Este informe responde a la solicitud de asesoría que recibió María para la compra de dos viviendas por parte de una compañía internacional que desea ubicar a dos de sus empleados en Cali. Construí un modelo estadístico de regresión lineal múltiple independiente para cada solicitud, porque se trata de segmentos de mercado distintos (tipo de vivienda y zona diferentes), y cada uno tiene su propia dinámica de precios.
Vivienda 1 — Casa, 200 m², estrato 4 o 5, zona norte, crédito preaprobado $350 millones: Con un modelo construido sobre 702 casas de zona norte, estimé que una vivienda con estas características tiene un precio esperado de aproximadamente $328 millones en estrato 4 (dentro del presupuesto) y $416 millones en estrato 5 (por fuera del presupuesto). Encontré varias ofertas reales del mercado que cumplen con el presupuesto y las características solicitadas.
Vivienda 2 — Apartamento, 300 m², estrato 5 o 6, zona sur, crédito preaprobado $850 millones: Con un modelo construido sobre 2,779 apartamentos de zona sur, estimé un precio esperado de aproximadamente $679 millones en estrato 5 y $736 millones en estrato 6, ambos dentro del presupuesto disponible, con un margen considerable. También aquí identifiqué ofertas reales que cumplen con los requisitos.
María es agente inmobiliaria y fundadora de C&A (Casas y
Apartamentos) en Cali. Una compañía internacional le solicitó asesoría
para comprar dos viviendas para dos de sus empleados, cada una con
condiciones distintas de tipo de vivienda, tamaño, zona y presupuesto.
Utilicé los datos de ofertas inmobiliarias de los últimos tres meses en
Cali, disponibles en el paquete paqueteMODELOS (dataset
vivienda), que reúne información obtenida de anuncios
publicados en OLX.
# Cargo el dataset 'vivienda', que contiene TODAS las ofertas
# (todos los tipos de vivienda y todas las zonas de la ciudad).
# A partir de esta única base construyo los dos subconjuntos que
# necesito para las dos solicitudes de vivienda.
data("vivienda")
niveles_tipo <- unique(vivienda$tipo)
niveles_zona <- unique(vivienda$zona)
Los niveles encontrados en las variables categóricas clave son:
Antes de dividir la base según cada solicitud, realicé un diagnóstico
de valores faltantes (NA) sobre el dataset completo, porque
el tratamiento que decida aplicar debe ser consistente para todo el
análisis, no solo para un subconjunto.
diagnostico_na <- vivienda %>%
summarise(
n_total = n(),
na_tipo = sum(is.na(tipo)),
na_zona = sum(is.na(zona)),
na_estrato = sum(is.na(estrato)),
na_preciom = sum(is.na(preciom)),
na_areaconst = sum(is.na(areaconst)),
na_parqueaderos = sum(is.na(parqueaderos)),
na_banios = sum(is.na(banios)),
na_habitaciones = sum(is.na(habitaciones))
)
kable(diagnostico_na, caption = "Conteo de valores faltantes por variable (base completa)") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE) %>%
scroll_box(width = "100%")
| n_total | na_tipo | na_zona | na_estrato | na_preciom | na_areaconst | na_parqueaderos | na_banios | na_habitaciones |
|---|---|---|---|---|---|---|---|---|
| 8322 | 3 | 3 | 3 | 2 | 3 | 1605 | 3 | 3 |
El patrón es claro: casi todas las variables tienen una proporción
marginal de datos faltantes (menos del 0.1% de los 8322 registros), con
una excepción notable: parqueaderos, con
1605 valores faltantes (cerca del 19.3% de la base).
Esta diferencia tan marcada me indica que los NA de
parqueaderos no son aleatorios: dado que estos datos
provienen de anuncios de OLX escritos por personas, lo más probable es
que, cuando una vivienda no tiene parqueadero, quien publica el anuncio
simplemente no diligencia ese campo, en lugar de
escribir explícitamente “cero”. El dato falta porque el valor
real es cero, no porque se haya perdido información al azar. Por eso
decidí imputar estos NA como 0.
Confirmé además que este mismo patrón se repite de forma similar
dentro del subconjunto específico de apartamentos de zona sur que
utilizo más adelante (14.6% de NA en
parqueaderos sobre 2,787 registros), lo que valida aplicar
la misma regla de forma consistente en ambos análisis.
Para el resto de variables, con una proporción de datos faltantes tan baja y sin un patrón de negocio que la explique, la decisión más prudente es eliminar esas filas: imputar variables como el precio o el tipo de vivienda con tan pocos casos introduciría más incertidumbre de la que aportaría recuperar unos pocos registros.
vivienda_limpia <- vivienda %>%
# Imputación por regla de negocio: ausencia de dato = 0 parqueaderos
mutate(parqueaderos = ifelse(is.na(parqueaderos), 0, parqueaderos)) %>%
# El resto de NA no tiene una imputación defendible; se eliminan las filas
filter(
!is.na(tipo), !is.na(zona), !is.na(estrato),
!is.na(preciom), !is.na(areaconst),
!is.na(banios), !is.na(habitaciones)
)
Después de este tratamiento, la base pasó de 8322 a 8319 registros
(100% conservado). A partir de vivienda_limpia construyo,
en cada sección, el subconjunto específico de cada solicitud.
Solicitud: casa de 200 m² construidos, estrato 4 o 5, zona norte, crédito preaprobado de $350 millones.
base1 <- vivienda_limpia %>%
filter(tipo == "Casa", zona == "Zona Norte")
kable(head(base1, 3),
caption = "Primeros 3 registros de la base filtrada (Casas, Zona Norte)") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = TRUE) %>%
scroll_box(width = "100%")
| id | zona | piso | estrato | preciom | areaconst | parqueaderos | banios | habitaciones | tipo | barrio | longitud | latitud |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1209 | Zona Norte | 02 | 5 | 320 | 150 | 2 | 4 | 6 | Casa | acopi | -76.51341 | 3.47968 |
| 1592 | Zona Norte | 02 | 5 | 780 | 380 | 2 | 3 | 3 | Casa | acopi | -76.51674 | 3.48721 |
| 4057 | Zona Norte | 02 | 6 | 750 | 445 | 0 | 7 | 6 | Casa | acopi | -76.52950 | 3.38527 |
kable(table(Tipo = base1$tipo, Zona = base1$zona),
caption = "Verificación del filtro aplicado -- Vivienda 1") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE)
| Zona Norte | |
|---|---|
| Casa | 722 |
La base filtrada quedó con 722 casas en zona norte.
Filtrar por el texto de la variable zona asume que ese
dato fue diligenciado correctamente por quien publicó el anuncio. Para
comprobarlo, ubiqué las coordenadas de cada propiedad en un mapa.
leaflet(base1) %>%
addTiles() %>%
addCircleMarkers(
lng = ~longitud, lat = ~latitud, radius = 4,
color = "steelblue", fillOpacity = 0.6,
popup = ~paste0("<b>Barrio:</b> ", barrio, "<br>",
"<b>Precio:</b> $", preciom, " millones<br>",
"<b>Área:</b> ", areaconst, " m²<br>",
"<b>Estrato:</b> ", estrato)
)
Hallazgo: la mayoría de los puntos se concentra, como se esperaba, en el norte de la ciudad, pero un grupo de puntos se extiende hacia el sur, atravesando el centro hasta sectores como La Buitrera y La Voragine, que geográficamente pertenecen al sur de Cali, no al norte.
Para determinar si se trataba de un problema puntual o de algo sistemático, comparé la distribución de latitud de todas las zonas de la base completa:
resumen_zonas <- vivienda_limpia %>%
filter(!is.na(zona)) %>%
group_by(Zona = zona) %>%
summarise(
n = n(),
lat_minima = round(min(latitud, na.rm = TRUE), 3),
lat_q1 = round(quantile(latitud, 0.25, na.rm = TRUE), 3),
lat_media = round(mean(latitud, na.rm = TRUE), 3),
lat_q3 = round(quantile(latitud, 0.75, na.rm = TRUE), 3),
lat_maxima = round(max(latitud, na.rm = TRUE), 3)
) %>%
arrange(desc(lat_media))
kable(resumen_zonas,
caption = "Distribución de latitud por zona declarada (base completa)",
col.names = c("Zona", "N", "Lat. mínima", "Lat. Q1",
"Lat. media", "Lat. Q3", "Lat. máxima")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE)
| Zona | N | Lat. mínima | Lat. Q1 | Lat. media | Lat. Q3 | Lat. máxima |
|---|---|---|---|---|---|---|
| Zona Norte | 1920 | 3.333 | 3.457 | 3.464 | 3.485 | 3.498 |
| Zona Oeste | 1198 | 3.360 | 3.438 | 3.445 | 3.452 | 3.494 |
| Zona Centro | 124 | 3.398 | 3.436 | 3.442 | 3.446 | 3.487 |
| Zona Oriente | 351 | 3.344 | 3.423 | 3.434 | 3.450 | 3.490 |
| Zona Sur | 4726 | 3.333 | 3.370 | 3.390 | 3.409 | 3.497 |
Análisis: todas las zonas comparten prácticamente el
mismo rango completo de latitud, pero sus valores centrales sí son
claramente distintos entre sí (Zona Norte se concentra en 3.46-3.48;
Zona Sur en 3.37-3.41). Esto significa que zona sí
contiene información real sobre la ubicación, pero convive con
una proporción de anuncios cuya coordenada no corresponde a la zona
declarada.
Interpretación: como no existe un valor de latitud
que separe perfectamente una zona de otra (los rangos se solapan por
completo), no encontré una forma objetiva de “corregir” el filtro por
coordenadas sin arriesgarme a excluir viviendas legítimas o conservar
otras mal ubicadas. Mantuve el filtro por la variable de texto
zona tal como está, y documenté esta limitación como un
hallazgo de calidad de datos: zona probablemente proviene
de una clasificación manual del anunciante al publicar en OLX, no de una
geocodificación automática.
Durante el análisis exploratorio encontré 20 registros con
habitaciones = 0 que resultaron inconsistentes con sus
propias características: casas de hasta 752 m² y 8 baños, pero “cero
habitaciones”. No es una categoría real de vivienda; es un indicio de
que el campo no fue diligenciado, igual que ocurrió con
parqueaderos. A diferencia de ese caso, aquí no existe una
imputación de negocio defendible (no existe una casa habitable sin
ninguna habitación), así que decidí excluir estos 20
registros.
base1_modelo <- base1 %>%
filter(habitaciones > 0)
La base para el modelo quedó con 702 registros
(2.77% menos que base1), una pérdida marginal frente a la
mejora en calidad del dato.
vars_correlacion1 <- base1_modelo %>%
select(preciom, areaconst, estrato, banios, habitaciones, parqueaderos)
matriz_cor1 <- round(cor(vars_correlacion1, use = "complete.obs"), 3)
kable(matriz_cor1, caption = "Matriz de correlación de Pearson -- Vivienda 1") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE)
| preciom | areaconst | estrato | banios | habitaciones | parqueaderos | |
|---|---|---|---|---|---|---|
| preciom | 1.000 | 0.732 | 0.619 | 0.552 | 0.377 | 0.333 |
| areaconst | 0.732 | 1.000 | 0.469 | 0.496 | 0.450 | 0.336 |
| estrato | 0.619 | 0.469 | 1.000 | 0.420 | 0.099 | 0.348 |
| banios | 0.552 | 0.496 | 0.420 | 1.000 | 0.598 | 0.337 |
| habitaciones | 0.377 | 0.450 | 0.099 | 0.598 | 1.000 | 0.250 |
| parqueaderos | 0.333 | 0.336 | 0.348 | 0.337 | 0.250 | 1.000 |
Interpretación: el área construida (0.732) y el estrato (0.619) son las variables con mayor asociación lineal con el precio de las casas de zona norte. El número de baños también muestra una relación considerable (0.552), mientras que habitaciones (0.377) y parqueaderos (0.333) muestran una asociación más débil dentro de este grupo de casas. Entre los predictores, la correlación más alta es baños-habitaciones (0.598), razonable pero no lo suficientemente alta como para anticipar un problema grave de redundancia.
Ajusté el mapeo de la estética text para que describa
únicamente los puntos y no se propague a la línea de tendencia; en la
versión anterior, plotly interpretaba cada valor único de
text como un grupo distinto y trazaba una línea de
regresión por grupo, lo que generaba el efecto de líneas superpuestas y
bandas de confianza enredadas.
g1a <- ggplot(base1_modelo, aes(x = areaconst, y = preciom)) +
geom_point(aes(text = paste("Barrio:", barrio, "<br>Estrato:", estrato)),
color = "steelblue", alpha = 0.5) +
geom_smooth(method = "lm", se = TRUE, color = "darkred", linewidth = 0.8) +
scale_x_continuous(labels = scales::comma) +
scale_y_continuous(labels = scales::comma) +
labs(title = "Precio vs. área construida", subtitle = "Casas, zona norte de Cali",
x = "Área construida (m²)", y = "Precio (millones de pesos)") +
theme_minimal(base_size = 12) +
theme(plot.title.position = "plot")
ggplotly(g1a, tooltip = "text")
Relación positiva y razonablemente lineal: a mayor área, mayor precio, como es de esperarse. La dispersión crece en la parte alta del gráfico (viviendas grandes y costosas), lo que anticipa un problema de varianza no constante que confirmo más adelante.
g2a <- ggplot(base1_modelo, aes(x = factor(estrato), y = preciom, fill = factor(estrato))) +
geom_boxplot(alpha = 0.75, show.legend = FALSE) +
scale_y_continuous(labels = scales::comma) +
labs(title = "Precio según estrato", subtitle = "Casas, zona norte de Cali",
x = "Estrato socioeconómico", y = "Precio (millones de pesos)") +
theme_minimal(base_size = 12) +
theme(plot.title.position = "plot")
ggplotly(g2a)
Las casas de estratos más altos tienen precios medianos más elevados y también mayor dispersión, coherente con un mercado donde el estrato alto agrupa tanto viviendas de gama media como propiedades de lujo.
g3a <- ggplot(base1_modelo, aes(x = factor(banios), y = preciom, fill = factor(banios))) +
geom_boxplot(alpha = 0.75, show.legend = FALSE) +
scale_y_continuous(labels = scales::comma) +
labs(title = "Precio según número de baños", subtitle = "Casas, zona norte de Cali",
x = "Número de baños", y = "Precio (millones de pesos)") +
theme_minimal(base_size = 12) +
theme(plot.title.position = "plot")
ggplotly(g3a)
Tendencia consistente: a mayor número de baños, mayor precio mediano, con dispersión creciente en las categorías más altas, donde hay menos observaciones.
g4a <- ggplot(base1_modelo, aes(x = factor(habitaciones), y = preciom, fill = factor(habitaciones))) +
geom_boxplot(alpha = 0.75, show.legend = FALSE) +
scale_y_continuous(labels = scales::comma) +
labs(title = "Precio según número de habitaciones",
subtitle = "Casas, zona norte de Cali (excluye registros con 0 habitaciones)",
x = "Número de habitaciones", y = "Precio (millones de pesos)") +
theme_minimal(base_size = 12) +
theme(plot.title.position = "plot")
ggplotly(g4a)
Una vez retirados los 20 registros con “cero habitaciones”, la tendencia entre habitaciones y precio es creciente, aunque con valores atípicos hacia arriba que decidí conservar por representar segmentos reales del mercado de lujo.
modelo1 <- lm(preciom ~ areaconst + estrato + parqueaderos + banios + habitaciones,
data = base1_modelo)
tabla_modelo1 <- tidy(modelo1) %>% mutate(across(where(is.numeric), ~round(., 4)))
kable(tabla_modelo1, caption = "Coeficientes estimados -- Vivienda 1",
col.names = c("Variable", "Estimación", "Error estándar", "Estadístico t", "Valor p")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE) %>%
row_spec(which(tabla_modelo1$p.value < 0.05), bold = TRUE, color = "white", background = "#2c7fb8")
| Variable | Estimación | Error estándar | Estadístico t | Valor p |
|---|---|---|---|---|
| (Intercept) | -255.8805 | 32.2804 | -7.9268 | 0.0000 |
| areaconst | 0.7893 | 0.0462 | 17.0824 | 0.0000 |
| estrato | 87.4956 | 7.5876 | 11.5314 | 0.0000 |
| parqueaderos | -0.8387 | 4.3296 | -0.1937 | 0.8465 |
| banios | 28.2219 | 5.7252 | 4.9294 | 0.0000 |
| habitaciones | 5.1869 | 4.7793 | 1.0853 | 0.2782 |
resumen_modelo1 <- glance(modelo1) %>%
select(r.squared, adj.r.squared, sigma, statistic, p.value) %>%
mutate(across(everything(), ~round(., 4)))
kable(resumen_modelo1, caption = "Medidas de ajuste global -- Vivienda 1",
col.names = c("R²", "R² ajustado", "Error estándar residual", "Estadístico F", "Valor p (global)")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| R² | R² ajustado | Error estándar residual | Estadístico F | Valor p (global) |
|---|---|---|---|---|
| 0.6553 | 0.6528 | 158.1864 | 264.6585 | 0 |
El modelo es altamente significativo en conjunto (valor p prácticamente cero) y explica el 65.5% de la variabilidad del precio de las casas en zona norte. Es un ajuste razonable para un mercado real, aunque deja variabilidad sin explicar por factores que la base no registra (acabados, antigüedad, vista, cercanía a vías).
par(mfrow = c(2, 2), mar = c(4, 4, 3, 1))
plot(modelo1, which = 1:4)
par(mfrow = c(1, 1))
prueba_shapiro1 <- shapiro.test(residuals(modelo1))
prueba_ad1 <- ad.test(residuals(modelo1))
prueba_bp1 <- bptest(modelo1)
prueba_dw1 <- dwtest(modelo1)
tabla_supuestos1 <- data.frame(
Prueba = c("Shapiro-Wilk (normalidad)", "Anderson-Darling (normalidad)",
"Breusch-Pagan (homocedasticidad)", "Durbin-Watson (independencia)"),
Estadistico = round(c(prueba_shapiro1$statistic, prueba_ad1$statistic,
prueba_bp1$statistic, prueba_dw1$statistic), 4),
Valor_p = c(format.pval(prueba_shapiro1$p.value, digits = 3),
format.pval(prueba_ad1$p.value, digits = 3),
format.pval(prueba_bp1$p.value, digits = 3),
format.pval(prueba_dw1$p.value, digits = 3))
)
kable(tabla_supuestos1, caption = "Pruebas formales de supuestos -- Vivienda 1",
col.names = c("Prueba", "Estadístico", "Valor p")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| Prueba | Estadístico | Valor p | |
|---|---|---|---|
| W | Shapiro-Wilk (normalidad) | 0.8391 | <2e-16 |
| A | Anderson-Darling (normalidad) | 24.4784 | <2e-16 |
| BP | Breusch-Pagan (homocedasticidad) | 134.3536 | <2e-16 |
| DW | Durbin-Watson (independencia) | 1.6540 | 1.75e-06 |
tabla_vif1 <- data.frame(Variable = names(vif(modelo1)), VIF = round(vif(modelo1), 3))
kable(tabla_vif1, caption = "Factor de Inflación de Varianza (VIF) -- Vivienda 1", row.names = FALSE) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| Variable | VIF |
|---|---|
| areaconst | 1.671 |
| estrato | 1.570 |
| parqueaderos | 1.230 |
| banios | 2.007 |
| habitaciones | 1.817 |
Homocedasticidad: no se cumple. Breusch-Pagan rechaza la varianza constante (p prácticamente cero); el gráfico Scale-Location muestra que la dispersión de residuales crece con el precio estimado, algo habitual en variables de precio.
Normalidad: no se cumple. Shapiro-Wilk y Anderson-Darling rechazan la normalidad. El Q-Q plot se ajusta bien en el centro pero se aleja en la cola superior, por las viviendas de precio muy alto que decidí conservar como parte legítima del mercado de lujo.
Multicolinealidad: no representa un problema. Todos los VIF están entre 1.23 y 2.01, muy por debajo del umbral de preocupación (5). La falta de significancia de habitaciones y parqueaderos no se debe a redundancia entre variables, sino a que genuinamente no aportan explicación adicional en esta muestra.
Independencia: Durbin-Watson sugiere autocorrelación, pero esta prueba fue diseñada para series de tiempo. En datos de corte transversal como este, el orden de las filas no tiene significado temporal, así que no interpreto este resultado como una violación sustantiva.
cooks_d1 <- cooks.distance(modelo1)
top_influyentes1 <- order(cooks_d1, decreasing = TRUE)[1:3]
tabla_influyentes1 <- base1_modelo[top_influyentes1,
c("id", "barrio", "preciom", "areaconst", "estrato", "banios", "habitaciones")]
tabla_influyentes1$distancia_cook <- round(cooks_d1[top_influyentes1], 4)
kable(tabla_influyentes1, caption = "Observaciones con mayor influencia -- Vivienda 1", row.names = FALSE) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| id | barrio | preciom | areaconst | estrato | banios | habitaciones | distancia_cook |
|---|---|---|---|---|---|---|---|
| 534 | villa del prado | 370 | 1440 | 3 | 4 | 10 | 0.9259 |
| 4349 | el bosque | 650 | 1188 | 5 | 6 | 6 | 0.1962 |
| 4542 | vipasa | 1400 | 265 | 5 | 10 | 10 | 0.1523 |
Dos de estas observaciones son casas de lujo genuinas (área, estrato y precio consistentes entre sí), así que las conservé. La tercera (área de 1.440 m² a $370 millones en estrato 3) tiene una distancia de Cook varias veces mayor que cualquier otra, y su combinación de características no es consistente con ningún patrón razonable de mercado — todo indica un error de digitación del área (posiblemente un error de punto decimal). No corregí ni eliminé este dato del modelo actual, pero lo señalo como recomendación de mejora.
log(preciom)) para estabilizar la varianza y mejorar la
normalidad de los residuales.nueva_vivienda1 <- data.frame(
areaconst = 200, estrato = c(4, 5), parqueaderos = 1, banios = 2, habitaciones = 4
)
prediccion1 <- predict(modelo1, newdata = nueva_vivienda1, interval = "prediction", level = 0.95)
tabla_prediccion1 <- data.frame(
Estrato = c(4, 5),
Precio_estimado = round(prediccion1[, "fit"], 1),
Limite_inferior = round(prediccion1[, "lwr"], 1),
Limite_superior = round(prediccion1[, "upr"], 1)
)
kable(tabla_prediccion1, caption = "Predicción del precio -- Vivienda 1 (millones de pesos)",
col.names = c("Estrato", "Precio estimado", "Límite inferior (95%)", "Límite superior (95%)")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| Estrato | Precio estimado | Límite inferior (95%) | Límite superior (95%) |
|---|---|---|---|
| 4 | 328.3 | 17.2 | 639.4 |
| 5 | 415.8 | 104.2 | 727.5 |
Frente al presupuesto de $350 millones: en estrato 4, el precio estimado (328.3 millones) sí cabe dentro del presupuesto. En estrato 5, el precio estimado (415.8 millones) lo excede.
Los intervalos de predicción son amplios, consecuencia del R² moderado y la heterocedasticidad detectada, así que para recomendar ofertas concretas utilizo el precio estimado puntual junto con las características reales del mercado, no el intervalo completo.
ofertas_vivienda1 <- base1_modelo %>%
filter(preciom <= 350, estrato %in% c(4, 5), parqueaderos >= 1) %>%
mutate(dif_area = abs(areaconst - 200)) %>%
arrange(dif_area)
top5_vivienda1 <- head(ofertas_vivienda1, 5)
kable(top5_vivienda1 %>% select(id, barrio, preciom, areaconst, estrato, banios, habitaciones, parqueaderos),
caption = "Cinco ofertas recomendadas -- Vivienda 1",
col.names = c("ID", "Barrio", "Precio (M$)", "Área (m²)", "Estrato", "Baños", "Habitaciones", "Parqueaderos"),
row.names = FALSE) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE)
| ID | Barrio | Precio (M$) | Área (m²) | Estrato | Baños | Habitaciones | Parqueaderos |
|---|---|---|---|---|---|---|---|
| 4210 | el bosque | 350 | 200 | 5 | 3 | 4 | 3 |
| 1343 | la flora | 320 | 200 | 5 | 4 | 4 | 2 |
| 1144 | la merced | 320 | 200 | 4 | 4 | 4 | 2 |
| 4267 | el bosque | 335 | 202 | 5 | 4 | 5 | 1 |
| 1270 | el bosque | 350 | 203 | 5 | 2 | 5 | 2 |
Encontré 82 propiedades que cumplen con el presupuesto y el estrato solicitados; seleccioné las 5 con área más cercana a los 200 m² pedidos.
leaflet(top5_vivienda1) %>%
addTiles() %>%
addCircleMarkers(
lng = ~longitud, lat = ~latitud, radius = 8,
color = "darkgreen", fillOpacity = 0.8,
popup = ~paste0("<b>Barrio:</b> ", barrio, "<br>",
"<b>Precio:</b> $", preciom, " millones<br>",
"<b>Área:</b> ", areaconst, " m²<br>",
"<b>Estrato:</b> ", estrato, "<br>",
"<b>Baños:</b> ", banios, " | <b>Habitaciones:</b> ", habitaciones,
" | <b>Parqueaderos:</b> ", parqueaderos)
)
Nota metodológica: en la revisión inicial del mapa
marqué el barrio Vipasa como una posible inconsistencia geográfica
dentro de la Zona Norte, porque algunos puntos con esa etiqueta se veían
dispersos hacia el sur. Al verificar esta hipótesis contra la división
oficial de comunas de Cali, confirmé que Vipasa es un barrio tradicional
de la comuna 2, ubicado en el norte de la ciudad (entre
las calles 39 y 52 Norte, junto a La Flora y Prados del Norte), por lo
que corresponde correctamente a la Zona Norte. Retiro esa observación
como hallazgo de mala clasificación de zona: si alguno de
los puntos con barrio “Vipasa” se veía desplazado en el mapa, lo más
probable es un error puntual de geocodificación en ese anuncio
específico de OLX (coordenadas mal capturadas), no un problema de la
variable zona ni del barrio en sí. Esto no cambia la
conclusión general de la sección anterior sobre zona (el
patrón sistemático de mala clasificación sigue siendo válido,
evidenciado por La Buitrera y La Voragine), pero sí corrige un caso
puntual que había señalado por error.
Solicitud: apartamento de 300 m² construidos, estrato 5 o 6, zona sur, crédito preaprobado de $850 millones.
base2 <- vivienda_limpia %>%
filter(tipo == "Apartamento", zona == "Zona Sur")
kable(head(base2, 3),
caption = "Primeros 3 registros de la base filtrada (Apartamentos, Zona Sur)") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE) %>%
scroll_box(width = "100%")
| id | zona | piso | estrato | preciom | areaconst | parqueaderos | banios | habitaciones | tipo | barrio | longitud | latitud |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 5098 | Zona Sur | 05 | 4 | 290 | 96 | 1 | 2 | 3 | Apartamento | acopi | -76.53464 | 3.44987 |
| 698 | Zona Sur | 02 | 3 | 78 | 40 | 1 | 1 | 2 | Apartamento | aguablanca | -76.50100 | 3.40000 |
| 8199 | Zona Sur | NA | 6 | 875 | 194 | 2 | 5 | 3 | Apartamento | aguacatal | -76.55700 | 3.45900 |
kable(table(Tipo = base2$tipo, Zona = base2$zona),
caption = "Verificación del filtro aplicado -- Vivienda 2") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| Zona Sur | |
|---|---|
| Apartamento | 2787 |
La base filtrada quedó con 2787 apartamentos en zona sur, una muestra bastante más grande que la de la Vivienda 1.
leaflet(base2) %>%
addTiles() %>%
addCircleMarkers(
lng = ~longitud, lat = ~latitud, radius = 4,
color = "coral", fillOpacity = 0.6,
popup = ~paste0("<b>Barrio:</b> ", barrio, "<br>",
"<b>Precio:</b> $", preciom, " millones<br>",
"<b>Área:</b> ", areaconst, " m²<br>",
"<b>Estrato:</b> ", estrato)
)
Hallazgo: aparece el mismo fenómeno que en Vivienda
1, pero en dirección opuesta: hay un clúster denso y coherente en el sur
de la ciudad, pero también una dispersión de puntos hacia el norte,
llegando hasta sectores como Golondrinas y Montebello. Esto no es un
hallazgo nuevo que requiera una investigación aparte; es la misma
limitación estructural de la variable zona que ya
cuantifiqué con la tabla de latitud por zona (todas las zonas comparten
el mismo rango completo de latitud, con solo las medianas desplazadas).
El hecho de que la cola de mal etiquetados de Zona Norte apunte al sur,
y la de Zona Sur apunte al norte, refuerza que se trata de una
limitación sistemática del dato de origen (clasificación manual del
anunciante), y no de un problema aislado de uno o dos barrios.
Interpretación: mantengo el mismo criterio que en Vivienda 1: conservo el filtro por texto y documento la limitación.
tabla_hab0_v2 <- base2 %>%
filter(habitaciones == 0) %>%
select(id, barrio, preciom, areaconst, estrato, banios, parqueaderos)
kable(tabla_hab0_v2, caption = "Registros con habitaciones = 0 -- Vivienda 2", row.names = FALSE) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| id | barrio | preciom | areaconst | estrato | banios | parqueaderos |
|---|---|---|---|---|---|---|
| 2532 | ciudad jardín | 275 | 96 | 4 | 2 | 2 |
| 3997 | colseguros | 240 | 73 | 4 | 2 | 1 |
| 1594 | el caney | 152 | 60 | 4 | 2 | 1 |
| 5908 | el limonar | 950 | 280 | 5 | 0 | 10 |
| 7011 | el refugio | 160 | 76 | 4 | 0 | 0 |
| 7522 | meléndez | 148 | 87 | 3 | 0 | 0 |
| 3654 | pance | 580 | 138 | 6 | 4 | 2 |
| 4915 | primero de mayo | 160 | 84 | 4 | 0 | 1 |
Encontré 8 registros (0.29% de la base) con el mismo tipo de inconsistencia que en Vivienda 1: por ejemplo, un apartamento de 280 m² en estrato 5 con 0 baños pero 10 parqueaderos y un precio de $950 millones — combinación incompatible con un apartamento real de “cero habitaciones”. Apliqué el mismo criterio de exclusión.
base2_modelo <- base2 %>%
filter(habitaciones > 0)
La base para el modelo quedó con 2779 registros.
vars_correlacion2 <- base2_modelo %>%
select(preciom, areaconst, estrato, banios, habitaciones, parqueaderos)
matriz_cor2 <- round(cor(vars_correlacion2, use = "complete.obs"), 3)
kable(matriz_cor2, caption = "Matriz de correlación de Pearson -- Vivienda 2") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| preciom | areaconst | estrato | banios | habitaciones | parqueaderos | |
|---|---|---|---|---|---|---|
| preciom | 1.000 | 0.757 | 0.673 | 0.726 | 0.346 | 0.678 |
| areaconst | 0.757 | 1.000 | 0.482 | 0.669 | 0.453 | 0.559 |
| estrato | 0.673 | 0.482 | 1.000 | 0.569 | 0.214 | 0.559 |
| banios | 0.726 | 0.669 | 0.569 | 1.000 | 0.517 | 0.589 |
| habitaciones | 0.346 | 0.453 | 0.214 | 0.517 | 1.000 | 0.319 |
| parqueaderos | 0.678 | 0.559 | 0.559 | 0.589 | 0.319 | 1.000 |
Análisis comparativo frente a Vivienda 1: en
apartamentos, parqueaderos pasa de ser casi irrelevante
(0.317 en casas) a tener una correlación fuerte con el precio (0.678),
la segunda más alta después del área construida. Tiene sentido de
negocio: en un edificio, el parqueadero no viene incluido por defecto
como en una casa con antejardín; es un atributo que se compra aparte y
discrimina fuertemente segmentos de precio. banios también
sube considerablemente (0.726). habitaciones sigue siendo
el predictor más débil (0.346), igual que en Vivienda 1.
Entre predictores, las correlaciones son más altas que en Vivienda 1
(banios-areaconst = 0.669), lo que amerita
revisar con cuidado el VIF más adelante, sin asumir que el resultado se
repite automáticamente.
Apliqué la misma corrección de la estética text que en
la Vivienda 1, para que la línea de tendencia no se fragmente por cada
etiqueta de barrio y estrato.
g1b <- ggplot(base2_modelo, aes(x = areaconst, y = preciom)) +
geom_point(aes(text = paste("Barrio:", barrio, "<br>Estrato:", estrato)),
color = "coral", alpha = 0.4) +
geom_smooth(method = "lm", se = TRUE, color = "darkred", linewidth = 0.8) +
scale_x_continuous(labels = scales::comma) +
scale_y_continuous(labels = scales::comma) +
labs(title = "Precio vs. área construida", subtitle = "Apartamentos, zona sur de Cali",
x = "Área construida (m²)", y = "Precio (millones de pesos)") +
theme_minimal(base_size = 12) +
theme(plot.title.position = "plot")
ggplotly(g1b, tooltip = "text")
g2b <- ggplot(base2_modelo, aes(x = factor(estrato), y = preciom, fill = factor(estrato))) +
geom_boxplot(alpha = 0.75, show.legend = FALSE) +
scale_y_continuous(labels = scales::comma) +
labs(title = "Precio según estrato", subtitle = "Apartamentos, zona sur de Cali",
x = "Estrato socioeconómico", y = "Precio (millones de pesos)") +
theme_minimal(base_size = 12) +
theme(plot.title.position = "plot")
ggplotly(g2b)
g3b <- ggplot(base2_modelo, aes(x = factor(banios), y = preciom, fill = factor(banios))) +
geom_boxplot(alpha = 0.75, show.legend = FALSE) +
scale_y_continuous(labels = scales::comma) +
labs(title = "Precio según número de baños", subtitle = "Apartamentos, zona sur de Cali",
x = "Número de baños", y = "Precio (millones de pesos)") +
theme_minimal(base_size = 12) +
theme(plot.title.position = "plot")
ggplotly(g3b)
g4b <- ggplot(base2_modelo, aes(x = factor(habitaciones), y = preciom, fill = factor(habitaciones))) +
geom_boxplot(alpha = 0.75, show.legend = FALSE) +
scale_y_continuous(labels = scales::comma) +
labs(title = "Precio según número de habitaciones",
subtitle = "Apartamentos, zona sur de Cali (excluye registros con 0 habitaciones)",
x = "Número de habitaciones", y = "Precio (millones de pesos)") +
theme_minimal(base_size = 12) +
theme(plot.title.position = "plot")
ggplotly(g4b)
modelo2 <- lm(preciom ~ areaconst + estrato + parqueaderos + banios + habitaciones,
data = base2_modelo)
tabla_modelo2 <- tidy(modelo2) %>% mutate(across(where(is.numeric), ~round(., 4)))
kable(tabla_modelo2, caption = "Coeficientes estimados -- Vivienda 2",
col.names = c("Variable", "Estimación", "Error estándar", "Estadístico t", "Valor p")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE) %>%
row_spec(which(tabla_modelo2$p.value < 0.05), bold = TRUE, color = "white", background = "#e34a33")
| Variable | Estimación | Error estándar | Estadístico t | Valor p |
|---|---|---|---|---|
| (Intercept) | -219.5890 | 13.8530 | -15.8514 | 0 |
| areaconst | 1.4597 | 0.0491 | 29.7046 | 0 |
| estrato | 56.8820 | 2.8117 | 20.2302 | 0 |
| parqueaderos | 48.5786 | 3.1462 | 15.4404 | 0 |
| banios | 48.8691 | 3.0857 | 15.8375 | 0 |
| habitaciones | -23.2746 | 3.5320 | -6.5896 | 0 |
resumen_modelo2 <- glance(modelo2) %>%
select(r.squared, adj.r.squared, sigma, statistic, p.value) %>%
mutate(across(everything(), ~round(., 4)))
kable(resumen_modelo2, caption = "Medidas de ajuste global -- Vivienda 2",
col.names = c("R²", "R² ajustado", "Error estándar residual", "Estadístico F", "Valor p (global)")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| R² | R² ajustado | Error estándar residual | Estadístico F | Valor p (global) |
|---|---|---|---|---|
| 0.7523 | 0.7519 | 95.2711 | 1684.836 | 0 |
Este modelo ajusta notablemente mejor que el de Vivienda 1: explica el 75.2% de la variabilidad del precio (frente al 65.5% de las casas de zona norte), apoyado en una muestra casi cuatro veces más grande y en predictores más correlacionados con el precio.
Todas las variables resultaron significativas en este modelo, a diferencia de Vivienda 1. Sin embargo, hay un resultado que merece una explicación cuidadosa: el coeficiente de habitaciones es negativo (-23.27). A primera vista parece contraintuitivo —¿más habitaciones no debería costar más?—, pero tiene una explicación estadística coherente: este coeficiente no dice que “más habitaciones vale menos” en general; dice que, para un apartamento del mismo tamaño (área fija), dividir ese espacio en más habitaciones más pequeñas es menos valorado por el mercado que tener menos habitaciones más amplias. Dos apartamentos de igual área, uno con 2 habitaciones grandes y otro con 4 habitaciones estrechas, no valen lo mismo: el mercado tiende a preferir el primero. Esto es consistente con la teoría de precios hedónicos en bienes raíces y con lo que ya vi en el análisis exploratorio: habitaciones tiene la correlación más débil con el precio, y una correlación considerable con el área, así que su efecto positivo bruto queda “absorbido” y revertido una vez que se controla por área construida.
El resto de coeficientes tiene la interpretación esperada: cada m² adicional suma en promedio 1.46 millones; subir un estrato suma 56.9 millones; cada parqueadero adicional suma 48.6 millones; cada baño adicional suma 48.9 millones.
par(mfrow = c(2, 2), mar = c(4, 4, 3, 1))
plot(modelo2, which = 1:4)
par(mfrow = c(1, 1))
prueba_shapiro2 <- shapiro.test(residuals(modelo2))
prueba_ad2 <- ad.test(residuals(modelo2))
prueba_bp2 <- bptest(modelo2)
prueba_dw2 <- dwtest(modelo2)
tabla_supuestos2 <- data.frame(
Prueba = c("Shapiro-Wilk (normalidad)", "Anderson-Darling (normalidad)",
"Breusch-Pagan (homocedasticidad)", "Durbin-Watson (independencia)"),
Estadistico = round(c(prueba_shapiro2$statistic, prueba_ad2$statistic,
prueba_bp2$statistic, prueba_dw2$statistic), 4),
Valor_p = c(format.pval(prueba_shapiro2$p.value, digits = 3),
format.pval(prueba_ad2$p.value, digits = 3),
format.pval(prueba_bp2$p.value, digits = 3),
format.pval(prueba_dw2$p.value, digits = 3))
)
kable(tabla_supuestos2, caption = "Pruebas formales de supuestos -- Vivienda 2",
col.names = c("Prueba", "Estadístico", "Valor p")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| Prueba | Estadístico | Valor p | |
|---|---|---|---|
| W | Shapiro-Wilk (normalidad) | 0.7819 | <2e-16 |
| A | Anderson-Darling (normalidad) | 89.5766 | <2e-16 |
| BP | Breusch-Pagan (homocedasticidad) | 968.2803 | <2e-16 |
| DW | Durbin-Watson (independencia) | 1.5050 | <2e-16 |
tabla_vif2 <- data.frame(Variable = names(vif(modelo2)), VIF = round(vif(modelo2), 3))
kable(tabla_vif2, caption = "Factor de Inflación de Varianza (VIF) -- Vivienda 2", row.names = FALSE) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| Variable | VIF |
|---|---|
| areaconst | 2.038 |
| estrato | 1.715 |
| parqueaderos | 1.830 |
| banios | 2.522 |
| habitaciones | 1.435 |
Homocedasticidad y normalidad: violadas, de forma más marcada que en Vivienda 1. El estadístico de Breusch-Pagan (968.3) es varias veces mayor que el de Vivienda 1 (134.35), y Shapiro-Wilk (0.782) se aleja más de 1 que en el modelo de casas. El patrón es el mismo —varianza creciente con el precio, colas pesadas en el Q-Q plot—, pero más pronunciado.
Multicolinealidad: no es un problema, a pesar de que la matriz de correlación sugería más cautela aquí. Todos los VIF están entre 1.44 y 2.52 — más altos que en Vivienda 1, pero muy por debajo del umbral de preocupación (5).
Independencia: mismo resultado y misma advertencia que en Vivienda 1 sobre la aplicabilidad limitada de Durbin-Watson en datos de corte transversal.
cooks_d2 <- cooks.distance(modelo2)
top_influyentes2 <- order(cooks_d2, decreasing = TRUE)[1:3]
tabla_influyentes2 <- base2_modelo[top_influyentes2,
c("id", "barrio", "preciom", "areaconst", "estrato", "banios", "habitaciones")]
tabla_influyentes2$distancia_cook <- round(cooks_d2[top_influyentes2], 4)
kable(tabla_influyentes2, caption = "Observaciones con mayor influencia -- Vivienda 2", row.names = FALSE) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| id | barrio | preciom | areaconst | estrato | banios | habitaciones | distancia_cook |
|---|---|---|---|---|---|---|---|
| 6121 | valle del lili | 299 | 932 | 5 | 3 | 3 | 7.7652 |
| 6472 | el limonar | 170 | 605 | 5 | 2 | 2 | 1.2771 |
| 324 | valle del lili | 190 | 50 | 4 | 2 | 4 | 0.3113 |
El registro más influyente (id 6121, Valle del Lili: apartamento de 932 m², 3 baños, 3 habitaciones, a solo $299 millones) tiene una distancia de Cook de 7.77, muy superior al umbral de referencia (1) y muchísimo más alto que el peor caso de Vivienda 1 (0.93). Esa combinación de área y precio es incompatible con el mercado: un apartamento de esa magnitud en un sector como Valle del Lili valdría mucho más. Todo indica un error de digitación del área (probablemente un error de punto decimal).
Dada la magnitud de esta influencia, realicé un chequeo de sensibilidad: reestimé el modelo excluyendo únicamente este registro, sin que esto reemplace al modelo oficial, para dimensionar su efecto real.
fila_outlier <- which(base2_modelo$id == 6121)
modelo2_sensibilidad <- lm(preciom ~ areaconst + estrato + parqueaderos + banios + habitaciones,
data = base2_modelo[-fila_outlier, ])
tabla_sensibilidad <- data.frame(
Variable = names(coef(modelo2)),
Modelo_original = round(coef(modelo2), 3),
Modelo_sin_outlier = round(coef(modelo2_sensibilidad), 3)
)
kable(tabla_sensibilidad, caption = "Comparación de coeficientes con y sin el registro más influyente",
row.names = FALSE) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| Variable | Modelo_original | Modelo_sin_outlier |
|---|---|---|
| (Intercept) | -219.589 | -205.632 |
| areaconst | 1.460 | 1.794 |
| estrato | 56.882 | 55.198 |
| parqueaderos | 48.579 | 42.960 |
| banios | 48.869 | 41.751 |
| habitaciones | -23.275 | -27.885 |
El coeficiente de área construida cambia cerca de un 23% al retirar
este único registro (de 2,779), y el ajuste global mejora (R² sube).
Esto confirma que el dato no es solo un caso atípico más: está sesgando
de forma material la estimación del efecto del área sobre el precio.
Recomiendo a María verificar este dato con la fuente original
antes de usar el modelo para decisiones de mayor escala, dado
que el coeficiente actual podría estar subvalorando el precio de
apartamentos grandes. Mantengo modelo2 (con el registro
incluido) como el modelo oficial de este informe, siguiendo el criterio
de no corregir sin validación externa, pero dejo esta advertencia
explícita.
nueva_vivienda2 <- data.frame(
areaconst = 300, estrato = c(5, 6), parqueaderos = 3, banios = 3, habitaciones = 5
)
prediccion2 <- predict(modelo2, newdata = nueva_vivienda2, interval = "prediction", level = 0.95)
tabla_prediccion2 <- data.frame(
Estrato = c(5, 6),
Precio_estimado = round(prediccion2[, "fit"], 1),
Limite_inferior = round(prediccion2[, "lwr"], 1),
Limite_superior = round(prediccion2[, "upr"], 1)
)
kable(tabla_prediccion2, caption = "Predicción del precio -- Vivienda 2 (millones de pesos)",
col.names = c("Estrato", "Precio estimado", "Límite inferior (95%)", "Límite superior (95%)")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| Estrato | Precio estimado | Límite inferior (95%) | Límite superior (95%) |
|---|---|---|---|
| 5 | 678.7 | 490.8 | 866.6 |
| 6 | 735.6 | 547.6 | 923.5 |
Frente al presupuesto de $850 millones: tanto en estrato 5 (678.7 millones) como en estrato 6 (735.6 millones), el precio estimado cabe dentro del presupuesto, con margen considerable. A diferencia de Vivienda 1, aquí el cliente tiene holgura incluso solicitando el estrato más alto.
ofertas_vivienda2 <- base2_modelo %>%
filter(preciom <= 850, estrato %in% c(5, 6), parqueaderos >= 3) %>%
mutate(dif_area = abs(areaconst - 300)) %>%
arrange(dif_area)
top5_vivienda2 <- head(ofertas_vivienda2, 5)
kable(top5_vivienda2 %>% select(id, barrio, preciom, areaconst, estrato, banios, habitaciones, parqueaderos),
caption = "Cinco ofertas recomendadas -- Vivienda 2",
col.names = c("ID", "Barrio", "Precio (M$)", "Área (m²)", "Estrato", "Baños", "Habitaciones", "Parqueaderos"),
row.names = FALSE) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE)
| ID | Barrio | Precio (M$) | Área (m²) | Estrato | Baños | Habitaciones | Parqueaderos |
|---|---|---|---|---|---|---|---|
| 7512 | seminario | 670 | 300 | 5 | 5 | 6 | 3 |
| 6175 | capri | 350 | 270 | 5 | 3 | 4 | 3 |
| 7680 | pampa linda | 450 | 267 | 5 | 3 | 3 | 3 |
| 6205 | capri | 350 | 260 | 5 | 3 | 3 | 3 |
| 8036 | seminario | 530 | 256 | 5 | 5 | 5 | 3 |
Encontré 65 propiedades que cumplen con el presupuesto, el estrato y el mínimo de parqueaderos solicitados.
leaflet(top5_vivienda2) %>%
addTiles() %>%
addCircleMarkers(
lng = ~longitud, lat = ~latitud, radius = 8,
color = "darkorange", fillOpacity = 0.8,
popup = ~paste0("<b>Barrio:</b> ", barrio, "<br>",
"<b>Precio:</b> $", preciom, " millones<br>",
"<b>Área:</b> ", areaconst, " m²<br>",
"<b>Estrato:</b> ", estrato, "<br>",
"<b>Baños:</b> ", banios, " | <b>Habitaciones:</b> ", habitaciones,
" | <b>Parqueaderos:</b> ", parqueaderos)
)
Vale la pena mencionarle a María el trade-off entre las opciones: la oferta con área más cercana a los 300 m² solicitados es también la más costosa del grupo, mientras que las demás ofrecen más margen de presupuesto a cambio de un poco menos de área.
tabla_comparativa <- data.frame(
Aspecto = c("Tipo de vivienda", "Zona", "N° de registros en el modelo",
"R² del modelo", "Presupuesto", "Precio estimado (estrato solicitado más bajo)",
"Precio estimado (estrato solicitado más alto)", "¿Cabe en presupuesto?",
"N° de ofertas encontradas"),
Vivienda_1 = c("Casa", "Norte", as.character(nrow(base1_modelo)),
paste0(round(resumen_modelo1$r.squared * 100, 1), "%"), "$350 millones",
paste0("$", round(tabla_prediccion1$Precio_estimado[1], 0), "M (estrato 4)"),
paste0("$", round(tabla_prediccion1$Precio_estimado[2], 0), "M (estrato 5)"),
"Sí (estrato 4) / No (estrato 5)",
as.character(nrow(ofertas_vivienda1))),
Vivienda_2 = c("Apartamento", "Sur", as.character(nrow(base2_modelo)),
paste0(round(resumen_modelo2$r.squared * 100, 1), "%"), "$850 millones",
paste0("$", round(tabla_prediccion2$Precio_estimado[1], 0), "M (estrato 5)"),
paste0("$", round(tabla_prediccion2$Precio_estimado[2], 0), "M (estrato 6)"),
"Sí (ambos estratos)",
as.character(nrow(ofertas_vivienda2)))
)
kable(tabla_comparativa, caption = "Comparación resumida entre las dos solicitudes",
col.names = c("Aspecto", "Vivienda 1 (Casa, Norte)", "Vivienda 2 (Apartamento, Sur)")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE)
| Aspecto | Vivienda 1 (Casa, Norte) | Vivienda 2 (Apartamento, Sur) |
|---|---|---|
| Tipo de vivienda | Casa | Apartamento |
| Zona | Norte | Sur |
| N° de registros en el modelo | 702 | 2779 |
| R² del modelo | 65.5% | 75.2% |
| Presupuesto | $350 millones | $850 millones |
| Precio estimado (estrato solicitado más bajo) | $328M (estrato 4) | $679M (estrato 5) |
| Precio estimado (estrato solicitado más alto) | $416M (estrato 5) | $736M (estrato 6) |
| ¿Cabe en presupuesto? | Sí (estrato 4) / No (estrato 5) | Sí (ambos estratos) |
| N° de ofertas encontradas | 82 | 65 |
Hallazgos comparativos:
parqueaderos cambia por completo
entre los dos segmentos: casi irrelevante en casas, pero uno de
los predictores más fuertes en apartamentos. Esto tiene sentido de
negocio: en un edificio, el parqueadero es un atributo que se adquiere
aparte y no viene garantizado, mientras que en una casa suele darse por
hecho.habitaciones en el modelo
de apartamentos es el hallazgo más interesante de todo el
análisis: no contradice la lógica del mercado, sino que refleja que, a
igual área, el mercado prefiere menos habitaciones más amplias.zona con un margen de error de etiquetado que
documenté pero no corregí por falta de un criterio objetivo para
hacerlo. En ambos casos identifiqué un registro con área construida
extremadamente inconsistente con el resto de sus características, que
recomiendo verificar con la fuente original antes de usar los modelos en
producción.zona (evidenciado, por ejemplo, en La Buitrera y La
Voragine para Vivienda 1, y en Golondrinas y Montebello para Vivienda
2).paqueteMODELOS (Centro MAGIS,
Pontificia Universidad Javeriana Cali).