Assignment 5: House Price Prediction, Chicago

MUSA 5080 · Team assignment

Team Mahjong: predicting Chicago single-family sale prices, 2023–2025, with spatial features and an equity check.
Author

Team Mahjong: Chenyan Zhao, Peirong Luo, Terry Xu, Xuanyu Zhou

Published

October 30, 2026

Part 0: Setup

Show code
library(tidyverse)
library(tidycensus)
library(sf)
library(caret)
library(spdep)
library(nngeo)
library(gt)
options(scipen = 999, timeout = 300, tigris_use_cache = TRUE)
theme_set(theme_minimal(base_size = 12))
knitr::opts_chunk$set(fig.width = 8, fig.height = 5, dpi = 160)
dollars <- scales::label_dollar(accuracy = 1)
dir.create("data/assignment5/figures", recursive = TRUE, showWarnings = FALSE)
save_plot <- function(plot, name, width = 8, height = 5) {
  ggsave(file.path("data/assignment5/figures", paste0(name, ".png")),
         plot, width = width, height = height, dpi = 160, bg = "white")
  plot
}

Part 1: Clean and explore

Market-sale sample

I exclude transfers at or below $10,000, multiple-property sales, and records without a valid building area or location. A multiple-property transaction does not identify the price of one home, following the Cook County Assessor’s sales criteria. There is no upper-price cutoff. Missing predictors are filled using training-fold medians.

Show code
suppressPackageStartupMessages({
  library(tidyverse)
  library(sf)
  library(tidycensus)
  library(nngeo)
})
options(scipen = 999, timeout = 300, tigris_use_cache = TRUE)
data_dir <- "data/assignment5"
dir.create(data_dir, recursive = TRUE, showWarnings = FALSE)

raw_train <- read_csv("data/assignment5/chicago_train.csv", show_col_types = FALSE)
raw_test <- read_csv("data/assignment5/chicago_test.csv", show_col_types = FALSE)
all_sales <- bind_rows(mutate(raw_train, split = "train"),
                      mutate(raw_test, split = "test")) %>%
  mutate(sale_date = mdy(sale_date),
         location_id = paste(lat, lon, sep = "/"),
         exclusion = case_when(
           split == "test" ~ "Test",
           !is.finite(sale_price) | sale_price <= 10000 ~ "Price at or below $10,000",
           is_multisale == 1 ~ "Multiple-property sale",
           !is.finite(building_000sf) | building_000sf <= 0 ~ "Invalid building area",
           !is.finite(lat) | !is.finite(lon) ~ "Missing coordinates",
           TRUE ~ "Retained"
         )) %>%
  st_as_sf(coords = c("lon", "lat"), crs = 4326, remove = FALSE) %>%
  st_transform(3435)

cleaning_log <- tibble(
  rule = c("Price at or below $10,000", "Multiple-property sale",
           "Invalid building area", "Missing coordinates"),
  reason = c("Remove nominal transfers", "Price covers more than one property",
             "Require a positive residential building area", "Require a location")
) %>%
  left_join(st_drop_geometry(all_sales) %>% count(exclusion),
            by = c("rule" = "exclusion")) %>%
  mutate(removed = replace_na(n, 0L),
         remaining = nrow(raw_train) - cumsum(removed)) %>% select(-n)

# Save fixed external responses so subsequent renders use the same inputs.
ward_file <- file.path(data_dir, "wards_2023.rds")
if (!file.exists(ward_file)) {
  wards <- st_read(paste0("https://data.cityofchicago.org/resource/",
                          "p293-wvbd.geojson?$limit=100"), quiet = TRUE)
  saveRDS(wards, ward_file)
}
wards <- readRDS(ward_file) %>% st_transform(3435) %>%
  select(ward) %>% mutate(ward = as.integer(ward)) %>% arrange(ward)

income_file <- file.path(data_dir, "acs_2023_tracts.rds")
if (!file.exists(income_file)) {
  tracts <- get_acs(geography = "tract", state = "IL", county = "Cook",
                    variables = c(income = "B19013_001"), year = 2023,
                    survey = "acs5", geometry = TRUE, output = "wide")
  saveRDS(tracts, income_file)
}
tracts <- readRDS(income_file) %>% st_transform(3435) %>%
  mutate(incomeE = if_else(incomeE > 0, incomeE, NA_real_))

# One polygon per sale, including small coordinate/boundary discrepancies.
ward_matches <- st_intersects(all_sales, wards)
ward_index <- vapply(ward_matches, function(x) if (length(x)) x[1] else NA_integer_, 1L)
missing_ward <- is.na(ward_index)
ward_index[missing_ward] <- st_nearest_feature(all_sales[missing_ward, ], wards)
all_sales$ward <- wards$ward[ward_index]
all_sales$ward_gap_ft <- 0
all_sales$ward_gap_ft[missing_ward] <- as.numeric(st_distance(
  all_sales[missing_ward, ], wards[ward_index[missing_ward], ], by_element = TRUE))

tract_matches <- st_intersects(all_sales, tracts)
tract_index <- vapply(tract_matches, function(x) if (length(x)) x[1] else NA_integer_, 1L)
all_sales$GEOID <- tracts$GEOID[tract_index]
all_sales$income <- tracts$incomeE[tract_index]
all_sales$income_moe <- tracts$incomeM[tract_index]

# Merge sparse wards using geography and training counts, without sale prices.
ward_counts <- all_sales %>% filter(exclusion == "Retained") %>%
  st_drop_geometry() %>% count(ward)
common_wards <- ward_counts %>% filter(n >= 80) %>% pull(ward)
centers <- st_point_on_surface(wards)
ward_map <- tibble(ward = wards$ward, model_ward = wards$ward)
rare <- !ward_map$ward %in% common_wards
ward_map$model_ward[rare] <- common_wards[st_nearest_feature(
  centers[rare, ], centers[match(common_wards, wards$ward), ])]
all_sales$model_ward <- factor(ward_map$model_ward[match(all_sales$ward, ward_map$ward)])

# Distances are measured in miles from a fixed downtown reference point.
downtown <- st_sfc(st_point(c(-87.6319, 41.8838)), crs = 4326) %>% st_transform(3435)
xy <- st_coordinates(all_sales)
origin <- st_coordinates(downtown)[1, ]
all_sales <- all_sales %>% mutate(
  east_mi = (xy[, 1] - origin[1]) / 5280,
  north_mi = (xy[, 2] - origin[2]) / 5280,
  downtown_mi = as.numeric(st_distance(all_sales, downtown)) / 5280,
  year_f = factor(year), quarter_f = factor(sale_qrt),
  crime_window_end = as.Date(floor_date(sale_date - days(7), "quarter")),
  crime_window_start = crime_window_end %m-% months(3),
  crime_unavailable = as.integer(crime_window_start < as.Date("2023-01-01")),
  robbery_660ft = NA_real_, robbery_knn5_mi = NA_real_
)

crime_file <- file.path(data_dir, "robbery_2023_2025.csv")
if (!file.exists(crime_file)) {
  query <- list(
    "$where" = paste("date >= '2023-01-01T00:00:00'",
                     "AND date < '2025-10-01T00:00:00' AND primary_type = 'ROBBERY'"),
    "$select" = "id,date,latitude,longitude,updated_on",
    "$order" = "date,id", "$limit" = "100000")
  response <- httr::GET("https://data.cityofchicago.org/resource/ijzp-q8t2.csv",
                         query = query, httr::timeout(300))
  httr::stop_for_status(response)
  writeBin(httr::content(response, as = "raw"), crime_file)
  writeLines(response$url, file.path(data_dir, "robbery_query.txt"))
}
crime_raw <- read_csv(crime_file, show_col_types = FALSE)
robbery <- crime_raw %>% filter(is.finite(latitude), is.finite(longitude)) %>%
  mutate(event_date = as.Date(date)) %>%
  st_as_sf(coords = c("longitude", "latitude"), crs = 4326) %>% st_transform(3435)
crime_coverage <- tibble()
for (end_text in as.character(sort(unique(all_sales$crime_window_end[all_sales$crime_unavailable == 0])))) {
  end <- as.Date(end_text)
  start <- end %m-% months(3)
  rows <- which(all_sales$crime_window_end == end & all_sales$crime_unavailable == 0)
  incidents <- robbery %>% filter(event_date >= start, event_date < end)
  all_sales$robbery_660ft[rows] <- lengths(st_intersects(
    st_buffer(all_sales[rows, ], 660), incidents))
  nearest <- nngeo::st_nn(all_sales[rows, ], incidents, k = 5,
                          returnDist = TRUE, progress = FALSE)
  all_sales$robbery_knn5_mi[rows] <- vapply(nearest$dist, mean, numeric(1)) / 5280
  crime_coverage <- bind_rows(crime_coverage, tibble(
    start = start, end = end, incidents = nrow(incidents), houses = length(rows)))
}

train_raw <- all_sales %>% filter(exclusion == "Retained")
test_raw <- all_sales %>% filter(split == "test")
stopifnot(nrow(all_sales) == nrow(raw_train) + nrow(raw_test),
          nrow(test_raw) == nrow(raw_test), !anyDuplicated(all_sales$id),
          all(all_sales$crime_window_end <= all_sales$sale_date - 7))
saveRDS(list(all_sales = all_sales, train_raw = train_raw, test_raw = test_raw,
             wards = wards, tracts = tracts, cleaning_log = cleaning_log,
             ward_map = ward_map, ward_counts = ward_counts,
             crime_coverage = crime_coverage,
             crime_missing_coordinates = sum(is.na(crime_raw$latitude)),
             crime_records = nrow(crime_raw)), file.path(data_dir, "features.rds"))
write_csv(cleaning_log, file.path(data_dir, "cleaning_log.csv"))
write_csv(crime_coverage, file.path(data_dir, "crime_coverage.csv"))
cat("Feature rows:", nrow(all_sales), "Training:", nrow(train_raw), "Test:", nrow(test_raw), "\n")
cat("Ward matches repaired:", sum(missing_ward), "Max gap (ft):", max(all_sales$ward_gap_ft), "\n")
cat("Missing income:", sum(is.na(train_raw$income)), "\n")
Show code
cleaning_log %>%
  gt() %>% cols_label(rule = "Rule", reason = "Reason", removed = "Removed",
                     remaining = "Remaining") %>%
  fmt_number(columns = c(removed, remaining), decimals = 0)
Rule Reason Removed Remaining
Price at or below $10,000 Remove nominal transfers 0 23,200
Multiple-property sale Price covers more than one property 1,232 21,968
Invalid building area Require a positive residential building area 0 21,968
Missing coordinates Require a location 0 21,968

The analysis retains 21,968 training sales. All 5,789 test rows remain. Of the retained sales, 586 are below $50,000 and 367 exceed $2 million.

Exploration

Show code
p_price <- ggplot(train_raw, aes(sale_price)) +
  geom_histogram(bins = 45, fill = "#2c7fb8", color = "white") +
  scale_x_log10(labels = scales::label_dollar()) +
  labs(title = "Sale prices have a long upper tail", x = "Sale price (log scale)",
       y = "Sales")
save_plot(p_price, "price_distribution")

Histogram of sale prices with a logarithmic dollar axis, showing the long upper tail.

Show code
p_map <- ggplot() +
  geom_sf(data = wards, fill = "#f7f7f7", color = "#cccccc", linewidth = 0.15) +
  geom_sf(data = train_raw, aes(color = sale_price), size = 0.35, alpha = 0.7) +
  scale_color_viridis_c(trans = "log10", labels = scales::label_dollar(),
                        name = "Sale price", option = "C") +
  coord_sf(datum = NA) + labs(title = "Price varies sharply across Chicago") +
  theme(panel.grid = element_blank())
save_plot(p_map, "price_map", height = 7)

Map of Chicago sales colored by sale price. Higher prices cluster north of downtown and near the lake.

Show code
p_size <- ggplot(train_raw, aes(building_000sf, sale_price)) +
  geom_point(alpha = 0.18, size = 0.7, color = "#2c7fb8") +
  geom_smooth(method = "lm", formula = y ~ x + I(x^2), se = FALSE,
              color = "#b35806") +
  scale_y_continuous(labels = scales::label_dollar(scale = 1e-6, suffix = "m")) +
  labs(title = "Larger houses have a wider range of prices",
       x = "Building area (1,000 square feet)", y = "Sale price")
save_plot(p_size, "size_price")

Scatterplot of building area and sale price with a quadratic fitted curve.

Show code
age_summary <- train_raw %>% st_drop_geometry() %>%
  mutate(age_bin = floor(building_age / 10) * 10) %>%
  group_by(age_bin) %>% summarize(n = n(), median_price = median(sale_price),
                                 .groups = "drop")
p_age <- ggplot(age_summary, aes(age_bin, median_price)) +
  geom_line(color = "#2c7fb8") + geom_point(aes(size = n), color = "#2c7fb8") +
  scale_y_continuous(labels = scales::label_dollar()) +
  labs(title = "Price does not decline uniformly with age",
       x = "Building age (start of 10-year band)", y = "Median sale price", size = "Sales")
save_plot(p_age, "age_price")

Median sale price by decade of building age, showing a nonlinear age pattern.

The price map motivates location effects. The size plot suggests a nonlinear area term and different area slopes across locations. The age pattern supports a quadratic term. I keep the expensive houses because they are part of the prediction task; their large dollar errors will have a strong influence on RMSE.

Part 2: Build the model

Features

Feature Construction
Structure Building area, age, lot size, bedrooms, bathrooms, fireplaces, garage, air conditioning, stories, basement, exterior, and furnace
Transformations Log lot area; squared building area and age
Time Year and calendar-quarter indicators
Broad location Distance to the downtown reference point, its square, and east/north coordinates in miles
Local location Spatial join to 2023 wards; building area × ward interactions
Robbery count Log of one plus incidents within 660 feet during the previous complete quarter
Robbery distance Mean distance to the five nearest incidents in that quarter, also tested as log1p(distance)
Final interaction Building area × building age

The 2023 ward boundaries define fixed geographic zones. Wards with fewer than 80 retained training sales are merged with the nearest adequately represented ward: 34 → 27, 42 → 2, and 46 → 44. The 31 points outside a ward polygon use the nearest ward; the largest gap is 525 feet.

The Chicago robbery records cover January 2023 through September 2025. Each house uses a complete preceding quarter ending at least seven days before its sale date. Early-2023 houses have no permitted earlier window, so both crime features are filled from the training fold and flagged as unavailable. Of 24,723 downloaded events, 121 lack coordinates. The portal supplies a current extract of historical events, not archived releases.

Cross-validation

I use ten fixed folds, with seed 5080. Sales at identical coordinates stay in the same fold because the files do not contain parcel IDs. Every candidate uses the same retained rows and folds. Medians are learned inside each training fold, then applied to its held-out rows. The log reports mean fold RMSE and Moran’s I of out-of-fold residuals using five nearest sales and row-standardized weights.

Show code
suppressPackageStartupMessages({
  library(tidyverse)
  library(sf)
  library(caret)
  library(spdep)
})
features <- readRDS("data/assignment5/features.rds")
train_raw <- features$train_raw
test_raw <- features$test_raw

impute_vars <- c("lotsize_acre", "num_half_baths", "num_fireplaces", "garage_size",
                 "full_basement", "warmairfurnace", "robbery_660ft", "robbery_knn5_mi")
learn_fill <- function(data) {
  vapply(st_drop_geometry(data)[impute_vars], median, numeric(1), na.rm = TRUE)
}
prepare <- function(data, fill) {
  data <- st_drop_geometry(data)
  for (v in names(fill)) data[[v]][is.na(data[[v]])] <- fill[[v]]
  data %>% mutate(age_decade = building_age / 10,
                 log_lot = log(lotsize_acre),
                 log_size = log(building_000sf),
                 log_robbery = log1p(robbery_660ft),
                 log_knn5 = log1p(robbery_knn5_mi))
}

# Keep every record at an identical coordinate in the same validation fold.
set.seed(5080)
locations <- unique(train_raw$location_id)
location_folds <- createFolds(seq_along(locations), k = 10, returnTrain = FALSE)
location_fold <- integer(length(locations))
for (k in seq_along(location_folds)) location_fold[location_folds[[k]]] <- k
fold_id <- location_fold[match(train_raw$location_id, locations)]
fold_data <- lapply(1:10, function(k) {
  fill <- learn_fill(train_raw[fold_id != k, ])
  list(analysis = prepare(train_raw[fold_id != k, ], fill),
       assessment = prepare(train_raw[fold_id == k, ], fill),
       rows = which(fold_id == k), fill = fill)
})

# Moran's I uses five neighboring sales, including co-located sales, but never self.
xy <- st_coordinates(train_raw)
nn <- nngeo::st_nn(train_raw, train_raw, k = 6, progress = FALSE)
neighbor_matrix <- t(vapply(seq_along(nn), function(i) {
  as.integer(head(setdiff(nn[[i]], i), 5))
}, integer(5)))
knn <- list(nn = neighbor_matrix, np = nrow(xy), k = 5, dimension = 2, x = xy)
class(knn) <- "knn"
weights <- nb2listw(knn2nb(knn), style = "W")
moran_i <- function(e) unname(moran(e, weights, length(e), Szero(weights))$I)

f1 <- sale_price ~ building_000sf + age_decade + lotsize_acre + num_bedrooms +
  num_full_baths + num_half_baths + num_fireplaces + garage_size + central_air +
  one_story + full_basement + finished_basement + brick_stone_ext + warmairfurnace
f2 <- update(f1, . ~ . - lotsize_acre + log_lot + I(building_000sf^2) + I(age_decade^2))
f3 <- update(f2, . ~ . + year_f + quarter_f)
f4 <- update(f3, . ~ . + downtown_mi + I(downtown_mi^2) + east_mi + north_mi)
f5 <- update(f4, . ~ . + model_ward)
f6 <- update(f5, . ~ . + building_000sf:model_ward)
f7 <- update(f6, . ~ . + log_robbery + crime_unavailable)
f8 <- update(f7, . ~ . + robbery_knn5_mi)
f9 <- update(f8, . ~ . - robbery_knn5_mi + log_knn5)
f10 <- update(f9, . ~ . + building_000sf:age_decade)
formulas <- list(m1 = f1, m2 = f2, m3 = f3, m4 = f4, m5 = f5,
                 m6 = f6, m7 = f7, m8 = f8, m9 = f9, m10 = f10)
added <- c("Structure only", "Log lot size; size and age quadratics",
           "Year and quarter", "Downtown distance and coordinates",
           "Ward fixed effects", "Building area by ward",
           "Log robbery count within 660 ft", "Mean distance to five robberies",
           "Log-transform robbery distance", "Building area by age")
notes <- c("Establish a house-characteristic baseline.",
           "Allow size and age slopes to vary; reduce lot-size skew.",
           "Capture changes in the market and seasonality.",
           "Represent broad geographic price gradients.",
           "Capture local differences beyond downtown distance.",
           "Allow the value of additional floor space to vary by location.",
           "Measure nearby incidents in the preceding available quarter.",
           "Distinguish locations with no incidents inside the buffer.",
           "Test diminishing effects of additional separation.",
           "Allow the area slope to differ between newer and older houses.")

model_log <- tibble()
oof <- list()
fold_scores <- tibble()
for (j in seq_along(formulas)) {
  name <- names(formulas)[j]
  prediction <- rep(NA_real_, nrow(train_raw))
  scores <- numeric(10)
  for (k in 1:10) {
    fold <- fold_data[[k]]
    fit <- lm(formulas[[j]], data = fold$analysis, na.action = na.fail)
    stopifnot(fit$rank == length(coef(fit)))
    prediction[fold$rows] <- predict(fit, newdata = fold$assessment)
    scores[k] <- sqrt(mean((fold$assessment$sale_price - prediction[fold$rows])^2))
  }
  stopifnot(all(is.finite(prediction)))
  oof[[name]] <- prediction
  model_log <- bind_rows(model_log, tibble(
    model = name, added = added[j], cv_rmse = mean(scores),
    moran_i = moran_i(train_raw$sale_price - prediction), notes = notes[j]))
  fold_scores <- bind_rows(fold_scores, tibble(model = name, fold = 1:10, rmse = scores))
  cat(name, "CV RMSE:", round(mean(scores), 2), "Moran I:", round(tail(model_log$moran_i, 1), 4), "\n")
}
final_name <- model_log$model[which.min(model_log$cv_rmse)]
f_final <- formulas[[final_name]]
fill_final <- learn_fill(train_raw)
train <- prepare(train_raw, fill_final)
test <- prepare(test_raw, fill_final)
m_final <- lm(f_final, data = train, na.action = na.fail)
predictor_vars <- all.vars(delete.response(terms(f_final)))
stopifnot(all(complete.cases(test[predictor_vars])), m_final$rank == length(coef(m_final)))
preds <- tibble(id = test$id, pred = as.numeric(predict(m_final, newdata = test)))
stopifnot(identical(preds$id, test_raw$id), nrow(preds) == 5789,
          !anyDuplicated(preds$id), all(is.finite(preds$pred)))

diagnostics <- train_raw %>% mutate(
  fold = fold_id, pred_baseline = oof$m1, pred_final = oof[[final_name]],
  residual_baseline = sale_price - pred_baseline,
  residual_final = sale_price - pred_final,
  ratio = pred_final / sale_price,
  income_group = cut(income, c(-Inf, 50000, 75000, 100000, Inf),
                     labels = c("Under $50k", "$50k–75k", "$75k–100k", "$100k+")))
write_csv(model_log, "data/assignment5/model_log.csv")
write_csv(fold_scores, "data/assignment5/fold_scores.csv")
write_csv(st_drop_geometry(diagnostics) %>% select(
  id, location_id, fold, sale_price, pred_baseline, pred_final,
  residual_baseline, residual_final, ratio, ward, model_ward,
  income, income_moe, income_group), "data/assignment5/out_of_fold_predictions.csv")
write_csv(preds, "data/assignment5/assignment5_predictions.csv")
saveRDS(list(model_log = model_log, fold_scores = fold_scores, formulas = formulas,
             final_name = final_name, f_final = f_final, m_final = m_final,
             fill_final = fill_final, diagnostics = diagnostics,
             train = train, test = test, preds = preds, fold_id = fold_id,
             weights = weights), "data/assignment5/model_results.rds")
cat("Final model:", final_name, "\n")
Show code
model_log %>% gt() %>%
  cols_label(model = "Model", added = "Change", cv_rmse = "CV RMSE",
             moran_i = "Moran's I", notes = "Reason") %>%
  fmt_currency(columns = cv_rmse, decimals = 0) %>%
  fmt_number(columns = moran_i, decimals = 3) %>%
  tab_options(table.font.size = px(12), data_row.padding = px(6))
Model Change CV RMSE Moran's I Reason
m1 Structure only $281,468 0.377 Establish a house-characteristic baseline.
m2 Log lot size; size and age quadratics $267,870 0.416 Allow size and age slopes to vary; reduce lot-size skew.
m3 Year and quarter $267,063 0.419 Capture changes in the market and seasonality.
m4 Downtown distance and coordinates $235,864 0.398 Represent broad geographic price gradients.
m5 Ward fixed effects $196,399 0.204 Capture local differences beyond downtown distance.
m6 Building area by ward $182,389 0.170 Allow the value of additional floor space to vary by location.
m7 Log robbery count within 660 ft $182,373 0.170 Measure nearby incidents in the preceding available quarter.
m8 Mean distance to five robberies $182,297 0.169 Distinguish locations with no incidents inside the buffer.
m9 Log-transform robbery distance $182,236 0.168 Test diminishing effects of additional separation.
m10 Building area by age $181,295 0.171 Allow the area slope to differ between newer and older houses.

Part 3: Final model

The final specification is m10, with the lowest mean fold RMSE: $181,295. This is a 35.6% improvement over the structure-only baseline. The outcome remains in dollars.

Show code
f_final
sale_price ~ building_000sf + age_decade + num_bedrooms + num_full_baths + 
    num_half_baths + num_fireplaces + garage_size + central_air + 
    one_story + full_basement + finished_basement + brick_stone_ext + 
    warmairfurnace + log_lot + I(building_000sf^2) + I(age_decade^2) + 
    year_f + quarter_f + downtown_mi + I(downtown_mi^2) + east_mi + 
    north_mi + model_ward + log_robbery + crime_unavailable + 
    log_knn5 + building_000sf:model_ward + building_000sf:age_decade
Show code
coef_table <- broom::tidy(m_final) %>%
  filter(term %in% c("num_full_baths", "num_half_baths", "central_air",
                     "log_lot", "I(building_000sf^2)", "I(age_decade^2)",
                     "log_robbery", "log_knn5", "building_000sf:age_decade")) %>%
  select(term, estimate, std.error)
coef_table %>% gt() %>%
  cols_label(term = "Term", estimate = "Coefficient", std.error = "Standard error") %>%
  fmt_number(columns = c(estimate, std.error), decimals = 1) %>%
  tab_header(title = "Selected coefficients", subtitle = "Full retained training sample")
Selected coefficients
Full retained training sample
Term Coefficient Standard error
num_full_baths 47,613.6 2,857.4
num_half_baths 13,176.4 2,793.0
central_air 13,178.3 3,276.9
log_lot 142,984.4 4,533.7
I(building_000sf^2) 20,877.4 1,738.5
I(age_decade^2) 1,561.6 139.6
log_robbery −954.6 3,396.7
log_knn5 60,744.7 9,991.5
building_000sf:age_decade −8,282.4 513.9
Show code
write_csv(broom::tidy(m_final), "data/assignment5/final_coefficients.csv")

Ward effects produced the largest single reduction, from $235,864 to $196,399. Allowing the area slope to vary by ward reduced RMSE by another $14,011. Crime features were disappointing: the count and distance terms together improved m6 by only $153. The area–age interaction added a further $940 improvement.

Show code
residual_maps <- diagnostics %>%
  select(id, residual_baseline, residual_final) %>%
  pivot_longer(starts_with("residual_"), names_to = "model", values_to = "residual") %>%
  mutate(model = factor(model, levels = c("residual_baseline", "residual_final"),
                         labels = c("Baseline", "Final")))
p_residual <- ggplot() +
  geom_sf(data = wards, fill = "#f7f7f7", color = "#d8d8d8", linewidth = 0.1) +
  geom_sf(data = residual_maps, aes(color = residual), size = 0.3, alpha = 0.65) +
  scale_color_gradient2(low = "#b2182b", mid = "#f7f7f7", high = "#2166ac",
                         limits = c(-500000, 500000), breaks = c(-500000, 0, 500000),
                         oob = scales::squish,
                         labels = scales::label_dollar(scale = 1e-3, suffix = "k"),
                         name = "Actual − predicted",
                         guide = guide_colorbar(barwidth = grid::unit(9, "cm"),
                                                title.position = "top")) +
  facet_wrap(~model) + coord_sf(datum = NA) +
  labs(title = "Location features reduce clustered errors",
       caption = "Blue: underprediction. Red: overprediction. Colors capped at ±$500,000; all sales shown.") +
  theme(legend.position = "bottom", panel.grid = element_blank())
save_plot(p_residual, "residual_maps", height = 7)

Side-by-side maps of out-of-fold residuals from the baseline and final models on the same color scale.

Moran’s I falls from 0.377 to 0.171. The broad north-side underprediction and south-side overprediction weaken. Local clusters remain, and m10 has slightly more residual clustering than m9 despite its lower RMSE.

Part 4: Where it fails, and who pays

4A. Largest errors

Show code
error_by_ward <- diagnostics %>% st_drop_geometry() %>% group_by(ward) %>%
  summarize(sales = n(), rmse = sqrt(mean(residual_final^2)),
             mean_residual = mean(residual_final),
             median_price = median(sale_price), .groups = "drop")
error_by_ward %>% filter(sales >= 50) %>% arrange(desc(rmse)) %>% slice_head(n = 6) %>%
  gt() %>% cols_label(ward = "Ward", sales = "Sales", rmse = "OOF RMSE",
                      mean_residual = "Mean actual − predicted", median_price = "Median price") %>%
  fmt_currency(columns = c(rmse, mean_residual, median_price), decimals = 0) %>%
  tab_header(title = "Largest ward-level errors", subtitle = "Wards with at least 50 retained sales")
Largest ward-level errors
Wards with at least 50 retained sales
Ward Sales OOF RMSE Mean actual − predicted Median price
43 303 $790,710 $4,167 $1,810,000
2 120 $565,446 −$1,665 $1,447,500
44 100 $423,947 $83,344 $1,540,000
46 50 $368,308 −$165,343 $707,950
47 352 $337,516 $695 $1,325,000
5 152 $331,801 $2,186 $443,500
Show code
largest_errors <- diagnostics %>% st_drop_geometry() %>%
  arrange(desc(abs(residual_final))) %>% slice_head(n = 5) %>%
  select(id, ward, sale_price, pred_final, residual_final)
largest_errors %>% gt() %>%
  cols_label(id = "ID", ward = "Ward", sale_price = "Sale price",
             pred_final = "OOF prediction", residual_final = "Actual − predicted") %>%
  fmt_currency(columns = c(sale_price, pred_final, residual_final), decimals = 0)
ID Ward Sale price OOF prediction Actual − predicted
19188 43 $14,250,000 $7,176,987 $7,073,013
7638 43 $4,750,000 $868,848 $3,881,152
12182 42 $5,650,000 $2,070,790 $3,579,210
13591 42 $4,900,000 $2,070,716 $2,829,284
2541 2 $5,650,000 $2,895,435 $2,754,565

Ward 43 has the largest RMSE among wards with at least 50 sales: $790,710. Its mean residual is only $4,167, so errors in both directions cancel in the average. The most extreme misses are underpredictions: the $14.25 million sale is predicted at $7,176,987. Interior quality, renovation, and unusual luxury properties are plausible missing factors.

Ward 42 has only 19 sales and is merged with Ward 2 for fitting. Two of the largest misses occur there. Pooling keeps the category estimable but loses some local detail.

Show code
p_fit <- ggplot(diagnostics, aes(sale_price, pred_final)) +
  geom_abline(intercept = 0, slope = 1, linetype = 2, color = "#555555") +
  geom_point(alpha = 0.25, size = 0.8, color = "#2c7fb8") +
  scale_x_continuous(labels = scales::label_dollar(scale = 1e-6, suffix = "m")) +
  scale_y_continuous(labels = scales::label_dollar(scale = 1e-6, suffix = "m")) +
  coord_equal() + labs(title = "The largest misses are expensive houses",
                       x = "Observed sale price", y = "Out-of-fold prediction")
save_plot(p_fit, "observed_predicted")

Observed versus out-of-fold predicted prices with a 45-degree reference line. Several luxury homes are substantially underpredicted.

4B. Neighborhood income and valuation ratios

Neighborhood income is tract median household income from 2023 ACS five-year table B19013, covering 2019–2023. These estimates were released in December 2024, so they are used for this retrospective equity check, not as predictors of earlier sales. The 71 sales without a valid tract income remain in the model and are excluded only from the income comparison.

Show code
income_summary <- diagnostics %>% st_drop_geometry() %>% filter(!is.na(income_group)) %>%
  group_by(income_group) %>% summarize(
    sales = n(), baseline_ratio = median(pred_baseline / sale_price),
    median_ratio = median(ratio), mean_ratio = mean(ratio),
    share_overvalued = mean(ratio > 1), .groups = "drop")
income_summary %>% gt() %>%
  cols_label(income_group = "Tract median income", sales = "Sales",
             baseline_ratio = "Baseline median ratio", median_ratio = "Final median ratio",
             mean_ratio = "Final mean ratio", share_overvalued = "Above 1") %>%
  fmt_number(columns = c(baseline_ratio, median_ratio, mean_ratio), decimals = 3) %>%
  fmt_percent(columns = share_overvalued, decimals = 1)
Tract median income Sales Baseline median ratio Final median ratio Final mean ratio Above 1
Under $50k 4641 1.607 1.132 1.574 57.9%
$50k–75k 6175 1.145 1.016 1.277 51.5%
$75k–100k 5312 0.916 0.990 1.107 48.7%
$100k+ 5769 0.829 0.990 1.048 48.1%
Show code
p_income <- income_summary %>%
  select(income_group, baseline_ratio, median_ratio) %>%
  pivot_longer(-income_group, names_to = "model", values_to = "ratio") %>%
  mutate(model = factor(model, levels = c("baseline_ratio", "median_ratio"),
                         labels = c("Baseline", "Final"))) %>%
  ggplot(aes(income_group, ratio, group = model, color = model)) +
  geom_hline(yintercept = 1, linetype = 2, color = "#666666") +
  geom_line(linewidth = 0.8) + geom_point(aes(shape = model), size = 3) +
  scale_color_manual(values = c("#888888", "#2166ac")) +
  labs(title = "Lower-income neighborhoods remain overvalued",
       x = "Tract median household income", y = "Median predicted / sale price",
       color = NULL, shape = NULL) + theme(legend.position = "bottom")
save_plot(p_income, "income_ratios")

The final median ratio is 1.132 in tracts below $50,000 and 0.990 in tracts above $100,000. In the lowest-income group, 57.9% of homes are overvalued. The mean ratio is higher than the median because some low-price sales have very large proportional errors.

Show code
cleaning_by_income <- all_sales %>% st_drop_geometry() %>% filter(split == "train") %>%
  mutate(income_group = cut(income, c(-Inf, 50000, 75000, 100000, Inf),
                             labels = levels(diagnostics$income_group))) %>%
  filter(!is.na(income_group)) %>% group_by(income_group) %>%
  summarize(original_sales = n(), excluded = sum(exclusion != "Retained"),
             excluded_share = mean(exclusion != "Retained"),
             retained_under_50k = sum(exclusion == "Retained" & sale_price < 50000),
             .groups = "drop")
cleaning_by_income %>% gt() %>%
  cols_label(income_group = "Tract median income", original_sales = "Original sales",
             excluded = "Excluded", excluded_share = "Excluded share",
             retained_under_50k = "Retained sales below $50k") %>%
  fmt_percent(columns = excluded_share, decimals = 1)
Tract median income Original sales Excluded Excluded share Retained sales below $50k
Under $50k 4898 257 5.2% 397
$50k–75k 6545 370 5.7% 153
$75k–100k 5520 208 3.8% 27
$100k+ 6160 391 6.3% 9

Cleaning removes multiple-property transactions while retaining inexpensive single-property sales. A higher price cutoff would remove some of the homes most affected by overvaluation. Exclusion rates also differ across income groups, so these ratios describe the retained single-property sample. The Tax Divide documented disproportionately high assessments on lower-value homes. The final model reduces the income gap but does not eliminate that pattern. Holding tax rates and exemptions constant, overvaluation increases an owner’s share of the tax burden.

Part 5: Predict the test houses

Show code
# Refit on all retained training sales, then apply the same preprocessing to test.
fill_final <- learn_fill(train_raw)
train <- prepare(train_raw, fill_final)
test <- prepare(test_raw, fill_final)
m_final <- lm(f_final, data = train, na.action = na.fail)
predictor_vars <- all.vars(delete.response(terms(f_final)))
stopifnot(all(complete.cases(test[predictor_vars])))
preds <- tibble(id = test$id, pred = as.numeric(predict(m_final, newdata = test)))
stopifnot(identical(preds$id, raw_test$id), nrow(preds) == nrow(raw_test),
          !anyDuplicated(preds$id), all(is.finite(preds$pred)))
write_csv(preds, "data/assignment5/assignment5_predictions.csv")
tibble(test_rows = nrow(preds), finite_predictions = sum(is.finite(preds$pred)),
       negative_predictions = sum(preds$pred < 0)) %>% knitr::kable()
test_rows finite_predictions negative_predictions
5789 5789 12

Final CV RMSE: $181,295. Pooled out-of-fold RMSE is $182,182. The test file contains 5,789 predictions in the original ID order. Test RMSE requires the instructor’s hidden sale prices. The linear model produces 12 negative test predictions; the CSV keeps the same unmodified prediction rule evaluated in CV.

Part 6: Reflection

Location made the largest difference. Adding ward indicators reduced mean fold RMSE from $235,864 to $196,399, and allowing building-area slopes to vary across wards reduced it to $182,389. A square foot does not carry the same predicted value everywhere. The crime measures added little after these terms, suggesting that broad location already captures much of the variation they describe. The final area–age interaction brought RMSE to $181,295, a 35.6% reduction from the baseline.

Coefficient reading: The full-bathroom coefficient is $47,614. Holding the model’s other characteristics constant, homes with one additional full bathroom have an estimated price that is higher by this amount. This is a conditional association: bathrooms can also proxy for unobserved interior quality. Prediction reading: For two otherwise identical feature records, changing full bathrooms from one to two increases the fitted price by $47,614. It does not establish the return from adding a bathroom.

I would use this model as a starting point for assessment review. I would not use its output directly for tax bills. The final median valuation ratio remains about 1.13 in the lowest-income tracts, compared with 0.99 in the highest-income group. Optimizing dollar RMSE has not removed unequal proportional errors. The most expensive homes also produce the largest misses, and the model generates 56 negative held-out predictions. Those failures need review before values are assigned.

The next improvements would focus on renovation, property condition, and finer location information for sparsely represented areas. I would evaluate them on the same retained sample and inspect both RMSE and income-group ratios. The house-grouped folds estimate performance on other houses in this three-year market. Predicting a future assessment year would need a time-based validation. Finally, the instructor’s held-out test will show how well the model-selection result carries over to unseen houses.