{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "---\n", "title: \"Chapter 14: Matplotlib and Seaborn\"\n", "---" ] }, { "cell_type": "markdown", "id": "1", "metadata": {}, "source": [ "[](https://colab.research.google.com/github/sambaiga/ds-mlops-path/blob/main/tutorials/01-python-basics/14-matplotlib.ipynb) [](https://raw.githubusercontent.com/sambaiga/ds-mlops-path/main/tutorials/01-python-basics/14-matplotlib.ipynb)" ] }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "You have just finished cleaning `university_analytics.csv`. The head looks right, the dtypes are correct, the nulls are gone. Then your manager asks: *\"What does the score distribution actually look like?\"* You could print quartiles. You could sort and read 2,400 rows. Or you could write three lines of matplotlib and see the entire shape in half a second.\n", "\n", "Chapter 7 introduced NumPy arrays as the numerical backbone. This notebook puts those arrays on screen. You will build every standard chart type, compose multi-panel figures, and learn the seaborn shortcut for statistical graphics. Chapter 15 (`15-lets-plot.ipynb`) introduces a different approach entirely: the grammar of graphics.\n", "\n", "> Callout markers: [book cover page](../../index.qmd#callout-guide)." ] }, { "cell_type": "markdown", "id": "3", "metadata": {}, "source": [ "## Python's plotting landscape\n", "\n", "That chart needs a library. Python has several, and they make different trade-offs:\n", "\n", "| Library | Style | Strengths | Best for |\n", "| --- | --- | --- | --- |\n", "| **Matplotlib** ([matplotlib.org](https://matplotlib.org)) | Imperative (OO API) | Total control, publication quality, runs everywhere | Custom figures, fine-grained layout, saving to PNG/PDF/SVG |\n", "| **Seaborn** ([seaborn.pydata.org](https://seaborn.pydata.org)) | High-level on top of Matplotlib | Statistical plots in one line, beautiful defaults | Distribution plots, pair plots, heatmaps |\n", "| **Lets-Plot** ([lets-plot.org](https://lets-plot.org)) | Declarative, Grammar of Graphics | Expressive, ggplot2-compatible, interactive-ready | Layered charts, Chapter 15 of this book |\n", "| **Plotly** ([plotly.com/python](https://plotly.com/python)) | Declarative, interactive | Hover, zoom, dash integration | Dashboards, interactive reports |\n", "| **Bokeh** ([bokeh.org](https://docs.bokeh.org)) | Declarative, interactive | Streaming, large data | Real-time visualisations |\n", "| **Altair** ([altair-viz.github.io](https://altair-viz.github.io)) | Declarative, grammar of graphics | Concise, tidy-data native, composable | Exploratory charts, smaller datasets |\n", "\n", "This chapter focuses on Matplotlib and Seaborn. Every other library in the list either wraps Matplotlib, exports to it, or assumes you understand it. Matplotlib is the bedrock: learn it once and every other plotting library becomes a set of shortcuts on top of something you already know.\n", "\n", "### Already in your environment\n", "\n", "Both libraries are in `pyproject.toml`. For a standalone project:\n", "\n", "```bash\n", "uv add matplotlib seaborn\n", "```" ] }, { "cell_type": "markdown", "id": "4", "metadata": {}, "source": [ "::: {.callout-note collapse=\"true\" icon=false}\n", "## Learning objectives\n", "\n", "By the end of Chapter 14 you will be able to:\n", "\n", "| # | Skill | Covered in |\n", "|---|---|---|\n", "| 1 | Explain the Figure/Axes object model and why it matters | Sec. 1 |\n", "| 2 | Build line, scatter, bar, and histogram charts with the object-oriented API | Sec. 2 |\n", "| 3 | Lay out and compare multiple Axes in one Figure | Sec. 3 |\n", "| 4 | Save a figure at the right resolution and format for its destination | Sec. 3 |\n", "| 5 | Use seaborn for one-line statistical graphics, then keep customising with matplotlib | Sec. 4 |\n", ":::\n" ] }, { "cell_type": "markdown", "id": "5", "metadata": {}, "source": [ "## 1. Why visualise? The figure and axes\n", "\n", "A table of a thousand exam scores tells you nothing at a glance. A histogram of the same thousand scores tells you the shape of the distribution in about half a second. That is the entire case for visualisation: it trades a small amount of precision for a large amount of immediate understanding, which is exactly what you want before you have decided which question to ask next." ] }, { "cell_type": "code", "execution_count": null, "id": "6", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "\n", "df = pd.read_csv(\"data/university_analytics.csv\")\n", "final_scores = df[\"final_score\"]\n", "\n", "print(f\"mean : {final_scores.mean():.1f}\")\n", "print(f\"median : {final_scores.median():.1f}\")\n", "print(f\"std : {final_scores.std():.1f}\")\n", "# The numbers alone do not tell you whether the distribution is symmetric,\n", "# has a long tail, or is bimodal. A histogram answers that in one look." ] }, { "cell_type": "markdown", "id": "7", "metadata": {}, "source": [ "matplotlib has two APIs for building the same chart. The older one, `pyplot` (`plt.plot(...)`), is a state machine: it always draws onto \"whichever figure was most recently touched,\" which is fine for a single quick chart and confusing the moment you need two charts side by side. The **object-oriented API** is explicit instead: you ask for a `Figure` (the whole canvas) and one or more `Axes` (an individual plot inside it), then call methods directly on the `Axes` you want to draw on." ] }, { "cell_type": "code", "execution_count": null, "id": "8", "metadata": {}, "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "\n", "# The object-oriented pattern you will use for almost every chart in this\n", "# notebook: ask for a Figure and an Axes, then call methods on the Axes.\n", "fig, ax = plt.subplots(figsize=(5, 3))\n", "ax.hist(final_scores, bins=20, color=\"#4477AA\", edgecolor=\"white\")\n", "ax.set_xlabel(\"Final score\")\n", "ax.set_ylabel(\"Number of students\")\n", "ax.set_title(\"Final score distribution\");" ] }, { "cell_type": "markdown", "id": "9", "metadata": {}, "source": [ "
Figure is the whole canvas: the window or page a chart is drawn on, and the thing you save to a file. An Axes is one plot inside that canvas, with its own x-axis, y-axis, title, and data. fig, ax = plt.subplots() gives you one of each. Every method that actually draws data (.plot(), .scatter(), .bar(), .hist()) lives on the Axes, not the Figure.\n",
"plt.plot() and ax.plot()plt.title(\"x\") sets the title of whichever Axes pyplot thinks is \"current\", which silently changes after you create a new subplot. The moment you have more than one Axes, calling plt.xlabel() instead of ax.set_xlabel() is a common way to label the wrong chart. Once you have an ax object, call methods on it directly and skip plt.* entirely.\n",
"scores = np.random.default_rng(42).normal(72, 12, 500).clip(0, 100).fig, ax = plt.subplots(figsize=(7, 4)).ax.hist(scores, bins=20, color='#0369A1', edgecolor='white').ax.set_title(), ax.set_xlabel(), ax.set_ylabel().ax.axvline(scores.mean(), color='#DC2626', lw=2, label='mean').ax.axline()ax.axline(xy1, slope=m) draws an infinite line through point xy1 at gradient m. Unlike ax.plot(), which clips to the data you provide, axline always extends to the full axis range even when the axes limits change later. Use it to add a y = x diagonal (slope 1 through the origin) on any scatter of two variables that should ideally be equal.\n",
"ax.bar_label() to annotate bar values automaticallyax.text() for each rectangle. Since matplotlib 3.4, ax.bar_label(container) does it in one line. ax.bar() returns a BarContainer; pass it to bar_label and optionally format the numbers with fmt:\n",
"\n",
"bars = ax.bar(courses, course_means, color=[\"#4477AA\", \"#EE6677\", \"#228833\"])\n",
"ax.bar_label(bars, fmt=\"{:.1f}\", padding=3)\n",
"\n",
"fmt accepts a format string (applied to each value) or a labels keyword with an explicit list. padding pushes the text a few points above the bar top.\n",
"attendance_pct with 15 bins, label both axes, and give it a title. Use the object-oriented pattern: fig, ax = plt.subplots(), then call methods on ax.\n",
"fig, ax = plt.subplots(figsize=(5, 3))\n",
"ax.hist(attendance_pct, bins=15, ...)\n",
"# expect a rough bell shape centred around 75%, with a scattering\n",
"# of students down near 30%\n",
"fig displays the figure in Jupyterx on its own line. Ending a plotting cell with fig (or letting ax.hist(...) be the last call) shows the chart without an explicit plt.show(), which you only need outside a notebook.\n",
"fig, axes = plt.subplots(2, 3) gives you a (2, 3) NumPy array of Axes objects. Use axes.flat to iterate over all six in order, regardless of grid shape. sharey=True links the y-axis scale across all panels so comparisons stay honest.\n",
"sharey=True, matplotlib autoscales each Axes to its own data. Three histograms that look like they have the same number of students can actually have wildly different counts, because each y-axis silently uses a different scale. Whenever you put similar charts side by side for comparison, force a shared scale.\n",
"maths = np.array([78,82,91,65,88,74,95,61,83,79]), stats = np.array([72,88,65,91,77,83,69,94,71,86]), prog = np.array([85,79,92,68,88,76,93,71,84,90]).fig, axes = plt.subplots(1, 3, figsize=(11, 3.5), sharey=True).zip(axes.flat, ['Maths','Stats','Programming'], [maths, stats, prog]) and plot a histogram on each axis.sns.histplot(..., ax=ax) draws onto the Axes you pass it and returns that same Axes. Nothing from Sections 1-3 is wasted: every ax.set_title(), ax.set_xlabel(), or fig.savefig() you already know still works on a seaborn chart. Seaborn only replaces the part where you would otherwise have looped over groups and called ax.hist() once per group yourself.\n",
"sns.boxplot to compare study_hours across the three courses (x-axis: course, y-axis: study_hours), then add a title with ax.set_title().\n",
"fig, ax = plt.subplots(figsize=(6, 3.5))\n",
"sns.boxplot(data=results, x=\"course\", y=\"study_hours\", ax=ax)\n",
"ax.set_title(...)\n",
"Hint: This is almost identical to the boxplot above, just with a different y-axis and no hue.\n",
"seaborn.objects (so.Plot()), a fully composable layer built on the same grammar-of-graphics ideas as Chapter 15's Lets-Plot. It's worth knowing even if you do not switch immediately:\n",
"\n",
"import seaborn.objects as so\n",
"\n",
"(\n",
" so.Plot(results, x=\"study_hours\", y=\"final_score\", color=\"course\")\n",
" .add(so.Dot(alpha=0.4))\n",
" .label(title=\"Study hours vs. final score\")\n",
")\n",
"\n",
"so.Plot() is lazy (nothing renders until you call .show() or display it), composable (chain .add() calls to layer marks), and consistent with the Lets-Plot mental model from Chapter 15. For exploratory work the classic sns.* functions are still faster to type; so.Plot() pays off when a chart needs several layers or custom marks.\n",
"[[\"hist\", \"hist\"], [\"scatter\", \"box\"]] means: row 1 has one panel called \"hist\" spanning two columns; row 2 has \"scatter\" and \"box\" side by side. Repeated letters span multiple cells. Reference each panel by name via axes[\"hist\"]. The layout reads like ASCII art, which makes it easy to reason about before you see the output.\n",
"(1, 3) grid of Axes:\n",
"final_score for \"Machine Learning\" onlystudy_hours vs. final_score, same course onlyfinal_score per course (all courses)fig.suptitle() and each Axes its own ax.set_title(). Hint: Filter with ml_mask = results[\"course\"] == \"Machine Learning\", then index results[ml_mask] for the first two panels.\n",
"