21  Visual Exploration

21.1 Learning Objectives

By the end of this chapter, you will be able to:

21.2 Why Visualize

A well-chosen visualization reveals structure that no table of numbers can convey: the shape of a distribution, the trajectory of a trend, the clustering of groups, the isolation of outliers. This chapter introduces a systematic repertoire of plot types for exploratory data analysis using matplotlib and seaborn, organized by what you are trying to see — distributions, relationships, trends, and comparisons. The goal is not to produce publication-ready graphics (though some of these will be close) but to build visual intuitions that inform your analytical decisions.

Every plot in this chapter answers a question. Before writing any plotting code, state the question in a Markdown cell. After producing the plot, write one or two sentences interpreting what it shows. This habit turns exploratory plots from pretty pictures into analytical evidence.

21.3 Setup

This chapter uses pandas, numpy, matplotlib, and seaborn.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

pd.set_option('display.max_columns', None)
pd.set_option('display.float_format', '{:.3f}'.format)

# Seaborn defaults: clean, readable, and appropriately sized
sns.set_style('whitegrid')
sns.set_context('notebook', font_scale=1.1)

Install missing packages with conda install pandas numpy matplotlib seaborn (or pip install pandas numpy matplotlib seaborn) and restart the Jupyter kernel. seaborn depends on matplotlib, so if one is missing, the other likely is too. See Chapter 1.

21.4 Datasets

# Lahman batting — qualified player-seasons
batting = pd.read_csv('Batting.csv')
batting['BA'] = batting['H'] / batting['AB'].replace(0, float('nan'))
batting['TB'] = batting['H'] + 2*batting['2B'] + 3*batting['3B'] + 4*batting['HR']
batting['SLG'] = batting['TB'] / batting['AB'].replace(0, float('nan'))
batting['HBP'] = batting['HBP'].fillna(0)
batting['SF'] = batting['SF'].fillna(0)
batting['OBP'] = (
    (batting['H'] + batting['BB'] + batting['HBP'])
    / (batting['AB'] + batting['BB'] + batting['HBP'] + batting['SF']).replace(0, float('nan'))
)
batting['OPS'] = batting['OBP'] + batting['SLG']
batting['decade'] = (batting['yearID'] // 10) * 10
qualified = batting[batting['AB'] >= 100].copy()

# Salaries
salaries = pd.read_csv('Salaries.csv')
bat_sal = pd.merge(
    qualified, salaries[['playerID', 'yearID', 'salary']],
    on=['playerID', 'yearID'], how='inner'
)
# Colorado population
url = 'https://storage.googleapis.com/co-publicdata/sya-county.csv'
pop = pd.read_csv(url, skiprows=1, dtype={'countyfips': str})
estimates = pop[pop['datatype'] == 'Estimate'].copy()
estimates['age_band'] = pd.cut(
    estimates['age'], bins=[0, 18, 65, 101],
    labels=['Children (0-17)', 'Working Age (18-64)', 'Seniors (65+)'],
    right=False
)
# Cannabis sales
cannabis = pd.read_csv(
    'colorado_cannabis_sales_by_county.csv',
    header=2, skipfooter=8, engine='python', na_values=['NR']
)
cannabis.columns = ['month', 'year', 'county', 'medical', 'retail']
cannabis['county'] = cannabis['county'].str.strip()
for col in ['medical', 'retail']:
    cannabis[col] = pd.to_numeric(
        cannabis[col].str.replace('$', '', regex=False).str.replace(',', '', regex=False),
        errors='coerce'
    )
cannabis = cannabis[cannabis['county'] != 'Total']

21.5 Visualizing Distributions

The first question to ask about any numeric variable is: what does its distribution look like? Is it symmetric, skewed, bimodal, or uniform? Distributions guide your choice of summary statistics (mean vs. median), your approach to outliers, and your selection of statistical tests.

21.5.1 Histograms

A histogram divides the range of values into bins and counts how many observations fall in each. It is the most direct visualization of a distribution’s shape.

fig, ax = plt.subplots(figsize=(8, 4))
ax.hist(qualified['BA'], bins=50, edgecolor='white', alpha=0.7)
ax.set_xlabel('Batting Average')
ax.set_ylabel('Count')
ax.set_title('Distribution of Batting Average (qualified player-seasons, AB ≥ 100)')
ax.axvline(qualified['BA'].mean(), color='red', linestyle='--', label=f'Mean: {qualified["BA"].mean():.3f}')
ax.axvline(qualified['BA'].median(), color='orange', linestyle='--', label=f'Median: {qualified["BA"].median():.3f}')
ax.legend()
plt.tight_layout()
plt.show()

The mean and median are close together, suggesting a roughly symmetric distribution. If they diverged substantially, the distribution would be skewed.

21.5.2 Kernel density estimation (KDE)

A KDE plot smooths the histogram into a continuous curve, making it easier to see the overall shape:

fig, ax = plt.subplots(figsize=(8, 4))
sns.kdeplot(data=qualified, x='BA', ax=ax, fill=True, alpha=0.3)
ax.set_title('KDE of Batting Average')
ax.set_xlabel('Batting Average')
plt.tight_layout()
plt.show()

21.5.3 Comparing distributions across groups

Overlaying distributions for different groups reveals how they differ. Here we compare batting averages across eras:

era_filter = qualified['decade'].isin([1920, 1960, 2000])
era_data = qualified[era_filter].copy()
era_data['era'] = era_data['decade'].map({1920: '1920s', 1960: '1960s', 2000: '2000s'})

fig, ax = plt.subplots(figsize=(8, 4))
sns.kdeplot(data=era_data, x='BA', hue='era', ax=ax, fill=True, alpha=0.3)
ax.set_title('Batting Average Distribution by Era')
ax.set_xlabel('Batting Average')
plt.tight_layout()
plt.show()

21.5.4 Box plots

Box plots show the five-number summary (min, Q1, median, Q3, max) plus individual outlier points. They are especially effective for comparing distributions across many groups:

fig, ax = plt.subplots(figsize=(10, 5))
decade_data = qualified[qualified['decade'] >= 1900]
sns.boxplot(data=decade_data, x='decade', y='BA', ax=ax, color='lightblue')
ax.set_title('Batting Average by Decade (AB ≥ 100)')
ax.set_xlabel('Decade')
ax.set_ylabel('Batting Average')
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()

The boxes show the interquartile range (middle 50%), the horizontal line is the median, and the dots are outliers beyond the IQR fences.

21.5.5 Violin plots

A violin plot combines a box plot with a KDE, showing the full shape of each group’s distribution:

fig, ax = plt.subplots(figsize=(10, 5))
selected_decades = decade_data[decade_data['decade'].isin([1920, 1950, 1980, 2010])]
sns.violinplot(data=selected_decades, x='decade', y='HR', ax=ax, inner='quartile')
ax.set_title('Home Run Distribution by Selected Decades')
ax.set_xlabel('Decade')
ax.set_ylabel('Home Runs')
plt.tight_layout()
plt.show()

21.6 Visualizing Relationships

When you want to understand how two numeric variables relate to each other, scatter plots and correlation heatmaps are your primary tools.

21.6.1 Scatter plots

fig, ax = plt.subplots(figsize=(8, 6))
sample = bat_sal[bat_sal['yearID'] == 2015].copy()
ax.scatter(sample['salary'] / 1e6, sample['OPS'], alpha=0.4, s=20)
ax.set_xlabel('Salary (millions $)')
ax.set_ylabel('OPS')
ax.set_title('Salary vs. OPS (2015 season, qualified batters)')
plt.tight_layout()
plt.show()

If the points form a rising cloud, the variables are positively correlated. If the cloud is flat or shapeless, there is little linear relationship. If the cloud curves, the relationship may be nonlinear — something a correlation coefficient alone would not reveal.

21.6.2 Adding context with color and size

Seaborn’s scatterplot() can encode additional variables using color (hue) and size:

fig, ax = plt.subplots(figsize=(9, 6))
sns.scatterplot(
    data=sample, x=sample['salary']/1e6, y='OPS',
    hue='AB', size='HR', sizes=(20, 200), alpha=0.6, ax=ax
)
ax.set_xlabel('Salary (millions $)')
ax.set_title('Salary vs. OPS — sized by HR, colored by AB')
plt.tight_layout()
plt.show()

21.6.3 Pair plots

When you have several numeric variables, a pair plot shows every pairwise scatter plot and each variable’s distribution in a single figure:

pair_cols = ['BA', 'SLG', 'OBP', 'HR']
pair_data = qualified[qualified['decade'] == 2010][pair_cols].dropna().sample(500, random_state=42)

sns.pairplot(pair_data, diag_kind='kde', plot_kws={'alpha': 0.3, 's': 15})
plt.suptitle('Pairwise Relationships — 2010s Batting (sample)', y=1.02)
plt.tight_layout()
plt.show()

21.6.4 Correlation heatmaps

A heatmap visualizes the correlation matrix from Chapter 18, making it easy to spot strong relationships at a glance:

metrics = qualified[['AB', 'H', 'HR', 'BB', 'BA', 'SLG', 'OBP', 'OPS']].dropna()
corr = metrics.corr()

fig, ax = plt.subplots(figsize=(8, 6))
sns.heatmap(corr, annot=True, fmt='.2f', cmap='coolwarm', center=0, ax=ax,
            square=True, linewidths=0.5)
ax.set_title('Correlation Matrix — Batting Metrics')
plt.tight_layout()
plt.show()

Red cells indicate strong positive correlations, blue cells indicate negative correlations, and white cells indicate little linear relationship.

21.8 Visualizing Comparisons

When you want to compare a metric across categorical groups, bar charts, grouped bar charts, and faceted plots make the differences visible.

21.8.1 Bar charts for group comparisons

# Dependency ratio by county type (top vs bottom 10)
dep_wide = (
    estimates[estimates['year'] == 2020]
    .groupby(['county', 'age_band'], observed=True)
    .agg(pop=('totalpopulation', 'sum'))
    .reset_index()
    .pivot_table(index='county', columns='age_band', values='pop', aggfunc='sum')
    .reset_index()
)
dep_wide.columns.name = None
dep_wide['dep_ratio'] = (
    (dep_wide['Children (0-17)'] + dep_wide['Seniors (65+)'])
    / dep_wide['Working Age (18-64)']
)

top5 = dep_wide.nlargest(5, 'dep_ratio')
bot5 = dep_wide.nsmallest(5, 'dep_ratio')
comparison = pd.concat([top5, bot5]).sort_values('dep_ratio', ascending=True)

fig, ax = plt.subplots(figsize=(8, 5))
colors = ['steelblue' if x in bot5['county'].values else 'coral' for x in comparison['county']]
ax.barh(comparison['county'], comparison['dep_ratio'], color=colors)
ax.set_xlabel('Dependency Ratio')
ax.set_title('Highest and Lowest Dependency Ratios — Colorado Counties (2020)')
ax.axvline(dep_wide['dep_ratio'].median(), color='gray', linestyle='--', alpha=0.5, label='Median')
ax.legend()
plt.tight_layout()
plt.show()

21.8.2 Faceted plots (small multiples)

Faceting breaks a single plot into a grid of subplots, one per group. This is one of the most effective techniques for comparing patterns across categories:

# Batting average distribution across four selected decades
four_decades = qualified[qualified['decade'].isin([1930, 1960, 1990, 2020])].copy()
four_decades['era'] = four_decades['decade'].astype(str) + 's'

g = sns.FacetGrid(four_decades, col='era', col_wrap=2, height=3.5, aspect=1.3)
g.map(sns.histplot, 'BA', bins=30, kde=True)
g.set_axis_labels('Batting Average', 'Count')
g.set_titles('{col_name}')
g.figure.suptitle('Batting Average Distribution by Era', y=1.02)
plt.tight_layout()
plt.show()

21.9 Choosing the Right Plot

Question Plot type When to use
What does this variable look like? Histogram, KDE, box plot Single numeric variable
How do distributions differ across groups? Overlaid KDE, grouped box plot, violin plot One numeric + one categorical
How are two variables related? Scatter plot, pair plot Two numeric variables
What are all pairwise relationships? Correlation heatmap, pair plot Many numeric variables
How has a variable changed over time? Line plot Numeric variable + time
How do groups compare on a metric? Bar chart, grouped bar chart One numeric + one categorical
How do patterns vary across subgroups? Faceted plot (small multiples) Any plot + a grouping variable

21.10 Common Issues in This Chapter

WarningThe plot is too small or text is overlapping

What it means: The default figure size is often too small for more than a toy example.

How to fix it: Pass figsize=(10, 5) (or similar) to plt.subplots(). Call plt.tight_layout() before plt.show() to prevent label overlap. For long category labels, rotate with plt.xticks(rotation=45).

WarningSeaborn’s ci parameter is deprecated

What it means: In seaborn 0.12+, ci=95 was replaced with errorbar=('ci', 95) in functions like lineplot. Your code may throw a FutureWarning.

How to fix it: Replace ci=95 with errorbar=('ci', 95). For no confidence interval, use errorbar=None instead of ci=None. See seaborn’s release notes.

WarningThe plot shows <Figure size ... Axes: ...> text instead of a figure

What it means: You used a plotting call that returned the figure/axes object without rendering. This is common when the last expression in a notebook cell is a plot object.

How to fix it: End the cell with plt.show(), or assign the figure to an underscore: _ = ax.plot(...). Or put the call in a different cell.

WarningFacetGrid or hue does not color the way you expect

What it means: Seaborn infers color order from the column’s dtype. Numeric columns get a continuous palette; categorical columns get a discrete one.

How to fix it: Cast the column to str or category before plotting if you want a discrete palette. Or pass an explicit palette argument: palette='Set2'.

21.11 Key Takeaways

  • Every plot should answer a specific question. Write the question first, the code second, the interpretation third.
  • Distributions: histograms, KDEs, box plots, violin plots. Relationships: scatter, pairplot, heatmap. Trends: line plots. Comparisons: bar charts, faceted plots.
  • Seaborn and matplotlib compose. You can start a plot with sns. and keep refining it with ax. methods.
  • Always label axes, set a title, and include units. A plot without labels is not evidence.
  • Small multiples (faceted plots) beat one crowded plot almost every time.

21.12 Further Reading

21.13 Exercises

Create overlaid KDE plots comparing home run distributions for the 1990s and 2010s. What differences do you observe in the shape, center, and spread of the two distributions?

era_hr = qualified[qualified['decade'].isin([1990, 2010])].copy()
era_hr['era'] = era_hr['decade'].map({1990: '1990s', 2010: '2010s'})

fig, ax = plt.subplots(figsize=(8, 4))
sns.kdeplot(data=era_hr, x='HR', hue='era', fill=True, alpha=0.3, ax=ax)
ax.set_title('Home Run Distribution: 1990s vs 2010s')
plt.tight_layout()
plt.show()

Create a scatter plot of medical vs. retail cannabis sales by county-year. Add a 45-degree reference line. What does the plot tell you about the relationship between the two sales categories?

county_year = cannabis.groupby(['county', 'year'])[['medical', 'retail']].sum().dropna().reset_index()

fig, ax = plt.subplots(figsize=(7, 7))
ax.scatter(county_year['medical'], county_year['retail'], alpha=0.3, s=15)
lim = max(county_year['medical'].max(), county_year['retail'].max())
ax.plot([0, lim], [0, lim], 'r--', alpha=0.5, label='1:1 line')
ax.set_xlabel('Medical Sales ($)')
ax.set_ylabel('Retail Sales ($)')
ax.set_title('Medical vs. Retail Cannabis Sales by County-Year')
ax.legend()
plt.tight_layout()
plt.show()

Most points fall above the 1:1 line, indicating that retail sales generally exceed medical sales — increasingly so over time as the recreational market has grown.

Create a faceted line plot showing the annual dependency ratio trend for four Colorado counties of your choice. Use FacetGrid or col_wrap in seaborn. What similarities and differences do you observe across counties?