본문 바로가기
통계/머신러닝

9. 의사결정나무(Decision Tree) 에 대해서 알아보자 with Python

by 부자 꽁냥이 2021. 6. 10.

이번 포스팅에서는 모형의 해석이 쉽다는 장점을 가진 의사결정나무를 공부한 내용을 포스팅하려고 한다. 의사결정이 무엇인지 알아보고 의사결정나무 모형을 직접 구현하는 방법을 소개하고 마지막에 실제 데이터를 이용하여 앞서 만든 모형이 잘 동작하는지 확인해볼 것이다. 또한 sklearn을 이용하는 방법도 소개한다.

 

여기서 다루는 내용은 다음과 같다.

 

1. 의사결정나무란?

2. 의사결정나무 모형 만들기

3. 의사결정나무 구현하기

4. 예제 with Python


   1. 의사결정나무란?

- 정의 -

의사결정나무(Decision Tree)는 입력값에 대한 예측값을 나무 형태로 나타내어주는 모형이다. 

 

- 용어 정리 -

먼저 의사결정나무에서 사용되는 주요 용어를 살펴보자.

  • 뿌리 마디(root node) : 시작되는 마디로 전체 자료를 포함한다.
  • 자식 마디(childe node) : 하나의 마디로부터 분리된 2개 이상의 마디들
  • 부모 마디(parent node) : 주어진 마디의 상위 마디
  • 끝마디(terminal node) : 자식 마디가 없는 마디
  • 중간 마디(internal node) : 부모 마디와 자식 마디가 모두 있는 마디
  • 가지(branch) : 연결되어있는 2개 이상의 마디 집합
  • 깊이(depth) : 뿌리 마디로부터 끝마디까지 연결된 마디의 개수

아래 그림을 보면 용어를 이해하는데 도움이 될 것이다(노드와 마디는 같은 뜻이다).

 

- 장단점 -

1) 장점

의사결정나무는 모형의 해석이 쉽다. 여기서 모형의 해석이 쉽다는 것은 입력값이 주어졌을 때 설명변수의 영역을 따라가며 출력값이 어떻게 나올 수 있는지 알기 쉽다는 것이다.

 

2) 단점

예측력이 다른 지도 학습모형에 비하여 떨어진다. 이것은 어찌 보면 당연하다. 왜냐하면 의사결정나무는 설명변수의 일부 영역에서 단순히 평균 또는 다수결 법칙에 의하여 예측을 수행하기 때문이다. 특히 회귀나무의 예측력은 더 좋지 않은 편인데 이는 해당 마디에 포함된 반응 변수의 평균을 예측값으로 추정하는데 아무래도 평균을 사용하다보니 이상치에 영향을 받을 수 있기 때문이다.

반응형

   2. 의사결정나무 모형 만들기

의사결정나무 모형은 크게 성장(growing), 가지치기(Pruning) 단계를 통하여 만들어진다.

 

성장(growing) 단계에서는 먼저 최적화할 목적함수를 정한다. 각 마디에서 이 목적함수를 최적화하는 변수와 그 변수의 분리 기준을 찾아 의서결정나무를 성장시키며 사전에 정의된 정지 규칙(stopping rule)을 만족하면 성장을 중단한다. 가지치기(pruning) 단계에서는 과적합(Overfitting)을 방지, 해석이 안 되는 규칙 등 불필요한 가지를 제거한다.

 

출력 변수의 종류에 따라서 의사결정나무의 종류도 나누어지게 된다. 출력 변수가 연속형인 경우 회귀나무(regression tree), 범주형인 경우 분류나무(classification)라고 한다. 이제 회귀나무와 분류나무를 만들어내는 방법을 알아보자. 여기서는 마디를 2진 분리시키는 CART(Classification and Regression Tree) 알고리즘을 소개한다.

1. 회귀나무(Regression Tree)

먼저 데이터 $(x_i, y_i), i=1,2,\ldots, n$ 이 주어졌다고 하자. 여기서 $x_i = (x_{i1}, \ldots, x_{ip})^t$이다. 의사결정나무 모형은(회귀나무든 분류나무든 마찬가지) 다음과 같이 나타낼 수 있다. 

$$f(x) = \sum_{m=1}^Mc_mI(x\in R_m)\tag{2.1}$$

여기서 $R_1, \ldots, R_M$은 $M$개로 쪼갠 설명변수들의 (겹치지 않는) 영역이다. 식 (2.1)이 의미하는 것은 설명변수 벡터 $x$가 $R_m$에 포함된다면 $c_m$으로 예측하겠다는 것이다. 또한 식 (2.1)로 부터 모형은 $c_m$과 $R_m$에 의해 결정된다는 것을 알 수 있다. 따라서 $c_m, R_m$을 어떻게 구할 것인지 알아야 한다. 

1.1 불순도(Impurity)

$c_m, R_m$은 불순도라는 측도를 이용하여 정하게 된다. 회귀나무에서는 불순도의 측도로 오차 제곱합을 사용한다. CART 알고리즘은 부모마디로부터 2 마디로 분리한다. 따라서 두 개의 영역으로 계속해서 나눠나간다. 주어진 $j$번째 설명변수 $x_j$와 해당하는 값(또는 집합) $s$에 대하여 두 영역을 $A_1(j, s), A_2(j, s)$라 하자. 만약 $x_j$가 연속형이라면 $s$는 실수가 되며  두 영역은 다음과 같이 정의할 수 있다.

$$A_1(j, s) = \{x_j : x_j\leq s\}, A_2(j, s) = \{x_j : x_j > s\}$$

만약 $x_j$가 범주형이라면 $s$는 집합이 되며 두 영역은 다음과 같이 정의할 수 있다.

$$A_1(j, s) = \{x_j \in s \}, A_2(j, s) = \{x_j \in s^c \}$$

 

최적 분리 변수와 그 변수에 해당하는 기준은 다음의 최적화 문제를 풀어서 구하게 된다.

$$\min_{j, s}\left ( \min_{c_1^*}\sum_{x_i\in A_1(j,s)}(y_i-c_1^*)^2 + \min_{c_2^*}\sum_{x_i\in A_2(j,s)}(y_i-c_2^*)^2 \right)$$

주어진 $j, s$에 대하여 최적 $c_1^*, c_2^*$는 각각 $A_1(j, s), A_2(j, s)$에 속하는 $y_i$의 평균이 된다. 이렇게 최적 변수와 그에 대응하는 분리 기준을 찾았다면 분리된 두 영역에 대해서 동일한 과정을 반복한다. 이를 반복하면 회귀나무(의사결정나무)를 성장시킬 수 있다.

표기상 $R_m$과 $A_1, A_2$가 헷갈릴 수 있는데 각 마디에서 분리된 두 영역 $A_1, A_2$들을 이용하여 분리된 영역이 $R_m$인 것이다. 아래 그림에서 왼쪽은 $x_1$에서 3을 기준으로 분리했을 때 영역과 $f(x)$를 나타낸 것이고 오른쪽은 $x_2$에서 4를 기준으로 분리했을 때 영역과 $f(x)$를 나타낸 것이다.

1.2 분리 기준 후보 선정

한편 분리 기준을 정할 때 후보가 되는 $s$는 $x_j$가 연속형인 경우 가질 수 있는 유니크한 값을 후보로 정하며 범주형인 경우는 공집합과 전체집합을 제외하고 중복되지 않는 부분집합을 고려한다. 예를 들어

$x_j$가 연속형이고 가질 수 있는 값의 집합이 {1.2, 1.5, 1.7}라고 하면 분리 기준 후보는 1.2, 1.5을 분리 기준 후보로 지정한다(또는 3개의 값의 사이에 들어있는 중앙값 1.35, 1.6을 써도 된다). $x_j$가 범주형이고 전체 집합이 {1, 2, 3, 4}라면 {1}, {1, 2} 등을 고려한다. 이때 {1, 2}를 분리 기준 후보로 두었다면 {3, 4}는 할 필요가 없다. {3, 4}는 {1, 2}의 여집합이라서 {1, 2}를 분리 기준 후보로 사용했을 때 이미 고려가 된 것이기 때문이다.

1.3 가지치기(Pruning)

앞 절에서 의사결정나무를 성장시키는 방법을 알아보았다. 그렇다면 언제까지 성장시켜야 하는지에 대한 질문이 나올 수 있다. 왜냐하면 너무 깊이가 깊은 나무는 과적합의 문제가 있고 깊이가 얕은 나무는 과소 적합의 문제가 있을 수 있기 때문이다. 이러한 문제를 하기 위해 나무의 깊이가 너무 깊어지지 않도록 가지치기를 하게 된다. 가지치기에는 사전 가지치기(pre-pruning)와 사후 가지치기(post-pruning)가 있다.

 

사전 가지치기는 사전에 정지 규칙을 만들어 나무의 성장을 중지시키는 방법이다. 예를 들면 깊이를 4로 미리 지정한다거나 마디에 해당하는 샘플 수가 5개 이하이면 성장을 멈추는 방법이 모두 사전 가지치기에 해당한다. 이때 사전 가지치기에서 사용할 깊이와 샘플 수는 검증 데이터를 통하여 결정하는 것이 일반적이다.

 

사후 가지치기는 주어진 나무 $T_0$에 대하여 아래의 목적함수를 최소화하는 나무 모형 $T_{\alpha}\subset T_0$을 찾는다.

$$C_{\alpha}(T) = \sum_{m=1}^{|T|}N_mQ_m(T)+\alpha |T|$$

여기에서 $|T|$는 끝마디 개수, $N_m$은 영역 $R_m$에서의 데이터 개수, $\hat{c}_m$은 $R_m$에서의 $y_i$의 평균, 그리고 

$$Q_m(T) = \frac{1}{N_m}\sum_{x_i\in R_m}(y_i-\hat{c}_m)^2 $$

이다.

 

이때 $\alpha \geq 0$은 나무 모형의 복잡도와 적합도를 조절하는 Tuning Parameter로 \alpha가 크면 (작으면) $T_{\alpha}$의 크기는 작아(커) 진다. $\alpha=0$이면 사후 가지치기는 하지 않고 $T_0$를 최종 의사결정나무로 선정한다. $\alpha$는 교차검증법을 이용하여 추정한다.

 

사후 가지치기(Post Pruning)를 하는 방법과 구현은 다음 포스팅해서 다루려고 한다.


2. 분류나무(Classification Tree)

분류 나무에서 분리 기준을 정하는 목적함수는 카이제곱 통계량, 지니 계수(Gini Index), 엔트로피 지수(Entropy)를 불순도의 측도로 사용하며 앞서 살펴본 회귀나무를 성장시키는 방법과 동일하다. 입력값 $x\in R_m$가 주어졌을 때 분류나무의 예측값은 $R_m$에 속하는 $y_i$의 범주가 가장 많은 범주를 예측값으로 한다. 즉, 분류나무는 예측값을 $R_m$에서 다수결의 원칙으로 정하는 것이다.

분류나무에서 사용하는 불순도의 측도를 알아보기 위해 데이터가 아래와 같이 분리되었다고 해보자.

 

2.1 카이제곱 통계량

먼저 2개로 분리되는 마디를 아래와 같이 관측 도수(기대 도수)를 포함한 2x2 테이블로 만들어준다.

 

  Good Bad Total
왼쪽 자식 노드 32 (56) 48 (24) 80
오른쪽 자식 노드 178 (154) 42 (66) 220
Total 210 90 300

 

카이제곱 통계량 $\chi^2$은 아래와 같이 계산한다.

$$\chi^2 = \sum_{k=1}^4(E_k-O_k)^2/E_k$$

여기서 $E_k$와 $O_k$는 각각 기대 도수와 관측 도수이다. 분리 기준은 카이제곱 통계량이 최대가 되는 값으로 정한다.

 

이 표에서 카이제곱 통계량은

$$\frac{(56-32)^2}{56}+\frac{(24-48)^2}{24}+\frac{(154-178)}{154}+\frac{(66-42)}{66} = 46.75$$

가 된다.

2.2 지니 지수(Gini Index)

한 노드에서 지니 지수 $G$는 다음과 같이 정의된다.

$$G = G_LP(\text{Left}) +G_RP(\text{Right})\tag{2.2.1}$$

여기서

$$\begin{align}\small G_L &= 2\cdot[P(\text{Good in Left})P(\text{Bad in Left})] \\ &= P(\text{Good in Left})[1-P(\text{Good in Left})] \\ &+ P(\text{Bad in Left})[1-P(\text{Bad in Left})] \end{align}$$

이고 $G_R$도 비슷하게 정의할 수 있다. 분리 기준은 지니 지수가 최소가 되도록 하는 값으로 정한다. 앞의 나무에 대해서 지니 지수는 다음과 같다.

$$\begin{align}2\left (\frac{32}{80}\cdot\frac{48}{80}\cdot\frac{80}{300}+\frac{178}{220}\cdot\frac{42}{220}\cdot\frac{220}{300}\right ) = 0.355\end{align}$$

여기서는 $y_i$가 2개의 클래스인 것만 고려하였는데 만약 $K$개의 클래스를 갖고 있다고 하자. 이때 $p_k^L$을 왼쪽 마디에 있는 데이터 중에서 클래스가 $k$인 데이터의 비율이라고 하자. 그렇다면 식 (2.2.1)에서 $G_L  = \sum_{k=1}^Kp_k^L(1-p_k^L)$이 된다. 유사하게 $G_R$도 정의할 수 있다.

2.3 엔트로피 지수(Entropy Index)

한 노드에서 엔트로피 지수 $E$는 다음과 같이 정의한다. 

$$E = E_LP(\text{Left}) + E_RP(\text{Right}) \tag{2.3.1}$$

이고 $$E_L=-P(\text{Good in Left})\log_2 P(\text{Good in Left}) \\ -P(\text{Bad in Left})\log_2P(\text{Bad in Left})$$이고 $E_R$도 비슷하게 정의된다.

분리 기준은 엔트로피 지수를 최소화시키는 값으로 정한다.

$$-\left (\frac{32}{80}\log_2\frac{32}{80} + \frac{48}{80}\log_2\frac{48}{80}\right )\cdot\frac{80}{300} \\ -\left ( \frac{178}{220}\log_2\frac{178}{220} + \frac{42}{220}\log_2\frac{42}{220} \right )\cdot\frac{220}{300} = 0.7747$$

 

여기서는 $y_i$가 2개의 클래스인 것만 고려하였는데 만약 $K$개의 클래스를 갖고 있다고 하자. 이때 $p_k^L$을 왼쪽 마디에 있는 데이터 중에서 클래스가 $k$인 데이터의 비율이라고 하자. 그렇다면 식 (2.3.1)에서 $E_L  = -\sum_{k=1}^Kp_k^L\log_2 p_k^L$이 된다. 유사하게 $E_R$도 정의할 수 있다.

 

2.4 불순도 측도의 고찰

여기서는 클래스가 2개인 경우를 가정하고 설명하려고 한다. 보통 불순도는 한 노드 안에서 두 클래스의 분포가 얼마나 섞여 있는지를 의미한다. 아래 그림은 부모 노드로부터 분리된 자식 노드들을 2가지 경우로 나타낸 것이다. 

 

1) 불순도가 높은 경우

 

2) 불순도가 낮은 경우

 

불순도는 순한 정도의 반대이므로 1) 번의 불순도가 2) 번 보다 큰 것을 알 수 있다. 그리고 불순도가 낮으면 그만큼 예측되는 클래스가 더 분명해지기 때문에 분리 기준을 정할 때에는 불순도가 최소화되는 것을 골라야 한다.

 

앞에서 불순도를 측정하는 측도로써 카이제곱 통계량, 지니 지수, 엔트로피 지수를 소개하였다. 지니 지수와 엔트로피 지수 같은 경우는 하나의 클래스가 다른 클래스보다 많은 경우 그 값이 작아지며 이는 불순도가 최소화된다는 것을 의미한다. 그 이유를 살펴보자.

 

식 (2.2.1)에서 지니 지수 $G$를 수식으로 다시 쓰면 다음과 같다.

$$G = 2*[p_{GL}(1-p_{GL})p_L+p_{GR}(1-p_{GR})p_R]\tag{2.4.1}$$

위 식에서 왼쪽 부분인 $G_L = p_{GL}(1-p_{GL})$를 살펴보자. $G_L$의 성질을 살펴보기 위해 $f(x) = x(1-x), (0 \leq x \leq 1)$의 그래프를 그려보았다.

 

$f(x) = x(1-x)$ 그래프

 

위 그림에서 알 수 있듯이 $G_L$은 $p_{GL} = 0.5$인 경우 최대가 되며 $p_{GL}$이 0 또는 1인 경우 최소가 된다. 전자는 마디 안에 두 클래스(Good, Bad)가 반반씩 섞여있어서 불순도가 가장 높은 경우이고 후자는 하나의 클래스만 존재한다. 따라서 하나의 클래스가 다른 클래스보다 많은 경우 $p_{GL}$이 0 또는 1에 가까워져서 불순도 여기서는 지니 지수가 작아지게 된다. 식 (2.4.1)의 오른쪽 부분도 마찬가지이다. 요약하자면 지니 지수가 최소화되는 분리 기준을 정해야 한다는 것이다.

 

다음으로 식 (2.3.1)에서 엔트로피 지수 $E$를 수식으로 다시 쓰면 다음과 같다.

$$E = E_Lp_L+E_Rp_R\tag{2.4.2}$$

이때 $E_L = -p_{GL}\log_2p_{GL}-(1-p_{GL})\log_2(1-p_{GL})$이고 $E_R = -p_{GR}\log_2p_{GR}-(1-p_{GR})\log_2(1-p_{GR})$이다. $E_L$에 대한 성질을 알아보기 위해 $f(x) = -x\log_2 x - (1-x)\log_2 (1-x), (0 < x < 1)$의 그래프를 그려보자.

 

$f(x) = $

 

위 그림을 토대로 $E_L$을 보면 지니 지수와 마찬가지로 $p_{GL} = 0.5$인 경우 최대가 되며 $p_{GR}$ 0 또는 1에 가까워지는 경우 계속 감소하게 된다. 전자는 마디 안에 두 클래스(Good, Bad)가 반반씩 섞여있어서 불순도가 가장 높은 경우가 되고 후자는 하나의 클래스만 존재한다. 따라서 하나의 클래스가 다른 클래스보다 많은 경우 $p_{GL}$이 0 또는 1에 가까워져서 불순도 여기에서는 엔트로피 지수가 작아지게 될 것이다. 식 (2.4.2)의 $E_R$도 마찬가지이다. 즉, 엔트로피 지수를 최소화시키는 값을 분리 기준으로 정하는 것이 좋다는 것이다.

 

카이제곱 통계량은 지니 지수, 엔트로피 지수와는 불순도 관점이 다르다. 지니 지수와 엔트로피 지수는 순도가 높은 다시 말하면 한쪽 클래스가 다른 클래스보다 많아지는 분리 기준을 정한다. 하지만 카이제곱 통계량은 왼쪽 마디에서 두 클래스의 분포와 오른쪽 마디에서 두 클래스의 분포 차이를 크게 하는 분리 기준을 선택한다. 예를 들어 다음과 같이 2가지 경우로 분리가 되었다고 해보자. 

 

 

지니 지수와 엔트로피 지수 관점에서 봤을 때에는 왼쪽 표의 분리 기준을 선택한다. 왜냐하면 왼쪽 마디와 자식 마디에서 Bad 클래스가 일방적으로 많으므로 불순도가 작기 때문이다. 하지만 카이제곱 통계량은 오른쪽 분리 기준을 선택한다. 왜냐하면 왼쪽 표의 경우 왼쪽 마디에서의 Good, Bad 분포(1대 14)와 오른쪽 마디에서의 Good, Bad 분포(1대 14)가 같기 때문에 카이제곱 통계량 값은 작기 때문이다(이 경우 정확히 0이 된다). 


그렇다면 어떤 것을 써야 할까? 


한 클래스를 잘 분리해내고 싶다면 지니 지수와 엔트로피 지수를 사용하고 설명 변수가 타겟 클래스 분포에 영향을 미치는지 확인하고 싶다면 카이제곱 통계량을 사용할 수 있다. 그리고 지니 지수와 엔트로피 지수 같은 경우 엔트로피 지수는 로그 계산을 포함하고 있기 때문에 계산적인 측면에서는 지니 지수가 유리하며  두 지수를 사용했을 때 성능 차이는 크지 않다고 한다. 

2.5 다른 불순도의 측도들

이외에도 오분류율, 정보 획득(Infomation Gain), 분산 감소량(Variance Reduction)등 여러 가지 불순도 측도가 있다. 이에 대한 내용은 여기에 가면 자세히 나와있다.

반응형

   3. 의사결정나무 구현하기

이제 의사결정나무를 파이썬으로 구현해보자. 전체 코드는 아래에 첨부해두었다.

 

decision_tree_final.ipynb
0.22MB

 

- 의사결정나무 생성 알고리즘 -

아래 그림은 의사결정나무 알고리즘을 그림으로 나타낸 것이다.

 

의사결정나무 알고리즘

 

먼저 주어진 데이터의 조건을 확인한다. 여기서 말하는 조건이란 한 마디의 최소 샘플 수, 최대 깊이이며 분류나무의 경우 주어진 데이터의 라벨이 하나인 조건이 추가된다. 이때 조건을 만족한 노드의 분리를 멈춘다. 만약 해당 노드가 왼쪽(오른쪽) 노드라면 오른쪽(왼쪽) 데이터에 대해서 동일한 과정을 반복한다. 

 

만약 조건을 만족하지 않는 경우 모든 설명 변수에 대하여 분리기준 후보를 생성한다. 그런 다음 불순도 측도를 이용하여 최적 분리기준을 찾고 이를 통하여 데이터를 분리한다. 이 경우 데이터는 왼쪽 데이터와 오른쪽 데이터로 나누어지고 이 2개의 데이터에 대해서 동일한 과정을 반복한다.

 

- 의사결정나무 알고리즘 구현하기 -

이제 의사결정나무를 파이썬을 이용하여 구현해보자~!! 내용이 길어서 다음과 같이 나누었다.

 

3.1 모듈 임포트, 부가적인 함수 및 클래스 정의

3.2 메인 클래스 정의


3.1 모듈 임포트, 부가적인 함수 및 클래스 정의

먼저 이번 포스팅에서 사용할 모듈을 임포트해준다.

 

from graphviz import *
from sklearn.datasets import load_iris
from sklearn import tree

from collections import Counter
from itertools import chain, combinations

import ast
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np 
import graphviz
import seaborn as sns

 

먼저 의사결정나무의 기본이 되는 마디(Node) 클래스를 정의한다. 상황에 따라서 마디와 노드 두 가지를 혼용해서 쓰겠다. 해당 클래스는 노드 식별 아이디(nodeId), 노드에 표시할 텍스트(label) 등을 초기화해준다. 여기서 level 필드는 노드가 속한 층으로 뿌리 노드의 경우 level은 1이다. 이 필드를 추가한 이유는 나중에 각 층마다 노드 배경색을 다르게 해 주기 위함이다.

 

class Node:
    def __init__(self,nodeId, label, isRoot=False,parentNode=None,
               leftNode=None,rightNode=None,isTerminal=False, 
                 attr={}):
        self.nodeId = nodeId ## 노드 식별 아이디
        self.label = label ## 노드 텍스트
        self.attr = attr ## 노드 스타일 속성
        self.isRoot = isRoot ## 루트 노드 여부
        self.parentNode = parentNode ## 부모 마디(노드)
        self.leftNode = leftNode ## 왼쪽 자식 노드(마디)
        self.rightNode = rightNode ## 오른쪽 자식 노드
        self.isTerminal = isTerminal ## 터미널 노드 여부
        self.level = 0 ## 노드가 속한 층

 

다음은 의사결정나무를 graphviz를 이용하여 시각화해주는 함수이다. 이 함수는 먼저 뿌리 마디를 그려주고 왼쪽 마디와 오른쪽 마디를 지나면서 마디의 스타일을 적용하고 화살표를 부모와 연결시켜준다. graphviz 사용법은 추후 포스팅하겠다. 

 

def visualize_tree(tree):
    def add_node_edge(tree, dot=None):
        if dot is None:
            dot = Digraph()
#             name = tree
            dot.node(name = str(tree.nodeId), label = str(tree.label), **tree.attr)

        ## left
        if tree.leftNode:
            dot.node(name=str(tree.leftNode.nodeId),label=str(tree.leftNode.label),
                     **tree.leftNode.attr) 
            dot.edge(str(tree.nodeId), str(tree.leftNode.nodeId),
                     **{'taillabel':"yes",'labeldistance':'2.5'})
            dot = add_node_edge(tree.leftNode, dot)
            
        if tree.rightNode:
            dot.node(name=str(tree.rightNode.nodeId),label=str(tree.rightNode.label),
                     **tree.rightNode.attr)
            dot.edge(str(tree.nodeId), str(tree.rightNode.nodeId),
                    **{'headlabel':" no",'labeldistance':'2'})
            dot = add_node_edge(tree.rightNode, dot)

        return dot
        
    dot = add_node_edge(tree)
    
    return dot

 

다음은 rgb 색상을 16진수로 바꿔주는 함수이다. 여기에서 고수님이 만든 코드가 있어서 가져왔다.

 

def RGBtoHex(vals, rgbtype=1):
    """Converts RGB values in a variety of formats to Hex values.

     @param  vals     An RGB/RGBA tuple
     @param  rgbtype  Valid valus are:
                          1 - Inputs are in the range 0 to 1
                        256 - Inputs are in the range 0 to 255

     @return A hex string in the form '#RRGGBB' or '#RRGGBBAA'
    """

    if len(vals)!=3 and len(vals)!=4:
        raise Exception("RGB or RGBA inputs to RGBtoHex must have three or four elements!")
    if rgbtype!=1 and rgbtype!=256:
        raise Exception("rgbtype must be 1 or 256!")

    #Convert from 0-1 RGB/RGBA to 0-255 RGB/RGBA
    if rgbtype==1:
        vals = [255*x for x in vals]

    #Ensure values are rounded integers, convert to hex, and concatenate
    return '#' + ''.join(['{:02X}'.format(int(round(x))) for x in vals])

 

다음은 입력값이 정수인지 아닌지 출력하는 함수이다.

 

def is_integer_num(n):
    if isinstance(n, int):
        return True
    if isinstance(n, float):
        return n.is_integer()
    return False

3.2 메인 클래스 정의

이제 주인공이 등장할 차례다. 이 부분은 코드가 꽤 길어서 중복되는 부분은 생략하고 설명하겠다. 먼저 DecisionTree 클래스를 정의한다. 인스턴스 생성 시 분류나무인지 회귀나무인지 정해주는 tree_type인자를 입력하도록 했다.

 

class DecisionTree:
    def __init__(self, tree_type='classification',):
        tree_types = ['classification','regression']
        assert tree_type in tree_types, f'tree_type must be the one of the {tree_types}'
        self.tree_type = tree_type ## 트리 유형
        self.impurity_measure = None ## 불순도 측도
        self.root = None ## 트리 노드
        self.node_id = 0 ## 노드 아이디
        self.col_names = None ## 칼럼 이름
        self.col_types = None ## 변수 타입
        self.X = None ## train data X
        self.y = None ## train data y
        self.leaf_attr = None ## 끝마디 스타일 속성

 

다음으로 의사결정나무의 모든 마디를 돌 수 있는 traverseInOrder 함수를 정의했다. 이는 의사결정나무의 깊이를 계산할 때 필요하며 깊이는 getDepth를 이용하여 계산한다. 또한 각 노드가 몇 층에 있는지 확인해보기 위해 getLevel 함수도 정의하였다.

 

class DecisionTree:
        
    '''
    중략
    '''
    
    def traverseInOrder(self, node):
        res = []
        if node.leftNode != None:
            res = res + self.traverseInOrder(node.leftNode)
        res.append(node)
        if node.rightNode != None:
            res = res + self.traverseInOrder(node.rightNode)
        return res
    
    def getDepth(self, root):
        res = self.traverseInOrder(root)
        res = [abs(node.level) for node in res]
        return max(res)
    
    def getLevel(self, node, counter = 1):
        if node.parentNode is None:
            return counter
        else:
            counter += 1
            counter = self.getLevel(node.parentNode,counter)
        return counter

 

다음은 변수의 타입을 자동으로 설정하는 함수이다. 변수 타입을 수동으로 하기 귀찮을 때 쓰면 좋다. 여기서는 디폴트로 해당 변수의 유니크한 원소 개수가 15개 이하라면 범주형으로 아닌 경우 연속형으로 지정했다.

 

class DecisionTree:
        
    '''
    중략
    '''
    
    def determineTypeOfCol(self,X, num_unique_values_threshold=15):
        col_types = []
        for col in X.columns:
            unique_values = X[col].unique()
            example_value = unique_values[0]
            
            if (isinstance(example_value, str)) or (len(unique_values) <= num_unique_values_threshold):
                col_types.append('categorical')
            else:
                col_types.append('continuous')
        self.col_types = col_types

 

다음으로 마디에 포함된 데이터 라벨이 하나인지 아닌지 알려주는 isPure, 자식 마디의 불순도를 계산하는 impurity, 개별 마디에 포함된 불순도를 계산하는 individualImpurity, 그리고 엔트로피와 지니 지수 그리고 제곱 평균오차를 계산하는데 필요한 _entropy, _gini, _mse를 정의하였다. 마지막으로 끝마디를 생성하는 createLeaf 도 정의하였다.

 

class DecisionTree:
        
    '''
    중략
    '''
    
    def isPure(self,y):
        if len(np.unique(y)) > 1:
            return False
        return True
    
    def impurity(self, left_y, right_y):
        y = self.y
        n = len(left_y)+len(right_y)
        
        if self.impurity_measure == 'chi2':
            try:
                label = np.unique(y)
                contingency_table = dict()
                expected_table = dict()
                for l in label:
                    temp1 = []
                    temp1.append(np.sum(left_y==l))
                    temp1.append(np.sum(right_y==l))
                    contingency_table[l] = temp1
                    temp2 = []
                    temp2.append((np.sum(left_y==l) + np.sum(right_y==l))*len(left_y)/n)
                    temp2.append((np.sum(left_y==l) + np.sum(right_y==l))*len(right_y)/n)
                    expected_table[l] = temp2

                observed = np.array([v for k,v in contingency_table.items()]).flatten()
                expected = np.array([v for k,v in expected_table.items()]).flatten()
                impurity_val = np.nansum(np.square(observed-expected)/expected)
            except RuntimeWarning:
                raise
        else:
            pl, pr = len(left_y)/n, len(right_y)/n
            impurity_val = pl*self.individualImpurity(left_y)+\
                            pr*self.individualImpurity(right_y)
        return impurity_val
            
    def individualImpurity(self, y):
        if self.impurity_measure == 'entropy':
            return self._entropy(y)
        elif self.impurity_measure == 'gini':
            return self._gini(y)
        elif self.impurity_measure == 'mse':
            return self._mse(y)
    
    def _entropy(self, y):
        _, counts = np.unique(y, return_counts=True)
        ps = counts / len(y)
        return -np.sum([p * np.log2(p) for p in ps if p > 0])
    
    def _gini(self, y):
        _, counts = np.unique(y, return_counts=True)
        ps = counts / len(y)
        return np.sum([p*(1-p) for p in ps if p > 0])
    
    def _mse(self, y):
        if len(y) == 0:
            return 0
        mse = np.mean(np.square(y-np.mean(y)))
        return mse
    
    def createLeaf(self, y, tree_type):
        if tree_type == 'classification':
            classes, counts = np.unique(y, return_counts=True)
            index = counts.argmax()
            return classes[index]
        else:
            return np.mean(y)

 

이제 분리 기준 후보를 만들고 최적 분리 기준을 선택하는 과정에서 필요한 함수들이다. powerset_generator 함수는 변수가 범주형일 때 공집합과 전체집합을 제외한 모든 부분집합을 구해준다. splitSet 함수는 변수가 범주형일 때 분리 기준 후보를 만들어준다. 여기서는 하나의 부분집합을 알면 나머지 여집합은 필요가 없으므로 실제 필요한 분리 기준은 powerset_generator에서 구해준 모든 부분집합 중 절반만 필요하다는 것을 주목하자. getPotentialSplits 함수는 모든 변수에 대한 분리 기준 후보를 생성한다. 변수 인덱스를 키, 변수의 분리 기준 후보 리스트를 값으로 하는 딕셔너리를 리턴한다. split은 해당 변수와 분리 기준을 이용하여 왼쪽 마디, 오른쪽 마디에 각각 들어갈 데이터 인덱스를 리턴한다. 마지막으로 determinBestSplit은 getPotentialSplits가 생성한 분리 기준을 통하여 최적 변수와 그에 대응하는 분리 기준, 그리고 불순도 측도 값을 리턴한다.

 

class DecisionTree:
        
    '''
    중략
    '''
    
    def powerset_generator(self, i):
        for subset in chain.from_iterable(combinations(i, r) for r in range(len(i)+1)):
            yield set(subset)
        
    def splitSet(self, x):
        ps = [i for i in self.powerset_generator(x) if i != set() and len(i) != len(x)]
        idx = int(len(ps)/2)
        split_set = []
        for j in range(idx):
            split_set.append(tuple(ps[j]))
        return split_set
        
    def getPotentialSplits(self,X):
        potential_splits = {}
        col_types = self.col_types
        for col_idx in range(X.shape[1]):
            unique_value = np.unique(X[:,col_idx])
            if col_types[col_idx] == 'continuous':
                potential_splits[col_idx] = unique_value
            else:
                potential_splits[col_idx] = self.splitSet(unique_value)
        return potential_splits
    
    def split(self,X, col_idx, threshold):
        X_col = X[:,col_idx]
        col_types = self.col_types
        if col_types[col_idx] == 'continuous':
            left_idx = np.argwhere(X_col<=threshold).flatten()
            right_idx = np.argwhere(X_col>threshold).flatten()
        else:
            left_idx = np.argwhere(np.isin(X_col,threshold)).flatten()
            right_idx = np.argwhere(~np.isin(X_col,threshold)).flatten()
        return left_idx, right_idx
    
    def determinBestSplit(self, X, y, potential_splits):
        best_split_column, best_split_value, opt_impurity = '', '', ''
        if self.impurity_measure in ['entropy','gini','mse']: 
            opt_impurity = np.infty
            for col in potential_splits:
                for val in potential_splits[col]:
                    left_idx, right_idx = self.split(X,col,val)
                    cur_impurity = self.impurity(y[left_idx],y[right_idx])
                    if cur_impurity <= opt_impurity:
                        opt_impurity = cur_impurity
                        best_split_column = col
                        best_split_value = val
        else:
            opt_impurity = -np.infty
            for col in potential_splits:
                for val in potential_splits[col]:
                    left_idx, right_idx = self.split(X,col,val)
                    cur_impurity = self.impurity(y[left_idx],y[right_idx])
                    if cur_impurity >= opt_impurity:
                        opt_impurity = cur_impurity
                        best_split_column = col
                        best_split_value = val

        return best_split_column, best_split_value, opt_impurity

 

다음으로 fit 함수는 의사결정나무 모형을 적합시키는 함수이며 내부 함수 _growTree를 통하여 의사결정나무를 성장시키는 구조로 만들었다. 최종 의사결정나무는 DecisionTree 클래스에서 root 필드에 넣어주었다. 나무를 성장시킨 뒤에는(line 42~) 각 마디에 층을 계산하고 층별로 마디에 배경색을 지정한다. 그리고 분류나무인 경우 끝마디에 예측 라벨을 포함하는 데 이때 같은 라벨인 경우에는 같은 스타일을 갖도록 지정했다. 그리고 나서 나무의 각 층별로 마디의 스타일을 지정했다.

 

class DecisionTree:
        
    '''
    중략
    '''
    
    def fit(self,X,y,impurity_measure='entropy',min_sample=5, max_depth=5, 
            type_of_col=None, auto_determine_type_of_col=True,
            num_unique_values_threshold = 15
           ):
        '''
        impurity_measure : 불순도 측도
        min_sample : 노드가 포함해야하는 최소 샘플 개수,
        max_depth : 나무 최대 깊이 설정
        type_of_col : 변수 타입 리스트
        auto_determine_type_of_col : 변수 타입 자동 생성 여부
        num_unique_values_threshold : 범주형으로 지정할 최대 유니크 원소 개수
        '''   
           
        self.X = X
        self.y = y
        if auto_determine_type_of_col:
            self.determineTypeOfCol(X, num_unique_values_threshold)
        else:
            if type_of_col is None:
                raise ValueError('When auto_determine_type_of_col is False, then type_of_col must be specified')
            assert X.shape[1] == len(type_of_col), 'type_of_col has the same length of X columns'
            give_type_of_col = list(set(type_of_col))
            for toc in give_type_of_col:
                if toc != 'categorical' and toc != 'continuous':
                    raise ValueError('type_of_col must contain categorical or continuous')
            self.col_types = type_of_col
            
        tree_type = self.tree_type
        impurity_measures = ['entropy','gini','chi2'] if tree_type == 'classification' else ['mse']
        assert impurity_measure in impurity_measures,\
                f'impurity_measure must be the one of the {impurity_measures}'
        self.impurity_measure = impurity_measure
        tree_type = self.tree_type
        self.root = self._growTree(X,y,tree_type,min_sample=min_sample, max_depth=max_depth)
        
        ### assign node a style
        iod = self.traverseInOrder(self.root)
        root_node = [node for node in iod if node.nodeId == 1][0]
        root_node.isRoot = True
        
        ## set node level
        for nd in iod:
            nd.level = self.getLevel(nd)
        
        colors = sns.color_palette('hls', self.getDepth(self.root))
        
        ## set node level
        if tree_type == 'classification':
            leaf_color = sns.color_palette('pastel', len(np.unique(y)))
            leaf_attr = dict()
            for i, l in enumerate(np.unique(y)):
                leaf_attr[l] = {'shape':'box', 'color':f'{RGBtoHex(leaf_color[i])}', 
                                       'fontcolor':f'{RGBtoHex(leaf_color[i])}','peripheries':'2'}
            self.leaf_attr = leaf_attr
        for l in range(1,self.getDepth(self.root)+1):
            color = RGBtoHex(colors[l-1])
            for nd in iod:
                if nd.level == l:
                    if nd.isTerminal:
                        if tree_type == 'classification':
                            nd.attr =  leaf_attr[nd.label]
						else:
                            nd.attr = {'shape':'box','peripheries':'2'}
                    else:
                        nd.attr = {'shape':'box','style':'filled',
                                   'fillcolor':f'{color}'}

 

_growTree 함수는 의사결정나무를 성장시킨다. 이 부분은 중요하므로 자세히 살펴보자.

 

class DecisionTree:
        
    '''
    중략
    '''
 
    def _growTree(self, X, y, tree_type, counter=0, min_sample=5, max_depth=5):
        self.node_id += 1
        
        if counter == 0:
            global col_names

            col_names = X.columns
            self.col_names = X.columns
            if isinstance(X, pd.DataFrame):
                X = X.values
            if isinstance(y, pd.Series):
                y = y.values
        else:
            X = X
        if (self.isPure(y)) or (len(y) <= min_sample) or (counter == max_depth):
            leaf = self.createLeaf(y, tree_type)
            if isinstance(leaf, float):
                if not leaf.is_integer():
                    leaf = round(leaf,2)
            return Node(self.node_id, label=leaf, isTerminal=True)

        else:
            counter += 1
            potential_splits = self.getPotentialSplits(X)
            best_split_column, best_split_value, opt_impurity =\
                self.determinBestSplit(X, y, potential_splits)
            opt_impurity = round(opt_impurity,4)
            left_idx, right_idx = self.split(X,best_split_column,best_split_value)
            
            ## check for empty data
            if len(left_idx) == 0 or len(right_idx) == 0:
                leaf = self.createLeaf(y, tree_type)
                if isinstance(leaf, float):
                    if not leaf.is_integer():
                        leaf = round(leaf,2)
                return Node(self.node_id, label=round(leaf,4), isTerminal=True)
            
            total_sample = len(y)
            
            col_name = col_names[best_split_column]
            if self.col_types[best_split_column] == 'continuous':
                question = f'{col_name} <= {best_split_value}\n'+\
                            f'{self.impurity_measure} : {opt_impurity}\n'+\
                            f'Samples : {total_sample}'
            else:
                question = f'{col_name} in {best_split_value}\n'+\
                            f'{self.impurity_measure} : {opt_impurity}\n'+\
                            f'Samples : {total_sample}'

            node = Node(self.node_id, label=question)
            
            left_child = self._growTree(X[left_idx,:],y[left_idx],tree_type,counter, min_sample, max_depth)  
            right_child = self._growTree(X[right_idx,:],y[right_idx],tree_type,counter, min_sample, max_depth)
            

            if left_child.label == right_child.label:
                node = left_child
            else:
                node.leftNode = left_child
                node.rightNode = right_child
                left_child.parentNode = node
                right_child.parentNode = node

            return node

 

line 10~18

먼저 첫 번째 호출에서는 칼럼명을 col_names 필드에 저장하고 X를 2d numpy array로 바꿔준다. 마찬가지로 y도 1d numpy array로 바꿔준다.

 

line 21~26

그리고 해당 마디 안에 클래스가 하나밖에 없거나 샘플의 개수가 min_sample 이하일 때, 또는 나무의 깊이가 max_depth에 도달하면 성장을 멈추고 끝마디를 생성한다. 

 

line 29~34

counter는 하나씩 증가시켜준다. 이는 나무의 깊이를 확인하는데 필요하다. 그리고 모든 설명변수에 대해서 분리 기준 후보를 구하고 이를 이용하여 최적 변수와 분리 기준을 구한다. 해당 마디에서 불순도 측도값을 확인하기 위해 이를 opt_impurity에 저장한다. 그리고 최적 변수와 분리 기준을 이용하여 해당 마디에 속하는 y 데이터를 분리한다.

 

line 37~42

y 데이터를 분리한 후 데이터 어느 한쪽에 데이터가 없을 경우 끝마디를 생성한다. 회귀나무의 경우 마디에 표시할 텍스트가 숫자이므로 너무 길어지지 않게 하기 위해 소수 둘째 자리만 표시하도록 했다.

 

line 44~54

현재 마디에 속한 총 샘플 개수와 최적 변수의 칼럼명을 저장하고 노드에 표시할 텍스트를 해당 변수가 연속형인 경우와 범주형인 경우로 나누어서 설정한다. 연속형인 경우는 분리 기준보다 작거나 큰 것으로 나뉘므로 '<='을 포함시켰고 범주형은 분리 기준이 집합이 되므로 'in'을 포함시켰다.

 

line 56~59

중간 마디를 생성한 후 왼쪽 마디와 오른쪽 마디를 생성한다.

 

line 62~68

먼저 왼쪽 마디(left_child)와 오른쪽 마디(right_child)의 예측 결과가 같은 경우는 굳이 분리하지 않아도 되므로 이때에는 중간 마디를 왼쪽 끝마디로 대체한다. 그게 아니라면 왼쪽 마디와 오른쪽 마디의 부모를 중간 마디(node)로 설정한다.

 

이제 만들어진 의사결정나무를 가지고 새로운 데이터에 대하여 예측을 수행하는 함수를 정의했다. 예측은 predict를 통하여 할 수 있으며 predict 내부에 _traverse_tree 함수를 통하여 예측하는 구조로 되어있다. _traverse_tree는 끝마디에 도달하면 그 마디의 예측값을 리턴하고 끝마디가 아닌 경우는 노드에 포함된 질문에 따라서 _traverse_tree 함수를 재귀적으로 호출한다.

 

class DecisionTree:
        
    '''
    중략
    '''
    
    def predict(self,X):
        return np.array([self._traverse_tree(x, self.root) for _, x in X.iterrows()])
    
    def _traverse_tree(self, x, node):
        if node.isTerminal:
            if isinstance(node.label, str):
                node.label = node.label.replace('\n','')
            return node.label
        
        question = node.label.split('\n')[0]
        
        if ' <= ' in question:
            col_name, value = question.split(' <= ')
            if x[col_name] <= float(value):
                return self._traverse_tree(x, node.leftNode)
            return self._traverse_tree(x, node.rightNode)
        else:
            col_name, value = question.split(' in ')
            if x[col_name] in ast.literal_eval(value):
                return self._traverse_tree(x, node.leftNode)
            return self._traverse_tree(x, node.rightNode)
반응형

   4. 예제 with Python

이제 앞에서 구현한 것과 실제 데이터를 이용하여 의사결정나무를 만들어보자. 여기에서는 sklearn 라이브러리에서 분류나무와 회귀나무를 만들어주는 DecisionTreeClassifier와 DecisionTreeRegressor 클래스의 사용법도 알아보고 결과를 비교해보고자 한다.

 

여기서 다룰 데이터는 그 유명한 붓꽃(Iris) 데이터, 타이타닉 데이터, 자전거 공유 데이터이다. 데이터는 아래에 첨부해 두었다(iris 데이터는 모듈에서 불러올 수 있다)

 

- 타이타닉 데이터 -

titanic.csv
0.06MB
titanic_description.txt
0.00MB

 

- 자전거 공유 데이터 -

bike_share.csv
0.05MB
bike_share_description.txt
0.00MB


1. 붓꽃 데이터

먼저 데이터를 불러오자. 이때 붓꽃 종류는 species 열에 넣어두었다.

 

iris = load_iris()
df = pd.DataFrame(iris.data, columns=iris.feature_names)
df['species'] = [iris.target_names[x] for x in iris.target]

 

 

여기서는 붓꽃 클래스를 분류하는 문제이므로 분류나무를 만들 것이다. 최대 깊이는 3으로 지정했고 4개 변수가 모두 연속형이므로 type_of_col 인자에 'continuous'가 4개 포함된 리스트를 넘겨주었다. 타입은 수동으로 지정했으므로 auto_determine_type_of_col은 False로 했다.

 

X, y = df.iloc[:,:4], df['species'].values
clf = DecisionTree()
clf.fit(X,y, max_depth=3, type_of_col=['continuous']*4,
        auto_determine_type_of_col=False)

 

이제 만들어진 나무를 눈으로 확인해볼 시간이다. 혹시 오류가 나는 사람들은 graphviz를 설치하고 환경변수를 따로 설정해줘야 한다. 이에 대한 방법은 여기를 참고하자.

 

visualize_tree(clf.root)

 

 

나무가 잘 완성되었다. 참고로 위 시각화 결과를 그림파일로 저장하고 싶다면 다음과 같이 해주자. 아래 코드는 결과를 현재 위치에 'iris_tree.png'로 저장해주는 것이다.

 

dot = visualize_tree(clf.root)
dot.format = 'png'
dot.render(filename='iris_tree', directory='./', cleanup=True)

 

 

이 나무의 훈련데이터 정확도를 알아보자(테스트 데이터가 아니다). 

 

np.sum(y == clf.predict(X))/len(y)

 

 

97.3%가 나왔다. 다시 한번 말하지만 이는 테스트 데이터를 이용한 것은 아니다. 따라서 실제 모형 성능 평가 시에는 훈련데이터와 테스트 데이터를 나눠야 할 것이다. 이번엔 sklearn을 이용하여 나무를 생성해보자.

 

sklearn.tree.DecisionTreeClassifier 클래스를 이용하여 분류나무를 생성할 수 있다. 사용법은 인스턴스 생성시 불순도 측도 criterion, 최대 깊이 max_depth, 자식 마디의 최소 샘플수 min_samples_split을 지정한다. 그리고 fit을 통하여 분류나무를 생성한다.

 

X, y = iris.data, iris.target
clf = tree.DecisionTreeClassifier(criterion='entropy', max_depth=3, min_samples_split=5)
clf = clf.fit(X, y)

 

여기서 만들어진 나무가 앞서 구현한 나무와 성능이 같은지 확인해보았다.

 

np.sum(y == clf.predict(X))/len(y)

 

 

정확하게 일치하였다. sklearn에서는 tree.export_graphviz를 통하여 시각화할 수 있다. 

 

dot_data = tree.export_graphviz(clf, out_file=None, 
                     feature_names=iris.feature_names,  
                     class_names=iris.target_names,  
                     filled=False, rounded=True,  
                     special_characters=False)  
graph = graphviz.Source(dot_data)  
graph 

 

만약 결과를 그림파일로 저장하고 싶다면 아래 코드를 추가한다.

 

graph.format = 'png'
graph.render(filename='titanic_tree_sk', directory='./', cleanup=True)

 

 

엔트로피 계산이 조금 다르게 나왔다. 이는 나중에 확인해봐야겠다. 그리고 분리기준이 다른데 나는 실제 데이터 값을 사용했고 sklearn에서는 인접한 두 값의 중간값을 사용했기 때문이다. 여기서 주목해야 할 부분은 위 빨간 네모 안에 있는 3개의 마디가 모두 'virginica'으로 예측한다는 것 이다. 이 경우 사실 분리할 필요가 없고 이 3개 노드를 그냥 하나의 끝마디로 해도 무방하다. 앞에서 살펴본 나무가 바로 이러한 점을 반영한 것이다(앞의 그림에서 확인해보자).

 

붓꽃 데이터를 통해 확인해본 결과 앞서 구현한 의사결정나무가 잘 작동하는 것 같다.

반응형

2. 타이타닉 데이터

먼저 데이터를 불러온다.

 

df = pd.read_csv('../dataset/titanic.csv')

 

 

이 데이터에는 결측치가 포함되어 있다. 제거하는 대신 Age은 중간값, Embark는 최빈값으로 채웠다.

 

median_age = df['Age'].median()
mode_embarked = df['Embarked'].mode()[0]
df = df.fillna({'Age':median_age, 'Embarked':mode_embarked})

 

이제 분류나무를 만들어보자. 최대 깊이는 4로 설정하였다. 여기서는 변수의 타입을 자동으로 설정(디폴트)했다.

 

X = df[['Pclass', 'Sex', 'Age', 'SibSp', 'Parch', 'Fare', 'Embarked']]
y = df['Survived'].values

clf = DecisionTree()
clf.fit(X,y,max_depth=4)

 

시각화해보자.

 

visualize_tree(clf.root)

 

 

이제 훈련데이터를 통하여 훈련 정확도를 체크해보자.

 

np.sum(y == clf.predict(X))/len(y)

 

 

훈련 정확도는 83.8% 정도가 된다. 이번엔 sklearn을 이용해보자. sklearn 문서에 따르면 sklearn에서는 변수가 범주형인 경우를 구현하지 않았다고 한다. ㅠ.ㅠ

 

 

하루빨리 구현되었으면 좋겠다. (범주를 숫자로 바꿔서 해볼 수 있겠지만 완벽하지 않다)

 

따라서 연속형 변수만 따로 뽑아주었다.

 

X = df[['Age', 'Fare']]
y = df['Survived']

 

나무를 만들어보자. tree.DecisionTreeClassifier를 이용하여 분류나무 만드는 법은 앞에서 설명했으므로 생략한다.

 

clf = tree.DecisionTreeClassifier(max_depth=4, min_samples_split=5)
clf = clf.fit(X, y)

 

정확도를 확인해보자.

 

np.sum(y == clf.predict(X))/len(y)

 

 

70.9%가 나왔다. 모든 변수를 고려하지 않았기 때문에 앞에서 살펴본 83.8% 보다 낮은 것은 당연하다. 나무를 시각화해보자. 

 

dot_data = tree.export_graphviz(clf, out_file=None, 
                     feature_names=X.columns,  
                     class_names=[str(x) for x in np.unique(y)],  
                     filled=False, rounded=True,  
                     special_characters=False)  
graph = graphviz.Source(dot_data)  
graph 

 

 

나는 이 경우에도 내가 구현한 모형이 정확하게 일치하는지 확인하고 싶었다.  앞에서 지니 지수를 사용했으므로 이번엔 불순도의 측도를 지니 지수로 했으며 최대 깊이도 동일하게 4로 설정하였다.

 

myclf = DecisionTree()
myclf.fit(X,y,impurity_measure='gini',type_of_col=['continuous']*2, max_depth=4)

 

 

정확도를 확인해보았다.

 

 

앞에 결과와 정확하게 소수점 한자리도 틀리지 않고 정확하다. 하는 김에 시각화도 해보았다.

 


3. 자전거 공유 데이터

이번엔 회귀나무를 만들 것이다. 데이터를 불러오자.

 

df = pd.read_csv('../dataset/bike_share.csv')

 

 

날짜 정보를 좀 더 효율적으로 사용하기 위하여 아래와 같이 추가적인 변수를 만들어 준다.

 

df = df.rename(columns = {"dteday": "date"})

date_column = pd.to_datetime(df.date)

df["day_of_year"] = date_column.dt.dayofyear
df["day_of_month"] = date_column.dt.day

df["quarter"] = date_column.dt.quarter
df["week"] = date_column.dt.isocalendar()['week']

df["is_month_end"] = date_column.dt.is_month_end
df["is_month_start"] = date_column.dt.is_month_start
df["is_quarter_end"] = date_column.dt.is_quarter_end
df["is_quarter_start"] = date_column.dt.is_quarter_start
df["is_year_end"] = date_column.dt.is_year_end
df["is_year_start"] = date_column.dt.is_year_start
df.set_index('date', inplace=True)
df['response'] = df['cnt']
df.drop(['cnt','instant','registered'],axis=1,inplace=True)

 

이번엔 데이터 셋을 나누었다.

 

## split data
train_df = df.iloc[:-61]
test_df = df.iloc[-61:]     # Nov and Dec of 2012

 

회귀나무를 만들어보자.

 

X = train_df.drop('response',axis=1)
y = train_df['response']
type_of_col = []
for col in X.columns:
    if col not in ['temp', 'atemp', 'hum', 'windspeed']:
        type_of_col.append('categorical')
    else:
        type_of_col.append('continuous')
    
reg = DecisionTree(tree_type = 'regression')
reg.fit(X,y,impurity_measure='mse',max_depth=3)

 

만들어진 회귀나무는 직접 봐야 속이 후련할 것이다. 

 

 

테스트 데이터를 통해 모형의 성능을 알아보자. 성능의 측도는 오차제곱합으로 선택하였다.

 

X_test = test_df.drop('response',axis=1)
y_test = test_df['response']

np.mean(np.sum(np.square(y_test - reg.predict(X_test))))

 

 

120,894,567 정도가 나왔다. 이번엔 sklearn으로 회귀나무를 만들어보자. sklearn.tree.DecisionTreeRegressor를 이용하여 만들 수 있다. 여기에서도 연속형 변수만 가져왔다.

 

## split data
df = df[['temp', 'atemp', 'hum', 'windspeed', 'casual','response']]
train_df = df.iloc[:-61]
test_df = df.iloc[-61:]     # Nov and Dec of 2012

X = train_df.drop('response',axis=1)
y = train_df['response']

 

DecisionTreeRegressor 클래스의 인스턴스를 생성한다. 여기서는 최대 깊이 3, 최소 자식 마디에 포함된 샘플 수는 5로 지정했다.

 

 

regressor = tree.DecisionTreeRegressor(max_depth=3, min_samples_split=5) 
regressor.fit(X, y)

 

오차제곱합을 계산해보면 다음과 같다.

 

X_test = test_df.drop('response',axis=1)
y_test = test_df['response']

np.mean(np.sum(np.square(y_test - regressor.predict(X_test))))

 

 

오차 제곱합은 216,335,303로 계산되었다. 이는 앞에서 모든 변수를 다 고려한 경우의 오차 제곱합보다 두배 정도로 높은 것이다. 이제 시각화해보자.

 

dot_data = tree.export_graphviz(regressor, out_file=None, 
                     feature_names=X.columns,
                     filled=False, rounded=True,  
                     special_characters=False)  
graph = graphviz.Source(dot_data)  
graph 

 

 

이번에도 내가 만든 모형의 결과가 sklearn을 사용한 결과와 일치하는지 확인해보았다.

 

reg = DecisionTree(tree_type = 'regression')
reg.fit(X,y,impurity_measure='mse',max_depth=3)

 

마찬가지로 오차 제곱합을 구해주었다.

 

np.mean(np.sum(np.square(y_test - reg.predict(X_test))))

 

 

값이 살짝 다르다. 내가 구현한 코드에서는 회귀 나무의 예측값을 소수점 2번째 자리까지 나타내도록 했는데 이러한 차이로 인하여 값이 다르게 나온 것이다. 시각화 결과를 비교해보면 나무는 동일한 것을 알 수 있다.

 


이번 포스팅에서는 의사결정나무의 정의, 불순도 측도, 나무 생성 알고리즘 그리고 파이썬을 이용하여 구현해보고 이를 실제 데이터에 적용해보았다. 또한 sklearn을 이용하여 나무를 만드는 방법도 알아보았다. 이번 포스팅은 특히 힘들었다. 의사결정나무를 만드는 방법은 유투브에 도움을 많이 받았지만 시각화에서 삽질을 많이했기 때문이다. 내가 직접 시각화를 해야겠다고 생각한 이유는 R이나 sklearn에서 제공하는 나무 그림이 맘에 들지 않아서다.. 그래도 이번 기회에 시각화도 해보고 의사결정나무를 이론적으로 공부한 것과 더불어 구현까지 해봐서 뜻깊은 시간이었다.

 

다음 포스팅에서는 가지치기를 소개하려고 한다. 물론 내 관심이 다른 곳에 생기면 다른 주제로 포스팅할 수 있다 ㅎㅎ;;

 

 

참고자료

Decision Tree : Gini Index vs Entropy - https://quantdare.com/decision-trees-gini-vs-entropy/

김용대 외 4명 R을 이용한 데이터마이닝

Decision Tree in Python - https://www.youtube.com/channel/UCbXgNpp0jedKWcQiULLbDTA

Coding a Decision Tree from Scratch in Python - https://www.youtube.com/channel/UCCtkE-r-0Mvp7PwAvxzSdvw

Decision Tree - https://scikit-learn.org/stable/modules/tree.html

댓글


맨 위로