Una compañía internacional solicitó a María, propietaria de C&A, apoyo para seleccionar dos viviendas en Cali para reubicar a dos empleados y sus familias. Las solicitudes son:
| Característica | Vivienda 1 | Vivienda 2 |
|---|---|---|
| Tipo | Casa | Apartamento |
| Área construida | 200 m² | 300 m² |
| Parqueaderos | 1 | 3 |
| Baños | 2 | 3 |
| Habitaciones | 4 | 5 |
| Estrato | 4 o 5 | 5 o 6 |
| Zona | Norte | Sur |
| Crédito preaprobado | $350 millones | $850 millones |
El objetivo del análisis es estimar el precio esperado de cada vivienda mediante regresión lineal múltiple, validar el comportamiento de los modelos, predecir el precio de las viviendas solicitadas y seleccionar ofertas potenciales que cumplan las restricciones del cliente.
Se construyen dos modelos independientes:
La variable respuesta es preciom, expresada en millones
de pesos, y las variables explicativas son:
El modelo general es:
\[ \text{Precio}_i = \beta_0+ \beta_1 \text{Área}_i+ \beta_2 \text{Estrato}_i+ \beta_3 \text{Habitaciones}_i+ \beta_4 \text{Parqueaderos}_i+ \beta_5 \text{Baños}_i+ \varepsilon_i \]
Como estrato es categórica, se representa mediante
variables indicadoras tomando una categoría como referencia. El análisis
sigue esta secuencia:
Los resultados numéricos definitivos se generan automáticamente
después de estimar los modelos y calcular las predicciones. El informe
utiliza como fuente de verdad los objetos modelo1,
modelo2, pred1 y pred2, y
presenta sus resultados en las secciones correspondientes y en el
Resumen ejecutivo final.
La lectura ejecutiva se centra en cuatro preguntas para cada vivienda:
Esta estructura permite que el informe siga siendo reproducible si cambian los datos o la especificación del modelo.
La estimación puntual se compara con el crédito preaprobado de $350 millones. Sin embargo, la decisión no debe basarse únicamente en el valor puntual, si el límite superior del intervalo de predicción supera el presupuesto, el intervalo incluye valores de precio superiores al crédito disponible. Esto no implica que el precio real vaya a superar el crédito, indica que el procedimiento de predicción no permite descartarlo.
Se aplica el mismo criterio a la vivienda de 300 m², estrato 5, 5 habitaciones, 3 parqueaderos y 3 baños, con presupuesto máximo de $850 millones. Además de la estimación y su intervalo, se informa si fue necesario relajar alguno de los requisitos para encontrar cinco alternativas de mercado.
# Ejecutar si los paquetes no están instalados:
# install.packages(c(
# "devtools", "tidyverse", "plotly", "leaflet", "GGally",
# "car", "lmtest", "nortest", "kableExtra", "broom", "spdep", "sandwich"
# ))
# Ejecutar si el paquete institucional no está instalado:
# devtools::install_github("centromagis/paqueteMODELOS", force = TRUE)
library(paqueteMODELOS)
library(tidyverse)
library(plotly)
library(leaflet)
library(GGally)
library(car)
library(lmtest)
library(sandwich)
library(nortest)
library(kableExtra)
library(broom)
library(spdep)
data("vivienda")
glimpse(vivienda)
## Rows: 8,322
## Columns: 13
## $ id <dbl> 1147, 1169, 1350, 5992, 1212, 1724, 2326, 4386, 1209, 159…
## $ zona <chr> "Zona Oriente", "Zona Oriente", "Zona Oriente", "Zona Sur…
## $ piso <chr> NA, NA, NA, "02", "01", "01", "01", "01", "02", "02", "02…
## $ estrato <dbl> 3, 3, 3, 4, 5, 5, 4, 5, 5, 5, 6, 4, 5, 6, 4, 5, 5, 4, 5, …
## $ preciom <dbl> 250, 320, 350, 400, 260, 240, 220, 310, 320, 780, 750, 62…
## $ areaconst <dbl> 70, 120, 220, 280, 90, 87, 52, 137, 150, 380, 445, 355, 2…
## $ parqueaderos <dbl> 1, 1, 2, 3, 1, 1, 2, 2, 2, 2, NA, 3, 2, 2, 1, 4, 2, 2, 2,…
## $ banios <dbl> 3, 2, 2, 5, 2, 3, 2, 3, 4, 3, 7, 5, 6, 2, 4, 4, 4, 3, 2, …
## $ habitaciones <dbl> 6, 3, 4, 3, 3, 3, 3, 4, 6, 3, 6, 5, 6, 2, 5, 5, 4, 3, 3, …
## $ tipo <chr> "Casa", "Casa", "Casa", "Casa", "Apartamento", "Apartamen…
## $ barrio <chr> "20 de julio", "20 de julio", "20 de julio", "3 de julio"…
## $ longitud <dbl> -76.51168, -76.51237, -76.51537, -76.54000, -76.51350, -7…
## $ latitud <dbl> 3.43382, 3.43369, 3.43566, 3.43500, 3.45891, 3.36971, 3.4…
dim(vivienda)
## [1] 8322 13
summary(vivienda)
## id zona piso estrato
## Min. : 1 Length :8322 Length :8322 Min. :3.000
## 1st Qu.:2080 N.unique : 5 N.unique : 12 1st Qu.:4.000
## Median :4160 N.blank : 0 N.blank : 0 Median :5.000
## Mean :4160 Min.nchar: 8 Min.nchar: 2 Mean :4.634
## 3rd Qu.:6240 Max.nchar: 12 Max.nchar: 2 3rd Qu.:5.000
## Max. :8319 NAs : 3 NAs :2638 Max. :6.000
## NAs :3 NAs :3
## preciom areaconst parqueaderos banios
## Min. : 58.0 Min. : 30.0 Min. : 1.000 Min. : 0.000
## 1st Qu.: 220.0 1st Qu.: 80.0 1st Qu.: 1.000 1st Qu.: 2.000
## Median : 330.0 Median : 123.0 Median : 2.000 Median : 3.000
## Mean : 433.9 Mean : 174.9 Mean : 1.835 Mean : 3.111
## 3rd Qu.: 540.0 3rd Qu.: 229.0 3rd Qu.: 2.000 3rd Qu.: 4.000
## Max. :1999.0 Max. :1745.0 Max. :10.000 Max. :10.000
## NAs :2 NAs :3 NAs :1605 NAs :3
## habitaciones tipo barrio longitud
## Min. : 0.000 Length :8322 Length :8322 Min. :-76.59
## 1st Qu.: 3.000 N.unique : 2 N.unique : 436 1st Qu.:-76.54
## Median : 3.000 N.blank : 0 N.blank : 0 Median :-76.53
## Mean : 3.605 Min.nchar: 4 Min.nchar: 4 Mean :-76.53
## 3rd Qu.: 4.000 Max.nchar: 11 Max.nchar: 29 3rd Qu.:-76.52
## Max. :10.000 NAs : 3 NAs : 3 Max. :-76.46
## NAs :3 NAs :3
## latitud
## Min. :3.333
## 1st Qu.:3.381
## Median :3.416
## Mean :3.418
## 3rd Qu.:3.452
## Max. :3.498
## NAs :3
colSums(is.na(vivienda))
## id zona piso estrato preciom areaconst
## 3 3 2638 3 2 3
## parqueaderos banios habitaciones tipo barrio longitud
## 1605 3 3 3 3 3
## latitud
## 3
La base de 8322 inmuebles presenta una marcada asimetría positiva en precio (media 434 M vs mediana 330 M) y en área construida (media 175 m² vs mediana 123 m²), con máximos de 1999 M y 1745 m², lo que evidencia la presencia de propiedades de lujo. Los estratos (3 a 6, media 4.63) y las medianas de habitaciones y baños (3 cada una) muestran viviendas de nivel medio-alto. Las coordenadas geográficas confirman que los datos corresponden a Cali.
El diagnóstico de faltantes se concentra en piso (2638
NAs, 31.7%) y parqueaderos (1605 NAs, 19.3%). Dado que
piso no se usa como predictor, su alta tasa de ausencia no
requiere tratamiento; los faltantes en parqueaderos se
abordan mediante eliminación por casos completos. El resto de variables
presentan 3 NAs, correspondientes a filas corruptas que se depuran. Este
perfil de ausencias permite construir muestras limpias para los modelos
de regresión.
vivienda <- vivienda %>%
distinct() %>%
mutate(
zona = factor(zona),
piso = factor(piso),
estrato = factor(estrato),
tipo = factor(tipo),
barrio = factor(barrio)
)
# La referencia se fija explícitamente para que las ecuaciones sean reproducibles.
vivienda$estrato <- relevel(vivienda$estrato, ref = "3")
str(vivienda)
## tibble [8,321 × 13] (S3: tbl_df/tbl/data.frame)
## $ id : num [1:8321] 1147 1169 1350 5992 1212 ...
## $ zona : Factor w/ 5 levels "Zona Centro",..: 4 4 4 5 2 2 2 2 2 2 ...
## $ piso : Factor w/ 12 levels "01","02","03",..: NA NA NA 2 1 1 1 1 2 2 ...
## $ estrato : Factor w/ 4 levels "3","4","5","6": 1 1 1 2 3 3 2 3 3 3 ...
## $ preciom : num [1:8321] 250 320 350 400 260 240 220 310 320 780 ...
## $ areaconst : num [1:8321] 70 120 220 280 90 87 52 137 150 380 ...
## $ parqueaderos: num [1:8321] 1 1 2 3 1 1 2 2 2 2 ...
## $ banios : num [1:8321] 3 2 2 5 2 3 2 3 4 3 ...
## $ habitaciones: num [1:8321] 6 3 4 3 3 3 3 4 6 3 ...
## $ tipo : Factor w/ 2 levels "Apartamento",..: 2 2 2 2 1 1 1 1 2 2 ...
## $ barrio : Factor w/ 436 levels "20 de julio",..: 1 1 1 2 3 3 3 3 3 3 ...
## $ longitud : num [1:8321] -76.5 -76.5 -76.5 -76.5 -76.5 ...
## $ latitud : num [1:8321] 3.43 3.43 3.44 3.44 3.46 ...
La conversión de variables categóricas a factores reduce el conjunto
de datos a 8321 registros (se eliminó una fila duplicada) y define las
estructuras necesarias para el modelado: 5 zonas, 2 tipos de propiedad
(Apartamento y Casa), 4 niveles de estrato y 436 barrios distintos. La
variable piso se tipifica con 12 niveles categóricos,
aunque su alta tasa de valores faltantes impide su uso como predictor en
los modelos. Finalmente, se fija la categoría de referencia de
estrato en el nivel 3, lo que garantiza que las ecuaciones
estimadas y los coeficientes sean reproducibles e interpretables como
diferencias respecto a ese estrato base.
Primero se documenta la presencia de datos faltantes. No se deben eliminar registros antes de conocer dónde se concentran.
faltantes <- vivienda %>%
summarise(
precio = sum(is.na(preciom)),
area = sum(is.na(areaconst)),
estrato = sum(is.na(estrato)),
habitaciones = sum(is.na(habitaciones)),
parqueaderos = sum(is.na(parqueaderos)),
banios = sum(is.na(banios)),
zona = sum(is.na(zona)),
tipo = sum(is.na(tipo)),
barrio = sum(is.na(barrio)),
longitud = sum(is.na(longitud)),
latitud = sum(is.na(latitud))
)
faltantes %>%
pivot_longer(everything(), names_to = "variable", values_to = "NA") %>%
kbl(caption = "Tabla 1. Valores faltantes por variable") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| variable | NA |
|---|---|
| precio | 1 |
| area | 2 |
| estrato | 2 |
| habitaciones | 2 |
| parqueaderos | 1604 |
| banios | 2 |
| zona | 2 |
| tipo | 2 |
| barrio | 2 |
| longitud | 2 |
| latitud | 2 |
# Interpretación generada a partir del objeto `faltantes` ya calculado
# arriba, para que los valores citados coincidan siempre con la Tabla 1.
var_max_na <- names(faltantes)[which.max(as.numeric(faltantes[1, ]))]
n_max_na <- max(as.numeric(faltantes[1, ]))
cat(sprintf(
"**Tabla 1.** La variable con más valores faltantes es `%s`, con %d registros sin dato (%.1f%% de la base), muy por encima del resto de variables, cuyo máximo de faltantes no relacionados con `%s` no supera unas pocas unidades. Esta concentración de faltantes en una sola variable es la que justifica el tratamiento diferenciado que se describe a continuación, en vez de una eliminación uniforme de casos incompletos.\n\n",
var_max_na, n_max_na, 100 * n_max_na / nrow(vivienda), var_max_na
))
Tabla 1. La variable con más valores faltantes es
parqueaderos, con 1604 registros sin dato (19.3% de la
base), muy por encima del resto de variables, cuyo máximo de faltantes
no relacionados con parqueaderos no supera unas pocas
unidades. Esta concentración de faltantes en una sola variable es la que
justifica el tratamiento diferenciado que se describe a continuación, en
vez de una eliminación uniforme de casos incompletos.
vivienda %>%
filter(is.na(parqueaderos)) %>%
count(tipo, zona) %>%
arrange(desc(n)) %>%
kbl(caption = "Tabla 2. Distribución de NA en parqueaderos por tipo y zona") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| tipo | zona | n |
|---|---|---|
| Apartamento | Zona Sur | 406 |
| Apartamento | Zona Norte | 346 |
| Casa | Zona Norte | 287 |
| Casa | Zona Sur | 215 |
| Casa | Zona Oriente | 148 |
| Apartamento | Zona Oeste | 63 |
| Casa | Zona Centro | 46 |
| Apartamento | Zona Oriente | 40 |
| Casa | Zona Oeste | 37 |
| Apartamento | Zona Centro | 14 |
| NA | NA | 2 |
# Interpretación generada desde la misma tabla que se acaba de imprimir,
# para citar la combinación tipo/zona con más faltantes sin escribirla a mano.
distribucion_na_parq <- vivienda %>%
filter(is.na(parqueaderos)) %>%
count(tipo, zona) %>%
arrange(desc(n))
fila_top_na <- distribucion_na_parq[1, ]
cat(sprintf(
"**Tabla 2.** La combinación con más valores faltantes en `parqueaderos` es %s en %s, con %d registros sin dato. Los faltantes no se distribuyen de forma pareja entre tipo y zona, lo que respalda tratarlos como una limitación potencial (posible sesgo de no aleatoriedad) y no como ruido uniforme del sistema de captura.\n\n",
tolower(as.character(fila_top_na$tipo)), as.character(fila_top_na$zona), fila_top_na$n
))
Tabla 2. La combinación con más valores faltantes en
parqueaderos es apartamento en Zona Sur, con 406 registros
sin dato. Los faltantes no se distribuyen de forma pareja entre tipo y
zona, lo que respalda tratarlos como una limitación potencial (posible
sesgo de no aleatoriedad) y no como ruido uniforme del sistema de
captura.
Para este análisis se utiliza eliminación por casos completos en las variables requeridas por los modelos. Esta decisión debe reconocerse como una limitación, especialmente si los faltantes no son completamente aleatorios.
vivienda_modelo <- vivienda %>%
filter(
!is.na(preciom),
!is.na(areaconst),
!is.na(estrato),
!is.na(habitaciones),
!is.na(parqueaderos),
!is.na(banios),
preciom > 0,
areaconst > 0
)
cat("Registros originales:", nrow(vivienda), "\n")
## Registros originales: 8321
cat("Registros después de depuración:", nrow(vivienda_modelo), "\n")
## Registros después de depuración: 6717
cat("Registros eliminados:", nrow(vivienda) - nrow(vivienda_modelo), "\n")
## Registros eliminados: 1604
dim(vivienda_modelo)
## [1] 6717 13
base1 <- vivienda_modelo %>%
filter(
tipo == "Casa",
zona == "Zona Norte"
)
dim(base1)
## [1] 435 13
head(base1, 3) %>%
kbl(caption = "Tabla 3. Primeros tres registros — Casas, Zona Norte") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| id | zona | piso | estrato | preciom | areaconst | parqueaderos | banios | habitaciones | tipo | barrio | longitud | latitud |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1209 | Zona Norte | 02 | 5 | 320 | 150 | 2 | 4 | 6 | Casa | acopi | -76.51341 | 3.47968 |
| 1592 | Zona Norte | 02 | 5 | 780 | 380 | 2 | 3 | 3 | Casa | acopi | -76.51674 | 3.48721 |
| 4460 | Zona Norte | 02 | 4 | 625 | 355 | 3 | 5 | 5 | Casa | acopi | -76.53179 | 3.40590 |
# Verificación explícita del filtro solicitado
base1 %>%
count(tipo, zona) %>%
kbl(caption = "Tabla 4. Verificación del filtro — Casas, Zona Norte") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| tipo | zona | n |
|---|---|---|
| Casa | Zona Norte | 435 |
cat(sprintf(
"**Tabla 3.** Muestra los tres primeros registros de `base1` tal como quedan tras el filtro `tipo == \"Casa\"` y `zona == \"Zona Norte\"`, con todas las columnas originales (identificador, características físicas, barrio y coordenadas), a modo de verificación visual de que el filtro produce registros coherentes con lo solicitado.\n\n"
))
Tabla 3. Muestra los tres primeros registros de
base1 tal como quedan tras el filtro
tipo == "Casa" y zona == "Zona Norte", con
todas las columnas originales (identificador, características físicas,
barrio y coordenadas), a modo de verificación visual de que el filtro
produce registros coherentes con lo solicitado.
cat(sprintf(
"**Tabla 4.** Confirma numéricamente el resultado del filtro: los %d registros de `base1` corresponden en su totalidad a la combinación `Casa` / `Zona Norte`, sin mezcla de otros tipos o zonas.\n\n",
nrow(base1)
))
Tabla 4. Confirma numéricamente el resultado del
filtro: los 435 registros de base1 corresponden en su
totalidad a la combinación Casa / Zona Norte,
sin mezcla de otros tipos o zonas.
base2 <- vivienda_modelo %>%
filter(
tipo == "Apartamento",
zona == "Zona Sur"
)
dim(base2)
## [1] 2381 13
head(base2, 3) %>%
kbl(caption = "Tabla 5. Primeros tres registros — Apartamentos, Zona Sur") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| id | zona | piso | estrato | preciom | areaconst | parqueaderos | banios | habitaciones | tipo | barrio | longitud | latitud |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 5098 | Zona Sur | 05 | 4 | 290 | 96 | 1 | 2 | 3 | Apartamento | acopi | -76.53464 | 3.44987 |
| 698 | Zona Sur | 02 | 3 | 78 | 40 | 1 | 1 | 2 | Apartamento | aguablanca | -76.50100 | 3.40000 |
| 8199 | Zona Sur | NA | 6 | 875 | 194 | 2 | 5 | 3 | Apartamento | aguacatal | -76.55700 | 3.45900 |
# Verificación explícita del filtro solicitado
base2 %>%
count(tipo, zona) %>%
kbl(caption = "Tabla 6. Verificación del filtro — Apartamentos, Zona Sur") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| tipo | zona | n |
|---|---|---|
| Apartamento | Zona Sur | 2381 |
cat(sprintf(
"**Tabla 5.** Muestra los tres primeros registros de `base2` tras el filtro `tipo == \"Apartamento\"` y `zona == \"Zona Sur\"`, con las mismas columnas originales que la Tabla 3, para verificar visualmente el resultado del filtro sobre la segunda solicitud.\n\n"
))
Tabla 5. Muestra los tres primeros registros de
base2 tras el filtro tipo == "Apartamento" y
zona == "Zona Sur", con las mismas columnas originales que
la Tabla 3, para verificar visualmente el resultado del filtro sobre la
segunda solicitud.
cat(sprintf(
"**Tabla 6.** Confirma que los %d registros de `base2` corresponden en su totalidad a la combinación `Apartamento` / `Zona Sur`, sin registros de otro tipo o zona. Esta base es considerablemente más grande que `base1` (%d registros), lo cual se retoma más adelante al comparar la precisión de ambos modelos.\n\n",
nrow(base2), nrow(base1)
))
Tabla 6. Confirma que los 2381 registros de
base2 corresponden en su totalidad a la combinación
Apartamento / Zona Sur, sin registros de otro
tipo o zona. Esta base es considerablemente más grande que
base1 (435 registros), lo cual se retoma más adelante al
comparar la precisión de ambos modelos.
leaflet(base1) %>%
addTiles() %>%
addCircleMarkers(
lng = ~longitud,
lat = ~latitud,
radius = 4,
color = "darkred",
fillColor = "darkred",
popup = ~paste0(
"Barrio: ", barrio,
"<br>Precio: $", preciom, " M"
),
fillOpacity = 0.7
) %>%
addLegend(
position = "bottomright",
colors = "darkred",
labels = "Casas — Zona Norte",
title = "Tipo de vivienda"
)
cat(sprintf(
"**Fig. 1.** Ubicación geográfica de los %d registros de `base1` (casas, Zona Norte). Cada punto rojo representa una vivienda; al hacer clic se muestra su barrio y precio. Sirve como verificación visual previa al chequeo cuantitativo de zona que se realiza más adelante.\n\n",
nrow(base1)
))
Fig. 1. Ubicación geográfica de los 435 registros de
base1 (casas, Zona Norte). Cada punto rojo representa una
vivienda; al hacer clic se muestra su barrio y precio. Sirve como
verificación visual previa al chequeo cuantitativo de zona que se
realiza más adelante.
leaflet(base2) %>%
addTiles() %>%
addCircleMarkers(
lng = ~longitud,
lat = ~latitud,
radius = 4,
color = "darkgreen",
fillColor = "darkgreen",
popup = ~paste0(
"Barrio: ", barrio,
"<br>Precio: $", preciom, " M"
),
fillOpacity = 0.7
) %>%
addLegend(
position = "bottomright",
colors = "darkgreen",
labels = "Apartamentos — Zona Sur",
title = "Tipo de vivienda"
)
cat(sprintf(
"**Fig. 2.** Ubicación geográfica de los %d registros de `base2` (apartamentos, Zona Sur). Cada punto verde representa una vivienda con su barrio y precio disponibles al pasar el cursor. Al comparar visualmente con la Fig. 1, se observa una nube de puntos considerablemente más densa, consistente con que `base2` tiene más de %dx el número de observaciones de `base1`.\n\n",
nrow(base2), round(nrow(base2) / nrow(base1))
))
Fig. 2. Ubicación geográfica de los 2381 registros
de base2 (apartamentos, Zona Sur). Cada punto verde
representa una vivienda con su barrio y precio disponibles al pasar el
cursor. Al comparar visualmente con la Fig. 1, se observa una nube de
puntos considerablemente más densa, consistente con que
base2 tiene más de 5x el número de observaciones de
base1.
El criterio de valores atípicos geográficos no permite concluir por sí solo que una vivienda esté fuera de la zona. Sirve para identificar observaciones que requieren revisión.
verificar_geo <- function(base) {
base %>%
mutate(
out_lon = abs(longitud - median(longitud, na.rm = TRUE)) >
3 * IQR(longitud, na.rm = TRUE),
out_lat = abs(latitud - median(latitud, na.rm = TRUE)) >
3 * IQR(latitud, na.rm = TRUE)
) %>%
filter(out_lon | out_lat)
}
out_geo1 <- verificar_geo(base1)
out_geo2 <- verificar_geo(base2)
nrow(out_geo1)
## [1] 16
nrow(out_geo2)
## [1] 32
El criterio utilizado identifica coordenadas potencialmente extremas respecto de la distribución espacial de cada muestra. No equivale por sí mismo a demostrar que una vivienda pertenece a otra zona, por lo que las observaciones detectadas no se eliminan automáticamente.
cat(sprintf(
"**Casas — Zona Norte:** se identificaron %d observaciones con coordenadas potencialmente atípicas según el criterio estadístico utilizado.
",
nrow(out_geo1)
))
Casas — Zona Norte: se identificaron 16 observaciones con coordenadas potencialmente atípicas según el criterio estadístico utilizado.
cat(sprintf(
"**Apartamentos — Zona Sur:** se identificaron %d observaciones con coordenadas potencialmente atípicas según el criterio estadístico utilizado.
",
nrow(out_geo2)
))
Apartamentos — Zona Sur: se identificaron 32 observaciones con coordenadas potencialmente atípicas según el criterio estadístico utilizado.
cat("Este criterio solo detecta rarezas *dentro* de la propia muestra, no compara contra las demás zonas. Un chequeo que sí permite afirmar (o descartar) que un registro pertenece geográficamente a otra zona se hace en la sección siguiente.\n\n")
Este criterio solo detecta rarezas dentro de la propia muestra, no compara contra las demás zonas. Un chequeo que sí permite afirmar (o descartar) que un registro pertenece geográficamente a otra zona se hace en la sección siguiente.
A diferencia del chequeo anterior, aquí sí se compara cada vivienda
contra todas las zonas de la ciudad, no solo contra su
propia muestra. Se calcula el centroide (mediana de longitud/latitud) de
cada una de las cinco zonas usando la base completa, y se determina si
el centroide más cercano a cada vivienda de
base1/base2 coincide con su zona
declarada.
centroides_zona <- vivienda_modelo %>%
filter(!is.na(longitud), !is.na(latitud)) %>%
group_by(zona) %>%
summarise(
lon_centroide = median(longitud, na.rm = TRUE),
lat_centroide = median(latitud, na.rm = TRUE),
.groups = "drop"
)
centroides_zona %>%
kbl(digits = 5, caption = "Tabla 7. Centroide de referencia por zona (base completa)") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| zona | lon_centroide | lat_centroide |
|---|---|---|
| Zona Centro | -76.52733 | 3.43884 |
| Zona Norte | -76.52100 | 3.47561 |
| Zona Oeste | -76.54870 | 3.44900 |
| Zona Oriente | -76.50733 | 3.44000 |
| Zona Sur | -76.53198 | 3.38400 |
zona_mas_norte <- centroides_zona$zona[which.max(centroides_zona$lat_centroide)]
zona_mas_sur <- centroides_zona$zona[which.min(centroides_zona$lat_centroide)]
cat(sprintf(
"**Tabla 7.** Reporta la longitud y latitud medianas de cada una de las %d zonas, calculadas sobre la base completa (`vivienda_modelo`) antes de filtrar por Vivienda 1 o Vivienda 2. Estos cinco puntos son la referencia contra la que se compara cada vivienda individual en la verificación cruzada siguiente. Por su posición, `%s` es la zona con centroide más al norte y `%s` la de centroide más al sur, lo cual es consistente con la disposición longitudinal de la ciudad de Cali.\n\n",
nrow(centroides_zona), zona_mas_norte, zona_mas_sur
))
Tabla 7. Reporta la longitud y latitud medianas de
cada una de las 5 zonas, calculadas sobre la base completa
(vivienda_modelo) antes de filtrar por Vivienda 1 o
Vivienda 2. Estos cinco puntos son la referencia contra la que se
compara cada vivienda individual en la verificación cruzada siguiente.
Por su posición, Zona Norte es la zona con centroide más al
norte y Zona Sur la de centroide más al sur, lo cual es
consistente con la disposición longitudinal de la ciudad de Cali.
# Distancia euclidiana simple en grados (suficiente como diagnóstico a esta
# escala; no se requiere precisión geodésica para identificar la zona más
# cercana entre 5 centroides bien separados dentro de una misma ciudad).
zona_mas_cercana <- function(lon, lat) {
d <- sqrt((lon - centroides_zona$lon_centroide)^2 + (lat - centroides_zona$lat_centroide)^2)
as.character(centroides_zona$zona[which.min(d)])
}
verificar_zona_real <- function(base, zona_declarada) {
base %>%
filter(!is.na(longitud), !is.na(latitud)) %>%
rowwise() %>%
mutate(zona_mas_cercana = zona_mas_cercana(longitud, latitud)) %>%
ungroup() %>%
mutate(coincide = zona_mas_cercana == zona_declarada)
}
chequeo_zona1 <- verificar_zona_real(base1, "Zona Norte")
chequeo_zona2 <- verificar_zona_real(base2, "Zona Sur")
n_incons1 <- sum(!chequeo_zona1$coincide)
n_incons2 <- sum(!chequeo_zona2$coincide)
cat("Casas 'Zona Norte' cuyo centroide más cercano es otra zona:", n_incons1, "de", nrow(chequeo_zona1), "\n")
## Casas 'Zona Norte' cuyo centroide más cercano es otra zona: 109 de 435
cat("Apartamentos 'Zona Sur' cuyo centroide más cercano es otra zona:", n_incons2, "de", nrow(chequeo_zona2), "\n")
## Apartamentos 'Zona Sur' cuyo centroide más cercano es otra zona: 462 de 2381
if (n_incons1 > 0) {
chequeo_zona1 %>%
filter(!coincide) %>%
count(zona_mas_cercana, name = "n_registros") %>%
arrange(desc(n_registros)) %>%
kbl(caption = "Tabla 8. Casas 'Zona Norte' - a qué zona resultan geográficamente más cercanas") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
}
| zona_mas_cercana | n_registros |
|---|---|
| Zona Centro | 43 |
| Zona Oeste | 31 |
| Zona Oriente | 21 |
| Zona Sur | 14 |
if (n_incons2 > 0) {
chequeo_zona2 %>%
filter(!coincide) %>%
count(zona_mas_cercana, name = "n_registros") %>%
arrange(desc(n_registros)) %>%
kbl(caption = "Tabla 9. Apartamentos 'Zona Sur' — a qué zona resultan geográficamente más cercanos") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
}
| zona_mas_cercana | n_registros |
|---|---|
| Zona Centro | 254 |
| Zona Oeste | 89 |
| Zona Oriente | 70 |
| Zona Norte | 49 |
# Interpretación generada a partir de los mismos objetos que producen las
# Tablas 8 y 9, citando la zona de destino más frecuente en cada caso.
if (n_incons1 > 0) {
destino1 <- chequeo_zona1 %>% filter(!coincide) %>% count(zona_mas_cercana, name = "n_registros") %>% arrange(desc(n_registros))
cat(sprintf(
"**Tabla 8.** De las %d casas 'Zona Norte' que resultan más cercanas a otra zona, la mayoría (%d registros) queda más cerca del centroide de `%s`, seguida del resto de zonas con conteos menores. Esta tabla desagrega por destino el total ya reportado en la sección de Conclusión geográfica.\n\n",
n_incons1, destino1$n_registros[1], destino1$zona_mas_cercana[1]
))
}
Tabla 8. De las 109 casas ‘Zona Norte’ que resultan
más cercanas a otra zona, la mayoría (43 registros) queda más cerca del
centroide de Zona Centro, seguida del resto de zonas con
conteos menores. Esta tabla desagrega por destino el total ya reportado
en la sección de Conclusión geográfica.
if (n_incons2 > 0) {
destino2 <- chequeo_zona2 %>% filter(!coincide) %>% count(zona_mas_cercana, name = "n_registros") %>% arrange(desc(n_registros))
cat(sprintf(
"**Tabla 9.** De los %d apartamentos 'Zona Sur' que resultan más cercanos a otra zona, la mayoría (%d registros) queda más cerca del centroide de `%s`. Al igual que en la Tabla 8, esto no confirma un error de etiquetado por sí solo, pero identifica hacia dónde se concentran las inconsistencias detectadas.\n\n",
n_incons2, destino2$n_registros[1], destino2$zona_mas_cercana[1]
))
}
Tabla 9. De los 462 apartamentos ‘Zona Sur’ que
resultan más cercanos a otra zona, la mayoría (254 registros) queda más
cerca del centroide de Zona Centro. Al igual que en la
Tabla 8, esto no confirma un error de etiquetado por sí solo, pero
identifica hacia dónde se concentran las inconsistencias detectadas.
# Esta conclusión se redacta a partir de n_incons1 / n_incons2 calculados
# arriba — nunca se afirma de antemano que "todo está en su zona": se
# reporta lo que el chequeo cruzado realmente encontró.
if (n_incons1 == 0 && n_incons2 == 0) {
cat("Bajo el criterio de centroide más cercano, **todas** las viviendas de `base1` y `base2` resultan geográficamente más cercanas a su propia zona declarada que a cualquier otra. No se encontraron registros que, por este criterio, pertenezcan claramente a otra zona.\n\n")
} else {
cat(sprintf(
"Bajo el criterio de centroide más cercano, %d de %d casas 'Zona Norte' (%.1f%%) y %d de %d apartamentos 'Zona Sur' (%.1f%%) resultan geográficamente **más cercanos a otra zona** que a la declarada. Esto no demuestra por sí solo un error de etiquetado, el criterio de centroide único asume zonas compactas, lo cual es una simplificación en una ciudad de forma irregular, pero sí es una señal a verificar contra la dirección o el anuncio original antes de tratar esos registros como plenamente confiables.\n\n",
n_incons1, nrow(chequeo_zona1), 100 * n_incons1 / nrow(chequeo_zona1),
n_incons2, nrow(chequeo_zona2), 100 * n_incons2 / nrow(chequeo_zona2)
))
}
Bajo el criterio de centroide más cercano, 109 de 435 casas ‘Zona Norte’ (25.1%) y 462 de 2381 apartamentos ‘Zona Sur’ (19.4%) resultan geográficamente más cercanos a otra zona que a la declarada. Esto no demuestra por sí solo un error de etiquetado, el criterio de centroide único asume zonas compactas, lo cual es una simplificación en una ciudad de forma irregular, pero sí es una señal a verificar contra la dirección o el anuncio original antes de tratar esos registros como plenamente confiables.
Antes de restringir el análisis a las dos solicitudes, se compara
descriptivamente el precio por zona dentro de cada tipo de vivienda.
Esto permite contextualizar la selección de Zona Norte para casas y Zona
Sur para apartamentos sin introducir zona como predictor
dentro de modelos donde ya es constante.
base_casas <- vivienda_modelo %>% filter(tipo == "Casa")
base_aptos <- vivienda_modelo %>% filter(tipo == "Apartamento")
p_zona_casas <- ggplot(base_casas, aes(x = zona, y = preciom, fill = zona)) +
geom_boxplot() +
labs(
title = "Fig. 3. Precio por zona — Casas",
x = "Zona",
y = "Precio (millones de pesos)"
)
p_zona_aptos <- ggplot(base_aptos, aes(x = zona, y = preciom, fill = zona)) +
geom_boxplot() +
labs(
title = "Fig. 4. Precio por zona — Apartamentos",
x = "Zona",
y = "Precio (millones de pesos)"
)
ggplotly(p_zona_casas)
ggplotly(p_zona_aptos)
# Interpretación generada a partir de las medianas reales por zona, leídas
# de las mismas bases (base_casas / base_aptos) que alimentan los boxplots.
medianas_casas <- base_casas %>% group_by(zona) %>% summarise(mediana = median(preciom), .groups = "drop") %>% arrange(desc(mediana))
medianas_aptos <- base_aptos %>% group_by(zona) %>% summarise(mediana = median(preciom), .groups = "drop") %>% arrange(desc(mediana))
cat(sprintf(
"**Fig. 3.** Distribución del precio de casas por zona. La zona con mediana de precio más alta es `%s` ($%.0f M) y la más baja es `%s` ($%.0f M). `Zona Norte`, que es la base del Modelo 1, tiene una mediana de $%.0f M.\n\n",
medianas_casas$zona[1], medianas_casas$mediana[1],
medianas_casas$zona[nrow(medianas_casas)], medianas_casas$mediana[nrow(medianas_casas)],
medianas_casas$mediana[medianas_casas$zona == "Zona Norte"]
))
Fig. 3. Distribución del precio de casas por zona.
La zona con mediana de precio más alta es Zona Oeste ($696
M) y la más baja es Zona Oriente ($255 M).
Zona Norte, que es la base del Modelo 1, tiene una mediana
de $425 M.
cat(sprintf(
"**Fig. 4.** Distribución del precio de apartamentos por zona. La zona con mediana de precio más alta es `%s` ($%.0f M) y la más baja es `%s` ($%.0f M). `Zona Sur`, que es la base del Modelo 2, tiene una mediana de $%.0f M.\n\n",
medianas_aptos$zona[1], medianas_aptos$mediana[1],
medianas_aptos$zona[nrow(medianas_aptos)], medianas_aptos$mediana[nrow(medianas_aptos)],
medianas_aptos$mediana[medianas_aptos$zona == "Zona Sur"]
))
Fig. 4. Distribución del precio de apartamentos por
zona. La zona con mediana de precio más alta es Zona Oeste
($580 M) y la más baja es Zona Oriente ($113 M).
Zona Sur, que es la base del Modelo 2, tiene una mediana de
$260 M.
La comparación es únicamente descriptiva. No se interpreta como evidencia causal y no reemplaza el análisis dentro de las zonas específicas solicitadas.
base1 %>%
summarise(
n = n(),
precio_promedio = mean(preciom),
precio_mediana = median(preciom),
area_promedio = mean(areaconst),
habitaciones_promedio = mean(habitaciones),
parqueaderos_promedio = mean(parqueaderos),
banios_promedio = mean(banios)
) %>%
kbl(digits = 2, caption = "Tabla 10. Estadísticos descriptivos — Modelo 1") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| n | precio_promedio | precio_mediana | area_promedio | habitaciones_promedio | parqueaderos_promedio | banios_promedio |
|---|---|---|---|---|---|---|
| 435 | 479.77 | 425 | 292.72 | 4.81 | 2.18 | 3.78 |
base2 %>%
summarise(
n = n(),
precio_promedio = mean(preciom),
precio_mediana = median(preciom),
area_promedio = mean(areaconst),
habitaciones_promedio = mean(habitaciones),
parqueaderos_promedio = mean(parqueaderos),
banios_promedio = mean(banios)
) %>%
kbl(digits = 2, caption = "Tabla 11. Estadísticos descriptivos — Modelo 2") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| n | precio_promedio | precio_mediana | area_promedio | habitaciones_promedio | parqueaderos_promedio | banios_promedio |
|---|---|---|---|---|---|---|
| 2381 | 318.24 | 260 | 102.16 | 3.02 | 1.41 | 2.59 |
desc1 <- base1 %>% summarise(n = n(), precio_promedio = mean(preciom), precio_mediana = median(preciom), area_promedio = mean(areaconst))
desc2 <- base2 %>% summarise(n = n(), precio_promedio = mean(preciom), precio_mediana = median(preciom), area_promedio = mean(areaconst))
cat(sprintf(
"**Tabla 10.** Sobre %d casas en Zona Norte, el precio promedio ($%.0f M) supera a la mediana ($%.0f M), lo que indica asimetría positiva (algunas viviendas de precio muy alto elevan el promedio). El área construida promedio es de %.0f m².\n\n",
desc1$n, desc1$precio_promedio, desc1$precio_mediana, desc1$area_promedio
))
Tabla 10. Sobre 435 casas en Zona Norte, el precio promedio ($480 M) supera a la mediana ($425 M), lo que indica asimetría positiva (algunas viviendas de precio muy alto elevan el promedio). El área construida promedio es de 293 m².
cat(sprintf(
"**Tabla 11.** Sobre %d apartamentos en Zona Sur, el precio promedio es de $%.0f M frente a una mediana de $%.0f M, con área construida promedio de %.0f m², considerablemente menor al área promedio de las casas de la Tabla 10, lo cual es consistente con la diferencia típica de tamaño entre estos dos tipos de vivienda.\n\n",
desc2$n, desc2$precio_promedio, desc2$precio_mediana, desc2$area_promedio
))
Tabla 11. Sobre 2381 apartamentos en Zona Sur, el precio promedio es de $318 M frente a una mediana de $260 M, con área construida promedio de 102 m², considerablemente menor al área promedio de las casas de la Tabla 10, lo cual es consistente con la diferencia típica de tamaño entre estos dos tipos de vivienda.
La correlación de Pearson es:
\[ r_{XY} = \frac{ \sum_{i=1}^{n}(X_i-\bar X)(Y_i-\bar Y) }{ \sqrt{ \sum_{i=1}^{n}(X_i-\bar X)^2 \sum_{i=1}^{n}(Y_i-\bar Y)^2 } } \]
El coeficiente mide intensidad y dirección de una relación lineal entre dos variables cuantitativas. No se interpreta como causalidad.
num1 <- base1 %>%
select(preciom, areaconst, banios, habitaciones)
cor(num1, use = "complete.obs") %>%
round(2) %>%
kbl(caption = "Tabla 12. Matriz de correlación — Casas, Zona Norte") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| preciom | areaconst | banios | habitaciones | |
|---|---|---|---|---|
| preciom | 1.00 | 0.69 | 0.51 | 0.37 |
| areaconst | 0.69 | 1.00 | 0.46 | 0.42 |
| banios | 0.51 | 0.46 | 1.00 | 0.59 |
| habitaciones | 0.37 | 0.42 | 0.59 | 1.00 |
ggpairs(num1)
cor1 <- cor(num1, use = "complete.obs")
var_mas_corr1 <- names(which.max(cor1["preciom", -which(colnames(cor1) == "preciom")]))
cat(sprintf(
"**Tabla 12.** De las variables cuantitativas, `%s` es la que muestra mayor correlación lineal con `preciom` (r = %.2f), seguida por las demás en el orden que muestra la tabla. Todas las correlaciones con el precio son positivas, consistente con que a mayor área, baños u habitaciones se espera un mayor precio.\n\n",
var_mas_corr1, cor1["preciom", var_mas_corr1]
))
Tabla 12. De las variables cuantitativas,
areaconst es la que muestra mayor correlación lineal con
preciom (r = 0.69), seguida por las demás en el orden que
muestra la tabla. Todas las correlaciones con el precio son positivas,
consistente con que a mayor área, baños u habitaciones se espera un
mayor precio.
cat(sprintf(
"**Fig. 5.** Matriz de dispersión y densidades (`ggpairs`) para las mismas cuatro variables de la Tabla 12 en `base1`. Las celdas superiores repiten los coeficientes de correlación con su significancia, las inferiores muestran los diagramas de dispersión y la diagonal las distribuciones univariadas.\n\n"
))
Fig. 5. Matriz de dispersión y densidades
(ggpairs) para las mismas cuatro variables de la Tabla 12
en base1. Las celdas superiores repiten los coeficientes de
correlación con su significancia, las inferiores muestran los diagramas
de dispersión y la diagonal las distribuciones univariadas.
num2 <- base2 %>%
select(preciom, areaconst, banios, habitaciones)
cor(num2, use = "complete.obs") %>%
round(2) %>%
kbl(caption = "Tabla 13. Matriz de correlación — Apartamentos, Zona Sur") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| preciom | areaconst | banios | habitaciones | |
|---|---|---|---|---|
| preciom | 1.00 | 0.74 | 0.71 | 0.30 |
| areaconst | 0.74 | 1.00 | 0.66 | 0.41 |
| banios | 0.71 | 0.66 | 1.00 | 0.52 |
| habitaciones | 0.30 | 0.41 | 0.52 | 1.00 |
ggpairs(num2)
cor2 <- cor(num2, use = "complete.obs")
var_mas_corr2 <- names(which.max(cor2["preciom", -which(colnames(cor2) == "preciom")]))
cat(sprintf(
"**Tabla 13.** En `base2`, la variable más correlacionada con `preciom` es `%s` (r = %.2f). Comparada con la Tabla 12, la correlación de `preciom` con `%s` es %s en apartamentos que en casas, lo que anticipa un R² potencialmente distinto entre los dos modelos.\n\n",
var_mas_corr2, cor2["preciom", var_mas_corr2], var_mas_corr1,
ifelse(cor2["preciom", var_mas_corr1] > cor1["preciom", var_mas_corr1], "más alta", "más baja")
))
Tabla 13. En base2, la variable más
correlacionada con preciom es areaconst (r =
0.74). Comparada con la Tabla 12, la correlación de preciom
con areaconst es más alta en apartamentos que en casas, lo
que anticipa un R² potencialmente distinto entre los dos modelos.
cat(sprintf(
"**Fig. 6.** Matriz de dispersión y densidades para `base2`, equivalente a la Fig. 5 pero sobre apartamentos de Zona Sur.\n\n"
))
Fig. 6. Matriz de dispersión y densidades para
base2, equivalente a la Fig. 5 pero sobre apartamentos de
Zona Sur.
p_area1 <- ggplot(
base1,
aes(
x = areaconst,
y = preciom,
color = estrato,
text = paste(
"Barrio:", barrio,
"<br>Estrato:", estrato,
"<br>Precio:", preciom, "M"
)
)
) +
geom_point(alpha = 0.7) +
geom_smooth(
method = "lm",
se = FALSE,
color = "black",
linetype = "dashed"
) +
labs(
title = "Fig. 7. Precio vs. área — Casas, Zona Norte",
x = "Área construida (m²)",
y = "Precio (millones de pesos)"
)
ggplotly(
p_area1,
tooltip = c("x", "y", "colour", "text")
)
cat(sprintf(
"**Fig. 7.** Precio vs. área construida en casas de Zona Norte, coloreado por estrato. La línea punteada es la tendencia lineal simple, su pendiente positiva es consistente con la correlación de r = %.2f reportada en la Tabla 12.\n\n",
cor1["preciom", "areaconst"]
))
Fig. 7. Precio vs. área construida en casas de Zona Norte, coloreado por estrato. La línea punteada es la tendencia lineal simple, su pendiente positiva es consistente con la correlación de r = 0.69 reportada en la Tabla 12.
p_area2 <- ggplot(
base2,
aes(
x = areaconst,
y = preciom,
color = estrato,
text = paste(
"Barrio:", barrio,
"<br>Estrato:", estrato,
"<br>Precio:", preciom, "M"
)
)
) +
geom_point(alpha = 0.7) +
geom_smooth(
method = "lm",
se = FALSE,
color = "black",
linetype = "dashed"
) +
labs(
title = "Fig. 8. Precio vs. área — Apartamentos, Zona Sur",
x = "Área construida (m²)",
y = "Precio (millones de pesos)"
)
ggplotly(
p_area2,
tooltip = c("x", "y", "colour", "text")
)
cat(sprintf(
"**Fig. 8.** Precio vs. área construida en apartamentos de Zona Sur, coloreado por estrato. La correlación asociada (r = %.2f, Tabla 13) es %s que la observada para casas en la Fig. 7, lo que se refleja en una nube de puntos algo %s dispersa alrededor de la línea de tendencia.\n\n",
cor2["preciom", "areaconst"],
ifelse(cor2["preciom", "areaconst"] > cor1["preciom", "areaconst"], "más alta", "más baja"),
ifelse(cor2["preciom", "areaconst"] > cor1["preciom", "areaconst"], "menos", "más")
))
Fig. 8. Precio vs. área construida en apartamentos de Zona Sur, coloreado por estrato. La correlación asociada (r = 0.74, Tabla 13) es más alta que la observada para casas en la Fig. 7, lo que se refleja en una nube de puntos algo menos dispersa alrededor de la línea de tendencia.
Como estrato es una variable categórica, no se utiliza
la correlación de Pearson. Se compara mediante boxplots
interactivos.
p_estrato1 <- ggplot(base1, aes(x = estrato, y = preciom, fill = estrato)) +
geom_boxplot() +
labs(title = "Fig. 9. Precio por estrato — Casas, Zona Norte",
x = "Estrato", y = "Precio (millones de pesos)")
ggplotly(p_estrato1)
p_estrato2 <- ggplot(base2, aes(x = estrato, y = preciom, fill = estrato)) +
geom_boxplot() +
labs(title = "Fig. 10. Precio por estrato — Apartamentos, Zona Sur",
x = "Estrato", y = "Precio (millones de pesos)")
ggplotly(p_estrato2)
med_estrato1 <- base1 %>% group_by(estrato) %>% summarise(mediana = median(preciom), .groups = "drop") %>% arrange(estrato)
med_estrato2 <- base2 %>% group_by(estrato) %>% summarise(mediana = median(preciom), .groups = "drop") %>% arrange(estrato)
cat(sprintf(
"**Fig. 9.** Precio por estrato en casas de Zona Norte. La mediana pasa de $%.0f M en estrato %s a $%.0f M en estrato %s, mostrando una relación creciente y monótona entre estrato y precio, consistente con los coeficientes positivos y crecientes de `estrato4`, `estrato5` y `estrato6` que se estiman más adelante en el Modelo 1.\n\n",
med_estrato1$mediana[1], med_estrato1$estrato[1],
med_estrato1$mediana[nrow(med_estrato1)], med_estrato1$estrato[nrow(med_estrato1)]
))
Fig. 9. Precio por estrato en casas de Zona Norte.
La mediana pasa de $235 M en estrato 3 a $725 M en estrato 6, mostrando
una relación creciente y monótona entre estrato y precio, consistente
con los coeficientes positivos y crecientes de estrato4,
estrato5 y estrato6 que se estiman más
adelante en el Modelo 1.
cat(sprintf(
"**Fig. 10.** Precio por estrato en apartamentos de Zona Sur. La mediana pasa de $%.0f M en estrato %s a $%.0f M en estrato %s, con el mismo patrón creciente observado en la Fig. 9.\n\n",
med_estrato2$mediana[1], med_estrato2$estrato[1],
med_estrato2$mediana[nrow(med_estrato2)], med_estrato2$estrato[nrow(med_estrato2)]
))
Fig. 10. Precio por estrato en apartamentos de Zona Sur. La mediana pasa de $138 M en estrato 3 a $580 M en estrato 6, con el mismo patrón creciente observado en la Fig. 9.
p_banios1 <- ggplot(
base1,
aes(x = banios, y = preciom)
) +
geom_jitter(alpha = 0.5, width = 0.15) +
geom_smooth(method = "lm", se = FALSE) +
labs(
title = "Fig. 11. Precio vs. número de baños — Casas, Zona Norte",
x = "Baños",
y = "Precio (millones de pesos)"
)
p_hab1 <- ggplot(
base1,
aes(x = habitaciones, y = preciom)
) +
geom_jitter(alpha = 0.5, width = 0.15) +
geom_smooth(method = "lm", se = FALSE) +
labs(
title = "Fig. 12. Precio vs. habitaciones — Casas, Zona Norte",
x = "Habitaciones",
y = "Precio (millones de pesos)"
)
ggplotly(p_banios1)
cat(sprintf(
"**Fig. 11.** Precio vs. número de baños en casas de Zona Norte (con dispersión horizontal aleatoria para separar puntos superpuestos). La pendiente positiva de la línea de tendencia es consistente con la correlación r = %.2f entre `preciom` y `banios` reportada en la Tabla 12.\n\n",
cor1["preciom", "banios"]
))
Fig. 11. Precio vs. número de baños en casas de Zona
Norte (con dispersión horizontal aleatoria para separar puntos
superpuestos). La pendiente positiva de la línea de tendencia es
consistente con la correlación r = 0.51 entre preciom y
banios reportada en la Tabla 12.
ggplotly(p_hab1)
cat(sprintf(
"**Fig. 12.** Precio vs. número de habitaciones en casas de Zona Norte. La correlación con el precio (r = %.2f, Tabla 12) es %s que la de baños (Fig. 11), lo que anticipa un coeficiente de `habitaciones` comparativamente más débil en el Modelo 1.\n\n",
cor1["preciom", "habitaciones"],
ifelse(cor1["preciom", "habitaciones"] > cor1["preciom", "banios"], "más alta", "más baja")
))
Fig. 12. Precio vs. número de habitaciones en casas
de Zona Norte. La correlación con el precio (r = 0.37, Tabla 12) es más
baja que la de baños (Fig. 11), lo que anticipa un coeficiente de
habitaciones comparativamente más débil en el Modelo 1.
p_banios2 <- ggplot(
base2,
aes(x = banios, y = preciom)
) +
geom_jitter(alpha = 0.5, width = 0.15) +
geom_smooth(method = "lm", se = FALSE) +
labs(
title = "Fig. 13. Precio vs. número de baños — Apartamentos, Zona Sur",
x = "Baños",
y = "Precio (millones de pesos)"
)
p_hab2 <- ggplot(
base2,
aes(x = habitaciones, y = preciom)
) +
geom_jitter(alpha = 0.5, width = 0.15) +
geom_smooth(method = "lm", se = FALSE) +
labs(
title = "Fig. 14. Precio vs. habitaciones — Apartamentos, Zona Sur",
x = "Habitaciones",
y = "Precio (millones de pesos)"
)
ggplotly(p_banios2)
cat(sprintf(
"**Fig. 13.** Precio vs. número de baños en apartamentos de Zona Sur (r = %.2f con `preciom`, Tabla 13). Es la variable con la correlación más alta después de `areaconst` en este segmento.\n\n",
cor2["preciom", "banios"]
))
Fig. 13. Precio vs. número de baños en apartamentos
de Zona Sur (r = 0.71 con preciom, Tabla 13). Es la
variable con la correlación más alta después de areaconst
en este segmento.
ggplotly(p_hab2)
cat(sprintf(
"**Fig. 14.** Precio vs. número de habitaciones en apartamentos de Zona Sur. Aunque la correlación marginal con el precio es positiva (r = %.2f, Tabla 13), en la sección de Modelo de regresión lineal múltiple el coeficiente condicional de `habitaciones` resulta negativo una vez que se controla por área, estrato, baños y parqueaderos, un ejemplo de por qué la correlación simple no debe confundirse con el efecto condicional de un modelo multivariado.\n\n",
cor2["preciom", "habitaciones"]
))
Fig. 14. Precio vs. número de habitaciones en
apartamentos de Zona Sur. Aunque la correlación marginal con el precio
es positiva (r = 0.30, Tabla 13), en la sección de Modelo de regresión
lineal múltiple el coeficiente condicional de habitaciones
resulta negativo una vez que se controla por área, estrato, baños y
parqueaderos, un ejemplo de por qué la correlación simple no debe
confundirse con el efecto condicional de un modelo multivariado.
Para ambos segmentos se estima:
\[ Y_i = \beta_0+ \beta_1 \text{Área}_i+ \beta_2 \text{Estrato}_i+ \beta_3 \text{Habitaciones}_i+ \beta_4 \text{Parqueaderos}_i+ \beta_5 \text{Baños}_i+ \varepsilon_i \]
La estimación por mínimos cuadrados ordinarios busca minimizar:
\[ S(\beta)= \sum_{i=1}^{n} (Y_i-\hat Y_i)^2 \]
modelo1 <- lm(
preciom ~ areaconst + estrato + habitaciones +
parqueaderos + banios,
data = base1
)
modelo2 <- lm(
preciom ~ areaconst + estrato + habitaciones +
parqueaderos + banios,
data = base2
)
summary(modelo1)
##
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos +
## banios, data = base1)
##
## Residuals:
## Min 1Q Median 3Q Max
## -790.71 -74.72 -18.93 46.54 991.70
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 11.71883 27.15751 0.432 0.66631
## areaconst 0.68098 0.05283 12.890 < 0.0000000000000002 ***
## estrato4 80.91006 24.55085 3.296 0.00106 **
## estrato5 147.53872 22.70871 6.497 0.000000000228539 ***
## estrato6 281.68942 37.33161 7.546 0.000000000000274 ***
## habitaciones 7.17906 5.69802 1.260 0.20839
## parqueaderos 24.22922 5.86635 4.130 0.000043616959395 ***
## banios 18.09024 7.62857 2.371 0.01816 *
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 154.9 on 427 degrees of freedom
## Multiple R-squared: 0.607, Adjusted R-squared: 0.6006
## F-statistic: 94.24 on 7 and 427 DF, p-value: < 0.00000000000000022
summary(modelo2)
##
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos +
## banios, data = base2)
##
## Residuals:
## Min 1Q Median 3Q Max
## -1058.69 -39.21 0.38 36.96 898.14
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -28.38462 12.93336 -2.195 0.02828 *
## areaconst 1.28595 0.05105 25.189 < 0.0000000000000002 ***
## estrato4 30.40026 9.55717 3.181 0.00149 **
## estrato5 50.88889 9.61940 5.290 0.000000133 ***
## estrato6 204.40443 11.17300 18.294 < 0.0000000000000002 ***
## habitaciones -17.10675 3.70357 -4.619 0.000004062 ***
## parqueaderos 62.13696 3.79229 16.385 < 0.0000000000000002 ***
## banios 41.95467 3.24893 12.913 < 0.0000000000000002 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 92.52 on 2373 degrees of freedom
## Multiple R-squared: 0.7762, Adjusted R-squared: 0.7755
## F-statistic: 1175 on 7 and 2373 DF, p-value: < 0.00000000000000022
Una vez estimados los dos modelos, las ecuaciones se muestran
inmediatamente a continuación. Se generan directamente a partir de los
coeficientes obtenidos por lm(), de modo que no es
necesario copiar manualmente ningún valor.
extraer_ecuacion <- function(modelo) {
b <- coef(modelo)
nombres <- names(b)
# Formatear cada término
formatear_termino <- function(beta, nombre, primero = FALSE) {
if (primero) {
return(sprintf("%.3f", beta))
}
signo <- if (beta >= 0) " + " else " - "
paste0(
signo,
sprintf("%.3f", abs(beta)),
"\\,",
nombre
)
}
primera_fila <- formatear_termino(
b[1],
nombres[1],
primero = TRUE
)
# Agregar los primeros 4 predictores
for (j in 2:min(5, length(b))) {
primera_fila <- paste0(
primera_fila,
formatear_termino(
b[j],
nombres[j]
)
)
}
segunda_fila <- ""
if (length(b) > 5) {
for (j in 6:length(b)) {
segunda_fila <- paste0(
segunda_fila,
formatear_termino(
b[j],
nombres[j]
)
)
}
}
paste0(
"$$\\begin{aligned}",
"\\widehat{Precio} &= ",
primera_fila,
" \\\\",
"&\\quad ",
segunda_fila,
"\\end{aligned}$$"
)
}
cat("### Modelo 1 — Casas, Zona Norte\n\n")
cat(
extraer_ecuacion(modelo1),
"\n\n"
)
\[\begin{aligned}\widehat{Precio} &= 11.719 + 0.681\,areaconst + 80.910\,estrato4 + 147.539\,estrato5 + 281.689\,estrato6 \\&\quad + 7.179\,habitaciones + 24.229\,parqueaderos + 18.090\,banios\end{aligned}\]
cat(
"La categoría de referencia de `estrato` es el estrato 3. ",
"Por tanto, los términos `estrato4`, `estrato5` y `estrato6`, ",
"si están presentes, representan diferencias esperadas frente al estrato 3, ",
"manteniendo constantes las demás variables.\n\n"
)
La categoría de referencia de estrato es el estrato 3.
Por tanto, los términos estrato4, estrato5 y
estrato6, si están presentes, representan diferencias
esperadas frente al estrato 3, manteniendo constantes las demás
variables.
cat("### Modelo 2 — Apartamentos, Zona Sur\n\n")
cat(
extraer_ecuacion(modelo2),
"\n\n"
)
\[\begin{aligned}\widehat{Precio} &= -28.385 + 1.286\,areaconst + 30.400\,estrato4 + 50.889\,estrato5 + 204.404\,estrato6 \\&\quad - 17.107\,habitaciones + 62.137\,parqueaderos + 41.955\,banios\end{aligned}\]
cat(
"Nuevamente, los coeficientes de las categorías de `estrato` ",
"se interpretan respecto al estrato 3 y todos los demás coeficientes son ",
"asociaciones condicionales, manteniendo constantes las otras variables incluidas.\n"
)
Nuevamente, los coeficientes de las categorías de
estrato se interpretan respecto al estrato 3 y todos los
demás coeficientes son asociaciones condicionales, manteniendo
constantes las otras variables incluidas.
Estas ecuaciones son las que se utilizan conceptualmente para explicar la predicción de las viviendas solicitadas. Si el modelo se modifica, las ecuaciones mostradas también se actualizan automáticamente.
Para cada predictor se contrasta: \(H_0:\beta_j=0\) frente a \(H_1:\beta_j\neq0\). Un valor \(p<0.05\) se interpreta como evidencia de asociación estadística, manteniendo constantes las demás variables, no implica causalidad.
coef1 <- tidy(modelo1, conf.int = TRUE)
coef1 %>%
kbl(
digits = 3,
caption = "Tabla 14. Coeficientes — Modelo 1: Casas, Zona Norte"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
| term | estimate | std.error | statistic | p.value | conf.low | conf.high |
|---|---|---|---|---|---|---|
| (Intercept) | 11.719 | 27.158 | 0.432 | 0.666 | -41.660 | 65.098 |
| areaconst | 0.681 | 0.053 | 12.890 | 0.000 | 0.577 | 0.785 |
| estrato4 | 80.910 | 24.551 | 3.296 | 0.001 | 32.654 | 129.166 |
| estrato5 | 147.539 | 22.709 | 6.497 | 0.000 | 102.904 | 192.173 |
| estrato6 | 281.689 | 37.332 | 7.546 | 0.000 | 208.313 | 355.066 |
| habitaciones | 7.179 | 5.698 | 1.260 | 0.208 | -4.021 | 18.379 |
| parqueaderos | 24.229 | 5.866 | 4.130 | 0.000 | 12.699 | 35.760 |
| banios | 18.090 | 7.629 | 2.371 | 0.018 | 3.096 | 33.084 |
coef2 <- tidy(modelo2, conf.int = TRUE)
coef2 %>%
kbl(
digits = 3,
caption = "Tabla 15. Coeficientes — Modelo 2: Apartamentos, Zona Sur"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
| term | estimate | std.error | statistic | p.value | conf.low | conf.high |
|---|---|---|---|---|---|---|
| (Intercept) | -28.385 | 12.933 | -2.195 | 0.028 | -53.746 | -3.023 |
| areaconst | 1.286 | 0.051 | 25.189 | 0.000 | 1.186 | 1.386 |
| estrato4 | 30.400 | 9.557 | 3.181 | 0.001 | 11.659 | 49.142 |
| estrato5 | 50.889 | 9.619 | 5.290 | 0.000 | 32.026 | 69.752 |
| estrato6 | 204.404 | 11.173 | 18.294 | 0.000 | 182.495 | 226.314 |
| habitaciones | -17.107 | 3.704 | -4.619 | 0.000 | -24.369 | -9.844 |
| parqueaderos | 62.137 | 3.792 | 16.385 | 0.000 | 54.700 | 69.574 |
| banios | 41.955 | 3.249 | 12.913 | 0.000 | 35.584 | 48.326 |
Las Tablas 14 y 15 se interpretan en detalle, coeficiente por coeficiente y con sus valores reales, en la sección “Interpretación contextualizada de los coeficientes” más adelante, una vez definido si corresponde usar inferencia clásica o HC3.
Antes de seleccionar el esquema de inferencia se calcula la prueba de Breusch-Pagan. Esto es necesario porque la decisión entre inferencia clásica y HC3 debe estar disponible antes de generar la interpretación de los coeficientes.
bp1 <- lmtest::bptest(modelo1)
bp2 <- lmtest::bptest(modelo2)
usar_hc3_1 <- is.finite(bp1$p.value) && bp1$p.value < 0.05
usar_hc3_2 <- is.finite(bp2$p.value) && bp2$p.value < 0.05
Dado que la prueba de Breusch-Pagan se utiliza para evaluar la homocedasticidad y puede existir heterocedasticidad, además de la tabla clásica se calcula una inferencia robusta mediante errores estándar HC3. Esta corrección no cambia los coeficientes estimados por MCO, cambia sus errores estándar, estadísticos de prueba y valores p.
robusto1 <- coeftest(modelo1, vcov. = vcovHC(modelo1, type = "HC3"))
robusto2 <- coeftest(modelo2, vcov. = vcovHC(modelo2, type = "HC3"))
crear_tabla_hc3 <- function(resultado) {
mat <- as.matrix(resultado)
tibble(
term = rownames(mat),
estimate = as.numeric(mat[, 1]),
SE_HC3 = as.numeric(mat[, 2]),
t_HC3 = as.numeric(mat[, 3]),
p_HC3 = as.numeric(mat[, 4])
)
}
robusto1_df <- crear_tabla_hc3(robusto1)
robusto2_df <- crear_tabla_hc3(robusto2)
robusto1_df %>%
kbl(digits = 4, caption = "Tabla 16. Inferencia robusta HC3 — Modelo 1") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| term | estimate | SE_HC3 | t_HC3 | p_HC3 |
|---|---|---|---|---|
| (Intercept) | 11.7188 | 31.8369 | 0.3681 | 0.7130 |
| areaconst | 0.6810 | 0.1483 | 4.5930 | 0.0000 |
| estrato4 | 80.9101 | 20.0553 | 4.0343 | 0.0001 |
| estrato5 | 147.5387 | 25.5803 | 5.7677 | 0.0000 |
| estrato6 | 281.6894 | 52.7907 | 5.3360 | 0.0000 |
| habitaciones | 7.1791 | 7.0489 | 1.0185 | 0.3090 |
| parqueaderos | 24.2292 | 7.1128 | 3.4064 | 0.0007 |
| banios | 18.0902 | 10.5031 | 1.7224 | 0.0857 |
robusto2_df %>%
kbl(digits = 4, caption = "Tabla 17. Inferencia robusta HC3 — Modelo 2") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| term | estimate | SE_HC3 | t_HC3 | p_HC3 |
|---|---|---|---|---|
| (Intercept) | -28.3846 | 17.9069 | -1.5851 | 0.1131 |
| areaconst | 1.2860 | 0.3841 | 3.3479 | 0.0008 |
| estrato4 | 30.4003 | 6.1467 | 4.9458 | 0.0000 |
| estrato5 | 50.8889 | 6.9318 | 7.3414 | 0.0000 |
| estrato6 | 204.4044 | 12.6891 | 16.1086 | 0.0000 |
| habitaciones | -17.1067 | 6.6580 | -2.5694 | 0.0102 |
| parqueaderos | 62.1370 | 13.8295 | 4.4931 | 0.0000 |
| banios | 41.9547 | 8.6012 | 4.8778 | 0.0000 |
cat(sprintf(
"**Tablas 16 y 17.** Repiten los coeficientes de las Tablas 14 y 15, pero presentan errores estándar, estadístico t y valor p recalculados bajo el esquema robusto HC3. Para el Modelo 1, la prueba de Breusch-Pagan (p = %s) %s el uso de HC3 sobre la inferencia clásica, para el Modelo 2 (p = %s), %s. La comparación detallada, término por término, se presenta en la interpretación contextualizada que sigue.\n\n",
formatC(bp1$p.value, format = "g", digits = 3),
ifelse(usar_hc3_1, "respalda", "no respalda"),
formatC(bp2$p.value, format = "g", digits = 3),
ifelse(usar_hc3_2, "también se recomienda HC3", "la inferencia clásica es suficiente")
))
Tablas 16 y 17. Repiten los coeficientes de las Tablas 14 y 15, pero presentan errores estándar, estadístico t y valor p recalculados bajo el esquema robusto HC3. Para el Modelo 1, la prueba de Breusch-Pagan (p = 3.4e-15) respalda el uso de HC3 sobre la inferencia clásica, para el Modelo 2 (p = 5.97e-149), también se recomienda HC3. La comparación detallada, término por término, se presenta en la interpretación contextualizada que sigue.
Cuando el diagnóstico de heterocedasticidad resulte significativo, la
significancia individual se interpreta con los valores p de
HC3. La tabla clásica de lm() se conserva
para documentar la estimación MCO, pero no se utiliza como evidencia
principal de significancia individual en presencia de
heterocedasticidad.
interpretar_modelo <- function(modelo, nombre, robusto_df, usar_hc3 = TRUE) {
co <- tidy(modelo, conf.int = TRUE) %>%
select(term, estimate, p.value)
if (usar_hc3) {
co <- co %>%
left_join(robusto_df %>% select(term, p_HC3), by = "term") %>%
mutate(p_interpretacion = p_HC3)
} else {
co <- co %>% mutate(p_interpretacion = p.value)
}
sig <- co %>% filter(term != "(Intercept)", p_interpretacion < 0.05)
cat(paste0("### ", nombre, "\n\n"))
if (nrow(sig) == 0) {
cat("No se identificaron coeficientes de variables explicativas estadísticamente significativos al 5% bajo el esquema de inferencia seleccionado. Por tanto, no se realiza una interpretación individual concluyente de asociaciones.\n\n")
return(invisible(NULL))
}
for (i in seq_len(nrow(sig))) {
termino <- sig$term[i]
beta <- sig$estimate[i]
p <- sig$p_interpretacion[i]
if (grepl("^areaconst$", termino)) {
cat(sprintf(
"- **Área construida:** manteniendo constantes las demás variables, un aumento de 1 m² se asocia con un cambio promedio de **$%.2f millones** en el precio estimado (p %s).\n",
beta, ifelse(p < 0.001, "< 0.001", sprintf("= %.3f", p))
))
} else if (grepl("^habitaciones$", termino)) {
cat(sprintf(
"- **Habitaciones:** manteniendo constantes área, estrato, parqueaderos y baños, una habitación adicional se asocia con un cambio promedio de **$%.2f millones** en el precio estimado (p %s). Esta asociación debe interpretarse con cautela y no como causalidad.\n",
beta, ifelse(p < 0.001, "< 0.001", sprintf("= %.3f", p))
))
} else if (grepl("^parqueaderos$", termino)) {
cat(sprintf(
"- **Parqueaderos:** manteniendo constantes las demás variables, un parqueadero adicional se asocia con un cambio promedio de **$%.2f millones** en el precio estimado (p %s).\n",
beta, ifelse(p < 0.001, "< 0.001", sprintf("= %.3f", p))
))
} else if (grepl("^banios$", termino)) {
cat(sprintf(
"- **Baños:** manteniendo constantes las demás variables, un baño adicional se asocia con un cambio promedio de **$%.2f millones** en el precio estimado (p %s).\n",
beta, ifelse(p < 0.001, "< 0.001", sprintf("= %.3f", p))
))
} else if (grepl("^estrato", termino)) {
nivel <- sub("^estrato", "", termino)
cat(sprintf(
"- **Estrato %s:** respecto a la categoría de referencia del factor `estrato`, pertenecer a esta categoría se asocia con un cambio promedio de **$%.2f millones** en el precio, manteniendo constantes las demás variables (p %s).\n",
nivel, beta, ifelse(p < 0.001, "< 0.001", sprintf("= %.3f", p))
))
} else {
cat(sprintf(
"- **%s:** el coeficiente es %.3f y resulta significativo al 5%% (p %s). Su interpretación se realiza manteniendo constantes las demás variables.\n",
termino, beta, ifelse(p < 0.001, "< 0.001", sprintf("= %.3f", p))
))
}
}
cat("\n El signo y magnitud de cada coeficiente describen asociaciones condicionales dentro de la muestra y dependen del esquema de inferencia seleccionado (clásico o HC3). Cuando aparece un signo contraintuitivo, no debe atribuirse automáticamente a un mecanismo económico específico, debe revisarse junto con la estructura de la muestra, las variables omitidas, la multicolinealidad y las observaciones influyentes.\n\n")
}
interpretar_modelo(modelo1, "Modelo 1 — Casas, Zona Norte", robusto1_df, usar_hc3 = usar_hc3_1)
estrato, pertenecer a esta categoría se asocia
con un cambio promedio de $80.91 millones en el precio,
manteniendo constantes las demás variables (p < 0.001).estrato, pertenecer a esta categoría se asocia
con un cambio promedio de $147.54 millones en el
precio, manteniendo constantes las demás variables (p < 0.001).estrato, pertenecer a esta categoría se asocia
con un cambio promedio de $281.69 millones en el
precio, manteniendo constantes las demás variables (p < 0.001).El signo y magnitud de cada coeficiente describen asociaciones condicionales dentro de la muestra y dependen del esquema de inferencia seleccionado (clásico o HC3). Cuando aparece un signo contraintuitivo, no debe atribuirse automáticamente a un mecanismo económico específico, debe revisarse junto con la estructura de la muestra, las variables omitidas, la multicolinealidad y las observaciones influyentes.
interpretar_modelo(modelo2, "Modelo 2 — Apartamentos, Zona Sur", robusto2_df, usar_hc3 = usar_hc3_2)
estrato, pertenecer a esta categoría se asocia
con un cambio promedio de $30.40 millones en el precio,
manteniendo constantes las demás variables (p < 0.001).estrato, pertenecer a esta categoría se asocia
con un cambio promedio de $50.89 millones en el precio,
manteniendo constantes las demás variables (p < 0.001).estrato, pertenecer a esta categoría se asocia
con un cambio promedio de $204.40 millones en el
precio, manteniendo constantes las demás variables (p < 0.001).El signo y magnitud de cada coeficiente describen asociaciones condicionales dentro de la muestra y dependen del esquema de inferencia seleccionado (clásico o HC3). Cuando aparece un signo contraintuitivo, no debe atribuirse automáticamente a un mecanismo económico específico, debe revisarse junto con la estructura de la muestra, las variables omitidas, la multicolinealidad y las observaciones influyentes.
Para cada coeficiente se contrasta:
\[ H_0:\beta_j=0 \]
frente a:
\[ H_1:\beta_j\neq0 \]
Si \(p<0.05\), existe evidencia
estadística de asociación entre la variable y el precio, manteniendo
constantes las demás variables del modelo. La fuente del valor
p debe identificarse explícitamente: se utiliza inferencia
HC3 cuando la prueba de Breusch-Pagan detecta
heterocedasticidad al 5%; en caso contrario, se utiliza la inferencia
clásica de lm().
La interpretación debe hacerse como asociación condicional, no como causalidad.
Los coeficientes estimados por mínimos cuadrados ordinarios son los mismos independientemente de que se utilicen errores estándar clásicos o robustos. Lo que cambia es la estimación de la incertidumbre de esos coeficientes.
lm() y supone, entre otras condiciones,
varianza constante de los errores.Por tanto, un coeficiente puede resultar significativo con errores
estándar clásicos y no serlo con HC3, o viceversa. En este informe,
cuando exista evidencia de heterocedasticidad, la conclusión sobre
significancia individual debe basarse en la tabla HC3, mientras que la
tabla clásica se conserva como referencia del ajuste MCO. Esta
distinción no modifica las predicciones puntuales ni convierte
automáticamente los intervalos de predict.lm() en
intervalos robustos.
La prueba global contrasta:
\[ H_0: \beta_1=\beta_2=\cdots=\beta_k=0 \]
frente a:
\[ H_1: \text{al menos un }\beta_j\neq0 \]
glance(modelo1) %>%
select(
statistic,
p.value
) %>%
kbl(
digits = 4,
caption = "Tabla 18. Prueba F global — Modelo 1"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
| statistic | p.value |
|---|---|
| 94.2353 | 0 |
glance(modelo2) %>%
select(
statistic,
p.value
) %>%
kbl(
digits = 4,
caption = "Tabla 19. Prueba F global — Modelo 2"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
| statistic | p.value |
|---|---|
| 1175.414 | 0 |
f1 <- glance(modelo1)
f2 <- glance(modelo2)
cat(sprintf(
"**Tabla 18.** El estadístico F del Modelo 1 es %.2f con p %s, por lo que se rechaza H₀:, las variables explicativas sí están asociadas al precio de las casas de Zona Norte.\n\n",
f1$statistic, ifelse(f1$p.value < 0.001, "< 0.001", sprintf("= %.4f", f1$p.value))
))
Tabla 18. El estadístico F del Modelo 1 es 94.24 con p < 0.001, por lo que se rechaza H₀:, las variables explicativas sí están asociadas al precio de las casas de Zona Norte.
cat(sprintf(
"**Tabla 19.** El estadístico F del Modelo 2 es %.2f con p %s, también significativo. El valor de F es %s que el del Modelo 1, en parte porque `base2` tiene %d observaciones frente a las %d de `base1`.\n\n",
f2$statistic, ifelse(f2$p.value < 0.001, "< 0.001", sprintf("= %.4f", f2$p.value)),
ifelse(f2$statistic > f1$statistic, "considerablemente mayor", "menor"),
nrow(base2), nrow(base1)
))
Tabla 19. El estadístico F del Modelo 2 es 1175.41
con p < 0.001, también significativo. El valor de F es
considerablemente mayor que el del Modelo 1, en parte porque
base2 tiene 2381 observaciones frente a las 435 de
base1.
El coeficiente de determinación es:
\[ R^2 = 1-\frac{SCR}{SCT} \]
El R² ajustado es:
\[ R^2_{aj} = 1- \left( \frac{n-1}{n-p-1} \right) (1-R^2) \]
comparacion_r2 <- bind_rows(
glance(modelo1) %>%
mutate(modelo = "Casas — Zona Norte"),
glance(modelo2) %>%
mutate(modelo = "Apartamentos — Zona Sur")
) %>%
select(
modelo,
nobs,
r.squared,
adj.r.squared,
sigma,
statistic,
p.value
)
comparacion_r2 %>%
kbl(
digits = 4,
caption = "Tabla 20. Indicadores generales de los modelos"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
| modelo | nobs | r.squared | adj.r.squared | sigma | statistic | p.value |
|---|---|---|---|---|---|---|
| Casas — Zona Norte | 435 | 0.6070 | 0.6006 | 154.8969 | 94.2353 | 0 |
| Apartamentos — Zona Sur | 2381 | 0.7762 | 0.7755 | 92.5165 | 1175.4140 | 0 |
cat(sprintf(
"**Tabla 20.** El Modelo 1 (casas) alcanza un R² de %.3f (R² ajustado %.3f), las variables incluidas explican cerca del %.0f%% de la variabilidad observada del precio dentro de Zona Norte. El Modelo 2 (apartamentos) alcanza un R² de %.3f (R² ajustado %.3f), %s que el Modelo 1. La diferencia entre R² y R² ajustado es pequeña en ambos casos (%.4f y %.4f respectivamente), lo que muestra que el número de predictores no está inflando artificialmente el ajuste.\n\n",
comparacion_r2$r.squared[1], comparacion_r2$adj.r.squared[1], comparacion_r2$r.squared[1] * 100,
comparacion_r2$r.squared[2], comparacion_r2$adj.r.squared[2],
ifelse(comparacion_r2$r.squared[2] > comparacion_r2$r.squared[1], "más alto", "más bajo"),
comparacion_r2$r.squared[1] - comparacion_r2$adj.r.squared[1],
comparacion_r2$r.squared[2] - comparacion_r2$adj.r.squared[2]
))
Tabla 20. El Modelo 1 (casas) alcanza un R² de 0.607 (R² ajustado 0.601), las variables incluidas explican cerca del 61% de la variabilidad observada del precio dentro de Zona Norte. El Modelo 2 (apartamentos) alcanza un R² de 0.776 (R² ajustado 0.775), más alto que el Modelo 1. La diferencia entre R² y R² ajustado es pequeña en ambos casos (0.0064 y 0.0007 respectivamente), lo que muestra que el número de predictores no está inflando artificialmente el ajuste.
El R² debe interpretarse como la proporción de la variabilidad observada del precio que queda asociada al ajuste lineal de las variables incluidas en el modelo, dentro del segmento analizado. La fracción restante representa variabilidad que el modelo no reproduce, no puede atribuirse directamente a variables específicas, ni interpretarse como el efecto causal de factores omitidos. Variables como antigüedad, acabados, ubicación exacta u otras características podrían aportar información adicional, pero este análisis no permite cuantificar ni aislar su contribución individual.
Por esta razón, el R² no debe utilizarse como único criterio para afirmar que un modelo es mejor que otro cuando se aplican a mercados diferentes. Los dos modelos corresponden a tipos de vivienda, zonas y tamaños muestrales distintos.
La linealidad se revisa mediante gráficos de residuos frente a valores ajustados y gráficos parciales.
par(mfrow = c(2, 2))
plot(modelo1)
par(mfrow = c(1, 1))
par(mfrow = c(2, 2))
plot(modelo2)
par(mfrow = c(1, 1))
cat("**Fig. 15.** Panel de cuatro gráficos diagnósticos estándar de `lm()` para el Modelo 1: Residuals vs Fitted (linealidad y homocedasticidad), Normal Q-Q (normalidad), Scale-Location (homocedasticidad) y Residuals vs Leverage (observaciones influyentes). Se retoman con detalle, con sus propios valores, en las subsecciones de Normalidad, Homocedasticidad y Observaciones influyentes que siguen.\n\n")
Fig. 15. Panel de cuatro gráficos diagnósticos
estándar de lm() para el Modelo 1: Residuals vs Fitted
(linealidad y homocedasticidad), Normal Q-Q (normalidad), Scale-Location
(homocedasticidad) y Residuals vs Leverage (observaciones influyentes).
Se retoman con detalle, con sus propios valores, en las subsecciones de
Normalidad, Homocedasticidad y Observaciones influyentes que siguen.
cat("**Fig. 16.** El mismo panel de cuatro gráficos diagnósticos, aplicado al Modelo 2. Al compararlo con la Fig. 15, la nube de puntos de `Residuals vs Fitted` es visiblemente más densa, consistente con que `base2` tiene muchas más observaciones que `base1`.\n\n")
Fig. 16. El mismo panel de cuatro gráficos
diagnósticos, aplicado al Modelo 2. Al compararlo con la Fig. 15, la
nube de puntos de Residuals vs Fitted es visiblemente más
densa, consistente con que base2 tiene muchas más
observaciones que base1.
La ausencia de patrones sistemáticos en los residuos sería consistente con una especificación lineal adecuada. Patrones curvos sugerirían que podrían requerirse transformaciones o términos no lineales.
Se contrasta:
\[ H_0:\varepsilon_i\sim N(0,\sigma^2) \]
frente a:
\[ H_1:\varepsilon_i\not\sim N(0,\sigma^2) \]
shapiro1 <- shapiro.test(residuals(modelo1))
shapiro2 <- shapiro.test(residuals(modelo2))
shapiro1
##
## Shapiro-Wilk normality test
##
## data: residuals(modelo1)
## W = 0.8487, p-value < 0.00000000000000022
shapiro2
##
## Shapiro-Wilk normality test
##
## data: residuals(modelo2)
## W = 0.77823, p-value < 0.00000000000000022
También se utiliza el gráfico Q-Q:
qqnorm(residuals(modelo1))
qqline(residuals(modelo1))
qqnorm(residuals(modelo2))
qqline(residuals(modelo2))
cat(sprintf(
"**Fig. 17.** Gráfico Q-Q de los residuos del Modelo 1. Los puntos se desvían de la línea diagonal en las colas, lo que es visualmente consistente con el resultado de Shapiro-Wilk (W = %.4f, p %s), que rechaza la normalidad de los residuos.\n\n",
shapiro1$statistic, ifelse(shapiro1$p.value < 0.001, "< 0.001", sprintf("= %.4f", shapiro1$p.value))
))
Fig. 17. Gráfico Q-Q de los residuos del Modelo 1. Los puntos se desvían de la línea diagonal en las colas, lo que es visualmente consistente con el resultado de Shapiro-Wilk (W = 0.8487, p < 0.001), que rechaza la normalidad de los residuos.
cat(sprintf(
"**Fig. 18.** Gráfico Q-Q de los residuos del Modelo 2 (W = %.4f, p %s). Al igual que en la Fig. 17, se observan colas alejadas de la diagonal, coherente con el rechazo de normalidad detectado por Shapiro-Wilk.\n\n",
shapiro2$statistic, ifelse(shapiro2$p.value < 0.001, "< 0.001", sprintf("= %.4f", shapiro2$p.value))
))
Fig. 18. Gráfico Q-Q de los residuos del Modelo 2 (W = 0.7782, p < 0.001). Al igual que en la Fig. 17, se observan colas alejadas de la diagonal, coherente con el rechazo de normalidad detectado por Shapiro-Wilk.
Con muestras grandes, Shapiro-Wilk puede detectar desviaciones muy pequeñas de normalidad. Por ello, el resultado debe analizarse junto con el Q-Q plot y el objetivo de inferencia.
Se contrasta:
\[ H_0:Var(\varepsilon_i)=\sigma^2 \]
frente a:
\[ H_1:Var(\varepsilon_i)\neq\sigma^2 \]
# bp1 y bp2 se calcularon antes de la inferencia HC3 y se reutilizan aquí.
bp1
##
## studentized Breusch-Pagan test
##
## data: modelo1
## BP = 82.976, df = 7, p-value = 0.0000000000000034
bp2
##
## studentized Breusch-Pagan test
##
## data: modelo2
## BP = 709.57, df = 7, p-value < 0.00000000000000022
plot(
fitted(modelo1),
residuals(modelo1),
xlab = "Valores ajustados",
ylab = "Residuos",
main = "Fig. 19. Residuos vs. ajustados — Modelo 1"
)
abline(h = 0, lty = 2)
plot(
fitted(modelo2),
residuals(modelo2),
xlab = "Valores ajustados",
ylab = "Residuos",
main = "Fig. 20. Residuos vs. ajustados — Modelo 2"
)
abline(h = 0, lty = 2)
cat(sprintf(
"**Fig. 19.** Residuos vs. valores ajustados del Modelo 1. El ensanchamiento del abanico de puntos a medida que aumenta el valor ajustado es la señal visual de varianza no constante, coherente con el resultado de Breusch-Pagan (BP = %.2f, p %s), que rechaza la homocedasticidad.\n\n",
bp1$statistic, ifelse(bp1$p.value < 0.001, "< 0.001", sprintf("= %.4f", bp1$p.value))
))
Fig. 19. Residuos vs. valores ajustados del Modelo 1. El ensanchamiento del abanico de puntos a medida que aumenta el valor ajustado es la señal visual de varianza no constante, coherente con el resultado de Breusch-Pagan (BP = 82.98, p < 0.001), que rechaza la homocedasticidad.
cat(sprintf(
"**Fig. 20.** Residuos vs. valores ajustados del Modelo 2 (BP = %.2f, p %s). Presenta el mismo patrón de abanico creciente que la Fig. 19, confirmando heterocedasticidad también en este modelo.\n\n",
bp2$statistic, ifelse(bp2$p.value < 0.001, "< 0.001", sprintf("= %.4f", bp2$p.value))
))
Fig. 20. Residuos vs. valores ajustados del Modelo 2 (BP = 709.57, p < 0.001). Presenta el mismo patrón de abanico creciente que la Fig. 19, confirmando heterocedasticidad también en este modelo.
Si se detecta heterocedasticidad, las estimaciones de los coeficientes por MCO pueden seguir siendo útiles bajo condiciones apropiadas, pero los errores estándar e inferencias usuales pueden verse afectados. Entre las alternativas se encuentran errores estándar robustos o transformaciones como \(\log(\text{Precio})\).
Se utiliza:
\[ VIF_j=\frac{1}{1-R_j^2} \]
vif1 <- vif(modelo1)
vif2 <- vif(modelo2)
# Mostrar con kbl para mejor formato
vif1_df <- if (is.matrix(vif1)) {
as.data.frame(vif1) %>% rownames_to_column("Variable")
} else {
data.frame(Variable = names(vif1), VIF = vif1)
}
vif2_df <- if (is.matrix(vif2)) {
as.data.frame(vif2) %>% rownames_to_column("Variable")
} else {
data.frame(Variable = names(vif2), VIF = vif2)
}
cat("### VIF — Modelo 1\n\n")
vif1_df %>%
kbl(digits = 2, caption = "Tabla 21. Factor de inflación de varianza (VIF) — Modelo 1") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| Variable | GVIF | Df | GVIF^(1/(2*Df)) |
|---|---|---|---|
| areaconst | 1.47 | 1 | 1.21 |
| estrato | 1.38 | 3 | 1.06 |
| habitaciones | 1.75 | 1 | 1.32 |
| parqueaderos | 1.23 | 1 | 1.11 |
| banios | 2.05 | 1 | 1.43 |
cat("\n\n### VIF — Modelo 2\n\n")
vif2_df %>%
kbl(digits = 2, caption = "Tabla 22. Factor de inflación de varianza (VIF) — Modelo 2") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| Variable | GVIF | Df | GVIF^(1/(2*Df)) |
|---|---|---|---|
| areaconst | 2.07 | 1 | 1.44 |
| estrato | 1.71 | 3 | 1.09 |
| habitaciones | 1.45 | 1 | 1.21 |
| parqueaderos | 1.79 | 1 | 1.34 |
| banios | 2.60 | 1 | 1.61 |
col_vif <- function(df) {
nombre <- intersect(c("GVIF..1..2.Df..", "VIF"), names(df))
if (length(nombre) == 0) nombre <- tail(names(df), 1)
df[[nombre[1]]]
}
interpretar_vif <- function(df, nombre_modelo) {
valores <- col_vif(df)
var_max <- df$Variable[which.max(valores)]
vif_max <- max(valores)
nivel <- if (vif_max >= 10) {
"sugiere multicolinealidad relevante y amerita revisión"
} else if (vif_max >= 5) {
"está en un rango moderado; conviene vigilarlo, aunque no es crítico"
} else {
"está en un rango bajo, sin evidencia de multicolinealidad problemática"
}
cat(sprintf(
"**%s:** el VIF más alto corresponde a `%s` (%.2f), lo cual %s. El resto de variables presenta valores similares o menores.\n\n",
nombre_modelo, var_max, vif_max, nivel
))
}
interpretar_vif(vif1_df, "Tabla 21. Modelo 1 — Casas, Zona Norte")
Tabla 21. Modelo 1 — Casas, Zona Norte: el VIF más
alto corresponde a banios (1.43), lo cual está en un rango
bajo, sin evidencia de multicolinealidad problemática. El resto de
variables presenta valores similares o menores.
interpretar_vif(vif2_df, "Tabla 22. Modelo 2 — Apartamentos, Zona Sur")
Tabla 22. Modelo 2 — Apartamentos, Zona Sur: el VIF
más alto corresponde a banios (1.61), lo cual está en un
rango bajo, sin evidencia de multicolinealidad problemática. El resto de
variables presenta valores similares o menores.
La independencia de los errores se analiza con cautela porque los datos corresponden a viviendas de corte transversal y poseen una dimensión geográfica. Se reporta Durbin-Watson como diagnóstico complementario, pero no se interpreta como una prueba de autocorrelación temporal, ya que las observaciones no forman una serie ordenada en el tiempo. Para la estructura espacial, el diagnóstico principal es Moran’s I.
dw1 <- dwtest(modelo1)
dw2 <- dwtest(modelo2)
dw1
##
## Durbin-Watson test
##
## data: modelo1
## DW = 1.79, p-value = 0.01124
## alternative hypothesis: true autocorrelation is greater than 0
dw2
##
## Durbin-Watson test
##
## data: modelo2
## DW = 1.6866, p-value = 0.00000000000000731
## alternative hypothesis: true autocorrelation is greater than 0
Un valor cercano a 2 en Durbin-Watson es compatible con ausencia de autocorrelación según el orden de las observaciones, sin embargo, en este problema su interpretación es secundaria. La evidencia relevante para la dependencia geográfica se obtiene mediante Moran’s I.
calcular_moran <- function(base, modelo, k = 5) {
if (!all(c("longitud", "latitud") %in% names(base))) {
return(NULL)
}
idx <- complete.cases(base[, c("longitud", "latitud")])
if (!all(idx)) {
warning("Hay coordenadas faltantes en las observaciones usadas por el modelo; Moran's I se omite para esta base.")
return(NULL)
}
coords <- as.matrix(base[, c("longitud", "latitud")])
# Coordenadas repetidas ("placeholder" por barrio) SON esperables en esta
# base y no impiden calcular Moran's I: knearneigh() las tolera sin
# problema. Solo se aborta si no hay suficientes puntos ÚNICOS para
# construir un vecindario de tamaño k con sentido.
n_unicos <- nrow(unique(coords))
if (n_unicos <= k) {
warning("No hay suficientes ubicaciones únicas para calcular Moran's I con k = ", k, ".")
return(NULL)
}
if (anyDuplicated(coords)) {
message("Existen coordenadas duplicadas (posibles valores por defecto de barrio); Moran's I se calcula igualmente, con jitter mínimo para evitar vecindarios ambiguos entre puntos idénticos.")
set.seed(123)
coords <- coords + matrix(rnorm(length(coords), sd = 1e-6), ncol = 2)
}
vecinos <- knn2nb(knearneigh(coords, k = k))
pesos <- nb2listw(vecinos, style = "W")
moran.test(residuals(modelo), pesos)
}
moran1 <- calcular_moran(base1, modelo1, k = 5)
moran2 <- calcular_moran(base2, modelo2, k = 5)
if (!is.null(moran1)) moran1
##
## Moran I test under randomisation
##
## data: residuals(modelo)
## weights: pesos
##
## Moran I statistic standard deviate = 1.4465, p-value = 0.07402
## alternative hypothesis: greater
## sample estimates:
## Moran I statistic Expectation Variance
## 0.037184152 -0.002304147 0.000745225
if (!is.null(moran2)) moran2
##
## Moran I test under randomisation
##
## data: residuals(modelo)
## weights: pesos
##
## Moran I statistic standard deviate = 15.229, p-value <
## 0.00000000000000022
## alternative hypothesis: greater
## sample estimates:
## Moran I statistic Expectation Variance
## 0.1800196618 -0.0004201681 0.0001403870
interpretar_moran <- function(resultado, nombre_modelo) {
if (is.null(resultado)) {
cat("**", nombre_modelo, ":** Moran's I no pudo calcularse — revisar la advertencia del bloque anterior para la causa exacta (coordenadas faltantes o ubicaciones únicas insuficientes).\n\n", sep = "")
return(invisible(NULL))
}
I <- unname(resultado$estimate[1])
p <- resultado$p.value
if (p < 0.05) {
texto <- paste0(
"**", nombre_modelo, ":** Moran's I = ", round(I, 3),
", p ", ifelse(p < 0.0001, "< 0.0001", paste0("= ", format.pval(p, digits = 3))),
". Se rechaza H₀ al 5%, existe evidencia de autocorrelación espacial en los residuos.\n\n"
)
} else {
texto <- paste0(
"**", nombre_modelo, ":** Moran's I = ", round(I, 3),
", p = ", format.pval(p, digits = 3),
". No se rechaza H₀ al 5%, no existe evidencia suficiente de autocorrelación espacial de los residuos.\n\n"
)
}
cat(texto)
}
interpretar_moran(moran1, "Modelo 1 — Casas, Zona Norte")
Modelo 1 — Casas, Zona Norte: Moran’s I = 0.037, p = 0.074. No se rechaza H₀ al 5%, no existe evidencia suficiente de autocorrelación espacial de los residuos.
interpretar_moran(moran2, "Modelo 2 — Apartamentos, Zona Sur")
Modelo 2 — Apartamentos, Zona Sur: Moran’s I = 0.18, p < 0.0001. Se rechaza H₀ al 5%, existe evidencia de autocorrelación espacial en los residuos.
cooks1 <- cooks.distance(modelo1)
cooks2 <- cooks.distance(modelo2)
influencia1 <- tibble(
observacion = seq_along(cooks1),
cooks_d = cooks1
) %>%
filter(cooks_d > 4 / nrow(base1)) %>%
arrange(desc(cooks_d))
influencia2 <- tibble(
observacion = seq_along(cooks2),
cooks_d = cooks2
) %>%
filter(cooks_d > 4 / nrow(base2)) %>%
arrange(desc(cooks_d))
extremas1 <- influencia1 %>%
filter(cooks_d > 1)
extremas2 <- influencia2 %>%
filter(cooks_d > 1)
if (nrow(extremas2) > 0) {
obs_extrema2 <- base2[extremas2$observacion, ] %>%
select(
barrio,
preciom,
areaconst,
estrato,
banios,
habitaciones
) %>%
bind_cols(
cooks_d = round(extremas2$cooks_d, 2)
)
tabla23 <- obs_extrema2 %>%
kbl(
caption = "Tabla 23. Observaciones con distancia de Cook extrema (D > 1) — Modelo 2",
format = "html"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
print(tabla23)
} else {
cat(
"**No se identificaron observaciones con distancia de Cook extrema (D > 1) en el Modelo 2.**\n\n"
)
}
| barrio | preciom | areaconst | estrato | banios | habitaciones | cooks_d |
|---|---|---|---|---|---|---|
| valle del lili | 299 | 932 | 5 | 3 | 3 | 5.53 |
if (nrow(extremas1) > 0) {
obs_extrema1 <- base1[extremas1$observacion, ] %>%
select(
barrio,
preciom,
areaconst,
estrato,
banios,
habitaciones
) %>%
bind_cols(
cooks_d = round(extremas1$cooks_d, 2)
)
tabla24 <- obs_extrema1 %>%
kbl(
caption = "Tabla 24. Observaciones con distancia de Cook extrema (D > 1) — Modelo 1",
format = "html"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
print(tabla24)
} else {
cat(
"**Para la tabla 24 no se identificaron observaciones con distancia de Cook extrema (D > 1) en el Modelo 1.**\n\n"
)
}
Para la tabla 24 no se identificaron observaciones con distancia de Cook extrema (D > 1) en el Modelo 1.
cat(sprintf(
"El Modelo 1 tiene %d observaciones por encima del umbral de referencia 4/n (Cook's D > %.4f), pero ninguna supera el umbral clásico de D > 1, por lo que no aplica la Tabla 24 en esta ejecución.\n\n",
nrow(influencia1), 4 / nrow(base1)
))
El Modelo 1 tiene 25 observaciones por encima del umbral de referencia 4/n (Cook’s D > 0.0092), pero ninguna supera el umbral clásico de D > 1, por lo que no aplica la Tabla 24 en esta ejecución.
if (nrow(extremas2) > 0) {
cat(sprintf(
"**Tabla 23.** Identifica la observación del Modelo 2 cuya distancia de Cook (%.2f) supera claramente el umbral D > 1, en el barrio `%s`, con precio $%.0f M y área %.0f m². Esta única observación se somete a la prueba de sensibilidad de la siguiente subsección.\n\n",
extremas2$cooks_d[1], obs_extrema2$barrio[1], obs_extrema2$preciom[1], obs_extrema2$areaconst[1]
))
}
Tabla 23. Identifica la observación del Modelo 2
cuya distancia de Cook (5.53) supera claramente el umbral D > 1, en
el barrio valle del lili, con precio $299 M y área 932 m².
Esta única observación se somete a la prueba de sensibilidad de la
siguiente subsección.
La distancia de Cook se utiliza para identificar observaciones que podrían ejercer una influencia importante sobre el modelo. Una observación influyente no debe eliminarse automáticamente, primero debe revisarse si corresponde a un dato válido o a un error.
if (nrow(extremas2) > 0) {
fila_outlier <- extremas2$observacion[
which.max(extremas2$cooks_d)
]
modelo2_sin_outlier <- lm(
preciom ~ areaconst + estrato + habitaciones +
parqueaderos + banios,
data = base2[-fila_outlier, ]
)
comparacion_sensibilidad <- bind_rows(
tidy(modelo2) %>%
filter(term == "habitaciones") %>%
mutate(
modelo = "Modelo 2 (con la observación)"
),
tidy(modelo2_sin_outlier) %>%
filter(term == "habitaciones") %>%
mutate(
modelo = "Modelo 2 (sin la observación)"
)
) %>%
select(
modelo,
estimate,
std.error,
statistic,
p.value
)
tabla25 <- comparacion_sensibilidad %>%
kbl(
digits = 3,
format = "html",
caption = paste0(
"Tabla 25. Sensibilidad del coeficiente de 'habitaciones' ",
" con y sin la observación más influyente"
),
col.names = c(
"Modelo",
"Estimación",
"Error estándar",
"Estadístico t",
"Valor p"
)
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
print(tabla25)
} else {
cat(
"**No se identificaron observaciones con distancia de Cook > 1 ",
"en el Modelo 2; por tanto, no se realiza el análisis de sensibilidad ",
"por exclusión de una observación extrema.**\n"
)
}
| Modelo | Estimación | Error estándar | Estadístico t | Valor p |
|---|---|---|---|---|
| Modelo 2 (con la observación) | -17.107 | 3.704 | -4.619 | 0 |
| Modelo 2 (sin la observación) | -20.979 | 3.584 | -5.853 | 0 |
if (nrow(extremas2) > 0) {
cat(sprintf(
"**Tabla 25.** Con la observación influyente incluida, el coeficiente de `habitaciones` es %.2f; al excluirla, pasa a %.2f. El signo se mantiene %s en ambos casos, y la magnitud cambia en %.2f millones (%.0f%% relativo), por lo que la asociación negativa de `habitaciones` con el precio en el Modelo 2 **no depende exclusivamente** de esta observación puntual.\n\n",
comparacion_sensibilidad$estimate[1], comparacion_sensibilidad$estimate[2],
ifelse(sign(comparacion_sensibilidad$estimate[1]) == sign(comparacion_sensibilidad$estimate[2]), "igual (negativo)", "distinto"),
abs(comparacion_sensibilidad$estimate[1] - comparacion_sensibilidad$estimate[2]),
100 * abs(comparacion_sensibilidad$estimate[1] - comparacion_sensibilidad$estimate[2]) / abs(comparacion_sensibilidad$estimate[1])
))
}
Tabla 25. Con la observación influyente incluida, el
coeficiente de habitaciones es -17.11; al excluirla, pasa a
-20.98. El signo se mantiene igual (negativo) en ambos casos, y la
magnitud cambia en 3.87 millones (23% relativo), por lo que la
asociación negativa de habitaciones con el precio en el
Modelo 2 no depende exclusivamente de esta observación
puntual.
Para evitar errores al interpretar vif() cuando
estrato es un factor, se utiliza el VIF generalizado
ajustado para los grados de libertad:
vif_max <- function(modelo_vif) {
if (is.matrix(modelo_vif)) {
# Para factores, car::vif reporta GVIF y GVIF^(1/(2*Df)).
# Se utiliza directamente la última columna como VIF ajustado.
max(modelo_vif[, "GVIF^(1/(2*Df))"], na.rm = TRUE)
} else {
max(modelo_vif, na.rm = TRUE)
}
}
formatear_p <- function(p) {
format.pval(p, digits = 3, eps = 0.001)
}
moran_resumen <- function(resultado) {
if (is.null(resultado)) {
return("No calculable — revisar coordenadas")
}
paste0(
"Moran I = ", round(unname(resultado$estimate[1]), 3),
"; p = ", formatear_p(resultado$p.value)
)
}
validacion <- tibble(
supuesto = c(
"Linealidad",
"Normalidad",
"Homocedasticidad",
"Multicolinealidad",
"Dependencia espacial"
),
modelo1 = c(
"Revisar gráficos diagnósticos",
ifelse(shapiro1$p.value < 0.05, "Se rechaza normalidad", "No se rechaza normalidad"),
ifelse(bp1$p.value < 0.05, "Se detecta heterocedasticidad", "No se detecta heterocedasticidad"),
paste0("VIF máximo ajustado = ", round(vif_max(vif1), 2)),
moran_resumen(moran1)
),
modelo2 = c(
"Revisar gráficos diagnósticos",
ifelse(shapiro2$p.value < 0.05, "Se rechaza normalidad", "No se rechaza normalidad"),
ifelse(bp2$p.value < 0.05, "Se detecta heterocedasticidad", "No se detecta heterocedasticidad"),
paste0("VIF máximo ajustado = ", round(vif_max(vif2), 2)),
moran_resumen(moran2)
)
)
validacion %>%
kbl(caption = "Tabla 26. Resumen de validación de supuestos") %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
| supuesto | modelo1 | modelo2 |
|---|---|---|
| Linealidad | Revisar gráficos diagnósticos | Revisar gráficos diagnósticos |
| Normalidad | Se rechaza normalidad | Se rechaza normalidad |
| Homocedasticidad | Se detecta heterocedasticidad | Se detecta heterocedasticidad |
| Multicolinealidad | VIF máximo ajustado = 1.43 | VIF máximo ajustado = 1.61 |
| Dependencia espacial | Moran I = 0.037; p = 0.074 | Moran I = 0.18; p = <0.001 |
La Tabla 26 se interpreta supuesto por supuesto, con sus valores concretos, en la subsección “Interpretación de la validación” a continuación.
interpretar_validacion <- function(nombre, shapiro, bp, vif_obj, moran) {
vif_val <- vif_max(vif_obj)
cat(sprintf("### %s\n\n", nombre))
cat(if (shapiro$p.value < 0.05) {
"La prueba de Shapiro-Wilk rechaza la normalidad de los residuos al 5%. Este resultado debe contrastarse con el gráfico Q-Q, especialmente si la muestra es grande.\n\n"
} else {
"La prueba de Shapiro-Wilk no rechaza la normalidad de los residuos al 5%. Debe complementarse con el gráfico Q-Q.\n\n"
})
cat(if (bp$p.value < 0.05) {
"La prueba de Breusch-Pagan detecta evidencia de heterocedasticidad al 5%. Se pueden considerar errores estándar robustos o una transformación de la variable respuesta.\n\n"
} else {
"La prueba de Breusch-Pagan no detecta evidencia estadística de heterocedasticidad al 5%.\n\n"
})
cat(sprintf(
"El VIF/GVIF ajustado máximo es %.2f. Valores elevados indicarían posible multicolinealidad y justificarían revisar la relación entre predictores.\n\n",
vif_val
))
if (is.null(moran)) {
cat("Moran's I no pudo calcularse con las condiciones disponibles de coordenadas, por tanto, la dependencia espacial no se puede concluir mediante esta prueba en este informe.\n\n")
} else if (moran$p.value < 0.05) {
if (grepl("Modelo 2", nombre, fixed = TRUE)) {
cat(sprintf(
"El Índice de Moran es %.3f con p %s, por lo que existe evidencia estadísticamente significativa de autocorrelación espacial de los residuos. En este modelo, la ubicación parece contener estructura que no está siendo capturada completamente por los predictores incluidos. Por ello, los resultados del Modelo 2, incluidas sus predicciones e intervalos de predicción, deben interpretarse con mayor cautela y como una referencia estadística.\n\n",
unname(moran$estimate[1]),
ifelse(moran$p.value < 0.001, "< 0.001", sprintf("= %.3f", moran$p.value))
))
} else {
cat(sprintf(
"El Índice de Moran es %.3f con p %s, por lo que existe evidencia de autocorrelación espacial de los residuos. Esto indica que la ubicación contiene estructura no capturada por el modelo y sugiere considerar variables geográficas o modelos SAR/SEM.\n\n",
unname(moran$estimate[1]),
ifelse(moran$p.value < 0.001, "< 0.001", sprintf("= %.3f", moran$p.value))
))
}
} else {
cat(sprintf(
"El Índice de Moran es %.3f con p = %.3f. Al 5%% no se rechaza la hipótesis nula de ausencia de autocorrelación espacial, aunque un resultado cercano al umbral debe interpretarse con cautela.\n\n",
unname(moran$estimate[1]), moran$p.value
))
}
}
interpretar_validacion("Modelo 1 — Casas, Zona Norte", shapiro1, bp1, vif1, moran1)
La prueba de Shapiro-Wilk rechaza la normalidad de los residuos al 5%. Este resultado debe contrastarse con el gráfico Q-Q, especialmente si la muestra es grande.
La prueba de Breusch-Pagan detecta evidencia de heterocedasticidad al 5%. Se pueden considerar errores estándar robustos o una transformación de la variable respuesta.
El VIF/GVIF ajustado máximo es 1.43. Valores elevados indicarían posible multicolinealidad y justificarían revisar la relación entre predictores.
El Índice de Moran es 0.037 con p = 0.074. Al 5% no se rechaza la hipótesis nula de ausencia de autocorrelación espacial, aunque un resultado cercano al umbral debe interpretarse con cautela.
interpretar_validacion("Modelo 2 — Apartamentos, Zona Sur", shapiro2, bp2, vif2, moran2)
La prueba de Shapiro-Wilk rechaza la normalidad de los residuos al 5%. Este resultado debe contrastarse con el gráfico Q-Q, especialmente si la muestra es grande.
La prueba de Breusch-Pagan detecta evidencia de heterocedasticidad al 5%. Se pueden considerar errores estándar robustos o una transformación de la variable respuesta.
El VIF/GVIF ajustado máximo es 1.61. Valores elevados indicarían posible multicolinealidad y justificarían revisar la relación entre predictores.
El Índice de Moran es 0.180 con p < 0.001, por lo que existe evidencia estadísticamente significativa de autocorrelación espacial de los residuos. En este modelo, la ubicación parece contener estructura que no está siendo capturada completamente por los predictores incluidos. Por ello, los resultados del Modelo 2, incluidas sus predicciones e intervalos de predicción, deben interpretarse con mayor cautela y como una referencia estadística.
Características:
vivienda1_req <- tibble(
areaconst = solicitud1$area,
estrato = factor(
solicitud1$estrato,
levels = levels(base1$estrato)
),
habitaciones = 4,
parqueaderos = 1,
banios = 2
)
pred1 <- predict(
modelo1,
newdata = vivienda1_req,
interval = "prediction",
level = 0.95
)
pred1 %>%
as.data.frame() %>%
kbl(
digits = 2,
caption = "Tabla 27. Predicción para Vivienda 1"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
| fit | lwr | upr |
|---|---|---|
| 317.95 | 11.08 | 624.82 |
p1_prev <- as.numeric(pred1[1, "fit"])
l1_prev <- as.numeric(pred1[1, "lwr"])
u1_prev <- as.numeric(pred1[1, "upr"])
cat(sprintf(
"**Tabla 27.** Para una casa de 200 m², estrato 4, 4 habitaciones, 1 parqueadero y 2 baños en Zona Norte, el Modelo 1 predice un precio puntual de $%.2f millones, con un intervalo de predicción al 95%% entre $%.2f y $%.2f millones. Frente al presupuesto de $%d millones, la estimación puntual queda %s crédito disponible.\n\n",
p1_prev, l1_prev, u1_prev, solicitud1$presupuesto,
ifelse(p1_prev <= solicitud1$presupuesto, "por debajo del", "por encima del")
))
Tabla 27. Para una casa de 200 m², estrato 4, 4 habitaciones, 1 parqueadero y 2 baños en Zona Norte, el Modelo 1 predice un precio puntual de $317.95 millones, con un intervalo de predicción al 95% entre $11.08 y $624.82 millones. Frente al presupuesto de $350 millones, la estimación puntual queda por debajo del crédito disponible.
El intervalo de predicción es apropiado porque se desea estimar el precio de una vivienda individual nueva, no únicamente el precio medio de todas las viviendas con esas características.
La predicción puntual se obtiene mediante:
\[ \hat Y_0=x_0'\hat\beta \]
Bajo el modelo lineal clásico, el intervalo de predicción para una nueva observación se expresa como:
\[ \hat Y_0\pm t_{1-\alpha/2,n-p-1}\,s\sqrt{1+x_0'(X'X)^{-1}x_0} \]
El término adicional 1 dentro de la raíz incorpora la
variabilidad individual de una nueva vivienda, por eso el intervalo de
predicción es más amplio que el intervalo de confianza para el precio
medio.
Si se repitiera el procedimiento de muestreo y estimación bajo las condiciones del modelo clásico, un intervalo de predicción del 95% estaría diseñado para cubrir el precio de una nueva vivienda con esas características aproximadamente el 95% de las veces. No significa que exista una probabilidad del 95% de que el precio de esta vivienda concreta esté dentro del intervalo. Además, como los diagnósticos del presente análisis muestran incumplimientos de homocedasticidad y/o normalidad, la cobertura nominal del 95% no está garantizada. Por eso, el intervalo se utiliza como referencia cuantitativa de incertidumbre y no como una garantía de valoración comercial.
Características:
vivienda2_req <- tibble(
areaconst = solicitud2$area,
estrato = factor(
solicitud2$estrato,
levels = levels(base2$estrato)
),
habitaciones = 5,
parqueaderos = 3,
banios = 3
)
pred2 <- predict(
modelo2,
newdata = vivienda2_req,
interval = "prediction",
level = 0.95
)
pred2 %>%
as.data.frame() %>%
kbl(
digits = 2,
caption = "Tabla 28. Predicción para Vivienda 2"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
| fit | lwr | upr |
|---|---|---|
| 635.03 | 452.27 | 817.79 |
p2_prev <- as.numeric(pred2[1, "fit"])
l2_prev <- as.numeric(pred2[1, "lwr"])
u2_prev <- as.numeric(pred2[1, "upr"])
cat(sprintf(
"**Tabla 28.** Para un apartamento de 300 m², estrato 5, 5 habitaciones, 3 parqueaderos y 3 baños en Zona Sur, el Modelo 2 predice un precio puntual de $%.2f millones, con un intervalo de predicción al 95%% entre $%.2f y $%.2f millones. Frente al presupuesto de $%d millones, tanto la estimación puntual como el %s del intervalo quedan %s crédito disponible.\n\n",
p2_prev, l2_prev, u2_prev, solicitud2$presupuesto,
ifelse(u2_prev <= solicitud2$presupuesto, "límite superior", "límite superior"),
ifelse(u2_prev <= solicitud2$presupuesto, "por debajo del", "por encima del")
))
Tabla 28. Para un apartamento de 300 m², estrato 5, 5 habitaciones, 3 parqueaderos y 3 baños en Zona Sur, el Modelo 2 predice un precio puntual de $635.03 millones, con un intervalo de predicción al 95% entre $452.27 y $817.79 millones. Frente al presupuesto de $850 millones, tanto la estimación puntual como el límite superior del intervalo quedan por debajo del crédito disponible.
Los intervalos de predicción calculados por predict.lm()
son intervalos clásicos y no incorporan la corrección HC3 utilizada para
la inferencia robusta de los coeficientes. Bajo los supuestos clásicos,
un intervalo nominal del 95% está diseñado para cubrir el valor de una
nueva observación en aproximadamente el 95% de repeticiones del
procedimiento. Debido a la heterocedasticidad y la no-normalidad
detectadas en los diagnósticos, esa cobertura nominal no está
garantizada en esta aplicación. Por esto, el intervalo debe utilizarse
como referencia de incertidumbre y no como garantía probabilística
exacta ni como valoración comercial.
La selección debe diferenciar entre:
No se debe ordenar únicamente por precio predicho.
Para evitar que variables medidas en escalas muy diferentes dominen la selección, se utiliza una distancia basada en diferencias relativas respecto a las características solicitadas:
\[ D_i= \sqrt{ \sum_j \left( \frac{x_{ij}-x_{0j}}{x_{0j}} \right)^2 } \]
El área se utiliza primero como filtro de comparabilidad y después se excluye de la distancia de similitud. Así se evita darle doble peso al área, una vez por el filtro de ±15%/±30% y otra vez dentro de la distancia. La distancia final prioriza baños, habitaciones y parqueaderos, mientras que el área sigue siendo una condición explícita de cercanía al inmueble solicitado.
# `solicitud1` ya trae `areaconst` desde el chunk `setup`, no se redefine
# aquí para evitar dos fuentes de verdad que puedan divergir.
area_min1 <- solicitud1$areaconst * 0.85
area_max1 <- solicitud1$areaconst * 1.15
ofertas1_exactas <- base1 %>%
filter(
preciom <= solicitud1$presupuesto,
estrato %in% c("4", "5"),
areaconst >= area_min1,
areaconst <= area_max1,
banios >= solicitud1$banios,
habitaciones >= solicitud1$habitaciones,
parqueaderos >= solicitud1$parqueaderos
)
if (nrow(ofertas1_exactas) > 0) {
ofertas1_exactas$pred_precio <- as.numeric(predict(modelo1, newdata = ofertas1_exactas))
}
# Si el filtro de área deja muy pocas opciones (<5), se amplía de forma
# explícita a ±30%.
if (nrow(ofertas1_exactas) < 5) {
area_min1 <- solicitud1$areaconst * 0.70
area_max1 <- solicitud1$areaconst * 1.30
ofertas1_exactas <- base1 %>%
filter(
preciom <= solicitud1$presupuesto,
estrato %in% c("4", "5"),
areaconst >= area_min1,
areaconst <= area_max1,
banios >= solicitud1$banios,
habitaciones >= solicitud1$habitaciones,
parqueaderos >= solicitud1$parqueaderos
)
if (nrow(ofertas1_exactas) > 0) {
ofertas1_exactas$pred_precio <- as.numeric(predict(modelo1, newdata = ofertas1_exactas))
}
message(
"Menos de 5 ofertas con área ±15%, se amplió el rango a ±30%."
)
}
nrow(ofertas1_exactas)
## [1] 12
ofertas1 <- ofertas1_exactas %>%
mutate(
# El área ya fue utilizada como filtro de comparabilidad,
# se excluye de la distancia para evitar doble ponderación.
dist_similitud = sqrt(
((banios - solicitud1$banios) /
solicitud1$banios)^2 +
((habitaciones - solicitud1$habitaciones) /
solicitud1$habitaciones)^2 +
((parqueaderos - solicitud1$parqueaderos) /
solicitud1$parqueaderos)^2
),
brecha_pct =
(pred_precio - preciom) / preciom * 100
) %>%
arrange(dist_similitud)
ofertas1_5 <- ofertas1 %>%
slice_head(n = 5)
cat("Número de ofertas disponibles después de los filtros para Vivienda 1:", nrow(ofertas1), "\n")
## Número de ofertas disponibles después de los filtros para Vivienda 1: 12
ofertas1_5 <- ofertas1_5 %>%
mutate(
Confiabilidad = case_when(
abs(brecha_pct) <= 15 ~ "Alta — comparable con confianza",
abs(brecha_pct) <= 40 ~ "Media — revisar antes de mostrar",
TRUE ~ "Baja — verificar precio antes de negociar"
)
)
ofertas1_5 %>%
select(
barrio,
estrato,
areaconst,
habitaciones,
banios,
parqueaderos,
preciom,
pred_precio,
brecha_pct,
dist_similitud,
Confiabilidad
) %>%
rename(
Barrio = barrio,
Estrato = estrato,
Área = areaconst,
Habitaciones = habitaciones,
Baños = banios,
Parqueaderos = parqueaderos,
Precio = preciom,
Predicción = pred_precio,
`Brecha %` = brecha_pct,
`Distancia de similitud` = dist_similitud
) %>%
kbl(
digits = 2,
caption = "Tabla 29. Cinco ofertas potenciales — Vivienda 1"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
| Barrio | Estrato | Área | Habitaciones | Baños | Parqueaderos | Precio | Predicción | Brecha % | Distancia de similitud | Confiabilidad |
|---|---|---|---|---|---|---|---|---|---|---|
| la merced | 5 | 216 | 4 | 2 | 2 | 350 | 419.70 | 19.92 | 1.00 | Media — revisar antes de mostrar |
| el bosque | 5 | 202 | 5 | 4 | 1 | 335 | 429.30 | 28.15 | 1.03 | Media — revisar antes de mostrar |
| el bosque | 5 | 203 | 5 | 2 | 2 | 350 | 418.03 | 19.44 | 1.03 | Media — revisar antes de mostrar |
| vipasa | 5 | 203 | 4 | 3 | 2 | 340 | 428.94 | 26.16 | 1.12 | Media — revisar antes de mostrar |
| urbanización la merced | 5 | 210 | 5 | 3 | 2 | 320 | 440.89 | 37.78 | 1.15 | Media — revisar antes de mostrar |
# Se genera ahora a partir de los valores reales de ofertas1_5.
discutir_oferta <- function(fila, solicitud, numero) {
diffs <- c(
habitaciones = fila$habitaciones - solicitud$habitaciones,
banios = fila$banios - solicitud$banios,
parqueaderos = fila$parqueaderos - solicitud$parqueaderos
)
texto_diffs <- names(diffs)[diffs != 0]
frase_diffs <- if (length(texto_diffs) == 0) {
"coincide exactamente con lo solicitado en habitaciones, baños y parqueaderos"
} else {
nombres_es <- c(
habitaciones = "habitaciones",
banios = "baños",
parqueaderos = "parqueaderos"
)
partes <- sapply(texto_diffs, function(v) sprintf("%s %+d", nombres_es[[v]], diffs[[v]]))
paste0("difiere en ", paste(partes, collapse = ", "))
}
lectura_brecha <- if (abs(fila$brecha_pct) <= 15) {
"el precio observado es coherente con lo que predice el modelo"
} else if (fila$brecha_pct > 15) {
"el modelo predice un precio más alto que el de lista, lo que puede ser una oportunidad o mostrar un atributo no observado, conviene verificar"
} else {
"el precio de lista es más alto que lo que predice el modelo, conviene revisar acabados o ubicación exacta antes de asumir sobrevaloración"
}
sprintf(
"%d. **%s** ($%.0f M, brecha %.1f%%): %s. En cuanto a precio, %s.\n",
numero, fila$barrio, fila$preciom, fila$brecha_pct, frase_diffs, lectura_brecha
)
}
for (i in seq_len(nrow(ofertas1_5))) {
cat(discutir_oferta(ofertas1_5[i, ], solicitud1, i))
}
cat("\nTodas las ofertas anteriores están dentro del presupuesto y en la zona norte, con estrato 4 o 5, y cumplen los requisitos mínimos de baños, habitaciones y parqueaderos.\n")
Todas las ofertas anteriores están dentro del presupuesto y en la zona norte, con estrato 4 o 5, y cumplen los requisitos mínimos de baños, habitaciones y parqueaderos.
ofertas1_mapa <- ofertas1_5 %>%
filter(!is.na(longitud), !is.na(latitud))
if (nrow(ofertas1_mapa) > 0) {
leaflet(ofertas1_mapa) %>%
addTiles() %>%
addCircleMarkers(
lng = ~longitud,
lat = ~latitud,
radius = 7,
popup = ~paste0(
"Barrio: ", barrio,
"<br>Precio: $", preciom, " M",
"<br>Área: ", areaconst, " m²"
),
fillOpacity = 0.8
) %>%
addLegend(
position = "bottomright",
colors = "blue",
labels = "Ofertas potenciales — Vivienda 1",
title = "Ofertas seleccionadas"
)
} else {
cat("No se encontraron ofertas que cumplan los filtros establecidos.")
}
if (nrow(ofertas1_mapa) > 0) {
cat(sprintf(
"**Fig. 21.** Ubicación de las %d ofertas de la Tabla 29 que sí cuentan con coordenadas. Permite verificar visualmente su cercanía relativa dentro de Zona Norte antes de presentarlas al cliente.\n\n",
nrow(ofertas1_mapa)
))
}
Fig. 21. Ubicación de las 5 ofertas de la Tabla 29 que sí cuentan con coordenadas. Permite verificar visualmente su cercanía relativa dentro de Zona Norte antes de presentarlas al cliente.
# `solicitud2` ya trae `areaconst` desde el chunk `setup`.
ofertas2_exactas <- base2 %>%
filter(
preciom <= solicitud2$presupuesto,
estrato %in% c("5", "6"),
areaconst >= solicitud2$areaconst * 0.85,
areaconst <= solicitud2$areaconst * 1.15,
banios >= solicitud2$banios,
habitaciones >= solicitud2$habitaciones,
parqueaderos >= solicitud2$parqueaderos
)
cat("Ofertas que cumplen exactamente los requisitos (con área ±15%):", nrow(ofertas2_exactas), "\n")
## Ofertas que cumplen exactamente los requisitos (con área ±15%): 2
Si existen cinco o más ofertas que cumplen los requisitos, se seleccionan las cinco más similares.
Si existen menos de cinco, se relajan los requisitos de forma controlada y documentada.
La relajación se realiza en etapas, priorizando mantener el presupuesto, el estrato y las condiciones mínimas de baños y parqueaderos.
# Etapa 1: requisitos completos y área ±15%.
ofertas2 <- base2 %>%
filter(
preciom <= solicitud2$presupuesto,
estrato %in% c("5", "6"),
areaconst >= solicitud2$areaconst * 0.85,
areaconst <= solicitud2$areaconst * 1.15,
parqueaderos >= solicitud2$parqueaderos,
banios >= solicitud2$banios,
habitaciones >= solicitud2$habitaciones
)
nivel_relajacion2 <- "Sin relajación"
# Etapa 2: si hay menos de 5, se permite área ±30%.
if (nrow(ofertas2) < 5) {
ofertas2 <- base2 %>%
filter(
preciom <= solicitud2$presupuesto,
estrato %in% c("5", "6"),
areaconst >= solicitud2$areaconst * 0.70,
areaconst <= solicitud2$areaconst * 1.30,
parqueaderos >= solicitud2$parqueaderos,
banios >= solicitud2$banios,
habitaciones >= solicitud2$habitaciones
)
nivel_relajacion2 <- "Área ampliada a ±30%"
}
# Etapa 3: si todavía hay menos de 5, se permite un parqueadero menos.
if (nrow(ofertas2) < 5) {
ofertas2 <- base2 %>%
filter(
preciom <= solicitud2$presupuesto,
estrato %in% c("5", "6"),
areaconst >= solicitud2$areaconst * 0.70,
areaconst <= solicitud2$areaconst * 1.30,
parqueaderos >= solicitud2$parqueaderos - 1,
banios >= solicitud2$banios,
habitaciones >= solicitud2$habitaciones
)
nivel_relajacion2 <- "Área ±30% y parqueaderos ≥2"
}
# Etapa 4: si todavía hay menos de 5, se permite una habitación menos.
if (nrow(ofertas2) < 5) {
ofertas2 <- base2 %>%
filter(
preciom <= solicitud2$presupuesto,
estrato %in% c("5", "6"),
areaconst >= solicitud2$areaconst * 0.70,
areaconst <= solicitud2$areaconst * 1.30,
parqueaderos >= solicitud2$parqueaderos - 1,
banios >= solicitud2$banios,
habitaciones >= solicitud2$habitaciones - 1
)
nivel_relajacion2 <- "Área ±30%, parqueaderos ≥2 y habitaciones ≥4"
}
if (nrow(ofertas2) > 0) {
ofertas2$pred_precio <- as.numeric(predict(modelo2, newdata = ofertas2))
}
ofertas2 <- ofertas2 %>%
mutate(
# El área ya fue utilizada como filtro de comparabilidad;
# se excluye de la distancia para evitar doble ponderación.
dist_similitud = sqrt(
((banios - solicitud2$banios) /
solicitud2$banios)^2 +
((habitaciones - solicitud2$habitaciones) /
solicitud2$habitaciones)^2 +
((parqueaderos - solicitud2$parqueaderos) /
solicitud2$parqueaderos)^2
),
brecha_pct =
(pred_precio - preciom) / preciom * 100
) %>%
arrange(dist_similitud)
ofertas2_5 <- ofertas2 %>%
slice_head(n = 5)
cat("Número de ofertas disponibles después de los filtros para Vivienda 2:", nrow(ofertas2), "\n")
## Número de ofertas disponibles después de los filtros para Vivienda 2: 6
cat("Nivel de relajación aplicado: ", nivel_relajacion2)
## Nivel de relajación aplicado: Área ±30% y parqueaderos ≥2
La relajación es condicional, solo se aplica cuando el conjunto de alternativas obtenido en la etapa anterior contiene menos de cinco viviendas. En todas las etapas se conserva el presupuesto máximo, el tipo apartamento, la zona sur, el estrato 5–6 y un mínimo de tres baños. Si incluso después de la última etapa no existen cinco registros, el informe no inventa alternativas, reporta el número realmente disponible.
ofertas2_5 <- ofertas2_5 %>%
mutate(
Confiabilidad = case_when(
abs(brecha_pct) <= 15 ~ "Alta — comparable con confianza",
abs(brecha_pct) <= 40 ~ "Media — revisar antes de mostrar",
TRUE ~ "Baja — verificar precio antes de negociar"
)
)
ofertas2_5 %>%
select(
barrio,
estrato,
areaconst,
habitaciones,
banios,
parqueaderos,
preciom,
pred_precio,
brecha_pct,
dist_similitud,
Confiabilidad
) %>%
rename(
Barrio = barrio,
Estrato = estrato,
Área = areaconst,
Habitaciones = habitaciones,
Baños = banios,
Parqueaderos = parqueaderos,
Precio = preciom,
Predicción = pred_precio,
`Brecha %` = brecha_pct,
`Distancia de similitud` = dist_similitud
) %>%
kbl(
digits = 2,
caption = "Tabla 30. Cinco ofertas potenciales — Vivienda 2"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
| Barrio | Estrato | Área | Habitaciones | Baños | Parqueaderos | Precio | Predicción | Brecha % | Distancia de similitud | Confiabilidad |
|---|---|---|---|---|---|---|---|---|---|---|
| el ingenio | 6 | 250 | 5 | 4 | 2 | 700 | 704.07 | 0.58 | 0.47 | Alta — comparable con confianza |
| San Fernando | 5 | 258 | 5 | 4 | 2 | 350 | 560.84 | 60.24 | 0.47 | Baja — verificar precio antes de negociar |
| seminario | 5 | 256 | 5 | 5 | 3 | 530 | 662.36 | 24.97 | 0.67 | Media — revisar antes de mostrar |
| seminario | 5 | 300 | 6 | 5 | 3 | 670 | 701.83 | 4.75 | 0.70 | Alta — comparable con confianza |
| ciudadela pasoancho | 5 | 275 | 5 | 5 | 2 | 650 | 624.65 | -3.90 | 0.75 | Alta — comparable con confianza |
cat(sprintf(
"Dado que el mercado de apartamentos en zona sur con los requisitos exactos es limitado, se aplicó una relajación controlada: %s. Las ofertas se priorizan por distancia de similitud, de menor a mayor:\n\n",
nivel_relajacion2
))
Dado que el mercado de apartamentos en zona sur con los requisitos exactos es limitado, se aplicó una relajación controlada: Área ±30% y parqueaderos ≥2. Las ofertas se priorizan por distancia de similitud, de menor a mayor:
for (i in seq_len(nrow(ofertas2_5))) {
cat(discutir_oferta(ofertas2_5[i, ], solicitud2, i))
}
cat(
"\n**Nivel de relajación aplicado:** ",
nivel_relajacion2,
".\n\n",
"Las modificaciones de requisitos se aplican únicamente cuando el filtro anterior ",
"no permite obtener cinco alternativas. El presupuesto y el estrato se mantienen como ",
"restricciones obligatorias en todas las etapas."
)
Nivel de relajación aplicado: Área ±30% y parqueaderos ≥2 .
Las modificaciones de requisitos se aplican únicamente cuando el filtro anterior no permite obtener cinco alternativas. El presupuesto y el estrato se mantienen como restricciones obligatorias en todas las etapas.
ofertas2_mapa <- ofertas2_5 %>%
filter(!is.na(longitud), !is.na(latitud))
if (nrow(ofertas2_mapa) > 0) {
leaflet(ofertas2_mapa) %>%
addTiles() %>%
addCircleMarkers(
lng = ~longitud,
lat = ~latitud,
radius = 7,
color = "purple",
fillColor = "purple",
popup = ~paste0(
"Barrio: ", barrio,
"<br>Precio: $", preciom, " M",
"<br>Área: ", areaconst, " m²"
),
fillOpacity = 0.8
) %>%
addLegend(
position = "bottomright",
colors = "purple",
labels = "Ofertas potenciales — Vivienda 2",
title = "Ofertas seleccionadas"
)
} else {
cat("No se encontraron ofertas para la Vivienda 2 con los criterios establecidos.")
}
if (nrow(ofertas2_mapa) > 0) {
cat(sprintf(
"**Fig. 22.** Ubicación de las %d ofertas de la Tabla 30 que cuentan con coordenadas, equivalente a la Fig. 21 pero para Vivienda 2 en Zona Sur.\n\n",
nrow(ofertas2_mapa)
))
}
Fig. 22. Ubicación de las 5 ofertas de la Tabla 30 que cuentan con coordenadas, equivalente a la Fig. 21 pero para Vivienda 2 en Zona Sur.
Esta tabla concentra los resultados que deben consultarse antes de revisar las ofertas. Todos los valores se generan directamente desde los objetos del modelo y de las predicciones.
pred1_tablero <- as.data.frame(pred1)
pred2_tablero <- as.data.frame(pred2)
if (!all(c("fit", "lwr", "upr") %in% names(pred1_tablero))) {
stop("pred1 no contiene las columnas fit, lwr y upr requeridas para el tablero.")
}
if (!all(c("fit", "lwr", "upr") %in% names(pred2_tablero))) {
stop("pred2 no contiene las columnas fit, lwr y upr requeridas para el tablero.")
}
p1 <- as.numeric(pred1_tablero$fit[1])
l1 <- as.numeric(pred1_tablero$lwr[1])
u1 <- as.numeric(pred1_tablero$upr[1])
p2 <- as.numeric(pred2_tablero$fit[1])
l2 <- as.numeric(pred2_tablero$lwr[1])
u2 <- as.numeric(pred2_tablero$upr[1])
n_ofertas1_tablero <- if (exists("ofertas1_5", inherits = TRUE)) nrow(ofertas1_5) else NA_integer_
n_ofertas2_tablero <- if (exists("ofertas2_5", inherits = TRUE)) nrow(ofertas2_5) else NA_integer_
texto_ofertas1_tablero <- ifelse(is.na(n_ofertas1_tablero), "Se detallan más adelante", as.character(n_ofertas1_tablero))
texto_ofertas2_tablero <- ifelse(is.na(n_ofertas2_tablero), "Se detallan más adelante", as.character(n_ofertas2_tablero))
tablero <- tibble(
Indicador = c(
"Precio estimado",
"Presupuesto",
"Margen frente al presupuesto",
"Intervalo de predicción 95%",
"¿Estimación puntual dentro del presupuesto?",
"¿Límite superior dentro del presupuesto?",
"Ofertas seleccionadas"
),
`Vivienda 1 — Casa Norte` = c(
sprintf("$%.1f M", p1),
sprintf("$%d M", solicitud1$presupuesto),
sprintf("$%.1f M", solicitud1$presupuesto - p1),
sprintf("$%.1f–$%.1f M", l1, u1),
ifelse(p1 <= solicitud1$presupuesto, "Sí", "No"),
ifelse(u1 <= solicitud1$presupuesto, "Sí", "No"),
texto_ofertas1_tablero
),
`Vivienda 2 — Apartamento Sur` = c(
sprintf("$%.1f M", p2),
sprintf("$%d M", solicitud2$presupuesto),
sprintf("$%.1f M", solicitud2$presupuesto - p2),
sprintf("$%.1f–$%.1f M", l2, u2),
ifelse(p2 <= solicitud2$presupuesto, "Sí", "No"),
ifelse(u2 <= solicitud2$presupuesto, "Sí", "No"),
texto_ofertas2_tablero
)
)
tablero %>%
kbl(align = c("l", "c", "c"), caption = "Tabla 31. Resumen ejecutivo de estimaciones, incertidumbre y presupuesto") %>%
kable_styling(bootstrap_options = c("striped", "hover", "responsive"), full_width = FALSE)
| Indicador | Vivienda 1 — Casa Norte | Vivienda 2 — Apartamento Sur |
|---|---|---|
| Precio estimado | $318.0 M | $635.0 M |
| Presupuesto | $350 M | $850 M |
| Margen frente al presupuesto | $32.0 M | $215.0 M |
| Intervalo de predicción 95% | $11.1–$624.8 M | $452.3–$817.8 M |
| ¿Estimación puntual dentro del presupuesto? | Sí | Sí |
| ¿Límite superior dentro del presupuesto? | No | Sí |
| Ofertas seleccionadas | 5 | 5 |
cat(sprintf(
"**Tabla 31.** Vivienda 1: precio estimado $%.1f M frente a un presupuesto de $%d M (margen de $%.1f M), su límite superior de $%.1f M %s el presupuesto. Vivienda 2: precio estimado $%.1f M frente a un presupuesto de $%d M (margen de $%.1f M), su límite superior de $%.1f M %s presupuesto. Vivienda 2 cuenta con un margen presupuestal proporcionalmente %s que Vivienda 1.\n\n",
p1, solicitud1$presupuesto, solicitud1$presupuesto - p1, u1,
ifelse(u1 <= solicitud1$presupuesto, "sí cabe dentro del", "supera"),
p2, solicitud2$presupuesto, solicitud2$presupuesto - p2, u2,
ifelse(u2 <= solicitud2$presupuesto, "sí cabe dentro del", "supera"),
ifelse((solicitud2$presupuesto - p2) / solicitud2$presupuesto > (solicitud1$presupuesto - p1) / solicitud1$presupuesto, "mayor", "menor")
))
Tabla 31. Vivienda 1: precio estimado $318.0 M frente a un presupuesto de $350 M (margen de $32.0 M), su límite superior de $624.8 M supera el presupuesto. Vivienda 2: precio estimado $635.0 M frente a un presupuesto de $850 M (margen de $215.0 M), su límite superior de $817.8 M sí cabe dentro del presupuesto. Vivienda 2 cuenta con un margen presupuestal proporcionalmente mayor que Vivienda 1.
tabla_modelos <- bind_rows(
glance(modelo1) %>%
transmute(
Modelo = "Casas — Zona Norte",
n = nobs,
R2 = r.squared,
R2_ajustado = adj.r.squared,
Error_residual = sigma
),
glance(modelo2) %>%
transmute(
Modelo = "Apartamentos — Zona Sur",
n = nobs,
R2 = r.squared,
R2_ajustado = adj.r.squared,
Error_residual = sigma
)
)
tabla_modelos %>%
kbl(
digits = 4,
caption = "Tabla 32. Comparación de los modelos"
) %>%
kable_styling(
bootstrap_options = c("striped", "hover"),
full_width = FALSE
)
| Modelo | n | R2 | R2_ajustado | Error_residual |
|---|---|---|---|---|
| Casas — Zona Norte | 435 | 0.6070 | 0.6006 | 154.8969 |
| Apartamentos — Zona Sur | 2381 | 0.7762 | 0.7755 | 92.5165 |
cat(sprintf(
"**Tabla 32.** El Modelo 2 (apartamentos, n = %d) tiene un R² de %.4f y un error residual (sigma) de %.2f millones, frente al Modelo 1 (casas, n = %d), con R² de %.4f y error residual de %.2f millones. Aunque el Modelo 2 ajusta mejor en términos relativos, su error residual absoluto también es %s en unidades monetarias, ambos indicadores calculados sobre poblaciones y tamaños muestrales distintos, por lo que no deben leerse como una competencia directa entre modelos.\n\n",
tabla_modelos$n[2], tabla_modelos$R2[2], tabla_modelos$Error_residual[2],
tabla_modelos$n[1], tabla_modelos$R2[1], tabla_modelos$Error_residual[1],
ifelse(tabla_modelos$Error_residual[2] < tabla_modelos$Error_residual[1], "menor", "mayor")
))
Tabla 32. El Modelo 2 (apartamentos, n = 2381) tiene un R² de 0.7762 y un error residual (sigma) de 92.52 millones, frente al Modelo 1 (casas, n = 435), con R² de 0.6070 y error residual de 154.90 millones. Aunque el Modelo 2 ajusta mejor en términos relativos, su error residual absoluto también es menor en unidades monetarias, ambos indicadores calculados sobre poblaciones y tamaños muestrales distintos, por lo que no deben leerse como una competencia directa entre modelos.
El R² del modelo de casas y el R² del modelo de apartamentos no constituyen por sí solos una competencia entre modelos, porque se estiman sobre poblaciones distintas.
pred1_df <- as.data.frame(pred1)
if (nrow(pred1_df) < 1L || !all(c("fit", "lwr", "upr") %in% names(pred1_df))) {
stop("La predicción de la Vivienda 1 no contiene una fila válida con las columnas fit, lwr y upr. Revise vivienda1_req y modelo1.")
}
p1 <- as.numeric(pred1_df$fit[1])
l1 <- as.numeric(pred1[1, "lwr"])
u1 <- as.numeric(pred1[1, "upr"])
presupuesto1 <- solicitud1$presupuesto
margen_puntual1 <- presupuesto1 - p1
margen_conservador1 <- presupuesto1 - u1
alerta1 <- if (margen_conservador1 < 0) {
sprintf(
"El límite superior del intervalo de predicción supera el presupuesto en $%.1f millones. Aunque la estimación puntual se encuentra dentro del presupuesto, el intervalo es compatible con precios individuales superiores al crédito disponible.",
abs(margen_conservador1)
)
} else {
sprintf(
"El límite superior del intervalo de predicción permanece por debajo del presupuesto, con una diferencia de $%.1f millones frente al crédito disponible.",
margen_conservador1
)
}
cat(sprintf(
"**Precio estimado:** $%.1f millones. \n**Intervalo de predicción 95%%:** $%.1f a $%.1f millones. \n**Presupuesto:** $%.0f millones. \n**Margen (estimación puntual):** $%.1f millones. \n**Margen respecto al límite superior del intervalo:** $%.1f millones. \n\n%s",
p1,
l1,
u1,
presupuesto1,
margen_puntual1,
margen_conservador1,
alerta1
))
Precio estimado: $318.0 millones.
Intervalo de predicción 95%: $11.1 a $624.8
millones.
Presupuesto: $350 millones.
Margen (estimación puntual): $32.0 millones.
Margen respecto al límite superior del intervalo:
$-274.8 millones.
El límite superior del intervalo de predicción supera el presupuesto en $274.8 millones. Aunque la estimación puntual se encuentra dentro del presupuesto, el intervalo es compatible con precios individuales superiores al crédito disponible.
La recomendación debe considerar el precio estimado junto con las ofertas seleccionadas. Las ofertas deben verificarse antes de presentarse como opciones definitivas.
pred2_df <- as.data.frame(pred2)
if (nrow(pred2_df) < 1L || !all(c("fit", "lwr", "upr") %in% names(pred2_df))) {
stop("La predicción de la Vivienda 2 no contiene una fila válida con las columnas fit, lwr y upr. Revise vivienda2_req y modelo2.")
}
p2 <- as.numeric(pred2_df$fit[1])
l2 <- as.numeric(pred2[1, "lwr"])
u2 <- as.numeric(pred2[1, "upr"])
presupuesto2 <- solicitud2$presupuesto
margen_puntual2 <- presupuesto2 - p2
margen_conservador2 <- presupuesto2 - u2
alerta2 <- if (margen_conservador2 < 0) {
sprintf(
"El límite superior del intervalo de predicción supera el presupuesto en $%.1f millones.",
abs(margen_conservador2)
)
} else {
sprintf(
"El límite superior del intervalo de predicción permanece por debajo del presupuesto, con una diferencia de $%.1f millones frente al crédito disponible.",
margen_conservador2
)
}
cat(sprintf(
"**Precio estimado:** $%.1f millones. \n**Intervalo de predicción 95%%:** $%.1f a $%.1f millones. \n**Presupuesto:** $%.0f millones. \n**Margen (estimación puntual):** $%.1f millones. \n**Margen respecto al límite superior del intervalo:** $%.1f millones. \n\n%s",
p2,
l2,
u2,
presupuesto2,
margen_puntual2,
margen_conservador2,
alerta2
))
Precio estimado: $635.0 millones.
Intervalo de predicción 95%: $452.3 a $817.8
millones.
Presupuesto: $850 millones.
Margen (estimación puntual): $215.0 millones.
Margen respecto al límite superior del intervalo: $32.2
millones.
El límite superior del intervalo de predicción permanece por debajo del presupuesto, con una diferencia de $32.2 millones frente al crédito disponible.
Si fue necesario relajar requisitos para encontrar cinco alternativas, debe indicarse explícitamente al cliente cuáles fueron relajados.
presupuesto1 <- solicitud1$presupuesto
presupuesto2 <- solicitud2$presupuesto
p1 <- as.numeric(pred1[1, "fit"])
l1 <- as.numeric(pred1[1, "lwr"])
u1 <- as.numeric(pred1[1, "upr"])
p2 <- as.numeric(pred2[1, "fit"])
l2 <- as.numeric(pred2[1, "lwr"])
u2 <- as.numeric(pred2[1, "upr"])
cat(sprintf(
"## Caso 1 — Casa, Zona Norte\n\n**Precio estimado:** $%.1f millones. \n**Intervalo de predicción 95%%:** $%.1f–$%.1f millones. \n**Presupuesto:** $%.0f millones. \n**Ofertas potenciales identificadas:** %d. \n\n",
p1, l1, u1, presupuesto1, nrow(ofertas1_5)
))
Precio estimado: $318.0 millones.
Intervalo de predicción 95%: $11.1–$624.8
millones.
Presupuesto: $350 millones.
Ofertas potenciales identificadas: 5.
if (u1 > presupuesto1) {
cat(sprintf(
"La estimación puntual se encuentra dentro del presupuesto, pero el límite superior del intervalo supera el crédito disponible en $%.1f millones. Por tanto, el procedimiento de predicción no permite descartar un precio individual superior al crédito, la decisión debe condicionarse a la verificación comercial y a un margen de negociación suficiente.\n\n",
u1 - presupuesto1
))
} else {
cat("Tanto la estimación puntual como el límite superior del intervalo se encuentran dentro del presupuesto. Esto es compatible con el presupuesto bajo el procedimiento de predicción utilizado, pero no constituye una garantía sobre el precio de una vivienda individual, las ofertas deben verificarse antes de una negociación.\n\n")
}
La estimación puntual se encuentra dentro del presupuesto, pero el límite superior del intervalo supera el crédito disponible en $274.8 millones. Por tanto, el procedimiento de predicción no permite descartar un precio individual superior al crédito, la decisión debe condicionarse a la verificación comercial y a un margen de negociación suficiente.
cat(sprintf(
"## Caso 2 — Apartamento, Zona Sur\n\n**Precio estimado:** $%.1f millones. \n**Intervalo de predicción 95%%:** $%.1f–$%.1f millones. \n**Presupuesto:** $%.0f millones. \n**Ofertas potenciales identificadas:** %d. \n\n",
p2, l2, u2, presupuesto2, nrow(ofertas2_5)
))
Precio estimado: $635.0 millones.
Intervalo de predicción 95%: $452.3–$817.8
millones.
Presupuesto: $850 millones.
Ofertas potenciales identificadas: 5.
if (u2 > presupuesto2) {
cat(sprintf(
"El límite superior del intervalo supera el crédito disponible en $%.1f millones. Además, el Modelo 2 presenta evidencia estadísticamente significativa de autocorrelación espacial de los residuos según Moran's I; por tanto, la predicción debe considerarse una referencia estadística y la decisión debe manejarse con especial cautela, incorporando la ubicación específica y la verificación comercial de las ofertas.\n\n",
u2 - presupuesto2
))
} else {
cat("El límite superior del intervalo de predicción se mantiene dentro del crédito disponible. Sin embargo, el Modelo 2 presenta evidencia estadísticamente significativa de autocorrelación espacial de los residuos según Moran's I. Por esta razón, la estimación de $635 millones y su intervalo de predicción deben interpretarse con mayor cautela, son una referencia estadística compatible con el presupuesto, pero no una garantía del precio individual. Se recomienda complementar la decisión con la ubicación específica del inmueble y verificar comercialmente las ofertas antes de negociar.\n\n")
}
El límite superior del intervalo de predicción se mantiene dentro del crédito disponible. Sin embargo, el Modelo 2 presenta evidencia estadísticamente significativa de autocorrelación espacial de los residuos según Moran’s I. Por esta razón, la estimación de $635 millones y su intervalo de predicción deben interpretarse con mayor cautela, son una referencia estadística compatible con el presupuesto, pero no una garantía del precio individual. Se recomienda complementar la decisión con la ubicación específica del inmueble y verificar comercialmente las ofertas antes de negociar.
cat("Los modelos son una herramienta de apoyo a la decisión. La selección final debe combinar precio, características, presupuesto, intervalo de predicción, similitud, ubicación y validación comercial.\n")
Los modelos son una herramienta de apoyo a la decisión. La selección final debe combinar precio, características, presupuesto, intervalo de predicción, similitud, ubicación y validación comercial.
No se debe presentar una oferta como recomendación definitiva únicamente porque su precio esté por debajo del modelo. Debe cumplir los filtros definidos, ser suficientemente similar a la solicitud, tener una brecha de precio razonable y superar la verificación de ubicación y consistencia de los datos.
Los modelos de regresión permiten construir una referencia cuantitativa para apoyar la decisión de compra de las dos viviendas solicitadas.
La recomendación final debe combinar:
En particular, el Modelo 2 debe interpretarse con mayor cautela si presenta evidencia estadísticamente significativa de autocorrelación espacial. En ese caso, una extensión mediante variables geográficas más detalladas o modelos espaciales puede mejorar la especificación.
Por tanto, el modelo debe utilizarse como una herramienta de apoyo a la decisión, y no como sustituto de una valoración inmobiliaria profesional.