Random Forest classifier and regressor, written in C11 from scratch. No upstream dependencies.
Part of wlearn (GitHub, all packages). Runs as native C, WASM (browser + Node), with JS and Python wrappers.
src/is the canonical C11 source.js/is the npm package for@wlearn/rf; itscsrc/directory is generated from rootsrc/.py/is the Python package forwlearn-rf; itscsrc/directory is generated from rootsrc/.- Set
WLEARN_PYTHON=/path/to/pythonto run Python sync/build/test targets with a specific environment. - Default Python tests use package fixtures. External sklearn parity lives behind
make test-py-ref.
- Classification (Gini, entropy, Hellinger) and regression (MSE, MAE)
- Bootstrap aggregating with adjustable sample rate
- ExtraTrees mode (random threshold per feature)
- Heterogeneous RF (depth-dependent feature weighting)
- OOB scoring with optional per-tree weighting
- MDI feature importances and permutation importance
- Cost-complexity pruning
- Local linear leaf models (regression)
- Missing value handling (learned NaN direction per split)
- Monotonic constraints (bound propagation)
- Quantile regression forests (per-leaf sample storage)
- Conformal prediction intervals (Jackknife+-after-Bootstrap)
- Sample weights (per-sample, or
classWeight: 'balanced') - Histogram binning (5-50x speedup on large datasets)
- JARF rotation (Jacobian Aligned Random Forests)
- Proximity matrix (co-leaf fraction across trees)
- Deterministic (LCG PRNG, fixed seed)
- Binary serialization (WLRN bundle with RF01 native artifact)
- C, JS, Python, and external parity test suites
Default parameters are sklearn-inspired while preserving wlearn-specific options.
npm install @wlearn/rf
WLEARN_PYTHON=/path/to/python make build-pymkdir build && cd build
cmake .. -DBUILD_TESTING=ON
make
./test_rf # run testsconst { readFileSync, writeFileSync } = require('fs')
const { RFModel } = require('@wlearn/rf')
// Classification
const clf = await RFModel.create({ nEstimators: 100, seed: 42 })
clf.fit(X_train, y_train)
const predictions = clf.predict(X_test)
const accuracy = clf.score(X_test, y_test)
const probabilities = clf.predictProba(X_test)
// Regression
const reg = await RFModel.create({
nEstimators: 100, task: 'regression', seed: 42
})
reg.fit(X_train, y_train)
const r2 = reg.score(X_test, y_test)
// Quantile predictions
const median = reg.predictQuantile(X_test, 0.5)
const [q10, q90] = reg.predictQuantile(X_test, [0.1, 0.9])
// Prediction intervals (90% coverage)
const { lower, upper } = reg.predictInterval(X_test, 0.1)from wlearn_rf import RFModel
# Classification
clf = RFModel({'n_estimators': 100, 'seed': 42})
clf.fit(X_train, y_train)
predictions = clf.predict(X_test)
accuracy = clf.score(X_test, y_test)
probabilities = clf.predict_proba(X_test)
# Regression
reg = RFModel({'n_estimators': 100, 'task': 'regression', 'seed': 42})
reg.fit(X_train, y_train)
r2 = reg.score(X_test, y_test)
# Quantile predictions
median = reg.predict_quantile(X_test, quantile=0.5)
# Prediction intervals (90% coverage)
lower, upper = reg.predict_interval(X_test, alpha=0.1)#include "rf.h"
rf_params_t params;
rf_params_init(¶ms);
params.n_estimators = 100;
params.seed = 42;
params.task = 0; // 0 = classification, 1 = regression
rf_forest_t *forest = rf_fit(X, nrow, ncol, y, ¶ms);
double *preds = malloc(nrow * sizeof(double));
rf_predict(forest, X, nrow, ncol, preds);
// Quantile predictions (requires store_leaf_samples=1)
params.store_leaf_samples = 1;
rf_forest_t *qrf = rf_fit(X, nrow, ncol, y, ¶ms);
double quantiles[] = {0.1, 0.5, 0.9};
double *qout = malloc(nrow * 3 * sizeof(double));
rf_predict_quantile(qrf, X_test, ntest, ncol, quantiles, 3, qout);
// Conformal prediction intervals (requires bootstrap + OOB)
double *lower = malloc(ntest * sizeof(double));
double *upper = malloc(ntest * sizeof(double));
rf_predict_interval(forest, X_test, ntest, ncol, 0.1, lower, upper);
rf_free(forest);
rf_free(qrf);All parameters have defaults that match sklearn. New features are opt-in and do not change default behavior.
| Parameter | JS name | Python name | C field | Default | Description |
|---|---|---|---|---|---|
| Number of trees | nEstimators |
n_estimators |
n_estimators |
100 | Number of trees in the forest |
| Max depth | maxDepth |
max_depth |
max_depth |
0 (unlimited) | Maximum tree depth. 0 = grow until pure or min_samples |
| Min samples split | minSamplesSplit |
min_samples_split |
min_samples_split |
2 | Minimum samples to split an internal node |
| Min samples leaf | minSamplesLeaf |
min_samples_leaf |
min_samples_leaf |
1 | Minimum samples per leaf |
| Max features | maxFeatures |
max_features |
max_features |
0 (auto) | Features per split. 0 = sqrt(n) for cls, n/3 for reg. Also: 'sqrt', 'log2', 'third' |
| Max leaf nodes | maxLeafNodes |
max_leaf_nodes |
max_leaf_nodes |
0 (unlimited) | Maximum number of leaf nodes per tree |
| Bootstrap | bootstrap |
bootstrap |
bootstrap |
1 (true) | Whether to bootstrap sample |
| Compute OOB | computeOob |
compute_oob |
compute_oob |
1 (true) | Whether to compute OOB score |
| ExtraTrees | extraTrees |
extra_trees |
extra_trees |
0 (false) | ExtraTrees mode (random threshold) |
| Seed | seed |
seed |
seed |
42 | Random seed for reproducibility |
| Task | task |
task |
task |
'classification' / 0 |
'classification' or 'regression' (JS); 0 or 1 (C/Python) |
| Parameter | JS name | Python name | C field | Default | Description |
|---|---|---|---|---|---|
| Criterion | criterion |
criterion |
criterion |
0 / 'gini' |
Split criterion. Classification: 'gini'(0), 'entropy'(1), 'hellinger'(2). Regression: 'mse'(0), 'mae'(1) |
| Sample rate | sampleRate |
sample_rate |
sample_rate |
1.0 | Fraction of samples per tree. Like sklearn max_samples |
| Heterogeneous | heterogeneous |
heterogeneous |
heterogeneous |
0 | Depth-dependent feature weighting. 1 = enabled |
| OOB weighting | oobWeighting |
oob_weighting |
oob_weighting |
0 | Weight tree votes by OOB performance. 1 = enabled |
| Alpha trim | alphaTrim |
alpha_trim |
alpha_trim |
0.0 | Cost-complexity pruning penalty. 0 = no pruning |
| Leaf model | leafModel |
leaf_model |
leaf_model |
0 | Leaf prediction model. 0 = constant (mean), 1 = local linear (regression only) |
| Parameter | JS name | Python name | C field | Default | Description |
|---|---|---|---|---|---|
| Store leaf samples | storeLeafSamples |
store_leaf_samples |
store_leaf_samples |
auto | Store per-leaf sample indices for quantile prediction. Auto-enabled for regression |
| Monotonic constraints | monotonicCst |
monotonic_cst |
monotonic_cst |
null | Per-feature constraints: -1 = decreasing, 0 = none, +1 = increasing. Array of length n_features |
| Parameter | JS name | Python name | C field | Default | Description |
|---|---|---|---|---|---|
| Sample weight | sampleWeight |
sample_weight |
sample_weight |
null (uniform) | Per-sample weights for splits, leaves, bootstrap, and OOB |
| Class weight | classWeight |
class_weight |
-- | null | 'balanced' auto-computes n / (K * n_k) per class (JS/Python wrapper only) |
| Histogram binning | histogramBinning |
histogram_binning |
histogram |
0 | Pre-bin features into up to 256 uint8 buckets. 1 = enabled |
| Max bins | maxBins |
max_bins |
max_bins |
256 | Maximum number of histogram bins (2-256) |
| JARF | jarf |
jarf |
jarf |
0 | Jacobian Aligned Random Forests. 1 = enabled |
| JARF estimators | jarfNEstimators |
jarf_n_estimators |
jarf_n_estimators |
50 | Number of trees in JARF surrogate forest |
| JARF max depth | jarfMaxDepth |
jarf_max_depth |
jarf_max_depth |
6 | Max depth of JARF surrogate trees |
// Construction
const model = await RFModel.create(params)
// Training
model.fit(X, y) // X: number[][] or Float64Array, y: number[]
// Prediction
model.predict(X) // Returns Float64Array
model.predictProba(X) // Classification only. Returns Float64Array (flat, row-major)
model.score(X, y) // Accuracy (cls) or R2 (reg)
// Quantile prediction (regression, requires store_leaf_samples)
model.predictQuantile(X, 0.5) // Single quantile: returns Float64Array
model.predictQuantile(X, [0.1, 0.9]) // Multiple: returns [Float64Array, Float64Array]
// Conformal prediction intervals (regression, requires bootstrap + OOB)
model.predictInterval(X, alpha) // Returns { lower: Float64Array, upper: Float64Array }
// Inspection
model.featureImportances() // Returns Float64Array (normalized MDI)
model.permutationImportance(X, y, { nRepeats: 5, seed: 42 }) // Model-agnostic importance
model.oobScore() // OOB accuracy (cls) or R2 (reg)
model.proximity(X) // Returns Float64Array (nrow*nrow, symmetric)
model.nFeatures // Number of features
model.nClasses // Number of classes (0 for regression)
model.nTrees // Number of trees
model.isFitted // Whether model is fitted
model.capabilities // { classifier, regressor, predictProba, featureImportances, ... }
// Persistence
writeFileSync('rf.wlrn', clf.save())
const loaded = await RFModel.load(readFileSync('rf.wlrn'))
// Deterministic cleanup for long-running loops
model.dispose()
// AutoML
RFModel.defaultSearchSpace() // Returns search space IR for hyperparameter tuning
// Params
model.getParams() // Returns current params
model.setParams({ maxDepth: 5 })
# Construction
model = RFModel(params_dict)
# Training
model.fit(X, y) # X: numpy 2D array, y: numpy 1D array
# Prediction
model.predict(X) # Returns numpy array
model.predict_proba(X) # Classification only. Returns (n, n_classes) array
model.score(X, y) # Accuracy (cls) or R2 (reg)
# Quantile prediction (regression)
model.predict_quantile(X, quantile=0.5) # Returns numpy array
# Conformal prediction intervals (regression)
lower, upper = model.predict_interval(X, alpha=0.1) # Returns (lower, upper) arrays
# Inspection
model.feature_importances() # Returns numpy array
model.permutation_importance(X, y, n_repeats=5, seed=42) # Model-agnostic importance
model.oob_score() # OOB score
model.proximity(X) # Returns (nrow, nrow) numpy array
model.n_features # Number of features
model.n_classes # Number of classes
model.n_trees # Number of trees
model.is_fitted # Whether model is fitted
# Persistence
clf.save("rf.wlrn")
loaded = RFModel.load("rf.wlrn")
// Initialize params with defaults
void rf_params_init(rf_params_t *params);
// Fit a forest. Returns NULL on error.
rf_forest_t *rf_fit(const double *X, int32_t nrow, int32_t ncol,
const double *y, const rf_params_t *params);
// Predict. out must be pre-allocated (nrow doubles). Returns 0 on success.
int rf_predict(const rf_forest_t *forest, const double *X,
int32_t nrow, int32_t ncol, double *out);
// Predict probabilities (classification). out: nrow * n_classes doubles.
int rf_predict_proba(const rf_forest_t *forest, const double *X,
int32_t nrow, int32_t ncol, double *out);
// Predict quantiles (requires store_leaf_samples=1).
// out: nrow * n_quantiles doubles, row-major.
int rf_predict_quantile(const rf_forest_t *forest, const double *X,
int32_t nrow, int32_t ncol,
const double *quantiles, int32_t n_quantiles,
double *out);
// Predict conformal intervals (requires bootstrap + OOB).
// out_lower, out_upper: nrow doubles each.
int rf_predict_interval(const rf_forest_t *forest, const double *X,
int32_t nrow, int32_t ncol, double alpha,
double *out_lower, double *out_upper);
// Permutation importance (model-agnostic).
// out: ncol doubles, mean score drop per feature.
int rf_permutation_importance(const rf_forest_t *forest,
const double *X, int32_t nrow, int32_t ncol,
const double *y, int32_t n_repeats,
uint32_t seed, double *out);
// Proximity matrix. out: nrow * nrow doubles, row-major, symmetric.
int rf_proximity(const rf_forest_t *forest, const double *X,
int32_t nrow, int32_t ncol, double *out);
// Serialize / deserialize
int rf_save(const rf_forest_t *forest, char **out_buf, int32_t *out_len);
rf_forest_t *rf_load(const char *buf, int32_t len);
// Free resources
void rf_free(rf_forest_t *forest);
void rf_free_buffer(void *ptr);
// Error message
const char *rf_get_error(void);NaN values are handled automatically. At each split, both NaN directions (left/right) are tried and the best is learned. At prediction time, NaN values follow the learned direction.
// JS: NaN values work transparently
const model = await RFModel.create({ seed: 42 })
const X = [[1.0, NaN], [2.0, 3.0], [NaN, 4.0]]
const y = [0, 1, 0]
model.fit(X, y)
model.predict([[NaN, 2.0]]) // NaN follows learned direction# Python: NaN values work transparently
import numpy as np
model = RFModel({'seed': 42})
X = np.array([[1.0, np.nan], [2.0, 3.0], [np.nan, 4.0]])
y = np.array([0, 1, 0])
model.fit(X, y)
model.predict(np.array([[np.nan, 2.0]]))Enforce monotonic relationships between features and predictions. Useful when domain knowledge dictates that increasing a feature should never decrease the prediction.
// X must have 3 columns.
const model = await RFModel.create({
task: 'regression',
monotonicCst: [1, -1, 0], // feature 0: increasing, 1: decreasing, 2: unconstrained
seed: 42
})
model.fit(X, y)# X must have 3 columns.
model = RFModel({
'task': 'regression',
'monotonic_cst': [1, -1, 0],
'seed': 42
})
model.fit(X, y)Predict any quantile of the conditional distribution. A single fitted model serves all quantiles.
const model = await RFModel.create({
task: 'regression', nEstimators: 200, seed: 42
})
model.fit(X_train, y_train)
// 90% prediction interval via quantiles
const [q05, q95] = model.predictQuantile(X_test, [0.05, 0.95])
model.dispose()model = RFModel({'task': 'regression', 'n_estimators': 200, 'seed': 42})
model.fit(X_train, y_train)
q05 = model.predict_quantile(X_test, quantile=0.05)
q95 = model.predict_quantile(X_test, quantile=0.95)Distribution-free prediction intervals with finite-sample coverage guarantees via Jackknife+-after-Bootstrap. Uses OOB residuals computed during training.
const model = await RFModel.create({
task: 'regression', nEstimators: 200, seed: 42
})
model.fit(X_train, y_train)
// 90% prediction intervals (alpha=0.1)
const { lower, upper } = model.predictInterval(X_test, 0.1)
model.dispose()model = RFModel({'task': 'regression', 'n_estimators': 200, 'seed': 42})
model.fit(X_train, y_train)
lower, upper = model.predict_interval(X_test, alpha=0.1)Per-sample weights affect split criteria, leaf predictions, bootstrap sampling, and OOB scoring.
JavaScript also accepts fit(X, y, { sampleWeight }), including through a core
Pipeline. Per-fit weights override constructor weights for that fit and are not
stored as parameters; constructor weights remain the fallback.
Python provides the same behavior with fit(X, y, sample_weight=weights).
const model = await RFModel.create({
nEstimators: 100, seed: 42,
sampleWeight: X.map((_, i) => i % 2 ? 2 : 1) // per-sample weights
})
model.fit(X, y)
// Or use classWeight: 'balanced' for auto-computed weights
const balanced = await RFModel.create({
nEstimators: 100, classWeight: 'balanced', seed: 42
})
balanced.fit(X_imbalanced, y_imbalanced)model = RFModel({
'n_estimators': 100, 'seed': 42,
'sample_weight': [2.0 if i % 2 else 1.0 for i in range(len(X))]
})
model.fit(X, y)
# Or class_weight='balanced'
balanced = RFModel({'n_estimators': 100, 'class_weight': 'balanced', 'seed': 42})
balanced.fit(X_imbalanced, y_imbalanced)Pre-bins features into up to 256 quantile-spaced uint8 buckets. Split search becomes O(B) per feature instead of O(n). 5-50x speedup on large datasets. Auto-disabled for ExtraTrees mode.
const model = await RFModel.create({
nEstimators: 200, histogramBinning: 1, seed: 42
})
model.fit(X_large, y_large) // much faster for n > 10Kmodel = RFModel({'n_estimators': 200, 'histogram_binning': 1, 'seed': 42})
model.fit(X_large, y_large)Jacobian Aligned Random Forests: trains a lightweight surrogate forest, computes finite-difference Jacobian per sample, forms EJOP matrix, eigendecomposes, and rotates features before training the main forest. Improves accuracy on rotated/diagonal decision boundaries.
const model = await RFModel.create({
nEstimators: 200, jarf: 1, seed: 42
})
model.fit(X, y) // rotation learned and applied automatically
model.predict(X_test) // rotation applied at prediction time toomodel = RFModel({'n_estimators': 200, 'jarf': 1, 'seed': 42})
model.fit(X, y)
model.predict(X_test)Model-agnostic, unbiased feature importance. Shuffles each feature, re-predicts, measures score drop. Unlike MDI, unbiased toward high-cardinality features.
const imp = clf.permutationImportance(X_test, y_test, { nRepeats: 10, seed: 42 })
// imp is Float64Array of length n_featuresimp = clf.permutation_importance(X_test, y_test, n_repeats=10, seed=42)
# imp is numpy array of length n_featuresComputes the fraction of trees where each pair of samples lands in the same leaf. Useful for outlier detection, clustering visualization, and missing value imputation.
const prox = clf.proximity(X) // Float64Array of length nrow*nrow
// prox[i * nrow + j] = fraction of trees where samples i and j share a leafprox = clf.proximity(X) # (nrow, nrow) numpy arrayconst model = await RFModel.create({
nEstimators: 200,
criterion: 'entropy',
sampleRate: 0.7,
seed: 42
})
model.fit(X, y)Depth-dependent feature weighting promotes diversity. Features overused at shallow depths are downweighted at deeper levels, giving more features a chance to contribute.
model = RFModel({
'n_estimators': 200,
'heterogeneous': 1,
'max_features': 0, # auto (sqrt)
'task': 'regression',
'seed': 42
})
model.fit(X, y)Instead of predicting the leaf mean, fit a ridge regression per leaf.
Best combined with shallow trees (max_depth=3-7) so each leaf has enough
samples for a meaningful linear fit.
model = RFModel({
'n_estimators': 100,
'task': 'regression',
'leaf_model': 1, # local linear
'max_depth': 5, # shallow trees
'seed': 42
})
model.fit(X, y)
r2 = model.score(X_test, y_test)Hellinger distance is robust to class imbalance -- it measures divergence between class distributions at each split rather than impurity reduction.
const model = await RFModel.create({
nEstimators: 200,
criterion: 'hellinger',
seed: 42
})
model.fit(X_imbalanced, y_imbalanced)Post-hoc pruning reduces overfitting by collapsing subtrees whose impurity gain
is less than alpha per leaf.
const model = await RFModel.create({
nEstimators: 100,
alphaTrim: 0.01,
seed: 42
})
model.fit(X, y)Weight each tree's vote by its individual OOB performance. Trees that generalize better get more influence.
model = RFModel({
'n_estimators': 200,
'oob_weighting': 1,
'seed': 42
})
model.fit(X, y)// X must have 5 columns.
const model = await RFModel.create({
nEstimators: 200,
task: 'regression',
criterion: 'mse',
leafModel: 1, // local linear leaves
maxDepth: 5, // shallow trees for linear leaves
heterogeneous: 1, // depth-dependent feature weighting
oobWeighting: 1, // weight trees by OOB performance
sampleRate: 0.8, // subsample 80%
monotonicCst: [1, 0, -1, 0, 0], // monotonic constraints
seed: 42
})
model.fit(X, y)Benchmark on synthetic data (n=500, 100 trees, seed=42). OOB scores are the most meaningful metric since they estimate generalization without a separate test set.
| Configuration | Train Acc | OOB Acc | Fit (ms) |
|---|---|---|---|
| Default (Gini) | 1.0000 | 0.9700 | 54 |
| Entropy | 1.0000 | 0.9680 | 29 |
| Hellinger | 1.0000 | 0.9980 | 23 |
| Sample rate 0.7 | 1.0000 | 0.9740 | 15 |
| Heterogeneous RF | 1.0000 | 0.9820 | 22 |
| OOB weighting | 1.0000 | 0.9700 | 22 |
| Alpha trim 0.01 | 1.0000 | 0.9700 | 22 |
| ExtraTrees | 1.0000 | 0.9820 | 4 |
| Entropy + HRF + OOB wt | 1.0000 | 0.9680 | 24 |
Hellinger distance achieves the best OOB accuracy (0.998) on this multiclass problem. ExtraTrees and HRF also improve over the default.
| Configuration | Train R2 | OOB R2 | Fit (ms) |
|---|---|---|---|
| Default (MSE) | 0.9762 | 0.8236 | 86 |
| MAE | 0.9731 | 0.8011 | 178 |
| Sample rate 0.7 | 0.9536 | 0.8140 | 57 |
| Heterogeneous RF | 0.9687 | 0.7664 | 91 |
| OOB weighting | 0.9771 | 0.8236 | 88 |
| Alpha trim 0.01 | 0.9684 | 0.8144 | 89 |
| Linear leaves | 0.9762 | 0.8236 | 87 |
| Linear leaves (depth=5) | 0.9913 | 0.9677 | 55 |
| ExtraTrees | 0.9744 | 0.8083 | 25 |
| Linear + HRF + OOB wt | 0.9706 | 0.7664 | 95 |
Linear leaves with shallow trees (depth=5) dramatically improve OOB R2 from 0.82 to 0.97 on this linear signal, confirming that local linear models capture within-leaf trends that constant leaves miss. The fit time is also lower due to fewer nodes.
The public JS and Python APIs serialize models as WLRN bundles. The native C model bytes inside the bundle use the compact RF01 format, format version 4. v4 stores learned NaN direction, histogram flags, JARF flags, max bins, and JARF rotation matrix data.
// JS: wlearn bundle format (wraps RF binary in WLRN container)
writeFileSync('rf.wlrn', clf.save())
const loaded = await RFModel.load(readFileSync('rf.wlrn'))# Python: wlearn bundle format
clf.save("rf.wlrn")
loaded = RFModel.load("rf.wlrn")// C: raw RF binary format
char *buf; int32_t len;
rf_save(forest, &buf, &len);
rf_forest_t *loaded = rf_load(buf, len);
rf_free_buffer(buf);mkdir build && cd build
cmake .. -DBUILD_TESTING=ON
make
./test_rfRequires Emscripten.
cd js
bash scripts/build-wasm.sh # outputs wasm/rf.js
bash scripts/verify-exports.sh # verifies 24 exportscd js && npm testmake test-py # package fixture/wrapper tests
make test-py-ref # optional sklearn/reference parity testsgcc -O2 -std=c11 -o build/benchmark test/benchmark.c src/rf.c -Isrc -lm
./build/benchmark- Meinshausen (2006). "Quantile Regression Forests." JMLR, 7(35):983-999.
- Barber et al. (2021). "Predictive Inference with the Jackknife+." Annals of Statistics, 49(1):486-507.
- Bartley et al. (2020). "An Improved Method for Monotone Constraints in Gradient Boosting." arXiv:2011.00986.
- Cieslak et al. (2012). "Hellinger distance decision trees are robust and skew-insensitive." Data Mining and Knowledge Discovery, 24(1), 136-158.
- Klusowski & Tian (2024). "Heterogeneous Random Forests." arXiv:2410.19022.
- Mentch & Zhou (2024). "Randomized Trees and Applications." arXiv:2410.04297.
- Correia & Lecue (2024). "Adaptive Random Forests with Cost-Complexity Pruning." arXiv:2408.07151.
- Horvath et al. (2025). "RaFFLE: Random Forest with Local Linear Extensions." arXiv:2502.10185.
- Ke et al. (2017). "LightGBM: A Highly Efficient Gradient Boosting Decision Tree." NeurIPS.
- Rauniyar et al. (2025). "Jacobian Aligned Random Forests." arXiv:2512.08306.
MIT
Python RFModel.create(params) implements the same construction contract used by
wlearn AutoML and ensembles; RFModel(params) remains available for direct use.
Classification accepts int32 labels, including negative and nonconsecutive values.
The classes property gives the probability-column order. Artifacts with a
nonidentity label mapping use wlearn.rf.classifier@2; ordinal classifiers and
regressors retain their existing @1 format. Both formats load in this release.