Module 6: Reproducible pipeline

PUBHLT 0411 · Python for Public Health Data Analysis

Soumik Purkayastha

Dept. of Biostatistics and Health Data Science, University of Pittsburgh

November 17, 2026

A finding that contradicts what everyone knows.

Firearm death is understood as an urban problem, and Pennsylvania’s data disagrees

Firearm injury is commonly described as a problem of cities. Pennsylvania’s county-level data, correctly assembled, reports a higher firearm death rate outside metropolitan counties than inside them.

The question this session opens

Which Pennsylvania counties carry the highest burden of firearm death — and does the answer depend on how the analysis handles the counties that report nothing?

This session establishes the pipeline as a single reproducible object: import, clean, join, group, summarise, visualise, report — one document another person can run.

Required from Modules 4 and 5: pd.read_csv(), .dropna(), .merge(), .groupby(), .agg(), and plt.subplots().

This session is one working session, and the project starts in it

First twenty minutes The pipeline as one object, and the one error that reverses this finding
The rest of the session Lab — build the analysis, end to end, on the real data
What you leave with A document that runs top to bottom, and the paragraph that reports it

The pipeline is seven stages, and the whole session is one pass through it

Stage Verb Module
Import pd.read_csv() 4
Clean .dropna(), .fillna(), .astype() 4
Join .merge() 4
Group .groupby() 4
Summarise .agg(), derived columns 4
Visualise plt.subplots(), ax.bar() 5
Report prose, with the numbers in it 6

Every stage has already been taught. The new skill is running all seven as one object that another person can execute and get the same answer.

The data is two files, and the join is the reason there are two

pa_firearm_county_year.csv has 402 rows — 67 counties × 6 years, 2019–2024. It carries deaths and it does not carry metro status. pa_counties.csv carries metro status and it does not carry deaths.

Source

CDC, Mapping Injury, Overdose, and Violence — County (psx4-wq38); Census CO-EST2023 and the NCHS Urban–Rural Classification, 2013.

Forty percent of the cells report no number, and the reason is protective

CDC suppresses a county-year cell when the count is small enough that publishing it could identify a decedent. 159 of 402 cells carry no number. In 2023, 25 of 67 counties report nothing.

Suppression is not random, and that is the whole problem

Cells are suppressed because the count is small. Small counts occur in small counties, and small counties are disproportionately nonmetro — 19 of 30 nonmetro counties are suppressed in 2023, against 6 of 37 metro counties.

The pooled rate is total deaths over total population

A pooled rate sums the numerators and sums the denominators, then divides once. It is not the average of the county rates.

The same analysis without the .dropna() reverses the finding

2023, pooled Correct Without .dropna()
Metro 14.1 13.6
Nonmetro 18.5 8.2

.sum() skips missing values, and the population column does not. A suppressed county therefore contributes its full population to the denominator and zero deaths to the numerator, which drags the group rate downward — hardest where suppression concentrates.

This error raises no exception

Both versions run. Both produce a clean table. One says nonmetro rates are lower.

The suicide share explains the gap

Among counties reporting a death count, 62.1% of nonmetro firearm deaths are suicides, against 54.7% in metro counties.

The definition is part of the finding

Suppressed suicide counts are treated as zero here, across the counties that report a death total. Restricting instead to counties reporting both columns gives 83.7% for nonmetro — a different, much smaller denominator. The number moves when the definition moves, so the definition is stated with the number.

The finding holds across six years, so it is not one unusual year

Figure 1. Line chart of pooled firearm death rate per 100,000 for reporting Pennsylvania counties, 2019 to 2024, with two series distinguished by both colour and marker shape. Nonmetro counties, drawn in dark gold with triangular markers, run at 18.0 in 2019, dip to 13.4 in 2020, then rise to 16.3, 16.1, and 18.5 before falling to 15.7 in 2024. Metro counties, drawn in royal blue with circular markers, rise from 11.6 in 2019 to a peak of 15.3 in 2022, then fall to 14.1 and 12.4. The nonmetro line sits above the metro line in five of the six years; the exception is 2020.

Notebook hygiene is what makes the result reproducible

Rule Reason
Imports in the first cell A notebook that only runs after scrolling up is broken
Restart and run all, before submitting Out-of-order state is invisible until it is fatal
Relative paths — data/file.csv /Users/soumik/Desktop/... runs on exactly one machine
np.random.seed(...) if anything is random Same input, same output, every time
No manual edits to output A number typed by hand is not a result

The failure this prevents

A notebook where cell 12 was run, then cell 4 was edited, holds a value in memory that no longer matches any code on screen. The document is the record, not the memory.

Uncertainty is reported in words, with the counts that produced it

The nonmetro rate is built from 11 counties and 116 deaths. The metro rate is built from 31 counties and 1,567 deaths. A rate computed from a small number of events moves a great deal when one event is added or removed.

Written honestly Written badly
“18.5 per 100,000, from 116 deaths across 11 reporting counties” “18.5 per 100,000”
“Suppression removes 19 of 30 nonmetro counties” “Nonmetro counties: 18.5”
“The gap holds in five of six years” “Nonmetro rates are higher”

This course does not compute p-values

Uncertainty is described: how many events, how many counties, how much the estimate moves between years, and what suppression removed. A p-value is not required to report an estimate honestly, and it is not a substitute for reporting the counts.

The report is the deliverable, and it has a fixed shape

Move One or two sentences
The question What was asked, of which population, in which years
The data Source, coverage, and what is missing and why
The method The pipeline in prose — filtered, dropped, joined, pooled
The finding The numbers, with their denominators
The limit What this design cannot establish

Integrating computer output

Output is evidence inside a sentence, never a pasted block left to speak for itself. ✗ A bare table dropped into the document. ✓ “Among the 42 counties reporting a 2023 count, the pooled nonmetro rate was 18.5 per 100,000 against 14.1 in metro counties (Table 1).”

The finding, stated as it would appear in a report

Among the 42 Pennsylvania counties reporting a 2023 firearm death count, the pooled death rate was 18.5 per 100,000 in nonmetropolitan counties and 14.1 per 100,000 in metropolitan counties. Suicides account for 62.1% of nonmetropolitan firearm deaths and 54.7% of metropolitan ones. Because CDC suppresses small counts, 25 of 67 counties report no 2023 total, and suppression is concentrated in nonmetropolitan counties (19 of 30). Counties reporting no total are excluded from both the numerator and the denominator; retaining their population while summing only reported deaths reverses the comparison to 8.2 against 13.6. The nonmetropolitan rate exceeds the metropolitan rate in five of the six years from 2019 to 2024.

What this paragraph does

It states the numbers, the coverage, the definition, the sensitivity, and the consistency check. It makes no causal claim.

Lab

The remainder of the session is build time. Nine exercises assemble the pipeline in the order it runs, and the last one produces the paragraph.

1–4 Import, inspect, clean, and join — including the suppression decision
5 Question only, no scaffold — group and summarise, for the headline finding
6 The silent error, reproduced deliberately and repaired
7–8 Question only — the figure and its description
9 Question only — write the finding as a paragraph

Four of these supply no code

Exercises 5, 7, 8, and 9 state a question and the deliverable, and the cell is empty. The project will not supply a scaffold either.

This is the project skeleton

The final project replaces the question and the dataset. The seven stages do not change.

Exercise 1 — Import and inspect

Exercise 1 — Import and inspect

Worked example. A pipeline opens by loading its inputs and confirming their shape. An analysis that never checks what arrived is an analysis that trusts a file it has not read.

Exercise 1 — Import and inspect — your turn

Your turn. Load data/pa_counties.csv into county_reference. Print its shape, how many counties fall into each metro category, and confirm the file has one row per county.

# Four blanks: read_csv, shape, value_counts, nunique.
#
# The last line is the one worth thinking about. It asks whether the
# number of distinct county names equals the number of rows — if a county
# appeared twice, every rate you compute later would be wrong, and nothing
# would raise an error. Checking it at import costs one line.
import pandas as pd county_reference = pd.read_csv("data/pa_counties.csv") print(county_reference.shape) print(county_reference["metro"].value_counts()) print("one row per county:", county_reference["county"].nunique() == len(county_reference))
import pandas as pd

county_reference = pd.read_csv("data/pa_counties.csv")

print(county_reference.shape)
print(county_reference["metro"].value_counts())
print("one row per county:", county_reference["county"].nunique() == len(county_reference))

Sixty-seven, every time

Pennsylvania has 67 counties. A county-level table that does not have 67 rows has either lost some or gained duplicates, and finding that at import costs nothing.

Exercise 2 — Filter to one year

Exercise 2 — Filter to one year

Worked example. The file holds six years stacked in one column. A question about 2023 requires selecting those rows before anything else happens.

Exercise 2 — Filter to one year — your turn

Your turn. Build f2023, the 2023 rows only. Print its shape, how many firearm_deaths values are missing, and what fraction of counties that leaves reporting.

# Four blanks: firearm, firearm, ==, isna.
#
# A single = would try to assign, which is not what a filter does. You are
# asking each row a yes/no question, exactly as in Module 1.
#
# Note what the last line reports. Not a count — a proportion. "25 missing"
# means nothing without "of 67", and that fraction is the first thing a
# reader of your report will want.
f2023 = firearm[firearm["year"] == 2023] n_missing = f2023["firearm_deaths"].isna().sum() n_reporting = len(f2023) - n_missing print(f2023.shape) print(n_missing, "suppressed in 2023") print("reporting:", n_reporting, "of", len(f2023), f"({n_reporting / len(f2023):.0%})")
f2023 = firearm[firearm["year"] == 2023]

n_missing = f2023["firearm_deaths"].isna().sum()
n_reporting = len(f2023) - n_missing

print(f2023.shape)
print(n_missing, "suppressed in 2023")
print("reporting:", n_reporting, "of", len(f2023),
      f"({n_reporting / len(f2023):.0%})")

Exercise 3 — Clean, and decide about the blanks

Exercise 3 — Clean, and decide about the blanks

Worked example. .dropna(subset=[...]) removes rows missing a value in the named column and leaves every other column alone. Naming the column matters: dropping on the whole frame would also discard counties whose homicide count is suppressed but whose total is known.

Exercise 3 — Clean, and decide about the blanks — your turn

Your turn. Build reporting: the 2023 counties that report a firearm_deaths total, dropping on that column only. Then show what the careless version costs, by naming the counties it would discard even though their death total is known.

# Four blanks: dropna, subset, dropna, sum.
#
# The `lost` line is the point of the exercise. It subtracts the careless
# result from the careful one, so what remains is the counties whose death
# total is perfectly good but whose homicide or suicide cell happens to be
# suppressed. A bare .dropna() silently discards all 23 of them.
#
# Read the names it prints. Bucks, Chester, Westmoreland, Dauphin — these
# are among the largest counties in the state. Losing them is not a small
# rural rounding error; it would gut the metro group.
reporting = f2023.dropna(subset=["firearm_deaths"]) lost = set(reporting["county"]) - set(f2023.dropna()["county"]) print(len(reporting), "counties report a 2023 total") print(reporting["firearm_deaths"].sum(), "deaths among them") print(len(lost), "counties a bare dropna() would have thrown away:") print(sorted(lost))
reporting = f2023.dropna(subset=["firearm_deaths"])

lost = set(reporting["county"]) - set(f2023.dropna()["county"])

print(len(reporting), "counties report a 2023 total")
print(reporting["firearm_deaths"].sum(), "deaths among them")
print(len(lost), "counties a bare dropna() would have thrown away:")
print(sorted(lost))

Twenty-five counties just left the analysis

They left for a defensible reason, and the report must say so. A cleaning step that is not described in the writeup is a cleaning step the reader cannot check.

Exercise 4 — Join

Exercise 4 — Join

Worked example. .merge() attaches columns from a second table by matching on a shared key. how="left" keeps every row of the left table whether or not it finds a match, which makes an unmatched key visible instead of silent.

Exercise 4 — Join — your turn

Your turn. Merge reporting with the county, metro, and population_2023 columns of county_reference, matching on county. Confirm the row count is unchanged and that nothing failed to match.

# Five blanks: merge, on, left, ==, isna.
#
# The row-count check catches a failure the missing-value check cannot. If
# the right-hand table had two rows for some county, the merge would
# silently DUPLICATE the left-hand row and that county's deaths would be
# counted twice. A join can lose rows and it can invent them; check both.
#
# `on="county"` says the two tables are lined up by county name. If these
# files spelled a county differently — the real "Mc Kean" / "McKean" bug
# from Module 4 — that county would arrive with a missing metro value,
# which is exactly what the second print statement is checking for.
before = len(reporting) joined = reporting.merge( county_reference[["county", "metro", "population_2023"]], on="county", how="left" ) print(joined.shape) print("rows preserved:", len(joined) == before) print(joined["metro"].isna().sum(), "rows failed to match")
before = len(reporting)

joined = reporting.merge(
    county_reference[["county", "metro", "population_2023"]],
    on="county",
    how="left"
)

print(joined.shape)
print("rows preserved:", len(joined) == before)
print(joined["metro"].isna().sum(), "rows failed to match")

Exercise 5 — Group and summarise

Exercise 5 — Group and summarise

Worked example. .groupby().agg() collapses many rows into one row per group, naming each output column and the operation that fills it. "size" counts rows; "sum" totals a column.

Exercise 5 — Group and summarise — your turn

Your turn — no scaffold. Answer this question from joined:

Do Pennsylvanians living outside metropolitan counties die by firearm at a higher rate than those living in them?

Produce a table called summary, one row per metro group, reporting for each group: how many counties it contains, its total firearm deaths, its total population, and its firearm death rate per 100,000 people. Print it.

# Two verbs and one arithmetic line, all of which you have used before.
#
# Group `joined` by the metro column, and inside .agg() name each output
# column and the (column, operation) pair that fills it. "size" counts the
# rows in a group; "sum" totals a column. That gets you counties, deaths,
# and population.
#
# Then the rate. A rate is events divided by the population at risk, times
# 100,000 — which is what makes it "per 100,000" rather than a proportion.
# Add it as a new column on the grouped table, not on `joined`.
#
# Name the columns exactly: counties, deaths, population, rate_per_100k.
summary = joined.groupby("metro").agg( counties=("county", "size"), deaths=("firearm_deaths", "sum"), population=("population_2023", "sum"), ) summary["rate_per_100k"] = summary["deaths"] / summary["population"] * 100000 print(summary.round(1))
summary = joined.groupby("metro").agg(
    counties=("county", "size"),
    deaths=("firearm_deaths", "sum"),
    population=("population_2023", "sum"),
)

summary["rate_per_100k"] = summary["deaths"] / summary["population"] * 100000
print(summary.round(1))

That is the finding

18.5 against 14.1. The rate outside metropolitan Pennsylvania is higher, and it was produced by five ordinary lines of pandas.

Exercise 6 — The error that raises nothing

Exercise 6 — The error that raises nothing

Worked example. The pipeline below is identical to Exercise 5 except for one omission. It runs without complaint and returns the opposite finding.

The suppressed counties were never removed. .sum() skips their missing deaths; the population column does not skip their people.

Exercise 6 — The error that raises nothing — your turn

Your turn. Repair it, then measure the damage the broken version did: how many people were sitting in the nonmetro denominator with no deaths above them, and how far that pushed the nonmetro rate.

# Five blanks: dropna, subset, population, -, -.
#
# Think about what belongs in a denominator. A rate's denominator is the
# population a counted event could have happened to. If a county's deaths
# were never published, its people cannot sit in the bottom of the fraction
# while nothing of theirs is in the top.
#
# The last two lines quantify the error rather than just correcting it.
# That number — how far wrong the careless version was — is what belongs in
# a writeup's sensitivity sentence.
repaired = f2023.dropna(subset=["firearm_deaths"]).merge( county_reference[["county", "metro", "population_2023"]], on="county", how="left" ) fixed = repaired.groupby("metro").agg( deaths=("firearm_deaths", "sum"), population=("population_2023", "sum"), ) fixed["rate_per_100k"] = fixed["deaths"] / fixed["population"] * 100000 phantom = (broken_summary.loc["Nonmetro", "population"] - fixed.loc["Nonmetro", "population"]) understated_by = (fixed.loc["Nonmetro", "rate_per_100k"] - broken_summary.loc["Nonmetro", "rate_per_100k"]) print(fixed.round(1)) print(f"{phantom:,} nonmetro residents were in the denominator with no deaths above them") print(f"the broken rate understated nonmetro by {understated_by:.1f} per 100,000")
repaired = f2023.dropna(subset=["firearm_deaths"]).merge(
    county_reference[["county", "metro", "population_2023"]], on="county", how="left"
)

fixed = repaired.groupby("metro").agg(
    deaths=("firearm_deaths", "sum"),
    population=("population_2023", "sum"),
)
fixed["rate_per_100k"] = fixed["deaths"] / fixed["population"] * 100000

phantom = (broken_summary.loc["Nonmetro", "population"]
           - fixed.loc["Nonmetro", "population"])
understated_by = (fixed.loc["Nonmetro", "rate_per_100k"]
                  - broken_summary.loc["Nonmetro", "rate_per_100k"])

print(fixed.round(1))
print(f"{phantom:,} nonmetro residents were in the denominator with no deaths above them")
print(f"the broken rate understated nonmetro by {understated_by:.1f} per 100,000")

Running is not checking

The broken version produced a clean table with plausible numbers and no warning. Every result in a report needs a reason to be believed that is stronger than the absence of an error message.

Exercise 7 — Visualise

Exercise 7 — Visualise

Worked example. A two-bar comparison is the right figure for a two-group finding. The zero baseline is what keeps the comparison honest, and the value labels remove the need to read heights against a gridline.

Figure 2. Bar chart of the pooled 2023 firearm death rate for the 42 reporting Pennsylvania counties. The metro bar, in royal blue, reaches 14.1 deaths per 100,000. The nonmetro bar, in dark gold, reaches 18.5. Both bars are labelled with their value and the vertical axis starts at zero.

Exercise 7 — Visualise — your turn

Your turn — no scaffold. A reader accepts that the nonmetro rate is higher and asks the obvious follow-up: what kind of firearm death is driving it?

Build a table called share giving, for each metro group, its total firearm deaths, its total firearm suicides, and suicides as a percentage of firearm deaths. Treat a suppressed suicide count as zero. Then draw a figure that lets a reader compare the two percentages, and print the table.

Requirements the figure must meet

A labelled vertical axis · a title · a baseline at zero · each bar labelled with its value · colours drawn from the course palette (#003594, #8a6400).

# Build the table first, the figure second.
#
# For the table: a suppressed suicide count is missing, not zero, so
# .fillna(0) converts it before you total it. Then group `joined` by metro
# and .agg() the deaths and the suicides, exactly as in Exercise 5. The
# percentage is suicides / deaths * 100, added as a new column.
# Name that column suicide_share_pct.
#
# For the figure: fig, ax = plt.subplots(...), then ax.bar() with the two
# category names and the two values. ax.bar_label(bars, fmt="%.1f") writes
# the value above each bar. ax.set_ylim(0, 80) fixes the baseline at zero —
# starting a bar axis above zero makes a small difference look enormous,
# which Module 5 called axis truncation.
#
# Keep a reference to the axes in `ax`; the check inspects it.
joined["suicides"] = joined["firearm_suicide"].fillna(0) share = joined.groupby("metro").agg(deaths=("firearm_deaths", "sum"), suicides=("suicides", "sum")) share["suicide_share_pct"] = share["suicides"] / share["deaths"] * 100 fig, ax = plt.subplots(figsize=(6, 3.2)) bars = ax.bar(["Metro", "Nonmetro"], [share.loc["Metro", "suicide_share_pct"], share.loc["Nonmetro", "suicide_share_pct"]], color=["#003594", "#8a6400"], edgecolor="white") ax.bar_label(bars, fmt="%.1f", padding=3) ax.set_ylim(0, 80) ax.set_ylabel("Suicide share of firearm deaths (%)") ax.set_title("Suicide share of 2023 firearm deaths, 42 reporting PA counties") plt.tight_layout() plt.show() print(share.round(1))
joined["suicides"] = joined["firearm_suicide"].fillna(0)
share = joined.groupby("metro").agg(deaths=("firearm_deaths", "sum"),
                                    suicides=("suicides", "sum"))
share["suicide_share_pct"] = share["suicides"] / share["deaths"] * 100

fig, ax = plt.subplots(figsize=(6, 3.2))
bars = ax.bar(["Metro", "Nonmetro"],
              [share.loc["Metro", "suicide_share_pct"],
               share.loc["Nonmetro", "suicide_share_pct"]],
              color=["#003594", "#8a6400"], edgecolor="white")
ax.bar_label(bars, fmt="%.1f", padding=3)
ax.set_ylim(0, 80)
ax.set_ylabel("Suicide share of firearm deaths (%)")
ax.set_title("Suicide share of 2023 firearm deaths, 42 reporting PA counties")
plt.tight_layout()
plt.show()

print(share.round(1))

Exercise 8 — Describe the figure

Exercise 8 — Describe the figure

Worked example. A figure that cannot be read aloud is unavailable to part of its audience and to anyone whose image fails to load. A description states the chart type, the axes, and the values — not the conclusion.

Exercise 8 — Describe the figure — your turn

Your turn — no scaffold. Someone using a screen reader will hear your description instead of seeing the figure you drew in Exercise 7. Write it into a string called share_description, and print it.

They must be able to reconstruct the figure from your words alone: what kind of chart it is, what the two groups are, what quantity is being shown, both of its values, and where the vertical axis begins. Describe what is on the page, not what it means — the conclusion is the reader’s to draw.

# Two or three sentences, written for someone who cannot see the screen.
#
# Work through what they would need: the chart type ("bar chart"), the two
# categories (metro and nonmetro), the quantity (suicide share of firearm
# deaths, as a percentage), both values (62.1 and 54.7), and the fact that
# the axis starts at zero — that last one tells them the comparison has not
# been visually exaggerated.
#
# Resist writing the conclusion. "Nonmetro counties have a higher suicide
# share" is your interpretation; the reader is entitled to reach it, or
# not, from the same numbers a sighted reader gets.
share_description = ( "Bar chart of the suicide share of 2023 firearm deaths in the 42 reporting " "Pennsylvania counties. The metro bar reaches 54.7 percent and the nonmetro " "bar reaches 62.1 percent. The vertical axis starts at zero." ) print(share_description)
share_description = (
    "Bar chart of the suicide share of 2023 firearm deaths in the 42 reporting "
    "Pennsylvania counties. The metro bar reaches 54.7 percent and the nonmetro "
    "bar reaches 62.1 percent. The vertical axis starts at zero."
)

print(share_description)

Where this goes

In an ordinary Quarto document, this text is the figure’s alt text. In these slides it is printed under the figure as visible text, because the slide format does not carry alt text through to the browser.

Exercise 9 — Report the finding

Exercise 9 — Report the finding

Worked example. A reported number is assembled from the pipeline’s own variables rather than typed by hand. A number typed by hand stops being a result the moment the data changes.

Exercise 9 — Report the finding — your turn

Your turn — no scaffold. Write the paragraph a reader would see, into a string called finding, and print it.

It must state, in prose: how many counties reported a count and out of how many; both pooled rates, identified by group; how many deaths the nonmetropolitan estimate rests on and across how many counties; and that counties reporting no total were excluded from both the numerator and the denominator.

Every number must be computed, not typed

Build the sentence with an f-string reading from summary and reporting. A number typed by hand stops being a result the moment the data changes.

# Pull each number out of the pipeline first, give it a name, then write
# one f-string that reads those names. Something like:
#
#   n_reporting   = len(reporting)
#   nonmetro_rate = summary.loc["Nonmetro", "rate_per_100k"]
#
# and so on for the metro rate, the nonmetro death count, and the nonmetro
# county count. Inside an f-string, {nonmetro_rate:.1f} prints one decimal.
#
# Report the count behind the rate, not just the rate. A reader can then
# judge how much the estimate would move if one death had landed in a
# different year. That sentence is what this course asks for instead of a
# p-value.
#
# The test of whether you did this right: change the year at the top of
# your pipeline to 2022 and run everything again. A sentence built from
# f-strings updates itself. A sentence with the numbers typed in keeps
# claiming 18.5 while the table above it says something else.
n_reporting = len(reporting) n_total = len(f2023) nonmetro_rate = summary.loc["Nonmetro", "rate_per_100k"] metro_rate = summary.loc["Metro", "rate_per_100k"] nonmetro_deaths = int(summary.loc["Nonmetro", "deaths"]) nonmetro_counties = int(summary.loc["Nonmetro", "counties"]) finding = ( f"Among the {n_reporting} of {n_total} Pennsylvania counties reporting a 2023 " f"firearm death count, the pooled rate was {nonmetro_rate:.1f} per 100,000 in " f"nonmetropolitan counties against {metro_rate:.1f} in metropolitan counties. " f"The nonmetropolitan estimate rests on {nonmetro_deaths} deaths across " f"{nonmetro_counties} reporting counties. Counties reporting no total are " f"excluded from both the numerator and the denominator." ) print(finding)
n_reporting = len(reporting)
n_total = len(f2023)
nonmetro_rate = summary.loc["Nonmetro", "rate_per_100k"]
metro_rate = summary.loc["Metro", "rate_per_100k"]
nonmetro_deaths = int(summary.loc["Nonmetro", "deaths"])
nonmetro_counties = int(summary.loc["Nonmetro", "counties"])

finding = (
    f"Among the {n_reporting} of {n_total} Pennsylvania counties reporting a 2023 "
    f"firearm death count, the pooled rate was {nonmetro_rate:.1f} per 100,000 in "
    f"nonmetropolitan counties against {metro_rate:.1f} in metropolitan counties. "
    f"The nonmetropolitan estimate rests on {nonmetro_deaths} deaths across "
    f"{nonmetro_counties} reporting counties. Counties reporting no total are "
    f"excluded from both the numerator and the denominator."
)

print(finding)

Restart and run all

Before this notebook counts as finished, restart the runtime and run every cell from the top. A result that only exists in memory is not a result.

Session outcomes

1. The seven-stage pipeline — import, clean, join, group, summarise, visualise, report

2. Suppression as a protective mechanism, and its non-random pattern

3. The silent denominator error, and the fact that it reverses this finding

4. Notebook hygiene: imports at the top, relative paths, seeds, restart-and-run-all

5. Uncertainty stated verbally — counts, coverage, and consistency across years

6. The five-move report, and computer output integrated into sentences

The finding, paid off

Nonmetro 18.5 per 100,000 against metro 14.1, with suicides at 62.1% of nonmetro firearm deaths against 54.7% in metro counties. Handled carelessly, the same data reports 8.2 against 13.6 and confirms the opposite.

The final project applies this pipeline to a question of the student’s own. The seven stages do not change — only the question and the data do.