# Ejecutar UNA sola vez antes de compilar. No corre durante el knit.
install.packages(c("tidyverse", "plotly", "leaflet", "knitr", "kableExtra",
                   "modelsummary", "broom", "car", "lmtest", "nortest", "devtools"))
devtools::install_github("centromagis/paqueteMODELOS", force = TRUE)

1 Alcance

María, gerente de C&A, recibió la solicitud de una compañía internacional que necesita reubicar dos familias en Cali. Cada solicitud llega con un perfil cerrado y un crédito preaprobado que no se puede exceder.

Tabla 1. Requerimientos de las dos familias
Caracteristica Solicitud 1 Solicitud 2
Tipo Casa Apartamento
Área construida (m2) 200 300
Parqueaderos 1 3
Banos 2 3
Habitaciones 4 5
Estrato 4 o 5 5 o 6
Zona Norte Sur
Credito preaprobado $350 millones $850 millones

Se construyen dos submercados independientes (casas del norte y apartamentos del sur) y sobre cada uno se recorren los siete pasos solicitados: filtro y mapa, análisis exploratorio, estimación por MCO, validación de supuestos, comparación de modelos, predicción y búsqueda de ofertas. Todas las cifras del texto se calculan al compilar, de modo que la narrativa y las salidas de R nunca se contradicen.

2 Datos y preparación

2.1 Carga

data("vivienda")
datos_crudos <- as_tibble(vivienda)
glimpse(datos_crudos)
#> 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…

2.2 Tratamiento de faltantes

datos_crudos |>
  group_by(tipo) |>
  summarise(Ofertas = n(),
            `NA piso` = sum(is.na(piso)),
            `% piso` = 100 * mean(is.na(piso)),
            `NA parqueaderos` = sum(is.na(parqueaderos)),
            `% parqueaderos` = 100 * mean(is.na(parqueaderos)),
            .groups = "drop") |>
  tabla("Tabla 2. Valores faltantes por tipo de vivienda")
Tabla 2. Valores faltantes por tipo de vivienda
tipo Ofertas NA piso % piso NA parqueaderos % parqueaderos
Apartamento 5,100 1,381 27.08 869 17.04
Casa 3,219 1,254 38.96 733 22.77
NA 3 3 100.00 3 100.00

El patrón no es aleatorio y eso define el tratamiento. En las casas la variable piso carece de sentido (una casa no ocupa un piso dentro de una edificación) y ahí se concentran los vacíos; como además no entra en el modelo que fija el enunciado, no se imputa. Los faltantes de parqueaderos se comportan como un cero no diligenciado: el anunciante omite el campo cuando el inmueble no tiene parqueadero, así que imputar con la media sobreestimaría el atributo. Se imputan con 0.

limites_cali <- list(lat = c(3.28, 3.56), lon = c(-76.62, -76.42))

datos <- datos_crudos |>
  mutate(
    parqueaderos = replace_na(parqueaderos, 0),
    coord_valida = !is.na(latitud) & !is.na(longitud) &
      between(latitud, limites_cali$lat[1], limites_cali$lat[2]) &
      between(longitud, limites_cali$lon[1], limites_cali$lon[2]),
    precio_m2 = if_else(!is.na(areaconst) & areaconst > 0, preciom / areaconst, NA_real_)
  )

tibble(Indicador = c("Registros totales", "NA en parqueaderos tras imputar",
                     "Coordenadas validas", "Coordenadas inconsistentes"),
       Valor = c(nrow(datos), sum(is.na(datos$parqueaderos)),
                 sum(datos$coord_valida), sum(!datos$coord_valida))) |>
  tabla("Tabla 3. Resultado del preprocesamiento")
Tabla 3. Resultado del preprocesamiento
Indicador Valor
Registros totales 8,322
NA en parqueaderos tras imputar 0
Coordenadas validas 8,319
Coordenadas inconsistentes 3

2.3 Rutinas reutilizables

Como el analisis se repite dos veces (casas del norte y apartamentos del sur), en lugar de copiar el mismo codigo en ambas partes se definen aqui, una sola vez, las funciones que se usaran en los siete pasos de cada solicitud. La ventaja es que cualquier decision metodologica (como se imputa, como se mide el error, que umbral se usa) queda escrita en un solo lugar y se aplica de forma identica a los dos submercados, de modo que los resultados son comparables por construccion.

Cada funcion corresponde a un paso concreto del enunciado:

Funcion Para que sirve Se usa en
construir_base() Filtra la base por tipo y zona y crea las (k-1) variables dummy del estrato Punto 1
centroides_zona y marcar_coherencia() Calculan el centro geografico de cada zona y marcan si cada oferta cae o no donde dice su rotulo Punto 1 (mapa)
formula_nivel, formula_log, especificaciones Guardan las dos ecuaciones que se van a comparar: precio en pesos y precio en logaritmos Puntos 3 y 5
factor_duan() y predecir_millones() Devuelven las predicciones siempre en millones de pesos; el factor de Duan corrige el sesgo que aparece al deshacer el logaritmo Puntos 5, 6 y 7
validacion_repetida(), resumen_validacion(), grafico_validacion() Parten la base en 70/30 cien veces, calculan el error de prediccion en cada corte y resumen su distribucion Punto 5
clasificar_vif() y vif_max() Traducen el VIF a los umbrales del curso (hasta 5 sin problema, 5 a 10 moderada, mas de 10 severa) Punto 4
tabla_supuestos() Corre de una vez las pruebas de Breusch-Pagan, Goldfeld-Quandt, Durbin-Watson, Anderson-Darling y la distancia de Cook Punto 4
buscar_ofertas() y mapa_ofertas() Filtran las viviendas que caben en el presupuesto, las ordenan por parecido al perfil pedido y las dibujan en el mapa Punto 7

Ademas, en un bloque oculto al inicio se definieron cuatro ayudas de formato: fmt() para mostrar numeros con dos decimales, p_fmt() para los valores p, veredicto() para escribir si un coeficiente es significativo, coef_de() para leer un coeficiente del modelo sin que falle el documento si no existe, y tabla() para dar el mismo estilo a todas las tablas. Son las que permiten que las cifras del texto se calculen solas al compilar.

# 1. Submercado y codificacion dummy (k-1 categorias) -------------------------
construir_base <- function(datos, tipo_obj, zona_obj) {
  base <- datos |>
    filter(str_detect(tipo, regex(tipo_obj, ignore_case = TRUE)),
           str_detect(zona, regex(zona_obj, ignore_case = TRUE)))
  niveles <- sort(unique(base$estrato))
  base$estrato_f <- factor(base$estrato, levels = niveles)
  for (e in niveles[-1]) base[[paste0("D_estrato", e)]] <- as.numeric(base$estrato == e)
  base
}

# 2. Coherencia entre zona declarada y ubicacion real -------------------------
centroides_zona <- datos |>
  filter(coord_valida) |>
  group_by(zona) |>
  summarise(lat_c = median(latitud), lon_c = median(longitud), .groups = "drop")

marcar_coherencia <- function(base) {
  base |>
    rowwise() |>
    mutate(zona_geo = if (coord_valida) {
      centroides_zona$zona[which.min(sqrt((latitud - centroides_zona$lat_c)^2 +
                                          (longitud - centroides_zona$lon_c)^2))]
    } else NA_character_) |>
    ungroup() |>
    mutate(coherente = zona_geo == zona)
}

# 3. Especificaciones a comparar ----------------------------------------------
formula_nivel <- preciom ~ areaconst + estrato_f + habitaciones + banios + parqueaderos
formula_log   <- log(preciom) ~ areaconst + estrato_f + habitaciones + banios + parqueaderos

especificaciones <- list("A. Nivel-nivel" = list(f = formula_nivel, log = FALSE),
                         "B. Log-nivel"   = list(f = formula_log,   log = TRUE))

factor_duan <- function(modelo) mean(exp(residuals(modelo)))

predecir_millones <- function(modelo, datos_nuevos, escala_log) {
  p <- predict(modelo, newdata = datos_nuevos)
  if (escala_log) p <- exp(p) * factor_duan(modelo)
  as.numeric(p)
}

# 4. Validacion simple repetida (validacion cruzada) --------------------------
validacion_repetida <- function(base, prop = 0.7, reps = 100, semilla = 2024) {
  set.seed(semilla)
  n <- nrow(base); nombres <- names(especificaciones)
  MSE <- matrix(NA_real_, reps, length(nombres), dimnames = list(NULL, nombres))
  MAE <- MSE; MAPE <- MSE
  for (i in seq_len(reps)) {
    idx <- sample(seq_len(n), size = round(prop * n))
    train <- base[idx, ]; test <- base[-idx, ]
    if (nrow(test) < 5) next
    for (j in seq_along(nombres)) {
      m <- lm(especificaciones[[j]]$f, data = train)
      p <- predecir_millones(m, test, especificaciones[[j]]$log)
      MSE[i, j]  <- mean((test$preciom - p)^2, na.rm = TRUE)
      MAE[i, j]  <- mean(abs(test$preciom - p), na.rm = TRUE)
      MAPE[i, j] <- 100 * mean(abs(test$preciom - p) / test$preciom, na.rm = TRUE)
    }
  }
  list(MSE = MSE, MAE = MAE, MAPE = MAPE)
}

resumen_validacion <- function(vc) {
  tibble(Modelo = colnames(vc$MSE),
         `MSE medio`  = colMeans(vc$MSE, na.rm = TRUE),
         `RMSE medio` = sqrt(colMeans(vc$MSE, na.rm = TRUE)),
         `MAE medio`  = colMeans(vc$MAE, na.rm = TRUE),
         `MAPE medio (%)` = colMeans(vc$MAPE, na.rm = TRUE))
}

grafico_validacion <- function(vc, titulo) {
  as.data.frame(sqrt(vc$MSE)) |>
    pivot_longer(everything(), names_to = "Modelo", values_to = "RMSE") |>
    ggplot(aes(x = Modelo, y = RMSE, fill = Modelo)) +
    geom_boxplot(outlier.shape = NA, alpha = 0.55) +
    geom_jitter(width = 0.12, alpha = 0.3, size = 0.9, colour = "#034A94") +
    scale_fill_manual(values = paleta) + coord_flip() +
    labs(title = titulo, subtitle = "100 particiones aleatorias 70/30",
         x = NULL, y = "RMSE en el conjunto de prueba (millones)") +
    theme_bw() + theme(legend.position = "none")
}

# 5. Diagnosticos --------------------------------------------------------------
clasificar_vif <- function(v) {
  if (is.na(v)) "no evaluable"
  else if (v <= 5) "sin multicolinealidad"
  else if (v <= 10) "multicolinealidad moderada"
  else "multicolinealidad severa"
}

vif_max <- function(modelo) {
  g <- vif(modelo)
  if (is.matrix(g)) max(g[, ncol(g)]^2) else max(g)
}

tabla_supuestos <- function(modelo) {
  bp <- bptest(modelo); gq <- gqtest(modelo, order.by = fitted(modelo))
  dw <- dwtest(modelo); ad <- ad.test(residuals(modelo))
  cook <- cooks.distance(modelo)
  tibble(
    Supuesto = c("Varianza constante", "Varianza constante", "Independencia",
                 "Normalidad", "Observaciones influyentes"),
    Prueba = c("Breusch-Pagan", "Goldfeld-Quandt", "Durbin-Watson",
               "Anderson-Darling", "Distancia de Cook > 4/n"),
    `Estadistico` = c(unname(bp$statistic), unname(gq$statistic), unname(dw$statistic),
                      unname(ad$statistic), sum(cook > 4 / length(cook))),
    `Valor p` = c(bp$p.value, gq$p.value, dw$p.value, ad$p.value, NA_real_))
}

# 6. Busqueda de ofertas -------------------------------------------------------
buscar_ofertas <- function(base, modelo, escala_log, requerimiento, presupuesto, top = 5) {
  vars <- c("areaconst", "habitaciones", "banios", "parqueaderos")
  escalas <- map_dbl(vars, ~ sd(base[[.x]], na.rm = TRUE)); names(escalas) <- vars
  escalas[escalas == 0 | is.na(escalas)] <- 1

  factibles <- base |>
    filter(preciom <= presupuesto, estrato_f %in% requerimiento$estratos)
  if (nrow(factibles) == 0) {
    return(factibles |> mutate(distancia = numeric(0), valor_modelo = numeric(0),
                               brecha = numeric(0), `brecha %` = numeric(0)))
  }

  d <- map_dbl(seq_len(nrow(factibles)), function(i) {
    sqrt(sum(map_dbl(vars, ~ ((factibles[[.x]][i] - requerimiento[[.x]]) / escalas[[.x]])^2)))
  })

  factibles |>
    mutate(distancia = d,
           valor_modelo = predecir_millones(modelo, factibles, escala_log),
           brecha = valor_modelo - preciom,
           `brecha %` = 100 * brecha / valor_modelo) |>
    arrange(distancia, desc(brecha)) |>
    slice_head(n = top)
}

mapa_ofertas <- function(ofertas, titulo) {
  ofertas <- ofertas |> filter(coord_valida)
  if (nrow(ofertas) == 0) {
    return(leaflet() |> addTiles() |>
             addControl(paste0("<b>", titulo, " (sin coordenadas)</b>"), position = "topright"))
  }
  leaflet(ofertas) |> addTiles() |>
    addMarkers(lng = ~longitud, lat = ~latitud,
               label = ~paste0(barrio, " - $", round(preciom), " M"),
               popup = ~paste0("<b>", barrio, "</b><br/>Precio: $", round(preciom, 1),
                               " M<br/>\u00c1rea: ", areaconst, " m2<br/>Estrato: ", estrato,
                               "<br/>Hab: ", habitaciones, " | Ba\u00f1os: ", banios,
                               " | Parq: ", parqueaderos,
                               "<br/>Valor del modelo: $", round(valor_modelo, 1), " M")) |>
    addControl(paste0("<b>", titulo, "</b>"), position = "topright")
}

3 Solicitud 1: casa en la zona norte

3.1 Punto 1. Filtro de la base y mapa

requerimiento_1 <- list(areaconst = 200, parqueaderos = 1, banios = 2, habitaciones = 4,
                        estratos = c("4", "5"), presupuesto = 350)

base_norte <- construir_base(datos, "Casa", "Norte")

head(base_norte |> select(id, barrio, estrato, areaconst, habitaciones,
                          banios, parqueaderos, preciom), 3) |>
  tabla("Tabla 4. Primeros tres registros de la base filtrada")
Tabla 4. Primeros tres registros de la base filtrada
id barrio estrato areaconst habitaciones banios parqueaderos preciom
1,209 acopi 5 150 6 4 2 320
1,592 acopi 5 380 3 3 2 780
4,057 acopi 6 445 6 7 0 750
tibble(`Comprobacion` = c("Ofertas en la base", "Tipos presentes", "Zonas presentes",
                          "Estratos presentes", "Precio minimo (M$)", "Precio maximo (M$)"),
       Resultado = c(as.character(nrow(base_norte)),
                     paste(unique(base_norte$tipo), collapse = ", "),
                     paste(unique(base_norte$zona), collapse = ", "),
                     paste(sort(unique(base_norte$estrato)), collapse = ", "),
                     fmt(min(base_norte$preciom)), fmt(max(base_norte$preciom)))) |>
  tabla("Tabla 5. Comprobacion del filtro")
Tabla 5. Comprobacion del filtro
Comprobacion Resultado
Ofertas en la base 722
Tipos presentes Casa
Zonas presentes Zona Norte
Estratos presentes 3, 4, 5, 6
Precio minimo (M\() </td> <td style="text-align:center;"> 89.00 </td> </tr> <tr> <td style="text-align:center;"> Precio maximo (M\)) 1,940.00
base_norte |>
  group_by(estrato) |>
  summarise(Ofertas = n(),
            `Participacion (%)` = 100 * n() / nrow(base_norte),
            `Area media (m2)` = mean(areaconst, na.rm = TRUE),
            `Precio medio (M$)` = mean(preciom, na.rm = TRUE),
            `Precio/m2 (M$)` = mean(precio_m2, na.rm = TRUE), .groups = "drop") |>
  tabla("Tabla 6. Composicion y precios por estrato")
Tabla 6. Composicion y precios por estrato
estrato Ofertas Participacion (%) Area media (m2) Precio medio (M\() </th> <th style="text-align:center;"> Precio/m2 (M\))
3 235 32.55 166.1 244.2 1.74
4 161 22.30 262.0 438.8 1.83
5 271 37.53 325.3 549.5 1.86
6 55 7.62 397.6 818.2 2.28

El filtro es correcto: la base contiene un único tipo de vivienda (Casa), una única zona (Zona Norte) y 722 ofertas.

3.1.1 Mapa de las ofertas

base_norte_geo <- marcar_coherencia(base_norte)
pal_coh <- colorFactor(c("#c0504d", "#2e8b57"), domain = c(FALSE, TRUE))

leaflet(base_norte_geo |> filter(coord_valida)) |>
  addTiles() |>
  addCircleMarkers(lng = ~longitud, lat = ~latitud, radius = 4, stroke = FALSE,
                   fillOpacity = 0.75, fillColor = ~pal_coh(coherente),
                   popup = ~paste0("<b>", barrio, "</b><br/>Zona declarada: ", zona,
                                   "<br/>Zona m\u00e1s cercana por coordenada: ", zona_geo,
                                   "<br/>Precio: $", round(preciom, 1), " M")) |>
  addLegend(position = "bottomright", colors = c("#2e8b57", "#c0504d"),
            labels = c("Coherente", "Discrepante"), opacity = 0.9,
            title = "Zona declarada vs. ubicaci\u00f3n") |>
  addControl("<b>Mapa 1. Casas ofertadas en la zona norte</b>", position = "topright")
base_norte_geo |>
  filter(coord_valida) |>
  count(zona_geo, name = "Ofertas") |>
  mutate(`% del total` = 100 * Ofertas / sum(Ofertas)) |>
  arrange(desc(Ofertas)) |>
  tabla("Tabla 7. Zona sugerida por las coordenadas de las casas rotuladas como norte")
Tabla 7. Zona sugerida por las coordenadas de las casas rotuladas como norte
zona_geo Ofertas % del total
Zona Norte 505 69.94
Zona Centro 80 11.08
Zona Sur 68 9.42
Zona Oeste 38 5.26
Zona Oriente 31 4.29

Discusión: no todos los puntos caen en la zona. Se calculó el centroide de cada zona con toda la base y se reasignó cada oferta a la zona cuyo centroide le queda más cerca. De las 722 casas con coordenada valida, 217 (30.1%) quedarían clasificadas en otra zona. No son necesariamente errores de digitación; hay tres causas plausibles:

  1. La zona es una convención comercial, no una frontera cartográfica. El rótulo “Zona Norte” responde a la nomenclatura de las agencias inmobiliarias y no coincide con las comunas ni con una partición geométrica de la ciudad.
  2. Barrios de frontera. Los barrios sobre el límite entre dos zonas se asignan a una u otra según el criterio del anunciante, y un centroide calculado con la mediana los empuja hacia la zona vecina.
  3. Geocodificación a nivel de barrio. Varias ofertas comparten exactamente la misma coordenada, señal de que la ubicación se tomó del centroide del barrio y no del inmueble.

Se conserva el rótulo declarado, porque es el criterio con el que la familia conversa sobre la ciudad, pero la discrepancia se retoma al recomendar ofertas concretas.

3.2 Punto 2. Análisis exploratorio y correlación

mat_cor <- base_norte |>
  select(preciom, areaconst, estrato, banios, habitaciones, parqueaderos) |>
  cor(use = "complete.obs") |> round(2)

plot_ly(x = colnames(mat_cor), y = rownames(mat_cor), z = mat_cor, type = "heatmap",
        colors = colorRamp(c("#ffffff", "#6ba3d6", "#1f4e79")), zmin = -1, zmax = 1,
        text = mat_cor, texttemplate = "%{text}", hoverinfo = "x+y+z") |>
  layout(title = "Correlaciones de Pearson - casas zona norte",
         xaxis = list(title = ""), yaxis = list(title = "", autorange = "reversed"))

El precio se asocia sobre todo con el área construida (r = 0.73), el estrato (r = 0.61) y los baños (r = 0.52). Las habitaciones muestran una asociación mucho más débil (r = 0.32), lo que anticipa que aportan poca información una vez conocido el tamaño de la casa. La correlación entre área y baños (r = 0.46) es el primer indicio de que los regresores no son independientes y de que habrá que revisar la multicolinealidad.

plot_ly(base_norte, x = ~areaconst, y = ~preciom, color = ~factor(estrato),
        colors = paleta, type = "scatter", mode = "markers",
        marker = list(size = 7, opacity = 0.65),
        text = ~paste0(barrio, "<br/>", areaconst, " m2 - $", round(preciom), " M"),
        hoverinfo = "text") |>
  layout(title = "Precio vs. \u00e1rea construida por estrato",
         xaxis = list(title = "\u00c1rea construida (m2)"),
         yaxis = list(title = "Precio (millones)"),
         legend = list(title = list(text = "Estrato")))

La nube se abre en abanico: la dispersión de precios crece con el área, lo que anticipa que la varianza del error no será constante. Además las pendientes por estrato difieren, es decir, el metro cuadrado no vale lo mismo en todos los estratos.

b1 <- plot_ly(base_norte, x = ~factor(estrato), y = ~preciom, type = "box",
              name = "Estrato", marker = list(color = "#e0a800"), line = list(color = "#e0a800"))
b2 <- plot_ly(base_norte, x = ~factor(habitaciones), y = ~preciom, type = "box",
              name = "Habitaciones", marker = list(color = "#1f4e79"), line = list(color = "#1f4e79"))
b3 <- plot_ly(base_norte, x = ~factor(banios), y = ~preciom, type = "box",
              name = "Banos", marker = list(color = "#2e8b57"), line = list(color = "#2e8b57"))
b4 <- plot_ly(base_norte, x = ~factor(parqueaderos), y = ~preciom, type = "box",
              name = "Parqueaderos", marker = list(color = "#c0504d"), line = list(color = "#c0504d"))
subplot(b1, b2, b3, b4, nrows = 2, shareY = TRUE, titleX = FALSE) |>
  layout(title = "Precio segun estrato, habitaciones, banos y parqueaderos",
         yaxis = list(title = "Precio (millones)"))

Los diagramas de caja confirman la lectura: el precio mediano sube de forma clara con el estrato y con el número de baños, sube de forma más irregular con los parqueaderos, y prácticamente no cambia con el número de habitaciones. La dispersión también crece en los grupos de precio alto, señal temprana de heterocedasticidad.

datos |>
  filter(str_detect(tipo, "Casa")) |>
  group_by(zona) |>
  summarise(Ofertas = n(), `Precio mediano (M$)` = median(preciom),
            `Precio/m2 mediano (M$)` = median(precio_m2, na.rm = TRUE), .groups = "drop") |>
  arrange(desc(`Precio mediano (M$)`)) |>
  tabla("Tabla 8. Efecto de la zona (todas las casas de la ciudad)")
Tabla 8. Efecto de la zona (todas las casas de la ciudad)
zona Ofertas Precio mediano (M\() </th> <th style="text-align:center;"> Precio/m2 mediano (M\))
Zona Oeste 169 680 2.07
Zona Sur 1,939 480 2.17
Zona Norte 722 390 1.66
Zona Centro 100 310 1.49
Zona Oriente 289 235 1.26

Sobre la variable zona. Dentro de la base filtrada la zona es constante y por eso no tiene varianza ni puede entrar al modelo. Su efecto es justamente lo que justifica haber segmentado: la Tabla 8 muestra que el precio mediano de una casa cambia sustancialmente entre zonas. Restringir el análisis a la zona norte equivale a controlar la localización de la forma más fuerte posible, fijandola, en lugar de resumirla en un promedio.

3.3 Punto 3. Modelo de regresión lineal múltiple

El estrato es cualitativo, así que se codifica con (k-1) variables binarias dejando el estrato más bajo como categoría de referencia.

base_norte |>
  distinct(estrato, pick(starts_with("D_estrato"))) |>
  arrange(estrato) |>
  tabla("Tabla 9. Codificaci\u00f3n (k-1) de la variable estrato", digitos = 0)
Tabla 9. Codificación (k-1) de la variable estrato
estrato D_estrato4 D_estrato5 D_estrato6
3 0 0 0
4 1 0 0
5 0 1 0
6 0 0 1

\[\text{preciom}_i = \beta_0 + \beta_1 \text{areaconst}_i + \sum_e \delta_e D^{(e)}_i + \beta_2 \text{habitaciones}_i + \beta_3 \text{banios}_i + \beta_4 \text{parqueaderos}_i + \varepsilon_i\]

modelo_norte <- lm(formula_nivel, data = base_norte)
summary(modelo_norte)
#> 
#> Call:
#> lm(formula = formula_nivel, data = base_norte)
#> 
#> Residuals:
#>    Min     1Q Median     3Q    Max 
#> -968.5  -73.7  -16.1   45.6 1069.4 
#> 
#> Coefficients:
#>              Estimate Std. Error t value             Pr(>|t|)    
#> (Intercept)   31.2881    17.7126    1.77                0.078 .  
#> areaconst      0.8244     0.0431   19.13 < 0.0000000000000002 ***
#> estrato_f4    84.2528    17.5448    4.80   0.0000019133573690 ***
#> estrato_f5   136.7761    16.9079    8.09   0.0000000000000026 ***
#> estrato_f6   329.2170    26.5567   12.40 < 0.0000000000000002 ***
#> habitaciones   1.5129     4.1422    0.37                0.715    
#> banios        25.6403     5.3555    4.79   0.0000020523504837 ***
#> parqueaderos   2.3523     4.3557    0.54                0.589    
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> Residual standard error: 157 on 714 degrees of freedom
#> Multiple R-squared:  0.661,  Adjusted R-squared:  0.658 
#> F-statistic:  199 on 7 and 714 DF,  p-value: <0.0000000000000002

3.3.1 Interpretación de los coeficientes

Área construida: 0.82 millones por m2 (p < 0,001, estadisticamente significativo). Manteniendo constantes estrato, habitaciones, baños y parqueaderos, cada metro cuadrado adicional se asocia con un aumento promedio de 0.82 millones en el precio de oferta. Es el efecto más robusto y es lógico: el área es el insumo básico que se transa en el mercado de vivienda.

Estrato (referencia: estrato 3):

  • Estrato 4: 84.25 millones frente a una casa comparable de estrato 3 (p < 0,001, estadisticamente significativo).
  • Estrato 5: 136.78 millones frente a una casa comparable de estrato 3 (p < 0,001, estadisticamente significativo).
  • Estrato 6: 329.22 millones frente a una casa comparable de estrato 3 (p < 0,001, estadisticamente significativo).

El escalonamiento captura la calidad del entorno, la dotación de servicios y el prestigio del sector. Confirma que en Cali el estrato es un determinante de primer orden del precio, incluso comparando casas del mismo tamaño dentro de la misma zona.

Baños: 25.64 millones por unidad (p < 0,001, estadisticamente significativo). Con el área fija, un bano más implica una vivienda mejor dotada por metro cuadrado y con acabados más costosos. El signo positivo es el esperado.

Parqueaderos: 2.35 millones por unidad (p 0.589, no significativo). El parqueadero es un bien escaso en la ciudad y se cotiza casi como un activo separado del inmueble. Coherente con la práctica comercial.

Habitaciones: 1.51 millones por unidad (p 0.715, no significativo). El signo es positivo, aunque de magnitud modesta frente al área. La clave es que el coeficiente se interpreta con el área construida fija: añadir una habitación sin aumentar los metros cuadrados no agranda la casa, la subdivide. El modelo compara dos viviendas del mismo tamaño, una con más cuartos y por lo tanto más pequeños. En el segmento alto, que es el relevante para esta solicitud, el comprador prefiere espacios amplios sobre mayor número de recintos. No es una anomalía de los datos sino el efecto parcial correctamente definido; la advertencia práctica es que no debe leerse como “las habitaciones restan valor” sino como “a igual área, más subdivisión no agrega valor”.

Intercepto (31.29). Precio esperado de una casa de estrato 3 con cero metros, cero habitaciones, cero baños y cero parqueaderos. Es un punto fuera del rango de los datos, así que no tiene interpretación sustantiva: solo ancla la recta.

3.3.2 Ajuste del modelo

tibble(Indicador = c("R2", "R2 ajustado", "AIC", "BIC", "Error estandar residual (M$)",
                     "Error estandar / precio medio", "F", "Valor p del F", "Observaciones"),
       Valor = c(fmt(r2_1, 4), fmt(r2a_1, 4), fmt(AIC(modelo_norte), 1),
                 fmt(BIC(modelo_norte), 1), fmt(sigma_1),
                 paste0(fmt(100 * sigma_1 / precio_medio_1, 1), "%"),
                 fmt(f_1[1], 1), p_fmt(pf_1), as.character(nrow(base_norte)))) |>
  tabla("Tabla 10. Indicadores de ajuste")
Tabla 10. Indicadores de ajuste
Indicador Valor
R2 0.6613
R2 ajustado 0.6579
AIC 9,359.7
BIC 9,400.9
Error estandar residual (M$) 156.95
Error estandar / precio medio 35.2%
F 199.1
Valor p del F < 0,001
Observaciones 722

El estadístico F de 199.1 (p < 0,001) rechaza la hipótesis de que todos los coeficientes de pendiente son cero: el conjunto de regresores sí aporta información.

El R2 ajustado de 0.658 indica que las cinco características explican cerca del 65.8% de la variabilidad del precio. Para un modelo hedónico con tan pocos regresores es un ajuste alto y confirma que área y estrato hacen la mayor parte del trabajo.

El complemento es igual de informativo: queda sin explicar cerca del 34.2% y el error típico es de 156.95 millones, el 35.2% del precio promedio. La razón es identificable: la base no observa antigüedad, estado de acabados, orientación, zonas comunes, vigilancia ni la ubicación exacta dentro del barrio. Dos casas idénticas en las cinco variables pueden diferir en decenas de millones por esos atributos omitidos.

Como mejorarlo: (i) incluir el barrio como efecto fijo o un índice de precio por m2 del sector, que absorberia la heterogeneidad espacial; (ii) modelar el precio en logaritmos, porque la relación luce multiplicativa y la dispersión crece con el nivel; (iii) permitir que el valor del m2 dependa del estrato con una interacción área x estrato; (iv) enriquecer la base con antigüedad y estado. La opción (ii) se evalúa formalmente en el Punto 5.

3.4 Punto 4. Validación de supuestos

as.data.frame(vif(modelo_norte)) |> rownames_to_column("Variable") |>
  tabla("Tabla 11. Factores de inflacion de varianza (VIF)", digitos = 3)
Tabla 11. Factores de inflacion de varianza (VIF)
Variable GVIF Df GVIF^(1/(2*Df))
areaconst 1.519 1 1.232
estrato_f 1.635 3 1.085
habitaciones 1.677 1 1.295
banios 1.949 1 1.396
parqueaderos 1.294 1 1.137
sup_1 <- tabla_supuestos(modelo_norte)
tabla(sup_1, "Tabla 12. Validaci\u00f3n de supuestos sobre los errores", digitos = 4)
Tabla 12. Validación de supuestos sobre los errores
Supuesto Prueba Estadistico Valor p
Varianza constante Breusch-Pagan 139.873 0
Varianza constante Goldfeld-Quandt 7.942 0
Independencia Durbin-Watson 1.688 0
Normalidad Anderson-Darling 27.677 0
Observaciones influyentes Distancia de Cook > 4/n 49.000 NA
par(mfrow = c(2, 2), mar = c(4.2, 4.2, 2.6, 1.2))
plot(modelo_norte, which = 1, col = adjustcolor("#1f4e79", 0.4), pch = 16)
plot(modelo_norte, which = 2, col = adjustcolor("#1f4e79", 0.4), pch = 16)
plot(modelo_norte, which = 3, col = adjustcolor("#1f4e79", 0.4), pch = 16)
plot(modelo_norte, which = 5, col = adjustcolor("#1f4e79", 0.4), pch = 16)

par(mfrow = c(1, 1))

Multicolinealidad. El mayor VIF corregido es 1.95, es decir sin multicolinealidad según los umbrales del curso (hasta 5 sin problema, entre 5 y 10 moderada, sobre 10 severa). Los predictores comparten información (las casas grandes tienen más baños) pero no al punto de impedir separar sus efectos, así que los coeficientes son interpretables individualmente.

Media cero de los errores. El promedio de los residuos es -1.78e-15, cero salvo error numérico. Es un resultado mecánico de MCO con intercepto: no se pone a prueba, se verifica.

Varianza constante (Breusch-Pagan p < 0,001; Goldfeld-Quandt p < 0,001). Ambas pruebas rechazan la homocedasticidad: hay heterocedasticidad. El gráfico de residuos contra valores ajustados lo confirma con la forma de abanico ya anticipada en el análisis exploratorio: los errores son pequeños en las casas económicas y grandes en las costosas. La consecuencia no es sesgo en los coeficientes, que siguen siendo insesgados, sino errores estándar mal calculados, lo que invalida los valores p y los intervalos que reporta summary().

Normalidad (Anderson-Darling p < 0,001). Se rechaza la normalidad de los residuos. El Q-Q plot muestra colas más pesadas que las de una normal, sobre todo en el extremo superior: hay casas cuyo precio de oferta supera con mucho lo que predicen sus atributos. Con 722 observaciones el teorema central del límite protege la inferencia sobre los coeficientes, así que este es el incumplimiento menos grave; sí afecta, en cambio, la cobertura de los intervalos de predicción individuales que se usan en el Punto 6.

Independencia (Durbin-Watson = 1.69, p < 0,001). La prueba detecta correlacion entre residuos consecutivos. Conviene precisar el sentido: los datos son de corte transversal, no una serie de tiempo, de modo que el orden de las filas no tiene significado temporal. Si la prueba señala dependencia, lo más probable es que la base venga ordenada por barrio o precio y que lo detectado sea autocorrelación espacial: casas vecinas comparten atributos no observados del entorno.

Observaciones influyentes. 49 registros superan el criterio \(D_i > 4/n\) de la distancia de Cook. Son ofertas atípicas que conviene revisar antes de usar el modelo en producción.

3.4.1 Sugerencias de corrección

Problema Corrección sugerida Efecto esperado
Heterocedasticidad Errores estándar robustos HC3, o modelar log(precio) Inferencia valida sin cambiar los coeficientes; el log además estabiliza la varianza
Colas pesadas e influyentes Regresión robusta o winsorización del 1% extremo Reduce el peso de las ofertas atípicas
Dependencia espacial Efecto fijo de barrio o modelo de error espacial Absorbe atributos no observados del entorno
Variables omitidas Incorporar antigüedad, estado y zonas comunes Reduce el error de predicción

3.5 Punto 5. Comparación de modelos y validación cruzada

Se contrastan dos especificaciones: la del enunciado en niveles y una alternativa en logaritmos que responde a la heterocedasticidad detectada. La comparación combina indicadores dentro de muestra con el error fuera de muestra por validación simple repetida 100 veces, para no depender de una sola partición.

ajustes_1 <- map(especificaciones, ~ lm(.x$f, data = base_norte))

map_dfr(ajustes_1, glance, .id = "Modelo") |>
  select(Modelo, r.squared, adj.r.squared, sigma, statistic, AIC, BIC, nobs) |>
  rename(`R2` = r.squared, `R2 ajustado` = adj.r.squared, `Sigma` = sigma,
         `F` = statistic, `n` = nobs) |>
  tabla("Tabla 13. Indicadores de ajuste de las dos especificaciones", digitos = 3)
Tabla 13. Indicadores de ajuste de las dos especificaciones
Modelo R2 R2 ajustado Sigma F AIC BIC n
A. Nivel-nivel 0.661 0.658 156.953 199.1 9,359.7 9,401 722
B. Log-nivel 0.750 0.748 0.285 306.0 246.8 288 722

El R2 no es comparable entre un modelo en niveles y uno en logaritmos, porque la variable dependiente cambia de escala. Por eso el criterio de selección es el error fuera de muestra, medido siempre en millones tras retrotransformar con el factor de Duan.

vc_1 <- validacion_repetida(base_norte)
tabla(resumen_validacion(vc_1), "Tabla 14. Validaci\u00f3n simple repetida (100 particiones 70/30)", digitos = 3)
Tabla 14. Validación simple repetida (100 particiones 70/30)
Modelo MSE medio RMSE medio MAE medio MAPE medio (%)
A. Nivel-nivel 26,530 162.9 100.0 22.02
B. Log-nivel 39,283 198.2 108.8 24.34
grafico_validacion(vc_1, "Distribuci\u00f3n del error de validaci\u00f3n - casas zona norte")

La especificación A. Nivel-nivel obtiene el menor error fuera de muestra (RMSE medio = 162.88 millones; MAE = 100.00; MAPE = 22.0%) y es la que se usa para valorar la solicitud. El modelo definitivo se reestima sobre la base completa: la partición ya cumplió su función de elegir la especificación y estimar el error esperado.

3.6 Punto 6. Predicción del precio de la vivienda solicitada

estratos_1 <- intersect(requerimiento_1$estratos, levels(base_norte$estrato_f))
if (length(estratos_1) == 0) estratos_1 <- levels(base_norte$estrato_f)

escenarios_1 <- tibble(areaconst = requerimiento_1$areaconst,
                       habitaciones = requerimiento_1$habitaciones,
                       banios = requerimiento_1$banios,
                       parqueaderos = requerimiento_1$parqueaderos,
                       estrato_f = factor(estratos_1, levels = levels(base_norte$estrato_f)))

pred_1 <- predict(modelo_final_1, newdata = escenarios_1, interval = "prediction", level = 0.95)

resultado_1 <- escenarios_1 |>
  mutate(Estrato = as.character(estrato_f),
         duan = if (esclog_1) factor_duan(modelo_final_1) else 1,
         `Precio estimado (M$)` = if (esclog_1) exp(pred_1[, "fit"]) * duan else pred_1[, "fit"],
         `Limite inferior 95%` = if (esclog_1) exp(pred_1[, "lwr"]) * duan else pred_1[, "lwr"],
         `Limite superior 95%` = if (esclog_1) exp(pred_1[, "upr"]) * duan else pred_1[, "upr"],
         `Presupuesto (M$)` = requerimiento_1$presupuesto,
         `Alcanza?` = if_else(`Precio estimado (M$)` <= requerimiento_1$presupuesto, "Si", "No")) |>
  select(Estrato, `Precio estimado (M$)`, `Limite inferior 95%`, `Limite superior 95%`,
         `Presupuesto (M$)`, `Alcanza?`)

tabla(resultado_1, "Tabla 15. Predicci\u00f3n para la vivienda 1 (200 m2, 4 hab., 2 ba\u00f1os, 1 parqueadero)")
Tabla 15. Predicción para la vivienda 1 (200 m2, 4 hab., 2 baños, 1 parqueadero)
Estrato Precio estimado (M\() </th> <th style="text-align:center;"> Limite inferior 95% </th> <th style="text-align:center;"> Limite superior 95% </th> <th style="text-align:center;"> Presupuesto (M\)) Alcanza?
4 340.1 30.54 649.7 350 Si
5 392.6 83.31 702.0 350 No

Una casa de 200 m2 con cuatro habitaciones, dos baños y un parqueadero en la zona norte se valora entre 340.10 y 392.63 millones según el estrato. Al menos uno de los escenarios cabe dentro del crédito de 350 millones, de modo que la solicitud es viable aunque con margen estrecho. El intervalo de predicción al 95% es amplio porque incorpora la incertidumbre del inmueble individual y no solo la del valor medio; es la cifra honesta para negociar.

3.7 Punto 7. Ofertas potenciales dentro del presupuesto

La búsqueda tiene dos etapas. Primero se restringe la base a las ofertas financieramente viables (precio menor o igual al crédito y estrato dentro del rango pedido). Luego se ordenan por cercanía al perfil, con la distancia euclídea sobre las cuatro características estructurales estandarizadas para que ninguna domine por su escala. El desempate lo da la brecha frente a la valoración del modelo: si una casa se ofrece por debajo de lo que el modelo estima, hay margen de negociación.

ofertas_1 <- buscar_ofertas(base_norte, modelo_final_1, esclog_1,
                            requerimiento_1, requerimiento_1$presupuesto, top = 5)

n_fact_1 <- base_norte |>
  filter(preciom <= requerimiento_1$presupuesto, estrato_f %in% estratos_1) |> nrow()

ofertas_1 |>
  transmute(Barrio = barrio, Estrato = estrato, `Area (m2)` = areaconst,
            Hab = habitaciones, `Banos` = banios, Parq = parqueaderos,
            `Precio (M$)` = preciom, `Valor del modelo (M$)` = valor_modelo,
            `Brecha (M$)` = brecha, `Brecha (%)` = `brecha %`,
            `Distancia al perfil` = distancia) |>
  tabla("Tabla 16. Cinco ofertas recomendadas para la familia 1")
Tabla 16. Cinco ofertas recomendadas para la familia 1
Barrio Estrato Area (m2) Hab Banos Parq Precio (M\() </th> <th style="text-align:center;"> Valor del modelo (M\)) Brecha (M$) Brecha (%) Distancia al perfil
alamos 4 120 4 2 1 275 274.1 -0.85 -0.31 0.48
la merced 4 240 3 2 1 330 371.6 41.57 11.19 0.60
prados del norte 5 140 3 2 1 280 341.6 61.65 18.04 0.65
la merced 5 216 4 2 2 350 408.2 58.17 14.25 0.66
vipasa 4 264 3 2 1 320 391.4 71.35 18.23 0.67
mapa_ofertas(ofertas_1, "Mapa 2. Ofertas recomendadas - familia 1")

Discusión. En la zona norte hay 112 casas que cumplen a la vez el tope de 350 millones y el rango de estratos pedido; de ellas se eligen las cinco más parecidas al requerimiento. Se ubican en alamos, la merced, prados del norte, vipasa, con áreas entre 120 y 264 m2 y precios entre 275.00 y 350.00 millones. 4 de las cinco se ofrecen por debajo de la valoración del modelo, lo que las convierte en las candidatas más atractivas.

El punto que María debe transmitir a la empresa es el canje inevitable: con 350 millones en la zona norte no se consiguen a la vez 200 m2, estrato 5 y cuatro habitaciones. Las palancas, de menor a mayor impacto sobre la familia, son (i) bajar el área hacia 150-170 m2 conservando estrato y habitaciones, que es lo más eficiente porque el área es el atributo mejor pagado; (ii) aceptar estrato 4 en un buen barrio del norte, que libera margen sin cambiar el tamaño; o (iii) ampliar el presupuesto hacia el rango de la Tabla 15.

Además, por lo visto en el Punto 1, algunas de estas ofertas están en barrios de frontera cuya coordenada las acerca a otra zona: conviene verificar la dirección exacta antes de mostrarlas, porque a la familia se le ofreció el norte.


4 Solicitud 2: apartamento en la zona sur

4.1 Punto 1. Filtro de la base y mapa

requerimiento_2 <- list(areaconst = 300, parqueaderos = 3, banios = 3, habitaciones = 5,
                        estratos = c("5", "6"), presupuesto = 850)

base_sur <- construir_base(datos, "Apartamento", "Sur")

head(base_sur |> select(id, barrio, estrato, piso, areaconst, habitaciones,
                        banios, parqueaderos, preciom), 3) |>
  tabla("Tabla 17. Primeros tres registros de la base filtrada")
Tabla 17. Primeros tres registros de la base filtrada
id barrio estrato piso areaconst habitaciones banios parqueaderos preciom
5,098 acopi 4 05 96 3 2 1 290
698 aguablanca 3 02 40 2 1 1 78
8,199 aguacatal 6 NA 194 3 5 2 875
tibble(`Comprobacion` = c("Ofertas en la base", "Tipos presentes", "Zonas presentes",
                          "Estratos presentes", "Precio minimo (M$)", "Precio maximo (M$)"),
       Resultado = c(as.character(nrow(base_sur)),
                     paste(unique(base_sur$tipo), collapse = ", "),
                     paste(unique(base_sur$zona), collapse = ", "),
                     paste(sort(unique(base_sur$estrato)), collapse = ", "),
                     fmt(min(base_sur$preciom)), fmt(max(base_sur$preciom)))) |>
  tabla("Tabla 18. Comprobacion del filtro")
Tabla 18. Comprobacion del filtro
Comprobacion Resultado
Ofertas en la base 2787
Tipos presentes Apartamento
Zonas presentes Zona Sur
Estratos presentes 3, 4, 5, 6
Precio minimo (M\() </td> <td style="text-align:center;"> 75.00 </td> </tr> <tr> <td style="text-align:center;"> Precio maximo (M\)) 1,750.00
base_sur |>
  group_by(estrato) |>
  summarise(Ofertas = n(), `Area media (m2)` = mean(areaconst, na.rm = TRUE),
            `Precio medio (M$)` = mean(preciom, na.rm = TRUE),
            `Precio/m2 (M$)` = mean(precio_m2, na.rm = TRUE), .groups = "drop") |>
  tabla("Tabla 19. Composicion y precios por estrato")
Tabla 19. Composicion y precios por estrato
estrato Ofertas Area media (m2) Precio medio (M\() </th> <th style="text-align:center;"> Precio/m2 (M\))
3 201 68.70 141.1 2.09
4 1,091 75.95 203.6 2.71
5 1,033 102.24 293.7 3.01
6 462 150.15 594.5 3.98
base_sur_geo <- marcar_coherencia(base_sur)

leaflet(base_sur_geo |> filter(coord_valida)) |>
  addTiles() |>
  addCircleMarkers(lng = ~longitud, lat = ~latitud, radius = 4, stroke = FALSE,
                   fillOpacity = 0.75, fillColor = ~pal_coh(coherente),
                   popup = ~paste0("<b>", barrio, "</b><br/>Zona declarada: ", zona,
                                   "<br/>Zona m\u00e1s cercana por coordenada: ", zona_geo,
                                   "<br/>Precio: $", round(preciom, 1), " M")) |>
  addLegend(position = "bottomright", colors = c("#2e8b57", "#c0504d"),
            labels = c("Coherente", "Discrepante"), opacity = 0.9,
            title = "Zona declarada vs. ubicaci\u00f3n") |>
  addControl("<b>Mapa 3. Apartamentos ofertados en la zona sur</b>", position = "topright")
base_sur_geo |>
  filter(coord_valida) |> count(zona_geo, name = "Ofertas") |>
  mutate(`% del total` = 100 * Ofertas / sum(Ofertas)) |>
  arrange(desc(Ofertas)) |>
  tabla("Tabla 20. Zona sugerida por las coordenadas de los apartamentos rotulados como sur")
Tabla 20. Zona sugerida por las coordenadas de los apartamentos rotulados como sur
zona_geo Ofertas % del total
Zona Sur 2,222 79.73
Zona Centro 312 11.19
Zona Oriente 102 3.66
Zona Oeste 86 3.09
Zona Norte 65 2.33

Discusión. 565 de las 2787 ofertas georreferenciadas (20.3%) quedarían asignadas a otra zona con el criterio del centroide más cercano. Aplican las tres causas del caso anterior, más un factor propio del sur: es la zona de expansión más reciente y su frontera comercial con el oriente y con el corredor sur no está estabilizada, de modo que un mismo conjunto residencial puede anunciarse como “sur” o como parte de otro sector según la agencia.

4.2 Punto 2. Análisis exploratorio y correlación

mat_cor_2 <- base_sur |>
  select(preciom, areaconst, estrato, banios, habitaciones, parqueaderos) |>
  cor(use = "complete.obs") |> round(2)

plot_ly(x = colnames(mat_cor_2), y = rownames(mat_cor_2), z = mat_cor_2, type = "heatmap",
        colors = colorRamp(c("#ffffff", "#7fbf9e", "#2e8b57")), zmin = -1, zmax = 1,
        text = mat_cor_2, texttemplate = "%{text}", hoverinfo = "x+y+z") |>
  layout(title = "Correlaciones de Pearson - apartamentos zona sur",
         xaxis = list(title = ""), yaxis = list(title = "", autorange = "reversed"))

El área vuelve a dominar (r = 0.76), seguida por baños (r = 0.72) y parqueaderos (r = 0.67). La correlación con el estrato (r = 0.67) es incluso mayor que en las casas del norte.

plot_ly(base_sur, x = ~areaconst, y = ~preciom, color = ~factor(estrato),
        colors = paleta, type = "scatter", mode = "markers",
        marker = list(size = 7, opacity = 0.6),
        text = ~paste0(barrio, "<br/>", areaconst, " m2 - $", round(preciom), " M"),
        hoverinfo = "text") |>
  layout(title = "Precio vs. \u00e1rea construida por estrato",
         xaxis = list(title = "\u00c1rea construida (m2)"),
         yaxis = list(title = "Precio (millones)"),
         legend = list(title = list(text = "Estrato")))
c1 <- plot_ly(base_sur, x = ~factor(estrato), y = ~preciom, type = "box", name = "Estrato",
              marker = list(color = "#e0a800"), line = list(color = "#e0a800"))
c2 <- plot_ly(base_sur, x = ~factor(habitaciones), y = ~preciom, type = "box", name = "Habitaciones",
              marker = list(color = "#1f4e79"), line = list(color = "#1f4e79"))
c3 <- plot_ly(base_sur, x = ~factor(banios), y = ~preciom, type = "box", name = "Banos",
              marker = list(color = "#2e8b57"), line = list(color = "#2e8b57"))
c4 <- plot_ly(base_sur, x = ~factor(parqueaderos), y = ~preciom, type = "box", name = "Parqueaderos",
              marker = list(color = "#c0504d"), line = list(color = "#c0504d"))
subplot(c1, c2, c3, c4, nrows = 2, shareY = TRUE, titleX = FALSE) |>
  layout(title = "Precio segun estrato, habitaciones, banos y parqueaderos",
         yaxis = list(title = "Precio (millones)"))

La lectura es la misma que en el norte con un matiz: en propiedad horizontal el número de parqueaderos separa los grupos de precio con más nitidez, porque es un derecho escaso asignado por el proyecto y no algo que el propietario pueda improvisar.

datos |>
  filter(str_detect(tipo, "Apartamento")) |>
  group_by(zona) |>
  summarise(Ofertas = n(), `Precio mediano (M$)` = median(preciom),
            `Precio/m2 mediano (M$)` = median(precio_m2, na.rm = TRUE), .groups = "drop") |>
  arrange(desc(`Precio mediano (M$)`)) |>
  tabla("Tabla 21. Efecto de la zona (todos los apartamentos de la ciudad)")
Tabla 21. Efecto de la zona (todos los apartamentos de la ciudad)
zona Ofertas Precio mediano (M\() </th> <th style="text-align:center;"> Precio/m2 mediano (M\))
Zona Oeste 1,029 570.0 3.84
Zona Norte 1,198 250.0 2.67
Zona Sur 2,787 245.0 2.90
Zona Centro 24 152.5 1.84
Zona Oriente 62 115.0 1.58

4.3 Punto 3. Modelo de regresión lineal múltiple

base_sur |>
  distinct(estrato, pick(starts_with("D_estrato"))) |> arrange(estrato) |>
  tabla("Tabla 22. Codificaci\u00f3n (k-1) de la variable estrato", digitos = 0)
Tabla 22. Codificación (k-1) de la variable estrato
estrato D_estrato4 D_estrato5 D_estrato6
3 0 0 0
4 1 0 0
5 0 1 0
6 0 0 1
modelo_sur <- lm(formula_nivel, data = base_sur)
summary(modelo_sur)
#> 
#> Call:
#> lm(formula = formula_nivel, data = base_sur)
#> 
#> Residuals:
#>     Min      1Q  Median      3Q     Max 
#> -1150.8   -36.6     0.3    32.7   899.1 
#> 
#> Coefficients:
#>              Estimate Std. Error t value             Pr(>|t|)    
#> (Intercept)   -2.0800     9.9291   -0.21               0.8341    
#> areaconst      1.3881     0.0455   30.49 < 0.0000000000000002 ***
#> estrato_f4    18.2219     6.9349    2.63               0.0086 ** 
#> estrato_f5    38.0364     7.2514    5.25           0.00000017 ***
#> estrato_f6   200.0779     9.1079   21.97 < 0.0000000000000002 ***
#> habitaciones -14.8544     3.1851   -4.66           0.00000325 ***
#> banios        39.8380     2.8686   13.89 < 0.0000000000000002 ***
#> parqueaderos  45.1798     2.8108   16.07 < 0.0000000000000002 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> Residual standard error: 88.3 on 2779 degrees of freedom
#> Multiple R-squared:  0.788,  Adjusted R-squared:  0.787 
#> F-statistic: 1.48e+03 on 7 and 2779 DF,  p-value: <0.0000000000000002

4.3.1 Interpretación de los coeficientes

Área construida: 1.39 millones por m2 (p < 0,001, estadisticamente significativo). El metro cuadrado en un apartamento del sur se valora por encima del de una casa del norte (0.82 millones). La comparación le sirve a María para saber en cual submercado se paga mejor el crecimiento del área.

Estrato (referencia: estrato 3):

  • Estrato 4: 18.22 millones frente a un apartamento comparable de estrato 3 (p 0.009, estadisticamente significativo).
  • Estrato 5: 38.04 millones frente a un apartamento comparable de estrato 3 (p < 0,001, estadisticamente significativo).
  • Estrato 6: 200.08 millones frente a un apartamento comparable de estrato 3 (p < 0,001, estadisticamente significativo).

Baños: 39.84 millones (p < 0,001, estadisticamente significativo). En propiedad horizontal el número de baños es un buen indicador del nivel de acabados y del segmento del proyecto.

Parqueaderos: 45.18 millones (p < 0,001, estadisticamente significativo). El efecto es mayor que en las casas del norte, coherente con que en un apartamento el parqueadero es un derecho escaso y no puede improvisarse.

Habitaciones: -14.85 millones (p < 0,001, estadisticamente significativo). Igual que en las casas, el efecto parcial es negativo: a igual área, subdividir en más cuartos no agrega valor y en el segmento alto incluso lo resta.

4.3.2 Ajuste del modelo

tibble(Indicador = c("R2", "R2 ajustado", "AIC", "BIC", "Error estandar residual (M$)",
                     "Error estandar / precio medio", "Observaciones"),
       Valor = c(fmt(summary(modelo_sur)$r.squared, 4), fmt(r2a_2, 4),
                 fmt(AIC(modelo_sur), 1), fmt(BIC(modelo_sur), 1), fmt(sigma_2),
                 paste0(fmt(100 * sigma_2 / precio_medio_2, 1), "%"),
                 as.character(nrow(base_sur)))) |>
  tabla("Tabla 23. Indicadores de ajuste")
Tabla 23. Indicadores de ajuste
Indicador Valor
R2 0.7880
R2 ajustado 0.7874
AIC 32,895.8
BIC 32,949.1
Error estandar residual (M$) 88.32
Error estandar / precio medio 29.7%
Observaciones 2787

El R2 ajustado de 0.787 es superior al de las casas del norte (0.658). La explicación es de mercado: los apartamentos son bienes más estandarizados, se construyen por proyectos con plantas repetidas y acabados homogéneos, de modo que sus cinco atributos observables describen mejor el producto. Las casas son diseños únicos donde la antigüedad, el lote y el estado de conservación pesan mucho más y no están en la base.

A las mejoras ya señaladas se suman dos propias de la propiedad horizontal: incorporar el piso, que sí está en la base y es relevante en apartamentos porque la altura y la vista se cotizan, y la administración mensual, que no está en los datos pero condiciona la decisión de compra.

4.4 Punto 4. Validación de supuestos

as.data.frame(vif(modelo_sur)) |> rownames_to_column("Variable") |>
  tabla("Tabla 24. Factores de inflacion de varianza (VIF)", digitos = 3)
Tabla 24. Factores de inflacion de varianza (VIF)
Variable GVIF Df GVIF^(1/(2*Df))
areaconst 2.046 1 1.430
estrato_f 1.847 3 1.108
habitaciones 1.450 1 1.204
banios 2.566 1 1.602
parqueaderos 1.783 1 1.335
sup_2 <- tabla_supuestos(modelo_sur)
tabla(sup_2, "Tabla 25. Validaci\u00f3n de supuestos sobre los errores", digitos = 4)
Tabla 25. Validación de supuestos sobre los errores
Supuesto Prueba Estadistico Valor p
Varianza constante Breusch-Pagan 853.181 0
Varianza constante Goldfeld-Quandt 12.858 0
Independencia Durbin-Watson 1.716 0
Normalidad Anderson-Darling 97.361 0
Observaciones influyentes Distancia de Cook > 4/n 146.000 NA
par(mfrow = c(2, 2), mar = c(4.2, 4.2, 2.6, 1.2))
plot(modelo_sur, which = 1, col = adjustcolor("#2e8b57", 0.4), pch = 16)
plot(modelo_sur, which = 2, col = adjustcolor("#2e8b57", 0.4), pch = 16)
plot(modelo_sur, which = 3, col = adjustcolor("#2e8b57", 0.4), pch = 16)
plot(modelo_sur, which = 5, col = adjustcolor("#2e8b57", 0.4), pch = 16)

par(mfrow = c(1, 1))

El mayor VIF corregido es 2.57 (sin multicolinealidad). Breusch-Pagan (p < 0,001) y Goldfeld-Quandt (p < 0,001) detectan heterocedasticidad, con el mismo patron de abanico del caso anterior; Anderson-Darling (p < 0,001) rechaza la normalidad de los residuos, con colas pesadas en el extremo superior; Durbin-Watson (p < 0,001) senala dependencia entre residuos consecutivos, atribuible al ordenamiento espacial de la base y no a una estructura temporal. Hay 146 observaciones influyentes según la distancia de Cook, concentradas en el extremo superior de precios: apartamentos de lujo cuya valoración excede lo que explican los cinco atributos. Las sugerencias de corrección son las mismas de la tabla del Punto 4 anterior.

4.5 Punto 5. Comparación de modelos y validación cruzada

ajustes_2 <- map(especificaciones, ~ lm(.x$f, data = base_sur))

map_dfr(ajustes_2, glance, .id = "Modelo") |>
  select(Modelo, r.squared, adj.r.squared, sigma, statistic, AIC, BIC, nobs) |>
  rename(`R2` = r.squared, `R2 ajustado` = adj.r.squared, `Sigma` = sigma,
         `F` = statistic, `n` = nobs) |>
  tabla("Tabla 26. Indicadores de ajuste de las dos especificaciones", digitos = 3)
Tabla 26. Indicadores de ajuste de las dos especificaciones
Modelo R2 R2 ajustado Sigma F AIC BIC n
A. Nivel-nivel 0.788 0.787 88.31 1,475 32,896 32,949.1 2,787
B. Log-nivel 0.815 0.815 0.22 1,751 -513 -459.6 2,787
vc_2 <- validacion_repetida(base_sur)
tabla(resumen_validacion(vc_2), "Tabla 27. Validaci\u00f3n simple repetida (100 particiones 70/30)", digitos = 3)
Tabla 27. Validación simple repetida (100 particiones 70/30)
Modelo MSE medio RMSE medio MAE medio MAPE medio (%)
A. Nivel-nivel 8,439 91.86 52.99 18.32
B. Log-nivel 17,402 131.92 54.01 18.43
grafico_validacion(vc_2, "Distribuci\u00f3n del error de validaci\u00f3n - apartamentos zona sur")

La especificación A. Nivel-nivel minimiza el error fuera de muestra (RMSE medio = 91.87 millones; MAPE = 18.3%) y es la que se usa para valorar la segunda solicitud, reestimada sobre la base completa.

4.6 Punto 6. Predicción del precio de la vivienda solicitada

estratos_2 <- intersect(requerimiento_2$estratos, levels(base_sur$estrato_f))
if (length(estratos_2) == 0) estratos_2 <- levels(base_sur$estrato_f)

escenarios_2 <- tibble(areaconst = requerimiento_2$areaconst,
                       habitaciones = requerimiento_2$habitaciones,
                       banios = requerimiento_2$banios,
                       parqueaderos = requerimiento_2$parqueaderos,
                       estrato_f = factor(estratos_2, levels = levels(base_sur$estrato_f)))

pred_2 <- predict(modelo_final_2, newdata = escenarios_2, interval = "prediction", level = 0.95)

resultado_2 <- escenarios_2 |>
  mutate(Estrato = as.character(estrato_f),
         duan = if (esclog_2) factor_duan(modelo_final_2) else 1,
         `Precio estimado (M$)` = if (esclog_2) exp(pred_2[, "fit"]) * duan else pred_2[, "fit"],
         `Limite inferior 95%` = if (esclog_2) exp(pred_2[, "lwr"]) * duan else pred_2[, "lwr"],
         `Limite superior 95%` = if (esclog_2) exp(pred_2[, "upr"]) * duan else pred_2[, "upr"],
         `Presupuesto (M$)` = requerimiento_2$presupuesto,
         `Alcanza?` = if_else(`Precio estimado (M$)` <= requerimiento_2$presupuesto, "Si", "No")) |>
  select(Estrato, `Precio estimado (M$)`, `Limite inferior 95%`, `Limite superior 95%`,
         `Presupuesto (M$)`, `Alcanza?`)

tabla(resultado_2, "Tabla 28. Prediccion para la vivienda 2 (300 m2, 5 hab., 3 banos, 3 parqueaderos)")
Tabla 28. Prediccion para la vivienda 2 (300 m2, 5 hab., 3 banos, 3 parqueaderos)
Estrato Precio estimado (M\() </th> <th style="text-align:center;"> Limite inferior 95% </th> <th style="text-align:center;"> Limite superior 95% </th> <th style="text-align:center;"> Presupuesto (M\)) Alcanza?
5 633.2 458.9 807.4 850 Si
6 795.2 620.9 969.5 850 Si

Un apartamento de 300 m2 con cinco habitaciones, tres baños y tres parqueaderos en la zona sur se valora entre 633.16 y 795.20 millones según el estrato. El crédito de 850 millones cubre al menos uno de los escenarios, con una holgura de unos 216.84 millones frente a la alternativa más economica.

Hay que advertir que un apartamento de 300 m2 es un producto atípico: en esta base solo hay 40 ofertas de 280 m2 o más y el máximo observado es de 932 m2. La predicción se hace cerca del borde del rango observado, donde el modelo extrapola y hay pocos comparables, así que el intervalo es especialmente ancho y la cifra debe tomarse como orden de magnitud para negociar, no como una tasación.

4.7 Punto 7. Ofertas potenciales dentro del presupuesto

ofertas_2 <- buscar_ofertas(base_sur, modelo_final_2, esclog_2,
                            requerimiento_2, requerimiento_2$presupuesto, top = 5)

n_fact_2 <- base_sur |>
  filter(preciom <= requerimiento_2$presupuesto, estrato_f %in% estratos_2) |> nrow()

ofertas_2 |>
  transmute(Barrio = barrio, Estrato = estrato, `Area (m2)` = areaconst,
            Hab = habitaciones, `Banos` = banios, Parq = parqueaderos,
            `Precio (M$)` = preciom, `Valor del modelo (M$)` = valor_modelo,
            `Brecha (M$)` = brecha, `Brecha (%)` = `brecha %`,
            `Distancia al perfil` = distancia) |>
  tabla("Tabla 29. Cinco ofertas recomendadas para la familia 2")
Tabla 29. Cinco ofertas recomendadas para la familia 2
Barrio Estrato Area (m2) Hab Banos Parq Precio (M\() </th> <th style="text-align:center;"> Valor del modelo (M\)) Brecha (M$) Brecha (%) Distancia al perfil
capri 5 270.0 4 3 3 350 606.4 256.37 42.28 1.68
San Fernando 5 258.0 5 4 2 350 569.5 219.52 38.54 1.83
el ingenio 6 250.0 5 4 2 700 720.5 20.46 2.84 1.91
cuarto de legua 5 295.6 4 4 2 410 636.5 226.50 35.58 2.29
seminario 5 256.0 5 5 3 530 651.8 121.76 18.68 2.30
mapa_ofertas(ofertas_2, "Mapa 4. Ofertas recomendadas - familia 2")

Discusión. En la zona sur hay 1439 apartamentos que cumplen el tope de 850 millones y el rango de estratos pedido, un conjunto más amplio que el de la primera solicitud. Las cinco opciones están en capri, San Fernando, el ingenio, cuarto de legua, seminario, con áreas entre 250 y 296 m2 y precios entre 350.00 y 700.00 millones; 5 se ofrecen por debajo de su valoración estimada.

Aquí la restricción no es el presupuesto sino la disponibilidad: la oferta de apartamentos de 300 m2 es muy delgada, así que el conjunto factible se llena con inmuebles algo más pequeños pero con estrato y dotación acordes. La recomendación práctica es presentar opciones de 200 a 260 m2 con cinco habitaciones y tres parqueaderos, que preservan lo que realmente importa para una familia trasladada y liberan margen del crédito. Si la empresa insiste en los 300 m2, conviene ampliar la búsqueda a la zona oeste, donde el inventario de vivienda grande de estratos altos es mayor, advirtiendo que eso incumple la preferencia de zona.


5 Comparación de los dos submercados

tibble(Indicador = c("Ofertas en la base", "Precio mediano (M$)", "Precio/m2 mediano (M$)",
                     "Valor del m2 (modelo en niveles)", "R2 ajustado", "VIF maximo",
                     "Especificacion seleccionada", "RMSE fuera de muestra (M$)",
                     "Predicci\u00f3n de la solicitud (M$)", "Presupuesto (M$)",
                     "Ofertas factibles halladas"),
       `Familia 1 - Casa norte` = c(
         as.character(nrow(base_norte)), fmt(median(base_norte$preciom)),
         fmt(median(base_norte$precio_m2, na.rm = TRUE), 3), fmt(b_area_1, 3),
         fmt(r2a_1, 3), fmt(vifmax_1, 2), mejor_1, fmt(rmse_1),
         paste0(fmt(est_min_1, 0), " - ", fmt(est_max_1, 0)),
         fmt(requerimiento_1$presupuesto, 0), as.character(n_fact_1)),
       `Familia 2 - Apto sur` = c(
         as.character(nrow(base_sur)), fmt(median(base_sur$preciom)),
         fmt(median(base_sur$precio_m2, na.rm = TRUE), 3), fmt(b_area_2, 3),
         fmt(r2a_2, 3), fmt(vifmax_2, 2), mejor_2, fmt(rmse_2),
         paste0(fmt(est_min_2, 0), " - ", fmt(est_max_2, 0)),
         fmt(requerimiento_2$presupuesto, 0), as.character(n_fact_2))) |>
  tabla("Tabla 30. Cuadro comparativo")
Tabla 30. Cuadro comparativo
Indicador Familia 1 - Casa norte Familia 2 - Apto sur
Ofertas en la base 722 2787
Precio mediano (M\() </td> <td style="text-align:center;"> 390.00 </td> <td style="text-align:center;"> 245.00 </td> </tr> <tr> <td style="text-align:center;"> Precio/m2 mediano (M\)) 1.664 2.902
Valor del m2 (modelo en niveles) 0.824 1.388
R2 ajustado 0.658 0.787
VIF maximo 1.95 2.57
Especificacion seleccionada A. Nivel-nivel A. Nivel-nivel
RMSE fuera de muestra (M\() </td> <td style="text-align:center;"> 162.88 </td> <td style="text-align:center;"> 91.87 </td> </tr> <tr> <td style="text-align:center;"> Predicción de la solicitud (M\)) 340 - 393 633 - 795
Presupuesto (M$) 350 850
Ofertas factibles halladas 112 1439

6 Informe ejecutivo para María

1. Las dos solicitudes no tienen la misma dificultad. La primera es viable pero con margen estrecho y la segunda cabe dentro del crédito aprobado. El obstáculo es distinto en cada caso: en la familia 1 la restricción es el precio; en la familia 2, la disponibilidad de inventario de inmuebles tan grandes.

2. El precio en Cali lo determinan, en ese orden, el área y el estrato. Con solo cinco características se explica el 66% de la variación en casas del norte y el 79% en apartamentos del sur. Es base suficiente para tasar rápido y para detectar ofertas mal preciadas.

3. Más habitaciones no significa más precio. A igual área, subdividir en más cuartos no agrega valor en los segmentos altos. Conviene orientar la conversación con las familias hacia metros cuadrados y calidad del entorno antes que hacia el conteo de habitaciones.

4. Familia 1. Presentar las cinco opciones de la Tabla 16, priorizando las que se ofrecen por debajo de la valoración del modelo, y preparar a la empresa para el canje: con 350 millones en el norte se consigue área o estrato alto, difícilmente ambos al nivel pedido.

5. Familia 2. Presentar las cinco opciones de la Tabla 29 y proponer explícitamente el rango de 200 a 260 m2, que conserva habitaciones y parqueaderos y libera presupuesto. Advertir que la oferta de 300 m2 en el sur es marginal.

6. Advertencias. Los modelos se estiman sobre precios de oferta, no de cierre, así que tienden a estar por encima del valor de transacción. Los datos corresponden a tres meses de un mercado deprimido por las tensiones sociales y políticas, de modo que las valoraciones deberían recalcularse cuando el sector se reactive. Y el error típico de predicción (162.88 millones en casas, 91.87 en apartamentos) es la incertidumbre real con la que María abre cualquier negociación. Las principales variables que faltan (antigüedad, acabados, zonas comunes, administración) explican buena parte de ese error.