import pandas as pd
1. Data Sources¶
Stroke mortality data was obtained from the Underlying Cause of Death, 2018-2024, Single Race Request database by CDC WONDER (Wide-ranging Online Data for Epidemiologic Research).
To capture the full spectrum of stroke mortality, deaths were segmented into two distinct clinical classifications based on ICD-10 codes:
| Category | Description | ICD-10 Codes / Saved Query | Target Variable |
|---|---|---|---|
| Acute stroke deaths | Deaths where stroke was the direct, acute cause | I60–I64 / D510F921 | acute_stroke_mortality_per_100k |
| Sequelae of stroke deaths | Deaths attributed to long-term/late effects of a prior stroke | I69 / D510F922 | sequelae_stroke_mortality_per_100k |
CDC WONDER population estimates are unavailable at the traditional county level for Connecticut from 2022 onward due to the state's transition to Planning Regions. Therefore, a substitute for CDC WONDER's population data was pulled from the cleaned acs_data.csv file (which was sourced from U.S. Census Bureau’s 2023 American Community Survey (ACS) 5-Year Estimates).
2. Temporal Scope: Why Data Was Pooled Across Years¶
County-level death counts, especially for a single cause like stroke, can fluctuate substantially year to year, particularly in lower-population counties. A single bad (or good) year can produce a misleadingly volatile rate. To address this, seven years of data (2018–2024) were pooled before calculating rates to create a stable, representative death count baseline that aligns its center right at 2021.
The 2023 5-Year ACS Estimates reflect a period estimate pooling data collected over 60 months between 2019 and 2023, which also structurally centers the dataset's operational midpoint on 2021.
3. Handling Suppressed Death Counts¶
CDC WONDER suppresses any cell where the death count falls between 1 and 9, displaying it as "Suppressed" rather than the actual number, in order to protect privacy in low-count cells.
Suppressed cells were replaced with a fixed proxy value of 5 (the exact middle of that range), making it the value that minimizes the maximum possible distortion in either direction.
4. Mortality Rate Calculation Methodology:¶
To compare the 7-year stroke data (2018-2024) fairly against our 5-year estimate census data from ACS, the pooled death total is first converted into an annualized average before being related to the population baseline.
$$\text{Annual Mortality Rate per 100,000} = \frac{\dfrac{\text{Total deaths from 2018–2024}}{\text{7 years}}}{\text{ACS total population}} \times 100000$$
Acute Stroke Mortality: acute_stroke_mortality_per_100k¶
# Import data for acute stroke deaths
df1 = pd.read_csv('Acute Stroke_Underlying Cause of Death, 2018-2024, Single Race.csv', dtype={'County Code':str}, skipfooter=68, engine='python')
df1.tail()
| Notes | State | State Code | County | County Code | Deaths | Population | Crude Rate | Crude Rate Lower 95% Confidence Interval | Crude Rate Upper 95% Confidence Interval | |
|---|---|---|---|---|---|---|---|---|---|---|
| 86 | NaN | New York | 36 | Washington County, NY | 36115 | 77 | 424690 | 18.1 | 14.3 | 22.7 |
| 87 | NaN | New York | 36 | Wayne County, NY | 36117 | 101 | 632955 | 16.0 | 12.8 | 19.1 |
| 88 | NaN | New York | 36 | Westchester County, NY | 36119 | 757 | 6886506 | 11.0 | 10.2 | 11.8 |
| 89 | NaN | New York | 36 | Wyoming County, NY | 36121 | 33 | 278686 | 11.8 | 8.2 | 16.6 |
| 90 | NaN | New York | 36 | Yates County, NY | 36123 | 39 | 172457 | 22.6 | 16.1 | 30.9 |
# Keep only important columns
df1 = df1[['State', 'County', 'County Code', 'Deaths']]
df1.rename(columns={'County Code': 'fips'}, inplace=True)
df1.head()
| State | County | fips | Deaths | |
|---|---|---|---|---|
| 0 | Connecticut | Fairfield County, CT | 09001 | 803 |
| 1 | Connecticut | Hartford County, CT | 09003 | 853 |
| 2 | Connecticut | Litchfield County, CT | 09005 | 246 |
| 3 | Connecticut | Middlesex County, CT | 09007 | 163 |
| 4 | Connecticut | New Haven County, CT | 09009 | 822 |
# Import ACS data to get counties' total population
acs_df = pd.read_csv('../acs_data.csv', dtype={'fips':str})
acs_df.head(2)
| fips | county | state | total_pop | pcnt_65_plus | poverty_rate | pcnt_insured | pcnt_bachelors | pcnt_low_income | pcnt_middle_class | pcnt_upper_class | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 36001 | Albany | NY | 315374 | 17.8 | 12.9 | 97.0 | 41.6 | 29.9 | 39.1 | 12.1 |
| 1 | 36003 | Allegany | NY | 47027 | 19.7 | 16.8 | 95.1 | 21.0 | 40.2 | 39.1 | 4.1 |
# Merge ACS data with df1
acute = pd.merge(acs_df[['fips', 'county', 'state', 'total_pop']], df1[['fips','Deaths']], on='fips', how='left')
acute
| fips | county | state | total_pop | Deaths | |
|---|---|---|---|---|---|
| 0 | 36001 | Albany | NY | 315374 | 256 |
| 1 | 36003 | Allegany | NY | 47027 | 51 |
| 2 | 36005 | Bronx | NY | 1419250 | 913 |
| 3 | 36007 | Broome | NY | 197738 | 225 |
| 4 | 36009 | Cattaraugus | NY | 76479 | 94 |
| ... | ... | ... | ... | ... | ... |
| 86 | 09007 | Middlesex | CT | 164983 | 163 |
| 87 | 09009 | New Haven | CT | 862028 | 822 |
| 88 | 09011 | New London | CT | 267707 | 318 |
| 89 | 09013 | Tolland | CT | 146907 | 95 |
| 90 | 09015 | Windham | CT | 116156 | 118 |
91 rows × 5 columns
acute.info()
<class 'pandas.DataFrame'> RangeIndex: 91 entries, 0 to 90 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 fips 91 non-null str 1 county 91 non-null str 2 state 91 non-null str 3 total_pop 91 non-null int64 4 Deaths 91 non-null str dtypes: int64(1), str(4) memory usage: 3.7 KB
# Search for rows where death count is suppressed
acute.loc[acute['Deaths']=='Suppressed']
| fips | county | state | total_pop | Deaths | |
|---|---|---|---|---|---|
| 20 | 36041 | Hamilton | NY | 5102 | Suppressed |
# Replace "Suppressed" with "5"
acute.loc[acute['Deaths']=='Suppressed', 'Deaths'] = '5'
acute.loc[acute['county'] == 'Hamilton County',:]
| fips | county | state | total_pop | Deaths |
|---|
# Now "Deaths" column can be converted to integer
acute['Deaths'] = acute['Deaths'].astype('int64')
acute.info()
<class 'pandas.DataFrame'> RangeIndex: 91 entries, 0 to 90 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 fips 91 non-null str 1 county 91 non-null str 2 state 91 non-null str 3 total_pop 91 non-null int64 4 Deaths 91 non-null int64 dtypes: int64(2), str(3) memory usage: 3.7 KB
# Calculate the mortality rate
acute['acute_stroke_mortality_per_100k'] = (((acute['Deaths'] / 7) / acute['total_pop']) *100000).round(2)
acute
| fips | county | state | total_pop | Deaths | acute_stroke_mortality_per_100k | |
|---|---|---|---|---|---|---|
| 0 | 36001 | Albany | NY | 315374 | 256 | 11.60 |
| 1 | 36003 | Allegany | NY | 47027 | 51 | 15.49 |
| 2 | 36005 | Bronx | NY | 1419250 | 913 | 9.19 |
| 3 | 36007 | Broome | NY | 197738 | 225 | 16.26 |
| 4 | 36009 | Cattaraugus | NY | 76479 | 94 | 17.56 |
| ... | ... | ... | ... | ... | ... | ... |
| 86 | 09007 | Middlesex | CT | 164983 | 163 | 14.11 |
| 87 | 09009 | New Haven | CT | 862028 | 822 | 13.62 |
| 88 | 09011 | New London | CT | 267707 | 318 | 16.97 |
| 89 | 09013 | Tolland | CT | 146907 | 95 | 9.24 |
| 90 | 09015 | Windham | CT | 116156 | 118 | 14.51 |
91 rows × 6 columns
# Remove total_pop and Deaths columns
acute.drop(columns=['total_pop','Deaths'], inplace=True)
acute.head(3)
| fips | county | state | acute_stroke_mortality_per_100k | |
|---|---|---|---|---|
| 0 | 36001 | Albany | NY | 11.60 |
| 1 | 36003 | Allegany | NY | 15.49 |
| 2 | 36005 | Bronx | NY | 9.19 |
Sequelae of Stroke Mortality: sequelae_stroke_mortality_per_100k¶
# Import CDC data for sequelae stroke deaths
df2 = pd.read_csv('Sequelae of Stroke_Underlying Cause of Death, 2018-2024, Single Race.csv', dtype={'County Code':str}, skipfooter=64, engine='python')
df2.tail()
| Notes | State | State Code | County | County Code | Deaths | Population | Crude Rate | Crude Rate Lower 95% Confidence Interval | Crude Rate Upper 95% Confidence Interval | |
|---|---|---|---|---|---|---|---|---|---|---|
| 90 | NaN | New York | 36.0 | Westchester County, NY | 36119 | 97 | 6886506 | 1.4 | 1.1 | 1.7 |
| 91 | NaN | New York | 36.0 | Wyoming County, NY | 36121 | Suppressed | 278686 | Suppressed | Suppressed | Suppressed |
| 92 | NaN | New York | 36.0 | Yates County, NY | 36123 | Suppressed | 172457 | Suppressed | Suppressed | Suppressed |
| 93 | Total | New York | 36.0 | NaN | NaN | 2148 | 137284074 | 1.6 | 1.5 | 1.6 |
| 94 | Total | NaN | NaN | NaN | NaN | 4613 | 215578231 | 2.1 | 2.1 | 2.2 |
df2.drop(index = df2[df2['Notes'] == 'Total'].index, inplace=True)
df2.tail()
| Notes | State | State Code | County | County Code | Deaths | Population | Crude Rate | Crude Rate Lower 95% Confidence Interval | Crude Rate Upper 95% Confidence Interval | |
|---|---|---|---|---|---|---|---|---|---|---|
| 88 | NaN | New York | 36.0 | Washington County, NY | 36115 | Suppressed | 424690 | Suppressed | Suppressed | Suppressed |
| 89 | NaN | New York | 36.0 | Wayne County, NY | 36117 | 18 | 632955 | 2.8 | 1.7 | 4.5 |
| 90 | NaN | New York | 36.0 | Westchester County, NY | 36119 | 97 | 6886506 | 1.4 | 1.1 | 1.7 |
| 91 | NaN | New York | 36.0 | Wyoming County, NY | 36121 | Suppressed | 278686 | Suppressed | Suppressed | Suppressed |
| 92 | NaN | New York | 36.0 | Yates County, NY | 36123 | Suppressed | 172457 | Suppressed | Suppressed | Suppressed |
# Keep only important columns
df2 = df2[['State', 'County', 'County Code', 'Deaths']]
df2.rename(columns={'County Code': 'fips'}, inplace=True)
df2.head(3)
| State | County | fips | Deaths | |
|---|---|---|---|---|
| 0 | Connecticut | Fairfield County, CT | 09001 | 90 |
| 1 | Connecticut | Hartford County, CT | 09003 | 116 |
| 2 | Connecticut | Litchfield County, CT | 09005 | 25 |
# Merge ACS data with df2
sequelae = pd.merge(acs_df[['fips', 'county', 'state', 'total_pop']], df2[['fips','Deaths']], on='fips', how='left')
sequelae
| fips | county | state | total_pop | Deaths | |
|---|---|---|---|---|---|
| 0 | 36001 | Albany | NY | 315374 | 30 |
| 1 | 36003 | Allegany | NY | 47027 | 12 |
| 2 | 36005 | Bronx | NY | 1419250 | 119 |
| 3 | 36007 | Broome | NY | 197738 | 42 |
| 4 | 36009 | Cattaraugus | NY | 76479 | 13 |
| ... | ... | ... | ... | ... | ... |
| 86 | 09007 | Middlesex | CT | 164983 | 20 |
| 87 | 09009 | New Haven | CT | 862028 | 139 |
| 88 | 09011 | New London | CT | 267707 | 47 |
| 89 | 09013 | Tolland | CT | 146907 | 16 |
| 90 | 09015 | Windham | CT | 116156 | 17 |
91 rows × 5 columns
# Replace "Suppressed" with "5"
sequelae.loc[sequelae['Deaths']=='Suppressed', 'Deaths'] = '5'
sequelae.tail()
| fips | county | state | total_pop | Deaths | |
|---|---|---|---|---|---|
| 86 | 09007 | Middlesex | CT | 164983 | 20 |
| 87 | 09009 | New Haven | CT | 862028 | 139 |
| 88 | 09011 | New London | CT | 267707 | 47 |
| 89 | 09013 | Tolland | CT | 146907 | 16 |
| 90 | 09015 | Windham | CT | 116156 | 17 |
# Conver Death column to integer before calculating the mortality rate
sequelae['Deaths'] = sequelae['Deaths'].astype('int64')
sequelae.info()
<class 'pandas.DataFrame'> RangeIndex: 91 entries, 0 to 90 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 fips 91 non-null str 1 county 91 non-null str 2 state 91 non-null str 3 total_pop 91 non-null int64 4 Deaths 91 non-null int64 dtypes: int64(2), str(3) memory usage: 3.7 KB
# Calculating the mortality rate
sequelae['sequelae_stroke_mortality_per_100k'] = (((sequelae['Deaths'] / 7) / sequelae['total_pop']) *100000).round(2)
sequelae
| fips | county | state | total_pop | Deaths | sequelae_stroke_mortality_per_100k | |
|---|---|---|---|---|---|---|
| 0 | 36001 | Albany | NY | 315374 | 30 | 1.36 |
| 1 | 36003 | Allegany | NY | 47027 | 12 | 3.65 |
| 2 | 36005 | Bronx | NY | 1419250 | 119 | 1.20 |
| 3 | 36007 | Broome | NY | 197738 | 42 | 3.03 |
| 4 | 36009 | Cattaraugus | NY | 76479 | 13 | 2.43 |
| ... | ... | ... | ... | ... | ... | ... |
| 86 | 09007 | Middlesex | CT | 164983 | 20 | 1.73 |
| 87 | 09009 | New Haven | CT | 862028 | 139 | 2.30 |
| 88 | 09011 | New London | CT | 267707 | 47 | 2.51 |
| 89 | 09013 | Tolland | CT | 146907 | 16 | 1.56 |
| 90 | 09015 | Windham | CT | 116156 | 17 | 2.09 |
91 rows × 6 columns
# Remove total_pop and Deaths columns
sequelae.drop(columns=['total_pop','Deaths'], inplace=True)
sequelae.head(3)
| fips | county | state | sequelae_stroke_mortality_per_100k | |
|---|---|---|---|---|
| 0 | 36001 | Albany | NY | 1.36 |
| 1 | 36003 | Allegany | NY | 3.65 |
| 2 | 36005 | Bronx | NY | 1.20 |
Merge Acute Stroke Mortality Rate with Sequelae Stroke Mortality Rate¶
stroke_mortality = pd.merge(acute, sequelae[['fips','sequelae_stroke_mortality_per_100k']], on='fips', how='left')
stroke_mortality.head(3)
| fips | county | state | acute_stroke_mortality_per_100k | sequelae_stroke_mortality_per_100k | |
|---|---|---|---|---|---|
| 0 | 36001 | Albany | NY | 11.60 | 1.36 |
| 1 | 36003 | Allegany | NY | 15.49 | 3.65 |
| 2 | 36005 | Bronx | NY | 9.19 | 1.20 |
stroke_mortality.tail(3)
| fips | county | state | acute_stroke_mortality_per_100k | sequelae_stroke_mortality_per_100k | |
|---|---|---|---|---|---|
| 88 | 09011 | New London | CT | 16.97 | 2.51 |
| 89 | 09013 | Tolland | CT | 9.24 | 1.56 |
| 90 | 09015 | Windham | CT | 14.51 | 2.09 |
stroke_mortality.info()
<class 'pandas.DataFrame'> RangeIndex: 91 entries, 0 to 90 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 fips 91 non-null str 1 county 91 non-null str 2 state 91 non-null str 3 acute_stroke_mortality_per_100k 91 non-null float64 4 sequelae_stroke_mortality_per_100k 91 non-null float64 dtypes: float64(2), str(3) memory usage: 3.7 KB
stroke_mortality.to_csv('../stroke_mortality.csv', index=False)