Carga del dataset

knitr::opts_chunk$set(
  warning = FALSE,  # oculta warnings
  message = FALSE   # oculta mensajes (p.ej. de library)
)
# Luego instala el paquete desde GitHub
devtools::install_github("centromagis/paqueteMODELOS")

# Y finalmente cárgalo
library(paqueteMODELOS)

# Cargar dataset
data("vivienda")

# Ver estructura y primeras filas
str(vivienda)
## spc_tbl_ [8,322 × 13] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
##  $ id          : num [1:8322] 1147 1169 1350 5992 1212 ...
##  $ zona        : chr [1:8322] "Zona Oriente" "Zona Oriente" "Zona Oriente" "Zona Sur" ...
##  $ piso        : chr [1:8322] NA NA NA "02" ...
##  $ estrato     : num [1:8322] 3 3 3 4 5 5 4 5 5 5 ...
##  $ preciom     : num [1:8322] 250 320 350 400 260 240 220 310 320 780 ...
##  $ areaconst   : num [1:8322] 70 120 220 280 90 87 52 137 150 380 ...
##  $ parqueaderos: num [1:8322] 1 1 2 3 1 1 2 2 2 2 ...
##  $ banios      : num [1:8322] 3 2 2 5 2 3 2 3 4 3 ...
##  $ habitaciones: num [1:8322] 6 3 4 3 3 3 3 4 6 3 ...
##  $ tipo        : chr [1:8322] "Casa" "Casa" "Casa" "Casa" ...
##  $ barrio      : chr [1:8322] "20 de julio" "20 de julio" "20 de julio" "3 de julio" ...
##  $ longitud    : num [1:8322] -76.5 -76.5 -76.5 -76.5 -76.5 ...
##  $ latitud     : num [1:8322] 3.43 3.43 3.44 3.44 3.46 ...
##  - attr(*, "spec")=List of 3
##   ..$ cols   :List of 13
##   .. ..$ id          : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ zona        : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ piso        : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ estrato     : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ preciom     : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ areaconst   : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ parqueaderos: list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ banios      : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ habitaciones: list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ tipo        : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ barrio      : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_character" "collector"
##   .. ..$ longitud    : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   .. ..$ latitud     : list()
##   .. .. ..- attr(*, "class")= chr [1:2] "collector_double" "collector"
##   ..$ default: list()
##   .. ..- attr(*, "class")= chr [1:2] "collector_guess" "collector"
##   ..$ delim  : chr ";"
##   ..- attr(*, "class")= chr "col_spec"
##  - attr(*, "problems")=<externalptr>
head(vivienda)
## # A tibble: 6 × 13
##      id zona    piso  estrato preciom areaconst parqueaderos banios habitaciones
##   <dbl> <chr>   <chr>   <dbl>   <dbl>     <dbl>        <dbl>  <dbl>        <dbl>
## 1  1147 Zona O… <NA>        3     250        70            1      3            6
## 2  1169 Zona O… <NA>        3     320       120            1      2            3
## 3  1350 Zona O… <NA>        3     350       220            2      2            4
## 4  5992 Zona S… 02          4     400       280            3      5            3
## 5  1212 Zona N… 01          5     260        90            1      2            3
## 6  1724 Zona N… 01          5     240        87            1      3            3
## # ℹ 4 more variables: tipo <chr>, barrio <chr>, longitud <dbl>, latitud <dbl>
summary(vivienda)
##        id           zona               piso              estrato     
##  Min.   :   1   Length:8322        Length:8322        Min.   :3.000  
##  1st Qu.:2080   Class :character   Class :character   1st Qu.:4.000  
##  Median :4160   Mode  :character   Mode  :character   Median :5.000  
##  Mean   :4160                                         Mean   :4.634  
##  3rd Qu.:6240                                         3rd Qu.:5.000  
##  Max.   :8319                                         Max.   :6.000  
##  NA's   :3                                            NA's   :3      
##     preciom         areaconst       parqueaderos        banios      
##  Min.   :  58.0   Min.   :  30.0   Min.   : 1.000   Min.   : 0.000  
##  1st Qu.: 220.0   1st Qu.:  80.0   1st Qu.: 1.000   1st Qu.: 2.000  
##  Median : 330.0   Median : 123.0   Median : 2.000   Median : 3.000  
##  Mean   : 433.9   Mean   : 174.9   Mean   : 1.835   Mean   : 3.111  
##  3rd Qu.: 540.0   3rd Qu.: 229.0   3rd Qu.: 2.000   3rd Qu.: 4.000  
##  Max.   :1999.0   Max.   :1745.0   Max.   :10.000   Max.   :10.000  
##  NA's   :2        NA's   :3        NA's   :1605     NA's   :3       
##   habitaciones        tipo              barrio             longitud     
##  Min.   : 0.000   Length:8322        Length:8322        Min.   :-76.59  
##  1st Qu.: 3.000   Class :character   Class :character   1st Qu.:-76.54  
##  Median : 3.000   Mode  :character   Mode  :character   Median :-76.53  
##  Mean   : 3.605                                         Mean   :-76.53  
##  3rd Qu.: 4.000                                         3rd Qu.:-76.52  
##  Max.   :10.000                                         Max.   :-76.46  
##  NA's   :3                                              NA's   :3       
##     latitud     
##  Min.   :3.333  
##  1st Qu.:3.381  
##  Median :3.416  
##  Mean   :3.418  
##  3rd Qu.:3.452  
##  Max.   :3.498  
##  NA's   :3

Limpieza de datos

*Eliminación de duplicados

Validamos el dataset, Cuantas filas y columnas hay, cuantos datos datos faltantes por columnas y si hay o no IDs repetidos

library(tidyverse)
library(janitor)
# 1) Copia de trabajo con nombres estandarizados
df <- vivienda %>% clean_names()

# 2) Cuántas filas/columnas y nombres
nrow(df); ncol(df); names(df)
## [1] 8322
## [1] 13
##  [1] "id"           "zona"         "piso"         "estrato"      "preciom"     
##  [6] "areaconst"    "parqueaderos" "banios"       "habitaciones" "tipo"        
## [11] "barrio"       "longitud"     "latitud"
# 3) Faltantes por columna
colSums(is.na(df))
##           id         zona         piso      estrato      preciom    areaconst 
##            3            3         2638            3            2            3 
## parqueaderos       banios habitaciones         tipo       barrio     longitud 
##         1605            3            3            3            3            3 
##      latitud 
##            3
# 4) ¿IDs repetidos?
sum(duplicated(df$id))
## [1] 2

Quitamos duplicados por ID

# Quitar duplicados por id
df <- df %>% distinct(id, .keep_all = TRUE)

# Revisar tamaño final
nrow(df)
## [1] 8320

*Creamos un dataframe master para de ahi tomar los datos para los escenarios

library(tidyverse)
library(janitor)

# 1) Partimos del dataset original y construimos la base maestra
df_master <- vivienda %>%
  clean_names() %>%           # nombres en snake_case
  filter(!is.na(id)) %>%      # sin filas sin id
  distinct(id, .keep_all = TRUE)  # sin duplicados por id

# 2) Revisión de NA en variables clave (numéricas y categóricas que usaremos)
vars_clave <- c("estrato","preciom","areaconst","parqueaderos","banios","habitaciones",
                "tipo","zona","barrio","piso","longitud","latitud")
na_overview <- sapply(df_master[, vars_clave], function(x) sum(is.na(x)))
na_overview
##      estrato      preciom    areaconst parqueaderos       banios habitaciones 
##            0            0            0         1602            0            0 
##         tipo         zona       barrio         piso     longitud      latitud 
##            0            0            0         2635            0            0

*Datos faltantes

- Variable Parqueaderos

1. validar si son realmente faltantes o significan 0 parqueaderos

El objetivo del análisis es determinar si estos NA representan realmente propiedades sin parqueadero o son errores/omisiones en el registro, y definir la mejor estrategia para su imputación.

Casos con NA en Parqueaderos

### Revisar casos con NA en parqueaderos ###
df_master %>%
  filter(is.na(parqueaderos)) %>%
  select(parqueaderos, tipo, estrato, areaconst, preciom) %>%
  head(20)
## # A tibble: 20 × 5
##    parqueaderos tipo        estrato areaconst preciom
##           <dbl> <chr>         <dbl>     <dbl>   <dbl>
##  1           NA Casa              6       445     750
##  2           NA Apartamento       3        49     100
##  3           NA Casa              3       160     230
##  4           NA Apartamento       5       105     430
##  5           NA Casa              3       435     190
##  6           NA Casa              3       120     180
##  7           NA Casa              3       210     500
##  8           NA Apartamento       3       176     199
##  9           NA Apartamento       5       235     320
## 10           NA Casa              5       455     520
## 11           NA Apartamento       5        85     345
## 12           NA Apartamento       3        61     130
## 13           NA Apartamento       5        74     185
## 14           NA Apartamento       3        72      98
## 15           NA Apartamento       3        96     130
## 16           NA Apartamento       3        60      92
## 17           NA Apartamento       3        68      98
## 18           NA Casa              3       300     380
## 19           NA Apartamento       4        82     220
## 20           NA Apartamento       3        60     120

Casos con NA en parqueaderos agrupados por estrato y tipo de vivienda

df_master %>%
  filter(is.na(parqueaderos)) %>%
  group_by(tipo, estrato) %>%
  summarise(cantidad = n()) %>%
  arrange(desc(cantidad))
## # A tibble: 8 × 3
## # Groups:   tipo [2]
##   tipo        estrato cantidad
##   <chr>         <dbl>    <int>
## 1 Casa              3      426
## 2 Apartamento       4      357
## 3 Apartamento       3      343
## 4 Casa              4      131
## 5 Casa              5      121
## 6 Apartamento       5      107
## 7 Apartamento       6       62
## 8 Casa              6       55

2. Validar si los NA se concentran en viviendas muy pequeñas o tienen algun tipo de patron

library(ggplot2)

ggplot(df_master %>% filter(is.na(parqueaderos)), aes(x = areaconst)) +
  geom_histogram(binwidth = 5, fill = "steelblue") +
  labs(title = "Distribución de área en inmuebles con NA en parqueaderos")

3. Comparamos con registros sin NA

df_master %>%
  group_by(is.na(parqueaderos)) %>%
  summarise(
    promedio_precio = mean(preciom, na.rm = TRUE),
    promedio_area   = mean(areaconst, na.rm = TRUE),
    promedio_habit  = mean(habitaciones, na.rm = TRUE)
  )
## # A tibble: 2 × 4
##   `is.na(parqueaderos)` promedio_precio promedio_area promedio_habit
##   <lgl>                           <dbl>         <dbl>          <dbl>
## 1 FALSE                            469.          181.           3.61
## 2 TRUE                             287.          149.           3.58

4. Revisamos ubicación geografica

df_master %>%
  filter(is.na(parqueaderos)) %>%
  count(zona, sort = TRUE)
## # A tibble: 5 × 2
##   zona             n
##   <chr>        <int>
## 1 Zona Norte     633
## 2 Zona Sur       621
## 3 Zona Oriente   188
## 4 Zona Oeste     100
## 5 Zona Centro     60

5. Revisamos tipo inmueble

df_master %>%
  filter(is.na(parqueaderos)) %>%
  count(tipo, sort = TRUE)
## # A tibble: 2 × 2
##   tipo            n
##   <chr>       <int>
## 1 Apartamento   869
## 2 Casa          733

6. Revisamos correlación de NA con otras variables

ggplot(df_master, aes(x = areaconst, y = preciom, color = is.na(parqueaderos))) +
  geom_point(alpha = 0.5) +
  labs(title = "Parqueaderos NA vs No NA")

7. Revisamos NA agrupado por estrato

df_master %>%
  filter(is.na(parqueaderos)) %>%
  count(estrato, sort = TRUE)
## # A tibble: 4 × 2
##   estrato     n
##     <dbl> <int>
## 1       3   769
## 2       4   488
## 3       5   228
## 4       6   117

8. Comparamos el precio promedio por metro cuadrado

df_master %>%
  mutate(precio_m2 = preciom / areaconst) %>%
  group_by(is.na(parqueaderos)) %>%
  summarise(
    promedio_precio_m2 = mean(precio_m2, na.rm = TRUE),
    n = n()
  )
## # A tibble: 2 × 3
##   `is.na(parqueaderos)` promedio_precio_m2     n
##   <lgl>                              <dbl> <int>
## 1 FALSE                               2.86  6717
## 2 TRUE                                2.16  1602

9. Para validar si podrían ser propiedades sin parqueadero, revisamos los valores mínimos existentes independiente para cada tipo de vivienda.

Casa

df_master %>%
  filter(tipo == "Casa") %>%
  count(parqueaderos) %>%
  arrange(parqueaderos)
## # A tibble: 11 × 2
##    parqueaderos     n
##           <dbl> <int>
##  1            1   857
##  2            2   891
##  3            3   268
##  4            4   296
##  5            5    64
##  6            6    66
##  7            7    17
##  8            8    17
##  9            9     4
## 10           10     6
## 11           NA   733

Apartamento

df_master %>%
  filter(tipo == "Apartamento") %>%
  count(parqueaderos) %>%
  arrange(parqueaderos)
## # A tibble: 9 × 2
##   parqueaderos     n
##          <dbl> <int>
## 1            1  2298
## 2            2  1584
## 3            3   252
## 4            4    88
## 5            5     4
## 6            6     2
## 7            7     1
## 8           10     2
## 9           NA   869

Relacion de tipo de vivienda con y sin NA en parqueadero comparando Area y estrato para validar si es un error o NA tiene relación con 0 parqueaderos

df_master %>%
  group_by(tipo, estrato, is.na(parqueaderos)) %>%
  summarise(
    promedio_area = mean(areaconst, na.rm = TRUE),
    mediana_area = median(areaconst, na.rm = TRUE),
    n = n()
  ) %>%
  arrange(tipo, estrato, `is.na(parqueaderos)`)
## # A tibble: 16 × 6
## # Groups:   tipo, estrato [8]
##    tipo        estrato `is.na(parqueaderos)` promedio_area mediana_area     n
##    <chr>         <dbl> <lgl>                         <dbl>        <dbl> <int>
##  1 Apartamento       3 FALSE                          69.1          60    296
##  2 Apartamento       3 TRUE                           65.2          60    343
##  3 Apartamento       4 FALSE                          81.4          75   1047
##  4 Apartamento       4 TRUE                           68.3          60    357
##  5 Apartamento       5 FALSE                         111.           98   1659
##  6 Apartamento       5 TRUE                          106.           90    107
##  7 Apartamento       6 FALSE                         177.          157   1229
##  8 Apartamento       6 TRUE                          181.          142.    62
##  9 Casa              3 FALSE                         216.          187    388
## 10 Casa              3 TRUE                          186.          154.   426
## 11 Casa              4 FALSE                         227.          203    594
## 12 Casa              4 TRUE                          264.          235    131
## 13 Casa              5 FALSE                         287.          250    863
## 14 Casa              5 TRUE                          282.          250    121
## 15 Casa              6 FALSE                         381.          330    641
## 16 Casa              6 TRUE                          391.          350     55

10. boxplot de comparacion de area y parqueadero

ggplot(df_master, aes(x = factor(estrato), y = areaconst,
                      fill = is.na(parqueaderos))) +
  geom_boxplot() +
  facet_wrap(~ tipo) +
  labs(
    title = "Área construida por estrato y tipo de vivienda",
    x = "Estrato",
    y = "Área construida"
  )

df_master %>%
  mutate(na_parqueaderos = ifelse(is.na(parqueaderos), "NA", "No NA")) %>%
  count(tipo, estrato, na_parqueaderos) %>%
  ggplot(aes(x = factor(estrato), y = tipo, fill = n)) +
  geom_tile(color = "white") +
  geom_text(aes(label = n), color = "black") +
  facet_wrap(~ na_parqueaderos) +
  labs(title = "Distribución por estrato, tipo de vivienda y NA en parqueaderos",
       x = "Estrato", y = "Tipo de vivienda")

11. Análisis:

  • Los NA se distribuyen por estrato y tipo, con valores de área y precio similares a propiedades con 1 parqueadero.

  • Los NA no están distribuidos aleatoriamente:

  • En casas, hay NA en todos los estratos, pero son más frecuentes en estratos 3 y 4.

  • En apartamentos, los NA son frecuentes en estratos 3 y 4, y mucho menos en 5 y 6.

  • En la mayoría de estratos y tipos de vivienda, los NA tienen un área construida ligeramente menor que los no NA, pero no es una diferencia considerable para representar el patron de no parqueadero

  • El patrón sugiere omisión de registro y no ausencia real de parqueadero.

  • Con base en el análisis, lo más coherente sería imputar los NA usando la mediana de parqueaderos agrupada por tipo de vivienda y estrato. Esto preserva el patrón real, evita introducir valores inexistentes (no hay ninguna vivienda con parqueadero 0) y respeta la segmentación socioeconómica del dataset.

12. Imputación de datos NA para variable parqueaderos

  • Se imputaran los NA de esta variable usando la mediana de parqueaderos agrupada por tipo de vivienda y estrato. Esto se debe a que generalmente, los estratos mas altos tienden a tener más parqueaderos e igual sucede en relación de apartamentos y casas, estas ultimas tienden a tener algunas veces mas parqueaderos
library(dplyr)

# Calcular la mediana por grupo (tipo + estrato) si tener en cuenta NA
medianas_parqueaderos <- df_master %>%
  group_by(tipo, estrato) %>%
  summarise(mediana_parq = median(parqueaderos, na.rm = TRUE)) %>%
  mutate(mediana_parq = round(mediana_parq))  # redondear por si acaso

# Unir y reemplazar NA con la mediana del grupo
df_master <- df_master %>%
  left_join(medianas_parqueaderos, by = c("tipo", "estrato")) %>%
  mutate(parqueaderos = ifelse(is.na(parqueaderos), mediana_parq, parqueaderos)) %>%
  select(-mediana_parq)  # limpiar columna auxiliar
  • Revisamos la imputación en parqueaderos
# Debe dar 0
sum(is.na(df_master$parqueaderos))
## [1] 0
# Cantidad de ceros
sum(df_master$parqueaderos == 0, na.rm = TRUE)
## [1] 0
# ¿Hay valores con decimales?
any(df_master$parqueaderos %% 1 != 0)
## [1] FALSE

-Variable Piso

# Conteo de NA por tipo de vivienda y estrato
df_master %>%
  mutate(na_piso = is.na(piso)) %>%
  count(tipo, estrato, na_piso) %>%
  arrange(desc(n))
## # A tibble: 16 × 4
##    tipo        estrato na_piso     n
##    <chr>         <dbl> <lgl>   <int>
##  1 Apartamento       5 FALSE    1328
##  2 Apartamento       4 FALSE    1056
##  3 Apartamento       6 FALSE     894
##  4 Casa              5 FALSE     630
##  5 Casa              4 FALSE     460
##  6 Apartamento       3 FALSE     441
##  7 Apartamento       5 TRUE      438
##  8 Casa              6 FALSE     438
##  9 Casa              3 FALSE     437
## 10 Apartamento       6 TRUE      397
## 11 Casa              3 TRUE      377
## 12 Casa              5 TRUE      354
## 13 Apartamento       4 TRUE      348
## 14 Casa              4 TRUE      265
## 15 Casa              6 TRUE      258
## 16 Apartamento       3 TRUE      198

Relacion de tipo de vivienda con y sin NA en piso comparando Area y estrato para validar si es un error

df_master %>%
  mutate(na_piso = is.na(piso)) %>%
  group_by(tipo, estrato, na_piso) %>%
  summarise(
    promedio_area = mean(areaconst, na.rm = TRUE),
    mediana_area  = median(areaconst, na.rm = TRUE),
    n = n()
  ) %>%
  arrange(tipo, estrato, na_piso)
## # A tibble: 16 × 6
## # Groups:   tipo, estrato [8]
##    tipo        estrato na_piso promedio_area mediana_area     n
##    <chr>         <dbl> <lgl>           <dbl>        <dbl> <int>
##  1 Apartamento       3 FALSE            66.2          60    441
##  2 Apartamento       3 TRUE             68.8          60    198
##  3 Apartamento       4 FALSE            77.4          71   1056
##  4 Apartamento       4 TRUE             80.1          71    348
##  5 Apartamento       5 FALSE           109.           96   1328
##  6 Apartamento       5 TRUE            114.           97    438
##  7 Apartamento       6 FALSE           175.          153    894
##  8 Apartamento       6 TRUE            181.          165    397
##  9 Casa              3 FALSE           199.          170    437
## 10 Casa              3 TRUE            202.          166    377
## 11 Casa              4 FALSE           238.          220    460
## 12 Casa              4 TRUE            227.          200    265
## 13 Casa              5 FALSE           287.          250    630
## 14 Casa              5 TRUE            285.          254.   354
## 15 Casa              6 FALSE           377.          326    438
## 16 Casa              6 TRUE            389.          349    258

1. Validar por tipo de vivienda la variable pisos, para determinar si en la variable “casa” el piso corresponde al piso de ubicación o cantidad de pisos y con esto decidir como se imputará

# Para Casas
df_master %>%
  filter(tipo == "Casa", !is.na(piso)) %>%
  count(piso) %>%
  arrange(piso)
## # A tibble: 8 × 2
##   piso      n
##   <chr> <int>
## 1 01      430
## 2 02      938
## 3 03      524
## 4 04       62
## 5 05        3
## 6 06        2
## 7 07        4
## 8 10        2
  • Lo anterior sugiere que en casas, la variable piso probablemente no representa un nivel de edificio, sino el número de pisos que tiene la casa por lo cual no se debe imputar por 1.

  • Para Apartamentos, la variable piso casi siempre representa el nivel dentro de un edificio. Por lo cual se va a validar la distribución para determinar los pisos mas comunes y por estrato para encontrar algun patrón

df_master %>%
  filter(tipo == "Apartamento", !is.na(piso)) %>%
  count(estrato, piso) %>%
  arrange(estrato, piso)
## # A tibble: 46 × 3
##    estrato piso      n
##      <dbl> <chr> <int>
##  1       3 01       68
##  2       3 02       76
##  3       3 03       82
##  4       3 04       79
##  5       3 05      121
##  6       3 06        2
##  7       3 07        2
##  8       3 08        7
##  9       3 10        2
## 10       3 11        2
## # ℹ 36 more rows

Relación de No. de Piso, estrato y cantidad de viviendas con esta caracteristica

library(dplyr)
library(ggplot2)

# Tabla por estrato y piso
tabla_pisos_apto <- df_master %>%
  filter(tipo == "Apartamento", !is.na(piso)) %>%
  count(estrato, piso) %>%
  arrange(estrato, piso)
# Gráfico de distribución
df_master %>%
  filter(tipo == "Apartamento", !is.na(piso)) %>%
  ggplot(aes(x = as.numeric(piso), fill = as.factor(estrato))) +
  geom_histogram(binwidth = 1, position = "dodge") +
  labs(title = "Distribución de pisos en Apartamentos por estrato",
       x = "Piso", y = "Cantidad", fill = "Estrato") +
  theme_minimal()

2. Análisis

Con el analisis por tipo de vivienda anterior, podemos elegir la tecnica de imputación:

  • Para Casas usaremos la moda global ya que hay un patron concentrado en 1 y 2 pisos}
  • Para apartamentos el patrón es diferente, y varia dependiendo el estrato, se imputará por moda por grupo (tipo de vivienda + estrato)
# Guardar copia base antes de imputar
df_base <- df_master
# Calcular moda por tipo y estrato
moda_pisos <- df_master %>%
  group_by(tipo, estrato) %>%
  summarise(moda_piso = names(sort(table(piso), decreasing = TRUE))[1],
            .groups = "drop")

# Calcular moda global para casas
moda_casa <- df_master %>%
  filter(tipo == "Casa", !is.na(piso)) %>%
  summarise(moda_piso_casa = names(sort(table(piso), decreasing = TRUE))[1]) %>%
  pull(moda_piso_casa)

# Unir y reemplazar
df_master <- df_master %>%
  left_join(moda_pisos, by = c("tipo", "estrato")) %>%
  mutate(
    piso = ifelse(tipo == "Casa" & is.na(piso), moda_casa,
           ifelse(tipo == "Apartamento" & is.na(piso), moda_piso, piso))
  ) %>%
  select(-moda_piso)
  • Verificamos que no haya NA en variable piso
# Verificar que no haya NA en piso
sum(is.na(df_master$piso))
## [1] 0

Validación NA en todas las variables

  • Valiadmos que no hayan NA en ninguna de las variables
vars_clave <- c("estrato","preciom","areaconst","parqueaderos","banios",
                "habitaciones","piso","tipo","zona","barrio","longitud","latitud")
colSums(is.na(df_master[, vars_clave]))
##      estrato      preciom    areaconst parqueaderos       banios habitaciones 
##            0            0            0            0            0            0 
##         piso         tipo         zona       barrio     longitud      latitud 
##            0            0            0            0            0            0
  • Validamos tipo de datos en variable piso
# Chequeos rápidos
str(df_master$piso)
##  chr [1:8319] "02" "02" "02" "02" "01" "01" "01" "01" "02" "02" "02" "02" ...
summary(df_master$piso)
##    Length     Class      Mode 
##      8319 character character

4. Pasamos a interger la variable piso para que sea facil de analizar y realizamos una verificación rapida del código aplicado.

df_master <- df_master %>%
  mutate(
    piso = as.integer(as.character(piso)),
    parqueaderos = as.integer(parqueaderos)
  )

# Revisar
str(df_master$piso)
##  int [1:8319] 2 2 2 2 1 1 1 1 2 2 ...
summary(df_master$piso)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   1.000   2.000   3.000   3.507   5.000  12.000

Despues de limpiar el Dataset e imputar los datos NA guardamos una versión base antes de validar los outliers que nos sirvirá para trabajar para cada análisis requerido en el ejercicio. Este esun df limpio sin datos duplicados, Sin NA.

# Guardar dataset maestro limpio
df_master_base <- df_master

# guardar en archivo para no perderlo
save(df_master_base, file = "df_master_base.RData")
write.csv(df_master_base, "df_master_base.csv", row.names = FALSE)

Outliers

# 1Crear copia del maestro limpio
df_outliers <- df_master_base

# Función para detectar outliers usando IQR
detectar_outliers <- function(x) {
  if (is.numeric(x)) {
    Q1 <- quantile(x, 0.25, na.rm = TRUE)
    Q3 <- quantile(x, 0.75, na.rm = TRUE)
    IQR_val <- Q3 - Q1
    lim_inf <- Q1 - 1.5 * IQR_val
    lim_sup <- Q3 + 1.5 * IQR_val
    return(ifelse(x < lim_inf | x > lim_sup, TRUE, FALSE))
  } else {
    return(rep(FALSE, length(x)))
  }
}

# Aplicar detección a todas las variables numéricas
outlier_flags <- as.data.frame(lapply(df_outliers, detectar_outliers))

# Contar outliers por variable
conteo_outliers <- colSums(outlier_flags)
conteo_outliers <- sort(conteo_outliers, decreasing = TRUE)

#  Ver resumen
conteo_outliers
## habitaciones parqueaderos      preciom    areaconst         piso     longitud 
##          888          567          552          382          297          130 
##       banios           id         zona      estrato         tipo       barrio 
##           72            0            0            0            0            0 
##      latitud 
##            0

Boxplots Outliers

library(ggplot2)
library(tidyr)

# Filtrar variables con outliers, excluyendo longitud y latitud
variables_con_outliers <- names(conteo_outliers[conteo_outliers > 0])
variables_con_outliers <- setdiff(variables_con_outliers, c("longitud", "latitud"))

# Filtrar dataset para esas variables
df_outliers_plot <- df_outliers[ , variables_con_outliers]

# Pasar a formato largo para ggplot
df_outliers_long <- df_outliers_plot %>%
  pivot_longer(cols = everything(), names_to = "Variable", values_to = "Valor")

# Graficar boxplots
ggplot(df_outliers_long, aes(x = Variable, y = Valor)) +
  geom_boxplot(outlier.colour = "red", outlier.shape = 16) +
  theme_minimal() +
  labs(title = "Distribución y Outliers por Variable",
       y = "Valor", x = "Variable") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

library(ggplot2)
library(tidyr)

# Filtrar variables con outliers, excluyendo longitud y latitud
variables_con_outliers <- names(conteo_outliers[conteo_outliers > 0])
variables_con_outliers <- setdiff(variables_con_outliers, c("longitud", "latitud"))

# Filtrar dataset para esas variables
df_outliers_plot <- df_outliers[ , variables_con_outliers]

# Pasar a formato largo para ggplot
df_outliers_long <- df_outliers_plot %>%
  pivot_longer(cols = everything(), names_to = "Variable", values_to = "Valor")

# Graficar boxplots pequeños por variable
ggplot(df_outliers_long, aes(x = "", y = Valor)) +
  geom_boxplot(outlier.colour = "red", outlier.shape = 16) +
  facet_wrap(~Variable, scales = "free", ncol = 4) +
  theme_minimal() +
  labs(title = "Outliers por Variable",
       y = "Valor", x = "") +
  theme(axis.text.x = element_blank(),
        axis.ticks.x = element_blank())

  • Creamos una columna adicional en el df para identificar los outliers y no generar nuevos calculos ya que los outliers se deben tratar de forma difernte dependiendo el escenario.

  • Mostrramos la cantidad de outliers por variable

library(dplyr)

# 1) Partimos del maestro limpio
df_outliers <- df_master_base

# 2) Variables numéricas a evaluar (sin coordenadas)
vars <- c("estrato","preciom","areaconst","parqueaderos","banios","habitaciones","piso")

# 3) Función IQR para marcar outliers (TRUE/FALSE)
detect_outliers_iqr <- function(x){
  q1 <- quantile(x, 0.25, na.rm = TRUE)
  q3 <- quantile(x, 0.75, na.rm = TRUE)
  iqr <- q3 - q1
  li <- q1 - 1.5 * iqr
  ls <- q3 + 1.5 * iqr
  (x < li) | (x > ls)
}

# 4) Matriz de flags por variable
flag_mat <- sapply(vars, function(v) detect_outliers_iqr(df_outliers[[v]]))
flag_df  <- as.data.frame(flag_mat)
names(flag_df) <- paste0(vars, "_out")

# 5) Agregar flags y el conteo por fila
df_outliers <- bind_cols(df_outliers, flag_df)
df_outliers$outlier_count <- rowSums(flag_df)

# 6) Chequeos rápidos
table(df_outliers$outlier_count)   # distribución de # de outliers por fila
## 
##    0    1    2    3    4    5 
## 6380 1390  343  152   44   10
colSums(flag_df)                   # cuántos outliers por variable
##      estrato_out      preciom_out    areaconst_out parqueaderos_out 
##                0              552              382              567 
##       banios_out habitaciones_out         piso_out 
##               72              888              297
bad_idx <- with(df_master_base,
                which(!is.finite(preciom) | preciom <= 0 |
                      !is.finite(areaconst) | areaconst <= 0))
length(bad_idx)  # ¿cuántos?
## [1] 0
# vistazo
head(df_master_base[bad_idx, c("id","tipo","estrato","zona","preciom","areaconst")])
## # A tibble: 0 × 6
## # ℹ 6 variables: id <dbl>, tipo <chr>, estrato <dbl>, zona <chr>,
## #   preciom <dbl>, areaconst <dbl>

1 PCA Analisis de componentes Principales

Realiazamos los siguientes pasos:

  1. Tratamiento de outliers

Usamos Winsorizing con IQR:

Usamos log1p() para comprimir la escala de valores grandes, pero sin afectar tanto los valores pequeños, con esto evitamos que el precio o area de “domine” la varianza sobre uno menor o mayor ya que estas variables tienden a estar sesgadas.

  1. Estandarización de todas las variables numéricas

Usamos scale() para que cada variable tenga: Media = 0, Desviación estándar = 1. Esto ya que el PCA se basa en varianzas y se deben estandarizar para que variables con valores mas grandes no dominen a las que tienen rangos mas pequeños.

# Reiniciar SIEMPRE desde el maestro limpio
df_pca <- df_master_base[, c("estrato","preciom","areaconst",
                             "parqueaderos","banios","habitaciones")]

# --- Winsor para conteos (IQR) ---
winsor_iqr <- function(x){
  q1 <- quantile(x, 0.25, na.rm = TRUE); q3 <- quantile(x, 0.75, na.rm = TRUE)
  iqr <- q3 - q1; li <- q1 - 1.5*iqr; ls <- q3 + 1.5*iqr
  pmin(pmax(x, li), ls)
}
df_pca$parqueaderos <- winsor_iqr(df_pca$parqueaderos)
df_pca$banios       <- winsor_iqr(df_pca$banios)
df_pca$habitaciones <- winsor_iqr(df_pca$habitaciones)
# (estrato no se toca aquí)

# --- Log a variables sesgadas ---
df_pca$preciom   <- log1p(df_pca$preciom)     # log(1 + x)
df_pca$areaconst <- log1p(df_pca$areaconst)

# --- Escalar todo (media 0, sd 1) ---
df_pca[] <- scale(df_pca)

Aplicamos PCA

pca_res <- prcomp(df_pca, center = FALSE, scale. = FALSE)

Varianza explicada

# Tabla compacta
var_exp <- pca_res$sdev^2
prop    <- var_exp / sum(var_exp)
cumprop <- cumsum(prop)
round(rbind(StdDev = pca_res$sdev,
            PropVar = prop,
            CumVar  = cumprop), 4)
##           [,1]   [,2]   [,3]   [,4]   [,5]   [,6]
## StdDev  1.9571 1.0834 0.6160 0.5318 0.4907 0.3051
## PropVar 0.6383 0.1956 0.0632 0.0471 0.0401 0.0155
## CumVar  0.6383 0.8340 0.8972 0.9444 0.9845 1.0000
# Scree (usa factoextra si lo tienes)
# install.packages("factoextra") # si falta
library(factoextra)
fviz_eig(pca_res, addlabels = TRUE)

PC1 = 63.8%, PC2 = 19.6%, con estos dos componentes ya explicamos el 83.4 de variación total en los precios.

Cargas

loadings <- pca_res$rotation
# Top aportantes en PC1 y PC2
sort(loadings[,1], decreasing = TRUE)  # PC1
##      preciom    areaconst       banios parqueaderos      estrato habitaciones 
##    0.4734284    0.4519489    0.4465318    0.4131081    0.3250900    0.3096373
sort(loadings[,2], decreasing = TRUE)  # PC2
## habitaciones    areaconst       banios      preciom parqueaderos      estrato 
##    0.6623422    0.2284640    0.1784501   -0.1912170   -0.2301237   -0.6226891
# (opcional) contribución visual por eje
fviz_contrib(pca_res, choice = "var", axes = 1, top = 6)

fviz_contrib(pca_res, choice = "var", axes = 2, top = 6)

PC1 está armado sobre todo por precio, área, baños y parqueaderos PC2 está dominado por Habitaciones y estrato

# Solo variables (más limpio)
fviz_pca_var(pca_res, repel = TRUE)

# Variables + individuos (si no se satura tu plot)
fviz_pca_biplot(pca_res, repel = TRUE, alpha.ind = 0.2)

2 Clustering

Tratamiento de outliers y escalar:

library(dplyr)

# Partimos del maestro
df_for_clust <- df_master_base %>%
  mutate(row_id = row_number()) %>%
  select(row_id, id, tipo, zona, barrio,
         estrato, preciom, areaconst, parqueaderos, banios, habitaciones)

# Función winsor IQR (suaviza extremos en conteos)
winsor_iqr <- function(x){
  q1 <- quantile(x, 0.25, na.rm = TRUE); q3 <- quantile(x, 0.75, na.rm = TRUE)
  iqr <- q3 - q1; li <- q1 - 1.5*iqr; ls <- q3 + 1.5*iqr
  pmin(pmax(x, li), ls)
}

# Variables numéricas para k-means (mismas que en PCA)
X <- df_for_clust %>%
  select(estrato, preciom, areaconst, parqueaderos, banios, habitaciones)

# Tratamiento suave de outliers + log a sesgadas
X$parqueaderos <- winsor_iqr(X$parqueaderos)
X$banios       <- winsor_iqr(X$banios)
X$habitaciones <- winsor_iqr(X$habitaciones)
X$preciom      <- log1p(X$preciom)
X$areaconst    <- log1p(X$areaconst)

# Escalar (guardamos atributos para luego des-escalar centros)
X_mat <- scale(X)                  # matriz con atributos
X_sc  <- as.data.frame(X_mat)      # data frame para usar en funciones

# Chequeos
sapply(X_sc, function(x) sum(!is.finite(x)))  # debe ser todo 0
##      estrato      preciom    areaconst parqueaderos       banios habitaciones 
##            0            0            0            0            0            0

Se realiza el paso anterior ya que en el escenario de clustering si una variable tiene números grandes, dominaría el resultado.

Elegir K (Numero de clusters)

Elegimos K pro metodo codo y Silhouette

library(factoextra)

# --- Método del codo (WSS) ---
set.seed(123)
fviz_nbclust(X_sc, kmeans, method = "wss") +
  labs(title = "Método del codo (WSS)")

# --- Método de la silueta ---
set.seed(123)
fviz_nbclust(X_sc, kmeans, method = "silhouette") +
  labs(title = "Silhouette promedio")

Probamos con k=3 y K=4 ya que k=2 podria ser poco detallado yt esconder subconjuntos en él.

Entrenamos Kmeans con K=3

library(factoextra)
library(cluster)
set.seed(123)

k <- 3
km3 <- kmeans(X_sc, centers = k, nstart = 50, iter.max = 100)

# Silhouette del modelo elegido
sil3 <- silhouette(km3$cluster, dist(X_sc))
mean_sil3 <- mean(sil3[, "sil_width"]); mean_sil3
## [1] 0.3449765
# Mapa de clusters
fviz_cluster(km3, data = X_sc, geom = "point", ellipse.type = "norm",
             ggtheme = theme_minimal(), alpha = 0.6)

  • Comparación de k-2 vs k-3 vs k-4
comp_k <- function(k){
  set.seed(123)
  fit <- kmeans(X_sc, centers = k, nstart = 50)
  ms  <- mean(silhouette(fit$cluster, dist(X_sc))[, "sil_width"])
  sizes <- fit$size
  list(k=k, mean_sil=round(ms,3), sizes=sizes, model=fit)
}
res2 <- comp_k(2); res3 <- comp_k(3); res4 <- comp_k(4)
res2[c("k","mean_sil","sizes")]
## $k
## [1] 2
## 
## $mean_sil
## [1] 0.388
## 
## $sizes
## [1] 4805 3514
res3[c("k","mean_sil","sizes")]
## $k
## [1] 3
## 
## $mean_sil
## [1] 0.345
## 
## $sizes
## [1] 4345 1448 2526
res4[c("k","mean_sil","sizes")]
## $k
## [1] 4
## 
## $mean_sil
## [1] 0.319
## 
## $sizes
## [1] 3251 1454 2430 1184
  • Se elige k-3 ya que genera segmentos claros y manejables con una separación aceptable

Perfilamos (numeros + Categorías)

# 1) Añadir etiqueta de cluster al dataset original
df_clusters <- df_for_clust %>%
  mutate(cluster = factor(km3$cluster))

# 2) Resumen numérico por cluster (medianas en unidades originales)
perfil_num <- df_clusters %>%
  group_by(cluster) %>%
  summarise(
    n            = n(),
    precio_med   = median(preciom, na.rm = TRUE),
    area_med     = median(areaconst, na.rm = TRUE),
    estrato_med  = median(estrato, na.rm = TRUE),
    banios_med   = median(banios, na.rm = TRUE),
    parq_med     = median(parqueaderos, na.rm = TRUE),
    hab_med      = median(habitaciones, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  arrange(desc(area_med))
perfil_num
## # A tibble: 3 × 8
##   cluster     n precio_med area_med estrato_med banios_med parq_med hab_med
##   <fct>   <int>      <dbl>    <dbl>       <dbl>      <dbl>    <dbl>   <dbl>
## 1 3        2526        690      234           6          4        2       4
## 2 2        1448        360      225           4          4        1       5
## 3 1        4345        230       82           4          2        1       3
# (Opcional) centros del k-means en unidades reales (promedios)
cent_scaled   <- km3$centers
cent_unscaled <- sweep(cent_scaled, 2, attr(X_mat, "scaled:scale"), "*")
cent_unscaled <- sweep(cent_unscaled, 2, attr(X_mat, "scaled:center"), "+")
cent_unscaled[,"preciom"]   <- expm1(cent_unscaled[,"preciom"])
cent_unscaled[,"areaconst"] <- expm1(cent_unscaled[,"areaconst"])
round(cent_unscaled, 2)
##   estrato preciom areaconst parqueaderos banios habitaciones
## 1    4.26  217.30     83.58         1.18   2.13         2.84
## 2    3.96  367.42    224.01         1.42   3.75         4.74
## 3    5.66  730.17    244.59         2.52   4.38         3.82
# 3) Distribución por tipo y zona (porcentajes dentro de cada cluster)
dist_tipo <- df_clusters %>%
  count(cluster, tipo) %>%
  group_by(cluster) %>% mutate(pct = round(100*n/sum(n),1)) %>% ungroup()
dist_tipo
## # A tibble: 6 × 4
##   cluster tipo            n   pct
##   <fct>   <chr>       <int> <dbl>
## 1 1       Apartamento  3664  84.3
## 2 1       Casa          681  15.7
## 3 2       Apartamento   184  12.7
## 4 2       Casa         1264  87.3
## 5 3       Apartamento  1252  49.6
## 6 3       Casa         1274  50.4
dist_zona <- df_clusters %>%
  count(cluster, zona) %>%
  group_by(cluster) %>% mutate(pct = round(100*n/sum(n),1)) %>% ungroup()
dist_zona
## # A tibble: 15 × 4
##    cluster zona             n   pct
##    <fct>   <chr>        <int> <dbl>
##  1 1       Zona Centro     50   1.2
##  2 1       Zona Norte    1200  27.6
##  3 1       Zona Oeste     352   8.1
##  4 1       Zona Oriente   160   3.7
##  5 1       Zona Sur      2583  59.4
##  6 2       Zona Centro     72   5  
##  7 2       Zona Norte     344  23.8
##  8 2       Zona Oeste      77   5.3
##  9 2       Zona Oriente   190  13.1
## 10 2       Zona Sur       765  52.8
## 11 3       Zona Centro      2   0.1
## 12 3       Zona Norte     376  14.9
## 13 3       Zona Oeste     769  30.4
## 14 3       Zona Oriente     1   0  
## 15 3       Zona Sur      1378  54.6

Graficas de composicion de perfilados

library(ggplot2)

ggplot(dist_tipo, aes(x = cluster, y = pct, fill = tipo)) +
  geom_col(position = "fill") +
  scale_y_continuous(labels = scales::percent_format(scale = 1)) +
  labs(title = "Composición por TIPO dentro de cada cluster", x = "Cluster", y = "%") +
  theme_minimal()

ggplot(dist_zona, aes(x = cluster, y = pct, fill = zona)) +
  geom_col(position = "fill") +
  scale_y_continuous(labels = scales::percent_format(scale = 1)) +
  labs(title = "Composición por ZONA dentro de cada cluster", x = "Cluster", y = "%") +
  theme_minimal()

  • El mapa de clusters muestra buena separación (silhouette = 0.345 es aceptable).

    • Cluster 1 se ubica a la izquierda del plano (menor PC1 = más pequeño/menor precio).

    • Cluster 3 a la derecha (mayor PC1 = grande/caro y mayor estrato).

    • Cluster 2 queda intermedio en precio pero alto en numero de habitaciones (familiar).

Explicación de clusters:

  • Cluster 1 pequeño

    • Tamaño del grupo: 4,345 52%.

    • Producto típico: 84% apartamentos.

    • Perfil numérico: área 84 m², precio 217 Millones, estrato 4, 2 baños, 1 parqueadero, 3 habitaciones.

    • Zonas más frecuentes: Sur 59% y Norte 28%.

Este cluster es de unidades pequeñas y de ticket bajo/medio, compradores sensibles al precio.Se sugieren paquetes de financiación y cierre rápid, les interesa entre 2–3 habitaciones y 1 parqueadero. Ideal generar campañas masivas en Zona Sur y Norte, estas viviendas son de rotación alta.

  • Cluster 2 — Familiar amplio (mayoría casas)

    • Tamaño del grupo: 1,448 17%.

    • Producto típico: 87% casas.

    • Perfil numérico: área 224 m², precio 360 Millones, estrato 4, 4 baños, 1 o 2 parqueaderos, 5 habitaciones.

    • Zonas más frecuentes: Sur 53%, Norte 24%, Oriente 13%.

Este cluster sugiere una vivienda familiar grande, ticket medio, patio y más dormitorios pesan mucho en la decisión. Se enfatiza en el numero de habitaciones y espacios sociale. Mejora en cocina y patio. Se puede llegar con campañas dirigidas a familias en Sur y Norte

  • Cluster 3 — Premium alto estrato (grande y caro)

    • Tamaño del grupo: 2,526 30%.

    • Producto típico: mixto 50% apartamento y 50% casa.

    • Perfil numérico: área 235–245 m², precio 690–730 M, estrato 6, 4 baños, 2–3 parqueaderos, 4 habitaciones.

    • Zonas más frecuentes: Sur 55%, Oeste 30% y algo de Norte 15%.

Portafolio premium: alto valor agregado y menor elasticidad al precio. Se destacan mas espacios, seguridad, acabados.

Cluster por estrato

# % de estrato dentro de cada cluster
dist_estrato <- df_clusters %>%
  count(cluster, estrato) %>%
  group_by(cluster) %>%
  mutate(pct = round(100*n/sum(n),1)) %>%
  ungroup()

dist_estrato
## # A tibble: 12 × 4
##    cluster estrato     n   pct
##    <fct>     <dbl> <int> <dbl>
##  1 1             3   941  21.7
##  2 1             4  1567  36.1
##  3 1             5  1593  36.7
##  4 1             6   244   5.6
##  5 2             3   510  35.2
##  6 2             4   498  34.4
##  7 2             5   429  29.6
##  8 2             6    11   0.8
##  9 3             3     2   0.1
## 10 3             4    64   2.5
## 11 3             5   728  28.8
## 12 3             6  1732  68.6
# (opcional) gráfico apilado
library(ggplot2)
ggplot(dist_estrato, aes(x = cluster, y = pct, fill = factor(estrato))) +
  geom_col(position = "fill") +
  scale_y_continuous(labels = scales::percent_format(scale = 1)) +
  labs(title = "Composición por ESTRATO en cada cluster", x = "Cluster", y = "%", fill = "Estrato") +
  theme_minimal()

Diferencias entre clusters (Kruskal–Wallis)

# pruebas no paramétricas por variable clave
vars_test <- c("preciom","areaconst","estrato","banios","parqueaderos","habitaciones")
kw <- lapply(vars_test, function(v){
  f <- as.formula(paste(v, "~ cluster"))
  list(var=v, test=kruskal.test(f, data = df_clusters))
})
# ver p-values
sapply(kw, function(x) paste0(x$var, ": p=", signif(x$test$p.value, 3)))
## [1] "preciom: p=0"      "areaconst: p=0"    "estrato: p=0"     
## [4] "banios: p=0"       "parqueaderos: p=0" "habitaciones: p=0"

Los tres clusters son estadisticamente diferentes, representan patrones reales y representan perfiles diferentes.. Los perfiles tienen sentido, son de tamaño razonable y funcionan para segmentar y tomar decisiones sobre que ofrecer y donde.

3 Análisis de correspondencia CA

Limpiamos el dataframe master sin outliers y solo con las varaibles categoricas que vamos a utilziar

library(dplyr)

# 0.1) Nos quedamos con las variables categóricas necesarias
df_ca <- df_master_base %>%
  select(tipo, zona, barrio) %>%
  filter(!is.na(tipo), !is.na(zona), !is.na(barrio))

# 0.2) Agrupamos barrios con muy pocos casos a "Otros" para que el mapa sea legible
umbral <- 30  # ajusta si salen demasiadas categorías
barrios_ok <- df_ca %>% count(barrio, sort = TRUE) %>% filter(n >= umbral) %>% pull(barrio)

df_ca <- df_ca %>%
  mutate(barrio2 = if_else(barrio %in% barrios_ok, barrio, "Otros"))

TIPO × ZONA: tabla + Chi Cuadrado + heatmap (residuales)

  • Vamos a tener una visión macro del mercado, en que zona se concetra la oferta
library(ggplot2)

# 1.1) Tabla de frecuencias tipo × zona
tab_tz <- table(df_ca$tipo, df_ca$zona)
tab_tz
##              
##               Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
##   Apartamento          24       1198       1029           62     2787
##   Casa                100        722        169          289     1939
# 1.2) Prueba de independencia chi-cuadrado
chi_tz <- chisq.test(tab_tz)
chi_tz$p.value   # p < 0.05 => hay asociación (no independencia)
## [1] 3.207561e-148
# 1.3) Heatmap de residuales estandarizados (más/menos de lo esperado)
res_tz <- as.data.frame(as.table(chi_tz$stdres))
names(res_tz) <- c("tipo","zona","stdres")

ggplot(res_tz, aes(x = zona, y = tipo, fill = stdres)) +
  geom_tile(color = "white") +
  scale_fill_gradient2(low = "#d73027", mid = "white", high = "#1a9850", midpoint = 0) +
  labs(title = "Residuales estandarizados (χ²) — Tipo × Zona",
       x = "Zona", y = "Tipo", fill = "Std. resid") +
  theme_minimal()

Se evidencia relación entre tipo de vivienda y zona (no son independientes). Existe una asociación muy significativa entre tipo y zona. Los apartamentos se concentran más en Zona Oeste y menos en Centro,Oriente, Sur. Las casas aparecen más de lo esperado en Centro,Oriente,Sur y menos en Oeste.

CA para tipo x zona

# --- CA: Tipo × Zona (1 dimensión) ---

library(dplyr)
library(ggplot2)
library(FactoMineR)

# 1) Tabla de contingencia
tab_tz <- with(df_master, table(tipo, zona))
print(tab_tz)
##              zona
## tipo          Zona Centro Zona Norte Zona Oeste Zona Oriente Zona Sur
##   Apartamento          24       1198       1029           62     2787
##   Casa                100        722        169          289     1939
# 2) CA (sin gráficos automáticos)
ca_tz <- CA(tab_tz, graph = FALSE)
ca_tz$eig                # ver inercia; habrá solo "Dim 1"
##       eigenvalue percentage of variance cumulative percentage of variance
## dim 1 0.08305442                    100                               100
# 3) Filas (TIPO) - coord 1D robusto
row_coord <- ca_tz$row$coord                # puede ser vector o matriz
row_lab   <- if (!is.null(rownames(row_coord))) rownames(row_coord) else names(row_coord)
row_df <- data.frame(
  tipo = row_lab,
  Dim1 = as.numeric(drop(row_coord)),       # <- aquí la magia
  row.names = NULL
)

ggplot(row_df, aes(x = Dim1, y = tipo, color = tipo, label = tipo)) +
  geom_point(size = 3) +
  geom_vline(xintercept = 0, linetype = 3) +
  geom_text(nudge_x = 0.03, show.legend = FALSE) +
  scale_color_manual(values = rep("#2E86AB", nrow(row_df))) +
  labs(title = "CA Tipo × Zona — Filas (TIPO) en Dim 1", x = "Dim 1", y = NULL) +
  theme_minimal()

# 4) Columnas (ZONA) - coord 1D robusto
col_coord <- ca_tz$col$coord
col_lab   <- if (!is.null(rownames(col_coord))) rownames(col_coord) else names(col_coord)
col_df <- data.frame(
  zona = col_lab,
  Dim1 = as.numeric(drop(col_coord)),
  row.names = NULL
)

ggplot(col_df, aes(x = Dim1, y = zona, color = zona, label = zona)) +
  geom_point(size = 3) +
  geom_vline(xintercept = 0, linetype = 3) +
  geom_text(nudge_x = 0.03, show.legend = FALSE) +
  labs(title = "CA Tipo × Zona — Columnas (ZONA) en Dim 1", x = "Dim 1", y = NULL) +
  theme_minimal()

# 5) (Opcional) Mapa 1D conjunto
rows <- row_df %>% transmute(etiqueta = tipo, Dim1, grupo = "Tipo")
cols <- col_df %>% transmute(etiqueta = zona, Dim1, grupo = "Zona")
both <- bind_rows(rows, cols)

ggplot(both, aes(x = Dim1, y = 0, color = grupo, label = etiqueta)) +
  geom_point(size = 3) +
  geom_vline(xintercept = 0, linetype = 3) +
  geom_text(nudge_y = 0.05, show.legend = FALSE) +
  scale_color_manual(values = c("Tipo" = "#2E86AB", "Zona" = "#E67E22")) +
  labs(title = "CA Tipo × Zona — Mapa 1D (Dim 1)", x = "Dim 1", y = NULL) +
  theme_minimal() +
  theme(axis.text.y = element_blank(),
        axis.ticks.y = element_blank(),
        panel.grid.major.y = element_blank())

Análisis:

  • Hay relación significativa entre tipo de vivienda y zona, Casas están relativamente concentradas en Centro y Oriente. Apartamentos están relativamente concentrados en Oeste y, en menor medida, Norte. Sur muestra una mezcla mezcla

CA TIPO × BARRIO: Tabla + Chi Cuadrado + heatmap (residuales)

Vamos a tener una visión mas detallada del mercado, lo validamos en concentración por barrios

# Tipo x Barrio por ZONA (Top-k)
# ===============================

library(dplyr)
library(ggplot2)
library(forcats)
library(scales)
library(ragg)   # salida nítida y segura en UTF-8

# Parámetros
thr   <- 50   # mínimo de registros por barrio
top_k <- 15   # top-k barrios por zona por mayor |desviación|

# Base limpia
base <- df_master %>%
  mutate(
    barrio = trimws(barrio),
    barrio = if_else(is.na(barrio) | barrio == "", "Sin barrio", barrio)
  ) %>%
  filter(!is.na(zona), !is.na(tipo))

# Conteos por zona-barrio-tipo
cont <- base %>% count(zona, barrio, tipo, name = "n")

# Totales por barrio
tot_barrio <- cont %>%
  group_by(zona, barrio) %>%
  summarise(n_total = sum(n), .groups = "drop")

# % de Casas por barrio (solo barrios con n_total >= thr)
p_casa_barrio <- cont %>%
  filter(tipo == "Casa") %>%
  right_join(tot_barrio, by = c("zona", "barrio")) %>%
  mutate(
    n = replace_na(n, 0L),
    p_casa = n / n_total
  ) %>%
  filter(n_total >= thr)

# % de Casas promedio ponderado por zona
p_casa_zona <- p_casa_barrio %>%
  group_by(zona) %>%
  summarise(p_casa_zona = weighted.mean(p_casa, w = n_total), .groups = "drop")

# Desviación y Top-k por zona
plot_df <- p_casa_barrio %>%
  left_join(p_casa_zona, by = "zona") %>%
  mutate(
    dev   = p_casa - p_casa_zona,
    # <- SIN ACENTOS para evitar el crash del viewer
    quien = if_else(dev >= 0, "Mas Casa", "Mas Apartamento")
  ) %>%
  group_by(zona) %>%
  slice_max(order_by = abs(dev), n = top_k, with_ties = FALSE) %>%
  mutate(barrio = fct_reorder(barrio, dev)) %>%
  ungroup()

# Gráfico tipo lollipop por zona
p <- ggplot(plot_df, aes(x = dev, y = barrio, color = quien)) +
  geom_segment(aes(x = 0, xend = dev, yend = barrio), linewidth = 1, alpha = 0.9) +
  geom_point(size = 2.6) +
  facet_wrap(~ zona, scales = "free_y") +
  geom_vline(xintercept = 0, linetype = 3) +
  scale_color_manual(values = c("Mas Casa" = "#2ecc71", "Mas Apartamento" = "#e74c3c")) +
  scale_x_continuous(labels = percent_format(accuracy = 1)) +
  labs(
    title    = "Tipo × Barrio por zona — desviación de % Casas vs promedio de la zona",
    subtitle = paste0("Se muestran los ", top_k,
                      " barrios con mayor |desviación| por zona (n ≥ ", thr, ")."),
    x = "Desviación de % Casas (barrio − media zonal)",
    y = "Barrio",
    color = NULL
  ) +
  theme_minimal(base_size = 11) +
  theme(
    legend.position      = "bottom",
    panel.grid.major.y   = element_blank(),
    panel.grid.minor     = element_blank()
  )

# ==== OPCIÓN A (recomendada): solo guardar con ragg (sin imprimir al viewer) ====
ragg::agg_png("tipo_x_barrio_por_zona.png", width = 1400, height = 900, res = 150)
print(p)
dev.off()
## png 
##   2
# ==== OPCIÓN B (si quieres ver en pantalla) ====
# Como ya quitamos acentos, esto suele funcionar:

print(p)

Zona Norte

  • Foco Casas (verde fuerte): Villa del Prado (muy por encima del promedio), seguido por Acopi. Un buen lugar paraproyectos de casas y campañas con mensajes de “casas con patio y mas espacio para la familia”.

  • Foco Apartamentos (rojo): Torres de Comfandi, Versalles, Prados del Norte, La Flora. Zonas con mayor demanda por apartamentos: torres, con precios competitivo.

Zona Oeste

Bajo perfil para Casas: Cristales, Los Cristales, Aguacatal.

Bajo perfil para Apartamentos: El Peñón, Santa Teresita, Normandía.

Para esta zona conviene segmentar por estrato y precio además del tipo, porque la preferencia por producto no es tan extrema como en Norte o Sur.

Zona Sur

-Foco Casas: Ciudad 2000, Parcelaciones Pance, Ciudad Jardín, Pance, Nuevo Tequendama, El Limonar. -Se puede generar oferta con tickets altos para Pance y Ciudad Jardín.

  • Foco Apartamentos: Valle del Lili, Quintas de Don, Meléndez, Capri, Caney, La Hacienda, El Caney, El Lido. Para apartamentos en estos barrios funcionan viviendas de 2-3 habitacionnes, torre con servicios y financiación flexible.

RESUMEN

  • El comportamiento del mercado se explica sobre todo por tamaño/valor de la vivienda (área, precio, baños, parqueaderos) y, en segundo lugar, por habitaciones/estrato.

  • Se identificaron tres segmentos consistentes y útiles para el análisis.

  • Hay zonas y barrios donde se favorece claramente la venta de casas y otras donde predominan los apartamentos. Alinear producto y geografía mejora velocidad de venta y margen.

Estructura de los datos

PCA

  • Componente 1 (63.8% de la varianza): representa tamaño/valor. Aporta más precio, área, baños y parqueaderos.

  • Componente 2 (19.6%): diferencia composición/estrato, con mayor peso de habitaciones y estrato.

Lo que más separa las viviendas es su tamaño/valor, después, su perfil familiar y estrato.

Clusterización

  • Apartamentos medianos (Cluster 1): tamaño: 83–90 m², No de habitaciones: 2-3, baños: 2, 1 parqueadero, estrato 4, precio medio 220 a 370. Ruerte presencia en zona Sur 59% y Norte 28%.

  • Casas familiares (Cluster 2): Tamaño: 220–225 m², No de habitaciones: 4–5, 4 baños, 1 o 2 parqueaderos, estrato 4, precio alto 360.Mayor presencia en Sur 53%, en el Norte 24% y Oriente 13%.

  • Alto valor (Cluster 3): Tamaño: 240–250 m²,4 baños, mas de 2 parqueaderos, estrato alto 5–6, precio alto promedio o superior a 730. 50% casas y 50% apartamentos, fuerte en Sur 55% y Oeste 30%.

Tipo x Zona (Chi Cuadrado)

  • Casas: Ubicadas en zonas Centro y Oriente.
  • Apartamentos: Ubicados en zona Sur, Norte y Oeste.

La probabilidad de encontrar más casas o más apartamentos depende de la zona.

Tipo x barro

  • Zona Norte

Casas: Villa del Prado, Acopi.

Apartamentos: Torres de Comfandi, Versalles, Prados del Norte.

  • Zona Oeste

Casas: Cristales, Los Cristales, Aguacatal.

Apartamentos: El Peñón, Santa Teresita, Normandía.

  • Zona Sur

Casas: Ciudad 2000, Parcelaciones Pance, Ciudad Jardín, Pance, El Limonar.

Apartamentos: Valle del Lili, Caney, Meléndez, Capri, La Hacienda.

RECOMENDACIONES

  • Para organizar el protafolio es importante priorizar zona por tipo de vivienda y estrato. Si son casas priorizar zona sur en barrios como pance, ciudad jardin, limonar y ciudad 2000 o en el Norte barrios como villa del prado y acopi y en la zona Oeste barrio cristales

  • Si son apartamentos, tambien se prioriza en zona sur pero en barrios como Valle de lili, caney, melendez, capri. en el norte barrios como Torres de comfandi, Versalles, ¨Prados norte. En el Oeste barrios como El peñon y santa teresita.

  • Si se quiere priorizar o vender viviendas de alto valor, se debe priorizar la zona Sur y Oeste.