{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "---\n", "title: \"Chapter 16: Data storytelling and house style\"\n", "---" ] }, { "cell_type": "markdown", "id": "1", "metadata": {}, "source": [ "[](https://colab.research.google.com/github/sambaiga/ds-mlops-path/blob/main/tutorials/01-python-basics/16-data-storytelling.ipynb) [](https://raw.githubusercontent.com/sambaiga/ds-mlops-path/main/tutorials/01-python-basics/16-data-storytelling.ipynb)" ] }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "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.\n", "\n", "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.\n", "\n", "> Callout markers: [book cover page](../../index.qmd#callout-guide)." ] }, { "cell_type": "markdown", "id": "3", "metadata": {}, "source": [ "::: {.callout-note collapse=\"true\" icon=false}\n", "## Learning objectives\n", "\n", "By the end of Chapter 16 you will be able to:\n", "\n", "| # | Skill | Covered in |\n", "|---|---|---|\n", "| 1 | Choose a chart type based on the question, not habit | Sec. 1 |\n", "| 2 | Apply Gestalt principles to explain why grouped charts work | Sec. 2 |\n", "| 3 | Recognise and avoid the most common ways charts mislead | Sec. 3 |\n", "| 4 | Decide when a table communicates better than a chart | Sec. 4 |\n", "| 5 | Apply `ark.plot`'s house theme to matplotlib and lets-plot charts | Sec. 5 |\n", "| 6 | Identify preattentive attributes and use them deliberately | Sec. 6 |\n", "| 7 | Add annotations that carry the chart's narrative | Sec. 7 |\n", "| 8 | Explain why summary statistics alone are never sufficient (Datasaurus) | Sec. 8 |\n", "| 9 | Produce one polished, captioned chart from a messy dataset | Capstone |\n", ":::" ] }, { "cell_type": "markdown", "id": "4", "metadata": {}, "source": [ "## 1. Picking the Right Chart, and Cutting What Does Not Help\n", "\n", "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." ] }, { "cell_type": "code", "execution_count": null, "id": "5", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "\n", "# Full platform export, used directly wherever a chart doesn't need a fixed category count\n", "df7 = pd.read_csv(\"data/university_analytics.csv\")\n", "\n", "# Same three-course, three-Fall-cohort slice as Chapters 14 and 15, so every\n", "# chart in this notebook is comparing the same students\n", "_courses = [\"Machine Learning\", \"Data Structures\", \"Python Programming\"]\n", "_semesters = [\"Fall 2022\", \"Fall 2023\", \"Fall 2024\"]\n", "results = df7[df7[\"course\"].isin(_courses) & df7[\"semester\"].isin(_semesters)].copy()\n", "results.head()" ] }, { "cell_type": "markdown", "id": "6", "metadata": {}, "source": [ "**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:" ] }, { "cell_type": "code", "execution_count": null, "id": "7", "metadata": {}, "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "\n", "course_means = results.groupby(\"course\")[\"final_score\"].mean()\n", "\n", "fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))\n", "\n", "# Left: matplotlib defaults. Box on all four sides, both gridlines, a\n", "# legend the bar colours already make redundant since each bar has its\n", "# own label directly underneath it.\n", "axes[0].bar(course_means.index, course_means.values, color=[\"#4477AA\", \"#EE6677\", \"#228833\"])\n", "axes[0].set_title(\"Before: matplotlib defaults\")\n", "axes[0].grid(True)\n", "\n", "# Right: same data, decluttered. No top/right spines, no gridlines, the\n", "# value printed directly on each bar instead of relying on the y-axis.\n", "axes[1].bar(course_means.index, course_means.values, color=\"#4477AA\")\n", "axes[1].spines[\"top\"].set_visible(False)\n", "axes[1].spines[\"right\"].set_visible(False)\n", "axes[1].set_yticks([])\n", "for i, value in enumerate(course_means.values):\n", " axes[1].text(i, value + 1, f\"{value:.0f}\", ha=\"center\")\n", "axes[1].set_title(\"After: higher data-ink ratio\")\n", "\n", "fig.tight_layout()" ] }, { "cell_type": "markdown", "id": "8", "metadata": {}, "source": [ "
fig, axes = plt.subplots(1, 2) comparison code from above into a new cell.ax.grid(True) and ax.set_facecolor('#F0F0F0').fig, axes = plt.subplots(1, 2, figsize=(9, 3.5), sharey=True).alpha=0.6 in brand colours.axes[0].set_ylim(0, None) to start the scale at zero.summary table from this section (mean, std, n per course).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.\n",
"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.\n",
"import seaborn as sns\n",
"\n",
"fig, ax = plt.subplots()\n",
"sns.boxplot(data=results, x=\"course\", y=\"final_score\", ax=ax)\n",
"ax.set_title(...)\n",
"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.\n",
"midterm_score by region.\n",
"Highlight the region with the highest mean in #0369A1 and colour every other bar #ADB5BD.\n",
"Add a concise title stating which region leads.\n",
"region_means = df7.groupby(\"region\")[\"midterm_score\"].mean().sort_values()\n",
"# ... build the chart with one highlighted bar ...\n",
"final_score by semester as a line chart.\n",
"Add ax.annotate() to flag the semester with the highest mean and write a one-sentence insight as the chart title.\n",
"sem_means = df7.groupby(\"semester\")[\"final_score\"].mean()\n",
"# ... line chart + annotation ...\n",
"university_analytics.csv, compare the distribution of final_score for two programs side-by-side using overlapping KDE plots (or box plots).\n",
"Before plotting, print the mean and standard deviation for each program. Then reflect: would the statistics alone have revealed the difference?\n",
"import seaborn as sns\n",
"# ... print stats, then plot KDE for each program ...\n",
"configure_matplotlib_style()configure_matplotlib_style() only affects charts drawn after it runs, so call it before plt.subplots(), not after.\n",
"