1 Introducción

1.1 Contexto y planteamiento del problema

El mercado de vivienda urbana concentra información heterogénea —precios, áreas construidas, ubicación geográfica, atributos físicos y clasificación socioeconómica— cuya lectura conjunta rara vez es evidente a partir de estadísticas univariadas. Para una empresa inmobiliaria que participa activamente en la compra, venta y valoración de inmuebles, la capacidad de sintetizar esa complejidad en patrones interpretables constituye una fuente directa de ventaja competitiva: permite anticipar segmentos de demanda, calibrar precios de referencia por zona y tipo de inmueble, y focalizar el esfuerzo comercial en los nichos de mayor rentabilidad relativa.

Este informe desarrolla un análisis estadístico multivariado sobre una base de oferta inmobiliaria urbana, obtenida mediante web scraping del portal OLX y dispuesta en el paquete paqueteMODELOS (objeto vivienda); el tamaño exacto de la muestra y su composición se detallan en la sección 3. Se abordan tres técnicas de reducción y clasificación de la información —Análisis de Componentes Principales (ACP), Análisis de Conglomerados y Análisis de Correspondencias (simple y múltiple)— complementadas con visualización geoespacial, con el fin de construir una lectura integral del mercado que soporte decisiones estratégicas de inversión y valoración.

1.2 Objetivos

Objetivo general. Caracterizar la estructura multidimensional de la oferta inmobiliaria urbana mediante técnicas de análisis estadístico multivariado, con fines de apoyo a la toma de decisiones comerciales y de inversión.

Objetivos específicos.

  1. Reducir la dimensionalidad de las variables cuantitativas del inmueble mediante Análisis de Componentes Principales, identificando los ejes de variación que mejor explican las diferencias de precio y características físicas de la oferta.
  2. Segmentar el portafolio de inmuebles en conglomerados homogéneos internamente y heterogéneos entre sí, describiendo su perfil socioeconómico y geográfico.
  3. Examinar la asociación entre tipo de vivienda, zona y barrio mediante Análisis de Correspondencias, identificando patrones de especialización de la oferta en el territorio.
  4. Traducir los hallazgos anteriores en recomendaciones estratégicas concretas para la empresa inmobiliaria.

1.3 Estructura del informe

El documento se organiza en un marco metodológico (sección 2), la preparación del entorno y los datos (sección 3), un análisis exploratorio (sección 4), los tres análisis multivariados solicitados —ACP, conglomerados y correspondencias— (secciones 5 a 7), una síntesis integradora (sección 8) y, finalmente, las conclusiones y recomendaciones estratégicas (sección 9), las referencias (sección 10) y un anexo de reproducibilidad (sección 11).

2 Marco metodológico

2.1 Fuente y descripción de los datos

Los datos provienen del objeto vivienda del paquete paqueteMODELOS (repositorio centromagis/paqueteMODELOS), construido mediante web scraping de anuncios de venta de vivienda publicados en OLX. Cada fila representa un inmueble ofertado, con las siguientes variables:

Variable Tipo Descripción
id Identificador Código único del anuncio (no se usa como variable analítica).
zona Categórica Zona urbana en la que se ubica el inmueble.
piso Categórica Piso del inmueble (aplica principalmente a apartamentos; NA en casas).
estrato Ordinal/Numérica Estrato socioeconómico (1 a 6, sistema colombiano de clasificación predial).
preciom Cuantitativa Precio de venta, en millones de pesos.
areaconst Cuantitativa Área construida, en metros cuadrados.
parqueaderos Cuantitativa Número de parqueaderos.
banios Cuantitativa Número de baños.
habitaciones Cuantitativa Número de habitaciones.
tipo Categórica Tipo de inmueble (casa, apartamento, etc.).
barrio Categórica Barrio de ubicación.
longitud, latitud Numérica Coordenadas geográficas del inmueble.

2.2 Técnicas estadísticas empleadas

Análisis de Componentes Principales (ACP). Técnica de reducción de dimensionalidad que, a partir de la matriz de correlaciones de \(p\) variables cuantitativas estandarizadas, obtiene su descomposición en valores y vectores propios (\(R = V\Lambda V^{T}\)) para construir \(p\) nuevas variables no correlacionadas (componentes), ordenadas por la varianza que explican. Se emplea aquí para sintetizar el tamaño/calidad del inmueble a partir de sus atributos físicos y de precio (Jolliffe, 2002; Husson, Lê & Pagès, 2017).

Análisis de Conglomerados. Conjunto de técnicas de clasificación no supervisada que agrupan observaciones minimizando la variabilidad intra-grupo y maximizando la variabilidad entre grupos. Se utiliza la metodología HCPC (Hierarchical Clustering on Principal Components), que aplica clasificación jerárquica de Ward sobre las coordenadas factoriales del ACP —evitando así el ruido de las dimensiones de baja varianza— y valida el número de grupos mediante el criterio de pérdida de inercia y con un algoritmo particional (k-means) como contraste (Ward, 1963; Kaufman & Rousseeuw, 1990; Husson et al., 2017).

Análisis de Correspondencias (simple y múltiple). Técnica análoga al ACP pero aplicada a tablas de contingencia entre variables categóricas: descompone la distancia ji-cuadrado entre perfiles fila y columna para representar en un plano de baja dimensión las asociaciones entre categorías. El Análisis de Correspondencias Simple (CA) se usa para dos variables (tipo × zona) y el Análisis de Correspondencias Múltiples (MCA) se extiende a tres o más variables categóricas (tipo, zona y barrio), tal como lo requiere el reto 3 (Greenacre, 2007; Husson et al., 2017).

3 Preparación del entorno y carga de datos

3.1 Instalación y carga de librerías

# Asegura un mirror de CRAN configurado (algunas instalaciones nuevas de
# R/RStudio no traen uno por defecto y install.packages() falla con
# "trying to use CRAN without setting a mirror"). Se fija de forma
# incondicional: no tiene efectos secundarios si ya había uno configurado.
options(repos = c(CRAN = "https://cloud.r-project.org"))

paquetes_cran <- c(
  "tidyverse", "FactoMineR", "factoextra", "cluster", "psych",
  "corrplot", "leaflet", "DT", "kableExtra", "scales",
  "RColorBrewer", "remotes", "e1071", "gridExtra"
)

faltantes <- paquetes_cran[!paquetes_cran %in% installed.packages()[, "Package"]]
# dependencies = c("Depends","Imports","LinkingTo") evita instalar de forma
# recursiva las dependencias "Suggests" (opcionales) de cada paquete, que
# pueden ser muy pesadas e innecesarias (p. ej. Factoshiny, FactoInvestigate,
# missMDA, summarytools -- este último requiere tcltk/X11 y suele fallar en
# Mac sin XQuartz instalado).
if (length(faltantes) > 0) {
  install.packages(faltantes, dependencies = c("Depends", "Imports", "LinkingTo"))
}

paquete_modelos_instalado <- "paqueteMODELOS" %in% rownames(installed.packages())
# Nota: se usa installed.packages() en lugar de requireNamespace() a
# propósito -- requireNamespace() SÍ carga el paquete si ya está instalado
# (disparando de nuevo el problema de tcltk descrito abajo), mientras que
# installed.packages() solo consulta metadatos, sin cargar nada.
if (!paquete_modelos_instalado) {
  # dependencies = FALSE: paqueteMODELOS solo se necesita para su dataset
  # "vivienda"; no hace falta instalar sus dependencias opcionales.
  #
  # INSTALL_opts = "--no-test-load": paqueteMODELOS declara summarytools
  # como dependencia, y summarytools importa tcltk, que en macOS requiere
  # tener XQuartz (X11) instalado. Si no está, R CMD INSTALL falla en su
  # paso final de verificación ("testing if installed package can be
  # loaded...") y DESINSTALA todo el paquete recién compilado -- aunque
  # los datos ya se habían escrito correctamente a disco. Con esta opción
  # se omite esa verificación final, así el paquete (y su dataset) quedan
  # instalados de todas formas. Como más abajo solo leemos el dataset con
  # data(..., package=...) -- sin hacer library(paqueteMODELOS) -- nunca
  # se llega a cargar su espacio de nombres ni, por lo tanto, tcltk.
  #
  # Alternativa equivalente: devtools::install_github("centromagis/paqueteMODELOS", force = TRUE)
  remotes::install_github("centromagis/paqueteMODELOS", force = TRUE,
                           dependencies = FALSE, upgrade = "never",
                           INSTALL_opts = c("--no-test-load"))
}

# Nota: "paqueteMODELOS" se deja fuera de este lapply a propósito. Solo
# necesitamos su dataset "vivienda" (chunk siguiente), al que se accede con
# data(..., package = "paqueteMODELOS") sin necesidad de adjuntar el paquete
# completo con library() -- evitando así depender de summarytools/tcltk.
invisible(lapply(paquetes_cran, library, character.only = TRUE))

3.2 Carga del conjunto de datos

# El objeto se llama "vivienda" en el paquete; se incluye una alternativa
# defensiva por si la versión instalada expone el nombre con la grafía
# "vivenda" (variante observada en algunas versiones del paquete).
vivienda <- tryCatch(
  { data("vivienda", package = "paqueteMODELOS"); vivienda },
  error = function(e) { data("vivenda", package = "paqueteMODELOS"); vivenda }
)

str(vivienda)
## 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")=
##   .. cols(
##   ..   id = col_double(),
##   ..   zona = col_character(),
##   ..   piso = col_character(),
##   ..   estrato = col_double(),
##   ..   preciom = col_double(),
##   ..   areaconst = col_double(),
##   ..   parqueaderos = col_double(),
##   ..   banios = col_double(),
##   ..   habitaciones = col_double(),
##   ..   tipo = col_character(),
##   ..   barrio = col_character(),
##   ..   longitud = col_double(),
##   ..   latitud = col_double()
##   .. )
##  - attr(*, "problems")=<externalptr>
n_original <- nrow(vivienda)

La base contiene 8322 anuncios de vivienda con 13 variables registradas.

3.3 Preparación de variables derivadas

vivienda <- vivienda %>%
  mutate(
    id = as.character(id),
    zona = factor(str_trim(zona)),
    tipo = factor(str_trim(tipo)),
    barrio = str_trim(barrio),
    estrato_cat = factor(estrato, levels = sort(unique(estrato)), ordered = TRUE),
    piso_num = suppressWarnings(as.numeric(piso)),
    log_preciom = log(preciom),
    precio_m2 = preciom * 1e6 / areaconst  # pesos por m2, para lectura de negocio
  )

# Agregación de "barrio" a las categorías más frecuentes + "Otros",
# necesaria porque el MCA pierde interpretabilidad con decenas de
# niveles poco poblados (regla práctica: se conservan los barrios que
# concentran individualmente al menos ~1% de las observaciones).
tabla_barrios <- vivienda %>% count(barrio, sort = TRUE) %>%
  mutate(pct = n / sum(n))
umbral_barrio <- 0.01
barrios_top <- tabla_barrios %>% filter(pct >= umbral_barrio) %>% pull(barrio)

vivienda <- vivienda %>%
  mutate(barrio_agg = if_else(barrio %in% barrios_top, barrio, "Otros"),
         barrio_agg = fct_infreq(factor(barrio_agg)))

n_barrios_agg <- n_distinct(vivienda$barrio_agg)

Tras la agregación, barrio quedó representado por 18 categorías (los barrios con al menos el 1% de la oferta, más la categoría residual “Otros”), lo que preserva la interpretabilidad del análisis de correspondencias sin perder representatividad.

La variable piso presenta valores faltantes de tipo estructural (no aleatorio): únicamente aplica a inmuebles verticales, por lo que las casas quedan naturalmente codificadas como NA. Por ello no se imputa ni se excluye del conjunto: simplemente no participa como variable activa en los análisis multivariados, y se describe de forma independiente para los inmuebles tipo apartamento.

4 Análisis exploratorio de datos

4.1 Calidad de los datos: valores faltantes

na_tabla <- vivienda %>%
  summarise(across(everything(), ~ sum(is.na(.)))) %>%
  pivot_longer(everything(), names_to = "variable", values_to = "n_na") %>%
  mutate(pct_na = scales::percent(n_na / n_original, accuracy = 0.1)) %>%
  arrange(desc(n_na))

na_tabla %>%
  kableExtra::kbl(caption = "Valores faltantes por variable") %>%
  kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Valores faltantes por variable
variable n_na pct_na
piso 2638 31.7%
piso_num 2638 31.7%
parqueaderos 1605 19.3%
id 3 0.0%
zona 3 0.0%
estrato 3 0.0%
areaconst 3 0.0%
banios 3 0.0%
habitaciones 3 0.0%
tipo 3 0.0%
barrio 3 0.0%
longitud 3 0.0%
latitud 3 0.0%
estrato_cat 3 0.0%
precio_m2 3 0.0%
preciom 2 0.0%
log_preciom 2 0.0%
barrio_agg 0 0.0%

4.2 Estadística descriptiva de las variables cuantitativas

vars_cuanti <- c("preciom", "areaconst", "parqueaderos", "banios", "habitaciones")

desc_cuanti <- vivienda %>%
  select(all_of(vars_cuanti)) %>%
  summarise(across(everything(),
    list(min = ~min(., na.rm = TRUE), media = ~mean(., na.rm = TRUE),
         mediana = ~median(., na.rm = TRUE), ds = ~sd(., na.rm = TRUE),
         max = ~max(., na.rm = TRUE),
         asimetria = ~e1071::skewness(., na.rm = TRUE)),
    .names = "{.col}__{.fn}")) %>%
  pivot_longer(everything(), names_sep = "__", names_to = c("variable", "estadistico")) %>%
  pivot_wider(names_from = estadistico, values_from = value)

desc_cuanti %>%
  kableExtra::kbl(digits = 2, caption = "Estadística descriptiva de variables cuantitativas") %>%
  kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Estadística descriptiva de variables cuantitativas
variable min media mediana ds max asimetria
preciom 58 433.89 330 328.65 1999 1.85
areaconst 30 174.93 123 142.96 1745 2.69
parqueaderos 1 1.84 2 1.12 10 2.33
banios 0 3.11 3 1.43 10 0.93
habitaciones 0 3.61 3 1.46 10 1.63
asim_precio <- desc_cuanti %>% filter(variable == "preciom") %>% pull(asimetria)

El precio (preciom) presenta una asimetría de 1.85, marcadamente positiva: la distribución tiene una cola derecha larga, propia de mercados donde un segmento reducido de inmuebles de alto valor eleva la media por encima de la mediana. Esta característica se tiene en cuenta más adelante al decidir el tratamiento de valores extremos para el ACP y el análisis de conglomerados.

4.3 Distribución del precio

p1 <- ggplot(vivienda, aes(x = preciom)) +
  geom_histogram(bins = 40, fill = "#2c7fb8", color = "white") +
  labs(x = "Precio (millones COP)", y = "Frecuencia",
       title = "Distribución del precio de oferta") +
  theme_minimal()

p2 <- ggplot(vivienda, aes(x = log_preciom)) +
  geom_histogram(bins = 40, fill = "#41ab5d", color = "white") +
  labs(x = "log(Precio)", y = "Frecuencia",
       title = "Distribución del precio (escala logarítmica)") +
  theme_minimal()

gridExtra::grid.arrange(p1, p2, ncol = 2)
Distribución del precio de oferta

Distribución del precio de oferta

4.4 Relación entre variables cuantitativas

mat_cor <- vivienda %>% select(all_of(vars_cuanti)) %>%
  cor(use = "pairwise.complete.obs")

corrplot::corrplot(mat_cor, method = "color", type = "upper",
                    addCoef.col = "black", tl.col = "black", tl.srt = 45,
                    number.cex = 0.8, diag = FALSE,
                    title = "Matriz de correlaciones — variables del inmueble",
                    mar = c(0, 0, 2, 0))

cor_precio_area <- cor(vivienda$preciom, vivienda$areaconst, use = "complete.obs")

El precio y el área construida muestran una correlación de 0.69, consistente con el patrón esperado en mercados inmobiliarios: a mayor área, mayor precio, aunque con dispersión suficiente para que otras variables (zona, estrato, tipo) aporten información adicional no redundante.

4.5 Composición de la oferta por variables categóricas

b1 <- ggplot(vivienda, aes(x = fct_infreq(zona))) +
  geom_bar(fill = "#2c7fb8") + coord_flip() +
  labs(x = NULL, y = "N° de inmuebles", title = "Oferta por zona") + theme_minimal()

b2 <- ggplot(vivienda, aes(x = fct_infreq(tipo))) +
  geom_bar(fill = "#41ab5d") + coord_flip() +
  labs(x = NULL, y = "N° de inmuebles", title = "Oferta por tipo de vivienda") + theme_minimal()

b3 <- ggplot(vivienda, aes(x = estrato_cat)) +
  geom_bar(fill = "#fd8d3c") +
  labs(x = "Estrato", y = "N° de inmuebles", title = "Oferta por estrato") + theme_minimal()

b4 <- ggplot(vivienda, aes(x = zona, y = preciom, fill = zona)) +
  geom_boxplot(show.legend = FALSE) + coord_flip() +
  labs(x = NULL, y = "Precio (millones COP)", title = "Precio por zona") + theme_minimal()

gridExtra::grid.arrange(b1, b2, b3, b4, ncol = 2)

4.6 Tratamiento de datos atípicos

q_bajo <- quantile(vivienda$preciom, 0.01, na.rm = TRUE)
q_alto <- quantile(vivienda$preciom, 0.99, na.rm = TRUE)

vivienda_activa <- vivienda %>%
  filter(preciom >= q_bajo, preciom <= q_alto) %>%
  filter(if_all(all_of(vars_cuanti), ~ !is.na(.)))

n_excluidos <- n_original - nrow(vivienda_activa)

Para el ACP y el análisis de conglomerados se construye un subconjunto analítico (vivienda_activa) que excluye el 1% inferior y superior de la distribución de precio —outliers extremos que, bajo distancia euclídea, distorsionarían desproporcionadamente los primeros componentes— así como los registros con valores faltantes en las variables cuantitativas activas. Se excluyeron 1706 observaciones (20.5% de la base), que se conservan íntegramente en los análisis descriptivos, en el análisis de correspondencias y en los mapas exploratorios; el subconjunto analítico resultante conserva 6616 inmuebles.

5 Análisis de Componentes Principales

5.1 Verificación de supuestos

mat_cor_activa <- vivienda_activa %>% select(all_of(vars_cuanti)) %>% cor()

kmo_res <- psych::KMO(mat_cor_activa)
bartlett_res <- psych::cortest.bartlett(mat_cor_activa, n = nrow(vivienda_activa))

El índice KMO global es de 0.76 (adecuación muestral buena a excelente según los umbrales convencionales de Kaiser), y el test de esfericidad de Bartlett resulta significativo (p < 0.05), lo que rechaza la hipótesis de que la matriz de correlaciones es la identidad y respalda la pertinencia de aplicar ACP.

5.2 Ejecución del ACP

Se incluyen como variables activas las cinco variables cuantitativas del inmueble (preciom, areaconst, parqueaderos, banios, habitaciones); estrato se incorpora como variable cuantitativa suplementaria (no participa en el cálculo de los ejes, pero se proyecta sobre ellos para apoyar la interpretación), y zona y tipo como variables categóricas suplementarias.

df_pca <- vivienda_activa %>%
  select(all_of(vars_cuanti), estrato, zona, tipo) %>%
  as.data.frame()

res.pca <- PCA(df_pca, quanti.sup = 6, quali.sup = c(7, 8), graph = FALSE)

5.3 Selección del número de componentes

eig_tabla <- as.data.frame(res.pca$eig)
colnames(eig_tabla) <- c("Valor propio", "% Varianza", "% Varianza acumulada")

eig_tabla %>%
  kableExtra::kbl(digits = 2, caption = "Valores propios y varianza explicada") %>%
  kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Valores propios y varianza explicada
Valor propio % Varianza % Varianza acumulada
comp 1 3.23 64.66 64.66
comp 2 0.86 17.13 81.79
comp 3 0.38 7.66 89.45
comp 4 0.34 6.71 96.16
comp 5 0.19 3.84 100.00
factoextra::fviz_eig(res.pca, addlabels = TRUE, barfill = "#2c7fb8", barcolor = "#2c7fb8",
                      ylim = c(0, max(eig_tabla$`% Varianza`) * 1.15)) +
  labs(title = "Varianza explicada por componente")

n_kaiser <- sum(res.pca$eig[, 1] > 1)
var_2d <- eig_tabla$`% Varianza acumulada`[2]

Bajo el criterio de Kaiser (valores propios superiores a 1) se retienen 1 componentes. Las dos primeras dimensiones explican en conjunto 81.8% de la varianza total, proporción que se considera suficiente para una lectura bidimensional del fenómeno y que se usa como base para el análisis de conglomerados en la siguiente sección.

5.4 Círculo de correlaciones e interpretación de las dimensiones

factoextra::fviz_pca_var(res.pca, col.var = "contrib",
                          gradient.cols = c("#2166ac", "#f7f7f7", "#b2182b"),
                          repel = TRUE) +
  labs(title = "Círculo de correlaciones — contribución de las variables")

contrib_tabla <- as.data.frame(res.pca$var$contrib) %>%
  rownames_to_column("variable") %>%
  select(variable, Dim.1, Dim.2)

contrib_tabla %>%
  kableExtra::kbl(digits = 1, caption = "Contribución (%) de cada variable a Dim.1 y Dim.2") %>%
  kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Contribución (%) de cada variable a Dim.1 y Dim.2
variable Dim.1 Dim.2
preciom 21.8 17.2
areaconst 23.1 0.5
parqueaderos 18.5 19.6
banios 23.8 2.3
habitaciones 12.8 60.5
top_dim1 <- contrib_tabla$variable[which.max(contrib_tabla$Dim.1)]
top_dim2 <- contrib_tabla$variable[which.max(contrib_tabla$Dim.2)]

La variable con mayor contribución a la Dimensión 1 es banios (23.8%), seguida por las demás variables de tamaño físico y precio, todas con cargas de igual signo: este primer eje se interpreta como un factor de “tamaño y valor del inmueble”, que ordena las viviendas de menor a mayor superficie, número de espacios y precio de oferta. La Dimensión 2 está dominada por habitaciones (60.5%) y captura variación adicional no explicada por el eje de tamaño: diferencias en la composición de espacios (por ejemplo, inmuebles con proporciones atípicas de baños/habitaciones respecto a su área construida), es decir, un eje de “composición/eficiencia del espacio”.

5.5 Mapa de individuos

# habillage referencia la posición de la variable en el data.frame usado en
# PCA (df_pca): 1-5 activas, 6 = estrato (quanti.sup), 7 = zona, 8 = tipo.
factoextra::fviz_pca_ind(res.pca, geom = "point", habillage = 7,
                          addEllipses = TRUE, ellipse.level = 0.68,
                          alpha.ind = 0.5, palette = "Set1") +
  labs(title = "Individuos en el plano factorial, coloreados por zona")

factoextra::fviz_pca_ind(res.pca, geom = "point", habillage = 8,
                          addEllipses = TRUE, ellipse.level = 0.68,
                          alpha.ind = 0.5, palette = "Dark2") +
  labs(title = "Individuos en el plano factorial, coloreados por tipo de vivienda")

La proyección de las variables suplementarias categóricas (zona, tipo) y de estrato sobre el plano factorial permite anclar la interpretación puramente estadística de los componentes con las categorías de negocio: los elipses de confianza por zona y tipo cuya posición se desplaza hacia la derecha del eje 1 corresponden a los segmentos con inmuebles de mayor tamaño y valor.

5.6 Síntesis del ACP

El ACP confirma que la variabilidad del mercado inmobiliario analizado responde, en su mayor parte, a un único eje dominante de tamaño/valor del inmueble, sobre el cual se diferencian las zonas y estratos. Esta síntesis en 1 componentes es la que se utiliza a continuación como insumo para la segmentación por conglomerados, evitando así que el ruido de variables poco informativas afecte la clasificación.

6 Análisis de Conglomerados

6.1 Determinación del número de conglomerados

Se emplea la metodología HCPC (clasificación jerárquica de Ward sobre las coordenadas del ACP), que además de construir el dendrograma sugiere de forma automática el número de grupos que maximiza la ganancia relativa de inercia entre particiones sucesivas.

res.hcpc <- HCPC(res.pca, nb.clust = -1, graph = FALSE)

n_clusters <- n_distinct(res.hcpc$data.clust$clust)
plot(res.hcpc, choice = "tree", title = "Dendrograma — clasificación jerárquica de Ward")

El criterio de HCPC sugiere 3 conglomerados como partición óptima. Como validación complementaria, se estima el coeficiente de silueta promedio para particiones de k-means alternativas sobre las mismas coordenadas factoriales:

# drop = FALSE evita que R "aplane" la matriz a un vector cuando n_kaiser
# es 1 (comportamiento por defecto de R al indexar una sola columna), lo
# que rompía fviz_nbclust() al exigir una matriz/data.frame. Además se usa
# como mínimo 2 dimensiones para que la validación por silueta tenga
# sentido geométrico, incluso si Kaiser retuvo una sola componente.
n_dim_cluster <- min(max(n_kaiser, 2), ncol(res.pca$ind$coord))
coords_pca <- res.pca$ind$coord[, 1:n_dim_cluster, drop = FALSE]

factoextra::fviz_nbclust(coords_pca, kmeans, method = "silhouette", k.max = 8) +
  labs(title = "Validación del número de conglomerados (silueta promedio, k-means)")

6.2 Mapa factorial de los conglomerados

plot(res.hcpc, choice = "map", draw.tree = FALSE,
     title = "Individuos coloreados por conglomerado (plano ACP)")

6.3 Caracterización de los conglomerados

vivienda_activa$cluster <- res.hcpc$data.clust$clust

perfil_cluster <- vivienda_activa %>%
  group_by(cluster) %>%
  summarise(
    n = n(), pct = n() / nrow(vivienda_activa),
    precio_prom = mean(preciom), precio_m2_prom = mean(precio_m2),
    area_prom = mean(areaconst), banios_prom = mean(banios),
    habitaciones_prom = mean(habitaciones), parqueaderos_prom = mean(parqueaderos),
    estrato_prom = mean(estrato)
  ) %>% arrange(desc(precio_prom))

perfil_cluster %>%
  kableExtra::kbl(digits = 1, caption = "Perfil promedio por conglomerado") %>%
  kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Perfil promedio por conglomerado
cluster n pct precio_prom precio_m2_prom area_prom banios_prom habitaciones_prom parqueaderos_prom estrato_prom
3 852 0.1 1033.4 2942323 410.3 5.2 4.7 3.7 5.6
2 2091 0.3 533.0 2691804 224.7 4.1 4.4 1.9 5.0
1 3673 0.6 276.1 2908702 97.7 2.3 2.9 1.3 4.5
cluster_premium <- perfil_cluster$cluster[1]
cluster_economico <- perfil_cluster$cluster[nrow(perfil_cluster)]

Ordenando los conglomerados por precio promedio, el conglomerado 3 concentra los inmuebles de mayor valor (1033 millones COP en promedio, con 410 m² promedio), mientras que el conglomerado 1 agrupa la oferta más económica (276 millones COP en promedio). Esta ordenación monótona en precio, área y estrato confirma que la Dimensión 1 del ACP —tamaño/valor— es también el criterio que mejor separa los conglomerados, dando coherencia interna al análisis.

6.3.1 Descripción automática de conglomerados (variables cuantitativas y categóricas)

# res.hcpc$desc.var$quanti describe, por conglomerado, qué variables
# cuantitativas se alejan significativamente de la media global (v.test);
# res.hcpc$desc.var$category hace lo propio para variables categóricas
# suplementarias (zona, tipo), vía prueba de proporciones.
res.hcpc$desc.var$quanti
## $`1`
##              v.test Mean in category Overall mean sd in category Overall sd
## estrato      -26.80            4.546        4.824         0.8440      0.943
## parqueaderos -43.83            1.288        1.807         0.4689      1.074
## habitaciones -46.52            2.907        3.606         0.6219      1.365
## areaconst    -52.46           97.685      178.093        38.7326    139.267
## preciom      -53.39          276.074      454.790       108.9059    304.133
## banios       -61.37            2.320        3.239         0.6064      1.361
##                                                                                                                                                                          p.value
## estrato      0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002859
## parqueaderos 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## habitaciones 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## areaconst    0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## preciom      0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## banios       0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
##                 n
## estrato      3673
## parqueaderos 3673
## habitaciones 3673
## areaconst    3673
## preciom      3673
## banios       3673
## 
## $`2`
##              v.test Mean in category Overall mean sd in category Overall sd
## banios       33.105            4.054        3.239         0.8859      1.361
## habitaciones 32.383            4.406        3.606         1.4700      1.365
## areaconst    18.510          224.718      178.093        89.3393    139.267
## preciom      14.210          532.958      454.790       186.7178    304.133
## estrato      10.091            4.996        4.824         0.9838      0.943
## parqueaderos  6.733            1.937        1.807         0.6679      1.074
##                                                                                                                                                                                                                                                            p.value
## banios       0.000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002497
## habitaciones 0.000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000048243053653831
## areaconst    0.000000000000000000000000000000000000000000000000000000000000000000000000000172811703986534895627274277073126896917641894631915727262830305780075146251047265146114854555300246775367565688491919113084406777040916627225784152760342866667670533059
## preciom      0.000000000000000000000000000000000000000000000793642341195119437580574279065681225250697890612337133546435188082790739452693594264977334286565253492250780468944375278805836160245235078036785125732421875000000000000000000000000000000000000000000
## estrato      0.000000000000000000000006057556713944972768636703753126413361494581239820244127920796802476421927252658861107192933559417724609375000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## parqueaderos 0.000000000016597452182053207290810137943364564218351464575107456767000257968902587890625000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
##                 n
## banios       2091
## habitaciones 2091
## areaconst    2091
## preciom      2091
## estrato      2091
## parqueaderos 2091
## 
## $`3`
##              v.test Mean in category Overall mean sd in category Overall sd
## preciom       59.49         1033.401      454.790       306.7396    304.133
## parqueaderos  55.69            3.719        1.807         1.4348      1.074
## areaconst     52.14          410.312      178.093       193.4411    139.267
## banios        45.09            5.202        3.239         1.3230      1.361
## estrato       25.76            5.601        4.824         0.6699      0.943
## habitaciones  24.07            4.657        3.606         1.6501      1.365
##                                                                                                                                                              p.value
## preciom      0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## parqueaderos 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## areaconst    0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## banios       0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
## estrato      0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002418
## habitaciones 0.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005210555472425525159459
##                n
## preciom      852
## parqueaderos 852
## areaconst    852
## banios       852
## estrato      852
## habitaciones 852

6.4 Cruce con zona, tipo y estrato

tab_cluster_zona <- table(vivienda_activa$cluster, vivienda_activa$zona)
chi_cluster_zona <- chisq.test(tab_cluster_zona)

prop.table(tab_cluster_zona, margin = 1) %>%
  as.data.frame() %>%
  rename(cluster = Var1, zona = Var2, proporcion = Freq) %>%
  ggplot(aes(x = cluster, y = proporcion, fill = zona)) +
  geom_col(position = "fill") +
  scale_y_continuous(labels = scales::percent) +
  labs(x = "Conglomerado", y = "% de inmuebles", fill = "Zona",
       title = "Composición de cada conglomerado por zona") +
  theme_minimal()

La asociación entre conglomerado y zona es estadísticamente significativa (chi-cuadrado, p < 0.05), lo que indica que los segmentos de mercado identificados no se distribuyen de forma homogénea en el territorio, sino que tienden a concentrarse en zonas específicas de la ciudad.

6.5 Visualización geográfica de los conglomerados

pal_cluster <- colorFactor(RColorBrewer::brewer.pal(max(3, n_clusters), "Set1"),
                            domain = vivienda_activa$cluster)

leaflet(vivienda_activa) %>%
  addProviderTiles(providers$CartoDB.Positron) %>%
  addCircleMarkers(
    lng = ~longitud, lat = ~latitud, radius = 4, stroke = FALSE,
    fillOpacity = 0.65, color = ~pal_cluster(cluster),
    popup = ~paste0("<b>Conglomerado: </b>", cluster,
                     "<br><b>Zona: </b>", zona,
                     "<br><b>Tipo: </b>", tipo,
                     "<br><b>Precio: </b>", preciom, " M COP",
                     "<br><b>Área: </b>", areaconst, " m²")
  ) %>%
  addLegend("bottomright", pal = pal_cluster, values = ~cluster, title = "Conglomerado")

6.6 Síntesis del análisis de conglomerados

La segmentación en 3 conglomerados, construida sobre el espacio reducido del ACP, ofrece una tipología de mercado operativa: conglomerados ordenables de forma consistente por precio, tamaño y estrato, con distribución geográfica diferenciada. Esta tipología es la que se retoma en las recomendaciones estratégicas (sección 9).

7 Análisis de Correspondencias

7.1 Análisis de Correspondencias Simple: Tipo × Zona

tab_tipo_zona <- table(vivienda$tipo, vivienda$zona)

tab_tipo_zona %>% as.data.frame() %>%
  pivot_wider(names_from = Var2, values_from = Freq) %>%
  rename(tipo = Var1) %>%
  kableExtra::kbl(caption = "Tabla de contingencia: tipo de vivienda × zona") %>%
  kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Tabla de contingencia: tipo de vivienda × zona
tipo Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
Apartamento 24 1198 1029 62 2787
Casa 100 722 169 289 1939
chi_tipo_zona <- chisq.test(tab_tipo_zona)

n_tot <- sum(tab_tipo_zona)
k_min <- min(nrow(tab_tipo_zona), ncol(tab_tipo_zona)) - 1
v_cramer <- sqrt(as.numeric(chi_tipo_zona$statistic) / (n_tot * k_min))

La prueba ji-cuadrado de independencia entre tipo de vivienda y zona resulta significativa (p < 0.05) (\(\chi^2\) = 690.9, gl = 4), con una V de Cramér de 0.288 (asociación débil a moderada), lo que confirma que el tipo de inmueble ofertado no es independiente de la zona, justificando el análisis de correspondencias.

res.ca <- CA(tab_tipo_zona, graph = FALSE)

# La dimensionalidad total de un CA está acotada por
# min(n_filas - 1, n_columnas - 1). Si `tipo` solo tiene dos categorías,
# esa cota es 1, y un biplot de 2 ejes (el que pide fviz_ca_biplot por
# defecto) no es aplicable porque no existe una segunda dimensión.
n_dim_ca <- nrow(res.ca$eig)

if (n_dim_ca >= 2) {
  factoextra::fviz_ca_biplot(res.ca, repel = TRUE) +
    labs(title = "Análisis de Correspondencias Simple — Tipo × Zona")
} else {
  # Con una única dimensión disponible, se proyectan ambas variables sobre
  # ese único eje en lugar de forzar un biplot de dos ejes.
  coord_fila <- as.data.frame(res.ca$row$coord)
  coord_fila$categoria <- rownames(res.ca$row$coord)
  coord_fila$variable <- "tipo"
  names(coord_fila)[1] <- "Dim1"

  coord_col <- as.data.frame(res.ca$col$coord)
  coord_col$categoria <- rownames(res.ca$col$coord)
  coord_col$variable <- "zona"
  names(coord_col)[1] <- "Dim1"

  bind_rows(coord_fila, coord_col) %>%
    ggplot(aes(x = Dim1, y = fct_reorder(categoria, Dim1), color = variable)) +
    geom_vline(xintercept = 0, linetype = "dashed", color = "grey60") +
    geom_point(size = 3) +
    labs(x = "Dimensión 1 (única dimensión del CA)", y = NULL, color = NULL,
         title = "Análisis de Correspondencias Simple — Tipo × Zona (dimensión única)") +
    theme_minimal()
}

inercia_ca <- round(sum(res.ca$eig[1:n_dim_ca, 2]), 1)

El CA explica 100% de la inercia total con 1 dimensión (el máximo posible para esta tabla, ya que min(filas−1, columnas−1) = 1), por lo que la representación anterior resume adecuadamente la asociación tipo–zona. La proximidad entre una categoría de tipo y una de zona en el gráfico indica una asociación positiva —esa combinación aparece con mayor frecuencia relativa de la esperada bajo independencia—, mientras que las categorías más alejadas del origen son las que más contribuyen a la asociación global.

7.2 Análisis de Correspondencias Múltiples: Tipo, Zona y Barrio

df_mca <- vivienda %>%
  select(tipo, zona, barrio_agg) %>%
  mutate(across(everything(), as.factor)) %>%
  as.data.frame()

res.mca <- MCA(df_mca, graph = FALSE)

factoextra::fviz_eig(res.mca, addlabels = TRUE) +
  labs(title = "MCA — varianza explicada por dimensión")

var_mca_2d <- round(sum(res.mca$eig[1:2, 2]), 1)

Las dos primeras dimensiones del MCA explican en conjunto 16% de la inercia total —un porcentaje moderado, esperable en MCA dado el elevado número de categorías involucradas (particularmente en barrio_agg)—, suficiente para una lectura exploratoria de los patrones dominantes de asociación.

factoextra::fviz_mca_var(res.mca, repel = TRUE, col.var = "contrib",
                          gradient.cols = c("#2166ac", "#f7f7f7", "#b2182b"),
                          labelsize = 3) +
  labs(title = "MCA — categorías de tipo, zona y barrio en el plano factorial")

# Se referencian las columnas por posición (1 y 2), no por nombre: en
# algunos objetos de FactoMineR las columnas de coordenadas/contribuciones
# se llaman "Dim 1" (con espacio) en lugar de "Dim.1", lo que rompía una
# referencia por nombre como en el ACP.
contrib_mca_raw <- as.data.frame(res.mca$var$contrib)

contrib_mca <- data.frame(
  categoria = rownames(res.mca$var$contrib),
  Dim1 = contrib_mca_raw[[1]],
  Dim2 = if (ncol(contrib_mca_raw) >= 2) contrib_mca_raw[[2]] else NA_real_
) %>%
  arrange(desc(Dim1)) %>%
  head(10)

contrib_mca %>%
  kableExtra::kbl(digits = 1, col.names = c("Categoría", "Dim. 1", "Dim. 2"),
                   caption = "Categorías con mayor contribución a las dos primeras dimensiones del MCA") %>%
  kableExtra::kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Categorías con mayor contribución a las dos primeras dimensiones del MCA
Categoría Dim. 1 Dim. 2
tipo.NA 49.9 0.0
zona.NA 49.9 0.0
Otros 0.1 0.2
Zona Sur 0.0 6.7
Apartamento 0.0 4.5
valle del lili 0.0 0.6
Zona Oeste 0.0 36.5
santa teresita 0.0 14.1
ciudad jardín 0.0 2.0
pance 0.0 1.2

La lectura conjunta del biplot y de la tabla de contribuciones permite identificar combinaciones características de tipo–zona–barrio: categorías de barrio_agg que se ubican cerca de una categoría de tipo o zona particular evidencian una especialización de la oferta en ese territorio (por ejemplo, barrios que concentran casi exclusivamente apartamentos frente a otros dominados por casas), información directamente aplicable a la segmentación geográfica del portafolio comercial.

7.3 Síntesis del análisis de correspondencias

El CA simple confirma una asociación estadísticamente significativa entre tipo de vivienda y zona, y el MCA extiende esa lectura incorporando el barrio como unidad geográfica de mayor resolución, revelando patrones de especialización de la oferta que el análisis bivariado por sí solo no permite apreciar.

8 Integración de resultados

tab_cluster_tipo <- table(vivienda_activa$cluster, vivienda_activa$tipo)

prop.table(tab_cluster_tipo, margin = 1) %>%
  as.data.frame() %>%
  rename(cluster = Var1, tipo = Var2, proporcion = Freq) %>%
  ggplot(aes(x = cluster, y = proporcion, fill = tipo)) +
  geom_col(position = "fill") +
  scale_y_continuous(labels = scales::percent) +
  labs(x = "Conglomerado", y = "% de inmuebles", fill = "Tipo",
       title = "Composición de cada conglomerado por tipo de vivienda") +
  theme_minimal()

pal_precio <- colorNumeric("YlOrRd", domain = vivienda$preciom)

leaflet(vivienda) %>%
  addProviderTiles(providers$CartoDB.Positron) %>%
  addCircleMarkers(lng = ~longitud, lat = ~latitud, radius = 3, stroke = FALSE,
                    fillOpacity = 0.55, color = ~pal_precio(preciom)) %>%
  addLegend("bottomright", pal = pal_precio, values = ~preciom,
            title = "Precio (M COP)")

Los tres análisis convergen en una misma lectura del mercado: el eje dominante de variación es el tamaño/valor del inmueble (ACP), que produce una segmentación clara y geográficamente diferenciada en conglomerados (HCPC), y esa segmentación es consistente con patrones de especialización de la oferta por tipo de vivienda, zona y barrio (CA/MCA). Esta triangulación metodológica —tres técnicas distintas apuntando al mismo patrón subyacente— refuerza la validez de los hallazgos.

9 Conclusiones y recomendaciones estratégicas

9.1 Hallazgos principales

  • La variación de precios y atributos físicos de la oferta inmobiliaria se explica principalmente por un eje de tamaño/valor (64.7% de la varianza en la Dimensión 1 del ACP), con un segundo eje asociado a la composición interna de espacios.
  • El mercado se organiza en 3 segmentos claramente diferenciados por precio, área y estrato socioeconómico, con distribución geográfica no homogénea (chi-cuadrado cluster × zona, p < 0.05).
  • Existe asociación estadísticamente significativa entre tipo de vivienda y zona (V de Cramér = 0.288), y el MCA identifica barrios con especialización marcada por tipo de inmueble.

9.2 Recomendaciones para la empresa inmobiliaria

  1. Valoración diferenciada por segmento. Utilizar los 3 conglomerados identificados —y no únicamente la zona o el estrato— como unidad base para la estimación de precios de referencia y para la detección de inmuebles sub- o sobrevalorados dentro de su propio segmento.
  2. Focalización comercial geográfica. Dirigir la prospección de nuevo inventario hacia los barrios que el MCA identifica como especializados en el tipo de inmueble de mayor rotación o margen, evitando dispersar el esfuerzo comercial en zonas de baja coherencia de oferta.
  3. Portafolio por perfil de inversor. Emplear el conglomerado premium
    1. y el económico (1) como polos de una oferta segmentada por perfil de comprador/inversionista, ajustando el discurso comercial y el canal de promoción a cada uno.
  4. Monitoreo continuo. Dado que la base se construye por web scraping periódico, se recomienda repetir este análisis de forma trimestral para detectar desplazamientos de los conglomerados (cambios en composición o en precio promedio) que anticipen tendencias del mercado.

9.3 Limitaciones y líneas futuras

El análisis se basa en precios de oferta (anuncios), no en precios de cierre de transacción, por lo que puede sobreestimar el nivel general de precios. La cobertura depende de la disponibilidad de anuncios en OLX, lo que puede subrepresentar ciertos segmentos del mercado (p. ej., vivienda nueva comercializada directamente por constructoras). Como extensión natural de este trabajo se propone un modelo de precios hedónicos (regresión) que cuantifique el efecto marginal de cada atributo controlando por zona y estrato, así como el enriquecimiento de la base con variables de contexto (cercanía a vías principales, equipamientos urbanos).

10 Referencias

Greenacre, M. (2007). Correspondence Analysis in Practice (2nd ed.). Chapman & Hall/CRC.

Husson, F., Lê, S., & Pagès, J. (2017). Exploratory Multivariate Analysis by Example Using R (2nd ed.). CRC Press.

Jolliffe, I. T. (2002). Principal Component Analysis (2nd ed.). Springer.

Kaufman, L., & Rousseeuw, P. J. (1990). Finding Groups in Data: An Introduction to Cluster Analysis. Wiley.

Ward, J. H. (1963). Hierarchical grouping to optimize an objective function. Journal of the American Statistical Association, 58(301), 236–244.

11 Anexo: información de sesión (reproducibilidad)

sessionInfo()
## R version 4.5.1 (2025-06-13)
## Platform: aarch64-apple-darwin20
## Running under: macOS Tahoe 26.5.2
## 
## Matrix products: default
## BLAS:   /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRblas.0.dylib 
## LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
## 
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
## 
## time zone: America/Bogota
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] gridExtra_2.3.1    e1071_1.7-16       remotes_2.5.0      RColorBrewer_1.1-3
##  [5] scales_1.4.0       kableExtra_1.4.0   DT_0.34.0          leaflet_2.2.3     
##  [9] corrplot_0.95      psych_2.6.5        cluster_2.1.8.3    factoextra_2.2.0  
## [13] FactoMineR_2.16    lubridate_1.9.5    forcats_1.0.1      stringr_1.6.0     
## [17] dplyr_1.2.1        purrr_1.2.2        readr_2.2.0        tidyr_1.3.2       
## [21] tibble_3.3.1       ggplot2_4.0.3      tidyverse_2.0.0   
## 
## loaded via a namespace (and not attached):
##  [1] tidyselect_1.2.1        viridisLite_0.4.3       farver_2.1.2           
##  [4] S7_0.2.2                fastmap_1.2.0           digest_0.6.39          
##  [7] timechange_0.4.0        estimability_2.0.0      lifecycle_1.0.5        
## [10] multcompView_0.1-12     magrittr_2.0.5          compiler_4.5.1         
## [13] rlang_1.3.0             sass_0.4.10             tools_4.5.1            
## [16] yaml_2.3.12             ggsignif_0.6.4          knitr_1.51             
## [19] labeling_0.4.3          htmlwidgets_1.6.4       mnormt_2.1.2           
## [22] scatterplot3d_0.3-45    xml2_1.5.1              showtextdb_3.0         
## [25] abind_1.4-8             withr_3.0.3             grid_4.5.1             
## [28] ggpubr_1.0.0            sysfonts_0.8.9          xtable_1.8-4           
## [31] emmeans_2.0.4           MASS_7.3-65             flashClust_1.1-4       
## [34] cli_3.6.6               mvtnorm_1.4-2           crayon_1.5.3           
## [37] rmarkdown_2.30          generics_0.1.4          otel_0.2.0             
## [40] rstudioapi_0.17.1       tzdb_0.5.0              proxy_0.4-28           
## [43] cachem_1.1.0            parallel_4.5.1          vctrs_0.7.3            
## [46] Matrix_1.7-3            carData_3.0-6           jsonlite_2.0.0         
## [49] car_3.1-5               hms_1.1.4               rstatix_1.1.0          
## [52] ggrepel_0.9.8           Formula_1.2-5           irlba_2.3.7            
## [55] systemfonts_1.3.1       crosstalk_1.2.2         jquerylib_0.1.4        
## [58] glue_1.8.1              leaflet.providers_3.0.0 ggtext_0.1.2           
## [61] stringi_1.8.9           gtable_0.3.6            pillar_1.11.1          
## [64] htmltools_0.5.9         showtext_0.9-8          R6_2.6.1               
## [67] textshaping_1.0.4       evaluate_1.0.5          lattice_0.22-7         
## [70] backports_1.5.1         leaps_3.2               gridtext_0.1.6         
## [73] broom_1.0.13            bslib_0.9.0             class_7.3-23           
## [76] Rcpp_1.1.2              svglite_2.2.2           nlme_3.1-168           
## [79] xfun_0.60               pkgconfig_2.0.3