# Datos
library(paqueteMODELOS)  # contiene el dataset "vivienda"
library(dplyr) # Manipulación de datos
library(ggplot2)
library(plotly)          # gráficos interactivos
library(leaflet)         # mapas interactivos 
library(car)             # VIF, pruebas de homocedasticidad, etc.
library(lmtest)          # bptest (Breusch-Pagan), dwtest (Durbin-Watson)
library(knitr)
library(kableExtra)      # tablas con mejor formato
library(stringr)

1 Introducción

Este análisis se apoya en la base de datos vivienda del paquete paqueteMODELOS, la cual ya fue objeto de un análisis exploratorio previo en el marco de un ejercicio de Análisis de Componentes Principales, en el que se identificaron y trataron diversas inconsistencias en los datos: valores faltantes concentrados principalmente en las variables piso y parqueaderos, así como registros con valores inválidos (banios == 0 y habitaciones == 0) atribuibles a errores de captura durante el proceso de web scraping. En dicho ejercicio, el interés estaba puesto en resumir la estructura de correlación entre las variables cuantitativas del mercado, por lo que las coordenadas geográficas (longitud, latitud) fueron excluidas al no considerarse relevantes para ese objetivo.

El enfoque del presente análisis es distinto y, en dos aspectos, complementa directamente lo excluido previamente. Primero, el objetivo ya no es exploratorio ni de reducción de dimensionalidad, sino predictivo: se busca estimar un modelo de regresión lineal múltiple que explique el precio de la vivienda (preciom) en función de sus características físicas (área construida, estrato, número de habitaciones, parqueaderos y baños), y utilizar dicho modelo para predecir el precio de las viviendas solicitadas y sugerir ofertas alternativas viables. Segundo, el componente geográfico —ausente en el análisis anterior— cobra ahora un rol central, pues se requiere verificar la ubicación de las ofertas mediante mapas interactivos y, eventualmente, presentar las alternativas recomendadas georreferenciadas.

Dado que la calidad de los datos no depende del objetivo del análisis, se retoma en gran medida el proceso de limpieza y preparación desarrollado previamente (eliminación de registros con exceso de valores faltantes, imputación de parqueaderos y exclusión de valores inválidos en banios y habitaciones), ajustándolo donde corresponde a los requerimientos de este nuevo enfoque. A partir de esta base depurada, el dataset se segmenta según las condiciones de cada solicitud —casas en zona norte para la primera vivienda, apartamentos en zona sur para la segunda— y sobre cada segmento se desarrolla el análisis exploratorio, la estimación del modelo, la validación de sus supuestos, la predicción del precio solicitado y la identificación de ofertas alternativas dentro del presupuesto disponible.

2 Carga y exploración inicial de los datos

tabla_estructura <- data.frame(
  Variable = names(vivienda),
  Tipo = sapply(vivienda, class),
  Ejemplo = sapply(vivienda, function(x) as.character(head(x[!is.na(x)], 1))),
  Valores_faltantes = sapply(vivienda, function(x) sum(is.na(x))),
  row.names = NULL
)

knitr::kable(tabla_estructura,
             caption = "Estructura del dataset: tipo de dato, ejemplo y valores faltantes por variable",
             col.names = c("Variable", "Tipo de dato", "Ejemplo", "Valores faltantes"))
Table 2.1: Estructura del dataset: tipo de dato, ejemplo y valores faltantes por variable
Variable Tipo de dato Ejemplo Valores faltantes
id numeric 1147 3
zona character Zona Oriente 3
piso character 02 2638
estrato numeric 3 3
preciom numeric 250 2
areaconst numeric 70 3
parqueaderos numeric 1 1605
banios numeric 3 3
habitaciones numeric 6 3
tipo character Casa 3
barrio character 20 de julio 3
longitud numeric -76.51168 3
latitud numeric 3.43382 3

3 Preparación de los datos

3.1 Eliminación de filas con 12 o más valores faltantes

Un registro con 12 o más valores faltantes (de un total de 13 columnas) no aporta información utilizable para el análisis, ya que prácticamente todo el registro estaría compuesto por datos ausentes. Por esta razón, antes de proceder con la imputación de parqueaderos y la exclusión de valores inválidos en banios y habitaciones, se eliminan del dataset las filas que presentan 12 o más valores faltantes.

n_antes <- nrow(vivienda)

vivienda <- vivienda %>%
  filter(rowSums(is.na(.)) < 12)

n_despues <- nrow(vivienda)

cat("Registros antes de eliminar filas con 12 o más valores faltantes:", n_antes, "\n")
## Registros antes de eliminar filas con 12 o más valores faltantes: 8322
cat("Registros después de eliminar filas con 12 o más valores faltantes:", n_despues, "\n")
## Registros después de eliminar filas con 12 o más valores faltantes: 8319
cat("Registros eliminados:", n_antes - n_despues, "\n")
## Registros eliminados: 3

3.2 Imputación de datos faltantes en parqueaderos

La variable parqueaderos es una de las variables explicativas del modelo de regresión que se estimará más adelante, por lo que resulta clave dejarla completa antes de filtrar por zona y tipo de vivienda. Dado el volumen de valores faltantes, se optó por imputar en lugar de excluir las filas correspondientes, bajo el supuesto de que un NA en esta variable representa información no reportada por el anunciante y no necesariamente la ausencia de parqueadero en el inmueble. La imputación se realiza usando la mediana de parqueaderos calculada por grupos definidos por tipo de vivienda y estrato, con el fin de capturar de mejor manera la heterogeneidad del mercado inmobiliario según estas dos características. Esta imputación se realiza sobre el dataset completo, antes de filtrar por zona, para aprovechar un mayor número de observaciones por grupo al calcular la mediana.

na_antes <- sum(is.na(vivienda$parqueaderos))

vivienda <- vivienda %>%
  group_by(tipo, estrato) %>%
  mutate(parqueaderos = ifelse(is.na(parqueaderos),
                                median(parqueaderos, na.rm = TRUE),
                                parqueaderos)) %>%
  ungroup()

na_despues <- sum(is.na(vivienda$parqueaderos))

if (na_despues > 0) {
  vivienda$parqueaderos[is.na(vivienda$parqueaderos)] <- median(vivienda$parqueaderos, na.rm = TRUE)
}

cat("NA en parqueaderos antes de imputar:", na_antes, "\n")
## NA en parqueaderos antes de imputar: 1602
cat("NA en parqueaderos después de imputar:", na_despues, "\n")
## NA en parqueaderos después de imputar: 0

3.3 Exclusión de registros con valores inválidos en baños y habitaciones

Las variables banios y habitaciones registran un mínimo de 0, un valor sin sentido para una vivienda habitable, ya que toda propiedad residencial cuenta por definición con al menos un baño y una habitación. Estos casos probablemente corresponden a errores de captura y, dado que banios y habitaciones son predictoras directas del modelo de precio (paso 3), se excluyen estos registros en lugar de imputarlos, ya que no existe un supuesto razonable que permita reconstruir el valor real a partir de otras variables.

n_antes <- nrow(vivienda)

vivienda <- vivienda %>%
  filter(banios > 0, habitaciones > 0, !is.na(estrato))

n_despues <- nrow(vivienda)

cat("Registros antes de excluir valores inválidos:", n_antes, "\n")
## Registros antes de excluir valores inválidos: 8319
cat("Registros después de excluir valores inválidos:", n_despues, "\n")
## Registros después de excluir valores inválidos: 8243
cat("Registros excluidos:", n_antes - n_despues, "\n")
## Registros excluidos: 76

3.4 Verificación final

Se verifica que las variables clave para el modelo de regresión y la segmentación por solicitud (preciom, areaconst, parqueaderos, banios, habitaciones, zona, tipo, longitud, latitud) queden libres de valores faltantes tras la preparación.

vars_clave <- c("preciom", "areaconst", "parqueaderos", "banios",
                 "habitaciones", "zona", "tipo", "longitud", "latitud", "estrato")

sapply(vivienda[, vars_clave], function(x) sum(is.na(x)))
##      preciom    areaconst parqueaderos       banios habitaciones         zona 
##            0            0            0            0            0            0 
##         tipo     longitud      latitud      estrato 
##            0            0            0            0

3.5 Normalización de nombres de barrio

La variable barrio presenta inconsistencias de captura que generan categorías duplicadas para el mismo barrio: variaciones de mayúsculas y minúsculas ("La Flora" vs. "la flora"), espacios adicionales, y corrupción de caracteres especiales (tildes y la letra “ñ”) atribuible a un problema de codificación de texto (encoding) en el proceso de origen de los datos. Esta falta de normalización, si no se corrige, infla artificialmente el número de barrios únicos y distorsiona cualquier análisis posterior que agrupe información por barrio —incluyendo la identificación de inconsistencias en la variable zona y la búsqueda de ofertas alternativas en las secciones siguientes—. Por esta razón, se normaliza barrio antes de proceder con el filtro por solicitud.

4 Revisa todas las columnas de texto del dataset

n_barrios_antes <- n_distinct(vivienda$barrio)

vivienda <- vivienda %>%
  mutate(
    barrio = str_trim(barrio),
    barrio = str_to_lower(barrio),
    barrio = iconv(barrio, from = "UTF-8", to = "UTF-8", sub = ""),  # elimina bytes corruptos/inválidos
    barrio = str_squish(barrio)
  )

n_barrios_despues <- n_distinct(vivienda$barrio)

cat("Barrios únicos antes de normalizar:", n_barrios_antes, "\n")
## Barrios únicos antes de normalizar: 433
cat("Barrios únicos después de normalizar:", n_barrios_despues, "\n")
## Barrios únicos después de normalizar: 404

5 Filtro de la base de datos por solicitud

Se construyen dos subconjuntos de datos a partir del dataset vivienda ya depurado, correspondientes a las características de cada una de las solicitudes recibidas: base1 reúne las ofertas de casas ubicadas en la zona norte de la ciudad (solicitud con crédito preaprobado de 350 millones de pesos), mientras que base2 reúne las ofertas de apartamentos en la zona sur (solicitud con crédito preaprobado de 850 millones de pesos).

base1 <- vivienda %>%
  filter(tipo == "Casa", zona == "Zona Norte")

base2 <- vivienda %>%
  filter(tipo == "Apartamento", zona == "Zona Sur")

cat("base1 (Casas, Zona Norte):", nrow(base1), "registros\n")
## base1 (Casas, Zona Norte): 700 registros
cat("base2 (Apartamentos, Zona Sur):", nrow(base2), "registros\n")
## base2 (Apartamentos, Zona Sur): 2777 registros

5.1 Primeros registros

knitr::kable(head(base1, 3),
             caption = "Primeros 3 registros de base1 (Casas, Zona Norte)")
Table 5.1: Primeros 3 registros de base1 (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
4057 Zona Norte 02 6 750 445 3 7 6 Casa acopi -76.52950 3.38527
knitr::kable(head(base2, 3),
             caption = "Primeros 3 registros de base2 (Apartamentos, Zona Sur)")
Table 5.2: Primeros 3 registros de base2 (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
unique(vivienda$tipo)
## [1] "Casa"        "Apartamento"

5.2 Tablas de verificación del filtro

Se verifica que cada base contenga únicamente los valores esperados de tipo y zona, como comprobación de que el filtro se aplicó correctamente.

verificacion_combinada <- bind_rows(
  base1 %>% count(Base = "base1 (Casas, Zona Norte)", tipo, zona),
  base2 %>% count(Base = "base2 (Apartamentos, Zona Sur)", tipo, zona)
)

knitr::kable(verificacion_combinada,
             caption = "Verificación de filtro en base1 y base2: combinaciones de tipo y zona",
             col.names = c("Base", "Tipo", "Zona", "Registros"))
Table 5.3: Verificación de filtro en base1 y base2: combinaciones de tipo y zona
Base Tipo Zona Registros
base1 (Casas, Zona Norte) Casa Zona Norte 700
base2 (Apartamentos, Zona Sur) Apartamento Zona Sur 2777

5.3 Verificación geográfica del filtro

leaflet(base1) %>%
  addTiles() %>%
  addMarkers(
    lng = ~longitud,
    lat = ~latitud,
    popup = ~paste0("Barrio: ", barrio,
                     "<br>Precio: ", preciom, " millones",
                     "<br>Área: ", areaconst, " m²")
  )

Figure 5.1: Ubicación geográfica de las ofertas en base1 (Casas, Zona Norte)

leaflet(base2) %>%
  addTiles() %>%
  addMarkers(
    lng = ~longitud,
    lat = ~latitud,
    popup = ~paste0("Barrio: ", barrio,
                     "<br>Precio: ", preciom, " millones",
                     "<br>Área: ", areaconst, " m²")
  )

Figure 5.2: Ubicación geográfica de las ofertas en base2 (Apartamentos, Zona Sur)

Las Figuras 5.1 y 5.2 permiten verificar visualmente si los puntos filtrados se ubican efectivamente dentro de la zona esperada de la ciudad. En ambos casos se observa que la mayoría de los puntos sí se concentra en el sector geográfico correspondiente, lo que confirma que el filtro por zona es, en términos generales, funcional. Sin embargo, en los dos mapas es visible también un número considerable de puntos ubicados en el extremo opuesto de la ciudad al esperado —viviendas etiquetadas como Zona Norte que aparecen geográficamente al sur, y viceversa—, un patrón que no corresponde a casos aislados sino a una proporción notoria del total de cada base. Esta observación motivó un análisis más detallado de la relación entre zona, barrio y las coordenadas geográficas, cuyos resultados y posibles causas se discuten a continuación.

5.4 Inconsistencias geográficas en base1 y base2

Se investigó la relación entre la variable zona, el barrio declarado y las coordenadas geográficas (longitud, latitud), a partir de puntos observados fuera del área esperada en los mapas de verificación.

# Zona moda (mayoritaria) por barrio, sobre el dataset completo normalizado
moda_zona_barrio <- vivienda %>%
  count(barrio, zona, sort = TRUE) %>%
  group_by(barrio) %>%
  slice_max(n, n = 1, with_ties = FALSE) %>%
  ungroup() %>%
  select(barrio, zona_moda = zona)

# Registros cuya zona declarada no coincide con la moda de su barrio
base1_check <- base1 %>%
  left_join(moda_zona_barrio, by = "barrio") %>%
  mutate(zona_no_coincide_moda = zona != zona_moda)

base2_check <- base2 %>%
  left_join(moda_zona_barrio, by = "barrio") %>%
  mutate(zona_no_coincide_moda = zona != zona_moda)

sum(base1_check$zona_no_coincide_moda); nrow(base1)
sum(base2_check$zona_no_coincide_moda); nrow(base2)

# Caso ilustrativo: Valle del Lili
vivienda %>% filter(barrio == "valle del lili") %>% count(zona)
# Evidencia de baja precisión geográfica: coordenadas compartidas
# por múltiples registros distintos
n_distinct(vivienda %>% select(longitud, latitud))
nrow(vivienda)

coordenadas_repetidas <- vivienda %>%
  count(longitud, latitud, sort = TRUE) %>%
  head(20)

# La coordenada más repetida: ¿a qué barrio/zona corresponde?
vivienda %>%
  filter(longitud == coordenadas_repetidas$longitud[1],
         latitud == coordenadas_repetidas$latitud[1]) %>%
  count(barrio, zona, sort = TRUE)
referencia_lat <- vivienda %>%
  filter(barrio %in% c("la flora", "valle del lili")) %>%
  group_by(barrio) %>%
  summarise(lat_mediana = median(latitud, na.rm = TRUE), .groups = "drop")

ggplot(base1, aes(x = latitud)) +
  geom_histogram(bins = 40, fill = "steelblue") +
  geom_vline(xintercept = referencia_lat$lat_mediana[referencia_lat$barrio == "la flora"],
             color = "blue", linetype = "dashed", linewidth = 1) +
  geom_vline(xintercept = referencia_lat$lat_mediana[referencia_lat$barrio == "valle del lili"],
             color = "red", linetype = "dashed", linewidth = 1) +
  labs(title = "Distribución de latitud en base1 (Casas, Zona Norte)",
       subtitle = "Línea azul = mediana de 'la flora' (norte); línea roja = mediana de 'valle del lili' (sur)",
       x = "Latitud", y = "Frecuencia")
Distribución de latitud en base1 frente a barrios de referencia del norte y del sur

Figure 5.3: Distribución de latitud en base1 frente a barrios de referencia del norte y del sur

La verificación geográfica de base1 y base2 reveló una desconexión entre la variable zona y las coordenadas geográficas (longitud, latitud) que va más allá de errores puntuales de captura. Si bien la inconsistencia directa entre barrio y zona es baja —por ejemplo, de 1,008 registros del barrio Valle del Lili, 1,004 (99.6%) están correctamente etiquetados como Zona Sur y solo 4 aparecen como Zona Norte—, la distribución espacial de base1 se encuentra sistemáticamente corrida hacia el sur de lo esperado para casas de “Zona Norte”, sin que un grupo identificable de barrios explique la totalidad del patrón (5.3). A esto se suma que el dataset completo contiene apenas 5,862 combinaciones únicas de coordenadas para 8,243 registros, con casos de hasta 106 propiedades distintas compartiendo exactamente la misma latitud y longitud, además de valores inválidos en el campo barrio (como “Cali” o “Zona Norte” en lugar de un nombre real de barrio). En conjunto, esta evidencia sugiere que zona/barrio y las coordenadas geográficas provienen de procesos de captura independientes durante el proceso de web scraping: el primero corresponde a una clasificación de texto libre asignada por el anunciante o el portal, mientras que las coordenadas probablemente se heredan de un mapa aproximado embebido en la página del anuncio (referido al sector o barrio, no a la dirección exacta de la vivienda), sin que ambas fuentes hayan sido validadas de forma cruzada antes de la publicación de los datos. Por esta razón, se recomienda no utilizar longitud/latitud como criterio de verificación fina de la zona declarada, limitando su uso en este informe a fines ilustrativos de ubicación aproximada, y se sugiere para trabajos futuros contrastar ambas variables contra una fuente oficial de geocodificación.

comparacion <- bind_rows(
  base1 %>% mutate(Base = "Zona Norte (Casas)"),
  base2 %>% mutate(Base = "Zona Sur (Apartamentos)")
)

color_norte <- "#8DA0CB"
color_sur   <- "#FC8D62"

# --- Fila 1: Área construida ---
p1_norte <- plot_ly(base1, x = ~areaconst, y = ~preciom, type = "scatter", mode = "markers",
                     marker = list(color = color_norte, opacity = 0.6, size = 6),
                     name = "Zona Norte") %>%
  layout(xaxis = list(title = "Área (m²)"), yaxis = list(title = "Precio (millones)"))

p1_sur <- plot_ly(base2, x = ~areaconst, y = ~preciom, type = "scatter", mode = "markers",
                   marker = list(color = color_sur, opacity = 0.6, size = 6),
                   name = "Zona Sur") %>%
  layout(xaxis = list(title = "Área (m²)"), yaxis = list(title = "Precio (millones)"))

# --- Fila 2: Estrato ---
p2_norte <- plot_ly(base1, x = ~factor(estrato), y = ~preciom, type = "box",
                     marker = list(color = color_norte), line = list(color = color_norte),
                     name = "Zona Norte") %>%
  layout(xaxis = list(title = "Estrato"), yaxis = list(title = "Precio (millones)"))

p2_sur <- plot_ly(base2, x = ~factor(estrato), y = ~preciom, type = "box",
                   marker = list(color = color_sur), line = list(color = color_sur),
                   name = "Zona Sur") %>%
  layout(xaxis = list(title = "Estrato"), yaxis = list(title = "Precio (millones)"))

# --- Fila 3: Habitaciones ---
p3_norte <- plot_ly(base1, x = ~factor(habitaciones), y = ~preciom, type = "box",
                     marker = list(color = color_norte), line = list(color = color_norte),
                     name = "Zona Norte") %>%
  layout(xaxis = list(title = "Habitaciones"), yaxis = list(title = "Precio (millones)"))

p3_sur <- plot_ly(base2, x = ~factor(habitaciones), y = ~preciom, type = "box",
                   marker = list(color = color_sur), line = list(color = color_sur),
                   name = "Zona Sur") %>%
  layout(xaxis = list(title = "Habitaciones"), yaxis = list(title = "Precio (millones)"))

subplot(
  p1_norte, p1_sur,
  p2_norte, p2_sur,
  p3_norte, p3_sur,
  nrows = 3, shareY = TRUE, titleX = TRUE, titleY = TRUE,
  margin = 0.06
) %>%
  layout(
    title = "Precio vs. Área, Estrato y Habitaciones — Zona Norte vs. Zona Sur",
    showlegend = FALSE,
    annotations = list(
      list(x = 0.18, y = 1.05, xref = "paper", yref = "paper", showarrow = FALSE,
           text = "<b>Zona Norte</b>", font = list(size = 13)),
      list(x = 0.82, y = 1.05, xref = "paper", yref = "paper", showarrow = FALSE,
           text = "<b>Zona Sur</b>", font = list(size = 13))
    )
  )

Figure 5.4: Comparación de precio entre Zona Norte (base1) y Zona Sur (base2) por variable