macro_rules! stored_names {
($($ty:ident { $($variant:ident => $name:literal),+ $(,)? })+) => {$(
impl $ty {
/// The variant's stored name.
pub(crate) fn name(self) -> &'static str {
match self {
$($ty::$variant => $name,)+
}
}
pub(crate) fn from_name(name: &str) -> Option<Self> {
match name {
$($name => Some($ty::$variant),)+
_ => None,
}
}
}
)+};
}
mod absolute;
mod classification;
mod count;
mod custom;
pub mod distributional;
mod multi_target;
mod multiclass;
mod params;
mod quantile;
mod query;
mod ranking;
mod regression;
mod spec;
mod survival;
mod xendcg;
pub(crate) use absolute::AbsoluteError;
pub(crate) use classification::{Hinge, LogisticLoss};
pub(crate) use count::{Gamma, Poisson, TweedieLoss};
pub use custom::CustomLoss;
pub(crate) use multiclass::Softmax;
pub use params::{
Aft, AftDistribution, Expectiles, LambdaRank, Multiclass, PseudoHuber, Quantiles, RegLoss,
Tweedie,
};
pub(crate) use quantile::{Expectile, Quantile};
pub(crate) use ranking::LambdaMart;
pub(crate) use regression::{PseudoHuberLoss, SquaredError, SquaredLogError};
pub use spec::Objective;
pub(crate) use spec::{LossContext, OBJECTIVE_PARAMS, ObjectiveParts};
pub(crate) use xendcg::Xendcg;
pub(crate) use survival::{AftLoss, Cox};
pub(crate) use survival::{abs_label_order, aft_nloglik};
use rayon::prelude::*;
use crate::data::MetaInfo;
use crate::error::{HessboostError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Default)]
#[repr(C)]
pub struct GradPair {
pub grad: f32,
pub hess: f32,
}
impl GradPair {
#[inline]
pub fn new(grad: f32, hess: f32) -> Self {
GradPair { grad, hess }
}
}
pub type PointwiseLoss<'a> = Box<dyn Fn(f32, f32) -> f64 + Send + Sync + 'a>;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct SplitGradient {
pub gpair: Vec<GradPair>,
pub n_targets: usize,
}
impl SplitGradient {
pub fn new(gpair: Vec<GradPair>, n_targets: usize) -> Self {
SplitGradient { gpair, n_targets }
}
}
pub(crate) const GRADIENT_BLOCK_ROWS: usize = 16;
const GRADIENT_CHUNK_ROWS: usize = 8192;
const _: () = assert!(GRADIENT_CHUNK_ROWS.is_multiple_of(GRADIENT_BLOCK_ROWS));
pub(crate) const MIN_HESS: f32 = 1e-16;
pub(crate) const MIN_HESS_F64: f64 = 1e-16;
pub(crate) fn rowwise_gradient<K>(
n_rows: usize,
n_outputs: usize,
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
out: &mut [GradPair],
kernel: K,
) where
K: Fn(&[f32], &[f32], Option<&[f32]>, &mut [GradPair]) + Sync,
{
check_gradient_inputs(n_rows, n_outputs, preds, labels, weights, out);
let shape = RowShape {
n_rows,
n_outputs,
label_cols: 1,
};
rowwise_cells(shape, preds, labels, weights, out, kernel);
}
#[derive(Clone, Copy)]
pub(crate) struct RowShape {
pub(crate) n_rows: usize,
pub(crate) n_outputs: usize,
pub(crate) label_cols: usize,
}
pub(crate) fn rowwise_cells<K>(
shape: RowShape,
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
out: &mut [GradPair],
kernel: K,
) where
K: Fn(&[f32], &[f32], Option<&[f32]>, &mut [GradPair]) + Sync,
{
let RowShape {
n_rows,
n_outputs,
label_cols,
} = shape;
let complete = n_rows
.checked_mul(n_outputs)
.is_some_and(|values| preds.len() == values && out.len() == values)
&& n_rows
.checked_mul(label_cols)
.is_some_and(|values| labels.len() == values)
&& weights.is_none_or(|w| w.len() == n_rows);
if !complete
|| n_rows == 0
|| n_outputs == 0
|| n_rows < 2 * GRADIENT_CHUNK_ROWS
|| rayon::current_num_threads() <= 1
{
kernel(preds, labels, weights, out);
return;
}
let run_chunk = |first: usize, out: &mut [GradPair]| {
let rows = out.len() / n_outputs;
kernel(
&preds[first * n_outputs..(first + rows) * n_outputs],
&labels[first * label_cols..(first + rows) * label_cols],
weights.map(|w| &w[first..first + rows]),
out,
);
};
let chunk_values = GRADIENT_CHUNK_ROWS * n_outputs;
if out.len().is_multiple_of(chunk_values) {
out.par_chunks_mut(chunk_values)
.enumerate()
.for_each(|(index, out)| run_chunk(index * GRADIENT_CHUNK_ROWS, out));
return;
}
let head_rows = (n_rows / GRADIENT_CHUNK_ROWS - 1) * GRADIENT_CHUNK_ROWS;
let (head, tail) = out.split_at_mut(head_rows * n_outputs);
rayon::join(
|| {
head.par_chunks_mut(chunk_values)
.enumerate()
.for_each(|(index, out)| run_chunk(index * GRADIENT_CHUNK_ROWS, out));
},
|| run_chunk(head_rows, tail),
);
}
pub(crate) fn elementwise_gradient(
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
out: &mut [GradPair],
pair: impl Fn(f32, f32, f32) -> GradPair + Sync,
) {
rowwise_gradient(
labels.len(),
1,
preds,
labels,
weights,
out,
|preds, labels, weights, out| {
for i in 0..preds.len() {
let w = weights.map_or(1.0, |ws| ws[i]);
out[i] = pair(preds[i], labels[i], w);
}
},
);
}
#[cfg(test)]
fn gradient_pairs(
objective: &dyn Loss,
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
) -> Vec<GradPair> {
let mut out = vec![GradPair::default(); preds.len()];
objective.gradient(preds, labels, weights, &mut out);
out
}
#[cfg(test)]
fn base_margins(objective: &dyn Loss, labels: &[f32], weights: Option<&[f32]>) -> Vec<f32> {
objective.base_margins_info(&MetaInfo::new(labels, weights, None))
}
pub(crate) fn check_gradient_inputs(
n_rows: usize,
n_outputs: usize,
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
out: &[GradPair],
) {
debug_assert_eq!(preds.len(), n_rows * n_outputs);
debug_assert_eq!(out.len(), n_rows * n_outputs);
debug_assert_eq!(labels.len(), n_rows);
if let Some(w) = weights {
debug_assert_eq!(w.len(), n_rows);
}
}
pub trait Loss: Send + Sync {
fn name(&self) -> &str;
fn n_outputs(&self) -> usize {
1
}
fn gradient(
&self,
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
out: &mut [GradPair],
);
fn gradient_grouped(
&self,
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
_group: Option<&crate::data::GroupInfo>,
out: &mut [GradPair],
) {
self.gradient(preds, labels, weights, out);
}
fn gradient_info(&self, preds: &[f32], info: &MetaInfo, out: &mut [GradPair]) {
self.gradient_grouped(preds, info.label_values(), info.weights, info.group, out);
}
fn gradient_info_at(
&self,
preds: &[f32],
info: &MetaInfo,
out: &mut [GradPair],
_iteration: usize,
) {
self.gradient_info(preds, info, out);
}
fn const_hess(&self) -> bool {
false
}
fn pred_transform(&self, _preds: &mut [f32]) {}
fn base_margins_info(&self, info: &MetaInfo) -> Vec<f32> {
newton_intercepts(self, info)
}
fn eval_transform(&self, preds: &mut [f32]) {
self.pred_transform(preds);
}
fn probs_to_margins(&self, _scores: &mut [f32]) {}
fn validate_base_score(&self, _base_score: f64) -> Result<()> {
Ok(())
}
fn margins_to_probs(&self, margins: &mut [f32]) {
self.pred_transform(margins);
}
fn validate_info(&self, _info: &MetaInfo) -> Result<()> {
Ok(())
}
fn requires_labels(&self) -> bool {
true
}
fn split_gradient(&self, _iteration: usize, _gpair: &[GradPair]) -> Option<SplitGradient> {
None
}
fn pointwise_loss(&self) -> Option<PointwiseLoss<'_>> {
None
}
fn default_metric(&self) -> crate::metric::EvalMetric;
}
pub(crate) fn newton_intercepts<O: Loss + ?Sized>(objective: &O, info: &MetaInfo) -> Vec<f32> {
let k = objective.n_outputs();
let Some(len) = info
.n_rows
.checked_mul(k)
.filter(|_| info.check_layout().is_ok())
else {
return vec![f32::NAN; k];
};
let zeros = vec![0.0f32; len];
let mut gpair = vec![GradPair::default(); len];
objective.gradient_info(&zeros, info, &mut gpair);
let mut out = fit_stump(&gpair, k);
objective.pred_transform(&mut out);
objective.probs_to_margins(&mut out);
out
}
pub(crate) fn fit_stump(gpair: &[GradPair], k: usize) -> Vec<f32> {
let mut sum_grad = vec![0.0f64; k];
let mut sum_hess = vec![0.0f64; k];
for row in gpair.chunks_exact(k) {
for (c, gp) in row.iter().enumerate() {
sum_grad[c] += f64::from(gp.grad);
sum_hess[c] += f64::from(gp.hess);
}
}
sum_grad
.iter()
.zip(&sum_hess)
.map(|(g, h)| (-g / h.max(crate::K_RT_EPS)) as f32)
.collect()
}
pub(crate) fn log_link(scores: &mut [f32]) {
for s in scores {
*s = s.ln();
}
}
pub(crate) fn check_base_score_domain(base_score: f64, domain: OutputDomain) -> Result<()> {
let inside = match domain {
OutputDomain::Probability => 0.0 < base_score && base_score < 1.0,
OutputDomain::Positive => base_score > 0.0,
};
if inside {
Ok(())
} else {
Err(HessboostError::invalid_param(
"base_score",
"is outside the objective's valid output domain",
))
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum OutputDomain {
Probability,
Positive,
}
pub(crate) fn check_label_domain(info: &MetaInfo, invalid: impl Fn(f32) -> bool) -> Result<()> {
if info.label_values().iter().any(|&y| invalid(y)) {
return Err(HessboostError::invalid_data(
"labels",
"outside the objective's valid domain",
));
}
Ok(())
}
pub(crate) fn check_label_width(info: &MetaInfo, n_targets: usize) -> Result<()> {
if info.n_targets() != n_targets {
return Err(HessboostError::invalid_data(
"labels",
format!(
"{} label columns but the objective models {n_targets}",
info.n_targets()
),
));
}
Ok(())
}
pub(crate) fn weighted_label_mean(labels: &[f32], weights: Option<&[f32]>) -> f32 {
let mean = match weights {
Some(w) => {
let sum_w: f64 = w.iter().map(|&wi| f64::from(wi)).sum();
if sum_w > 0.0 {
labels
.iter()
.zip(w)
.map(|(&y, &wi)| f64::from(y) / sum_w * f64::from(wi))
.sum()
} else {
0.0
}
}
None if labels.is_empty() => 0.0,
None => {
let n = labels.len() as f64;
labels.iter().map(|&y| f64::from(y) / n).sum()
}
};
mean as f32
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TrainingParams;
use serde_json::json;
#[test]
fn weighted_mean_basic() {
let labels = [1.0f32, 3.0];
assert_eq!(weighted_label_mean(&labels, None), 2.0);
let w = [3.0f32, 1.0];
assert_eq!(weighted_label_mean(&labels, Some(&w)), 1.5);
}
#[test]
fn default_base_margins_is_newton_step_through_link() {
let obj = LogisticLoss::new(2.0);
let labels = [1.0f32, 0.0, 0.0, 0.0];
let margins = base_margins(&obj, &labels, None);
assert_eq!(margins.len(), 1);
let mut through_link = [-0.4f32];
obj.pred_transform(&mut through_link);
obj.probs_to_margins(&mut through_link);
assert_eq!(margins[0], through_link[0]);
assert!((margins[0] + 0.4).abs() < 1e-6, "got {}", margins[0]);
}
#[test]
fn default_intercept_reads_the_original_metadata() {
struct Midpoint;
impl Loss for Midpoint {
fn name(&self) -> &'static str {
"test:midpoint"
}
fn gradient(&self, _: &[f32], _: &[f32], _: Option<&[f32]>, out: &mut [GradPair]) {
out.fill(GradPair::new(f32::NAN, 1.0));
}
fn gradient_info(&self, preds: &[f32], info: &MetaInfo, out: &mut [GradPair]) {
let Some((lo, hi)) = info.bounds.map(|b| (b.lower(), b.upper())) else {
return self.gradient(preds, info.label_values(), info.weights, out);
};
for (i, g) in out.iter_mut().enumerate() {
*g = GradPair::new(preds[i] - f32::midpoint(lo[i], hi[i]), 1.0);
}
}
fn default_metric(&self) -> crate::metric::EvalMetric {
crate::metric::EvalMetric::Rmse
}
}
let (lower, upper) = ([0.0f32, 4.0], [2.0f32, 6.0]);
let info = MetaInfo {
n_rows: 2,
bounds: Some(crate::data::LabelBounds::new(&lower, &upper)),
weights: None,
..MetaInfo::unlabeled(0)
};
assert_eq!(Midpoint.base_margins_info(&info), vec![3.0]);
let inconsistent = MetaInfo {
labels: Some(crate::data::Labels::new(&[], std::num::NonZeroUsize::MAX)),
..info
};
assert!(Midpoint.base_margins_info(&inconsistent)[0].is_nan());
}
#[test]
fn factory_resolves_known_and_rejects_unknown() {
let p = TrainingParams::from_xgboost([("objective", json!("reg:squarederror"))]).unwrap();
assert_eq!(p.loss(1).unwrap().name(), "reg:squarederror");
assert!(TrainingParams::from_xgboost([("objective", json!("nope:whatever"))]).is_err());
}
fn with_objective(objective: Objective) -> TrainingParams {
TrainingParams {
objective,
..TrainingParams::default()
}
}
#[test]
fn factory_accepts_label_matrices_only_for_elementwise_objectives() {
for name in [
"reg:squarederror",
"reg:linear",
"reg:pseudohubererror",
"binary:logistic",
"reg:logistic",
"reg:absoluteerror",
] {
let p = TrainingParams::from_xgboost([("objective", json!(name))]).unwrap();
assert_eq!(p.loss(3).unwrap().n_outputs(), 3, "{name}");
}
for objective in [
Objective::Softprob(Multiclass::new(3).unwrap()),
Objective::Poisson,
Objective::Gamma(RegLoss::default()),
Objective::Tweedie(Tweedie::default()),
Objective::Quantile(Quantiles::new([0.5]).unwrap()),
Objective::Expectile(Expectiles::new([0.5]).unwrap()),
Objective::RankNdcg(LambdaRank::default()),
] {
let name = objective.name().to_owned();
let p = with_objective(objective);
assert!(p.loss(1).is_ok(), "{name}");
match p.loss(2) {
Err(HessboostError::InvalidData { input, .. }) => {
assert_eq!(input, "labels", "{name}");
}
Err(other) => panic!("{name}: unexpected error {other}"),
Ok(_) => panic!("{name}: accepted two targets"),
}
}
}
#[test]
fn factory_sizes_alpha_objectives_and_requires_alphas() {
for (name, param) in [
("reg:quantileerror", "quantile_alpha"),
("reg:expectileerror", "expectile_alpha"),
] {
match TrainingParams::from_xgboost([("objective", json!(name))]) {
Err(HessboostError::InvalidParameter { name: got, .. }) => assert_eq!(got, param),
Err(other) => panic!("{name}: unexpected error {other}"),
Ok(_) => panic!("{name}: accepted an empty alpha list"),
}
let p = TrainingParams::from_xgboost([
("objective", json!(name)),
(param, json!([0.1, 0.5, 0.9])),
])
.unwrap();
assert_eq!(p.loss(1).unwrap().n_outputs(), 3, "{name}");
}
}
#[test]
fn chunked_gradients_match_whole_batch() {
let c = GRADIENT_CHUNK_ROWS;
let objectives: Vec<(Box<dyn Loss>, usize, Vec<usize>)> = vec![
(Box::new(SquaredError::new(1.5)), 1, vec![2 * c + 4097]),
(
Box::new(LogisticLoss::new(1.5)),
1,
(1..=15).map(|r| 2 * c + r).collect(),
),
(
Box::new(Poisson::new(0.7)),
1,
(1..=15).map(|r| 2 * c + r).collect(),
),
(
Box::new(Gamma::new(1.5)),
1,
(1..=15).map(|r| 2 * c + r).collect(),
),
(
Box::new(TweedieLoss::new(Tweedie::new(1.3).unwrap())),
1,
(1..=15).map(|r| 2 * c + r).collect(),
),
(Box::new(Softmax::new(2, true)), 2, vec![2 * c + 4]),
(Box::new(Softmax::new(3, true)), 3, vec![2 * c + 4]),
(Box::new(Softmax::new(9, false)), 9, vec![2 * c + 1]),
(
Box::new(Quantile::new(&[0.1, 0.5, 0.9]).unwrap()),
3,
vec![2 * c + 3],
),
(
Box::new(Expectile::new(&[0.2, 0.8]).unwrap()),
2,
vec![2 * c + 3],
),
(Box::new(AbsoluteError::new(1)), 1, vec![2 * c + 5]),
];
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(4)
.build()
.unwrap();
for (objective, k, ns) in objectives {
for n in ns {
let preds: Vec<f32> = (0..n * k)
.map(|i| ((i * 7919) % 2003) as f32 / 97.0 - 10.0)
.collect();
let labels: Vec<f32> = (0..n).map(|i| (i % k.max(2)) as f32).collect();
let weights: Vec<f32> = (0..n).map(|i| 0.5 + (i % 5) as f32 * 0.25).collect();
for weights in [None, Some(weights.as_slice())] {
let mut whole = vec![GradPair::default(); n * k];
rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.unwrap()
.install(|| objective.gradient(&preds, &labels, weights, &mut whole));
let mut chunked = vec![GradPair::default(); n * k];
pool.install(|| objective.gradient(&preds, &labels, weights, &mut chunked));
for (i, (a, b)) in whole.iter().zip(&chunked).enumerate() {
assert_eq!(
a.grad.to_bits(),
b.grad.to_bits(),
"{} grad {i}",
objective.name()
);
assert_eq!(
a.hess.to_bits(),
b.hess.to_bits(),
"{} hess {i}",
objective.name()
);
}
}
}
}
}
}