はじめに
引き続き、今度はGBDTに関しての記事を書いていこうと思います。GBDTといえば、最近も機械学習の第一手段として人気の、XGboostやLightGBMの元となっているモデルです。(LightGBMはXGboostを基に改善したモデルですが)概念を理解している人は多くいるものの、図解的な概念での理解にとどまっており、理解しながら使用してる人は実際は少ないのではないかと思っているモデルでもあります。バギングに比べ、ブースティングは少々理解するのが難しい部分でもあるので、そんなGBDTに関して理論を中心に、フルスクラッチのコードを添えて記事を書いていこうと思います。
元々Xgboostまでは触れようと思っていたのですが、自分のなまけ癖が限界を迎えようとしており、扱えないかもしれませんが許してください。前回のランダムフォレストみたいなグダグダ解説にならないように気を付けながら進めたいと思います。
GBDT(勾配ブースティング決定木)とは
1. 概要
勾配ブースティング決定木(GBDT)は、弱学習器(通常は決定木)を逐次的に組み合わせて強力な予測モデルを構築する機械学習アルゴリズムです。各ステップで、前のモデルの残差(実際の値と予測値の差)を予測する新しい決定木を追加することで、モデルを改善していきます。
2.数学的な説明
目的関数:
この目的関数は、モデル全体の性能を評価するために使用されます。GBDTの目標は、この関数を最小化することです。
構成要素:
1.
一般的に使用される損失関数:
1. 回帰問題の場合
2. 二値分類の問題の場合
3. 多クラス分類問題の場合
GBDTの良いところは、問題の性質や要求される特性に対応して、様々な損失関数を使い、それを最小化するように、モデルが作成される点にあります。
目的関数を最小化することで、GBDTは予測と実際の値の差を最小限に抑えるようにモデルを調整します。各イテレーションで、モデルは前のステップの残差(実際の値と予測値の差)を予測する新しい決定木を追加することで、徐々にこの目的を達成していきます。
各ステップの解説
Step1
Step2
a)負の勾配の計算
d)モデルの更新
最後に、最適な係数を用いて新しい決定木をモデルに追加します。これにより、モデルは徐々に改善されていきます。
Step3
GBDTの特徴は、各ステップで前のモデルの誤差を修正する新しい決定木を追加することで、徐々にモデルを改善していく点にあります。これにより、複雑な非線形関係を捉えつつ、過学習を抑制することができます。
重要な特徴
正則化は過学習(オーバーフィッティング)を防ぐための重要な技術です。GBDTにおいて、学習率 (0 <
≤ 1)を導入することで正則化を実現します。
b)特徴量の重要度:モデル全体での各特徴量の重要度を計算できます。
- モデル内の全ての決定木を走査します。
- 各分割(ノード)で、その分割の重要度 $I_j$ を計算します。
- 特徴量 $k$ が使用された分割のみを考慮し、それらの重要度の二乗和を取ります。
- 通常、分割によって減少した不純度(例:ジニ不純度や情報利得)を使用します。
- 分割によって大きく不純度が減少した場合、その分割(および使用された特徴量)は重要と見なされます。
重要なハイパーパラメータ
- 木の深さ
- 木の数(反復数)
- 学習率
- サンプリング率
- 正則化パラメータ
以上が重要なハイパーパラメータになります。
GBDTの問題点
- ハイパーパラメータのチューニングが必要
- 解釈性が低い(個々の木は理解しやすいが、全体としては複雑な構造になっているため)
- 計算コストが高い
フルスクラッチ実装
GBDTのフルスクラッチ実装は以下です。
import numpy as np class DecisionNode: def __init__(self, feature_index=None, threshold=None, left=None, right=None, value=None): # 決定木の各ノードを表現するクラス self.feature_index = feature_index # 分割に使用する特徴量のインデックス self.threshold = threshold # 分割の閾値 self.left = left # 左の子ノード(閾値未満のデータ) self.right = right # 右の子ノード(閾値以上のデータ) self.value = value # 葉ノードの場合の予測値(回帰問題では平均値) class DecisionTreeRegressor: def __init__(self, max_depth=None, min_samples_split=2): # 決定木回帰器の初期化 self.max_depth = max_depth # 木の最大深さ(過学習防止のため) self.min_samples_split = min_samples_split # ノードを分割するための最小サンプル数 self.root = None # ルートノード(木の開始点) self.feature_importances_ = None # 各特徴量の重要度を格納する配列 def fit(self, X, y): # 決定木のトレーニングを行うメソッド self.n_features = X.shape[1] # 特徴量の数を取得 self.feature_importances_ = np.zeros(self.n_features) # 特徴量重要度の初期化 self.root = self._grow_tree(X, y) # 再帰的に木を成長させる def _grow_tree(self, X, y, depth=0): # 再帰的に決定木を成長させるメソッド n_samples, n_features = X.shape # 停止条件のチェック if (self.max_depth is not None and depth >= self.max_depth) or \ n_samples < self.min_samples_split: return DecisionNode(value=np.mean(y)) # 葉ノードを返す(予測値は平均) best_feature, best_threshold = self._best_split(X, y) # 最適な分割点を見つける # 分割できない場合(例:すべての特徴量が同じ値の場合) if best_feature is None: return DecisionNode(value=np.mean(y)) # 葉ノードを返す # データを左右に分割 left_indices = X[:, best_feature] < best_threshold right_indices = ~left_indices # 左右の子ノードを再帰的に成長させる left = self._grow_tree(X[left_indices], y[left_indices], depth + 1) right = self._grow_tree(X[right_indices], y[right_indices], depth + 1) # 特徴量の重要度を更新(二乗誤差の減少量を加算) self.feature_importances_[best_feature] += ( self._calculate_mse_reduction(y, y[left_indices], y[right_indices]) ) # 新しい内部ノードを返す return DecisionNode(feature_index=best_feature, threshold=best_threshold, left=left, right=right) def _best_split(self, X, y): # 最適な分割点を見つけるメソッド m = y.size if m <= 1: return None, None # 分割できない場合 # 親ノードの二乗誤差を計算 parent_var = np.var(y) * m # 分散 * サンプル数 = 二乗誤差の合計 best_var_reduction = 0 # 最大の分散減少量(初期値) best_feature = None # 最適な特徴量(初期値) best_threshold = None # 最適な閾値(初期値) for feature in range(self.n_features): thresholds = np.unique(X[:, feature]) # ユニークな閾値候補を取得 for threshold in thresholds: # データを左右に分割 left = y[X[:, feature] < threshold] right = y[X[:, feature] >= threshold] if len(left) == 0 or len(right) == 0: continue # 片方のノードにデータがない場合はスキップ # 子ノードの二乗誤差を計算し、分散の減少量を求める var_reduction = parent_var - (np.var(left) * len(left) + np.var(right) * len(right)) # より良い分割が見つかった場合、更新 if var_reduction > best_var_reduction: best_var_reduction = var_reduction best_feature = feature best_threshold = threshold return best_feature, best_threshold def _calculate_mse_reduction(self, parent, left, right): # 二乗誤差の減少量を計算するメソッド m = len(parent) m_l, m_r = len(left), len(right) # 各ノードの二乗誤差を計算 mse_parent = np.sum((parent - parent.mean()) ** 2) mse_left = np.sum((left - left.mean()) ** 2) mse_right = np.sum((right - right.mean()) ** 2) # 二乗誤差の減少量を計算 mse_reduction = mse_parent - (mse_left + mse_right) return mse_reduction / m # サンプル数で正規化 def predict(self, X): # 新しいデータに対する予測を行うメソッド return np.array([self._traverse_tree(x, self.root) for x in X]) def _traverse_tree(self, x, node): # 決定木を走査して予測値を得るメソッド if node.value is not None: return node.value # 葉ノードの場合、その値を返す if x[node.feature_index] < node.threshold: return self._traverse_tree(x, node.left) # 左の子ノードへ return self._traverse_tree(x, node.right) # 右の子ノードへ class GBDT: def __init__(self, n_estimators=100, learning_rate=0.1, max_depth=3): # GBDTモデルの初期化 self.n_estimators = n_estimators # ブースティングの反復回数 self.learning_rate = learning_rate # 学習率(ステップサイズ) self.max_depth = max_depth # 各決定木の最大深さ self.trees = [] # 決定木のリスト def fit(self, X, y): # GBDTモデルのトレーニングを行うメソッド self.initial_prediction = np.mean(y) # 初期予測値(全体の平均) F = np.full(len(y), self.initial_prediction) # 現在の予測値 for m in range(self.n_estimators): residuals = y - F # 残差(実際の値 - 予測値)を計算 # 残差に対して決定木を学習 tree = DecisionTreeRegressor(max_depth=self.max_depth) tree.fit(X, residuals) # 予測値を更新(学習率を考慮) update = self.learning_rate * tree.predict(X) F += update self.trees.append(tree) # 学習した木を保存 def predict(self, X): # 新しいデータに対する予測を行うメソッド predictions = np.full(len(X), self.initial_prediction) # 初期予測値で初期化 for tree in self.trees: # 各木の予測を加算(学習率を考慮) predictions += self.learning_rate * tree.predict(X) return predictions def feature_importance(self): # 特徴量の重要度を計算するメソッド importances = np.zeros(self.trees[0].n_features) # 重要度を格納する配列 for tree in self.trees: importances += tree.feature_importances_ # 各木の重要度を加算 return importances / len(self.trees) # 木の数で正規化
- 決定木の成長プロセス(
_grow_tree
メソッド) - 最適な分割点の選択方法(
_best_split
メソッド) - 二乗誤差の減少量の計算(
_calculate_mse_reduction
メソッド) - GBDTの学習プロセス(
fit
メソッド) - 予測の方法(
predict
メソッド) - 特徴量重要度の計算(
feature_importance
メソッド)
実際にサンプルデータを使って予測するコードは以下です。
import numpy as np from sklearn.datasets import make_regression from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error, r2_score # 前述のGBDTとDecisionTreeRegressorクラスの定義がここにあると仮定します # サンプルデータの生成 X, y = make_regression(n_samples=1000, n_features=10, noise=0.1, random_state=42) # データを訓練セットとテストセットに分割 X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) # GBDTモデルの初期化 gbdt = GBDT(n_estimators=100, learning_rate=0.1, max_depth=3) # モデルの学習 gbdt.fit(X_train, y_train) # テストデータで予測 y_pred = gbdt.predict(X_test) # 評価指標の計算 mse = mean_squared_error(y_test, y_pred) r2 = r2_score(y_test, y_pred) print(f"Mean Squared Error: {mse:.4f}") print(f"R-squared Score: {r2:.4f}") # 特徴量の重要度を表示 feature_importance = gbdt.feature_importance() for i, importance in enumerate(feature_importance): print(f"Feature {i}: {importance:.4f}") # 新しいデータポイントでの予測例 new_data_point = np.random.rand(1, 10) # 10個の特徴を持つ1つのデータポイント prediction = gbdt.predict(new_data_point) print(f"\n新しいデータポイントの予測値: {prediction[0]:.4f}") # 学習曲線の可視化(オプション) import matplotlib.pyplot as plt # 訓練データのサイズを変えて学習と評価を行う train_sizes = np.linspace(0.1, 0.9, 5) # 0.1から0.9までの5点を生成 train_errors = [] test_errors = [] for size in train_sizes: X_train_subset, _, y_train_subset, _ = train_test_split(X_train, y_train, train_size=size, random_state=42) gbdt = GBDT(n_estimators=100, learning_rate=0.1, max_depth=3) # 新しいモデルを作成 gbdt.fit(X_train_subset, y_train_subset) train_pred = gbdt.predict(X_train_subset) test_pred = gbdt.predict(X_test) train_errors.append(mean_squared_error(y_train_subset, train_pred)) test_errors.append(mean_squared_error(y_test, test_pred)) # 学習曲線のプロット plt.figure(figsize=(10, 6)) plt.plot(train_sizes, train_errors, 'r-', label='Training error') plt.plot(train_sizes, test_errors, 'b-', label='Test error') plt.xlabel('Training set size ratio') plt.ylabel('Mean Squared Error') plt.title('Learning Curve for GBDT') plt.legend() plt.show()
結果は以下のようになります。
最後に
今回の記事では、あまりとっ散らかった説明にならず、理解しやすい説明ができたのではなきかと思っております。意外とGBDTに関して、概念的な理解にとどまり、理論を理解していた人は少なかったのではないでしょうか?理論を理解できている状態でツールを使用するのと、理解していない状態でツールを使用するのは大きく違ってくると個人的には思っています。また、特徴量重要度含め、SHAPで解釈する場合にも多重共線性には注意してください(予測や分類タスクの精度には関係しませんが)フルスクラッチのコード含め、初学者の方の理解の一助になれば幸いです。次回はXGboostをやるのか?本当にやりたかった因果推論方面にもう行くのかで悩んでいますが、ご期待ください。