Analysis

54 - Stop Position and Near-Side Placement

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
  54_stop_position_placement(["54 - Stop Position and Near-Side Placement"])
  f1_54_stop_position_placement[/"data/GTFS/stop_times.txt"/] --> 54_stop_position_placement
  f2_54_stop_position_placement[/"data/GTFS/trips.txt"/] --> 54_stop_position_placement
  f3_54_stop_position_placement[/"analyses/53_stop_signal_placement/output/stop_classifications.csv"/] --> 54_stop_position_placement
  t_otp_monthly[("otp_monthly")] --> 54_stop_position_placement
  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
  d1_54_stop_position_placement(("analyses/53_stop_signal_placement (lib)")) --> 54_stop_position_placement
  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 54_stop_position_placement page;
  class t_otp_monthly table;
  class d1_54_stop_position_placement dep;
  class f1_54_stop_position_placement,f2_54_stop_position_placement,f3_54_stop_position_placement,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: Stop Position and Near-Side Placement

Summary

Near-side stop placement is uniformly distributed along PRT routes — it is just as common at the start, middle, and end of a route. The near-side fraction is virtually flat across all five position quintiles (81–85%, all within 4 pp of the 83% system average), and a logistic regression of classification on normalized position yields a negligible coefficient (−0.27, close to zero on the log-odds scale). Splitting routes into first-half and second-half near-side fractions does not improve the OTP correlation: both halves give r ≈ −0.12 to −0.14, no better than the overall near-side fraction from Analysis 53. Position along the route is not a meaningful moderator of the near-side/OTP relationship.

Key Numbers

  • 1,512 stops matched to position data (near_side + far_side from Analysis 53).
  • Near-side fraction by quintile: 82.9% / 82.8% / 85.0% / 81.3% / 81.4% — range of only 3.7 pp, no systematic trend.
  • Logistic regression coefficient on normalized position: −0.27 (near-zero; the model predicts essentially the same near-side probability at every point).
  • OTP correlations: overall r = −0.13, first-half r = −0.12, second-half r = −0.12. None significant (all p > 0.29, n = 63–64 routes).
  • KDE of stop position: near-side and far-side stops have nearly identical distributions along the route — both roughly uniform with shallow humps near the 20% and 80% marks.

Observations

  • No position gradient in near-side placement. The hypothesis that PRT might have more far-side stops near terminals (where planners have more right-of-way flexibility) or more near-side stops in the busy middle of routes is not supported. Near-side is the default at every position.
  • Second-half placement is no better an OTP predictor than first-half. The delay-accumulation argument — that a near-side stop at position 0.9 costs more than one at 0.1 — is theoretically sound but not detectable at the route level with the data available. The likely reason is the same as in Analysis 53: with 80–100% near-side fraction everywhere on most routes, there is simply not enough variance between routes for either half to explain OTP.
  • Far-side stops have a mild second-half lean. The KDE shows far-side stops slightly more concentrated in the 60–90% position range, while near-side stops are modestly more concentrated in the 10–40% range. The difference is subtle and not large enough to affect the quintile percentages, but it is consistent with the hypothesis that stops converted or planned as far-side tend to appear on the outbound half of routes where intersection geometry allows it.

Discussion

This analysis closes the position-moderation question: knowing where along a route a near-side stop sits does not add predictive power beyond knowing the overall near-side fraction. The fundamental constraint from Analysis 53 remains binding — near-side is so dominant (83% system-wide, 80–100% on most individual routes) that there is very little cross-route variance to correlate with anything.

The underlying issue is range restriction: to detect a correlation between near-side fraction and OTP, you need meaningful variance in near-side fraction. Most routes cluster at 85–100%, so comparisons are effectively near-side-heavy vs. slightly-less-near-side-heavy rather than near-side vs. far-side. This is not evidence that near-side placement has no effect — it is evidence that the current network is too uniformly near-side for a route-level correlation to emerge.

The ideal test would be a within-system paired comparison: two otherwise identical stops at the same intersection, one near-side and one far-side, with stop-level arrival time data to directly compare delay contribution. That requires AVL logs or GTFS-RT archives, which are not in the current pipeline. A before/after study of a deliberate stop-relocation program would be the cleanest test of all.

Caveats

  • Canonical trip only. Each stop is assigned to one canonical trip (longest shape for its shape_id). Position on other trips may differ slightly.
  • OTP is route-level, not stop-level. The route OTP used here is the monthly average across all stops and all trips; it does not capture within- route delay accumulation patterns.
  • Ecological framing. All correlations are route-level. No stop-level or rider-level causal claims are made.

Validation

  1. Data source verified. Classifications from analyses/53_stop_signal_placement/output/stop_classifications.csv; stop sequences from data/GTFS/stop_times.txt. Position computation checked: the range of normalized position is [0, 1] and the quintile counts are approximately equal (286, 309, 301, 310, 306).
  2. Null handling. 37 stops from Analysis 53 had no stop_times match after joining on shape_id + stop_id; these are silently dropped (inner join).
  3. Flat quintile chart inspected. The near-constant bars (81–85%) are consistent with a near-uniform distribution confirmed by the KDE. A systematic gradient of ≥5 pp would have been flagged for investigation.
  4. Direction of logistic coefficient. The coefficient (−0.27) indicates slightly fewer near-side stops toward the end of routes, consistent with the mild far-side lean seen in the KDE second half. Sign is as expected from the KDE; magnitude is too small to be operationally significant.

Output

Methods

Methods: Stop Position and Near-Side Placement

Question

Does near-side vs. far-side stop placement vary by position along the route? And does the near-side fraction in the second half of a route — where delay has had more time to accumulate — predict route OTP better than the overall near-side fraction?

Approach

Step 1 — Assign normalized position to each classified stop

Join the stop classifications from Analysis 53 to stop_times via the stop's canonical shape_idtrip_id mapping. For each stop on its canonical trip, compute:

position = (stop_sequence − 1) / (max_stop_sequence − 1)

This gives a value in [0, 1] where 0 = first stop, 1 = last stop. Only near_side and far_side stops are included; mid-block and ambiguous are excluded.

Step 2 — Test for position gradient in near-side fraction

Bin stops into 5 equal-width position quintiles (0–20%, 20–40%, …, 80–100%) and compute the near-side fraction within each quintile. Plot as a bar chart. Run a logistic regression of classification (near_side = 1, far_side = 0) on normalized position to test whether position is a significant predictor.

Step 3 — Split-half OTP predictor comparison

For each route, compute:

  • ns_first_half: near-side fraction among stops with position < 0.5
  • ns_second_half: near-side fraction among stops with position ≥ 0.5

Join to route mean OTP. Compare the Pearson r of each fraction with OTP against the overall near-side fraction from Analysis 53. The hypothesis is that ns_second_half is a stronger predictor because delay accumulates along the route — a near-side stop near the terminus costs more in end-point OTP.

Step 4 — KDE of stop position by classification

Overlay kernel density estimates of normalized stop position for near-side and far-side stops to show whether the two classes cluster differently along routes.

Data

Source How used
analyses/53_stop_signal_placement/output/stop_classifications.csv Classification + canonical shape_id
data/GTFS/stop_times.txt stop_id → stop_sequence per trip
data/GTFS/trips.txt trip_id → shape_id, route_id
otp_monthly table in prt.db Route mean OTP

Output

  • output/position_quintile_nearside.png — near-side fraction by route-position quintile
  • output/position_kde.png — KDE of stop position by classification
  • output/splithalf_otp_comparison.png — first-half vs. second-half near-side fraction vs. OTP
  • output/route_position_summary.csv — per-route ns_first_half, ns_second_half, ns_overall, mean_otp

Source Code

"""Test whether near-side/far-side placement varies by stop position along the route."""

from __future__ import annotations

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import polars as pl
from scipy.stats import pearsonr
from scipy.special import expit

from prt_otp_analysis.common import get_db

ANALYSIS_DIR = Path(__file__).parent
OUTPUT_DIR = ANALYSIS_DIR / "output"
GTFS_DIR = Path(__file__).parent.parent.parent / "data" / "GTFS"
CLASSIFICATIONS_CSV = (
    Path(__file__).parent.parent / "53_stop_signal_placement" / "output" / "stop_classifications.csv"
)

MIN_SIGNALIZED = 5  # minimum signalized stops per route for OTP comparison


def load_stop_positions() -> pl.DataFrame:
    """Return classified stops with their normalized position [0,1] along their canonical route.

    Normalized position = (stop_sequence - 1) / (max_stop_sequence - 1) per trip,
    using the canonical trip (most-served shape for that stop).
    """
    class_df = pl.read_csv(
        CLASSIFICATIONS_CSV,
        schema_overrides={"stop_id": pl.Utf8, "shape_id": pl.Utf8},
    ).filter(pl.col("classification").is_in(["near_side", "far_side"]))

    trips_df = pl.read_csv(
        GTFS_DIR / "trips.txt",
        schema_overrides={
            "trip_id": pl.Utf8,
            "shape_id": pl.Utf8,
            "route_id": pl.Utf8,
            "service_id": pl.Utf8,
        },
    ).select(["trip_id", "shape_id", "route_id"])

    stop_times_df = pl.read_csv(
        GTFS_DIR / "stop_times.txt",
        schema_overrides={"trip_id": pl.Utf8, "stop_id": pl.Utf8},
    ).select(["trip_id", "stop_id", "stop_sequence"])

    # For each shape_id, pick the canonical trip (most stops = most representative)
    trip_lengths_df = (
        stop_times_df
        .join(trips_df.select(["trip_id", "shape_id"]), on="trip_id")
        .group_by(["shape_id", "trip_id"])
        .agg(pl.len().alias("n_stops"))
        .sort("n_stops", descending=True)
        .unique(subset=["shape_id"], keep="first")
        .select(["shape_id", "trip_id"])
    )

    # Get stop_times for canonical trips only, with max_sequence
    canonical_trips = trip_lengths_df["trip_id"].to_list()
    canonical_stop_times_df = (
        stop_times_df
        .filter(pl.col("trip_id").is_in(canonical_trips))
        .join(
            stop_times_df
            .filter(pl.col("trip_id").is_in(canonical_trips))
            .group_by("trip_id")
            .agg(pl.col("stop_sequence").max().alias("max_seq")),
            on="trip_id",
        )
        .with_columns(
            ((pl.col("stop_sequence") - 1) / (pl.col("max_seq") - 1)).alias("position")
        )
        .select(["trip_id", "stop_id", "stop_sequence", "max_seq", "position"])
    )

    # Join canonical trip back via shape_id
    canonical_stop_times_df = canonical_stop_times_df.join(
        trip_lengths_df, on="trip_id"
    )

    # Join classification (which carries shape_id) to get positions
    result_df = (
        class_df
        .join(
            canonical_stop_times_df.select(["shape_id", "stop_id", "stop_sequence", "max_seq", "position"]),
            on=["shape_id", "stop_id"],
            how="inner",
        )
        .join(
            trips_df.unique(subset=["shape_id"]).select(["shape_id", "route_id"]),
            on="shape_id",
            how="left",
        )
    )
    return result_df


def main() -> None:
    OUTPUT_DIR.mkdir(exist_ok=True)

    print("Loading stop positions…")
    stops_df = load_stop_positions()
    print(f"  {len(stops_df)} classified stops with position data")
    print(f"  near_side: {(stops_df['classification'] == 'near_side').sum()}, "
          f"far_side: {(stops_df['classification'] == 'far_side').sum()}")

    positions = stops_df["position"].to_numpy()
    is_nearside = (stops_df["classification"] == "near_side").to_numpy().astype(int)

    # ── Output 1: near-side fraction by position quintile ─────────────────────
    quintile_labels = ["0–20%", "20–40%", "40–60%", "60–80%", "80–100%"]
    edges = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0001]
    ns_fracs, n_stops = [], []
    for lo, hi in zip(edges[:-1], edges[1:]):
        mask = (positions >= lo) & (positions < hi)
        total = mask.sum()
        ns = is_nearside[mask].sum()
        ns_fracs.append(ns / total if total > 0 else np.nan)
        n_stops.append(total)

    fig, ax = plt.subplots(figsize=(8, 5))
    bars = ax.bar(quintile_labels, [f * 100 for f in ns_fracs], color="#e74c3c", edgecolor="white")
    ax.bar_label(bars, fmt="%.1f%%", padding=4, fontsize=10)
    for i, n in enumerate(n_stops):
        ax.text(i, 2, f"n={n}", ha="center", va="bottom", fontsize=8, color="white")
    ax.set_ylim(0, 100)
    ax.axhline(83, color="#555", linewidth=1, linestyle="--", label="System avg (83%)")
    ax.set_xlabel("Stop position along route")
    ax.set_ylabel("Near-side fraction (%)")
    ax.set_title("Near-Side Fraction by Route Position Quintile", fontsize=13, fontweight="bold")
    ax.legend()
    plt.tight_layout()
    fig.savefig(OUTPUT_DIR / "position_quintile_nearside.png", dpi=150)
    plt.close(fig)
    print("Wrote position_quintile_nearside.png")
    for lbl, frac, n in zip(quintile_labels, ns_fracs, n_stops):
        print(f"  {lbl}: {frac*100:.1f}%  (n={n})")

    # ── Logistic regression: position → near_side? ────────────────────────────
    # Manual MLE via scipy.optimize since sklearn is not available
    from scipy.optimize import minimize

    def neg_log_likelihood(params: np.ndarray) -> float:
        b0, b1 = params
        logit = b0 + b1 * positions
        prob = expit(logit)
        eps = 1e-12
        return -np.sum(is_nearside * np.log(prob + eps) + (1 - is_nearside) * np.log(1 - prob + eps))

    res = minimize(neg_log_likelihood, [0.0, 0.0], method="L-BFGS-B")
    b0, b1 = res.x
    coef = b1
    print(f"\nLogistic regression coef (position → near_side): {coef:.3f}")
    print("  (positive = more near-side toward end; negative = more far-side toward end)")

    # ── Output 2: KDE of position by classification ───────────────────────────
    from scipy.stats import gaussian_kde

    ns_pos = positions[is_nearside == 1]
    fs_pos = positions[is_nearside == 0]
    x_grid = np.linspace(0, 1, 300)

    fig, ax = plt.subplots(figsize=(8, 5))
    if len(ns_pos) > 5:
        kde_ns = gaussian_kde(ns_pos, bw_method=0.15)
        ax.fill_between(x_grid, kde_ns(x_grid), alpha=0.35, color="#e74c3c", label=f"Near-side (n={len(ns_pos)})")
        ax.plot(x_grid, kde_ns(x_grid), color="#e74c3c", linewidth=1.5)
    if len(fs_pos) > 5:
        kde_fs = gaussian_kde(fs_pos, bw_method=0.15)
        ax.fill_between(x_grid, kde_fs(x_grid), alpha=0.35, color="#2980b9", label=f"Far-side (n={len(fs_pos)})")
        ax.plot(x_grid, kde_fs(x_grid), color="#2980b9", linewidth=1.5)
    ax.set_xlabel("Normalized stop position (0 = route start, 1 = route end)")
    ax.set_ylabel("Density")
    ax.set_title("Distribution of Stop Position by Classification", fontsize=13, fontweight="bold")
    ax.legend()
    plt.tight_layout()
    fig.savefig(OUTPUT_DIR / "position_kde.png", dpi=150)
    plt.close(fig)
    print("Wrote position_kde.png")

    # ── Output 3: split-half route summaries ─────────────────────────────────
    route_df = (
        stops_df
        .with_columns(
            (pl.col("position") >= 0.5).alias("second_half")
        )
        .group_by(["route_id", "second_half"])
        .agg([
            (pl.col("classification") == "near_side").sum().alias("ns"),
            pl.len().alias("total"),
        ])
        .with_columns(
            (pl.col("ns") / pl.col("total")).alias("ns_frac")
        )
    )

    first_half_df = (
        route_df.filter(~pl.col("second_half"))
        .select(["route_id", pl.col("ns_frac").alias("ns_first_half"), pl.col("total").alias("n_first")])
    )
    second_half_df = (
        route_df.filter(pl.col("second_half"))
        .select(["route_id", pl.col("ns_frac").alias("ns_second_half"), pl.col("total").alias("n_second")])
    )
    overall_df = (
        stops_df
        .group_by("route_id")
        .agg([
            (pl.col("classification") == "near_side").sum().alias("ns_total"),
            pl.len().alias("total"),
        ])
        .with_columns((pl.col("ns_total") / pl.col("total")).alias("ns_overall"))
        .filter(pl.col("total") >= MIN_SIGNALIZED)
        .select(["route_id", "ns_overall", "total"])
    )

    db = get_db()
    otp_df = pl.from_records(
        db.execute(
            "SELECT route_id, AVG(otp) AS mean_otp FROM otp_monthly GROUP BY route_id"
        ).fetchall(),
        schema=["route_id", "mean_otp"],
        orient="row",
    )

    summary_df = (
        overall_df
        .join(first_half_df, on="route_id", how="left")
        .join(second_half_df, on="route_id", how="left")
        .join(otp_df, on="route_id", how="left")
        .drop_nulls(subset=["ns_overall", "mean_otp"])
    )
    summary_df.write_csv(OUTPUT_DIR / "route_position_summary.csv")
    print(f"\nWrote {len(summary_df)} rows → route_position_summary.csv")

    # Correlations
    for col, label in [
        ("ns_overall", "Overall near-side fraction"),
        ("ns_first_half", "First-half near-side fraction"),
        ("ns_second_half", "Second-half near-side fraction"),
    ]:
        sub = summary_df.drop_nulls(subset=[col, "mean_otp"])
        if len(sub) < 10:
            print(f"  {label}: too few routes")
            continue
        r, p = pearsonr(sub[col].to_numpy(), sub["mean_otp"].to_numpy())
        print(f"  {label}: r={r:.3f}  p={p:.4f}  n={len(sub)}")

    # ── Output 4: side-by-side scatter: first-half vs second-half vs OTP ──────
    sub = summary_df.drop_nulls(subset=["ns_first_half", "ns_second_half", "mean_otp"])
    if len(sub) >= 10:
        fig, axes = plt.subplots(1, 2, figsize=(12, 5), sharey=True)
        for ax, col, title, color in [
            (axes[0], "ns_first_half", "First-half near-side fraction", "#e67e22"),
            (axes[1], "ns_second_half", "Second-half near-side fraction", "#e74c3c"),
        ]:
            x = sub[col].to_numpy()
            y = sub["mean_otp"].to_numpy()
            r, p = pearsonr(x, y)
            ax.scatter(x * 100, y * 100, alpha=0.6, s=50, color=color,
                       edgecolors="white", linewidths=0.5)
            m, b = np.polyfit(x, y, 1)
            xline = np.linspace(x.min(), x.max(), 100)
            ax.plot(xline * 100, (m * xline + b) * 100, color="#333",
                    linewidth=1.5, linestyle="--")
            ax.set_title(f"{title}\nr = {r:.2f}  (p = {p:.3f})", fontsize=11, fontweight="bold")
            ax.set_xlabel("Near-side fraction (%)")
        axes[0].set_ylabel("Mean OTP (%)")
        fig.suptitle("Near-Side Fraction vs. Route OTP: First Half vs. Second Half of Route",
                     fontsize=12, fontweight="bold", y=1.01)
        plt.tight_layout()
        fig.savefig(OUTPUT_DIR / "splithalf_otp_comparison.png", dpi=150, bbox_inches="tight")
        plt.close(fig)
        print("Wrote splithalf_otp_comparison.png")

    print("\nDone.")


if __name__ == "__main__":
    main()

Sources

NameTypeWhy It MattersOwnerFreshnessCaveat
data/GTFS/stop_times.txt file GTFS stop sequence per trip. Local project data owner not specified. Snapshot file; refresh by rerunning its pipeline step. May lag upstream source updates.
data/GTFS/trips.txt file GTFS shape-to-route mapping. Local project data owner not specified. Snapshot file; refresh by rerunning its pipeline step. May lag upstream source updates.
analyses/53_stop_signal_placement/output/stop_classifications.csv file Per-stop near-side / far-side / mid-block classification from Analysis 53. Local project data owner not specified. Snapshot file; refresh by rerunning its pipeline step. May lag upstream source updates.
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.
analyses/53_stop_signal_placement 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.