1 Resumen

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.

2 Contexto del caso

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:

  • Tipo de vivienda: Casa, Apartamento
  • Zona: Zona Oriente, Zona Sur, Zona Norte, Zona Oeste, Zona Centro

2.1 Diagnóstico y tratamiento de valores faltantes (aplica a ambas viviendas)

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%")
Conteo de valores faltantes por variable (base completa)
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.


3 Vivienda 1: Casa en zona norte

Solicitud: casa de 200 m² construidos, estrato 4 o 5, zona norte, crédito preaprobado de $350 millones.

3.1 Preparación y depuración de los datos

3.1.1 Filtro de la base

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%")
Primeros 3 registros de la base filtrada (Casas, Zona Norte)
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)
Verificación del filtro aplicado – Vivienda 1
Zona Norte
Casa 722

La base filtrada quedó con 722 casas en zona norte.

3.1.2 Verificación geográfica del filtro

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)
Distribución de latitud por zona declarada (base completa)
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.

3.1.3 Depuración adicional: viviendas con cero habitaciones

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.

3.2 Análisis exploratorio

3.2.1 Matriz de correlación

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)
Matriz de correlación de Pearson – Vivienda 1
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.

3.2.2 Precio vs. área construida

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.

3.2.3 Precio según estrato

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.

3.2.4 Precio según número de baños

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.

3.2.5 Precio según número de habitaciones

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.

3.3 Estimación del modelo de regresión lineal múltiple

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")
Coeficientes estimados – Vivienda 1
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)
Medidas de ajuste global – Vivienda 1
R² ajustado Error estándar residual Estadístico F Valor p (global)
0.6553 0.6528 158.1864 264.6585 0

3.3.1 Interpretación de los coeficientes

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).

  • Área construida: por cada m² adicional, el precio sube en promedio 0.789 millones, manteniendo lo demás constante. Es el factor más determinante, con alta significancia.
  • Estrato: subir un estrato se asocia con +87.5 millones en promedio, también muy significativo. Tiene sentido porque el estrato resume características del entorno no capturadas en otra variable.
  • Baños: cada baño adicional se asocia con +28.2 millones, significativo.
  • Habitaciones y parqueaderos: no resultaron significativos (valores p de 0.278 y 0.847). Esto no significa que no le importen al comprador; significa que, conociendo ya el área y los baños de la casa, esa información adicional no mejora la explicación del precio en esta muestra. Confirmé que esto no se debe a redundancia entre variables (ver validación de supuestos).

3.4 Validación de los supuestos del modelo

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)
Pruebas formales de supuestos – Vivienda 1
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)
Factor de Inflación de Varianza (VIF) – Vivienda 1
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.

3.4.1 Observaciones más influyentes

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)
Observaciones con mayor influencia – Vivienda 1
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.

3.4.2 Recomendaciones para mejorar el modelo (sin corregirlo)

  1. Transformar el precio a escala logarítmica (log(preciom)) para estabilizar la varianza y mejorar la normalidad de los residuales.
  2. Usar errores estándar robustos a heterocedasticidad en lugar de los clásicos de mínimos cuadrados.
  3. Revisar y corregir el registro con área de 1.440 m² antes de reestimar el modelo.

3.5 Estimación puntual: predicción para la Vivienda 1

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)
Predicción del precio – Vivienda 1 (millones de pesos)
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.

3.6 Recomendación de ofertas potenciales

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)
Cinco ofertas recomendadas – Vivienda 1
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.


4 Vivienda 2: Apartamento en zona sur

Solicitud: apartamento de 300 m² construidos, estrato 5 o 6, zona sur, crédito preaprobado de $850 millones.

4.1 Preparación y depuración de los datos

4.1.1 Filtro de la base

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%")
Primeros 3 registros de la base filtrada (Apartamentos, Zona Sur)
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)
Verificación del filtro aplicado – Vivienda 2
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.

4.1.2 Verificación geográfica del filtro

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.

4.1.3 Depuración adicional: viviendas con cero habitaciones

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)
Registros con habitaciones = 0 – Vivienda 2
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.

4.2 Análisis exploratorio

4.2.1 Matriz de correlación

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)
Matriz de correlación de Pearson – Vivienda 2
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.

4.2.2 Precio vs. área construida

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")

4.2.3 Precio según estrato

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)

4.2.4 Precio según número de baños

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)

4.2.5 Precio según número de habitaciones

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)

4.3 Estimación del modelo de regresión lineal múltiple

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")
Coeficientes estimados – Vivienda 2
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)
Medidas de ajuste global – Vivienda 2
R² ajustado Error estándar residual Estadístico F Valor p (global)
0.7523 0.7519 95.2711 1684.836 0

4.3.1 Interpretación de los coeficientes

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.

4.4 Validación de los supuestos del modelo

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)
Pruebas formales de supuestos – Vivienda 2
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)
Factor de Inflación de Varianza (VIF) – Vivienda 2
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.

4.4.1 Observaciones más influyentes: un caso que exige acción

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)
Observaciones con mayor influencia – Vivienda 2
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)
Comparación de coeficientes con y sin el registro más influyente
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.

4.4.2 Recomendaciones para mejorar el modelo (sin corregirlo)

  1. Verificar y corregir el registro 6121 (área de 932 m²) con la fuente original antes de reestimar el modelo oficial.
  2. Transformar el precio a escala logarítmica para atenuar la heterocedasticidad, que aquí es más severa que en Vivienda 1.
  3. Usar errores estándar robustos a heterocedasticidad para la inferencia.

4.5 Estimación puntual: predicción para la Vivienda 2

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)
Predicción del precio – Vivienda 2 (millones de pesos)
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.

4.6 Recomendación de ofertas potenciales

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)
Cinco ofertas recomendadas – Vivienda 2
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.


5 Comparación entre las dos viviendas y conclusiones generales

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)
Comparación resumida entre las dos solicitudes
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:

  1. El modelo de apartamentos (Vivienda 2) ajusta mejor que el de casas (Vivienda 1) —75.2% frente a 65.5%—, apoyado en una muestra mucho más grande y en predictores más fuertemente correlacionados con el precio.
  2. El rol de 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.
  3. El signo negativo de 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.
  4. Ambos modelos comparten las mismas limitaciones estructurales: heterocedasticidad, residuales no normales en la cola superior (por las propiedades de lujo que decidí conservar), y una variable 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.
  5. Ambas solicitudes tienen una recomendación viable dentro del presupuesto, aunque con distinto margen: la Vivienda 1 exige que el cliente acepte estrato 4 (no 5) para no exceder el presupuesto, mientras que la Vivienda 2 tiene margen amplio en ambos estratos solicitados.

5.1 Recomendaciones finales para María

  1. Presentar al cliente de la Vivienda 1 las opciones de estrato 4, dejando claro que el estrato 5 probablemente exceda su presupuesto según el comportamiento actual del mercado.
  2. Presentar al cliente de la Vivienda 2 cualquiera de los estratos solicitados, dado el margen de presupuesto disponible; destacar el trade-off entre área y precio entre las opciones encontradas.
  3. Verificar presencialmente cualquier propiedad recomendada cuya ubicación en el mapa no coincida claramente con la zona declarada, dado el margen de error de etiquetado que identifiqué en la variable zona (evidenciado, por ejemplo, en La Buitrera y La Voragine para Vivienda 1, y en Golondrinas y Montebello para Vivienda 2).
  4. Para futuras actualizaciones del modelo, corregir con la fuente original los dos registros de área construida identificados como probables errores de digitación (uno por vivienda), dado su efecto desproporcionado sobre las estimaciones.

6 Referencias

  • Weiers, R. M. (2006). Introducción a la estadística para negocios. Thomson.
  • James, G., Witten, D., Hastie, T., & Tibshirani, R. (2013). An Introduction to Statistical Learning. Springer.
  • Documentación del paquete paqueteMODELOS (Centro MAGIS, Pontificia Universidad Javeriana Cali).