1 Resumen ejecutivo

C&A necesita ubicar vivienda para dos empleados de un cliente corporativo: una casa en el norte de Cali (200 m², 4 habitaciones, 2 baños, 1 parqueadero, estrato 4 o 5, crédito preaprobado de $350 millones) y un apartamento en el sur (300 m², 5 habitaciones, 3 baños, 3 parqueaderos, estrato 5 o 6, crédito de $850 millones). Este informe estima un precio de referencia justo para cada perfil y propone propiedades concretas dentro del inventario disponible.

Se depuró una base de 8322 transacciones de vivienda en Cali hasta dejar 8,215 registros útiles. Como el precio se comporta de forma distinta según la zona y el tipo de inmueble, se construyeron dos modelos de regresión lineal múltiple independientes en lugar de uno solo: uno para casas del norte (n = 698) y otro para apartamentos del sur (n = 2,776).

En ambos modelos, el área construida y el estrato son los factores que más explican el precio. Las diferencias interesantes aparecen en el resto: en apartamentos del sur, más parqueaderos y más baños suben el precio de forma clara, pero más habitaciones —a igual área— lo baja, señal de que el mercado valora más los espacios amplios que la cantidad de cuartos. En casas del norte, en cambio, ni las habitaciones ni los parqueaderos resultan predictores confiables una vez se controla por área, estrato y baños. El modelo de casas explica 67% de la variación del precio; el de apartamentos, 76%.

Para elegir entre un modelo simple, el modelo completo y una versión logarítmica del precio, el criterio decisivo fue el error de predicción en pesos (no el R²): el modelo completo en escala original predice mejor en la práctica y es más fácil de interpretar, así que es el que se usa en todo el informe.

Solicitud Escenario Precio estimado Intervalo de predicción (95%) Presupuesto
1. Casa, zona norte Estrato 4 $328 M $27 M – $630 M $350 M
1. Casa, zona norte Estrato 5 $408 M $106 M – $711 M $350 M
2. Apartamento, zona sur Estrato 5 $690 M $505 M – $875 M $850 M
2. Apartamento, zona sur Estrato 6 $746 M $561 M – $931 M $850 M

La recomendación es clara en ambos casos. Para la casa del norte, solo el estrato 4 cabe dentro del presupuesto; el estrato 5 lo excede en cerca de $58 M. Para el apartamento del sur, ambos estratos son viables, con margen cómodo de presupuesto — suficiente para inclinarse por estrato 6 si el cliente prioriza ubicación y acabados. Se identificaron 110 y 1,436 propiedades reales dentro de presupuesto para cada solicitud, respectivamente, de las cuales se seleccionan al menos cinco por caso más adelante en el informe.


2 Contexto y objetivo

C&A es una agencia inmobiliaria en Cali que apoya a una empresa internacional en la búsqueda de vivienda para sus empleados. Cuenta con un histórico de transacciones (zona, tipo, estrato, área, habitaciones, baños, parqueaderos, barrio, coordenadas y precio), pero no con una forma sistemática de convertir ese histórico en un precio de referencia y una lista corta de alternativas para un perfil concreto.

Para cada una de las dos solicitudes, C&A necesita responder dos preguntas: ¿cuál es un precio razonable para el inmueble solicitado, dado el crédito preaprobado del cliente?, y ¿qué propiedades del inventario disponible se ajustan mejor a ese perfil y ese presupuesto?

El objetivo estadístico de este informe es estimar, mediante regresión lineal múltiple, la relación entre el precio de la vivienda y sus características físicas —área, estrato, habitaciones, baños y parqueaderos— de forma específica para cada zona y tipo de inmueble, y usar esa relación para predecir precios y evaluar alternativas.

3 Datos y depuración

La base vivienda, distribuida en el paquete paqueteMODELOS del curso, contiene 8322 registros de transacciones de vivienda en Cali.

glimpse(vivienda)
## Rows: 8,322
## Columns: 13
## $ id           <dbl> 1147, 1169, 1350, 5992, 1212, 1724, 2326, 4386, 1209, 159…
## $ zona         <chr> "Zona Oriente", "Zona Oriente", "Zona Oriente", "Zona Sur…
## $ piso         <chr> NA, NA, NA, "02", "01", "01", "01", "01", "02", "02", "02…
## $ estrato      <dbl> 3, 3, 3, 4, 5, 5, 4, 5, 5, 5, 6, 4, 5, 6, 4, 5, 5, 4, 5, …
## $ preciom      <dbl> 250, 320, 350, 400, 260, 240, 220, 310, 320, 780, 750, 62…
## $ areaconst    <dbl> 70, 120, 220, 280, 90, 87, 52, 137, 150, 380, 445, 355, 2…
## $ parqueaderos <dbl> 1, 1, 2, 3, 1, 1, 2, 2, 2, 2, NA, 3, 2, 2, 1, 4, 2, 2, 2,…
## $ banios       <dbl> 3, 2, 2, 5, 2, 3, 2, 3, 4, 3, 7, 5, 6, 2, 4, 4, 4, 3, 2, …
## $ habitaciones <dbl> 6, 3, 4, 3, 3, 3, 3, 4, 6, 3, 6, 5, 6, 2, 5, 5, 4, 3, 3, …
## $ tipo         <chr> "Casa", "Casa", "Casa", "Casa", "Apartamento", "Apartamen…
## $ barrio       <chr> "20 de julio", "20 de julio", "20 de julio", "3 de julio"…
## $ longitud     <dbl> -76.51168, -76.51237, -76.51537, -76.54000, -76.51350, -7…
## $ latitud      <dbl> 3.43382, 3.43369, 3.43566, 3.43500, 3.45891, 3.36971, 3.4…
vars_num <- c("preciom", "areaconst", "estrato", "habitaciones", "banios", "parqueaderos")
data.frame(
  Variable = vars_num,
  `% NA` = sapply(vars_num, function(v) round(100 * mean(is.na(vivienda[[v]])), 1)),
  Mínimo  = sapply(vars_num, function(v) min(vivienda[[v]], na.rm = TRUE)),
  Media   = sapply(vars_num, function(v) round(mean(vivienda[[v]], na.rm = TRUE), 1)),
  Máximo  = sapply(vars_num, function(v) max(vivienda[[v]], na.rm = TRUE)),
  check.names = FALSE
) %>% tabla()
Variable % NA Mínimo Media Máximo
preciom preciom 0.0 58 433.9 1,999
areaconst areaconst 0.0 30 174.9 1,745
estrato estrato 0.0 3 4.6 6
habitaciones habitaciones 0.0 0 3.6 10
banios banios 0.0 0 3.1 10
parqueaderos parqueaderos 19.3 1 1.8 10

Antes de modelar, revisamos qué tan confiable es esta base. Encontramos seis problemas concretos:

  1. 3 filas completamente vacías — probablemente residuos del archivo de origen.
  2. parqueaderos falta en 19% de los registros, y piso en 32%. El resto de variables casi no tiene datos faltantes.
  3. Ningún registro duplicado con contenido real (las únicas filas idénticas son las 3 vacías).
  4. habitaciones = 0 o banios = 0 en 66 y 45 casos — implausible para una vivienda habitable.
  5. Precios por m² físicamente inconsistentes en algunos registros: casas de más de 1,300 m² vendidas por menos de $150,000/m², muy por debajo de cualquier costo de construcción razonable en Cali.
  6. barrio con formato inconsistente (mayúsculas y acentos mal codificados). No afecta el modelo porque barrio no es una variable explicativa, pero limita un análisis de precio por barrio sin limpieza adicional.

Con esto, tomamos cinco decisiones de depuración:

  • Eliminar las filas vacías y cualquier registro sin dato en las variables que usará el modelo (zona, tipo, estrato, preciom, areaconst, habitaciones, banios).
  • Imputar parqueaderos = 0 cuando falta el dato. Los valores que sí están registrados van de 1 a 10 — nunca 0 — lo que sugiere que un anuncio sin parqueadero simplemente no diligencia el campo, en vez de escribir “0”. Es un supuesto razonable pero no verificado; se preservan así 1,605 registros que de otro modo se perderían.
  • Excluir piso del modelo. Con un tercio de datos faltantes y sin estar entre las variables que pide el caso, no compensa el riesgo de sesgo por imputación.
  • Eliminar los registros con cero habitaciones o cero baños (1.3% del total).
  • Eliminar los registros cuyo precio por m² cae fuera de $300,000–$6,500,000 COP/m², un rango amplio pero realista para el mercado de Cali (0.3% del total).
n0 <- nrow(vivienda)

vivienda_limpia <- vivienda %>%
  filter(!is.na(zona), !is.na(tipo), !is.na(estrato), !is.na(preciom),
         !is.na(areaconst), !is.na(habitaciones), !is.na(banios)) %>%
  mutate(parqueaderos = ifelse(is.na(parqueaderos), 0, parqueaderos)) %>%
  filter(habitaciones > 0, banios > 0, areaconst > 0, preciom > 0) %>%
  mutate(precio_m2 = preciom * 1e6 / areaconst) %>%
  filter(precio_m2 >= 300000, precio_m2 <= 6500000)

n1 <- nrow(vivienda_limpia)
cat("Registros iniciales:", n0, "| Tras depuración:", n1,
    "| Removido:", round(100*(n0-n1)/n0, 2), "%\n")
## Registros iniciales: 8322 | Tras depuración: 8215 | Removido: 1.29 %

La pérdida neta es de 1.29%: de 8322 registros quedan 8215, una base que conserva la representatividad del mercado.

De ahí se filtran los dos subconjuntos que usará cada solicitud:

casas_norte <- vivienda_limpia %>% filter(zona == "Zona Norte", tipo == "Casa")
aptos_sur   <- vivienda_limpia %>% filter(zona == "Zona Sur", tipo == "Apartamento")

cat("Casas, zona norte:", nrow(casas_norte), "| Apartamentos, zona sur:", nrow(aptos_sur), "\n")
## Casas, zona norte: 698 | Apartamentos, zona sur: 2776

Ambos tamaños de muestra (698 y 2776) son más que suficientes para una regresión con cinco predictores.

4 Análisis exploratorio

4.1 ¿Cómo se distribuye el precio?

p1 <- ggplot(casas_norte, aes(x = preciom)) +
  geom_histogram(bins = 30, fill = "#2C5F8A", color = "white") +
  labs(title = "Casas — Zona Norte", x = "Precio (millones $)", y = "Frecuencia") +
  theme_minimal()

p2 <- ggplot(aptos_sur, aes(x = preciom)) +
  geom_histogram(bins = 30, fill = "#B2472B", color = "white") +
  labs(title = "Apartamentos — Zona Sur", x = "Precio (millones $)", y = "Frecuencia") +
  theme_minimal()

gridExtra::grid.arrange(p1, p2, ncol = 2)

bind_rows(
  casas_norte %>% summarise(Segmento = "Casas Zona Norte", n = n(), Media = mean(preciom),
                             Mediana = median(preciom), `Desv. estándar` = sd(preciom),
                             Mín. = min(preciom), Máx. = max(preciom)),
  aptos_sur %>% summarise(Segmento = "Apartamentos Zona Sur", n = n(), Media = mean(preciom),
                           Mediana = median(preciom), `Desv. estándar` = sd(preciom),
                           Mín. = min(preciom), Máx. = max(preciom))
) %>% tabla()
Segmento n Media Mediana Desv. estándar Mín. Máx.
Casas Zona Norte 698 443.31 390 268.13 89 1,940
Apartamentos Zona Sur 2,776 296.97 245 190.89 75 1,750

En los dos segmentos el precio está sesgado a la derecha: la mayoría de las propiedades se agrupa en precios bajos-medios y una cola de inmuebles de alto valor estira la distribución. Esta asimetría es justamente la razón por la que, más adelante, evaluamos una transformación logarítmica del precio como alternativa de modelamiento.

4.2 Precio frente a cada característica de la vivienda

mk_scatter <- function(df, var, titulo, color_pt) {
  ggplot(df, aes(x = .data[[var]], y = preciom)) +
    geom_point(alpha = 0.25, color = color_pt) +
    geom_smooth(method = "lm", se = FALSE, color = "#333333") +
    labs(title = titulo, x = var, y = "Precio (millones $)") +
    theme_minimal(base_size = 10)
}

gridExtra::grid.arrange(
  mk_scatter(casas_norte, "areaconst", "Área — Casas Norte", "#2C5F8A"),
  mk_scatter(casas_norte, "estrato", "Estrato — Casas Norte", "#2C5F8A"),
  mk_scatter(casas_norte, "habitaciones", "Habitaciones — Casas Norte", "#2C5F8A"),
  mk_scatter(casas_norte, "banios", "Baños — Casas Norte", "#2C5F8A"),
  ncol = 2
)

gridExtra::grid.arrange(
  mk_scatter(aptos_sur, "areaconst", "Área — Aptos. Sur", "#B2472B"),
  mk_scatter(aptos_sur, "estrato", "Estrato — Aptos. Sur", "#B2472B"),
  mk_scatter(aptos_sur, "habitaciones", "Habitaciones — Aptos. Sur", "#B2472B"),
  mk_scatter(aptos_sur, "banios", "Baños — Aptos. Sur", "#B2472B"),
  ncol = 2
)

En ambos segmentos, el área construida es la relación más fuerte y clara con el precio: a más metros cuadrados, precios sistemáticamente más altos. El estrato también marca una tendencia positiva evidente. Habitaciones y baños muestran relaciones más ruidosas, con bastante dispersión alrededor de la línea de tendencia — una primera señal de que su aporte, una vez controladas las demás variables, podría ser más débil de lo que sugiere el gráfico. Esto se confirma (y en el caso de las habitaciones, se matiza de forma interesante) en la sección de modelo.

4.3 Correlaciones

cor_norte <- round(cor(casas_norte[, c("preciom","areaconst","estrato","habitaciones","banios","parqueaderos")]), 2)
cor_sur   <- round(cor(aptos_sur[, c("preciom","areaconst","estrato","habitaciones","banios","parqueaderos")]), 2)

plot_ly(z = cor_norte, x = colnames(cor_norte), y = rownames(cor_norte),
        type = "heatmap", colors = colorRamp(c("#B2472B", "white", "#2C5F8A")),
        zmin = -1, zmax = 1) %>%
  layout(title = "Correlaciones — Casas Zona Norte")
plot_ly(z = cor_sur, x = colnames(cor_sur), y = rownames(cor_sur),
        type = "heatmap", colors = colorRamp(c("#B2472B", "white", "#2C5F8A")),
        zmin = -1, zmax = 1) %>%
  layout(title = "Correlaciones — Apartamentos Zona Sur")

El área es la variable más correlacionada con el precio en los dos segmentos (0.76 y 0.77), seguida del estrato (0.62 y 0.67) y los baños (0.57 y 0.73); las habitaciones son, en ambos casos, la de menor correlación (0.38 y 0.34). Entre los propios predictores, la correlación más alta es la de baños con habitaciones (0.61 y 0.52) — esperable, porque las viviendas más grandes tienden a tener más de las dos cosas. Esa correlación no es lo bastante alta como para anticipar un problema de multicolinealidad, pero lo confirmamos formalmente con el VIF una vez estimado el modelo.

5 Modelo de regresión lineal múltiple

El modelo conceptual, igual para los dos segmentos, es:

\[\text{preciom} = \beta_0 + \beta_1\,\text{areaconst} + \beta_2\,\text{estrato} + \beta_3\,\text{habitaciones} + \beta_4\,\text{parqueaderos} + \beta_5\,\text{banios} + \varepsilon\]

preciom es el precio en millones de pesos; cada \(\beta_i\) es el cambio esperado en el precio por unidad adicional de esa variable, manteniendo las demás constantes; \(\beta_0\) es el intercepto y \(\varepsilon\) el error no explicado. Los coeficientes se estiman por mínimos cuadrados ordinarios, el método que encuentra los valores de \(\beta\) que minimizan la suma de los residuales al cuadrado.

Como el precio se comporta distinto según la zona y el tipo de inmueble —ya visible en la exploración anterior—, ajustamos un modelo independiente por segmento en vez de forzar uno solo sobre toda la base. Usar el modelo de casas para predecir apartamentos, o viceversa, extrapolaría relaciones estimadas en un mercado distinto; la justificación completa de esta decisión se retoma en la comparación de modelos, más adelante.

5.1 Estimación

modelo_norte <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
                    data = casas_norte)
modelo_sur <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
                  data = aptos_sur)
bind_rows(
  tidy(modelo_norte, conf.int = TRUE) %>% mutate(Segmento = "Casas — Norte"),
  tidy(modelo_sur,   conf.int = TRUE) %>% mutate(Segmento = "Apartamentos — Sur")
) %>%
  mutate(term = ifelse(term == "(Intercept)", "Intercepto", term),
         p.value = fmt_p(p.value)) %>%
  select(Segmento, Variable = term, Estimación = estimate, `Error estándar` = std.error,
         `valor p` = p.value, `IC 95% inf.` = conf.low, `IC 95% sup.` = conf.high) %>%
  tabla(digits = 2)
Segmento Variable Estimación Error estándar valor p IC 95% inf. IC 95% sup.
Casas — Norte Intercepto -242.18 31.55 < 0.001 -304.12 -180.24
Casas — Norte areaconst 0.88 0.05 < 0.001 0.79 0.98
Casas — Norte estrato 80.13 7.49 < 0.001 65.43 94.83
Casas — Norte habitaciones 5.83 4.69 0.214 -3.38 15.04
Casas — Norte parqueaderos -2.22 4.21 0.597 -10.48 6.04
Casas — Norte banios 26.07 5.77 < 0.001 14.75 37.40
Apartamentos — Sur Intercepto -209.57 13.67 < 0.001 -236.37 -182.77
Apartamentos — Sur areaconst 1.56 0.05 < 0.001 1.46 1.67
Apartamentos — Sur estrato 55.83 2.77 < 0.001 50.39 61.26
Apartamentos — Sur habitaciones -26.65 3.49 < 0.001 -33.49 -19.80
Apartamentos — Sur parqueaderos 47.27 3.11 < 0.001 41.16 53.37
Apartamentos — Sur banios 47.43 3.12 < 0.001 41.31 53.55

El área y el estrato encabezan los dos modelos, con signo positivo y alta significancia: cada m² adicional suma en promedio $0.88 M en casas del norte y $1.56 M en apartamentos del sur; cada estrato adicional suma $80 M y $56 M, respectivamente (siempre manteniendo las demás variables fijas).

El resto de variables es donde los dos segmentos se separan. En casas del norte, ni las habitaciones (p = 0.21) ni los parqueaderos (p = 0.60) son estadísticamente significativos una vez controlados área, estrato y baños — el modelo no logra distinguir su efecto del ruido. En apartamentos del sur, en cambio, parqueaderos y baños sí suman de forma clara y significativa (+$47 M cada uno), y las habitaciones muestran un efecto negativo y significativo (-$27 M por habitación adicional, p < 0.001): a igual área, más habitaciones implica cuartos más pequeños, y en este segmento el mercado paga menos por eso. Vale la pena tenerlo presente para la solicitud 2, que pide 5 habitaciones en 300 m² — un promedio generoso de 60 m² por habitación, lejos del escenario de cuartos diminutos que penaliza el coeficiente, pero un factor a vigilar al comparar alternativas.

5.2 Qué tan bueno es cada modelo

bind_rows(
  glance(modelo_norte) %>% mutate(Segmento = "Casas — Norte"),
  glance(modelo_sur)   %>% mutate(Segmento = "Apartamentos — Sur")
) %>%
  mutate(p.value = fmt_p(p.value)) %>%
  select(Segmento, n = nobs, R2 = r.squared, `R2 ajustado` = adj.r.squared,
         `Error residual ($M)` = sigma, `Estadístico F` = statistic, `valor p` = p.value) %>%
  tabla(digits = 3)
Segmento n R2 R2 ajustado Error residual ($M) Estadístico F valor p
Casas — Norte 698 0.675 0.673 153.327 287.905 < 0.001
Apartamentos — Sur 2,776 0.760 0.760 93.599 1,754.509 < 0.001

El modelo de casas del norte explica 67% de la variación del precio; el de apartamentos del sur, 76% — un ajuste notablemente mejor, en parte por la muestra más grande (2,776 vs. 698) y en parte porque en apartamentos las características físicas capturan una porción mayor del precio. Ambos modelos son globalmente significativos (p < 0.001). Un R² de 67-76% es útil para orientar una decisión de negocio, pero está lejos de 100%: ni acabados, ni antigüedad, ni ubicación exacta dentro de la zona están en la base, y esas variables sí influyen en el precio real de cada propiedad.

5.3 Multicolinealidad

data.frame(
  Variable = names(vif(modelo_norte)),
  `Casas Norte` = round(vif(modelo_norte), 2),
  `Apartamentos Sur` = round(vif(modelo_sur), 2),
  check.names = FALSE
) %>% tabla()
Variable Casas Norte Apartamentos Sur
areaconst areaconst 1.73 2.22
estrato estrato 1.61 1.73
habitaciones habitaciones 1.82 1.45
parqueaderos parqueaderos 1.23 1.85
banios banios 2.13 2.66

Todos los VIF están por debajo de 2.7, muy lejos del umbral de preocupación habitual (5-10). Los predictores aportan información suficientemente independiente entre sí en los dos modelos, así que no hay razón para remover ninguno por redundancia.

5.4 Validación de supuestos

Antes de confiar en las predicciones, revisamos si los residuales del modelo se comportan como asume la regresión lineal: relación lineal con los predictores, independencia entre observaciones, varianza constante (homocedasticidad) y distribución normal, además de identificar observaciones que puedan estar dominando el ajuste.

Casas — Zona Norte

par(mfrow = c(2,2))
plot(modelo_norte, main = "")

Apartamentos — Zona Sur

par(mfrow = c(2,2))
plot(modelo_sur, main = "")

sh_n <- shapiro.test(residuals(modelo_norte)); sh_s <- shapiro.test(residuals(modelo_sur))
bp_n <- bptest(modelo_norte); bp_s <- bptest(modelo_sur)
dw_n <- dwtest(modelo_norte); dw_s <- dwtest(modelo_sur)
cd_n <- cooks.distance(modelo_norte); cd_s <- cooks.distance(modelo_sur)

data.frame(
  Supuesto = c("Normalidad (Shapiro-Wilk)", "Homocedasticidad (Breusch-Pagan)",
               "Independencia (Durbin-Watson)", "Obs. influyentes (Cook's D > 4/n)"),
  `Casas Norte` = c(
    sprintf("W = %.3f, p %s", sh_n$statistic, fmt_p(sh_n$p.value)),
    sprintf("BP = %.1f, p %s", bp_n$statistic, fmt_p(bp_n$p.value)),
    sprintf("DW = %.2f, p %s", dw_n$statistic, fmt_p(dw_n$p.value)),
    sprintf("%d de %d (%.1f%%)", sum(cd_n > 4/length(cd_n)), length(cd_n), 100*mean(cd_n > 4/length(cd_n)))
  ),
  `Apartamentos Sur` = c(
    sprintf("W = %.3f, p %s", sh_s$statistic, fmt_p(sh_s$p.value)),
    sprintf("BP = %.1f, p %s", bp_s$statistic, fmt_p(bp_s$p.value)),
    sprintf("DW = %.2f, p %s", dw_s$statistic, fmt_p(dw_s$p.value)),
    sprintf("%d de %d (%.1f%%)", sum(cd_s > 4/length(cd_s)), length(cd_s), 100*mean(cd_s > 4/length(cd_s)))
  ),
  check.names = FALSE
) %>% tabla()
Supuesto Casas Norte Apartamentos Sur
Normalidad (Shapiro-Wilk) W = 0.851, p < 0.001 W = 0.786, p < 0.001
Homocedasticidad (Breusch-Pagan) BP = 105.8, p < 0.001 BP = 944.6, p < 0.001
Independencia (Durbin-Watson) DW = 1.64, p < 0.001 DW = 1.51, p < 0.001
Obs. influyentes (Cook’s D > 4/n) 51 de 698 (7.3%) 153 de 2776 (5.5%)

Linealidad. El panel de residuales vs. ajustados no muestra curvatura sistemática en ninguno de los dos modelos, aunque la dispersión crece con el precio — más sobre esto en homocedasticidad. No hay evidencia fuerte de una relación no lineal que el modelo esté ignorando.

Independencia. Los datos son de corte transversal, no una serie temporal, así que la independencia depende del diseño muestral más que de un orden en el tiempo. Aun así, Durbin-Watson se aleja de 2 en los dos modelos (1.64 y 1.51, p < 0.001), lo que probablemente refleja que registros consecutivos en la base comparten barrio o lote de captura, no una dependencia temporal real. No lo interpretamos como una violación grave, pero sí como motivo para tratar los errores estándar con cautela — ver limitaciones.

Homocedasticidad. Breusch-Pagan es significativo en ambos modelos, más marcado en apartamentos del sur (BP = 945 vs. 106), consistente con su rango de precios más amplio ($75 M a $1,900 M). Hay heterocedasticidad: los residuales se dispersan más en las viviendas de precio alto. En la práctica, esto significa que los intervalos de predicción son menos precisos para las propiedades más caras de cada segmento — algo a tener presente al leer los intervalos de la siguiente sección.

Normalidad. Shapiro-Wilk rechaza la normalidad en los dos modelos, y los histogramas y gráficos Q-Q (paneles superior derecho e inferior izquierdo, arriba) muestran colas más pesadas que una normal — causadas por un número pequeño de propiedades de precio muy alto en cada segmento. Esto no invalida los coeficientes estimados, que siguen siendo insesgados, pero sí reduce la exactitud de los intervalos de predicción, que deben leerse como aproximados.

Observaciones influyentes. Bajo el criterio de referencia 4/n, 51 casas del norte (7.3%) y 153 apartamentos del sur (5.5%) destacan como influyentes — una proporción esperable con este criterio conservador. Se concentran en los extremos de precio de cada segmento y no hay evidencia de que sean errores de captura, así que se conservan: eliminarlas sesgaría el modelo hacia el precio “típico” y perdería justo la información sobre el extremo alto del mercado que puede interesarle al cliente.

5.5 Comparación de modelos

Para cada segmento comparamos tres especificaciones — un modelo simple (solo área), el modelo completo del caso, y una versión con el precio en escala logarítmica, para atender el sesgo visto en la exploración — usando una partición 80/20 (entrenamiento/validación) que da una medida de error honesta fuera de muestra.

comparar_modelos <- function(df) {
  idx <- sample(seq_len(nrow(df)), size = floor(0.8 * nrow(df)))
  train <- df[idx, ]; test <- df[-idx, ]

  m1 <- lm(preciom ~ areaconst, data = train)
  m2 <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios, data = train)
  m3 <- lm(log(preciom) ~ areaconst + estrato + habitaciones + parqueaderos + banios, data = train)

  rmse <- function(mod, log = FALSE) {
    pred <- predict(mod, newdata = test)
    if (log) pred <- exp(pred)
    sqrt(mean((test$preciom - pred)^2, na.rm = TRUE))
  }

  data.frame(
    Modelo = c("M1: solo área", "M2: completo (nivel)", "M3: completo (log)"),
    `R2 ajustado` = c(summary(m1)$adj.r.squared, summary(m2)$adj.r.squared, summary(m3)$adj.r.squared),
    AIC = c(AIC(m1), AIC(m2), AIC(m3)),
    BIC = c(BIC(m1), BIC(m2), BIC(m3)),
    `RMSE validación ($M)` = c(rmse(m1), rmse(m2), rmse(m3, log = TRUE)),
    check.names = FALSE
  )
}

comp_norte <- comparar_modelos(casas_norte) %>% mutate(Segmento = "Casas Norte", .before = 1)
comp_sur   <- comparar_modelos(aptos_sur)   %>% mutate(Segmento = "Apartamentos Sur", .before = 1)

bind_rows(comp_norte, comp_sur) %>% tabla(digits = 2)
Segmento Modelo R2 ajustado AIC BIC RMSE validación ($M)
Casas Norte M1: solo área 0.59 7,343.43 7,356.40 174.45
Casas Norte M2: completo (nivel) 0.69 7,194.26 7,224.53 161.42
Casas Norte M3: completo (log) 0.77 148.32 178.59 178.01
Apartamentos Sur M1: solo área 0.58 27,646.61 27,663.72 116.82
Apartamentos Sur M2: completo (nivel) 0.75 26,449.72 26,489.65 94.87
Apartamentos Sur M3: completo (log) 0.81 -376.27 -336.33 115.99

Agregar estrato, habitaciones, parqueaderos y baños (M2) mejora sustancialmente sobre el modelo de solo área (M1) en R², AIC y BIC, en los dos segmentos — no es una mejora marginal. La comparación con M3 (log) requiere más cuidado: su AIC y BIC no son comparables con los de M1/M2 porque modela log(preciom), una variable respuesta en otra escala; comparar directamente esos valores llevaría a una conclusión equivocada. El criterio común entre escalas es el RMSE de validación, siempre en pesos — y ahí M2 gana en los dos segmentos (161 vs. 178 en casas; 95 vs. 116 en apartamentos). M3 ajusta mejor su propia escala logarítmica, pero al deshacer la transformación para volver a pesos, predice peor en la práctica.

Por eso el modelo seleccionado es M2, en escala original, para los dos segmentos: es el que mejor predice en la unidad que le importa al negocio (millones de pesos) y sus coeficientes se leen directamente en esa unidad, sin pasos adicionales de interpretación. Los modelos modelo_norte y modelo_sur usados en el resto del informe se reajustan sobre el 100% de los datos de cada segmento — práctica estándar una vez elegida la especificación, para maximizar la precisión de las estimaciones finales.

6 Predicción

Antes de ver los números: una estimación puntual es el precio esperado más probable para una vivienda con exactamente el perfil solicitado. Un intervalo de predicción (95%) es el rango donde razonablemente caería el precio de una vivienda individual con ese perfil — más ancho que un intervalo de confianza para el promedio, porque suma la incertidumbre del modelo a la variabilidad natural entre viviendas parecidas. Dada la heterocedasticidad documentada arriba, estos intervalos son una referencia amplia, no un límite exacto.

nuevo1_e4 <- data.frame(areaconst = 200, estrato = 4, habitaciones = 4, parqueaderos = 1, banios = 2)
nuevo1_e5 <- data.frame(areaconst = 200, estrato = 5, habitaciones = 4, parqueaderos = 1, banios = 2)
nuevo2_e5 <- data.frame(areaconst = 300, estrato = 5, habitaciones = 5, parqueaderos = 3, banios = 3)
nuevo2_e6 <- data.frame(areaconst = 300, estrato = 6, habitaciones = 5, parqueaderos = 3, banios = 3)

pred1_e4 <- predict(modelo_norte, newdata = nuevo1_e4, interval = "prediction", level = 0.95)
pred1_e5 <- predict(modelo_norte, newdata = nuevo1_e5, interval = "prediction", level = 0.95)
pred2_e5 <- predict(modelo_sur, newdata = nuevo2_e5, interval = "prediction", level = 0.95)
pred2_e6 <- predict(modelo_sur, newdata = nuevo2_e6, interval = "prediction", level = 0.95)

preds <- data.frame(
  Solicitud = c("1. Casa, Zona Norte", "1. Casa, Zona Norte", "2. Apto., Zona Sur", "2. Apto., Zona Sur"),
  Escenario = c("Estrato 4", "Estrato 5", "Estrato 5", "Estrato 6"),
  `Precio estimado` = c(pred1_e4[1], pred1_e5[1], pred2_e5[1], pred2_e6[1]),
  `IC 95% inf.` = c(pred1_e4[2], pred1_e5[2], pred2_e5[2], pred2_e6[2]),
  `IC 95% sup.` = c(pred1_e4[3], pred1_e5[3], pred2_e5[3], pred2_e6[3]),
  Presupuesto = c(350, 350, 850, 850),
  check.names = FALSE
)
preds %>% tabla(digits = 1)
Solicitud Escenario Precio estimado IC 95% inf. IC 95% sup. Presupuesto
  1. Casa, Zona Norte
Estrato 4 328.2 26.7 629.8 350
  1. Casa, Zona Norte
Estrato 5 408.4 106.3 710.5 350
  1. Apto., Zona Sur
Estrato 5 689.9 505.2 874.6 850
  1. Apto., Zona Sur
Estrato 6 745.7 561.0 930.5 850

Casa, zona norte. En estrato 4, el precio esperado ($328 M) cabe dentro del crédito preaprobado, con margen de $22 M. En estrato 5, el precio esperado ($408 M) excede el presupuesto en $58 M — viable solo si el cliente aporta recursos adicionales o negocia el precio a la baja.

Apartamento, zona sur. Los dos escenarios quedan por debajo del presupuesto de $850 M, con margen de $160 M (estrato 5) y $104 M (estrato 6). Ambos son financieramente viables; la diferencia entre estratos ($56 M en promedio) es, simplemente, el efecto del estrato manteniendo todo lo demás constante.

7 Selección de ofertas

Para cada solicitud filtramos las propiedades del segmento correspondiente que cumplen el estrato pedido y cuestan dentro del presupuesto. Sobre ese grupo calculamos un índice de ajuste al perfil (0 a 100) que penaliza la distancia relativa al área, las habitaciones, los baños y los parqueaderos solicitados, dando el doble de peso al área por ser la variable más asociada al precio:

\[\text{índice} = 100 - 100\times\big(0.4\,d_{\text{área}} + 0.2\,d_{\text{habitaciones}} + 0.2\,d_{\text{baños}} + 0.2\,d_{\text{parqueaderos}}\big)\]

donde cada \(d\) es la diferencia absoluta frente al valor solicitado, como proporción de ese valor. Junto al precio observado (preciom) mostramos el precio que el modelo esperaría para esas características (precio_estimado): cuando el observado es menor al estimado, la propiedad está relativamente bien valorada frente al resto del mercado.

7.1 Solicitud 1 — Casas, zona norte

target1 <- list(areaconst = 200, parqueaderos = 1, banios = 2, habitaciones = 4)
presupuesto1 <- 350

cand1 <- casas_norte %>% filter(estrato %in% c(4, 5), preciom <= presupuesto1)
cand1$precio_estimado <- predict(modelo_norte, newdata = cand1)

cand1 <- cand1 %>%
  mutate(
    d_area = abs(areaconst - target1$areaconst) / target1$areaconst,
    d_hab  = abs(habitaciones - target1$habitaciones) / target1$habitaciones,
    d_ban  = abs(banios - target1$banios) / target1$banios,
    d_parq = abs(parqueaderos - target1$parqueaderos) / max(target1$parqueaderos, 1),
    indice = 100 - 100 * (0.4*d_area + 0.2*d_hab + 0.2*d_ban + 0.2*d_parq)
  ) %>%
  arrange(desc(indice))

cat("Propiedades dentro de presupuesto y estrato:", nrow(cand1), "\n")
## Propiedades dentro de presupuesto y estrato: 110
top1 <- cand1 %>%
  transmute(id, barrio = tools::toTitleCase(trimws(barrio)), preciom, areaconst, habitaciones,
            banios, parqueaderos, estrato, precio_estimado = round(precio_estimado,1),
            indice = round(indice,1)) %>%
  head(6)

top1 %>% tabla()
id barrio preciom areaconst habitaciones banios parqueaderos estrato precio_estimado indice
3,586 La Merced 330 240 3 2 1 4 357.8 87.0
1,666 Alamos 275 120 4 2 1 4 257.6 84.0
1,352 La Flora 350 190 3 3 1 5 419.8 83.0
4,779 Prados Del Norte 280 140 3 2 1 5 349.5 83.0
94 Zona Norte 265 162 4 3 1 4 320.8 82.4
1,924 Vipasa 320 264 3 2 1 4 379.0 82.2
pal1 <- colorFactor(c("#999999", "#B2472B"), domain = c("otras", "top"))
cand1_map <- cand1 %>% mutate(grupo = ifelse(id %in% top1$id, "top", "otras"))

leaflet(cand1_map) %>%
  addProviderTiles(providers$CartoDB.Positron) %>%
  addCircleMarkers(~longitud, ~latitud, radius = ~ifelse(grupo=="top", 8, 4),
                    color = ~pal1(grupo), stroke = FALSE, fillOpacity = 0.8,
                    popup = ~paste0("<b>", tools::toTitleCase(trimws(barrio)), "</b><br>",
                                     "Precio: $", format(preciom, big.mark=","), " M<br>",
                                     "Área: ", areaconst, " m² | Hab: ", habitaciones,
                                     " | Baños: ", banios, " | Parq: ", parqueaderos)) %>%
  addLegend(position = "bottomright", colors = c("#B2472B","#999999"),
            labels = c("Top 6 recomendadas","Otras dentro de presupuesto"))

7.2 Solicitud 2 — Apartamentos, zona sur

target2 <- list(areaconst = 300, parqueaderos = 3, banios = 3, habitaciones = 5)
presupuesto2 <- 850

cand2 <- aptos_sur %>% filter(estrato %in% c(5, 6), preciom <= presupuesto2)
cand2$precio_estimado <- predict(modelo_sur, newdata = cand2)

cand2 <- cand2 %>%
  mutate(
    d_area = abs(areaconst - target2$areaconst) / target2$areaconst,
    d_hab  = abs(habitaciones - target2$habitaciones) / target2$habitaciones,
    d_ban  = abs(banios - target2$banios) / target2$banios,
    d_parq = abs(parqueaderos - target2$parqueaderos) / max(target2$parqueaderos, 1),
    indice = 100 - 100 * (0.4*d_area + 0.2*d_hab + 0.2*d_ban + 0.2*d_parq)
  ) %>%
  arrange(desc(indice))

cat("Propiedades dentro de presupuesto y estrato:", nrow(cand2), "\n")
## Propiedades dentro de presupuesto y estrato: 1436
top2 <- cand2 %>%
  transmute(id, barrio = tools::toTitleCase(trimws(barrio)), preciom, areaconst, habitaciones,
            banios, parqueaderos, estrato, precio_estimado = round(precio_estimado,1),
            indice = round(indice,1)) %>%
  head(6)

top2 %>% tabla()
id barrio preciom areaconst habitaciones banios parqueaderos estrato precio_estimado indice
6,175 Capri 350 270.00 4 3 3 5 669.6 92.0
7,680 Pampa Linda 450 267.00 3 3 3 5 691.6 87.6
6,205 Capri 350 260.00 3 3 3 5 680.6 86.7
7,512 Seminario 670 300.00 6 5 3 5 758.1 82.7
5,423 Ciudad Jardín 695 227.00 3 3 3 6 684.8 82.3
8,113 Cuarto De Legua 410 295.55 4 4 2 5 709.8 82.1
pal2 <- colorFactor(c("#999999", "#2C5F8A"), domain = c("otras", "top"))
cand2_map <- cand2 %>% mutate(grupo = ifelse(id %in% top2$id, "top", "otras"))

leaflet(cand2_map) %>%
  addProviderTiles(providers$CartoDB.Positron) %>%
  addCircleMarkers(~longitud, ~latitud, radius = ~ifelse(grupo=="top", 8, 4),
                    color = ~pal2(grupo), stroke = FALSE, fillOpacity = 0.8,
                    popup = ~paste0("<b>", tools::toTitleCase(trimws(barrio)), "</b><br>",
                                     "Precio: $", format(preciom, big.mark=","), " M<br>",
                                     "Área: ", areaconst, " m² | Hab: ", habitaciones,
                                     " | Baños: ", banios, " | Parq: ", parqueaderos)) %>%
  addLegend(position = "bottomright", colors = c("#2C5F8A","#999999"),
            labels = c("Top 6 recomendadas","Otras dentro de presupuesto"))

En los dos casos, todas las propiedades listadas cumplen presupuesto y estrato por diseño del filtro; el índice solo ordena por cercanía al perfil solicitado. Son el punto de partida natural para la negociación — en particular las que tienen precio observado por debajo de su precio estimado, la mejor relación precio/valor del grupo.

8 Recomendación final

Casa, zona norte. Buscar en estrato 4: el precio esperado ($328 M) cabe en el crédito preaprobado; estrato 5 lo excede en $58 M y solo es viable con recursos adicionales o negociación. Las propiedades de la sección anterior son el punto de partida.

Apartamento, zona sur. Ambos estratos son viables financieramente, con margen amplio. Se recomienda estrato 6 si el cliente prioriza ubicación y acabados — el presupuesto lo permite —, o estrato 5 si prefiere maximizar el margen de negociación. Como más habitaciones a igual área castigan el precio en este segmento, conviene priorizar entre las alternativas las de mejor relación área/habitación, no solo el conteo de cuartos.

En conjunto, área construida y estrato son las variables que más pesan en el precio en los dos segmentos. Parqueaderos y baños importan en apartamentos del sur pero no en casas del norte; habitaciones nunca es, por sí sola, un buen predictor, y en apartamentos del sur su efecto es incluso negativo a igualdad de área.

El principal riesgo a tener presente: los intervalos de predicción son amplios — reflejo de la heterocedasticidad y de un R² lejos de 100% — así que el precio estimado debe usarse como referencia de negociación, no como precio de cierre.

9 Limitaciones

  • Variables no observadas. El modelo no incluye antigüedad, acabados, orientación ni cercanía a vías principales — factores con efecto conocido sobre el precio que quedan absorbidos en el error residual, y que explican en parte el R² no cercano a 100% y la heterocedasticidad observada.
  • Tamaño de muestra desigual. El modelo de casas del norte (n = 698) es bastante más pequeño que el de apartamentos del sur (n = 2,776), por lo que sus estimaciones son menos precisas.
  • Supuesto sobre parqueaderos. Imputar los NA como 0 es razonable pero no está confirmado con la fuente original; si fuera incorrecto para una fracción relevante de casos, el coeficiente de parqueaderos podría estar sesgado.
  • Heterocedasticidad y no normalidad. No invalidan los coeficientes, pero hacen que los intervalos de predicción sean aproximados, sobre todo en el extremo alto de precio de cada segmento.
  • Posible agrupamiento por barrio. El resultado de Durbin-Watson sugiere correlación entre observaciones cercanas en la base, probablemente por barrio; errores estándar agrupados (clustered) por barrio serían un refinamiento natural en una siguiente iteración.
  • Calidad de barrio. Persisten inconsistencias de formato que no afectan el modelo pero sí limitarían un análisis de precio por barrio específico.
  • Alcance predictivo. Ambos perfiles solicitados caen dentro del rango de valores observado en su segmento (área, habitaciones, baños y parqueaderos), así que las predicciones no están extrapolando fuera de los datos.

Ninguna de estas limitaciones invalida las conclusiones del informe; delimitan la precisión con la que deben usarse las estimaciones para negociar y seleccionar propiedades.

10 Conclusiones

  1. Área construida y estrato son los determinantes más consistentes del precio de vivienda en Cali, en casas del norte y en apartamentos del sur por igual, y deben ser el foco al evaluar alternativas.
  2. Modelar cada segmento por separado fue la decisión correcta: la relación entre habitaciones y precio incluso cambia de signo entre uno y otro.
  3. Solo el estrato 4 es financieramente compatible con el presupuesto de la solicitud 1; en la solicitud 2, tanto estrato 5 como 6 lo son, con margen razonable.
  4. El modelo en escala original —no el logarítmico— es la mejor opción operativa: predice mejor en la unidad que importa (millones de pesos), aunque su ajuste en escala logarítmica sea ligeramente inferior.
  5. Hay suficientes alternativas reales dentro de presupuesto para ambas solicitudes (110 y 1,436 propiedades) como para sostener una negociación con varias opciones sobre la mesa.

11 Anexos técnicos

11.1 Anexo A: Código R completo

Código reproducible de principio a fin (ya ejecutado en las secciones anteriores; aquí se presenta consolidado, sin volver a ejecutarse).

# ---- Librerías ----
library(dplyr); library(ggplot2); library(car); library(lmtest)
library(broom); library(kableExtra); library(plotly); library(leaflet)
library(paqueteMODELOS)
data("vivienda")

# ---- Depuración ----
vivienda_limpia <- vivienda %>%
  filter(!is.na(zona), !is.na(tipo), !is.na(estrato), !is.na(preciom),
         !is.na(areaconst), !is.na(habitaciones), !is.na(banios)) %>%
  mutate(parqueaderos = ifelse(is.na(parqueaderos), 0, parqueaderos)) %>%
  filter(habitaciones > 0, banios > 0, areaconst > 0, preciom > 0) %>%
  mutate(precio_m2 = preciom * 1e6 / areaconst) %>%
  filter(precio_m2 >= 300000, precio_m2 <= 6500000)

casas_norte <- vivienda_limpia %>% filter(zona == "Zona Norte", tipo == "Casa")
aptos_sur   <- vivienda_limpia %>% filter(zona == "Zona Sur", tipo == "Apartamento")

# ---- Modelos finales ----
modelo_norte <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
                    data = casas_norte)
modelo_sur <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
                  data = aptos_sur)

# ---- Diagnósticos ----
vif(modelo_norte); vif(modelo_sur)
bptest(modelo_norte); bptest(modelo_sur)
dwtest(modelo_norte); dwtest(modelo_sur)
shapiro.test(residuals(modelo_norte)); shapiro.test(residuals(modelo_sur))

# ---- Predicción ----
predict(modelo_norte, newdata = data.frame(areaconst=200, estrato=4, habitaciones=4,
                                            parqueaderos=1, banios=2),
        interval = "prediction", level = 0.95)
predict(modelo_sur, newdata = data.frame(areaconst=300, estrato=5, habitaciones=5,
                                          parqueaderos=3, banios=3),
        interval = "prediction", level = 0.95)

11.2 Anexo B: Salidas estadísticas completas

summary(modelo_norte)
## 
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos + 
##     banios, data = casas_norte)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -740.41  -76.11  -16.19   47.58 1034.55 
## 
## Coefficients:
##                Estimate Std. Error t value Pr(>|t|)    
## (Intercept)  -242.18007   31.54533  -7.677 5.55e-14 ***
## areaconst       0.88334    0.04757  18.571  < 2e-16 ***
## estrato        80.12981    7.48814  10.701  < 2e-16 ***
## habitaciones    5.82813    4.69051   1.243    0.214    
## parqueaderos   -2.22186    4.20572  -0.528    0.597    
## banios         26.07379    5.76958   4.519 7.30e-06 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 153.3 on 692 degrees of freedom
## Multiple R-squared:  0.6754, Adjusted R-squared:  0.673 
## F-statistic: 287.9 on 5 and 692 DF,  p-value: < 2.2e-16
summary(modelo_sur)
## 
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos + 
##     banios, data = aptos_sur)
## 
## Residuals:
##      Min       1Q   Median       3Q      Max 
## -1338.70   -42.54    -1.45    36.28   919.19 
## 
## Coefficients:
##                Estimate Std. Error t value Pr(>|t|)    
## (Intercept)  -209.57018   13.66564 -15.336  < 2e-16 ***
## areaconst       1.56492    0.05191  30.147  < 2e-16 ***
## estrato        55.82860    2.77237  20.138  < 2e-16 ***
## habitaciones  -26.64897    3.49052  -7.635  3.1e-14 ***
## parqueaderos   47.26581    3.11313  15.183  < 2e-16 ***
## banios         47.43374    3.12130  15.197  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 93.6 on 2770 degrees of freedom
## Multiple R-squared:   0.76,  Adjusted R-squared:  0.7596 
## F-statistic:  1755 on 5 and 2770 DF,  p-value: < 2.2e-16

11.3 Anexo C: Resumen de la sesión de R

sessionInfo()
## R version 4.5.2 (2025-10-31 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 10 x64 (build 19045)
## 
## 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] paqueteMODELOS_0.1.0 summarytools_1.1.5   knitr_1.51          
##  [4] gridExtra_2.3        GGally_2.4.0         boot_1.3-32         
##  [7] leaflet_2.2.3        plotly_4.12.1        kableExtra_1.4.0    
## [10] broom_1.0.12         lmtest_0.9-40        zoo_1.8-15          
## [13] car_3.1-5            carData_3.0-6        ggplot2_4.0.2       
## [16] dplyr_1.2.0         
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.6            xfun_0.56               bslib_0.10.0           
##  [4] htmlwidgets_1.6.4       lattice_0.22-7          leaflet.providers_3.0.0
##  [7] vctrs_0.7.1             tools_4.5.2             crosstalk_1.2.2        
## [10] generics_0.1.4          tibble_3.3.1            pkgconfig_2.0.3        
## [13] Matrix_1.7-4            data.table_1.18.2.1     checkmate_2.3.4        
## [16] RColorBrewer_1.1-3      S7_0.2.1                lifecycle_1.0.5        
## [19] compiler_4.5.2          farver_2.1.2            stringr_1.6.0          
## [22] rapportools_1.2         textshaping_1.0.4       htmltools_0.5.9        
## [25] sass_0.4.10             yaml_2.3.12             Formula_1.2-5          
## [28] pillar_1.11.1           jquerylib_0.1.4         tidyr_1.3.2            
## [31] MASS_7.3-65             cachem_1.1.0            magick_2.9.0           
## [34] abind_1.4-8             nlme_3.1-168            ggstats_0.13.0         
## [37] tidyselect_1.2.1        digest_0.6.39           stringi_1.8.7          
## [40] reshape2_1.4.5          pander_0.6.6            purrr_1.2.1            
## [43] splines_4.5.2           labeling_0.4.3          fastmap_1.2.0          
## [46] grid_4.5.2              cli_3.6.6               magrittr_2.0.4         
## [49] base64enc_0.1-6         withr_3.0.2             scales_1.4.0           
## [52] backports_1.5.0         timechange_0.4.0        lubridate_1.9.5        
## [55] rmarkdown_2.31          httr_1.4.9              matrixStats_1.5.0      
## [58] otel_0.2.0              evaluate_1.0.5          tcltk_4.5.2            
## [61] viridisLite_0.4.3       mgcv_1.9-3              rlang_1.3.0            
## [64] Rcpp_1.1.1              glue_1.8.0              xml2_1.5.2             
## [67] svglite_2.2.2           rstudioapi_0.18.0       jsonlite_2.0.0         
## [70] plyr_1.8.9              R6_2.6.1                systemfonts_1.3.1