Перейти к содержанию

Кастомные функции потерь

Декораторы для своих функций потерь и метрик. Пользователь пишет обычный numpy-код; всё, что нужно CatBoost (форматы данных, знаки производных, веса, преобразования через sigmoid или softmax), берёт на себя обёртка. Функции компилируются через numba.

import numpy as np
from catboost_utils.objectives import objective, metric

@objective(task="regression")
def my_huber(y_true: np.ndarray, y_pred: np.ndarray):
    delta = 1.0
    err = y_pred - y_true
    is_small = np.abs(err) <= delta
    grad = np.where(is_small, err, delta * np.sign(err))
    hess = np.where(is_small, 1.0, 0.0)
    return grad, hess

@metric(task="regression", name="MAE", higher_is_better=False)
def mae(y_true, y_pred) -> float:
    return float(np.mean(np.abs(y_true - y_pred)))

Сигнатуры функций

Сигнатура зависит от задачи. CatBoost для разных задач даёт данные в разном формате — обёртка скрывает разницу, но сигнатура функции должна ей соответствовать.

task Что вызывает CatBoost Пространство y_pred Сигнатура
regression пакетно, calc_ders_range как есть (ndarray[n], ndarray[n]) -> (ndarray[n], ndarray[n])
binary пакетно, calc_ders_range sigmoid то же
multiclass по одному объекту, calc_ders_multi softmax (int, ndarray[K]) -> (ndarray[K], ndarray[K])

Настраиваемые параметры

Параметры после y_true / y_pred (с аннотацией типа и значением по умолчанию) становятся настраиваемыми через .with_params(...):

@objective(task="regression")
def huber(y_true: np.ndarray, y_pred: np.ndarray, delta: float = 1.0):
    err = y_pred - y_true
    is_small = np.abs(err) <= delta
    grad = np.where(is_small, err, delta * np.sign(err))
    hess = np.where(is_small, 1.0, 0.0)
    return grad, hess

# Со значениями по умолчанию
model = CatBoostRegressor(loss_function=huber, eval_metric="RMSE")

# С другими значениями — возвращает новый адаптер, исходный не меняется
strict = huber.with_params(delta=0.5)
loose  = huber.with_params(delta=2.0)
huber.get_params()    # {"delta": 1.0}
strict.get_params()   # {"delta": 0.5}

with_params(...) проверяет каждое значение по аннотации: неизвестное имя → TypeError, неподходящий тип → TypeError. Numba не пересобирает функцию при смене значений.

Допустимые аннотации: int, float, bool. У каждого дополнительного параметра должно быть значение по умолчанию. Всё остальное (без аннотации, Optional, np.ndarray, свои типы) отвергается на этапе декорации.

Если нужен не скаляр — замыкание

Для массивов, словарей и других не-скалярных значений используйте замыкание:

import numpy as np
from catboost_utils.objectives import objective

def make_weighted_ce(class_weights: np.ndarray):
    """Multiclass cross-entropy с весами по классам."""

    @objective(task="multiclass")
    def loss(y_true: int, y_pred: np.ndarray):
        # class_weights захватывается из внешней области; numba его подставит при компиляции.
        w = class_weights[y_true]
        grad = w * (y_pred - _onehot(y_true, y_pred.shape[0]))
        hess = w * np.ones_like(y_pred)
        return grad, hess

    return loss


def _onehot(idx: int, n: int) -> np.ndarray:
    out = np.zeros(n)
    out[idx] = 1.0
    return out


# Готовый loss с конкретными весами
loss = make_weighted_ce(np.array([1.0, 2.0, 0.5]))
model = CatBoostClassifier(loss_function=loss, eval_metric="MultiClass")

Numba скомпилирует loss при первом использовании, а массив будет вшит в скомпилированный код. Если потом построить ещё один loss с другими весами — это отдельная компиляция. Подходит для разовой настройки; если веса хочется менять часто, лучше переписать через скалярные параметры.

catboost_utils.objectives.decorator.objective

objective(
    *, task: TaskType
) -> Callable[[Callable[..., Any]], Any]

Wrap a user function as a CatBoost custom objective.

catboost_utils.objectives.decorator.metric

metric(
    *, task: TaskType, name: str, higher_is_better: bool
) -> Callable[[Callable[..., float]], Any]

Wrap a user function as a CatBoost custom evaluation metric.