Module 4: pandas and wrangling

PUBHLT 0411 · Python for Public Health Data Analysis

Soumik Purkayastha

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

October 29, 2026

Twenty-three blanks.

Sometimes official statistics aren’t reported because they’re not trustworthy!

Sixty-seven counties, twenty-three blanks

Figure 1. All 67 Pennsylvania counties in alphabetical order, one tile each. Tiles with a published infant mortality rate are shaded blue, darker for higher rates, from Bucks at 3.4 to Mifflin at 10.2 per 1,000. Twenty-three tiles are white with a dashed gold border and a dash in place of a number: Bradford, Cameron, Carbon, Clinton, Columbia, Elk, Forest, Fulton, Greene, Jefferson, Juniata, McKean, Montour, Pike, Potter, Snyder, Sullivan, Susquehanna, Tioga, Union, Warren, Wayne, and Wyoming.

A DataFrame is a table with named columns

pd.read_csv() reads an entire table in one call. .shape gives rows and columns, .head(n) the first rows, and .dtypes the type of each column.

Inspect before computing

A column read as text when it should be numeric produces no error at read time. The failure appears later, when arithmetic on it returns something wrong or raises TypeError.

.info() and .describe() report what is missing

The table has 67 rows. infant_mortality_per_1000 has 44 non-null values.

The gap is the finding, not an obstacle

Twenty-three counties are missing a rate — the answer to the question this session opened, found in the second minute of inspection. In .describe(), count is the number of present values, and every other statistic is computed over it.

A column is a Series; .loc and .iloc select rows

A Series is one column: values plus an index that labels them. .count() counts present values; .size counts rows. .loc[row, column] selects by label; .iloc[row, column] by position.

KeyError names a label that is not there

births["infant_mortality"] raises KeyError: 'infant_mortality' — the column is infant_mortality_per_1000. Resolution: print df.columns.tolist() and copy the name.

A boolean mask filters rows

The mask is Module 3’s boolean array. Passing it to [] keeps the rows where it is True, and every column comes along.

This is what the DataFrame buys

Module 3’s mask filtered one array and left the names behind. Here the county names, the rates, and the incomes all move together, because they are rows of one object.

Combining conditions requires &, |, and parentheses

The parentheses are mandatory

& binds more tightly than >. Without parentheses, Python evaluates 20 & births[...] first and raises TypeError. This is the most common pandas error after KeyError.

Resolution: parenthesise every comparison. (a > 1) & (b < 2), never a > 1 & b < 2.

.assign(), .rename(), and .drop() change the columns

.assign() adds a derived column. .rename(columns={old: new}) renames. .drop(columns=[...]) removes. All three return a new DataFrame and leave the original unchanged.

.drop() needs columns=

.drop("year") alone raises KeyError, because .drop() removes a row by default.

.groupby() splits, applies, and combines

.groupby(column) splits the rows into groups, applies the aggregation to each, and combines the results into a table indexed by the grouping value. .sort_values() orders rows, and every column travels with its row.

count is the denominator

.agg() takes several functions at once. count reports how many rows carried a value in each group — the number the mean and median were computed over.

.merge() attaches a second table’s columns

on= names the key column. The result carries the columns of both tables.

The grouping variables live in a different file

Neither region nor metro appears in any outcome file. Every grouped comparison by place is possible only after a join. Compare the row count before and after every join.

Missing is not zero

.isna().sum() counts the gaps in every column. Filling with zero asserts that no infant died in those twenty-three counties. The state withheld the figure precisely because it is not zero.

.fillna() writes a claim into the data

Every fill value is an assertion about the world. Zero asserts absence. The mean asserts the county is typical. Neither is neutral, and both belong in the writeup.

Grouped rates are biased downward by suppression

.sum() skips NaN, but the denominator counts every county

A suppressed county contributes its population and none of its deaths. The rate is therefore too low, and no error is raised.

.melt() and .pivot() reshape a table

.melt(): wide → long. One row per county-year, with the year as data. .pivot(): long → wide, the reverse.

Long form is what .groupby() and plotting expect

Published tables arrive wide because wide is readable; analysis wants long because long is groupable.

pd.to_datetime() makes a date column behave like a date

A text column sorts alphabetically. A datetime column sorts chronologically and exposes .dt.year, .dt.month, and .dt.day_name().

The twenty-three.

Which counties, and what they share.

Exercise: who is missing, and what do they share?

# The blank is left. Every county in the births table should survive the
# join, whether or not it has a published rate — dropping rows here would
# delete the very counties the question is about.
#
# inner returns the same 67 rows in this case, because the keys match
# perfectly. left states the intent, and it is the join that stays correct
# if a key ever stops matching.
import pandas as pd births = pd.read_csv("data/pa_maternal_infant.csv") counties = pd.read_csv("data/pa_counties.csv") joined = births.merge(counties, on="county", how="left") joined["suppressed"] = joined["infant_mortality_per_1000"].isna() print(f"Suppressed counties: {joined['suppressed'].sum()} of {len(joined)}") print() print(joined.groupby("suppressed")["population_2023"].median()) print() print(pd.crosstab(joined["metro"], joined["suppressed"]))
import pandas as pd

births = pd.read_csv("data/pa_maternal_infant.csv")
counties = pd.read_csv("data/pa_counties.csv")

joined = births.merge(counties, on="county", how="left")

joined["suppressed"] = joined["infant_mortality_per_1000"].isna()

print(f"Suppressed counties: {joined['suppressed'].sum()} of {len(joined)}")
print()
print(joined.groupby("suppressed")["population_2023"].median())
print()
print(pd.crosstab(joined["metro"], joined["suppressed"]))

pd.crosstab(a, b) counts one categorical against another.

Twenty-three rural counties

Figure 2. Each mark is one county, positioned by population on a logarithmic scale. The twenty-three counties with no published infant mortality rate, gold squares on the upper row, sit almost entirely below 70,000 residents with a median of 37,607. The forty-four counties that do publish a rate, blue circles on the lower row, run from about 45,000 to well over a million with a median of 157,825. The two groups barely overlap.

Twenty-three of Pennsylvania’s sixty-seven counties have no published infant mortality rate. Eighteen of them are nonmetro. Median population: 37,607, against 157,825 for the counties that report.

Challenge: what does the rural rate describe?

Group the joined table by metro status and report how many counties stand behind each mean.

# The blank is the aggregation that counts PRESENT values, the one that
# differs from "size" wherever data is missing.
by_metro = joined.groupby("metro")["infant_mortality_per_1000"].agg(["size", "count", "mean"]) print(by_metro.round(2))
by_metro = joined.groupby("metro")["infant_mortality_per_1000"].agg(["size", "count", "mean"])

print(by_metro.round(2))

Your turn!

Nonmetro infant mortality is 7.38 per 1,000 against 5.68 for metro. How many nonmetro counties does 7.38 describe, and which ones are left out? Is 7.38 the rate of rural Pennsylvania?

Session outcomes

1. Reading a table with pd.read_csv(); Series, DataFrame, and Index

2. Inspecting with .head(), .info(), .describe(), .shape, and .dtypes

3. Selecting with [], .loc by label, and .iloc by position

4. Filtering with boolean masks and & and | with parentheses

5. Changing columns with .assign(), .rename(), and .drop()

6. Summarising with .groupby() and .agg(); ordering with .sort_values()

7. Joining with .merge() on a shared key, and checking the row count

8. Handling missing data with .isna(), .dropna(), and .fillna() — missing is not zero

9. Reshaping with .melt() and .pivot(); dates with pd.to_datetime() and .dt

Preparation for the lab, Thursday 11/5

The lab is six days after this lecture, across Election Day. It restates each construct before exercising it, so returning to it cold is expected and planned for.

Module 5 turns these tables into figures.

That’s all folks!

See you on Thursday — no class Tuesday, it’s Election Day.