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

[Matplotlib] 하나의 좌표 축(Axes) 안에 여러 개 히트맵(Multiple Heatmaps) 그리기 (feat. PatchCollection, Rectangle)

by 부자 꽁냥이 2022. 10. 23.

안녕하세요~ 꽁냥이에요. 오늘은 하나의 좌표축(Axes) 안에 여러 히트맵(Multiple Heatmaps)을 그리는 방법에 대해서 소개하려고 합니다.

 

보통 히트맵은 한 좌표 축(Axes) 안에 그려지게 됩니다. 즉, 일반적인 방법으로는 한 좌표 축(Axes) 안에 그릴 수 없는데요. 하지만 히트맵의 구성 요소 하나하나는 사각형이고 히트맵은 이들의 집합이라는 점을 이해한다면 사각형 하나는 Rectangle로 히트맵은 이들의 집합인 PatchCollection으로 감싸준다면 하나의 axes 안에 여러 히트맵을 그릴 수 있습니다.

 

개념을 대략적으로 설명했는데요. 그 개념을 좀 더 이해하기 쉽게 구체적으로 이야기하고  여러 히트맵(Multiple Heatmaps)을 그리는 방법에 대해 소개하겠습니다.

 

기본적인 히트맵을 그리는 방법은 아래 포스팅을 참고해주세요.

[히트 맵(Heat Map)] 1. 히트 맵 그리기 - 기본

[히트 맵(Heat Map)] 2. 히트 맵 꾸미기


   하나의 좌표 축(Axes) 안에 여러개 히트맵(Multiple Heatmaps) 그리기

1) 개념 파헤치기

여기서 좌표 축(Axes)이라는 것은 x축(x-axis)과 y축(y-axis)를 포함하는 단어입니다. 꽁냥이가 아까 말씀드렸죠? 일반적으로 하나의 좌표 축 안에는 하나의 히트맵만 그릴 수 있다고요. 아래와 같이 말이죠.

 

하나의 Axes에는 하나의 히트맵 밖에 못그린다.

그렇다면 여러 히트맵을

하나의 좌표 축에 그리는 방법이 없나요?

 

물론 있습니다. 일반적으로는 못그리지만 방법은 있습니다. 원리를 이해하기 위하여 아래 그림을 살펴보겠습니다. 먼저 히트맵 구성요소는 사각형(직사각형) 임을 알 수 있고요. 히트맵은 이러한 사각형의 모임(집합)으로 이루어져 있음을 알 수 있습니다. 이러한 집합을 원하는 개수만큼 만들어 한 좌표 축(Axes)에 삽입하면 되는 것이지요.

 

여러 히트맵 그리는 원리

Matplotlib에서는 사각형을 Rectangle 클래스로 그릴 수 있으며 여러 사각형은 PatchCollection 클래스로 하나의 집합으로 모을 수 있습니다. 그리고 이러한 집합을 원하는 개수 만큼 삽입하면 되는 것입니다.

이것이 이번 포스팅의 핵심입니다.


2) 아이디어

꽁냥이의 아이디어는 다음과 같습니다.

 

먼저 전체적인 구조를 잡아줍니다. 첫 번째로 그리고 싶은 히트맵 개수를 정하고요. 여기서는 3개라고 해볼게요. 그런 다음 x축을 기준으로 영역을 3 등분해줍니다(아래 그림에서 파란 실선). 각 영역 안에는 하나의 히트맵이 그려지게 됩니다. 다음으로 모든 히트맵의 폭과 높이를 같게 설정하고 히트맵이 그려지는 위치를 설정합니다(아래 그림에서 빨간점). 이 위치를 기준으로 왼쪽으로 폭만큼 위쪽으로 높이 만큼에 해당하는 영역이 맵이 들어갈 공간이 됩니다. 마지막으로 너무 다닥다닥 붙지 않게 약간의 여백을 주도록 합니다(패딩).

 

 

이제 각 공간 안에 히트맵을 넣어야 합니다. 히트맵은 다음과 같이 그려집니다. 먼저 열 개수와 행 개수를 지정합니다. 그에 따라 사각형 하나의 폭과 높이가 결정됩니다. 다음으로 아래 그림에서 빨간 점을 기준으로 사각형이 오른쪽으로 추가됩니다. 만약 최대 열에 도달하면 다시 한 칸 위로 올라간 다음 맨 왼쪽에서 오른쪽 방향으로 추가되지요.

 

이때 추가함과 동시에 각 사각형 값에 대응하는 색상을 부여합니다.


3) 구현

자 이제 아이디어를 알았으니 파이썬으로 구현해보겠습니다. 먼저 히트맵을 그릴 데이터를 생성했어요. 꽁냥이는 4개를 그리고 싶어서 4개의 데이터를 만들었습니다. 이때 결측값도 섞어줌으로써 해당 영역에는 결측을 의미하는 색상을 넣고자 했습니다.

 

import matplotlib.pyplot as plt
import numpy as np

from matplotlib.patches import Rectangle
from matplotlib.collections import PatchCollection

## 데이터 생성
np.random.seed(100)
size = 180
data1 = np.random.rand(size)
data2 = np.random.rand(size)+0.5
data3 = np.random.rand(size)+1
data4 = np.random.rand(size)+0.2
temp_data = [data1, data2, data3, data4]
data = []
for i in range(4):
    na_idx = np.random.choice(range(size), 20, replace=False)
    temp_data[i][na_idx] = np.nan
    data.append(temp_data[i])
    
## 데이터를 하나로 모음 -> y축을 통일하기 위해서
total_data = np.concatenate(data)

 

그리고 부가적인 함수를 정의했습니다. 먼저 주어진 컬러맵에 대응하는 보색의 RGB 값을 반환하는 함수와 컬러맵을 생성하는 함수를 정의했습니다. 컬러맵을 생성하는 과정은 여기에 포스팅 해두었으니 참고해주세요. 보색 같은 경우는 결측을 의미하는 색상으로 사용하려고 합니다. 반대되는 색상인 보색을 사용해야 결측이 더 눈에 띌 것 같아서요.

 

## 컬러맵의 가장 진한색상에 보색을 RGB로 바꾸는 함수
def get_complementary_color(vmin, vmax, cmap, alpha=1):
    def complement(r, g, b):
        def hilo(a, b, c):
            if c<b: b, c = c, b
            if b<a: a, b = b, a
            if c<b: b, c = c, b
            return a+c
        k = hilo(r, g, b)
        return tuple(k-u for u in (r, g, b))
    
    import matplotlib.cm as cm
    import matplotlib.colors as mcolors
    
    norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
    scale_map = cm.ScalarMappable(norm=norm, cmap=cmap)
    r, g, b = (int(256*x) for x in scale_map.to_rgba(vmax)[:3])
    
    complementary_color = np.array(complement(r, g, b))/256
    return np.insert(complementary_color, 3, alpha)

## 컬러맵 생성함수
def colormap(cmap, vmin, vmax):
    import matplotlib.cm as cm
    import matplotlib.colors as mcolors
    norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
    colormapping = cm.ScalarMappable(norm=norm, cmap=cmap)
    return colormapping

 

이제 이번 포스팅의 주인공이라 할 수 있는 individual_map 함수를 살펴보겠습니다. 여기에서는 Rectangle, PatchCollection 사용법을 위주로 설명하겠습니다. 나머지는 주석을 참고해주세요.

 

def individual_map(data, width, height, left_offset, bottom_offset, ax, max_row, max_col, 
                   colormapping, vmax, na_color='gray'):
    ## 사각형 폭과 높이
    small_width = width/max_col
    small_height = height/max_row
    
    rectangles = [] ## 사각형 담는 리스트
    for i in range(max_col*max_row):
        font_color='k' ## 폰트 색상
        ## 사각형의 좌측 하단 꼭지점
        left = left_offset+(i%max_col)*small_width
        bottom = bottom_offset+(i//max_col)*small_height
        ## 결측인 경우와 아닌 경우 배경색과 텍스트 설정
        if np.isnan(data[i]):
            facecolor=na_color
            text=''
        else:
            val = data[i]
            facecolor = colormapping.to_rgba(val)
            text = f'{val:.1f}'
            if val > 0.8*vmax: ## 찐한 부분에서 색상을 하얀색으로 바꿈
                font_color='white'
                
        rectangles.append(Rectangle((left, bottom), small_width, small_height, facecolor=facecolor))
        
        ## 텍스트 추가
        cx, cy = left+0.5*small_width, bottom+0.5*small_height
        ax.annotate(text, (cx, cy), ha='center', va='center', color=font_color)
        
    ## Rectangle을 하나의 집합으로 취급  
    pc = PatchCollection(rectangles, match_original=True)
    ax.add_collection(pc) ## PatchCollection axes에 추가 : 실질적으로 여기에서 그림에 추가된다.

 

line 54

Rectangle 클래스의 사용법은 기본적으로 사각형의 좌측 하단의 x, y좌표를 튜플 형식으로 첫 번째 인자에 전달합니다. 다음으로 폭과 높이를 각각 두 번째, 세 번째 인자에 전달하면 됩니다. kwargs 인자를 이용하여 사각형을 꾸미는 인자를 추가할 수도 있는데 여기서는 사각형의 배경색인 facecolor 인자를 사용했습니다. 이외에도 여러 인자들이 있는데 자세한 내용은 여기를 참고하시면 됩니다.

 

Rectangle( (좌측 하단의 x좌표, 좌측 하단의 y좌표), 폭, 높이, **kwargs )

 

이제 각 Rectangle 객체들을 리스트에 담아줍니다.

 

line 61~62

PatchCollections은 Rectangle이 담긴 리스트를 넘겨주면 됩니다. 이때 각 Rectangle이 갖고 있는 인자들을 적용하기 위해 match_original을 True로 설정했습니다(line 61). 이제 이렇게 만들어진 PatchCollections를 axes에 추가해줘야 실질적으로 히트맵이 그려지게 되는 것입니다(line 62).

 

이제 함수는 정의됐으니 히트맵 여러 개를 그려봅시다. 꽁냥이는 히트맵의 중심 y좌표를 해당 데이터 평균에 오도록 설정했어요.

 

fig = plt.figure(figsize=(20, 10))
fig.set_facecolor('white')
ax = fig.add_subplot()

num_heat_map = len(data) ## 히트맵 개수
xlim = (0, num_heat_map) ## x축 범위
ylim = (np.nanmin(total_data), np.nanmax(total_data)) ## y축 범위

cmap = plt.cm.get_cmap('Reds') ## 컬러맵
colormapping = colormap(cmap, vmin=ylim[0], vmax=ylim[1]) ## 컬러맵 함수 생성
complementary_color = get_complementary_color(vmin=ylim[0], vmax=ylim[1], cmap=cmap)

padding = 0.1 ## 여백 비율
width_space = (xlim[1]-xlim[0])/num_heat_map ## 4등분 영역의 폭
width = width_space*(1-2*padding) ## 히트맵 전체 폭
padding_space = width_space*padding ## 여백
height = 0.5*(ylim[1]-ylim[0]) ## 히트맵 전체 높이

## 초기 x, y축 범위
ax_xmin = xlim[0]
ax_xmax = xlim[1]
ax_ymin = ylim[0]
ax_ymax = ylim[1]

xticks = [] ## x축 눈금
xticklabels = [] ## x축 눈금 라벨
for nhm in range(num_heat_map):
    ind_data = data[nhm]
    data_mean = np.nanmean(ind_data)
    left_offset = xlim[0]+padding_space+(width+2*padding_space)*nhm
    bottom_offset = data_mean-0.5*height
    
    ## x축, y축 범위 재설정
    if xlim[0] + width*(nhm+1) > ax_xmax: 
        ax_xmax = xlim[0] + width*(nhm+1)
    if data_mean+0.5*height > ax_ymax:
        ax_ymax = data_mean+0.5*height
    if data_mean-0.5*height < ax_ymin:
        ax_ymin = data_mean-0.5*height
        
#     print(left_offset, bottom_offset)
    individual_map(ind_data, width=width, height=height, left_offset=left_offset, 
                   bottom_offset=bottom_offset, ax=ax, max_row=18, max_col=10, 
                   colormapping=colormapping, vmax=ylim[1], na_color=complementary_color
                  )
        
    x_center = left_offset+0.5*width
    xticks.append(x_center)
    xticklabels.append(f'{nhm+1}월')
    
    if nhm > 0:
        ax.axvline(left_offset-padding_space, color='gray', linestyle='--')
    
## 바닥과 천장에 달라 붙지 않도록 여백 조절
new_ax_ymin = ax_ymin-0.025*(ax_ymax-ax_ymin)/0.95
new_ax_ymax = ax_ymax+0.025*(ax_ymax-ax_ymin)/0.95

## x y축 범위 설정
ax.set_xlim((ax_xmin, ax_xmax))
ax.set_ylim((new_ax_ymin, new_ax_ymax))

## x축 눈금과 라벨 설정
ax.set_xticks(xticks) 
ax.set_xticklabels(xticklabels)
cbar = fig.colorbar(colormapping, ax=ax) ## 컬러바 추가
plt.show()

 


이번 포스팅에서는 실제로 데이터 분석시 필요했던 부분을 공유했습니다. 이게 특별히 자주 쓰일 것 같지는 않지만 그래도 맵의 변화를 다양한 각도로 보고 싶을 때에는 쓰일 수 있습니다. 부디 이번 포스팅이 많은 분들께 도움이 되시길 바라며 이상 포스팅 마치겠습니다.

 

다음에도 좋은 주제로 찾아뵐 것을 약속드리며 이상 포스팅 마치겠습니다. 안녕히 계세요~

 


댓글


맨 위로