Can it learn a 90% probability without a useful input?

Calibration
Generate yes answers 90% of the time, independently of the input. The right probability is therefore 90%.

Example

x: 0.0
entity: id-12
label: yes

yes or no is a sampled hidden answer. A single answer does not determine the true probability.

Model tree with record, x, entity, label. Amber cards are hidden prediction targets. The answer is sampled; its population probability is 90%.

Model tree with record, x, entity, label. Amber cards are hidden prediction targets. The answer is sampled; its population probability is 90%.

Figure 1: Amber cards are hidden prediction targets. The answer is sampled; its population probability is 90%.

Comparison

Compare known and new input identities, using two different internal output allocations.

Result

P060 · Latest full run · Passing

Seed 7601 on cpu: 10 of 10 test checks met. History: 11 of 14 full runs met every check, across 3 distinct seeds. History can include earlier source versions. The experiment code has changed since this run; these measurements describe its earlier version.

Comparison Latest measurement
True yes probability 90.0%
Predicted, known inputs 91.8%
Predicted, new inputs 92.0%

The latest model learns probabilities near the population rate. Accuracy alone would not reveal whether its confidence was appropriate; this does not establish general probability calibration.

Experiment details and code

Latest full run

Seed 7601, cpu, recorded 2026-09-21T18:46:02.628010+00:00. Outcome: met.

Source fingerprint: 248e741ac2fcab55ada3fed9fdefe01cfe761b571679a89e8bc5c62227055607. Python 3.12.6; Torch 2.12.0.

Measurement Value
prior 0.9
weighted_optimum 0.75
oracle_nll 0.325083
capacity_2_known mean_probability: 0.917779; prior_rmse: 0.0190939; excess_nll: 0.00234553
capacity_2_novel mean_probability: 0.919603; prior_rmse: 0.0196032; excess_nll: 0.00242681
capacity_128_known mean_probability: 0.915548; prior_rmse: 0.0160068; excess_nll: 0.00159211
capacity_128_novel mean_probability: 0.915192; prior_rmse: 0.0151918; excess_nll: 0.00141279
Behavioral checks
Behavioral check Outcome
capacity_2_known: probability RMSE below 0.06 Met
capacity_2_known: excess expected NLL below 0.02 Met
capacity_2_novel: probability RMSE below 0.06 Met
capacity_2_novel: excess expected NLL below 0.02 Met
capacity_2: evaluation preserves vocabulary and counts Met
capacity_128_known: probability RMSE below 0.06 Met
capacity_128_known: excess expected NLL below 0.02 Met
capacity_128_novel: probability RMSE below 0.06 Met
capacity_128_novel: excess expected NLL below 0.02 Met
capacity_128: evaluation preserves vocabulary and counts Met

Full run history

Runs may include earlier source versions. Repeating a seed is not an independent seed check.

Seed Outcome Recorded Code fingerprint
7601 not met 2026-09-16T23:45:55.739082+00:00 1d79ecfca1099ce789f03592c9aff739fcde4cd6ade49d8b13f67976c7c8327e
7601 met 2026-09-16T23:59:00.611049+00:00 1d79ecfca1099ce789f03592c9aff739fcde4cd6ade49d8b13f67976c7c8327e
7601 met 2026-09-16T23:58:59.179846+00:00 1d79ecfca1099ce789f03592c9aff739fcde4cd6ade49d8b13f67976c7c8327e
7701 met 2026-09-17T00:00:06.247796+00:00 1d79ecfca1099ce789f03592c9aff739fcde4cd6ade49d8b13f67976c7c8327e
7702 met 2026-09-17T00:01:01.684268+00:00 1d79ecfca1099ce789f03592c9aff739fcde4cd6ade49d8b13f67976c7c8327e
7601 met 2026-09-17T01:02:49.124155+00:00 1d79ecfca1099ce789f03592c9aff739fcde4cd6ade49d8b13f67976c7c8327e
7601 not met 2026-09-18T16:42:38.697313+00:00 1d79ecfca1099ce789f03592c9aff739fcde4cd6ade49d8b13f67976c7c8327e
7601 met 2026-09-18T16:45:23.152865+00:00 1d79ecfca1099ce789f03592c9aff739fcde4cd6ade49d8b13f67976c7c8327e
7601 not met 2026-09-18T16:46:02.571060+00:00 1d79ecfca1099ce789f03592c9aff739fcde4cd6ade49d8b13f67976c7c8327e
7601 met 2026-09-18T16:48:47.910496+00:00 1d79ecfca1099ce789f03592c9aff739fcde4cd6ade49d8b13f67976c7c8327e
7601 met 2026-09-18T16:54:04.310946+00:00 1d79ecfca1099ce789f03592c9aff739fcde4cd6ade49d8b13f67976c7c8327e
7601 met 2026-09-20T02:45:24.101852+00:00 95df8f9222c5e5482e82b08d203ebe692e5e5df6fabe8e604af40072661dc89d
7601 met 2026-09-20T02:52:08.223317+00:00 95df8f9222c5e5482e82b08d203ebe692e5e5df6fabe8e604af40072661dc89d
7601 met 2026-09-21T18:46:02.628010+00:00 7c8561340407a6741d1d31fd1d771d119f4bb1291b9d9d5f1e8b05487cbce99f

Recorded results.

Run this experiment

Proof execution is optional and separate from building this site. Use a RelFlow build matching the recorded provenance; replace the wheel path below with that artifact. Run from this documentation repository’s root:

uv run --with /path/to/matching-relflow.whl python proofs/run.py P060

Or, from a prepared proof environment, run the self-contained script directly:

PYTHONPATH=proofs python proofs/calibration/categorical_prior.py

Add --accelerator gpu for CUDA or --seed 42 for another seeded experiment. --steps 2 checks execution with a short training budget; it is recorded as a smoke run.

Download the complete proof.

Training and evaluation

The proof forces internal output allocations of 2 and 128 as experimental controls; users do not configure storage. Both use the xs preset, identical 32,768-row training streams, and 400 updates. Validation has its own seed. Each held-out evaluation has 8,192 rows, with known or disjoint input IDs. Compare written probabilities with the true 0.9 prior using RMSE and expected log loss, not sample accuracy. The constant 0.9 oracle and the analytic weighted-loss optimum are controls. Gates are provisional and declared before the acceptance seed panel.

Remaining work

A ten-seed calibration panel and feature-dependent imbalanced populations remain necessary. This two-label proof does not establish rare-label recall or calibration of other tensorfield losses.

Complete experiment code

Code
"""P060: recover an imbalanced population prior with uninformative inputs."""

from collections.abc import Iterator
from functools import partial

import lightning.pytorch as lit
import numpy as np
import pyarrow as pa
from reporting import report

import relflow as rf
from relflow.helpers.resize import Resize

PROOF_ID = "P060"
BUDGET = 400


def records(*, rows: int, seed: int, novel: bool = False) -> Iterator[dict]:
    rng = np.random.default_rng(seed)
    for entity, positive in zip(rng.integers(0, 4096, rows), rng.random(rows) < 0.9, strict=True):
        yield {"x": 0.0, "entity": f"{'new' if novel else 'id'}-{entity}", "label": "yes" if positive else "no"}


def build(size: int) -> rf.Model:
    model = rf.Model.xs(
        batch_size=128,
        x=rf.Number,
        entity=rf.Category(),
        label=rf.Category(mask=True),
    )
    # Experimental storage control only: allocation is not a schema option.
    node = model.nodes["/label"]
    resize = Resize()
    resize.embedding(node.embedder.embeddings["content"], size)
    resize.linear(node.decoder.linears["content"], size)
    node.embedder.counters["content"].resize(size, resize)
    resize.attribute(node.embedder.vocab, "size", size)
    resize.commit()
    model.optimizer = rf.adamw(learning_rate=0.002)
    return model


def run(seed: int, steps: int | None, accelerator: str) -> tuple[dict, dict]:
    oracle_nll = float(-0.9 * np.log(0.9) - 0.1 * np.log(0.1))
    metrics = {"prior": 0.9, "weighted_optimum": 0.75, "oracle_nll": oracle_nll}
    checks = {}
    for size in (2, 128):
        lit.seed_everything(seed, workers=True)
        model = build(size)
        data = rf.SyntheticDataModule(
            model=model,
            train=partial(records, rows=32768, seed=seed + 1),
            validate=partial(records, rows=1024, seed=seed + 2),
            seed=seed,
        )
        trainer = lit.Trainer(
            accelerator=accelerator,
            devices=1,
            max_epochs=-1,
            max_steps=BUDGET if steps is None else min(steps, BUDGET),
            logger=False,
            enable_checkpointing=False,
            enable_progress_bar=False,
            enable_model_summary=False,
            deterministic=True,
            num_sanity_val_steps=0,
        )
        trainer.fit(model, datamodule=data)
        model.eval()
        counts = rf.Category.counts(model, "/label")
        vocabulary = rf.Category.vocabulary(model, "/entity")
        for novel in (False, True):
            rows = list(records(rows=8192, seed=seed + 3, novel=novel))
            predictions = model.predict(pa.Table.from_pylist(rows))["predictions"].to_pylist()
            values = [row["label"]["content"] for row in predictions]
            p = np.asarray(
                [item["probability"] if item["value"] == "yes" else 1 - item["probability"] for item in values]
            )
            p = p.clip(1e-7, 1 - 1e-7)
            key = f"capacity_{size}_{'novel' if novel else 'known'}"
            rmse = float(np.sqrt(np.mean((p - 0.9) ** 2)))
            excess = float(np.mean(-0.9 * np.log(p) - 0.1 * np.log1p(-p)) - oracle_nll)
            metrics[key] = {"mean_probability": float(p.mean()), "prior_rmse": rmse, "excess_nll": excess}
            checks[f"{key}: probability RMSE below 0.06"] = rmse < 0.06
            checks[f"{key}: excess expected NLL below 0.02"] = excess < 0.02
        checks[f"capacity_{size}: evaluation preserves vocabulary and counts"] = vocabulary == rf.Category.vocabulary(
            model, "/entity"
        ) and counts == rf.Category.counts(model, "/label")
    return metrics, checks


if __name__ == "__main__":
    report(PROOF_ID, run, seed=7601)