Causilo vs XGBoost and LightGBM on one dataset
One dataset, one split, three models, the numbers as they come out. This is not a benchmark; it is the comparison you would run first, on one dataset, to decide whether a proper evaluation is worth your time. A proper one runs several datasets and several splits, with the boosters tuned.
The dataset is German credit (OpenML id 31), the split is 80/20 stratified with random_state=42, and the boosters run with their library defaults. Causilo's time includes the network round trip to the API.
pip install -U causilo-client xgboost lightgbm scikit-learn
python causilo_vs_xgboost.py
import os import time import lightgbm as lgb import numpy as np import pandas as pd import xgboost as xgb from sklearn.datasets import fetch_openml from sklearn.metrics import roc_auc_score from sklearn.model_selection import train_test_split from causilo_client import Causilo X, y = fetch_openml(data_id=31, as_frame=True, return_X_y=True) y = (y == "good").astype(int) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42) rows = []
Causilo
One call. A cold instance takes a few seconds to come up, and wake() waits
for that, so the start-up is not counted against the call. classes gives
the column order of the probability matrix, which is the one field you cannot
read probas without.
cx = Causilo(os.environ["CAUSILO_ENDPOINT"], token=os.environ["CAUSILO_KEY"]) cx.wake() t0 = time.perf_counter() proba, meta = cx.predict_with_metadata(X_train.assign(good=y_train.values), X_test, target="good", model="causilo-clf", output_type="probas") elapsed = time.perf_counter() - t0 p = np.array(proba)[:, meta["classes"].index(1)] rows.append(("Causilo (API, one call)", roc_auc_score(y_test, p), elapsed))
The boosters
XGBoost at its defaults, with categorical columns passed through rather than
encoded. enable_categorical=True is what lets it take the DataFrame as it
comes, which is the closest thing to the API's own handling.
t0 = time.perf_counter() booster = xgb.XGBClassifier(enable_categorical=True, tree_method="hist", random_state=42) booster.fit(X_train, y_train) p = booster.predict_proba(X_test)[:, 1] rows.append(("XGBoost (defaults)", roc_auc_score(y_test, p), time.perf_counter() - t0))
LightGBM at its defaults too. Both are given random_state=42; neither is
tuned, and tuning is where most of a booster's accuracy on this dataset
lives.
t0 = time.perf_counter() model = lgb.LGBMClassifier(random_state=42, verbose=-1) model.fit(X_train, y_train) p = model.predict_proba(X_test)[:, 1] rows.append(("LightGBM (defaults)", roc_auc_score(y_test, p), time.perf_counter() - t0)) table = pd.DataFrame(rows, columns=["model", "ROC AUC", "seconds"]).round({"ROC AUC": 4, "seconds": 2}) print(table.to_string(index=False))
Reading the table
The boosters were given nothing but defaults, and Causilo was given nothing at all: no hyperparameters exist on the API side to set. On a table this size the round trip is most of Causilo's time. The honest comparison is the one you run on your own data, with the boosters tuned the way you would tune them in production, on more than one split; the seconds column then has to include the tuning.
Output
stdout
model ROC AUC seconds
Causilo (API, one call) 0.7771 0.64
XGBoost (defaults) 0.7238 0.16
LightGBM (defaults) 0.7229 0.44
Recorded 2026-09-18 · image 0.12.16