# 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)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.
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"))| 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 |
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
## Registros después de eliminar filas con 12 o más valores faltantes: 8319
## Registros eliminados: 3
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
## NA en parqueaderos después de imputar: 0
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
## Registros después de excluir valores inválidos: 8243
## Registros excluidos: 76
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
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.
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
## Barrios únicos después de normalizar: 404
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
## base2 (Apartamentos, Zona Sur): 2777 registros
| 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 |
| 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 |
## [1] "Casa" "Apartamento"
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"))| Base | Tipo | Zona | Registros |
|---|---|---|---|
| base1 (Casas, Zona Norte) | Casa | Zona Norte | 700 |
| base2 (Apartamentos, Zona Sur) | Apartamento | Zona Sur | 2777 |
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.
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")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