API de sciki-learn et modèles customisés

Links: notebook, html, python, slides, GitHub

scikit-learn est devenu le module incontournable quand il s’agit de machine learning. Cela tient en partie à son API épurée qui permet à quiconque d’implémenter ses propres modèles tout permettant à scikit-learn de les manipuler comme s’il s’agissait des siens.

from jyquickhelper import add_notebook_menu
add_notebook_menu(last_level=3)

Cette présentation détaille l’API de scikit-learn, aborde la mise en production avec pickle, montre un exemple d’implémentation d’un modèle customisé appliqué à la sélection d’arbres dans une forêt aléatoire.

import matplotlib.pyplot as plt
from jupytalk.pres_helper import show_images

Design et API

On peut penser que deux implémentations du même algorithme se valent à partir du moment où elles produisent les mêmes résultats. Voici deux chaises, vers laquelle votre instinct vous poussera-t-il ?

show_images("zigzag.jpg", "chaise.jpg", figsize=(14, 4), title2="Le Corbusier");
../_images/sklearn_api_5_0.png

Quatre ou cinq librairies ont fait le succès de Python

  • numpy: calcul matriciel - existait avant Python (matlab, R, …)

  • pandas: manipulation de données - existait avant Python (R, …)

  • matplotlib: graphes - existait avant Python - (matlab, R…)

  • scikit-learn: machine learning - innovation : design

  • jupyter: notebooks - innovation : mélange interactif code, texte, images

show_images("trends.png", title1="Google Trendss Python / Matlab");
../_images/sklearn_api_7_0.png

Machine learning résumé

  • Modèle de machine learning = résultat d’une optimisation

  • Cette optimisation dépend de paramètres (dimension, pas du gradient, …)

  • Optimisation = apprentissage

  • On s’en sert pour faire de la prédiction.

Ce que les codeurs imaginent

Des designs souvent très jolis mais à usage unique.

show_images("coop.jpg", "coop2.jpg", title1="Coop Himeblau", title2="Rooftop", figsize=(16,8));
../_images/sklearn_api_10_0.png

Vues incompatibles

  • Les chercheurs aiment l’innonvation, cherchent de nouveaux modèles.

  • Les datascientist assemblent des modèles existants.

  • L’estimation d’un modèle arrivent à la toute fin.

On retient facilement ce qui est court et qui se répète.

Vocabulaire scikit-learn

  • Predictor : modèle de machine learning qu’on apprend (fit) et qui prédit (predict)

  • Transformer : prétraitement de données qui précède un prédicteur, qu’on apprend (fit) et qui transforme les données (transform)

Utilisation de classes : predictor

::
class Predictor:
def __init__(self, **kwargs):

# kwargs sont les paramètres d’apprentissage

def fit(self, X, y):

# apprentissage return self

def predict(self, X):

# prédiction

Utilisation de classes : transformer

::
class Transformer:
def __init__(self, **kwargs):

# kwargs sont les paramètres d’apprentissage

def fit(self, X, y):

# apprentissage return self

def transform(self, X):

# prédiction

pipeline (sandwitch en français)

Normalisation + ACP + Régression Logistique

Classe Step 1 Step 2 Step 3 Step 4 ================== ========== =================== ==================== ==================== Normalizer fit(X) X2=transform(X) X2=transform(X) X2=transform(X) PCA . fit(X2) X3=transform(X2) X3=transform(X2) LogisticRegression . . fit(X3,y) X4=predict(X3) ================== ========== =================== ==================== ====================

En langage Python

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import Normalizer
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression

pipe = Pipeline([
    ('norm', Normalizer()),
    ('pca', PCA()),
    ('lr', LogisticRegression())
])
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
data = load_iris()
X, y = data.data, data.target
X_train, X_test, y_train, y_test = train_test_split(X, y)
pipe.fit(X_train, y_train)
Pipeline(memory=None,
         steps=[('norm', Normalizer(copy=True, norm='l2')),
                ('pca',
                 PCA(copy=True, iterated_power='auto', n_components=None,
                     random_state=None, svd_solver='auto', tol=0.0,
                     whiten=False)),
                ('lr',
                 LogisticRegression(C=1.0, class_weight=None, dual=False,
                                    fit_intercept=True, intercept_scaling=1,
                                    l1_ratio=None, max_iter=100,
                                    multi_class='auto', n_jobs=None,
                                    penalty='l2', random_state=None,
                                    solver='lbfgs', tol=0.0001, verbose=0,
                                    warm_start=False))],
         verbose=False)
prediction = pipe.predict(X_test)
prediction[:5]
array([2, 2, 0, 2, 2])
pipe.score(X_test, y_test)
0.6578947368421053

Raffinement

show_images("church-of-light-1024x614.jpg", title1="Tadao Ando", figsize=(10, 6));
../_images/sklearn_api_22_0.png

Un design commun aux régresseurs et classifieurs

  • Les régresseurs sont les plus simples, ils modèlisent une fonction f(X \in \mathbb{R}^d) \rightarrow \mathbb{R}.

  • Les classifieurs modélisent une fonction f(X \in \mathbb{R}^d) \rightarrow \mathbb{N}

Mais

Les classifieurs sont liés à la notion de distance par rapport à la frontière, distance qu’on relie ensuite à une probabilité mais pas toujours.

show_images('logreg.png');
../_images/sklearn_api_24_0.png

Besoin d’un classifieur

::
class Classifier:
def __init__(self, **kwargs):

# kwargs sont les paramètres d’apprentissage

def fit(self, X, y):

# apprentissage return self

def decision_function(self, X):

# distances

def predict_proba(self, X):

# distances –> proba

def predict(self, X):

# classes

Besoin d’un régresseur par mimétisme

::
class Classifier:
def __init__(self, **kwargs):

# kwargs sont les paramètres d’apprentissage

def fit(self, X, y):

# apprentissage return self

def decision_function(self, X):

# une ou plusieurs régressions

def predict(self, X):

# moyennes

Paramètres et résultats d’apprentissage

  • Tout attribut terminé par _ est un résultat d’apprentissage.

  • A l’opposé, tout ce qui ne se termine pas par _ est connu avant l’apprentissage

show_images("lasso.png");
../_images/sklearn_api_28_0.png

Problèmes standards - moule commun

show_images('sklearn_base.png');
../_images/sklearn_api_30_0.png

Analyser ou prédire

Certains modèles ne peuvent pas prédire, simplement analyser. C’est le cas du SpectralClustering.

::
class NoPredictionButAnalysis:
def __init__(self, **kwargs):

# kwargs sont les paramètres d’apprentissage

def fit_predict(self, X, y=None):

# apprentissage et prédiction return self

Limites du concept

Et si on veut réutiliser les sorties d’un prédicteur pour en faire autre chose ?

VotingClassifier

A suivre… dans la dernière partie.

Le design, c’est le design, le code, c’est de la bidouille.

pickle

Un modèle c’est :

  • une classe, un pipeline, une liste de traitements définis avant apprentissage

  • des coefficients obtenus après apprentissage

Comment conserver le résultat ? –> pickle

Cas des dataframes

from pandas import DataFrame, read_csv
df = DataFrame(X)
df['label'] = y
df.head()
0 1 2 3 label
0 5.1 3.5 1.4 0.2 0
1 4.9 3.0 1.4 0.2 0
2 4.7 3.2 1.3 0.2 0
3 4.6 3.1 1.5 0.2 0
4 5.0 3.6 1.4 0.2 0
df.to_csv("data_iris.csv")
%timeit read_csv("data_iris.csv")
3.4 ms ± 217 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
import pickle
with open("data_iris.pickle", "wb") as f:
    pickle.dump(df, f)
def load_from_pickle(name):
    with open(name, "rb") as f:
        return pickle.load(f)

%timeit load_from_pickle("data_iris.pickle")
874 µs ± 35.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

pickle est plus rapide

  • read_csv : convertit un fichier texte en dataframe –> format intermédiaire csv

  • pickle : conserve des données comme elles sont stockées en mémoire –> pas de conversion

from jyquickhelper import RenderJsDot
RenderJsDot('''digraph{ rankdir="LR";
    B [label="mémoire"]; C [label="csv"]; C2 [label="csv"];
    D [label="disque"]; B -> C [label="to_csv", color="red"];
    C -> D ; D -> C2 ;
    C2 -> B [label="read_csv", color="red"];
    B -> D [label="pickle.dump", color="blue"];
    D -> B [label="pickle.load", color="blue"];
}''')

scikit-learn, pickle

unique moyen de conserver les modèles

with open("pipe.pickle", "wb") as f:
    pickle.dump(pipe, f)
with open("pipe.pickle", "rb") as f:
    pipe2 = pickle.load(f)
from numpy.testing import assert_almost_equal
assert_almost_equal(pipe.predict(X_test), pipe2.predict(X_test))

Problème avec pickle

  • L’état de la mémoire dépend très fortement des librairies installées

  • Changer de version scikit-learn –> l’état de la mémoire est différente

  • Analogie : pickle ne conserve que les coefficients en mémoire, ils sont cryptés en quelque sorte.

  • On ne peut les décrypter qu’avec le même code.

Dissocier les colonnes

Toutes les colonnes subissent le même traitement.

pipe = Pipeline([
    ('norm', Normalizer()),
    ('pca', PCA()),
    ('lr', LogisticRegression())
])

Mais ce n’est pas forcément ce que l’on veut.

from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import MinMaxScaler

pipe2 = Pipeline([
    ('multi', ColumnTransformer([
        ('c01', Normalizer(), [0, 1]),
        ('c23', MinMaxScaler(), [2, 3]),
    ])),
    ('pca', PCA()),
    ('lr', LogisticRegression())
])

pipe2.fit(X_train, y_train);
from mlinsights.plotting import pipeline2dot
RenderJsDot(pipeline2dot(pipe2, X_train))

Concepts appliqués à un nouveau régresseur

On construit une forêt d’arbres puis on réduit le nombre d’arbres à l’aide d’une régression Lasso.

from sklearn.datasets import load_boston
from sklearn.model_selection import train_test_split

data = load_boston()
X, y = data.data, data.target
X_train, X_test, y_train, y_test = train_test_split(X, y)

Sketch de l’algorithme

import numpy
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import Lasso

# Apprentissage d'une forêt aléatoire
clr = RandomForestRegressor()
clr.fit(X_train, y_train)

# Récupération de la prédiction de chaque arbre
X_train_2 = numpy.zeros((X_train.shape[0], len(clr.estimators_)))
estimators = numpy.array(clr.estimators_).ravel()
for i, est in enumerate(estimators):
    pred = est.predict(X_train)
    X_train_2[:, i] = pred

# Apprentissage d'une régression Lasso
lrs = Lasso(max_iter=10000)
lrs.fit(X_train_2, y_train)
lrs.coef_
array([0.        , 0.        , 0.02869904, 0.        , 0.        ,
       0.01230231, 0.        , 0.        , 0.06268181, 0.        ,
       0.04434885, 0.04454832, 0.        , 0.        , 0.        ,
       0.        , 0.00328847, 0.        , 0.        , 0.        ,
       0.01964477, 0.02122032, 0.        , 0.03659488, 0.        ,
       0.        , 0.01859637, 0.        , 0.        , 0.        ,
       0.        , 0.08754916, 0.        , 0.        , 0.        ,
       0.        , 0.01080401, 0.        , 0.03181241, 0.        ,
       0.01764386, 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.07638484,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.00960185, 0.03188872, 0.        , 0.04100308, 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.03538388,
       0.02744132, 0.03044355, 0.05237069, 0.02037819, 0.01031177,
       0.        , 0.        , 0.        , 0.05316585, 0.        ,
       0.        , 0.        , 0.04871499, 0.        , 0.        ,
       0.00355434, 0.        , 0.        , 0.        , 0.        ,
       0.03507367, 0.03236543, 0.        , 0.        , 0.01899959,
       0.        , 0.01047442, 0.        , 0.        , 0.        ,
       0.01293732, 0.        , 0.        , 0.01444821, 0.01773635])

Ce que l’on veut

::
class LassoRandomForestRegressor:
def fit(self, X, y):

# apprendre une random forest # sélectionner les arbres à garder avec un Lasso # supprimer les arbres associés à un poids nul return self

def predict(self, X):

# retourner une moyenne pondérée des prédictions return …

Implémentation

lasso_random_forest_regressor.py

import numpy
from sklearn.base import BaseEstimator, RegressorMixin, clone
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import Lasso


class LassoRandomForestRegressor(BaseEstimator, RegressorMixin):

    def __init__(self, rf_estimator=None, lasso_estimator=None):
        BaseEstimator.__init__(self)
        RegressorMixin.__init__(self)
        if rf_estimator is None:
            rf_estimator = RandomForestRegressor()
        if lasso_estimator is None:
            lasso_estimator = Lasso()
        self.rf_estimator = rf_estimator
        self.lasso_estimator = lasso_estimator

    def fit(self, X, y, sample_weight=None):
        self.rf_estimator_ = clone(self.rf_estimator)
        self.rf_estimator_.fit(X, y, sample_weight)

        estims = self.rf_estimator_.estimators_
        estimators = numpy.array(estims).ravel()
        X2 = numpy.zeros((X.shape[0], len(estimators)))
        for i, est in enumerate(estimators):
            pred = est.predict(X)
            X2[:, i] = pred

        self.lasso_estimator_ = clone(self.lasso_estimator)
        self.lasso_estimator_.fit(X2, y)

        not_null = self.lasso_estimator_.coef_ != 0
        self.intercept_ = self.lasso_estimator_.intercept_
        self.estimators_ = estimators[not_null]
        self.coef_ = self.lasso_estimator_.coef_[not_null]
        return self

    def predict(self, X):
        prediction = None
        for i, est in enumerate(self.estimators_):
            pred = est.predict(X)
            if prediction is None:
                prediction = pred * self.coef_[i]
            else:
                prediction += pred * self.coef_[i]
        return prediction + self.intercept_
ls = LassoRandomForestRegressor()
ls.fit(X_train, y_train)
C:xavierdupre__home_github_forkscikit-learnsklearnlinear_modelcoordinate_descent.py:475: ConvergenceWarning: Objective did not converge. You might want to increase the number of iterations. Duality gap: 14.277320258655209, tolerance: 3.196316
  positive)
LassoRandomForestRegressor(lasso_estimator=Lasso(alpha=1.0, copy_X=True,
                                                 fit_intercept=True,
                                                 max_iter=1000, normalize=False,
                                                 positive=False,
                                                 precompute=False,
                                                 random_state=None,
                                                 selection='cyclic', tol=0.0001,
                                                 warm_start=False),
                           rf_estimator=RandomForestRegressor(bootstrap=True,
                                                              ccp_alpha=0.0,
                                                              criterion='mse',
                                                              max_depth=None,
                                                              max_features='auto',
                                                              max_leaf_nodes=None,
                                                              max_samples=None,
                                                              min_impurity_decrease=0.0,
                                                              min_impurity_split=None,
                                                              min_samples_leaf=1,
                                                              min_samples_split=2,
                                                              min_weight_fraction_leaf=0.0,
                                                              n_estimators=100,
                                                              n_jobs=None,
                                                              oob_score=False,
                                                              random_state=None,
                                                              verbose=0,
                                                              warm_start=False))

Résultats

La forêt aléatoire seule.

clr.score(X_test, y_test)
0.8005425626013631

La forêt aléatoire réduite.

ls.score(X_test, y_test)
0.8325193431184871

Avec une réduction conséquente.

len(ls.estimators_), len(clr.estimators_)
(36, 100)

Critère AIC

On peut même sélectionner le nombre d’arbres avec un critère AIC et le modèle LassoLarsIC.

from sklearn.linear_model import LassoLarsIC
ls_aic = LassoRandomForestRegressor(lasso_estimator=LassoLarsIC())
ls_aic.fit(X_train, y_train)
LassoRandomForestRegressor(lasso_estimator=LassoLarsIC(copy_X=True,
                                                       criterion='aic',
                                                       eps=2.220446049250313e-16,
                                                       fit_intercept=True,
                                                       max_iter=500,
                                                       normalize=True,
                                                       positive=False,
                                                       precompute='auto',
                                                       verbose=False),
                           rf_estimator=RandomForestRegressor(bootstrap=True,
                                                              ccp_alpha=0.0,
                                                              criterion='mse',
                                                              max_depth=None,
                                                              max_features='auto',
                                                              max_leaf_nodes=None,
                                                              max_samples=None,
                                                              min_impurity_decrease=0.0,
                                                              min_impurity_split=None,
                                                              min_samples_leaf=1,
                                                              min_samples_split=2,
                                                              min_weight_fraction_leaf=0.0,
                                                              n_estimators=100,
                                                              n_jobs=None,
                                                              oob_score=False,
                                                              random_state=None,
                                                              verbose=0,
                                                              warm_start=False))
ls_aic.score(X_test, y_test)
0.8122126507071663
len(ls_aic.estimators_)
19

pickling

A partir du moment où les conventions de l’API de scikit-learn sont respectées, tout est pris en charge.

from io import BytesIO
by = BytesIO()
pickle.dump(ls, by)
by2 = BytesIO(by.getvalue())
mod2 = pickle.load(by2)
p1 = ls.predict(X_test)
p2 = mod2.predict(X_test)
p1[:5], p2[:5]
(array([26.75262967, 18.61136749, 22.82312896, 18.0698006 , 22.2971346 ]),
 array([26.75262967, 18.61136749, 22.82312896, 18.0698006 , 22.2971346 ]))

Conclusion

L’API est une sorte de légo. Tout marche si on respecte les dimensions de départ.

show_images('lego.png', 'lego-architecture-studio-8804.jpg', figsize=(16,6));
../_images/sklearn_api_74_0.png
show_images('vue-interieure-cite-de-musique-christian-de.jpg', 'PaulPoiret-7.jpg', figsize=(16,6));
../_images/sklearn_api_75_0.png
show_images('lycee_chanzy_maquette.jpg', figsize=(16,10));
../_images/sklearn_api_76_0.png