Примечание
Перейти в конец, чтобы скачать полный код примера или запустить этот пример в браузере через JupyterLite или Binder.
Построение порога принятия решения для обучения с учетом затрат
После обучения классификатора результаты метода predict представляют собой предсказания меток классов, полученные путем порогового преобразования результата либо decision_function, либо predict_proba. Для бинарного классификатора порог по умолчанию соответствует апостериорной оценке вероятности 0.5 или оценке решения 0.0.
Однако эта стратегия по умолчанию, скорее всего, не оптимальна для поставленной задачи. В качестве примера рассмотрим набор данных о кредитоспособности в Германии «Statlog» [1]. Задача состоит в том, чтобы предсказать, является ли кредит человека «хорошим» или «плохим». Кроме того, предоставлена матрица затрат, задающая стоимость ошибочной классификации. В частности, ошибочно классифицировать «плохой» кредит как «хороший» в среднем в пять раз затратнее, чем ошибочно классифицировать «хороший» кредит как «плохой».
Мы используем TunedThresholdClassifierCV, чтобы выбрать пороговое значение функции принятия решения, минимизирующее заданные бизнес-затраты.
Во второй части примера мы расширим этот подход, рассмотрев задачу обнаружения мошенничества при операциях с кредитными картами: в этом случае бизнес-метрика зависит от суммы каждой отдельной операции.
Ссылки
# Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause
Обучение с учетом затрат при постоянных выгодах и затратах
В этом первом разделе мы покажем, как использовать TunedThresholdClassifierCV для обучения с учетом затрат, когда выгоды и затраты, связанные с каждой ячейкой матрицы ошибок, постоянны. Мы рассмотрим задачу, описанную в [2], используя набор данных о кредитоспособности в Германии «Statlog» [1].
Набор данных о кредитоспособности в Германии «Statlog»
Загрузим набор данных о кредитоспособности в Германии из OpenML.
import sklearn from sklearn.datasets import fetch_openml sklearn.set_config(transform_output="pandas") german_credit = fetch_openml(data_id=31, as_frame=True, parser="pandas") X, y = german_credit.data, german_credit.target
Проверим типы признаков, доступные в X.
X.info()
<class 'pandas.DataFrame'> RangeIndex: 1000 entries, 0 to 999 Data columns (total 20 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 checking_status 1000 non-null category 1 duration 1000 non-null int64 2 credit_history 1000 non-null category 3 purpose 1000 non-null category 4 credit_amount 1000 non-null int64 5 savings_status 1000 non-null category 6 employment 1000 non-null category 7 installment_commitment 1000 non-null int64 8 personal_status 1000 non-null category 9 other_parties 1000 non-null category 10 residence_since 1000 non-null int64 11 property_magnitude 1000 non-null category 12 age 1000 non-null int64 13 other_payment_plans 1000 non-null category 14 housing 1000 non-null category 15 existing_credits 1000 non-null int64 16 job 1000 non-null category 17 num_dependents 1000 non-null int64 18 own_telephone 1000 non-null category 19 foreign_worker 1000 non-null category dtypes: category(13), int64(7) memory usage: 68.9 KB
Многие признаки являются категориальными и обычно представлены строками. При разработке предиктивной модели нам нужно закодировать эти категории. Проверим целевые значения.
y.value_counts()
class good 700 bad 300 Name: count, dtype: int64
Также обратим внимание, что набор данных несбалансирован. При оценке предиктивной модели нужно быть осторожными и использовать набор метрик, подходящих для такого случая.
Кроме того, целевая переменная представлена строками. Для некоторых метрик (например, точности и полноты) нужно указать интересующую метку, также называемую «положительной меткой». Здесь мы определим цель как предсказание того, является ли образец «плохим» кредитом.
pos_label, neg_label = "bad", "good"
Для проведения анализа разделим набор данных, выполнив одно стратифицированное разбиение.
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, random_state=0)
Теперь мы готовы спроектировать предиктивную модель и соответствующую стратегию оценки.
Метрики оценки
В этом разделе мы определим набор метрик, которые будем использовать далее. Чтобы увидеть эффект настройки порогового значения, оценим предиктивную модель с помощью ROC-кривой (Receiver Operating Characteristic) и кривой «точность — полнота». Значения, отображаемые на этих графиках, — это доля истинно положительных результатов (TPR), также известная как полнота или чувствительность, и доля ложноположительных результатов (FPR), также известная как специфичность, для ROC-кривой, а также точность и полнота для кривой «точность — полнота».
Из этих четырех метрик scikit-learn не предоставляет функцию-оценщик для FPR. Поэтому нам нужно определить небольшую пользовательскую функцию для ее вычисления.
from sklearn.metrics import confusion_matrix
def fpr_score(y, y_pred, neg_label, pos_label):
cm = confusion_matrix(y, y_pred, labels=[neg_label, pos_label])
tn, fp, _, _ = cm.ravel()
tnr = tn / (tn + fp)
return 1 - tnr
Как уже отмечалось, «положительная метка» не задана значением «1», и вызов некоторых метрик с этим нестандартным значением приводит к ошибке. Нужно сообщить метрикам, какая метка считается «положительной».
Для этого определим оценщик scikit-learn с помощью make_scorer, передав в него необходимые сведения. Все пользовательские оценщики мы сохраним в словаре. Чтобы использовать их, нужно передать обученную модель, данные и целевую переменную, на которых мы хотим оценить предиктивную модель.
from sklearn.metrics import make_scorer, precision_score, recall_score
tpr_score = recall_score # TPR and recall are the same metric
scoring = {
"precision": make_scorer(precision_score, pos_label=pos_label),
"recall": make_scorer(recall_score, pos_label=pos_label),
"fpr": make_scorer(fpr_score, neg_label=neg_label, pos_label=pos_label),
"tpr": make_scorer(tpr_score, pos_label=pos_label),
}
Кроме того, в первоначальном исследовании [1] определена пользовательская бизнес-метрика. «Бизнес-метрикой» мы называем любую функцию метрики, предназначенную для количественной оценки влияния предсказаний (верных или ошибочных) на бизнес-ценность внедрения заданной модели машинного обучения в конкретном прикладном контексте. Для нашей задачи прогнозирования кредитоспособности авторы предлагают пользовательскую матрицу затрат, отражающую тот факт, что классифицировать «плохой» кредит как «хороший» в среднем в 5 раз затратнее, чем наоборот: финансовому учреждению менее затратно отказать в кредите потенциальному клиенту, который не допустил бы просрочки (и тем самым упустить хорошего клиента, который в противном случае выплатил бы кредит и проценты), чем выдать кредит клиенту, который допустит просрочку.
Определим функцию Python, которая взвешивает матрицу ошибок и возвращает общую стоимость. Строки матрицы ошибок содержат количества наблюдаемых классов, а столбцы — количества предсказанных классов. Здесь положительным классом считается «плохой» кредит (вторая строка и второй столбец). Инструменты выбора моделей scikit-learn предполагают, что мы соблюдаем соглашение «чем выше, тем лучше», поэтому следующая матрица выгод назначает отрицательные значения (затраты) двум типам ошибочных предсказаний:
- выгода
-1за каждый ложноположительный результат (кредит «хороший», помеченный как «плохой»), - выгода
-5за каждый ложноотрицательный результат (кредит «плохой», помеченный как «хороший»), - выгода
0за истинно положительный и истинно отрицательный результаты.
Обратите внимание: теоретически, если наша модель откалибрована, а набор данных репрезентативен и достаточно велик, настраивать порог не требуется — его можно безопасно установить равным 1/5 отношения затрат, как указано в уравнении (2) статьи Элкана [2].
import numpy as np
def credit_gain_score(y, y_pred, neg_label, pos_label):
cm = confusion_matrix(y, y_pred, labels=[neg_label, pos_label])
gain_matrix = np.array(
[
[0, -1], # -1 gain for false positives
[-5, 0], # -5 gain for false negatives
]
)
return np.sum(cm * gain_matrix)
scoring["credit_gain"] = make_scorer(
credit_gain_score, neg_label=neg_label, pos_label=pos_label
)
Базовая прогнозная модель
В качестве прогнозной модели мы используем HistGradientBoostingClassifier, которая изначально поддерживает категориальные признаки и пропущенные значения.
from sklearn.ensemble import HistGradientBoostingClassifier
model = HistGradientBoostingClassifier(
categorical_features="from_dtype", random_state=0
).fit(X_train, y_train)
model
Мы оцениваем качество нашей прогнозной модели с помощью ROC-кривой и кривой Precision-Recall.
import matplotlib.pyplot as plt
from sklearn.metrics import PrecisionRecallDisplay, RocCurveDisplay
fig, axs = plt.subplots(nrows=1, ncols=2, figsize=(14, 6))
PrecisionRecallDisplay.from_estimator(
model, X_test, y_test, pos_label=pos_label, ax=axs[0], name="GBDT"
)
axs[0].plot(
scoring["recall"](model, X_test, y_test),
scoring["precision"](model, X_test, y_test),
marker="o",
markersize=10,
color="tab:blue",
label="Default cut-off point at a probability of 0.5",
)
axs[0].set_title("Precision-Recall curve")
axs[0].legend()
RocCurveDisplay.from_estimator(
model,
X_test,
y_test,
pos_label=pos_label,
ax=axs[1],
name="GBDT",
plot_chance_level=True,
)
axs[1].plot(
scoring["fpr"](model, X_test, y_test),
scoring["tpr"](model, X_test, y_test),
marker="o",
markersize=10,
color="tab:blue",
label="Default cut-off point at a probability of 0.5",
)
axs[1].set_title("ROC curve")
axs[1].legend()
_ = fig.suptitle("Evaluation of the vanilla GBDT model")

Напомним, что эти кривые показывают статистические характеристики прогнозной модели при разных пороговых значениях. Для кривой Precision-Recall отображаются точность и полнота, а для ROC-кривой — TPR (то же, что и полнота) и FPR.
Здесь разным пороговым значениям соответствуют разные оценки апостериорной вероятности в диапазоне от 0 до 1. По умолчанию model.predict использует пороговое значение, равное оценке вероятности 0.5. Метрики для такого порогового значения отмечены на кривых синими точками: они соответствуют статистическим характеристикам модели при использовании model.predict.
Однако напомним, что исходная цель состояла в минимизации затрат (или максимизации выгоды), определяемых бизнес-метрикой. Мы можем вычислить значение бизнес-метрики:
print(f"Business defined metric: {scoring['credit_gain'](model, X_test, y_test)}")
Business defined metric: -209
На этом этапе мы ещё не знаем, может ли другое пороговое значение обеспечить большую выгоду. Чтобы найти оптимальное значение, нужно вычислить затраты и выгоду по бизнес-метрике для всех возможных пороговых значений и выбрать наилучшее. Реализовывать эту стратегию вручную может быть довольно утомительно, но нам поможет класс TunedThresholdClassifierCV. Он автоматически вычисляет затраты и выгоду для всех возможных пороговых значений и оптимизирует scoring.
Настройка порогового значения
Для настройки порогового значения мы используем TunedThresholdClassifierCV. Нужно указать бизнес-метрику, которую следует оптимизировать, и метку положительного класса. Внутри алгоритма оптимальное пороговое значение выбирается так, чтобы максимизировать бизнес-метрику с помощью перекрёстной проверки. По умолчанию используется стратифицированная перекрёстная проверка с 5 блоками.
from sklearn.model_selection import TunedThresholdClassifierCV
tuned_model = TunedThresholdClassifierCV(
estimator=model,
scoring=scoring["credit_gain"],
store_cv_results=True, # necessary to inspect all results
)
tuned_model.fit(X_train, y_train)
print(f"{tuned_model.best_threshold_=:0.2f}")
tuned_model.best_threshold_=0.03
Построим ROC-кривые и кривые Precision-Recall для базовой и настроенной моделей. Также отметим пороговые значения, которые использует каждая модель. Поскольку этот код будет использоваться далее повторно, определим функцию для построения графиков.
def plot_roc_pr_curves(vanilla_model, tuned_model, *, title):
fig, axs = plt.subplots(nrows=1, ncols=3, figsize=(21, 6))
linestyles = ("dashed", "dotted")
markerstyles = ("o", ">")
colors = ("tab:blue", "tab:orange")
names = ("Vanilla GBDT", "Tuned GBDT")
for idx, (est, linestyle, marker, color, name) in enumerate(
zip((vanilla_model, tuned_model), linestyles, markerstyles, colors, names)
):
decision_threshold = getattr(est, "best_threshold_", 0.5)
PrecisionRecallDisplay.from_estimator(
est,
X_test,
y_test,
pos_label=pos_label,
ax=axs[0],
name=name,
curve_kwargs={"linestyle": linestyle, "color": color},
)
axs[0].plot(
scoring["recall"](est, X_test, y_test),
scoring["precision"](est, X_test, y_test),
marker,
markersize=10,
color=color,
label=f"Cut-off point at probability of {decision_threshold:.2f}",
)
RocCurveDisplay.from_estimator(
est,
X_test,
y_test,
pos_label=pos_label,
curve_kwargs={"linestyle": linestyle, "color": color},
ax=axs[1],
name=name,
plot_chance_level=idx == 1,
)
axs[1].plot(
scoring["fpr"](est, X_test, y_test),
scoring["tpr"](est, X_test, y_test),
marker,
markersize=10,
color=color,
label=f"Cut-off point at probability of {decision_threshold:.2f}",
)
axs[0].set_title("Precision-Recall curve")
axs[0].legend()
axs[1].set_title("ROC curve")
axs[1].legend()
axs[2].plot(
tuned_model.cv_results_["thresholds"],
tuned_model.cv_results_["scores"],
color="tab:orange",
)
axs[2].plot(
tuned_model.best_threshold_,
tuned_model.best_score_,
"o",
markersize=10,
color="tab:orange",
label="Optimal cut-off point for the business metric",
)
axs[2].legend()
axs[2].set_xlabel("Decision threshold (probability)")
axs[2].set_ylabel("Objective score (using cost-matrix)")
axs[2].set_title("Objective score as a function of the decision threshold")
fig.suptitle(title)
title = "Comparison of the cut-off point for the vanilla and tuned GBDT model" plot_roc_pr_curves(model, tuned_model, title=title)

Первое замечание: обе модели классификации имеют в точности одинаковые ROC-кривые и кривые Precision-Recall. Это ожидаемо, поскольку по умолчанию классификатор обучается на одних и тех же данных. В одном из следующих разделов мы подробнее рассмотрим доступные варианты повторного обучения модели и перекрёстной проверки.
Второе замечание: пороговые значения базовой и настроенной моделей различаются. Чтобы понять, почему настроенная модель выбрала именно это пороговое значение, можно посмотреть на график справа, где показана целевая оценка, совпадающая с нашей бизнес-метрикой. Видно, что оптимальный порог соответствует максимальному значению целевой оценки. Максимум достигается при пороге принятия решения, значительно меньшем 0.5: настроенная модель обеспечивает гораздо более высокую полноту за счёт существенно меньшей точности. Она гораздо охотнее присваивает метку класса «bad» большей доле людей.
Теперь можно проверить, приводит ли выбор этого порогового значения к более высокой оценке на тестовой выборке:
print(f"Business defined metric: {scoring['credit_gain'](tuned_model, X_test, y_test)}")
Business defined metric: -143
Мы видим, что настройка порога принятия решения почти вдвое увеличивает нашу бизнес-выгоду.
Рассмотрение переобучения модели и кросс-валидации
В приведённом выше эксперименте мы использовали настройки по умолчанию для TunedThresholdClassifierCV. В частности, пороговое значение подбирается с помощью стратифицированной кросс-валидации с 5 блоками. Кроме того, после выбора порогового значения базовая прогнозная модель повторно обучается на всём обучающем наборе данных.
Эти две стратегии можно изменить, задав параметры refit и cv. Например, можно передать обученный estimator и установить cv="prefit" — в этом случае пороговое значение будет найдено на всём наборе данных, переданном во время обучения. Кроме того, можно отключить повторное обучение базового классификатора, задав refit=False. Попробуем провести такой эксперимент.
model.fit(X_train, y_train)
tuned_model.set_params(cv="prefit", refit=False).fit(X_train, y_train)
print(f"{tuned_model.best_threshold_=:0.2f}")
tuned_model.best_threshold_=0.25
Затем оценим нашу модель тем же способом, что и ранее:
title = "Tuned GBDT model without refitting and using the entire dataset" plot_roc_pr_curves(model, tuned_model, title=title)

Мы видим, что оптимальное пороговое значение отличается от найденного в предыдущем эксперименте. Если посмотреть на график справа, можно заметить, что бизнес-выигрыш имеет широкое плато, где выигрыш близок к оптимальному значению 0 для большого диапазона пороговых значений принятия решения. Такое поведение характерно для переобучения. Поскольку мы отключили кросс-валидацию, пороговое значение подбиралось на том же наборе данных, на котором обучалась модель, что и объясняет наблюдаемое переобучение.
Поэтому этот параметр следует использовать с осторожностью. Необходимо убедиться, что данные, переданные при обучении в TunedThresholdClassifierCV, отличаются от данных, использованных для обучения базового классификатора. Такой сценарий может возникнуть, если требуется лишь настроить прогнозную модель на совершенно новом проверочном наборе, не выполняя дорогостоящее полное переобучение.
Если кросс-валидация обходится слишком дорого, можно использовать в качестве альтернативы единственное разделение на обучающую и тестовую выборки, задав параметру cv вещественное число из диапазона [0, 1]. Данные разделяются на обучающую и тестовую выборки. Рассмотрим этот вариант:
tuned_model.set_params(cv=0.75).fit(X_train, y_train)
title = "Tuned GBDT model without refitting and using the entire dataset" plot_roc_pr_curves(model, tuned_model, title=title)

Что касается порогового значения, мы видим, что оптимум близок к результату многократной повторной кросс-валидации. Однако следует учитывать, что одно разбиение не отражает вариативность процесса обучения/предсказания, поэтому мы не можем определить, есть ли дисперсия у порогового значения. При повторной кросс-валидации этот эффект усредняется.
Ещё одно наблюдение касается ROC-кривой и кривой Precision-Recall настроенной модели. Как и ожидалось, эти кривые отличаются от кривых исходной модели, поскольку базовый классификатор обучался на подмножестве данных, предоставленных при обучении, а проверочная выборка была зарезервирована для настройки порогового значения.
Обучение с учетом затрат, когда выгоды и затраты непостоянны
Как указано в [2], в реальных задачах выгоды и затраты, как правило, непостоянны. В этом разделе мы используем пример, аналогичный приведенному в [2], для задачи обнаружения мошенничества в записях транзакций по кредитным картам.
Набор данных о кредитных картах
credit_card = fetch_openml(data_id=1597, as_frame=True, parser="pandas") credit_card.frame.info()
<class 'pandas.DataFrame'> RangeIndex: 284807 entries, 0 to 284806 Data columns (total 30 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 V1 284807 non-null float64 1 V2 284807 non-null float64 2 V3 284807 non-null float64 3 V4 284807 non-null float64 4 V5 284807 non-null float64 5 V6 284807 non-null float64 6 V7 284807 non-null float64 7 V8 284807 non-null float64 8 V9 284807 non-null float64 9 V10 284807 non-null float64 10 V11 284807 non-null float64 11 V12 284807 non-null float64 12 V13 284807 non-null float64 13 V14 284807 non-null float64 14 V15 284807 non-null float64 15 V16 284807 non-null float64 16 V17 284807 non-null float64 17 V18 284807 non-null float64 18 V19 284807 non-null float64 19 V20 284807 non-null float64 20 V21 284807 non-null float64 21 V22 284807 non-null float64 22 V23 284807 non-null float64 23 V24 284807 non-null float64 24 V25 284807 non-null float64 25 V26 284807 non-null float64 26 V27 284807 non-null float64 27 V28 284807 non-null float64 28 Amount 284807 non-null float64 29 Class 284807 non-null category dtypes: category(1), float64(29) memory usage: 63.3 MB
Набор данных содержит сведения о записях транзакций по кредитным картам, часть которых является мошеннической, а часть — законной. Следовательно, цель состоит в том, чтобы предсказать, является ли запись о транзакции по кредитной карте мошеннической.
columns_to_drop = ["Class"] data = credit_card.frame.drop(columns=columns_to_drop) target = credit_card.frame["Class"].astype(int)
Сначала проверим распределение классов в наборах данных.
target.value_counts(normalize=True)
Class 0 0.998273 1 0.001727 Name: proportion, dtype: float64
Набор данных сильно несбалансирован: мошеннические транзакции составляют лишь 0.17% данных. Поскольку мы хотим обучить модель машинного обучения, необходимо также убедиться, что в миноритарном классе достаточно примеров для обучения модели.
target.value_counts()
Class 0 284315 1 492 Name: count, dtype: int64
Мы видим, что у нас около 500 примеров, что является нижней границей необходимого для обучения модели машинного обучения количества. Помимо распределения целевой переменной, проверим распределение сумм мошеннических транзакций.
fraud = target == 1
amount_fraud = data["Amount"][fraud]
_, ax = plt.subplots()
ax.hist(amount_fraud, bins=30)
ax.set_title("Amount of fraud transaction")
_ = ax.set_xlabel("Amount (€)")

Решение задачи с помощью бизнес-метрики
Теперь создадим бизнес-метрику, зависящую от суммы каждой транзакции. Мы определяем матрицу затрат аналогично [2]. Принятие законной транзакции приносит прибыль в размере 2% от ее суммы. Однако принятие мошеннической транзакции приводит к убытку, равному сумме транзакции. Как указано в [2], выгоду и убыток, связанные с отказом (от мошеннических и законных транзакций), непросто определить. Здесь мы считаем, что отказ от законной транзакции приводит к убытку в размере 5€, а отказ от мошеннической транзакции приносит выгоду в размере 50€. Таким образом, определим следующую функцию для вычисления общей выгоды от принятого решения:
def business_metric(y_true, y_pred, amount):
mask_true_positive = (y_true == 1) & (y_pred == 1)
mask_true_negative = (y_true == 0) & (y_pred == 0)
mask_false_positive = (y_true == 0) & (y_pred == 1)
mask_false_negative = (y_true == 1) & (y_pred == 0)
fraudulent_refuse = mask_true_positive.sum() * 50
fraudulent_accept = -amount[mask_false_negative].sum()
legitimate_refuse = mask_false_positive.sum() * -5
legitimate_accept = (amount[mask_true_negative] * 0.02).sum()
return fraudulent_refuse + fraudulent_accept + legitimate_refuse + legitimate_accept
На основе этой бизнес-метрики мы создаем оценщик scikit-learn, который вычисляет бизнес-метрику для обученного классификатора и тестового набора. Для этого мы используем фабрику make_scorer. Переменная amount — это дополнительные метаданные, которые передаются оценщику; чтобы учесть эту информацию, необходимо использовать маршрутизацию метаданных.
sklearn.set_config(enable_metadata_routing=True) business_scorer = make_scorer(business_metric).set_score_request(amount=True)
Итак, на этом этапе видно, что сумма транзакции используется дважды: сначала как признак для обучения прогнозной модели, а затем как метаданные для вычисления бизнес-метрики и, следовательно, статистической эффективности нашей модели. При использовании суммы в качестве признака достаточно иметь в data столбец, содержащий сумму каждой транзакции. Чтобы использовать эту информацию как метаданные, нужна внешняя переменная, которую можно передать оценщику или модели, маршрутизирующей эти метаданные оценщику. Создадим такую переменную.
amount = credit_card.frame["Amount"].to_numpy()
from sklearn.model_selection import train_test_split
data_train, data_test, target_train, target_test, amount_train, amount_test = (
train_test_split(
data, target, amount, stratify=target, test_size=0.5, random_state=42
)
)
Сначала оценим несколько базовых стратегий, которые будут служить ориентиром. Напомним, что класс «0» соответствует законным транзакциям, а класс «1» — мошенническим.
from sklearn.dummy import DummyClassifier
always_accept_policy = DummyClassifier(strategy="constant", constant=0)
always_accept_policy.fit(data_train, target_train)
benefit = business_scorer(
always_accept_policy, data_test, target_test, amount=amount_test
)
print(f"Benefit of the 'always accept' policy: {benefit:,.2f}€")
Benefit of the 'always accept' policy: 221,445.07€
Стратегия, считающая все транзакции законными, принесла бы прибыль около 220,000€. Аналогично оценим классификатор, который предсказывает, что все транзакции являются мошенническими.
always_reject_policy = DummyClassifier(strategy="constant", constant=1)
always_reject_policy.fit(data_train, target_train)
benefit = business_scorer(
always_reject_policy, data_test, target_test, amount=amount_test
)
print(f"Benefit of the 'always reject' policy: {benefit:,.2f}€")
Benefit of the 'always reject' policy: -698,490.00€
Такая стратегия повлекла бы катастрофические убытки — около 670,000€. Это ожидаемо, поскольку подавляющее большинство транзакций являются законными, а стратегия отклоняла бы их, неся существенные затраты.
Прогнозная модель, которая адаптирует решения о принятии или отклонении для каждой транзакции, в идеале должна позволить получить прибыль выше 220,000€ — результата лучшей из наших постоянных базовых стратегий.
Начнем с логистической регрессии, использующей порог принятия решения по умолчанию, равный 0.5. Здесь мы настраиваем гиперпараметр C логистической регрессии с помощью корректного правила оценки (логарифмической функции потерь), чтобы вероятностные предсказания модели, возвращаемые методом predict_proba, были максимально точными независимо от выбранного значения порога принятия решения.
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
logistic_regression = make_pipeline(StandardScaler(), LogisticRegression())
param_grid = {"logisticregression__C": np.logspace(-6, 6, 13)}
model = GridSearchCV(logistic_regression, param_grid, scoring="neg_log_loss").fit(
data_train, target_train
)
model
print(
"Benefit of logistic regression with default threshold: "
f"{business_scorer(model, data_test, target_test, amount=amount_test):,.2f}€"
)
Benefit of logistic regression with default threshold: 244,919.87€
Бизнес-метрика показывает, что наша прогнозная модель с порогом принятия решения по умолчанию уже приносит больше прибыли, чем базовый вариант, и её уже выгодно использовать для принятия или отклонения транзакций вместо того, чтобы принимать все транзакции.
Настройка порога принятия решения
Итак, вопрос заключается в следующем: является ли наша модель оптимальной для типа решения, которое мы хотим принять? До этого момента мы не оптимизировали порог принятия решения. Мы используем TunedThresholdClassifierCV, чтобы оптимизировать решение с учетом нашей бизнес-оценочной функции. Чтобы избежать вложенной перекрестной проверки, мы воспользуемся лучшей моделью, найденной при предыдущем поиске по сетке.
tuned_model = TunedThresholdClassifierCV(
estimator=model.best_estimator_,
scoring=business_scorer,
thresholds=100,
n_jobs=2,
)
Поскольку для нашей бизнес-оценочной функции требуется сумма каждой транзакции, эту информацию нужно передать в метод fit. TunedThresholdClassifierCV автоматически передает эти метаданные используемой оценочной функции.
tuned_model.fit(data_train, target_train, amount=amount_train)
Мы видим, что настроенный порог принятия решения значительно отличается от значения по умолчанию 0.5:
print(f"Tuned decision threshold: {tuned_model.best_threshold_:.2f}")
Tuned decision threshold: 0.03
print(
"Benefit of logistic regression with a tuned threshold: "
f"{business_scorer(tuned_model, data_test, target_test, amount=amount_test):,.2f}€"
)
Benefit of logistic regression with a tuned threshold: 249,433.39€
Мы видим, что настройка порога принятия решения увеличивает ожидаемую прибыль при использовании нашей модели, о чём свидетельствует бизнес-метрика. Поэтому, когда это возможно, полезно оптимизировать порог принятия решения с учётом бизнес-метрики.
Ручная настройка порога принятия решений вместо его подбора
В предыдущем примере мы использовали TunedThresholdClassifierCV, чтобы найти оптимальный порог принятия решений. Однако в некоторых случаях мы можем располагать предварительными знаниями о рассматриваемой задаче и предпочесть задать порог принятия решений вручную.
Класс FixedThresholdClassifier позволяет вручную задать порог принятия решений. Во время предсказания он работает так же, как предыдущая модель с подобранным порогом, но в процессе обучения поиск не выполняется. Обратите внимание, что здесь мы используем FrozenEstimator, чтобы обернуть модель предсказания и избежать повторного обучения.
Здесь мы повторно используем порог принятия решений, найденный в предыдущем разделе, чтобы создать новую модель и проверить, что она дает те же результаты.
from sklearn.frozen import FrozenEstimator
from sklearn.model_selection import FixedThresholdClassifier
model_fixed_threshold = FixedThresholdClassifier(
estimator=FrozenEstimator(model), threshold=tuned_model.best_threshold_
)
business_score = business_scorer(
model_fixed_threshold, data_test, target_test, amount=amount_test
)
print(f"Benefit of logistic regression with a tuned threshold: {business_score:,.2f}€")
Benefit of logistic regression with a tuned threshold: 249,433.39€
Мы видим, что получили точно такие же результаты, однако процесс обучения прошел гораздо быстрее, поскольку мы не выполняли поиск гиперпараметров.
Наконец, оценка самого (среднего) бизнес-показателя может быть ненадежной, особенно если число точек данных в классе меньшинства очень мало. Любое влияние на бизнес, оцененное с помощью кросс-валидации бизнес-показателя на исторических данных (офлайн-оценка), в идеале следует подтвердить A/B-тестированием на актуальных данных (онлайн-оценка). Однако обратите внимание, что тестирование моделей методом A/B выходит за рамки самой библиотеки scikit-learn.
В завершение отключим флаг конфигурации маршрутизации метаданных:
.. GENERATED FROM PYTHON SOURCE LINES 693-694
sklearn.set_config(enable_metadata_routing=False)
Общее время выполнения скрипта: (0 минут 28.847 секунд)
Связанные примеры
© 2007–2026 The scikit-learn developers
Licensed under the 3-clause BSD License.
https://scikit-learn.org/1.9/auto_examples/model_selection/plot_cost_sensitive_learning.html