自定义损失¶
在梯度提升树中,损失是一个函数,它接受标签值和预测,并返回该预测的“错误量”。模型的训练目标是最小化所有训练示例的平均损失。YDF 实现了各种常见的损失函数。您可以通过“loss”参数来配置它们。可用损失的列表可以在 这里 查看。如果您不指定损失,系统会根据模型任务自动选择。例如,如果任务是回归,损失默认设置为均方误差。
如果 YDF 不支持您需要的损失,您可以手动定义它。这被称为“自定义损失”。
在本入门教程中,我们将创建一个名为 均方对数误差 的自定义 回归损失。
什么是自定义损失?¶
在 YDF 中,自定义损失由四部分组成:
- 初始预测:模型的初始预测,例如标签的平均值。
- 梯度和海森矩阵:一个函数,它根据标签和模型在激活函数之前的预测计算损失的梯度和海森矩阵的对角线。
- 损失:一个衡量当前解质量的函数。虽然理论上梯度和海森矩阵实际上是损失函数的梯度和海森矩阵,但在实践中,近似效果非常好。
- 激活:一个应用于预测的函数,用于将其转换为正确的空间(例如,分类问题的概率)。
使用自定义损失训练梯度提升树¶
我们首先设置一个回归数据集。
# 加载库
import ydf # Yggdrasil决策森林
import pandas as pd # 我们使用Pandas加载小型数据集。
import numpy as np # 我们使用 numpy 进行数值运算。
import numpy.typing as npty
from typing import Tuple
# 下载一个回归数据集,并将其加载为Pandas DataFrame。
ds_path = "https://raw.githubusercontent.com/google/yggdrasil-decision-forests/main/yggdrasil_decision_forests/test_data/dataset"
all_ds = pd.read_csv(f"{ds_path}/abalone.csv")
# 将数据集随机划分为训练集(70%)和测试集(30%)
all_ds = all_ds.sample(frac=1)
split_idx = len(all_ds) * 7 // 10
train_ds = all_ds.iloc[:split_idx]
test_ds = all_ds.iloc[split_idx:]
# 打印前5个训练样本
train_ds.head(5)
均方对数误差¶
我们在本教程中使用均方对数误差(MSLE)损失。MSLE的计算公式为
MSLE = $\frac{1}{n} \sum_{i=1}^n (\log(p_i + 1) - \log(a_i+1))^2$,
其中 $n$ 是总的观测数,$p_i$ 和 $a_i$ 分别是第 $i$ 个例子的预测值和标签,$\log$ 表示自然对数。
MSLE损失相对于预测值 $p_i$ 的梯度为
$\frac{1}{n} \cdot \frac{2(\log(p_i + 1) - \log(a_i+1))}{p_i + 1}$
MSLE损失的海森矩阵是一个矩阵。出于简化和性能的考虑,YDF仅使用海森矩阵的对角线。对角线的第 $i$ 个元素为
$\frac{1}{n} \cdot \frac{2(1 - \log(p_i + 1) + \log(a_i+1))}{(p_i + 1)^2}$
# 如果预测值接近-1,数值不稳定性将会扭曲
# 结果。因此,预测值略微高于-1。
PREDICTION_MINIMUM = -1 + 1e-6
def loss_msle(
labels: npty.NDArray[np.float32],
predictions: npty.NDArray[np.float32],
weights: npty.NDArray[np.float32],
) -> np.float32:
clipped_pred = np.maximum(PREDICTION_MINIMUM, predictions)
return np.sum((np.log1p(clipped_pred) - np.log1p(labels))**2) / len(labels)
def initial_predictions_msle(
labels: npty.NDArray[np.float32], _: npty.NDArray[np.float32]
) -> npty.NDArray[np.float32]:
return np.exp(np.mean(np.log1p(labels))) - 1
def grad_msle(
labels: npty.NDArray[np.float32], predictions: npty.NDArray[np.float32]
) -> npty.NDArray[np.float32]:
gradient = (2/ len(labels))*(np.log1p(predictions) - np.log1p(labels)) / (predictions + 1)
return gradient
def hessian_msle(
labels: npty.NDArray[np.float32], predictions: npty.NDArray[np.float32]
) -> npty.NDArray[np.float32]:
hessian = (2/ len(labels))*(1 - np.log1p(predictions) + np.log1p(labels)) / (predictions + 1)**2
return hessian
def gradient_and_hessian_msle(
labels: npty.NDArray[np.float32], predictions: npty.NDArray[np.float32]
) -> Tuple[npty.NDArray[np.float32], npty.NDArray[np.float32]]:
clipped_pred = np.maximum(PREDICTION_MINIMUM, predictions)
return [grad_msle(labels, clipped_pred), hessian_msle(labels, clipped_pred)]
# 构建损失对象。
msle_custom_loss = ydf.RegressionLoss(
initial_predictions=initial_predictions_msle,
gradient_and_hessian=gradient_and_hessian_msle,
loss=loss_msle,
activation=ydf.Activation.IDENTITY,
)
模型的训练与往常一样,损失对象作为超参数。
model = ydf.GradientBoostedTreesLearner(label="Rings", task=ydf.Task.REGRESSION, loss=msle_custom_loss).train(train_ds)
模型描述展示了训练损失和验证损失的演变。
model.describe()
我们可以将这个模型与使用均方根误差(RMSE)损失训练的模型进行比较。
model.evaluate(test_ds)
# 使用默认回归损失(即均方根误差损失)训练的模型
model_rmse_loss = ydf.GradientBoostedTreesLearner(label="Rings", task=ydf.Task.REGRESSION).train(train_ds)
model_rmse_loss.evaluate(test_ds)
def binomial_initial_predictions(
labels: npty.NDArray[np.int32], weights: npty.NDArray[np.float32]
) -> np.float32:
sum_weights = np.sum(weights)
sum_weights_positive = np.sum((labels == 2) * weights)
ratio_positive = sum_weights_positive / sum_weights
if ratio_positive == 0.0:
return -np.iinfo(np.float32).max
elif ratio_positive == 1.0:
return np.iinfo(np.float32).max
return np.log(ratio_positive / (1 - ratio_positive))
def binomial_gradient_and_hessian(
labels: npty.NDArray[np.int32], predictions: npty.NDArray[np.float32]
) -> Tuple[npty.NDArray[np.float32], npty.NDArray[np.float32]]:
pred_probability = 1.0 / (1.0 + np.exp(-predictions))
binary_labels = labels == 2
return (
pred_probability - binary_labels,
pred_probability * (pred_probability - 1),
)
def binomial_loss(
labels: npty.NDArray[np.int32],
predictions: npty.NDArray[np.float32],
weights: npty.NDArray[np.float32],
) -> np.float32:
binary_labels = labels == 2
return (-2.0 * np.sum(
binary_labels * predictions- np.log(1.0 + np.exp(predictions))
) / len(labels)
)
binomial_custom_loss = ydf.BinaryClassificationLoss(
initial_predictions=binomial_initial_predictions,
gradient_and_hessian=binomial_gradient_and_hessian,
loss=binomial_loss,
activation=ydf.Activation.SIGMOID,
)
多类分类¶
对于多类分类问题,标签是从1开始的整数。损失函数必须为每个标签类提供梯度和海森矩阵。梯度和海森矩阵必须返回d-by-n的矩阵,其中n是样本数量,d是标签类的数量。同样,模型必须为每个标签类提供一个初始预测,作为一个d元素的向量。
YDF支持Softmax激活函数,用于不在概率空间中操作的损失。
为了演示目的,以下代码重新实现了多项式对数似然损失作为自定义损失。请注意,这个损失也可以通过loss=MULTINOMIAL_LOG_LIKELIHOOD
超参数直接获取。
def multinomial_initial_predictions(
labels: npty.NDArray[np.int32], _: npty.NDArray[np.float32]
) -> npty.NDArray[np.float32]:
dimension = np.max(labels)
return np.zeros(dimension, dtype=np.float32)
def multinomial_gradient(
labels: npty.NDArray[np.int32], predictions: npty.NDArray[np.float32]
) -> Tuple[npty.NDArray[np.float32], npty.NDArray[np.float32]]:
dimension = np.max(labels)
normalization = 1.0 / np.sum(np.exp(predictions), axis=1)
normalized_predictions = np.exp(predictions) * normalization[:, None]
label_indicator = (
(labels - 1)[:, np.newaxis] == np.arange(dimension)
).astype(int)
gradient = normalized_predictions - label_indicator
hessian = np.abs(gradient) * (np.abs(gradient) - 1)
return (np.transpose(gradient), np.transpose(hessian))
def multinomial_loss(
labels: npty.NDArray[np.int32],
predictions: npty.NDArray[np.float32],
weights: npty.NDArray[np.float32],
) -> np.float32:
dimension = np.max(labels)
sum_exp_pred = np.sum(np.exp(predictions), axis=1)
indicator_matrix = (
(labels - 1)[:, np.newaxis] == np.arange(dimension)
).astype(int)
label_exp_pred = np.exp(np.sum(predictions * indicator_matrix, axis=1))
return (
-np.sum(np.log(label_exp_pred / sum_exp_pred)) / len(labels)
)
multinomial_custom_loss = ydf.MultiClassificationLoss(
initial_predictions=multinomial_initial_predictions,
gradient_and_hessian=multinomial_gradient,
loss=multinomial_loss,
activation=ydf.Activation.SOFTMAX,
)
import jax
import jax.numpy as jnp
@jax.jit
def huber_loss(labels, pred, delta=1.0):
abs_diff = jnp.abs(labels - pred)
return jnp.average(jnp.where(abs_diff > delta,delta * (abs_diff - .5 * delta), 0.5 * abs_diff ** 2))
huber_grad = jax.jit(jax.grad(huber_loss, argnums=1))
huber_hessian = jax.jit(jax.jacfwd(jax.jacrev(huber_loss, argnums=1)))
huber_init = jax.jit(lambda labels, weights: jnp.average(labels))
huber = ydf.RegressionLoss(
initial_predictions=jax.block_until_ready(huber_init),
gradient_and_hessian=lambda label, pred: (
huber_grad(label, pred).block_until_ready(),
jnp.diagonal(huber_hessian(label, pred)).block_until_ready()
),
loss=lambda label, pred, weight: huber_loss(label, pred).block_until_ready(),
activation=ydf.Activation.IDENTITY,
)
model = ydf.GradientBoostedTreesLearner(label="Rings", task=ydf.Task.REGRESSION, loss=huber).train(train_ds)
额外细节和提示¶
- 为了简化说明,上面的示例假设权重为单位权重。
- 损失函数不应引用标签、预测和权重数组。这些数组由C++内存支持,可能随时在C++端被删除。
- 使用自定义损失时,YDF可能会触发垃圾回收(GC)以捕获非法内存访问。将损失对象上的
may_trigger_gc=False
设置为避免这种情况,但请注意,这样YDF可能不会警告非法内存访问。 - 自定义损失函数返回的数组可能会被YDF修改。
- 使用自定义损失进行训练通常比使用内置损失慢约10%。
- 自定义损失在模型检查和分析方面并不完全支持 - 在YDF中,目前尚无法在测试集上计算模型的自定义损失。