Introducción

La Flora es uno de los barrios tradicionales del norte de Cali. Es estrato 5 en su gran mayoría, y en sus calles conviven edificios de apartamentos con casas amplias de urbanización, muchas de ellas con décadas encima. Esa convivencia de dos mercados, el del apartamento en edificio y el de la casa grande, es lo que hace interesante modelar sus precios: no siempre lo más grande es lo más caro por metro.

En este informe se filtra la base Datos_Vivienda (8.322 ofertas de vivienda usada en Cali) por el barrio La Flora, sin filtrar por tipo de inmueble, y se resuelven dos bloques:

  1. Modelación simple: tres modelos con un solo predictor, su interpretación, la validación de supuestos y la pertinencia de una transformación logarítmica.
  2. Modelación múltiple: dos modelos con varios predictores (uno de ellos con variable dummy), validación de supuestos y de transformaciones, selección de variables paso a paso, una predicción hipotética con predict() y el cálculo del MAE.

1. Preparación de los datos

1.1 Librerías

# Si falta alguna librería, instálala una sola vez con:
# install.packages(c("readxl", "dplyr", "ggplot2", "plotly", "lmtest", "car"))

library(readxl)   # leer el archivo de Excel
library(dplyr)    # filtrar, transformar y resumir datos
library(ggplot2)  # gráficos
library(plotly)   # gráficos interactivos
library(lmtest)   # pruebas de supuestos: Breusch-Pagan, Durbin-Watson y RESET
library(car)      # VIF (multicolinealidad) y errores estándar robustos

1.2 Carga de la base

El archivo Datos_Vivienda.xlsx debe estar en la misma carpeta que este .Rmd.

datos <- read_excel("Datos_Vivienda.xlsx",
    col_types = c("text", "text", "text",
        "numeric", "numeric", "numeric",
        "numeric", "numeric", "text", "text",
        "numeric", "numeric"))

# Se fijan los nombres de las 12 columnas (en el mismo orden del archivo) para que
# el código no dependa de mayúsculas o tildes. Si tu archivo ya trae estos nombres,
# esta línea no cambia nada.
names(datos) <- c("Zona", "piso", "Estrato", "precio_millon", "Area_contruida",
                  "parqueaderos", "Banos", "Habitaciones", "Tipo", "Barrio",
                  "cordenada_longitud", "Cordenada_latitud")

# Identificador único por fila, como en el ejemplo de clase
datos$id <- 1:nrow(datos)

dim(datos)
## [1] 8319   13

1.3 Unificación del nombre del barrio

En la base, un mismo barrio aparece escrito de varias formas: con o sin artículo, pegado, en mayúsculas o con el prefijo “Urbanización”. Antes de filtrar conviene ver todas las grafías que contienen la palabra flora:

# Versión estandarizada del nombre: minúsculas y sin espacios sobrantes
datos$barrio_std <- tolower(trimws(datos$Barrio))

# Todas las formas en que aparece "flora" en la base
table(datos$Barrio[grepl("flora", datos$barrio_std)])
## 
##                 flora      flora industrial              floralia 
##                     1                    16                     6 
##              la flora              La Flora               laflora 
##                   366                     2                     1 
##        norte la flora urbanizacion la flora 
##                     1                    83

La tabla mezcla dos situaciones:

  • Son La Flora: “La Flora”, “la flora”, “laflora”, “flora”, “norte la flora” y “urbanizacion la flora”. Son el mismo barrio escrito de seis maneras. Si filtráramos solo por “la flora” dejaríamos por fuera la urbanización completa, con más de 80 ofertas.
  • No son La Flora: “Flora Industrial” es un sector industrial vecino con otra dinámica de precios, y “Floralia” es un barrio distinto. Ambos se excluyen.

También conviene revisar la zona asignada a estos registros:

es_flora <- grepl("flora", datos$barrio_std) & !grepl("industrial|floralia", datos$barrio_std)
table(datos$Zona[es_flora])
## 
## Zona Norte   Zona Sur 
##        453          1

La Flora está en el norte de la ciudad, así que el único registro marcado como “Zona Sur” es un error de digitación y se descarta.

datos_sub <- datos %>%
  filter(grepl("flora", barrio_std),
         !grepl("industrial|floralia", barrio_std),
         Zona == "Zona Norte") %>%
  mutate(Barrio  = "La Flora",
         Estrato = as.numeric(Estrato),          # viene como texto desde el Excel
         Tipo    = factor(Tipo, levels = c("Apartamento", "Casa")))

nrow(datos_sub)
## [1] 453
table(datos_sub$Tipo)
## 
## Apartamento        Casa 
##         330         123

1.4 Depuración

Una vivienda en venta con cero habitaciones o cero baños es un error de digitación, y además impide aplicar logaritmos (log(0) no existe). Esos registros se revisan y se retiran:

datos_sub %>%
  filter(Habitaciones == 0 | Banos == 0) %>%
  select(id, Tipo, precio_millon, Area_contruida, Habitaciones, Banos)
id Tipo precio_millon Area_contruida Habitaciones Banos
2295 Casa 334 243 0 0
2643 Apartamento 420 104 0 2
2688 Apartamento 320 85 0 2
2905 Casa 530 455 4 0
3893 Casa 470 336 0 0
datos_sub <- datos_sub %>% filter(Habitaciones > 0, Banos > 0)
nrow(datos_sub)
## [1] 448
# Valores faltantes por variable
colSums(is.na(datos_sub[, c("precio_millon", "Area_contruida", "Habitaciones",
                            "Banos", "parqueaderos", "Estrato", "Tipo", "piso")]))
##  precio_millon Area_contruida   Habitaciones          Banos   parqueaderos 
##              0              0              0              0             17 
##        Estrato           Tipo           piso 
##              0              0              0

Quedan 448 ofertas, muy por encima del mínimo de 30. Las variables centrales (precio, área, habitaciones, baños, estrato y tipo) están completas. Faltan datos en parqueaderos (17 registros) y en piso. El piso no se usará: no aplica a las casas y está vacío en muchos registros.

1.5 Mirada exploratoria

# Resumen por tipo de vivienda y total del barrio
resumen <- datos_sub %>%
  group_by(Tipo) %>%
  summarise(ofertas = n(),
            precio_promedio = round(mean(precio_millon), 1),
            precio_mediano  = median(precio_millon),
            area_promedio   = round(mean(Area_contruida), 1),
            habitaciones    = round(mean(Habitaciones), 1),
            banos           = round(mean(Banos), 1),
            precio_m2       = round(median(precio_millon / Area_contruida), 2))

total <- datos_sub %>%
  summarise(ofertas = n(),
            precio_promedio = round(mean(precio_millon), 1),
            precio_mediano  = median(precio_millon),
            area_promedio   = round(mean(Area_contruida), 1),
            habitaciones    = round(mean(Habitaciones), 1),
            banos           = round(mean(Banos), 1),
            precio_m2       = round(median(precio_millon / Area_contruida), 2)) %>%
  mutate(Tipo = "Total La Flora")

rbind(as.data.frame(resumen), as.data.frame(total))
Tipo ofertas precio_promedio precio_mediano area_promedio habitaciones banos precio_m2
Apartamento 328 334.9 320.0 99.7 2.9 2.5 3.48
Casa 120 511.0 482.5 310.9 4.5 3.9 1.72
Total La Flora 448 382.1 353.5 156.3 3.4 2.9 3.20
# Distribución del estrato
table(datos_sub$Estrato)
## 
##   3   4   5   6 
##   1  37 404   6
# Correlaciones entre el precio y las variables cuantitativas
cor_tabla <- round(cor(datos_sub[, c("precio_millon", "Area_contruida", "Habitaciones",
                                     "Banos", "parqueaderos", "Estrato")],
                       use = "complete.obs"), 2)
cor_tabla
##                precio_millon Area_contruida Habitaciones Banos parqueaderos
## precio_millon           1.00           0.77         0.54  0.67         0.46
## Area_contruida          0.77           1.00         0.68  0.65         0.47
## Habitaciones            0.54           0.68         1.00  0.65         0.39
## Banos                   0.67           0.65         0.65  1.00         0.55
## parqueaderos            0.46           0.47         0.39  0.55         1.00
## Estrato                 0.03          -0.03        -0.05  0.00         0.00
##                Estrato
## precio_millon     0.03
## Area_contruida   -0.03
## Habitaciones     -0.05
## Banos             0.00
## parqueaderos      0.00
## Estrato           1.00
ggplotly(
  ggplot(datos_sub, aes(x = precio_millon)) +
    geom_histogram(binwidth = 50, fill = "#2a78d6", color = "white", alpha = 0.9) +
    labs(title = "Figura 1. Distribución del precio de la vivienda en La Flora",
         x = "Precio (millones de pesos)", y = "Número de ofertas") +
    theme_minimal()
)
ggplotly(
  ggplot(datos_sub, aes(x = Tipo, y = precio_millon, fill = Tipo)) +
    geom_boxplot(alpha = 0.8, outlier.colour = "black") +
    scale_fill_manual(values = c("Apartamento" = "#2a78d6", "Casa" = "#eb6834")) +
    labs(title = "Figura 2. Precio según el tipo de vivienda",
         x = "", y = "Precio (millones de pesos)") +
    theme_minimal()
)
ggplotly(
  ggplot(datos_sub, aes(x = Area_contruida, y = precio_millon, color = Tipo)) +
    geom_point(alpha = 0.7, size = 2) +
    geom_smooth(method = "lm", se = FALSE, linewidth = 0.8) +
    scale_color_manual(values = c("Apartamento" = "#2a78d6", "Casa" = "#eb6834")) +
    labs(title = paste("Figura 3. Precio vs. área construida  |  r =",
                       round(cor(datos_sub$precio_millon, datos_sub$Area_contruida), 2)),
         x = "Área construida (m²)", y = "Precio (millones de pesos)") +
    theme_minimal()
)

Lo que muestran los datos. En La Flora predominan los apartamentos (328 de 448 ofertas), con unos 100 m² en promedio y un precio mediano de 320 millones. Las 120 casas son otra cosa: promedian 311 m², el triple, pero su precio mediano (482.5 millones) apenas es una vez y media el de un apartamento. El metro cuadrado de casa cuesta la mitad que el de apartamento (1.72 frente a 3.48 millones por m²). Esa es la tensión central de este barrio, y los modelos tendrán que lidiar con ella.

El histograma se concentra entre 250 y 450 millones (precio mediano de 353.5), pero tiene una cola larga a la derecha: 20 ofertas superan los 600 millones y la más cara llega a 1400. El estrato casi no varía (90 % de las ofertas son estrato 5), así que su correlación con el precio es prácticamente nula (0.03).

En la Figura 3, el área tiene la relación más fuerte con el precio (r = 0.77). Las dos rectas cuentan la historia: la de los apartamentos sube con pendiente pronunciada y la de las casas, con cerca de la mitad de inclinación, es mucho más plana. La nube se abre como un abanico hacia las áreas grandes, una primera señal de heterocedasticidad.

1.6 Funciones de apoyo para validar supuestos

Cada modelo se va a evaluar con las mismas cuatro pruebas formales, además de los cuatro gráficos de diagnóstico de plot(). Para no repetir código se definen unas funciones de apoyo:

Supuesto Prueba Hipótesis nula (H0) Se cumple si
Normalidad de los residuos Shapiro-Wilk Los residuos son normales p > 0.05
Varianza constante Breusch-Pagan Homocedasticidad p > 0.05
Independencia Durbin-Watson No hay autocorrelación p > 0.05
Forma funcional RESET de Ramsey El modelo lineal está bien especificado p > 0.05

Una advertencia sobre Durbin-Watson: esta prueba fue pensada para datos ordenados en el tiempo. Aquí las filas siguen el orden en que vienen en el archivo, que no tiene sentido temporal, así que su resultado depende de cómo esté ordenada la base. Un p-valor bajo indica que ofertas contiguas en el archivo se parecen entre sí (por ejemplo, porque se publicaron o cargaron juntas), no que exista una dinámica en el tiempo. Se reporta, pero con esa cautela.

# 0) Formato de números y p-valores (para las tablas y para el texto)
num  <- function(x, d = 2) formatC(as.numeric(x), format = "f", digits = d)
pct  <- function(b, d = 1) formatC((exp(as.numeric(b)) - 1) * 100, format = "f", digits = d)
sgn  <- function(x, d = 3) ifelse(x < 0, paste("-", num(abs(x), d)), paste("+", num(x, d)))
pnum <- function(p) {
  if (is.character(p)) return(p)
  ifelse(p < 0.001, "< 0.001", formatC(as.numeric(p), format = "f", digits = 3))
}
pval <- function(p) { s <- pnum(p); ifelse(grepl("<", s), s, paste("=", s)) }

# 1) Las cuatro pruebas de supuestos en una sola tabla
validar_supuestos <- function(modelo) {
  pruebas <- list(shapiro.test(residuals(modelo)),
                  bptest(modelo),
                  dwtest(modelo),
                  resettest(modelo, power = 2:3))
  p   <- sapply(pruebas, function(x) x$p.value)
  est <- sapply(pruebas, function(x) unname(x$statistic))
  data.frame(Supuesto    = c("Normalidad de los residuos", "Varianza constante",
                             "Independencia de los errores", "Forma funcional lineal"),
             Prueba      = c("Shapiro-Wilk", "Breusch-Pagan", "Durbin-Watson", "RESET de Ramsey"),
             Estadistico = round(est, 3),
             p_valor     = pnum(p),
             Resultado   = ifelse(p > 0.05, "Se cumple", "No se cumple"))
}

# 2) MAE en la escala original (millones), aunque el modelo esté en logaritmos.
#    Así se pueden comparar modelos con y sin log en la misma unidad.
mae <- function(modelo) {
  y_log <- grepl("^log\\(", deparse(formula(modelo)[[2]]))
  real  <- model.response(model.frame(modelo))
  pred  <- fitted(modelo)
  if (y_log) { real <- exp(real); pred <- exp(pred) }
  mean(abs(real - pred))
}

# 3) Tabla comparativa de varios modelos
comparar_modelos <- function(modelos) {
  filas <- lapply(names(modelos), function(nombre) {
    m <- modelos[[nombre]]
    s <- validar_supuestos(m)
    data.frame(Modelo         = nombre,
               R2_ajustado    = round(summary(m)$adj.r.squared, 3),
               MAE            = round(mae(m), 1),
               p_Shapiro      = s$p_valor[1],
               p_BreuschPagan = s$p_valor[2],
               p_DurbinWatson = s$p_valor[3],
               p_RESET        = s$p_valor[4],
               Cook_max       = round(max(cooks.distance(m)), 3))
  })
  tabla <- do.call(rbind, filas)
  rownames(tabla) <- NULL
  tabla
}

# 4) Las cuatro especificaciones (con y sin logaritmo) de un modelo simple
comparar_log <- function(variable, datos) {
  formulas <- c("Lin-Lin" = paste0("precio_millon ~ ", variable),
                "Log-Lin" = paste0("log(precio_millon) ~ ", variable),
                "Lin-Log" = paste0("precio_millon ~ log(", variable, ")"),
                "Log-Log" = paste0("log(precio_millon) ~ log(", variable, ")"))
  modelos <- lapply(formulas, function(f) lm(as.formula(f), data = datos))
  comparar_modelos(modelos)
}

En las tablas comparativas, Lin-Lin es precio ~ x, Log-Lin es log(precio) ~ x, Lin-Log es precio ~ log(x) y Log-Log es log(precio) ~ log(x). El R² ajustado solo es comparable entre modelos con la misma variable dependiente. Por eso el criterio para comparar escalas distintas es el MAE en millones, junto con el cumplimiento de los supuestos. La columna Cook_max muestra la mayor distancia de Cook del modelo: un valor cercano o superior a 1 indica que una sola observación está moviendo la estimación.

2. Modelación simple

Se eligen los tres predictores con mayor correlación con el precio: el área construida (r = 0.77), el número de baños (r = 0.67) y el número de habitaciones (r = 0.54).

2.1 Modelo simple 1: precio ~ área construida

Estimación

mod1 <- lm(precio_millon ~ Area_contruida, data = datos_sub)
summary(mod1)
## 
## Call:
## lm(formula = precio_millon ~ Area_contruida, data = datos_sub)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -285.51  -49.96   -8.95   54.23  491.18 
## 
## Coefficients:
##                 Estimate Std. Error t value Pr(>|t|)    
## (Intercept)    245.68338    6.73725   36.47   <2e-16 ***
## Area_contruida   0.87290    0.03406   25.63   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 87.45 on 446 degrees of freedom
## Multiple R-squared:  0.5956, Adjusted R-squared:  0.5947 
## F-statistic: 656.9 on 1 and 446 DF,  p-value: < 2.2e-16
b1 <- coef(mod1)

Interpretación

La ecuación estimada es precio = 245.68 + 0.873 × área.

En La Flora, cada metro cuadrado adicional se asocia, en promedio, con 0.87 millones más en el precio de oferta, unos 8.7 millones por cada 10 m². El intercepto es alto (245.7 millones) y no describe una vivienda de 0 m². Indica que en este barrio buena parte del precio se paga por la ubicación antes de contar los metros: la recta arranca alta y sube despacio.

Ambos coeficientes son altamente significativos (p < 0.001). El R² de 0.596 dice que el área sola explica cerca del 60 % de la variación del precio.

Validación de supuestos

par(mfrow = c(2, 2))
plot(mod1)

par(mfrow = c(1, 1))

sup1 <- validar_supuestos(mod1)
sup1
Supuesto Prueba Estadistico p_valor Resultado
Normalidad de los residuos Shapiro-Wilk 0.961 < 0.001 No se cumple
Varianza constante Breusch-Pagan 79.159 < 0.001 No se cumple
Independencia de los errores Durbin-Watson 1.812 0.023 No se cumple
Forma funcional lineal RESET de Ramsey 12.785 < 0.001 No se cumple
# La observación más influyente del modelo
cook1 <- cooks.distance(mod1)
datos_sub[which.max(cook1), c("id", "Tipo", "precio_millon", "Area_contruida",
                              "Habitaciones", "Banos", "Estrato")]
id Tipo precio_millon Area_contruida Habitaciones Banos Estrato
8319 Casa 1400 838 5 5 5
max(cook1)
## [1] 0.9888881

Los diagnósticos muestran tres problemas. El primero es la varianza: en Residuals vs Fitted y Scale-Location los residuos se abren a medida que crece el precio ajustado, y Breusch-Pagan lo confirma con contundencia (p < 0.001). El segundo es la normalidad: el Q-Q plot se despega de la diagonal, sobre todo en la cola superior (Shapiro p < 0.001). El tercero es la influencia: una casa de 838 m² ofertada en 1400 millones tiene una distancia de Cook de 0.99, prácticamente en el umbral de 1. Esa sola vivienda tira de la recta. RESET también rechaza la forma lineal (p < 0.001).

¿Conviene una transformación logarítmica?

cmp1 <- comparar_log("Area_contruida", datos_sub)
cmp1
Modelo R2_ajustado MAE p_Shapiro p_BreuschPagan p_DurbinWatson p_RESET Cook_max
Lin-Lin 0.595 65.4 < 0.001 < 0.001 0.023 < 0.001 0.989
Log-Lin 0.508 67.2 < 0.001 0.131 0.065 < 0.001 0.380
Lin-Log 0.601 63.1 < 0.001 < 0.001 < 0.001 < 0.001 0.832
Log-Log 0.581 63.4 0.002 0.224 0.002 < 0.001 0.095

La comparación no tiene un ganador absoluto, así que hay que sopesar. El Lin-Log tiene el R² ajustado más alto (0.601), pero su varianza sigue siendo no constante. Las especificaciones con el precio en logaritmo corrigen la heterocedasticidad, y entre ellas el Log-Log es claramente mejor que el Log-Lin. Frente al modelo lineal, el Log-Log cumple homocedasticidad (Breusch-Pagan p = 0.224), acerca los residuos a la normalidad, reduce el MAE de 65.4 a 63.4 millones y baja la distancia de Cook máxima de 0.99 a 0.10: en escala logarítmica, la casa de 838 m² deja de ser una amenaza. Cede un poco de R² ajustado, pero esa cifra no es comparable entre escalas.

mod1_log <- lm(log(precio_millon) ~ log(Area_contruida), data = datos_sub)
summary(mod1_log)
## 
## Call:
## lm(formula = log(precio_millon) ~ log(Area_contruida), data = datos_sub)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -0.6751 -0.1334  0.0083  0.1673  0.6529 
## 
## Coefficients:
##                     Estimate Std. Error t value Pr(>|t|)    
## (Intercept)          3.77705    0.08547   44.19   <2e-16 ***
## log(Area_contruida)  0.43525    0.01748   24.91   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 0.2082 on 446 degrees of freedom
## Multiple R-squared:  0.5817, Adjusted R-squared:  0.5808 
## F-statistic: 620.3 on 1 and 446 DF,  p-value: < 2.2e-16
par(mfrow = c(2, 2))
plot(mod1_log)

par(mfrow = c(1, 1))

sup1_log <- validar_supuestos(mod1_log)
sup1_log
Supuesto Prueba Estadistico p_valor Resultado
Normalidad de los residuos Shapiro-Wilk 0.989 0.002 No se cumple
Varianza constante Breusch-Pagan 1.481 0.224 Se cumple
Independencia de los errores Durbin-Watson 1.723 0.002 No se cumple
Forma funcional lineal RESET de Ramsey 14.623 < 0.001 No se cumple
b1l <- coef(mod1_log)

Interpretación del modelo Log-Log. El coeficiente 0.435 es una elasticidad: si el área construida aumenta 1 %, el precio aumenta en promedio 0.44 %. Con un aumento del 10 % en el área, el precio sube cerca de 4.2 %. La elasticidad es baja: duplicar el área multiplica el precio apenas por 1.35. Es la misma tensión de la exploración: en La Flora, los metros adicionales se pagan cada vez menos, sobre todo cuando vienen en forma de casa grande.

Los residuos siguen sin ser del todo normales (Shapiro p = 0.002) y RESET aún detecta curvatura (p < 0.001). Parte de esa curvatura viene de que el barrio mezcla dos mercados (apartamentos y casas) que una sola variable no separa, y esa es la motivación de la dummy de la sección 3. Con 448 observaciones, el teorema del límite central respalda la inferencia sobre los coeficientes. Conclusión: la transformación logarítmica es pertinente y el Log-Log es el modelo simple preferido para el área.

2.2 Modelo simple 2: precio ~ baños

Estimación

mod2 <- lm(precio_millon ~ Banos, data = datos_sub)
summary(mod2)
## 
## Call:
## lm(formula = precio_millon ~ Banos, data = datos_sub)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -230.30  -58.43   -7.76   37.24  819.63 
## 
## Coefficients:
##             Estimate Std. Error t value Pr(>|t|)    
## (Intercept)  117.687     14.385   8.181 2.96e-15 ***
## Banos         92.537      4.749  19.484  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 101.1 on 446 degrees of freedom
## Multiple R-squared:  0.4598, Adjusted R-squared:  0.4586 
## F-statistic: 379.6 on 1 and 446 DF,  p-value: < 2.2e-16
b2 <- coef(mod2)

Interpretación

La ecuación es precio = 117.69 + 92.54 × baños. Cada baño adicional se asocia con 92.5 millones más. Más que el valor de un baño en sí, el coeficiente captura lo que los baños delatan: tamaño, categoría y, a menudo, una remodelación reciente. El coeficiente es significativo (p < 0.001) y el modelo explica el 46 % de la variación del precio (R² = 0.460).

Validación de supuestos

par(mfrow = c(2, 2))
plot(mod2)

par(mfrow = c(1, 1))

sup2 <- validar_supuestos(mod2)
sup2
Supuesto Prueba Estadistico p_valor Resultado
Normalidad de los residuos Shapiro-Wilk 0.874 < 0.001 No se cumple
Varianza constante Breusch-Pagan 12.942 < 0.001 No se cumple
Independencia de los errores Durbin-Watson 1.729 0.002 No se cumple
Forma funcional lineal RESET de Ramsey 1.814 0.164 Se cumple

Como los baños toman pocos valores, los residuos se ordenan en columnas verticales, algo esperable con un predictor discreto. La forma lineal es aceptable (RESET p = 0.164), pero la dispersión crece con el número de baños, y Breusch-Pagan rechaza la varianza constante (p < 0.001). Los residuos tampoco son normales (p < 0.001): hay viviendas con muchos baños y precios muy por encima de lo esperado.

¿Conviene una transformación logarítmica?

cmp2 <- comparar_log("Banos", datos_sub)
cmp2
Modelo R2_ajustado MAE p_Shapiro p_BreuschPagan p_DurbinWatson p_RESET Cook_max
Lin-Lin 0.459 68.6 < 0.001 < 0.001 0.002 0.164 0.417
Log-Lin 0.460 69.6 < 0.001 0.582 0.079 0.416 0.090
Lin-Log 0.423 70.5 < 0.001 < 0.001 < 0.001 < 0.001 0.338
Log-Log 0.456 68.7 < 0.001 0.276 0.021 0.014 0.081
mod2_log <- lm(log(precio_millon) ~ Banos, data = datos_sub)
summary(mod2_log)
## 
## Call:
## lm(formula = log(precio_millon) ~ Banos, data = datos_sub)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -0.8167 -0.1449  0.0139  0.1332  0.9751 
## 
## Coefficients:
##             Estimate Std. Error t value Pr(>|t|)    
## (Intercept)  5.27158    0.03363  156.77   <2e-16 ***
## Banos        0.21701    0.01110   19.55   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 0.2362 on 446 degrees of freedom
## Multiple R-squared:  0.4614, Adjusted R-squared:  0.4602 
## F-statistic: 382.1 on 1 and 446 DF,  p-value: < 2.2e-16
par(mfrow = c(2, 2))
plot(mod2_log)

par(mfrow = c(1, 1))

sup2_log <- validar_supuestos(mod2_log)
sup2_log
Supuesto Prueba Estadistico p_valor Resultado
Normalidad de los residuos Shapiro-Wilk 0.984 < 0.001 No se cumple
Varianza constante Breusch-Pagan 0.303 0.582 Se cumple
Independencia de los errores Durbin-Watson 1.868 0.079 Se cumple
Forma funcional lineal RESET de Ramsey 0.878 0.416 Se cumple
b2l <- coef(mod2_log)

Aquí gana el Log-Lin, con el precio en logaritmo y los baños en niveles. Es la única especificación que cumple a la vez varianza constante (Breusch-Pagan p = 0.582) y forma funcional (RESET p = 0.416), con el R² ajustado más alto (0.460). Su MAE (69.6) es casi igual al del lineal (68.6). El Log-Log también corrige la varianza, pero rompe la forma funcional, y el Lin-Log no corrige nada.

Interpretación del modelo Log-Lin. En un modelo con el precio en logaritmo y el predictor en niveles, el coeficiente se traduce en porcentaje con (e^b − 1) × 100. Cada baño adicional eleva el precio en un 24.2 %. Leerlo en porcentaje tiene más sentido que en millones fijos, porque un baño adicional no vale lo mismo en un apartamento de 250 millones que en una casa de 800. Conclusión: la transformación logarítmica del precio es pertinente.

2.3 Modelo simple 3: precio ~ habitaciones

Estimación

mod3 <- lm(precio_millon ~ Habitaciones, data = datos_sub)
summary(mod3)
## 
## Call:
## lm(formula = precio_millon ~ Habitaciones, data = datos_sub)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -262.82  -71.64   -7.87   54.30  900.89 
## 
## Coefficients:
##              Estimate Std. Error t value Pr(>|t|)    
## (Intercept)    142.92      18.22   7.843 3.27e-14 ***
## Habitaciones    71.24       5.18  13.752  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 115.2 on 446 degrees of freedom
## Multiple R-squared:  0.2978, Adjusted R-squared:  0.2962 
## F-statistic: 189.1 on 1 and 446 DF,  p-value: < 2.2e-16
b3 <- coef(mod3)

Interpretación

La ecuación es precio = 142.92 + 71.24 × habitaciones. Cada habitación adicional se asocia con 71.2 millones más. El coeficiente es significativo (p < 0.001), pero el modelo explica solo el 30 % de la variación del precio (R² = 0.298), muy por debajo del área y los baños. En La Flora, contar habitaciones dice poco: una casa antigua de cinco cuartos puede costar menos que un apartamento moderno de tres.

Validación de supuestos

par(mfrow = c(2, 2))
plot(mod3)

par(mfrow = c(1, 1))

sup3 <- validar_supuestos(mod3)
sup3
Supuesto Prueba Estadistico p_valor Resultado
Normalidad de los residuos Shapiro-Wilk 0.888 < 0.001 No se cumple
Varianza constante Breusch-Pagan 9.836 0.002 No se cumple
Independencia de los errores Durbin-Watson 1.761 0.005 No se cumple
Forma funcional lineal RESET de Ramsey 15.707 < 0.001 No se cumple

El modelo incumple homocedasticidad (Breusch-Pagan p = 0.002), normalidad (p < 0.001) y forma funcional (RESET p < 0.001). Los residuos más grandes son viviendas muy caras que el número de cuartos no logra anticipar.

¿Conviene una transformación logarítmica?

cmp3 <- comparar_log("Habitaciones", datos_sub)
cmp3
Modelo R2_ajustado MAE p_Shapiro p_BreuschPagan p_DurbinWatson p_RESET Cook_max
Lin-Lin 0.296 82.1 < 0.001 0.002 0.005 < 0.001 0.239
Log-Lin 0.275 81.4 0.004 0.312 0.147 < 0.001 0.094
Lin-Log 0.259 84.4 < 0.001 0.039 0.002 < 0.001 0.298
Log-Log 0.243 81.7 < 0.001 0.405 0.087 < 0.001 0.300
mod3_log <- lm(log(precio_millon) ~ Habitaciones, data = datos_sub)
summary(mod3_log)
## 
## Call:
## lm(formula = log(precio_millon) ~ Habitaciones, data = datos_sub)
## 
## Residuals:
##      Min       1Q   Median       3Q      Max 
## -0.82359 -0.16435 -0.00366  0.18193  1.08861 
## 
## Coefficients:
##              Estimate Std. Error t value Pr(>|t|)    
## (Intercept)   5.35215    0.04330  123.60   <2e-16 ***
## Habitaciones  0.16069    0.01231   13.05   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 0.2738 on 446 degrees of freedom
## Multiple R-squared:  0.2765, Adjusted R-squared:  0.2748 
## F-statistic: 170.4 on 1 and 446 DF,  p-value: < 2.2e-16
par(mfrow = c(2, 2))
plot(mod3_log)

par(mfrow = c(1, 1))

sup3_log <- validar_supuestos(mod3_log)
sup3_log
Supuesto Prueba Estadistico p_valor Resultado
Normalidad de los residuos Shapiro-Wilk 0.990 0.004 No se cumple
Varianza constante Breusch-Pagan 1.024 0.312 Se cumple
Independencia de los errores Durbin-Watson 1.902 0.147 Se cumple
Forma funcional lineal RESET de Ramsey 13.443 < 0.001 No se cumple
b3l <- coef(mod3_log)

El logaritmo del precio resuelve la heterocedasticidad (Breusch-Pagan pasa de 0.002 a 0.312 en el Log-Lin) y acerca los residuos a la normalidad, pero ninguna especificación arregla la forma funcional: RESET rechaza en las cuatro. Se elige el Log-Lin porque cumple la varianza constante con el MAE más bajo (81.4 millones), aunque su R² ajustado (0.275) queda ligeramente por debajo del lineal.

Interpretación del modelo Log-Lin. Cada habitación adicional eleva el precio un 17.4 % en promedio. Conclusión: la transformación es pertinente para corregir la varianza, pero no convierte a las habitaciones en un buen predictor por sí solas. Su verdadero papel aparece en el modelo múltiple, cuando se compara a igualdad de área.

2.4 Resumen de la modelación simple

data.frame(
  Modelo         = c("1. Área construida", "2. Baños", "3. Habitaciones"),
  Especificacion = c("Log-Log", "Log-Lin", "Log-Lin"),
  R2_ajustado    = round(c(summary(mod1_log)$adj.r.squared, summary(mod2_log)$adj.r.squared,
                           summary(mod3_log)$adj.r.squared), 3),
  MAE_millones   = round(c(mae(mod1_log), mae(mod2_log), mae(mod3_log)), 1)
)
Modelo Especificacion R2_ajustado MAE_millones
1. Área construida Log-Log 0.581 63.4
2. Baños Log-Lin 0.460 69.6
3. Habitaciones Log-Lin 0.275 81.4

El área construida es el mejor predictor individual del precio en La Flora: explica más y se equivoca menos. En los tres casos, llevar el precio a logaritmo fue la decisión correcta, porque la varianza crecía con el precio en todos los modelos lineales. La forma del predictor sí cambió de un caso a otro: logaritmo para el área, que tiene rendimientos decrecientes, y niveles para baños y habitaciones, cuyo efecto se lee mejor como un porcentaje fijo por unidad.

3. Modelación múltiple

3.1 Base común para los modelos múltiples

Para que los modelos múltiples y la selección paso a paso sean comparables, todos deben estimarse sobre las mismas filas. Se construye una base con las variables candidatas y se eliminan los registros incompletos (los faltantes vienen de parqueaderos):

datos_mod <- datos_sub %>%
  select(id, precio_millon, Area_contruida, Habitaciones, Banos, parqueaderos, Estrato, Tipo) %>%
  na.omit()

nrow(datos_mod)
## [1] 431
table(datos_mod$Tipo)
## 
## Apartamento        Casa 
##         321         110

Se trabaja con 431 viviendas completas.

3.2 Modelo múltiple 1: tamaño y distribución

Este primer modelo usa solo variables cuantitativas que describen el inmueble: área, habitaciones, baños y parqueaderos.

Estimación

mm1 <- lm(precio_millon ~ Area_contruida + Habitaciones + Banos + parqueaderos, data = datos_mod)
summary(mm1)
## 
## Call:
## lm(formula = precio_millon ~ Area_contruida + Habitaciones + 
##     Banos + parqueaderos, data = datos_mod)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -260.48  -48.31   -3.16   52.68  488.27 
## 
## Coefficients:
##                 Estimate Std. Error t value Pr(>|t|)    
## (Intercept)    185.28226   15.21637  12.177  < 2e-16 ***
## Area_contruida   0.70813    0.04849  14.604  < 2e-16 ***
## Habitaciones   -13.48226    5.45976  -2.469   0.0139 *  
## Banos           43.52722    5.93482   7.334 1.13e-12 ***
## parqueaderos     5.23846    4.82095   1.087   0.2778    
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 81.33 on 426 degrees of freedom
## Multiple R-squared:  0.6539, Adjusted R-squared:  0.6507 
## F-statistic: 201.2 on 4 and 426 DF,  p-value: < 2.2e-16
c1 <- coef(mm1)
p1 <- summary(mm1)$coefficients[, 4]

Interpretación

En un modelo múltiple, cada coeficiente se lee manteniendo constantes las demás variables:

  • Área construida: con igual distribución, cada m² adicional suma 0.71 millones.
  • Habitaciones: aquí aparece la primera sorpresa. Con la misma área, una habitación adicional resta 13.5 millones (p = 0.014). Tiene lógica: si el área no cambia, una habitación más significa espacios más pequeños y fragmentados. En La Flora, el mercado premia la amplitud, no la cantidad de cuartos.
  • Baños: cada baño adicional suma 43.5 millones (p < 0.001), el efecto más fuerte después del área. Los baños parecen funcionar como indicador de categoría y remodelación.
  • Parqueaderos: el coeficiente (5.2) no es significativo (p = 0.278). Conocido el tamaño, el número de parqueaderos no aporta información adicional.

El modelo explica el 65.1 % de la variación del precio (R² ajustado), más que cualquiera de los modelos simples.

Validación de supuestos

par(mfrow = c(2, 2))
plot(mm1)

par(mfrow = c(1, 1))

sup_mm1 <- validar_supuestos(mm1)
sup_mm1
Supuesto Prueba Estadistico p_valor Resultado
Normalidad de los residuos Shapiro-Wilk 0.950 < 0.001 No se cumple
Varianza constante Breusch-Pagan 102.656 < 0.001 No se cumple
Independencia de los errores Durbin-Watson 1.650 < 0.001 No se cumple
Forma funcional lineal RESET de Ramsey 1.436 0.239 Se cumple
# Multicolinealidad: un VIF mayor a 5 sería preocupante
vif(mm1)
## Area_contruida   Habitaciones          Banos   parqueaderos 
##       2.227919       2.181479       2.285302       1.476864
# Observación más influyente
cook_mm1 <- cooks.distance(mm1)
datos_mod[which.max(cook_mm1), ]
id precio_millon Area_contruida Habitaciones Banos parqueaderos Estrato Tipo
8319 1400 838 5 5 1 5 Casa
max(cook_mm1)
## [1] 1.101167

El VIF está por debajo de 5 en todas las variables, así que la multicolinealidad es moderada y no infla los errores estándar de manera grave. La forma funcional es aceptable (RESET p = 0.239), pero hay dos problemas serios. La heterocedasticidad es fuerte (Breusch-Pagan p < 0.001) y una observación supera la distancia de Cook de 1 (1.10): de nuevo la casa de 838 m² y 1400 millones, que en Residuals vs Leverage aparece justo sobre la curva punteada que marca la distancia de Cook de 1. Los residuos tampoco son normales (p < 0.001).

No se elimina esa vivienda, porque no hay evidencia de que sea un error: es una casa muy grande y muy cara, y existen. La alternativa es una transformación que le quite peso.

Validación de la transformación logarítmica

Siguiendo lo aprendido en la modelación simple, se toma el logaritmo del precio y del área. Habitaciones, baños y parqueaderos se dejan en niveles, porque son conteos pequeños.

mm1_log <- lm(log(precio_millon) ~ log(Area_contruida) + Habitaciones + Banos + parqueaderos,
              data = datos_mod)
summary(mm1_log)
## 
## Call:
## lm(formula = log(precio_millon) ~ log(Area_contruida) + Habitaciones + 
##     Banos + parqueaderos, data = datos_mod)
## 
## Residuals:
##      Min       1Q   Median       3Q      Max 
## -0.64582 -0.13279  0.01357  0.14917  0.64600 
## 
## Coefficients:
##                      Estimate Std. Error t value Pr(>|t|)    
## (Intercept)          3.957677   0.102543  38.595  < 2e-16 ***
## log(Area_contruida)  0.374347   0.028750  13.021  < 2e-16 ***
## Habitaciones        -0.047690   0.013602  -3.506 0.000503 ***
## Banos                0.092975   0.014704   6.323 6.49e-10 ***
## parqueaderos         0.008919   0.011673   0.764 0.445273    
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 0.1956 on 426 degrees of freedom
## Multiple R-squared:  0.6239, Adjusted R-squared:  0.6203 
## F-statistic: 176.6 on 4 and 426 DF,  p-value: < 2.2e-16
par(mfrow = c(2, 2))
plot(mm1_log)

par(mfrow = c(1, 1))

validar_supuestos(mm1_log)
Supuesto Prueba Estadistico p_valor Resultado
Normalidad de los residuos Shapiro-Wilk 0.984 < 0.001 No se cumple
Varianza constante Breusch-Pagan 9.071 0.059 Se cumple
Independencia de los errores Durbin-Watson 1.595 < 0.001 No se cumple
Forma funcional lineal RESET de Ramsey 4.752 0.009 No se cumple
comparar_modelos(list("MM1 lineal" = mm1, "MM1 log" = mm1_log))
Modelo R2_ajustado MAE p_Shapiro p_BreuschPagan p_DurbinWatson p_RESET Cook_max
MM1 lineal 0.651 60.6 < 0.001 < 0.001 < 0.001 0.239 1.101
MM1 log 0.620 60.9 < 0.001 0.059 < 0.001 0.009 0.087
c1l <- coef(mm1_log)
p1l <- summary(mm1_log)$coefficients[, 4]

Esta vez la decisión tiene costos y beneficios. La versión logarítmica gana en dos frentes importantes: la heterocedasticidad deja de ser significativa al 5 % (Breusch-Pagan p = 0.059) y la distancia de Cook máxima cae de 1.10 a 0.09, así que la casa influyente pierde casi todo su peso. A cambio, pierde un poco de R² ajustado y RESET pasa a rechazar (p = 0.009). El MAE es prácticamente igual (60.9 frente a 60.6).

Se prefiere la versión logarítmica. Una estimación que depende de una sola casa y unos errores estándar distorsionados por la heterocedasticidad son problemas más graves para interpretar los coeficientes que una leve curvatura residual.

Interpretación (versión log). Con lo demás constante, un 10 % más de área se asocia con un 3.6 % más de precio. Cada habitación adicional, a igual área, reduce el precio un 4.7 % (p < 0.001), mientras que cada baño lo eleva un 9.7 % (p < 0.001). Los parqueaderos siguen sin ser significativos (p = 0.445).

3.3 Modelo múltiple 2: con variables dummy

El segundo modelo incorpora qué se vende y en qué estrato, además del tamaño (área, baños y parqueaderos):

  • Tipo es una variable cualitativa que R convierte en una dummy: TipoCasa = 1 si el inmueble es casa y 0 si es apartamento. La categoría base es Apartamento.
  • Estrato entra como variable numérica y mide el salto de precio por cada nivel.
# Codificación de la dummy: Apartamento es la base (fila de ceros)
contrasts(datos_mod$Tipo)
##             Casa
## Apartamento    0
## Casa           1

Estimación

mm2 <- lm(precio_millon ~ Area_contruida + Banos + parqueaderos + Tipo + Estrato, data = datos_mod)
summary(mm2)
## 
## Call:
## lm(formula = precio_millon ~ Area_contruida + Banos + parqueaderos + 
##     Tipo + Estrato, data = datos_mod)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -278.18  -47.25    0.58   46.69  517.03 
## 
## Coefficients:
##                 Estimate Std. Error t value Pr(>|t|)    
## (Intercept)     38.37144   62.40791   0.615 0.538983    
## Area_contruida   0.78876    0.05448  14.477  < 2e-16 ***
## Banos           41.24338    5.52454   7.465 4.74e-13 ***
## parqueaderos     7.52367    4.78665   1.572 0.116740    
## TipoCasa       -55.85021   14.37288  -3.886 0.000118 ***
## Estrato         21.57644   12.41934   1.737 0.083054 .  
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 80.31 on 425 degrees of freedom
## Multiple R-squared:  0.6633, Adjusted R-squared:  0.6594 
## F-statistic: 167.5 on 5 and 425 DF,  p-value: < 2.2e-16
c2 <- coef(mm2)
p2 <- summary(mm2)$coefficients[, 4]

Interpretación

  • TipoCasa (dummy): este es el hallazgo central del informe. Con igual área, baños, parqueaderos y estrato, una casa se ofrece en promedio 55.9 millones más barata que un apartamento (p < 0.001). El coeficiente negativo confirma con rigor lo que el precio por metro cuadrado insinuaba. Una explicación plausible es la antigüedad: los apartamentos de La Flora suelen estar en edificios con portería, ascensor y zonas comunes, mientras que muchas casas son antiguas y el comprador descuenta el costo de remodelarlas.
  • Estrato: cada nivel suma 21.6 millones, pero solo es significativo al 10 % (p = 0.083). Con el 90 % de las viviendas en estrato 5, hay muy poca variación para medir su efecto con precisión.
  • Área construida: cada m² suma 0.79 millones con lo demás constante.
  • Baños: cada baño suma 41.2 millones (p < 0.001).
  • Parqueaderos: no significativo (p = 0.117).
  • El intercepto no tiene interpretación práctica: correspondería a un apartamento de 0 m² y estrato 0.

El R² ajustado es 0.659, algo mayor que el del modelo múltiple 1, aunque este modelo no incluye las habitaciones. El tipo de inmueble aporta información que el tamaño no contiene.

Validación de supuestos

par(mfrow = c(2, 2))
plot(mm2)

par(mfrow = c(1, 1))

sup_mm2 <- validar_supuestos(mm2)
sup_mm2
Supuesto Prueba Estadistico p_valor Resultado
Normalidad de los residuos Shapiro-Wilk 0.942 < 0.001 No se cumple
Varianza constante Breusch-Pagan 99.391 < 0.001 No se cumple
Independencia de los errores Durbin-Watson 1.713 0.001 No se cumple
Forma funcional lineal RESET de Ramsey 3.005 0.051 Se cumple
vif(mm2)
## Area_contruida          Banos   parqueaderos           Tipo        Estrato 
##       2.884754       2.030940       1.493188       2.623951       1.002261

La multicolinealidad es baja (VIF < 5) y la forma funcional queda justo en el límite (RESET p = 0.051). La heterocedasticidad sigue siendo fuerte (Breusch-Pagan p < 0.001), los residuos no son normales (p < 0.001) y la casa grande vuelve a acercarse al umbral de influencia (Cook máximo de 0.93).

Validación de la transformación logarítmica

mm2_log <- lm(log(precio_millon) ~ log(Area_contruida) + Banos + parqueaderos + Tipo + Estrato,
              data = datos_mod)
summary(mm2_log)
## 
## Call:
## lm(formula = log(precio_millon) ~ log(Area_contruida) + Banos + 
##     parqueaderos + Tipo + Estrato, data = datos_mod)
## 
## Residuals:
##      Min       1Q   Median       3Q      Max 
## -0.67480 -0.11706  0.02259  0.13686  0.72856 
## 
## Coefficients:
##                     Estimate Std. Error t value Pr(>|t|)    
## (Intercept)          3.10821    0.20931  14.850  < 2e-16 ***
## log(Area_contruida)  0.46348    0.03549  13.059  < 2e-16 ***
## Banos                0.07618    0.01381   5.517 6.00e-08 ***
## parqueaderos         0.01500    0.01141   1.315   0.1892    
## TipoCasa            -0.20975    0.03936  -5.329 1.61e-07 ***
## Estrato              0.07085    0.02952   2.400   0.0168 *  
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 0.191 on 425 degrees of freedom
## Multiple R-squared:  0.642,  Adjusted R-squared:  0.6378 
## F-statistic: 152.5 on 5 and 425 DF,  p-value: < 2.2e-16
par(mfrow = c(2, 2))
plot(mm2_log)

par(mfrow = c(1, 1))

validar_supuestos(mm2_log)
Supuesto Prueba Estadistico p_valor Resultado
Normalidad de los residuos Shapiro-Wilk 0.977 < 0.001 No se cumple
Varianza constante Breusch-Pagan 17.675 0.003 No se cumple
Independencia de los errores Durbin-Watson 1.712 0.001 No se cumple
Forma funcional lineal RESET de Ramsey 1.283 0.278 Se cumple
comparar_modelos(list("MM2 lineal" = mm2, "MM2 log" = mm2_log))
Modelo R2_ajustado MAE p_Shapiro p_BreuschPagan p_DurbinWatson p_RESET Cook_max
MM2 lineal 0.659 58.2 < 0.001 < 0.001 0.001 0.051 0.934
MM2 log 0.638 57.3 < 0.001 0.003 0.001 0.278 0.077
c2l <- coef(mm2_log)
p2l <- summary(mm2_log)$coefficients[, 4]

La versión logarítmica es mejor. Tiene el MAE más bajo hasta ahora (57.3 millones), conserva una forma funcional aceptable (RESET p = 0.278) y reduce la distancia de Cook máxima a 0.08. La heterocedasticidad no desaparece del todo (Breusch-Pagan p = 0.003), pero se reduce de forma drástica: el estadístico pasa de 99.4 a 17.7. Lo que queda se puede atender con errores estándar robustos, como se hace en la sección 3.4.

Interpretación (versión log). Con el precio en logaritmo, el coeficiente de una dummy se traduce en porcentaje con (e^b − 1) × 100:

  • TipoCasa: una casa vale un 18.9 % menos que un apartamento comparable (p < 0.001). Es el descuento de la casa frente al apartamento, expresado como proporción.
  • Estrato: cada nivel eleva el precio un 7.3 % (p = 0.017).
  • Área: un 10 % más de área equivale a un 4.5 % más de precio.
  • Baños: cada baño suma un 7.9 % (p < 0.001). Los parqueaderos no son significativos (p = 0.189).

3.4 Selección de variables paso a paso (stepwise)

Los dos modelos anteriores se armaron con criterio propio. La selección paso a paso deja que los datos decidan. Se parte del modelo completo (todas las variables candidatas, en la especificación logarítmica validada) y en cada paso se quita o se agrega la variable que más reduce el AIC (criterio de información de Akaike). El proceso se detiene cuando ningún cambio mejora el AIC.

modelo_completo <- lm(log(precio_millon) ~ log(Area_contruida) + Habitaciones + Banos +
                        parqueaderos + Tipo + Estrato, data = datos_mod)

modelo_step <- step(modelo_completo, direction = "both", trace = 1)
## Start:  AIC=-1428
## log(precio_millon) ~ log(Area_contruida) + Habitaciones + Banos + 
##     parqueaderos + Tipo + Estrato
## 
##                       Df Sum of Sq    RSS     AIC
## - parqueaderos         1    0.0462 15.233 -1428.7
## <none>                             15.187 -1428.0
## - Estrato              1    0.1826 15.369 -1424.8
## - Habitaciones         1    0.3205 15.507 -1421.0
## - Tipo                 1    0.9196 16.106 -1404.7
## - Banos                1    1.3700 16.556 -1392.8
## - log(Area_contruida)  1    6.4848 21.671 -1276.7
## 
## Step:  AIC=-1428.69
## log(precio_millon) ~ log(Area_contruida) + Habitaciones + Banos + 
##     Tipo + Estrato
## 
##                       Df Sum of Sq    RSS     AIC
## <none>                             15.233 -1428.7
## + parqueaderos         1    0.0462 15.187 -1428.0
## - Estrato              1    0.1828 15.415 -1425.5
## - Habitaciones         1    0.3374 15.570 -1421.2
## - Tipo                 1    0.8956 16.128 -1406.1
## - Banos                1    1.6819 16.915 -1385.5
## - log(Area_contruida)  1    6.6956 21.928 -1273.7
# Resumen de los pasos realizados
modelo_step$anova
Step Df Deviance Resid. Df Resid. Dev AIC
NA NA 424 15.18651 -1427.997
- parqueaderos 1 0.0462044 425 15.23272 -1428.688
# Comprobación: la selección hacia adelante, partiendo del modelo vacío
modelo_nulo <- lm(log(precio_millon) ~ 1, data = datos_mod)
modelo_fwd  <- step(modelo_nulo, scope = formula(modelo_completo), direction = "forward", trace = 0)
formula(modelo_fwd)
## log(precio_millon) ~ log(Area_contruida) + Banos + Tipo + Habitaciones + 
##     Estrato
summary(modelo_step)
## 
## Call:
## lm(formula = log(precio_millon) ~ log(Area_contruida) + Habitaciones + 
##     Banos + Tipo + Estrato, data = datos_mod)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -0.6584 -0.1219  0.0213  0.1455  0.7046 
## 
## Coefficients:
##                     Estimate Std. Error t value Pr(>|t|)    
## (Intercept)          3.04926    0.20764  14.686  < 2e-16 ***
## log(Area_contruida)  0.50281    0.03679  13.668  < 2e-16 ***
## Habitaciones        -0.04056    0.01322  -3.068  0.00229 ** 
## Banos                0.09309    0.01359   6.850 2.60e-11 ***
## TipoCasa            -0.19546    0.03910  -4.999 8.45e-07 ***
## Estrato              0.06618    0.02930   2.258  0.02442 *  
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 0.1893 on 425 degrees of freedom
## Multiple R-squared:  0.6484, Adjusted R-squared:  0.6442 
## F-statistic: 156.7 on 5 and 425 DF,  p-value: < 2.2e-16
cs <- coef(modelo_step)
ps <- summary(modelo_step)$coefficients[, 4]

Qué decidió el algoritmo. Solo sobró una variable: parqueaderos, que ya era no significativa en todos los modelos. Al retirarla, el AIC bajó de -1428.0 a -1428.7, y ningún otro cambio lo mejoraba. La selección hacia adelante, que parte del modelo vacío, llega al mismo conjunto de variables.

El modelo final es:

log(precio) = 3.049 + 0.503 · log(área) - 0.041 · habitaciones + 0.093 · baños - 0.195 · Casa + 0.066 · estrato

  • Un 10 % más de área se asocia con un 4.9 % más de precio.
  • Cada habitación adicional, a igual área, resta un 4.0 % (p = 0.002).
  • Cada baño adicional suma un 9.8 % (p < 0.001).
  • Ser casa en lugar de apartamento resta un 17.8 % (p < 0.001).
  • Cada nivel de estrato suma un 6.8 % (p = 0.024).

Validación de supuestos del modelo seleccionado

par(mfrow = c(2, 2))
plot(modelo_step)

par(mfrow = c(1, 1))

sup_step <- validar_supuestos(modelo_step)
sup_step
Supuesto Prueba Estadistico p_valor Resultado
Normalidad de los residuos Shapiro-Wilk 0.979 < 0.001 No se cumple
Varianza constante Breusch-Pagan 14.757 0.011 No se cumple
Independencia de los errores Durbin-Watson 1.696 < 0.001 No se cumple
Forma funcional lineal RESET de Ramsey 1.394 0.249 Se cumple
vif(modelo_step)
## log(Area_contruida)        Habitaciones               Banos                Tipo 
##            4.998291            2.360819            2.211193            3.494696 
##             Estrato 
##            1.004074
# Errores estándar robustos (HC3): corrigen la inferencia ante heterocedasticidad
rob <- coeftest(modelo_step, vcov. = hccm(modelo_step, type = "hc3"))
rob
## 
## t test of coefficients:
## 
##                      Estimate Std. Error t value  Pr(>|t|)    
## (Intercept)          3.049262   0.268902 11.3397 < 2.2e-16 ***
## log(Area_contruida)  0.502811   0.041986 11.9758 < 2.2e-16 ***
## Habitaciones        -0.040561   0.014585 -2.7811  0.005659 ** 
## Banos                0.093087   0.014902  6.2465 1.020e-09 ***
## TipoCasa            -0.195459   0.036225 -5.3957 1.136e-07 ***
## Estrato              0.066179   0.037999  1.7416  0.082305 .  
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

El modelo seleccionado cumple la forma funcional (RESET p = 0.249), pero conserva algo de heterocedasticidad (Breusch-Pagan p = 0.011) y residuos no normales (p < 0.001). El VIF del logaritmo del área ronda 5.0, en el límite de lo aceptable. Esto se explica porque área y tipo van de la mano: las casas son las viviendas grandes.

Por la heterocedasticidad, la inferencia correcta es la de los errores estándar robustos (HC3). Con ellos, área, habitaciones, baños y tipo mantienen su significancia, pero el estrato deja de ser significativo al 5 % (p robusto = 0.082). La lectura honesta es que, en un barrio casi todo estrato 5, el efecto del estrato no se puede afirmar con confianza.

3.5 Predicción hipotética

¿Cuánto debería pedirse por dos viviendas típicas de La Flora? Se usa el modelo seleccionado con predict(). Como el modelo está en logaritmos, la predicción se devuelve a millones con exp(). Así se obtiene el precio mediano esperado, que es justo la cantidad que minimiza el error absoluto (MAE).

nuevas <- data.frame(
  Tipo           = factor(c("Apartamento", "Casa"), levels = levels(datos_mod$Tipo)),
  Area_contruida = c(100, 200),
  Habitaciones   = c(3, 4),
  Banos          = c(2, 3),
  parqueaderos   = c(1, 2),
  Estrato        = c(5, 5))

pred_conf <- exp(predict(modelo_step, newdata = nuevas, interval = "confidence", level = 0.95))
pred_pred <- exp(predict(modelo_step, newdata = nuevas, interval = "prediction", level = 0.95))

data.frame(nuevas,
           Precio_estimado = round(pred_pred[, "fit"], 1),
           IC95_media_inf  = round(pred_conf[, "lwr"], 1),
           IC95_media_sup  = round(pred_conf[, "upr"], 1),
           IP95_inf        = round(pred_pred[, "lwr"], 1),
           IP95_sup        = round(pred_pred[, "upr"], 1))
Tipo Area_contruida Habitaciones Banos parqueaderos Estrato Precio_estimado IC95_media_inf IC95_media_sup IP95_inf IP95_sup
Apartamento 100 3 2 1 5 317.4 309.3 325.7 218.6 460.9
Casa 200 4 3 2 5 389.9 373.5 406.9 268.1 567.0

Interpretación. Para un apartamento de 100 m², 3 habitaciones, 2 baños, estrato 5, el modelo estima 317 millones. Para una casa de 200 m², 4 habitaciones, 3 baños, estrato 5, estima 390 millones. La casa tiene el doble de área y un baño más, pero su precio estimado es apenas un 23 % mayor. La elasticidad baja del área y el descuento por ser casa se combinan para producir ese resultado.

El intervalo de confianza (309 a 326 millones para el apartamento) es estrecho porque estima el precio promedio de todos los apartamentos con esas características. El intervalo de predicción (219 a 461 millones) es mucho más ancho porque se refiere a una oferta concreta, con sus acabados, su piso y el estado de su edificio. Una oferta de ese apartamento por encima de 461 millones estaría fuera de lo que el mercado del barrio sugiere.

Los parqueaderos se incluyen en nuevas por completitud, pero el modelo seleccionado no los usa.

3.6 Métricas de evaluación: MAE

El MAE (error absoluto medio) mide, en millones de pesos, cuánto se equivoca el modelo en promedio. Se calcula de dos formas: sobre los mismos datos con que se estimó cada modelo (dentro de muestra), y con validación cruzada de 10 pliegues (fuera de muestra). En la validación cruzada, la base se divide en 10 partes. Cada parte se usa una vez como prueba mientras el modelo se estima con las otras nueve, de modo que cada vivienda se predice con un modelo que nunca la vio. Es la prueba honesta, y es más estable que un único corte entre entrenamiento y prueba.

# Referencia: el mejor modelo simple, reestimado en la misma base
ref_simple <- lm(log(precio_millon) ~ log(Area_contruida), data = datos_mod)

modelos_finales <- list("Simple Log-Log (área)" = ref_simple,
                        "MM1 lineal"            = mm1,
                        "MM1 log"               = mm1_log,
                        "MM2 lineal (dummy)"    = mm2,
                        "MM2 log (dummy)"       = mm2_log,
                        "Stepwise (log)"        = modelo_step)

# MAE fuera de muestra con validación cruzada de 10 pliegues
set.seed(123)
k <- 10
pliegues <- sample(rep(1:k, length.out = nrow(datos_mod)))

mae_cv <- function(modelo) {
  errores <- c()
  for (i in 1:k) {
    entrenamiento <- datos_mod[pliegues != i, ]
    prueba        <- datos_mod[pliegues == i, ]
    m    <- lm(formula(modelo), data = entrenamiento)
    pred <- predict(m, newdata = prueba)
    if (grepl("^log\\(", deparse(formula(modelo)[[2]]))) pred <- exp(pred)
    errores <- c(errores, abs(prueba$precio_millon - pred))
  }
  mean(errores)
}

tabla_mae <- data.frame(
  Modelo            = names(modelos_finales),
  R2_ajustado       = sapply(modelos_finales, function(m) round(summary(m)$adj.r.squared, 3)),
  MAE_dentro        = sapply(modelos_finales, function(m) round(mae(m), 1)),
  MAE_val_cruzada   = sapply(modelos_finales, function(m) round(mae_cv(m), 1)))
tabla_mae$MAE_relativo_pct <- round(100 * sapply(modelos_finales, mae) / mean(datos_mod$precio_millon), 1)
rownames(tabla_mae) <- NULL
tabla_mae
Modelo R2_ajustado MAE_dentro MAE_val_cruzada MAE_relativo_pct
Simple Log-Log (área) 0.578 63.3 63.8 16.6
MM1 lineal 0.651 60.6 62.1 15.9
MM1 log 0.620 60.9 62.0 16.0
MM2 lineal (dummy) 0.659 58.2 60.2 15.2
MM2 log (dummy) 0.638 57.3 58.9 15.0
Stepwise (log) 0.644 57.4 58.6 15.1
ggplotly(
  ggplot(data.frame(real = datos_mod$precio_millon, estimado = exp(fitted(modelo_step)),
                    Tipo = datos_mod$Tipo),
         aes(x = estimado, y = real, color = Tipo)) +
    geom_point(alpha = 0.7, size = 2) +
    geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey40") +
    scale_color_manual(values = c("Apartamento" = "#2a78d6", "Casa" = "#eb6834")) +
    labs(title = "Figura 4. Precio real vs. estimado (modelo stepwise)",
         x = "Precio estimado (millones)", y = "Precio real (millones)") +
    theme_minimal()
)

Interpretación. El modelo stepwise se equivoca en promedio 57.4 millones por vivienda, cerca del 15.1 % del precio medio del barrio (381.5 millones). El mejor modelo simple fallaba por 63.3 millones: añadir distribución, tipo y estrato recorta el error en 5.9 millones por vivienda, y el R² ajustado sube de 0.578 a 0.644.

En validación cruzada, el MAE del stepwise es de 58.6 millones, un poco por encima del valor dentro de muestra, como es normal. No hay señales de sobreajuste. Los modelos con dummy (MM2 y stepwise) le ganan al MM1 tanto dentro como fuera de muestra: incorporar el tipo de vivienda y el estrato mejora la predicción más que sumar medidas de tamaño. El MM2 log y el stepwise quedan prácticamente empatados (diferencias de menos de un millón). El stepwise se elige con el AIC, no con el MAE, y ambos son buenas opciones.

En la Figura 4, los puntos siguen la diagonal y se dispersan más en los precios altos. Las casas grandes y caras siguen siendo las más difíciles de anticipar.

4. Conclusiones

  1. El área manda, pero con elasticidad baja. Es el mejor predictor individual del precio en La Flora. En el modelo final, un 10 % más de área se traduce en cerca de un 4.9 % más de precio: los metros adicionales se pagan cada vez menos.
  2. La dummy invierte la intuición. A igualdad de área, distribución y estrato, una casa vale cerca de un 18 % menos que un apartamento. En este barrio el mercado prefiere el apartamento en edificio sobre la casa grande, probablemente por la antigüedad y el costo de remodelación de las casas.
  3. Amplitud antes que cantidad de cuartos. A igual área, más habitaciones restan valor y más baños lo suman. El comprador de La Flora paga por espacios amplios y bien dotados.
  4. El logaritmo se justificó por dos razones. Corrigió (o redujo drásticamente) la heterocedasticidad en todos los modelos, y le quitó a una sola casa de 838 m² el poder de mover la estimación. En el MM1 costó un poco de forma funcional, un precio razonable a cambio de una inferencia confiable.
  5. El modelo final (log del área, habitaciones, baños, tipo y estrato) explica cerca del 64 % de la variación del precio y se equivoca en unos 57 millones dentro de muestra y 59 en validación cruzada.
  6. Limitaciones. Queda heterocedasticidad leve y no normalidad, por lo que se usaron errores robustos. El estrato casi no varía en el barrio, así que su efecto es incierto. Faltan variables clave como la antigüedad, el estado de remodelación o el tamaño del lote, y la pendiente del área podría ser distinta para casas y apartamentos (una interacción Tipo:Area, que muestra la Figura 3). Esas son las vías naturales para mejorar el modelo.