Should a number be represented by size or percentile?

Quantile
Compare Number and Quantile on rank and amount tasks, with clean training and rare corrupt measurements.

Example

x: 1.0
rank: lower
amount: 0.24

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

Model tree with measurement, x, target. Amber cards are hidden prediction targets. Separate models predict rank or amount.

Model tree with measurement, x, target. Amber cards are hidden prediction targets. Separate models predict rank or amount.

Figure 1: Amber cards are hidden prediction targets. Separate models predict rank or amount.

Comparison

Measure overall and tail errors separately. Also compare noisy output targets learned in original units versus percentile units.

Result

P077 · Latest full run · Passing

Seed 7703 on cpu: 26 of 26 test checks met. History: 3 of 3 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
Corrupt training, Number 13.0% of baseline error
Corrupt training, Quantile 9.2% 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.

Neither representation wins every comparison. Percentiles can reduce some contamination effects while worsening tail error; changing output units can also change which answer the loss prefers.

Experiment details and code

Latest full run

Seed 7703, cpu, recorded 2026-09-22T17:15:47.662191+00:00. Outcome: met.

Source fingerprint: 7dcf83565f91769f39430ef8843eb5ae7bfa96d6e025121fa14623b0b4a420e5. Python 3.12.10; Torch 2.12.0.

Measurement Value
train_rows 8192
test_rows 4096
tail_rows 206
central_rows 3890
input_tasks rank_number_clean: steps: 400; accuracy: 0.996582; central_accuracy: 0.996401; tail_accuracy: 1; shuffled_accuracy: 0.494141; majority_accuracy: 0.505615; rank_number_contaminated: steps: 400; accuracy: 0.999268; central_accuracy: 0.999229; tail_accuracy: 1; shuffled_accuracy: 0.495361; majority_accuracy: 0.505615; rank_quantile_clean: steps: 400; accuracy: 0.997314; central_accuracy: 0.997172; tail_accuracy: 1; shuffled_accuracy: 0.493896; majority_accuracy: 0.505615; rank_quantile_contaminated: steps: 400; accuracy: 0.995117; central_accuracy: 0.994859; tail_accuracy: 1; shuffled_accuracy: 0.493164; majority_accuracy: 0.505615; amount_number_clean: steps: 400; rmse: 0.117828; baseline_rmse: 2.74818; normalized_rmse: 0.0428749; tail_rmse: 0.116111; central_rmse: 0.117918; shuffled_normalized_rmse: 1.4018; amount_number_contaminated: steps: 400; rmse: 0.35646; baseline_rmse: 2.74818; normalized_rmse: 0.129708; tail_rmse: 0.322806; central_rmse: 0.358154; shuffled_normalized_rmse: 1.37233; amount_quantile_clean: steps: 400; rmse: 0.181971; baseline_rmse: 2.74818; normalized_rmse: 0.0662149; tail_rmse: 0.146192; central_rmse: 0.183671; shuffled_normalized_rmse: 1.38979; amount_quantile_contaminated: steps: 400; rmse: 0.253531; baseline_rmse: 2.74818; normalized_rmse: 0.092254; tail_rmse: 0.460424; central_rmse: 0.237604; shuffled_normalized_rmse: 1.42336
noisy_targets number_mse: steps: 400; group_predictions: 0.697812, 1.29876, 2.38087; mean_relative_optimum_error: 0.0900276; source_rmse: 1.69247; source_mae: 0.944434; percentile_mse: 0.0818515; percentile_mae: 0.230591; optimum_source_rmse: 1.6906; optimum_source_mae: 0.949629; number_mae: steps: 400; group_predictions: 0.588377, 0.922157, 1.50175; mean_relative_optimum_error: 0.065639; source_rmse: 1.7786; source_mae: 0.878548; percentile_mse: 0.0685815; percentile_mae: 0.217197; optimum_source_rmse: 1.75125; optimum_source_mae: 0.875889; quantile_mse: steps: 400; group_predictions: 0.663232, 0.978057, 1.4397; mean_relative_optimum_error: 0.0224914; source_rmse: 1.7815; source_mae: 0.880506; percentile_mse: 0.067013; percentile_mae: 0.217925; optimum_source_rmse: 1.77661; optimum_source_mae: 0.880715; quantile_mae: steps: 400; group_predictions: 0.59128, 0.942851, 1.52566; mean_relative_optimum_error: 0.0523108; source_rmse: 1.7732; source_mae: 0.877443; percentile_mse: 0.068416; percentile_mae: 0.216783; optimum_source_rmse: 1.75125; optimum_source_mae: 0.875889
population_optima number_mse: 0.83527, 1.37713, 2.2705; number_mae: 0.606531, 1, 1.64872; quantile_mse: 0.68667, 1, 1.4563; quantile_mae: 0.606531, 1, 1.64872
learned_mse_separation_fraction 0.229601
Behavioral checks
Behavioral check Outcome
rank_number_clean: accuracy reaches 0.90 Met
rank_number_clean: shuffled accuracy at most 0.60 Met
rank_number_clean: finite measurements Met
rank_number_contaminated: finite measurements Met
rank_quantile_clean: accuracy reaches 0.90 Met
rank_quantile_clean: shuffled accuracy at most 0.60 Met
rank_quantile_clean: finite measurements Met
rank_quantile_contaminated: finite measurements Met
amount_number_clean: normalized RMSE at most 0.35 Met
amount_number_clean: shuffled normalized RMSE at least 0.90 Met
amount_number_clean: finite measurements Met
amount_number_contaminated: finite measurements Met
amount_quantile_clean: normalized RMSE at most 0.35 Met
amount_quantile_clean: shuffled normalized RMSE at least 0.90 Met
amount_quantile_clean: finite measurements Met
amount_quantile_contaminated: finite measurements Met
number_mse: predictions are finite Met
number_mse: relative optimum error at most 0.25 Met
number_mae: predictions are finite Met
number_mae: relative optimum error at most 0.25 Met
quantile_mse: predictions are finite Met
quantile_mse: relative optimum error at most 0.25 Met
quantile_mae: predictions are finite Met
quantile_mae: relative optimum error at most 0.25 Met
Percentile and source-unit MSE population optima differ materially Met
Learned MSE predictions differ by at least 10 percent of source means Met

Full run history

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

Seed Outcome Recorded Code fingerprint
7701 met 2026-09-22T17:14:07.869481+00:00 9f47f3927cb8d7d7f12931d1608ef14cdda83a89312d3421c635cca9f03e198e
7702 met 2026-09-22T17:14:56.788474+00:00 9f47f3927cb8d7d7f12931d1608ef14cdda83a89312d3421c635cca9f03e198e
7703 met 2026-09-22T17:15:47.662191+00:00 9f47f3927cb8d7d7f12931d1608ef14cdda83a89312d3421c635cca9f03e198e

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 P077

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

PYTHONPATH=proofs python proofs/quantile/representation_tradeoffs.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.

Input representation experiment

Draw u ~ Uniform(0, 1) and x = exp(4u) - 1. The hidden rank label is upper when u exceeds 0.5. The numeric target is 0.2*x + Normal(0, 0.1). Only x is visible, and each task gets its own model. In the contaminated arm, multiply 2% of training x values by 100 while retaining their original targets. This models input measurement errors, not a change in the true rule. Validation and testing stay clean, with paired observations across all arms.

Test rows with u > 0.95 receive separate tail metrics.

Noisy-target experiment and analytical baselines

Three equally likely Enum groups generate target = exp(mu[group] + 0.8*z), with z standard normal and mu equal to -0.5, 0, or 0.5. Neither z nor mu is a model input. The source-unit mean is exp(mu + 0.8**2/2) and the median is exp(mu). The population CDF F is the equal mixture of these three lognormal distributions. For percentile MSE, the optimum is F^-1(E[F(target)|group]). The expectation has an analytical normal-CDF expression; bisection computes its inverse. These evaluator-only oracles never fit a test distribution.

Four matched models compare Number/Quantile targets under MSE/MAE. Report source-unit MAE/RMSE, percentile MAE/MSE, and distance to the corresponding population optimum. Quantile learns an approximate empirical training CDF, so the population optimum is a reference rather than an exact sketch optimum.

Every arm has 8,192 training rows, 1,024 validation rows, 400 AdamW updates at learning rate 0.002, and batch size 128. Evaluation uses 4,096 fresh rows. Architecture width and optimizer budget match; datatype parameter counts may differ. Training, validation, and testing use seed*100 plus distinct offsets.

Gates are fixed before execution: clean rank accuracy >=0.90; clean amount nRMSE <=0.35; input shuffling leaves rank accuracy <=0.60 and amount nRMSE >=0.90. Each noisy-target model must approach its own optimum with mean relative error <=0.25. Contamination comparisons and tail differences are diagnostics, not gates requiring Quantile superiority. The learned Number-MSE predictions must exceed Quantile-MSE by an average of at least 10% of the conditional source means, establishing a behavioral distinction in addition to the analytical one.

Complete experiment code

Code
"""P077: compare numerical representations and the quantities their objectives estimate."""

from collections.abc import Iterator
from functools import partial

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 = "P077"
BUDGET = 400
TRAIN_ROWS = 8192
TEST_ROWS = 4096
GROUPS = ("amber", "birch", "cove")
LOCATIONS = np.array([-0.5, 0.0, 0.5])
SIGMA = 0.8


def records(*, rows: int, seed: int, contaminated: bool = False) -> Iterator[dict]:
    rng = np.random.default_rng(seed)
    u = rng.uniform(0, 1, rows)
    x = np.expm1(4 * u)
    amount = 0.2 * x + rng.normal(0, 0.1, rows)
    visible = x.copy()
    if contaminated:
        visible[rng.random(rows) < 0.02] *= 100
    for index in range(rows):
        yield {
            "x": float(visible[index]),
            "rank": "upper" if u[index] > 0.5 else "lower",
            "amount": float(amount[index]),
        }


def build(representation: str, task: str, objective: str = "mse") -> rf.Model:
    scalar = rf.Quantile if representation == "quantile" else rf.Number
    if task == "noisy":
        model = rf.Model.xs(
            batch_size=128,
            group=rf.Enum(values=GROUPS, p_unavailable=0.0),
            target=scalar(objective=objective, mask=True),
        )
    else:
        target = (
            rf.Enum(values=("lower", "upper"), p_unavailable=0.0, mask=True)
            if task == "rank"
            else rf.Number(objective="mse", mask=True)
        )
        model = rf.Model.xs(batch_size=128, x=scalar, fields={task: target})
    model.optimizer = rf.adamw(learning_rate=0.002)
    return model


def fit(model: rf.Model, train, validate, seed: int, steps: int | None, accelerator: str) -> int:
    data = rf.SyntheticDataModule(model=model, train=train, validate=validate, 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()
    return trainer.global_step


def predict(model: rf.Model, rows: list[dict], task: str) -> np.ndarray:
    visible = [{"group": row["group"]} if task == "noisy" else {"x": row["x"]} for row in rows]
    predictions = model.predict(pa.Table.from_pylist(visible))["predictions"].to_pylist()
    name = "target" if task == "noisy" else task
    content = [row[name]["content"] for row in predictions]
    if task == "rank":
        return np.asarray([row["value"] == "upper" for row in content])
    return np.asarray(content, dtype=float)


def noisy_records(*, rows: int, seed: int) -> Iterator[dict]:
    rng = np.random.default_rng(seed)
    group = np.resize(np.arange(len(GROUPS)), rows)
    rng.shuffle(group)
    target = np.exp(LOCATIONS[group] + SIGMA * rng.standard_normal(rows))
    for index in range(rows):
        yield {"group": GROUPS[group[index]], "target": float(target[index])}


def cdf(values: np.ndarray) -> np.ndarray:
    """Analytical CDF of the equally weighted three-lognormal population mixture."""
    scaled = (np.log(np.maximum(values, 1e-300))[..., None] - LOCATIONS) / SIGMA
    return torch.special.ndtr(torch.from_numpy(scaled)).mean(dim=-1).numpy()


def inverse(percentiles: np.ndarray) -> np.ndarray:
    lower = np.zeros_like(percentiles)
    upper = np.full_like(percentiles, np.exp(LOCATIONS.max() + 10 * SIGMA))
    for _ in range(64):
        midpoint = (lower + upper) / 2
        below = cdf(midpoint) < percentiles
        lower = np.where(below, midpoint, lower)
        upper = np.where(below, upper, midpoint)
    return (lower + upper) / 2


def optima() -> dict[str, np.ndarray]:
    differences = (LOCATIONS[:, None] - LOCATIONS[None, :]) / (np.sqrt(2) * SIGMA)
    mean_percentiles = torch.special.ndtr(torch.from_numpy(differences)).mean(dim=1).numpy()
    return {
        "number_mse": np.exp(LOCATIONS + SIGMA**2 / 2),
        "number_mae": np.exp(LOCATIONS),
        "quantile_mse": inverse(mean_percentiles),
        "quantile_mae": np.exp(LOCATIONS),
    }


def run(seed: int, steps: int | None, accelerator: str) -> tuple[dict, dict]:
    stream = seed * 100
    test = list(records(rows=TEST_ROWS, seed=stream + 3))
    tail = np.asarray([row["x"] > np.expm1(4 * 0.95) for row in test])
    permutation = np.random.default_rng(stream + 4).permutation(TEST_ROWS)
    shuffled = [{**row, "x": test[index]["x"]} for row, index in zip(test, permutation, strict=True)]
    training = list(records(rows=TRAIN_ROWS, seed=stream + 1))
    mean = float(np.mean([row["amount"] for row in training]))
    target = np.asarray([row["amount"] for row in test])
    baseline = float(np.sqrt(np.mean((target - mean) ** 2)))
    labels = np.asarray([row["rank"] == "upper" for row in test])
    metrics = {
        "train_rows": TRAIN_ROWS,
        "test_rows": TEST_ROWS,
        "tail_rows": int(tail.sum()),
        "central_rows": int((~tail).sum()),
        "input_tasks": {},
        "noisy_targets": {},
    }
    checks = {}
    for task in ("rank", "amount"):
        for representation in ("number", "quantile"):
            for contaminated in (False, True):
                lit.seed_everything(seed, workers=True)
                model = build(representation, task)
                updates = fit(
                    model,
                    partial(records, rows=TRAIN_ROWS, seed=stream + 1, contaminated=contaminated),
                    partial(records, rows=1024, seed=stream + 2),
                    seed,
                    steps,
                    accelerator,
                )
                output = predict(model, test, task)
                control = predict(model, shuffled, task)
                key = f"{task}_{representation}_{'contaminated' if contaminated else 'clean'}"
                if task == "rank":
                    result = {
                        "steps": updates,
                        "accuracy": float(np.mean(output == labels)),
                        "central_accuracy": float(np.mean(output[~tail] == labels[~tail])),
                        "tail_accuracy": float(np.mean(output[tail] == labels[tail])),
                        "shuffled_accuracy": float(np.mean(control == labels)),
                        "majority_accuracy": float(max(labels.mean(), 1 - labels.mean())),
                    }
                    if not contaminated:
                        checks[f"{key}: accuracy reaches 0.90"] = result["accuracy"] >= 0.90
                        checks[f"{key}: shuffled accuracy at most 0.60"] = result["shuffled_accuracy"] <= 0.60
                else:
                    result = {
                        "steps": updates,
                        "rmse": float(np.sqrt(np.mean((output - target) ** 2))),
                        "baseline_rmse": baseline,
                        "normalized_rmse": float(np.sqrt(np.mean((output - target) ** 2)) / baseline),
                        "tail_rmse": float(np.sqrt(np.mean((output[tail] - target[tail]) ** 2))),
                        "central_rmse": float(np.sqrt(np.mean((output[~tail] - target[~tail]) ** 2))),
                        "shuffled_normalized_rmse": float(np.sqrt(np.mean((control - target) ** 2)) / baseline),
                    }
                    if not contaminated:
                        checks[f"{key}: normalized RMSE at most 0.35"] = result["normalized_rmse"] <= 0.35
                        checks[f"{key}: shuffled normalized RMSE at least 0.90"] = (
                            result["shuffled_normalized_rmse"] >= 0.90
                        )
                metrics["input_tasks"][key] = result
                checks[f"{key}: finite measurements"] = bool(np.isfinite(list(result.values())).all())
    noisy = list(noisy_records(rows=TEST_ROWS, seed=stream + 13))
    groups = np.asarray([GROUPS.index(row["group"]) for row in noisy])
    observed = np.asarray([row["target"] for row in noisy])
    targets = optima()
    metrics["population_optima"] = {key: values.tolist() for key, values in targets.items()}
    for representation in ("number", "quantile"):
        for objective in ("mse", "mae"):
            lit.seed_everything(seed, workers=True)
            model = build(representation, "noisy", objective)
            updates = fit(
                model,
                partial(noisy_records, rows=TRAIN_ROWS, seed=stream + 11),
                partial(noisy_records, rows=1024, seed=stream + 12),
                seed,
                steps,
                accelerator,
            )
            output = predict(model, noisy, "noisy")
            key = f"{representation}_{objective}"
            optimum = targets[key][groups]
            group_predictions = np.asarray([output[groups == group].mean() for group in range(len(GROUPS))])
            relative = float(np.mean(np.abs(group_predictions - targets[key]) / targets[key]))
            rank_error = cdf(output) - cdf(observed)
            result = {
                "steps": updates,
                "group_predictions": group_predictions.tolist(),
                "mean_relative_optimum_error": relative,
                "source_rmse": float(np.sqrt(np.mean((output - observed) ** 2))),
                "source_mae": float(np.mean(np.abs(output - observed))),
                "percentile_mse": float(np.mean(rank_error**2)),
                "percentile_mae": float(np.mean(np.abs(rank_error))),
                "optimum_source_rmse": float(np.sqrt(np.mean((optimum - observed) ** 2))),
                "optimum_source_mae": float(np.mean(np.abs(optimum - observed))),
            }
            metrics["noisy_targets"][key] = result
            checks[f"{key}: predictions are finite"] = bool(np.isfinite(output).all())
            checks[f"{key}: relative optimum error at most 0.25"] = relative <= 0.25
    checks["Percentile and source-unit MSE population optima differ materially"] = bool(
        np.mean(np.abs(targets["number_mse"] - targets["quantile_mse"]) / targets["number_mse"]) > 0.10
    )
    number_mse = np.asarray(metrics["noisy_targets"]["number_mse"]["group_predictions"])
    quantile_mse = np.asarray(metrics["noisy_targets"]["quantile_mse"]["group_predictions"])
    separation = float(np.mean((number_mse - quantile_mse) / targets["number_mse"]))
    metrics["learned_mse_separation_fraction"] = separation
    checks["Learned MSE predictions differ by at least 10 percent of source means"] = separation >= 0.10
    return metrics, checks


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