Code
import geopandas as gpd
from matplotlib import pyplot as plt
from fuzzywuzzy import fuzz# 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.
# 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
)
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.
160
# 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_esidNumber 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.
# 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 resultarray(['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)
# 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.
# 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')# 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()