Analysis

24 - Weekday vs Weekend Ridership Trends

Ridership and External Factors

Coverage: 2017-01 to 2025-11 (from otp_monthly, ridership_monthly).

Built 2026-04-03 20:09 UTC · Commit 7c56b9a

Page Navigation

Analysis Navigation

Data Provenance

flowchart LR
  24_daytype_ridership_trends(["24 - Weekday vs Weekend Ridership Trends"])
  t_otp_monthly[("otp_monthly")] --> 24_daytype_ridership_trends
  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_ridership_monthly[("ridership_monthly")] --> 24_daytype_ridership_trends
  01_data_ingestion[["Data Ingestion"]] --> t_ridership_monthly
  d1_24_daytype_ridership_trends(("polars (lib)")) --> 24_daytype_ridership_trends
  d2_24_daytype_ridership_trends(("scipy (lib)")) --> 24_daytype_ridership_trends
  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 24_daytype_ridership_trends page;
  class t_otp_monthly,t_ridership_monthly table;
  class d1_24_daytype_ridership_trends,d2_24_daytype_ridership_trends 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: Weekday vs Weekend Ridership Trends

Summary

Weekend ridership has recovered far more strongly than weekday ridership since COVID. As of October 2024, Saturday service is at 90.7% of its January 2019 level while weekday service is at just 64.5%. Weekend's share of total ridership rose from 13.6% pre-COVID to 17.8% post-2023, a +4.2 pp structural shift. The weekend-to-weekday ridership ratio does not significantly correlate with route-level OTP (Pearson r = -0.20, p = 0.097).

Key Numbers

  • Weekday recovery (Oct 2024 vs Jan 2019): 64.5% -- weekday ridership has not recovered
  • Saturday recovery: 90.7% -- nearly fully recovered
  • Sunday recovery: 83.9%
  • Weekend share pre-COVID (Jan 2019 -- Feb 2020): 13.6%
  • Weekend share post-2023 (Jan 2023 -- Oct 2024): 17.8%
  • Shift: +4.2 pp toward weekend travel
  • 74 routes with 6+ months of all three day types for route-level analysis
  • 67 routes with both weekend ratio and OTP data for correlation

Observations

  • The indexed ridership chart shows a dramatic divergence after COVID. All three day types crashed in spring 2020, but Saturday and Sunday rebounded much faster and more completely than weekday service.
  • The weekend share chart shows a step-change during COVID (weekend share spiked to ~25% when weekday commuting collapsed) followed by a partial return, stabilizing around 17-18% -- well above the pre-COVID 13-14% level.
  • Route-level weekend ratios vary widely: median 0.97 (roughly equal weekend and weekday ridership per day of service), but ranging from 0.29 (route BLSV, heavily weekday-oriented) to 1.85+ for some routes.
  • The correlation between weekend ratio and OTP is weakly negative (r = -0.20) but not statistically significant at alpha = 0.05. Routes with higher weekend share do not have meaningfully different OTP.

Discussion

The 26-percentage-point gap in recovery between weekday (64.5%) and Saturday (90.7%) service is the headline finding. This is consistent with national trends: remote and hybrid work has permanently reduced weekday commuting, while discretionary weekend travel has largely returned. For PRT, this means:

  1. Revenue and planning implications: The traditional weekday-peak service model serves a shrinking share of total demand. Weekend service, historically treated as reduced-frequency filler, now carries a proportionally larger role.
  2. OTP is not driving the gap: Weekend-heavy routes do not have significantly different OTP, suggesting the weekday ridership collapse is driven by exogenous factors (remote work) rather than service quality differences between day types.
  3. Seasonal patterns visible: Both Saturday and Sunday series show strong seasonal swings (summer peaks, winter troughs) that are more pronounced than weekday patterns, consistent with discretionary travel being more weather-sensitive.

Caveats

  • The avg_riders field is an average daily ridership for each month, not a total. Multiplying by day_count gives an estimate of total monthly riders, but this assumes uniform ridership across all days of a given type within a month.
  • The January 2019 baseline is a single month; seasonal effects mean the indexed values fluctuate even in the pre-COVID period. The recovery percentages should be interpreted as approximate.
  • Some routes have extreme weekend ratios (e.g., route 68 at 239x) likely due to very low weekday ridership rather than high weekend ridership. These outliers are excluded from the OTP correlation by the 6-month minimum and OTP data join.
  • OTP data is not available by day type -- the otp_monthly table contains a single OTP value per route per month, so we cannot directly compare weekday vs weekend OTP.

Output

Methods

Methods: Weekday vs Weekend Ridership Trends

Question

How have weekday, Saturday, and Sunday ridership patterns changed over time, and does the weekend-to-weekday ridership ratio correlate with OTP?

Approach

  • Compute system-wide total ridership by day_type (WEEKDAY, SAT., SUN.) per month using avg_riders * day_count to estimate total monthly riders.
  • Index each series to Jan 2019 = 100 to show relative recovery trends.
  • Compute per-route weekend-to-weekday ridership ratio (Saturday + Sunday avg_riders divided by weekday avg_riders, averaged over all months with all three day types present) and correlate with route-level average OTP.
  • Test whether routes with higher weekend ridership share have different OTP (Pearson, Spearman).
  • Plot the weekend share trend over time system-wide: weekend share = (SAT total riders + SUN total riders) / (WEEKDAY + SAT + SUN total riders).

Data

Name Description Source
ridership_monthly route_id, month, day_type, avg_riders, day_count; all day types used for ridership trends prt.db table
otp_monthly route_id, month, otp; used for correlation with weekend share prt.db table

Notes: Overlap period (Jan 2019 -- Oct 2024) for OTP correlation.

Output

  • output/daytype_ridership_trend.png -- indexed ridership by day type over time
  • output/weekend_share_trend.png -- weekend ridership share over time
  • output/weekend_share_vs_otp.png -- scatter of weekend share vs route OTP
  • output/daytype_summary.csv -- monthly ridership by day type

Source Code

"""Analysis 24: Compare weekday, Saturday, and Sunday ridership trends and correlate weekend share with OTP."""

import polars as pl
from scipy import stats

from prt_otp_analysis.common import PRE_COVID_BASELINE_MONTH, analysis_dir, correlate, phase, query_to_polars, run_analysis, save_chart, save_csv, setup_plotting

OUT = analysis_dir(__file__)


def load_ridership() -> pl.DataFrame:
    """Load all ridership data across day types."""
    return query_to_polars("""
        SELECT route_id, month, day_type, avg_riders, day_count
        FROM ridership_monthly
        WHERE avg_riders IS NOT NULL AND day_count IS NOT NULL
    """)


def load_otp() -> pl.DataFrame:
    """Load OTP data for correlation analysis."""
    return query_to_polars("""
        SELECT route_id, month, otp
        FROM otp_monthly
    """)


def system_monthly(df: pl.DataFrame) -> pl.DataFrame:
    """Compute system-wide total monthly riders by day type."""
    return (
        df.with_columns(
            (pl.col("avg_riders") * pl.col("day_count")).alias("total_riders"),
        )
        .group_by("month", "day_type")
        .agg(
            total_riders=pl.col("total_riders").sum(),
            avg_riders_sum=pl.col("avg_riders").sum(),
            n_routes=pl.col("route_id").n_unique(),
        )
        .sort("month", "day_type")
    )


def index_to_baseline(monthly: pl.DataFrame, baseline_month: str = PRE_COVID_BASELINE_MONTH) -> pl.DataFrame:
    """Index each day type series to baseline_month = 100."""
    baseline = (
        monthly.filter(pl.col("month") == baseline_month)
        .select("day_type", pl.col("total_riders").alias("baseline_riders"))
    )
    return (
        monthly.join(baseline, on="day_type", how="left")
        .with_columns(
            (pl.col("total_riders") / pl.col("baseline_riders") * 100).alias("indexed"),
        )
    )


def weekend_share_monthly(monthly: pl.DataFrame) -> pl.DataFrame:
    """Compute weekend ridership share per month."""
    pivoted = (
        monthly.group_by("month")
        .agg(
            weekday=pl.col("total_riders").filter(pl.col("day_type") == "WEEKDAY").sum(),
            saturday=pl.col("total_riders").filter(pl.col("day_type") == "SAT.").sum(),
            sunday=pl.col("total_riders").filter(pl.col("day_type") == "SUN.").sum(),
        )
        .with_columns(
            total=pl.col("weekday") + pl.col("saturday") + pl.col("sunday"),
        )
        .with_columns(
            weekend_share=(pl.col("saturday") + pl.col("sunday")) / pl.col("total"),
            sat_share=pl.col("saturday") / pl.col("total"),
            sun_share=pl.col("sunday") / pl.col("total"),
        )
        .sort("month")
    )
    return pivoted


def route_weekend_share(df: pl.DataFrame) -> pl.DataFrame:
    """Compute per-route average weekend-to-weekday ridership ratio.

    For each route-month where all three day types are present, compute:
      weekend_ratio = (SAT avg_riders + SUN avg_riders) / WEEKDAY avg_riders
    Then average across months.
    """
    # Pivot to get all three day types per route-month
    wide = (
        df.pivot(on="day_type", index=["route_id", "month"], values="avg_riders")
    )
    # Only keep rows where all three day types are present
    required = ["WEEKDAY", "SAT.", "SUN."]
    for col in required:
        if col not in wide.columns:
            return pl.DataFrame()
    wide = wide.drop_nulls(subset=required)
    wide = wide.filter(pl.col("WEEKDAY") > 0)

    wide = wide.with_columns(
        weekend_ratio=(
            (pl.col("SAT.") + pl.col("SUN.")) / pl.col("WEEKDAY")
        ),
    )

    route_avg = (
        wide.group_by("route_id")
        .agg(
            avg_weekend_ratio=pl.col("weekend_ratio").mean(),
            n_months=pl.col("month").count(),
        )
        .filter(pl.col("n_months") >= 6)  # require at least 6 months with all day types
        .sort("avg_weekend_ratio", descending=True)
    )
    return route_avg


def correlate_weekend_otp(route_wkend: pl.DataFrame, otp_df: pl.DataFrame) -> dict:
    """Correlate route-level weekend share with average OTP."""
    if len(route_wkend) == 0:
        return {"n": 0, "error": "no routes with weekend data"}
    route_otp = (
        otp_df.group_by("route_id")
        .agg(avg_otp=pl.col("otp").mean())
    )
    merged = route_wkend.join(route_otp, on="route_id", how="inner")
    if len(merged) < 10:
        return {"n": len(merged), "error": "too few routes"}

    corr = correlate(merged, "avg_weekend_ratio", "avg_otp")

    return {
        "n": len(merged),
        "r_pearson": corr["pearson_r"],
        "p_pearson": corr["pearson_p"],
        "r_spearman": corr["spearman_r"],
        "p_spearman": corr["spearman_p"],
        "merged": merged,
    }


def make_trend_chart(monthly_idx: pl.DataFrame) -> None:
    """Plot indexed ridership by day type over time."""
    plt = setup_plotting()
    fig, ax = plt.subplots(figsize=(12, 6))

    day_type_styles = {
        "WEEKDAY": {"color": "#2563eb", "label": "Weekday"},
        "SAT.": {"color": "#16a34a", "label": "Saturday"},
        "SUN.": {"color": "#e11d48", "label": "Sunday/Holiday"},
    }

    all_months = sorted(monthly_idx["month"].unique().to_list())
    month_to_x = {m: i for i, m in enumerate(all_months)}
    tick_positions = [i for i, m in enumerate(all_months) if m.endswith("-01")]
    tick_labels = [all_months[i][:4] for i in tick_positions]

    for day_type, style in day_type_styles.items():
        sub = monthly_idx.filter(pl.col("day_type") == day_type).sort("month")
        if len(sub) == 0:
            continue
        x = [month_to_x[m] for m in sub["month"].to_list()]
        y = sub["indexed"].to_list()
        ax.plot(x, y, color=style["color"], linewidth=1.8, label=style["label"], alpha=0.85)

    ax.axhline(100, color="gray", linestyle="--", alpha=0.4, linewidth=0.8)

    if "2020-03" in all_months:
        covid_idx = month_to_x["2020-03"]
        ax.axvline(covid_idx, color="#ef4444", linestyle=":", alpha=0.5, label="COVID (Mar 2020)")

    ax.set_ylabel("Indexed Ridership (Jan 2019 = 100)")
    ax.set_xlabel("Month")
    ax.set_title("System-Wide Ridership by Day Type (Indexed to Jan 2019)")
    ax.set_xticks(tick_positions)
    ax.set_xticklabels(tick_labels)
    ax.legend(loc="upper right", fontsize=9)

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


def make_weekend_share_chart(wk_share: pl.DataFrame) -> None:
    """Plot weekend ridership share over time."""
    plt = setup_plotting()
    fig, ax = plt.subplots(figsize=(12, 5))

    all_months = sorted(wk_share["month"].to_list())
    month_to_x = {m: i for i, m in enumerate(all_months)}
    tick_positions = [i for i, m in enumerate(all_months) if m.endswith("-01")]
    tick_labels = [all_months[i][:4] for i in tick_positions]

    x = [month_to_x[m] for m in wk_share["month"].to_list()]
    y_total = wk_share["weekend_share"].to_list()
    y_sat = wk_share["sat_share"].to_list()
    y_sun = wk_share["sun_share"].to_list()

    ax.plot(x, y_total, color="#2563eb", linewidth=2.0, label="Weekend (Sat + Sun)", alpha=0.85)
    ax.plot(x, y_sat, color="#16a34a", linewidth=1.2, label="Saturday only", alpha=0.7, linestyle="--")
    ax.plot(x, y_sun, color="#e11d48", linewidth=1.2, label="Sunday only", alpha=0.7, linestyle="--")

    if "2020-03" in all_months:
        covid_idx = month_to_x["2020-03"]
        ax.axvline(covid_idx, color="#ef4444", linestyle=":", alpha=0.5)

    ax.set_ylabel("Share of Total Ridership")
    ax.set_xlabel("Month")
    ax.set_title("Weekend Ridership Share Over Time")
    ax.set_xticks(tick_positions)
    ax.set_xticklabels(tick_labels)
    ax.legend(loc="upper left", fontsize=9)
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.0%}"))

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


def make_scatter_chart(merged: pl.DataFrame) -> None:
    """Scatter plot of weekend ratio vs route-level OTP."""
    plt = setup_plotting()
    fig, ax = plt.subplots(figsize=(8, 6))

    x = merged["avg_weekend_ratio"].to_list()
    y = merged["avg_otp"].to_list()

    ax.scatter(x, y, alpha=0.5, s=30, color="#2563eb", edgecolors="white", linewidth=0.5)

    # Trend line
    slope, intercept, _, _, _ = stats.linregress(x, y)
    x_line = [min(x), max(x)]
    y_line = [slope * xi + intercept for xi in x_line]
    ax.plot(x_line, y_line, color="#e11d48", linewidth=1.5, linestyle="--", alpha=0.7)

    ax.set_xlabel("Average Weekend-to-Weekday Ridership Ratio")
    ax.set_ylabel("Average OTP")
    ax.set_title("Weekend Ridership Ratio vs On-Time Performance")
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.0%}"))

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


@run_analysis(24, "Weekday vs Weekend Ridership Trends")
def main() -> None:
    """Entry point: load data, compute trends, correlate, chart, and save."""

    with phase("Loading ridership data"):
        ride_df = load_ridership()
        print(f"  {len(ride_df):,} rows, {ride_df['route_id'].n_unique()} routes, "
              f"{ride_df['day_type'].n_unique()} day types")
        print(f"  Day types: {sorted(ride_df['day_type'].unique().to_list())}")
        print(f"  Month range: {ride_df['month'].min()} to {ride_df['month'].max()}")

    with phase("Computing system-wide monthly ridership by day type"):
        monthly = system_monthly(ride_df)

        # Summary stats by day type
        print("\n  Day type summary (total monthly riders, averaged across months):")
        for dt in sorted(monthly["day_type"].unique().to_list()):
            sub = monthly.filter(pl.col("day_type") == dt)
            avg = sub["total_riders"].mean()
            print(f"    {dt:<12s}: {avg:>12,.0f} avg monthly riders")

    with phase("Indexing to Jan 2019 baseline"):
        monthly_idx = index_to_baseline(monthly, PRE_COVID_BASELINE_MONTH)

        # Latest index values
        latest_month = monthly_idx["month"].max()
        print(f"\n  Latest month ({latest_month}) indexed values:")
        latest = monthly_idx.filter(pl.col("month") == latest_month)
        for row in latest.iter_rows(named=True):
            print(f"    {row['day_type']:<12s}: {row['indexed']:>6.1f}")

    with phase("Computing weekend ridership share"):
        wk_share = weekend_share_monthly(monthly)

        # Pre-COVID vs latest weekend share
        pre_covid_avg = wk_share.filter(
            (pl.col("month") >= PRE_COVID_BASELINE_MONTH) & (pl.col("month") <= "2020-02")
        )["weekend_share"].mean()
        post_2023_avg = wk_share.filter(pl.col("month") >= "2023-01")["weekend_share"].mean()
        print(f"\n  Weekend share (pre-COVID, 2019-01 to 2020-02): {pre_covid_avg:.1%}")
        print(f"  Weekend share (2023-01 to latest):             {post_2023_avg:.1%}")
        print(f"  Change: {post_2023_avg - pre_covid_avg:+.1%}")

    with phase("Computing per-route weekend-to-weekday ratio"):
        route_wkend = route_weekend_share(ride_df)
        print(f"  {len(route_wkend)} routes with 6+ months of all three day types")

        if len(route_wkend) > 0:
            print(f"  Median weekend ratio: {route_wkend['avg_weekend_ratio'].median():.3f}")
            print(f"  Range: {route_wkend['avg_weekend_ratio'].min():.3f} -- "
                  f"{route_wkend['avg_weekend_ratio'].max():.3f}")

            # Top/bottom 5
            print("\n  Top 5 weekend-heavy routes:")
            for row in route_wkend.head(5).iter_rows(named=True):
                print(f"    {row['route_id']:<8s} ratio={row['avg_weekend_ratio']:.3f}")
            print("  Bottom 5 weekend-heavy routes:")
            for row in route_wkend.tail(5).iter_rows(named=True):
                print(f"    {row['route_id']:<8s} ratio={row['avg_weekend_ratio']:.3f}")

    with phase("Correlating weekend share with OTP"):
        otp_df = load_otp()
        corr = correlate_weekend_otp(route_wkend, otp_df)
        if "error" not in corr:
            print(f"  n = {corr['n']} routes")
            print(f"  Pearson  r = {corr['r_pearson']:.3f}, p = {corr['p_pearson']:.4f}")
            print(f"  Spearman r = {corr['r_spearman']:.3f}, p = {corr['p_spearman']:.4f}")
        else:
            print(f"  {corr['error']}")

    with phase("Saving CSV"):
        save_csv(monthly, OUT / "daytype_summary.csv")

    with phase("Generating charts"):
        make_trend_chart(monthly_idx)
        make_weekend_share_chart(wk_share)
        if "merged" in corr:
            make_scatter_chart(corr["merged"])


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.
ridership_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.
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.