Module 5: Visualisation and description

PUBHLT 0411 · Python for Public Health Data Analysis

Soumik Purkayastha

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

November 10, 2026

A rate that refuses to move.

One in ten Pennsylvania adults has asthma, and the rate barely moves between counties

Across all sixty-seven counties, adult asthma prevalence runs from 9.7% to 11.6%. The entire state fits inside two percentage points.

The question this session opens

A variable that hardly varies looks like a dead end. What else moves with it?

This session establishes the figure as an instrument: a chart is built to answer a question, read for what it shows, and checked for what it hides.

Required from Module 4: pd.read_csv(), boolean filtering, .groupby(), .merge(), and .isna().

Session plan

1. The figure/axes model — the two objects every matplotlib figure is made of

2. Bar, histogram, scatter, and line, and the question each one answers

3. Labels, titles, ticks, legends, and saving a figure to a file

4. Multi-panel figures with plt.subplots()

5. The pandas .plot() shortcut, and seaborn for grouped figures

6. Accessibility as a requirement: alt text, contrast, and never colour alone

7. .describe(), grouped summaries, and .corr() as co-movement

8. Rates against counts — the error that survives every other check

9. EDA as a method: question → figure → better question

Descriptive statistics only

This course computes and interprets summaries, distributions, and correlations. It does not perform statistical inference — no p-values, no significance tests, no models.

The figure and the axes

Every matplotlib figure is two objects

1. The figure is the canvas: its size, its resolution, and the file it saves to

2. The axes is one set of coordinates drawn on that canvas: the data, the labels, the ticks

plt.subplots() returns both, and nearly every figure in practice begins with that call.

ax is not “axis”

An axes is an entire plotting area. An axis is one of its edges, reached as ax.xaxis or ax.yaxis. The naming is unfortunate and permanent.

ax.bar() compares a value across categories

Figure 1. Bar chart of adult asthma prevalence for the five Pennsylvania counties with the highest rates: Philadelphia at 11.6 percent, then Fayette, Sullivan, Blair, and Clinton between 11.2 and 11.3 percent. The bars are nearly the same height.

A truncated axis manufactures a difference that is not there

Figure 2. Two bar charts of the same five county asthma rates side by side. The left panel has a y-axis starting at zero and the five bars look nearly identical. The right panel has a y-axis starting at 11 percent and the same five bars now look dramatically different in height.

Identical data. The right panel implies Philadelphia has roughly four times the asthma burden of Clinton County. The true difference is 0.4 percentage points.

The rule for bars

A bar encodes value by length, so its baseline must be zero. Truncating the axis breaks the correspondence between length and value.

ax.hist() shows the shape of one variable

Figure 3. Histogram of adult asthma prevalence across all 67 Pennsylvania counties, with bars concentrated between 10 and 11.2 percent and a single vertical dashed line marking the state mean of 10.7 percent. The distribution is roughly symmetric and tightly clustered.

A bar chart compares named categories. A histogram bins a continuous variable and counts what falls in each bin.

ax.scatter() shows how two variables move together

Figure 4. Scatter plot of adult asthma prevalence against adult smoking prevalence across 67 Pennsylvania counties. Points rise from left to right: counties near 11 percent smoking sit close to 9.8 percent asthma, and counties near 21 percent smoking sit near 11.3 percent asthma.

ax.plot() draws a value that moves through time

Figure 5. Line chart of monthly COVID-19 deaths across all Pennsylvania counties from March 2020 through March 2024. Two large peaks appear in winter 2020 and winter 2021, with the tallest at 4,294 deaths in January 2022, after which the line falls close to zero.

A line asserts that consecutive points are connected — that the value moved from one to the next. Categories are never connected by a line.

Making a figure readable

An unlabelled figure states nothing

Figure 6. Two panels of the same scatter of asthma against smoking. The left panel has no axis labels or title and is uninterpretable. The right panel is fully labelled with axis names, units, a title, and a source note.

Both panels plot identical data. Only one of them can be read by someone who was not in the room when it was made.

The minimum for any figure that leaves a notebook

An x-label with units, a y-label with units, a title stating the claim, and a source.

savefig() writes the figure to a file

1. dpi=200 sets the resolution; the default of 100 looks soft in a document

2. bbox_inches="tight" trims the surrounding whitespace and rescues cut-off labels

3. The extension chooses the format: .png for slides and documents, .pdf for print

savefig() before show()

plt.show() renders and then clears the figure. Calling savefig() afterwards writes a blank image.

plt.subplots() builds a grid of panels

Figure 7. Four-panel grid of Pennsylvania county health distributions: adult asthma prevalence, adult smoking prevalence, infant mortality per 1,000 births, and child poverty percent. Asthma is tightly clustered while the other three are visibly wider and more skewed.

plt.subplots(2, 2) returns a 2-D array of axes, indexed axes[row, col] — the same indexing NumPy introduced in Module 3.

Accessibility

Alt text is required on every figure

A figure is invisible to a screen reader. Its alt text is the only description that reaches a reader who cannot see it.

#| fig-alt: "Scatter plot of adult asthma prevalence against adult smoking
#|   prevalence across 67 Pennsylvania counties. Points rise from left to
#|   right, from about 9.8% asthma at 11% smoking to 11.3% at 21% smoking."

Alt text states what the figure shows, not that it exists

✗ “Scatter plot of the data.” ✓ “Asthma prevalence rises with smoking prevalence across 67 counties, from 9.8% to 11.3%.”

Where the description goes depends on the tool

fig-alt works in a rendered Quarto document. It does not work in these slides — the figures here are drawn in the browser, and the attribute never reaches them. Every figure in this deck therefore carries its description as visible text underneath, which reaches a screen reader in either case.

University of Pittsburgh policy and the ADA Title II compliance date of April 2027 both require this. It applies to every figure submitted in this course, including the final project.

Colour is never the only channel

Figure 8. Two panels comparing metro and nonmetro counties on smoking and asthma. The left panel separates the groups by colour alone. The right panel uses colour, distinct marker shapes, and a legend, so the groups remain distinguishable without colour.

Marks must reach 3:1 against the background

Text needs 4.5:1. A meaningful graphic — a bar, a point, a line that carries data — needs 3:1.

Colour Ratio on white Verdict
#003594 Pitt royal 10.84 : 1 Passes as text and as a mark
#8a6400 dark gold 5.38 : 1 Passes as text and as a mark
#6b7280 gray 4.83 : 1 Passes; reserved for annotation
#B87333 Pitt bronze 3.79 : 1 Mark only — fails as text
#FFB81C Pitt gold 1.73 : 1 Fails both. Never on white.

Pitt gold is a brand colour, not a data colour

#FFB81C on white reaches 1.73:1. It is legible only as a fill behind dark text, or on the royal blue background used by the section dividers in this deck.

pandas and seaborn shortcuts

.plot() draws directly from a DataFrame

Figure 9. Horizontal bar chart of mean adult asthma prevalence for six Pennsylvania regions, ranging from about 10.4 percent in the Southeast to 10.8 percent in North Central. The bars are close to equal in length.

.plot() on a Series or DataFrame calls matplotlib underneath and returns an axes, so every ax.set_*() method remains available.

seaborn draws grouped statistical figures in one call

seaborn is not bundled with this browser’s Python. micropip fetches it at runtime, and the download takes a few seconds on first use.

In JupyterLab, seaborn is already installed

import seaborn as sns is enough there — it ships with the course environment. The micropip step is specific to Python running inside a browser.

sns.boxplot() compares distributions across groups

Figure 10. Box plot of adult asthma prevalence for metro and nonmetro Pennsylvania counties. The two boxes overlap heavily, with nonmetro counties centred slightly higher at 10.7 percent against 10.5 percent for metro counties.

A box marks the median and the interquartile range; the whiskers reach the furthest points within 1.5 × IQR, and anything beyond is drawn individually.

histplot and scatterplot take the grouping as an argument

Figure 11. Two seaborn panels. The left is a histogram of adult smoking prevalence split by metro status, with metro counties concentrated at lower values. The right is a scatter of asthma against smoking, metro as circles and nonmetro as triangles, rising left to right.

style="metro" varies the marker shape alongside the colour — the second channel, supplied by one argument.

seaborn’s default palettes fail the contrast threshold

Every seaborn default palette contains colours below 3:1 against white:

Palette Lowest ratio Failing colours
deep (the default) 2.80 : 1 #DD8452, #55A868
colorblind 2.61 : 1 #DE8F05, #CA9161
muted 2.01 : 1 #EE854A, #6ACC64
tab10 2.53 : 1 #FF7F0E

A palette named colorblind is not an accessible palette

colorblind is designed so its hues stay distinguishable from one another under colour vision deficiency. That is a different property from contrast against the background, and it does not imply it.

This course passes an explicit palette= argument to every seaborn call.

sns.heatmap() shows a matrix of relationships

Figure 12. Heat map of correlations among five maternal and infant health measures across 44 Pennsylvania counties, with numeric values printed in every cell. Child poverty and infant mortality correlate at 0.65, income and poverty at negative 0.81, and infant mortality with income at negative 0.60.

annot=True prints the value in every cell — the numbers are the second channel, so the figure survives being read without colour.

Describing what the figure shows

.describe() summarises every numeric column at once

Eight numbers per column: the count, the mean, the standard deviation, the minimum, the three quartiles, and the maximum.

count is the first number to read

It reports non-missing values. A count below the number of rows means missing data is present in that column.

.groupby() and .agg() describe each group

The comparison this table makes

Metro and nonmetro counties differ by 0.17 percentage points in asthma prevalence, and by 2.34 percentage points in smoking prevalence.

.corr() measures how two columns move together

A correlation runs from −1 to +1. It reports the strength and direction of a straight-line relationship, and nothing else.

What a correlation cannot establish

It cannot establish cause. It cannot detect a curved relationship. It is distorted by outliers. And these are county averages, so it describes counties, not people.

A correlation without its scatter can be a fiction

Figure 13. Two scatter plots. The left panel shows adult smoking against asthma across 67 counties, an even upward cloud with correlation 0.659. The right panel shows the same data with one fabricated extreme point added at the top right, and the correlation rises to 0.827 on the strength of that single point.

Rates against counts

A figure of counts often draws a map of population

Figure 14. Two horizontal bar charts of Pennsylvania COVID-19 mortality. The left ranks counties by total deaths and lists Philadelphia, Allegheny, Montgomery, Lancaster, and Bucks — the five largest counties. The right ranks by deaths per 100,000 and lists Potter, McKean, Juniata, Jefferson, and Armstrong — five small rural counties. The two lists share no counties.

The two lists share no counties.

The check-in: which figure answers which question

# The blank is population_2023.
#
# The two printed correlations are the point of the exercise. A count
# correlates with population at +0.946 — it is very nearly a measure of
# how many people live there. Dividing by population removes that, and
# the rate correlates with population at -0.232.
import pandas as pd covid = pd.read_csv("data/pa_covid_monthly.csv") counties = pd.read_csv("data/pa_counties.csv") totals = (covid.groupby("county", as_index=False)["covid_deaths"].sum() .merge(counties[["county", "population_2023"]], on="county")) totals["rate_per_100k"] = totals["covid_deaths"] / totals["population_2023"] * 100000 print("deaths vs population:", round(totals["covid_deaths"].corr(totals["population_2023"]), 3)) print("rate vs population:", round(totals["rate_per_100k"].corr(totals["population_2023"]), 3))
import pandas as pd

covid = pd.read_csv("data/pa_covid_monthly.csv")
counties = pd.read_csv("data/pa_counties.csv")

totals = (covid.groupby("county", as_index=False)["covid_deaths"].sum()
               .merge(counties[["county", "population_2023"]], on="county"))

totals["rate_per_100k"] = totals["covid_deaths"] / totals["population_2023"] * 100000

print("deaths vs population:", round(totals["covid_deaths"].corr(totals["population_2023"]), 3))
print("rate   vs population:", round(totals["rate_per_100k"].corr(totals["population_2023"]), 3))

EDA as a method

A figure’s job is to produce a better question

1. Ask — one question the data could answer

2. Draw — the simplest figure that addresses it

3. Read — what the figure shows, and what it excludes

4. Ask again — the sharper question the figure exposed

This session has run that loop three times.

Question Figure What it produced
How much does asthma vary? Histogram Very little — 9.7% to 11.6%
Does region explain it? Bar chart by region No — 0.4 points across six regions
Does metro status explain it? Box plot No — boxes overlap almost entirely

Three negative answers narrowed the question to the one the data can answer.

The closing figure

Figure 15. Scatter plot of adult asthma prevalence against adult smoking prevalence across all 67 Pennsylvania counties, with metro counties as circles and nonmetro counties as triangles. Points rise clearly from left to right, correlation 0.659. Chester County sits at the low left with 10.6 percent smoking and 9.7 percent asthma; Sullivan County sits at the upper right with 21.1 percent smoking and 11.3 percent asthma.

Session outcomes

1. The figure/axes model, and plt.subplots() as the entry point to every figure

2. ax.bar(), ax.hist(), ax.scatter(), ax.plot() — and the question each answers

3. Zero baselines for bars; axis truncation as a distortion

4. Labels, units, titles, sources, legends, and savefig() with bbox_inches="tight"

5. Multi-panel figures indexed axes[row, col]

6. pandas .plot(); seaborn boxplot and heatmap via micropip

7. Accessibility: alt text on every figure, marks at 3:1, and colour never alone

8. .describe(), grouped .agg(), and .corr() as co-movement, never cause

9. Rates against counts — the denominator question

10. EDA: question → figure → better question

Preparation for the lab, Thursday 11/12

The lab builds twelve figures from the five course datasets, including a full multi-panel figure with alt text, and repairs three figures that are misleading rather than broken.

Module 6 assembles the entire pipeline — import, clean, join, group, summarise, visualise, report — into one reproducible document.

That’s all folks!

See you on Thursday!