Chapter 16: Data storytelling and house style

Open In Colab Download Notebook

You have built the chart. The axes are labelled, the data is correct. You export it and paste it into the slide deck. Ten minutes later someone asks: “What am I supposed to take from this?” The chart was not wrong. It was just not a story.

Chapters 14 and 15 taught you the mechanics: matplotlib’s object model, lets-plot’s grammar of graphics. This notebook is about judgment: which chart to pick, what ink to cut, how your eye is deceived by common tricks, and how to apply a house style so every chart you publish looks like it belongs to the same project. Chapter 17 puts every one of these judgment calls to work on a single real case, start to finish.

Callout markers: book cover page.

By the end of Chapter 16 you will be able to:

# Skill Covered in
1 Choose a chart type based on the question, not habit Sec. 1
2 Apply Gestalt principles to explain why grouped charts work Sec. 2
3 Recognise and avoid the most common ways charts mislead Sec. 3
4 Decide when a table communicates better than a chart Sec. 4
5 Apply ark.plot’s house theme to matplotlib and lets-plot charts Sec. 5
6 Identify preattentive attributes and use them deliberately Sec. 6
7 Add annotations that carry the chart’s narrative Sec. 7
8 Explain why summary statistics alone are never sufficient (Datasaurus) Sec. 8
9 Produce one polished, captioned chart from a messy dataset Capstone

1. Picking the Right Chart, and Cutting What Does Not Help

The most common visualisation mistake isn’t a styling problem, it’s a selection problem: building a chart type out of habit instead of asking what question it needs to answer. A pie chart can’t answer “which course improved the most,” because comparing angles is something people are measurably worse at than comparing positions along a shared axis. That isn’t opinion, it’s the finding of a well-known study by Cleveland and McGill (1984) ranking how accurately people read different visual encodings: position along a common scale first, then length, then angle, then area, with colour saturation last.

import numpy as np
import pandas as pd

# Full platform export, used directly wherever a chart doesn't need a fixed category count
df7 = pd.read_csv("data/university_analytics.csv")

# Same three-course, three-Fall-cohort slice as Chapters 14 and 15, so every
# chart in this notebook is comparing the same students
_courses = ["Machine Learning", "Data Structures", "Python Programming"]
_semesters = ["Fall 2022", "Fall 2023", "Fall 2024"]
results = df7[df7["course"].isin(_courses) & df7["semester"].isin(_semesters)].copy()
results.head()
student_id cohort program gender region guardian has_internet course_id course semester enrollment_date study_hours attendance_pct midterm_score final_score project_score final_grade passed
0 S0001 2023 Information Technology F South Father True C01 Python Programming Fall 2023 2023-09-04 10.8 88.6 49.9 58.2 61.6 C True
2 S0001 2023 Information Technology F South Father True C03 Data Structures Fall 2023 2023-09-04 23.6 71.5 54.1 60.9 79.8 C True
4 S0001 2023 Information Technology F South Father True C05 Machine Learning Fall 2023 2023-09-04 16.2 63.2 48.4 54.2 51.8 D True
6 S0002 2023 Data Science M West Mother True C01 Python Programming Fall 2023 2023-09-04 17.4 67.0 93.6 60.5 67.0 B True
8 S0002 2023 Data Science M West Mother True C03 Data Structures Fall 2023 2023-09-04 30.4 84.9 67.4 93.5 70.6 B True

Data-ink ratio, a term from Edward Tufte’s The Visual Display of Quantitative Information, is the proportion of a chart’s ink that represents actual data, as opposed to gridlines, borders, redundant legends, and other decoration. Maximising it doesn’t mean a bare chart, it means every mark earns its place. Alberto Cairo calls this “functional art”: a chart still has to be engaging, it just can’t be engaging instead of being clear. Compare matplotlib’s defaults to a version with the same data and nothing extra:

import matplotlib.pyplot as plt

course_means = results.groupby("course")["final_score"].mean()

fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))

# Left: matplotlib defaults. Box on all four sides, both gridlines, a
# legend the bar colours already make redundant since each bar has its
# own label directly underneath it.
axes[0].bar(course_means.index, course_means.values, color=["#4477AA", "#EE6677", "#228833"])
axes[0].set_title("Before: matplotlib defaults")
axes[0].grid(True)

# Right: same data, decluttered. No top/right spines, no gridlines, the
# value printed directly on each bar instead of relying on the y-axis.
axes[1].bar(course_means.index, course_means.values, color="#4477AA")
axes[1].spines["top"].set_visible(False)
axes[1].spines["right"].set_visible(False)
axes[1].set_yticks([])
for i, value in enumerate(course_means.values):
    axes[1].text(i, value + 1, f"{value:.0f}", ha="center")
axes[1].set_title("After: higher data-ink ratio")

fig.tight_layout()

Key Concept: Data-ink ratio

Every gridline, border, and redundant legend entry competes with your data for the reader’s attention. The right-hand chart above removes the y-axis entirely (the labelled bars make it redundant) and the top/right spines, and prints each value once, directly on its bar, instead of forcing the reader to trace a line back to an axis. Decluttering isn’t about making a chart sparse for its own sake, it’s about removing anything that doesn’t carry information.

One message per chart is the last principle worth internalising before the chart-lie gallery in Section 2: a chart that tries to answer three questions at once usually answers none of them clearly. If you find yourself adding a third colour dimension, a secondary y-axis, and a trend line to the same chart, that is a sign you need two charts, not one crowded one.

Activity 1 - Cut the Ink

Apply the data-ink ratio principle to the comparison chart from this section.

1. Copy the fig, axes = plt.subplots(1, 2) comparison code from above into a new cell.
2. On the ‘before’ panel, add two extra elements that increase ink without adding information: ax.grid(True) and ax.set_facecolor(‘#F0F0F0’).
3. On the ‘after’ panel, remove every element you can: spines, tick labels, background: keep only what is needed to read the values.
4. Add a markdown cell: ‘I removed X because…’ for each removed element.

Expected: the ‘after’ panel communicates the same data with fewer marks.

2. Gestalt Principles: Why Grouped Charts Work

Preattentive attributes tell you what stands out before conscious attention. Gestalt principles tell you how the brain groups what it sees. The two work together: colour creates salience (preattentive); proximity creates grouping (Gestalt). Four principles appear everywhere in DS charts.

import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import pandas as pd

fig, axes = plt.subplots(1, 2, figsize=(10, 4))

# Proximity: dots grouped by closeness
x_left = [1, 1.2, 1.4, 3, 3.2, 3.4]
y_left = [1] * 6
axes[0].scatter(x_left, y_left, s=200, color="#38BDF8")
axes[0].set_xlim(0, 4.5)
axes[0].set_ylim(0, 2)
axes[0].set_title("Proximity: spacing creates groups", pad=10)
axes[0].axis("off")

# Similarity: dots grouped by colour
x_right = [1, 2, 3, 4, 5, 6]
colors = ["#38BDF8", "#7C3AED", "#38BDF8", "#7C3AED", "#38BDF8", "#7C3AED"]
axes[1].scatter(x_right, [1] * 6, s=200, c=colors)
axes[1].set_xlim(0, 7)
axes[1].set_ylim(0, 2)
axes[1].set_title("Similarity: colour creates groups", pad=10)
axes[1].axis("off")

plt.suptitle("Gestalt: Proximity vs Similarity", fontweight="bold")
plt.tight_layout()
plt.show()

Key Concept: Four Gestalt Principles in Chart Design

The four Gestalt principles used in chart design: (1) Proximity: elements near each other are perceived as a group: this is why grouped bars and facets work; (2) Similarity: elements sharing colour, shape, or size seem to belong together; (3) Enclosure: a box, border, or shaded region creates a visual group; (4) Continuity: the eye follows a smooth path: this is why line charts work for time series but not categorical comparisons.

Activity 2 - Proximity Without a Legend

Use layout alone to group related series without adding a legend.

1. Create a fig, axes = plt.subplots(1, 2, figsize=(9, 3.5), sharey=True).
2. On the left panel plot Maths and Statistics scores as two overlapping histograms with alpha=0.6 in brand colours.
3. On the right panel plot Programming and Data Science the same way.
4. Add a title to each panel that says what it shows: no legend.

Expected: proximity (left vs right) groups the courses; the title replaces the legend.

3. Common Chart Lies

None of the charts below contain incorrect numbers. Each one is drawn in a way that makes the reader’s brain compute the wrong conclusion from correct data. Recognising these on sight, in your own draft charts, is the actual skill.

Key Concept: A misleading chart shows correct numbers in a deceptive frame

Truncating the y-axis at a non-zero value makes small differences look large. A dual y-axis on the same plot invites the reader to see causation in coincidence. A pie chart with six slices is almost impossible to read because humans compare angles poorly. None of these charts lie about the numbers: they lie about what the numbers mean.

fig, axes = plt.subplots(1, 2, figsize=(9, 3.5))

axes[0].bar(course_means.index, course_means.values, color="#4477AA")
axes[0].set_ylim(0, 100)
axes[0].set_title("Honest: y-axis starts at 0")

axes[1].bar(course_means.index, course_means.values, color="#EE6677")
axes[1].set_ylim(50, 66)
axes[1].set_title("Misleading: y-axis starts at 50")

fig.tight_layout()

Common Mistake: Truncating the y-axis

The two charts above show the exact same three numbers. The right-hand one starts its y-axis at 50 instead of 0, which makes a 7-point difference between courses look like one course scores roughly twice as high as another. Bar charts in particular must start at zero, because the reader judges bar height (and therefore a ratio) automatically. Line charts can sometimes justify a non-zero baseline if it’s labelled clearly, bars almost never can.

fig, ax = plt.subplots(figsize=(6, 3.5))

cohort_size = np.array([320, 340, 410, 580])
avg_score = np.array([71, 72, 70, 74])
years = ["2022", "2023", "2024", "2025"]

ax.plot(years, cohort_size, color="#4477AA", marker="o", label="Cohort size")
ax.set_ylabel("Cohort size", color="#4477AA")

ax2 = ax.twinx()
ax2.plot(years, avg_score, color="#EE6677", marker="o", label="Average score")
ax2.set_ylabel("Average score", color="#EE6677")

ax.set_title("Two independently scaled axes invite a false correlation");

Common Mistake: Dual axes with independent scales

Cohort size and average score both happen to rise together in this chart, but each axis was scaled independently to make the two lines fit nicely, which is exactly what makes dual axes dangerous: you can almost always pick scales that make two unrelated series appear to move together. If two series genuinely need comparing, plot their percent change from a common baseline on one shared axis instead, or use two separate charts stacked vertically with the same x-axis.

enrollment = pd.Series(
    {"ML": 145, "Data Structures": 132, "Statistics": 98, "Databases": 41, "Networks": 28, "Ethics": 15}
)

fig, axes = plt.subplots(1, 2, figsize=(10, 4))

axes[0].pie(enrollment.values, labels=enrollment.index, autopct="%1.0f%%")
axes[0].set_title("Pie: hard to rank six similar slices")

axes[1].barh(enrollment.index[::-1], enrollment.values[::-1], color="#4477AA")
axes[1].set_title("Bar: ranking is immediate")

fig.tight_layout()

Common Mistake: Too many pie slices

Six categories is already past where a pie chart works: Ethics and Networks are visibly different bars, but as pie slices they are nearly impossible to rank by eye, exactly the angle-judgment weakness Cleveland and McGill measured. A horizontal bar chart, sorted by value, answers “which course has the most enrollment” instantly. Reserve pie charts for two or three categories where one slice is clearly dominant, and prefer a bar chart almost everywhere else.

Activity 3 - Fix the Truncated Y-Axis

Spot and correct a chart lie in this section.

1. Re-run the truncated y-axis bar chart from this section.
2. Add axes[0].set_ylim(0, None) to start the scale at zero.
3. In a markdown cell below, describe in one sentence how the story changes when the axis starts at zero.

Expected: the differences between courses look smaller; the story is more honest.

4. When a Table Beats a Chart

A chart is for spotting a pattern at a glance. A table is for looking up an exact number, or comparing many numbers a reader needs to cite precisely, such as a results appendix someone will quote in a report. This project’s ark.plot.gt_style module wraps great_tables with the same brand colours used everywhere else, so a results table looks like it belongs in the same document as the chart next to it:

Key Concept: Use a chart for patterns, a table for lookups

A chart answers: ‘is there a trend, a cluster, an outlier?’ A table answers: ‘what was the exact value for X in period Y?’ If your reader needs to look up a specific number, a table is faster. If your reader needs to see a shape across many values at once, a chart is faster. Showing both for the same data wastes space and splits attention.

from great_tables import GT, md as gt_md

from ark.plot.gt_style import themed_gt

summary = results.groupby("course")["final_score"].agg(mean="mean", std="std", n="count").round(1).reset_index()

table = themed_gt(
    GT(summary)
    .tab_header(title=gt_md("**Final Score Summary**"), subtitle="Mean, spread, and sample size per course")
    .cols_label(course="Course", mean="Mean", std="Std. Dev.", n="N"),
    n_rows=len(summary),
)
table
Final Score Summary
Mean, spread, and sample size per course
Course Mean Std. Dev. N
Data Structures 57.5 14.4 400
Machine Learning 56.6 15.3 400
Python Programming 64.1 15.4 400

Pro Tip: Pair a chart with a table, don’t choose between them

A results section in a report often wants both: the histogram from Chapter 14 to show the shape of the distribution, and a table like the one above to give the exact numbers someone will want to quote. Neither replaces the other.

Activity 4 - Chart vs Table Decision

Decide which form is right for the question.

1. Create the summary table from this section (mean, std, n per course).
2. Also create a line chart of mean final score by course using matplotlib.
3. In a markdown cell, answer: a department head wants to compare pass rates at a glance: which do you show and why?
4. Now answer: a student wants to know their course’s exact mean: which do you show and why?

Expected: different questions, different answers.

5. From Default to Branded

Chapters 14 and 15 used each library’s own defaults on purpose, so the charts in those notebooks teach the mechanics without a styling layer in the way. This project’s ark.plot module exists precisely so you don’t have to restate the same colours, fonts, and spacing on every single chart by hand. Compare a lets-plot chart with and without it:

from lets_plot import LetsPlot, aes, geom_histogram, gggrid, ggplot, ggsize

LetsPlot.setup_html()

default_chart = ggplot(results, aes(x="final_score", fill="course")) + geom_histogram(bins=20, alpha=0.6)
default_chart + ggsize(450, 300)

Apply the same transformation to the full dataset:

from lets_plot import labs, scale_fill_manual

from ark.plot.theme import modern_theme, pro_colors

branded_chart = (
    ggplot(results, aes(x="final_score", fill="course"))
    + geom_histogram(bins=20, alpha=0.85)
    + scale_fill_manual(values=pro_colors)
    + labs(
        title="Final score distribution by course",
        x="Final score (0-100)",
        y="Number of students",
        fill="Course",
    )
    + modern_theme(grid=True)
)
branded_chart + ggsize(450, 300)

modern_theme() is one more + layer, exactly like every geom_*() in Chapter 15. scale_fill_manual(values=pro_colors) pins each course to the brand palette, and labs() replaces the auto-generated column names with reader-facing text. gggrid() puts both charts side by side in one output, which is the cleanest way to show a before/after comparison in a single cell:

gggrid([default_chart + ggsize(580, 380), branded_chart + ggsize(580, 380)], ncol=1)

The matplotlib equivalent, ark.plot.matplot_theme.configure_matplotlib_style(), works differently: it updates matplotlib’s global rcParams, so it applies to every chart drawn after you call it, not just one. That is the right trade-off for a notebook or script that draws many charts you want to look consistent, at the cost of not being able to mix styled and unstyled charts in the same figure:

from ark.plot.matplot_theme import configure_matplotlib_style

configure_matplotlib_style(font_size=10, fig_width=5)

fig, ax = plt.subplots()
ax.hist(results["final_score"], bins=20)
ax.set_xlabel("Final score")
ax.set_ylabel("Number of students")
ax.set_title("Same distribution as Section 1, after configure_matplotlib_style()");

Key Concept: House style is a default, not a style applied chart by chart

Calling configure_matplotlib_style() once at the top of a notebook applies the same font size, spine removal, and grid style to every subsequent matplotlib chart. Passing modern_theme() and a branded colour scale once defines the look for every lets-plot chart in the session. The goal is consistency across an entire report, not per-chart customisation.

Activity 5 - Brand a Boxplot

Rebuild Chapter 14’s seaborn boxplot of final_score by course, but after calling configure_matplotlib_style() from this section. No other code changes: the same sns.boxplot() call should now pick up the brand colour cycle automatically.
import seaborn as sns

fig, ax = plt.subplots()
sns.boxplot(data=results, x="course", y="final_score", ax=ax)
ax.set_title(...)
import seaborn as sns

# TODO: boxplot of final_score by course, styled by the already-active house theme
...
Ellipsis

Pro Tip: A house theme is a contract, not a one-off setting

The value of ark.plot isn’t any single colour choice, it’s that every chart in this project, in any notebook, in any report, reaches for the same module instead of redefining its own palette. When the brand colours change, they change in one file.

6. Preattentive Attributes: What the Brain Sees First

Before you consciously read a chart, your visual system has already processed certain features. These are called preattentive attributes: colour hue, luminance, shape, orientation, size, position, and enclosure. They register in under 250 milliseconds, before attention is directed.

The design implication is simple but easy to violate: use at most one preattentive attribute for emphasis per chart. Two or more compete for attention and cancel each other out.

Key Concept: Preattentive Attributes

Visual properties processed before conscious attention: colour hue, luminance, shape, orientation, size, position, and motion. The practical rule: encode at most one categorical dimension preattentively, and make sure it carries information, not decoration.

Reference: Ware, C. (2012). Information Visualization: Perception for Design, 3rd ed.

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

df7 = pd.read_csv("data/university_analytics.csv")
course_means = df7.groupby("course")["final_score"].mean().sort_values()

# Left: every bar the same colour: no preattentive signal
# Right: one bar highlighted with colour: attention goes there instantly
TARGET = "Machine Learning"

fig, axes = plt.subplots(1, 2, figsize=(11, 3.8))

for ax, use_highlight in zip(axes, [False, True], strict=False):
    colors = (
        ["#009E73" if c == TARGET else "#ADB5BD" for c in course_means.index]
        if use_highlight
        else ["#ADB5BD"] * len(course_means)
    )
    ax.barh(course_means.index, course_means.values, color=colors)
    ax.set_xlabel("Mean final score")
    ax.spines[["top", "right"]].set_visible(False)
    ax.set_xlim(0, 100)
    title = "No preattentive signal" if not use_highlight else f"Colour highlights {TARGET!r}"
    ax.set_title(title, fontsize=11, fontweight="bold", color="#1E293B")

fig.suptitle("Same data: preattentive colour changes where the eye goes first", fontsize=10, color="#6B7280")
fig.tight_layout()
plt.show()

Activity 6 - Highlight by Region

Build a horizontal bar chart of mean midterm_score by region. Highlight the region with the highest mean in #0369A1 and colour every other bar #ADB5BD. Add a concise title stating which region leads.
region_means = df7.groupby("region")["midterm_score"].mean().sort_values()
# ... build the chart with one highlighted bar ...
# TODO: highlight the top-scoring region in the midterm score chart
...

7. Annotation as Narrative

A chart without annotation forces viewers to form their own interpretation. Annotation is where the “so what?” lives: the one sentence that turns a picture into an argument.

Effective annotation does three things: 1. Points at the specific data feature that supports the message (arrow or callout). 2. States that feature in plain language, not chart jargon. 3. Uses restraint: one annotation per message, not one per data point.

Key Concept: Annotation as the “So What?”

Knaflic’s rule (2015): “the most important single thing you can do to improve your graph is to add a descriptive title.” Add arrows and labels that direct attention to the exact point you want the viewer to remember. A good annotation answers: what should I notice here, and why does it matter?

fig, axes = plt.subplots(1, 2, figsize=(12, 4.2))

# ── Left: unannotated: what's the takeaway? ──────────────────────────────────
program_means = df7.groupby("program")["final_score"].mean().sort_values()

for ax in axes:
    bars = ax.bar(program_means.index, program_means.values, color=["#ADB5BD"] * len(program_means))
    ax.set_ylabel("Mean final score")
    ax.spines[["top", "right"]].set_visible(False)
    ax.set_ylim(0, 85)
    ax.tick_params(axis="x", rotation=15)

axes[0].set_title("Without annotation\n(viewer guesses the message)", fontsize=10, fontweight="bold")

# ── Right: annotated: narrative is explicit ──────────────────────────────────
best_idx = list(program_means.index).index(program_means.idxmax())
axes[1].patches[best_idx].set_facecolor("#0369A1")

axes[1].annotate(
    f"Data Science leads\nat {program_means.max():.1f}",
    xy=(best_idx, program_means.max()),
    xytext=(best_idx + 0.6, program_means.max() - 4),
    fontsize=9,
    color="#0369A1",
    fontweight="bold",
    arrowprops={"arrowstyle": "->", "color": "#0369A1", "lw": 1.4},
    ha="left",
)
axes[1].set_title("With annotation\n(message is explicit)", fontsize=10, fontweight="bold")

fig.suptitle("Annotation turns a picture into an argument", fontsize=10, color="#6B7280")
fig.tight_layout()
plt.show()

Activity 7 - Annotate a Trend

Plot mean final_score by semester as a line chart. Add ax.annotate() to flag the semester with the highest mean and write a one-sentence insight as the chart title.
sem_means = df7.groupby("semester")["final_score"].mean()
# ... line chart + annotation ...
# TODO: line chart of final_score by semester with an annotation on the peak semester
...

8. The Datasaurus Dozen: Always Visualize

In 1973 Francis Anscombe constructed four datasets with nearly identical summary statistics: same mean, variance, and correlation: but radically different visual shapes. In 2017, Matejka and Fitzmaurice extended this to thirteen datasets, including a dinosaur, which gave the collection its name: the Datasaurus Dozen.

The lesson isn’t subtle: summary statistics alone hide the shape of your data. A mean and a standard deviation can’t distinguish a normal distribution from a bimodal one, a dinosaur, or a circle.

Key Concept: Datasaurus Dozen

Matejka, J. & Fitzmaurice, G. (2017). Same stats, different graphs. Proceedings of CHI 2017. The original Anscombe’s Quartet dates from 1973. Both show the same principle: visualize before summarizing. A modern corollary for ML: never report only RMSE without looking at a residual plot.

# Demonstrate "same stats, different shapes" with two student subsets
# that have matching means and standard deviations but different distributions.

rng = np.random.default_rng(7)

# Group A: normally distributed scores (typical class)
n = 200
group_a = pd.DataFrame(
    {
        "group": "Group A (normal)",
        "midterm": rng.normal(68, 12, n).clip(20, 100),
        "final": rng.normal(70, 12, n).clip(20, 100),
    }
)

# Group B: bimodal: two subpopulations merged (e.g. two campuses with different resources)
group_b = pd.DataFrame(
    {
        "group": "Group B (bimodal)",
        "midterm": np.concatenate([rng.normal(55, 6, n // 2), rng.normal(82, 6, n // 2)]),
        "final": np.concatenate([rng.normal(57, 6, n // 2), rng.normal(84, 6, n // 2)]),
    }
)

combined = pd.concat([group_a, group_b], ignore_index=True)

# Summary stats: almost identical
print("Summary statistics:")
print(combined.groupby("group")[["midterm", "final"]].describe().round(1).to_string())
print()

fig, axes = plt.subplots(1, 2, figsize=(11, 4), sharey=False)

for ax, (name, grp) in zip(axes, combined.groupby("group"), strict=False):
    ax.scatter(grp["midterm"], grp["final"], alpha=0.4, s=18, color="#0369A1")
    ax.set_xlabel("Midterm score")
    ax.set_ylabel("Final score")
    ax.set_title(name, fontweight="bold")
    ax.spines[["top", "right"]].set_visible(False)
    # Annotate means
    ax.axvline(grp["midterm"].mean(), color="#D97706", lw=1.2, ls="--", label=f"mean={grp['midterm'].mean():.1f}")
    ax.axhline(grp["final"].mean(), color="#D97706", lw=1.2, ls="--")
    ax.legend(fontsize=8)

fig.suptitle("Same means and standard deviations: completely different structures", fontsize=10, color="#6B7280")
fig.tight_layout()
plt.show()
Summary statistics:
                  midterm                                            final                                          
                    count  mean   std   min   25%   50%   75%   max  count  mean   std   min   25%   50%   75%   max
group                                                                                                               
Group A (normal)    200.0  66.4  10.5  37.8  60.2  66.4  73.9  94.9  200.0  69.0  11.7  31.0  60.7  69.3  76.3  97.1
Group B (bimodal)   200.0  67.4  14.8  38.1  53.2  68.2  81.4  93.2  200.0  70.4  14.5  40.4  56.8  71.0  83.0  97.7

Common Mistake: Reporting only mean ± std for a bimodal distribution

Group B above has the same mean and standard deviation as Group A. A table of statistics alone would show no difference. The scatter plot reveals that Group B is actually two separate subpopulations: a finding with direct intervention implications.

Rule: for any continuous variable you care about, always plot a distribution (histogram, KDE, strip plot, or box plot) before reporting a single number.

Activity 8 - Unmask the Hidden Structure

From university_analytics.csv, compare the distribution of final_score for two programs side-by-side using overlapping KDE plots (or box plots). Before plotting, print the mean and standard deviation for each program. Then reflect: would the statistics alone have revealed the difference?
import seaborn as sns
# ... print stats, then plot KDE for each program ...
# TODO: compare final_score distributions for two programs: print stats, then KDE plot
...

Capstone: Rescue a Misleading Chart

Below is a chart with three problems from this notebook stacked together: a truncated y-axis, no axis labels, and matplotlib’s unbranded defaults. Fix all three, then add one caption sentence stating the chart’s single message.

# The chart to rescue. Run this first to see what is wrong with it.
fig, ax = plt.subplots(figsize=(5, 3))
ax.bar(course_means.index, course_means.values, color=["#4477AA", "#EE6677", "#228833"])
ax.set_ylim(50, 66)
fig.tight_layout()

Capstone Exercise - Fix the Chart

  1. Fix the y-axis to start at 0
  2. Add an x and y axis label
  3. Apply the house style with configure_matplotlib_style()
  4. Add one print statement with a one-sentence caption stating the chart’s single message

Hint: configure_matplotlib_style() only affects charts drawn after it runs, so call it before plt.subplots(), not after.

# TODO: rescue the chart
...

print("Caption: ...")
Caption: ...

Further reading

Resource Why it matters
Tufte, E.R. (1983). The Visual Display of Quantitative Information. Graphics Press. The original data-ink ratio argument that Section 1 is built on, illustrated with 250 examples
Knaflic, C.N. (2015). Storytelling with Data. Wiley. The most practical data visualisation book for analysts; the annotated chart examples in Chapter 5 are worth the price alone
Cairo, A. (2012). The Functional Art. New Riders. A data-journalism perspective on the same tension Section 1 covers: a chart must be accurate and engaging, not one traded for the other
Wilke, C.O. (2019). Fundamentals of Data Visualization. O’Reilly. Free at clauswilke.com/dataviz: Chapter 17 on “proportional ink” and Chapter 18 on chart junk directly support Sections 1–2 of this notebook
Ware, C. (2012). Information Visualization: Perception for Design, 3rd ed. Morgan Kaufmann. The perceptual science behind Section 6’s preattentive attributes, already cited inline there
Schwabish, J. (2021). Better Data Visualizations. Columbia University Press. Covers annotation, layout, and the “sorta spaghetti” problem; strong on the gap between what analysts produce and what executives read
Cleveland, W.S. & McGill, R. (1984). Graphical perception. Journal of the American Statistical Association 79(387), 531–554. The empirical study that established the encoding hierarchy used in Section 1
Matejka, J. & Fitzmaurice, G. (2017). Same stats, different graphs. CHI 2017. The Datasaurus Dozen paper: free PDF on the ACM DL

Summary

Concept Key rule
Chart selection Let the question pick the chart; position beats angle beats area for accurate reading
Data-ink ratio Every gridline, border, and redundant legend competes with your data
One message per chart A third colour dimension or a second y-axis doubles the cognitive load
Table vs chart Use a table when the reader needs exact numbers; use a chart when the pattern matters
House style Apply the house theme once and leave it: consistency is itself a signal of quality
Preattentive attributes Encode at most one categorical dimension preattentively; two or more cancel out
Annotation as narrative The title and annotation carry the “so what?”: write them before you design the chart
Datasaurus Always visualise before reporting summary statistics: same stats, different shapes

Next: 17-storytelling-capstone.ipynb, running the full judgment loop, audience to explanatory chart, on one real case.