# Test fixtures
## `tiny_binary.lgb`
A minimal LightGBM binary classifier (8 trees, 5 features) used by
`lgb::tests::test_lightgbm_parity` to check that the pure-Rust parser
reproduces LightGBM's own predictions bit-for-bit.
Trained with `boost_from_average=false` so the raw score is *exactly* the sum of
tree leaf values — no init score to account for — which lets the Rust test assert
parity to `1e-9`.
Regenerate (LightGBM 4.6, deterministic):
```python
import numpy as np, lightgbm as lgb
rng = np.random.default_rng(42)
X = rng.standard_normal((2000, 5))
logit = 1.3*X[:,0] - 0.8*X[:,1] + 0.5*X[:,2]*X[:,3]
y = (logit + 0.3*rng.standard_normal(2000) > 0).astype(int)
params = dict(objective="binary", num_leaves=7, learning_rate=0.3,
min_data_in_leaf=20, boost_from_average=False,
deterministic=True, num_threads=1, seed=0, verbose=-1)
booster = lgb.train(params, lgb.Dataset(X, y), num_boost_round=8)
booster.save_model("tiny_binary.lgb")
print(booster.predict(np.array([[0.5, -0.2, 0.7, 1.1, -0.9]]))[0]) # -> 0.879687246542221
```
## `tiny_nan.lgb`
A second binary classifier (8 trees, 5 features) used by
`lgb::tests::test_lightgbm_nan_parity`. It is trained on data **with
missing values**, so its splits carry non-default missing directions and
missing-type bits — the code path `tiny_binary.lgb` never exercises. This is the
regression guard for the missing-value decision logic.
Regenerate (LightGBM 4.6, deterministic):
```python
import numpy as np, lightgbm as lgb
rng = np.random.default_rng(7)
X = rng.standard_normal((4000, 5))
X[rng.random((4000, 5)) < 0.15] = np.nan # inject missing values
logit = 1.3*np.nan_to_num(X[:,0]) - 0.8*np.nan_to_num(X[:,1]) + 0.5*np.nan_to_num(X[:,2]*X[:,3])
y = (logit + 0.3*rng.standard_normal(4000) > 0).astype(int)
params = dict(objective="binary", num_leaves=7, learning_rate=0.3, min_data_in_leaf=20,
boost_from_average=False, deterministic=True, num_threads=1, seed=0, verbose=-1)
booster = lgb.train(params, lgb.Dataset(X, y), num_boost_round=8)
booster.save_model("tiny_nan.lgb")
print(booster.predict(np.array([[np.nan, 0.3, np.nan, -0.5, 1.2]]))[0]) # -> 0.18514897124790036
```
## `tiny_cat.lgb`
A binary classifier (10 trees, 5 features, features 3 and 4 categorical) used by
`lgb::tests::test_lightgbm_categorical_parity`. Its trees carry 39
categorical (bitset) splits; the test inputs cover in-bitset, unseen (beyond the
bitset), negative, and NaN categories.
Regenerate (LightGBM 4.6, deterministic):
```python
import numpy as np, lightgbm as lgb
rng = np.random.default_rng(11)
N = 4000
X = rng.standard_normal((N, 5))
X[:, 4] = rng.integers(0, 12, N)
X[:, 3] = rng.integers(0, 30, N)
y = ((X[:,4] % 3 == 0).astype(float) + (X[:,3] > 15) * 0.7 + 0.5*X[:,0]
+ 0.2*rng.standard_normal(N) > 0.8).astype(int)
params = dict(objective="binary", num_leaves=15, learning_rate=0.3, min_data_in_leaf=20,
boost_from_average=False, deterministic=True, num_threads=1, seed=0, verbose=-1)
booster = lgb.train(params, lgb.Dataset(X, y, categorical_feature=[3, 4]), num_boost_round=10)
booster.save_model("tiny_cat.lgb")
cases = np.array([
[0.5, -0.2, 0.7, 1.0, 3.0],
[0.5, -0.2, 0.7, 15.0, 0.0],
[-1.0, 0.3, -0.4, 29.0, 11.0],
[-1.0, 0.3, -0.4, 40.0, 25.0], # unseen categories
[0.0, 0.0, 0.0, -1.0, -2.0], # negative categories
[0.0, 0.0, 0.0, np.nan, np.nan], # NaN categories
])
print(booster.predict(cases)) # -> CAT_EXPECTED in lgb.rs tests
```
## `tiny_zero.lgb`
A binary classifier (8 trees, 5 features) trained with `zero_as_missing=true`,
used by `lgb::tests::test_lightgbm_zero_as_missing_parity`. Its splits
carry `missing_type=Zero` (48 of them) — the branch the other fixtures never
exercise. The test cases pin LightGBM's zero *band*: `IsZero(v)` is
`|v| <= kZeroThreshold` with `kZeroThreshold = 1e-35f` (`include/LightGBM/meta.h`),
so `1e-40` must predict identically to an exact `0.0` while `1e-30` must not.
Regenerate (LightGBM 4.6, deterministic):
```python
import numpy as np, lightgbm as lgb
rng = np.random.default_rng(3)
N = 4000
X = rng.standard_normal((N, 5))
X[rng.random((N, 5)) < 0.2] = 0.0 # exact zeros -> missing
logit = 1.3*X[:,0] - 0.8*X[:,1] + 0.5*X[:,2]*X[:,3]
y = (logit + 0.3*rng.standard_normal(N) > 0).astype(int)
params = dict(objective="binary", num_leaves=7, learning_rate=0.3, min_data_in_leaf=20,
boost_from_average=False, deterministic=True, num_threads=1, seed=0,
verbose=-1, zero_as_missing=True, use_missing=True)
booster = lgb.train(params, lgb.Dataset(X, y), num_boost_round=8)
booster.save_model("tiny_zero.lgb")
cases = np.array([
[0.0, 0.3, 0.0, -0.5, 1.2], # exact zeros -> missing branch
[1e-40, 0.3, 1e-40, -0.5, 1.2], # inside the kZeroThreshold band
[1e-30, 0.3, 1e-30, -0.5, 1.2], # outside the band -> ordinary values
[0.5, -0.2, 0.7, 1.1, -0.9], # no zeros at all
[0.0, 0.0, 0.0, 0.0, 0.0], # everything missing
])
print(booster.predict(cases)) # -> expected values in the Rust test
```
## `tiny_sqrt.lgb`
A regressor (8 trees, 5 features) trained with `reg_sqrt=true`, used by
`lgb::tests::test_lightgbm_sqrt_parity`. Its objective line is
`regression sqrt` and the prediction is `sign(raw) * raw^2` — the transform a
plain `regression` model never exercises (this was a real silent-misprediction
bug: the `sqrt` token used to be ignored). The second test case produces a
negative raw score, pinning the sign handling. Besides `regression`, the
objectives `regression_l1`, `fair`, `quantile`, and `mape` also honour
`reg_sqrt` and write the same token; `huber` accepts the parameter but writes
no token, and `poisson`/`gamma`/`tweedie` ignore it entirely (verified against
LightGBM 4.6).
Regenerate (LightGBM 4.6, deterministic):
```python
import numpy as np, lightgbm as lgb
rng = np.random.default_rng(21)
N = 2000
X = rng.standard_normal((N, 5))
y = 1.3*X[:,0] - 0.8*X[:,1] + 0.5*X[:,2]*X[:,3] + 0.1*rng.standard_normal(N)
params = dict(objective="regression", reg_sqrt=True, num_leaves=7, learning_rate=0.3,
min_data_in_leaf=20, boost_from_average=False,
deterministic=True, num_threads=1, seed=0, verbose=-1)
booster = lgb.train(params, lgb.Dataset(X, y), num_boost_round=8)
booster.save_model("tiny_sqrt.lgb")
cases = np.array([
[0.5, -0.2, 0.7, 1.1, -0.9],
[-1.0, 0.3, -0.4, 0.2, 0.6], # negative raw score -> sign matters
[0.0, 0.0, 0.0, 0.0, 0.0],
])
print(booster.predict(cases)) # -> expected values in the Rust test
```
## Objective transforms
The output transforms in `lgb::Objective` (sigmoid with coefficient,
`exp` for poisson/gamma/tweedie, `log1p(exp)` for `cross_entropy_lambda`,
`average_output` division for random forest) were each verified bit-for-bit
against LightGBM 4.6 `booster.predict` across a 15-configuration matrix
(all supported objectives × NaN inputs × categorical features). The in-repo
`test_objective_transforms` pins the formulas with single-stump models.
`reg_sqrt` (`sign(raw) * raw^2`) was verified separately against LightGBM
4.6: exactly five objectives write a `sqrt` token into the objective line
and square their output — reproduce with
```python
import numpy as np, lightgbm as lgb
rng = np.random.default_rng(0)
X = rng.standard_normal((300, 5)); y = np.abs(X[:, 0]) + 0.5
row = np.array([[0.5, -0.2, 0.7, 1.1, -0.9]])
for obj in ["regression", "regression_l1", "huber", "fair", "quantile",
"mape", "poisson", "gamma", "tweedie"]:
m = lgb.train({"objective": obj, "reg_sqrt": True, "num_leaves": 4,
"verbose": -1}, lgb.Dataset(X, y), num_boost_round=4)
line = [l for l in m.model_to_string().splitlines()
if l.startswith("objective=")][0]
p, r = m.predict(row)[0], m.predict(row, raw_score=True)[0]
print(f"{line!r:35s} squared={np.isclose(p, np.sign(r)*r*r)}")
# regression / regression_l1 / fair / quantile / mape -> "<name> sqrt",
# squared=True; huber writes no token; poisson/gamma/tweedie ignore reg_sqrt.
```
Booster-level parity for the transform is pinned by `tiny_sqrt.lgb` (above);
`test_sqrt_objective_selection` pins the name → transform mapping for all
five in-repo. Tokens after the objective name that the parser does not
recognise are refused at load, since they may change the transform the way
`sqrt` does — the accepted vocabulary is audited against every LightGBM
release from 2.1 through 4.6 (see `Objective::parse` in `src/lgb.rs`).
## `tiny_binary.cbm`, `tiny_reg.cbm`, `tiny_multi.cbm`
CatBoost fixtures for the `catboost` backend tests: a Logloss binary
classifier (parity for `Output::Probability`), an RMSE regressor (parity for
`Output::Raw` — the guard against applying a sigmoid to regression output),
and a 3-class MultiClass model that must be *rejected* at load
(`get_dimensions_count() == 3`).
Regenerate (CatBoost 1.2.10):
```python
import numpy as np
from catboost import CatBoostClassifier, CatBoostRegressor
rng = np.random.default_rng(17)
N = 2000
X = rng.standard_normal((N, 5))
logit = 1.3*X[:,0] - 0.8*X[:,1] + 0.5*X[:,2]*X[:,3]
y_bin = (logit + 0.3*rng.standard_normal(N) > 0).astype(int)
y_reg = logit + 0.1*rng.standard_normal(N)
y_multi = np.digitize(logit, [-0.7, 0.7])
common = dict(iterations=8, depth=3, learning_rate=0.3, random_seed=0,
verbose=False, allow_writing_files=False, thread_count=1)
clf = CatBoostClassifier(loss_function="Logloss", **common).fit(X, y_bin)
clf.save_model("tiny_binary.cbm")
reg = CatBoostRegressor(loss_function="RMSE", **common).fit(X, y_reg)
reg.save_model("tiny_reg.cbm")
CatBoostClassifier(loss_function="MultiClass", **common).fit(X, y_multi) \
.save_model("tiny_multi.cbm")
cases = np.array([[0.5, -0.2, 0.7, 1.1, -0.9],
[-1.0, 0.3, -0.4, 0.2, 0.6],
[0.0, 0.0, 0.0, 0.0, 0.0]], dtype=np.float32)
print(clf.predict(cases, prediction_type="Probability")[:, 1])
print(reg.predict(cases)) # -> expected values in catboost.rs tests
```
## `tiny_binary.onnx`
An ONNX export of the `tiny_binary.lgb` booster, used by `test_onnx_parity`
(only compiled under the `onnx` feature; the test skips if the file is
missing). Exported with `zipmap=False` so the classifier output stays a plain
probability tensor `[N, 2]` — the bosk ONNX backend does not read ZipMap
sequences.
Regenerate (needs `lightgbm` + `onnxmltools`):
```python
import lightgbm as lgb, onnxmltools
from onnxconverter_common.data_types import FloatTensorType
booster = lgb.Booster(model_file="tiny_binary.lgb")
onx = onnxmltools.convert_lightgbm(
booster, initial_types=[("input", FloatTensorType([None, 5]))], zipmap=False)
onnxmltools.utils.save_model(onx, "tiny_binary.onnx")
```