use super::absolute::residual_scales;
use super::{Expectiles, GradPair, Loss, Quantiles, fit_stump, weighted_label_mean};
use crate::K_RT_EPS_F32;
use crate::error::Result;
use crate::metric::EvalMetric;
const SMOOTHING_SCALE: f32 = 0.04;
const MIN_SURROGATE_RATIO: f32 = 3.0e-4;
#[derive(Debug, Clone)]
pub(crate) struct Quantile {
levels: Quantiles,
alpha: Vec<f32>,
}
impl Quantile {
pub(crate) fn from_levels(levels: Quantiles) -> Self {
Quantile {
alpha: levels.alpha_f32(),
levels,
}
}
#[cfg(test)]
pub(crate) fn new(alpha: &[f64]) -> Result<Self> {
Ok(Quantile::from_levels(Quantiles::new(
alpha.iter().copied(),
)?))
}
}
#[inline]
fn quantile_pair(r: f32, s: f32, alpha: f32, w: f32) -> GradPair {
if s.is_nan() || s <= 0.0 || w == 0.0 {
return GradPair::new(0.0, 0.0);
}
let x = r / (SMOOTHING_SCALE * s);
let tanh_x = x.tanh();
let ratio = if x == 0.0 { 1.0 } else { tanh_x / x };
let ratio = ratio.max(MIN_SURROGATE_RATIO);
let grad = 0.5 * s * (tanh_x + 1.0 - 2.0 * alpha);
let hess = 0.5 / SMOOTHING_SCALE * ratio;
GradPair::new(w * grad, w * hess)
}
fn stable_order(labels: &[f32]) -> Vec<usize> {
let mut order: Vec<usize> = (0..labels.len()).collect();
order.sort_by(|&l, &r| {
labels[l]
.partial_cmp(&labels[r])
.unwrap_or(std::cmp::Ordering::Equal)
});
order
}
fn interpolated_quantile(alpha: f32, sorted: &[f32]) -> f32 {
let Some((&first, &last)) = sorted.first().zip(sorted.last()) else {
return f32::NAN;
};
let alpha = f64::from(alpha);
let n = sorted.len() as f64;
if alpha <= 1.0 / (n + 1.0) {
return first;
}
if alpha >= n / (n + 1.0) {
return last;
}
let x = alpha * (n + 1.0);
let k = x.floor() - 1.0;
let d = (x - 1.0) - k;
let v0 = sorted[k as usize];
let v1 = sorted[k as usize + 1];
(f64::from(v0) + d * f64::from(v1 - v0)) as f32
}
fn weighted_quantile(alpha: f32, labels: &[f32], weights: &[f32], order: &[usize]) -> f32 {
if order.is_empty() {
return f32::NAN;
}
let mut cdf = Vec::with_capacity(order.len());
let mut acc = 0.0f32;
for (i, &row) in order.iter().enumerate() {
acc = if i == 0 {
weights[row]
} else {
acc + weights[row]
};
cdf.push(acc);
}
let thresh = (f64::from(acc) * f64::from(alpha)) as f32;
let idx = cdf.partition_point(|&c| c < thresh).min(order.len() - 1);
labels[order[idx]]
}
impl Loss for Quantile {
fn name(&self) -> &'static str {
"reg:quantileerror"
}
fn n_outputs(&self) -> usize {
self.alpha.len()
}
fn gradient(
&self,
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
out: &mut [GradPair],
) {
let k = self.alpha.len();
let n = labels.len();
let scales = residual_scales(preds, weights, k, |i, _| labels[i]);
let alpha = &self.alpha;
super::rowwise_gradient(
n,
k,
preds,
labels,
weights,
out,
|preds, labels, weights, out| {
for (i, (row, out_row)) in preds
.chunks_exact(k)
.zip(out.chunks_exact_mut(k))
.enumerate()
{
let y = labels[i];
let w = weights.map_or(1.0, |ws| ws[i]);
for j in 0..k {
out_row[j] = quantile_pair(row[j] - y, scales[j], alpha[j], w);
}
}
},
);
}
fn pred_transform(&self, preds: &mut [f32]) {
for row in preds.chunks_exact_mut(self.alpha.len()) {
for i in 1..row.len() {
let value = row[i];
let mut pos = i;
while pos > 0 && row[pos - 1] > value {
row[pos] = row[pos - 1];
pos -= 1;
}
row[pos] = value;
}
}
}
fn margins_to_probs(&self, _margins: &mut [f32]) {}
fn validate_info(&self, info: &crate::data::MetaInfo) -> Result<()> {
super::check_label_width(info, 1)
}
fn base_margins_info(&self, info: &crate::data::MetaInfo) -> Vec<f32> {
let (labels, weights) = (info.label_values(), info.weights);
let order = stable_order(labels);
match weights {
None => {
let sorted: Vec<f32> = order.iter().map(|&i| labels[i]).collect();
self.alpha
.iter()
.map(|&a| interpolated_quantile(a, &sorted))
.collect()
}
Some(w) => self
.alpha
.iter()
.map(|&a| weighted_quantile(a, labels, w, &order))
.collect(),
}
}
fn default_metric(&self) -> EvalMetric {
EvalMetric::Quantile(self.levels.clone())
}
}
#[derive(Debug, Clone)]
pub(crate) struct Expectile {
levels: Expectiles,
alpha: Vec<f32>,
}
impl Expectile {
pub(crate) fn from_levels(levels: Expectiles) -> Self {
Expectile {
alpha: levels.alpha_f32(),
levels,
}
}
#[cfg(test)]
pub(crate) fn new(alpha: &[f64]) -> Result<Self> {
Ok(Expectile::from_levels(Expectiles::new(
alpha.iter().copied(),
)?))
}
}
#[inline]
fn softplus(x: f32) -> f32 {
if x > 0.0 {
x + (-x).exp().ln_1p()
} else {
x.exp().ln_1p()
}
}
#[inline]
fn softplus_inv(x: f32) -> f32 {
let x = x.max(K_RT_EPS_F32);
x + (-(-x).exp_m1()).ln()
}
#[inline]
fn expectile_scale(diff: f32, alpha: f32) -> f32 {
if diff >= 0.0 { 1.0 - alpha } else { alpha }
}
impl Loss for Expectile {
fn name(&self) -> &'static str {
"reg:expectileerror"
}
fn n_outputs(&self) -> usize {
self.alpha.len()
}
fn gradient(
&self,
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
out: &mut [GradPair],
) {
let k = self.alpha.len();
let n = labels.len();
let alpha = &self.alpha;
super::rowwise_gradient(
n,
k,
preds,
labels,
weights,
out,
|preds, labels, weights, out| {
let mut q = vec![0.0f32; k];
for (i, (row, out_row)) in preds
.chunks_exact(k)
.zip(out.chunks_exact_mut(k))
.enumerate()
{
let label = labels[i];
let w = weights.map_or(1.0, |ws| ws[i]);
let mut pred = row[0];
for (kk, slot) in q.iter_mut().enumerate() {
if kk > 0 {
pred += K_RT_EPS_F32 + softplus(row[kk]);
}
*slot = pred;
}
for j in 0..k {
let mut grad_sum = 0.0f32;
let mut hess_sum = 0.0f32;
for (&pred, &a) in q[j..].iter().zip(&alpha[j..]) {
let diff = pred - label;
let scale = expectile_scale(diff, a);
grad_sum += scale * diff * w;
hess_sum += scale * w;
}
let chain = if j == 0 {
1.0
} else {
crate::simd::sigmoid_scalar(row[j])
};
out_row[j] = GradPair::new(chain * grad_sum, chain * chain * hess_sum);
}
}
},
);
}
fn pred_transform(&self, preds: &mut [f32]) {
for row in preds.chunks_exact_mut(self.alpha.len()) {
let mut pred = row[0];
for value in &mut row[1..] {
pred += K_RT_EPS_F32 + softplus(*value);
*value = pred;
}
}
}
fn probs_to_margins(&self, scores: &mut [f32]) {
for j in (1..scores.len()).rev() {
let gap = scores[j] - scores[j - 1];
scores[j] = softplus_inv(gap - K_RT_EPS_F32);
}
}
fn validate_info(&self, info: &crate::data::MetaInfo) -> Result<()> {
super::check_label_width(info, 1)
}
fn base_margins_info(&self, info: &crate::data::MetaInfo) -> Vec<f32> {
let (labels, weights) = (info.label_values(), info.weights);
let k = self.alpha.len();
let mean = weighted_label_mean(labels, weights);
let mut gpair = Vec::with_capacity(labels.len() * k);
for (i, &y) in labels.iter().enumerate() {
let diff = mean - y;
let w = weights.map_or(1.0, |ws| ws[i]);
for &a in &self.alpha {
let scale = expectile_scale(diff, a);
gpair.push(GradPair::new(scale * diff * w, scale * w));
}
}
let mut out = fit_stump(&gpair, k);
for v in &mut out {
*v += mean;
}
for j in 1..k {
out[j] = out[j].max(out[j - 1]);
}
self.probs_to_margins(&mut out);
out
}
fn default_metric(&self) -> EvalMetric {
EvalMetric::Expectile(self.levels.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::HessboostError;
use crate::model::Iterations;
use crate::model::ModelFormat;
use crate::objective::Objective;
use crate::objective::{base_margins, gradient_pairs};
use crate::training::Trainer;
#[test]
fn alpha_lists_are_validated() {
for bad in [&[][..], &[0.5, 0.2], &[-0.1], &[1.5], &[f64::NAN]] {
assert!(Quantile::new(bad).is_err(), "{bad:?}");
assert!(Expectile::new(bad).is_err(), "{bad:?}");
}
assert!(Quantile::new(&[0.0, 0.5, 0.5, 1.0]).is_ok());
assert!(Expectile::new(&[0.0, 1.0]).is_ok());
}
#[test]
fn quantile_gradient_at_zero_and_saturated_residuals() {
let obj = Quantile::new(&[0.25]).unwrap();
let mut labels = vec![0.0f32; 20];
labels[19] = 4.0;
let out = gradient_pairs(&obj, &[0.0; 20], &labels, None);
let s = 0.01f32;
assert_eq!(out[0], GradPair::new(0.5 * s * (1.0 - 0.5), 12.5));
let tanh = (-4.0f32 / (SMOOTHING_SCALE * s)).tanh();
assert_eq!(out[19].grad, 0.5 * s * (tanh + 1.0 - 0.5));
assert_eq!(out[19].hess, 0.5 / SMOOTHING_SCALE * MIN_SURROGATE_RATIO);
assert!((out[19].grad + 0.25 * s).abs() < 1e-8);
}
#[test]
fn quantile_gradient_zero_scale_and_zero_weight() {
let obj = Quantile::new(&[0.5]).unwrap();
let out = gradient_pairs(&obj, &[1.0, 2.0], &[1.0, 2.0], None);
assert!(out.iter().all(|p| *p == GradPair::new(0.0, 0.0)));
let out = gradient_pairs(&obj, &[1.0, 0.0], &[0.0, 0.0], Some(&[0.0, 2.0]));
assert_eq!(out[0], GradPair::new(0.0, 0.0));
assert_eq!(out[1], GradPair::new(0.0, 0.0));
let out = gradient_pairs(&obj, &[1.0, 1.0], &[0.0, 0.0], Some(&[0.0, 2.0]));
assert_eq!(out[0], GradPair::new(0.0, 0.0));
let t = 25.0f32.tanh();
assert_eq!(
out[1],
GradPair::new(2.0 * (0.5 * (t + 1.0 - 1.0)), 2.0 * (12.5 * (t / 25.0)))
);
}
#[test]
fn quantile_outputs_use_their_own_alpha() {
let obj = Quantile::new(&[0.1, 0.9]).unwrap();
let out = gradient_pairs(&obj, &[0.0, 0.0], &[0.0], None);
assert_eq!(out, vec![GradPair::default(); 2]);
let out = gradient_pairs(&obj, &[-10.0, 10.0, 10.0, 10.0], &[0.0, 0.0], None);
let tilt0 = 1.0 - 2.0 * 0.1f32;
let tilt1 = 1.0 - 2.0 * 0.9f32;
assert!(out[0].grad < 0.0 && out[2].grad > 0.0);
assert_eq!(out[1].grad, out[3].grad);
assert!((out[1].grad - 0.5 * 10.0 * (1.0 + tilt1)).abs() < 1e-4);
assert!((out[2].grad - 0.5 * 10.0 * (1.0 + tilt0)).abs() < 1e-4);
}
#[test]
fn quantile_transform_sorts_each_row() {
let obj = Quantile::new(&[0.1, 0.5, 0.9]).unwrap();
let mut p = [3.0, 1.0, 2.0, 0.0, 5.0, -1.0];
obj.pred_transform(&mut p);
assert_eq!(p, [1.0, 2.0, 3.0, -1.0, 0.0, 5.0]);
}
#[test]
fn quantile_intercepts() {
let obj = Quantile::new(&[0.1, 0.25, 0.5, 0.9]).unwrap();
let labels = [4.0f32, 1.0, 3.0, 2.0];
assert_eq!(base_margins(&obj, &labels, None), vec![1.0, 1.25, 2.5, 4.0]);
let w = [5.0f32, 1.0, 1.0, 1.0];
assert_eq!(
base_margins(&obj, &labels, Some(&w)),
vec![1.0, 2.0, 4.0, 4.0]
);
assert!(base_margins(&obj, &[], None)[0].is_nan());
}
#[test]
fn expectile_transform_is_monotone_and_inverts() {
let obj = Expectile::new(&[0.1, 0.5, 0.9]).unwrap();
let mut p = [2.0, -30.0, 3.0];
obj.pred_transform(&mut p);
assert_eq!(p[0], 2.0);
assert!(p[0] < p[1] && p[1] < p[2]);
let mut scores = [0.5f32, 1.0, 3.0];
obj.probs_to_margins(&mut scores);
obj.pred_transform(&mut scores);
for (a, b) in scores.iter().zip([0.5f32, 1.0, 3.0]) {
assert!((a - b).abs() < 1e-5, "{scores:?}");
}
let mut tied = [1.0f32, 1.0];
obj.probs_to_margins(&mut tied);
assert_eq!(tied[1], softplus_inv(K_RT_EPS_F32));
}
#[test]
fn expectile_gradient_formulas() {
let single = Expectile::new(&[0.2]).unwrap();
let out = gradient_pairs(&single, &[1.0, -1.0], &[0.0, 0.0], Some(&[2.0, 0.0]));
assert_eq!(out[0], GradPair::new(0.8 * 1.0 * 2.0, 0.8 * 2.0));
assert_eq!(out[1], GradPair::new(0.0, 0.0));
let obj = Expectile::new(&[0.2, 0.8]).unwrap();
let out = gradient_pairs(&obj, &[0.0, 0.0], &[1.0], None);
let q1 = K_RT_EPS_F32 + softplus(0.0);
let (d0, d1) = (-1.0f32, q1 - 1.0);
let (a0, a1) = (0.2f32, if d1 >= 0.0 { 0.2 } else { 0.8 });
assert_eq!(out[0], GradPair::new(a0 * d0 + a1 * d1, a0 + a1));
let s = 0.5f32;
assert_eq!(out[1], GradPair::new(s * (a1 * d1), s * s * a1));
}
#[test]
fn expectile_intercept_is_newton_step_from_mean_then_running_max() {
let obj = Expectile::new(&[0.5]).unwrap();
assert_eq!(base_margins(&obj, &[1.0, 2.0, 6.0], None), vec![3.0]);
let obj = Expectile::new(&[0.1, 0.9]).unwrap();
let labels = [0.0f32, 0.0, 0.0, 10.0];
let mut q = base_margins(&obj, &labels, None);
obj.pred_transform(&mut q);
let low = 2.5 - (6.0f64 / 2.8) as f32;
let high = 2.5 - ((0.75f64 - 6.75) / (0.3 + 0.9)) as f32;
assert!(
(q[0] - low).abs() < 1e-6 && (q[1] - high).abs() < 1e-5,
"{q:?}"
);
let tied = Expectile::new(&[0.5, 0.5]).unwrap();
let mut q = base_margins(&tied, &labels, None);
tied.pred_transform(&mut q);
assert!(q[1] >= q[0]);
}
#[test]
fn multi_quantile_training_is_calibrated_and_ordered() {
use crate::config::TrainingParams;
let n = 400;
let x: Vec<f32> = (0..n).map(|i| i as f32 / n as f32).collect();
let y: Vec<f32> = x
.iter()
.enumerate()
.map(|(i, &v)| v + (1.0 + v) * (((i * 7919) % 1000) as f32 / 1000.0 - 0.5))
.collect();
let d = crate::test_support::labeled_dense(&x, n, 1, &y);
let params = TrainingParams::builder()
.objective(Objective::Quantile(
Quantiles::new(vec![0.1, 0.5, 0.9]).unwrap(),
))
.max_depth(3)
.eta(0.3)
.build()
.unwrap();
let result = Trainer::new(¶ms, &d, 30)
.eval(&d, "train")
.train()
.unwrap();
let history = &result.history;
assert_eq!(history.metrics(), ["quantile"]);
assert!(history.last().unwrap().values()[0] < history.round(0).unwrap().values()[0]);
let pred = result.model.predict(&d, Iterations::Best).unwrap();
assert_eq!((pred.n_rows(), pred.width()), (n, 3));
let mut below = [0usize; 3];
for (row, &yi) in pred.rows().zip(&y) {
assert!(row[0] <= row[1] && row[1] <= row[2], "{row:?}");
for (count, &q) in below.iter_mut().zip(row) {
*count += usize::from(yi <= q);
}
}
let frac = below.map(|c| c as f64 / n as f64);
for (f, alpha) in frac.iter().zip([0.1, 0.5, 0.9]) {
assert!((f - alpha).abs() < 0.1, "coverage {frac:?}");
}
}
#[test]
fn quantile_intercepts_export_unsorted() {
let obj = Quantile::new(&[0.1, 0.9]).unwrap();
let mut stored = [10.0f32, 0.0];
obj.margins_to_probs(&mut stored);
assert_eq!(stored, [10.0, 0.0]);
}
#[test]
fn quantile_xgboost_round_trip_keeps_intercept_order() {
use crate::config::TrainingParams;
use crate::model::{BoostedModel, Predictions};
let n = 32;
let x: Vec<f32> = (0..n).map(|i| i as f32 / n as f32).collect();
let d = crate::test_support::labeled_dense(&x, n, 1, &x);
let params = TrainingParams::builder()
.objective(Objective::Quantile(Quantiles::new(vec![0.1, 0.9]).unwrap()))
.max_depth(2)
.build()
.unwrap();
let mut model = crate::training::train(¶ms, &d, 3).unwrap();
model.set_base_scores(vec![10.0, 0.0]);
let restored = BoostedModel::decode(
model.encode(ModelFormat::XgboostJson).unwrap(),
ModelFormat::XgboostJson,
)
.unwrap();
assert_eq!(restored.base_scores(), [10.0, 0.0]);
let close = |a: Predictions, b: Predictions| {
assert_eq!((a.n_rows(), a.width()), (b.n_rows(), b.width()));
for (x, y) in a.as_slice().iter().zip(b.as_slice()) {
assert!((x - y).abs() <= 1e-5 * x.abs().max(1.0), "{a:?} vs {b:?}");
}
};
close(
model.predict_margin(&d, Iterations::Best).unwrap(),
restored.predict_margin(&d, Iterations::Best).unwrap(),
);
close(
model.predict(&d, Iterations::Best).unwrap(),
restored.predict(&d, Iterations::Best).unwrap(),
);
}
#[test]
fn alpha_objectives_require_one_label_column() {
use crate::config::TrainingParams;
use crate::data::DMatrix;
use crate::objective::{Expectiles, Objective, Quantiles};
let d = DMatrix::from_dense(&[0.0, 1.0], 2, 1)
.unwrap()
.with_label_matrix(&[0.0, 1.0, 1.0, 2.0], 2)
.unwrap();
for objective in [
Objective::Quantile(Quantiles::new([0.1, 0.9]).unwrap()),
Objective::Expectile(Expectiles::new([0.1, 0.9]).unwrap()),
] {
let params = TrainingParams::builder()
.objective(objective)
.build()
.unwrap();
assert!(matches!(
Trainer::new(¶ms, &d, 1).train(),
Err(HessboostError::InvalidData {
input: "labels",
..
})
));
}
}
#[test]
fn trained_alphas_rebuild_on_load() {
use crate::config::TrainingParams;
use crate::model::BoostedModel;
use crate::objective::{Objective, Quantiles};
let d =
crate::test_support::labeled_dense(&[0.0, 1.0, 2.0, 3.0], 4, 1, &[0.0, 1.0, 2.0, 3.0]);
let objective = Objective::Quantile(Quantiles::new([0.1, 0.9]).unwrap());
let params = TrainingParams::builder()
.objective(objective.clone())
.build()
.unwrap();
let model = Trainer::new(¶ms, &d, 1).train().unwrap().model;
let loaded = BoostedModel::decode(
model.encode(ModelFormat::Binary).unwrap(),
ModelFormat::Binary,
)
.unwrap();
assert_eq!(loaded.objective().built_in(), Some(&objective));
}
}