[에러 해결] Matplotlib 랩어라운드 각도 데이터 박스플롯 문제 원인과 해결 방법

안녕하세요, 개발자 여러분! 데이터 시각화는 분석의 핵심이자 결과 전달의 중요한 수단입니다. 특히 Matplotlib는 파이썬 생태계에서 가장 널리 사용되는 시각화 라이브러리 중 하나죠. 하지만 특정 종류의 데이터를 다룰 때 예상치 못한 문제가 발생하기도 합니다. 오늘 다룰 주제는 바로 ‘랩어라운드(Wrap-around)’ 특성을 가진 각도 데이터를 Matplotlib 박스플롯(Boxplot)으로 시각화할 때 발생하는 문제와 그 해결 방법입니다.

각도, 방위, 위상 등 원형(Circular) 데이터는 ±180° 또는 0°/360°과 같이 특정 지점에서 ‘경계(boundary)’를 넘어가면 값이 다시 시작되는 랩어라운드 특성을 가집니다. 예를 들어, 170°와 -170°는 선형적으로는 340° 차이가 나지만, 원형적으로는 20° (180° 경계를 넘어가는 최단 경로)밖에 차이가 나지 않습니다. Matplotlib의 기본 박스플롯은 이러한 특성을 인지하지 못해 데이터의 분포를 오해하게 만들 수 있습니다. 지금부터 이 문제의 원인을 파헤치고, 효과적인 해결책을 제시해 드리겠습니다.

1. 에러 발생 상황

이 문제는 주로 각도 추정치와 같이 ±180° 또는 0°/360° 경계를 넘어가는 값을 박스플롯으로 시각화할 때 나타납니다. Matplotlib의 boxplot 메서드는 입력된 각도 값을 선형적인 숫자로만 해석하여 통계량(사분위수, 중앙값 등)을 계산합니다. 이로 인해 다음과 같은 현상이 발생합니다.

  • 극단적으로 긴 수염(Whiskers): 특정 데이터 클러스터가 ±180° 경계를 가로지를 때, Matplotlib는 이들을 선형적으로 멀리 떨어진 값으로 인식합니다. 예를 들어, 150°, 170°, -170°와 같은 값들이 함께 있을 경우, boxplot은 -170°와 170°를 멀리 떨어진 값으로 보고, 중앙값과 사분위수를 잘못 계산하여 수염이 비정상적으로 길게 늘어납니다.
  • 잘못된 분포 표현: 실제로는 ±180° 경계를 중심으로 조밀하게 모여 있는 데이터가 박스플롯에서는 전체 데이터 범위에 걸쳐 넓게 퍼져있는 것처럼 보일 수 있습니다. 이는 데이터의 실제 분포를 완전히 오해하게 만듭니다.

첨부된 이미지(원본 Stack Overflow 게시물 참조)에서 볼 수 있듯이, 90° 주변의 데이터는 정상적인 박스플롯을 보여주지만, 150° 주변의 데이터는 경계를 넘나드는 값들 때문에 수염이 화면 전체를 가로지를 정도로 길게 나타나는 것을 확인할 수 있습니다. 이는 박스플롯이 통계적 분포를 나타내는 본연의 목적을 상실하게 만듭니다.

2. 명확한 발생 원인

문제의 핵심은 Matplotlib의 boxplot선형 통계량에 기반한다는 점입니다. 박스플롯은 데이터를 오름차순으로 정렬한 후, 다음 통계량을 계산합니다.

  • 중앙값(Median): 데이터의 50번째 백분위수.
  • Q1 (1사분위수): 데이터의 25번째 백분위수.
  • Q3 (3사분위수): 데이터의 75번째 백분위수.
  • IQR (사분위 범위): Q3 – Q1.
  • 수염(Whiskers): 보통 Q1 – 1.5*IQR부터 Q3 + 1.5*IQR 범위 내의 데이터를 표시합니다.

원형 데이터, 특히 ±180°와 같은 경계를 넘나드는 경우, 이러한 선형적인 계산 방식은 치명적인 오류를 만듭니다.


# 예시: 180도 경계를 넘나드는 각도 데이터
angles = np.array([170, 175, 178, -179, -175, -170]) 

# 선형적으로 정렬:
# [-179, -175, -170, 170, 175, 178]

# 이 데이터를 기준으로 사분위수와 IQR을 계산하면
# 중앙값은 대략 0에 가깝게 나오고, Q1과 Q3는 -170대와 170대 초반에 위치하여
# IQR이 매우 커지게 됩니다. 이는 실제 데이터가 180도 부근에 조밀하게 모여 있다는 사실을
# 반영하지 못합니다.

즉, Matplotlib는 ±180° 사이의 ‘최단 경로’를 인식하지 못하고, 단순히 가장 작은 값부터 가장 큰 값까지의 선형적인 거리를 기준으로 통계량을 계산하기 때문에 문제가 발생합니다.

3. 해결 방법 및 코드 예시

이 문제를 해결하기 위한 ‘적절한(proper)’ 방법은 원형 통계량(Circular Statistics)의 개념을 도입하여 데이터를 전처리하는 것입니다. Matplotlib의 boxplot은 선형 데이터용이므로, 원형 데이터를 boxplot이 올바르게 처리할 수 있는 선형 형태로 ‘선형화(linearize)’하는 과정이 필요합니다.

3.1. 원형 데이터 선형화를 위한 헬퍼 함수 정의

데이터를 boxplot에 적용하기 전에, 원형 데이터의 특성을 반영하여 통계량을 올바르게 계산할 수 있도록 데이터를 변환하는 함수를 정의합니다. 핵심은 원형 중앙값(Circular Median)을 기준으로 데이터를 ‘풀어내는(unwrap)’ 것입니다.


import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import circmean # 원형 평균 계산 (원형 중앙값 대신 사용될 수 있음)

# 각도를 [-180, 180] 범위로 정규화하는 헬퍼 함수
def wrap_angle(angle):
    """
    각도를 -180도에서 180도 사이의 범위로 정규화합니다.
    """
    return (angle + 180) % 360 - 180

# 원형 중앙값을 계산하는 함수 (scipy.stats.circmean 활용)
def calculate_circular_median(angles_deg):
    """
    주어진 각도 데이터의 원형 중앙값을 계산합니다.
    scipy.stats.circmean은 사실 원형 평균을 계산하지만,
    박스플롯의 기준점으로 사용하기에 적합하며 구현이 간단합니다.
    (진정한 원형 중앙값은 계산이 더 복잡할 수 있습니다.)
    """
    if not angles_deg.size:
        return np.nan
    # 각도를 라디안으로 변환
    angles_rad = np.deg2rad(angles_deg)
    # circmean은 라디안 단위의 low/high 경계를 사용합니다.
    median_rad = circmean(angles_rad, low=-np.pi, high=np.pi)
    # 다시 도로 변환하여 반환
    return np.rad2deg(median_rad)

# 박스플롯을 위해 각도 데이터를 선형화하는 함수
def linearize_angles_for_boxplot(angles_deg):
    """
    원형 각도 데이터를 원형 중앙값을 기준으로 선형화하여,
    boxplot이 올바른 통계량을 계산하도록 돕습니다.
    """
    if not angles_deg.size:
        return np.array([])
        
    # 원형 중앙값을 계산하여 데이터의 기준점으로 삼습니다.
    central_angle = calculate_circular_median(angles_deg)
    
    # 각 데이터 포인트와 중앙값 사이의 각도 차이를 계산합니다.
    # 이 과정에서 wrap_angle 함수를 사용하여 최단 경로를 고려합니다.
    # 결과적으로 중앙값을 0으로 간주하는 선형적인 범위의 값들을 얻습니다.
    linearized_angles = wrap_angle(angles_deg - central_angle)
    
    # 원래의 중앙값을 다시 더해줌으로써, 데이터의 상대적인 분포는 유지하되
    # y축의 값은 원래의 각도 범위로 해석할 수 있도록 합니다.
    # 이렇게 하면 박스플롯의 통계량은 올바르게 계산되면서도,
    # 시각화된 y축 레이블은 직관적으로 이해할 수 있습니다.
    return linearized_angles + central_angle

3.2. 해결 방법 적용 및 시각화 예시

이제 위에서 정의한 헬퍼 함수들을 사용하여 데이터를 전처리한 후 Matplotlib의 boxplot에 적용합니다.


# 샘플 데이터 생성 (문제 상황과 유사하게 180도 경계를 넘나드는 데이터 포함)
np.random.seed(42) # 재현성을 위한 시드 설정

# 일반적인 분포 데이터
data_normal = np.random.normal(loc=90, scale=10, size=50)

# 180도 경계 부근에 걸쳐 있는 문제성 데이터
# 170도 주변과 -170도 주변에 분포
data_problematic_cluster = np.concatenate([
    np.random.normal(loc=170, scale=5, size=25), 
    np.random.normal(loc=-170, scale=5, size=25)
])

# 모든 데이터를 -180 ~ 180도로 정규화
data_normal = wrap_angle(data_normal)
data_problematic_cluster = wrap_angle(data_problematic_cluster)

# 원본 데이터와 선형화된 데이터 준비
all_data_raw = [data_normal, data_problematic_cluster]
all_data_linearized = [linearize_angles_for_boxplot(d) for d in all_data_raw]

# 시각화
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# 1. 원본 데이터로 박스플롯 그리기 (문제점 확인)
axes[0].boxplot(all_data_raw)
axes[0].set_title('원본 Matplotlib Boxplot (문제 발생)', fontsize=14)
axes[0].set_ylabel('각도 (도)', fontsize=12)
axes[0].set_xlabel('데이터셋', fontsize=12)
axes[0].set_xticks([1, 2], ['일반 분포', '경계 클러스터'])
axes[0].set_ylim(-180, 180)
axes[0].set_yticks(np.arange(-180, 181, 60))
axes[0].grid(True, linestyle='--', alpha=0.6)

# 2. 선형화된 데이터로 박스플롯 그리기 (해결된 모습 확인)
axes[1].boxplot(all_data_linearized)
axes[1].set_title('선형화된 데이터 박스플롯 (해결됨)', fontsize=14)
axes[1].set_ylabel('각도 (도)', fontsize=12)
axes[1].set_xlabel('데이터셋', fontsize=12)
axes[1].set_xticks([1, 2], ['일반 분포', '경계 클러스터'])
axes[1].set_ylim(-180, 180)
axes[1].set_yticks(np.arange(-180, 181, 60))
axes[1].grid(True, linestyle='--', alpha=0.6)

plt.tight_layout()
plt.show()

위 코드를 실행하면, 첫 번째 그래프에서는 ‘경계 클러스터’ 데이터의 박스플롯 수염이 비정상적으로 길게 늘어나는 것을 볼 수 있습니다. 반면, 두 번째 그래프에서는 linearize_angles_for_boxplot 함수를 통해 전처리된 데이터를 사용하여, 180° 경계를 넘나드는 클러스터임에도 불구하고 실제 분포를 정확하게 반영하는 박스플롯이 그려진 것을 확인할 수 있습니다. 수염이 적절한 길이를 유지하며, 중앙값과 사분위수가 데이터의 원형 특성을 고려하여 계산됩니다.

이 방법은 데이터의 원형 특성을 이해하고 통계량을 계산한 뒤, Matplotlib가 이를 선형적으로 시각화할 수 있도록 데이터를 조정하는 ‘적절한’ 해결책입니다.

4. 향후 예방을 위한 팁

이와 같은 문제가 재발하는 것을 방지하고, 원형 데이터를 더욱 효과적으로 다루기 위한 몇 가지 팁입니다.

  • 데이터 특성 이해: 항상 데이터가 어떤 특성을 가지고 있는지(선형, 원형, 범주형 등) 명확하게 이해하는 것이 중요합니다. 특히 각도, 시간(하루 주기), 나침반 방위와 같은 원형 데이터는 선형 데이터와 다른 접근 방식이 필요합니다.
  • 원형 통계 라이브러리 활용: pycircstat, astropy.stats(천문학 데이터에 특화)와 같이 원형 통계량 계산을 위한 전용 라이브러리들이 있습니다. 이러한 라이브러리는 원형 평균, 원형 분산, 원형 중앙값 등을 계산하는 데 필요한 복잡한 수학적 배경을 추상화하여 제공하므로, 보다 견고한 분석에 도움이 될 수 있습니다.
  • 시각화 방법의 다양화: 박스플롯은 선형 데이터에 매우 강력하지만, 원형 데이터에는 적합하지 않을 수 있습니다. 원형 데이터 시각화를 위해 다음과 같은 대안을 고려해 보세요.
    • 극좌표 히스토그램 (Circular Histogram): 각도 분포를 원형으로 직접 표시하여 직관적인 이해를 돕습니다.
    • 밀도 플롯 (Density Plot on Polar Coordinates): 극좌표계에서 데이터 밀도를 부드러운 곡선으로 나타내어 분포의 형태를 시각화합니다.
    • 방위 장미(Rose Diagram): 특정 방향의 빈도나 강도를 막대 형태로 표현합니다.
  • 데이터 전처리 습관화: Matplotlib와 같은 일반적인 시각화 도구를 사용할 때는 데이터의 특성을 고려한 전처리 과정을 항상 염두에 두어야 합니다. 특히 랩어라운드 현상이 나타나는 데이터는 boxplot에 바로 적용하기보다, 오늘 제시된 선형화 방법과 같이 적절한 변환을 거치는 것을 습관화하는 것이 좋습니다.

원형 데이터는 그 특성상 섬세한 접근이 필요하지만, 올바른 방법을 사용하면 복잡한 데이터 분포도 정확하고 효과적으로 시각화할 수 있습니다. 이 글이 Matplotlib로 각도 데이터를 다루는 개발자분들에게 유용한 해결책이 되기를 바랍니다!

댓글 남기기