1 Informe Ejecutivo

1.1 Objetivo

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.

1.2 Metodología

Se construyen dos modelos independientes:

  • Modelo 1: casas de la zona norte.
  • Modelo 2: apartamentos de la zona sur.

La variable respuesta es preciom, expresada en millones de pesos, y las variables explicativas son:

  • Área construida.
  • Estrato.
  • Habitaciones.
  • Parqueaderos.
  • Baños.

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:

  1. Carga y revisión de datos.
  2. Depuración y tratamiento de faltantes.
  3. Construcción de las bases correspondientes a cada solicitud.
  4. Verificación descriptiva y geográfica.
  5. Análisis exploratorio.
  6. Estimación de los modelos.
  7. Validación de supuestos.
  8. Predicción de las viviendas solicitadas.
  9. Selección y comparación de ofertas.
  10. Recomendaciones ejecutivas.

1.3 Resultados principales

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:

  1. ¿Cuál es el precio puntual estimado?
  2. ¿Cuál es su intervalo de predicción al 95%?
  3. ¿Qué margen existe frente al crédito disponible?
  4. ¿El límite superior del intervalo permanece dentro del presupuesto?

Esta estructura permite que el informe siga siendo reproducible si cambian los datos o la especificación del modelo.

1.3.1 Vivienda 1 - Casa, zona norte

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.

1.3.2 Vivienda 2 - Apartamento, zona sur

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.

1.3.3 Lectura ejecutiva

  • La estimación puntual indica el precio esperado según las características incluidas en el modelo.
  • El intervalo de predicción incorpora la incertidumbre asociada a una vivienda individual y, por ello, debe considerarse para una decisión conservadora.
  • Una estimación puntual inferior al presupuesto no garantiza que una vivienda individual pueda adquirirse por ese valor.
  • La selección de ofertas se realiza posteriormente combinando restricciones, similitud, precio observado y diagnóstico de calidad.

2 Preparación de los Datos

2.1 Instalación y librerías

# 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")

2.2 Inspección inicial

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.

2.3 Tipificación de variables

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.

2.4 Tratamiento de valores faltantes

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)
Tabla 1. Valores faltantes por variable
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.

2.4.1 Faltantes en parqueaderos

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)
Tabla 2. Distribución de NA en parqueaderos por tipo y zona
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

3 Construcción de las Bases de Análisis

3.1 Base 1 — Casas, zona norte

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)
Tabla 3. Primeros tres registros — Casas, Zona Norte
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)
Tabla 4. Verificación del filtro — Casas, Zona Norte
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.

3.2 Base 2 — Apartamentos, zona sur

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)
Tabla 5. Primeros tres registros — Apartamentos, Zona Sur
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)
Tabla 6. Verificación del filtro — Apartamentos, Zona Sur
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.

4 Análisis Geográfico

4.1 Mapa de casas de zona norte

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.

4.2 Mapa de apartamentos de zona sur

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.

4.3 Verificación de coordenadas (atípicos internos)

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

4.3.1 Interpretación del diagnóstico geográfico

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.

4.4 Verificación cruzada de zona

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)
Tabla 7. Centroide de referencia por zona (base completa)
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)
}
Tabla 8. Casas ‘Zona Norte’ - a qué zona resultan geográficamente más cercanas
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)
}
Tabla 9. Apartamentos ‘Zona Sur’ — a qué zona resultan geográficamente más cercanos
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.

4.5 Conclusión geográfica

# 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.

5 Comparación Descriptiva por Zona

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.

6 Análisis Exploratorio de Datos

6.1 Estadísticos descriptivos

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)
Tabla 10. Estadísticos descriptivos — Modelo 1
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)
Tabla 11. Estadísticos descriptivos — Modelo 2
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.

6.2 Correlación entre variables cuantitativas

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)
Tabla 12. Matriz de correlación — Casas, Zona Norte
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)
Tabla 13. Matriz de correlación — Apartamentos, Zona Sur
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.

6.3 Precio frente al área

6.3.1 Modelo 1

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.

6.3.2 Modelo 2

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.

6.4 Precio según estrato

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.

6.5 Precio frente a baños y habitaciones

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.

7 Modelo de Regresión Lineal Múltiple

7.1 Especificación

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 \]

7.2 Estimación de los modelos

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

7.3 Ecuaciones estimadas e interpretación del modelo

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")

7.3.1 Modelo 1 — Casas, Zona Norte

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")

7.3.2 Modelo 2 — Apartamentos, Zona Sur

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.

7.4 Significancia individual y contexto económico

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.

7.5 Coeficientes estimados

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
  )
Tabla 14. Coeficientes — Modelo 1: Casas, Zona Norte
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
  )
Tabla 15. Coeficientes — Modelo 2: Apartamentos, Zona Sur
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.

7.5.1 Inferencia robusta HC3 para los coeficientes

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)
Tabla 16. Inferencia robusta HC3 — Modelo 1
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)
Tabla 17. Inferencia robusta HC3 — Modelo 2
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.

7.5.2 Interpretación contextualizada de los coeficientes

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)

7.5.3 Modelo 1 — Casas, Zona Norte

  • Área construida: manteniendo constantes las demás variables, un aumento de 1 m² se asocia con un cambio promedio de $0.68 millones en el precio estimado (p < 0.001).
  • Estrato 4: respecto a la categoría de referencia del factor 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 5: respecto a la categoría de referencia del factor 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 6: respecto a la categoría de referencia del factor 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).
  • Parqueaderos: manteniendo constantes las demás variables, un parqueadero adicional se asocia con un cambio promedio de $24.23 millones en el precio estimado (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)

7.5.4 Modelo 2 — Apartamentos, Zona Sur

  • Área construida: manteniendo constantes las demás variables, un aumento de 1 m² se asocia con un cambio promedio de $1.29 millones en el precio estimado (p < 0.001).
  • Estrato 4: respecto a la categoría de referencia del factor 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 5: respecto a la categoría de referencia del factor 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 6: respecto a la categoría de referencia del factor 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).
  • Habitaciones: manteniendo constantes área, estrato, parqueaderos y baños, una habitación adicional se asocia con un cambio promedio de $-17.11 millones en el precio estimado (p = 0.010). Esta asociación debe interpretarse con cautela y no como causalidad.
  • Parqueaderos: manteniendo constantes las demás variables, un parqueadero adicional se asocia con un cambio promedio de $62.14 millones en el precio estimado (p < 0.001).
  • Baños: manteniendo constantes las demás variables, un baño adicional se asocia con un cambio promedio de $41.95 millones en el precio estimado (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.

7.5.5 Regla de interpretación

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.

7.5.6 Inferencia clásica frente a errores estándar HC3

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.

  • La inferencia clásica utiliza los errores estándar convencionales de lm() y supone, entre otras condiciones, varianza constante de los errores.
  • La inferencia HC3 modifica los errores estándar para hacerlos más robustos frente a heterocedasticidad y es la referencia preferida para interpretar la significancia individual cuando la prueba de Breusch-Pagan evidencia varianza no constante.

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.

7.6 Prueba F global

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
  )
Tabla 18. Prueba F global — Modelo 1
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
  )
Tabla 19. Prueba F global — Modelo 2
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.

7.7 R² y R² ajustado

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
  )
Tabla 20. Indicadores generales de los modelos
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.

8 Validación de Supuestos

8.1 Linealidad

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.

8.2 Normalidad

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.

8.3 Homocedasticidad

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})\).

8.4 Multicolinealidad

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")

8.4.1 VIF — Modelo 1

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)
Tabla 21. Factor de inflación de varianza (VIF) — Modelo 1
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")

8.4.2 VIF — Modelo 2

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)
Tabla 22. Factor de inflación de varianza (VIF) — Modelo 2
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.

8.5 Independencia y dependencia espacial

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

8.5.1 Interpretación de Moran’s I

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.

8.6 Observaciones influyentes

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"
  )
}
Tabla 23. Observaciones con distancia de Cook extrema (D > 1) — Modelo 2
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.

8.6.1 Prueba de sensibilidad para la observación más influyente del Modelo 2

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"
  )
}
Tabla 25. Sensibilidad del coeficiente de ‘habitaciones’ con y sin la observación más influyente
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.

8.7 Resumen de validación

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
  )
Tabla 26. Resumen de validación de supuestos
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.

8.7.1 Interpretación de la validació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)

8.7.2 Modelo 1 — Casas, Zona Norte

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)

8.7.3 Modelo 2 — Apartamentos, Zona Sur

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.

9 Predicción de las Viviendas Solicitadas

9.1 Vivienda 1

Características:

  • 200 m²;
  • estrato 4;
  • 4 habitaciones;
  • 1 parqueadero;
  • 2 baños.
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
  )
Tabla 27. Predicción para Vivienda 1
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.

9.2 Vivienda 2

Características:

  • 300 m²;
  • estrato 5;
  • 5 habitaciones;
  • 3 parqueaderos;
  • 3 baños.
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
  )
Tabla 28. Predicción para Vivienda 2
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.

10 Selección de Ofertas

10.1 Principio de selección

La selección debe diferenciar entre:

  1. Requisitos obligatorios.
  2. Presupuesto máximo.
  3. Similitud con la vivienda solicitada.
  4. Precio observado frente al precio estimado.

No se debe ordenar únicamente por precio predicho.

10.1.1 Criterio de similitud

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.

10.2 Vivienda 1 — filtros exactos

# `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

10.3 Vivienda 1 — distancia de similitud

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

10.3.1 Tabla

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
  )
Tabla 29. Cinco ofertas potenciales — Vivienda 1
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

10.3.2 Discusión y priorización de ofertas

# 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))
}
  1. la merced ($350 M, brecha 19.9%): difiere en parqueaderos +1. En cuanto a precio, 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.
  2. el bosque ($335 M, brecha 28.1%): difiere en habitaciones +1, baños +2. En cuanto a precio, 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.
  3. el bosque ($350 M, brecha 19.4%): difiere en habitaciones +1, parqueaderos +1. En cuanto a precio, 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.
  4. vipasa ($340 M, brecha 26.2%): difiere en baños +1, parqueaderos +1. En cuanto a precio, 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.
  5. urbanización la merced ($320 M, brecha 37.8%): difiere en habitaciones +1, baños +1, parqueaderos +1. En cuanto a precio, 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.
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.

10.3.3 Mapa

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.

10.4 Vivienda 2 — comprobación de requisitos exactos

# `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.

10.5 Vivienda 2 — relajación controlada

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.

10.5.1 Tabla

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
  )
Tabla 30. Cinco ofertas potenciales — Vivienda 2
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

10.5.2 Discusión y priorización de ofertas

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))
}
  1. el ingenio ($700 M, brecha 0.6%): difiere en baños +1, parqueaderos -1. En cuanto a precio, el precio observado es coherente con lo que predice el modelo.
  2. San Fernando ($350 M, brecha 60.2%): difiere en baños +1, parqueaderos -1. En cuanto a precio, 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.
  3. seminario ($530 M, brecha 25.0%): difiere en baños +2. En cuanto a precio, 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.
  4. seminario ($670 M, brecha 4.8%): difiere en habitaciones +1, baños +2. En cuanto a precio, el precio observado es coherente con lo que predice el modelo.
  5. ciudadela pasoancho ($650 M, brecha -3.9%): difiere en baños +2, parqueaderos -1. En cuanto a precio, el precio observado es coherente con lo que predice el modelo.
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.

10.5.3 Mapa

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.

11 Tablero Ejecutivo de Resultados

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)
Tabla 31. Resumen ejecutivo de estimaciones, incertidumbre y presupuesto
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?
¿Límite superior dentro del presupuesto? No
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.

11.0.1 Lectura del tablero

  • Estimación puntual: representa el valor central producido por el modelo para las características solicitadas.
  • Margen presupuestal: diferencia entre el presupuesto disponible y la estimación puntual.
  • Límite superior: si supera el presupuesto, existe incertidumbre estadística compatible con un precio individual superior al crédito disponible.
  • Ofertas seleccionadas: corresponde al número realmente obtenido después de aplicar el procedimiento de filtros y relajación condicional.

12 Comparación Final de los Modelos

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
  )
Tabla 32. Comparación de los modelos
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.

13 Recomendaciones Ejecutivas

13.1 Vivienda 1

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.

13.2 Vivienda 2

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.

14 Resumen Ejecutivo Final

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)
))

14.1 Caso 1 — Casa, Zona Norte

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)
))

14.2 Caso 2 — Apartamento, Zona Sur

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.

15 Limitaciones

  1. La base cubre el periodo disponible en los datos analizados, sin información temporal adicional no debe interpretarse como una descripción del mercado ni necesariamente como una referencia del mercado actual.
  2. La eliminación de observaciones con valores faltantes puede introducir sesgo si los faltantes no son aleatorios.
  3. Se pueden presentar valores atípicos e influyentes.
  4. Los supuestos clásicos pueden presentar incumplimientos.
  5. La información disponible no incorpora necesariamente variables relevantes como antigüedad, acabados, ubicación exacta dentro del barrio, seguridad o cercanía a servicios.
  6. Puede existir dependencia espacial entre viviendas cercanas, esta debe evaluarse mediante los resultados de Moran’s I.
  7. La predicción estadística no es una valoración comercial definitiva.
  8. Las ofertas identificadas en la base deben ser verificadas antes de iniciar una negociación, especialmente aquellas marcadas con confiabilidad “Media” o “Baja”.
  9. El margen presupuestal debe evaluarse tanto con la estimación puntual como con el intervalo de predicción.
  10. Los criterios de selección de ofertas son reglas operativas para apoyar la decisión y no sustituyen una valoración inmobiliaria profesional.
  11. La sección “Análisis geográfico” incluye dos chequeos con propósitos distintos, uno detecta atípicos internos a la propia muestra (no compara contra otras zonas) y otro compara cada vivienda contra el centroide de las cinco zonas de la ciudad. Este segundo criterio asume zonas razonablemente compactas, lo cual es una simplificación, cualquier inconsistencia detectada debe verificarse contra la dirección o el anuncio original antes de excluir un registro.

16 Conclusión

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:

  • Características solicitadas.
  • Presupuesto disponible.
  • Precio estimado.
  • Intervalo de predicción.
  • Similitud de las ofertas.
  • Confiabilidad de precio de cada oferta.
  • Ubicación geográfica.
  • Diagnóstico de calidad de los datos.
  • Validación comercial de las alternativas.

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.