"사용자가 결제 버튼을 눌렀는가?", "최근 오류가 있었는가?", "재시도 횟수가 기준 이상인가?"처럼 조건문을 따라 결과를 판단하는 방식은 개발자에게 익숙하다.
결정트리(Decision Tree)는 이런 질문을 여러 단계로 나누어 데이터를 구분하는 머신러닝 알고리즘이다. 분석 과정을 비교적 쉽게 설명할 수 있지만 나무를 너무 깊게 성장시키면 과적합되기 쉽다.
트리는 하나의 질문에서 시작해 답에 따라 아래 방향으로 가지를 뻗는다.
재시도 횟수 >= 2?
/ \
아니요 예
/ \
결제 성공 여부? 오류 가능성 높음
/ \
성공 실패
기본 용어는 다음과 같다.
실제 트리는 학습 데이터를 보고 분할 조건을 찾는다. 사람이 위의 조건을 미리 모두 입력하는 것은 아니다.
한쪽에는 성공 데이터만 있고 다른 쪽에는 실패 데이터만 모이면 구분이 잘된 것이다.
반대로 성공과 실패가 뒤섞인 노드는 구분이 어렵다. 이 혼합 정도를 불순도(Impurity)라고 한다.
트리 알고리즘은 일반적으로 분할한 뒤 불순도를 많이 낮추는 조건을 선택한다.
분류 트리의 대표적인 기준이다.
[
Gini = 1-\sum_{k=1}^{K}p_k^2
]
p_k는 노드에 속한 클래스 k의 비율이다.
두 클래스가 절반씩 섞여 있다면:
[
Gini = 1-(0.5^2+0.5^2)=0.5
]
모두 같은 클래스라면:
[
Gini = 1-(1^2+0^2)=0
]
def gini(class_ratios):
return 1 - sum(p ** 2 for p in class_ratios)
print(gini([0.5, 0.5])) # 0.5
print(gini([1.0, 0.0])) # 0.0
지니 불순도가 작을수록 해당 노드의 클래스가 더 순수하다.
또 다른 불순도 기준은 엔트로피(Entropy)다.
[
H = -\sum_{k=1}^{K}p_k\log_2(p_k)
]
p=0인 항은 0으로 취급한다. 두 클래스가 각각 50%면 엔트로피는 1이고, 한 종류만 있으면 0이다.
좋은 분할은 분할 전보다 분할 후 엔트로피를 낮춘다. 감소량을 정보이득(Information Gain)이라 한다.
[
IG = H(parent)-\sum_j\frac{n_j}{n}H(child_j)
]
여기서 자식 노드의 엔트로피는 표본 수에 따라 가중 평균한다.
인공 결제 요청 데이터로 간단한 예측기를 만들어 보자.
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier, plot_tree
from sklearn.metrics import classification_report
import matplotlib.pyplot as plt
rng = np.random.default_rng(42)
n = 500
retry_count = rng.integers(0, 6, n)
latency_ms = rng.uniform(100, 3000, n)
amount = rng.uniform(1000, 120000, n)
risk = (retry_count >= 3) | ((latency_ms > 2000) & (amount > 50000))
failed = np.where(rng.random(n) < 0.07, ~risk, risk).astype(int)
X = pd.DataFrame({
"retry_count": retry_count,
"latency_ms": latency_ms,
"amount": amount,
})
y = failed
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y
)
model = DecisionTreeClassifier(max_depth=3, random_state=42)
model.fit(X_train, y_train)
pred = model.predict(X_test)
print(classification_report(y_test, pred))
plt.figure(figsize=(13, 6))
plot_tree(
model,
feature_names=X.columns,
class_names=["success", "failure"],
filled=True,
rounded=True,
fontsize=9,
)
plt.show()
plot_tree()를 사용하면 외부 Graphviz 설치 없이도 기본적인 트리 구조를 확인할 수 있다.
결정트리는 분류뿐 아니라 회귀에도 사용할 수 있다.
DecisionTreeClassifier: 리프의 클래스를 예측DecisionTreeRegressor: 리프의 수치값을 예측from sklearn.tree import DecisionTreeRegressor
rng = np.random.default_rng(21)
X_reg = rng.uniform(0, 10, (250, 1))
y_reg = np.sin(X_reg[:, 0]) * 20 + rng.normal(0, 2, 250)
regressor = DecisionTreeRegressor(max_depth=4, random_state=42)
regressor.fit(X_reg, y_reg)
print(regressor.predict([[3.5]]))
분류에서는 보통 지니 불순도나 엔트로피를 고려하고, 회귀에서는 제곱 오차 같은 기준으로 노드를 나눈다. 따라서 클래스 예측 문제에 Regressor를 사용하지 않도록 주의하자.
깊이를 제한하지 않고 나무를 계속 키우면 학습 데이터를 지나치게 세밀하게 나눌 수 있다. 학습 점수는 높아지더라도 새로운 데이터에서는 성능이 떨어질 수 있다.
이 문제를 다루기 위한 대표적인 하이퍼파라미터는 다음과 같다.
| 파라미터 | 의미 |
|---|---|
max_depth | 트리 최대 깊이 |
min_samples_split | 노드를 분할하기 위한 최소 표본 수 |
min_samples_leaf | 리프 노드에 남겨야 할 최소 표본 수 |
max_leaf_nodes | 만들 수 있는 최대 리프 수 |
ccp_alpha | 비용 복잡도 가지치기 강도 |
simple_tree = DecisionTreeClassifier(
max_depth=4,
min_samples_leaf=8,
random_state=42,
)
simple_tree.fit(X_train, y_train)
print("Train:", simple_tree.score(X_train, y_train))
print("Test: ", simple_tree.score(X_test, y_test))
이런 복잡도 제한을 가지치기(Pruning)라고 부른다. 제한을 과하게 주면 과소적합이 생길 수도 있다.
학습한 트리에는 feature_importances_가 있다.
importance = pd.Series(
model.feature_importances_,
index=X.columns,
).sort_values(ascending=False)
print(importance)
기본 변수 중요도는 트리를 만들 때 각 변수가 불순도를 얼마나 줄이는 데 기여했는지에 기반한다.
하지만 중요도가 높다고 그 변수가 결과의 원인이라고 단정하면 안 된다. 불순도 기반 중요도는 값의 종류가 많은 변수에 유리해지는 편향도 있으므로, 필요하다면 검증 데이터에서 permutation_importance()를 함께 사용하자.
결정트리는 보통 변수의 값이 특정 임계값보다 큰지 작은지를 판단하므로 표준화가 필수는 아니다.
반면 문자열 범주형 변수는 사용 중인 Scikit-learn 구현과 데이터 형태에 맞게 처리해야 한다. 순서가 없는 범주에 단순히 0, 1, 2를 부여하면 그 숫자에 거짓된 순서 관계가 생길 수 있다.
따라서 일반적인 경우에는 One-Hot Encoding 같은 명시적 처리를 우선 검토하고, 범주 수가 너무 많다면 별도의 인코딩 전략이나 범주형 변수를 직접 지원하는 모델을 고려해야 한다.