# Ejecutar UNA sola vez antes de compilar. No corre durante el knit.
install.packages(c("tidyverse", "plotly", "leaflet", "knitr", "kableExtra",
"modelsummary", "broom", "car", "lmtest", "nortest", "devtools"))
devtools::install_github("centromagis/paqueteMODELOS", force = TRUE)María, gerente de C&A, recibió la solicitud de una compañía internacional que necesita reubicar dos familias en Cali. Cada solicitud llega con un perfil cerrado y un crédito preaprobado que no se puede exceder.
| Caracteristica | Solicitud 1 | Solicitud 2 |
|---|---|---|
| Tipo | Casa | Apartamento |
| Área construida (m2) | 200 | 300 |
| Parqueaderos | 1 | 3 |
| Banos | 2 | 3 |
| Habitaciones | 4 | 5 |
| Estrato | 4 o 5 | 5 o 6 |
| Zona | Norte | Sur |
| Credito preaprobado | $350 millones | $850 millones |
Se construyen dos submercados independientes (casas del norte y apartamentos del sur) y sobre cada uno se recorren los siete pasos solicitados: filtro y mapa, análisis exploratorio, estimación por MCO, validación de supuestos, comparación de modelos, predicción y búsqueda de ofertas. Todas las cifras del texto se calculan al compilar, de modo que la narrativa y las salidas de R nunca se contradicen.
#> 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.51, -76.51, -76.52, -76.54, -76.51, -76.52, -76.52, -…
#> $ latitud <dbl> 3.434, 3.434, 3.436, 3.435, 3.459, 3.370, 3.426, 3.383, 3…
datos_crudos |>
group_by(tipo) |>
summarise(Ofertas = n(),
`NA piso` = sum(is.na(piso)),
`% piso` = 100 * mean(is.na(piso)),
`NA parqueaderos` = sum(is.na(parqueaderos)),
`% parqueaderos` = 100 * mean(is.na(parqueaderos)),
.groups = "drop") |>
tabla("Tabla 2. Valores faltantes por tipo de vivienda")| tipo | Ofertas | NA piso | % piso | NA parqueaderos | % parqueaderos |
|---|---|---|---|---|---|
| Apartamento | 5,100 | 1,381 | 27.08 | 869 | 17.04 |
| Casa | 3,219 | 1,254 | 38.96 | 733 | 22.77 |
| NA | 3 | 3 | 100.00 | 3 | 100.00 |
El patrón no es aleatorio y eso define el tratamiento. En las casas
la variable piso carece de sentido (una casa no ocupa un
piso dentro de una edificación) y ahí se concentran los vacíos; como
además no entra en el modelo que fija el enunciado, no se imputa. Los
faltantes de parqueaderos se comportan como un cero
no diligenciado: el anunciante omite el campo cuando el
inmueble no tiene parqueadero, así que imputar con la media
sobreestimaría el atributo. Se imputan con 0.
limites_cali <- list(lat = c(3.28, 3.56), lon = c(-76.62, -76.42))
datos <- datos_crudos |>
mutate(
parqueaderos = replace_na(parqueaderos, 0),
coord_valida = !is.na(latitud) & !is.na(longitud) &
between(latitud, limites_cali$lat[1], limites_cali$lat[2]) &
between(longitud, limites_cali$lon[1], limites_cali$lon[2]),
precio_m2 = if_else(!is.na(areaconst) & areaconst > 0, preciom / areaconst, NA_real_)
)
tibble(Indicador = c("Registros totales", "NA en parqueaderos tras imputar",
"Coordenadas validas", "Coordenadas inconsistentes"),
Valor = c(nrow(datos), sum(is.na(datos$parqueaderos)),
sum(datos$coord_valida), sum(!datos$coord_valida))) |>
tabla("Tabla 3. Resultado del preprocesamiento")| Indicador | Valor |
|---|---|
| Registros totales | 8,322 |
| NA en parqueaderos tras imputar | 0 |
| Coordenadas validas | 8,319 |
| Coordenadas inconsistentes | 3 |
Como el analisis se repite dos veces (casas del norte y apartamentos del sur), en lugar de copiar el mismo codigo en ambas partes se definen aqui, una sola vez, las funciones que se usaran en los siete pasos de cada solicitud. La ventaja es que cualquier decision metodologica (como se imputa, como se mide el error, que umbral se usa) queda escrita en un solo lugar y se aplica de forma identica a los dos submercados, de modo que los resultados son comparables por construccion.
Cada funcion corresponde a un paso concreto del enunciado:
| Funcion | Para que sirve | Se usa en |
|---|---|---|
construir_base() |
Filtra la base por tipo y zona y crea las (k-1) variables dummy del estrato | Punto 1 |
centroides_zona y marcar_coherencia() |
Calculan el centro geografico de cada zona y marcan si cada oferta cae o no donde dice su rotulo | Punto 1 (mapa) |
formula_nivel, formula_log,
especificaciones |
Guardan las dos ecuaciones que se van a comparar: precio en pesos y precio en logaritmos | Puntos 3 y 5 |
factor_duan() y predecir_millones() |
Devuelven las predicciones siempre en millones de pesos; el factor de Duan corrige el sesgo que aparece al deshacer el logaritmo | Puntos 5, 6 y 7 |
validacion_repetida(),
resumen_validacion(),
grafico_validacion() |
Parten la base en 70/30 cien veces, calculan el error de prediccion en cada corte y resumen su distribucion | Punto 5 |
clasificar_vif() y vif_max() |
Traducen el VIF a los umbrales del curso (hasta 5 sin problema, 5 a 10 moderada, mas de 10 severa) | Punto 4 |
tabla_supuestos() |
Corre de una vez las pruebas de Breusch-Pagan, Goldfeld-Quandt, Durbin-Watson, Anderson-Darling y la distancia de Cook | Punto 4 |
buscar_ofertas() y mapa_ofertas() |
Filtran las viviendas que caben en el presupuesto, las ordenan por parecido al perfil pedido y las dibujan en el mapa | Punto 7 |
Ademas, en un bloque oculto al inicio se definieron cuatro ayudas de
formato: fmt() para mostrar numeros con dos decimales,
p_fmt() para los valores p, veredicto() para
escribir si un coeficiente es significativo, coef_de() para
leer un coeficiente del modelo sin que falle el documento si no existe,
y tabla() para dar el mismo estilo a todas las tablas. Son
las que permiten que las cifras del texto se calculen solas al
compilar.
# 1. Submercado y codificacion dummy (k-1 categorias) -------------------------
construir_base <- function(datos, tipo_obj, zona_obj) {
base <- datos |>
filter(str_detect(tipo, regex(tipo_obj, ignore_case = TRUE)),
str_detect(zona, regex(zona_obj, ignore_case = TRUE)))
niveles <- sort(unique(base$estrato))
base$estrato_f <- factor(base$estrato, levels = niveles)
for (e in niveles[-1]) base[[paste0("D_estrato", e)]] <- as.numeric(base$estrato == e)
base
}
# 2. Coherencia entre zona declarada y ubicacion real -------------------------
centroides_zona <- datos |>
filter(coord_valida) |>
group_by(zona) |>
summarise(lat_c = median(latitud), lon_c = median(longitud), .groups = "drop")
marcar_coherencia <- function(base) {
base |>
rowwise() |>
mutate(zona_geo = if (coord_valida) {
centroides_zona$zona[which.min(sqrt((latitud - centroides_zona$lat_c)^2 +
(longitud - centroides_zona$lon_c)^2))]
} else NA_character_) |>
ungroup() |>
mutate(coherente = zona_geo == zona)
}
# 3. Especificaciones a comparar ----------------------------------------------
formula_nivel <- preciom ~ areaconst + estrato_f + habitaciones + banios + parqueaderos
formula_log <- log(preciom) ~ areaconst + estrato_f + habitaciones + banios + parqueaderos
especificaciones <- list("A. Nivel-nivel" = list(f = formula_nivel, log = FALSE),
"B. Log-nivel" = list(f = formula_log, log = TRUE))
factor_duan <- function(modelo) mean(exp(residuals(modelo)))
predecir_millones <- function(modelo, datos_nuevos, escala_log) {
p <- predict(modelo, newdata = datos_nuevos)
if (escala_log) p <- exp(p) * factor_duan(modelo)
as.numeric(p)
}
# 4. Validacion simple repetida (validacion cruzada) --------------------------
validacion_repetida <- function(base, prop = 0.7, reps = 100, semilla = 2024) {
set.seed(semilla)
n <- nrow(base); nombres <- names(especificaciones)
MSE <- matrix(NA_real_, reps, length(nombres), dimnames = list(NULL, nombres))
MAE <- MSE; MAPE <- MSE
for (i in seq_len(reps)) {
idx <- sample(seq_len(n), size = round(prop * n))
train <- base[idx, ]; test <- base[-idx, ]
if (nrow(test) < 5) next
for (j in seq_along(nombres)) {
m <- lm(especificaciones[[j]]$f, data = train)
p <- predecir_millones(m, test, especificaciones[[j]]$log)
MSE[i, j] <- mean((test$preciom - p)^2, na.rm = TRUE)
MAE[i, j] <- mean(abs(test$preciom - p), na.rm = TRUE)
MAPE[i, j] <- 100 * mean(abs(test$preciom - p) / test$preciom, na.rm = TRUE)
}
}
list(MSE = MSE, MAE = MAE, MAPE = MAPE)
}
resumen_validacion <- function(vc) {
tibble(Modelo = colnames(vc$MSE),
`MSE medio` = colMeans(vc$MSE, na.rm = TRUE),
`RMSE medio` = sqrt(colMeans(vc$MSE, na.rm = TRUE)),
`MAE medio` = colMeans(vc$MAE, na.rm = TRUE),
`MAPE medio (%)` = colMeans(vc$MAPE, na.rm = TRUE))
}
grafico_validacion <- function(vc, titulo) {
as.data.frame(sqrt(vc$MSE)) |>
pivot_longer(everything(), names_to = "Modelo", values_to = "RMSE") |>
ggplot(aes(x = Modelo, y = RMSE, fill = Modelo)) +
geom_boxplot(outlier.shape = NA, alpha = 0.55) +
geom_jitter(width = 0.12, alpha = 0.3, size = 0.9, colour = "#034A94") +
scale_fill_manual(values = paleta) + coord_flip() +
labs(title = titulo, subtitle = "100 particiones aleatorias 70/30",
x = NULL, y = "RMSE en el conjunto de prueba (millones)") +
theme_bw() + theme(legend.position = "none")
}
# 5. Diagnosticos --------------------------------------------------------------
clasificar_vif <- function(v) {
if (is.na(v)) "no evaluable"
else if (v <= 5) "sin multicolinealidad"
else if (v <= 10) "multicolinealidad moderada"
else "multicolinealidad severa"
}
vif_max <- function(modelo) {
g <- vif(modelo)
if (is.matrix(g)) max(g[, ncol(g)]^2) else max(g)
}
tabla_supuestos <- function(modelo) {
bp <- bptest(modelo); gq <- gqtest(modelo, order.by = fitted(modelo))
dw <- dwtest(modelo); ad <- ad.test(residuals(modelo))
cook <- cooks.distance(modelo)
tibble(
Supuesto = c("Varianza constante", "Varianza constante", "Independencia",
"Normalidad", "Observaciones influyentes"),
Prueba = c("Breusch-Pagan", "Goldfeld-Quandt", "Durbin-Watson",
"Anderson-Darling", "Distancia de Cook > 4/n"),
`Estadistico` = c(unname(bp$statistic), unname(gq$statistic), unname(dw$statistic),
unname(ad$statistic), sum(cook > 4 / length(cook))),
`Valor p` = c(bp$p.value, gq$p.value, dw$p.value, ad$p.value, NA_real_))
}
# 6. Busqueda de ofertas -------------------------------------------------------
buscar_ofertas <- function(base, modelo, escala_log, requerimiento, presupuesto, top = 5) {
vars <- c("areaconst", "habitaciones", "banios", "parqueaderos")
escalas <- map_dbl(vars, ~ sd(base[[.x]], na.rm = TRUE)); names(escalas) <- vars
escalas[escalas == 0 | is.na(escalas)] <- 1
factibles <- base |>
filter(preciom <= presupuesto, estrato_f %in% requerimiento$estratos)
if (nrow(factibles) == 0) {
return(factibles |> mutate(distancia = numeric(0), valor_modelo = numeric(0),
brecha = numeric(0), `brecha %` = numeric(0)))
}
d <- map_dbl(seq_len(nrow(factibles)), function(i) {
sqrt(sum(map_dbl(vars, ~ ((factibles[[.x]][i] - requerimiento[[.x]]) / escalas[[.x]])^2)))
})
factibles |>
mutate(distancia = d,
valor_modelo = predecir_millones(modelo, factibles, escala_log),
brecha = valor_modelo - preciom,
`brecha %` = 100 * brecha / valor_modelo) |>
arrange(distancia, desc(brecha)) |>
slice_head(n = top)
}
mapa_ofertas <- function(ofertas, titulo) {
ofertas <- ofertas |> filter(coord_valida)
if (nrow(ofertas) == 0) {
return(leaflet() |> addTiles() |>
addControl(paste0("<b>", titulo, " (sin coordenadas)</b>"), position = "topright"))
}
leaflet(ofertas) |> addTiles() |>
addMarkers(lng = ~longitud, lat = ~latitud,
label = ~paste0(barrio, " - $", round(preciom), " M"),
popup = ~paste0("<b>", barrio, "</b><br/>Precio: $", round(preciom, 1),
" M<br/>\u00c1rea: ", areaconst, " m2<br/>Estrato: ", estrato,
"<br/>Hab: ", habitaciones, " | Ba\u00f1os: ", banios,
" | Parq: ", parqueaderos,
"<br/>Valor del modelo: $", round(valor_modelo, 1), " M")) |>
addControl(paste0("<b>", titulo, "</b>"), position = "topright")
}requerimiento_1 <- list(areaconst = 200, parqueaderos = 1, banios = 2, habitaciones = 4,
estratos = c("4", "5"), presupuesto = 350)
base_norte <- construir_base(datos, "Casa", "Norte")
head(base_norte |> select(id, barrio, estrato, areaconst, habitaciones,
banios, parqueaderos, preciom), 3) |>
tabla("Tabla 4. Primeros tres registros de la base filtrada")| id | barrio | estrato | areaconst | habitaciones | banios | parqueaderos | preciom |
|---|---|---|---|---|---|---|---|
| 1,209 | acopi | 5 | 150 | 6 | 4 | 2 | 320 |
| 1,592 | acopi | 5 | 380 | 3 | 3 | 2 | 780 |
| 4,057 | acopi | 6 | 445 | 6 | 7 | 0 | 750 |
tibble(`Comprobacion` = c("Ofertas en la base", "Tipos presentes", "Zonas presentes",
"Estratos presentes", "Precio minimo (M$)", "Precio maximo (M$)"),
Resultado = c(as.character(nrow(base_norte)),
paste(unique(base_norte$tipo), collapse = ", "),
paste(unique(base_norte$zona), collapse = ", "),
paste(sort(unique(base_norte$estrato)), collapse = ", "),
fmt(min(base_norte$preciom)), fmt(max(base_norte$preciom)))) |>
tabla("Tabla 5. Comprobacion del filtro")| Comprobacion | Resultado |
|---|---|
| Ofertas en la base | 722 |
| Tipos presentes | Casa |
| Zonas presentes | Zona Norte |
| Estratos presentes | 3, 4, 5, 6 |
| Precio minimo (M\() </td> <td style="text-align:center;"> 89.00 </td> </tr> <tr> <td style="text-align:center;"> Precio maximo (M\)) | 1,940.00 |
base_norte |>
group_by(estrato) |>
summarise(Ofertas = n(),
`Participacion (%)` = 100 * n() / nrow(base_norte),
`Area media (m2)` = mean(areaconst, na.rm = TRUE),
`Precio medio (M$)` = mean(preciom, na.rm = TRUE),
`Precio/m2 (M$)` = mean(precio_m2, na.rm = TRUE), .groups = "drop") |>
tabla("Tabla 6. Composicion y precios por estrato")| estrato | Ofertas | Participacion (%) | Area media (m2) | Precio medio (M\() </th> <th style="text-align:center;"> Precio/m2 (M\)) | |
|---|---|---|---|---|---|
| 3 | 235 | 32.55 | 166.1 | 244.2 | 1.74 |
| 4 | 161 | 22.30 | 262.0 | 438.8 | 1.83 |
| 5 | 271 | 37.53 | 325.3 | 549.5 | 1.86 |
| 6 | 55 | 7.62 | 397.6 | 818.2 | 2.28 |
El filtro es correcto: la base contiene un único tipo de vivienda (Casa), una única zona (Zona Norte) y 722 ofertas.
base_norte_geo <- marcar_coherencia(base_norte)
pal_coh <- colorFactor(c("#c0504d", "#2e8b57"), domain = c(FALSE, TRUE))
leaflet(base_norte_geo |> filter(coord_valida)) |>
addTiles() |>
addCircleMarkers(lng = ~longitud, lat = ~latitud, radius = 4, stroke = FALSE,
fillOpacity = 0.75, fillColor = ~pal_coh(coherente),
popup = ~paste0("<b>", barrio, "</b><br/>Zona declarada: ", zona,
"<br/>Zona m\u00e1s cercana por coordenada: ", zona_geo,
"<br/>Precio: $", round(preciom, 1), " M")) |>
addLegend(position = "bottomright", colors = c("#2e8b57", "#c0504d"),
labels = c("Coherente", "Discrepante"), opacity = 0.9,
title = "Zona declarada vs. ubicaci\u00f3n") |>
addControl("<b>Mapa 1. Casas ofertadas en la zona norte</b>", position = "topright")base_norte_geo |>
filter(coord_valida) |>
count(zona_geo, name = "Ofertas") |>
mutate(`% del total` = 100 * Ofertas / sum(Ofertas)) |>
arrange(desc(Ofertas)) |>
tabla("Tabla 7. Zona sugerida por las coordenadas de las casas rotuladas como norte")| zona_geo | Ofertas | % del total |
|---|---|---|
| Zona Norte | 505 | 69.94 |
| Zona Centro | 80 | 11.08 |
| Zona Sur | 68 | 9.42 |
| Zona Oeste | 38 | 5.26 |
| Zona Oriente | 31 | 4.29 |
Discusión: no todos los puntos caen en la zona. Se calculó el centroide de cada zona con toda la base y se reasignó cada oferta a la zona cuyo centroide le queda más cerca. De las 722 casas con coordenada valida, 217 (30.1%) quedarían clasificadas en otra zona. No son necesariamente errores de digitación; hay tres causas plausibles:
Se conserva el rótulo declarado, porque es el criterio con el que la familia conversa sobre la ciudad, pero la discrepancia se retoma al recomendar ofertas concretas.
mat_cor <- base_norte |>
select(preciom, areaconst, estrato, banios, habitaciones, parqueaderos) |>
cor(use = "complete.obs") |> round(2)
plot_ly(x = colnames(mat_cor), y = rownames(mat_cor), z = mat_cor, type = "heatmap",
colors = colorRamp(c("#ffffff", "#6ba3d6", "#1f4e79")), zmin = -1, zmax = 1,
text = mat_cor, texttemplate = "%{text}", hoverinfo = "x+y+z") |>
layout(title = "Correlaciones de Pearson - casas zona norte",
xaxis = list(title = ""), yaxis = list(title = "", autorange = "reversed"))El precio se asocia sobre todo con el área construida (r = 0.73), el estrato (r = 0.61) y los baños (r = 0.52). Las habitaciones muestran una asociación mucho más débil (r = 0.32), lo que anticipa que aportan poca información una vez conocido el tamaño de la casa. La correlación entre área y baños (r = 0.46) es el primer indicio de que los regresores no son independientes y de que habrá que revisar la multicolinealidad.
plot_ly(base_norte, x = ~areaconst, y = ~preciom, color = ~factor(estrato),
colors = paleta, type = "scatter", mode = "markers",
marker = list(size = 7, opacity = 0.65),
text = ~paste0(barrio, "<br/>", areaconst, " m2 - $", round(preciom), " M"),
hoverinfo = "text") |>
layout(title = "Precio vs. \u00e1rea construida por estrato",
xaxis = list(title = "\u00c1rea construida (m2)"),
yaxis = list(title = "Precio (millones)"),
legend = list(title = list(text = "Estrato")))La nube se abre en abanico: la dispersión de precios crece con el área, lo que anticipa que la varianza del error no será constante. Además las pendientes por estrato difieren, es decir, el metro cuadrado no vale lo mismo en todos los estratos.
b1 <- plot_ly(base_norte, x = ~factor(estrato), y = ~preciom, type = "box",
name = "Estrato", marker = list(color = "#e0a800"), line = list(color = "#e0a800"))
b2 <- plot_ly(base_norte, x = ~factor(habitaciones), y = ~preciom, type = "box",
name = "Habitaciones", marker = list(color = "#1f4e79"), line = list(color = "#1f4e79"))
b3 <- plot_ly(base_norte, x = ~factor(banios), y = ~preciom, type = "box",
name = "Banos", marker = list(color = "#2e8b57"), line = list(color = "#2e8b57"))
b4 <- plot_ly(base_norte, x = ~factor(parqueaderos), y = ~preciom, type = "box",
name = "Parqueaderos", marker = list(color = "#c0504d"), line = list(color = "#c0504d"))
subplot(b1, b2, b3, b4, nrows = 2, shareY = TRUE, titleX = FALSE) |>
layout(title = "Precio segun estrato, habitaciones, banos y parqueaderos",
yaxis = list(title = "Precio (millones)"))Los diagramas de caja confirman la lectura: el precio mediano sube de forma clara con el estrato y con el número de baños, sube de forma más irregular con los parqueaderos, y prácticamente no cambia con el número de habitaciones. La dispersión también crece en los grupos de precio alto, señal temprana de heterocedasticidad.
datos |>
filter(str_detect(tipo, "Casa")) |>
group_by(zona) |>
summarise(Ofertas = n(), `Precio mediano (M$)` = median(preciom),
`Precio/m2 mediano (M$)` = median(precio_m2, na.rm = TRUE), .groups = "drop") |>
arrange(desc(`Precio mediano (M$)`)) |>
tabla("Tabla 8. Efecto de la zona (todas las casas de la ciudad)")| zona | Ofertas | Precio mediano (M\() </th> <th style="text-align:center;"> Precio/m2 mediano (M\)) | |
|---|---|---|---|
| Zona Oeste | 169 | 680 | 2.07 |
| Zona Sur | 1,939 | 480 | 2.17 |
| Zona Norte | 722 | 390 | 1.66 |
| Zona Centro | 100 | 310 | 1.49 |
| Zona Oriente | 289 | 235 | 1.26 |
Sobre la variable zona. Dentro de la base filtrada la zona es constante y por eso no tiene varianza ni puede entrar al modelo. Su efecto es justamente lo que justifica haber segmentado: la Tabla 8 muestra que el precio mediano de una casa cambia sustancialmente entre zonas. Restringir el análisis a la zona norte equivale a controlar la localización de la forma más fuerte posible, fijandola, en lugar de resumirla en un promedio.
El estrato es cualitativo, así que se codifica con (k-1) variables binarias dejando el estrato más bajo como categoría de referencia.
base_norte |>
distinct(estrato, pick(starts_with("D_estrato"))) |>
arrange(estrato) |>
tabla("Tabla 9. Codificaci\u00f3n (k-1) de la variable estrato", digitos = 0)| estrato | D_estrato4 | D_estrato5 | D_estrato6 |
|---|---|---|---|
| 3 | 0 | 0 | 0 |
| 4 | 1 | 0 | 0 |
| 5 | 0 | 1 | 0 |
| 6 | 0 | 0 | 1 |
\[\text{preciom}_i = \beta_0 + \beta_1 \text{areaconst}_i + \sum_e \delta_e D^{(e)}_i + \beta_2 \text{habitaciones}_i + \beta_3 \text{banios}_i + \beta_4 \text{parqueaderos}_i + \varepsilon_i\]
#>
#> Call:
#> lm(formula = formula_nivel, data = base_norte)
#>
#> Residuals:
#> Min 1Q Median 3Q Max
#> -968.5 -73.7 -16.1 45.6 1069.4
#>
#> Coefficients:
#> Estimate Std. Error t value Pr(>|t|)
#> (Intercept) 31.2881 17.7126 1.77 0.078 .
#> areaconst 0.8244 0.0431 19.13 < 0.0000000000000002 ***
#> estrato_f4 84.2528 17.5448 4.80 0.0000019133573690 ***
#> estrato_f5 136.7761 16.9079 8.09 0.0000000000000026 ***
#> estrato_f6 329.2170 26.5567 12.40 < 0.0000000000000002 ***
#> habitaciones 1.5129 4.1422 0.37 0.715
#> banios 25.6403 5.3555 4.79 0.0000020523504837 ***
#> parqueaderos 2.3523 4.3557 0.54 0.589
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#>
#> Residual standard error: 157 on 714 degrees of freedom
#> Multiple R-squared: 0.661, Adjusted R-squared: 0.658
#> F-statistic: 199 on 7 and 714 DF, p-value: <0.0000000000000002
Área construida: 0.82 millones por m2 (p < 0,001, estadisticamente significativo). Manteniendo constantes estrato, habitaciones, baños y parqueaderos, cada metro cuadrado adicional se asocia con un aumento promedio de 0.82 millones en el precio de oferta. Es el efecto más robusto y es lógico: el área es el insumo básico que se transa en el mercado de vivienda.
Estrato (referencia: estrato 3):
El escalonamiento captura la calidad del entorno, la dotación de servicios y el prestigio del sector. Confirma que en Cali el estrato es un determinante de primer orden del precio, incluso comparando casas del mismo tamaño dentro de la misma zona.
Baños: 25.64 millones por unidad (p < 0,001, estadisticamente significativo). Con el área fija, un bano más implica una vivienda mejor dotada por metro cuadrado y con acabados más costosos. El signo positivo es el esperado.
Parqueaderos: 2.35 millones por unidad (p 0.589, no significativo). El parqueadero es un bien escaso en la ciudad y se cotiza casi como un activo separado del inmueble. Coherente con la práctica comercial.
Habitaciones: 1.51 millones por unidad (p 0.715, no significativo). El signo es positivo, aunque de magnitud modesta frente al área. La clave es que el coeficiente se interpreta con el área construida fija: añadir una habitación sin aumentar los metros cuadrados no agranda la casa, la subdivide. El modelo compara dos viviendas del mismo tamaño, una con más cuartos y por lo tanto más pequeños. En el segmento alto, que es el relevante para esta solicitud, el comprador prefiere espacios amplios sobre mayor número de recintos. No es una anomalía de los datos sino el efecto parcial correctamente definido; la advertencia práctica es que no debe leerse como “las habitaciones restan valor” sino como “a igual área, más subdivisión no agrega valor”.
Intercepto (31.29). Precio esperado de una casa de estrato 3 con cero metros, cero habitaciones, cero baños y cero parqueaderos. Es un punto fuera del rango de los datos, así que no tiene interpretación sustantiva: solo ancla la recta.
tibble(Indicador = c("R2", "R2 ajustado", "AIC", "BIC", "Error estandar residual (M$)",
"Error estandar / precio medio", "F", "Valor p del F", "Observaciones"),
Valor = c(fmt(r2_1, 4), fmt(r2a_1, 4), fmt(AIC(modelo_norte), 1),
fmt(BIC(modelo_norte), 1), fmt(sigma_1),
paste0(fmt(100 * sigma_1 / precio_medio_1, 1), "%"),
fmt(f_1[1], 1), p_fmt(pf_1), as.character(nrow(base_norte)))) |>
tabla("Tabla 10. Indicadores de ajuste")| Indicador | Valor |
|---|---|
| R2 | 0.6613 |
| R2 ajustado | 0.6579 |
| AIC | 9,359.7 |
| BIC | 9,400.9 |
| Error estandar residual (M$) | 156.95 |
| Error estandar / precio medio | 35.2% |
| F | 199.1 |
| Valor p del F | < 0,001 |
| Observaciones | 722 |
El estadístico F de 199.1 (p < 0,001) rechaza la hipótesis de que todos los coeficientes de pendiente son cero: el conjunto de regresores sí aporta información.
El R2 ajustado de 0.658 indica que las cinco características explican cerca del 65.8% de la variabilidad del precio. Para un modelo hedónico con tan pocos regresores es un ajuste alto y confirma que área y estrato hacen la mayor parte del trabajo.
El complemento es igual de informativo: queda sin explicar cerca del 34.2% y el error típico es de 156.95 millones, el 35.2% del precio promedio. La razón es identificable: la base no observa antigüedad, estado de acabados, orientación, zonas comunes, vigilancia ni la ubicación exacta dentro del barrio. Dos casas idénticas en las cinco variables pueden diferir en decenas de millones por esos atributos omitidos.
Como mejorarlo: (i) incluir el barrio como efecto fijo o un índice de precio por m2 del sector, que absorberia la heterogeneidad espacial; (ii) modelar el precio en logaritmos, porque la relación luce multiplicativa y la dispersión crece con el nivel; (iii) permitir que el valor del m2 dependa del estrato con una interacción área x estrato; (iv) enriquecer la base con antigüedad y estado. La opción (ii) se evalúa formalmente en el Punto 5.
as.data.frame(vif(modelo_norte)) |> rownames_to_column("Variable") |>
tabla("Tabla 11. Factores de inflacion de varianza (VIF)", digitos = 3)| Variable | GVIF | Df | GVIF^(1/(2*Df)) |
|---|---|---|---|
| areaconst | 1.519 | 1 | 1.232 |
| estrato_f | 1.635 | 3 | 1.085 |
| habitaciones | 1.677 | 1 | 1.295 |
| banios | 1.949 | 1 | 1.396 |
| parqueaderos | 1.294 | 1 | 1.137 |
sup_1 <- tabla_supuestos(modelo_norte)
tabla(sup_1, "Tabla 12. Validaci\u00f3n de supuestos sobre los errores", digitos = 4)| Supuesto | Prueba | Estadistico | Valor p |
|---|---|---|---|
| Varianza constante | Breusch-Pagan | 139.873 | 0 |
| Varianza constante | Goldfeld-Quandt | 7.942 | 0 |
| Independencia | Durbin-Watson | 1.688 | 0 |
| Normalidad | Anderson-Darling | 27.677 | 0 |
| Observaciones influyentes | Distancia de Cook > 4/n | 49.000 | NA |
par(mfrow = c(2, 2), mar = c(4.2, 4.2, 2.6, 1.2))
plot(modelo_norte, which = 1, col = adjustcolor("#1f4e79", 0.4), pch = 16)
plot(modelo_norte, which = 2, col = adjustcolor("#1f4e79", 0.4), pch = 16)
plot(modelo_norte, which = 3, col = adjustcolor("#1f4e79", 0.4), pch = 16)
plot(modelo_norte, which = 5, col = adjustcolor("#1f4e79", 0.4), pch = 16)Multicolinealidad. El mayor VIF corregido es 1.95, es decir sin multicolinealidad según los umbrales del curso (hasta 5 sin problema, entre 5 y 10 moderada, sobre 10 severa). Los predictores comparten información (las casas grandes tienen más baños) pero no al punto de impedir separar sus efectos, así que los coeficientes son interpretables individualmente.
Media cero de los errores. El promedio de los residuos es -1.78e-15, cero salvo error numérico. Es un resultado mecánico de MCO con intercepto: no se pone a prueba, se verifica.
Varianza constante (Breusch-Pagan p < 0,001;
Goldfeld-Quandt p < 0,001). Ambas pruebas rechazan la
homocedasticidad: hay heterocedasticidad. El gráfico de residuos contra
valores ajustados lo confirma con la forma de abanico ya anticipada en
el análisis exploratorio: los errores son pequeños en las casas
económicas y grandes en las costosas. La consecuencia no es sesgo en los
coeficientes, que siguen siendo insesgados, sino errores
estándar mal calculados, lo que invalida los valores p y los
intervalos que reporta summary().
Normalidad (Anderson-Darling p < 0,001). Se rechaza la normalidad de los residuos. El Q-Q plot muestra colas más pesadas que las de una normal, sobre todo en el extremo superior: hay casas cuyo precio de oferta supera con mucho lo que predicen sus atributos. Con 722 observaciones el teorema central del límite protege la inferencia sobre los coeficientes, así que este es el incumplimiento menos grave; sí afecta, en cambio, la cobertura de los intervalos de predicción individuales que se usan en el Punto 6.
Independencia (Durbin-Watson = 1.69, p < 0,001). La prueba detecta correlacion entre residuos consecutivos. Conviene precisar el sentido: los datos son de corte transversal, no una serie de tiempo, de modo que el orden de las filas no tiene significado temporal. Si la prueba señala dependencia, lo más probable es que la base venga ordenada por barrio o precio y que lo detectado sea autocorrelación espacial: casas vecinas comparten atributos no observados del entorno.
Observaciones influyentes. 49 registros superan el criterio \(D_i > 4/n\) de la distancia de Cook. Son ofertas atípicas que conviene revisar antes de usar el modelo en producción.
| Problema | Corrección sugerida | Efecto esperado |
|---|---|---|
| Heterocedasticidad | Errores estándar robustos HC3, o modelar log(precio) | Inferencia valida sin cambiar los coeficientes; el log además estabiliza la varianza |
| Colas pesadas e influyentes | Regresión robusta o winsorización del 1% extremo | Reduce el peso de las ofertas atípicas |
| Dependencia espacial | Efecto fijo de barrio o modelo de error espacial | Absorbe atributos no observados del entorno |
| Variables omitidas | Incorporar antigüedad, estado y zonas comunes | Reduce el error de predicción |
Se contrastan dos especificaciones: la del enunciado en niveles y una alternativa en logaritmos que responde a la heterocedasticidad detectada. La comparación combina indicadores dentro de muestra con el error fuera de muestra por validación simple repetida 100 veces, para no depender de una sola partición.
ajustes_1 <- map(especificaciones, ~ lm(.x$f, data = base_norte))
map_dfr(ajustes_1, glance, .id = "Modelo") |>
select(Modelo, r.squared, adj.r.squared, sigma, statistic, AIC, BIC, nobs) |>
rename(`R2` = r.squared, `R2 ajustado` = adj.r.squared, `Sigma` = sigma,
`F` = statistic, `n` = nobs) |>
tabla("Tabla 13. Indicadores de ajuste de las dos especificaciones", digitos = 3)| Modelo | R2 | R2 ajustado | Sigma | F | AIC | BIC | n |
|---|---|---|---|---|---|---|---|
| A. Nivel-nivel | 0.661 | 0.658 | 156.953 | 199.1 | 9,359.7 | 9,401 | 722 |
| B. Log-nivel | 0.750 | 0.748 | 0.285 | 306.0 | 246.8 | 288 | 722 |
El R2 no es comparable entre un modelo en niveles y uno en logaritmos, porque la variable dependiente cambia de escala. Por eso el criterio de selección es el error fuera de muestra, medido siempre en millones tras retrotransformar con el factor de Duan.
vc_1 <- validacion_repetida(base_norte)
tabla(resumen_validacion(vc_1), "Tabla 14. Validaci\u00f3n simple repetida (100 particiones 70/30)", digitos = 3)| Modelo | MSE medio | RMSE medio | MAE medio | MAPE medio (%) |
|---|---|---|---|---|
| A. Nivel-nivel | 26,530 | 162.9 | 100.0 | 22.02 |
| B. Log-nivel | 39,283 | 198.2 | 108.8 | 24.34 |
La especificación A. Nivel-nivel obtiene el menor error fuera de muestra (RMSE medio = 162.88 millones; MAE = 100.00; MAPE = 22.0%) y es la que se usa para valorar la solicitud. El modelo definitivo se reestima sobre la base completa: la partición ya cumplió su función de elegir la especificación y estimar el error esperado.
estratos_1 <- intersect(requerimiento_1$estratos, levels(base_norte$estrato_f))
if (length(estratos_1) == 0) estratos_1 <- levels(base_norte$estrato_f)
escenarios_1 <- tibble(areaconst = requerimiento_1$areaconst,
habitaciones = requerimiento_1$habitaciones,
banios = requerimiento_1$banios,
parqueaderos = requerimiento_1$parqueaderos,
estrato_f = factor(estratos_1, levels = levels(base_norte$estrato_f)))
pred_1 <- predict(modelo_final_1, newdata = escenarios_1, interval = "prediction", level = 0.95)
resultado_1 <- escenarios_1 |>
mutate(Estrato = as.character(estrato_f),
duan = if (esclog_1) factor_duan(modelo_final_1) else 1,
`Precio estimado (M$)` = if (esclog_1) exp(pred_1[, "fit"]) * duan else pred_1[, "fit"],
`Limite inferior 95%` = if (esclog_1) exp(pred_1[, "lwr"]) * duan else pred_1[, "lwr"],
`Limite superior 95%` = if (esclog_1) exp(pred_1[, "upr"]) * duan else pred_1[, "upr"],
`Presupuesto (M$)` = requerimiento_1$presupuesto,
`Alcanza?` = if_else(`Precio estimado (M$)` <= requerimiento_1$presupuesto, "Si", "No")) |>
select(Estrato, `Precio estimado (M$)`, `Limite inferior 95%`, `Limite superior 95%`,
`Presupuesto (M$)`, `Alcanza?`)
tabla(resultado_1, "Tabla 15. Predicci\u00f3n para la vivienda 1 (200 m2, 4 hab., 2 ba\u00f1os, 1 parqueadero)")| Estrato | Precio estimado (M\() </th> <th style="text-align:center;"> Limite inferior 95% </th> <th style="text-align:center;"> Limite superior 95% </th> <th style="text-align:center;"> Presupuesto (M\)) | Alcanza? | |||
|---|---|---|---|---|---|
| 4 | 340.1 | 30.54 | 649.7 | 350 | Si |
| 5 | 392.6 | 83.31 | 702.0 | 350 | No |
Una casa de 200 m2 con cuatro habitaciones, dos baños y un parqueadero en la zona norte se valora entre 340.10 y 392.63 millones según el estrato. Al menos uno de los escenarios cabe dentro del crédito de 350 millones, de modo que la solicitud es viable aunque con margen estrecho. El intervalo de predicción al 95% es amplio porque incorpora la incertidumbre del inmueble individual y no solo la del valor medio; es la cifra honesta para negociar.
La búsqueda tiene dos etapas. Primero se restringe la base a las ofertas financieramente viables (precio menor o igual al crédito y estrato dentro del rango pedido). Luego se ordenan por cercanía al perfil, con la distancia euclídea sobre las cuatro características estructurales estandarizadas para que ninguna domine por su escala. El desempate lo da la brecha frente a la valoración del modelo: si una casa se ofrece por debajo de lo que el modelo estima, hay margen de negociación.
ofertas_1 <- buscar_ofertas(base_norte, modelo_final_1, esclog_1,
requerimiento_1, requerimiento_1$presupuesto, top = 5)
n_fact_1 <- base_norte |>
filter(preciom <= requerimiento_1$presupuesto, estrato_f %in% estratos_1) |> nrow()
ofertas_1 |>
transmute(Barrio = barrio, Estrato = estrato, `Area (m2)` = areaconst,
Hab = habitaciones, `Banos` = banios, Parq = parqueaderos,
`Precio (M$)` = preciom, `Valor del modelo (M$)` = valor_modelo,
`Brecha (M$)` = brecha, `Brecha (%)` = `brecha %`,
`Distancia al perfil` = distancia) |>
tabla("Tabla 16. Cinco ofertas recomendadas para la familia 1")| Barrio | Estrato | Area (m2) | Hab | Banos | Parq | Precio (M\() </th> <th style="text-align:center;"> Valor del modelo (M\)) | Brecha (M$) | Brecha (%) | Distancia al perfil | |
|---|---|---|---|---|---|---|---|---|---|---|
| alamos | 4 | 120 | 4 | 2 | 1 | 275 | 274.1 | -0.85 | -0.31 | 0.48 |
| la merced | 4 | 240 | 3 | 2 | 1 | 330 | 371.6 | 41.57 | 11.19 | 0.60 |
| prados del norte | 5 | 140 | 3 | 2 | 1 | 280 | 341.6 | 61.65 | 18.04 | 0.65 |
| la merced | 5 | 216 | 4 | 2 | 2 | 350 | 408.2 | 58.17 | 14.25 | 0.66 |
| vipasa | 4 | 264 | 3 | 2 | 1 | 320 | 391.4 | 71.35 | 18.23 | 0.67 |
Discusión. En la zona norte hay 112 casas que cumplen a la vez el tope de 350 millones y el rango de estratos pedido; de ellas se eligen las cinco más parecidas al requerimiento. Se ubican en alamos, la merced, prados del norte, vipasa, con áreas entre 120 y 264 m2 y precios entre 275.00 y 350.00 millones. 4 de las cinco se ofrecen por debajo de la valoración del modelo, lo que las convierte en las candidatas más atractivas.
El punto que María debe transmitir a la empresa es el canje inevitable: con 350 millones en la zona norte no se consiguen a la vez 200 m2, estrato 5 y cuatro habitaciones. Las palancas, de menor a mayor impacto sobre la familia, son (i) bajar el área hacia 150-170 m2 conservando estrato y habitaciones, que es lo más eficiente porque el área es el atributo mejor pagado; (ii) aceptar estrato 4 en un buen barrio del norte, que libera margen sin cambiar el tamaño; o (iii) ampliar el presupuesto hacia el rango de la Tabla 15.
Además, por lo visto en el Punto 1, algunas de estas ofertas están en barrios de frontera cuya coordenada las acerca a otra zona: conviene verificar la dirección exacta antes de mostrarlas, porque a la familia se le ofreció el norte.
requerimiento_2 <- list(areaconst = 300, parqueaderos = 3, banios = 3, habitaciones = 5,
estratos = c("5", "6"), presupuesto = 850)
base_sur <- construir_base(datos, "Apartamento", "Sur")
head(base_sur |> select(id, barrio, estrato, piso, areaconst, habitaciones,
banios, parqueaderos, preciom), 3) |>
tabla("Tabla 17. Primeros tres registros de la base filtrada")| id | barrio | estrato | piso | areaconst | habitaciones | banios | parqueaderos | preciom |
|---|---|---|---|---|---|---|---|---|
| 5,098 | acopi | 4 | 05 | 96 | 3 | 2 | 1 | 290 |
| 698 | aguablanca | 3 | 02 | 40 | 2 | 1 | 1 | 78 |
| 8,199 | aguacatal | 6 | NA | 194 | 3 | 5 | 2 | 875 |
tibble(`Comprobacion` = c("Ofertas en la base", "Tipos presentes", "Zonas presentes",
"Estratos presentes", "Precio minimo (M$)", "Precio maximo (M$)"),
Resultado = c(as.character(nrow(base_sur)),
paste(unique(base_sur$tipo), collapse = ", "),
paste(unique(base_sur$zona), collapse = ", "),
paste(sort(unique(base_sur$estrato)), collapse = ", "),
fmt(min(base_sur$preciom)), fmt(max(base_sur$preciom)))) |>
tabla("Tabla 18. Comprobacion del filtro")| Comprobacion | Resultado |
|---|---|
| Ofertas en la base | 2787 |
| Tipos presentes | Apartamento |
| Zonas presentes | Zona Sur |
| Estratos presentes | 3, 4, 5, 6 |
| Precio minimo (M\() </td> <td style="text-align:center;"> 75.00 </td> </tr> <tr> <td style="text-align:center;"> Precio maximo (M\)) | 1,750.00 |
base_sur |>
group_by(estrato) |>
summarise(Ofertas = n(), `Area media (m2)` = mean(areaconst, na.rm = TRUE),
`Precio medio (M$)` = mean(preciom, na.rm = TRUE),
`Precio/m2 (M$)` = mean(precio_m2, na.rm = TRUE), .groups = "drop") |>
tabla("Tabla 19. Composicion y precios por estrato")| estrato | Ofertas | Area media (m2) | Precio medio (M\() </th> <th style="text-align:center;"> Precio/m2 (M\)) | |
|---|---|---|---|---|
| 3 | 201 | 68.70 | 141.1 | 2.09 |
| 4 | 1,091 | 75.95 | 203.6 | 2.71 |
| 5 | 1,033 | 102.24 | 293.7 | 3.01 |
| 6 | 462 | 150.15 | 594.5 | 3.98 |
base_sur_geo <- marcar_coherencia(base_sur)
leaflet(base_sur_geo |> filter(coord_valida)) |>
addTiles() |>
addCircleMarkers(lng = ~longitud, lat = ~latitud, radius = 4, stroke = FALSE,
fillOpacity = 0.75, fillColor = ~pal_coh(coherente),
popup = ~paste0("<b>", barrio, "</b><br/>Zona declarada: ", zona,
"<br/>Zona m\u00e1s cercana por coordenada: ", zona_geo,
"<br/>Precio: $", round(preciom, 1), " M")) |>
addLegend(position = "bottomright", colors = c("#2e8b57", "#c0504d"),
labels = c("Coherente", "Discrepante"), opacity = 0.9,
title = "Zona declarada vs. ubicaci\u00f3n") |>
addControl("<b>Mapa 3. Apartamentos ofertados en la zona sur</b>", position = "topright")base_sur_geo |>
filter(coord_valida) |> count(zona_geo, name = "Ofertas") |>
mutate(`% del total` = 100 * Ofertas / sum(Ofertas)) |>
arrange(desc(Ofertas)) |>
tabla("Tabla 20. Zona sugerida por las coordenadas de los apartamentos rotulados como sur")| zona_geo | Ofertas | % del total |
|---|---|---|
| Zona Sur | 2,222 | 79.73 |
| Zona Centro | 312 | 11.19 |
| Zona Oriente | 102 | 3.66 |
| Zona Oeste | 86 | 3.09 |
| Zona Norte | 65 | 2.33 |
Discusión. 565 de las 2787 ofertas georreferenciadas (20.3%) quedarían asignadas a otra zona con el criterio del centroide más cercano. Aplican las tres causas del caso anterior, más un factor propio del sur: es la zona de expansión más reciente y su frontera comercial con el oriente y con el corredor sur no está estabilizada, de modo que un mismo conjunto residencial puede anunciarse como “sur” o como parte de otro sector según la agencia.
mat_cor_2 <- base_sur |>
select(preciom, areaconst, estrato, banios, habitaciones, parqueaderos) |>
cor(use = "complete.obs") |> round(2)
plot_ly(x = colnames(mat_cor_2), y = rownames(mat_cor_2), z = mat_cor_2, type = "heatmap",
colors = colorRamp(c("#ffffff", "#7fbf9e", "#2e8b57")), zmin = -1, zmax = 1,
text = mat_cor_2, texttemplate = "%{text}", hoverinfo = "x+y+z") |>
layout(title = "Correlaciones de Pearson - apartamentos zona sur",
xaxis = list(title = ""), yaxis = list(title = "", autorange = "reversed"))El área vuelve a dominar (r = 0.76), seguida por baños (r = 0.72) y parqueaderos (r = 0.67). La correlación con el estrato (r = 0.67) es incluso mayor que en las casas del norte.
plot_ly(base_sur, x = ~areaconst, y = ~preciom, color = ~factor(estrato),
colors = paleta, type = "scatter", mode = "markers",
marker = list(size = 7, opacity = 0.6),
text = ~paste0(barrio, "<br/>", areaconst, " m2 - $", round(preciom), " M"),
hoverinfo = "text") |>
layout(title = "Precio vs. \u00e1rea construida por estrato",
xaxis = list(title = "\u00c1rea construida (m2)"),
yaxis = list(title = "Precio (millones)"),
legend = list(title = list(text = "Estrato")))c1 <- plot_ly(base_sur, x = ~factor(estrato), y = ~preciom, type = "box", name = "Estrato",
marker = list(color = "#e0a800"), line = list(color = "#e0a800"))
c2 <- plot_ly(base_sur, x = ~factor(habitaciones), y = ~preciom, type = "box", name = "Habitaciones",
marker = list(color = "#1f4e79"), line = list(color = "#1f4e79"))
c3 <- plot_ly(base_sur, x = ~factor(banios), y = ~preciom, type = "box", name = "Banos",
marker = list(color = "#2e8b57"), line = list(color = "#2e8b57"))
c4 <- plot_ly(base_sur, x = ~factor(parqueaderos), y = ~preciom, type = "box", name = "Parqueaderos",
marker = list(color = "#c0504d"), line = list(color = "#c0504d"))
subplot(c1, c2, c3, c4, nrows = 2, shareY = TRUE, titleX = FALSE) |>
layout(title = "Precio segun estrato, habitaciones, banos y parqueaderos",
yaxis = list(title = "Precio (millones)"))La lectura es la misma que en el norte con un matiz: en propiedad horizontal el número de parqueaderos separa los grupos de precio con más nitidez, porque es un derecho escaso asignado por el proyecto y no algo que el propietario pueda improvisar.
datos |>
filter(str_detect(tipo, "Apartamento")) |>
group_by(zona) |>
summarise(Ofertas = n(), `Precio mediano (M$)` = median(preciom),
`Precio/m2 mediano (M$)` = median(precio_m2, na.rm = TRUE), .groups = "drop") |>
arrange(desc(`Precio mediano (M$)`)) |>
tabla("Tabla 21. Efecto de la zona (todos los apartamentos de la ciudad)")| zona | Ofertas | Precio mediano (M\() </th> <th style="text-align:center;"> Precio/m2 mediano (M\)) | |
|---|---|---|---|
| Zona Oeste | 1,029 | 570.0 | 3.84 |
| Zona Norte | 1,198 | 250.0 | 2.67 |
| Zona Sur | 2,787 | 245.0 | 2.90 |
| Zona Centro | 24 | 152.5 | 1.84 |
| Zona Oriente | 62 | 115.0 | 1.58 |
base_sur |>
distinct(estrato, pick(starts_with("D_estrato"))) |> arrange(estrato) |>
tabla("Tabla 22. Codificaci\u00f3n (k-1) de la variable estrato", digitos = 0)| estrato | D_estrato4 | D_estrato5 | D_estrato6 |
|---|---|---|---|
| 3 | 0 | 0 | 0 |
| 4 | 1 | 0 | 0 |
| 5 | 0 | 1 | 0 |
| 6 | 0 | 0 | 1 |
#>
#> Call:
#> lm(formula = formula_nivel, data = base_sur)
#>
#> Residuals:
#> Min 1Q Median 3Q Max
#> -1150.8 -36.6 0.3 32.7 899.1
#>
#> Coefficients:
#> Estimate Std. Error t value Pr(>|t|)
#> (Intercept) -2.0800 9.9291 -0.21 0.8341
#> areaconst 1.3881 0.0455 30.49 < 0.0000000000000002 ***
#> estrato_f4 18.2219 6.9349 2.63 0.0086 **
#> estrato_f5 38.0364 7.2514 5.25 0.00000017 ***
#> estrato_f6 200.0779 9.1079 21.97 < 0.0000000000000002 ***
#> habitaciones -14.8544 3.1851 -4.66 0.00000325 ***
#> banios 39.8380 2.8686 13.89 < 0.0000000000000002 ***
#> parqueaderos 45.1798 2.8108 16.07 < 0.0000000000000002 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#>
#> Residual standard error: 88.3 on 2779 degrees of freedom
#> Multiple R-squared: 0.788, Adjusted R-squared: 0.787
#> F-statistic: 1.48e+03 on 7 and 2779 DF, p-value: <0.0000000000000002
Área construida: 1.39 millones por m2 (p < 0,001, estadisticamente significativo). El metro cuadrado en un apartamento del sur se valora por encima del de una casa del norte (0.82 millones). La comparación le sirve a María para saber en cual submercado se paga mejor el crecimiento del área.
Estrato (referencia: estrato 3):
Baños: 39.84 millones (p < 0,001, estadisticamente significativo). En propiedad horizontal el número de baños es un buen indicador del nivel de acabados y del segmento del proyecto.
Parqueaderos: 45.18 millones (p < 0,001, estadisticamente significativo). El efecto es mayor que en las casas del norte, coherente con que en un apartamento el parqueadero es un derecho escaso y no puede improvisarse.
Habitaciones: -14.85 millones (p < 0,001, estadisticamente significativo). Igual que en las casas, el efecto parcial es negativo: a igual área, subdividir en más cuartos no agrega valor y en el segmento alto incluso lo resta.
tibble(Indicador = c("R2", "R2 ajustado", "AIC", "BIC", "Error estandar residual (M$)",
"Error estandar / precio medio", "Observaciones"),
Valor = c(fmt(summary(modelo_sur)$r.squared, 4), fmt(r2a_2, 4),
fmt(AIC(modelo_sur), 1), fmt(BIC(modelo_sur), 1), fmt(sigma_2),
paste0(fmt(100 * sigma_2 / precio_medio_2, 1), "%"),
as.character(nrow(base_sur)))) |>
tabla("Tabla 23. Indicadores de ajuste")| Indicador | Valor |
|---|---|
| R2 | 0.7880 |
| R2 ajustado | 0.7874 |
| AIC | 32,895.8 |
| BIC | 32,949.1 |
| Error estandar residual (M$) | 88.32 |
| Error estandar / precio medio | 29.7% |
| Observaciones | 2787 |
El R2 ajustado de 0.787 es superior al de las casas del norte (0.658). La explicación es de mercado: los apartamentos son bienes más estandarizados, se construyen por proyectos con plantas repetidas y acabados homogéneos, de modo que sus cinco atributos observables describen mejor el producto. Las casas son diseños únicos donde la antigüedad, el lote y el estado de conservación pesan mucho más y no están en la base.
A las mejoras ya señaladas se suman dos propias de la propiedad horizontal: incorporar el piso, que sí está en la base y es relevante en apartamentos porque la altura y la vista se cotizan, y la administración mensual, que no está en los datos pero condiciona la decisión de compra.
as.data.frame(vif(modelo_sur)) |> rownames_to_column("Variable") |>
tabla("Tabla 24. Factores de inflacion de varianza (VIF)", digitos = 3)| Variable | GVIF | Df | GVIF^(1/(2*Df)) |
|---|---|---|---|
| areaconst | 2.046 | 1 | 1.430 |
| estrato_f | 1.847 | 3 | 1.108 |
| habitaciones | 1.450 | 1 | 1.204 |
| banios | 2.566 | 1 | 1.602 |
| parqueaderos | 1.783 | 1 | 1.335 |
sup_2 <- tabla_supuestos(modelo_sur)
tabla(sup_2, "Tabla 25. Validaci\u00f3n de supuestos sobre los errores", digitos = 4)| Supuesto | Prueba | Estadistico | Valor p |
|---|---|---|---|
| Varianza constante | Breusch-Pagan | 853.181 | 0 |
| Varianza constante | Goldfeld-Quandt | 12.858 | 0 |
| Independencia | Durbin-Watson | 1.716 | 0 |
| Normalidad | Anderson-Darling | 97.361 | 0 |
| Observaciones influyentes | Distancia de Cook > 4/n | 146.000 | NA |
par(mfrow = c(2, 2), mar = c(4.2, 4.2, 2.6, 1.2))
plot(modelo_sur, which = 1, col = adjustcolor("#2e8b57", 0.4), pch = 16)
plot(modelo_sur, which = 2, col = adjustcolor("#2e8b57", 0.4), pch = 16)
plot(modelo_sur, which = 3, col = adjustcolor("#2e8b57", 0.4), pch = 16)
plot(modelo_sur, which = 5, col = adjustcolor("#2e8b57", 0.4), pch = 16)El mayor VIF corregido es 2.57 (sin multicolinealidad). Breusch-Pagan (p < 0,001) y Goldfeld-Quandt (p < 0,001) detectan heterocedasticidad, con el mismo patron de abanico del caso anterior; Anderson-Darling (p < 0,001) rechaza la normalidad de los residuos, con colas pesadas en el extremo superior; Durbin-Watson (p < 0,001) senala dependencia entre residuos consecutivos, atribuible al ordenamiento espacial de la base y no a una estructura temporal. Hay 146 observaciones influyentes según la distancia de Cook, concentradas en el extremo superior de precios: apartamentos de lujo cuya valoración excede lo que explican los cinco atributos. Las sugerencias de corrección son las mismas de la tabla del Punto 4 anterior.
ajustes_2 <- map(especificaciones, ~ lm(.x$f, data = base_sur))
map_dfr(ajustes_2, glance, .id = "Modelo") |>
select(Modelo, r.squared, adj.r.squared, sigma, statistic, AIC, BIC, nobs) |>
rename(`R2` = r.squared, `R2 ajustado` = adj.r.squared, `Sigma` = sigma,
`F` = statistic, `n` = nobs) |>
tabla("Tabla 26. Indicadores de ajuste de las dos especificaciones", digitos = 3)| Modelo | R2 | R2 ajustado | Sigma | F | AIC | BIC | n |
|---|---|---|---|---|---|---|---|
| A. Nivel-nivel | 0.788 | 0.787 | 88.31 | 1,475 | 32,896 | 32,949.1 | 2,787 |
| B. Log-nivel | 0.815 | 0.815 | 0.22 | 1,751 | -513 | -459.6 | 2,787 |
vc_2 <- validacion_repetida(base_sur)
tabla(resumen_validacion(vc_2), "Tabla 27. Validaci\u00f3n simple repetida (100 particiones 70/30)", digitos = 3)| Modelo | MSE medio | RMSE medio | MAE medio | MAPE medio (%) |
|---|---|---|---|---|
| A. Nivel-nivel | 8,439 | 91.86 | 52.99 | 18.32 |
| B. Log-nivel | 17,402 | 131.92 | 54.01 | 18.43 |
La especificación A. Nivel-nivel minimiza el error fuera de muestra (RMSE medio = 91.87 millones; MAPE = 18.3%) y es la que se usa para valorar la segunda solicitud, reestimada sobre la base completa.
estratos_2 <- intersect(requerimiento_2$estratos, levels(base_sur$estrato_f))
if (length(estratos_2) == 0) estratos_2 <- levels(base_sur$estrato_f)
escenarios_2 <- tibble(areaconst = requerimiento_2$areaconst,
habitaciones = requerimiento_2$habitaciones,
banios = requerimiento_2$banios,
parqueaderos = requerimiento_2$parqueaderos,
estrato_f = factor(estratos_2, levels = levels(base_sur$estrato_f)))
pred_2 <- predict(modelo_final_2, newdata = escenarios_2, interval = "prediction", level = 0.95)
resultado_2 <- escenarios_2 |>
mutate(Estrato = as.character(estrato_f),
duan = if (esclog_2) factor_duan(modelo_final_2) else 1,
`Precio estimado (M$)` = if (esclog_2) exp(pred_2[, "fit"]) * duan else pred_2[, "fit"],
`Limite inferior 95%` = if (esclog_2) exp(pred_2[, "lwr"]) * duan else pred_2[, "lwr"],
`Limite superior 95%` = if (esclog_2) exp(pred_2[, "upr"]) * duan else pred_2[, "upr"],
`Presupuesto (M$)` = requerimiento_2$presupuesto,
`Alcanza?` = if_else(`Precio estimado (M$)` <= requerimiento_2$presupuesto, "Si", "No")) |>
select(Estrato, `Precio estimado (M$)`, `Limite inferior 95%`, `Limite superior 95%`,
`Presupuesto (M$)`, `Alcanza?`)
tabla(resultado_2, "Tabla 28. Prediccion para la vivienda 2 (300 m2, 5 hab., 3 banos, 3 parqueaderos)")| Estrato | Precio estimado (M\() </th> <th style="text-align:center;"> Limite inferior 95% </th> <th style="text-align:center;"> Limite superior 95% </th> <th style="text-align:center;"> Presupuesto (M\)) | Alcanza? | |||
|---|---|---|---|---|---|
| 5 | 633.2 | 458.9 | 807.4 | 850 | Si |
| 6 | 795.2 | 620.9 | 969.5 | 850 | Si |
Un apartamento de 300 m2 con cinco habitaciones, tres baños y tres parqueaderos en la zona sur se valora entre 633.16 y 795.20 millones según el estrato. El crédito de 850 millones cubre al menos uno de los escenarios, con una holgura de unos 216.84 millones frente a la alternativa más economica.
Hay que advertir que un apartamento de 300 m2 es un producto atípico: en esta base solo hay 40 ofertas de 280 m2 o más y el máximo observado es de 932 m2. La predicción se hace cerca del borde del rango observado, donde el modelo extrapola y hay pocos comparables, así que el intervalo es especialmente ancho y la cifra debe tomarse como orden de magnitud para negociar, no como una tasación.
ofertas_2 <- buscar_ofertas(base_sur, modelo_final_2, esclog_2,
requerimiento_2, requerimiento_2$presupuesto, top = 5)
n_fact_2 <- base_sur |>
filter(preciom <= requerimiento_2$presupuesto, estrato_f %in% estratos_2) |> nrow()
ofertas_2 |>
transmute(Barrio = barrio, Estrato = estrato, `Area (m2)` = areaconst,
Hab = habitaciones, `Banos` = banios, Parq = parqueaderos,
`Precio (M$)` = preciom, `Valor del modelo (M$)` = valor_modelo,
`Brecha (M$)` = brecha, `Brecha (%)` = `brecha %`,
`Distancia al perfil` = distancia) |>
tabla("Tabla 29. Cinco ofertas recomendadas para la familia 2")| Barrio | Estrato | Area (m2) | Hab | Banos | Parq | Precio (M\() </th> <th style="text-align:center;"> Valor del modelo (M\)) | Brecha (M$) | Brecha (%) | Distancia al perfil | |
|---|---|---|---|---|---|---|---|---|---|---|
| capri | 5 | 270.0 | 4 | 3 | 3 | 350 | 606.4 | 256.37 | 42.28 | 1.68 |
| San Fernando | 5 | 258.0 | 5 | 4 | 2 | 350 | 569.5 | 219.52 | 38.54 | 1.83 |
| el ingenio | 6 | 250.0 | 5 | 4 | 2 | 700 | 720.5 | 20.46 | 2.84 | 1.91 |
| cuarto de legua | 5 | 295.6 | 4 | 4 | 2 | 410 | 636.5 | 226.50 | 35.58 | 2.29 |
| seminario | 5 | 256.0 | 5 | 5 | 3 | 530 | 651.8 | 121.76 | 18.68 | 2.30 |
Discusión. En la zona sur hay 1439 apartamentos que cumplen el tope de 850 millones y el rango de estratos pedido, un conjunto más amplio que el de la primera solicitud. Las cinco opciones están en capri, San Fernando, el ingenio, cuarto de legua, seminario, con áreas entre 250 y 296 m2 y precios entre 350.00 y 700.00 millones; 5 se ofrecen por debajo de su valoración estimada.
Aquí la restricción no es el presupuesto sino la disponibilidad: la oferta de apartamentos de 300 m2 es muy delgada, así que el conjunto factible se llena con inmuebles algo más pequeños pero con estrato y dotación acordes. La recomendación práctica es presentar opciones de 200 a 260 m2 con cinco habitaciones y tres parqueaderos, que preservan lo que realmente importa para una familia trasladada y liberan margen del crédito. Si la empresa insiste en los 300 m2, conviene ampliar la búsqueda a la zona oeste, donde el inventario de vivienda grande de estratos altos es mayor, advirtiendo que eso incumple la preferencia de zona.
tibble(Indicador = c("Ofertas en la base", "Precio mediano (M$)", "Precio/m2 mediano (M$)",
"Valor del m2 (modelo en niveles)", "R2 ajustado", "VIF maximo",
"Especificacion seleccionada", "RMSE fuera de muestra (M$)",
"Predicci\u00f3n de la solicitud (M$)", "Presupuesto (M$)",
"Ofertas factibles halladas"),
`Familia 1 - Casa norte` = c(
as.character(nrow(base_norte)), fmt(median(base_norte$preciom)),
fmt(median(base_norte$precio_m2, na.rm = TRUE), 3), fmt(b_area_1, 3),
fmt(r2a_1, 3), fmt(vifmax_1, 2), mejor_1, fmt(rmse_1),
paste0(fmt(est_min_1, 0), " - ", fmt(est_max_1, 0)),
fmt(requerimiento_1$presupuesto, 0), as.character(n_fact_1)),
`Familia 2 - Apto sur` = c(
as.character(nrow(base_sur)), fmt(median(base_sur$preciom)),
fmt(median(base_sur$precio_m2, na.rm = TRUE), 3), fmt(b_area_2, 3),
fmt(r2a_2, 3), fmt(vifmax_2, 2), mejor_2, fmt(rmse_2),
paste0(fmt(est_min_2, 0), " - ", fmt(est_max_2, 0)),
fmt(requerimiento_2$presupuesto, 0), as.character(n_fact_2))) |>
tabla("Tabla 30. Cuadro comparativo")| Indicador | Familia 1 - Casa norte | Familia 2 - Apto sur |
|---|---|---|
| Ofertas en la base | 722 | 2787 |
| Precio mediano (M\() </td> <td style="text-align:center;"> 390.00 </td> <td style="text-align:center;"> 245.00 </td> </tr> <tr> <td style="text-align:center;"> Precio/m2 mediano (M\)) | 1.664 | 2.902 |
| Valor del m2 (modelo en niveles) | 0.824 | 1.388 |
| R2 ajustado | 0.658 | 0.787 |
| VIF maximo | 1.95 | 2.57 |
| Especificacion seleccionada | A. Nivel-nivel | A. Nivel-nivel |
| RMSE fuera de muestra (M\() </td> <td style="text-align:center;"> 162.88 </td> <td style="text-align:center;"> 91.87 </td> </tr> <tr> <td style="text-align:center;"> Predicción de la solicitud (M\)) | 340 - 393 | 633 - 795 |
| Presupuesto (M$) | 350 | 850 |
| Ofertas factibles halladas | 112 | 1439 |
1. Las dos solicitudes no tienen la misma dificultad. La primera es viable pero con margen estrecho y la segunda cabe dentro del crédito aprobado. El obstáculo es distinto en cada caso: en la familia 1 la restricción es el precio; en la familia 2, la disponibilidad de inventario de inmuebles tan grandes.
2. El precio en Cali lo determinan, en ese orden, el área y el estrato. Con solo cinco características se explica el 66% de la variación en casas del norte y el 79% en apartamentos del sur. Es base suficiente para tasar rápido y para detectar ofertas mal preciadas.
3. Más habitaciones no significa más precio. A igual área, subdividir en más cuartos no agrega valor en los segmentos altos. Conviene orientar la conversación con las familias hacia metros cuadrados y calidad del entorno antes que hacia el conteo de habitaciones.
4. Familia 1. Presentar las cinco opciones de la Tabla 16, priorizando las que se ofrecen por debajo de la valoración del modelo, y preparar a la empresa para el canje: con 350 millones en el norte se consigue área o estrato alto, difícilmente ambos al nivel pedido.
5. Familia 2. Presentar las cinco opciones de la Tabla 29 y proponer explícitamente el rango de 200 a 260 m2, que conserva habitaciones y parqueaderos y libera presupuesto. Advertir que la oferta de 300 m2 en el sur es marginal.
6. Advertencias. Los modelos se estiman sobre precios de oferta, no de cierre, así que tienden a estar por encima del valor de transacción. Los datos corresponden a tres meses de un mercado deprimido por las tensiones sociales y políticas, de modo que las valoraciones deberían recalcularse cuando el sector se reactive. Y el error típico de predicción (162.88 millones en casas, 91.87 en apartamentos) es la incertidumbre real con la que María abre cualquier negociación. Las principales variables que faltan (antigüedad, acabados, zonas comunes, administración) explican buena parte de ese error.