Learners¶
The nuisance functions are ordinary prediction problems. dml-rs accepts two kinds of learners:
- Built-in learners, implemented in Rust. Cross-fitting with them never touches Python, so folds run in parallel and the GIL is released.
- Any scikit-learn compatible estimator (
fit/predict, andpredict_probafor classifiers). It is cloned for each fold and called from Rust.
Built-in learners¶
| Regression | Classification | Matches scikit-learn's |
|---|---|---|
Ols(ridge=0.0) |
LinearRegression / Ridge(alpha=ridge) |
|
Lasso(alpha) |
Lasso(alpha) |
|
LassoCV() |
LassoCV() |
|
LogisticRegression(C) |
LogisticRegression(C) (L2) |
|
LogisticRegressionCV() |
LogisticRegressionCV(scoring="neg_log_loss") |
|
RandomForestRegressor(...) |
RandomForestClassifier(...) |
RandomForest* (same parameters) |
GradientBoostingRegressor(...) |
GradientBoostingClassifier(...) |
GradientBoosting* (same parameters) |
Parameter names follow scikit-learn, so switching between the two is a matter of changing the import.
- Linear learners minimise the same objectives as scikit-learn with the same algorithms and stopping rules (coordinate descent for the Lasso, Newton's method for logistic regression), and agree with it to numerical precision.
- Cross-validated learners use scikit-learn's grids, fold schemes and selection rules:
LassoCVtries 100 penalties on a log scale from the smallest penalty giving an all-zero solution down to 1/1000 of it, on 5 contiguous folds, and keeps the one with the lowest mean test error;LogisticRegressionCVtries 10 values ofCfrom1e-4to1e4on stratified folds, scored by log loss (scikit-learn's default from version 1.11;scoring="accuracy"is available). Both refit on all the data with the chosen penalty, and the fitted attributes (alpha_,mse_path_,C_,scores_, ...) use scikit-learn's names. - Gradient boosting grows the same trees as scikit-learn. When several splits fit a small node
equally well, scikit-learn breaks the tie by a random feature order set by its
random_state; dml-rs breaks it deterministically. Predictions on the training data are identical; on new data they agree up to the variation scikit-learn itself shows across seeds. - Random forests follow scikit-learn's parameters and bootstrap scheme with their own random number generator, so they are statistically equivalent rather than identical.
scikit-learn estimators¶
from sklearn.ensemble import HistGradientBoostingRegressor, HistGradientBoostingClassifier
model = dml_rs.IRM(HistGradientBoostingRegressor(), HistGradientBoostingClassifier()).fit(X, y, d)
Pipelines and tuned search objects (GridSearchCV, ...) work too. Python learners are slower than
built-in ones because each fold calls back into Python, but the DML algebra still runs in Rust.
Tuning¶
GridSearchRegressor and GridSearchClassifier choose the best of several candidate learners by
5-fold cross-validation (mean squared error, or log loss with stratified folds) and refit it.
Candidates can be built-in or scikit-learn learners. As a nuisance learner the search runs inside
every cross-fitting fold on that fold's training data, which is DoubleML's
tune(..., tune_on_folds=True); with scikit-learn candidates it reproduces GridSearchCV with
scoring="neg_mean_squared_error" exactly. LassoCV and LogisticRegressionCV tune their
penalty the same way, faster.
ml = dml_rs.GridSearchRegressor([dml_rs.RandomForestRegressor(max_depth=d) for d in (3, 5, 8)])
model = dml_rs.PLR(ml, ml).fit(X, y, d)
let grid = || {
let candidates: Vec<Box<dyn Regressor>> = [3, 5, 8]
.iter()
.map(|&depth| Box::new(RandomForestRegressor::new().max_depth(Some(depth))) as Box<dyn Regressor>)
.collect();
GridSearchRegressor::new(candidates)
};
let fit = Plr::new(grid(), grid()).fit(&data)?;
Comparing learners¶
Each fit reports the out-of-fold loss of every nuisance learner, on data the learner did not see:
RMSE for regressions and log loss for classifications, in DoubleML's layout (n_rep by
n_treat). Comparing learners on these losses is a simple, honest way to choose them.
model.nuisance_loss_
# {'ml_g0': array([[1.05]]), 'ml_g1': array([[1.07]]), 'ml_m': array([[0.65]])}
from sklearn.metrics import mean_absolute_error
def mae(y_true, y_pred):
keep = ~np.isnan(y_true) # some learners apply only to part of the sample
return mean_absolute_error(y_true[keep], y_pred[keep])
model.evaluate_learners(metric=mae)
See the clustered data and diagnostics example.
External predictions¶
Nuisance predictions computed elsewhere (a model trained on another system, or an expensive model
fitted once and reused) can replace any learner, as DoubleML's external_predictions. They must
be out-of-fold predictions for the same sample splits, one column per repetition. The learners
they replace are not fitted and have no out-of-sample loss. Supported by PLR, IRM, APO, PLIV,
IIVM, DID, DIDCS, PLPR, PQ, LPQ and LPLR, with DoubleML's learner names (ml_l, ml_m, ml_g0,
...). LPQ takes all five of its learners or none. LPLR's double cross-fitted ml_M and ml_a also
need their inner predictions, ml_M_inner_k and ml_a_inner_k for every outer fold k (a fit
reports them in predictions_).
# {treatment: {learner: array (n_obs, n_rep)}}, or {learner: array} for one treatment
model = dml_rs.PLR(ml_l, ml_m).fit(X, y, d, folds=folds, external_predictions={"ml_m": m_hat})
let external = ExternalPredictions::new().with(0, "ml_m", m_hat); // (n_obs, n_rep)
let fit = Plr::new(ml_l, ml_m).fit_with_folds(&data.with_external_predictions(external), &folds)?;
Your own learner in Rust¶
Implement the Regressor (or Classifier) trait: fit, predict (or predict_proba) and
fresh, which returns an unfitted copy with the same settings for the next fold.
fit_weighted is optional and needed only by the R-learner. Learners must be Send + Sync so
that folds can be fitted on parallel threads.
use dml_core::{Regressor, Result};
use ndarray::{Array1, ArrayView1, ArrayView2};
#[derive(Clone)]
struct Mean(f64);
impl Regressor for Mean {
fn fit(&mut self, _x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
self.0 = y.mean().unwrap_or(0.0);
Ok(())
}
fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
Ok(Array1::from_elem(x.nrows(), self.0))
}
fn fresh(&self) -> Box<dyn Regressor> {
Box::new(Mean(0.0))
}
}