El mercado inmobiliario urbano es complejo y depende de factores físicos, geográficos y socioeconómicos. En Cali atraviesa una desaceleración, pero la solicitud de compra de dos viviendas de alta gama por parte de una empresa internacional abre una oportunidad estratégica para C&A. Este informe analiza dichas propiedades y ofrece recomendaciones basadas en modelos de estimación de precios para apoyar la negociación y la toma de decisiones..
library(tidymodels)
library(knitr)
library(kableExtra)
# Cargar datos
datos <- paqueteMODELOS::vivienda
# Crear tabla descriptiva
Tabla_descriptiva <- data.frame(
Variable = names(datos),
Descripción = c(
"Identificador único de la vivienda",
"Zona de la ciudad",
"Piso en el que se encuentra la vivienda",
"Estrato socioeconómico",
"Precio (millones de pesos)",
"Área construida (m²)",
"Número de parqueaderos",
"Número de baños",
"Número de habitaciones",
"Tipo de vivienda",
"Barrio",
"Coordenada de longitud",
"Coordenada de latitud"
),
Tipo = c(
"Categórica",
"Categórica",
"Categórica",
"Categórica",
"Numérica",
"Numérica",
"Numérica",
"Numérica",
"Numérica",
"Categórica",
"Categórica",
"Ubicación",
"Ubicación"
)
)
Tabla_descriptiva %>%
kable("html", caption = "Tabla descriptiva de variables") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed", "responsive"),
full_width = FALSE,
position = "center")
| Variable | Descripción | Tipo |
|---|---|---|
| id | Identificador único de la vivienda | Categórica |
| zona | Zona de la ciudad | Categórica |
| piso | Piso en el que se encuentra la vivienda | Categórica |
| estrato | Estrato socioeconómico | Categórica |
| preciom | Precio (millones de pesos) | Numérica |
| areaconst | Área construida (m²) | Numérica |
| parqueaderos | Número de parqueaderos | Numérica |
| banios | Número de baños | Numérica |
| habitaciones | Número de habitaciones | Numérica |
| tipo | Tipo de vivienda | Categórica |
| barrio | Barrio | Categórica |
| longitud | Coordenada de longitud | Ubicación |
| latitud | Coordenada de latitud | Ubicación |
# Función para normalizar variables categóricas, incluyendo la eliminación de tildes
normalizar_columnas_base <- function(df, columnas) {
for (col in columnas) {
# Convertir a minúsculas, eliminar espacios en blanco y tildes
df[[col]] <- tolower(trimws(df[[col]]))
df[[col]] <- gsub("á", "a", df[[col]])
df[[col]] <- gsub("é", "e", df[[col]])
df[[col]] <- gsub("í", "i", df[[col]])
df[[col]] <- gsub("ó", "o", df[[col]])
df[[col]] <- gsub("ú", "u", df[[col]])
df[[col]] <- gsub("ñ", "n", df[[col]])
}
return(df)
}
# Aplicar la función a las columnas seleccionadas
datos2 <- normalizar_columnas_base(datos,c("id","zona","estrato","tipo","barrio"))
Se construyó una base de datos específica con la información de las viviendas clasificadas como casas localizadas en la Zona Norte de la ciudad. De los 8.321 registros que conforman la base original de viviendas, se seleccionaron 722 que cumplen simultáneamente con estos criterios, equivalentes al 8,7% del total. Este conjunto reducido servirá como base para el análisis posterior.
library(dplyr)
Base1<-datos2%>%
filter(tipo=="casa",zona=="zona norte")
head(Base1, 3)
## # A tibble: 3 × 13
## id zona piso estrato preciom areaconst parqueaderos banios habitaciones
## <chr> <chr> <chr> <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 1209 zona n… 02 5 320 150 2 4 6
## 2 1592 zona n… 02 5 780 380 2 3 3
## 3 4057 zona n… 02 6 750 445 NA 7 6
## # ℹ 4 more variables: tipo <chr>, barrio <chr>, longitud <dbl>, latitud <dbl>
Se detectaron inconsistencias en la base de datos filtrada. A continuación, se muestra una lista de barrios que, aunque fueron incluidos en el filtro inicial como pertenecientes a la Zona Norte, en realidad se ubican geográficamente en otras zonas de la ciudad.
barrios <- sort(unique(trimws(na.omit(Base1$barrio))))
cols <- 3
pad <- (cols - length(barrios) %% cols) %% cols
m <- matrix(c(barrios, rep("", pad)), ncol = cols, byrow = TRUE)
colnames(m) <- paste0("Barrio ", 1:cols)
knitr::kable(m, caption = "Barrios (distribuidos en columnas)")
| Barrio 1 | Barrio 2 | Barrio 3 |
|---|---|---|
| acopi | alameda del rio | alamos |
| atanasio girardot | barranquilla | barrio tranquilo y |
| base aérea | berlin | brisas de los |
| brisas del guabito | cali | calibella |
| calima | calimio norte | cambulos |
| centenario | chapinero | chipichape |
| ciudad los alamos | colinas del bosque | cristales |
| el bosque | el cedro | el gran limonar |
| el guabito | el sena | el trébol |
| evaristo garcia | flora industrial | floralia |
| gaitan | granada | jorge eliecer gaitan |
| juanamb√∫ | la base | la campina |
| la esmeralda | la flora | la floresta |
| la merced | la rivera | la rivera i |
| la rivera ii | la riviera | la villa del |
| las acacias | las américas | las ceibas |
| las delicias | las granjas | los andes |
| los guaduales | los guayacanes | manzanares |
| menga | metropolitano del norte | nueva tequendama |
| oasis de comfandi | occidente | pacara |
| parque residencial el | paseo de los | paso del comercio |
| poblado campestre | popular | portada de comfandi |
| portales de comfandi | porvenir | prados del norte |
| quintas de salomia | rozo la torre | salomia |
| san luis | san vicente | santa barbara |
| santa monica | santa monica norte | santa monica residencial |
| santander | tejares de san | torres de comfandi |
| union de vivienda | urbanizacion barranquilla | urbanizacion la flora |
| urbanizacion la merced | urbanizacion la nueva | valle del lili |
| versalles | villa colombia | villa de veracruz |
| villa del prado | villa del sol | villas de veracruz |
| vipasa | zona norte | zona oriente |
Nuevo Filtro por Coordenadas
Para propósitos prácticos y de análisis, se realizará un nuevo filtro para incluir solo las viviendas que están realmente ubicadas en la zona norte de la ciudad de Cali. Este filtro se hará a través de las coordenadas de latitud y longitud.
Base1a<-datos2%>%
filter(latitud>=3.458,longitud>=-76.54,tipo=="casa")
# Mapa interactivo de Cali con las casas de Base1
library(leaflet)
# Asegura que las coordenadas sean numéricas
Base1a$latitud <- as.numeric(Base1a$latitud)
Base1a$longitud <- as.numeric(Base1a$longitud)
# Bounding box para encuadrar los puntos (fallback a centro de Cali si faltan NAs)
bb <- with(na.omit(Base1a), list(
xmin = min(longitud, na.rm = TRUE),
xmax = max(longitud, na.rm = TRUE),
ymin = min(latitud, na.rm = TRUE),
ymax = max(latitud, na.rm = TRUE)
))
mapa <- leaflet(Base1a) |>
addProviderTiles(leaflet::providers$CartoDB.Positron) |>
# Centro de Cali por si el bbox no sirve (valores NA o solo 1 punto)
setView(lng = -76.5320, lat = 3.4516, zoom = 12) |>
addCircleMarkers(
lng = ~longitud, lat = ~latitud,
radius = 5, stroke = FALSE, fillOpacity = 0.8,
color = "red", fillColor = "red", # 🔴 Puntos rojos
popup = ~paste0(
"<b>", if ("tipo" %in% names(Base1a)) tipo else "Inmueble", "</b><br>",
if ("barrio" %in% names(Base1a)) paste0(barrio, "<br>") else "",
if ("zona" %in% names(Base1a)) paste0(zona, "<br>") else ""
)
) |>
addScaleBar(position = "bottomleft")
# Si hay al menos 2 puntos distintos, ajusta a su extensión
if (is.finite(bb$xmin) && is.finite(bb$xmax) && is.finite(bb$ymin) && is.finite(bb$ymax) &&
bb$xmin < bb$xmax && bb$ymin < bb$ymax) {
mapa <- mapa |> fitBounds(lng1 = bb$xmin, lat1 = bb$ymin, lng2 = bb$xmax, lat2 = bb$ymax)
}
mapa
library(summarytools)
##
## Adjuntando el paquete: 'summarytools'
## The following object is masked from 'package:tibble':
##
## view
datos_filtrados <- Base1a[, !names(Base1a) %in% c("id", "latitud", "longitud")]
print(descr(datos_filtrados), method = "render")
## Non-numerical variable(s) ignored: zona, piso, estrato, tipo, barrio
| areaconst | banios | habitaciones | parqueaderos | preciom | |
|---|---|---|---|---|---|
| Mean | 251.05 | 3.46 | 4.55 | 2.19 | 417.29 |
| Std.Dev | 166.25 | 1.46 | 1.75 | 1.46 | 259.53 |
| Min | 30.00 | 0.00 | 0.00 | 1.00 | 85.00 |
| Q1 | 130.00 | 2.00 | 3.00 | 1.00 | 235.00 |
| Median | 228.00 | 3.00 | 4.00 | 2.00 | 360.00 |
| Q3 | 320.00 | 4.00 | 5.00 | 3.00 | 520.00 |
| Max | 1500.00 | 10.00 | 10.00 | 10.00 | 1800.00 |
| MAD | 145.29 | 1.48 | 1.48 | 1.48 | 207.56 |
| IQR | 190.00 | 2.00 | 2.00 | 2.00 | 285.00 |
| CV | 0.66 | 0.42 | 0.38 | 0.67 | 0.62 |
| Skewness | 2.41 | 0.70 | 0.96 | 1.91 | 2.00 |
| SE.Skewness | 0.10 | 0.10 | 0.10 | 0.12 | 0.10 |
| Kurtosis | 11.77 | 0.95 | 1.25 | 4.57 | 6.00 |
| N.Valid | 581 | 581 | 581 | 386 | 581 |
| N | 581 | 581 | 581 | 581 | 581 |
| Pct.Valid | 100.00 | 100.00 | 100.00 | 66.44 | 100.00 |
Generated by summarytools 1.1.4 (R version 4.5.1)
2025-09-03
duplicados <- Base1a[duplicated(Base1a), ]
sum(duplicated(Base1a))
## [1] 0
Base1a <- unique(Base1a)
blancos <- is.na(Base1a)
# Contar el número de celdas vacías (NA) por columna
columna_blancos <- colSums(blancos)
# Ver la cantidad de celdas vacías por columna
#print(columna_blancos)
tabla_blancos <- data.frame(
Variable = names(columna_blancos),
NA_n = as.integer(columna_blancos),
Porcentaje = round(columna_blancos / nrow(Base1a) * 100, 2),
row.names = NULL
)
tabla_blancos %>%
kable("html", caption = "Celdas NA por columna") %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed", "responsive"),
full_width = FALSE, position = "center"
)
| Variable | NA_n | Porcentaje |
|---|---|---|
| id | 0 | 0.00 |
| zona | 0 | 0.00 |
| piso | 248 | 42.69 |
| estrato | 0 | 0.00 |
| preciom | 0 | 0.00 |
| areaconst | 0 | 0.00 |
| parqueaderos | 195 | 33.56 |
| banios | 0 | 0.00 |
| habitaciones | 0 | 0.00 |
| tipo | 0 | 0.00 |
| barrio | 0 | 0.00 |
| longitud | 0 | 0.00 |
| latitud | 0 | 0.00 |
Dado que se identificó una concentración de valores ausentes en dos variables, se optará por aplicar un proceso de imputación empleando la moda como método de reemplazo.
# Copia de trabajo
datos_imp <- Base1a
# (opcional) columnas a excluir de la imputación
excluir <- c("id") # agrega "latitud","longitud", etc. si no quieres imputarlas
cols <- setdiff(names(datos_imp), excluir)
# Función de moda (sirve para numéricas, factor y character)
moda <- function(x) {
y <- x
if (is.character(y)) y[y == ""] <- NA # trata vacíos como NA (opcional)
y <- y[!is.na(y)]
if (!length(y)) return(NA)
ux <- unique(y)
ux[which.max(tabulate(match(y, ux)))]
}
# Imputación con moda por columna (simple y directa)
for (v in cols) {
m <- moda(datos_imp[[v]])
if (is.na(m)) next
if (is.factor(datos_imp[[v]])) {
# asegura que la moda exista como nivel
lv <- levels(datos_imp[[v]])
if (!(as.character(m) %in% lv)) levels(datos_imp[[v]]) <- c(lv, as.character(m))
datos_imp[[v]][is.na(datos_imp[[v]])] <- as.character(m)
} else {
datos_imp[[v]][is.na(datos_imp[[v]])] <- m
}
}
tabla_na <- data.frame(
Variable = names(datos_imp),
NA_n = colSums(is.na(datos_imp)),
Porcentaje = round(colMeans(is.na(datos_imp)) * 100, 2),
row.names = NULL
)
tabla_na %>%
kable("html", caption = "") %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed", "responsive"),
full_width = FALSE, position = "center"
)
| Variable | NA_n | Porcentaje |
|---|---|---|
| id | 0 | 0 |
| zona | 0 | 0 |
| piso | 0 | 0 |
| estrato | 0 | 0 |
| preciom | 0 | 0 |
| areaconst | 0 | 0 |
| parqueaderos | 0 | 0 |
| banios | 0 | 0 |
| habitaciones | 0 | 0 |
| tipo | 0 | 0 |
| barrio | 0 | 0 |
| longitud | 0 | 0 |
| latitud | 0 | 0 |
# 1) Excluir columnas no numéricas
vars_excluir <- c("id","zona","tipo","barrio","latitud","longitud")
datos_num <- datos_imp[, setdiff(names(datos_imp), vars_excluir), drop = FALSE]
datos_num <- datos_num[, sapply(datos_num, is.numeric), drop = FALSE]
# 2) Transformación log estable (log1p = log(1+x))
datos_log <- as.data.frame(lapply(datos_num, log1p))
# 3) Outliers por IQR, devolviendo índices (mejor para localizar filas)
detectar_idx_iqr <- function(x){
x <- x[!is.na(x)]
if (length(x) < 2) return(integer(0))
Q1 <- quantile(x, .25); Q3 <- quantile(x, .75); I <- Q3 - Q1
which(x < (Q1 - 1.5*I) | x > (Q3 + 1.5*I))
}
idx_por_var <- lapply(datos_log, detectar_idx_iqr)
resumen <- data.frame(
Variable = names(idx_por_var),
Valores_Atipicos = sapply(idx_por_var, length),
row.names = NULL
)
#Conteo de válidos por variable (en datos_log)
n_valid <- sapply(datos_log, function(x) sum(!is.na(x)))
# Conteo de outliers (ya lo tienes en idx_por_var)
n_out <- sapply(idx_por_var, length)
# Porcentaje de outliers
pct_out <- ifelse(n_valid > 0, round(100 * n_out / n_valid, 2), NA_real_)
# Tabla final
tabla_out <- data.frame(
Variable = names(n_out),
N_validos = as.integer(n_valid[names(n_out)]),
N_outliers = as.integer(n_out),
Porcentaje = pct_out,
row.names = NULL
)
# Ordenar por mayor porcentaje (opcional)
tabla_out <- tabla_out[order(-tabla_out$Porcentaje), ]
tabla_out %>%
kable("html", caption = "") %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed", "responsive"),
full_width = FALSE, position = "center"
)
| Variable | N_validos | N_outliers | Porcentaje | |
|---|---|---|---|---|
| 3 | parqueaderos | 581 | 28 | 4.82 |
| 5 | habitaciones | 581 | 7 | 1.20 |
| 4 | banios | 581 | 6 | 1.03 |
| 2 | areaconst | 581 | 3 | 0.52 |
| 1 | preciom | 581 | 1 | 0.17 |
# =========================
# Preparación de datos (tu parte)
# =========================
vars <- c("preciom","areaconst","parqueaderos","banios","habitaciones")
vars <- intersect(vars, names(datos_imp))
to_num <- function(x) if (is.numeric(x)) x else suppressWarnings(as.numeric(as.character(x)))
X <- setNames(lapply(datos_imp[vars], to_num), vars)
# =========================
# Función para graficar en distintos estilos
# =========================
suppressPackageStartupMessages({
library(dplyr)
library(tidyr)
library(ggplot2)
})
prep_long <- function(X) {
as.data.frame(X) |>
dplyr::select(where(is.numeric)) |>
tidyr::pivot_longer(everything(), names_to="Variable", values_to="x") |>
dplyr::filter(is.finite(x), x > -1) |>
dplyr::mutate(lx = log1p(x))
}
plot_vars <- function(X, style = c("box", "violin", "hist", "density", "ridge"),
ncol = 3, palette = "Set2", log_label = "log(1 + x)") {
style <- match.arg(style)
df <- prep_long(X)
# Comun: tema y escalas
base_theme <- theme_minimal(base_size = 12) +
theme(
strip.text = element_text(face = "bold"),
panel.grid.minor = element_blank()
)
if (style == "box") {
p <- ggplot(df, aes(x = "", y = lx, fill = Variable)) +
geom_boxplot(width = 0.6, notch = TRUE, outlier.shape = 16, outlier.alpha = 0.45, color = "grey30") +
facet_wrap(~ Variable, scales = "free_y", ncol = ncol) +
scale_fill_brewer(palette = palette, guide = "none") +
labs(title = "Distribución por variable (Boxplot, log1p)", x = NULL, y = log_label) +
base_theme +
theme(axis.text.x = element_blank(), axis.ticks.x = element_blank())
} else if (style == "violin") {
p <- ggplot(df, aes(x = "", y = lx, fill = Variable)) +
geom_violin(trim = TRUE, alpha = 0.9, color = NA) +
stat_summary(fun = median, geom = "point", size = 2, color = "grey10") +
facet_wrap(~ Variable, scales = "free_y", ncol = ncol) +
scale_fill_brewer(palette = palette, guide = "none") +
labs(title = "Distribución por variable (Violín, log1p)", x = NULL, y = log_label) +
base_theme +
theme(axis.text.x = element_blank(), axis.ticks.x = element_blank())
} else if (style == "hist") {
p <- ggplot(df, aes(x = lx, fill = Variable)) +
geom_histogram(bins = 30, alpha = 0.9, color = "white") +
facet_wrap(~ Variable, scales = "free_y", ncol = ncol) +
scale_fill_brewer(palette = palette, guide = "none") +
labs(title = "Histograma por variable (log1p)", x = log_label, y = "Frecuencia") +
base_theme
} else if (style == "density") {
p <- ggplot(df, aes(x = lx, fill = Variable)) +
geom_density(alpha = 0.8) +
facet_wrap(~ Variable, scales = "free_y", ncol = ncol) +
scale_fill_brewer(palette = palette, guide = "none") +
labs(title = "Densidad por variable (log1p)", x = log_label, y = "Densidad") +
base_theme
} else if (style == "ridge") {
if (!requireNamespace("ggridges", quietly = TRUE)) {
warning("Instala 'ggridges' para usar style='ridge'. Mostrando densidad por defecto.")
return(plot_vars(X, style = "density", ncol = ncol, palette = palette, log_label = log_label))
}
p <- ggplot(df, aes(x = lx, y = Variable, fill = Variable)) +
ggridges::geom_density_ridges(scale = 1.2, alpha = 0.9, color = NA) +
scale_fill_brewer(palette = palette, guide = "none") +
labs(title = "Ridgelines por variable (log1p)", x = log_label, y = NULL) +
base_theme
}
return(p)
}
# =========================
# Ejemplos de uso (elige uno)
# =========================
p1 <- plot_vars(X, style = "box", ncol = 3) # Boxplots facetados
p2 <- plot_vars(X, style = "violin", ncol = 3) # Violines + mediana
p3 <- plot_vars(X, style = "hist", ncol = 3) # Histogramas
p4 <- plot_vars(X, style = "density",ncol = 3) # Densidades
# p5 <- plot_vars(X, style = "ridge") # Requiere ggridges
print(p1) # o p2/p3/p4
## Notch went outside hinges
## ℹ Do you want `notch = FALSE`?
Los valores atípicos de las variables ‘parqueaderos’, ‘baños’, ‘área construida’ y ‘precio’ fueron removidos del conjunto de datos. Esta decisión se basa en que estos casos representan menos del 5 % del total de registros. Se consideró que la presencia de valores excesivamente altos para baños y parqueaderos no es consistente con el tipo de vivienda bajo estudio.
# === Eliminar outliers (IQR en log1p) excepto la variable 'habitaciones' ===
# 1) Variables numéricas a evaluar (excluye id/zona/tipo/barrio/lat/long y 'habitaciones')
vars_excluir <- c("id","zona","tipo","barrio","latitud","longitud")
num_cols <- names(datos_imp)[sapply(datos_imp, is.numeric)]
vars_filtrar <- setdiff(intersect(num_cols, setdiff(names(datos_imp), vars_excluir)), "habitaciones")
# 2) Máscara de outliers en log1p alineada a filas
is_outlier_log1p <- function(x, k = 1.5){
m <- rep(FALSE, length(x))
ok <- is.finite(x) & (x > -1) # log1p definido para x > -1
if (sum(ok) < 2) return(m)
lx <- log1p(x[ok])
Q1 <- quantile(lx, .25); Q3 <- quantile(lx, .75); I <- Q3 - Q1
m_ok <- (lx < Q1 - k*I) | (lx > Q3 + k*I)
m[ok] <- m_ok
m
}
if (length(vars_filtrar) == 0) {
message("No hay variables para filtrar (aparte de 'habitaciones').")
datos_sin_out <- datos_imp
} else {
masks <- lapply(vars_filtrar, function(v) is_outlier_log1p(datos_imp[[v]]))
mask_any <- Reduce("|", masks) # outlier en cualquiera de las variables
cat("Filas a eliminar:", sum(mask_any), "de", nrow(datos_imp), "\n")
datos_sin_out <- datos_imp[!mask_any, ]
}
## Filas a eliminar: 38 de 581
library(dplyr); library(plotly)
##
## Adjuntando el paquete: 'plotly'
## The following object is masked from 'package:ggplot2':
##
## last_plot
## The following object is masked from 'package:stats':
##
## filter
## The following object is masked from 'package:graphics':
##
## layout
df <- datos_sin_out
# Dispersión interactiva: Precio vs Área (color por estrato) ---
p_area <- plot_ly(
df,
x = ~areaconst, y = ~preciom,
color = ~as.factor(estrato),
colors = "Set2", # paleta distinta
type = "scatter", mode = "markers",
marker = list(size = 9, # tamaño puntos
symbol = "circle", # forma
line = list(width = 1, color = "black")), # borde negro
text = ~paste0(
"<b>Barrio:</b> ", barrios,
"<br><b>Estrato:</b> ", estrato,
"<br><b>Baños:</b> ", banios,
"<br><b>Habitaciones:</b> ", habitaciones
),
hoverinfo = "text+x+y"
) |>
layout(
title = list(text = "Precio vs Área construida", x = 0.5), # título centrado
xaxis = list(title = "Área construida (m²)",
zeroline = FALSE,
gridcolor = "lightgrey"),
yaxis = list(title = "Precio (millones)",
zeroline = FALSE,
gridcolor = "lightgrey"),
plot_bgcolor = "white", # fondo blanco del panel
paper_bgcolor = "white", # fondo general
legend = list(title = list(text = "<b>Estrato</b>"))
)
p_area
La correlación entre el precio y el área construida es positiva y evidente, lo que se refleja en la nube de puntos con una clara línea de tendencia ascendente. El estrato social actúa como un factor diferenciador importante: a igual área construida, los inmuebles en estratos altos (5 y 6) tienen un precio considerablemente mayor que los de estratos medios (3 y 4). Esto sugiere que el estrato añade un valor adicional al precio, independientemente del tamaño de la propiedad.
# -- Boxplots interactivos: Precio por Estrato ---
p_estrato <- plot_ly(
df,
x = ~as.factor(estrato),
y = ~preciom,
color = ~as.factor(estrato),
colors = "Set2",
type = "box",
boxpoints = "all", # muestra todos los puntos además del boxplot
jitter = 0.4, # dispersión de puntos
pointpos = -1.8, # posición de los puntos respecto a la caja
marker = list(size = 5, opacity = 0.6),
line = list(width = 1)
) |>
layout(
title = list(text = "Distribución de precios por estrato", x = 0.5),
xaxis = list(title = "Estrato"),
yaxis = list(title = "Precio (millones)", gridcolor = "lightgrey"),
plot_bgcolor = "white",
paper_bgcolor = "white",
showlegend = FALSE
)
p_estrato
Al analizar la relación por estrato, se evidencia una tendencia ascendente clara: la mediana de los precios crece de forma consistente del estrato 3 al 6. Esto valida una asociación positiva y directa entre el nivel socioeconómico y el precio de la vivienda. Se identificaron valores extremos en los estratos 3, 5 y 6; estos puntos anómalos no son errores de datos, sino que corresponden a propiedades con un área construida excepcionalmente grande para sus respectivos estratos.
library(plotly)
library(dplyr)
num_df <- df |>
select(preciom, areaconst, estrato, banios, habitaciones) |>
mutate(across(everything(), as.numeric)) |>
na.omit()
cm <- cor(num_df, use = "complete.obs", method = "spearman")
# Heatmap interactivo con anotaciones
p_corr <- plot_ly(
x = colnames(cm),
y = rownames(cm),
z = cm,
type = "heatmap",
colorscale = "RdBu", # paleta rojo-azul
reversescale = TRUE,
zmin = -1, zmax = 1,
text = round(cm, 2), # valores como texto
hovertemplate = paste(
"<b>Var1:</b> %{y}<br>",
"<b>Var2:</b> %{x}<br>",
"<b>Correlación:</b> %{z:.2f}<extra></extra>"
)
) |>
layout(
title = list(text = "Correlaciones (Spearman): precio y predictores", x = 0.5),
xaxis = list(title = "", side = "top"),
yaxis = list(title = "", autorange = "reversed"),
plot_bgcolor = "white",
paper_bgcolor = "white"
)
p_corr
El mapa de calor de correlaciones (Spearman) valida los resultados previamente obtenidos. Se identifica la relación más fuerte y positiva entre el precio y el área construida. El estrato social también demuestra una correlación positiva y relevante con el precio, confirmando su influencia en el valor del inmueble más allá de su tamaño. Por otro lado, la correlación entre el precio y el número de baños es moderada, mientras que la del precio y el número de habitaciones es la más baja de todas las variables estudiadas.
m_lin <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
data = df )
summary(m_lin)
##
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos +
## banios, data = df)
##
## Residuals:
## Min 1Q Median 3Q Max
## -672.31 -56.65 -14.94 37.29 929.10
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 33.81258 18.64787 1.813 0.0704 .
## areaconst 0.85821 0.05235 16.395 < 2e-16 ***
## estrato4 70.20860 16.48783 4.258 2.43e-05 ***
## estrato5 109.73976 16.07940 6.825 2.39e-11 ***
## estrato6 307.42093 31.65216 9.712 < 2e-16 ***
## habitaciones 4.45781 4.53561 0.983 0.3261
## parqueaderos 12.79676 7.56011 1.693 0.0911 .
## banios 13.54556 5.92898 2.285 0.0227 *
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 129.1 on 535 degrees of freedom
## Multiple R-squared: 0.6867, Adjusted R-squared: 0.6826
## F-statistic: 167.5 on 7 and 535 DF, p-value: < 2.2e-16
El análisis de regresión revela que el área construida y el estrato social son los principales impulsores del precio de la vivienda.
Área construida: Por cada metro cuadrado adicional, el precio aumenta en aproximadamente $858 mil (0.858 millones). Esta relación es altamente significativa, demostrando que el tamaño es un factor fundamental en la valoración de una propiedad.
Estrato social: El estrato es un factor determinante del precio. Comparado con el estrato 3, las viviendas en el estrato 4 son $70.2 millones más costosas, mientras que las de estrato 5 valen $109.7 millones más. El mayor incremento se observa en el estrato 6, donde el precio es $307.4 millones superior, lo que demuestra un valor significativamente mayor.
Baños y parqueaderos: El número de baños también es un factor significativo, ya que un baño extra se asocia a un aumento de $13.55 millones en el precio. Por su parte, la presencia de parqueaderos muestra una relación positiva, pero con una evidencia estadística más débil (significancia marginal).
Habitaciones: El número de habitaciones no es un factor significativo para el precio. Esto se debe probablemente a que está altamente correlacionado con el área construida; es decir, una vez que se considera el tamaño de la vivienda, el número de cuartos no agrega nueva información relevante.
En resumen, los hallazgos confirman que las propiedades con mayor área y ubicadas en estratos socioeconómicos más altos tienen un valor significativamente superior.
El modelo demuestra una capacidad explicativa notable, capturando cerca del 68.7% de la varianza en el precio de las propiedades. No obstante, un 31% de la variabilidad permanece sin ser explicada. Este fenómeno es típico en el sector inmobiliario debido a la ausencia de variables de granularidad fina en la base de datos, como la ubicación precisa, el estado de conservación, los acabados y las comodidades ofrecidas.
¿Cómo mejorarlo?
m_log <- lm(log10(preciom) ~ log10(areaconst) + estrato + habitaciones + parqueaderos + banios,
data = df )
summary(m_log)
##
## Call:
## lm(formula = log10(preciom) ~ log10(areaconst) + estrato + habitaciones +
## parqueaderos + banios, data = df)
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.28789 -0.06730 -0.00805 0.06316 0.44423
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 1.205927 0.049853 24.190 < 2e-16 ***
## log10(areaconst) 0.484631 0.026841 18.056 < 2e-16 ***
## estrato4 0.105553 0.014387 7.337 8.16e-13 ***
## estrato5 0.150568 0.014390 10.464 < 2e-16 ***
## estrato6 0.265298 0.027477 9.655 < 2e-16 ***
## habitaciones 0.005084 0.003902 1.303 0.1932
## parqueaderos 0.013411 0.006393 2.098 0.0364 *
## banios 0.022390 0.005106 4.385 1.40e-05 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.1103 on 535 degrees of freedom
## Multiple R-squared: 0.7879, Adjusted R-squared: 0.7851
## F-statistic: 283.9 on 7 and 535 DF, p-value: < 2.2e-16
Conclusiones sobre la mejora del modelo logarítmico
La aplicación de una transformación logarítmica al modelo lineal ha generado mejoras significativas. El poder explicativo del modelo se incrementó notablemente, evidenciado por un aumento de 10 puntos porcentuales en el R² (de 0.687 a 0.788). Este cambio indica que el modelo ahora captura una mayor proporción de la variabilidad en los precios. Asimismo, la transformación ha hecho el error más interpretable y robusto. El error residual estándar se redujo drásticamente de 129.1 millones a 0.1103. En términos prácticos, esto significa que el error típico es de aproximadamente ±29% del precio de la vivienda, y la varianza de los residuos es más constante, lo que fortalece la validez del modelo.
m <- m_log # o m_lin
par(mfrow = c(2,2))
plot(m, which = 1, main = "Residuos vs Ajustados"); abline(h = 0, lty = 2)
plot(m, which = 2, main = "Q–Q de residuos")
plot(m, which = 3, main = "Scale–Location")
plot(m, which = 5, main = "Residuos vs Leverage (Cook)")
par(mfrow = c(1,1))
El análisis de los gráficos de diagnóstico confirma que el modelo ha mejorado significativamente tras la transformación logarítmica.
Gráfico de Residuales vs. Valores Ajustados: Este gráfico muestra que los residuos son aleatorios y no siguen un patrón, lo cual confirma la linealidad del modelo. La dispersión de los puntos es uniforme, lo que indica que se ha corregido el problema de heterocedasticidad (varianza no constante), haciendo que el modelo sea más fiable.
Gráfico Q-Q Normal: La mayoría de los puntos se alinean con la línea diagonal, lo que demuestra que los residuos tienen una distribución aproximadamente normal. Aunque hay algunas desviaciones en los extremos, esto es común en datos reales y no compromete la validez del modelo.
Gráfico de Escala-Ubicación: Este gráfico refuerza la conclusión del primer gráfico, mostrando una línea de tendencia plana que confirma la homocedasticidad, es decir, que la varianza de los residuos es constante a lo largo de los valores ajustados.
Gráfico de Residuales vs. Leverage: Este gráfico ayuda a identificar puntos influyentes. La mayoría de los puntos tienen una Distancia de Cook baja, lo que indica que, a pesar de que hay algunos valores con potencial de influencia, no hay puntos extremos que afecten desproporcionadamente los coeficientes del modelo. Por lo tanto, no es necesario eliminar ningún dato para mejorar la precisión.
Para una casa con: area de 200 mt2 1 parqueadero 2 baños 4 habitaciones Estrato 5 en la zona norte
El precio es de: 366 millones
newdata2 <- data.frame(
areaconst = 200, # m²
estrato = "5",
habitaciones = 4,
parqueaderos = 1,
banios = 2
)
pred_log10_2 <- predict(m_log, newdata = newdata2)
smear <- mean(10^residuals(m_log), na.rm = TRUE)
pred_log2_mill <- 10^pred_log10_2 * smear
pred_log2_mill
## 1
## 366.9063
Para una casa en la zona norte de Cali con: area de 200 mt2 1 parqueadero 2 baños 4 habitaciones Estrato 4
El precio es de: 330 millones
newdata3 <- data.frame(
areaconst = 200, # m²
estrato = "4",
habitaciones = 4,
parqueaderos = 1,
banios = 2
)
pred_log10_3 <- predict(m_log, newdata = newdata3)
smear <- mean(10^residuals(m_log), na.rm = TRUE)
pred_log3_mill <- 10^pred_log10_3 * smear
pred_log3_mill
## 1
## 330.781
se identifica cuatro ofertas que se ajustan a su presupuesto y requisitos para una vivienda en la zona norte.
La opción más completa es una casa de 200 m² con cuatro habitaciones, un parqueadero, dos baños y estrato 4, por un valor de $330 millones. Esta propuesta no solo cumple con todas las especificaciones, sino que también ofrece la posibilidad de ampliarse a cinco habitaciones y dos parqueaderos.
En caso de que su prioridad sea vivir en un estrato 5, deberá considerar algunos ajustes. Las posibles compensaciones son:
Reducir el área construida si el tamaño es negociable.
Disminuir la cantidad de baños o habitaciones si el área es un requisito fijo. Por ejemplo, podríamos ofrecer una casa del mismo tamaño, pero con menos baños o, en un caso extremo, con solo dos habitaciones y sin parqueadero.
# Data frame para graficar
oferta <- data.frame(
areaconst = 200, # m²
estrato = "4",
habitaciones = 4,
parqueaderos = 1,
banios = 2
)
pred_log10_3 <- predict(m_log, newdata = oferta)
smear <- mean(10^residuals(m_log), na.rm = TRUE)
pred_log3_mill <- 10^pred_log10_3 * smear
df <- data.frame(
Variable = c("Área (m²)", "Estrato", "Habitaciones", "Parqueaderos", "Baños", "Precio (MM)"),
Valor = c(oferta$areaconst,
as.numeric(oferta$estrato),
oferta$habitaciones,
oferta$parqueaderos,
oferta$banios,
round(pred_log3_mill, 2))
)
ggplot(df, aes(x = Variable, y = Valor, group = 1)) +
geom_line(color = "steelblue", size = 1.2) +
geom_point(color = "red", size = 4) +
geom_text(aes(label = round(Valor, 2)), vjust = -0.8, size = 4) +
labs(title = "Variables del inmueble y precio estimado",
y = "Valor", x = "") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold", hjust = 0.5))
## Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
## ℹ Please use `linewidth` instead.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.
# Data frame para graficar
oferta <- data.frame(
areaconst = 200, # m²
estrato = "4",
habitaciones = 5,
parqueaderos = 2,
banios = 2
)
pred_log10_3 <- predict(m_log, newdata = oferta)
smear <- mean(10^residuals(m_log), na.rm = TRUE)
pred_log3_mill <- 10^pred_log10_3 * smear
df <- data.frame(
Variable = c("Área (m²)", "Estrato", "Habitaciones", "Parqueaderos", "Baños", "Precio (MM)"),
Valor = c(oferta$areaconst,
as.numeric(oferta$estrato),
oferta$habitaciones,
oferta$parqueaderos,
oferta$banios,
round(pred_log3_mill, 2))
)
ggplot(df, aes(x = Variable, y = Valor, group = 1)) +
geom_line(color = "steelblue", size = 1.2) +
geom_point(color = "red", size = 4) +
geom_text(aes(label = round(Valor, 2)), vjust = -0.8, size = 4) +
labs(title = "Variables del inmueble y precio estimado",
y = "Valor", x = "") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold", hjust = 0.5))
# Data frame para graficar
oferta <- data.frame(
areaconst = 180, # m²
estrato = "5",
habitaciones = 4,
parqueaderos = 1,
banios = 2
)
pred_log10_3 <- predict(m_log, newdata = oferta)
smear <- mean(10^residuals(m_log), na.rm = TRUE)
pred_log3_mill <- 10^pred_log10_3 * smear
df <- data.frame(
Variable = c("Área (m²)", "Estrato", "Habitaciones", "Parqueaderos", "Baños", "Precio (MM)"),
Valor = c(oferta$areaconst,
as.numeric(oferta$estrato),
oferta$habitaciones,
oferta$parqueaderos,
oferta$banios,
round(pred_log3_mill, 2))
)
ggplot(df, aes(x = Variable, y = Valor, group = 1)) +
geom_line(color = "steelblue", size = 1.2) +
geom_point(color = "red", size = 4) +
geom_text(aes(label = round(Valor, 2)), vjust = -0.8, size = 4) +
labs(title = "Variables del inmueble y precio estimado",
y = "Valor", x = "") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold", hjust = 0.5))
# Data frame para graficar
oferta <- data.frame(
areaconst = 200, # m²
estrato = "5",
habitaciones = 4,
parqueaderos = 1,
banios = 1
)
pred_log10_3 <- predict(m_log, newdata = oferta)
smear <- mean(10^residuals(m_log), na.rm = TRUE)
pred_log3_mill <- 10^pred_log10_3 * smear
df <- data.frame(
Variable = c("Área (m²)", "Estrato", "Habitaciones", "Parqueaderos", "Baños", "Precio (MM)"),
Valor = c(oferta$areaconst,
as.numeric(oferta$estrato),
oferta$habitaciones,
oferta$parqueaderos,
oferta$banios,
round(pred_log3_mill, 2))
)
ggplot(df, aes(x = Variable, y = Valor, group = 1)) +
geom_line(color = "steelblue", size = 1.2) +
geom_point(color = "red", size = 4) +
geom_text(aes(label = round(Valor, 2)), vjust = -0.8, size = 4) +
labs(title = "Variables del inmueble y precio estimado",
y = "Valor", x = "") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold", hjust = 0.5))
# Data frame para graficar
oferta <- data.frame(
areaconst = 200, # m²
estrato = "5",
habitaciones = 2,
parqueaderos = 0,
banios = 2
)
pred_log10_3 <- predict(m_log, newdata = oferta)
smear <- mean(10^residuals(m_log), na.rm = TRUE)
pred_log3_mill <- 10^pred_log10_3 * smear
df <- data.frame(
Variable = c("Área (m²)", "Estrato", "Habitaciones", "Parqueaderos", "Baños", "Precio (MM)"),
Valor = c(oferta$areaconst,
as.numeric(oferta$estrato),
oferta$habitaciones,
oferta$parqueaderos,
oferta$banios,
round(pred_log3_mill, 2))
)
ggplot(df, aes(x = Variable, y = Valor, group = 1)) +
geom_line(color = "steelblue", size = 1.2) +
geom_point(color = "red", size = 4) +
geom_text(aes(label = round(Valor, 2)), vjust = -0.8, size = 4) +
labs(title = "Variables del inmueble y precio estimado",
y = "Valor", x = "") +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold", hjust = 0.5))
library(dplyr)
Base2a<-datos2%>%
filter(latitud<=3.41,longitud>=-76.54,tipo=="casa")
# Mapa interactivo de Cali con las casas de Base2
library(leaflet)
# Asegura que las coordenadas sean numéricas
Base2a$latitud <- as.numeric(Base2a$latitud)
Base2a$longitud <- as.numeric(Base2a$longitud)
# Bounding box para encuadrar los puntos (fallback a centro de Cali si faltan NAs)
bb <- with(na.omit(Base2a), list(
xmin = min(longitud, na.rm = TRUE),
xmax = max(longitud, na.rm = TRUE),
ymin = min(latitud, na.rm = TRUE),
ymax = max(latitud, na.rm = TRUE)
))
mapa <- leaflet(Base2a) |>
addProviderTiles(leaflet::providers$CartoDB.Positron) |>
# Centro de Cali por si el bbox no sirve (valores NA o solo 1 punto)
setView(lng = -76.5320, lat = 3.4516, zoom = 12) |>
addCircleMarkers(
lng = ~longitud, lat = ~latitud,
radius = 5,
stroke = FALSE,
color = "red", # borde rojo
fillColor = "red", # relleno rojo
fillOpacity = 0.8, # un poco más intenso
popup = ~paste0(
"<b>", if ("tipo" %in% names(Base2a)) tipo else "Inmueble", "</b><br>",
if ("barrio" %in% names(Base2a)) paste0(barrio, "<br>") else "",
if ("zona" %in% names(Base2a)) paste0(zona, "<br>") else ""
)
) |>
addScaleBar(position = "bottomleft")
# Si hay al menos 2 puntos distintos, ajusta a su extensión
if (is.finite(bb$xmin) && is.finite(bb$xmax) && is.finite(bb$ymin) && is.finite(bb$ymax) &&
bb$xmin < bb$xmax && bb$ymin < bb$ymax) {
mapa <- mapa |> fitBounds(lng1 = bb$xmin, lat1 = bb$ymin, lng2 = bb$xmax, lat2 = bb$ymax)
}
mapa
library(summarytools)
datos_filtrados2 <- Base2a[, !names(Base2a) %in% c("id", "latitud", "longitud")]
print(descr(datos_filtrados2), method = "render")
## Non-numerical variable(s) ignored: zona, piso, estrato, tipo, barrio
| areaconst | banios | habitaciones | parqueaderos | preciom | |
|---|---|---|---|---|---|
| Mean | 268.91 | 4.13 | 4.22 | 2.43 | 597.00 |
| Std.Dev | 180.62 | 1.39 | 1.32 | 1.57 | 376.83 |
| Min | 50.00 | 0.00 | 0.00 | 1.00 | 80.00 |
| Q1 | 150.00 | 3.00 | 3.00 | 1.00 | 330.00 |
| Median | 230.00 | 4.00 | 4.00 | 2.00 | 460.00 |
| Q3 | 332.00 | 5.00 | 5.00 | 3.00 | 790.00 |
| Max | 1600.00 | 10.00 | 10.00 | 10.00 | 1900.00 |
| MAD | 133.43 | 1.48 | 1.48 | 1.48 | 281.69 |
| IQR | 182.00 | 2.00 | 2.00 | 2.00 | 460.00 |
| CV | 0.67 | 0.34 | 0.31 | 0.64 | 0.63 |
| Skewness | 2.36 | 0.52 | 0.82 | 1.71 | 1.32 |
| SE.Skewness | 0.07 | 0.07 | 0.07 | 0.08 | 0.07 |
| Kurtosis | 9.57 | 0.80 | 2.31 | 3.70 | 1.26 |
| N.Valid | 1124 | 1124 | 1124 | 991 | 1124 |
| N | 1124 | 1124 | 1124 | 1124 | 1124 |
| Pct.Valid | 100.00 | 100.00 | 100.00 | 88.17 | 100.00 |
Generated by summarytools 1.1.4 (R version 4.5.1)
2025-09-03
duplicados2 <- Base2a[duplicated(Base2a), ]
sum(duplicated(Base2a))
## [1] 0
blancos2 <- is.na(Base2a)
# Contar el número de celdas vacías (NA) por columna
columna_blancos2 <- colSums(blancos2)
# Ver la cantidad de celdas vacías por columna
#print(columna_blancos)
tabla_blancos2 <- data.frame(
Variable = names(columna_blancos2),
NA_n = as.integer(columna_blancos2),
Porcentaje = round(columna_blancos2 / nrow(Base2a) * 100, 2),
row.names = NULL
)
tabla_blancos2 %>%
kable("html", caption = "Celdas NA por columna") %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed", "responsive"),
full_width = FALSE, position = "center"
)
| Variable | NA_n | Porcentaje |
|---|---|---|
| id | 0 | 0.00 |
| zona | 0 | 0.00 |
| piso | 394 | 35.05 |
| estrato | 0 | 0.00 |
| preciom | 0 | 0.00 |
| areaconst | 0 | 0.00 |
| parqueaderos | 133 | 11.83 |
| banios | 0 | 0.00 |
| habitaciones | 0 | 0.00 |
| tipo | 0 | 0.00 |
| barrio | 0 | 0.00 |
| longitud | 0 | 0.00 |
| latitud | 0 | 0.00 |
Debido a que se ha encontrado una concentración de datos faltantes en dos variables, se procederá a realizar una imputación utilizando el método de la moda.
# Copia de trabajo
datos_imp2 <- Base2a
# (opcional) columnas a excluir de la imputación
excluir <- c("id") # agrega "latitud","longitud", etc. si no quieres imputarlas
cols <- setdiff(names(datos_imp2), excluir)
# Función de moda (sirve para numéricas, factor y character)
moda <- function(x) {
y <- x
if (is.character(y)) y[y == ""] <- NA # trata vacíos como NA (opcional)
y <- y[!is.na(y)]
if (!length(y)) return(NA)
ux <- unique(y)
ux[which.max(tabulate(match(y, ux)))]
}
# Imputación con moda por columna (simple y directa)
for (v in cols) {
m <- moda(datos_imp2[[v]])
if (is.na(m)) next
if (is.factor(datos_imp[[v]])) {
# asegura que la moda exista como nivel
lv <- levels(datos_imp[[v]])
if (!(as.character(m) %in% lv)) levels(datos_imp2[[v]]) <- c(lv, as.character(m))
datos_imp2[[v]][is.na(datos_imp2[[v]])] <- as.character(m)
} else {
datos_imp2[[v]][is.na(datos_imp2[[v]])] <- m
}
}
tabla_na <- data.frame(
Variable = names(datos_imp2),
NA_n = colSums(is.na(datos_imp2)),
Porcentaje = round(colMeans(is.na(datos_imp2)) * 100, 2),
row.names = NULL
)
tabla_na %>%
kable("html", caption = "") %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed", "responsive"),
full_width = FALSE, position = "center"
)
| Variable | NA_n | Porcentaje |
|---|---|---|
| id | 0 | 0 |
| zona | 0 | 0 |
| piso | 0 | 0 |
| estrato | 0 | 0 |
| preciom | 0 | 0 |
| areaconst | 0 | 0 |
| parqueaderos | 0 | 0 |
| banios | 0 | 0 |
| habitaciones | 0 | 0 |
| tipo | 0 | 0 |
| barrio | 0 | 0 |
| longitud | 0 | 0 |
| latitud | 0 | 0 |
# 1) Excluir columnas no numéricas
vars_excluir <- c("id","zona","tipo","barrio","latitud","longitud")
datos_num <- datos_imp2[, setdiff(names(datos_imp2), vars_excluir), drop = FALSE]
datos_num <- datos_num[, sapply(datos_num, is.numeric), drop = FALSE]
# 2) Transformación log estable (log1p = log(1+x))
datos_log <- as.data.frame(lapply(datos_num, log1p))
# 3) Outliers por IQR, devolviendo índices (mejor para localizar filas)
detectar_idx_iqr <- function(x){
x <- x[!is.na(x)]
if (length(x) < 2) return(integer(0))
Q1 <- quantile(x, .25); Q3 <- quantile(x, .75); I <- Q3 - Q1
which(x < (Q1 - 1.5*I) | x > (Q3 + 1.5*I))
}
idx_por_var <- lapply(datos_log, detectar_idx_iqr)
resumen <- data.frame(
Variable = names(idx_por_var),
Valores_Atipicos = sapply(idx_por_var, length),
row.names = NULL
)
#Conteo de válidos por variable (en datos_log)
n_valid <- sapply(datos_log, function(x) sum(!is.na(x)))
# Conteo de outliers (ya lo tienes en idx_por_var)
n_out <- sapply(idx_por_var, length)
# Porcentaje de outliers
pct_out <- ifelse(n_valid > 0, round(100 * n_out / n_valid, 2), NA_real_)
# Tabla final
tabla_out <- data.frame(
Variable = names(n_out),
N_validos = as.integer(n_valid[names(n_out)]),
N_outliers = as.integer(n_out),
Porcentaje = pct_out,
row.names = NULL
)
# Ordenar por mayor porcentaje (opcional)
tabla_out <- tabla_out[order(-tabla_out$Porcentaje), ]
tabla_out %>%
kable("html", caption = "") %>%
kable_styling(
bootstrap_options = c("striped", "hover", "condensed", "responsive"),
full_width = FALSE, position = "center"
)
| Variable | N_validos | N_outliers | Porcentaje | |
|---|---|---|---|---|
| 4 | banios | 1124 | 15 | 1.33 |
| 5 | habitaciones | 1124 | 11 | 0.98 |
| 2 | areaconst | 1124 | 6 | 0.53 |
| 1 | preciom | 1124 | 2 | 0.18 |
| 3 | parqueaderos | 1124 | 0 | 0.00 |
# =========================
# Preparación de datos (tu parte)
# =========================
vars <- c("preciom","areaconst","parqueaderos","banios","habitaciones")
vars <- intersect(vars, names(datos_imp2))
to_num <- function(x) if (is.numeric(x)) x else suppressWarnings(as.numeric(as.character(x)))
X <- setNames(lapply(datos_imp2[vars], to_num), vars)
# =========================
# Función para graficar en distintos estilos
# =========================
suppressPackageStartupMessages({
library(dplyr)
library(tidyr)
library(ggplot2)
})
prep_long <- function(X) {
as.data.frame(X) |>
dplyr::select(where(is.numeric)) |>
tidyr::pivot_longer(everything(), names_to="Variable", values_to="x") |>
dplyr::filter(is.finite(x), x > -1) |>
dplyr::mutate(lx = log1p(x))
}
plot_vars <- function(X, style = c("box", "violin", "hist", "density", "ridge"),
ncol = 3, palette = "Set2", log_label = "log(1 + x)") {
style <- match.arg(style)
df <- prep_long(X)
# Comun: tema y escalas
base_theme <- theme_minimal(base_size = 12) +
theme(
strip.text = element_text(face = "bold"),
panel.grid.minor = element_blank()
)
if (style == "box") {
p <- ggplot(df, aes(x = "", y = lx, fill = Variable)) +
geom_boxplot(width = 0.6, notch = TRUE, outlier.shape = 16, outlier.alpha = 0.45, color = "grey30") +
facet_wrap(~ Variable, scales = "free_y", ncol = ncol) +
scale_fill_brewer(palette = palette, guide = "none") +
labs(title = "Distribución por variable (Boxplot, log1p)", x = NULL, y = log_label) +
base_theme +
theme(axis.text.x = element_blank(), axis.ticks.x = element_blank())
} else if (style == "violin") {
p <- ggplot(df, aes(x = "", y = lx, fill = Variable)) +
geom_violin(trim = TRUE, alpha = 0.9, color = NA) +
stat_summary(fun = median, geom = "point", size = 2, color = "grey10") +
facet_wrap(~ Variable, scales = "free_y", ncol = ncol) +
scale_fill_brewer(palette = palette, guide = "none") +
labs(title = "Distribución por variable (Violín, log1p)", x = NULL, y = log_label) +
base_theme +
theme(axis.text.x = element_blank(), axis.ticks.x = element_blank())
} else if (style == "hist") {
p <- ggplot(df, aes(x = lx, fill = Variable)) +
geom_histogram(bins = 30, alpha = 0.9, color = "white") +
facet_wrap(~ Variable, scales = "free_y", ncol = ncol) +
scale_fill_brewer(palette = palette, guide = "none") +
labs(title = "Histograma por variable (log1p)", x = log_label, y = "Frecuencia") +
base_theme
} else if (style == "density") {
p <- ggplot(df, aes(x = lx, fill = Variable)) +
geom_density(alpha = 0.8) +
facet_wrap(~ Variable, scales = "free_y", ncol = ncol) +
scale_fill_brewer(palette = palette, guide = "none") +
labs(title = "Densidad por variable (log1p)", x = log_label, y = "Densidad") +
base_theme
} else if (style == "ridge") {
if (!requireNamespace("ggridges", quietly = TRUE)) {
warning("Instala 'ggridges' para usar style='ridge'. Mostrando densidad por defecto.")
return(plot_vars(X, style = "density", ncol = ncol, palette = palette, log_label = log_label))
}
p <- ggplot(df, aes(x = lx, y = Variable, fill = Variable)) +
ggridges::geom_density_ridges(scale = 1.2, alpha = 0.9, color = NA) +
scale_fill_brewer(palette = palette, guide = "none") +
labs(title = "Ridgelines por variable (log1p)", x = log_label, y = NULL) +
base_theme
}
return(p)
}
# =========================
# Ejemplos de uso (elige uno)
# =========================
p1 <- plot_vars(X, style = "box", ncol = 3) # Boxplots facetados
p2 <- plot_vars(X, style = "violin", ncol = 3) # Violines + mediana
p3 <- plot_vars(X, style = "hist", ncol = 3) # Histogramas
p4 <- plot_vars(X, style = "density",ncol = 3) # Densidades
# p5 <- plot_vars(X, style = "ridge") # Requiere ggridges
print(p1) # o p2/p3/p4
Se optó por remover los valores atípicos en las variables parqueaderos, baños, área construida y precio, ya que en conjunto representan menos del 3 % del total de registros. Además, para los tipos de vivienda analizados, resulta poco coherente que existan valores tan elevados en el número de baños y parqueaderos.
# === Eliminar outliers (IQR en log1p) excepto la variable 'habitaciones' ===
# 1) Variables numéricas a evaluar (excluye id/zona/tipo/barrio/lat/long y 'habitaciones')
vars_excluir <- c("id","zona","tipo","barrio","latitud","longitud")
num_cols <- names(datos_imp2)[sapply(datos_imp2, is.numeric)]
vars_filtrar <- setdiff(intersect(num_cols, setdiff(names(datos_imp2), vars_excluir)), "habitaciones")
# 2) Máscara de outliers en log1p alineada a filas
is_outlier_log1p <- function(x, k = 1.5){
m <- rep(FALSE, length(x))
ok <- is.finite(x) & (x > -1) # log1p definido para x > -1
if (sum(ok) < 2) return(m)
lx <- log1p(x[ok])
Q1 <- quantile(lx, .25); Q3 <- quantile(lx, .75); I <- Q3 - Q1
m_ok <- (lx < Q1 - k*I) | (lx > Q3 + k*I)
m[ok] <- m_ok
m
}
if (length(vars_filtrar) == 0) {
message("No hay variables para filtrar (aparte de 'habitaciones').")
datos_sin_out2 <- datos_imp2
} else {
masks <- lapply(vars_filtrar, function(v) is_outlier_log1p(datos_imp2[[v]]))
mask_any <- Reduce("|", masks) # outlier en cualquiera de las variables
cat("Filas a eliminar:", sum(mask_any), "de", nrow(datos_imp2), "\n")
datos_sin_out2 <- datos_imp2[!mask_any, ]
}
## Filas a eliminar: 22 de 1124
library(dplyr); library(plotly)
df2 <- datos_sin_out2
# -- Dispersión interactiva: Precio vs Área (color por estrato) ---
p_area <- plot_ly(
df2,
x = ~areaconst, y = ~preciom,
color = ~as.factor(estrato),
colors = "Set2", # paleta de colores suaves
type = "scatter", mode = "markers",
marker = list(
size = 9, # tamaño de puntos
symbol = "circle", # forma
line = list(width = 1, color = "black") # borde negro
),
text = ~paste0(
"<b>Barrio:</b> ", barrios,
"<br><b>Estrato:</b> ", estrato,
"<br><b>Baños:</b> ", banios,
"<br><b>Habitaciones:</b> ", habitaciones
),
hoverinfo = "text+x+y"
) |>
layout(
title = list(text = "Precio vs Área construida", x = 0.5),
xaxis = list(title = "Área construida (m²)",
zeroline = FALSE,
gridcolor = "lightgrey"),
yaxis = list(title = "Precio (millones)",
zeroline = FALSE,
gridcolor = "lightgrey"),
plot_bgcolor = "white",
paper_bgcolor = "white",
legend = list(title = list(text = "<b>Estrato</b>"))
)
p_area
library(dplyr); library(plotly)
num_df2 <- df2 |>
select(preciom, areaconst, estrato, banios, habitaciones) |>
mutate(across(everything(), as.numeric)) |>
na.omit()
cm <- cor(num_df2, use = "complete.obs", method = "spearman")
# Heatmap con nuevo formato
p_corr <- plot_ly(
x = colnames(cm),
y = rownames(cm),
z = cm,
type = "heatmap",
colorscale = "RdBu", # rojo-azul
reversescale = TRUE, # rojo = negativo, azul = positivo
zmin = -1, zmax = 1, # escala fija
text = round(cm, 2),
hovertemplate = paste(
"<b>Var1:</b> %{y}<br>",
"<b>Var2:</b> %{x}<br>",
"<b>Correlación:</b> %{z:.2f}<extra></extra>"
)
) |>
layout(
title = list(text = "Correlaciones (Spearman): Precio y predictores", x = 0.5),
xaxis = list(title = "", side = "top"),
yaxis = list(title = "", autorange = "reversed"),
plot_bgcolor = "white",
paper_bgcolor = "white"
)
p_corr
m_lin2 <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios,
data = df2 )
summary(m_lin2)
##
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos +
## banios, data = df2)
##
## Residuals:
## Min 1Q Median 3Q Max
## -741.75 -91.22 -17.52 52.29 938.67
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -59.77088 33.28666 -1.796 0.0728 .
## areaconst 0.75741 0.04984 15.197 < 2e-16 ***
## estrato4 88.12043 26.93371 3.272 0.0011 **
## estrato5 154.48193 27.41244 5.635 2.22e-08 ***
## estrato6 404.64417 29.67588 13.635 < 2e-16 ***
## habitaciones -8.64408 5.49599 -1.573 0.1161
## parqueaderos 54.11170 5.27605 10.256 < 2e-16 ***
## banios 36.16974 6.39079 5.660 1.94e-08 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 192.2 on 1094 degrees of freedom
## Multiple R-squared: 0.7309, Adjusted R-squared: 0.7292
## F-statistic: 424.5 on 7 and 1094 DF, p-value: < 2.2e-16
Factores con Mayor Impacto en el Precio Área Construida: Por cada metro cuadrado adicional, el precio de una vivienda aumenta en $757.000, un factor altamente significativo.
Estrato: El estrato socioeconómico tiene un impacto sustancial en el precio. Comparadas con las viviendas del estrato 3, las del estrato 4 son $88 millones más caras, las del estrato 5 valen $155 millones más, y las del estrato 6 tienen un valor superior de $405 millones.
Parqueaderos y Baños: El número de parqueaderos y baños también influye de manera significativa en el precio. Cada cupo de parqueadero se asocia con un aumento de $54 millones, mientras que cada baño adicional incrementa el precio en $36 millones.
Factores con Menor Impacto Habitaciones: El número de habitaciones no afecta el precio de manera independiente una vez que se considera el área construida. Esto se debe a que el tamaño de la vivienda y el número de habitaciones suelen estar relacionados, por lo que el área ya explica la mayor parte del valor.
Intercepto: El intercepto no es estadísticamente significativo y no tiene una interpretación práctica en este contexto.
En resumen, al analizar o comparar propiedades, es crucial dar prioridad a factores como el área construida, el estrato, los parqueaderos y los baños, ya que son los que más influyen en el precio final.
El modelo es muy efectivo, logrando explicar aproximadamente el 73.1% de la variación en el precio de las viviendas. Aunque es un resultado sólido, el 26.9% restante no se puede explicar con los datos que tenemos. Esto es común en los análisis inmobiliarios, ya que el modelo no incluye detalles más específicos como la ubicación exacta, la edad de la propiedad, la calidad de los acabados o las comodidades que ofrece.
¿Cómo mejorarlo?
1- pasar las variables precio y area a una base logaritmica (Log10) pero para este caso de la oferta no estan aclarando un barrio especifico
m_log2 <- lm(log10(preciom) ~ log10(areaconst) + estrato + habitaciones + parqueaderos + banios,
data = df2 )
summary(m_log2)
##
## Call:
## lm(formula = log10(preciom) ~ log10(areaconst) + estrato + habitaciones +
## parqueaderos + banios, data = df2)
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.43451 -0.06872 -0.01034 0.05849 0.52610
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 1.350933 0.035990 37.536 < 2e-16 ***
## log10(areaconst) 0.405119 0.017972 22.541 < 2e-16 ***
## estrato4 0.162683 0.014614 11.132 < 2e-16 ***
## estrato5 0.246234 0.014903 16.523 < 2e-16 ***
## estrato6 0.392256 0.016232 24.166 < 2e-16 ***
## habitaciones -0.003213 0.003007 -1.068 0.286
## parqueaderos 0.025038 0.002811 8.908 < 2e-16 ***
## banios 0.022633 0.003470 6.522 1.06e-10 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.104 on 1094 degrees of freedom
## Multiple R-squared: 0.8292, Adjusted R-squared: 0.8281
## F-statistic: 758.9 on 7 and 1094 DF, p-value: < 2.2e-16
m2 <- m_log2 # o m_lin
par(mfrow = c(2,2))
plot(m2, which = 1, main = "Residuos vs Ajustados"); abline(h = 0, lty = 2)
plot(m2, which = 2, main = "Q–Q de residuos")
plot(m2, which = 3, main = "Scale–Location")
plot(m2, which = 5, main = "Residuos vs Leverage (Cook)")
Para una casa con: area de 300 mt2 3 parqueadero 3 baños 5 habitaciones Estrato 5 en la zona norte
El precio es de: 550 millones
newdata3 <- data.frame(
areaconst = 300, # m²
estrato = "5",
habitaciones = 5,
parqueaderos = 3,
banios = 3
)
pred_log10_2a <- predict(m_log2, newdata = newdata3)
smear <- mean(10^residuals(m_log2), na.rm = TRUE)
pred_log2a_mill <- 10^pred_log10_2a * smear
pred_log2a_mill
## 1
## 550.2154
Para una casa con: area de 300 mt2 3 parqueadero 3 baños 5 habitaciones Estrato 6 en la zona norte
El precio es de: 770 millones
newdata4 <- data.frame(
areaconst = 300, # m²
estrato = "6",
habitaciones = 5,
parqueaderos = 3,
banios = 3
)
pred_log10_3a <- predict(m_log2, newdata = newdata4)
smear <- mean(10^residuals(m_log2), na.rm = TRUE)
pred_log3a_mill <- 10^pred_log10_3a * smear
pred_log3a_mill
## 1
## 770.1133