Passed
Push — master ( 4a40b1...18f8a0 )
by Simon
01:39
created

SklearnCvExperiment._evaluate()   A

Complexity

Conditions 1

Size

Total Lines 33
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 14
dl 0
loc 33
rs 9.7
c 0
b 0
f 0
cc 1
nop 2
1
"""Experiment adapter for sklearn cross-validation experiments."""
2
# copyright: hyperactive developers, MIT License (see LICENSE file)
3
4
from sklearn import clone
5
from sklearn.metrics import check_scoring
6
from sklearn.model_selection import cross_validate
7
from sklearn.utils.validation import _num_samples
8
9
from hyperactive.base import BaseExperiment
10
11
class SklearnCvExperiment(BaseExperiment):
12
    """Experiment adapter for sklearn cross-validation experiments.
13
14
    This class is used to perform cross-validation experiments using a given
15
    sklearn estimator. It allows for hyperparameter tuning and evaluation of
16
    the model's performance using cross-validation.
17
18
    The score returned is the mean of the cross-validation scores,
19
    of applying cross-validation to ``estimator`` with the parameters given in
20
    ``score`` ``params``.
21
22
    The cross-validation performed is specified by the ``cv`` parameter,
23
    and the scoring metric is specified by the ``scoring`` parameter.
24
    The ``X`` and ``y`` parameters are the input data and target values,
25
    which are used in fit/predict cross-validation.
26
27
    Parameters
28
    ----------
29
    estimator : sklearn estimator
30
        The estimator to be used for the experiment.
31
    X : array-like, shape (n_samples, n_features)
32
            The input data for the model.
33
    y : array-like, shape (n_samples,) or (n_samples, n_outputs)
34
        The target values for the model.
35
    cv : int or cross-validation generator, default = KFold(n_splits=3, shuffle=True)
36
        The number of folds or cross-validation strategy to be used.
37
        If int, the cross-validation used is KFold(n_splits=cv, shuffle=True).
38
    scoring : callable or str, default = accuracy_score or mean_squared_error
39
        sklearn scoring function or metric to evaluate the model's performance.
40
        Default is determined by the type of estimator:
41
        ``accuracy_score`` for classifiers, and
42
        ``mean_squared_error`` for regressors, as per sklearn convention
43
        through the default ``score`` method of the estimator.
44
45
    Example
46
    -------
47
    >>> from hyperactive.experiment.integrations import SklearnCvExperiment
48
    >>> from sklearn.datasets import load_iris
49
    >>> from sklearn.svm import SVC
50
    >>> from sklearn.metrics import accuracy_score
51
    >>> from sklearn.model_selection import KFold
52
    >>>
53
    >>> X, y = load_iris(return_X_y=True)
54
    >>>
55
    >>> sklearn_exp = SklearnCvExperiment(
56
    ...     estimator=SVC(),
57
    ...     scoring=accuracy_score,
58
    ...     cv=KFold(n_splits=3, shuffle=True),
59
    ...     X=X,
60
    ...     y=y,
61
    ... )
62
    >>> params = {"C": 1.0, "kernel": "linear"}
63
    >>> score, add_info = sklearn_exp.score(params)
64
65
    For default choices of ``scoring`` and ``cv``:
66
    >>> sklearn_exp = SklearnCvExperiment(
67
    ...     estimator=SVC(),
68
    ...     X=X,
69
    ...     y=y,
70
    ... )
71
    >>> params = {"C": 1.0, "kernel": "linear"}
72
    >>> score, add_info = sklearn_exp.score(params)
73
74
    Quick call without metadata return or dictionary:
75
    >>> score = sklearn_exp(C=1.0, kernel="linear")
76
    """
77
78
    def __init__(self, estimator, X, y, scoring=None, cv=None):
79
        self.estimator = estimator
80
        self.X = X
81
        self.y = y
82
        self.scoring = scoring
83
        self.cv = cv
84
85
        super().__init__()
86
87
        if cv is None:
88
            from sklearn.model_selection import KFold
89
90
            self._cv = KFold(n_splits=3, shuffle=True)
91
        elif isinstance(cv, int):
92
            from sklearn.model_selection import KFold
93
94
            self._cv = KFold(n_splits=cv, shuffle=True)
95
        else:
96
            self._cv = cv
97
98
        # check if scoring is a scorer by checking for "estimator" in signature
99
        if scoring is None:
100
            self._scoring = check_scoring(self.estimator)
101
        # check using inspect.signature for "estimator" in signature
102
        elif callable(scoring):
103
            from inspect import signature
104
105
            if "estimator" in signature(scoring).parameters:
106
                self._scoring = scoring
107
            else:
108
                from sklearn.metrics import make_scorer
109
110
                self._scoring = make_scorer(scoring)
111
        self.scorer_ = self._scoring
112
113
        # Set the sign of the scoring function
114
        if hasattr(self._scoring, "_score"):
115
            score_func = self._scoring._score_func
116
            _sign = _guess_sign_of_sklmetric(score_func)
117
            _sign_str = "higher" if _sign == 1 else "lower"
118
            self.set_tags(**{"property:higher_or_lower_is_better": _sign_str})
119
120
    def _paramnames(self):
121
        """Return the parameter names of the search.
122
123
        Returns
124
        -------
125
        list of str
126
            The parameter names of the search parameters.
127
        """
128
        return list(self.estimator.get_params().keys())
129
130
    def _evaluate(self, params):
131
        """Evaluate the parameters.
132
133
        Parameters
134
        ----------
135
        params : dict with string keys
136
            Parameters to evaluate.
137
138
        Returns
139
        -------
140
        float
141
            The value of the parameters as per evaluation.
142
        dict
143
            Additional metadata about the search.
144
        """
145
        estimator = clone(self.estimator)
146
        estimator.set_params(**params)
147
148
        cv_results = cross_validate(
149
            estimator,
150
            self.X,
151
            self.y,
152
            scoring=self._scoring,
153
            cv=self._cv,
154
        )
155
156
        add_info_d = {
157
            "score_time": cv_results["score_time"],
158
            "fit_time": cv_results["fit_time"],
159
            "n_test_samples": _num_samples(self.X),
160
        }
161
162
        return cv_results["test_score"].mean(), add_info_d
163
164
    @classmethod
165
    def get_test_params(cls, parameter_set="default"):
166
        """Return testing parameter settings for the skbase object.
167
168
        ``get_test_params`` is a unified interface point to store
169
        parameter settings for testing purposes. This function is also
170
        used in ``create_test_instance`` and ``create_test_instances_and_names``
171
        to construct test instances.
172
173
        ``get_test_params`` should return a single ``dict``, or a ``list`` of ``dict``.
174
175
        Each ``dict`` is a parameter configuration for testing,
176
        and can be used to construct an "interesting" test instance.
177
        A call to ``cls(**params)`` should
178
        be valid for all dictionaries ``params`` in the return of ``get_test_params``.
179
180
        The ``get_test_params`` need not return fixed lists of dictionaries,
181
        it can also return dynamic or stochastic parameter settings.
182
183
        Parameters
184
        ----------
185
        parameter_set : str, default="default"
186
            Name of the set of test parameters to return, for use in tests. If no
187
            special parameters are defined for a value, will return `"default"` set.
188
189
        Returns
190
        -------
191
        params : dict or list of dict, default = {}
192
            Parameters to create testing instances of the class
193
            Each dict are parameters to construct an "interesting" test instance, i.e.,
194
            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
195
            `create_test_instance` uses the first (or only) dictionary in `params`
196
        """
197
        from sklearn.datasets import load_diabetes, load_iris
198
        from sklearn.svm import SVC, SVR
199
        from sklearn.metrics import accuracy_score, mean_absolute_error
200
        from sklearn.model_selection import KFold
201
202
        X, y = load_iris(return_X_y=True)
203
        params_classif = {
204
            "estimator": SVC(),
205
            "scoring": accuracy_score,
206
            "cv": KFold(n_splits=3, shuffle=True),
207
            "X": X,
208
            "y": y,
209
        }
210
211
        X, y = load_diabetes(return_X_y=True)
212
        params_regress = {
213
            "estimator": SVR(),
214
            "scoring": mean_absolute_error,
215
            "cv": 2,
216
            "X": X,
217
            "y": y,
218
        }
219
220
        X, y = load_diabetes(return_X_y=True)
221
        params_all_default = {
222
            "estimator": SVR(),
223
            "X": X,
224
            "y": y,
225
        }
226
227
        return [params_classif, params_regress, params_all_default]
228
229
    @classmethod
230
    def _get_score_params(self):
231
        """Return settings for testing score/evaluate functions. Used in tests only.
232
233
        Returns a list, the i-th element should be valid arguments for
234
        self.evaluate and self.score, of an instance constructed with
235
        self.get_test_params()[i].
236
237
        Returns
238
        -------
239
        list of dict
240
            The parameters to be used for scoring.
241
        """
242
        score_params_classif = {"C": 1.0, "kernel": "linear"}
243
        score_params_regress = {"C": 1.0, "kernel": "linear"}
244
        score_params_defaults = {"C": 1.0, "kernel": "linear"}
245
        return [score_params_classif, score_params_regress, score_params_defaults]
246
247
248
def _guess_sign_of_sklmetric(scorer):
249
    """Guess the sign of a sklearn metric scorer.
250
251
    Parameters
252
    ----------
253
    scorer : callable
254
        The sklearn metric scorer to guess the sign for.
255
256
    Returns
257
    -------
258
    int
259
        1 if higher scores are better, -1 if lower scores are better.
260
    """
261
    HIGHER_IS_BETTER = {
262
        # Classification
263
        "accuracy_score": True,
264
        "auc": True,
265
        "average_precision_score": True,
266
        "balanced_accuracy_score": True,
267
        "brier_score_loss": False,
268
        "class_likelihood_ratios": False,
269
        "cohen_kappa_score": True,
270
        "d2_log_loss_score": True,
271
        "dcg_score": True,
272
        "f1_score": True,
273
        "fbeta_score": True,
274
        "hamming_loss": False,
275
        "hinge_loss": False,
276
        "jaccard_score": True,
277
        "log_loss": False,
278
        "matthews_corrcoef": True,
279
        "ndcg_score": True,
280
        "precision_score": True,
281
        "recall_score": True,
282
        "roc_auc_score": True,
283
        "top_k_accuracy_score": True,
284
        "zero_one_loss": False,
285
286
        # Regression
287
        "d2_absolute_error_score": True,
288
        "d2_pinball_score": True,
289
        "d2_tweedie_score": True,
290
        "explained_variance_score": True,
291
        "max_error": False,
292
        "mean_absolute_error": False,
293
        "mean_absolute_percentage_error": False,
294
        "mean_gamma_deviance": False,
295
        "mean_pinball_loss": False,
296
        "mean_poisson_deviance": False,
297
        "mean_squared_error": False,
298
        "mean_squared_log_error": False,
299
        "mean_tweedie_deviance": False,
300
        "median_absolute_error": False,
301
        "r2_score": True,
302
        "root_mean_squared_error": False,
303
        "root_mean_squared_log_error": False,
304
    }
305
306
    scorer_name = getattr(scorer, "__name__", None)
307
308
    if hasattr(scorer, "greater_is_better"):
309
        return 1 if scorer.greater_is_better else -1
310
    elif scorer_name in HIGHER_IS_BETTER:
311
        return 1 if HIGHER_IS_BETTER[scorer_name] else -1
312
    elif scorer_name.endswith("_score"):
313
        # If the scorer name ends with "_score", we assume higher is better
314
        return 1
315
    elif scorer_name.endswith("_loss") or scorer_name.endswith("_deviance"):
316
        # If the scorer name ends with "_loss", we assume lower is better
317
        return -1
318
    elif scorer_name.endswith("_error"):
319
        return -1
320
    else:
321
        # If we cannot determine the sign, we assume lower is better
322
        return -1
323