Can hiding values teach a relationship in both directions?

Dynamic masking
Sometimes hide x or y during training, then ask the model to recover the missing value from the other.

Example

x: 0.4
y: 1.1
hide_x: false
hide_y: true

Values marked for reconstruction are hidden. During training, the selection is sampled separately for each field.

Model tree with record, x, y. Selector notes identify hidden values. Each field is sometimes hidden during training.

Model tree with record, x, y. Selector notes identify hidden values. Each field is sometimes hidden during training.

Figure 1: Selector notes identify hidden values. Each field is sometimes hidden during training.

Comparison

Score both directions on new records. Shuffle the visible value, or hide both, to remove the needed information.

Result

P051 · Latest full run · Passing

Seed 5101 on cpu: 16 of 16 test checks met. History: 8 of 8 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
Recover y from x 13.1% of baseline error
Recover x from y 11.3% of baseline error

Baseline error compares prediction error with a constant guess. 100% matches that guess; lower is better. Details specify the baseline and root-mean-square error calculation.

The model learns both directions. When both values are hidden, the original answer cannot be identified; this is a small relationship test, not general imputation evidence.

Experiment details and code

Latest full run

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

Source fingerprint: 16b1b4583e97acdd5cfca92775ce9237b56ac0a5dfe75a4bd6bf213340f6294f. Python 3.12.6; Torch 2.12.0.

Measurement Value
steps 400
directions y: rmse: 0.151371; baseline_rmse: 1.15431; nrmse: 0.131136; shuffled_nrmse: 1.40794; both_hidden_nrmse: 1.00263; hidden_value_drift: 0; sampled_fraction: 0.356445; x: rmse: 0.065431; baseline_rmse: 0.577156; nrmse: 0.113368; shuffled_nrmse: 1.38915; both_hidden_nrmse: 1.00367; hidden_value_drift: 0; sampled_fraction: 0.361328
Behavioral checks
Behavioral check Outcome
y: held-out nRMSE below 0.25 Met
y: shuffled context nRMSE above 0.85 Met
y: no context nRMSE above 0.85 Met
y: hidden values cannot affect predictions Met
y: sampling is partial Met
y: sampling repeats within an epoch Met
y: sampling changes across epochs Met
y: ordinary prediction requests no reconstruction Met
x: held-out nRMSE below 0.25 Met
x: shuffled context nRMSE above 0.85 Met
x: no context nRMSE above 0.85 Met
x: hidden values cannot affect predictions Met
x: sampling is partial Met
x: sampling repeats within an epoch Met
x: sampling changes across epochs Met
x: ordinary prediction requests no reconstruction Met

Full run history

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

Seed Outcome Recorded Code fingerprint
5101 met 2026-09-16T21:58:55.269770+00:00 4febc9e7f0e9f8fb3d61e350118c298c99068c38168e7a4b104e14bac19223dc
5101 met 2026-09-16T21:59:48.220165+00:00 4febc9e7f0e9f8fb3d61e350118c298c99068c38168e7a4b104e14bac19223dc
6101 met 2026-09-16T22:00:31.771323+00:00 4febc9e7f0e9f8fb3d61e350118c298c99068c38168e7a4b104e14bac19223dc
6102 met 2026-09-16T22:01:15.529874+00:00 4febc9e7f0e9f8fb3d61e350118c298c99068c38168e7a4b104e14bac19223dc
5101 met 2026-09-17T00:01:57.208782+00:00 4febc9e7f0e9f8fb3d61e350118c298c99068c38168e7a4b104e14bac19223dc
5101 met 2026-09-17T01:00:28.725944+00:00 4febc9e7f0e9f8fb3d61e350118c298c99068c38168e7a4b104e14bac19223dc
5101 met 2026-09-18T16:39:35.118662+00:00 4febc9e7f0e9f8fb3d61e350118c298c99068c38168e7a4b104e14bac19223dc
5101 met 2026-09-21T18:46:55.911653+00:00 d118b5ad1ac7a30d318209e62d43f913cdb52836e5dad5b4f3ec5a05978f213f

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 P051

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

PYTHONPATH=proofs python proofs/masking/sampled_reconstruction.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 records draw x ~ Uniform(-1, 1) and set y = 2*x + 0.3. Train, validation, and test contain 4,096, 512, and 2,048 independent rows. Each field has a 35% sampled reconstruction policy and a Boolean query policy. Query flags are false during fitting; inference turns on exactly one flag. Neither flag is an embedded field. No separate supervised target is added.

Training and controls

The model uses the xs preset.

Fit for 400 AdamW updates at learning rate 0.002. Evaluate the final model; no checkpoint or gate is selected using the test set. Each direction must achieve nRMSE below 0.25 relative to its training-mean baseline. Shuffling the remaining input, or hiding both fields, must give nRMSE above 0.85. Poisoning only a hidden source value must leave predictions unchanged within 1e-5. Mask diagnostics also check reproducibility, epoch variation, and the absence of sampled reconstruction requests during ordinary prediction.

Complete experiment code

Code
"""P051: sampled cross-field reconstruction with deterministic inference controls."""

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 = "P051"
BUDGET = 400


def records(*, rows: int, seed: int) -> Iterator[dict]:
    rng = np.random.default_rng(seed)
    for value in rng.uniform(-1.0, 1.0, rows):
        yield {"x": float(value), "y": float(2 * value + 0.3), "hide_x": False, "hide_y": False}


def build() -> rf.Model:
    return rf.Model.xs(
        batch_size=64,
        fields={
            name: rf.Number(
                objective="mse",
                mask=(
                    rf.Mask(rate=0.35, reconstruct=True),
                    rf.Mask(query=f"hide_{name}", reconstruct=True),
                ),
            )
            for name in ("x", "y")
        },
    )


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


def error(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))
    source = pa.Table.from_pylist(test)
    # Training-mode audits must not update the fitted model with held-out moments.
    probe = build()
    encoded = [probe.encode(source, strata="train", seed=seed, epoch=epoch) for epoch in (0, 0, 1)]
    ordinary = probe.encode(source, strata="predict")
    metrics = {"steps": trainer.global_step, "directions": {}}
    checks = {}
    order = np.random.default_rng(seed + 4).permutation(len(test))
    for target, context in (("y", "x"), ("x", "y")):
        selected = [{**row, f"hide_{target}": True} for row in test]
        actual = np.asarray([row[target] for row in test])
        baseline = error(actual, float(np.mean([row[target] for row in train])))
        predicted = prediction(model, selected, target)
        shuffled = [{**row, context: test[index][context]} for row, index in zip(selected, order, strict=True)]
        hidden = [{**row, f"hide_{context}": True} for row in selected]
        poisoned = [{**row, target: 1000.0 + index} for index, row in enumerate(selected)]
        measured = {
            "rmse": error(actual, predicted),
            "baseline_rmse": baseline,
            "nrmse": error(actual, predicted) / baseline,
            "shuffled_nrmse": error(actual, prediction(model, shuffled, target)) / baseline,
            "both_hidden_nrmse": error(actual, prediction(model, hidden, target)) / baseline,
            "hidden_value_drift": float(np.max(np.abs(predicted - prediction(model, poisoned, target)))),
        }
        masks = [batch[f"/{target}"].trainable for batch in encoded]
        measured["sampled_fraction"] = float(masks[0].float().mean())
        metrics["directions"][target] = measured
        checks[f"{target}: held-out nRMSE below 0.25"] = measured["nrmse"] < 0.25
        checks[f"{target}: shuffled context nRMSE above 0.85"] = measured["shuffled_nrmse"] > 0.85
        checks[f"{target}: no context nRMSE above 0.85"] = measured["both_hidden_nrmse"] > 0.85
        checks[f"{target}: hidden values cannot affect predictions"] = measured["hidden_value_drift"] < 1e-5
        checks[f"{target}: sampling is partial"] = 0.25 < measured["sampled_fraction"] < 0.45
        checks[f"{target}: sampling repeats within an epoch"] = bool((masks[0] == masks[1]).all())
        checks[f"{target}: sampling changes across epochs"] = bool((masks[0] != masks[2]).any())
        checks[f"{target}: ordinary prediction requests no reconstruction"] = not bool(
            ordinary[f"/{target}"].inferred.any()
        )
    return metrics, checks


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