6  Histograms

In Chapter 5 you learned the principles of effective visualization. Now you will apply those principles to a specific chart type: the histogram. Histograms show the distribution of a single continuous variable — how frequently different values occur. Understanding the shape of your data is one of the most important steps in any analysis, and histograms are the workhorse tool for doing so.

This chapter also serves as your deep engagement with matplotlib’s object-oriented interface. Rather than calling convenience functions, you will create Figure and Axes objects explicitly and manipulate them. Every visual element in a matplotlib chart is an object with methods you can call, and understanding this architecture gives you fine-grained control over every pixel of your visualization.

6.1 Setup

import numpy as np
np.set_printoptions(suppress=True)
import pandas as pd

idx = pd.IndexSlice

pd.options.display.max_columns = 100

Load the Boulder and Broomfield weather data. Note the pipe (|) delimiter — not all “CSV” files use commas.

boulder_df = pd.read_csv('boulder_weather.csv', sep='|')
boulder_df.head()
broomfield_df = pd.read_csv('broomfield_weather.csv', sep='|')
broomfield_df.head()

6.2 Setting Up Matplotlib

When we use matplotlib within a Jupyter Notebook, we need to run a special command called a “cell magic” so that the images it generates are included directly in the notebook. Without %matplotlib inline, you would need to call plt.show() or plt.savefig() to see your visualizations.

We construct and manage plots through matplotlib’s pyplot (docs) interface.

%matplotlib inline

import matplotlib.pyplot as plt

6.3 Creating and Customizing Figures and Subplots

This is our first stop on the object-oriented coding style. Before we plot any data, let’s understand what a figure is by creating an empty one.

f, ax = plt.subplots()

The f object is the Figure — the entire canvas. The ax object is the Axes — a single subplot within the figure. Calling f returns the (now-empty) visualization.

f

The Figure contains an enormous number of methods and attributes. The .get_axes() method retrieves the subplot objects within the figure. There is only one subplot here, so it is a list of one item.

f.get_axes()

The ax object has its own methods and attributes. The .get_children() method shows all the components of this “empty” plot: spines for the four borders (top, bottom, right, left), the XAxis and YAxis with tick labels 0.0 to 1.0, empty Text objects for the xlabel, ylabel, and title, and a Rectangle patch object.

ax.get_children()

6.3.1 Mutating objects

Every visual element is an object you can modify. The Rectangle has a height, width, and origin.

rect = ax.get_children()[-1]
rect.get_height(), rect.get_width(), rect.get_xy()

We can mutate this Rectangle — color it red and shrink it.

rect.set_facecolor('red')
rect.set_height(.5)
rect.set_width(.5)

Check the figure again. The rectangle is now red and half-sized.

f

Mutate the rect object (docs) so that it has an edgecolor of green and a linewidth of 10. Display the result.

Solution:

rect.set_edgecolor('green')
rect.set_linewidth(10)

f

This exercise may seem trivial, but it demonstrates the core principle: matplotlib visualizations are built from objects, and you customize them by calling methods on those objects. Every bar in a histogram, every line in a time series, and every point in a scatter plot is an object you can inspect and modify.

6.4 One-Dimensional Histogram

Now let’s make a real histogram. First, retrieve the temperature values from the Boulder weather data as a numpy array using the .values attribute.

boulder_temps = boulder_df['TEMP'].values
boulder_temps

Create an empty figure and subplot.

f, ax = plt.subplots()

Plot a histogram of boulder_temps in the ax object. The .hist method returns three values: the count of values in each bin, the boundaries of each bin, and the BarContainer object that populates the axes.

ax.hist(boulder_temps)

But where is the plot? It was drawn into ax, which lives inside f. Check f.

f

If you create the figure, subplot, and histogram in the same cell (which we will do from now on), the figure appears automatically. You can silence the extra output from .hist() by adding a semicolon at the end of the line.

f, ax = plt.subplots()
ax.hist(boulder_temps);  # Semicolon silences the bin counts and boundaries

6.4.1 Choosing the number of bins

The bins parameter controls how many bars the histogram displays. Too few bins obscure the shape of the distribution; too many create noise. Experiment with different values.

Few bins (5): The shape is barely visible.

f, ax = plt.subplots()
ax.hist(boulder_temps, bins=5);

Moderate bins (25): We start to see a bimodal distribution — two peaks corresponding to winter and summer temperatures. This is exactly the kind of insight histograms are designed to reveal.

f, ax = plt.subplots()
ax.hist(boulder_temps, bins=25);

Many bins (100): The bimodal pattern is clearer, but this is probably more granular than a typical histogram needs to be.

6.4.2 Labeling axes

One of the most essential visualization practices is to label your axes. A histogram without labels forces the reader to guess what they are looking at.

f, ax = plt.subplots()
ax.hist(boulder_temps, bins=25, facecolor='#CFB87C')

ax.set_xlabel('Temperature (F)')
ax.set_ylabel('Count (Days)')
ax.set_title('Temperatures in Boulder, Colorado (2010–2021)')

The #CFB87C color is CU Boulder’s gold. Choosing meaningful colors — team colors, brand colors, or colorblind-friendly palettes — is a small detail that elevates your visualizations.

Create an empty figure and subplot, extract the “TEMP” values from broomfield_df, make a histogram with 25 bins, and label your axes.

Solution:

broomfield_temps = broomfield_df['TEMP'].values

f, ax = plt.subplots()
ax.hist(broomfield_temps, bins=25, facecolor='#CFB87C')

ax.set_xlabel('Temperature (F)')
ax.set_ylabel('Count (Days)')
ax.set_title('Temperatures in Broomfield, Colorado (2010–2021)')

6.5 Skewed Data

The weather data has nice statistical properties — it is approximately normally distributed (or bimodal, reflecting seasons). Many real-world datasets do not share this property. Income, population, case counts, and web traffic often show a strong right skew: many small values and a few extremely large ones.

Load the Colorado county-level COVID data.

co_covid_df = pd.read_csv('co_county_covid.csv')
co_covid_df.head()
co_covid_df['Cases'].describe()

Make a histogram. There is an enormous number of county-months with zero or few cases and a handful with thousands. The first bar dominates the chart, making it uninformative.

covid_cases = co_covid_df['Cases'].values

f, ax = plt.subplots()
ax.hist(covid_cases, bins=50)

ax.set_xlabel('Cases')
ax.set_ylabel('Count (Monthly)')
ax.set_title('COVID-19 Cases in Colorado Counties')

Even adding more bins does not improve this visualization. The fundamental problem is that a linear scale cannot accommodate data spanning several orders of magnitude.

6.5.1 Log transformations

A common approach to handling skewed data is to use logarithmic bins and axes. Rather than bin boundaries being linearly spaced (0, 1000, 2000, …), they are geometrically spaced (1, 10, 100, 1000, …).

Numpy provides two functions for generating evenly-spaced sequences. linspace creates linearly-spaced values and geomspace creates geometrically-spaced values.

np.linspace(0, 10000, 11)
np.geomspace(1, 10000, 11)  # Start at 10^0 = 1, end at 10^4 = 10000

Pass geometric bin boundaries to the bins parameter, then set logarithmic scales on the axes.

f, ax = plt.subplots()
ax.hist(covid_cases, bins=np.geomspace(1, 10000, 11))

ax.set_xlabel('Cases')
ax.set_ylabel('Count (Monthly)')
ax.set_title('Monthly COVID-19 Cases in Colorado Counties')

ax.set_xscale('log')

Adding a log scale to the y-axis as well creates a log-log plot, which reveals the underlying distributional shape.

f, ax = plt.subplots()
ax.hist(covid_cases, bins=np.geomspace(1, 10000, 11))

ax.set_xlabel('Cases')
ax.set_ylabel('Count (Monthly)')
ax.set_title('Monthly COVID-19 Cases in Colorado Counties')

ax.set_xscale('log')
ax.set_yscale('log')
WarningLog of zero is undefined

You cannot take the logarithm of zero, and np.geomspace cannot start at zero. Filter your data to positive values before applying a log transform, or use np.geomspace(1, ...) to start at 1.

6.5.2 Why log-binned data matters

Different probability distributions look similar on linear scales but have very different shapes on log scales. This is important because the distributional shape determines which statistical tools are appropriate. Here is a demonstration with four simulated distributions.

from sklearn.preprocessing import minmax_scale
_size = 1000

random_exponential = minmax_scale(np.random.exponential(size=_size), feature_range=(0, 1))
random_lognormal = minmax_scale(np.random.lognormal(size=_size), feature_range=(0, 1))
random_pareto = minmax_scale(np.random.pareto(a=1, size=_size), feature_range=(0, 1))
random_power = minmax_scale(np.random.power(a=5, size=_size), feature_range=(0, 1))

On linear axes, these distributions look quite similar.

f, (ax0, ax1, ax2, ax3) = plt.subplots(nrows=1, ncols=4, figsize=(16, 4))

ax0.hist(random_exponential, bins=25)
ax1.hist(random_lognormal, bins=25)
ax2.hist(random_pareto, bins=25)
ax3.hist(random_power, bins=25)

ax0.set_title('Exponential')
ax1.set_title('Log-normal')
ax2.set_title('Pareto')
ax3.set_title('Power')

f.suptitle('Linear axes and bins', fontweight='bold')
f.tight_layout()

On log axes with geometric bins, they look very different — each has a distinctive signature shape.

f, (ax0, ax1, ax2, ax3) = plt.subplots(
    nrows=1, ncols=4, figsize=(16, 4),
    subplot_kw={'xscale': 'log'}
)

ax0.hist(random_exponential, bins=np.geomspace(1e-3, 1e0, 25))
ax1.hist(random_lognormal, bins=np.geomspace(1e-3, 1e0, 25))
ax2.hist(random_pareto, bins=np.geomspace(1e-4, 1e0, 25))
ax3.hist(random_power, bins=np.geomspace(1e-3, 1e0, 25))

ax0.set_title('Exponential')
ax1.set_title('Log-normal')
ax2.set_title('Pareto')
ax3.set_title('Power')

f.suptitle('Log axes and geometric bins', fontweight='bold')
f.tight_layout()

Extract the “Deaths” values from co_covid_df. Make a histogram with geometrically-spaced bins, log-scaled axes, and labeled axes.

Solution:

covid_deaths = co_covid_df['Deaths'].values

f, ax = plt.subplots()
ax.hist(covid_deaths, bins=np.geomspace(1, 50, 25))

ax.set_title('COVID Deaths, Colorado, 2020–2022')
ax.set_xlabel('Deaths')
ax.set_ylabel('Count')

ax.set_xscale('log')
ax.set_yscale('log')

6.6 Visualizing Multiple Histograms

We often want to compare distributions across groups. Let’s compare temperatures in Boulder and Broomfield. Because the datasets cover slightly different date ranges and have different sizes, we first inner-join them on the date column so we are comparing the same days.

boulder_df.shape, broomfield_df.shape
both_df = pd.merge(
    left=boulder_df,
    right=broomfield_df,
    left_on='DATE',
    right_on='DATE',
    how='inner',
    suffixes=('_BOULDER', '_BROOMFIELD')
)

both_df.head()
boulder_temps = both_df['TEMP_BOULDER'].values
broomfield_temps = both_df['TEMP_BROOMFIELD'].values

6.6.1 Option 1: Side-by-side subplots

Use ncols=2 to create two subplots side by side. The sharey=True parameter ensures they use the same y-axis scale for fair comparison.

f, (ax0, ax1) = plt.subplots(
    nrows=1, ncols=2,
    figsize=(8, 4),
    sharey=True
)

ax0.hist(boulder_temps, bins=25)
ax0.set_xlabel('Temperature (F)')
ax0.set_ylabel('Count (Days)')
ax0.set_title('Boulder, Colorado')

ax1.hist(broomfield_temps, bins=25)
ax1.set_xlabel('Temperature (F)')
ax1.set_title('Broomfield, Colorado')

f.suptitle('Temperatures 2010–2021', fontweight='heavy')
f.tight_layout()

6.6.2 Option 2: Grouped bars on a single plot

Stack the two numpy arrays together and pass them to .hist() with color and label parameters.

stacked_arrays = np.stack([boulder_temps, broomfield_temps], axis=1)
boulder_temps.shape, broomfield_temps.shape, stacked_arrays.shape
f, ax = plt.subplots()

ax.hist(
    stacked_arrays,
    bins=25,
    histtype='bar',
    color=['tab:red', 'tab:blue'],
    label=['Boulder', 'Broomfield']
)

ax.set_xlabel('Temperature (F)')
ax.set_ylabel('Count (Days)')
ax.set_title('Temperatures (2010–2021)', fontweight='heavy')
ax.legend(prop={'size': 10})

6.6.3 Option 3: Stacked bars

Setting stacked=True layers one distribution on top of the other. This is perceptually harder to compare but shows the combined total.

f, ax = plt.subplots()

ax.hist(
    stacked_arrays,
    bins=15,
    histtype='bar',
    stacked=True,
    color=['tab:red', 'tab:blue'],
    label=['Boulder', 'Broomfield']
)

ax.set_xlabel('Temperature (F)')
ax.set_ylabel('Count (Days)')
ax.set_title('Temperatures (2010–2021)', fontweight='heavy')
ax.legend(prop={'size': 10})

6.6.4 Option 4: Cumulative histogram

A cumulative histogram shows the running total. This is useful for answering questions like “on what fraction of days was the temperature below 50°F?”

f, ax = plt.subplots()

ax.hist(
    stacked_arrays,
    bins=15,
    histtype='bar',
    cumulative=True,
    color=['tab:red', 'tab:blue'],
    label=['Boulder', 'Broomfield']
)

ax.set_xlabel('Temperature (F)')
ax.set_ylabel('Count (Days, cumulative)')
ax.set_title('Temperatures (2010–2021)', fontweight='heavy')
ax.legend(prop={'size': 10})

6.6.5 Small multiples with pandas

You can use pandas to generate a grid of histograms grouped by a categorical variable — one histogram per county, for example.

f, axs = plt.subplots(nrows=13, ncols=5, figsize=(10, 20))

co_covid_df.plot.hist(
    column='Cases',
    by=['County'],
    ax=axs,
    legend=False
);

f.tight_layout()

6.7 Pandas Histogram Methods

Everything above involved extracting numpy arrays from pandas. You can also create histograms directly from pandas objects.

The .hist() method on a Series produces a quick histogram.

both_df['TEMP_BOULDER'].hist(grid=False, bins=25)

Calling .hist() on a DataFrame makes one histogram per column.

both_df[['TEMP_BOULDER', 'TEMP_BROOMFIELD']].hist()

The .plot.hist() method offers additional options like stacking and horizontal orientation.

both_df[['TEMP_BOULDER', 'TEMP_BROOMFIELD']].plot.hist(stacked=True)
both_df[['TEMP_BOULDER', 'TEMP_BROOMFIELD']].plot.hist(orientation='horizontal')

You can also combine pandas plotting with your own figure setup by passing ax as a parameter.

f, (ax0, ax1) = plt.subplots(ncols=2, figsize=(8, 4))

both_df[['TEMP_BOULDER', 'TEMP_BROOMFIELD']].plot.hist(stacked=True, ax=ax0, legend=False)
both_df[['TEMP_BOULDER', 'TEMP_BROOMFIELD']].plot.hist(orientation='horizontal', ax=ax1)

6.8 Two-Dimensional Histograms

You can visualize the joint distribution of two variables with a 2D histogram. This works like a terrain map: darker colors represent lower counts and brighter colors represent higher counts.

f, ax = plt.subplots(figsize=(7, 6))

counts, xedges, yedges, im = ax.hist2d(
    x=boulder_temps,
    y=broomfield_temps,
    bins=25
)

ax.set_xlabel('Temperatures in Boulder (F)')
ax.set_ylabel('Temperatures in Broomfield (F)')
f.colorbar(im, ax=ax)

Unsurprisingly, two nearby cities have very similar temperatures. All the values lie along the diagonal — there are no days when it is 80°F in Boulder and 20°F in Broomfield.

6.9 Exercises

Make a figure with two side-by-side subplots. The left subplot should show a histogram of COVID cases and the right subplot should show COVID deaths. Use appropriate bins and consider whether log scaling is needed.

Solution:

f, (ax0, ax1) = plt.subplots(1, 2, figsize=(12, 4))

ax0.hist(co_covid_df['Cases'].values, bins=np.geomspace(1, 10000, 20))
ax0.set_xscale('log')
ax0.set_xlabel('Cases')
ax0.set_ylabel('Count')
ax0.set_title('COVID Cases')

ax1.hist(co_covid_df['Deaths'].values, bins=np.geomspace(1, 100, 20))
ax1.set_xscale('log')
ax1.set_xlabel('Deaths')
ax1.set_ylabel('Count')
ax1.set_title('COVID Deaths')

f.suptitle('Colorado County-Month COVID-19 Distributions')
f.tight_layout()

Compute the mean, median, and standard deviation of Boulder’s temperature data. Add vertical lines to your histogram at the mean and median. Do they fall in the same place? What does the difference (or similarity) tell you about the symmetry of the distribution?

Solution:

mean_temp = np.mean(boulder_temps)
median_temp = np.median(boulder_temps)

f, ax = plt.subplots(figsize=(10, 5))
ax.hist(boulder_temps, bins=25, facecolor='#CFB87C', edgecolor='white')
ax.axvline(mean_temp, color='red', linestyle='--', label=f'Mean: {mean_temp:.1f}°F')
ax.axvline(median_temp, color='blue', linestyle='--', label=f'Median: {median_temp:.1f}°F')
ax.legend()
ax.set_xlabel('Temperature (F)')
ax.set_ylabel('Count (Days)')
ax.set_title('Boulder Temperatures with Mean and Median')

If the mean and median are close, the distribution is approximately symmetric. If they diverge, the distribution is skewed. For bimodal data like temperatures, both measures fall between the two peaks and may be close to each other even though neither peak is near the center.

Create a figure with four subplots (2 rows, 2 columns) showing histograms of four different weather variables from the Boulder data (e.g., TEMP, PRCP, WDSP, MAX). Label each subplot appropriately.

Solution:

f, axs = plt.subplots(2, 2, figsize=(12, 8))

vars_and_labels = [
    ('TEMP', 'Temperature (°F)'),
    ('PRCP', 'Precipitation (in)'),
    ('WDSP', 'Wind Speed (mph)'),
    ('MAX', 'Max Temperature (°F)')
]

for ax, (col, label) in zip(axs.flatten(), vars_and_labels):
    vals = boulder_df[col].dropna().values
    ax.hist(vals, bins=25, color='steelblue', edgecolor='white')
    ax.set_xlabel(label)
    ax.set_ylabel('Count')

f.suptitle('Boulder Weather Variable Distributions', fontsize=14)
f.tight_layout()

6.10 Looking Ahead

Histograms show the distribution of one continuous variable. In Chapter 7, you will learn to compare distributions across categories — Democrats vs. Republicans, different world regions, incumbents vs. newcomers — using seaborn’s powerful categorical plotting functions.