Resumen ejecutivo

Se analizaron 8.319 inmuebles en oferta con técnicas multivariantes de interdependencia. Los tres hallazgos con mayor implicación estratégica son:

1. La oferta se describe con dos dimensiones, no con seis. El Análisis de Componentes Principales resume las seis variables métricas en dos componentes que explican el 76,1% de la variabilidad total: una de escala del inmueble (tamaño y precio) y otra que opone espacio habitacional frente a exclusividad de la ubicación. Esa segunda dimensión es la que no se ve mirando el precio.

2. Existen cuatro segmentos operativos, y uno de ellos es una anomalía de precio. La segmentación identifica un grupo de 944 inmuebles (11.3% de la oferta) con 6 habitaciones y 282 m² de mediana, cuyo precio por m² es un 47% inferior a la mediana del mercado. Es el foco natural de adquisición.

3. La geografía no es un dato de contexto: es la variable estructurante. El Análisis de Correspondencia muestra que zona y estrato están asociados con una intensidad muy alta (V de Cramér = 0,392) y que la Zona Oeste concentra el estrato 6 mientras la Zona Oriente concentra el estrato 3, con residuos estandarizados de +35,4 y +40,0 respectivamente. La segmentación de productos debe hacerse por corredor geográfico, no de forma global.


1 Introducción y planteamiento del problema

1.1 Contexto de negocio

Una empresa inmobiliaria requiere comprender la estructura del mercado de vivienda urbana para orientar decisiones de compra, venta y valoración. La información disponible es una base de anuncios obtenida por webscraping del portal OLX, contenida en el paquete paqueteMODELOS.

El problema no es predictivo sino descriptivo y exploratorio: no se pide estimar el precio de un inmueble, sino entender cómo está organizada la oferta. Esa distinción determina toda la elección metodológica que sigue.

1.2 Preguntas de análisis

  1. ¿Cuáles son las dimensiones latentes que explican la variabilidad de la oferta, y cuántas se necesitan realmente?
  2. ¿Existen segmentos de inmuebles con comportamiento homogéneo, y cómo se describen en términos de negocio?
  3. ¿Cómo se relacionan las variables categóricas —tipo de vivienda, zona, barrio y estrato— entre sí?
  4. ¿Qué recomendaciones concretas se derivan para la dirección de la empresa?

2 Marco metodológico

2.1 Justificación de la selección de técnicas

La elección de técnicas no es arbitraria: sigue el árbol de decisión del análisis multivariante. La primera bifurcación pregunta qué tipo de relación se está estudiando.

Árbol de decisión para la selección de técnicas multivariantes. La rama utilizada en este informe es la de INDEPENDENCIA.

Árbol de decisión para la selección de técnicas multivariantes. La rama utilizada en este informe es la de INDEPENDENCIA.

En este problema no hay variable dependiente: ninguna variable se está prediciendo a partir de las otras. Esto sitúa el análisis íntegramente en la rama de independencia, y desde ahí la estructura de la relación define la técnica:

Foco de la relación Técnica Justificación en este caso
Variables Análisis de Componentes Principales Reducir seis variables métricas correlacionadas a un plano interpretable
Casos / individuos Análisis de conglomerados Agrupar los inmuebles en segmentos homogéneos de oferta
Objetos con atributos no métricos Análisis de correspondencia Relacionar tipo, zona, barrio y estrato, que son categóricos

Las tres son no supervisadas: describen estructura, no predicen. Y las tres exigen la misma decisión humana —¿cuántas dimensiones o grupos retengo?— que ningún criterio automático resuelve por completo. Ese punto se documenta explícitamente en cada sección.

2.2 Arquitectura del proyecto

El análisis está modularizado: el informe no contiene lógica de cálculo, solo llama a funciones definidas en R/. Esto garantiza que cada resultado sea reproducible y auditable de forma independiente.

Actividad_1_Modelos_Estadisticos/
├── R/
│   ├── 00_setup.R            entorno reproducible
│   ├── 01_preparacion.R      carga, auditoría y depuración
│   ├── 02_pca.R              componentes principales
│   ├── 03_clustering.R       conglomerados
│   └── 04_correspondencia.R  correspondencia simple y múltiple
├── informe/Informe_Actividad_1.Rmd
├── recursos/                 enunciado y diagrama
└── salidas/                  figuras y tablas exportadas

3 Datos: auditoría, depuración y exploración

3.1 Estructura del conjunto original

dim(viv)
#> [1] 8322   13
str(viv, give.attr = FALSE)
#> spc_tbl_ [8,322 × 13] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
#>  $ id          : num [1:8322] 1147 1169 1350 5992 1212 ...
#>  $ zona        : chr [1:8322] "Zona Oriente" "Zona Oriente" "Zona Oriente" "Zona Sur" ...
#>  $ piso        : chr [1:8322] NA NA NA "02" ...
#>  $ estrato     : num [1:8322] 3 3 3 4 5 5 4 5 5 5 ...
#>  $ preciom     : num [1:8322] 250 320 350 400 260 240 220 310 320 780 ...
#>  $ areaconst   : num [1:8322] 70 120 220 280 90 87 52 137 150 380 ...
#>  $ parqueaderos: num [1:8322] 1 1 2 3 1 1 2 2 2 2 ...
#>  $ banios      : num [1:8322] 3 2 2 5 2 3 2 3 4 3 ...
#>  $ habitaciones: num [1:8322] 6 3 4 3 3 3 3 4 6 3 ...
#>  $ tipo        : chr [1:8322] "Casa" "Casa" "Casa" "Casa" ...
#>  $ barrio      : chr [1:8322] "20 de julio" "20 de julio" "20 de julio" "3 de julio" ...
#>  $ longitud    : num [1:8322] -76.5 -76.5 -76.5 -76.5 -76.5 ...
#>  $ latitud     : num [1:8322] 3.43 3.43 3.44 3.44 3.46 ...

3.2 Auditoría de calidad

Antes de cualquier análisis se cuantifica el estado real de los datos. Este paso es lo que separa un resultado de un artefacto.

tb(auditar_calidad(viv),
   "Auditoría de valores faltantes y cardinalidad")
Auditoría de valores faltantes y cardinalidad
Variable Tipo NA_n NA_pct Distintos
piso character 2638 31.7 12
parqueaderos numeric 1605 19.3 10
id numeric 3 0.0 8319
zona character 3 0.0 5
estrato numeric 3 0.0 4
preciom numeric 2 0.0 539
areaconst numeric 3 0.0 652
banios numeric 3 0.0 11
habitaciones numeric 3 0.0 11
tipo character 3 0.0 2
barrio character 3 0.0 436
longitud numeric 3 0.0 2928
latitud numeric 3 0.0 3679

3.3 Decisiones de depuración

La auditoría revela tres problemas, y cada uno se resuelve con una decisión explícita y justificada:

1. piso — 31,7% de faltantes → se descarta. La variable solo aplica a apartamentos; para una casa el valor no está perdido, no existe. Imputarla generaría estructura inexistente en un análisis cuyo propósito es precisamente descubrir estructura.

2. parqueaderos — 19,3% de faltantes → se imputa con la mediana. En anuncios inmobiliarios, la ausencia de este campo suele significar “el anuncio no lo destaca”, no “el dato se perdió al azar”. Se conserva la bandera imp_parq para poder auditar el efecto de la imputación.

3. Tres registros completamente vacíos → se eliminan.

Adicionalmente, id, longitud y latitud se excluyen del PCA y del clustering: son un identificador y coordenadas, no atributos del inmueble. Las coordenadas se reservan para la visualización geográfica.

cat(sprintf("Original : %s registros\nDepurado : %s registros (%.1f%% retenido)\n",
            m(nrow(viv)), m(nrow(dat)),
            nrow(dat) / nrow(viv) * 100))
#> Original : 8.322 registros
#> Depurado : 8.319 registros (100.0% retenido)

3.4 Análisis exploratorio

tb(tabla_descriptiva(dat),
   "Estadísticos descriptivos de las variables métricas")
Estadísticos descriptivos de las variables métricas
Variable Media Mediana DE CV Min Max Asimetria
preciom 433.90 330 328.67 0.76 58 1999 1.85
areaconst 174.93 123 142.96 0.82 30 1745 2.69
parqueaderos 1.87 2 1.01 0.54 1 10 2.48
banios 3.11 3 1.43 0.46 0 10 0.93
habitaciones 3.61 3 1.46 0.40 0 10 1.63
estrato 4.63 5 1.03 0.22 3 6 -0.18

Dos rasgos condicionan todo lo que sigue:

  • Asimetría positiva marcada en preciom (1,85) y areaconst (2,69): hay una cola de inmuebles de lujo que estira la distribución.
  • Escalas radicalmente distintas: preciom se mide en cientos de millones y estrato en unidades de 3 a 6. Esto hace que estandarizar sea obligatorio, no opcional, tanto en PCA como en clustering.
dat |>
  select(preciom, areaconst) |>
  pivot_longer(everything(), names_to = "var", values_to = "valor") |>
  mutate(var = recode(var,
                      preciom = "Precio (millones COP)",
                      areaconst = "Área construida (m²)")) |>
  ggplot(aes(valor)) +
  geom_histogram(bins = 50, fill = COL$azul, alpha = .85) +
  facet_wrap(~var, scales = "free") +
  labs(title = "Distribuciones con cola derecha pronunciada",
       subtitle = "La asimetría justifica el uso de la mediana en los perfiles",
       x = NULL, y = "Frecuencia") +
  theme_minimal(base_size = 11)
Distribución de las variables métricas principales.

Distribución de las variables métricas principales.

dat |>
  count(zona, tipo) |>
  ggplot(aes(reorder(zona, n), n, fill = tipo)) +
  geom_col() +
  coord_flip() +
  scale_fill_manual(values = c(COL$azul, COL$naranja)) +
  labs(title = "La Zona Sur concentra más de la mitad de la oferta",
       x = NULL, y = "Número de inmuebles", fill = "Tipo") +
  theme_minimal(base_size = 11)
Composición de la oferta por zona y tipo de vivienda.

Composición de la oferta por zona y tipo de vivienda.

M <- cor(dat[, VARS_NUM])
as.data.frame(as.table(M)) |>
  ggplot(aes(Var1, Var2, fill = Freq)) +
  geom_tile(colour = "white") +
  geom_text(aes(label = sprintf("%.2f", Freq)), size = 3.2,
            colour = ifelse(abs(as.vector(M)) > .6, "white", COL$gris)) +
  scale_fill_gradient2(low = COL$celeste, mid = "white", high = COL$azul,
                       midpoint = 0, limits = c(-1, 1)) +
  labs(title = "Correlaciones altas justifican la reducción de dimensionalidad",
       subtitle = "Si las variables fueran independientes, el PCA no aportaría nada",
       x = NULL, y = NULL, fill = "r") +
  theme_minimal(base_size = 11) +
  theme(axis.text.x = element_text(angle = 35, hjust = 1))
Matriz de correlaciones entre las variables métricas.

Matriz de correlaciones entre las variables métricas.

Las correlaciones entre preciom, areaconst y banios superan 0,6. Ese solapamiento es exactamente lo que el PCA convierte en una única dimensión.


4 Análisis de Componentes Principales

4.1 Especificación del modelo

pca <- ajustar_pca(dat)

Se aplica sobre la matriz de correlación (scale.unit = TRUE). Sin estandarizar, preciom —con una varianza cinco órdenes de magnitud mayor que estrato— capturaría la primera componente por su unidad de medida, no por su importancia.

4.2 Varianza explicada y número de componentes

tb(tabla_eigen(pca), "Eigenvalores y varianza explicada por componente")
Eigenvalores y varianza explicada por componente
Componente Eigenvalor Var_expl Var_acum Kaiser
CP1 3.366 56.1 56.1 Retener
CP2 1.201 20.0 76.1 Retener
CP3 0.623 10.4 86.5 Descartar
CP4 0.388 6.5 93.0 Descartar
CP5 0.237 4.0 96.9 Descartar
CP6 0.186 3.1 100.0 Descartar
grafico_scree(pca)
Gráfico de sedimentación. La línea discontinua marca el umbral del 80%.

Gráfico de sedimentación. La línea discontinua marca el umbral del 80%.

4.2.1 Conciliación de criterios

Los tres criterios de retención no coinciden
Criterio Componentes Implicación
Kaiser (eigenvalor > 1) 2 Retiene 76.1% de la varianza
Codo del scree plot 2 Coincide con Kaiser
Varianza acumulada ≥ 80% 3 Retiene 86.5% de la varianza

Kaiser y el codo indican 2 componentes; el umbral del 80% pide 3. Esta discrepancia no es un fallo del análisis: es el punto donde el criterio técnico se agota y debe intervenir el juicio del analista.

Decisión: se retienen 2 componentes. Tres razones:

  1. La tercera componente aporta solo 10,4% adicional, con eigenvalor 0,623 — por debajo de la varianza de una variable original estandarizada.
  2. Está dominada por parqueaderos (60,7% de contribución), es decir, es prácticamente una variable disfrazada de componente, no una dimensión latente.
  3. El objetivo declarado es la interpretación y visualización: dos componentes producen un plano legible por la dirección; tres, no.

4.3 Interpretación de las componentes

tb(tabla_contribuciones(pca, 3),
   "Correlaciones y contribuciones (%) de cada variable")
Correlaciones y contribuciones (%) de cada variable
Variable Correl_CP1 Correl_CP2 Correl_CP3 Contrib_CP1 Contrib_CP2 Contrib_CP3
preciom 0.882 -0.276 -0.014 23.1 6.3 0.0
areaconst 0.838 0.209 -0.090 20.9 3.6 1.3
parqueaderos 0.723 -0.138 -0.615 15.5 1.6 60.7
banios 0.863 0.166 0.255 22.1 2.3 10.5
habitaciones 0.553 0.746 0.190 9.1 46.3 5.8
estrato 0.560 -0.692 0.367 9.3 39.9 21.7
grafico_circulo(pca)
Círculo de correlaciones. El color indica la contribución de cada variable al plano.

Círculo de correlaciones. El color indica la contribución de cada variable al plano.

Con el umbral de referencia de 16,7% (= 100 / número de variables), la lectura es inequívoca:

CP1 — “Escala del inmueble” (56,1%). Todas las correlaciones son positivas y las cuatro dominantes son preciom (0,88), banios (0,86), areaconst (0,84) y parqueaderos (0,72). Es un eje de tamaño y valor global: a la derecha, inmuebles grandes y caros; a la izquierda, compactos y económicos.

CP2 — “Espacio habitacional vs. exclusividad” (20,0%). Aquí está el hallazgo no evidente. habitaciones (+0,75) y estrato (−0,69) se oponen con contribuciones de 46,3% y 39,9%. La componente contrapone la casa amplia de estrato medio, con muchas habitaciones, frente al apartamento de estrato alto, con menos habitaciones pero ubicación exclusiva. Dos productos con precio comparable y lógica de mercado opuesta: esa distinción no aparece en ninguna variable por separado.

4.4 Proyección de los inmuebles

grafico_individuos(pca, dat)
Proyección de los inmuebles sobre el plano principal, diferenciados por estrato.

Proyección de los inmuebles sobre el plano principal, diferenciados por estrato.

Las elipses se desplazan de forma ordenada a lo largo de CP2, confirmando que esa componente captura efectivamente el gradiente socioeconómico.

4.5 Diagnóstico de estabilidad

El PCA maximiza varianza, por lo que es teóricamente sensible a valores extremos. Dado que las distribuciones mostraron colas largas, esto no puede asumirse: hay que medirlo.

tb(diagnostico_outliers(dat),
   "Sensibilidad de la solución al recorte del percentil 99")
Sensibilidad de la solución al recorte del percentil 99
Escenario n Var_CP1 Var_CP1_CP2
Completo 8319 56.1 76.1
Sin percentil >99% 8163 55.9 76.4

Resultado: la solución es estable. Al eliminar el 1% superior de precio y área, la CP1 varía de 56,1% a 55,9% — una diferencia de apenas 0,2 puntos. Los valores extremos aquí no son errores de medición sino inmuebles de lujo reales, coherentes con la estructura general. Se conservan en el análisis, y esta comprobación es la que permite afirmarlo con respaldo en lugar de asumirlo.


5 Análisis de Conglomerados

5.1 Preparación y selección del número de segmentos

X    <- preparar_matriz(dat)
codo <- curva_codo(X)
sil  <- curva_silueta(X)

El clustering se realiza sobre las variables métricas estandarizadas y no sobre los scores del PCA, para que el perfil de cada segmento pueda leerse directamente en unidades de negocio (millones de pesos, m², habitaciones).

gridExtra::grid.arrange(grafico_codo(codo), grafico_silueta(sil), ncol = 2)
Criterios para la elección de k: codo (izquierda) y silueta (derecha).

Criterios para la elección de k: codo (izquierda) y silueta (derecha).

Silueta promedio por número de segmentos
k Silueta Lectura
2 0.419 Máximo global
3 0.317
4 0.331 Máximo local
5 0.268
6 0.261

5.1.1 La decisión que el criterio automático no puede tomar

La silueta alcanza su máximo en k = 2 (0,419), pero esa partición separa únicamente “caro y grande” de “barato y pequeño”: es estadísticamente óptima y estratégicamente inútil, porque no dice nada que la dirección no sepa ya.

Se adopta k = 4, por tres razones:

  1. Presenta un máximo local de silueta (0,331), superior al de k = 3 — la calidad de separación no se degrada monótonamente, sino que repunta.
  2. El método del codo muestra que la mejora sustancial se agota alrededor de k = 4.
  3. Criterio decisivo: produce segmentos con perfiles accionables y diferenciados, incluyendo uno que k = 2 oculta por completo (§5.3).

Este es el principio operativo del módulo: cuando los criterios estadísticos discrepan, la decisión la fija el uso que tendrá el resultado, y debe documentarse — no promediarse.

5.2 Solución final y perfilado

K   <- 4
km  <- ajustar_kmeans(X, K)
dat$segmento <- factor(km$cluster, labels = paste0("S", 1:K))

Se usa nstart = 50. No es un detalle cosmético: K-Means es sensible a la inicialización, y con nstart = 1 la solución cambia entre corridas.

tb(perfilar(dat, km$cluster),
   "Perfil de los cuatro segmentos (medianas). precio_m2 en COP por m²")
Perfil de los cuatro segmentos (medianas). precio_m2 en COP por m²
cluster n pct precio_med area_med precio_m2 banios_med habit_med parq_med estrato_med pct_casa
1 898 10.8 1150 367 2940351 5 4 4 6 71.5
2 2512 30.2 470 153 3261791 3 3 2 6 33.1
3 944 11.3 412 282 1464305 4 6 2 4 97.2
4 3965 47.7 216 78 2578125 2 3 1 4 20.9

5.3 Caracterización estratégica de los segmentos

Traducción de los segmentos al lenguaje del negocio
cluster Segmento n pct precio_med area_med precio_m2 habit_med estrato_med pct_casa
1 Vivienda de lujo 898 10.8 1150 367 2940351 4 6 71.5
2 Apartamento premium compacto 2512 30.2 470 153 3261791 3 6 33.1
3 Casa familiar amplia 944 11.3 412 282 1464305 6 4 97.2
4 Vivienda de entrada 3965 47.7 216 78 2578125 3 4 20.9

S — Vivienda de lujo (10.8%). Mediana de 1150 millones y 367 m², estrato 6, 71.5% casas. Segmento de baja rotación y alto margen unitario.

S — Apartamento premium compacto (30.2%). Área moderada (153 m²) pero el precio por m² más alto del mercado (3.261.791 COP). Aquí se paga la ubicación, no el espacio.

S — Casa familiar amplia (11.3%) — el hallazgo del análisis. 6 habitaciones, 282 m², 97.2% casas, estrato 4. Su precio por m² es de 1.464.305 COP: el más bajo del mercado con diferencia, pese a tener un área muy superior a la mediana. Es exactamente el polo negativo de CP2 identificado en el PCA — mucho espacio, estrato medio — y la partición en k = 2 lo disolvía por completo.

S — Vivienda de entrada (47.7%). El volumen del mercado: 78 m², 216 millones. Alta rotación, margen unitario bajo.

grafico_clusters_pca(pca, km$cluster)
Segmentos proyectados sobre el plano principal del PCA.

Segmentos proyectados sobre el plano principal del PCA.

5.4 Validación por triangulación

Un resultado que depende del algoritmo elegido es un artefacto. Se contrasta K-Means contra un método de lógica distinta —jerárquico aglomerativo de Ward— sobre la misma submuestra.

val2 <- validar_estabilidad(X, 2)
val4 <- validar_estabilidad(X, K)

tb(data.frame(
  Solución = c("k = 2", "k = 4"),
  Concordancia_pct = c(val2$concordancia, val4$concordancia),
  n_submuestra = c(val2$n, val4$n)
), "Concordancia entre K-Means y jerárquico Ward")
Concordancia entre K-Means y jerárquico Ward
Solución Concordancia_pct n_submuestra
k = 2 88.3 1200
k = 4 72.2 1200
val4$tabla
#>       Ward
#> KMeans   1   2   3   4
#>      1 277  44   6   1
#>      2  15   0 121  19
#>      3 192 404   0   0
#>      4  11  46   0  64

La concordancia de 72.2% para k = 4 es moderada, frente al 88.3% de k = 2. Se reporta de forma explícita porque es una limitación real: la estructura gruesa del mercado es muy robusta, mientras que la subdivisión fina depende en parte del método. Los cuatro segmentos son válidos como herramienta de gestión comercial, pero no deben tratarse como categorías naturales inmutables.

5.5 Distribución geográfica

mapa_clusters(dat, dat$segmento)
Localización de los inmuebles según segmento. La silueta reproduce la trama urbana real de la ciudad.

Localización de los inmuebles según segmento. La silueta reproduce la trama urbana real de la ciudad.

tb(as.data.frame.matrix(composicion_cluster(dat, dat$segmento, "zona")),
   "Composición zonal de cada segmento (% por fila)")
Composición zonal de cada segmento (% por fila)
Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
S1 0.2 10.8 28.2 0.2 60.6
S2 0.1 19.9 27.5 0.0 52.4
S3 5.2 22.2 5.6 16.8 50.1
S4 1.8 28.1 5.0 4.8 60.3

Los segmentos no se distribuyen de forma homogénea en el territorio: se concentran en corredores diferenciados. Esto conecta directamente con el análisis siguiente.


6 Análisis de Correspondencia

6.1 Protocolo de validación previa

Regla metodológica innegociable: antes de dibujar un mapa de correspondencia hay que probar que exista asociación. Cualquier algoritmo devuelve un gráfico, incluso cuando las variables son independientes; en ese caso el mapa dibuja ruido muestral que después se interpreta como si fuera señal. Toda tabla de este informe se somete primero a la prueba χ².

Se reportan conjuntamente el p-valor (¿existe asociación?) y el V de Cramér (¿de qué magnitud?). Con n > 8.000, casi cualquier asociación resulta significativa; solo el tamaño del efecto indica si es relevante.

6.2 Tipo de vivienda × Zona

t1 <- table(Tipo = dat$tipo, Zona = dat$zona)
tb(as.data.frame.matrix(t1), "Tabla de contingencia: tipo × zona")
Tabla de contingencia: tipo × zona
Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
Apartamento 24 1198 1029 62 2787
Casa 100 722 169 289 1939
tb(prueba_asociacion(t1), "Prueba de asociación")
Prueba de asociación
Chi2 gl p_valor Inercia Coef_C V_Cramer Decision
690.93 4 <1e-16 0.0831 0.277 0.288 Asociacion significativa: procede el AC
tb(as.data.frame.matrix(residuos_estandarizados(t1)),
   "Residuos estandarizados (|valor| > 2 indica celda relevante)")
Residuos estandarizados (|valor| > 2 indica celda relevante)
Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
Apartamento -9.66 1.12 18.89 -17.15 -5.01
Casa 9.66 -1.12 -18.89 17.15 5.01

Asociación significativa y de magnitud moderada (V = 0,288). La tabla 2×5 genera una sola dimensión, por lo que el mapa aporta poco frente a los residuos, que son concluyentes: la Zona Oeste está fuertemente especializada en apartamentos (residuo +18,9) y la Zona Oriente en casas (+17,2).

6.3 Zona × Estrato

t2 <- table(Zona = dat$zona, Estrato = dat$estratof)
tb(as.data.frame.matrix(t2), "Tabla de contingencia: zona × estrato")
Tabla de contingencia: zona × estrato
Estrato 3 Estrato 4 Estrato 5 Estrato 6
Zona Centro 105 14 4 1
Zona Norte 572 407 769 172
Zona Oeste 54 84 290 770
Zona Oriente 340 8 2 1
Zona Sur 382 1616 1685 1043
tb(prueba_asociacion(t2), "Prueba de asociación")
Prueba de asociación
Chi2 gl p_valor Inercia Coef_C V_Cramer Decision
3830.44 12 <1e-16 0.4604 0.561 0.392 Asociacion significativa: procede el AC
tb(as.data.frame.matrix(residuos_estandarizados(t2)),
   "Residuos estandarizados")
Residuos estandarizados
Estrato 3 Estrato 4 Estrato 5 Estrato 6
Zona Centro 19.86 -3.68 -7.11 -6.07
Zona Norte 16.22 -5.03 7.43 -17.49
Zona Oeste -12.77 -15.93 -7.04 35.44
Zona Oriente 40.03 -10.23 -13.22 -10.60
Zona Sur -25.85 20.62 5.77 -4.45
ac2 <- ajustar_ac(t2)
tb(tabla_inercia(ac2), "Descomposición de la inercia")
Descomposición de la inercia
Dimension Inercia Pct Acumulado
Dim1 0.32215 70.0 70.0
Dim2 0.12745 27.7 97.6
Dim3 0.01084 2.4 100.0
mapa_ac(ac2, "Zona × Estrato: estructura socioeconómica del territorio")
Mapa de correspondencia entre zona y estrato socioeconómico.

Mapa de correspondencia entre zona y estrato socioeconómico.

Dos dimensiones capturan el 97,6% de la inercia, de modo que el mapa es una representación casi completa de la tabla. La estructura es nítida y los residuos la confirman celda por celda:

  • Zona Oeste ↔︎ Estrato 6 (residuo +35,4): el corredor de mayor poder adquisitivo.
  • Zona Oriente ↔︎ Estrato 3 (+40,0): el extremo opuesto, con especialización aún más marcada.
  • Zona Sur ↔︎ Estrato 4 (+20,6): el mercado medio de mayor volumen.
  • Zona Norte ↔︎ Estrato 3 y 5 (+16,2 y +7,4): la zona menos especializada, con oferta repartida — y por tanto la de perfil comercial más ambiguo.

6.4 Barrio × Estrato

barrio tiene 436 niveles; un mapa con esa cantidad de etiquetas sería ilegible. Se retienen los 15 barrios de mayor volumen de oferta, que es donde la empresa efectivamente decide.

tb15 <- top_barrios(dat, 15)
sub  <- dat[dat$barrio %in% tb15, ]
t3   <- table(Barrio = sub$barrio, Estrato = sub$estratof)
tb(prueba_asociacion(t3), "Prueba de asociación: barrio × estrato")
Prueba de asociación: barrio × estrato
Chi2 gl p_valor Inercia Coef_C V_Cramer Decision
4259.98 42 <1e-16 1.0413 0.714 0.589 Asociacion significativa: procede el AC
ac3 <- ajustar_ac(t3)
tb(tabla_inercia(ac3), "Descomposición de la inercia")
Descomposición de la inercia
Dimension Inercia Pct Acumulado
Dim1 0.74092 71.2 71.2
Dim2 0.22367 21.5 92.6
Dim3 0.07672 7.4 100.0
mapa_ac(ac3, "Barrio × Estrato: posicionamiento de los barrios de mayor oferta")
Mapa de correspondencia entre los 15 barrios de mayor oferta y el estrato.

Mapa de correspondencia entre los 15 barrios de mayor oferta y el estrato.

Es la asociación más fuerte de todo el informe (V = 0,589, 92,6% de inercia en dos dimensiones). El mapa ordena los barrios sobre un gradiente socioeconómico continuo, con Pance, Ciudad Jardín y Santa Teresita en el extremo del estrato 6, y Valle del Lili y El Caney posicionados en el estrato 4.

Advertencia de lectura. La proximidad entre categorías de variables distintas en un mapa simétrico indica asociación sugerida, no probada; y un punto cercano al origen representa un perfil promedio, no un barrio “intermedio interesante”. Por eso todas las afirmaciones anteriores se apoyan en los residuos estandarizados, no en la lectura visual del mapa.

6.5 Análisis de Correspondencia Múltiple

Las tres tablas anteriores son bivariadas. El ACM permite examinar las cuatro variables categóricas simultáneamente —incluyendo el segmento derivado del clustering— e integrar así los tres análisis en una sola representación.

acm <- ajustar_acm(dat, c("tipo", "zona", "estratof", "segmento"))
tb(round(head(as.data.frame(acm$eig), 5), 3),
   "Eigenvalores del ACM")
Eigenvalores del ACM
eigenvalue percentage of variance cumulative percentage of variance
dim 1 0.532 19.361 19.361
dim 2 0.437 15.904 35.265
dim 3 0.325 11.808 47.074
dim 4 0.294 10.698 57.772
dim 5 0.250 9.092 66.863
mapa_acm(acm, "ACM: integración de tipo, zona, estrato y segmento")
Mapa de correspondencia múltiple: tipo, zona, estrato y segmento.

Mapa de correspondencia múltiple: tipo, zona, estrato y segmento.

En el ACM la inercia se reparte entre muchas más dimensiones —consecuencia conocida de la codificación disyuntiva—, por lo que los porcentajes son bajos por construcción y no deben compararse con los del AC simple. Lo relevante es la configuración relativa: los segmentos de clustering se posicionan de forma coherente junto a las zonas y estratos con los que efectivamente coocurren, lo que constituye una validación cruzada independiente de la segmentación obtenida en la sección anterior.


7 Síntesis integrada

Las tres técnicas responden a preguntas distintas sobre la misma realidad
Técnica Insumo Métrica Resultado
Componentes principales 6 variables métricas Varianza 2 CP = 76.1% de la varianza
Conglomerados 6 variables estandarizadas Distancia euclidiana 4 segmentos operativos
Correspondencia simple Tablas de contingencia Inercia (χ²/n) Zona×Estrato: 97.6% en 2 dims
Correspondencia múltiple 4 variables categóricas Inercia (Burt) Coherencia entre segmentos y territorio

Los tres análisis convergen en un mismo relato, y esa convergencia es la evidencia más sólida del informe:

  1. El PCA identifica en CP2 una tensión entre espacio habitacional y exclusividad de ubicación.
  2. El clustering materializa esa tensión en dos segmentos concretos y cuantificados: Casa familiar amplia (mucho espacio, precio/m² mínimo) y Apartamento premium compacto (poco espacio, precio/m² máximo).
  3. El AC explica dónde ocurre: zonas Oriente y Norte frente a la Zona Oeste.

Ninguna técnica por separado revela esto. La misma estructura aparece en tres métodos con supuestos distintos, y ese es el criterio que permite tratarla como real y no como artefacto.


8 Conclusiones y recomendaciones estratégicas

8.1 Conclusiones analíticas

C1. La oferta inmobiliaria se describe adecuadamente con dos dimensiones latentes que retienen el 76,1% de la variabilidad: escala del inmueble y espacio habitacional frente a exclusividad. La segunda no es observable en ninguna variable individual.

C2. Existen cuatro segmentos operativos con perfiles diferenciados. La estructura gruesa (k = 2) es muy robusta; la subdivisión fina tiene concordancia moderada entre métodos (72.2%) y debe usarse como herramienta de gestión, no como taxonomía definitiva.

C3. El segmento Casa familiar amplia presenta una anomalía de precio consistente: el precio por m² más bajo del mercado con el área más alta después del lujo. Es un desajuste estructural, no un efecto de muestreo.

C4. Zona y estrato están fuertemente asociados (V = 0,392), y a nivel de barrio la asociación es aún más intensa (V = 0,589). El territorio es la variable organizadora del mercado.

C5. La Zona Norte es la menos especializada: reparte su oferta entre estratos 3 y 5 sin un perfil dominante.

8.2 Recomendaciones para la dirección

R1 — Priorizar la adquisición en el segmento Casa familiar amplia. Es donde el precio por m² se aparta más a la baja de la mediana del mercado teniendo un área muy superior. Recomendación operativa: enfocar la búsqueda en casas de estrato 4 con 5–6 habitaciones en las zonas Oriente y Norte, y verificar caso a caso si el descuento responde a estado del inmueble o a ineficiencia de mercado. Es la recomendación con mayor retorno potencial y la que requiere validación adicional más urgente.

R2 — Diferenciar el argumento comercial por segmento. En Apartamento premium compacto el valor se comunica por ubicación y exclusividad; en Casa familiar amplia, por metros cuadrados y número de habitaciones. Aplicar el mismo discurso a ambos destruye margen en el primero y rotación en el segundo.

R3 — Organizar la estructura comercial por corredor geográfico. La evidencia del AC no admite una estrategia global: Oeste (estrato 6, apartamentos), Oriente (estrato 3, casas) y Sur (estrato 4–5, volumen) exigen equipos, inventario y precios distintos.

R4 — Tratar la Zona Norte como mercado de prueba. Su falta de especialización, que dificulta el posicionamiento, es también la condición ideal para pilotear productos y precios antes de escalarlos a zonas consolidadas.

R5 — Instrumentar la captura de datos. piso se perdió por un 31,7% de faltantes y parqueaderos requirió imputación. Ambas son variables de valoración relevantes. Estandarizar su registro en el origen aumentaría la capacidad analítica sin coste de modelado.

8.3 Limitaciones del estudio

Se declaran de forma explícita, porque condicionan el uso de los resultados:

  1. Los datos son de oferta, no de transacción. Los precios son los pedidos en el anuncio, no los pagados. Toda conclusión sobre valoración está sujeta a esta diferencia.
  2. La imputación de parqueaderos (19,3%) reduce artificialmente la varianza de esa variable y por tanto su peso en el PCA.
  3. La concordancia moderada en k = 4 implica que la frontera entre segmentos adyacentes no es nítida.
  4. El AC y el PCA capturan solo relaciones lineales / de asociación; no detectan interacciones complejas ni relaciones no monótonas.
  5. Corte transversal único: no permite inferir tendencias temporales del mercado.

9 Anexos

9.1 Anexo A — Reproducibilidad

El informe es autoinstalable: al knitearlo verifica sus dependencias y descarga de CRAN (y de GitHub, en el caso de paqueteMODELOS) únicamente las que falten, en la biblioteca del usuario y sin permisos de administrador. En un equipo nuevo basta con:

rmarkdown::render("informe/Informe_Actividad_1.Rmd")   # genera el informe

o pulsar Knit en RStudio. El script R/00_setup.R sigue disponible para preparar el entorno por separado, pero ya no es un paso obligatorio.

La primera compilación guarda una copia del dataset en datos/vivienda.rds, de modo que las siguientes funcionan también sin conexión a internet.

La semilla está fijada en set.seed(2026); K-Means usa nstart = 50 para neutralizar la sensibilidad a la inicialización. Los resultados numéricos son idénticos entre corridas.

9.2 Anexo B — Código fuente de los módulos

9.2.1 R/01_preparacion.R

# =============================================================
# Actividad 1 | 01_preparacion.R
# Carga, auditoria de calidad y depuracion del dataset 'vivienda'
# =============================================================
# Todas las funciones son puras: reciben datos y devuelven datos.
# El informe .Rmd las llama; asi el analisis no se duplica.
# -------------------------------------------------------------

suppressPackageStartupMessages({
  library(dplyr)
})

# Paleta institucional del modulo (tomada de los tutoriales learnr)
COL <- list(
  naranja = "#FF7F00",
  azul    = "#034a94",
  celeste = "#0eb0c6",
  gris    = "#686868"
)

# Variables metricas usadas en PCA y clustering
VARS_NUM <- c("preciom", "areaconst", "parqueaderos",
              "banios", "habitaciones", "estrato")

# -------------------------------------------------------------
# cargar_vivienda(): trae el dataset crudo del paquete del curso
# -------------------------------------------------------------
cargar_vivienda <- function() {
  data("vivienda", package = "paqueteMODELOS", envir = environment())
  get("vivienda", envir = environment())
}

# -------------------------------------------------------------
# auditar_calidad(): tabla de valores faltantes por variable
# -------------------------------------------------------------
auditar_calidad <- function(df) {
  data.frame(
    Variable   = names(df),
    Tipo       = vapply(df, function(x) class(x)[1], character(1)),
    NA_n       = colSums(is.na(df)),
    NA_pct     = round(colSums(is.na(df)) / nrow(df) * 100, 1),
    Distintos  = vapply(df, function(x) length(unique(x[!is.na(x)])), integer(1)),
    row.names  = NULL
  ) |> dplyr::arrange(dplyr::desc(NA_pct))
}

# -------------------------------------------------------------
# depurar_vivienda(): aplica las decisiones de limpieza
#
# Decisiones tomadas y su justificacion:
#   1. 'piso' se DESCARTA: 31,7% de faltantes y solo aplica a
#      apartamentos. Imputarla inventaria estructura inexistente.
#   2. 'parqueaderos' (19,3% NA) se IMPUTA con la mediana. En este
#      mercado el NA del anuncio significa casi siempre "no destaca
#      el parqueadero", no "dato perdido al azar". Se conserva la
#      bandera imp_parq para poder auditar el efecto.
#   3. Las 3 filas con id = NA son registros vacios -> se eliminan.
#   4. 'id', 'longitud' y 'latitud' NO entran al PCA: son
#      identificadores y coordenadas, no atributos del inmueble.
#      Las coordenadas se reservan para el mapa.
# -------------------------------------------------------------
depurar_vivienda <- function(df) {
  df |>
    dplyr::filter(!is.na(id)) |>
    dplyr::mutate(
      imp_parq     = is.na(parqueaderos),
      parqueaderos = ifelse(imp_parq,
                            stats::median(parqueaderos, na.rm = TRUE),
                            parqueaderos),
      zona    = factor(zona),
      tipo    = factor(tipo),
      estratof = factor(estrato, levels = sort(unique(estrato)),
                        labels = paste("Estrato", sort(unique(estrato))))
    ) |>
    dplyr::select(-piso) |>
    tidyr::drop_na(dplyr::all_of(VARS_NUM), zona, tipo)
}

# -------------------------------------------------------------
# tabla_descriptiva(): resumen de las variables metricas
# -------------------------------------------------------------
tabla_descriptiva <- function(df, vars = VARS_NUM) {
  data.frame(
    Variable = vars,
    Media    = round(sapply(df[vars], mean), 2),
    Mediana  = round(sapply(df[vars], stats::median), 2),
    DE       = round(sapply(df[vars], stats::sd), 2),
    CV       = round(sapply(df[vars], stats::sd) /
                     sapply(df[vars], mean), 2),
    Min      = round(sapply(df[vars], min), 2),
    Max      = round(sapply(df[vars], max), 2),
    Asimetria = round(sapply(df[vars], function(x) {
      n <- length(x); m <- mean(x); s <- stats::sd(x)
      sum((x - m)^3) / (n * s^3)
    }), 2),
    row.names = NULL
  )
}

9.2.2 R/02_pca.R

# =============================================================
# Actividad 1 | 02_pca.R
# Analisis de Componentes Principales
# =============================================================

suppressPackageStartupMessages({
  library(ggplot2); library(FactoMineR); library(factoextra)
})

# -------------------------------------------------------------
# ajustar_pca(): PCA sobre la matriz de correlacion
# scale.unit = TRUE es obligatorio aqui: preciom esta en millones
# y estrato en unidades; sin escalar, preciom secuestraria la CP1
# solo por su unidad de medida.
# -------------------------------------------------------------
ajustar_pca <- function(df, vars = VARS_NUM) {
  FactoMineR::PCA(df[, vars], scale.unit = TRUE, graph = FALSE,
                  ncp = length(vars))
}

# -------------------------------------------------------------
# tabla_eigen(): varianza explicada + criterio de Kaiser
# -------------------------------------------------------------
tabla_eigen <- function(pca) {
  e <- as.data.frame(pca$eig)
  data.frame(
    Componente  = paste0("CP", seq_len(nrow(e))),
    Eigenvalor  = round(e[[1]], 3),
    Var_expl    = round(e[[2]], 1),
    Var_acum    = round(e[[3]], 1),
    Kaiser      = ifelse(e[[1]] > 1, "Retener", "Descartar"),
    row.names   = NULL
  )
}

# -------------------------------------------------------------
# decidir_ncp(): concilia los tres criterios de retencion
# Devuelve los tres valores y la decision razonada.
# -------------------------------------------------------------
decidir_ncp <- function(pca, umbral = 80) {
  e       <- pca$eig
  kaiser  <- sum(e[, 1] > 1)
  umbralk <- which(e[, 3] >= umbral)[1]
  # Codo: mayor caida de segunda diferencia en el scree
  d2      <- diff(diff(e[, 1]))
  codo    <- if (length(d2)) which.max(abs(d2)) + 1 else kaiser
  list(kaiser  = as.integer(kaiser),
       umbral  = as.integer(unname(umbralk)),
       codo    = as.integer(unname(codo)),
       elegido = as.integer(max(kaiser, 2)))  # nunca menos de 2: hace falta un plano
}

# -------------------------------------------------------------
# tabla_contribuciones(): cuanto aporta cada variable a cada CP
# Regla de lectura: contribucion > 100/p (aqui 16,7%) = variable
# relevante para esa componente.
# -------------------------------------------------------------
tabla_contribuciones <- function(pca, ncp = 2) {
  ctr <- as.data.frame(round(pca$var$contrib[, seq_len(ncp), drop = FALSE], 1))
  cor <- as.data.frame(round(pca$var$cor[, seq_len(ncp), drop = FALSE], 3))
  names(ctr) <- paste0("Contrib_CP", seq_len(ncp))
  names(cor) <- paste0("Correl_CP",  seq_len(ncp))
  cbind(Variable = rownames(ctr), cor, ctr, row.names = NULL)
}

# -------------------------------------------------------------
# Graficos
# -------------------------------------------------------------
grafico_scree <- function(pca, umbral = 80) {
  e <- as.data.frame(pca$eig)
  d <- data.frame(cp = seq_len(nrow(e)), var = e[[2]], acum = e[[3]])
  ggplot(d, aes(cp)) +
    geom_col(aes(y = var), fill = COL$azul, width = .65) +
    geom_line(aes(y = acum), colour = COL$naranja, linewidth = 1) +
    geom_point(aes(y = acum), colour = COL$naranja, size = 2.4) +
    geom_hline(yintercept = umbral, linetype = "dashed", colour = COL$gris) +
    geom_text(aes(y = var, label = paste0(round(var, 1), "%")),
              vjust = -0.6, size = 3.1, colour = COL$azul) +
    annotate("text", x = nrow(e) - .5, y = umbral + 4,
             label = paste0(umbral, "%"), colour = COL$gris, size = 3) +
    scale_x_continuous(breaks = d$cp, labels = paste0("CP", d$cp)) +
    scale_y_continuous(limits = c(0, 105)) +
    labs(title = "Grafico de sedimentacion",
         subtitle = "Barras: varianza de cada componente | Linea: acumulada",
         x = NULL, y = "% de varianza explicada") +
    theme_minimal(base_size = 11)
}

grafico_circulo <- function(pca) {
  factoextra::fviz_pca_var(
    pca, col.var = "contrib", repel = TRUE,
    gradient.cols = c(COL$celeste, COL$azul, COL$naranja)
  ) +
    labs(title = "Circulo de correlaciones (CP1-CP2)",
         subtitle = "Flechas largas y proximas al borde = variable bien representada") +
    theme_minimal(base_size = 11)
}

grafico_individuos <- function(pca, df, color_por = "estratof") {
  factoextra::fviz_pca_ind(
    pca, geom = "point", pointsize = .8, alpha.ind = .35,
    habillage = df[[color_por]], addEllipses = TRUE, ellipse.level = .9
  ) +
    labs(title = "Proyeccion de las viviendas en el plano CP1-CP2",
         subtitle = "Elipses de concentracion al 90% por estrato") +
    theme_minimal(base_size = 11)
}

# -------------------------------------------------------------
# diagnostico_outliers(): cuantifica la fragilidad del PCA
# Compara la solucion completa contra una recortada al 1% superior
# de precio y area. Si la estructura cambia mucho, el PCA no es
# estable y hay que reportarlo.
# -------------------------------------------------------------
diagnostico_outliers <- function(df, vars = VARS_NUM, p = .99) {
  lim  <- sapply(df[, c("preciom", "areaconst")], stats::quantile, p)
  sub  <- df[df$preciom <= lim[1] & df$areaconst <= lim[2], ]
  full <- ajustar_pca(df,  vars)
  trim <- ajustar_pca(sub, vars)
  data.frame(
    Escenario   = c("Completo", paste0("Sin percentil >", p * 100, "%")),
    n           = c(nrow(df), nrow(sub)),
    Var_CP1     = round(c(full$eig[1, 2], trim$eig[1, 2]), 1),
    Var_CP1_CP2 = round(c(full$eig[2, 3], trim$eig[2, 3]), 1)
  )
}

9.2.3 R/03_clustering.R

# =============================================================
# Actividad 1 | 03_clustering.R
# Analisis de conglomerados: segmentacion de la oferta
# =============================================================

suppressPackageStartupMessages({
  library(ggplot2); library(cluster); library(dplyr)
})

# -------------------------------------------------------------
# preparar_matriz(): estandariza las variables metricas.
# El clustering se hace sobre las variables escaladas, no sobre
# los scores del PCA, para que el perfil de cada segmento se
# pueda leer directamente en unidades de negocio.
# -------------------------------------------------------------
preparar_matriz <- function(df, vars = VARS_NUM) scale(df[, vars])

# -------------------------------------------------------------
# curva_codo(): SC intra-cluster para k = 1..kmax
# -------------------------------------------------------------
curva_codo <- function(X, kmax = 10, semilla = 2026) {
  set.seed(semilla)
  data.frame(
    k   = 1:kmax,
    wss = sapply(1:kmax, function(k)
      stats::kmeans(X, k, nstart = 25, iter.max = 50)$tot.withinss)
  )
}

# -------------------------------------------------------------
# curva_silueta(): silueta promedio para k = 2..kmax
# La silueta se calcula sobre una submuestra: la matriz de
# distancias completa de ~6.700 filas ocupa demasiada memoria.
# -------------------------------------------------------------
curva_silueta <- function(X, kmax = 10, n_sub = 1500, semilla = 2026) {
  set.seed(semilla)
  idx <- sample(nrow(X), min(n_sub, nrow(X)))
  Xs  <- X[idx, ]
  d   <- stats::dist(Xs)
  data.frame(
    k       = 2:kmax,
    silueta = sapply(2:kmax, function(k) {
      cl <- stats::kmeans(Xs, k, nstart = 25, iter.max = 50)$cluster
      mean(cluster::silhouette(cl, d)[, 3])
    })
  )
}

# -------------------------------------------------------------
# ajustar_kmeans(): solucion final.
# nstart = 50 no es cosmetico: con nstart = 1 la solucion cambia
# entre corridas porque K-Means es sensible a la inicializacion.
# -------------------------------------------------------------
ajustar_kmeans <- function(X, k, semilla = 2026) {
  set.seed(semilla)
  stats::kmeans(X, centers = k, nstart = 50, iter.max = 100)
}

# -------------------------------------------------------------
# perfilar(): traduce los clusters al idioma del negocio.
# Un cluster sin perfil no es un resultado entregable.
# -------------------------------------------------------------
perfilar <- function(df, cl, vars = VARS_NUM) {
  df |>
    dplyr::mutate(cluster = factor(cl)) |>
    dplyr::group_by(cluster) |>
    dplyr::summarise(
      n            = dplyr::n(),
      pct          = round(dplyr::n() / nrow(df) * 100, 1),
      precio_med   = round(stats::median(preciom)),
      area_med     = round(stats::median(areaconst)),
      precio_m2    = round(stats::median(preciom * 1e6 / areaconst)),
      banios_med   = stats::median(banios),
      habit_med    = stats::median(habitaciones),
      parq_med     = stats::median(parqueaderos),
      estrato_med  = stats::median(estrato),
      pct_casa     = round(mean(tipo == "Casa") * 100, 1),
      .groups = "drop"
    )
}

# -------------------------------------------------------------
# composicion_cluster(): distribucion de una variable categorica
# dentro de cada cluster (en %)
# -------------------------------------------------------------
composicion_cluster <- function(df, cl, var) {
  t <- table(cluster = cl, df[[var]])
  round(prop.table(t, 1) * 100, 1)
}

# -------------------------------------------------------------
# validar_estabilidad(): compara K-Means contra jerarquico Ward.
# Si dos metodos con logicas distintas coinciden, la estructura
# es del dato y no del algoritmo.
# -------------------------------------------------------------
validar_estabilidad <- function(X, k, n_sub = 1200, semilla = 2026) {
  set.seed(semilla)
  idx <- sample(nrow(X), min(n_sub, nrow(X)))
  Xs  <- X[idx, ]
  km  <- stats::kmeans(Xs, k, nstart = 50)$cluster
  hc  <- stats::cutree(stats::hclust(stats::dist(Xs), method = "ward.D2"), k)
  tab <- table(KMeans = km, Ward = hc)
  # Concordancia: mejor emparejamiento posible entre etiquetas
  conc <- sum(apply(tab, 1, max)) / sum(tab)
  list(tabla = tab, concordancia = round(conc * 100, 1), n = length(idx))
}

# -------------------------------------------------------------
# Graficos
# -------------------------------------------------------------
grafico_codo <- function(d) {
  ggplot(d, aes(k, wss)) +
    geom_line(colour = COL$azul, linewidth = .9) +
    geom_point(colour = COL$naranja, size = 2.4) +
    scale_x_continuous(breaks = d$k) +
    labs(title = "Metodo del codo",
         subtitle = "Se busca el k donde la mejora deja de ser sustancial",
         x = "Numero de clusters (k)", y = "SC intra-cluster") +
    theme_minimal(base_size = 11)
}

grafico_silueta <- function(d) {
  ggplot(d, aes(k, silueta)) +
    geom_line(colour = COL$azul, linewidth = .9) +
    geom_point(colour = COL$naranja, size = 2.4) +
    geom_point(data = d[which.max(d$silueta), ], size = 4.5,
               shape = 21, stroke = 1.2, fill = NA, colour = COL$naranja) +
    scale_x_continuous(breaks = d$k) +
    labs(title = "Metodo de la silueta",
         subtitle = "Circulo: k con mejor separacion media",
         x = "Numero de clusters (k)", y = "Silueta promedio") +
    theme_minimal(base_size = 11)
}

grafico_clusters_pca <- function(pca, cl) {
  d <- data.frame(pca$ind$coord[, 1:2], cluster = factor(cl))
  names(d)[1:2] <- c("Dim1", "Dim2")
  ggplot(d, aes(Dim1, Dim2, colour = cluster)) +
    geom_point(alpha = .35, size = .8) +
    stat_ellipse(level = .9, linewidth = .8) +
    labs(title = "Segmentos proyectados en el plano principal",
         x = sprintf("CP1 (%.1f%%)", pca$eig[1, 2]),
         y = sprintf("CP2 (%.1f%%)", pca$eig[2, 2]),
         colour = "Segmento") +
    theme_minimal(base_size = 11)
}

# -------------------------------------------------------------
# mapa_clusters(): geografia real de la oferta.
# No es un mapa con tiles: es un scatter de coordenadas, que para
# 6.700 puntos dibuja la silueta urbana con nitidez suficiente y
# no depende de conexion a internet al knitear.
# -------------------------------------------------------------
mapa_clusters <- function(df, cl) {
  d <- data.frame(lon = df$longitud, lat = df$latitud,
                  cluster = factor(cl), zona = df$zona)
  d <- d[stats::complete.cases(d), ]
  ggplot(d, aes(lon, lat, colour = cluster)) +
    geom_point(alpha = .5, size = .75) +
    coord_fixed(ratio = 1.11) +   # correccion aproximada por latitud (~3.4 N)
    labs(title = "Distribucion geografica de los segmentos",
         subtitle = "Cada punto es un inmueble en oferta",
         x = "Longitud", y = "Latitud", colour = "Segmento") +
    theme_minimal(base_size = 11)
}

9.2.4 R/04_correspondencia.R

# =============================================================
# Actividad 1 | 04_correspondencia.R
# Analisis de correspondencia simple y multiple
# =============================================================

suppressPackageStartupMessages({
  library(ggplot2); library(FactoMineR); library(factoextra); library(ca)
})

# -------------------------------------------------------------
# REGLA DE ORO: antes de dibujar un mapa de correspondencia hay
# que probar que exista asociacion. Cualquier algoritmo devuelve
# un grafico, incluso cuando las variables son independientes:
# ahi el mapa solo dibuja ruido muestral.
# -------------------------------------------------------------

# -------------------------------------------------------------
# prueba_asociacion(): chi-cuadrado + tamano del efecto
# El p-valor dice SI hay asociacion; V de Cramer dice CUANTA.
# Con n grande casi todo sale significativo, por eso se reportan
# las dos cosas juntas.
# -------------------------------------------------------------
prueba_asociacion <- function(tabla) {
  ji <- suppressWarnings(stats::chisq.test(tabla))
  n  <- sum(tabla)
  I  <- as.numeric(ji$statistic) / n                      # inercia total
  data.frame(
    Chi2      = round(as.numeric(ji$statistic), 2),
    gl        = as.integer(ji$parameter),
    p_valor   = format.pval(ji$p.value, digits = 3, eps = 1e-16),
    Inercia   = round(I, 4),
    Coef_C    = round(sqrt(as.numeric(ji$statistic) /
                           (as.numeric(ji$statistic) + n)), 3),
    V_Cramer  = round(sqrt(I / (min(dim(tabla)) - 1)), 3),
    Decision  = ifelse(ji$p.value < .05,
                       "Asociacion significativa: procede el AC",
                       "No se rechaza independencia: el AC seria ruido"),
    row.names = NULL
  )
}

# -------------------------------------------------------------
# residuos_estandarizados(): DONDE esta la asociacion.
# |residuo| > 2 marca la celda que se aparta de la independencia.
# -------------------------------------------------------------
residuos_estandarizados <- function(tabla) {
  round(suppressWarnings(stats::chisq.test(tabla))$stdres, 2)
}

# -------------------------------------------------------------
# perfiles_fila(): % dentro de cada fila.
# Si todos los perfiles fueran iguales -> independencia -> el AC
# no tendria nada que mostrar. El AC vive de sus diferencias.
# -------------------------------------------------------------
perfiles_fila <- function(tabla) round(prop.table(tabla, 1) * 100, 1)

# -------------------------------------------------------------
# ajustar_ac(): analisis de correspondencia simple.
# NOTA TECNICA: MASS::corresp() no despacha sobre objetos 'table'
# (falla con "invalid table specification"). FactoMineR::CA() si
# los acepta, por eso se usa aqui.
# -------------------------------------------------------------
ajustar_ac <- function(tabla) {
  FactoMineR::CA(as.data.frame.matrix(tabla), graph = FALSE)
}

tabla_inercia <- function(ac) {
  e <- as.data.frame(ac$eig)
  data.frame(
    Dimension = paste0("Dim", seq_len(nrow(e))),
    Inercia   = round(e[[1]], 5),
    Pct       = round(e[[2]], 1),
    Acumulado = round(e[[3]], 1),
    row.names = NULL
  )
}

# -------------------------------------------------------------
# mapa_ac(): grafico simetrico de coordenadas.
# COMO LEERLO:
#  - Misma variable, puntos cercanos    -> perfiles parecidos (valido).
#  - Variables distintas, cercanos      -> asociacion SUGERIDA:
#    confirmarla siempre con los residuos estandarizados.
#  - Punto sobre el origen              -> perfil promedio, no informa.
# -------------------------------------------------------------
mapa_ac <- function(ac, titulo) {
  factoextra::fviz_ca_biplot(
    ac, repel = TRUE,
    col.row = COL$azul, col.col = COL$naranja
  ) +
    labs(title = titulo,
         subtitle = sprintf("Dim1: %.1f%% | Dim2: %.1f%% de la inercia",
                            ac$eig[1, 2],
                            ifelse(nrow(ac$eig) > 1, ac$eig[2, 2], 0))) +
    theme_minimal(base_size = 11)
}

# -------------------------------------------------------------
# ajustar_acm(): correspondencia MULTIPLE, para mas de dos
# variables categoricas simultaneamente.
# -------------------------------------------------------------
ajustar_acm <- function(df, vars) {
  d <- df[, vars]
  d[] <- lapply(d, factor)
  FactoMineR::MCA(d, graph = FALSE)
}

mapa_acm <- function(acm, titulo) {
  factoextra::fviz_mca_var(
    acm, repel = TRUE, col.var = "contrib",
    gradient.cols = c(COL$celeste, COL$azul, COL$naranja)
  ) +
    labs(title = titulo,
         subtitle = "Categorias proximas = perfiles de oferta similares") +
    theme_minimal(base_size = 11)
}

# -------------------------------------------------------------
# top_barrios(): 'barrio' tiene 437 niveles; un mapa con 437
# etiquetas es ilegible. Se retienen los de mayor volumen de
# oferta, que es donde la empresa realmente decide.
# -------------------------------------------------------------
top_barrios <- function(df, n = 15) {
  names(sort(table(df$barrio), decreasing = TRUE))[seq_len(n)]
}

9.3 Anexo C — Entorno de ejecución

sessionInfo()
#> R version 4.4.2 (2024-10-31 ucrt)
#> Platform: x86_64-w64-mingw32/x64
#> Running under: Windows 11 x64 (build 26200)
#> 
#> 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/La_Paz
#> tzcode source: internal
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] ca_0.71.1        cluster_2.1.8.2  factoextra_2.2.0 FactoMineR_2.14 
#> [5] kableExtra_1.4.0 knitr_1.51       ggplot2_4.0.3    tidyr_1.3.2     
#> [9] dplyr_1.2.1     
#> 
#> loaded via a namespace (and not attached):
#>  [1] gtable_0.3.6         xfun_0.54            bslib_0.9.0         
#>  [4] htmlwidgets_1.6.4    ggrepel_0.9.8        rstatix_1.1.0       
#>  [7] lattice_0.22-6       vctrs_0.7.1          tools_4.4.2         
#> [10] generics_0.1.4       tibble_3.3.1         pkgconfig_2.0.3     
#> [13] RColorBrewer_1.1-3   S7_0.2.2             scatterplot3d_0.3-45
#> [16] lifecycle_1.0.5      compiler_4.4.2       farver_2.1.2        
#> [19] stringr_1.6.0        textshaping_1.0.4    leaps_3.2           
#> [22] carData_3.0-5        htmltools_0.5.9      sass_0.4.10         
#> [25] yaml_2.3.12          Formula_1.2-5        car_3.1-3           
#> [28] pillar_1.11.1        ggpubr_1.0.0         jquerylib_0.1.4     
#> [31] MASS_7.3-61          flashClust_1.1-4     DT_0.34.0           
#> [34] cachem_1.1.0         abind_1.4-8          tidyselect_1.2.1    
#> [37] digest_0.6.39        mvtnorm_1.3-7        stringi_1.8.7       
#> [40] purrr_1.0.4          labeling_0.4.3       fastmap_1.2.0       
#> [43] grid_4.4.2           cli_3.6.6            magrittr_2.0.3      
#> [46] broom_1.0.13         withr_3.0.3          backports_1.5.1     
#> [49] scales_1.4.0         estimability_2.0.0   rmarkdown_2.30      
#> [52] paqueteMODELOS_0.1.0 emmeans_2.0.4        otel_0.2.0          
#> [55] gridExtra_2.3.1      ggsignif_0.6.4       evaluate_1.0.5      
#> [58] viridisLite_0.4.3    rlang_1.2.0          Rcpp_1.1.0          
#> [61] xtable_1.8-4         glue_1.8.0           xml2_1.4.1          
#> [64] svglite_2.2.2        rstudioapi_0.19.0    jsonlite_2.0.0      
#> [67] R6_2.6.1             systemfonts_1.3.1    multcompView_0.1-12

Informe elaborado con R y RMarkdown. Datos: paqueteMODELOS, obtenidos de OLX mediante webscraping.