How to Boxplot Angular and Wrapping Data in Matplotlib
The Challenge with Angular Data in Standard Boxplots
When visualizing angular data (such as wind directions, phase angles, or compass headings), you quickly run into a classic topology problem: wrapping. Whether your data is bound between 0° and 360° or -180° and 180°, the boundary points are actually right next to each other.
By default, Matplotlib’s boxplot() treats data as linear. If you have a tight cluster of measurements around the wrap-around point (for example, some at 175° and others at -175°), a linear boxplot sees a massive spread of 350° instead of a tight 10° cluster. This results in artificially long whiskers, distorted boxes, and incorrect median representations.
In this article, we will look at how to properly handle and boxplot wrapping angular data using Python, NumPy, and Matplotlib.
The Solution: Pre-computing Circular Statistics
Instead of letting Matplotlib calculate linear statistics on raw angular data, the correct approach is to compute circular statistics first, and then pass those custom statistics directly to Matplotlib using plt.bxp().
To calculate circular quantiles (like the 25th, 50th, and 75th percentiles), we can perform the following steps:
- Convert the angular data to radians.
- Calculate the circular mean to find the true center of the distribution.
- Shift the data so that the circular mean is centered at 0. This temporarily removes the boundary wrapping issue.
- Compute the standard linear percentiles (Q1, Median, Q3) on this shifted data.
- Shift the computed percentiles back to the original angular space.
- Use Matplotlib's
bxp()to draw the boxplot using these custom stats.
Step-by-Step Python Implementation
Here is a complete, reusable implementation using scipy.stats.circmean and Matplotlib's manual boxplot drawer:
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import circmean
def get_circular_boxplot_stats(data, label="", low=-180, high=180):
"""
Computes circular statistics for a 1D array of angles
and returns a dictionary compatible with matplotlib.pyplot.bxp
"""
# Convert data to radians
period = high - low
data_rad = np.deg2rad(data)
# 1. Compute circular mean as the center anchor
c_mean_rad = circmean(data_rad, high=np.pi, low=-np.pi)
# 2. Shift data so the circular mean is at 0
shifted_rad = (data_rad - c_mean_rad + np.pi) % (2 * np.pi) - np.pi
# 3. Calculate percentiles on the shifted linear data
q1_shift, med_shift, q3_shift = np.percentile(shifted_rad, [25, 50, 75])
# Calculate whiskers (1.5 * IQR rule on shifted data)
iqr = q3_shift - q1_shift
whislo_shift = np.min(shifted_rad[shifted_rad >= q1_shift - 1.5 * iqr])
whishi_shift = np.max(shifted_rad[shifted_rad <= q3_shift + 1.5 * iqr])
# Identify outliers on shifted data
outliers_mask = (shifted_rad < whislo_shift) | (shifted_rad > whishi_shift)
outliers_shift = shifted_rad[outliers_mask]
# 4. Shift stats back to the original scale
def to_orig_deg(val_rad):
deg = np.rad2deg((val_rad + c_mean_rad + np.pi) % (2 * np.pi) - np.pi)
# Map back to the [low, high] range
return (deg - low) % period + low
stats = {
"label": label,
"med": to_orig_deg(med_shift),
"q1": to_orig_deg(q1_shift),
"q3": to_orig_deg(q3_shift),
"whislo": to_orig_deg(whislo_shift),
"whishi": to_orig_deg(whishi_shift),
"fliers": [to_orig_deg(o) for o in outliers_shift]
}
return statsPlotting the Circular Boxplot
Now, let's generate some mock data clustering around the $\pm180^\circ$ boundary and plot it using our new function:
# Generate mock data clustering around 180 / -180 degrees
np.random.seed(42)
group_1 = np.random.normal(loc=175, scale=10, size=100)
# Wrap values exceeding 180 back to the negative range
group_1 = (group_1 + 180) % 360 - 180
group_2 = np.random.normal(loc=0, scale=15, size=100)
# Compute custom circular stats for both groups
stats_g1 = get_circular_boxplot_stats(group_1, label="Cluster @ 180°")
stats_g2 = get_circular_boxplot_stats(group_2, label="Cluster @ 0°")
# Plot using plt.bxp
fig, ax = plt.subplots(figsize=(8, 6))
ax.bxp([stats_g1, stats_g2], showfliers=True)
ax.set_ylabel("Angle (Degrees)")
ax.set_ylim(-180, 180)
ax.set_title("Corrected Circular Boxplot for Wrapping Data")
plt.grid(True, linestyle="--", alpha=0.6)
plt.show()Why This Approach Works
- No Boundary Distortion: By shifting the local cluster center to 0, we eliminate the artificial boundary gap, allowing standard linear percentile formulas to accurately calculate the spread.
- Native Matplotlib Integration: Using
plt.bxp()allows you to draw native-looking boxplots without having to manually draw polygons or lines. - Handles Outliers Correctly: Outliers are detected relative to the true circular IQR and mapped back to their correct coordinate spaces.