1 Informe ejecutivo

Para: María, Gerente General — C&A (Casas y Apartamentos), Cali
De: Área de Analítica
Asunto: Valoración de las dos viviendas solicitadas por la compañía internacional y recomendación de oferta

1.1 Qué se pidió y qué se hizo

La compañía internacional solicitó asesoría para adquirir dos viviendas en Cali con condiciones definidas y con créditos preaprobados de $350 y $850 millones. Para responder con cifras defendibles y no con intuición de mercado, se construyeron dos modelos de regresión lineal múltiple sobre la base de ofertas de los últimos tres meses (8.322 registros): uno para casas de la Zona Norte (700 ofertas) y otro para apartamentos de la Zona Sur (2.777 ofertas). Cada mercado se modeló por separado porque la formación de precio es distinta en cada uno.

1.2 Los dos veredictos

Valoración de las dos solicitudes según los modelos estimados (cifras en millones de pesos). Veredicto: ‘No’ si el precio medio estimado supera el crédito; ‘ajustado’ si el límite superior del IC lo supera o el margen es menor al 10 %.
Solicitud Perfil solicitado Estrato Precio estimado IC 95% (precio medio) IP 95% (una vivienda) Crédito ¿Alcanza?
Vivienda 1 · Casa, Zona Norte 200 m² · 4 hab. · 2 baños · 1 parq. 4 $322 M $291 – $352 M $14 – $629 M $350 M Sí, ajustado
Vivienda 1 · Casa, Zona Norte 200 m² · 4 hab. · 2 baños · 1 parq. 5 $383 M $354 – $412 M $76 – $690 M $350 M No
Vivienda 2 · Apto., Zona Sur 300 m² · 5 hab. · 3 baños · 3 parq. 5 $650 M $629 – $670 M $477 – $823 M $850 M Sí, con holgura
Vivienda 2 · Apto., Zona Sur 300 m² · 5 hab. · 3 baños · 3 parq. 6 $803 M $782 – $823 M $630 – $976 M $850 M Sí, ajustado

Solicitud 1 — la restricción real es el estrato, no el tamaño. Una casa de 200 m² con 4 habitaciones y 2 baños en la Zona Norte se valora en $322 millones en estrato 4, cifra que cabe dentro del crédito de $350 millones pero con margen estrecho (el intervalo de confianza llega hasta $352 millones). En estrato 5 el precio esperado sube a $383 millones, es decir $33 millones por encima del crédito preaprobado. La recomendación operativa es concentrar la búsqueda en estrato 4 y tratar el estrato 5 solo si el cliente amplía el cupo o acepta reducir área.

Solicitud 2 — hay capacidad de compra, pero muy poca oferta. Un apartamento de 300 m² en la Zona Sur se valora entre $650 millones (estrato 5) y $803 millones (estrato 6); ambos caben en el crédito de $850 millones. El problema no es presupuestal sino de disponibilidad: apenas 31 de 2.777 apartamentos (1.1%) de la Zona Sur superan los 300 m². El área solicitada está en el extremo superior del mercado, así que la estimación para ese punto debe leerse como una referencia de negociación y no como un precio de lista.

1.3 Recomendaciones

  1. Presentar la oferta de la Vivienda 1 en estrato 4 (barrios con más candidatas: El Bosque, La Merced, Vipasa, La Flora, Prados del Norte). Se identificaron 39 casas que cumplen el perfil dentro del presupuesto; en el Anexo 2 se listan las mejores por relación precio/valor estimado.
  2. Advertir al cliente sobre el estrato 5 en la Vivienda 1. Si es innegociable, la vía realista es bajar el área a ~158 m² manteniendo las demás características, o ampliar el crédito.
  3. Para la Vivienda 2, negociar por debajo del techo de $850 millones. El modelo da margen, y la escasez de apartamentos grandes en la Zona Sur juega a favor del comprador. Como con el perfil literal de 300 m² solo hay 2 opciones, el criterio se amplió a área desde 200 m² y 2 parqueaderos, con lo que se llega a 8 candidatas. Los barrios con más candidatas son El Ingenio, Seminario, Ciudadela Pasoancho, Cuarto de Legua, Guadalupe.
  4. Verificar en campo las ofertas más “baratas” frente al modelo. Las oportunidades recomendadas están entre $46 y $476 millones por debajo de su valor estimado; esa brecha puede ser una ganga real o un indicio de problemas no registrados en la base (estado del inmueble, situación jurídica, error de digitación). No se debe ofertar sin inspección previa.

1.4 Alcance y limitaciones

Los modelos explican el 66.5% (casas Norte) y el 79.0% (apartamentos Sur) de la variación de precios, lo cual es alto para datos de oferta inmobiliaria, pero no capturan antigüedad, estado de conservación, acabados, seguridad del sector ni valorización esperada. Además, la base registra precios de oferta y no de cierre, por lo que las cifras deben leerse como referencia de negociación. Las pruebas de supuestos (Anexo 2, sección de validación de supuestos) muestran heterocedasticidad y residuos no normales en el Modelo 1 y heterocedasticidad y residuos no normales en el Modelo 2: los valores puntuales siguen siendo válidos como referencia central, pero los intervalos deben interpretarse con prudencia.


2 Anexo 1 — Plan de trabajo y metodología

2.1 Problema de negocio traducido a problema estadístico

Pregunta de negocio Traducción analítica
¿Cuánto vale una casa con estas características en el norte? Estimar \(E[\text{precio} \mid X]\) mediante regresión lineal múltiple sobre casas de la Zona Norte
¿Alcanza el crédito preaprobado? Comparar la estimación puntual y su intervalo de confianza contra el tope de $350 / $850 millones
¿Qué inmuebles concretos ofrecer? Filtrar la base por restricciones del cliente y ordenar por residual del modelo (precio de oferta vs. valor estimado)
¿Qué tan confiable es la respuesta? \(R^2\), significancia de coeficientes y validación de los cuatro supuestos del modelo lineal

2.2 Diseño de la solución

El caso pide dos viviendas de tipo y zona distintos. En lugar de un modelo único con variables indicadoras de tipo y zona, se estiman dos modelos independientes:

  • Modelo 1 — base1: casas de la Zona Norte → responde la Solicitud 1.
  • Modelo 2 — base2: apartamentos de la Zona Sur → responde la Solicitud 2.

La razón es de negocio antes que estadística: el precio por metro cuadrado, el peso del estrato y el valor del parqueadero no se forman igual en el mercado de casas del norte que en el de apartamentos del sur (más adelante se verifica: el metro cuadrado mediano es $1.67 M en el primero y $2.90 M en el segundo). Un modelo agregado promediaría dos estructuras de precio distintas y sesgaría ambas respuestas.

2.3 Especificación del modelo

Para cada base se estima el modelo con las cinco variables que define la solicitud del cliente:

\[ \text{preciom}_i = \beta_0 + \beta_1 \text{areaconst}_i + \sum_{k=4}^{6}\gamma_k \, \mathbb{1}[\text{estrato}_i = k] + \beta_2 \text{habitaciones}_i + \beta_3 \text{parqueaderos}_i + \beta_4 \text{banios}_i + \varepsilon_i \]

con \(\varepsilon_i \sim N(0, \sigma^2)\) independientes. El estrato se trata como variable categórica (no como número) porque es una escala ordinal administrativa: pasar de estrato 3 a 4 no tiene por qué valer lo mismo que pasar de 5 a 6. La categoría de referencia es el estrato 3.

2.4 Reglas de depuración adoptadas

Situación detectada Decisión Justificación
3 registros completamente vacíos Eliminar No aportan información
2 id duplicados Conservar el primero Evita duplicar ofertas en la estimación
45 inmuebles con 0 baños y 66 con 0 habitaciones Eliminar Físicamente implausible en vivienda formal: error de captura
1605 faltantes en parqueaderos (19%) Imputar con 1 Eliminarlos costaría el 40% de las casas de la Zona Norte. En los portales el campo suele dejarse vacío cuando hay un cupo estándar; la moda observada es 1 y el mínimo 1
2638 faltantes en piso (32%) No usar la variable El modelo solicitado no la incluye y su tasa de vacíos la hace inutilizable
Coordenadas inconsistentes con la zona declarada Conservar, documentar Afectan el mapa, no la estimación (el modelo no usa lat/lon). Ver sección de consistencia geográfica

2.5 Criterios de aceptación del modelo

  1. Coeficientes con signo económicamente coherente y significancia individual (p < 0.05).
  2. Sin multicolinealidad severa: \(\text{VIF} < 5\).
  3. Validación de supuestos: linealidad (gráfico de residuos), homocedasticidad (Breusch-Pagan), normalidad de residuos (Shapiro-Wilk) e independencia (Durbin-Watson).
  4. Estimación puntual acompañada de intervalo, nunca un número suelto.

3 Anexo 2 — Resultados de la modelación

3.1 Guía de lectura: dónde se resuelve cada paso

La actividad plantea 7 pasos. La siguiente tabla indica en qué sección de este anexo se desarrolla cada uno.

Paso Sección de este informe
1. Filtro de base1 (casas, Zona Norte), primeros 3 registros, tablas de comprobación, mapa y discusión de la consistencia de zonas §4.1 · réplica en §5.1
2. Análisis exploratorio de la correlación precio ~ área, estrato, baños, habitaciones y zona, con gráficos interactivos en plotly §4.2 · réplica en §5.2
3. Estimación del modelo de regresión lineal múltiple, interpretación de coeficientes significativos, R² y discusión del ajuste §4.3 · réplica en §5.3
4. Validación de los supuestos del modelo e interpretación, con sugerencias de corrección §4.4 · réplica en §5.4
5. Predicción del precio de la vivienda con las características de la primera solicitud §4.5
6. Al menos 5 ofertas potenciales en un mapa, dentro del crédito de $350 millones, con su análisis §4.6
7. Repetir los pasos 1 a 6 para la segunda solicitud, con crédito de $850 millones §5 completa (§5.1 a §5.6)

Adicionalmente, el entregable pedido en la página de Evaluación se organiza así: el informe ejecutivo es la §1; el anexo 1 (plan de trabajo) es la §2; y el anexo 2 (resultados de la modelación, validación y comparación de modelos) comprende de la §3 a la §6.

3.2 Preparación del entorno y carga de datos

# ---- Paquetes ---------------------------------------------------------------
library(dplyr)      # manipulación de datos
library(plotly)     # gráficos y mapas interactivos
library(knitr)      # tablas
library(kableExtra) # formato de tablas
library(broom)      # salidas ordenadas de modelos
library(lmtest)     # pruebas de supuestos (Breusch-Pagan, Durbin-Watson)
library(car)        # VIF
# ---- Carga de datos ---------------------------------------------------------

if (requireNamespace("paqueteMODELOS", quietly = TRUE)) {
  data("vivienda", package = "paqueteMODELOS")
} else {
  load("vivienda.rda")   # copia local de respaldo, idéntica a la del paquete
}

vivienda <- as.data.frame(vivienda)
dim(vivienda)
## [1] 8322   13

4 Pasos 1 a 6 · Solicitud 1: casa en la Zona Norte

4.1 Paso 1 · Filtro inicial, depuración y mapa de las bases

4.1.1 Estructura de la base original

glimpse(vivienda)
## Rows: 8,322
## Columns: 13
## $ id           <dbl> 1147, 1169, 1350, 5992, 1212, 1724, 2326, 4386, 1209, 159…
## $ zona         <chr> "Zona Oriente", "Zona Oriente", "Zona Oriente", "Zona Sur…
## $ piso         <chr> NA, NA, NA, "02", "01", "01", "01", "01", "02", "02", "02…
## $ estrato      <dbl> 3, 3, 3, 4, 5, 5, 4, 5, 5, 5, 6, 4, 5, 6, 4, 5, 5, 4, 5, …
## $ preciom      <dbl> 250, 320, 350, 400, 260, 240, 220, 310, 320, 780, 750, 62…
## $ areaconst    <dbl> 70, 120, 220, 280, 90, 87, 52, 137, 150, 380, 445, 355, 2…
## $ parqueaderos <dbl> 1, 1, 2, 3, 1, 1, 2, 2, 2, 2, NA, 3, 2, 2, 1, 4, 2, 2, 2,…
## $ banios       <dbl> 3, 2, 2, 5, 2, 3, 2, 3, 4, 3, 7, 5, 6, 2, 4, 4, 4, 3, 2, …
## $ habitaciones <dbl> 6, 3, 4, 3, 3, 3, 3, 4, 6, 3, 6, 5, 6, 2, 5, 5, 4, 3, 3, …
## $ tipo         <chr> "Casa", "Casa", "Casa", "Casa", "Apartamento", "Apartamen…
## $ barrio       <chr> "20 de julio", "20 de julio", "20 de julio", "3 de julio"…
## $ longitud     <dbl> -76.51, -76.51, -76.52, -76.54, -76.51, -76.52, -76.52, -…
## $ latitud      <dbl> 3.434, 3.434, 3.436, 3.435, 3.459, 3.370, 3.426, 3.383, 3…
# ---- Valores que toman las variables categóricas del modelo -------------------
# Se verifica ANTES de fijar los niveles del factor estrato: si existiera un
# estrato distinto de 3-6, factor(levels = 3:6) lo convertiría en NA sin avisar.
table(Estrato = vivienda$estrato, useNA = "ifany")
## Estrato
##    3    4    5    6 <NA> 
## 1453 2129 2750 1987    3
table(Tipo = vivienda$tipo, Zona = vivienda$zona, useNA = "ifany")
##              Zona
## Tipo          Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur <NA>
##   Apartamento          24       1198       1029           62     2787    0
##   Casa                100        722        169          289     1939    0
##   <NA>                  0          0          0            0        0    3
# ---- Diagnóstico de calidad: faltantes por variable --------------------------
calidad <- data.frame(
  Variable = names(vivienda),
  Faltantes = colSums(is.na(vivienda)),
  `% Faltante` = round(100 * colMeans(is.na(vivienda)), 1),
  check.names = FALSE, row.names = NULL
) %>% arrange(desc(Faltantes))

kable(calidad, caption = "Valores faltantes por variable en la base original") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Valores faltantes por variable en la base original
Variable Faltantes % Faltante
piso 2638 31.7
parqueaderos 1605 19.3
id 3 0.0
zona 3 0.0
estrato 3 0.0
areaconst 3 0.0
banios 3 0.0
habitaciones 3 0.0
tipo 3 0.0
barrio 3 0.0
longitud 3 0.0
latitud 3 0.0
preciom 2 0.0

Dos hallazgos condicionan la depuración: piso tiene 32% de vacíos —queda descartada— y parqueaderos tiene 19%, una proporción demasiado alta para eliminar filas y por eso se imputa.

4.1.2 Función de depuración

# Corrige la doble codificación de tildes presente en algunos barrios
arregla_texto <- function(x) {
  x <- gsub("√©", "é", x); x <- gsub("√±", "ñ", x)
  x <- gsub("√≠", "í", x); x <- gsub("√≥", "ó", x)
  x <- gsub("√°", "á", x); x <- gsub("√∫", "ú", x)
  trimws(tolower(x))
}

# Valor de imputación para parqueaderos faltantes (justificación en el Anexo 1)
PARQ_IMPUTA <- 1
table(Parqueaderos = vivienda$parqueaderos, useNA = "ifany")
## Parqueaderos
##    1    2    3    4    5    6    7    8    9   10 <NA> 
## 3155 2475  520  384   68   68   18   17    4    8 1605
depurar <- function(d) {
  d %>%
    # 1. registros sin información en las variables del modelo
    filter(!is.na(zona), !is.na(tipo), !is.na(preciom), !is.na(areaconst),
           !is.na(estrato), !is.na(banios), !is.na(habitaciones)) %>%
    # 2. ofertas duplicadas
    distinct(id, .keep_all = TRUE) %>%
    # 3. valores físicamente implausibles
    filter(banios > 0, habitaciones > 0) %>%
    # 4. imputación y tipificación
    mutate(
      parqueaderos = ifelse(is.na(parqueaderos), PARQ_IMPUTA, parqueaderos),
      estrato      = factor(estrato, levels = c(3, 4, 5, 6)),
      barrio       = arregla_texto(barrio),
      precio_m2    = preciom / areaconst
    )
}

vivienda_dep <- depurar(vivienda)

cat("Registros originales :", nrow(vivienda), "\n")
## Registros originales : 8322
cat("Registros depurados  :", nrow(vivienda_dep), "\n")
## Registros depurados  : 8243
cat("Registros eliminados :", nrow(vivienda) - nrow(vivienda_dep),
    sprintf("(%.1f%%)", 100 * (1 - nrow(vivienda_dep) / nrow(vivienda))), "\n")
## Registros eliminados : 79 (0.9%)

4.1.3 Filtro solicitado: base1

# ---- base1: casas de la Zona Norte ------------------------------------------
base1 <- vivienda_dep %>% filter(tipo == "Casa", zona == "Zona Norte")

cat("base1 —", nrow(base1), "ofertas de casas en la Zona Norte\n")
## base1 — 700 ofertas de casas en la Zona Norte

Primeros 3 registros de base1:

base1 %>%
  select(id, zona, tipo, estrato, areaconst, habitaciones, banios,
         parqueaderos, preciom, barrio) %>%
  head(3) %>%
  kable(caption = "Primeros 3 registros de base1 (casas · Zona Norte)") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)
Primeros 3 registros de base1 (casas · Zona Norte)
id zona tipo estrato areaconst habitaciones banios parqueaderos preciom barrio
1209 Zona Norte Casa 5 150 6 4 2 320 acopi
1592 Zona Norte Casa 5 380 3 3 2 780 acopi
4057 Zona Norte Casa 6 445 6 7 1 750 acopi

Tablas de comprobación de la consulta. Si el filtro está bien aplicado, base1 debe contener un único valor de tipo y un único valor de zona:

kable(as.data.frame(table(Tipo = base1$tipo)),   caption = "Verificación: tipo en base1") %>%
  kable_styling(full_width = FALSE, position = "float_left")
Verificación: tipo en base1
Tipo Freq
Casa 700
kable(as.data.frame(table(Zona = base1$zona)),   caption = "Verificación: zona en base1") %>%
  kable_styling(full_width = FALSE, position = "left")
Verificación: zona en base1
Zona Freq
Zona Norte 700
# ---- Comparación de la base filtrada contra el total de la ciudad -----------
comparacion <- bind_rows(
  vivienda_dep %>% summarise(Base = "Toda la ciudad", n = n(),
                             `Precio mediano (M)` = median(preciom),
                             `Área mediana (m²)` = median(areaconst),
                             `Precio/m² (M)` = round(median(precio_m2), 2)),
  base1 %>% summarise(Base = "base1 · Casas Zona Norte", n = n(),
                      `Precio mediano (M)` = median(preciom),
                      `Área mediana (m²)` = median(areaconst),
                      `Precio/m² (M)` = round(median(precio_m2), 2))
)
kable(comparacion, caption = "base1 frente al total del mercado") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
base1 frente al total del mercado
Base n Precio mediano (M) Área mediana (m²) Precio/m² (M)
Toda la ciudad 8243 330 122 2.65
base1 · Casas Zona Norte 700 390 240 1.67
base1 %>% count(estrato, name = "Ofertas") %>%
  mutate(`% del total` = sprintf("%.1f%%", 100 * Ofertas / sum(Ofertas))) %>%
  rename(Estrato = estrato) %>%
  kable(caption = "Distribución de base1 por estrato") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Distribución de base1 por estrato
Estrato Ofertas % del total
3 229 32.7%
4 151 21.6%
5 267 38.1%
6 53 7.6%

Las casas del norte son más grandes y más caras que el promedio de la ciudad, pero su metro cuadrado es más barato ($1.67 M/m² frente a $2.65 M/m²): se paga por área, no por ubicación premium. La oferta se concentra en los estratos 5 y 3.

4.1.4 Mapa de las bases y consistencia geográfica

# ---- Mapa de todas las ofertas, coloreadas por zona -------------------------
plot_ly(
  data = vivienda_dep,
  lat = ~latitud, lon = ~longitud,
  type = "scattermapbox", mode = "markers",
  color = ~zona,
  colors = c("#1b6ca8", "#e07b39", "#3f9b5a", "#b5446e", "#8c6bb1"),
  marker = list(size = 5, opacity = 0.6),
  hoverinfo = "text",
  text = ~paste0("<b>", barrio, "</b><br>", zona, " · ", tipo,
                 "<br>Estrato ", estrato, " · ", areaconst, " m²",
                 "<br>Precio: $", preciom, " M")
) %>%
  layout(
    title = "Ofertas de vivienda en Cali por zona declarada",
    mapbox = list(style = "open-street-map", zoom = 10.6,
                  center = list(lat = 3.42, lon = -76.53)),
    legend = list(orientation = "h", y = -0.05)
  )
# ---- ¿Los puntos caen en la zona que declaran? ------------------------------
# Referencia: la latitud 3.45 separa aproximadamente el norte del sur de Cali.
LAT_CORTE <- 3.45

consistencia <- vivienda_dep %>%
  group_by(zona) %>%
  summarise(
    n = n(),
    `Lat. mínima` = round(min(latitud), 4),
    `Lat. mediana` = round(median(latitud), 4),
    `Lat. máxima` = round(max(latitud), 4),
    `Al sur de 3.45` = sum(latitud < LAT_CORTE),
    `% al sur` = sprintf("%.1f%%", 100 * mean(latitud < LAT_CORTE))
  )
kable(consistencia, caption = "Rango de latitudes por zona declarada") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)
Rango de latitudes por zona declarada
zona n Lat. mínima Lat. mediana Lat. máxima Al sur de 3.45 % al sur
Zona Centro 121 3.398 3.440 3.487 102 84.3%
Zona Norte 1888 3.334 3.473 3.498 330 17.5%
Zona Oeste 1188 3.360 3.449 3.494 672 56.6%
Zona Oriente 345 3.344 3.438 3.490 265 76.8%
Zona Sur 4701 3.333 3.385 3.497 4507 95.9%
# ---- Mapa exclusivo de base1, marcando los puntos inconsistentes ------------
base1_mapa <- base1 %>%
  mutate(consistencia = ifelse(latitud < LAT_CORTE,
                               "Coordenada inconsistente (al sur de 3.45)",
                               "Coordenada consistente con Zona Norte"))

plot_ly(
  data = base1_mapa,
  lat = ~latitud, lon = ~longitud,
  type = "scattermapbox", mode = "markers",
  color = ~consistencia, colors = c("#2c7a4b", "#c0392b"),
  marker = list(size = 7, opacity = 0.75),
  hoverinfo = "text",
  text = ~paste0("<b>", barrio, "</b><br>Estrato ", estrato,
                 "<br>", areaconst, " m² · ", habitaciones, " hab.",
                 "<br>Precio: $", preciom, " M")
) %>%
  layout(
    title = "base1 · Casas declaradas en Zona Norte",
    mapbox = list(style = "open-street-map", zoom = 10.8,
                  center = list(lat = 3.44, lon = -76.53)),
    legend = list(orientation = "h", y = -0.05)
  )
inconsistentes1 <- base1 %>%
  filter(latitud < LAT_CORTE) %>%
  count(barrio, sort = TRUE)

inconsistentes1 %>%
  head(8) %>%
  kable(col.names = c("Barrio", "Ofertas mal georreferenciadas"),
        caption = "Barrios de base1 con coordenadas al sur del corte") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Barrios de base1 con coordenadas al sur del corte
Barrio Ofertas mal georreferenciadas
acopi 45
cali 11
la flora 11
san vicente 6
prados del norte 5
villa del prado 5
brisas de los 4
el bosque 4
# Un mismo barrio no debería aparecer disperso por toda la ciudad. Se toman los dos barrios con más registros inconsistentes y se mide su dispersión en latitud:
barrios_disp <- head(inconsistentes1$barrio, 2)
dispersion1 <- base1 %>%
  filter(barrio %in% barrios_disp) %>%
  group_by(barrio) %>%
  summarise(n = n(), `Lat. mín` = round(min(latitud), 4),
            `Lat. mediana` = round(median(latitud), 4),
            `Lat. máx` = round(max(latitud), 4),
            `Rango (km aprox.)` = round((max(latitud) - min(latitud)) * 111, 1))
kable(dispersion1, caption = "Dispersión geográfica dentro de un mismo barrio") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Dispersión geográfica dentro de un mismo barrio
barrio n Lat. mín Lat. mediana Lat. máx Rango (km aprox.)
acopi 60 3.350 3.396 3.487 15.2
cali 11 3.337 3.367 3.435 11.0
# Coordenadas repetidas de forma exacta en inmuebles distintos: indicio de coordenadas "por defecto" asignadas por el portal cuando no ubica la dirección.
coord_rep <- vivienda_dep %>%
  count(latitud, longitud, name = "inmuebles") %>%
  filter(inmuebles > 1) %>%
  arrange(desc(inmuebles))
cat("Pares (lat, lon) compartidos por más de un inmueble:", nrow(coord_rep), "\n")
## Pares (lat, lon) compartidos por más de un inmueble: 881
cat("Inmuebles que comparten la coordenada más repetida  :", max(c(0, coord_rep$inmuebles)), "\n")
## Inmuebles que comparten la coordenada más repetida  : 106

Discusión. No todos los puntos caen en la zona que declaran. 330 de las 1888 ofertas rotuladas como Zona Norte tienen coordenadas al sur de la latitud 3.45, y a la inversa 192 ofertas de Zona Sur aparecen al norte. Las causas más probables son tres, y ninguna es un error de la consulta:

  1. Geocodificación imprecisa. Barrios como Acopi aparecen con registros repartidos en un rango de 15.2 km: cuando el portal no logra ubicar la dirección exacta, asigna una coordenada aproximada o el centroide de la ciudad. De hecho, 881 pares de coordenadas se repiten exactamente en más de un inmueble (el más frecuente, en 106), señal de coordenadas por defecto.
  2. Barrios en el límite municipal. Algunos sectores se comercializan como “zona norte” aunque pertenezcan a municipios vecinos como Yumbo, y su geocodificación queda fuera del polígono urbano de Cali (es el caso de Acopi, presente en la base).
  3. La variable zona es comercial, no cartográfica. Corresponde a la clasificación que usa el portal para agrupar la oferta, y no siempre coincide con la división geográfica estricta.

Implicación para el análisis: el modelo no utiliza latitud ni longitud, de modo que estas inconsistencias no sesgan las estimaciones de precio. Sí obligan a advertir que el mapa es referencial y que, antes de mostrar una oferta al cliente, la dirección debe verificarse. Corregirlo requeriría regeocodificar contra el shapefile de comunas de Cali, lo cual excede el alcance de esta actividad.

4.2 Paso 2 · Análisis exploratorio de datos

# ---- Precio por zona (sobre todas las casas de la ciudad) -------------------
# Dentro de base1 la zona es constante por construcción del filtro, así que la relación precio ~ zona se observa antes de filtrar, para el mismo tipo de inmueble.
plot_ly(vivienda_dep %>% filter(tipo == "Casa"),
        x = ~zona, y = ~preciom, color = ~zona,
        colors = c("#1b6ca8", "#e07b39", "#3f9b5a", "#b5446e", "#8c6bb1"),
        type = "box", boxpoints = "outliers") %>%
  layout(title = "Precio de las casas por zona de la ciudad",
         xaxis = list(title = ""), yaxis = list(title = "Precio (millones)"),
         showlegend = FALSE)
vivienda_dep %>% filter(tipo == "Casa") %>%
  group_by(Zona = zona) %>%
  summarise(Ofertas = n(), `Precio mediano (M)` = median(preciom),
            `Área mediana (m²)` = median(areaconst),
            `Precio/m² mediano (M)` = round(median(precio_m2), 2)) %>%
  kable(caption = "Casas: precio, área y precio por m² según zona") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Casas: precio, área y precio por m² según zona
Zona Ofertas Precio mediano (M) Área mediana (m²) Precio/m² mediano (M)
Zona Centro 97 300 200 1.50
Zona Norte 700 390 240 1.67
Zona Oeste 164 680 300 2.13
Zona Oriente 284 234 178 1.25
Zona Sur 1924 480 247 2.17

El gráfico y la tabla muestran cuánto cambia el precio entre zonas para el mismo tipo de inmueble (la Zona Norte tiene un precio/m² mediano de $1.67 M frente a $1.92 M del conjunto de casas de la ciudad). Al fijar zona = "Zona Norte" en base1, esa fuente de variación queda controlada por diseño y el resto del análisis se concentra en las variables que sí varían dentro del segmento.

# ---- Distribución de la variable respuesta ----------------------------------
plot_ly(base1, x = ~preciom, type = "histogram", nbinsx = 45,
        marker = list(color = "#1b6ca8", line = list(color = "white", width = 0.5))) %>%
  layout(title = "Distribución del precio · Casas Zona Norte",
         xaxis = list(title = "Precio (millones de pesos)"),
         yaxis = list(title = "Número de ofertas"), bargap = 0.02)

La distribución es asimétrica a la derecha: la mayoría de casas se concentra entre $256 y $550 millones, con una cola de inmuebles de lujo que llega hasta $1940 millones. Esta asimetría anticipa problemas de normalidad en los residuos, que se confirman en la sección de validación de supuestos del Modelo 1.

# ---- Matriz de correlación entre variables numéricas ------------------------
num1 <- base1 %>% select(preciom, areaconst, habitaciones, parqueaderos, banios)
cor1 <- round(cor(num1), 3)

plot_ly(x = colnames(cor1), y = rownames(cor1), z = cor1, type = "heatmap",
        colorscale = "Blues", reversescale = TRUE, zmin = -1, zmax = 1,
        text = cor1, texttemplate = "%{text}", hoverinfo = "z") %>%
  layout(title = "Matriz de correlación · base1",
         xaxis = list(title = ""), yaxis = list(title = "", autorange = "reversed"))

El área construida es el predictor dominante (\(r = 0.73\)), seguida del número de baños (\(r = 0.568\)). Las habitaciones y los parqueaderos correlacionan débilmente con el precio (\(r = 0.375\) y 0.344), lo que ya sugiere que podrían no resultar significativos una vez se controla por área. La correlación entre baños y habitaciones (\(r = 0.607\)) es la más alta entre predictores, pero no alcanza niveles preocupantes de multicolinealidad. El estrato, por ser ordinal, no entra en la matriz de Pearson; su asociación con el precio (Spearman \(\rho = 0.717\)) se examina en los diagramas de cajas siguientes.

# ---- Precio vs. área construida, diferenciado por estrato -------------------
plot_ly(base1, x = ~areaconst, y = ~preciom, color = ~estrato,
        colors = c("#a8c8e8", "#5b9bd5", "#2e75b6", "#1f4e79"),
        type = "scatter", mode = "markers",
        marker = list(size = 7, opacity = 0.65),
        hoverinfo = "text",
        text = ~paste0("<b>", barrio, "</b><br>", areaconst, " m² · Estrato ", estrato,
                       "<br>$", preciom, " M")) %>%
  layout(title = "Precio vs. área construida por estrato · Casas Zona Norte",
         xaxis = list(title = "Área construida (m²)"),
         yaxis = list(title = "Precio (millones)"),
         legend = list(title = list(text = "<b>Estrato</b>")))

La relación precio–área es positiva y aproximadamente lineal, y las nubes de puntos aparecen escalonadas por estrato: a igual área, un estrato superior se ubica por encima. Esto valida tratar el estrato como variable categórica con efecto de nivel. También se observa que la dispersión crece con el área —primer indicio visual de heterocedasticidad.

# ---- Precio por estrato ------------------------------------------------------
plot_ly(base1, x = ~estrato, y = ~preciom, color = ~estrato,
        colors = c("#a8c8e8", "#5b9bd5", "#2e75b6", "#1f4e79"),
        type = "box", boxpoints = "outliers") %>%
  layout(title = "Precio por estrato · Casas Zona Norte",
         xaxis = list(title = "Estrato"),
         yaxis = list(title = "Precio (millones)"), showlegend = FALSE)
# ---- Precio por número de habitaciones y de baños ---------------------------
p_hab <- plot_ly(base1 %>% filter(habitaciones <= 8),
                 x = ~factor(habitaciones), y = ~preciom, type = "box",
                 name = "Habitaciones", marker = list(color = "#e07b39"),
                 line = list(color = "#e07b39"))
p_ban <- plot_ly(base1 %>% filter(banios <= 8),
                 x = ~factor(banios), y = ~preciom, type = "box",
                 name = "Baños", marker = list(color = "#3f9b5a"),
                 line = list(color = "#3f9b5a"))
subplot(p_hab, p_ban, nrows = 1, shareY = TRUE, titleX = TRUE) %>%
  layout(title = "Precio según habitaciones (izq.) y baños (der.)",
         yaxis = list(title = "Precio (millones)"))

Interpretación conjunta del EDA. El precio de una casa en el norte de Cali se explica sobre todo por cuánto mide y en qué estrato está. El número de baños escala con el precio de forma monótona y clara; el de habitaciones se aplana a partir de 4–5, señal de que más habitaciones dentro de la misma área no agregan valor, solo reparten el espacio. La mediana de precio sube de forma marcada entre estratos (estrato 3: $210 M · estrato 4: $380 M · estrato 5: $480 M · estrato 6: $780 M).

4.3 Paso 3 · Estimación e interpretación del modelo

# ---- Modelo de regresión lineal múltiple ------------------------------------
formula_modelo <- preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios

modelo1 <- lm(formula_modelo, data = base1)
summary(modelo1)
## 
## Call:
## lm(formula = formula_modelo, data = base1)
## 
## Residuals:
##    Min     1Q Median     3Q    Max 
## -921.8  -70.9  -15.0   45.5 1090.5 
## 
## Coefficients:
##              Estimate Std. Error t value             Pr(>|t|)    
## (Intercept)   12.3442    18.9474    0.65                0.515    
## areaconst      0.7768     0.0458   16.97 < 0.0000000000000002 ***
## estrato4      72.4612    17.6429    4.11   0.0000448552523531 ***
## estrato5     133.8308    16.8880    7.92   0.0000000000000092 ***
## estrato6     323.3245    27.2934   11.85 < 0.0000000000000002 ***
## habitaciones   3.8387     4.7583    0.81                0.420    
## parqueaderos   9.7819     5.2454    1.86                0.063 .  
## banios        28.1559     5.9257    4.75   0.0000024567678465 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 156 on 692 degrees of freedom
## Multiple R-squared:  0.665,  Adjusted R-squared:  0.662 
## F-statistic:  196 on 7 and 692 DF,  p-value: <0.0000000000000002
# Nombres legibles de los términos; se asignan por nombre (no por posición) para que la tabla no dependa de que todos los niveles de estrato estén presentes.
nombres_coef <- c("(Intercept)" = "Intercepto", areaconst = "Área construida (m²)",
                  estrato4 = "Estrato 4 (vs. 3)", estrato5 = "Estrato 5 (vs. 3)",
                  estrato6 = "Estrato 6 (vs. 3)", habitaciones = "Habitaciones",
                  parqueaderos = "Parqueaderos", banios = "Baños")

tidy(modelo1, conf.int = TRUE) %>%
  mutate(
    Variable = unname(nombres_coef[term]),
    Signif = case_when(p.value < 0.001 ~ "***", p.value < 0.01 ~ "**",
                       p.value < 0.05 ~ "*", p.value < 0.1 ~ ".", TRUE ~ "")
  ) %>%
  select(Variable, Coeficiente = estimate, `Error est.` = std.error,
         `t` = statistic, `p-valor` = p.value,
         `IC 95% inf` = conf.low, `IC 95% sup` = conf.high, Signif) %>%
  mutate(across(where(is.numeric), ~round(., 3))) %>%
  kable(caption = "Modelo 1 · Casas Zona Norte — coeficientes estimados") %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed")) %>%
  row_spec(which(summary(modelo1)$coefficients[, 4] < 0.05), bold = TRUE)
Modelo 1 · Casas Zona Norte — coeficientes estimados
Variable Coeficiente Error est. t p-valor IC 95% inf IC 95% sup Signif
Intercepto 12.344 18.947 0.652 0.515 -24.857 49.546
Área construida (m²) 0.777 0.046 16.975 0.000 0.687 0.867 ***
Estrato 4 (vs. 3) 72.461 17.643 4.107 0.000 37.821 107.101 ***
Estrato 5 (vs. 3) 133.831 16.888 7.925 0.000 100.673 166.989 ***
Estrato 6 (vs. 3) 323.324 27.293 11.846 0.000 269.737 376.912 ***
Habitaciones 3.839 4.758 0.807 0.420 -5.504 13.181
Parqueaderos 9.782 5.245 1.865 0.063 -0.517 20.081 .
Baños 28.156 5.926 4.751 0.000 16.521 39.790 ***

4.3.1 Interpretación contextualizada de los coeficientes

  • Área construida = 0.777 (p < 0.001). Cada metro cuadrado adicional aumenta el precio esperado en $0.78 millones (≈ $777 mil por m²), manteniendo constantes estrato, habitaciones, parqueaderos y baños. Para dimensionarlo: ampliar la casa en 20 m² vale unos $16 millones. Es coherente con el precio/m² mediano observado ($1.67 M).

  • Estrato (p < 0.001 o menor en los tres niveles; referencia estrato 3). A igualdad de características físicas, una casa en estrato 4 vale $72 millones más que una en estrato 3; en estrato 5, $134 millones más; y en estrato 6, $323 millones más. El salto no es lineal: pasar de 5 a 6 cuesta $189 millones, 3.1 veces lo que cuesta pasar de 4 a 5 ($61 millones). Esto confirma la decisión metodológica de tratar el estrato como categórico: un modelo que lo usara como número forzaría saltos iguales y subestimaría el estrato 6.

  • Baños = 28.16 (p < 0.001). Cada baño adicional agrega $28 millones, a igual área. Tiene sentido económico: un baño más dentro de la misma superficie señala mejor distribución y acabados de mayor categoría.

  • Parqueaderos = 9.78 (p = 0.063). No es significativo al 5% (sí al 10%). En las casas del norte el parqueadero prácticamente se da por descontado, y su aporte marginal no se distingue del ruido una vez se controla por área. La imputación de faltantes también diluye su efecto.

  • Habitaciones = 3.84 (p = 0.420). No es significativo al 5%. Es un resultado lógico, no una anomalía: a área constante, subdividir la casa en más habitaciones no crea valor, solo lo redistribuye. El mercado paga por metros cuadrados y por estrato, no por el número de tabiques.

  • Intercepto = 12.34 (p = 0.515). Correspondería a una casa de 0 m², 0 habitaciones y 0 baños en estrato 3: carece de interpretación práctica y solo cumple una función de ajuste; su significancia o no significancia no afecta la validez del modelo.

4.3.2 Bondad de ajuste

g1 <- glance(modelo1)
data.frame(
  Indicador = c("R²", "R² ajustado", "Error estándar residual (M)",
                "Estadístico F", "p-valor del modelo", "Observaciones"),
  Valor = c(round(g1$r.squared, 4), round(g1$adj.r.squared, 4),
            round(g1$sigma, 1), round(g1$statistic, 1),
            format.pval(g1$p.value, digits = 3), nrow(base1))
) %>%
  kable(caption = "Modelo 1 · Indicadores de ajuste") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Modelo 1 · Indicadores de ajuste
Indicador Valor
0.665
R² ajustado 0.6616
Error estándar residual (M) 155.8
Estadístico F 196.3
p-valor del modelo <0.0000000000000002
Observaciones 700

El \(R^2 = 0.665\) indica que las cinco variables explican el 66.5% de la variabilidad del precio de las casas del norte; el 33.5% restante queda sin explicar. El \(R^2\) ajustado (0.6616) casi no difiere, señal de que no hay variables sobrantes inflando el ajuste. El estadístico F es altamente significativo: el modelo en conjunto sí explica el precio.

¿Es un buen ajuste? Para valoración inmobiliaria con solo cinco variables, sí. Pero el error estándar residual de $156 millones es la cifra que María debe tener presente: es el margen típico de error de una tasación individual, sobre un precio mediano de $390 millones. Sirve para orientar una negociación, no para fijar un precio de cierre.

Qué se podría hacer para mejorarlo. El 33% no explicado proviene de atributos que la base no registra:

  1. Incorporar variables ausentes: antigüedad del inmueble, estado de conservación, calidad de acabados, número de pisos de la casa, tamaño del lote frente a área construida, y disponibilidad de zonas comunes o seguridad privada.
  2. Añadir localización fina: el barrio como efecto (o coordenadas ya validadas) capturaría la plusvalía de sectores específicos que el estrato promedia.
  3. Transformar la variable respuesta: modelar \(\log(\text{precio})\) atenuaría la asimetría del precio y convertiría los coeficientes en variaciones porcentuales, más naturales para el negocio.
  4. Revisar los atípicos con criterio experto: el gráfico de residuos vs. apalancamiento (abajo, en la validación de supuestos) señala inmuebles que influyen mucho en el ajuste y que convendría revisar uno por uno antes de decidir si se excluyen.

4.3.3 Verificación de significancia conjunta

# ¿Aportan conjuntamente habitaciones y parqueaderos, pese a su no significancia individual?
modelo1_red <- lm(preciom ~ areaconst + estrato + banios, data = base1)
anova(modelo1_red, modelo1)

La prueba F compara el modelo solicitado contra una versión sin habitaciones ni parqueaderos. El p-valor resultante (= 0.116) indica que su aporte conjunto tampoco es significativo al 5%. Se conservan en el modelo por ser parte de la especificación solicitada y porque el cliente define su solicitud en esos términos, pero María debe saber que no son las palancas de precio: negociar una habitación de más no debería costar dinero adicional.

4.4 Paso 4 · Validación de supuestos del modelo

par(mfrow = c(2, 2), mar = c(4, 4, 3, 1))
plot(modelo1, col = "#1b6ca8", pch = 19, cex = 0.5)

par(mfrow = c(1, 1))

Los cuatro gráficos se leen así: Residuals vs Fitted evalúa linealidad y varianza constante (se espera una nube horizontal sin curvatura ni forma de abanico); Q-Q Residuals evalúa normalidad (los puntos deben seguir la diagonal); Scale-Location vuelve sobre la homocedasticidad (la línea roja debe ser plana); y Residuals vs Leverage identifica observaciones con mucha influencia sobre el ajuste (fuera de las curvas de Cook).

# ---- Pruebas formales --------------------------------------------------------
# Shapiro-Wilk admite máximo 5.000 observaciones: si hay más, se toma una muestra.
shapiro_seguro <- function(m) {
  r <- residuals(m)
  if (length(r) > 5000) { set.seed(123); r <- sample(r, 5000) }
  shapiro.test(r)
}
bp1 <- bptest(modelo1)          # homocedasticidad
sw1 <- shapiro_seguro(modelo1)  # normalidad
dw1 <- dwtest(modelo1)          # independencia

data.frame(
  Supuesto = c("Homocedasticidad", "Normalidad de residuos", "Independencia de residuos"),
  Prueba = c("Breusch-Pagan", "Shapiro-Wilk", "Durbin-Watson"),
  Estadístico = round(c(bp1$statistic, sw1$statistic, dw1$statistic), 4),
  `p-valor` = format.pval(c(bp1$p.value, sw1$p.value, dw1$p.value), digits = 3, eps = 0.0001),
  Conclusión = c(ifelse(bp1$p.value < 0.05, "Se rechaza — heterocedasticidad", "No se rechaza"),
                 ifelse(sw1$p.value < 0.05, "Se rechaza — no normalidad", "No se rechaza"),
                 ifelse(dw1$p.value < 0.05, "Se rechaza — hay autocorrelación", "No se rechaza")),
  check.names = FALSE
) %>%
  kable(caption = "Modelo 1 · Pruebas formales de supuestos (α = 0.05)") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)
Modelo 1 · Pruebas formales de supuestos (α = 0.05)
Supuesto Prueba Estadístico p-valor Conclusión
BP Homocedasticidad Breusch-Pagan 133.9463 <0.0001 Se rechaza — heterocedasticidad
W Normalidad de residuos Shapiro-Wilk 0.8267 <0.0001 Se rechaza — no normalidad
DW Independencia de residuos Durbin-Watson 1.6887 <0.0001 Se rechaza — hay autocorrelación
# ---- Multicolinealidad -------------------------------------------------------
# Con una variable categórica (estrato) car::vif devuelve el GVIF; la columna
# GVIF^(1/(2*Df)) es la comparable con el umbral usual (VIF < 5).
vif1 <- vif(modelo1)
kable(round(as.data.frame(vif1), 3),
      caption = "Modelo 1 · Factores de inflación de varianza (VIF)") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Modelo 1 · Factores de inflación de varianza (VIF)
GVIF Df GVIF^(1/(2*Df))
areaconst 1.675 1 1.294
estrato 1.632 3 1.085
habitaciones 1.852 1 1.361
parqueaderos 1.228 1 1.108
banios 2.180 1 1.476
vif1_max <- max(vif1[, ncol(vif1)])   # mayor GVIF ajustado

4.4.1 Interpretación de los diagnósticos

Supuesto Resultado Lectura
Multicolinealidad Mayor GVIF ajustado = 1.48 Cumple. No hay redundancia entre predictores; los coeficientes son estables e interpretables por separado
Homocedasticidad Breusch-Pagan p < 0.001 No cumple. La varianza del error no es constante: el modelo acierta más en unos tramos de precio que en otros (ver el abanico en Residuals vs Fitted)
Normalidad Shapiro-Wilk p < 0.001 No cumple. Los residuos se apartan de la normal (ver colas del Q-Q plot); la asimetría del mercado inmobiliario se traslada a los residuos
Linealidad Lectura visual de Residuals vs Fitted Se verifica en el gráfico: una nube centrada en cero y sin curvatura sistemática indica que la forma lineal es adecuada para el rango observado
Independencia Durbin-Watson p < 0.001 Formalmente se rechaza, pero el dato es de corte transversal y no tiene orden temporal: la señal refleja más bien que casas del mismo barrio comparten factores no observados

Sugerencias de corrección (se enuncian como trabajo futuro; no se aplican en esta actividad):

  1. Para la heterocedasticidad: usar errores estándar robustos a heterocedasticidad; los coeficientes no cambian, pero los intervalos quedan correctamente calculados.
  2. Para la no normalidad y la asimetría: modelar \(\log(\text{precio})\), que suele corregir a la vez normalidad y heterocedasticidad en datos de precios.
  3. Para la dependencia entre inmuebles cercanos: incorporar el barrio como variable explicativa.
  4. Para las observaciones influyentes: revisar las 49 observaciones con distancia de Cook mayor a 4/n y validar si son inmuebles atípicos legítimos o errores de captura.

Consecuencia práctica: la heterocedasticidad y la no normalidad afectan la precisión de los intervalos, no la insesgadez de las estimaciones puntuales. Las valoraciones del informe ejecutivo siguen siendo válidas como referencia central; los intervalos deben leerse como aproximados.

4.5 Paso 5 · Estimación puntual para la Solicitud 1

# ---- Perfil solicitado: casa, 200 m², 1 parq., 2 baños, 4 hab., estrato 4 o 5
nuevo <- function(area, est, hab, parq, ban) {
  data.frame(areaconst = area, estrato = factor(est, levels = c(3, 4, 5, 6)),
             habitaciones = hab, parqueaderos = parq, banios = ban)
}

solicitud1 <- bind_rows(nuevo(200, 4, 4, 1, 2), nuevo(200, 5, 4, 1, 2))

pred_conf1 <- predict(modelo1, solicitud1, interval = "confidence", level = 0.95)
pred_pred1 <- predict(modelo1, solicitud1, interval = "prediction", level = 0.95)

resultado1 <- data.frame(
  Escenario = c("Estrato 4", "Estrato 5"),
  `Precio estimado (M)` = round(pred_conf1[, "fit"], 1),
  `IC 95% del precio medio` = sprintf("[%.1f ; %.1f]", pred_conf1[, "lwr"], pred_conf1[, "upr"]),
  `IP 95% de una casa individual` = sprintf("[%.1f ; %.1f]", pred_pred1[, "lwr"], pred_pred1[, "upr"]),
  `Crédito (M)` = 350,
  `Diferencia (M)` = round(350 - pred_conf1[, "fit"], 1),
  check.names = FALSE
)
# Color de cada fila según si el precio medio estimado cabe o no en el crédito
color_fila <- ifelse(pred_conf1[, "fit"] <= 350, "#eaf7ee", "#fdecea")
kable(resultado1, caption = "Solicitud 1 · Estimación del precio de la casa en la Zona Norte") %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE) %>%
  row_spec(1, background = color_fila[1]) %>%
  row_spec(2, background = color_fila[2])
Solicitud 1 · Estimación del precio de la casa en la Zona Norte
Escenario Precio estimado (M) IC 95% del precio medio IP 95% de una casa individual Crédito (M) Diferencia (M)
Estrato 4 321.6 [290.8 ; 352.5] [14.3 ; 629.0] 350 28.4
Estrato 5 383.0 [354.5 ; 411.5] [75.9 ; 690.1] 350 -33.0

4.5.1 Interpretación de la estimación

Estrato 4. El precio esperado es de $321.6 millones. El intervalo de confianza del 95% —$290.8 a $352.5 millones— se refiere al precio promedio de todas las casas con ese perfil, y es la cifra apropiada para orientar una política de oferta. El crédito de $350 millones alcanza, con un margen de $28.4 millones, pero el límite superior del intervalo ($352.5 M) llega casi al tope: no hay espacio para sobrecostos.

Estrato 5. El precio esperado sube a $383.0 millones, es decir $33.0 millones por encima del crédito. Todo el intervalo de confianza queda por fuera del presupuesto, así que no se trata de un caso límite: con $350 millones no se compra esa casa en estrato 5.

Sobre los dos intervalos. El intervalo de predicción es mucho más amplio ($14 a $629 millones en estrato 4) porque proyecta una casa concreta e incorpora la variabilidad individual, no solo la incertidumbre sobre el promedio. Su amplitud es consecuencia directa del error residual de $156 millones y confirma que el modelo sirve para enmarcar la negociación, no para tasar un inmueble específico sin visitarlo.

Nota de validez: el perfil solicitado (200 m²) está dentro del rango observado —el 60% de las casas del norte supera esa área— por lo que no se está extrapolando. La estimación es interna al soporte de los datos.

4.6 Paso 6 · Ofertas potenciales para la Solicitud 1

# ---- Criterio: cumplir el perfil del cliente y no exceder el crédito --------
CREDITO1 <- 350

base1_val <- base1 %>%
  mutate(valor_estimado = predict(modelo1, .),
         residual = preciom - valor_estimado,
         descuento_pct = 100 * residual / valor_estimado)

# El enunciado exige al menos cinco ofertas. Se parte del perfil literal del cliente y, si no se alcanzan, se baja un peldaño de la escalera de relajación.
MIN_OFERTAS <- 5

filtra1 <- function(d, area_min, parq_min) d %>%
  filter(preciom  <= CREDITO1,        # cabe en el crédito preaprobado
         areaconst >= area_min,       # área solicitada (o relajada)
         habitaciones >= 4,           # requisito del cliente, no se relaja
         banios     >= 2,             # requisito del cliente, no se relaja
         parqueaderos >= parq_min,    # requisito del cliente (o relajado)
         estrato %in% c(4, 5)) %>%    # estratos solicitados, no se relajan
  arrange(residual)

aplica_escalera <- function(d, filtro, escalera) {
  for (i in seq_along(escalera)) {
    cand <- filtro(d, escalera[[i]]$area, escalera[[i]]$parq)
    if (nrow(cand) >= MIN_OFERTAS || i == length(escalera))
      return(list(candidatas = cand, criterio = escalera[[i]]$etiqueta, peldano = i))
  }
}

escalera1 <- list(
  list(area = 200, parq = 1, etiqueta = "perfil literal (200 m², 1 parqueadero)"),
  list(area = 180, parq = 1, etiqueta = "área desde 180 m²"))

# Traza completa: cuántas candidatas deja cada peldaño
for (e in escalera1)
  cat(sprintf("%-42s -> %d candidatas\n", e$etiqueta,
              nrow(filtra1(base1_val, e$area, e$parq))))
## perfil literal (200 m², 1 parqueadero)    -> 39 candidatas
## área desde 180 m²                        -> 41 candidatas
sel1 <- aplica_escalera(base1_val, filtra1, escalera1)
candidatas1 <- sel1$candidatas; CRITERIO1 <- sel1$criterio
cat("Criterio usado:", CRITERIO1, "->", nrow(candidatas1), "casas candidatas\n")
## Criterio usado: perfil literal (200 m², 1 parqueadero) -> 39 casas candidatas
ofertas1 <- candidatas1 %>% slice_head(n = 6)

ofertas1 %>%
  transmute(Barrio = titulo(barrio), Estrato = estrato, `Área (m²)` = areaconst,
            Hab. = habitaciones, Baños = banios, Parq. = parqueaderos,
            `Precio oferta (M)` = preciom,
            `Valor estimado (M)` = round(valor_estimado, 0),
            `Dif. (M)` = round(residual, 0),
            `Descuento` = sprintf("%.0f%%", -descuento_pct)) %>%
  kable(caption = paste0("Solicitud 1 · ", nrow(ofertas1),
                         " ofertas recomendadas, ordenadas por mayor descuento frente al valor estimado")) %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE)
Solicitud 1 · 6 ofertas recomendadas, ordenadas por mayor descuento frente al valor estimado
Barrio Estrato Área (m²) Hab. Baños Parq. Precio oferta (M) Valor estimado (M) Dif. (M) Descuento
San Vicente 5 355 8 5 2 340 613 -273 45%
El Bosque 5 243 5 4 1 250 477 -227 48%
El Bosque 5 300 6 5 3 350 572 -222 39%
Los Andes 4 280 6 4 2 260 458 -198 43%
Vipasa 5 205 6 5 2 300 489 -189 39%
La Merced 5 249 5 5 1 321 509 -188 37%
plot_ly() %>%
  add_trace(data = candidatas1, lat = ~latitud, lon = ~longitud,
            type = "scattermapbox", mode = "markers", name = "Otras candidatas",
            marker = list(size = 8, color = "#b9c6d2", opacity = 0.7),
            hoverinfo = "text",
            text = ~paste0(barrio, " · ", areaconst, " m² · $", preciom, " M")) %>%
  add_trace(data = ofertas1, lat = ~latitud, lon = ~longitud,
            type = "scattermapbox", mode = "markers",
            name = paste("Top", nrow(ofertas1), "recomendadas"),
            marker = list(size = 16, color = "#c0392b", opacity = 0.95),
            hoverinfo = "text",
            text = ~paste0("<b>", barrio, "</b><br>Estrato ", estrato, " · ",
                           areaconst, " m²<br>", habitaciones, " hab · ", banios, " baños",
                           "<br><b>Oferta: $", preciom, " M</b>",
                           "<br>Valor estimado: $", round(valor_estimado), " M")) %>%
  layout(title = "Solicitud 1 · Ofertas potenciales de casa en la Zona Norte",
         mapbox = list(style = "open-street-map", zoom = 11.2,
                       center = list(lat = 3.46, lon = -76.52)),
         legend = list(orientation = "h", y = -0.05))

4.6.1 Discusión de las ofertas

De las 700 casas de la Zona Norte, 39 cumplen simultáneamente las condiciones del cliente y el techo de $350 millones, bajo el criterio perfil literal (200 m², 1 parqueadero). El criterio de ordenamiento no es el precio más bajo sino el residual del modelo: cuánto está por debajo cada oferta de lo que el modelo dice que debería valer. Una casa de $340 millones cuyo valor estimado es $613 millones representa una oportunidad de $273 millones si el inmueble está en las condiciones que declara.

Tres advertencias que María debe trasladar al cliente:

  1. La brecha grande exige explicación. Descuentos superiores al 30% frente al valor estimado rara vez son gratuitos. Antes de ofertar hay que verificar estado de conservación, antigüedad, situación jurídica y si el precio publicado está desactualizado.
  2. La concentración por barrio importa. Las oportunidades se agrupan en El Bosque, La Merced, Vipasa. Conviene visitar en bloque para aprovechar el desplazamiento y comparar en sitio.
  3. El estrato define el margen. Dentro de las candidatas hay 16 de estrato 4 y 23 de estrato 5; estas últimas están por debajo del crédito precisamente porque tienen alguna característica que el modelo valora más de lo que el mercado está pidiendo, y son las que más requieren inspección.

5 Paso 7 · Solicitud 2: apartamento en la Zona Sur

Se replican íntegramente los pasos 1 a 6 del enunciado sobre el segundo mercado: apartamentos de la Zona Sur, con crédito preaprobado de $850 millones.

5.1 Réplica del paso 1 · Filtro inicial y depuración

# ---- base2: apartamentos de la Zona Sur -------------------------------------
base2 <- vivienda_dep %>% filter(tipo == "Apartamento", zona == "Zona Sur")

cat("base2 —", nrow(base2), "ofertas de apartamentos en la Zona Sur\n")
## base2 — 2777 ofertas de apartamentos en la Zona Sur

Primeros 3 registros de base2:

base2 %>%
  select(id, zona, tipo, estrato, areaconst, habitaciones, banios,
         parqueaderos, preciom, barrio) %>%
  head(3) %>%
  kable(caption = "Primeros 3 registros de base2 (apartamentos · Zona Sur)") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)
Primeros 3 registros de base2 (apartamentos · Zona Sur)
id zona tipo estrato areaconst habitaciones banios parqueaderos preciom barrio
5098 Zona Sur Apartamento 4 96 3 2 1 290 acopi
698 Zona Sur Apartamento 3 40 2 1 1 78 aguablanca
8199 Zona Sur Apartamento 6 194 3 5 2 875 aguacatal
kable(as.data.frame(table(Tipo = base2$tipo)), caption = "Verificación: tipo en base2") %>%
  kable_styling(full_width = FALSE, position = "float_left")
Verificación: tipo en base2
Tipo Freq
Apartamento 2777
kable(as.data.frame(table(Zona = base2$zona)), caption = "Verificación: zona en base2") %>%
  kable_styling(full_width = FALSE, position = "left")
Verificación: zona en base2
Zona Freq
Zona Sur 2777
bind_rows(
  base1 %>% summarise(Base = "base1 · Casas Zona Norte", n = n(),
                      `Precio mediano (M)` = median(preciom),
                      `Área mediana (m²)` = median(areaconst),
                      `Precio/m² (M)` = round(median(precio_m2), 2)),
  base2 %>% summarise(Base = "base2 · Aptos. Zona Sur", n = n(),
                      `Precio mediano (M)` = median(preciom),
                      `Área mediana (m²)` = median(areaconst),
                      `Precio/m² (M)` = round(median(precio_m2), 2))
) %>%
  kable(caption = "Contraste entre los dos mercados analizados") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Contraste entre los dos mercados analizados
Base n Precio mediano (M) Área mediana (m²) Precio/m² (M)
base1 · Casas Zona Norte 700 390 240 1.67
base2 · Aptos. Zona Sur 2777 245 85 2.90
base2 %>% count(estrato, name = "Ofertas") %>%
  mutate(`% del total` = sprintf("%.1f%%", 100 * Ofertas / sum(Ofertas))) %>%
  rename(Estrato = estrato) %>%
  kable(caption = "Distribución de base2 por estrato") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Distribución de base2 por estrato
Estrato Ofertas % del total
3 200 7.2%
4 1086 39.1%
5 1031 37.1%
6 460 16.6%

El contraste entre mercados es nítido y justifica haberlos modelado por separado: los apartamentos del sur son mucho más pequeños (mediana de 85 m² frente a 240 m²) y sin embargo su metro cuadrado cuesta 74% más ($2.90 M/m² vs. $1.67 M/m²). En el sur se paga por ubicación; en el norte, por área.

base2_mapa <- base2 %>%
  mutate(consistencia = ifelse(latitud > LAT_CORTE,
                               "Coordenada inconsistente (al norte de 3.45)",
                               "Coordenada consistente con Zona Sur"))

plot_ly(data = base2_mapa, lat = ~latitud, lon = ~longitud,
        type = "scattermapbox", mode = "markers",
        color = ~consistencia, colors = c("#2c7a4b", "#c0392b"),
        marker = list(size = 6, opacity = 0.65),
        hoverinfo = "text",
        text = ~paste0("<b>", barrio, "</b><br>Estrato ", estrato,
                       "<br>", areaconst, " m² · ", habitaciones, " hab.",
                       "<br>Precio: $", preciom, " M")) %>%
  layout(title = "base2 · Apartamentos declarados en Zona Sur",
         mapbox = list(style = "open-street-map", zoom = 10.8,
                       center = list(lat = 3.40, lon = -76.53)),
         legend = list(orientation = "h", y = -0.05))
inconsistentes2 <- base2 %>% filter(latitud > LAT_CORTE) %>% count(barrio, sort = TRUE)
inconsistentes2 %>% head(5) %>%
  kable(col.names = c("Barrio", "Ofertas mal georreferenciadas"),
        caption = "Barrios de base2 con coordenadas al norte del corte") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Barrios de base2 con coordenadas al norte del corte
Barrio Ofertas mal georreferenciadas
valle del lili 56
pance 6
bochalema 5
ciudad bochalema 4
el caney 4

Se repite el patrón detectado en base1: 124 de 2777 apartamentos (4.5%) aparecen georreferenciados al norte del corte, encabezados por Valle del Lili y Pance. Confirma que la inconsistencia proviene de la geocodificación, no del filtro ni de la clasificación comercial de zonas.

5.2 Réplica del paso 2 · Análisis exploratorio de datos

# ---- Precio por zona (sobre todos los apartamentos de la ciudad) ------------
plot_ly(vivienda_dep %>% filter(tipo == "Apartamento"),
        x = ~zona, y = ~preciom, color = ~zona,
        colors = c("#1b6ca8", "#e07b39", "#3f9b5a", "#b5446e", "#8c6bb1"),
        type = "box", boxpoints = "outliers") %>%
  layout(title = "Precio de los apartamentos por zona de la ciudad",
         xaxis = list(title = ""), yaxis = list(title = "Precio (millones)"),
         showlegend = FALSE)

Como en la Solicitud 1, la zona queda fijada por el filtro de base2; el gráfico muestra la posición de la Zona Sur frente al resto de la ciudad para el mismo tipo de inmueble (precio/m² mediano de $2.90 M frente a $3.00 M del conjunto de apartamentos).

num2 <- base2 %>% select(preciom, areaconst, habitaciones, parqueaderos, banios)
cor2 <- round(cor(num2), 3)

plot_ly(x = colnames(cor2), y = rownames(cor2), z = cor2, type = "heatmap",
        colorscale = "Greens", reversescale = TRUE, zmin = -1, zmax = 1,
        text = cor2, texttemplate = "%{text}", hoverinfo = "z") %>%
  layout(title = "Matriz de correlación · base2",
         xaxis = list(title = ""), yaxis = list(title = "", autorange = "reversed"))

Correlaciones con el precio en base2: área (0.757), baños (0.733), parqueaderos (0.708) y habitaciones (0.344); frente a base1, el cambio más notorio es el del parqueadero (0.344 en las casas del norte). En propiedad horizontal el parqueadero es un activo escaso que se transa por separado, mientras que en una casa unifamiliar viene incorporado.

plot_ly(base2, x = ~areaconst, y = ~preciom, color = ~estrato,
        colors = c("#b7e0c4", "#7bc496", "#3f9b5a", "#1e6b39"),
        type = "scatter", mode = "markers",
        marker = list(size = 6, opacity = 0.55),
        hoverinfo = "text",
        text = ~paste0("<b>", barrio, "</b><br>", areaconst, " m² · Estrato ", estrato,
                       "<br>$", preciom, " M")) %>%
  layout(title = "Precio vs. área construida por estrato · Apartamentos Zona Sur",
         xaxis = list(title = "Área construida (m²)"),
         yaxis = list(title = "Precio (millones)"),
         legend = list(title = list(text = "<b>Estrato</b>")))
p1 <- plot_ly(base2, x = ~estrato, y = ~preciom, color = ~estrato,
              colors = c("#b7e0c4", "#7bc496", "#3f9b5a", "#1e6b39"),
              type = "box", showlegend = FALSE)
p2 <- plot_ly(base2 %>% filter(parqueaderos <= 4), x = ~factor(parqueaderos), y = ~preciom,
              type = "box", name = "Parqueaderos",
              marker = list(color = "#e07b39"), line = list(color = "#e07b39"),
              showlegend = FALSE)
subplot(p1, p2, nrows = 1, shareY = TRUE, titleX = TRUE) %>%
  layout(title = "Precio por estrato (izq.) y por número de parqueaderos (der.)",
         yaxis = list(title = "Precio (millones)"))
plot_ly(base2, x = ~areaconst, type = "histogram", nbinsx = 60,
        marker = list(color = "#3f9b5a")) %>%
  layout(title = "Distribución del área construida · Apartamentos Zona Sur",
         xaxis = list(title = "Área construida (m²)", range = c(0, 400)),
         yaxis = list(title = "Número de ofertas"), bargap = 0.02,
         shapes = list(list(type = "line", x0 = 300, x1 = 300, y0 = 0, y1 = 1,
                            yref = "paper",
                            line = list(color = "#c0392b", width = 2, dash = "dash"))),
         annotations = list(list(x = 300, y = 1, yref = "paper", xanchor = "left",
                                 text = "300 m² solicitados", showarrow = FALSE,
                                 font = list(color = "#c0392b"))))

Hallazgo crítico para el caso. El área solicitada (300 m²) queda en el extremo derecho de la distribución: solo 31 apartamentos (1.1%) de la Zona Sur alcanzan ese tamaño, frente a una mediana de 85 m². La solicitud no es inalcanzable por presupuesto sino por disponibilidad de producto, y esto condiciona tanto la confiabilidad de la estimación como la estrategia de búsqueda.

5.3 Réplica del paso 3 · Estimación e interpretación del modelo

modelo2 <- lm(formula_modelo, data = base2)
summary(modelo2)
## 
## Call:
## lm(formula = formula_modelo, data = base2)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -1081.0   -35.6    -2.1    35.0   895.3 
## 
## Coefficients:
##              Estimate Std. Error t value             Pr(>|t|)    
## (Intercept)  -42.1351    10.3607   -4.07    0.000048991846998 ***
## areaconst      1.3142     0.0474   27.75 < 0.0000000000000002 ***
## estrato4      28.4466     6.8886    4.13    0.000037422631659 ***
## estrato5      53.8148     7.1119    7.57    0.000000000000052 ***
## estrato6     207.0340     8.9520   23.13 < 0.0000000000000002 ***
## habitaciones -12.7093     3.2944   -3.86              0.00012 ***
## parqueaderos  62.8145     3.7342   16.82 < 0.0000000000000002 ***
## banios        39.5855     2.9716   13.32 < 0.0000000000000002 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 87.6 on 2769 degrees of freedom
## Multiple R-squared:  0.79,   Adjusted R-squared:  0.789 
## F-statistic: 1.49e+03 on 7 and 2769 DF,  p-value: <0.0000000000000002
tidy(modelo2, conf.int = TRUE) %>%
  mutate(
    Variable = unname(nombres_coef[term]),   # mismos nombres legibles del Modelo 1
    Signif = case_when(p.value < 0.001 ~ "***", p.value < 0.01 ~ "**",
                       p.value < 0.05 ~ "*", p.value < 0.1 ~ ".", TRUE ~ "")
  ) %>%
  select(Variable, Coeficiente = estimate, `Error est.` = std.error,
         `t` = statistic, `p-valor` = p.value,
         `IC 95% inf` = conf.low, `IC 95% sup` = conf.high, Signif) %>%
  mutate(across(where(is.numeric), ~round(., 3))) %>%
  kable(caption = "Modelo 2 · Apartamentos Zona Sur — coeficientes estimados") %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed")) %>%
  row_spec(which(summary(modelo2)$coefficients[, 4] < 0.05), bold = TRUE)
Modelo 2 · Apartamentos Zona Sur — coeficientes estimados
Variable Coeficiente Error est. t p-valor IC 95% inf IC 95% sup Signif
Intercepto -42.135 10.361 -4.067 0 -62.451 -21.820 ***
Área construida (m²) 1.314 0.047 27.754 0 1.221 1.407 ***
Estrato 4 (vs. 3) 28.447 6.889 4.130 0 14.939 41.954 ***
Estrato 5 (vs. 3) 53.815 7.112 7.567 0 39.870 67.760 ***
Estrato 6 (vs. 3) 207.034 8.952 23.127 0 189.481 224.587 ***
Habitaciones -12.709 3.294 -3.858 0 -19.169 -6.249 ***
Parqueaderos 62.815 3.734 16.822 0 55.492 70.137 ***
Baños 39.586 2.972 13.321 0 33.759 45.412 ***

5.3.1 Interpretación contextualizada

  • Área construida = 1.314 (p < 0.001). Cada m² adicional vale $1.31 millones, es decir un 69% más que en las casas del norte ($0.78 M). Coincide con lo observado en el EDA: el suelo del sur es más caro.

  • Estrato. Frente al estrato 3, el 4 agrega $28 millones, el 5 $54 millones y el 6 $207 millones. El salto al estrato 6 es de nuevo desproporcionado ($153 millones sobre el 5), y aquí es menos marcado que en el norte ($189 millones): en la Zona Sur el estrato 6 concentra la oferta premium (barrios con más ofertas de estrato 6: Pance, Ciudad Jardín, Parcelaciones Pance) y funciona casi como un mercado aparte.

  • Parqueaderos = 62.81 (p < 0.001). Cada cupo adicional vale $63 millones. Es el contraste más interesante con el Modelo 1, donde la variable no era significativa: en propiedad horizontal el parqueadero es un bien escaso que se transa por separado, mientras que en una casa unifamiliar viene incorporado. Un mismo atributo tiene valor económico distinto según el mercado — exactamente el argumento que sostenía estimar dos modelos.

  • Baños = 39.59 (p < 0.001). Cada baño adicional suma $40 millones, más que en las casas del norte ($28 M).

  • Habitaciones = -12.71 (p < 0.001) — coeficiente negativo. A igual área, estrato, baños y parqueaderos, cada habitación adicional reduce el precio esperado en $13 millones. No es un error: es la lectura correcta de un modelo con controles. Partir 90 m² en cuatro habitaciones en vez de tres produce espacios más pequeños y un producto menos apetecido en un segmento que valora amplitud. El mercado del sur premia apartamentos amplios con pocas habitaciones grandes, no la subdivisión.

  • Intercepto = -42.14: sin interpretación práctica (implicaría un apartamento de 0 m²); cumple función de ajuste.

5.3.2 Bondad de ajuste

data.frame(
  Indicador = c("R²", "R² ajustado", "Error estándar residual (M)",
                "Estadístico F", "p-valor del modelo", "Observaciones"),
  Valor = c(round(g2$r.squared, 4), round(g2$adj.r.squared, 4),
            round(g2$sigma, 1), round(g2$statistic, 1),
            format.pval(g2$p.value, digits = 3), nrow(base2))
) %>%
  kable(caption = "Modelo 2 · Indicadores de ajuste") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Modelo 2 · Indicadores de ajuste
Indicador Valor
0.7897
R² ajustado 0.7892
Error estándar residual (M) 87.6
Estadístico F 1485.7
p-valor del modelo <0.0000000000000002
Observaciones 2777

El \(R^2 = 0.7897\): el modelo explica el 79.0% de la variabilidad del precio, mejor ajuste que en las casas del norte (66.5%). La razón es que los apartamentos son un producto mucho más estandarizado: dentro de un mismo conjunto residencial las unidades se parecen entre sí, mientras que las casas varían en lote, diseño y antigüedad de forma que las cinco variables no capturan. Además, 5 de las cinco variables resultan significativas al 5% (en el Modelo 1 fueron 3).

El error residual es de $88 millones sobre un precio mediano de $245 millones (36% del precio mediano, frente al 40% en el Modelo 1). Las vías de mejora son las mismas señaladas en la sección de estimación del Modelo 1, con un énfasis adicional: incorporar piso, antigüedad del edificio y administración mensual, determinantes de precio propios de la propiedad horizontal que la base no registra adecuadamente.

5.4 Réplica del paso 4 · Validación de supuestos

par(mfrow = c(2, 2), mar = c(4, 4, 3, 1))
plot(modelo2, col = "#3f9b5a", pch = 19, cex = 0.4)

par(mfrow = c(1, 1))
bp2 <- bptest(modelo2)
sw2 <- shapiro_seguro(modelo2)   # muestra de 5.000 residuos si n es mayor
dw2 <- dwtest(modelo2)

data.frame(
  Supuesto = c("Homocedasticidad", "Normalidad de residuos", "Independencia de residuos"),
  Prueba = c("Breusch-Pagan", "Shapiro-Wilk", "Durbin-Watson"),
  Estadístico = round(c(bp2$statistic, sw2$statistic, dw2$statistic), 4),
  `p-valor` = format.pval(c(bp2$p.value, sw2$p.value, dw2$p.value), digits = 3, eps = 0.0001),
  Conclusión = c(ifelse(bp2$p.value < 0.05, "Se rechaza — heterocedasticidad", "No se rechaza"),
                 ifelse(sw2$p.value < 0.05, "Se rechaza — no normalidad", "No se rechaza"),
                 ifelse(dw2$p.value < 0.05, "Se rechaza — hay autocorrelación", "No se rechaza")),
  check.names = FALSE
) %>%
  kable(caption = "Modelo 2 · Pruebas formales de supuestos (α = 0.05)") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)
Modelo 2 · Pruebas formales de supuestos (α = 0.05)
Supuesto Prueba Estadístico p-valor Conclusión
BP Homocedasticidad Breusch-Pagan 852.1467 <0.0001 Se rechaza — heterocedasticidad
W Normalidad de residuos Shapiro-Wilk 0.7755 <0.0001 Se rechaza — no normalidad
DW Independencia de residuos Durbin-Watson 1.7095 <0.0001 Se rechaza — hay autocorrelación
vif2 <- vif(modelo2)
kable(round(as.data.frame(vif2), 3),
      caption = "Modelo 2 · Factores de inflación de varianza (VIF)") %>%
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Modelo 2 · Factores de inflación de varianza (VIF)
GVIF Df GVIF^(1/(2*Df))
areaconst 2.185 1 1.478
estrato 1.846 3 1.108
habitaciones 1.470 1 1.212
parqueaderos 1.905 1 1.380
banios 2.750 1 1.658
vif2_max <- max(vif2[, ncol(vif2)])
Supuesto Resultado Lectura
Multicolinealidad Mayor GVIF ajustado = 1.66 Cumple. Sin redundancia entre predictores
Homocedasticidad Breusch-Pagan p < 0.001 No cumple. La varianza del error no es constante (abanico en Residuals vs Fitted)
Normalidad Shapiro-Wilk p < 0.001 No cumple. Colas pesadas en el Q-Q plot, provocadas por los apartamentos de mayor precio
Linealidad Lectura visual de Residuals vs Fitted Nube centrada en cero sin curvatura sistemática = forma lineal adecuada en el rango observado
Independencia Durbin-Watson p < 0.001 Formalmente se rechaza; como en el Modelo 1, refleja factores compartidos por inmuebles del mismo conjunto o barrio, no una secuencia temporal

El diagnóstico reproduce el patrón del Modelo 1: heterocedasticidad y residuos no normales con multicolinealidad controlada. Se identifican 144 observaciones influyentes (distancia de Cook > 4/n). Las sugerencias de corrección son las mismas de la sección de validación de supuestos del Modelo 1: errores estándar robustos, transformación logarítmica del precio y control por barrio. Se enuncian como trabajo futuro y no se aplican en esta actividad.

5.5 Réplica del paso 5 · Estimación puntual para la Solicitud 2

# ---- Perfil solicitado: apto., 300 m², 3 parq., 3 baños, 5 hab., estrato 5 o 6
solicitud2 <- bind_rows(nuevo(300, 5, 5, 3, 3), nuevo(300, 6, 5, 3, 3))

pred_conf2 <- predict(modelo2, solicitud2, interval = "confidence", level = 0.95)
pred_pred2 <- predict(modelo2, solicitud2, interval = "prediction", level = 0.95)

data.frame(
  Escenario = c("Estrato 5", "Estrato 6"),
  `Precio estimado (M)` = round(pred_conf2[, "fit"], 1),
  `IC 95% del precio medio` = sprintf("[%.1f ; %.1f]", pred_conf2[, "lwr"], pred_conf2[, "upr"]),
  `IP 95% de un apto. individual` = sprintf("[%.1f ; %.1f]", pred_pred2[, "lwr"], pred_pred2[, "upr"]),
  `Crédito (M)` = 850,
  `Margen (M)` = round(850 - pred_conf2[, "fit"], 1),
  check.names = FALSE
) %>%
  kable(caption = "Solicitud 2 · Estimación del precio del apartamento en la Zona Sur") %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE) %>%
  row_spec(1, background = ifelse(pred_conf2[1, "fit"] <= 850, "#eaf7ee", "#fdecea")) %>%
  row_spec(2, background = ifelse(pred_conf2[2, "fit"] <= 850, "#eaf7ee", "#fdecea"))
Solicitud 2 · Estimación del precio del apartamento en la Zona Sur
Escenario Precio estimado (M) IC 95% del precio medio IP 95% de un apto. individual Crédito (M) Margen (M)
Estrato 5 649.6 [629.2 ; 670.0] [476.6 ; 822.6] 850 200.4
Estrato 6 802.8 [782.2 ; 823.5] [629.8 ; 975.9] 850 47.2

5.5.1 Interpretación de la estimación

Estrato 5. Precio esperado de $649.6 millones (IC 95%: $629.2 – $670.0). El crédito de $850 millones deja un margen de $200 millones, holgura suficiente para absorber gastos notariales, impuesto de registro y una eventual sobreoferta competitiva.

Estrato 6. Precio esperado de $802.8 millones (IC 95%: $782.2 – $823.5). También cabe en el crédito, pero el margen se reduce a $47 millones y el intervalo de predicción individual llega hasta $976 millones: un apartamento concreto de ese perfil sí podría superar el presupuesto.

Advertencia sobre el soporte de los datos. A diferencia de la Solicitud 1, aquí el perfil está en el borde del rango observado: solo 31 de los 2777 apartamentos alcanzan 300 m². El modelo estima sobre un tramo con muy pocas observaciones, de modo que la estimación conserva validez como referencia pero con menor precisión efectiva que la que sugiere el intervalo. La recomendación práctica es tratarla como techo de negociación y contrastarla con los inmuebles reales del listado siguiente.

5.6 Réplica del paso 6 · Ofertas potenciales para la Solicitud 2

CREDITO2 <- 850

base2_val <- base2 %>%
  mutate(valor_estimado = predict(modelo2, .),
         residual = preciom - valor_estimado,
         descuento_pct = 100 * residual / valor_estimado)

# Misma escalera que en la Solicitud 1, con el perfil literal del cliente
# (300 m², 5 hab., 3 baños, 3 parq., estrato 5-6) como primer peldaño.
filtra2 <- function(d, area_min, parq_min) d %>%
  filter(preciom <= CREDITO2,
         areaconst >= area_min,
         habitaciones >= 5,           # requisito del cliente, no se relaja
         banios >= 3,                 # requisito del cliente, no se relaja
         parqueaderos >= parq_min,
         estrato %in% c(5, 6)) %>%    # estratos solicitados, no se relajan
  arrange(residual)

escalera2 <- list(
  list(area = 300, parq = 3, etiqueta = "perfil literal (300 m², 3 parqueaderos)"),
  list(area = 200, parq = 3, etiqueta = "área desde 200 m²"),
  list(area = 200, parq = 2, etiqueta = "área desde 200 m² y 2 parqueaderos"))

for (e in escalera2)
  cat(sprintf("%-42s -> %d candidatas\n", e$etiqueta,
              nrow(filtra2(base2_val, e$area, e$parq))))
## perfil literal (300 m², 3 parqueaderos)   -> 2 candidatas
## área desde 200 m²                        -> 3 candidatas
## área desde 200 m² y 2 parqueaderos       -> 8 candidatas
candidatas2_lit <- filtra2(base2_val, 300, 3)

if (nrow(candidatas2_lit) > 0) {
  candidatas2_lit %>%
    transmute(Barrio = titulo(barrio), Estrato = estrato, `Área (m²)` = areaconst,
              Hab. = habitaciones, Baños = banios, Parq. = parqueaderos,
              `Precio oferta (M)` = preciom, `Valor estimado (M)` = round(valor_estimado, 0)) %>%
    kable(caption = "Solicitud 2 · Apartamentos que cumplen el perfil literal de 300 m²") %>%
    kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)
}
Solicitud 2 · Apartamentos que cumplen el perfil literal de 300 m²
Barrio Estrato Área (m²) Hab. Baños Parq. Precio oferta (M) Valor estimado (M)
Guadalupe 5 573 5 8 3 730 1206
Seminario 5 300 6 5 3 670 716
sel2 <- aplica_escalera(base2_val, filtra2, escalera2)
candidatas2 <- sel2$candidatas; CRITERIO2 <- sel2$criterio
cat("Criterio usado:", CRITERIO2, "->", nrow(candidatas2), "apartamentos candidatos\n")
## Criterio usado: área desde 200 m² y 2 parqueaderos -> 8 apartamentos candidatos
ofertas2 <- candidatas2 %>% slice_head(n = 6)

ofertas2 %>%
  transmute(Barrio = titulo(barrio), Estrato = estrato, `Área (m²)` = areaconst,
            Hab. = habitaciones, Baños = banios, Parq. = parqueaderos,
            `Precio oferta (M)` = preciom,
            `Valor estimado (M)` = round(valor_estimado, 0),
            `Dif. (M)` = round(residual, 0),
            `Descuento` = sprintf("%.0f%%", -descuento_pct)) %>%
  kable(caption = paste0("Solicitud 2 · ", nrow(ofertas2),
                         " ofertas recomendadas, ordenadas por mayor descuento frente al valor estimado")) %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE)
Solicitud 2 · 6 ofertas recomendadas, ordenadas por mayor descuento frente al valor estimado
Barrio Estrato Área (m²) Hab. Baños Parq. Precio oferta (M) Valor estimado (M) Dif. (M) Descuento
Guadalupe 5 573 5 8 3 730 1206 -476 39%
El Ingenio 5 600 5 4 2 650 1021 -371 36%
San Fernando 5 258 5 4 2 350 571 -221 39%
Seminario 5 256 5 5 3 530 671 -141 21%
Cuarto de Legua 5 220 5 5 2 420 561 -141 25%
Seminario 5 300 6 5 3 670 716 -46 6%
plot_ly() %>%
  add_trace(data = candidatas2, lat = ~latitud, lon = ~longitud,
            type = "scattermapbox", mode = "markers", name = "Otras candidatas",
            marker = list(size = 8, color = "#b9c6d2", opacity = 0.7),
            hoverinfo = "text",
            text = ~paste0(barrio, " · ", areaconst, " m² · $", preciom, " M")) %>%
  add_trace(data = ofertas2, lat = ~latitud, lon = ~longitud,
            type = "scattermapbox", mode = "markers",
            name = paste("Top", nrow(ofertas2), "recomendadas"),
            marker = list(size = 16, color = "#1e6b39", opacity = 0.95),
            hoverinfo = "text",
            text = ~paste0("<b>", barrio, "</b><br>Estrato ", estrato, " · ",
                           areaconst, " m²<br>", habitaciones, " hab · ", banios, " baños",
                           " · ", parqueaderos, " parq.",
                           "<br><b>Oferta: $", preciom, " M</b>",
                           "<br>Valor estimado: $", round(valor_estimado), " M")) %>%
  layout(title = "Solicitud 2 · Ofertas potenciales de apartamento en la Zona Sur",
         mapbox = list(style = "open-street-map", zoom = 11.5,
                       center = list(lat = 3.37, lon = -76.53)),
         legend = list(orientation = "h", y = -0.05))

5.6.1 Discusión de las ofertas

El perfil literal del cliente (300 m², 5 habitaciones, 3 baños, 3 parqueaderos, estrato 5 o 6) deja solo 2 opciones, insuficientes para las cinco ofertas que exige el enunciado. Bajando la escalera de relajación, el criterio que sí las alcanza es área desde 200 m² y 2 parqueaderos; habitaciones, baños y estrato se mantienen en lo solicitado. Se obtienen 8 apartamentos que cumplen las condiciones dentro del crédito de $850 millones. La oferta se concentra en El Ingenio, Seminario, Ciudadela Pasoancho.

Puntos para la conversación con el cliente:

  1. El presupuesto no es la restricción; el producto sí. Con $850 millones se accede al segmento alto del sur, pero apartamentos de 300 m² son excepcionales. Conviene plantear al cliente la disyuntiva entre área y ubicación premium.
  2. Las ofertas con mayor descuento frente al modelo exigen verificación. Los inmuebles de 573–600 m² que encabezan la lista aparecen por debajo de su valor estimado (hasta $476 millones). Cuando la brecha se concentra en unidades grandes, parte de ella puede ser un artefacto del modelo, entrenado sobre apartamentos de 85 m² de mediana y con pocas observaciones en el tramo alto. Hay que contrastar contra precios reales de cierre en esos conjuntos antes de ofertar.
  3. La liquidez juega a favor del comprador. Un apartamento de gran área en el sur tiene pocos compradores potenciales, lo que amplía el margen de negociación. Recomendable abrir la oferta entre un 10% y un 15% por debajo del precio publicado.

6 Comparación de los dos modelos

# Un coeficiente se marca "(n.s.)" si su p-valor es >= 0.05; la lista de variables
# significativas se arma desde los p-valores, no a mano.
coef_ns <- function(coef, p) paste0(round(coef, 1), ifelse(p < 0.05, "", " (n.s.)"))
signif_lista <- function(p) {
  p <- p[names(p) != "(Intercept)"]
  etiquetas <- c(areaconst = "área", estrato4 = "estrato", estrato5 = "estrato",
                 estrato6 = "estrato", habitaciones = "habitaciones",
                 parqueaderos = "parqueaderos", banios = "baños")
  sig <- unique(unname(etiquetas[names(p)[p < 0.05]]))
  if (length(sig) == 0) "Ninguna" else if (length(sig) == 5) "Todas" else paste(sig, collapse = ", ")
}

comp <- data.frame(
  Aspecto = c("Mercado", "Observaciones", "R²", "R² ajustado",
              "Error residual (M)", "Precio mediano (M)", "Área mediana (m²)",
              "Valor del m² (M)", "Efecto estrato 6 vs. 3 (M)",
              "Efecto por baño (M)", "Efecto por parqueadero (M)",
              "Efecto por habitación (M)", "Variables significativas (5%)"),
  `Modelo 1 · Casas Zona Norte` = c(
    "Casas · Zona Norte", nrow(base1), round(g1$r.squared, 4), round(g1$adj.r.squared, 4),
    round(g1$sigma, 1), median(base1$preciom), median(base1$areaconst),
    round(b1c["areaconst"], 3), round(b1c["estrato6"], 1),
    coef_ns(b1c["banios"], p1c["banios"]),
    coef_ns(b1c["parqueaderos"], p1c["parqueaderos"]),
    coef_ns(b1c["habitaciones"], p1c["habitaciones"]),
    signif_lista(p1c)),
  `Modelo 2 · Aptos. Zona Sur` = c(
    "Apartamentos · Zona Sur", nrow(base2), round(g2$r.squared, 4), round(g2$adj.r.squared, 4),
    round(g2$sigma, 1), median(base2$preciom), median(base2$areaconst),
    round(b2c["areaconst"], 3), round(b2c["estrato6"], 1),
    coef_ns(b2c["banios"], p2c["banios"]),
    coef_ns(b2c["parqueaderos"], p2c["parqueaderos"]),
    coef_ns(b2c["habitaciones"], p2c["habitaciones"]),
    signif_lista(p2c)),
  check.names = FALSE
)
kable(comp, caption = "Comparación de los dos modelos estimados") %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE) %>%
  column_spec(1, bold = TRUE)
Comparación de los dos modelos estimados
Aspecto Modelo 1 · Casas Zona Norte Modelo 2 · Aptos. Zona Sur
Mercado Casas · Zona Norte Apartamentos · Zona Sur
Observaciones 700 2777
0.665 0.7897
R² ajustado 0.6616 0.7892
Error residual (M) 155.8 87.6
Precio mediano (M) 390 245
Área mediana (m²) 240 85
Valor del m² (M) 0.777 1.314
Efecto estrato 6 vs. 3 (M) 323.3 207
Efecto por baño (M) 28.2 39.6
Efecto por parqueadero (M) 9.8 (n.s.) 62.8
Efecto por habitación (M) 3.8 (n.s.) -12.7
Variables significativas (5%) área, estrato, baños Todas

Qué muestra la comparación. Los dos mercados obedecen a lógicas de precio distintas y eso valida la decisión metodológica del Anexo 1:

  • El metro cuadrado del sur vale 69% más que el del norte, pese a que los inmuebles son mucho más pequeños.
  • El parqueadero es significativo únicamente en apartamentos ($63 M por cupo), donde escasea; en las casas del norte no aporta valor medible.
  • Las habitaciones cambian de signo: nulas en el norte, negativas en el sur. En propiedad horizontal, subdividir el área penaliza el precio.
  • El Modelo 2 ajusta mejor (79.0% vs. 66.5%) por la mayor homogeneidad del producto.

Un modelo único para toda la ciudad habría promediado estas dos estructuras y produciría, en ambos casos, valoraciones sesgadas.


7 Conclusiones

  1. La Solicitud 1 es viable solo en estrato 4. Con $350 millones se compra una casa de 200 m², 4 habitaciones y 2 baños en la Zona Norte si es estrato 4 ($322 M estimados); en estrato 5 el precio esperado ($383 M) excede el crédito y todo el intervalo de confianza queda por fuera del presupuesto.

  2. La Solicitud 2 es viable en ambos estratos, con precios estimados de $650 M (estrato 5) y $803 M (estrato 6) frente a un crédito de $850 millones. La restricción efectiva es la escasez de apartamentos de 300 m² en la Zona Sur (1.1% de la oferta).

  3. El precio se forma de manera distinta en cada mercado. El área y el estrato mandan en ambos, pero el parqueadero solo tiene valor en apartamentos y las habitaciones no crean valor en ninguno de los dos —lo penalizan en el sur—. Es información directamente accionable para el equipo comercial de C&A al armar y presentar ofertas.

  4. Los modelos cumplen su propósito con límites conocidos. Explican el 67% y el 79% de la variación de precios; las pruebas de supuestos muestran heterocedasticidad y residuos no normales (Modelo 1) y heterocedasticidad y residuos no normales (Modelo 2), por lo que sus intervalos son aproximados. Sirven para enmarcar una negociación, no para reemplazar el avalúo profesional del inmueble seleccionado.

  5. La base tiene problemas de georreferenciación que conviene reportar al proveedor. Alrededor del 17% de las ofertas del norte y el 4% de las del sur tienen coordenadas inconsistentes con su zona declarada. No afecta las estimaciones —el modelo no usa coordenadas— pero sí la confiabilidad de cualquier mapa que se muestre al cliente.


Fuente de datos: vivienda, paqueteMODELOS (Centro MAGIS) — ofertas de vivienda en Cali de los últimos tres meses. Caso: adaptado de Weiers, Ronald M., Introducción a la estadística para Negocios, 2006. Herramientas: R 4.5.2 · RMarkdown · plotly · lmtest · car. Documento generado el 07/09/2026.