1 Enunciado

María (C&A - Casas y Apartamentos) recibe la solicitud para ubicar dos viviendas:

Características de las viviendas comparadas
Características Vivienda.1 Vivienda.2
Tipo Casa Apartamento
Área construida (m²) 200 300
Parqueaderos 1 3
Baños 2 3
Habitaciones 4 5
Estrato 4 o 5 5 o 6
Zona Norte Sur
Crédito (M COP) 350 850

Objetivo general. Explicar y predecir precio (millones, preciom) con regresión lineal múltiple, siguiendo un flujo reproducible:

preparación y verificación geo–zona;

EDA interactivo (plotly);

modelos con variables numéricas clave;

validación en TEST (R² adj, RMSE, MAE) y supuestos;

predicciones para ambos casos;

selección de ofertas factibles con mapas.

2 Librerías y datos

set.seed(2025)

# --- Asegurar pipe %>% y verbos base ---
if (!requireNamespace("magrittr", quietly = TRUE)) {
  install.packages("magrittr", repos = "https://cloud.r-project.org")
}
if (!requireNamespace("dplyr", quietly = TRUE)) {
  install.packages("dplyr", repos = "https://cloud.r-project.org")
}
if (!requireNamespace("janitor", quietly = TRUE)) {
  install.packages("janitor", repos = "https://cloud.r-project.org")
}
suppressPackageStartupMessages({
  library(magrittr)  # define %>%
  library(dplyr)
  library(janitor)
})

# --- Flags para gráficos interactivos y extras (no fallar si no están) ---
has_plotly  <- requireNamespace("plotly",  quietly = TRUE)
has_leaflet <- requireNamespace("leaflet", quietly = TRUE)
has_car     <- requireNamespace("car",     quietly = TRUE)
has_diag    <- requireNamespace("DiagrammeR", quietly = TRUE)
has_metrics <- requireNamespace("Metrics", quietly = TRUE)
if (has_plotly)  suppressPackageStartupMessages(library(plotly))
if (has_leaflet) suppressPackageStartupMessages(library(leaflet))
if (has_car)     suppressPackageStartupMessages(library(car))
if (has_diag)    suppressPackageStartupMessages(library(DiagrammeR))
if (has_metrics) suppressPackageStartupMessages(library(Metrics))
if (requireNamespace("skimr", quietly = TRUE)) {
  suppressPackageStartupMessages(library(skimr))
}
if (requireNamespace("naniar", quietly = TRUE)) {
  suppressPackageStartupMessages(library(naniar))
}
if (requireNamespace("broom", quietly = TRUE)) {
  suppressPackageStartupMessages(library(broom))
}

# --- Carga de datos (prioriza paqueteMODELOS desde GitHub; respaldo CSV) ---
if (!requireNamespace("paqueteMODELOS", quietly = TRUE)) {
  if (!requireNamespace("devtools", quietly = TRUE)) {
    install.packages("devtools", repos = "https://cloud.r-project.org")
  }
  options(timeout = max(600, getOption("timeout", 60)))
  # Intento silencioso (si no hay internet, seguirá al CSV)
  try(
    devtools::install_github("centromagis/paqueteMODELOS",
                             dependencies = TRUE, force = TRUE, upgrade = "never"),
    silent = TRUE
  )
}

if (requireNamespace("paqueteMODELOS", quietly = TRUE)) {
  suppressPackageStartupMessages(library(paqueteMODELOS))
  try(data("vivienda", package = "paqueteMODELOS"), silent = TRUE)
}

if (!exists("vivienda")) {
  message("Usando respaldo local: 'vivienda.csv'")
  vivienda <- read.csv("vivienda.csv", stringsAsFactors = FALSE, encoding = "UTF-8")
}

# --- Limpieza de nombres y tipificación robusta ---
raw <- vivienda %>% janitor::clean_names()

datos <- raw %>%
  mutate(
    preciom      = suppressWarnings(as.numeric(preciom)),
    areaconst    = suppressWarnings(as.numeric(areaconst)),
    parqueaderos = suppressWarnings(as.numeric(parqueaderos)),
    banios       = suppressWarnings(as.numeric(banios)),
    habitaciones = suppressWarnings(as.numeric(habitaciones)),
    estrato      = suppressWarnings(as.numeric(estrato)),
    longitud     = suppressWarnings(as.numeric(longitud)),
    latitud      = suppressWarnings(as.numeric(latitud)),
    zona         = as.character(zona),
    tipo         = as.character(tipo),
    barrio       = as.character(barrio)
  ) %>%
  distinct()

# Subconjunto con coordenadas válidas
datos_geo <- datos %>% filter(is.finite(longitud), is.finite(latitud))

# Métricas auxiliares (por si no está Metrics)
if (!has_metrics) {
  rmse <- function(y, yhat) sqrt(mean((y - yhat)^2))
  mae  <- function(y, yhat) mean(abs(y - yhat))
}

# Chequeos rápidos
print(utils::head(datos, 3))
## # A tibble: 3 × 13
##      id zona    piso  estrato preciom areaconst parqueaderos banios habitaciones
##   <dbl> <chr>   <chr>   <dbl>   <dbl>     <dbl>        <dbl>  <dbl>        <dbl>
## 1  1147 Zona O… <NA>        3     250        70            1      3            6
## 2  1169 Zona O… <NA>        3     320       120            1      2            3
## 3  1350 Zona O… <NA>        3     350       220            2      2            4
## # ℹ 4 more variables: tipo <chr>, barrio <chr>, longitud <dbl>, latitud <dbl>
if (exists("skim")) skim(datos) else print(summary(datos))
Data summary
Name datos
Number of rows 8321
Number of columns 13
_______________________
Column type frequency:
character 4
numeric 9
________________________
Group variables None

Variable type: character

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

Variable type: numeric

skim_variable n_missing complete_rate mean sd p0 p25 p50 p75 p100 hist
id 2 1.00 4160.00 2401.63 1.00 2080.50 4160.00 6239.50 8319.00 ▇▇▇▇▇
estrato 2 1.00 4.63 1.03 3.00 4.00 5.00 5.00 6.00 ▅▆▁▇▆
preciom 1 1.00 433.89 328.65 58.00 220.00 330.00 540.00 1999.00 ▇▂▁▁▁
areaconst 2 1.00 174.93 142.96 30.00 80.00 123.00 229.00 1745.00 ▇▁▁▁▁
parqueaderos 1604 0.81 1.84 1.12 1.00 1.00 2.00 2.00 10.00 ▇▁▁▁▁
banios 2 1.00 3.11 1.43 0.00 2.00 3.00 4.00 10.00 ▇▇▃▁▁
habitaciones 2 1.00 3.61 1.46 0.00 3.00 3.00 4.00 10.00 ▂▇▂▁▁
longitud 2 1.00 -76.53 0.02 -76.59 -76.54 -76.53 -76.52 -76.46 ▁▅▇▂▁
latitud 2 1.00 3.42 0.04 3.33 3.38 3.42 3.45 3.50 ▃▇▅▇▅

Las variables categóricas presentan alta completitud salvo piso (NA≈32%). Dado su carácter textual y la ausencia concentrada en casas, se opta por no incluir piso en el modelo principal. La variable zona casi no tiene faltantes, pero su etiqueta es inconsistente con las coordenadas; por ello se reemplaza por zona_geo obtenida vía clustering geográfico. tipo (Casa/Apartamento) se usa como factor, mientras que barrio (436 niveles) se reserva para visualización y agregaciones, evitando sobreparametrización en el modelo

Diagnóstico de datos. La base presenta alta completitud (>98%), salvo parqueaderos con ~19% NA. Precio (mediana ≈ 330 M, máx ≈ 1.999 M) y área (mediana ≈ 123 m², máx ≈ 1.745 m²) muestran asimetría positiva y outliers, por lo que se anticipa heterocedasticidad; se contrasta el modelo base con una especificación log(preciom) y/o errores robustos. Estrato (3–6) se trata como factor ordenado. Se detectan registros con 0 baños/habitaciones (probables errores), que se tratan como faltantes. Las coordenadas (≈ −76.53, 3.42) son coherentes con Cali y permiten re-etiquetar Norte/Sur mediante clustering Haversine (K=2) ante ruido en la zona declarada. Dado el 32% NA en piso y la alta cardinalidad de barrio (436 niveles), ambas variables se excluyen del modelo principal (se usan para mapas/anexos). En consecuencia, el modelo base emplea área, estrato, baños, habitaciones y parqueaderos (imputado por mediana por tipo × zona_geo), con validación 70/30 y reporte de R² ajustado, RMSE y MAE.

3 Preparación y tipificación

datos <- raw %>%
  mutate(
    # Forzar tipos numéricos robustamente
    preciom      = suppressWarnings(as.numeric(preciom)),
    areaconst    = suppressWarnings(as.numeric(areaconst)),
    parqueaderos = suppressWarnings(as.numeric(parqueaderos)),
    banios       = suppressWarnings(as.numeric(banios)),
    habitaciones = suppressWarnings(as.numeric(habitaciones)),
    estrato      = suppressWarnings(as.numeric(estrato)),
    longitud     = suppressWarnings(as.numeric(longitud)),
    latitud      = suppressWarnings(as.numeric(latitud)),
    zona         = as.character(zona),
    tipo         = as.character(tipo),
    barrio       = as.character(barrio)
  ) %>%
  distinct()

# Conjunto con coordenadas válidas
datos_geo <- datos %>% filter(!is.na(longitud), !is.na(latitud))
nrow(datos_geo)
## [1] 8319

4 El etiquetado Norte/Sur declarado es consistente?

# Subconjuntos por "zona" DECLARADA
norte_decl <- datos_geo %>% filter(zona %in% c("Zona Norte","Norte","zona norte"))
sur_decl   <- datos_geo %>% filter(zona %in% c("Zona Sur","Sur","zona sur"))

# Mapa Norte declarado
leaflet(norte_decl) %>% addTiles() %>%
  addCircleMarkers(~longitud, ~latitud, radius = 3, stroke = FALSE, fillOpacity = 0.7,
                   color = "steelblue",
                   popup = ~paste0("<b>Zona declarada:</b> ", zona,
                                   "<br><b>Barrio:</b> ", barrio,
                                   "<br><b>Precio (M):</b> ", round(preciom,1)))
# Mapa Sur declarado
leaflet(sur_decl) %>% addTiles() %>%
  addCircleMarkers(~longitud, ~latitud, radius = 3, stroke = FALSE, fillOpacity = 0.7,
                   color = "tomato",
                   popup = ~paste0("<b>Zona declarada:</b> ", zona,
                                   "<br><b>Barrio:</b> ", barrio,
                                   "<br><b>Precio (M):</b> ", round(preciom,1)))

Descripción de las Figuras 1 y 2. La Figura 1 muestra las ofertas etiquetadas como “Zona Norte” (puntos azules) y la Figura 2 las etiquetadas como “Zona Sur” (puntos naranjas). Aunque la mayor densidad de azules se concentra en el norte y la de naranjas en el sur, se observa una franja de puntos cruzados: registros rotulados como Norte aparecen en el sur/centro y viceversa.

La variable zona presenta inconsistencias sistemáticas respecto a la posición geográfica real (longitud/latitud). Esto sugiere que el rótulo de zona puede reflejar criterios comerciales/administrativos (o errores de captura) y no siempre la ubicación.

Si filtráramos por zona declarada para formar las bases (p. ej., “Casas – Zona Norte”), contaminaríamos los conjuntos con observaciones del lado opuesto, distorsionando el EDA, la estimación y la selección de ofertas.

Re-etiquetamos las observaciones con un criterio objetivo por coordenadas usando Weighted K-means con distancia de Haversine (K=2), obteniendo zona_geo ∈ {Zona Norte (geo), Zona Sur (geo)}. A partir de aquí, todas las segmentaciones y modelos utilizan zona_geo; zona declarada se conserva solo para contraste y control de calidad.

5 Re–segmentación geográfica: Weighted K-means (Haversine, K = 2)

# --- Haversine robusto (m) ---
haversine_m <- function(lon1, lat1, lon2, lat2){
  R <- 6371000
  to_rad <- pi/180
  lon1 <- as.numeric(lon1); lat1 <- as.numeric(lat1)
  lon2 <- as.numeric(lon2); lat2 <- as.numeric(lat2)
  dphi <- (lat2 - lat1) * to_rad
  dlmb <- (lon2 - lon1) * to_rad
  phi1 <- lat1 * to_rad; phi2 <- lat2 * to_rad
  a <- sin(dphi/2)^2 + cos(phi1)*cos(phi2)*sin(dlmb/2)^2
  2 * R * atan2(sqrt(a), sqrt(1 - a))
}

# --- K-means geográfico con salvaguardas ---
weighted_kmeans_geo <- function(df_llw, K = 2, w = NULL, nstart = 5, max_iter = 100){
  stopifnot(all(c("longitud","latitud") %in% names(df_llw)))
  df_llw <- df_llw %>%
    mutate(longitud = as.numeric(longitud),
           latitud  = as.numeric(latitud)) %>%
    filter(is.finite(longitud), is.finite(latitud))
  n <- nrow(df_llw)
  if (n < K) stop("Muy pocos puntos para el número de clusters.")

  if (is.null(w)) w <- rep(1, n)

  best <- list(sse = Inf, clus = NULL, cents = NULL)

  for (rep in seq_len(nstart)){
    set.seed(1000 + rep)
    idx    <- sample.int(n, K)
    cents  <- as.matrix(df_llw[idx, c("longitud","latitud")])
    clus_prev <- rep(0L, n)  # <- sin NAs para evitar el fallo del if

    for (it in seq_len(max_iter)){
      # Distancias (llena con Inf si algo saliera no-finito)
      dist_mat <- matrix(0, nrow = n, ncol = K)
      for (k in 1:K){
        for (i in 1:n){
          d <- haversine_m(df_llw$longitud[i], df_llw$latitud[i], cents[k,1], cents[k,2])
          if (!is.finite(d)) d <- Inf
          dist_mat[i,k] <- d
        }
      }
      clus <- max.col(-dist_mat, ties.method = "random")

      # Criterio de parada (sin NAs)
      if (isTRUE(all(clus == clus_prev))) break
      clus_prev <- clus

      # Actualización de centroides
      for (k in 1:K){
        idx_k <- which(clus == k)
        if (length(idx_k) == 0){
          # Re-inicializa centroide vacío para evitar NAs
          pick <- sample.int(n, 1)
          cents[k,] <- as.numeric(df_llw[pick, c("longitud","latitud")])
        } else {
          cents[k,1] <- weighted.mean(df_llw$longitud[idx_k], w[idx_k])
          cents[k,2] <- weighted.mean(df_llw$latitud[idx_k],  w[idx_k])
        }
      }
    }

    # SSE con Haversine (ponderado)
    sse <- 0
    for (k in 1:K){
      idx_k <- which(clus == k)
      if (length(idx_k) == 0) next
      d <- haversine_m(df_llw$longitud[idx_k], df_llw$latitud[idx_k], cents[k,1], cents[k,2])
      d[!is.finite(d)] <- 0
      sse <- sse + sum((d^2) * w[idx_k])
    }

    if (sse < best$sse) best <- list(sse = sse, clus = clus, cents = cents)
  }

  best
}

# --- Llamada (reemplaza tu llamada actual) ---
datos_geo <- datos %>% filter(is.finite(as.numeric(longitud)), is.finite(as.numeric(latitud)))
llw <- datos_geo %>% transmute(longitud = as.numeric(longitud),
                               latitud  = as.numeric(latitud),
                               peso = 1)

set.seed(2025)
km2 <- weighted_kmeans_geo(df_llw = llw, K = 2, w = llw$peso, nstart = 7, max_iter = 100)

# Etiquetado Norte/Sur por latitud de centroides
cents <- tibble(longitud = km2$cents[,1], latitud = km2$cents[,2], k = 1:2)
labs  <- ifelse(cents$latitud == max(cents$latitud), "Zona Norte (geo)", "Zona Sur (geo)")
names(labs) <- cents$k

datos_geo <- datos_geo %>%
  mutate(k_geo = km2$clus,
         zona_geo = factor(labs[as.character(k_geo)], levels = c("Zona Norte (geo)","Zona Sur (geo)")))

6 Bases de trabajo (filtro post K-means)

# 6. Bases de trabajo (filtro post K-means)

# Base 1: Casas en Norte (geo)
base1 <- datos_geo %>%
  filter(tipo == "Casa", zona_geo == "Zona Norte (geo)") %>%
  mutate(id = dplyr::row_number()) %>%             # <-- crear id aquí
  select(id, zona_geo, estrato, preciom, areaconst,
         parqueaderos, banios, habitaciones, tipo, barrio, longitud, latitud)

head(base1, 3)
## # A tibble: 3 × 12
##      id zona_geo      estrato preciom areaconst parqueaderos banios habitaciones
##   <int> <fct>           <dbl>   <dbl>     <dbl>        <dbl>  <dbl>        <dbl>
## 1     1 Zona Norte (…       3     250        70            1      3            6
## 2     2 Zona Norte (…       3     320       120            1      2            3
## 3     3 Zona Norte (…       3     350       220            2      2            4
## # ℹ 4 more variables: tipo <chr>, barrio <chr>, longitud <dbl>, latitud <dbl>
if (nrow(base1) > 0) {
  leaflet(base1) %>% addTiles() %>% 
    addMarkers(~longitud, ~latitud,
               label = ~paste0("Precio: ", round(preciom), " M"), 
               popup = ~paste0("<b>", tipo, "</b> / ", barrio,
                               "<br>Estrato: ", estrato,
                               "<br>Área: ", round(areaconst), " m²",
                               "<br>Baños: ", banios,
                               "<br>Hab: ", habitaciones,
                               "<br>Pk: ", parqueaderos))
} else {
  cat("Base1 vacía: no hay casas en Zona Norte (geo) con coordenadas válidas.\n")
}
# Base 2: Apartamentos en Sur (geo)
base2 <- datos_geo %>%
  filter(tipo == "Apartamento", zona_geo == "Zona Sur (geo)") %>%
  mutate(id = dplyr::row_number()) %>%             # <-- crear id aquí
  select(id, zona_geo, estrato, preciom, areaconst,
         parqueaderos, banios, habitaciones, tipo, barrio, longitud, latitud)

head(base2, 3)
## # A tibble: 3 × 12
##      id zona_geo      estrato preciom areaconst parqueaderos banios habitaciones
##   <int> <fct>           <dbl>   <dbl>     <dbl>        <dbl>  <dbl>        <dbl>
## 1     1 Zona Sur (ge…       5     240        87            1      3            3
## 2     2 Zona Sur (ge…       5     310       137            2      3            4
## 3     3 Zona Sur (ge…       4     320       108            2      3            3
## # ℹ 4 more variables: tipo <chr>, barrio <chr>, longitud <dbl>, latitud <dbl>
if (nrow(base2) > 0) {
  leaflet(base2) %>% addTiles() %>%
    addMarkers(~longitud, ~latitud,
               label = ~paste0("Precio: ", round(preciom), " M"), 
               popup = ~paste0("<b>", tipo, "</b> / ", barrio,
                               "<br>Estrato: ", estrato,
                               "<br>Área: ", round(areaconst), " m²",
                               "<br>Baños: ", banios,
                               "<br>Hab: ", habitaciones,
                               "<br>Pk: ", parqueaderos))
} else {
  cat("Base2 vacía: no hay apartamentos en Zona Sur (geo) con coordenadas válidas.\n")
}

Re-segmentación geográfica. Tras aplicar Weighted K-means con distancia de Haversine (K=2), obtuvimos dos grupos espaciales contiguos que denominamos Zona Norte (geo) y Zona Sur (geo). La figura muestra la concentración compacta del cluster representado, con pocos puntos aislados, lo que respalda la coherencia espacial de la partición. A partir de esta etapa utilizamos zona_geo para todos los filtros, EDA y modelos, y dejamos la zona declarada solo como referencia de calidad de dato.

# Tamaños y centroides por cluster
resumen_clusters <- datos_geo |>
  dplyr::group_by(zona_geo) |>
  dplyr::summarise(
    n = dplyr::n(),
    lon_cent = mean(longitud, na.rm = TRUE),
    lat_cent = mean(latitud,  na.rm = TRUE),
    precio_med = median(preciom, na.rm = TRUE),
    area_med   = median(areaconst, na.rm = TRUE)
  )
resumen_clusters
## # A tibble: 2 × 6
##   zona_geo             n lon_cent lat_cent precio_med area_med
##   <fct>            <int>    <dbl>    <dbl>      <dbl>    <dbl>
## 1 Zona Norte (geo)  4016    -76.5     3.46        350      135
## 2 Zona Sur (geo)    4303    -76.5     3.38        315      111
# Tabla de confusión declarada vs geo (porcentajes por fila)
df_conf <- dplyr::filter(datos_geo, !is.na(zona), !is.na(zona_geo)) |>
  dplyr::select(zona, zona_geo)
tab_conf   <- with(df_conf, table(Declarada = zona, Geo = zona_geo))
prop_filas <- round(prop.table(tab_conf, 1)*100, 1)
tab_conf; prop_filas
##               Geo
## Declarada      Zona Norte (geo) Zona Sur (geo)
##   Zona Centro               122              2
##   Zona Norte               1713            207
##   Zona Oeste               1138             60
##   Zona Oriente              273             78
##   Zona Sur                  770           3956
##               Geo
## Declarada      Zona Norte (geo) Zona Sur (geo)
##   Zona Centro              98.4            1.6
##   Zona Norte               89.2           10.8
##   Zona Oeste               95.0            5.0
##   Zona Oriente             77.8           22.2
##   Zona Sur                 16.3           83.7
# Calidad del cluster (distancia media al centroide vs distancia entre centroides)
haversine_m <- function(lon1, lat1, lon2, lat2){
  R <- 6371000; to_rad <- pi/180
  dphi <- (lat2-lat1)*to_rad; dlmb <- (lon2-lon1)*to_rad
  phi1 <- lat1*to_rad; phi2 <- lat2*to_rad
  a <- sin(dphi/2)^2 + cos(phi1)*cos(phi2)*sin(dlmb/2)^2
  2*R*atan2(sqrt(a), sqrt(1-a))
}
cents <- dplyr::summarise(dplyr::group_by(datos_geo, zona_geo),
                          lon=mean(longitud), lat=mean(latitud))
d_within <- datos_geo |>
  dplyr::left_join(cents, by="zona_geo") |>
  dplyr::mutate(d = haversine_m(longitud, latitud, lon, lat)) |>
  dplyr::group_by(zona_geo) |>
  dplyr::summarise(within_mean_m = mean(d, na.rm=TRUE))
d_between <- haversine_m(cents$lon[1], cents$lat[1], cents$lon[2], cents$lat[2])
d_within; d_between
## # A tibble: 2 × 2
##   zona_geo         within_mean_m
##   <fct>                    <dbl>
## 1 Zona Norte (geo)         2999.
## 2 Zona Sur (geo)           2401.
## [1] 8331.561

El clustering Haversine (K=2) produjo dos grupos contiguos y coherentes: Zona Norte (geo) (n=4016, mediana 350 M y 135 m²) y Zona Sur (geo) (n=4303, mediana 315 M y 111 m²). La tabla de confusión entre la zona declarada y la zona por coordenadas evidencia inconsistencias: por ejemplo, el 10.8% de las ofertas declaradas Norte caen en Sur (geo) y el 16.3% de las declaradas Sur caen en Norte (geo); en Oriente el cruce llega a 22.2%. Con base en ello, en el resto del análisis usamos zona_geo como referencia espacial y dejamos zona declarada solo para contraste y control de calidad. La compacidad intra-cluster (≈2.4–3.0 km) indica agrupaciones geográficas razonables.

# Distancia Haversine
haversine_m <- function(lon1, lat1, lon2, lat2){
  R <- 6371000; to_rad <- pi/180
  dphi <- (lat2 - lat1) * to_rad
  dlmb <- (lon2 - lon1) * to_rad
  phi1 <- lat1 * to_rad; phi2 <- lat2 * to_rad
  a <- sin(dphi/2)^2 + cos(phi1)*cos(phi2)*sin(dlmb/2)^2
  2 * R * atan2(sqrt(a), sqrt(1 - a))
}

# Centroides por zona_geo
cents <- datos_geo |>
  dplyr::group_by(zona_geo) |>
  dplyr::summarise(lon = mean(longitud), lat = mean(latitud), .groups = "drop")

# Distancia entre centroides
d_between <- haversine_m(cents$lon[1], cents$lat[1], cents$lon[2], cents$lat[2])

# Ratio separación / compacidad (usar tus within ya calculadas)
within_tbl <- datos_geo |>
  dplyr::left_join(cents, by = "zona_geo") |>
  dplyr::mutate(d = haversine_m(longitud, latitud, lon, lat)) |>
  dplyr::group_by(zona_geo) |>
  dplyr::summarise(within_mean_m = mean(d, na.rm=TRUE), .groups="drop")

d_between
## [1] 8331.561
within_tbl
## # A tibble: 2 × 2
##   zona_geo         within_mean_m
##   <fct>                    <dbl>
## 1 Zona Norte (geo)         2999.
## 2 Zona Sur (geo)           2401.
d_between / max(within_tbl$within_mean_m)  # si > 1.5–2, separación aceptable
## [1] 2.777876

Con el clustering geográfico (K-means con distancia de Haversine) obtuvimos:

Distancia entre centroides: 8.33 km. Distancia media intra-cluster (within) Zona Norte (geo): 3.00 km Zona Sur (geo): 2.40 km

Razón de separación:entre/max(within)=8.33/3.00≈2.78

La distancia entre los centros de los dos grupos es casi 3 veces la dispersión media dentro del cluster más amplio. Un cociente > 2 indica buena separación y compacidad: los puntos se agrupan coherentemente dentro de cada zona y los clusters están bien diferenciados en el espacio. Esto valida el uso de zona_geo como partición Norte/Sur para filtrar, describir y modelar, en lugar de la zona declarada (que mostró cruces).

7 EDA interactivo (plotly)

plot_ly(datos_geo, x = ~preciom, color = ~zona_geo, type = "histogram",
        histnorm = "probability density", opacity = 0.6) %>%
  layout(barmode = "overlay", title = "Distribución (densidad) de precio por zona (geo)")

Ambas zonas exhiben una asimetría positiva (cola a la derecha): la mayor masa está entre ≈150–500 M y hay pocas ofertas muy altas (>1.000 M). La Zona Norte (geo) aparece desplazada a la derecha respecto a la Zona Sur (geo) es decir, precios típicos más altos y una cola superior más pesada, consistente con los resúmenes (medianas aprox.: Norte ≈ 350 M, Sur ≈ 315 M). Aun así, hay superposición amplia entre 200–500 M, lo que indica un mercado parcialmente compartido.

datos_geo %>%
  group_by(zona_geo) %>%
  summarise(mediana = median(preciom, na.rm=TRUE),
            IQR = IQR(preciom, na.rm=TRUE))
## # A tibble: 2 × 3
##   zona_geo         mediana   IQR
##   <fct>              <dbl> <dbl>
## 1 Zona Norte (geo)     350   345
## 2 Zona Sur (geo)       315   280
# Prueba no paramétrica (diferencia de medianas)
wilcox.test(preciom ~ zona_geo, data = datos_geo)
## 
##  Wilcoxon rank sum test with continuity correction
## 
## data:  preciom by zona_geo
## W = 9034299, p-value = 0.00032
## alternative hypothesis: true location shift is not equal to 0

Diferencias de precio por zona (geo). La distribución (densidad) de preciom es asimétrica a la derecha en ambas zonas. No obstante, la Zona Norte (geo) aparece desplazada hacia valores mayores. Las medianas confirman esta diferencia: Norte ≈ 350 M vs Sur ≈ 315 M (IQR: 345 M vs 280 M, mayor dispersión en Norte). La prueba no paramétrica de Wilcoxon indica evidencia estadística fuerte de diferencia entre zonas (p = 0.00032), coherente con precios típicos más altos en el Norte. Aunque existe superposición amplia entre 200–500 M, el desplazamiento sistemático justifica incluir zona_geo como factor (o ajustar modelos por zona) y evaluar transformaciones como log(preciom) por la cola larga.

# --- EDA global rápido (color por zona_geo) ---
plot_ly(datos_geo, x = ~preciom, type = "histogram", color = ~zona_geo) %>% 
  layout(title = "Distribución de precio por zona (geo)")
plot_ly(datos_geo, x = ~areaconst, y = ~preciom, color = ~zona_geo, type = "scatter", mode = "markers") %>%
  layout(title = "Precio vs Área (color = zona_geo)")

El histograma confirma una asimetría positiva marcada en ambas zonas (colas largas hacia valores altos). La Zona Norte (geo) presenta una masa ligeramente más desplazada a la derecha (precios típicos algo más altos) y una cola superior más pesada; la Zona Sur (geo) concentra más observaciones en el rango ≈200–400 M. Aun así, hay superposición amplia entre 200–500 M, lo que indica un tramo de mercado compartido. La presencia de valores muy altos (>1.000 M) sugiere outliers y posibles problemas de heterocedasticidad, por lo que conviene contrastar el modelo base con log(preciom) (y/o errores robustos) y revisar observaciones extremas.

# --- EDA Base1 (Casa Norte_geo) ---
plot_ly(base1, x = ~preciom, type = "histogram") %>% layout(title = "Base1: Histograma de precio")
plot_ly(base1, x = ~areaconst, y = ~preciom, type = "scatter", mode = "markers") %>% layout(title = "Base1: Precio vs Área")
plot_ly(base1, x = ~estrato, y = ~preciom, type = "scatter", mode = "markers") %>% layout(title = "Base1: Precio vs Estrato")
plot_ly(base1, x = ~banios, y = ~preciom, type = "scatter", mode = "markers") %>% layout(title = "Base1: Precio vs Baños")
plot_ly(base1, x = ~habitaciones, y = ~preciom, type = "scatter", mode = "markers") %>% layout(title = "Base1: Precio vs Habitaciones")
plot_ly(base1, x = ~parqueaderos, y = ~preciom, type = "scatter", mode = "markers") %>% layout(title = "Base1: Precio vs Parqueaderos")

Precio vs Área construida. Tendencia positiva clara; se abre en abanico a medida que crece el área → heterocedasticidad (varianza creciente). Hay outliers (>1.000 m²) con precios altos que pueden tener alta influencia. Útil considerar log(preciom) (y, opcional, log(área)) o errores robustos.

Precio vs Estrato. Comportamiento escalonado (3→6): el nivel típico de precio sube por estrato, pero con gran dispersión dentro de cada grupo. Trátalo como factor ordenado en el modelo (no como numérico lineal).

Precio vs Baños. Relación creciente hasta ~6–7 baños y luego rendimientos decrecientes/meseta. Aparecen algunos 0 baños → probablemente errores de captura; recodificar 0 → NA antes de modelar.

Precio vs Habitaciones. Tendencia positiva pero ruidosa; concentración en 3–5 cuartos y posibles rendimientos decrecientes en valores altos. Esperable colinealidad con área → revisar VIF.

Precio vs Parqueaderos. Mediana de precio aumenta de 1 a 3–4 cupos y luego se aplaca. Recuerda que parqueaderos tiene ~19% NA: imputar por mediana tipo×zona_geo o filtrar al ajustar.

En el análisis preliminar se observa que las relaciones son consistentes con lo esperado: el área construida y el estrato parecen ser los factores que más influyen en el precio de la vivienda, mientras que variables como el número de baños, habitaciones o parqueaderos también aportan, aunque con efectos que tienden a saturarse y a introducir algo de ruido. Para la estimación del modelo de regresión múltiple se recomienda tratar el estrato como una variable categórica, controlar la colinealidad entre área, habitaciones y baños mediante la revisión de los VIF, y atender la posible presencia de heterocedasticidad, ya sea aplicando una transformación logarítmica al precio o utilizando errores estándar robustos. Finalmente, es importante identificar y documentar el manejo de los valores atípicos, ya que pueden tener un impacto considerable en los resultados.

plot_ly(base1, x=~areaconst, y=~preciom, type="scatter", mode="markers",
        name="datos") %>%
  add_lines(x=~areaconst,
            y=fitted(loess(preciom ~ areaconst, data=base1, span=0.6)),
            name="LOESS", inherit=FALSE)

Relación precio–área (Base1, Casa–Norte_geo). La suavización LOESS muestra una relación positiva del precio con el área hasta ~900–1.100 m² y luego una saturación/descenso. Se observa patrón en abanico (varianza creciente) y poca densidad en áreas muy grandes, por lo que el tramo descendente podría estar influido por pocos outliers/de alta influencia. Esto sugiere no linealidad y heterocedasticidad: además del modelo lineal base, evaluamos (i) una especificación log–log y (ii) flexibilidad en área (término cuadrático o splines). Reportamos métricas en TEST (RMSE, MAE) y, ante heterocedasticidad, errores robustos.

# Correlaciones Base1
vars1 <- base1 %>% select(preciom, areaconst, parqueaderos, banios, habitaciones, estrato)
corr1 <- cor(vars1, use = "pairwise.complete.obs")
plot_ly(z = corr1, x = colnames(corr1), y = colnames(corr1), type = "heatmap") %>% layout(title = "Base1: Matriz de correlación")

El mapa de calor muestra que preciom se asocia positivamente con areaconst, estrato, banios y parqueaderos (intensidad media), mientras que la relación con habitaciones es débil e incluso ligeramente negativa. Entre predictores, las mayores asociaciones aparecen entre banios–habitaciones y areaconst–(habitaciones/banios), y estrato–areaconst, todas moderadas. No se observan pares > 0.8, por lo que la multicolinealidad no luce crítica; aun así, las variables de “tamaño/calidad” (área, baños, habitaciones, parqueaderos) comparten señal, por lo que verificamos VIF en el modelo y vigilamos redundancias. En síntesis, el precio crece con área, calidad (estrato) y amenidades (baños, parqueaderos); el conteo de habitaciones aporta poca señal adicional una vez se controla por área y baños.

# --- EDA Base2 (Apto Sur_geo) ---
plot_ly(base2, x = ~preciom, type = "histogram") %>% layout(title = "Base2: Histograma de precio")
plot_ly(base2, x = ~areaconst, y = ~preciom, type = "scatter", mode = "markers") %>% layout(title = "Base2: Precio vs Área")
plot_ly(base2, x = ~estrato, y = ~preciom, type = "scatter", mode = "markers") %>% layout(title = "Base2: Precio vs Estrato")
plot_ly(base2, x = ~banios, y = ~preciom, type = "scatter", mode = "markers") %>% layout(title = "Base2: Precio vs Baños")
plot_ly(base2, x = ~habitaciones, y = ~preciom, type = "scatter", mode = "markers") %>% layout(title = "Base2: Precio vs Habitaciones")
plot_ly(base2, x = ~parqueaderos, y = ~preciom, type = "scatter", mode = "markers") %>% layout(title = "Base2: Precio vs Parqueaderos")

EDA Base2 (Apto – Sur_geo)

Histograma de precio. Distribución claramente asimétrica a la derecha: la mayor masa está entre ≈150–400 M, con cola larga hasta >1.600 M (outliers esperables). Esto anticipa heterocedasticidad y motiva contrastar el modelo con log(preciom) o errores robustos.

Precio vs Área. Relación positiva: a mayor areaconst, mayor precio, pero con dispersión creciente (abanico) y algunos puntos de área moderada con precio muy alto (amenidades/ubicación). Sugiere no linealidad y presencia de pocos valores influyentes en áreas grandes.

Precio vs Estrato. Patrón monótono y escalonado: los precios típicos suben de estrato 3→6; la mayor concentración está en 5–6, coherente con el segmento Sur_geo de apartamentos. Persisten dispersiones amplias dentro de cada estrato.

Precio vs Baños. Tendencia creciente hasta ~4–5 baños y luego rendimientos decrecientes/meseta. Puntos con 0–1 baño muestran precios bajos (posibles registros atípicos en aptos; revisar y, de ser necesario, recodificar).

Precio vs Habitaciones. Relación positiva pero ruidosa; mucha masa en 3–4 habitaciones. Parte de la señal la explica área, por lo que se espera correlación y algo de multicolinealidad con areaconst/banios (a verificar con VIF).

Precio vs Parqueaderos. Incremento de precio hasta 3–4 cupos y luego saturación; algunos casos extremos (p.ej., 10 cupos) con comportamiento atípico. Recordar el manejo de NA en parqueaderos a nivel general (imputación o filtrado en el ajuste).

Implicaciones para el modelo

Usar estrato como factor (ordenado); considerar un término flexible para areaconst (cuadrático o spline) o log(preciom) para estabilizar varianza.

Verificar multicolinealidad (VIF) entre variables de tamaño/amenidades (área, baños, habitaciones, parqueaderos).

Reportar desempeño con split 70/30: R² ajustado (TRAIN) y RMSE/MAE (TEST), comentando brecha (over/underfitting).

Mantener control de outliers/influencia (diagnósticos de residuos y leverage).

vars2 <- base2 %>% select(preciom, areaconst, parqueaderos, banios, habitaciones, estrato)
corr2 <- cor(vars2, use = "pairwise.complete.obs")
plot_ly(z = corr2, x = colnames(corr2), y = colnames(corr2), type = "heatmap") %>% layout(title = "Base2: Matriz de correlación")

El precio sube, en general, cuando aumenta el área, los baños, los parqueaderos y el estrato. Nada sorpresivo: más metros y mejores dotaciones → más valor.

Habitaciones aporta poco por sí sola; probablemente su efecto ya va “metido” en el área y los baños.

Entre las explicativas hay relaciones moderadas (por ejemplo, área con baños y parqueaderos), pero no llegan a ser tan altas como para preocuparnos por una multicolinealidad fuerte.

8 Modelado, supuestos y validación (Train/Test)

Fórmula base (numérica):

preciom∼areaconst+estrato+habitaciones+banios+parqueaderos

ajusta_evalua <- function(df){
  dfm <- df %>%
    dplyr::select(preciom, areaconst, estrato, habitaciones, banios, parqueaderos) %>%
    tidyr::drop_na()

  set.seed(2025)
  N <- nrow(dfm); id_tr <- sample(seq_len(N), floor(0.7*N))
  train <- dfm[id_tr, ]; test <- dfm[-id_tr, ]

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

  pred_tr <- stats::predict(mod, newdata = train)
  pred_ts <- stats::predict(mod, newdata = test)

  # Métricas (usa Metrics si está; si no, define rápidas)
  if (!exists("rmse")) rmse <- function(y,yhat) sqrt(mean((y-yhat)^2))
  if (!exists("mae"))  mae  <- function(y,yhat) mean(abs(y-yhat))

  met <- tibble::tibble(
    PART = c("TRAIN","TEST"),
    R2        = c(gl$r.squared, NA_real_),
    R2_adj    = c(gl$adj.r.squared, NA_real_),
    RMSE      = c(rmse(train$preciom, pred_tr), rmse(test$preciom, pred_ts)),
    MAE       = c(mae(train$preciom,  pred_tr), mae(test$preciom,  pred_ts))
  )

  list(modelo = mod, train = train, test = test,
       pred_tr = pred_tr, pred_ts = pred_ts,
       metrics = met)
}

# Base1
res1 <- ajusta_evalua(base1)
summary(res1$modelo)
## 
## Call:
## stats::lm(formula = preciom ~ areaconst + estrato + habitaciones + 
##     banios + parqueaderos, data = train)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -489.55 -115.55  -12.96   72.63 1012.67 
## 
## Coefficients:
##                Estimate Std. Error t value Pr(>|t|)    
## (Intercept)  -331.77219   40.74767  -8.142 1.83e-15 ***
## areaconst       0.85751    0.05551  15.449  < 2e-16 ***
## estrato       111.91994    8.77446  12.755  < 2e-16 ***
## habitaciones  -15.80039    4.97484  -3.176  0.00156 ** 
## banios         28.29190    6.88010   4.112 4.39e-05 ***
## parqueaderos   43.21206    6.42613   6.724 3.71e-11 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 190.2 on 687 degrees of freedom
## Multiple R-squared:  0.6672, Adjusted R-squared:  0.6648 
## F-statistic: 275.5 on 5 and 687 DF,  p-value: < 2.2e-16
res1$metrics
## # A tibble: 2 × 5
##   PART      R2 R2_adj  RMSE   MAE
##   <chr>  <dbl>  <dbl> <dbl> <dbl>
## 1 TRAIN  0.667  0.665  189.  132.
## 2 TEST  NA     NA      231.  148.
res1$vifs
## NULL

En el análisis preliminar se observa que las relaciones son consistentes con lo esperado: el área construida y el estrato parecen ser los factores que más influyen en el precio de la vivienda, mientras que variables como el número de baños, habitaciones o parqueaderos también aportan, aunque con efectos que tienden a saturarse y a introducir algo de ruido. Para la estimación del modelo de regresión múltiple se recomienda tratar el estrato como una variable categórica, controlar la colinealidad entre área, habitaciones y baños mediante la revisión de los VIF, y atender la posible presencia de heterocedasticidad, ya sea aplicando una transformación logarítmica al precio o utilizando errores estándar robustos. Finalmente, es importante identificar y documentar el manejo de los valores atípicos, ya que pueden tener un impacto considerable en los resultados.

# Diagnósticos Base1
par(mfrow=c(2,2)); plot(res1$modelo); par(mfrow=c(1,1))

res_ts1 <- res1$test$preciom - res1$pred_ts
qqnorm(res_ts1); qqline(res_ts1, col="red")

shapiro.test(sample(res_ts1, min(length(res_ts1), 500)))
## 
##  Shapiro-Wilk normality test
## 
## data:  sample(res_ts1, min(length(res_ts1), 500))
## W = 0.86883, p-value = 3.285e-15
plot(res1$pred_ts, res_ts1, xlab="Predicción (TEST)", ylab="Residuo (TEST)"); abline(h=0, col="red")

Validación de supuestos y diagnóstico del modelo

Diagnóstico en TRAIN (panel de 4 gráficos).

Residuos vs Fitted: aparece un patrón en abanico y una ligera curvatura, señales de heterocedasticidad y no linealidad (especialmente en areaconst).

Q–Q de residuos: las colas se desvían de la recta teórica (colas pesadas). La prueba de Shapiro–Wilk confirma no normalidad de los residuos (W ≈ 0.869; p ≈ 3.3e-15).

Scale–Location: la pendiente positiva refuerza la presencia de heterocedasticidad (la dispersión del error crece con el valor ajustado).

Residuos vs Leverage: se identifican puntos influyentes cercanos/sobre el umbral de Cook, que conviene revisar y monitorear en análisis de sensibilidad.

Validación en TEST (residuo vs predicción). El gráfico reproduce el abanico: errores pequeños para precios bajos y mayor dispersión a medida que sube la predicción. Además, se observa un sesgo leve: el modelo sobrepredice parte del rango medio (pred. ~400–700 M) y subpredice el tramo alto (>900 M), es decir, aplana los extremos.

Para la inferencia reportamos errores estándar robustos (HC3);

contrastamos el modelo base con una especificación log(preciom) y con términos flexibles para areaconst (cuadrático/splines) para capturar la curvatura;

realizamos revisión de observaciones influyentes (Cook) y análisis de sensibilidad;

comparamos el desempeño por R²_aj (TRAIN) y RMSE/MAE (TEST) para descartar sobre/infraajuste.

Aunque los supuestos no se cumplen perfectamente (heterocedasticidad y colas pesadas), el modelo captura los impulsores principales del precio (área, estrato y amenidades) y resulta útil para estimar y ordenar ofertas, especialmente cuando se acompaña de intervalos y de las salvaguardas anteriores. Para inmuebles de segmento alto, recomendamos interpretar con cautela y apoyarse en la versión logarítmica/flexible del modelo.

# Base2
res2 <- ajusta_evalua(base2)
summary(res2$modelo)
## 
## Call:
## stats::lm(formula = preciom ~ areaconst + estrato + habitaciones + 
##     banios + parqueaderos, data = train)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -895.85  -43.57   -2.05   41.96  829.77 
## 
## Coefficients:
##                Estimate Std. Error t value Pr(>|t|)    
## (Intercept)  -254.82705   21.21617 -12.011  < 2e-16 ***
## areaconst       1.50908    0.07375  20.463  < 2e-16 ***
## estrato        59.79906    4.09511  14.603  < 2e-16 ***
## habitaciones  -31.15813    5.27437  -5.907 4.29e-09 ***
## banios         45.44988    4.63595   9.804  < 2e-16 ***
## parqueaderos   80.10276    5.85612  13.678  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 102.7 on 1501 degrees of freedom
## Multiple R-squared:  0.7555, Adjusted R-squared:  0.7547 
## F-statistic: 927.5 on 5 and 1501 DF,  p-value: < 2.2e-16
res2$metrics
## # A tibble: 2 × 5
##   PART      R2 R2_adj  RMSE   MAE
##   <chr>  <dbl>  <dbl> <dbl> <dbl>
## 1 TRAIN  0.755  0.755  102.  62.6
## 2 TEST  NA     NA      113.  63.7
res2$vifs
## NULL
# Diagnósticos Base2
par(mfrow=c(2,2)); plot(res2$modelo); par(mfrow=c(1,1))

res_ts2 <- res2$test$preciom - res2$pred_ts
qqnorm(res_ts2); qqline(res_ts2, col="red")

shapiro.test(sample(res_ts2, min(length(res_ts2), 500)))
## 
##  Shapiro-Wilk normality test
## 
## data:  sample(res_ts2, min(length(res_ts2), 500))
## W = 0.76714, p-value < 2.2e-16
plot(res2$pred_ts, res_ts2, xlab="Predicción (TEST)", ylab="Residuo (TEST)"); abline(h=0, col="red")

Residuos vs Fitted. Se ve un abanico claro y algo de curvatura: la varianza del error aumenta cuando el precio esperado es alto → heterocedasticidad y no linealidad (especialmente por areaconst).

Q–Q de residuos (dos gráficos). Las colas se separan de la recta teórica: hay colas pesadas y outliers; un par de puntos extremos dominan la cola derecha y uno muy bajo la izquierda.

Scale–Location. La línea roja ascendente confirma la varianza creciente.

Residuos vs Leverage. Aparecen observaciones influyentes (algunas cerca/sobre las curvas de Cook’s distance). Conviene revisarlas y reportar análisis de sensibilidad.

Validación en TEST (residuo vs predicción)

El patrón se repite: abanico y ligera tendencia. El modelo aplana los extremos: tiende a sobreestimar parte del rango medio y a subestimar predicciones muy altas (residuos positivos grandes). Esto es coherente con la asimetría observada en el EDA.

Aun con heterocedasticidad y colas pesadas, el modelo captura bien los drivers del precio (área, estrato y amenidades). Con robustos, transformación/curva flexible y sensibilidad a outliers, los resultados son útiles para estimar y priorizar ofertas; interpretar con cautela predicciones muy altas.

9 Predicciones para las solicitudes

# Vivienda 1 (Casa, Norte_geo): 200 m2, pk=1, baños=2, hab=4, estrato 4 o 5
sol1 <- expand.grid(
  areaconst = 200,
  estrato   = c(4,5),
  habitaciones = 4,
  banios = 2,
  parqueaderos = 1
)
pred1 <- predict(res1$modelo, newdata = sol1, interval = "prediction")
cbind(sol1, pred1)
##   areaconst estrato habitaciones banios parqueaderos      fit       lwr
## 1       200       4            4      2            1 324.0031 -50.22031
## 2       200       5            4      2            1 435.9231  61.09080
##        upr
## 1 698.2266
## 2 810.7553
# Vivienda 2 (Apto, Sur_geo): 300 m2, pk=3, baños=3, hab=5, estrato 5 o 6
sol2 <- expand.grid(
  areaconst = 300,
  estrato   = c(5,6),
  habitaciones = 5,
  banios = 3,
  parqueaderos = 3
)
pred2 <- predict(res2$modelo, newdata = sol2, interval = "prediction")
cbind(sol2, pred2)
##   areaconst estrato habitaciones banios parqueaderos      fit      lwr      upr
## 1       300       5            5      3            3 717.7582 514.0467 921.4698
## 2       300       6            5      3            3 777.5573 573.7688 981.3458

Vivienda 1 (Casa – Zona Norte_geo, 200 m², 1 pk, 2 baños, 4 hab).

Estrato 4: 𝑦^ = 324 M; PI95% ≈ [−50, 698]. Cumple el tope de 350 M en el valor puntual. El intervalo es amplio; existe incertidumbre y el extremo alto supera el tope.

Estrato 5: 𝑦^ = 436 M; PI95% ≈ [61, 811]. El valor puntual no cumple el tope de 350 M; sólo la parte baja del intervalo cae por debajo. Requiere negociación o ajuste de especificaciones (menor área/amenidades) si se mantiene estrato 5.

Conclusión V1. Priorizar estrato 4 y apuntar a un rango operativo ≈ 300–340 M. Si se exige estrato 5, prever negociación o alternativas con menor metraje/amenidades.

Vivienda 2 (Apartamento – Zona Sur_geo, 300 m², 3 pk, 3 baños, 5 hab).

Estrato 5: 𝑦^ = 718 M; PI95% ≈ [514, 921]. Cumple el tope de 850 M en el valor puntual; existe riesgo de excederlo si el precio real cae en el extremo alto del intervalo.

Estrato 6: 𝑦^ = 779 M; PI95% ≈ [574, 981]. Cumple en el valor puntual, pero el límite superior supera 850 M; mantener margen de negociación y priorizar ofertas ≤ 800 M.

Conclusión V2. El crédito es suficiente para apartamentos de estrato 5–6 en Sur_geo, con mayor holgura en estrato 5. Recomiendo filtrar ofertas ≤ 800–820 M para reducir el riesgo de sobrepasar el tope.

10 Ofertas potenciales y mapas

# --- Vivienda 1: Casa + Norte_geo (>=5 ofertas si existen) ---
ofertas1 <- base1 %>%
  filter(areaconst >= 200,
         banios >= 2,
         habitaciones >= 4,
         parqueaderos >= 1,
         estrato %in% c(4,5),
         preciom <= 350) %>%
  arrange(estrato, preciom)

nrow(ofertas1)
## [1] 45
head(ofertas1, 10)
## # A tibble: 10 × 12
##       id zona_geo     estrato preciom areaconst parqueaderos banios habitaciones
##    <int> <fct>          <dbl>   <dbl>     <dbl>        <dbl>  <dbl>        <dbl>
##  1   683 Zona Norte …       4     230       250            2      3            5
##  2   761 Zona Norte …       4     260       280            2      4            6
##  3   318 Zona Norte …       4     300       270            1      2            5
##  4   349 Zona Norte …       4     300       248            2      4            5
##  5   370 Zona Norte …       4     315       270            2      4            4
##  6   233 Zona Norte …       4     320       252            1      3            5
##  7   697 Zona Norte …       4     320       200            2      4            4
##  8  1151 Zona Norte …       4     320       300            1      4            4
##  9  1210 Zona Norte …       4     320       380            2      4            4
## 10  1209 Zona Norte …       4     325       355            2      3            4
## # ℹ 4 more variables: tipo <chr>, barrio <chr>, longitud <dbl>, latitud <dbl>
leaflet(head(ofertas1, 5)) %>% addTiles() %>%
  addMarkers(~longitud, ~latitud,
             label = ~paste0("Precio: ", round(preciom), " M"),
             popup = ~paste0("<b>", tipo, "</b> / ", barrio,
                             "<br>Estrato: ", estrato,
                             "<br>Área: ", round(areaconst), " m²",
                             "<br>Baños: ", banios,
                             "<br>Hab: ", habitaciones,
                             "<br>Pk: ", parqueaderos))

Ofertas candidatas – Vivienda 1 (Casa, Norte_geo, crédito ≤ 350 M)

Qué muestra la tabla. Es un extracto de 10 ofertas que cumplen simultáneamente el filtro: Zona Norte_geo, estrato 4, área ≥ 200 m², ≥1 parqueadero, ≥2 baños, ≥4 habitaciones y precio ≤ 350 M. Resumen rápido de este lote:

Precio: 230–325 M (mediana ~318 M, promedio ~301 M).

Área: 200–380 m² → precio/m² entre ~0.8 y 1.5 M (variación amplia útil para negociar).

Amenidades: 1–2 parqueaderos; 2–4 baños; 4–6 habitaciones.

Todas las opciones están dentro del tope del crédito de la Vivienda 1 y alineadas con el perfil (200 m², 1 pk, 2 baños, 4 hab). La dispersión en precio por m² sugiere oportunidades de valor (m² más baratos) sin sacrificar el mínimo de amenidades.

Mapa de 5 propuestas priorizadas

El mapa ubica 5 inmuebles seleccionados como primera visita (criterios: estar dentro del crédito, cumplir especificaciones y ofrecer buen precio/m²). Están distribuidos en subzonas del Norte_geo, lo que diversifica opciones respecto a accesos viales, cercanía a servicios y vecindarios. Esta dispersión geográfica ayuda a comparar calidad de entorno y conectividad antes de decidir.

# --- Vivienda 2: Apto + Sur_geo (>=5 ofertas si existen) ---
ofertas2 <- base2 %>%
  filter(areaconst >= 300,
         banios >= 3,
         habitaciones >= 5,
         parqueaderos >= 3,
         estrato %in% c(5,6),
         preciom <= 850) %>%
  arrange(estrato, preciom)

nrow(ofertas2)
## [1] 2
head(ofertas2, 10)
## # A tibble: 2 × 12
##      id zona_geo      estrato preciom areaconst parqueaderos banios habitaciones
##   <int> <fct>           <dbl>   <dbl>     <dbl>        <dbl>  <dbl>        <dbl>
## 1  1763 Zona Sur (ge…       5     670       300            3      5            6
## 2  1067 Zona Sur (ge…       5     730       573            3      8            5
## # ℹ 4 more variables: tipo <chr>, barrio <chr>, longitud <dbl>, latitud <dbl>
leaflet(head(ofertas2, 5)) %>% addTiles() %>%
  addMarkers(~longitud, ~latitud,
             label = ~paste0("Precio: ", round(preciom), " M"),
             popup = ~paste0("<b>", tipo, "</b> / ", barrio,
                             "<br>Estrato: ", estrato,
                             "<br>Área: ", round(areaconst), " m²",
                             "<br>Baños: ", banios,
                             "<br>Hab: ", habitaciones,
                             "<br>Pk: ", parqueaderos))

Ofertas finalistas – Vivienda 2 (Sur_geo, crédito ≤ 850 M)

Se filtraron 2 apartamentos en Zona Sur (geo), estrato 5, que cumplen la solicitud (≥300 m², 3 parqueaderos, ≥3 baños, ≥5 habitaciones) y están dentro del crédito:

ID 1763: 300 m², 3 pk, 5 baños, 6 hab, 670 M → 2.23 M/m².

ID 1067: 573 m², 3 pk, 8 baños, 5 hab, 730 M → 1.27 M/m².

Con la especificación para V2 (estrato 5) el valor de referencia del modelo es ≈ 718 M (PI95% ~[514, 921]).

ID 1763 está ≈ 48 M por debajo del valor puntual (−6.7%), lo que sugiere buena relación precio/valor si el estado físico es adecuado.

ID 1067 está ≈ 12 M por encima (+1.7%), pero su precio por m² es notablemente bajo por el gran metraje; es una opción atractiva si se prioriza amplitud.

Las ubicaciones quedan sobre el eje Calle 5 (Sur), con acceso a servicios (comercio de proximidad) y conectividad vial. La concentración en esta zona facilita comparar entorno y movilidad en visitas presenciales.

El inmueble de 573 m² es ATIPICO (segmento alto); verificar valor administración, antigüedad y estado de las áreas comunes.

Debido a la heterocedasticidad observada en el modelo, mantener margen de negociación y contrastar con comparables recientes.

Recomendación operativa.

VISITAR AMBOS !!!!

Objetivos de negociación: ID 1763: 650–700 M; ID 1067: ≤ 720 M.

Confirmar documentación, costos de administración y ruido/tráfico en horarios pico.

En síntesis, ambas alternativas cumplen el tope de 850 M: la primera ofrece precio competitivo en metrajes estándar; la segunda brinda gran área con bajo precio por m², ideal si la empresa prioriza espacio para familia/teletrabajo.

11 Conclusiones (ejemplo guía)

Re-etiquetado geo (K=2, Haversine) mejora la coherencia Norte/Sur respecto a las coordenadas. La tabla Declarada vs Geo evidencia el desorden inicial.

En ambos modelos, área y estrato suelen dominar; baños y parqueaderos aportan; habitaciones puede perder significancia si colinea con área/baños.

R² adj (TRAIN) y RMSE/MAE (TEST) muestran el rendimiento. Si hay brecha grande, considerar regularización o variables adicionales (antigüedad, estado del inmueble, amenidades, seguridad, accesibilidad).

Predicciones: comparar punto e intervalo con los topes de crédito.

Ofertas: validar geográficamente y con criterios mínimos del cliente.

12 Flujo de trabajo

DiagrammeR::mermaid("
flowchart TD
A[Datos vivienda.csv / paqueteMODELOS] --> B[Preparación y tipificación]
B --> C[Mapas por zona declarada]
C --> D[Weighted K-means (Haversine, K=2)]
D --> E[Bases: Casa-Norte_geo / Apto-Sur_geo]
E --> F[EDA interactivo (plotly)]
F --> G[Modelos OLS por base]
G --> H[Supuestos & VIF]
G --> I[Train/Test: R2 adj, RMSE, MAE]
I --> J[Predicciones Solicitudes]
J --> K[Ofertas y Mapas]
K --> L[Conclusiones & Recomendaciones]
")
install.packages("rsconnect", dependencies = TRUE)
library(rsconnect)

# Tamaño del HTML (opcional)
round(file.info("Regresion_Lineal_Multiple.html")$size/1024^2, 1)

# Sube a RPubs
res <- rsconnect::rpubsUpload(
  title   = "Analisis_multivariado_Vivienda_Final_28ago2025",
  htmlFile = "Regresion_Lineal_Multiple.html"
)

if (!is.null(res$continueUrl)) {
  message("Publicado en: ", res$continueUrl)
} else {
  print(res)  # verás la causa exacta si falla
}