Skip to main content

hessboost/objective/distributional/
mod.rs

1//! Distributional (probabilistic) boosting: objectives that predict a full
2//! conditional distribution `p(y | x)` instead of a point, in the style of
3//! NGBoost (Duan et al., 2020, arXiv:1910.03225) and XGBoostLSS (März, 2019,
4//! arXiv:1907.03178). Opt-in: the objective names live
5//! outside XGBoost's namespace, and XGBoost-format export refuses them.
6//!
7//! # Families and parameterizations
8//!
9//! Objective `dist:<family>` gives the model one output per distribution
10//! parameter (`n_outputs = n_params`, one tree per parameter and round on the
11//! ordinary `one_output_per_tree` path, or one shared tree per round with
12//! `multi_output_tree`, see below). Each output is an *unconstrained*
13//! margin `η`; positive parameters use a log link. Log-link margins are
14//! clamped to `[-30, 30]` ([`LOG_LINK_BOUND`]) before the link, in the
15//! gradients as in prediction, so no parameter over- or underflows.
16//!
17//! | objective | [`DistFamily`] | margins | natural parameters |
18//! |---|---|---|---|
19//! | `dist:normal` | `Normal` | `(μ, ln σ)` | mean `μ`, standard deviation `σ` |
20//! | `dist:lognormal` | `LogNormal` | `(μ, ln σ)` | `ln y ~ N(μ, σ²)` |
21//! | `dist:gamma` | `Gamma` | `(ln m, ln a)` | mean `m`, shape `a` (rate `a / m`) |
22//! | `dist:poisson` | `Poisson` | `ln λ` | rate `λ` |
23//! | `dist:negbinomial` | `NegativeBinomial` | `(ln m, ln r)` | mean `m`, size `r` (variance `m + m²/r`) |
24//!
25//! The parameterizations are chosen *orthogonal*: the Fisher information of
26//! each family is diagonal in its margins, so the diagonal Fisher that fits
27//! XGBoost's per-output second-order trees is the full Fisher matrix, and
28//! the per-row natural gradient `I(η)⁻¹ ∇η` is elementwise.
29//!
30//! # Gradients and the "Hessian" (`dist_gradient`)
31//!
32//! The loss is the negative log-likelihood `-ln p(y | η)` (the log scoring
33//! rule). Every mode uses its exact gradient `g = ∇η NLL`; the
34//! [`DistGradient`] parameter selects what the trees see as the second-order
35//! statistic:
36//!
37//! - [`DistGradient::Fisher`] (default): `(g, diag I(η))`, the expected
38//!   Hessian (Fisher scoring). Always positive, and independent of the label.
39//!   Because the Fisher matrix is diagonal here, a leaf's Newton step
40//!   `-Σg / (ΣI + λ)` is a natural-gradient step.
41//! - [`DistGradient::Hessian`]: `(g, diag ∇²η NLL)`, the diagonal of the
42//!   exact (observed) Hessian as XGBoostLSS uses it. The negative-binomial
43//!   size entry turns negative for counts far above the mean and the
44//!   Normal / LogNormal `ln σ` entry `2z²` vanishes at `y = μ`; values are
45//!   floored at `1e-16`, like XGBoost's own objectives.
46//! - [`DistGradient::Natural`]: NGBoost's natural gradient, `(I(η)⁻¹ g, 1)`:
47//!   trees regress the per-row natural gradient by (weighted) least
48//!   squares, and `eta` is the step size (no line search).
49//!
50//! Row weights multiply both statistics. The per-family formulas (`t = y/m`,
51//! `z = (y - μ)/σ`, `ψ` digamma, `ψ'` trigamma):
52//!
53//! - Normal: `g = (-z/σ, 1 - z²)`, `I = (1/σ², 2)`, exact diagonal
54//!   `(1/σ², 2z²)`. LogNormal is the same on `ln y`.
55//! - Gamma: `g = (a(1 - t), a(ψ(a) - ln a + t - 1 - ln t))`,
56//!   `I = (a, a²(ψ'(a) - 1/a))`, exact diagonal `(a t, g₂ + a²(ψ'(a) - 1/a))`.
57//! - Poisson: `g = λ - y`, `I = λ` (exactly `count:poisson` without its
58//!   `max_delta_step` Hessian inflation).
59//! - Negative binomial: `g₁ = r(m - y)/(r + m)`, `g₂ = -r D` with
60//!   `D = ψ(y + r) - ψ(r) + ln(r/(r + m)) + (m - y)/(r + m)`;
61//!   `I₁ = m r/(m + r)` and `I₂ = r² (E[ψ'(r) - ψ'(Y + r)] - m/(r(r + m)))`.
62//!   The expectation has no closed form: it is the series
63//!   `Σ_k P(Y > k)/(r + k)²`, summed from the probability mass function
64//!   until the upper tail falls below `1e-12` (at most 100 000 terms, then
65//!   closed with a geometric-tail estimate; the terms below
66//!   `m - 12·sd` use `P(Y > k) = 1` and the trigamma difference). When 24
67//!   standard deviations span more than 50 000 values, the equivalent
68//!   `ψ'(r) - 1/r - E[ψ'(r + Y) - 1/(r + Y) + (Y - m)²/((r + m)²(r + Y))]`
69//!   is summed instead over blocks of values, the same blocks as the count
70//!   CRPS.
71//!
72//! # Intercepts
73//!
74//! The intercept is the maximum-likelihood fit of the *marginal* (weighted)
75//! label distribution: sample mean and (biased) standard deviation for
76//! Normal / LogNormal (on `ln y`), the mean and the shape solving
77//! `ln a - ψ(a) = ln ȳ - mean(ln y)` for Gamma, the mean for Poisson, and
78//! the mean and the size solving the profile score equation (by bisection
79//! on `ln r`) for the negative binomial. A scalar `base_score` is only
80//! accepted for the one-parameter `dist:poisson` (as its rate).
81//!
82//! # Shared trees: parallel gradient boosting
83//!
84//! With `multi_strategy = multi_output_tree` every round grows *one*
85//! vector-leaf tree for all parameters instead of one tree per parameter.
86//! [`DistSplitDirection`] (`dist_split_direction`) selects its structure:
87//!
88//! - [`DistSplitDirection::Random`] (default) and
89//!   [`DistSplitDirection::Cyclic`] implement parallel gradient boosting
90//!   (Chapelle, Vayatis, Falissard & Sedki, 2026, arXiv:2607.13550,
91//!   Algorithm 1). The common descent direction of a round is a canonical
92//!   basis vector `e_m`: parameter `m` is drawn uniformly at random from
93//!   `seed` and the iteration (the paper's choice), or swept as
94//!   `iteration mod n_params`; either visits every parameter infinitely
95//!   often, the paper's convergence condition. The tree structure is grown
96//!   from that parameter's gradient pairs alone
97//!   ([`Loss::split_gradient`](crate::objective::Loss::split_gradient), the projected pseudo-residuals
98//!   `⟨∇L_i, e_m⟩`), and every leaf then takes the per-parameter Newton step
99//!   `-G_k / (H_k + λ)` over its rows. That is the second-order form of the
100//!   paper's leaf-wise multidimensional line search `argmin_γ Σ L(g + h γ)`:
101//!   with the diagonal curvature of the `dist_gradient` mode the line search
102//!   separates across parameters, which the paper's convergence argument
103//!   also relies on. With [`DistGradient::Natural`] (unit Hessians) the
104//!   structure fit is the paper's least-squares fit of the projected
105//!   pseudo-residuals, on the natural gradient.
106//! - [`DistSplitDirection::All`]: plain vector-leaf trees, whose split gain
107//!   sums over every parameter's gradients.
108//!
109//! One-parameter families (`dist:poisson`) keep ordinary trees. Shared trees
110//! need one structure search per round instead of one per parameter, and
111//! all parameters move together each round.
112//!
113//! # Predictions
114//!
115//! [`BoostedModel::predict`](crate::model::BoostedModel::predict) returns
116//! the natural parameters `[row][parameter]` (the table's last column);
117//! [`BoostedModel::predict_distribution`](crate::model::BoostedModel::predict_distribution)
118//! returns one [`Dist`] per row with its mean, variance, CDF, quantiles,
119//! log density, CRPS, intervals, and inverse-CDF sampling. Metrics `nll`
120//! (the default) and `crps` score them.
121
122mod count;
123mod dist;
124mod family;
125mod loss;
126pub(crate) mod special;
127
128use serde::{Deserialize, Serialize};
129
130pub use family::{Dist, DistFamily};
131pub(crate) use loss::DistLoss;
132
133/// The second-order statistic the `dist:*` distributional objectives give
134/// the trees (see [`crate::objective::distributional`]).
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
136#[serde(rename_all = "lowercase")]
137#[non_exhaustive]
138pub enum DistGradient {
139    /// Gradient of the negative log-likelihood with the diagonal Fisher
140    /// information as Hessian (Fisher scoring; a natural-gradient Newton
141    /// step for the orthogonal parameterizations used).
142    #[default]
143    Fisher,
144    /// Gradient with the diagonal of the exact (observed) Hessian, floored
145    /// at `1e-16` (XGBoostLSS-style).
146    Hessian,
147    /// NGBoost's natural gradient `I⁻¹ ∇` with unit Hessian: trees regress
148    /// the natural gradient by least squares.
149    Natural,
150}
151
152/// How the shared tree of a `dist:*` objective chooses its structure under
153/// `multi_strategy = multi_output_tree` (see
154/// [`crate::objective::distributional`]).
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
156#[serde(rename_all = "lowercase")]
157#[non_exhaustive]
158pub enum DistSplitDirection {
159    /// Parallel gradient boosting (Chapelle et al., 2026, Algorithm 1): each
160    /// round grows the structure from the gradients of one distribution
161    /// parameter drawn uniformly at random (seeded by `seed` and the
162    /// iteration), a canonical descent direction `e_m`.
163    #[default]
164    Random,
165    /// Parallel gradient boosting with a deterministic sweep: parameter
166    /// `iteration mod n_params` drives round `iteration`.
167    Cyclic,
168    /// Plain vector-leaf trees: the split gain sums over every parameter.
169    All,
170}
171
172stored_names! {
173    DistGradient { Fisher => "fisher", Hessian => "hessian", Natural => "natural" }
174    DistSplitDirection { Random => "random", Cyclic => "cyclic", All => "all" }
175}
176
177/// A `dist:*` objective: the distribution family whose parameters the model
178/// predicts, the second-order statistic its trees see, and, for shared
179/// (vector-leaf) trees, how their structure is chosen.
180///
181/// ```
182/// use hessboost::objective::distributional::{
183///     DistFamily, DistGradient, DistSplitDirection, Distributional,
184/// };
185///
186/// let normal = Distributional::new(DistFamily::Normal)
187///     .with_gradient(DistGradient::Natural)
188///     .with_split_direction(DistSplitDirection::Cyclic);
189/// assert_eq!(normal.split_direction(), Some(DistSplitDirection::Cyclic));
190/// ```
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub struct Distributional {
193    family: DistFamily,
194    gradient: DistGradient,
195    split_direction: Option<DistSplitDirection>,
196}
197
198impl Distributional {
199    /// The objective `dist:<family>` with Fisher scoring and, for shared
200    /// trees, the default random split direction.
201    pub fn new(family: DistFamily) -> Self {
202        Distributional {
203            family,
204            gradient: DistGradient::Fisher,
205            split_direction: None,
206        }
207    }
208
209    /// Give the trees `gradient`'s second-order statistic (default
210    /// [`DistGradient::Fisher`]).
211    #[must_use]
212    pub fn with_gradient(mut self, gradient: DistGradient) -> Self {
213        self.gradient = gradient;
214        self
215    }
216
217    /// Choose the structure of shared (vector-leaf) trees by `direction`
218    /// (default [`DistSplitDirection::Random`]). Only shared trees have one:
219    /// training refuses it without `multi_strategy = multi_output_tree`.
220    #[must_use]
221    pub fn with_split_direction(mut self, direction: DistSplitDirection) -> Self {
222        self.split_direction = Some(direction);
223        self
224    }
225
226    /// The distribution family.
227    pub fn family(&self) -> DistFamily {
228        self.family
229    }
230
231    /// The trees' second-order statistic.
232    pub fn gradient(&self) -> DistGradient {
233        self.gradient
234    }
235
236    /// The shared-tree split direction, `None` for the default (random).
237    pub fn split_direction(&self) -> Option<DistSplitDirection> {
238        self.split_direction
239    }
240}
241
242/// Bound on log-link margins: `ln` of a positive parameter is clamped to
243/// `[-LOG_LINK_BOUND, LOG_LINK_BOUND]` before the link is applied.
244pub const LOG_LINK_BOUND: f64 = 30.0;
245
246#[cfg(test)]
247mod tests;