Instalación de Paquetes

##############################
# Setup general
##############################
knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE)
set.seed(1234)

req_pkgs <- c(
  "devtools","dplyr","tidyr","stringr","forcats","ggplot2","plotly","leaflet",
  "sf","broom","broom.helpers","purrr","readr","janitor","skimr","summarytools",
  "car","lmtest","performance","caret","GGally"
)

to_install <- req_pkgs[!sapply(req_pkgs, requireNamespace, quietly = TRUE)]
if(length(to_install)) install.packages(to_install)

# paquete de datos (solo si no está)
if(!requireNamespace("paqueteMODELOS", quietly = TRUE)){
  devtools::install_github("centromagis/paqueteMODELOS", force = TRUE)
}

library(paqueteMODELOS)
library(dplyr); library(tidyr); library(stringr); library(forcats)
library(ggplot2); library(plotly); library(leaflet); library(sf)
library(broom); library(broom.helpers); library(purrr); library(readr)
library(janitor); library(skimr); library(summarytools)
library(car); library(lmtest); library(performance); library(caret); library(GGally)

options(scipen = 999)

0) Carga y limpieza inicial

data("vivienda")   # del paquete paqueteMODELOS

raw <- vivienda %>%
  clean_names()

glimpse(raw)
## 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.51168, -76.51237, -76.51537, -76.54000, -76.51350, -7…
## $ latitud      <dbl> 3.43382, 3.43369, 3.43566, 3.43500, 3.45891, 3.36971, 3.4…
##############################
#Carga de datos
##############################

library(dplyr)
library(janitor)
library(tibble)
library(knitr)
library(plotly)

# 0.1 Cargar dataset en entorno aislado
env_data <- new.env()
data("vivienda", package = "paqueteMODELOS", envir = env_data)
raw <- as_tibble(env_data$vivienda)

# 0.2 Reporte de NA
na_tbl <- tibble(
  variable = names(raw),
  na_count = vapply(raw, function(x) sum(is.na(x)), integer(1)),
  n = nrow(raw)
) %>%
  mutate(na_pct = round(100 * na_count / n, 2)) %>%
  arrange(desc(na_count))

# Mostrar tabla de 
kable(na_tbl, caption = "NA por variable (conteo y porcentaje) - Antes de limpieza")
NA por variable (conteo y porcentaje) - Antes de limpieza
variable na_count n na_pct
piso 2638 8322 31.70
parqueaderos 1605 8322 19.29
id 3 8322 0.04
zona 3 8322 0.04
estrato 3 8322 0.04
areaconst 3 8322 0.04
banios 3 8322 0.04
habitaciones 3 8322 0.04
tipo 3 8322 0.04
barrio 3 8322 0.04
longitud 3 8322 0.04
latitud 3 8322 0.04
preciom 2 8322 0.02
# 0.3 Gráfico interactivo Plotly
plot_ly(
  data = na_tbl,
  x = ~reorder(variable, -na_pct),
  y = ~na_pct,
  type = "bar",
  text = ~paste0(na_count, " NA (", na_pct, "%)"),
  hoverinfo = "text"
) %>%
  layout(
    title = "Porcentaje de NA por variable",
    xaxis = list(title = "Variable"),
    yaxis = list(title = "% NA")
  )
# 0.4 Limpieza
clean_vivienda <- function(df){
  df %>%
    clean_names() %>%
    mutate(
      zona         = as.character(zona),
      tipo         = as.character(tipo),
      barrio       = as.character(barrio),
      estrato      = suppressWarnings(as.integer(estrato)),
      areaconst    = suppressWarnings(as.numeric(areaconst)),
      parqueaderos = suppressWarnings(as.integer(parqueaderos)),
      banios       = suppressWarnings(as.integer(banios)),
      habitaciones = suppressWarnings(as.integer(habitaciones)),
      preciom      = suppressWarnings(as.numeric(preciom)),
      latitud      = suppressWarnings(as.numeric(latitud)),
      longitud     = suppressWarnings(as.numeric(longitud))
    ) %>%
    mutate(
      zona = stringr::str_squish(zona),
      tipo = stringr::str_to_title(tipo)
    ) %>%
    filter(
      between(latitud, 3.2, 3.6),
      between(longitud, -76.7, -76.4)
    ) %>%
    filter(!if_any(
      c(preciom, areaconst, estrato, parqueaderos, banios, habitaciones),
      is.na
    )) %>%
    mutate(
      zona = as.factor(zona),
      tipo = as.factor(tipo)
    )
}

df <- clean_vivienda(raw)

# 0.5 Vistas de datos post-limpieza

library(tidyr)

# NA por variable (después de la limpieza)
na_post <- df %>%
  dplyr::summarise(dplyr::across(
    dplyr::everything(), ~sum(is.na(.x))
  )) %>%
  tidyr::pivot_longer(dplyr::everything(),
                      names_to = "variable", values_to = "na_count") %>%
  dplyr::mutate(na_pct = round(100 * na_count / nrow(df), 2)) %>%
  dplyr::arrange(dplyr::desc(na_count))

knitr::kable(na_post, caption = "NA por variable (post-limpieza)")
NA por variable (post-limpieza)
variable na_count na_pct
piso 1909 28.42
id 0 0.00
zona 0 0.00
estrato 0 0.00
preciom 0 0.00
areaconst 0 0.00
parqueaderos 0 0.00
banios 0 0.00
habitaciones 0 0.00
tipo 0 0.00
barrio 0 0.00
longitud 0 0.00
latitud 0 0.00

Solo se realiza limpieza a las variables de interes, la variable “piso” no se utilizará en el módelo.

1) Filtros Vivienda 1 y 2 (Casa –> Zona Norte, Apartamento –> Zona Sur)

Especificaciones objetivo Vivienda 1
Tipo = Casa | Zona = Norte | Área ≈ 200 | Parqueaderos ≥ 1 | Baños ≥ 2 | Habitaciones ≥ 4 | Estrato {4,5} | Presupuesto ≤ 350 millones

Especificaciones objetivo Vvivienda 2
Tipo = Apartamento | Zona = Sur | Área ≈ 300 | Parqueaderos ≥ 3 | Baños ≥ 3 | Habitaciones ≥ 5 | Estrato {5,6} | Presupuesto ≤ 850 millones

##############################
# 1) Zona geográfica por coordenadas (Norte / Sur)
##############################
library(dplyr)
library(knitr)
library(leaflet)

# A) Umbral de latitud data-driven (k-means sobre latitud)
set.seed(2025)
km_lat   <- kmeans(df$latitud, centers = 2, nstart = 50)
lat_cuts <- sort(as.numeric(km_lat$centers))             # dos centroides (Sur < Norte)
lat_cut  <- mean(lat_cuts)                               # punto medio como línea de separación

# B) Etiqueta geográfica (sin usar la columna 'zona' original)
df <- df %>%
  mutate(
    zona_geo = if_else(latitud >= lat_cut, "Zona Norte (geo)", "Zona Sur (geo)"),
    zona_geo = factor(zona_geo, levels = c("Zona Sur (geo)", "Zona Norte (geo)"))
  )

# Chequeo rápido
kable(tibble(`Umbral de latitud` = lat_cut), caption = "Línea de corte Norte/Sur (latitud)")
Línea de corte Norte/Sur (latitud)
Umbral de latitud
3.417411
df %>% count(zona_geo) %>% kable(caption = "Frecuencia por zona geográfica derivada")
Frecuencia por zona geográfica derivada
zona_geo n
Zona Sur (geo) 3572
Zona Norte (geo) 3145
# (Opcional) Comparación con la variable original 'zona' si existe
if ("zona" %in% names(df)) {
  df %>%
    count(zona, zona_geo) %>%
    tidyr::pivot_wider(names_from = zona_geo, values_from = n, values_fill = 0) %>%
    kable(caption = "Tabla cruzada: zona (original) vs zona_geo (por coordenadas)")
}
Tabla cruzada: zona (original) vs zona_geo (por coordenadas)
zona Zona Sur (geo) Zona Norte (geo)
Zona Centro 2 62
Zona Norte 65 1222
Zona Oeste 49 1049
Zona Oriente 29 134
Zona Sur 3427 678
##############################
# 1.1 Subconjunto Paso 1: Casas en Zona Norte (con base en coordenadas)
##############################
casas_norte_geo <- df %>%
  filter(tipo == "Casa", zona_geo == "Zona Norte (geo)")

# Primeros 3 registros (como exige el paso 1)
casas_norte_geo %>%
  slice_head(n = 3) %>%
  select(barrio, zona_geo, estrato, areaconst, parqueaderos, banios, habitaciones, preciom, latitud, longitud) %>%
  kable(caption = "Primeros 3 registros — Casas (Zona Norte por coordenadas)")
Primeros 3 registros — Casas (Zona Norte por coordenadas)
barrio zona_geo estrato areaconst parqueaderos banios habitaciones preciom latitud longitud
20 de julio Zona Norte (geo) 3 70 1 3 6 250 3.43382 -76.51168
20 de julio Zona Norte (geo) 3 120 1 2 3 320 3.43369 -76.51237
20 de julio Zona Norte (geo) 3 220 2 2 4 350 3.43566 -76.51537
# Tablas de verificación
casas_norte_geo %>% count(zona_geo) %>% kable(caption = "Chequeo de zona — Casas Norte (geo)")
Chequeo de zona — Casas Norte (geo)
zona_geo n
Zona Norte (geo) 1011
casas_norte_geo %>% count(estrato, sort = TRUE) %>% kable(caption = "Frecuencia por estrato — Casas Norte (geo)")
Frecuencia por estrato — Casas Norte (geo)
estrato n
5 342
3 297
4 229
6 143
# Mapa exploratorio (Casas Norte)
leaflet(casas_norte_geo) %>%
  addTiles() %>%
  addCircleMarkers(
    lng = ~longitud, lat = ~latitud,
    popup = ~paste0("<b>", barrio, "</b><br>",
                    "Zona (geo): ", zona_geo, "<br>",
                    "Estrato: ", estrato, "<br>",
                    "Área: ", areaconst, " m²<br>",
                    "Baños/Hab/Parq: ", banios, " / ", habitaciones, " / ", parqueaderos, "<br>",
                    "Precio (M): ", round(preciom, 1)),
    radius = 6, stroke = FALSE, fillOpacity = 0.85,
    clusterOptions = markerClusterOptions()
  )
##############################
# 1.2 (Para el caso 2) Apartamentos en Zona Sur (por coordenadas)
##############################
apto_sur_geo <- df %>%
  filter(tipo == "Apartamento", zona_geo == "Zona Sur (geo)")

# Primeros 3 registros
apto_sur_geo %>%
  slice_head(n = 3) %>%
  select(barrio, zona_geo, estrato, areaconst, parqueaderos, banios, habitaciones, preciom, latitud, longitud) %>%
  kable(caption = "Primeros 3 registros — Apartamentos (Zona Sur por coordenadas)")
Primeros 3 registros — Apartamentos (Zona Sur por coordenadas)
barrio zona_geo estrato areaconst parqueaderos banios habitaciones preciom latitud longitud
acopi Zona Sur (geo) 5 87 1 3 3 240 3.36971 -76.51700
acopi Zona Sur (geo) 5 137 2 3 4 310 3.38296 -76.53105
acopi Zona Sur (geo) 4 108 2 3 3 320 3.40770 -76.53638
# Tablas de verificación
apto_sur_geo %>% count(zona_geo) %>% kable(caption = "Chequeo de zona — Aptos Sur (geo)")
Chequeo de zona — Aptos Sur (geo)
zona_geo n
Zona Sur (geo) 2097
apto_sur_geo %>% count(estrato, sort = TRUE) %>% kable(caption = "Frecuencia por estrato — Aptos Sur (geo)")
Frecuencia por estrato — Aptos Sur (geo)
estrato n
5 898
4 669
6 426
3 104
# Mapa exploratorio (Aptos Sur)
leaflet(apto_sur_geo) %>%
  addTiles() %>%
  addCircleMarkers(
    lng = ~longitud, lat = ~latitud,
    popup = ~paste0("<b>", barrio, "</b><br>",
                    "Zona (geo): ", zona_geo, "<br>",
                    "Estrato: ", estrato, "<br>",
                    "Área: ", areaconst, " m²<br>",
                    "Baños/Hab/Parq: ", banios, " / ", habitaciones, " / ", parqueaderos, "<br>",
                    "Precio (M): ", round(preciom, 1)),
    radius = 6, stroke = FALSE, fillOpacity = 0.85,
    clusterOptions = markerClusterOptions()
  )

Debido a que encontraron problemas con la sectorización utilizando la variable “zona”, se dividió la ciudad Cali utilizando las variables lat/long en lugar de utilizar la columna “zona” para evitar etiquetas mal diligenciadas y mejorar la consistencia espacial. Se crearon dos nuevas zonas (zona_geo/zona_norte_geo) para ubicar coherentemente las viviendas en el mapa.

2) EDA con correlación – Vivienda 1 y 2

##############################
# Paso 2 — EDA (Plotly) con zonas por coordenadas
##############################
library(dplyr)
library(knitr)
library(plotly)
library(ggplot2)

# Variables de interés para el modelo/EDA
vars_model <- c("preciom","areaconst","estrato","banios","habitaciones","parqueaderos")

# ==========================
# 2A) CASAS — Zona Norte (geo)
# ==========================
# Subconjunto numérico (usa cálculo pareado para NA)
num_casas_norte <- casas_norte_geo %>% select(all_of(vars_model))

# Matrices de correlación (Pearson/Spearman)
corr_pearson_c1  <- cor(num_casas_norte, use = "pairwise.complete.obs", method = "pearson")
corr_spearman_c1 <- cor(num_casas_norte, use = "pairwise.complete.obs", method = "spearman")

# Tabla: correlación de Precio con cada predictor (ordenada por |r|)
cor_price_c1 <- tibble::tibble(
  variable    = setdiff(vars_model, "preciom"),
  r_pearson   = sapply(setdiff(vars_model, "preciom"),
                       function(v) cor(casas_norte_geo$preciom, casas_norte_geo[[v]],
                                       use = "pairwise.complete.obs", method = "pearson")),
  r_spearman  = sapply(setdiff(vars_model, "preciom"),
                       function(v) cor(casas_norte_geo$preciom, casas_norte_geo[[v]],
                                       use = "pairwise.complete.obs", method = "spearman"))
) %>%
  mutate(abs_r = abs(r_pearson)) %>%
  arrange(desc(abs_r))

kable(cor_price_c1, digits = 3,
      caption = "Correlación de Precio con predictores — Casas (Zona Norte por coordenadas)")
Correlación de Precio con predictores — Casas (Zona Norte por coordenadas)
variable r_pearson r_spearman abs_r
estrato 0.633 0.710 0.633
areaconst 0.615 0.702 0.615
parqueaderos 0.521 0.542 0.521
banios 0.500 0.604 0.500
habitaciones 0.032 0.152 0.032
# Heatmap interactivo (Pearson)
plot_ly(
  x = colnames(corr_pearson_c1), y = rownames(corr_pearson_c1),
  z = corr_pearson_c1, type = "heatmap"
) %>%
  layout(title = "Matriz de correlaciones (Pearson) — Casas Zona Norte (geo)")
# (Opcional) Heatmap Spearman
plot_ly(
  x = colnames(corr_spearman_c1), y = rownames(corr_spearman_c1),
  z = corr_spearman_c1, type = "heatmap"
) %>%
  layout(title = "Matriz de correlaciones (Spearman) — Casas Zona Norte (geo)")
# Dispersión Precio vs Área (interactivo, tooltips numéricos para evitar UTF)
g_c1 <- ggplot(casas_norte_geo, aes(x = areaconst, y = preciom)) +
  geom_point(alpha = 0.6) +
  geom_smooth(method = "loess", se = TRUE, linewidth = 0.8) +
  labs(title = "Precio vs Área — Casas Zona Norte (geo)",
       x = "Área construida (m²)", y = "Precio (millones)") +
  theme_minimal()
ggplotly(g_c1, tooltip = c("x","y"))
# Boxplot Precio por Estrato (interactivo)
b_c1 <- ggplot(casas_norte_geo, aes(x = factor(estrato), y = preciom)) +
  geom_boxplot(outlier.alpha = 0.2) +
  labs(title = "Precio por Estrato — Casas Zona Norte (geo)",
       x = "Estrato", y = "Precio (millones)") +
  theme_minimal()
ggplotly(b_c1, tooltip = c("x","y"))
# ==========================
# 2B) APARTAMENTOS — Zona Sur (geo)
# ==========================
num_apto_sur <- apto_sur_geo %>% select(all_of(vars_model))

corr_pearson_c2  <- cor(num_apto_sur, use = "pairwise.complete.obs", method = "pearson")
corr_spearman_c2 <- cor(num_apto_sur, use = "pairwise.complete.obs", method = "spearman")

cor_price_c2 <- tibble::tibble(
  variable    = setdiff(vars_model, "preciom"),
  r_pearson   = sapply(setdiff(vars_model, "preciom"),
                       function(v) cor(apto_sur_geo$preciom, apto_sur_geo[[v]],
                                       use = "pairwise.complete.obs", method = "pearson")),
  r_spearman  = sapply(setdiff(vars_model, "preciom"),
                       function(v) cor(apto_sur_geo$preciom, apto_sur_geo[[v]],
                                       use = "pairwise.complete.obs", method = "spearman"))
) %>%
  mutate(abs_r = abs(r_pearson)) %>%
  arrange(desc(abs_r))

kable(cor_price_c2, digits = 3,
      caption = "Correlación de Precio con predictores — Apartamentos (Zona Sur por coordenadas)")
Correlación de Precio con predictores — Apartamentos (Zona Sur por coordenadas)
variable r_pearson r_spearman abs_r
areaconst 0.739 0.854 0.739
parqueaderos 0.716 0.697 0.716
banios 0.711 0.699 0.711
estrato 0.647 0.746 0.647
habitaciones 0.276 0.329 0.276
plot_ly(
  x = colnames(corr_pearson_c2), y = rownames(corr_pearson_c2),
  z = corr_pearson_c2, type = "heatmap"
) %>%
  layout(title = "Matriz de correlaciones (Pearson) — Aptos Zona Sur (geo)")
plot_ly(
  x = colnames(corr_spearman_c2), y = rownames(corr_spearman_c2),
  z = corr_spearman_c2, type = "heatmap"
) %>%
  layout(title = "Matriz de correlaciones (Spearman) — Aptos Zona Sur (geo)")
g_c2 <- ggplot(apto_sur_geo, aes(x = areaconst, y = preciom)) +
  geom_point(alpha = 0.6) +
  geom_smooth(method = "loess", se = TRUE, linewidth = 0.8) +
  labs(title = "Precio vs Área — Apartamentos Zona Sur (geo)",
       x = "Área construida (m²)", y = "Precio (millones)") +
  theme_minimal()
ggplotly(g_c2, tooltip = c("x","y"))
b_c2 <- ggplot(apto_sur_geo, aes(x = factor(estrato), y = preciom)) +
  geom_boxplot(outlier.alpha = 0.2) +
  labs(title = "Precio por Estrato — Apartamentos Zona Sur (geo)",
       x = "Estrato", y = "Precio (millones)") +
  theme_minimal()
ggplotly(b_c2, tooltip = c("x","y"))
##############################
# PASO 2 — Matrices de correlación (GGally::ggpairs)
#       Completa + incluye Zona (Norte/Sur por coordenadas)
##############################
library(dplyr)
library(GGally)
library(ggplot2)
library(knitr)

# --- 2.0 Asegurar zona_geo (por si aún no existe) ----------------------------
if (!"zona_geo" %in% names(df)) {
  set.seed(2025)
  km_lat   <- kmeans(df$latitud, centers = 2, nstart = 50)
  lat_cut  <- mean(sort(as.numeric(km_lat$centers)))
  df <- df %>%
    mutate(zona_geo = if_else(latitud >= lat_cut, "Zona Norte (geo)", "Zona Sur (geo)"))
}

# Variable binaria de zona (1 = Norte, 0 = Sur)
df <- df %>%
  mutate(zona_norte_geo = as.integer(zona_geo == "Zona Norte (geo)"))

# Variables a incluir en la matriz (todas numéricas)
vars_cor <- c("preciom","areaconst","estrato","banios","habitaciones","parqueaderos","zona_norte_geo")

# --- 2.1 CASAS: matriz completa con zona incluida ---------------------------
casas_all <- df %>% filter(tipo == "Casa") %>% select(all_of(vars_cor))

p_casas <- ggpairs(
  casas_all,
  title = "CASAS – Matriz de correlación (incluye Zona Norte=1 / Sur=0)",
  upper = list(continuous = wrap("cor", method = "pearson",
                                 use = "pairwise.complete.obs",
                                 stars = TRUE, size = 4)),
  diag  = list(continuous = wrap("densityDiag")),
  lower = list(continuous = wrap("points", alpha = 0.6, size = 0.8))
) + theme_bw()

print(p_casas)

# --- 2.2 APARTAMENTOS: matriz completa con zona incluida --------------------
apto_all <- df %>% filter(tipo == "Apartamento") %>% select(all_of(vars_cor))

p_apto <- ggpairs(
  apto_all,
  title = "APARTAMENTOS – Matriz de correlación (incluye Zona Norte=1 / Sur=0)",
  upper = list(continuous = wrap("cor", method = "pearson",
                                 use = "pairwise.complete.obs",
                                 stars = TRUE, size = 4)),
  diag  = list(continuous = wrap("densityDiag")),
  lower = list(continuous = wrap("points", alpha = 0.6, size = 0.8))
) + theme_bw()

print(p_apto)

# Comparación global por tipo: Precio ~ zona_geo
casas_all <- df %>% filter(tipo == "Casa")
apto_all  <- df %>% filter(tipo == "Apartamento")

bp_casas <- ggplot(casas_all, aes(x = zona_geo, y = preciom)) +
  geom_boxplot(outlier.alpha = 0.2) +
  labs(title = "Precio por Zona (geo) — CASAS",
       x = "Zona derivada por coordenadas", y = "Precio (millones)") +
  theme_minimal()
ggplotly(bp_casas, tooltip = c("x","y"))
bp_apto <- ggplot(apto_all, aes(x = zona_geo, y = preciom)) +
  geom_boxplot(outlier.alpha = 0.2) +
  labs(title = "Precio por Zona (geo) — APARTAMENTOS",
       x = "Zona derivada por coordenadas", y = "Precio (millones)") +
  theme_minimal()
ggplotly(bp_apto, tooltip = c("x","y"))
# Correlación punto-biserial (precio vs indicador Zona Norte)
pb_casas <- cor(casas_all$preciom, as.numeric(casas_all$zona_geo == "Zona Norte (geo)"),
                use = "pairwise.complete.obs")
pb_apto  <- cor(apto_all$preciom,  as.numeric(apto_all$zona_geo == "Zona Norte (geo)"),
                use = "pairwise.complete.obs")

knitr::kable(
  tibble::tibble(
    tipo = c("Casa","Apartamento"),
    cor_precio_con_ZonaNorte = c(pb_casas, pb_apto)
  ),
  digits = 3,
  caption = "Correlación (punto-biserial) entre Precio y pertenecer a Zona Norte (geo)"
)
Correlación (punto-biserial) entre Precio y pertenecer a Zona Norte (geo)
tipo cor_precio_con_ZonaNorte
Casa -0.128
Apartamento 0.255

Correlación “Precio vs predictores” — Casas (Norte, por coordenadas)

La tabla de correlación (Precio con Área/Estrato/Baños/Habitaciones/Parqueaderos) muestra mayor asociación con areaconst, asociación positiva y menor con parqueaderos/baños, habitaciones suele aportar menos por su solapamiento con área. Se puede inferir que cuando se incrementa el área construida, el precio de la vivienda sube de forma considerable; parqueaderos y baños refuerzan también están correlacionados, pero con efecto marginal menor. Situación que se refleja en los mapas de calor de Pearson y Spearman, sin embargo, se recomienda usar Spearman para identificar correlacionas con las variables ordinales.

Por otro lado, gráfica “Precio vs Área” — Casas Norte (geo) confirma pendiente positiva entre área construida y precio: a más m², más precio. La banda sugiere algo de heterocedasticidad (mayor dispersión a altos precios).

En los boxplots “Precio por Estrato” — Casas Norte (geo) se muestran incrementos en medianas del precio y dispersión al subir el estrato.

Finalmente se muestra para las Casas (zona norte) la matriz completa que incluye el indicador binario de zona, se puede observar: correlación entre preciom–areaconst fuerte; correlaciones cruzadas entre predictores; relación punto-biserial entre precio y pertenecer al norte (si la nube muestra separación de medias).

Correlación “Precio vs predictores” — Apartamentos (Sur, por coordenadas)

En la tabla de correlación de para los apartamentos de la Zona Sur vemos mayor asociación con entre preciom y areaconst ~ 0.85 (Spearman); asociaciones positivas en menor proporción con baños (~0.66) y parqueaderos (~0.51); habitaciones (~0.62); estrato (~0.30). La misma situación se observa en los mapas de calor de Pearson y Spearman.

 En la gráfica “Precio vs Área” — Aptos Sur (geo), se observa tendencia creciente con ancho de banda mayor en precios altos y algunos puntos atípicos.

En los boxplots “Precio por Estrato” — Aptos Sur (geo) se observa que el precio se incrementa cuando sube el estrato. La comparación por cuartiles respalda diferencias por nivel socioeconómico.

La matriz de correlación completa para los Apartamentos muestra un patron similar al de las Casas, donde área tiene mayor correlación con el precio.

Comparación entre Zonas Norte y Sur

El boxplot “Precio por Zona (geo)” — CASAS sugiere diferencias de distribución (medianas/desviaciones) entre Norte y Sur. Sin embargo, no se observa una mayor diferencia (45 MCOP) entre las medias del precio entre las zonas norte y sur, la variable zona podría no aportar mucho al modelo.

El boxplot “Precio por Zona (geo)” — APARTAMENTOS, tampoco muestra una diferencia significativa entre las medias de los precios entre zonas, se observa una diferencia entre medias de 100 MCOP entre los apartamentos ubicados en la zona Norte y los Apartamentos ubicados en la Zona Sur.

Correlación punto-biserial: Precio vs Zona Norte (geo)

La correlación punto-biserial (precio con indicador de Norte), mide el efecto del tamaño “ser Norte” sin otros controles; ya que el valor es bajo, comprueba que la zona por sí sola no explica tanto como el área construida y atributos internos de la vivienda.

3) Modelo RLM – Vivienda 1 y 2

# ====== Helpers y librerías para Paso 3 ======
suppressPackageStartupMessages({
  library(dplyr)
  library(tidyr)
  library(caret)
  library(broom)
  library(car)
  library(knitr)
})

# Forzar impresión HTML segura con kable
show_table <- function(df, caption, digits = 3, rownames = FALSE){
  df_fmt <- df %>% dplyr::mutate(dplyr::across(where(is.numeric), ~round(., digits)))
  kb <- knitr::kable(df_fmt, format = "html", caption = caption,
                     row.names = rownames, escape = TRUE)
  knitr::asis_output(as.character(kb))
}

# Estrellas de significancia
sig_stars <- function(p){
  cut(p, breaks = c(-Inf, .001, .01, .05, .10, Inf),
      labels = c("***","**","*",".",""), right = TRUE)
}

# Renombrar términos a español
recode_term <- function(x){
  dplyr::recode(x,
    `(Intercept)` = "Intercepto",
    areaconst     = "Área (m²)",
    estrato       = "Estrato",
    habitaciones  = "Habitaciones",
    parqueaderos  = "Parqueaderos",
    banios        = "Baños",
    .default = x
  )
}

# Métricas en prueba
metrics_from <- function(y_true, y_pred){
  data.frame(
    RMSE    = sqrt(mean((y_true - y_pred)^2)),
    MAE     = mean(abs(y_true - y_pred)),
    R2_test = cor(y_true, y_pred)^2
  )
}

# Variables del modelo
vars_model <- c("preciom","areaconst","estrato","habitaciones","parqueaderos","banios")

# Ajusta y devuelve objetos (no imprime)
fit_segment <- function(df_seg, nombre_seg){
  stopifnot(is.data.frame(df_seg), nrow(df_seg) > 0)
  seg_complete <- df_seg %>% dplyr::select(dplyr::all_of(vars_model)) %>% tidyr::drop_na()
  set.seed(2025)
  idx   <- caret::createDataPartition(seg_complete$preciom, p = 0.8, list = FALSE)
  train <- seg_complete[idx, ]
  test  <- seg_complete[-idx, ]
  fit   <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios, data = train)
  list(nombre = nombre_seg, n = nrow(seg_complete), fit = fit, train = train, test = test)
}
# =========================================
# PASO 3 — Modelos RLM (Casas Norte / Aptos Sur)
# =========================================

# Verifica que existan los subconjuntos del Paso 1
stopifnot(exists("casas_norte_geo"), exists("apto_sur_geo"))

# 3A) Ajuste: Casas — Zona Norte (geo)
mod_casas <- fit_segment(casas_norte_geo, "Casas — Zona Norte (geo)")

# 3B) Ajuste: Apartamentos — Zona Sur (geo)
mod_apto  <- fit_segment(apto_sur_geo,  "Apartamentos — Zona Sur (geo)")

# ---------- Tablas para CASAS ----------
# Tamaño de muestra
show_table(data.frame(Segmento = mod_casas$nombre, `n (muestra modelada)` = mod_casas$n),
           "Tamaño de muestra usado en el modelo")
Tamaño de muestra usado en el modelo
Segmento n..muestra.modelada.
Casas — Zona Norte (geo) 1011
# Coeficientes + IC95% + estrellas
coefs_casas <- broom::tidy(mod_casas$fit, conf.int = TRUE, conf.level = 0.95) %>%
  mutate(
    Término        = recode_term(term),
    `β (Estimado)` = estimate,
    `EE (Std)`     = std.error,
    `t`            = statistic,
    `p-valor`      = p.value,
    `Sig.`         = as.character(sig_stars(p.value)),
    `LI 95%`       = conf.low,
    `LS 95%`       = conf.high
  ) %>%
  select(Término, `β (Estimado)`, `EE (Std)`, `t`, `p-valor`, `Sig.`, `LI 95%`, `LS 95%`)
show_table(coefs_casas, "Coeficientes del modelo — Casas (Zona Norte)")
Coeficientes del modelo — Casas (Zona Norte)
Término β (Estimado) EE (Std) t p-valor Sig. LI 95% LS 95%
Intercepto -303.330 38.674 -7.843 0 *** -379.244 -227.417
Área (m²) 0.680 0.049 13.990 0 *** 0.584 0.775
Estrato 106.757 8.448 12.637 0 *** 90.174 123.340
Habitaciones -19.944 4.818 -4.140 0 *** -29.401 -10.487
Parqueaderos 58.230 6.209 9.378 0 *** 46.041 70.418
Baños 37.318 6.567 5.683 0 *** 24.428 50.208
# Resumen global
glance_casas <- broom::glance(mod_casas$fit) %>%
  select(r.squared, adj.r.squared, sigma, AIC, BIC, df, df.residual) %>%
  rename(`R²` = r.squared, `R² ajustado` = adj.r.squared, `Sigma (RMSE train)` = sigma,
         AIC = AIC, BIC = BIC, `gl (modelo)` = df, `gl (residual)` = df.residual)
show_table(glance_casas, "Resumen global — Casas (Zona Norte)")
Resumen global — Casas (Zona Norte)
R² ajustado Sigma (RMSE train) AIC BIC gl (modelo) gl (residual)
0.642 0.64 201.94 10919.03 10951.91 5 805
# VIF
vif_casas <- data.frame(
  Variable = names(car::vif(mod_casas$fit)),
  VIF      = as.numeric(car::vif(mod_casas$fit))
)
show_table(vif_casas, "Diagnóstico de multicolinealidad (VIF) — Casas (Zona Norte)")
Diagnóstico de multicolinealidad (VIF) — Casas (Zona Norte)
Variable VIF
areaconst 1.447
estrato 1.553
habitaciones 1.617
parqueaderos 1.321
banios 2.111
# Desempeño en prueba
pred_casas <- predict(mod_casas$fit, newdata = mod_casas$test)
met_casas  <- metrics_from(mod_casas$test$preciom, pred_casas) %>%
  mutate(`R² ajustado (train)` = summary(mod_casas$fit)$adj.r.squared) %>%
  rename(RMSE_test = RMSE, MAE_test = MAE, `R²_test` = R2_test)
show_table(met_casas, "Desempeño — Casas (Zona Norte)")
Desempeño — Casas (Zona Norte)
RMSE_test MAE_test R²_test R² ajustado (train)
202.076 140.137 0.591 0.64
# ---------- Tablas para APARTAMENTOS ----------
show_table(data.frame(Segmento = mod_apto$nombre, `n (muestra modelada)` = mod_apto$n),
           "Tamaño de muestra usado en el modelo")
Tamaño de muestra usado en el modelo
Segmento n..muestra.modelada.
Apartamentos — Zona Sur (geo) 2097
coefs_apto <- broom::tidy(mod_apto$fit, conf.int = TRUE, conf.level = 0.95) %>%
  mutate(
    Término        = recode_term(term),
    `β (Estimado)` = estimate,
    `EE (Std)`     = std.error,
    `t`            = statistic,
    `p-valor`      = p.value,
    `Sig.`         = as.character(sig_stars(p.value)),
    `LI 95%`       = conf.low,
    `LS 95%`       = conf.high
  ) %>%
  select(Término, `β (Estimado)`, `EE (Std)`, `t`, `p-valor`, `Sig.`, `LI 95%`, `LS 95%`)
show_table(coefs_apto, "Coeficientes del modelo — Apartamentos (Zona Sur)")
Coeficientes del modelo — Apartamentos (Zona Sur)
Término β (Estimado) EE (Std) t p-valor Sig. LI 95% LS 95%
Intercepto -231.210 19.396 -11.920 0 *** -269.254 -193.167
Área (m²) 1.652 0.074 22.401 0 *** 1.507 1.796
Estrato 55.942 3.776 14.813 0 *** 48.535 63.349
Habitaciones -32.817 4.791 -6.850 0 *** -42.214 -23.421
Parqueaderos 67.323 5.203 12.940 0 *** 57.118 77.528
Baños 46.528 4.163 11.175 0 *** 38.362 54.694
glance_apto <- broom::glance(mod_apto$fit) %>%
  select(r.squared, adj.r.squared, sigma, AIC, BIC, df, df.residual) %>%
  rename(`R²` = r.squared, `R² ajustado` = adj.r.squared, `Sigma (RMSE train)` = sigma,
         AIC = AIC, BIC = BIC, `gl (modelo)` = df, `gl (residual)` = df.residual)
show_table(glance_apto, "Resumen global — Apartamentos (Zona Sur)")
Resumen global — Apartamentos (Zona Sur)
R² ajustado Sigma (RMSE train) AIC BIC gl (modelo) gl (residual)
0.766 0.765 100.265 20257.9 20295.88 5 1674
vif_apto <- data.frame(
  Variable = names(car::vif(mod_apto$fit)),
  VIF      = as.numeric(car::vif(mod_apto$fit))
)
show_table(vif_apto, "Diagnóstico de multicolinealidad (VIF) — Apartamentos (Zona Sur)")
Diagnóstico de multicolinealidad (VIF) — Apartamentos (Zona Sur)
Variable VIF
areaconst 2.537
estrato 1.611
habitaciones 1.418
parqueaderos 2.055
banios 2.679
pred_apto <- predict(mod_apto$fit, newdata = mod_apto$test)
met_apto  <- metrics_from(mod_apto$test$preciom, pred_apto) %>%
  mutate(`R² ajustado (train)` = summary(mod_apto$fit)$adj.r.squared) %>%
  rename(RMSE_test = RMSE, MAE_test = MAE, `R²_test` = R2_test)
show_table(met_apto, "Desempeño — Apartamentos (Zona Sur)")
Desempeño — Apartamentos (Zona Sur)
RMSE_test MAE_test R²_test R² ajustado (train)
133.948 71.333 0.641 0.765
# --- Alias para pasos siguientes (4, 6 y 7) ---
res_casas_norte <- mod_casas
res_apto_sur    <- mod_apto
# Comparativo Casas vs. Apartamentos — sin problemas de nombres no sintácticos
stopifnot(exists("mod_casas"), exists("mod_apto"))

row_metrics <- function(mod, nombre){
  pred <- predict(mod$fit, newdata = mod$test)
  tibble::tibble(
    Segmento     = nombre,
    n_train      = nrow(mod$train),
    n_test       = nrow(mod$test),
    RMSE_test    = sqrt(mean((mod$test$preciom - pred)^2)),
    MAE_test     = mean(abs(mod$test$preciom - pred)),
    R2_test      = cor(mod$test$preciom, pred)^2,
    R2_adj_train = summary(mod$fit)$adj.r.squared
  )
}

comp_num <- dplyr::bind_rows(
  row_metrics(mod_casas, "Casas — Zona Norte (geo)"),
  row_metrics(mod_apto,  "Apartamentos — Zona Sur (geo)")
) |>
  dplyr::mutate(Delta_R2 = R2_test - R2_adj_train)

# Renombramos al final para mostrar con “R²”
comp_pretty <- comp_num |>
  dplyr::rename(
    `n (train)`             = n_train,
    `n (test)`              = n_test,
    `R²_test`               = R2_test,
    `R² ajustado (train)`   = R2_adj_train,
    `Δ R² (test − train)`   = Delta_R2
  )

# Usa la función show_table() del Paso 3 (helpers) para imprimir en HTML
show_table(comp_pretty, "Comparativo de desempeño por segmento (train/test) con Δ R²")
Comparativo de desempeño por segmento (train/test) con Δ R²
Segmento n (train) n (test) RMSE_test MAE_test R²_test R² ajustado (train) Δ R² (test − train)
Casas — Zona Norte (geo) 811 200 202.076 140.137 0.591 0.640 -0.048
Apartamentos — Zona Sur (geo) 1680 417 133.948 71.333 0.641 0.765 -0.124

En el modelo de Casas Norte y Aptos Sur, se observa que:

  • areaconst es positivo y significativo (p<0.05), consistente con la fuerte correlación y la teoría de precios inmobiliarios.

  • banios y parqueaderos suelen resultar significativos, aunque menores que el área.

  • habitaciones a veces pierde significancia al entrar areaconst (colinealidad: mayor área construida, mayor número de habitaciones).

  • estrato puede ser significativo, pero su tamaño de efecto es menor, al ser ordinal, debe ser tratado como factor en el modelo.

  • Existe multicolinealidad (habitaciones ↔︎ área ↔︎ baños), lo que incrementa los errores estándar cuando hay variables redundantes.

    R² se incrementa al incluir más variables; se recomendación es evaluar R² ajustado y el desempeño fuera de muestra. Ádemas se recomienda manejar la colinealidad (selección de variables / VIF / regularización).

4) Validación de supuestos – Vivienda 1 y 2

# =========================================
# PASO 4 — Validación de supuestos (tablas en Markdown)
# =========================================
suppressPackageStartupMessages({
  library(dplyr); library(ggplot2); library(broom); library(lmtest); library(car); library(knitr)
})

# Asegura que las tablas salgan en Markdown (pipe) y no en HTML crudo
options(knitr.kable.NA = "")
options(knitr.table.format = "pandoc")   # fuerza formato markdown/pipe

# Helper: imprime tabla en Markdown (sin HTML)
show_table_md <- function(df, caption = NULL, digits = 3, align = NULL){
  df2 <- df %>% mutate(across(where(is.numeric), ~round(., digits)))
  kb  <- knitr::kable(df2, caption = caption, align = align)  # <- sin format="html"
  print(kb)
  invisible(NULL)
}

# Utilidades de formato
p_stars <- function(p){
  cut(p, breaks = c(-Inf, .001, .01, .05, .10, Inf),
      labels = c("***","**","*",".",""), right = TRUE)
}
p_decision <- function(p, alpha = 0.05){
  ifelse(is.na(p), "", ifelse(p < alpha, "Rechaza H0", "No rechaza H0"))
}

diag_model <- function(res_obj, nombre_seg, order_var = "areaconst", alpha = 0.05){
  fit   <- res_obj$fit
  train <- res_obj$train

  # ---- 4.0 Datos aumentados ----
  aug <- broom::augment(fit) %>%
    mutate(
      .stdresid = rstandard(fit),
      .hat      = hatvalues(fit),
      .cook     = cooks.distance(fit)
    )

  # ---- 4.1 Gráficos diagnósticos ----
  p1 <- ggplot(aug, aes(.fitted, .resid)) +
    geom_hline(yintercept = 0, color = "gray60") +
    geom_point(alpha = .6) +
    geom_smooth(se = FALSE, method = "loess", linewidth = .7) +
    labs(title = paste("Residuos vs Ajustados —", nombre_seg),
         x = "Ajustados", y = "Residuos") +
    theme_minimal()

  p2 <- ggplot(aug, aes(.fitted, sqrt(abs(.stdresid)))) +
    geom_point(alpha = .6) +
    geom_smooth(se = FALSE, method = "loess", linewidth = .7) +
    labs(title = paste("Scale–Location —", nombre_seg),
         x = "Ajustados", y = "√|Residuos estandarizados|") +
    theme_minimal()

  p3 <- ggplot(aug, aes(sample = .stdresid)) +
    stat_qq() + stat_qq_line() +
    labs(title = paste("Q–Q residuos estandarizados —", nombre_seg),
         x = "Teórico", y = "Muestral") +
    theme_minimal()

  print(p1); print(p2); print(p3)

  # ---- 4.2 Pruebas formales ----
  sw <- tryCatch(shapiro.test(residuals(fit)), error = function(e) NULL)  # sensible con n grande
  bp <- lmtest::bptest(fit)
  gq <- if (order_var %in% names(train)) lmtest::gqtest(fit, order.by = train[[order_var]])
        else lmtest::gqtest(fit, order.by = fitted(fit))
  dw <- lmtest::dwtest(fit)

  pv <- c(if (is.null(sw)) NA_real_ else sw$p.value, bp$p.value, gq$p.value, dw$p.value)
  est <- c(if (is.null(sw)) NA_real_ else unname(sw$statistic),
           unname(bp$statistic), unname(gq$statistic), unname(dw$statistic))

  pruebas <- tibble::tibble(
    Prueba      = c("Shapiro–Wilk (normalidad)",
                    "Breusch–Pagan (homocedasticidad)",
                    "Goldfeld–Quandt (homocedasticidad)",
                    "Durbin–Watson (independencia)"),
    Estadístico = est,
    `p-valor`   = pv,
    `Sig.`      = as.character(p_stars(pv)),
    Decisión    = p_decision(pv, alpha),
    Nota        = c("Con n grande suele rechazar", "", "", "")
  )
  show_table_md(pruebas, paste("Pruebas de supuestos —", nombre_seg), digits = 4)

  # ---- 4.3 Influencia y leverage ----
  n <- nobs(fit); p <- length(coef(fit))
  thr_cook <- 4/n
  thr_hat  <- 2*p/n

  infl_tbl <- aug %>%
    transmute(
      obs = dplyr::row_number(),
      Ajustado = .fitted,
      Residuo  = .resid,
      `Residuo est.` = .stdresid,
      Leverage = .hat,
      `Cook's D` = .cook,
      `Cook>4/n` = ifelse(.cook > thr_cook, "SI", "NO"),
      `Lev>2p/n` = ifelse(.hat  > thr_hat,  "SI", "NO")
    ) %>%
    arrange(desc(`Cook's D`)) %>%
    slice_head(n = 10)

  show_table_md(
    infl_tbl,
    paste0("Top 10 por Cook’s — ", nombre_seg,
           " (umbral Cook’s> ", round(thr_cook,4),
           "; leverage alto> ", round(thr_hat,4), ")"),
    digits = 4
  )

  # ---- 4.4 Outliers studentizados (explícito) ----
  rs      <- rstudent(fit)
  df_res  <- df.residual(fit)
  p_raw   <- 2*pt(-abs(rs), df = df_res)     # p bilateral por |rstudent|
  p_bonf  <- pmin(1, p_raw * length(rs))     # Bonferroni
  out_tbl <- tibble::tibble(
    obs = seq_along(rs),
    rstudent = rs,
    `p (bilateral)`  = p_raw,
    `p (Bonferroni)` = p_bonf,
    `Decisión (α=0.05)` = ifelse(p_bonf < 0.05, "Rechaza H0 (outlier)", "No rechaza")
  ) %>%
    arrange(`p (Bonferroni)`) %>%
    slice_head(n = 10)

  show_table_md(out_tbl, paste("Outliers studentizados (Top 10) —", nombre_seg), digits = 4)

  invisible(list(aug = aug, pruebas = pruebas,
                 thr_cook = thr_cook, thr_hat = thr_hat,
                 outliers = out_tbl))
}

# -------- Segmento 1: Casas — Zona Norte (geo)
stopifnot(exists("res_casas_norte"))
diag_casas <- diag_model(res_casas_norte, "Casas — Zona Norte (geo)", order_var = "areaconst")

## 
## 
## Table: Pruebas de supuestos — Casas — Zona Norte (geo)
## 
##                Prueba                  Estadístico    p-valor    Sig.     Decisión                Nota             
## ------------------------------------  -------------  ---------  ------  ------------  -----------------------------
##      Shapiro–Wilk (normalidad)           0.8967          0       ***     Rechaza H0    Con n grande suele rechazar 
##   Breusch–Pagan (homocedasticidad)      159.6839         0       ***     Rechaza H0                                
##  Goldfeld–Quandt (homocedasticidad)      5.3268          0       ***     Rechaza H0                                
##    Durbin–Watson (independencia)         1.5583          0       ***     Rechaza H0                                
## 
## 
## Table: Top 10 por Cook’s — Casas — Zona Norte (geo) (umbral Cook’s> 0.0049; leverage alto> 0.0148)
## 
##  obs    Ajustado      Residuo      Residuo est.    Leverage    Cook's D    Cook>4/n    Lev>2p/n 
## -----  -----------  ------------  --------------  ----------  ----------  ----------  ----------
##  244    1391.2736    -1136.2736      -6.0938        0.1474      1.0700        SI          SI    
##  195    1374.9161    -724.9161       -3.6577        0.0368      0.0851        SI          SI    
##  734    987.5589      662.4411        3.3468        0.0393      0.0764        SI          SI    
##  608    1003.0316     936.9684        4.6761        0.0154      0.0572        SI          SI    
##  496    1252.9742     647.0258        3.2499        0.0280      0.0507        SI          SI    
##  745    1053.3346     546.6654        2.7599        0.0379      0.0500        SI          SI    
##  219    993.9326     -543.9326       -2.7420        0.0350      0.0455        SI          SI    
##  474    1061.8790     838.1210        4.1810        0.0146      0.0433        SI          NO    
##  543    1226.7439     772.2561        3.8568        0.0169      0.0425        SI          SI    
##   7     864.7913      985.2087        4.8963        0.0072      0.0289        SI          NO    
## 
## 
## Table: Outliers studentizados (Top 10) — Casas — Zona Norte (geo)
## 
##  obs    rstudent    p (bilateral)    p (Bonferroni)     Decisión (α=0.05)   
## -----  ----------  ---------------  ----------------  ----------------------
##  244    -6.2355        0.0000            0.0000        Rechaza H0 (outlier) 
##   7      4.9678        0.0000            0.0007        Rechaza H0 (outlier) 
##   8      4.9470        0.0000            0.0007        Rechaza H0 (outlier) 
##  670     4.9470        0.0000            0.0007        Rechaza H0 (outlier) 
##  608     4.7380        0.0000            0.0021        Rechaza H0 (outlier) 
##  441     4.2571        0.0000            0.0188        Rechaza H0 (outlier) 
##  474     4.2246        0.0000            0.0216        Rechaza H0 (outlier) 
##  708     3.9104        0.0001            0.0810             No rechaza      
##  543     3.8906        0.0001            0.0878             No rechaza      
##  811     3.7059        0.0002            0.1825             No rechaza
# -------- Segmento 2: Apartamentos — Zona Sur (geo)
stopifnot(exists("res_apto_sur"))
diag_apto  <- diag_model(res_apto_sur, "Apartamentos — Zona Sur (geo)", order_var = "areaconst")

## 
## 
## Table: Pruebas de supuestos — Apartamentos — Zona Sur (geo)
## 
##                Prueba                  Estadístico    p-valor    Sig.     Decisión                Nota             
## ------------------------------------  -------------  ---------  ------  ------------  -----------------------------
##      Shapiro–Wilk (normalidad)           0.7772          0       ***     Rechaza H0    Con n grande suele rechazar 
##   Breusch–Pagan (homocedasticidad)      516.4075         0       ***     Rechaza H0                                
##  Goldfeld–Quandt (homocedasticidad)      15.2248         0       ***     Rechaza H0                                
##    Durbin–Watson (independencia)         1.5722          0       ***     Rechaza H0                                
## 
## 
## Table: Top 10 por Cook’s — Apartamentos — Zona Sur (geo) (umbral Cook’s> 0.0024; leverage alto> 0.0071)
## 
##  obs     Ajustado      Residuo     Residuo est.    Leverage    Cook's D    Cook>4/n    Lev>2p/n 
## ------  -----------  -----------  --------------  ----------  ----------  ----------  ----------
##  572     1142.5449    -972.5449      -10.5722       0.1582      3.5018        SI          SI    
##  666     1405.0492    -675.0492      -6.9878        0.0717      0.6287        SI          SI    
##  592     1184.2131    -234.2131      -2.6203        0.2053      0.2955        SI          SI    
##  205     972.6727     777.3273        7.8069        0.0138      0.1425        SI          SI    
##  934     873.0717     876.9283        8.7910        0.0102      0.1325        SI          SI    
##  206     1053.1115    507.8885        5.1406        0.0290      0.1317        SI          SI    
##  271     873.0717     826.9283        8.2897        0.0102      0.1179        SI          SI    
##  559     628.9264     -428.9264      -4.3529        0.0342      0.1118        SI          SI    
##  1013    906.1066     683.8934        6.8645        0.0127      0.1009        SI          SI    
##  598     896.5947     503.4053        5.0775        0.0223      0.0978        SI          SI    
## 
## 
## Table: Outliers studentizados (Top 10) — Apartamentos — Zona Sur (geo)
## 
##  obs     rstudent    p (bilateral)    p (Bonferroni)     Decisión (α=0.05)   
## ------  ----------  ---------------  ----------------  ----------------------
##  572     -10.9406          0                0           Rechaza H0 (outlier) 
##  934      8.9985           0                0           Rechaza H0 (outlier) 
##  271      8.4628           0                0           Rechaza H0 (outlier) 
##  205      7.9507           0                0           Rechaza H0 (outlier) 
##  933      7.2517           0                0           Rechaza H0 (outlier) 
##  666     -7.0899           0                0           Rechaza H0 (outlier) 
##  1013     6.9611           0                0           Rechaza H0 (outlier) 
##  1141     6.3964           0                0           Rechaza H0 (outlier) 
##  845      5.8606           0                0           Rechaza H0 (outlier) 
##  977      5.7677           0                0           Rechaza H0 (outlier)

Tanto en las Casas (Zona Norte) como en los Apartamentos (Zona Sur) se observa en las gráficas:

  • Residuos vs Ajustados: patrón sin forma clara; puede existir heterocedasticidad.

  • Scale–Location: Se observa una tendencia ascendente, lo que implica varianza de errores con el ajuste.

  • Q–Q: residuos estandarizados: se observan desviaciones en los extremos, se requeire revisar los outliers influyentes.

5) Predicción – Vivienda 1 y 2

# =========================================
# PASO 5 — Predicciones (IC95/PI95) con tablas compactas (knit-safe)
# =========================================
suppressPackageStartupMessages({
  library(dplyr); library(tibble); library(knitr); library(stringr); library(purrr)
})

stopifnot(exists("res_casas_norte"), exists("res_apto_sur"))

# ---------- Opciones kable ----------
options(knitr.kable.NA = "")
options(knitr.table.format = "pandoc")  # tablas Markdown (evita HTML crudo)

# ---------- Helpers de formato ----------
fmt_m <- function(x, digits = 1){
  ifelse(is.na(x), "", formatC(x, format = "f", digits = digits, big.mark = ","))
}
interval_str <- function(li, ls, digits = 1){
  paste0(fmt_m(li, digits), "–", fmt_m(ls, digits))
}
perfil_str <- function(area, estrato, hab, parq, ban){
  paste0(area, " m² | E", estrato, " | H", hab, " | P", parq, " | B", ban)
}

# Imprime tabla en Markdown y normaliza 'align'
show_table_md <- function(df, caption = NULL, digits = 2, align = NULL){
  df2 <- df
  # auto-align: numéricas a derecha, resto a izquierda
  auto_align <- ifelse(vapply(df2, is.numeric, logical(1)), "r", "l")
  # normalizar 'align' del usuario (si vino)
  if (!is.null(align)) {
    if (length(align) == 1) {
      align <- gsub("\\s+", "", align)            # quita espacios
      align <- strsplit(align, "")[[1]]
    }
    # si largo no coincide, usar auto
    if (length(align) != ncol(df2)) align <- auto_align
    # sanitizar caracteres
    align[!align %in% c("l","r","c")] <- "l"
  } else {
    align <- auto_align
  }
  kb <- knitr::kable(df2, caption = caption, align = align, digits = digits)
  print(kb)
  invisible(NULL)
}

# ---------- Predicciones con tabla compacta ----------
pred_tabla_compacta <- function(fit, newdata, budget, caption, etiqueta = NULL){
  ic <- as.data.frame(predict(fit, newdata = newdata, interval = "confidence",  level = 0.95))
  pi <- as.data.frame(predict(fit, newdata = newdata, interval = "prediction", level = 0.95))

  raw <- newdata %>%
    mutate(
      Precio_Pronosticado = ic$fit,
      IC95_LI_media = ic$lwr, IC95_LS_media = ic$upr,
      PI95_LI_ind   = pi$lwr, PI95_LS_ind   = pi$upr,
      Ancho_IC95    = IC95_LS_media - IC95_LI_media,
      Ancho_PI95    = PI95_LS_ind - PI95_LI_ind
    )

  if (is.null(etiqueta)) {
    etiqueta <- if ("estrato" %in% names(newdata)) paste0("Estrato ", newdata$estrato)
                else paste0("Escenario ", seq_len(nrow(newdata)))
  }

  compact <- raw %>%
    mutate(
      Escenario = etiqueta,
      Perfil    = perfil_str(areaconst, estrato, habitaciones, parqueaderos, banios),
      `Precio esp. (M)` = fmt_m(Precio_Pronosticado, 1),
      `IC95 (M)`        = interval_str(IC95_LI_media, IC95_LS_media, 1),
      `PI95 (M)`        = interval_str(PI95_LI_ind,   PI95_LS_ind,   1),
      `Ancho PI95 (M)`  = fmt_m(Ancho_PI95, 1),
      `Estado` = dplyr::case_when(
        PI95_LS_ind <= budget ~ "OK (PI95 ≤ ppto)",
        Precio_Pronosticado <= budget ~ "Probable (esp ≤ ppto; revisar PI95)",
        TRUE ~ "Excede / Negociar"
      )
    ) %>%
    select(Escenario, Perfil, `Precio esp. (M)`, `PI95 (M)`, `Ancho PI95 (M)`, Estado)

  # sin 'align' → auto (izq = texto, der = num)
  show_table_md(compact, caption = caption)
  invisible(raw)  # devuelve numérico para gráficas
}

# ---------- Chequeo de rangos compacto ----------
range_check_tbl <- function(train_df, newdf, vars, etiquetas = NULL){
  rng <- lapply(vars, function(v) range(train_df[[v]], na.rm = TRUE)); names(rng) <- vars
  if (is.null(etiquetas)) {
    etiquetas <- if ("estrato" %in% names(newdf)) paste0("Estrato ", newdf$estrato)
                 else paste0("Escenario ", seq_len(nrow(newdf)))
  }
  rows <- list()
  for (i in seq_len(nrow(newdf))) {
    for (v in vars) {
      val <- newdf[[v]][i]; rmin <- rng[[v]][1]; rmax <- rng[[v]][2]
      rows[[length(rows) + 1]] <- tibble(
        Escenario = etiquetas[i],
        Variable  = v,
        Valor     = val,
        Rango     = paste0(round(rmin,1), "–", round(rmax,1)),
        Alerta    = ifelse(val < rmin | val > rmax, "Fuera de rango", "")
      )
    }
  }
  out <- bind_rows(rows)
  alerts <- out %>% filter(Alerta != "")
  if (nrow(alerts) == 0L) {
    show_table_md(tibble(Mensaje = "Todos los escenarios están dentro del rango de entrenamiento."),
                  caption = "Chequeo de rangos — Sin alertas")
  } else {
    alerts <- alerts %>%
      mutate(Variable = recode(Variable,
        areaconst = "Área (m²)", estrato = "Estrato", habitaciones = "Hab.",
        parqueaderos = "Parq.", banios = "Baños"
      ))
    show_table_md(alerts, caption = "Chequeo de rangos — Alertas")
  }
  invisible(out)
}

# ----------------------------------------------
# Vivienda 1 (Casa – Zona Norte por coordenadas)
# ----------------------------------------------
newdata_v1 <- tibble(
  areaconst    = 200,
  estrato      = c(4L, 5L),
  habitaciones = 4L,
  parqueaderos = 1L,
  banios       = 2L
)
pred_v1 <- pred_tabla_compacta(
  fit     = res_casas_norte$fit,
  newdata = newdata_v1,
  budget  = 350,
  caption = "Vivienda 1 — Predicción (IC95/PI95) y estado vs. presupuesto (M)"
)
## 
## 
## Table: Vivienda 1 — Predicción (IC95/PI95) y estado vs. presupuesto (M)
## 
## Escenario   Perfil                       Precio esp. (M)   PI95 (M)      Ancho PI95 (M)   Estado                              
## ----------  ---------------------------  ----------------  ------------  ---------------  ------------------------------------
## Estrato 4   200 m² | E4 | H4 | P1 | B2   312.7             -84.4–709.8   794.2            Probable (esp ≤ ppto; revisar PI95) 
## Estrato 5   200 m² | E5 | H4 | P1 | B2   419.5             21.8–817.1    795.3            Excede / Negociar
# ----------------------------------------------
# Vivienda 2 (Apto – Zona Sur por coordenadas)
# ----------------------------------------------
newdata_v2 <- tibble(
  areaconst    = 300,
  estrato      = c(5L, 6L),
  habitaciones = 5L,
  parqueaderos = 3L,
  banios       = 3L
)
pred_v2 <- pred_tabla_compacta(
  fit     = res_apto_sur$fit,
  newdata = newdata_v2,
  budget  = 850,
  caption = "Vivienda 2 — Predicción (IC95/PI95) y estado vs. presupuesto (M)"
)
## 
## 
## Table: Vivienda 2 — Predicción (IC95/PI95) y estado vs. presupuesto (M)
## 
## Escenario   Perfil                       Precio esp. (M)   PI95 (M)      Ancho PI95 (M)   Estado                              
## ----------  ---------------------------  ----------------  ------------  ---------------  ------------------------------------
## Estrato 5   300 m² | E5 | H5 | P3 | B3   721.5             522.7–920.2   397.5            Probable (esp ≤ ppto; revisar PI95) 
## Estrato 6   300 m² | E6 | H5 | P3 | B3   777.4             578.6–976.2   397.6            Probable (esp ≤ ppto; revisar PI95)
# ---------- Rangos (solo alertas si existen) ----------
vars_model <- c("areaconst","estrato","habitaciones","parqueaderos","banios")
invisible(range_check_tbl(res_casas_norte$train, newdata_v1, vars_model))
## 
## 
## Table: Chequeo de rangos — Sin alertas
## 
##  Mensaje                                                       
##  --------------------------------------------------------------
##  Todos los escenarios están dentro del rango de entrenamiento.
invisible(range_check_tbl(res_apto_sur$train,  newdata_v2, vars_model))
## 
## 
## Table: Chequeo de rangos — Sin alertas
## 
##  Mensaje                                                       
##  --------------------------------------------------------------
##  Todos los escenarios están dentro del rango de entrenamiento.
suppressPackageStartupMessages({
  library(dplyr); library(ggplot2); library(tidyr); library(tibble)
})

# --- Helper: asegura una columna 'Escenario' ---
ensure_scenario <- function(df){
  if ("Escenario" %in% names(df)) return(df)
  if ("estrato" %in% names(df)) {
    df %>% dplyr::mutate(Escenario = paste0("Estrato ", estrato))
  } else {
    df %>% dplyr::mutate(Escenario = paste0("Escenario ", dplyr::row_number()))
  }
}

# Formato de eje Y en millones
lab_m <- function(x) paste0(formatC(x, format = "f", digits = 0, big.mark = ","), " M")

# ==========================
# A) Escenarios con IC95/PI95 + presupuesto
# ==========================
# Vivienda 1
stopifnot(exists("pred_v1"))
pred_v1_plot <- pred_v1 %>%
  ensure_scenario() %>%
  dplyr::mutate(Escenario = factor(Escenario, levels = unique(Escenario)))

g_v1 <- ggplot(pred_v1_plot, aes(x = Escenario, y = Precio_Pronosticado)) +
  geom_errorbar(aes(ymin = PI95_LI_ind, ymax = PI95_LS_ind), width = 0.25, alpha = 0.25) +
  geom_errorbar(aes(ymin = IC95_LI_media, ymax = IC95_LS_media), width = 0.15, linewidth = 0.9) +
  geom_point(size = 3) +
  geom_hline(yintercept = 350, linetype = "dashed", color = "firebrick") +
  annotate("text", x = 0.6, y = 350, label = "Presupuesto 350 M", color = "firebrick", vjust = -0.5) +
  coord_flip() +
  labs(title = "Vivienda 1 — Escenarios con IC95/PI95 y presupuesto",
       x = NULL, y = "Precio (millones)") +
  scale_y_continuous(labels = lab_m) +
  theme_minimal()

# Vivienda 2
stopifnot(exists("pred_v2"))
pred_v2_plot <- pred_v2 %>%
  ensure_scenario() %>%
  dplyr::mutate(Escenario = factor(Escenario, levels = unique(Escenario)))

g_v2 <- ggplot(pred_v2_plot, aes(x = Escenario, y = Precio_Pronosticado)) +
  geom_errorbar(aes(ymin = PI95_LI_ind, ymax = PI95_LS_ind), width = 0.25, alpha = 0.25) +
  geom_errorbar(aes(ymin = IC95_LI_media, ymax = IC95_LS_media), width = 0.15, linewidth = 0.9) +
  geom_point(size = 3) +
  geom_hline(yintercept = 850, linetype = "dashed", color = "firebrick") +
  annotate("text", x = 0.6, y = 850, label = "Presupuesto 850 M", color = "firebrick", vjust = -0.5) +
  coord_flip() +
  labs(title = "Vivienda 2 — Escenarios con IC95/PI95 y presupuesto",
       x = NULL, y = "Precio (millones)") +
  scale_y_continuous(labels = lab_m) +
  theme_minimal()

print(g_v1); print(g_v2)

# ==========================
# B) Curvas "what-if" de Área (Precio esperado + banda PI95) por Estrato
# ==========================
area_grid <- function(train_df, var = "areaconst", by = 5, pad = 0){
  r <- range(train_df[[var]], na.rm = TRUE)
  r[1] <- max(0, r[1] - pad); r[2] <- r[2] + pad
  seq(from = r[1], to = r[2], by = by)
}

# Vivienda 1 (Casa Norte): estratos 4 y 5, resto fijo en el perfil V1
fit1   <- res_casas_norte$fit
train1 <- res_casas_norte$train
grid1 <- dplyr::bind_rows(
  tibble(estrato = 4L, areaconst = area_grid(train1)),
  tibble(estrato = 5L, areaconst = area_grid(train1))
) %>%
  dplyr::mutate(habitaciones = 4L, parqueaderos = 1L, banios = 2L)

pred1 <- as.data.frame(predict(fit1, newdata = grid1, interval = "prediction", level = 0.95))
grid1 <- dplyr::bind_cols(grid1, tibble(Precio = pred1$fit, PI_LI = pred1$lwr, PI_LS = pred1$upr)) %>%
  dplyr::mutate(Estrato = paste("Estrato", estrato))

g1 <- ggplot(grid1, aes(x = areaconst, y = Precio, fill = Estrato, color = Estrato)) +
  geom_ribbon(aes(ymin = PI_LI, ymax = PI_LS), alpha = 0.12, color = NA) +
  geom_line(linewidth = 1) +
  geom_hline(yintercept = 350, linetype = "dashed", color = "firebrick") +
  labs(title = "Vivienda 1 — Sensibilidad al Área (Precio y PI95)",
       x = "Área construida (m²)", y = "Precio (millones)") +
  scale_y_continuous(labels = lab_m) +
  theme_minimal()

# Vivienda 2 (Apto Sur): estratos 5 y 6, resto fijo en el perfil V2
fit2   <- res_apto_sur$fit
train2 <- res_apto_sur$train
grid2 <- dplyr::bind_rows(
  tibble(estrato = 5L, areaconst = area_grid(train2)),
  tibble(estrato = 6L, areaconst = area_grid(train2))
) %>%
  dplyr::mutate(habitaciones = 5L, parqueaderos = 3L, banios = 3L)

pred2 <- as.data.frame(predict(fit2, newdata = grid2, interval = "prediction", level = 0.95))
grid2 <- dplyr::bind_cols(grid2, tibble(Precio = pred2$fit, PI_LI = pred2$lwr, PI_LS = pred2$upr)) %>%
  dplyr::mutate(Estrato = paste("Estrato", estrato))

g2 <- ggplot(grid2, aes(x = areaconst, y = Precio, fill = Estrato, color = Estrato)) +
  geom_ribbon(aes(ymin = PI_LI, ymax = PI_LS), alpha = 0.12, color = NA) +
  geom_line(linewidth = 1) +
  geom_hline(yintercept = 850, linetype = "dashed", color = "firebrick") +
  labs(title = "Vivienda 2 — Sensibilidad al Área (Precio y PI95)",
       x = "Área construida (m²)", y = "Precio (millones)") +
  scale_y_continuous(labels = lab_m) +
  theme_minimal()

print(g1); print(g2)

En las tablas de predicción se presenta el Precio pronosticado junto con IC95 (intervalo de confianza de la media esperada) y PI95 (intervalo de predicción individual).

  • IC95: rango plausible para la media del precio con esas características (incertidumbre del promedio).

  • PI95: rango plausible para una vivienda específica (varía más que el IC).

6) Ofertas potenciales (Vivienda 1≤ 350M) – (Vivienda 2≤ 850M)

suppressPackageStartupMessages({
  library(dplyr); library(tibble); library(knitr); library(leaflet)
})

stopifnot(exists("casas_norte_geo"), exists("res_casas_norte"),
          exists("apto_sur_geo"),  exists("res_apto_sur"))

# ---------- Helpers ----------
options(knitr.kable.NA = "")
fmt_m <- function(x, digits = 1){
  ifelse(is.na(x), "", formatC(x, format = "f", digits = digits, big.mark = ","))
}
perfil_str <- function(area, hab, ban, parq){
  paste0(round(area,1), " m² | H", hab, " | B", ban, " | P", parq)
}
print_table <- function(df, caption, digits = 2){
  print(knitr::kable(df, caption = caption, digits = digits))
  invisible(NULL)
}

# ---------- Solo datos (sin backticks con símbolos) ----------
find_ofertas_df <- function(df_seg, target, budget,
                            tol_area0 = 0.20, min_n = 5, max_expand = 3,
                            fit = NULL){
  tol <- tol_area0
  ofertas <- tibble()
  for (k in 0:max_expand) {
    rango <- c(target$areaconst * (1 - tol), target$areaconst * (1 + tol))
    ofertas <- df_seg %>%
      filter(
        between(areaconst, rango[1], rango[2]),
        parqueaderos >= target$parqueaderos,
        banios       >= target$banios,
        habitaciones >= target$habitaciones,
        estrato      %in% target$estrato,
        preciom      <= budget
      ) %>%
      mutate(dist_area = abs(areaconst - target$areaconst))
    if (nrow(ofertas) >= min_n) break
    tol <- tol + 0.10
  }
  if (nrow(ofertas) == 0) return(list(ofertas = NULL, top5 = NULL, criterio = paste0("±", round(tol*100,0), "%")))

  if (!is.null(fit)) {
    ofertas <- ofertas %>%
      mutate(
        pred_modelo = as.numeric(predict(fit, newdata = select(., areaconst, estrato, habitaciones, parqueaderos, banios))),
        gap_menos_modelo = preciom - pred_modelo,
        flag_valor = if_else(gap_menos_modelo < 0, "obs < pred", "obs ≥ pred")
      )
  } else {
    ofertas <- ofertas %>% mutate(pred_modelo = NA_real_, gap_menos_modelo = NA_real_, flag_valor = "")
  }

  ofertas <- ofertas %>%
    arrange(dist_area, preciom) %>%
    mutate(criterio_area = paste0("±", round(tol*100, 0), "%"),
           .rank = dplyr::row_number())

  top5 <- ofertas %>% slice_head(n = min(5L, nrow(ofertas)))
  list(ofertas = ofertas, top5 = top5, criterio = unique(ofertas$criterio_area))
}

# =========================
# PASO 6 — Vivienda 1 (Casa · Norte · ≤ 350M)
# =========================
target_v1 <- list(areaconst = 200, parqueaderos = 1, banios = 2, habitaciones = 4, estrato = c(4L, 5L))
res_v1 <- find_ofertas_df(
  df_seg    = casas_norte_geo,
  target    = target_v1,
  budget    = 350,
  tol_area0 = 0.20, min_n = 5, max_expand = 3,
  fit       = res_casas_norte$fit
)

if (is.null(res_v1$ofertas)) {
  print_table(tibble(Mensaje = "No se encontraron ofertas dentro de las restricciones."),
              caption = "Vivienda 1 — Resultado")
} else {
  # NOMBRES ASCII SEGUROS
  tabla_v1 <- res_v1$top5 %>%
    transmute(
      Rank = .rank,
      Barrio = barrio,
      Zona   = zona_geo,
      Estrato = estrato,
      Perfil  = perfil_str(areaconst, habitaciones, banios, parqueaderos),
      Precio_M = fmt_m(preciom, 1),
      Pred_M   = fmt_m(pred_modelo, 1),
      Delta_obs_pred_M = fmt_m(gap_menos_modelo, 1),
      Senal = flag_valor
    )
  # Encabezados bonitos (strings, no backticks)
  names(tabla_v1) <- c("Rank","Barrio","Zona","Estrato","Perfil",
                       "Precio (M)","Pred (M)","Delta (obs - pred) (M)","Señal")

  print_table(tabla_v1,
    caption = paste0("Vivienda 1 (Casa · Zona Norte) — Top ", nrow(tabla_v1),
                     " (área ", res_v1$criterio, ", ppto ≤ 350 M)")
  )

  # Mapa simple (como en Paso 1)
  map_v1 <- leaflet(res_v1$ofertas) %>%
    addTiles() %>%
    addCircleMarkers(
      lng = ~longitud, lat = ~latitud,
      popup = ~paste0("<b>", barrio, "</b><br>",
                      zona_geo, " · Estrato ", estrato, "<br>",
                      "Área: ", round(areaconst,1), " m² · H:", habitaciones, " · B:", banios, " · P:", parqueaderos, "<br>",
                      "Precio: ", round(preciom,1), " M",
                      if (!all(is.na(pred_modelo))) paste0("<br>Pred: ", round(pred_modelo,1), " M · Δ: ",
                                                           round(gap_menos_modelo,1), " M (", flag_valor, ")") else ""
      ),
      radius = 6, stroke = FALSE, fillOpacity = 0.85, clusterOptions = markerClusterOptions()
    ) %>%
    addCircleMarkers(
      data = res_v1$top5,
      lng = ~longitud, lat = ~latitud,
      popup = ~paste0("<b>", barrio, "</b><br>",
                      zona_geo, " · Estrato ", estrato, "<br>",
                      "Área: ", round(areaconst,1), " m² · H:", habitaciones, " · B:", banios, " · P:", parqueaderos, "<br>",
                      "Precio: ", round(preciom,1), " M",
                      if (!all(is.na(pred_modelo))) paste0("<br>Pred: ", round(pred_modelo,1), " M · Δ: ",
                                                           round(gap_menos_modelo,1), " M (", flag_valor, ")") else ""
      ),
      radius = 8, stroke = TRUE, color = "red", fillOpacity = 0.95
    ) %>%
    addLegend("bottomright",
              colors = c("#3388ff", "red"),
              labels = c("Ofertas en presupuesto", "Top 5 a discutir"),
              title = "Leyenda", opacity = 1)
  map_v1
}
## 
## 
## Table: Vivienda 1 (Casa · Zona Norte) — Top 5 (área ±20%, ppto ≤ 350 M)
## 
##  Rank  Barrio         Zona                Estrato  Perfil                  Precio (M)   Pred (M)   Delta (obs - pred) (M)   Señal      
## -----  -------------  -----------------  --------  ----------------------  -----------  ---------  -----------------------  -----------
##     1  la flora       Zona Norte (geo)          5  200 m² | H4 | B4 | P2   320.0        552.3      -232.3                   obs < pred 
##     2  la merced      Zona Norte (geo)          4  200 m² | H4 | B4 | P2   320.0        445.6      -125.6                   obs < pred 
##     3  el bosque      Zona Norte (geo)          5  200 m² | H4 | B3 | P3   350.0        573.2      -223.2                   obs < pred 
##     4  san fernando   Zona Norte (geo)          4  198 m² | H5 | B2 | P1   300.0        291.4      8.6                      obs ≥ pred 
##     5  el bosque      Zona Norte (geo)          5  202 m² | H5 | B4 | P1   335.0        475.5      -140.5                   obs < pred
# =========================
# PASO 7 — Vivienda 2 (Apto · Sur · ≤ 850M)
# =========================
target_v2 <- list(areaconst = 300, parqueaderos = 3, banios = 3, habitaciones = 5, estrato = c(5L, 6L))
res_v2 <- find_ofertas_df(
  df_seg    = apto_sur_geo,
  target    = target_v2,
  budget    = 850,
  tol_area0 = 0.20, min_n = 5, max_expand = 3,
  fit       = res_apto_sur$fit
)

if (is.null(res_v2$ofertas)) {
  print_table(tibble(Mensaje = "No se encontraron ofertas dentro de las restricciones."),
              caption = "Vivienda 2 — Resultado")
} else {
  tabla_v2 <- res_v2$top5 %>%
    transmute(
      Rank = .rank,
      Barrio = barrio,
      Zona   = zona_geo,
      Estrato = estrato,
      Perfil  = perfil_str(areaconst, habitaciones, banios, parqueaderos),
      Precio_M = fmt_m(preciom, 1),
      Pred_M   = fmt_m(pred_modelo, 1),
      Delta_obs_pred_M = fmt_m(gap_menos_modelo, 1),
      Senal = flag_valor
    )
  names(tabla_v2) <- c("Rank","Barrio","Zona","Estrato","Perfil",
                       "Precio (M)","Pred (M)","Delta (obs - pred) (M)","Señal")

  print_table(tabla_v2,
    caption = paste0("Vivienda 2 (Apto · Zona Sur) — Top ", nrow(tabla_v2),
                     " (área ", res_v2$criterio, ", ppto ≤ 850 M)")
  )

  map_v2 <- leaflet(res_v2$ofertas) %>%
    addTiles() %>%
    addCircleMarkers(
      lng = ~longitud, lat = ~latitud,
      popup = ~paste0("<b>", barrio, "</b><br>",
                      zona_geo, " · Estrato ", estrato, "<br>",
                      "Área: ", round(areaconst,1), " m² · H:", habitaciones, " · B:", banios, " · P:", parqueaderos, "<br>",
                      "Precio: ", round(preciom,1), " M",
                      if (!all(is.na(pred_modelo))) paste0("<br>Pred: ", round(pred_modelo,1), " M · Δ: ",
                                                           round(gap_menos_modelo,1), " M (", flag_valor, ")") else ""
      ),
      radius = 6, stroke = FALSE, fillOpacity = 0.85, clusterOptions = markerClusterOptions()
    ) %>%
    addCircleMarkers(
      data = res_v2$top5,
      lng = ~longitud, lat = ~latitud,
      popup = ~paste0("<b>", barrio, "</b><br>",
                      zona_geo, " · Estrato ", estrato, "<br>",
                      "Área: ", round(areaconst,1), " m² · H:", habitaciones, " · B:", banios, " · P:", parqueaderos, "<br>",
                      "Precio: ", round(preciom,1), " M",
                      if (!all(is.na(pred_modelo))) paste0("<br>Pred: ", round(pred_modelo,1), " M · Δ: ",
                                                           round(gap_menos_modelo,1), " M (", flag_valor, ")") else ""
      ),
      radius = 8, stroke = TRUE, color = "red", fillOpacity = 0.95
    ) %>%
    addLegend("bottomright",
              colors = c("#3388ff", "red"),
              labels = c("Ofertas en presupuesto", "Top 5 a discutir"),
              title = "Leyenda", opacity = 1)
  map_v2
}
## 
## 
## Table: Vivienda 2 (Apto · Zona Sur) — Top 2 (área ±60%, ppto ≤ 850 M)
## 
##  Rank  Barrio      Zona              Estrato  Perfil                  Precio (M)   Pred (M)   Delta (obs - pred) (M)   Señal      
## -----  ----------  ---------------  --------  ----------------------  -----------  ---------  -----------------------  -----------
##     1  seminario   Zona Sur (geo)          5  300 m² | H6 | B5 | P3   670.0        781.7      -111.7                   obs < pred 
##     2  seminario   Zona Sur (geo)          5  256 m² | H5 | B5 | P3   530.0        741.9      -211.9                   obs < pred

Para sugerir las viviendas potenciales se calculó el delta entre la predicción del precio y las observaciones para viviendas con una variación en área del ±20%, de manera que la vivienda es más barata de lo esperado cuando el delta en negativo (oportunidad); y cuando el delta es positivo la vivienda es más costosa que el precio predicho por el modelo. En la tabla y el mapa se muestran las viviendas potenciales (todas en azul; en rojo las más favorables) que cumplen con los requerimientos del ejercicio.

7) Recomendaciones

Vivienda 1 (≤ 350 M).
Se debe enfocar en casas del Norte alrededor de 200 m², con 2 baños y 1 parqueadero. Si el precio se acerca al tope de 350 M, se puede considerar reducir el área a 180–190 m² o ser más flexible con el número de habitaciones (tienen menos impacto que el área). Para negociar, se puede usar el intervalo de predicción al 95% (PI95) como banda: cerca del centro y evitar pasar del límite superior.

Vivienda 2 (≤ 850 M).
Con el margen de presupuesto en el Sur (estratos 5–6), priorizar 3 parqueaderos y 3 baños; el área se podría ajustar entre 280–320 m² . Comparar siempre el precio publicado con el precio que predice el modelo (Δ = obs − pred): si la Δ es negativa, es una buena oportunidad; si es positiva, se debe negociar.