Skip to the content

EXECUTED, WITH ASSERTIONS

This program was run during verification and its results asserted. The runner that does it is tools/run_data_labs.py.

The code

Straight from labs/course-9-python-da/18_plots.py, unchanged.

"""Practical 18 — Basic visualizations with matplotlib (and Seaborn, Plotly).

Runs under the Agg backend: it opens no window and writes PNG files to plots/,
beside this file, so it works on a server and in CI, and you can open them.
[Changed: they went to a temporary directory, deleted at the end, so no one could
look at the charts this practical is about.]

Seaborn and Plotly are imported CONDITIONALLY -- if either is absent the script
says so and skips that section rather than failing.
"""
import pathlib
import numpy as np
import pandas as pd

import matplotlib
matplotlib.use("Agg")               # MUST come before importing pyplot
import matplotlib.pyplot as plt

from fixtures import students


def four_plot_types(tmp):
    df = students()
    ds = df.loc[df.dept == "DS", "maths"]
    st = df.loc[df.dept == "Stats", "maths"]

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

    # A HISTOGRAM shows the distribution of ONE CONTINUOUS variable.
    # The bars are bins and they TOUCH, because the axis is continuous.
    axes[0, 0].hist(df.maths, bins=5, color="#2b4c7e", edgecolor="white")
    axes[0, 0].set_title("Distribution of maths marks")
    axes[0, 0].set_xlabel("Marks")
    axes[0, 0].set_ylabel("Number of students")

    # A BAR CHART compares CATEGORIES. The bars have GAPS, because there is
    # nothing between 'DS' and 'Stats'.
    means = df.groupby("dept").maths.mean()
    axes[0, 1].bar(means.index, means.to_numpy(), color="#2b8a3e")
    axes[0, 1].set_title("Mean maths mark by department")
    axes[0, 1].set_xlabel("Department")
    axes[0, 1].set_ylabel("Mean marks")
    axes[0, 1].set_ylim(0, 100)          # bar charts START AT ZERO

    axes[1, 0].scatter(df.maths, df.stats, s=80, color="#c92a2a")
    axes[1, 0].set_title("Maths vs Statistics")
    axes[1, 0].set_xlabel("Maths marks")
    axes[1, 0].set_ylabel("Statistics marks")
    axes[1, 0].axhline(float(df.stats.mean()), color="grey", linestyle=":",
                       label="mean stats")
    axes[1, 0].legend()

    axes[1, 1].boxplot([ds.to_numpy(), st.to_numpy()],
                       tick_labels=["DS", "Stats"])
    axes[1, 1].set_title("Marks by department")
    axes[1, 1].set_xlabel("Department")
    axes[1, 1].set_ylabel("Marks")

    fig.tight_layout()
    out = tmp / "overview.png"
    fig.savefig(out, dpi=100, bbox_inches="tight")

    # Every axes must carry a title and BOTH labels -- an unlabelled axis
    # makes a chart unreadable, and this asserts it rather than saying it.
    for ax in axes.ravel():
        assert ax.get_title(), "every plot needs a title"
        assert ax.get_xlabel(), "every plot needs an x label"
        assert ax.get_ylabel(), "every plot needs a y label"

    assert axes[0, 1].get_ylim()[0] == 0, "a bar chart's y-axis starts at zero"

    plt.close(fig)
    assert out.exists() and out.stat().st_size > 1000

    print(f"  matplotlib: 4 subplots written to {out.name} "
          f"({out.stat().st_size // 1024} KB), all axes labelled")


def object_oriented_not_pyplot(tmp):
    """pyplot's hidden 'current figure' breaks the moment you have two."""
    figs = []
    for i, colour in enumerate(["#2b4c7e", "#2b8a3e"]):
        fig, ax = plt.subplots(figsize=(4, 3))
        ax.plot([1, 2, 3], [i + 1, i + 2, i + 3], color=colour)
        ax.set_title(f"Chart {i}")
        ax.set_xlabel("x")
        ax.set_ylabel("y")
        figs.append((fig, ax))

    # Each figure is a distinct object -- no hidden state to confuse.
    assert figs[0][0] is not figs[1][0]
    assert figs[0][1].get_title() == "Chart 0"
    assert figs[1][1].get_title() == "Chart 1"

    for i, (fig, _) in enumerate(figs):
        fig.savefig(tmp / f"chart{i}.png", dpi=80)
        plt.close(fig)

    assert (tmp / "chart0.png").exists() and (tmp / "chart1.png").exists()
    print("  the fig/ax interface keeps two charts separate; pyplot's hidden")
    print("       'current figure' does not -- and plt.close() avoids a leak")


def pandas_plotting(tmp):
    df = students()
    ax = df.plot(kind="scatter", x="maths", y="stats", figsize=(5, 4))
    ax.set_title("Pandas .plot returns a matplotlib Axes")
    assert ax.get_title()
    fig = ax.get_figure()
    fig.savefig(tmp / "pandas.png", dpi=80)
    plt.close(fig)

    ax2 = df.groupby("dept").maths.mean().plot(kind="bar", figsize=(4, 3))
    ax2.set_xlabel("Department")
    ax2.set_ylabel("Mean marks")
    plt.close(ax2.get_figure())

    assert (tmp / "pandas.png").exists()
    print("  df.plot() is a thin matplotlib wrapper and RETURNS an Axes, so you")
    print("       can customise it exactly as above")


def seaborn_section(tmp):
    try:
        import seaborn as sns
    except ImportError:
        print("  seaborn: NOT INSTALLED -- section skipped (not a failure)")
        return

    df = students()
    fig, axes = plt.subplots(1, 3, figsize=(15, 4))

    sns.boxplot(data=df, x="dept", y="maths", ax=axes[0])
    axes[0].set_title("Marks by department")
    axes[0].set_xlabel("Department")
    axes[0].set_ylabel("Marks")

    sns.scatterplot(data=df, x="maths", y="stats", hue="dept", s=100, ax=axes[1])
    axes[1].set_title("Maths vs Statistics, coloured by department")
    axes[1].set_xlabel("Maths")
    axes[1].set_ylabel("Statistics")

    sns.heatmap(df[["maths", "stats"]].corr(), annot=True, cmap="coolwarm",
                center=0, vmin=-1, vmax=1, ax=axes[2])
    axes[2].set_title("Correlation")
    axes[2].set_xlabel("")
    axes[2].set_ylabel("")

    fig.tight_layout()
    out = tmp / "seaborn.png"
    fig.savefig(out, dpi=100, bbox_inches="tight")
    plt.close(fig)

    assert out.exists() and out.stat().st_size > 1000
    print(f"  seaborn {sns.__version__}: hue= groups by colour with no loop;")
    print(f"       heatmap(corr(), center=0) uses a DIVERGING map around a")
    print(f"       meaningful midpoint, which is the correct use of one")


def plotly_section(tmp):
    try:
        import plotly.express as px
    except ImportError:
        print("  plotly: NOT INSTALLED -- section skipped (not a failure)")
        return

    df = students()
    fig = px.scatter(df, x="maths", y="stats", color="dept",
                     hover_data=["name"], title="Maths vs Statistics")
    out = tmp / "interactive.html"
    fig.write_html(out)

    assert out.exists()
    size_kb = out.stat().st_size // 1024
    assert size_kb > 100, "plotly bundles a JavaScript library"

    print(f"  plotly: interactive.html is {size_kb} KB -- it EMBEDS a JS library")
    print(f"       fine on a web page, wrong in an email or a printed report")


def honest_charts():
    """Truncating a bar chart's y-axis exaggerates the difference."""
    a, b = 95.0, 100.0

    real_difference = (b - a) / a             # the honest figure
    truncated_ratio = (b - 90) / (a - 90)     # y-axis starting at 90

    assert round(real_difference, 3) == 0.053, "the real difference is 5.3%"
    assert truncated_ratio == 2.0, "truncated, one bar looks TWICE the other"

    print(f"  honesty: 95 vs 100 is a {real_difference:.1%} difference, but with the")
    print(f"       y-axis starting at 90 one bar is {truncated_ratio:.0f}x the")
    print(f"       other. A bar's LENGTH is the encoding, so it must start at 0.")


def main():
    print("Practical 18 -- Visualization")
    # Step 1: Draw the plots, into plots/
    out = pathlib.Path(__file__).resolve().parent / "plots"
    out.mkdir(exist_ok=True)
    four_plot_types(out)
    object_oriented_not_pyplot(out)
    pandas_plotting(out)
    seaborn_section(out)
    plotly_section(out)
    print(f"  the charts are in {out.name}/: "
          + ", ".join(sorted(f.name for f in out.iterdir())))
    # Step 2: Draw honest charts
    honest_charts()


if __name__ == "__main__":
    main()

Where this sits

One experiment from the Python for Data Analysis and Visualization lab. The rest of them, and the theory behind this one, are on the lab page.