1 0. Resumen ejecutivo (1 página)

Resumen para dirección

Cobertura del análisis. Se revisaron 8322 inmuebles; tras el saneo de calidad quedaron 6694 (depuración del 19.6% por reglas simples y control de valores extremos).

Qué mueve el precio. El factor más importante está asociado a tamaño y calidad del inmueble (área, baños, habitaciones, parqueaderos) y explica 50.2% de las diferencias observadas entre propiedades (detalle técnico en el anexo).

Segmentación del mercado. Identificamos 3 grupos operativos con niveles de precio y tamaño claramente diferenciados.
- Mediana de precio por grupo (Millones COP): 470 / 1000 / 250
- Mediana de área (m²): 180 / 360 / 85

Zonas de mayor valor. Top-3 por precio mediano: Zona Oeste, Zona Sur, Zona Norte.

Qué hacer ahora (resumen).
- Ajustar y revisar precios usando los segmentos detectados.
- Enfocar compras y campañas en zonas y tipos de vivienda que mejor funcionan.
- Mantener una oferta equilibrada entre segmentos para no depender de uno solo.
- Mejorar la calidad del dato (área > 10 m², estrato 1–6 y el piso escrito de forma consistente).

2 1. Contexto y objetivos

Este informe analiza la base de datos paqueteMODELOS::vivienda para entender patrones del mercado inmobiliario urbano: limpieza y trazabilidad de datos, EDA, ACP (PCA), Clustering, Análisis de Correspondencias (CA/MCA) y visualizaciones para comunicar hallazgos y generar estrategias para la dirección.

3 2. Carga de datos y paquetes

# Si faltan, instalar antes de knit (descomentando):
# install.packages(c("tidyverse","janitor","skimr","naniar","scales","GGally",
#                    "corrplot","factoextra","FactoMineR","NbClust","cluster",
#                    "leaflet","gridExtra","viridis","broom"))

library(tidyverse)
library(janitor)
library(skimr)
library(naniar)
library(scales)
library(GGally)
library(corrplot)
library(factoextra)
library(FactoMineR)
library(NbClust)
library(cluster)
library(leaflet)
library(gridExtra)
library(viridis)
library(broom)

library(paqueteMODELOS)
data("vivienda")

# Estandarizar nombres
vivienda <- vivienda |> janitor::clean_names()
glimpse(vivienda)
## Rows: 8,322
## Columns: 13
## $ id           <dbl> 1147, 1169, 1350, 5992, 1212, 1724, 2326, 4386, 1209, 159…
## $ zona         <chr> "Zona Oriente", "Zona Oriente", "Zona Oriente", "Zona Sur…
## $ piso         <chr> NA, NA, NA, "02", "01", "01", "01", "01", "02", "02", "02…
## $ estrato      <dbl> 3, 3, 3, 4, 5, 5, 4, 5, 5, 5, 6, 4, 5, 6, 4, 5, 5, 4, 5, …
## $ preciom      <dbl> 250, 320, 350, 400, 260, 240, 220, 310, 320, 780, 750, 62…
## $ areaconst    <dbl> 70, 120, 220, 280, 90, 87, 52, 137, 150, 380, 445, 355, 2…
## $ parqueaderos <dbl> 1, 1, 2, 3, 1, 1, 2, 2, 2, 2, NA, 3, 2, 2, 1, 4, 2, 2, 2,…
## $ banios       <dbl> 3, 2, 2, 5, 2, 3, 2, 3, 4, 3, 7, 5, 6, 2, 4, 4, 4, 3, 2, …
## $ habitaciones <dbl> 6, 3, 4, 3, 3, 3, 3, 4, 6, 3, 6, 5, 6, 2, 5, 5, 4, 3, 3, …
## $ tipo         <chr> "Casa", "Casa", "Casa", "Casa", "Apartamento", "Apartamen…
## $ barrio       <chr> "20 de julio", "20 de julio", "20 de julio", "3 de julio"…
## $ longitud     <dbl> -76.51168, -76.51237, -76.51537, -76.54000, -76.51350, -7…
## $ latitud      <dbl> 3.43382, 3.43369, 3.43566, 3.43500, 3.45891, 3.36971, 3.4…

4 3. Estado de variables e inconsistencias

Variables:
Numéricas – preciom, areaconst, estrato, parqueaderos, banios, habitaciones.
Categóricas – zona, tipo, barrio, piso (texto).
Geográficas – latitud, longitud.

4.1 3.1 Faltantes, tipos y duplicados

naniar::miss_var_summary(vivienda) |> head(10)
tibble(variable = names(vivienda), clase = sapply(vivienda, class))
# Duplicados por id
vivienda |> count(id) |> filter(n > 1) |> nrow()
## [1] 1

4.2 3.2 Limpieza y depuración

winsor_iqr <- function(x, k = 3) {
  q <- quantile(x, probs = c(0.25, 0.75), na.rm = TRUE)
  iqr <- q[2] - q[1]; low <- q[1] - k * iqr; high <- q[2] + k * iqr
  pmin(pmax(x, low), high)
}

vivienda_limp <- vivienda |>
  mutate(
    piso_num = suppressWarnings(readr::parse_number(piso)),
    zona = as.factor(zona),
    tipo = as.factor(tipo),
    barrio = as.factor(barrio)
  ) |>
  filter(
    !is.na(preciom), preciom > 0,
    !is.na(areaconst), areaconst > 10,
    estrato %in% 1:6, banios >= 0,
    habitaciones >= 1, parqueaderos >= 0
  ) |>
  mutate(
    preciom_w = winsor_iqr(preciom, 3),
    areaconst_w = winsor_iqr(areaconst, 3),
    banios_w = winsor_iqr(banios, 3),
    habitaciones_w = winsor_iqr(habitaciones, 3),
    parqueaderos_w = winsor_iqr(parqueaderos, 3)
  )

n_raw <- nrow(vivienda); n_clean <- nrow(vivienda_limp)
tribble(~Etapa, ~Filas, "Original", n_raw, "Limpieza", n_clean)

5 4. Análisis exploratorio de datos (EDA)

5.1 4.1 Descriptivos

skimr::skim(vivienda_limp |> select(preciom, areaconst, estrato, banios, habitaciones, parqueaderos, piso_num))
Data summary
Name select(…)
Number of rows 6694
Number of columns 7
_______________________
Column type frequency:
numeric 7
________________________
Group variables None

Variable type: numeric

skim_variable n_missing complete_rate mean sd p0 p25 p50 p75 p100 hist
preciom 0 1.00 468.68 335.05 58 246.5 355 580 1999 ▇▃▁▁▁
areaconst 0 1.00 180.96 144.03 30 86.0 130 232 1745 ▇▁▁▁▁
estrato 0 1.00 4.83 0.95 3 4.0 5 6 6 ▂▅▁▇▆
banios 0 1.00 3.26 1.38 0 2.0 3 4 10 ▆▇▃▁▁
habitaciones 0 1.00 3.62 1.35 1 3.0 3 4 10 ▁▇▂▁▁
parqueaderos 0 1.00 1.83 1.12 1 1.0 2 2 10 ▇▁▁▁▁
piso_num 1903 0.72 3.88 2.67 1 2.0 3 5 12 ▇▃▁▁▁

5.2 4.2 Distribuciones y relaciones

vivienda_limp |>
  select(preciom_w, areaconst_w, estrato, banios_w, habitaciones_w, parqueaderos_w) |>
  pivot_longer(everything(), names_to = "variable", values_to = "valor") |>
  ggplot(aes(valor)) +
  geom_histogram(bins = 30) +
  facet_wrap(~ variable, scales = "free") +
  labs(title = "Distribuciones (winsorizadas)", x = NULL, y = "Frecuencia")

Comentario (EDA – distribuciones).
La gráfica muestra que las variables se concentran en rangos medios y aparecen algunos valores altos, propios del mercado (precios y áreas grandes). Tras la limpieza no vemos valores imposibles (como áreas negativas), lo que da confianza para usar estos datos en decisiones.

vivienda_limp |>
  ggplot(aes(x = factor(estrato), y = preciom_w)) +
  geom_boxplot() +
  scale_y_continuous(labels = scales::label_number(scale_cut = scales::cut_short_scale())) +
  labs(title = "Precio por estrato", x = "Estrato", y = "Precio (millones)")

Comentario (Precio por estrato).
La gráfica muestra que el precio sube a medida que aumenta el estrato. En los estratos altos el precio es mayor y suele ser más estable; en los bajos hay más variación y valores menores.

vivienda_limp |>
  ggplot(aes(areaconst_w, preciom_w, color = factor(estrato))) +
  geom_point(alpha = 0.5) +
  scale_y_continuous(labels = scales::label_number(scale_cut = scales::cut_short_scale())) +
  labs(title = "Área vs precio", x = "Área (m²)", y = "Precio (millones)", color = "Estrato")

Comentario (Área vs precio).
La gráfica muestra que a mayor área, mayor precio. Además, los puntos de estratos altos se ubican en la zona de precios superiores, lo que refuerza que tamaño y estrato empujan el valor.

5.3 4.3 Correlaciones

num_mat <- vivienda_limp |>
  select(preciom_w, areaconst_w, estrato, banios_w, habitaciones_w, parqueaderos_w, piso_num) |>
  drop_na() |>
  as.matrix()

cor_mat <- cor(num_mat)
corrplot::corrplot(cor_mat, method = "color", type = "upper", addCoef.col = "black", tl.cex = 0.8)

Comentario (Matriz de correlaciones).
La gráfica muestra que el precio está directamente relacionado con área, baños, habitaciones y parqueaderos; el estrato también aporta en positivo. Son las palancas principales del valor.

5.4 4.4 Mapa interactivo

map_df <- vivienda_limp |> filter(!is.na(latitud), !is.na(longitud))
leaflet(map_df) |>
  addTiles() |>
  addCircleMarkers(~longitud, ~latitud, radius = 4, stroke = FALSE, fillOpacity = 0.6,
                   popup = ~paste0("<b>", tipo, "</b><br>Zona: ", zona,
                                   "<br>Precio: ", scales::number(preciom_w, accuracy = 1), " M",
                                   "<br>Área: ", areaconst_w, " m²"))

Comentario (Mapa).
El mapa permite ubicar la oferta en la ciudad y cruzar ubicación con precio y área. Esto ayuda a priorizar zonas y entender dónde se concentran las propiedades de mayor valor.

6 5. Análisis de Componentes Principales (ACP)

vars_num <- vivienda_limp |>
  select(preciom_w, areaconst_w, estrato, banios_w, habitaciones_w, parqueaderos_w, piso_num) |>
  drop_na()
vars_num_s <- scale(vars_num)

# PCA con precio
pca_con <- prcomp(vars_num_s, center = TRUE, scale. = TRUE)

# PCA sin precio
vars_sin_precio <- vars_num |> select(-preciom_w) |> scale()
pca_sin <- prcomp(vars_sin_precio, center = TRUE, scale. = TRUE)
fviz_eig(pca_con, addlabels = TRUE)

Comentario (Varianza por componente).
La gráfica resume qué tanto aporta cada factor del análisis. Los primeros componentes concentran la mayor parte de la información, por lo que con pocos factores ya entendemos gran parte del comportamiento del mercado.

fviz_pca_biplot(pca_sin, repel = TRUE, col.var = "#2c7fb8", col.ind = "#7fcdbb",
                title = "Biplot PCA (sin precio)")

Comentario (Biplot PCA).
La gráfica posiciona propiedades y variables en dos ejes de sentido económico. El primer eje ordena principalmente por tamaño/calidad (área y amenidades); el segundo diferencia otros rasgos de oferta. Las flechas indican hacia dónde crece cada característica.

cargas <- pca_con$rotation %>%                 # matriz de cargas
  as.data.frame() %>%
  tibble::rownames_to_column("variable") %>%   # pasar nombres de fila a columna
  tidyr::pivot_longer(
    cols = -variable,
    names_to = "component",
    values_to = "value"
  ) %>%
  dplyr::arrange(component, dplyr::desc(abs(value)))

head(cargas, 20)
# Varianza explicada
eig_con <- factoextra::get_eigenvalue(pca_con)
eig_sin <- factoextra::get_eigenvalue(pca_sin)

Resultados PCA (dinámico): - PC1 explica 50.2% (tamaño/calidad: área, baños, habitaciones, parqueaderos) y suele alinearse positivamente con el precio. - PC2 explica 21.2% (contraste de amenidades/verticalidad vs. estrato). - Incluyendo precio en la matriz de variables del PCA, PC1 explica 53.1%.

7 6. Análisis de Conglomerados (Clustering)

vivienda_ix <- vivienda_limp |> mutate(rowid = dplyr::row_number())

clust_df <- vivienda_ix |>
  select(rowid, preciom_w, areaconst_w, estrato, banios_w, habitaciones_w, parqueaderos_w, piso_num) |>
  drop_na()

clust_mat <- scale(clust_df |> select(-rowid))
fviz_nbclust(clust_mat, kmeans, method = "wss") + ggtitle("K óptimo — método del codo")

Comentario (Método del codo).
La gráfica sugiere un punto de equilibrio alrededor de 3 grupos: más segmentos ya no reducen sustancialmente el error y complican la lectura para negocio.

fviz_nbclust(clust_mat, kmeans, method = "silhouette") + ggtitle("K óptimo — silhouette")

Comentario (Silhouette).
La gráfica confirma que el número de grupos elegido separa de forma razonable los perfiles; hay distancia útil entre segmentos.

K <- 3; set.seed(123)
km <- kmeans(clust_mat, centers = K, nstart = 25)

seg_df_full <- vivienda_ix |>
  inner_join(tibble::tibble(rowid = clust_df$rowid, segmento = factor(km$cluster)), by = "rowid")

get_mode <- function(x) names(sort(table(x), decreasing = TRUE))[1]

perfil <- seg_df_full |>
  group_by(segmento) |>
  summarize(
    n = n(),
    precio_mediana = median(preciom_w, na.rm = TRUE),
    area_mediana = median(areaconst_w, na.rm = TRUE),
    estrato_mediana = median(estrato, na.rm = TRUE),
    banios_mediana = median(banios_w, na.rm = TRUE),
    habitaciones_mediana = median(habitaciones_w, na.rm = TRUE),
    parqueaderos_mediana = median(parqueaderos_w, na.rm = TRUE),
    zona_moda = get_mode(zona),
    tipo_moda = get_mode(tipo),
    .groups = 'drop'
  ) |>
  arrange(segmento)
perfil
fviz_cluster(list(data = clust_mat, cluster = km$cluster)) +
  ggtitle("Clusters sobre plano principal (k-means)") + theme_minimal()

Comentario (Clusters).
La gráfica muestra tres segmentos bien diferenciados por tamaño y precio. Cada grupo representa un tipo de oferta con necesidades distintas de precio, abastecimiento y marketing.

8 7. Análisis de Correspondencia (CA/MCA)

tab_tz <- vivienda_limp |>
  count(tipo, zona) |>
  tidyr::pivot_wider(names_from = zona, values_from = n, values_fill = 0) |>
  tibble::column_to_rownames("tipo")

top_barrios <- vivienda_limp |>
  count(barrio, sort = TRUE) |>
  slice_head(n = 30) |>
  pull(barrio)

tab_tb <- vivienda_limp |>
  filter(barrio %in% top_barrios) |>
  count(tipo, barrio) |>
  tidyr::pivot_wider(names_from = barrio, values_from = n, values_fill = 0) |>
  tibble::column_to_rownames("tipo")
# --- CA: Tipo vs Zona (robusto 0D/1D/2D) ---
mat_tz <- vivienda_limp %>%
  count(tipo, zona) %>%
  tidyr::pivot_wider(names_from = zona, values_from = n, values_fill = 0) %>%
  tibble::column_to_rownames("tipo") %>%
  as.matrix()

# limpiar filas/columnas vacías
mat_tz <- mat_tz[rowSums(mat_tz) > 0, , drop = FALSE]
mat_tz <- mat_tz[, colSums(mat_tz) > 0, drop = FALSE]

ndim_tz <- min(nrow(mat_tz) - 1, ncol(mat_tz) - 1)

if (ndim_tz >= 1) {
  ca_tz <- FactoMineR::CA(mat_tz, graph = FALSE)
  if (ndim_tz >= 2) {
    # Biplot normal con dos dimensiones
    print(factoextra::fviz_ca_biplot(ca_tz, axes = c(1, 2), repel = TRUE,
                                     title = "CA: Tipo vs Zona"))
  } else {
    # 1 dimensión: coordenas pueden venir como vector -> conviértelas
    row_coords <- ca_tz$row$coord
    col_coords <- ca_tz$col$coord

    if (is.null(dim(row_coords))) {
      if (is.null(names(row_coords))) names(row_coords) <- rownames(mat_tz)
      rows <- tibble::tibble(tipo = names(row_coords),
                             Dim1 = as.numeric(row_coords))
    } else {
      tmp <- as.data.frame(row_coords)[, 1, drop = TRUE]
      rows <- tibble::tibble(tipo = rownames(row_coords),
                             Dim1 = as.numeric(tmp))
    }

    if (is.null(dim(col_coords))) {
      if (is.null(names(col_coords))) names(col_coords) <- colnames(mat_tz)
      cols <- tibble::tibble(zona = names(col_coords),
                             Dim1 = as.numeric(col_coords))
    } else {
      tmp <- as.data.frame(col_coords)[, 1, drop = TRUE]
      cols <- tibble::tibble(zona = rownames(col_coords),
                             Dim1 = as.numeric(tmp))
    }

    print(ggplot(rows, aes(x = reorder(tipo, Dim1), y = Dim1)) +
            geom_col() + coord_flip() +
            labs(title = "CA 1D: Tipos (Dim1)", x = NULL, y = "Coordenada Dim1") +
            theme_minimal())

    print(ggplot(cols, aes(x = reorder(zona, Dim1), y = Dim1)) +
            geom_col() + coord_flip() +
            labs(title = "CA 1D: Zonas (Dim1)", x = NULL, y = "Coordenada Dim1") +
            theme_minimal())
  }
} else {
  cat("No hay suficiente variación para CA en Tipo vs Zona (1xN o Nx1).\n")
}

Comentario (CA – tipo vs zona).
La gráfica muestra afinidades entre el tipo de vivienda y las zonas. Las categorías que aparecen cercanas tienden a presentarse juntas en la oferta, lo que orienta qué tipo encaja mejor en cada zona.

# --- CA: Tipo vs Barrios (Top 30) — robusto 0D/1D/2D ---
top_barrios <- vivienda_limp %>%
  count(barrio, sort = TRUE) %>% slice_head(n = 30) %>% pull(barrio)

mat_tb <- vivienda_limp %>%
  filter(barrio %in% top_barrios) %>%
  count(tipo, barrio) %>%
  tidyr::pivot_wider(names_from = barrio, values_from = n, values_fill = 0) %>%
  tibble::column_to_rownames("tipo") %>%
  as.matrix()

mat_tb <- mat_tb[rowSums(mat_tb) > 0, , drop = FALSE]
mat_tb <- mat_tb[, colSums(mat_tb) > 0, drop = FALSE]

ndim_tb <- min(nrow(mat_tb) - 1, ncol(mat_tb) - 1)

if (ndim_tb >= 1) {
  ca_tb <- FactoMineR::CA(mat_tb, graph = FALSE)
  if (ndim_tb >= 2) {
    print(factoextra::fviz_ca_biplot(ca_tb, axes = c(1, 2), repel = TRUE,
                                     title = "CA: Tipo vs Barrios (Top 30)"))
  } else {
    row_coords <- ca_tb$row$coord
    col_coords <- ca_tb$col$coord

    if (is.null(dim(row_coords))) {
      if (is.null(names(row_coords))) names(row_coords) <- rownames(mat_tb)
      rows <- tibble::tibble(tipo = names(row_coords),
                             Dim1 = as.numeric(row_coords))
    } else {
      tmp <- as.data.frame(row_coords)[, 1, drop = TRUE]
      rows <- tibble::tibble(tipo = rownames(row_coords),
                             Dim1 = as.numeric(tmp))
    }

    if (is.null(dim(col_coords))) {
      if (is.null(names(col_coords))) names(col_coords) <- colnames(mat_tb)
      cols <- tibble::tibble(barrio = names(col_coords),
                             Dim1 = as.numeric(col_coords))
    } else {
      tmp <- as.data.frame(col_coords)[, 1, drop = TRUE]
      cols <- tibble::tibble(barrio = rownames(col_coords),
                             Dim1 = as.numeric(tmp))
    }

    print(ggplot(rows, aes(x = reorder(tipo, Dim1), y = Dim1)) +
            geom_col() + coord_flip() +
            labs(title = "CA 1D: Tipos (Dim1)", x = NULL, y = "Coordenada Dim1") +
            theme_minimal())

    print(ggplot(cols, aes(x = reorder(barrio, Dim1), y = Dim1)) +
            geom_col() + coord_flip() +
            labs(title = "CA 1D: Barrios (Dim1)", x = NULL, y = "Coordenada Dim1") +
            theme_minimal())
  }
} else {
  cat("No hay suficiente variación para CA en Tipo vs Barrios (1xN o Nx1).\n")
}

Comentario (CA – tipo vs barrios).
La gráfica muestra combinaciones frecuentes entre tipos de vivienda y barrios. Donde la barra (o el punto) es más alta, hay mayor presencia conjunta de ese tipo en ese barrio.

8.0.1 7.3 Correspondencia múltiple (MCA) — extensión

cat_df <- vivienda_limp |>
  transmute(
    zona, tipo,
    estrato_cat = factor(dplyr::case_when(
      estrato <= 2 ~ "Bajo", estrato <= 4 ~ "Medio", TRUE ~ "Alto"),
      levels = c("Bajo","Medio","Alto")),
    categoria_area = factor(dplyr::case_when(
      areaconst_w < quantile(areaconst_w, 0.33, na.rm = TRUE) ~ "Pequeña",
      areaconst_w < quantile(areaconst_w, 0.66, na.rm = TRUE) ~ "Mediana",
      TRUE ~ "Grande"), levels = c("Pequeña","Mediana","Grande")),
    categoria_precio = factor(dplyr::case_when(
      preciom_w < quantile(preciom_w, 0.33, na.rm = TRUE) ~ "Económica",
      preciom_w < quantile(preciom_w, 0.66, na.rm = TRUE) ~ "Media",
      TRUE ~ "Alta"), levels = c("Económica","Media","Alta")),
    tiene_parqueadero = factor(ifelse(parqueaderos_w > 0, "Sí", "No"), levels = c("No","Sí"))
  ) |>
  drop_na()

mca_res <- FactoMineR::MCA(cat_df, graph = FALSE)
factoextra::fviz_mca_biplot(mca_res, repel = TRUE, ggtheme = theme_minimal(),
                            title = "MCA — mapa conjunto de categorías")

Comentario (MCA).
La gráfica muestra qué categorías suelen aparecer juntas (zona, tipo, tamaño, nivel de precio, parqueadero). Las categorías cercanas comparten patrón de oferta.

factoextra::fviz_contrib(mca_res, choice = "var", axes = 1:2, top = 15) +
  ggtitle("Contribución de variables al MCA") + theme_minimal()

Comentario (Contribución al MCA).
La gráfica muestra qué atributos pesan más para diferenciar la oferta en el mapa de categorías. Es útil para priorizar qué variables contar siempre en la captura del dato.

9 8. Visualización integral (dashboard)

# 1) Precio promedio por zona y tipo
p1 <- seg_df_full |>
  group_by(zona, tipo) |>
  summarise(precio_promedio = mean(preciom_w, na.rm = TRUE), .groups = 'drop') |>
  ggplot(aes(x = zona, y = precio_promedio, fill = tipo)) +
  geom_col(position = "dodge") +
  scale_fill_viridis_d() +
  labs(title = "Precio promedio por zona y tipo", x = "Zona", y = "Precio (millones)", fill = "Tipo") +
  theme_minimal() + theme(axis.text.x = element_text(angle = 45, hjust = 1))

# 2) Composición de estratos por zona
p2 <- seg_df_full |>
  ggplot(aes(x = zona, fill = factor(estrato))) +
  geom_bar(position = "fill") +
  scale_fill_viridis_d() +
  labs(title = "Composición de estratos por zona", x = "Zona", y = "Proporción", fill = "Estrato") +
  theme_minimal() + theme(axis.text.x = element_text(angle = 45, hjust = 1))

# 3) Área vs Precio por segmento
p3 <- seg_df_full |>
  ggplot(aes(x = areaconst_w, y = preciom_w, color = segmento)) +
  geom_point(alpha = 0.6) +
  labs(title = "Área vs Precio por segmento", x = "Área (m²)", y = "Precio (millones)", color = "Segmento") +
  theme_minimal()

gridExtra::grid.arrange(p1, p2, p3, nrow = 3)

Comentario (Dashboard).
Arriba: precio promedio por zona y tipo; se observan zonas y tipologías con valores superiores que conviene priorizar.
En medio: composición de estratos por zona; ayuda a alinear oferta con el perfil socioeconómico local.
Abajo: área vs precio por segmento; confirma que los segmentos se diferencian claramente por tamaño y valor.

10 9. Conclusiones y recomendaciones (para negocio)

10.1 9.1 Lo que aprendimos

  • El mercado se organiza en tres grupos claros por tamaño y nivel de precio.
  • El precio crece con área y amenidades (baños, habitaciones, parqueaderos) y con el estrato.
  • Algunas zonas concentran la oferta de mayor valor; aprovechar estas diferencias mejora la rentabilidad.
  • La depuración del 19.6% de registros muestra oportunidades para mejorar la captura de los datos.

10.2 9.2 Qué hacer en los próximos 90 días

  • Precios. Usar el segmento de cada inmueble para revisar listas y negociar mejor.
  • Abastecimiento. Priorizar zonas y tipos con mejor desempeño (según el análisis de correspondencias).
  • Portafolio. Definir una meta de mix por segmento (p. ej., 40% económico, 40% medio, 20% alto) para balancear ingresos y rotación.
  • Marketing. Mensajes y campañas diferenciadas por segmento y zona (no todo funciona igual en toda la ciudad).
  • Calidad del dato. Estandarizar reglas: área > 10 m², estrato entre 1 y 6, y piso con formato numérico; auditoría mensual básica.

Nota: Los detalles técnicos (métodos y gráficos) están documentados en el informe y el anexo de trazabilidad; no se requiere formación estadística para implementar estas acciones.

11 10. Anexos — Trazabilidad de limpieza y reproducibilidad

# ANEXO: Trazabilidad de limpieza — tabla comparativa antes vs. después por regla
base_raw <- vivienda |>
  mutate(piso_num = suppressWarnings(readr::parse_number(piso)),
         zona = as.factor(zona), tipo = as.factor(tipo), barrio = as.factor(barrio))

n0 <- nrow(base_raw); curr <- base_raw
apply_step <- function(desc, f) {
  n_before <- nrow(curr); curr <<- f(curr); n_after <- nrow(curr)
  tibble::tibble(regla = desc, n_antes = n_before, n_despues = n_after,
                 removidos = n_before - n_after,
                 pct_sobre_inicial = round(100*(n_before - n_after)/n0, 2))
}

audit <- dplyr::bind_rows(
  apply_step("1) Quitar NA en precio (preciom)", function(d) d |> dplyr::filter(!is.na(preciom))),
  apply_step("2) Precio > 0", function(d) d |> dplyr::filter(preciom > 0)),
  apply_step("3) Quitar NA en área (areaconst)", function(d) d |> dplyr::filter(!is.na(areaconst))),
  apply_step("4) Área > 10 m²", function(d) d |> dplyr::filter(areaconst > 10)),
  apply_step("5) Estrato en 1–6", function(d) d |> dplyr::filter(estrato %in% 1:6)),
  apply_step("6) Baños >= 0", function(d) d |> dplyr::filter(banios >= 0)),
  apply_step("7) Habitaciones >= 1", function(d) d |> dplyr::filter(habitaciones >= 1)),
  apply_step("8) Parqueaderos >= 0", function(d) d |> dplyr::filter(parqueaderos >= 0))
) |> dplyr::mutate(n_acumulado = n_despues)

knitr::kable(audit, caption = "Trazabilidad de limpieza: efecto de cada regla sobre N")
Trazabilidad de limpieza: efecto de cada regla sobre N
regla n_antes n_despues removidos pct_sobre_inicial n_acumulado
1) Quitar NA en precio (preciom) 8322 8320 2 0.02 8320
2) Precio > 0 8320 8320 0 0.00 8320
3) Quitar NA en área (areaconst) 8320 8319 1 0.01 8319
4) Área > 10 m² 8319 8319 0 0.00 8319
5) Estrato en 1–6 8319 8319 0 0.00 8319
6) Baños >= 0 8319 8319 0 0.00 8319
7) Habitaciones >= 1 8319 8253 66 0.79 8253
8) Parqueaderos >= 0 8253 6694 1559 18.73 6694
resumen_n <- tibble::tibble(
  etapa = c("Original", "Tras filtros"),
  N = c(n0, nrow(curr)),
  Removidos = c(NA, n0 - nrow(curr)),
  `% de la base removida` = c(NA, round(100*(n0 - nrow(curr))/n0, 2))
)
knitr::kable(resumen_n, caption = "Resumen global de depuración")
Resumen global de depuración
etapa N Removidos % de la base removida
Original 8322 NA NA
Tras filtros 6694 1628 19.56
stats_before <- base_raw |> summarise(
  precio_min = min(preciom, na.rm = TRUE),
  precio_p50 = median(preciom, na.rm = TRUE),
  precio_max = suppressWarnings(max(preciom, na.rm = TRUE)),
  area_min = min(areaconst, na.rm = TRUE),
  area_p50 = median(areaconst, na.rm = TRUE),
  area_max = suppressWarnings(max(areaconst, na.rm = TRUE))
)

stats_after_filters <- curr |> summarise(
  precio_min = min(preciom, na.rm = TRUE),
  precio_p50 = median(preciom, na.rm = TRUE),
  precio_max = suppressWarnings(max(preciom, na.rm = TRUE)),
  area_min = min(areaconst, na.rm = TRUE),
  area_p50 = median(areaconst, na.rm = TRUE),
  area_max = suppressWarnings(max(areaconst, na.rm = TRUE))
)

stats_after_winsor <- vivienda_limp |> summarise(
  precio_min = min(preciom_w, na.rm = TRUE),
  precio_p50 = median(preciom_w, na.rm = TRUE),
  precio_max = max(preciom_w, na.rm = TRUE),
  area_min = min(areaconst_w, na.rm = TRUE),
  area_p50 = median(areaconst_w, na.rm = TRUE),
  area_max = max(areaconst_w, na.rm = TRUE)
)

comparativa <- dplyr::bind_rows(
  stats_before |> mutate(etapa = "Antes (original)"),
  stats_after_filters |> mutate(etapa = "Después (filtros)"),
  stats_after_winsor |> mutate(etapa = "Después (winsorización)")
) |> dplyr::select(etapa, dplyr::everything())

knitr::kable(comparativa, caption = "Comparativa antes vs. después por etapa (precio y área)")
Comparativa antes vs. después por etapa (precio y área)
etapa precio_min precio_p50 precio_max area_min area_p50 area_max
Antes (original) 58 330 1999.0 30 123 1745
Después (filtros) 58 355 1999.0 30 130 1745
Después (winsorización) 58 355 1580.5 30 130 670
sessionInfo()
## R version 4.4.3 (2025-02-28)
## Platform: aarch64-apple-darwin20
## Running under: macOS Sequoia 15.5
## 
## Matrix products: default
## BLAS:   /Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/lib/libRblas.0.dylib 
## LAPACK: /Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.0
## 
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
## 
## time zone: America/Bogota
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] viridis_0.6.5        viridisLite_0.4.2    leaflet_2.2.2       
##  [4] cluster_2.1.8        NbClust_3.0.1        FactoMineR_2.12     
##  [7] factoextra_1.0.7     corrplot_0.95        scales_1.3.0        
## [10] naniar_1.1.0         skimr_2.2.1          janitor_2.2.1       
## [13] lubridate_1.9.4      forcats_1.0.0        stringr_1.5.1       
## [16] dplyr_1.1.4          purrr_1.0.4          readr_2.1.5         
## [19] tidyr_1.3.1          tibble_3.2.1         tidyverse_2.0.0     
## [22] paqueteMODELOS_0.1.0 summarytools_1.1.4   knitr_1.50          
## [25] gridExtra_2.3        GGally_2.3.0         ggplot2_3.5.2       
## [28] broom_1.0.8          boot_1.3-31         
## 
## loaded via a namespace (and not attached):
##  [1] tidyselect_1.2.1     farver_2.1.2         S7_0.2.0            
##  [4] fastmap_1.2.0        digest_0.6.37        timechange_0.3.0    
##  [7] estimability_1.5.1   lifecycle_1.0.4      multcompView_0.1-10 
## [10] magrittr_2.0.3       compiler_4.4.3       rlang_1.1.5         
## [13] sass_0.4.9           tools_4.4.3          yaml_2.3.10         
## [16] ggsignif_0.6.4       labeling_0.4.3       htmlwidgets_1.6.4   
## [19] scatterplot3d_0.3-44 plyr_1.8.9           repr_1.1.7          
## [22] RColorBrewer_1.1-3   abind_1.4-8          withr_3.0.2         
## [25] grid_4.4.3           ggpubr_0.6.1         xtable_1.8-4        
## [28] colorspace_2.1-1     emmeans_1.11.2       MASS_7.3-64         
## [31] flashClust_1.01-2    cli_3.6.4            mvtnorm_1.3-3       
## [34] rmarkdown_2.29       generics_0.1.3       rstudioapi_0.17.1   
## [37] reshape2_1.4.4       tzdb_0.5.0           cachem_1.1.0        
## [40] pander_0.6.6         matrixStats_1.5.0    base64enc_0.1-3     
## [43] vctrs_0.6.5          carData_3.0-5        jsonlite_1.9.1      
## [46] car_3.1-3            hms_1.1.3            rapportools_1.2     
## [49] rstatix_0.7.2        ggrepel_0.9.6        visdat_0.6.0        
## [52] Formula_1.2-5        crosstalk_1.2.1      magick_2.8.7        
## [55] jquerylib_0.1.4      glue_1.8.0           ggstats_0.10.0      
## [58] codetools_0.2-20     DT_0.33              stringi_1.8.4       
## [61] gtable_0.3.6         munsell_0.5.1        pillar_1.10.1       
## [64] htmltools_0.5.8.1    R6_2.6.1             tcltk_4.4.3         
## [67] evaluate_1.0.3       lattice_0.22-6       backports_1.5.0     
## [70] leaps_3.2            snakecase_0.11.1     pryr_0.1.6          
## [73] bslib_0.9.0          Rcpp_1.0.14          checkmate_2.3.2     
## [76] xfun_0.51            pkgconfig_2.0.3