Tutorial: Paper 1 – Representation Invariance¶
Source paper: Zheng, Low & Wang (2026), Regime Labels Are Not Representation-Invariant.
This tutorial shows how to use mrv.invariance.rep_invariance_validator()
to test whether a regime model’s labels are stable across different feature
representations. The core question is: if a practitioner had chosen different
features (volume vs. drawdown, VaR vs. CVaR), would the model assign the same
regime labels to each day?
Theory background¶
Paper 1 defines representation invariance as the property that the label assignment \(\ell(\mathbf{x})\) for an observation \(\mathbf{x}\) does not change when \(\mathbf{x}\) is drawn from an admissible alternative specification \(\phi \in \Phi\). The validator measures this empirically with two layers: partition stability, quantified by the mean off-diagonal Adjusted Rand Index (ARI) across specification pairs, and risk-ordering stability, quantified by a rank-aligned Spearman correlation against a risk proxy.
A model passes the partition layer when the mean ARI meets the library threshold, and passes the ordering layer when the mean Spearman correlation meets its threshold, even if the categorical labels themselves differ.
Step 1: prepare labels¶
The validator expects a dict mapping asset names to dicts of label arrays. Labels are integers (regime identifiers); length must match across specifications for a given asset.
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n = 300
K = 3
# True latent regime (Markov chain)
true_labels = np.zeros(n, dtype=int)
state = 0
trans = {0: [0.90, 0.07, 0.03],
1: [0.10, 0.80, 0.10],
2: [0.05, 0.15, 0.80]}
for i in range(1, n):
state = rng.choice(K, p=trans[state])
true_labels[i] = state
def perturb(labels, noise_p, seed):
rng_l = np.random.default_rng(seed)
out = labels.copy()
mask = rng_l.random(len(out)) < noise_p
out[mask] = rng_l.integers(0, K, mask.sum())
return out
# Three specifications with increasing noise
labels_a = perturb(true_labels, 0.03, 1) # vol + dd + var
labels_b = perturb(true_labels, 0.07, 2) # vol + var + CVaR
labels_c = perturb(true_labels, 0.11, 3) # vol + skew only
Step 2: run the validator¶
from mrv.invariance import rep_invariance_validator
result = rep_invariance_validator(
model_fn=None, # not needed: labels are pre-computed
admissible_class={
"vol+dd+var": labels_a,
"vol+var+cvar": labels_b,
"vol+skew": labels_c,
},
returns=None, # ordering check skipped when None
K=K,
)
result.summary()
print("ARI per pair:", result.ari_per_pair)
print("Mean ARI: ", result.mean_ari)
print("Pass (ARI >= 0.65):", result.partition_pass)
The 1/K null¶
Paper 1 (Supplement, around Table 3) shows that a random relabelling baseline achieves ARI approximately \(1/K\). The validator exposes this null so you can report the margin above chance:
print("1/K null:", result.null_1_over_K)
print("Margin above null:", result.mean_ari - result.null_1_over_K)
Ordering consistency¶
When a risk proxy (e.g., rolling volatility) is available, the validator also checks whether the ordinal risk ordering of regimes is consistent across specifications. This corresponds to the ordering null reported in Paper 1 Table 3.
dates = pd.bdate_range("2023-01-02", periods=n)
ret = rng.normal(0, 0.01, n)
risk_proxy = pd.Series(ret, index=dates).rolling(20).std().bfill().values
result2 = rep_invariance_validator(
model_fn=None,
admissible_class={
"vol+dd+var": labels_a,
"vol+var+cvar": labels_b,
},
returns=risk_proxy,
K=K,
)
print("Ordering pass:", result2.ordering_pass)
print("Ordering per pair:", result2.ordering_per_pair)
Interpreting results¶
Field |
Interpretation |
|---|---|
|
Average ARI across all specification pairs. >= 0.65 is the Paper 1 threshold. |
|
True if mean_ari >= ARI_THRESHOLD (0.65). |
|
True if Spearman >= 0.85 for all pairs. |
|
Expected ARI under random relabelling (baseline = 1/K). |
See also¶
mrv.invariance.rep_invariance_validator()– full API referencemrv.invariance.RepInvarianceResult– result object fieldsTutorial: Paper 2 – Resolution Invariance – resolution invariance in detail