Resumen ejecutivo

Se analizan 8.322 registros de vivienda ofertada en Cali, obtenidos por web scraping de OLX (paqueteMODELOS), mediante tres técnicas multivariadas: componentes principales, conglomerados y correspondencias.

  1. Dos dimensiones resumen la oferta. El ACP retiene el 81,1 % de la varianza en dos componentes: tamaño y valor (59,5 %) y capacidad habitacional frente a estrato (21,6 %). El precio por m² se asocia principalmente con la segunda componente (r = −0,697) y muy poco con la primera (r = 0,156).

  2. Cuatro segmentos. Premium de gran formato (12,7 %), alta gama compacta (32,7 %), casa amplia de estrato medio-bajo (11,8 %) y vivienda de entrada (42,8 %). El precio por inmueble varía 4,6 veces entre extremos; el precio por m², solo 2,2 — y el premium no encabeza el valor unitario.

  3. La localización presenta una asociación marcada con el estrato (V de Cramér = 0,392). El Oeste concentra el estrato 6 y el mayor precio por m² (3,70 millones); Oriente y Centro, el estrato 3 y la casa unifamiliar.

  4. Hipótesis de inversión principal: evaluar las casas amplias de estratos 3–4 dentro de S3, segmento compuesto por 932 inmuebles (96,2 % casas; 72,7 % en estratos 3–4), con unos 280 m² de área media y un precio mediano de 1,46 millones/m². Al tratarse de precios de oferta y no de transacción, el margen debe validarse con un piloto.


1 Introducción

1.1 Problema y objetivos

Una empresa inmobiliaria necesita comprender el mercado de vivienda urbana para decidir sobre compra, venta y valoración. El reto no es predecir un precio puntual sino entender la estructura del mercado: qué variables diferencian realmente a los inmuebles, qué grupos naturales existen y cómo se relacionan los atributos cualitativos.

Objetivo general. Caracterizar la estructura de la oferta inmobiliaria urbana mediante análisis multivariado y derivar recomendaciones estratégicas.

Objetivos específicos. Depurar y validar la base; reducir la dimensionalidad con ACP; segmentar con conglomerados; examinar la asociación entre variables categóricas con correspondencias; y comunicar los hallazgos con visualizaciones y un mapa interactivo.

1.2 Datos

## 'data.frame':    8322 obs. of  13 variables:
##  $ id          : num  1147 1169 1350 5992 1212 ...
##  $ zona        : chr  "Zona Oriente" "Zona Oriente" "Zona Oriente" "Zona Sur" ...
##  $ piso        : chr  NA NA NA "02" ...
##  $ estrato     : num  3 3 3 4 5 5 4 5 5 5 ...
##  $ preciom     : num  250 320 350 400 260 240 220 310 320 780 ...
##  $ areaconst   : num  70 120 220 280 90 87 52 137 150 380 ...
##  $ parqueaderos: num  1 1 2 3 1 1 2 2 2 2 ...
##  $ banios      : num  3 2 2 5 2 3 2 3 4 3 ...
##  $ habitaciones: num  6 3 4 3 3 3 3 4 6 3 ...
##  $ tipo        : chr  "Casa" "Casa" "Casa" "Casa" ...
##  $ barrio      : chr  "20 de julio" "20 de julio" "20 de julio" "3 de julio" ...
##  $ longitud    : num  -76.5 -76.5 -76.5 -76.5 -76.5 ...
##  $ latitud     : num  3.43 3.43 3.44 3.44 3.46 ...

La base tiene 8.322 observaciones y 13 variables.

Tabla 1. Diccionario de variables
Variable Tipo Descripción
id Identificador Código único del anuncio
zona Cualitativa nominal Zona de la ciudad (Norte, Sur, Oriente, Oeste, Centro)
piso Cualitativa ordinal Piso en que se ubica el inmueble (aplica a apartamentos)
estrato Cualitativa ordinal Estrato socioeconómico (3 a 6)
preciom Cuantitativa continua Precio de oferta en millones de pesos
areaconst Cuantitativa continua Área construida en metros cuadrados
parqueaderos Cuantitativa discreta Número de parqueaderos
banios Cuantitativa discreta Número de baños
habitaciones Cuantitativa discreta Número de habitaciones
tipo Cualitativa nominal Tipo de inmueble (Casa / Apartamento)
barrio Cualitativa nominal Barrio en que se ubica
longitud Cuantitativa continua Coordenada de longitud
latitud Cuantitativa continua Coordenada de latitud

1.3 Enfoque

Etapa Técnica Propósito
1 Depuración y EDA Validez de las inferencias posteriores
2 ACP Reducir 6 variables correlacionadas a 2–3 dimensiones
3 k-medias + Ward Segmentar sobre las coordenadas factoriales
4 ACS y ACM Asociación entre atributos cualitativos
5 Georreferenciación Traducir segmentos a decisiones territoriales

Todas las técnicas son descriptivas y no supervisadas: revelan estructura, no predicen precios.


2 Preparación de los datos

2.1 Valores faltantes

El primer paso es cuantificar la pérdida de información por variable.

Tabla 2. Valores faltantes por variable
Variable N.º de faltantes % del total
piso 2638 31.70
parqueaderos 1605 19.29
id 3 0.04
zona 3 0.04
estrato 3 0.04
areaconst 3 0.04
banios 3 0.04
habitaciones 3 0.04
tipo 3 0.04
barrio 3 0.04
longitud 3 0.04
latitud 3 0.04
preciom 2 0.02

El patrón de ausencias se visualiza con md.pattern(): cada fila es una combinación de variables presentes (azul) y ausentes (rosa), con su frecuencia a la izquierda.

Tres situaciones distintas exigen tratamientos distintos.

piso (31,7 %). No es faltante aleatorio: no aplica a las casas (38,7 % de la oferta). Se convierte en categórica con una modalidad explícita “Casa (no aplica)”.

parqueaderos (19,3 %). Los registros sin dato tienen precios y áreas medianas sustancialmente menores:

Tabla 3. Perfil de los registros sin dato de parqueadero
tipo Reporta n Precio mediano (millones) Área mediana (m²) Estrato mediano
Apartamento Reporta parqueadero 4231 300 97 5
Apartamento Sin dato 869 148 60 4
Casa Reporta parqueadero 2486 450 250 5
Casa Sin dato 733 305 200 3

Un apartamento sin dato tiene precio mediano de 148 millones frente a 300, y 60 m² frente a 97. Además, el valor cero nunca aparece en la variable observada (mínimo = 1). La lectura razonable es que el anunciante omite el campo cuando no hay parqueadero: se imputa 0, conservando 1.555 registros. Eliminarlos habría sesgado la muestra hacia el segmento alto.

Resto (≤ 0,04 %). Dos filas vacías y algunos faltantes aislados; se eliminan por listas.

2.2 Valores implausibles y depuración

Un segundo control busca valores que, sin ser faltantes, resultan imposibles:

Tabla 4. Registros con valores estructuralmente imposibles
Variable Casos
habitaciones = 0 66
baños = 0 45

Una vivienda no puede tener cero habitaciones ni cero baños: son errores de captura. Se eliminan los 77 registros. En barrio se detectan duplicados por tildes y caracteres mal codificados (“alferez real” / “alf√©rez real”); al normalizar, los barrios distintos pasan de 436 a 385.

## Registros tras la depuración básica: 8243

2.3 Atípicos multivariantes

Un inmueble puede ser normal en cada variable y aun así ser una combinación imposible (10 habitaciones en 50 m²). La distancia de Mahalanobis detecta ese caso porque incorpora la estructura de covarianzas.

Tabla 5. Perfil de los inmuebles atípicos excluidos
n Precio mediano (millones) Área mediana (m²) Habitaciones (mediana) Baños (mediana) Área máxima (m²)
341 1100 435 5 5 1745

Los 341 excluidos son mansiones y lotes de hasta 1.745 m² que absorberían un conglomerado entero y distorsionarían los ejes del ACP. Se retiran del análisis factorial pero se conservan en el descriptivo.

Tabla 6. Trazabilidad de la depuración
Paso Registros Pérdida acumulada (%)
Base original 8322 0.00
Se eliminan filas completamente vacías 8320 0.02
Se eliminan faltantes aislados en variables clave 8319 0.04
Se eliminan habitaciones = 0 y baños = 0 8243 0.95
Se imputan 1.555 parqueaderos faltantes con 0 8243 0.95
Se excluyen atípicos multivariantes (Mahalanobis) 7902 5.05

Base final: 7.902 inmuebles, el 94,9 % de los registros originales.


3 Análisis exploratorio

3.1 Distribuciones y mercado por zona

Se examinan primero las dos variables continuas centrales del análisis:

Ambas distribuciones son asimétricas a la derecha: la mediana de precio (330 millones) está muy por debajo de la media (434). De ahí que todo el perfilado posterior use medianas.

Tabla 7. Caracterización del mercado por zona
Zona N.º de inmuebles Precio mediano (millones) Área mediana (m²) Precio/m² mediano (millones) % apartamentos
Zona Oeste 1128 560 157 3.70 87.9
Zona Sur 4520 310 110 2.70 61.3
Zona Norte 1829 290 104 2.25 64.8
Zona Centro 109 270 152 1.53 22.0
Zona Oriente 316 200 150 1.32 19.3

El contraste entre zonas se aprecia mejor sobre el valor unitario:

La Zona Oeste es la más costosa por m² (3,70 millones), seguida del Sur (2,70). Oriente y Centro están en el extremo opuesto (1,32 y 1,53) y suman apenas el 5,4 % de la oferta.

3.2 Correlaciones

La matriz de correlaciones resume la relación lineal entre las seis variables activas:

Tres lecturas: el precio se asocia con baños (0,72), área (0,71), parqueaderos (0,67) y estrato (0,65), pero muy poco con habitaciones (0,27); la correlación estrato–habitaciones es nula y negativa (−0,07); y el nivel general de correlación justifica reducir dimensionalidad, lo que se confirma formalmente:

Tabla 8. Pruebas de adecuación para el ACP
Prueba Estadístico Valor p Interpretación
Medida KMO de adecuación muestral 0.783 Adecuación aceptable (> 0,70)
Prueba de esfericidad de Bartlett 29819.900 <0.001 Se rechaza la hipótesis de matriz identidad

La medida se puede desglosar por variable:

Tabla 9. Medida de adecuación muestral (MSA) de cada variable
Variable MSA individual
parqueaderos 0.915
banios 0.832
areaconst 0.784
preciom 0.772
estrato 0.738
habitaciones 0.618

Ninguna variable desentona lo suficiente como para excluirla, aunque habitaciones es la peor integrada (0,618) —coherente con todo lo demás que se ha visto sobre ella— y parqueaderos la mejor (0,915).

KMO = 0,783 y una prueba de Bartlett significativa indican que la estructura de correlaciones es adecuada para que una reducción dimensional resulte informativa. Matemáticamente siempre es posible calcular componentes principales; lo que estas pruebas evalúan es si existe suficiente estructura de relaciones para que hacerlo tenga sentido.

3.3 Del análisis univariado al multivariado

3.3.1 Univariado

Se parte de los estadísticos descriptivos del precio de oferta:

Tabla 10. Estadísticos descriptivos del precio de oferta (millones de pesos)
Indicador Valor
N 7902.000
Media 405.273
Desviación estándar 283.379
Mínimo 58.000
Q1 215.000
Mediana 320.000
Q3 500.000
Máximo 1750.000
MAD 192.738
Rango intercuartílico 285.000
Coeficiente de variación 0.699
Asimetría 1.649
Curtosis 2.815

El precio tiene CV = 0,70, asimetría +1,65 y curtosis 2,82: mucha variabilidad y cola derecha pesada. El análisis univariado detecta el problema pero no dice con qué se relaciona.

3.3.2 Bivariado cualitativa–cuantitativa

Para contrastar el precio entre casas y apartamentos se aplica una prueba de comparación de medias:

Tabla 11. Comparación de medias de precio entre casas y apartamentos (prueba t de Welch)
Elemento Resultado
Media casas (millones) 490.9
Media apartamentos (millones) 356.4
Diferencia estimada (Casa − Apartamento) 134.6
Estadístico t 20.28
Grados de libertad (Welch) 5482
Valor p <0.0001
IC 95 % de la diferencia [121.6 ; 147.6]
d de Cohen 0.488

La comparación gráfica de ambas distribuciones matiza el resultado:

Se usa Welch porque las varianzas no son iguales. El contraste se expresa en la dirección Casa − Apartamento, de modo que todos los indicadores comparten signo: las casas se ofertan 134,6 millones por encima de los apartamentos (t = +20,28; IC 95 % [121,6 ; 147,6]; p < 0,0001). Pero significancia no es relevancia: con casi 8.000 casos casi todo resulta significativo, y la d de Cohen es +0,49, apenas mediana. El solapamiento entre cajas es amplio.

3.3.3 Bivariado cuantitativa–cuantitativa

Entre variables cuantitativas, la asociación se mide con la covarianza y la correlación:

Tabla 12. Relación lineal del precio con el área construida y con el número de habitaciones
Par Covarianza Correlación r IC 95 % R² (% explicado) Valor p
Precio – Área construida 22567.0 0.7078 [0.697 ; 0.719] 50.1 <0.0001
Precio – Habitaciones 97.3 0.2721 [0.252 ; 0.292] 7.4 <0.0001

La correlación precio–área es 0,708 pero explica solo el 50,1 % de la varianza. Con habitaciones, r = 0,272 y 7,4 %: también significativa, también modesta en magnitud.

Ahora bien, una correlación simple puede ser engañosa cuando dos variables comparten una tercera. Las viviendas con más habitaciones también son más grandes, de modo que conviene aislar el efecto del área:

Tabla 13. Correlación simple frente a correlación parcial entre precio y número de habitaciones
Medida Valor
Correlación simple precio – habitaciones 0.2721
Correlación parcial precio – habitaciones (controlando área) -0.2821
Estadístico t de la correlación parcial -26.13
Valor p <0.0001

El resultado cambia de signo: la correlación parcial es −0,282 (t = −26,1; p < 0,0001). La asociación positiva simple existe únicamente porque más habitaciones vienen acompañadas de más área. A igualdad de área, más habitaciones se asocia con un precio por m² menor: entre inmuebles de 80 a 140 m², el valor unitario cae de 3,51 millones con dos habitaciones a 2,42 con seis.

Es el mismo fenómeno que la segunda componente del ACP capturará como un eje completo, y muestra por qué un análisis por pares puede inducir a error: la correlación simple sugería un efecto positivo débil donde en realidad hay un efecto negativo apreciable enmascarado por el área.

3.3.4 Por qué lo bivariado se queda corto

Tres cosas que el enfoque por pares no puede ver y que sí aparecen después:

  • Redundancia. Precio, área, baños y parqueaderos están todos correlacionados. Analizarlos por pares repite la misma información sin decir cuántas dimensiones independientes hay. El ACP responde: dos.
  • Efectos que se cancelan. Habitaciones correlaciona +0,272 con precio y −0,073 con estrato. Por separado, ambas cifras parecen triviales; juntas son los dos polos del segundo eje.
  • Estructura de grupos. Ninguna correlación revela que la oferta se organiza en cuatro segmentos.

4 Análisis de Componentes Principales

4.1 Especificación

Variables activas: precio, área, parqueaderos, baños, habitaciones y estrato. Como variable cuantitativa suplementaria se añade el precio por m² —es un cociente de dos activas, por lo que incluirlo sería redundante— y como cualitativas suplementarias, zona y tipo.

Nota sobre el estrato. En el diccionario es cualitativa ordinal, que es su naturaleza correcta. Aquí entra como activa codificada numéricamente, asumiendo que las categorías 3 a 6 son aproximadamente equidistantes. La alternativa conservadora sería tratarla como suplementaria; se descartó porque el estrato aporta el 32,2 % de la segunda componente y excluirlo vaciaría de sentido ese eje. Las distancias entre estratos en el plano quedan, por tanto, condicionadas a ese supuesto. Como contrapeso, la estructura categórica del estrato se evalúa después sin supuesto alguno de equidistancia, mediante el análisis de correspondencias simple (zona × estrato) y el múltiple, donde entra como conjunto de modalidades. Ambas lecturas coinciden, lo que da respaldo al tratamiento numérico adoptado aquí.

4.2 Autovalores y autovectores

El ACP diagonaliza la matriz de correlaciones \(R\): sus autovalores miden la varianza captada por cada componente y sus autovectores dan los coeficientes de la combinación lineal.

Tabla 14. Varianza de las variables activas antes y después de tipificar
Variable Varianza en escala original Varianza tras tipificar
preciom 80303.39 1
areaconst 12660.46 1
parqueaderos 1.15 1
banios 1.69 1
habitaciones 1.59 1
estrato 1.04 1

La varianza del precio es 80.303 y la del estrato 1,0. Sobre la matriz de covarianzas, la primera componente sería el precio y nada más: el resultado dependería de las unidades. Tipificar —equivalente a usar la matriz de correlaciones— iguala todas las varianzas a 1. De ahí scale.unit = TRUE.

Tabla 15. Autovectores de la matriz de correlaciones (coeficientes de las combinaciones lineales)
v1 v2 v3 v4 v5 v6
preciom -0.4734 0.1860 -0.1250 0.4407 -0.0587 0.7267
areaconst -0.4483 -0.2839 0.0088 0.5778 0.2972 -0.5443
parqueaderos -0.4062 0.2775 0.8350 -0.2417 0.0170 -0.0440
banios -0.4684 -0.1558 -0.2438 -0.2480 -0.7665 -0.2187
habitaciones -0.2804 -0.6793 -0.0382 -0.4677 0.3873 0.2996
estrato -0.3346 0.5676 -0.4755 -0.3650 0.4128 -0.1903
Tabla 16. Autovalores de la matriz de correlaciones
Componente Autovalor Varianza explicada (%)
Dim 1 3.5695 59.49
Dim 2 1.2952 21.59
Dim 3 0.4177 6.96
Dim 4 0.3350 5.58
Dim 5 0.2255 3.76
Dim 6 0.1572 2.62
Tabla 17. Verificación de la descomposición espectral
Comprobación Valor
Suma de los autovalores (debe ser igual a p) 6.0000
Varianza de las coordenadas de los individuos en Dim 1 3.5695
Autovalor 1 obtenido por eigen() 3.5695
Carga de ‘preciom’ en Dim 1 según FactoMineR 0.8943
Autovector v1 de ‘preciom’ × raíz del autovalor 1 0.8943

Tres comprobaciones: los autovalores suman exactamente 6 = p; la varianza de las coordenadas en Dim 1 (3,5695) coincide con el primer autovalor; y la carga de preciom (0,894) es su autovector por la raíz del autovalor. El signo de los autovectores es arbitrario, de modo que una implementación puede devolver el eje invertido sin alterar la interpretación.

4.3 Número de componentes

El gráfico de sedimentación ordena las componentes por varianza explicada:

Los mismos valores en detalle numérico:

Tabla 18. Valores propios y varianza explicada
Componente Valor propio % de varianza % acumulado
Dim 1 3.57 59.49 59.49
Dim 2 1.30 21.59 81.08
Dim 3 0.42 6.96 88.04
Dim 4 0.33 5.58 93.62
Dim 5 0.23 3.76 97.38
Dim 6 0.16 2.62 100.00

Los tres criterios coinciden: Kaiser retiene 2 (3,569 y 1,295); el codo se estabiliza en la tercera (0,418); la varianza acumulada llega al 81,08 %. Se retienen dos componentes.

4.4 Interpretación

Las cargas indican cuánto pesa cada variable en cada componente:

Tabla 19. Cargas, contribuciones y calidad de representación de las variables
Correlación Dim1 Correlación Dim2 Contribución Dim1 (%) Contribución Dim2 (%) Calidad (cos² Dim1+2)
preciom 0.894 -0.212 22.4 3.5 0.845
areaconst 0.847 0.323 20.1 8.1 0.822
parqueaderos 0.768 -0.316 16.5 7.7 0.689
banios 0.885 0.177 21.9 2.4 0.815
habitaciones 0.530 0.773 7.9 46.1 0.878
estrato 0.632 -0.646 11.2 32.2 0.817

El círculo de correlaciones representa esas mismas cargas en el plano factorial:

Dimensión 1 — Tamaño y valor (59,5 %). Todas las variables cargan positivamente: es un factor de tamaño. Precio (0,894), baños (0,885), área (0,847) y parqueaderos (0,768) aportan el 80,9 % del eje. Funciona como índice sintético de envergadura del inmueble.

Dimensión 2 — Habitaciones frente a estrato (21,6 %). Este eje contrapone habitaciones (+0,773; 46,1 % de contribución) a estrato (−0,646; 32,2 %). A igual tamaño, un inmueble puede repartir la superficie en muchos cuartos (vivienda familiar de estrato medio-bajo) o concentrarla en pocos espacios amplios (estrato alto). No son dos niveles de lo mismo: son dos lógicas de producto.

El precio por m² suplementario lo confirma: correlaciona 0,156 con Dim 1 y −0,697 con Dim 2.

El precio por metro cuadrado no se asocia con el tamaño del inmueble sino con su posición en la segunda dimensión. Ahí está el diferencial aprovechable.

4.5 Rotación varimax

Las componentes se extraen maximizando varianza, no interpretabilidad, y por eso casi todas las variables cargan algo en el primer eje. La rotación varimax gira los ejes conservando su ortogonalidad para que cada variable pese mucho en una componente y poco en las demás. No altera la varianza total explicada: solo la reparte de otro modo. Se emplea psych::principal().

Tabla 20. Cargas de los componentes tras la rotación varimax
RC1 RC2
preciom 0.818 0.419
areaconst 0.435 0.795
parqueaderos 0.789 0.257
banios 0.559 0.709
habitaciones -0.098 0.932
estrato 0.900 -0.082

La estructura queda más nítida que en la solución sin rotar:

  • RC1 — nivel socioeconómico y dotación: estrato (0,900), precio (0,818) y parqueaderos (0,789); habitaciones carga prácticamente cero (−0,098).
  • RC2 — capacidad física del inmueble: habitaciones (0,932), área (0,795) y baños (0,709); el estrato carga cero (−0,082).

Es la misma oposición que describe la segunda componente sin rotar, pero expresada como dos bloques limpios y separados en lugar de un eje de tamaño general más otro de contraste. Precio y baños participan de ambos, lo cual es coherente: el precio responde tanto al estrato como al tamaño, y los baños escalan con la superficie y con la gama.

Tabla 21. Varianza explicada por los componentes rotados
RC1 RC2
Suma de cargas al cuadrado 2.6138 2.2509
Proporción de varianza 0.4356 0.3751
Proporción acumulada 0.4356 0.8108

Los dos componentes rotados explican en conjunto el 81,08 %, exactamente lo mismo que antes de rotar: la rotación redistribuye el reparto —43,6 % y 37,5 %, mucho más equilibrado que el 59,5 % / 21,6 % original— sin crear ni destruir información. El ajuste sobre los elementos fuera de la diagonal de la matriz de correlaciones es de 0,986, señal de que dos componentes reproducen casi por completo la estructura de correlaciones observada.

Proyectando los inmuebles sobre estos ejes se obtiene la nube de puntuaciones:

Los cuadrantes admiten una lectura comercial directa: arriba a la derecha, inmuebles grandes en estratos altos; abajo a la derecha, apartamentos compactos de estrato alto; arriba a la izquierda, casas amplias de estrato bajo —el segmento S3—; abajo a la izquierda, la vivienda de entrada. La densidad también muestra que la nube es continua y no presenta agrupaciones separadas por vacíos, lo que anticipa la conclusión del análisis de conglomerados.

4.6 Casos extremos

Para fijar el sentido de los ejes conviene mirar los inmuebles situados en sus extremos.

Tabla 22. Inmuebles situados en los extremos de cada componente
tipo barrio estrato areaconst habitaciones banios parqueaderos preciom Precio/m² (mill.) Dim 1 Dim 2
Máximo en Dim 1 Casa ciudadela pasoancho 6 660.0 7 8 5 1250 1.89 7.78 1.48
Mínimo en Dim 1 Apartamento acopi 3 60.0 1 1 0 92 1.53 -3.28 -0.38
Máximo en Dim 2 Casa salomia 3 472.0 10 6 2 560 1.19 3.70 5.28
Mínimo en Dim 2 Apartamento pance 6 50.3 1 1 2 245 4.87 -1.32 -2.70

Situados sobre el plano, estos cuatro casos delimitan la extensión de cada eje:

El eje horizontal queda claro: en Dim 1 = +7,78 hay una casa de 660 m² y 1.250 millones en estrato 6; en Dim 1 = −3,28, un apartamento de 60 m² y 92 millones en estrato 3. Es la escala de envergadura.

El eje vertical es más interesante. En Dim 2 = +5,28 aparece una casa de 472 m² con 10 habitaciones en estrato 3 (Salomia), a 1,19 millones/m². En Dim 2 = −2,70, un apartamento de 50 m² con 1 habitación en estrato 6 (Pance), a 4,87 millones/m². Mismo mercado, precio unitario cuatro veces mayor en el inmueble pequeño. Los dos casos ilustran por sí solos toda la interpretación de la segunda componente.

4.7 Variables suplementarias

El biplot superpone individuos y variables en un mismo plano:

Las coordenadas de las categorías suplementarias precisan esa lectura:

Tabla 23. Coordenadas de las categorías suplementarias
Coordenada Dim1 Coordenada Dim2
Zona
Zona Centro -0.800 1.602
Zona Norte -0.569 0.225
Zona Oeste 1.052 -0.808
Zona Oriente -0.958 1.859
Zona Sur 0.054 -0.058
Tipo
Apartamento -0.571 -0.461
Casa 1.000 0.808

La descomposición de la varianza de cada eje cuantifica el aporte de las variables cualitativas:

Tabla 24. Descomposición de la varianza de cada eje según las variables cualitativas suplementarias
Variable Dimensión Valor p
tipo Dim 1 0.1600 <0.001
zona Dim 1 0.0784 <0.001
zona Dim 2 0.2165 <0.001
tipo Dim 2 0.2879 <0.001
  • Zona Oeste (+1,052; −0,808): la única que combina inmuebles grandes con estrato alto y pocas habitaciones.
  • Zona Oriente (−0,958; +1,859) y Centro (−0,800; +1,602): pequeños en Dim 1 pero con muchas habitaciones. Perfil de casa familiar popular.
  • Zona Sur casi en el origen (0,054; −0,058): concentra el 57 % de la oferta y es el mercado promedio de la ciudad.
  • Casa (+1,000; +0,808) y Apartamento (−0,571; −0,461) se oponen diagonalmente. La descomposición de la varianza (Tabla 24) confirma que tipo explica el 16,0 % de Dim 1 y el 28,8 % de Dim 2; zona, el 7,8 % y el 21,6 % (p < 0,001).

5 Análisis de conglomerados

5.1 Estrategia

La segmentación usa las coordenadas factoriales de las tres primeras dimensiones, no las variables originales: elimina redundancia, filtra ruido y pondera cada dirección según su aporte informativo. Se combinan k-medias (partición final) y Ward (contraste con un algoritmo de otra lógica).

Se usan tres componentes aquí y dos en el ACP porque los objetivos difieren. Para interpretar bastan dos, que son las legibles en un plano. Para segmentar el criterio es conservar estructura: la tercera eleva la información retenida del 81,1 % al 88,0 % y recoge el contraste en parqueaderos (69,7 % de esa dimensión).

Estandarización. Las distancias son sensibles a las unidades: sin tipificar, el precio (varianza 80.303) dominaría el cálculo. Se aplica

\[z_{ij} = \frac{x_{ij} - \bar{x}_j}{\sigma_j}\]

lo que ya está incorporado, pues las coordenadas provienen de un ACP sobre la matriz de correlaciones.

Distancia. Se emplea la euclidiana,

\[d(x_i, x_j) = \sqrt{\sum_{p=1}^{m} (x_{ip} - x_{jp})^2}\]

que es la métrica sobre la que k-medias está definido. Manhattan o Minkowski serían pertinentes con escalas heterogéneas o atípicos; ambas condiciones ya fueron tratadas.

5.2 Número de conglomerados

Se aplican dos criterios habituales para orientar la elección de k:

Tabla 25. Ganancia marginal por cada clúster adicional
k SCI total Reducción respecto a k-1 (%)
2 22643 45.8
3 17578 22.4
4 13231 24.7
5 11134 15.9
6 9988 10.3
7 9083 9.1
8 8314 8.5

Los criterios no coinciden del todo, y conviene decirlo. La silueta favorece 2 o 3 grupos (0,438 y 0,433 frente a 0,357 con k = 4). El codo mantiene ganancia alta hasta k = 4 (24,7 %) y cae en k = 5 (15,9 %).

Se adopta k = 4 por tres razones: la ganancia entre 3 y 4 sigue siendo sustancial; la solución de 2 grupos solo reproduce la separación casa/apartamento, que ya se conoce; y 4 es la primera que aísla el segmento de casas amplias de estrato bajo, el de mayor interés comercial.

## Varianza explicada entre grupos: 68.3 %

La partición explica el 68,3 % de la variabilidad de las coordenadas factoriales.

5.3 Criterios de calidad

La calidad del agrupamiento se evalúa descomponiendo la variabilidad total:

Tabla 26. Descomposición de la variabilidad de la partición en cuatro conglomerados
Criterio Valor
Suma de cuadrados total (SCT) 41741.2
Suma de cuadrados dentro de los conglomerados (SCI) 13231
Suma de cuadrados entre conglomerados (SCE) 28510.2
Proporción explicada (SCE / SCT) 68.3 %

Se cumple la identidad \(SCT = SCI + SCE\): la variabilidad se reparte entre lo que ocurre dentro de los grupos (a minimizar) y entre grupos (a maximizar).

Tabla 27. Centros de los conglomerados en las unidades originales de las variables
Segmento preciom areaconst parqueaderos banios habitaciones estrato
S1. Premium de gran formato 958.8 336.16 3.18 4.93 4.22 5.74
S2. Alta gama compacta 450.1 148.78 1.73 3.27 3.28 5.33
S3. Casa amplia estrato medio-bajo 418.8 279.65 1.10 4.08 5.86 3.90
S4. Vivienda de entrada 203.5 83.32 0.73 2.00 2.88 3.95
Tabla 28. Centros de los conglomerados en unidades tipificadas (desviaciones estándar respecto de la media general)
Segmento preciom areaconst parqueaderos banios habitaciones estrato
S1. Premium de gran formato 1.953 1.566 1.654 1.461 0.545 1.088
S2. Alta gama compacta 0.158 -0.099 0.299 0.183 -0.199 0.694
S3. Casa amplia estrato medio-bajo 0.048 1.064 -0.292 0.808 1.845 -0.709
S4. Vivienda de entrada -0.712 -0.681 -0.637 -0.795 -0.518 -0.657

Los centros tipificados leen cada segmento como desviaciones respecto del mercado. S1 supera la media en todo (precio +1,95 σ). S3 combina habitaciones +1,85 σ con estrato −0,71 σ, que es la firma del segundo eje. S4 es negativo en todo sin ser extremo en nada.

Tabla 29. Distancias entre centros de conglomerado: Euclidiana
S1 S2 S3 S4
S1 0.000 3.120 3.571 5.158
S2 3.120 0.000 2.866 2.165
S3 3.571 2.866 0.000 3.438
S4 5.158 2.165 3.438 0.000
Tabla 30. Distancias entre centros de conglomerado: Manhattan
S1 S2 S3 S4
S1 0.000 3.724 5.440 5.729
S2 3.724 0.000 3.454 2.923
S3 5.440 3.454 0.000 5.068
S4 5.729 2.923 5.068 0.000
Tabla 31. Distancias entre centros de conglomerado: Minkowski (p = 3)
S1 S2 S3 S4
S1 0.000 3.088 3.162 5.140
S2 3.088 0.000 2.807 2.075
S3 3.162 2.807 0.000 3.074
S4 5.140 2.075 3.074 0.000

Las tres métricas ordenan igual los pares. El más distante es S1–S4 (5,16 euclidiana): los extremos del eje de valor. El más cercano, S2–S4 (2,17): ambos son mayoritariamente apartamentos y se separan por gama, no por producto. S3 mantiene distancias intermedias con todos porque se separa en otra dirección del espacio.

5.4 Contraste con clasificación jerárquica

El dendrograma de Ward permite observar cómo se agregan los inmuebles por niveles:

Cortar el dendrograma en cuatro ramas no demuestra que existan cuatro grupos: el número se impone de antemano. Lo que permite es evaluar si esa partición resulta interpretable y comparable con la de k-medias usando otro algoritmo.

Tabla 32. Criterio del mayor salto de nodo a nodo en el dendrograma
Número de grupos Altura de fusión Salto respecto al nivel anterior
8 17.16 2.22
7 19.63 2.47
6 20.65 1.02
5 20.96 0.31
4 30.37 9.41
3 37.31 6.94
2 45.75 8.44
1 81.69 35.94

Aplicando el criterio del mayor salto de nodo a nodo —descartando la fusión final, que siempre es la mayor— el máximo se produce al pasar de 5 a 4 grupos (Δ = 9,41), por encima de los saltos hacia 3 (6,94) y hacia 2 (8,44). Este criterio, independiente del codo y de la silueta, respalda k = 4.

Para cuantificar la coincidencia entre particiones se usa el índice de Rand ajustado (ARI), que corrige el acuerdo esperable por azar: vale 1 si son idénticas y ~0 si coinciden como el azar.

Tabla 33. Índice de Rand ajustado entre la clasificación jerárquica y k-medias, según el método de enlace
Método de enlace ARI frente a k-medias Tamaños de los 4 grupos
ward.D2 0.387 155 / 605 / 311 / 429
complete 0.466 314 / 948 / 119 / 119
average 0.117 1377 / 94 / 27 / 2
single 0.008 1490 / 7 / 2 / 1
Tabla 34. Tabla de concordancia entre Ward (4 grupos) y k-medias sobre la submuestra
S1. Premium de gran formato S2. Alta gama compacta S3. Casa amplia estrato medio-bajo S4. Vivienda de entrada
25 0 130 0
0 319 46 240
152 149 10 0
0 0 5 424
  • El ARI de Ward frente a k-medias es 0,387: concordancia moderada, superior al azar pero lejos de una coincidencia.
  • El enlace completo alcanza 0,466, de modo que Ward ni siquiera es el más coincidente.
  • Los enlaces promedio (0,117) y simple (0,008) colapsan en un grupo con casi toda la submuestra. Es el fenómeno de encadenamiento, y confirma que la estructura del mercado es continua más que discreta.

La concordancia muestra que los extremos S1 y S4 se reconocen consistentemente, mientras que la frontera S2–S3 se reparte distinto.

Lectura honesta. No hay evidencia de exactamente cuatro grupos naturales. La silueta apunta a 2–3 y los métodos jerárquicos coinciden solo moderadamente. Lo que sí se sostiene: la partición es estable en sus extremos, interpretable y útil para la decisión comercial. Es una herramienta de gestión construida sobre los datos, no una estructura latente descubierta.

5.5 Caracterización de los segmentos

El perfil de cada segmento sintetiza las diferencias en las variables originales:

Tabla 35. Perfil de los cuatro segmentos de la oferta
Segmento N.º % de la oferta Precio mediano (millones) Área mediana (m²) Precio/m² (millones) Habitaciones Baños Parqueaderos Estrato modal % casas
S1. Premium de gran formato 1001 12.7 900.0 305.0 2.92 4 5 3 6 63.7
S2. Alta gama compacta 2586 32.7 410.0 134.5 3.22 3 3 2 5 27.6
S3. Casa amplia estrato medio-bajo 932 11.8 391.5 265.0 1.46 6 4 1 3 96.2
S4. Vivienda de entrada 3383 42.8 195.0 74.0 2.55 3 2 1 4 18.4

S1 · Premium de gran formato — 1.001 (12,7 %). 900 millones y 305 m² medianos; estrato 6 en el 75,4 %; 3 parqueaderos y 5 baños. Predominan casas (63,7 %) en Ciudad Jardín, Pance y Santa Teresita. El 88,9 % está en Sur y Oeste.

S2 · Alta gama compacta — 2.586 (32,7 %). Apartamentos (72,4 %) de estrato 5–6, 134 m² y 410 millones. Tiene el precio unitario más alto del mercado: 3,22 millones/m², por encima del premium: superficie concentrada, dotación alta y ubicación de prestigio. Absorbe el 57,1 % de la oferta del Oeste.

S3 · Casa amplia de estrato medio-bajo — 932 (11,8 %). El más singular. Casas casi en su totalidad (96,2 %), 280 m² promedio —más que la alta gama— pero estrato 3 o 4 en el 72,7 % y precio de 391 millones. Su precio por m² es 1,46 millones, el más bajo y menos de la mitad que S2. Ocupa el polo positivo de Dim 2: mucha superficie en 6 habitaciones con un solo parqueadero. Domina en Centro (45,0 %) y Oriente (44,3 %).

S4 · Vivienda de entrada — 3.383 (42,8 %). El más numeroso: apartamentos (81,6 %) de 74 m² y 195 millones, estrato 3–4 en el 75,4 %. Valle del Lili aporta 771 inmuebles, el 22,8 % del segmento.

La distribución del valor unitario por segmento revela el punto central del análisis:

Este gráfico sintetiza el argumento central: el orden por precio total no es el orden por precio unitario. S2 vale menos por inmueble que S1 pero más por m², y S3 —con áreas comparables a S1— vale menos de la mitad por m².


6 Análisis de correspondencias

6.1 Zona y estrato

El punto de partida es la tabla de contingencia entre ambas variables:

Tabla 36. Contingencia zona × estrato
E3 E4 E5 E6 Sum
Zona Centro 93 12 4 0 109
Zona Norte 556 385 730 158 1829
Zona Oeste 48 78 274 728 1128
Zona Oriente 305 8 2 1 316
Zona Sur 364 1581 1636 939 4520
Sum 1366 2064 2646 1826 7902
Tabla 37. Prueba de independencia zona × estrato
Estadístico Valor
Chi-cuadrado 3647.2
Grados de libertad 12
Valor p <0.0001
V de Cramér 0.392

Se rechaza la independencia (χ² = 3.647,2; gl = 12; p < 0,0001). La V de Cramér de 0,392 evidencia una asociación sustancial entre zona y estrato: la variabilidad de la composición socioeconómica entre zonas está lejos de ser atribuible al azar, aunque tampoco la zona reproduce el estrato de forma determinista.

6.1.1 Del chi-cuadrado al mapa factorial

El ACS es la descomposición geométrica de la prueba chi-cuadrado. Vale la pena recorrer el cálculo.

Paso 1. Valores esperados. Bajo independencia, \(e_{ij} = n_{i\cdot}\,n_{\cdot j}/n\).

Tabla 38. Matriz de valores esperados bajo independencia
E3 E4 E5 E6
Zona Centro 18.84 28.47 36.5 25.19
Zona Norte 316.17 477.73 612.4 422.65
Zona Oeste 194.99 294.63 377.7 260.66
Zona Oriente 54.63 82.54 105.8 73.02
Zona Sur 781.36 1180.62 1513.5 1044.48

Para Centro–E3: \(e_{11} = (109 \times 1\,366)\,/\,7\,902 = 18{,}84\), frente a 93 observadas.

Paso 2. Discrepancias. Cada celda aporta \(d_{ij} = (n_{ij}-e_{ij})^2/e_{ij}\).

Tabla 39. Matriz de discrepancias: aporte de cada celda al estadístico chi-cuadrado
E3 E4 E5 E6
Zona Centro 291.9 9.53 28.94 25.19
Zona Norte 181.9 18.00 22.56 165.71
Zona Oeste 110.8 159.28 28.48 837.91
Zona Oriente 1147.6 67.31 101.85 71.04
Zona Sur 222.9 135.78 9.91 10.65
Tabla 40. El estadístico chi-cuadrado es la suma de las discrepancias
Comprobación Valor
Suma de la matriz de discrepancias 3647
Estadístico chi-cuadrado de chisq.test() 3647

La suma de las 20 celdas reproduce el estadístico (3.647,21). Y localiza la asociación: Oriente–E3 aporta 1.147,6 —casi un tercio del total— y Oeste–E6, 837,9. Entre ambas, más de la mitad de la dependencia.

Paso 3. Descomposición en valores singulares. El ACS aplica una SVD a la matriz de residuos estandarizados \(S = D_r^{-1/2}(P - rc^{\top})D_c^{-1/2}\), obteniendo \(S = UDV^{\top}\).

Tabla 41. Valores singulares e inercia de cada eje factorial
Eje Valor singular Autovalor (inercia) % de inercia
Dim 1 0.5624 0.3163 68.53
Dim 2 0.3670 0.1347 29.18
Dim 3 0.1027 0.0105 2.28
Dim 4 0.0000 0.0000 0.00
Tabla 42. Coordenadas obtenidas por SVD manual frente a las de FactoMineR
Categoría SVD manual Dim 1 FactoMineR Dim 1 SVD manual Dim 2 FactoMineR Dim 2
Zonas (filas)
Zona Centro 1.7438 1.7438 0.4094 0.4094
Zona Norte 0.4127 0.4127 -0.1175 -0.1175
Zona Oeste -0.6050 -0.6050 0.8004 0.8004
Zona Oriente 2.0021 2.0021 0.5926 0.5926
Zona Sur -0.1980 -0.1980 -0.2035 -0.2035
Estratos (columnas)
E3 1.1729 1.1729 0.2350 0.2350
E4 -0.1417 -0.1417 -0.3893 -0.3893
E5 -0.1193 -0.1193 -0.2024 -0.2024
E6 -0.5445 -0.5445 0.5575 0.5575

Las coordenadas calculadas paso a paso coinciden con FactoMineR::CA() hasta el cuarto decimal; el cambio de signo en Dim 1 es la arbitrariedad ya mencionada. Y se cumple la identidad que conecta ambas secciones:

\[\text{Inercia total} \;=\; \sum_k \lambda_k \;=\; \frac{\chi^2}{n} \;=\; \frac{3\,647{,}21}{7\,902} \;=\; 0{,}4616\]

La inercia del mapa es el chi-cuadrado normalizado por n. La prueba dice si hay asociación; el mapa, cómo se reparte.

6.1.2 Mapa factorial

El mapa factorial representa filas y columnas en un mismo plano:

La inercia se concentra casi por completo en los dos primeros ejes:

Las coordenadas y contribuciones permiten interpretar cada eje:

Tabla 43. Coordenadas y contribuciones del ACS zona × estrato
Categoría Dim 1 Dim 2 Contribución Dim1 (%) Calidad (cos² 1+2)
Zonas (filas)
Zona Centro 1.744 0.409 13.3 0.984
Zona Norte 0.413 -0.118 12.5 0.867
Zona Oeste -0.605 0.800 16.5 0.999
Zona Oriente 2.002 0.593 50.7 0.993
Zona Sur -0.198 -0.203 7.1 0.961
Estratos (columnas)
E3 1.173 0.235 75.2 1.000
E4 -0.142 -0.389 1.7 0.908
E5 -0.119 -0.202 1.5 0.762
E6 -0.544 0.558 21.7 0.999

Los dos ejes recogen el 97,7 % de la inercia: el mapa es casi una representación exacta de la tabla.

Dimensión 1 (68,5 %) — gradiente socioeconómico. Opone estrato 3 (+1,173; 75,2 % de contribución) a estrato 6 (−0,544; 21,7 %). Del lado bajo, Oriente (+2,002) y Centro (+1,744); del alto, Oeste (−0,605).

Dimensión 2 (29,2 %) — especificidad del Oeste. La Zona Oeste aporta el 67,9 % de este eje. Existe fundamentalmente para separarla del resto: su perfil no es simplemente “de estrato alto” sino que se concentra fuertemente en el estrato 6 (728 de 1.128 inmuebles, el 64,5 %), con una franja intermedia mucho más delgada que la de Sur y Norte.

Las asociaciones positivas más fuertes: Oeste–E6 (+35,7), Oriente–E3 (+38,0) y Sur–E4 (+20,7). Las negativas: Sur–E3 (−25,1) y Norte–E6 (−16,8). Un residuo mayor a ±3 ya es significativo.

6.2 Barrio y segmento

Con 376 barrios en la base final, el análisis se restringe a los 30 de mayor oferta.

Tabla 44. Barrios que más contribuyen al primer eje
Barrio Contribución Dim1 (%)
Ciudad Jardin 19.05
Valle Del Lili 19.05
Pance 17.48
Santa Teresita 9.38
El Caney 4.94
Normandia 4.56
Brisas De Los 3.50
Melendez 2.77
Torres De Comfandi 2.57
Los Cristales 2.55

La inercia total (0,641) revela fuerte especialización barrial.

Dimensión 1 (75,9 %). En el extremo positivo, Pance (+1,079), Ciudad Jardín (+0,968) y Santa Teresita (+0,957), junto a S1 (+1,101). En el negativo, Torres de Comfandi (−1,049), Brisas (−1,021), Meléndez (−0,962) y Valle del Lili (−0,680), junto a S4 (−0,732). Cuatro barrios aportan el 65,0 % del eje: son los que estructuran el mercado.

Dimensión 2 (14,6 %). Aísla a S3 (+0,968) y a Ciudad 2000 (+0,850), Santa Isabel (+0,783), El Limonar (+0,551) y Nueva Tequendama (+0,550): barrios consolidados de trama antigua.

Valle del Lili, el de mayor oferta (1.006 registros), contribuye 19,1 % a cada eje. Es bimodal: 771 inmuebles de entrada y 210 de alta gama. No admite una estrategia comercial única.

6.3 Correspondencias múltiples

El ACM extiende el análisis a cinco variables categóricas. Precio y área se discretizan en rangos. Se excluye piso del conjunto activo por ser redundante con tipo.

Tabla 45. Inercia del ACM con corrección de Benzécri
Dimensión Valor propio % bruto % corregido (Benzécri)
Dim 1 0.5083 18.15 66.03
Dim 2 0.3664 13.09 19.25
Dim 3 0.3275 11.70 11.29
Dim 4 0.2657 9.49 3.00
Dim 5 0.2237 7.99 0.39
Dim 6 0.2077 7.42 0.04

Con la corrección de Benzécri, los dos primeros ejes explican el 85,3 % de la inercia ajustada.

La razón de correlación mide cuánto de cada eje explica cada variable:

Tabla 46. Razón de correlación de cada variable con los ejes
η² Dim 1 η² Dim 2
tipo 0.131 0.235
zona 0.218 0.332
estrato_f 0.597 0.554
rango_area 0.718 0.391
rango_precio 0.878 0.320

Dimensión 1 (66,0 %) — eje de gama. En el extremo negativo, precio bajo (−1,284), área pequeña (−1,208) y estrato 3 (−0,896); en el positivo, precio premium (+1,433), estrato 6 (+1,218), área muy grande (+1,088) y Zona Oeste (+1,038); estas cuatro aportan el 42,2 % del eje. Lo relevante: área, precio y estrato se ordenan monótonamente sobre el mismo eje, es decir, son manifestaciones de un único factor de gama.

Dimensión 2 (19,3 %) — configuración del producto. Contrapone Oriente (+2,450), Centro (+1,857) y estrato 3 (+1,463; 20,2 % de contribución) frente a área media (−0,879) y estrato 5 (−0,707). Es la traducción cualitativa del segundo eje del ACP, dominada por estrato (η² = 0,554) y zona (η² = 0,332).

Los segmentos proyectados como suplementarios validan la coherencia del conjunto: S1 (+1,458; +0,662), S2 (+0,508; −0,584), S3 (+0,356; +0,856) y S4 (−0,918; +0,015). Segmentos construidos con variables cuantitativas ocupan posiciones interpretables en el espacio cualitativo.

6.4 Tipo y zona

La composición por tipo de inmueble varía de forma marcada entre zonas:

Tabla 47. Composición por tipo dentro de cada zona (V de Cramér = 0.286, p < 0,0001)
Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
Apartamento (%) 22.0 64.8 87.9 19.3 61.3
Casa (%) 78.0 35.2 12.1 80.7 38.7
Total de inmuebles 109 1829 1128 316 4520

La asociación es significativa y de magnitud apreciable (V = 0,286), menor que la observada entre zona y estrato. El contraste más fuerte: Oeste, con 87,9 % de apartamentos, frente a Oriente (80,7 % casas) y Centro (78,0 %). Al ser un corte transversal, estos datos describen la composición actual y no permiten afirmar que exista un proceso de densificación en curso.


7 Visualización geográfica

Como referencia territorial se incorpora el límite municipal de Santiago de Cali a partir de un archivo shapefile obtenido de la capa de “Centros poblados” de Colombia en mapas del Portal Nacional de Información Geoespacial (https://www.colombiaenmapas.gov.co/)

## Límite municipal cargado: 1 polígono(s) · sistema de referencia EPSG:4326

Los mapas estáticos se dibujan sobre ese contorno pero sin cartografía de fondo, para que el documento se pueda compilar y leer sin conexión; los mapas interactivos del final de la sección sí llevan mapa base de OpenStreetMap. Cada punto es un inmueble, coloreado por segmento:

Separando los segmentos se aprecia mejor la huella territorial de cada uno:

Las facetas revelan lógicas territoriales distintas: S1 dibuja un arco compacto en el sur y el corredor oeste; S2 se extiende sobre el mismo eje con mayor alcance al norte; S3 aparece disperso en la franja oriental y central que los demás abandonan; S4 es el más extendido, con núcleo denso en Valle del Lili.

La misma superficie de valor sobre cartografía de OpenStreetMap, donde las calles y los barrios sirven de referencia:

Sobre el callejero se distingue con nitidez el corredor del oeste —la franja de mayor valor unitario, encajada contra los cerros— y el contraste con la ladera oriental de la ciudad.

El mapa interactivo permite explorar la oferta inmueble por inmueble:

Muestra de 400 inmuebles por segmento para preservar la fluidez. Se puede activar cada segmento en el control superior derecho y consultar la ficha haciendo clic.

La tabla permite filtrar y ordenar los 56 barrios con oferta suficiente para un análisis fiable, y sirve de referencia operativa para las recomendaciones de la última sección.


8 Conclusiones

1. Dos dimensiones latentes describen la oferta. Seis variables se resumen en dos componentes que retienen el 81,1 % de la varianza. Un tasador puede posicionar cualquier inmueble con dos coordenadas en lugar de seis atributos.

2. El número de habitaciones muestra la asociación más débil con el precio entre los atributos analizados. Su correlación simple es 0,27, la más baja del conjunto, y es la variable peor representada en la componente de valor (cos² = 0,281). Más aún, al controlar por el área la asociación cambia de signo (r parcial = −0,282; p < 0,0001): a igualdad de superficie, más habitaciones se asocia con un menor precio por m². Los datos son compatibles con que el mercado valore la superficie y su dotación por encima del número de espacios en que se subdivide, aunque un diseño observacional como este no permite atribuir el efecto a una causa.

3. Cuatro segmentos con lógicas comerciales propias. Explican el 68,3 % de la variabilidad factorial. El precio mediano varía 4,6 veces entre S1 y S4, pero en precio por m² esos dos apenas se separan un 15 % y el orden se reordena: S2 encabeza con 3,22 millones/m² y S3 cierra con 1,46.

4. La localización presenta una asociación marcada con el estrato, aunque no uniforme. V de Cramér 0,392; el Oeste presenta una fuerte concentración y asociación con el estrato 6 (64,5 % de su oferta; residuo estandarizado +35,7) y el Oriente con el estrato 3 (96,5 %; +38,0). La Zona Sur es transversal: aloja los cuatro segmentos y concentra el 57 % de la oferta.

5. Los barrios están altamente especializados, con excepciones estratégicas. Inercia de 0,641; cuatro barrios estructuran el eje principal. Valle del Lili es bimodal y contribuye a ambos ejes.

6. Existe un diferencial de precio identificable. S3 —932 inmuebles, el 96,2 % de ellos casas, con 280 m² de área media y el 72,7 % en estratos 3–4— se oferta a 1,46 millones/m², un 55 % por debajo de la alta gama. Es un diferencial en precios de oferta: llamarlo arbitraje exigiría conocer precios de cierre, costos de intervención y tiempos de venta.


9 Recomendaciones estratégicas

9.0.1 Valoración y captación

R1. Usar el score factorial como herramienta de posicionamiento, no de tasación. Las coordenadas sitúan cualquier inmueble respecto de la nube y dicen a qué segmento se parece y qué tan típico es. No es un instrumento de tasación: como preciom es variable activa, la posición ya contiene el precio, y usarla para juzgar si el precio es anómalo sería circular. Para valorar hace falta un modelo donde el precio sea la variable dependiente —una regresión hedónica—, con el ACP como etapa previa para la multicolinealidad.

R2. Reponderar los criterios de captación hacia área, baños, parqueaderos y estrato, que aportan en conjunto el 69,7 % de la primera componente. El número de habitaciones no debe eliminarse del formulario —su contribución es del 7,9 %, baja pero no nula—, aunque sí conviene dejar de tratarlo como indicador de valor: como mostró la correlación parcial, a igualdad de área se asocia con un menor precio por m².

R3. Usar el precio por m² como métrica primaria de comparación. El precio total confunde tamaño con posicionamiento, y el orden de los segmentos cambia según la métrica.

9.0.2 Estrategia por segmento

R4. S3 como línea prioritaria a evaluar. Priorizar dentro de S3 las casas amplias de estratos 3–4. El segmento presenta un precio mediano de 1,46 millones/m², y ese subconjunto concreto —656 inmuebles, el 70,4 % de S3— se oferta a 1,41 millones/m² con 265 m² de área media: el diferencial de precio de oferta más amplio de la base. Adquirirlas y reposicionarlas es la hipótesis a contrastar. Barrios objetivo según el ACS: El Caney, La Flora, Ciudad 2000, El Limonar, Santa Isabel y Acopi. Piloto de 8 a 10 inmuebles antes de escalar; el margen efectivo depende de costos y tiempos que estos datos no contienen.

R5. S2 como foco del esfuerzo comercial principal. 2.586 inmuebles y el precio unitario más alto. No puede afirmarse que sea el de mayor rotación —eso exigiría tiempos en mercado—, pero sí donde más producto hay en el rango de mayor valor unitario. Concentrar en Ciudad Jardín, Valle del Lili, Pance y La Flora.

R6. S1 con tratamiento especializado y de bajo volumen en Pance, Ciudad Jardín y Santa Teresita. La expectativa habitual es un ciclo de comercialización más largo, pero esta base no permite verificarlo: sería la primera métrica a instrumentar.

R7. S4 como base de volumen con foco en eficiencia operativa. Al ser el 42,8 % y el más homogéneo, es donde más rinde estandarizar: procesos digitales, visitas virtuales, financiación preaprobada. La concentración en Valle del Lili permite economías de escala.

9.0.3 Estrategia territorial

R8. Doble estrategia en Valle del Lili. Su bimodalidad exige dos equipos y dos narrativas; una sola aproximación desperdicia la mitad del potencial del barrio con mayor oferta de la ciudad.

R9. Priorizar la Zona Oeste para obra nueva en altura. 87,9 % de apartamentos y el precio unitario más alto: la combinación más favorable para producto vertical.

R10. Oriente y Centro con criterio de opción. Tienen la menor presencia en la oferta publicada (5,4 %) y los precios unitarios más bajos. Eso no los hace ilíquidos —la liquidez se mide con transacciones—, pero desaconseja una operación ordinaria. Mantener seguimiento del precio por m²: ante una renovación urbana, el potencial de reposicionamiento partiría de una base de costo muy baja.

9.0.4 Gestión de la información

R11. Corregir la captura de datos. El análisis expuso tres fallas evitables: campos vacíos en lugar de cero (parqueaderos), valores imposibles (77 registros) e inconsistencia de codificación en barrio (436 nombres para 385 barrios). Validación en origen y catálogo maestro de barrios.

R12. Ampliar la base con variables de tiempo y resultado: fecha de publicación, tiempo en mercado y precio final de venta. Es el paso que permitiría pasar del análisis descriptivo a modelos de valoración y de predicción de tiempos de venta.


10 Limitaciones

  • Los datos reflejan precios de oferta, no de transacción: los niveles absolutos son techos, no valores efectivos.
  • Sin dimensión temporal: no hay tendencias, estacionalidad ni antigüedad del anuncio. Es una fotografía estática.
  • Un solo portal: la muestra puede sobrerrepresentar segmentos y omitir operaciones por canales tradicionales.
  • Solo estratos 3 a 6: las conclusiones no aplican a vivienda de interés social.
  • La imputación de parqueaderos es una decisión razonada, no verificable con los datos disponibles.
  • Los métodos son descriptivos: identifican estructura y asociación, no causalidad.

11 Referencias

  • Benzécri, J.-P. (1992). Correspondence Analysis Handbook. Marcel Dekker.
  • Everitt, B. S., Landau, S., Leese, M., & Stahl, D. (2011). Cluster Analysis (5.ª ed.). Wiley.
  • Greenacre, M. (2017). Correspondence Analysis in Practice (3.ª ed.). Chapman & Hall/CRC.
  • Husson, F., Lê, S., & Pagès, J. (2017). Exploratory Multivariate Analysis by Example Using R (2.ª ed.). Chapman & Hall/CRC.
  • Jolliffe, I. T. (2002). Principal Component Analysis (2.ª ed.). Springer.
  • Lê, S., Josse, J., & Husson, F. (2008). FactoMineR: An R Package for Multivariate Analysis. Journal of Statistical Software, 25(1), 1–18.
  • Peña, D. (2002). Análisis de datos multivariantes. McGraw-Hill.
  • Datos: paqueteMODELOS, conjunto vivienda, obtenido de OLX mediante web scraping.

12 Anexo A. Código completo

Todo el código que genera este informe se reúne aquí, en el mismo orden en que se ejecuta. Los bloques están etiquetados con el nombre del fragmento correspondiente, de modo que cada tabla y cada figura del documento puede rastrearse hasta las líneas que la producen.

knitr::opts_chunk$set(
  echo = FALSE, warning = FALSE, message = FALSE,
  fig.align = "center", fig.width = 9, fig.height = 5.5,
  dpi = 110, out.width = "100%"
)
options(scipen = 999, digits = 4)
set.seed(2024)

# Numeración automática de tablas: evita desajustes al insertar o mover secciones
.tab_n <- 0
tabnum <- function(texto) { .tab_n <<- .tab_n + 1; paste0("Tabla ", .tab_n, ". ", texto) }
library(dplyr)            # manipulación
library(tidyr)            # reestructuración
library(ggplot2)          # gráficos
library(FactoMineR)       # ACP, ACS, ACM
library(factoextra)       # visualización de métodos factoriales
library(cluster)          # silueta, gap statistic
library(corrplot)         # matriz de correlaciones
library(psych)            # KMO y prueba de Bartlett
library(ca)               # análisis de correspondencias
library(leaflet)          # mapa interactivo
library(DT)               # tablas interactivas
library(knitr)            # kable
library(kableExtra)       # formato de tablas
library(patchwork)        # composición de gráficos
library(RColorBrewer)     # paletas
library(stringi)          # normalización de texto independiente del locale
library(sf)               # lectura del límite municipal (shapefile)

tema_informe <- theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold", size = 13),
        plot.subtitle = element_text(color = "grey35"),
        legend.position = "bottom",
        panel.grid.minor = element_blank())
theme_set(tema_informe)

pal_cl <- c("#1b6ca8", "#22a699", "#f2a25c", "#c0392b")
# ---------------------------------------------------------------------
# Carga de la base. Vía principal: el paquete del curso, que no está en
# CRAN y se instala con
#   remotes::install_github("centromagis/paqueteMODELOS")
# Vía de respaldo: si el paquete no está disponible, se descarga el mismo
# archivo de datos directamente del repositorio, de modo que el informe
# siempre se pueda compilar.
# ---------------------------------------------------------------------
if (requireNamespace("paqueteMODELOS", quietly = TRUE)) {
  library(paqueteMODELOS)
  data("vivienda")
  origen_datos <- "paquete paqueteMODELOS"
} else {
  url_rda <- paste0("https://raw.githubusercontent.com/centromagis/",
                    "paqueteMODELOS/main/data/vivienda.rda")
  archivo <- tempfile(fileext = ".rda")
  download.file(url_rda, archivo, mode = "wb", quiet = TRUE)
  load(archivo)
  origen_datos <- "archivo vivienda.rda del repositorio"
}

viv <- as.data.frame(vivienda)
dim_original <- dim(viv)
str(viv, give.attr = FALSE)
diccionario <- data.frame(
  Variable = c("id","zona","piso","estrato","preciom","areaconst","parqueaderos",
               "banios","habitaciones","tipo","barrio","longitud","latitud"),
  Tipo = c("Identificador","Cualitativa nominal","Cualitativa ordinal","Cualitativa ordinal",
           "Cuantitativa continua","Cuantitativa continua","Cuantitativa discreta",
           "Cuantitativa discreta","Cuantitativa discreta","Cualitativa nominal",
           "Cualitativa nominal","Cuantitativa continua","Cuantitativa continua"),
  Descripcion = c("Código único del anuncio",
                  "Zona de la ciudad (Norte, Sur, Oriente, Oeste, Centro)",
                  "Piso en que se ubica el inmueble (aplica a apartamentos)",
                  "Estrato socioeconómico (3 a 6)",
                  "Precio de oferta en millones de pesos",
                  "Área construida en metros cuadrados",
                  "Número de parqueaderos",
                  "Número de baños",
                  "Número de habitaciones",
                  "Tipo de inmueble (Casa / Apartamento)",
                  "Barrio en que se ubica",
                  "Coordenada de longitud",
                  "Coordenada de latitud")
)
kable(diccionario, col.names = c("Variable","Tipo","Descripción"),
      caption = tabnum("Diccionario de variables")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = TRUE)
na_tab <- data.frame(
  Variable = names(viv),
  Faltantes = colSums(is.na(viv)),
  Porcentaje = round(100 * colSums(is.na(viv)) / nrow(viv), 2),
  row.names = NULL
) %>% arrange(desc(Faltantes))

kable(na_tab, col.names = c("Variable","N.º de faltantes","% del total"),
      caption = tabnum("Valores faltantes por variable")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"))
invisible(mice::md.pattern(viv, rotate.names = TRUE))
viv %>%
  filter(!is.na(tipo)) %>%
  mutate(Reporta = ifelse(is.na(parqueaderos), "Sin dato", "Reporta parqueadero")) %>%
  group_by(tipo, Reporta) %>%
  summarise(n = n(),
            `Precio mediano (millones)` = median(preciom, na.rm = TRUE),
            `Área mediana (m²)` = median(areaconst, na.rm = TRUE),
            `Estrato mediano` = median(estrato, na.rm = TRUE), .groups = "drop") %>%
  kable(caption = tabnum("Perfil de los registros sin dato de parqueadero")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"))
ceros <- data.frame(
  Variable = c("habitaciones = 0", "baños = 0"),
  Casos = c(sum(viv$habitaciones == 0, na.rm = TRUE), sum(viv$banios == 0, na.rm = TRUE))
)
kable(ceros, caption = tabnum("Registros con valores estructuralmente imposibles")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
# Normalización robusta e independiente del sistema operativo:
# 1) repara los caracteres doblemente codificados que trae el scraping,
# 2) translitera las tildes a ASCII, 3) unifica minúsculas y espacios.
normalizar_texto <- function(x) {
  x <- enc2utf8(as.character(x))
  mojibake <- c("\u221a\u00b0" = "a", "\u221a\u00a9" = "e", "\u221a\u2260" = "i",
                "\u221a\u00b3" = "o", "\u221a\u222b" = "u", "\u221a\u00b1" = "n")
  for (k in names(mojibake)) x <- stri_replace_all_fixed(x, k, mojibake[[k]])
  x <- stri_trans_general(x, "Latin-ASCII")
  x <- tolower(trimws(x))
  x <- gsub("[^a-z0-9 ]", " ", x)
  trimws(gsub("\\s+", " ", x))
}

viv_lim <- viv %>%
  filter(rowSums(is.na(.)) < ncol(.)) %>%                       # filas totalmente vacías
  filter(!is.na(preciom), !is.na(areaconst), !is.na(estrato),
         !is.na(zona), !is.na(tipo)) %>%                        # faltantes aislados
  filter(habitaciones > 0, banios > 0) %>%                      # valores imposibles
  mutate(
    parqueaderos = ifelse(is.na(parqueaderos), 0, parqueaderos),# imputación justificada
    barrio       = normalizar_texto(barrio),
    tipo         = factor(tipo),
    zona         = factor(zona),
    estrato_f    = factor(paste0("E", estrato)),
    precio_m2    = preciom * 1e6 / areaconst,
    piso_num     = suppressWarnings(as.numeric(piso)),
    piso_cat     = case_when(
      tipo == "Casa"      ~ "Casa (no aplica)",
      is.na(piso_num)     ~ "Sin dato",
      piso_num <= 2       ~ "Piso 1-2",
      piso_num <= 5       ~ "Piso 3-5",
      piso_num <= 8       ~ "Piso 6-8",
      TRUE                ~ "Piso 9 o más")
  )

cat("Registros tras la depuración básica:", nrow(viv_lim))
vars_activas <- c("preciom","areaconst","parqueaderos","banios","habitaciones","estrato")
X <- viv_lim[, vars_activas]
d_mah <- mahalanobis(X, colMeans(X), cov(X))
umbral <- qchisq(0.999, df = length(vars_activas))
viv_lim$atipico <- d_mah > umbral

ggplot(data.frame(d = d_mah, at = viv_lim$atipico), aes(x = d, fill = at)) +
  geom_histogram(bins = 90, alpha = .9) +
  geom_vline(xintercept = umbral, linetype = "dashed", color = "#c0392b", linewidth = .8) +
  annotate("text", x = umbral * 1.6, y = Inf, vjust = 2,
           label = paste0("Umbral χ²(0,999) = ", round(umbral, 1)), color = "#c0392b", size = 3.6) +
  scale_x_continuous(trans = "log1p", breaks = c(0, 5, 22, 100, 500, 2000)) +
  scale_fill_manual(values = c("#1b6ca8", "#c0392b"), labels = c("Regular","Atípico")) +
  labs(title = "Distribución de la distancia de Mahalanobis",
       subtitle = paste0(sum(viv_lim$atipico), " inmuebles (",
                         round(100 * mean(viv_lim$atipico), 2),
                         " %) superan el umbral y se excluyen del análisis factorial"),
       x = "Distancia de Mahalanobis (escala log)", y = "Frecuencia", fill = NULL)
viv_lim %>% filter(atipico) %>%
  summarise(n = n(),
            `Precio mediano (millones)` = median(preciom),
            `Área mediana (m²)` = median(areaconst),
            `Habitaciones (mediana)` = median(habitaciones),
            `Baños (mediana)` = median(banios),
            `Área máxima (m²)` = max(areaconst)) %>%
  kable(caption = tabnum("Perfil de los inmuebles atípicos excluidos")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"))
viv_f <- viv_lim %>% filter(!atipico)

flujo <- data.frame(
  Paso = c("Base original",
           "Se eliminan filas completamente vacías",
           "Se eliminan faltantes aislados en variables clave",
           "Se eliminan habitaciones = 0 y baños = 0",
           "Se imputan 1.555 parqueaderos faltantes con 0",
           "Se excluyen atípicos multivariantes (Mahalanobis)"),
  Registros = c(8322, 8320, 8319, 8243, 8243, nrow(viv_f))
) %>% mutate(`Pérdida acumulada (%)` = round(100 * (8322 - Registros) / 8322, 2))

kable(flujo, caption = tabnum("Trazabilidad de la depuración")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed")) %>%
  row_spec(6, bold = TRUE, background = "#eaf3f8")
p1 <- ggplot(viv_f, aes(x = preciom)) +
  geom_histogram(bins = 45, fill = "#1b6ca8", alpha = .88) +
  labs(title = "Precio de oferta", x = "Millones de pesos", y = "Frecuencia")

p2 <- ggplot(viv_f, aes(x = areaconst)) +
  geom_histogram(bins = 45, fill = "#22a699", alpha = .88) +
  labs(title = "Área construida", x = "Metros cuadrados", y = NULL)

p1 + p2 + plot_annotation(
  title = "Ambas variables presentan asimetría positiva pronunciada",
  subtitle = "La oferta se concentra en el rango bajo con una cola larga hacia el segmento premium",
  theme = theme(plot.title = element_text(face = "bold")))
viv_f %>%
  group_by(Zona = zona) %>%
  summarise(`N.º de inmuebles` = n(),
            `Precio mediano (millones)` = median(preciom),
            `Área mediana (m²)` = median(areaconst),
            `Precio/m² mediano (millones)` = round(median(precio_m2) / 1e6, 2),
            `% apartamentos` = round(100 * mean(tipo == "Apartamento"), 1),
            .groups = "drop") %>%
  arrange(desc(`Precio/m² mediano (millones)`)) %>%
  kable(caption = tabnum("Caracterización del mercado por zona")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"))
ggplot(viv_f, aes(x = reorder(zona, precio_m2, median), y = precio_m2 / 1e6, fill = zona)) +
  geom_boxplot(outlier.alpha = .12, width = .6) +
  scale_fill_brewer(palette = "Set2", guide = "none") +
  coord_flip() +
  labs(title = "Precio por metro cuadrado según zona de la ciudad",
       subtitle = "La Zona Oeste se separa claramente del resto del mercado",
       x = NULL, y = "Millones de pesos por m²")
M <- cor(viv_f[, vars_activas])
corrplot(M, method = "color", type = "upper", order = "hclust",
         addCoef.col = "black", number.cex = .85, tl.col = "black",
         tl.srt = 45, diag = FALSE, col = colorRampPalette(c("#c0392b","white","#1b6ca8"))(200),
         mar = c(0,0,2,0), title = "Matriz de correlaciones de las variables activas")
kmo <- KMO(M)
bart <- cortest.bartlett(M, n = nrow(viv_f))
data.frame(
  Prueba = c("Medida KMO de adecuación muestral", "Prueba de esfericidad de Bartlett"),
  Estadístico = c(round(kmo$MSA, 3), round(bart$chisq, 1)),
  `Valor p` = c("\u2014", format.pval(bart$p.value, eps = .001)),
  Interpretación = c("Adecuación aceptable (> 0,70)", "Se rechaza la hipótesis de matriz identidad"),
  check.names = FALSE
) %>%
  kable(caption = tabnum("Pruebas de adecuación para el ACP")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"))
data.frame(Variable = names(kmo$MSAi), `MSA individual` = round(kmo$MSAi, 3),
           check.names = FALSE, row.names = NULL) %>%
  arrange(desc(`MSA individual`)) %>%
  kable(caption = tabnum("Medida de adecuación muestral (MSA) de cada variable")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
pm <- viv_f$preciom
data.frame(
  Indicador = c("N", "Media", "Desviación estándar", "Mínimo", "Q1", "Mediana", "Q3",
                "Máximo", "MAD", "Rango intercuartílico", "Coeficiente de variación",
                "Asimetría", "Curtosis"),
  Valor = round(c(length(pm), mean(pm), sd(pm), min(pm), quantile(pm, .25), median(pm),
                  quantile(pm, .75), max(pm), mad(pm), IQR(pm), sd(pm)/mean(pm),
                  psych::skew(pm), psych::kurtosi(pm)), 3)
) %>%
  kable(caption = tabnum("Estadísticos descriptivos del precio de oferta (millones de pesos)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
# Se releveliza `tipo` para que el contraste se exprese como Casa − Apartamento
viv_f$tipo_t <- relevel(factor(viv_f$tipo), ref = "Casa")
tt <- t.test(preciom ~ tipo_t, data = viv_f)

n_ap <- sum(viv_f$tipo == "Apartamento"); n_ca <- sum(viv_f$tipo == "Casa")
s_p  <- sqrt(((n_ap-1)*var(viv_f$preciom[viv_f$tipo=="Apartamento"]) +
              (n_ca-1)*var(viv_f$preciom[viv_f$tipo=="Casa"])) / (nrow(viv_f)-2))
dif <- unname(tt$estimate[1] - tt$estimate[2])   # media Casa − media Apartamento

data.frame(
  Elemento = c("Media casas (millones)", "Media apartamentos (millones)",
               "Diferencia estimada (Casa − Apartamento)", "Estadístico t",
               "Grados de libertad (Welch)", "Valor p",
               "IC 95 % de la diferencia", "d de Cohen"),
  Resultado = c(round(tt$estimate[1], 1), round(tt$estimate[2], 1),
                round(dif, 1), round(tt$statistic, 2),
                round(tt$parameter, 0), format.pval(tt$p.value, eps = .0001),
                paste0("[", round(tt$conf.int[1], 1), " ; ", round(tt$conf.int[2], 1), "]"),
                round(dif/s_p, 3))
) %>%
  kable(caption = tabnum("Comparación de medias de precio entre casas y apartamentos (prueba t de Welch)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
ggplot(viv_f, aes(x = tipo, y = preciom, fill = tipo)) +
  geom_boxplot(width = .45, outlier.alpha = .1) +
  scale_fill_manual(values = c("#1b6ca8", "#c0392b"), guide = "none") +
  coord_flip() +
  labs(title = "Precio de oferta según tipo de inmueble",
       subtitle = "La diferencia de medias es significativa, pero el solapamiento es amplio",
       x = NULL, y = "Millones de pesos")
ct  <- cor.test(viv_f$preciom, viv_f$areaconst)
cth <- cor.test(viv_f$preciom, viv_f$habitaciones)
data.frame(
  Par = c("Precio – Área construida", "Precio – Habitaciones"),
  Covarianza = c(round(cov(viv_f$preciom, viv_f$areaconst), 1),
                 round(cov(viv_f$preciom, viv_f$habitaciones), 1)),
  `Correlación r` = c(round(ct$estimate, 4), round(cth$estimate, 4)),
  `IC 95 %` = c(paste0("[", round(ct$conf.int[1],3), " ; ", round(ct$conf.int[2],3), "]"),
                paste0("[", round(cth$conf.int[1],3), " ; ", round(cth$conf.int[2],3), "]")),
  `R² (% explicado)` = c(round(100*ct$estimate^2, 1), round(100*cth$estimate^2, 1)),
  `Valor p` = c(format.pval(ct$p.value, eps = .0001), format.pval(cth$p.value, eps = .0001)),
  check.names = FALSE
) %>%
  kable(caption = tabnum("Relación lineal del precio con el área construida y con el número de habitaciones")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"))
r_ph <- cor(viv_f$preciom, viv_f$habitaciones)
r_pa <- cor(viv_f$preciom, viv_f$areaconst)
r_ah <- cor(viv_f$areaconst, viv_f$habitaciones)
r_parcial <- (r_ph - r_pa * r_ah) / sqrt((1 - r_pa^2) * (1 - r_ah^2))
n_obs <- nrow(viv_f); t_par <- r_parcial * sqrt((n_obs - 3)/(1 - r_parcial^2))

data.frame(
  Medida = c("Correlación simple precio – habitaciones",
             "Correlación parcial precio – habitaciones (controlando área)",
             "Estadístico t de la correlación parcial", "Valor p"),
  Valor = c(round(r_ph, 4), round(r_parcial, 4), round(t_par, 2),
            format.pval(2 * pt(-abs(t_par), n_obs - 3), eps = .0001))
) %>%
  kable(caption = tabnum("Correlación simple frente a correlación parcial entre precio y número de habitaciones")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
viv_f %>% filter(areaconst >= 80, areaconst <= 140, habitaciones <= 6) %>%
  group_by(habitaciones) %>%
  summarise(n = n(), pm2 = median(precio_m2)/1e6, .groups = "drop") %>%
  ggplot(aes(x = factor(habitaciones), y = pm2)) +
  geom_col(fill = "#1b6ca8", alpha = .88, width = .65) +
  geom_text(aes(label = paste0(round(pm2, 2), "\nn=", n)), vjust = -0.25, size = 3.2) +
  scale_y_continuous(expand = expansion(mult = c(0, .16))) +
  labs(title = "Precio por m² según número de habitaciones, a igualdad de área",
       subtitle = "Inmuebles entre 80 y 140 m²",
       x = "Número de habitaciones", y = "Millones de pesos por m²")
# `ncp` se fija en el número de variables activas: a partir de FactoMineR 2.x
# el objeto `$eig` se trunca a `ncp` filas, y necesitamos la tabla completa
# de valores propios (deben sumar p = 6).
acp <- PCA(viv_f[, c(vars_activas, "precio_m2", "zona", "tipo")],
           quanti.sup = 7, quali.sup = 8:9,
           scale.unit = TRUE, ncp = length(vars_activas), graph = FALSE)
data.frame(Variable = vars_activas,
           `Varianza en escala original` = round(diag(cov(viv_f[, vars_activas])), 2),
           `Varianza tras tipificar` = 1,
           check.names = FALSE, row.names = NULL) %>%
  kable(caption = tabnum("Varianza de las variables activas antes y después de tipificar")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
R_mat <- cor(viv_f[, vars_activas])
eig_r <- eigen(R_mat)

vec <- as.data.frame(round(eig_r$vectors, 4))
names(vec) <- paste0("v", 1:ncol(vec))
rownames(vec) <- vars_activas
kable(vec, caption = tabnum("Autovectores de la matriz de correlaciones (coeficientes de las combinaciones lineales)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"))

data.frame(Componente = paste("Dim", 1:6),
           Autovalor = round(eig_r$values, 4),
           `Varianza explicada (%)` = round(100 * eig_r$values / sum(eig_r$values), 2),
           check.names = FALSE) %>%
  kable(caption = tabnum("Autovalores de la matriz de correlaciones")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
verif <- data.frame(
  Comprobación = c("Suma de los autovalores (debe ser igual a p)",
                   "Varianza de las coordenadas de los individuos en Dim 1",
                   "Autovalor 1 obtenido por eigen()",
                   "Carga de 'preciom' en Dim 1 según FactoMineR",
                   "Autovector v1 de 'preciom' × raíz del autovalor 1"),
  Valor = round(c(sum(eig_r$values),
                  var(acp$ind$coord[,1]) * (nrow(viv_f)-1) / nrow(viv_f),
                  eig_r$values[1],
                  acp$var$coord["preciom", 1],
                  abs(eig_r$vectors[1,1]) * sqrt(eig_r$values[1])), 4))
kable(verif, caption = tabnum("Verificación de la descomposición espectral")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
fviz_eig(acp, addlabels = TRUE, barfill = "#1b6ca8", barcolor = "#1b6ca8",
         linecolor = "#c0392b", ylim = c(0, 65)) +
  geom_hline(yintercept = 100/6, linetype = "dashed", color = "grey40") +
  annotate("text", x = 5, y = 100/6 + 3, label = "Criterio de Kaiser (16,7 %)",
           color = "grey30", size = 3.5) +
  labs(title = "Varianza explicada por cada componente principal",
       subtitle = "Dos componentes concentran el 81,1 % de la información original",
       x = "Componente principal", y = "% de varianza explicada")
eig <- as.data.frame(acp$eig)
rownames(eig) <- NULL
eig$Componente <- paste("Dim", 1:nrow(eig))
eig %>%
  select(Componente, everything()) %>%
  setNames(c("Componente","Valor propio","% de varianza","% acumulado")) %>%
  mutate(across(2:4, ~round(.x, 2))) %>%
  kable(caption = tabnum("Valores propios y varianza explicada")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed")) %>%
  row_spec(1:2, bold = TRUE, background = "#eaf3f8")
cargas <- as.data.frame(round(acp$var$coord[, 1:2], 3))
cargas$`Contribución Dim1 (%)` <- round(acp$var$contrib[, 1], 1)
cargas$`Contribución Dim2 (%)` <- round(acp$var$contrib[, 2], 1)
cargas$`Calidad (cos² Dim1+2)` <- round(rowSums(acp$var$cos2[, 1:2]), 3)
names(cargas)[1:2] <- c("Correlación Dim1", "Correlación Dim2")
kable(cargas, caption = tabnum("Cargas, contribuciones y calidad de representación de las variables")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"))
fviz_pca_var(acp, col.var = "contrib", repel = TRUE,
             gradient.cols = c("#8fb8d4", "#1b6ca8", "#c0392b"),
             col.quanti.sup = "#f39c12") +
  labs(title = "Círculo de correlaciones",
       subtitle = "En naranja, el precio por m² proyectado como variable suplementaria",
       color = "Contribución (%)")
rot <- psych::principal(viv_f[, vars_activas], nfactors = 2,
                        rotate = "varimax", scores = TRUE)

kable(round(unclass(rot$loadings)[, 1:2], 3), col.names = c("RC1", "RC2"),
      caption = tabnum("Cargas de los componentes tras la rotación varimax")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
vac <- as.data.frame(round(rot$Vaccounted[c("SS loadings","Proportion Var","Cumulative Var"), ], 4))
rownames(vac) <- c("Suma de cargas al cuadrado", "Proporción de varianza", "Proporción acumulada")
kable(vac, caption = tabnum("Varianza explicada por los componentes rotados")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
sc <- as.data.frame(rot$scores); names(sc) <- c("RC1", "RC2")

ggplot(sc, aes(RC1, RC2)) +
  geom_point(alpha = .18, size = .7, color = "grey20") +
  geom_hline(yintercept = 0, color = "#1b6ca8", linewidth = .5) +
  geom_vline(xintercept = 0, color = "#1b6ca8", linewidth = .5) +
  labs(title = "Distribución de las viviendas en los componentes",
       subtitle = "Puntuaciones de los componentes rotados (varimax)",
       x = "Componente 1 (RC1) · nivel socioeconómico y dotación",
       y = "Componente 2 (RC2) · capacidad física")
CO <- acp$ind$coord
sel <- c(which.max(CO[,1]), which.min(CO[,1]), which.max(CO[,2]), which.min(CO[,2]))
casos <- viv_f[sel, c("tipo","barrio","estrato","areaconst","habitaciones","banios",
                      "parqueaderos","preciom")]
casos$`Precio/m² (mill.)` <- round(viv_f$precio_m2[sel]/1e6, 2)
casos$`Dim 1` <- round(CO[sel,1], 2); casos$`Dim 2` <- round(CO[sel,2], 2)
rownames(casos) <- c("Máximo en Dim 1","Mínimo en Dim 1","Máximo en Dim 2","Mínimo en Dim 2")
kable(casos, caption = tabnum("Inmuebles situados en los extremos de cada componente")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), font_size = 12)
ext <- data.frame(CO[sel, 1:2]); ext$eje <- c("Dim 1","Dim 1","Dim 2","Dim 2")
ext$lab <- c("máx Dim1","mín Dim1","máx Dim2","mín Dim2")

fviz_pca_ind(acp, geom = "point", col.ind = "grey82", pointsize = .6) +
  geom_point(data = ext, aes(Dim.1, Dim.2, color = eje), size = 4) +
  ggrepel::geom_text_repel(data = ext, aes(Dim.1, Dim.2, label = lab), size = 3.6) +
  scale_color_manual(values = c("#c0392b", "#1b6ca8")) +
  labs(title = "Casos extremos sobre el plano factorial",
       subtitle = "Los cuatro inmuebles que definen el sentido de cada eje",
       x = paste0("Dim 1 (", round(acp$eig[1,2],1), " %)"),
       y = paste0("Dim 2 (", round(acp$eig[2,2],1), " %)"), color = NULL)
fviz_pca_biplot(acp, geom.ind = "point", pointsize = .7, alpha.ind = .16,
                habillage = viv_f$tipo, addEllipses = TRUE, ellipse.level = .8,
                palette = c("#1b6ca8", "#c0392b"), col.var = "grey20",
                repel = TRUE, label = "var") +
  labs(title = "Biplot: individuos y variables sobre el mismo plano",
       subtitle = "Elipses de concentración al 80 % por tipo de inmueble",
       x = paste0("Dim 1 (", round(acp$eig[1,2],1), " %)"),
       y = paste0("Dim 2 (", round(acp$eig[2,2],1), " %)"))
qs <- as.data.frame(round(acp$quali.sup$coord[, 1:2], 3))
names(qs) <- c("Coordenada Dim1", "Coordenada Dim2")
kable(qs, caption = tabnum("Coordenadas de las categorías suplementarias")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed")) %>%
  pack_rows("Zona", 1, 5) %>% pack_rows("Tipo", 6, 7)
dd <- dimdesc(acp, axes = 1:2)
data.frame(
  Variable = c(rownames(dd$Dim.1$quali), rownames(dd$Dim.2$quali)),
  Dimensión = rep(c("Dim 1", "Dim 2"), each = 2),
  `` = round(c(dd$Dim.1$quali[, "R2"], dd$Dim.2$quali[, "R2"]), 4),
  `Valor p` = format.pval(c(dd$Dim.1$quali[, "p.value"], dd$Dim.2$quali[, "p.value"]), eps = .001),
  check.names = FALSE, row.names = NULL) %>%
  kable(caption = tabnum("Descomposición de la varianza de cada eje según las variables cualitativas suplementarias")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
n_dimdesc <- .tab_n
coord <- acp$ind$coord[, 1:3]

g_wss <- fviz_nbclust(coord, kmeans, method = "wss", k.max = 10, nstart = 25) +
  labs(title = "Método del codo", subtitle = "Suma de cuadrados intra-grupo", x = "Número de clústeres", y = "SCI total")

set.seed(2024)
sub <- coord[sample(nrow(coord), 2000), ]
g_sil <- fviz_nbclust(sub, kmeans, method = "silhouette", k.max = 8, nstart = 25) +
  labs(title = "Método de la silueta", subtitle = "Submuestra de 2.000 inmuebles", x = "Número de clústeres", y = "Ancho de silueta")

g_wss + g_sil
set.seed(2024)
wss <- sapply(1:8, function(k) kmeans(coord, k, nstart = 25, iter.max = 100)$tot.withinss)
data.frame(k = 2:8,
           `SCI total` = round(wss[2:8], 0),
           `Reducción respecto a k-1 (%)` = round(100 * (1 - wss[2:8] / wss[1:7]), 1),
           check.names = FALSE) %>%
  kable(caption = tabnum("Ganancia marginal por cada clúster adicional")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE) %>%
  row_spec(3, bold = TRUE, background = "#eaf3f8")
set.seed(2024)
km <- kmeans(coord, centers = 4, nstart = 50, iter.max = 100)
viv_f$cluster <- factor(km$cluster)

# La numeración que devuelve k-medias es arbitraria. Las etiquetas se asignan
# a partir del perfil de cada grupo para que el informe sea reproducible.
res_cl <- viv_f %>% group_by(cluster) %>%
  summarise(precio = median(preciom), area = median(areaconst),
            pm2 = median(precio_m2), .groups = "drop")

etiquetas <- setNames(rep(NA_character_, 4), as.character(res_cl$cluster))
etiquetas[as.character(res_cl$cluster[which.max(res_cl$precio)])] <- "S1. Premium de gran formato"
etiquetas[as.character(res_cl$cluster[which.min(res_cl$pm2)])]    <- "S3. Casa amplia estrato medio-bajo"
etiquetas[as.character(res_cl$cluster[which.min(res_cl$area)])]   <- "S4. Vivienda de entrada"
etiquetas[is.na(etiquetas)] <- "S2. Alta gama compacta"
stopifnot(length(unique(etiquetas)) == 4)

niveles <- c("S1. Premium de gran formato", "S2. Alta gama compacta",
             "S3. Casa amplia estrato medio-bajo", "S4. Vivienda de entrada")
viv_f$segmento <- factor(etiquetas[as.character(viv_f$cluster)], levels = niveles)
pal_cl <- setNames(c("#c0392b", "#1b6ca8", "#f2a25c", "#22a699"), niveles)
cat("Varianza explicada entre grupos:", round(100 * km$betweenss / km$totss, 1), "%")
data.frame(
  Criterio = c("Suma de cuadrados total (SCT)",
               "Suma de cuadrados dentro de los conglomerados (SCI)",
               "Suma de cuadrados entre conglomerados (SCE)",
               "Proporción explicada (SCE / SCT)"),
  Valor = c(round(km$totss, 1), round(km$tot.withinss, 1), round(km$betweenss, 1),
            paste0(round(100 * km$betweenss / km$totss, 2), " %"))
) %>%
  kable(caption = tabnum("Descomposición de la variabilidad de la partición en cuatro conglomerados")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
centros_orig <- viv_f %>% group_by(Segmento = segmento) %>%
  summarise(across(all_of(vars_activas), ~round(mean(.x), 2)), .groups = "drop")
kable(centros_orig, caption = tabnum("Centros de los conglomerados en las unidades originales de las variables")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed")) %>%
  column_spec(1, bold = TRUE)

Z <- scale(viv_f[, vars_activas])
centros_z <- aggregate(Z, by = list(Segmento = viv_f$segmento), FUN = mean)
centros_z[, -1] <- round(centros_z[, -1], 3)
kable(centros_z, caption = tabnum("Centros de los conglomerados en unidades tipificadas (desviaciones estándar respecto de la media general)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed")) %>%
  column_spec(1, bold = TRUE)
cent <- km$centers
rownames(cent) <- etiquetas[rownames(cent)]
cent <- cent[niveles, ]
rownames(cent) <- substr(niveles, 1, 2)

for (m in c("euclidean", "manhattan", "minkowski")) {
  nombre <- c(euclidean = "Euclidiana", manhattan = "Manhattan",
              minkowski = "Minkowski (p = 3)")[m]
  cat(kable(round(as.matrix(dist(cent, method = m, p = 3)), 3), format = "html",
            caption = tabnum(paste0("Distancias entre centros de conglomerado: ", nombre))) %>%
        kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE))
  cat("\n\n")
}
set.seed(2024)
idx <- sample(nrow(coord), 1500)
hc  <- hclust(dist(coord[idx, ]), method = "ward.D2")

fviz_dend(hc, k = 4, cex = .001, k_colors = c("#1b6ca8","#22a699","#f2a25c","#c0392b"),
          rect = TRUE, rect_fill = TRUE,
          show_labels = FALSE, main = "") +
  labs(title = "Dendrograma de Ward sobre una submuestra de 1.500 inmuebles",
       subtitle = "Corte en cuatro grupos para contrastar la partición obtenida por k-medias",
       y = "Distancia de agregación")
alturas <- tail(hc$height, 9)
saltos  <- data.frame(k = 9:1, Altura = round(alturas, 2))
saltos$Salto <- c(NA, round(diff(alturas), 2))

kable(saltos[-1, ], row.names = FALSE,
      col.names = c("Número de grupos", "Altura de fusión", "Salto respecto al nivel anterior"),
      caption = tabnum("Criterio del mayor salto de nodo a nodo en el dendrograma")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE) %>%
  row_spec(which(saltos$k[-1] == 4), bold = TRUE, background = "#eaf3f8")
# Índice de Rand ajustado calculado a partir de la tabla de contingencia
indice_rand <- function(a, b) {
  tab <- table(a, b); n <- sum(tab)
  suma_ij <- sum(choose(tab, 2))
  suma_i  <- sum(choose(rowSums(tab), 2))
  suma_j  <- sum(choose(colSums(tab), 2))
  esperado <- suma_i * suma_j / choose(n, 2)
  (suma_ij - esperado) / (0.5 * (suma_i + suma_j) - esperado)
}

D_sub <- dist(coord[idx, ]); km_sub <- km$cluster[idx]
enlaces <- c("ward.D2", "complete", "average", "single")
comparacion <- do.call(rbind, lapply(enlaces, function(m) {
  corte <- cutree(hclust(D_sub, method = m), 4)
  data.frame(`Método de enlace` = m,
             `ARI frente a k-medias` = round(indice_rand(corte, km_sub), 3),
             `Tamaños de los 4 grupos` = paste(table(corte), collapse = " / "),
             check.names = FALSE)
}))
kable(comparacion, caption = tabnum("Índice de Rand ajustado entre la clasificación jerárquica y k-medias, según el método de enlace")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE) %>%
  row_spec(1, bold = TRUE, background = "#eaf3f8")
tab_conc <- table(Ward = cutree(hc, 4), `k-medias` = viv_f$segmento[idx])
kable(tab_conc, caption = tabnum("Tabla de concordancia entre Ward (4 grupos) y k-medias sobre la submuestra")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)

perfil <- viv_f %>%
  group_by(Segmento = segmento) %>%
  summarise(`N.º` = n(),
            `% de la oferta` = round(100 * n() / nrow(viv_f), 1),
            `Precio mediano (millones)` = median(preciom),
            `Área mediana (m²)` = median(areaconst),
            `Precio/m² (millones)` = round(median(precio_m2) / 1e6, 2),
            `Habitaciones` = median(habitaciones),
            `Baños` = median(banios),
            `Parqueaderos` = median(parqueaderos),
            `Estrato modal` = as.numeric(names(sort(table(estrato), decreasing = TRUE))[1]),
            `% casas` = round(100 * mean(tipo == "Casa"), 1),
            .groups = "drop")

kable(perfil, caption = tabnum("Perfil de los cuatro segmentos de la oferta")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), font_size = 12) %>%
  column_spec(1, bold = TRUE)
centroides <- data.frame(coord[, 1:2], segmento = viv_f$segmento) %>%
  group_by(segmento) %>% summarise(across(everything(), mean), .groups = "drop")

ggplot(data.frame(coord[, 1:2], segmento = viv_f$segmento),
       aes(x = Dim.1, y = Dim.2, color = segmento)) +
  geom_point(alpha = .22, size = .8) +
  stat_ellipse(level = .8, linewidth = .8) +
  geom_point(data = centroides, size = 4.5, shape = 21, fill = "white", stroke = 1.6) +
  scale_color_manual(values = pal_cl) +
  labs(title = "Los cuatro segmentos sobre el plano factorial",
       subtitle = "Eje horizontal: tamaño y valor · Eje vertical: habitaciones frente a estrato · Elipses al 80 %",
       x = paste0("Dim 1 (", round(acp$eig[1,2],1), " %)"),
       y = paste0("Dim 2 (", round(acp$eig[2,2],1), " %)"), color = NULL) +
  guides(color = guide_legend(nrow = 2, override.aes = list(alpha = 1, size = 3)))
viv_f %>%
  select(segmento, preciom, areaconst, parqueaderos, banios, habitaciones, estrato) %>%
  group_by(segmento) %>% summarise(across(everything(), mean), .groups = "drop") %>%
  tidyr::pivot_longer(-segmento) %>%
  group_by(name) %>% mutate(z = as.numeric(scale(value))) %>% ungroup() %>%
  mutate(name = factor(name,
        levels = c("preciom","areaconst","banios","parqueaderos","habitaciones","estrato"),
        labels = c("Precio","Área","Baños","Parqueaderos","Habitaciones","Estrato"))) %>%
  ggplot(aes(x = name, y = z, fill = segmento)) +
  geom_col(position = position_dodge(.8), width = .72) +
  geom_hline(yintercept = 0, color = "grey35") +
  scale_fill_manual(values = pal_cl) +
  labs(title = "Firma característica de cada segmento",
       subtitle = "Media de cada variable tipificada entre segmentos; 0 = promedio del mercado",
       x = NULL, y = "Desviaciones estándar respecto al promedio", fill = NULL) +
  theme(legend.position = "bottom") +
  guides(fill = guide_legend(nrow = 2))
viv_f %>%
  count(zona, segmento) %>% group_by(zona) %>% mutate(pct = 100 * n / sum(n)) %>%
  ggplot(aes(x = zona, y = pct, fill = segmento)) +
  geom_col(width = .7) +
  geom_text(aes(label = ifelse(pct > 7, paste0(round(pct), "%"), "")),
            position = position_stack(vjust = .5), size = 3.2, color = "white", fontface = "bold") +
  scale_fill_manual(values = pal_cl) +
  labs(title = "Composición de la oferta por zona de la ciudad",
       subtitle = "Cada zona tiene una mezcla de producto característica",
       x = NULL, y = "% de la oferta de la zona", fill = NULL) +
  guides(fill = guide_legend(nrow = 2))
ggplot(viv_f, aes(x = segmento, y = precio_m2 / 1e6, fill = segmento)) +
  geom_violin(alpha = .35, color = NA) +
  geom_boxplot(width = .18, outlier.alpha = .1) +
  scale_fill_manual(values = pal_cl, guide = "none") +
  scale_x_discrete(labels = function(x) gsub(" ", "\n", x)) +
  labs(title = "Precio por metro cuadrado según segmento",
       subtitle = "El segmento de alta gama compacta supera al premium en valor unitario",
       x = NULL, y = "Millones de pesos por m²")
tabla_ze <- table(viv_f$zona, viv_f$estrato_f)
kable(addmargins(tabla_ze), caption = tabnum("Contingencia zona × estrato")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)

chi <- chisq.test(tabla_ze)
v_cramer <- sqrt(chi$statistic / (sum(tabla_ze) * (min(dim(tabla_ze)) - 1)))
data.frame(Estadístico = c("Chi-cuadrado", "Grados de libertad", "Valor p", "V de Cramér"),
           Valor = c(round(chi$statistic, 1), chi$parameter,
                     format.pval(chi$p.value, eps = .0001), round(v_cramer, 3))) %>%
  kable(caption = tabnum("Prueba de independencia zona × estrato")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
esp <- chi$expected
kable(round(esp, 2), caption = tabnum("Matriz de valores esperados bajo independencia")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
discrep <- (tabla_ze - esp)^2 / esp
kable(round(discrep, 2), caption = tabnum("Matriz de discrepancias: aporte de cada celda al estadístico chi-cuadrado")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)

data.frame(Comprobación = c("Suma de la matriz de discrepancias",
                            "Estadístico chi-cuadrado de chisq.test()"),
           Valor = round(c(sum(discrep), as.numeric(chi$statistic)), 3)) %>%
  kable(caption = tabnum("El estadístico chi-cuadrado es la suma de las discrepancias")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
ca_ze <- CA(tabla_ze, graph = FALSE)

P  <- tabla_ze / sum(tabla_ze)
r_ <- rowSums(P); c_ <- colSums(P)
S_ <- diag(1/sqrt(r_)) %*% (P - r_ %*% t(c_)) %*% diag(1/sqrt(c_))
sv <- svd(S_)

data.frame(Eje = paste("Dim", 1:4),
           `Valor singular` = round(sv$d, 5),
           `Autovalor (inercia)` = round(sv$d^2, 5),
           `% de inercia` = round(100 * sv$d^2 / sum(sv$d^2), 2),
           check.names = FALSE) %>%
  kable(caption = tabnum("Valores singulares e inercia de cada eje factorial")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
F_ <- diag(1/sqrt(r_)) %*% sv$u %*% diag(sv$d)
G_ <- diag(1/sqrt(c_)) %*% sv$v %*% diag(sv$d)

data.frame(
  Categoría = c(rownames(tabla_ze), colnames(tabla_ze)),
  `SVD manual Dim 1` = round(c(-F_[,1], -G_[,1]), 4),
  `FactoMineR Dim 1` = round(c(ca_ze$row$coord[,1], ca_ze$col$coord[,1]), 4),
  `SVD manual Dim 2` = round(c(F_[,2], G_[,2]), 4),
  `FactoMineR Dim 2` = round(c(ca_ze$row$coord[,2], ca_ze$col$coord[,2]), 4),
  check.names = FALSE, row.names = NULL) %>%
  kable(caption = tabnum("Coordenadas obtenidas por SVD manual frente a las de FactoMineR")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed")) %>%
  pack_rows("Zonas (filas)", 1, 5) %>% pack_rows("Estratos (columnas)", 6, 9)
fviz_ca_biplot(ca_ze, repel = TRUE, map = "symmetric",
               col.row = "#1b6ca8", col.col = "#c0392b", arrows = c(FALSE, FALSE)) +
  labs(title = "Mapa de correspondencias: zona × estrato",
       subtitle = paste0("Los dos primeros ejes recogen el ",
                         round(sum(ca_ze$eig[1:2,2]), 1), " % de la inercia total (",
                         round(sum(ca_ze$eig[,1]), 3), ")"),
       x = paste0("Dim 1 (", round(ca_ze$eig[1,2],1), " %)"),
       y = paste0("Dim 2 (", round(ca_ze$eig[2,2],1), " %)"))
fviz_screeplot(ca_ze, addlabels = TRUE, barfill = "#1b6ca8", barcolor = "#1b6ca8",
               ylim = c(0, 80)) +
  labs(title = "Inercia explicada por cada eje del análisis de correspondencias",
       x = "Ejes", y = "Porcentaje de inercia explicada")
data.frame(
  Categoría = c(rownames(ca_ze$row$coord), rownames(ca_ze$col$coord)),
  `Dim 1` = round(c(ca_ze$row$coord[,1], ca_ze$col$coord[,1]), 3),
  `Dim 2` = round(c(ca_ze$row$coord[,2], ca_ze$col$coord[,2]), 3),
  `Contribución Dim1 (%)` = round(c(ca_ze$row$contrib[,1], ca_ze$col$contrib[,1]), 1),
  `Calidad (cos² 1+2)` = round(c(rowSums(ca_ze$row$cos2[,1:2]), rowSums(ca_ze$col$cos2[,1:2])), 3),
  check.names = FALSE, row.names = NULL) %>%
  kable(caption = tabnum("Coordenadas y contribuciones del ACS zona × estrato")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed")) %>%
  pack_rows("Zonas (filas)", 1, 5) %>% pack_rows("Estratos (columnas)", 6, 9)
res <- as.data.frame(as.table(chi$stdres))
names(res) <- c("Zona","Estrato","Residuo")
ggplot(res, aes(x = Estrato, y = Zona, fill = Residuo)) +
  geom_tile(color = "white", linewidth = 1) +
  geom_text(aes(label = round(Residuo, 1)), size = 3.8, fontface = "bold") +
  scale_fill_gradient2(low = "#c0392b", mid = "white", high = "#1b6ca8", midpoint = 0) +
  labs(title = "Residuos estandarizados de la tabla zona × estrato",
       subtitle = "Azul: más casos de los esperados bajo independencia · Rojo: menos casos",
       x = NULL, y = NULL)
top30 <- names(sort(table(viv_f$barrio), decreasing = TRUE))[1:30]
sub_b <- viv_f %>% filter(barrio %in% top30)
tabla_bs <- table(sub_b$barrio, sub_b$segmento)
colnames(tabla_bs) <- c("S1 Premium", "S2 Alta gama", "S3 Casa amplia", "S4 Entrada")

ca_bs <- CA(tabla_bs, graph = FALSE)
fviz_ca_biplot(ca_bs, repel = TRUE, map = "symmetric",
               col.row = "#4a6572", col.col = "#c0392b",
               labelsize = 3.4, arrows = c(FALSE, FALSE)) +
  labs(title = "Mapa de correspondencias: barrios × segmentos de oferta",
       subtitle = paste0("30 barrios con mayor oferta · ",
                         round(sum(ca_bs$eig[1:2,2]), 1), " % de la inercia representada"),
       x = paste0("Dim 1 (", round(ca_bs$eig[1,2],1), " %)"),
       y = paste0("Dim 2 (", round(ca_bs$eig[2,2],1), " %)"))
titulo <- function(x) gsub("\\b([a-z])", "\\U\\1", x, perl = TRUE)
data.frame(Barrio = titulo(names(sort(ca_bs$row$contrib[,1], decreasing = TRUE))[1:10]),
           `Contribución Dim1 (%)` = round(sort(ca_bs$row$contrib[,1], decreasing = TRUE)[1:10], 2),
           check.names = FALSE, row.names = NULL) %>%
  kable(caption = tabnum("Barrios que más contribuyen al primer eje")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
datos_acm <- viv_f %>%
  mutate(
    rango_precio = cut(preciom, breaks = c(0, 200, 350, 600, 2000),
                       labels = c("Precio bajo (<200M)","Precio medio (200-350M)",
                                  "Precio alto (350-600M)","Precio premium (>600M)")),
    rango_area = cut(areaconst, breaks = c(0, 80, 140, 250, 2000),
                     labels = c("Área pequeña (<80)","Área media (80-140)",
                                "Área grande (140-250)","Área muy grande (>250)"))
  ) %>%
  select(tipo, zona, estrato_f, rango_area, rango_precio, segmento)

# Número de variables activas y de modalidades. La inercia total de un ACM es
# K/Q - 1, valor que se calcula de forma analítica para no depender de que
# `$eig` venga completo (FactoMineR 2.x lo trunca a `ncp` filas).
Q <- 5
K <- sum(vapply(datos_acm[, 1:Q], nlevels, integer(1)))
inercia_total <- K/Q - 1

acm <- MCA(datos_acm, quali.sup = 6, graph = FALSE, ncp = K - Q)

ev <- acm$eig[, 1]; ev_ret <- ev[ev > 1/Q]
benzecri <- (Q/(Q-1))^2 * (ev_ret - 1/Q)^2
data.frame(Dimensión = paste("Dim", seq_along(ev_ret)),
           `Valor propio` = round(ev_ret, 4),
           `% bruto` = round(100 * ev_ret / inercia_total, 2),
           `% corregido (Benzécri)` = round(100 * benzecri / sum(benzecri), 2),
           check.names = FALSE, row.names = NULL) %>%
  kable(caption = tabnum("Inercia del ACM con corrección de Benzécri")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
fviz_mca_var(acm, repel = TRUE, col.var = "contrib",
             gradient.cols = c("#8fb8d4", "#1b6ca8", "#c0392b"),
             col.quali.sup = "#f39c12", labelsize = 3.5) +
  labs(title = "Mapa de correspondencias múltiples",
       subtitle = "En naranja, los segmentos del clúster proyectados como variable suplementaria",
       x = paste0("Dim 1 (", round(acm$eig[1,2],1), " %)"),
       y = paste0("Dim 2 (", round(acm$eig[2,2],1), " %)"),
       color = "Contribución (%)")
as.data.frame(round(acm$var$eta2[, 1:2], 3)) %>%
  setNames(c("η² Dim 1", "η² Dim 2")) %>%
  kable(caption = tabnum("Razón de correlación de cada variable con los ejes")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)
tz <- table(viv_f$tipo, viv_f$zona)
chi_tz <- chisq.test(tz)
v_tz <- sqrt(chi_tz$statistic / (sum(tz) * (min(dim(tz)) - 1)))

rbind(`Apartamento (%)` = format(round(100 * prop.table(tz, 2)[1, ], 1), nsmall = 1),
      `Casa (%)` = format(round(100 * prop.table(tz, 2)[2, ], 1), nsmall = 1),
      `Total de inmuebles` = format(colSums(tz))) %>%
  kable(caption = paste0(tabnum("Composición por tipo dentro de cada zona (V de Cramér = "),
                         round(v_tz, 3), ", p < 0,0001)")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"))
# Ruta al shapefile del límite municipal. Deben estar presentes, en la misma
# carpeta y con el mismo nombre, los archivos .shp, .shx, .dbf y .prj.
# En Windows se usan barras normales (/) o dobles invertidas (\\).
ruta_limite <- "Z:/00_Maestria_DataScience/2do_Semestre/Estadistica/u_1/cali.shp"

limite_cali <- NULL
if (file.exists(ruta_limite)) {
  limite_cali <- tryCatch({
    x <- sf::st_read(ruta_limite, quiet = TRUE)
    if (is.na(sf::st_crs(x))) sf::st_crs(x) <- 4326   # si falta el .prj
    x <- sf::st_zm(x, drop = TRUE)                    # descartar coordenada Z/M
    sf::st_transform(x, 4326)                         # a lon/lat WGS84
  }, error = function(e) NULL)
}
# Control de cordura: el contorno debe cubrir la nube de puntos. Si no la
# cubre (por ejemplo, si falta el .prj y el archivo venía proyectado), se
# descarta en lugar de dibujar un mapa incoherente.
if (!is.null(limite_cali)) {
  bb <- sf::st_bbox(limite_cali)
  cubre <- bb["xmin"] <= -76.45 && bb["xmax"] >= -76.60 + 0.12 &&
           bb["ymin"] <=   3.35 && bb["ymax"] >=   3.45
  if (!cubre) {
    warning("El límite leído no coincide con las coordenadas de la base; se omite.")
    limite_cali <- NULL
  }
}
hay_limite <- !is.null(limite_cali)

# Capas reutilizables: si no hay límite, ambas quedan neutras
capa_limite <- if (hay_limite) {
  geom_sf(data = limite_cali, fill = "grey95", color = "grey45",
          linewidth = .4, inherit.aes = FALSE)
} else NULL

coord_mapa <- if (hay_limite) coord_sf(expand = TRUE) else coord_fixed(1.02)

if (hay_limite) {
  cat("Límite municipal cargado:", nrow(limite_cali), "polígono(s) ·",
      "sistema de referencia EPSG:4326")
} else {
  cat("No se encontró el shapefile en la ruta indicada.",
      "Los mapas se dibujan sin el contorno municipal.")
}
ggplot(viv_f, aes(x = longitud, y = latitud, color = segmento)) +
  capa_limite +
  geom_point(alpha = .38, size = .75) +
  scale_color_manual(values = pal_cl) +
  coord_mapa +
  labs(title = "Localización de la oferta inmobiliaria en Santiago de Cali",
       subtitle = "Cada punto es un inmueble ofertado, coloreado según su segmento",
       x = "Longitud", y = "Latitud", color = NULL) +
  guides(color = guide_legend(nrow = 2, override.aes = list(size = 3, alpha = 1))) +
  theme(panel.background = element_rect(fill = "grey97", color = NA))
ggplot(viv_f, aes(x = longitud, y = latitud)) +
  capa_limite +
  geom_point(data = viv_f %>% select(-segmento), color = "grey80", size = .35) +
  geom_point(aes(color = segmento), alpha = .5, size = .7) +
  facet_wrap(~ segmento, nrow = 2) +
  scale_color_manual(values = pal_cl, guide = "none") +
  coord_mapa +
  labs(title = "Huella territorial de cada segmento",
       subtitle = "En gris, la totalidad de la oferta como referencia",
       x = "Longitud", y = "Latitud") +
  theme(strip.text = element_text(face = "bold"),
        panel.background = element_rect(fill = "grey98", color = NA))
ggplot(viv_f, aes(x = longitud, y = latitud, color = precio_m2 / 1e6)) +
  capa_limite +
  geom_point(alpha = .55, size = .85) +
  scale_color_gradientn(colors = c("#2c7bb6","#abd9e9","#ffffbf","#fdae61","#d7191c"),
                        limits = c(0.5, 6), oob = scales::squish,
                        name = "millones por m²") +
  coord_mapa +
  labs(title = "Mapa de valor unitario del suelo edificado",
       subtitle = "El corredor oeste y el sur consolidado concentran los valores más altos por metro cuadrado",
       x = "Longitud", y = "Latitud") +
  theme(panel.background = element_rect(fill = "grey96", color = NA))
set.seed(2024)
m_pm2 <- viv_f %>% slice_sample(n = 2500)
pal_pm2 <- colorNumeric(c("#2c7bb6","#abd9e9","#ffffbf","#fdae61","#d7191c"),
                        domain = c(0.5, 6), na.color = "grey70")

mapa_val <- leaflet(m_pm2) %>%
  addProviderTiles(providers$OpenStreetMap.Mapnik, group = "OpenStreetMap") %>%
  addProviderTiles(providers$CartoDB.Positron,     group = "Mapa claro") %>%
  setView(lng = -76.53, lat = 3.42, zoom = 12) %>%
  addCircleMarkers(~longitud, ~latitud,
    color = ~pal_pm2(pmin(pmax(precio_m2/1e6, 0.5), 6)),
    radius = 4, stroke = FALSE, fillOpacity = .75,
    popup = ~paste0("<b>", barrio, "</b><br>",
                    "<b>Precio/m²:</b> ", round(precio_m2/1e6, 2), " millones<br>",
                    "<b>Precio:</b> ", format(preciom, big.mark = "."), " millones<br>",
                    "<b>Área:</b> ", areaconst, " m² &middot; <b>Estrato:</b> ", estrato)) %>%
  addLegend("bottomright", pal = pal_pm2, values = c(0.5, 6),
            title = "Millones/m²", opacity = .9)

if (hay_limite) {
  mapa_val <- mapa_val %>%
    addPolygons(data = limite_cali, fill = FALSE, color = "#1a1a1a",
                weight = 2, opacity = .75, group = "Límite municipal")
}

mapa_val %>%
  addLayersControl(baseGroups = c("OpenStreetMap", "Mapa claro"),
                   overlayGroups = if (hay_limite) "Límite municipal" else NULL,
                   options = layersControlOptions(collapsed = FALSE))
set.seed(2024)
muestra <- viv_f %>% group_by(segmento) %>% slice_sample(n = 400) %>% ungroup()

pal_leaf <- colorFactor(unname(pal_cl[niveles]), domain = niveles)

mapa_seg <- leaflet(muestra) %>%
  addProviderTiles(providers$OpenStreetMap.Mapnik, group = "OpenStreetMap") %>%
  addProviderTiles(providers$CartoDB.Positron,     group = "Mapa claro") %>%
  addProviderTiles(providers$Esri.WorldImagery,    group = "Satélite") %>%
  setView(lng = -76.53, lat = 3.42, zoom = 12) %>%
  addCircleMarkers(
    ~longitud, ~latitud,
    color = ~pal_leaf(segmento), radius = 4, stroke = FALSE, fillOpacity = .72,
    popup = ~paste0(
      "<b>", segmento, "</b><br>",
      "<b>Barrio:</b> ", barrio, "<br>",
      "<b>Zona:</b> ", zona, " &middot; <b>Estrato:</b> ", estrato, "<br>",
      "<b>Tipo:</b> ", tipo, "<br>",
      "<b>Precio:</b> ", format(preciom, big.mark = "."), " millones<br>",
      "<b>Área:</b> ", areaconst, " m²<br>",
      "<b>Precio/m²:</b> ", round(precio_m2/1e6, 2), " millones<br>",
      "<b>Hab./Baños/Parq.:</b> ", habitaciones, " / ", banios, " / ", parqueaderos),
    group = ~as.character(segmento)) %>%
  addLegend("bottomright", pal = pal_leaf, values = ~segmento,
            title = "Segmento", opacity = .9)

if (hay_limite) {
  mapa_seg <- mapa_seg %>%
    addPolygons(data = limite_cali, fill = FALSE, color = "#1a1a1a",
                weight = 2, opacity = .75, group = "Límite municipal")
}

mapa_seg %>%
  addLayersControl(baseGroups = c("OpenStreetMap", "Mapa claro", "Satélite"),
                   overlayGroups = c(levels(viv_f$segmento),
                                     if (hay_limite) "Límite municipal"),
                   options = layersControlOptions(collapsed = FALSE))
viv_f %>%
  group_by(Barrio = titulo(barrio)) %>%
  filter(n() >= 30) %>%
  summarise(`Oferta` = n(),
            `Precio mediano (millones)` = median(preciom),
            `Área mediana (m²)` = median(areaconst),
            `Precio/m² (millones)` = round(median(precio_m2)/1e6, 2),
            `Estrato modal` = as.numeric(names(sort(table(estrato), decreasing = TRUE))[1]),
            `Segmento dominante` = names(sort(table(segmento), decreasing = TRUE))[1],
            .groups = "drop") %>%
  arrange(desc(`Precio/m² (millones)`)) %>%
  datatable(rownames = FALSE, filter = "top",
            options = list(pageLength = 12, scrollX = TRUE,
                           language = list(search = "Buscar:", lengthMenu = "Mostrar _MENU_ registros",
                                           info = "Mostrando _START_ a _END_ de _TOTAL_ barrios",
                                           paginate = list(previous = "Anterior", `next` = "Siguiente"))),
            caption = tabnum("Barrios con 30 o más inmuebles ofertados, ordenados por valor unitario"))
sessionInfo()

13 Anexo B. Información de la sesión

Versiones de R y de los paquetes empleados, para garantizar la reproducibilidad del análisis.

## R version 4.3.1 (2023-06-16 ucrt)
## Platform: x86_64-w64-mingw32/x64 (64-bit)
## Running under: Windows 10 x64 (build 19045)
## 
## Matrix products: default
## 
## 
## locale:
## [1] LC_COLLATE=English_United States.utf8 
## [2] LC_CTYPE=English_United States.utf8   
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C                          
## [5] LC_TIME=English_United States.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         broom_1.0.5          boot_1.3-28.1       
##  [7] sf_1.0-19            stringi_1.8.3        RColorBrewer_1.1-3  
## [10] patchwork_1.3.2      kableExtra_1.4.0     knitr_1.45          
## [13] DT_0.33              leaflet_2.2.2        ca_0.71.1           
## [16] psych_2.6.5          corrplot_0.92        cluster_2.1.8.3     
## [19] factoextra_1.0.7     FactoMineR_2.16      ggplot2_4.0.3       
## [22] tidyr_1.3.2          dplyr_1.1.4         
## 
## loaded via a namespace (and not attached):
##   [1] rstudioapi_0.15.0       jsonlite_1.8.9          shape_1.4.6.1          
##   [4] magrittr_2.0.3          TH.data_1.1-2           estimability_2.0.0     
##   [7] jomo_2.7-6              magick_2.9.1            farver_2.1.1           
##  [10] nloptr_2.0.3            rmarkdown_2.27          vctrs_0.6.5            
##  [13] minqa_1.2.6             base64enc_0.1-3         rstatix_1.1.0          
##  [16] htmltools_0.5.7         mitml_0.4-5             sass_0.4.9             
##  [19] KernSmooth_2.23-21      bslib_0.6.1             htmlwidgets_1.6.4      
##  [22] plyr_1.8.9              sandwich_3.1-1          emmeans_2.0.4          
##  [25] zoo_1.8-12              lubridate_1.9.3         cachem_1.0.8           
##  [28] lifecycle_1.0.5         iterators_1.0.14        pkgconfig_2.0.3        
##  [31] Matrix_1.6-5            R6_2.5.1                fastmap_1.1.1          
##  [34] digest_0.6.35           showtext_0.9-8          irlba_2.3.7            
##  [37] textshaping_0.4.0       crosstalk_1.2.1         ggpubr_0.6.0           
##  [40] labeling_0.4.3          fansi_1.0.6             timechange_0.3.0       
##  [43] abind_1.4-5             compiler_4.3.1          proxy_0.4-27           
##  [46] withr_3.0.0             pander_0.6.6            S7_0.2.2               
##  [49] backports_1.5.0         viridis_0.6.5           carData_3.0-5          
##  [52] DBI_1.2.2               ggstats_0.13.0          dendextend_1.19.1      
##  [55] highr_0.10              ggsignif_0.6.4          pan_2.0                
##  [58] MASS_7.3-60             classInt_0.4-10         scatterplot3d_0.3-45   
##  [61] flashClust_1.1-4        tools_4.3.1             units_0.8-5            
##  [64] nnet_7.3-19             glue_1.8.1              nlme_3.1-162           
##  [67] gridtext_0.1.6          grid_4.3.1              checkmate_2.3.1        
##  [70] reshape2_1.4.4          generics_0.1.3          leaflet.providers_2.0.0
##  [73] gtable_0.3.6            class_7.3-22            car_3.1-2              
##  [76] xml2_1.3.6              utf8_1.2.4              ggrepel_0.9.5          
##  [79] foreach_1.5.2           pillar_1.9.0            stringr_1.5.1          
##  [82] splines_4.3.1           ggtext_0.1.2            lattice_0.21-8         
##  [85] showtextdb_3.0          survival_3.5-5          tidyselect_1.2.1       
##  [88] svglite_2.2.2           xfun_0.42               rapportools_1.2        
##  [91] matrixStats_1.5.0       yaml_2.3.8              evaluate_1.0.5         
##  [94] codetools_0.2-19        tcltk_4.3.1             tibble_3.2.1           
##  [97] multcompView_0.1-12     cli_3.6.2               rpart_4.1.19           
## [100] xtable_1.8-4            systemfonts_1.3.1       jquerylib_0.1.4        
## [103] Rcpp_1.0.12             coda_0.19-4.1           parallel_4.3.1         
## [106] ellipsis_0.3.2          leaps_3.1               lme4_1.1-35.1          
## [109] glmnet_5.0              viridisLite_0.4.2       mvtnorm_1.2-4          
## [112] scales_1.4.0            sysfonts_0.8.9          e1071_1.7-14           
## [115] purrr_1.0.2             rlang_1.1.3             multcomp_1.4-26        
## [118] mnormt_2.1.2            mice_3.19.0