Analysis

45 - Population-Weighted System OTP

Route and Service Drivers

Coverage: 2019-01 to 2025-11 (from otp_monthly).

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

Page Navigation

Analysis Navigation

Data Provenance

flowchart LR
  45_population_weighted_otp(["45 - Population-Weighted System OTP"])
  t_otp_monthly[("otp_monthly")] --> 45_population_weighted_otp
  01_data_ingestion[["Data Ingestion"]] --> t_otp_monthly
  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_route_stops[("route_stops")] --> 45_population_weighted_otp
  01_data_ingestion[["Data Ingestion"]] --> t_route_stops
  t_stops[("stops")] --> 45_population_weighted_otp
  01_data_ingestion[["Data Ingestion"]] --> t_stops
  t_routes[("routes")] --> 45_population_weighted_otp
  01_data_ingestion[["Data Ingestion"]] --> t_routes
  t_census_tracts[("census_tracts")] --> 45_population_weighted_otp
  d1_45_population_weighted_otp(("polars (lib)")) --> 45_population_weighted_otp
  d2_45_population_weighted_otp(("scipy (lib)")) --> 45_population_weighted_otp
  d3_45_population_weighted_otp(("geopandas (lib)")) --> 45_population_weighted_otp
  d4_45_population_weighted_otp(("shapely (lib)")) --> 45_population_weighted_otp
  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 45_population_weighted_otp page;
  class t_census_tracts,t_otp_monthly,t_route_stops,t_routes,t_stops table;
  class d1_45_population_weighted_otp,d2_45_population_weighted_otp,d3_45_population_weighted_otp,d4_45_population_weighted_otp dep;
  class 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: Population-Weighted System OTP

Summary

The OTP experienced by the average resident-near-a-route is 68.1% -- about 0.85 percentage points higher than the trip-weighted system OTP (67.3%) but 1.27 pp lower than the unweighted average (69.4%). The population-weighted vs trip-weighted gap is statistically significant (paired t = -15.8, p < 0.001; Wilcoxon W = 20, p < 0.001; n = 83 months). PRT's lateness-prone routes concentrate where many people live, but slightly less than scheduled trip frequency alone would imply.

Key Numbers

  • Unweighted OTP (all 92 routes equal): 69.4% mean, 69.7% median
  • Trip-weighted OTP (route_stops.trips_7d): 67.3% mean, 67.4% median
  • Population-weighted OTP (walkshed residents from Analysis 44): 68.1% mean, 68.4% median
  • Population vs trip-weighted gap: +0.85 pp (p < 0.001, n = 83 months)
  • Cohort: 92 routes with >= 12 months of OTP data, Mar 2018 -- Nov 2025
  • Total population_served sum: 2,273,565 (exceeds the 2,171,546 5-county total because residents within walking distance of multiple routes are counted once per route -- see Caveats)

Interpretation

Both weighted schemes pull system OTP down from the unweighted average, meaning that routes carrying more scheduled trips and routes serving more residents both tend to perform somewhat worse than average. The two effects largely overlap -- high-frequency urban-core routes serve dense population centers -- but they are not identical: population weighting penalizes the system roughly half as much as trip weighting does.

The gap is consistent with what Analysis 19 found for ridership weighting (+1.6 pp above trip-weighted): scheduled trip frequency overstates how concentrated rider exposure actually is on the worst-performing routes. Both ridership and walkshed population are distributed somewhat more evenly across the OTP spectrum than scheduled trips are.

This is an area-level association (which routes serve dense areas, weighted by their OTP), not a per-resident estimate.

Observations

  • The three series move together over time -- all show the COVID OTP spike in 2020 and the steady decline through late 2022, then partial stabilization through 2024-2025.
  • The population vs trip-weighted gap is small but extremely stable across the 83-month window, which is why the paired t-test reaches such a large statistic despite a sub-1pp difference.
  • The unweighted-vs-trip-weighted gap (~2.1 pp) is wider than the population-vs-trip gap (~0.85 pp), confirming that route trip frequency is more concentrated on lateness-prone routes than walkshed population is.

Caveats

  • Exposure double-counting. Residents within walksheds of multiple routes are counted once per route. Population-weighted OTP is the OTP of the average route-resident exposure, not the average resident. A resident with access to many routes contributes more weight than one served by a single route. Per-resident OTP would require modeling which route each resident actually rides, which we cannot do without trip-level rider data.
  • Static walkshed weights. population_served is computed from current stops and 2018-2022 ACS population; the same per-route weight is applied to every month. Route footprints and demographics that shifted within the window are not reflected.
  • Static trip weights. route_stops.trips_7d is also a current snapshot, not a monthly time series, matching Analysis 19's treatment for comparability.
  • Cohort. Restricted to 92 routes with >= 12 months of OTP. Routes that exist in the schedule but lack consistent OTP data (e.g., short-lived service) are excluded from all three series.

Validation

  • Data source verified. otp_monthly, route_stops, stops, routes, and census_tracts columns checked against data/DATA_DICTIONARY.md. All three table joins use validated query_to_polars results.
  • Geographic/temporal scope. All three weighting schemes use the identical 92-route cohort and identical 83-month window; the only difference is the weight column.
  • Aggregates sanity-checked. Unweighted system OTP 69.4% mean matches Analysis 01 (system trend) within rounding. Trip-weighted 67.3% matches Analysis 19's trip-weighted 67.8% to within ~0.5 pp -- the small difference is because Analysis 19 restricts to the ridership-overlap window (Jan 2019 - Oct 2024) while this analysis uses the full OTP window.
  • Direction of effects checked. Trip-weighted < unweighted is the expected sign (high-frequency routes are more lateness-prone, per Analysis 10 frequency-vs-OTP). Population-weighted falling between the two is the expected pattern given that walkshed population correlates with but is less concentrated than trip frequency.
  • Population sum cross-check. The 2,273,565 cohort population sum exceeding the 2,171,546 5-county total directly confirms multi-route exposure overlap; the ratio (~5% over) is small because most multi-route overlap is concentrated in downtown Pittsburgh, which is a small slice of the 5-county population.
  • Ecological framing. Findings describe area-level associations (route-level OTP weighted by route-level walkshed population), never per-resident outcomes. Caveat documented above.

Output

Methods

Methods: Population-Weighted System OTP

Question

What OTP does the average resident of PRT's service area experience, and how does it differ from the OTP of the average route (unweighted) or the average scheduled trip (trip-weighted, Analysis 19)? Service that runs reliably on lightly-populated routes should not look as good as service that runs reliably where many people live.

Approach

  • For each route, compute population_served = ACS tract population within walking distance of any of its stops (400 m for bus, 800 m for rail/incline), areal-interpolated against tract polygons. Same construction as Analysis 44; both analyses call the shared prt_otp_analysis.walksheds.route_population_served() helper.
  • Restrict to routes with >= 12 months of OTP observations, matching the Analysis 19 cohort so the three weighting schemes are comparable.
  • For each month, compute three system OTP series:
    1. Unweighted: simple mean of route OTPs.
    2. Trip-weighted: sum(otp_i * trips_7d_i) / sum(trips_7d_i) with trips_7d from route_stops (static scheduled-frequency weight).
    3. Population-weighted: sum(otp_i * population_served_i) / sum(population_served_i) (static walkshed-population weight).
  • Compute summary statistics (mean, median, std, min, max) for each scheme.
  • Test whether population-weighted differs significantly from trip-weighted via paired t-test and Wilcoxon signed-rank across months.
  • Plot the three monthly series with a COVID marker.

Caveat -- exposure double-counting. Residents within the walksheds of multiple routes are counted once per route. The population-weighted OTP is therefore the OTP experienced by the average exposure, not the average resident. A resident with access to many bus routes contributes more weight than one served by a single route. This matches how a rider would perceive the system but does not estimate per-resident OTP; that would require modeling which route(s) each resident actually rides.

Data

Name Description Source
otp_monthly Route, month, OTP prt.db table
route_stops trips_7d for trip-weighted baseline prt.db table
stops, routes For walkshed construction prt.db tables
census_tracts ACS 5-year (2018-2022) tract population, TIGER 2022 polygons prt.db table (Pipeline 10)

Output

  • output/population_weighted_otp_trend.png -- three-series monthly time plot
  • output/weighting_comparison.csv -- monthly values for unweighted, trip-weighted, population-weighted
  • output/summary_stats.csv -- mean/median/std/min/max for each scheme
  • output/route_weights.csv -- per-route population_served and trips_7d used as weights, for transparency

Source Code

"""Analysis 45: Population-Weighted System OTP.

Compute the OTP experienced by the average resident by weighting each route's
monthly OTP by its walkshed population (Analysis 44 construction), then compare
to trip-weighted (Analysis 19) and unweighted system OTP.
"""

from pathlib import Path

import polars as pl
from scipy import stats

from prt_otp_analysis.common import (
    output_dir,
    phase,
    query_to_polars,
    run_analysis,
    save_chart,
    save_csv,
    setup_plotting,
    weighted_mean,
)
from prt_otp_analysis.walksheds import route_population_served

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

MIN_MONTHS = 12


def load_otp_with_weights() -> tuple[pl.DataFrame, pl.DataFrame]:
    """Return (long OTP+weights df, per-route weights df).

    Long df has one row per (route, month) with otp, population_served, trips_7d.
    Restricted to routes with >= MIN_MONTHS of OTP observations.
    """
    pop_df = route_population_served()
    trips_df = query_to_polars(
        "SELECT route_id, SUM(trips_7d) AS trips_7d FROM route_stops GROUP BY route_id"
    )
    otp_df = query_to_polars(
        "SELECT route_id, month, otp FROM otp_monthly WHERE otp IS NOT NULL"
    )

    weights_df = (
        pop_df.join(trips_df, on="route_id", how="left")
        .with_columns(trips_7d=pl.col("trips_7d").fill_null(0))
    )

    df = otp_df.join(weights_df, on="route_id", how="inner")

    route_counts = df.group_by("route_id").agg(pl.col("month").count().alias("n"))
    keep = route_counts.filter(pl.col("n") >= MIN_MONTHS)["route_id"].to_list()
    df = df.filter(pl.col("route_id").is_in(keep))
    weights_df = weights_df.filter(pl.col("route_id").is_in(keep))
    return df, weights_df


def compute_monthly(df: pl.DataFrame) -> pl.DataFrame:
    """Three monthly OTP series: unweighted, trip-weighted, population-weighted."""
    return (
        df.group_by("month")
        .agg(
            unweighted_otp=pl.col("otp").mean(),
            trip_weighted_otp=weighted_mean("otp", "trips_7d", safe=True),
            population_weighted_otp=weighted_mean("otp", "population_served", safe=True),
            route_count=pl.col("route_id").n_unique(),
            total_population_served=pl.col("population_served").sum(),
        )
        .sort("month")
    )


def compute_summary(monthly: pl.DataFrame) -> pl.DataFrame:
    """Summary statistics for each weighting scheme."""
    rows = []
    for col in ["unweighted_otp", "trip_weighted_otp", "population_weighted_otp"]:
        s = monthly[col]
        rows.append({
            "weighting": col.replace("_otp", ""),
            "mean": s.mean(),
            "median": s.median(),
            "std": s.std(),
            "min": s.min(),
            "max": s.max(),
        })
    return pl.DataFrame(rows)


def statistical_test(monthly: pl.DataFrame) -> dict:
    """Test whether population-weighted OTP differs from trip-weighted OTP."""
    trip = monthly["trip_weighted_otp"].to_numpy()
    pop = monthly["population_weighted_otp"].to_numpy()
    t_stat, t_p = stats.ttest_rel(trip, pop)
    w_stat, w_p = stats.wilcoxon(trip, pop)
    return {
        "mean_difference": float((pop - trip).mean()),
        "paired_t_stat": float(t_stat),
        "paired_t_p": float(t_p),
        "wilcoxon_stat": float(w_stat),
        "wilcoxon_p": float(w_p),
        "n_months": int(len(trip)),
    }


def make_chart(monthly: pl.DataFrame) -> None:
    """Plot three OTP series over time."""
    plt = setup_plotting()

    months = monthly["month"].to_list()
    x = list(range(len(months)))
    tick_positions = [i for i, m in enumerate(months) if m.endswith("-01")]
    tick_labels = [months[i][:4] for i in tick_positions]

    fig, ax = plt.subplots(figsize=(14, 6))

    ax.plot(x, monthly["unweighted_otp"].to_list(),
            color="#9ca3af", linewidth=1, linestyle="--",
            label="Unweighted (all routes equal)")
    ax.plot(x, monthly["trip_weighted_otp"].to_list(),
            color="#2563eb", linewidth=1.5,
            label="Trip-weighted (scheduled frequency)")
    ax.plot(x, monthly["population_weighted_otp"].to_list(),
            color="#059669", linewidth=1.5,
            label="Population-weighted (walkshed residents)")

    trip = monthly["trip_weighted_otp"].to_list()
    pop = monthly["population_weighted_otp"].to_list()
    ax.fill_between(x, trip, pop, alpha=0.15, color="#059669",
                    label="Trip vs population gap")

    if "2020-03" in months:
        covid_idx = months.index("2020-03")
        ax.axvline(covid_idx, color="#ef4444", linestyle=":", alpha=0.7)
        ax.text(covid_idx + 0.5, ax.get_ylim()[1] * 0.98, "COVID",
                color="#ef4444", fontsize=8, va="top")

    ax.set_ylabel("On-Time Performance")
    ax.set_xlabel("Month")
    ax.set_title("PRT System OTP: Population vs Trips vs Unweighted")
    ax.set_xticks(tick_positions)
    ax.set_xticklabels(tick_labels)
    ax.legend(loc="lower left", fontsize=8)
    ax.set_ylim(0.5, 0.85)

    save_chart(fig, OUT / "population_weighted_otp_trend.png")


@run_analysis(45, "Population-Weighted System OTP")
def main() -> None:
    with phase("Loading OTP and route weights"):
        df, weights_df = load_otp_with_weights()
        n_routes = df["route_id"].n_unique()
        print(f"  {len(df):,} route-month observations ({n_routes} routes "
              f">= {MIN_MONTHS} months OTP)")
        print(f"  population_served sum across cohort: "
              f"{weights_df['population_served'].sum():,}")

    with phase("Computing monthly OTP series"):
        monthly = compute_monthly(df)
        print(f"  {len(monthly)} months computed")

    with phase("Summary statistics"):
        summary = compute_summary(monthly)
        for row in summary.iter_rows(named=True):
            print(f"  {row['weighting']:25s}  mean={row['mean']:.3%}  "
                  f"median={row['median']:.3%}  std={row['std']:.3%}")

    with phase("Statistical test (population-weighted vs trip-weighted)"):
        test = statistical_test(monthly)
        print(f"  Mean difference: {test['mean_difference']:+.4%}")
        print(f"  Paired t-test:   t={test['paired_t_stat']:.3f}, p={test['paired_t_p']:.4f}")
        print(f"  Wilcoxon test:   W={test['wilcoxon_stat']:.0f}, p={test['wilcoxon_p']:.4f}")
        print(f"  N months:        {test['n_months']}")

    with phase("Saving CSVs"):
        save_csv(monthly, OUT / "weighting_comparison.csv")
        save_csv(summary, OUT / "summary_stats.csv")
        save_csv(
            weights_df.sort("population_served", descending=True),
            OUT / "route_weights.csv",
        )

    with phase("Generating chart"):
        make_chart(monthly)


if __name__ == "__main__":
    main()

Sources

NameTypeWhy It MattersOwnerFreshnessCaveat
otp_monthly 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.
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.
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.
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.
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.
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.
shapely 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.