Restricted environments: JSON dumps + the Rust genetic engine¶
Model-validation and audit hosts often cannot install the training framework or a solver. treecf parses JSON dumps directly, and its genetic backend runs on a compiled Rust core bundled in the wheel — no solver, no xgboost, no Python dependencies beyond numpy.
import numpy as np
rng = np.random.default_rng(42)
n = 6000
names = [
"income_monthly", "utilization", "n_active_loans", "n_loans_total",
"max_dpd_30d", "max_dpd_12m", "months_since_last_delinq", "age",
]
income = rng.lognormal(8.3, 0.5, n).round(-1)
utilization = rng.beta(2, 3, n).round(3)
n_total = rng.poisson(4, n).astype(float) + 1
n_active = np.minimum(np.floor(n_total * rng.beta(3, 2, n)), n_total)
dpd_12m = np.floor(rng.exponential(6, n)) * (rng.random(n) < 0.4)
dpd_30d = np.floor(dpd_12m * rng.beta(2, 4, n))
months_delinq = rng.exponential(14, n).round(0)
months_delinq[dpd_12m == 0] = np.nan # no delinquency -> no record
age = rng.integers(21, 75, n).astype(float)
X = np.column_stack([income, utilization, n_active, n_total,
dpd_30d, dpd_12m, months_delinq, age])
risk = (
-0.9 * np.log(income / 4000)
+ 2.2 * utilization
+ 0.35 * dpd_30d + 0.15 * dpd_12m
- 0.02 * np.nan_to_num(months_delinq, nan=36.0)
- 0.015 * age
)
y = (risk + rng.logistic(scale=0.8, size=n) > np.median(risk)).astype(int)
X.shape, y.mean().round(3)
((6000, 8), np.float64(0.534))
import xgboost as xgb
model = xgb.XGBClassifier(n_estimators=120, max_depth=4, learning_rate=0.2,
random_state=0)
model.fit(X, y)
model.get_booster().feature_names = list(names) # domain names for explanations
proba = model.predict_proba(X)[:, 1]
cutoff = 0.30 # the credit policy's PD cutoff
applicant = X[int(np.argmax(proba))] # a clearly declined applicant
float(model.predict_proba(applicant.reshape(1, -1))[0, 1])
0.9998914003372192
Ship the dump, not the framework¶
(Here we round-trip through a file in place of the audit host's copy.)
import tempfile, pathlib
dump_path = pathlib.Path(tempfile.mkdtemp()) / "model.json"
model.get_booster().save_model(str(dump_path))
from treecf import Explainer, Target, constraint
exp = Explainer(
str(dump_path), # no xgboost import needed
background=X,
constraints=[constraint("max_dpd_30d <= max_dpd_12m")],
)
Solve with the bundled engine¶
The genetic engine is feasibility-first and seed-deterministic. It returns proof="heuristic" — it never claims optimality, and the result is still float-verified against the model before being returned.
res = exp.explain(applicant,
target=Target.probability(range=(0.0, cutoff)),
backend="genetic", seed=0)
res.proof, res.solver_stats["backend"], res.changes
('heuristic',
'rust',
{'income_monthly': (1640.0, 1957.3270819095842),
'utilization': (0.186, 0.05899999663233757),
'max_dpd_30d': (15.0, 0.9999999403953552),
'max_dpd_12m': (24.0, 7.999999523162842),
'age': (29.0, 38.15863377167009)})
float(model.predict_proba(np.nan_to_num(res.x_cf, nan=np.nan).reshape(1, -1))[0, 1]) # the native model agrees
0.2977936267852783
How fast is the Rust engine?¶
backend="python" runs the original numpy implementation of the same algorithm, kept as a reference engine — identical result quality (the two are held to statistical parity), just slower. On production-sized models (300 trees, 50 features) the gap is 44–58× — see Backends and proofs in the docs. On this notebook's small model:
import time
def timed(backend):
start = time.perf_counter()
for seed in range(5):
exp.explain(applicant,
target=Target.probability(range=(0.0, cutoff)),
backend=backend, seed=seed)
return (time.perf_counter() - start) / 5
rust_s, python_s = timed("genetic"), timed("python")
print(f"rust {rust_s * 1000:7.1f} ms/solve")
print(f"python {python_s * 1000:7.1f} ms/solve")
print(f"speedup {python_s / rust_s:5.1f}x")
rust 22.6 ms/solve python 862.9 ms/solve speedup 38.2x