School Data

Code
import geopandas as gpd
from matplotlib import pyplot as plt
from fuzzywuzzy import fuzz

School Location and Catchment Area Analysis

Code
# school locations as points
school_pnts = gpd.read_file('./data/Schools.geojson').to_crs(3857)

# school catchment areas for the Philadelphia Public School District
school_ct_es = gpd.read_file('./data/psd_catchments/SchoolDist_Catchments_ES.geojson').to_crs(3857)
school_ct_ms = gpd.read_file('./data/psd_catchments/SchoolDist_Catchments_MS.geojson').to_crs(3857)
school_ct_hs = gpd.read_file('./data/psd_catchments/SchoolDist_Catchments_HS.geojson').to_crs(3857)

# Philadelphia city boundary from PA school district shapefile
phl_boundary = gpd.read_file('./data/PaSchoolDistricts2024_03.geojson').query('CTY_NAME == "PHILADELPHIA"').to_crs(3857) 

The city of Philadelphia provides the locations of most schools within the boundaries of Philadelphia County. Also available are catchment areas - regions of the city where residents’ children get priority admission status and do not need apply to attend their associated schools - as well as boundaries for school districts within Pennsylvania, of which the School District of Philadelphia was extracted as an external boundary for this analysis.

Code
# plot school locations by grade level using geopandas with philly boundary overlaid
m = school_pnts.explore(
    column='grade_level',
    cmap='gist_rainbow',
    marker_kwds={'radius': 3},
    legend=True,
    tiles='CartoDB Positron',
    tooltip=True,
    legend_kwds={'caption': 'Grade Level'}
)
phl_boundary.explore(
    m=m,
    color='grey',
    style_kwds={'fillOpacity': 0, 'weight': 2},
    tooltip=False, highlight=False
)
Make this Notebook Trusted to load map: File -> Trust Notebook
Code
# create a table of the count of each school type
school_type_counts = school_pnts['type_specific'].value_counts()
print("\nSchool Type Counts:\n", school_type_counts, sep="")
schools_pnts_district = school_pnts.query('type_specific == "DISTRICT"')

School Type Counts:
DISTRICT       235
CHARTER        102
PRIVATE         99
ARCHDIOCESE     40
CONTRACTED      19
Name: type_specific, dtype: int64

There are 235 schools in the Philadelphia School Points dataset that are classified as “District,” meaning that they are within the Philadelphia School District. However, these are further divied up by District and Alternative Education schools, the latter of while is not specified in the dataset (SDP, 2025). The next step will be to confirm that they can be spatially joined with the school catchment areas dataset in order to confirm that the datasets correspond (i.e. 1 school per catchment area, as indicated in the ‘ES_/MS_/HS_ID’ values). While both datasets are relatively current, the points dataset provides a more accurate dataset of which schools are currently in operation. No duplicated ID values exist in any of the catchment area datasets, so theoretically the district school points should only join one-to-one.

Code
# confirm length of the elementary catchment zone before joining to confirm success
len(school_ct_es)
160
Code
# check which school catchment areas have missing elementary school assignments by spatially joining school points filtered to just elementaries
school_ct_es_sj = gpd.sjoin(school_ct_es, schools_pnts_district[schools_pnts_district['grade_level'].str.contains("elementary", case=False)][['school_name', 'location_id', 'geometry']], how='left', predicate='contains', rsuffix='pnt')

# check for duplicated schools
print("Number of Duplicate Rows Post-Join:", len(school_ct_es_sj) - len(school_ct_es))
dupes_esid = school_ct_es_sj[school_ct_es_sj['ES_NAME'].duplicated(keep=False)]
dupes_esid
Number of Duplicate Rows Post-Join: 10
OBJECTID ES_ID ES_NAME MS_ID MS_NAME HS_ID HS_NAME GR_ID_K GR_ID_01 GR_ID_02 ... GR_ID_12 ES_GRADE MS_GRADE HS_GRADE Shape__Area Shape__Length geometry index_pnt school_name location_id
42 43 1470 Locke, Alain 1470 Locke, Alain 1020 West Philadelphia HS 1470 1470 1470 ... 1020 K-8 K-8 9-12 2.699519e+06 8661.940577 POLYGON ((-8371731.267 4860788.060, -8371834.5... 170.0 PHILADELPHIA JUVENILE JUSTICE SERVICES CENTER 2530
42 43 1470 Locke, Alain 1470 Locke, Alain 1020 West Philadelphia HS 1470 1470 1470 ... 1020 K-8 K-8 9-12 2.699519e+06 8661.940577 POLYGON ((-8371731.267 4860788.060, -8371834.5... 299.0 LOCKE, ALAIN SCHOOL 1470
50 51 2490 Waring, Laura W 2490 Waring, Laura W 2010 Franklin, Benjamin HS 2490 2490 2490 ... 2010 K-8 K-8 9-12 2.642568e+06 7631.090463 POLYGON ((-8367280.204 4861703.700, -8367364.3... 481.0 STUDENT TRANSITION CENTER NaN
50 51 2490 Waring, Laura W 2490 Waring, Laura W 2010 Franklin, Benjamin HS 2490 2490 2490 ... 2010 K-8 K-8 9-12 2.642568e+06 7631.090463 POLYGON ((-8367280.204 4861703.700, -8367364.3... 202.0 PHILADELPHIA VIRTUAL ACADEMY 8780
50 51 2490 Waring, Laura W 2490 Waring, Laura W 2010 Franklin, Benjamin HS 2490 2490 2490 ... 2010 K-8 K-8 9-12 2.642568e+06 7631.090463 POLYGON ((-8367280.204 4861703.700, -8367364.3... 383.0 WARING, LAURA W. SCHOOL 2490
54 55 7200 Barton, Clara 7500 Feltonville School of Arts and Sciences 7060 Olney High School 7200 7200 7200 ... 7060 K-2 6-8 9-12 5.283003e+06 13091.581638 POLYGON ((-8361520.256 4869850.387, -8361524.1... 438.0 CROSSROADS @ HUNTING PARK 3190
54 55 7200 Barton, Clara 7500 Feltonville School of Arts and Sciences 7060 Olney High School 7200 7200 7200 ... 7060 K-2 6-8 9-12 5.283003e+06 13091.581638 POLYGON ((-8361520.256 4869850.387, -8361524.1... 132.0 FELTONVILLE INTERMEDIATE 7310
54 55 7200 Barton, Clara 7500 Feltonville School of Arts and Sciences 7060 Olney High School 7200 7200 7200 ... 7060 K-2 6-8 9-12 5.283003e+06 13091.581638 POLYGON ((-8361520.256 4869850.387, -8361524.1... 165.0 BARTON SCHOOL 7200
60 61 4240 Cassidy, Lewis C 4240 Cassidy, Lewis C 4020 Overbrook High 4240 4240 4240 ... 4020 K-8 K-8 9-12 3.027245e+06 8085.200227 POLYGON ((-8376356.755 4862790.797, -8376503.6... 303.0 OVERBROOK EDUCATIONAL CENTER 4480
60 61 4240 Cassidy, Lewis C 4240 Cassidy, Lewis C 4020 Overbrook High 4240 4240 4240 ... 4020 K-8 K-8 9-12 3.027245e+06 8085.200227 POLYGON ((-8376356.755 4862790.797, -8376503.6... 179.0 CASSIDY,LEWIS C ACADEMICS PLUS 4240
62 63 4270 Dick, William 4270 Dick, William 4140 Strawberry Mansion HS 4270 4270 4270 ... 4140 K-8 K-8 9-12 1.038674e+06 5326.762372 POLYGON ((-8367452.044 4863904.448, -8367414.7... 116.0 DICK, WILLIAM SCHOOL 4270
62 63 4270 Dick, William 4270 Dick, William 4140 Strawberry Mansion HS 4270 4270 4270 ... 4140 K-8 K-8 9-12 1.038674e+06 5326.762372 POLYGON ((-8367452.044 4863904.448, -8367414.7... 493.0 (TEMPORARY) DICK, WILLIAM SCHOOL 4270
119 120 6340 Pennell, Joseph 7130 Wagner, General Louis 6060 King, Martin Luther 6340 6340 6340 ... 6060 K-5 6-8 9-12 2.168672e+06 7422.991708 POLYGON ((-8365266.834 4872662.887, -8365383.8... 320.0 WIDENER MEMORIAL SCHOOL 6400
119 120 6340 Pennell, Joseph 7130 Wagner, General Louis 6060 King, Martin Luther 6340 6340 6340 ... 6060 K-5 6-8 9-12 2.168672e+06 7422.991708 POLYGON ((-8365266.834 4872662.887, -8365383.8... 353.0 PENNELL, JOSEPH ELEMENTARY 6340
153 154 6470 Kelly, John B 6360 Roosevelt, Theodore 6060 King, Martin Luther 6470 6470 6470 ... 6060 K-5 K-8 9-12 4.803549e+06 8820.115823 POLYGON ((-8368326.224 4871059.161, -8368412.3... 336.0 KELLY, JOHN B. SCHOOL 6470
153 154 6470 Kelly, John B 6360 Roosevelt, Theodore 6060 King, Martin Luther 6470 6470 6470 ... 6060 K-5 K-8 9-12 4.803549e+06 8820.115823 POLYGON ((-8368326.224 4871059.161, -8368412.3... 134.0 FITLER ACADEMICS PLUS 6230
157 158 8130 Northeast Community Propel Academy 8130 Northeast Community Propel Academy 8010 Lincoln, Abraham 8130 8130 8130 ... 8010 K-8 K-8 9-12 6.065923e+06 14488.884903 POLYGON ((-8352877.171 4872634.423, -8352918.8... 468.0 NORTHEAST COMMUNITY PROPEL ACADEMY\n 8130\n
157 158 8130 Northeast Community Propel Academy 8130 Northeast Community Propel Academy 8010 Lincoln, Abraham 8130 8130 8130 ... 8010 K-8 K-8 9-12 6.065923e+06 14488.884903 POLYGON ((-8352877.171 4872634.423, -8352918.8... 492.0 (TEMPORARY) HOLME, THOMAS SCHOOL 8270

18 rows × 29 columns

There are 10 duplicate rows that resulted from this join, which indicates that there are 10 elementary schools labeled as “District” that fall within the catchment area of a district school, yet they are not associated with the ES_ID value of that catchment area. While having multiple schools within a catchment zone does not necessarily detract from the analysis - catchment IDs can be manually associated with each point through a spatial join - it cannot be guaranteed that the majority of students traveling to that school will be coming from that catchment area. Therefore, these 10 duplicate rows need to be identified and removed from the points dataset.

Code
# function to compute fuzzy name match ratios between two columns and return a list of ratio
def name_match(list1, list2):
    # create empty list to store results
    results = []
    
    # iterate through the lists (same length)
    for i in range(len(list1)):
        
        # compute fuzzy match ratio
        ratio = fuzz.ratio(list1[i], list2[i])
        
        # append ratio to results list
        results.append(ratio)
    return results

# function that identifies duplicate school catchment areas based on spatial join to school points 
def exclude_schools(df, sch_level, catchment_name):
    
    # spatially join gdf to school points
    sch_ctchmt_join = gpd.sjoin(df, schools_pnts_district[schools_pnts_district['grade_level'].str.contains(sch_level, case=False)][['school_name', 'geometry']], how='left', predicate='contains', rsuffix='pnt')

    # identify duplicates based on catchment_name
    dupes = sch_ctchmt_join[sch_ctchmt_join[catchment_name].duplicated(keep=False)]

    # create copy to avoid warnings
    dupes_cpy = dupes.copy()

    list1 = dupes[catchment_name].tolist()
    list2 = dupes['school_name'].tolist()
    
    # apply name matching function to lists of catchment names and school names in the duplicates dataframe
    dupes_cpy['name_match'] = name_match(list1, list2)
    
    # sort by descending name_match score and drop the first duplicate catchment_name, keeping only the poorer matches
    dupes_cpy_sort = dupes_cpy.sort_values(by='name_match', ascending=False)
    
    # identify duplicate rows to drop
    dupes_to_drop = dupes_cpy_sort[dupes_cpy_sort.groupby(catchment_name).cumcount() != 0]
    
    # return list of point indices to drop
    pnt_ind_to_drop = dupes_to_drop['index_pnt']

    # filter original schools points gdf to exclude duplicates
    result = schools_pnts_district[schools_pnts_district['grade_level'].str.contains(sch_level, case=False)].drop(index=pnt_ind_to_drop)
    
    return result
Code
# confirm which strings to use for filtering schools to spatially join to catchment areas
school_pnts['grade_level'].unique()
array(['ELEMENTARY/MIDDLE', 'PRE-K/KINDERGARTEN', 'ELEMENTARY SCHOOL',
       'MIDDLE/HIGH', 'MIDDLE SCHOOL', 'HIGH SCHOOL',
       'ELEMENTARY/MIDDLE/HIGH', 'UNGRADED', 'SPECIAL CENTER',
       'ELEMENTARY?MIDDLE/HIGH', nan, 'PRE-K/ELEMENTARY', 'ELEMENTARY'],
      dtype=object)
Code
# identify the schools that need to be excluded from each catchment area type
es_exclude = exclude_schools(school_ct_es, 'elementary', 'ES_NAME')
ms_exclude = exclude_schools(school_ct_ms, 'middle', 'MS_NAME')
hs_exclude = exclude_schools(school_ct_hs, 'high', 'HS_NAME')
Code
# Sanity Check: compare original counts, filtered counts, and catchment zone counts
school_info = {
    "Elementary": {"exclude": es_exclude, "catchment": school_ct_es},
    "Middle": {"exclude": ms_exclude, "catchment": school_ct_ms},
    "High": {"exclude": hs_exclude, "catchment": school_ct_hs}
}

for level, info in school_info.items():
    original_count = len(schools_pnts_district[
        schools_pnts_district['grade_level'].str.contains(level, case=False)
    ])
    filtered_count = len(info['exclude'])
    catchment_count = len(info['catchment'])
    
    print(f"{level} School Counts:")
    print(f"  Original: {original_count}")
    print(f"  Filtered: {filtered_count}")
    print(f"  Catchment Zones: {catchment_count}\n")
Elementary School Counts:
  Original: 156
  Filtered: 146
  Catchment Zones: 160

Middle School Counts:
  Original: 134
  Filtered: 115
  Catchment Zones: 126

High School Counts:
  Original: 66
  Filtered: 20
  Catchment Zones: 22

Individually, approximately 91% of each category of catchment area is represented by district school points in the dataset that are served by those catchment areas. While the reason for this is unclear, this analysis will only concern catchment areas that have an associated school point.

Code
# spatially filter catchment areas to only those with matched schools
school_ct_es_filt = school_ct_es.sjoin(es_exclude[['location_id', 'geometry']], how='inner', predicate='contains')
school_ct_ms_filt = school_ct_ms.sjoin(ms_exclude[['location_id', 'geometry']], how='inner', predicate='contains')
school_ct_hs_filt = school_ct_hs.sjoin(hs_exclude[['location_id', 'geometry']], how='inner', predicate='contains')
Code
# faceted plot of the catchment areas with their respective filtered schools
fig, axes = plt.subplots(1, 3, figsize=(18, 6))

# Elementary Schools
school_ct_es_filt.plot(ax=axes[0], color='honeydew', edgecolor='grey')
es_exclude.plot(ax=axes[0], color='forestgreen', markersize=10)
axes[0].set_title('Elementary School Catchment Areas')

# Middle Schools
school_ct_ms_filt.plot(ax=axes[1], color='lightcyan', edgecolor='grey')
ms_exclude.plot(ax=axes[1], color='steelblue', markersize=10)
axes[1].set_title('Middle School Catchment Areas')

# High Schools
school_ct_hs_filt.plot(ax=axes[2], color='mistyrose', edgecolor='grey')
hs_exclude.plot(ax=axes[2], color='maroon', markersize=10)
axes[2].set_title('High School Catchment Areas')

# edit plot elements
for ax in axes:
    ax.axis('off')
plt.tight_layout()
plt.show()