mod ebm;
mod kernel;
mod linalg;
mod refit;
mod solver;
mod term_kernel;
use std::num::NonZeroUsize;
use serde::{Deserialize, Serialize};
use crate::check::ensure;
use crate::conformal::Interval;
use crate::data::DMatrix;
use crate::error::{HessboostError, Result};
use crate::model::{BoostedModel, Iterations, Predictions};
use crate::objective::Objective;
use crate::objective::distributional::special::norm_ppf;
use kernel::{Kernel, LeafKernel};
use rayon::prelude::*;
use solver::RidgeSolver;
pub use ebm::{EbmInference, TermBands};
pub use refit::honest_refit;
const QUERY_BLOCK: usize = 32;
pub const MAX_EXACT_ROWS: usize = 8192;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BoulevardInfo {
pub dropout: f64,
pub learning_rate: f64,
pub subsample: f64,
pub reg_lambda: f64,
#[serde(deserialize_with = "truncation_from_json")]
pub truncation: Option<f64>,
pub seed: u64,
pub intercept_from_labels: bool,
}
fn truncation_from_json<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<f64>, D::Error> {
Ok(Option::<f64>::deserialize(deserializer)?.filter(|&t| t != 0.0))
}
impl BoulevardInfo {
pub(crate) fn validate(&self, model: &BoostedModel) -> Result<()> {
let fail = |reason: &str| {
Err(HessboostError::model_format(format!(
"invalid Boulevard record: {reason}"
)))
};
if !(self.dropout.is_finite() && (0.0..1.0).contains(&self.dropout)) {
return fail("dropout must be in [0, 1)");
}
if !(self.learning_rate > 0.0 && self.learning_rate <= 1.0) {
return fail("learning_rate must be in (0, 1]");
}
if !(self.subsample > 0.0 && self.subsample <= 1.0) {
return fail("subsample must be in (0, 1]");
}
if !(self.reg_lambda.is_finite() && self.reg_lambda >= 0.0) {
return fail("reg_lambda must be finite and >= 0");
}
if self.truncation.is_some_and(|t| !(t.is_finite() && t > 0.0)) {
return fail("truncation must be finite and > 0");
}
if model.num_parallel_tree() > 1 && (self.dropout != 0.0 || self.learning_rate != 1.0) {
return fail("BRAT-P (num_parallel_tree > 1) needs dropout 0 and learning_rate 1");
}
if !model
.objective()
.built_in()
.is_some_and(Objective::is_unweighted_squared_error)
|| model.n_outputs() != 1
|| model.has_vector_leaves()
|| model.has_non_unit_tree_weights()
|| model.trees().iter().any(|t| t.linear_leaves().is_some())
{
return fail("only single-output reg:squarederror tree ensembles are Boulevard fits");
}
Ok(())
}
fn ridge(&self, parallel: usize) -> (f64, f64) {
if parallel > 1 {
let k = parallel as f64;
(1.0 / (k - 1.0), k / (k - 1.0))
} else {
let lq = self.learning_rate * (1.0 - self.dropout);
(1.0 / lq, (1.0 + lq) / lq)
}
}
fn kappa(&self) -> f64 {
self.reg_lambda / self.subsample
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum KernelSolver {
#[default]
Exact,
Nystrom {
landmarks: NonZeroUsize,
seed: u64,
},
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum NoiseVariance<'a> {
Holdout(&'a DMatrix),
TrainingResiduals,
Known(f64),
}
pub struct BoulevardInference<'a> {
model: &'a BoostedModel,
kernel: LeafKernel,
solver: RidgeSolver,
c: f64,
s: f64,
intercept_from_labels: bool,
noise_variance: f64,
holdout: Option<&'a DMatrix>,
}
impl std::fmt::Debug for BoulevardInference<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BoulevardInference")
.field("rows", &self.kernel.n())
.field("trees", &self.kernel.n_trees())
.field("ridge", &self.c)
.field("scale", &self.s)
.field("noise_variance", &self.noise_variance)
.finish_non_exhaustive()
}
}
fn check_data(
model: &BoostedModel,
data: &DMatrix,
what: &'static str,
labelled: bool,
) -> Result<()> {
if data.n_rows() == 0 {
return Err(HessboostError::EmptyDataset(what));
}
if data.n_cols() != model.n_features() {
return Err(HessboostError::dimension_mismatch(
what,
model.n_features(),
data.n_cols(),
));
}
if labelled && data.labels().is_none() {
return Err(HessboostError::invalid_data(what, "needs labels"));
}
if labelled && data.n_targets() != 1 {
return Err(HessboostError::invalid_data(what, "needs one label column"));
}
if data.weights().is_some_and(|w| w.iter().any(|&v| v != 1.0)) {
return Err(HessboostError::invalid_data(
what,
"row weights other than 1 are not supported: Boulevard inference assumes equal \
noise per row",
));
}
if data.base_margin().is_some() {
return Err(HessboostError::invalid_data(
what,
"base margins are not supported by Boulevard inference",
));
}
Ok(())
}
fn check_alpha(alpha: f64) -> Result<()> {
ensure(
"alpha",
alpha > 0.0 && alpha < 1.0,
format!("must be in (0, 1), got {alpha}"),
)
}
fn z_value(alpha: f64) -> f64 {
norm_ppf(1.0 - alpha / 2.0)
}
fn mean_squared_residual(model: &BoostedModel, data: &DMatrix) -> Result<f64> {
let preds = model.predict(data, Iterations::Best)?;
let labels = data.labels().unwrap_or_default();
let sum: f64 = preds
.as_slice()
.iter()
.zip(labels)
.map(|(&p, &y)| (f64::from(y) - f64::from(p)).powi(2))
.sum();
Ok(sum / labels.len() as f64)
}
fn noise_estimate(model: &BoostedModel, train: &DMatrix, noise: NoiseVariance) -> Result<f64> {
let noise_variance = match noise {
NoiseVariance::Holdout(holdout) => {
check_data(model, holdout, "holdout", true)?;
mean_squared_residual(model, holdout)?
}
NoiseVariance::TrainingResiduals => mean_squared_residual(model, train)?,
NoiseVariance::Known(v) => v,
};
ensure(
"noise",
noise_variance.is_finite() && noise_variance > 0.0,
format!("the noise variance must be finite and > 0, got {noise_variance}"),
)?;
Ok(noise_variance)
}
fn fit_noise(model: &BoostedModel, train: &DMatrix, noise: NoiseVariance) -> Result<f64> {
check_data(
model,
train,
"train",
matches!(noise, NoiseVariance::TrainingResiduals),
)?;
noise_estimate(model, train, noise)
}
fn normal_intervals(
model: &BoostedModel,
data: &DMatrix,
alpha: f64,
norms: impl FnOnce() -> Result<Vec<f64>>,
width: impl Fn(f64) -> f64,
) -> Result<Vec<Interval<f64>>> {
check_alpha(alpha)?;
let z = z_value(alpha);
let norms = norms()?;
let preds = model.predict(data, Iterations::Best)?;
Ok(preds
.as_slice()
.iter()
.zip(norms)
.map(|(&p, w2)| {
let (center, half) = (f64::from(p), z * width(w2));
Interval {
lower: center - half,
upper: center + half,
}
})
.collect())
}
fn build_solver(kernel: &impl Kernel, solver: KernelSolver, c: f64) -> Result<RidgeSolver> {
let n = kernel.n();
match solver {
KernelSolver::Exact => {
if n > MAX_EXACT_ROWS {
return Err(HessboostError::invalid_param(
"solver",
format!(
"the exact solver factors at most {MAX_EXACT_ROWS} rows, got {n}; use \
`KernelSolver::Nystrom`"
),
));
}
RidgeSolver::exact(kernel, c)
}
KernelSolver::Nystrom { landmarks, seed } => {
RidgeSolver::nystrom(kernel, c, landmarks.get(), seed)
}
}
}
impl<'a> BoulevardInference<'a> {
pub fn fit(
model: &'a BoostedModel,
train: &DMatrix,
noise: NoiseVariance<'a>,
solver: KernelSolver,
) -> Result<Self> {
let info = model.boulevard().ok_or_else(|| {
HessboostError::incompatible_model(
"model",
"not a Boulevard fit: train it with `booster = boulevard`",
)
})?;
if model.num_trees() == 0 {
return Err(HessboostError::incompatible_model(
"model",
"has no trees (trained for 0 rounds), so its leaf kernel has no rows",
));
}
let noise_variance = fit_noise(model, train, noise)?;
let (c, s) = info.ridge(model.num_parallel_tree());
let leaves = model.predict_leaf(train, ..)?;
let kernel = LeafKernel::new(model.trees(), &leaves, info.kappa())?;
let solver = build_solver(&kernel, solver, c)?;
Ok(BoulevardInference {
model,
kernel,
solver,
c,
s,
intercept_from_labels: info.intercept_from_labels,
noise_variance,
holdout: match noise {
NoiseVariance::Holdout(h) => Some(h),
_ => None,
},
})
}
pub fn noise_variance(&self) -> f64 {
self.noise_variance
}
fn weight_gram(&self, gram: &[f64], sums: &[f64], m: usize) -> Vec<f64> {
let s = self.s;
let n = self.kernel.n() as f64;
let gamma: Vec<f64> = sums
.iter()
.map(|&su| {
if self.intercept_from_labels {
(1.0 - s * su) / n
} else {
0.0
}
})
.collect();
let mut out = vec![0.0; m * m];
for a in 0..m {
for b in 0..m {
out[a * m + b] = s * s * gram[a * m + b]
+ s * (gamma[a] * sums[b] + gamma[b] * sums[a])
+ n * gamma[a] * gamma[b];
}
}
out
}
fn leaves(&self, data: &DMatrix) -> Result<Predictions<u32>> {
check_data(self.model, data, "data", false)?;
self.model.predict_leaf(data, ..)
}
fn kernel_vectors(&self, leaves: &Predictions<u32>, rows: std::ops::Range<usize>) -> Vec<f64> {
let n = self.kernel.n();
let mut k = vec![0.0; rows.len() * n];
for (out, ids) in k
.chunks_exact_mut(n)
.zip(rows.filter_map(|row| leaves.row(row)))
{
self.kernel.add_query(ids, out);
}
k
}
fn weight_norms(&self, data: &DMatrix) -> Result<Vec<f64>> {
let leaves = self.leaves(data)?;
let rows = data.n_rows();
let blocks: Vec<Vec<f64>> = (0..rows.div_ceil(QUERY_BLOCK))
.into_par_iter()
.map(|b| {
let range = b * QUERY_BLOCK..((b + 1) * QUERY_BLOCK).min(rows);
let m = range.len();
let k = self.kernel_vectors(&leaves, range);
let solved = self.solver.solve(&k, m, self.c);
let g = self.weight_gram(&solved.gram, &solved.sums, m);
(0..m).map(|a| g[a * m + a].max(0.0)).collect()
})
.collect();
Ok(blocks.concat())
}
pub fn standard_errors(&self, data: &DMatrix) -> Result<Predictions<f64>> {
let sigma = self.noise_variance.sqrt();
let se: Vec<f64> = self
.weight_norms(data)?
.into_iter()
.map(|w2| sigma * w2.sqrt())
.collect();
Ok(Predictions::new(se, data.n_rows(), 1))
}
fn intervals(
&self,
data: &DMatrix,
alpha: f64,
width: impl Fn(f64) -> f64,
) -> Result<Vec<Interval<f64>>> {
normal_intervals(self.model, data, alpha, || self.weight_norms(data), width)
}
pub fn confidence_intervals(&self, data: &DMatrix, alpha: f64) -> Result<Vec<Interval<f64>>> {
let sigma2 = self.noise_variance;
self.intervals(data, alpha, |w2| (sigma2 * w2).sqrt())
}
pub fn prediction_intervals(&self, data: &DMatrix, alpha: f64) -> Result<Vec<Interval<f64>>> {
let sigma2 = self.noise_variance;
self.intervals(data, alpha, |w2| (sigma2 * (1.0 + w2)).sqrt())
}
pub fn reproduction_intervals(&self, data: &DMatrix, alpha: f64) -> Result<Vec<Interval<f64>>> {
let sigma2 = self.noise_variance;
self.intervals(data, alpha, |w2| (2.0 * sigma2 * w2).sqrt())
}
pub fn calibrated_prediction_intervals(
&self,
data: &DMatrix,
alpha: f64,
) -> Result<Vec<Interval<f64>>> {
let holdout = self.holdout.ok_or_else(|| {
HessboostError::invalid_param(
"noise",
"calibrated intervals need `NoiseVariance::Holdout` rows",
)
})?;
let calibration = self.prediction_intervals(holdout, alpha)?;
let labels = holdout.labels().unwrap_or_default();
let mut ratios: Vec<f64> = calibration
.iter()
.zip(labels)
.map(|(iv, &y)| {
let half = (iv.upper - iv.lower) / 2.0;
let center = f64::midpoint(iv.upper, iv.lower);
(f64::from(y) - center).abs() / half.max(f64::MIN_POSITIVE)
})
.collect();
let scale = crate::conformal::conformal_quantile(&mut ratios, alpha);
Ok(self
.prediction_intervals(data, alpha)?
.into_iter()
.map(|Interval { lower, upper }| {
let (center, half) = (f64::midpoint(upper, lower), (upper - lower) / 2.0 * scale);
if half.is_finite() {
Interval {
lower: center - half,
upper: center + half,
}
} else {
Interval {
lower: f64::NEG_INFINITY,
upper: f64::INFINITY,
}
}
})
.collect())
}
}