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