Analysis

46 - Population Transit Proximity

Coverage: Coverage window unavailable for this page.

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

Page Navigation

Analysis Navigation

Data Provenance

flowchart LR
  46_population_transit_proximity(["46 - Population Transit Proximity"])
  t_stops[("stops")] --> 46_population_transit_proximity
  01_data_ingestion[["Data Ingestion"]] --> t_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_census_tracts[("census_tracts")] --> 46_population_transit_proximity
  d1_46_population_transit_proximity(("geopandas (lib)")) --> 46_population_transit_proximity
  d2_46_population_transit_proximity(("polars (lib)")) --> 46_population_transit_proximity
  d3_46_population_transit_proximity(("scipy (lib)")) --> 46_population_transit_proximity
  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 46_population_transit_proximity page;
  class t_census_tracts,t_stops table;
  class d1_46_population_transit_proximity,d2_46_population_transit_proximity,d3_46_population_transit_proximity 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 Transit Proximity

Key findings

  1. Denser census tracts sit much closer to PRT transit. Across the 386 populated census tracts in Allegheny County, population density and distance to the nearest stop are strongly negatively correlated (Spearman ρ = −0.62, p < 0.001). The county's people-dense neighborhoods are, as a rule, its best-served.

  2. The proximity gradient is steep and consistent. Sorting tracts into population-density quartiles, the median distance to the nearest stop falls monotonically as density rises:

    | Density quartile | Median distance to nearest stop | |------------------|---------------------------------| | Q1 (sparsest) | 1,350 m (~0.84 mi) | | Q2 | 417 m | | Q3 | 194 m | | Q4 (densest) | 151 m (~0.09 mi) |

    A resident of the densest quartile of tracts is typically within a two-minute walk of a stop; in the sparsest quartile the nearest stop is nearly nine times farther away.

  3. The typical tract is well served. The median Allegheny County tract centroid is just 318 m from the nearest stop — comfortably inside a quarter-mile walk.

  4. Coverage thins out in the lower-density tracts. 301 of 394 tracts have their centroid within 805 m (≈ ½ mile) of a stop, but those tracts hold only 67.5% of the county's population. The roughly one-third of residents beyond that threshold are concentrated in the lower-density tracts that the gradient above shows are farthest from service.

  5. Together with Analysis 44, this answers both halves of the access question. Analysis 44 ranks which routes reach the most people; this analysis shows that proximity to transit tracks population density — PRT's network is built where the people are, and the unserved share lives in the spread-out periphery.

Limitations

  • Area-level (ecological) result. The correlation describes census tracts, not individuals. It does not measure who rides transit or how far any particular resident walks.
  • Straight-line distance from the tract centroid. The real walk follows the street network and is usually longer; Pittsburgh's rivers and hillsides make this gap larger than in a flat, gridded city. A centroid also misrepresents large or irregularly shaped tracts.
  • Stop presence is not service quality. A nearby stop may be served only hourly. Proximity is necessary but not sufficient for useful transit access.
  • Allegheny County only. Tracts in the four adjacent counties present in the census_tracts table are excluded, since PRT service there is sparse and would dominate the "far from transit" tail without being informative.

Validation

  • Data source verified. Tract population, land area, and polygons from the census_tracts table (prt.db), loaded via walksheds.load_tracts and checked against the CENSUS_TRACTS schema. Stop coordinates from the stops table.
  • Geographic scope matches. Both inputs are filtered to Allegheny County (county_fips = '003'), 394 tracts — consistent with the 2020 Census tract geography for the county.
  • Null/missing handling. Stops with null coordinates are excluded. The 8 tracts with zero ACS population are excluded from the density correlation and quartiles (density is undefined for them) but retained for the coverage count.
  • Direction of effects checked. Density and distance are negatively correlated and the quartile gradient is monotonic — the expected direction (transit is built where people are). A positive correlation would have been a red flag.
  • Surprising results investigated. None. The result is consistent with Analysis 44 and with standard transit-geography expectations; no value fell outside the plausible range.
  • Small-sample note. Distances are deterministic geographic measures, not sampled estimates, so no minimum-observation threshold applies. The correlation uses all 386 populated tracts.

Output

Methods

Methods: Population Transit Proximity

Question

Within Allegheny County, do more densely populated census tracts sit closer to a PRT transit stop than sparsely populated ones? This is the proximity counterpart to Analysis 44 (which ranks routes by the population they reach): here the unit of analysis is the census tract, and the question is whether the people-dense parts of the county are the best served.

Approach

  1. Tracts. Load Allegheny County census tracts (county_fips = '003') from the census_tracts table via prt_otp_analysis.walksheds.load_tracts, which returns polygons projected to EPSG:32617 (UTM 17N, meters).
  2. Stops. Load every PRT stop with valid coordinates from the stops table and project the points to the same meter-based CRS.
  3. Proximity. For each tract, compute the straight-line distance from the tract polygon centroid to the nearest stop, using a nearest-neighbor spatial join (geopandas.sjoin_nearest).
  4. Density. Compute population density as population / land_area_km2 (land area converted from the table's land_area_m2).
  5. Correlation. Test the association between tract population density and distance to the nearest stop with a Spearman rank correlation. The expected sign is negative — denser tracts closer to transit. A positive sign would be a red flag, not a finding.
  6. Density-quartile gradient. Split tracts into population-density quartiles and report the median distance to the nearest stop in each, to show the gradient in plain terms.
  7. Coverage. Classify each tract as within 805 m (≈ 1/2 mile) of a stop and report both the share of tracts and the share of county population that falls inside that walk-shed.

Data

  • census_tracts (prt.db) — geoid, county_fips, population, land_area_m2, tract polygon (geometry_wkt). Filtered to Allegheny County.
  • stops (prt.db) — stop_id, lat, lon. Stops with null coordinates are excluded.

Output

  • output/tract_transit_proximity.csv — per tract: population, density, distance to nearest stop, density quartile, and within-805 m flag.
  • output/density_vs_distance.png — scatter of tract population density against distance to the nearest stop, with the Spearman result annotated.
  • output/distance_by_density_quartile.png — bar chart of median distance to the nearest stop by population-density quartile.

Caveats

  • Results are area-level (ecological) associations: they describe census tracts, not individual residents or their travel behavior.
  • Distance is straight-line from the tract centroid, not a network walk. Rivers, hillsides, and highways mean the real walk is often longer, and a centroid can misrepresent a large or irregularly shaped tract.
  • Stop presence is not service quality: a nearby stop may be served infrequently. Proximity is a necessary, not sufficient, condition for useful transit access.

Source Code

"""Analysis 46: test whether denser Allegheny County census tracts sit closer
to PRT transit stops than sparsely populated ones."""

from pathlib import Path

import geopandas as gpd
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,
)
from prt_otp_analysis.walksheds import CRS_GEO, CRS_M, load_tracts

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

ALLEGHENY_FIPS = "003"
HALF_MILE_M = 805.0  # ~1/2 mile, the standard transit walk-shed threshold


def load_stops() -> gpd.GeoDataFrame:
    """Load PRT stops with valid coordinates, projected to meters."""
    stop_df = query_to_polars(
        "SELECT stop_id, lat, lon FROM stops WHERE lat IS NOT NULL AND lon IS NOT NULL"
    )
    gdf = gpd.GeoDataFrame(
        stop_df.to_pandas(),
        geometry=gpd.points_from_xy(stop_df["lon"], stop_df["lat"]),
        crs=CRS_GEO,
    )
    return gdf.to_crs(CRS_M)


def tract_proximity(tracts_gdf: gpd.GeoDataFrame, stops_gdf: gpd.GeoDataFrame) -> pl.DataFrame:
    """Distance from each tract centroid to its nearest stop, with density."""
    centroids = tracts_gdf[["geoid", "population", "land_area_m2"]].copy()
    centroids = gpd.GeoDataFrame(
        centroids, geometry=tracts_gdf.geometry.centroid, crs=CRS_M
    )
    nearest = gpd.sjoin_nearest(
        centroids, stops_gdf[["geometry"]], distance_col="dist_to_stop_m"
    )
    # sjoin_nearest can emit ties; keep the closest row per tract.
    nearest = nearest.sort_values("dist_to_stop_m").drop_duplicates("geoid")

    tract_df = pl.from_pandas(
        nearest[["geoid", "population", "land_area_m2", "dist_to_stop_m"]]
    )
    tract_df = tract_df.with_columns(
        pop_density=pl.col("population") / (pl.col("land_area_m2") / 1_000_000),
        within_half_mile=pl.col("dist_to_stop_m") <= HALF_MILE_M,
    )
    # 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 analyze(tract_df: pl.DataFrame) -> dict:
    """Spearman correlation, quartile gradient, and coverage summary."""
    populated = tract_df.filter(pl.col("population") > 0)
    rho, p = stats.spearmanr(
        populated["pop_density"], populated["dist_to_stop_m"]
    )
    quartile_df = (
        populated.group_by("density_quartile")
        .agg(
            n_tracts=pl.len(),
            median_dist_m=pl.col("dist_to_stop_m").median(),
            median_density=pl.col("pop_density").median(),
        )
        .sort("density_quartile")
    )
    served = tract_df.filter(pl.col("within_half_mile"))
    total_pop = tract_df["population"].sum()
    return {
        "n_tracts": tract_df.height,
        "n_populated": populated.height,
        "spearman_rho": rho,
        "spearman_p": p,
        "quartile_df": quartile_df,
        "tracts_within_half_mile": tract_df["within_half_mile"].sum(),
        "pop_within_half_mile": served["population"].sum(),
        "total_pop": total_pop,
        "pop_coverage_pct": 100 * served["population"].sum() / total_pop,
        "median_dist_m": tract_df["dist_to_stop_m"].median(),
    }


def make_charts(tract_df: pl.DataFrame, results: dict) -> None:
    """Density-vs-distance scatter and the quartile gradient bar chart."""
    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["dist_to_stop_m"].to_list(),
        color="#3b82f6", s=28, alpha=0.6, edgecolors="white", linewidths=0.5,
    )
    ax.set_xscale("log")
    ax.set_xlabel("Tract population density (people / km², log scale)")
    ax.set_ylabel("Distance to nearest stop (meters)")
    ax.set_title("Population Density vs Distance to Nearest PRT Stop\n(Allegheny County census tracts)")
    ax.annotate(
        f"Spearman rho = {results['spearman_rho']:.3f} (p = {results['spearman_p']:.3g})",
        xy=(0.04, 0.93), xycoords="axes fraction", fontsize=10,
    )
    save_chart(fig, OUT / "density_vs_distance.png")

    quartile_df = results["quartile_df"]
    fig, ax = plt.subplots(figsize=(9, 6))
    labels = ["Q1\n(sparsest)", "Q2", "Q3", "Q4\n(densest)"]
    ax.bar(labels, quartile_df["median_dist_m"].to_list(), color="#3b82f6")
    ax.set_ylabel("Median distance to nearest stop (meters)")
    ax.set_title("Transit Proximity by Population-Density Quartile")
    for i, d in enumerate(quartile_df["median_dist_m"].to_list()):
        ax.text(i, d, f"{d:,.0f} m", ha="center", va="bottom", fontsize=9)
    save_chart(fig, OUT / "distance_by_density_quartile.png")


@run_analysis(46, "Population Transit Proximity")
def main() -> None:
    """Entry point: load data, compute proximity, analyze, chart, and save."""
    with phase("Loading Allegheny County tracts and PRT stops"):
        tracts_gdf = load_tracts(extra_cols=["county_fips"])
        tracts_gdf = tracts_gdf[tracts_gdf["county_fips"] == ALLEGHENY_FIPS].copy()
        stops_gdf = load_stops()
        print(f"  {len(tracts_gdf)} tracts, {len(stops_gdf)} stops")

    with phase("Computing tract-to-stop proximity"):
        tract_df = tract_proximity(tracts_gdf, stops_gdf)

    with phase("Analyzing"):
        results = analyze(tract_df)
        print(f"  Density vs distance: Spearman rho = {results['spearman_rho']:.4f} "
              f"(p = {results['spearman_p']:.3g}), n = {results['n_populated']} populated tracts")
        print(f"  Median tract distance to nearest stop: {results['median_dist_m']:,.0f} m")
        for row in results["quartile_df"].iter_rows(named=True):
            print(f"    Q{row['density_quartile']}: median {row['median_dist_m']:,.0f} m "
                  f"to nearest stop ({row['n_tracts']} tracts)")
        print(f"  Coverage: {results['tracts_within_half_mile']} of {results['n_tracts']} "
              f"tracts and {results['pop_coverage_pct']:.1f}% of county population "
              f"within {HALF_MILE_M:.0f} m of a stop")
        save_csv(tract_df, OUT / "tract_transit_proximity.csv")

    with phase("Generating charts"):
        make_charts(tract_df, results)


if __name__ == "__main__":
    main()

Sources

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