# ---- CARGA SEGURA (SIN INSTALAR/ACTUALIZAR DURANTE KNIT) ----
safelib <- function(pkg) {
  if (!requireNamespace(pkg, quietly = TRUE)) {
    stop(sprintf("El paquete '%s' no está instalado. Ejecuta el script 'install_once.R' y vuelve a Knit.", pkg), call. = FALSE)
  }
  suppressPackageStartupMessages(library(pkg, character.only = TRUE))
}

pkgs <- c("tidyverse","janitor","skimr","kableExtra","stringr",
          "plotly","leaflet","sf","tmap",
          "broom","car","lmtest","yardstick","rsample",
          "paqueteMODELOS")
invisible(lapply(pkgs, safelib))

1 0. Entendimiento del negocio (CRISP-DM)


2 1. Entendimiento de los datos

data("vivienda")
viv_raw <- dplyr::as_tibble(vivienda) %>% janitor::clean_names()
glimpse(viv_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…

3 2. Limpieza, deduplicación, imputación (incluye ‘piso’) y geofiltrado

3.1 2.1 Normalización de textos y tipos

clean_str <- function(x) {
  x <- trimws(x)
  x <- tolower(x)
  x <- gsub("[ÁÀÂÄáàâä]", "a", x)
  x <- gsub("[ÉÈÊËéèêë]", "e", x)
  x <- gsub("[ÍÌÎÏíìîï]", "i", x)
  x <- gsub("[ÓÒÔÖóòôö]", "o", x)
  x <- gsub("[ÚÙÛÜúùûü]", "u", x)
  x <- gsub("ñ", "n", x, fixed = TRUE)
  x <- gsub("Ñ", "n", x, fixed = TRUE)
  x
}
capwords <- function(s) stringr::str_to_title(s)

num <- function(x) readr::parse_number(as.character(x),
                                       locale = readr::locale(decimal_mark = ".", grouping_mark = ","))

viv <- viv_raw %>% 
  dplyr::mutate(
    zona   = capwords(clean_str(zona)),
    tipo   = capwords(clean_str(tipo)),
    barrio = capwords(barrio),
    piso   = clean_str(piso),      # 'piso' como texto normalizado; lo convertimos luego
    preciom     = as.numeric(num(preciom)),
    areaconst   = as.numeric(num(areaconst)),
    estrato     = as.integer(num(estrato)),
    parqueaderos= as.integer(num(parqueaderos)),
    banios      = as.integer(num(banios)),
    habitaciones= as.integer(num(habitaciones)),
    longitud    = as.numeric(num(longitud)),
    latitud     = as.numeric(num(latitud))
  )

3.2 2.2 Eliminación de duplicados

if ("id" %in% names(viv)) {
  viv_nodup <- viv %>% dplyr::arrange(id) %>% dplyr::distinct(id, .keep_all = TRUE)
} else {
  viv_nodup <- viv %>% 
    dplyr::distinct(tipo, zona, barrio, areaconst, estrato, banios, habitaciones, parqueaderos, preciom, latitud, longitud, .keep_all = TRUE)
}
kableExtra::kable(
  tibble::tibble(
    Registros_iniciales = nrow(viv),
    Registros_sin_duplicar = nrow(viv_nodup),
    Eliminados = nrow(viv) - nrow(viv_nodup)
  ),
  caption = "Efecto de la deduplicación"
) %>% kableExtra::kable_styling()
Efecto de la deduplicación
Registros_iniciales Registros_sin_duplicar Eliminados
8322 8320 2

3.3 2.3 Imputación (medianas por grupo) incluye piso y eliminar filas sin lat/long

Criterio para piso:
- Se parsea a entero con un parser robusto (palabras comunes + dígitos).
- Casas: si falta, se imputa 1 (primer piso/planta baja).
- Apartamentos: si falta, se imputa la mediana del grupo (tipo, zona, estrato); si el grupo no tiene datos, se usa la mediana global de apartamentos.

El resto (areaconst, habitaciones, parqueaderos, banios, estrato) se imputan por mediana del grupo con fallback global.
Las filas sin latitud/longitud se eliminan (no se imputan).

# Parser robusto de 'piso' -> entero
parse_piso <- function(x) {
  x <- clean_str(x)
  # 1) intenta extraer número explícito
  num1 <- suppressWarnings(readr::parse_number(x))
  # 2) mapea textos a número
  word_num <- dplyr::case_when(
    stringr::str_detect(x, "primer|primero|planta baja|\\bpb\\b") ~ 1,
    stringr::str_detect(x, "segund") ~ 2,
    stringr::str_detect(x, "tercer|tercero") ~ 3,
    stringr::str_detect(x, "cuart") ~ 4,
    stringr::str_detect(x, "quint") ~ 5,
    stringr::str_detect(x, "sext")  ~ 6,
    stringr::str_detect(x, "septim|s[eé]ptim") ~ 7,
    stringr::str_detect(x, "octav") ~ 8,
    stringr::str_detect(x, "noven") ~ 9,
    stringr::str_detect(x, "decim|d[eé]cim") ~ 10,
    TRUE ~ as.numeric(NA)
  )
  out <- ifelse(is.na(num1), word_num, num1)
  out <- ifelse(!is.na(out) & out < 1, 1, out)   # pisos <1 -> 1
  as.integer(out)
}

# Medianas globales (fallback)
globals <- viv_nodup %>% 
  dplyr::mutate(piso_num = parse_piso(piso)) %>%
  dplyr::summarise(
    g_areaconst    = stats::median(areaconst, na.rm=TRUE),
    g_estrato      = stats::median(estrato, na.rm=TRUE),
    g_habitaciones = stats::median(habitaciones, na.rm=TRUE),
    g_parqueaderos = stats::median(parqueaderos, na.rm=TRUE),
    g_banios       = stats::median(banios, na.rm=TRUE),
    g_piso_apto    = stats::median(piso_num[tipo %in% c("Apartamento","Apartamentos")], na.rm=TRUE)
  )

viv_impu <- viv_nodup %>%
  dplyr::mutate(piso_num = parse_piso(piso)) %>%
  dplyr::group_by(tipo, zona, estrato) %>%
  dplyr::mutate(
    # Imputación de piso
    piso_num = dplyr::case_when(
      is.na(piso_num) & stringr::str_detect(tipo, "^Casa") ~ 1L,
      is.na(piso_num) & stringr::str_detect(tipo, "^Apart") ~ {
        grp_med <- stats::median(piso_num, na.rm = TRUE)
        if (is.finite(grp_med)) as.integer(round(grp_med)) else as.integer(round(globals$g_piso_apto))
      },
      TRUE ~ piso_num
    ),
    # Otras imputaciones por mediana de grupo con fallback global
    areaconst    = ifelse(is.na(areaconst),
                          dplyr::if_else(is.finite(stats::median(areaconst, na.rm=TRUE)),
                                         stats::median(areaconst, na.rm=TRUE), globals$g_areaconst),
                          areaconst),
    habitaciones = ifelse(is.na(habitaciones),
                          dplyr::if_else(is.finite(stats::median(habitaciones, na.rm=TRUE)),
                                         stats::median(habitaciones, na.rm=TRUE), globals$g_habitaciones),
                          habitaciones),
    parqueaderos = ifelse(is.na(parqueaderos),
                          dplyr::if_else(is.finite(stats::median(parqueaderos, na.rm=TRUE)),
                                         stats::median(parqueaderos, na.rm=TRUE), globals$g_parqueaderos),
                          parqueaderos),
    banios       = ifelse(is.na(banios),
                          dplyr::if_else(is.finite(stats::median(banios, na.rm=TRUE)),
                                         stats::median(banios, na.rm=TRUE), globals$g_banios),
                          banios),
    estrato      = ifelse(is.na(estrato), globals$g_estrato, estrato)
  ) %>%
  dplyr::ungroup()

# ELIMINAR si faltan coordenadas (no se imputan)
viv_geo <- viv_impu %>% dplyr::filter(!is.na(latitud), !is.na(longitud))

faltantes_post <- viv_geo %>%
  dplyr::summarise(dplyr::across(c(preciom, areaconst, estrato, banios, habitaciones, parqueaderos, piso_num, latitud, longitud), ~sum(is.na(.x)))) %>%
  tidyr::pivot_longer(dplyr::everything(), names_to = "variable", values_to = "n_na") %>%
  dplyr::arrange(dplyr::desc(n_na))

kableExtra::kable(faltantes_post, caption="NA restantes tras imputación + filtro geográfico (preciom no se imputa)") %>% kableExtra::kable_styling()
NA restantes tras imputación + filtro geográfico (preciom no se imputa)
variable n_na
preciom 0
areaconst 0
estrato 0
banios 0
habitaciones 0
parqueaderos 0
piso_num 0
latitud 0
longitud 0

3.4 2.4 Winsorización opcional (robustez a outliers)

do_winsorize <- TRUE

winsorize <- function(x, probs = c(0.01, 0.99)) {
  qs <- stats::quantile(x, probs = probs, na.rm = TRUE)
  pmin(pmax(x, qs[1]), qs[2])
}

viv_clean <- viv_geo %>%
  dplyr::mutate(
    preciom_w   = if (do_winsorize) winsorize(preciom)   else preciom,
    areaconst_w = if (do_winsorize) winsorize(areaconst) else areaconst
  )

4 3. Análisis Exploratorio de Datos (EDA completo)

4.1 3.1 Estadísticos y distribuciones

skimr::skim(viv_clean)
Data summary
Name viv_clean
Number of rows 8319
Number of columns 16
_______________________
Column type frequency:
character 4
numeric 12
________________________
Group variables None

Variable type: character

skim_variable n_missing complete_rate min max empty n_unique whitespace
zona 0 1.00 8 12 0 5 0
piso 2635 0.68 2 2 0 12 0
tipo 0 1.00 4 11 0 2 0
barrio 0 1.00 4 29 0 407 0

Variable type: numeric

skim_variable n_missing complete_rate mean sd p0 p25 p50 p75 p100 hist
id 0 1 4160.00 2401.63 1.00 2080.50 4160.00 6239.50 8319.00 ▇▇▇▇▇
estrato 0 1 4.63 1.03 3.00 4.00 5.00 5.00 6.00 ▅▆▁▇▆
preciom 0 1 433.90 328.67 58.00 220.00 330.00 540.00 1999.00 ▇▂▁▁▁
areaconst 0 1 174.93 142.96 30.00 80.00 123.00 229.00 1745.00 ▇▁▁▁▁
parqueaderos 0 1 1.72 1.06 1.00 1.00 1.00 2.00 10.00 ▇▁▁▁▁
banios 0 1 3.11 1.43 0.00 2.00 3.00 4.00 10.00 ▇▇▃▁▁
habitaciones 0 1 3.61 1.46 0.00 3.00 3.00 4.00 10.00 ▂▇▂▁▁
longitud 0 1 -76.53 0.02 -76.59 -76.54 -76.53 -76.52 -76.46 ▁▅▇▂▁
latitud 0 1 3.42 0.04 3.33 3.38 3.42 3.45 3.50 ▃▇▅▇▅
piso_num 0 1 3.43 2.41 1.00 1.00 3.00 4.00 12.00 ▇▅▁▁▁
preciom_w 0 1 432.57 322.74 92.00 220.00 330.00 540.00 1650.00 ▇▃▁▁▁
areaconst_w 0 1 172.85 131.06 50.00 80.00 123.00 229.00 716.40 ▇▂▁▁▁
p_price <- ggplot2::ggplot(viv_clean, ggplot2::aes(preciom_w)) + ggplot2::geom_histogram(bins=30, alpha=.85) +
  ggplot2::labs(title="Distribución de precio (millones, wins)", x="Precio (M)", y="Frecuencia")

p_area  <- ggplot2::ggplot(viv_clean, ggplot2::aes(areaconst_w)) + ggplot2::geom_histogram(bins=30, alpha=.85) +
  ggplot2::labs(title="Distribución de área construida (m², wins)", x="Área (m²)", y="Frecuencia")

p_estr  <- ggplot2::ggplot(viv_clean, ggplot2::aes(x=factor(estrato))) + ggplot2::geom_bar() +
  ggplot2::labs(title="Distribución de estrato", x="Estrato", y="Conteo")

p_banos <- ggplot2::ggplot(viv_clean, ggplot2::aes(x=banios)) + ggplot2::geom_bar() +
  ggplot2::labs(title="Distribución de baños", x="Baños", y="Conteo")

p_habs  <- ggplot2::ggplot(viv_clean, ggplot2::aes(x=habitaciones)) + ggplot2::geom_bar() +
  ggplot2::labs(title="Distribución de habitaciones", x="Habitaciones", y="Conteo")

p_piso_apto <- ggplot2::ggplot(viv_clean %>% dplyr::filter(stringr::str_detect(tipo,"^Apart")), 
                               ggplot2::aes(x=piso_num)) + ggplot2::geom_bar() +
  ggplot2::labs(title="Distribución de piso (solo Apartamentos)", x="Piso (entero)", y="Conteo")

p_price; p_area; p_estr; p_banos; p_habs; p_piso_apto

5 Corrección de zonas (preferible con polígonos + unión espacial)

A continuación se corrige la columna de zona de la manera más confiable disponible. 1) Si existen polígonos oficiales (GeoJSON/SHP) en la carpeta del proyecto, se realiza una unión espacial para obtener la zona oficial. 2) Si no hay polígonos disponibles, se usa un método de cuadrantes (centro + Norte/Sur/Oriente/Occidente) como alternativa reproducible.

suppressPackageStartupMessages({
  library(dplyr); library(rlang); library(sf)
})

# === Detectar data frame base ===
base_candidates <- c("viv_imp","viv_seg","viv_mediana","viv_media","viv","vivienda","datos","data")
present_objs <- base_candidates[base_candidates %in% ls()]
if (length(present_objs) == 0) stop("No se encontró un objeto de datos esperado.")
df <- get(present_objs[1])

# === Detectar columnas lat/lon ===
possible_lat <- intersect(names(df), c("lat","latitud","latitude","Lat","Latitud"))
possible_lng <- intersect(names(df), c("lng","long","longitud","longitude","Lon","Longitud","Long"))
if (!(length(possible_lat) >= 1 && length(possible_lng) >= 1)) {
  message("No hay columnas de coordenadas; no es posible corregir zonas. Se conservarán etiquetas existentes.")
} else {
  lat_col <- possible_lat[1]; lng_col <- possible_lng[1]
  
  # === Intentar leer polígonos desde rutas comunes ===
  candidates <- c(
    "data/zonas.geojson","data/zonas_cali.geojson","zonas.geojson",
    "data/zonas.shp","zonas.shp","datos/zonas.geojson","datos/zonas.shp"
  )
  existing <- candidates[file.exists(candidates)]
  zonas_path <- if (length(existing) > 0) existing[1] else NA_character_
  
  zona_text_cols <- intersect(names(df), c("zona","Zona","zone","Zone","estrato","Estrato","sector","Sector","barrio","Barrio"))
  zona_orig_col <- if (length(zona_text_cols) >= 1) zona_text_cols[1] else NA_character_
  
  make_quadrant <- function(df, lat_col, lng_col) {
    rad <- pi/180
    bearing_deg <- function(lat, lng, lat0, lng0){
      y <- sin((lng - lng0)*rad)*cos(lat*rad)
      x <- cos(lat0*rad)*sin(lat*rad) - sin(lat0*rad)*cos(lat*rad)*cos((lng - lng0)*rad)
      ((atan2(y,x) * 180/pi) + 360) %% 360
    }
    dist_km <- function(lat, lng, lat0, lng0){
      R <- 6371
      dlat <- (lat-lat0)*rad; dlng <- (lng-lng0)*rad
      a <- sin(dlat/2)^2 + cos(lat0*rad)*cos(lat*rad)*sin(dlng/2)^2
      2*R*atan2(sqrt(a), sqrt(1-a))
    }
    c_lat <- median(df[[lat_col]], na.rm = TRUE)
    c_lng <- median(df[[lng_col]], na.rm = TRUE)
    tmp <- df %>%
      filter(!is.na(.data[[lat_col]]), !is.na(.data[[lng_col]])) %>%
      mutate(
        .dist_km = dist_km(.data[[lat_col]], .data[[lng_col]], c_lat, c_lng),
        .ang     = bearing_deg(.data[[lat_col]], .data[[lng_col]], c_lat, c_lng)
      )
    radio_centro_km <- quantile(tmp$.dist_km, 0.15, na.rm = TRUE)
    tmp %>%
      mutate(
        zona_calc = dplyr::case_when(
          .dist_km <= radio_centro_km                       ~ "Zona Centro",
          .ang >= 315 | .ang < 45                           ~ "Zona Norte",
          .ang >= 45  & .ang < 135                          ~ "Zona Oriente",
          .ang >= 135 & .ang < 225                          ~ "Zona Sur",
          TRUE                                              ~ "Zona Occidente"
        )
      ) %>%
      select(all_of(c(lng_col, lat_col)), zona_calc)
  }
  
  if (!is.na(zonas_path)) {
    # === Unión espacial con polígonos ===
    zonas_sf <- sf::st_read(zonas_path, quiet = TRUE) %>%
      sf::st_make_valid() %>%
      sf::st_transform(4326)
    # Detectar campo de nombre de zona (elige el más probable entre columnas de texto)
    char_cols <- names(zonas_sf)[sapply(zonas_sf, function(x) is.character(x) || is.factor(x))]
    preferred <- intersect(char_cols, c("zona","ZONA","nombre","NOMBRE","comuna","COMUNA","barrio","BARRIO","upz","UPZ"))
    zona_name_col <- if (length(preferred) >= 1) preferred[1] else char_cols[1]
    zonas_sf <- zonas_sf %>% dplyr::select(zona_oficial = all_of(zona_name_col))
    
    pts_sf <- df %>%
      dplyr::filter(!is.na(.data[[lat_col]]), !is.na(.data[[lng_col]])) %>%
      sf::st_as_sf(coords = c(lng_col, lat_col), crs = 4326, remove = FALSE)
    
    joined <- sf::st_join(pts_sf, zonas_sf, left = TRUE)
    corr <- joined %>% sf::st_drop_geometry() %>% dplyr::select(all_of(c(lng_col, lat_col)), zona_oficial)
    
    df <- df %>%
      dplyr::left_join(corr, by = setNames(c(lng_col, lat_col), c(lng_col, lat_col))) %>%
      dplyr::mutate(
        zona_corr = dplyr::coalesce(zona_oficial, if (!is.na(zona_orig_col)) .data[[zona_orig_col]] else NA_character_),
        zona_final = dplyr::coalesce(zona_corr, "Sin categoría")
      )
  } else {
    # === Fallback por cuadrantes ===
    tmp <- make_quadrant(df, lat_col, lng_col)
    df <- df %>%
      dplyr::left_join(tmp, by = setNames(c(lng_col, lat_col), c(lng_col, lat_col))) %>%
      dplyr::mutate(
        zona_corr = zona_calc,
        zona_final = dplyr::coalesce(zona_corr, if (length(zona_text_cols) >= 1) .data[[zona_text_cols[1]]] else NA_character_, "Sin categoría")
      )
  }
  
  # Guardar de vuelta en el objeto base
  assign(present_objs[1], df, inherits = FALSE)
}

# === Métrica de consistencia (si existía una zona original) ===
if (exists("df")) {
  zona_text_cols <- intersect(names(df), c("zona","Zona","zone","Zone","estrato","Estrato","sector","Sector","barrio","Barrio"))
  if (length(zona_text_cols) >= 1 && "zona_final" %in% names(df)) {
    zc <- zona_text_cols[1]
    comp <- df %>%
      filter(!is.na(.data[[zc]]), !is.na(zona_final)) %>%
      summarise(
        n = dplyr::n(),
        coinciden = sum(.data[[zc]] == zona_final, na.rm = TRUE),
        tasa_coincidencia = coinciden / n
      )
    msg <- sprintf("Consistencia zona original vs. corregida: %.1f%% de coincidencia (n=%d).",
                   100*comp$tasa_coincidencia, comp$n)
    cat(msg)
  }
}
## Consistencia zona original vs. corregida: 52.1% de coincidencia (n=55373).

6 Mapa interactivo por zonas (ORIGINAL)

Este mapa usa la zona original (o la mejor columna disponible: zona/estrato/sector/barrio) tal como viene en el dataset, para contrastar con la versión corregida.

suppressPackageStartupMessages({
  library(dplyr); library(magrittr); library(leaflet); library(scales)
})

base_candidates <- c("viv_imp","viv_seg","viv_mediana","viv_media","viv","vivienda","datos","data")
present_objs <- base_candidates[base_candidates %in% ls()]
if (length(present_objs) == 0) {
  cat("**Nota:** No se encontró un objeto de datos esperado (p. ej., 'viv', 'vivienda'). Se omite el mapa.")
} else {
  df <- get(present_objs[1])

  possible_lat <- intersect(names(df), c("lat","latitud","latitude","Lat","Latitud"))
  possible_lng <- intersect(names(df), c("lng","long","longitud","longitude","Lon","Longitud","Long"))

  if (length(possible_lat) >= 1 && length(possible_lng) >= 1) {
    lat_col <- possible_lat[1]; lng_col <- possible_lng[1]

    df_map <- df %>%
      dplyr::filter(!is.na(.data[[lat_col]]), !is.na(.data[[lng_col]])) %>%
      dplyr::mutate(`..lat` = .data[[lat_col]], `..lng` = .data[[lng_col]])

    # Columna de zona ORIGINAL (no usa zona_corr / zona_final)
    possible_zone_orig <- intersect(names(df_map), c("zona","Zona","zone","Zone","estrato","Estrato","barrio","Barrio","sector","Sector"))
    if (length(possible_zone_orig) == 0) {
      df_map[["__zona_origen__"]] <- "Sin categoría"
      zona_col <- "__zona_origen__"
    } else {
      zona_col <- possible_zone_orig[1]
    }
    df_map <- df_map %>% dplyr::mutate(`..zona` = as.factor(.data[[zona_col]]))

    categorias <- unique(df_map$`..zona`)
    pal <- colorFactor(
      palette = colorRampPalette(c("#1b9e77","#d95f02","#7570b3","#e7298a",
                                   "#66a61e","#e6ab02","#a6761d","#666666",
                                   "#1f78b4","#b2df8a","#fb9a99","#fdbf6f"))(length(categorias)),
      domain  = df_map$`..zona`
    )

    popup_fun <- function(i) {
      txt <- sprintf("<b>Zona (original):</b> %s", as.character(df_map$`..zona`[i]))
      if ("precio" %in% names(df_map)) {
        txt <- paste0(txt, "<br><b>Precio:</b> ", dollar(df_map$precio[i], prefix = "$", big.mark = ".", decimal.mark=","))
      }
      if ("area" %in% names(df_map)) {
        txt <- paste0(txt, "<br><b>Área:</b> ", df_map$area[i], " m²")
      }
      if ("piso" %in% names(df_map)) {
        txt <- paste0(txt, "<br><b>Piso:</b> ", df_map$piso[i])
      }
      txt
    }

    leaflet(df_map) %>%
      addTiles() %>%
      addCircleMarkers(lng = ~`..lng`, lat = ~`..lat`,
                       radius = 5, stroke = FALSE, fillOpacity = 0.8,
                       color = ~pal(`..zona`),
                       popup = lapply(seq_len(nrow(df_map)), popup_fun)) %>%
      addLegend(position = "bottomright", pal = pal, values = df_map$`..zona`,
                title = "Categoría de zona (original)", opacity = 1)
  } else {
    cat("**Nota:** No se encontraron columnas de coordenadas (p. ej., 'lat*', 'lng*'). Se omite el mapa.")
  }
}

7 Mapa interactivo por zonas (CORREGIDO)

Este mapa usa la zona corregida (zona_final, o zona_corr si aplica). Si no existe corrección, cae de forma segura a la columna original.

suppressPackageStartupMessages({
  library(dplyr); library(magrittr); library(leaflet); library(scales)
})

base_candidates <- c("viv_imp","viv_seg","viv_mediana","viv_media","viv","vivienda","datos","data")
present_objs <- base_candidates[base_candidates %in% ls()]
if (length(present_objs) == 0) {
  cat("**Nota:** No se encontró un objeto de datos esperado (p. ej., 'viv', 'vivienda'). Se omite el mapa.")
} else {
  df <- get(present_objs[1])

  possible_lat <- intersect(names(df), c("lat","latitud","latitude","Lat","Latitud"))
  possible_lng <- intersect(names(df), c("lng","long","longitud","longitude","Lon","Longitud","Long"))

  if (length(possible_lat) >= 1 && length(possible_lng) >= 1) {
    lat_col <- possible_lat[1]; lng_col <- possible_lng[1]

    df_map <- df %>%
      dplyr::filter(!is.na(.data[[lat_col]]), !is.na(.data[[lng_col]])) %>%
      dplyr::mutate(`..lat` = .data[[lat_col]], `..lng` = .data[[lng_col]])

    # Selección preferente: zona_final -> zona_corr -> original
    zona_col <- NULL
    if ("zona_final" %in% names(df_map)) zona_col <- "zona_final"
    if (is.null(zona_col) && "zona_corr" %in% names(df_map)) zona_col <- "zona_corr"
    if (is.null(zona_col)) {
      possible_zone_orig <- intersect(names(df_map), c("zona","Zona","zone","Zone","estrato","Estrato","barrio","Barrio","sector","Sector"))
      if (length(possible_zone_orig) == 0) {
        df_map[["__zona_origen__"]] <- "Sin categoría"
        zona_col <- "__zona_origen__"
      } else {
        zona_col <- possible_zone_orig[1]
      }
    }
    df_map <- df_map %>% dplyr::mutate(`..zona` = as.factor(.data[[zona_col]]))

    categorias <- unique(df_map$`..zona`)
    pal <- colorFactor(
      palette = colorRampPalette(c("#1b9e77","#d95f02","#7570b3","#e7298a",
                                   "#66a61e","#e6ab02","#a6761d","#666666",
                                   "#1f78b4","#b2df8a","#fb9a99","#fdbf6f"))(length(categorias)),
      domain  = df_map$`..zona`
    )

    popup_fun <- function(i) {
      txt <- sprintf("<b>Zona (corregida):</b> %s", as.character(df_map$`..zona`[i]))
      if ("precio" %in% names(df_map)) {
        txt <- paste0(txt, "<br><b>Precio:</b> ", dollar(df_map$precio[i], prefix = "$", big.mark = ".", decimal.mark=","))
      }
      if ("area" %in% names(df_map)) {
        txt <- paste0(txt, "<br><b>Área:</b> ", df_map$area[i], " m²")
      }
      if ("piso" %in% names(df_map)) {
        txt <- paste0(txt, "<br><b>Piso:</b> ", df_map$piso[i])
      }
      txt
    }

    leaflet(df_map) %>%
      addTiles() %>%
      addCircleMarkers(lng = ~`..lng`, lat = ~`..lat`,
                       radius = 5, stroke = FALSE, fillOpacity = 0.8,
                       color = ~pal(`..zona`),
                       popup = lapply(seq_len(nrow(df_map)), popup_fun)) %>%
      addLegend(position = "bottomright", pal = pal, values = df_map$`..zona`,
                title = "Categoría de zona (corregida)", opacity = 1)
  } else {
    cat("**Nota:** No se encontraron columnas de coordenadas (p. ej., 'lat*', 'lng*'). Se omite el mapa.")
  }
}

7.1 3.2 Correlaciones (CASAS) y tablas

casas_df <- viv_clean %>%
  dplyr::filter(stringr::str_detect(tipo, "^Casa")) %>%
  dplyr::select(preciom = preciom_w, areaconst = areaconst_w, estrato, banios, habitaciones) %>%
  tidyr::drop_na()

corr_mat <- round(stats::cor(casas_df, use="pairwise.complete.obs"), 3)
kableExtra::kable(corr_mat, caption="Matriz de correlaciones (Casas)") %>% kableExtra::kable_styling()
Matriz de correlaciones (Casas)
preciom areaconst estrato banios habitaciones
preciom 1.000 0.691 0.673 0.563 0.098
areaconst 0.691 1.000 0.400 0.533 0.317
estrato 0.673 0.400 1.000 0.449 -0.114
banios 0.563 0.533 0.449 1.000 0.476
habitaciones 0.098 0.317 -0.114 0.476 1.000
res_zona <- viv_clean %>%
  dplyr::group_by(zona) %>%
  dplyr::summarise(
    n = dplyr::n(),
    med_precio = median(preciom_w, na.rm=TRUE),
    med_area   = median(areaconst_w, na.rm=TRUE),
    med_piso_apto = median(piso_num[stringr::str_detect(tipo,"^Apart")], na.rm=TRUE)
  ) %>% dplyr::arrange(desc(n))

kableExtra::kable(res_zona, caption="Resumen por Zona (medianas wins + piso aptos)") %>% kableExtra::kable_styling()
Resumen por Zona (medianas wins + piso aptos)
zona n med_precio med_area med_piso_apto
Zona Sur 4726 320 113.0 4
Zona Norte 1920 300 107.0 4
Zona Oeste 1198 580 165.5 5
Zona Oriente 351 210 160.0 2
Zona Centro 124 297 160.0 4

7.2 3.3 Plotly interactivo (scatter, boxplot, matriz)

p1 <- ggplot2::ggplot(viv_clean %>% dplyr::filter(stringr::str_detect(tipo,"^Casa")), 
             ggplot2::aes(x=areaconst_w, y=preciom_w, color=factor(estrato))) +
  ggplot2::geom_point(alpha=.7) + ggplot2::geom_smooth(method="lm", se=FALSE) +
  ggplot2::labs(title="Precio (M) vs Área – Casas (color: Estrato)", x="Área (m², wins)", y="Precio (M, wins)", color="Estrato")
plotly::ggplotly(p1)
p2 <- ggplot2::ggplot(viv_clean %>% dplyr::filter(stringr::str_detect(tipo,"^Casa")),
             ggplot2::aes(x=banios, y=preciom_w, color=factor(estrato))) +
  ggplot2::geom_point(alpha=.7, position=ggplot2::position_jitter(width=.1, height=0)) + ggplot2::geom_smooth(method="lm", se=FALSE) +
  ggplot2::labs(title="Precio (M) vs Nº Baños – Casas", x="Baños", y="Precio (M, wins)", color="Estrato")
plotly::ggplotly(p2)
p3 <- ggplot2::ggplot(viv_clean %>% dplyr::filter(stringr::str_detect(tipo,"^Casa")),
             ggplot2::aes(x=habitaciones, y=preciom_w, color=factor(estrato))) +
  ggplot2::geom_point(alpha=.7, position=ggplot2::position_jitter(width=.1, height=0)) + ggplot2::geom_smooth(method="lm", se=FALSE) +
  ggplot2::labs(title="Precio (M) vs Nº Habitaciones – Casas", x="Habitaciones", y="Precio (M, wins)", color="Estrato")
plotly::ggplotly(p3)
p4 <- ggplot2::ggplot(viv_clean %>% dplyr::filter(stringr::str_detect(tipo,"^Casa")),
             ggplot2::aes(x=zona, y=preciom_w, fill=zona)) +
  ggplot2::geom_boxplot(outlier.alpha=.5) +
  ggplot2::labs(title="Precio (M, wins) por ZONA – Casas", x="Zona", y="Precio (M, wins)") +
  ggplot2::theme(legend.position = "none")
plotly::ggplotly(p4)
casas_plotly <- viv_clean %>%
  dplyr::filter(stringr::str_detect(tipo, "^Casa")) %>%
  dplyr::select(preciom = preciom_w, areaconst = areaconst_w, estrato, banios, habitaciones) %>%
  tidyr::drop_na()

plotly::plot_ly(type = "splom",
  dimensions = list(
    list(label="Precio (M)",    values = casas_plotly$preciom),
    list(label="Área (m²)",     values = casas_plotly$areaconst),
    list(label="Estrato",       values = casas_plotly$estrato),
    list(label="Baños",         values = casas_plotly$banios),
    list(label="Habitaciones",  values = casas_plotly$habitaciones)
  ),
  text = ~paste("Estrato:", casas_plotly$estrato),
  showupperhalf = FALSE, diagonal = list(visible = TRUE)
)

8 4. Punto 1 – base1 (Casa, Zona Norte) + verificación + mapa + PDF

base1 <- viv_clean %>%
  dplyr::filter(stringr::str_detect(tipo, "^Casa"), stringr::str_detect(zona, "Norte")) %>%
  dplyr::filter(!is.na(preciom_w), !is.na(areaconst_w))

base1 %>% dplyr::select(tipo, zona, preciom_w, areaconst_w, estrato, banios, habitaciones, parqueaderos, barrio, piso_num) %>%
  utils::head(3) %>% kableExtra::kable("html", caption="Primeros 3 registros – base1 (Casa, Zona Norte)") %>% kableExtra::kable_styling()
Primeros 3 registros – base1 (Casa, Zona Norte)
tipo zona preciom_w areaconst_w estrato banios habitaciones parqueaderos barrio piso_num
Casa Zona Norte 400 212 5 2 4 2 Santa Mónica Residencial 1
Casa Zona Norte 175 130 3 3 4 1 Brisas De Los 1
Casa Zona Norte 265 162 4 3 4 1 Zona Norte 2
base1 %>% dplyr::count(tipo, zona, name="n") %>% dplyr::arrange(dplyr::desc(n)) %>%
  kableExtra::kable("html", caption="Conteo por tipo y zona – base1") %>% kableExtra::kable_styling()
Conteo por tipo y zona – base1
tipo zona n
Casa Zona Norte 722
df_b1 <- base1 %>%
  dplyr::mutate(.lng = as.numeric(longitud), .lat = as.numeric(latitud), .val = preciom_w) %>%
  dplyr::filter(!is.na(.lng), !is.na(.lat))
stopifnot("No hay coordenadas válidas en base1." = nrow(df_b1) > 0)

pal_b1 <- leaflet::colorNumeric("viridis", domain = df_b1$.val, na.color = "transparent")
leaflet::leaflet(df_b1) %>% leaflet::addTiles() %>%
  leaflet::addCircleMarkers(lng=~.lng, lat=~.lat, radius=5, color=~pal_b1(.val),
                   stroke=FALSE, fillOpacity=0.85,
                   popup=~paste0("<b>Precio:</b> ", round(.val,1), " M<br>",
                                 "<b>Área:</b> ", round(areaconst_w,0), " m²<br>",
                                 "<b>Estrato:</b> ", estrato, "<br>",
                                 "<b>Piso:</b> ", piso_num, "<br>",
                                 "<b>Baños:</b> ", banios, " · <b>Habitaciones:</b> ", habitaciones)) %>%
  leaflet::addLegend("bottomright", pal=pal_b1, values=~.val, title="Precio (M)")
sf_b1 <- sf::st_as_sf(base1, coords = c("longitud","latitud"), crs = 4326, remove = FALSE)
tmap::tmap_mode("plot")
mapa_tm <- tmap::tm_shape(sf_b1) +
  tmap::tm_dots(col="preciom_w", palette="viridis", size=0.40, alpha=0.8, title="Precio (M)") +
  tmap::tm_layout(title="Casas – Zona Norte (precio en millones, wins)", legend.outside=TRUE)
tmap::tmap_save(mapa_tm, "mapa.pdf", width=8.27, height=11.69, units="in")

9 5. Modelado con balanceo y evaluación

Nota: el enunciado del modelo no incluye piso. Lo imputamos y usamos en EDA y mapas; el modelo se mantiene según rúbrica: área, estrato, habitaciones, parqueaderos, baños.

model_df <- viv_clean %>%
  dplyr::transmute(
    preciom = preciom_w,
    areaconst = areaconst_w,
    estrato, habitaciones, parqueaderos, banios
  ) %>%
  tidyr::drop_na(preciom, areaconst, estrato, habitaciones, parqueaderos, banios)

set.seed(1234)
split <- rsample::initial_split(model_df, prop = 0.7, strata = estrato)
train <- rsample::training(split); test <- rsample::testing(split)

# Pesos inversos por frecuencia de estrato (balanceo; media = 1)
w_tbl <- train %>% dplyr::count(estrato, name="n") %>% dplyr::mutate(w = 1/n)
train <- train %>% dplyr::left_join(w_tbl, by="estrato") %>% dplyr::mutate(w = w/mean(w))

mod <- stats::lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
                 data = train, weights = w)

kableExtra::kable(broom::tidy(mod), digits=4, caption="Coeficientes del modelo WLS (train con balanceo)") %>% kableExtra::kable_styling()
Coeficientes del modelo WLS (train con balanceo)
term estimate std.error statistic p.value
(Intercept) -320.8932 11.8987 -26.9687 0
areaconst 1.0479 0.0253 41.4812 0
estrato 89.2918 2.5352 35.2211 0
habitaciones -24.4576 2.0456 -11.9565 0
parqueaderos 65.3667 2.7011 24.2003 0
banios 46.1453 2.4970 18.4803 0
kableExtra::kable(broom::glance(mod), digits=4, caption="Métricas del ajuste (train)") %>% kableExtra::kable_styling()
Métricas del ajuste (train)
r.squared adj.r.squared sigma statistic p.value df logLik AIC BIC deviance df.residual nobs
0.7584 0.7582 161.3758 3650.755 0 5 -37921.17 75856.35 75903.03 151435002 5815 5821
pred_test <- stats::predict(mod, newdata = test)
eval_tbl <- tibble::tibble(truth = test$preciom, estimate = pred_test) %>% yardstick::metrics(truth, estimate)
kableExtra::kable(eval_tbl, digits=3, caption="Desempeño en test (holdout 30%)") %>% kableExtra::kable_styling()
Desempeño en test (holdout 30%)
.metric .estimator .estimate
rmse standard 164.031
rsq standard 0.744
mae standard 106.753
test_preds <- test %>%
  dplyr::mutate(
    pred   = pred_test,
    error  = pred - preciom,
    abs_err= abs(error),
    ape    = abs_err / pmax(preciom, 1e-9)
  )

kableExtra::kable(test_preds %>%
        dplyr::select(preciom_real = preciom, prediccion = pred, error, abs_err) %>%
        utils::head(10) %>%
        dplyr::mutate(dplyr::across(dplyr::everything(), ~round(.x, 2))),
      caption = "Predicciones en set de prueba (muestra de 10)") %>% kableExtra::kable_styling()
Predicciones en set de prueba (muestra de 10)
preciom_real prediccion error abs_err
250 299.97 49.97 49.97
1280 993.47 -286.53 286.53
1300 1305.78 5.78 5.78
513 600.00 87.00 87.00
220 417.77 197.77 197.77
200 284.25 84.25 84.25
110 195.43 85.43 85.43
95 86.41 -8.59 8.59
580 654.89 74.89 74.89
480 487.47 7.47 7.47
plotly::plot_ly(test_preds, x = ~pred, y = ~preciom, type = "scatter", mode = "markers",
                text = ~paste("Error:", round(error,2), " M")) %>%
  plotly::add_lines(x = range(test_preds$pred), y = range(test_preds$pred), inherit = FALSE) %>%
  plotly::layout(title = "Predicho vs Real (test)", xaxis = list(title="Predicho (M)"), yaxis = list(title="Real (M)"))

9.0.1 Indicadores + CV 10-fold (con balanceo por fold)

mape_val <- yardstick::mape_vec(truth = test_preds$preciom, estimate = test_preds$pred)
kableExtra::kable(tibble::tibble(Metric = "MAPE", Value = round(mape_val, 4)), caption = "Indicador adicional: MAPE en test") %>% kableExtra::kable_styling()
Indicador adicional: MAPE en test
Metric Value
MAPE 26.9332
set.seed(123)
cv <- rsample::vfold_cv(model_df, v = 10, strata = estrato)

cv_metrics <- purrr::map_dfr(cv$splits, function(s){
  tr <- rsample::analysis(s)
  vl <- rsample::assessment(s)
  w_loc <- tr %>% dplyr::count(estrato, name="n") %>% dplyr::mutate(w = 1/n)
  tr <- tr %>% dplyr::left_join(w_loc, by="estrato") %>% dplyr::mutate(w = w/mean(w))
  fit <- stats::lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios, data = tr, weights = w)
  preds <- stats::predict(fit, newdata = vl)
  tibble::tibble(
    RMSE = yardstick::rmse_vec(truth = vl$preciom, estimate = preds),
    MAE  = yardstick::mae_vec (truth = vl$preciom, estimate = preds),
    R2   = yardstick::rsq_vec (truth = vl$preciom, estimate = preds),
    MAPE = yardstick::mape_vec(truth = vl$preciom, estimate = preds)
  )
})

resumen_cv <- cv_metrics %>%
  dplyr::summarise(dplyr::across(dplyr::everything(),
                   list(mean = ~mean(.x, na.rm=TRUE),
                        sd   = ~sd(.x, na.rm=TRUE)))) %>%
  tidyr::pivot_longer(dplyr::everything(), names_to = c("metric","stat"), names_sep = "_") %>%
  tidyr::pivot_wider(names_from = stat, values_from = value) %>%
  dplyr::mutate(dplyr::across(c(mean,sd), ~round(.x, 4)))

kableExtra::kable(resumen_cv, caption = "Validación cruzada 10-fold con balanceo (media ± sd)") %>% kableExtra::kable_styling()
Validación cruzada 10-fold con balanceo (media ± sd)
metric mean sd
RMSE 162.7145 8.3617
MAE 106.5445 4.0291
R2 0.7457 0.0266
MAPE 27.0543 0.8638

10 6. Predicción y recomendaciones – Vivienda 1 (Casa, Norte, tope 350M)

new1_e4 <- tibble::tibble(areaconst=200, estrato=4, habitaciones=4, parqueaderos=1, banios=2)
new1_e5 <- tibble::tibble(areaconst=200, estrato=5, habitaciones=4, parqueaderos=1, banios=2)

pred1_e4 <- stats::predict(mod, newdata=new1_e4, interval="prediction", level=0.95) %>% tibble::as_tibble()
pred1_e5 <- stats::predict(mod, newdata=new1_e5, interval="prediction", level=0.95) %>% tibble::as_tibble()

kableExtra::kable(dplyr::bind_rows(
  dplyr::bind_cols(estrato=4, new1_e4, pred1_e4),
  dplyr::bind_cols(estrato=5, new1_e5, pred1_e5)
) %>% dplyr::mutate(dplyr::across(where(is.numeric), ~round(.x,1))),
caption="Predicción Vivienda 1 (M) para estrato 4 y 5 – IC95%") %>% kableExtra::kable_styling()
Predicción Vivienda 1 (M) para estrato 4 y 5 – IC95%
estrato…1 areaconst estrato…3 habitaciones parqueaderos banios fit lwr upr
4 200 4 4 1 2 305.7 -10.8 622.1
5 200 5 4 1 2 395.0 78.5 711.5
cand1 <- viv_clean %>%
  dplyr::filter(stringr::str_detect(tipo,"^Casa"), stringr::str_detect(zona,"Norte")) %>%
  dplyr::select(preciom=preciom_w, areaconst=areaconst_w, estrato, habitaciones, parqueaderos, banios, latitud, longitud, barrio, piso_num) %>%
  tidyr::drop_na(areaconst, estrato, habitaciones, banios) %>% dplyr::distinct()

cand1$pred <- stats::predict(mod, newdata = cand1 %>% dplyr::select(areaconst, estrato, habitaciones, parqueaderos, banios))
cand1_fil <- cand1 %>% dplyr::filter(pred <= 350 | preciom <= 350)

target1 <- c(areaconst=200, estrato=4.5, habitaciones=4, parqueaderos=1, banios=2)
features <- names(target1)
iqr_vec <- sapply(cand1_fil %>% dplyr::select(dplyr::all_of(features)), stats::IQR, na.rm = TRUE)
score_fun <- function(x) sum(abs(x - target1) / (iqr_vec + 1e-9))
cand1_fil$score <- apply(as.matrix(cand1_fil %>% dplyr::select(dplyr::all_of(features))), 1, score_fun)

ofertas1 <- cand1_fil %>% dplyr::arrange(score) %>% utils::head(5)
kableExtra::kable(ofertas1 %>% dplyr::select(preciom, pred, dplyr::all_of(features), barrio, piso_num) %>%
        dplyr::mutate(dplyr::across(c(preciom,pred), ~paste0(round(.x,1)," M"))),
      caption="Top 5 ofertas sugeridas – Vivienda 1 (≤350M)") %>% kableExtra::kable_styling()
Top 5 ofertas sugeridas – Vivienda 1 (≤350M)
preciom pred areaconst estrato habitaciones parqueaderos banios barrio piso_num
275 M 221.8 M 120 4 4 1 2 Alamos 2
270 M 212.2 M 196 3 4 1 2 Calima 1
350 M 477.1 M 216 5 4 2 2 La Merced 1
300 M 241.5 M 224 3 4 1 2 Salomia 1
290 M 184.9 M 170 3 4 1 2 Paseo De Los 2
df_o1 <- ofertas1 %>% dplyr::filter(!is.na(latitud), !is.na(longitud)) %>%
  dplyr::mutate(.lng=as.numeric(longitud), .lat=as.numeric(latitud), .val=pred)
stopifnot("No hay coordenadas válidas en las 5 ofertas (Sol.1)." = nrow(df_o1) > 0)

pal_o1 <- leaflet::colorNumeric("YlOrRd", domain=df_o1$.val)
leaflet::leaflet(df_o1) %>% leaflet::addTiles() %>%
  leaflet::addCircleMarkers(lng=~.lng, lat=~.lat, radius=7,
                   color=~pal_o1(.val), stroke=TRUE, weight=1, fillOpacity=0.9,
                   popup=~paste0(
                     "<b>Pred:</b> ", round(.val,1), " M",
                     "<br><b>Listado:</b> ", round(preciom,1), " M",
                     "<br><b>Área:</b> ", areaconst, " m²",
                     "<br><b>Estrato:</b> ", estrato,
                     "<br><b>Piso:</b> ", piso_num,
                     "<br><b>Hab:</b> ", habitaciones,
                     "<br><b>Baños:</b> ", banios,
                     "<br><b>Parq:</b> ", parqueaderos,
                     "<br><b>Barrio:</b> ", barrio
                   )) %>%
  leaflet::addLegend("bottomright", pal=pal_o1, values=~.val, title="Pred (M)")

11 7. Repetición – Vivienda 2 (Apartamento, Sur, tope 850M)

new2_e5 <- tibble::tibble(areaconst=300, estrato=5, habitaciones=5, parqueaderos=3, banios=3)
new2_e6 <- tibble::tibble(areaconst=300, estrato=6, habitaciones=5, parqueaderos=3, banios=3)

pred2_e5 <- stats::predict(mod, newdata=new2_e5, interval="prediction", level=0.95) %>% tibble::as_tibble()
pred2_e6 <- stats::predict(mod, newdata=new2_e6, interval="prediction", level=0.95) %>% tibble::as_tibble()

kableExtra::kable(dplyr::bind_rows(
  dplyr::bind_cols(estrato=5, new2_e5, pred2_e5),
  dplyr::bind_cols(estrato=6, new2_e6, pred2_e6)
) %>% dplyr::mutate(dplyr::across(where(is.numeric), ~round(.x,1))),
caption="Predicción Vivienda 2 (M) para estrato 5 y 6 – IC95%") %>% kableExtra::kable_styling()
Predicción Vivienda 2 (M) para estrato 5 y 6 – IC95%
estrato…1 areaconst estrato…3 habitaciones parqueaderos banios fit lwr upr
5 300 5 5 3 3 652.2 335.7 968.7
6 300 6 5 3 3 741.5 424.9 1058.1
cand2 <- viv_clean %>%
  dplyr::filter(stringr::str_detect(tipo,"^Apart"), stringr::str_detect(zona,"Sur")) %>%
  dplyr::select(preciom=preciom_w, areaconst=areaconst_w, estrato, habitaciones, parqueaderos, banios, latitud, longitud, barrio, zona, piso_num) %>%
  tidyr::drop_na(areaconst, estrato, habitaciones, banios) %>% dplyr::distinct()

cand2$pred <- stats::predict(mod, newdata = cand2 %>% dplyr::select(areaconst, estrato, habitaciones, parqueaderos, banios))
cand2_fil <- cand2 %>% dplyr::filter(pred <= 850 | preciom <= 850)

target2 <- c(areaconst=300, estrato=5.5, habitaciones=5, parqueaderos=3, banios=3)
features <- names(target2)
iqr_vec2 <- sapply(cand2_fil %>% dplyr::select(dplyr::all_of(features)), stats::IQR, na.rm = TRUE)
score_fun2 <- function(x) sum(abs(x - target2) / (iqr_vec2 + 1e-9))
cand2_fil$score <- apply(as.matrix(cand2_fil %>% dplyr::select(dplyr::all_of(features))), 1, score_fun2)

ofertas2 <- cand2_fil %>% dplyr::arrange(score) %>% utils::head(5)
kableExtra::kable(ofertas2 %>% dplyr::select(zona, preciom, pred, dplyr::all_of(features), barrio, piso_num) %>%
        dplyr::mutate(dplyr::across(c(preciom,pred), ~paste0(round(.x,1)," M"))),
      caption="Top 5 ofertas sugeridas – Vivienda 2 (≤850M)") %>% kableExtra::kable_styling()
Top 5 ofertas sugeridas – Vivienda 2 (≤850M)
zona preciom pred areaconst estrato habitaciones parqueaderos banios barrio piso_num
Zona Sur 350 M 588.9 M 258 5 5 2 4 San Fernando 4
Zona Sur 530 M 698.4 M 256 5 5 3 5 Seminario 4
Zona Sur 700 M 669.9 M 250 6 5 2 4 El Ingenio 5
Zona Sur 650 M 652.9 M 275 5 5 2 5 Ciudadela Pasoancho 12
Zona Sur 420 M 595.3 M 220 5 5 2 5 Cuarto De Legua 2
df_o2 <- ofertas2 %>% dplyr::filter(!is.na(latitud), !is.na(longitud)) %>%
  dplyr::mutate(.lng=as.numeric(longitud), .lat=as.numeric(latitud), .val=pred)
stopifnot("No hay coordenadas válidas en las 5 ofertas (Sol.2)." = nrow(df_o2) > 0)

pal_o2 <- leaflet::colorNumeric("PuBuGn", domain=df_o2$.val)
leaflet::leaflet(df_o2) %>% leaflet::addTiles() %>%
  leaflet::addCircleMarkers(lng=~.lng, lat=~.lat, radius=7,
                   color=~pal_o2(.val), stroke=TRUE, weight=1, fillOpacity=0.9,
                   popup=~paste0(
                     "<b>Pred:</b> ", round(.val,1), " M",
                     "<br><b>Listado:</b> ", round(preciom,1), " M",
                     "<br><b>Área:</b> ", areaconst, " m²",
                     "<br><b>Estrato:</b> ", estrato,
                     "<br><b>Piso:</b> ", piso_num,
                     "<br><b>Hab:</b> ", habitaciones,
                     "<br><b>Baños:</b> ", banios,
                     "<br><b>Parq:</b> ", parqueaderos,
                     "<br><b>Barrio:</b> ", barrio,
                     "<br><b>Zona:</b> ", zona
                   )) %>%
  leaflet::addLegend("bottomright", pal=pal_o2, values=~.val, title="Pred (M)")

12 8. resumen

13 9. hallazgos claves

Geoespacial:

Oportunidades:

14 10. Resultados

Hay oferta suficiente en clústeres específicos de la Zona Norte.

Recomendación: priorizar inmuebles dentro del rango de área objetivo y con 2 baños; buscar listados con diferencia positiva “estimado–listado” y programar visitas.

Negociación: cuando el listado supere el estimado, anclar oferta en el modelo y en comparables recientes del mismo clúster.

Oferta más dispersa; conviene filtrar por proyectos y año de construcción.

Recomendación: focalizar en subzonas con densidad de puntos y precios consistentes; validar amenidades de edificio (portería, parqueaderos, zonas comunes) que explican las diferencias.

Negociación/validación: revisar cuotas de administración y estado físico (mantenimiento) que impactan el valor efectivo.