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)
}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
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.
#> '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.
Antes de procesar realiza una exploracion general de que es cada variable presente en el dataset.
| 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 |
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.
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
Caja y espacios. Pasar todo a minusculas resuelve valle del lili frente a Valle Del Lili sin examinarlo caso por caso.
Articulo determinado inicial — la flora frente a flora.
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.
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.
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.
#> Registros completamente vacios: 3
#> 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.
#> Inmuebles con 0 baños: 45
#> Inmuebles con 0 habitaciones: 66
#> Areas menores a 20 m²: 0
#> 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.
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
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"))| 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 |
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"))| 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.
En los estratos altos el umbral global marca como atipicos inmuebles perfectamente normales para su segmento.
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.
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)| 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.
| 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.
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.
| 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.
| 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 |
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)))
}#> 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.
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.
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.
| 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) |
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.
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.
| 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 |
| 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.
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.
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%.
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.
| 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.
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"))| 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.
| 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.
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.
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()
}#> [1] 722
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)"))| 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 |
# 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"))| 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"))| 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"))| 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 |
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"))| 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.
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.
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.
Nombres de barrio que se repiten en distintos sectores de Cali, lo que lleva al geocodificador al homonimo equivocado.
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.
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.
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.
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.
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.
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.
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
| 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 |
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 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.
| Indicador | Valor |
|---|---|
| R² | 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).
| 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"))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.
| 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.
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)| 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 |
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:
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.
Criterio de seleccion, en tres filtros y un ordenamiento:
Presupuesto: precio ≤ $350 millones.
Estrato: 4 o 5, segun lo pedido.
Ubicacion verificada: se excluyen los registros con coordenada fuera del nucleo de la Zona Norte (punto 1).
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.
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
| 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.")))| 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.
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.
#> [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)"))| 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 |
| 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"))| 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"))| 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 |
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"))| 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.
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).
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.
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.
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.
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.
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
| 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 |
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.
| Indicador | Valor |
|---|---|
| R² | 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.
| 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"))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.
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)| 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 |
Tomando encuenta la informacion presentada en la tabla 30 y la figura 29 podemos realizar la siguiente lectura:
(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.
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
| 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.")))| 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:
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.
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
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.
| 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 |
Sobre las dos solicitudes
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.
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.
En ambos casos el estrato es la variable que decide la viabilidad financiera, por encima de cualquier otro atributo negociable.
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.
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.
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.
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.
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.
Se comparan tres especificaciones sobre las mismas variables del enunciado:
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"))| 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"))| 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 |
| 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 |
Entonces, ¿por que se eligio M3 para predecir? No por el ajuste, sino por tres razones que la tabla de precision no captura:
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.
#>
#> 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
#>
#> 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
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)"))| 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"))| 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”.
¿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"))| 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 efecto de la otra gran intervencion —la imputacion de
parqueaderos— esta aislado por separado en el Anexo B.
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"))| 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)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)#> 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