library(dplyr)
library(tidyr)
library(plotly)
library(knitr)
library(kableExtra)
library(DT)
library(mice)
library(e1071)
library(lmtest)
library(car)
library(nortest)
library(broom)
# Fuente primaria: el paquete del enunciado.
# El bloque `else` es un respaldo para que el documento compile en
# equipos donde `paqueteMODELOS` aun no este instalado
if (requireNamespace("paqueteMODELOS", quietly = TRUE)) {
  data("vivienda", package = "paqueteMODELOS")
} else {
  load("vivienda.rda")
}
viv0 <- as.data.frame(vivienda)
dim(viv0)
#> [1] 8322   13
# SISTEMA VISUAL PARA TODO EL INFORME


pal_cat <- c("#2a78d6", "#eb6834", "#1baf7a", "#eda100",
             "#e87ba4", "#008300", "#4a3aa7", "#e34948")

pal_seq <- c("#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b")

pal_div <- c("#2a78d6", "#f0efec", "#e34948")

# Estilo base comun a todos los graficos interactivos
estilo <- function(p, titulo = "", x = "", y = "", leyenda = NULL) {
  p %>% layout(
    title  = list(text = titulo, x = 0.02,
                  font = list(size = 14, color = "#0b0b0b")),
    xaxis  = list(title = x, gridcolor = "#e8e7e3", zerolinecolor = "#e8e7e3"),
    yaxis  = list(title = y, gridcolor = "#e8e7e3", zerolinecolor = "#e8e7e3"),
    legend = list(orientation = "h", y = -0.16,
                  title = list(text = leyenda)),
    font   = list(family = "system-ui, -apple-system, sans-serif", size = 12),
    paper_bgcolor = "white", plot_bgcolor = "white",
    margin = list(l = 60, r = 20, t = 45, b = 60))
}

# NUMERACION AUTOMATICA DE FIGURAS Y TABLAS
# ref_fig()` / `ref_tab()` permiten citarlas desde el texto sin escribir el numero a mano.

.n_fig <- 0; .n_tab <- 0; .ref_fig <- list(); .ref_tab <- list()

fig <- function(txt, id = NULL) {
  .n_fig <<- .n_fig + 1
  if (!is.null(id)) .ref_fig[[id]] <<- .n_fig
  paste0("Figura ", .n_fig, ". ", txt)
}
tab_cap <- function(txt, id = NULL) {
  .n_tab <<- .n_tab + 1
  if (!is.null(id)) .ref_tab[[id]] <<- .n_tab
  paste0("Tabla ", .n_tab, ". ", txt)
}
ref_fig <- function(id) paste0("Figura ", .ref_fig[[id]])
ref_tab <- function(id) paste0("Tabla ", .ref_tab[[id]])

# Formato de numeros
fnum <- function(x, dec = 1) {
  formatC(round(x, dec), format = "f", digits = dec,
          big.mark = ".", decimal.mark = ",")
}

# Tabla estatica con formato uniforme
tabla <- function(x, cap = NULL, ...) {
  kable(x, caption = cap, align = "c", ...) %>%
    kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
                  full_width = FALSE, position = "center", font_size = 13)
}

# Etiquetas legibles para los nombres de las variables
etq <- function(v) {
  d <- c(preciom = "Precio", areaconst = "Area construida", estrato = "Estrato",
         banios = "Baños", habitaciones = "Habitaciones",
         parqueaderos = "Parqueaderos", precio_m2 = "Precio por m²")
  ifelse(v %in% names(d), unname(d[v]), v)
}

# Rotulos de panel para los subplot
paneles <- function(txts, y = 1.04) {
  n <- length(txts)
  x <- (seq_len(n) - 0.5) / n
  lapply(seq_len(n), function(i)
    list(x = x[i], y = y, text = txts[i],
         showarrow = FALSE, xref = "paper", yref = "paper",
         font = list(size = 13, color = "#0b0b0b"), xanchor = "center"))
}

# Linea horizontal de referencia que no altera la escala del eje x.
linea_ref <- function(y, color = pal_cat[2]) {
  list(type = "line", xref = "paper", x0 = 0, x1 = 1,
       yref = "y", y0 = y, y1 = y,
       line = list(color = color, width = 3, dash = "dash"))
}

# Capitalizacion para etiquetas de presentacion. Las preposiciones y
# articulos internos se dejan en minuscula ("Valle del Lili", no "Valle Del
# Lili"); la primera palabra siempre va en mayuscula.
capitaliza <- function(x) {
  y <- gsub("(^|[ -])([a-záéíóúñ])", "\\1\\U\\2", x, perl = TRUE)
  y <- gsub("\\b(De|Del|La|Las|Los|El|Y)\\b", "\\L\\1", y, perl = TRUE)
  sub("^(.)", "\\U\\1", y, perl = TRUE)
}

Objeto del informe

La empresa recibio dos solicitudes de compra y dispone de dos creditos preaprobados independientes:

Vivienda 1 Vivienda 2
Tipo Casa Apartamento
Area 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
Credito preaprobado $350 millones $850 millones

Para cada una se construye una base de la oferta comparable, se estima un modelo de precio, se valora el inmueble solicitado y se identifican ofertas reales acordes al presupuesto.

Las secciones 1 a 3 preparan el terreno: clasifican las variables, depuran la base y describen el mercado completo. Las secciones 4 y 5 desarrollan los seis puntos del enunciado para cada solicitud sobre la base ya tratada.

Los anexos soportan las decisiones metodologicas. Las conclusiones y la recomendacion de compra estan en la seccion 6


1 Los datos

1.1 Origen y estructura

La base vivienda reune 8.322 anuncios de venta de inmuebles en Santiago de Cali, obtenidos de OLX mediante web scraping y distribuidos en el paquete paqueteMODELOS.

str(viv0, give.attr = FALSE)
#> 'data.frame':    8322 obs. of  13 variables:
#>  $ id          : num  1147 1169 1350 5992 1212 ...
#>  $ zona        : chr  "Zona Oriente" "Zona Oriente" "Zona Oriente" "Zona Sur" ...
#>  $ piso        : chr  NA NA NA "02" ...
#>  $ estrato     : num  3 3 3 4 5 5 4 5 5 5 ...
#>  $ preciom     : num  250 320 350 400 260 240 220 310 320 780 ...
#>  $ areaconst   : num  70 120 220 280 90 87 52 137 150 380 ...
#>  $ parqueaderos: num  1 1 2 3 1 1 2 2 2 2 ...
#>  $ banios      : num  3 2 2 5 2 3 2 3 4 3 ...
#>  $ habitaciones: num  6 3 4 3 3 3 3 4 6 3 ...
#>  $ tipo        : chr  "Casa" "Casa" "Casa" "Casa" ...
#>  $ barrio      : chr  "20 de julio" "20 de julio" "20 de julio" "3 de julio" ...
#>  $ longitud    : num  -76.5 -76.5 -76.5 -76.5 -76.5 ...
#>  $ latitud     : num  3.43 3.43 3.44 3.44 3.46 ...

El estudio trabaja con las once variables que describen el inmueble y su localizacion administrativa. Se conservan ademas longitud y latitud, que no son variables de analisis pero si el soporte para los mapas.

1.2 Clasificacion de las variables

Antes de procesar realiza una exploracion general de que es cada variable presente en el dataset.

Tabla 1. Descripcion, naturaleza y tipo de las variables del conjunto de datos
Variable Descripcion Naturaleza Tipo de variable Escala de medicion
id Codigo unico del anuncio Identificador
zona Zona de la ciudad (Norte, Sur, Oriente, Oeste, Centro) Cualitativa Nominal Nominal
barrio Barrio de ubicacion (436 categorias en bruto) Cualitativa Nominal Nominal
tipo Casa o Apartamento Cualitativa Nominal Nominal
estrato Estrato socioeconomico (3, 4, 5 o 6) Cualitativa Ordinal Ordinal
piso Piso en que se ubica el inmueble (01 a 12) Cualitativa Ordinal Ordinal
preciom Precio de venta en millones de pesos colombianos Cuantitativa Continua Razon
areaconst Area construida en metros cuadrados Cuantitativa Continua Razon
parqueaderos Numero de parqueaderos Cuantitativa Discreta Razon
banios Numero de baños Cuantitativa Discreta Razon
habitaciones Numero de habitaciones Cuantitativa Discreta Razon
longitud / latitud Coordenadas geograficas del inmueble Geografica Continua Intervalo

1.2.1 Dos advertencias sobre la clasificacion

La variable estrato es cualitativa, no cuantitativa, los numeros son etiquetas de categorias, no cantidades, esta variable e clasifica como ordinal porque estas categorias si poseen orden natural.

Por otra parte la variable piso tambien es cualitativa ordinal, esta codificada como texto (“01”,“02”, …, “12”), esta Se trata como categorica y, por las razones que se exponen en la seccion 2.4, se excluye del analisis.

1.3 Normalizacion de las etiquetas de texto

Este paso se aplica antes de la exploracion, para asi evitar errores de doble lectura u omision debidos a las diferentes maneras en las que se puede escribir un barrio (mayusculas o tildes)

# Paso base: minusculas y espacios unificados en todas las variables de texto
normaliza_texto <- function(x) trimws(gsub("[[:space:]]+", " ", tolower(x)))

# La fuente trae 93 registros con doble codificacion ("el trébol" en
# lugar de "el trebol"), esto se repara antes de comparar categorias.
corrige_mojibake <- function(x) {
  r <- c("√°" = "á", "√©" = "é", "√≠" = "í",
         "√≥" = "ó", "√∫" = "ú", "√±" = "ñ", "√º" = "ü")
  for (k in names(r)) x <- gsub(k, r[[k]], x, fixed = TRUE)
  x
}
sin_tildes <- function(x) chartr("áéíóúüñ", "aeiouun", x)

normaliza_barrio <- function(x) {
  y <- corrige_mojibake(normaliza_texto(x))
  y <- sub("^(el|la|los|las) ", "", y)
  sin_tildes(y)
}

barrios_antes <- n_distinct(viv0$barrio[!is.na(viv0$barrio)])
barrios_caja  <- n_distinct(sub("^(el|la|los|las) ", "",
                                normaliza_texto(viv0$barrio[!is.na(viv0$barrio)])))

viv0 <- viv0 %>%
  mutate(zona   = normaliza_texto(zona),
         tipo   = normaliza_texto(tipo),
         barrio = normaliza_barrio(barrio))

c(en_bruto = barrios_antes, tras_caja_y_articulo = barrios_caja,
  tras_tildes_y_mojibake = n_distinct(viv0$barrio[!is.na(viv0$barrio)]))
#>               en_bruto   tras_caja_y_articulo tras_tildes_y_mojibake 
#>                    436                    388                    366

La duplicacion de categorias tiene cuatro fuentes

  1. Caja y espacios. Pasar todo a minusculas resuelve valle del lili frente a Valle Del Lili sin examinarlo caso por caso.

  2. Articulo determinado inicial — la flora frente a flora.

  3. Doble codificacion. La fuente trae 93 registros donde los acentos se guardaron mal: el trébol en lugar de el trebol, juanambú en lugar de juanambu. juanambu y juanambú se cuentan como dos barrios distintos.

  4. Tildes. Aun reparado el mojibake, quedan pares como melendez/ melendez, ciudad jardin/ciudad jardin o san joaquin/san joaquin, que designan el mismo barrio escrito con y sin tilde.

La agrupacion se hace sobre una clave sin tildes, que es tambien la etiqueta que se muestra: asi el nombre de un barrio se escribe siempre igual en todas las tablas y mapas del informe, sin depender de como lo haya digitadoen el dataset.

2 Procesamiento de datos

2.1 Inconsistencias

La revision se organiza en tres bloques, debido a que el comportamiento de cada variable es distinta segun su naturaleza. Los duplicados operan sobre la fila completa ya que se pueden evidenciar en ambas.

2.1.1 Registros duplicados y vacios

cat("Registros completamente vacios:", sum(is.na(viv0$id)), "\n")
#> Registros completamente vacios: 3
cat("Filas duplicadas exactas:      ", sum(duplicated(viv0)), "\n")
#> Filas duplicadas exactas:       1

Cada fila corresponde a un anuncio independiente, de modo que la informacion de una fila no deberia repetirse. Conviene precisar que son exactamente estas filas, porque el conteo engaña: de las tres filas sin id, dos estan enteramente vacias y una conserva solo el precio ($330 M, todo lo demas ausente). Las unicas filas duplicadas exactas son precisamente las dos filas vacias, identicas entre si, por lo que la base pasa de 8.322 a 8.319 registros.

2.1.2 Valores imposibles variables cuantitativas

cat("Inmuebles con 0 baños:       ", sum(viv0$banios == 0, na.rm = TRUE), "\n")
#> Inmuebles con 0 baños:        45
cat("Inmuebles con 0 habitaciones:", sum(viv0$habitaciones == 0, na.rm = TRUE), "\n")
#> Inmuebles con 0 habitaciones: 66
cat("Areas menores a 20 m²:       ", sum(viv0$areaconst < 20, na.rm = TRUE), "\n")
#> Areas menores a 20 m²:        0
cat("Precios no positivos:        ", sum(viv0$preciom <= 0, na.rm = TRUE), "\n")
#> Precios no positivos:         0

Se encontraron 45 inmuebles con 0 baños y 66 con 0 habitaciones: una vivienda en venta no puede carecer de ninguno de los dos, de modo que se trata de errores de captura y no de valores reales, estos valores se recodifican como NA.

2.1.3 Verificacion de consistencia de las variables cualitativas

En esta seccion se comparar las categorias contra un criterio más agresivo que el previamente aplicado. clave_estricta() hace lo mismo que la normalización original y además borra todo lo que no sea letra o número, con la finaliadad de poder revisar las categorias del dataset y garantizar la correcta lectura de las mismas.

clave_estricta <- function(x) gsub("[^a-z0-9]", "", sin_tildes(tolower(x)))
sapply(c("zona", "tipo", "barrio"), function(nm) {
  x <- viv0[[nm]][!is.na(viv0[[nm]])]
  length(unique(x)) - length(unique(clave_estricta(x)))  # 0 = sin residuos
})
#>   zona   tipo barrio 
#>      0      0      6

Las tres variables devuelven cero: ni siquiera bajo un criterio mas agresivo quedan categorias que se solapen.

viv <- viv0 %>%
  filter(!is.na(id)) %>%          # elimina los registros vacios
  distinct() %>%                  # elimina el duplicado exacto
  mutate(
    banios       = ifelse(banios == 0, NA, banios),
    habitaciones = ifelse(habitaciones == 0, NA, habitaciones)
  )
c(registros = nrow(viv), barrios = n_distinct(viv$barrio))
#> registros   barrios 
#>      8319       366

2.2 Datos atipicos

El criterio empleado es el del rango intercuartilico: se considera atipico todo valor fuera del intervalo entre Q1 y Q3.

cuant <- c("preciom", "areaconst", "parqueaderos", "banios", "habitaciones")

resumen_atipicos <- do.call(rbind, lapply(cuant, function(nm) {
  x   <- viv[[nm]]
  q   <- quantile(x, c(.25, .75), na.rm = TRUE)
  iqr <- q[2] - q[1]
  li  <- q[1] - 1.5 * iqr; ls <- q[2] + 1.5 * iqr
  n   <- sum(x < li | x > ls, na.rm = TRUE)
  data.frame(Variable = nm,
             Media = round(mean(x, na.rm = TRUE), 2), Mediana = median(x, na.rm = TRUE),
             Min = min(x, na.rm = TRUE), Q1 = unname(q[1]), Q3 = unname(q[2]),
             Max = max(x, na.rm = TRUE),
             "Limite inf." = round(li, 1), "Limite sup." = round(ls, 1),
             "Atipicos" = n, "% atipicos" = round(100 * n / sum(!is.na(x)), 2),
             check.names = FALSE, row.names = NULL)
}))
tabla(resumen_atipicos,
      cap = tab_cap("Tendencia central, limites 1,5×RIC y porcentaje de atipicos"))
Tabla 2. Tendencia central, limites 1,5×RIC y porcentaje de atipicos
Variable Media Mediana Min Q1 Q3 Max Limite inf. Limite sup. Atipicos % atipicos
preciom 433.90 330 58 220 540 1999 -260.0 1020.0 552 6.64
areaconst 174.93 123 30 80 229 1745 -143.5 452.5 382 4.59
parqueaderos 1.84 2 1 1 2 10 -0.5 3.5 567 8.44
banios 3.13 3 1 2 4 10 -1.0 7.0 72 0.87
habitaciones 3.63 3 1 3 4 10 1.5 5.5 822 9.96

2.2.1 Deteccion condicionada por estrato

El criterio anterior tiene un defecto conceptual ya que compara todos los inmuebles contra un mismo patron, sin tomar encuenta los estratos, ya que un inmueble de estrato 6 es naturalmente mas costoso que uno de estrato 3, esto sin llegar a ser un valor atipico real, es por esto que este paso es fundamental para poder realizar una exploracion adecuada del dataset.

lim_iqr <- function(x) {
  q <- quantile(x, c(.25, .75), na.rm = TRUE); i <- q[2] - q[1]
  x < q[1] - 1.5 * i | x > q[2] + 1.5 * i
}
comparacion <- do.call(rbind, lapply(cuant, function(nm) {
  x <- viv[[nm]]
  g <- sum(lim_iqr(x), na.rm = TRUE)                                       # global
  e <- sum(unlist(lapply(split(x, viv$estrato), lim_iqr)), na.rm = TRUE)   # por estrato
  n <- sum(!is.na(x))
  data.frame(Variable = nm,
             "Atipicos (global)" = g, "% global" = round(100 * g / n, 2),
             "Atipicos (por estrato)" = e, "% por estrato" = round(100 * e / n, 2),
             "Variacion" = sprintf("%+.0f %%", 100 * (e / g - 1)),
             check.names = FALSE, row.names = NULL)
}))
tabla(comparacion,
      cap = tab_cap("Atipicos con umbral global frente a umbral condicionado por estrato"))
Tabla 3. Atipicos con umbral global frente a umbral condicionado por estrato
Variable Atipicos (global) % global Atipicos (por estrato) % por estrato Variacion
preciom 552 6.64 340 4.09 -38 %
areaconst 382 4.59 496 5.96 +30 %
parqueaderos 567 8.44 795 11.84 +40 %
banios 72 0.87 306 3.70 +325 %
habitaciones 822 9.96 572 6.93 -30 %
  • En preciom los atipicos caen de 552 a 340 (−38 %) y en habitaciones de 822 a 572 (−30 %). Confirmando lo mencionado anteriormente, lo que el umbral global marcaba como anomalo eran datos de estratos mas altos.

  • En banios, parqueaderos y areaconst los atipicos aumentan, demostrando que dentro de un estrato la dispersion es menor, el rango intercuartilico se estrecha y las vallas se acercan a la mediana.

vallas <- viv %>%
  group_by(estrato) %>%
  summarise(valla = quantile(preciom, .75, na.rm = TRUE) +
              1.5 * IQR(preciom, na.rm = TRUE), .groups = "drop")
valla_global <- quantile(viv$preciom, .75, na.rm = TRUE) + 1.5 * IQR(viv$preciom, na.rm = TRUE)

plot_ly(vallas, x = ~factor(estrato), y = ~valla, type = "bar",
        marker = list(color = pal_seq[c(2, 4, 5, 7)]),
        hovertemplate = "Estrato %{x}: valla propia $%{y:.0f} M<extra></extra>") %>%
  estilo(titulo = fig("Umbral de atipicos del precio: global frente a condicionado por estrato", "vallas"),
         x = "Estrato", y = "Limite superior (millones $)") %>%
  layout(showlegend = FALSE,
         shapes = list(linea_ref(valla_global)),
         annotations = list(list(
           x = 0.02, y = valla_global, xref = "paper", yref = "y",
           text = paste0("Valla global: $", fnum(valla_global, 0), " M"),
           showarrow = FALSE, yshift = 12, xanchor = "left",
           font = list(color = pal_cat[2], size = 12))))

La Figura 1 muestra informacion en general, el umbral global es unico para toda la base, mientras que el condicionado se adapta a cada segmento.

  1. En los estratos altos el umbral global marca como atipicos inmuebles perfectamente normales para su segmento.

  2. En el estrato 3 el umbral global casi nunca se alcanza: solo 4 inmuebles lo superan. Con el umbral propio del estrato los casos detectados pasan a ser 50.

2.2.2 Decision de tratamiento

La lectura conjunta de las Tablas 2 y 3 permite observar dos situaciones que el criterio 1,5×RIC no separa por si solo:

  • preciom (6,64 %) y areaconst (4,59 %) son continuas con una cola derecha muy pesada, el maximo de area es 1.745 m2 frente a una mediana de 123 m2, Un puñado de inmuebles dominan la varianza.

  • En parqueaderos (8,44 %), banios (0,87 %) y habitaciones (9,96 %) el criterio marca como atipicos valores perfectamente legitimos, en habitaciones las vallas caen en 1,5 y 5,5, de modo que se etiquetarian como anomalos 822 inmuebles 763 por tener seis o mas alcobas y 59 por tener una sola, ,ninguna de las dos cosas es una anomalia real, son los extremos naturales de un conteo que solo toma valores de 1 a 10. Tratarlos seria borrar informacion real.

Es por esto que se aplica capping al percentil 99 reemplazar los valores por encima de ese percentil por el valor del percentil unicamente a preciom y areaconst. No se recorta la cola inferior porque precios bajos y areas pequeñas son plenamente coherentes con la oferta de estrato 3, el percentil 99 se calcula dentro de cada estrato.

cap_99_estrato <- function(x, grupo) {
  ave(x, grupo, FUN = function(z) {
    s <- quantile(z, 0.99, na.rm = TRUE); ifelse(z > s, s, z)
  })
}
viv_antes <- viv # copia para comparar
viv$preciom   <- cap_99_estrato(viv$preciom,   viv$estrato)
viv$areaconst <- cap_99_estrato(viv$areaconst, viv$estrato)
Tabla 4. Umbrales de capping y registros recortados dentro de cada estrato
Estrato n P99 precio (M) P99 area (m²) Precios recortados Areas recortadas
3 1453 674 514.8 15 15
4 2129 800 507.8 20 22
5 2750 1400 685.3 22 28
6 1987 1850 934.2 13 20

De esta manera el percentil 99 del precio va de $674 M en estrato 3 a $1.850 M en estrato 6, frente al valor unico de $1650 M que habria impuesto el calculo global.

Tabla 5. Indicadores antes y despues del tratamiento de atipicos
Variable Media antes Media despues Mediana antes Mediana despues SD antes SD despues Asimetria antes Asimetria despues Max antes Max despues
preciom 433.9 431.8 330 330 328.7 323.4 1.85 1.82 1999 1850.0
areaconst 174.9 173.1 123 123 143.0 133.2 2.69 1.96 1745 934.2

La mediana de ambas variables no cambia ($330 M y 123 m2), lo que confirma que la intervencion no desplaza el centro de la distribucion, Lo que si cambia es la dispersion y la desviacion estandar.

2.3 Datos faltantes

A diferencia de los atipicos, los datos faltantes afectan a todos los datos independientemente de su naturaleza y deben revisarse, en las cuantitativas se puede contrastar el mecanismo con pruebas estadisticas y recurrir a imputacion multiple, en las cualitativas corresponde el analisis es de frecuencias.

Tabla 6. Numero y porcentaje de datos NA por variable
Variable Numero de datos NA Porcentaje de NA (%)
piso 2635 31.67
parqueaderos 1602 19.26
habitaciones 66 0.79
banios 45 0.54
id 0 0.00
zona 0 0.00
estrato 0 0.00
preciom 0 0.00
areaconst 0 0.00
tipo 0 0.00
barrio 0 0.00
longitud 0 0.00
latitud 0 0.00
patrones <- viv %>%
  transmute(across(all_of(c("piso", "parqueaderos", "banios", "habitaciones")),
                   ~ifelse(is.na(.x), "falta", "presente"))) %>%
  count(piso, parqueaderos, banios, habitaciones, sort = TRUE) %>%
  mutate(patron = paste0(
    ifelse(piso == "falta", "piso ", ""),
    ifelse(parqueaderos == "falta", "parqueaderos ", ""),
    ifelse(banios == "falta", "baños ", ""),
    ifelse(habitaciones == "falta", "habitaciones ", "")),
    patron = ifelse(trimws(patron) == "", "sin faltantes", trimws(patron)),
    pct = 100 * n / nrow(viv))

plot_ly(head(patrones, 8), x = ~reorder(patron, -n), y = ~n, type = "bar",
        marker = list(color = pal_cat[1]),
        hovertemplate = "%{x}<br>%{y} registros<extra></extra>") %>%
  estilo(titulo = fig("Patrones de ausencia mas frecuentes"),
         x = "", y = "Registros")

1.En la variable piso hay un componente estructural y otro de simple no diligenciamiento, y los datos disponibles no permiten separarlos registro a registro, la decision por la que obto fue conservarla como variable descriptiva y excluirla del analisis sin imputarla.

2.Respecto a la variable de parqueaderos esta presenta un patron muy marcado por estrato.

3.Por otra parte banios y habitaciones provienen de la recodificacion de los ceros imposibles de la seccion 2.1.

Tabla 7. Ausencia de parqueaderos segun estrato socioeconomico (%)
Estrato % con dato % sin dato
3 47.1 52.9
4 77.1 22.9
5 91.7 8.3
6 94.1 5.9

2.4 Identificacion del mecanismo de ausencia

2.4.1 Prueba de Little (MCAR)

Para evaluar formalmente si los faltantes se distribuyen de manera completamente aleatoria se aplica la prueba de Little sobre el bloque cuantitativo. Su hipotesis nula es que los datos son MCAR.

La prueba requiere estimadores de la media y la matriz de covarianzas.

# EM para la normal multivariante con datos faltantes
em_mvn <- function(X, tol = 1e-7, maxit = 1000) {
  X <- as.matrix(X); n <- nrow(X); p <- ncol(X); R <- !is.na(X)
  mu <- colMeans(X, na.rm = TRUE)
  S  <- cov(X, use = "pairwise.complete.obs")
  S[is.na(S)] <- 0; diag(S)[diag(S) <= 0] <- 1
  for (it in seq_len(maxit)) {
    T1 <- numeric(p); T2 <- matrix(0, p, p)
    for (i in seq_len(n)) {
      o <- R[i, ]; m <- !o
      xi <- X[i, ]; Ci <- matrix(0, p, p)
      if (any(m)) {
        if (all(m)) { xi[m] <- mu[m]; Ci[m, m] <- S[m, m] }
        else {
          Soo <- S[o, o, drop = FALSE]; Smo <- S[m, o, drop = FALSE]
          B <- Smo %*% solve(Soo)
          xi[m] <- mu[m] + B %*% (X[i, o] - mu[o])   # E[x_mis | x_obs]
          Ci[m, m] <- S[m, m, drop = FALSE] - B %*% t(Smo)
        }
      }
      T1 <- T1 + xi; T2 <- T2 + tcrossprod(xi) + Ci
    }
    mu_new <- T1 / n; S_new <- T2 / n - tcrossprod(mu_new)
    delta <- max(abs(mu_new - mu), abs(S_new - S))
    mu <- mu_new; S <- S_new
    if (delta < tol) break
  }
  list(mu = mu, sigma = S, iter = it)
}

# Estadistico de Little
little_mcar <- function(X) {
  X <- as.matrix(X); p <- ncol(X); R <- !is.na(X)
  fit <- em_mvn(X)
  clave <- apply(R, 1, function(z) paste(as.integer(z), collapse = ""))
  d2 <- 0; gl <- 0
  for (k in unique(clave)) {
    idx <- clave == k; o <- R[which(idx)[1], ]
    if (!any(o)) next
    nj   <- sum(idx)
    ybar <- colMeans(X[idx, o, drop = FALSE])
    dif  <- ybar - fit$mu[o]
    d2   <- d2 + nj * as.numeric(t(dif) %*% solve(fit$sigma[o, o, drop = FALSE]) %*% dif)
    gl   <- gl + sum(o)
  }
  gl <- gl - p
  list(estadistico = d2, gl = gl,
       p_valor = pchisq(d2, gl, lower.tail = FALSE),
       patrones = length(unique(clave)))
}
mcar <- little_mcar(viv[, cuant])
unlist(mcar)
#>   estadistico            gl       p_valor      patrones 
#>  7.434187e+02  2.300000e+01 3.109580e-142  8.000000e+00

El resultado es de 743,4 con 23 grados de libertad segun la formula de Little el numero de variables observadas en cada patron sobre 8 patrones distintos, y el valor p es indistinguible de cero, por lo que Se rechaza la hipotesis nula.

2.4.2 Normalidad de las variables con ausencias

subplot(
  plot_ly(x = ~viv$parqueaderos, type = "histogram", nbinsx = 12,
          marker = list(color = pal_cat[1])),
  plot_ly(x = ~viv$banios, type = "histogram", nbinsx = 12,
          marker = list(color = pal_cat[1])),
  plot_ly(x = ~viv$habitaciones, type = "histogram", nbinsx = 12,
          marker = list(color = pal_cat[1])),
  nrows = 1, titleX = FALSE, margin = 0.05) %>%
  estilo(titulo = fig("Forma de la distribucion de las tres variables con datos faltantes"),
         y = "Frecuencia") %>%
  layout(showlegend = FALSE, margin = list(t = 75),
         annotations = paneles(c("Parqueaderos", "Baños", "Habitaciones")))

Las tres son discretas y asimetricas a la derecha, comprometiendo la fiabilidad de la prueba de Little, que supone normalidad multivariada, y descarta la media como metodo de imputacion.

2.4.3 Regresion logistica sobre las indicadoras de ausencia

Para determinar si la probabilidad de ausencia depende de variables observadas lo que definiria un mecanismo MAR se construye, para cada variable con faltantes, un indicador binario y se ajusta una regresion logistica.

mod_parq <- glm(is.na(parqueaderos) ~ preciom + areaconst + estrato + I(tipo == "casa"),
                family = binomial, data = viv)
round(summary(mod_parq)$coefficients, 5)
#>                       Estimate Std. Error   z value Pr(>|z|)
#> (Intercept)            2.97101    0.16718  17.77113  0.00000
#> preciom               -0.00071    0.00026  -2.69221  0.00710
#> areaconst              0.00044    0.00046   0.97023  0.33193
#> estrato               -0.99646    0.04595 -21.68799  0.00000
#> I(tipo == "casa")TRUE  0.15386    0.08232   1.86905  0.06162

La variable estrato resulta altamente significativa. El coeficiente (-0,996), cada escalon de estrato reduce en un `63 % La Tabla 7 permite evidenciar que la ausencia cae de 52,9 % en estrato 3 a 22,9 %, 8,3 % y 5,9 %

round(summary(glm(is.na(banios) ~ preciom + areaconst + estrato,
                  family = binomial, data = viv))$coefficients, 6)
#>              Estimate Std. Error   z value Pr(>|z|)
#> (Intercept) -3.678536   0.704230 -5.223486 0.000000
#> preciom      0.000209   0.000732  0.286031 0.774855
#> areaconst    0.004638   0.001086  4.269120 0.000020
#> estrato     -0.596270   0.192563 -3.096494 0.001958
round(summary(glm(is.na(habitaciones) ~ preciom + areaconst + estrato,
                  family = binomial, data = viv))$coefficients, 6)
#>              Estimate Std. Error   z value Pr(>|z|)
#> (Intercept) -2.515516   0.582191 -4.320772 0.000016
#> preciom      0.000423   0.000649  0.652239 0.514247
#> areaconst    0.004202   0.000973  4.319719 0.000016
#> estrato     -0.776935   0.164504 -4.722891 0.000002

En banios y habitaciones resultan significativas areaconst y estrato, la probabilidad de ausencia depende de variables observadas, es decir que el patron es consistente con un mecanismo MAR.

2.4.4 Conclusion sobre el mecanismo

Tabla 8. Mecanismo de ausencia identificado y tratamiento asignado
Variable % NA Evidencia Mecanismo Tratamiento
piso 31.67 No aplica a parte de las casas, pero el 52 % de la ausencia son apartamentos Mixto: estructural + no diligenciado No se imputa; se excluye del analisis (mecanismo ambiguo)
parqueaderos 19.26 estrato altamente significativo (OR = 0,37 por escalon); el valor 0 no existe en la base Estructural / MNAR Imputacion deterministica con 0
banios 0.54 areaconst y estrato significativos; procede de ceros imposibles Compatible con MAR Imputacion multiple (mice, PMM)
habitaciones 0.79 areaconst y estrato significativos; procede de ceros imposibles Compatible con MAR Imputacion multiple (mice, PMM)

2.5 Imputacion

Para banios y habitaciones se aplica imputacion multiple por encadenamiento (mice) con el metodo PMM (Predictive Mean Matching), elegido ya que no supone normalidad y porque solo devuelve valores que ya existen en los datos observados, lo que garantiza imputaciones posibles para variables de conteo.

bloque <- viv %>% select(preciom, areaconst, parqueaderos, banios, habitaciones, estrato)
imp <- mice(bloque, m = 5, maxit = 10, method = "pmm", printFlag = FALSE, seed = 2026)
completo <- complete(imp, 1)
# Proporcion de cada valor entero, en los datos observados y en cada una de las
# cinco imputaciones. Superponer seis histogramas translucidos seria ilegible:
# se usan barras para lo observado y puntos para las imputaciones.
niveles <- sort(unique(viv$habitaciones[!is.na(viv$habitaciones)]))
prop_de <- function(x) as.numeric(table(factor(x, levels = niveles))) / length(x)

obs  <- viv$habitaciones[!is.na(viv$habitaciones)]
imps <- lapply(1:5, function(k) complete(imp, k)$habitaciones[is.na(viv$habitaciones)])

p <- plot_ly(x = niveles, y = prop_de(obs), type = "bar",
             marker = list(color = pal_cat[1]), opacity = 0.85,
             name = paste0("Observados (n = ", fnum(length(obs), 0), ")"),
             hovertemplate = "%{x} habitaciones: %{y:.1%} de los observados<extra></extra>")
for (k in 1:5) {
  p <- add_markers(p, x = niveles, y = prop_de(imps[[k]]), inherit = FALSE,
                   marker = list(color = pal_cat[2], size = 9,
                                 line = list(color = "white", width = 1.5)),
                   name = paste0("Imputaciones (m = 5, n = ", fnum(length(imps[[1]]), 0), ")"),
                   legendgroup = "imp", showlegend = (k == 1),
                   hovertemplate = "%{x} habitaciones: %{y:.1%} de las imputaciones<extra></extra>")
}
p %>%
  estilo(titulo = fig("Habitaciones: proporcion observada frente a las cinco imputaciones", "imput"),
         x = "Numero de habitaciones", y = "Proporcion") %>%
  layout(xaxis = list(dtick = 1))

Lectura del diagnostico. En la Figura 4, las cinco imputaciones caen integramente dentro del rango observado (1 a 10) y se concentran en la zona central de la distribucion, sin generar valores imposibles, la seccion anterior mostro que la ausencia se concentra en inmuebles mayor area y PMM incorpora precisamente esa informacion.

viv$banios       <- completo$banios
viv$habitaciones <- completo$habitaciones
viv$parqueaderos <- ifelse(is.na(viv$parqueaderos), 0, viv$parqueaderos)
sum(is.na(viv[, cuant]))   # 0 faltantes en las variables de analisis
#> [1] 0

Respecto a las variables cualitativas, ninguna requirio imputacion: zona, barrio y tipo no presentan un solo valor faltante una vez eliminados los registros vacios, y piso no se imputa por la razon ya expuesta.

2.6 Base de datos final

viv <- viv %>%
  mutate(
    precio_m2 = preciom * 1e6 / areaconst,
    estrato_f = factor(estrato, levels = 3:6, ordered = TRUE),
    rango_precio = cut(preciom, breaks = c(0, 200, 350, 600, Inf),
                       labels = c("Economico", "Medio", "Alto", "Premium")),
    rango_area   = cut(areaconst, breaks = c(0, 80, 150, 300, Inf),
                       labels = c("Pequeña", "Mediana", "Grande", "Muy grande"))
  )
c(registros = nrow(viv), variables = ncol(viv))
#> registros variables 
#>      8319        17

Los cortes de rango_precio se fijaron en los cuartiles redondeados de la distribucion ($200 M, $350 M, $600 M) y los de rango_area en umbrales de mercado (80, 150 y 300 m²), de modo que las categorias sean interpretables por un corredor y no solo estadisticamente balanceadas.

Tabla 9. Medidas descriptivas de las variables cuantitativas de la base limpia
Variable Minimo Maximo Media Desv. estandar Q1 Mediana Q3 Coef. variacion (%) Asimetria
preciom 58.0 1850.0 431.79 323.44 220 330 540 74.9 1.82
areaconst 30.0 934.2 173.07 133.18 80 123 229 77.0 1.96
parqueaderos 0.0 10.0 1.48 1.24 1 1 2 83.9 1.65
banios 1.0 10.0 3.13 1.41 2 3 4 45.2 0.98
habitaciones 1.0 10.0 3.64 1.44 3 3 4 39.4 1.82
precio_m2 280991.7 9468085.1 2719974.33 1077400.62 1915635 2637363 3376623 39.6 0.69
Tabla 10. Descripcion de las variables cualitativas de la base limpia
Variable Categorias Moda Frec. de la moda % de la moda Categoria menos frecuente Frec. minima
zona 5 Zona Sur 4726 56.8 Zona Centro 124
tipo 2 Apartamento 5100 61.3 Casa 3219
estrato 4 5 2750 33.1 3 1453
barrio 366 Valle del Lili 1009 12.1 Zona Residencial 1

La base final contiene 8319 registros sin valores faltantes en las variables de analisis. Sobre ella se desarrolla el resto del informe cuyas medidas descriptivas se pueden apreciar en las tablas 9 y 10.

3 Analisis exploratorio de datos

Esta seccion describe el mercado completo, que se tomara como el contexto en el que se enmarcan las dos solicitudes. El analisis exploratorio especifico de cada base filtrada que se solicita el punto 2 del enunciado se desarrolla dentro de cada solicitud, en las secciones 4.2 y 5.2.

3.1 Panorama univariado

La asimetria positiva que persiste tras el tratamiento 1.82 en preciom y 1.96 en areaconst es el hecho estadistico mas relevante de la Tabla 9, la media $432 M supera a la mediana $330 M en un 31 %, Reportar el todo el informe usa la mediana como medida de posicion.

subplot(
  plot_ly(viv, x = ~preciom, type = "histogram", nbinsx = 60,
          marker = list(color = pal_cat[1])) %>%
    layout(xaxis = list(title = "Precio (millones $)")),
  plot_ly(viv, x = ~areaconst, type = "histogram", nbinsx = 60,
          marker = list(color = pal_cat[1])) %>%
    layout(xaxis = list(title = "Area construida (m²)")),
  nrows = 1, titleX = TRUE, margin = 0.07) %>%
  estilo(titulo = fig("Distribucion del precio y del area construida en la base final"),
         y = "Frecuencia") %>%
  layout(showlegend = FALSE, margin = list(t = 75),
         annotations = paneles(c("Precio", "Area construida")))
subplot(
  viv %>% count(zona) %>%
    plot_ly(x = ~reorder(capitaliza(zona), -n), y = ~n, type = "bar",
            marker = list(color = pal_cat[1]),
            hovertemplate = "%{x}: %{y} anuncios<extra></extra>"),
  viv %>% count(tipo) %>%
    plot_ly(x = ~reorder(capitaliza(tipo), -n), y = ~n, type = "bar",
            marker = list(color = pal_cat[1]),
            hovertemplate = "%{x}: %{y} anuncios<extra></extra>"),
  nrows = 1, titleX = FALSE, widths = c(.65, .35), margin = 0.08) %>%
  estilo(titulo = fig("Composicion de la oferta por zona y por tipo de inmueble"),
         y = "Anuncios") %>%
  layout(showlegend = FALSE, margin = list(t = 75),
         annotations = paneles(c("Por zona de la ciudad", "Por tipo de inmueble")))

En la Zona Sur concentra el 56,8% de toda la oferta y la Zona Norte el 23,1 %, entre las dos suman cuatro de cada cinco anuncios. La Zona Centro 1,5% y la Zona Oriente 4,2% estan estructuralmente subrepresentadas en el mercado formal de OLX un sesgo del canal que debe advertirse antes de extrapolar conclusiones a toda la ciudad. En cuanto al producto, el apartamento predomina con el 61,3%.

3.2 Relacion entre precio, area y estrato

viv %>%
  group_by(estrato) %>%
  summarise(mediana = median(precio_m2) / 1e6, .groups = "drop") %>%
  plot_ly(x = ~factor(estrato), y = ~mediana, type = "bar",
          marker = list(color = pal_seq[c(2, 4, 5, 7)]),
          hovertemplate = "Estrato %{x}: $%{y:.2f} M/m²<extra></extra>") %>%
  estilo(titulo = fig("Precio mediano por metro cuadrado segun estrato"),
         x = "Estrato", y = "Millones $ por m²")

El precio por metro cuadrado crece en conjunto con el metro cuadrado pero no de manera lineal, este es el argumento empirico central contra tratar el estrato como numero si cada escalon del estrato valiera lo mismo las barras subirian en pasos iguales y no lo hacen.

plot_ly(viv, x = ~areaconst, y = ~preciom, color = ~factor(estrato),
        colors = pal_seq[c(2, 4, 5, 7)], type = "scatter", mode = "markers",
        marker = list(size = 4, opacity = .4),
        hovertext = ~paste0(capitaliza(barrio), "<br>", areaconst, " m² · $",
                            fnum(preciom, 0), " M"),
        hoverinfo = "text") %>%
  estilo(titulo = fig("Precio frente a area construida, por estrato"),
         x = "Area construida (m²)", y = "Precio (millones $)",
         leyenda = "Estrato")

En la figura 8 lo relevante no es solo que las nubes esten desplazadas estas tambien que se abren en abanico y la pendiente del precio frente al area crece con el estrato.

Tabla 11. Pendiente del precio sobre el area, estimada dentro de cada estrato
Estrato Pendiente ($M por m²)
3 0.868
4 1.142
5 1.293
6 1.604

El metro cuadrado marginal pasa de $0.87 M en estrato 3 a $1.60 M en estrato 6, al añadir la interaccion area × estrato, los terminos resultan significativos.

Los datos difieren del modelo lineal en el punto 3, ya que estos suguieren que el estrato actua como un factor multiplicativo, encareciendo proporcionalmente cada metro. Un modelo log(precio) ~ log(area) + estrato si representa esa estructura, porque en escala logaritmica un efecto multiplicativo es un desplazamiento vertical y las rectas vuelven a ser paralelas. Es la razon de fondo por la que el Anexo A acaba prefiriendo esa especificacion.

3.3 Estructura de correlaciones

vars_cor <- c("preciom", "areaconst", "estrato", "banios",
              "habitaciones", "parqueaderos", "precio_m2")
Mcor <- cor(viv[, vars_cor])

plot_ly(x = etq(vars_cor), y = etq(vars_cor), z = round(Mcor, 3), type = "heatmap",
        colors = colorRamp(pal_div), zmin = -1, zmax = 1,
        text = round(Mcor, 2), texttemplate = "%{text}",
        hovertemplate = "%{y} vs %{x}: %{z}<extra></extra>") %>%
  estilo(titulo = fig("Matriz de correlaciones del mercado completo")) %>%
  layout(yaxis = list(autorange = "reversed"))
Tabla 12. Correlaciones seleccionadas y su interpretacion
Par de variables r Lectura
preciom – areaconst 0.711 El area es el mejor predictor individual del precio total
areaconst – banios 0.703 Consistencia interna del tamaño del inmueble
preciom – banios 0.684 El numero de baños opera como proxy del estandar de acabados
preciom – parqueaderos 0.646 Relacion fuerte: el parqueadero es un atributo de precio, no de tamaño
preciom – estrato 0.623 El estrato aporta casi tanto como los atributos fisicos
banios – habitaciones 0.594 Relacion moderada dentro del bloque de tamaño
preciom – habitaciones 0.269 Sorprendentemente debil
estrato – habitaciones -0.086 Negativa: mas habitaciones, menor estrato
precio_m2 – areaconst -0.272 Negativa: economias de escala — el metro grande vale menos
precio_m2 – habitaciones -0.357 Negativa y notable: a mas alcobas por m², menor valor unitario

El numero de habitaciones que es la primera variable que un comprador suele mencionar es el peor predictor del precio de todo el conjunto esta se relaciona negativamente con el estrato y de forma mucho mas marcada con el precio por metro cuadrado. La explicacion es de mercado: en los estratos altos el metraje se destina a espacios sociales, closets y zonas de servicio, mientras que en los estratos bajos la misma area se subdivide en mas alcobas. habitaciones mide densidad de ocupacion, no calidad.

Ademas que el precio por metro cuadrado correlaciona negativamente con el area, el mercado paga economias de escala, de modo que el metro marginal de un inmueble grande vale menos que el de uno pequeño.

3.4 Comparacion entre zonas

Tabla 13. Perfil comparado de las cinco zonas de la ciudad
Zona Anuncios % oferta Precio mediano (M) Area mediana (m²) Precio/m² (M) % casas Estrato modal
Zona Oeste 1198 14.4 580 165.5 3.70 14.1 6
Zona Sur 4726 56.8 320 113.0 2.69 41.0 5
Zona Norte 1920 23.1 300 107.0 2.22 37.6 5
Zona Centro 124 1.5 297 160.0 1.53 80.6 3
Zona Oriente 351 4.2 210 160.0 1.32 82.3 3
# Se destacan las dos zonas que intervienen en las solicitudes el resto queda
# en gris para que la comparacion relevante salte a la vista.
ord_zona <- viv %>% group_by(zona) %>% summarise(m = median(preciom)) %>%
  arrange(desc(m)) %>% pull(zona) %>% capitaliza()
viv %>%
  mutate(dest = ifelse(zona %in% c("zona norte", "zona sur"),
                       "Zonas de las solicitudes", "Resto de la ciudad")) %>%
  plot_ly(x = ~factor(capitaliza(zona), levels = ord_zona), y = ~preciom, color = ~dest,
          colors = c(`Zonas de las solicitudes` = pal_cat[1],
                     `Resto de la ciudad` = "#b9bcc0"),
          type = "box", boxpoints = FALSE) %>%
  estilo(titulo = fig("Distribucion del precio por zona de la ciudad"),
         y = "Precio (millones $)") %>%
  layout(xaxis = list(categoryorder = "array", categoryarray = ord_zona))

La Zona Oeste es la mas cara del mercado pese a concentrar solo el 14,7 % de la oferta y la Zona Oriente la mas economica, una diferencia de casi tres veces en el valor del metro cuadrado dentro de una misma ciudad.

El dato de gestion esta en la comparacion Centro–Norte, ambas tienen un precio total mediano casi identico, pero los inmuebles del Centro poseen mas area en relacion a los demas, mirando solo el precio de lista las dos zonas parecen equivalentes las medidas por metro cuadrado, pertenecen a mercados distintos recalcando la necesidad de la separacion por zonas ya quemezclarlas produciria un modelo que no describe ningun mercado real.

3.5 Concentracion de la oferta por barrio

top20 <- viv %>% count(barrio, sort = TRUE) %>% head(20)

plot_ly(top20, x = ~n, y = ~reorder(capitaliza(barrio), n), type = "bar",
        orientation = "h", marker = list(color = pal_cat[1]),
        hovertemplate = "%{y}: %{x} anuncios<extra></extra>") %>%
  estilo(titulo = fig("Los veinte barrios con mayor oferta"), x = "Anuncios") %>%
  layout(height = 560, margin = list(l = 160))

La concentracion apreciada en la figura 11 tiene una implicacion estadistica directa que se retoma en la validacion de supuestos las observaciones no son independientes. Los inmuebles de un mismo barrio comparten urbanismo y zonas comunes de modo que sus residuos estaran correlacionados.

4 Solicitud 1 — Casa en Zona Norte, credito de $350 millones

Perfil pedido: casa, 200 m² construidos, 1 parqueadero, 2 baños, 4 habitaciones, estrato 4 o 5, Zona Norte. Credito preaprobado: $350 millones.

# FUNCIONES DE APOYO PARA LOS PUNTOS 1 A 6
# Se definen una sola vez y se aplican de forma identica a las dos solicitudes,
# de modo que ambas se analicen exactamente con el mismo procedimiento.

## Marca los registros cuya coordenada cae fuera del nucleo geografico de su
## propia zona (regla de Tukey, 1,5 × RIC, sobre latitud y longitud).
marcar_geo <- function(d) {
  lim <- function(x) {
    q <- stats::quantile(x, c(.25, .75), na.rm = TRUE)
    c(q[1] - 1.5 * diff(q), q[2] + 1.5 * diff(q))
  }
  la <- lim(d$latitud); lo <- lim(d$longitud)
  d$geo_ok <- d$latitud  >= la[1] & d$latitud  <= la[2] &
              d$longitud >= lo[1] & d$longitud <= lo[2]
  d
}

## Filtro del punto 1: opera sobre la base YA procesada en la seccion 2
filtrar_base <- function(datos, tipo_v, zona_v) {
  datos %>% filter(tipo == tipo_v, zona == zona_v) %>% marcar_geo()
}

## VIF maximo. `car::vif()` devuelve un vector cuando todos los terminos tienen
## 1 grado de libertad y una matriz (GVIF) cuando hay factores; se normalizan
## los dos casos al VIF generalizado corregido, comparable entre modelos.
vif_max <- function(m) {
  v <- car::vif(m)
  if (is.matrix(v)) max(v[, ncol(v)]^2) else max(v)
}

## Mapa interactivo (plotly + OpenStreetMap, sin token)
mapa <- function(d, titulo = "", tam = 7, etiqueta = NULL, zoom = 11.3) {
  if (is.null(etiqueta)) {
    etiqueta <- with(d, paste0(
      capitaliza(barrio), "<br>Estrato ", estrato,
      "<br>Precio: $", fnum(preciom, 0), " M",
      "<br>", fnum(areaconst, 0), " m² | ", habitaciones, " hab | ",
      banios, " baños | ", parqueaderos, " parq."))
  }
  plot_ly(d, lat = ~latitud, lon = ~longitud, type = "scattermapbox",
          mode = "markers", color = ~factor(estrato),
          colors = pal_seq[c(2, 4, 5, 7)],
          marker = list(size = tam, opacity = .75),
          hovertext = etiqueta, hoverinfo = "text") %>%
    layout(
      title = list(text = titulo, x = 0.02, font = list(size = 14)),
      mapbox = list(style = "open-street-map", zoom = zoom,
                    center = list(lat = median(d$latitud, na.rm = TRUE),
                                  lon = median(d$longitud, na.rm = TRUE))),
      legend = list(orientation = "h", y = -0.05, title = list(text = "Estrato")),
      margin = list(l = 0, r = 0, t = 35, b = 0))
}

## Los tres modelos candidatos, sobre las mismas variables del enunciado
ajustar_modelos <- function(d) {
  list(
    M1 = lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
            data = d),
    M2 = lm(preciom ~ areaconst + estrato_f + habitaciones + parqueaderos + banios,
            data = d),
    M3 = lm(log(preciom) ~ log(areaconst) + estrato_f + habitaciones +
              parqueaderos + banios, data = d)
  )
}

## Metricas comparables entre modelos en nivel y en logaritmo.
## La escala de la respuesta se deduce de la formula, no de un argumento: asi es
## imposible comparar por error un RMSE en logaritmos con uno en millones.
## Para los modelos log el ajuste se devuelve a millones con el factor de Duan
## (smearing), de modo que RMSE, MAE y MAPE sean comparables entre filas.
metricas <- function(m, d) {
  log_resp <- grepl("^log\\(", deparse(formula(m)[[2]]))
  ajust <- if (log_resp) exp(fitted(m)) * mean(exp(residuals(m))) else fitted(m)
  e <- d$preciom - ajust
  data.frame(
    R2 = summary(m)$r.squared, R2_adj = summary(m)$adj.r.squared,
    AIC = AIC(m), BIC = BIC(m),
    RMSE = sqrt(mean(e^2)), MAE = mean(abs(e)),
    MAPE = mean(abs(e / d$preciom)) * 100,
    p_BP = lmtest::bptest(m)$p.value,
    p_AD = nortest::ad.test(residuals(m))$p.value,
    VIF_max = vif_max(m), row.names = NULL)
}

tabla_comparacion <- function(mods, d) {
  cbind(Modelo = c("M1: nivel, estrato numerico",
                   "M2: nivel, estrato como factor",
                   "M3: log-log, estrato como factor"),
        rbind(metricas(mods$M1, d), metricas(mods$M2, d), metricas(mods$M3, d)))
}

## Bateria de supuestos
diagnosticos <- function(m) {
  r <- residuals(m)
  data.frame(
    Supuesto = c("Linealidad / especificacion", "Homocedasticidad",
                 "Normalidad de residuos", "Independencia", "No multicolinealidad"),
    Prueba = c("RESET de Ramsey", "Breusch-Pagan", "Anderson-Darling",
               "Durbin-Watson", "VIF maximo"),
    Estadistico = unname(c(lmtest::resettest(m)$statistic, lmtest::bptest(m)$statistic,
                    nortest::ad.test(r)$statistic, lmtest::dwtest(m)$statistic,
                    vif_max(m))),
    `Valor p` = c(lmtest::resettest(m)$p.value, lmtest::bptest(m)$p.value,
                  nortest::ad.test(r)$p.value, lmtest::dwtest(m)$p.value, NA),
    check.names = FALSE)
}

tabla_supuestos <- function(m, cap) {
  diagnosticos(m) %>%
    mutate(Estadistico = round(Estadistico, 3),
           `Valor p` = ifelse(is.na(`Valor p`), "—",
                       ifelse(`Valor p` < 0.0001, "< 0,0001", fnum(`Valor p`, 4)))) %>%
    tabla(cap = cap)
}

## Graficos de diagnostico interactivos
graficos_diagnostico <- function(m, titulo = "") {
  df <- data.frame(ajustado = fitted(m), resid = residuals(m),
                   std = rstandard(m), cook = cooks.distance(m))
  ejes <- function(p, x, y) layout(p, xaxis = list(title = x, gridcolor = "#e8e7e3"),
                                     yaxis = list(title = y, gridcolor = "#e8e7e3"))
  p1 <- plot_ly(df, x = ~ajustado, y = ~resid, type = "scatter", mode = "markers",
                marker = list(size = 5, opacity = .45, color = pal_cat[1]),
                hovertemplate = "ajustado %{x:.0f} · residuo %{y:.0f}<extra></extra>") %>%
    add_lines(x = range(df$ajustado), y = c(0, 0), inherit = FALSE,
              line = list(dash = "dash", color = pal_cat[2]), showlegend = FALSE) %>%
    ejes("Valor ajustado", "Residuo")
  q  <- stats::qqnorm(df$std, plot.it = FALSE)
  p2 <- plot_ly(x = q$x, y = q$y, type = "scatter", mode = "markers",
                marker = list(size = 5, opacity = .45, color = pal_cat[1]),
                hovertemplate = "teorico %{x:.2f} · observado %{y:.2f}<extra></extra>") %>%
    add_lines(x = range(q$x), y = range(q$x), inherit = FALSE,
              line = list(dash = "dash", color = pal_cat[2]), showlegend = FALSE) %>%
    ejes("Cuantil teorico normal", "Residuo estandarizado")
  p3 <- plot_ly(x = df$std, type = "histogram", nbinsx = 40,
                marker = list(color = pal_cat[1])) %>%
    ejes("Residuo estandarizado", "Frecuencia")
  p4 <- plot_ly(x = seq_len(nrow(df)), y = df$cook, type = "bar",
                marker = list(color = pal_cat[1])) %>%
    ejes("Observacion", "Distancia de Cook")
  rot <- function(x, y, t) list(x = x, y = y, text = t,
                                showarrow = FALSE, xref = "paper", yref = "paper",
                                xanchor = "center",
                                font = list(size = 13, color = "#0b0b0b"))
  subplot(p1, p2, p3, p4, nrows = 2, titleX = TRUE, titleY = TRUE,
          margin = c(0.07, 0.07, 0.06, 0.14)) %>%
    layout(showlegend = FALSE, paper_bgcolor = "white", plot_bgcolor = "white",
           height = 720,
           title = list(text = titulo, x = 0.02, font = list(size = 14)),
           margin = list(t = 100, l = 70, r = 30, b = 60),
           font = list(family = "system-ui, -apple-system, sans-serif", size = 11),
           annotations = list(
             rot(.22, 1.05, "Residuos vs. ajustados"),
             rot(.78, 1.05, "Q-Q normal"),
             rot(.22, .455, "Histograma de residuos estandarizados"),
             rot(.78, .455, "Distancia de Cook")))
}

## Prediccion con intervalos, siempre en millones de pesos.
## En los modelos con respuesta logaritmica, exp() del ajuste estima la MEDIANA
## condicional, no la media. Se reporta tambien la media, corregida con el
## factor de Duan (smearing): las dos cifras responden a preguntas distintas y
## aqui la diferencia toca el limite del credito.
predecir <- function(m, nuevo, nivel = .95) {
  log_resp <- grepl("^log\\(", deparse(formula(m)[[2]]))
  ip <- predict(m, nuevo, interval = "prediction", level = nivel)
  ic <- predict(m, nuevo, interval = "confidence", level = nivel)
  duan <- if (log_resp) mean(exp(residuals(m))) else 1
  if (log_resp) { ip <- exp(ip); ic <- exp(ic) }
  out <- data.frame(Estrato = nuevo$estrato, `Precio estimado` = ip[, "fit"],
                    `IC 95% inf` = ic[, "lwr"], `IC 95% sup` = ic[, "upr"],
                    `IP 95% inf` = ip[, "lwr"], `IP 95% sup` = ip[, "upr"],
                    check.names = FALSE, row.names = NULL)
  if (log_resp) out <- cbind(out[, 1:2], `Media (Duan)` = ip[, "fit"] * duan, out[, 3:6])
  out
}

## Ranking de ofertas: combina cercania al perfil pedido y descuento frente al
## precio que predice el modelo.
ranking_ofertas <- function(d, m, tope, estratos, objetivo, n = 8) {
  log_resp <- grepl("^log\\(", deparse(formula(m)[[2]]))
  d$pred <- if (log_resp) exp(predict(m, d)) else predict(m, d)
  d$descuento <- (d$pred - d$preciom) / d$pred
  esc <- function(x, ref) abs(x - ref) / stats::sd(x, na.rm = TRUE)
  d$distancia <- esc(d$areaconst, objetivo$areaconst) +
                 esc(d$habitaciones, objetivo$habitaciones) +
                 esc(d$banios, objetivo$banios) +
                 esc(d$parqueaderos, objetivo$parqueaderos)
  # Puntaje: penaliza alejarse del perfil, premia estar por debajo del precio
  # predicho. El descuento pesa el doble que la distancia.
  d$puntaje <- d$distancia - 2 * d$descuento
  cand <- d %>%
    filter(preciom <= tope, estrato %in% estratos, geo_ok,
           # descuentos superiores al 55 % se descartan: en una base de portal
           # inmobiliario suelen ser errores de digitacion
           descuento < 0.55) %>%
    arrange(puntaje)
  list(candidatos = cand, top = head(cand, n))
}

tabla_ofertas <- function(x) {
  x %>%
    transmute(ID = id, Barrio = capitaliza(barrio), Estrato = estrato,
              `Area (m²)` = areaconst, Hab = habitaciones, `Baños` = banios,
              Parq = parqueaderos, `Precio ($M)` = round(preciom, 0),
              `Estimado ($M)` = round(pred, 0),
              `Dif. vs. modelo` = paste0(ifelse(descuento >= 0, "-", "+"),
                                         fnum(abs(descuento) * 100, 1), "%")) %>%
    tabla()
}

4.1 Punto 1 · Filtro de la base y verificacion

base1 <- filtrar_base(viv, tipo_v = "casa", zona_v = "zona norte")
nrow(base1)
#> [1] 722

4.1.1 Primeros tres registros

base1 %>%
  select(id, barrio, estrato, preciom, areaconst, habitaciones, banios,
         parqueaderos, longitud, latitud) %>%
  head(3) %>%
  tabla(cap = tab_cap("Base 1 — primeros tres registros (casas de la Zona Norte)"))
Tabla 14. Base 1 — primeros tres registros (casas de la Zona Norte)
id barrio estrato preciom areaconst habitaciones banios parqueaderos longitud latitud
1209 acopi 5 320 150 6 4 2 -76.51341 3.47968
1592 acopi 5 780 380 3 3 2 -76.51674 3.48721
4057 acopi 6 750 445 6 7 0 -76.52950 3.38527

4.1.2 Tablas de comprobacion

# El filtro debe dejar una sola combinacion tipo × zona
table(base1$tipo, base1$zona) %>%
  tabla(cap = tab_cap("Comprobacion del filtro: una sola celda no vacia"))
Tabla 15. Comprobacion del filtro: una sola celda no vacia
zona norte
casa 722
base1 %>%
  group_by(Estrato = estrato) %>%
  summarise(Ofertas = n(),
            `Precio mediano ($M)` = median(preciom),
            `Area mediana (m²)`   = median(areaconst),
            `$M por m² (mediana)` = round(median(precio_m2) / 1e6, 2),
            .groups = "drop") %>%
  tabla(cap = tab_cap("Base 1 — distribucion por estrato"))
Tabla 16. Base 1 — distribucion por estrato
Estrato Ofertas Precio mediano ($M) Area mediana (m²) $M por m² (mediana)
3 235 215 130 1.56
4 161 380 264 1.60
5 271 480 298 1.71
6 55 800 350 2.11
base1 %>%
  count(Barrio = capitaliza(barrio), sort = TRUE) %>%
  head(10) %>% rename(Ofertas = n) %>%
  tabla(cap = tab_cap("Base 1 — diez barrios con mas oferta"))
Tabla 17. Base 1 — diez barrios con mas oferta
Barrio Ofertas
Flora 100
Acopi 70
Villa del Prado 41
Bosque 37
Prados del Norte 31
San Vicente 31
Vipasa 30
Merced 24
Urbanizacion la Flora 23
Brisas de los 22

4.1.3 Mapa de la base y verificacion geografica

mapa(base1, titulo = fig("Base 1: casas ofertadas en la Zona Norte de Cali"))

4.1.4 ¿los puntos estan en la zona correspondiente?

No. 70 de los 722 registros (9,7 %) caen fuera del nucleo geografico de la propia Zona Norte, y 93 registros se ubican al sur del centro de la ciudad pese a estar etiquetados como norte.

La regla de Tukey marca atipicos respecto de la propia nube de puntos de la zona y debe leerse como registros que conviene verificar, no como registros probadamente erroneos”. Lo que si esta probado son los casos concretos que se documentan abajo.

fuera1 %>%
  count(Barrio = capitaliza(barrio), sort = TRUE) %>% head(8) %>%
  rename(`Registros fuera del nucleo` = n) %>%
  tabla(cap = tab_cap("Base 1 — barrios que concentran los puntos mal ubicados"))
Tabla 18. Base 1 — barrios que concentran los puntos mal ubicados
Barrio Registros fuera del nucleo
Acopi 42
Cali 10
Flora 4
Urbanizacion la Flora 2
Floresta 1
Gran Limonar 1
Granjas 1
Juanambu 1

Zona y las coordenadas provienen de fuentes distintas, la zona es un atributo administrativo que el portal asigna al aviso, mientras que longitud/latitud salen de un geocodificador que intenta resolver la direccion.

  1. Repliegue al centroide de la ciudad. La coordenada (-76.532, 3.452) —practicamente el centro de Cali— se repite 21 veces en esta base. Es la respuesta tipica de un geocodificador cuando no logra resolver la direccion.

  2. Etiquetas de barrio genericas o ambiguas. Aparecen valores como cali, zona norte o acopi. Este ultimo corresponde a la zona industrial de Yumbo, un municipio vecino, y sus 70 registros tienen 65 coordenadas distintas repartidas por toda la ciudad.

  3. Nombres de barrio que se repiten en distintos sectores de Cali, lo que lleva al geocodificador al homonimo equivocado.

  4. Errores de digitacion en la direccion de origen del aviso.

La clasificacion por zona es la variable confiable para filtrar la oferta; las coordenadas lo son para visualizar, pero no todas son validas, por eso el mapa de ofertas recomendadas del punto 6 excluye los puntos marcados como geo_ok = FALSEy es necesario recomendar al cliente visitar una casa cuya coordenada apunta a otro extremo de la ciudad seria un error operativo.

4.2 Punto 2 · Analisis exploratorio de la base 1

4.2.1 Correlaciones

vars <- c("preciom", "areaconst", "estrato", "banios", "habitaciones", "parqueaderos")
M1cor <- cor(base1[, vars])

plot_ly(x = etq(vars), y = etq(vars), z = round(M1cor, 2), type = "heatmap",
        colors = colorRamp(pal_div), zmin = -1, zmax = 1,
        text = round(M1cor, 2), texttemplate = "%{text}",
        hovertemplate = "%{y} vs %{x}: %{z}<extra></extra>") %>%
  estilo(titulo = fig("Base 1 — matriz de correlaciones")) %>%
  layout(yaxis = list(autorange = "reversed"))
cor_precio <- sort(M1cor["preciom", -1], decreasing = TRUE)
plot_ly(x = etq(names(cor_precio)), y = as.numeric(cor_precio), type = "bar",
        marker = list(color = pal_cat[1]),
        hovertemplate = "%{x}: r = %{y:.3f}<extra></extra>") %>%
  estilo(titulo = fig("Correlacion de cada atributo con el precio (base 1)"),
         y = "Coeficiente de Pearson") %>%
  # plotly reordena alfabeticamente los ejes categoricos: se fija el orden
  layout(xaxis = list(categoryorder = "array", categoryarray = etq(names(cor_precio))))

Lectura. El area construida es el predictor individual mas fuerte (r = 0,770), seguida del estrato (r = 0,654) y del numero de baños (r = 0,575). Las habitaciones (r = 0,350) y los parqueaderos (r = 0,343) muestran asociacion debil.

Baños y habitaciones estan correlacionados entre si (r = 0,607) y ambos con el area, Buena parte de lo que “explican” por separado es en realidad tamaño del inmueble. La regresion multiple del punto 3 separa esos efectos.

4.2.2 Precio y area construida

plot_ly(base1, x = ~areaconst, y = ~preciom, color = ~factor(estrato),
        colors = pal_seq[c(2, 4, 5, 7)], type = "scatter", mode = "markers",
        marker = list(size = 7, opacity = .6),
        hovertext = ~paste0(capitaliza(barrio), "<br>", areaconst, " m² · $",
                            fnum(preciom, 0), " M · estrato ", estrato),
        hoverinfo = "text") %>%
  estilo(titulo = fig("Precio vs. area construida por estrato (base 1)"),
         x = "Area construida (m²)", y = "Precio (millones $)", leyenda = "Estrato")

La nube es claramente creciente pero se abre a medida que crece el area, en casas pequeñas los precios se parecen entre si, en casas grandes la dispersion es mayor, este abanico es heterocedasticidad y reaparecera en la validacion de supuestos, gracias al capping de la seccion 2.2, el eje ya no esta dominado por dos o tres inmuebles de mas de 1.000 m2, la relacion se ve con mucha mas claridad que sobre la base cruda.

4.2.3 Precio por estrato

plot_ly(base1, x = ~factor(estrato), y = ~preciom, color = ~factor(estrato),
        colors = pal_seq[c(2, 4, 5, 7)], type = "box", boxpoints = "outliers") %>%
  estilo(titulo = fig("Distribucion del precio por estrato (base 1)"),
         x = "Estrato", y = "Precio (millones $)") %>%
  layout(showlegend = FALSE)

En la figura 16 se aprecia como la mediana sube de forma monotona con el estrato, pero el salto no es uniforme, entre estrato 5 y 6 la diferencia es mucho mayor que entre 3 y 4, confirmando asi el argumento de la seccion 1.2, el estrato es ordinal, no numerico.

4.2.4 Efecto de la zona

La seccion 3.4 mostro que las cinco zonas tienen niveles de precio muy distintos, y que la Zona Oeste y la Oriente estan en extremos opuestos del mercado. Dentro de la base 1 la variable zona es constante, asi que no puede entrar como predictor en el modelo del punto 3, su efecto ya quedo absorbido por el filtro.

4.2.5 Baños, habitaciones y parqueaderos

subplot(
  plot_ly(base1, x = ~factor(banios), y = ~preciom, type = "box",
          marker = list(color = pal_cat[1]), name = "Baños"),
  plot_ly(base1, x = ~factor(habitaciones), y = ~preciom, type = "box",
          marker = list(color = pal_cat[3]), name = "Habitaciones"),
  plot_ly(base1, x = ~factor(parqueaderos), y = ~preciom, type = "box",
          marker = list(color = pal_cat[4]), name = "Parqueaderos"),
  nrows = 1, shareY = TRUE, titleX = FALSE) %>%
  estilo(titulo = fig("Precio segun baños, habitaciones y parqueaderos (base 1)"),
         y = "Precio (millones $)")

En la figura 17 es posible observar como el precio crece con el numero de baños de formar regular, con las habitaciones la relacion es plana a partir de 4 cuartos y con los parqueaderos es erratica en los valores altos porque hay pocas observaciones alli. Esto anticipa cuales coeficientes van a resultar no significativos.

4.3 Punto 3 · Modelo de regresion lineal multiple

modelo1 <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
              data = base1)
summary(modelo1)
#> 
#> Call:
#> lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos + 
#>     banios, data = base1)
#> 
#> Residuals:
#>     Min      1Q  Median      3Q     Max 
#> -366.56  -77.69  -14.63   49.32  782.96 
#> 
#> Coefficients:
#>                Estimate Std. Error t value Pr(>|t|)    
#> (Intercept)  -224.30281   28.02044  -8.005 4.83e-15 ***
#> areaconst       0.89112    0.04679  19.043  < 2e-16 ***
#> estrato        81.53439    6.72510  12.124  < 2e-16 ***
#> habitaciones   -0.77933    4.13115  -0.189    0.850    
#> parqueaderos    1.45518    3.73168   0.390    0.697    
#> banios         25.80971    5.07826   5.082 4.76e-07 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> Residual standard error: 138.6 on 716 degrees of freedom
#> Multiple R-squared:  0.6994, Adjusted R-squared:  0.6973 
#> F-statistic: 333.2 on 5 and 716 DF,  p-value: < 2.2e-16
Tabla 19. Modelo 1 (base 1) — coeficientes estimados
Variable Coeficiente Error estandar t Valor p Significativo
(Intercept) -224.303 28.020 -8.00 < 0,0001 Si
areaconst 0.891 0.047 19.04 < 0,0001 Si
estrato 81.534 6.725 12.12 < 0,0001 Si
habitaciones -0.779 4.131 -0.19 0,8504 No
parqueaderos 1.455 3.732 0.39 0,6967 No
banios 25.810 5.078 5.08 < 0,0001 Si

4.3.1 Interpretacion de los coeficientes

Significancia al 5 %:

  • Area construida (0,891). Cada metro cuadrado adicional se asocia con $0,89 millones mas de precio unos $891 mil pesos por m2 manteniendo constantes, por otra parte estrato, habitaciones, parqueaderos y baños para la casa solicitada, pasan de 200 a 220 m2 implicando unos $18 millones adicionales. siendo asi coherente con el mercado, el area es el atributo que mas se paga.

  • Estrato (81,5). Subir un estrato se asocia con $82 millones mas, de igual tamaño y demas atributos esto tene sentido economico, el estrato resume la calidad del entorno, el acceso a servicios y el prestigio del sector, todo lo cual se capitaliza en el precio, pero conviene recordar la advertencia de la seccion 1.2: este coeficiente unico impone que pasar de estrato 3 a 4 valga exactamente lo mismo que pasar de 5 a 6 y los boxplots del punto 2 muestran que no es asi. El Anexo A cuantifica el costo de esa imposicion.

  • Baños (25,8). Cada baño adicional se asocia con $26 millones mas. En vivienda el numero de baños funciona como indicador de gama del acabado, no solo de tamaño, por eso sobrevive incluso controlando por area.

No significativos:

  • Habitaciones (p = 0,850) y parqueaderos (p = 0,697) no aportan informacion una vez que el area esta en el modelo, esto no significa que no importen para el comprador, significa que su efecto ya esta contenido en el area construida (una casa con mas cuartos es, casi por definicion, una casa mas grande) la correlacion simple de habitaciones con precio era 0,35, al controlar por tamaño se desvanece. Es exactamente el fenomeno que la seccion 3.3 anticipo al mostrar que habitaciones mide densidad de ocupacion, no calidad.

El intercepto (-224) no tiene interpretacion sustantiva: corresponderia a una casa de 0 m2, estrato 0 y sin baños. Su valor negativo es solo el ajuste algebraico de la recta.

4.3.2 Coeficiente de determinacion y ajuste

Tabla 20. Modelo 1 (base 1) — bondad de ajuste
Indicador Valor
0,6994
R² ajustado 0,6973
Error estandar residual ($M) 138,6
Error absoluto medio ($M) 94,3
Error porcentual absoluto medio 21,6%
F (valor p) < 0,0001

El modelo explica el 69,9 % de la variabilidad del precio de las casas de la Zona Norte, y deja fuera el 30,1 %. Es un ajuste razonable para un modelo con cinco atributos fisicos.

El 70 % Corresponde al error estandar residual es de $139 millones: para un inmueble del orden de $350 millones, la desviacion tipica del error equivale al 40 % de su valor, el modelo se equivoca en promedio en $94 millones (error absoluto medio), si en vez de promediar los pesos se promedian los errores relativos de cada inmueble, la cifra es 21,6 % —mas baja porque las casas economicas que son mayoria se predicen con un menor error absoluto.

Que falta y como mejorarlo:

1.Antiguedad y estado de conservacion: Dos casas de 200 m2 en el mismo estrato pueden diferir en un 40 % segun sean de obra nueva o de treinta años. Es, casi con seguridad, la variable omitida mas costosa.

2.Microlocalizacion: Incluir el barrio como efecto o la distancia a vias principales, centros comerciales y colegios— captaria diferencias reales dentro de la misma zona. La seccion 3.5 mostro que la oferta esta muy concentrada por barrio, de modo que hay datos suficientes para estimar esos efectos.

3.Forma funcional logaritmica: El diagrama de dispersion mostro un abanico creciente: el precio no responde de forma lineal al area. Especificar log(precio) ~ log(area) corrige buena parte de la heterocedasticidad y evita predicciones imposibles (Anexo A).

4.4 Punto 4 · Validacion de supuestos

tabla_supuestos(modelo1, "Modelo 1 (base 1) — pruebas de supuestos")
Modelo 1 (base 1) — pruebas de supuestos
Supuesto Prueba Estadistico Valor p
Linealidad / especificacion RESET de Ramsey 9.096 0,0001
Homocedasticidad Breusch-Pagan 92.061 < 0,0001
Normalidad de residuos Anderson-Darling 15.887 < 0,0001
Independencia Durbin-Watson 1.611 < 0,0001
No multicolinealidad VIF maximo 2.121
graficos_diagnostico(modelo1,
  fig("Diagnostico de residuos del modelo lineal de casas de la Zona Norte"))

4.4.1 Interpretacion

  • Homocedasticidad — se rechaza. Breusch-Pagan da un valor p practicamente nulo y los residuos contra ajustados muestran el embudo caracteristico. Los coeficientes siguen siendo insesgados, pero no su precision, la formula de minimos cuadrados estima una sola varianza del error y la aplica a todas las observaciones, cuando aqui la del segmento caro cuadruplica a la del economico. Errores estandar, valores p e intervalos de confianza quedan por tanto mal calculados.

  • Normalidad de los residuos — se rechaza. Anderson-Darling con valor p en el minimo detectable; el Q-Q se despega en la cola derecha. Con n = 722 el teorema central del limite protege la inferencia sobre los coeficientes frente a la no normalidad conviene precisar que no protege frente a la heterocedasticidad, que es un problema distinto y sigue exigiendo errores estandar robustos y no protege en absoluto los intervalos de prediccion individuales, que es lo que se usa en el punto 5.

  • Independencia — se rechaza. Durbin-Watson resulta significativo. DW contrasta la correlacion entre residuos en el orden en que estan las filas, y en datos de corte transversal ese orden es arbitrario. Aqui no lo es del todo, porque la base viene agrupada por barrio, de modo que lo que detecta es agrupamiento espacial — exactamente la concentracion que documento la seccion 3.5. El diagnostico apropiado no seria DW sino errores agrupados por barrio o una prueba explicita de autocorrelacion espacial (I de Moran).

  • Especificacion — RESET significativo, lo que confirma que la relacion precio-area no es lineal.

  • Multicolinealidad — sin problema. El VIF maximo es 2,12, muy por debajo del umbral habitual de 5. Los efectos de las variables se pueden separar sin ambiguedad.

4.4.2 Que se podria hacer

Problema Correccion sugerida
Heterocedasticidad Errores estandar robustos de White (sandwich::vcovHC), o transformacion logaritmica de la respuesta, que es lo que hace el modelo M3 del Anexo A
No normalidad Transformacion log; para intervalos de prediccion, bootstrap o regresion cuantilica
Mala especificacion log(precio) ~ log(area), terminos cuadraticos, interaccion area × estrato
Atipicos influyentes Ya se acotaron en la seccion 2.2 mediante capping condicionado; podria ademas usarse regresion robusta (MASS::rlm)

El resultado mas importante de esta seccion. El Anexo A muestra que la transformacion logaritmica, aplicada sobre esta base ya tratada, hace que el valor p de Breusch-Pagan pase de <2e-16 a 0,193: el modelo log-log de casas de la Zona Norte ya no rechaza la homocedasticidad, las dos intervenciones se necesitan mutuamente —el capping de la seccion 2 y la forma funcional del Anexo A—, y ninguna por separado alcanza ese resultado.

Por eso las predicciones de los puntos 5 y 6 se hacen con ese modelo, reportando siempre tambien el modelo del punto 3 para comparacion.

4.5 Punto 5 · Prediccion del precio solicitado

solicitud1 <- data.frame(areaconst = 200, estrato = c(4, 5), habitaciones = 4,
                         parqueaderos = 1, banios = 2) %>%
  mutate(estrato_f = factor(estrato, levels = levels(base1$estrato_f), ordered = TRUE))

pred_M1 <- predecir(modelo1,  solicitud1)
pred_M3 <- predecir(mods1$M3, solicitud1)
Tabla 21. Precio estimado para la vivienda 1 (millones de pesos). En el modelo log-log, ‘Precio estimado’ es la mediana condicional y ‘Media (Duan)’ el valor esperado.
Modelo Estrato Precio estimado Media (Duan) IC 95% inf IC 95% sup IP 95% inf IP 95% sup
…1 Punto 3 (lineal) 4 330 314 346 58 603
…2 Punto 3 (lineal) 5 412 389 434 139 685
1 Anexo A (log-log) 4 330 341 314 346 198 550
2 Anexo A (log-log) 5 376 389 360 394 226 627

4.5.1 Lectura del resultado

Esto se realiza con los resultados obtenidos en la tabla 21 y la figura 19 una casa de 200 m², 4 habitaciones, 2 baños y 1 parqueadero en la Zona Norte se valora en:

  • Estrato 4: $330 millones. mediano, o $341 millones.
  • Estrato 5: $376 millones. mediano, o $389 millones.

Mediana o media: Como el modelo elegido tiene la respuesta en logaritmos, exp() del valor ajustado estima la mediana del precio de las casas con esas caracteristicas, no su promedio. Por la misma razon, las columnas “IC 95 %” e “IP 95 %” de la tabla son intervalos para esa mediana, no para la media corregida por Duan, que se reporta como cifra puntual. La media se obtiene multiplicando por el factor de Duan (1,0348 en esta base). La mediana responde a ¿cuanto cuesta la casa tipica con este perfil? y la media a ¿cuanto costaria en promedio comprar una? se reportan las dos porque la diferencia —unos $11 millones cae justo sobre el limite del credito.

Frente al credito de $350 millones (la Figura 19 lo muestra de un vistazo): la solicitud es viable en estrato 4 pero sin ningun margen. La mediana estimada deja $20 millones de holgura, y la media $9. En estrato 5 la operacion no es financiable bajo ninguna de las dos lecturas: $376 – $389 millones contra un cupo de $350.

El modelo lineal del punto 3 coincide en estrato 4 ($330 M), lo que da confianza en la cifra. En estrato 5 discrepa mas ($412 M) porque impone un salto de estrato constante que los datos no respaldan.

Advertencia sobre la precision. El intervalo de prediccion individual —el que corresponde a una casa concreta, no al valor tipico del grupo— va de $198 a $550 millones en estrato 4, es decir −40 % / +67 % alrededor de la estimacion esa amplitud refleja el 21 % de variacion que el modelo no explica antiguedad, estado, acabados, no un defecto de calculo.

Vale la pena destacar ventaja del modelo log-log, que el modelo lineal del punto 3 produce un intervalo de prediccion de $58 a $603 millones para esa misma casa: un limite inferior de $58 millones para una casa de 200 m² es imposible, y es consecuencia directa de asumir errores normales de varianza constante sobre una variable que no puede ser negativa.

4.6 Punto 6 · Ofertas potenciales dentro del credito

Criterio de seleccion, en tres filtros y un ordenamiento:

  1. Presupuesto: precio ≤ $350 millones.

  2. Estrato: 4 o 5, segun lo pedido.

  3. Ubicacion verificada: se excluyen los registros con coordenada fuera del nucleo de la Zona Norte (punto 1).

  4. Descuento plausible: se descartan las ofertas listadas mas de un 55 % por debajo del precio que predice el modelo. A esa magnitud, en una base de portal inmobiliario, lo mas probable es un error de digitacion en el precio o el area, no una oportunidad real.

  5. Ordenamiento: puntaje que combina la cercania al perfil solicitado distancia estandarizada en area, habitaciones, baños y parqueaderos— con el descuento frente al precio que predice el modelo. Una oferta listada por debajo de su valor estimado es una oportunidad; el descuento pesa el doble que la distancia al perfil.

ofertas1 <- ranking_ofertas(
  base1, mods1$M3, tope = 350, estratos = c(4, 5),
  objetivo = list(areaconst = 200, habitaciones = 4, banios = 2, parqueaderos = 1))

nrow(ofertas1$candidatos)
#> [1] 102
tabla_ofertas(ofertas1$top)
ID Barrio Estrato Area (m²) Hab Baños Parq Precio ($M) Estimado ($M) Dif. vs. modelo
1943 Vipasa 5 346.00 4 2 1 350 484 -27,7%
1163 Merced 5 216.00 4 2 2 350 395 -11,4%
1008 Ciudad los Alamos 4 143.85 4 2 0 215 280 -23,2%
94 Zona Norte 4 162.00 4 3 1 265 316 -16,2%
1666 Alamos 4 120.00 4 2 1 275 261 +5,5%
3586 Merced 4 240.00 3 2 1 330 354 -6,8%
1376 Flora 5 160.00 4 3 1 320 359 -10,9%
1924 Vipasa 4 264.00 3 2 1 320 370 -13,5%
top1 <- ofertas1$top
mapa(top1, zoom = 12,
     titulo = fig("Vivienda 1: ocho ofertas recomendadas (≤ $350 M, Zona Norte)"),
     etiqueta = with(top1, paste0(
       capitaliza(barrio), " (ID ", id, ")<br>$", fnum(preciom, 0),
       " M · estimado $", fnum(pred, 0), " M<br>", fnum(areaconst, 0),
       " m² · estrato ", estrato, "<br>", habitaciones, " hab · ", banios,
       " baños · ", parqueaderos, " parq.")))

4.6.1 Analisis de las ofertas

Tabla 22. Perfil del conjunto de ofertas viables — vivienda 1
Indicador Valor
Ofertas que cumplen todos los filtros 102
Precio mediano ($M) 320
Area mediana (m²) 168
Ofertas con area ≥ 180 m² 48
Ofertas por debajo del precio estimado 84 (82%)
Barrios representados 23

Lo que muestra el mapa. Las ocho recomendaciones se reparten entre Alamos, Ciudad los Alamos, Flora, Merced, Vipasa, Zona Norte. En el conjunto completo de 102 candidatas los barrios con mas oferta viable son Bosque, Flora, Merced, Villa del Sol: ahi es donde coincide la oferta de casas de estrato 4 y 5 con precios por debajo de $350 millones. Los sectores del extremo norte, mas costosos, quedan fuera del cupo.

Tres lecturas para el cliente:

1.El presupuesto compra area, pero cediendo en estrato o en acabados. Las ofertas mejor rankeadas rondan los 189 m² y son mayoritariamente de estrato 4. El cliente puede tener el tamaño que quiere si acepta el estrato bajo del rango.

2.Hay oportunidades reales de negociacion.54 ofertas estan listadas mas de un 15 % por debajo del precio que predice el modelo. No todas seran gangas: parte de esa brecha corresponde a antiguedad o estado, que el modelo no observa. Son las que conviene visitar primero, con esa hipotesis en mente.

3.El perfil exacto es escaso. Solo 9 ofertas combinan area entre 180 y 220 m² con 4 habitaciones dentro del presupuesto. Conviene plantear al cliente que flexibilice el numero de habitaciones —que, como mostraron el punto 3 y la seccion 3.3, no es lo que determina el precio— antes que el area o el estrato.


5 Solicitud 2 — Apartamento en Zona Sur, credito de $850 millones

Punto 7 del enunciado: se repite integramente el procedimiento de los puntos 1 a 6, con las mismas funciones y los mismos criterios, para la segunda solicitud.

Perfil pedido: apartamento, 300 m² construidos, 3 parqueaderos, 3 baños, 5 habitaciones, estrato 5 o 6, Zona Sur. Credito preaprobado: $850 millones.

5.1 Punto 1 · Filtro de la base y verificacion

base2 <- filtrar_base(viv, tipo_v = "apartamento", zona_v = "zona sur")
nrow(base2)
#> [1] 2787
base2 %>%
  select(id, barrio, estrato, preciom, areaconst, habitaciones, banios,
         parqueaderos, longitud, latitud) %>%
  head(3) %>%
  tabla(cap = tab_cap("Base 2 — primeros tres registros (apartamentos de la Zona Sur)"))
Tabla 23. Base 2 — primeros tres registros (apartamentos de la Zona Sur)
id barrio estrato preciom areaconst habitaciones banios parqueaderos longitud latitud
5098 acopi 4 290 96 3 2 1 -76.53464 3.44987
698 aguablanca 3 78 40 2 1 1 -76.50100 3.40000
8199 aguacatal 6 875 194 3 5 2 -76.55700 3.45900
table(base2$tipo, base2$zona) %>% tabla(cap = tab_cap("Comprobacion del filtro"))
Tabla 24. Comprobacion del filtro
zona sur
apartamento 2787
base2 %>%
  group_by(Estrato = estrato) %>%
  summarise(Ofertas = n(),
            `Precio mediano ($M)` = median(preciom),
            `Area mediana (m²)`   = median(areaconst),
            `$M por m² (mediana)` = round(median(precio_m2) / 1e6, 2),
            .groups = "drop") %>%
  tabla(cap = tab_cap("Base 2 — distribucion por estrato"))
Tabla 25. Base 2 — distribucion por estrato
Estrato Ofertas Precio mediano ($M) Area mediana (m²) $M por m² (mediana)
3 201 128 61 2.03
4 1091 188 70 2.70
5 1033 280 91 3.06
6 462 580 136 3.97
base2 %>%
  count(Barrio = capitaliza(barrio), sort = TRUE) %>% head(10) %>%
  rename(Ofertas = n) %>%
  tabla(cap = tab_cap("Base 2 — diez barrios con mas oferta"))
Tabla 26. Base 2 — diez barrios con mas oferta
Barrio Ofertas
Valle del Lili 838
Ciudad Jardin 227
Pance 205
Caney 183
Ingenio 129
Hacienda 109
Refugio 78
Limonar 59
Melendez 59
Quintas de Don 58

5.1.1 Mapa de la base

mapa(base2, titulo = fig("Base 2: apartamentos ofertados en la Zona Sur de Cali"), tam = 5)

5.1.2 Discusion geografica

116 registros (4,2 %) caen fuera del nucleo de la Zona Sur, y 125 aparecen al norte de la latitud 3,45, muy lejos del sector.

fuera2 %>%
  count(Barrio = capitaliza(barrio), sort = TRUE) %>% head(8) %>%
  rename(`Registros fuera del nucleo` = n) %>%
  tabla(cap = tab_cap("Base 2 — barrios que concentran los puntos mal ubicados"))
Tabla 27. Base 2 — barrios que concentran los puntos mal ubicados
Barrio Registros fuera del nucleo
Valle del Lili 55
Ciudad Bochalema 10
Pance 10
Ciudad Jardin 6
Caney 4
Refugio 4
Bochalema 3
Ingenio 3

El diagnostico es el mismo del punto 1 y su magnitud es menor que en la base 1 (4,2 % frente a 9,7 %). La razon es que la Zona Sur concentra barrios de urbanizacion reciente y bien delimitada Valle del Lili, Ciudad Jardin, Pance, El Ingenio—, con nomenclatura estandarizada que el geocodificador resuelve mejor. Aun asi, valle del lili que aporta 838 de las 2.787 ofertas tiene coordenadas que van de la latitud 3,344 a la 3,483, un recorrido imposible para un solo barrio: es un sector muy extenso y de rapido crecimiento cuya geocodificacion arrastra direcciones nuevas mal resueltas.

Igual que antes, filtrar por zona, visualizar con las coordenadas y excluir del mapa de recomendaciones los puntos no verificados.

5.2 Punto 2 · Analisis exploratorio de la base 2

5.2.1 Correlaciones

M2cor <- cor(base2[, vars])

plot_ly(x = etq(vars), y = etq(vars), z = round(M2cor, 2), type = "heatmap",
        colors = colorRamp(pal_div), zmin = -1, zmax = 1,
        text = round(M2cor, 2), texttemplate = "%{text}",
        hovertemplate = "%{y} vs %{x}: %{z}<extra></extra>") %>%
  estilo(titulo = fig("Base 2 — matriz de correlaciones")) %>%
  layout(yaxis = list(autorange = "reversed"))
cor_precio2 <- sort(M2cor["preciom", -1], decreasing = TRUE)
plot_ly(x = etq(names(cor_precio2)), y = as.numeric(cor_precio2), type = "bar",
        marker = list(color = pal_cat[1]),
        hovertemplate = "%{x}: r = %{y:.3f}<extra></extra>") %>%
  estilo(titulo = fig("Correlacion de cada atributo con el precio (base 2)"),
         y = "Coeficiente de Pearson") %>%
  # plotly reordena alfabeticamente los ejes categoricos: se fija el orden
  layout(xaxis = list(categoryorder = "array", categoryarray = etq(names(cor_precio2))))

Hay una diferencia notable frente a la base 1 en apartamentos, baños (r = 0,736) y parqueaderos (r = 0,675) estan mucho mas asociados al precio que en casas (alli eran 0,57 y 0,34), esto tiene una explicacion de mercado clara: en propiedad horizontal el parqueadero es un bien escaso que se vende como unidad privada, mientras que en una casa el garaje viene con el lote las habitaciones siguen siendo el atributo mas debil (r = 0,346).

5.2.2 Precio y area construida

plot_ly(base2, x = ~areaconst, y = ~preciom, color = ~factor(estrato),
        colors = pal_seq[c(2, 4, 5, 7)], type = "scatter", mode = "markers",
        marker = list(size = 5, opacity = .5),
        hovertext = ~paste0(capitaliza(barrio), "<br>", areaconst, " m² · $",
                            fnum(preciom, 0), " M · estrato ", estrato),
        hoverinfo = "text") %>%
  estilo(titulo = fig("Precio vs. area construida por estrato (base 2)"),
         x = "Area construida (m²)", y = "Precio (millones $)", leyenda = "Estrato")

Tal como se aprecia en la figura 24 la nube es mas compacta que en casas los apartamentos son un producto mas estandarizado y la separacion por estrato es nitida, el estrato 6 forma una banda claramente superior, la mayor parte de las observaciones se concentra por debajo de los 150 m2, el perfil pedido de 300 m2 se ubica en el extremo derecho, donde hay pocos datos. Esto es clave para la prediccion del punto 5.

5.2.3 Precio por estrato

plot_ly(base2, x = ~factor(estrato), y = ~preciom, color = ~factor(estrato),
        colors = pal_seq[c(2, 4, 5, 7)], type = "box", boxpoints = "outliers") %>%
  estilo(titulo = fig("Distribucion del precio por estrato (base 2)"),
         x = "Estrato", y = "Precio (millones $)") %>%
  layout(showlegend = FALSE)

En la figura 25 podemos observar que el salto entre estrato 5 y 6 es todavia mas pronunciado que en casas, la mediana practicamente se duplica. Refuerza el argumento de la seccion 1.2.

5.2.4 Efecto de la zona

ord_zona2 <- viv %>% filter(tipo == "apartamento") %>% group_by(zona) %>%
  summarise(m = median(preciom)) %>% arrange(desc(m)) %>% pull(zona) %>% capitaliza()
viv %>%
  filter(tipo == "apartamento") %>%
  mutate(dest = ifelse(zona %in% c("zona norte", "zona sur"),
                       "Zonas de las solicitudes", "Resto de la ciudad")) %>%
  plot_ly(x = ~factor(capitaliza(zona), levels = ord_zona2), y = ~preciom, color = ~dest,
          colors = c(`Zonas de las solicitudes` = pal_cat[1],
                     `Resto de la ciudad` = "#b9bcc0"),
          type = "box", boxpoints = FALSE) %>%
  estilo(titulo = fig("Precio de apartamentos por zona de la ciudad"),
         y = "Precio (millones $)") %>%
  layout(xaxis = list(categoryorder = "array", categoryarray = ord_zona2))

En apartamentos el ordenamiento entre zonas cambia respecto al de casas, la zona Oeste sigue encabezando, pero la Zona Norte y la zona Sur quedan practicamente empatadas en mediana ($250 M contra $245 M), a pesar que laTabla 12 mostraba la zona sur claramente por encima considerando todos los inmuebles, es una razon adicional para no mezclar tipos en un mismo modelo: el efecto de la zona depende del tipo de vivienda, es decir, hay interaccion entre las dos variables y el filtro del punto 1 la resuelve por separacion.

5.2.5 Baños, habitaciones y parqueaderos

subplot(
  plot_ly(base2, x = ~factor(banios), y = ~preciom, type = "box",
          marker = list(color = pal_cat[1]), name = "Baños"),
  plot_ly(base2, x = ~factor(habitaciones), y = ~preciom, type = "box",
          marker = list(color = pal_cat[3]), name = "Habitaciones"),
  plot_ly(base2, x = ~factor(parqueaderos), y = ~preciom, type = "box",
          marker = list(color = pal_cat[4]), name = "Parqueaderos"),
  nrows = 1, shareY = TRUE, titleX = FALSE) %>%
  estilo(titulo = fig("Precio segun baños, habitaciones y parqueaderos (base 2)"),
         y = "Precio (millones $)")

En apartamentos la relacion con baños y parqueaderos es fuerte y monotona. Con las habitaciones el precio mediano tambien sube (de $245 M con 3 alcobas a $480 M con 5), pero esa subida es puro efecto del tamaño total los apartamentos de 5 habitaciones son mas grandes. Por lo que conviene retener este grafico, porque en el punto 3 el coeficiente de habitaciones va a salir negativo: una vez que se controla por el area, el grafico marginal y el efecto parcial apuntan en direcciones opuestas. Es el ejemplo mas claro de por que un boxplot no permite anticipar el signo de un coeficiente de regresion multiple.

5.3 Punto 3 · Modelo de regresion lineal multiple

modelo2 <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
              data = base2)
summary(modelo2)
#> 
#> Call:
#> lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos + 
#>     banios, data = base2)
#> 
#> Residuals:
#>     Min      1Q  Median      3Q     Max 
#> -958.98  -42.09   -1.39   36.15  920.23 
#> 
#> Coefficients:
#>               Estimate Std. Error t value Pr(>|t|)    
#> (Intercept)  -212.6741    13.6005 -15.637  < 2e-16 ***
#> areaconst       1.5755     0.0515  30.593  < 2e-16 ***
#> estrato        56.1767     2.7485  20.439  < 2e-16 ***
#> habitaciones  -25.7006     3.4741  -7.398 1.82e-13 ***
#> parqueaderos   45.6069     2.9943  15.231  < 2e-16 ***
#> banios         47.1942     3.1191  15.131  < 2e-16 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> Residual standard error: 93.52 on 2781 degrees of freedom
#> Multiple R-squared:  0.7621, Adjusted R-squared:  0.7616 
#> F-statistic:  1781 on 5 and 2781 DF,  p-value: < 2.2e-16
Tabla 28. Modelo 2 (base 2) — coeficientes estimados
Variable Coeficiente Error estandar t Valor p Significativo
(Intercept) -212.674 13.601 -15.64 < 0,0001 Si
areaconst 1.575 0.051 30.59 < 0,0001 Si
estrato 56.177 2.749 20.44 < 0,0001 Si
habitaciones -25.701 3.474 -7.40 < 0,0001 Si
parqueaderos 45.607 2.994 15.23 < 0,0001 Si
banios 47.194 3.119 15.13 < 0,0001 Si

5.3.1 Interpretacion de los coeficientes

A diferencia de la base 1, aqui las cinco variables son significativas al 5 %.

  • Area construida (1,575). Cada m2 adicional se asocia con $1,58 millones mas, cerca del doble que en casas de la Zona Norte ($0,89 M/m2). El suelo y la construccion en la Zona Sur se pagan considerablemente mas caros.

  • Estrato (56,2). Cada estrato adicional se asocia con $56 millones mas. Este promedio unico esconde que los escalones no valen lo mismo: en el modelo log-log del Anexo A los saltos son +NA %, +NA % y +NA % — el paso de 5 a 6 es el mayor, pero el de 3 a 4 es casi igual de grande, y el de 4 a 5 apenas la mitad.

  • Parqueaderos (45,6). Cada cupo se asocia con $46 millones mas. Es el contraste mas claro con la base 1, donde no era significativo: en propiedad horizontal el parqueadero es una unidad privada escasa y con precio propio.

  • Baños (47,2). Cada baño adicional se asocia con $47 millones mas, cerca del doble de la prima estimada en casas ($26 M). La diferencia es coherente con dos mercados distintos: en propiedad horizontal el baño adicional suele venir con una alcoba con baño privado, un atributo de gama alta.

  • Habitaciones (-25,7) — coeficiente negativo y significativo. A primera vista parece ilogico, y merece detenerse, el coeficiente dice que manteniendo constante el area, cada habitacion adicional se asocia con $26 millones menos. No significa que las habitaciones resten valor, significa que subdividir la misma superficie en mas cuartos va asociado a un apartamento de menor gama. Un apartamento de 120 m2 con 2 alcobas amplias vale mas que uno de 120 m2 con 4 alcobas pequeñas. La correlacion simple con el precio era positiva (0,35), el signo se invierte al controlar por tamaño. El resultado es economicamente logico una vez que se lee el coeficiente como efecto parcial, y confirma en este segmento lo que la seccion 3.3 habia detectado en todo el mercado: habitaciones mide densidad de ocupacion, no calidad.

5.3.2 Coeficiente de determinacion y ajuste

Tabla 29. Modelo 2 (base 2) — bondad de ajuste
Indicador Valor
0,7621
R² ajustado 0,7616
Error estandar residual ($M) 93,5
Error absoluto medio ($M) 57,1
Error porcentual absoluto medio 20,8%
F (valor p) < 0,0001

El modelo explica el 76,2 % de la variabilidad del precio, 6 puntos por encima del ajuste logrado en casas. La razon de fondo es que el apartamento es un producto mas homogeneo: dentro de un mismo conjunto residencial las unidades comparten antiguedad, acabados y zonas comunes, de modo que las variables omitidas pesan menos. En casas, cada inmueble es practicamente unico.

Las vias de mejora son las mismas del punto 3 anterior, mas una especifica de propiedad horizontal: el piso y la presencia de ascensor y las zonas comunes del conjunto. Sobre piso la seccion 2.4 fue explicita: su ausencia del 31,7 % es estructural no aplica a las casas y aunque en esta base, compuesta solo por apartamentos, la cobertura es mejor, sigue faltando en 22,3 % de los registros, insuficiente para incorporarla con garantias.

5.4 Punto 4 · Validacion de supuestos

tabla_supuestos(modelo2, "Modelo 2 (base 2) — pruebas de supuestos")
Modelo 2 (base 2) — pruebas de supuestos
Supuesto Prueba Estadistico Valor p
Linealidad / especificacion RESET de Ramsey 323.083 < 0,0001
Homocedasticidad Breusch-Pagan 854.976 < 0,0001
Normalidad de residuos Anderson-Darling 87.170 < 0,0001
Independencia Durbin-Watson 1.495 < 0,0001
No multicolinealidad VIF maximo 2.693
graficos_diagnostico(modelo2,
  fig("Diagnostico de residuos del modelo lineal de apartamentos de la Zona Sur"))

5.4.1 Interpretacion

El patron es el mismo de la base 1, agravado por el mayor tamaño muestral, se rechazan homocedasticidad, normalidad e independencia; la multicolinealidad sigue sin ser problema (VIF maximo 2,69).

Dos observaciones propias de esta base:

  • La heterocedasticidad es mas severa. Con n = 2.787 ya que el embudo de residuos es muy marcado, los apartamentos de mas de $800 millones tienen errores varias veces mayores que los de $200 millones, es el segmento de lujo, precisamente donde cae la solicitud 2, lo que obliga a leer con cautela la prediccion del punto 5.

  • Las pruebas rechazan con mas facilidad por el tamaño de muestra. Con casi 3.000 observaciones, desviaciones pequeñas de la normalidad resultan significativas aunque sean irrelevantes en la practica. El Q-Q es mas informativo que el valor p: muestra que el problema esta en la cola derecha, no en el centro. Añadase que nortest::ad.test() satura su valor p en torno a 3,7·10⁻24, de modo que valores p de esa magnitud no permiten comparar modelos entre si, solo constatar el rechazo.

5.5 Punto 5 · Prediccion del precio solicitado

solicitud2 <- data.frame(areaconst = 300, estrato = c(5, 6), habitaciones = 5,
                         parqueaderos = 3, banios = 3) %>%
  mutate(estrato_f = factor(estrato, levels = levels(base2$estrato_f), ordered = TRUE))

pred2_M1 <- predecir(modelo2,  solicitud2)
pred2_M3 <- predecir(mods2$M3, solicitud2)
Tabla 30. Precio estimado para la vivienda 2 (millones de pesos). En el modelo log-log, ‘Precio estimado’ es la mediana condicional y ‘Media (Duan)’ el valor esperado.
Modelo Estrato Precio estimado Media (Duan) IC 95% inf IC 95% sup IP 95% inf IP 95% sup
…1 Punto 3 (lineal) 5 691 670 712 506 875
…2 Punto 3 (lineal) 6 747 726 768 562 932
1 Anexo A (log-log) 5 659 672 634 686 449 968
2 Anexo A (log-log) 6 886 903 851 923 604 1301

5.5.1 Lectura del resultado

Tomando encuenta la informacion presentada en la tabla 30 y la figura 29 podemos realizar la siguiente lectura:

  • Estrato 5: $659 millones de precio mediano, $672 millones de precio medio.
  • Estrato 6: $886 millones de precio mediano, $903 millones de precio medio.

(Sobre la distincion entre mediana y media, vease la nota del punto 5 de la solicitud 1. El factor de Duan en esta base es 1,0186.)

Frente al credito de $850 millones (Figura 29): la solicitud es viable en estrato 5 queda un margen de $178 millones sobre el precio medio y queda por encima del cupo en estrato 6 ($903 M). Igual que en la solicitud 1, el estrato es la variable que decide la viabilidad financiera de la operacion.

Conviene matizar la palabra “margen”: el intervalo de prediccion individual en estrato 5 llega hasta $968 millones, por encima del cupo. Es decir, el apartamento tipico con ese perfil cabe en el credito, pero un apartamento concreto puede no caber. El margen existe sobre el valor esperado, no sobre cualquier inmueble que cumpla las especificaciones.

5.6 Punto 6 · Ofertas potenciales dentro del credito

ofertas2 <- ranking_ofertas(
  base2, mods2$M3, tope = 850, estratos = c(5, 6),
  objetivo = list(areaconst = 300, habitaciones = 5, banios = 3, parqueaderos = 3))

nrow(ofertas2$candidatos)
#> [1] 1384
tabla_ofertas(ofertas2$top)
ID Barrio Estrato Area (m²) Hab Baños Parq Precio ($M) Estimado ($M) Dif. vs. modelo
6175 Capri 5 270.00 4 3 3 350 639 -45,2%
2308 San Fernando 5 258.00 5 4 2 350 575 -39,2%
8036 Seminario 5 256.00 5 5 3 530 666 -20,4%
6205 Capri 5 260.00 3 3 3 350 647 -45,9%
4266 Ingenio 6 250.00 5 4 2 700 758 -7,6%
7680 Pampa Linda 5 267.00 3 3 3 450 658 -31,6%
8113 Cuarto de Legua 5 295.55 4 4 2 410 652 -37,1%
7512 Seminario 5 300.00 6 5 3 670 712 -5,9%
top2 <- ofertas2$top
mapa(top2, zoom = 12.5,
     titulo = fig("Vivienda 2: ocho ofertas recomendadas (≤ $850 M, Zona Sur)"),
     etiqueta = with(top2, paste0(
       capitaliza(barrio), " (ID ", id, ")<br>$", fnum(preciom, 0),
       " M · estimado $", fnum(pred, 0), " M<br>", fnum(areaconst, 0),
       " m² · estrato ", estrato, "<br>", habitaciones, " hab · ", banios,
       " baños · ", parqueaderos, " parq.")))

5.6.1 Analisis de las ofertas

Tabla 31. Perfil del conjunto de ofertas viables — vivienda 2
Indicador Valor
Ofertas que cumplen todos los filtros 1.384
Precio mediano ($M) 309
Area mediana (m²) 100
Ofertas con area > 250 m² 18
Ofertas por debajo del precio estimado 638 (46%)
Barrios representados 63

Lo que muestra el mapa de la figura 30 son Las ocho recomendaciones se reparten entre Capri, Cuarto de Legua, Ingenio, Pampa Linda, San Fernando, Seminario. Notese que el ranking premia la cercania al perfil pedido apartamentos grandes y eso lo lleva a barrios de vivienda amplia y antigua mas que a los conjuntos nuevos del corredor sur. En el conjunto completo de 1.384 candidatas, en cambio, los barrios dominantes son Valle del Lili, Ciudad Jardin, Pance, Ingenio, que es donde se concentra la oferta de apartamentos de estrato 5 y 6 dentro del cupo.

Tres lecturas para el cliente:

  1. El presupuesto es comodo; el problema es la escasez del producto. De las 1.384 ofertas que caben en $850 millones, solo 18 superan los 250 m². La restriccion no es financiera sino de disponibilidad: apartamentos de 300 m² en la Zona Sur son un nicho muy delgado del mercado.

  2. La recomendacion es negociar el area a la baja, aunque tampoco lo resuelve todo. Bajar la exigencia a 200–250 m² en estrato 6 con 3 parqueaderos deja solo 4 ofertas en toda la base; flexibilizar ademas el numero de parqueaderos abre el abanico a

    1. La conversacion con el cliente deberia ordenar sus prioridades: area, estrato y parqueaderos no caben los tres simultaneamente en este presupuesto.
  3. Cuidado con los descuentos extremos. El filtro descarta ofertas listadas mas de un 55 % por debajo del precio estimado, porque a esa magnitud lo mas probable es un error de digitacion en el precio o el area. Aun asi, 31 ofertas del conjunto viable quedan mas de 35 % por debajo del modelo y deben verificarse contra la publicacion original antes de contactar al vendedor.


6 Sintesis y conclusiones

6.1 Respuesta a las dos solicitudes

Tabla 32. Sintesis de las dos solicitudes
Concepto Vivienda 1 Vivienda 2
Perfil solicitado Casa, 200 m², 4 hab., 2 baños, 1 parq., estrato 4-5, Zona Norte Apartamento, 300 m², 5 hab., 3 baños, 3 parq., estrato 5-6, Zona Sur
Base comparable 722 casas en Zona Norte 2.787 apartamentos en Zona Sur
Credito preaprobado $350 millones $850 millones
Precio estimado (mediana – media) $330–341 M (estrato 4) / $376–389 M (estrato 5) $659–672 M (estrato 5) / $886–903 M (estrato 6)
¿El credito alcanza? Solo en estrato 4, y practicamente sin holgura Si en estrato 5; en estrato 6 queda por encima del cupo
Ofertas viables en la base 102 casas 1.384 apartamentos
Recomendacion Concentrar la busqueda en estrato 4; en estrato 5 el perfil exigido excede el cupo Viable en estrato 5; el area de 300 m² es el percentil 99 de la oferta y escasea

6.2 Conclusiones

Sobre las dos solicitudes

  1. La vivienda 1 solo es financiable en estrato 4 con el credito de $350 millones, y sin holgura: el modelo la valora entre $330 y $341 M en estrato 4, y entre $376 y $389 M en estrato 5. Recomendacion: acotar la busqueda a estrato 4, o flexibilizar el numero de habitaciones —que no determina el precio— para ganar margen.

  2. La vivienda 2 es viable en estrato 5 ($672 M contra un cupo de $850 M) y no lo es en estrato 6 ($903 M). La restriccion real, sin embargo, no es el precio sino la escasez de apartamentos de 300 m² en la Zona Sur.

  3. En ambos casos el estrato es la variable que decide la viabilidad financiera, por encima de cualquier otro atributo negociable.

  4. El area construida y el estrato concentran la capacidad explicativa en los dos segmentos. Las habitaciones no aportan valor una vez controlado el tamaño, y en apartamentos incluso restan: miden densidad de ocupacion, no calidad. Es el hallazgo que la exploracion de la seccion 3.3 anticipo y que los dos modelos confirmaron por separado.

  5. Casas y apartamentos se comportan como mercados distintos. El parqueadero es significativo en propiedad horizontal —asociado a +$46 M por cupo, y de forma robusta a los tres tratamientos de imputacion ensayados— mientras que en casas su efecto no se puede establecer con esta base, porque depende de como se lea el 40 % de dato ausente (Anexo B); el metro cuadrado en la Zona Sur vale cerca del doble que en la Zona Norte ($1,58 M contra $0,89 M). Modelar por separado, como pide el enunciado, era la decision correcta.

  6. El procesamiento de la seccion 2: cambio los resultados. El capping condicionado por estrato subio el R² ajustado del modelo de casas de 0,651 a 0,697 y, combinado con la forma logaritmica, consiguio que el modelo de la base 1 dejara de rechazar la homocedasticidad (Breusch-Pagan p = 0,193). El Anexo C documenta la comparacion completa contra la base cruda.

  7. Los modelos en nivel violan homocedasticidad, normalidad e independencia. Ninguna de esas violaciones invalida el uso que aqui se les da —filtrar y priorizar oferta—, pero si impide usarlos como avaluo. Las correcciones prioritarias son errores estandar robustos y efectos por barrio.

  8. La calidad geografica de la base es limitada: 9,7 % y 4,2 % de los registros tienen coordenadas que conviene verificar, con casos de geocodificacion fallida documentados. Cualquier uso operativo de las coordenadas exige depuracion previa.

7 Anexos

7.1 Anexo A · Comparacion de modelos

Se comparan tres especificaciones sobre las mismas variables del enunciado:

  • M1 — el modelo del punto 3: precio en nivel, estrato como numero.
  • M2 — precio en nivel, estrato como factor (permite saltos desiguales, como argumento la seccion 1.2).
  • M3 — log-log: log(precio) ~ log(area) + estrato_f + …
tabla_comparacion(mods1, base1) %>%
  mutate(across(c(R2, R2_adj), ~fnum(.x, 4)),
         across(c(AIC, BIC, RMSE, MAE, MAPE, VIF_max), ~fnum(.x, 1)),
         p_BP = format.pval(p_BP, digits = 2), p_AD = format.pval(p_AD, digits = 2)) %>%
  tabla(cap = tab_cap("Base 1 (casas Zona Norte) — comparacion de modelos"))
Tabla 33. Base 1 (casas Zona Norte) — comparacion de modelos
Modelo R2 R2_adj AIC BIC RMSE MAE MAPE p_BP p_AD VIF_max
M1: nivel, estrato numerico 0,6994 0,6973 9.177,7 9.209,8 138,0 94,3 21,6 <2e-16 < 2e-16 2,1
M2: nivel, estrato como factor 0,7108 0,7080 9.153,8 9.195,0 135,3 90,9 20,7 <2e-16 < 2e-16 2,2
M3: log-log, estrato como factor 0,7870 0,7849 108,8 150,0 139,1 93,0 21,3 0.19 0.00022 2,2
tabla_comparacion(mods2, base2) %>%
  mutate(across(c(R2, R2_adj), ~fnum(.x, 4)),
         across(c(AIC, BIC, RMSE, MAE, MAPE, VIF_max), ~fnum(.x, 1)),
         p_BP = format.pval(p_BP, digits = 2), p_AD = format.pval(p_AD, digits = 2)) %>%
  tabla(cap = tab_cap("Base 2 (apartamentos Zona Sur) — comparacion de modelos"))
Tabla 34. Base 2 (apartamentos Zona Sur) — comparacion de modelos
Modelo R2 R2_adj AIC BIC RMSE MAE MAPE p_BP p_AD VIF_max
M1: nivel, estrato numerico 0,7621 0,7616 33.212,9 33.254,4 93,4 57,1 20,8 <2e-16 < 2e-16 2,7
M2: nivel, estrato como factor 0,7954 0,7949 32.796,4 32.849,8 86,6 51,9 18,0 <2e-16 < 2e-16 2,8
M3: log-log, estrato como factor 0,8558 0,8554 -1.204,4 -1.151,0 78,3 46,6 15,7 <2e-16 4.8e-12 3,3
Tabla 35. Solo las metricas comparables entre escalas (millones de pesos y %)
Base Modelo RMSE MAE MAPE
Base 1 (casas) M1: nivel, estrato numerico 138,0 94,3 21,6
Base 1 (casas) M2: nivel, estrato como factor 135,3 90,9 20,7
Base 1 (casas) M3: log-log, estrato como factor 139,1 93,0 21,3
Base 2 (aptos) M1: nivel, estrato numerico 93,4 57,1 20,8
Base 2 (aptos) M2: nivel, estrato como factor 86,6 51,9 18,0
Base 2 (aptos) M3: log-log, estrato como factor 78,3 46,6 15,7
  • En la base 2 M3 gana con claridad: RMSE de 78,3 frente a 93,4 de M1.
  • En la base 1 el resultado es un empate tecnico: M2 tiene RMSE 135,3 y M3 139,1. En precision predictiva pura, M2 es marginalmente mejor. Seria incorrecto afirmar que M3 domina.

Entonces, ¿por que se eligio M3 para predecir? No por el ajuste, sino por tres razones que la tabla de precision no captura:

  1. Produce predicciones coherentes. El intervalo de prediccion de M1 para la vivienda 1 en estrato 4 arranca en $58 millones. Una casa de 200 m² a ese precio no existe. El modelo en nivel asume errores normales de varianza constante sobre una variable acotada por abajo, y por eso puede predecir precios absurdos. El log-log no puede: por construccion devuelve valores positivos.
  2. Cumple mejor los supuestos. En la base 1, Breusch-Pagan pasa de <2e-16 a 0,193: sobre la base tratada, ya no se rechaza la homocedasticidad. En la base 2 sigue rechazandose.
  3. Sus coeficientes tienen lectura economica directa. El coeficiente de log(area) es una elasticidad: 0,460 en casas y 0,649 en apartamentos. Un aumento del 1 % en el area se asocia con un aumento de 0,46 % y 0,65 % en el precio. Ambas son menores que 1: hay rendimientos decrecientes del area. Es exactamente la economia de escala que la seccion 3.3 detecto por correlacion (precio/m² frente a area, r = -0,272), y lo que el modelo lineal con pendiente constante no puede capturar.

Sobre el aporte de tratar el estrato como factor (M1 → M2): es una mejora real y verificable dentro de la misma escala, tanto en RMSE como en R² —aqui si comparables, porque ambos modelos tienen la misma variable respuesta—. Confirma empiricamente el argumento conceptual de la seccion 1.2.

Una limitacion de la tabla. Los valores p de Anderson-Darling aparecen como < 2e-16 en varias celdas, pero nortest::ad.test() satura en torno a 3,7e-24: esas celdas estan en el piso del estadistico y no sirven para ordenar modelos entre si.

summary(mods1$M3)
#> 
#> Call:
#> lm(formula = log(preciom) ~ log(areaconst) + estrato_f + habitaciones + 
#>     parqueaderos + banios, data = d)
#> 
#> Residuals:
#>      Min       1Q   Median       3Q      Max 
#> -0.90437 -0.17586 -0.01065  0.15832  1.09618 
#> 
#> Coefficients:
#>                Estimate Std. Error t value     Pr(>|t|)    
#> (Intercept)    3.256899   0.111688  29.161      < 2e-16 ***
#> log(areaconst) 0.460112   0.022754  20.221      < 2e-16 ***
#> estrato_f.L    0.479466   0.031909  15.026      < 2e-16 ***
#> estrato_f.Q    0.005860   0.023935   0.245      0.80665    
#> estrato_f.C    0.061197   0.019669   3.111      0.00194 ** 
#> habitaciones   0.012205   0.007804   1.564      0.11825    
#> parqueaderos   0.012567   0.007148   1.758      0.07916 .  
#> banios         0.055649   0.009610   5.791 0.0000000105 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> Residual standard error: 0.2591 on 714 degrees of freedom
#> Multiple R-squared:  0.787,  Adjusted R-squared:  0.7849 
#> F-statistic: 376.9 on 7 and 714 DF,  p-value: < 2.2e-16
summary(mods2$M3)
#> 
#> Call:
#> lm(formula = log(preciom) ~ log(areaconst) + estrato_f + habitaciones + 
#>     parqueaderos + banios, data = d)
#> 
#> Residuals:
#>      Min       1Q   Median       3Q      Max 
#> -1.67420 -0.12130  0.01032  0.13237  0.89177 
#> 
#> Coefficients:
#>                 Estimate Std. Error t value   Pr(>|t|)    
#> (Intercept)     2.466150   0.063870  38.612    < 2e-16 ***
#> log(areaconst)  0.648594   0.017136  37.850    < 2e-16 ***
#> estrato_f.L     0.484774   0.014200  34.138    < 2e-16 ***
#> estrato_f.Q     0.022541   0.009419   2.393     0.0168 *  
#> estrato_f.C     0.063125   0.006889   9.163    < 2e-16 ***
#> habitaciones   -0.036686   0.007491  -4.897 0.00000103 ***
#> parqueaderos    0.094852   0.006373  14.884    < 2e-16 ***
#> banios          0.056597   0.006764   8.368    < 2e-16 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> Residual standard error: 0.1946 on 2779 degrees of freedom
#> Multiple R-squared:  0.8558, Adjusted R-squared:  0.8554 
#> F-statistic:  2356 on 7 and 2779 DF,  p-value: < 2.2e-16

7.2 Anexo B · Sensibilidad al tratamiento de parqueaderos

La seccion 2.4 concluyo, a partir de la regresion logistica y del hecho de que el valor 0 no aparece nunca en la fuente, que la ausencia en parqueaderos es estructural y codifica el cero. Este anexo cuantifica que habria cambiado bajo otros supuestos.

sensibilidad <- function(tipo_v, zona_v, etiqueta) {
  d0 <- viv0 %>% filter(!is.na(id), tipo == tipo_v, zona == zona_v)
  variantes <- list(
    `NA → 0`          = d0 %>% mutate(parqueaderos = ifelse(is.na(parqueaderos), 0, parqueaderos)),
    `NA → mediana`    = d0 %>% mutate(parqueaderos = ifelse(is.na(parqueaderos),
                                        median(parqueaderos, na.rm = TRUE), parqueaderos)),
    `Casos completos` = d0 %>% filter(!is.na(parqueaderos)))
  do.call(rbind, lapply(names(variantes), function(nm) {
    d <- variantes[[nm]]
    m <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios, data = d)
    data.frame(Base = etiqueta, Tratamiento = nm, n = nobs(m),
               `R² ajustado` = round(summary(m)$adj.r.squared, 4),
               `Coef. parqueaderos` = round(coef(m)["parqueaderos"], 2),
               `Valor p` = format.pval(summary(m)$coefficients["parqueaderos", 4], digits = 2),
               check.names = FALSE, row.names = NULL)
  }))
}
rbind(sensibilidad("casa", "zona norte", "Base 1 (casas Norte)"),
      sensibilidad("apartamento", "zona sur", "Base 2 (aptos Sur)")) %>%
  tabla(cap = tab_cap("Sensibilidad al tratamiento de los faltantes en parqueaderos (base sin capping)"))
Tabla 36. Sensibilidad al tratamiento de los faltantes en parqueaderos (base sin capping)
Base Tratamiento n R² ajustado Coef. parqueaderos Valor p
Base 1 (casas Norte) NA → 0 722 0.6484 -1.68 0.7
Base 1 (casas Norte) NA → mediana 722 0.6524 16.56 0.0038
Base 1 (casas Norte) Casos completos 435 0.5995 24.01 0.000051
Base 2 (aptos Sur) NA → 0 2787 0.7531 48.36 <2e-16
Base 2 (aptos Sur) NA → mediana 2787 0.7643 73.21 <2e-16
Base 2 (aptos Sur) Casos completos 2381 0.7480 72.91 <2e-16

Lectura honesta. En la base 2 la conclusion es robusta: el coeficiente de parqueaderos es positivo, grande y significativo bajo los tres tratamientos. En la base 1 no lo es: bajo NA → 0 es practicamente cero, mientras que con casos completos pasa a ser positivo y significativo. La razon es que en casas de la Zona Norte cerca del 40 % de los registros tenia el dato ausente, y el supuesto sobre que significa ese vacio determina el resultado.

Por que se mantiene la decision. La seccion 2.4 no eligio NA → 0 por conveniencia sino por evidencia: (i) el valor 0 no existe en las 6.717 observaciones registradas, siendo el minimo 1; (ii) la razon de momios del estrato es 0,37, es decir, la ausencia se concentra en el segmento economico, exactamente donde es plausible no tener parqueadero. Imputar con la mediana afirmaria que 1.602 inmuebles, en su mayoria de estrato 3, tienen parqueadero — que es la hipotesis que la evidencia contradice.

Efecto sobre la valoracion de la vivienda 1. Para medirlo sin mezclar escalas, se reestima el modelo del punto 3 bajo los tres tratamientos y se predice el perfil solicitado (200 m², estrato 4, 4 habitaciones, 2 baños, 1 parqueadero):

d0 <- viv0 %>% filter(!is.na(id), tipo == "casa", zona == "zona norte")
variantes <- list(
  `NA → 0`          = d0 %>% mutate(parqueaderos = ifelse(is.na(parqueaderos), 0, parqueaderos)),
  `NA → mediana`    = d0 %>% mutate(parqueaderos = ifelse(is.na(parqueaderos),
                                      median(parqueaderos, na.rm = TRUE), parqueaderos)),
  `Casos completos` = d0 %>% filter(!is.na(parqueaderos)))

data.frame(
  Tratamiento = names(variantes),
  n = sapply(variantes, nrow),
  `Precio estimado ($M)` = sapply(variantes, function(d) {
    m <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios, data = d)
    round(predict(m, data.frame(areaconst = 200, estrato = 4, habitaciones = 4,
                                parqueaderos = 1, banios = 2)), 1)
  }),
  check.names = FALSE, row.names = NULL
) %>% tabla(cap = tab_cap("Vivienda 1 en estrato 4 — valoracion segun el tratamiento de los faltantes"))
Tabla 37. Vivienda 1 en estrato 4 — valoracion segun el tratamiento de los faltantes
Tratamiento n Precio estimado ($M)
NA → 0 722 332.6
NA → mediana 722 319.1
Casos completos 435 312.1

El rango entre tratamientos es de unos veinte millones de pesos y va a la baja —no al alza—, con las tres cifras por debajo del cupo de $350 millones: la conclusion operativa de la solicitud 1 no cambia bajo ningun supuesto. La recomendacion de fondo es exigir a la fuente que distinga explicitamente entre “cero parqueaderos” y “dato no reportado”.

7.3 Anexo C · Efecto del procesamiento sobre los modelos

¿Cuanto cambio realmente el modelado por haber procesado la base? Para que la comparacion sea limpia hay que aislar una intervencion a la vez. Comparar sin mas contra la base cruda mezclaria dos efectos: el capping y el cambio de muestra (la base cruda pierde por eliminacion de casos los registros con parqueaderos ausente, que en casas son casi el 40 %). Por eso se compara sin capping frente a con capping sobre exactamente las mismas filas, con la imputacion ya aplicada en ambas: asi el R², el error residual, el MAPE y la prueba de Breusch-Pagan son directamente comparables.

comparar_proceso <- function(tipo_v, zona_v, etiqueta) {
  # Misma muestra en las dos versiones: lo unico que cambia es el capping
  d_con <- viv %>% filter(tipo == tipo_v, zona == zona_v)
  d_sin <- viv_antes %>%
    filter(tipo == tipo_v, zona == zona_v) %>%
    mutate(banios = d_con$banios, habitaciones = d_con$habitaciones,
           parqueaderos = d_con$parqueaderos)
  f <- preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios
  ms <- lm(f, data = d_sin); mc <- lm(f, data = d_con)
  data.frame(
    Base = etiqueta,
    Version = c("Sin capping", "Con capping por estrato (seccion 2.2)"),
    n = c(nobs(ms), nobs(mc)),
    `R² ajustado` = round(c(summary(ms)$adj.r.squared, summary(mc)$adj.r.squared), 4),
    `Error residual ($M)` = round(c(summary(ms)$sigma, summary(mc)$sigma), 1),
    `MAPE (%)` = round(c(mean(abs(residuals(ms) / d_sin$preciom)),
                         mean(abs(residuals(mc) / d_con$preciom))) * 100, 1),
    # se muestra en notacion cientifica, no con format.pval, para que se vea la
    # diferencia entre dos valores p que ambos redondearian a "< 2e-16"
    `BP (valor p)` = formatC(c(lmtest::bptest(ms)$p.value,
                               lmtest::bptest(mc)$p.value), format = "e", digits = 1),
    check.names = FALSE, row.names = NULL)
}
rbind(comparar_proceso("casa", "zona norte", "Casas Zona Norte"),
      comparar_proceso("apartamento", "zona sur", "Aptos Zona Sur")) %>%
  tabla(cap = tab_cap("Modelo del punto 3 con y sin tratamiento de atipicos, sobre la misma muestra"))
Tabla 38. Modelo del punto 3 con y sin tratamiento de atipicos, sobre la misma muestra
Base Version n R² ajustado Error residual ($M) MAPE (%) BP (valor p)
Casas Zona Norte Sin capping 722 0.6506 158.6 22.3 2.3e-27
Casas Zona Norte Con capping por estrato (seccion 2.2) 722 0.6973 138.6 21.6 2.5e-18
Aptos Zona Sur Sin capping 2787 0.7537 95.1 21.1 2.2e-203
Aptos Zona Sur Con capping por estrato (seccion 2.2) 2787 0.7616 93.5 20.8 1.5e-182

Tres observaciones:

  • El ajuste mejora en las dos bases sin perder una sola observacion. El capping no elimina registros: los acota. La ganancia viene de impedir que un puñado de inmuebles extremos gobierne la recta de minimos cuadrados.
  • La heterocedasticidad se atenua, aunque con el modelo en niveles no llegue a desaparecer en ninguna de las dos bases. Es la combinacion de capping y transformacion logaritmica la que consigue que la base 1 deje de rechazarla (Anexo A).
  • El sentido de la mejora es acotar influencia, no maquillar resultados. La mediana del precio y del area no se movio (Tabla 5), y las conclusiones cualitativas de los puntos 3 a 6 —que variables son significativas, que signo tienen, si el credito alcanza— son las mismas en las dos versiones. Lo que cambia es la precision de las estimaciones, no su direccion.

El efecto de la otra gran intervencion —la imputacion de parqueaderos— esta aislado por separado en el Anexo B.

7.4 Anexo D · Diagnostico geografico detallado

rbind(
  data.frame(Base = "Base 1 — casas Zona Norte", n = nrow(base1),
             `Fuera del nucleo` = sum(!base1$geo_ok),
             `%` = round(100 * mean(!base1$geo_ok), 1),
             `Coord. duplicadas` = sum(duplicated(base1[, c("longitud", "latitud")])),
             check.names = FALSE),
  data.frame(Base = "Base 2 — apartamentos Zona Sur", n = nrow(base2),
             `Fuera del nucleo` = sum(!base2$geo_ok),
             `%` = round(100 * mean(!base2$geo_ok), 1),
             `Coord. duplicadas` = sum(duplicated(base2[, c("longitud", "latitud")])),
             check.names = FALSE)
) %>% tabla(cap = tab_cap("Resumen del diagnostico geografico"))
Tabla 39. Resumen del diagnostico geografico
Base n Fuera del nucleo % Coord. duplicadas
Base 1 — casas Zona Norte 722 70 9.7 114
Base 2 — apartamentos Zona Sur 2787 116 4.2 865
bind_rows(base1 %>% mutate(Base = "Base 1 (Casa / Norte)"),
          base2 %>% mutate(Base = "Base 2 (Apto / Sur)")) %>%
  plot_ly(x = ~longitud, y = ~latitud, color = ~Base,
          colors = c(pal_cat[1], pal_cat[3]), type = "scatter", mode = "markers",
          marker = list(size = 5, opacity = .5),
          hovertext = ~paste0(capitaliza(barrio), " · estrato ", estrato),
          hoverinfo = "text") %>%
  estilo(titulo = fig("Superposicion geografica de las dos bases", "superp"),
         x = "Longitud", y = "Latitud")

En la Figura 31 las dos nubes estan claramente separadas en latitud —la base 1 al norte, la base 2 al sur—, pero con puntos de cada una infiltrados en el territorio de la otra. Esos son exactamente los registros que la regla de Tukey marca como geo_ok = FALSE.

bind_rows(base1 %>% filter(!geo_ok) %>% mutate(Base = "Base 1"),
          base2 %>% filter(!geo_ok) %>% mutate(Base = "Base 2")) %>%
  transmute(Base, ID = id, Barrio = capitaliza(barrio), Estrato = estrato,
            `Precio ($M)` = preciom, `Area` = areaconst,
            Longitud = longitud, Latitud = latitud) %>%
  datatable(options = list(pageLength = 10, scrollX = TRUE),
            caption = tab_cap("Registros con coordenada fuera del nucleo de su zona"),
            rownames = FALSE)

7.5 Anexo E · Conjunto completo de ofertas viables

ofertas1$candidatos %>%
  transmute(ID = id, Barrio = capitaliza(barrio), Estrato = estrato,
            `Area` = areaconst, Hab = habitaciones, `Baños` = banios,
            Parq = parqueaderos, `Precio ($M)` = preciom,
            `Estimado ($M)` = round(pred, 0), `Dif. %` = round(100 * descuento, 1)) %>%
  datatable(options = list(pageLength = 10, scrollX = TRUE, order = list()),
            caption = tab_cap("Vivienda 1 — todas las ofertas viables (≤ $350 M, estrato 4-5, Zona Norte)"),
            rownames = FALSE)
ofertas2$candidatos %>%
  transmute(ID = id, Barrio = capitaliza(barrio), Estrato = estrato,
            `Area` = areaconst, Hab = habitaciones, `Baños` = banios,
            Parq = parqueaderos, `Precio ($M)` = preciom,
            `Estimado ($M)` = round(pred, 0), `Dif. %` = round(100 * descuento, 1)) %>%
  datatable(options = list(pageLength = 10, scrollX = TRUE, order = list()),
            caption = tab_cap("Vivienda 2 — todas las ofertas viables (≤ $850 M, estrato 5-6, Zona Sur)"),
            rownames = FALSE)

7.6 Anexo F · Reproducibilidad

sessionInfo()
#> R version 4.4.1 (2024-06-14 ucrt)
#> Platform: x86_64-w64-mingw32/x64
#> Running under: Windows 11 x64 (build 26200)
#> 
#> Matrix products: default
#> 
#> 
#> locale:
#> [1] LC_COLLATE=Spanish_Colombia.utf8  LC_CTYPE=Spanish_Colombia.utf8   
#> [3] LC_MONETARY=Spanish_Colombia.utf8 LC_NUMERIC=C                     
#> [5] LC_TIME=Spanish_Colombia.utf8    
#> 
#> time zone: America/Bogota
#> tzcode source: internal
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#>  [1] broom_1.0.13     nortest_1.0-4    car_3.1-3        carData_3.0-5   
#>  [5] lmtest_0.9-40    zoo_1.8-14       e1071_1.7-17     mice_3.19.0     
#>  [9] DT_0.34.0        kableExtra_1.4.0 knitr_1.51       plotly_4.12.1   
#> [13] ggplot2_4.0.3    tidyr_1.3.2      dplyr_1.2.1     
#> 
#> loaded via a namespace (and not attached):
#>  [1] tidyselect_1.2.1     viridisLite_0.4.3    farver_2.1.2        
#>  [4] S7_0.2.2             fastmap_1.2.0        digest_0.6.39       
#>  [7] rpart_4.1.23         lifecycle_1.0.5      survival_3.6-4      
#> [10] magrittr_2.0.5       compiler_4.4.1       rlang_1.3.0         
#> [13] sass_0.4.10          tools_4.4.1          yaml_2.3.12         
#> [16] data.table_1.18.0    htmlwidgets_1.6.4    xml2_1.5.1          
#> [19] RColorBrewer_1.1-3   abind_1.4-8          withr_3.0.3         
#> [22] purrr_1.2.2          paqueteMODELOS_0.1.0 nnet_7.3-19         
#> [25] grid_4.4.1           jomo_2.7-6           scales_1.4.0        
#> [28] iterators_1.0.14     MASS_7.3-60.2        cli_3.6.6           
#> [31] rmarkdown_2.30       reformulas_0.4.0     generics_0.1.4      
#> [34] otel_0.2.0           rstudioapi_0.19.0    httr_1.4.7          
#> [37] minqa_1.2.8          cachem_1.1.0         proxy_0.4-28        
#> [40] stringr_1.6.0        splines_4.4.1        vctrs_0.7.3         
#> [43] boot_1.3-30          glmnet_4.1-10        Matrix_1.7-0        
#> [46] jsonlite_2.0.0       mitml_0.4-5          Formula_1.2-5       
#> [49] crosstalk_1.2.2      systemfonts_1.3.1    foreach_1.5.2       
#> [52] jquerylib_0.1.4      glue_1.8.1           nloptr_2.2.1        
#> [55] pan_1.9              codetools_0.2-20     stringi_1.8.9       
#> [58] shape_1.4.6.1        gtable_0.3.6         lme4_1.1-37         
#> [61] tibble_3.3.1         pillar_1.11.1        htmltools_0.5.9     
#> [64] R6_2.6.1             textshaping_1.0.4    Rdpack_2.6.4        
#> [67] evaluate_1.0.5       lattice_0.22-7       rbibutils_2.3       
#> [70] backports_1.5.1      bslib_0.9.0          class_7.3-22        
#> [73] Rcpp_1.1.2           svglite_2.2.2        nlme_3.1-164        
#> [76] xfun_0.60            pkgconfig_2.0.3