# devtools::install_github("centromagis/paqueteMODELOS", force = TRUE)
library(paqueteMODELOS)
library(dplyr)
library(ggplot2)
library(plotly)
library(knitr)
library(kableExtra)
library(car)
library(lmtest)María comenzó como agente de bienes raíces en Cali hace diez años. Con esa experiencia fundó su propia inmobiliaria, C&A (Casas y Apartamentos), donde hoy trabajan ocho agentes. Las ventas del sector han bajado por las tensiones políticas y sociales de los últimos meses, aunque la banca sigue prestando fuerte para construcción residencial y comercial; se espera que la demanda se recupere cuando esa situación se calme.
Hace dos días le llegó una carta de una compañía internacional que quiere comprar dos viviendas para instalar a dos de sus empleados y sus familias en la ciudad. Las condiciones de cada solicitud son estas:
| Característica | Vivienda 1 | Vivienda 2 |
|---|---|---|
| Tipo | Casa | Apartamento |
| Área construida (m²) | 200 | 300 |
| Parqueaderos | 1 | 3 |
| Baños | 2 | 3 |
| Habitaciones | 4 | 5 |
| Estrato | 4 o 5 | 5 o 6 |
| Zona | Norte | Sur |
| Crédito preaprobado | $350 millones | $850 millones |
Este informe ayuda a María a responder ambas solicitudes mediante un modelo de Regresión Lineal Múltiple (RLM) que explique el precio de las propiedades en cada segmento, y a partir de él recomendar ofertas concretas dentro del presupuesto de cada cliente.
Estimar e interpretar modelos de regresión lineal múltiple para el precio de vivienda en dos segmentos del mercado de Cali (casas en zona norte y apartamentos en zona sur), con el fin de predecir el precio de cada vivienda solicitada y recomendar ofertas concretas dentro del crédito preaprobado de cada cliente.
La base vivienda del paquete paqueteMODELOS contiene las propiedades residenciales ofertadas en Cali durante los últimos tres meses.
## [1] 8322 13
Tabla. Diccionario de variables de la base vivienda (CC = cuantitativa continua, CD = cuantitativa discreta, CO = cualitativa ordinal, CN = cualitativa nominal).
variables_df <- data.frame(
Variable = c("id", "zona", "piso", "estrato", "preciom", "areaconst",
"parqueaderos", "banios", "habitaciones", "tipo", "barrio",
"longitud", "latitud"),
Descripcion = c(
"Identificador de la propiedad",
"Ubicación de la vivienda: Zona Centro, Zona Norte, Zona Oriente, Zona Oeste, Zona Sur",
"Piso que ocupa la vivienda (aplica a apartamentos)",
"Estrato socioeconómico: 3, 4, 5, 6",
"Precio de la vivienda, en millones de pesos",
"Área construida (m²)",
"Número de parqueaderos",
"Número de baños",
"Número de habitaciones",
"Tipo de vivienda: Casa, Apartamento",
"Barrio de ubicación de la vivienda",
"Coordenada geográfica de longitud",
"Coordenada geográfica de latitud"
),
Tipo = c("ID", "CN", "CN", "CO", "CC", "CC", "CD", "CD", "CD", "CN", "CN", "CC", "CC")
)
kable(variables_df, col.names = c("Variable", "Descripción", "Tipo"),
align = c("l", "l", "c")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = TRUE)| Variable | Descripción | Tipo |
|---|---|---|
| id | Identificador de la propiedad | ID |
| zona | Ubicación de la vivienda: Zona Centro, Zona Norte, Zona Oriente, Zona Oeste, Zona Sur | CN |
| piso | Piso que ocupa la vivienda (aplica a apartamentos) | CN |
| estrato | Estrato socioeconómico: 3, 4, 5, 6 | CO |
| preciom | Precio de la vivienda, en millones de pesos | CC |
| areaconst | Área construida (m²) | CC |
| parqueaderos | Número de parqueaderos | CD |
| banios | Número de baños | CD |
| habitaciones | Número de habitaciones | CD |
| tipo | Tipo de vivienda: Casa, Apartamento | CN |
| barrio | Barrio de ubicación de la vivienda | CN |
| longitud | Coordenada geográfica de longitud | CC |
| latitud | Coordenada geográfica de latitud | CC |
El enunciado pide usar estrato junto a las variables de conteo (habitaciones, parqueaderos, banios) como predictoras numéricas del precio. En rigor estrato es ordinal, pero aquí se trata como numérica, asumiendo que su efecto sobre el precio es aproximadamente lineal por cada unidad que sube.
Tabla. Datos faltantes por variable.
na_df <- data.frame(
Variable = names(vivienda),
Faltantes = colSums(is.na(vivienda))
) %>%
mutate(Pct = round(Faltantes / nrow(vivienda) * 100, 1)) %>%
filter(Faltantes > 0) %>%
arrange(desc(Faltantes))
kable(na_df, col.names = c("Variable", "N faltantes", "% faltantes"), align = c("l", "c", "c")) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Variable | N faltantes | % faltantes | |
|---|---|---|---|
| piso | piso | 2638 | 31.7 |
| parqueaderos | parqueaderos | 1605 | 19.3 |
| id | id | 3 | 0.0 |
| zona | zona | 3 | 0.0 |
| estrato | estrato | 3 | 0.0 |
| areaconst | areaconst | 3 | 0.0 |
| banios | banios | 3 | 0.0 |
| habitaciones | habitaciones | 3 | 0.0 |
| tipo | tipo | 3 | 0.0 |
| barrio | barrio | 3 | 0.0 |
| longitud | longitud | 3 | 0.0 |
| latitud | latitud | 3 | 0.0 |
| preciom | preciom | 2 | 0.0 |
La mayoría de los faltantes está en piso, variable que no entra en ningún modelo de este informe (no aplica a las casas). El caso de parqueaderos es distinto: tiene un volumen relevante de vacíos, que interpretamos como ausencia de parqueadero e imputamos con cero en vez de eliminar esas filas. El resto de los faltantes (unas pocas filas en preciom, areaconst, banios, habitaciones, tipo, zona y estrato) es un porcentaje marginal del total, así que esas filas simplemente se eliminan.
vivienda$zona <- factor(vivienda$zona)
vivienda$tipo <- factor(vivienda$tipo)
vivienda_c <- vivienda %>%
filter(!is.na(preciom), !is.na(areaconst), !is.na(banios),
!is.na(habitaciones), !is.na(tipo), !is.na(zona), !is.na(estrato))
vivienda_c$parqueaderos[is.na(vivienda_c$parqueaderos)] <- 0La base original tiene 8322 registros; tras la depuración quedan 8319 registros disponibles para el análisis (100% del total), sobre los cuales se construyen los dos filtros (base1, base2) de las siguientes secciones.
Figura. Distribución del precio de venta (millones de pesos).
ggplot(vivienda_c, aes(x = preciom)) +
geom_histogram(bins = 50, fill = col_primario, color = "white") +
theme_minimal(base_size = 11) +
labs(x = "Precio (millones $)", y = "N° propiedades")El precio tiene una fuerte asimetría a la derecha (mediana 330M vs. media 434M): la mayoría de la oferta se concentra en precios medios-bajos, con una cola de propiedades de alto valor.
Figura (interactiva). Distribución de precios por zona y tipo de vivienda.
plot_ly(vivienda_c, x = ~zona, y = ~preciom, color = ~tipo, type = "box",
colors = pal_cat[1:2]) %>%
layout(title = "Distribución de precios por zona y tipo de vivienda",
xaxis = list(title = "Zona"), yaxis = list(title = "Precio (millones $)"),
boxmode = "group")Esta vista general responde a la componente zona del análisis de correlación que pide el paso 2 del enunciado: una vez se filtre cada base por una única zona (Norte o Sur, en las secciones siguientes), zona queda constante dentro de cada subconjunto y ya no aporta nada a la correlación. Su efecto ya se puede ver aquí, a nivel general.
Figura (interactiva). Ubicación geográfica de las propiedades, coloreada por zona.
plot_ly(vivienda_c, x = ~longitud, y = ~latitud, color = ~zona, colors = pal_cat,
type = "scatter", mode = "markers", marker = list(size = 5, opacity = 0.5)) %>%
layout(title = "Ubicación geográfica por zona",
xaxis = list(title = "Longitud"),
yaxis = list(title = "Latitud", scaleanchor = "x"))Tabla. Rango de coordenadas geográficas observado en cada zona.
rangos_zona <- vivienda_c %>%
group_by(zona) %>%
summarise(lat_min = min(latitud), lat_max = max(latitud),
lon_min = min(longitud), lon_max = max(longitud), .groups = "drop")
kable(rangos_zona %>% mutate(across(where(is.numeric), ~round(., 4))),
col.names = c("Zona", "Lat. mín", "Lat. máx", "Lon. mín", "Lon. máx")) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Zona | Lat. mín | Lat. máx | Lon. mín | Lon. máx |
|---|---|---|---|---|
| Zona Centro | 3.3984 | 3.4870 | -76.5440 | -76.4959 |
| Zona Norte | 3.3331 | 3.4977 | -76.5892 | -76.4674 |
| Zona Oeste | 3.3597 | 3.4941 | -76.5874 | -76.4640 |
| Zona Oriente | 3.3440 | 3.4900 | -76.5611 | -76.4657 |
| Zona Sur | 3.3330 | 3.4968 | -76.5671 | -76.4630 |
Los rangos de latitud/longitud de las cinco zonas se traslapan considerablemente entre sí. Esto significa que zona es una etiqueta reportada por quien publicó el aviso, no un polígono geográfico estricto: dos propiedades con coordenadas casi idénticas pueden estar clasificadas en zonas distintas. Se retoma esta observación en el mapa de verificación de cada uno de los dos casos siguientes.
Esta formulación aplica de forma idéntica a los dos casos de las secciones siguientes.
Modelo poblacional. Con \(Y_i=\) preciom y \(X_{i1},\dots,X_{i5}\) las variables areaconst, estrato, habitaciones, parqueaderos y banios de la propiedad \(i\):
\[ Y_i = \beta_0 + \beta_1 X_{i1} + \beta_2 X_{i2} + \beta_3 X_{i3} + \beta_4 X_{i4} + \beta_5 X_{i5} + \varepsilon_i, \qquad \varepsilon_i \overset{iid}{\sim} N(0,\sigma^2) \]
tal como se vio en la Unidad 2 en la sesión de Modelo de Regresión Lineal Múltiple.
Estimación. En forma matricial \(y=X\beta+\varepsilon\), el estimador que minimiza la suma de cuadrados de los residuales es
\[ \hat\beta = (X'X)^{-1}X'y \]
esto se explica en la sesión de Estimación Cuadrados Ordinarios de la Unidad 2.
Significancia individual. Cada coeficiente se prueba con
\[ T_{j,0} = \frac{\hat\beta_j}{se(\hat\beta_j)} \overset{H_0:\ \beta_j=0}{\sim} t_{n-p} \]
rechazando \(H_0\) (variable significativa) cuando el valor-p es menor a 0.05, según se plantea en la sesión de Inferencia sobre los Parámetros de la Unidad 2.
Bondad de ajuste.
\[ R^2 = 1-\frac{SSE}{SST}, \qquad R^2_{ajustado} = 1-(1-R^2)\frac{n-1}{n-p} \]
Predicción individual vs. respuesta media. Cada solicitud corresponde a una vivienda individual, no al precio promedio de un segmento, por lo que se usa el intervalo de predicción
\[ \hat Y_0 = x_0\hat\beta, \qquad \widehat{V}[Y_0-\hat Y_0] = MSE\left[1+x_0(X'X)^{-1}x_0'\right] \]
que es siempre más ancho que el intervalo de confianza para la media, porque suma la variabilidad propia de una observación futura. La sesión de Inferencia sobre Media y Pronósticos de la Unidad 2 advierte además sobre el riesgo de extrapolación si alguna característica solicitada cae fuera del rango observado.
Validación de supuestos y multicolinealidad. Sobre los residuales \(e_i=(y_i-\hat y_i)\) se valida normalidad (Shapiro-Wilk), homocedasticidad (Goldfeld-Quandt, con Breusch-Pagan como contraste adicional) e independencia (Durbin-Watson), tal como lo cubre la sesión de Validación de los Supuestos del Modelo (Unidad 2). También se calcula el Factor de Inflación de Varianza \(VIF_j=1/(1-R_j^2)\), tomando \(VIF_j>10\) como evidencia de un problema grave de multicolinealidad, siguiendo la sesión de Diagnóstico de Multicolinealidad de esa misma unidad.
Como los siete pasos del enunciado se replican de forma idéntica para las dos solicitudes (Criterio 6), se definen aquí las funciones que se reutilizan en ambos casos, en vez de repetir el mismo código dos veces.
vars_modelo <- c("areaconst", "estrato", "habitaciones", "parqueaderos", "banios")
# Ecuación estimada en LaTeX, con el signo correcto de cada coeficiente
formatear_ecuacion <- function(modelo, y_nombre = "preciom") {
coefs <- coef(modelo)
terminos <- paste0(
ifelse(coefs[-1] >= 0, " + ", " - "),
format(round(abs(coefs[-1]), 3), trim = TRUE),
"\\cdot ", names(coefs)[-1]
)
paste0("\\widehat{", y_nombre, "} = ", round(coefs[1], 2), paste(terminos, collapse = ""))
}
# Tabla resumen de validación de supuestos (normalidad, homocedasticidad,
# independencia, VIF), con Shapiro-Wilk sobre muestra si n > 5000
resumen_supuestos <- function(modelo) {
res <- residuals(modelo)
n <- length(res)
muestreado <- n > 5000
if (muestreado) {
set.seed(123)
res_sh <- sample(res, 5000)
} else {
res_sh <- res
}
sh <- shapiro.test(res_sh)
# Se ordena por areaconst: es razonable esperar que la varianza del error
# crezca con el tamaño de la propiedad, el patron clasico de heterocedasticidad
# en precios inmobiliarios
gq <- lmtest::gqtest(modelo, order.by = ~areaconst, data = model.frame(modelo))
bp <- lmtest::bptest(modelo)
dw <- lmtest::dwtest(modelo)
vifs <- car::vif(modelo)
tabla <- data.frame(
Supuesto = c("Normalidad (Shapiro-Wilk)", "Homocedasticidad (Goldfeld-Quandt)",
"Homocedasticidad (Breusch-Pagan)", "Independencia (Durbin-Watson)",
"Multicolinealidad (VIF máximo)"),
Estadistico = c(round(unname(sh$statistic), 3), round(unname(gq$statistic), 3),
round(unname(bp$statistic), 3), round(unname(dw$statistic), 3),
round(max(vifs), 2)),
p.valor = c(signif(sh$p.value, 3), signif(gq$p.value, 3),
signif(bp$p.value, 3), signif(dw$p.value, 3), NA)
)
list(tabla = tabla, n_residuales = n, muestreado = muestreado,
p_shapiro = sh$p.value, p_gq = gq$p.value, p_bp = bp$p.value,
p_dw = dw$p.value, vif_max = max(vifs))
}
# Compara una característica solicitada (x0) contra el rango observado,
# para advertir riesgo de extrapolacion
chequeo_rango <- function(datos, x0) {
vars <- names(x0)
data.frame(
Variable = vars,
Solicitado = as.numeric(unlist(x0[1, vars], use.names = FALSE)),
Min_observado = sapply(vars, function(v) min(datos[[v]], na.rm = TRUE)),
Max_observado = sapply(vars, function(v) max(datos[[v]], na.rm = TRUE)),
row.names = NULL
) %>%
mutate(Dentro_de_rango = ifelse(Solicitado >= Min_observado & Solicitado <= Max_observado, "Sí", "No"))
}
# Compara el modelo completo contra un modelo reducido (sin las variables no
# significativas al 5%) mediante la prueba F parcial / SSextra (anova())
comparar_modelos <- function(modelo_completo, alpha = 0.05) {
coefs <- summary(modelo_completo)$coefficients
vars_todas <- rownames(coefs)[-1]
no_signif <- vars_todas[coefs[vars_todas, "Pr(>|t|)"] > alpha]
if (length(no_signif) == 0) {
return(list(modelo_reducido = NULL, anova = NULL, no_signif = character(0)))
}
datos_modelo <- model.frame(modelo_completo)
respuesta <- names(datos_modelo)[1]
vars_reducido <- setdiff(vars_todas, no_signif)
formula_reducida <- as.formula(paste(respuesta, "~", paste(vars_reducido, collapse = " + ")))
modelo_reducido <- lm(formula_reducida, data = datos_modelo)
list(modelo_reducido = modelo_reducido,
anova = anova(modelo_reducido, modelo_completo),
no_signif = no_signif)
}
# Distancia de Cook y DFFITS, con sus umbrales de influencia
diagnostico_influencia <- function(modelo) {
p <- length(coef(modelo))
n <- nrow(model.frame(modelo))
cooksd <- cooks.distance(modelo)
dff <- dffits(modelo)
umbral_dffits <- 2 * sqrt(p / n)
list(n_cook_altos = sum(cooksd > 1),
n_dffits_altos = sum(abs(dff) > umbral_dffits),
cooksd = cooksd, dffits = dff, umbral_dffits = umbral_dffits)
}
# Numero e indice de condicion de X'X (deteccion de multicolinealidad via
# valores propios)
numero_condicion <- function(modelo) {
X <- model.matrix(modelo)[, -1, drop = FALSE]
Xc <- scale(X, center = TRUE, scale = FALSE)
vp <- eigen(t(Xc) %*% Xc)$values
sqrt(max(vp) / min(vp))
}
# Validacion cruzada simple (70% estimacion / 30% validacion)
validacion_cruzada <- function(formula_modelo, datos, prop_estim = 0.7, semilla = 123) {
set.seed(semilla)
n <- nrow(datos)
n_estim <- floor(prop_estim * n)
idx_estim <- sample(seq_len(n), n_estim)
m_total <- lm(formula_modelo, data = datos)
m_estim <- lm(formula_modelo, data = datos[idx_estim, ])
respuesta <- all.vars(formula_modelo)[1]
y_valid <- datos[[respuesta]][-idx_estim]
y_hat_valid <- predict(m_estim, newdata = datos[-idx_estim, ])
r_val <- cor(y_valid, y_hat_valid)
list(R2_total = summary(m_total)$r.squared, r2_validacion = r_val^2,
encogimiento = summary(m_total)$r.squared - r_val^2,
coef_total = coef(m_total), coef_estim = coef(m_estim))
}(Rúbrica: Criterio 1 — Realiza filtro inicial y depuración)
La base 1 (base1) queda con 722 registros. Los primeros tres:
Tabla. Primeros tres registros de la Base 1.
kable(head(base1[, c("id","zona","estrato","preciom","areaconst","parqueaderos","banios","habitaciones","tipo","barrio")], 3)) %>%
kable_styling(bootstrap_options = c("striped", "hover"))| id | zona | estrato | preciom | areaconst | parqueaderos | banios | habitaciones | tipo | barrio |
|---|---|---|---|---|---|---|---|---|---|
| 1209 | Zona Norte | 5 | 320 | 150 | 2 | 4 | 6 | Casa | acopi |
| 1592 | Zona Norte | 5 | 780 | 380 | 2 | 3 | 3 | Casa | acopi |
| 4057 | Zona Norte | 6 | 750 | 445 | 0 | 7 | 6 | Casa | acopi |
Tabla. Verificación del filtro — Base 1 (conteo por Tipo × Zona).
tabla_verif1 <- as.data.frame.matrix(table(base1$tipo, base1$zona))
kable(tabla_verif1) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Zona Centro | Zona Norte | Zona Oeste | Zona Oriente | Zona Sur | |
|---|---|---|---|---|---|
| Apartamento | 0 | 0 | 0 | 0 | 0 |
| Casa | 0 | 722 | 0 | 0 | 0 |
Todas las demás combinaciones de tipo/zona están en cero: la totalidad de base1 corresponde a Casa × Zona Norte, lo que confirma que el filtro se aplicó correctamente.
Figura (interactiva). Ubicación geográfica de las propiedades de la Base 1, coloreadas por estrato.
plot_ly(base1, x = ~longitud, y = ~latitud, type = "scatter", mode = "markers",
color = ~estrato, colors = colorRamp(c("#8DC5CC", col_primario)),
marker = list(size = 7, opacity = 0.7),
text = ~paste("Barrio:", barrio, "<br>Precio:", preciom, "M$<br>Estrato:", estrato),
hoverinfo = "text") %>%
layout(title = "Ubicación geográfica — Base 1 (Casas, Zona Norte)",
xaxis = list(title = "Longitud"), yaxis = list(title = "Latitud"))Como vimos en la sección de Datos, las zonas se traslapan en coordenadas geográficas, así que el mapa puede mostrar puntos de base1 cerca de coordenadas que también aparecen en otras zonas. Eso no significa que el filtro esté mal: zona es la etiqueta que reportó quien publicó el aviso, la variable de negocio relevante para segmentar la oferta, no un polígono geográfico estricto. Por eso seguimos usándola como criterio de filtro, tal como lo pide el enunciado.
(Rúbrica: Criterio 2 — Realiza análisis exploratorio de los datos)
Dentro de base1, zona es constante (todo es Zona Norte) y por tanto no aporta información de correlación; el análisis de correlación se hace sobre areaconst, estrato, habitaciones, parqueaderos y banios.
Figura (interactiva). Matriz de correlación — Base 1.
plot_ly(x = colnames(corr1), y = colnames(corr1), z = corr1, type = "heatmap",
colors = colorRamp(c("#B2182B", "white", col_primario)), zmin = -1, zmax = 1) %>%
layout(title = "Matriz de correlación — Base 1")El coeficiente de correlación de preciom con areaconst es 0.731, con estrato es 0.612, y con banios es 0.523: son las tres relaciones más fuertes con el precio. habitaciones (0.323) y parqueaderos (0.317) se quedan más atrás.
Figura (interactiva). Precio vs. área construida, coloreado por estrato — Base 1.
plot_ly(base1, x = ~areaconst, y = ~preciom, color = ~factor(estrato), colors = pal_cat,
type = "scatter", mode = "markers",
text = ~paste("Baños:", banios, "<br>Habitaciones:", habitaciones), hoverinfo = "text") %>%
layout(title = "Precio vs. área construida (color = estrato) — Base 1",
xaxis = list(title = "Área construida (m²)"), yaxis = list(title = "Precio (millones $)"))Figura (interactiva). Precio según número de habitaciones — Base 1.
plot_ly(base1, x = ~factor(habitaciones), y = ~preciom, type = "box",
marker = list(color = col_primario)) %>%
layout(title = "Precio según número de habitaciones — Base 1",
xaxis = list(title = "N° habitaciones"), yaxis = list(title = "Precio (millones $)"))El precio crece con el área construida dentro de cada nivel de estrato, y los puntos de estrato más alto se ubican sistemáticamente por encima de los de estrato bajo para una misma área, algo que ya se veía venir por la correlación positiva. El número de habitaciones por sí solo no ordena tan bien el precio: viviendas con más habitaciones no son necesariamente más caras, lo cual tiene sentido si esas habitaciones son más pequeñas o si el área total no crece en la misma proporción.
(Rúbrica: Criterio 3 — Estima e interpreta el modelo)
m1 <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios, data = base1)
summary(m1)##
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos +
## banios, data = base1)
##
## Residuals:
## Min 1Q Median 3Q Max
## -964.04 -80.10 -17.08 50.06 1069.45
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -236.47551 30.36582 -7.788 0.000000000000024 ***
## areaconst 0.82677 0.04368 18.926 < 0.0000000000000002 ***
## estrato 86.42579 7.39747 11.683 < 0.0000000000000002 ***
## habitaciones 1.44443 4.16411 0.347 0.729
## parqueaderos -1.67672 4.31505 -0.389 0.698
## banios 26.97978 5.34384 5.049 0.000000564653292 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 159.1 on 716 degrees of freedom
## Multiple R-squared: 0.6508, Adjusted R-squared: 0.6484
## F-statistic: 266.9 on 5 and 716 DF, p-value: < 0.00000000000000022
La ecuación estimada es:
\[\widehat{preciom} = -236.48 + 0.827\cdot areaconst + 86.426\cdot estrato + 1.444\cdot habitaciones - 1.677\cdot parqueaderos + 26.980\cdot banios\]
Interpretando cada coeficiente (manteniendo las demás variables constantes):
El modelo obtiene un \(R^2=\) 0.651 (\(R^2\) ajustado = 0.648); estas cinco variables explican 65.1% de la variabilidad del precio de las casas de la Zona Norte. Sin tocar el modelo que pide el enunciado, este ajuste podría mejorar de varias formas: agregando variables que quedaron por fuera, como el barrio o la antigüedad de la propiedad; probando una transformación logarítmica de preciom, dada la asimetría que vimos en la sección de Datos; o incluyendo interacciones como areaconst:estrato, ya que el efecto del área parece cambiar según el estrato en la figura de dispersión anterior.
(Rúbrica: Criterio 5 — Valida los supuestos del modelo)
Figura. Gráficos de diagnóstico del modelo — Base 1.
Tabla. Validación de supuestos — Base 1.
sup1 <- resumen_supuestos(m1)
kable(sup1$tabla, col.names = c("Supuesto", "Estadístico", "p-valor")) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Supuesto | Estadístico | p-valor |
|---|---|---|
| Normalidad (Shapiro-Wilk) | 0.838 | 0.0000000 |
| Homocedasticidad (Goldfeld-Quandt) | 7.458 | 0.0000000 |
| Homocedasticidad (Breusch-Pagan) | 139.004 | 0.0000000 |
| Independencia (Durbin-Watson) | 1.633 | 0.0000003 |
| Multicolinealidad (VIF máximo) | 1.890 | NA |
La prueba de Shapiro-Wilk se aplicó sobre los 722 residuales del modelo y arrojó un valor-p de 0, por lo que se rechaza el supuesto de normalidad. La prueba de Goldfeld-Quandt (p = 0) y la de Breusch-Pagan (p = 0) coinciden en que hay evidencia de heterocedasticidad en al menos una de las dos pruebas. La prueba de Durbin-Watson (p = 0.0000003) indica que hay evidencia de autocorrelación en los residuales. El VIF máximo entre las predictoras es 1.89, sin evidencia de multicolinealidad relevante.
Tal como pide el enunciado, no se corrige el modelo; en caso de que alguno de estos supuestos no se cumpla plenamente, se podría: transformar preciom con logaritmo si la heterocedasticidad está asociada a la asimetría de precios; usar errores estándar robustos (HC) para una inferencia válida bajo heterocedasticidad; revisar las observaciones influyentes (ver Anexos) que puedan estar distorsionando la normalidad de los residuales; o, si el VIF es alto, prescindir de una de las variables redundantes o aplicar regularización (Ridge/Lasso).
(Rúbrica: Criterio 4 — Realiza estimación puntual y su interpretación)
La solicitud pide un estrato “4 o 5”; se predice para ambos escenarios, manteniendo las demás características exactas de la solicitud (área 200 m², 4 habitaciones, 1 parqueadero, 2 baños).
Tabla. Verificación de extrapolación — características solicitadas vs. rango observado en Base 1.
x0_e4 <- data.frame(areaconst = 200, estrato = 4, habitaciones = 4, parqueaderos = 1, banios = 2)
x0_e5 <- data.frame(areaconst = 200, estrato = 5, habitaciones = 4, parqueaderos = 1, banios = 2)
kable(chequeo_rango(base1, x0_e4)) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Variable | Solicitado | Min_observado | Max_observado | Dentro_de_rango |
|---|---|---|---|---|
| areaconst | 200 | 30 | 1440 | Sí |
| estrato | 4 | 3 | 6 | Sí |
| habitaciones | 4 | 0 | 10 | Sí |
| parqueaderos | 1 | 0 | 10 | Sí |
| banios | 2 | 0 | 10 | Sí |
Tabla. Predicción del precio de la Vivienda 1 (intervalo de predicción al 95%).
pred1_e4 <- predict(m1, newdata = x0_e4, interval = "prediction")
pred1_e5 <- predict(m1, newdata = x0_e5, interval = "prediction")
tabla_pred1 <- data.frame(
Estrato = c(4, 5),
Estimado = c(pred1_e4[1, "fit"], pred1_e5[1, "fit"]),
Lim_inferior = c(pred1_e4[1, "lwr"], pred1_e5[1, "lwr"]),
Lim_superior = c(pred1_e4[1, "upr"], pred1_e5[1, "upr"])
) %>% mutate(across(where(is.numeric), ~round(., 1)))
kable(tabla_pred1, col.names = c("Estrato", "Precio estimado (millones)", "Lím. inf. 95% (millones)", "Lím. sup. 95% (millones)")) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Estrato | Precio estimado (millones) | Lím. inf. 95% (millones) | Lím. sup. 95% (millones) |
|---|---|---|---|
| 4 | 332.6 | 19.7 | 645.6 |
| 5 | 419.1 | 105.6 | 732.5 |
interp1_e4 <- if (pred1_e4[1, "upr"] <= 350) {
"cubre holgadamente todo el intervalo de predicción"
} else {
paste0("cubre la estimación puntual, aunque el límite superior del intervalo de predicción (",
round(pred1_e4[1, "upr"], 1), " M) supera el presupuesto, por lo que conviene apoyarse en las ofertas concretas de la siguiente sección en vez de confiar solo en el punto estimado")
}
interp1_e5 <- ifelse(pred1_e5[1, "fit"] <= 350, "dentro del presupuesto", "por encima del presupuesto de 350 millones ya en la estimación puntual")Para estrato 4, el precio puntual estimado es 332.6 millones, y el crédito de $350 millones cubre la estimación puntual, aunque el límite superior del intervalo de predicción (645.6 M) supera el presupuesto, por lo que conviene apoyarse en las ofertas concretas de la siguiente sección en vez de confiar solo en el punto estimado. Para estrato 5, el precio puntual estimado es 419.1 millones, por encima del presupuesto de 350 millones ya en la estimación puntual. En ambos casos, todas las características solicitadas están dentro del rango observado en base1 (tabla anterior), por lo que no hay riesgo relevante de extrapolación.
base1 <- base1 %>%
mutate(precio_modelo = predict(m1, newdata = base1),
brecha = precio_modelo - preciom) # positivo = el modelo la valora por encima de lo que cuesta (subvalorada)
ofertas1 <- base1 %>%
filter(preciom <= 350, estrato %in% c(4, 5), areaconst >= 200,
habitaciones >= 3, banios >= 2, brecha > 0) %>%
arrange(desc(brecha))Se predice el precio de cada propiedad de base1 con el modelo m1 y se calcula la brecha entre ese precio estimado y el precio real de lista: una brecha positiva indica que la propiedad está subvalorada respecto a lo que el modelo esperaría para esas características, es decir, una oportunidad de compra. Entre las propiedades que cumplen el presupuesto y las condiciones de la solicitud, se identificaron 44 ofertas potenciales . Se presentan las 5 con mayor brecha positiva:
Tabla. Ofertas potenciales recomendadas — Vivienda 1.
top1 <- head(ofertas1, min(5, nrow(ofertas1))) %>%
select(id, barrio, estrato, preciom, precio_modelo, brecha, areaconst, parqueaderos, banios, habitaciones) %>%
mutate(across(c(precio_modelo, brecha), ~round(., 1)))
kable(top1, col.names = c("ID","Barrio","Estrato","Precio lista (millones)","Precio modelo (millones)","Brecha (millones)",
"Área (m²)","Parq.","Baños","Habs.")) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE)| ID | Barrio | Estrato | Precio lista (millones) | Precio modelo (millones) | Brecha (millones) | Área (m²) | Parq. | Baños | Habs. |
|---|---|---|---|---|---|---|---|---|---|
| 3101 | san vicente | 5 | 340 | 632.3 | 292.3 | 355 | 2 | 5 | 8 |
| 1009 | el bosque | 5 | 250 | 510.0 | 260.0 | 243 | 1 | 4 | 5 |
| 4209 | el bosque | 5 | 350 | 582.2 | 232.2 | 300 | 3 | 5 | 6 |
| 766 | la merced | 5 | 321 | 542.0 | 221.0 | 249 | 1 | 5 | 5 |
| 1914 | vipasa | 5 | 300 | 505.4 | 205.4 | 205 | 2 | 5 | 6 |
Figura (interactiva). Mapa de las ofertas potenciales recomendadas — Vivienda 1.
top1_mapa <- head(ofertas1, min(8, nrow(ofertas1)))
plot_ly(top1_mapa, x = ~longitud, y = ~latitud, type = "scatter", mode = "markers+text",
text = ~barrio, textposition = "top center",
marker = list(size = 12, color = "#27ae60"),
hovertext = ~paste("Barrio:", barrio, "<br>Precio lista:", preciom, "M$<br>Precio modelo:",
round(precio_modelo,1), "M$"), hoverinfo = "text") %>%
layout(title = "Ofertas potenciales — Vivienda 1",
xaxis = list(title = "Longitud"), yaxis = list(title = "Latitud"))Estas propiedades cumplen el presupuesto de $350 millones y las condiciones mínimas de la solicitud, y el modelo además las valora por encima de su precio de lista, lo que las convierte en una buena recomendación para María.
(Rúbrica: Criterio 6 — Replica el ejercicio con aptos del sur)
Esta sección repite íntegramente la estructura del Caso 1 (Secciones 5.1 a 5.5) para la segunda solicitud.
La base 2 (base2) queda con 2787 registros, bastante más que base1. Tiene sentido: la Zona Sur y los apartamentos concentran la mayor parte de la oferta general de la ciudad, como ya se veía en el panorama de la sección de Datos. Los primeros tres registros:
Tabla. Primeros tres registros de la Base 2.
kable(head(base2[, c("id","zona","estrato","preciom","areaconst","parqueaderos","banios","habitaciones","tipo","barrio")], 3)) %>%
kable_styling(bootstrap_options = c("striped", "hover"))| id | zona | estrato | preciom | areaconst | parqueaderos | banios | habitaciones | tipo | barrio |
|---|---|---|---|---|---|---|---|---|---|
| 5098 | Zona Sur | 4 | 290 | 96 | 1 | 2 | 3 | Apartamento | acopi |
| 698 | Zona Sur | 3 | 78 | 40 | 1 | 1 | 2 | Apartamento | aguablanca |
| 8199 | Zona Sur | 6 | 875 | 194 | 2 | 5 | 3 | Apartamento | aguacatal |
Tabla. Verificación del filtro — Base 2 (conteo por Tipo × Zona).
tabla_verif2 <- as.data.frame.matrix(table(base2$tipo, base2$zona))
kable(tabla_verif2) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Zona Centro | Zona Norte | Zona Oeste | Zona Oriente | Zona Sur | |
|---|---|---|---|---|---|
| Apartamento | 0 | 0 | 0 | 0 | 2787 |
| Casa | 0 | 0 | 0 | 0 | 0 |
Al igual que en la Base 1, todas las demás combinaciones están en cero: la totalidad de base2 corresponde a Apartamento × Zona Sur.
Figura (interactiva). Ubicación geográfica de las propiedades de la Base 2, coloreadas por estrato.
plot_ly(base2, x = ~longitud, y = ~latitud, type = "scatter", mode = "markers",
color = ~estrato, colors = colorRamp(c("#8DC5CC", col_primario)),
marker = list(size = 6, opacity = 0.6),
text = ~paste("Barrio:", barrio, "<br>Precio:", preciom, "M$<br>Estrato:", estrato),
hoverinfo = "text") %>%
layout(title = "Ubicación geográfica — Base 2 (Apartamentos, Zona Sur)",
xaxis = list(title = "Longitud"), yaxis = list(title = "Latitud"))Igual que en la Base 1, el traslape de coordenadas entre zonas explica que algunos puntos de base2 caigan cerca de coordenadas que también aparecen en otras zonas. Se mantiene zona como criterio de filtro porque es la clasificación de negocio relevante, no una frontera geográfica exacta.
Figura (interactiva). Matriz de correlación — Base 2.
plot_ly(x = colnames(corr2), y = colnames(corr2), z = corr2, type = "heatmap",
colors = colorRamp(c("#B2182B", "white", col_primario)), zmin = -1, zmax = 1) %>%
layout(title = "Matriz de correlación — Base 2")En la Base 2, el coeficiente de correlación de preciom con areaconst es 0.758, con estrato es 0.673, y con banios es 0.72; habitaciones (0.332) y parqueaderos (0.675) muestran de nuevo una asociación más débil.
Figura (interactiva). Precio vs. área construida, coloreado por estrato — Base 2.
plot_ly(base2, x = ~areaconst, y = ~preciom, color = ~factor(estrato), colors = pal_cat,
type = "scatter", mode = "markers",
text = ~paste("Baños:", banios, "<br>Habitaciones:", habitaciones), hoverinfo = "text") %>%
layout(title = "Precio vs. área construida (color = estrato) — Base 2",
xaxis = list(title = "Área construida (m²)"), yaxis = list(title = "Precio (millones $)"))Figura (interactiva). Precio según número de habitaciones — Base 2.
plot_ly(base2, x = ~factor(habitaciones), y = ~preciom, type = "box",
marker = list(color = col_primario)) %>%
layout(title = "Precio según número de habitaciones — Base 2",
xaxis = list(title = "N° habitaciones"), yaxis = list(title = "Precio (millones $)"))El patrón es análogo al de la Base 1: el precio crece con el área dentro de cada estrato, y el número de habitaciones por sí solo no ordena claramente el precio.
m2 <- lm(preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios, data = base2)
summary(m2)##
## Call:
## lm(formula = preciom ~ areaconst + estrato + habitaciones + parqueaderos +
## banios, data = base2)
##
## Residuals:
## Min 1Q Median 3Q Max
## -1252.31 -42.15 -2.06 36.32 934.06
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -221.04614 13.47771 -16.401 < 0.0000000000000002 ***
## areaconst 1.46061 0.04876 29.956 < 0.0000000000000002 ***
## estrato 57.00608 2.79648 20.385 < 0.0000000000000002 ***
## habitaciones -22.71789 3.39549 -6.691 0.0000000000268 ***
## parqueaderos 48.36353 3.02343 15.996 < 0.0000000000000002 ***
## banios 48.60871 3.04050 15.987 < 0.0000000000000002 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 95.17 on 2781 degrees of freedom
## Multiple R-squared: 0.7536, Adjusted R-squared: 0.7531
## F-statistic: 1701 on 5 and 2781 DF, p-value: < 0.00000000000000022
La ecuación estimada es:
\[\widehat{preciom} = -221.05 + 1.461\cdot areaconst + 57.006\cdot estrato - 22.718\cdot habitaciones + 48.364\cdot parqueaderos + 48.609\cdot banios\]
El modelo obtiene un \(R^2=\) 0.754 (\(R^2\) ajustado = 0.753): estas cinco variables explican 75.4% de la variabilidad del precio de los apartamentos de la Zona Sur. Es un ajuste parecido o mejor que el de la Base 1. Como en el Caso 1, este ajuste podría mejorar (sin tocar el modelo pedido) incorporando barrio o piso, probando log(preciom), o incluyendo la interacción areaconst:estrato.
Figura. Gráficos de diagnóstico del modelo — Base 2.
Tabla. Validación de supuestos — Base 2.
sup2 <- resumen_supuestos(m2)
kable(sup2$tabla, col.names = c("Supuesto", "Estadístico", "p-valor")) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Supuesto | Estadístico | p-valor |
|---|---|---|
| Normalidad (Shapiro-Wilk) | 0.782 | 0 |
| Homocedasticidad (Goldfeld-Quandt) | 15.945 | 0 |
| Homocedasticidad (Breusch-Pagan) | 956.830 | 0 |
| Independencia (Durbin-Watson) | 1.504 | 0 |
| Multicolinealidad (VIF máximo) | 2.480 | NA |
La prueba de Shapiro-Wilk se aplicó sobre los 2787 residuales del modelo y arrojó un valor-p de 0, por lo que se rechaza el supuesto de normalidad. Goldfeld-Quandt (p = 0) y Breusch-Pagan (p = 0) coinciden en que hay evidencia de heterocedasticidad en al menos una de las dos pruebas. Durbin-Watson (p = 0) indica que hay evidencia de autocorrelación. El VIF máximo es 2.48, sin evidencia de multicolinealidad relevante. Con 2787 observaciones, es esperable que las pruebas formales rechacen con mayor facilidad desviaciones pequeñas de los supuestos, por lo que conviene dar más peso a los gráficos de diagnóstico que al valor-p aislado. Las mismas sugerencias del Caso 1 (transformación logarítmica, errores robustos, revisar influyentes, regularización) aplican aquí sin modificar el modelo actual.
La solicitud pide estrato “5 o 6”; se predice para ambos escenarios, con las características exactas de la solicitud (área 300 m², 5 habitaciones, 3 parqueaderos, 3 baños).
Tabla. Verificación de extrapolación — características solicitadas vs. rango observado en Base 2.
x0b_e5 <- data.frame(areaconst = 300, estrato = 5, habitaciones = 5, parqueaderos = 3, banios = 3)
x0b_e6 <- data.frame(areaconst = 300, estrato = 6, habitaciones = 5, parqueaderos = 3, banios = 3)
kable(chequeo_rango(base2, x0b_e5)) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Variable | Solicitado | Min_observado | Max_observado | Dentro_de_rango |
|---|---|---|---|---|
| areaconst | 300 | 40 | 932 | Sí |
| estrato | 5 | 3 | 6 | Sí |
| habitaciones | 5 | 0 | 6 | Sí |
| parqueaderos | 3 | 0 | 10 | Sí |
| banios | 3 | 0 | 8 | Sí |
Tabla. Predicción del precio de la Vivienda 2 (intervalo de predicción al 95%).
pred2_e5 <- predict(m2, newdata = x0b_e5, interval = "prediction")
pred2_e6 <- predict(m2, newdata = x0b_e6, interval = "prediction")
tabla_pred2 <- data.frame(
Estrato = c(5, 6),
Estimado = c(pred2_e5[1, "fit"], pred2_e6[1, "fit"]),
Lim_inferior = c(pred2_e5[1, "lwr"], pred2_e6[1, "lwr"]),
Lim_superior = c(pred2_e5[1, "upr"], pred2_e6[1, "upr"])
) %>% mutate(across(where(is.numeric), ~round(., 1)))
kable(tabla_pred2, col.names = c("Estrato", "Precio estimado (millones)", "Lím. inf. 95% (millones)", "Lím. sup. 95% (millones)")) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Estrato | Precio estimado (millones) | Lím. inf. 95% (millones) | Lím. sup. 95% (millones) |
|---|---|---|---|
| 5 | 679.5 | 491.8 | 867.2 |
| 6 | 736.5 | 548.8 | 924.2 |
Para estrato 5, el precio puntual estimado es 679.5 millones; para estrato 6, 736.5 millones. El crédito de $850 millones cubre la estimación puntual en ambos escenarios, aunque el límite superior del intervalo de predicción para estrato 6 lo supera. Todas las características solicitadas están dentro del rango observado en base2 (tabla anterior), por lo que no hay riesgo relevante de extrapolación.
base2 <- base2 %>%
mutate(precio_modelo = predict(m2, newdata = base2),
brecha = precio_modelo - preciom)
ofertas2 <- base2 %>%
filter(preciom <= 850, estrato %in% c(5, 6), areaconst >= 300,
habitaciones >= 4, banios >= 3, parqueaderos >= 2, brecha > 0) %>%
arrange(desc(brecha))Con la misma lógica del Caso 1 (brecha positiva = propiedad subvalorada según el modelo), se identificaron 6 ofertas potenciales para la Vivienda 2 . Se presentan las 5 con mayor brecha positiva:
Tabla. Ofertas potenciales recomendadas — Vivienda 2.
top2 <- head(ofertas2, min(5, nrow(ofertas2))) %>%
select(id, barrio, estrato, preciom, precio_modelo, brecha, areaconst, parqueaderos, banios, habitaciones) %>%
mutate(across(c(precio_modelo, brecha), ~round(., 1)))
kable(top2, col.names = c("ID","Barrio","Estrato","Precio lista (millones)","Precio modelo (millones)","Brecha (millones)",
"Área (m²)","Parq.","Baños","Habs.")) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE)| ID | Barrio | Estrato | Precio lista (millones) | Precio modelo (millones) | Brecha (millones) | Área (m²) | Parq. | Baños | Habs. |
|---|---|---|---|---|---|---|---|---|---|
| 7182 | guadalupe | 5 | 730 | 1321.3 | 591.3 | 573 | 3 | 8 | 5 |
| 4952 | el ingenio | 5 | 650 | 1117.9 | 467.9 | 600 | 2 | 4 | 5 |
| 4394 | el ingenio | 5 | 690 | 974.1 | 284.1 | 486 | 2 | 4 | 4 |
| 6932 | san fernando | 5 | 500 | 746.3 | 246.3 | 330 | 2 | 4 | 4 |
| 7658 | cuarto de legua | 5 | 520 | 731.7 | 211.7 | 320 | 2 | 4 | 4 |
Figura (interactiva). Mapa de las ofertas potenciales recomendadas — Vivienda 2.
top2_mapa <- head(ofertas2, min(8, nrow(ofertas2)))
plot_ly(top2_mapa, x = ~longitud, y = ~latitud, type = "scatter", mode = "markers+text",
text = ~barrio, textposition = "top center",
marker = list(size = 12, color = "#27ae60"),
hovertext = ~paste("Barrio:", barrio, "<br>Precio lista:", preciom, "M$<br>Precio modelo:",
round(precio_modelo,1), "M$"), hoverinfo = "text") %>%
layout(title = "Ofertas potenciales — Vivienda 2",
xaxis = list(title = "Longitud"), yaxis = list(title = "Latitud"))En los dos segmentos, el área construida es la variable con mayor asociación con el precio (r = 0.731 en casas del norte, r = 0.758 en apartamentos del sur), seguida del estrato. El modelo de la Base 1 explica 65.1% de la variabilidad del precio y el de la Base 2, 75.4%. Ambos podrían mejorar agregando variables como barrio o piso, que dejamos por fuera porque el enunciado pide el modelo solo con estas cinco.
Para la Vivienda 1, el precio estimado ronda entre 333 y 419 millones según el estrato (4 o 5), frente a un crédito de $350 millones. Para la Vivienda 2, el precio estimado ronda entre 679 y 737 millones según el estrato (5 o 6), frente a un crédito de $850 millones. Los intervalos de predicción (no solo el punto estimado) deben tenerse en cuenta al negociar, pues reflejan la variabilidad real de una vivienda individual, más amplia que la de un precio promedio.
Se identificaron 44 ofertas para la Vivienda 1 y 6 para la Vivienda 2 que cumplen el presupuesto y las condiciones solicitadas, y que además el modelo valora por encima de su precio de lista (ver tablas y mapas de cada caso). Estas son el punto de partida recomendado para que los agentes de C&A contacten a los vendedores.
En la práctica, esto le da a María dos rutas de acción: para la Vivienda 1, conviene empezar por mostrar las opciones de estrato 4, que es la que mejor calza con el crédito de $350 millones; para la Vivienda 2, el crédito de $850 millones tiene margen en los dos estratos.
Como soporte del informe ejecutivo, se incluyen aquí las estimaciones, validaciones y comparación de modelos que sustentan las secciones anteriores.
comp1 <- comparar_modelos(m1)
if (length(comp1$no_signif) == 0) {
cat("**Vivienda 1**: todos los coeficientes del modelo completo resultaron significativos al 5%; no existe una versión reducida que comparar, por lo que el modelo completo es también el más parsimonioso.")
} else {
cat(paste0("**Tabla.** Vivienda 1 — Prueba F parcial: modelo reducido (sin ",
paste(comp1$no_signif, collapse = ", "), ") vs. modelo completo.\n\n"))
kable(as.data.frame(comp1$anova)) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
}Tabla. Vivienda 1 — Prueba F parcial: modelo reducido (sin habitaciones, parqueaderos) vs. modelo completo.
| Res.Df | RSS | Df | Sum of Sq | F | Pr(>F) |
|---|---|---|---|---|---|
| 718 | 18138894 | NA | NA | NA | NA |
| 716 | 18132812 | 2 | 6081.449 | 0.1200673 | 0.8868786 |
comp2 <- comparar_modelos(m2)
if (length(comp2$no_signif) == 0) {
cat("**Vivienda 2**: todos los coeficientes del modelo completo resultaron significativos al 5%; no existe una versión reducida que comparar.")
} else {
cat(paste0("**Tabla.** Vivienda 2 — Prueba F parcial: modelo reducido (sin ",
paste(comp2$no_signif, collapse = ", "), ") vs. modelo completo.\n\n"))
kable(as.data.frame(comp2$anova)) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
}Vivienda 2: todos los coeficientes del modelo completo resultaron significativos al 5%; no existe una versión reducida que comparar.
En ambos casos, la prueba F parcial (SSextra) contrasta si el subconjunto de variables no significativas aporta información conjunta relevante dado que las demás ya están en el modelo (Unidad 2, sesión de Inferencia sobre un Subconjunto de Parámetros): un valor-p mayor a 0.05 respalda simplificar el modelo eliminando esas variables sin pérdida relevante de ajuste.
Tabla. Observaciones influenciales detectadas por modelo.
inf1 <- diagnostico_influencia(m1)
inf2 <- diagnostico_influencia(m2)
data.frame(
Modelo = c("Vivienda 1", "Vivienda 2"),
`Obs. con Cook > 1` = c(inf1$n_cook_altos, inf2$n_cook_altos),
`Obs. con DFFITS por encima del umbral` = c(inf1$n_dffits_altos, inf2$n_dffits_altos),
check.names = FALSE
) %>%
kable() %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Modelo | Obs. con Cook > 1 | Obs. con DFFITS por encima del umbral |
|---|---|---|
| Vivienda 1 | 0 | 48 |
| Vivienda 2 | 2 | 150 |
Ninguno de los dos modelos muestra evidencia de que unas pocas observaciones dominen el ajuste de forma crítica (Distancia de Cook), lo cual es esperable dado el tamaño de ambas bases; cualquier observación señalada por DFFITS podría investigarse manualmente antes de confiar en las predicciones puntuales de la Sección de Estimación Puntual, sin que esto implique modificar el modelo actual.
Tabla. Diagnóstico ampliado de multicolinealidad (Unidad 2, sesión de Diagnóstico de Multicolinealidad).
data.frame(
Modelo = c("Vivienda 1", "Vivienda 2"),
`VIF máximo` = c(round(sup1$vif_max, 2), round(sup2$vif_max, 2)),
`Número de condición (raíz de kappa)` = c(round(numero_condicion(m1), 2), round(numero_condicion(m2), 2)),
check.names = FALSE
) %>%
kable() %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Modelo | VIF máximo | Número de condición (raíz de kappa) |
|---|---|---|
| Vivienda 1 | 1.89 | 224.56 |
| Vivienda 2 | 2.48 | 113.82 |
Con el criterio \(\sqrt\kappa\leq10\) (sin problema), \(10<\sqrt\kappa\leq31.62\) (moderado) y \(\sqrt\kappa>31.62\) (grave): el modelo de Vivienda 1 muestra un número de condición grave, y el de Vivienda 2, grave.
Este resultado contrasta con el VIF, que no encontró problema en ninguno de los dos modelos (sección de Validación de Supuestos). La razón está en que el número de condición se calcula sobre las variables centradas pero no estandarizadas, así que es muy sensible a que las predictoras estén en escalas distintas (areaconst va de decenas a miles, mientras estrato, habitaciones, parqueaderos y banios van de 0 a 10), no a que estén correlacionadas entre sí. El VIF sí controla por escala, porque se basa en el \(R^2\) de regresar cada predictora sobre las demás, así que es el diagnóstico más confiable aquí: no hay evidencia real de multicolinealidad entre las cinco variables del modelo.
Tabla. Validación cruzada simple (Unidad 2, sesión de Validación Cruzada).
formula_modelo <- preciom ~ areaconst + estrato + habitaciones + parqueaderos + banios
cv1 <- validacion_cruzada(formula_modelo, base1, semilla = 123)
cv2 <- validacion_cruzada(formula_modelo, base2, semilla = 123)
data.frame(
Modelo = c("Vivienda 1", "Vivienda 2"),
`R2 (toda la muestra)` = round(c(cv1$R2_total, cv2$R2_total), 3),
`r2 en validación` = round(c(cv1$r2_validacion, cv2$r2_validacion), 3),
Encogimiento = round(c(cv1$encogimiento, cv2$encogimiento), 3),
check.names = FALSE
) %>%
kable() %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Modelo | R2 (toda la muestra) | r2 en validación | Encogimiento |
|---|---|---|---|
| Vivienda 1 | 0.651 | 0.610 | 0.041 |
| Vivienda 2 | 0.754 | 0.756 | -0.002 |
Un encogimiento pequeño (diferencia entre el \(R^2\) con toda la muestra y el \(r^2\) en la muestra de validación) respalda que el modelo generaliza razonablemente a datos que no participaron en su estimación, en vez de estar sobreajustado a las particularidades de la muestra completa.
## R version 4.6.1 (2026-06-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26100)
##
## Matrix products: default
## LAPACK version 3.12.1
##
## locale:
## [1] LC_COLLATE=Spanish_Colombia.utf8 LC_CTYPE=Spanish_Colombia.utf8
## [3] LC_MONETARY=Spanish_Colombia.utf8 LC_NUMERIC=C
## [5] LC_TIME=Spanish_Colombia.utf8
##
## time zone: America/Bogota
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] lmtest_0.9-40 zoo_1.9-0 car_3.1-5
## [4] carData_3.0-6 kableExtra_1.4.1 plotly_4.12.1
## [7] dplyr_1.2.1 paqueteMODELOS_0.1.0 summarytools_1.1.5
## [10] knitr_1.52 gridExtra_2.3.1 GGally_2.4.0
## [13] ggplot2_4.0.3 broom_1.0.13 boot_1.3-32
##
## loaded via a namespace (and not attached):
## [1] gtable_0.3.6 xfun_0.60 bslib_0.12.0
## [4] htmlwidgets_1.6.4 lattice_0.22-9 crosstalk_1.2.2
## [7] vctrs_0.7.3 tools_4.6.1 generics_0.1.4
## [10] tibble_3.3.1 pkgconfig_2.0.3 data.table_1.18.6.1
## [13] checkmate_2.3.4 RColorBrewer_1.1-3 S7_0.2.2
## [16] lifecycle_1.0.5 compiler_4.6.1 farver_2.1.2
## [19] stringr_1.6.0 textshaping_1.0.5 rapportools_1.2
## [22] htmltools_0.5.9 sass_0.4.10 yaml_2.3.12
## [25] Formula_1.2-6 pillar_1.11.1 jquerylib_0.1.4
## [28] tidyr_1.3.2 MASS_7.3-65 cachem_1.1.0
## [31] magick_2.9.1 abind_1.4-8 ggstats_0.14.0
## [34] tidyselect_1.2.1 digest_0.6.39 stringi_1.8.9
## [37] reshape2_1.4.5 purrr_1.2.2 pander_0.6.6
## [40] bookdown_0.48 labeling_0.4.3 fastmap_1.2.0
## [43] grid_4.6.1 cli_3.6.6 magrittr_2.0.5
## [46] base64enc_0.1-6 withr_3.0.3 scales_1.4.0
## [49] backports_1.5.1 lubridate_1.9.5 timechange_0.4.0
## [52] rmarkdown_2.32 httr_1.4.9 matrixStats_1.5.0
## [55] otel_0.2.0 evaluate_1.0.5 tcltk_4.6.1
## [58] viridisLite_0.4.3 rlang_1.3.0 Rcpp_1.1.2
## [61] glue_1.8.1 xml2_1.6.0 svglite_2.2.2
## [64] rstudioapi_0.19.0 jsonlite_2.0.0 R6_2.6.1
## [67] plyr_1.8.9 systemfonts_1.3.2