Este documento analiza el Caso C&A para apoyar decisiones de compra de dos viviendas en Cali. Con base en la base vivienda del paquete paqueteMODELOS, se realizan: filtro de datos y verificación geográfica, EDA con gráficos interactivos, estimación de un modelo de regresión lineal múltiple (precio en millones ~ área, estrato, cuartos, parqueaderos, baños), validación de supuestos, predicciones para las dos solicitudes y una selección georreferenciada de ofertas dentro de los topes de crédito.
library(paqueteMODELOS)
library(dplyr)
library(stringr)
library(tidyr)
library(DT)
library(plotly)
library(leaflet)
library(broom)
library(ggplot2)
library(car)
library(lmtest)
library(performance)
library(Metrics)
data("vivienda")
# Estandarización ligera de nombres/formatos
vivienda <- vivienda %>%
rename(
zona = zona, piso = piso, estrato = estrato, preciom = preciom,
areaconst = areaconst, parqueaderos = parqueaderos, banios = banios,
habitaciones = habitaciones, tipo = tipo, barrio = barrio,
longitud = longitud, latitud = latitud
) %>%
mutate(
zona = as.character(zona),
tipo = as.character(tipo),
barrio = as.character(barrio),
estrato = as.integer(estrato),
parqueaderos = as.integer(parqueaderos),
banios = as.integer(banios),
habitaciones = as.integer(habitaciones)
)
# Vista general
datatable(head(vivienda, 10), caption = "Muestra de datos (10 primeras filas)")
summary(vivienda)
## id zona piso estrato
## Min. : 1 Length:8322 Length:8322 Min. :3.000
## 1st Qu.:2080 Class :character Class :character 1st Qu.:4.000
## Median :4160 Mode :character Mode :character Median :5.000
## Mean :4160 Mean :4.634
## 3rd Qu.:6240 3rd Qu.:5.000
## Max. :8319 Max. :6.000
## NA's :3 NA's :3
## preciom areaconst parqueaderos banios
## Min. : 58.0 Min. : 30.0 Min. : 1.000 Min. : 0.000
## 1st Qu.: 220.0 1st Qu.: 80.0 1st Qu.: 1.000 1st Qu.: 2.000
## Median : 330.0 Median : 123.0 Median : 2.000 Median : 3.000
## Mean : 433.9 Mean : 174.9 Mean : 1.835 Mean : 3.111
## 3rd Qu.: 540.0 3rd Qu.: 229.0 3rd Qu.: 2.000 3rd Qu.: 4.000
## Max. :1999.0 Max. :1745.0 Max. :10.000 Max. :10.000
## NA's :2 NA's :3 NA's :1605 NA's :3
## habitaciones tipo barrio longitud
## Min. : 0.000 Length:8322 Length:8322 Min. :-76.59
## 1st Qu.: 3.000 Class :character Class :character 1st Qu.:-76.54
## Median : 3.000 Mode :character Mode :character Median :-76.53
## Mean : 3.605 Mean :-76.53
## 3rd Qu.: 4.000 3rd Qu.:-76.52
## Max. :10.000 Max. :-76.46
## NA's :3 NA's :3
## latitud
## Min. :3.333
## 1st Qu.:3.381
## Median :3.416
## Mean :3.418
## 3rd Qu.:3.452
## Max. :3.498
## NA's :3
Se filtran registros que cumplan la solicitud 1 (tipo Casa, Zona Norte, estratos 4–5, y mínimos razonables en parqueaderos/baños/habitaciones). Se presentan tablas de control (zona, estrato, barrio) y una muestra para verificar que el subconjunto es coherente con el enunciado.
# Casas ubicadas en Zona Norte
base1 <- vivienda %>%
dplyr::filter(
stringr::str_detect(zona, regex("Norte", ignore_case = TRUE)),
stringr::str_detect(tipo, regex("^Casa$", ignore_case = TRUE))
)
DT::datatable(head(base1, 3), caption = "Primeros 3 registros: Casas – Zona Norte")
# Tablas de comprobación
tab_zona <- base1 %>% dplyr::count(zona, name = "n")
tab_estrato <- base1 %>% dplyr::count(estrato, name = "n")
tab_barrio <- base1 %>% dplyr::count(barrio, name = "n") %>% dplyr::arrange(desc(n))
tab_zona; tab_estrato; head(tab_barrio, 10)
Interpretaciòn: Tras aplicar el filtro tipo = Casa y zona = Norte, se obtuvieron 722 registros válidos. La distribución por estrato fue: 3 (235), 4 (161), 5 (271) y 6 (55). Para la Solicitud 1 (estratos 4–5) se dispone de 432 inmuebles (≈60% del subconjunto), lo que indica oferta suficiente para avanzar con el análisis.
La oferta se concentra en barrios como La Flora (99), Acopi (70), Villa del Prado (40), El Bosque (37), Prados del Norte (31), San Vicente (31) y Vipasa (30), entre otros, lo cual orienta la priorización territorial. La muestra inicial refleja alta dispersión de área (p. ej., 150–445 m²) y precio (p. ej., 320–780 MM), evidenciando heterogeneidad por barrio/estrato y la posible presencia de atípicos que se evaluarán en el EDA.
Se muestra un mapa base con los puntos del filtro anterior para validar coordenadas y zona.
# Convertir TODAS las columnas de texto a UTF-8
base1_utf <- base1 %>%
dplyr::mutate(dplyr::across(where(is.character),
~ iconv(.x, from = "", to = "UTF-8")))
vivienda_utf <- vivienda %>%
dplyr::mutate(dplyr::across(where(is.character),
~ iconv(.x, from = "", to = "UTF-8")))
leaflet::leaflet(base1_utf) %>%
addTiles() %>%
setView(lng = -76.53, lat = 3.44, zoom = 12) %>%
addCircleMarkers(
~longitud, ~latitud,
popup = ~paste0(
"<b>", tipo, "</b><br>",
"Zona: ", zona, "<br>",
"Barrio: ", barrio, "<br>",
"Estrato: ", estrato, "<br>",
"Area: ", areaconst, " m", "\u00B2", "<br>", # m²
"Parq: ", parqueaderos, " | Ba", "\u00F1", "os: ", banios, # ñ
" | Hab: ", habitaciones, "<br>",
"Precio: $", round(preciom, 1), " MM"
),
radius = 6, stroke = FALSE, fillOpacity = 0.8
)
Interpretaciòn: El mapa confirma que las ofertas filtradas se concentran mayoritariamente en el sector norte de la ciudad, con un patrón continuo a lo largo del eje vial principal. No obstante, se observan algunos puntos aislados hacia el centro–sur, que podrían corresponder a (i) errores de coordenadas, (ii) registros en barrios limítrofes cuya etiqueta de zona es ambigua o (iii) re-clasificaciones de zona no actualizadas.
Para garantizar consistencia espacial, se estimó un centro robusto (medianas de latitud/longitud) y se calculó la distancia geodésica punto–centro. Se etiquetaron como outliers los registros con distancia mayor que el máximo entre mediana + 3·MAD y el percentil 98, y adicionalmente el 10% más austral (límite por latitud). Los outliers se revisaron en tabla y mapa; luego se generó una base depurada para los siguientes análisis.
library(geosphere)
# base1 = Casas en Zona Norte (del paso 1)
stopifnot(exists("base1"))
# 1) Centro robusto (medianas)
centro_rob <- c(
long = median(base1$longitud, na.rm = TRUE),
lat = median(base1$latitud, na.rm = TRUE)
)
# 2) Distancia Haversine al centro (km)
base1 <- base1 %>%
mutate(
dist_km = geosphere::distHaversine(
cbind(longitud, latitud),
matrix(centro_rob, nrow = n(), ncol = 2, byrow = TRUE)
) / 1000
)
# 3) Umbrales robustos con summarise (evita 'objeto no encontrado')
stats <- base1 %>%
summarise(
med = median(dist_km, na.rm = TRUE),
mad = mad(dist_km, na.rm = TRUE),
q98 = quantile(dist_km, 0.98, na.rm = TRUE)
)
umbral <- max(stats$med + 3*stats$mad, stats$q98)
lat_q10 <- quantile(base1$latitud, 0.10, na.rm = TRUE)
base1 <- base1 %>%
mutate(sospechoso = dist_km > umbral | latitud < lat_q10)
# 4) Tabla de revisión
DT::datatable(
base1 %>%
arrange(desc(dist_km)) %>%
select(sospechoso, barrio, zona, estrato, areaconst, preciom, latitud, longitud, dist_km) %>%
head(15),
caption = "Top 15 por distancia — posibles outliers"
)
# 5) Mapa (azul=ok, rojo=sospechoso)
base1 <- base1 %>%
dplyr::mutate(
popup_txt = paste0(
"<b>", tipo, " – ", barrio, "</b><br>",
"Estrato ", estrato, " | Area ", areaconst, " m", "\u00B2", "<br>",
"Precio $", round(preciom,1), " MM",
"<br>Dist: ", round(dist_km, 2), " km",
"<br><i>Sospechoso: ", sospechoso, "</i>"
)
)
leaflet::leaflet(base1) %>% leaflet::addTiles() %>%
leaflet::addCircleMarkers(
~longitud, ~latitud,
color = ~ifelse(sospechoso, "red", "blue"),
popup = ~popup_txt,
radius = 6, fillOpacity = 0.8
)
# 6) Base depurada + contadores para inline
base1_clean <- base1 %>% filter(!sospechoso)
n_total <- nrow(base1)
n_removidos <- n_total - nrow(base1_clean)
pct_rem <- round(100 * n_removidos / n_total, 1)
list(n_total = n_total, n_removidos = n_removidos, pct_removidos = pct_rem)
## $n_total
## [1] 722
##
## $n_removidos
## [1] 71
##
## $pct_removidos
## [1] 9.8
Se removieron **71** registros (**9.8%**) y quedaron **651** casas de Zona Norte para ofertas y mapas.
Se removieron 71 registros (9.8%) y quedaron 651 casas de Zona Norte para ofertas y mapas.
Si
n_removidos/pct_remno existen aún en tu sesión, crea antes (en un chunk) estas dos líneas:n_removidos <- n_total - nrow(base1_clean) pct_rem <- round(100 * n_removidos / n_total, 1)
Tras detectar y revisar registros con coordenadas atípicas (puntos fuera o muy lejos del corredor de “Zona Norte”), se construyó una versión depurada de la base para esta solicitud. Se removieron r n_removidos registros (r pct_rem%) y quedaron r nrow(base1_clean) casas de Zona Norte para ofertas y mapas. Con esto reducimos el riesgo de sesgos por georreferenciación errónea y garantizamos que los candidatos del Paso 6 correspondan efectivamente a la zona solicitada.
Se exploran relaciones entre precio y las variables clave (área, estrato, baños, habitaciones, parqueaderos) con gráficos interactivos (plotly) y resúmenes numéricos.
library(dplyr); library(stringr); library(tidyr)
library(plotly)
# 1) Base: solo Casas, variables clave; sin NA y normalizando a UTF-8
eda_casas <- vivienda %>%
dplyr::filter(stringr::str_detect(tipo, "^Casa$", TRUE)) %>%
dplyr::select(preciom, areaconst, estrato, banios, habitaciones, parqueaderos, zona, barrio) %>%
tidyr::drop_na() %>%
dplyr::mutate(
# cualquier texto a UTF-8 (evita "invalid UTF-8")
dplyr::across(where(is.character), ~ iconv(., from = "", to = "UTF-8", sub = ""))
)
# 2) Recorte suave 1–99% para mejorar legibilidad de gráficos
qP <- stats::quantile(eda_casas$preciom, c(.01, .99), na.rm = TRUE)
qA <- stats::quantile(eda_casas$areaconst, c(.01, .99), na.rm = TRUE)
eda_plot <- eda_casas %>%
dplyr::filter(
dplyr::between(preciom, qP[1], qP[2]),
dplyr::between(areaconst, qA[1], qA[2])
) %>%
# Tooltip/hover ya en UTF-8 (usa escapes para símbolos: m², ñ, Á…)
dplyr::mutate(
hover = enc2utf8(paste0(
"Zona: ", zona,
"<br>Barrio: ", barrio,
"<br>Estrato: ", estrato,
"<br>\u00C1rea: ", areaconst, " m\u00B2",
"<br>Ba\u00F1os: ", banios,
"<br>Hab: ", habitaciones,
"<br>Parq: ", parqueaderos,
"<br>Precio: ", round(preciom, 1), " MM"
))
)
# Verificacion preventiva: no debe quedar nada fuera de UTF-8
stopifnot(!any(is.na(iconv(eda_plot$hover, to = "UTF-8"))))
# --- 2.1 Dispersión Precio vs Área (color por estrato) ---
plot_ly(
eda_plot,
x = ~areaconst, y = ~preciom, color = ~factor(estrato),
type = "scatter", mode = "markers",
text = ~hover, hoverinfo = "text"
) %>%
layout(
title = enc2utf8("Precio vs \u00C1rea (color: Estrato) \u2013 CASAS"),
xaxis = list(title = enc2utf8("\u00C1rea (m\u00B2)")),
yaxis = list(title = "Precio (MM)")
)
# --- 2.2 Dispersión Precio vs Área (color por zona) ---
plot_ly(
eda_plot,
x = ~areaconst, y = ~preciom, color = ~zona,
type = "scatter", mode = "markers",
text = ~hover, hoverinfo = "text"
) %>%
layout(
title = enc2utf8("Precio vs \u00C1rea (color: Zona) \u2013 CASAS"),
xaxis = list(title = enc2utf8("\u00C1rea (m\u00B2)")),
yaxis = list(title = "Precio (MM)")
)
# --- 2.3 Boxplots: Precio por Zona ---
plot_ly(eda_plot, x = ~zona, y = ~preciom, type = "box") %>%
layout(
title = enc2utf8("Precio por Zona \u2013 CASAS"),
xaxis = list(title = "Zona"),
yaxis = list(title = "Precio (MM)")
)
# --- 2.4 Boxplots: Precio por Estrato ---
plot_ly(eda_plot, x = ~factor(estrato), y = ~preciom, type = "box") %>%
layout(
title = "Precio por Estrato \u2013 CASAS",
xaxis = list(title = "Estrato"),
yaxis = list(title = "Precio (MM)")
)
# --- 2.5 Heatmap de correlacion (variables numéricas) ---
num_vars <- eda_plot %>%
dplyr::select(preciom, areaconst, estrato, banios, habitaciones, parqueaderos)
corr_mat <- round(stats::cor(num_vars, use = "pairwise.complete.obs"), 2)
plot_ly(
x = colnames(corr_mat), y = rownames(corr_mat),
z = corr_mat, type = "heatmap",
zmin = -1, zmax = 1, colorscale = "RdBu", reversescale = TRUE
) %>%
layout(title = enc2utf8("Matriz de correlaci\u00F3n (CASAS)"))
Paso 2 — EDA (S1: Casas, Zona Norte)
El subconjunto depurado para S1 (Casas en Zona Norte) contiene 60 observaciones. La relación Precio–Área es claramente positiva: la correlación lineal es 0.5, lo que confirma que, en promedio, a mayor área construida mayor precio de oferta.
Los boxplots por estrato muestran niveles de precio crecientes con el estrato; las medianas por estrato (MM) son:
| Estrato | Mediana precio (MM) | P25 | P75 |
|---|---|---|---|
| 4 | 317.5 | 266.25 | 333.75 |
| 5 | 340.0 | 320.00 | 348.25 |
Interpretación:
El subconjunto depurado para S1 contiene 60 observaciones.
La relación Precio–Área es positiva y de magnitud media (corr ≈ 0,50). En los dispersogramas se aprecia mayor dispersión del precio a medida que aumenta el área, lo que sugiere heterogeneidad típica del mercado.
Los precios crecen por estrato. Las medianas (MM) obtenidas fueron:
Estrato 4: 317,5 (P25 266,25 – P75 333,75).
Estrato 5: 340,0 (P25 320,00 – P75 348,25).
La matriz de correlación confirma que precio se asocia sobre todo con área y estrato; baños y parqueaderos aportan señal adicional, mientras que habitaciones tiene efecto más débil al controlar por el resto. No se observan correlaciones extremas entre regresores numéricos.
Implicación para el modelo: conviene incluir área y estrato como ejes principales, más baños y parqueaderos como variables de soporte. Dado el patrón de dispersión creciente, más adelante puede considerarse log(Precio) o, al menos, errores robustos para inferencia.
Se ajusta un modelo lineal múltiple en un conjunto de entrenamiento (70%) y se evalúa en prueba (30%). La especificación es:
Precio(MM) -> Área + Estrato + Habitaciones + Parqueaderos + Baños
library(dplyr)
library(tidyr)
library(broom)
library(DT)
library(Metrics)
# Base para modelar (toda la ciudad, ambos tipos) con las variables requeridas
df_model <- vivienda %>%
dplyr::select(preciom, areaconst, estrato, habitaciones, parqueaderos, banios) %>%
tidyr::drop_na()
set.seed(123)
n <- nrow(df_model)
idx <- sample(seq_len(n), size = floor(0.7*n))
train <- df_model[idx, ]
test <- df_model[-idx, ]
# Modelo lineal
m1 <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
data = train)
# Coeficientes (ordenados por p-valor) con IC 95%
coef_tab <- broom::tidy(m1, conf.int = TRUE) %>% dplyr::arrange(p.value)
DT::datatable(coef_tab, caption = "Coeficientes del modelo (IC 95%)")
# ---- Indicadores ----
# Train
pred_train <- predict(m1, newdata = train)
R2_train <- 1 - sum((train$preciom - pred_train)^2) / sum((train$preciom - mean(train$preciom))^2)
RMSE_train <- Metrics::rmse(train$preciom, pred_train)
MAE_train <- Metrics::mae (train$preciom, pred_train)
# Test
pred_test <- predict(m1, newdata = test)
R2_test <- 1 - sum((test$preciom - pred_test)^2) / sum((test$preciom - mean(test$preciom))^2)
RMSE_test <- Metrics::rmse(test$preciom, pred_test)
MAE_test <- Metrics::mae (test$preciom, pred_test)
MAPE_test <- Metrics::mape(test$preciom, pred_test) * 100
DT::datatable(
data.frame(
Conjunto = c("Train","Test"),
R2 = c(R2_train, R2_test),
RMSE = c(RMSE_train, RMSE_test),
MAE = c(MAE_train, MAE_test),
MAPE = c(NA, MAPE_test)
),
caption = "Indicadores de rendimiento (train/test)"
)
Se ajustó un modelo de regresión lineal para explicar el precio (MM COP) en función de área construida, estrato, parqueaderos, baños y habitaciones. Los coeficientes estimados (todos con p-valor ≪ 0.01) y su lectura económica son:
Área construida: β ≈ 0.835 A igualdad del resto, cada m² adicional se asocia con +0.835 MM (≈ $835.000 COP) en el precio. Ej.: +100 m² ⇒ +83.5 MM.
Estrato: β ≈ 96.44 Subir un nivel de estrato se asocia con +96.4 MM en el precio, manteniendo las demás variables constantes. El efecto del estrato es muy marcado.
Parqueaderos: β ≈ 75.48 Cada parqueadero adicional se asocia con +75.5 MM.
Baños: β ≈ 61.09 Cada baño adicional se asocia con +61.1 MM.
Habitaciones: β ≈ −31.84 Manteniendo constante el tamaño del inmueble, más habitaciones se asocian con −31.8 MM por dormitorio. Interpretación: a área fija, más cuartos suelen implicar habitaciones más pequeñas o distribuciones menos valoradas por el mercado.
Intercepto: ≈ −372.8 (sin interpretación directa útil; es el precio predicho cuando todas las covariables valen 0).
Se revisan:
Linealidad y homocedasticidad: gráficos de residuos vs. ajustados y prueba de Breusch–Pagan.
Normalidad de residuos: QQ-plot.
Multicolinealidad: VIF.
Autocorrelacion: Durbin–Watson
# Paquetes (solo si faltan)
if (!requireNamespace("see", quietly = TRUE)) install.packages("see")
# 1) Grafico compacto de performance
performance::check_model(m1)
# 2) Graficos base de R (cuatro paneles)
par(mfrow = c(2,2))
plot(m1) # Residuals vs Fitted, QQ-plot, Scale-Location, Cook's Distance
par(mfrow = c(1,1))
# Asegúrate de tener m1
stopifnot(exists("m1"))
# --- helpers de codificacion ----
to_utf8_df <- function(df) {
# Convierte todo texto a UTF-8 y limpia nombres
df <- df |>
dplyr::mutate(dplyr::across(where(is.character),
~ iconv(.x, from = "", to = "UTF-8", sub = "?")))
names(df) <- iconv(names(df), from = "", to = "UTF-8", sub = "?")
df
}
to_utf8 <- function(x) iconv(x, from = "", to = "UTF-8", sub = "?")
# Residuos y tamanio
res <- resid(m1)
fit <- fitted(m1)
dfm <- cbind(model.frame(m1), .fit = fit, .res = res)
n <- NROW(dfm)
# Pruebas
bp <- lmtest::bptest(m1) # Heterocedasticidad
ncv <- car::ncvTest(m1) # Varianza no constante
dw <- lmtest::dwtest(m1) # Autocorrelacion
set.seed(123)
res_s <- if (n > 5000) sample(res, 5000) else res
sh <- shapiro.test(res_s) # Normalidad (muestra)
vif_vals <- car::vif(m1) # Colinealidad
# Influencia (Cook > 4/n)
cook <- cooks.distance(m1)
lev <- hatvalues(m1)
thr_cook <- 4/n
infl_idx <- which(cook > thr_cook)
infl_tbl <- tibble::tibble(
idx = infl_idx,
cook = cook[infl_idx],
leverage = lev[infl_idx],
resid_std = rstandard(m1)[infl_idx]
) |>
dplyr::arrange(dplyr::desc(cook))
# ---- Resumen de pruebas (ASCII + UTF-8) ----
sum_row <- tibble::tibble(
Prueba = c("Breusch-Pagan (heterocedasticidad)",
"ncvTest (varianza no constante)",
"Durbin-Watson (autocorrelacion)",
"Shapiro-Wilk (normalidad; muestra)",
"VIF (maximo)"),
Estadistico = c(unname(bp$statistic), ncv$ChiSquare,
unname(dw$statistic), sh$statistic, max(vif_vals)),
p_value = c(bp$p.value, ncv$p, dw$p.value, sh$p.value, NA_real_)
)
sum_row_fmt <- sum_row |>
dplyr::mutate(dplyr::across(where(is.numeric), ~ round(.x, 4))) |>
to_utf8_df()
DT::datatable(
sum_row_fmt,
caption = to_utf8("Pruebas de supuestos - Resumen")
)
# ---- VIF ----
vif_tbl <- tibble::tibble(
Variable = names(vif_vals),
VIF = round(as.numeric(vif_vals), 2)
) |>
to_utf8_df()
DT::datatable(
vif_tbl,
caption = to_utf8("VIF por variable (colinealidad)")
)
# ---- Observaciones influyentes ----
if (nrow(infl_tbl)) {
infl_tbl_fmt <- infl_tbl |>
dplyr::mutate(dplyr::across(where(is.numeric), ~ round(.x, 4))) |>
to_utf8_df()
DT::datatable(
head(infl_tbl_fmt, 10),
caption = to_utf8(paste0(
"Observaciones influyentes (Cook > ", round(thr_cook, 4), ") - Top 10"
))
)
} else {
htmltools::div(to_utf8("Sin observaciones influyentes (Cook <= umbral)."))
}
Las pruebas indican heterocedasticidad (Breusch–Pagan y ncvTest, p < 0.001) y no normalidad de los residuos (Shapiro–Wilk, p < 0.001), mientras que no se evidencia autocorrelación (Durbin–Watson, p ≈ 0.61). Los factores de inflación de varianza (VIF) son bajos (máx. ≈ 2.95), por lo que no hay colinealidad preocupante. Se detectaron observaciones influyentes (Cook’s D elevadas). Para la interpretación de coeficientes se emplean errores estándar robustos (HC3) y se reportan resultados consistentes. Como análisis de sensibilidad opcional, se evaluó una especificación logarítmica de precio para mitigar la heterocedasticidad.
if (!requireNamespace("sandwich", quietly = TRUE)) install.packages("sandwich")
if (!requireNamespace("lmtest", quietly = TRUE)) install.packages("lmtest")
# Carga
library(lmtest)
library(sandwich)
library(broom) # ya lo venías usando
library(dplyr) # para mutate/select
# Matriz de var-cov robusta (HC3) y test de coeficientes
rob_vcov <- sandwich::vcovHC(m1, type = "HC3")
rob_ct <- lmtest::coeftest(m1, vcov. = rob_vcov)
# A tabla ordenada + IC al 95% (normal aprox.)
alpha <- 0.05; z <- qnorm(1 - alpha/2)
coefs_rob <- broom::tidy(rob_ct) |>
dplyr::mutate(
conf.low = estimate - z * std.error,
conf.high = estimate + z * std.error
)
DT::datatable(
coefs_rob |> dplyr::mutate(across(where(is.numeric), ~round(.x, 4))),
caption = "Coeficientes del modelo con errores robustos (HC3)"
)
Las pruebas indican heterocedasticidad (Breusch–Pagan, p < 0.001) y no normalidad de residuos (Shapiro–Wilk, p < 0.001), sin evidencia de autocorrelación (Durbin–Watson, p ≈ 0.61) ni colinealidad problemática (VIF máx. ≈ 2.95). En consecuencia, los errores estándar se estimaron con HC3, manteniendo los coeficientes del modelo. Los efectos robustos muestran que el precio (MM) aumenta con el área, el estrato, los parqueaderos y los baños; mientras que, a área fija, un mayor número de habitaciones se asocia a una leve reducción del valor, consistente con cuartos más pequeños y menor “calidad percibida”. Los intervalos de confianza robustos confirman significancia en todas las variables.
Con el modelo estimado se calcula un rango esperado (valores puntuales/intervalos) para cada solicitud, usando los perfiles requeridos.
Solicitud 1 (Casa – Zona Norte; 200 m²; 1 parq; 2 baños; 4 hab; estrato 4–5): rango esperado r v1_fit_min–r v1_fit_max MM.
Solicitud 2 (Apartamento – Zona Sur; 300 m²; 3 parq; 3 baños; 5 hab; estrato 5–6): rango esperado r v2_fit_min–r v2_fit_max MM.
# Helpers para forzar UTF-8 (solo se definen si no existen)
if (!exists("to_utf8_df")) {
to_utf8_df <- function(df) {
df <- df |>
dplyr::mutate(dplyr::across(where(is.character),
~ iconv(.x, from = "", to = "UTF-8", sub = "?")))
names(df) <- iconv(names(df), from = "", to = "UTF-8", sub = "?")
df
}
}
if (!exists("to_utf8")) {
to_utf8 <- function(x) iconv(x, from = "", to = "UTF-8", sub = "?")
}
# --- Solicitud 1: Casa Zona Norte ---
new_s1 <- tibble::tibble(
areaconst = c(200, 200),
estrato = c(4, 5),
parqueaderos = 1,
banios = 2,
habitaciones = 4
)
pred_s1 <- as.data.frame(
predict(m1, newdata = new_s1, interval = "prediction", level = 0.95)
)
pred_s1 <- dplyr::bind_cols(new_s1, pred_s1) |>
dplyr::mutate(lwr = pmax(lwr, 0)) |>
dplyr::mutate(dplyr::across(where(is.numeric), ~ round(.x, 1))) |>
to_utf8_df()
# Rango para inline
v1_fit_min <- min(pred_s1$lwr)
v1_fit_max <- max(pred_s1$upr)
DT::datatable(
pred_s1,
caption = to_utf8("Predicciones S1 (con IC de prediccion 95%)"),
options = list(pageLength = 10)
)
# --- Solicitud 2: Apto Sur (300 m2; estratos 5-6; 3 parq; 3 banos; 5 hab) ---
new_s2 <- tibble::tibble(
areaconst = c(300, 300),
estrato = c(5, 6),
parqueaderos = 3,
banios = 3,
habitaciones = 5
)
pred_s2 <- as.data.frame(
predict(m1, newdata = new_s2, interval = "prediction", level = 0.95)
)
pred_s2 <- dplyr::bind_cols(new_s2, pred_s2) |>
dplyr::mutate(lwr = pmax(lwr, 0)) |>
dplyr::mutate(dplyr::across(where(is.numeric), ~ round(.x, 1))) |>
to_utf8_df()
# Rango para inline
v2_fit_min <- min(pred_s2$lwr)
v2_fit_max <- max(pred_s2$upr)
DT::datatable(
pred_s2,
caption = to_utf8("Predicciones S2 (con IC de prediccion 95%)"),
options = list(pageLength = 10)
)
Solicitud 1: rango esperado 0–693
MM.
Solicitud 2: rango esperado 264.2–1053.3
MM.
Interpretación:
Solicitud 1 — Casa, Zona Norte (200 m²; 1 parq; 2 baños; 4 hab)
Estrato 4: estimado puntual ≈ 250 MM; intervalo de predicción 95%: 0–596.5 MM. Interpretación: para un inmueble con esas características, el precio observado podría caer en un rango amplio, con centro alrededor de 250 MM. El presupuesto de 350 MM está por encima del estimado, pero dentro del rango posible.
Estrato 5: estimado puntual ≈ 346.7 MM; intervalo 95%: 0.5–693.0 MM. Interpretación: el punto central está muy próximo al presupuesto de 350 MM; es un objetivo realista. Conviene buscar en 330–370 MM y estar preparado para ver listados por encima, dado el intervalo.
Nota: los límites inferiores se truncaron en 0 para evitar valores negativos. Los intervalos son de predicción (para una vivienda individual), por eso son amplios.
Solicitud 2 — Apartamento, Zona Sur (300 m²; 3 parq; 3 baños; 5 hab)
Estrato 5: estimado puntual ≈ 610.4 MM; intervalo 95%: 264.2–956.7 MM.
Estrato 6: estimado puntual ≈ 706.9 MM; intervalo 95%: 360.5–1053.3 MM.
Interpretación: con tope de 850 MM, ambos escenarios son compatibles con el presupuesto. El nivel de precios esperado se concentra alrededor de 600–710 MM, con variación sustancial según barrio y estado del inmueble. Para búsqueda/negociación, un rango operativo 600–750 MM es razonable (estrato 6 tiende a estar más alto).
Lectura general
Los intervalos de predicción cuantifican la variabilidad esperada del precio real para un caso nuevo; no son intervalos de confianza del promedio. Por eso la incertidumbre es alta.
La amplitud de los rangos refleja:
heterocedasticidad detectada en el paso 4,variables omitidas (barrio específico, estado, antigüedad, amenities),mezcla de segmentos del mercado.
Aun así, los puntos estimados son coherentes con la EDA: el precio sube con área, estrato, parqueaderos y baños.
Para acotar la incertidumbre en futuras iteraciones, sugerimos: modelar log(precio), incluir dummies de barrio/zona, añadir antigüedad/calidad, e incorporar interacciones (p. ej., área×tipo).
Resumen operativo
S1: objetivo realista ≈ 350 MM (estrato 5); en estrato 4, puedes encontrar opciones por debajo del presupuesto.
S2: objetivo ≈ 600–710 MM; presupuesto de 850 MM cubre la mayor parte de casos comparables en estratos 5–6.
Se priorizan Top 5 Casas – Zona Norte con precio ≤ $350 MM y área cercana a 200 m² (tolerancia inicial 15%, ampliada a 20% si faltan opciones), manteniendo mínimos (≥1 parq, ≥2 baños, ≥4 hab; estrato 4–5).
# Helpers para UTF-8 (una sola vez)
if (!exists("to_utf8_df")) {
to_utf8_df <- function(df) {
df <- df |>
dplyr::mutate(dplyr::across(where(is.character),
~ iconv(.x, from = "", to = "UTF-8", sub = "?")))
names(df) <- iconv(names(df), from = "", to = "UTF-8", sub = "?")
df
}
}
if (!exists("to_utf8")) {
to_utf8 <- function(x) iconv(x, from = "", to = "UTF-8", sub = "?")
}
# Fuente (depurada si existe)
fuente_s1 <- if (exists("base1_clean")) base1_clean else base1
tol_area1 <- 0.15 # tolerancia ±15% alrededor de 200 m^2
cand1 <- fuente_s1 %>%
dplyr::filter(
preciom <= 350,
estrato %in% c(4, 5),
parqueaderos >= 1, banios >= 2, habitaciones >= 4
) %>%
dplyr::mutate(
area_diff = abs(areaconst - 200) / 200,
score = (1 - pmin(area_diff, 1)) +
0.2*(parqueaderos >= 1) +
0.2*(banios >= 2) +
0.2*(habitaciones >= 4)
) %>%
dplyr::arrange(dplyr::desc(score), area_diff, preciom)
# Dentro de tolerancia; si hay pocos, relajar a 20%
cand1_sel <- cand1 %>% dplyr::filter(area_diff <= tol_area1)
if (nrow(cand1_sel) < 5) {
cand1_sel <- cand1 %>% dplyr::filter(area_diff <= 0.20)
}
# Top 5 con coordenadas válidas
cand1_sel <- cand1_sel %>%
dplyr::slice_head(n = 5) %>%
dplyr::filter(!is.na(longitud), !is.na(latitud)) %>%
to_utf8_df() # <- forzar UTF-8 en todas las columnas de texto
DT::datatable(
cand1_sel %>%
dplyr::select(barrio, zona, estrato, areaconst, parqueaderos, banios, habitaciones, preciom),
caption = to_utf8("Top 5 ofertas - Solicitud 1 (<= 350 MM)")
)
# Popup seguro en UTF-8
popup_txt <- with(cand1_sel, paste0(
"<b>Casa - ", barrio, "</b><br>",
"Estrato ", estrato, " | Area ", areaconst, " m\u00B2<br>",
"Parq ", parqueaderos, " | Ba\u00F1os ", banios,
" | Hab ", habitaciones, "<br>",
"<b>Precio:</b> $", round(preciom, 1), " MM"
))
popup_txt <- to_utf8(popup_txt)
leaflet::leaflet(cand1_sel) %>%
leaflet::addTiles() %>%
leaflet::addCircleMarkers(
~longitud, ~latitud,
popup = popup_txt,
radius = 7, fillOpacity = 0.85
)
Resultado S1. Se priorizaron 5 casas cercanas a
200 m² que cumplen mínimos (≥1 parqueadero, ≥2 baños,
≥4 habitaciones; estratos 4–5).
Área media: 201 m² (rango 200–203).
Precio medio: 333 MM (rango 320–350 MM).
Barrios sugeridos: la flora, la merced, el bosque, vipasa.
Ver tabla “Top 5 ofertas — Solicitud 1 (≤ 350 MM)” y el mapa interactivo.
Interpretación
Con los criterios definidos (casa en Zona Norte, precio ≤ $350 MM, estrato 4–5, ≥ 1 parqueadero, ≥ 2 baños y ≥ 4 habitaciones) y priorizando cercanía al objetivo de 200 m² (tolerancia ±15%), el filtro arrojó cinco opciones concentradas en La Flora, La Merced, El Bosque y Vipasa. Todas cumplen los mínimos y se ubican en el corredor norte (Av. 3N/2N), lo que sugiere buena accesibilidad y servicios.
El conjunto presenta un área media de 201 m² (rango 200–203 m²) y un precio medio de 333 MM (rango 320–350 MM), equivalente a ~1.60–1.75 MM/m². Esto ubica las alternativas en la banda baja-media del mercado norte para el metraje objetivo, con precios relativamente consistentes entre sí.
En términos de conveniencia, La Flora (200 m², estrato 5, 2 parq, 4 baños, 4 hab, 320 MM) y La Merced (200 m², estrato 4, 2 parq, 4 baños, 4 hab, 320 MM) destacan por mejor relación m²/precio; la diferencia principal es el estrato (E5 vs. E4). Si el criterio crítico es más parqueaderos, la opción de El Bosque (200 m², 3 parq, 3 baños, 4 hab, 350 MM) aporta valor funcional aun con un precio tope. Si la prioridad es 5 habitaciones, El Bosque (202 m², 1 parq, 4 baños, 5 hab, 335 MM) equilibra metraje y capacidad. Vipasa (203 m², 2 parq, 3 baños, 4 hab, 340 MM) ofrece un punto medio en precio y dotación.
En suma, el mercado ofrece alternativas alineadas con el perfil S1 en el entorno de 200 m² y $320–350 MM. Operativamente, conviene visitar primero La Flora y La Merced (mejor m²/precio), y mantener margen de negociación en $320–335 MM según estado, antigüedad y costos de administración. Antes de decidir, verifique ruido/flujo vial, cercanía a servicios, tiempo en mercado y posibles costos de actualización, especialmente en las opciones con precio más alto o dotaciones “extra” (más parqueaderos o más habitaciones).
Se generan Top 5 Apartamentos – Zona Sur con precio ≤ $850 MM y área cercana a 300 m². Se distinguen coincidencias estrictas (cumplen Parq ≥3, Baños ≥3, Hab ≥5) y cercanas (faltan 1–2 requisitos pero son buenas alternativas), para no perder opciones de mercado.
# --- Helpers UTF-8 (solo se crean si no existen) ---
if (!exists("to_utf8_df")) {
to_utf8_df <- function(df) {
df <- df |>
dplyr::mutate(dplyr::across(where(is.character),
~ iconv(.x, from = "", to = "UTF-8", sub = "?")))
names(df) <- iconv(names(df), from = "", to = "UTF-8", sub = "?")
df
}
}
if (!exists("to_utf8")) {
to_utf8 <- function(x) iconv(x, from = "", to = "UTF-8", sub = "?")
}
# --- Selección S2 ---
tol_area2 <- 0.15
cand2 <- vivienda %>%
dplyr::filter(
stringr::str_detect(tipo, stringr::regex("^Apartamento$", TRUE)),
stringr::str_detect(zona, stringr::regex("\\bSur\\b", TRUE)),
preciom <= 850,
estrato %in% c(5, 6)
) %>%
dplyr::mutate(
area_diff = abs(areaconst - 300) / 300,
strict_ok = parqueaderos >= 3 & banios >= 3 & habitaciones >= 5
) %>%
dplyr::arrange(dplyr::desc(strict_ok), area_diff, preciom)
cand2_sel <- cand2 %>% dplyr::filter(strict_ok, area_diff <= tol_area2)
if (nrow(cand2_sel) < 5) {
cand2_sel <- cand2 %>% dplyr::filter(strict_ok, area_diff <= 0.20)
}
if (nrow(cand2_sel) < 5) {
faltan <- 5 - nrow(cand2_sel)
cerca2 <- cand2 %>%
dplyr::filter(!strict_ok) %>%
dplyr::mutate(gap = (parqueaderos < 3) + (banios < 3) + (habitaciones < 5)) %>%
dplyr::arrange(gap, area_diff, preciom)
cand2_final <- dplyr::bind_rows(
dplyr::mutate(cand2_sel, match = "estricto"),
dplyr::mutate(dplyr::slice_head(cerca2, n = faltan), match = "cercano")
)
} else {
cand2_final <- dplyr::mutate(cand2_sel, match = "estricto")
}
# Chequeos
stopifnot(all(stringr::str_detect(cand2_final$tipo, stringr::regex("^Apartamento$", TRUE))))
stopifnot(all(stringr::str_detect(cand2_final$zona, stringr::regex("Sur", TRUE))))
stopifnot(all(cand2_final$estrato %in% c(5, 6)))
# Top 5 con coordenadas válidas + forzar UTF-8 en texto
cand2_final <- cand2_final %>%
dplyr::filter(!is.na(longitud), !is.na(latitud)) %>%
dplyr::slice_head(n = 5) %>%
to_utf8_df()
# Tabla (caption en UTF-8 simple)
DT::datatable(
cand2_final %>% dplyr::select(
match, barrio, zona, estrato, areaconst, parqueaderos, banios, habitaciones, preciom
),
caption = to_utf8("Solicitud 2: Top 5 (estrictos y cercanos, ZONA SUR)")
)
# Popup sin caracteres problemáticos
popup2 <- with(cand2_final, paste0(
"<b>Apartamento - ", barrio, "</b><br>",
"Estrato ", estrato, " | Area ", areaconst, " m\u00B2<br>",
"Parq ", parqueaderos, " | Ba\u00F1os ", banios,
" | Hab ", habitaciones, "<br>",
"<b>Precio:</b> $", round(preciom, 1), " MM",
"<br><i>Coincidencia: ", match, "</i>"
))
popup2 <- to_utf8(popup2)
leaflet::leaflet(cand2_final) %>%
leaflet::addTiles() %>%
leaflet::addCircleMarkers(
~longitud, ~latitud,
color = ~ifelse(match == "estricto", "blue", "orange"),
popup = popup2,
radius = 7, fillOpacity = 0.85
)
Rango esperado de precio: 264.2–1053.3 MM.
Resultado S2. Se priorizaron 5 apartamentos cercanos
a 300 m² (estratos 5–6):
2 estrictos y 3 cercanos
(azul = estricto, naranja = cercano en el mapa).
Área media: 274 m² (rango 256–300);
precio medio: 530 MM (rango 350–670 MM).
Barrios sugeridos: seminario, ciudadela pasoancho, capri, pampa
linda.
Ver tabla “Solicitud 2: Top 5 (estrictos y cercanos, ZONA SUR)” y el mapa interactivo.
Interpretación El mercado en la Zona Sur ofrece alternativas para un apartamento alrededor de 300 m². Encontramos dos opciones que cumplen estrictamente los mínimos (≥3 parques, ≥3 baños, ≥5 hab, estrato 5–6), ambas en Seminario: una cerca de 300 m² y otra de 256 m². Estas reúnen la mejor combinación de tamaño, dotación y localización, con precios de referencia alrededor de $530–670 MM. El resto de opciones identificadas son “cercanas” (ceden 1–2 requisitos) en Capri, Pampa Linda y Ciudadela Pasonancho; allí el precio baja (ej. Capri ~ $350 MM) pero se sacrifica, sobre todo, número de habitaciones o baños. En conjunto, las cinco alternativas se sitúan en 274 m² promedio (256–300) y $530 MM promedio (350–670), dentro de la banda que sugiere el modelo ($264,2–1053,3 MM), lo que respalda la consistencia de precios observados.
En términos de conveniencia, Seminario luce como el polo con mejor valor esperado: cumple requisitos, concentra oferta y reduce riesgos de adecuaciones. Capri destaca por precio si se acepta 4 habitaciones (en lugar de 5); Pampa Linda y Pasonancho son viables solo si se flexibiliza habitaciones/baños o hay posibilidad de adecuación. Por ello, la ruta práctica sería priorizar visitas y negociación primero en Seminario (especialmente la unidad ~256 m² por su relación atributos/precio) y dejar como plan B la unidad ~300 m² si se privilegia mayor área. Las demás opciones quedarían en reserva condicionada a flexibilización de requisitos o a obtener mejores condiciones (precio/adecuaciones/documentos).
El análisis exploratorio confirmó una relación clara y positiva entre el área construida y el precio. En S1 (casas en Zona Norte) la correlación precio–área ronda 0,5 y las medianas por estrato se ordenan como cabría esperar (E4 ≈ 317,5 MM; E5 ≈ 340,0 MM), lo que sugiere un mercado coherente con la calidad del entorno y los atributos físicos. En S2 (apartamentos en Zona Sur) el patrón es similar, con diferencias por microzona que ya se insinúan en los gráficos.
El modelo lineal múltiple, estimado con área, estrato, parqueaderos, baños y habitaciones, explica cerca del 71–72 % de la variación de precios en prueba (R² ≈ 0,71; RMSE ≈ 179 MM; MAE ≈ 116 MM; MAPE ≈ 26 %). Económicamente, a igualdad del resto, cada m² adicional se asocia con +0,835 MM (~$835 mil) en el precio; subir un nivel de estrato añade ~+96,4 MM; cada parqueadero suma ~+75,5 MM y cada baño ~+61,1 MM. A área fija, más habitaciones se asocian con una leve reducción del valor (−31,9 MM), consistente con cuartos más pequeños o menor “calidad percibida”. Las pruebas detectan heterocedasticidad y no normalidad —esperables en precios—, sin autocorrelación y sin colinealidad preocupante (VIF máx. ≈ 2,95). Por ello se usaron errores robustos (HC3), que mantienen la significancia de los efectos.
Con ese modelo, las predicciones se utilizan como bandas de negociación. Para S1 (casa, Norte, 200 m², 1 parq, 2 baños, 4 hab) el rango de precio esperado va de 0 a 693 MM (puntos de referencia: E4 ≈ 250 MM; E5 ≈ 347 MM). Para S2 (apartamento, Sur, 300 m², 3 parq, 3 baños, 5 hab) el rango es 264–1053 MM (puntos: E5 ≈ 610 MM; E6 ≈ 707 MM). La amplitud de los intervalos refleja la heterogeneidad del mercado; deben leerse como márgenes razonables para ofertar/cerrar, no como precios puntuales.
Integrando datos y criterios, en S1 se priorizaron cinco casas en la Zona Norte con precio ≤ 350 MM y área cercana a 200 m². El conjunto seleccionado presenta un área media de ~201 m² (200–203 m²) y un precio medio de ~333 MM (320–350 MM). Los barrios con mejor encaje son la flora, la merced, el bosque y vipasa, donde se observa buena convergencia entre atributos y valoración del modelo.
En S2 se priorizaron cinco apartamentos en la Zona Sur cercanos a 300 m² y con tope de 850 MM: dos cumplen estrictamente todos los mínimos (Seminario) y tres son alternativas cercanas (Capri, Pampa Linda y Ciudadela Pasonancho). El área media del grupo es ~274 m² (256–300 m²) y el precio medio ~530 MM (350–670 MM). Seminario ofrece el mejor balance entre atributos y precio; Capri se perfila como opción de ahorro si se flexibiliza un requisito.
En términos de decisión, para S1 es razonable negociar en el entorno 320–350 MM en los barrios listados; para S2, 530–670 MM es un corredor consistente, con picos cercanos a 700 MM en estrato 6 y ubicaciones premium. Se recomienda iniciar visitas y negociación en esos “clusters”, usando las bandas del modelo para anclar ofertas y detectar outliers. Como mejora futura, conviene modelar el logaritmo del precio, incorporar variables de micro-localización y estado/amenities, permitir no linealidades e interacciones, y contrastar con métodos complementarios (p. ej., regresión cuantílica).