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.
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).
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.
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.
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.
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.
## '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.
| 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 |
| 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.
El primer paso es cuantificar la pérdida de información 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:
| 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.
Un segundo control busca valores que, sin ser faltantes, resultan 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
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.
| 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.
| 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.
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.
| 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.
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:
| 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:
| 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.
Se parte de los estadísticos descriptivos del precio de oferta:
| 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.
Para contrastar el precio entre casas y apartamentos se aplica una prueba de comparación de medias:
| 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.
Entre variables cuantitativas, la asociación se mide con la covarianza y la correlación:
| 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:
| 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.
Tres cosas que el enfoque por pares no puede ver y que sí aparecen después:
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í.
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.
| 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.
| 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 |
| 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 |
| 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.
El gráfico de sedimentación ordena las componentes por varianza explicada:
Los mismos valores en detalle numérico:
| 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.
Las cargas indican cuánto pesa cada variable en cada componente:
| 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.
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().
| 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:
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.
| 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.
Para fijar el sentido de los ejes conviene mirar los inmuebles situados en sus extremos.
| 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.
El biplot superpone individuos y variables en un mismo plano:
Las coordenadas de las categorías suplementarias precisan esa lectura:
| 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:
| Variable | Dimensión | R² | 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 |
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).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.
Se aplican dos criterios habituales para orientar la elección de k:
| 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.
La calidad del agrupamiento se evalúa descomponiendo la variabilidad total:
| 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).
| 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 |
| 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.
| 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 |
| 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 |
| 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.
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.
| 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.
| 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 |
| 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 |
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.
El perfil de cada segmento sintetiza las diferencias en las variables originales:
| 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².
El punto de partida es la tabla de contingencia entre ambas variables:
| 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 |
| 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.
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\).
| 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}\).
| 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 |
| 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}\).
| 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 |
| 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.
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:
| 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.
Con 376 barrios en la base final, el análisis se restringe a los 30 de mayor oferta.
| 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.
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.
| 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:
| η² 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.
La composición por tipo de inmueble varía de forma marcada entre zonas:
| 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.
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.
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.
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.
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.
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.
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.
paqueteMODELOS, conjunto vivienda,
obtenido de OLX mediante web scraping.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),
`R²` = 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² · <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, " · <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()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