Source code for skfolio.metrics._scorer

"""Scorer module."""

# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
# Implementation derived from:
# scikit-portfolio, Copyright (c) 2022, Carlo Nicolini, Licensed under MIT Licence.
# scikit-learn, Copyright (c) 2007-2010 David Cournapeau, Fabian Pedregosa, Olivier
# Grisel Licensed under BSD 3 clause.

from __future__ import annotations

from collections.abc import Callable

import skfolio.typing as skt
from skfolio.measures import BaseMeasure
from skfolio.portfolio import Portfolio
from skfolio.typing import ArrayLike

__all__ = ["make_scorer"]


class _BaseScorer:
    """Base scorer that stores the score function, sign, and bound kwargs."""

    def __init__(
        self,
        score_func: Callable,
        sign: int,
        kwargs: dict,
        response_method: str | None = "predict",
    ):
        self._score_func = score_func
        self._sign = sign
        self._kwargs = kwargs
        self._response_method = response_method

    def __repr__(self) -> str:
        name = getattr(self._score_func, "__name__", repr(self._score_func))
        kwargs_string = "".join([f", {k}={v}" for k, v in self._kwargs.items()])
        response_str = (
            ""
            if self._response_method == "predict"
            else f", response_method={self._response_method!r}"
        )
        return (
            f"make_scorer({name}"
            f"{'' if self._sign > 0 else ', greater_is_better=False'}"
            f"{response_str}"
            f"{kwargs_string})"
        )


class _PortfolioScorer(_BaseScorer):
    """Scorer for portfolio optimization estimators.

    Calls `estimator.predict(X)` and passes the resulting
    :class:`~skfolio.portfolio.Portfolio` to `score_func`.

    Created by :func:`make_scorer` with `response_method="predict"`.
    """

    def __call__(self, estimator, X_test: ArrayLike, y=None) -> float:
        """Compute the score of the estimator prediction on X.

        Parameters
        ----------
        estimator : BaseOptimization
            Trained Portfolio Optimization estimator to use for scoring (e.g.
            :class:`~skfolio.optimization.MeanRisk`).

        X_test : array-like of shape (n_observations, n_assets)
            Test data that will be fed to `estimator.predict`.

        y : ignored
            Present for scikit-learn scorer protocol compatibility.

        Returns
        -------
        score : float
            Score of the estimator prediction on X_test.
        """
        pred = estimator.predict(X_test)
        return self._sign * self._score_func(pred, **self._kwargs)


class _EstimatorScorer(_BaseScorer):
    """Scorer for non-predictor estimators (covariance, expected returns, prior).

    These estimators implement `fit` but not `predict`, so the scorer passes
    the fitted estimator and test data directly to
    `score_func(estimator, X_test, **kwargs)`.

    Created by :func:`make_scorer` with `response_method=None`.
    """

    def __call__(self, estimator, X_test: ArrayLike, y=None) -> float:
        """Score a fitted non-predictor estimator against test data.

        Parameters
        ----------
        estimator : BaseEstimator
            Fitted non-predictor estimator (e.g.
            :class:`~skfolio.moments.EWCovariance`,
            :class:`~skfolio.moments.EWMu`,
            :class:`~skfolio.prior.EmpiricalPrior`).

        X_test : array-like of shape (n_observations, n_assets)
            Test data passed directly to `score_func` alongside
            `estimator`.

        y : ignored
            Present for scikit-learn scorer protocol compatibility.

        Returns
        -------
        score : float
            Score of the estimator on `X_test`.
        """
        return self._sign * self._score_func(estimator, X_test, **self._kwargs)


[docs] def make_scorer( score_func: skt.Measure | Callable, greater_is_better: bool | None = None, response_method: str | None = "predict", **kwargs, ) -> _PortfolioScorer | _EstimatorScorer: """Make a scorer from a :ref:`measure <measures_ref>`, a portfolio score function, or a non-predictor estimator score function. This function wraps scoring functions for use in model selection: * `response_method="predict"` (default): for portfolio optimization estimators (e.g. :class:`~skfolio.optimization.MeanRisk`). Compatible with :class:`~sklearn.model_selection.GridSearchCV` and :func:`~sklearn.model_selection.cross_val_score`. * `response_method=None`: for non-predictor estimators (covariance, expected returns, prior) that implement `fit` but not `predict`. Compatible with both sklearn cross-validation utilities and skfolio online utilities (:class:`~skfolio.model_selection.OnlineGridSearch`, :func:`~skfolio.model_selection.online_score`). .. note :: For online evaluation of portfolio optimization estimators, pass a :ref:`measure <measures_ref>` directly to the `scoring` parameter instead of using `make_scorer`. Online evaluation scores the full aggregated :class:`~skfolio.portfolio.MultiPeriodPortfolio` rather than averaging per-fold scores. Parameters ---------- score_func : Measure | callable If `score_func` is a :ref:`measure <measures_ref>`, we return the measure of the predicted :class:`~skfolio.portfolio.Portfolio` times `1` or `-1` depending on `greater_is_better`. `response_method` must be `"predict"` in this case. If `response_method="predict"`, `score_func` must be a score function (or loss function) with signature `score_func(pred, **kwargs)` where `pred` is the predicted :class:`~skfolio.portfolio.Portfolio`. If `response_method=None`, `score_func` must be a score function (or loss function) with signature `score_func(estimator, X_test, **kwargs)` where `estimator` is the fitted non-predictor estimator and `X_test` the realized returns. greater_is_better : bool, optional Whether `score_func` is a score function (high is good) or a loss function (low is good). In the latter case the scorer sign-flips the outcome so that higher values always indicate a better model. The default (`None`) is: * If `score_func` is a :ref:`measure <measures_ref>`: * `True` for :class:`~skfolio.measures.PerfMeasure` and :class:`~skfolio.measures.RatioMeasure`. * `False` for :class:`~skfolio.measures.RiskMeasure` and :class:`~skfolio.measures.ExtraRiskMeasure`. * Otherwise, `True`. response_method : str or None, default="predict" Determines how the scorer obtains predictions. Only `"predict"` and `None` are supported: * `"predict"`: call `estimator.predict(X)` and pass the resulting :class:`~skfolio.portfolio.Portfolio` to `score_func`. Use for portfolio optimization estimators (e.g. :class:`~skfolio.optimization.MeanRisk`). * `None`: pass `(estimator, X_test)` directly to `score_func` without calling any response method. Use for non-predictor estimators (covariance, expected returns, prior). **kwargs : additional arguments Additional parameters to be passed to `score_func`. Returns ------- scorer : callable Callable object with signature `scorer(estimator, X, y=None)` that returns a scalar score (higher is better). Examples -------- Portfolio scorer from a measure: >>> from skfolio.measures import RatioMeasure >>> scorer = make_scorer(RatioMeasure.SHARPE_RATIO) Portfolio scorer from a custom function: >>> def custom(pred): ... return pred.mean - 2 * pred.variance >>> scorer = make_scorer(custom) Non-predictor estimator scorer for covariance evaluation: >>> from skfolio.metrics import portfolio_variance_qlike_loss >>> import numpy as np >>> scorer = make_scorer( ... portfolio_variance_qlike_loss, ... greater_is_better=False, ... response_method=None, ... portfolio_weights=np.ones(20) / 20, ... ) """ if response_method not in ("predict", None): raise ValueError( f"response_method must be 'predict' (for portfolio optimization " f"estimators) or None (for non-predictor estimators), " f"got {response_method!r}." ) if callable(score_func): if greater_is_better is None: greater_is_better = True measure = None else: measure = score_func if not isinstance(measure, BaseMeasure): raise TypeError("`score_func` must be a callable or a measure") if response_method is None: raise ValueError( "response_method=None is not supported when score_func is a " "measure. Measures require response_method='predict'." ) if greater_is_better is None: if measure.is_perf or measure.is_ratio: greater_is_better = True else: greater_is_better = False def score_func(pred: Portfolio) -> float: return getattr(pred, measure.value) score_func.__name__ = repr(measure) sign = 1 if greater_is_better else -1 if response_method == "predict": return _PortfolioScorer(score_func, sign, kwargs) return _EstimatorScorer(score_func, sign, kwargs, response_method=None)