前回は、ロジスティック回帰を使って、線形スコアを確率に変換し、しきい値で分類する方法を見ました。
今回は、もう一つの代表的な分類モデルである 決定木 を扱います。決定木は、データに対して「持ち家はあるか」「信用履歴は良いか」「年収は一定以上か」のような質問を順番に投げかけ、最後にクラスを決めるモデルです。
ファインマン風に言えば、決定木は「はい/いいえの質問をうまい順番で並べて、迷子にならずに答えへたどり着く分類器」です。
目次
- 決定木で何をしたいのか
- この記事で使うデータについて
- 決定木の部品を押さえる
- エントロピー:混ざり具合を測る
- 条件付きエントロピー:質問した後の混ざり具合
- 情報利得と ID3:よい質問を選ぶ
- C4.5 と情報利得比
- ジニ指数と CART
- 枝刈り(剪定):伸びすぎた木を抑える
- 連続値と欠損値の扱い
- 多変量決定木という考え方
- Scikit-learn で決定木を使う
- ケース1:Iris の品種を分類する
- ケース2:架空の個人ローン審査を分類する
- 分類モデルの評価指標
- まとめ
1. 決定木で何をしたいのか
決定木は、入力された特徴量を使って、サンプルを少しずつ分けていくモデルです。
たとえば、個人ローンの審査を単純化して考えると、次のような質問を順番にしていくイメージです。
- 持ち家はあるか
- 安定収入はあるか
- 信用履歴は良いか
- 年収は一定以上か
質問に答えていくと、最後に「承認」または「見送り」のような分類結果にたどり着きます。
ここで大事なのは、決定木が「質問の順番」をデータから学習することです。
人間がなんとなく「持ち家が重要そう」と決めるのではなく、データをよく分けられる特徴量を指標で選びます。その指標として、この後で出てくるエントロピー、情報利得、ジニ指数などを使います。
具体的な指標の話に入る前に、まずこの記事で使うデータを確認しておきましょう。
2. この記事で使うデータについて
この記事では、コードの目的に応じてデータを分けて使います。
- 決定木の仕組みを手で追う部分では、説明用の 架空の個人ローン審査データ をコード内で作ります。外部ファイルに依存しないので、そのままコピーして動かせます。
- Scikit-learn の基本例では、ライブラリに付属している Iris データセット を使います。
sklearn.datasetsから読み込めるため、ローカルの独自ファイルには依存しません。 - 個人信用リスク評価のケースでも、この記事では小さな架空データを作ります。ほかのケースと同じく、外部ファイルを用意しなくてもそのままコピーして同じ前処理と評価の流れを再現できるようにするためです。
まず、仕組みの説明に使う架空データを用意します。
import pandas as pd
loan_jp = pd.DataFrame({
"年代": [
"20代", "20代", "20代", "20代", "20代",
"30-40代", "30-40代", "30-40代", "30-40代", "30-40代",
"50代以上", "50代以上", "50代以上", "50代以上", "50代以上",
],
"安定収入": [
"なし", "なし", "あり", "あり", "なし",
"なし", "なし", "あり", "なし", "なし",
"なし", "なし", "あり", "あり", "なし",
],
"持ち家": [
"なし", "なし", "なし", "あり", "なし",
"なし", "なし", "あり", "あり", "あり",
"あり", "あり", "なし", "なし", "なし",
],
"信用履歴": [
"普通", "良好", "良好", "普通", "普通",
"普通", "良好", "良好", "とても良好", "とても良好",
"とても良好", "良好", "良好", "とても良好", "普通",
],
"審査結果": [
"見送り", "見送り", "承認", "承認", "見送り",
"見送り", "見送り", "承認", "承認", "承認",
"承認", "承認", "承認", "承認", "見送り",
],
})
print(loan_jp)
各列の意味は次の通りです。
| 列名 | 意味 |
|---|---|
年代 |
申込者の年代。ここでは 20代、30-40代、50代以上 の3区分にしています。 |
安定収入 |
継続的な収入があるかどうか。 |
持ち家 |
持ち家があるかどうか。 |
信用履歴 |
過去の返済状況を簡略化した評価。 |
審査結果 |
今回予測したいラベル。承認 または 見送り です。 |
このデータは説明用に作った架空データであり、実際の審査基準を表すものではありません。決定木が「どの特徴量で分けるとラベルがそろいやすいか」を学ぶ様子を見るための小さな表です。
データが用意できたところで、まずは決定木を組み立てる部品の名前を先に押さえておきましょう。
3. 決定木の部品を押さえる
決定木は、木の形をしたモデルです。よく出てくる用語を先に整理します。
| 用語 | 意味 |
|---|---|
| 根ノード | 最初に使う質問。木の一番上にあります。 |
| 内部ノード | 途中で使う質問。さらに枝分かれします。 |
| 枝 | 質問に対する答えや条件。 |
| 葉ノード | 最終的な予測結果。ここで分類が決まります。 |
決定木の学習で一番大事な問いは、次の一文に集約できます。
今いるデータを、次にどの特徴量で分けると一番きれいになるか。
ここでいう「きれい」とは、分けた先のグループでラベルがなるべくそろっている状態です。たとえば、ある枝の先にあるサンプルがすべて 承認 なら、その枝はとてもきれいです。逆に 承認 と 見送り が半分ずつ混ざっているなら、まだ迷いが大きい状態です。
この「混ざり具合」を数字にするために、次はエントロピーを見ます。
4. エントロピー:混ざり具合を測る
エントロピーは、不確実性や混ざり具合を表す指標です。
分類の文脈では、次のように考えるとわかりやすいです。
- 1つのグループがすべて
承認なら、迷いはほぼない -
承認と見送りが半分ずつ混ざっているなら、迷いが大きい - 迷いが大きいほど、エントロピーは大きくなる
クラスの確率が $p_1,p_2,\ldots,p_K$ のとき、エントロピーは次のように書けます。
$$
H(p)=-\sum_{k=1}^{K}p_k\log_2 p_k
$$
二分類なら、承認 の確率を $p$、見送り の確率を $1-p$ として、次の形になります。
$$
H(p)=-p\log_2p-(1-p)\log_2(1-p)
$$
コードで、確率とエントロピーの関係を見てみます。
import numpy as np
import matplotlib.pyplot as plt
def binary_entropy(p):
p = np.asarray(p, dtype=float)
p = np.clip(p, 1e-15, 1 - 1e-15)
return -(p * np.log2(p) + (1 - p) * np.log2(1 - p))
probabilities = np.linspace(0, 1, 101)
entropy_values = binary_entropy(probabilities)
plt.plot(probabilities, entropy_values)
plt.xlabel("承認の割合 p")
plt.ylabel("エントロピー")
plt.title("二分類のエントロピー")
plt.show()
$p=0.5$ の近くでエントロピーが最大になります。これは、承認 と 見送り が半分ずつ混ざっていて、最も迷う状態だからです。
では、2節の loan_jp 全体のエントロピーを計算してみます。
import numpy as np
def entropy(labels):
probabilities = pd.Series(labels).value_counts(normalize=True)
return -(probabilities * np.log2(probabilities)).sum()
base_entropy = entropy(loan_jp["審査結果"])
print(f"データ全体のエントロピー: {base_entropy:.3f}")
print(loan_jp["審査結果"].value_counts())
このデータでは 承認 が9件、見送り が6件です。そのため、エントロピーは次のようになります。
$$
H(D)=-\frac{9}{15}\log_2\frac{9}{15}
-\frac{6}{15}\log_2\frac{6}{15}
\approx0.971
$$
ここまでで、分ける前の混ざり具合を測れるようになりました。次は、ある質問で分けた後に、混ざり具合がどれくらい残るかを見ます。
5. 条件付きエントロピー:質問した後の混ざり具合
条件付きエントロピーは、「ある特徴量でデータを分けた後、各グループにどれくらい混ざりが残っているか」を表します。
特徴量 $A$ でデータ $D$ を $D_1,D_2,\ldots,D_v$ に分けたとします。このとき、条件付きエントロピーは次のように書けます。
$$
H(D|A)=\sum_{i=1}^{v}\frac{|D_i|}{|D|}H(D_i)
$$
大きいグループの混ざり具合は強く、小さいグループの混ざり具合は弱く効くように、サンプル数の比率で重みを付けています。
コードにすると、次のようになります。
def conditional_entropy(data, feature, target):
total = len(data)
score = 0.0
for _, subset in data.groupby(feature):
weight = len(subset) / total
score += weight * entropy(subset[target])
return score
for feature in ["年代", "安定収入", "持ち家", "信用履歴"]:
value = conditional_entropy(loan_jp, feature, "審査結果")
print(f"{feature}: {value:.3f}")
実行すると、持ち家が0.551、安定収入が0.647、信用履歴が0.608、年代が0.888という値になります。分ける前のエントロピーが0.971だったことを思い出すと、持ち家で分けたときに一番大きく下がっていることがわかります。
ファインマン風に言えば、条件付きエントロピーは「その質問をした後でも、まだどれくらい迷っているか」です。
よい質問なら、質問した後のグループでラベルがかなりそろいます。つまり、条件付きエントロピーは小さくなります。この「どれだけ減ったか」を直接の数字にしたものが、次に見る情報利得です。
6. 情報利得と ID3:よい質問を選ぶ
情報利得は、特徴量 $A$ で分けたことによって、どれくらい不確実性が減ったかを表します。
$$
g(D,A)=H(D)-H(D|A)
$$
分ける前のエントロピーから、分けた後の条件付きエントロピーを引くだけです。
情報利得 = 分ける前の迷い - 分けた後に残った迷い
情報利得が大きいほど、その特徴量はデータをよく整理できたことになります。
2節のデータで計算してみます。
def information_gain(data, feature, target):
return entropy(data[target]) - conditional_entropy(data, feature, target)
feature_cols = ["年代", "安定収入", "持ち家", "信用履歴"]
gain_table = pd.DataFrame({
"特徴量": feature_cols,
"条件付きエントロピー": [
conditional_entropy(loan_jp, feature, "審査結果")
for feature in feature_cols
],
"情報利得": [
information_gain(loan_jp, feature, "審査結果")
for feature in feature_cols
],
})
print(gain_table.sort_values("情報利得", ascending=False).round(3))
実行結果を情報利得が大きい順に並べると、持ち家(0.420)、信用履歴(0.363)、安定収入(0.324)、年代(0.083)という順になります。
この架空データでは、持ち家 の情報利得が最も大きくなります。つまり、最初の質問としては「持ち家があるか」が有力です。
実際に、その分かれ方を確認してみます。
print(pd.crosstab(loan_jp["持ち家"], loan_jp["審査結果"]))
持ち家 が あり のグループは 承認 にかなり寄っています。一方、なし のグループには 承認 と 見送り が混ざっているので、さらに別の質問が必要です。
ID3 は、この情報利得を使って決定木を作るアルゴリズムです。
再帰を止める代表的な条件は次の通りです。
- そのノードのサンプルがすべて同じクラスになった
- もう使える特徴量が残っていない
- 情報利得がほとんど増えない
- 木が深くなりすぎた
小さな再帰関数で、ID3 の流れも確認してみます。次のコードは、前に作った information_gain を使います。
from pprint import pprint
def majority_label(labels):
return labels.value_counts().idxmax()
def build_id3_tree(data, features, target):
labels = data[target]
if labels.nunique() == 1:
return labels.iloc[0]
if len(features) == 0:
return majority_label(labels)
gains = {
feature: information_gain(data, feature, target)
for feature in features
}
best_feature = max(gains, key=gains.get)
remaining_features = [feature for feature in features if feature != best_feature]
tree = {best_feature: {}}
for value, subset in data.groupby(best_feature):
tree[best_feature][value] = build_id3_tree(subset, remaining_features, target)
return tree
id3_tree = build_id3_tree(loan_jp, feature_cols, "審査結果")
pprint(id3_tree, width=80)
コード中の max(gains, key=gains.get) は、辞書 gains の中から、値(情報利得)が一番大きいキー(特徴量名)を取り出す書き方です。「情報利得が最大の特徴量を選ぶ」という手順を、そのまま Python の1行に落とし込んだものだと考えてください。
この簡易実装は、カテゴリ特徴量だけを前提にした学習用コードです。連続値、欠損値、枝刈りまで含めて使いたい場合は、後半で扱う Scikit-learn の DecisionTreeClassifier を使う方が現実的です。
ID3 の考え方がわかると、決定木は「その場で一番よく片づく質問を選び続ける方法」として見えてきます。
7. C4.5 と情報利得比
情報利得には、一つ注意点があります。取れる値の種類が多い特徴量を、必要以上に高く評価してしまうことがあるのです。
極端な例として、申込ID のようにサンプルごとにほぼ一意な値を持つ列を考えます。IDで分ければ、各グループに1件ずつしか入らないため、ラベルは完全にそろったように見えます。しかし、それは新しいデータへの予測にはほとんど役に立ちません。
C4.5 は、この弱点を和らげるために 情報利得比 を使います。
まず、特徴量 $A$ 自身がどれくらい細かくデータを分けるかを表す値を考えます。
$$
H_A(D)=-\sum_{i=1}^{v}\frac{|D_i|}{|D|}
\log_2\frac{|D_i|}{|D|}
$$
そして、情報利得をこの値で割ります。
$$
g_R(D,A)=\frac{g(D,A)}{H_A(D)}
$$
コードでも確認しておきます。
def split_info(data, feature):
probabilities = data[feature].value_counts(normalize=True)
return -(probabilities * np.log2(probabilities)).sum()
def gain_ratio(data, feature, target):
denominator = split_info(data, feature)
if denominator == 0:
return 0.0
return information_gain(data, feature, target) / denominator
gain_ratio_table = pd.DataFrame({
"特徴量": feature_cols,
"情報利得": [information_gain(loan_jp, feature, "審査結果") for feature in feature_cols],
"分割情報量": [split_info(loan_jp, feature) for feature in feature_cols],
"情報利得比": [gain_ratio(loan_jp, feature, "審査結果") for feature in feature_cols],
})
print(gain_ratio_table.sort_values("情報利得比", ascending=False).round(3))
ファインマン風に言えば、情報利得比は「よく分けたこと」は評価しつつ、「細かく分けすぎただけ」には少しブレーキをかける指標です。
ID3 と C4.5 は、どちらもエントロピー系の指標でした。次は、Scikit-learn の決定木で標準的に使われている、別の考え方の指標を見ていきます。
8. ジニ指数と CART
Scikit-learn の DecisionTreeClassifier で標準的に使われる基準は、情報利得ではなく ジニ不純度 です。
クラス $k$ の確率を $p_k$ とすると、ジニ不純度は次のように書けます。
$$
\operatorname{Gini}(p)=1-\sum_{k=1}^{K}p_k^2
$$
二分類なら、片方のクラスの確率を $p$ として次のようになります。
$$
\operatorname{Gini}(p)=1-p^2-(1-p)^2
$$
エントロピーと同じく、1つのクラスだけなら 0 に近く、クラスが混ざっているほど大きくなります。
def gini(labels):
probabilities = pd.Series(labels).value_counts(normalize=True)
return 1 - np.sum(probabilities ** 2)
def weighted_gini_for_category(data, feature, target):
total = len(data)
score = 0.0
for _, subset in data.groupby(feature):
score += (len(subset) / total) * gini(subset[target])
return score
gini_table = pd.DataFrame({
"特徴量": feature_cols,
"分割後のジニ不純度": [
weighted_gini_for_category(loan_jp, feature, "審査結果")
for feature in feature_cols
],
})
print(gini_table.sort_values("分割後のジニ不純度").round(3))
情報利得では「大きいほどよい」でしたが、ここで計算しているのは分割後のジニ不純度そのものなので「小さいほどよい」です。分けた後のグループがきれいなほど、ジニ不純度が小さくなるからです。
厳密には、情報利得と同じように「分割前のジニ不純度 - 分割後のジニ不純度」という差(ジニ不純度の減少量)を計算することもできます。ただし、同じデータセットの中で候補を比べるだけなら、分割前のジニ不純度はどの候補でも共通の値なので、分割後のジニ不純度が一番小さい候補を選ぶのと、減少量が一番大きい候補を選ぶのは、結局同じ結果になります。
CART は、ジニ不純度などを使いながら二分木を作る代表的な決定木アルゴリズムです。Scikit-learn の決定木も、基本的にはこの CART 系の考え方で動きます。
ここまでで、決定木が「よい質問」を選ぶための代表的な指標がそろいました。次は、木をどこまで成長させるかを考えます。
9. 枝刈り(剪定):伸びすぎた木を抑える
決定木は、放っておくと訓練データにぴったり合わせすぎることがあります。
たとえば、最後の1件まで完全に分けようとすると、訓練データでは高い正解率になっても、新しいデータでは外れやすくなります。これは過学習です。
そこで、木の複雑さを抑えるために 枝刈り(剪定) を使います。
枝刈りには、大きく分けて2つの考え方があります。
9.1 事前枝刈り
事前枝刈りは、木を作っている途中で「これ以上分けない」と早めに止める方法です。
Scikit-learn では、次のようなパラメータが事前枝刈りに対応します。
| パラメータ | 意味 |
|---|---|
max_depth |
木の最大深さを制限する |
min_samples_split |
ノードを分割するために必要な最小サンプル数 |
min_samples_leaf |
葉ノードに必要な最小サンプル数 |
max_leaf_nodes |
葉ノードの最大数を制限する |
ファインマン風に言えば、事前枝刈りは「質問を増やしすぎる前に、ここで十分と止める」方法です。
9.2 事後枝刈り
事後枝刈りは、いったん大きな木を作ってから、不要そうな枝を後で切る方法です。
Scikit-learn では、コスト複雑度枝刈りに対応する ccp_alpha というパラメータがあります。値を大きくすると、より強く枝を削る方向に働きます。
たとえば min_samples_leaf=2 にしておくと、「たった1件のサンプルのためだけに、そこまで到達する深い枝を作る」ということができなくなります。1件だけの例外に木を合わせにいくのを防ぐ、というイメージです。
決定木は説明しやすいモデルですが、深くしすぎると細かい例外ルールの集まりになってしまいます。読みやすい木に保つことも、実務では大切です。
ここまでは、木の「育ち方」を抑える話でした。次は、木に渡すデータの形、具体的には連続値と欠損値の扱いを見ていきます。
10. 連続値と欠損値の扱い
ここまでは、持ち家 や 安定収入 のようなカテゴリ特徴量を中心に見てきました。実際には、年収や年齢のような連続値もよく出てきます。
決定木で連続値を扱うときは、候補となるしきい値をいくつか試し、分割後の指標が最もよくなるしきい値を選びます。
たとえば、年収を使うなら次のような質問になります。
年収_万円 <= 420 か?
候補しきい値は、値を小さい順に並べたときの隣り合う値の中間から作れます。
$$
t_i=\frac{a_i+a_{i+1}}{2}
$$
小さなコードで確認してみます。
loan_with_income = loan_jp.copy()
loan_with_income["年収_万円"] = [
260, 280, 330, 430, 300,
310, 350, 520, 480, 560,
600, 570, 420, 460, 340,
]
def weighted_gini_for_threshold(data, feature, target, threshold):
left = data[data[feature] <= threshold]
right = data[data[feature] > threshold]
total = len(data)
return (len(left) / total) * gini(left[target]) + (len(right) / total) * gini(right[target])
sorted_values = sorted(loan_with_income["年収_万円"].unique())
thresholds = [
(left + right) / 2
for left, right in zip(sorted_values[:-1], sorted_values[1:])
]
threshold_table = pd.DataFrame({
"しきい値": thresholds,
"分割後のジニ不純度": [
weighted_gini_for_threshold(loan_with_income, "年収_万円", "審査結果", threshold)
for threshold in thresholds
],
})
print(threshold_table.sort_values("分割後のジニ不純度").head().round(3))
連続値でも、結局やっていることは同じです。「どこで切ると、左右のグループが一番きれいになるか」を探しています。
欠損値については、実装によって扱いが変わります。考え方としては、主に次の2つがあります。
- 欠損していないサンプルだけで、分割の良さを評価する
- 分割後は、欠損サンプルを各枝の比率に応じて重み付きで配る
ただし、欠損値の扱いは Scikit-learn のバージョンやモデルによって差があります。初心者のうちは、まず数値なら中央値、カテゴリなら最頻値や 不明 というカテゴリで補完してから学習させる流れを押さえると理解しやすいです。
ここまでは、1つのノードで1つの特徴量だけを見る、普通の決定木を前提にしてきました。最後に、少し発展した考え方として、複数の特徴量を同時に見る決定木にも触れておきます。
11. 多変量決定木という考え方
普通の決定木では、1つのノードで1つの特徴量だけを使います。
たとえば、次のような質問です。
年収_万円 <= 420 か?
このような分け方は、特徴量の軸に平行な境界になります。
一方、多変量決定木では、複数の特徴量を組み合わせた質問を使います。
$$
\sum_{j=1}^{d}w_jx_j=t
$$
2つの特徴量なら、次のような形です。
$$
w_1x_1+w_2x_2=t
$$
多変量決定木は、斜めの境界を作れるため表現力が上がります。ただし、1つの質問が複雑になるぶん、普通の決定木より説明しにくくなることがあります。
最初は、通常の決定木を「1つの質問で1つの特徴量を見るモデル」として理解しておけば十分です。
12. Scikit-learn で決定木を使う
指標の考え方が見えたところで、実装を Scikit-learn に任せてみます。
必要なライブラリが入っていない場合は、先にインストールします。
pip install numpy pandas matplotlib scikit-learn
Scikit-learn では、分類用の決定木は DecisionTreeClassifier として用意されています。
from sklearn.tree import DecisionTreeClassifier
model = DecisionTreeClassifier(
criterion="gini",
max_depth=3,
random_state=42,
)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
上のコードは呼び出し方を示す最小形です。X_train や y_train を実際に用意する完全な例は、次の Iris 分類と架空ローン審査のケースで示します。
よく使うパラメータは次の通りです。
| パラメータ | 意味 |
|---|---|
criterion="gini" |
ジニ不純度で分割を選ぶ。標準設定です。 |
criterion="entropy" |
エントロピーを使う。情報利得に近い見方ができます。 |
max_depth |
木の最大深さを制限する。過学習対策によく使います。 |
min_samples_split |
ノードを分割するために必要な最小サンプル数。 |
min_samples_leaf |
葉ノードに必要な最小サンプル数。 |
random_state |
再現性のための乱数シード。 |
決定木は特徴量のスケールに比較的強いモデルです。線形回帰やロジスティック回帰のように、必ず標準化しないと学習が不安定になる、というタイプではありません。ただし、欠損値処理やカテゴリ変数のエンコードは必要になります。
13. ケース1:Iris の品種を分類する
まずは、Iris データセットでアヤメの品種を分類します。第3回でも使った、Scikit-learn 付属のデータセットです。
ここからの X・y は、2節で作ったローン審査データではなく、新しく読み込む Iris データセットを指します。
import pandas as pd
from sklearn.datasets import load_iris
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier, export_text
iris = load_iris(as_frame=True)
X = iris.data
y = iris.target
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.2,
random_state=42,
stratify=y,
)
tree_model = DecisionTreeClassifier(
criterion="gini",
max_depth=3,
random_state=42,
)
tree_model.fit(X_train, y_train)
y_pred = tree_model.predict(X_test)
print(f"accuracy: {accuracy_score(y_test, y_pred):.3f}")
print(confusion_matrix(y_test, y_pred))
print(classification_report(y_test, y_pred, target_names=iris.target_names))
決定木のよいところは、学習したルールを文章として確認しやすいことです。
tree_rules = export_text(
tree_model,
feature_names=list(iris.feature_names),
)
print(tree_rules)
petal length (cm) <= 2.45 のようなルールが表示されます。これは、「花びらの長さが 2.45cm 以下なら、この枝へ進む」という意味です。
特徴量の重要度も見られます。
importance = pd.Series(
tree_model.feature_importances_,
index=iris.feature_names,
).sort_values(ascending=False)
print(importance.round(3))
feature_importances_ は、その特徴量が分割にどれくらい貢献したかを表します。ただし、重要度は学習データや木の深さに影響されます。絶対的な因果関係として読むのではなく、「このモデルでは何をよく見ていたか」の手がかりとして扱います。
14. ケース2:架空の個人ローン審査を分類する
次に、カテゴリ特徴量と数値特徴量が混ざったデータで決定木を使ってみます。
ここで使う credit_jp も、説明用に作った架空データです。記事内のコードだけで、そのまま再現できるようにしています。
| 列名 | 意味 |
|---|---|
年代 |
申込者の年代。 |
雇用形態 |
正社員、契約社員、自営業、パートなどの区分。 |
住宅状況 |
持ち家、賃貸、家族同居などの住まいの状況。 |
年収_万円 |
年収の概算。単位は万円です。 |
借入額_万円 |
希望借入額。単位は万円です。 |
返済遅延歴 |
過去に返済遅延があったかどうか。 |
審査結果 |
予測したいラベル。承認 または 見送り です。 |
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder
from sklearn.tree import DecisionTreeClassifier, export_text
credit_jp = pd.DataFrame({
"年代": [
"20代", "20代", "30代", "30代", "40代", "40代", "50代", "50代",
"20代", "30代", "40代", "50代", "30代", "40代", "50代", "20代",
"30代", "40代", "50代", "30代",
],
"雇用形態": [
"契約社員", "正社員", "正社員", "自営業", "正社員", "正社員", "自営業", "正社員",
"パート", "契約社員", "自営業", "正社員", "正社員", "契約社員", "自営業", "パート",
"正社員", "正社員", "契約社員", "自営業",
],
"住宅状況": [
"賃貸", "賃貸", "持ち家", "賃貸", "持ち家", "持ち家", "家族同居", "持ち家",
"賃貸", "賃貸", "持ち家", "持ち家", "家族同居", "賃貸", "持ち家", "家族同居",
"賃貸", "持ち家", "賃貸", "家族同居",
],
"年収_万円": [
260, 380, 520, 450, 620, 700, 480, 760,
220, 340, 580, 690, 410, 360, 540, 240,
470, 650, 390, 430,
],
"借入額_万円": [
180, 250, 300, 520, 350, 420, 600, 380,
160, 420, 500, 450, 280, 460, 650, 120,
360, 400, 500, 550,
],
"返済遅延歴": [
"あり", "なし", "なし", "あり", "なし", "なし", "あり", "なし",
"あり", "あり", "なし", "なし", "なし", "あり", "あり", "なし",
"なし", "なし", "あり", "あり",
],
"審査結果": [
"見送り", "承認", "承認", "見送り", "承認", "承認", "見送り", "承認",
"見送り", "見送り", "承認", "承認", "承認", "見送り", "見送り", "見送り",
"承認", "承認", "見送り", "見送り",
],
})
print(credit_jp.head())
カテゴリ特徴量は、そのままでは DecisionTreeClassifier に入れられません。そこで、OneHotEncoder で 0/1 の列に変換します。数値特徴量はそのまま通します。
なお、ここからの X・y は、13節の Iris の X・y とは別物です。この節では credit_jp から新しく作り直しています。
target_col = "審査結果"
categorical_features = ["年代", "雇用形態", "住宅状況", "返済遅延歴"]
numeric_features = ["年収_万円", "借入額_万円"]
X = credit_jp[categorical_features + numeric_features]
y = credit_jp[target_col]
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.3,
random_state=42,
stratify=y,
)
preprocess = ColumnTransformer(
transformers=[
("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features),
("num", "passthrough", numeric_features),
]
)
credit_model = make_pipeline(
preprocess,
DecisionTreeClassifier(
criterion="gini",
max_depth=3,
min_samples_leaf=2,
random_state=42,
),
)
credit_model.fit(X_train, y_train)
y_pred = credit_model.predict(X_test)
print(f"accuracy: {accuracy_score(y_test, y_pred):.3f}")
print(confusion_matrix(y_test, y_pred, labels=["承認", "見送り"]))
print(classification_report(y_test, y_pred))
ここでは Pipeline を使っています。カテゴリ変数のエンコードと決定木の学習を1つにまとめることで、訓練データとテストデータで同じ前処理を確実に使えるようにするためです。
OneHotEncoder(handle_unknown="ignore") の handle_unknown="ignore" は、テストデータや新しいデータに、訓練データでは見たことのないカテゴリ値が出てきたときに、エラーで止まらせず、その列をすべて0にして処理を続ける設定です。小さな架空データでは起きにくい状況ですが、実務データでは珍しくないので、つけておくと安心です。
学習した木のルールも確認できます。
fitted_preprocess = credit_model.named_steps["columntransformer"]
fitted_tree = credit_model.named_steps["decisiontreeclassifier"]
feature_names = fitted_preprocess.get_feature_names_out()
rules = export_text(
fitted_tree,
feature_names=list(feature_names),
)
print(rules)
cat__返済遅延歴_あり <= 0.50 のようなルールが出た場合、それは「返済遅延歴が あり ではないか」という質問に近い意味です。One-hot 化された列なので、0.50 以下なら該当カテゴリではない、0.50 より大きければ該当カテゴリである、と読めます。
最後に、新しい申込者を1人だけ分類してみます。
new_applicant = pd.DataFrame({
"年代": ["30代"],
"雇用形態": ["正社員"],
"住宅状況": ["賃貸"],
"返済遅延歴": ["なし"],
"年収_万円": [480],
"借入額_万円": [320],
})
prediction = credit_model.predict(new_applicant)[0]
probability = credit_model.predict_proba(new_applicant)[0]
probability_table = pd.Series(
probability,
index=credit_model.classes_,
)
print(f"予測結果: {prediction}")
print(probability_table.round(3))
このように、決定木は分類結果だけでなく、どのような条件でその結果になったかも比較的追いやすいモデルです。ただし、このデータは説明用の小さな架空データなので、現実の審査判断には使えません。
ここまでは正解率だけをさらっと見てきましたが、最後にもう少し詳しく、分類モデルの評価指標を整理しておきます。
15. 分類モデルの評価指標
決定木に限らず、分類モデルでは「当たった数」だけでなく、どの種類の間違いをしたかを見ることが重要です。
二分類では、混同行列を次のように整理できます。
| 実際 | 予測が正例 | 予測が負例 |
|---|---|---|
| 正例 | TP | FN |
| 負例 | FP | TN |
ここで、
- TP:本当に正例で、予測も正例
- FN:本当は正例なのに、予測は負例
- FP:本当は負例なのに、予測は正例
- TN:本当に負例で、予測も負例
です。
代表的な評価指標は次の通りです。
15.1 Accuracy
Accuracy は、全体のうち正しく分類できた割合です。
$$
\operatorname{Accuracy}=\frac{TP+TN}{TP+FP+FN+TN}
$$
15.2 Precision
Precision は、正例と予測したもののうち、本当に正例だった割合です。
$$
\operatorname{Precision}=\frac{TP}{TP+FP}
$$
たとえば「承認」と予測した申込者のうち、実際にも承認でよかった割合、と読むことができます。
15.3 Recall
Recall は、実際の正例のうち、モデルが正例として拾えた割合です。
$$
\operatorname{Recall}=\frac{TP}{TP+FN}
$$
見逃しを減らしたい場面では、Recall が特に重要になります。
15.4 F値
Precision と Recall をまとめて見る指標が F値です。よく使われる $F_1$ は、次のように書けます。
$$
F_1=\frac{2PR}{P+R}
$$
ここで、$P$ は Precision、$R$ は Recall です。
より一般には、Recall と Precision のどちらを重く見るかを $\beta$ で調整した $F_\beta$ も使えます。
$$
F_\beta=\frac{(1+\beta^2)PR}{\beta^2P+R}
$$
Scikit-learn の classification_report は、Precision、Recall、F1-score をまとめて出してくれます。第13節と第14節のコードで使っていたのはこのためです。
個別に計算したい場合は、次のように書けます。ここでは、14節の架空ローン審査で作った y_test と y_pred を使い、見送り を正例として見ます。
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score
positive_label = "見送り"
metrics = pd.Series({
"accuracy": accuracy_score(y_test, y_pred),
"precision": precision_score(y_test, y_pred, pos_label=positive_label, zero_division=0),
"recall": recall_score(y_test, y_pred, pos_label=positive_label, zero_division=0),
"f1": f1_score(y_test, y_pred, pos_label=positive_label, zero_division=0),
})
print(metrics.round(3))
評価指標は、問題設定とセットで選びます。たとえば「危険な申請を見逃したくない」のか、「安全な申請を誤って落としたくない」のかで、重視すべき指標は変わります。
16. まとめ
今回の内容をまとめます。
- 決定木は、質問を順番に重ねて分類するモデルである。
- 根ノード、内部ノード、枝、葉ノードという部品で木を作る。
- エントロピーは、クラスの混ざり具合を表す。
- 条件付きエントロピーは、ある特徴量で分けた後に残る混ざり具合を表す。
- 情報利得は、分割によってどれくらい不確実性が減ったかを表す。
- ID3 は情報利得を使って特徴量を選ぶ。
- C4.5 は情報利得比を使い、細かく分けすぎる特徴量への偏りを抑える。
- CART はジニ不純度などを使って二分木を作る。
- 決定木は深くしすぎると過学習しやすいため、枝刈り(剪定)が重要である。
- 連続値は候補しきい値を試し、欠損値は補完や重み付き処理で扱う。
- Scikit-learn の
DecisionTreeClassifierを使うと、Iris や架空の信用リスクデータで決定木分類を試せる。 - 分類モデルの評価では、Accuracy だけでなく Precision、Recall、F1-score も見る。
決定木は、モデルの中身を人間が追いやすいという大きな長所を持っています。最初に学ぶ分類モデルとしても、実務で説明可能性を重視したい場面でも、とても重要です。
一方で、木を深くしすぎると訓練データの細かい癖まで覚えてしまいます。「どの質問を選ぶか」と同じくらい、「どこで質問を止めるか」も大切です。ここを押さえておくと、次に出てくるランダムフォレストや勾配ブースティングの理解にもつながります。