[혼공머신] 4주차 05-1 결정트리
본 게시글은 한빛미디어의 혼자 공부하는 머신러닝+딥러닝을 바탕으로 작성되었습니다.
혼공학습단
오늘은 너무 추웠다. 날씨 때문인지 주식 창이 파란색으로 가득 찼기 때문인지는 모르겠지만 너무 온도차가 컸다. 아무튼 이번주 내용을 보다가 실수를 하나 했다는 생각이 들었다. 지난주에 minibatch gradient descent를 설명하면서 교차검증을 언급하는 실수를 해버렸다. 교차검증이 안 나올 줄 알고 그냥 언급을 한 것이었는데, 이번 장에서 나오는 것이었다.
물론 큰 상관은 없다만 커리큘럼대로 하지 않았다는? 느낌이 들어 약간 아쉬웠다. 그리고 이번에 혼공학습단 50% 기념으로 와플을 준다고 해서 매우 기대가 된다. 그리고 우수 혼공족 발표도 기대가 된다. 사실 우수 혼공족이 단순히 많이 쓰고, 잘한다고 선정되는 것이 아니라, 지난번보다 더 향상된 사람들도 대상으로 한 것을 깨닫고 크게 관심을 두지 않기로 하였으나 그래도 신경이 쓰이는 것은 어쩔 수 없다. 하지만 그보다는 혼공족장님이 남겨 주시는 코멘트를 보는 재미로 혼공학습단을 하고 있다. Padlet에 최근 수정 시간이 나오는데 그걸 바탕으로 언제 내 게시글에 코멘트가 달릴지 예상하고 계속 기다리는 걸 요즘 반복하고 있다.
언젠가는 모든 게시글에 남겨지긴 하겠지만 그래도 기다려지는 것도 어쩔 수 없다. 이런 점에서 피드백이 주어진다는 것이 동기부여에 큰 영향을 끼치는 것 같다. 다른 활동에서는 과제를 할 때 피드백이 주어지지 않는 경우가 대부분이었고, 그러한 경우 중도 포기자가 많았던 것으로 기억한다. 물론 나도 몇번의 과제를 넘긴 적도 있었다. 그런데 이렇게 피드백이 주어지니 하기 귀찮더라도 피드백을 보기 위해서 하게 되는 것 같다.
그나저나 거의 200명에 가까운 인원들에 대해서 멘트도 다르게 하고 글도 읽으면서 코멘트를 작성하는 혼공족장님은 대단한 것 같다. 거의 블로그를 2분 내지 4분 정도 읽으시고 코멘트를 작성하시는 것 같은데 고등학교 생활기록부 작성 프로그램처럼 멘트 모음집이 있어서 그걸 바탕으로 하는지 궁금하다.
사족이 길었는데, 이제 새로운 알고리즘인 트리 알고리즘에 대해서 살펴보자.
로지스틱 회귀분석의 한계
우선 트리 알고리즘에 대해서 다루기 전에 왜 기존에 사용하던 이쁜 모델이 있는데 굳이 트리 알고리즘을 사용해야 하는지 알아야 한다. 그렇기 위해서는 기존 모델의 한계를 생각해보는 것이 가장 간단하다. 먼저, 지난번에 살펴본 logistic regression으로 classification을 해보도록 하자. 늘 하던대로 해준다. 결과는 0.7 정도지만 나쁘지 않긴 하다. Training score랑 test score를 비교했을 때, overfitting이나 underfitting에 대한 우려는 딱히 안 보이는 것 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
wine = pd.read_csv("https://bit.ly/wine_csv_data")
data = wine[['alcohol', 'sugar', 'pH']].to_numpy()
target = wine['class'].to_numpy()
X_train, X_test, y_train, y_test = train_test_split(data, target, test_size=0.2, random_state=42)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.fit_transform(X_test)
lr = LogisticRegression()
lr.fit(X_train_scaled, y_train)
print(lr.score(X_train_scaled, y_train), lr.score(X_test_scaled, y_test))
0.7808350971714451 0.7753846153846153
그런데 여기서 한 가지 문제가 발생한다. 이렇게 logistic regression을 통해서 결과를 냈는데 왜 이러한 결과가 나왔는지 설명을 해주려고 한다. 그러려면 어떻게 해야 할까? 우리는 예전에 봤던 회귀식을 작성해서 설명할 수 있다.
1
print(lr.coef_, lr.intercept_)
\[\phi=\frac{1}{1+e^{-(1.81773456+0.51268071x_1+1.67335441x_2-0.68775646x_3)}}\tag{1}\][[ 0.51268071 1.67335441 -0.68775646]] [1.81773456]
여기서 문제가 발생하게 된다. 우리는 data를 분석한 다음 결정을 하는 것을 돕는 것이 목적이다. 그런데, 만약 data 분석이나 통계에 대한 지식이 없는 사람, 일반적으로 경영을 하는 경영자에게 이러한 수식을 들이밀면서 이러한 과정을 통해 분석을 했습니다 라고 보고서를 내밀면 어떻게 될까? 아마 이게 무슨 소리지 하면서 자기 혼자 아는 것을 자랑한다고 생각할 수도 있을 것이다. 그렇기 때문에 우리는 이러한 수학적인 모델을 사용하지 않고 설명할 수 있는 방법을 찾아야 한다.
결정 트리
만약 아무런 배경 지식이 없는 사람들에게 설명을 하고자 한다면 수식은 최악의 선택일 것이다. 전공을 하고 있더라도 수식을 보면 속이 안 좋을 수 있는데, 하물며 수학과 절연한 사람들이라면 더더욱 안 좋을 것이다. 이러한 경우, 가장 좋은 선택은 그림을 통해서 설명하는 것일 것이다. 우리는 이를 위해 결정 트리(Decision Tree)라는 것을 사용할 것이다. Decision tree를 사용하면, 아래 그림과 같이 왜 어떤 결과가 나왔는지 엄청나게 이해가 쉽다.
이제 한번 코드를 작성해보자. 코드는 아까와 비슷하지만 모델만 decision tree로 바꾸어주면 된다. 이번엔 결과를 보니 training score는 매우 높지만 test score는 조금 많이 낮은 것을 확인할 수 있기에 overfitting을 의심해야 할 것이다. 다만 일단 decision tree 로 설명하기 위한 그림을 먼저 그려보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
wine = pd.read_csv("https://bit.ly/wine_csv_data")
data = wine[['alcohol', 'sugar', 'pH']].to_numpy()
target = wine['class'].to_numpy()
X_train, X_test, y_train, y_test = train_test_split(data, target, test_size=0.2, random_state=42)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.fit_transform(X_test)
dt = DecisionTreeClassifier(random_state=42)
dt.fit(X_train_scaled, y_train)
print(dt.score(X_train_scaled, y_train), dt.score(X_test_scaled, y_test))
0.996921300750433 0.8407692307692308
그림을 그려보았는데, 뭔가 잘못되어도 심각하게 잘못되었다는 생각이 들 것이다. 이런 그림을 설명하겠다고 가져갔다가는 욕을 먹어도 할 말이 없을 것이다. 그리고 아까 말했듯이 overfitting의 우려가 있어 보인다고 언급했었다. 만약 그렇다면 어떻게 해야할까? 먼저, 아래의 그림 중 하나의 분류기를 노드(Node)라고 한다. 그리고 우리는 아래의 그림 중 제일 위에 있는 것을 모든 것의 뿌리가 되는 node라고 해 루트 노드(Root Node)라고 부른다. 그리고 그 아래에 있는 것을 나무 가지에 달려있는 잎, 즉 리프 노드(Leaf Node)라고 부른다. 따라서 수많은 classifier들이 root node에서 시작해 다양한 leaf node를 거쳐 최종 classification을 수행하게 된다.
1
2
3
4
import matplotlib.pyplot as plt
from sklearn.tree import plot_tree
plot_tree(dt)
plt.show()
우리는 이걸 조금 더 자세히 살펴보기 위해 root node와 하위 1단계의 leaf node를 살펴보도록 하자. 이렇게 보니 아까 살펴봤던 어지러운 그림과는 다르게 안에 내용이 있다는 것을 알 수 있다. 먼저, 첫번째 줄은 당도의 수치를 바탕으로 classification을 수행한다는 것임을 알 수 있긴 하다. 그런데 그 다음에 gini라는 이상한 것이 나온다. 그 다음을 보면, samples를 볼 수 있는데, 이건 당연히 sample의 수일 것이다. 마지막으로, value가 의미하는 것은 결과적으로 classify된 개수를 나타내는 것이다. 그럼 gini는 무엇인지는 나중에 살펴보고, 처음에 logistic regression에서 회귀식을 통해 설명하는 것보다는 매우 쉽게 알아볼 수 있다.
1
2
3
4
import matplotlib.pyplot as plt
from sklearn.tree import plot_tree
plot_tree(dt, max_depth=1, filled=True, feature_names=['alchol', 'sugar', 'pH'])
plt.show()
위의 대용을 조금 더 구체적으로 살펴보면, 일단 제일 먼저 당도가 -0.239보다 작거나 같은지 아니면 더 큰지를 바탕으로 2개로 나눈다. 그리고 또 이 상태에서 -0.802와 0.204를 기준으로 2개로 나누어 총 4개로 나뉘게 된다. 여기서 우리가 알아야 하는 것은 처음 root node에서는 음성 클래스, 즉 왼쪽에 있는 것이 총 1,258개이고 오른쪽에 있는 양성 클래스가 3,939개인 것을 확인할 수 있다. 만약 여기서 decision tree의 성장을 멈추면 둘 중 더 많은 수의 sample을 가진 양성 클래스로 클래스가 classify되게 된다. 매우 간단한 방식이다.
이제 한 번 더 성장시켰을 경우를 살펴보자. 왼쪽의 node를 살펴보면 음성 클래스가 1,177개, 양성 클래스가 1,745개로 아까와 마찬가지로 양성 클래스의 수가 더 많음을 알 수 있으며, 양성 클래스로 classify된다. 오른쪽 node도 마찬가지임을 확인할 수 있다.
여기서 또 참고로 볼 수 있는 것은 node의 색의 진하기이다. 색이 진할수록 어떤 class의 비율이 더 크다는 것을 의미하며 더 분명하게 classify된다는 것을 의미한다.
Gini 불순도
Gini 불순도(Gini Impurity)는 한 node에서 얼마나 불순한 class가 섞여 있는지를 나타내는 지표이다. 먼저 아래의 식을 보자. $c$는 class의 개수이다. 그런데 우리는 먼저 조금 간단한 binary 형태의 gini impurity를 구해볼 것이기에 $c=1$로 설정해서 다시 한 번 살펴보자.
\[Gini\ Impurity=1-\sum^{c-1}_{i=0}p_i^2\tag{2}\] \[Gini\ Impurity=1-(p_0^2+p_1^2)\tag{3}\]그런데 $p_0$는 class 0에 속할 확률이고, $p_1$은 class 1에 속할 확률이다. 여기서 우리는 class가 2개밖에 없는 binary 형태이므로 다음 (4)가 만족함은 자명하다. 따라서 (4)를 변형해서 (5)를 만들 수 있고, 이 (5)를 (3)에 대입해서 조금 더 식을 정리해보자.
\[p_0+p_1=1\tag{4}\] \[p_1=1-p_0\tag{5}\](5)를 (3)에 대입해서 식을 정리하면 다음 (9)와 같이 정리할 수 있다.
\[\begin{align} Gini\ Impurity&=1-[p_0^2+(1-p_0)^2]\tag{6}\\ &=1-(p_0^2+1-2p_0+p_0^2)\tag{7}\\ &=1-2p_0^2-1+2p_0\tag{8}\\ &=2p_0(1-p_0)\tag{9} \end{align}\]이렇게 gini impurity를 구해봤는데, 한가지 의문이 들 수도 있다. 굳이 왜 확률을 제곱할까? 확률은 음수가 있을 수 없는데 굳이 error처럼 제곱을 해야할까? 이건 식을 보면 바로 알 수 있다. 아까 (4)에서 봤듯이 전체 class의 확률을 더하면 1이 된다. 그런데 (3)의 식을 보았을 때, 제곱을 안 하고 그냥 확률을 더한 다음 1에 빼면 항상 gini impurity는 0이 나와버린다. 그럼 gini impurity를 구할 수가 없게 되어버린다. 따라서 제곱을 해서 impurity를 구할 수 있도록 한 것이다.
이제 아까의 그림에서 root node의 값을 바탕으로 gini impurity를 구해보도록 하자. 먼저, 각 class의 확률인 $p_0, p_1$을 구해보도록 하자. 전체 sample의 수는 $n=5,197$개이며, 각 class의 sample 수는 $n_0=1258$, $n_1=3939$이다. 따라서 확률은 다음과 같이 구할 수 있다.
\(p_0=\frac{1258}{5197}\approx0.242\tag{10}\) \(p_1=\frac{3939}{5197}\approx0.758\tag{11}\)
(9)의 식을 사용해서도 구할 수 있으나, 우리의 교재에는 (3)의 식을 활용하고 있으므로 일단 (3)에 (10), (11)을 대입해서 gini impurity를 계산해보자. 위 이미지에서 나왔던 0.367이라는 수치를 구할 수 있음을 알 수 있다. 그러면 아까 정리했던 식인 (9)를 사용해서도 동일한 수치가 나오는지도 확인해보자.
\[\begin{align} Gini\ Impurity&=1-(p_0^2+p_1^2)\tag{12}\\ &=1-[(0.242)^2+(0.758)^2]\tag{13}\\ &=1-(0.058564+0.574564)\tag{14}\\ &=0.366872\tag{15}\\ &\approx0.367\tag{16} \end{align}\]아래의 (17)~(21)의 결과를 통해서 (9)에 (10), (11)을 대입해서 구한 결과도 위 이미지와 (16)에서 구한 값과 동일하게 구해짐을 확인할 수 있다.
\[\begin{align} Gini\ Impurity&=2p_0(1-p_0)\tag{17}\\ &=2(0.242)(1-0.242)\tag{18}\\ &=0.484\times0.758\tag{19}\\ &=0.366872\tag{20}\\ &\approx0.367\tag{21} \end{align}\]이렇게 gini impurity를 구했는데, 이것을 통해서 무엇을 알 수 있는지, 그래서 높은 것이 좋은지 낮은 것이 좋은지 알아보도록 하자. Impurity는 말 그래도 불순도, 얼마나 순수하지 않은지를 의미하며, 만약 class가 0.5:0.5로 섞여 있다면 가장 불순할 것이다. 그리고 0:1 또는 1:0이라면 제일 순수할 것이다. 이것을 통해서 이 두 경우의 gini impurity를 계산해서 높은 것이 좋은지 낮은 것이 좋은지 계산해보도록 하자. 먼저, 가장 불순한 경우를 보자.
가장 불순했을 경우를 가정하여 (9)에 $p_0=0.5$의 확률을 대입하면 아래와 같이 계산되며, 최종적으로 gini impurity는 0.5가 나옴을 알 수 있다.
\[\begin{align} Gini\ Impurity&=2p_0(1-p_0)\tag{22}\\ &=1(1-0.5)\tag{23}\\ &=0.5\tag{24} \end{align}\]가장 순수할 때를 가정하여 (9)에 $p_0=1$의 확률을 대입하면 아래와 같이 계산되며, 최종적으로 gini impurity는 0이 나옴을 알 수 있다. 이때, $p_0=0$의 확률을 대입해도 동일한 결과가 나온다는 것을 확인할 수 있다.
\[\begin{align} Gini\ Impurity&=2p_0(1-p_0)\tag{25}\\ &=1(1-1)\tag{26}\\ &=0\tag{27} \end{align}\] \[\begin{align} Gini\ Impurity&=2p_0(1-p_0)\tag{28}\\ &=0(1-0)\tag{29}\\ &=0\tag{30} \end{align}\]따라서 (24), (27), (30)을 통해서 가장 불순했을 때의 gini impurity는 0.5이고, 가장 순수할 때의 gini impurity는 0이라는 것을 확인할 수 있다. 결과적으로 gini impurity는 0에 가까울수록 순수하며, 0.5에 가까울수록 불순함을 확인할 수 있다. 따라서 0에 가까울 수록 좋은 것이다.
그런데 여기서도 아까 decision tree의 그림을 봤을 때, node는 한 개가 아니었다. 엄청나게 많은 경우도 있었으나, 간단했던 그림만 다시 보도록 하자. Decision tree에서는 아래와 같이 여러 node들이 있다. 그런데 상위 node랑 하위 node가 있는데 이러한 상위 node와 하위 node를 지칭하는 용어로 부모 노드(Parent Node)와 자식 노드(Child Node)가 있다. 무엇이 상위 node이고 무엇이 하위 node인지는 이름만 봐도 알 수 있을 것이라고 생각한다. Decision tree에서는 이 parnet node와 child node의 impurity 차이가 가능한 한 크도록 tree를 성장시키게 된다. 이때, impurity의 차이인 gini gain을 통해서 계산할 수 있다.
Gini gain는 parent node의 impurity에 child node의 impurity를 sample의 개수에 비례해 더한 것을 빼는 것이다. 이를 조금 더 명확하게 식으로 작성하면 다음과 같다.
\[Gini\ Gain=Parent\ Gini-\sum_{i=0}^{k}\frac{n_k}{n}Child\ Gini_k\tag{31}\]이제 위 그림을 바탕으로 (31)에 값을 대입해서 gini gain을 계산해보도록 하자. 우선, parent node의 gini impurity는 0.367이다. 그리고 node가 2개가 있었다. 그럴 경우 (31)은 다음과 같이 표현될 수 있다. 여기서 첫번째 node는 왼쪽에 있는 것, 그리고 두번째 node는 오른쪽에 있는 것으로 설정하여 $n_1=2922$, $n_2=2275$로 할 수 있다. 따라서 결과적으로 0.067이 나오게 되었다.
\[\begin{align} Gini\ Gain&=Parent\ Gini-\sum_{i=0}^{k}\frac{n_k}{n}Child\ Gini_k\tag{31}\\ &=Parent\ Gini-\left(\frac{n_1}{n}Child\ Gini_1+\frac{n_2}{n}Child\ Gini_2\right)\tag{32}\\ &=0.367-\left(\frac{2922}{5197}0.481+\frac{2275}{5197}0.069\right)\tag{33}\\ &\approx0.367-(0.27+0.03)\tag{34}\\ &=0.067\tag{35} \end{align}\]지금까지 gini impurity를 살펴보았는데, 이것 말고 또 다른 척도가 있다. 엔트로피(Entropy)이다. Entropy는 제곱을 사용했던 gini impurity와는 다르게 밑이 2인 로그를 사용한다. 이를 식으로 표현하면 아래와 같다.
\[Entropy=-\sum^{c-1}_{i=1}p_i\log_2p_i\tag{36}\]이때 아까와 마찬가지로 binary case이므로 다시 식을 작성해보면 다음과 같다. \(\begin{align} Entropy&=-\sum^{c-1}_{i=1}p_i\log_2p_i\tag{37}\\ &=-(p_0\log_2p_0+p_1\log_2p_1)\tag{38} \end{align}\)
이제 간단히 root node의 값을 활용해 entropy값을 구해보도록 하자. 이때, 각 비율은 (10), (11)에서 구했으므로 그것을 활용하기로 하자.
\[\begin{align} Entropy&=-(p_0\log_2p_0+p_1\log_2p_1)\tag{39}\\ &=-(0.242\log_20.242+0.758\log_20.758)\tag{40}\\ &\approx-(-0.495-0.303)\tag{41}\\ &=0.798\tag{42} \end{align}\]Gini impurity에서는 값이 0.367이 나왔었으나 지금의 entropy값은 0.798이 나왔다. 차이가 엄청 크게 보일 수도 있다고 생각할 수 있는데, gini impurity는 최대값이 0.5였지만 entropy값은 최댓값이 1이다. 따라서 gini impurity를 2배해서 보면 거의 비슷하다는 것을 확인할 수 있다. 그러나 여기서는 gini impurity를 주로 사용하기로 했기에 gini impurity를 중심으로 살펴보도록 하자.
가지치기
우리가 처음 decision tree 모델을 살펴볼 때 봤던 그림을 아마 기억할 것이다. 너무 많은 node들이 있어서 안에 무엇이 적혀 있는지 조차 확인할 수 없었고, 그 결과도 overfitting의 우려가 있었다. 이를 방지하기 위해서 우리는 tree의 깊이, 즉 depth를 조절하는 방법을 사용할 수 있다. 따라서 기존의 코드에서 최대 깊이를 3으로 제한하고 다시 결과를 살펴보자. 아까보다 드라마틱하게 성능 향상이 있진 않지만, trian score과 test score과의 차이가 크지 않고, 아까보다 덜 복잡한 tree를 만들었지만 test score는 이전과 약간이지만 늘어났다는 것을 확인할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
wine = pd.read_csv("https://bit.ly/wine_csv_data")
data = wine[['alcohol', 'sugar', 'pH']].to_numpy()
target = wine['class'].to_numpy()
X_train, X_test, y_train, y_test = train_test_split(data, target, test_size=0.2, random_state=42)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.fit_transform(X_test)
dt = DecisionTreeClassifier(max_depth=3, random_state=42)
dt.fit(X_train_scaled, y_train)
print(dt.score(X_train_scaled, y_train), dt.score(X_test_scaled, y_test))
0.8454877814123533 0.8415384615384616
그리고 tree를 그려보니 아까의 어지러운 그림과 달리 엄청 이쁜 모양이 나온 것을 확인할 수 있다. 그런데, 여기서 유의할 것은 항상 sugar만을 기준으로 training set을 나누지 않았다는 것이다. pH나 alcohol을 기준으로도 구분을 한다는 것을 확인할 수 있다. 그리고 제일 아래의 value를 보면 왼쪽에서 3번째 node를 제외하고는 다 양성 class가 더 많음을 알 수 있다. 따라서 결과적으로 3번째 node에 온 smaple만 레드 와인으로 분류가 되고 나머지는 화이트 와인으로 분류가 된다.
1
2
3
4
import matplotlib.pyplot as plt
from sklearn.tree import plot_tree
plot_tree(dt, filled=True, feature_names=['alchol', 'sugar', 'pH'])
plt.show()
그런데 사실 decision tree에 한 가지 숨겨진 장점이 있다. decision tree는 distance를 기반으로 classification을 하는 것이 아니기에 scale의 영향을 미치지 않는다. 따라서 아까 코드의 standard scalar를 사용할 필요가 없다. 그리고 또 한 가지 유용한 기능으로 어떤 특성이 가장 중요한지를 알려주는 기능이 있다. 아래와 같이 작성하면 각각의 중요도가 어떻게 되는지 알 수 있다. 이를 기반으로 살펴보면 sugar, 당도가 제일 중요하다는 것을 확인할 수 있다.
1
dt.feature_importances_
array([0.12345626, 0.86862934, 0.0079144 ])