본문 바로가기
데이터 분석/시각화

[도넛 차트(Donut chart)] 2. Matplotlib을 이용하여 하위 그룹을 포함하는 도넛 차트(Nested donut chart) 그리기

by 부자 꽁냥이 2020. 9. 12.

안녕하세요~ 꽁냥이에요!!

 

데이터를 다루다 보면 상위 그룹과 하위 그룹으로 이루어진 데이터 구조를 자주 볼 수 있어요. 이러한 구조를 갖는 데이터를 시각화할 때 그룹 바 차트(Grouped bar chart) 혹은 스택 바 차트(Stacked bar chart)로 그릴 수 있는데요. 도넛 차트를 이용해서 표현할 수도 있어요.

 

이번 포스팅에서는 Matplotlib을 이용하여 하위 그룹을 포함하는 도넛 차트(Nested donut chart)를 그리는 방법에 대해서 알아보겠습니다.

 

그룹 바 차트와 스택 바 차트를 그리는 방법이 궁금하다면 아래 포스팅을 참고하세요.

 

[바 차트(Bar chart)] 4. Matplotlib을 이용하여 그룹 바 차트(Grouped bar chart) 그리기

[바 차트(Bar chart)] 5. Matplotlib을 이용하여 스택 바 차트(Stacked bar chart) 그리기


  데이터 준비

이번 포스팅에서 사용할 데이터를 만들어 줍니다. 아래 코드를 실행해주세요.

 

import pandas as pd
import numpy as np
## 데이터 준비
df = pd.DataFrame()
df['득표수'] = [10,20,15,13,22,44,11,22,33,16,39,22]
df['지역'] = ['서울','서울','서울','경기','경기','경기','인천','인천','인천','인천','인천','제주']
df['연예인'] = ['강호동','유재석','이상민','이수근','하하','탁재훈','아이린','다현','한예슬','김사랑','강민경','수지']

  전략

꽁냥이의 전략은 다음과 같아요.


1. 빈도수, 상위 그룹, 하위 그룹 칼럼을 정한다.

2. 각 상위 그룹에 대한 하위 그룹의 색상을 지정한다.


1. 빈도수, 상위 그룹, 하위 그룹 칼럼을 정한다.

 

먼저 그래프에 표시할 빈도수(또는 통계치)에 해당하는 칼럼을 지정하고요. 상위 그룹과 하위 그룹을 나타내는 칼럼을 지정해야 해요. 상위 그룹은 바깥쪽에 하위 그룹은 안쪽에 그릴 거예요.

위 데이터에서 빈도수, 상위 그룹, 하위 그룹에 대응하는 칼럼은 각각 '득표수', '지역', '연예인'입니다.

 

2. 각 상위 그룹에 대한 하위 그룹의 색상을 지정한다.

 

이 부분이 가장 핵심이에요. 먼저 아래 그림을 살펴볼게요.

 

 

하위 그룹(안쪽 도넛)의 색상은 상위 그룹(바깥쪽 도넛)의 색상과 비슷하지만 하위 그룹의 개수만큼 구분이 가능하도록 해야 합니다.

 

이를 위하여 꽁냥이는 hsv 색상 체계를 이용할 거예요. hsv에 대한 설명은 위키백과를 참고하세요.

 

우선 바깥쪽 도넛 조각의 hsv 값을 가져온다음, 안쪽 도넛 조각의 색상(Hue) 값을 바깥쪽 색상 값과 동일한 값으로 설정합니다. 그리고 하위 그룹의 색상을 구분하기 위하여 채도(Saturation)의 값을 이용합니다. 채도 값은 0과 바깥쪽 도넛 조각의 채도 사이에서 하위 그룹 개수만큼의 값을 가져올 거예요. 마지막으로 안쪽 도넛의 명도 값은 1로 고정합니다. 명도 값을 1로 잡아줘야 하위 그룹의 색상 구분이 잘되기 때문이에요. 

 

이해를 돕기 위하여 아래 그림을 살펴볼게요.

 

 

 

 

위 그림과 같이 상위 그룹의 색상, 채도, 명도(hsv)값을 각각 0.9, 0.8, 0.7이라고 해볼게요. 그러면 하위 그룹 3개에 대한 색상 값은 모두 0.9로 고정합니다. 그리고 채도 값은 0과 0.8 사이에서 하위 그룹의 개수인 3개의 값을 꺼내와야 하는데요. 이는 0과 0.8 사이의 4(=하위 그룹 개수+1)등분점, 즉 0.2, 0.4, 0.6을 가져옵니다. 그리고 하위 그룹 3개에 대하여 명도 값은 1로 고정합니다.


  구현

이제 코드를 설명할 시간이에요. 아래 코드를 살펴보겠습니다.

 

import matplotlib.pyplot as plt
import matplotlib.colors as mcl
import matplotlib.patches as mpt
import seaborn as sns

freq_col = '득표수' ## 빈도수 칼럼
outer_col = '지역' ## 상위 그룹 칼럼
inner_col = '연예인' ## 하위 그룹 칼럼

size = 0.3 ## 바깥쪽, 안쪽 도넛 조각 조각의 반지름 비율을 0.3으로 한다.
threshold = 4 ## 상한선 백분율

color = sns.color_palette('hls',len(df[outer_col].unique())) ## 바깥쪽 도넛의 색상설정

summary = df.groupby(outer_col)[freq_col].sum().reset_index() ## 지역별로 득표수를 집계한다.
outer_data = summary[freq_col] ## 바깥쪽 도넛에 해당하는 데이터
inner_data = [] ## 안쪽 도넛에 대응하는 데이터
for s in summary[outer_col]:
    inner_data += list(df.query('{0}==@s'.format(outer_col))[freq_col])

fig = plt.figure(figsize=(8,8)) ## 캔버스 생성
fig.set_facecolor('white') ## 캔버스 배경색을 하얀색으로 설정
ax = fig.add_subplot() ## 프레임 생성

## 바깥쪽 도넛 조각 차트 출력
out_pie = ax.pie(outer_data,
             radius=1,
             colors=color,
             wedgeprops=dict(width=size,edgecolor='w'))

## 바깥쪽 도넛 백분율 텍스트 출력
total = np.sum(outer_data) ## 바깥쪽 빈도수의 총합

sum_pct = 0 ## 백분율 초기값

for i in range(len(outer_data)):
    ang1, ang2 = out_pie[0][i].theta1, out_pie[0][i].theta2 ## 각1, 각2
    out_r = out_pie[0][i].r ## 원의 반지름
    
    x = ((2*out_r-size)/2)*np.cos(np.pi/180*((ang1+ang2)/2)) ## 바깥쪽 도넛 조각의 중앙쪽 x좌표
    y = ((2*out_r-size)/2)*np.sin(np.pi/180*((ang1+ang2)/2)) ## 바깥쪽 도넛 조각의 중앙쪽 y좌표
    
    if i < len(outer_data) - 1:
        sum_pct += float(f'{outer_data[i]/total*100:.2f}') ## 백분율을 누적한다.
        ax.text(x,y,f'{outer_data[i]/total*100:.2f}%',ha='center',va='center') ## 백분율 텍스트 표시
    else: ## 총합을 100으로 맞추기위해 마지막 백분율은 100에서 백분율 누적값을 빼준다.
        ax.text(x,y,f'{100-sum_pct:.2f}%',ha='center',va='center')

outer_color = [] ## 바깥쪽 도넛 조각의 색상을 hsv 컬러로 담을 리스트
for p in out_pie[0]:
    outer_color.append(p.get_facecolor()) ## 바깥쪽 도넛 조각을 rgb 컬러로 가져온다.
outer_color_hsv = [mcl.rgb_to_hsv(x[:3]) for x in outer_color] ## rgb를 hsv로 바꾼다.
outer_color_hsv = [(x[0],x[1],1) for x in outer_color_hsv] ## 색상 채도만 가져오고 명도는 1로 고정한다.

inner_color = [] ## 안쪽 도넛 조각의 색상을 담는 리스트
for i, g in enumerate(summary[outer_col]):
    num_sub_group = len(df.query('{0}==@g'.format(outer_col))) ## 하위 그룹 개수
    jump = outer_color_hsv[i][1]/(num_sub_group+1) ## 채도 등분점 간격
    temp_list = []
    temp_s = np.arange(0,outer_color_hsv[i][1],jump) 
    temp_s = temp_s[1:] ## 채도 등분점
    for t in temp_s:
        h = outer_color_hsv[i][0] ## 색상
        s = t ## 채도
        v = outer_color_hsv[i][2] ## 명도
        temp_list.append((h,s,v))
    inner_color += temp_list[::-1] ## 순서를 바꿈
    
inner_color = [mcl.hsv_to_rgb(x) for x in inner_color] #3 hsv를 다시 rgb로 바꾼다.

## 안쪽 도넛 차트 출력
inner_pie = ax.pie(inner_data,
       radius=1-size,
       colors=inner_color,
       wedgeprops=dict(width=size,edgecolor='w'))

## 안쪽 도넛 백분율 텍스트 출력
bbox_props = dict(boxstyle='square',fc='w',ec='w',alpha=0) ## annotation 박스 스타일
config = dict(arrowprops=dict(arrowstyle='-'),bbox=bbox_props,va='center')

inner_sum_pct = 0 ## 안쪽 도넛 백분율 초기값
for i in range(len(inner_data)):
    ang1, ang2 = inner_pie[0][i].theta1, inner_pie[0][i].theta2 ## 안쪽 각1, 안쪽 각2
    r = inner_pie[0][i].r ## 안쪽 도넛의 반지름
    
    x = ((2*r-size)/2)*np.cos(np.pi/180*((ang1+ang2)/2)) ## 안쪽 도넛 조각의 중앙쪽 x좌표
    y = ((2*r-size)/2)*np.sin(np.pi/180*((ang1+ang2)/2)) ## 안쪽 도넛 조각의 중앙쪽 y좌표
    
    if i < len(inner_data) - 1:
        inner_sum_pct += float(f'{inner_data[i]/total*100:.2f}') ## 백분율을 누적한다.
        text = f'{inner_data[i]/total*100:.2f}%' ## 백분율 텍스트 표시
    else: ## 총합을 100으로 맞추기위해 마지막 백분율은 100에서 백분율 누적값을 빼준다.
        text = f'{100-inner_sum_pct:.2f}%'
        
    ## 비율 상한선보다 작은 것들은 Annotation으로 만든다.
    if inner_data[i]/total*100 < threshold:
        ang = (ang1+ang2)/2 ## 중심각
        x = out_r*np.cos(np.deg2rad(ang)) ## Annotation의 끝점에 해당하는 x좌표
        y = out_r*np.sin(np.deg2rad(ang)) ## Annotation의 끝점에 해당하는 y좌표
        
        ## x좌표가 양수이면 즉 y축을 중심으로 오른쪽에 있으면 왼쪽 정렬
        ## x좌표가 음수이면 즉 y축을 중심으로 왼쪽에 있으면 오른쪽 정렬
        horizontalalignment = {-1: "right", 1: "left"}[int(np.sign(x))]
        connectionstyle = "angle,angleA=0,angleB={}".format(ang) ## 시작점과 끝점 연결 스타일
        config["arrowprops"].update({"connectionstyle": connectionstyle}) ## 
        ax.annotate(text, xy=((out_r-size)*x, (out_r-size)*y), xytext=(1.5*x, 1.2*y),
                    horizontalalignment=horizontalalignment, **config)
    else:
        x = ((2*r-size)/2)*np.cos(np.pi/180*((ang1+ang2)/2)) ## 텍스트 x좌표
        y = ((2*r-size)/2)*np.sin(np.pi/180*((ang1+ang2)/2)) ## 텍스트 y좌표
        ax.text(x,y,text,ha='center',va='center')

## 범례
## 범례는 2줄로 만든다. 왼쪽 줄에는 상위 그룹을 표시하고 오른쪽 줄에는 하위 그룹을 표시한다.
inner_pie_index = -1 ## 안쪽 도넛 차트의 데이터에 접근할 인덱스 초기값
right_legend_patches = [] ## 오른쪽 범례 칼럼에 들어가는 요소
left_legend_patches = [] ## 왼쪽 범례 칼럼에 들어가는 요소
right_labels = [] ## 오른쪽 범례 칼럼에 들어가는 라벨
left_labels = [] ## 왼쪽 범례 칼러에 들어가는 라벨
for i in range(len(outer_data)):
    left_legend_patches.append(out_pie[0][i])
    
    outer_label = summary[outer_col][i] ## 바깥쪽 도넛 차트 라벨

    left_labels.append(outer_label)
    temp_data = df.query('{0}==@outer_label'.format(outer_col)) ## 바깥쪽 라벨에 대응하는 안쪽 도넛 데이터
    temp_data = temp_data.reset_index(drop=True)
    
    temp_number = len(temp_data)-1
    
    ## 오른쪽 범례 개수와 맞추기 위해 빈 범례를 만듬
    for k in range(temp_number):
        rect = mpt.Rectangle((0,0),1,1.1,facecolor='None')
        left_legend_patches.append(rect)
        left_labels.append('')
    
    ## 오른쪽 범례 칼럼을 만든다.
    for j in range(len(temp_data)):
        inner_pie_index += 1
        
        right_legend_patches.append(inner_pie[0][inner_pie_index])
        right_labels.append(temp_data[inner_col][j])
        
    ## 범례 요소와 라벨을 합친다.
    legend_patches = left_legend_patches+right_legend_patches
    labels = left_labels + right_labels
        
## 범례 출력
plt.legend(legend_patches,
           labels,
           ncol=2,
           loc='upper right',
           handleheight=1, ## 범례 줄 맞춤
           labelspacing=0.5, ## 범례 줄 간격
           bbox_to_anchor=(1.2,1))

plt.show()

 

(코드는 핵심 부분만 설명하겠습니다. 여기서 설명하지 않는 부분은 주석을 참고하세요. 이해가 안되는 부분은 댓글로 남겨주세요~)

 

line 6~8

빈도수, 상위 그룹, 하위 그룹에 대응하는 칼럼 이름을 지정합니다.

 

line 26~47

바깥쪽 도넛 차트를 출력하고 백분율을 표시합니다. 이에 대한 설명은 여기를 참고하세요 .

 

line 49~53

바깥쪽 도넛의 hsv 색상 정보를 가져옵니다. 이때 get_facecolor메서드를 사용하여 바깥쪽 도넛의 rgb 색상 정보를 가져옵니다(line 51). 그리고 matplotlib.color 모듈에서 제공하는 rgb_to_hsv 메서드를 이용하여 rgb 색상을 hsv 색상으로 변환합니다(line 52). 그리고 안쪽 도넛에서는 명도를 1로 고정할 것이기 때문에 명도를 모두 1로 바꾸어 줍니다(line 53).

 

line 55~69

안쪽 도넛의 색상을 만들어줍니다. matplotlib의 그래프 요소는 rgb 색상으로 인식하기 때문에 hsv 색상을 rgb로 바꿔야 합니다(line 69).

 

line 71~111

안쪽 도넛 차트와 백분율을 표시합니다. 여기서 백분율이 4% 미만이면 텍스트를 바깥쪽으로 출력하도록 했습니다. 이에 대한 설명은 여기를 참고하세요.

 

위 코드를 실행해볼까요?

 

 

보시는 바와 같이 Nested 도넛 차트가 예쁘게 나온 것을 알 수 있어요. 혹시 한글이 깨져서 나오는 분들은 여기를 참고하셔서 한글 관련 설정을 해주시기 바랍니다.


이번 포스팅에서는 하위 그룹을 포함하는 도넛 차트를 그리는 방법에 대해서 알아보았습니다. 궁금한 점, 잘못된 점, 하고 싶은 말은 댓글로 남겨주세요. 

 

지금까지 꽁냥이의 글 읽어주셔서 감사합니다.

 


댓글


맨 위로