Is an unfamiliar category the same as a null?

Vocabulary and OOV
Compare familiar labels, new labels, and missing values in a task where identity and numeric context both matter.

Example

x: 0.4
code: right
target: 2.3

The answer is shown here for explanation; it is hidden from the model when scored.

Model tree with record, x, code, target. Amber cards are hidden prediction targets. Other fields provide the input.

Model tree with record, x, code, target. Amber cards are hidden prediction targets. Other fields provide the input.

Figure 1: Amber cards are hidden prediction targets. Other fields provide the input.

Comparison

Rename new labels without changing other inputs. Train with some input labels made unavailable so the model can learn a fallback.

Result

P055 · Latest full run · Limited

Seed 5501 on cpu: 9 of 9 test checks met. This experiment demonstrates a limitation. History: 10 of 10 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
Error on paired new identities 1.511
Unavoidable error without identity 1.5

Different new labels share unavailable identity content, while null remains distinct. Other context can support a fallback, but the model cannot infer the new label’s individual effect.

Experiment details and code

Latest full run

Seed 5501, cpu, recorded 2026-09-21T18:47:47.028550+00:00. Outcome: met.

Source fingerprint: 628b126af744d38b1fbc857587343fe944aea8af6f2c878fa23a704287e40f3d. Python 3.12.6; Torch 2.12.0.

Measurement Value
steps 600
vocabulary left, right
known rmse: 0.177017; baseline_rmse: 1.92537; nrmse: 0.0919392
null rmse: 0.102182; baseline_rmse: 1.12059; nrmse: 0.0911858
unknown fallback_nrmse: 0.15782; paired_rmse: 1.51101; information_limit_rmse: 1.5; renaming_drift: 0; null_prediction_gap: 2.92265
Behavioral checks
Behavioral check Outcome
known: held-out nRMSE below 0.25 Met
null: held-out nRMSE below 0.25 Met
Unknown codes retain valued state Met
Unknown content uses the allocation-independent sentinel Met
Unknown fallback nRMSE below 0.25 Met
Paired novel identities stay near their information limit Met
Unknown spelling cannot alter predictions Met
Null remains distinct from unknown content Met
Evaluation does not admit labels or change 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
5501 met 2026-09-16T22:46:20.477375+00:00 7dbab06728b25f9c91f0f959ebc6e5a1b061a7b202d7270a598890f91f7c3cf3
5501 met 2026-09-16T22:48:37.432663+00:00 7dbab06728b25f9c91f0f959ebc6e5a1b061a7b202d7270a598890f91f7c3cf3
6501 met 2026-09-16T22:49:07.353127+00:00 7dbab06728b25f9c91f0f959ebc6e5a1b061a7b202d7270a598890f91f7c3cf3
6502 met 2026-09-16T22:49:36.534378+00:00 7dbab06728b25f9c91f0f959ebc6e5a1b061a7b202d7270a598890f91f7c3cf3
5501 met 2026-09-17T00:03:04.757493+00:00 7dbab06728b25f9c91f0f959ebc6e5a1b061a7b202d7270a598890f91f7c3cf3
5501 met 2026-09-17T01:01:35.931085+00:00 7dbab06728b25f9c91f0f959ebc6e5a1b061a7b202d7270a598890f91f7c3cf3
5501 met 2026-09-18T16:40:57.491013+00:00 7dbab06728b25f9c91f0f959ebc6e5a1b061a7b202d7270a598890f91f7c3cf3
5501 met 2026-09-20T02:47:17.945300+00:00 b11768a556c8a7670c41a37bdaac8570bb11b3861a8acc7c039a70d23bbdbde0
5501 met 2026-09-20T02:52:41.387186+00:00 b11768a556c8a7670c41a37bdaac8570bb11b3861a8acc7c039a70d23bbdbde0
5501 met 2026-09-21T18:47:47.028550+00:00 bbc66c4f70c5642249fbffed7f2cc5ccfe2024fc470292a491776c39e05fe51e

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 P055

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

PYTHONPATH=proofs python proofs/vocabulary/unknown_input_fallback.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.

Data and model

Independent rows draw x ~ Uniform(-1, 1) and a code with probabilities 0.4 left, 0.4 right, and 0.2 null. The target is 2*x + effect(code). Training/validation/test use 4,096/512/2,048 rows. At test time, two never-seen codes have opposite effects of -1.5 and +1.5. The model cannot distinguish their identities, so 2*x is the paired unknown-code conditional mean.

Training and controls

The model uses the xs preset.

Use 600 AdamW updates at learning rate 0.002. Known-code and null predictions must each achieve nRMSE below 0.25 against their training-mean baseline. Unknown predictions must approximate 2*x below 0.25 nRMSE and stay within 15% of the 1.5 RMSE information limit on paired novel identities. Renaming an unknown code cannot change predictions; replacing it with null must. All evaluation uses frozen vocabularies and normalization statistics.

Complete experiment code

Code
"""P055: known category effects, unknown-content fallback, and distinct null semantics."""

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

PROOF_ID = "P055"
BUDGET = 600
EFFECTS = {"left": -1.5, "right": 1.5, None: -3.0}


def records(*, rows: int, seed: int) -> Iterator[dict]:
    rng = np.random.default_rng(seed)
    codes = np.asarray(["left", "right", None], dtype=object)
    for x, index in zip(rng.uniform(-1, 1, rows), rng.choice(3, rows, p=[0.4, 0.4, 0.2]), strict=True):
        code = codes[index]
        yield {"x": float(x), "code": code, "target": float(2 * x + EFFECTS[code])}


def build() -> rf.Model:
    return rf.Model.xs(
        batch_size=64,
        x=rf.Number,
        code=rf.Category(p_unavailable=0.3),
        target=rf.Number(mask=True, objective="mse"),
    )


def prediction(model: rf.Model, rows: list[dict]) -> np.ndarray:
    result = model.predict(pa.Table.from_pylist(rows))["predictions"].to_pylist()
    return np.asarray([row["target"]["content"] for row in result], dtype=np.float64)


def rmse(actual: np.ndarray, predicted: np.ndarray | float) -> float:
    return float(np.sqrt(np.mean((actual - predicted) ** 2)))


def run(seed: int, steps: int | None, accelerator: str) -> tuple[dict, dict]:
    lit.seed_everything(seed, workers=True)
    model = build()
    model.optimizer = rf.adamw(learning_rate=0.002)
    data = rf.SyntheticDataModule(
        model=model,
        train=partial(records, rows=4096, seed=seed + 1),
        validate=partial(records, rows=512, 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),
        deterministic=True,
        logger=False,
        enable_checkpointing=False,
        enable_progress_bar=False,
        enable_model_summary=False,
        num_sanity_val_steps=0,
    )
    trainer.fit(model, datamodule=data)
    model.eval()
    train = list(records(rows=4096, seed=seed + 1))
    test = list(records(rows=2048, seed=seed + 3))
    vocabulary = rf.Category.vocabulary(model, "/code")
    counts = rf.Category.counts(model, "/code")
    predicted = prediction(model, test)
    metrics = {"steps": trainer.global_step, "vocabulary": vocabulary}
    checks = {}
    for name, is_null in (("known", False), ("null", True)):
        selected = np.asarray([(row["code"] is None) == is_null for row in test])
        actual = np.asarray([row["target"] for row in test])[selected]
        mean = float(np.mean([row["target"] for row in train if (row["code"] is None) == is_null]))
        baseline = rmse(actual, mean)
        metrics[name] = {
            "rmse": rmse(actual, predicted[selected]),
            "baseline_rmse": baseline,
            "nrmse": rmse(actual, predicted[selected]) / baseline,
        }
        checks[f"{name}: held-out nRMSE below 0.25"] = metrics[name]["nrmse"] < 0.25
    novel = [{**row, "code": "novel-left"} for row in test]
    renamed = [{**row, "code": "novel-right"} for row in test]
    nulls = [{**row, "code": None} for row in test]
    first = prediction(model, novel)
    second = prediction(model, renamed)
    expected = 2 * np.asarray([row["x"] for row in test])
    baseline = rmse(expected, float(np.mean([2 * row["x"] for row in train])))
    metrics["unknown"] = {
        "fallback_nrmse": rmse(expected, first) / baseline,
        "paired_rmse": rmse(np.concatenate((expected - 1.5, expected + 1.5)), np.concatenate((first, second))),
        "information_limit_rmse": 1.5,
        "renaming_drift": float(np.max(np.abs(first - second))),
        "null_prediction_gap": float(np.mean(np.abs(first - prediction(model, nulls)))),
    }
    field = model.encode(pa.Table.from_pylist(novel), strata="test")["/code"]
    checks.update(
        {
            "Unknown codes retain valued state": bool(field.state.eq(rf.Tokens.valued).all()),
            "Unknown content uses the allocation-independent sentinel": bool(field.content.eq(-1).all()),
            "Unknown fallback nRMSE below 0.25": metrics["unknown"]["fallback_nrmse"] < 0.25,
            "Paired novel identities stay near their information limit": 1.5 - 1e-6
            <= metrics["unknown"]["paired_rmse"]
            < 1.5 * 1.15,
            "Unknown spelling cannot alter predictions": metrics["unknown"]["renaming_drift"] < 1e-5,
            "Null remains distinct from unknown content": metrics["unknown"]["null_prediction_gap"] > 2.0,
            "Evaluation does not admit labels or change counts": rf.Category.vocabulary(model, "/code") == vocabulary
            and rf.Category.counts(model, "/code") == counts,
        }
    )
    return metrics, checks


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