Does adding a label teach the model to predict it?

Vocabulary and OOV
Introduce new output labels to a model trained on an earlier pair of classes.

Example

x: -0.9
label: negative

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

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

Model tree with record, x, label. 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

Measure before admission, immediately after admission without training, and after continued fitting. Rehearse the old classes too.

Result

P059 · Latest full run · Passing

Seed 5901 on cpu: 13 of 13 test checks met. History: 12 of 12 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
New labels, just admitted 0.0%
New labels, after fitting 100.0%
Old labels, after fitting 100.0%

Admission creates storage; further training teaches the new distinctions. Old-label retention here depends on continued examples of the old classes.

Experiment details and code

Latest full run

Seed 5901, cpu, recorded 2026-09-21T18:48:08.610532+00:00. Outcome: met.

Source fingerprint: 38ffe90218c84ba8cebab1513ac48212fcaf995d0c93f4c0a20a333c94359451. Python 3.12.6; Torch 2.12.0.

Measurement Value
initial_steps 300
continuation_steps 400
initial_vocabulary positive, negative
final_vocabulary positive, negative, far-positive, far-negative
initial_accuracy 1
new_accuracy_before_admission 0
new_accuracy_immediately_after_admission 0
final_old_accuracy 1
final_new_accuracy 1
two_class_chance 0.5
Behavioral checks
Behavioral check Outcome
Cold prediction leaves the vocabulary empty Met
Empty output vocabulary returns no label and zero probability Met
Initial known-label accuracy reaches 0.95 Met
Initial checkpoint preserves mapping, counts, and predictions Met
Evaluation cannot admit new output labels Met
Evaluation leaves exposure counts and numerical moments frozen Met
Unadmitted labels cannot be predicted Met
Training admission appends both labels without moving old indices Met
Admission alone preserves existing learned parameter rows Met
Continued fitting learns new labels above 0.95 accuracy Met
Rehearsal retains old labels above 0.95 accuracy Met
Continued fitting preserves all admitted indices Met
Continued checkpoint preserves mapping, counts, and predictions Met

Full run history

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

Seed Outcome Recorded Code fingerprint
5901 met 2026-09-16T22:47:03.166085+00:00 d93f17e2b3ae2ad5a3ffe406dde8846968db2edf2ae76846ccad3ca7e05fe268
5901 met 2026-09-16T22:49:12.446615+00:00 d93f17e2b3ae2ad5a3ffe406dde8846968db2edf2ae76846ccad3ca7e05fe268
6501 met 2026-09-16T22:50:07.914224+00:00 d93f17e2b3ae2ad5a3ffe406dde8846968db2edf2ae76846ccad3ca7e05fe268
6502 met 2026-09-16T22:50:55.647929+00:00 d93f17e2b3ae2ad5a3ffe406dde8846968db2edf2ae76846ccad3ca7e05fe268
5901 met 2026-09-17T00:04:03.694909+00:00 d93f17e2b3ae2ad5a3ffe406dde8846968db2edf2ae76846ccad3ca7e05fe268
5901 met 2026-09-17T01:02:33.233003+00:00 d93f17e2b3ae2ad5a3ffe406dde8846968db2edf2ae76846ccad3ca7e05fe268
5901 met 2026-09-18T16:42:16.624039+00:00 d93f17e2b3ae2ad5a3ffe406dde8846968db2edf2ae76846ccad3ca7e05fe268
5901 met 2026-09-20T02:45:46.783822+00:00 2575869fcbc888e13c7c7c8158c18cd66419d3ee01e41758ef1d2c4d277c42fe
5901 met 2026-09-20T02:47:08.114849+00:00 2575869fcbc888e13c7c7c8158c18cd66419d3ee01e41758ef1d2c4d277c42fe
5901 met 2026-09-20T02:52:31.605258+00:00 2575869fcbc888e13c7c7c8158c18cd66419d3ee01e41758ef1d2c4d277c42fe
5901 met 2026-09-20T21:19:36.866317+00:00 3cd155b23a9b01d77bf89d3213228641a6deda54d68be48688382ca59961ebb9
5901 met 2026-09-21T18:48:08.610532+00:00 6d4ebea2aeb2b6390e48bcbf58076fb8bf4db9e988047deee5d2faad4458be61

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 P059

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

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

Initially, x lies within 0.15 of -1 or +1 and predicts negative or positive. New classes lie within 0.15 of -3 or +3. Each split balances its classes and shuffles them with an independent local RNG. Each fitting phase uses 4,096 training and 512 validation rows; the old and new tests each use 1,024 rows. Storage initially learns two output labels, then grows to hold four.

Training and controls

The model uses the xs preset.

Use 300 initial and 400 continuation AdamW updates at learning rate 0.002, one device, and no loader workers. Require 0.95 accuracy on the initial task, then on both old and new held-out classes after continued fitting. Chance is 0.5 on each two-class test. Before admission, new-class accuracy is necessarily zero. Validation, test, and prediction must not admit labels or change counts or numerical moments. Explicit train encoding must append labels without changing existing parameter rows; new rows, normalization, and count buffers may change. Save/load must preserve labels, counts, and predictions in both phases. Immediate accuracy after admission is diagnostic, not an acceptance gate.

Complete experiment code

Code
"""P059: vocabulary persistence and new-label admission followed by measured learning."""

from collections.abc import Iterator
from functools import partial
from pathlib import Path
from tempfile import TemporaryDirectory

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

import relflow as rf

PROOF_ID = "P059"
BUDGET = 300
CONTINUATION = 400
LABELS = ("negative", "positive", "far-negative", "far-positive")
CENTERS = (-1.0, 1.0, -3.0, 3.0)


def records(*, rows: int, seed: int, phase: str = "initial") -> Iterator[dict]:
    rng = np.random.default_rng(seed)
    indices = {"initial": (0, 1), "new": (2, 3), "mixed": (0, 1, 2, 3)}[phase]
    classes = np.resize(np.asarray(indices), rows)
    rng.shuffle(classes)
    noise = rng.uniform(-0.15, 0.15, rows)
    for index, deviation in zip(classes, noise, strict=True):
        yield {"x": float(CENTERS[index] + deviation), "label": LABELS[index]}


def build() -> rf.Model:
    return rf.Model.xs(
        batch_size=64,
        x=rf.Number,
        label=rf.Category(p_unavailable=0.0, topk=[3], mask=True),
    )


def fit(model: rf.Model, phase: str, seed: int, budget: int, accelerator: str) -> int:
    model.optimizer = rf.adamw(learning_rate=0.002)
    data = rf.SyntheticDataModule(
        model=model,
        train=partial(records, rows=4096, seed=seed + 1, phase=phase),
        validate=partial(records, rows=512, seed=seed + 2, phase=phase),
        seed=seed,
    )
    trainer = lit.Trainer(
        accelerator=accelerator,
        devices=1,
        max_epochs=-1,
        max_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()
    return trainer.global_step


def prediction(model: rf.Model, rows: list[dict]) -> list[dict]:
    output = model.predict(pa.Table.from_pylist(rows))["predictions"].to_pylist()
    return [row["label"]["content"] for row in output]


def accuracy(output: list[dict], rows: list[dict]) -> float:
    return float(np.mean([value["value"] == row["label"] for value, row in zip(output, rows, strict=True)]))


def roundtrip(model: rf.Model, rows: list[dict], path: Path) -> tuple[rf.Model, bool]:
    before = prediction(model, rows)
    vocabulary = rf.Category.vocabulary(model, "/label")
    counts = rf.Category.counts(model, "/label")
    model.save(path)
    loaded = rf.Model.load(path).to(model.device)
    loaded.eval()
    after = prediction(loaded, rows)
    matches = rf.Category.vocabulary(loaded, "/label") == vocabulary
    matches &= rf.Category.counts(loaded, "/label") == counts
    matches &= all(
        a["value"] == b["value"] and abs(a["probability"] - b["probability"]) < 1e-5
        for a, b in zip(before, after, strict=True)
    )
    return loaded, matches


def run(seed: int, steps: int | None, accelerator: str) -> tuple[dict, dict]:
    lit.seed_everything(seed, workers=True)
    model = build()
    old = list(records(rows=1024, seed=seed + 3))
    new = list(records(rows=1024, seed=seed + 4, phase="new"))
    cold = prediction(model, new)
    checks = {
        "Cold prediction leaves the vocabulary empty": rf.Category.vocabulary(model, "/label") == (),
        "Empty output vocabulary returns no label and zero probability": all(
            row["value"] is None and row["probability"] == 0.0 for row in cold
        ),
    }
    initial_steps = fit(model, "initial", seed, BUDGET if steps is None else min(steps, BUDGET), accelerator)
    initial_accuracy = accuracy(prediction(model, old), old)
    vocabulary = rf.Category.vocabulary(model, "/label")
    counts = rf.Category.counts(model, "/label")
    normalization = rf.Number.normalization(model, "/x")
    checks["Initial known-label accuracy reaches 0.95"] = initial_accuracy >= 0.95
    with TemporaryDirectory(prefix="relflow-vocabulary-") as directory:
        model, matches = roundtrip(model, old, Path(directory) / "initial.pt")
        checks["Initial checkpoint preserves mapping, counts, and predictions"] = matches
        before_admission = accuracy(prediction(model, new), new)
        source = pa.Table.from_pylist(new)
        for strata in ("validate", "test", "predict"):
            model.encode(source, strata=strata)
        checks["Evaluation cannot admit new output labels"] = rf.Category.vocabulary(model, "/label") == vocabulary
        checks["Evaluation leaves exposure counts and numerical moments frozen"] = (
            rf.Category.counts(model, "/label") == counts and rf.Number.normalization(model, "/x") == normalization
        )
        checks["Unadmitted labels cannot be predicted"] = before_admission == 0.0
        parameters = {name: value.detach().clone() for name, value in model.named_parameters()}
        admission = pa.Table.from_pylist(list(records(rows=32, seed=seed + 10, phase="new")))
        model.encode(admission, strata="train")
        admitted = rf.Category.vocabulary(model, "/label")
        checks["Training admission appends both labels without moving old indices"] = (
            admitted[:2] == vocabulary and len(admitted) == 4 and set(admitted) == set(LABELS)
        )
        checks["Admission alone preserves existing learned parameter rows"] = all(
            torch.equal(parameters[name], value[: parameters[name].shape[0]])
            for name, value in model.named_parameters()
        )
        after_admission = accuracy(prediction(model, new), new)
        continuation_steps = fit(
            model, "mixed", seed + 20, CONTINUATION if steps is None else min(steps, CONTINUATION), accelerator
        )
        final_old = accuracy(prediction(model, old), old)
        final_new = accuracy(prediction(model, new), new)
        checks["Continued fitting learns new labels above 0.95 accuracy"] = final_new >= 0.95
        checks["Rehearsal retains old labels above 0.95 accuracy"] = final_old >= 0.95
        checks["Continued fitting preserves all admitted indices"] = rf.Category.vocabulary(model, "/label") == admitted
        model, matches = roundtrip(model, old + new, Path(directory) / "continued.pt")
        checks["Continued checkpoint preserves mapping, counts, and predictions"] = matches
    return {
        "initial_steps": initial_steps,
        "continuation_steps": continuation_steps,
        "initial_vocabulary": vocabulary,
        "final_vocabulary": admitted,
        "initial_accuracy": initial_accuracy,
        "new_accuracy_before_admission": before_admission,
        "new_accuracy_immediately_after_admission": after_admission,
        "final_old_accuracy": final_old,
        "final_new_accuracy": final_new,
        "two_class_chance": 0.5,
    }, checks


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