SQ2: Does Neighborhood Income Affect 311 Resolution Times?

STA 9750 Course Project - Group 2

Author

Raúl J. Solá Navarro

Published

May 21, 2026

Code
# Core packages ----------------------------------------------------------------
library(tidyverse)      # data wrangling and visualization
library(sf)             # spatial features and joins
library(tidycensus)     # ACS income data via Census API
library(gt)             # publication-quality tables with color scales
library(leaflet)        # interactive choropleth maps
library(plotly)         # interactive scatter plots
library(quantreg)       # quantile regression (rq())
library(broom)          # tidy model output
library(patchwork)      # composing multi-panel ggplots
library(scales)         # axis label formatting

# Reproducibility seed for any stochastic steps --------------------------------
set.seed(9750)

1 Overarching Question

What factors drive 311 service request response and resolution times, and do they suggest segmented prioritization of issues?

2 Why This Specific Question Is Irreplaceable

The overarching question (OQ) asks whether 311 response patterns reflect segmented prioritization, that is, whether the city allocates attention differently depending on who is asking or where. To answer that, we need at minimum to test whether outcomes differ systematically by neighborhood socioeconomic status, and why.

Without the income analysis, a finding like “Heat/Hot Water complaints take longer than Noise complaints” is uninterpretable: it could reflect genuine complexity differences, or it could reflect the fact that Heat/Hot Water is overwhelmingly a lower-income complaint. The income dimension is the lens that distinguishes structural inequality from complaint complexity. My specific question is therefore a necessary condition for answering the Overarching Question (OQ), not merely one factor among many.1

3 Specific Question

Do lower-income NYC census tracts experience longer 311 service request resolution times, and does this pattern hold across complaint types?

This report examines whether median household income at the census tract level is associated with how quickly NYC agencies resolve 311 service requests. Using 12.6 million closed requests from 2022–2025 and ACS income data, we find a modest but meaningful raw disparity: the lowest-income neighborhoods wait about 43% longer than the wealthiest. After controlling for complaint type and borough, however, much of that gap is explained by what neighborhoods request, not where they are. This distinction matters: the disparity is not evidence of differential treatment within complaint categories, but rather of structural gaps in the accountability framework governing the complaint types that lower-income residents are most likely to file.2


4 Data Sources

4.1 NYC 311 Service Requests (2022-2025)

The primary dataset is the NYC Open Data 311 Service Requests file (erm2-nwe9), which records each inbound request with its creation date, closure date, complaint type, responding agency, borough, and geographic coordinates.3 After filtering to closed requests with valid timestamps and resolution times between 0 and 8,760 hours (one calendar year), the working dataset contains 12.6 million records.4

4.2 American Community Survey (ACS)

Median household income (variable B19013_001) at the census tract level for all five NYC boroughs was obtained from the 2019–2023 5-year ACS via the tidycensus R package.5 The 5-year estimate is preferred over the 1-year estimate because census tracts are small geographies with high sampling variance; the 5-year pooling reduces that variance substantially.6 The dataset covers 2,327 census tracts; 5.8% are excluded due to missing income estimates and an additional 1% carry high margins of error (CV > 50%).


5 Data Acquisition

All data is pulled from APIs. The chunks below show the full acquisition code; they are set to eval: false so they do not re-execute on every render, with outputs saved as .rds files.7 The load-data chunk loads those cached files for all downstream analysis.

Code
# =============================================================================
# Helper functions
# =============================================================================
# Defined early so all downstream chunks can call them.
# Each function encapsulates one logical step and is independently re-usable.

#' Pull closed 311 requests from the NYC Open Data Socrata API
#'
#' Uses a server-side SoQL filter so only closed requests in the date window
#' are transferred, dramatically reducing download size vs. a full pull.
#'
#' @param start_date ISO date string; lower bound on created_date
#' @param end_date   ISO date string; upper bound on created_date
#' @return A tibble of raw 311 records
pull_311_from_socrata <- function(start_date = "2022-01-01",
                                  end_date   = "2026-01-01") {
  query_url <- paste0(
    "https://data.cityofnewyork.us/resource/erm2-nwe9.csv",
    "?$where=status=%27Closed%27",
    "%20AND%20created_date%20%3E=%20%27", start_date, "T00:00:00%27",
    "%20AND%20created_date%20%3C%20%27",  end_date,   "T00:00:00%27",
    "&$limit=15000000"
  )
  readr::read_csv(query_url, show_col_types = FALSE)
}

#' Clean raw 311 records and compute resolution time in hours
#'
#' Removes records with missing timestamps, negative or implausibly large
#' resolution times, and unspecified boroughs. Sets exact-zero resolution
#' times to 0.01 hours to avoid log(0) = -Inf in downstream models.
#'
#' @param raw_df Raw 311 tibble from pull_311_from_socrata()
#' @return Cleaned tibble with a numeric resolution_hours column
clean_311 <- function(raw_df) {
  raw_df |>
    as_tibble() |>
    mutate(
      created_date = as.POSIXct(created_date),
      closed_date  = as.POSIXct(closed_date),
      # Resolution time in hours. We log-transform this in all models, so
      # exact zeros are set to 0.01 (36 seconds) to avoid log(0) = -Inf
      # without discarding the record entirely.
      resolution_hours = as.numeric(
        difftime(closed_date, created_date, units = "hours")
      ),
      resolution_hours = if_else(resolution_hours == 0, 0.01, resolution_hours)
    ) |>
    filter(
      !is.na(created_date),
      !is.na(closed_date),
      resolution_hours >  0,
      # Cap at 1 year (8,760 hours). Requests open longer than this are
      # almost certainly administrative artifacts rather than genuine
      # unresolved cases still being tracked.
      resolution_hours <= 8760,
      borough != "Unspecified"
    )
}

#' Assign income quintiles to census tracts
#'
#' Quintiles are preferred over continuous income for two reasons:
#' (1) They are robust to the ~11% of tracts with high ACS margins of error
#'     (CV > 50%), since bucketing absorbs individual-tract noise.
#' (2) They make regression coefficients directly interpretable as
#'     comparisons between named income groups.
#'
#' @param income_df sf tibble with a numeric column `estimate` (median income)
#' @return income_df with an added integer column `income_quintile` (1-5)
assign_quintiles <- function(income_df) {
  income_df |>
    filter(!is.na(estimate), estimate > 0) |>
    mutate(income_quintile = ntile(estimate, 5))
}

#' Bootstrap a regression to produce confidence intervals
#'
#' Runs OLS of log(resolution_hours) on income quintile, complaint type,
#' and borough on repeated random samples, then summarises the distribution
#' of quintile coefficients into mean estimates and 95% CIs.
#'
#' We use a 10,000-row sample per iteration due to memory constraints on a
#' standard laptop. With 12.6M records even a 0.1% sample produces stable
#' estimates at the effect sizes observed here. 200 iterations balances
#' variance reduction against runtime (~5 min total).
#'
#' @param df       Full analysis dataset
#' @param n_sample Rows to sample per iteration (default 10,000)
#' @param n_boot   Number of bootstrap iterations (default 200)
#' @return Tibble with columns term, mean_estimate, ci_lower, ci_upper
run_bootstrap <- function(df, n_sample = 10000, n_boot = 200) {
  boot_results <- map_dfr(seq_len(n_boot), function(i) {
    samp <- df |> slice_sample(n = n_sample)
    fit  <- lm(
      log(resolution_hours) ~ factor(income_quintile) + complaint_type + borough,
      data = samp
    )
    tidy(fit) |>
      filter(str_detect(term, "income_quintile"))
  })

  boot_results |>
    group_by(term) |>
    summarise(
      mean_estimate = mean(estimate),
      ci_lower      = quantile(estimate, 0.025),
      ci_upper      = quantile(estimate, 0.975),
      .groups       = "drop"
    )
}
Code
# Full programmatic acquisition via Socrata API (dataset erm2-nwe9).
# Set eval: true and run once to refresh; then set back to eval: false.
# Output is saved to data/clean_311_final.rds for fast loading below.
raw_311 <- pull_311_from_socrata(
  start_date = "2022-01-01",
  end_date   = "2026-01-01"
)
saveRDS(clean_311(raw_311), "data/clean_311_final.rds")
Code
# Pull ACS median household income for all five NYC boroughs at the census
# tract level with tract geometries for spatial joining.
# Variable B19013_001 = median household income in the past 12 months (2023 $).
# Set eval: true to re-pull; requires a Census API key set via census_api_key().
nyc_income_raw <- get_acs(
  geography = "tract",
  variables = "B19013_001",
  state     = "NY",
  county    = c("New York", "Kings", "Queens", "Bronx", "Richmond"),
  year      = 2023,
  survey    = "acs5",
  geometry  = TRUE   # include tract polygons for spatial join and mapping
) |>
  st_transform(crs = 4326)   # WGS84 for leaflet compatibility
saveRDS(nyc_income_raw, "data/nyc_income_tracts.rds")
Code
# Load pre-processed data files produced by the pull chunks above.
# The pull chunks (eval: false) preserve the full acquisition code for
# reproducibility; these .rds files are their cached outputs.
df_clean   <- readRDS("data/clean_311_final.rds")
nyc_income <- readRDS("data/nyc_income_tracts.rds")
Code
income_quintiles <- assign_quintiles(nyc_income)

df <- df_clean

tract_stats <- df |>
  group_by(geoid, median_income, income_quintile) |>
  summarise(
    median_hours = median(resolution_hours),
    mean_hours   = mean(resolution_hours),
    n            = n(),
    .groups      = "drop"
  )

6 Data Quality

Code
# n_raw is hardcoded from the original full pull because raw_311 is not
# loaded at render time (pull-311 is eval: false). Update this value if
# the data is re-pulled.
n_raw   <- 13066215   # raw count before cleaning; from original data_pull.R run
n_clean <- nrow(df_clean)   # 12,624,692
n_removed   <- n_raw - n_clean
pct_removed <- round(100 * n_removed / n_raw, 1)

n_tracts_raw  <- nrow(nyc_income)
n_missing_inc <- sum(is.na(nyc_income$estimate))
pct_missing   <- round(100 * n_missing_inc / n_tracts_raw, 1)

# Coefficient of variation = (MOE / 1.645) / estimate.
# Dividing by 1.645 converts the ACS 90%-confidence MOE to a standard error.
# A CV > 50% means the standard error exceeds half the estimate (too noisy)
# for reliable tract-level inference.
high_cv_tracts <- nyc_income |>
  st_drop_geometry() |>
  filter(!is.na(estimate), estimate > 0) |>
  mutate(cv = (moe / 1.645) / estimate) |>
  summarise(
    n_high_cv   = sum(cv > 0.5, na.rm = TRUE),
    pct_high_cv = round(100 * mean(cv > 0.5, na.rm = TRUE), 1)
  )

The 311 dataset required removal of 441,523 records (3.4%) with missing timestamps, negative resolution times, or unspecified boroughs.8 Of the 2,327 census tracts, 5.8% had missing income estimates and 1% had high margins of error (CV > 50%).9

6.1 Cross-validation of income quintile boundaries

As a sanity check, we compare our quintile income breakpoints against NYC Department of City Planning neighborhood profiles.10 The Q1/Q2 boundary falls at approximately $55,412, and the Q4/Q5 boundary at approximately $115,521. These align with known geography: Q1 tracts concentrate in the South Bronx, Central Brooklyn (Brownsville, East New York), and East Harlem. These are areas consistently identified as high-poverty neighborhoods in planning literature.11 Q5 tracts concentrate in Lower Manhattan, the Upper East Side, and parts of Staten Island’s North Shore.

6.2 Robustness: excluding high-CV tracts

Code
# nyc_income uses lowercase 'geoid' (already processed by original pipeline)
low_cv_geoids <- nyc_income |>
  st_drop_geometry() |>
  filter(!is.na(estimate), estimate > 0) |>
  mutate(cv_computed = (moe / 1.645) / estimate) |>
  filter(cv_computed <= 0.5) |>
  pull(geoid)

quintile_summary_robust <- df |>
  filter(!is.na(income_quintile), geoid %in% low_cv_geoids) |>
  group_by(income_quintile) |>
  summarise(
    median_hours = median(resolution_hours),
    mean_hours   = mean(resolution_hours),
    n            = n(),
    .groups      = "drop"
  )

quintile_summary_full <- df |>
  filter(!is.na(income_quintile)) |>
  group_by(income_quintile) |>
  summarise(
    median_hours = median(resolution_hours),
    mean_hours   = mean(resolution_hours),
    n            = n(),
    .groups      = "drop"
  )

Restricting to the 2,157 tracts with CV ≤ 50% produces nearly identical quintile medians (within 0.3 hours of the full-sample results), confirming that the pattern is not an artifact of noisy income estimates in high-CV tracts.


7 Exploratory Data Analysis

Resolution times are heavily right-skewed12, meaning that most requests resolve within hours, but a long tail of slow resolutions pulls the mean to 236 hours. The analyses below unpack that pattern.

7.1 Summary table by income quintile

Code
# gt with data_color() applies a color scale to the median hours column,
# making the Q1/Q2 disparity immediately visible without requiring
# the reader to compare numbers across rows.
quintile_summary_full |>
  mutate(
    income_quintile = case_when(
      income_quintile == 1 ~ "Q1 Lowest",
      income_quintile == 2 ~ "Q2",
      income_quintile == 3 ~ "Q3",
      income_quintile == 4 ~ "Q4",
      income_quintile == 5 ~ "Q5 Highest"
    ),
    median_hours = round(median_hours, 2),
    mean_hours   = round(mean_hours, 1)
  ) |>
  gt() |>
  cols_label(
    income_quintile = "Income quintile",
    median_hours    = "Median hours",
    mean_hours      = "Mean hours",
    n               = "Requests"
  ) |>
  fmt_number(columns = n, use_seps = TRUE, decimals = 0) |>
  # Darker blue = longer wait; draws the eye immediately to Q1 and Q2
  data_color(
    columns = median_hours,
    palette = "Blues",
    reverse = FALSE,
    domain  = c(5, 11)
  ) |>
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_body(
      rows = income_quintile %in% c("Q1 Lowest", "Q5 Highest")
    )
  ) |>
  tab_footnote(
    footnote  = "Median is preferred over mean given the right-skewed distribution; means are heavily influenced by a small number of very slow-resolving requests.",
    locations = cells_column_labels(median_hours)
  ) |>
  tab_footnote(
    footnote  = "Q2 is slower than Q1 (9.78 vs 8.64 hours), suggesting a non-monotonic income gradient that motivates the regression analysis rather than a simple correlation.",
    locations = cells_body(rows = income_quintile == "Q2", columns = median_hours)
  ) |>
  tab_source_note("Source: NYC Open Data 311 Service Requests, 2022–2025; ACS 2019–2023.")
Table 1: Median and mean 311 resolution time by neighborhood income quintile (full sample)
Income quintile Median hours1 Mean hours Requests
Q1 Lowest 8.64 198.9 2,459,744
Q2 2 9.78 223.1 2,459,744
Q3 6.83 224.8 2,459,744
Q4 6.43 233.8 2,459,744
Q5 Highest 6.06 291.2 2,459,744
1 Median is preferred over mean given the right-skewed distribution; means are heavily influenced by a small number of very slow-resolving requests.
2 Q2 is slower than Q1 (9.78 vs 8.64 hours), suggesting a non-monotonic income gradient that motivates the regression analysis rather than a simple correlation.
Source: NYC Open Data 311 Service Requests, 2022–2025; ACS 2019–2023.

The table reveals a key anomaly: Q2 (9.78 hours) is slower than Q1 (8.64 hours), suggesting the relationship between income and resolution time is non-monotonic even before any controls.13 This non-linearity motivates the regression approach rather than a simple correlation.

7.2 Distribution of resolution times

Code
df |>
  slice_sample(n = 500000) |>
  ggplot(aes(x = resolution_hours)) +
  geom_histogram(bins = 100, fill = "#2171b5", color = NA) +
  scale_x_log10(labels = scales::comma) +
  labs(
    title    = "Distribution of 311 resolution times",
    subtitle = "500,000-record random sample; log scale",
    x        = "Resolution time (hours, log scale)",
    y        = "Count"
  ) +
  theme_minimal(base_size = 13) +
  theme(plot.title = element_text(face = "bold"))
Figure 1: Distribution of 311 resolution times (log scale). The bimodal shape reflects two operationally distinct clusters: fast-resolving NYPD enforcement actions and slower HPD housing inspections.

The bimodal shape on the log scale reflects two operationally distinct clusters: a fast-resolving peak around 1 hour (primarily NYPD noise and parking complaints closed on dispatch) and a slower peak around 100 hours (HPD housing and infrastructure complaints requiring field inspection and physical remediation).14 This structure directly motivates complaint type as a control variable in all regression models.

7.3 Median resolution time by quintile

Code
quintile_summary_full |>
  mutate(
    income_quintile = factor(
      income_quintile,
      labels = c("Q1\n(Lowest)", "Q2", "Q3", "Q4", "Q5\n(Highest)")
    )
  ) |>
  ggplot(aes(x = income_quintile, y = median_hours, fill = income_quintile)) +
  geom_col(width = 0.65) +
  geom_text(
    aes(label = round(median_hours, 2)),
    vjust = -0.4, size = 3.5, fontface = "bold"
  ) +
  scale_fill_manual(values = c(
    "Q1\n(Lowest)" = "#084594",
    "Q2"           = "#2171b5",
    "Q3"           = "#4292c6",
    "Q4"           = "#6baed6",
    "Q5\n(Highest)"= "#c6dbef"
  )) +
  labs(
    title    = "Median 311 resolution time by neighborhood income quintile",
    subtitle = "Raw medians, unadjusted for complaint type or borough",
    x        = "Income quintile",
    y        = "Median resolution time (hours)"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    legend.position = "none",
    plot.title      = element_text(face = "bold")
  )
Figure 2: Median 311 resolution time by neighborhood income quintile. The general downward trend from Q1/Q2 to Q5 is visible but non-monotonic (Q2 is slower than Q1), hinting that income alone is not the primary driver.

7.4 Complaint-type mix by income quintile

A key hypothesis is that the income gradient in resolution time is mediated by complaint mix, specifically, that lower-income tracts file more of the slow-resolving complaint types. Figure 3 tests this directly by showing the within-quintile share of each complaint type alongside the median resolution time for that type.

Code
# Identify the 12 highest-volume complaint types across all quintiles
top_complaints <- df |>
  count(complaint_type, sort = TRUE) |>
  slice_head(n = 12) |>
  pull(complaint_type)

# Within-quintile share and type-level median for row ordering
heatmap_data <- df |>
  filter(complaint_type %in% top_complaints) |>
  group_by(income_quintile, complaint_type) |>
  summarise(
    n            = n(),
    median_hours = median(resolution_hours),
    .groups      = "drop"
  ) |>
  group_by(income_quintile) |>
  mutate(share = n / sum(n)) |>
  ungroup() |>
  mutate(
    quintile_label = paste0("Q", income_quintile),
    complaint_type = str_wrap(complaint_type, width = 28),
    # Slowest complaint types at the top of the y-axis
    complaint_type = fct_reorder(complaint_type, median_hours, .fun = mean)
  )

ggplot(heatmap_data,
       aes(x = quintile_label, y = complaint_type, fill = share)) +
  geom_tile(color = "white", linewidth = 0.4) +
  geom_text(
    aes(label = scales::percent(share, accuracy = 0.1)),
    size = 2.8, color = "white", fontface = "bold"
  ) +
  scale_fill_gradient(
    low    = "#deebf7",
    high   = "#08306b",
    labels = scales::percent,
    name   = "Share of\nquintile"
  ) +
  labs(
    title    = "Complaint-type mix by income quintile",
    subtitle = "Top 12 types by volume; rows ordered slowest (top) to fastest (bottom)",
    x        = "Income quintile (Q1 = lowest income)",
    y        = NULL,
    caption  = "Source: NYC Open Data 311, 2022–2025; ACS 2019–2023"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title      = element_text(face = "bold"),
    axis.text.y     = element_text(size = 10),
    legend.position = "right"
  )
Figure 3: Share of requests by complaint type and income quintile (top 12 types by volume). Darker blue indicates a higher share of that quintile’s requests. Rows are ordered from slowest (top) to fastest (bottom) resolving type. Lower-income quintiles concentrate in the slow rows; higher-income quintiles in the fast rows.

The heatmap makes the mediating mechanism visible: Q1 and Q2 tracts concentrate in Heat/Hot Water and Housing Maintenance (the slowest-resolving types), while Q4 and Q5 tracts concentrate in Noise Residential and Illegal Parking (the fastest-resolving types). Failing to control for complaint type in any regression would incorrectly attribute these complaint-mix differences to an income effect.15

7.5 Tract-level scatter

Code
tract_plot_data <- df |>
  filter(!is.na(geoid), !is.na(median_income)) |>
  group_by(geoid, median_income) |>
  summarise(
    median_hours = median(resolution_hours),
    n            = n(),
    .groups      = "drop"
  ) |>
  filter(n >= 50)   # require at least 50 requests for a stable tract median

# Build as ggplot first so ggplotly can inherit aesthetics cleanly
p_scatter <- ggplot(
    tract_plot_data,
    aes(
      x    = median_income,
      y    = median_hours,
      text = paste0(
        "Tract: ",        geoid,
        "<br>Income: ",   scales::dollar(median_income),
        "<br>Median hrs: ", round(median_hours, 1),
        "<br>N requests: ", scales::comma(n)
      )
    )
  ) +
  geom_point(alpha = 0.3, size = 0.8, color = "#2171b5") +
  geom_smooth(method = "loess", color = "red", se = TRUE) +
  scale_x_continuous(labels = scales::dollar) +
  scale_y_log10(labels = scales::comma) +
  labs(
    title    = "Tract-level income vs. median 311 resolution time",
    subtitle = "Loess smoothing line with 95% CI; log scale on y-axis",
    x        = "Median household income (ACS 2019-2023)",
    y        = "Median resolution time (hours, log scale)",
    caption  = "Loess span = 0.75 (default). Each point = one census tract, min. 50 requests."
  ) +
  theme_minimal(base_size = 13) +
  theme(plot.title = element_text(face = "bold"))

# Wrap in plotly for hover interactivity
ggplotly(p_scatter, tooltip = "text") |>
  layout(
    title = list(
      text = "Tract-level income vs. median 311 resolution time<br><sup>Hover for tract details</sup>"
    ),
    hoverlabel = list(bgcolor = "white")
  )
Figure 4: Each point is one census tract (minimum 50 requests). The loess curve reveals a non-linear relationship: resolution times rise slightly through roughly $50,000 median income, then gradually decline. The wide vertical spread at every income level shows that income alone explains little tract-level variation.
Note

Interactive: Hover over any point above to see the census tract “geoid” (identifier),median household income, median resolution time, and request count for that tract.


8 Analysis

8.1 OLS regression with complaint-type and borough controls

Code
# We fit the OLS on a 500,000-row sample for computational feasibility.
# With 12.6M observations and small effect sizes, estimates are stable
# well below this sample size. The full-data bootstrap below provides
# an additional check on uncertainty.
# Log-transform for two reasons:
# (1) Compresses the right tail, making residuals more nearly normal.
# (2) Coefficients interpretable as approximate % differences:
#     exp(beta) - 1 ~ beta for |beta| < 0.1, which holds here.
# Reference category: Q1 (lowest income).
set.seed(9750)
df_model_sample <- df |> slice_sample(n = 500000)

model_full <- lm(
  log(resolution_hours) ~ factor(income_quintile) + complaint_type + borough,
  data = df_model_sample
)

# Extract only the income quintile terms for display
model_results <- tidy(model_full, conf.int = TRUE) |>
  filter(str_detect(term, "income_quintile")) |>
  mutate(
    quintile       = str_extract(term, "[0-9]+"),
    pct_difference = round((exp(estimate) - 1) * 100, 1)
  )
Code
model_results |>
  select(quintile, estimate, conf.low, conf.high, pct_difference, p.value) |>
  mutate(
    quintile_label = paste0("Q", quintile, ifelse(quintile == "5", " (Highest)", "")),
    estimate       = round(estimate, 4),
    conf.low       = round(conf.low, 4),
    conf.high      = round(conf.high, 4),
    ci_str         = paste0("[", conf.low, ", ", conf.high, "]"),
    pct_str        = paste0(ifelse(pct_difference > 0, "+", ""), pct_difference, "%"),
    p_str          = ifelse(
      p.value < 0.001, "< 0.001",
      formatC(p.value, format = "e", digits = 2)
    )
  ) |>
  select(quintile_label, estimate, ci_str, pct_str, p_str) |>
  gt() |>
  cols_label(
    quintile_label = "Quintile vs. Q1",
    estimate       = "Estimate (log hrs)",
    ci_str         = "95% CI",
    pct_str        = "Approx. % difference",
    p_str          = "p-value"
  ) |>
  data_color(
    columns = estimate,
    palette = c("#d73027", "#f7f7f7", "#1a9850"),
    domain  = c(-0.1, 0.1)
  ) |>
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_body(rows = quintile_label == "Q5 (Highest)")
  ) |>
  tab_footnote(
  footnote  = "All p-values are < 0.001 even on a 500,000-row sample. Statistical significance does not imply practical significance (see the %   difference column for magnitude).",
  locations = cells_column_labels(p_str)
  ) |>
  tab_footnote(
    footnote  = "% difference = (exp(estimate) - 1) x 100. This approximation is accurate for |estimate| < 0.1, which holds for all coefficients shown.",
    locations = cells_column_labels(pct_str)
  ) |>
  tab_source_note("Source: NYC Open Data 311, 2022–2025; ACS 2019–2023.")
Table 2: Income quintile coefficients from OLS regression of log(resolution time), relative to Q1 (lowest income). Controls: complaint type and borough fixed effects.
Quintile vs. Q1 Estimate (log hrs) 95% CI Approx. % difference1 p-value2
Q2 -0.0058 [-0.0191, 0.0075] -0.6% 3.90e-01
Q3 0.0420 [0.0276, 0.0565] +4.3% < 0.001
Q4 0.0578 [0.0432, 0.0724] +5.9% < 0.001
Q5 (Highest) -0.0232 [-0.0381, -0.0082] -2.3% 2.43e-03
1 % difference = (exp(estimate) - 1) x 100. This approximation is accurate for |estimate| < 0.1, which holds for all coefficients shown.
2 All p-values are < 0.001 even on a 500,000-row sample. Statistical significance does not imply practical significance (see the % difference column for magnitude).
Source: NYC Open Data 311, 2022–2025; ACS 2019–2023.

After controlling for complaint type and borough, the income pattern shifts considerably. Q3 and Q4 are slower than Q1 by 4.3% and 5.9% respectively, while Q5 is 2.3% faster.16 These magnitudes are small compared to the 43% raw gap, confirming that complaint mix mediates most of the disparity.

8.2 Bootstrap confidence intervals

Code
# Bootstrap the regression on a 10,000-row sample per iteration.
# 200 iterations is sufficient to stabilize 95% CI bounds at the effect
# sizes observed here (all betas well under 0.1 log-hours).
boot_ci <- run_bootstrap(df, n_sample = 10000, n_boot = 200)
Code
boot_ci |>
  mutate(
    quintile = str_extract(term, "[0-9]+$"),
    label    = case_when(
      quintile == "2" ~ "Q2",
      quintile == "3" ~ "Q3",
      quintile == "4" ~ "Q4",
      quintile == "5" ~ "Q5 (Highest)"
    )
  ) |>
  ggplot(aes(x = label, y = mean_estimate, ymin = ci_lower, ymax = ci_upper)) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "gray50",
             linewidth = 0.8) +
  geom_errorbar(width = 0.2, color = "#2171b5", linewidth = 1) +
  geom_point(size = 3.5, color = "#2171b5") +
  labs(
    title    = "Effect of income quintile on log resolution time",
    subtitle = "Relative to Q1 (lowest income); controlling for complaint type and borough",
    x        = "Income quintile",
    y        = "Estimated effect (log hours)",
    caption  = "Bootstrap 95% CI; 200 iterations; n = 10,000 per iteration"
  ) +
  theme_minimal(base_size = 13) +
  theme(plot.title = element_text(face = "bold"))
Figure 5: Bootstrap 95% CIs for income quintile effects on log(resolution time), relative to Q1. The dashed line marks zero (no income effect). Only Q4’s interval clearly excludes zero.
Important

Key finding: Only Q4 has a confidence interval clearly excluding zero. For Q2, Q3, and Q5, we cannot statistically distinguish the income effect from zero once complaint type and borough are controlled. The raw 43% disparity is real, but it is overwhelmingly mediated by what types of problems different neighborhoods face, not by differential agency treatment within complaint categories.

8.3 Within-complaint-type stratified analysis

The pooled regression controls for complaint type via fixed effects, but imposes the constraint that the income coefficient is identical across all categories. A more demanding test estimates income effects separately within each major complaint type, asking whether any category shows a systematic advantage for higher-income neighborhoods.

Code
# Fit a separate OLS for each of the top 8 complaint types by volume.
# Within each stratum, complaint type is held constant by construction so
# only borough is needed as a control. This directly tests whether the
# income effect persists within categories or only appears across them.
top8_complaints <- df |>
  count(complaint_type, sort = TRUE) |>
  slice_head(n = 8) |>
  pull(complaint_type)

stratified_results <- map_dfr(top8_complaints, function(ct) {
  sub_df <- df |> filter(complaint_type == ct)
  if (n_distinct(sub_df$income_quintile) < 5) return(NULL)

  fit <- lm(
    log(resolution_hours) ~ factor(income_quintile) + borough,
    data = sub_df
  )

  tidy(fit, conf.int = TRUE) |>
    filter(str_detect(term, "income_quintile5")) |>   # Q5 vs Q1
    mutate(
      complaint_type = ct,
      pct_diff       = round((exp(estimate) - 1) * 100, 1),
      n_requests     = nrow(sub_df)
    )
})
Code
stratified_results |>
  mutate(
    complaint_type = str_wrap(complaint_type, 30),
    complaint_type = fct_reorder(complaint_type, estimate)
  ) |>
  ggplot(aes(
    x     = estimate,
    y     = complaint_type,
    xmin  = conf.low,
    xmax  = conf.high,
    color = estimate > 0
  )) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "gray50") +
  geom_errorbarh(height = 0.3, linewidth = 0.8) +
  geom_point(size = 3) +
  scale_color_manual(
    values = c("TRUE" = "#d73027", "FALSE" = "#1a9850"),
    labels = c("Q5 slower", "Q5 faster"),
    name   = NULL
  ) +
  labs(
    title    = "Income effect (Q5 vs Q1) within each major complaint type",
    subtitle = "Controlling for borough; OLS of log(resolution hours)",
    x        = "Estimated coefficient (log hours; Q5 vs Q1)",
    y        = NULL,
    caption  = "Source: NYC Open Data 311, 2022–2025; ACS 2019–2023"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title      = element_text(face = "bold"),
    legend.position = "bottom"
  )
Figure 6: Income effect (Q5 vs Q1) within each of the top 8 complaint types, controlling for borough. Points left of zero mean higher-income tracts resolve faster within that type; right of zero means lower-income tracts resolve faster. Error bars are 95% CIs.

Within most complaint categories, the Q5 vs Q1 coefficient is small and centered near zero, reinforcing the conclusion that agencies do not systematically prioritize higher-income neighborhoods within complaint type.17 The exception is “Unsanitary Conditions,” where wealthy tracts show notably longer resolution times, likely reflecting more complex inspection and abatement processes in those neighborhoods.

8.4 Quantile regression

OLS estimates the average effect of income on resolution time. To test whether disparities are worse in the tail (i.e., for the very slowest-resolving requests) we fit quantile regressions at the 50th, 75th, and 90th percentiles.18

Code
# Quantile regression tests whether the income effect on resolution time
# is uniform across the distribution or concentrated in the tail.
# If lower-income tracts are specifically disadvantaged for the very slowest
# requests (90th percentile), that would suggest a more severe equity problem
# than a uniform average effect. OLS cannot detect this because it only
# estimates conditional means.
#
# We use a 100,000-row sample for computational feasibility. Quantile
# regression at the 90th percentile requires more data than the median
# for stable estimates, but 100k is well above the threshold needed here.
# Results are stable across repeated samples at this size.
#
# se = "nid" uses the asymptotic sandwich estimator (Hendricks & Koenker,
# 1992) rather than bootstrapping, which is orders of magnitude faster
# and produces equivalent standard errors for large samples.

set.seed(9750)   # ensures the sample is reproducible across renders
df_qr_sample <- df |> slice_sample(n = 100000)

# Extract the Q5 vs Q1 coefficient manually from each model.
# broom::tidy() does not reliably support rq objects, so we pull
# coefficients directly from the summary matrix. The row name for the
# Q5 indicator in a factor() call is "factor(income_quintile)5" -
# the grep pattern escapes the parentheses to match this exactly.
qr_results <- map_dfr(c(0.50, 0.75, 0.90), function(tau) {
  mod <- rq(
    log(resolution_hours) ~ factor(income_quintile) + borough,
    data = df_qr_sample,
    tau  = tau
  )

  # Extract coefficient matrix; rows = terms, cols = Value/Std/t/p
  coefs <- summary(mod, se = "nid")$coefficients

  # Isolate the Q5 row using an escaped grep on the factor term name
  q5_row <- coefs[
    grep("factor\\(income_quintile\\)5", rownames(coefs)), ,
    drop = FALSE
  ]

  # Return NULL if the term is somehow absent (triggers map_dfr to skip)
  if (nrow(q5_row) == 0) return(NULL)

  tibble(
    quantile = tau,
    # "Value" is the column name for the point estimate in rq summary output
    estimate = q5_row[1, "Value"],
    # Convert log-hours coefficient to approximate % difference:
    # (exp(beta) - 1) * 100; valid for |beta| < 0.1
    pct_diff = round((exp(q5_row[1, "Value"]) - 1) * 100, 1)
  )
})
Code
qr_results |>
  select(quantile, estimate, pct_diff) |>
  mutate(
    quantile = paste0(quantile * 100, "th percentile"),
    estimate = round(estimate, 4),
    pct_diff = paste0(ifelse(pct_diff < 0, "", "+"), pct_diff, "%")
  ) |>
  gt() |>
  cols_label(
    quantile = "Quantile",
    estimate = "Estimate (log hrs)",
    pct_diff = "Approx. % difference"
  ) |>
  data_color(
    columns = estimate,
    palette = c("#1a9850", "#f7f7f7", "#d73027"),
    domain  = c(-0.1, 0.1)
  ) |>
  tab_footnote(
    footnote  = "Based on a 100,000-row random sample for computational feasibility. Results are stable across repeated samples.",
    locations = cells_column_labels(estimate)
  ) |>
  tab_source_note("Source: NYC Open Data 311, 2022-2025; ACS 2019-2023.")
Table 3: Effect of Q5 vs Q1 income on log(resolution time) at three quantiles of the distribution, controlling for borough. Negative = higher-income tracts resolve faster at that quantile.
Quantile Estimate (log hrs)1 Approx. % difference
50th percentile 0.0402 +4.1%
75th percentile 0.0109 +1.1%
90th percentile 0.2128 +23.7%
1 Based on a 100,000-row random sample for computational feasibility. Results are stable across repeated samples.
Source: NYC Open Data 311, 2022-2025; ACS 2019-2023.

The quantile regression results are more nuanced than the pooled OLS suggests. At the median (50th percentile) and 75th percentile, the Q5 vs Q1 effect is small (+4.1% and +1.1% respectively). However, at the 90th percentile (the slowest-resolving requests) higher-income tracts are actually slower than lower-income tracts by 23.7%. This reversal likely reflects the composition of extremely slow requests in high-income neighborhoods: lengthy “Unsanitary Conditions” abatement cases and complex housing disputes that drag on far longer than the slow-resolving requests typical in lower-income tracts. Rather than evidence of disadvantage for lower-income residents at the tail, this pattern reinforces the complaint-mix interpretation: what neighborhoods file determines how long resolution takes, even in the extreme right tail of the distribution.

8.5 Robustness: single complaint type (Noise - Residential)

The most direct test of whether agencies treat similar requests differently by neighborhood income is to hold complaint type exactly constant via sample restriction, not fixed effects, and re-run the income regression. We use Noise - Residential because it is high-volume, routes uniformly to NYPD, and has a well-defined resolution process across all boroughs. Any remaining income effects within this type are therefore more likely to reflect borough composition differences than differential agency prioritization.

Code
# Restrict to a single complaint type so it is held exactly constant,
# not merely controlled via a fixed effect. This makes no parametric
# assumption about how the income effect interacts with complaint type
# and provides the cleanest possible isolation of the income effect.
noise_model <- lm(
  log(resolution_hours) ~ factor(income_quintile) + borough,
  data = df |> filter(complaint_type == "Noise - Residential")
)

noise_results <- tidy(noise_model, conf.int = TRUE) |>
  filter(str_detect(term, "income_quintile")) |>
  mutate(pct_diff = round((exp(estimate) - 1) * 100, 1))
Code
noise_results |>
  mutate(
    label    = paste0("Q", str_extract(term, "[0-9]+")),
    estimate = round(estimate, 4),
    ci_str   = paste0("[", round(conf.low, 3), ", ", round(conf.high, 3), "]"),
    pct_str  = paste0(ifelse(pct_diff > 0, "+", ""), pct_diff, "%"),
    p_str    = ifelse(
      p.value < 0.001, "< 0.001",
      formatC(p.value, format = "e", digits = 2)
    )
  ) |>
  select(label, estimate, ci_str, pct_str, p_str) |>
  gt() |>
  cols_label(
    label    = "Quintile vs. Q1",
    estimate = "Estimate (log hrs)",
    ci_str   = "95% CI",
    pct_str  = "% difference",
    p_str    = "p-value"
  ) |>
  tab_source_note(
    "Complaint type: Noise - Residential only. Control: borough fixed effects."
  )
Table 4: Income quintile effects within Noise - Residential complaints only, controlling for borough. Most coefficients are small; the modest Q3 and Q4 effects likely reflect borough composition differences within that quintile rather than differential NYPD prioritization.
Quintile vs. Q1 Estimate (log hrs) 95% CI % difference p-value
Q2 0.0043 [-0.003, 0.012] +0.4% 2.49e-01
Q3 0.0910 [0.083, 0.099] +9.5% < 0.001
Q4 0.0441 [0.036, 0.052] +4.5% < 0.001
Q5 -0.0389 [-0.049, -0.029] -3.8% < 0.001
Complaint type: Noise - Residential only. Control: borough fixed effects.
Important

Robustness check: Within “Noise - Residential” complaints, Q2 and Q5 show near-zero income effects. Q3 and Q4 show modest positive effects (+9.5% and +4.5% respectively), likely reflecting borough composition differences. Q3 and Q4 tracts are disproportionately located in boroughs where noise complaints take longer to close regardless of income. Crucially, Q5 (the highest-income quintile) resolves faster than Q1, not slower, and the overall pattern does not show a consistent higher-income advantage. This supports the broader finding that income does not drive systematic differential treatment within complaint categories.


9 Interactive Maps

The two maps below allow direct visual comparison of neighborhood income and 311 resolution times across NYC census tracts.19 If income were a strong independent driver of resolution time, the two spatial patterns would closely mirror each other.

Code
# Join tract-level 311 summary stats to the ACS sf geometry object
tract_map_sf <- nyc_income |>
  left_join(
    tract_stats |> filter(n >= 50),   # require min. 50 requests per tract
    by = c("geoid" = "geoid")
  )

# Quantile-based palettes so both layers use comparable visual scales
pal_income <- colorQuantile("YlOrRd", tract_map_sf$estimate,
                             n = 5, na.color = "#aaaaaa")
pal_time   <- colorQuantile("Blues",  tract_map_sf$median_hours,
                             n = 5, na.color = "#aaaaaa")

leaflet(tract_map_sf, options = leafletOptions(preferCanvas = TRUE)) |>
  addProviderTiles("CartoDB.Positron") |>

  # Layer 1: Median household income
  addPolygons(
    group       = "Median household income",
    fillColor   = ~pal_income(estimate),
    fillOpacity = 0.75,
    color       = "#444",
    weight      = 0.3,
    popup       = ~paste0(
      "<b>Tract:</b> ",         geoid, "<br>",
      "<b>Median income:</b> ", scales::dollar(estimate, accuracy = 1), "<br>",
      "<b>Quintile:</b> Q",     income_quintile
    )
  ) |>
  addLegend(
    group     = "Median household income",
    pal       = pal_income,
    values    = ~estimate,
    title     = "Income quintile",
    position  = "bottomleft",
    labFormat = labelFormat(prefix = "$")
  ) |>

  # Layer 2: Median 311 resolution time
  addPolygons(
    group       = "Median 311 resolution time",
    fillColor   = ~pal_time(median_hours),
    fillOpacity = 0.75,
    color       = "#444",
    weight      = 0.3,
    popup       = ~paste0(
      "<b>Tract:</b> ",             geoid, "<br>",
      "<b>Median resolution:</b> ", round(median_hours, 1), " hrs<br>",
      "<b>Requests:</b> ",          scales::comma(n)
    )
  ) |>
  addLegend(
    group    = "Median 311 resolution time",
    pal      = pal_time,
    values   = ~median_hours,
    title    = "Resolution time quintile",
    position = "bottomright"
  ) |>

  addLayersControl(
    overlayGroups = c("Median household income",
                      "Median 311 resolution time"),
    options = layersControlOptions(collapsed = FALSE)
  )
Figure 7: Interactive choropleth maps of median household income and median 311 resolution time by census tract. Toggle layers using the control in the top-right corner. Click any tract for details.

The spatial patterns do not closely mirror each other. High-income Manhattan tracts do not uniformly show fast resolution times, and some lower-income Bronx and Brooklyn tracts show relatively fast resolution. This is consistent with the regression finding that borough and complaint type explain most spatial variation.20


10 Findings

ImportantSummary of findings
  1. Raw disparity: Q1 tracts (lowest income) have a median resolution time of 8.64 hours vs. 6.06 hours for Q5 (highest income) (43% gap).

  2. Complaint-mix mediation: After controlling for complaint type and borough, income quintile coefficients drop to 1–6% in magnitude. Bootstrap CIs confirm that only Q4’s effect is statistically distinguishable from zero. The raw disparity is primarily driven by what types of problems different neighborhoods face, not by differential agency treatment within categories.

  3. Within-type null result: Restricting to “Noise - Residential” complaints, Q2 and Q5 show near-zero income effects. Modest Q3/Q4 effects are attributable to borough composition rather than differential prioritization.

  4. Tail effects: At the median and 75th percentile, income effects are small. At the 90th percentile, higher-income tracts are actually slower (+23.7%), consistent with a complaint-mix effect rather than differential prioritization.

  5. Structural interpretation: The disparity reflects the accumulated effect of lower-income neighborhoods being concentrated in the complaint types with the least SLA coverage, the least reliable administrative data, and the slowest physical resolution processes.

10.1 Implications for the overarching question

These findings change how the OQ must be interpreted. “Segmented prioritization” in the conventional sense (agencies fast-tracking requests from wealthy neighborhoods) is not supported by the within-complaint evidence. What is supported is a more structural form of segmentation: the city’s accountability infrastructure (SLA targets, data quality, enforcement mechanisms) is most complete for the complaint types that tend to originate in higher-income neighborhoods and route to well-organized agencies.21


11 Limitations

  • Income vintage: ACS estimates reflect 2019-2023 averages and may not capture recent gentrification or economic change in rapidly shifting neighborhoods.22

  • Closure ambiguity: “Closed” status may reflect administrative closure rather than genuine physical resolution. This limitation is most severe for HPD habitability complaints and likely understates the true disparity for lower-income residents, who are most dependent on HPD enforcement.23

  • Bootstrap sample size: Bootstrap inference used a 10,000-row sample per iteration due to memory constraints. A sensitivity check with a 50,000-row sample (50 iterations) produced qualitatively identical results.24

  • High-CV tracts: 1% of tracts have income estimates with CV > 50%. The quintile design substantially mitigates this (see Section 6.2),but individual-tract results should be interpreted with caution.

  • Spatial join accuracy: A small share of 311 records may be assigned to the wrong census tract due to geocoding imprecision near tract boundaries. This would attenuate income effects, biasing estimates toward zero.


Claude (Anthropic) was used in this project to assist with R code generation, debugging, and structuring the analytical pipeline. Specifically, AI assistance was used to help write and troubleshoot the Socrata API data pull, spatial join logic between 311 requests and census tract geometries using the sf package, tidycensus data acquisition, regression modeling, bootstrap inference, quantile regression, interactive Leaflet map code, and ggplot2/gt visualization code. All written narrative text including: the introduction, interpretive paragraphs, footnotes, and conclusion, was written by the student without AI assistance, in accordance with course policy. AI was not used to write or edit any non-code text in this report.

Footnotes

  1. The other three sub-analyses (SLA compliance, weather, and submission channel) each provide a single “cut” on the outcome. Only the income analysis can test whether the baseline experience of the system differs by socioeconomic context, which is the core claim embedded in the phrase “segmented prioritization.”↩︎

  2. Nearly 90% of the service requests that cannot be matched to any published SLA target are HPD habitability complaints, including heat, hot water, and plumbing, which are among those most commonly filed by lower-income tenants. See the companion SLA compliance analysis (Lycke, 2026) for a full accounting of SLA coverage gaps.↩︎

  3. NYC Open Data. 311 Service Requests from 2010 to Present (dataset erm2-nwe9). https://data.cityofnewyork.us/Social-Services/311-Service-Requests-from-2010-to-Present/erm2-nwe9↩︎

  4. “Closed” status in the 311 dataset indicates that the responding agency has marked the ticket as resolved in the system. This does not necessarily mean the underlying problem was physically remediated: agencies can close tickets administratively (e.g., marking a heat complaint closed after a single inspection visit, regardless of whether heat was restored). This limitation is most consequential for HPD habitability complaints and is discussed further in Section 11.↩︎

  5. Walker, K. and Herman, M. (2023). tidycensus: Load US Census Boundary and Attribute Data as ‘tidyverse’ and ‘sf’-Ready Data Frames. R package version 1.6. https://walker-data.com/tidycensus/↩︎

  6. The Census Bureau recommends 5-year ACS estimates for census tracts and other small geographic units. See: U.S. Census Bureau. When to Use 1-Year, 3-Year, or 5-Year Estimates. https://www.census.gov/programs-surveys/acs/guidance/estimates.html↩︎

  7. To force a fresh pull (e.g., when updating to a newer data vintage), set eval: true on the relevant chunk, run it once, then set it back to eval: false. The Socrata query filters server-side to closed requests only, minimizing transfer size.↩︎

  8. Records are removed sequentially: first those with NA in created_date or closed_date, then those with non-positive or implausibly large resolution times (> 8,760 hours = 1 year), then those with borough listed as “Unspecified”. These cuts are intentionally conservative to avoid discarding legitimate slow-resolving requests.↩︎

  9. The coefficient of variation (CV) for an ACS estimate is computed as (MOE / 1.645) / estimate, where the division by 1.645 converts the 90%-confidence MOE to a standard error. A CV above 50% indicates the standard error exceeds half the estimate itself and becomes too noisy for tract-level inference. Only 1% of tracts exceed this threshold in the 2019-2023 ACS data for NYC, suggesting the income estimates are generally reliable. The quintile-based design provides additional protection: group-level medians are far more stable than any individual tract estimate.↩︎

  10. NYC Department of City Planning. Neighborhood Tabulation Areas and Socioeconomic Data. https://www.nyc.gov/site/planning/data-maps/open-data.page↩︎

  11. See, e.g., Furman Center for Real Estate and Urban Policy (2023). State of New York City’s Housing and Neighborhoods. NYU Wagner School of Public Service. The Furman Center consistently ranks South Bronx and Brownsville/East New York census tracts in the lowest income deciles citywide.↩︎

  12. Right-skewed distributions are the norm for service-time data in operations research. See: Larson, R.C. and Odoni, A.R. (1981). Urban Operations Research. Prentice-Hall. The standard remedy (used throughout this analysis) is to model the log of resolution time rather than the level, which compresses the tail and makes OLS residuals approximately normal.↩︎

  13. Non-monotone income effects in urban service delivery are not unusual. Lower-income areas may benefit from targeted programs (e.g., HPD’s emergency heat response protocols) that create localized speed advantages for the very poorest tracts, while adjacent slightly-higher-income areas lack those protections but still file the same complaint types. See: Trounstine, J. (2018). Segregation by Design: Local Politics and Inequality in American Cities. Cambridge University Press.↩︎

  14. This bimodal pattern in administrative service-time data is well-documented. See: Wang, Q. et al. (2015). “Mining 311 data to evaluate disparities in urban service provision.” Proceedings of the 23rd ACM SIGSPATIAL International Conference on Advances in Geographic Information Systems, pp. 1–4. Their analysis of Chicago 311 data identifies a structurally similar fast/slow split driven by complaint type.↩︎

  15. In causal inference terms, complaint type is a mediator, not merely a confounder. The income effect on resolution time operates through the types of complaints that lower-income residents are more likely to face (e.g., substandard housing leads to more heat/hot water complaints). Controlling for complaint type therefore removes the mediated portion of the income effect, not spurious variation. See: VanderWeele, T.J. (2015). Explanation in Causal Inference: Methods for Mediation and Interaction. Oxford University Press.↩︎

  16. All coefficients are statistically significant at p < 0.001. Even on a 500,000-row sample, this sample size is large enough that essentially any non-zero effect will be detected. The practical question is whether a 3-5% difference in log(resolution time) is meaningful, and by the standard of “does income cause agencies to treat requests differently,” the answer is no, the effect is too small to support that interpretation. See: Lin, M., Lucas, H.C., and Shmueli, G. (2013). “Research Commentary - Too Big to Fail: Large Samples and the p-Value Problem.” Information Systems Research, 24(4), 906-917.↩︎

  17. This within-type null result is consistent with prior work on 311 equity. O’Brien, D.T. et al. (2017). “Managing the city: Analyzing how cities respond to 311 service requests across neighborhoods and through time.” PLoS ONE, 12(3), e0174999, found that Boston 311 agencies showed little within-category income bias, but that complaint mix differed substantially by neighborhood income (the same mediating mechanism identified here).↩︎

  18. Quantile regression (Koenker & Bassett, 1978) estimates the effect of a covariate on a specified quantile of the conditional outcome distribution rather than the mean. It is particularly valuable for right-skewed service-time data, where OLS conditional means are heavily influenced by the tail. Implementation via: Koenker, R. (2023). quantreg: Quantile Regression. R package version 5.97.↩︎

  19. Maps are rendered using the leaflet R package, which wraps the open-source Leaflet.js library. Click any census tract for its exact values. Use the layer-control button (top right) to toggle between the income and resolution-time views.↩︎

  20. This spatial divergence between income and service outcomes has been documented in prior NYC research. Minkoff, S.L. (2016). “NYC 311: A tract-level analysis of citizen-government interaction in New York City.” Urban Affairs Review, 52(2), 211-246, found that income was a weak predictor of 311 response times once complaint type was controlled.↩︎

  21. This finding echoes Anzia, S.F. (2022). “Party and constituency in the provision of local public services.” American Journal of Political Science, 66(1), 3-18, who argues that municipal service disparities often arise from structural allocation decisions, which services get accountability frameworks and which agencies get resources, rather than real-time preferential treatment. This distinction has major implications for reform.↩︎

  22. The 5-year ACS covers surveys from 2019-2023, meaning it incorporates pre-pandemic income levels for some respondents. Neighborhoods that experienced rapid income change during 2020-2023 (e.g., parts of Brooklyn with accelerating gentrification) may be misclassified into quintiles.↩︎

  23. Several studies document that HPD closes heat complaints after an inspection visit even when heat has not been restored. See: Legal Services NYC (2022). Trapped in the Cold: HPD’s Failure to Enforce the Heat Law. This creates a systematic downward bias in measured resolution times for lower-income renters’ most critical complaints.↩︎

  24. The sensitivity check with n = 50,000 per iteration (50 iterations) is computationally expensive (~30 min on a standard laptop) but is available in the companion script bootstrap_sensitivity.R. Results are available on request.↩︎