KDE + threshold

  • 밀도 함수는 데이터 분포를 볼 때 가장 많이 그리는 그림 중 하나입니다.
  • 특정 값을 기준으로 Pass와 Fail을 정한다고 할 때, 전체의 비율도 중요합니다.
  • seaborn kdeplot을 살짝 다듬어서 쪼개고 비율을 계산합니다.

1. 오늘의 목표

  • 오늘 우리는 데이터를 선별하는 데 쓰는, 이런 그림을 그릴 겁니다.
  • 특정 값을 기준으로 왼쪽은 Fail, 오른쪽은 Pass입니다.
  • 공장에서 발생하는 양품과 불량품으로 생각을 해도 좋고, 학생들 시험 결과의 분포로 봐도 좋습니다.
  • 중요한 것은 특정 지점을 기준으로 KDE plot을 자르고, 좌우를 다른 색으로 칠하는 것입니다.


2. 데이터 → 밀도 함수

  • numpy를 사용해서 정규분포에 가까운 데이터를 만듭니다.
  • np.random.normal()을 사용하면 뚝딱 만들어집니다.
  • loc, scale을 사용해서 평균과 표준편차를 지정하고, size에는 10만을 넣습니다.
  • 이렇게 얻은 결과를 seaborn.kdeplot()으로 밀도함수로 표현합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
%matplotlib inline

import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
sns.set_context("talk")

# 예제 데이터 생성
set0 = np.random.normal(loc=3, scale=1, size=100_000)

# 데이터 분포 밀도함수 시각화
fig, ax = plt.subplots(figsize=(5, 3), constrained_layout=True)
sns.kdeplot(set0, fill=True, ax=ax)


  • 매끈한 밀도함수가 얻어졌습니다.
  • 이토록 데이터 분포가 매끈해보이는 것은 seaborn.kdeplot()에 숨겨진 gridsize=200이라는 매개변수 덕분입니다.
  • 데이터가 쪼개지는 지점을 눈에 잘 띄게 하겠습니다. gridsize=20을 입력해서 같은 데이터를 거칠게 표현합니다.
1
2
fig, ax = plt.subplots(figsize=(5, 3), constrained_layout=True)
sns.kdeplot(set0, fill=True, gridsize=20, ax=ax)


3. 밀도 함수 절단

matplotlib.org matplotlib.path

  • 똑같은 데이터인데 전혀 매끈하지 않습니다.

  • 한 눈에 봐도 꼭지점과 선분으로 이루어진 다각형이라는 것을 알 수 있습니다.

  • $x = 2$

  • 꼭지점 중 $x > 2$만 남겨서 이것들로 다각형을 새로 만들면 되지 않을까요?

  • KDE plot을 구성하는 다각형은 ax.collections로 추출할 수 있습니다.

  • 이 중에서도 윤곽선은 .get_path()[0]명령으로 뽑아낼 수 있고,

  • 꼭지점은 여기에 .vertices, 꼭지점의 특성은 .codes 속성을 보면 됩니다.

  • 한번 추출해 봅니다.

1
2
3
4
5
6
7
# vertices
path = ax.collections[0].get_paths()[0]
vertices = path.vertices
codes = path.codes

print(f"# vertices = {vertices}")
print(f"# codes = {codes}")
  • 실행 결과
1
2
3
4
5
6
7
8
9
10
11
12
# vertices = [[-1.68144422e+00  4.44446302e-07]
[-1.68144422e+00 0.00000000e+00]
[-1.20843724e+00 0.00000000e+00]
[-7.35430261e-01 0.00000000e+00]
[-2.62423283e-01 0.00000000e+00]

(중략)

[-1.68144422e+00 4.44446302e-07]
[-1.68144422e+00 4.44446302e-07]]
# codes = [ 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 79]
  • vertices에는 수많은 점의 $(x, y)$ 좌표가 나열되어 있습니다.
  • 이 데이터를 기준으로 threshold를 적용하면 될 것 같습니다.
  • 해당 데이터의 index를 추출합니다.
1
2
3
# x > 2 인 꼭지점 추출
idx_th = np.where(vertices[:, 0] > 2)[0]
idx_th
  • 실행 결과
1
2
array([ 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
26, 27, 28, 29, 30, 31, 32, 33])
  • codes중요한 정보를 담고 있습니다.
  • 1은 시작점, 2는 연결점, 79는 polygon close입니다.
  • $ x > 2 $인 점들의 index를 추출하다 보면 code가 규칙에서 어긋날 수 있습니다.
  • 그렇기 때문에, 첫 점과 마지막 점의 code에 강제로 1과 79를 할당합니다.
  • 이렇게 추출된 vertices와 codes를 다시 윤곽선을 의미하는 path에 할당하고 그림을 그리면 변화가 관찰됩니다.
1
2
3
4
5
6
7
8
9
vertices_th = vertices[idx_th]
codes_th = codes[idx_th]

path.vertices = vertices_th
path.codes = codes_th
path.codes[0] = 1 # 시작점 (MOVETE)
path.codes[-1] = 79 # 닫는 점 (CLOSEPOLY)

display(fig)

4. 부가 요소 활용

  • 전체 코드를 한 번 정리합니다.
  • gridsize를 기본값으로 복구시켜 매끈한 곡선을 얻고,
  • 밀도 함수를 절단한 뒤 다시 전체 밀도 함수를 선으로만 그려 부분과 전체를 동시에 표시합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fig, ax = plt.subplots(figsize=(5, 3), constrained_layout=True)

# 1. threshold 적용 KDE plot
sns.kdeplot(set0, fill=True, ax=ax)

# vertices
path = ax.collections[0].get_paths()[0]
vertices = path.vertices
codes = path.codes

# threshold
idx_th = np.where(vertices[:, 0] > 2)[0]
vertices_th = vertices[idx_th]
codes_th = codes[idx_th]
path.vertices = vertices_th
path.codes = codes_th
path.codes[0] = 1
path.codes[-1] = 79

# 2. threshold 미적용 KDE plot
sns.kdeplot(set0, fill=False, color="k", ax=ax)

  • 이제 저 영역이 Pass라는 것을 문자를 사용해 명시합니다.
  • 색도 기본색보다 조금은 의지를 반영해 특정 색을 지정합니다. 파랑으로 갑시다.
  • 기준점이 되는 $x = 2$에 기준 막대도 우뚝 세워줍니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
fig, ax = plt.subplots(figsize=(5, 3), constrained_layout=True)

# 1. threshold 적용 KDE plot
sns.kdeplot(set0, color="b", gridsize=500, fill=True, ax=ax)

# vertices
path = ax.collections[0].get_paths()[0]
vertices = path.vertices
codes = path.codes

# threshold
idx_th = np.where(vertices[:, 0] > 2)[0]
vertices_th = vertices[idx_th]
codes_th = codes[idx_th]
path.vertices = vertices_th
path.codes = codes_th
path.codes[0] = 1
path.codes[-1] = 79

# 2. threshold 미적용 KDE plot
sns.kdeplot(set0, fill=False, color="k", ax=ax)

# 3. additional information
ax.collections[0].set_lw(0) # threshold 적용 KDE plot의 윤곽선 제거
ax.axvline(2, c="k", lw=3, alpha=0.5) # threshold line
ax.text(3.2, 0.15, "PASS", color="b", ha="center", va="center")

  • 같은 요령으로, 왼쪽에 FAIL이라고 명시할 수 있습니다.
  • 동일한 작업을 threshold 방향만 바꾸어 반복하면 됩니다.
코드 보기/접기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# PASS and FAIL
fig, ax = plt.subplots(figsize=(5, 3), constrained_layout=True)

# 1. threshold 적용 KDE plot
sns.kdeplot(set0, color="b", gridsize=500, fill=True, ax=ax) # Pass
sns.kdeplot(set0, color="r", gridsize=500, fill=True, ax=ax) # Fail

## PASS
# vertices
path_p = ax.collections[0].get_paths()[0]
vertices_p = path_p.vertices
codes_p = path_p.codes

# threshold
idx_th_p = np.where(vertices_p[:, 0] > 2)[0]
vertices_th_p = vertices_p[idx_th_p]
codes_th_p = codes_p[idx_th_p]
path_p.vertices = vertices_th_p
path_p.codes = codes_th_p
path_p.codes[0] = 1
path_p.codes[-1] = codes[-1]

## FAIL
# vertices
path_f = ax.collections[1].get_paths()[0]
vertices_f = path_f.vertices
codes_f = path_f.codes

# threshold
idx_th_f = np.where(vertices_p[:, 0] <= 2)[0]
vertices_th_f = vertices_p[idx_th_f]
codes_th_f = codes_p[idx_th_f]
path_f.vertices = vertices_th_f
path_f.codes = codes_th_f
path_f.codes[0] = 1
path_f.codes[-1] = 79

# 2. threshold 미적용 KDE plot
sns.kdeplot(set0, fill=False, color="k", ax=ax)

# 3. additional information
ax.collections[0].set_lw(0) # PASS KDE plot의 윤곽선 제거
ax.collections[1].set_lw(0) # FAIL KDE plot의 윤곽선 제거
ax.axvline(2, c="k", lw=3, alpha=0.5) # threshold line
ax.text(3.2, 0.15, "PASS", color="b", ha="center", va="center")
ax.text(0.5, 0.15, "FAIL", color="r", ha="center", va="center")

5. 넓이 계산

shapely.geometry.Polygon

  • 이런 시각화는 그림 뿐 아니라 숫자도 중요합니다.

  • 기준선을 넘은 데이터가 전체의 몇 %인지, 넘지 못한 것은 얼마인지 알아야 합니다.

  • 밀도 함수의 전체 넓이는 1이라는 사실은 널리 알려져 있지만 이렇게 자르면 계산이 어렵습니다.

  • shapely 라이브러리가 이런 도형 계산에 편리합니다.

  • shapely.geometry.Polygon()에 vertices를 넣은 뒤 .area속성을 출력하면 넓이가 나옵니다.

1
2
3
4
5
6
7
8
from shapely.geometry import Polygon

poly_p = Polygon(vertices_th_p)
poly_f = Polygon(vertices_th_f)

print(f"# PASS: {poly_p.area*100:.2f} %")
print(f"# FAIL: {poly_f.area*100:.2f} %")
print(f"# PASS + FAIL: {(poly_p.area + poly_f.area)*100:.2f} %")
  • 실행 결과
1
2
3
# PASS: 83.87 %
# FAIL: 15.70 %
# PASS + FAIL: 99.57 %
  • 더해서 100%가 되어야 하는데, 0.5%가량 부족하지만 전체적으로 얼추 맞습니다.
  • 대략 84%는 Pass, 16%는 Fail로 볼 수 있을 듯 합니다.
  • 실제 앞에서 만든 우리 데이터셋으로 확인하면 84.25% vs 15.75%라고 합니다.
1
2
print(f"# PASS (Ground Truth): {len(set0[set0 > 2])/1e5 * 100:.2f}")
print(f"# FAIL (Ground Truth): {len(set0[set0 <= 2])/1e5 * 100:.2f}")
  • 실행 결과
1
2
# PASS (Ground Truth): 84.25
# FAIL (Ground Truth): 15.75

6. 함수 제작

  • 자, 이제 함수를 만들어 사용합시다.
  • data와 threshold를 필수로 입력하게 하고, pass와 fail의 색, 그리고 gridsize를 보조 입력으로 받습니다.
  • 제가 만드는 다른 함수들처럼 활용성을 위해 Axes를 입력받을 수 있는, Axes를 출력하는 함수로 만듭니다.
  • 이러면 다른 큰 그림의 일부로 활용하기 좋습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# 함수 정의
def plot_passfail(data, threshold, color_pass="b", color_fail="r", gridsize=500, ax=None):

if not ax: # 입력 Axes가 없을 때, 생성
fig, ax = plt.subplots(figsize=(6, 4), constrained_layout=True)

# 1. threshold 적용 KDE plot
sns.kdeplot(x=data, color=color_pass, gridsize=gridsize, fill=True, ax=ax) # Pass
sns.kdeplot(x=data, color=color_fail, gridsize=gridsize, fill=True, ax=ax) # Fail


# 2. pass, fail thrsholding & coloring
labels = []
for i, (part, color_pf, label) in enumerate(zip(ax.collections, [color_pass, color_fail], ["PASS", "FAIL"])):
part.set_lw(0)
path = part.get_paths()[0]
vertices = path.vertices
codes = path.codes

if i == 0: # pass
idx_th = np.where(vertices[:, 0] > threshold)[0]
else:
idx_th = np.where(vertices[:, 0] <= threshold)[0]

vertices_th = vertices[idx_th]
codes_th = codes_p[idx_th]
path.vertices = vertices_th
path.codes = codes_th
path.codes[0] = 1
path.codes[-1] = 79

# calculate area
poly = Polygon(vertices_th)
labels.append(f"{label}: {poly.area*100:.1f} %")

# 3. threshold 미적용 KDE plot
sns.kdeplot(data, fill=False, color="k", ax=ax)

# 4. additional information
ax.axvline(threshold, c="k", lw=3, alpha=0.5) # threshold line
ax.legend(handles=ax.collections, labels=labels, loc="upper right")

# 5. auxiliaries
ax.spines[["left", "top", "right"]].set_visible(False)
ax.set_yticks([])
ax.set_ylabel("")

return ax

# 함수 실행
ax = plot_passfail(set0, 2)


  • Pass와 Fail 비율은 범례로 출력하게 만들었습니다.
  • 실제 활용시 다른 그래프와 중첩될 수 있고, 그래프 모양이 데이터에 따라 달라지기 때문에
  • 아까처럼 그래프 위에 글자를 놓으려면 고칠 일이 더 많아질 수 있기 때문입니다.
  • 이제 한 줄로 threshold가 반영된 밀도 함수를 그릴 수 있게 되었습니다.

7. 함수 수정

  • 이렇게 만들어진 그래프는 객체 제어를 통해 색을 비롯한 여러 요소를 마음껏 제어할 수 있습니다.
  • Pass를 green, Fail을 orange로 바꾸고 legend까지 반영합니다.
1
2
3
4
5
6
7
8
9
10
labels = []
for part, fc, label in zip(ax.collections, ["green", "orange"], ["PASS", "FAIL"]):
part.set_fc(fc)
part.set_alpha(0.5)
vertices = part.get_paths()[0].vertices
labels.append(f"{label}: {Polygon(vertices).area*100:.1f} %")

ax.legend(ax.collections, labels, loc="upper right")
ax.set_xlim(-1, 8)
display(ax.figure)


8. 활용 - 펭귄 데이터셋

  • 펭귄 데이터셋의 세 수치형 데이터, bill_length, bill_depth, flipper_length에 이 함수를 적용합니다.
  • plt.subplots(ncols=3)으로 틀을 잡아 놓고 Axes마다 데이터를 threshold와 함께 넣었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 데이터셋 읽어오기
df_peng = sns.load_dataset("penguins")

# 전체 Figure 설정
fig, axs = plt.subplots(ncols=3, figsize=(10, 4), constrained_layout=True)

# Axes마다 함수 적용
plot_passfail(df_peng["bill_length_mm"], 40, ax=axs[0], color_pass="b", color_fail="r")
plot_passfail(df_peng["bill_depth_mm"], 15, ax=axs[1], color_pass="g", color_fail="orange")
plot_passfail(df_peng["flipper_length_mm"], 180, ax=axs[2], color_pass="c", color_fail="m")

# Axes마다 y 범위 설정
axs[0].set_ylim(0, 0.1)
axs[1].set_ylim(0, 0.4)
axs[2].set_ylim(0, 0.06)

# 전체 title 설정
fig.suptitle("penguins dataset feature distribution with threshold\n", color="gray")

fig.savefig("127_kdeth_8.png")



도움이 되셨나요? 카페인을 투입하시면 다음 포스팅으로 변환됩니다

Share