Skip to content

Objectives

Decorators for custom loss functions and evaluation metrics. Functions are JIT-compiled with numba; the decorator handles all CatBoost internals (list-of-list approxes, sign convention, weights, sigmoid/softmax transform).

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)))

Signature contracts per task

task API y_pred space Signature
regression batched calc_ders_range identity (ndarray[n], ndarray[n]) -> (ndarray[n], ndarray[n])
binary batched calc_ders_range sigmoid same as regression
multiclass per-object calc_ders_multi softmax (int, ndarray[K]) -> (ndarray[K], ndarray[K])

Tunable parameters

Parameters beyond y_true / y_pred (with type annotations and defaults) become tunable via .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

# Use defaults
model = CatBoostRegressor(loss_function=huber, eval_metric="RMSE")

# Configure — returns a new adapter, original is unchanged
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(...) validates each value against its annotation: unknown name → TypeError, wrong type → TypeError. Numba does not recompile when values change.

Allowed annotations: int, float, bool. Every extra parameter must have a default. Anything else (untyped, Optional, np.ndarray, custom types) is rejected at decoration time.

Closure pattern for non-scalar parameters

For arrays, dicts, or any other non-scalar value, capture it via a closure:

import numpy as np
from catboost_utils.objectives import objective

def make_weighted_ce(class_weights: np.ndarray):
    """Return a multiclass cross-entropy with per-class weights."""

    @objective(task="multiclass")
    def loss(y_true: int, y_pred: np.ndarray):
        # `class_weights` is captured from the outer scope; numba inlines it.
        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


# Build a configured loss
loss = make_weighted_ce(np.array([1.0, 2.0, 0.5]))
model = CatBoostClassifier(loss_function=loss, eval_metric="MultiClass")

Numba compiles loss once when it's first used; the array is baked into the compiled code. Building a second loss with different weights produces a separate compiled version — fine for one-time configuration, slow if you change weights every iteration.

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.