Analysis

49 - Transit Service & Boardings vs Population Density

Equity and Strategic Planning

Coverage: Coverage window unavailable for this page.

Built 2026-06-15 11:52 UTC · Commit e5cf673

Page Navigation

Analysis Navigation

Data Provenance

flowchart LR
  49_transit_service_density(["49 - Transit Service & Boardings vs Population Density"])
  f1_49_transit_service_density[/"data/bus-stop-usage/wprdc_stop_data.csv"/] --> 49_transit_service_density
  t_route_stops[("route_stops")] --> 49_transit_service_density
  01_data_ingestion[["Data Ingestion"]] --> t_route_stops
  u1_01_data_ingestion[/"data/routes_by_month.csv"/] --> 01_data_ingestion
  u2_01_data_ingestion[/"data/PRT_Current_Routes_Full_System_de0e48fcbed24ebc8b0d933e47b56682.csv"/] --> 01_data_ingestion
  u3_01_data_ingestion[/"data/Transit_stops_(current)_by_route_e040ee029227468ebf9d217402a82fa9.csv"/] --> 01_data_ingestion
  u4_01_data_ingestion[/"data/PRT_Stop_Reference_Lookup_Table.csv"/] --> 01_data_ingestion
  u5_01_data_ingestion[/"data/average-ridership/12bb84ed-397e-435c-8d1b-8ce543108698.csv"/] --> 01_data_ingestion
  t_routes[("routes")] --> 49_transit_service_density
  01_data_ingestion[["Data Ingestion"]] --> t_routes
  t_stops[("stops")] --> 49_transit_service_density
  01_data_ingestion[["Data Ingestion"]] --> t_stops
  t_census_tracts[("census_tracts")] --> 49_transit_service_density
  d1_49_transit_service_density(("geopandas (lib)")) --> 49_transit_service_density
  d2_49_transit_service_density(("polars (lib)")) --> 49_transit_service_density
  d3_49_transit_service_density(("scipy (lib)")) --> 49_transit_service_density
  classDef page fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a,stroke-width:2px;
  classDef table fill:#ecfeff,stroke:#0e7490,color:#164e63;
  classDef dep fill:#fff7ed,stroke:#c2410c,color:#7c2d12,stroke-dasharray: 4 2;
  classDef file fill:#eef2ff,stroke:#6366f1,color:#3730a3;
  classDef api fill:#f0fdf4,stroke:#16a34a,color:#14532d;
  classDef pipeline fill:#f5f3ff,stroke:#7c3aed,color:#4c1d95;
  class 49_transit_service_density page;
  class t_census_tracts,t_route_stops,t_routes,t_stops table;
  class d1_49_transit_service_density,d2_49_transit_service_density,d3_49_transit_service_density dep;
  class f1_49_transit_service_density,u1_01_data_ingestion,u2_01_data_ingestion,u3_01_data_ingestion,u4_01_data_ingestion,u5_01_data_ingestion file;
  class 01_data_ingestion pipeline;

Findings

Findings: Transit Service & Boardings vs Population Density

Summary

Bus service scales strongly with residential density — denser Allegheny County tracts get far more service, not just a nearer stop (Spearman ρ = 0.49 between density and weekly bus trips). Boardings follow the same density gradient (ρ = 0.56) and track service almost perfectly (ρ = 0.90), but they diverge sharply from raw population: a tract's headcount barely predicts its boardings (ρ = −0.14), and boardings are far more concentrated than people — the densest quartile of tracts holds 23% of residents but generates 73% of boardings, and Downtown alone accounts for 27%.

Key Numbers

  • 394 tracts analyzed (386 populated), Allegheny County. 139,221 average weekday boardings total (Sept 2019), of which 138,780 fall inside a tract.
  • Service vs density: Spearman ρ = 0.49 (p ≈ 7e-25). Median weekly bus trips by density quartile: 237 → 2,400 → 3,475 → 6,104 — a ~26× rise from the sparsest to the densest quarter of tracts.
  • Boardings vs density: Spearman ρ = 0.56 (p ≈ 2e-33). Median weekday boardings by quartile: 3.8 → 44 → 92 → 330.
  • Boardings vs population: Spearman ρ = −0.14 — essentially no relationship. Boardings vs service: ρ = 0.90.
  • Boardings per 1,000 residents by density quartile: 0.7 → 12 → 44 → 122 — a ~180× gradient, far steeper than the service gradient.
  • Concentration: the densest quartile (22.7% of county population) produces 72.9% of boardings; the top 10 tracts produce 46.6%; the Central Business District alone produces 26.6%.

Observations

  • Q1 — service tracks density. Service is not just present in dense tracts, it is concentrated there. The 26× quartile gradient in weekly trips is steeper than the proximity gradient Analysis 46 found, so the answer is unambiguous: dense neighborhoods get both a closer stop and much more service on it.
  • Q2 — boardings follow density but diverge from population. Boardings rise with density at almost the same rank-correlation strength as service, and they shadow service itself extremely closely (ρ = 0.90). But the relationship is strongly super-linear: boardings per resident climb ~180× across density quartiles, so dense tracts board far more than their share of people. Raw population is a non-predictor (ρ = −0.14) because census tracts are drawn to hold roughly equal populations — what varies is density and destination role.
  • The biggest over-performers are destinations, not dense housing. The Central Business District boards 36,202 riders a day — 27% of the county total — against only ~142 predicted by its (moderate) residential density. Other large over-performers are near-empty riverfront/industrial tracts (Chateau, 12 residents; South Shore, 47) and the airport-area Findlay township. Boardings are recorded where trips start, which concentrates them at employment and activity hubs.
  • The under-performers are the light-rail South Hills. The tracts that board far below their density (Bethel Park, Castle Shannon, Mount Lebanon) are light-rail-served: their residents board the "T", which the bus-stop dataset does not cover. These are flagged rail_served in the output.

Discussion

Analysis 46 showed PRT puts stops where the people are. This analysis shows the same logic governs service volume: dense tracts get disproportionately more bus service, and riders respond — boardings follow service almost one-for-one. In that sense boardings do not diverge from the density pattern; they amplify it.

The divergence is one of degree and driver. Boardings are far more unequal than either population or service: they are destination-driven, so they pile up in a handful of dense activity centers — above all Downtown — rather than spreading across residential tracts in proportion to headcount. A planner reading this should note that "serve the dense places" and "serve the most residents" are not the same instruction: the boarding map is dominated by where people go, not only where they live.

This is an area-level (ecological) association. It does not show that individuals in dense tracts ride more — only that dense tracts, as places, generate more boardings.

Caveats

  • Bus-only. The WPRDC stop-usage dataset covers bus and busway service; light rail has boarding data for a single stop. Both the service and the boardings metrics are therefore restricted to bus service. Light-rail-corridor tracts (notably the South Hills) genuinely show near-zero bus boardings because their riders use the "T"; they are flagged rail_served.
  • Temporal mismatch. Boardings are September 2019; bus trip counts are the current GTFS snapshot; population is the ACS 2018–2022 estimate. The analysis assumes the spatial pattern is stable, not that levels are contemporaneous.
  • Stop-to-tract assignment is point-in-polygon. A stop on a tract boundary counts entirely for one tract. Highland Park is the clearest artifact: it has 1,580 weekly bus trips but ~0 recorded boardings because its service runs on perimeter corridors assigned to neighboring tracts and its interior is largely parkland.
  • weekly_trips counts scheduled trips, not capacity or time-of-day frequency.
  • Zero-population tracts are excluded from quartiles and correlations.

Validation

  • Data sources verified. route_stops, routes, stops, and census_tracts columns checked against the live schema; the boardings CSV schema inspected directly (long format, datekey/serviceday/avg_ons).
  • System total checked. Summed boardings = 139,221 average weekday boardings, consistent with PRT's known ~130–135k pre-pandemic weekday ridership. Tract count (394) matches Analysis 46.
  • Scope checked. Only 26 service / 35 boardings stops fall outside all Allegheny tracts — a small, expected edge effect (stops just over county/state lines).
  • Surprising result investigated. Tracts with high service but zero boardings were traced to a data-source limitation: the bus-stop-usage dataset excludes light rail (1 rail stop present vs 98 in the network). The service metric was restricted to bus service to match, and rail-served tracts are flagged rather than silently treated as poorly-used.
  • Direction of effects checked. Service-vs-density and boardings-vs-density correlations are both positive, consistent with Analysis 46; the near-zero boardings-vs-population correlation is explained by census tracts being drawn to roughly equal population.
  • Ecological framing applied throughout — results describe tracts, not individuals.

Output

Methods

Methods: Transit Service & Boardings vs Population Density

Question

Analysis 46 showed that denser Allegheny County census tracts sit closer to a PRT stop — routes are placed near high-density areas. Two follow-up questions:

  1. Service. Does the amount of transit service at a tract scale with its population density, or do dense areas just get a nearby stop without correspondingly more service?
  2. Boardings. Do boardings follow the same density gradient, or do they diverge from residential population density?

The unit of analysis is the census tract, mirroring Analysis 46 so the three gradients (proximity, service, boardings) are directly comparable.

Approach

  1. Tracts. Load Allegheny County tracts (county_fips = '003') from census_tracts via prt_otp_analysis.walksheds.load_tracts (polygons in EPSG:32617, meters). Population density = population / land_area_km2.
  2. Service. From route_stops, sum trips_7d (weekly scheduled trips) across all routes and directions at each stop. Point-in-polygon assign each stop to its tract and sum to get weekly scheduled trips serving the tract.
  3. Boardings. From the WPRDC bus-stop-usage data, take the pre-pandemic weekday snapshot (datekey = 201909, serviceday = Weekday), sum avg_ons across routes per physical stop, assign stops to tracts, and sum to get average weekday boardings within the tract.
  4. Spatial join. Stops are matched to tracts with a point-in-polygon join (geopandas.sjoin, predicate="within"). Tracts with no stops keep a true zero for service and boardings rather than being dropped.
  5. Correlation. Spearman rank correlation of population density against (a) weekly trips and (b) boardings, plus a population-density-quartile gradient for each. Expected sign is positive — denser tracts better served.
  6. Divergence (Q2). Compare boardings against population as well as density: per-1,000-resident boarding rate by density quartile, a log-log regression of boardings on population, and the tracts with the largest positive/negative residuals — those that board far above or below what their resident population predicts.

Data

  • census_tracts (prt.db) — geoid, county_fips, population, land_area_m2, tract polygon. Filtered to Allegheny County.
  • route_stops (prt.db) — stop_id, trips_7d (weekly scheduled trips).
  • stops (prt.db) — stop_id, lat, lon. Null coordinates excluded.
  • data/bus-stop-usage/wprdc_stop_data.csv — WPRDC stop-level boardings. Slice used: datekey = 201909, serviceday = Weekday (pre-pandemic); avg_ons is average daily boardings; the CSV carries its own latitude/ longitude per stop.

Output

  • output/tract_service_boardings.csv — per tract: population, density, weekly trips, boardings, boardings per 1,000 residents, density quartile, population-predicted boardings, and regression residual.
  • output/boardings_outlier_tracts.csv — tracts that board most above and below what their population predicts.
  • output/quartile_summary.csv — per-density-quartile medians.
  • output/density_vs_service.png — scatter of density vs weekly trips.
  • output/service_by_density_quartile.pngmedian weekly trips by quartile.
  • output/density_vs_boardings.png — scatter of density vs boardings.
  • output/boardings_by_density_quartile.pngmedian boardings by quartile.
  • output/boardings_per_capita_by_quartile.pngmedian boardings per 1,000 residents by quartile (the divergence visual).
  • output/boardings_vs_population_residuals.png — log-log boardings vs population with fitted line and labeled outlier tracts.

Caveats

  • Results are area-level (ecological) associations: they describe census tracts, not individual residents or their travel behavior.
  • Temporal mismatch. Boardings are September 2019; route_stops trip counts are the current GTFS snapshot; ACS population is the 2018–2022 5-year estimate. The analysis assumes the spatial pattern of density, service, and ridership is reasonably stable across this window even though absolute levels are not contemporaneous. This is the largest caveat.
  • A boarding is recorded where a trip starts, which is weighted toward employment and activity centers (Downtown, Oakland) as well as residences — so a gap between boardings and residential density is expected, and is the point of the divergence analysis rather than a data flaw.
  • weekly_trips counts scheduled trips, not vehicle capacity or time-of-day frequency: many low-frequency stops can sum to the same value as one high-frequency stop.

Source Code

"""Analysis 49: test whether transit service and boardings track Allegheny
County census-tract population density, the way stop proximity does (Analysis
46) -- and whether boardings diverge from residential density."""

from pathlib import Path

import geopandas as gpd
import numpy as np
import polars as pl
from scipy import stats

from prt_otp_analysis.common import (
    COLOR_GRAY,
    COLOR_PRIMARY,
    DATA_DIR,
    output_dir,
    phase,
    query_to_polars,
    run_analysis,
    save_chart,
    save_csv,
    setup_plotting,
)
from prt_otp_analysis.walksheds import CRS_GEO, CRS_M, load_tracts

HERE = Path(__file__).resolve().parent
OUT = output_dir(HERE)

ALLEGHENY_FIPS = "003"
BOARDINGS_DATEKEY = 201909  # September 2019 -- canonical pre-pandemic month
BOARDINGS_SERVICEDAY = "Weekday"
QUARTILE_LABELS = ["Q1\n(sparsest)", "Q2", "Q3", "Q4\n(densest)"]


def load_service_stops() -> pl.DataFrame:
    """Weekly scheduled bus trips per stop, with coordinates and a label.

    Restricted to bus (and busway) service: the WPRDC boardings dataset is
    bus-only, so light-rail and incline service are excluded to keep the
    service and boardings metrics on the same footing.
    """
    rs_df = query_to_polars(
        "SELECT rs.stop_id, rs.trips_7d FROM route_stops rs "
        "JOIN routes r ON rs.route_id = r.route_id WHERE r.mode = 'BUS'"
    )
    service_df = rs_df.group_by("stop_id").agg(weekly_trips=pl.col("trips_7d").sum())
    stops_df = query_to_polars(
        "SELECT stop_id, lat, lon, hood, muni FROM stops "
        "WHERE lat IS NOT NULL AND lon IS NOT NULL"
    )
    return stops_df.join(service_df, on="stop_id", how="left").with_columns(
        weekly_trips=pl.col("weekly_trips").fill_null(0)
    )


def load_boardings_stops() -> pl.DataFrame:
    """Average weekday boardings per physical stop (pre-pandemic), with coordinates."""
    csv_path = DATA_DIR / "bus-stop-usage" / "wprdc_stop_data.csv"
    df = pl.read_csv(csv_path, null_values=["NA", ""], infer_schema_length=20000)
    slc = df.filter(
        (pl.col("datekey") == BOARDINGS_DATEKEY)
        & (pl.col("serviceday") == BOARDINGS_SERVICEDAY)
    )
    return slc.group_by("stop_id").agg(
        boardings=pl.col("avg_ons").fill_null(0).sum(),
        lat=pl.col("latitude").first(),
        lon=pl.col("longitude").first(),
    )


def sum_to_tracts(
    points_df: pl.DataFrame, tracts_gdf: gpd.GeoDataFrame, value_col: str
) -> tuple[pl.DataFrame, int]:
    """Point-in-polygon assign stops to tracts; return per-tract sums and the
    count of stops that fell outside every tract."""
    pdf = points_df.to_pandas()
    gdf = gpd.GeoDataFrame(
        pdf, geometry=gpd.points_from_xy(pdf["lon"], pdf["lat"]), crs=CRS_GEO
    ).to_crs(CRS_M)
    joined = gpd.sjoin(
        gdf, tracts_gdf[["geoid", "geometry"]], how="left", predicate="within"
    )
    n_unmatched = int(joined["geoid"].isna().sum())
    matched_df = pl.from_pandas(joined.dropna(subset=["geoid"])[["geoid", value_col]])
    summed = matched_df.group_by("geoid").agg(pl.col(value_col).sum())
    return summed, n_unmatched


def tract_neighborhood_labels(
    service_df: pl.DataFrame, tracts_gdf: gpd.GeoDataFrame
) -> pl.DataFrame:
    """Most common neighborhood/municipality among each tract's stops, for labels."""
    pdf = service_df.to_pandas()
    gdf = gpd.GeoDataFrame(
        pdf, geometry=gpd.points_from_xy(pdf["lon"], pdf["lat"]), crs=CRS_GEO
    ).to_crs(CRS_M)
    joined = gpd.sjoin(
        gdf, tracts_gdf[["geoid", "geometry"]], how="inner", predicate="within"
    )
    labelled = pl.from_pandas(
        joined[["geoid", "hood", "muni"]]
    ).with_columns(
        area=pl.coalesce(pl.col("hood"), pl.col("muni"))
    )
    return (
        labelled.drop_nulls("area")
        .group_by("geoid", "area")
        .len()
        .sort("len", descending=True)
        .group_by("geoid")
        .agg(area_label=pl.col("area").first())
    )


def rail_served_geoids(tracts_gdf: gpd.GeoDataFrame) -> set[str]:
    """GEOIDs of tracts that contain at least one light-rail stop.

    Light-rail-corridor tracts board mostly on the 'T', which the bus-stop-usage
    dataset does not cover; flagging them keeps the boardings results honest.
    """
    rail_df = query_to_polars(
        "SELECT DISTINCT s.stop_id, s.lat, s.lon FROM stops s "
        "JOIN route_stops rs ON s.stop_id = rs.stop_id "
        "JOIN routes r ON rs.route_id = r.route_id "
        "WHERE r.mode = 'RAIL' AND s.lat IS NOT NULL"
    )
    pdf = rail_df.to_pandas()
    gdf = gpd.GeoDataFrame(
        pdf, geometry=gpd.points_from_xy(pdf["lon"], pdf["lat"]), crs=CRS_GEO
    ).to_crs(CRS_M)
    joined = gpd.sjoin(
        gdf, tracts_gdf[["geoid", "geometry"]], how="inner", predicate="within"
    )
    return set(joined["geoid"])


def build_tract_table(
    tracts_gdf: gpd.GeoDataFrame,
    service_by_tract: pl.DataFrame,
    boardings_by_tract: pl.DataFrame,
    labels_df: pl.DataFrame,
    rail_geoids: set[str],
) -> pl.DataFrame:
    """One row per Allegheny tract: density, service, boardings, and quartile."""
    tract_df = pl.from_pandas(
        tracts_gdf[["geoid", "population", "land_area_m2"]]
    ).with_columns(rail_served=pl.col("geoid").is_in(list(rail_geoids)))
    tract_df = (
        tract_df.join(service_by_tract, on="geoid", how="left")
        .join(boardings_by_tract, on="geoid", how="left")
        .join(labels_df, on="geoid", how="left")
        .with_columns(
            weekly_trips=pl.col("weekly_trips").fill_null(0),
            boardings=pl.col("boardings").fill_null(0.0),
            pop_density=pl.col("population") / (pl.col("land_area_m2") / 1_000_000),
        )
        .with_columns(
            boardings_per_1000=pl.when(pl.col("population") > 0)
            .then(1000 * pl.col("boardings") / pl.col("population"))
            .otherwise(None),
        )
    )
    # Density quartile (1 = sparsest, 4 = densest); only for populated tracts.
    populated = tract_df.filter(pl.col("population") > 0)
    populated = populated.with_columns(
        density_quartile=(
            (pl.col("pop_density").rank(method="ordinal") - 1)
            / populated.height
            * 4
        )
        .floor()
        .clip(0, 3)
        .cast(pl.Int64)
        + 1
    )
    return tract_df.join(
        populated.select("geoid", "density_quartile"), on="geoid", how="left"
    )


def fit_residuals(tract_df: pl.DataFrame) -> pl.DataFrame:
    """Log-log regression of boardings on density; attach predicted + residual.

    Residuals isolate tracts that board far above or below what their
    residential density predicts -- the destination-driven divergence.
    """
    populated = tract_df.filter(pl.col("population") > 0)
    x = np.log10(populated["pop_density"].to_numpy())
    y = np.log10(populated["boardings"].to_numpy() + 1)
    slope, intercept, r, p, _ = stats.linregress(x, y)
    expected = 10 ** (slope * x + intercept) - 1
    resid = y - (slope * x + intercept)
    fitted = populated.select("geoid").with_columns(
        expected_boardings=pl.Series(expected),
        boardings_resid=pl.Series(resid),
    )
    out = tract_df.join(fitted, on="geoid", how="left")
    return out, {"slope": slope, "intercept": intercept, "r": r, "p": p}


def analyze(tract_df: pl.DataFrame) -> dict:
    """Spearman correlations and density-quartile gradients."""
    populated = tract_df.filter(pl.col("population") > 0)
    rho_serv, p_serv = stats.spearmanr(
        populated["pop_density"], populated["weekly_trips"]
    )
    rho_board, p_board = stats.spearmanr(
        populated["pop_density"], populated["boardings"]
    )
    rho_board_pop, p_board_pop = stats.spearmanr(
        populated["population"], populated["boardings"]
    )
    rho_serv_pop, _ = stats.spearmanr(
        populated["population"], populated["weekly_trips"]
    )
    quartile_df = (
        populated.group_by("density_quartile")
        .agg(
            n_tracts=pl.len(),
            median_density=pl.col("pop_density").median(),
            median_weekly_trips=pl.col("weekly_trips").median(),
            median_boardings=pl.col("boardings").median(),
            median_boardings_per_1000=pl.col("boardings_per_1000").median(),
        )
        .sort("density_quartile")
    )
    return {
        "n_populated": populated.height,
        "rho_serv": rho_serv,
        "p_serv": p_serv,
        "rho_board": rho_board,
        "p_board": p_board,
        "rho_board_pop": rho_board_pop,
        "p_board_pop": p_board_pop,
        "rho_serv_pop": rho_serv_pop,
        "quartile_df": quartile_df,
    }


def chart_scatter(tract_df: pl.DataFrame, y_col: str, ylabel: str,
                  title: str, rho: float, p: float, path: Path,
                  log_y: bool = False) -> None:
    """Density-vs-metric scatter, annotated with the Spearman correlation."""
    plt = setup_plotting()
    populated = tract_df.filter(pl.col("population") > 0)
    fig, ax = plt.subplots(figsize=(10, 7))
    ax.scatter(
        populated["pop_density"].to_list(),
        populated[y_col].to_list(),
        color=COLOR_PRIMARY, s=28, alpha=0.6, edgecolors="white", linewidths=0.5,
    )
    ax.set_xscale("log")
    if log_y:
        ax.set_yscale("symlog")
    ax.set_xlabel("Tract population density (people / km², log scale)")
    ax.set_ylabel(ylabel)
    ax.set_title(title)
    ax.annotate(
        f"Spearman rho = {rho:.3f} (p = {p:.3g})",
        xy=(0.04, 0.93), xycoords="axes fraction", fontsize=10,
    )
    save_chart(fig, path)


def chart_quartile_bar(quartile_df: pl.DataFrame, col: str, ylabel: str,
                       title: str, path: Path, fmt: str = ",.0f") -> None:
    """Bar chart of a metric's median across density quartiles."""
    plt = setup_plotting()
    fig, ax = plt.subplots(figsize=(9, 6))
    vals = quartile_df[col].to_list()
    ax.bar(QUARTILE_LABELS, vals, color=COLOR_PRIMARY)
    ax.set_ylabel(ylabel)
    ax.set_title(title)
    for i, v in enumerate(vals):
        ax.text(i, v, f"{v:{fmt}}", ha="center", va="bottom", fontsize=9)
    save_chart(fig, path)


def chart_residuals(tract_df: pl.DataFrame, outliers: pl.DataFrame,
                    fit: dict, path: Path) -> None:
    """Log-log boardings-vs-density scatter with fit line and labeled outliers."""
    plt = setup_plotting()
    populated = tract_df.filter(pl.col("population") > 0)
    dens = populated["pop_density"].to_numpy()
    board = populated["boardings"].to_numpy()
    fig, ax = plt.subplots(figsize=(10, 7))
    ax.scatter(dens, board + 1, color=COLOR_GRAY, s=24, alpha=0.5,
               edgecolors="white", linewidths=0.5)
    xs = np.array([dens.min(), dens.max()])
    ys = 10 ** (fit["slope"] * np.log10(xs) + fit["intercept"])
    ax.plot(xs, ys, color=COLOR_PRIMARY, linewidth=2,
            label="Boardings predicted by population density")
    # Label the over-boarding (positive-residual) outliers; the under-boarding
    # tracts all pile up at zero boardings, so labelling them just overprints.
    for row in outliers.filter(pl.col("boardings_resid") > 0).iter_rows(named=True):
        ax.annotate(
            str(row["area_label"] or row["geoid"]),
            xy=(row["pop_density"], row["boardings"] + 1),
            xytext=(4, 3), textcoords="offset points",
            fontsize=7.5, alpha=0.9,
        )
    ax.set_xscale("log")
    ax.set_yscale("log")
    ax.set_xlabel("Tract population density (people / km², log scale)")
    ax.set_ylabel("Average weekday boardings + 1 (log scale)")
    ax.set_title("Boardings vs Density: Tracts That Over- and Under-Board\n"
                 "(Allegheny County census tracts)")
    ax.legend(loc="lower right", fontsize=9)
    save_chart(fig, path)


@run_analysis(49, "Transit Service & Boardings vs Population Density")
def main() -> None:
    """Load data, assign stops to tracts, correlate, chart, and save."""
    with phase("Loading Allegheny County tracts and stop data"):
        tracts_gdf = load_tracts(extra_cols=["county_fips"])
        tracts_gdf = tracts_gdf[tracts_gdf["county_fips"] == ALLEGHENY_FIPS].copy()
        service_stops_df = load_service_stops()
        boardings_stops_df = load_boardings_stops()
        print(f"  {len(tracts_gdf)} tracts, {service_stops_df.height} service stops, "
              f"{boardings_stops_df.height} boardings stops")
        print(f"  total pre-pandemic weekday boardings: "
              f"{boardings_stops_df['boardings'].sum():,.0f}")

    with phase("Assigning stops to tracts"):
        service_by_tract, n_unmatched_serv = sum_to_tracts(
            service_stops_df.select("stop_id", "lat", "lon", "weekly_trips"),
            tracts_gdf, "weekly_trips",
        )
        boardings_by_tract, n_unmatched_board = sum_to_tracts(
            boardings_stops_df, tracts_gdf, "boardings",
        )
        labels_df = tract_neighborhood_labels(service_stops_df, tracts_gdf)
        rail_geoids = rail_served_geoids(tracts_gdf)
        print(f"  stops outside all Allegheny tracts: "
              f"{n_unmatched_serv} service, {n_unmatched_board} boardings")

    with phase("Building tract table"):
        tract_df = build_tract_table(
            tracts_gdf, service_by_tract, boardings_by_tract, labels_df,
            rail_geoids,
        )
        tract_df, fit = fit_residuals(tract_df)
        assigned_board = tract_df["boardings"].sum()
        print(f"  {tract_df.height} tracts ({len(rail_geoids)} light-rail-served); "
              f"{assigned_board:,.0f} boardings assigned to tracts")

    with phase("Analyzing"):
        results = analyze(tract_df)
        print(f"  Q1 service: density vs weekly trips "
              f"Spearman rho = {results['rho_serv']:.3f} "
              f"(p = {results['p_serv']:.3g}), n = {results['n_populated']}")
        print(f"  Q2 boardings: density vs boardings "
              f"Spearman rho = {results['rho_board']:.3f} "
              f"(p = {results['p_board']:.3g})")
        print(f"     boardings vs population Spearman rho = "
              f"{results['rho_board_pop']:.3f}")
        for row in results["quartile_df"].iter_rows(named=True):
            print(f"    Q{row['density_quartile']}: "
                  f"{row['median_weekly_trips']:,.0f} weekly trips, "
                  f"{row['median_boardings']:,.0f} boardings, "
                  f"{row['median_boardings_per_1000']:,.0f} boardings/1k residents "
                  f"({row['n_tracts']} tracts)")

    with phase("Saving data and charts"):
        save_csv(
            tract_df.select(
                "geoid", "area_label", "population", "land_area_m2", "pop_density",
                "weekly_trips", "boardings", "boardings_per_1000",
                "density_quartile", "rail_served", "expected_boardings",
                "boardings_resid",
            ).sort("pop_density", descending=True),
            OUT / "tract_service_boardings.csv",
        )
        save_csv(results["quartile_df"], OUT / "quartile_summary.csv")

        ranked = tract_df.filter(pl.col("boardings_resid").is_not_null()).sort(
            "boardings_resid", descending=True
        )
        outliers = pl.concat([ranked.head(10), ranked.tail(10)])
        save_csv(
            outliers.select(
                "geoid", "area_label", "population", "pop_density",
                "boardings", "rail_served", "expected_boardings", "boardings_resid",
            ),
            OUT / "boardings_outlier_tracts.csv",
        )

        qdf = results["quartile_df"]
        chart_scatter(tract_df, "weekly_trips", "Weekly scheduled trips serving tract",
                      "Population Density vs Transit Service\n"
                      "(Allegheny County census tracts)",
                      results["rho_serv"], results["p_serv"],
                      OUT / "density_vs_service.png")
        chart_quartile_bar(qdf, "median_weekly_trips",
                           "Median weekly scheduled trips",
                           "Transit Service by Population-Density Quartile",
                           OUT / "service_by_density_quartile.png")
        chart_scatter(tract_df, "boardings", "Average weekday boardings",
                      "Population Density vs Boardings\n"
                      "(Allegheny County census tracts)",
                      results["rho_board"], results["p_board"],
                      OUT / "density_vs_boardings.png", log_y=True)
        chart_quartile_bar(qdf, "median_boardings",
                           "Median average weekday boardings",
                           "Boardings by Population-Density Quartile",
                           OUT / "boardings_by_density_quartile.png")
        chart_quartile_bar(qdf, "median_boardings_per_1000",
                           "Median boardings per 1,000 residents",
                           "Boardings per Resident by Population-Density Quartile",
                           OUT / "boardings_per_capita_by_quartile.png")
        chart_residuals(tract_df, outliers, fit,
                        OUT / "boardings_vs_density_residuals.png")


if __name__ == "__main__":
    main()

Sources

NameTypeWhy It MattersOwnerFreshnessCaveat
data/bus-stop-usage/wprdc_stop_data.csv file Stop-level ridership with lat/lon, used to sum average weekday boardings per census tract. Local project data owner not specified. Snapshot file; refresh by rerunning its pipeline step. May lag upstream source updates.
route_stops table Primary analytical table used in this page's computations. Produced by Data Ingestion. Updated when the producing pipeline step is rerun. Coverage depends on upstream source availability and ETL assumptions.
Upstream sources (5)
  • file data/routes_by_month.csv — Monthly route OTP source table in wide format.
  • file data/PRT_Current_Routes_Full_System_de0e48fcbed24ebc8b0d933e47b56682.csv — Current route metadata and mode classifications.
  • file data/Transit_stops_(current)_by_route_e040ee029227468ebf9d217402a82fa9.csv — Current stop-to-route coverage and trip counts.
  • file data/PRT_Stop_Reference_Lookup_Table.csv — Historical stop reference file with geography attributes.
  • file data/average-ridership/12bb84ed-397e-435c-8d1b-8ce543108698.csv — Average ridership by route and month.
routes table Primary analytical table used in this page's computations. Produced by Data Ingestion. Updated when the producing pipeline step is rerun. Coverage depends on upstream source availability and ETL assumptions.
Upstream sources (5)
  • file data/routes_by_month.csv — Monthly route OTP source table in wide format.
  • file data/PRT_Current_Routes_Full_System_de0e48fcbed24ebc8b0d933e47b56682.csv — Current route metadata and mode classifications.
  • file data/Transit_stops_(current)_by_route_e040ee029227468ebf9d217402a82fa9.csv — Current stop-to-route coverage and trip counts.
  • file data/PRT_Stop_Reference_Lookup_Table.csv — Historical stop reference file with geography attributes.
  • file data/average-ridership/12bb84ed-397e-435c-8d1b-8ce543108698.csv — Average ridership by route and month.
stops table Primary analytical table used in this page's computations. Produced by Data Ingestion. Updated when the producing pipeline step is rerun. Coverage depends on upstream source availability and ETL assumptions.
Upstream sources (5)
  • file data/routes_by_month.csv — Monthly route OTP source table in wide format.
  • file data/PRT_Current_Routes_Full_System_de0e48fcbed24ebc8b0d933e47b56682.csv — Current route metadata and mode classifications.
  • file data/Transit_stops_(current)_by_route_e040ee029227468ebf9d217402a82fa9.csv — Current stop-to-route coverage and trip counts.
  • file data/PRT_Stop_Reference_Lookup_Table.csv — Historical stop reference file with geography attributes.
  • file data/average-ridership/12bb84ed-397e-435c-8d1b-8ce543108698.csv — Average ridership by route and month.
census_tracts table Primary analytical table used in this page's computations. Project pipeline owner not linked. Refresh cadence unknown. Coverage depends on upstream source availability and ETL assumptions.
geopandas dependency Runtime dependency required for this page's pipeline or analysis code. Open-source Python ecosystem maintainers. Version pinned by project environment until dependency updates are applied. Library updates may change behavior or defaults.
polars dependency Runtime dependency required for this page's pipeline or analysis code. Open-source Python ecosystem maintainers. Version pinned by project environment until dependency updates are applied. Library updates may change behavior or defaults.
scipy dependency Runtime dependency required for this page's pipeline or analysis code. Open-source Python ecosystem maintainers. Version pinned by project environment until dependency updates are applied. Library updates may change behavior or defaults.