mod binary;
pub mod lambdarank;
mod regression;
use crate::backend::ObjectiveSetup;
use crate::collective::Collective;
use crate::config::{default_label_gain, Objective, Params};
use crate::data::BinnedDataset;
use crate::error::{Result, SilvicError};
pub(crate) const K_EPSILON: f64 = 1e-15_f32 as f64;
pub fn setup(params: &Params, train: &BinnedDataset) -> Result<ObjectiveSetup> {
check_finite_labels(&train.label)?;
match params.objective {
Objective::Regression => Ok(ObjectiveSetup::Regression),
Objective::Binary => Ok(ObjectiveSetup::Binary {
sigmoid: params.sigmoid,
}),
Objective::LambdaRank => lambdarank_setup(params, train),
}
}
fn lambdarank_setup(params: &Params, train: &BinnedDataset) -> Result<ObjectiveSetup> {
let boundaries = train
.query_boundaries
.as_deref()
.ok_or_else(|| SilvicError::data("ranking tasks require query information"))?;
let label_gain = effective_label_gain(params);
lambdarank::check_labels(&train.label, label_gain.len())?;
let truncation_level = u32::try_from(params.lambdarank_truncation_level).unwrap_or(u32::MAX);
let inverse_max_dcg = boundaries
.windows(2)
.map(|w| {
let max_dcg = lambdarank::max_dcg_at_k(
truncation_level as usize,
&train.label[w[0] as usize..w[1] as usize],
&label_gain,
);
if max_dcg > 0.0 {
1.0 / max_dcg
} else {
0.0
}
})
.collect();
Ok(ObjectiveSetup::LambdaRank {
sigmoid: params.sigmoid,
norm: params.lambdarank_norm,
truncation_level,
label_gain,
inverse_max_dcg,
})
}
pub(crate) fn effective_label_gain(params: &Params) -> Vec<f64> {
if params.label_gain.is_empty() {
default_label_gain()
} else {
params.label_gain.clone()
}
}
fn check_finite_labels(label: &[f32]) -> Result<()> {
match label.iter().position(|l| !l.is_finite()) {
Some(row) => Err(SilvicError::data(format!("label of row {row} is {}", label[row]))),
None => Ok(()),
}
}
pub fn constant_hessian(setup: &ObjectiveSetup, weight: Option<&[f32]>) -> Option<f64> {
match (setup, weight) {
(ObjectiveSetup::Regression, None) => Some(1.0),
_ => None,
}
}
pub fn init_score(params: &Params, train: &BinnedDataset, collective: &mut dyn Collective) -> Result<f64> {
if !params.boost_from_average || params.objective == Objective::LambdaRank {
return Ok(0.0);
}
let weight = train.weight.as_deref();
let mut sums = match params.objective {
Objective::Binary => binary::label_sums(&train.label, weight),
_ => regression::label_sums(&train.label, weight),
};
collective.allreduce_sum_f64(&mut sums)?;
let [sum_label, sum_weight] = sums;
let score = match params.objective {
Objective::Binary => binary::boost_from_score(sum_label, sum_weight, params.sigmoid),
_ => sum_label / sum_weight,
};
Ok(if score.abs() > K_EPSILON { score } else { 0.0 })
}
pub fn gradients(
setup: &ObjectiveSetup,
scores: &[f32],
label: &[f32],
weight: Option<&[f32]>,
query_boundaries: Option<&[u32]>,
grad: &mut [f32],
hess: &mut [f32],
) {
match setup {
ObjectiveSetup::Regression => regression::gradients(scores, label, weight, grad, hess),
ObjectiveSetup::Binary { sigmoid } => binary::gradients(*sigmoid, scores, label, weight, grad, hess),
ObjectiveSetup::LambdaRank { .. } => {
let boundaries = query_boundaries.expect("lambdarank gradients need query boundaries");
lambdarank::gradients(setup, boundaries, scores, label, weight, grad, hess);
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn lambdarank_query(
setup: &ObjectiveSetup,
query: usize,
scores: &[f32],
label: &[f32],
weight: Option<&[f32]>,
grad: &mut [f32],
hess: &mut [f32],
) {
let ObjectiveSetup::LambdaRank { sigmoid, .. } = setup else {
panic!("lambdarank gradients need a lambdarank objective setup");
};
let mut scratch = lambdarank::Scratch::new(lambdarank::SigmoidTable::shared(*sigmoid));
lambdarank::query_gradients(setup, query, scores, label, weight, grad, hess, &mut scratch);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::run_ranks;
fn dataset(label: Vec<f32>, weight: Option<Vec<f32>>, query_boundaries: Option<Vec<u32>>) -> BinnedDataset {
BinnedDataset {
num_rows: label.len(),
row_offset: 0,
features: Vec::new(),
groups: Vec::new(),
total_bins: 0,
label,
weight,
query_boundaries,
}
}
fn params(objective: Objective) -> Params {
Params {
objective,
..Params::default()
}
}
#[test]
fn init_score_is_the_global_weighted_mean_over_ranks() {
let labels = [vec![1.0f32, 2.0, 4.0], vec![0.5f32, 7.0]];
let weights = [vec![1.0f32, 0.5, 2.0], vec![3.0f32, 0.25]];
let all_rows = dataset(labels.concat(), Some(weights.concat()), None);
for objective in [Objective::Regression, Objective::Binary] {
let params = params(objective);
let single = init_score(¶ms, &all_rows, &mut crate::LocalCollective).expect("init score");
let per_rank = run_ranks(2, |rank, collective| {
let local = dataset(labels[rank].clone(), Some(weights[rank].clone()), None);
init_score(¶ms, &local, collective).expect("init score")
});
assert_eq!(per_rank, vec![single; 2], "{objective:?}: ranks agree with one process");
}
let regression = init_score(¶ms(Objective::Regression), &all_rows, &mut crate::LocalCollective).unwrap();
let want = (1.0 + 1.0 + 8.0 + 1.5 + 1.75) / (1.0 + 0.5 + 2.0 + 3.0 + 0.25);
assert!((regression - want).abs() < 1e-12, "regression: {regression} vs {want}");
let binary = init_score(¶ms(Objective::Binary), &all_rows, &mut crate::LocalCollective).unwrap();
let p = 1.0 - K_EPSILON;
assert_eq!(binary, (p / (1.0 - p)).ln(), "binary: clamped rate");
}
#[test]
fn init_score_of_binary_is_the_log_odds_over_ranks() {
let labels = [vec![1.0f32, 0.0, 0.0], vec![1.0f32, 0.0]];
let mut params = params(Objective::Binary);
params.sigmoid = 2.0;
let per_rank = run_ranks(2, |rank, collective| {
init_score(¶ms, &dataset(labels[rank].clone(), None, None), collective).expect("init score")
});
let want = (0.4f64 / 0.6).ln() / 2.0;
for score in per_rank {
assert!((score - want).abs() < 1e-15, "binary: {score} vs {want}");
}
}
#[test]
fn init_score_is_zero_without_boost_from_average_or_for_ranking() {
let data = dataset(vec![3.0, 1.0], None, Some(vec![0, 2]));
let mut off = params(Objective::Regression);
off.boost_from_average = false;
assert_eq!(init_score(&off, &data, &mut crate::LocalCollective).unwrap(), 0.0);
let rank = params(Objective::LambdaRank);
assert_eq!(init_score(&rank, &data, &mut crate::LocalCollective).unwrap(), 0.0);
}
#[test]
fn constant_hessian_only_for_unweighted_regression() {
assert_eq!(constant_hessian(&ObjectiveSetup::Regression, None), Some(1.0));
assert_eq!(constant_hessian(&ObjectiveSetup::Regression, Some(&[1.0, 1.0])), None);
assert_eq!(constant_hessian(&ObjectiveSetup::Binary { sigmoid: 1.0 }, None), None);
}
#[test]
fn setup_checks_labels() {
let nan = dataset(vec![1.0, f32::NAN], None, None);
assert!(setup(¶ms(Objective::Regression), &nan).is_err());
let ranking = params(Objective::LambdaRank);
assert!(
setup(&ranking, &dataset(vec![1.0, 2.0], None, None)).is_err(),
"no queries"
);
let err = setup(&ranking, &dataset(vec![1.0, 31.0], None, Some(vec![0, 2]))).unwrap_err();
assert!(err.to_string().contains("label 31"), "{err}");
let ok = setup(&ranking, &dataset(vec![1.0, 2.0, 0.0, 0.0], None, Some(vec![0, 2, 4]))).expect("setup");
let ObjectiveSetup::LambdaRank { inverse_max_dcg, .. } = ok else {
panic!("lambdarank setup")
};
let max_dcg = 3.0 * lambdarank::discount(0) + 1.0 * lambdarank::discount(1);
assert_eq!(inverse_max_dcg, vec![1.0 / max_dcg, 0.0]);
}
#[test]
fn pointwise_gradients_follow_lightgbm() {
let scores = [0.5f32, -1.0, 2.0];
let label = [1.0f32, 0.0, 3.0];
let weight = [2.0f32, 0.5, 1.0];
let (mut grad, mut hess) = ([0.0f32; 3], [0.0f32; 3]);
gradients(
&ObjectiveSetup::Regression,
&scores,
&label,
Some(&weight),
None,
&mut grad,
&mut hess,
);
assert_eq!(grad, [-1.0, -0.5, -1.0]);
assert_eq!(hess, weight);
let sigmoid = 1.5f64;
gradients(
&ObjectiveSetup::Binary { sigmoid },
&scores,
&label,
None,
None,
&mut grad,
&mut hess,
);
for row in 0..3 {
let y = if label[row] > 0.0 { 1.0 } else { -1.0 };
let response = -y * sigmoid / (1.0 + (y * sigmoid * f64::from(scores[row])).exp());
assert_eq!(grad[row], response as f32, "binary: gradient of row {row}");
let abs = response.abs();
assert_eq!(
hess[row],
(abs * (sigmoid - abs)) as f32,
"binary: hessian of row {row}"
);
}
}
#[test]
fn lambdarank_gradients_run_every_query() {
let label = vec![2.0f32, 0.0, 1.0, 0.0, 3.0, 1.0, 1.0];
let boundaries = vec![0u32, 3, 3, 7];
let data = dataset(label.clone(), None, Some(boundaries.clone()));
let objective = setup(¶ms(Objective::LambdaRank), &data).expect("setup");
let scores = [0.1f32, 0.3, -0.2, 0.0, 0.5, 0.5, -1.0];
let (mut grad, mut hess) = (vec![0.0f32; 7], vec![0.0f32; 7]);
gradients(
&objective,
&scores,
&label,
None,
Some(&boundaries),
&mut grad,
&mut hess,
);
for (query, w) in boundaries.windows(2).enumerate() {
let rows = w[0] as usize..w[1] as usize;
let (mut g, mut h) = (vec![0.0f32; rows.len()], vec![0.0f32; rows.len()]);
lambdarank_query(
&objective,
query,
&scores[rows.clone()],
&label[rows.clone()],
None,
&mut g,
&mut h,
);
assert_eq!(&grad[rows.clone()], &g[..], "query {query}: gradients");
assert_eq!(&hess[rows], &h[..], "query {query}: hessians");
}
assert!(grad.iter().all(|g| g.is_finite()) && grad[0] < 0.0 && grad[1] > 0.0);
}
}