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;