nykergoto’s blog

機械学習とpythonをメインに、雑多な内容をとりとめなく扱うブログです。

scikit-learn 準拠の予測モデルのつくりかた

機械学習で色々やっていると、いろいろなモデルを複合したアンサンブルモデルなど、自分で新しい予測モデルを作りたい場合があります。 その場合自分でいちから作り上げても良いのですが、そうやって作ったモデルは、たとえば scikit-learn のパラメータ最適化モジュールである GridSearchRandomSearch を利用することができなくて、少々不便です。 この際に scikit-learn の定義にしたがってモデルを定義すればうまく連携がとれて効率的です。以下では scikit-learn 準拠の予測モデルをどうやって作ればよいか、その際の注意点や推奨事項を取り上げます。

Scikit Learnの予測モデル

はじめに、作成するモデルのタイプを選びます。scikit-learn ではモデルは以下の4つのタイプに分類されています。

  • Classifer
    • ex). Naive Bayes Classifer などの分類モデル
  • Clusterring
  • Regressor
    • ex). Lasso, Ridge などの回帰モデル
  • Transformer
    • ex). PCA などの変数の変換モデル

それぞれのモデルに対して Mixin が定義されていて, BaseEstimator と同時にそれも継承することが推奨されています。

BaseEstimator, 各Mixinのコードは以下を参照してください https://github.com/scikit-learn/scikit-learn/blob/14031f6/sklearn/base.py

予測モデルのコンストラクタでの制約

予測モデルのコンストラクタには以下の制約が存在します。

  • __init__ で呼ばれるすべての引数は初期値を持たなくてはいけません。
  • 入力変数の確認は __init__ 内部では行いません。 fit が呼ばれたときに行うようにします。
  • __init__ でのすべての引数は作成されたインスタンスの属性と同じ名前を持つことが推奨されます。(たとえば引数hogeself.huga = hogeのように与えることはだめです。)
  • __init__ ではデータを与えません。 fit メソッドで初めて与えるようにします。

Fit method

fit メソッド内ではデータの加工及びパラメータの確認を行います。 この部分以外でデータを取り扱うのは非推奨です(繰り返しになりますが、例えば __init__内部でデータをあたえてnormalizeする等です)。

get_params & set_params

get_paramsset_paramsBaseEstimator によって定義されている関数です。これをオーバーライドするのは推奨されません。

予測値の取扱

返すベクトルをインプットしなくても良い場合が存在します。(例えばクラスタリングのときなどです)。その場合でも、実装上の問題から、予測値yを引数に定義することが必要です。( y=None で定義することが推奨されます)。こうすることで GridSearch に予測器を与えることが可能になります。

score と gridsearch

グリッドサーチにかけるためには、必要であれば score メソッドをオーバーライドする必要があります。 なぜならばグリッドサーチでは「どのモデルが最も良いのか」を判断する必要があり、その基準となる score メソッドが必要だからです。

score メソッドは 数字が大きいほど良いモデル というルールがあります。 したがって最小化問題が目的関数となっているモデルでは、それにマイナスをつけたものを score として登録する必要があります。 (LassoやRidge回帰のような, 二乗ロス関数を損失関数に持つモデルを gridsearch にかけると、モデルのスコアが負になって表示されるのはこのためです。) 後述する Mixin クラスには score メソッドがすでに定義されているので, Mixin クラスのデフォルトの score メソッドを用いる場合には自分で定義する必要はありません。

サンプル

以上を踏まえた予測器を定義していきます。今回は [訓練データの平均値 + int_val] よりも大きいか小さいかをboolで返す分類器を作成します。分類器なので BaseEstimator と同時に ClassiferMixin も継承しています。

# coding: utf-8
__author__ = "nyk510"

from sklearn.base import BaseEstimator, ClassifierMixin
from sklearn.model_selection import RandomizedSearchCV
import numpy as np


class SampleClassifer(BaseEstimator, ClassifierMixin):
    """
    分類器のサンプル
    """
    
    def __init__(self, int_val=0, sigma=.5, hogevalue=None):
        """
        分類器のコンストラクタ
        全部の引数に初期値を与えること !!
        
        :param int int_val:
        :param float sigma:
        :param str hogevalue:
        """
        self.int_val = int_val
        self.sigma = sigma
        self.hogevalue = hogevalue
        self.huga = hogevalue  # 引数とインスタンス変数の名前が違っている. 非推奨. 
         
    def fit(self, X, y=None):
        """
        データへのフィッティングを行うメソッド。
        すべての加工, パラメータの確認はここで定義する。
        Note: `assert`よりも`try/exception`を用いるほうが本当は良い. けどめんどうなのでassert使ってます
        
        :param numpy.ndarray X: 2-D array. 訓練特徴
        :param numpy.ndarray y: 1-D array. ターゲット変数
        :return: self
        :rtype: SampleClassifer
        """
        assert(isinstance(self.int_val, int) or isinstance(self.int_val, np.int64)), "int_valは整数値でないと駄目です. "
        self.treshold_ = (sum(X)/len(X)) + self.int_val
        return self  # return selfするのが慣習
    
    def _meaning(self, x):
        """
        平均値よりも大きければTrueを返す分類
        :rtype: bool
        """
        if x > self.treshold_:
            return True
        else:
            return False
        
    def predict(self, X, y=None):
        """
        予測を行う
        :param numpy.ndarray X: 特徴量. 2-D array
        :param numpy.ndarray y: ターゲット変数. 分類問題なので使わないけど`y=None`でおいておく
        :return: 1-D array
        :rtype: np.ndarray
        """
        try:
            getattr(self, "treshold_")
        except AttributeError:
            raise RuntimeError("モデルは訓練されていません")
            
        return ([self._meaning(x) for x in X])
    
    def score(self, X, y=None):
        """
        モデルの良さを数値化する
        なんでもいいけれど、大きい方が良くて、小さいほうがだめ。
        今回は平均以上の値がいくつあるかをスコアとして定義する。
        
        :return: 平均値の値よりも大きい数
        :rtype: int
        """
        return (sum(self.predict(X)))

ところでこの ClassiferMixin にある Mixin とは何でしょうか。wikipediaを引用すると

mixin とはオブジェクト指向プログラミング言語において、サブクラスによって継承されることにより機能を提供し、単体で動作することを意図しないクラスである。 Mixin - Wikipedia

Mixin は継承することで初めて機能を提供できるクラスを指しています。Scikit-Learnの ClassiferMixin のコードを見ると以下が定義されています。

class ClassifierMixin(object):
    """Mixin class for all classifiers in scikit-learn."""
    _estimator_type = "classifier"

    def score(self, X, y, sample_weight=None):
        """Returns the mean accuracy on the given test data and labels.
        In multi-label classification, this is the subset accuracy
        which is a harsh metric since you require for each sample that
        each label set be correctly predicted.
        Parameters
        ----------
        X : array-like, shape = (n_samples, n_features)
            Test samples.
        y : array-like, shape = (n_samples) or (n_samples, n_outputs)
            True labels for X.
        sample_weight : array-like, shape = [n_samples], optional
            Sample weights.
        Returns
        -------
        score : float
            Mean accuracy of self.predict(X) wrt. y.
        """
        from .metrics import accuracy_score
        return accuracy_score(y, self.predict(X), sample_weight=sample_weight)

自分の名前(_estimator_type)とscore関数のデフォルトが定義されています。 score関数内ではインスタンスメソッドのself.predict(X)が呼ばれているので、このクラスはBaseEstimatorを継承してpredictを持っているインスタンスに継承されて初めて意味があるものだとわかります。 python では __init__ メソッドを定義せずにインスタンスメソッドのみを定義することで、新しく Mixin クラスを作ることが可能です。

つぎに、適当にデータを作成してこのモデルに対してランダムサーチをしてみましょう。

x_train = np.random.normal(.1, 4, size=100)
x_test = np.random.normal(-.1, 4, size=20)
model_params = {
    "int_val": [-10, -1, 0, 1, 2],
    "sigma": np.linspace(-1, 1, 100)
}

clf = SampleClassifer()
random_search = RandomizedSearchCV(estimator=clf, param_distributions=model_params)
random_search.fit(x_train)

score関数はTrueの数が多いほど大きいという風に定義していますから、int_valが一番小さい値(すなわち-10)の予測器がbest_estimator_となっているはずです。 (本当はRandomSearchなので -10 が選択されているとは限らない, ということに今気が付きました……ほんとうはGridSearchすべきでしたね)1

random_search.best_params_
{'int_val': -10, 'sigma': 0.51515151515151536}

ちゃんと -10 が選ばれていることが分かります。


  1. int_val のとりうる値の数は5なので, 1 - (4/5)**10 ~ 0.892 より, -10 が1回でも選ばれる確率はだいたい 89.2%です