Implementación Modelo Hedonico de Precios - Autos Usados

1. Propósito del archivo

Este documento presenta el procedimiento completo para adaptar el modelo hedónico de precios a la canasta efectiva de autos usados del IPC. La idea central es dejar de trabajar con el universo general de avisos y pasar a una muestra restringida a los modelos que efectivamente pertenecen a la canasta. Esto mejora la representatividad del ejercicio y permite defender mejor la metodología.

El documento está organizado por bloques. En cada uno se explica:

  • qué hace el bloque,
  • por qué es necesario,
  • qué resultado conviene revisar,
  • y luego se muestra el código correspondiente.

2. Estructura general del procedimiento

El flujo del trabajo sigue la siguiente lógica:

  1. Cargar librerías y bases.
  2. Definir funciones auxiliares para limpieza, homologación y diagnósticos.
  3. Limpiar la base principal de avisos.
  4. Construir variables de marca y modelo desde los títulos.
  5. Preparar la canasta oficial y homologar nombres entre ambas fuentes.
  6. Filtrar la base para conservar solo los modelos de la canasta.
  7. Asignar zonas geográficas.
  8. Exigir un mínimo de observaciones por modelo para la especificación más exigente.
  9. Estimar varias especificaciones hedónicas.
  10. Depurar observaciones influyentes.
  11. Estimar el modelo final y revisar sus diagnósticos.

BLOQUE 0. Librerías

Explicación

En este bloque se cargan los paquetes necesarios para trabajar con la base, limpiar texto, estimar modelos, calcular errores robustos y revisar diagnósticos. También se incluye una función sencilla para instalar paquetes en caso de que alguno no esté disponible.

Este bloque debe ejecutarse primero porque todo el resto del procedimiento depende de estas librerías.

Qué revisar

Conviene comprobar que todas las librerías carguen sin errores. Si alguna falla, el código posterior también fallará.

Código

rm(list = ls())

###############################################################
# BLOQUE 0: LIBRERÍAS
###############################################################

paquetes <- c(
  "tidyverse", "readxl", "stringi", "janitor", "broom",
  "lmtest", "sandwich", "car", "tseries", "MASS"
)

instalar_si_falta <- function(pkgs){
  for(p in pkgs){
    if(!requireNamespace(p, quietly = TRUE)) install.packages(p)
  }
}

instalar_si_falta(paquetes)

library(tidyverse)
library(readxl)
library(stringi)
library(janitor)
library(broom)
library(lmtest)
library(sandwich)
library(car)
library(tseries)
library(MASS)

options(scipen = 999)

BLOQUE 1. Carga de datos

Explicación

Aquí se leen dos archivos:

  • la base principal de avisos de autos usados,
  • y la canasta oficial que contiene los modelos relevantes para el IPC.

Además, se limpian los nombres de columnas para evitar problemas por espacios en blanco. Este paso es importante porque varios errores ocurren simplemente porque el nombre de una columna no coincide exactamente con el que se está usando en el código.

Qué revisar

Hay que verificar:

  • que la hoja del Excel sea correcta,
  • que la base principal tenga las columnas esperadas,
  • y que la canasta efectivamente tenga la columna MODELO.

Código

###############################################################
# BLOQUE 1: CARGA DE DATOS
###############################################################

archivo_base <- "C:/Users/mnorellanam/Documents/Base_A_2022_02_MARZO.xlsx"
archivo_canasta <- "C:/Users/mnorellanam/Documents/Base_B.xlsx"

df <- read_excel(archivo_base, sheet = "Datos MercadoLibre")
canasta <- read_excel(archivo_canasta, sheet = "Hoja1")

names(df) <- trimws(names(df))
names(canasta) <- trimws(names(canasta))

cat("Filas base principal:", nrow(df), "\n")
cat("Filas canasta:", nrow(canasta), "\n")

cat("\nColumnas base principal:\n")
print(names(df))

cat("\nColumnas canasta:\n")
print(names(canasta))

BLOQUE 2. Funciones auxiliares

Explicación

Este bloque reúne todas las funciones de apoyo del procedimiento. La lógica es dejar en un solo lugar las funciones que se reutilizan más adelante.

Las funciones incluidas cumplen distintos propósitos:

  • normalizar_texto(): limpia texto general, como marcas, títulos o comunas.
  • normalizar_modelo_catalogo(): hace una limpieza más agresiva sobre nombres de modelos, eliminando expresiones como NEW, HB, SEDAN, etc.
  • extraer_modelo_titulo(): intenta obtener el modelo desde el título del aviso.
  • calcular_vif(): revisa multicolinealidad.
  • imprimir_resumen_modelo(): muestra coeficientes clásicos y robustos.
  • diagnosticos_modelo(): calcula pruebas de normalidad, heterocedasticidad, especificación y medidas de ajuste.
  • graficos_residuos(): genera gráficos diagnósticos.
  • eliminar_outliers_iterativo(): depura observaciones influyentes de manera iterativa.

Qué revisar

Lo importante aquí es que todas las funciones queden definidas correctamente. Si alguna de ellas presenta error, el flujo posterior no va a ejecutarse.

Código

###############################################################
# BLOQUE 2: FUNCIONES AUXILIARES
###############################################################

normalizar_texto <- function(texto){
  texto <- as.character(texto)
  texto <- trimws(tolower(texto))
  texto <- stringi::stri_trans_general(texto, "Latin-ASCII")
  texto <- gsub("[[:punct:]]", " ", texto)
  texto <- gsub("\\s+", " ", texto)
  texto <- trimws(texto)
  texto[texto == ""] <- NA_character_
  texto
}

normalizar_modelo_catalogo <- function(texto){
  texto <- as.character(texto)
  texto <- trimws(toupper(texto))
  texto <- stringi::stri_trans_general(texto, "Latin-ASCII")

  texto <- gsub("-", " ", texto)
  texto <- gsub("/", " ", texto)
  texto <- gsub("\\.", " ", texto)

  texto <- gsub("\\bALL NEW\\b", "", texto)
  texto <- gsub("\\bNEW\\b", "", texto)
  texto <- gsub("\\bNUEVA\\b", "", texto)
  texto <- gsub("\\bNUEVO\\b", "", texto)
  texto <- gsub("\\bHB\\b", "", texto)
  texto <- gsub("\\bH B\\b", "", texto)
  texto <- gsub("\\bSEDAN\\b", "", texto)
  texto <- gsub("\\bSPORT\\b", "", texto)
  texto <- gsub("\\bNB\\b", "", texto)
  texto <- gsub("\\bMT\\b", "", texto)
  texto <- gsub("\\bAT\\b", "", texto)
  texto <- gsub("\\bMEC\\b", "", texto)
  texto <- gsub("\\bAUT\\b", "", texto)
  texto <- gsub("\\bLX\\b", "", texto)
  texto <- gsub("\\bEX\\b", "", texto)
  texto <- gsub("\\bGL\\b", "", texto)
  texto <- gsub("\\bGLS\\b", "", texto)

  texto <- gsub("I10", "I 10", texto)
  texto <- gsub("I20", "I 20", texto)
  texto <- gsub("CX([0-9])", "CX \\1", texto)
  texto <- gsub("CX\\s*([0-9]{2})", "CX \\1", texto)

  texto <- gsub("\\s+", " ", texto)
  texto <- trimws(texto)
  texto[texto == ""] <- NA_character_
  texto
}

extraer_modelo_titulo <- function(titulo, marca_std){
  if(is.na(titulo) || is.na(marca_std)) return(NA_character_)

  txt <- normalizar_texto(titulo)
  marca_txt <- normalizar_texto(marca_std)

  txt <- gsub(paste0("^", marca_txt, "\\s+"), "", txt)
  palabras <- unlist(strsplit(txt, " "))

  if(length(palabras) == 0) return(NA_character_)
  if(length(palabras) == 1) return(toupper(palabras[1]))

  modelo <- paste(palabras[1:min(2, length(palabras))], collapse = " ")
  modelo <- toupper(modelo)
  modelo
}

calcular_vif <- function(modelo_ols){
  mm <- model.matrix(modelo_ols)
  mm <- as.data.frame(mm)
  if("(Intercept)" %in% names(mm)) mm[["(Intercept)"]] <- NULL
  if(ncol(mm) < 2){
    return(data.frame(Variable = names(mm), VIF = NA_real_))
  }
  vif_vals <- car::vif(modelo_ols)
  if(is.matrix(vif_vals)) vif_vals <- vif_vals[, 1]
  data.frame(Variable = names(vif_vals), VIF = as.numeric(vif_vals))
}

imprimir_resumen_modelo <- function(modelo_ols, robust_type = "HC1"){
  cat("\n--- Resumen OLS clásico ---\n")
  print(summary(modelo_ols))

  cat(paste0("\n--- Coeficientes con errores robustos ", robust_type, " ---\n"))
  tabla_robusta <- lmtest::coeftest(
    modelo_ols,
    vcov = sandwich::vcovHC(modelo_ols, type = robust_type)
  )
  print(tabla_robusta)

  invisible(tabla_robusta)
}

diagnosticos_modelo <- function(modelo_ols, nombre_modelo = "Modelo"){
  residuos <- resid(modelo_ols)
  pred <- fitted(modelo_ols)

  jb <- tryCatch(tseries::jarque.bera.test(residuos), error = function(e) NULL)

  muestra_shapiro <- residuos
  if(length(residuos) > 5000){
    set.seed(123)
    muestra_shapiro <- sample(residuos, 5000)
  }
  sh <- tryCatch(shapiro.test(muestra_shapiro), error = function(e) NULL)

  bp_p <- tryCatch(lmtest::bptest(modelo_ols)$p.value, error = function(e) NA_real_)
  white_p <- tryCatch(lmtest::bptest(modelo_ols, ~ fitted(modelo_ols) + I(fitted(modelo_ols)^2))$p.value,
                      error = function(e) NA_real_)
  reset_p <- tryCatch(lmtest::resettest(modelo_ols, power = 2:3, type = "fitted")$p.value,
                      error = function(e) NA_real_)

  vif_tabla <- tryCatch(calcular_vif(modelo_ols), error = function(e) data.frame(Variable = NA, VIF = NA))
  max_vif <- suppressWarnings(max(vif_tabla$VIF, na.rm = TRUE))
  if(is.infinite(max_vif)) max_vif <- NA_real_

  rmse <- sqrt(mean((model.response(model.frame(modelo_ols)) - pred)^2))
  mae  <- mean(abs(model.response(model.frame(modelo_ols)) - pred))

  cat("\n", strrep("=", 70), "\n", sep = "")
  cat("DIAGNÓSTICOS -", nombre_modelo, "\n")
  cat(strrep("=", 70), "\n", sep = "")

  cat("\nNormalidad\n")
  cat("Jarque-Bera p-value:", ifelse(is.null(jb), NA, round(jb$p.value, 6)), "\n")
  cat("Shapiro-Wilk p-value:", ifelse(is.null(sh), NA, round(sh$p.value, 6)), "\n")

  cat("\nHeterocedasticidad\n")
  cat("Breusch-Pagan p-value:", bp_p, "\n")
  cat("White p-value:", white_p, "\n")

  cat("\nEspecificación\n")
  cat("RESET Ramsey p-value:", reset_p, "\n")

  cat("\nMulticolinealidad (top 15 VIF)\n")
  print(head(vif_tabla[order(-vif_tabla$VIF), ], 15))

  cat("\nMétricas de ajuste\n")
  cat("R2:", round(summary(modelo_ols)$r.squared, 4), "\n")
  cat("R2 ajustado:", round(summary(modelo_ols)$adj.r.squared, 4), "\n")
  cat("AIC:", round(AIC(modelo_ols), 2), "\n")
  cat("BIC:", round(BIC(modelo_ols), 2), "\n")
  cat("RMSE log:", round(rmse, 4), "\n")
  cat("MAE  log:", round(mae, 4), "\n")

  list(
    jb_p = ifelse(is.null(jb), NA, jb$p.value),
    shapiro_p = ifelse(is.null(sh), NA, sh$p.value),
    bp_p = bp_p,
    white_p = white_p,
    reset_p = reset_p,
    max_vif = max_vif,
    rmse = rmse,
    mae = mae,
    vif_tabla = vif_tabla
  )
}

graficos_residuos <- function(modelo_ols, titulo = "Diagnóstico de residuos"){
  residuos <- resid(modelo_ols)
  pred <- fitted(modelo_ols)

  oldpar <- par(no.readonly = TRUE)
  on.exit(par(oldpar))

  par(mfrow = c(1, 3))

  hist(residuos, breaks = 30, main = paste("Histograma -", titulo), xlab = "Residuos")
  qqnorm(residuos, main = paste("QQ-plot -", titulo))
  qqline(residuos, col = 2)
  plot(pred, residuos,
       main = paste("Residuos vs Ajustados -", titulo),
       xlab = "Ajustados", ylab = "Residuos")
  abline(h = 0, col = 2, lty = 2)
}

eliminar_outliers_iterativo <- function(df_base, formula_modelo,
                                        max_iter = 20,
                                        max_pct_eliminar = 0.15,
                                        min_flags = 2,
                                        verbose = TRUE){

  df_work <- df_base
  n_inicial <- nrow(df_base)
  max_eliminar <- floor(n_inicial * max_pct_eliminar)
  historial <- data.frame()

  for(i in 1:max_iter){
    modelo <- lm(formula_modelo, data = df_work)

    cooks <- cooks.distance(modelo)
    rstud <- rstudent(modelo)
    hats <- hatvalues(modelo)

    umbral_cook <- 4 / nrow(df_work)
    umbral_hat <- 2 * length(coef(modelo)) / nrow(df_work)

    flags <- (abs(rstud) > 3) + (cooks > umbral_cook) + (hats > umbral_hat)
    idx_eliminar <- which(flags >= min_flags)

    if(verbose){
      cat("\nIteración", i,
          "- obs actuales:", nrow(df_work),
          "- candidatas a eliminar:", length(idx_eliminar), "\n")
    }

    historial <- bind_rows(
      historial,
      data.frame(
        iteracion = i,
        n_actual = nrow(df_work),
        n_eliminar = length(idx_eliminar),
        cook_umbral = umbral_cook,
        hat_umbral = umbral_hat
      )
    )

    if(length(idx_eliminar) == 0) break

    if((n_inicial - (nrow(df_work) - length(idx_eliminar))) > max_eliminar){
      if(verbose) cat("Se alcanzó el máximo % de eliminación permitido.\n")
      break
    }

    df_work <- df_work[-idx_eliminar, , drop = FALSE]
  }

  modelo_final <- lm(formula_modelo, data = df_work)

  list(
    df_final = df_work,
    modelo_final_ols = modelo_final,
    modelo_final_rob = lmtest::coeftest(modelo_final, vcov = sandwich::vcovHC(modelo_final, type = "HC1")),
    historial = historial
  )
}

BLOQUE 3. Limpieza de la base principal

Explicación

En este punto se limpian las variables cuantitativas principales del modelo: precio y kilometraje. Se convierten a formato numérico, se eliminan filas con datos faltantes en variables esenciales y se aplican filtros para evitar valores extremos o poco plausibles.

Luego se construyen LogPrecio y LogKm, ya que la especificación hedónica se trabaja en forma logarítmica.

Qué revisar

Conviene comprobar:

  • que Precio_num y Km_num no tengan demasiados NA,
  • que la reducción de observaciones sea razonable,
  • y que las variables logarítmicas se creen sin errores.

Código

###############################################################
# BLOQUE 3: LIMPIEZA BASE PRINCIPAL
###############################################################

df <- df %>%
  mutate(
    Precio_num = as.numeric(gsub("\\.", "", as.character(Precio))),
    Km_num = as.numeric(gsub("[^0-9]", "", as.character(Kilometraje)))
  )

df <- df %>%
  filter(!is.na(Precio_num), !is.na(Km_num), !is.na(Título), !is.na(Marca), !is.na(Comuna))

df <- df %>%
  filter(Precio_num >= 1000000, Km_num >= 1500)

p_km <- quantile(df$Km_num, probs = c(0.01, 0.99), na.rm = TRUE)
p_pr <- quantile(df$Precio_num, probs = c(0.01, 0.99), na.rm = TRUE)

df <- df %>%
  filter(
    between(Km_num, p_km[1], p_km[2]),
    between(Precio_num, p_pr[1], p_pr[2])
  ) %>%
  mutate(
    LogPrecio = log(Precio_num),
    LogKm = log(Km_num)
  )

BLOQUE 4. Construcción de variables de marca y modelo

Explicación

Aquí se preparan las variables de texto que luego se usarán para el cruce con la canasta. Primero se normalizan marca, título y comuna. Después se intenta extraer el modelo a partir del título del aviso. Finalmente, se construye una llave compuesta Marca_Modelo.

Esta etapa es fundamental porque el cruce con la canasta depende de que la base principal tenga una representación razonable del modelo del vehículo.

Qué revisar

Conviene revisar una muestra de Marca_Modelo para confirmar que la extracción desde el título sea coherente.

Código

###############################################################
# BLOQUE 4: VARIABLES DE MARCA / MODELO
###############################################################

df <- df %>%
  mutate(
    Marca_std = toupper(normalizar_texto(Marca)),
    Titulo_norm = normalizar_texto(Título),
    Comuna_norm = normalizar_texto(Comuna)
  )

df$Modelo_std <- mapply(extraer_modelo_titulo, df$Título, df$Marca_std)
df$Modelo_std <- toupper(df$Modelo_std)

df <- df %>%
  mutate(
    Marca_Modelo = paste(Marca_std, Modelo_std),
    Marca_Modelo = gsub("\\s+", " ", Marca_Modelo),
    Marca_Modelo = trimws(Marca_Modelo)
  )

BLOQUE 4.1. Preparación de la canasta y homologación

Explicación

Este bloque es uno de los más importantes del procedimiento. Aquí se limpia la columna MODELO de la canasta y se normalizan los nombres tanto en la canasta como en la base principal. Luego se aplica una tabla de homologación para corregir diferencias de escritura entre ambas fuentes.

Este paso es indispensable porque, en la práctica, los nombres rara vez coinciden de forma exacta. Por ejemplo, puede aparecer HYUNDAI GRAND I-10 en una fuente y HYUNDAI GRAND I10 en otra.

Qué revisar

Si el filtro por canasta posterior reconoce muy pocos modelos, casi siempre el problema está aquí. En ese caso, hay que ampliar o corregir la tabla de homologación.

Código

###############################################################
# BLOQUE 4.1: CANASTA REAL + HOMOLOGACIÓN
###############################################################

canasta <- canasta %>%
  mutate(
    MODELO_base = as.character(MODELO),
    MODELO_std = normalizar_modelo_catalogo(MODELO_base)
  ) %>%
  filter(!is.na(MODELO_std), MODELO_std != "") %>%
  distinct(MODELO_std, .keep_all = TRUE)

df <- df %>%
  mutate(
    Marca_Modelo_std = normalizar_modelo_catalogo(Marca_Modelo)
  )

homologacion <- c(
  "CHERY TIGGO 2" = "CHERY TIGGO 2",
  "CHERY TIGGO 3" = "CHERY TIGGO 3",
  "CHERY TIGGO 8" = "CHERY TIGGO 8",
  "CHEVROLET CAPTIVA" = "CHEVROLET CAPTIVA",
  "CHEVROLET GROOVE" = "CHEVROLET GROOVE",
  "CHEVROLET SAIL NB" = "CHEVROLET SAIL",
  "CHEVROLET SAIL" = "CHEVROLET SAIL",
  "CHEVROLET TRACKER" = "CHEVROLET TRACKER",
  "FORD EXPLORER" = "FORD EXPLORER",
  "FORD ECOSPORT" = "FORD ECOSPORT",
  "FORD NUEVA ECOSPORT" = "FORD ECOSPORT",
  "FORD TERRITORY" = "FORD TERRITORY",
  "HYUNDAI ACCENT" = "HYUNDAI ACCENT",
  "HYUNDAI GRAND I10" = "HYUNDAI GRAND I 10",
  "HYUNDAI GRAND I 10" = "HYUNDAI GRAND I 10",
  "KIA MORNING" = "KIA MORNING",
  "KIA RIO 4" = "KIA RIO 4",
  "KIA SOLUTO" = "KIA SOLUTO",
  "MAZDA CX30" = "MAZDA CX 30",
  "MAZDA CX 30" = "MAZDA CX 30",
  "MAZDA NEW MAZDA2" = "MAZDA 2",
  "MAZDA 2" = "MAZDA 2",
  "MG ZX" = "MG ZX",
  "MG ZS" = "MG ZS",
  "NISSAN KICKS" = "NISSAN KICKS",
  "NISSAN MARCH" = "NISSAN MARCH",
  "NISSAN QASHQAI" = "NISSAN QASHQAI",
  "NISSAN VERSA" = "NISSAN VERSA",
  "PEUGEOT 301" = "PEUGEOT 301",
  "SUZUKI BALENO" = "SUZUKI BALENO",
  "SUZUKI SWIFT" = "SUZUKI SWIFT",
  "TOYOTA COROLLA CROSS" = "TOYOTA COROLLA CROSS",
  "TOYOTA RAIZE" = "TOYOTA RAIZE",
  "TOYOTA RAV4" = "TOYOTA RAV4",
  "TOYOTA YARIS" = "TOYOTA YARIS",
  "VOLKSWAGEN GOL" = "VOLKSWAGEN GOL",
  "VOLKSWAGEN NUEVO GOL" = "VOLKSWAGEN GOL",
  "VOLKSWAGEN POLO" = "VOLKSWAGEN POLO",
  "VOLKSWAGEN T CROSS" = "VOLKSWAGEN T CROSS",
  "VOLKSWAGEN T-CROSS" = "VOLKSWAGEN T CROSS"
)

df <- df %>%
  mutate(
    Marca_Modelo_homologado = ifelse(
      Marca_Modelo_std %in% names(homologacion),
      homologacion[Marca_Modelo_std],
      Marca_Modelo_std
    )
  )

canasta <- canasta %>%
  mutate(
    MODELO_homologado = ifelse(
      MODELO_std %in% names(homologacion),
      homologacion[MODELO_std],
      MODELO_std
    )
  )

BLOQUE 5. Filtro por canasta

Explicación

Una vez homologados los nombres, se filtra la base principal y se conservan únicamente las observaciones cuyo modelo pertenezca a la canasta.

Este paso define el universo efectivo de estimación. A partir de aquí, el modelo ya no se estima sobre todos los autos usados disponibles, sino sobre los autos relevantes para el IPC.

Qué revisar

Es importante mirar:

  • cuántas observaciones sobreviven al filtro,
  • cuántos modelos de la canasta aparecen realmente en la base,
  • y cuáles modelos de la canasta no fueron encontrados.

Código

###############################################################
# BLOQUE 5: FILTRO POR CANASTA
###############################################################

modelos_canasta <- unique(canasta$MODELO_homologado)

df_canasta <- df %>%
  filter(Marca_Modelo_homologado %in% modelos_canasta)

cat("\n", strrep("=", 70), "\n", sep = "")
cat("FILTRO POR CANASTA\n")
cat(strrep("=", 70), "\n", sep = "")
cat("Observaciones base total   :", nrow(df), "\n")
cat("Observaciones base canasta :", nrow(df_canasta), "\n")
cat("Modelos encontrados        :", length(unique(df_canasta$Marca_Modelo_homologado)), "\n")

cat("\nFrecuencia por modelo encontrado:\n")
print(sort(table(df_canasta$Marca_Modelo_homologado), decreasing = TRUE))

modelos_no_encontrados <- setdiff(modelos_canasta, unique(df_canasta$Marca_Modelo_homologado))
cat("\nModelos de canasta no encontrados:\n")
print(modelos_no_encontrados)

BLOQUE 6. Construcción de zonas

Explicación

En este bloque se asigna una zona geográfica a cada observación según su comuna. La variable Zona se utilizará como control espacial en las especificaciones hedónicas.

La agrupación por zonas permite capturar diferencias sistemáticas de precios asociadas a localización sin sobrecargar el modelo con demasiadas categorías geográficas.

Qué revisar

Hay que verificar si quedan comunas sin asignación. Si ocurre, el mapa de comunas debe ampliarse.

Código

###############################################################
# BLOQUE 6: ZONAS
###############################################################

mapa_comuna_zona <- c(
  "las condes" = "Oriente",
  "vitacura" = "Oriente",
  "providencia" = "Oriente",
  "lo barnechea" = "Oriente",
  "la reina" = "Oriente",
  "nunoa" = "Oriente",
  "penalolen" = "Oriente",
  "macul" = "Oriente",
  "la florida" = "Oriente",
  "puente alto" = "Oriente",
  "san jose de maipo" = "Oriente",
  "colina" = "Norte",
  "lampa" = "Norte",
  "renca" = "Norte",
  "quilicura" = "Norte",
  "conchali" = "Norte",
  "huechuraba" = "Norte",
  "independencia" = "Norte",
  "recoleta" = "Norte",
  "san bernardo" = "Sur",
  "el bosque" = "Sur",
  "la cisterna" = "Sur",
  "la granja" = "Sur",
  "san ramon" = "Sur",
  "la pintana" = "Sur",
  "calera de tango" = "Sur",
  "buin" = "Sur",
  "paine" = "Sur",
  "padre hurtado" = "Sur",
  "penaflor" = "Sur",
  "talagante" = "Sur",
  "curacavi" = "Sur",
  "melipilla" = "Sur",
  "cerro navia" = "Poniente",
  "estacion central" = "Poniente",
  "maipu" = "Poniente",
  "pudahuel" = "Poniente",
  "quinta normal" = "Poniente",
  "cerrillos" = "Poniente",
  "santiago" = "Poniente",
  "san joaquin" = "Poniente",
  "san miguel" = "Poniente",
  "pedro aguirre cerda" = "Poniente"
)

df_canasta$Zona <- unname(mapa_comuna_zona[df_canasta$Comuna_norm])
df_canasta <- df_canasta %>% filter(!is.na(Zona))

BLOQUE 7. Filtro de representatividad mínima

Explicación

La especificación con dummies por modelo es mucho más exigente que las demás. Si algunos modelos tienen muy pocas observaciones, el modelo puede volverse inestable y producir problemas de leverage o errores robustos poco confiables.

Por eso se define un umbral mínimo de observaciones por modelo antes de estimar la especificación C.

Qué revisar

Si este umbral es demasiado bajo, la estimación puede presentar problemas numéricos. Si es demasiado alto, la muestra puede reducirse demasiado. Hay que buscar un equilibrio.

Código

###############################################################
# BLOQUE 7: FILTRO DE REPRESENTATIVIDAD PARA MODELO C
###############################################################

MIN_OBS_MODELO <- 12

freq_modelo <- sort(table(df_canasta$Marca_Modelo_homologado), decreasing = TRUE)
modelos_validos <- names(freq_modelo[freq_modelo >= MIN_OBS_MODELO])

df_modelo <- df_canasta %>%
  filter(Marca_Modelo_homologado %in% modelos_validos)

cat("\nModelos válidos canasta (>= ", MIN_OBS_MODELO, " obs): ",
    length(modelos_validos), "\n", sep = "")
cat("Observaciones para especificación C:", nrow(df_modelo), "\n")

BLOQUE 8. Estimación de especificaciones

Explicación

Aquí se estiman tres modelos hedónicos alternativos:

  • Especificación A: usa solo kilometraje y zona.
  • Especificación B: agrega marca.
  • Especificación C: agrega modelo homologado.

Las especificaciones A y B se estiman sobre la base filtrada por canasta. La especificación C se estima sobre una versión más restringida, que además exige representatividad mínima por modelo.

Qué revisar

Conviene comparar:

  • ajuste del modelo,
  • significancia,
  • diagnósticos,
  • estabilidad numérica,
  • y sentido económico de los coeficientes.

Código

###############################################################
# BLOQUE 8: ESPECIFICACIONES
###############################################################

formula_A <- LogPrecio ~ LogKm + factor(Zona)
formula_B <- LogPrecio ~ LogKm + factor(Zona) + factor(Marca_std)
formula_C <- LogPrecio ~ LogKm + factor(Zona) + factor(Marca_Modelo_homologado)

resultados <- list()

for(esp in c("A", "B", "C")){

  cat("\n", strrep("#", 80), "\n", sep = "")
  cat("ESTIMANDO MODELO HEDÓNICO — ESPECIFICACIÓN", esp, "\n")
  cat(strrep("#", 80), "\n", sep = "")

  if(esp == "A"){
    base_uso <- df_canasta
    formula_modelo <- formula_A
  } else if(esp == "B"){
    base_uso <- df_canasta
    formula_modelo <- formula_B
  } else {
    base_uso <- df_modelo
    formula_modelo <- formula_C
  }

  modelo_ols <- lm(formula_modelo, data = base_uso)

  tabla_robusta <- imprimir_resumen_modelo(modelo_ols, robust_type = "HC1")
  diag <- diagnosticos_modelo(modelo_ols, nombre_modelo = paste("Especificación", esp))
  graficos_residuos(modelo_ols, titulo = paste("Especificación", esp))

  resultados[[esp]] <- list(
    df = base_uso,
    ols = modelo_ols,
    robusta = tabla_robusta,
    diag = diag
  )
}

BLOQUE 9. Cuadro comparativo

Explicación

Este bloque resume las métricas principales de las tres especificaciones para facilitar la comparación. Se reportan tamaño muestral, ajuste, criterios de información y pruebas diagnósticas.

Qué revisar

No conviene elegir el modelo solo por R². También hay que mirar RESET, heterocedasticidad, VIF y estabilidad general.

Código

###############################################################
# BLOQUE 9: RESUMEN COMPARATIVO
###############################################################

cat("\n", strrep("=", 70), "\n", sep = "")
cat("CUADRO COMPARATIVO — ESPECIFICACIONES A, B, C\n")
cat(strrep("=", 70), "\n", sep = "")
cat(sprintf("%-25s %10s %10s %10s\n", "", "A", "B", "C"))
cat(strrep("-", 60), "\n", sep = "")

imprimir_fila <- function(nombre, f){
  vals <- sapply(c("A", "B", "C"), f)
  cat(sprintf("%-25s %10s %10s %10s\n", nombre, vals[1], vals[2], vals[3]))
}

imprimir_fila("N observaciones", function(m) as.character(nrow(resultados[[m]]$df)))
imprimir_fila("R² ajustado", function(m) sprintf("%.4f", summary(resultados[[m]]$ols)$adj.r.squared))
imprimir_fila("AIC", function(m) sprintf("%.1f", AIC(resultados[[m]]$ols)))
imprimir_fila("BIC", function(m) sprintf("%.1f", BIC(resultados[[m]]$ols)))
imprimir_fila("RMSE (log)", function(m) sprintf("%.4f", resultados[[m]]$diag$rmse))
imprimir_fila("RESET p", function(m) sprintf("%.4f", resultados[[m]]$diag$reset_p))
imprimir_fila("Breusch-Pagan p", function(m) sprintf("%.4f", resultados[[m]]$diag$bp_p))
imprimir_fila("White p", function(m) sprintf("%.4f", resultados[[m]]$diag$white_p))
imprimir_fila("Max VIF", function(m) sprintf("%.2f", resultados[[m]]$diag$max_vif))

BLOQUE 10. Depuración de observaciones influyentes

Explicación

Este bloque elimina observaciones problemáticas de manera iterativa usando criterios de influencia. Luego vuelve a revisar la representatividad de los modelos después de la depuración, ya que algunos pueden quedar con muy pocas observaciones.

Qué revisar

Hay que observar:

  • cuántas observaciones se eliminan,
  • si el porcentaje eliminado sigue siendo razonable,
  • y si algunos modelos quedan subrepresentados después de la depuración.

Código

###############################################################
# BLOQUE 10: DEPURACIÓN DE OUTLIERS
###############################################################

USAR_DEPURACION <- TRUE

if(USAR_DEPURACION){
  dep <- eliminar_outliers_iterativo(
    df_base = df_modelo,
    formula_modelo = formula_C,
    max_iter = 25,
    max_pct_eliminar = 0.15,
    min_flags = 2,
    verbose = TRUE
  )

  df_final_dep <- dep$df_final
  modelo_final_ols <- dep$modelo_final_ols
  tabla_final_rob <- dep$modelo_final_rob
  historial_dep <- dep$historial

  freq_post_dep <- sort(table(df_final_dep$Marca_Modelo_homologado), decreasing = FALSE)
  print(freq_post_dep)

  MIN_OBS_FINAL <- 10
  modelos_validos_final <- names(freq_post_dep[freq_post_dep >= MIN_OBS_FINAL])

  df_final_dep <- df_final_dep %>%
    filter(Marca_Modelo_homologado %in% modelos_validos_final)

  modelo_final_ols <- lm(formula_C, data = df_final_dep)
  tabla_final_rob <- lmtest::coeftest(
    modelo_final_ols,
    vcov = sandwich::vcovHC(modelo_final_ols, type = "HC1")
  )

} else {
  df_final_dep <- df_modelo
  modelo_final_ols <- lm(formula_C, data = df_final_dep)
  tabla_final_rob <- lmtest::coeftest(
    modelo_final_ols,
    vcov = sandwich::vcovHC(modelo_final_ols, type = "HC1")
  )
}

BLOQUE FINAL. Modelo elegido

Explicación

Aquí se presenta la estimación final elegida, junto con sus errores robustos, diagnósticos y distribución final por modelo.

Además, se listan los modelos de la canasta que no fueron encontrados en la base, lo que permite documentar la cobertura efectiva del ejercicio.

Qué revisar

Hay que verificar que el modelo final mantenga:

  • una muestra suficiente,
  • diagnósticos razonables,
  • cobertura aceptable de la canasta,
  • y estabilidad en los errores robustos.

Código

###############################################################
# BLOQUE FINAL: MODELO ELEGIDO
###############################################################

cat("\n", strrep("=", 80), "\n", sep = "")
cat("MODELO FINAL ELEGIDO — ESPECIFICACIÓN C DEPURADA\n")
cat(strrep("=", 80), "\n", sep = "")

print(summary(modelo_final_ols))
print(tabla_final_rob)

diag_final <- diagnosticos_modelo(modelo_final_ols, nombre_modelo = "Modelo final elegido C")
graficos_residuos(modelo_final_ols, titulo = "Modelo final elegido C")

cat("\nDistribución final por modelo:\n")
print(head(sort(table(df_final_dep$Marca_Modelo_homologado), decreasing = TRUE), 30))

cat("\nModelos de canasta no encontrados:\n")
print(setdiff(unique(canasta$MODELO_homologado), unique(df$Marca_Modelo_homologado)))

Conclusión metodológica

La adaptación del modelo hedónico a la canasta de autos usados requiere una etapa explícita de homologación entre la nomenclatura de la base de avisos y la nomenclatura de la canasta oficial. Ese es el corazón del procedimiento. Una vez resuelto ese punto, el resto del trabajo consiste en asegurar limpieza, representatividad y estabilidad econométrica.

Este enfoque permite estimar un modelo más defendible para fines de ajuste de calidad en el IPC, ya que se centra en los bienes efectivamente relevantes para la medición y no en el universo general de autos usados.