# 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::filter
AZUL    <- "#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)

Resumen ejecutivo

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

1 Contexto y objetivos

1.1 Situación

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"))
Table 1.1: Condiciones de las dos solicitudes recibidas por C&A.
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

1.2 Objetivo general

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.

1.3 Objetivos específicos

  1. Depurar la oferta disponible y delimitar los dos segmentos pertinentes: casas de la Zona Norte y apartamentos de la Zona Sur.
  2. Caracterizar la asociación entre el precio y los atributos físicos y socioeconómicos del inmueble.
  3. Estimar, validar y comparar modelos de regresión lineal múltiple para el precio en cada segmento.
  4. Producir estimaciones puntuales y por intervalo para los perfiles solicitados, distinguiendo la estimación de la respuesta media de la predicción de una observación individual.
  5. Seleccionar, para cada solicitud, un conjunto de inmuebles candidatos con criterios explícitos de precio, especificación y valoración relativa.

2 Datos

2.1 Origen y acceso

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"))
Table 2.1: Dimensiones del conjunto de datos original.
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.

2.2 Diccionario de variables

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"))
Table 2.2: Diccionario de variables y papel de cada una en el análisis.
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.

2.3 Funciones de trabajo

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)),
               `` = 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)
}

3 Solicitud 1: casa en la Zona Norte

3.1 Filtro y depuración

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"))
Table 3.1: Bitácora de depuración del segmento de casas de la Zona Norte.
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"))
Table 3.1: Desglose por variable de los registros retirados (casas, Zona Norte).
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"))
Table 3.2: 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.
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"))
Table 3.3: Comprobación de que el filtro se aplicó correctamente.
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"))
Table 3.4: Composición por estrato del segmento de casas de la Zona Norte.
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

3.1.1 Tratamiento de los parqueaderos

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)))
Table 3.5: Contraste del mecanismo de ausencia en parqueaderos (casas, Zona Norte).
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"))
Table 3.6: Proporción de registros sin dato de parqueadero, por estrato.
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))
Table 3.6: Asociación entre el indicador de ausencia y el estrato (casas, Zona Norte).
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.

3.1.2 Coherencia geográfica de la oferta

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"))
Table 3.7: Validación de la regla del eje divisorio sobre las cinco zonas del conjunto completo.
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"))
Table 3.7: Sensibilidad del recuento de discordancias al umbral elegido (casas, Zona Norte).
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"))
Table 3.8: Descomposición de los registros discordantes en causas mutuamente excluyentes (casas, Zona Norte).
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"))
Table 3.8: Tratamiento aplicado a cada causa.
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"))
Table 3.9: 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.
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.

3.2 Análisis exploratorio

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"))
Table 3.10: Correlación entre el precio y cada atributo (casas, Zona Norte).
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
grafico_precio_area(base1,
  "El precio crece con el área, y la pendiente difiere por estrato")

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)))
Table 3.11: Contraste de Kruskal-Wallis: diferencias de precio entre niveles de cada atributo.
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

3.2.1 Lectura del análisis exploratorio

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.

3.3 Estimación e interpretación del modelo

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.

3.3.1 El estrato: ¿variable numérica o factor?

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).")
Table 3.12: 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.

3.3.2 Modelo estimado

mod1 <- m1_1
tabla_coeficientes(mod1,
  "Modelo de regresión lineal múltiple para el precio de casas de la Zona Norte.")
Table 3.13: 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"))
Table 3.14: Medidas de ajuste global del modelo (casas, Zona Norte).
Medida Valor
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

3.3.3 Interpretación de los coeficientes

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.

3.3.4 Ajuste del modelo y cómo mejorarlo

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:

  1. Corregir la forma funcional. La relación precio–área es multiplicativa, no aditiva. Es la vía de mayor rendimiento esperado en este segmento: la comparación formal de la Sección 3.5 muestra que la especificación logarítmica reduce la asimetría residual de 1,61 a 0,43 y la curtosis en exceso de 11,13 a 0,89.
  2. Incorporar la localización fina. El diagnóstico espacial de la Sección 3.4 detecta correlación entre residuos de inmuebles vecinos. La dependencia es estadísticamente significativa pero de magnitud pequeña en este segmento, de modo que la mejora esperada es real aunque acotada; en el segmento de apartamentos, en cambio, es la prioridad.
  3. Añadir atributos ausentes de la fuente. Antigüedad, estado de conservación, estrato de acabados y presencia de zonas comunes son determinantes conocidos del precio que este conjunto simplemente no registra.
  4. Distinguir precio de oferta de precio de transacción. La variable preciom es el precio pedido, que en un mercado contraído como el descrito por María sobrestima sistemáticamente el precio de cierre.

3.4 Validación de supuestos

# `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"))
Table 3.15: Contrastes formales sobre los supuestos del modelo (casas, Zona Norte).
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"))
Table 3.16: Medidas de forma de la distribución residual (casas, Zona Norte).
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")
Diagnóstico gráfico del modelo en niveles (casas, Zona Norte).

Figure 3.7: Diagnóstico gráfico del modelo en niveles (casas, Zona Norte).

par(op)
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"))
Table 3.17: Diagnóstico de observaciones influyentes (casas, Zona Norte).
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))
Table 3.18: I de Moran sobre los residuos: contraste de autocorrelación espacial (casas, Zona Norte).
I E[I] (Cliff-Ord) z Valor p Observaciones
0,0400 -0,0035 2,5115 0.012 696

3.4.1 Lectura del diagnóstico

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:

  1. Transformar la respuesta. La heterocedasticidad y la asimetría son ambas síntomas de que el precio se determina de forma multiplicativa. Modelar 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.
  2. Errores estándar robustos a heterocedasticidad (HC3). Corrigen la inferencia sin alterar el modelo ni su interpretación. Se reportan en el Anexo A4.
  3. Incorporar estructura espacial, mediante efectos de barrio o un modelo de error espacial. En este segmento la dependencia detectada es débil (\(I = 0,040\)), de modo que la ganancia esperada es modesta; en el de apartamentos, no.
  4. Mínimos cuadrados ponderados o regresión robusta, si se prefiere conservar la escala original de la respuesta.

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.

3.5 Modelo seleccionado y predicción

3.5.1 Selección del modelo

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)))
Table 3.19: Comparación de las cinco especificaciones candidatas (casas, Zona Norte). AIC y BIC solo son comparables dentro de una misma escala de la respuesta.
Modelo Escala Parámetros 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"))
Table 3.20: Por qué se selecciona el modelo logarítmico: igual capacidad predictiva, inferencia válida.
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.

tabla_coeficientes(mod1_log,
  "Modelo logarítmico seleccionado (casas, Zona Norte).")
Table 3.21: Modelo logarítmico seleccionado (casas, Zona Norte).
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

3.5.2 Estimación puntual para la Vivienda 1

# `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)))
Table 3.22: 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).
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"))
Table 3.23: Apalancamiento del perfil solicitado: verificación de que la predicción interpola y no extrapola.
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"))
Table 3.24: Soporte muestral en la vecindad del perfil solicitado.
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

3.5.3 Interpretación de la estimación

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.

3.6 Cartera de inmuebles candidatos

# `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.")
Table 3.25: 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
mapa_cartera(base1, cart1, "Vivienda 1 · crédito 350 millones")

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.

4 Solicitud 2: apartamento en la Zona Sur

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.

4.1 Filtro y depuración

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"))
Table 4.1: Bitácora de depuración del segmento de apartamentos de la Zona Sur.
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"))
Table 4.1: Desglose por variable de los registros retirados (apartamentos, Zona Sur).
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"))
Table 4.2: 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.
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"))
Table 4.3: Comprobación del filtro aplicado al segmento de apartamentos del sur.
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"))
Table 4.4: Composición por estrato del segmento de apartamentos de la Zona Sur.
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)))
Table 4.5: Contraste del mecanismo de ausencia en parqueaderos (apartamentos, Zona Sur).
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"))
Table 4.5: Proporción de registros sin dato de parqueadero, por estrato (apartamentos, Zona Sur).
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))
Table 4.5: Asociación entre el indicador de ausencia y el estrato (apartamentos, Zona Sur).
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.

4.2 Análisis exploratorio

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"))
Table 4.6: Correlación entre el precio y cada atributo (apartamentos, Zona Sur).
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
grafico_precio_area(base2,
  "La relación precio–área es más estrecha que en las casas del norte")

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)))
Table 4.7: Contraste de Kruskal-Wallis por atributo (apartamentos, Zona Sur).
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.

4.3 Estimación e interpretación 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).")
Table 4.8: 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.")
Table 4.9: 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"))
Table 4.9: Medidas de ajuste global del modelo (apartamentos, Zona Sur).
Medida Valor
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

4.3.1 Interpretación de los coeficientes

Á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.

4.4 Validación de supuestos

tabla(dg2$pruebas,
      "Contrastes formales sobre los supuestos del modelo (apartamentos, Zona Sur).",
      digits = 4, align = c("l", "l", "r", "r", "l"))
Table 4.10: Contrastes formales sobre los supuestos del modelo (apartamentos, Zona Sur).
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"))
Table 4.10: Medidas de forma de la distribución residual (apartamentos, Zona Sur).
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")
Diagnóstico gráfico del modelo en niveles (apartamentos, Zona Sur).

Figure 4.6: Diagnóstico gráfico del modelo en niveles (apartamentos, Zona Sur).

par(op)
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"))
Table 4.11: Diagnóstico de observaciones influyentes (apartamentos, Zona Sur).
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))
Table 4.11: I de Moran sobre los residuos (apartamentos, Zona Sur).
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:

  • La heterocedasticidad es más severa (Breusch-Pagan < 0.0000000000000002).
  • La no normalidad es extrema: curtosis en exceso de 31,7, frente a 11,1 en las casas. La distribución de precios de apartamentos tiene una cola derecha muy larga.
  • La linealidad se rechaza aquí (RESET 0.00000000000000329), a diferencia del segmento anterior. La forma funcional aditiva está mal especificada.
  • La autocorrelación espacial es mucho más intensa: I de Moran 0,1498 con \(z = 17,3\), frente a 0,0400 en las casas. El precio de un apartamento en el sur depende fuertemente de su ubicación exacta —no es lo mismo Ciudad Jardín que Meléndez— y el modelo no dispone de esa información.
  • Hay una observación con distancia de Cook de 6,16, por encima del umbral crítico de 1: un único registro con capacidad de alterar el ajuste. El Anexo A4 verifica el efecto de excluirlo.

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.

4.5 Modelo seleccionado y predicción

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)))
Table 4.12: Comparación de las cinco especificaciones candidatas (apartamentos, Zona Sur).
Modelo Escala Parámetros 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).")
Table 4.13: 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).")
Table 4.14: 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"))
Table 4.15: Elasticidad del precio respecto al área construida, por estrato: un aumento del 1 % en el área se asocia con este aumento porcentual del precio.
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.

4.5.1 Estimación puntual para la Vivienda 2

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)))
Table 4.16: 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).
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"))
Table 4.17: Soporte muestral en la vecindad del perfil solicitado (apartamentos, Zona Sur).
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"))
Table 4.17: Apalancamiento del perfil solicitado (apartamentos, Zona Sur).
Estrato Apalancamiento del perfil Máximo muestral Promedio muestral
5 0,0175 0,1628 0,0040
6 0,0222 0,1628 0,0040

4.5.2 Interpretación de la estimación

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.

4.6 Cartera de inmuebles candidatos

# `cart2a` y `n_a <- attr(cart2a, "n_total")`: véase el preámbulo.

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"))
Table 4.18: Disponibilidad de oferta según el nivel de concesión aceptado (Vivienda 2).
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)))
Table 4.19: Cartera escalonada de inmuebles candidatos para la Vivienda 2. El nivel indica la concesión aceptada respecto a la especificación original.
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
mapa_cartera(base2, cartera2, "Vivienda 2 · crédito 850 millones")

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:

  • Los 3 inmuebles del nivel A son los únicos que cumplen la especificación completa. Se concentran geográficamente y su margen de negociación es limitado, precisamente por la escasez de oferta comparable.
  • El nivel B libera el requisito de cinco alcobas. Es la concesión de menor costo real, porque el modelo mostró que a área constante las alcobas adicionales restan valor de mercado: se obtiene el mismo espacio con mejor distribución y, con frecuencia, a menor precio.
  • El nivel C admite apartamentos entre 210 y 255 m². Amplía sustancialmente el conjunto de opciones y libera presupuesto, a costa de superficie.

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.

5 Recomendaciones para C&A

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"))
Table 5.1: Síntesis de las dos solicitudes (millones de pesos).
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.

6 Limitaciones

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.

7 Conclusiones

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  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.

8 Referencias

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].

Anexos

Anexo A1. Instalación del entorno

# 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"))

Anexo A2. Efecto de la zona sobre el conjunto completo

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)))
Table 8.1: Precio y área por zona sobre el conjunto completo, antes de filtrar.
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)
Table 8.1: Contraste global de diferencias de precio entre zonas.
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).")
Table 8.2: 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.

Anexo A3. Sensibilidad al tratamiento de parqueaderos

La 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"))
Table 8.3: Sensibilidad de los coeficientes al tratamiento de la ausencia en parqueaderos.
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.

Anexo A4. Inferencia robusta y estabilidad frente a observaciones influyentes

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)))
Table 8.4: Errores estándar robustos a heterocedasticidad (HC3) — modelo en niveles, casas.
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)))
Table 8.4: Errores estándar robustos a heterocedasticidad (HC3) — modelo en niveles, apartamentos.
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"))
Table 8.5: Estabilidad de los coeficientes al excluir las observaciones señaladas (modelo logarítmico, casas).
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"))
Table 8.5: Estabilidad de los coeficientes al excluir las observaciones señaladas (modelo logarítmico, apartamentos).
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.

Anexo A5. Diagnóstico completo del modelo logarítmico

tabla(dg1_log$pruebas,
      "Contrastes sobre los supuestos del modelo logarítmico (casas, Zona Norte).",
      digits = 4, align = c("l", "l", "r", "r", "l"))
Table 8.6: Contrastes sobre los supuestos del modelo logarítmico (casas, Zona Norte).
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"))
Table 8.6: Medidas de forma residual del modelo logarítmico (casas, Zona Norte).
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"))
Table 8.6: Contrastes sobre los supuestos del modelo logarítmico (apartamentos, Zona Sur).
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"))
Table 8.6: Medidas de forma residual del modelo logarítmico (apartamentos, Zona Sur).
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")
Diagnóstico gráfico de los modelos logarítmicos seleccionados.

Figure 8.1: Diagnóstico gráfico de los modelos logarítmicos seleccionados.

par(op)

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:

  • Casas. El RESET no rechaza (p = 0,487) y la heterocedasticidad se atenúa hasta un valor p de 0.0145, frente a <0.0000000000000002 en el modelo en niveles. La mejora es real.
  • Apartamentos. Aquí la transformación no resuelve el problema: el RESET sigue rechazando (p <0.0000000000000002) y Breusch-Pagan también (p <0.0000000000000002). Afirmar que la especificación logarítmica “elimina el rechazo de la linealidad” sería falso en este segmento.

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.

Anexo A6. Cartografía estática: mapa.pdf

Los 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.

Anexo A7. Información de la sesión

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"))
Table 8.7: Entorno de cómputo utilizado.
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"))
Table 8.7: Versiones de los paquetes empleados.
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