Distributional Gradient Boosting for Location, Scale, and Shape (LSS) in Rust.
A Rust implementation of probabilistic gradient boosting, supporting XGBoost and LightGBM backends.
- Unified Distribution Interface: Common
Distributiontrait for all probability distributions - Multiple Backends: Support for XGBoost and LightGBM through feature flags
- Response Functions: Identity, exp, softplus, sigmoid, relu, and more
- Loss Functions: Negative log-likelihood (NLL) and CRPS
- Gradient Stabilization: MAD and L2 stabilization methods
- Multivariate Distributions: Support for multivariate normal (MVN) and other multivariate distributions
Add to your Cargo.toml:
[dependencies]
gradientlss = "0.1"
# Enable backends as needed:
# gradientlss = { version = "0.1", features = ["xgboost"] }
# gradientlss = { version = "0.1", features = ["lightgbm"] }
# gradientlss = { version = "0.1", features = ["full"] } # Both backendsBoth XGBoost and LightGBM backends require native libraries to be compiled:
-
CMake (required for both):
# macOS brew install cmake # Ubuntu/Debian sudo apt-get install cmake # Windows # Download from https://cmake.org/download/
-
Clang/LLVM (for bindgen):
# macOS - usually pre-installed with Xcode xcode-select --install # Ubuntu/Debian sudo apt-get install llvm-dev libclang-dev clang
If you're updating from an earlier build, the following changes require action or change numerical results:
Any code constructing TrainConfig as an exhaustive struct literal will fail to
compile until the field is added:
let config = TrainConfig {
num_boost_round: 100,
early_stopping_rounds: Some(20),
verbose: false,
seed: 123,
collect_train_metrics: false, // <-- new field
};
// or sidestep future additions entirely:
let config = TrainConfig {
num_boost_round: 100,
..TrainConfig::default()
};The field controls whether the per-round training-set metric is computed when
nothing else needs it. It defaults to false because skipping the pass
measured ~20% faster end-to-end training with a validation set (see
examples/bench_collect_train_metrics.rs and OPTIMIZATION.md). Consequences:
TrainingResult::train_historyis now empty by default when a validation set drives early stopping. Setcollect_train_metrics: trueto get the per-round train loss curve back.- Nothing else changes: the train metric is still force-computed whenever it is
actually consumed — no validation set (train loss drives early stopping),
verbose: true, or registered callbacks — so early stopping, callbacks, and the trained model itself are identical either way.
LightGBM models now persist best_iteration (an 8-byte header before the model
text) so that predictions after early stopping use the best round, matching
LightGBM's Python behavior. Models saved with an older build will fail to
load — retrain and re-save them.
When early stopping is active, prediction truncates the ensemble to the best boosting iteration. Previously that iteration only advanced when the validation metric improved by the early-stopping min-delta (1e-4 relative), so it could lag the real optimum and predict a slightly under-fit model. It now records the true argmin of the validation curve (any improvement counts), matching XGBoost/LightGBM in Python.
- Effect: predictions from a model trained with a validation set + early stopping can change slightly — generally a touch more accurate. Models trained without early stopping are unchanged (all trees are used).
- Action: none required. If you pinned expected prediction values in tests, regenerate the baselines.
The early-stopping decision is unchanged: the patience counter still uses the min-delta, so training stops after the same number of rounds as before — only which iteration is selected for prediction moved.
hyper_opt with the default CvScheme::KFold now draws one seeded row
permutation (from HyperOptConfig::seed) and reuses it for every trial, instead
of using contiguous row-order folds. This makes seed actually control fold
composition (as documented) and matches xgboost.cv's random partitioning.
- Effect: CV scores — and therefore which hyperparameters are selected — will differ from previous runs. This is a one-time shift toward more reliable estimates (contiguous folds were biased whenever rows were ordered by target).
- Reproducibility: a fixed
seed(andhp_seed) now gives identical results run-to-run; a previous bug tied fold/RNG order toHashMapiteration order, so even seeded runs varied.
Time-series data is unaffected — and this is the mechanism to use. Shuffling
is applied only to CvScheme::KFold. Set cv_scheme: CvScheme::TimeSeries and
folds stay in row order with forward-chaining (expanding window): fold i trains
only on rows strictly before its test segment, so no future row ever informs a
past prediction. If your rows encode time, use TimeSeries and none of the
shuffling above applies:
let config = HyperOptConfig {
cv_scheme: CvScheme::TimeSeries, // preserves order; no shuffle
n_folds: 5,
..HyperOptConfig::default() // KFold is the default
};(The single 80/20 holdout, n_folds <= 1, is likewise never shuffled.)
LightGBMParams::set previously appended to LightGBM's parameter list, and
LightGBM keeps the first occurrence of a duplicated key — so any override of
a default (learning_rate, num_leaves, boosting, objective, verbose,
plus num_class on repeated set_n_dist_params) was silently ignored, with the
warning suppressed by verbose=-1. This included every hyper_opt-tuned
value: LightGBM trials all trained with learning_rate=0.1, num_leaves=31
regardless of what the optimizer sampled. set now replaces in place.
- Effect: LightGBM models trained with non-default params (directly or via
hyper_opt) will differ — they now honor your configuration. Re-evaluate any previously tuned "best" LightGBM hyperparameters: the scores they were selected by did not measure them. - Relatedly, the binned
Datasetis now constructed with the full training params (aslgb.traindoes in Python), so dataset-level params likemax_bin,min_data_in_leaf(feature pre-filtering), andzero_as_missingtake effect instead of being pinned to defaults. - The
[low, high]hyper_opt shorthand with integer bounds (e.g.num_leaves: [2, 64]) now samples integers (optunasuggest_intsemantics); LightGBM rejects float-valued integer params, which the override fix exposed.
Python XGBoostLSS's set_params_adj unconditionally overwrites
objective=None, base_score=0, and disable_default_eval_metric=True; the
Rust backend now does the same at train time. A user-carried objective (e.g.
"count:poisson") used to silently corrupt training — every internal predict
returned link-transformed values while boosting stayed on margin scale — and a
nonzero base_score shifted all predictions. If you were setting either, remove
them; the distribution's response functions are the link.
TrainConfig::seed is now wired into both backends (previously silently
ignored). Runs using subsample/colsample_*/bagging_fraction may differ; an
explicit seed set via backend params still wins.
predict(PredType::Samples | Quantiles)drew mixture components with the wrong probabilities: a second Gumbel perturbation plus temperature softmax was layered on top of the already-softmaxed mixing weights, biasing selection toward uniform (weights 0.9/0.1 sampled at roughly 0.82/0.18). Components are now drawn as an exact categorical, matchingMixtureSameFamily.sample().predict(PredType::Parameters)now returns mixing probabilities (summing to 1) for themix_prob_*columns, matching Python'spredict_dist. Previously it returned raw unbounded logits.
The evaluation metric aggregates per-sample log-probs with NaN treated as 0,
matching Python's -torch.nansum(log_prob). Previously one NaN log-prob (e.g. a
0·inf at a support boundary) made the metric NaN for the rest of training, which
froze best_iteration and burned out the early-stopping patience where Python
trains on.
LossFn::Crps computes gradients by finite differences through fixed-seed
sampling, which is only meaningful when the sampler is smooth in its parameters
(the rsample analogue). Training now fails fast with a clear error for
distributions whose samplers use rejection sampling or discrete draws (Gamma,
Beta, Dirichlet, StudentT, Poisson, NegativeBinomial, the ZI/ZA families, MVT,
Mixture) instead of silently producing garbage gradients. Torch errors the same
way — these have no rsample. Loc-scale and inverse-CDF families (Gaussian,
LogNormal, Logistic, Gumbel, Laplace, Cauchy, Weibull, Expectile, MVN, MVNLoRa,
SplineFlow) still support CRPS.
- XGBoost
feature_importancenow honors the requested type:Gain/Coverare parsed from the model dump (per-split averages, matchingget_score); previously every type silently returned split counts. - XGBoost saved models append
n_features(trailing, optional — old files still load, reporting 0);GradientLSS::num_features()now returns the real count instead of a stub 0. - A
CallbackAction::Stopon a new-minimum round no longer excludes that round frombest_iteration. num_boost_round: 0now reportsn_iterations: 0instead of 1.- Sigmoid response derivatives are 0 in the clamp region (|x| ≳ 6.9), matching
torch autograd through
torch.clampand this crate's own numerical path. - Softplus keeps its tail below x = −20 (
exp(x) + ε) instead of flooring to ε, matching torch.
use gradientlss::prelude::*;
use gradientlss::distributions::Gaussian;
// Create a Gaussian distribution
let dist = Gaussian::new(
Stabilization::None,
ResponseFn::Exp, // For scale parameter
LossFn::Nll,
false, // Don't initialize with start values
);
// With XGBoost backend
#[cfg(feature = "xgboost")]
{
use gradientlss::backend::XGBoostBackend;
let mut model = GradientLSS::<XGBoostBackend>::new(dist);
// Create dataset
let mut train_data = XGBoostDataset::from_data(
features.view(),
labels.view(),
)?;
// Train
let params = XGBoostBackend::create_params(2); // 2 params: loc, scale
let config = TrainConfig {
num_boost_round: 100,
early_stopping_rounds: Some(20),
verbose: true,
seed: 123,
collect_train_metrics: false, // set true to record per-round train loss
};
model.train(&mut train_data, None, params, config)?;
// Predict
let predictions = model.predict(
&test_features.view(),
PredType::Parameters,
1000, // n_samples (for sampling)
&[0.1, 0.5, 0.9], // quantiles
123, // seed
)?;
}Gaussian- Normal distribution with loc (mean) and scale (std) parametersGamma- Gamma distributionBeta- Beta distributionStudentT- Student's t distributionPoisson- Poisson distribution (discrete)NegativeBinomial- Negative binomial distribution (discrete)Weibull- Weibull distributionLogNormal- Log-normal distributionCauchy- Cauchy distributionLaplace- Laplace distributionGumbel- Gumbel distributionLogistic- Logistic distributionZAGamma- Zero-adjusted Gamma distributionZINB- Zero-inflated Negative Binomial distributionZIPoisson- Zero-inflated Poisson distribution
MVN- Multivariate Normal distribution with mean vector and Cholesky-decomposed covariance matrixMVT- Multivariate Student's T distribution with degrees of freedom, mean vector, and Cholesky-decomposed covariance matrixDirichlet- Dirichlet distribution for compositional data (proportions that sum to 1)
More distributions can be added by implementing the Distribution trait.
| Function | Description | Use Case |
|---|---|---|
Identity |
No transformation | Location parameters |
Exp |
Exponential | Strictly positive (scale) |
Softplus |
ln(1 + exp(x)) | Smooth positive |
Sigmoid |
1/(1 + exp(-x)) | Bounded (0, 1) |
ExpDf |
exp(x) + 2 | Degrees of freedom |
For multivariate distributions, the target data must be provided in a flattened format:
use gradientlss::distributions::MVN;
use gradientlss::backend::lightgbm_backend::LightGBMBackend;
use gradientlss::model::GradientLSS;
use ndarray::array;
// Create a 2-target MVN distribution
let mvn = MVN::new(2, Stabilization::None, ResponseFn::Exp, LossFn::Nll, false);
let mut model = GradientLSS::<LightGBMBackend>::new(mvn);
// For 3 observations with 2 targets each, labels should be:
// [y1_obs1, y2_obs1, y1_obs2, y2_obs2, y1_obs3, y2_obs3]
let features = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
let labels = array![1.0, 2.0, 2.0, 3.0, 3.0, 4.0]; // Flattened multivariate targets
let mut train_data = LightGBMBackend::Dataset::from_data(features.view(), labels.view())?;use gradientlss::distributions::{MVT, ResponseFn};
// Create a 2-target MVT distribution with separate response functions
let mvt = MVT::new(
2,
Stabilization::None,
ResponseFn::Exp, // For scale parameters
ResponseFn::ExpDf, // For degrees of freedom (ensures df > 2)
LossFn::Nll,
false
);use gradientlss::distributions::Dirichlet;
// Create a 3-target Dirichlet distribution for compositional data
let dirichlet = Dirichlet::new(3, Stabilization::None, ResponseFn::Exp, LossFn::Nll, false);
// For Dirichlet, targets must sum to 1 for each observation
// [p1_obs1, p2_obs1, p3_obs1, p1_obs2, p2_obs2, p3_obs2, ...]
let features = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
let labels = array![0.3, 0.4, 0.3, 0.2, 0.5, 0.3, 0.1, 0.6, 0.3];The model will automatically handle the reshaping and parameter estimation for each multivariate distribution type.
GradientLSS provides multiple approaches for model interpretation, matching the functionality of XGBoostLSS/LightGBMLSS.
// Feature importance
let importance = model.feature_importance(
FeatureImportanceType::Gain,
Some(vec!["age".to_string(), "income".to_string()]),
)?;
// Partial dependence plots
let pdp = model.partial_dependence(
&features.view(),
0, // feature index
0, // parameter index (e.g., 0 for 'loc')
Some(50), // grid size
None,
None,
)?;
// ICE (Individual Conditional Expectation) curves
let ice = model.ice_curves(
&features.view(),
0, // feature index
0, // parameter index
Some(50),
None,
None,
)?;// Feature importance plot
model.plot_feature_importance(
"importance.png",
FeatureImportanceType::Gain,
None, // aggregated across parameters
Some(feature_names),
None,
)?;
// Partial dependence plot
model.plot_partial_dependence(
&features.view(),
0, // feature index
0, // parameter index
"pdp.png",
None,
)?;
// Expectile plot (for Expectile distribution models)
model.expectile_plot(
&features.view(),
0, // feature index
"expectiles.png",
Some(50), // grid size
Some("Age".to_string()),
None,
true, // show confidence bands
)?;For advanced SHAP-based visualizations (beeswarm plots, dependency plots), GradientLSS provides a data export workflow compatible with Python's shap library:
// Export data for SHAP analysis
let shap_data = model.export_for_shap(
&features.view(),
Some(vec!["feature1".to_string(), "feature2".to_string()]),
)?;
// Save to JSON for Python consumption
shap_data.to_json_file("shap_export.json")?;Then in Python:
import json
import numpy as np
import shap
import xgboost as xgb
# Load the exported data
with open('shap_export.json', 'r') as f:
data = json.load(f)
X = np.array(data['features'])
predictions = np.array(data['predictions'])
feature_names = data['feature_names']
param_names = data['param_names']
# Use feature importance directly from GradientLSS
if data['feature_importance']:
for param, importance in data['feature_importance'].items():
print(f"{param}: {importance}")
# Or train a surrogate model for full SHAP values
# (useful for beeswarm plots and SHAP dependency plots)
model_loc = xgb.XGBRegressor()
model_loc.fit(X, predictions[:, 0]) # Train on 'loc' predictions
explainer = shap.TreeExplainer(model_loc)
shap_values = explainer.shap_values(X)
shap.summary_plot(shap_values, X, feature_names=feature_names)See examples/shap_integration.rs for a complete example.
- Full support for custom objective functions
- Uses
update_custom()for distributional gradient updates - Row-major (C-order) gradient layout
- Limited: Current Rust bindings don't expose custom objective API
- Uses built-in objectives only
- Column-major (Fortran-order) gradient layout
- Consider using XGBoost for full distributional regression support
gradientlss/
├── distributions/ # Distribution implementations
│ ├── base.rs # Distribution trait
│ └── gaussian.rs # Gaussian distribution
├── backend/ # Gradient boosting backends
│ ├── traits.rs # Backend trait definitions
│ ├── xgboost_backend.rs
│ └── lightgbm_backend.rs
├── utils.rs # Response functions
├── model.rs # GradientLSS model wrapper
└── error.rs # Error types
- Create a new file in
src/distributions/ - Implement the
Distributiontrait:
impl Distribution for MyDistribution {
fn n_params(&self) -> usize { /* ... */ }
fn params(&self) -> &[DistributionParam] { /* ... */ }
fn log_prob(&self, params: &[f64], target: f64) -> f64 { /* ... */ }
fn nll(&self, params: &ArrayView2<f64>, target: &ArrayView1<f64>) -> f64 { /* ... */ }
fn sample(&self, params: &ArrayView2<f64>, n_samples: usize, seed: u64) -> Array2<f64> { /* ... */ }
// ... other required methods
}- Export from
src/distributions/mod.rs
MIT