# devtools::install_github("centromagis/paqueteMODELOS", force = TRUE)
library(paqueteMODELOS)
library(dplyr)
library(ggplot2)
library(plotly)
library(knitr)
library(kableExtra)
library(car)
library(lmtest)


1 Descripción del caso

María comenzó como agente de bienes raíces en Cali hace diez años. Con esa experiencia fundó su propia inmobiliaria, C&A (Casas y Apartamentos), donde hoy trabajan ocho agentes. Las ventas del sector han bajado por las tensiones políticas y sociales de los últimos meses, aunque la banca sigue prestando fuerte para construcción residencial y comercial; se espera que la demanda se recupere cuando esa situación se calme.

Hace dos días le llegó una carta de una compañía internacional que quiere comprar dos viviendas para instalar a dos de sus empleados y sus familias en la ciudad. Las condiciones de cada solicitud son estas:

Característica Vivienda 1 Vivienda 2
Tipo Casa Apartamento
Área construida (m²) 200 300
Parqueaderos 1 3
Baños 2 3
Habitaciones 4 5
Estrato 4 o 5 5 o 6
Zona Norte Sur
Crédito preaprobado $350 millones $850 millones

Este informe ayuda a María a responder ambas solicitudes mediante un modelo de Regresión Lineal Múltiple (RLM) que explique el precio de las propiedades en cada segmento, y a partir de él recomendar ofertas concretas dentro del presupuesto de cada cliente.

2 Objetivos

2.1 Objetivo general

Estimar e interpretar modelos de regresión lineal múltiple para el precio de vivienda en dos segmentos del mercado de Cali (casas en zona norte y apartamentos en zona sur), con el fin de predecir el precio de cada vivienda solicitada y recomendar ofertas concretas dentro del crédito preaprobado de cada cliente.

2.2 Objetivos específicos

  1. Filtrar y depurar la base de datos para aislar cada segmento de interés (Criterio 1: Realiza filtro inicial y depuración).
  2. Explorar, mediante gráficos interactivos de correlación, qué variables se asocian más con el precio en cada segmento (Criterio 2: Realiza análisis exploratorio de los datos).
  3. Estimar un modelo de RLM del precio en función del área construida, el estrato, el número de habitaciones, parqueaderos y baños, e interpretar sus coeficientes y su ajuste (Criterio 3: Estima e interpreta el modelo).
  4. Predecir puntualmente el precio de cada vivienda solicitada e interpretar el resultado frente al crédito preaprobado (Criterio 4: Realiza estimación puntual y su interpretación).
  5. Validar los supuestos estadísticos de cada modelo e interpretar sus implicaciones, sin necesidad de corregirlos (Criterio 5: Valida los supuestos del modelo).
  6. A partir de las predicciones del modelo, identificar al menos cinco ofertas potenciales que respeten el presupuesto y las condiciones de cada cliente.
  7. Replicar íntegramente el análisis anterior para la segunda solicitud, con apartamentos de la zona sur (Criterio 6: Replica el ejercicio con aptos del sur).

3 Datos y depuración

3.1 Carga y descripción de variables

La base vivienda del paquete paqueteMODELOS contiene las propiedades residenciales ofertadas en Cali durante los últimos tres meses.

data("vivienda")
dim(vivienda)
## [1] 8322   13

Tabla. Diccionario de variables de la base vivienda (CC = cuantitativa continua, CD = cuantitativa discreta, CO = cualitativa ordinal, CN = cualitativa nominal).

variables_df <- data.frame(
  Variable = c("id", "zona", "piso", "estrato", "preciom", "areaconst",
               "parqueaderos", "banios", "habitaciones", "tipo", "barrio",
               "longitud", "latitud"),
  Descripcion = c(
    "Identificador de la propiedad",
    "Ubicación de la vivienda: Zona Centro, Zona Norte, Zona Oriente, Zona Oeste, Zona Sur",
    "Piso que ocupa la vivienda (aplica a apartamentos)",
    "Estrato socioeconómico: 3, 4, 5, 6",
    "Precio de la vivienda, en millones de pesos",
    "Área construida (m²)",
    "Número de parqueaderos",
    "Número de baños",
    "Número de habitaciones",
    "Tipo de vivienda: Casa, Apartamento",
    "Barrio de ubicación de la vivienda",
    "Coordenada geográfica de longitud",
    "Coordenada geográfica de latitud"
  ),
  Tipo = c("ID", "CN", "CN", "CO", "CC", "CC", "CD", "CD", "CD", "CN", "CN", "CC", "CC")
)

kable(variables_df, col.names = c("Variable", "Descripción", "Tipo"),
      align = c("l", "l", "c")) %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE)
Variable Descripción Tipo
id Identificador de la propiedad ID
zona Ubicación de la vivienda: Zona Centro, Zona Norte, Zona Oriente, Zona Oeste, Zona Sur CN
piso Piso que ocupa la vivienda (aplica a apartamentos) CN
estrato Estrato socioeconómico: 3, 4, 5, 6 CO
preciom Precio de la vivienda, en millones de pesos CC
areaconst Área construida (m²) CC
parqueaderos Número de parqueaderos CD
banios Número de baños CD
habitaciones Número de habitaciones CD
tipo Tipo de vivienda: Casa, Apartamento CN
barrio Barrio de ubicación de la vivienda CN
longitud Coordenada geográfica de longitud CC
latitud Coordenada geográfica de latitud CC

El enunciado pide usar estrato junto a las variables de conteo (habitaciones, parqueaderos, banios) como predictoras numéricas del precio. En rigor estrato es ordinal, pero aquí se trata como numérica, asumiendo que su efecto sobre el precio es aproximadamente lineal por cada unidad que sube.

3.2 Datos faltantes

Tabla. Datos faltantes por variable.

na_df <- data.frame(
  Variable  = names(vivienda),
  Faltantes = colSums(is.na(vivienda))
) %>%
  mutate(Pct = round(Faltantes / nrow(vivienda) * 100, 1)) %>%
  filter(Faltantes > 0) %>%
  arrange(desc(Faltantes))

kable(na_df, col.names = c("Variable", "N faltantes", "% faltantes"), align = c("l", "c", "c")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Variable N faltantes % faltantes
piso piso 2638 31.7
parqueaderos parqueaderos 1605 19.3
id id 3 0.0
zona zona 3 0.0
estrato estrato 3 0.0
areaconst areaconst 3 0.0
banios banios 3 0.0
habitaciones habitaciones 3 0.0
tipo tipo 3 0.0
barrio barrio 3 0.0
longitud longitud 3 0.0
latitud latitud 3 0.0
preciom preciom 2 0.0

La mayoría de los faltantes está en piso, variable que no entra en ningún modelo de este informe (no aplica a las casas). El caso de parqueaderos es distinto: tiene un volumen relevante de vacíos, que interpretamos como ausencia de parqueadero e imputamos con cero en vez de eliminar esas filas. El resto de los faltantes (unas pocas filas en preciom, areaconst, banios, habitaciones, tipo, zona y estrato) es un porcentaje marginal del total, así que esas filas simplemente se eliminan.

3.3 Depuración

vivienda$zona <- factor(vivienda$zona)
vivienda$tipo <- factor(vivienda$tipo)

vivienda_c <- vivienda %>%
  filter(!is.na(preciom), !is.na(areaconst), !is.na(banios),
         !is.na(habitaciones), !is.na(tipo), !is.na(zona), !is.na(estrato))

vivienda_c$parqueaderos[is.na(vivienda_c$parqueaderos)] <- 0

La base original tiene 8322 registros; tras la depuración quedan 8319 registros disponibles para el análisis (100% del total), sobre los cuales se construyen los dos filtros (base1, base2) de las siguientes secciones.

3.4 Panorama general del mercado

Figura. Distribución del precio de venta (millones de pesos).

ggplot(vivienda_c, aes(x = preciom)) +
  geom_histogram(bins = 50, fill = col_primario, color = "white") +
  theme_minimal(base_size = 11) +
  labs(x = "Precio (millones $)", y = "N° propiedades")

El precio tiene una fuerte asimetría a la derecha (mediana 330M vs. media 434M): la mayoría de la oferta se concentra en precios medios-bajos, con una cola de propiedades de alto valor.

Figura (interactiva). Distribución de precios por zona y tipo de vivienda.

plot_ly(vivienda_c, x = ~zona, y = ~preciom, color = ~tipo, type = "box",
        colors = pal_cat[1:2]) %>%
  layout(title = "Distribución de precios por zona y tipo de vivienda",
         xaxis = list(title = "Zona"), yaxis = list(title = "Precio (millones $)"),
         boxmode = "group")

Esta vista general responde a la componente zona del análisis de correlación que pide el paso 2 del enunciado: una vez se filtre cada base por una única zona (Norte o Sur, en las secciones siguientes), zona queda constante dentro de cada subconjunto y ya no aporta nada a la correlación. Su efecto ya se puede ver aquí, a nivel general.

Figura (interactiva). Ubicación geográfica de las propiedades, coloreada por zona.

plot_ly(vivienda_c, x = ~longitud, y = ~latitud, color = ~zona, colors = pal_cat,
        type = "scatter", mode = "markers", marker = list(size = 5, opacity = 0.5)) %>%
  layout(title = "Ubicación geográfica por zona",
         xaxis = list(title = "Longitud"),
         yaxis = list(title = "Latitud", scaleanchor = "x"))

Tabla. Rango de coordenadas geográficas observado en cada zona.

rangos_zona <- vivienda_c %>%
  group_by(zona) %>%
  summarise(lat_min = min(latitud), lat_max = max(latitud),
            lon_min = min(longitud), lon_max = max(longitud), .groups = "drop")

kable(rangos_zona %>% mutate(across(where(is.numeric), ~round(., 4))),
      col.names = c("Zona", "Lat. mín", "Lat. máx", "Lon. mín", "Lon. máx")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Zona Lat. mín Lat. máx Lon. mín Lon. máx
Zona Centro 3.3984 3.4870 -76.5440 -76.4959
Zona Norte 3.3331 3.4977 -76.5892 -76.4674
Zona Oeste 3.3597 3.4941 -76.5874 -76.4640
Zona Oriente 3.3440 3.4900 -76.5611 -76.4657
Zona Sur 3.3330 3.4968 -76.5671 -76.4630

Los rangos de latitud/longitud de las cinco zonas se traslapan considerablemente entre sí. Esto significa que zona es una etiqueta reportada por quien publicó el aviso, no un polígono geográfico estricto: dos propiedades con coordenadas casi idénticas pueden estar clasificadas en zonas distintas. Se retoma esta observación en el mapa de verificación de cada uno de los dos casos siguientes.

4 Formulación general del modelo de regresión lineal múltiple

Esta formulación aplica de forma idéntica a los dos casos de las secciones siguientes.

Modelo poblacional. Con \(Y_i=\) preciom y \(X_{i1},\dots,X_{i5}\) las variables areaconst, estrato, habitaciones, parqueaderos y banios de la propiedad \(i\):

\[ Y_i = \beta_0 + \beta_1 X_{i1} + \beta_2 X_{i2} + \beta_3 X_{i3} + \beta_4 X_{i4} + \beta_5 X_{i5} + \varepsilon_i, \qquad \varepsilon_i \overset{iid}{\sim} N(0,\sigma^2) \]

tal como se vio en la Unidad 2 en la sesión de Modelo de Regresión Lineal Múltiple.

Estimación. En forma matricial \(y=X\beta+\varepsilon\), el estimador que minimiza la suma de cuadrados de los residuales es

\[ \hat\beta = (X'X)^{-1}X'y \]

esto se explica en la sesión de Estimación Cuadrados Ordinarios de la Unidad 2.

Significancia individual. Cada coeficiente se prueba con

\[ T_{j,0} = \frac{\hat\beta_j}{se(\hat\beta_j)} \overset{H_0:\ \beta_j=0}{\sim} t_{n-p} \]

rechazando \(H_0\) (variable significativa) cuando el valor-p es menor a 0.05, según se plantea en la sesión de Inferencia sobre los Parámetros de la Unidad 2.

Bondad de ajuste.

\[ R^2 = 1-\frac{SSE}{SST}, \qquad R^2_{ajustado} = 1-(1-R^2)\frac{n-1}{n-p} \]

Predicción individual vs. respuesta media. Cada solicitud corresponde a una vivienda individual, no al precio promedio de un segmento, por lo que se usa el intervalo de predicción

\[ \hat Y_0 = x_0\hat\beta, \qquad \widehat{V}[Y_0-\hat Y_0] = MSE\left[1+x_0(X'X)^{-1}x_0'\right] \]

que es siempre más ancho que el intervalo de confianza para la media, porque suma la variabilidad propia de una observación futura. La sesión de Inferencia sobre Media y Pronósticos de la Unidad 2 advierte además sobre el riesgo de extrapolación si alguna característica solicitada cae fuera del rango observado.

Validación de supuestos y multicolinealidad. Sobre los residuales \(e_i=(y_i-\hat y_i)\) se valida normalidad (Shapiro-Wilk), homocedasticidad (Goldfeld-Quandt, con Breusch-Pagan como contraste adicional) e independencia (Durbin-Watson), tal como lo cubre la sesión de Validación de los Supuestos del Modelo (Unidad 2). También se calcula el Factor de Inflación de Varianza \(VIF_j=1/(1-R_j^2)\), tomando \(VIF_j>10\) como evidencia de un problema grave de multicolinealidad, siguiendo la sesión de Diagnóstico de Multicolinealidad de esa misma unidad.

4.1 Funciones de apoyo

Como los siete pasos del enunciado se replican de forma idéntica para las dos solicitudes (Criterio 6), se definen aquí las funciones que se reutilizan en ambos casos, en vez de repetir el mismo código dos veces.

vars_modelo <- c("areaconst", "estrato", "habitaciones", "parqueaderos", "banios")

# Ecuación estimada en LaTeX, con el signo correcto de cada coeficiente
formatear_ecuacion <- function(modelo, y_nombre = "preciom") {
  coefs <- coef(modelo)
  terminos <- paste0(
    ifelse(coefs[-1] >= 0, " + ", " - "),
    format(round(abs(coefs[-1]), 3), trim = TRUE),
    "\\cdot ", names(coefs)[-1]
  )
  paste0("\\widehat{", y_nombre, "} = ", round(coefs[1], 2), paste(terminos, collapse = ""))
}

# Tabla resumen de validación de supuestos (normalidad, homocedasticidad,
# independencia, VIF), con Shapiro-Wilk sobre muestra si n > 5000
resumen_supuestos <- function(modelo) {
  res <- residuals(modelo)
  n   <- length(res)
  muestreado <- n > 5000
  if (muestreado) {
    set.seed(123)
    res_sh <- sample(res, 5000)
  } else {
    res_sh <- res
  }

  sh   <- shapiro.test(res_sh)
  # Se ordena por areaconst: es razonable esperar que la varianza del error
  # crezca con el tamaño de la propiedad, el patron clasico de heterocedasticidad
  # en precios inmobiliarios
  gq   <- lmtest::gqtest(modelo, order.by = ~areaconst, data = model.frame(modelo))
  bp   <- lmtest::bptest(modelo)
  dw   <- lmtest::dwtest(modelo)
  vifs <- car::vif(modelo)

  tabla <- data.frame(
    Supuesto    = c("Normalidad (Shapiro-Wilk)", "Homocedasticidad (Goldfeld-Quandt)",
                     "Homocedasticidad (Breusch-Pagan)", "Independencia (Durbin-Watson)",
                     "Multicolinealidad (VIF máximo)"),
    Estadistico = c(round(unname(sh$statistic), 3), round(unname(gq$statistic), 3),
                     round(unname(bp$statistic), 3), round(unname(dw$statistic), 3),
                     round(max(vifs), 2)),
    p.valor     = c(signif(sh$p.value, 3), signif(gq$p.value, 3),
                     signif(bp$p.value, 3), signif(dw$p.value, 3), NA)
  )

  list(tabla = tabla, n_residuales = n, muestreado = muestreado,
       p_shapiro = sh$p.value, p_gq = gq$p.value, p_bp = bp$p.value,
       p_dw = dw$p.value, vif_max = max(vifs))
}

# Compara una característica solicitada (x0) contra el rango observado,
# para advertir riesgo de extrapolacion
chequeo_rango <- function(datos, x0) {
  vars <- names(x0)
  data.frame(
    Variable       = vars,
    Solicitado     = as.numeric(unlist(x0[1, vars], use.names = FALSE)),
    Min_observado  = sapply(vars, function(v) min(datos[[v]], na.rm = TRUE)),
    Max_observado  = sapply(vars, function(v) max(datos[[v]], na.rm = TRUE)),
    row.names      = NULL
  ) %>%
    mutate(Dentro_de_rango = ifelse(Solicitado >= Min_observado & Solicitado <= Max_observado, "Sí", "No"))
}

# Compara el modelo completo contra un modelo reducido (sin las variables no
# significativas al 5%) mediante la prueba F parcial / SSextra (anova())
comparar_modelos <- function(modelo_completo, alpha = 0.05) {
  coefs      <- summary(modelo_completo)$coefficients
  vars_todas <- rownames(coefs)[-1]
  no_signif  <- vars_todas[coefs[vars_todas, "Pr(>|t|)"] > alpha]

  if (length(no_signif) == 0) {
    return(list(modelo_reducido = NULL, anova = NULL, no_signif = character(0)))
  }

  datos_modelo      <- model.frame(modelo_completo)
  respuesta         <- names(datos_modelo)[1]
  vars_reducido     <- setdiff(vars_todas, no_signif)
  formula_reducida  <- as.formula(paste(respuesta, "~", paste(vars_reducido, collapse = " + ")))
  modelo_reducido   <- lm(formula_reducida, data = datos_modelo)

  list(modelo_reducido = modelo_reducido,
       anova = anova(modelo_reducido, modelo_completo),
       no_signif = no_signif)
}

# Distancia de Cook y DFFITS, con sus umbrales de influencia
diagnostico_influencia <- function(modelo) {
  p <- length(coef(modelo))
  n <- nrow(model.frame(modelo))
  cooksd <- cooks.distance(modelo)
  dff    <- dffits(modelo)
  umbral_dffits <- 2 * sqrt(p / n)

  list(n_cook_altos = sum(cooksd > 1),
       n_dffits_altos = sum(abs(dff) > umbral_dffits),
       cooksd = cooksd, dffits = dff, umbral_dffits = umbral_dffits)
}

# Numero e indice de condicion de X'X (deteccion de multicolinealidad via
# valores propios)
numero_condicion <- function(modelo) {
  X  <- model.matrix(modelo)[, -1, drop = FALSE]
  Xc <- scale(X, center = TRUE, scale = FALSE)
  vp <- eigen(t(Xc) %*% Xc)$values
  sqrt(max(vp) / min(vp))
}

# Validacion cruzada simple (70% estimacion / 30% validacion)
validacion_cruzada <- function(formula_modelo, datos, prop_estim = 0.7, semilla = 123) {
  set.seed(semilla)
  n         <- nrow(datos)
  n_estim   <- floor(prop_estim * n)
  idx_estim <- sample(seq_len(n), n_estim)

  m_total <- lm(formula_modelo, data = datos)
  m_estim <- lm(formula_modelo, data = datos[idx_estim, ])

  respuesta   <- all.vars(formula_modelo)[1]
  y_valid     <- datos[[respuesta]][-idx_estim]
  y_hat_valid <- predict(m_estim, newdata = datos[-idx_estim, ])
  r_val       <- cor(y_valid, y_hat_valid)

  list(R2_total = summary(m_total)$r.squared, r2_validacion = r_val^2,
       encogimiento = summary(m_total)$r.squared - r_val^2,
       coef_total = coef(m_total), coef_estim = coef(m_estim))
}

5 Caso 1 — Vivienda 1: Casa, Zona Norte (crédito hasta $350 millones)

5.1 Filtro y depuración de la base

(Rúbrica: Criterio 1 — Realiza filtro inicial y depuración)

base1 <- vivienda_c %>% filter(tipo == "Casa", zona == "Zona Norte")

La base 1 (base1) queda con 722 registros. Los primeros tres:

Tabla. Primeros tres registros de la Base 1.

kable(head(base1[, c("id","zona","estrato","preciom","areaconst","parqueaderos","banios","habitaciones","tipo","barrio")], 3)) %>%
  kable_styling(bootstrap_options = c("striped", "hover"))
id zona estrato preciom areaconst parqueaderos banios habitaciones tipo barrio
1209 Zona Norte 5 320 150 2 4 6 Casa acopi
1592 Zona Norte 5 780 380 2 3 3 Casa acopi
4057 Zona Norte 6 750 445 0 7 6 Casa acopi

Tabla. Verificación del filtro — Base 1 (conteo por Tipo × Zona).

tabla_verif1 <- as.data.frame.matrix(table(base1$tipo, base1$zona))
kable(tabla_verif1) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
Apartamento 0 0 0 0 0
Casa 0 722 0 0 0

Todas las demás combinaciones de tipo/zona están en cero: la totalidad de base1 corresponde a Casa × Zona Norte, lo que confirma que el filtro se aplicó correctamente.

Figura (interactiva). Ubicación geográfica de las propiedades de la Base 1, coloreadas por estrato.

plot_ly(base1, x = ~longitud, y = ~latitud, type = "scatter", mode = "markers",
        color = ~estrato, colors = colorRamp(c("#8DC5CC", col_primario)),
        marker = list(size = 7, opacity = 0.7),
        text = ~paste("Barrio:", barrio, "<br>Precio:", preciom, "M$<br>Estrato:", estrato),
        hoverinfo = "text") %>%
  layout(title = "Ubicación geográfica — Base 1 (Casas, Zona Norte)",
         xaxis = list(title = "Longitud"), yaxis = list(title = "Latitud"))

Como vimos en la sección de Datos, las zonas se traslapan en coordenadas geográficas, así que el mapa puede mostrar puntos de base1 cerca de coordenadas que también aparecen en otras zonas. Eso no significa que el filtro esté mal: zona es la etiqueta que reportó quien publicó el aviso, la variable de negocio relevante para segmentar la oferta, no un polígono geográfico estricto. Por eso seguimos usándola como criterio de filtro, tal como lo pide el enunciado.

5.2 Análisis exploratorio de los datos

(Rúbrica: Criterio 2 — Realiza análisis exploratorio de los datos)

Dentro de base1, zona es constante (todo es Zona Norte) y por tanto no aporta información de correlación; el análisis de correlación se hace sobre areaconst, estrato, habitaciones, parqueaderos y banios.

corr1 <- round(cor(base1[, c("preciom", vars_modelo)], use = "pairwise.complete.obs"), 3)

Figura (interactiva). Matriz de correlación — Base 1.

plot_ly(x = colnames(corr1), y = colnames(corr1), z = corr1, type = "heatmap",
        colors = colorRamp(c("#B2182B", "white", col_primario)), zmin = -1, zmax = 1) %>%
  layout(title = "Matriz de correlación — Base 1")

El coeficiente de correlación de preciom con areaconst es 0.731, con estrato es 0.612, y con banios es 0.523: son las tres relaciones más fuertes con el precio. habitaciones (0.323) y parqueaderos (0.317) se quedan más atrás.

Figura (interactiva). Precio vs. área construida, coloreado por estrato — Base 1.

plot_ly(base1, x = ~areaconst, y = ~preciom, color = ~factor(estrato), colors = pal_cat,
        type = "scatter", mode = "markers",
        text = ~paste("Baños:", banios, "<br>Habitaciones:", habitaciones), hoverinfo = "text") %>%
  layout(title = "Precio vs. área construida (color = estrato) — Base 1",
         xaxis = list(title = "Área construida (m²)"), yaxis = list(title = "Precio (millones $)"))

Figura (interactiva). Precio según número de habitaciones — Base 1.

plot_ly(base1, x = ~factor(habitaciones), y = ~preciom, type = "box",
        marker = list(color = col_primario)) %>%
  layout(title = "Precio según número de habitaciones — Base 1",
         xaxis = list(title = "N° habitaciones"), yaxis = list(title = "Precio (millones $)"))

El precio crece con el área construida dentro de cada nivel de estrato, y los puntos de estrato más alto se ubican sistemáticamente por encima de los de estrato bajo para una misma área, algo que ya se veía venir por la correlación positiva. El número de habitaciones por sí solo no ordena tan bien el precio: viviendas con más habitaciones no son necesariamente más caras, lo cual tiene sentido si esas habitaciones son más pequeñas o si el área total no crece en la misma proporción.

5.3 Estimación e interpretación del modelo

(Rúbrica: Criterio 3 — Estima e interpreta el modelo)

m1 <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios, data = base1)
summary(m1)
## 
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos + 
##     banios, data = base1)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -964.04  -80.10  -17.08   50.06 1069.45 
## 
## Coefficients:
##                Estimate Std. Error t value             Pr(>|t|)    
## (Intercept)  -236.47551   30.36582  -7.788    0.000000000000024 ***
## areaconst       0.82677    0.04368  18.926 < 0.0000000000000002 ***
## estrato        86.42579    7.39747  11.683 < 0.0000000000000002 ***
## habitaciones    1.44443    4.16411   0.347                0.729    
## parqueaderos   -1.67672    4.31505  -0.389                0.698    
## banios         26.97978    5.34384   5.049    0.000000564653292 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 159.1 on 716 degrees of freedom
## Multiple R-squared:  0.6508, Adjusted R-squared:  0.6484 
## F-statistic: 266.9 on 5 and 716 DF,  p-value: < 0.00000000000000022

La ecuación estimada es:

\[\widehat{preciom} = -236.48 + 0.827\cdot areaconst + 86.426\cdot estrato + 1.444\cdot habitaciones - 1.677\cdot parqueaderos + 26.980\cdot banios\]

coefs1 <- summary(m1)$coefficients

Interpretando cada coeficiente (manteniendo las demás variables constantes):

  • Área construida: cada metro cuadrado adicional cambia el precio esperado en 0.827 millones de pesos, un efecto estadísticamente significativo (p = 0). Tiene sentido que sea el factor con más peso: ya era la variable más correlacionada con el precio.
  • Estrato: un estrato adicional se asocia con un cambio esperado de 86.43 millones en el precio, efecto estadísticamente significativo (p = 0). El estrato socioeconómico suele marcar directamente el valor comercial de una zona, así que el resultado no sorprende.
  • Habitaciones: cada habitación adicional cambia el precio esperado en 1.44 millones; el efecto no resulta estadísticamente significativo (p = 0.729). El EDA ya lo insinuaba: el número de habitaciones por sí solo no explica bien el precio una vez se controla por el área construida, que probablemente ya recoge ese efecto.
  • Parqueaderos: cada parqueadero adicional cambia el precio esperado en -1.68 millones; el efecto no es estadísticamente significativo (p = 0.698).
  • Baños: cada baño adicional cambia el precio esperado en 26.98 millones, efecto estadísticamente significativo (p = 0.0000006). Probablemente porque el número de baños suele venir de la mano de mejores acabados y comodidades.

El modelo obtiene un \(R^2=\) 0.651 (\(R^2\) ajustado = 0.648); estas cinco variables explican 65.1% de la variabilidad del precio de las casas de la Zona Norte. Sin tocar el modelo que pide el enunciado, este ajuste podría mejorar de varias formas: agregando variables que quedaron por fuera, como el barrio o la antigüedad de la propiedad; probando una transformación logarítmica de preciom, dada la asimetría que vimos en la sección de Datos; o incluyendo interacciones como areaconst:estrato, ya que el efecto del área parece cambiar según el estrato en la figura de dispersión anterior.

5.4 Validación de los supuestos del modelo

(Rúbrica: Criterio 5 — Valida los supuestos del modelo)

Figura. Gráficos de diagnóstico del modelo — Base 1.

par(mfrow = c(2, 2)); plot(m1); par(mfrow = c(1, 1))

Tabla. Validación de supuestos — Base 1.

sup1 <- resumen_supuestos(m1)
kable(sup1$tabla, col.names = c("Supuesto", "Estadístico", "p-valor")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Supuesto Estadístico p-valor
Normalidad (Shapiro-Wilk) 0.838 0.0000000
Homocedasticidad (Goldfeld-Quandt) 7.458 0.0000000
Homocedasticidad (Breusch-Pagan) 139.004 0.0000000
Independencia (Durbin-Watson) 1.633 0.0000003
Multicolinealidad (VIF máximo) 1.890 NA

La prueba de Shapiro-Wilk se aplicó sobre los 722 residuales del modelo y arrojó un valor-p de 0, por lo que se rechaza el supuesto de normalidad. La prueba de Goldfeld-Quandt (p = 0) y la de Breusch-Pagan (p = 0) coinciden en que hay evidencia de heterocedasticidad en al menos una de las dos pruebas. La prueba de Durbin-Watson (p = 0.0000003) indica que hay evidencia de autocorrelación en los residuales. El VIF máximo entre las predictoras es 1.89, sin evidencia de multicolinealidad relevante.

Tal como pide el enunciado, no se corrige el modelo; en caso de que alguno de estos supuestos no se cumpla plenamente, se podría: transformar preciom con logaritmo si la heterocedasticidad está asociada a la asimetría de precios; usar errores estándar robustos (HC) para una inferencia válida bajo heterocedasticidad; revisar las observaciones influyentes (ver Anexos) que puedan estar distorsionando la normalidad de los residuales; o, si el VIF es alto, prescindir de una de las variables redundantes o aplicar regularización (Ridge/Lasso).

5.5 Estimación puntual y su interpretación

(Rúbrica: Criterio 4 — Realiza estimación puntual y su interpretación)

La solicitud pide un estrato “4 o 5”; se predice para ambos escenarios, manteniendo las demás características exactas de la solicitud (área 200 m², 4 habitaciones, 1 parqueadero, 2 baños).

Tabla. Verificación de extrapolación — características solicitadas vs. rango observado en Base 1.

x0_e4 <- data.frame(areaconst = 200, estrato = 4, habitaciones = 4, parqueaderos = 1, banios = 2)
x0_e5 <- data.frame(areaconst = 200, estrato = 5, habitaciones = 4, parqueaderos = 1, banios = 2)

kable(chequeo_rango(base1, x0_e4)) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Variable Solicitado Min_observado Max_observado Dentro_de_rango
areaconst 200 30 1440
estrato 4 3 6
habitaciones 4 0 10
parqueaderos 1 0 10
banios 2 0 10

Tabla. Predicción del precio de la Vivienda 1 (intervalo de predicción al 95%).

pred1_e4 <- predict(m1, newdata = x0_e4, interval = "prediction")
pred1_e5 <- predict(m1, newdata = x0_e5, interval = "prediction")

tabla_pred1 <- data.frame(
  Estrato = c(4, 5),
  Estimado = c(pred1_e4[1, "fit"], pred1_e5[1, "fit"]),
  Lim_inferior = c(pred1_e4[1, "lwr"], pred1_e5[1, "lwr"]),
  Lim_superior = c(pred1_e4[1, "upr"], pred1_e5[1, "upr"])
) %>% mutate(across(where(is.numeric), ~round(., 1)))

kable(tabla_pred1, col.names = c("Estrato", "Precio estimado (millones)", "Lím. inf. 95% (millones)", "Lím. sup. 95% (millones)")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Estrato Precio estimado (millones) Lím. inf. 95% (millones) Lím. sup. 95% (millones)
4 332.6 19.7 645.6
5 419.1 105.6 732.5
interp1_e4 <- if (pred1_e4[1, "upr"] <= 350) {
  "cubre holgadamente todo el intervalo de predicción"
} else {
  paste0("cubre la estimación puntual, aunque el límite superior del intervalo de predicción (",
         round(pred1_e4[1, "upr"], 1), " M) supera el presupuesto, por lo que conviene apoyarse en las ofertas concretas de la siguiente sección en vez de confiar solo en el punto estimado")
}
interp1_e5 <- ifelse(pred1_e5[1, "fit"] <= 350, "dentro del presupuesto", "por encima del presupuesto de 350 millones ya en la estimación puntual")

Para estrato 4, el precio puntual estimado es 332.6 millones, y el crédito de $350 millones cubre la estimación puntual, aunque el límite superior del intervalo de predicción (645.6 M) supera el presupuesto, por lo que conviene apoyarse en las ofertas concretas de la siguiente sección en vez de confiar solo en el punto estimado. Para estrato 5, el precio puntual estimado es 419.1 millones, por encima del presupuesto de 350 millones ya en la estimación puntual. En ambos casos, todas las características solicitadas están dentro del rango observado en base1 (tabla anterior), por lo que no hay riesgo relevante de extrapolación.

5.6 Ofertas potenciales para la Vivienda 1

base1 <- base1 %>%
  mutate(precio_modelo = predict(m1, newdata = base1),
         brecha = precio_modelo - preciom)  # positivo = el modelo la valora por encima de lo que cuesta (subvalorada)

ofertas1 <- base1 %>%
  filter(preciom <= 350, estrato %in% c(4, 5), areaconst >= 200,
         habitaciones >= 3, banios >= 2, brecha > 0) %>%
  arrange(desc(brecha))

Se predice el precio de cada propiedad de base1 con el modelo m1 y se calcula la brecha entre ese precio estimado y el precio real de lista: una brecha positiva indica que la propiedad está subvalorada respecto a lo que el modelo esperaría para esas características, es decir, una oportunidad de compra. Entre las propiedades que cumplen el presupuesto y las condiciones de la solicitud, se identificaron 44 ofertas potenciales . Se presentan las 5 con mayor brecha positiva:

Tabla. Ofertas potenciales recomendadas — Vivienda 1.

top1 <- head(ofertas1, min(5, nrow(ofertas1))) %>%
  select(id, barrio, estrato, preciom, precio_modelo, brecha, areaconst, parqueaderos, banios, habitaciones) %>%
  mutate(across(c(precio_modelo, brecha), ~round(., 1)))

kable(top1, col.names = c("ID","Barrio","Estrato","Precio lista (millones)","Precio modelo (millones)","Brecha (millones)",
                            "Área (m²)","Parq.","Baños","Habs.")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE)
ID Barrio Estrato Precio lista (millones) Precio modelo (millones) Brecha (millones) Área (m²) Parq. Baños Habs.
3101 san vicente 5 340 632.3 292.3 355 2 5 8
1009 el bosque 5 250 510.0 260.0 243 1 4 5
4209 el bosque 5 350 582.2 232.2 300 3 5 6
766 la merced 5 321 542.0 221.0 249 1 5 5
1914 vipasa 5 300 505.4 205.4 205 2 5 6

Figura (interactiva). Mapa de las ofertas potenciales recomendadas — Vivienda 1.

top1_mapa <- head(ofertas1, min(8, nrow(ofertas1)))
plot_ly(top1_mapa, x = ~longitud, y = ~latitud, type = "scatter", mode = "markers+text",
        text = ~barrio, textposition = "top center",
        marker = list(size = 12, color = "#27ae60"),
        hovertext = ~paste("Barrio:", barrio, "<br>Precio lista:", preciom, "M$<br>Precio modelo:",
                             round(precio_modelo,1), "M$"), hoverinfo = "text") %>%
  layout(title = "Ofertas potenciales — Vivienda 1",
         xaxis = list(title = "Longitud"), yaxis = list(title = "Latitud"))

Estas propiedades cumplen el presupuesto de $350 millones y las condiciones mínimas de la solicitud, y el modelo además las valora por encima de su precio de lista, lo que las convierte en una buena recomendación para María.


6 Caso 2 — Vivienda 2: Apartamento, Zona Sur (crédito hasta $850 millones)

(Rúbrica: Criterio 6 — Replica el ejercicio con aptos del sur)

Esta sección repite íntegramente la estructura del Caso 1 (Secciones 5.1 a 5.5) para la segunda solicitud.

6.1 Filtro y depuración de la base

base2 <- vivienda_c %>% filter(tipo == "Apartamento", zona == "Zona Sur")

La base 2 (base2) queda con 2787 registros, bastante más que base1. Tiene sentido: la Zona Sur y los apartamentos concentran la mayor parte de la oferta general de la ciudad, como ya se veía en el panorama de la sección de Datos. Los primeros tres registros:

Tabla. Primeros tres registros de la Base 2.

kable(head(base2[, c("id","zona","estrato","preciom","areaconst","parqueaderos","banios","habitaciones","tipo","barrio")], 3)) %>%
  kable_styling(bootstrap_options = c("striped", "hover"))
id zona estrato preciom areaconst parqueaderos banios habitaciones tipo barrio
5098 Zona Sur 4 290 96 1 2 3 Apartamento acopi
698 Zona Sur 3 78 40 1 1 2 Apartamento aguablanca
8199 Zona Sur 6 875 194 2 5 3 Apartamento aguacatal

Tabla. Verificación del filtro — Base 2 (conteo por Tipo × Zona).

tabla_verif2 <- as.data.frame.matrix(table(base2$tipo, base2$zona))
kable(tabla_verif2) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
Apartamento 0 0 0 0 2787
Casa 0 0 0 0 0

Al igual que en la Base 1, todas las demás combinaciones están en cero: la totalidad de base2 corresponde a Apartamento × Zona Sur.

Figura (interactiva). Ubicación geográfica de las propiedades de la Base 2, coloreadas por estrato.

plot_ly(base2, x = ~longitud, y = ~latitud, type = "scatter", mode = "markers",
        color = ~estrato, colors = colorRamp(c("#8DC5CC", col_primario)),
        marker = list(size = 6, opacity = 0.6),
        text = ~paste("Barrio:", barrio, "<br>Precio:", preciom, "M$<br>Estrato:", estrato),
        hoverinfo = "text") %>%
  layout(title = "Ubicación geográfica — Base 2 (Apartamentos, Zona Sur)",
         xaxis = list(title = "Longitud"), yaxis = list(title = "Latitud"))

Igual que en la Base 1, el traslape de coordenadas entre zonas explica que algunos puntos de base2 caigan cerca de coordenadas que también aparecen en otras zonas. Se mantiene zona como criterio de filtro porque es la clasificación de negocio relevante, no una frontera geográfica exacta.

6.2 Análisis exploratorio de los datos

corr2 <- round(cor(base2[, c("preciom", vars_modelo)], use = "pairwise.complete.obs"), 3)

Figura (interactiva). Matriz de correlación — Base 2.

plot_ly(x = colnames(corr2), y = colnames(corr2), z = corr2, type = "heatmap",
        colors = colorRamp(c("#B2182B", "white", col_primario)), zmin = -1, zmax = 1) %>%
  layout(title = "Matriz de correlación — Base 2")

En la Base 2, el coeficiente de correlación de preciom con areaconst es 0.758, con estrato es 0.673, y con banios es 0.72; habitaciones (0.332) y parqueaderos (0.675) muestran de nuevo una asociación más débil.

Figura (interactiva). Precio vs. área construida, coloreado por estrato — Base 2.

plot_ly(base2, x = ~areaconst, y = ~preciom, color = ~factor(estrato), colors = pal_cat,
        type = "scatter", mode = "markers",
        text = ~paste("Baños:", banios, "<br>Habitaciones:", habitaciones), hoverinfo = "text") %>%
  layout(title = "Precio vs. área construida (color = estrato) — Base 2",
         xaxis = list(title = "Área construida (m²)"), yaxis = list(title = "Precio (millones $)"))

Figura (interactiva). Precio según número de habitaciones — Base 2.

plot_ly(base2, x = ~factor(habitaciones), y = ~preciom, type = "box",
        marker = list(color = col_primario)) %>%
  layout(title = "Precio según número de habitaciones — Base 2",
         xaxis = list(title = "N° habitaciones"), yaxis = list(title = "Precio (millones $)"))

El patrón es análogo al de la Base 1: el precio crece con el área dentro de cada estrato, y el número de habitaciones por sí solo no ordena claramente el precio.

6.3 Estimación e interpretación del modelo

m2 <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios, data = base2)
summary(m2)
## 
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos + 
##     banios, data = base2)
## 
## Residuals:
##      Min       1Q   Median       3Q      Max 
## -1252.31   -42.15    -2.06    36.32   934.06 
## 
## Coefficients:
##                Estimate Std. Error t value             Pr(>|t|)    
## (Intercept)  -221.04614   13.47771 -16.401 < 0.0000000000000002 ***
## areaconst       1.46061    0.04876  29.956 < 0.0000000000000002 ***
## estrato        57.00608    2.79648  20.385 < 0.0000000000000002 ***
## habitaciones  -22.71789    3.39549  -6.691      0.0000000000268 ***
## parqueaderos   48.36353    3.02343  15.996 < 0.0000000000000002 ***
## banios         48.60871    3.04050  15.987 < 0.0000000000000002 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 95.17 on 2781 degrees of freedom
## Multiple R-squared:  0.7536, Adjusted R-squared:  0.7531 
## F-statistic:  1701 on 5 and 2781 DF,  p-value: < 0.00000000000000022

La ecuación estimada es:

\[\widehat{preciom} = -221.05 + 1.461\cdot areaconst + 57.006\cdot estrato - 22.718\cdot habitaciones + 48.364\cdot parqueaderos + 48.609\cdot banios\]

coefs2 <- summary(m2)$coefficients
  • Área construida: cada metro cuadrado adicional cambia el precio esperado en 1.461 millones, efecto estadísticamente significativo (p = 0).
  • Estrato: un estrato adicional se asocia con un cambio esperado de 57.01 millones, efecto estadísticamente significativo (p = 0).
  • Habitaciones: cada habitación adicional cambia el precio esperado en -22.72 millones; efecto estadísticamente significativo (p = 0).
  • Parqueaderos: cada parqueadero adicional cambia el precio esperado en 48.36 millones; efecto estadísticamente significativo (p = 0). En un edificio de apartamentos el parqueadero suele venderse casi como una unidad aparte, así que su efecto sobre el precio del apartamento puede no comportarse igual que en las casas de la Base 1.
  • Baños: cada baño adicional cambia el precio esperado en 48.61 millones, efecto estadísticamente significativo (p = 0).

El modelo obtiene un \(R^2=\) 0.754 (\(R^2\) ajustado = 0.753): estas cinco variables explican 75.4% de la variabilidad del precio de los apartamentos de la Zona Sur. Es un ajuste parecido o mejor que el de la Base 1. Como en el Caso 1, este ajuste podría mejorar (sin tocar el modelo pedido) incorporando barrio o piso, probando log(preciom), o incluyendo la interacción areaconst:estrato.

6.4 Validación de los supuestos del modelo

Figura. Gráficos de diagnóstico del modelo — Base 2.

par(mfrow = c(2, 2)); plot(m2); par(mfrow = c(1, 1))

Tabla. Validación de supuestos — Base 2.

sup2 <- resumen_supuestos(m2)
kable(sup2$tabla, col.names = c("Supuesto", "Estadístico", "p-valor")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Supuesto Estadístico p-valor
Normalidad (Shapiro-Wilk) 0.782 0
Homocedasticidad (Goldfeld-Quandt) 15.945 0
Homocedasticidad (Breusch-Pagan) 956.830 0
Independencia (Durbin-Watson) 1.504 0
Multicolinealidad (VIF máximo) 2.480 NA

La prueba de Shapiro-Wilk se aplicó sobre los 2787 residuales del modelo y arrojó un valor-p de 0, por lo que se rechaza el supuesto de normalidad. Goldfeld-Quandt (p = 0) y Breusch-Pagan (p = 0) coinciden en que hay evidencia de heterocedasticidad en al menos una de las dos pruebas. Durbin-Watson (p = 0) indica que hay evidencia de autocorrelación. El VIF máximo es 2.48, sin evidencia de multicolinealidad relevante. Con 2787 observaciones, es esperable que las pruebas formales rechacen con mayor facilidad desviaciones pequeñas de los supuestos, por lo que conviene dar más peso a los gráficos de diagnóstico que al valor-p aislado. Las mismas sugerencias del Caso 1 (transformación logarítmica, errores robustos, revisar influyentes, regularización) aplican aquí sin modificar el modelo actual.

6.5 Estimación puntual y su interpretación

La solicitud pide estrato “5 o 6”; se predice para ambos escenarios, con las características exactas de la solicitud (área 300 m², 5 habitaciones, 3 parqueaderos, 3 baños).

Tabla. Verificación de extrapolación — características solicitadas vs. rango observado en Base 2.

x0b_e5 <- data.frame(areaconst = 300, estrato = 5, habitaciones = 5, parqueaderos = 3, banios = 3)
x0b_e6 <- data.frame(areaconst = 300, estrato = 6, habitaciones = 5, parqueaderos = 3, banios = 3)

kable(chequeo_rango(base2, x0b_e5)) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Variable Solicitado Min_observado Max_observado Dentro_de_rango
areaconst 300 40 932
estrato 5 3 6
habitaciones 5 0 6
parqueaderos 3 0 10
banios 3 0 8

Tabla. Predicción del precio de la Vivienda 2 (intervalo de predicción al 95%).

pred2_e5 <- predict(m2, newdata = x0b_e5, interval = "prediction")
pred2_e6 <- predict(m2, newdata = x0b_e6, interval = "prediction")

tabla_pred2 <- data.frame(
  Estrato = c(5, 6),
  Estimado = c(pred2_e5[1, "fit"], pred2_e6[1, "fit"]),
  Lim_inferior = c(pred2_e5[1, "lwr"], pred2_e6[1, "lwr"]),
  Lim_superior = c(pred2_e5[1, "upr"], pred2_e6[1, "upr"])
) %>% mutate(across(where(is.numeric), ~round(., 1)))

kable(tabla_pred2, col.names = c("Estrato", "Precio estimado (millones)", "Lím. inf. 95% (millones)", "Lím. sup. 95% (millones)")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Estrato Precio estimado (millones) Lím. inf. 95% (millones) Lím. sup. 95% (millones)
5 679.5 491.8 867.2
6 736.5 548.8 924.2

Para estrato 5, el precio puntual estimado es 679.5 millones; para estrato 6, 736.5 millones. El crédito de $850 millones cubre la estimación puntual en ambos escenarios, aunque el límite superior del intervalo de predicción para estrato 6 lo supera. Todas las características solicitadas están dentro del rango observado en base2 (tabla anterior), por lo que no hay riesgo relevante de extrapolación.

6.6 Ofertas potenciales para la Vivienda 2

base2 <- base2 %>%
  mutate(precio_modelo = predict(m2, newdata = base2),
         brecha = precio_modelo - preciom)

ofertas2 <- base2 %>%
  filter(preciom <= 850, estrato %in% c(5, 6), areaconst >= 300,
         habitaciones >= 4, banios >= 3, parqueaderos >= 2, brecha > 0) %>%
  arrange(desc(brecha))

Con la misma lógica del Caso 1 (brecha positiva = propiedad subvalorada según el modelo), se identificaron 6 ofertas potenciales para la Vivienda 2 . Se presentan las 5 con mayor brecha positiva:

Tabla. Ofertas potenciales recomendadas — Vivienda 2.

top2 <- head(ofertas2, min(5, nrow(ofertas2))) %>%
  select(id, barrio, estrato, preciom, precio_modelo, brecha, areaconst, parqueaderos, banios, habitaciones) %>%
  mutate(across(c(precio_modelo, brecha), ~round(., 1)))

kable(top2, col.names = c("ID","Barrio","Estrato","Precio lista (millones)","Precio modelo (millones)","Brecha (millones)",
                            "Área (m²)","Parq.","Baños","Habs.")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE)
ID Barrio Estrato Precio lista (millones) Precio modelo (millones) Brecha (millones) Área (m²) Parq. Baños Habs.
7182 guadalupe 5 730 1321.3 591.3 573 3 8 5
4952 el ingenio 5 650 1117.9 467.9 600 2 4 5
4394 el ingenio 5 690 974.1 284.1 486 2 4 4
6932 san fernando 5 500 746.3 246.3 330 2 4 4
7658 cuarto de legua 5 520 731.7 211.7 320 2 4 4

Figura (interactiva). Mapa de las ofertas potenciales recomendadas — Vivienda 2.

top2_mapa <- head(ofertas2, min(8, nrow(ofertas2)))
plot_ly(top2_mapa, x = ~longitud, y = ~latitud, type = "scatter", mode = "markers+text",
        text = ~barrio, textposition = "top center",
        marker = list(size = 12, color = "#27ae60"),
        hovertext = ~paste("Barrio:", barrio, "<br>Precio lista:", preciom, "M$<br>Precio modelo:",
                             round(precio_modelo,1), "M$"), hoverinfo = "text") %>%
  layout(title = "Ofertas potenciales — Vivienda 2",
         xaxis = list(title = "Longitud"), yaxis = list(title = "Latitud"))

7 Resultados y Recomendaciones

7.1 ¿Qué determina el precio en cada segmento?

En los dos segmentos, el área construida es la variable con mayor asociación con el precio (r = 0.731 en casas del norte, r = 0.758 en apartamentos del sur), seguida del estrato. El modelo de la Base 1 explica 65.1% de la variabilidad del precio y el de la Base 2, 75.4%. Ambos podrían mejorar agregando variables como barrio o piso, que dejamos por fuera porque el enunciado pide el modelo solo con estas cinco.

7.2 ¿Alcanza el crédito preaprobado?

Para la Vivienda 1, el precio estimado ronda entre 333 y 419 millones según el estrato (4 o 5), frente a un crédito de $350 millones. Para la Vivienda 2, el precio estimado ronda entre 679 y 737 millones según el estrato (5 o 6), frente a un crédito de $850 millones. Los intervalos de predicción (no solo el punto estimado) deben tenerse en cuenta al negociar, pues reflejan la variabilidad real de una vivienda individual, más amplia que la de un precio promedio.

7.3 ¿Qué ofertas concretas recomendar?

Se identificaron 44 ofertas para la Vivienda 1 y 6 para la Vivienda 2 que cumplen el presupuesto y las condiciones solicitadas, y que además el modelo valora por encima de su precio de lista (ver tablas y mapas de cada caso). Estas son el punto de partida recomendado para que los agentes de C&A contacten a los vendedores.

En la práctica, esto le da a María dos rutas de acción: para la Vivienda 1, conviene empezar por mostrar las opciones de estrato 4, que es la que mejor calza con el crédito de $350 millones; para la Vivienda 2, el crédito de $850 millones tiene margen en los dos estratos.

8 Anexos

Como soporte del informe ejecutivo, se incluyen aquí las estimaciones, validaciones y comparación de modelos que sustentan las secciones anteriores.

8.1 A.1 Comparación de modelos (prueba F parcial / SSextra)

comp1 <- comparar_modelos(m1)
if (length(comp1$no_signif) == 0) {
  cat("**Vivienda 1**: todos los coeficientes del modelo completo resultaron significativos al 5%; no existe una versión reducida que comparar, por lo que el modelo completo es también el más parsimonioso.")
} else {
  cat(paste0("**Tabla.** Vivienda 1 — Prueba F parcial: modelo reducido (sin ",
             paste(comp1$no_signif, collapse = ", "), ") vs. modelo completo.\n\n"))
  kable(as.data.frame(comp1$anova)) %>%
    kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
}

Tabla. Vivienda 1 — Prueba F parcial: modelo reducido (sin habitaciones, parqueaderos) vs. modelo completo.

Res.Df RSS Df Sum of Sq F Pr(>F)
718 18138894 NA NA NA NA
716 18132812 2 6081.449 0.1200673 0.8868786
comp2 <- comparar_modelos(m2)
if (length(comp2$no_signif) == 0) {
  cat("**Vivienda 2**: todos los coeficientes del modelo completo resultaron significativos al 5%; no existe una versión reducida que comparar.")
} else {
  cat(paste0("**Tabla.** Vivienda 2 — Prueba F parcial: modelo reducido (sin ",
             paste(comp2$no_signif, collapse = ", "), ") vs. modelo completo.\n\n"))
  kable(as.data.frame(comp2$anova)) %>%
    kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
}

Vivienda 2: todos los coeficientes del modelo completo resultaron significativos al 5%; no existe una versión reducida que comparar.

En ambos casos, la prueba F parcial (SSextra) contrasta si el subconjunto de variables no significativas aporta información conjunta relevante dado que las demás ya están en el modelo (Unidad 2, sesión de Inferencia sobre un Subconjunto de Parámetros): un valor-p mayor a 0.05 respalda simplificar el modelo eliminando esas variables sin pérdida relevante de ajuste.

8.2 A.2 Observaciones influenciales

Tabla. Observaciones influenciales detectadas por modelo.

inf1 <- diagnostico_influencia(m1)
inf2 <- diagnostico_influencia(m2)

data.frame(
  Modelo = c("Vivienda 1", "Vivienda 2"),
  `Obs. con Cook > 1` = c(inf1$n_cook_altos, inf2$n_cook_altos),
  `Obs. con DFFITS por encima del umbral` = c(inf1$n_dffits_altos, inf2$n_dffits_altos),
  check.names = FALSE
) %>%
  kable() %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Modelo Obs. con Cook > 1 Obs. con DFFITS por encima del umbral
Vivienda 1 0 48
Vivienda 2 2 150

Ninguno de los dos modelos muestra evidencia de que unas pocas observaciones dominen el ajuste de forma crítica (Distancia de Cook), lo cual es esperable dado el tamaño de ambas bases; cualquier observación señalada por DFFITS podría investigarse manualmente antes de confiar en las predicciones puntuales de la Sección de Estimación Puntual, sin que esto implique modificar el modelo actual.

8.3 A.3 Multicolinealidad ampliada (número de condición)

Tabla. Diagnóstico ampliado de multicolinealidad (Unidad 2, sesión de Diagnóstico de Multicolinealidad).

data.frame(
  Modelo = c("Vivienda 1", "Vivienda 2"),
  `VIF máximo` = c(round(sup1$vif_max, 2), round(sup2$vif_max, 2)),
  `Número de condición (raíz de kappa)` = c(round(numero_condicion(m1), 2), round(numero_condicion(m2), 2)),
  check.names = FALSE
) %>%
  kable() %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Modelo VIF máximo Número de condición (raíz de kappa)
Vivienda 1 1.89 224.56
Vivienda 2 2.48 113.82

Con el criterio \(\sqrt\kappa\leq10\) (sin problema), \(10<\sqrt\kappa\leq31.62\) (moderado) y \(\sqrt\kappa>31.62\) (grave): el modelo de Vivienda 1 muestra un número de condición grave, y el de Vivienda 2, grave.

Este resultado contrasta con el VIF, que no encontró problema en ninguno de los dos modelos (sección de Validación de Supuestos). La razón está en que el número de condición se calcula sobre las variables centradas pero no estandarizadas, así que es muy sensible a que las predictoras estén en escalas distintas (areaconst va de decenas a miles, mientras estrato, habitaciones, parqueaderos y banios van de 0 a 10), no a que estén correlacionadas entre sí. El VIF sí controla por escala, porque se basa en el \(R^2\) de regresar cada predictora sobre las demás, así que es el diagnóstico más confiable aquí: no hay evidencia real de multicolinealidad entre las cinco variables del modelo.

8.4 A.4 Validación cruzada (70% estimación / 30% validación)

Tabla. Validación cruzada simple (Unidad 2, sesión de Validación Cruzada).

formula_modelo <- preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios
cv1 <- validacion_cruzada(formula_modelo, base1, semilla = 123)
cv2 <- validacion_cruzada(formula_modelo, base2, semilla = 123)

data.frame(
  Modelo = c("Vivienda 1", "Vivienda 2"),
  `R2 (toda la muestra)` = round(c(cv1$R2_total, cv2$R2_total), 3),
  `r2 en validación` = round(c(cv1$r2_validacion, cv2$r2_validacion), 3),
  Encogimiento = round(c(cv1$encogimiento, cv2$encogimiento), 3),
  check.names = FALSE
) %>%
  kable() %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Modelo R2 (toda la muestra) r2 en validación Encogimiento
Vivienda 1 0.651 0.610 0.041
Vivienda 2 0.754 0.756 -0.002

Un encogimiento pequeño (diferencia entre el \(R^2\) con toda la muestra y el \(r^2\) en la muestra de validación) respalda que el modelo generaliza razonablemente a datos que no participaron en su estimación, en vez de estar sobreajustado a las particularidades de la muestra completa.

8.5 A.5 Información de sesión

sessionInfo()
## R version 4.6.1 (2026-06-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26100)
## 
## Matrix products: default
##   LAPACK version 3.12.1
## 
## locale:
## [1] LC_COLLATE=Spanish_Colombia.utf8  LC_CTYPE=Spanish_Colombia.utf8   
## [3] LC_MONETARY=Spanish_Colombia.utf8 LC_NUMERIC=C                     
## [5] LC_TIME=Spanish_Colombia.utf8    
## 
## time zone: America/Bogota
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] lmtest_0.9-40        zoo_1.9-0            car_3.1-5           
##  [4] carData_3.0-6        kableExtra_1.4.1     plotly_4.12.1       
##  [7] dplyr_1.2.1          paqueteMODELOS_0.1.0 summarytools_1.1.5  
## [10] knitr_1.52           gridExtra_2.3.1      GGally_2.4.0        
## [13] ggplot2_4.0.3        broom_1.0.13         boot_1.3-32         
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.6        xfun_0.60           bslib_0.12.0       
##  [4] htmlwidgets_1.6.4   lattice_0.22-9      crosstalk_1.2.2    
##  [7] vctrs_0.7.3         tools_4.6.1         generics_0.1.4     
## [10] tibble_3.3.1        pkgconfig_2.0.3     data.table_1.18.6.1
## [13] checkmate_2.3.4     RColorBrewer_1.1-3  S7_0.2.2           
## [16] lifecycle_1.0.5     compiler_4.6.1      farver_2.1.2       
## [19] stringr_1.6.0       textshaping_1.0.5   rapportools_1.2    
## [22] htmltools_0.5.9     sass_0.4.10         yaml_2.3.12        
## [25] Formula_1.2-6       pillar_1.11.1       jquerylib_0.1.4    
## [28] tidyr_1.3.2         MASS_7.3-65         cachem_1.1.0       
## [31] magick_2.9.1        abind_1.4-8         ggstats_0.14.0     
## [34] tidyselect_1.2.1    digest_0.6.39       stringi_1.8.9      
## [37] reshape2_1.4.5      purrr_1.2.2         pander_0.6.6       
## [40] bookdown_0.48       labeling_0.4.3      fastmap_1.2.0      
## [43] grid_4.6.1          cli_3.6.6           magrittr_2.0.5     
## [46] base64enc_0.1-6     withr_3.0.3         scales_1.4.0       
## [49] backports_1.5.1     lubridate_1.9.5     timechange_0.4.0   
## [52] rmarkdown_2.32      httr_1.4.9          matrixStats_1.5.0  
## [55] otel_0.2.0          evaluate_1.0.5      tcltk_4.6.1        
## [58] viridisLite_0.4.3   rlang_1.3.0         Rcpp_1.1.2         
## [61] glue_1.8.1          xml2_1.6.0          svglite_2.2.2      
## [64] rstudioapi_0.19.0   jsonlite_2.0.0      R6_2.6.1           
## [67] plyr_1.8.9          systemfonts_1.3.2