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.
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.
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.
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.
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
#> [1] 8322 13
#> 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 ...
Antes de cualquier análisis se cuantifica el estado real de los datos. Este paso es lo que separa un resultado de un artefacto.
| 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 |
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)
| 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:
preciom
(1,85) y areaconst (2,69): hay una cola de inmuebles de
lujo que estira la distribución.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.
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.
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.
Las correlaciones entre preciom, areaconst
y banios superan 0,6. Ese solapamiento es
exactamente lo que el PCA convierte en una única dimensión.
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.
| 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 |
Gráfico de sedimentación. La línea discontinua marca el umbral del 80%.
| 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:
parqueaderos (60,7% de contribución),
es decir, es prácticamente una variable disfrazada de
componente, no una dimensión latente.| 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 |
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.
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.
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.
| 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.
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).
Criterios para la elección de k: codo (izquierda) y silueta (derecha).
| k | Silueta | Lectura |
|---|---|---|
| 2 | 0.419 | Máximo global |
| 3 | 0.317 | |
| 4 | 0.331 | Máximo local |
| 5 | 0.268 | |
| 6 | 0.261 |
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:
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.
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.
| 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 |
| 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.
Segmentos proyectados sobre el plano principal del PCA.
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")| Solución | Concordancia_pct | n_submuestra |
|---|---|---|
| k = 2 | 88.3 | 1200 |
| k = 4 | 72.2 | 1200 |
#> 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.
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)")| 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.
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.
t1 <- table(Tipo = dat$tipo, Zona = dat$zona)
tb(as.data.frame.matrix(t1), "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 |
| 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)")| 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).
t2 <- table(Zona = dat$zona, Estrato = dat$estratof)
tb(as.data.frame.matrix(t2), "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 |
| 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 |
| 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 |
| 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 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:
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")| 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 |
| 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 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.
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")| 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 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.
| 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:
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.
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.
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.
Se declaran de forma explícita, porque condicionan el uso de los resultados:
parqueaderos (19,3%)
reduce artificialmente la varianza de esa variable y por tanto su peso
en el PCA.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:
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.
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
)
}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)
)
}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)
}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)]
}#> 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.