Introducción

El presente informe desarrolla el caso C&A (Casas y Apartamentos), cuyo propósito es apoyar a la empresa inmobiliaria en la evaluación de dos solicitudes de vivienda mediante técnicas de análisis exploratorio, modelación estadística y regresión lineal múltiple.

La primera solicitud corresponde a una casa ubicada en la Zona Norte de Cali, con un área construida de 200 m², un parqueadero, dos baños, cuatro habitaciones, estrato 4 o 5 y un crédito máximo preaprobado de 350 millones de pesos.

La segunda solicitud corresponde a un apartamento en la Zona Sur de Cali, con un área construida de 300 m², tres parqueaderos, tres baños, cinco habitaciones, estrato 5 o 6 y un crédito máximo preaprobado de 850 millones de pesos.

El análisis incluye filtrado de datos, análisis descriptivo y de correlación, estimación y comparación de modelos de regresión, validación de supuestos, predicción de precios y selección de ofertas potenciales.

1. Preparación del entorno

paquetes <- c(
  "dplyr",
  "tidyr",
  "stringr",
  "ggplot2",
  "plotly",
  "leaflet",
  "broom",
  "lmtest",
  "car",
  "nortest",
  "knitr",
  "DT"
)

faltantes <- paquetes[!paquetes %in% rownames(installed.packages())]

if(length(faltantes) > 0){
  install.packages(faltantes)
}

invisible(lapply(paquetes, library, character.only = TRUE))

if(!requireNamespace("paqueteMODELOS", quietly = TRUE)){
  if(!requireNamespace("remotes", quietly = TRUE)){
    install.packages("remotes")
  }
  remotes::install_github(
    "centromagis/paqueteMODELOS",
    upgrade = "never"
  )
}

library(paqueteMODELOS)

2. Carga y revisión de la base de datos

# Carga explícita desde utils::data para evitar cualquier función data() enmascarada
utils::data("vivienda", package = "paqueteMODELOS", envir = environment())

if(!exists("vivienda")){
  stop("No fue posible cargar el conjunto de datos 'vivienda' desde paqueteMODELOS.")
}

datos <- vivienda

# Validación del esquema requerido por la actividad
columnas_requeridas <- c(
  "id", "zona", "piso", "estrato", "preciom", "areaconst",
  "parqueaderos", "banios", "habitaciones", "tipo", "barrio",
  "longitud", "latitud"
)

columnas_faltantes <- setdiff(columnas_requeridas, names(datos))

if(length(columnas_faltantes) > 0){
  stop(
    paste0(
      "La base no contiene las siguientes variables requeridas: ",
      paste(columnas_faltantes, collapse = ", ")
    )
  )
}

# Conversión defensiva de variables numéricas
variables_numericas <- c(
  "estrato", "preciom", "areaconst", "parqueaderos",
  "banios", "habitaciones", "longitud", "latitud"
)

datos[variables_numericas] <- lapply(
  datos[variables_numericas],
  function(x) suppressWarnings(as.numeric(as.character(x)))
)

if(nrow(datos) == 0){
  stop("La base 'vivienda' se cargó sin observaciones.")
}

datos <- datos %>%
  dplyr::mutate(
    zona   = stringr::str_squish(as.character(zona)),
    tipo   = stringr::str_squish(as.character(tipo)),
    barrio = stringr::str_squish(as.character(barrio))
  )

dim(datos)
## [1] 8322   13
str(datos)
## spc_tbl_ [8,322 × 13] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
##  $ id          : num [1:8322] 1147 1169 1350 5992 1212 ...
##  $ zona        : chr [1:8322] "Zona Oriente" "Zona Oriente" "Zona Oriente" "Zona Sur" ...
##  $ piso        : chr [1:8322] NA NA NA "02" ...
##  $ estrato     : num [1:8322] 3 3 3 4 5 5 4 5 5 5 ...
##  $ preciom     : num [1:8322] 250 320 350 400 260 240 220 310 320 780 ...
##  $ areaconst   : num [1:8322] 70 120 220 280 90 87 52 137 150 380 ...
##  $ parqueaderos: num [1:8322] 1 1 2 3 1 1 2 2 2 2 ...
##  $ banios      : num [1:8322] 3 2 2 5 2 3 2 3 4 3 ...
##  $ habitaciones: num [1:8322] 6 3 4 3 3 3 3 4 6 3 ...
##  $ tipo        : chr [1:8322] "Casa" "Casa" "Casa" "Casa" ...
##  $ barrio      : chr [1:8322] "20 de julio" "20 de julio" "20 de julio" "3 de julio" ...
##  $ longitud    : num [1:8322] -76.5 -76.5 -76.5 -76.5 -76.5 ...
##  $ latitud     : num [1:8322] 3.43 3.43 3.44 3.44 3.46 ...
##  - attr(*, "spec")=List of 3
##   ..$ cols   :List of 13
##   .. ..$ id          : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ zona        : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ piso        : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ estrato     : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ preciom     : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ areaconst   : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ parqueaderos: list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ banios      : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ habitaciones: list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ tipo        : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ barrio      : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ longitud    : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ latitud     : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   ..$ default: list()
##   .. ..- attr(*, "class")= chr [1:2] "collector_guess" "collector"
##   ..$ delim  : chr ";"
##   ..- attr(*, "class")= chr "col_spec"
##  - attr(*, "problems")=<externalptr>
head(datos)
## # A tibble: 6 × 13
##      id zona    piso  estrato preciom areaconst parqueaderos banios habitaciones
##   <dbl> <chr>   <chr>   <dbl>   <dbl>     <dbl>        <dbl>  <dbl>        <dbl>
## 1  1147 Zona O… <NA>        3     250        70            1      3            6
## 2  1169 Zona O… <NA>        3     320       120            1      2            3
## 3  1350 Zona O… <NA>        3     350       220            2      2            4
## 4  5992 Zona S… 02          4     400       280            3      5            3
## 5  1212 Zona N… 01          5     260        90            1      2            3
## 6  1724 Zona N… 01          5     240        87            1      3            3
## # ℹ 4 more variables: tipo <chr>, barrio <chr>, longitud <dbl>, latitud <dbl>

La base contiene información relacionada con la localización, características físicas y precio de las viviendas.

faltantes_datos <- data.frame(
  Variable = names(datos),
  Valores_perdidos = sapply(datos, function(x) sum(is.na(x)))
)

knitr::kable(
  faltantes_datos,
  caption = "Valores perdidos por variable"
)
Valores perdidos por variable
Variable Valores_perdidos
id id 3
zona zona 3
piso piso 2638
estrato estrato 3
preciom preciom 2
areaconst areaconst 3
parqueaderos parqueaderos 1605
banios banios 3
habitaciones habitaciones 3
tipo tipo 3
barrio barrio 3
longitud longitud 3
latitud latitud 3

3. Funciones auxiliares

# ============================================================
# FUNCIONES GRÁFICAS ROBUSTAS
# ============================================================

validar_plotly <- function(grafico_plotly, grafico_respaldo, contexto){
  tryCatch(
    {
      # Forzar la construcción interna permite detectar incompatibilidades
      # antes de que el documento llegue a la etapa de renderizado.
      invisible(plotly::plotly_build(grafico_plotly))
      grafico_plotly
    },
    error = function(e){
      warning(
        paste0(
          "No fue posible construir ", contexto,
          " con plotly. Se mostrará una versión estática. Detalle: ",
          conditionMessage(e)
        ),
        call. = FALSE
      )
      grafico_respaldo
    }
  )
}

grafico_precio <- function(base, variable, nombre_x){

  if(!variable %in% names(base)){
    stop(paste("La variable", variable, "no existe en la base suministrada."))
  }

  datos_graf <- data.frame(
    x_plot = suppressWarnings(as.numeric(as.character(base[[variable]]))),
    preciom = suppressWarnings(as.numeric(as.character(base$preciom)))
  )

  datos_graf <- datos_graf[
    is.finite(datos_graf$x_plot) & is.finite(datos_graf$preciom),
    ,
    drop = FALSE
  ]

  if(nrow(datos_graf) == 0){
    stop(paste("No hay observaciones válidas para graficar", nombre_x))
  }

  datos_graf$tooltip <- paste0(
    "Precio: ", round(datos_graf$preciom, 2), " millones",
    "<br>", nombre_x, ": ", datos_graf$x_plot
  )

  # Una sola traza: evita incompatibilidades por longitudes diferentes
  g_interactivo <- plotly::plot_ly(
    data = datos_graf,
    x = ~x_plot,
    y = ~preciom,
    type = "scatter",
    mode = "markers",
    text = ~tooltip,
    hoverinfo = "text"
  ) %>%
    plotly::layout(
      xaxis = list(title = nombre_x),
      yaxis = list(title = "Precio (millones de pesos)")
    )

  g_estatico <- ggplot2::ggplot(
    datos_graf,
    ggplot2::aes(x = x_plot, y = preciom)
  ) +
    ggplot2::geom_point(alpha = 0.5) +
    ggplot2::theme_minimal() +
    ggplot2::labs(
      x = nombre_x,
      y = "Precio (millones de pesos)"
    )

  validar_plotly(
    g_interactivo,
    g_estatico,
    paste("el gráfico de", nombre_x)
  )
}

grafico_correlacion <- function(matriz_cor, titulo){

  if(!is.matrix(matriz_cor)){
    matriz_cor <- as.matrix(matriz_cor)
  }

  g_interactivo <- plotly::plot_ly(
    x = colnames(matriz_cor),
    y = rownames(matriz_cor),
    z = matriz_cor,
    type = "heatmap",
    zmin = -1,
    zmax = 1
  ) %>%
    plotly::layout(title = titulo)

  tabla_cor <- as.data.frame(as.table(matriz_cor))
  names(tabla_cor) <- c("Variable_X", "Variable_Y", "Correlacion")

  g_estatico <- ggplot2::ggplot(
    tabla_cor,
    ggplot2::aes(
      x = Variable_X,
      y = Variable_Y,
      fill = Correlacion
    )
  ) +
    ggplot2::geom_tile() +
    ggplot2::geom_text(
      ggplot2::aes(label = round(Correlacion, 2)),
      size = 3
    ) +
    ggplot2::theme_minimal() +
    ggplot2::labs(
      title = titulo,
      x = NULL,
      y = NULL
    )

  validar_plotly(
    g_interactivo,
    g_estatico,
    "la matriz de correlaciones"
  )
}

seleccionar_ofertas <- function(
    base,
    modelo,
    presupuesto,
    area_obj,
    estratos_obj,
    habitaciones_obj,
    parqueaderos_obj,
    banios_obj,
    n = 5){

  candidatas <- base %>%
    dplyr::filter(
      preciom <= presupuesto,
      estrato %in% estratos_obj
    ) %>%
    tidyr::drop_na(
      preciom,
      areaconst,
      estrato,
      habitaciones,
      parqueaderos,
      banios,
      latitud,
      longitud
    )

  if(nrow(candidatas) == 0){
    candidatas$precio_estimado <- numeric(0)
    candidatas$diferencia_modelo <- numeric(0)
    candidatas$cumple_estricto <- logical(0)
    candidatas$puntuacion <- numeric(0)
    return(candidatas)
  }

  candidatas$precio_estimado <- as.numeric(
    predict(modelo, newdata = candidatas)
  )

  candidatas <- candidatas %>%
    dplyr::mutate(
      cumple_estricto =
        areaconst >= area_obj &
        habitaciones >= habitaciones_obj &
        parqueaderos >= parqueaderos_obj &
        banios >= banios_obj,

      diferencia_modelo = precio_estimado - preciom,

      puntuacion =
        abs(areaconst - area_obj) / area_obj +
        abs(habitaciones - habitaciones_obj) / habitaciones_obj +
        abs(parqueaderos - parqueaderos_obj) / parqueaderos_obj +
        abs(banios - banios_obj) / banios_obj +
        0.5 * abs(estrato - mean(estratos_obj))
    ) %>%
    dplyr::arrange(
      dplyr::desc(cumple_estricto),
      puntuacion,
      dplyr::desc(diferencia_modelo)
    ) %>%
    dplyr::slice_head(n = n)

  candidatas
}

Parte I. Vivienda 1: Casa en Zona Norte

4. Filtrado de la base 1

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

nrow(base1)
## [1] 722
knitr::kable(
  head(base1, 3),
  caption = "Primeros tres registros de base1"
)
Primeros tres registros de base1
id zona piso estrato preciom areaconst parqueaderos banios habitaciones tipo barrio longitud latitud
1209 Zona Norte 02 5 320 150 2 4 6 Casa acopi -76.51341 3.47968
1592 Zona Norte 02 5 780 380 2 3 3 Casa acopi -76.51674 3.48721
4057 Zona Norte 02 6 750 445 NA 7 6 Casa acopi -76.52950 3.38527
tabla_tipo1 <- as.data.frame(table(base1$tipo))
tabla_zona1 <- as.data.frame(table(base1$zona))
tabla_estrato1 <- as.data.frame(table(base1$estrato))

knitr::kable(tabla_tipo1, col.names = c("Tipo", "Frecuencia"))
Tipo Frecuencia
Casa 722
knitr::kable(tabla_zona1, col.names = c("Zona", "Frecuencia"))
Zona Frecuencia
Zona Norte 722
knitr::kable(tabla_estrato1, col.names = c("Estrato", "Frecuencia"))
Estrato Frecuencia
3 235
4 161
5 271
6 55

4.1 Mapa de las casas de Zona Norte

base1_mapa <- base1 %>%
  dplyr::filter(
    !is.na(latitud),
    !is.na(longitud),
    is.finite(latitud),
    is.finite(longitud)
  )

leaflet::leaflet(base1_mapa) %>%
  leaflet::addTiles() %>%
  leaflet::addCircleMarkers(
    lng = ~longitud,
    lat = ~latitud,
    radius = 4,
    fillOpacity = 0.6,
    popup = ~paste0(
      "<b>ID:</b> ", id,
      "<br><b>Barrio:</b> ", barrio,
      "<br><b>Precio:</b> $", preciom, " millones",
      "<br><b>Área:</b> ", areaconst, " m²",
      "<br><b>Estrato:</b> ", estrato
    )
  )

Interpretación

El filtro garantiza que todos los registros seleccionados estén clasificados como casas de Zona Norte. Sin embargo, si en el mapa se observan puntos alejados geográficamente, esto puede obedecer a inconsistencias en las coordenadas, errores de digitación, diferencias entre clasificación comercial y límites geográficos, o problemas de georreferenciación.

5. Análisis exploratorio - Vivienda 1

base1_modelo <- base1 %>%
  tidyr::drop_na(
    preciom,
    areaconst,
    estrato,
    habitaciones,
    parqueaderos,
    banios
  )

5.1 Matriz de correlación

variables1 <- base1_modelo %>%
  dplyr::select(
    preciom,
    areaconst,
    estrato,
    habitaciones,
    parqueaderos,
    banios
  )

cor1 <- cor(
  variables1,
  use = "complete.obs",
  method = "pearson"
)

round(cor1, 3)
##              preciom areaconst estrato habitaciones parqueaderos banios
## preciom        1.000     0.685   0.528        0.365        0.412  0.509
## areaconst      0.685     1.000   0.354        0.421        0.307  0.457
## estrato        0.528     0.354   1.000        0.058        0.261  0.351
## habitaciones   0.365     0.421   0.058        1.000        0.241  0.590
## parqueaderos   0.412     0.307   0.261        0.241        1.000  0.392
## banios         0.509     0.457   0.351        0.590        0.392  1.000
grafico_correlacion(
  cor1,
  "Matriz de correlaciones - Casas Zona Norte"
)

5.2 Gráficos interactivos

grafico_precio(base1_modelo, "areaconst", "Área construida (m²)")
grafico_precio(base1_modelo, "estrato", "Estrato")
grafico_precio(base1_modelo, "habitaciones", "Habitaciones")
grafico_precio(base1_modelo, "parqueaderos", "Parqueaderos")
grafico_precio(base1_modelo, "banios", "Baños")

Interpretación

Se debe analizar el sentido y la intensidad de la asociación entre el precio y las características de la vivienda. Una correlación positiva entre área construida y precio indica que las casas más grandes tienden a presentar valores superiores. Asimismo, estrato, baños y parqueaderos pueden mostrar asociaciones positivas con el precio debido a que representan características relacionadas con mayor tamaño y nivel socioeconómico.

6. Modelo de regresión múltiple - Vivienda 1

modelo1 <- lm(
  preciom ~ areaconst +
    estrato +
    habitaciones +
    parqueaderos +
    banios,
  data = base1_modelo
)

summary(modelo1)
## 
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos + 
##     banios, data = base1_modelo)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -784.29  -77.56  -16.03   47.67  978.61 
## 
## Coefficients:
##                Estimate Std. Error t value Pr(>|t|)    
## (Intercept)  -238.17090   44.40551  -5.364 1.34e-07 ***
## areaconst       0.67673    0.05281  12.814  < 2e-16 ***
## estrato        80.63495    9.82632   8.206 2.70e-15 ***
## habitaciones    7.64511    5.65873   1.351    0.177    
## parqueaderos   24.00598    5.86889   4.090 5.14e-05 ***
## banios         18.89938    7.48800   2.524    0.012 *  
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 155.1 on 429 degrees of freedom
## Multiple R-squared:  0.6041, Adjusted R-squared:  0.5995 
## F-statistic: 130.9 on 5 and 429 DF,  p-value: < 2.2e-16
coeficientes1 <- broom::tidy(
  modelo1,
  conf.int = TRUE
) %>%
  dplyr::mutate(
    Significativo = ifelse(p.value < 0.05, "Sí", "No")
  )

knitr::kable(
  coeficientes1,
  digits = 4,
  caption = "Coeficientes del modelo completo - Vivienda 1"
)
Coeficientes del modelo completo - Vivienda 1
term estimate std.error statistic p.value conf.low conf.high Significativo
(Intercept) -238.1709 44.4055 -5.3635 0.0000 -325.4503 -150.8915 Sí
areaconst 0.6767 0.0528 12.8140 0.0000 0.5729 0.7805 Sí
estrato 80.6349 9.8263 8.2060 0.0000 61.3212 99.9487 Sí
habitaciones 7.6451 5.6587 1.3510 0.1774 -3.4772 18.7674 No
parqueaderos 24.0060 5.8689 4.0904 0.0001 12.4706 35.5413 Sí
banios 18.8994 7.4880 2.5240 0.0120 4.1816 33.6171 Sí
ajuste1 <- broom::glance(modelo1)

knitr::kable(
  ajuste1 %>%
    dplyr::select(
      r.squared,
      adj.r.squared,
      sigma,
      statistic,
      p.value,
      AIC,
      BIC
    ),
  digits = 4,
  caption = "Indicadores de ajuste - Vivienda 1"
)
Indicadores de ajuste - Vivienda 1
r.squared adj.r.squared sigma statistic p.value AIC BIC
0.6041 0.5995 155.1149 130.919 0 5630.86 5659.387

Interpretación del modelo

Cada coeficiente representa el cambio promedio esperado en el precio, medido en millones de pesos, cuando la variable correspondiente aumenta en una unidad y las demás variables permanecen constantes.

Los coeficientes con valores p < 0.05 se consideran estadísticamente significativos al nivel del 5 %.

El coeficiente \(R^2\) indica la proporción de la variabilidad observada en el precio que es explicada conjuntamente por las variables incluidas en el modelo.

7. Comparación de modelos - Vivienda 1

modelo1_reducido <- step(
  modelo1,
  direction = "backward",
  trace = 0
)

comparacion1 <- dplyr::bind_rows(
  broom::glance(modelo1) %>% dplyr::mutate(Modelo = "Modelo completo"),
  broom::glance(modelo1_reducido) %>% dplyr::mutate(Modelo = "Modelo reducido")
) %>%
  dplyr::select(
    Modelo,
    r.squared,
    adj.r.squared,
    sigma,
    AIC,
    BIC
  )

knitr::kable(
  comparacion1,
  digits = 3,
  caption = "Comparación de modelos - Vivienda 1"
)
Comparación de modelos - Vivienda 1
Modelo r.squared adj.r.squared sigma AIC BIC
Modelo completo 0.604 0.599 155.115 5630.860 5659.387
Modelo reducido 0.602 0.599 155.264 5630.706 5655.159
modelo1_final <- if(AIC(modelo1_reducido) < AIC(modelo1)){
  modelo1_reducido
} else {
  modelo1
}

formula(modelo1_final)
## preciom ~ areaconst + estrato + parqueaderos + banios

8. Validación de supuestos - Vivienda 1

En la validación de supuestos se utilizan gráficos estadísticos de ggplot2 en lugar de convertirlos a plotly. Esto aumenta la estabilidad del documento y evita incompatibilidades entre versiones de plotly y ggplot2.

# Asegurar que exista el modelo final
if(!exists("modelo1_final")){
  modelo1 <- lm(
    preciom ~ areaconst +
      estrato +
      habitaciones +
      parqueaderos +
      banios,
    data = base1_modelo
  )

  modelo1_reducido <- step(
    modelo1,
    direction = "backward",
    trace = 0
  )

  modelo1_final <- if(AIC(modelo1_reducido) < AIC(modelo1)){
    modelo1_reducido
  } else {
    modelo1
  }
}

diagnostico1 <- data.frame(
  Ajustados = fitted(modelo1_final),
  Residuos = residuals(modelo1_final),
  Residuos_est = rstandard(modelo1_final)
)

# Residuos vs valores ajustados
g_res1 <- ggplot2::ggplot(
  diagnostico1,
  ggplot2::aes(x = Ajustados, y = Residuos)
) +
  ggplot2::geom_point(alpha = 0.45) +
  ggplot2::geom_hline(yintercept = 0, linetype = 2) +
  ggplot2::geom_smooth(method = "loess", se = FALSE) +
  ggplot2::theme_minimal() +
  ggplot2::labs(
    title = "Residuos vs valores ajustados - Vivienda 1",
    x = "Valores ajustados",
    y = "Residuos"
  )

print(g_res1)

# Gráfico Q-Q
g_qq1 <- ggplot2::ggplot(
  diagnostico1,
  ggplot2::aes(sample = Residuos_est)
) +
  ggplot2::stat_qq() +
  ggplot2::stat_qq_line() +
  ggplot2::theme_minimal() +
  ggplot2::labs(
    title = "Gráfico Q-Q de residuos - Vivienda 1",
    x = "Cuantiles teóricos",
    y = "Residuos estandarizados"
  )

print(g_qq1)

# Pruebas formales
cat("
Prueba de normalidad Anderson-Darling:
")
## 
## Prueba de normalidad Anderson-Darling:
print(nortest::ad.test(residuals(modelo1_final)))
## 
##  Anderson-Darling normality test
## 
## data:  residuals(modelo1_final)
## A = 13.321, p-value < 2.2e-16
cat("
Prueba de homocedasticidad Breusch-Pagan:
")
## 
## Prueba de homocedasticidad Breusch-Pagan:
print(lmtest::bptest(modelo1_final))
## 
##  studentized Breusch-Pagan test
## 
## data:  modelo1_final
## BP = 80.764, df = 4, p-value < 2.2e-16
cat("
Prueba de independencia Durbin-Watson:
")
## 
## Prueba de independencia Durbin-Watson:
print(lmtest::dwtest(modelo1_final))
## 
##  Durbin-Watson test
## 
## data:  modelo1_final
## DW = 1.7702, p-value = 0.00716
## alternative hypothesis: true autocorrelation is greater than 0
cat("
Factores de inflación de varianza (VIF):
")
## 
## Factores de inflación de varianza (VIF):
if(length(attr(terms(modelo1_final), "term.labels")) > 1){
  print(car::vif(modelo1_final))
} else {
  cat("El modelo final tiene un solo predictor; VIF no aplica.
")
}
##    areaconst      estrato parqueaderos       banios 
##     1.358491     1.220649     1.226227     1.437956
# Observaciones influyentes
cooks1 <- cooks.distance(modelo1_final)
limite_cook1 <- 4 / nobs(modelo1_final)

cat(
  "
Observaciones potencialmente influyentes según Cook:",
  sum(cooks1 > limite_cook1),
  "
"
)
## 
## Observaciones potencialmente influyentes según Cook: 24

Interpretación de supuestos

  • Linealidad: en el gráfico de residuos no debería observarse un patrón sistemático pronunciado.
  • Normalidad: un valor p menor a 0.05 en Anderson-Darling indica evidencia contra la normalidad de los residuos.
  • Homocedasticidad: un valor p menor a 0.05 en Breusch-Pagan indica evidencia de varianza no constante.
  • Independencia: la prueba Durbin-Watson ayuda a identificar autocorrelación residual.
  • Multicolinealidad: valores VIF inferiores a 5 suelen indicar ausencia de colinealidad problemática.
  • Influencia: la distancia de Cook permite identificar observaciones que merecen revisión individual.

Si se incumplen algunos supuestos, se recomienda evaluar transformación del precio, errores estándar robustos, regresión robusta o especificaciones con variables adicionales.

9. Predicción - Vivienda 1

# Verificación del rango de área para detectar posible extrapolación
rango_area1 <- range(base1_modelo$areaconst, na.rm = TRUE)
cat(
  "Rango observado de área en base1:",
  round(rango_area1[1], 2), "a", round(rango_area1[2], 2), "m²\n"
)
## Rango observado de área en base1: 30 a 1440 m²
solicitud1 <- data.frame(
  areaconst = c(200, 200),
  estrato = c(4, 5),
  habitaciones = c(4, 4),
  parqueaderos = c(1, 1),
  banios = c(2, 2)
)

prediccion1 <- tryCatch(
  predict(
    modelo1_final,
    newdata = solicitud1,
    interval = "prediction",
    level = 0.95
  ),
  error = function(e){
    stop(
      paste0(
        "No fue posible realizar la predicción de la Vivienda 1: ",
        conditionMessage(e)
      )
    )
  }
)

resultado_pred1 <- cbind(
  solicitud1,
  prediccion1
)

knitr::kable(
  resultado_pred1,
  digits = 2,
  caption = "Predicciones de precio - Vivienda 1"
)
Predicciones de precio - Vivienda 1
areaconst estrato habitaciones parqueaderos banios fit lwr upr
200 4 4 1 2 308.66 2.51 614.80
200 5 4 1 2 385.87 79.20 692.53

10. Ofertas potenciales - Vivienda 1

ofertas1 <- seleccionar_ofertas(
  base = base1,
  modelo = modelo1_final,
  presupuesto = 350,
  area_obj = 200,
  estratos_obj = c(4, 5),
  habitaciones_obj = 4,
  parqueaderos_obj = 1,
  banios_obj = 2,
  n = 5
)

knitr::kable(
  ofertas1 %>%
    dplyr::select(
      id,
      barrio,
      preciom,
      precio_estimado,
      diferencia_modelo,
      areaconst,
      estrato,
      habitaciones,
      parqueaderos,
      banios,
      cumple_estricto
    ),
  digits = 2,
  caption = "Cinco ofertas potenciales - Vivienda 1"
)
Cinco ofertas potenciales - Vivienda 1
id barrio preciom precio_estimado diferencia_modelo areaconst estrato habitaciones parqueaderos banios cumple_estricto
1943 vipasa 350 487.43 137.43 346 5 4 1 2 TRUE
1108 la merced 330 374.54 44.54 260 4 4 1 3 TRUE
1163 la merced 350 421.08 71.08 216 5 4 2 2 TRUE
4267 el bosque 335 435.55 100.55 202 5 5 1 4 TRUE
1270 el bosque 350 412.03 62.03 203 5 5 2 2 TRUE
if(nrow(ofertas1) > 0){
  leaflet::leaflet(ofertas1) %>%
    leaflet::addTiles() %>%
    leaflet::addCircleMarkers(
      lng = ~longitud,
      lat = ~latitud,
      radius = 7,
      fillOpacity = 0.8,
      popup = ~paste0(
        "<b>ID:</b> ", id,
        "<br><b>Barrio:</b> ", barrio,
        "<br><b>Precio:</b> $", preciom, " millones",
        "<br><b>Predicción:</b> $", round(precio_estimado, 1), " millones",
        "<br><b>Área:</b> ", areaconst, " m²",
        "<br><b>Estrato:</b> ", estrato,
        "<br><b>Habitaciones:</b> ", habitaciones,
        "<br><b>Baños:</b> ", banios,
        "<br><b>Parqueaderos:</b> ", parqueaderos,
        "<br><b>Cumple estrictamente:</b> ", cumple_estricto
      )
    )
}

Parte II. Vivienda 2: Apartamento en Zona Sur

11. Filtrado de la base 2

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

nrow(base2)
## [1] 2787
knitr::kable(
  head(base2, 3),
  caption = "Primeros tres registros de base2"
)
Primeros tres registros de base2
id zona piso estrato preciom areaconst parqueaderos banios habitaciones tipo barrio longitud latitud
5098 Zona Sur 05 4 290 96 1 2 3 Apartamento acopi -76.53464 3.44987
698 Zona Sur 02 3 78 40 1 1 2 Apartamento aguablanca -76.50100 3.40000
8199 Zona Sur NA 6 875 194 2 5 3 Apartamento aguacatal -76.55700 3.45900
tabla_tipo2 <- as.data.frame(table(base2$tipo))
tabla_zona2 <- as.data.frame(table(base2$zona))
tabla_estrato2 <- as.data.frame(table(base2$estrato))

knitr::kable(tabla_tipo2, col.names = c("Tipo", "Frecuencia"))
Tipo Frecuencia
Apartamento 2787
knitr::kable(tabla_zona2, col.names = c("Zona", "Frecuencia"))
Zona Frecuencia
Zona Sur 2787
knitr::kable(tabla_estrato2, col.names = c("Estrato", "Frecuencia"))
Estrato Frecuencia
3 201
4 1091
5 1033
6 462

11.1 Mapa base 2

base2_mapa <- base2 %>%
  dplyr::filter(
    !is.na(latitud),
    !is.na(longitud),
    is.finite(latitud),
    is.finite(longitud)
  )

leaflet::leaflet(base2_mapa) %>%
  leaflet::addTiles() %>%
  leaflet::addCircleMarkers(
    lng = ~longitud,
    lat = ~latitud,
    radius = 4,
    fillOpacity = 0.5,
    popup = ~paste0(
      "<b>ID:</b> ", id,
      "<br><b>Barrio:</b> ", barrio,
      "<br><b>Precio:</b> $", preciom, " millones",
      "<br><b>Área:</b> ", areaconst, " m²",
      "<br><b>Estrato:</b> ", estrato
    )
  )

12. Análisis exploratorio - Vivienda 2

base2_modelo <- base2 %>%
  tidyr::drop_na(
    preciom,
    areaconst,
    estrato,
    habitaciones,
    parqueaderos,
    banios
  )
variables2 <- base2_modelo %>%
  dplyr::select(
    preciom,
    areaconst,
    estrato,
    habitaciones,
    parqueaderos,
    banios
  )

cor2 <- cor(
  variables2,
  use = "complete.obs",
  method = "pearson"
)

round(cor2, 3)
##              preciom areaconst estrato habitaciones parqueaderos banios
## preciom        1.000     0.741   0.650        0.296        0.693  0.711
## areaconst      0.741     1.000   0.452        0.407        0.578  0.664
## estrato        0.650     0.452   1.000        0.177        0.486  0.535
## habitaciones   0.296     0.407   0.177        1.000        0.237  0.520
## parqueaderos   0.693     0.578   0.486        0.237        1.000  0.556
## banios         0.711     0.664   0.535        0.520        0.556  1.000
grafico_correlacion(
  cor2,
  "Matriz de correlaciones - Apartamentos Zona Sur"
)
grafico_precio(base2_modelo, "areaconst", "Área construida (m²)")
grafico_precio(base2_modelo, "estrato", "Estrato")
grafico_precio(base2_modelo, "habitaciones", "Habitaciones")
grafico_precio(base2_modelo, "parqueaderos", "Parqueaderos")
grafico_precio(base2_modelo, "banios", "Baños")

13. Modelo de regresión - Vivienda 2

modelo2 <- lm(
  preciom ~ areaconst +
    estrato +
    habitaciones +
    parqueaderos +
    banios,
  data = base2_modelo
)

summary(modelo2)
## 
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos + 
##     banios, data = base2_modelo)
## 
## Residuals:
##      Min       1Q   Median       3Q      Max 
## -1092.02   -42.28    -1.33    40.58   926.56 
## 
## Coefficients:
##                Estimate Std. Error t value Pr(>|t|)    
## (Intercept)  -261.62501   15.63220 -16.736  < 2e-16 ***
## areaconst       1.28505    0.05403  23.785  < 2e-16 ***
## estrato        60.89709    3.08408  19.746  < 2e-16 ***
## habitaciones  -24.83693    3.89229  -6.381 2.11e-10 ***
## parqueaderos   72.91468    3.95797  18.422  < 2e-16 ***
## banios         50.69675    3.39637  14.927  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 98.02 on 2375 degrees of freedom
## Multiple R-squared:  0.7485, Adjusted R-squared:  0.748 
## F-statistic:  1414 on 5 and 2375 DF,  p-value: < 2.2e-16
coeficientes2 <- broom::tidy(
  modelo2,
  conf.int = TRUE
) %>%
  dplyr::mutate(
    Significativo = ifelse(p.value < 0.05, "Sí", "No")
  )

knitr::kable(
  coeficientes2,
  digits = 4,
  caption = "Coeficientes del modelo - Vivienda 2"
)
Coeficientes del modelo - Vivienda 2
term estimate std.error statistic p.value conf.low conf.high Significativo
(Intercept) -261.6250 15.6322 -16.7363 0 -292.2792 -230.9708 Sí
areaconst 1.2850 0.0540 23.7853 0 1.1791 1.3910 Sí
estrato 60.8971 3.0841 19.7457 0 54.8493 66.9448 Sí
habitaciones -24.8369 3.8923 -6.3811 0 -32.4696 -17.2043 Sí
parqueaderos 72.9147 3.9580 18.4223 0 65.1533 80.6761 Sí
banios 50.6967 3.3964 14.9267 0 44.0366 57.3569 Sí

14. Comparación de modelos - Vivienda 2

modelo2_reducido <- step(
  modelo2,
  direction = "backward",
  trace = 0
)

comparacion2 <- dplyr::bind_rows(
  broom::glance(modelo2) %>% dplyr::mutate(Modelo = "Modelo completo"),
  broom::glance(modelo2_reducido) %>% dplyr::mutate(Modelo = "Modelo reducido")
) %>%
  dplyr::select(
    Modelo,
    r.squared,
    adj.r.squared,
    sigma,
    AIC,
    BIC
  )

knitr::kable(
  comparacion2,
  digits = 3,
  caption = "Comparación de modelos - Vivienda 2"
)
Comparación de modelos - Vivienda 2
Modelo r.squared adj.r.squared sigma AIC BIC
Modelo completo 0.749 0.748 98.019 28599.54 28639.96
Modelo reducido 0.749 0.748 98.019 28599.54 28639.96
modelo2_final <- if(AIC(modelo2_reducido) < AIC(modelo2)){
  modelo2_reducido
} else {
  modelo2
}

formula(modelo2_final)
## preciom ~ areaconst + estrato + habitaciones + parqueaderos + 
##     banios

15. Validación de supuestos - Vivienda 2

En la validación de supuestos se utilizan gráficos estadísticos de ggplot2 en lugar de convertirlos a plotly. Esto aumenta la estabilidad del documento y evita incompatibilidades entre versiones de plotly y ggplot2.

# Asegurar que exista el modelo final
if(!exists("modelo2_final")){
  modelo2 <- lm(
    preciom ~ areaconst +
      estrato +
      habitaciones +
      parqueaderos +
      banios,
    data = base2_modelo
  )

  modelo2_reducido <- step(
    modelo2,
    direction = "backward",
    trace = 0
  )

  modelo2_final <- if(AIC(modelo2_reducido) < AIC(modelo2)){
    modelo2_reducido
  } else {
    modelo2
  }
}

diagnostico2 <- data.frame(
  Ajustados = fitted(modelo2_final),
  Residuos = residuals(modelo2_final),
  Residuos_est = rstandard(modelo2_final)
)

# Residuos vs valores ajustados
g_res2 <- ggplot2::ggplot(
  diagnostico2,
  ggplot2::aes(x = Ajustados, y = Residuos)
) +
  ggplot2::geom_point(alpha = 0.45) +
  ggplot2::geom_hline(yintercept = 0, linetype = 2) +
  ggplot2::geom_smooth(method = "loess", se = FALSE) +
  ggplot2::theme_minimal() +
  ggplot2::labs(
    title = "Residuos vs valores ajustados - Vivienda 2",
    x = "Valores ajustados",
    y = "Residuos"
  )

print(g_res2)

# Gráfico Q-Q
g_qq2 <- ggplot2::ggplot(
  diagnostico2,
  ggplot2::aes(sample = Residuos_est)
) +
  ggplot2::stat_qq() +
  ggplot2::stat_qq_line() +
  ggplot2::theme_minimal() +
  ggplot2::labs(
    title = "Gráfico Q-Q de residuos - Vivienda 2",
    x = "Cuantiles teóricos",
    y = "Residuos estandarizados"
  )

print(g_qq2)

# Pruebas formales
cat("
Prueba de normalidad Anderson-Darling:
")
## 
## Prueba de normalidad Anderson-Darling:
print(nortest::ad.test(residuals(modelo2_final)))
## 
##  Anderson-Darling normality test
## 
## data:  residuals(modelo2_final)
## A = 72.413, p-value < 2.2e-16
cat("
Prueba de homocedasticidad Breusch-Pagan:
")
## 
## Prueba de homocedasticidad Breusch-Pagan:
print(lmtest::bptest(modelo2_final))
## 
##  studentized Breusch-Pagan test
## 
## data:  modelo2_final
## BP = 754.81, df = 5, p-value < 2.2e-16
cat("
Prueba de independencia Durbin-Watson:
")
## 
## Prueba de independencia Durbin-Watson:
print(lmtest::dwtest(modelo2_final))
## 
##  Durbin-Watson test
## 
## data:  modelo2_final
## DW = 1.5333, p-value < 2.2e-16
## alternative hypothesis: true autocorrelation is greater than 0
cat("
Factores de inflación de varianza (VIF):
")
## 
## Factores de inflación de varianza (VIF):
if(length(attr(terms(modelo2_final), "term.labels")) > 1){
  print(car::vif(modelo2_final))
} else {
  cat("El modelo final tiene un solo predictor; VIF no aplica.
")
}
##    areaconst      estrato habitaciones parqueaderos       banios 
##     2.066518     1.545162     1.429280     1.737878     2.529494
# Observaciones influyentes
cooks2 <- cooks.distance(modelo2_final)
limite_cook2 <- 4 / nobs(modelo2_final)

cat(
  "
Observaciones potencialmente influyentes según Cook:",
  sum(cooks2 > limite_cook2),
  "
"
)
## 
## Observaciones potencialmente influyentes según Cook: 118

Interpretación de supuestos

  • Linealidad: en el gráfico de residuos no debería observarse un patrón sistemático pronunciado.
  • Normalidad: un valor p menor a 0.05 en Anderson-Darling indica evidencia contra la normalidad de los residuos.
  • Homocedasticidad: un valor p menor a 0.05 en Breusch-Pagan indica evidencia de varianza no constante.
  • Independencia: la prueba Durbin-Watson ayuda a identificar autocorrelación residual.
  • Multicolinealidad: valores VIF inferiores a 5 suelen indicar ausencia de colinealidad problemática.
  • Influencia: la distancia de Cook permite identificar observaciones que merecen revisión individual.

Si se incumplen algunos supuestos, se recomienda evaluar transformación del precio, errores estándar robustos, regresión robusta o especificaciones con variables adicionales.

16. Predicción - Vivienda 2

# Verificación del rango de área para detectar posible extrapolación
rango_area2 <- range(base2_modelo$areaconst, na.rm = TRUE)
cat(
  "Rango observado de área en base2:",
  round(rango_area2[1], 2), "a", round(rango_area2[2], 2), "m²\n"
)
## Rango observado de área en base2: 40 a 932 m²
solicitud2 <- data.frame(
  areaconst = c(300, 300),
  estrato = c(5, 6),
  habitaciones = c(5, 5),
  parqueaderos = c(3, 3),
  banios = c(3, 3)
)

prediccion2 <- tryCatch(
  predict(
    modelo2_final,
    newdata = solicitud2,
    interval = "prediction",
    level = 0.95
  ),
  error = function(e){
    stop(
      paste0(
        "No fue posible realizar la predicción de la Vivienda 2: ",
        conditionMessage(e)
      )
    )
  }
)

resultado_pred2 <- cbind(
  solicitud2,
  prediccion2
)

knitr::kable(
  resultado_pred2,
  digits = 2,
  caption = "Predicciones de precio - Vivienda 2"
)
Predicciones de precio - Vivienda 2
areaconst estrato habitaciones parqueaderos banios fit lwr upr
300 5 5 3 3 675.02 481.45 868.59
300 6 5 3 3 735.92 542.31 929.53

17. Ofertas potenciales - Vivienda 2

ofertas2 <- seleccionar_ofertas(
  base = base2,
  modelo = modelo2_final,
  presupuesto = 850,
  area_obj = 300,
  estratos_obj = c(5, 6),
  habitaciones_obj = 5,
  parqueaderos_obj = 3,
  banios_obj = 3,
  n = 5
)

knitr::kable(
  ofertas2 %>%
    dplyr::select(
      id,
      barrio,
      preciom,
      precio_estimado,
      diferencia_modelo,
      areaconst,
      estrato,
      habitaciones,
      parqueaderos,
      banios,
      cumple_estricto
    ),
  digits = 2,
  caption = "Cinco ofertas potenciales - Vivienda 2"
)
Cinco ofertas potenciales - Vivienda 2
id barrio preciom precio_estimado diferencia_modelo areaconst estrato habitaciones parqueaderos banios cumple_estricto
7512 seminario 670 751.58 81.58 300 5 6 3 5 TRUE
7182 guadalupe 730 1279.33 549.33 573 5 5 3 8 TRUE
6175 capri 350 661.31 311.31 270 5 4 3 3 FALSE
7680 pampa linda 450 682.29 232.29 267 5 3 3 3 FALSE
6205 capri 350 673.30 323.30 260 5 3 3 3 FALSE
if(nrow(ofertas2) > 0){
  leaflet::leaflet(ofertas2) %>%
    leaflet::addTiles() %>%
    leaflet::addCircleMarkers(
      lng = ~longitud,
      lat = ~latitud,
      radius = 7,
      fillOpacity = 0.8,
      popup = ~paste0(
        "<b>ID:</b> ", id,
        "<br><b>Barrio:</b> ", barrio,
        "<br><b>Precio:</b> $", preciom, " millones",
        "<br><b>Predicción:</b> $", round(precio_estimado, 1), " millones",
        "<br><b>Área:</b> ", areaconst, " m²",
        "<br><b>Estrato:</b> ", estrato,
        "<br><b>Habitaciones:</b> ", habitaciones,
        "<br><b>Baños:</b> ", banios,
        "<br><b>Parqueaderos:</b> ", parqueaderos,
        "<br><b>Cumple estrictamente:</b> ", cumple_estricto
      )
    )
}

18. Resumen ejecutivo

resumen_modelos <- data.frame(
  Solicitud = c("Vivienda 1", "Vivienda 2"),
  Tipo = c("Casa", "Apartamento"),
  Zona = c("Zona Norte", "Zona Sur"),
  Presupuesto_millones = c(350, 850),
  R2 = c(
    summary(modelo1_final)$r.squared,
    summary(modelo2_final)$r.squared
  ),
  R2_Ajustado = c(
    summary(modelo1_final)$adj.r.squared,
    summary(modelo2_final)$adj.r.squared
  )
)

knitr::kable(
  resumen_modelos,
  digits = 3,
  caption = "Resumen comparativo de los modelos"
)
Resumen comparativo de los modelos
Solicitud Tipo Zona Presupuesto_millones R2 R2_Ajustado
Vivienda 1 Casa Zona Norte 350 0.602 0.599
Vivienda 2 Apartamento Zona Sur 850 0.749 0.748

Conclusiones

El análisis permitió construir modelos de regresión múltiple independientes para las dos solicitudes inmobiliarias. Para cada caso se evaluó la relación entre el precio y las características físicas de las viviendas, se verificó la significancia de los coeficientes, se compararon especificaciones alternativas y se analizaron los principales supuestos del modelo.

Para la primera solicitud, las predicciones deben contrastarse con el presupuesto máximo de 350 millones de pesos y con el cumplimiento de las características solicitadas. Las ofertas seleccionadas constituyen alternativas de interés por su cercanía al perfil buscado y por encontrarse dentro del límite financiero establecido.

Para la segunda solicitud, el mismo procedimiento permite establecer si los apartamentos disponibles y sus precios estimados son compatibles con el crédito máximo preaprobado de 850 millones de pesos.

Las estimaciones obtenidas deben considerarse como apoyo cuantitativo a la decisión y no como una valoración comercial definitiva. Antes de una recomendación final es conveniente complementar el análisis con inspección física, antigüedad del inmueble, estado de conservación, calidad de acabados, costos de administración, seguridad, accesibilidad y condiciones particulares de negociación.

Recomendaciones

  1. Priorizar las ofertas que cumplan simultáneamente con el presupuesto y las características mínimas requeridas.
  2. Revisar de manera individual las viviendas identificadas como potencialmente atractivas.
  3. Verificar las coordenadas geográficas cuando se detecten observaciones alejadas de la zona esperada.
  4. Complementar los modelos con variables como barrio, antigüedad, acabados, estado del inmueble y valor de administración.
  5. Considerar métodos robustos o transformaciones si se detecta heterocedasticidad o desviaciones importantes de los supuestos clásicos.

Información de reproducibilidad

El siguiente bloque registra las versiones de R y de los paquetes utilizados. Esto facilita la trazabilidad del análisis y permite diagnosticar diferencias entre equipos.

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