PUBHLT 0411 · Python for Public Health Data Analysis
Soumik Purkayastha
Dept. of Biostatistics and Health Data Science, University of Pittsburgh
October 22, 2026
Nine micrograms.
Sixty-seven counties and one federal threshold. No loops allowed!
The EPA annual standard for PM2.5 is 9 micrograms per cubic metre
Twenty-two of Pennsylvania’s 67 counties exceed it. One exceeds it by a wide margin.
The question this session opens
Ranking 67 counties against a threshold, and measuring how far each sits from the state’s typical value, requires arithmetic on every county at once.
This session establishes the array: one object holding all 67 values, arithmetic that applies to every element in a single expression, and selection by condition rather than by position.
Required from Module 2:for, the accumulator pattern, def and return, and the distinction between counts and rates.
Most counties sit near the standard; Allegheny sits far above it
Figure 1. PM2.5 annual averages for all 67 Pennsylvania counties, drawn as a violin with a box plot inside and one dot per county below. The median is 8.5 and the middle half runs from 7.5 to 9.2, so the bulk of the state straddles the dashed EPA line at 9.0. Twenty-two counties, in blue, sit above it. Allegheny, at 14.1, sits alone at the right edge.
A NumPy array holds many values of one type
1. np.array() builds an array from a list
2. .dtype reports the single type every element shares
3. .shape reports the size along each dimension, as a tuple
import numpy as np is the universal convention
Every NumPy example in every textbook and every answer online uses the name np. The alias is not required by the language, and departing from it makes code harder for others to read.
An array applies arithmetic to every element at once
A list requires a loop. An array does not.
Each operation produces a new array of the same shape, with the operation applied element by element. This is vectorisation.
The same operation on a list does something else entirely
[9.1, 14.1] * 2 repeats the list, producing four elements. np.array([9.1, 14.1]) * 2 doubles each value, producing two. The operator is the same; the type decides the meaning.
Reading a real file into an array
np.genfromtxt() reads a CSV directly into arrays. Column 0 holds names; column 1 holds the measurement.
skip_header=1 discards the header row. usecols= selects a column. Text needs dtype=str; numbers are read as float64 by default.
Source
County Health Rankings, PM2.5 annual average by county, all 67 Pennsylvania counties.
Indexing and slicing follow Module 1’s rules
Zero-indexing, exclusive endpoints, negative indices, and strides are identical to string and list slicing.
A two-dimensional array is indexed [row, column]
rates[1, 3] selects one element. rates[2] selects a whole row. rates[:, 4] selects a whole column — the colon means “every row.”
Rows and columns carry meaning
Here a row is a county and a column is a year. The shape (4, 5) states that directly: four counties, five years.
axis= chooses the direction a summary collapses
1. No axis= collapses everything to a single number
2. axis=1 collapses the columns, leaving one value per row — a per-county mean
3. axis=0 collapses the rows, leaving one value per column — a per-year mean
axis= names the dimension removed, not the one kept
axis=0 runs down the rows and returns a value per column. Reading it as “axis 0 means rows, so I get row answers” is the standard mistake, and it produces a plausible array of the wrong length.
Summary statistics are methods on the array
.mean(), .std(), .min(), and .max() are methods on the array. np.median() and np.percentile() are functions taking the array as an argument.
.std() divides by n, not by n − 1
NumPy’s default is the population standard deviation. R’s sd() and most statistics courses use the sample version, which is pm25.std(ddof=1). On 67 counties the two differ by under one percent, but the default is not the one a statistics course teaches.
A boolean mask selects elements by condition
A comparison on an array produces an array of booleans. Indexing with that array returns the elements where it is True.
.sum() on a boolean array counts the True values, because True equals 1.
The mask replaces the counting loop
Module 2 counted with a loop, an if, and a counter initialized outside. The mask states the condition once and the count follows from it.
Masking the counties above the standard
The same mask indexes both arrays, because the two are aligned element by element. ~ negates a mask.
Masks use &, |, and ~ — not and, or, not
and and or require a single true-or-false value and raise ValueError on an array. Each comparison also needs its own parentheses: (pm25 > 9.0) & (pm25 < 11.0).
np.where() builds a new array from a condition
np.where(condition, value_if_true, value_if_false) returns a full-length array, choosing element by element.
A mask filters; np.where() labels
pm25[pm25 > 9.0] returns 22 values. np.where(pm25 > 9.0, ...) returns 67, one per county. Selecting and labelling are different jobs.
Standardizing expresses each value in standard deviations
A z-score measures distance from the mean in units of the standard deviation.
The whole transformation is one line, because subtraction and division apply to every element.
A z-score is a statement about this collection
The value describes a county’s position among Pennsylvania counties. It carries no information about health effects, and a z-score near zero can still exceed a regulatory standard.
np.nan marks a value that is absent
Any arithmetic touching nan produces nan. One missing value makes the whole mean missing, which is the safe behaviour: the result announces the gap rather than hiding it.
nan is never equal to anything, including itself
np.nan == np.nan evaluates to False. Testing for missingness requires np.isnan().
The nan-aware functions skip the gaps
np.nanmean(), np.nanmedian(), np.nansum(), and np.nanstd() compute over the present values.
Skipping a value is a decision, and it belongs in the writeup
np.nanmean() computes the mean of the counties that reported. Where suppression is concentrated in small rural counties, that mean describes a different population than the one requested, and the count of omitted counties is part of the result.
A slice is a view; modifying it modifies the original
A slice of an array does not copy. It is a view onto the same memory, and writing through it changes the original array.
Slicing a list copies; slicing an array does not
list[:3] produces an independent copy. array[:3] produces a view. The syntax is identical and the behaviour is not.
Resolution:.copy() makes an independent array — first_three = pm25[:3].copy().
The outlier.
Putting the session together.
Exercise: standardize and rank
# The blank is mean. A z-score subtracts the mean, then divides by the# standard deviation. Subtracting the median would centre the values but# would not produce a z-score.## .argmax() returns the *position* of the largest value, which then indexes# both arrays — the same alignment used throughout this session.
Figure 2. Each dot is one Pennsylvania county’s annual average PM2.5. Sixty-six counties fall between 5.5 and 11.1; Allegheny sits alone at 14.1, a blue diamond well to the right of every other county. Twenty-two counties exceed the EPA standard of 9.0, marked in gold, and all but Allegheny cluster between 9.1 and 11.1. Two arrows compare the gaps: 3.0 from Lancaster up to Allegheny, and 2.6 from the state mean of 8.49 up to Lancaster.
Allegheny County’s PM2.5 average is 14.1 micrograms per cubic metre — a z-score of +4.37.
The state mean is 8.49. The next-highest county, Lancaster, is 11.1.
What the magnitude means
A z-score above 4 places Allegheny more than four standard deviations from the typical Pennsylvania county. The gap between Allegheny and second place is larger than the gap between second place and the state mean.
Twenty-two counties exceed the 9.0 standard. Twenty-one of them cluster between 9.1 and 11.1. Allegheny sits alone.
Challenge: the outlier hides the others
Allegheny inflates the standard deviation for every county. Remove it and recompute.
# The first blank builds a mask that is True for every county EXCEPT# Allegheny: the comparison operator for "not equal". The second blank is# the same summary method used in the z-score above.## counties[keep] applies the same mask to the names, so position `top`# in z_rest lines up with position `top` in counties[keep].
keep = counties != "Allegheny"
rest = pm25[keep]
z_rest = (rest - rest.mean()) / rest.std()
top = z_rest.argmax()
print(f"Without Allegheny, highest: {counties[keep][top]}, z = {z_rest[top]:+.2f}")
print(f"Beyond 2 SD, all 67: {((z > 2) | (z < -2)).sum()}")
print(f"Beyond 2 SD, without it: {((z_rest > 2) | (z_rest < -2)).sum()}")
keep = counties !="Allegheny"rest = pm25[keep]z_rest = (rest - rest.mean()) / rest.std()top = z_rest.argmax()print(f"Without Allegheny, highest: {counties[keep][top]}, z = {z_rest[top]:+.2f}")print(f"Beyond 2 SD, all 67: {((z >2) | (z <-2)).sum()}")print(f"Beyond 2 SD, without it: {((z_rest >2) | (z_rest <-2)).sum()}")
Your turn!
Lancaster’s PM2.5 did not change, yet its z-score rose from +2.03 to +2.47. Why? And why does removing one extreme county produce more counties beyond 2 SD, not fewer?
Session outcomes
1. Building arrays with np.array(), and reading real ones with np.genfromtxt()
2. Inspecting .dtype, .shape, and .size
3. Indexing and slicing in one dimension, and [row, column] in two
4. Vectorised arithmetic — one expression applied to every element
5. Summarising with .mean(), .std(), np.median(), and np.percentile()
6. Collapsing a chosen dimension with axis=
7. Selecting with a boolean mask, and labelling with np.where()
8. Standardising to z-scores
9. Handling np.nan with np.isnan() and the nan-aware functions
10. Distinguishing a view from a copy
Preparation for the lab, Tuesday 10/27
The lab reads both course files, masks against the standard, standardises overdose rates, and handles the suppressed counties.
Module 4 attaches these arrays to labelled columns and calls the result a DataFrame.