12장 결정 트리 만들기#

자료 출처: ISLP (An Introduction to Statistical Learning with Applications in Python)

이 장에서는 (의사)결정 트리(decision tree)와 관련된 통계적 학습 기법을 설명한다. 결정 트리는 회귀(regression)와 분류(classification) 모두에 사용될 수 있다. 트리 기법은 선형 회귀나 로지스틱 회귀 등 전통적인 회귀 및 분류 기법과는 접근법이 약간 다르다. 사실 (트리를 어떻게 만드는지를 논외로 한다면) 트리를 이용한 회귀나 분류가 전통적 기법에 비해 훨씬 직관적이고 이해하기 쉽다.

12.1 결정 트리 소개#

우선 회귀 트리(regression tree)를 사용한 데이터 분석 사례를 통해 트리가 무엇인지, 그리고 몇 가지 관련 용어들을 익히기로 하자.

Hitters 데이터세트에는 미국 메이저리그 프로야구 선수 263명의 연봉과 관련된 정보들이 들어있다. 여러 변수 중 Years(메이저리그 연차)와 Hits(전년도 안타 개수) 두 개만 사용하여 선수들의 연봉을 예측하는 모델을 만들고자 한다. 연봉 변수는 Salary인데, 여기에서는 원래의 연봉(단위: 1,000달러)에 자연로그를 취한 값을 사용한다.

만약 선형 회귀 모델로 선수들의 연봉을 예측한다면, 그 결과는 다음과 같은 형태일 것이다.

\[\widehat{\text{Salary}} = \hat \beta_0 + \hat \beta_1 \text{Years}+ \hat \beta_2 \text{Hits}\]

그런데 회귀 트리에 의한 예측은 일단 표현 방식이 위의 선형 회귀와는 많이 다르다. 아래 그림 12.1이 본 예제에 대한 회귀 트리의 결과를 보여준다. 주어진 데이터(즉 Years, Hits, Salary 변수)를 사용해 선수들의 연봉을 예측하는 간단한 결정 트리를 도출한 것으로서 추정 결과가 식(equation)이 아니라 그림, 즉 트리의 형태로 주어진다는 점이 특징이다.

우선 그림 12.1에 나와 있는 트리의 의미를 생각해보면, 맨 위에 나와 있는 Years<4.5는 결정 경계를 나타내는데, 메이저리그에서 뛴 연차가 4.5년 미만이면 왼쪽 가지(branch)이고, 4.5년 이상이면 오른쪽 가지에 해당한다는 의미이다. 마찬가지로 그 아래 있는 Hits<117.5 역시 결정 경계로서 전년도 안타수가 117.5개 미만이면 왼쪽이고, 117.5개 이상이면 오른쪽이다. 그리고 맨 끝에 나와 있는 세 개의 숫자, 즉 5.11, 6.00, 6.74는 주어진 각 영역에 속했을 때의 Salary 예측값이다.(소위 “사다리타기” 게임에서 맨 마지막 도착지인 셈이다.)

이 회귀 트리에 따르면, 예를 들어 어떤 선수가 메이저리그 경력 8년차이고, 전년도 안타수가 100개인 경우, 이 선수의 연봉(로그)은 6.0으로 예측된다. 이 로그값을 달러 금액으로 환산하면 \(e^{6.0}=403.429\)(천달러), 즉 40만 3,429달러이다. 이 선수의 연봉(로그)이 6.0으로 예측되는 이유는 자명하다. 이 선수가 Years\(=8\)이기 때문에 트리 맨 위의 갈림길에서 오른쪽에 해당하고, 그 다음 갈림길에서는 Hits\(=100\)이기 때문에 왼쪽 가지에 해당해 연봉 예측값 6.0에 도달하게 된다.

그림 12.1. Hitters 데이터세트에서 메이저리그 연차(Years)와 전년도 안타수(Hits)를 기반으로 선수들의 로그 연봉(Salary)을 예측하는 회귀 트리 결과이다. 내부마디에 있는 레이블(\(X_j < t_k\) 형식)은 해당 분할에서 나오는 왼쪽 가지 영역을 나타내고, 오른쪽 가지는 \(X_j ≥ t_k\)에 해당한다. 예를 들어, 트리 상단의 분할은 두 개의 가지를 생성하는데, 왼쪽 가지는 Years<4.5에 해당하고 오른쪽 가지는 Years>=4.5에 해당한다. 이 트리에는 2개의 내부마디와 3개의 끝마디(또는 잎)가 있다. 각 끝마디에 적혀 있는 숫자는 해당 영역에 속하는 관측들의 평균 반응값(여기서는 평균 Salary)으로서 이를 해당 영역의 예측값으로 삼는다.

회귀 트리 예

  • 그림 출처: ISLP, FIGURE 8.1

트리 기반(tree-based) 접근에서는 위 그림 12.1에 나와 있는 것을 하나의 트리(나무)로 본다. 단, 트리가 거꾸로 돼있는 것으로 보는데, 맨 위가 뿌리이며, 거기에서 아래쪽으로 가지가 뻗어 나간다. 맨 아래에는 (leaf)이 있고, 각 잎에는 연봉 예측값 숫자가 적혀 있다.

위 그림에서 가지가 분리되는 부분, 즉 Years<4.5Hits<117.5로 적혀 있는 부분을 내부마디(internal node)라고 부른다. 여기에 적혀 있는 레이블(\(X_j < t_k\) 형식)은 해당 분할에서 나오는 왼쪽 가지 영역을 나타내고, 오른쪽 가지는 \(X_j ≥ t_k\) 영역을 의미한다. 내부마디 중 맨 위쪽에 있는 (즉, 트리가 처음 시작하는) 마디를 뿌리마디(root node)라고 한다. 우리 예에서는 Years<4.5가 뿌리마디에 해당한다. 앞에서 언급했듯이 트리의 맨 아래쪽을 잎이라고 하는데, 이를 끝마디(terminal node)라고도 부른다.

각 끝마디는 영역(region)을 의미하는데, 위 트리에는 3개의 영역이 있다. 이것을 왼쪽부터 \(R_1\), \(R_2\), \(R_3\)라 하면, \(R_1 =\{X |\) Years\(<4.5\}\), \(R_2 =\{X |\) Years \(\ge 4.5,\) Hits \(<117.5\}\), \(R_3 =\{X |\) Years \(\ge 4.5,\) Hits \(\ge 117.5\}\)이다. 아래 그림 12.2는 이들 3개의 영역을 YearsHits를 축으로 하여 그린 것이다.

그림 12.2. 그림 12.1에 나온 회귀 트리의 3개 영역을 YearsHits를 축으로 하여 그린 것이다.

회귀 트리 3개 영역

  • 그림 출처: ISLP, FIGURE 8.2

결국 결정 트리는 예측변수들을 여러 영역으로 나눈 다음, 각 영역 별로 반응변수에 대한 예측값을 제시하는 것이다. 그렇다면 위 그림 12.1에 있는 예측값 5.11, 6.00, 6.74는 어디에서 나온 것일까? 그것은 각 영역에 속하는 선수들의 평균 Salary이다. 즉, 각 영역의 반응변수 평균값을 해당 영역의 예측값으로 삼는다.

그림 12.1에 표시된 회귀 트리를 다음과 같이 해석할 수 있다. Salary를 결정하는 가장 중요한 요소는 (뿌리마디 변수인) Years로서, 메이저리그 연차가 낮은 선수는 연차가 높은 선수보다 연봉이 적다. 연차가 낮은 신인급 선수에게는 전년도 타격 기록, 즉 안타수(Hits)가 더 이상 연봉에 영향을 미치지 않는다. 하지만 메이저리그에서 4.5년 이상 활약한 비교적 베테랑 선수들에게는 전년도 안타수가 연봉에 영향을 미치며, 안타수가 많은 선수가 연봉을 더 많이 받는 경향이 있다.

그림 12.1에 표시된 회귀 트리는 Hits, Years, Salary 간의 실제 관계를 지나치게 단순화한 것일 수 있다. 그러나 다른 유형의 회귀 모델에 비해 장점이 있다. 무엇보다도 트리라는 것이 무엇을 의미하는지 조금만 설명을 들으면 이해할 수 있고, 그래픽 표현이 가능하다는 점이다.(가령 회귀 분석 결과에 비해 트리 결과를 이해하는 것이 훨씬 쉽다!)

트리 기반 접근#

위에서 소개한 결정 트리를 만드는 방법을 개략적으로 소개하면, 예측변수(predictor) 공간을 여러 개의 단순 영역으로 계층화(stratifying) 하거나 분할하는(segmenting) 작업을 진행한다. 그런 다음, 각 영역별로 주어진 관측의 반응을 예측하는데, 일반적으로 해당 영역에 속한 훈련 관측 반응변수의 평균값이나 최빈값(mode)을 사용한다.

트리 기반 기법은 이해와 해석이 용이하다는 장점을 지닌다. 그러나 예측 정확도 면에서는 일반적으로 앞에서 살펴본 전통적인 선형 회귀나 로지스틱 회귀에 뒤지는 경우가 많다. 따라서 이런 단점을 보완하기 위해 배깅(bagging), 랜덤 포레스트(random forest), 부스팅(boosting) 기법들이 등장했다. 이들은 트리를 하나가 아니라 여러 개 생성하여 이것들을 결합시키는 방식으로 최종적인 예측을 도출하는 방법들로서 이에 대해서는 다음 장에서 다룬다. 많은 수의 트리를 결합하면, 해석 상의 손실은 어느 정도 감수해야 하지만, 예측 정확도가 획기적으로 높아지는 경우가 많다는 것을 알게 될 것이다.

12.2 회귀 트리 만들기#

특성 공간의 계층화를 통한 예측#

결정 트리는 회귀 및 분류 문제 모두에 적용할 수 있는데, 먼저 회귀 문제를 생각해보자. 회귀 트리는 앞에서도 언급했듯이 예측변수 공간의 계층화(stratification)를 통한 예측 기법으로서 이를 구축하는 과정은 크게 다음 두 단계로 나눌 수 있다.

  • STEP 1 : 예측변수(\(X_1,X_2,...,X_p\)) 공간을 \(J\)개의 서로 겹치지 않는 영역 \(R_1,R_2,...,R_J\)로 나눈다.

  • STEP 2 : \(R_j\) 영역에 속하는 훈련 관측들의 반응변수 평균값을 예측값(즉, 예상 반응값)으로 삼는다. 이는 \(R_j\) 영역에 속하는 모든 관측에 대해 동일하게 적용된다.

두 번째 단계부터 먼저 설명하면, 예를 들어 첫 번째 단계에서 \(R_1\)\(R_2\)라는 두 영역으로 분할이 이루어졌다고 해보자. 이 중 \(R_1\) 영역에 속하는 훈련 관측의 반응값 평균이 10이고, \(R_2\) 영역은 반응값 평균이 20이라고 하자. 이런 경우, 주어진 관측 \(X = x\)에 대해, 만약 \(x\)\(R_1\)에 속하면 10으로 예측하고, \(R_2\)에 속하면 20으로 예측한다는 것이다.

이제 위 첫 번째 단계에 대해 구체적으로 살펴 보자. 핵심은 \(R_1,...,R_J\) 영역을 어떻게 만드느냐이다. 이론적으로는 각 영역이 어떤 모양도 될 수 있다. 그러나 모형을 단순하게 하고, 해석을 용이하게 하기 위해 우리는 예측변수 공간을 직사각형 또는 상자 형태로 나눈다. 가령 2차원 공간이라면, 앞의 그림 12.2에 나와 있는 것처럼 두 변수로 이루어진 평면 공간에서 \(R_1\), \(R_2\), \(R_3\)의 형태로서 직사각형 형태만 고려할 뿐, 원이나 다항식 형태는 고려하지 않는다는 것이다.

이런 식으로 영역을 나누되, 우리의 목표는 다음의 RSS(residual sum of squares: 잔차제곱합)을 최소화하는 \(R_1,...,R_J\)를 찾는 것이다.

\[ \text{RSS}=\sum_{j=1}^{J}\sum_{i \in R_j}(y_i - \hat y_{R_j} )^2 \tag{12.1} \]

여기서 \(\hat y_{R_j}\)\(j\)번째 영역에 속하는 훈련 관측들의 평균 반응값이다.

그런데 관측 개수와 예측변수(즉 특성)의 개수가 많아지면 특성 공간을 분할하는 모든 가능한 영역을 고려하는 것은 사실상 불가능하다. 이 때문에 우리는 특성 공간의 영역 분할에 있어서 재귀적 이진 분할(recursive binary splitting)로 불리는 방식을 취한다. 이를 다음과 같은 예를 통해 설명한다.

회귀 트리 만들기 간단한 예#

먼저 가장 간단한 예를 사용하여 직접 재귀적 이진 분할을 실행해보자. 사용할 데이터는 앞 절에서 소개한 Hitters 데이터로서 원래는 관측 개수가 263개인데 여기에서는 단 3개의 관측만 사용하여 회귀 트리를 만들어 보자. 예제 데이터세트는 다음과 같고, 각 변수의 의미는 앞 절에서 설명한 대로이다.(Salary는 로그를 취한 값이다.)

\[\begin{split} \begin{array}{c|cccc} \text{No.} & \text{Years} & \text{Hits} & \text{Salary} \\ \hline 1 & 10 & 100 & 8.0 \\ 2 & 2 & 140 & 4.0 \\ 3 & 10 & 170 & 10.0 \\ \end{array} \end{split}\]

뿌리마디

  • 트리를 만들기 위해서는 우선 아래 그림처럼 뿌리마디부터 정해야 한다. 마디를 정한다는 것은 어떤 변수, 그리고 어떤 절단점(cutpoint)을 사용할 것인지를 정한다는 것으로서 그 기준은 잔차제곱합(RSS: residual sum of squares)을 최소화하는 것이다.(이를 잔차제곱합의 “감소를 최대화”한다고 표현할 수도 있다.)

회귀 트리 만들기 1

  • 우선 Years부터 시작해보면, 위 표에서 보듯이 관측값이 2와 10 두 개뿐이고, 그 중간점(midpoint)은 6이다.(관행상 2개 관측값의 절단점으로 중간점을 사용한다.) 따라서 6을 기준으로 그 미만과 그 이상으로 나눈다.

  • 먼저 Years\(<6\) 영역에는 2번 선수 한 명밖에 없기 때문에 그의 연봉이 곧 이 영역의 평균 Salary이고, 그 값은 4.0이다. 반대 영역인 Years\(\ge6\)에는 1번과 3번 선수가 해당하며, 둘의 평균 Salary는 9.0이다.

  • 이렇게 분할이 이루어졌을 때, RSS를 계산해보자. 1번 선수의 잔차(residual)는 \(8-9=-1\)이고, 2번 선수는 \(4-4=0\)이며, 3번 선수는 \(10-9=1\)이다. 따라서 \(\text{RSS}=(-1)^2+0^2+(1)^2=2\)이다. 이것을 기록해두고 다음으로 넘어 간다.


  • 이번에는 또 다른 입력변수인 Hits를 기준으로 분할해보자. Hits는 값이 100, 140, 170 세 개이기 때문에 100과 140의 중간점(즉 120), 그리고 140과 170의 중간점(즉 155) 등 두 곳을 절단점으로 고려해야 한다.

  • 먼저 첫 번째 절단점 Hits\(=120\)을 기준으로 그 미만과 그 이상으로 나눈다. 먼저 Hits\(<120\) 영역에는 1번 선수 한 명밖에 없기 때문에 그의 연봉이 곧 이 영역의 평균 Salary이고, 그 값은 8.0이다. 반대 영역인 Hits\(\ge120\)에는 2번과 3번 선수가 여기에 속하며, 이 두 선수의 평균 Salary는 7.0이다.

  • 이렇게 분할이 이루어졌을 때 RSS를 계산하면, 1번 선수의 잔차는 \(8-8=0\)이고, 2번 선수는 \(4-7=-3\)이며, 3번 선수는 \(10-7=3\)이다. 따라서 \(\text{RSS}=(0)^2+(-3)^2+(3)^2=18\)이다. 이것을 기록해두고 다음으로 넘어 간다.


  • 마지막으로 두 번째 절단점 Hits\(=155\)를 기준으로 그 미만과 그 이상으로 나눠 RSS를 구해보자. 먼저 Hits\(<155\) 영역에는 1번과 2번 선수가 여기에 해당하며, 이 두 선수의 평균 Salary는 6.0이다. 그리고 Hits\(\ge155\) 영역에는 3번 선수 한 명밖에 없기 때문에 그의 연봉이 곧 이 영역의 평균 Salary이고 그 값은 10.0이다.

  • 이렇게 분할이 이루어졌을 때 RSS를 계산하면, 1번 선수의 잔차는 \(8-6=2\)이고, 2번 선수는 \(4-6=-2\)이며, 3번 선수는 \(10-10=0\)이다. 따라서 \(\text{RSS}=(2)^2+(-2)^2+(0)^2=8\)이다.


  • 위에서 살펴 본 세 가지 분할의 RSS 값인 (\(2, 18, 8\)) 중에서 가장 작은 것은 첫 번째 경우, 즉 Years\(=6\)을 기준으로 분할했을 때이다. 따라서 이것이 트리의 시작점인 뿌리마디가 된다. 즉 이제 트리는 다음과 같은 모습을 갖췄다.

회귀 트리 만들기 2

중간마디 및 끝마디

  • 앞에서 뿌리마디를 정했는데, 여기에서 왼쪽 가지는 Years\(<6\)이고, 오른쪽 가지는 Years\(\ge 6\)을 의미한다. 이제는 이들 각 가지에서 다시 이진 분할을 따져봐야 한다. 그 기준과 절차는 뿌리마디를 찾을 때와 전적으로 동일하다.

  • 먼저 뿌리마디의 왼쪽 가지 Years\(<6\)의 경우에는 여기에 속하는 관측이 한 개(즉 2번 선수)밖에 없기 때문에 더 이상 분할을 할 수 없다. 즉 왼쪽 가지는 그것이 끝마디가 된다. 그리고 이 끝마디에 속할 경우, Salary 예측값은 2번 선수의 연봉인 4.0이 된다. 따라서 이제 트리는 다음과 같이 된다.

회귀 트리 만들기 3

  • 이번에는 뿌리마디의 오른쪽 가지인 Years\(\ge 6\)을 보면, 여기에 속하는 관측이 1번과 3번 선수 두 명이다. 이 상황에서 뿌리마디를 찾을 때 행했던 절차를 똑같이 적용하면 된다. 즉 YearsHits 두 변수 중 어떤 것에 대해 어떤 절단점을 사용할지를 찾는 것이다.

  • 그런데 우리 예에서는 일단 1번과 3번 선수 모두 Years가 10.0으로 동일하다. 즉 Years에 대해서는 더 이상 이진 분할이 되지 않는다.


  • 따라서 Hits에 대해서만 생각하면 되는데, 이 경우 가능한 이진 분할은 이들 두 명의 Hits 값(즉, 100 및 170)의 중간점인 Hits\(=135\)밖에 없다. 따라서 이것이 오른쪽 가지에 새로 생기는 중간마디가 된다.

  • 이 중간마디의 왼쪽 가지와 오른쪽 가지에는 각각 1번과 3번 한 명의 선수만 포함되기 때문에, 두 갈래 가지 자체가 끝마디가 되고, 각 끝마디의 Salary 예측값은 8.0과 10.0이 된다.


  • 이것으로 트리 작성은 끝났으며, 지금까지의 결과를 종합하면 트리의 최종적인 모습은 다음과 같다.

회귀 트리 만들기 4

  • 가령 어떤 선수가 메이저리그 경력 8년차이고, 전년도 안타수가 120개라면, 우리는 이 선수의 연봉(로그)을 8.0으로 예측하게 된다. 로그값이 아니라 금액으로 환산하면 \(e^{8.0}=2,981\)(천달러)이다.

  • 지금까지 트리를 만드는 과정에서 짐작할 수 있듯이, 관측의 개수와 예측변수의 개수가 많아지면 트리가 엄청나게 커지게 될 것이다. 따라서 어느 수준에서 트리의 성장을 중지시킬 필요가 있는데, 예를 들어 어떤 영역이든 해당 영역에 속한 관측의 개수가 5개에 이를 때까지라든지, 또는 트리의 깊이를 지정한다든지(그림 12.1에 나온 트리의 경우 “깊이”가 2임), 또는 끝마디의 개수를 최대 몇 개까지로 하는 것 등이 방법이 될 수 있다.

회귀 트리 코딩#

지금까지 수작업으로 작성한 결정 트리를 파이썬 코딩으로 실행해보자. 트리 모델을 피팅하기 위해서는 사이킷런(sklearn)의 tree 모듈이 필요하다. 거기에서 회귀 트리를 위해서는 DecisionTreeRegressor() 함수가 필요하고, 분류 트리를 위해서는 DecisionTreeClassifier() 함수가 필요하다. 먼저 주요 모듈과 함수들을 불러 들인다.

%matplotlib inline
import pandas as pd
import matplotlib.pyplot as plt

from sklearn import tree
from sklearn.tree import DecisionTreeRegressor, DecisionTreeClassifier

예제 데이터세트 입력

위에서 예시로 사용한 데이터를 입력한 다음, 예측변수를 X라는 이름으로, 그리고 반응변수를 y로 각각 지정한다.

Years = [10, 2, 10]
Hits = [100, 140, 170]
Salary = [8, 4, 10]

d = {'Years': Years, 'Hits': Hits}
X = pd.DataFrame(d)
y = Salary

회귀 트리 피팅 실행

회귀 트리는 DecisionTreeRegressor() 함수를 사용해 만든다. 이 함수의 주요 파라미터 값을 지정해야 하는데, 여기에서는 모두 기본값(default)을 사용하기로 하고, 인수를 전혀 입력하지 않기로 한다. 이럴 경우 트리가 제약 없이 최대한으로 성장하게 된다. 이 모형을 fit() 메서드를 사용해 데이터(X, y)에 피팅시킨다.

regr = DecisionTreeRegressor()
regr.fit(X, y)
DecisionTreeRegressor()
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

회귀 트리 모델에 사용된 파라미터 값

이로써 트리 피팅은 끝났다. 여기에서는 우선 트리 모델에 사용된 파라미터 값을 확인해보자. 피팅이 끝난 우리 모델(앞에서 regr라는 이름으로 지정했음)에 get_params() 메서드를 적용하면, 모델 피팅에 사용된 파라미터 값들이 나온다. 우리 경우는 아무런 인수를 입력하지 않았기 때문에 파라미터 기본값들을 얻게 된다.

아래 결과를 보면, 먼저 'criterion': 'squared_error'로 돼있다. 이는 이진 분할을 할 때, 평균제곱오차(MSE: mean squared error)를 기준으로 변수와 절단점을 선택하는 것으로, 앞에서 설명한 잔차제곱합(RSS)을 기준으로 한 것과 사실상 동일하다. squared_error 대신 가령 평균절대오차(absolute_error)를 사용할 수도 있다.

그 밖에는 트리의 크기에 제약을 가하는 파라미터들이 대부분인데 max_depth는 트리의 최대 깊이, max_leaf_nodes는 잎(끝마디)의 최대 개수, min_samples_leaf는 잎(끝마디)에 속한 최소 관측수, min_samples_split는 이진 분할이 실행되는 최소 관측수이다. 필요에 따라 이런 파라미터 값을 입력해 트리의 크기를 조정할 수 있다.

regr.get_params()
{'ccp_alpha': 0.0,
 'criterion': 'squared_error',
 'max_depth': None,
 'max_features': None,
 'max_leaf_nodes': None,
 'min_impurity_decrease': 0.0,
 'min_samples_leaf': 1,
 'min_samples_split': 2,
 'min_weight_fraction_leaf': 0.0,
 'monotonic_cst': None,
 'random_state': None,
 'splitter': 'best'}

트리 그림 그리기

피팅이 끝난 트리를 그림으로 그리는 몇 가지 방법들이 있다. 이에 대해서는 가령 Visualize a Decision Tree in 4 Ways with Scikit-Learn and Python을 참조할 수 있다.

여기에서는 tree 모듈의 plot_tree() 함수를 사용하기로 한다. 함수의 괄호 안에는 피팅된 모델 이름(이 예에서는 regr)과 예측변수 이름(feature_names)을 적으면 된다. filled=True는 트리 박스에 색깔을 집어넣는 옵션이다.

fig = plt.figure(figsize=(7,5), dpi=300)
ax = tree.plot_tree(regr,
                   feature_names=['Years', 'Hits'],
                   filled=True,
                   fontsize=9)

위 결과를 보면, 트리 모양은 달라도 내용에 있어서는 앞에서 우리가 수작업으로 도출한 트리와 완전히 동일한 것을 확인할 수 있다.

  • 위 그림에서 트리의 모든 마디 상자에 squared_error값이 적혀 있는데, 이것은 (이진 분할 하기 전) 해당 마디에 있어서의 평균제곱오차(MSE)를 의미한다. 여기에서 MSE는 RSS를 관측수로 나눈 것이다. RSS는 잔자제곱의 “합”이고, squared_error는 잔차제곱의 “평균”으로서 사실상 두 기준이 동일한 셈이다.

  • 위 결과에서 끝마디의 경우에는 관측이 하나씩밖에 없기 때문에 당연히 RSS가 0이고, squared_error 역시 0이다. 가령 뿌리마디의 경우, squared_error=6.222로 돼있는데, 확인 겸해서 이것을 계산해보면 다음과 같다. 우선 뿌리마디에서 이진 분할이 이루어지기 전단계에서의 평균 반응값은 \((8+4+10)/3=7.333\)이다. 따라서 뿌리마디에서 이것을 예측값으로 하여 RSS를 계산하면, \(\text{RSS}=(8-7.333)^2+(4-7.333)^2+(10-7.333)^2=18.666\)이다. 이것을 관측수 3으로 나누면 위 그림처럼 squared_error=6.222가 나온다.

  • 위 결과에서 각 마디의 마지막 줄에 value가 적혀 있는데, 이것은 해당 마디에 속한 관측들의 평균 반응값이다. 따라서 해당 마디가 끝마디일 경우에는 value가 예상 반응값(expected response value)이 된다.

위에서 우리가 사용한 데이터세트는 관측이 겨우 3개이고, 예측변수는 2개에 불과하다. 만약 데이터세트의 관측수 및 예측변수의 개수가 많아지면 트리 규모가 커지게 됨을 쉽게 짐작할 수 있다. 즉 DecisionTreeRegressor() 함수를 아무 제약없이 사용하면 트리가 무한정 커질 수 있기 때문에 앞에서 소개했듯이 트리 크기를 제어하는 파라미터(max_depth, min_samples_leaf, max_leaf_nodes 등)를 사용해 트리의 복잡성과 크기를 제어해야 한다. 또는 아래에서 설명할 가지치기(pruning)를 하는 것도 좋은 방법이다.

재귀적 이진 분할로 회귀 트리 만들기#

앞에서 회귀 트리 만드는 방법을 간단한 예를 통해 설명했는데, 이를 일반화시켜 정리하면 다음과 같다.

결정 트리는 (회귀 트리든 분류 트리든) 재귀적 이진 분할(recursive binary splitting)로 알려진 하향식 탐욕적 접근(top-down, greedy approach) 방식으로 만든다. 우선 “하향식”이라는 것은 트리의 상단(즉 뿌리)에서 시작하여 마치 나무 가지가 뻗어 나가듯이 예측변수 공간을 점차 세부적으로 분할해 나가는 방식을 취한다는 것이다. 그리고 “탐욕적”이라는 것은 이런 식의 각 분할이 이루어질 때, 트리의 미래(즉 전체 형태)를 내다보고 최상의 분할을 선택하는 것이 아니라 오직 그 해당 마디에서 평가한 최상의 분할을 선택한다는 의미이다.

  • 재귀적 이진 분할을 좀 더 정교하게 표현하면, 모든 예측변수 \(X_1,X_2,...,X_p\) 및 각 예측변수에 대한 모든 가능한 절단점 중에서 \(\{X \mid X_j < s\}\)\(\{X \mid X_j ≥ s\}\) 영역으로 이진 분할했을 때, RSS가 가장 작아지도록(달리 표현하면, RSS의 감소가 가장 커지도록) 예측변수 \(X_j\)와 절단점 \(s\)를 선택하는 방식이다. 구체적으로는, 먼저 \(j\)\(s\)에 대해 우리는 다음과 같은 한 쌍으로 된 영역을 정의한다.

\[ R_1(j,s) = \{X \mid X_j < s\}~~~\text{and}~~~R_2(j,s) = \{X \mid X_j \ge s\}\tag{12.2} \]
  • 그래서 다음 식으로 표현된 RSS를 최소화하는 \(j\)\(s\)값을 찾는다.

\[ \text{RSS}=\sum_{i:~x_i \in R_1(j,s)}(y_i - \hat y_{R_1} )^2 + \sum_{i:~x_i \in R_2(j,s)}(y_i - \hat y_{R_2} )^2 \tag{12.3} \]
  • 여기서 \(\hat y_{R_1}\)\(R_1(j,s)\)에 속하는 모든 훈련 관측의 평균 반응값이고, \(\hat y_{R_2}\)\(R_2(j,s)\)에 속하는 모든 훈련 관측의 평균 반응값이다. 즉 위 식은 \(R_1\)\(R_2\)의 각 영역에 속한 모든 관측값과 해당 영역에 속했을 때의 예상 반응값의 차이, 즉 잔차(residual)를 제곱하여 모두 합친 것으로, 이를 최소화 하는 \(j\)\(s\)값을 찾는다는 것이다.

  • 일단 뿌리마디의 분할이 이루어지면, 이번에는 나뉜 각 영역을 대상으로 또 다시 최상의 예측변수와 최상의 절단점을 찾는 작업을 반복하여 트리를 성장시켜 나간다. 이 과정을 어떤 정해진 기준에 도달할 때까지 계속한다.

  • 이런 절차를 거쳐 모든 영역 \(R_1, . . . ,R_J\)가 생성되면, 각 영역에 속한 훈련 관측의 평균 반응값을 사용하여 주어진 테스트 관측에 대한 반응을 예측한다.

이상 회귀 트리를 만드는 방법에 대한 설명을 앞에서 이미 살펴본 간단한 예와 비교하면서 생각하면 이해가 더 쉬울 것이다.

트리 가지치기(Tree Pruning)#

앞에서도 언급했듯이 위와 같은 방식으로 트리를 만들면 한 가지 문제가 생기는데, 트리가 너무 복잡해지고 커질 수 있다는 점이다. 그렇게 되면 훈련 세트에 대해서는 예측 성능이 좋을 수 있지만 데이터에 과적합(overfit)될 가능성이 있어 테스트 세트에 대해 성과가 나쁠 수 있다. 이를 해결하는 방법으로 분할을 더 적게 하는 것을 생각해 볼 수 있다. 즉, 영역 \(R_1, ... ,R_J\)의 개수를 줄이는 것이다. 이처럼 트리의 규모를 줄이면, 약간의 편향(bias)은 발생하겠지만, 분산(variance)이 줄어들고 해석도 더 용이해질 것이다.

분할 영역을 줄이는 데는 다양한 방법이 있을 수 있다. 가령 분할 시 RSS의 감소가 어떤 분계점(threshold)에 못 미치면 더 이상 분할을 하지 않는 것도 하나의 방법이다. 그러나 이 전략은 더 작은 트리를 만들기는 하겠지만, 근시안적인 결과를 낳을 위험이 있다. 왜냐하면 트리를 만드는 초기 단계에서는 RSS가 별로 감소하지 않다가 한참 뒤에야 RSS가 크게 감소하는 경우도 있을 수 있기 때문이다.

따라서 더 나은 전략은 매우 큰 트리 \(T_0\)를 만든 다음, 일부 가지를 쳐내는 소위 가지치기(pruning)를 통해 그것의 부분트리(subtree)를 얻는 것이다. 트리를 가지치기하는 가장 좋은 방법은 무엇일까? 우리의 목표는 명확하다. 즉 테스트 오류율 또는 오차가 가장 작은 부분트리를 선택하는 것이다. 일단 부분트리가 주어지면 교차검증(cross-validation) 또는 검증 세트(validation set)로 테스트 오차를 추정할 수 있다. 그렇다고 모든 가능한 부분트리들을 다 고려하는 것은 너무 경우의 수가 많기 때문에 보다 작은 부분트리 집합을 선택하는 방법이 필요하다.

비용-복잡성 가지치기

이런 목적에 부합하는 것이 비용-복잡성 가지치기(cost-complexity pruning)이다. 주어진 최초의 큰 트리를 \(T_0\)라 하고, 이것의 부분트리를 \(T\)라 하자. 즉 \(T \subset T_0\)이다. 트리 \(T\)의 끝마디 개수를 \(|T|\)로 표시하기로 하자. 비용-복잡성 가지치기는 0 또는 플러스 값을 갖는 조정 파라미터(tuning parameter) \(\alpha\)가 주어질 때, 다음 식을 최소화하는 \(|T|\)를 찾는 기법이다.

\[ \sum_{m=1}^{|T|}\sum_{i:~ x_i\in R_m} \left( y_i - \hat y_{R_m} \right)^2 + \alpha|T| \tag{12.4} \]

여기서 \(R_m\)\(m\)번째 끝마디 영역을 가리키며, \(\hat y_{R_m}\)\(R_m\) 영역의 예상 반응값, 즉 \(R_m\)에 속한 훈련 관측의 평균 반응값이다.

위 식을 최소화하는 방식으로 가지치기를 할 경우, 조정 파라미터 \(\alpha\)는 부분트리의 복잡성과 훈련 데이터에 대한 적합성 간의 균형을 잡는 역할을 한다. 가령 \(\alpha=0\)인 경우에 최적의 부분트리 \(T\)는 다름 아닌 \(T_0\)가 된다. 왜냐하면 위 식에서 \(\alpha|T|\) 부분이 사라지면 훈련 오차, 즉 RSS 부분만 남기 때문이다. 그렇게 되면 최초의 트리인 \(T_0\)가 어떤 부분트리에 비해서도 RSS가 작다. 가령 \(T_0\)의 모든 끝마디에 하나의 관측만 속하게 될 정도로 트리가 크고 복잡해지면, RSS는 0이 된다.

그런데 \(\alpha\)가 0이 아니고, 가령 1과 같은 플러스 값을 갖는다면, 끝마디 개수가 많을수록 \(\alpha|T|\)의 값이 커진다. 즉 끝마디 개수가 많은 복잡한 트리일수록 \(\alpha|T|\)만큼의 댓가를 지불해야 한다. 이 부분이 비용 항목으로 작용하는 것이다. 이 항목 때문에 가지치기된 더 작은 부분트리가 선택된다. 사실 식 12.4는 이전 장에서 다룬 라쏘(lasso)를 연상시킨다. 라쏘도 선형 모델의 복잡성을 통제하기 위해 유사한 식을 사용한다.

문제는 \(\alpha\)값을 어떻게 정하느냐인데, 교차검증 방식으로 최적의 \(\alpha\)값을 선택할 수 있다. 즉 다양한 \(\alpha\)값 후보에 대해 식 12.4를 최소화하는 최적의 부분트리를 구한 다음, 검증 세트를 사용해 예측오차(mean squared prediction error)를 계산한다. 교차검증의 경우 폴드를 교차해 가면서 이 작업을 반복하면, 각 폴드마다 \(\alpha\)값 별로 여러 개의 예측오차가 나오기 때문에, 최종적으로는 전체 폴드에 걸쳐 예측오차를 평균하여 그 값이 가장 작은 \(\alpha\)값을 선택하는 것이다. 그런 다음 다시 전체 데이터 세트로 돌아가서 선택된 \(\alpha\)에 해당하는 부분트리를 구하면 된다.

회귀 트리 예제#

출처: ISLP, pp.336-339.

아래 그림 12.3과 12.4는 Hitters 데이터세트에 들어있는 총 19개 예측변수 중 9개 특성을 사용하여 회귀 트리를 피팅한 결과이다. 관측이 총 263개인데, 이를 무작위로 반으로 나누어 훈련 세트 관측이 132개이고, 테스트 세트 관측이 131개이다.

먼저 훈련 데이터를 사용해 일차적으로 큰 트리를 만들었다. 그 결과가 그림 12.3에 나와 있는 트리이다. 그런 다음 가지치기를 통해 끝마디 개수를 하나씩 줄여가면서 부분트리들을 생성하였다.(이는 식 12.4의 \(\alpha\)값을 변경함으로써 수행된다.)

가지치기를 하지 않은 그림 12.3의 트리를 보면, 끝마디가 총 12개이다. 이 트리에 대해 가지치기를 통해 끝마디 개수를 하나씩 줄여나가 최종적으로는 끝마디 개수가 1개인 트리에 이르기까지 가지치기를 진행했다.(끝마디가 1개인 트리는 사실상 분할이 전혀 이루어지지 않은 상태를 말함.) 이 과정에서 훈련 세트 및 테스트 세트에 대해 예측 오차인 MSE를 계속 측정한다. 그 결과가 그림 12.4에 나와 있는데, 검은색이 훈련 세트에 대한 MSE이고, 오렌지색이 테스트 세트에 대한 MSE이다.(가로축 “Tree Size”는 끝마디 개수를 나타냄.) 전체적으로 테스트 세트 오류가 훈련 세트 오류보다 큰 것을 알 수 있다. 각 MSE 값의 위아래로는 그것의 표준편차 추정치, 즉 표준오차도 표시돼 있다.

한편, 위 작업과 함께 교차검증도 진행했다. 훈련 세트의 일부를 검증 세트로 남겨 놓는 식으로 서로 교차해가면서 트리를 만들고 그것을 검증 세트에 적용해 MSE를 계산한 다음, 그것들을 평균화하는 작업이다.(훈련 관측 개수 132가 6의 배수라서 6중 교차검증을 수행함.) 이 교차검증 역시 가장 큰 트리에서 시작해 가지치기를 통해 끝마디 개수를 하나씩 줄여가면서 검증 세트에 대해 MSE를 계산했다. 그 결과가 그림 12.4에서 녹색으로 표시돼 있다. 교차검증 오차는 테스트 오차의 합리적 근사값이다. 그림을 보면, 교차검증 오차는 끝마디가 3개인 트리에서 최소값을 취하는 반면, 테스트 오차는 끝마디가 3개인 트리에서도 감소하며, 끝마디가 10개인 트리에서 최소화된다. 교차검증 오차가 끝마디 3개 트리에서 최소화되기 때문에 그림 12.3의 큰 트리에 대해 3개의 끝마디를 갖도록 가지치기된 트리가 이 장의 맨 앞 그림 12.1에 나와 있다.

그림 12.3. Hitters 데이터에 대한 회귀 트리 분석. 훈련 데이터에 대해 하향식 탐욕적 분할로 트리를 만든 것으로 가지치기를 하지 않은 상태이다.

Hitters 회귀 트리 분석 1

  • 그림 출처: ISLP, FIGURE 8.4

그림 12.4. Hitters 데이터에 대한 회귀 트리 분석. 가지치기를 통해 끝마디 개수의 함수로 MSE를 구했다. 훈련 MSE(검은색), 교차검증 MSE(녹색), 테스트 MSE(오렌지색)가 동그라미 점으로 표시돼 있다. 각 MSE 값 위아래로는 표준오차 밴드가 표시돼 있다. 끝마디가 3개일 때, 교차검증 오류가 가장 작다.

Hitters 회귀 트리 분석 2

  • 그림 출처: ISLP, FIGURE 8.5

12.3 분류 트리 만들기#

분류 트리(classification tree)는 반응변수가 정량적이 아니라 정성적이라는 점만 빼면 회귀 트리와 매우 유사하다. 회귀 트리의 경우 어떤 관측에 대한 예측은 그 관측이 속한 영역(즉 끝마디)에 해당하는 훈련 관측의 평균 반응값이다. 분류 트리의 경우에도 이와 비슷한데, 단지 반응변수가 정량적 변수가 아니어서 평균값이 의미가 없기 때문에 그 대신 각 관측이 속한 영역(즉 끝마디)에서 가장 빈도수가 높은 훈련 관측 범주로 예측한다. 즉 범주별로 훈련 관측 개수를 세어 다수결의 원칙에 따라 해당 영역의 범주를 예측한다.

분류 트리를 만드는 작업은 회귀 트리를 만드는 작업과 매우 유사하다. 즉 회귀와 마찬가지로 재귀적 이진 분할을 사용하여 분류 트리를 만들어 나간다. 그런데 분류가 회귀와 다른 점 한 가지는 이진 분할을 할 때 잔차제곱합(RSS)을 사용할 수 없다는 점이다. 왜냐하면 반응변수가 숫자가 아니라 범주이기 때문에 RSS를 계산할 수도 없고 굳이 계산하더라도 적절한 평가도구가 아니다.

분류 문제에 있어서 RSS의 자연스러운 대안은 불순도(impurity)이다. 불순도 개념을 이해하기 위해 RSS를 다시 한 번 생각해보자. 회귀 트리처럼 반응변수가 숫자형인 경우에는 예측값과 실제값의 차이, 즉 오차(error) 또는 잔차(residual)로 모형의 적합성을 평가할 수 있다. 오차가 클수록 모형의 적합성이 떨어지는 것을 의미하기 때문에 우리는 RSS를 최소화하는 방식으로 재귀적 이진 분할을 해나간다.

이와 유사하게, 분류 트리에 있어서는 예측 범주와 실제 범주의 차이, 즉 오분류(classification error)와 관련된 지표를 사용하여 모형의 적합성을 평가하는 것이 적절하며, 그것을 “불순도”라고 표현할 수 있다. 가령 이진 분할의 결과, 모든 (훈련) 관측들에 대해 예측 범주와 실제 범주가 동일하다면 오분류가 하나도 없기 때문에 “완전히 깨끗한”(pure) 상태라고 표현할 수 있고, 반대로 오분류된 케이스들이 있는 것을 “오염된” 상태로 표현하는 것이다. 이런 맥락에서 “불순도”라는 표현을 사용하는 것이다. 그렇다면 불순도를 어떻게 측정하는 것이 좋을까? 몇 가지 방법들이 있는데, 아래 예를 통해 설명하기로 한다.

분류 트리 만들기 간단한 예#

가장 간단한 데이터를 사용하여 직접 분류 트리를 만들어 보자. 사용할 데이터세트는 관측이 단 3개인 Hitters 데이터세트이다. 예제 데이터세트는 다음과 같다.

\[\begin{split} \begin{array}{c|cccc} n & \text{Years} & \text{Hits} & \text{Salary} \\ \hline 1 & 10 & 100 & \text{High} \\ 2 & 2 & 140 & \text{Low} \\ 3 & 10 & 170 & \text{High} \\ \end{array} \end{split}\]

각 변수의 의미는 앞 절에서 설명한 대로이다. 단, 연봉을 의미하는 Salary 변수가 이번에는 숫자형 변수가 아니라 HighLow의 두 개 범주를 가진 정성적 변수라는 점이 앞 절과 다르다. 앞의 회귀 트리 사례와 모두 동일하고, 단지 Salary(로그) 값이 7.0 이상이면 High 범주로 분류했고, 7.0 미만이면 Low 범주로 분류했다는 점만 다르다. 반응변수가 범주로 주어졌다는 점만 다르고 나머지는 똑같기 때문에 이를 이용하여 만든 분류 트리도 앞의 회귀 트리와 크게 봐서는 비슷할 것으로 짐작할 수 있다. 이하 분류 트리 작성에 대한 설명은 앞의 회귀 트리 사례와 거의 동일해서 다소 지루할 수 있지만 생략 없이 다시 한 번 반복하기로 한다.

뿌리마디

  • 트리를 만들기 위해서는 우선 아래 그림처럼 뿌리마디부터 정해야 한다. 즉 “어떤 변수”에 대해 “어떤 절단점”을 사용할 것인지를 정해야 한다.

분류 트리 만들기 1

  • 우리는 앞의 회귀 트리에서는 “RSS 최소화”를 기준으로 변수와 절단점을 구했는데, 분류 트리에서는 “불순도 최소화”를 기준으로 한다.

  • 우선 Years부터 시작해보면, 관측값이 2와 10 두 개뿐이고, 그 중간점은 6이다. 따라서 6을 기준으로 그 미만과 그 이상으로 나눈다.

  • 먼저 Years\(<6\) 영역에는 2번 선수 한 명밖에 없기 때문에 그의 연봉 범주가 곧 이 영역의 Salary 범주가 되고, 그것은 Low이다. 반대 영역인 Years\(\ge6\)에는 1번과 3번 선수가 해당하며, 이 두 선수 모두 Salary 범주가 High이기 때문에 다수결의 원칙에 따라 이 영역의 Salary 범주는 High가 된다.

  • 이렇게 분할이 이루어졌을 때, 소위 불순도를 생각해보자. 이 경우는 깊이 생각할 것도 없이 불순도는 0이 되어야 할 것이다. 왜냐하면 모든 관측이 예측 범주와 실제 범주가 일치하기 때문이다. 완전히 깨끗한(pure) 분할인 것이다. 불순도를 측정하는 지표는 이런 경우 0의 값을 반환해야 할 것이다. 불순도를 측정하는 대표적 도구인 지니 지수(Gini index)를 사용해 불순도를 측정해보자. 이 경우 지니 지수는 다음과 같다.

\[ G = \hat p_{H}\left(1-\hat p_{H} \right) + \hat p_{L}\left(1-\hat p_{L} \right) \]
  • 여기서 \(\hat p_{H}\)는 주어진 영역에서 SalaryHigh인 훈련 관측 비율이고, \(\hat p_{L}\)SalaryLow인 훈련 관측 비율을 나타낸다. 이 식을 이용해 앞서 설명한 Years\(=6\)을 경계로 한 이진 분할의 지니 지수를 계산하면, 우선 왼쪽 가지(즉 Years\(<6\) 영역)에서는 모든 관측의 SalaryLow이기 때문에 \(\hat p_{L}=1\)이고, \(\hat p_{H}=0\)이다. 따라서 왼쪽 가지의 지니 지수는 \(G_{\text{Left}}=0\times(1-0)+1\times(1-1)=0\)이다. 마찬가지 방식으로 오른쪽 가지(즉 Years\(\ge 6\) 영역)의 지니 지수를 구하면, 여기에서는 모든 관측의 SalaryHigh이기 때문에 \(\hat p_{L}=0\)이고, \(\hat p_{H}=1\)이다. 따라서 오른쪽 가지의 지니 지수는 \(G_{\text{Right}}=1\times(1-1)+0\times(1-0)=0\)이다. 결국 Years\(=6\)을 경계로 한 이진 분할의 지니 지수는 이들 두 개의 지니 지수를 평균하면 된다. 마치 회귀 트리에서 RSS를 계산할 때, 왼쪽 가지와 오른쪽 가지의 RSS를 합친 것과 같은 맥락이다. 단, 두 개 지니 지수를 평균할 때, 단순 평균보다는 영역별 관측 개수를 가중치로 하여 가중 평균을 구하는 것이 더 적절할 것이다. 따라서 우리 경우에 Years\(=6\)을 경계로 한 이진 분할의 지니 지수는 다음 계산에 의해 0이 된다.(오분류가 전혀 없는 완전히 깨끗한 분할이다.)

\[ \left( \frac{1}{1+2} \right) \times G_{\text{Left}} + \left( \frac{2}{1+2} \right) \times G_{\text{Right}} = 0 \]

  • 이번에는 또 다른 입력변수인 Hits를 기준으로 분할해보자. Hits는 값이 100, 140, 170 세 개이기 때문에 100과 140의 중간점(즉 120), 그리고 140과 170의 중간점(즉 155) 등 두 곳을 절단점으로 고려해야 한다.

  • 우선 첫 번째 절단점 Hits\(=120\)을 기준으로 그 미만과 그 이상으로 나눈다. 먼저 Hits\(<120\) 영역에는 1번 선수 한 명밖에 없기 때문에 그의 연봉 범주가 곧 이 영역의 Salary 범주가 되고, 그것은 High이다. 반대 영역인 Hits\(\ge120\)에는 2번과 3번 선수가 여기에 해당하는데, 이들의 범주가 LowHigh로 엇갈려 다수결로 정할 수 없기 때문에, 여기에서 우리는 어떤 원칙을 정해야 한다. 이런 경우 무작위로 분류하는 등의 방법을 생각해볼 수 있지만, 여기에서는 두 범주의 빈도수가 동일할 때는 범주를 예측하지 않기로 하고 논의를 진행해보자.

  • 이 상황에서 앞에서와 마찬가지 방식으로 지니 지수를 계산하면, 우선 이진 분할의 왼쪽 가지(즉 Hits\(<120\) 영역)에서는 모든 관측의 SalaryHigh이기 때문에 \(\hat p_{H}=1\)이고, \(\hat p_{L}=0\)이다. 따라서 왼쪽 가지의 지니 지수는 \(G_{\text{Left}}=1\times(1-1)+0\times(1-0)=0\)이다. 이번에는 오른쪽 가지(즉 Hits\(\ge120\) 영역)의 지니 지수를 구하면, 여기에서는 두 관측의 범주가 서로 엇갈려 \(\hat p_{H}=0.5\)이고, \(\hat p_{L}=0.5\)이다. 따라서 오른쪽 가지의 지니 지수는 \(G_{\text{Right}}=0.5\times(1-0.5)+0.5\times(1-0.5)=0.5\)이다. 결국 Hits\(=120\)을 경계로 한 이진 분할의 지니 지수는 이들 두 값을 가중평균하면 \( \left( \frac{1}{1+2} \right) \times G_{\text{Left}} + \left( \frac{2}{1+2} \right) \times G_{\text{Right}} = 0.333\)이 된다. 우리는 이 경우 불순도가 0이 아니라 플러스 값을 갖는 것을 확인할 수 있다. 즉 분류 결과가 완전히 깨끗하지 않고 불순도가 섞여 있으며, 지니 지수로 평가한 불순도는 0.333으로 나왔다.


  • 마지막으로 두 번째 절단점 Hits\(=155\)를 기준으로 그 미만과 그 이상으로 나눠 지니 지수를 구해보자. 먼저 Hits\(<155\) 영역에는 1번과 2번 선수가 여기에 해당하는데, 이들의 범주가 HighLow로 엇갈리기 때문에 이 경우에는 범주를 예측하지 않기로 한다. 그리고 Hits\(\ge155\) 영역에는 3번 선수 한 명밖에 없기 때문에 그의 연봉 범주가 곧 이 영역의 Salary 범주가 되고, 그것은 High이다.

  • 이렇게 분할이 이뤄졌을 때의 지니 지수를 계산하면, 우선 이진 분할의 왼쪽 가지(즉 Hits\(<155\) 영역)에서는 두 관측의 범주가 서로 엇갈려 \(\hat p_{H}=0.5\)이고, \(\hat p_{L}=0.5\)이다. 따라서 왼쪽 가지의 지니 지수는 \(G_{\text{Left}}=0.5\times(1-0.5)+0.5\times(1-0.5)=0.5\)이다. 이번에는 오른쪽 가지(즉 Hits\(\ge155\) 영역)의 지니 지수를 구하면, 모든 관측의 SalaryHigh이기 때문에 \(\hat p_{H}=1\)이고, \(\hat p_{L}=0\)이다. 따라서 오른쪽 가지의 지니 지수는 \(G_{\text{Right}}=1\times(1-1)+0\times(1-0)=0\)이다. 결국 Hits\(=155\)를 경계로 한 이진 분할의 지니 지수는 이들 두 값을 가중평균하면 \( \left( \frac{2}{1+2} \right) \times G_{\text{Left}} + \left( \frac{1}{1+2} \right) \times G_{\text{Right}} = 0.333\)이 된다.


  • 위에서 살펴 본 세 분할의 지니 지수 (\(0\), \(0.333\), \(0.333\)) 중에서 가장 작은 것은 당연히 첫 번째인 Years\(=6\)을 기준으로 분할했을 때이다. 따라서 이것이 트리의 시작점인 뿌리마디가 된다. 즉 이제 트리는 다음과 같은 모습을 갖췄다.

분류 트리 만들기 2

중간마디 및 끝마디

  • 앞에서 뿌리마디를 정했는데, 여기에서 왼쪽 가지는 Years\(<6\)이고, 오른쪽 가지는 Years\(\ge 6\)을 의미한다. 이제는 이들 각 가지에서 다시 이진 분할을 따져봐야 한다. 그 기준과 절차는 뿌리마디를 찾을 때와 전적으로 동일하다.

  • 먼저 뿌리마디의 왼쪽 가지 Years\(<6\)의 경우에는 여기에 속하는 관측이 한 개(즉 2번 선수)밖에 없기 때문에 더 이상 분할을 할 수 없다. 즉 왼쪽 가지는 그것이 끝마디가 된다. 그리고 이 끝마디에 속할 경우, Salary 예측 범주는 2번 선수의 연봉 범주인 Low가 된다. 따라서 트리는 다음과 같이 된다.

분류 트리 만들기 3

  • 이번에는 뿌리마디의 오른쪽 가지인 Years\(\ge 6\)을 보면, 여기에 속하는 관측이 1번과 3번 선수 두 명이다. 두 명이 속해 있기는 하지만 이 경우에도 더 이상 이진 분할을 할 필요가 없다. 왜냐하면 오른쪽 가지에 속한 두 명 모두의 연봉 범주가 High로 동일해 이미 불순도가 0에 도달해서 더 이상 분할해봐야 불순도가 개선될 수 없기 때문이다. 결국 오른쪽 가지 자체가 끝마디가 되고, Salary 예측 범주는 (1번과 3번 선수의 연봉 범주가 모두 High이기 때문에) High가 된다.


  • 이것으로 트리 작성은 끝났으며, 지금까지의 결과를 종합하면 트리의 최종적인 모습은 다음과 같다.

분류 트리 만들기 4

  • 가령 어떤 선수가 메이저리그 경력 8년차이고, 전년도 안타수가 120개라면, 이 선수의 연봉 범주는 High로 예측된다.

  • 회귀 트리에서와 마찬가지로, 관측 개수와 예측변수 개수가 많아지면 트리가 엄청나게 커지게 될 것이다. 따라서 어느 단계에서 트리의 성장을 중단시킬 필요가 있다.

분류 트리 코딩#

지금까지 수작업으로 작성한 결정 트리를 파이썬 코딩으로 실행해보자. 분류 트리 모델 피팅은 사이킷런(sklearn)의 tree 모듈에 들어있는 DecisionTreeClassifier() 함수를 이용하면 된다.

예제 데이터세트 입력

위에서 예시로 사용한 데이터를 입력한다. 여기에서는 Salary1이라는 더미변수를 만들어 Salary가 7 미만이면 0을 부여하고, 7 이상이면 1을 부여했다. 즉 Salary1은 고연봉 더미변수이다.

Years = [10, 2, 10]
Hits = [100, 140, 170]
Salary = [8, 4, 10]

d = {'Years': Years, 'Hits': Hits, 'Salary': Salary}
df = pd.DataFrame(d)
df['Salary1'] = df.Salary.map(lambda x: 1 if x>7 else 0)
df
Years Hits Salary Salary1
0 10 100 8 1
1 2 140 4 0
2 10 170 10 1

분류 트리 피팅 실행

YearsHits을 예측변수 X로 하고, Salary1을 반응변수 y로 지정한 다음, DecisionTreeClassifier() 함수를 사용하여 분류 트리 모델의 내용을 정한다. 주요 파라미터 값을 지정해야 하는데, 여기에서는 모두 기본값(default)을 사용하기 위해 인수를 전혀 입력하지 않았다.

분류 트리의 이진 분할을 할 때, 불순도 측정 도구로서 지니 지수 외에 엔트로피 등이 있다. DecisionTreeClassifier() 함수의 경우, 지니 지수가 기본값(즉, criterion='gini')이다. 이를 엔트로피로 바꾸고 싶으면, criterion='entropy'로 하면 된다.

이 모형을 fit() 메서드를 사용해 데이터(X, y)에 피팅시킨다.

X = df[['Years', 'Hits']] 
y = df.Salary1

clf = DecisionTreeClassifier()
clf.fit(X, y)
DecisionTreeClassifier()
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

트리 그림 그리기

피팅이 끝난 트리(”clf”)를 plot_tree() 함수를 사용해 그림으로 그린다. class_names 파라미터를 사용해 반응변수의 범주 이름을 입력하면 트리 결과를 이해하는 데 도움이 된다.

X = df[['Years', 'Hits']] 
y = df.Salary1

clf = DecisionTreeClassifier()
clf.fit(X, y)

fig = plt.figure(figsize=(5,3), dpi=300)
ax = tree.plot_tree(clf,
                   feature_names=['Years', 'Hits'],
                   class_names=['Low', 'High'],
                   filled=True,
                   fontsize=5)

위 결과를 보면, 내용이 앞에서 우리가 수작업으로 도출한 트리와 동일한 것을 확인할 수 있다.

위 트리 그림의 모든 마디에 지니 지수(gini)가 적혀 있는데, 이것은 (이진 분할 하기 전) 해당 마디에 있어서의 지니 지수를 계산한 것이다. 끝마디의 경우에는 모든 관측의 범주가 동일하기 때문에 당연히 불순도(즉 지니 지수)가 0이다. 위 결과에서 뿌리마디의 경우, gini=0.444로 돼있는데, 확인 차원에서 이것을 계산해보자. 우선 세 개 관측의 범주를 보면, High가 2개이고, Low가 1개이기 때문에 \(\hat p_{H}=\frac{2}{3}\)이고, \(\hat p_{L}=\frac{1}{3}\)이다. 따라서 \(G=\frac{2}{3} \left( 1-\frac{2}{3} \right)+\frac{1}{3} \left( 1-\frac{1}{3} \right)=\frac{4}{9}=0.444\)가 된다.

위 트리 결과에서 각 마디의 세 번째 줄에 value가 적혀 있는데, 이것은 해당 마디에 속한 관측들이 범주별로 각각 몇 개씩인지를 의미한다. 가령 뿌리마디의 경우, value[1,2]로 나와 있는데, 이는 0의 범주(즉 Low)가 1개이고, 1의 범주(즉 High)가 2개라는 것이다.

각 마디 상자의 마지막 줄에 class가 적혀 있는데, 이것은 해당 마디에 속한 관측들의 범주 중 가장 다수를 차지하는 범주를 의미한다. 따라서 끝마디의 경우, 이는 예상 범주를 의미한다.

불순도 측정 도구#

지니 지수

앞에서 우리는 분류 트리를 만들 때, 불순도를 평가하는 측정 도구로서 지니 지수를 사용했는데, 이를 일반적인 형태로 표현하면 다음과 같다. \(m\)번째 영역의 불순도를 측정한다고 하고, 이 영역에 속한 훈련 관측들의 범주가 총 \(K\)개이며, \(\hat p_{mk}\)\(m\)번째 영역에 있어서 \(k\)번째 범주에 속한 훈련 관측의 비율을 나타낸다고 하자. 이 경우, 지니 지수는 다음과 같이 정의된다.

\[ G = \sum_{k=1}^{K} \hat p_{mk}\left(1-\hat p_{mk} \right) \tag{12.5} \]

이 식의 특징은 모든 \(\hat p_{mk}\)가 0 또는 1에 가까울 때, 지니 지수 값이 작아지도록 고안됐다는 점이다. 앞의 우리 예처럼 범주가 2개만 있는 상황에서, 만약 \(\hat p_{m1}=\hat p_{m2}=0.5\)이면, 지니 지수는 다음과 같이 0.5가 된다.(범주가 2개일 때, 지니 지수가 도달할 수 있는 최대값이 0.5이다.)

\[\begin{split} \begin{aligned} G & = \hat p_{m1}\left(1-\hat p_{m1}\right) + p_{m2}\left(1-\hat p_{m2}\right)\\ & = 0.5\left(1-0.5\right) + 0.5\left(1-0.5\right)\\ & = 0.5\times0.5 + 0.5\times0.5=0.5 \end{aligned} \end{split}\]

이와 달리, 가령 \(\hat p_{m1}=0.9\)이고 \(\hat p_{m2}=0.1\)이면, 지니 지수는 다음과 같이 0.18로서 0.5보다 훨씬 작아진다.

\[\begin{split} \begin{aligned} G & = \hat p_{m1}\left(1-\hat p_{m1}\right) + p_{m2}\left(1-\hat p_{m2}\right)\\ & = 0.9\left(1-0.9\right) + 0.1\left(1-0.1\right)\\ & = 0.9\times0.1 + 0.1\times0.9=0.18 \end{aligned} \end{split}\]

결국 주어진 영역에서 훈련 관측들이 어느 하나의 범주에 몰려 있을수록(즉 불순도가 낮을수록), 지니 지수가 작아지며, 모든 관측이 어느 하나의 범수에 속할 경우에는 0으로서 최소값에 도달한다.

엔트로피

불순도를 측정하는 또 다른 도구로서 다음과 같은 엔트로피(entropy)가 있다.

\[ D = -\sum_{k=1}^{K} \hat p_{mk} \log_2 \left(\hat p_{mk} \right) \tag{12.6} \]

여기에서 \(0 ≤ \hat p_{mk} ≤ 1\)이므로 \( -\hat p_{mk} \log \left(\hat p_{mk} \right) \ge 0\)가 된다. 지니 지수와 마찬가지로 엔트로피 역시 \(\hat p_{mk}\)가 모두 0에 가깝거나 1에 가까우면 엔트로피가 0에 가까워진다. 따라서 지니 지수와 마찬가지로 \(m\)번째 영역이 완전히 깨끗할수록(pure) 해당 영역의 엔트로피는 작아지고, 반대로 관측들이 다양한 범주에 속해 불순도가 높아질수록 엔트로피는 커진다. 실제로 지니 지수와 엔트로피는 수치적으로 상당히 유사한 것으로 밝혀졌다. 둘 다 회귀 트리에 있어서의 RSS와 마찬가지로 그 값을 최소화하는 방식으로 이진 분할이 진행된다.

분류 트리 예제#

출처: ISLP, pp.339-341.

심장질환 데이터세트

Heart 데이터세트는 가슴통증을 호소한 303명의 환자에 대해 조사한 것이다. 반응변수 AHD는 이진 변수로서 두 개의 범주가 있는데, Yes는 혈관 조영 검사 결과 심장질환이 있음을 나타내고, No는 심장질환이 없음을 나타낸다. Age, Sex, Chol을 비롯해 기타 심장 및 폐 기능 측정을 포함한 13개의 예측변수가 있다. 몇몇 주요 변수는 다음과 같다.

  • Age : 나이(년)

  • Sex : 성별 (1 = 남성; 0 = 여성)

  • Chol : 혈청 콜레스테롤(mg/dl)

  • ChestPain : 가슴 통증 유형 (typical = 전형적 협심증; nontypical = 비전형적 협심증; nonanginal = 비협심증 통증; asymptomatic = 무증상)

  • RestECG : 휴식 중 심전도 측정 결과 (0 = 정상; 1 = ST-T파 이상; 2 = Estes 기준에 의해 좌심실 비대가 의심되거나 확실함)

  • AHD: 혈관 조영 검사에 의한 심장질환 여부 (Yes, No)

모델 설정 및 결과

Heart 데이터세트를 사용하여 분류 트리를 피팅했는데, 먼저 전체 관측을 훈련 세트와 테스트 세트로 나누고, 훈련 데이터를 사용해 일차적으로 큰 트리를 만들었다. 그 결과가 아래 그림 12.5의 상단에 나와 있는 트리이다. 그런 다음 가지치기를 통해 끝마디 개수가 다른 부분트리들을 생성하였다. 가지치기를 하지 않은 상단의 트리를 보면, 끝마디가 총 18개인데, 이 트리에 대해 가지치기를 통해 끝마디 개수를 하나씩 줄여나가 최종적으로는 끝마디 개수가 1개인 트리에 이르기까지 가지치기를 진행했다.(끝마디가 1개인 트리는 사실상 분할이 이루어지지 않은 상태를 말함.) 이 과정에서 훈련 세트 및 테스트 세트에 대해 분류 오류, 즉 오분류율을 계속 측정했다. 이 결과가 그림 12.5의 왼쪽 하단 패널에 나와 있는데, 검은색이 훈련 세트에 대한 오류이고, 녹색이 테스트 세트에 대한 오류이다. 테스트 세트 오류가 훈련 세트 오류보다 훨씬 큰 것을 알 수 있다.

한편, 위 작업과 함께 교차검증도 진행했다. 훈련 세트의 일부를 검증 세트로 떼어 놓고 번갈아 가면서 트리를 만들고 그것을 검증 세트에 적용해 오류를 계산한 다음, 그것들을 평균화하는 작업이다. 이 교차검증 역시 가장 큰 트리에서 시작해 가지치기를 통해 끝마디 개수를 하나씩 줄여 가면서 검증 세트에 대한 오류를 계산했다. 그 결과가 아래 그림 12.5의 왼쪽 하단 패널에서 오렌지색으로 표시돼 있다. 교차검증 오류는 테스트 오류의 합리적 근사값이다. 그림을 보면, 교차검증 오류는 끝마디가 6개인 트리에서 가장 작아지기 때문에 상단의 큰 트리에 대해 6개의 끝마디를 갖도록 가지치기를 했으며, 그렇게 해서 나온 트리가 오른쪽 하단 그림이다.

그림 12.5. Heart 데이터세트에 대한 분류 트리. 상단: 가지치기 하지 않은 트리. 왼쪽 하단: 트리의 다양한 크기별 교차검증 오류, 훈련 오류, 테스트 오류. 오른쪽 하단: 교차검증 오류를 최소화하는 끝마디 개수를 가진 가지치기된 트리.

Hitters 분류 트리 1 Hitters 분류 트리 2

  • 그림 출처: ISLP, FIGURE 8.6

정성적 예측변수

지금까지 논의에서는 예측변수가 정량적인 경우만 다뤘다. 그러나 정성적 예측변수도 결정 트리를 만드는 데 아무런 문제가 없다. 예를 들어 Heart 데이터에서 Sex, Thal, ChestPain과 같은 예측변수는 범주형 변수들이다. 이러한 범주형 변수에 대해 분할을 할 경우에는 범주 중 일부를 하나의 가지에 할당하고 나머지를 다른 가지에 할당하는 식으로 하면 된다.

앞의 그림 12.5에서 일부 내부마디는 정성적 변수를 분할하고 있는데, 가령 상단 그림의 뿌리마디는 Thal 변수에 대한 분할이다. 먼저 표기법을 설명하면, Thal:a로 돼있는 것은 해당 마디에서 나오는 왼쪽 가지가 Thal 변수의 첫 번째 범주(normal)에 해당하고, 오른쪽 가지는 나머지 범주(fixedreversible)에 해당하는 것을 의미한다. 또한 같은 그림에서 뿌리마디의 왼쪽 가지 중 두번째 분할에 ChestPain:bc 표시가 있는데, 이는 ChestPain 변수의 가능한 값이 typical, nontypical, non-anginal, asymptomatic 등 네 개인데, 해당 마디에서 나오는 왼쪽 가지가 네 개 범주 중 두 번째 및 세 번째 값을 갖는 관측으로 구성돼 있음을 나타낸다. 이런 식으로 범주형 예측변수에 대해서도 동일한 방식으로 이진 분할을 하면 된다.

분할 양쪽 끝마디의 예측 범주가 동일한 경우

위 그림 12.5에는 한 가지 흥미로운 부분이 있는데, 일부 분할의 경우에는 양쪽 끝마디의 예측 범주가 동일하다는 점이다. 예를 들어, 그림 12.5의 가지치기되지 않은 상단 트리 그림에서 오른쪽 아래 부분의 분할 RestECG<1을 봐보자.(RestECG는 휴식 중 심전도 측정 결과로서 0, 1, 2로 표시된 세 개의 범주가 있다.) 이 경우 왼쪽 가지와 오른쪽 가지 모두 다 예측 범주가 Yes이다.

범주가 똑같은데도 이렇게 분할이 이루어진 이유는 무엇일까? 그 이유는 이렇게 분할을 함으로써 마디의 불순도가 감소하기 때문이다. 즉, 이 분할에서 오른쪽 끝마디(즉 RestECG\(\ge1\))에는 9개의 관측이 여기에 속하는데, 이들의 반응 범주가 모두 Yes이다. 이에 반해 왼쪽 끝마디(즉 RestECG\(<1\))에는 총 11개의 관측 중 7개만이 범주가 Yes이다. 이처럼 두 끝마디의 불순도가 다른 것이다. 이들 두 끝마디를 합쳐 놓는 것(즉 분할을 하지 않는 것)에 비해 RestECG=1을 경계로 분할을 함으로써 불순도를 낮출 수 있다. 달리 표현하면 이렇게 분할을 함으로써 추론에 이득이 생긴다고 할 수 있다. 가령 어떤 테스트 관측이 오른쪽 끝마디에 속하면, 반응 범주가 Yes라는 것을 강하게 확신할 수 있다. 이에 반해, 테스트 관측이 왼쪽 끝마디에 속하면, 이 경우에도 Yes 범주로 예측되지만, 그 확신의 정도가 오른쪽 끝마디에 비해 훨씬 낮아진다. 이런 추론 상의 정보 이득(information gain) 때문에 양쪽 끝마디의 예측 범주가 동일한 경우에도 분할이 일어날 수 있다.

트리의 장점과 단점#

이상 회귀 및 분류를 위한 결정 트리에 대해 기본적인 내용을 살펴 보았다. 결정 트리는 선형 회귀나 로지스틱 회귀 등 전통적 접근에 비해 장단점을 지니고 있다. 장점으로는 이해와 설명이 쉽고, 해석이 용이하다는 점을 들 수 있다. 또한 트리를 그래픽으로 표시할 수 있어 비전문가도 쉽게 이해할 수 있다. 이와 함께 결정 트리가 전통적 접근에 비해 인간의 의사 결정을 더 잘 반영한다는 주장도 있다. 그밖에 트리는 더미변수를 만들 필요 없이 정성적 예측 변수를 쉽게 처리할 수 있다.

이러한 장점에 비해 단점도 있는데, 무엇보다 예측 및 분류의 정확도 면에서 전통적 접근에 비해 일반적으로 열위에 있다. 또한 트리는 분석 결과의 강건성(robustness)이 아주 떨어질 수 있다. 즉, 데이터가 약간만 변해도 트리 결과가 크게 달라질 수 있다.

결정 트리의 이런 단점을 보완하기 위해 여러 기법들이 등장했는데, 대표적으로 배깅, 랜덤 포레스트, 부스팅 등이 있다. 소위 앙상블(ensemble) 기법으로 불리는데, 기본 아이디어는 많은 결정 트리를 결합하여 트리의 예측력을 높이는 것이다. 다음 장에서 이들 기법에 대해 다룬다.

12.4 결정 트리 예제#

코드 출처: Tree-based Methods by J. Warmenhoven

기본 라이브러리 및 함수 불러오기

%matplotlib inline
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

from sklearn import tree
from sklearn.tree import DecisionTreeRegressor, DecisionTreeClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error, confusion_matrix, classification_report

회귀 트리: Hitters 데이터세트#

앞에서도 다뤘던 Hitters 데이터세트는 1986년과 1987년 시즌 미국 메이저리그에서 뛰었던 322명 선수들에 대해 20개 항목을 기록한 데이터이다.

데이터 로딩

Hitters = pd.read_csv('../Data/Hitters.csv')
Hitters.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 322 entries, 0 to 321
Data columns (total 20 columns):
 #   Column     Non-Null Count  Dtype  
---  ------     --------------  -----  
 0   AtBat      322 non-null    int64  
 1   Hits       322 non-null    int64  
 2   HmRun      322 non-null    int64  
 3   Runs       322 non-null    int64  
 4   RBI        322 non-null    int64  
 5   Walks      322 non-null    int64  
 6   Years      322 non-null    int64  
 7   CAtBat     322 non-null    int64  
 8   CHits      322 non-null    int64  
 9   CHmRun     322 non-null    int64  
 10  CRuns      322 non-null    int64  
 11  CRBI       322 non-null    int64  
 12  CWalks     322 non-null    int64  
 13  League     322 non-null    object 
 14  Division   322 non-null    object 
 15  PutOuts    322 non-null    int64  
 16  Assists    322 non-null    int64  
 17  Errors     322 non-null    int64  
 18  Salary     263 non-null    float64
 19  NewLeague  322 non-null    object 
dtypes: float64(1), int64(16), object(3)
memory usage: 50.4+ KB

변수 설명

  • AtBat : 1986년 타수

  • Hits : 1986년 안타수

  • HmRun : 1986년 홈런 수

  • Runs : 1986년 득점

  • RBI : 1986년 타점

  • Walks : 1986년 볼넷 수

  • Years : 메이저 리그 연차

  • CAtBat : 경력 중 타수

  • CHits : 경력 중 안타수

  • CHmRun : 경력 중 홈런 수

  • CRuns : 경력 중 득점 수

  • CRBI : 경력 중 타점 수

  • CWalks : 경력 중 볼넷 수

  • League : 1986년말 소속 리그(A-아메리칸, N-내셔널)

  • Division : 1986년말 소속 디비전(E-동부, W-서부)

  • PutOuts : 1986년 풋아웃(아웃 실행) 수

  • Assists : 1986년 어시스트(아웃 도움) 수

  • Errors : 1986년 에러 수

  • Salary : 1987년 리그 개막 당시 연봉(천 달러)

  • NewLeague : 1987년초 소속 리그(A-아메리칸, N-내셔널)

결측값 지닌 관측 제거

info() 메서드 결과를 보면, Salary 변수의 경우, 상당수 관측에 결측값이 있다. 따라서 dropna() 메서드를 사용해 결측값을 지닌 관측들을 제거한다. 이렇게 하면, 관측 개수가 263개로 줄어든다.

df = Hitters.dropna()
print(df.shape)
df.head()
(263, 20)
AtBat Hits HmRun Runs RBI Walks Years CAtBat CHits CHmRun CRuns CRBI CWalks League Division PutOuts Assists Errors Salary NewLeague
1 315 81 7 24 38 39 14 3449 835 69 321 414 375 N W 632 43 10 475.0 N
2 479 130 18 66 72 76 3 1624 457 63 224 266 263 A W 880 82 14 480.0 A
3 496 141 20 65 78 37 11 5628 1575 225 828 838 354 N E 200 11 3 500.0 N
4 321 87 10 39 42 30 2 396 101 12 48 46 33 N E 805 40 4 91.5 N
5 594 169 4 74 51 35 11 4408 1133 19 501 336 194 A W 282 421 25 750.0 A

예측변수 및 반응변수

총 19개 예측변수 중 YearsHits 2개를 편의상 X라는 이름으로 지정한다. Salary를 반응변수 y로 지정하되, 여기에 자연로그를 취하기로 한다. 로그를 취하기 전과 후의 연봉 분포를 히스토그램으로 그려봤다.

X = df[['Years', 'Hits']]
y = np.log(df.Salary)
fig, (ax1, ax2) = plt.subplots(1,2, figsize=(7,3))
ax1.hist(df.Salary)
ax1.set_xlabel('Salary')
ax2.hist(y)
ax2.set_xlabel('Log(Salary)');

회귀 트리 피팅 실행

사이킷런의 DecisionTreeRegressor() 함수를 사용해 회귀 트리 모형을 설정한다. 끝마디 최대 개수를 3개로 지정해보자(max_leaf_nodes=3). 이렇게 설정한 모형을 fit() 메서드를 사용해 데이터(X, y)에 피팅시킨다.

regr = DecisionTreeRegressor(max_leaf_nodes=3)
regr.fit(X, y)
DecisionTreeRegressor(max_leaf_nodes=3)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

트리 결과

사이킷런 tree 모듈의 plot_tree() 함수를 사용해 트리 결과를 그림으로 그린 것이 아래 나와 있다. 이 결과는 이 장의 맨 앞 그림 12.1에 나와 있는 트리와 사실상 동일하다.

fig = plt.figure(figsize=(5,3), dpi=300)
ax = tree.plot_tree(regr,
                   feature_names=['Years', 'Hits'],
                   filled=True,
                   fontsize=5)

분류 트리: Heart 데이터세트#

앞에서도 다뤘던 Heart 데이터세트는 가슴통증을 호소한 303명의 환자에 대해 심장질환 여부 등 14개 항목을 조사한 데이터다.

데이터 로딩

Heart = pd.read_csv('../Data/Heart.csv').drop('Unnamed: 0', axis=1)
Heart.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 303 entries, 0 to 302
Data columns (total 14 columns):
 #   Column     Non-Null Count  Dtype  
---  ------     --------------  -----  
 0   Age        303 non-null    int64  
 1   Sex        303 non-null    int64  
 2   ChestPain  303 non-null    object 
 3   RestBP     303 non-null    int64  
 4   Chol       303 non-null    int64  
 5   Fbs        303 non-null    int64  
 6   RestECG    303 non-null    int64  
 7   MaxHR      303 non-null    int64  
 8   ExAng      303 non-null    int64  
 9   Oldpeak    303 non-null    float64
 10  Slope      303 non-null    int64  
 11  Ca         299 non-null    float64
 12  Thal       301 non-null    object 
 13  AHD        303 non-null    object 
dtypes: float64(2), int64(9), object(3)
memory usage: 33.3+ KB

변수 설명

  • Age : 나이(년)

  • Sex : 성별 (1 = 남성; 0 = 여성)

  • ChestPain : 가슴 통증 유형 (typical = 전형적 협심증; nontypical = 비전형적 협심증; nonanginal = 비협심증 통증; asymptomatic = 무증상)

  • RestBP : 안정시 혈압(입원시 측정, 단위: mm Hg)

  • Chol : 혈청 콜레스테롤(mg/dl)

  • Fbs : 공복 혈당 > 120mg/dl (1 = 참; 0 = 거짓)

  • RestECG : 휴식 중 심전도 결과 (0 = 정상; 1 = ST-T파 이상; 2 = Estes 기준에 의해 좌심실 비대가 의심되거나 확실함)

  • MaxHR : 최대 심박수

  • ExAng : 운동 유발 협심증 (1 = 예; 0 = 아니오)

  • Oldpeak : 운동으로 유발된 ST 분절 하강

  • Slope : 최대 운동 ST 분절의 기울기 (1 = 오르막; 2 = 평평; 3 = 내리막)

  • Ca : 투시영상으로 채색된 주요 혈관의 수 (0-3)

  • Thal: 탈륨(Thallium) 스트레스 테스트(normal = 정상; fixed = 고착 결함, reversable = 가역적 결함)

  • AHD: 혈관 조영 검사에 의한 심장질환 여부 (Yes/No)

결측값 지닌 관측 제거

info() 메서드 결과를 보면, 일부 변수(CaThal)가 결측값을 지니고 있다. 따라서 dropna() 메서드를 사용해 결측값을 지닌 관측들을 제거한다. 이렇게 하면 관측 개수가 297개로 줄어든다.

df2 = Heart.dropna()
print(df2.shape)
df2.head()
(297, 14)
Age Sex ChestPain RestBP Chol Fbs RestECG MaxHR ExAng Oldpeak Slope Ca Thal AHD
0 63 1 typical 145 233 1 2 150 0 2.3 3 0.0 fixed No
1 67 1 asymptomatic 160 286 0 2 108 1 1.5 2 3.0 normal Yes
2 67 1 asymptomatic 120 229 0 2 129 1 2.6 2 2.0 reversable Yes
3 37 1 nonanginal 130 250 0 0 187 0 3.5 3 0.0 normal No
4 41 0 nontypical 130 204 0 2 172 0 1.4 1 0.0 normal No

문자형 범주를 숫자형 범주로 전환

정성적 변수 중 일부가 문자형 범주로 돼있는데, 이를 숫자형 범주로 바꿔야 한다. 예측변수 중에서는 ChestPainThal이 여기에 속하고, 반응변수인 AHD도 여기에 속한다. pandas에서 제공하는 factorize() 함수를 사용하면 숫자형 범주로 전환하는 작업을 손쉽게 수행할 수 있다.

pd.options.mode.chained_assignment = None  # default='warn': 워닝 사인 안나오게 하기

df2['ChestPain'] = pd.factorize(df2.ChestPain)[0]
df2['Thal'] = pd.factorize(df2.Thal)[0]
df2['AHD'] = pd.factorize(df2.AHD)[0]
df2.head()
Age Sex ChestPain RestBP Chol Fbs RestECG MaxHR ExAng Oldpeak Slope Ca Thal AHD
0 63 1 0 145 233 1 2 150 0 2.3 3 0.0 0 0
1 67 1 1 160 286 0 2 108 1 1.5 2 3.0 1 1
2 67 1 1 120 229 0 2 129 1 2.6 2 2.0 2 1
3 37 1 2 130 250 0 0 187 0 3.5 3 0.0 1 0
4 41 0 3 130 204 0 2 172 0 1.4 1 0.0 1 0

예측변수 및 반응변수

데이터세트에서 심장질환 여부를 의미하는 AHD를 반응변수 y로 지정하고, 나머지 총 13개 변수를 예측변수 X로 지정한다.

X2 = df2.drop('AHD', axis=1)
y2 = df2['AHD']

모델 및 피팅

사이킷런의 DecisionTreeClassifier() 함수를 사용해 분류 트리 모형을 설정한다. 우선 끝마디 최대 개수(max_leaf_nodes)를 6개로 했다. 다음으로 예측변수 최대 개수(max_features)를 3개로 정했는데, 이는 각 마디에서 이진 분할을 할 때, 예측변수를 최대 몇 개 사용하는지를 의미한다. 그런데 이 경우 주어진 예측변수 개수는 총 13개인데, 모든 이진 분할을 실행할 때, 이 중에서 최대 3개 변수만 사용하는 것이기 때문에 3개를 어떻게 고를지가 문제이다. 이 점에 있어서 DecisionTreeClassifier() 함수는 “무작위로” 고르는 방식을 취한다. 모든 분할 작업에서 매번 13개 변수 중에 3개를 무작위로 선택하는 것이다. 따라서 이런 경우(즉, 주어진 예측변수 개수보다 max_features를 작게 설정하는 경우), 트리 피팅을 실행할 때마다 결과가 달라질 가능성이 크다. 이것을 원하지 않는다면, DecisionTreeClassifier() 함수 파라미터 중 random_state에 임의의 정수를 부여해놓으면, 다음 번에 실행할 때도 동일한 결과를 얻는다(아래 예에서는 “123”이라는 숫자를 부여함).

이렇게 설정한 모델을 fit() 메서드를 사용해 데이터(X2, y2)에 피팅시킨다.

clf = DecisionTreeClassifier(max_depth=None, 
                             max_leaf_nodes=6, 
                             max_features=3,
                             random_state=123)
clf.fit(X2, y2)
DecisionTreeClassifier(max_features=3, max_leaf_nodes=6, random_state=123)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

트리 결과

사이킷런 tree 모듈의 plot_tree() 함수를 사용해 트리 결과를 그림으로 그린 것이 아래 나와 있다. 우리가 모델에서 설정한 대로 끝마디 개수가 6개인 것을 확인할 수 있다.

트리 생성 오류

ChatGPT Q&A#

아래 명령을 실행했더니 오류가 발생했다??

fig = plt.figure(figsize=(10,6), dpi=500)  
ax = tree.plot_tree(clf,  
              feature_names=X2.columns,  
              class_names=['No', 'Yes'],  
              filled=True,  
              fontsize=9)  

오류 내용

InvalidParameterError: The ‘feature_names’ parameter of plot_tree must be an instance of ‘list’ or None. Got Index([‘Age’, ‘Sex’, ‘ChestPain’, ‘RestBP’, ‘Chol’, ‘Fbs’, ‘RestECG’, ‘MaxHR’, ‘ExAng’, ‘Oldpeak’, ‘Slope’, ‘Ca’, ‘Thal’], dtype=’object’) instead.


plot_tree 함수에서 발생한 오류는 feature_names 매개변수에 전달된 X2.columnslist 형태가 아니라 pandasIndex 객체이기 때문에 발생했습니다. plot_tree 함수는 feature_nameslist 형식의 입력을 요구합니다. 따라서, X2.columnslist로 변환해주어야 합니다.

아래 코드는 X2.columnslist로 변환하여 feature_names 매개변수에 제대로 전달하는 방법을 보여줍니다:

fig = plt.figure(figsize=(10,6), dpi=500)  
ax = tree.plot_tree(clf,  
              feature_names=list(X2.columns),  
              class_names=['No', 'Yes'],  
              filled=True,  
              fontsize=9)

이렇게 list(X2.columns)를 사용하여 Index 객체를 list로 변환함으로써, plot_tree 함수의 feature_names 매개변수로 올바른 형태의 입력을 제공할 수 있습니다.

ChatGPT가 제시한 코드 실행

fig = plt.figure(figsize=(10,6), dpi=500)
ax = tree.plot_tree(clf,
                   feature_names=list(X2.columns),
                   class_names=['No', 'Yes'],
                   filled=True,
                   fontsize=9)

분류 트리: Carseats 데이터세트#

이번에는 Carseats 데이터를 사용해 다시 한 번 분류 트리를 만들어 보자. 이것은 어느 어린이용 카시트 판매회사의 400개 매장별 판매량과 관련된 데이터세트이다.

데이터 로딩

Carseats = pd.read_csv('../Data/Carseats.csv')
Carseats
Sales CompPrice Income Advertising Population Price ShelveLoc Age Education Urban US
0 9.50 138 73 11 276 120 Bad 42 17 Yes Yes
1 11.22 111 48 16 260 83 Good 65 10 Yes Yes
2 10.06 113 35 10 269 80 Medium 59 12 Yes Yes
3 7.40 117 100 4 466 97 Medium 55 14 Yes Yes
4 4.15 141 64 3 340 128 Bad 38 13 Yes No
... ... ... ... ... ... ... ... ... ... ... ...
395 12.57 138 108 17 203 128 Good 33 14 Yes Yes
396 6.14 139 23 3 37 120 Medium 55 11 No Yes
397 7.41 162 26 12 368 159 Medium 40 18 Yes Yes
398 5.94 100 79 7 284 95 Bad 50 12 Yes Yes
399 9.71 134 37 0 27 120 Good 49 16 Yes Yes

400 rows × 11 columns

변수 설명

  • Sales: 매장의 판매량(단위: 천 개)

  • CompPrice: 매장에서 경쟁사가 부과하는 가격

  • Income: 지역 소득 수준(천 달러)

  • Advertising: 매장의 광고 예산(천 달러)

  • Population: 지역의 인구 규모(천 명)

  • Price: 매장이 부과하는 카시트 가격

  • ShelveLoc: 매장에서 카시트가 전시되는 매장 내 공간을 Bad, Medium, Good의 세 가지 등급으로 평가

  • Age: 지역 인구의 평균 연령

  • Education: 지역의 교육 수준

  • Urban: 매장이 도시에 있는지를 YesNo로 표시

  • US: 매장이 미국내에 있는지를 YesNo로 표시

Sales 변수를 이진 변수로 전환

트리를 사용해 카시트 판매량(Sales)을 결정하는 요인을 분석하려고 한다. 회귀 트리가 아니라 분류 트리 만드는 것을 연습해보기 위해 정량적 변수인 Sales를 이진 변수(bianry variable)로 인코딩한다. map 메서드와 lambda 함수를 사용하여 High라는 이름의 범주형 변수를 생성한다. 이 변수는 Sales가 8을 초과하면 1의 값을 갖고, 그렇지 않으면 0인 더미변수이다.

한편, ShelveLoc 변수의 경우, 매장에서 카시트가 전시되는 매장 내 공간을 Bad, Medium, Good의 세 가지 범주로 구분한 것으로서 이것 역시 문자형 범주를 숫자형으로 바꿔야 한다. 이를 위해 pd.factorize() 함수를 사용했다. 이와 함께, UrbanUS 변수 역시 각각 map 메서드를 사용해서 Yes는 1, No는 0의 값을 갖는 더미변수로 만들었다.

Carseats['High'] = Carseats.Sales.map(lambda x: 1 if x>8 else 0)

Carseats.ShelveLoc = pd.factorize(Carseats.ShelveLoc)[0]
Carseats.Urban = Carseats.Urban.map({'No':0, 'Yes':1})
Carseats.US = Carseats.US.map({'No':0, 'Yes':1})

Carseats.head()
Sales CompPrice Income Advertising Population Price ShelveLoc Age Education Urban US High
0 9.50 138 73 11 276 120 0 42 17 1 1 1
1 11.22 111 48 16 260 83 1 65 10 1 1 1
2 10.06 113 35 10 269 80 2 59 12 1 1 1
3 7.40 117 100 4 466 97 2 55 14 1 1 0
4 4.15 141 64 3 340 128 0 38 13 1 0 0

예측변수 및 반응변수

데이터세트에서 판매량이 많고 적음을 의미하는 High를 반응변수 y로 지정하고, 이것을 제외한 나머지 총 10개를 예측변수 X로 지정한다.

X = Carseats.drop(['Sales', 'High'], axis=1)
y = Carseats.High

모델 및 피팅

DecisionTreeClassifier() 함수를 사용해 분류 트리 모형을 설정한다. 여기에서는 트리의 최대 깊이(max_depth)를 6으로 제한했다. 트리의 “깊이”란 뿌리마디 아래로 몇 단계까지 마디가 성장하는지를 의미한다.

한편, 아래 명령문에서 random_state를 설정한 이유는 트리를 만들 때 무작위성(randomness)이 개입될 수 있기 때문이다. 우리는 바로 위 Heart 데이터세트 예에서 예측변수 최대 개수(max_features)와 관련하여 무작위성이 개입되기 때문에 다음 번에 실행할 때도 동일한 결과를 얻기 위해서는 random_state에 임의의 정수를 부여해야 한다는 것을 알았다. 그런데 아래의 분류 트리 모델에서 max_features를 따로 지정하지 않았는데도 random_state를 둔 이유는 또 다른 무작위성이 발생할 수 있기 때문이다. 즉 이진 분할을 할 때, 가령 지니 지수 값을 계산했는데, 어떤 두 가지 이상 선택지의 값이 정확히 동일한 경우가 발생할 수 있다. 이런 경우 DecisionTreeClassifier() 함수는 그 중 무작위로 하나를 고르게 된다. 이런 이유 때문에 트리 피팅을 실행할 때마다 결과가 달라질 수 있으며, 이런 상황을 원치 않으면 아래와 같이 random_state에 임의의 숫자를 부여해놓으면 된다.

clf = DecisionTreeClassifier(max_depth=6,
                             random_state=123)
clf.fit(X, y)
DecisionTreeClassifier(max_depth=6, random_state=123)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

트리 결과

아래 트리 그림 결과를 보면, 앞에서 트리의 깊이가 6이라는 것이 무엇을 의미하는지 확인할 수 있다.

fig = plt.figure(figsize=(25,7), dpi=500)
ax = tree.plot_tree(clf,
                   feature_names=list(X.columns),
                   class_names=['No', 'Yes'],
                   filled=True,
                   fontsize=6)

분류 성과

혼동행렬(confusion matrix, 분류결과표)은 앞의 7장 “로지스틱 회귀를 이용한 분류”의 부록에서 설명했듯이 관측 중 얼마만큼이 정확하게 분류되었고, 얼마만큼이 잘못 분류되었는지를 표로 정리한 것이다. 사이킷런(sklearn)이 제공하는 confusion_matrix() 함수를 이용하여 혼동행렬을 만들 수 있다.

아래 결과에서 각 열(column)의 레이블이 “예측(Predicted)”에 해당하고 각 행(row)의 레이블이 “실제(True)” 관측에 해당한다. 혼동행렬에서 정확도는 전체 관측 중에서 올바르게 예측된 관측의 비율인데, 아래 결과를 보면 총 400개 관측 중 \(3+30=33\)개(8.3%)를 제외하고는 올바르게 예측됐다. 따라서 오분류율은 8.3%이고, 정확도는 91.7%이다.

cm = confusion_matrix(y, clf.predict(X))
cm_df = pd.DataFrame(cm, index=['No', 'Yes'], columns=['No', 'Yes'])
cm_df.index.name = 'True'
cm_df.columns.name = 'Predicted'
print(cm_df)
Predicted   No  Yes
True               
No         233    3
Yes         30  134

한편, classification_report()을 이용하면 혼동행렬과 관련하여 정확도(accuracy) 등 여러 정보들을 얻을 수 있다. 아래 결과에서 정확도가 91.7%라는 것을 확인할 수 있다.

print(classification_report(y, clf.predict(X), digits=3))
              precision    recall  f1-score   support

           0      0.886     0.987     0.934       236
           1      0.978     0.817     0.890       164

    accuracy                          0.917       400
   macro avg      0.932     0.902     0.912       400
weighted avg      0.924     0.917     0.916       400

가지치기#

앞에서 트리의 크기를 줄이는 가지치기(pruning)에 대해 살펴 봤다. 일단 큰 트리를 만든 다음, 어떤 기준에 의해 트리의 가지들을 없애 크기를 줄이는 것이다. 대표적인 것이 소위 비용-복잡성 가지치기(cost-complexity pruning: “ccp”)로서 회귀 트리와 관련된 내용이 식 12.4에 나와 있다. 이 식에서 조정 파라미터 \(\alpha\)값을 적절히 선택해 가지치기를 수행하게 된다.(또는 교차검증을 통해 최적의 \(\alpha\)값을 선택한다.) 식 12.4는 회귀 트리에 대한 것이지만, 분류 트리도 사실상 이와 동일한 방식으로 비용-복잡성 가지치기를 한다.

분류 트리에 대해 가지치기를 수행하려면, DecisionTreeClassifier() 함수의 파라미터 ccp_alpha에 어떤 플러스 값을 지정해주기만 하면 된다. ccp_alpha는 식 12.4의 \(\alpha\)값에 해당하는 것으로 앞에서 설명했듯이 이 값이 0이면, 가지치기를 하지 않은 원래 트리가 생성된다. DecisionTreeClassifier() 함수에서 ccp_alpha 파라미터는 기본값이 0이기 때문에 이 값을 따로 지정하지 않는 한, 비용-복잡성 가지치기가 행해지지 않고 원래의 큰 트리가 생성된다. 여기에서는 앞에서 피팅한 원래의 트리(즉 max_depth=6)에 대해 ccp_alpha=0.01을 추가하여 가지치기를 실행했다.

clf = DecisionTreeClassifier(max_depth=6,
                             ccp_alpha=0.01,
                             random_state=123)
clf.fit(X, y)
DecisionTreeClassifier(ccp_alpha=0.01, max_depth=6, random_state=123)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

트리 결과(가지치기된 트리)

아래의 가지치기된 트리 결과를 보면, 앞에서 본 원래의 큰 트리에 비해 크기가 대폭 축소된 것을 확인할 수 있을 것이다.

fig = plt.figure(figsize=(15,5), dpi=500)
ax = tree.plot_tree(clf,
                   feature_names=list(X.columns),
                   class_names=['No', 'Yes'],
                   filled=True,
                   fontsize=5)

분류 성과(가지치기된 트리)

가지치기를 실행한 트리에 대해 혼동행렬로 분류 성과를 평가한 결과가 아래 나와 있다. 앞의 가지치기 이전의 트리에 비해 정확도가 91.7%에서 86.0%로 크게 낮아진 것을 알 수 있다. 이와 같은 결과는 결코 놀라운 것이 아니다. 가지치기의 목표 자체가 데이터의 과적합을 막으려는 것이기 때문이다. 즉 모델이 훈련 세트에 대해서(만) 분류를 너무 잘하는 바람에 테스트 세트에 대한 분류 성과가 크게 떨어질 수 있는 것이 과적합 문제인 것이다. 따라서 가지치기에 의해 트리가 훨씬 단순해지면 훈련 세트에 대해서는 분류 성과가 떨어지는 것은 충분히 예상되는 일이다. 따라서 추정에 사용된 훈련 세트를 대상으로 가지치기의 성과를 비교해서는 안되고, 훈련 세트와 테스트 세트를 분리한 다음, 훈련 세트를 사용해 도출한 트리를 테스트 세트에 적용하는 방식으로 분류 성과를 비교할 필요가 있다.

cm = confusion_matrix(y, clf.predict(X))
cm_df = pd.DataFrame(cm, index=['No', 'Yes'], columns=['No', 'Yes'])
cm_df.index.name = 'True'
cm_df.columns.name = 'Predicted'
print(cm_df)
print()
print(classification_report(y, clf.predict(X), digits=3))
Predicted   No  Yes
True               
No         220   16
Yes         40  124

              precision    recall  f1-score   support

           0      0.846     0.932     0.887       236
           1      0.886     0.756     0.816       164

    accuracy                          0.860       400
   macro avg      0.866     0.844     0.851       400
weighted avg      0.862     0.860     0.858       400

가지치기 전후의 테스트 오류 비교#

데이터세트 분할

Carseats 데이터세트를 임의로 절반씩 나누어 훈련 세트와 테스트 세트로 분할한다. sklearn model_selection 모듈의 train_test_split() 함수를 사용하면, 데이터세트 분리 작업을 간단히 수행할 수 있다.

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.5, random_state=0)

기본 트리

앞에서와 마찬가지로 트리의 최대 깊이(max_depth)가 6인 경우를 (가지치기 이전의) 기본 트리로 삼기로 한다. 훈련 세트(X_train, y_train)에 피팅한 결과를 테스트 세트(X_test)에 적용하여 예측 범주(pred)를 구했다.

clf = DecisionTreeClassifier(max_depth=6,
                             random_state=123)
clf.fit(X_train, y_train)
pred = clf.predict(X_test)

기본 트리의 테스트 오류

테스트 세트에 대해 예측 범주(pred)와 실제 범주(y_test)를 비교하여 혼동행렬을 작성했다. 아래 결과를 보면, 가지치기 이전 원래의 큰 트리에 있어서 테스트 정확도는 74.5%이다. 즉 테스트 오분류율이 25.5%인 것이다.

cm = confusion_matrix(y_test, pred)
cm_df = pd.DataFrame(cm, index=['No', 'Yes'], columns=['No', 'Yes'])
cm_df.index.name = 'True'
cm_df.columns.name = 'Predicted'
print(cm_df)
print()
print(classification_report(y_test, pred, digits=3))
Predicted  No  Yes
True              
No         99   19
Yes        32   50

              precision    recall  f1-score   support

           0      0.756     0.839     0.795       118
           1      0.725     0.610     0.662        82

    accuracy                          0.745       200
   macro avg      0.740     0.724     0.729       200
weighted avg      0.743     0.745     0.741       200

가지치기된 트리

앞에서와 마찬가지로 비용-복잡성 가지치기의 \(\alpha\)값(ccp_alpha)을 0.01로 정하여 가지치기된 트리를 구했다. 그런 다음, 동일한 절차를 통해 테스트 관측에 대한 예측 범주(pred)를 구했다.

clf = DecisionTreeClassifier(max_depth=6,
                             ccp_alpha=0.01,
                             random_state=123)
clf.fit(X_train, y_train)
pred = clf.predict(X_test)

가지치기된 트리의 테스트 오류

테스트 세트에 대해 예측 범주(pred)와 실제 범주(y_test)를 비교하여 혼동행렬을 작성했다. 아래 결과를 보면, 공교롭게도 가지치기된 트리의 테스트 정확도 역시 앞의 원래 큰 트리와 마찬가지로 74.5%이다. 우리는 앞에서 훈련 세트에 대해서는 가지치기된 트리의 정확도가 크게 낮아지는 것을 보았는데, 테스트 세트에 대해서는 정확도에 차이가 없게 나타난 것이다.

물론 \(\alpha\)값, 즉 ccp_alpha 파라미터 값을 어떻게 정하느냐에 따라, 그리고 어떤 테스트 세트를 사용하느냐에 따라 결과가 달라지겠지만, 어쨌든 여기서 한 가지 확인할 수 있는 것은 가지치기를 통해 트리의 크기를 대폭 줄였음에도 테스트 분류 성과는 별 차이가 없다는 점이다.

cm = confusion_matrix(y_test, pred)
cm_df = pd.DataFrame(cm, index=['No', 'Yes'], columns=['No', 'Yes'])
cm_df.index.name = 'True'
cm_df.columns.name = 'Predicted'
print(cm_df)
print()
print(classification_report(y_test, pred, digits=3))
Predicted  No  Yes
True              
No         97   21
Yes        30   52

              precision    recall  f1-score   support

           0      0.764     0.822     0.792       118
           1      0.712     0.634     0.671        82

    accuracy                          0.745       200
   macro avg      0.738     0.728     0.731       200
weighted avg      0.743     0.745     0.742       200