# ---- 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))
preciom (millones) y
recomendar ≥5 ofertas para cada caso, con mapas.piso), balancear si aplica y
eliminar filas sin lat/long (no se imputan).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…
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))
)
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()
| Registros_iniciales | Registros_sin_duplicar | Eliminados |
|---|---|---|
| 8322 | 8320 | 2 |
piso y
eliminar filas sin lat/longCriterio 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()
| variable | n_na |
|---|---|
| preciom | 0 |
| areaconst | 0 |
| estrato | 0 |
| banios | 0 |
| habitaciones | 0 |
| parqueaderos | 0 |
| piso_num | 0 |
| latitud | 0 |
| longitud | 0 |
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
)
skimr::skim(viv_clean)
| 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
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).
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.")
}
}
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.")
}
}
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()
| 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()
| 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 |
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)
)
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()
| 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()
| 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")
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()
| 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()
| 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()
| .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()
| 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)"))
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()
| 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()
| metric | mean | sd |
|---|---|---|
| RMSE | 162.7145 | 8.3617 |
| MAE | 106.5445 | 4.0291 |
| R2 | 0.7457 | 0.0266 |
| MAPE | 27.0543 | 0.8638 |
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()
| 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()
| 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)")
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()
| 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()
| 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)")
Se depuró la base (duplicados, faltantes, estandarización de variables) y se corrigieron las zonas geográficas para que coincidan con la ubicación real de los inmuebles.
Tras el tratamiento de atípicos (winsorización), el mercado muestra una distribución de precios más estable, lo que permite comparar ofertas sin sesgo por extremos.
Los mapas por zona evidencian clústeres de oferta bien definidos; la Zona Norte presenta alta concentración de casas en corredores específicos y la Zona Sur mayor heterogeneidad espacial.
En el análisis multivariable, área construida y estrato son los principales impulsores del precio; baños y habitaciones aportan pero con rendimientos decrecientes.
La comparación precio estimado vs. precio listado identifica oportunidades de negociación (sobreprecio) y listados atractivos (subprecio).
Distribución de precios: tras winsorizar, el mercado se concentra en un rango “medio” con cola larga hacia valores altos (asimetría positiva típica en vivienda).
Relación precio–área: clara pendiente positiva; a mayor área, mayor precio, con dispersión mayor en inmuebles grandes (efecto calidad/ubicación).
Estrato: incrementos de precio por salto de estrato; el efecto marginal del estrato supera al de sumar un baño/habitación en muchos casos.
Baños/Habitaciones: agregan valor pero el retorno marginal se reduce a partir de ciertos umbrales (p.ej., pasar de 1→2 baños pesa más que 3→4).
Geoespacial:
Zona Norte (casas): clústeres definidos cerca de ejes viales y barrios consolidados; el mapa muestra puntos densos y precios relativamente consistentes dentro de cada clúster.
Zona Sur (apartamentos/casas): mayor variabilidad entre barrios (mezcla de proyectos nuevos y oferta usada), lo que exige revisar micro-localización calle a calle.
Oportunidades:
Listados con precio listado > estimado: candidatos a negociación (establecer umbral operativo, p.ej. 10–15%).
Listados con precio listado < estimado: prioridad de visita; validar rápidamente estado, acabados y posibles vicios ocultos.
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.