El mercado inmobiliario urbano presenta una dinámica compleja influenciada por múltiples factores, entre ellos las características físicas de las propiedades, su ubicación geográfica y el contexto socioeconómico de las zonas en las que se encuentran. Comprender estas interacciones es clave para identificar patrones de oferta y orientar la toma de decisiones tanto de inversionistas como de entidades del sector.
or otra parte el mercado inmobiliario de Cali atraviesa un periodo de desaceleración. Sin embargo, la reciente solicitud de una empresa internacional para la compra de dos viviendas de alta gama representa una oportunidad estratégica para C&A. Este informe tiene como objetivo analizar las características de cada propiedad solicitada y proporcionar recomendaciones basadas en modelos de estimación de precios para orientar la negociación y la toma de decisiones.
library(paqueteMODELOS)
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"
)
)
# Mostrar tabla con estilo
Tabla_descriptiva %>%
kable("html", caption = "Tabla descriptiva de variables") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed", "responsive"),
full_width = FALSE,
position = "center") %>%
row_spec(0, bold = TRUE, color = "white", background = "#0073C2FF") %>% # Encabezado azul
row_spec(1:nrow(Tabla_descriptiva), background = ifelse(1:nrow(Tabla_descriptiva) %% 2 == 0, "#f7f7f7", "white"))
| 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 ha creado una nueva base de datos que contiene exclusivamente la información de las viviendas de tipo Casa ubicadas en la Zona Norte de la ciudad. De un total de 8321 de registros de la base de datos original viviendas, se han identificado 722 que cumplen con ambas características, lo que representa un 8.7% del total de la base de datos. Este subconjunto de datos será utilizado 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>
# tab: frecuencias en el filtrado (Base1)
tab <- with(Base1, table(zona, tipo))
# denominador: total de la base original
# si tu base original se llama 'datos' (como antes):
denom <- nrow(datos)
# (alternativa) denom <- nrow(paqueteMODELOS::vivienda)
# formatear cada celda como: n (xx.x%)
fmt <- function(n) sprintf("%d (%.1f%%)", n, 100 * n / denom)
cell_mat <- matrix(
fmt(as.vector(tab)),
nrow = nrow(tab), ncol = ncol(tab),
dimnames = dimnames(tab)
)
# totales (también vs base original)
rs <- rowSums(tab)
cs <- colSums(tab)
n_tot <- sum(tab)
out <- cbind(cell_mat, Total = fmt(rs))
out <- rbind(out, Total = c(fmt(cs), fmt(n_tot)))
knitr::kable(out, caption = "Cruzada zona × tipo: n (porcentaje vs base original)")
| casa | Total | |
|---|---|---|
| zona norte | 722 (8.7%) | 722 (8.7%) |
| Total | 722 (8.7%) | 722 (8.7%) |
Análisis del Mapa
Se evidencia que en el mapa se grafican ubicaciones de viviendas tipo casa no precisamente en el norte de la ciudad. Esto puede ser causado al momento de la recolección de datos, ya sea porque asignaron de forma incorrecta la zona o posiblemente un error de tipeo en las coordenadas.
# Mapa interactivo de Cali con las casas de Base1
library(leaflet)
# Asegura que las coordenadas sean numéricas
Base1$latitud <- as.numeric(Base1$latitud)
Base1$longitud <- as.numeric(Base1$longitud)
# Bounding box para encuadrar los puntos (fallback a centro de Cali si faltan NAs)
bb <- with(na.omit(Base1), 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(Base1) |>
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.7,
popup = ~paste0(
"<b>", if ("tipo" %in% names(Base1)) tipo else "Inmueble", "</b><br>",
if ("barrio" %in% names(Base1)) paste0(barrio, "<br>") else "",
if ("zona" %in% names(Base1)) 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
Se encuentra de inconsistencias en la base de datos filtrada,a continuacion se presenta la siguiente lista de barrios que, si bien aparecen en el filtro inicial como parte de la Zona Norte, geográficamente se encuentran en otras áreas 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.7,
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)
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.1 (R version 4.4.2)
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"
) %>%
row_spec(0, bold = TRUE, color = "white", background = "#0073C2") %>% # encabezado azul
column_spec(1, bold = TRUE)
| 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 |
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_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"
) %>%
row_spec(0, bold = TRUE, color = "white", background = "#0073C2") %>% # encabezado azul
column_spec(1, bold = TRUE)
| 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"
) %>%
row_spec(0, bold = TRUE, color = "white", background = "#0073C2") %>% # encabezado azul
column_spec(1, bold = TRUE)
| 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 |
vars <- c("preciom","areaconst","parqueaderos","banios","habitaciones")
vars <- intersect(vars, names(datos_imp))
# Conversión segura a numérico
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)
# X: data.frame/lista con variables numéricas
nmv <- names(X)
n <- length(nmv)
# Layout automático
nc <- ceiling(sqrt(n)); nr <- ceiling(n / nc)
op <- par(mfrow = c(nr, nc), mar = c(4, 4, 2, 1), bg = "white")
# Paleta disponible
cols <- grDevices::hcl.colors(n, palette = "Set2")
i <- 0
for (nm in nmv) {
i <- i + 1
x <- X[[nm]]
x <- x[is.finite(x) & x > -1] # log1p válido
if (length(x)) {
# Fondo con rejilla
plot(NA, xlab = "", ylab = "log(1 + x)", xlim = c(0.5, 1.5),
ylim = range(log1p(x), na.rm = TRUE), axes = FALSE, main = nm)
grid(nx = NA, ny = NULL, col = gray(0.9), lty = 3)
axis(2); box(bty = "n")
# Boxplot con color
boxplot(log1p(x), add = TRUE,
col = adjustcolor(cols[i], 0.85),
border = adjustcolor(cols[i], 0.9),
notch = TRUE, boxwex = 0.6,
medcol = "white", medlwd = 2,
whisklty = 1, outpch = 16,
outcol = adjustcolor(cols[i], 0.85),
outbg = adjustcolor(cols[i], 0.55),
axes = FALSE, names = "")
} else {
plot.new(); title(main = paste0(nm, " (sin datos válidos para log1p)"))
}
}
par(op)
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 5 % 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_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)
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),
type = "scatter", mode = "markers",
text = ~paste0("<br>Barrio: ", barrios,
"<br>Estrato: ", estrato,
"<br>Baños: ", banios,
"<br>Habitaciones: ", habitaciones),
hoverinfo = "text+x+y"
) |>
layout(title = "Precio vs Área (color: Estrato)",
xaxis = list(title = "Área construida"),
yaxis = list(title = "Precio"))
p_area
El análisis de la correlación entre el precio y el área construida, con el estrato como factor diferenciador, revela una relación positiva y clara: a medida que el área construida aumenta, el precio de la vivienda también lo hace, formando una diagonal ascendente en la nube de puntos. Se observa una prima significativa por estrato, ya que para un mismo metraje, las viviendas en estratos más altos (5 y 6) tienden a tener precios superiores a las de estratos 3 y 4, indicando que este factor agrega un valor independiente del área.
library(dplyr); library(plotly)
df <- datos_sin_out %>% filter(areaconst > 0, preciom > 0)
p_area <- plot_ly(
df, x = ~areaconst, y = ~preciom,
color = ~as.factor(estrato),
type = "scatter", mode = "markers",
text = ~paste0("<br>Barrio: ", barrios,
"<br>Estrato: ", estrato,
"<br>Baños: ", banios,
"<br>Habitaciones: ", habitaciones),
hoverinfo = "text+x+y"
) %>%
layout(
title = "Precio vs Área (color: Estrato) — ejes log10",
xaxis = list(title = "Área construida (log10)", type = "log"),
yaxis = list(title = "Precio (log10)", type = "log")
)
p_area
En esta grafica se realiza análisis a una escala logarítmica para confirmar la existencia de una relación lineal entre los datos.
# -- Boxplots interactivos: Precio por Estrato y por Zona ---
p_estrato <- plot_ly(df, x = ~as.factor(estrato), y = ~preciom,
type = "box", boxpoints = "outliers") |>
layout(title = "Precio por estrato",
xaxis = list(title = "Estrato"),
yaxis = list(title = "Precio"))
p_estrato
Respecto al estrato, se observa una clara tendencia ascendente: la mediana de los precios aumenta consistentemente a medida que el estrato sube del 3 al 6, lo que confirma una asociación positiva y directa entre el estrato socioeconómico y el precio de la vivienda. Al cruzar estos datos con el gráfico de dispersión, se evidencian valores atípicos con precios inusualmente altos en los estratos 3, 5 y 6. Estos puntos no son atípicos debido a errores en los datos, sino que representan viviendas con un área construida significativamente mayor a la esperada para sus respectivos estratos.
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")
plot_ly(x = colnames(cm), y = rownames(cm), z = cm, type = "heatmap") |>
layout(title = "Correlaciones (Spearman): precio y predictores")
El mapa de calor de correlaciones (Spearman) confirma estas observaciones. La relación más fuerte y positiva se da entre el precio y el área construida, indicando que a mayor metraje, mayor es el precio. La correlación entre el precio y el estrato es moderada a alta y también positiva, lo que subraya la influencia del estrato en el valor de la propiedad, independientemente de su área. Asimismo, la relación entre el precio y el número de baños es moderada, mientras que la del precio y el número de habitaciones resulta ser la más débil entre las variables analizadas.
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
Intercepto = 33.8 (p = 0.07) → no significativo. No tiene interpretación práctica (precio cuando todas las x son 0).
areaconst = 0.858 (SE 0.052; p < 2e-16) → significativo. Cada +1 m² se asocia con +0.858 millones (≈ $858 mil) en el precio, ceteris paribus. IC95% ≈ [0.756, 0.961].
estrato 4 = +70.2 M (p = 2.4e-05) → significativo. Frente a estrato 3, +70 millones manteniendo lo demás constante. IC95% ≈ [37.8, 102.6].
estrato 5 = +109.7 M (p = 2.3e-11) → significativo. IC95% ≈ [78.2, 141.3].
estrato 6 = +307.4 M (p < 2e-16) → significativo y grande. IC95% ≈ [245.4, 369.5].
habitaciones = +4.46 M (p = 0.326) → no significativo. IC95% ≈ [-4.43, 13.35]. Probable colinealidad con el área: cuando ya controlas por m², “tener más cuartos” no añade información adicional de forma clara.
parqueaderos = +12.8 M (p = 0.091) → marginal (10%). IC95% ≈ [-2.0, 27.6]. Señal positiva lógica, pero evidencia débil al 5%.
baños = +13.55 M (p = 0.022) → significativo. IC95% ≈ [1.92, 25.17]. Un baño extra incrementa el precio incluso controlando por área.
El área y el estrato son los motores principales del precio (más m² y mejores condiciones socio–urbanas ⇒ mayor valor). Baños también agrega valor; parqueadero apunta en la dirección esperada pero con significancia marginal; habitaciones pierde relevancia al ya controlar por m² (tamaño y distribución están correlacionados).
El modelo explica ~68.7% de la variación observada en el precio entre viviendas.
Aun así, ~31% de la variación queda sin explicar: es normal en datos inmobiliarios porque faltan atributos finos (ubicación exacta, estado/edad, acabados, lote, amenities, etc.)
¿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
El cambio a una escala logarítmica ha mejorado significativamente el modelo lineal en pesos. En primer lugar, se ha observado un mayor poder explicativo. El coeficiente de determinación (R2) subió de 0.687 a 0.788, y el R2 ajustado pasó de 0.683 a 0.785, lo que representa un aumento de aproximadamente 10 puntos porcentuales. Esto demuestra que el modelo en escala logarítmica capta una mayor proporción de la variación en el precio de las viviendas.
Además, el error es más interpretable y estable. El error residual estándar se redujo de 129.1 millones (en la escala original) a 0.1103 (en la escala logarítmica). En términos relativos, esto equivale a un error típico de ≈±29% sobre el precio, lo cual es mucho más intuitivo que el valor original. Los residuos en la escala logarítmica también muestran menos heterocedasticidad (una varianza más constante), lo que hace que el modelo sea más robusto.
# Instala los paquetes si no los tienes:
# install.packages("ggplot2")
# install.packages("plotly")
# install.packages("dplyr")
# Carga las librerías necesarias
library(ggplot2)
library(plotly)
library(dplyr)
# Supongamos que ya tienes el modelo m_log ajustado
# m_log <- lm(log10(precio) ~ areaconst + estrato + num_habitaciones + num_banos, data = df)
# Extrae los datos y calcula los valores para los gráficos de diagnóstico
modelo_df <- data.frame(
fitted_values = fitted(m_log),
residuals = resid(m_log),
# Se utiliza rstandard() para obtener los residuales estandarizados,
# que son necesarios para el gráfico de Escala-Ubicación.
sqrt_abs_residuals = sqrt(abs(rstandard(m_log))),
leverage = hatvalues(m_log),
cooks_distance = cooks.distance(m_log)
)
# ----------------------------------------------------
# 1. Gráfico de Residuales vs. Valores Ajustados
# ----------------------------------------------------
# Propósito: Evaluar la linealidad y la varianza constante (homocedasticidad)
# Si los puntos forman una banda horizontal sin patrón, el modelo es adecuado.
p1 <- ggplot(modelo_df, aes(x = fitted_values, y = residuals)) +
geom_point(alpha = 0.5, color = "#2c7bb6") +
geom_hline(yintercept = 0, linetype = "dashed", color = "red", size = 1) +
geom_smooth(method = "loess", se = FALSE, color = "#fdae61", size = 1) +
labs(
title = "Residuales vs. Valores Ajustados",
x = "Valores Ajustados (log10)",
y = "Residuales"
) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"))
# Convierte el gráfico de ggplot2 en interactivo con plotly
ggplotly(p1)
# ----------------------------------------------------
# 2. Gráfico Q-Q Normal
# ----------------------------------------------------
# Propósito: Verificar si los residuales siguen una distribución normal.
# Los puntos deben seguir la línea diagonal.
p2 <- ggplot(modelo_df, aes(sample = residuals)) +
stat_qq(color = "#2c7bb6") +
stat_qq_line(color = "red", linetype = "dashed", size = 1) +
labs(
title = "Gráfico Q-Q Normal",
x = "Cuantiles Teóricos",
y = "Cuantiles de los Residuales"
) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"))
# Convierte el gráfico de ggplot2 en interactivo con plotly
ggplotly(p2)
# ----------------------------------------------------
# 3. Gráfico de Escala-Ubicación
# ----------------------------------------------------
# Propósito: Evaluar la homocedasticidad (varianza constante de los residuales).
# Los puntos deben formar una banda horizontal sin patrón.
p3 <- ggplot(modelo_df, aes(x = fitted_values, y = sqrt_abs_residuals)) +
geom_point(alpha = 0.5, color = "#2c7bb6") +
geom_smooth(method = "loess", se = FALSE, color = "#fdae61", size = 1) +
labs(
title = "Gráfico de Escala-Ubicación",
x = "Valores Ajustados (log10)",
y = "Raíz Cuadrada de los Residuales Estandarizados"
) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"))
# Convierte el gráfico de ggplot2 en interactivo con plotly
ggplotly(p3)
# ----------------------------------------------------
# 4. Gráfico de Residuales vs. Leverage
# ----------------------------------------------------
# Propósito: Identificar puntos influyentes que pueden afectar los coeficientes del modelo.
# Los puntos con alta leverage (alto en el eje x) y/o altos residuales (alto en el eje y) son de interés.
p4 <- ggplot(modelo_df, aes(x = leverage, y = residuals, text = paste("Cook's D:", round(cooks_distance, 4)))) +
geom_point(aes(color = cooks_distance), alpha = 0.7) +
scale_color_viridis_c(name = "Distancia de Cook") +
geom_hline(yintercept = 0, linetype = "dashed", color = "red") +
labs(
title = "Residuales vs. Leverage",
x = "Leverage",
y = "Residuales"
) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"))
# Convierte el gráfico de ggplot2 en interactivo con plotly
ggplotly(p4, tooltip = "text")
1. Gráfico de Residuales vs. Valores Ajustados
Este gráfico nos ayuda a validar la suposición de linealidad y homocedasticidad. La línea de tendencia naranja sigue de cerca la línea roja discontinua, que representa el cero. Esto indica que no hay un patrón claro en los residuales, lo que apoya la suposición de linealidad. La dispersión de los puntos es relativamente constante a lo largo del eje x, lo que sugiere que el modelo ha corregido en gran medida el problema de heterocedasticidad que se observaba en el modelo lineal.
2. Gráfico Q-Q Normal
Este gráfico evalúa la suposición de normalidad en los residuales. La mayoría de los puntos se alinean con la línea de referencia diagonal, lo que indica que los residuales siguen una distribución normal. Sin embargo, se observa una ligera desviación en los extremos (las “colas” de la distribución), lo que es común en datos reales y no suele ser un problema grave para el modelo.
3. Gráfico de Escala-Ubicación
Este gráfico es otra forma de verificar la homocedasticidad (varianza constante). La línea de tendencia naranja es relativamente plana, lo que confirma que la dispersión de los residuales estandarizados es bastante uniforme a lo largo de los valores ajustados.
4. Gráfico de Residuales vs. Leverage
Este gráfico es crucial para identificar puntos influyentes. Los puntos con una alta Distancia de Cook (indicados por un color más claro) son aquellos que podrían estar afectando desproporcionadamente los coeficientes de tu modelo. Aunque hay algunos puntos con un leverage moderado, la mayoría tiene una Distancia de Cook baja. Esto significa que, aunque hay algunos valores con potencial de influencia, no hay un impacto significativo que obligue a removerlos o a reconsiderar el modelo.
Para una casa en la zona norte con:
area de 200 mt2
1 parqueadero
2 baños
4 habitaciones
Estrato 5
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
library(dplyr)
Base2a<-datos2%>%
filter(latitud<=3.41,longitud>=-76.54,tipo=="casa")
# Mapa interactivo de Cali con las casas de Base1
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, fillOpacity = 0.7,
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.1 (R version 4.4.2)
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"
) %>%
row_spec(0, bold = TRUE, color = "white", background = "#0073C2") %>% # encabezado azul
column_spec(1, bold = TRUE)
| 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"
) %>%
row_spec(0, bold = TRUE, color = "white", background = "#0073C2") %>% # encabezado azul
column_spec(1, bold = TRUE)
| 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"
) %>%
row_spec(0, bold = TRUE, color = "white", background = "#0073C2") %>% # encabezado azul
column_spec(1, bold = TRUE)
| 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 |
vars <- c("preciom","areaconst","parqueaderos","banios","habitaciones")
vars <- intersect(vars, names(datos_imp2))
# Conversión segura a numérico
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)
# X: data.frame/lista con variables numéricas
nmv <- names(X)
n <- length(nmv)
# Layout automático
nc <- ceiling(sqrt(n)); nr <- ceiling(n / nc)
op <- par(mfrow = c(nr, nc), mar = c(4, 4, 2, 1), bg = "white")
# Paleta disponible
cols <- grDevices::hcl.colors(n, palette = "Set2")
i <- 0
for (nm in nmv) {
i <- i + 1
x <- X[[nm]]
x <- x[is.finite(x) & x > -1] # log1p válido
if (length(x)) {
# Fondo con rejilla
plot(NA, xlab = "", ylab = "log(1 + x)", xlim = c(0.5, 1.5),
ylim = range(log1p(x), na.rm = TRUE), axes = FALSE, main = nm)
grid(nx = NA, ny = NULL, col = gray(0.9), lty = 3)
axis(2); box(bty = "n")
# Boxplot con color
boxplot(log1p(x), add = TRUE,
col = adjustcolor(cols[i], 0.85),
border = adjustcolor(cols[i], 0.9),
notch = TRUE, boxwex = 0.6,
medcol = "white", medlwd = 2,
whisklty = 1, outpch = 16,
outcol = adjustcolor(cols[i], 0.85),
outbg = adjustcolor(cols[i], 0.55),
axes = FALSE, names = "")
} else {
plot.new(); title(main = paste0(nm, " (sin datos válidos para log1p)"))
}
}
par(op)
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),
type = "scatter", mode = "markers",
text = ~paste0("<br>Barrio: ", barrios,
"<br>Estrato: ", estrato,
"<br>Baños: ", banios,
"<br>Habitaciones: ", habitaciones),
hoverinfo = "text+x+y"
) |>
layout(title = "Precio vs Área (color: Estrato)",
xaxis = list(title = "Área construida"),
yaxis = list(title = "Precio"))
p_area
library(dplyr); library(plotly)
df2 <- datos_sin_out2 %>% filter(areaconst > 0, preciom > 0)
p_area <- plot_ly(
df2, x = ~areaconst, y = ~preciom,
color = ~as.factor(estrato),
type = "scatter", mode = "markers",
text = ~paste0("<br>Barrio: ", barrios,
"<br>Estrato: ", estrato,
"<br>Baños: ", banios,
"<br>Habitaciones: ", habitaciones),
hoverinfo = "text+x+y"
) %>%
layout(
title = "Precio vs Área (color: Estrato) — ejes log10",
xaxis = list(title = "Área construida (log10)", type = "log"),
yaxis = list(title = "Precio (log10)", type = "log")
)
p_area
# -- Boxplots interactivos: Precio por Estrato y por Zona ---
p_estrato <- plot_ly(df2, x = ~as.factor(estrato), y = ~preciom,
type = "box", boxpoints = "outliers") |>
layout(title = "Precio por estrato",
xaxis = list(title = "Estrato"),
yaxis = list(title = "Precio"))
p_estrato
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")
plot_ly(x = colnames(cm), y = rownames(cm), z = cm, type = "heatmap") |>
layout(title = "Correlaciones (Spearman): precio y predictores")
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
Intercepto = −59.8 (SE 33.29; p = 0.073) → no significativo. No tiene interpretación práctica (precio cuando todas las x son 0). IC95% ≈ [−125.0, 5.5].
areaconst = 0.757 (SE 0.050; p < 2e−16) → significativo. Cada +1 m² se asocia con +0.757 millones (≈ $757 mil) en el precio, ceteris paribus. IC95% ≈ [0.66, 0.86].
estrato 4 = +88.1 M (SE 26.93; p = 0.0011) → significativo. Frente a estrato 3, +88 millones manteniendo lo demás constante. IC95% ≈ [35.3, 140.9].
estrato 5 = +154.5 M (SE 27.41; p = 2.2e−08) → significativo. IC95% ≈ [100.8, 208.2].
estrato 6 = +404.6 M (SE 29.68; p < 2e−16) → significativo y grande. IC95% ≈ [346.5, 462.8].
habitaciones = −8.64 M (SE 5.50; p = 0.116) → no significativo. IC95% ≈ [−19.4, 2.1]. Probable colinealidad con área: cuando ya controlas por m², “más cuartos” no añade valor adicional claro.
parqueaderos = +54.1 M (SE 5.28; p < 2e−16) → significativo. Un cupo extra de parqueadero se asocia con +54 millones. IC95% ≈ [43.8, 64.4].
baños = +36.2 M (SE 6.39; p = 1.9e−08) → significativo. Un baño adicional se asocia con +36 millones. IC95% ≈ [23.7, 48.7].
En conjunto, los resultados confirman que el tamaño y la localización socioeconómica son los principales determinantes del precio: cada m² adicional se asocia, en promedio, con +0,757 millones de pesos, y pertenecer a estratos más altos implica primas sustanciales frente al estrato 3 (+88 M en estrato 4, +155 M en estrato 5 y +405 M en estrato 6, ceteris paribus). Entre los atributos internos, parqueadero (+54 M por cupo) y baños (+36 M por unidad) agregan valor de forma estadísticamente significativa, mientras que el número de habitaciones no muestra efecto adicional una vez controlado el metraje—coherente con su colinealidad con el tamaño. El intercepto no es de interés práctico. En términos de gestión, para ajustar o comparar ofertas conviene priorizar área construida, estrato, parqueaderos y baños; las “habitaciones” influyen principalmente a través de cómo se distribuye el área.
El modelo explica ≈ 73.1% de la variación del precio entre viviendas (R² ajustado ≈ 72.9%, casi igual ⇒ poco sobreajuste).
Aun así, ≈ 26.9% de la variación queda sin explicar: habitual en inmobiliario por falta de atributos finos (ubicación exacta/barrio, edad y estado, acabados, tamaño del lote, amenities, etc.).
¿Cómo mejorarlo?
1- Agregar variables de ubicación como el barrio
2- 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
# Instala los paquetes si no los tienes:
# install.packages("ggplot2")
# install.packages("plotly")
# install.packages("dplyr")
# Carga las librerías necesarias
library(ggplot2)
library(plotly)
library(dplyr)
# Supongamos que ya tienes el modelo m_log ajustado
# m_log <- lm(log10(precio) ~ areaconst + estrato + num_habitaciones + num_banos, data = df)
# Extrae los datos y calcula los valores para los gráficos de diagnóstico
modelo_df <- data.frame(
fitted_values = fitted(m_log2),
residuals = resid(m_log2),
# Se utiliza rstandard() para obtener los residuales estandarizados,
# que son necesarios para el gráfico de Escala-Ubicación.
sqrt_abs_residuals = sqrt(abs(rstandard(m_log2))),
leverage = hatvalues(m_log2),
cooks_distance = cooks.distance(m_log2)
)
# ----------------------------------------------------
# 1. Gráfico de Residuales vs. Valores Ajustados
# ----------------------------------------------------
# Propósito: Evaluar la linealidad y la varianza constante (homocedasticidad)
# Si los puntos forman una banda horizontal sin patrón, el modelo es adecuado.
p1 <- ggplot(modelo_df, aes(x = fitted_values, y = residuals)) +
geom_point(alpha = 0.5, color = "#2c7bb6") +
geom_hline(yintercept = 0, linetype = "dashed", color = "red", size = 1) +
geom_smooth(method = "loess", se = FALSE, color = "#fdae61", size = 1) +
labs(
title = "Residuales vs. Valores Ajustados",
x = "Valores Ajustados (log10)",
y = "Residuales"
) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"))
# Convierte el gráfico de ggplot2 en interactivo con plotly
ggplotly(p1)
## `geom_smooth()` using formula = 'y ~ x'
# ----------------------------------------------------
# 2. Gráfico Q-Q Normal
# ----------------------------------------------------
# Propósito: Verificar si los residuales siguen una distribución normal.
# Los puntos deben seguir la línea diagonal.
p2 <- ggplot(modelo_df, aes(sample = residuals)) +
stat_qq(color = "#2c7bb6") +
stat_qq_line(color = "red", linetype = "dashed", size = 1) +
labs(
title = "Gráfico Q-Q Normal",
x = "Cuantiles Teóricos",
y = "Cuantiles de los Residuales"
) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"))
# Convierte el gráfico de ggplot2 en interactivo con plotly
ggplotly(p2)
# ----------------------------------------------------
# 3. Gráfico de Escala-Ubicación
# ----------------------------------------------------
# Propósito: Evaluar la homocedasticidad (varianza constante de los residuales).
# Los puntos deben formar una banda horizontal sin patrón.
p3 <- ggplot(modelo_df, aes(x = fitted_values, y = sqrt_abs_residuals)) +
geom_point(alpha = 0.5, color = "#2c7bb6") +
geom_smooth(method = "loess", se = FALSE, color = "#fdae61", size = 1) +
labs(
title = "Gráfico de Escala-Ubicación",
x = "Valores Ajustados (log10)",
y = "Raíz Cuadrada de los Residuales Estandarizados"
) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"))
# Convierte el gráfico de ggplot2 en interactivo con plotly
ggplotly(p3)
## `geom_smooth()` using formula = 'y ~ x'
# ----------------------------------------------------
# 4. Gráfico de Residuales vs. Leverage
# ----------------------------------------------------
# Propósito: Identificar puntos influyentes que pueden afectar los coeficientes del modelo.
# Los puntos con alta leverage (alto en el eje x) y/o altos residuales (alto en el eje y) son de interés.
p4 <- ggplot(modelo_df, aes(x = leverage, y = residuals, text = paste("Cook's D:", round(cooks_distance, 4)))) +
geom_point(aes(color = cooks_distance), alpha = 0.7) +
scale_color_viridis_c(name = "Distancia de Cook") +
geom_hline(yintercept = 0, linetype = "dashed", color = "red") +
labs(
title = "Residuales vs. Leverage",
x = "Leverage",
y = "Residuales"
) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"))
# Convierte el gráfico de ggplot2 en interactivo con plotly
ggplotly(p4, tooltip = "text")
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
Hemos identificado cuatro ofertas que se alinean con su presupuesto y requisitos para la vivienda en la zona norte.
La primera propuesta, y la más completa, es una casa de 200 m² en estrato 4, con cuatro habitaciones, un parqueadero y dos baños, por un valor de $330 millones. Esta opción satisface todas las especificaciones incluso se podria aumenta a 5 habitaciones y 2 parqueaderos.
Si la condición de estrato 5 es una prioridad para usted, será necesario ajustar algunos de los atributos de la vivienda. Dependiendo de sus preferencias, las compensaciones podrían ser:
Disminuir el área construida si el tamaño es flexible.
Reducir el número de baños y/o habitaciones si el área es un requisito fijo. Por ejemplo, podríamos ofrecer una casa de igual área, pero con menos baños, y 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))
)
# --- Gráfico con formato mejorado ---
ggplot(df, aes(x = Variable, y = Valor, fill = Variable)) +
geom_col(width = 0.7, show.legend = FALSE) +
geom_text(aes(label = round(Valor, 2)),
vjust = -0.4, size = 4, fontface = "bold", color = "black") +
scale_fill_brewer(palette = "Set2") +
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),
axis.text.x = element_text(angle = 20, hjust = 1),
panel.grid.minor = element_blank()
)
# 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))
)
# --- Gráfico con formato mejorado ---
ggplot(df, aes(x = Variable, y = Valor, fill = Variable)) +
geom_col(width = 0.7, show.legend = FALSE) +
geom_text(aes(label = round(Valor, 2)),
vjust = -0.4, size = 4, fontface = "bold", color = "black") +
scale_fill_brewer(palette = "Set2") +
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),
axis.text.x = element_text(angle = 20, hjust = 1),
panel.grid.minor = element_blank()
)
# 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))
)
# --- Gráfico con formato mejorado ---
ggplot(df, aes(x = Variable, y = Valor, fill = Variable)) +
geom_col(width = 0.7, show.legend = FALSE) +
geom_text(aes(label = round(Valor, 2)),
vjust = -0.4, size = 4, fontface = "bold", color = "black") +
scale_fill_brewer(palette = "Set2") +
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),
axis.text.x = element_text(angle = 20, hjust = 1),
panel.grid.minor = element_blank()
)
# 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))
)
# --- Gráfico con formato mejorado ---
ggplot(df, aes(x = Variable, y = Valor, fill = Variable)) +
geom_col(width = 0.7, show.legend = FALSE) +
geom_text(aes(label = round(Valor, 2)),
vjust = -0.4, size = 4, fontface = "bold", color = "black") +
scale_fill_brewer(palette = "Set2") +
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),
axis.text.x = element_text(angle = 20, hjust = 1),
panel.grid.minor = element_blank()
)
# 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))
)
# --- Gráfico con formato mejorado ---
ggplot(df, aes(x = Variable, y = Valor, fill = Variable)) +
geom_col(width = 0.7, show.legend = FALSE) +
geom_text(aes(label = round(Valor, 2)),
vjust = -0.4, size = 4, fontface = "bold", color = "black") +
scale_fill_brewer(palette = "Set2") +
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),
axis.text.x = element_text(angle = 20, hjust = 1),
panel.grid.minor = element_blank()
)
Para el caso del apartamento, el presupuesto del cliente es suficiente para cubrir las opciones disponibles y cumplir con sus requerimientos.