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:

  1. Convert the angular data to radians.
  2. Calculate the circular mean to find the true center of the distribution.
  3. Shift the data so that the circular mean is centered at 0. This temporarily removes the boundary wrapping issue.
  4. Compute the standard linear percentiles (Q1, Median, Q3) on this shifted data.
  5. Shift the computed percentiles back to the original angular space.
  6. 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 stats

Plotting 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.