Skip to content

How to fix: How can I boxplot values that wrap around using matplotlib?

When plotting directional or periodic data (e.g., wind direction in degrees, time of day, or phases), standard boxplots fail because they assume a linear Euclidean space. Matplotlib’s plt.boxplot calculates the median, quartiles, and whiskers based on the magnitude of values relative to an absolute zero.

For circular data, the value $359^\circ$ is physically adjacent to $1^\circ$. However, a standard boxplot treats them as being at opposite ends of the scale. This results in a “broken” boxplot where the median appears to be $180^\circ$ and the whiskers stretch across the entire plot, even if the data is highly clustered around the $0^\circ/360^\circ$ boundary. This is not a “bug” in Matplotlib, but a mathematical mismatch between the data’s topology (a circle) and the plot’s coordinate system (a line).

Cause Mechanism Impact
Modular Arithmetic Data follows $x \pmod P$, but the plotter assumes $x \in (-\infty, \infty)$. Statistical summaries (median/IQR) become physically meaningless.
Boundary Discontinuity The arbitrary cut-off (e.g., $0$ to $360$) splits a single cluster into two. Data clustered at the wrap point appears as two groups of extreme outliers.
Linear Averaging boxplot uses arithmetic means/medians instead of vector-based circular statistics. The “center” of the data is calculated as the midpoint of the range rather than the mean direction.

Solution 1: Data Unwrapping (Linearization)

Section titled “Solution 1: Data Unwrapping (Linearization)”

If your data is clustered around the wrap point, you can “unwrap” it by shifting the values to a range where the cluster is continuous.

import numpy as np
import matplotlib.pyplot as plt
# Simulated data clustered around 0/360 degrees
data = np.array([350, 355, 5, 10, 358, 2])
# 1. Transform data to -180 to 180 range to move the wrap point to the back
shifted_data = (data + 180) % 360 - 180
fig, ax = plt.subplots()
ax.boxplot(shifted_data)
# 2. Fix the labels to reflect original values
ticks = ax.get_yticks()
labels = [(t + 360) % 360 for t in ticks]
ax.set_yticklabels(labels)
plt.show()

Solution 2: Manual Circular Statistics with bxp

Section titled “Solution 2: Manual Circular Statistics with bxp”

For more robust analysis, calculate circular statistics using scipy.stats and feed the results into Matplotlib’s manual boxplot engine, bxp.

import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import circmean, circstd
def circular_boxplot_stats(data, low=0, high=360):
# Convert to radians for calculation
rad_data = np.deg2rad(data)
# Calculate circular mean and std
c_mean = np.rad2deg(circmean(rad_data, low=np.deg2rad(low), high=np.deg2rad(high)))
c_std = np.rad2deg(circstd(rad_data, low=np.deg2rad(low), high=np.deg2rad(high)))
# Create a dictionary compatible with Matplotlib's bxp
stats = [{
"label": "Circular Data",
"mean": c_mean,
"med": c_mean, # Circular mean is often used as the center
"q1": c_mean - c_std,
"q3": c_mean + c_std,
"whislo": c_mean - 2*c_std,
"whishi": c_mean + 2*c_std,
"fliers": []
}]
return stats
data = np.random.normal(0, 10, 100) % 360
stats = circular_boxplot_stats(data)
fig, ax = plt.subplots()
ax.bxp(stats)
plt.show()

Solution 3: The Polar Boxplot (Visual Hack)

Section titled “Solution 3: The Polar Boxplot (Visual Hack)”

Instead of forcing circular data onto a linear axis, project the boxplot onto a polar coordinate system.

import numpy as np
import matplotlib.pyplot as plt
# Data centered around 0 (360)
data = np.deg2rad(np.random.normal(0, 15, 100) % 360)
fig = plt.figure()
ax = fig.add_subplot(111, projection='polar')
# We calculate the stats linearly but plot them on the polar axis
# To avoid the wrap break, we ensure the median is the center of the plot
ax.set_theta_zero_location('N')
ax.set_theta_direction(-1)
# Use a boxplot-like visualization with barh on polar
median = np.median(data)
q1, q3 = np.percentile(data, [25, 75])
ax.barh(1, q3-q1, left=q1, height=0.2, color='skyblue', alpha=0.6)
ax.vlines(median, 0.8, 1.2, colors='red', lw=2) # The "Median" line
plt.show()
  1. Use Circular Libraries: For production-grade analysis, avoid rolling your own logic. Use Astropy (specifically astropy.stats.circstats) or PyCircular.
  2. Define the Modulus: Always explicitly define your period (e.g., $2\pi$ or $360$) in a constant variable to avoid “magic number” bugs in your modulus logic.
  3. Kernel Density Estimates (KDE): For wrapped data, a circular KDE (Von Mises distribution) is often more informative than a boxplot.
  4. Data Pre-processing: If you must use plt.boxplot, apply a shift: $x_{shifted} = (x + \text{offset}) \pmod P$ where the offset moves the expected cluster center away from the wrap boundary.
  5. Environment Check: Ensure your Numpy and Scipy versions are up to date, as circular statistical functions have seen performance improvements in recent releases.