One table, many questions
When the same table answers a series of questions -- scoring today's applicants, then tomorrow's, against the same history -- it is uploaded again on every call.
predict_cached leaves the context table on the server and hands back a
handle. The first call looks exactly like an ordinary one: context and query
together. Every call after it sends only the query.
What it saves is the upload, and nothing else. The server reads the table back and encodes it again on each call, so a cached batch takes about as long as an uncached one.
At this example's size -- 12,000 rows by 14 columns, 122 KB stored -- that is a convenience. It is worth something when a context is tens or hundreds of megabytes and the questions keep coming.
pip install -U causilo-client scikit-learn
python repeated_queries.py
import os import time import numpy as np 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 cx = Causilo(os.environ["CAUSILO_ENDPOINT"], token=os.environ["CAUSILO_KEY"])
Seeded from the server before the first call, so the first difference is that call and not the whole month.
_spent = [(cx.usage() or {}).get("used", 0)] def counted(meta) -> str: """Cells this call added to the month, from the server's own running total. `metadata.quota.used` is what the service has counted for this key, so the difference between two calls is what the second one cost. Reading it this way means the example cannot disagree with the ledger. """ used = (meta.get("quota") or {}).get("used") if used is None: return "not metered" delta, _spent[0] = used - _spent[0], used return f"{delta:,} cells" print("ready:", cx.health())
The adult census dataset from OpenML: 48,842 rows, 14 columns of mixed numeric and categorical features with missing values, and a >50K label.
A big context on purpose. What is saved is the upload, and at a few hundred rows there is nothing much to save.
X, y = fetch_openml(data_id=1590, as_frame=True, return_X_y=True) X_train, X_rest, y_train, y_rest = train_test_split( X, y, train_size=12_000, test_size=6_000, stratify=y, random_state=42 ) history = X_train.assign(income=y_train.values)
Three batches of applicants, as they might arrive on three days. Sliced with iloc rather than np.array_split, which turns a DataFrame into plain arrays and loses the column names the server matches on.
cuts = [len(X_rest) * i // 3 for i in range(4)] batches = [X_rest.iloc[a:b] for a, b in zip(cuts, cuts[1:])] labels = [y_rest.values[a:b] for a, b in zip(cuts, cuts[1:])] print("history", history.shape, "batches", [b.shape for b in batches]) POSITIVE = ">50K" def auc(proba, meta, truth): proba = np.array(proba) good = proba[:, meta["classes"].index(POSITIVE)] return round(roc_auc_score(truth == POSITIVE, good), 4)
The ordinary way: the history goes over the wire three times
print("\nwithout the cache") plain = [] for i, (batch, truth) in enumerate(zip(batches, labels)): t = time.monotonic() proba, meta = cx.predict_with_metadata( history, batch, target="income", model="causilo-clf", output_type="probas" ) plain.append(auc(proba, meta, truth)) # The cells the server charged, read off the response rather than worked # out here. A number this script computed would be this script's opinion # of the billing rule, and an example that shows its own opinion of the # bill is worth nothing -- it agrees with itself when the rule changes. print(f" batch {i}: {time.monotonic() - t:.2f}s " f"counted {counted(meta)} " f"AUC {plain[-1]}")
With the cache: once
print("\nwith the cache") t = time.monotonic() proba, ctx = cx.predict_cached( history, batches[0], target="income", model="causilo-clf", output_type="probas" )
The first call is a normal one. It carries the history, is charged for it, and answers the first batch -- the handle is what it leaves behind.
print(f" batch 0: {time.monotonic() - t:.2f}s " f"counted {counted(ctx.metadata)} (the context travels with this one)") print(" handle:", ctx) # The creating call's metadata rides on the handle. cached = [auc(proba, ctx.metadata, labels[0])] for i, (batch, truth) in enumerate(zip(batches[1:], labels[1:]), start=1): t = time.monotonic() proba, meta = ctx.predict_with_metadata(batch) cached.append(auc(proba, meta, truth)) print(f" batch {i}: {time.monotonic() - t:.2f}s " f"counted {counted(meta)} " f"AUC {cached[-1]}") # What the server is holding, and what it costs. for held in cx.list_contexts(): print("\nheld:", held)
Release it when the run is over. It would expire on its own after six hours, but until then it is your table sitting in our bucket.
print("released:", ctx.release()) print("held after release:", cx.list_contexts())
What to expect
Every call above counts 196,000 cells, cached or not: the context rows count on every query. They take about as long too, because the table is read back and encoded again.
So the saving is the upload and nothing else. Here that is 122 KB, which is a convenience; it is worth something when a context is tens or hundreds of megabytes and the questions keep coming.
The scores are identical, not merely close, and that is worth stating: an earlier version kept the encoded context on the GPU, where different CUDA kernels agreed with the uncached path only to about 1e-3.
stored_bytes says what is held: this 12,000-row context is 122 KB as a
table. Held as the model's encoded form it was 4.75 GB, which is why it is
not held that way.
print("\nAUC without the cache:", plain) print("AUC with the cache: ", cached)
Output
stdout
ready: True
history (12000, 15) batches [(2000, 14), (2000, 14), (2000, 14)]
without the cache
batch 0: 1.84s counted 196,000 cells AUC 0.9385
batch 1: 1.67s counted 196,000 cells AUC 0.9285
batch 2: 1.62s counted 196,000 cells AUC 0.9226
with the cache
batch 0: 2.15s counted 196,000 cells (the context travels with this one)
handle: CachedContext(model='causilo-clf', target='income', rows=12000, context_id='ctx_6OYS3SgavjGEjMMQC49pie')
batch 1: 1.64s counted 196,000 cells AUC 0.9285
batch 2: 1.58s counted 196,000 cells AUC 0.9226
held: {'context_id': 'ctx_6OYS3SgavjGEjMMQC49pie', 'model': 'causilo-clf', 'target': 'income', 'n_context': 12000, 'n_features': 14, 'stored_bytes': 122523, 'expires_in_s': 21595}
released: True
held after release: []
AUC without the cache: [0.9385, 0.9285, 0.9226]
AUC with the cache: [0.9385, 0.9285, 0.9226]
Recorded 2026-09-18 · image 0.12.16