Para: María, Gerente General — C&A (Casas y Apartamentos), Cali
De: Área de Analítica
Asunto: Valoración de las dos viviendas solicitadas por la compañía internacional y recomendación de oferta
La compañía internacional solicitó asesoría para adquirir dos viviendas en Cali con condiciones definidas y con créditos preaprobados de $350 y $850 millones. Para responder con cifras defendibles y no con intuición de mercado, se construyeron dos modelos de regresión lineal múltiple sobre la base de ofertas de los últimos tres meses (8.322 registros): uno para casas de la Zona Norte (700 ofertas) y otro para apartamentos de la Zona Sur (2.777 ofertas). Cada mercado se modeló por separado porque la formación de precio es distinta en cada uno.
| Solicitud | Perfil solicitado | Estrato | Precio estimado | IC 95% (precio medio) | IP 95% (una vivienda) | Crédito | ¿Alcanza? |
|---|---|---|---|---|---|---|---|
| Vivienda 1 · Casa, Zona Norte | 200 m² · 4 hab. · 2 baños · 1 parq. | 4 | $322 M | $291 – $352 M | $14 – $629 M | $350 M | Sí, ajustado |
| Vivienda 1 · Casa, Zona Norte | 200 m² · 4 hab. · 2 baños · 1 parq. | 5 | $383 M | $354 – $412 M | $76 – $690 M | $350 M | No |
| Vivienda 2 · Apto., Zona Sur | 300 m² · 5 hab. · 3 baños · 3 parq. | 5 | $650 M | $629 – $670 M | $477 – $823 M | $850 M | Sí, con holgura |
| Vivienda 2 · Apto., Zona Sur | 300 m² · 5 hab. · 3 baños · 3 parq. | 6 | $803 M | $782 – $823 M | $630 – $976 M | $850 M | Sí, ajustado |
Solicitud 1 — la restricción real es el estrato, no el tamaño. Una casa de 200 m² con 4 habitaciones y 2 baños en la Zona Norte se valora en $322 millones en estrato 4, cifra que cabe dentro del crédito de $350 millones pero con margen estrecho (el intervalo de confianza llega hasta $352 millones). En estrato 5 el precio esperado sube a $383 millones, es decir $33 millones por encima del crédito preaprobado. La recomendación operativa es concentrar la búsqueda en estrato 4 y tratar el estrato 5 solo si el cliente amplía el cupo o acepta reducir área.
Solicitud 2 — hay capacidad de compra, pero muy poca oferta. Un apartamento de 300 m² en la Zona Sur se valora entre $650 millones (estrato 5) y $803 millones (estrato 6); ambos caben en el crédito de $850 millones. El problema no es presupuestal sino de disponibilidad: apenas 31 de 2.777 apartamentos (1.1%) de la Zona Sur superan los 300 m². El área solicitada está en el extremo superior del mercado, así que la estimación para ese punto debe leerse como una referencia de negociación y no como un precio de lista.
Los modelos explican el 66.5% (casas Norte) y el 79.0% (apartamentos Sur) de la variación de precios, lo cual es alto para datos de oferta inmobiliaria, pero no capturan antigüedad, estado de conservación, acabados, seguridad del sector ni valorización esperada. Además, la base registra precios de oferta y no de cierre, por lo que las cifras deben leerse como referencia de negociación. Las pruebas de supuestos (Anexo 2, sección de validación de supuestos) muestran heterocedasticidad y residuos no normales en el Modelo 1 y heterocedasticidad y residuos no normales en el Modelo 2: los valores puntuales siguen siendo válidos como referencia central, pero los intervalos deben interpretarse con prudencia.
| Pregunta de negocio | Traducción analítica |
|---|---|
| ¿Cuánto vale una casa con estas características en el norte? | Estimar \(E[\text{precio} \mid X]\) mediante regresión lineal múltiple sobre casas de la Zona Norte |
| ¿Alcanza el crédito preaprobado? | Comparar la estimación puntual y su intervalo de confianza contra el tope de $350 / $850 millones |
| ¿Qué inmuebles concretos ofrecer? | Filtrar la base por restricciones del cliente y ordenar por residual del modelo (precio de oferta vs. valor estimado) |
| ¿Qué tan confiable es la respuesta? | \(R^2\), significancia de coeficientes y validación de los cuatro supuestos del modelo lineal |
El caso pide dos viviendas de tipo y zona distintos. En lugar de un modelo único con variables indicadoras de tipo y zona, se estiman dos modelos independientes:
base1: casas de la Zona
Norte → responde la Solicitud 1.base2: apartamentos de la
Zona Sur → responde la Solicitud 2.La razón es de negocio antes que estadística: el precio por metro cuadrado, el peso del estrato y el valor del parqueadero no se forman igual en el mercado de casas del norte que en el de apartamentos del sur (más adelante se verifica: el metro cuadrado mediano es $1.67 M en el primero y $2.90 M en el segundo). Un modelo agregado promediaría dos estructuras de precio distintas y sesgaría ambas respuestas.
Para cada base se estima el modelo con las cinco variables que define la solicitud del cliente:
\[ \text{preciom}_i = \beta_0 + \beta_1 \text{areaconst}_i + \sum_{k=4}^{6}\gamma_k \, \mathbb{1}[\text{estrato}_i = k] + \beta_2 \text{habitaciones}_i + \beta_3 \text{parqueaderos}_i + \beta_4 \text{banios}_i + \varepsilon_i \]
con \(\varepsilon_i \sim N(0, \sigma^2)\) independientes. El estrato se trata como variable categórica (no como número) porque es una escala ordinal administrativa: pasar de estrato 3 a 4 no tiene por qué valer lo mismo que pasar de 5 a 6. La categoría de referencia es el estrato 3.
| Situación detectada | Decisión | Justificación |
|---|---|---|
| 3 registros completamente vacíos | Eliminar | No aportan información |
2 id duplicados |
Conservar el primero | Evita duplicar ofertas en la estimación |
| 45 inmuebles con 0 baños y 66 con 0 habitaciones | Eliminar | Físicamente implausible en vivienda formal: error de captura |
1605 faltantes en parqueaderos (19%) |
Imputar con 1 | Eliminarlos costaría el 40% de las casas de la Zona Norte. En los portales el campo suele dejarse vacío cuando hay un cupo estándar; la moda observada es 1 y el mínimo 1 |
2638 faltantes en piso (32%) |
No usar la variable | El modelo solicitado no la incluye y su tasa de vacíos la hace inutilizable |
| Coordenadas inconsistentes con la zona declarada | Conservar, documentar | Afectan el mapa, no la estimación (el modelo no usa lat/lon). Ver sección de consistencia geográfica |
p < 0.05).La actividad plantea 7 pasos. La siguiente tabla indica en qué sección de este anexo se desarrolla cada uno.
| Paso | Sección de este informe |
|---|---|
1. Filtro de base1 (casas, Zona
Norte), primeros 3 registros, tablas de comprobación, mapa y discusión
de la consistencia de zonas |
§4.1 · réplica en §5.1 |
2. Análisis exploratorio de la correlación precio ~
área, estrato, baños, habitaciones y zona, con gráficos interactivos en
plotly |
§4.2 · réplica en §5.2 |
| 3. Estimación del modelo de regresión lineal múltiple, interpretación de coeficientes significativos, R² y discusión del ajuste | §4.3 · réplica en §5.3 |
| 4. Validación de los supuestos del modelo e interpretación, con sugerencias de corrección | §4.4 · réplica en §5.4 |
| 5. Predicción del precio de la vivienda con las características de la primera solicitud | §4.5 |
| 6. Al menos 5 ofertas potenciales en un mapa, dentro del crédito de $350 millones, con su análisis | §4.6 |
| 7. Repetir los pasos 1 a 6 para la segunda solicitud, con crédito de $850 millones | §5 completa (§5.1 a §5.6) |
Adicionalmente, el entregable pedido en la página de Evaluación se organiza así: el informe ejecutivo es la §1; el anexo 1 (plan de trabajo) es la §2; y el anexo 2 (resultados de la modelación, validación y comparación de modelos) comprende de la §3 a la §6.
# ---- Paquetes ---------------------------------------------------------------
library(dplyr) # manipulación de datos
library(plotly) # gráficos y mapas interactivos
library(knitr) # tablas
library(kableExtra) # formato de tablas
library(broom) # salidas ordenadas de modelos
library(lmtest) # pruebas de supuestos (Breusch-Pagan, Durbin-Watson)
library(car) # VIF# ---- Carga de datos ---------------------------------------------------------
if (requireNamespace("paqueteMODELOS", quietly = TRUE)) {
data("vivienda", package = "paqueteMODELOS")
} else {
load("vivienda.rda") # copia local de respaldo, idéntica a la del paquete
}
vivienda <- as.data.frame(vivienda)
dim(vivienda)## [1] 8322 13
## 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…
# ---- Valores que toman las variables categóricas del modelo -------------------
# Se verifica ANTES de fijar los niveles del factor estrato: si existiera un
# estrato distinto de 3-6, factor(levels = 3:6) lo convertiría en NA sin avisar.
table(Estrato = vivienda$estrato, useNA = "ifany")## Estrato
## 3 4 5 6 <NA>
## 1453 2129 2750 1987 3
## Zona
## Tipo Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur <NA>
## Apartamento 24 1198 1029 62 2787 0
## Casa 100 722 169 289 1939 0
## <NA> 0 0 0 0 0 3
# ---- Diagnóstico de calidad: faltantes por variable --------------------------
calidad <- data.frame(
Variable = names(vivienda),
Faltantes = colSums(is.na(vivienda)),
`% Faltante` = round(100 * colMeans(is.na(vivienda)), 1),
check.names = FALSE, row.names = NULL
) %>% arrange(desc(Faltantes))
kable(calidad, caption = "Valores faltantes por variable en la base original") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| Variable | Faltantes | % Faltante |
|---|---|---|
| piso | 2638 | 31.7 |
| parqueaderos | 1605 | 19.3 |
| id | 3 | 0.0 |
| zona | 3 | 0.0 |
| estrato | 3 | 0.0 |
| areaconst | 3 | 0.0 |
| banios | 3 | 0.0 |
| habitaciones | 3 | 0.0 |
| tipo | 3 | 0.0 |
| barrio | 3 | 0.0 |
| longitud | 3 | 0.0 |
| latitud | 3 | 0.0 |
| preciom | 2 | 0.0 |
Dos hallazgos condicionan la depuración: piso tiene 32%
de vacíos —queda descartada— y parqueaderos tiene 19%, una
proporción demasiado alta para eliminar filas y por eso se imputa.
# Corrige la doble codificación de tildes presente en algunos barrios
arregla_texto <- function(x) {
x <- gsub("√©", "é", x); x <- gsub("√±", "ñ", x)
x <- gsub("√≠", "í", x); x <- gsub("√≥", "ó", x)
x <- gsub("√°", "á", x); x <- gsub("√∫", "ú", x)
trimws(tolower(x))
}
# Valor de imputación para parqueaderos faltantes (justificación en el Anexo 1)
PARQ_IMPUTA <- 1
table(Parqueaderos = vivienda$parqueaderos, useNA = "ifany")## Parqueaderos
## 1 2 3 4 5 6 7 8 9 10 <NA>
## 3155 2475 520 384 68 68 18 17 4 8 1605
depurar <- function(d) {
d %>%
# 1. registros sin información en las variables del modelo
filter(!is.na(zona), !is.na(tipo), !is.na(preciom), !is.na(areaconst),
!is.na(estrato), !is.na(banios), !is.na(habitaciones)) %>%
# 2. ofertas duplicadas
distinct(id, .keep_all = TRUE) %>%
# 3. valores físicamente implausibles
filter(banios > 0, habitaciones > 0) %>%
# 4. imputación y tipificación
mutate(
parqueaderos = ifelse(is.na(parqueaderos), PARQ_IMPUTA, parqueaderos),
estrato = factor(estrato, levels = c(3, 4, 5, 6)),
barrio = arregla_texto(barrio),
precio_m2 = preciom / areaconst
)
}
vivienda_dep <- depurar(vivienda)
cat("Registros originales :", nrow(vivienda), "\n")## Registros originales : 8322
## Registros depurados : 8243
cat("Registros eliminados :", nrow(vivienda) - nrow(vivienda_dep),
sprintf("(%.1f%%)", 100 * (1 - nrow(vivienda_dep) / nrow(vivienda))), "\n")## Registros eliminados : 79 (0.9%)
base1# ---- base1: casas de la Zona Norte ------------------------------------------
base1 <- vivienda_dep %>% filter(tipo == "Casa", zona == "Zona Norte")
cat("base1 —", nrow(base1), "ofertas de casas en la Zona Norte\n")## base1 — 700 ofertas de casas en la Zona Norte
Primeros 3 registros de base1:
base1 %>%
select(id, zona, tipo, estrato, areaconst, habitaciones, banios,
parqueaderos, preciom, barrio) %>%
head(3) %>%
kable(caption = "Primeros 3 registros de base1 (casas · Zona Norte)") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)| id | zona | tipo | estrato | areaconst | habitaciones | banios | parqueaderos | preciom | barrio |
|---|---|---|---|---|---|---|---|---|---|
| 1209 | Zona Norte | Casa | 5 | 150 | 6 | 4 | 2 | 320 | acopi |
| 1592 | Zona Norte | Casa | 5 | 380 | 3 | 3 | 2 | 780 | acopi |
| 4057 | Zona Norte | Casa | 6 | 445 | 6 | 7 | 1 | 750 | acopi |
Tablas de comprobación de la consulta. Si el filtro
está bien aplicado, base1 debe contener un único valor de
tipo y un único valor de zona:
kable(as.data.frame(table(Tipo = base1$tipo)), caption = "Verificación: tipo en base1") %>%
kable_styling(full_width = FALSE, position = "float_left")| Tipo | Freq |
|---|---|
| Casa | 700 |
kable(as.data.frame(table(Zona = base1$zona)), caption = "Verificación: zona en base1") %>%
kable_styling(full_width = FALSE, position = "left")| Zona | Freq |
|---|---|
| Zona Norte | 700 |
# ---- Comparación de la base filtrada contra el total de la ciudad -----------
comparacion <- bind_rows(
vivienda_dep %>% summarise(Base = "Toda la ciudad", n = n(),
`Precio mediano (M)` = median(preciom),
`Área mediana (m²)` = median(areaconst),
`Precio/m² (M)` = round(median(precio_m2), 2)),
base1 %>% summarise(Base = "base1 · Casas Zona Norte", n = n(),
`Precio mediano (M)` = median(preciom),
`Área mediana (m²)` = median(areaconst),
`Precio/m² (M)` = round(median(precio_m2), 2))
)
kable(comparacion, caption = "base1 frente al total del mercado") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| Base | n | Precio mediano (M) | Área mediana (m²) | Precio/m² (M) |
|---|---|---|---|---|
| Toda la ciudad | 8243 | 330 | 122 | 2.65 |
| base1 · Casas Zona Norte | 700 | 390 | 240 | 1.67 |
base1 %>% count(estrato, name = "Ofertas") %>%
mutate(`% del total` = sprintf("%.1f%%", 100 * Ofertas / sum(Ofertas))) %>%
rename(Estrato = estrato) %>%
kable(caption = "Distribución de base1 por estrato") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| Estrato | Ofertas | % del total |
|---|---|---|
| 3 | 229 | 32.7% |
| 4 | 151 | 21.6% |
| 5 | 267 | 38.1% |
| 6 | 53 | 7.6% |
Las casas del norte son más grandes y más caras que el promedio de la ciudad, pero su metro cuadrado es más barato ($1.67 M/m² frente a $2.65 M/m²): se paga por área, no por ubicación premium. La oferta se concentra en los estratos 5 y 3.
# ---- Mapa de todas las ofertas, coloreadas por zona -------------------------
plot_ly(
data = vivienda_dep,
lat = ~latitud, lon = ~longitud,
type = "scattermapbox", mode = "markers",
color = ~zona,
colors = c("#1b6ca8", "#e07b39", "#3f9b5a", "#b5446e", "#8c6bb1"),
marker = list(size = 5, opacity = 0.6),
hoverinfo = "text",
text = ~paste0("<b>", barrio, "</b><br>", zona, " · ", tipo,
"<br>Estrato ", estrato, " · ", areaconst, " m²",
"<br>Precio: $", preciom, " M")
) %>%
layout(
title = "Ofertas de vivienda en Cali por zona declarada",
mapbox = list(style = "open-street-map", zoom = 10.6,
center = list(lat = 3.42, lon = -76.53)),
legend = list(orientation = "h", y = -0.05)
)# ---- ¿Los puntos caen en la zona que declaran? ------------------------------
# Referencia: la latitud 3.45 separa aproximadamente el norte del sur de Cali.
LAT_CORTE <- 3.45
consistencia <- vivienda_dep %>%
group_by(zona) %>%
summarise(
n = n(),
`Lat. mínima` = round(min(latitud), 4),
`Lat. mediana` = round(median(latitud), 4),
`Lat. máxima` = round(max(latitud), 4),
`Al sur de 3.45` = sum(latitud < LAT_CORTE),
`% al sur` = sprintf("%.1f%%", 100 * mean(latitud < LAT_CORTE))
)
kable(consistencia, caption = "Rango de latitudes por zona declarada") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)| zona | n | Lat. mínima | Lat. mediana | Lat. máxima | Al sur de 3.45 | % al sur |
|---|---|---|---|---|---|---|
| Zona Centro | 121 | 3.398 | 3.440 | 3.487 | 102 | 84.3% |
| Zona Norte | 1888 | 3.334 | 3.473 | 3.498 | 330 | 17.5% |
| Zona Oeste | 1188 | 3.360 | 3.449 | 3.494 | 672 | 56.6% |
| Zona Oriente | 345 | 3.344 | 3.438 | 3.490 | 265 | 76.8% |
| Zona Sur | 4701 | 3.333 | 3.385 | 3.497 | 4507 | 95.9% |
# ---- Mapa exclusivo de base1, marcando los puntos inconsistentes ------------
base1_mapa <- base1 %>%
mutate(consistencia = ifelse(latitud < LAT_CORTE,
"Coordenada inconsistente (al sur de 3.45)",
"Coordenada consistente con Zona Norte"))
plot_ly(
data = base1_mapa,
lat = ~latitud, lon = ~longitud,
type = "scattermapbox", mode = "markers",
color = ~consistencia, colors = c("#2c7a4b", "#c0392b"),
marker = list(size = 7, opacity = 0.75),
hoverinfo = "text",
text = ~paste0("<b>", barrio, "</b><br>Estrato ", estrato,
"<br>", areaconst, " m² · ", habitaciones, " hab.",
"<br>Precio: $", preciom, " M")
) %>%
layout(
title = "base1 · Casas declaradas en Zona Norte",
mapbox = list(style = "open-street-map", zoom = 10.8,
center = list(lat = 3.44, lon = -76.53)),
legend = list(orientation = "h", y = -0.05)
)inconsistentes1 <- base1 %>%
filter(latitud < LAT_CORTE) %>%
count(barrio, sort = TRUE)
inconsistentes1 %>%
head(8) %>%
kable(col.names = c("Barrio", "Ofertas mal georreferenciadas"),
caption = "Barrios de base1 con coordenadas al sur del corte") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| Barrio | Ofertas mal georreferenciadas |
|---|---|
| acopi | 45 |
| cali | 11 |
| la flora | 11 |
| san vicente | 6 |
| prados del norte | 5 |
| villa del prado | 5 |
| brisas de los | 4 |
| el bosque | 4 |
# Un mismo barrio no debería aparecer disperso por toda la ciudad. Se toman los dos barrios con más registros inconsistentes y se mide su dispersión en latitud:
barrios_disp <- head(inconsistentes1$barrio, 2)
dispersion1 <- base1 %>%
filter(barrio %in% barrios_disp) %>%
group_by(barrio) %>%
summarise(n = n(), `Lat. mín` = round(min(latitud), 4),
`Lat. mediana` = round(median(latitud), 4),
`Lat. máx` = round(max(latitud), 4),
`Rango (km aprox.)` = round((max(latitud) - min(latitud)) * 111, 1))
kable(dispersion1, caption = "Dispersión geográfica dentro de un mismo barrio") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| barrio | n | Lat. mín | Lat. mediana | Lat. máx | Rango (km aprox.) |
|---|---|---|---|---|---|
| acopi | 60 | 3.350 | 3.396 | 3.487 | 15.2 |
| cali | 11 | 3.337 | 3.367 | 3.435 | 11.0 |
# Coordenadas repetidas de forma exacta en inmuebles distintos: indicio de coordenadas "por defecto" asignadas por el portal cuando no ubica la dirección.
coord_rep <- vivienda_dep %>%
count(latitud, longitud, name = "inmuebles") %>%
filter(inmuebles > 1) %>%
arrange(desc(inmuebles))
cat("Pares (lat, lon) compartidos por más de un inmueble:", nrow(coord_rep), "\n")## Pares (lat, lon) compartidos por más de un inmueble: 881
## Inmuebles que comparten la coordenada más repetida : 106
Discusión. No todos los puntos caen en la zona que declaran. 330 de las 1888 ofertas rotuladas como Zona Norte tienen coordenadas al sur de la latitud 3.45, y a la inversa 192 ofertas de Zona Sur aparecen al norte. Las causas más probables son tres, y ninguna es un error de la consulta:
zona es comercial, no
cartográfica. Corresponde a la clasificación que usa el portal
para agrupar la oferta, y no siempre coincide con la división geográfica
estricta.Implicación para el análisis: el modelo no utiliza latitud ni longitud, de modo que estas inconsistencias no sesgan las estimaciones de precio. Sí obligan a advertir que el mapa es referencial y que, antes de mostrar una oferta al cliente, la dirección debe verificarse. Corregirlo requeriría regeocodificar contra el shapefile de comunas de Cali, lo cual excede el alcance de esta actividad.
# ---- Precio por zona (sobre todas las casas de la ciudad) -------------------
# Dentro de base1 la zona es constante por construcción del filtro, así que la relación precio ~ zona se observa antes de filtrar, para el mismo tipo de inmueble.
plot_ly(vivienda_dep %>% filter(tipo == "Casa"),
x = ~zona, y = ~preciom, color = ~zona,
colors = c("#1b6ca8", "#e07b39", "#3f9b5a", "#b5446e", "#8c6bb1"),
type = "box", boxpoints = "outliers") %>%
layout(title = "Precio de las casas por zona de la ciudad",
xaxis = list(title = ""), yaxis = list(title = "Precio (millones)"),
showlegend = FALSE)vivienda_dep %>% filter(tipo == "Casa") %>%
group_by(Zona = zona) %>%
summarise(Ofertas = n(), `Precio mediano (M)` = median(preciom),
`Área mediana (m²)` = median(areaconst),
`Precio/m² mediano (M)` = round(median(precio_m2), 2)) %>%
kable(caption = "Casas: precio, área y precio por m² según zona") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| Zona | Ofertas | Precio mediano (M) | Área mediana (m²) | Precio/m² mediano (M) |
|---|---|---|---|---|
| Zona Centro | 97 | 300 | 200 | 1.50 |
| Zona Norte | 700 | 390 | 240 | 1.67 |
| Zona Oeste | 164 | 680 | 300 | 2.13 |
| Zona Oriente | 284 | 234 | 178 | 1.25 |
| Zona Sur | 1924 | 480 | 247 | 2.17 |
El gráfico y la tabla muestran cuánto cambia el precio entre zonas
para el mismo tipo de inmueble (la Zona Norte tiene un precio/m² mediano
de $1.67 M frente a $1.92 M del conjunto de casas de la ciudad). Al
fijar zona = "Zona Norte" en base1, esa fuente
de variación queda controlada por diseño y el resto del análisis se
concentra en las variables que sí varían dentro del segmento.
# ---- Distribución de la variable respuesta ----------------------------------
plot_ly(base1, x = ~preciom, type = "histogram", nbinsx = 45,
marker = list(color = "#1b6ca8", line = list(color = "white", width = 0.5))) %>%
layout(title = "Distribución del precio · Casas Zona Norte",
xaxis = list(title = "Precio (millones de pesos)"),
yaxis = list(title = "Número de ofertas"), bargap = 0.02)La distribución es asimétrica a la derecha: la mayoría de casas se concentra entre $256 y $550 millones, con una cola de inmuebles de lujo que llega hasta $1940 millones. Esta asimetría anticipa problemas de normalidad en los residuos, que se confirman en la sección de validación de supuestos del Modelo 1.
# ---- Matriz de correlación entre variables numéricas ------------------------
num1 <- base1 %>% select(preciom, areaconst, habitaciones, parqueaderos, banios)
cor1 <- round(cor(num1), 3)
plot_ly(x = colnames(cor1), y = rownames(cor1), z = cor1, type = "heatmap",
colorscale = "Blues", reversescale = TRUE, zmin = -1, zmax = 1,
text = cor1, texttemplate = "%{text}", hoverinfo = "z") %>%
layout(title = "Matriz de correlación · base1",
xaxis = list(title = ""), yaxis = list(title = "", autorange = "reversed"))El área construida es el predictor dominante (\(r = 0.73\)), seguida del número de baños (\(r = 0.568\)). Las habitaciones y los parqueaderos correlacionan débilmente con el precio (\(r = 0.375\) y 0.344), lo que ya sugiere que podrían no resultar significativos una vez se controla por área. La correlación entre baños y habitaciones (\(r = 0.607\)) es la más alta entre predictores, pero no alcanza niveles preocupantes de multicolinealidad. El estrato, por ser ordinal, no entra en la matriz de Pearson; su asociación con el precio (Spearman \(\rho = 0.717\)) se examina en los diagramas de cajas siguientes.
# ---- Precio vs. área construida, diferenciado por estrato -------------------
plot_ly(base1, x = ~areaconst, y = ~preciom, color = ~estrato,
colors = c("#a8c8e8", "#5b9bd5", "#2e75b6", "#1f4e79"),
type = "scatter", mode = "markers",
marker = list(size = 7, opacity = 0.65),
hoverinfo = "text",
text = ~paste0("<b>", barrio, "</b><br>", areaconst, " m² · Estrato ", estrato,
"<br>$", preciom, " M")) %>%
layout(title = "Precio vs. área construida por estrato · Casas Zona Norte",
xaxis = list(title = "Área construida (m²)"),
yaxis = list(title = "Precio (millones)"),
legend = list(title = list(text = "<b>Estrato</b>")))La relación precio–área es positiva y aproximadamente lineal, y las nubes de puntos aparecen escalonadas por estrato: a igual área, un estrato superior se ubica por encima. Esto valida tratar el estrato como variable categórica con efecto de nivel. También se observa que la dispersión crece con el área —primer indicio visual de heterocedasticidad.
# ---- Precio por estrato ------------------------------------------------------
plot_ly(base1, x = ~estrato, y = ~preciom, color = ~estrato,
colors = c("#a8c8e8", "#5b9bd5", "#2e75b6", "#1f4e79"),
type = "box", boxpoints = "outliers") %>%
layout(title = "Precio por estrato · Casas Zona Norte",
xaxis = list(title = "Estrato"),
yaxis = list(title = "Precio (millones)"), showlegend = FALSE)# ---- Precio por número de habitaciones y de baños ---------------------------
p_hab <- plot_ly(base1 %>% filter(habitaciones <= 8),
x = ~factor(habitaciones), y = ~preciom, type = "box",
name = "Habitaciones", marker = list(color = "#e07b39"),
line = list(color = "#e07b39"))
p_ban <- plot_ly(base1 %>% filter(banios <= 8),
x = ~factor(banios), y = ~preciom, type = "box",
name = "Baños", marker = list(color = "#3f9b5a"),
line = list(color = "#3f9b5a"))
subplot(p_hab, p_ban, nrows = 1, shareY = TRUE, titleX = TRUE) %>%
layout(title = "Precio según habitaciones (izq.) y baños (der.)",
yaxis = list(title = "Precio (millones)"))Interpretación conjunta del EDA. El precio de una casa en el norte de Cali se explica sobre todo por cuánto mide y en qué estrato está. El número de baños escala con el precio de forma monótona y clara; el de habitaciones se aplana a partir de 4–5, señal de que más habitaciones dentro de la misma área no agregan valor, solo reparten el espacio. La mediana de precio sube de forma marcada entre estratos (estrato 3: $210 M · estrato 4: $380 M · estrato 5: $480 M · estrato 6: $780 M).
# ---- Modelo de regresión lineal múltiple ------------------------------------
formula_modelo <- preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios
modelo1 <- lm(formula_modelo, data = base1)
summary(modelo1)##
## Call:
## lm(formula = formula_modelo, data = base1)
##
## Residuals:
## Min 1Q Median 3Q Max
## -921.8 -70.9 -15.0 45.5 1090.5
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 12.3442 18.9474 0.65 0.515
## areaconst 0.7768 0.0458 16.97 < 0.0000000000000002 ***
## estrato4 72.4612 17.6429 4.11 0.0000448552523531 ***
## estrato5 133.8308 16.8880 7.92 0.0000000000000092 ***
## estrato6 323.3245 27.2934 11.85 < 0.0000000000000002 ***
## habitaciones 3.8387 4.7583 0.81 0.420
## parqueaderos 9.7819 5.2454 1.86 0.063 .
## banios 28.1559 5.9257 4.75 0.0000024567678465 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 156 on 692 degrees of freedom
## Multiple R-squared: 0.665, Adjusted R-squared: 0.662
## F-statistic: 196 on 7 and 692 DF, p-value: <0.0000000000000002
# Nombres legibles de los términos; se asignan por nombre (no por posición) para que la tabla no dependa de que todos los niveles de estrato estén presentes.
nombres_coef <- c("(Intercept)" = "Intercepto", areaconst = "Área construida (m²)",
estrato4 = "Estrato 4 (vs. 3)", estrato5 = "Estrato 5 (vs. 3)",
estrato6 = "Estrato 6 (vs. 3)", habitaciones = "Habitaciones",
parqueaderos = "Parqueaderos", banios = "Baños")
tidy(modelo1, conf.int = TRUE) %>%
mutate(
Variable = unname(nombres_coef[term]),
Signif = case_when(p.value < 0.001 ~ "***", p.value < 0.01 ~ "**",
p.value < 0.05 ~ "*", p.value < 0.1 ~ ".", TRUE ~ "")
) %>%
select(Variable, Coeficiente = estimate, `Error est.` = std.error,
`t` = statistic, `p-valor` = p.value,
`IC 95% inf` = conf.low, `IC 95% sup` = conf.high, Signif) %>%
mutate(across(where(is.numeric), ~round(., 3))) %>%
kable(caption = "Modelo 1 · Casas Zona Norte — coeficientes estimados") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed")) %>%
row_spec(which(summary(modelo1)$coefficients[, 4] < 0.05), bold = TRUE)| Variable | Coeficiente | Error est. | t | p-valor | IC 95% inf | IC 95% sup | Signif |
|---|---|---|---|---|---|---|---|
| Intercepto | 12.344 | 18.947 | 0.652 | 0.515 | -24.857 | 49.546 | |
| Área construida (m²) | 0.777 | 0.046 | 16.975 | 0.000 | 0.687 | 0.867 | *** |
| Estrato 4 (vs. 3) | 72.461 | 17.643 | 4.107 | 0.000 | 37.821 | 107.101 | *** |
| Estrato 5 (vs. 3) | 133.831 | 16.888 | 7.925 | 0.000 | 100.673 | 166.989 | *** |
| Estrato 6 (vs. 3) | 323.324 | 27.293 | 11.846 | 0.000 | 269.737 | 376.912 | *** |
| Habitaciones | 3.839 | 4.758 | 0.807 | 0.420 | -5.504 | 13.181 | |
| Parqueaderos | 9.782 | 5.245 | 1.865 | 0.063 | -0.517 | 20.081 | . |
| Baños | 28.156 | 5.926 | 4.751 | 0.000 | 16.521 | 39.790 | *** |
Área construida = 0.777 (p < 0.001). Cada metro cuadrado adicional aumenta el precio esperado en $0.78 millones (≈ $777 mil por m²), manteniendo constantes estrato, habitaciones, parqueaderos y baños. Para dimensionarlo: ampliar la casa en 20 m² vale unos $16 millones. Es coherente con el precio/m² mediano observado ($1.67 M).
Estrato (p < 0.001 o menor en los tres niveles; referencia estrato 3). A igualdad de características físicas, una casa en estrato 4 vale $72 millones más que una en estrato 3; en estrato 5, $134 millones más; y en estrato 6, $323 millones más. El salto no es lineal: pasar de 5 a 6 cuesta $189 millones, 3.1 veces lo que cuesta pasar de 4 a 5 ($61 millones). Esto confirma la decisión metodológica de tratar el estrato como categórico: un modelo que lo usara como número forzaría saltos iguales y subestimaría el estrato 6.
Baños = 28.16 (p < 0.001). Cada baño adicional agrega $28 millones, a igual área. Tiene sentido económico: un baño más dentro de la misma superficie señala mejor distribución y acabados de mayor categoría.
Parqueaderos = 9.78 (p = 0.063). No es significativo al 5% (sí al 10%). En las casas del norte el parqueadero prácticamente se da por descontado, y su aporte marginal no se distingue del ruido una vez se controla por área. La imputación de faltantes también diluye su efecto.
Habitaciones = 3.84 (p = 0.420). No es significativo al 5%. Es un resultado lógico, no una anomalía: a área constante, subdividir la casa en más habitaciones no crea valor, solo lo redistribuye. El mercado paga por metros cuadrados y por estrato, no por el número de tabiques.
Intercepto = 12.34 (p = 0.515). Correspondería a una casa de 0 m², 0 habitaciones y 0 baños en estrato 3: carece de interpretación práctica y solo cumple una función de ajuste; su significancia o no significancia no afecta la validez del modelo.
g1 <- glance(modelo1)
data.frame(
Indicador = c("R²", "R² ajustado", "Error estándar residual (M)",
"Estadístico F", "p-valor del modelo", "Observaciones"),
Valor = c(round(g1$r.squared, 4), round(g1$adj.r.squared, 4),
round(g1$sigma, 1), round(g1$statistic, 1),
format.pval(g1$p.value, digits = 3), nrow(base1))
) %>%
kable(caption = "Modelo 1 · Indicadores de ajuste") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| Indicador | Valor |
|---|---|
| R² | 0.665 |
| R² ajustado | 0.6616 |
| Error estándar residual (M) | 155.8 |
| Estadístico F | 196.3 |
| p-valor del modelo | <0.0000000000000002 |
| Observaciones | 700 |
El \(R^2 = 0.665\) indica que las cinco variables explican el 66.5% de la variabilidad del precio de las casas del norte; el 33.5% restante queda sin explicar. El \(R^2\) ajustado (0.6616) casi no difiere, señal de que no hay variables sobrantes inflando el ajuste. El estadístico F es altamente significativo: el modelo en conjunto sí explica el precio.
¿Es un buen ajuste? Para valoración inmobiliaria con solo cinco variables, sí. Pero el error estándar residual de $156 millones es la cifra que María debe tener presente: es el margen típico de error de una tasación individual, sobre un precio mediano de $390 millones. Sirve para orientar una negociación, no para fijar un precio de cierre.
Qué se podría hacer para mejorarlo. El 33% no explicado proviene de atributos que la base no registra:
# ¿Aportan conjuntamente habitaciones y parqueaderos, pese a su no significancia individual?
modelo1_red <- lm(preciom ~ areaconst + estrato + banios, data = base1)
anova(modelo1_red, modelo1)La prueba F compara el modelo solicitado contra una versión sin
habitaciones ni parqueaderos. El p-valor
resultante (= 0.116) indica que su aporte conjunto tampoco es
significativo al 5%. Se conservan en el modelo por ser parte de
la especificación solicitada y porque el cliente define su solicitud en
esos términos, pero María debe saber que no son las palancas de
precio: negociar una habitación de más no debería costar dinero
adicional.
Los cuatro gráficos se leen así: Residuals vs Fitted evalúa linealidad y varianza constante (se espera una nube horizontal sin curvatura ni forma de abanico); Q-Q Residuals evalúa normalidad (los puntos deben seguir la diagonal); Scale-Location vuelve sobre la homocedasticidad (la línea roja debe ser plana); y Residuals vs Leverage identifica observaciones con mucha influencia sobre el ajuste (fuera de las curvas de Cook).
# ---- Pruebas formales --------------------------------------------------------
# Shapiro-Wilk admite máximo 5.000 observaciones: si hay más, se toma una muestra.
shapiro_seguro <- function(m) {
r <- residuals(m)
if (length(r) > 5000) { set.seed(123); r <- sample(r, 5000) }
shapiro.test(r)
}
bp1 <- bptest(modelo1) # homocedasticidad
sw1 <- shapiro_seguro(modelo1) # normalidad
dw1 <- dwtest(modelo1) # independencia
data.frame(
Supuesto = c("Homocedasticidad", "Normalidad de residuos", "Independencia de residuos"),
Prueba = c("Breusch-Pagan", "Shapiro-Wilk", "Durbin-Watson"),
Estadístico = round(c(bp1$statistic, sw1$statistic, dw1$statistic), 4),
`p-valor` = format.pval(c(bp1$p.value, sw1$p.value, dw1$p.value), digits = 3, eps = 0.0001),
Conclusión = c(ifelse(bp1$p.value < 0.05, "Se rechaza — heterocedasticidad", "No se rechaza"),
ifelse(sw1$p.value < 0.05, "Se rechaza — no normalidad", "No se rechaza"),
ifelse(dw1$p.value < 0.05, "Se rechaza — hay autocorrelación", "No se rechaza")),
check.names = FALSE
) %>%
kable(caption = "Modelo 1 · Pruebas formales de supuestos (α = 0.05)") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)| Supuesto | Prueba | Estadístico | p-valor | Conclusión | |
|---|---|---|---|---|---|
| BP | Homocedasticidad | Breusch-Pagan | 133.9463 | <0.0001 | Se rechaza — heterocedasticidad |
| W | Normalidad de residuos | Shapiro-Wilk | 0.8267 | <0.0001 | Se rechaza — no normalidad |
| DW | Independencia de residuos | Durbin-Watson | 1.6887 | <0.0001 | Se rechaza — hay autocorrelación |
# ---- Multicolinealidad -------------------------------------------------------
# Con una variable categórica (estrato) car::vif devuelve el GVIF; la columna
# GVIF^(1/(2*Df)) es la comparable con el umbral usual (VIF < 5).
vif1 <- vif(modelo1)
kable(round(as.data.frame(vif1), 3),
caption = "Modelo 1 · Factores de inflación de varianza (VIF)") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| GVIF | Df | GVIF^(1/(2*Df)) | |
|---|---|---|---|
| areaconst | 1.675 | 1 | 1.294 |
| estrato | 1.632 | 3 | 1.085 |
| habitaciones | 1.852 | 1 | 1.361 |
| parqueaderos | 1.228 | 1 | 1.108 |
| banios | 2.180 | 1 | 1.476 |
| Supuesto | Resultado | Lectura |
|---|---|---|
| Multicolinealidad | Mayor GVIF ajustado = 1.48 | Cumple. No hay redundancia entre predictores; los coeficientes son estables e interpretables por separado |
| Homocedasticidad | Breusch-Pagan p < 0.001 | No cumple. La varianza del error no es constante: el modelo acierta más en unos tramos de precio que en otros (ver el abanico en Residuals vs Fitted) |
| Normalidad | Shapiro-Wilk p < 0.001 | No cumple. Los residuos se apartan de la normal (ver colas del Q-Q plot); la asimetría del mercado inmobiliario se traslada a los residuos |
| Linealidad | Lectura visual de Residuals vs Fitted | Se verifica en el gráfico: una nube centrada en cero y sin curvatura sistemática indica que la forma lineal es adecuada para el rango observado |
| Independencia | Durbin-Watson p < 0.001 | Formalmente se rechaza, pero el dato es de corte transversal y no tiene orden temporal: la señal refleja más bien que casas del mismo barrio comparten factores no observados |
Sugerencias de corrección (se enuncian como trabajo futuro; no se aplican en esta actividad):
Consecuencia práctica: la heterocedasticidad y la no normalidad afectan la precisión de los intervalos, no la insesgadez de las estimaciones puntuales. Las valoraciones del informe ejecutivo siguen siendo válidas como referencia central; los intervalos deben leerse como aproximados.
# ---- Perfil solicitado: casa, 200 m², 1 parq., 2 baños, 4 hab., estrato 4 o 5
nuevo <- function(area, est, hab, parq, ban) {
data.frame(areaconst = area, estrato = factor(est, levels = c(3, 4, 5, 6)),
habitaciones = hab, parqueaderos = parq, banios = ban)
}
solicitud1 <- bind_rows(nuevo(200, 4, 4, 1, 2), nuevo(200, 5, 4, 1, 2))
pred_conf1 <- predict(modelo1, solicitud1, interval = "confidence", level = 0.95)
pred_pred1 <- predict(modelo1, solicitud1, interval = "prediction", level = 0.95)
resultado1 <- data.frame(
Escenario = c("Estrato 4", "Estrato 5"),
`Precio estimado (M)` = round(pred_conf1[, "fit"], 1),
`IC 95% del precio medio` = sprintf("[%.1f ; %.1f]", pred_conf1[, "lwr"], pred_conf1[, "upr"]),
`IP 95% de una casa individual` = sprintf("[%.1f ; %.1f]", pred_pred1[, "lwr"], pred_pred1[, "upr"]),
`Crédito (M)` = 350,
`Diferencia (M)` = round(350 - pred_conf1[, "fit"], 1),
check.names = FALSE
)
# Color de cada fila según si el precio medio estimado cabe o no en el crédito
color_fila <- ifelse(pred_conf1[, "fit"] <= 350, "#eaf7ee", "#fdecea")
kable(resultado1, caption = "Solicitud 1 · Estimación del precio de la casa en la Zona Norte") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE) %>%
row_spec(1, background = color_fila[1]) %>%
row_spec(2, background = color_fila[2])| Escenario | Precio estimado (M) | IC 95% del precio medio | IP 95% de una casa individual | Crédito (M) | Diferencia (M) |
|---|---|---|---|---|---|
| Estrato 4 | 321.6 | [290.8 ; 352.5] | [14.3 ; 629.0] | 350 | 28.4 |
| Estrato 5 | 383.0 | [354.5 ; 411.5] | [75.9 ; 690.1] | 350 | -33.0 |
Estrato 4. El precio esperado es de $321.6 millones. El intervalo de confianza del 95% —$290.8 a $352.5 millones— se refiere al precio promedio de todas las casas con ese perfil, y es la cifra apropiada para orientar una política de oferta. El crédito de $350 millones alcanza, con un margen de $28.4 millones, pero el límite superior del intervalo ($352.5 M) llega casi al tope: no hay espacio para sobrecostos.
Estrato 5. El precio esperado sube a $383.0 millones, es decir $33.0 millones por encima del crédito. Todo el intervalo de confianza queda por fuera del presupuesto, así que no se trata de un caso límite: con $350 millones no se compra esa casa en estrato 5.
Sobre los dos intervalos. El intervalo de predicción es mucho más amplio ($14 a $629 millones en estrato 4) porque proyecta una casa concreta e incorpora la variabilidad individual, no solo la incertidumbre sobre el promedio. Su amplitud es consecuencia directa del error residual de $156 millones y confirma que el modelo sirve para enmarcar la negociación, no para tasar un inmueble específico sin visitarlo.
Nota de validez: el perfil solicitado (200 m²) está dentro del rango observado —el 60% de las casas del norte supera esa área— por lo que no se está extrapolando. La estimación es interna al soporte de los datos.
# ---- Criterio: cumplir el perfil del cliente y no exceder el crédito --------
CREDITO1 <- 350
base1_val <- base1 %>%
mutate(valor_estimado = predict(modelo1, .),
residual = preciom - valor_estimado,
descuento_pct = 100 * residual / valor_estimado)
# El enunciado exige al menos cinco ofertas. Se parte del perfil literal del cliente y, si no se alcanzan, se baja un peldaño de la escalera de relajación.
MIN_OFERTAS <- 5
filtra1 <- function(d, area_min, parq_min) d %>%
filter(preciom <= CREDITO1, # cabe en el crédito preaprobado
areaconst >= area_min, # área solicitada (o relajada)
habitaciones >= 4, # requisito del cliente, no se relaja
banios >= 2, # requisito del cliente, no se relaja
parqueaderos >= parq_min, # requisito del cliente (o relajado)
estrato %in% c(4, 5)) %>% # estratos solicitados, no se relajan
arrange(residual)
aplica_escalera <- function(d, filtro, escalera) {
for (i in seq_along(escalera)) {
cand <- filtro(d, escalera[[i]]$area, escalera[[i]]$parq)
if (nrow(cand) >= MIN_OFERTAS || i == length(escalera))
return(list(candidatas = cand, criterio = escalera[[i]]$etiqueta, peldano = i))
}
}
escalera1 <- list(
list(area = 200, parq = 1, etiqueta = "perfil literal (200 m², 1 parqueadero)"),
list(area = 180, parq = 1, etiqueta = "área desde 180 m²"))
# Traza completa: cuántas candidatas deja cada peldaño
for (e in escalera1)
cat(sprintf("%-42s -> %d candidatas\n", e$etiqueta,
nrow(filtra1(base1_val, e$area, e$parq))))## perfil literal (200 m², 1 parqueadero) -> 39 candidatas
## área desde 180 m² -> 41 candidatas
sel1 <- aplica_escalera(base1_val, filtra1, escalera1)
candidatas1 <- sel1$candidatas; CRITERIO1 <- sel1$criterio
cat("Criterio usado:", CRITERIO1, "->", nrow(candidatas1), "casas candidatas\n")## Criterio usado: perfil literal (200 m², 1 parqueadero) -> 39 casas candidatas
ofertas1 <- candidatas1 %>% slice_head(n = 6)
ofertas1 %>%
transmute(Barrio = titulo(barrio), Estrato = estrato, `Área (m²)` = areaconst,
Hab. = habitaciones, Baños = banios, Parq. = parqueaderos,
`Precio oferta (M)` = preciom,
`Valor estimado (M)` = round(valor_estimado, 0),
`Dif. (M)` = round(residual, 0),
`Descuento` = sprintf("%.0f%%", -descuento_pct)) %>%
kable(caption = paste0("Solicitud 1 · ", nrow(ofertas1),
" ofertas recomendadas, ordenadas por mayor descuento frente al valor estimado")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE)| Barrio | Estrato | Área (m²) | Hab. | Baños | Parq. | Precio oferta (M) | Valor estimado (M) | Dif. (M) | Descuento |
|---|---|---|---|---|---|---|---|---|---|
| San Vicente | 5 | 355 | 8 | 5 | 2 | 340 | 613 | -273 | 45% |
| El Bosque | 5 | 243 | 5 | 4 | 1 | 250 | 477 | -227 | 48% |
| El Bosque | 5 | 300 | 6 | 5 | 3 | 350 | 572 | -222 | 39% |
| Los Andes | 4 | 280 | 6 | 4 | 2 | 260 | 458 | -198 | 43% |
| Vipasa | 5 | 205 | 6 | 5 | 2 | 300 | 489 | -189 | 39% |
| La Merced | 5 | 249 | 5 | 5 | 1 | 321 | 509 | -188 | 37% |
plot_ly() %>%
add_trace(data = candidatas1, lat = ~latitud, lon = ~longitud,
type = "scattermapbox", mode = "markers", name = "Otras candidatas",
marker = list(size = 8, color = "#b9c6d2", opacity = 0.7),
hoverinfo = "text",
text = ~paste0(barrio, " · ", areaconst, " m² · $", preciom, " M")) %>%
add_trace(data = ofertas1, lat = ~latitud, lon = ~longitud,
type = "scattermapbox", mode = "markers",
name = paste("Top", nrow(ofertas1), "recomendadas"),
marker = list(size = 16, color = "#c0392b", opacity = 0.95),
hoverinfo = "text",
text = ~paste0("<b>", barrio, "</b><br>Estrato ", estrato, " · ",
areaconst, " m²<br>", habitaciones, " hab · ", banios, " baños",
"<br><b>Oferta: $", preciom, " M</b>",
"<br>Valor estimado: $", round(valor_estimado), " M")) %>%
layout(title = "Solicitud 1 · Ofertas potenciales de casa en la Zona Norte",
mapbox = list(style = "open-street-map", zoom = 11.2,
center = list(lat = 3.46, lon = -76.52)),
legend = list(orientation = "h", y = -0.05))De las 700 casas de la Zona Norte, 39 cumplen simultáneamente las condiciones del cliente y el techo de $350 millones, bajo el criterio perfil literal (200 m², 1 parqueadero). El criterio de ordenamiento no es el precio más bajo sino el residual del modelo: cuánto está por debajo cada oferta de lo que el modelo dice que debería valer. Una casa de $340 millones cuyo valor estimado es $613 millones representa una oportunidad de $273 millones si el inmueble está en las condiciones que declara.
Tres advertencias que María debe trasladar al cliente:
Se replican íntegramente los pasos 1 a 6 del enunciado sobre el segundo mercado: apartamentos de la Zona Sur, con crédito preaprobado de $850 millones.
# ---- base2: apartamentos de la Zona Sur -------------------------------------
base2 <- vivienda_dep %>% filter(tipo == "Apartamento", zona == "Zona Sur")
cat("base2 —", nrow(base2), "ofertas de apartamentos en la Zona Sur\n")## base2 — 2777 ofertas de apartamentos en la Zona Sur
Primeros 3 registros de base2:
base2 %>%
select(id, zona, tipo, estrato, areaconst, habitaciones, banios,
parqueaderos, preciom, barrio) %>%
head(3) %>%
kable(caption = "Primeros 3 registros de base2 (apartamentos · Zona Sur)") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)| id | zona | tipo | estrato | areaconst | habitaciones | banios | parqueaderos | preciom | barrio |
|---|---|---|---|---|---|---|---|---|---|
| 5098 | Zona Sur | Apartamento | 4 | 96 | 3 | 2 | 1 | 290 | acopi |
| 698 | Zona Sur | Apartamento | 3 | 40 | 2 | 1 | 1 | 78 | aguablanca |
| 8199 | Zona Sur | Apartamento | 6 | 194 | 3 | 5 | 2 | 875 | aguacatal |
kable(as.data.frame(table(Tipo = base2$tipo)), caption = "Verificación: tipo en base2") %>%
kable_styling(full_width = FALSE, position = "float_left")| Tipo | Freq |
|---|---|
| Apartamento | 2777 |
kable(as.data.frame(table(Zona = base2$zona)), caption = "Verificación: zona en base2") %>%
kable_styling(full_width = FALSE, position = "left")| Zona | Freq |
|---|---|
| Zona Sur | 2777 |
bind_rows(
base1 %>% summarise(Base = "base1 · Casas Zona Norte", n = n(),
`Precio mediano (M)` = median(preciom),
`Área mediana (m²)` = median(areaconst),
`Precio/m² (M)` = round(median(precio_m2), 2)),
base2 %>% summarise(Base = "base2 · Aptos. Zona Sur", n = n(),
`Precio mediano (M)` = median(preciom),
`Área mediana (m²)` = median(areaconst),
`Precio/m² (M)` = round(median(precio_m2), 2))
) %>%
kable(caption = "Contraste entre los dos mercados analizados") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| Base | n | Precio mediano (M) | Área mediana (m²) | Precio/m² (M) |
|---|---|---|---|---|
| base1 · Casas Zona Norte | 700 | 390 | 240 | 1.67 |
| base2 · Aptos. Zona Sur | 2777 | 245 | 85 | 2.90 |
base2 %>% count(estrato, name = "Ofertas") %>%
mutate(`% del total` = sprintf("%.1f%%", 100 * Ofertas / sum(Ofertas))) %>%
rename(Estrato = estrato) %>%
kable(caption = "Distribución de base2 por estrato") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| Estrato | Ofertas | % del total |
|---|---|---|
| 3 | 200 | 7.2% |
| 4 | 1086 | 39.1% |
| 5 | 1031 | 37.1% |
| 6 | 460 | 16.6% |
El contraste entre mercados es nítido y justifica haberlos modelado por separado: los apartamentos del sur son mucho más pequeños (mediana de 85 m² frente a 240 m²) y sin embargo su metro cuadrado cuesta 74% más ($2.90 M/m² vs. $1.67 M/m²). En el sur se paga por ubicación; en el norte, por área.
base2_mapa <- base2 %>%
mutate(consistencia = ifelse(latitud > LAT_CORTE,
"Coordenada inconsistente (al norte de 3.45)",
"Coordenada consistente con Zona Sur"))
plot_ly(data = base2_mapa, lat = ~latitud, lon = ~longitud,
type = "scattermapbox", mode = "markers",
color = ~consistencia, colors = c("#2c7a4b", "#c0392b"),
marker = list(size = 6, opacity = 0.65),
hoverinfo = "text",
text = ~paste0("<b>", barrio, "</b><br>Estrato ", estrato,
"<br>", areaconst, " m² · ", habitaciones, " hab.",
"<br>Precio: $", preciom, " M")) %>%
layout(title = "base2 · Apartamentos declarados en Zona Sur",
mapbox = list(style = "open-street-map", zoom = 10.8,
center = list(lat = 3.40, lon = -76.53)),
legend = list(orientation = "h", y = -0.05))inconsistentes2 <- base2 %>% filter(latitud > LAT_CORTE) %>% count(barrio, sort = TRUE)
inconsistentes2 %>% head(5) %>%
kable(col.names = c("Barrio", "Ofertas mal georreferenciadas"),
caption = "Barrios de base2 con coordenadas al norte del corte") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| Barrio | Ofertas mal georreferenciadas |
|---|---|
| valle del lili | 56 |
| pance | 6 |
| bochalema | 5 |
| ciudad bochalema | 4 |
| el caney | 4 |
Se repite el patrón detectado en base1: 124 de 2777
apartamentos (4.5%) aparecen georreferenciados al norte del corte,
encabezados por Valle del Lili y Pance. Confirma que
la inconsistencia proviene de la geocodificación, no
del filtro ni de la clasificación comercial de zonas.
# ---- Precio por zona (sobre todos los apartamentos de la ciudad) ------------
plot_ly(vivienda_dep %>% filter(tipo == "Apartamento"),
x = ~zona, y = ~preciom, color = ~zona,
colors = c("#1b6ca8", "#e07b39", "#3f9b5a", "#b5446e", "#8c6bb1"),
type = "box", boxpoints = "outliers") %>%
layout(title = "Precio de los apartamentos por zona de la ciudad",
xaxis = list(title = ""), yaxis = list(title = "Precio (millones)"),
showlegend = FALSE)Como en la Solicitud 1, la zona queda fijada por el filtro de
base2; el gráfico muestra la posición de la Zona Sur frente
al resto de la ciudad para el mismo tipo de inmueble (precio/m² mediano
de $2.90 M frente a $3.00 M del conjunto de apartamentos).
num2 <- base2 %>% select(preciom, areaconst, habitaciones, parqueaderos, banios)
cor2 <- round(cor(num2), 3)
plot_ly(x = colnames(cor2), y = rownames(cor2), z = cor2, type = "heatmap",
colorscale = "Greens", reversescale = TRUE, zmin = -1, zmax = 1,
text = cor2, texttemplate = "%{text}", hoverinfo = "z") %>%
layout(title = "Matriz de correlación · base2",
xaxis = list(title = ""), yaxis = list(title = "", autorange = "reversed"))Correlaciones con el precio en base2: área (0.757),
baños (0.733), parqueaderos (0.708) y habitaciones (0.344); frente a
base1, el cambio más notorio es el del parqueadero (0.344
en las casas del norte). En propiedad horizontal el parqueadero es un
activo escaso que se transa por separado, mientras que en una casa
unifamiliar viene incorporado.
plot_ly(base2, x = ~areaconst, y = ~preciom, color = ~estrato,
colors = c("#b7e0c4", "#7bc496", "#3f9b5a", "#1e6b39"),
type = "scatter", mode = "markers",
marker = list(size = 6, opacity = 0.55),
hoverinfo = "text",
text = ~paste0("<b>", barrio, "</b><br>", areaconst, " m² · Estrato ", estrato,
"<br>$", preciom, " M")) %>%
layout(title = "Precio vs. área construida por estrato · Apartamentos Zona Sur",
xaxis = list(title = "Área construida (m²)"),
yaxis = list(title = "Precio (millones)"),
legend = list(title = list(text = "<b>Estrato</b>")))p1 <- plot_ly(base2, x = ~estrato, y = ~preciom, color = ~estrato,
colors = c("#b7e0c4", "#7bc496", "#3f9b5a", "#1e6b39"),
type = "box", showlegend = FALSE)
p2 <- plot_ly(base2 %>% filter(parqueaderos <= 4), x = ~factor(parqueaderos), y = ~preciom,
type = "box", name = "Parqueaderos",
marker = list(color = "#e07b39"), line = list(color = "#e07b39"),
showlegend = FALSE)
subplot(p1, p2, nrows = 1, shareY = TRUE, titleX = TRUE) %>%
layout(title = "Precio por estrato (izq.) y por número de parqueaderos (der.)",
yaxis = list(title = "Precio (millones)"))plot_ly(base2, x = ~areaconst, type = "histogram", nbinsx = 60,
marker = list(color = "#3f9b5a")) %>%
layout(title = "Distribución del área construida · Apartamentos Zona Sur",
xaxis = list(title = "Área construida (m²)", range = c(0, 400)),
yaxis = list(title = "Número de ofertas"), bargap = 0.02,
shapes = list(list(type = "line", x0 = 300, x1 = 300, y0 = 0, y1 = 1,
yref = "paper",
line = list(color = "#c0392b", width = 2, dash = "dash"))),
annotations = list(list(x = 300, y = 1, yref = "paper", xanchor = "left",
text = "300 m² solicitados", showarrow = FALSE,
font = list(color = "#c0392b"))))Hallazgo crítico para el caso. El área solicitada (300 m²) queda en el extremo derecho de la distribución: solo 31 apartamentos (1.1%) de la Zona Sur alcanzan ese tamaño, frente a una mediana de 85 m². La solicitud no es inalcanzable por presupuesto sino por disponibilidad de producto, y esto condiciona tanto la confiabilidad de la estimación como la estrategia de búsqueda.
##
## Call:
## lm(formula = formula_modelo, data = base2)
##
## Residuals:
## Min 1Q Median 3Q Max
## -1081.0 -35.6 -2.1 35.0 895.3
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -42.1351 10.3607 -4.07 0.000048991846998 ***
## areaconst 1.3142 0.0474 27.75 < 0.0000000000000002 ***
## estrato4 28.4466 6.8886 4.13 0.000037422631659 ***
## estrato5 53.8148 7.1119 7.57 0.000000000000052 ***
## estrato6 207.0340 8.9520 23.13 < 0.0000000000000002 ***
## habitaciones -12.7093 3.2944 -3.86 0.00012 ***
## parqueaderos 62.8145 3.7342 16.82 < 0.0000000000000002 ***
## banios 39.5855 2.9716 13.32 < 0.0000000000000002 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 87.6 on 2769 degrees of freedom
## Multiple R-squared: 0.79, Adjusted R-squared: 0.789
## F-statistic: 1.49e+03 on 7 and 2769 DF, p-value: <0.0000000000000002
tidy(modelo2, conf.int = TRUE) %>%
mutate(
Variable = unname(nombres_coef[term]), # mismos nombres legibles del Modelo 1
Signif = case_when(p.value < 0.001 ~ "***", p.value < 0.01 ~ "**",
p.value < 0.05 ~ "*", p.value < 0.1 ~ ".", TRUE ~ "")
) %>%
select(Variable, Coeficiente = estimate, `Error est.` = std.error,
`t` = statistic, `p-valor` = p.value,
`IC 95% inf` = conf.low, `IC 95% sup` = conf.high, Signif) %>%
mutate(across(where(is.numeric), ~round(., 3))) %>%
kable(caption = "Modelo 2 · Apartamentos Zona Sur — coeficientes estimados") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed")) %>%
row_spec(which(summary(modelo2)$coefficients[, 4] < 0.05), bold = TRUE)| Variable | Coeficiente | Error est. | t | p-valor | IC 95% inf | IC 95% sup | Signif |
|---|---|---|---|---|---|---|---|
| Intercepto | -42.135 | 10.361 | -4.067 | 0 | -62.451 | -21.820 | *** |
| Área construida (m²) | 1.314 | 0.047 | 27.754 | 0 | 1.221 | 1.407 | *** |
| Estrato 4 (vs. 3) | 28.447 | 6.889 | 4.130 | 0 | 14.939 | 41.954 | *** |
| Estrato 5 (vs. 3) | 53.815 | 7.112 | 7.567 | 0 | 39.870 | 67.760 | *** |
| Estrato 6 (vs. 3) | 207.034 | 8.952 | 23.127 | 0 | 189.481 | 224.587 | *** |
| Habitaciones | -12.709 | 3.294 | -3.858 | 0 | -19.169 | -6.249 | *** |
| Parqueaderos | 62.815 | 3.734 | 16.822 | 0 | 55.492 | 70.137 | *** |
| Baños | 39.586 | 2.972 | 13.321 | 0 | 33.759 | 45.412 | *** |
Área construida = 1.314 (p < 0.001). Cada m² adicional vale $1.31 millones, es decir un 69% más que en las casas del norte ($0.78 M). Coincide con lo observado en el EDA: el suelo del sur es más caro.
Estrato. Frente al estrato 3, el 4 agrega $28 millones, el 5 $54 millones y el 6 $207 millones. El salto al estrato 6 es de nuevo desproporcionado ($153 millones sobre el 5), y aquí es menos marcado que en el norte ($189 millones): en la Zona Sur el estrato 6 concentra la oferta premium (barrios con más ofertas de estrato 6: Pance, Ciudad Jardín, Parcelaciones Pance) y funciona casi como un mercado aparte.
Parqueaderos = 62.81 (p < 0.001). Cada cupo adicional vale $63 millones. Es el contraste más interesante con el Modelo 1, donde la variable no era significativa: en propiedad horizontal el parqueadero es un bien escaso que se transa por separado, mientras que en una casa unifamiliar viene incorporado. Un mismo atributo tiene valor económico distinto según el mercado — exactamente el argumento que sostenía estimar dos modelos.
Baños = 39.59 (p < 0.001). Cada baño adicional suma $40 millones, más que en las casas del norte ($28 M).
Habitaciones = -12.71 (p < 0.001) — coeficiente negativo. A igual área, estrato, baños y parqueaderos, cada habitación adicional reduce el precio esperado en $13 millones. No es un error: es la lectura correcta de un modelo con controles. Partir 90 m² en cuatro habitaciones en vez de tres produce espacios más pequeños y un producto menos apetecido en un segmento que valora amplitud. El mercado del sur premia apartamentos amplios con pocas habitaciones grandes, no la subdivisión.
Intercepto = -42.14: sin interpretación práctica (implicaría un apartamento de 0 m²); cumple función de ajuste.
data.frame(
Indicador = c("R²", "R² ajustado", "Error estándar residual (M)",
"Estadístico F", "p-valor del modelo", "Observaciones"),
Valor = c(round(g2$r.squared, 4), round(g2$adj.r.squared, 4),
round(g2$sigma, 1), round(g2$statistic, 1),
format.pval(g2$p.value, digits = 3), nrow(base2))
) %>%
kable(caption = "Modelo 2 · Indicadores de ajuste") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| Indicador | Valor |
|---|---|
| R² | 0.7897 |
| R² ajustado | 0.7892 |
| Error estándar residual (M) | 87.6 |
| Estadístico F | 1485.7 |
| p-valor del modelo | <0.0000000000000002 |
| Observaciones | 2777 |
El \(R^2 = 0.7897\): el modelo explica el 79.0% de la variabilidad del precio, mejor ajuste que en las casas del norte (66.5%). La razón es que los apartamentos son un producto mucho más estandarizado: dentro de un mismo conjunto residencial las unidades se parecen entre sí, mientras que las casas varían en lote, diseño y antigüedad de forma que las cinco variables no capturan. Además, 5 de las cinco variables resultan significativas al 5% (en el Modelo 1 fueron 3).
El error residual es de $88 millones sobre un precio mediano de $245 millones (36% del precio mediano, frente al 40% en el Modelo 1). Las vías de mejora son las mismas señaladas en la sección de estimación del Modelo 1, con un énfasis adicional: incorporar piso, antigüedad del edificio y administración mensual, determinantes de precio propios de la propiedad horizontal que la base no registra adecuadamente.
bp2 <- bptest(modelo2)
sw2 <- shapiro_seguro(modelo2) # muestra de 5.000 residuos si n es mayor
dw2 <- dwtest(modelo2)
data.frame(
Supuesto = c("Homocedasticidad", "Normalidad de residuos", "Independencia de residuos"),
Prueba = c("Breusch-Pagan", "Shapiro-Wilk", "Durbin-Watson"),
Estadístico = round(c(bp2$statistic, sw2$statistic, dw2$statistic), 4),
`p-valor` = format.pval(c(bp2$p.value, sw2$p.value, dw2$p.value), digits = 3, eps = 0.0001),
Conclusión = c(ifelse(bp2$p.value < 0.05, "Se rechaza — heterocedasticidad", "No se rechaza"),
ifelse(sw2$p.value < 0.05, "Se rechaza — no normalidad", "No se rechaza"),
ifelse(dw2$p.value < 0.05, "Se rechaza — hay autocorrelación", "No se rechaza")),
check.names = FALSE
) %>%
kable(caption = "Modelo 2 · Pruebas formales de supuestos (α = 0.05)") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)| Supuesto | Prueba | Estadístico | p-valor | Conclusión | |
|---|---|---|---|---|---|
| BP | Homocedasticidad | Breusch-Pagan | 852.1467 | <0.0001 | Se rechaza — heterocedasticidad |
| W | Normalidad de residuos | Shapiro-Wilk | 0.7755 | <0.0001 | Se rechaza — no normalidad |
| DW | Independencia de residuos | Durbin-Watson | 1.7095 | <0.0001 | Se rechaza — hay autocorrelación |
vif2 <- vif(modelo2)
kable(round(as.data.frame(vif2), 3),
caption = "Modelo 2 · Factores de inflación de varianza (VIF)") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)| GVIF | Df | GVIF^(1/(2*Df)) | |
|---|---|---|---|
| areaconst | 2.185 | 1 | 1.478 |
| estrato | 1.846 | 3 | 1.108 |
| habitaciones | 1.470 | 1 | 1.212 |
| parqueaderos | 1.905 | 1 | 1.380 |
| banios | 2.750 | 1 | 1.658 |
| Supuesto | Resultado | Lectura |
|---|---|---|
| Multicolinealidad | Mayor GVIF ajustado = 1.66 | Cumple. Sin redundancia entre predictores |
| Homocedasticidad | Breusch-Pagan p < 0.001 | No cumple. La varianza del error no es constante (abanico en Residuals vs Fitted) |
| Normalidad | Shapiro-Wilk p < 0.001 | No cumple. Colas pesadas en el Q-Q plot, provocadas por los apartamentos de mayor precio |
| Linealidad | Lectura visual de Residuals vs Fitted | Nube centrada en cero sin curvatura sistemática = forma lineal adecuada en el rango observado |
| Independencia | Durbin-Watson p < 0.001 | Formalmente se rechaza; como en el Modelo 1, refleja factores compartidos por inmuebles del mismo conjunto o barrio, no una secuencia temporal |
El diagnóstico reproduce el patrón del Modelo 1: heterocedasticidad y residuos no normales con multicolinealidad controlada. Se identifican 144 observaciones influyentes (distancia de Cook > 4/n). Las sugerencias de corrección son las mismas de la sección de validación de supuestos del Modelo 1: errores estándar robustos, transformación logarítmica del precio y control por barrio. Se enuncian como trabajo futuro y no se aplican en esta actividad.
# ---- Perfil solicitado: apto., 300 m², 3 parq., 3 baños, 5 hab., estrato 5 o 6
solicitud2 <- bind_rows(nuevo(300, 5, 5, 3, 3), nuevo(300, 6, 5, 3, 3))
pred_conf2 <- predict(modelo2, solicitud2, interval = "confidence", level = 0.95)
pred_pred2 <- predict(modelo2, solicitud2, interval = "prediction", level = 0.95)
data.frame(
Escenario = c("Estrato 5", "Estrato 6"),
`Precio estimado (M)` = round(pred_conf2[, "fit"], 1),
`IC 95% del precio medio` = sprintf("[%.1f ; %.1f]", pred_conf2[, "lwr"], pred_conf2[, "upr"]),
`IP 95% de un apto. individual` = sprintf("[%.1f ; %.1f]", pred_pred2[, "lwr"], pred_pred2[, "upr"]),
`Crédito (M)` = 850,
`Margen (M)` = round(850 - pred_conf2[, "fit"], 1),
check.names = FALSE
) %>%
kable(caption = "Solicitud 2 · Estimación del precio del apartamento en la Zona Sur") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE) %>%
row_spec(1, background = ifelse(pred_conf2[1, "fit"] <= 850, "#eaf7ee", "#fdecea")) %>%
row_spec(2, background = ifelse(pred_conf2[2, "fit"] <= 850, "#eaf7ee", "#fdecea"))| Escenario | Precio estimado (M) | IC 95% del precio medio | IP 95% de un apto. individual | Crédito (M) | Margen (M) |
|---|---|---|---|---|---|
| Estrato 5 | 649.6 | [629.2 ; 670.0] | [476.6 ; 822.6] | 850 | 200.4 |
| Estrato 6 | 802.8 | [782.2 ; 823.5] | [629.8 ; 975.9] | 850 | 47.2 |
Estrato 5. Precio esperado de $649.6 millones (IC 95%: $629.2 – $670.0). El crédito de $850 millones deja un margen de $200 millones, holgura suficiente para absorber gastos notariales, impuesto de registro y una eventual sobreoferta competitiva.
Estrato 6. Precio esperado de $802.8 millones (IC 95%: $782.2 – $823.5). También cabe en el crédito, pero el margen se reduce a $47 millones y el intervalo de predicción individual llega hasta $976 millones: un apartamento concreto de ese perfil sí podría superar el presupuesto.
Advertencia sobre el soporte de los datos. A diferencia de la Solicitud 1, aquí el perfil está en el borde del rango observado: solo 31 de los 2777 apartamentos alcanzan 300 m². El modelo estima sobre un tramo con muy pocas observaciones, de modo que la estimación conserva validez como referencia pero con menor precisión efectiva que la que sugiere el intervalo. La recomendación práctica es tratarla como techo de negociación y contrastarla con los inmuebles reales del listado siguiente.
CREDITO2 <- 850
base2_val <- base2 %>%
mutate(valor_estimado = predict(modelo2, .),
residual = preciom - valor_estimado,
descuento_pct = 100 * residual / valor_estimado)
# Misma escalera que en la Solicitud 1, con el perfil literal del cliente
# (300 m², 5 hab., 3 baños, 3 parq., estrato 5-6) como primer peldaño.
filtra2 <- function(d, area_min, parq_min) d %>%
filter(preciom <= CREDITO2,
areaconst >= area_min,
habitaciones >= 5, # requisito del cliente, no se relaja
banios >= 3, # requisito del cliente, no se relaja
parqueaderos >= parq_min,
estrato %in% c(5, 6)) %>% # estratos solicitados, no se relajan
arrange(residual)
escalera2 <- list(
list(area = 300, parq = 3, etiqueta = "perfil literal (300 m², 3 parqueaderos)"),
list(area = 200, parq = 3, etiqueta = "área desde 200 m²"),
list(area = 200, parq = 2, etiqueta = "área desde 200 m² y 2 parqueaderos"))
for (e in escalera2)
cat(sprintf("%-42s -> %d candidatas\n", e$etiqueta,
nrow(filtra2(base2_val, e$area, e$parq))))## perfil literal (300 m², 3 parqueaderos) -> 2 candidatas
## área desde 200 m² -> 3 candidatas
## área desde 200 m² y 2 parqueaderos -> 8 candidatas
candidatas2_lit <- filtra2(base2_val, 300, 3)
if (nrow(candidatas2_lit) > 0) {
candidatas2_lit %>%
transmute(Barrio = titulo(barrio), Estrato = estrato, `Área (m²)` = areaconst,
Hab. = habitaciones, Baños = banios, Parq. = parqueaderos,
`Precio oferta (M)` = preciom, `Valor estimado (M)` = round(valor_estimado, 0)) %>%
kable(caption = "Solicitud 2 · Apartamentos que cumplen el perfil literal de 300 m²") %>%
kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)
}| Barrio | Estrato | Área (m²) | Hab. | Baños | Parq. | Precio oferta (M) | Valor estimado (M) |
|---|---|---|---|---|---|---|---|
| Guadalupe | 5 | 573 | 5 | 8 | 3 | 730 | 1206 |
| Seminario | 5 | 300 | 6 | 5 | 3 | 670 | 716 |
sel2 <- aplica_escalera(base2_val, filtra2, escalera2)
candidatas2 <- sel2$candidatas; CRITERIO2 <- sel2$criterio
cat("Criterio usado:", CRITERIO2, "->", nrow(candidatas2), "apartamentos candidatos\n")## Criterio usado: área desde 200 m² y 2 parqueaderos -> 8 apartamentos candidatos
ofertas2 <- candidatas2 %>% slice_head(n = 6)
ofertas2 %>%
transmute(Barrio = titulo(barrio), Estrato = estrato, `Área (m²)` = areaconst,
Hab. = habitaciones, Baños = banios, Parq. = parqueaderos,
`Precio oferta (M)` = preciom,
`Valor estimado (M)` = round(valor_estimado, 0),
`Dif. (M)` = round(residual, 0),
`Descuento` = sprintf("%.0f%%", -descuento_pct)) %>%
kable(caption = paste0("Solicitud 2 · ", nrow(ofertas2),
" ofertas recomendadas, ordenadas por mayor descuento frente al valor estimado")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE)| Barrio | Estrato | Área (m²) | Hab. | Baños | Parq. | Precio oferta (M) | Valor estimado (M) | Dif. (M) | Descuento |
|---|---|---|---|---|---|---|---|---|---|
| Guadalupe | 5 | 573 | 5 | 8 | 3 | 730 | 1206 | -476 | 39% |
| El Ingenio | 5 | 600 | 5 | 4 | 2 | 650 | 1021 | -371 | 36% |
| San Fernando | 5 | 258 | 5 | 4 | 2 | 350 | 571 | -221 | 39% |
| Seminario | 5 | 256 | 5 | 5 | 3 | 530 | 671 | -141 | 21% |
| Cuarto de Legua | 5 | 220 | 5 | 5 | 2 | 420 | 561 | -141 | 25% |
| Seminario | 5 | 300 | 6 | 5 | 3 | 670 | 716 | -46 | 6% |
plot_ly() %>%
add_trace(data = candidatas2, lat = ~latitud, lon = ~longitud,
type = "scattermapbox", mode = "markers", name = "Otras candidatas",
marker = list(size = 8, color = "#b9c6d2", opacity = 0.7),
hoverinfo = "text",
text = ~paste0(barrio, " · ", areaconst, " m² · $", preciom, " M")) %>%
add_trace(data = ofertas2, lat = ~latitud, lon = ~longitud,
type = "scattermapbox", mode = "markers",
name = paste("Top", nrow(ofertas2), "recomendadas"),
marker = list(size = 16, color = "#1e6b39", opacity = 0.95),
hoverinfo = "text",
text = ~paste0("<b>", barrio, "</b><br>Estrato ", estrato, " · ",
areaconst, " m²<br>", habitaciones, " hab · ", banios, " baños",
" · ", parqueaderos, " parq.",
"<br><b>Oferta: $", preciom, " M</b>",
"<br>Valor estimado: $", round(valor_estimado), " M")) %>%
layout(title = "Solicitud 2 · Ofertas potenciales de apartamento en la Zona Sur",
mapbox = list(style = "open-street-map", zoom = 11.5,
center = list(lat = 3.37, lon = -76.53)),
legend = list(orientation = "h", y = -0.05))El perfil literal del cliente (300 m², 5 habitaciones, 3 baños, 3 parqueaderos, estrato 5 o 6) deja solo 2 opciones, insuficientes para las cinco ofertas que exige el enunciado. Bajando la escalera de relajación, el criterio que sí las alcanza es área desde 200 m² y 2 parqueaderos; habitaciones, baños y estrato se mantienen en lo solicitado. Se obtienen 8 apartamentos que cumplen las condiciones dentro del crédito de $850 millones. La oferta se concentra en El Ingenio, Seminario, Ciudadela Pasoancho.
Puntos para la conversación con el cliente:
# Un coeficiente se marca "(n.s.)" si su p-valor es >= 0.05; la lista de variables
# significativas se arma desde los p-valores, no a mano.
coef_ns <- function(coef, p) paste0(round(coef, 1), ifelse(p < 0.05, "", " (n.s.)"))
signif_lista <- function(p) {
p <- p[names(p) != "(Intercept)"]
etiquetas <- c(areaconst = "área", estrato4 = "estrato", estrato5 = "estrato",
estrato6 = "estrato", habitaciones = "habitaciones",
parqueaderos = "parqueaderos", banios = "baños")
sig <- unique(unname(etiquetas[names(p)[p < 0.05]]))
if (length(sig) == 0) "Ninguna" else if (length(sig) == 5) "Todas" else paste(sig, collapse = ", ")
}
comp <- data.frame(
Aspecto = c("Mercado", "Observaciones", "R²", "R² ajustado",
"Error residual (M)", "Precio mediano (M)", "Área mediana (m²)",
"Valor del m² (M)", "Efecto estrato 6 vs. 3 (M)",
"Efecto por baño (M)", "Efecto por parqueadero (M)",
"Efecto por habitación (M)", "Variables significativas (5%)"),
`Modelo 1 · Casas Zona Norte` = c(
"Casas · Zona Norte", nrow(base1), round(g1$r.squared, 4), round(g1$adj.r.squared, 4),
round(g1$sigma, 1), median(base1$preciom), median(base1$areaconst),
round(b1c["areaconst"], 3), round(b1c["estrato6"], 1),
coef_ns(b1c["banios"], p1c["banios"]),
coef_ns(b1c["parqueaderos"], p1c["parqueaderos"]),
coef_ns(b1c["habitaciones"], p1c["habitaciones"]),
signif_lista(p1c)),
`Modelo 2 · Aptos. Zona Sur` = c(
"Apartamentos · Zona Sur", nrow(base2), round(g2$r.squared, 4), round(g2$adj.r.squared, 4),
round(g2$sigma, 1), median(base2$preciom), median(base2$areaconst),
round(b2c["areaconst"], 3), round(b2c["estrato6"], 1),
coef_ns(b2c["banios"], p2c["banios"]),
coef_ns(b2c["parqueaderos"], p2c["parqueaderos"]),
coef_ns(b2c["habitaciones"], p2c["habitaciones"]),
signif_lista(p2c)),
check.names = FALSE
)
kable(comp, caption = "Comparación de los dos modelos estimados") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE) %>%
column_spec(1, bold = TRUE)| Aspecto | Modelo 1 · Casas Zona Norte | Modelo 2 · Aptos. Zona Sur |
|---|---|---|
| Mercado | Casas · Zona Norte | Apartamentos · Zona Sur |
| Observaciones | 700 | 2777 |
| R² | 0.665 | 0.7897 |
| R² ajustado | 0.6616 | 0.7892 |
| Error residual (M) | 155.8 | 87.6 |
| Precio mediano (M) | 390 | 245 |
| Área mediana (m²) | 240 | 85 |
| Valor del m² (M) | 0.777 | 1.314 |
| Efecto estrato 6 vs. 3 (M) | 323.3 | 207 |
| Efecto por baño (M) | 28.2 | 39.6 |
| Efecto por parqueadero (M) | 9.8 (n.s.) | 62.8 |
| Efecto por habitación (M) | 3.8 (n.s.) | -12.7 |
| Variables significativas (5%) | área, estrato, baños | Todas |
Qué muestra la comparación. Los dos mercados obedecen a lógicas de precio distintas y eso valida la decisión metodológica del Anexo 1:
Un modelo único para toda la ciudad habría promediado estas dos estructuras y produciría, en ambos casos, valoraciones sesgadas.
La Solicitud 1 es viable solo en estrato 4. Con $350 millones se compra una casa de 200 m², 4 habitaciones y 2 baños en la Zona Norte si es estrato 4 ($322 M estimados); en estrato 5 el precio esperado ($383 M) excede el crédito y todo el intervalo de confianza queda por fuera del presupuesto.
La Solicitud 2 es viable en ambos estratos, con precios estimados de $650 M (estrato 5) y $803 M (estrato 6) frente a un crédito de $850 millones. La restricción efectiva es la escasez de apartamentos de 300 m² en la Zona Sur (1.1% de la oferta).
El precio se forma de manera distinta en cada mercado. El área y el estrato mandan en ambos, pero el parqueadero solo tiene valor en apartamentos y las habitaciones no crean valor en ninguno de los dos —lo penalizan en el sur—. Es información directamente accionable para el equipo comercial de C&A al armar y presentar ofertas.
Los modelos cumplen su propósito con límites conocidos. Explican el 67% y el 79% de la variación de precios; las pruebas de supuestos muestran heterocedasticidad y residuos no normales (Modelo 1) y heterocedasticidad y residuos no normales (Modelo 2), por lo que sus intervalos son aproximados. Sirven para enmarcar una negociación, no para reemplazar el avalúo profesional del inmueble seleccionado.
La base tiene problemas de georreferenciación que conviene reportar al proveedor. Alrededor del 17% de las ofertas del norte y el 4% de las del sur tienen coordenadas inconsistentes con su zona declarada. No afecta las estimaciones —el modelo no usa coordenadas— pero sí la confiabilidad de cualquier mapa que se muestre al cliente.
Fuente de datos: vivienda,
paqueteMODELOS (Centro MAGIS) — ofertas de vivienda en Cali de los
últimos tres meses. Caso: adaptado de Weiers, Ronald
M., Introducción a la estadística para Negocios, 2006.
Herramientas: R 4.5.2 · RMarkdown · plotly · lmtest ·
car. Documento generado el 07/09/2026.