2025, Sep 29 17:17
NaN в Brier Skill Score при кросс-валидации: причина и решение через response_method=predict_proba
Почему Brier Skill Score дает NaN при кросс-валидации на несбалансированных данных и как это исправить: задайте response_method=predict_proba в make_scorer.
Столкнуться с NaN при оценке моделей неприятно, особенно когда сравниваешь вероятностные классификаторы на несбалансированном наборе данных. Ниже — короткое и наглядное объяснение, почему это возникает с Brier Skill Score (BSS) при кросс-валидации и как устранить проблему, не меняя вашу модельную конфигурацию.
Контекст: оценка BSS на несбалансированном датасете по мошенничеству
Целевая переменная сильно несбалансирована: Counter({0: 2067, 1: 66}) примерно из 2133 строк. При 10-кратной кросс-валидации это всего около 6–7 положительных наблюдений на фолд, что зачастую вызывает подозрения в нестабильности. Однако здесь NaN был вызван другой причиной: настройкой скорера.
Воспроизводимость: откуда берётся NaN в BSS
Brier Skill Score сравнивает Brier-score вашей модели с базовой стратегией, которая всегда предсказывает долю положительного класса. Значения больше нуля означают улучшение относительно бэйзлайна; отрицательные — результат хуже бэйзлайна.
import pandas as pd
import numpy as np
from numpy import mean, std
from collections import Counter
from sklearn.preprocessing import LabelEncoder, StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.model_selection import RepeatedStratifiedKFold, cross_val_score
from sklearn.metrics import brier_score_loss, make_scorer
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
# --------------------------------------------------
# 1) Показатель Brier Skill Score
# --------------------------------------------------
def skill_brier(y_obs, y_pred_prob):
    pos_share = np.count_nonzero(y_obs) / len(y_obs)
    ref_pred = [pos_share for _ in range(len(y_obs))]
    ref_bs = brier_score_loss(y_obs, ref_pred)
    mdl_bs = brier_score_loss(y_obs, y_pred_prob)
    if ref_bs == 0:
        return 0.0
    return 1.0 - (mdl_bs / ref_bs)
# --------------------------------------------------
# 2) Оценивание с кросс-валидацией (проблемная конфигурация)
# --------------------------------------------------
def run_eval(X, y, estimator, folds=10, reps=3):
    kfold = RepeatedStratifiedKFold(n_splits=folds, n_repeats=reps, random_state=42)
    # Здесь в оценках может появляться NaN
    scorer = make_scorer(skill_brier, needs_proba=True)
    out = cross_val_score(estimator, X, y, scoring=scorer, cv=kfold, n_jobs=-1)
    print("Mean BSS: %.3f (%.3f)" % (mean(out), std(out)))
    return out
# --------------------------------------------------
# 3) Предобработка + конвейер модели
# --------------------------------------------------
def make_flow(X, base_estimator=None):
    num_feats = X.select_dtypes(include=["int64", "float64"]).columns
    cat_feats = X.select_dtypes(include=["object", "category"]).columns
    num_block = Pipeline(steps=[
        ("num_impute", SimpleImputer(strategy="mean")),
        ("num_scale", StandardScaler())
    ])
    cat_block = Pipeline(steps=[
        ("cat_impute", SimpleImputer(strategy="most_frequent")),
        ("cat_ohe", OneHotEncoder(handle_unknown="ignore"))
    ])
    features = ColumnTransformer(
        transformers=[
            ("num_blk", num_block, num_feats),
            ("cat_blk", cat_block, cat_feats)
        ]
    )
    final_est = base_estimator if base_estimator is not None else RandomForestClassifier(random_state=42)
    pipe = ImbPipeline(steps=[
        ("prep", features),
        ("clf", final_est)
    ])
    return pipe
# --------------------------------------------------
# 4) Пример использования
# --------------------------------------------------
# df = pd.read_csv("credit_card.csv")
# X = df.drop("Fraud_Flag", axis=1)
# y = LabelEncoder().fit_transform(df["Fraud_Flag"])
print(X.shape, y.shape, Counter(y))
base = DummyClassifier(strategy="prior")
base_pipe = make_flow(X, base)
print("\nБазовый уровень (DummyClassifier):")
run_eval(X, y, base_pipe)
lr_pipe = make_flow(X, LogisticRegression(max_iter=1000))
print("\nЛогистическая регрессия:")
run_eval(X, y, lr_pipe)
rf_pipe = make_flow(X, RandomForestClassifier(random_state=42))
print("\nСлучайный лес:")
run_eval(X, y, rf_pipe)
gb_pipe = make_flow(X, GradientBoostingClassifier(random_state=42))
print("\nГрадиентный бустинг:")
run_eval(X, y, gb_pipe)
Что на самом деле идёт не так
NaN появляется из-за того, как определён скорер. Использование make_scorer с needs_proba=True устарело в последних версиях scikit-learn и может приводить к нестабильному поведению. В данном случае метрика ожидает вероятности классов, поэтому функция оценки должна явно вызывать predict_proba.
Исправление: принудительно использовать predict_proba через response_method
Укажите метод ответа напрямую при создании скорера. Это убирает нестабильность и возвращает корректные значения BSS.
def run_eval_fixed(X, y, estimator, folds=10, reps=3):
    kfold = RepeatedStratifiedKFold(n_splits=folds, n_repeats=reps, random_state=42)
    # Явно запрашиваем predict_proba для вероятностных метрик
    scorer = make_scorer(skill_brier, response_method="predict_proba")
    out = cross_val_score(estimator, X, y, scoring=scorer, cv=kfold, n_jobs=-1)
    print("Mean BSS: %.3f (%.3f)" % (mean(out), std(out)))
    return out
# Пример использования (те же конвейеры, что и выше)
print("\nБазовый уровень (DummyClassifier) с исправленным скорером:")
run_eval_fixed(X, y, base_pipe)
print("\nЛогистическая регрессия с исправленным скорером:")
run_eval_fixed(X, y, lr_pipe)
print("\nСлучайный лес с исправленным скорером:")
run_eval_fixed(X, y, rf_pipe)
print("\nГрадиентный бустинг с исправленным скорером:")
run_eval_fixed(X, y, gb_pipe)
Почему это важно
При оценке вероятностных моделей по Brier Skill Score скорер должен получать вероятности классов, а не метки классов и не decision scores. Полагаться на устаревшие флаги — риск нарушить это требование и получить NaN, скрыв реальную эффективность модели. Явное указание response_method="predict_proba" снимает неоднозначность и делает путь вычисления метрики детерминированным.
В сильно несбалансированных наборах данных, как в этом примере — 66 положительных случаев из 2133, — при 10-кратной кросс-валидации на фолд приходится примерно 6–7 позитивов. Из-за этого оценки могут казаться «шумными». Но здесь проблему с NaN удалось решить именно настройкой скорера.
Итоги
Если вероятностная метрика возвращает NaN при кросс-валидации, проверьте, как скорер передаёт предсказания в вашу метрику. Для Brier Skill Score задайте response_method="predict_proba" в make_scorer, чтобы метрика работала с вероятностями. На несбалансированных целях следите за составом фолдов и используйте стратифицированную кросс-валидацию, как показано выше.
Статья основана на вопросе на StackOverflow от Br0k3nS0u1 и ответе Br0k3nS0u1.