# La instalación del entorno se documenta en el Anexo A1 (chunk no evaluado).
library(paqueteMODELOS) # base de datos `vivienda`
library(dplyr) # manipulación de datos
library(tidyr) # reestructuración
library(ggplot2) # gráficos
library(plotly) # gráficos interactivos
library(knitr) # tablas
library(kableExtra) # formato de tablas
library(leaflet) # cartografía interactiva
library(lmtest) # Breusch-Pagan, RESET, coeftest
library(sandwich) # matrices de covarianza robustas (HC3)
library(car) # factores de inflación de varianza
library(nortest) # Anderson-Darling, Lilliefors
library(tseries) # Jarque-Bera
library(e1071) # asimetría y curtosis
library(scales) # formato de ejes
# Paquetes usados solo con `::`; se verifica su disponibilidad para que el
# informe no falle a mitad de compilación.
paquetes_ns <- c("RColorBrewer", "htmltools", "tibble")
faltan_ns <- paquetes_ns[!vapply(paquetes_ns, requireNamespace, logical(1),
quietly = TRUE)]
if (length(faltan_ns) > 0)
stop("Faltan paquetes requeridos (véase el Anexo A1): ",
paste(faltan_ns, collapse = ", "))
select <- dplyr::select
filter <- dplyr::filterAZUL <- "#003087" # azul javeriano — encabezados y elementos estructurales
ACENTO <- "#0073B1" # acento único
GRIS <- "#D4D4D4" # contexto (Knaflic)
NARANJA <- "#E8833A" # señal de alerta / atípicos
tema_informe <- theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", colour = AZUL, size = 13),
plot.subtitle = element_text(colour = "#4D4D4D", size = 10.5),
axis.title = element_text(colour = "#4D4D4D", size = 10.5),
panel.grid.minor = element_blank(),
panel.grid.major = element_line(colour = "#EDEDED"),
legend.position = "bottom"
)
theme_set(tema_informe)
# Envoltura única para todas las tablas: formato homogéneo y sin doble
# numeración del caption.
# Formatea una columna numérica en convención española. Si todos sus valores
# son enteros se imprime sin decimales, replicando el comportamiento de
# `kable()` y evitando que una columna de conteos aparezca como "3,000".
fmt_columna <- function(v, digits) {
d <- if (all(is.na(v)) || all(v[!is.na(v)] == round(v[!is.na(v)]))) 0 else digits
out <- formatC(v, format = "f", digits = d, big.mark = ".",
decimal.mark = ",")
out[is.na(v)] <- NA_character_ # para que knitr imprima el guion
out
}
tabla <- function(x, caption, digits = 3, align = NULL) {
x <- as.data.frame(x)
num <- vapply(x, is.numeric, logical(1))
if (any(num)) x[num] <- lapply(x[num], fmt_columna, digits = digits)
kableExtra::kbl(x, caption = caption, format = "html", align = align) |>
kableExtra::kable_styling(
bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE, position = "center", font_size = 13) |>
kableExtra::row_spec(0, bold = TRUE, background = AZUL, color = "white")
}
# Formato monetario homogéneo (millones de pesos), en convención española:
# coma decimal y punto de millar. Se usa en todo el informe, tablas incluidas.
mm <- function(x, d = 1) formatC(x, format = "f", digits = d,
big.mark = ".", decimal.mark = ",")
# Entero con separador de millar.
ent <- function(x) formatC(x, format = "d", big.mark = ".")
# Formato de valores p. Nunca se reporta "0": por debajo de la precisión de la
# aritmética de doble precisión se reporta la cota < 2×10⁻¹⁶.
pval <- function(p) format.pval(p, digits = 3, eps = 2e-16)C&A recibió la solicitud de una compañía internacional que requiere adquirir dos inmuebles en Cali con presupuestos preaprobados de 350 y 850 millones de pesos. Este informe responde a esa solicitud con modelos de regresión lineal múltiple estimados sobre la oferta inmobiliaria de los últimos tres meses, y traduce las estimaciones en carteras concretas de inmuebles candidatos.
Las conclusiones para la decisión son cuatro:
Conclusiones para la decisión
Vivienda 1 (casa, Zona Norte, 200 m²). El precio medio esperado es de 336,7 millones en estrato 4 y 387,2 millones en estrato 5. En estrato 4 el crédito de 350 millones cubre el valor esperado, pero el intervalo de confianza de ese valor medio [319,6; 354,8] contiene al presupuesto: la holgura existe pero no es estadísticamente concluyente. En estrato 5, en cambio, el intervalo completo [369,3; 405,9] queda por encima de los 350 millones. Se recomienda orientar la búsqueda al estrato 4.
Vivienda 2 (apartamento, Zona Sur, 300 m²). El precio medio esperado es de 555,9 millones en estrato 5 y 1.010,7 millones en estrato 6. El crédito de 850 millones es holgado en estrato 5 e insuficiente en estrato 6, donde el intervalo de confianza queda íntegramente por encima del presupuesto. Se recomienda concentrar la búsqueda en estrato 5.
La disponibilidad real difiere radicalmente entre las dos solicitudes. Para la Vivienda 1 se identificaron 37 inmuebles que satisfacen simultáneamente presupuesto y especificaciones. Para la Vivienda 2 solo 3: el segmento de apartamentos de 300 m² con cinco alcobas en el sur de la ciudad es marginal en la oferta disponible. La cartera correspondiente se presenta escalonada por concesiones explícitas.
El área construida y el estrato explican la mayor parte del precio; el número de alcobas no aporta valor. En apartamentos, a área constante, más alcobas se asocia con menor precio, un resultado con lectura económica directa que se discute en la Sección 4.3.
Todas las cifras de este resumen se calculan a partir de los modelos del informe; ninguna está escrita a mano, de modo que no pueden desincronizarse si los datos o la especificación cambian.
El cuerpo del informe presenta el análisis en el orden en que sustenta la decisión. Todo el aparato técnico —comparación formal de modelos, diagnósticos completos, análisis de sensibilidad y contrastes auxiliares— se remite a los anexos, referenciados desde el punto correspondiente.
María dirige C&A, agencia de bienes raíces con ocho agentes en Cali. La actividad del sector se ha contraído en lo corrido del año, mientras las entidades de ahorro y vivienda mantienen una oferta amplia de crédito. En ese escenario recibe una solicitud de asesoría de una compañía internacional que trasladará a dos empleados con sus familias a la ciudad. Las condiciones son las siguientes.
solicitudes <- data.frame(
Característica = c("Tipo", "Zona", "Área construida (m²)", "Parqueaderos",
"Baños", "Habitaciones", "Estrato", "Crédito preaprobado"),
`Vivienda 1` = c("Casa", "Norte", "200", "1", "2", "4", "4 o 5",
"350 millones"),
`Vivienda 2` = c("Apartamento", "Sur", "300", "3", "3", "5", "5 o 6",
"850 millones"),
check.names = FALSE
)
tabla(solicitudes, "Condiciones de las dos solicitudes recibidas por C&A.",
align = c("l", "c", "c"))| Característica | Vivienda 1 | Vivienda 2 |
|---|---|---|
| Tipo | Casa | Apartamento |
| Zona | Norte | Sur |
| Área construida (m²) | 200 | 300 |
| Parqueaderos | 1 | 3 |
| Baños | 2 | 3 |
| Habitaciones | 4 | 5 |
| Estrato | 4 o 5 | 5 o 6 |
| Crédito preaprobado | 350 millones | 850 millones |
Estimar el valor de mercado de los dos inmuebles solicitados a partir de sus características observables y, sobre esa base, identificar inmuebles concretos de la oferta vigente que satisfagan las especificaciones dentro de los presupuestos preaprobados.
Los datos provienen del conjunto vivienda del paquete paqueteMODELOS, que
recoge la oferta inmobiliaria publicada en Cali durante los tres últimos meses.
El procedimiento de instalación se documenta en el Anexo A1.
data("vivienda")
vivienda <- as.data.frame(vivienda)
resumen_base <- data.frame(
Elemento = c("Registros", "Variables", "Casas", "Apartamentos",
"Zonas representadas"),
Valor = c(ent(nrow(vivienda)),
ncol(vivienda),
ent(sum(vivienda$tipo == "Casa", na.rm = TRUE)),
ent(sum(vivienda$tipo == "Apartamento", na.rm = TRUE)),
length(unique(na.omit(vivienda$zona))))
)
tabla(resumen_base, "Dimensiones del conjunto de datos original.",
align = c("l", "r"))| Elemento | Valor |
|---|---|
| Registros | 8.322 |
| Variables | 13 |
| Casas | 3.219 |
| Apartamentos | 5.100 |
| Zonas representadas | 5 |
Casas y apartamentos suman 8.319 de los 8.322 registros. Los 3 restantes son filas vacías —sin tipo, zona, barrio ni coordenadas— que el filtro por tipo y zona descarta automáticamente.
diccionario <- data.frame(
Variable = c("id", "zona", "piso", "estrato", "preciom", "areaconst",
"parqueaderos", "banios", "habitaciones", "tipo", "barrio",
"longitud", "latitud"),
Descripción = c(
"Identificador del anuncio",
"Zona de la ciudad: Norte, Sur, Centro, Oeste, Oriente",
"Piso que ocupa la vivienda",
"Estrato socioeconómico (3 a 6)",
"Precio de oferta, en millones de pesos",
"Área construida, en metros cuadrados",
"Número de parqueaderos",
"Número de baños",
"Número de habitaciones",
"Tipo de vivienda: Casa o Apartamento",
"Barrio de ubicación",
"Coordenada geográfica (longitud)",
"Coordenada geográfica (latitud)"),
Escala = c("Nominal", "Nominal", "Ordinal", "Ordinal", "Razón", "Razón",
"Razón (conteo)", "Razón (conteo)", "Razón (conteo)", "Nominal",
"Nominal", "Continua", "Continua"),
Papel = c("Identificación", "Filtro", "No utilizada", "Predictora",
"Respuesta", "Predictora", "Predictora", "Predictora", "Predictora",
"Filtro", "Auxiliar", "Auxiliar", "Auxiliar")
)
tabla(diccionario, "Diccionario de variables y papel de cada una en el análisis.",
align = c("l", "l", "l", "l"))| Variable | Descripción | Escala | Papel |
|---|---|---|---|
| id | Identificador del anuncio | Nominal | Identificación |
| zona | Zona de la ciudad: Norte, Sur, Centro, Oeste, Oriente | Nominal | Filtro |
| piso | Piso que ocupa la vivienda | Ordinal | No utilizada |
| estrato | Estrato socioeconómico (3 a 6) | Ordinal | Predictora |
| preciom | Precio de oferta, en millones de pesos | Razón | Respuesta |
| areaconst | Área construida, en metros cuadrados | Razón | Predictora |
| parqueaderos | Número de parqueaderos | Razón (conteo) | Predictora |
| banios | Número de baños | Razón (conteo) | Predictora |
| habitaciones | Número de habitaciones | Razón (conteo) | Predictora |
| tipo | Tipo de vivienda: Casa o Apartamento | Nominal | Filtro |
| barrio | Barrio de ubicación | Nominal | Auxiliar |
| longitud | Coordenada geográfica (longitud) | Continua | Auxiliar |
| latitud | Coordenada geográfica (latitud) | Continua | Auxiliar |
Decisión metodológica: exclusión de piso
La variable piso no interviene en este informe. El diagnóstico realizado en la
actividad anterior sobre este mismo conjunto mostró que su ausencia no es
estructural —hay casas con piso registrado y apartamentos sin él—, de modo que no
admite ni imputación defendible ni interpretación unívoca. No forma parte,
además, de la especificación solicitada.
Ambas solicitudes se resuelven con el mismo procedimiento aplicado a segmentos distintos. Para garantizar que las dos réplicas son estrictamente comparables —y que ninguna diferencia entre ellas proviene de una decisión de análisis inadvertida— todo el procesamiento se encapsula en funciones que se invocan dos veces con parámetros diferentes.
# Depuración de un segmento (tipo x zona).
# Devuelve la base depurada y la bitácora de cada exclusión.
depurar_segmento <- function(datos, tipo_sel, zona_sel) {
# which() descarta los NA de la condición en lugar de propagarlos como filas
# de NA, que es lo que ocurriría con la indexación lógica directa.
b0 <- datos[which(datos$tipo == tipo_sel & datos$zona == zona_sel), ]
n0 <- nrow(b0)
vars_dominio <- c("preciom", "areaconst", "banios", "habitaciones")
# (i) Valores no positivos: el dominio de estas cuatro variables los excluye
# por definición. Un inmueble no puede tener área, precio, baños o
# habitaciones iguales a cero, de modo que son errores de captura.
no_pos <- Reduce(`|`, lapply(vars_dominio, function(v)
!is.na(b0[[v]]) & b0[[v]] <= 0))
b1 <- b0[which(!no_pos), ]
# (ii) Valores ausentes en esas mismas variables, contados SOBRE b1, es decir
# sobre lo que queda tras la etapa (i). Contarlos sobre b0 haría que un
# registro con un valor no positivo y además una ausencia se computara en dos
# etapas, y la columna de excluidos dejaría de ser aditiva respecto de la
# columna de registros. Aquí las dos etapas son disjuntas por construcción.
ausentes <- Reduce(`|`, lapply(vars_dominio, function(v) is.na(b1[[v]])))
desglose <- data.frame(
Variable = c("Precio", "Área construida", "Baños", "Habitaciones"),
`Valores no positivos` = sapply(vars_dominio, function(v)
sum(!is.na(b0[[v]]) & b0[[v]] <= 0)),
`Valores ausentes` = sapply(vars_dominio, function(v) sum(is.na(b1[[v]]))),
check.names = FALSE, row.names = NULL)
b2 <- b1[which(!ausentes), ]
# (iii) Registros idénticos en las doce variables sustantivas, ignorando el
# identificador del anuncio. Es un criterio de coincidencia exacta: detecta
# republicaciones sin ninguna modificación, pero no capturaría el mismo
# inmueble reanunciado con el precio o la coordenada ligeramente alterados.
vars_sin_id <- setdiff(names(b2), "id")
dup <- duplicated(b2[, vars_sin_id])
b3 <- b2[which(!dup), ]
bitacora <- data.frame(
Etapa = c("Filtro por tipo y zona",
"Retiro de valores no positivos",
"Retiro de valores ausentes",
"Retiro de registros idénticos",
"Base analítica"),
Registros = c(n0, nrow(b1), nrow(b2), nrow(b3), nrow(b3)),
Excluidos = c(NA, sum(no_pos), sum(ausentes), sum(dup), NA)
)
# Comprobación de aditividad: los excluidos de cada etapa deben reproducir
# exactamente la caída de registros. Si alguna vez dejaran de hacerlo, la
# compilación se detiene en lugar de publicar una bitácora incoherente.
stopifnot(n0 - sum(no_pos) == nrow(b1),
nrow(b1) - sum(ausentes) == nrow(b2),
nrow(b2) - sum(dup) == nrow(b3))
list(base = b3, bitacora = bitacora, desglose = desglose, n_inicial = n0,
n_nopos = sum(no_pos), n_aus = sum(ausentes), n_dup = sum(dup))
}# Diagnóstico del mecanismo de ausencia de una variable.
# Contrasta si los registros sin dato difieren sistemáticamente de los demás.
# Se usa Mann-Whitney por la asimetría documentada del precio, con el tamaño del
# efecto r biserial de rangos en la convención de Kerby: r = 2U/(n1 n2) - 1,
# acotado en [-1, 1]. Con U = W devuelto por wilcox.test(sin_dato, con_dato) el
# signo es interpretable: r < 0 indica que el grupo sin dato se sitúa por debajo.
diagnostico_ausencia <- function(base, var_ausente,
vars = c("preciom", "areaconst", "banios",
"habitaciones", "estrato")) {
falta <- is.na(base[[var_ausente]])
res <- lapply(vars, function(v) {
g1 <- base[[v]][falta]; g1 <- g1[!is.na(g1)] # sin dato
g2 <- base[[v]][!falta]; g2 <- g2[!is.na(g2)] # con dato
w <- suppressWarnings(wilcox.test(g1, g2))
U <- unname(w$statistic)
data.frame(Variable = v,
`Mediana sin dato` = median(g1),
`Mediana con dato` = median(g2),
U = U,
`Valor p` = pval(w$p.value),
`r biserial` = 2 * U / (length(g1) * length(g2)) - 1,
check.names = FALSE)
})
do.call(rbind, res)
}
# Asociación entre el indicador de ausencia y el estrato.
asociacion_ausencia_estrato <- function(base, var_ausente) {
tab <- table(base$estrato, is.na(base[[var_ausente]]))
ji <- suppressWarnings(chisq.test(tab))
V <- sqrt(unname(ji$statistic) /
(sum(tab) * (min(dim(tab)) - 1)))
list(tabla = tab, prueba = ji, V = V, chi2 = unname(ji$statistic),
gl = unname(ji$parameter), p = ji$p.value,
resumen = data.frame(
`Ji-cuadrado` = unname(ji$statistic),
`Grados de libertad` = unname(ji$parameter),
`Valor p` = pval(ji$p.value),
`V de Cramér` = V, check.names = FALSE),
perfil = data.frame(
Estrato = as.numeric(rownames(tab)),
`Sin dato (%)` = round(100 * tab[, "TRUE"] / rowSums(tab), 1),
`Registros` = as.integer(rowSums(tab)),
check.names = FALSE, row.names = NULL))
}# Preparación final del segmento para el modelado.
preparar_modelado <- function(base) {
base$sin_parqueadero <- as.integer(is.na(base$parqueaderos))
base$parqueaderos <- ifelse(is.na(base$parqueaderos), 0, base$parqueaderos)
base$estrato_f <- factor(base$estrato)
base
}
# Conjunto de especificaciones candidatas. La primera es la que el enunciado
# solicita de forma literal; las restantes son las alternativas que la validación
# de supuestos obliga a considerar.
formulas_candidatas <- function() {
list(
`M0 · Niveles, estrato numérico` =
preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
`M1 · Niveles, estrato factor` =
preciom ~ areaconst + estrato_f + habitaciones + parqueaderos + banios,
`M2 · Log-lineal, estrato factor` =
log(preciom) ~ areaconst + estrato_f + habitaciones + parqueaderos + banios,
`M3 · Log-log en área` =
log(preciom) ~ log(areaconst) + estrato_f + habitaciones + parqueaderos + banios,
`M4 · Log-log con interacción área × estrato` =
log(preciom) ~ log(areaconst) * estrato_f + habitaciones + parqueaderos + banios
)
}
# Validación cruzada K-fold con error medido SIEMPRE en la escala original
# (millones de pesos). Para los modelos con respuesta logarítmica se aplica el
# estimador de reajuste de Duan (1983): E[Y|x] = exp(x'b) * mean(exp(e)). Omitir
# esa corrección subestimaría sistemáticamente la predicción y haría que la
# comparación entre escalas fuese inválida.
cv_error <- function(formula, datos, log_resp, K = 10, semilla = 2026) {
set.seed(semilla)
n <- nrow(datos)
pliegues <- sample(rep(1:K, length.out = n))
err <- numeric(0)
for (k in 1:K) {
ent <- datos[pliegues != k, ]
val <- datos[pliegues == k, ]
m <- lm(formula, data = ent)
p <- predict(m, newdata = val)
if (log_resp) p <- exp(p) * mean(exp(residuals(m)))
err <- c(err, val$preciom - p)
}
c(RMSE = sqrt(mean(err^2)), MAE = mean(abs(err)))
}
# Tabla comparativa de las especificaciones candidatas.
comparar_modelos <- function(datos) {
fs <- formulas_candidatas()
filas <- lapply(names(fs), function(nm) {
f <- fs[[nm]]
lg <- grepl("^log\\(preciom\\)", deparse(f[[2]]))
m <- lm(f, data = datos)
cv <- cv_error(f, datos, log_resp = lg)
data.frame(Modelo = nm,
Escala = ifelse(lg, "log(precio)", "precio"),
`Parámetros` = length(coef(m)),
`R²` = summary(m)$r.squared,
`R² ajustado` = summary(m)$adj.r.squared,
AIC = AIC(m), BIC = BIC(m),
`RMSE VC` = cv["RMSE"], `MAE VC` = cv["MAE"],
check.names = FALSE, row.names = NULL)
})
do.call(rbind, filas)
}Sobre la comparabilidad de los criterios
AIC y BIC solo son comparables entre modelos que comparten la misma variable
respuesta. Los modelos con respuesta logarítmica maximizan una verosimilitud
definida sobre log(preciom) y sus valores no son comparables con los de los
modelos en niveles. El único criterio comparable entre las cinco
especificaciones es el error de validación cruzada, medido en millones de pesos
tras deshacer la transformación con el factor de Duan. La comparación entre
escalas se apoya, por tanto, exclusivamente en RMSE VC y MAE VC.
# I de Moran para los residuos de MCO, con matriz de pesos de los k vecinos más
# próximos, estandarizada por filas. La independencia de los errores no puede
# contrastarse con Durbin-Watson en datos de corte transversal —no hay orden
# temporal—, pero sí frente a la estructura espacial, que es la fuente de
# dependencia pertinente en datos inmobiliarios georreferenciados.
#
# ADVERTENCIA METODOLÓGICA. Los momentos de I bajo aleatorización o normalidad
# que aparecen en los manuales —E[I] = -1/(n-1)— valen para una VARIABLE
# observada, no para residuos de MCO: los residuos satisfacen e = M y con
# M = I - X(X'X)⁻¹X', no son intercambiables y su matriz de covarianzas es
# σ²M, no σ²I. Usar aquellos momentos sesga el contraste. Se emplean por tanto
# los momentos exactos de Cliff y Ord (1972, 1981) para residuos de MCO:
# E[I] = (n/S0)·tr(MW)/(n-p)
# E[I²] = (n/S0)²·[tr(MWMW') + tr(MWMW) + (tr MW)²] / ((n-p)(n-p+2))
# Todas las trazas se calculan sin construir M (que sería n×n) mediante
# identidades cíclicas sobre matrices n×p y p×p.
#
# La distancia se mide en grados sobre coordenadas geográficas. A la latitud de
# Cali (≈3,4° N) un grado de longitud equivale a 111,1 km y uno de latitud a
# 110,6 km, de modo que la distorsión del vecindario por usar distancia euclídea
# en grados es inferior al 0,5 % y no afecta a la selección de vecinos.
moran_residuos <- function(modelo, datos, k = 8) {
lon <- datos$longitud; lat <- datos$latitud
ok <- !is.na(lon) & !is.na(lat)
if (!all(ok)) {
# El resultado solo es exacto si los residuos provienen del modelo ajustado
# sobre las MISMAS observaciones que entran en la matriz de pesos. Si
# faltan coordenadas se reajusta sobre la submuestra georreferenciada y se
# deja constancia del número de casos excluidos.
modelo <- lm(formula(modelo), data = datos[ok, , drop = FALSE])
}
e <- residuals(modelo); X <- model.matrix(modelo)
lon <- lon[ok]; lat <- lat[ok]
n <- length(e); p <- ncol(X)
stopifnot(n == sum(ok))
D <- as.matrix(dist(cbind(lon, lat)))
diag(D) <- Inf
W <- matrix(0, n, n)
for (i in 1:n) W[i, order(D[i, ])[1:k]] <- 1
W <- W / rowSums(W)
rm(D); invisible(gc(verbose = FALSE))
S0 <- sum(W); f <- n / S0 # f = 1 con W estandarizada
I <- as.numeric((t(e) %*% W %*% e) / sum(e^2))
A <- solve(crossprod(X))
WX <- W %*% X; WtX <- t(W) %*% X
XWX <- crossprod(X, WX); XWtX <- crossprod(X, WtX)
tr <- function(M) sum(diag(M))
trMW <- -tr(A %*% XWX) # tr(W) = 0 (diagonal nula)
trWWt <- sum(W * W); trWW <- sum(W * t(W))
trWHWt <- tr(A %*% crossprod(WX, WX))
trHWWt <- tr(A %*% crossprod(WtX, WtX))
trHWHWt <- tr(A %*% XWX %*% A %*% XWtX)
trMWMWt <- trWWt - trWHWt - trHWWt + trHWHWt
trWHW <- tr(A %*% crossprod(X, W %*% WX))
trHWHW <- tr(A %*% XWX %*% A %*% XWX)
trMWMW <- trWW - 2 * trWHW + trHWHW
EI <- f * trMW / (n - p)
EI2 <- f^2 * (trMWMWt + trMWMW + trMW^2) / ((n - p) * (n - p + 2))
VI <- EI2 - EI^2
zz <- (I - EI) / sqrt(VI)
data.frame(I = I, `E[I] (Cliff-Ord)` = EI, `z` = zz,
`Valor p` = pval(2 * (1 - pnorm(abs(zz)))),
`Observaciones` = n,
p_num = 2 * (1 - pnorm(abs(zz))), check.names = FALSE)
}
# Batería completa de diagnósticos sobre un modelo ajustado.
diagnosticar <- function(modelo, datos) {
e <- residuals(modelo)
n <- length(e); p <- length(coef(modelo))
bp <- bptest(modelo)
# Versión simplificada del contraste de White (Wooldridge, 2019, §8.3): se
# regresan los residuos al cuadrado sobre los valores ajustados y su cuadrado,
# lo que reduce a 2 los grados de libertad del contraste general de White.
# El estadístico es el LM estudentizado de Koenker, LM = n R², que bajo H0 de
# homocedasticidad sigue una ji-cuadrado con 2 grados de libertad. La fila
# anterior, `bptest()`, es también la versión estudentizada de Koenker
# (studentize = TRUE por defecto), no la forma original de Breusch-Pagan.
# Se calcula de forma explícita porque bptest() no evalúa correctamente una
# varformula que dependa de fitted(modelo) cuando recibe un objeto lm.
aj <- fitted(modelo)
aux <- lm(I(e^2) ~ aj + I(aj^2))
LM <- length(e) * summary(aux)$r.squared
wh <- list(statistic = LM, p.value = pchisq(LM, df = 2, lower.tail = FALSE))
ad <- ad.test(e); li <- lillie.test(e); jb <- jarque.bera.test(e)
rs <- resettest(modelo, power = 2, type = "fitted")
h <- hatvalues(modelo); ck <- cooks.distance(modelo)
df <- abs(dffits(modelo)); rt <- abs(rstudent(modelo))
pruebas <- data.frame(
Supuesto = c("Homocedasticidad", "Homocedasticidad",
"Normalidad", "Normalidad", "Normalidad",
"Linealidad / forma funcional"),
Prueba = c("Breusch-Pagan (estudentizado)",
"White simplificado (LM de Koenker)",
"Anderson-Darling", "Lilliefors", "Jarque-Bera",
"RESET de Ramsey (potencia 2)"),
`Estadístico` = c(unname(bp$statistic), unname(wh$statistic),
unname(ad$statistic), unname(li$statistic),
unname(jb$statistic), unname(rs$statistic)),
`Valor p` = pval(p_valores <- c(bp$p.value, wh$p.value, ad$p.value,
li$p.value, jb$p.value, rs$p.value)),
check.names = FALSE)
pruebas$Decisión <- ifelse(p_valores < 0.05,
"Se rechaza H₀", "No se rechaza H₀")
influencia <- data.frame(
Criterio = c("Distancia de Cook > 4/n",
"Apalancamiento > 2p/n",
"|DFFITS| > 2√(p/n)",
"|Residuo estudentizado| > 3"),
Umbral = c(4/n, 2*p/n, 2*sqrt(p/n), 3),
`Casos señalados` = c(sum(ck > 4/n), sum(h > 2*p/n),
sum(df > 2*sqrt(p/n)), sum(rt > 3)),
`% del total` = 100 * c(sum(ck > 4/n), sum(h > 2*p/n),
sum(df > 2*sqrt(p/n)), sum(rt > 3)) / n,
check.names = FALSE)
forma <- data.frame(
Medida = c("Asimetría de los residuos", "Curtosis en exceso",
"Máxima distancia de Cook", "Error estándar residual"),
Valor = c(e1071::skewness(e), e1071::kurtosis(e), max(ck),
summary(modelo)$sigma),
check.names = FALSE)
espacial <- moran_residuos(modelo, datos)
list(pruebas = pruebas, p_valores = p_valores, influencia = influencia,
forma = forma, espacial = espacial)
}# Estimación puntual y por intervalo para un perfil solicitado.
# Se distinguen dos objetos inferenciales que no deben confundirse:
# - Intervalo de confianza: cubre E[Y | x0], el precio MEDIO de todos los
# inmuebles con esas características. Es lo pertinente para valorar el
# segmento.
# - Intervalo de predicción: cubre el precio de UN inmueble concreto con esas
# características. Es lo pertinente para negociar una oferta particular.
# El segundo incorpora la varianza del error individual y es necesariamente más
# amplio.
#
# COHERENCIA ENTRE EL PUNTO Y EL INTERVALO EN LOS MODELOS LOGARÍTMICOS. Este es
# el punto donde es más fácil equivocarse, y por eso se explicita:
# - exp(x'b) estima la MEDIANA condicional, no la media. Exponenciar los
# límites del IC da un intervalo de confianza para la MEDIANA, porque la
# exponencial es monótona creciente y preserva la cobertura.
# - La MEDIA condicional se obtiene con el factor de reajuste de Duan (1983),
# E[Y|x] = exp(x'b)·mean(exp(e)). Acompañar esa media del IC exponenciado
# sin reajustar mezcla dos objetos distintos: el punto estimaría la media y
# el intervalo cubriría la mediana. Aquí el IC de la media se obtiene
# multiplicando ambos límites por el mismo factor, lo que equivale a tratar
# el factor de Duan como CONOCIDO; la cobertura resultante es por tanto
# ligeramente optimista, pues ignora la variabilidad del propio factor
# (para incorporarla haría falta bootstrap). Se declara y se reportan las
# dos escalas por separado.
# - El intervalo de PREDICCIÓN sí es exacto al exponenciar: cubre a Y, no a
# E[Y|x], y la monotonía de la exponencial basta. NO se le aplica Duan.
predecir_perfil <- function(modelo, perfil, log_resp, nivel = 0.95) {
ic <- predict(modelo, newdata = perfil, interval = "confidence", level = nivel)
ip <- predict(modelo, newdata = perfil, interval = "prediction", level = nivel)
if (log_resp) {
duan <- mean(exp(residuals(modelo)))
out <- data.frame(
`Mediana` = exp(ic[, "fit"]),
`IC mediana inf.` = exp(ic[, "lwr"]),
`IC mediana sup.` = exp(ic[, "upr"]),
`Media (Duan)` = exp(ic[, "fit"]) * duan,
`IC media inf.` = exp(ic[, "lwr"]) * duan,
`IC media sup.` = exp(ic[, "upr"]) * duan,
`IP inferior` = exp(ip[, "lwr"]), `IP superior` = exp(ip[, "upr"]),
check.names = FALSE)
} else {
out <- data.frame(
`Mediana` = NA_real_,
`IC mediana inf.` = NA_real_, `IC mediana sup.` = NA_real_,
`Media (Duan)` = ic[, "fit"],
`IC media inf.` = ic[, "lwr"], `IC media sup.` = ic[, "upr"],
`IP inferior` = ip[, "lwr"], `IP superior` = ip[, "upr"],
check.names = FALSE)
}
cbind(perfil["estrato_f"], out, row.names = NULL)
}
# Apalancamiento del punto de predicción. Si h0 excede el máximo observado en la
# muestra, el perfil solicitado está fuera del soporte de los datos y la
# predicción sería una extrapolación, no una interpolación.
apalancamiento_perfil <- function(modelo, perfil) {
X <- model.matrix(modelo)
x0 <- model.matrix(delete.response(terms(modelo)), data = perfil)
XtXi <- solve(crossprod(X))
h0 <- as.numeric(diag(x0 %*% XtXi %*% t(x0)))
data.frame(`Apalancamiento del perfil` = h0,
`Máximo muestral` = max(hatvalues(modelo)),
`Promedio muestral` = mean(hatvalues(modelo)),
check.names = FALSE)
}# Selección de inmuebles candidatos.
# Se combinan tres criterios explícitos y separables:
# (1) Viabilidad financiera: precio dentro del crédito preaprobado.
# (2) Cumplimiento de especificación: estrato solicitado y atributos no
# inferiores a los requeridos, con tolerancia declarada sobre el área.
# (3) Valoración relativa: precio de oferta por debajo del que predicen sus
# propios atributos. No significa "barata": el modelo ya descuenta el
# efecto de área, estrato y demás características.
# El valor modelado se calcula en la escala de la MEDIA condicional, con el
# mismo factor de reajuste de Duan que usa la sección de predicción. Emplear
# exp(x'b) a secas —la mediana— desplazaría todas las diferencias porcentuales
# de forma sistemática y haría incoherentes ambas secciones del informe.
# El orden final combina la diferencia porcentual y la proximidad al perfil
# solicitado, medida como distancia euclídea sobre los atributos estandarizados.
construir_cartera <- function(base, modelo, perfil, credito, estratos,
tol_area = 0.85, exigir_atributos = TRUE,
n_max = 8) {
b <- base
duan <- mean(exp(residuals(modelo)))
b$valor_modelado <- exp(predict(modelo, newdata = b)) * duan
b$infravaloracion <- 100 * (b$preciom - b$valor_modelado) / b$valor_modelado
cumple <- b$preciom <= credito &
as.character(b$estrato_f) %in% estratos &
b$areaconst >= tol_area * perfil$areaconst
if (exigir_atributos) {
cumple <- cumple &
b$banios >= perfil$banios &
b$habitaciones >= perfil$habitaciones &
b$parqueaderos >= perfil$parqueaderos
}
cand <- b[cumple, ]
if (nrow(cand) == 0) return(cand)
vs <- c("areaconst", "habitaciones", "parqueaderos", "banios")
mu <- sapply(b[vs], mean); sdv <- sapply(b[vs], sd)
z <- scale(cand[vs], center = mu, scale = sdv)
z0 <- (unlist(perfil[vs]) - mu) / sdv
cand$distancia <- sqrt(rowSums(sweep(z, 2, z0)^2))
# Menor puntuación = mejor: mayor descuento frente al valor modelado y mayor
# proximidad al perfil. El peso 10 iguala aproximadamente el rango de ambas
# componentes; se declara explícitamente por ser una elección del analista.
cand$puntuacion <- cand$infravaloracion + 10 * cand$distancia
cand <- cand[order(cand$puntuacion), ]
out <- head(cand, n_max)
# El número TOTAL de inmuebles que cumplen los criterios se transporta como
# atributo. Antes se recalculaba con una copia literal del filtro en el texto,
# lo que abría la puerta a que ambas cifras se desincronizaran en silencio.
attr(out, "n_total") <- nrow(cand)
out
}
# Presentación tabular de una cartera.
tabla_cartera <- function(cartera, caption) {
x <- cartera[, c("barrio", "estrato", "preciom", "valor_modelado",
"infravaloracion", "areaconst", "habitaciones", "banios",
"parqueaderos")]
names(x) <- c("Barrio", "Estrato", "Precio", "Valor modelado",
"Diferencia (%)", "Área", "Alcobas", "Baños", "Parq.")
rownames(x) <- NULL
tabla(x, caption, digits = 1,
align = c("l", rep("r", 8)))
}
# Cartografía de una cartera sobre la oferta del segmento.
mapa_cartera <- function(base, cartera, titulo) {
leaflet() |>
addProviderTiles(providers$CartoDB.Positron) |>
addCircleMarkers(data = base, lng = ~longitud, lat = ~latitud,
radius = 2, color = GRIS, stroke = FALSE,
fillOpacity = 0.35, group = "Oferta del segmento") |>
addCircleMarkers(
data = cartera, lng = ~longitud, lat = ~latitud,
radius = 8, color = AZUL, fillColor = NARANJA, weight = 2,
fillOpacity = 0.9, group = "Candidatas",
popup = ~paste0("<b>", barrio, "</b><br>",
"Precio: ", mm(preciom, 0), " millones<br>",
"Valor modelado: ", mm(valor_modelado, 0), " millones<br>",
"Diferencia: ", mm(infravaloracion, 1), " %<br>",
"Área: ", areaconst, " m² · Estrato ", estrato, "<br>",
habitaciones, " alcobas · ", banios, " baños · ",
parqueaderos, " parq.")) |>
addLegend("bottomright",
colors = c(GRIS, NARANJA), labels = c("Oferta del segmento",
"Inmuebles candidatos"),
title = titulo, opacity = 0.9)
}# Matriz de correlaciones de Spearman en formato interactivo.
# Se usa Spearman y no Pearson porque la distribución del precio es fuertemente
# asimétrica y la relación con el área no es exactamente lineal en niveles;
# Spearman mide asociación monótona y es invariante ante transformaciones
# crecientes, lo que la hace comparable entre las dos escalas del análisis.
grafico_correlaciones <- function(base, titulo) {
vs <- c("preciom", "areaconst", "estrato", "banios", "habitaciones",
"parqueaderos")
et <- c("Precio", "Área", "Estrato", "Baños", "Alcobas", "Parqueaderos")
M <- cor(base[vs], method = "spearman", use = "pairwise.complete.obs")
dimnames(M) <- list(et, et)
plot_ly(x = et, y = et, z = round(M, 3), type = "heatmap",
colors = colorRamp(c("#FFFFFF", ACENTO, AZUL)),
zmin = 0, zmax = 1,
hovertemplate = "%{y} – %{x}: %{z}<extra></extra>") |>
layout(title = list(text = titulo, font = list(color = AZUL, size = 14)),
xaxis = list(title = ""), yaxis = list(title = "",
autorange = "reversed"))
}
# Dispersión precio–área con suavizamiento, coloreada por estrato.
grafico_precio_area <- function(base, titulo) {
p <- ggplot(base, aes(x = areaconst, y = preciom, colour = estrato_f,
text = paste0(barrio, "<br>", areaconst, " m² · ",
mm(preciom, 0), " millones<br>Estrato ",
estrato))) +
geom_point(alpha = 0.5, size = 1.2) +
scale_colour_manual(values = c("3" = GRIS, "4" = "#9DC3E6",
"5" = ACENTO, "6" = AZUL),
name = "Estrato") +
scale_x_continuous(labels = comma) + scale_y_continuous(labels = comma) +
labs(title = titulo, x = "Área construida (m²)",
y = "Precio (millones de pesos)")
ggplotly(p, tooltip = "text")
}
# Distribución del precio por niveles de una variable discreta.
grafico_precio_categoria <- function(base, var, etiqueta, titulo) {
d <- base
d$grupo <- factor(d[[var]])
p <- ggplot(d, aes(x = grupo, y = preciom, fill = grupo)) +
geom_boxplot(outlier.alpha = 0.25, outlier.size = 0.8, colour = "#4D4D4D") +
scale_fill_manual(values = colorRampPalette(c(GRIS, ACENTO, AZUL))(
length(levels(d$grupo))), guide = "none") +
scale_y_continuous(labels = comma) +
labs(title = titulo, x = etiqueta, y = "Precio (millones de pesos)")
ggplotly(p)
}
# Contraste formal de diferencias de precio entre los niveles de una variable
# discreta. Se usa Kruskal-Wallis por la asimetría del precio. El tamaño del
# efecto es eta cuadrado basado en H, eta²[H] = (H - k + 1)/(n - k), en la
# notación de Tomczak y Tomczak (2014). NO es epsilon cuadrado, que esos mismos
# autores definen como e² = H/((n²-1)/(n+1)); confundirlos es un error de
# nomenclatura frecuente y aquí se evita nombrando la medida por su fórmula.
contraste_grupos <- function(base, var, etiqueta) {
f <- as.formula(paste("preciom ~ factor(", var, ")"))
kw <- kruskal.test(f, data = base)
k <- length(unique(base[[var]])); n <- nrow(base)
e2 <- (unname(kw$statistic) - k + 1) / (n - k)
data.frame(Variable = etiqueta, `Niveles` = k,
`H de Kruskal-Wallis` = unname(kw$statistic),
`Grados de libertad` = unname(kw$parameter),
`Valor p` = pval(kw$p.value), `Eta cuadrado (H)` = e2,
check.names = FALSE)
}# Formatea una tabla de análisis de varianza evitando que los valores p muy
# pequeños se impriman como 0.
tabla_anova <- function(a, caption) {
d <- as.data.frame(a)
if ("Pr(>F)" %in% names(d)) d[["Pr(>F)"]] <- pval(d[["Pr(>F)"]])
tabla(d, caption, digits = 4)
}# Los rótulos se asignan POR NOMBRE, nunca por posición: el orden de los
# coeficientes que devuelve lm() sigue el orden de los términos de la fórmula, y
# cualquier reordenamiento de la especificación desalinearía una asignación
# posicional sin producir ningún error visible.
ETIQUETAS <- c(
"(Intercept)" = "Intercepto",
"areaconst" = "Área construida",
"log(areaconst)" = "log(Área construida)",
"estrato_f4" = "Estrato 4",
"estrato_f5" = "Estrato 5",
"estrato_f6" = "Estrato 6",
"habitaciones" = "Alcobas",
"parqueaderos" = "Parqueaderos",
"banios" = "Baños",
"log(areaconst):estrato_f4" = "log(Área) × Estrato 4",
"log(areaconst):estrato_f5" = "log(Área) × Estrato 5",
"log(areaconst):estrato_f6" = "log(Área) × Estrato 6")
# Tabla de factores de inflación de varianza. Con un factor en el modelo,
# car::vif() devuelve el GVIF generalizado de Fox y Monette (1992) junto con
# GVIF^(1/(2·gl)), que es la cantidad comparable con el umbral habitual del VIF
# porque está en la escala de un error estándar. Los rótulos se asignan POR
# NOMBRE de término, no por posición.
ETIQUETAS_VIF <- c(areaconst = "Área construida", estrato_f = "Estrato",
habitaciones = "Alcobas", parqueaderos = "Parqueaderos",
banios = "Baños")
tabla_vif <- function(modelo, caption) {
vm <- car::vif(modelo)
nombres <- if (is.matrix(vm)) rownames(vm) else names(vm)
etq <- ifelse(nombres %in% names(ETIQUETAS_VIF),
ETIQUETAS_VIF[nombres], nombres)
if (is.matrix(vm)) {
# car::vif devuelve GVIF, gl y GVIF^(1/(2·gl)). La última columna está en
# la escala de un error estándar; su cuadrado es lo comparable con el
# umbral habitual del VIF, y es la columna que se interpreta en el texto.
d <- data.frame(`Término` = etq,
GVIF = as.numeric(vm[, 1]),
`Grados de libertad` = as.numeric(vm[, 2]),
`GVIF^(1/(2·gl))` = as.numeric(vm[, 3]),
`VIF equivalente` = as.numeric(vm[, 3])^2,
check.names = FALSE, row.names = NULL)
} else {
d <- data.frame(`Término` = etq, VIF = as.numeric(vm),
`VIF equivalente` = as.numeric(vm),
check.names = FALSE, row.names = NULL)
}
tabla(d, caption, digits = 3, align = c("l", rep("r", ncol(d) - 1)))
invisible(d)
}
tabla_coeficientes <- function(modelo, caption) {
cf <- summary(modelo)$coefficients
d <- data.frame(
Término = ifelse(rownames(cf) %in% names(ETIQUETAS),
ETIQUETAS[rownames(cf)], rownames(cf)),
`Estimación` = cf[, 1],
`Error estándar` = cf[, 2],
`t` = cf[, 3],
`Valor p` = pval(cf[, 4]),
`IC 95 % inferior` = confint(modelo)[, 1],
`IC 95 % superior` = confint(modelo)[, 2],
check.names = FALSE, row.names = NULL)
tabla(d, caption, digits = 3, align = c("l", rep("r", 6)))
}# Auditoría de coherencia entre la zona declarada y la ubicación observada.
# Distingue tres fuentes de discrepancia que exigen respuestas distintas:
# (a) error de etiqueta: el barrio pertenece mayoritariamente a otra zona;
# (b) imprecisión de la geocodificación: el barrio es correcto pero la
# coordenada del anuncio está desplazada;
# (c) etiquetas genéricas usadas como barrio.
auditoria_geografica <- function(datos, base, zona_sel, sentido) {
# Eje divisorio norte/sur: latitud mediana de la Zona Centro.
eje <- median(datos$latitud[datos$zona == "Zona Centro"], na.rm = TRUE)
contradice <- if (sentido == "norte") base$latitud < eje else base$latitud > eje
contradice[is.na(contradice)] <- FALSE
# (a) Zona mayoritaria de cada barrio en el conjunto completo.
zm <- datos |>
filter(!is.na(barrio), !is.na(zona)) |>
count(barrio, zona, name = "casos") |>
group_by(barrio) |>
slice_max(casos, n = 1, with_ties = FALSE) |>
ungroup() |>
select(barrio, zona_mayoritaria = zona)
b <- left_join(base, zm, by = "barrio")
etiqueta_erronea <- !is.na(b$zona_mayoritaria) & b$zona_mayoritaria != zona_sel
# (b) Dispersión de las coordenadas dentro de cada barrio, restringida a los
# barrios efectivamente presentes en el segmento analizado. Un barrio bien
# geocodificado debe ser espacialmente compacto.
disp <- datos |>
filter(!is.na(latitud), !is.na(barrio), barrio %in% unique(base$barrio)) |>
group_by(barrio) |>
summarise(anuncios = n(), sd_lat = sd(latitud), sd_lon = sd(longitud),
.groups = "drop") |>
filter(anuncios >= 20) |>
arrange(desc(sd_lat))
# (c) Etiquetas genéricas: nombres que no corresponden a ningún barrio.
genericas <- c("Cali", "zona norte", "zona sur", "zona centro", "zona oeste",
"zona oriente")
es_generica <- base$barrio %in% genericas
etiqueta_erronea <- etiqueta_erronea & !es_generica
# Descomposición de los registros discordantes en causas mutuamente
# excluyentes y exhaustivas.
# Nota sobre una versión anterior de esta tabla: incluía una fila "ambas
# simultáneamente" que era estructuralmente imposible, porque la línea
# anterior ya define `etiqueta_erronea` como disjunta de `es_generica`. Una
# fila que solo puede valer cero no es información. Se suprime.
# La tercera categoría es un RESIDUO —lo que no explican las dos primeras—,
# no una causa medida: se nombra como tal y se argumenta aparte, con la
# dispersión intra-barrio, que sea compatible con geocodificación imprecisa.
causas <- c("Barrio perteneciente mayoritariamente a otra zona",
"Etiqueta genérica en el campo `barrio`",
"Ninguna de las dos anteriores (residuo)")
cuenta <- c(sum(contradice & etiqueta_erronea),
sum(contradice & es_generica),
sum(contradice & !etiqueta_erronea & !es_generica))
stopifnot(sum(cuenta) == sum(contradice)) # exhaustividad y exclusión
descomposicion <- data.frame(
Causa = causas, Registros = cuenta,
`% de los discordantes` = 100 * cuenta / sum(contradice),
check.names = FALSE)
# Validación del eje. Solo son informativas las filas de Zona Norte y Zona
# Sur: que la Zona Centro quede partida en dos mitades es una identidad
# aritmética del eje —construido sobre su propia mediana— y no aporta
# evidencia alguna sobre la calidad de la regla.
# La regla debe clasificar coherentemente las cinco zonas,
# no solo la analizada.
validacion <- datos |>
filter(!is.na(zona), !is.na(latitud)) |>
group_by(Zona = zona) |>
summarise(Registros = n(),
`Latitud mediana` = median(latitud),
`Al norte del eje (%)` = 100 * mean(latitud > eje),
.groups = "drop") |>
arrange(desc(`Latitud mediana`))
# Sensibilidad al umbral: cuartiles de la latitud de la Zona Centro.
qs <- quantile(datos$latitud[datos$zona == "Zona Centro"],
c(.25, .5, .75), na.rm = TRUE)
sens <- data.frame(
Umbral = c("Percentil 25 de Zona Centro", "Mediana de Zona Centro (adoptado)",
"Percentil 75 de Zona Centro"),
Latitud = as.numeric(qs),
`Registros discordantes` = sapply(qs, function(u)
if (sentido == "norte") sum(base$latitud < u, na.rm = TRUE)
else sum(base$latitud > u, na.rm = TRUE)),
check.names = FALSE, row.names = NULL)
sens$`% del segmento` <- 100 * sens$`Registros discordantes` / nrow(base)
list(eje = eje,
n_contradice = sum(contradice), pct_contradice = 100*mean(contradice),
barrios_contradice = sort(table(base$barrio[contradice]),
decreasing = TRUE),
n_etiqueta = sum(etiqueta_erronea),
descomposicion = descomposicion, validacion = validacion,
sensibilidad = sens,
dispersion = disp, n_genericas = sum(es_generica),
indicador_contradice = contradice)
}El segmento pertinente son las casas ubicadas en la Zona Norte. La depuración aplica dos criterios, ambos verificables sobre los datos.
d1 <- depurar_segmento(vivienda, "Casa", "Zona Norte")
base1 <- preparar_modelado(d1$base)
tabla(d1$bitacora,
"Bitácora de depuración del segmento de casas de la Zona Norte.",
digits = 0, align = c("l", "r", "r"))| Etapa | Registros | Excluidos |
|---|---|---|
| Filtro por tipo y zona | 722 | — |
| Retiro de valores no positivos | 700 | 22 |
| Retiro de valores ausentes | 700 | 0 |
| Retiro de registros idénticos | 696 | 4 |
| Base analítica | 696 | — |
tabla(d1$desglose,
"Desglose por variable de los registros retirados (casas, Zona Norte).",
digits = 0, align = c("l", "r", "r"))| Variable | Valores no positivos | Valores ausentes |
|---|---|---|
| Precio | 0 | 0 |
| Área construida | 0 | 0 |
| Baños | 10 | 0 |
| Habitaciones | 20 | 0 |
De los 722 anuncios de casas en la Zona Norte se retiraron 22 registros con valores no positivos en baños, habitaciones, área o precio —un inmueble no puede tener cero baños ni cero habitaciones, de modo que son errores de captura y no ceros informativos—, 0 con valores ausentes en esas mismas cuatro variables, y 4 registros idénticos en las doce variables sustantivas. La base analítica queda en 696 registros.
Sobre los registros idénticos conviene ser preciso: el criterio detecta coincidencia exacta en las doce variables, coordenadas incluidas. La interpretación más plausible es que se trate del mismo inmueble republicado sin cambios, pero no es una interpretación verificable con estos datos, y el criterio no capturaría una republicación con el precio o la coordenada levemente alterados. Se retiran porque un registro duplicado pondera dos veces la misma información en la estimación, con independencia de su origen.
Valores extremos: se conservan Tras la depuración, el segmento conserva casas de entre 30 y 1.440 m², con precios entre 89 y 1.940 millones. Son valores extremos pero no implausibles: existen casas de gran superficie en el norte de Cali y su presencia en la oferta es real. No se recortan, porque hacerlo produciría un modelo que describe un mercado truncado artificialmente. El Anexo A4 verifica que su presencia no distorsiona los coeficientes.
# Se muestran sobre `d1$base`, es decir, ANTES de la recodificación de
# `parqueaderos` descrita en la Sección \@ref(parqueaderos-s1). De ese modo la
# tabla exhibe los valores tal como aparecen en la fuente: los guiones son
# ausencias reales, no ceros.
prim <- d1$base[1:3, c("barrio", "estrato", "preciom", "areaconst",
"habitaciones", "banios", "parqueaderos", "tipo", "zona")]
names(prim) <- c("Barrio", "Estrato", "Precio", "Área", "Alcobas", "Baños",
"Parq.", "Tipo", "Zona")
rownames(prim) <- NULL
tabla(prim, "Primeros tres registros de la base depurada de casas de la Zona Norte, con los valores tal como figuran en la fuente. Los guiones en la columna de parqueaderos son datos ausentes; su tratamiento se decide en la Sección 3.1.1.",
digits = 0, align = c("l", rep("r", 6), "l", "l"))| Barrio | Estrato | Precio | Área | Alcobas | Baños | Parq. | Tipo | Zona |
|---|---|---|---|---|---|---|---|---|
| acopi | 5 | 320 | 150 | 6 | 4 | 2 | Casa | Zona Norte |
| acopi | 5 | 780 | 380 | 3 | 3 | 2 | Casa | Zona Norte |
| acopi | 6 | 750 | 445 | 6 | 7 | — | Casa | Zona Norte |
verif <- data.frame(
Verificación = c("Valores distintos en `tipo`", "Valores distintos en `zona`",
"Registros", "Rango de precio (millones)",
"Rango de área (m²)", "Estratos presentes"),
Resultado = c(paste(unique(base1$tipo), collapse = ", "),
paste(unique(base1$zona), collapse = ", "),
nrow(base1),
paste0(min(base1$preciom), " – ", max(base1$preciom)),
paste0(min(base1$areaconst), " – ", max(base1$areaconst)),
paste(sort(unique(base1$estrato)), collapse = ", ")))
tabla(verif, "Comprobación de que el filtro se aplicó correctamente.",
align = c("l", "l"))| Verificación | Resultado |
|---|---|
Valores distintos en tipo
|
Casa |
Valores distintos en zona
|
Zona Norte |
| Registros | 696 |
| Rango de precio (millones) | 89 – 1940 |
| Rango de área (m²) | 30 – 1440 |
| Estratos presentes | 3, 4, 5, 6 |
de <- base1 |>
count(Estrato = estrato, name = "Registros") |>
mutate(`Porcentaje` = round(100 * Registros / sum(Registros), 1),
`Precio mediano` = sapply(Estrato, function(e)
median(base1$preciom[base1$estrato == e])))
tabla(de, "Composición por estrato del segmento de casas de la Zona Norte.",
digits = 1, align = c("c", "r", "r", "r"))| Estrato | Registros | Porcentaje | Precio mediano |
|---|---|---|---|
| 3 | 229 | 32,9 | 210 |
| 4 | 150 | 21,6 | 380 |
| 5 | 264 | 37,9 | 480 |
| 6 | 53 | 7,6 | 780 |
falt1 <- sum(is.na(d1$base$parqueaderos))
pct1 <- 100 * mean(is.na(d1$base$parqueaderos))
cero1 <- sum(d1$base$parqueaderos == 0, na.rm = TRUE)
diag1 <- diagnostico_ausencia(d1$base, "parqueaderos")
tabla(diag1,
"Contraste del mecanismo de ausencia en `parqueaderos` (casas, Zona Norte).",
digits = 3, align = c("l", rep("r", 5)))| Variable | Mediana sin dato | Mediana con dato | U | Valor p | r biserial |
|---|---|---|---|---|---|
| preciom | 299,500 | 427,500 | 37.986 | 0.0000000000000924 | -0,336 |
| areaconst | 160,000 | 264,500 | 37.567 | 0.0000000000000266 | -0,343 |
| banios | 3,000 | 4,000 | 42.753 | 0.00000000947 | -0,252 |
| habitaciones | 4,000 | 4,000 | 44.326 | 0.000000286 | -0,225 |
| estrato | 3,000 | 5,000 | 35.844 | <0.0000000000000002 | -0,373 |
La variable parqueaderos carece de dato en 266 registros
(38,2 %), una proporción que obliga a decidir de forma explícita y
justificada. Dos hechos delimitan el tratamiento.
Primero, los registros sin dato no son una muestra aleatoria del segmento: son sistemáticamente más económicos, más pequeños y de estrato inferior, con correlaciones biseriales de rangos entre -0,37 y -0,22. Los umbrales de Cohen (1988) se formularon para el coeficiente de Pearson y su traslado a la correlación biserial de rangos es una convención de uso, no una equivalencia: sirven para ordenar magnitudes, no para etiquetarlas. Con esa salvedad, las diferencias no son marginales.
Segundo, el valor cero no aparece ni una sola vez entre los 430 registros con dato (0 observaciones en cero): la plataforma de origen no dispone de una codificación observable para la ausencia de garaje.
ae1 <- asociacion_ausencia_estrato(d1$base, "parqueaderos")
tabla(ae1$perfil,
"Proporción de registros sin dato de parqueadero, por estrato.",
digits = 1, align = c("c", "r", "r"))| Estrato | Sin dato (%) | Registros |
|---|---|---|
| 3 | 64,6 | 229 |
| 4 | 32,7 | 150 |
| 5 | 16,3 | 264 |
| 6 | 49,1 | 53 |
tabla(ae1$resumen,
"Asociación entre el indicador de ausencia y el estrato (casas, Zona Norte).",
digits = 3, align = rep("r", 4))| Ji-cuadrado | Grados de libertad | Valor p | V de Cramér |
|---|---|---|---|
| 126,016 | 3 | <0.0000000000000002 | 0,426 |
La asociación entre la ausencia y el estrato es significativa (\(\chi^2 = 126,02\) con 3 grados de libertad, valor p <0.0000000000000002), con una V de Cramér de 0,426: una asociación de magnitud apreciable, no marginal.
Decisión metodológica: recodificación de parqueaderos
Qué establecen las pruebas y qué no. Los contrastes anteriores muestran que
la probabilidad de ausencia depende de variables observadas (precio, área,
estrato). Eso descarta el mecanismo completamente aleatorio (MCAR) y nada
más. Precisamente esa dependencia respecto de lo observado es la definición del
mecanismo aleatorio condicional (MAR) en la clasificación de Rubin (1976). La
distinción entre MAR y no aleatorio (MNAR) exige saber si la ausencia depende
además del valor no observado de parqueaderos, y esa distinción no es
contrastable con los datos disponibles (Little & Rubin, 2019, §1.3 y §6.1).
Ningún contraste puede resolverla, aquí ni en general.
El supuesto que se adopta. Se asume que el campo vacío codifica la ausencia de garaje. El fundamento es estructural, no inferencial: el valor cero no aparece registrado ni una sola vez, de modo que la plataforma de origen carece de codificación observable para “sin parqueadero”, y el campo vacío es el candidato natural a desempeñar ese papel. Es un supuesto sobre el proceso de captura, no un resultado de las pruebas, y como tal se declara.
Consecuencia. Se recodifica la ausencia como cero parqueaderos. Bajo el supuesto adoptado, la eliminación de casos completos sesgaría la muestra hacia inmuebles de gama alta, y la imputación por media o mediana asignaría garajes a inmuebles que presumiblemente no los tienen. El Anexo A3 cuantifica el efecto de la decisión comparándola con ambas alternativas.
Salvedad que conviene declarar. El patrón no es monótono: la proporción de ausencia cae del estrato 3 al 5 pero repunta en el estrato 6 (49,1 %). En ese estrato el supuesto es menos verosímil: es más plausible que corresponda a anuncios incompletos de inmuebles de gama alta. La recodificación introduce, por tanto, un sesgo conocido en el extremo superior del segmento, cuya magnitud acota el Anexo A3.
aud1 <- auditoria_geografica(vivienda, base1, "Zona Norte", "norte")
# Copia solo para el mapa. La base de modelado NO se modifica: si se le añade
# una columna aquí, las carteras construidas antes y después de este punto
# dejan de ser combinables.
mapa_base1 <- transform(base1, fuera_zona = aud1$indicador_contradice)
leaflet(mapa_base1) |>
addProviderTiles(providers$CartoDB.Positron) |>
addCircleMarkers(lng = ~longitud, lat = ~latitud,
radius = 3, stroke = FALSE, fillOpacity = 0.6,
color = ~ifelse(fuera_zona, NARANJA, ACENTO),
popup = ~paste0("<b>", barrio, "</b><br>Estrato ", estrato,
"<br>", mm(preciom, 0), " millones")) |>
addLegend("bottomright", colors = c(ACENTO, NARANJA),
labels = c("Coherente con la Zona Norte",
"Al sur del eje divisorio"),
title = "Ubicación", opacity = 0.9)Figure 3.1: Localización de las casas de la Zona Norte. Los puntos en naranja se sitúan al sur del eje divisorio de la ciudad pese a estar declarados en la Zona Norte.
108 registros (15,5 % del segmento) se ubican al sur de la latitud mediana de la Zona Centro (3,4399), que se adopta como eje divisorio de la ciudad. Antes de interpretar esa cifra hay que verificar que la regla es razonable, porque un eje mal elegido produciría discordancias artificiales.
tabla(aud1$validacion,
"Validación de la regla del eje divisorio sobre las cinco zonas del conjunto completo.",
digits = 4, align = c("l", "r", "r", "r"))| Zona | Registros | Latitud mediana | Al norte del eje (%) |
|---|---|---|---|
| Zona Norte | 1.920 | 3,4724 | 85,4688 |
| Zona Oeste | 1.198 | 3,4490 | 71,3689 |
| Zona Centro | 124 | 3,4399 | 50,0000 |
| Zona Oriente | 351 | 3,4380 | 47,0085 |
| Zona Sur | 4.726 | 3,3848 | 7,3847 |
tabla(aud1$sensibilidad,
"Sensibilidad del recuento de discordancias al umbral elegido (casas, Zona Norte).",
digits = 4, align = c("l", "r", "r", "r"))| Umbral | Latitud | Registros discordantes | % del segmento |
|---|---|---|---|
| Percentil 25 de Zona Centro | 3,4360 | 104 | 14,9425 |
| Mediana de Zona Centro (adoptado) | 3,4399 | 108 | 15,5172 |
| Percentil 75 de Zona Centro | 3,4460 | 116 | 16,6667 |
Antes de leer la tabla conviene descontar una fila: que la Zona Centro quede partida al 50 % es una identidad aritmética —el eje es su mediana— y no aporta evidencia. Las filas informativas son las de Zona Norte y Zona Sur.
La regla se comporta como debe: clasifica al norte del eje al 85,5 % de los registros de la Zona Norte y solo al 7,4 % de los de la Zona Sur. Esa separación sí es informativa, porque nada obligaba a que la mediana de la Zona Centro discriminara bien dos zonas ajenas a su construcción. El recuento es además estable frente al umbral: entre el percentil 25 y el 75 de la latitud de la Zona Centro, el número de discordancias varía de 104 a 116. La conclusión no depende del umbral concreto.
La pregunta pertinente, entonces, no es cuántos registros discrepan, sino por qué, porque cada causa exige una respuesta distinta.
tabla(aud1$descomposicion,
"Descomposición de los registros discordantes en causas mutuamente excluyentes (casas, Zona Norte).",
digits = 1, align = c("l", "r", "r"))| Causa | Registros | % de los discordantes |
|---|---|---|
| Barrio perteneciente mayoritariamente a otra zona | 12 | 11,1 |
Etiqueta genérica en el campo barrio
|
13 | 12,0 |
| Ninguna de las dos anteriores (residuo) | 83 | 76,9 |
causas <- data.frame(
Causa = c("Barrio perteneciente mayoritariamente a otra zona",
"Etiqueta genérica en el campo `barrio`",
"Ninguna de las dos anteriores (residuo)"),
Tratamiento = c(
"Se conservan: el filtro del enunciado opera sobre la `zona` declarada",
"Se conservan: `barrio` no se usa como predictor",
"Se conservan: la evidencia de dispersión intra-barrio es compatible con una coordenada desplazada, que no altera la clasificación del inmueble"))
tabla(causas, "Tratamiento aplicado a cada causa.", align = c("l", "l"))| Causa | Tratamiento |
|---|---|
| Barrio perteneciente mayoritariamente a otra zona |
Se conservan: el filtro del enunciado opera sobre la zona declarada
|
Etiqueta genérica en el campo barrio
|
Se conservan: barrio no se usa como predictor
|
| Ninguna de las dos anteriores (residuo) | Se conservan: la evidencia de dispersión intra-barrio es compatible con una coordenada desplazada, que no altera la clasificación del inmueble |
disp_top <- head(aud1$dispersion, 6)
names(disp_top) <- c("Barrio", "Anuncios", "Desv. típica latitud",
"Desv. típica longitud")
tabla(disp_top,
"Barrios del segmento con mayor dispersión espacial interna: evidencia de geocodificación imprecisa. Solo se incluyen barrios presentes en la base de casas de la Zona Norte con 20 o más anuncios.",
digits = 4, align = c("l", "r", "r", "r"))| Barrio | Anuncios | Desv. típica latitud | Desv. típica longitud |
|---|---|---|---|
| acopi | 158 | 0,0431 | 0,0144 |
| Cali | 37 | 0,0319 | 0,0120 |
| valle del lili | 1.008 | 0,0269 | 0,0111 |
| brisas de los | 81 | 0,0233 | 0,0129 |
| vipasa | 32 | 0,0232 | 0,0072 |
| urbanización la flora | 83 | 0,0223 | 0,0090 |
La categoría mayoritaria es la tercera —el residuo—, con 83 de los 108 registros discordantes (76,9 %). Que sea un residuo obliga a argumentar su interpretación aparte, en lugar de darla por establecida por la propia tabla: lo que sigue es esa evidencia. El barrio vipasa lo ilustra: figura en la tabla anterior con 32 anuncios y una desviación típica de la latitud de 0,0232°. Su latitud mediana es 3,4780 —inequívocamente al norte— pero sus anuncios se extienden hasta 3,3756. El barrio está correctamente clasificado; lo desplazado es la coordenada del anuncio individual.
Qué se puede y qué no se puede hacer con estas coordenadas La dispersión detectada es de orden kilométrico dentro de un mismo barrio. Las coordenadas son, por tanto, fiables a escala de barrio pero no a escala de manzana. Esto delimita con precisión su uso en el resto del informe: permiten localizar orientativamente una oferta y detectar dependencia espacial agregada —como hace el contraste de Moran de la Sección 3.4, que opera sobre vecindades amplias—, pero no permiten construir predictores de ubicación exacta, razón por la cual el modelo no las incorpora como covariables.
grafico_correlaciones(base1,
"Asociación monótona entre precio y atributos · Casas de la Zona Norte")Figure 3.2: Correlaciones de Spearman entre el precio y los atributos del inmueble (casas, Zona Norte).
vs <- c("areaconst", "estrato", "banios", "habitaciones", "parqueaderos")
et <- c("Área construida", "Estrato", "Baños", "Alcobas", "Parqueaderos")
ct <- data.frame(
Variable = et,
`Spearman con el precio` = sapply(vs, function(v)
cor(base1$preciom, base1[[v]], method = "spearman",
use = "complete.obs")),
`Pearson con el precio` = sapply(vs, function(v)
cor(base1$preciom, base1[[v]], use = "complete.obs")),
check.names = FALSE, row.names = NULL)
tabla(ct, "Correlación entre el precio y cada atributo (casas, Zona Norte).",
digits = 3, align = c("l", "r", "r"))| Variable | Spearman con el precio | Pearson con el precio |
|---|---|---|
| Área construida | 0,820 | 0,731 |
| Estrato | 0,718 | 0,617 |
| Baños | 0,645 | 0,569 |
| Alcobas | 0,436 | 0,377 |
| Parqueaderos | 0,409 | 0,336 |
Figure 3.3: Relación entre precio y área construida, por estrato (casas, Zona Norte).
grafico_precio_categoria(base1, "estrato", "Estrato",
"El estrato separa niveles de precio claramente distintos")Figure 3.4: Distribución del precio por estrato (casas, Zona Norte).
grafico_precio_categoria(base1, "banios", "Número de baños",
"El precio crece de forma sostenida con el número de baños")Figure 3.5: Distribución del precio por número de baños (casas, Zona Norte).
grafico_precio_categoria(base1, "habitaciones", "Número de alcobas",
"El número de alcobas no ordena el precio de forma monótona")Figure 3.6: Distribución del precio por número de alcobas (casas, Zona Norte).
cg1 <- rbind(
contraste_grupos(base1, "estrato", "Estrato"),
contraste_grupos(base1, "banios", "Número de baños"),
contraste_grupos(base1, "habitaciones", "Número de alcobas"),
contraste_grupos(base1, "parqueaderos", "Número de parqueaderos"))
tabla(cg1,
"Contraste de Kruskal-Wallis: diferencias de precio entre niveles de cada atributo.",
digits = 4, align = c("l", rep("r", 5)))| Variable | Niveles | H de Kruskal-Wallis | Grados de libertad | Valor p | Eta cuadrado (H) |
|---|---|---|---|---|---|
| Estrato | 4 | 365,6059 | 3 | <0.0000000000000002 | 0,5240 |
| Número de baños | 10 | 294,5052 | 9 | <0.0000000000000002 | 0,4162 |
| Número de alcobas | 10 | 149,2844 | 9 | <0.0000000000000002 | 0,2045 |
| Número de parqueaderos | 11 | 137,1972 | 10 | <0.0000000000000002 | 0,1857 |
Tres resultados orientan la especificación del modelo.
El área construida es el determinante principal. Su correlación de Spearman con el precio es de 0,820, la más alta del conjunto. La relación, sin embargo, no es exactamente lineal: la nube de puntos se abre a medida que crece el área, lo que anticipa el problema de heterocedasticidad que la Sección 3.4 confirma formalmente.
El estrato ordena el precio, pero no de forma equidistante. El salto de precio mediano del estrato 5 al 6 es sustancialmente mayor que el del 3 al 4. Esto es una advertencia explícita contra tratar el estrato como variable numérica: hacerlo impondría que cada escalón vale lo mismo, supuesto que los datos contradicen. La Sección 3.3 lo contrasta formalmente.
El número de alcobas no ordena el precio. A diferencia de los baños, cuyo efecto es monótono y claro, las cajas del gráfico de alcobas se superponen casi por completo. La razón es que las alcobas están fuertemente correlacionadas con el área: una vez que el área entra en el modelo, las alcobas dejan de aportar información propia. El contraste de Kruskal-Wallis las declara significativas —porque no controla por el área—, pero la regresión múltiple, que sí lo hace, llegará a la conclusión contraria. La discrepancia entre ambos resultados no es un error: es exactamente la diferencia entre asociación marginal y asociación parcial.
La zona no puede entrar en el modelo. El enunciado incluye la zona entre las
variables del análisis exploratorio. Dentro de la base filtrada, sin embargo,
zona toma un único valor —Zona Norte— y su varianza es cero: una
variable constante es linealmente dependiente del intercepto y su coeficiente no
es estimable. Su efecto se examina en el Anexo A2, sobre el conjunto completo y
antes de filtrar, que es el único punto donde la comparación entre zonas tiene
sentido.
El enunciado solicita el modelo \[ \text{precio} = f(\text{área construida},\ \text{estrato},\ \text{alcobas},\ \text{parqueaderos},\ \text{baños}), \] es decir, el modelo lineal \[ Y_i = \beta_0 + \beta_1 x_{i1} + \cdots + \beta_k x_{ik} + \varepsilon_i, \qquad \varepsilon_i \overset{\text{iid}}{\sim} (0, \sigma^2), \] estimado por mínimos cuadrados ordinarios. Antes de interpretarlo se resuelve una decisión de especificación que condiciona toda la lectura.
Tratar el estrato como número impone la restricción de que el paso del estrato 3 al 4 vale exactamente lo mismo que el del 5 al 6. Es una hipótesis contrastable, no una convención: los dos modelos están anidados y la comparación es un contraste F ordinario.
m0_1 <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
data = base1)
m1_1 <- lm(preciom ~ areaconst + estrato_f + habitaciones + parqueaderos + banios,
data = base1)
an1 <- anova(m0_1, m1_1)
tabla_anova(an1,
"Contraste anidado: estrato lineal frente a estrato como factor (casas, Zona Norte).")| Res.Df | RSS | Df | Sum of Sq | F | Pr(>F) |
|---|---|---|---|---|---|
| 690 | 17.325.872,4017 | — | — | — | NA |
| 688 | 16.848.500,1432 | 2 | 477.372,2586 | 9,7466 | 0.000067 |
\(H_0\): el efecto del estrato sobre el precio es lineal en la escala del estrato, esto es, los tres incrementos sucesivos son iguales.
\(H_1\): al menos un incremento difiere de los demás.
Estadístico \(F = 9,747\) con \((2, 688)\) grados de libertad, valor p \(= 0.000067\). Se rechaza \(H_0\) al 5 %.
El estrato entra en el modelo como factor, con el estrato 3 como categoría de referencia.
mod1 <- m1_1
tabla_coeficientes(mod1,
"Modelo de regresión lineal múltiple para el precio de casas de la Zona Norte.")| Término | Estimación | Error estándar | t | Valor p | IC 95 % inferior | IC 95 % superior |
|---|---|---|---|---|---|---|
| Intercepto | 17,122 | 19,082 | 0,897 | 0.370 | -20,343 | 54,587 |
| Área construida | 0,781 | 0,046 | 16,892 | < 0.0000000000000002 | 0,691 | 0,872 |
| Estrato 4 | 73,482 | 17,900 | 4,105 | 0.0000452569570496 | 38,337 | 108,626 |
| Estrato 5 | 137,006 | 17,380 | 7,883 | 0.0000000000000126 | 102,881 | 171,131 |
| Estrato 6 | 324,742 | 27,462 | 11,825 | < 0.0000000000000002 | 270,823 | 378,660 |
| Alcobas | 4,238 | 4,831 | 0,877 | 0.381 | -5,248 | 13,724 |
| Parqueaderos | 3,305 | 4,417 | 0,748 | 0.455 | -5,368 | 11,978 |
| Baños | 29,031 | 5,937 | 4,890 | 0.0000012564013049 | 17,375 | 40,687 |
s1 <- summary(mod1)
# La columna mezcla magnitudes muy dispares; se formatea a mano para que el
# valor p no aparezca redondeado a cero, que es lo que ocurriría con un número
# fijo de decimales para toda la columna.
aj1 <- data.frame(
Medida = c("R²", "R² ajustado", "Error estándar residual (millones)",
"Estadístico F", "Valor p del contraste global",
"Observaciones"),
Valor = c(mm(s1$r.squared, 4), mm(s1$adj.r.squared, 4), mm(s1$sigma, 1),
mm(unname(s1$fstatistic[1]), 1),
pval(pf(s1$fstatistic[1], s1$fstatistic[2], s1$fstatistic[3],
lower.tail = FALSE)),
ent(nrow(base1))))
tabla(aj1, "Medidas de ajuste global del modelo (casas, Zona Norte).",
digits = 4, align = c("l", "r"))| Medida | Valor |
|---|---|
| R² | 0,6636 |
| R² ajustado | 0,6602 |
| Error estándar residual (millones) | 156,5 |
| Estadístico F | 193,9 |
| Valor p del contraste global | <0.0000000000000002 |
| Observaciones | 696 |
Los coeficientes se interpretan manteniendo constantes las demás variables del modelo; esa cláusula no es un formalismo, es lo que distingue un efecto parcial de una correlación simple.
Área construida (0,781, p < 0,001). Cada metro cuadrado adicional se asocia con un incremento esperado de 0,78 millones de pesos, esto es, cerca de 781 mil pesos por metro cuadrado, entre casas del mismo estrato y con igual número de alcobas, baños y parqueaderos. Cien metros cuadrados adicionales valen unos 78 millones. Es el efecto de mayor magnitud económica del modelo y su signo es el esperado.
Estrato (todos significativos, p < 0,001). Frente a una casa de estrato 3 con idénticas características físicas, el precio esperado es 73,5 millones mayor en estrato 4, 137,0 millones mayor en estrato 5 y 324,7 millones mayor en estrato 6. La progresión es marcadamente convexa: el salto del estrato 5 al 6 (187,7 millones) es 2,55 veces el del 3 al 4 (73,5 millones). Esto confirma sobre los coeficientes lo que el contraste anidado ya había establecido, y tiene una lectura sustantiva: el estrato no mide solo capacidad de pago, sino un conjunto de atributos de localización — seguridad, entorno, servicios, valorización esperada— cuyo valor de mercado crece más que proporcionalmente en el extremo superior de la escala.
Baños (29,03, p < 0,001). Cada baño adicional se asocia con 29,0 millones más, a igual área. El resultado es lógico y económicamente interpretable: a superficie constante, más baños indica una distribución interior de mayor calidad y un inmueble mejor dotado.
Alcobas (4,24, p = 0,381) y parqueaderos (3,31, p = 0,455): no significativos. Ninguno aporta información sobre el precio una vez controlados el área y el estrato. Conviene ser preciso sobre lo que esto significa y lo que no.
Por qué las alcobas dejan de importar El análisis exploratorio mostró que el número de alcobas sí se asocia con el precio cuando se examina de forma aislada. Al entrar en el modelo junto al área, ese efecto desaparece porque las alcobas son en buena medida un reflejo del tamaño: casas más grandes tienen más alcobas. Una vez que el área está en el modelo, el número de alcobas no añade nada. La lectura correcta no es “las alcobas no valen”, sino: a igual superficie, subdividir en más alcobas no agrega valor de mercado. Para el comprador la superficie manda; el reparto interior es secundario.
El caso de los parqueaderos exige una advertencia adicional. Su falta de significación se estima sobre una variable que, en el 38,2 % de los registros, es una recodificación de un dato ausente y no una medición. La conclusión es que no puede establecerse un efecto del parqueadero con estos datos, no que el parqueadero carezca de valor. Es una limitación de la fuente, no un hallazgo sobre el mercado.
El modelo explica el 66,4 % de la variabilidad del precio de las casas de la Zona Norte (\(R^2 = 0,6636\); \(R^2\) ajustado \(= 0,6602\), prácticamente igual, lo que indica que ninguna variable está incluida a costa de penalizar el ajuste). El contraste global \(F = 193,9\) rechaza de forma contundente que todos los coeficientes de pendiente sean nulos.
La cifra relevante para la decisión de C&A, sin embargo, no es el \(R^2\) sino el error estándar residual: 156,5 millones de pesos. Significa que, conocidas todas las características del inmueble, el precio de una casa concreta puede desviarse del valor estimado en un orden de ±313 millones. Sobre un presupuesto de 350 millones, ese margen es enorme: el modelo sirve para valorar el segmento y detectar ofertas fuera de rango, no para fijar el precio de un inmueble individual.
Cuatro vías de mejora, ordenadas por el rendimiento que cabe esperar de cada una:
preciom es el precio pedido, que en un mercado contraído como el descrito
por María sobrestima sistemáticamente el precio de cierre.# `dg1` se calculó en el preámbulo (chunk `precalculo`) con la llamada
# diagnosticar(mod1, base1); aquí solo se presenta.
tabla(dg1$pruebas,
"Contrastes formales sobre los supuestos del modelo (casas, Zona Norte).",
digits = 4, align = c("l", "l", "r", "r", "l"))| Supuesto | Prueba | Estadístico | Valor p | Decisión |
|---|---|---|---|---|
| Homocedasticidad | Breusch-Pagan (estudentizado) | 135,9405 | <0.0000000000000002 | Se rechaza H₀ |
| Homocedasticidad | White simplificado (LM de Koenker) | 130,9656 | <0.0000000000000002 | Se rechaza H₀ |
| Normalidad | Anderson-Darling | 26,3655 | <0.0000000000000002 | Se rechaza H₀ |
| Normalidad | Lilliefors | 0,1397 | <0.0000000000000002 | Se rechaza H₀ |
| Normalidad | Jarque-Bera | 3.921,8186 | <0.0000000000000002 | Se rechaza H₀ |
| Linealidad / forma funcional | RESET de Ramsey (potencia 2) | 2,0017 | 0.158 | No se rechaza H₀ |
tabla(dg1$forma, "Medidas de forma de la distribución residual (casas, Zona Norte).",
digits = 4, align = c("l", "r"))| Medida | Valor |
|---|---|
| Asimetría de los residuos | 1,6054 |
| Curtosis en exceso | 11,1343 |
| Máxima distancia de Cook | 0,7288 |
| Error estándar residual | 156,4899 |
op <- par(mfrow = c(2, 2), mar = c(4.2, 4.2, 2.6, 1.2), col.axis = "#4D4D4D")
plot(mod1, which = 1, col = adjustcolor(ACENTO, 0.4), pch = 16,
caption = "Residuos frente a valores ajustados")
plot(mod1, which = 2, col = adjustcolor(ACENTO, 0.4), pch = 16,
caption = "Gráfico cuantil-cuantil normal")
plot(mod1, which = 3, col = adjustcolor(ACENTO, 0.4), pch = 16,
caption = "Escala-localización")
plot(mod1, which = 5, col = adjustcolor(ACENTO, 0.4), pch = 16,
caption = "Residuos frente a apalancamiento")Figure 3.7: Diagnóstico gráfico del modelo en niveles (casas, Zona Norte).
v1 <- tabla_vif(mod1,
"Factores de inflación de varianza (casas, Zona Norte). Para el estrato, que ocupa tres grados de libertad, la columna comparable con el umbral clásico es el VIF equivalente.")tabla(dg1$influencia,
"Diagnóstico de observaciones influyentes (casas, Zona Norte).",
digits = 3, align = c("l", "r", "r", "r"))| Criterio | Umbral | Casos señalados | % del total |
|---|---|---|---|
| Distancia de Cook > 4/n | 0,006 | 48 | 6,897 |
| Apalancamiento > 2p/n | 0,023 | 68 | 9,770 |
| |DFFITS| > 2√(p/n) | 0,214 | 49 | 7,040 |
| |Residuo estudentizado| > 3 | 3,000 | 17 | 2,443 |
tabla(dg1$espacial[, 1:5],
"I de Moran sobre los residuos: contraste de autocorrelación espacial (casas, Zona Norte).",
digits = 4, align = rep("r", 5))| I | E[I] (Cliff-Ord) | z | Valor p | Observaciones |
|---|---|---|---|---|
| 0,0400 | -0,0035 | 2,5115 | 0.012 | 696 |
Homocedasticidad: se rechaza. Breusch-Pagan y White coinciden con valores p inferiores a 10⁻²⁰. El gráfico de residuos frente a ajustados muestra el patrón de abanico característico: la dispersión del precio crece con el nivel del precio. Las estimaciones de los coeficientes siguen siendo insesgadas —la heterocedasticidad no afecta a \(E[\hat\beta]\)—, pero los errores estándar y por tanto todos los valores p y los intervalos dejan de ser válidos. Es el problema más grave del modelo.
Normalidad de los residuos: se rechaza. Los tres contrastes coinciden. La curtosis en exceso de 11,13 y la asimetría de 1,61 indican colas mucho más pesadas que las de una normal, con exceso de residuos positivos grandes: inmuebles que se venden muy por encima de lo que sus atributos predicen.
Dos matices que el rechazo por sí solo no transmite. Primero, con 696 observaciones estos contrastes tienen una potencia enorme y rechazan desviaciones irrelevantes: el rechazo per se dice poco, y lo que sustenta el diagnóstico son las medidas de forma —una curtosis en exceso de 11,1 no es una desviación menor—. Segundo, ese mismo tamaño muestral hace que el teorema central del límite proteja parcialmente los contrastes sobre los coeficientes, pero no protege los intervalos de predicción, que dependen de la distribución del error individual y no de la del estimador.
Linealidad: no se rechaza (RESET, p = 0,158). El modelo no muestra evidencia de forma funcional mal especificada en la media condicional.
Multicolinealidad: ausente. El mayor VIF equivalente es 2,16, muy lejos del umbral convencional de 10 y también del más exigente de 5. Los efectos parciales son separables; la falta de significación de alcobas y parqueaderos no es un artefacto de colinealidad.
Observaciones influyentes: presentes pero no determinantes. Se señalan 48 casos por distancia de Cook, con un máximo de 0,729, por debajo del umbral clásico de 1. No hay una observación que gobierne el ajuste por sí sola. El Anexo A4 verifica la estabilidad de los coeficientes al excluirlas.
Independencia de los errores: se rechaza en su versión espacial. El estadístico de Durbin-Watson no es aplicable aquí: no existe orden temporal en datos de corte transversal, y aplicarlo sobre el orden arbitrario de las filas sería un contraste sin contenido. La forma pertinente de dependencia en datos inmobiliarios es la espacial, y el I de Moran sobre los residuos —calculado con los momentos de Cliff y Ord para residuos de MCO, no con los de aleatorización, que no son válidos aquí— da 0,0400 con \(z = 2,51\) y valor p 0.012.
Conviene separar dos lecturas que es fácil confundir. El rechazo es estadístico, pero la magnitud es pequeña: un \(I\) de 0,040 sobre un rango efectivo próximo a \([-1, 1]\) indica una dependencia espacial débil, y con 696 observaciones un valor así alcanza significación sin representar por ello una fuente importante de variabilidad. La conclusión correcta es que existe algo de estructura espacial no recogida por el modelo, no que la localización sea el factor omitido dominante en este segmento. En el de apartamentos, donde \(I = 0,150\), la lectura sí es sustantiva; la comparación se retoma en la Sección 4.4.
Qué hacer con estos incumplimientos El enunciado no exige corregir, sino diagnosticar y sugerir. Las sugerencias, en orden de prioridad:
log(precio) con log(área) las atenúa simultáneamente. El Anexo A5 lo
verifica: en este segmento la asimetría cae de
1,61 a 0,43 y la
curtosis en exceso de 11,13 a
0,89.Consecuencia directa sobre la predicción El intervalo de predicción al 95 % que este modelo produce para el perfil solicitado en estrato 4 tiene límite inferior 16,4 millones de pesos. Un intervalo que admite precios cercanos a cero para una casa de 200 m² en el norte de Cali no es un resultado interpretable: es la manifestación numérica de la no normalidad y la heterocedasticidad detectadas. Por esta razón la predicción de la Sección 3.5 no se apoya en este modelo, sino en la especificación que la comparación formal de la Sección 3.5 identifica como superior.
cmp1 <- comparar_modelos(base1)
tabla(cmp1,
"Comparación de las cinco especificaciones candidatas (casas, Zona Norte). AIC y BIC solo son comparables dentro de una misma escala de la respuesta.",
digits = 3, align = c("l", "l", rep("r", 7)))| Modelo | Escala | Parámetros | R² | R² ajustado | AIC | BIC | RMSE VC | MAE VC |
|---|---|---|---|---|---|---|---|---|
| M0 · Niveles, estrato numérico | precio | 6 | 0,654 | 0,652 | 9.034,326 | 9.066,144 | 160,493 | 101,432 |
| M1 · Niveles, estrato factor | precio | 8 | 0,664 | 0,660 | 9.018,881 | 9.059,789 | 159,686 | 98,735 |
| M2 · Log-lineal, estrato factor | log(precio) | 8 | 0,758 | 0,756 | 218,393 | 259,301 | 184,016 | 105,754 |
| M3 · Log-log en área | log(precio) | 8 | 0,784 | 0,782 | 139,434 | 180,342 | 160,369 | 99,837 |
| M4 · Log-log con interacción área × estrato | log(precio) | 11 | 0,786 | 0,783 | 139,890 | 194,434 | 160,309 | 99,703 |
rmse_niveles1 <- cmp1$`RMSE VC`[cmp1$Modelo == "M1 · Niveles, estrato factor"]
rmse_loglog1 <- cmp1$`RMSE VC`[cmp1$Modelo == "M3 · Log-log en área"]
duan1 <- mean(exp(residuals(mod1_log))) # mod1_log y dg1_log: ver `precalculo`El criterio decisivo es el error de validación cruzada, único comparable entre escalas. El modelo log-log alcanza un RMSE de 160,4 millones frente a 159,7 millones del modelo en niveles. La diferencia en capacidad predictiva es pequeña: ambos modelos predicen prácticamente igual de bien y la elección no puede decidirse solo con este criterio. La diferencia en validez inferencial, en cambio, es determinante:
nuevo_perfil <- data.frame(
areaconst = 200, estrato_f = factor("4", levels = levels(base1$estrato_f)),
habitaciones = 4, parqueaderos = 1, banios = 2)
ip_niveles <- predict(mod1, newdata = nuevo_perfil,
interval = "prediction")[1, "lwr"]
ip_loglog <- exp(predict(mod1_log, newdata = nuevo_perfil,
interval = "prediction")[1, "lwr"])
comp_diag <- data.frame(
Criterio = c("Asimetría de los residuos", "Curtosis en exceso",
"Breusch-Pagan (valor p)", "RESET (valor p)",
"Máxima distancia de Cook",
"Límite inferior del IP 95 % (estrato 4, millones)"),
`Modelo en niveles` = c(
mm(dg1$forma$Valor[1], 3), mm(dg1$forma$Valor[2], 3),
pval(dg1$p_valores[1]), pval(dg1$p_valores[6]),
mm(dg1$forma$Valor[3], 4), mm(ip_niveles, 1)),
`Modelo log-log` = c(
mm(dg1_log$forma$Valor[1], 3), mm(dg1_log$forma$Valor[2], 3),
pval(dg1_log$p_valores[1]), pval(dg1_log$p_valores[6]),
mm(dg1_log$forma$Valor[3], 4), mm(ip_loglog, 1)),
check.names = FALSE)
tabla(comp_diag,
"Por qué se selecciona el modelo logarítmico: igual capacidad predictiva, inferencia válida.",
align = c("l", "r", "r"))| Criterio | Modelo en niveles | Modelo log-log |
|---|---|---|
| Asimetría de los residuos | 1,605 | 0,434 |
| Curtosis en exceso | 11,134 | 0,895 |
| Breusch-Pagan (valor p) | <0.0000000000000002 | 0.0145 |
| RESET (valor p) | 0.158 | 0.487 |
| Máxima distancia de Cook | 0,7288 | 0,0457 |
| Límite inferior del IP 95 % (estrato 4, millones) | 16,4 | 192,3 |
Modelo identificado para la predicción Se adopta la especificación \[ \log(\text{precio}_i) = \beta_0 + \beta_1 \log(\text{área}_i) + \sum_{j=4}^{6}\gamma_j \mathbb{1}\{\text{estrato}_i = j\} + \beta_2\,\text{alcobas}_i + \beta_3\,\text{parq.}_i + \beta_4\,\text{baños}_i + \varepsilon_i. \] Predice igual de bien que el modelo en niveles, cumple de forma razonable los supuestos que aquel viola, y produce intervalos económicamente admisibles. El coeficiente de \(\log(\text{área})\) es una elasticidad: un aumento del 1 % en el área se asocia con un aumento del 0,454 % en el precio esperado.
| Término | Estimación | Error estándar | t | Valor p | IC 95 % inferior | IC 95 % superior |
|---|---|---|---|---|---|---|
| Intercepto | 2,930 | 0,105 | 28,029 | < 0.0000000000000002 | 2,725 | 3,136 |
| log(Área construida) | 0,454 | 0,024 | 19,293 | < 0.0000000000000002 | 0,408 | 0,500 |
| Estrato 4 | 0,253 | 0,031 | 8,100 | 0.0000000000000025 | 0,192 | 0,314 |
| Estrato 5 | 0,393 | 0,031 | 12,623 | < 0.0000000000000002 | 0,331 | 0,454 |
| Estrato 6 | 0,663 | 0,048 | 13,851 | < 0.0000000000000002 | 0,569 | 0,757 |
| Alcobas | 0,016 | 0,008 | 1,890 | 0.0591 | -0,001 | 0,032 |
| Parqueaderos | 0,011 | 0,008 | 1,529 | 0.1268 | -0,003 | 0,026 |
| Baños | 0,060 | 0,010 | 5,915 | 0.0000000052278501 | 0,040 | 0,080 |
# `perfil1` y `pred1` se definen en el preámbulo, porque el resumen ejecutivo
# los necesita antes de esta sección. La llamada es:
# pred1 <- predecir_perfil(mod1_log, perfil1, log_resp = TRUE)
tabla(pred1,
"Precio estimado para la Vivienda 1: casa de 200 m², 4 alcobas, 2 baños y 1 parqueadero en la Zona Norte (millones de pesos).",
digits = 1, align = c("c", rep("r", 6)))| Estrato | Mediana | IC mediana inf. | IC mediana sup. | Media (Duan) | IC media inf. | IC media sup. | IP inferior | IP superior |
|---|---|---|---|---|---|---|---|---|
| 4 | 324,7 | 308,1 | 342,1 | 336,7 | 319,6 | 354,8 | 192,3 | 548,3 |
| 5 | 373,4 | 356,1 | 391,4 | 387,2 | 369,3 | 405,9 | 221,2 | 630,2 |
ap1 <- apalancamiento_perfil(mod1_log, perfil1)
ap1 <- cbind(Estrato = c("4", "5"), ap1)
tabla(ap1,
"Apalancamiento del perfil solicitado: verificación de que la predicción interpola y no extrapola.",
digits = 4, align = c("c", "r", "r", "r"))| Estrato | Apalancamiento del perfil | Máximo muestral | Promedio muestral |
|---|---|---|---|
| 4 | 0,0101 | 0,0809 | 0,0115 |
| 5 | 0,0082 | 0,0809 | 0,0115 |
sop1 <- data.frame(
Verificación = c("Casas con área ≥ 200 m²",
"Casas con área entre 180 y 220 m²",
"Percentil del área solicitada en el segmento",
"Casas de estrato 4", "Casas de estrato 5"),
Valor = c(sum(base1$areaconst >= 200),
sum(base1$areaconst >= 180 & base1$areaconst <= 220),
round(100 * mean(base1$areaconst <= 200), 1),
sum(base1$estrato == 4), sum(base1$estrato == 5)))
tabla(sop1, "Soporte muestral en la vecindad del perfil solicitado.",
digits = 1, align = c("l", "r"))| Verificación | Valor |
|---|---|
| Casas con área ≥ 200 m² | 417,0 |
| Casas con área entre 180 y 220 m² | 64,0 |
| Percentil del área solicitada en el segmento | 42,1 |
| Casas de estrato 4 | 150,0 |
| Casas de estrato 5 | 264,0 |
Qué dice cada intervalo El intervalo de confianza cubre el precio medio de todas las casas de la Zona Norte con estas características. Responde a la pregunta ¿cuánto vale este tipo de inmueble en este mercado? y es lo pertinente para valorar el segmento y juzgar si el presupuesto alcanza.
El intervalo de predicción cubre el precio de una casa concreta con estas características. Responde a ¿en qué rango puede estar el precio del inmueble que me ofrezcan? y es lo pertinente para negociar. Es necesariamente más amplio, porque incorpora la variabilidad individual además de la incertidumbre sobre la media.
Confundirlos lleva a error en direcciones opuestas: usar el IC para negociar transmite una falsa precisión; usar el IP para valorar el segmento sugiere una incertidumbre mayor de la que realmente hay sobre el valor típico.
En estrato 4, el precio medio esperado es de 336,7 millones, con un intervalo de confianza al 95 % de [319,6; 354,8] millones. El crédito de 350 millones cubre el valor esperado, pero conviene ser exacto sobre el alcance de esa afirmación: el límite superior del intervalo, 354,8 millones, queda por encima del presupuesto. Los datos son compatibles con que el precio medio de este tipo de casa esté algo por encima de 350 millones, de modo que la holgura es real en el valor puntual pero no está garantizada al 95 %. Operativamente: el estrato 4 es viable, sin margen para darlo por descontado.
En estrato 5, el precio medio esperado sube a 387,2 millones, con intervalo [369,3; 405,9]. El intervalo completo queda por encima de los 350 millones: con este presupuesto, una casa de 200 m² de estrato 5 en la Zona Norte está fuera de alcance en términos medios.
El intervalo de predicción sí incluye valores por debajo del crédito en ambos estratos ([221,2; 630,2] en estrato 5), lo que significa que existen inmuebles individuales de estrato 5 asequibles dentro del presupuesto, aunque no sean los típicos. La cartera de la sección siguiente los identifica de forma concreta.
El apalancamiento del perfil (0,0101) está muy por debajo del máximo muestral (0,0809) y hay 64 casas con área entre 180 y 220 m² en el segmento: la predicción interpola dentro del rango de los datos y no depende de una extrapolación.
# `cart1` y `n_cand1 <- attr(cart1, "n_total")` provienen del preámbulo. El
# recuento total lo devuelve la propia función de selección: antes se
# recalculaba aquí repitiendo el filtro literalmente, y dos copias del mismo
# criterio son dos cosas que pueden dejar de coincidir sin avisar.
tabla_cartera(cart1,
"Inmuebles candidatos para la Vivienda 1, ordenados por descuento frente al valor modelado y proximidad al perfil solicitado.")| Barrio | Estrato | Precio | Valor modelado | Diferencia (%) | Área | Alcobas | Baños | Parq. |
|---|---|---|---|---|---|---|---|---|
| el bosque | 5 | 250 | 484,3 | -48,4 | 243 | 5 | 4 | 1 |
| la merced | 4 | 230 | 406,5 | -43,4 | 250 | 5 | 3 | 2 |
| los andes | 4 | 260 | 461,5 | -43,7 | 280 | 6 | 4 | 2 |
| vipasa | 5 | 350 | 496,6 | -29,5 | 346 | 4 | 2 | 1 |
| la merced | 5 | 330 | 488,1 | -32,4 | 275 | 5 | 3 | 2 |
| la merced | 5 | 321 | 519,9 | -38,3 | 249 | 5 | 5 | 1 |
| la flora | 5 | 320 | 470,4 | -32,0 | 230 | 4 | 4 | 2 |
| prados del norte | 5 | 330 | 485,0 | -32,0 | 246 | 4 | 4 | 2 |
Figure 3.8: Localización de los inmuebles candidatos para la Vivienda 1 sobre el conjunto de la oferta de casas de la Zona Norte.
37 inmuebles de la oferta vigente satisfacen simultáneamente el presupuesto de 350 millones, el estrato solicitado, un área de al menos 170 m² —el 85 % de los 200 m² pedidos— y los mínimos de baños, alcobas y parqueaderos. La disponibilidad es holgada y permite recomendar sin concesiones sobre la especificación.
La columna Diferencia (%) es el criterio de valor: mide cuánto se aparta el precio de oferta del que predicen los propios atributos del inmueble. Un valor negativo señala una oferta cuyo precio está por debajo de su valor modelado; no significa “barata” en términos absolutos, puesto que el modelo ya descuenta área, estrato, baños y demás características.
Las tres primeras candidatas presentan descuentos superiores al 43 % frente a su valor modelado. Un descuento de esa magnitud admite dos lecturas, y ninguna puede descartarse con los datos disponibles: puede tratarse de una oportunidad real —un vendedor con urgencia en un mercado contraído, exactamente el escenario que describe María— o de un inmueble con un defecto no registrado en la fuente (estado de conservación, antigüedad, problemas del entorno inmediato).
Por qué el descuento observado es una cota optimista La cartera se ordena escogiendo los residuos más negativos del mismo modelo con el que se calculó el valor de referencia. Seleccionar los extremos de una distribución de errores captura, además de la señal buscada, la parte del error que es simple azar: es el fenómeno clásico de regresión a la media, y su efecto es que el descuento medido sobrestima sistemáticamente el descuento real que se observaría al revalorar esos mismos inmuebles con datos nuevos. La ordenación sigue siendo útil como criterio de prioridad —señala dónde mirar primero—, pero las cifras de la columna Diferencia (%) deben leerse como cotas superiores del descuento, no como estimaciones insesgadas. Comprobarlo exigiría una partición en muestra de estimación y muestra de valoración, que con 696 registros y 37 candidatas dejaría un margen de error demasiado amplio para ser informativa.
La recomendación operativa es visitar las candidatas con mayor descuento y verificar en terreno; el modelo señala dónde mirar, no sustituye la inspección.
Esta sección replica íntegramente el procedimiento de la Sección 3 sobre el segmento de apartamentos de la Zona Sur. Se emplean las mismas funciones, con los mismos criterios y umbrales, de modo que cualquier diferencia en los resultados proviene de los datos y no de una decisión de análisis distinta. La exposición se concentra en lo que cambia.
d2 <- depurar_segmento(vivienda, "Apartamento", "Zona Sur")
base2 <- preparar_modelado(d2$base)
tabla(d2$bitacora,
"Bitácora de depuración del segmento de apartamentos de la Zona Sur.",
digits = 0, align = c("l", "r", "r"))| Etapa | Registros | Excluidos |
|---|---|---|
| Filtro por tipo y zona | 2.787 | — |
| Retiro de valores no positivos | 2.777 | 10 |
| Retiro de valores ausentes | 2.777 | 0 |
| Retiro de registros idénticos | 2.750 | 27 |
| Base analítica | 2.750 | — |
tabla(d2$desglose,
"Desglose por variable de los registros retirados (apartamentos, Zona Sur).",
digits = 0, align = c("l", "r", "r"))| Variable | Valores no positivos | Valores ausentes |
|---|---|---|
| Precio | 0 | 0 |
| Área construida | 0 | 0 |
| Baños | 6 | 0 |
| Habitaciones | 8 | 0 |
Se retiraron 10 registros con valores no positivos, 0 con valores ausentes en esas cuatro variables y 27 registros idénticos, con el mismo criterio y las mismas salvedades de la Sección 3.1.
prim2 <- d2$base[1:3, c("barrio", "estrato", "preciom", "areaconst",
"habitaciones", "banios", "parqueaderos", "tipo", "zona")]
names(prim2) <- c("Barrio", "Estrato", "Precio", "Área", "Alcobas", "Baños",
"Parq.", "Tipo", "Zona")
rownames(prim2) <- NULL
tabla(prim2,
"Primeros tres registros de la base depurada de apartamentos de la Zona Sur, con los valores tal como figuran en la fuente. Los guiones en la columna de parqueaderos son datos ausentes.",
digits = 0, align = c("l", rep("r", 6), "l", "l"))| Barrio | Estrato | Precio | Área | Alcobas | Baños | Parq. | Tipo | Zona |
|---|---|---|---|---|---|---|---|---|
| acopi | 4 | 290 | 96 | 3 | 2 | 1 | Apartamento | Zona Sur |
| aguablanca | 3 | 78 | 40 | 2 | 1 | 1 | Apartamento | Zona Sur |
| aguacatal | 6 | 875 | 194 | 3 | 5 | 2 | Apartamento | Zona Sur |
verif2 <- data.frame(
Verificación = c("Valores distintos en `tipo`", "Valores distintos en `zona`",
"Registros", "Rango de precio (millones)",
"Rango de área (m²)", "Estratos presentes"),
Resultado = c(paste(unique(base2$tipo), collapse = ", "),
paste(unique(base2$zona), collapse = ", "),
nrow(base2),
paste0(min(base2$preciom), " – ", max(base2$preciom)),
paste0(min(base2$areaconst), " – ", max(base2$areaconst)),
paste(sort(unique(base2$estrato)), collapse = ", ")))
tabla(verif2, "Comprobación del filtro aplicado al segmento de apartamentos del sur.",
align = c("l", "l"))| Verificación | Resultado |
|---|---|
Valores distintos en tipo
|
Apartamento |
Valores distintos en zona
|
Zona Sur |
| Registros | 2750 |
| Rango de precio (millones) | 75 – 1750 |
| Rango de área (m²) | 40 – 932 |
| Estratos presentes | 3, 4, 5, 6 |
de2 <- base2 |>
count(Estrato = estrato, name = "Registros") |>
mutate(`Porcentaje` = round(100 * Registros / sum(Registros), 1),
`Precio mediano` = sapply(Estrato, function(e)
median(base2$preciom[base2$estrato == e])))
tabla(de2, "Composición por estrato del segmento de apartamentos de la Zona Sur.",
digits = 1, align = c("c", "r", "r", "r"))| Estrato | Registros | Porcentaje | Precio mediano |
|---|---|---|---|
| 3 | 199 | 7,2 | 128,0 |
| 4 | 1.070 | 38,9 | 185,5 |
| 5 | 1.022 | 37,2 | 280,0 |
| 6 | 459 | 16,7 | 580,0 |
falt2 <- sum(is.na(d2$base$parqueaderos))
pct2 <- 100 * mean(is.na(d2$base$parqueaderos))
diag2 <- diagnostico_ausencia(d2$base, "parqueaderos")
tabla(diag2,
"Contraste del mecanismo de ausencia en `parqueaderos` (apartamentos, Zona Sur).",
digits = 3, align = c("l", rep("r", 5)))| Variable | Mediana sin dato | Mediana con dato | U | Valor p | r biserial |
|---|---|---|---|---|---|
| preciom | 150 | 260 | 146.371,500 | <0.0000000000000002 | -0,689 |
| areaconst | 60 | 90 | 176.327,000 | <0.0000000000000002 | -0,626 |
| banios | 2 | 2 | 275.461,500 | <0.0000000000000002 | -0,415 |
| habitaciones | 3 | 3 | 336.305,000 | <0.0000000000000002 | -0,286 |
| estrato | 4 | 5 | 220.295,000 | <0.0000000000000002 | -0,532 |
ae2 <- asociacion_ausencia_estrato(d2$base, "parqueaderos")
tabla(ae2$perfil,
"Proporción de registros sin dato de parqueadero, por estrato (apartamentos, Zona Sur).",
digits = 1, align = c("c", "r", "r"))| Estrato | Sin dato (%) | Registros |
|---|---|---|
| 3 | 46,7 | 199 |
| 4 | 23,7 | 1.070 |
| 5 | 4,1 | 1.022 |
| 6 | 2,6 | 459 |
tabla(ae2$resumen,
"Asociación entre el indicador de ausencia y el estrato (apartamentos, Zona Sur).",
digits = 3, align = rep("r", 4))| Ji-cuadrado | Grados de libertad | Valor p | V de Cramér |
|---|---|---|---|
| 379,946 | 3 | <0.0000000000000002 | 0,372 |
La ausencia en parqueaderos afecta al 14,6 % de los registros, menos
de la mitad que en el segmento de casas (\(\chi^2 = 379,95\),
V de Cramér 0,372). El diagnóstico es, sin embargo, más nítido: los tamaños del efecto son considerablemente mayores (r biserial
de rangos de -0,689 para el precio, frente a
-0,336 en las casas) y aquí el patrón por estrato
sí es monótono decreciente, de
46,7 % en estrato 3 a
2,6 % en estrato 6. El supuesto estructural adoptado en la Sección 3.1.1 —que el
campo vacío codifica la ausencia de garaje— es, por tanto, más verosímil en este
segmento que en el anterior, aunque sigue siendo un supuesto y no un resultado
contrastado. Se aplica el mismo tratamiento.
aud2 <- auditoria_geografica(vivienda, base2, "Zona Sur", "sur")
mapa_base2 <- transform(base2, fuera_zona = aud2$indicador_contradice)
leaflet(mapa_base2) |>
addProviderTiles(providers$CartoDB.Positron) |>
addCircleMarkers(lng = ~longitud, lat = ~latitud,
radius = 3, stroke = FALSE, fillOpacity = 0.55,
color = ~ifelse(fuera_zona, NARANJA, ACENTO),
popup = ~paste0("<b>", barrio, "</b><br>Estrato ", estrato,
"<br>", mm(preciom, 0), " millones")) |>
addLegend("bottomright", colors = c(ACENTO, NARANJA),
labels = c("Coherente con la Zona Sur",
"Al norte del eje divisorio"),
title = "Ubicación", opacity = 0.9)Figure 4.1: Localización de los apartamentos de la Zona Sur. En naranja, los declarados en la Zona Sur pero situados al norte del eje divisorio.
202 registros (7,3 %) aparecen al norte del eje divisorio. El diagnóstico confirma la misma causa dominante que en el segmento anterior, y aquí resulta aún más evidente: los barrios afectados con mayor frecuencia son valle del lili, pance y ciudad jardín, que están sin ambigüedad en el extremo sur de la ciudad. Su latitud mediana lo confirma —3,3450 para pance—, de modo que la etiqueta de zona es correcta y lo que está desplazado es la coordenada del anuncio. Solo 35 registros corresponden a barrios cuya zona mayoritaria es efectivamente otra.
grafico_correlaciones(base2,
"Asociación monótona entre precio y atributos · Apartamentos de la Zona Sur")Figure 4.2: Correlaciones de Spearman entre el precio y los atributos del inmueble (apartamentos, Zona Sur).
ct2 <- data.frame(
Variable = et,
`Spearman con el precio` = sapply(vs, function(v)
cor(base2$preciom, base2[[v]], method = "spearman", use = "complete.obs")),
`Pearson con el precio` = sapply(vs, function(v)
cor(base2$preciom, base2[[v]], use = "complete.obs")),
check.names = FALSE, row.names = NULL)
tabla(ct2, "Correlación entre el precio y cada atributo (apartamentos, Zona Sur).",
digits = 3, align = c("l", "r", "r"))| Variable | Spearman con el precio | Pearson con el precio |
|---|---|---|
| Área construida | 0,866 | 0,757 |
| Estrato | 0,752 | 0,673 |
| Baños | 0,707 | 0,734 |
| Alcobas | 0,392 | 0,343 |
| Parqueaderos | 0,713 | 0,682 |
Figure 4.3: Relación entre precio y área construida, por estrato (apartamentos, Zona Sur).
grafico_precio_categoria(base2, "estrato", "Estrato",
"El salto de precio se concentra en el paso al estrato 6")Figure 4.4: Distribución del precio por estrato (apartamentos, Zona Sur).
grafico_precio_categoria(base2, "habitaciones", "Número de alcobas",
"El precio no crece de forma monótona con el número de alcobas")Figure 4.5: Distribución del precio por número de alcobas (apartamentos, Zona Sur).
cg2 <- rbind(
contraste_grupos(base2, "estrato", "Estrato"),
contraste_grupos(base2, "banios", "Número de baños"),
contraste_grupos(base2, "habitaciones", "Número de alcobas"),
contraste_grupos(base2, "parqueaderos", "Número de parqueaderos"))
tabla(cg2,
"Contraste de Kruskal-Wallis por atributo (apartamentos, Zona Sur).",
digits = 4, align = c("l", rep("r", 5)))| Variable | Niveles | H de Kruskal-Wallis | Grados de libertad | Valor p | Eta cuadrado (H) |
|---|---|---|---|---|---|
| Estrato | 4 | 1.580,8192 | 3 | <0.0000000000000002 | 0,5746 |
| Número de baños | 8 | 1.387,9057 | 7 | <0.0000000000000002 | 0,5036 |
| Número de alcobas | 6 | 429,9602 | 5 | <0.0000000000000002 | 0,1549 |
| Número de parqueaderos | 6 | 1.426,1627 | 5 | <0.0000000000000002 | 0,5179 |
La asociación entre precio y área es sensiblemente más fuerte que en las casas (Spearman 0,866 frente a 0,820), lo que es esperable: el mercado de apartamentos es más homogéneo y el metro cuadrado opera como unidad de valoración más directa. La diferencia se reflejará en un ajuste notablemente mejor del modelo.
m0_2 <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
data = base2)
m1_2 <- lm(preciom ~ areaconst + estrato_f + habitaciones + parqueaderos + banios,
data = base2)
an2 <- anova(m0_2, m1_2)
tabla_anova(an2,
"Contraste anidado: estrato lineal frente a estrato como factor (apartamentos, Zona Sur).")| Res.Df | RSS | Df | Sum of Sq | F | Pr(>F) |
|---|---|---|---|---|---|
| 2.744 | 24.974.968,5679 | — | — | — | NA |
| 2.742 | 21.529.505,7584 | 2 | 3.445.462,8094 | 219,4072 | <0.0000000000000002 |
El contraste rechaza la linealidad en el estrato con una contundencia mucho mayor que en el segmento anterior (\(F = 219,4\) frente a 9,75). El estrato entra como factor.
mod2 <- m1_2
tabla_coeficientes(mod2,
"Modelo de regresión lineal múltiple para el precio de apartamentos de la Zona Sur.")| Término | Estimación | Error estándar | t | Valor p | IC 95 % inferior | IC 95 % superior |
|---|---|---|---|---|---|---|
| Intercepto | -1,262 | 10,271 | -0,123 | 0.902 | -21,401 | 18,876 |
| Área construida | 1,360 | 0,047 | 28,631 | < 0.0000000000000002 | 1,267 | 1,453 |
| Estrato 4 | 17,396 | 7,003 | 2,484 | 0.013 | 3,664 | 31,128 |
| Estrato 5 | 36,797 | 7,323 | 5,025 | 0.000000536 | 22,439 | 51,156 |
| Estrato 6 | 197,643 | 9,237 | 21,396 | < 0.0000000000000002 | 179,530 | 215,756 |
| Alcobas | -15,894 | 3,349 | -4,746 | 0.000002180 | -22,460 | -9,327 |
| Parqueaderos | 45,334 | 2,953 | 15,352 | < 0.0000000000000002 | 39,544 | 51,125 |
| Baños | 42,219 | 3,001 | 14,069 | < 0.0000000000000002 | 36,335 | 48,104 |
s2 <- summary(mod2)
aj2 <- data.frame(
Medida = c("R²", "R² ajustado", "Error estándar residual (millones)",
"Estadístico F", "Valor p del contraste global", "Observaciones"),
Valor = c(mm(s2$r.squared, 4), mm(s2$adj.r.squared, 4), mm(s2$sigma, 1),
mm(unname(s2$fstatistic[1]), 1),
pval(pf(s2$fstatistic[1], s2$fstatistic[2], s2$fstatistic[3],
lower.tail = FALSE)),
ent(nrow(base2))))
tabla(aj2, "Medidas de ajuste global del modelo (apartamentos, Zona Sur).",
digits = 4, align = c("l", "r"))| Medida | Valor |
|---|---|
| R² | 0,7865 |
| R² ajustado | 0,7859 |
| Error estándar residual (millones) | 88,6 |
| Estadístico F | 1.442,9 |
| Valor p del contraste global | <0.0000000000000002 |
| Observaciones | 2.750 |
Área construida (1,360, p < 0,001). Cada metro cuadrado adicional se asocia con 1,36 millones más, 1,74 veces el valor del metro cuadrado estimado para las casas del norte. La diferencia es coherente con el mercado: el suelo del sur de Cali es más caro y los apartamentos concentran más valor por unidad de superficie.
Estrato. El patrón es fuertemente convexo. Frente al estrato 3, el precio esperado sube 17,4 millones en estrato 4 y 36,8 en estrato 5, pero 197,6 millones en estrato 6. El salto del estrato 5 al 6 —160,8 millones— es 4,4 veces todo el ascenso acumulado del estrato 3 al 5 (36,8 millones). En el sur de Cali el estrato 6 no es un peldaño más de la escala, sino un submercado distinto.
Baños (42,22) y parqueaderos (45,33), ambos p < 0,001. A diferencia del segmento de casas, aquí ambos son claramente significativos. Cada parqueadero adicional vale unos 45 millones y cada baño unos 42 millones. La explicación es directa: en propiedad horizontal el parqueadero es un bien escaso con precio propio, mientras que en una casa unifamiliar suele ser un atributo dado.
Alcobas: -15,89, negativo y significativo (p < 0,001).
Un coeficiente negativo que sí tiene sentido económico Leído sin cuidado, el resultado diría que añadir una alcoba reduce el precio en 15,9 millones, lo que es absurdo. La lectura correcta incorpora la cláusula ceteris paribus: el coeficiente compara dos apartamentos de la misma área construida, mismo estrato, mismos baños y mismos parqueaderos, donde uno tiene una alcoba más que el otro. Ese apartamento tiene, necesariamente, alcobas más pequeñas.
Lo que el modelo captura es que el mercado penaliza la subdivisión excesiva: a igualdad de metros cuadrados, el comprador de apartamentos en el sur de Cali prefiere espacios amplios a más habitaciones. Es un resultado conocido en valoración inmobiliaria y, lejos de invalidar el modelo, confirma que los efectos parciales están bien identificados: los VIF descartan que se trate de un artefacto de colinealidad.
Tiene además una consecuencia directa sobre la solicitud: las cinco alcobas pedidas para la Vivienda 2 restan valor de mercado en lugar de añadirlo, y restringen severamente la oferta disponible, como muestra la Sección 4.6.
El modelo explica el 78,6 % de la variabilidad del precio, frente al 66,4 % del segmento de casas. La comparación debe tomarse con cautela: el \(R^2\) se mide contra la varianza del precio dentro de cada segmento, y dos segmentos con dispersiones distintas no son directamente comparables por esta vía. La cifra comparable es el error estándar residual, 88,6 millones aquí frente a 156,5 en las casas. Las vías de mejora son las mismas señaladas en la Sección 3.3, con una prioridad todavía más marcada para la incorporación de la localización fina: el diagnóstico espacial que sigue lo justifica.
tabla(dg2$pruebas,
"Contrastes formales sobre los supuestos del modelo (apartamentos, Zona Sur).",
digits = 4, align = c("l", "l", "r", "r", "l"))| Supuesto | Prueba | Estadístico | Valor p | Decisión |
|---|---|---|---|---|
| Homocedasticidad | Breusch-Pagan (estudentizado) | 875,2529 | < 0.0000000000000002 | Se rechaza H₀ |
| Homocedasticidad | White simplificado (LM de Koenker) | 792,9859 | < 0.0000000000000002 | Se rechaza H₀ |
| Normalidad | Anderson-Darling | 95,4432 | < 0.0000000000000002 | Se rechaza H₀ |
| Normalidad | Lilliefors | 0,1316 | < 0.0000000000000002 | Se rechaza H₀ |
| Normalidad | Jarque-Bera | 115.964,2029 | < 0.0000000000000002 | Se rechaza H₀ |
| Linealidad / forma funcional | RESET de Ramsey (potencia 2) | 62,8067 | 0.00000000000000329 | Se rechaza H₀ |
tabla(dg2$forma, "Medidas de forma de la distribución residual (apartamentos, Zona Sur).",
digits = 4, align = c("l", "r"))| Medida | Valor |
|---|---|
| Asimetría de los residuos | 1,3113 |
| Curtosis en exceso | 31,6791 |
| Máxima distancia de Cook | 6,1623 |
| Error estándar residual | 88,6101 |
op <- par(mfrow = c(2, 2), mar = c(4.2, 4.2, 2.6, 1.2), col.axis = "#4D4D4D")
plot(mod2, which = 1, col = adjustcolor(ACENTO, 0.3), pch = 16,
caption = "Residuos frente a valores ajustados")
plot(mod2, which = 2, col = adjustcolor(ACENTO, 0.3), pch = 16,
caption = "Gráfico cuantil-cuantil normal")
plot(mod2, which = 3, col = adjustcolor(ACENTO, 0.3), pch = 16,
caption = "Escala-localización")
plot(mod2, which = 5, col = adjustcolor(ACENTO, 0.3), pch = 16,
caption = "Residuos frente a apalancamiento")Figure 4.6: Diagnóstico gráfico del modelo en niveles (apartamentos, Zona Sur).
v2 <- tabla_vif(mod2,
"Factores de inflación de varianza (apartamentos, Zona Sur).")
tabla(dg2$influencia,
"Diagnóstico de observaciones influyentes (apartamentos, Zona Sur).",
digits = 3, align = c("l", "r", "r", "r"))| Criterio | Umbral | Casos señalados | % del total |
|---|---|---|---|
| Distancia de Cook > 4/n | 0,001 | 144 | 5,236 |
| Apalancamiento > 2p/n | 0,006 | 216 | 7,855 |
| |DFFITS| > 2√(p/n) | 0,108 | 144 | 5,236 |
| |Residuo estudentizado| > 3 | 3,000 | 29 | 1,055 |
tabla(dg2$espacial[, 1:5],
"I de Moran sobre los residuos (apartamentos, Zona Sur).",
digits = 4, align = rep("r", 5))| I | E[I] (Cliff-Ord) | z | Valor p | Observaciones |
|---|---|---|---|---|
| 0,1498 | -0,0010 | 17,3138 | <0.0000000000000002 | 2.750 |
El cuadro de incumplimientos es el mismo que en el segmento de casas, pero agravado en todos los frentes:
El mayor VIF equivalente es 2,73, de nuevo muy por debajo de cualquier umbral de alarma: la multicolinealidad sigue descartada, lo que respalda la interpretación del coeficiente negativo de las alcobas. Las sugerencias de corrección son las de la Sección 3.4, con el rechazo del RESET añadiendo un argumento adicional a favor de la transformación logarítmica. Conviene anticipar, no obstante, un resultado que el Anexo A5 documenta y que no puede pasarse por alto: en este segmento la transformación logarítmica mejora la forma residual pero no basta —el RESET sigue rechazando y la heterocedasticidad persiste—, de modo que el modelo seleccionado se adopta por su ventaja predictiva y por ser el menos malo de los disponibles, no porque satisfaga los supuestos.
cmp2 <- comparar_modelos(base2)
tabla(cmp2,
"Comparación de las cinco especificaciones candidatas (apartamentos, Zona Sur).",
digits = 3, align = c("l", "l", rep("r", 7)))| Modelo | Escala | Parámetros | R² | R² ajustado | AIC | BIC | RMSE VC | MAE VC |
|---|---|---|---|---|---|---|---|---|
| M0 · Niveles, estrato numérico | precio | 6 | 0,752 | 0,752 | 32.881,740 | 32.923,176 | 98,382 | 58,381 |
| M1 · Niveles, estrato factor | precio | 8 | 0,786 | 0,786 | 32.477,503 | 32.530,778 | 91,306 | 53,189 |
| M2 · Log-lineal, estrato factor | log(precio) | 8 | 0,815 | 0,815 | -501,893 | -448,619 | 122,193 | 53,805 |
| M3 · Log-log en área | log(precio) | 8 | 0,855 | 0,855 | -1.168,370 | -1.115,096 | 79,773 | 46,936 |
| M4 · Log-log con interacción área × estrato | log(precio) | 11 | 0,863 | 0,862 | -1.311,268 | -1.240,236 | 76,945 | 45,525 |
rmse_niveles2 <- cmp2$`RMSE VC`[cmp2$Modelo == "M1 · Niveles, estrato factor"]
rmse_inter2 <- cmp2$`RMSE VC`[
cmp2$Modelo == "M4 · Log-log con interacción área × estrato"]# Modelo sin interacción, solo para el contraste anidado; `mod2_log` (con
# interacción) y `dg2_log` proceden del preámbulo.
mod2_log3 <- lm(log(preciom) ~ log(areaconst) + estrato_f + habitaciones +
parqueaderos + banios, data = base2)
an_int <- anova(mod2_log3, mod2_log)
tabla_anova(an_int,
"Contraste de la interacción área × estrato (apartamentos, Zona Sur).")| Res.Df | RSS | Df | Sum of Sq | F | Pr(>F) |
|---|---|---|---|---|---|
| 2.742 | 104,5925 | — | — | — | NA |
| 2.739 | 99,0799 | 3 | 5,5125 | 50,7969 | <0.0000000000000002 |
El modelo log-log con interacción alcanza un RMSE de 76,9 millones frente a 91,3 del modelo en niveles: aquí la ventaja predictiva sí es sustancial, a diferencia del segmento de casas. La interacción entre área y estrato es además significativa (\(F = 50,80\), valor p <0.0000000000000002), lo que tiene una lectura sustantiva: la elasticidad del precio respecto al área difiere entre estratos. Un metro cuadrado adicional no vale lo mismo en un apartamento de estrato 4 que en uno de estrato 6.
tabla_coeficientes(mod2_log,
"Modelo logarítmico con interacción seleccionado (apartamentos, Zona Sur).")| Término | Estimación | Error estándar | t | Valor p | IC 95 % inferior | IC 95 % superior |
|---|---|---|---|---|---|---|
| Intercepto | 1,953 | 0,209 | 9,353 | < 0.0000000000000002 | 1,543 | 2,362 |
| log(Área construida) | 0,692 | 0,051 | 13,620 | < 0.0000000000000002 | 0,593 | 0,792 |
| Estrato 4 | -0,074 | 0,225 | -0,327 | 0.7437 | -0,515 | 0,368 |
| Estrato 5 | 1,334 | 0,221 | 6,041 | 0.00000000174 | 0,901 | 1,767 |
| Estrato 6 | 0,137 | 0,241 | 0,570 | 0.5688 | -0,335 | 0,609 |
| Alcobas | -0,036 | 0,007 | -4,860 | 0.00000124065 | -0,051 | -0,022 |
| Parqueaderos | 0,098 | 0,007 | 14,979 | < 0.0000000000000002 | 0,085 | 0,110 |
| Baños | 0,057 | 0,007 | 8,457 | < 0.0000000000000002 | 0,044 | 0,070 |
| log(Área) × Estrato 4 | 0,074 | 0,054 | 1,385 | 0.1661 | -0,031 | 0,179 |
| log(Área) × Estrato 5 | -0,213 | 0,052 | -4,083 | 0.00004564862 | -0,315 | -0,111 |
| log(Área) × Estrato 6 | 0,102 | 0,055 | 1,838 | 0.0662 | -0,007 | 0,210 |
cf <- coef(mod2_log)
elas <- data.frame(
Estrato = c("3 (referencia)", "4", "5", "6"),
`Elasticidad precio–área` = c(
cf["log(areaconst)"],
cf["log(areaconst)"] + cf["log(areaconst):estrato_f4"],
cf["log(areaconst)"] + cf["log(areaconst):estrato_f5"],
cf["log(areaconst)"] + cf["log(areaconst):estrato_f6"]),
check.names = FALSE)
tabla(elas,
"Elasticidad del precio respecto al área construida, por estrato: un aumento del 1 % en el área se asocia con este aumento porcentual del precio.",
digits = 3, align = c("c", "r"))| Estrato | Elasticidad precio–área |
|---|---|
| 3 (referencia) | 0,692 |
| 4 | 0,766 |
| 5 | 0,479 |
| 6 | 0,794 |
La elasticidad no es monótona en el estrato, y el detalle importa para esta solicitud: el estrato 5 —el recomendado— es precisamente el de menor elasticidad de los cuatro. Es decir, en ese estrato el precio responde al área con menos intensidad que en los demás, de modo que la extrapolación hacia áreas grandes está gobernada por el coeficiente más plano de la tabla justo donde el soporte muestral es más escaso. La estimación que sigue hereda esa doble fragilidad y por eso se acompaña de la advertencia explícita del final de la sección.
tabla(pred2,
"Precio estimado para la Vivienda 2: apartamento de 300 m², 5 alcobas, 3 baños y 3 parqueaderos en la Zona Sur (millones de pesos).",
digits = 1, align = c("c", rep("r", 6)))| Estrato | Mediana | IC mediana inf. | IC mediana sup. | Media (Duan) | IC media inf. | IC media sup. | IP inferior | IP superior |
|---|---|---|---|---|---|---|---|---|
| 5 | 546,1 | 519,9 | 573,8 | 555,9 | 529,1 | 584,0 | 374,9 | 795,6 |
| 6 | 993,0 | 939,3 | 1.049,8 | 1.010,7 | 956,1 | 1.068,5 | 681,1 | 1.447,8 |
sop2 <- data.frame(
Verificación = c("Apartamentos con área ≥ 300 m², estrato 5",
"Apartamentos con área ≥ 300 m², estrato 6",
"Percentil del área solicitada dentro del estrato 5",
"Percentil del área solicitada dentro del estrato 6",
"Apartamentos con 5 alcobas o más (todo el segmento)"),
Valor = c(sum(base2$areaconst >= 300 & base2$estrato == 5),
sum(base2$areaconst >= 300 & base2$estrato == 6),
round(100 * mean(base2$areaconst[base2$estrato == 5] <= 300), 1),
round(100 * mean(base2$areaconst[base2$estrato == 6] <= 300), 1),
sum(base2$habitaciones >= 5)))
tabla(sop2, "Soporte muestral en la vecindad del perfil solicitado (apartamentos, Zona Sur).",
digits = 1, align = c("l", "r"))| Verificación | Valor |
|---|---|
| Apartamentos con área ≥ 300 m², estrato 5 | 8,0 |
| Apartamentos con área ≥ 300 m², estrato 6 | 19,0 |
| Percentil del área solicitada dentro del estrato 5 | 99,3 |
| Percentil del área solicitada dentro del estrato 6 | 95,9 |
| Apartamentos con 5 alcobas o más (todo el segmento) | 28,0 |
ap2 <- apalancamiento_perfil(mod2_log, perfil2)
ap2 <- cbind(Estrato = c("5", "6"), ap2)
tabla(ap2, "Apalancamiento del perfil solicitado (apartamentos, Zona Sur).",
digits = 4, align = c("c", "r", "r", "r"))| Estrato | Apalancamiento del perfil | Máximo muestral | Promedio muestral |
|---|---|---|---|
| 5 | 0,0175 | 0,1628 | 0,0040 |
| 6 | 0,0222 | 0,1628 | 0,0040 |
En estrato 5, el precio medio esperado es de 555,9 millones, con intervalo de confianza [529,1; 584,0]. El crédito de 850 millones cubre holgadamente el valor esperado; el margen disponible supera los 294 millones.
En estrato 6, el precio medio esperado asciende a 1.010,7 millones, con intervalo [956,1; 1.068,5]. El intervalo completo queda por encima del crédito preaprobado: con 850 millones no es realista aspirar a un apartamento de 300 m² de estrato 6 en el sur de Cali.
Advertencia sobre el alcance de esta estimación El perfil solicitado se sitúa en el percentil 99,3 del área dentro del estrato 5: solo 8 apartamentos de ese estrato en toda la base superan los 300 m². La predicción no llega a ser una extrapolación —el apalancamiento del perfil, 0,0175, se mantiene por debajo del máximo muestral 0,1628—, pero se apoya en una región de los datos escasamente poblada.
La consecuencia práctica es que la estimación para el estrato 5 debe manejarse con más cautela que la de la Vivienda 1, y que el intervalo de predicción [374,9; 795,6] —no la estimación puntual— es la referencia adecuada para una negociación concreta.
El filtro que en la Vivienda 1 produjo 37 candidatas aquí devuelve 3. No es un problema del método: es un hallazgo sobre el mercado. En todo el segmento de apartamentos de la Zona Sur solo hay 28 inmuebles con cinco alcobas o más, y solo 48 superan los 255 m². La combinación solicitada —300 m², cinco alcobas, tres baños, tres parqueaderos— es marginal en la oferta disponible.
Para responder a la solicitud con al menos cinco alternativas es necesario relajar criterios. Hacerlo de forma tácita ocultaría información esencial para la decisión del cliente, de modo que la cartera se presenta escalonada, con la concesión de cada nivel declarada explícitamente.
cart2b <- construir_cartera(base2, mod2_log, perfil2[1, ], credito = 850,
estratos = c("5", "6"), tol_area = 0.85,
exigir_atributos = FALSE, n_max = 40)
# Nivel B: se libera el requisito de alcobas, se conservan baños y parqueaderos.
nivel_b <- cart2b[cart2b$banios >= 3 & cart2b$parqueaderos >= 3 &
cart2b$habitaciones < 5, ]
# Nivel C: se libera además el área, con tolerancia del 70 %.
cart2c <- construir_cartera(base2, mod2_log, perfil2[1, ], credito = 850,
estratos = c("5", "6"), tol_area = 0.70,
exigir_atributos = FALSE, n_max = 60)
nivel_c <- cart2c[cart2c$areaconst < 0.85 * 300 & cart2c$banios >= 3 &
cart2c$parqueaderos >= 2, ]
niveles <- data.frame(
Nivel = c("A", "B", "C"),
Concesión = c("Ninguna: cumple toda la especificación",
"Menos de 5 alcobas; se conservan área, baños y parqueaderos",
"Área entre 210 y 255 m²; al menos 3 baños y 2 parqueaderos"),
`Inmuebles disponibles` = c(n_a, nrow(nivel_b), nrow(nivel_c)),
check.names = FALSE)
tabla(niveles,
"Disponibilidad de oferta según el nivel de concesión aceptado (Vivienda 2).",
digits = 0, align = c("c", "l", "r"))| Nivel | Concesión | Inmuebles disponibles |
|---|---|---|
| A | Ninguna: cumple toda la especificación | 3 |
| B | Menos de 5 alcobas; se conservan área, baños y parqueaderos | 4 |
| C | Área entre 210 y 255 m²; al menos 3 baños y 2 parqueaderos | 18 |
partes <- list(transform(cart2a, Nivel = "A"),
transform(head(nivel_b, 4), Nivel = "B"),
transform(head(nivel_c, 3), Nivel = "C"))
stopifnot(length(unique(lapply(partes, names))) == 1) # mismas columnas
cartera2 <- do.call(rbind, partes)
x2 <- cartera2[, c("Nivel", "barrio", "estrato", "preciom", "valor_modelado",
"infravaloracion", "areaconst", "habitaciones", "banios",
"parqueaderos")]
names(x2) <- c("Nivel", "Barrio", "Estrato", "Precio", "Valor modelado",
"Diferencia (%)", "Área", "Alcobas", "Baños", "Parq.")
rownames(x2) <- NULL
tabla(x2,
"Cartera escalonada de inmuebles candidatos para la Vivienda 2. El nivel indica la concesión aceptada respecto a la especificación original.",
digits = 1, align = c("c", "l", rep("r", 8)))| Nivel | Barrio | Estrato | Precio | Valor modelado | Diferencia (%) | Área | Alcobas | Baños | Parq. |
|---|---|---|---|---|---|---|---|---|---|
| A | seminario | 5 | 530 | 577,5 | -8,2 | 256 | 5 | 5 | 3 |
| A | seminario | 5 | 670 | 601,0 | 11,5 | 300 | 6 | 5 | 3 |
| A | guadalupe | 5 | 730 | 1.008,2 | -27,6 | 573 | 5 | 8 | 3 |
| B | capri | 5 | 350 | 548,0 | -36,1 | 270 | 4 | 3 | 3 |
| B | capri | 5 | 350 | 557,9 | -37,3 | 260 | 3 | 3 | 3 |
| B | pance | 6 | 850 | 1.359,7 | -37,5 | 352 | 3 | 3 | 4 |
| B | pampa linda | 5 | 450 | 565,1 | -20,4 | 267 | 3 | 3 | 3 |
| C | pance | 6 | 600 | 862,8 | -30,5 | 230 | 4 | 5 | 2 |
| C | el ingenio | 6 | 700 | 839,9 | -16,7 | 250 | 5 | 4 | 2 |
| C | santa anita | 5 | 350 | 556,7 | -37,1 | 250 | 3 | 5 | 2 |
Figure 4.7: Localización de los inmuebles candidatos para la Vivienda 2 sobre el conjunto de la oferta de apartamentos de la Zona Sur.
La cartera reúne 10 inmuebles, todos dentro del presupuesto de 850 millones. La lectura para el cliente es directa:
Recomendación: el nivel B es la vía preferible. Satisface la necesidad de espacio de una familia, evita pagar por una subdivisión interior que el mercado penaliza, y multiplica el número de alternativas disponibles para negociar.
sintesis <- data.frame(
Aspecto = c("Precio esperado (estrato bajo del rango)",
"Precio esperado (estrato alto del rango)",
"Crédito preaprobado",
"¿Alcanza en el estrato bajo?",
"¿Alcanza en el estrato alto?",
"Inmuebles que cumplen la especificación",
"Estrato recomendado"),
`Vivienda 1 (casa, norte)` = c(
paste0(mm(pred1[1, "Media (Duan)"], 1), " (estrato 4)"),
paste0(mm(pred1[2, "Media (Duan)"], 1), " (estrato 5)"),
"350", "Sí, con margen estrecho", "No", as.character(n_cand1), "4"),
`Vivienda 2 (apto, sur)` = c(
paste0(mm(pred2[1, "Media (Duan)"], 1), " (estrato 5)"),
paste0(mm(pred2[2, "Media (Duan)"], 1), " (estrato 6)"),
"850", "Sí, con holgura", "No", as.character(n_a), "5"),
check.names = FALSE)
tabla(sintesis, "Síntesis de las dos solicitudes (millones de pesos).",
align = c("l", "l", "l"))| Aspecto | Vivienda 1 (casa, norte) | Vivienda 2 (apto, sur) |
|---|---|---|
| Precio esperado (estrato bajo del rango) | 336,7 (estrato 4) | 555,9 (estrato 5) |
| Precio esperado (estrato alto del rango) | 387,2 (estrato 5) | 1.010,7 (estrato 6) |
| Crédito preaprobado | 350 | 850 |
| ¿Alcanza en el estrato bajo? | Sí, con margen estrecho | Sí, con holgura |
| ¿Alcanza en el estrato alto? | No | No |
| Inmuebles que cumplen la especificación | 37 | 3 |
| Estrato recomendado | 4 | 5 |
1. Concentrar la Vivienda 1 en el estrato 4. El presupuesto de 350 millones cubre el valor esperado de una casa de 200 m² en estrato 4 (336,7 millones) pero no el de estrato 5 (387,2 millones), cuyo intervalo de confianza queda íntegramente por encima del crédito. Existen 37 inmuebles que cumplen todas las condiciones, de modo que la restricción no obliga a ninguna concesión sobre la especificación.
2. Concentrar la Vivienda 2 en el estrato 5 y flexibilizar el número de alcobas. El estrato 6 exige 1.011 millones en promedio, 161 millones por encima del crédito. En estrato 5, en cambio, queda margen presupuestal. La exigencia de cinco alcobas es la restricción más costosa de toda la solicitud: reduce la oferta viable a 3 inmuebles y, según el modelo, reduce el valor de mercado del inmueble a igualdad de superficie. Renunciar a ella amplía las opciones sin sacrificar espacio.
3. Advertir al cliente sobre la asimetría entre las dos solicitudes. La Vivienda 1 opera en un mercado con oferta abundante y capacidad de negociación; la Vivienda 2, en un segmento marginal donde la escasez reduce el margen. Los plazos y expectativas de cada búsqueda deben fijarse en consecuencia.
4. Usar el modelo para detectar oportunidades, no para tasar. Con errores estándar residuales de 156 y 89 millones, el modelo no puede fijar el precio de un inmueble individual. Sí identifica de forma fiable qué ofertas se apartan de su valor esperado, que es exactamente lo que C&A necesita para priorizar visitas en un mercado contraído.
5. Verificar en terreno las mayores diferencias. Los inmuebles con descuentos superiores al 30 % frente a su valor modelado son los candidatos prioritarios, pero un descuento de esa magnitud puede reflejar tanto urgencia del vendedor como un defecto no registrado en la fuente. La inspección es indispensable.
El precio observado es precio de oferta, no de transacción. La variable
preciom recoge el valor pedido en el anuncio. En el mercado contraído que
describe el caso, el precio de cierre suele situarse por debajo, de modo que las
estimaciones de este informe deben leerse como cotas superiores del valor de
negociación.
El tratamiento de parqueaderos descansa en un supuesto no contrastable. En
el segmento de casas, el 38,2 % de los valores es una recodificación y
no una medición. Los datos permiten descartar que la ausencia sea completamente
aleatoria, pero no permiten decidir si depende del valor no observado de la propia
variable. El Anexo A3 acota el efecto de la decisión, pero no elimina el
supuesto.
Faltan determinantes conocidos del precio. Antigüedad, estado de conservación, calidad de acabados, zonas comunes y estado jurídico del inmueble son variables que la fuente no registra y que explican parte de la variabilidad residual.
Los residuos presentan autocorrelación espacial en ambos segmentos. El modelo no incorpora la localización más allá de la zona y el estrato. Esto no sesga los coeficientes, pero implica que existe información predictiva disponible que no se está utilizando, y que los errores estándar podrían estar subestimados.
Las coordenadas son fiables a escala de barrio, no de manzana. La dispersión intra-barrio documentada en las Secciones 3.1.2 y 4.1 es de orden kilométrico. Permiten detectar dependencia espacial agregada, como hace el contraste de Moran, pero no construir predictores de ubicación exacta. Los mapas de este informe son orientativos.
La estimación de la Vivienda 2 se apoya en una región poco poblada de los datos. Solo 8 apartamentos de estrato 5 superan los 300 m². La predicción interpola, pero con soporte muestral limitado.
El corte temporal es de tres meses. Los resultados describen la oferta de ese periodo y no incorporan tendencia ni estacionalidad.
El área construida y el estrato explican la mayor parte del precio en ambos segmentos: 66,4 % de la variabilidad en las casas de la Zona Norte y 78,6 % en los apartamentos de la Zona Sur. El efecto del estrato es marcadamente convexo y su tratamiento como variable numérica queda formalmente rechazado en ambos casos.
El número de alcobas no aporta valor una vez controlada el área. En apartamentos su efecto parcial es negativo y significativo, lo que refleja la penalización de mercado a la subdivisión interior excesiva.
Los modelos en niveles violan la homocedasticidad y la normalidad en ambos segmentos, y la linealidad en el de apartamentos. La especificación logarítmica es la que sustenta las predicciones del informe, pero su balance difiere por segmento: en casas corrige los incumplimientos sin pérdida de capacidad predictiva; en apartamentos mejora sustancialmente la predicción y la forma residual, aunque el rechazo de la linealidad y de la homocedasticidad persiste (Anexo A5), por lo que la inferencia de ese segmento se apoya en errores estándar robustos.
Los residuos exhiben autocorrelación espacial estadísticamente significativa en ambos segmentos, pero de magnitud muy distinta: el I de Moran es 0,040 en las casas —dependencia débil, significativa por el tamaño muestral más que por su importancia— y 0,150 en los apartamentos. Solo en este segundo caso la incorporación de la localización fina se perfila como la vía de mejora de mayor rendimiento esperado.
El crédito de 350 millones alcanza para la Vivienda 1 en estrato 4 y no en estrato 5. El de 850 millones alcanza para la Vivienda 2 en estrato 5 y no en estrato 6.
La disponibilidad real de oferta difiere radicalmente entre ambas solicitudes: 37 inmuebles cumplen la especificación de la Vivienda 1 frente a 3 en la Vivienda 2. Esta asimetría, y no el precio, es la restricción determinante de la segunda solicitud.
Belsley, D. A., Kuh, E., & Welsch, R. E. (1980). Regression Diagnostics: Identifying Influential Data and Sources of Collinearity. Wiley.
Breusch, T. S., & Pagan, A. R. (1979). A simple test for heteroscedasticity and random coefficient variation. Econometrica, 47(5), 1287–1294.
Cliff, A. D., & Ord, J. K. (1981). Spatial Processes: Models and Applications. Pion.
Cohen, J. (1988). Statistical Power Analysis for the Behavioral Sciences (2.ª ed.). Lawrence Erlbaum.
Cook, R. D., & Weisberg, S. (1982). Residuals and Influence in Regression. Chapman & Hall.
Duan, N. (1983). Smearing estimate: A nonparametric retransformation method. Journal of the American Statistical Association, 78(383), 605–610.
Hastie, T., Tibshirani, R., & Friedman, J. (2009). The Elements of Statistical Learning (2.ª ed.). Springer. [Capítulo 7: selección de modelos y validación cruzada].
Kutner, M. H., Nachtsheim, C. J., Neter, J., & Li, W. (2005). Applied Linear Statistical Models (5.ª ed.). McGraw-Hill.
Lay, D. C. (2012). Álgebra lineal y sus aplicaciones (4.ª ed.). Pearson. [Capítulo 6: §6.5 problema de mínimos cuadrados; §6.6 modelos lineales].
Little, R. J. A., & Rubin, D. B. (2019). Statistical Analysis with Missing Data (3.ª ed.). Wiley. [§1.3, taxonomía de los mecanismos de ausencia; §6.1, sobre su indecidibilidad empírica].
Long, J. S., & Ervin, L. H. (2000). Using heteroscedasticity consistent standard errors in the linear regression model. The American Statistician, 54(3), 217–224.
MacKinnon, J. G., & White, H. (1985). Some heteroskedasticity-consistent covariance matrix estimators with improved finite sample properties. Journal of Econometrics, 29(3), 305–325.
Montgomery, D. C., Peck, E. A., & Vining, G. G. (2012). Introduction to Linear Regression Analysis (5.ª ed.). Wiley.
Moran, P. A. P. (1950). Notes on continuous stochastic phenomena. Biometrika, 37(1/2), 17–23.
Ramsey, J. B. (1969). Tests for specification errors in classical linear least-squares regression analysis. Journal of the Royal Statistical Society: Series B, 31(2), 350–371.
Rubin, D. B. (1976). Inference and missing data. Biometrika, 63(3), 581–592.
Tomczak, M., & Tomczak, E. (2014). The need to report effect size estimates revisited. Trends in Sport Sciences, 1(21), 19–25.
Weiers, R. M. (2006). Introducción a la estadística para negocios (5.ª ed.). Thomson. [Caso adaptado del enunciado de la actividad].
Wooldridge, J. M. (2019). Introductory Econometrics: A Modern Approach (7.ª ed.). Cengage. [Capítulo 6: forma funcional logarítmica e interpretación de elasticidades].
# Paquete de datos del curso (una sola vez)
install.packages("devtools")
devtools::install_github("centromagis/paqueteMODELOS", force = TRUE)
# Paquetes de análisis
install.packages(c("dplyr", "tidyr", "ggplot2", "plotly", "knitr", "kableExtra",
"leaflet", "lmtest", "sandwich", "car", "nortest", "tseries",
"e1071", "scales", "RColorBrewer", "htmltools", "tibble",
"bookdown"))El enunciado incluye la zona entre las variables del análisis exploratorio. Dentro de cada base filtrada la zona es constante y su coeficiente no es estimable, de modo que su efecto solo puede examinarse antes del filtro. Este anexo lo hace.
vz <- vivienda[!is.na(vivienda$zona) & !is.na(vivienda$preciom) &
vivienda$preciom > 0, ]
tz <- vz |>
group_by(Zona = zona) |>
summarise(Registros = n(),
`Precio mediano` = median(preciom),
`Precio medio` = mean(preciom),
`Área mediana` = median(areaconst, na.rm = TRUE),
.groups = "drop") |>
arrange(desc(`Precio mediano`))
tabla(tz, "Precio y área por zona sobre el conjunto completo, antes de filtrar.",
digits = 1, align = c("l", rep("r", 4)))| Zona | Registros | Precio mediano | Precio medio | Área mediana |
|---|---|---|---|---|
| Zona Oeste | 1.198 | 580 | 677,6 | 165,5 |
| Zona Sur | 4.726 | 320 | 426,5 | 113,0 |
| Zona Norte | 1.920 | 300 | 345,6 | 107,0 |
| Zona Centro | 124 | 297 | 309,7 | 160,0 |
| Zona Oriente | 351 | 210 | 228,5 | 160,0 |
kw_z <- kruskal.test(preciom ~ factor(zona), data = vz)
k <- length(unique(vz$zona)); n <- nrow(vz)
e2_z <- (unname(kw_z$statistic) - k + 1) / (n - k)
tabla(data.frame(`H de Kruskal-Wallis` = unname(kw_z$statistic),
`Grados de libertad` = unname(kw_z$parameter),
`Valor p` = pval(kw_z$p.value),
`Eta cuadrado (H)` = e2_z, check.names = FALSE),
"Contraste global de diferencias de precio entre zonas.", digits = 4)| H de Kruskal-Wallis | Grados de libertad | Valor p | Eta cuadrado (H) |
|---|---|---|---|
| 1.063,5836 | 4 | <0.0000000000000002 | 0,1274 |
ph <- pairwise.wilcox.test(vz$preciom, vz$zona, p.adjust.method = "holm")
ph_fmt <- as.data.frame(apply(ph$p.value, 2, function(col)
ifelse(is.na(col), NA_character_, pval(col))),
stringsAsFactors = FALSE)
tabla(ph_fmt,
"Comparaciones múltiples por pares entre zonas (valores p ajustados por Holm).")| Zona Centro | Zona Norte | Zona Oeste | Zona Oriente | |
|---|---|---|---|---|
| Zona Norte | 0.6674 | — | — | — |
| Zona Oeste | < 0.0000000000000002 | <0.0000000000000002 | — | — |
| Zona Oriente | 0.0000000275 | <0.0000000000000002 | <0.0000000000000002 | — |
| Zona Sur | 0.0021 | <0.0000000000000002 | <0.0000000000000002 | <0.0000000000000002 |
La zona discrimina el precio de forma significativa (valor p <0.0000000000000002), con un tamaño del efecto \(\eta^2_H = 0,1274\). Este resultado justifica el filtro del enunciado: analizar zonas distintas en un mismo modelo mezclaría submercados con estructuras de precio diferentes. Una vez filtrado, la variable deja de aportar variabilidad y queda necesariamente fuera del modelo.
parqueaderosLa recodificación de la ausencia como cero es una decisión del analista. Este anexo cuantifica su efecto comparándola con las dos alternativas disponibles: análisis de casos completos —que descarta los registros sin dato— y modelo con indicador de ausencia, que estima un efecto propio para el grupo sin información.
sensibilidad <- function(base, etiqueta) {
m_rec <- lm(log(preciom) ~ log(areaconst) + estrato_f + habitaciones +
parqueaderos + banios, data = base)
cc <- base[base$sin_parqueadero == 0, ]
m_cc <- lm(log(preciom) ~ log(areaconst) + estrato_f + habitaciones +
parqueaderos + banios, data = cc)
m_ind <- lm(log(preciom) ~ log(areaconst) + estrato_f + habitaciones +
parqueaderos + banios + sin_parqueadero, data = base)
data.frame(
Segmento = etiqueta,
Tratamiento = c("Recodificación a cero (adoptado)",
"Casos completos", "Indicador de ausencia"),
n = c(nrow(base), nrow(cc), nrow(base)),
`Elasticidad del área` = c(coef(m_rec)["log(areaconst)"],
coef(m_cc)["log(areaconst)"],
coef(m_ind)["log(areaconst)"]),
`Coef. estrato 5` = c(coef(m_rec)["estrato_f5"], coef(m_cc)["estrato_f5"],
coef(m_ind)["estrato_f5"]),
`Coef. baños` = c(coef(m_rec)["banios"], coef(m_cc)["banios"],
coef(m_ind)["banios"]),
`R² ajustado` = c(summary(m_rec)$adj.r.squared,
summary(m_cc)$adj.r.squared,
summary(m_ind)$adj.r.squared),
check.names = FALSE, row.names = NULL)
}
sens <- rbind(sensibilidad(base1, "Casas, Zona Norte"),
sensibilidad(base2, "Apartamentos, Zona Sur"))
tabla(sens,
"Sensibilidad de los coeficientes al tratamiento de la ausencia en `parqueaderos`.",
digits = 4, align = c("l", "l", "r", "r", "r", "r", "r"))| Segmento | Tratamiento | n | Elasticidad del área | Coef. estrato 5 | Coef. baños | R² ajustado |
|---|---|---|---|---|---|---|
| Casas, Zona Norte | Recodificación a cero (adoptado) | 696 | 0,4541 | 0,3925 | 0,0598 | 0,7820 |
| Casas, Zona Norte | Casos completos | 430 | 0,4082 | 0,3933 | 0,0391 | 0,7240 |
| Casas, Zona Norte | Indicador de ausencia | 696 | 0,4547 | 0,4078 | 0,0566 | 0,7831 |
| Apartamentos, Zona Sur | Recodificación a cero (adoptado) | 2.750 | 0,6405 | 0,3801 | 0,0584 | 0,8546 |
| Apartamentos, Zona Sur | Casos completos | 2.349 | 0,6362 | 0,3622 | 0,0624 | 0,8363 |
| Apartamentos, Zona Sur | Indicador de ausencia | 2.750 | 0,6427 | 0,3747 | 0,0597 | 0,8546 |
Los coeficientes de las variables que sustentan las conclusiones del informe —la
elasticidad del área y el efecto del estrato— se mantienen estables entre los tres
tratamientos. La decisión sobre parqueaderos no gobierna los resultados. El
análisis de casos completos, sin embargo, opera sobre una submuestra sesgada hacia
inmuebles de gama alta, lo que confirma la inconveniencia de esa vía pese a la
estabilidad aparente.
robusto <- function(modelo, etiqueta) {
ct <- coeftest(modelo, vcov. = vcovHC(modelo, type = "HC3"))
d <- data.frame(Segmento = etiqueta,
Término = rownames(ct),
`Estimación` = ct[, 1],
`EE clásico` = summary(modelo)$coefficients[, 2],
`EE robusto HC3` = ct[, 2],
`Valor p robusto` = pval(ct[, 4]),
check.names = FALSE, row.names = NULL)
d
}
tabla(robusto(mod1, "Casas, Zona Norte"),
"Errores estándar robustos a heterocedasticidad (HC3) — modelo en niveles, casas.",
digits = 4, align = c("l", "l", rep("r", 4)))| Segmento | Término | Estimación | EE clásico | EE robusto HC3 | Valor p robusto |
|---|---|---|---|---|---|
| Casas, Zona Norte | (Intercept) | 17,1222 | 19,0816 | 26,8666 | 0.524138 |
| Casas, Zona Norte | areaconst | 0,7814 | 0,0463 | 0,1298 | 0.00000000287609 |
| Casas, Zona Norte | estrato_f4 | 73,4816 | 17,8999 | 19,3640 | 0.000161 |
| Casas, Zona Norte | estrato_f5 | 137,0058 | 17,3803 | 20,6488 | 0.00000000006574 |
| Casas, Zona Norte | estrato_f6 | 324,7419 | 27,4615 | 42,9602 | 0.00000000000013 |
| Casas, Zona Norte | habitaciones | 4,2382 | 4,8313 | 6,6751 | 0.525685 |
| Casas, Zona Norte | parqueaderos | 3,3052 | 4,4174 | 5,7753 | 0.567309 |
| Casas, Zona Norte | banios | 29,0309 | 5,9368 | 9,6970 | 0.002854 |
tabla(robusto(mod2, "Apartamentos, Zona Sur"),
"Errores estándar robustos a heterocedasticidad (HC3) — modelo en niveles, apartamentos.",
digits = 4, align = c("l", "l", rep("r", 4)))| Segmento | Término | Estimación | EE clásico | EE robusto HC3 | Valor p robusto |
|---|---|---|---|---|---|
| Apartamentos, Zona Sur | (Intercept) | -1,2624 | 10,2706 | 11,9419 | 0.91582 |
| Apartamentos, Zona Sur | areaconst | 1,3598 | 0,0475 | 0,3765 | 0.00031 |
| Apartamentos, Zona Sur | estrato_f4 | 17,3960 | 7,0031 | 5,5355 | 0.00169 |
| Apartamentos, Zona Sur | estrato_f5 | 36,7972 | 7,3227 | 6,5546 | 0.0000000218 |
| Apartamentos, Zona Sur | estrato_f6 | 197,6428 | 9,2374 | 12,0249 | < 0.0000000000000002 |
| Apartamentos, Zona Sur | habitaciones | -15,8940 | 3,3488 | 6,3687 | 0.01263 |
| Apartamentos, Zona Sur | parqueaderos | 45,3342 | 2,9530 | 7,9119 | 0.0000000111 |
| Apartamentos, Zona Sur | banios | 42,2192 | 3,0010 | 8,9804 | 0.0000027134 |
Se emplea el estimador HC3 y no HC0 siguiendo la recomendación de MacKinnon y White (1985) y Long y Ervin (2000): HC3 ofrece mejor desempeño en muestras finitas y es el más conservador de la familia. Las conclusiones sobre significación no cambian en ninguno de los dos segmentos: las variables significativas con errores clásicos lo siguen siendo con errores robustos, y las no significativas tampoco lo son. La heterocedasticidad afecta a la magnitud de los errores estándar, no al sentido de las conclusiones.
estabilidad <- function(modelo, base, etiqueta) {
ck <- cooks.distance(modelo)
h <- hatvalues(modelo)
n <- nrow(base); p <- length(coef(modelo))
marca <- ck > 4/n | h > 2*p/n | abs(rstudent(modelo)) > 3
m2 <- update(modelo, data = base[!marca, ])
b1 <- coef(modelo); b2 <- coef(m2)
data.frame(Segmento = etiqueta,
Término = names(b1),
`Modelo completo` = b1,
`Sin influyentes` = b2[names(b1)],
`Cambio relativo (%)` = 100 * (b2[names(b1)] - b1) / abs(b1),
check.names = FALSE, row.names = NULL)
}
tabla(estabilidad(mod1_log, base1, "Casas, Zona Norte"),
"Estabilidad de los coeficientes al excluir las observaciones señaladas (modelo logarítmico, casas).",
digits = 4, align = c("l", "l", "r", "r", "r"))| Segmento | Término | Modelo completo | Sin influyentes | Cambio relativo (%) |
|---|---|---|---|---|
| Casas, Zona Norte | (Intercept) | 2,9305 | 2,9323 | 0,0607 |
| Casas, Zona Norte | log(areaconst) | 0,4541 | 0,4453 | -1,9430 |
| Casas, Zona Norte | estrato_f4 | 0,2528 | 0,2568 | 1,5827 |
| Casas, Zona Norte | estrato_f5 | 0,3925 | 0,3926 | 0,0178 |
| Casas, Zona Norte | estrato_f6 | 0,6627 | 0,7025 | 6,0028 |
| Casas, Zona Norte | habitaciones | 0,0156 | 0,0167 | 7,1673 |
| Casas, Zona Norte | parqueaderos | 0,0115 | 0,0135 | 17,7182 |
| Casas, Zona Norte | banios | 0,0598 | 0,0642 | 7,3131 |
tabla(estabilidad(mod2_log, base2, "Apartamentos, Zona Sur"),
"Estabilidad de los coeficientes al excluir las observaciones señaladas (modelo logarítmico, apartamentos).",
digits = 4, align = c("l", "l", "r", "r", "r"))| Segmento | Término | Modelo completo | Sin influyentes | Cambio relativo (%) |
|---|---|---|---|---|
| Apartamentos, Zona Sur | (Intercept) | 1,9526 | 2,4523 | 25,5928 |
| Apartamentos, Zona Sur | log(areaconst) | 0,6922 | 0,5780 | -16,4926 |
| Apartamentos, Zona Sur | estrato_f4 | -0,0737 | -1,2173 | -1.552,2701 |
| Apartamentos, Zona Sur | estrato_f5 | 1,3337 | 0,4253 | -68,1154 |
| Apartamentos, Zona Sur | estrato_f6 | 0,1372 | -1,1933 | -969,9714 |
| Apartamentos, Zona Sur | habitaciones | -0,0361 | -0,0372 | -3,0394 |
| Apartamentos, Zona Sur | parqueaderos | 0,0975 | 0,0892 | -8,4949 |
| Apartamentos, Zona Sur | banios | 0,0570 | 0,0313 | -45,0515 |
| Apartamentos, Zona Sur | log(areaconst):estrato_f4 | 0,0742 | 0,3562 | 380,2847 |
| Apartamentos, Zona Sur | log(areaconst):estrato_f5 | -0,2129 | 0,0097 | 104,5575 |
| Apartamentos, Zona Sur | log(areaconst):estrato_f6 | 0,1017 | 0,4106 | 303,8842 |
Las observaciones señaladas por los criterios de influencia se conservan en el modelo final. La razón es sustantiva y no técnica: en un mercado inmobiliario, los inmuebles atípicos —los muy grandes, los muy caros— son parte legítima de la oferta y no errores de medición. Excluirlos produciría un modelo que describe un mercado que no existe. El propósito de este anexo es verificar que su presencia no distorsiona las conclusiones, no eliminarlas.
tabla(dg1_log$pruebas,
"Contrastes sobre los supuestos del modelo logarítmico (casas, Zona Norte).",
digits = 4, align = c("l", "l", "r", "r", "l"))| Supuesto | Prueba | Estadístico | Valor p | Decisión |
|---|---|---|---|---|
| Homocedasticidad | Breusch-Pagan (estudentizado) | 17,4859 | 0.0145 | Se rechaza H₀ |
| Homocedasticidad | White simplificado (LM de Koenker) | 28,1889 | 0.000000756578 | Se rechaza H₀ |
| Normalidad | Anderson-Darling | 2,2762 | 0.000008995967 | Se rechaza H₀ |
| Normalidad | Lilliefors | 0,0445 | 0.0023 | Se rechaza H₀ |
| Normalidad | Jarque-Bera | 45,7122 | 0.000000000119 | Se rechaza H₀ |
| Linealidad / forma funcional | RESET de Ramsey (potencia 2) | 0,4843 | 0.4867 | No se rechaza H₀ |
tabla(dg1_log$forma,
"Medidas de forma residual del modelo logarítmico (casas, Zona Norte).",
digits = 4, align = c("l", "r"))| Medida | Valor |
|---|---|
| Asimetría de los residuos | 0,4336 |
| Curtosis en exceso | 0,8949 |
| Máxima distancia de Cook | 0,0457 |
| Error estándar residual | 0,2656 |
tabla(dg2_log$pruebas,
"Contrastes sobre los supuestos del modelo logarítmico (apartamentos, Zona Sur).",
digits = 4, align = c("l", "l", "r", "r", "l"))| Supuesto | Prueba | Estadístico | Valor p | Decisión |
|---|---|---|---|---|
| Homocedasticidad | Breusch-Pagan (estudentizado) | 301,1670 | < 0.0000000000000002 | Se rechaza H₀ |
| Homocedasticidad | White simplificado (LM de Koenker) | 41,9932 | 0.000000000761 | Se rechaza H₀ |
| Normalidad | Anderson-Darling | 7,1340 | < 0.0000000000000002 | Se rechaza H₀ |
| Normalidad | Lilliefors | 0,0352 | 0.000000024216 | Se rechaza H₀ |
| Normalidad | Jarque-Bera | 472,2280 | < 0.0000000000000002 | Se rechaza H₀ |
| Linealidad / forma funcional | RESET de Ramsey (potencia 2) | 69,6534 | < 0.0000000000000002 | Se rechaza H₀ |
tabla(dg2_log$forma,
"Medidas de forma residual del modelo logarítmico (apartamentos, Zona Sur).",
digits = 4, align = c("l", "r"))| Medida | Valor |
|---|---|
| Asimetría de los residuos | -0,3427 |
| Curtosis en exceso | 1,9072 |
| Máxima distancia de Cook | 0,2768 |
| Error estándar residual | 0,1902 |
op <- par(mfrow = c(2, 2), mar = c(4.2, 4.2, 2.6, 1.2), col.axis = "#4D4D4D")
plot(mod1_log, which = 1, col = adjustcolor(ACENTO, 0.4), pch = 16,
caption = "Casas · residuos frente a ajustados")
plot(mod1_log, which = 2, col = adjustcolor(ACENTO, 0.4), pch = 16,
caption = "Casas · cuantil-cuantil normal")
plot(mod2_log, which = 1, col = adjustcolor(ACENTO, 0.3), pch = 16,
caption = "Apartamentos · residuos frente a ajustados")
plot(mod2_log, which = 2, col = adjustcolor(ACENTO, 0.3), pch = 16,
caption = "Apartamentos · cuantil-cuantil normal")Figure 8.1: Diagnóstico gráfico de los modelos logarítmicos seleccionados.
La transformación logarítmica corrige de forma sustancial la forma de la distribución residual en ambos segmentos: la asimetría pasa de 1,61 a 0,43 en las casas y de 1,31 a -0,34 en los apartamentos, y la curtosis en exceso cae de 31,7 a 1,9 en este último segmento.
Sobre los contrastes, en cambio, el balance es desigual y debe enunciarse sin adornos:
La lectura correcta es, por tanto, que en apartamentos el modelo logarítmico con interacción se adopta por su ventaja predictiva medida en validación cruzada —que sí es sustancial— y por presentar residuos de forma mucho más manejable, no porque satisfaga los supuestos del modelo lineal clásico. Con la heterocedasticidad y la falta de linealidad aún presentes, la inferencia sobre los coeficientes de ese segmento debe apoyarse en los errores robustos del Anexo A4, y los intervalos de la Sección 4.5 deben leerse como aproximaciones. Las vías pendientes son las ya señaladas: estructura espacial explícita —el I de Moran de ese segmento es 0,150, el más alto del informe— y términos adicionales de forma funcional.
mapa.pdfLos mapas del cuerpo del informe son interactivos (leaflet) y viven dentro del
HTML publicado en RPubs. Un mapa interactivo no se imprime ni se adjunta como
archivo, de modo que este anexo genera además una versión estática y
paginada, mapa.pdf, con la misma información: la oferta completa de cada
segmento y, sobre ella, los inmuebles candidatos.
Se construye sin teselas de fondo, a propósito: la cartografía base es un servicio externo y un anexo que depende de una conexión a internet para reproducirse no es un anexo reproducible. Las coordenadas se representan en grados con la relación de aspecto corregida por la latitud de Cali, de modo que las distancias horizontales y verticales sean visualmente comparables.
# Relación de aspecto: a la latitud de Cali un grado de longitud mide
# cos(3,44°) veces un grado de latitud.
lat_ref <- mean(range(c(base1$latitud, base2$latitud), na.rm = TRUE))
aspecto <- 1 / cos(lat_ref * pi / 180)
mapa_estatico <- function(base, cartera, titulo, subtitulo) {
# ggplot no envuelve los subtítulos largos: los recorta al ancho del panel.
# Se envuelven a mano para que el texto quede completo y separado del título.
subtitulo <- paste(strwrap(subtitulo, width = 76), collapse = "\n")
ggplot() +
geom_point(data = base, aes(longitud, latitud),
colour = GRIS, size = 0.7, alpha = 0.6) +
geom_point(data = cartera, aes(longitud, latitud),
colour = AZUL, fill = NARANJA, shape = 21,
size = 3.2, stroke = 0.8) +
coord_fixed(ratio = aspecto) +
theme(plot.title = element_text(margin = margin(b = 6)),
plot.subtitle = element_text(lineheight = 1.25,
margin = margin(b = 10))) +
labs(title = titulo, subtitle = subtitulo,
x = "Longitud (°)", y = "Latitud (°)",
caption = paste0("Gris: oferta del segmento. Naranja: inmuebles ",
"candidatos. Fuente: paqueteMODELOS::vivienda."))
}
pdf("mapa.pdf", width = 9, height = 7, onefile = TRUE)
print(mapa_estatico(
base1, cart1,
"Vivienda 1 · casas de la Zona Norte",
paste0("Oferta del segmento (", nrow(base1), " inmuebles) y ",
nrow(cart1), " candidatas dentro del crédito de 350 millones")))
print(mapa_estatico(
base2, cartera2,
"Vivienda 2 · apartamentos de la Zona Sur",
paste0("Oferta del segmento (", nrow(base2), " inmuebles) y ",
nrow(cartera2), " candidatas escalonadas dentro del crédito de 850 millones")))
invisible(dev.off())El archivo queda escrito en el directorio de compilación. Si la entrega en la
plataforma exige un mapa.pdf, es este archivo; el informe propiamente dicho se
entrega como enlace de RPubs.
si <- sessionInfo()
tabla(data.frame(
Elemento = c("Versión de R", "Plataforma", "Sistema operativo",
"Semilla global", "Fecha de compilación"),
Valor = c(si$R.version$version.string, si$R.version$platform,
si$running, "2026", format(Sys.time(), "%Y-%m-%d %H:%M"))),
"Entorno de cómputo utilizado.", align = c("l", "l"))| Elemento | Valor |
|---|---|
| Versión de R | R version 4.5.2 (2025-10-31 ucrt) |
| Plataforma | x86_64-w64-mingw32 |
| Sistema operativo | Windows 11 x64 (build 26200) |
| Semilla global | 2026 |
| Fecha de compilación | 2026-09-13 08:03 |
paq <- c("paqueteMODELOS", "dplyr", "ggplot2", "plotly", "leaflet", "lmtest",
"sandwich", "car", "nortest", "tseries", "e1071", "kableExtra")
tabla(data.frame(Paquete = paq,
`Versión` = sapply(paq, function(p)
as.character(packageVersion(p))),
check.names = FALSE, row.names = NULL),
"Versiones de los paquetes empleados.", align = c("l", "l"))| Paquete | Versión |
|---|---|
| paqueteMODELOS | 0.1.0 |
| dplyr | 1.2.1 |
| ggplot2 | 4.0.3 |
| plotly | 4.11.0 |
| leaflet | 2.2.3 |
| lmtest | 0.9.40 |
| sandwich | 3.1.1 |
| car | 3.1.3 |
| nortest | 1.0.4 |
| tseries | 0.10.62 |
| e1071 | 1.7.17 |
| kableExtra | 1.4.0 |
Luis Javier Rubio Hernández