PyDA Course
📖 Lesson 8 Advanced ⏱ 25 min ⚡ +100 XP ColabKagglenbviewerBinderDeepnoteGitHub storytelling · annotations · color-palettes · presentation · dashboard

Data Storytelling Principles

Structure visual narratives, annotate charts for clarity, and design presentations that drive action.

🎯 What you'll learn:
  • Apply the narrative arc (situation → complication → resolution) to data presentations
  • Use annotation-first design to make charts self-documenting
  • Choose color palettes, titles, and typography intentionally for audience impact
  • Structure a multi-chart dashboard that guides the viewer through a story

A chart that nobody reads is worse than no chart at all. Data storytelling is the skill of turning analysis into insight that changes decisions. This lesson covers the narrative structure, annotation techniques, color choices, and dashboard design principles that make your visualizations persuasive.

Key Concepts

The narrative arc

Every data story follows a three-part structure:

  1. Situation: What is the context? What does the audience already know?
  2. Complication: What is the problem, surprise, or tension the data reveals?
  3. Resolution: What does the data suggest we should do about it?
python
# Example narrative for Students Performance analysis
narrative = {
    "situation": "We analyzed exam scores for 1000 students across math, reading, and writing.",
    "complication": "Students who didn't complete test preparation scored 7-10 points lower on average.",
    "resolution": "Expanding test preparation access could close the performance gap."
}

Annotation-first design

The best charts explain themselves without a caption. Add annotations directly on the chart:

python
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

# students-performance.csv ships with the course — load it from the browser file system.
df = pd.read_csv("students-performance.csv")

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

# Box plot
sns.boxplot(data=df, x="test preparation course", y="math score", ax=ax, palette="Set2")

# Annotation: mean difference
mean_completed = df[df["test preparation course"] == "completed"]["math score"].mean()
mean_none = df[df["test preparation course"] == "none"]["math score"].mean()
diff = mean_completed - mean_none

ax.annotate(
    f"Average difference: +{diff:.1f} points",
    xy=(1, mean_completed),
    xytext=(0.5, mean_completed + 5),
    fontsize=12,
    fontweight="bold",
    color="darkgreen",
    arrowprops=dict(arrowstyle="->", color="darkgreen", lw=2),
    bbox=dict(boxstyle="round,pad=0.3", facecolor="lightyellow", edgecolor="darkgreen")
)

ax.set_title("Test Preparation Impact on Math Scores", fontsize=14, fontweight="bold")
ax.set_xlabel("Test Preparation Course")
ax.set_ylabel("Math Score")
plt.tight_layout()
plt.show()

Color palette choices

Color is not decoration, it is communication. Choose palettes based on your data type:

python
# Sequential: for ordered data (low to high)
fig, axes = plt.subplots(2, 2, figsize=(12, 10))

# Sequential
sns.color_palette("Blues_r", n_colors=5)
axes[0, 0].bar(range(5), range(5), color=sns.color_palette("Blues_r", 5))
axes[0, 0].set_title("Sequential (ordered)")

# Diverging: for data with a meaningful midpoint
sns.color_palette("RdBu_r", n_colors=5)
axes[0, 1].bar(range(5), [3, 1, 4, 2, 5], color=sns.color_palette("RdBu_r", 5))
axes[0, 1].set_title("Diverging (midpoint)")

# Qualitative: for categorical data
sns.color_palette("Set2", n_colors=5)
axes[1, 0].bar(range(5), [4, 2, 5, 3, 1], color=sns.color_palette("Set2", 5))
axes[1, 0].set_title("Qualitative (categorical)")

# Custom palette
custom = ["#2E86AB", "#A23B72", "#F18F01", "#C73E1D", "#3B1F2B"]
axes[1, 1].bar(range(5), [3, 5, 2, 4, 1], color=custom)
axes[1, 1].set_title("Custom Brand Palette")

plt.tight_layout()
plt.show()

Color rules:

  • Sequential for numerical values (darker = higher)
  • Diverging when comparing to a midpoint (red = bad, blue = good)
  • Qualitative for categories (distinct colors, no inherent order)
  • Never use color as the only encoding, always add labels or patterns for colorblind accessibility

Title and subtitle design

Good titles state the insight, not the chart type:

python
# Bad title
ax.set_title("Math Score Distribution by Gender")

# Good title
ax.set_title("Female Students Score 6 Points Higher on Reading Tests")

# Even better: subtitle with context
fig, ax = plt.subplots(figsize=(10, 6))
fig.suptitle("Gender Gap Varies by Subject", fontsize=16, fontweight="bold", y=1.02)
ax.set_title("Female students outperform in reading (+6.2) and writing (+5.6),\n"
             "while male students lead in math (+3.2)", fontsize=11, style="italic")

Dashboard structure

A dashboard is a collection of charts that tell a coherent story. Structure it like a narrative:

python
import matplotlib.gridspec as gridspec

fig = plt.figure(figsize=(16, 12))
gs = gridspec.GridSpec(3, 3, hspace=0.4, wspace=0.3, height_ratios=[0.5, 1.5, 1.5])

# Title banner
ax_title = fig.add_subplot(gs[0, :])
ax_title.axis("off")
ax_title.text(0.5, 0.5,
              "Students Performance in Exams — Key Findings",
              fontsize=18, fontweight="bold", ha="center", va="center",
              transform=ax_title.transAxes)
ax_title.text(0.5, 0.1,
              "Analysis of 1000 students across demographics and exam scores",
              fontsize=12, ha="center", va="center", style="italic",
              transform=ax_title.transAxes)

# Finding 1: Test prep impact
ax1 = fig.add_subplot(gs[1, 0])
sns.boxplot(data=df, x="test preparation course", y="math score",
            ax=ax1, palette="Set2")
ax1.set_title("Finding 1: Test Prep Boosts Scores", fontsize=10, fontweight="bold")

# Finding 2: Gender differences
ax2 = fig.add_subplot(gs[1, 1])
scores_by_gender = df.groupby("gender")[["math score", "reading score", "writing score"]].mean()
scores_by_gender.plot(kind="bar", ax=ax2, rot=0)
ax2.set_title("Finding 2: Gender Patterns by Subject", fontsize=10, fontweight="bold")
ax2.legend(fontsize=8)

# Finding 3: Lunch type matters
ax3 = fig.add_subplot(gs[1, 2])
sns.boxplot(data=df, x="lunch", y="math score", ax=ax3, palette="Set3")
ax3.set_title("Finding 3: Lunch Type Correlates with Scores", fontsize=10, fontweight="bold")

# Insight banner
ax_insight = fig.add_subplot(gs[2, :])
ax_insight.axis("off")
ax_insight.text(0.5, 0.5,
                "KEY INSIGHT: Test preparation and lunch type are the strongest predictors.\n"
                "Targeted interventions in these areas could improve outcomes for underserved students.",
                fontsize=14, ha="center", va="center",
                bbox=dict(boxstyle="round,pad=1", facecolor="lightyellow", edgecolor="orange"),
                transform=ax_insight.transAxes)

plt.tight_layout()
plt.show()

Audience-aware design

Design for your audience:

AudienceWhat they care aboutDesign approach
ExecutivesBottom line, action itemsBold insights, minimal detail
AnalystsMethod, reproducibilityCode, statistics, methodology
General publicClear, simple storiesFewer charts, plain language
python
# Executive-friendly: big numbers with context
fig, axes = plt.subplots(1, 3, figsize=(14, 4))

metrics = {
    "Avg Math Score": df["math score"].mean(),
    "Avg Reading Score": df["reading score"].mean(),
    "Test Prep Effect": diff,
}

for i, (label, value) in enumerate(metrics.items()):
    axes[i].text(0.5, 0.6, f"{value:.1f}", fontsize=48, fontweight="bold",
                 ha="center", va="center", transform=axes[i].transAxes)
    axes[i].text(0.5, 0.2, label, fontsize=14,
                 ha="center", va="center", transform=axes[i].transAxes, style="italic")
    axes[i].axis("off")

plt.suptitle("Students Performance Dashboard — Executive Summary", fontsize=16, fontweight="bold")
plt.tight_layout()
plt.show()

Try It

Create an annotated chart that tells a data story about the Students Performance dataset.

python
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

# students-performance.csv ships with the course — load it from the browser file system.
df = pd.read_csv("students-performance.csv")

# Story: Test preparation closes the gap
fig, axes = plt.subplots(1, 3, figsize=(14, 6))
fig.suptitle("Test Preparation Impact Across Subjects", fontsize=16, fontweight="bold")

for i, subject in enumerate(["math score", "reading score", "writing score"]):
    means = df.groupby("test preparation course")[subject].mean()
    diff = means["completed"] - means["none"]

    sns.boxplot(data=df, x="test preparation course", y=subject,
                ax=axes[i], palette="Set2")
    axes[i].set_title(subject.replace(" score", "").title())

    axes[i].annotate(
        f"+{diff:.1f} points",
        xy=(1, means["completed"]),
        xytext=(1.3, means["completed"] + 2),
        fontsize=11, fontweight="bold", color="darkgreen",
        arrowprops=dict(arrowstyle="->", color="darkgreen"),
        bbox=dict(boxstyle="round,pad=0.3", facecolor="lightyellow", edgecolor="darkgreen")
    )

plt.tight_layout()
plt.show()

Key Takeaways

  • Follow the narrative arc: situation → complication → resolution
  • Annotate charts directly, the chart should explain itself without a caption
  • Choose color palettes based on data type: sequential for ordered, diverging for midpoint comparison, qualitative for categories
  • Titles should state the insight, not the chart type
  • Structure dashboards like stories: headline → evidence → conclusion
  • Design for your audience, executives want insights, analysts want methodology

Practice Challenge

Create a 3-panel dashboard telling a story about the gender gap in exam scores: (1) grouped bar chart of mean scores by gender and subject, (2) box plot of the score gap (math minus writing) by gender, and (3) an insight banner summarizing the finding. Use annotations on each chart.

🧩 Challenge, think first, then reveal
python
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec

# students-performance.csv ships with the course — load it from the browser file system.
df = pd.read_csv("students-performance.csv")

fig = plt.figure(figsize=(16, 10))
gs = gridspec.GridSpec(2, 2, hspace=0.35, wspace=0.3, height_ratios=[1.5, 1])

# Panel 1: Grouped bar chart
ax1 = fig.add_subplot(gs[0, 0])
means = df.groupby("gender")[["math score", "reading score", "writing score"]].mean()
means.plot(kind="bar", ax=ax1, rot=0, colormap="Set2")
ax1.set_title("Average Scores by Gender and Subject", fontweight="bold")
ax1.set_ylabel("Mean Score")
ax1.legend(title="Subject")

# Panel 2: Score gap box plot
ax2 = fig.add_subplot(gs[0, 1])
df["math_writing_gap"] = df["math score"] - df["writing score"]
sns.boxplot(data=df, x="gender", y="math_writing_gap", ax=ax2, palette="Set1")
ax2.axhline(0, color="gray", linestyle="--", alpha=0.5)
ax2.set_title("Math-Writing Score Gap by Gender", fontweight="bold")
gap_means = df.groupby("gender")["math_writing_gap"].mean()
for i, gender in enumerate(["female", "male"]):
    ax2.annotate(
        f"Mean: {gap_means[gender]:+.1f}",
        xy=(i, gap_means[gender]),
        xytext=(i + 0.3, gap_means[gender] + 0.5),
        fontsize=10, fontweight="bold",
        arrowprops=dict(arrowstyle="->", color="black"),
    )

# Insight banner
ax_insight = fig.add_subplot(gs[1, :])
ax_insight.axis("off")
ax_insight.text(0.5, 0.5,
    "INSIGHT: Female students outperform in reading (+6.2) and writing (+5.6),\n"
    "while male students lead in math (+3.2). The math-writing gap is +8.8 for males\n"
    "and -5.0 for females — a 13.8 point swing. Interventions should target subject-specific gaps.",
    fontsize=13, ha="center", va="center",
    bbox=dict(boxstyle="round,pad=1", facecolor="lightyellow", edgecolor="orange"),
    transform=ax_insight.transAxes)

fig.suptitle("Gender Performance Gap — Data Story", fontsize=16, fontweight="bold", y=1.02)
plt.show()
Quick check +10 XP

1. What is the main goal of data storytelling?

2. What is the So What principle in data presentation?

3. Why should you lead with the key finding instead of methodology?

storytellingannotationscolor-palettespresentationdashboard