Files
lob-latent-regimes/Experiments/v3.py
T
PRAKUL HIREMATHandGitHub 76fa592ea0 Add v3 of latent micro-regime detection algorithm
Introduced a new version of the latent micro-regime detection algorithm with a focus on pre-transition instability detection using HMM posterior-based signals. This version includes detailed feature engineering, model fitting, and visualization enhancements.
2026-04-10 17:52:33 +05:30

639 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ============================================================
# Latent Micro-Regimes in Limit Order Books:
# Identification and Early Detection — v3
# ─────────────────────────────────────────
# KEY UPGRADE over v2:
# Hard regime-switch detection → Pre-transition instability
# detection via HMM posterior-based composite signal.
#
# Signal components (all from posterior probabilities):
# H_t = Shannon entropy of p(z | x_t) [primary]
# U_t = 1 - max_z p(z | x_t) [uncertainty]
# TI_t = ||p(z|x_t) - p(z|x_{t-1})||_1 [transition intensity]
# PS_t = pre-stress state posterior (regime 1) [regime-specific]
#
# Composite score = weighted average → adaptive percentile threshold
# + minimum-gap deduplication → sparse, meaningful signals τ
#
# Evaluation pipeline (UNCHANGED from v2):
# - leakage-free stress events
# - lead-time Δ = σ τ
# - bootstrap CI + MannWhitney
# - baselines (imbalance + volatility) with same dedup
# ============================================================
# !pip install hmmlearn scikit-learn scipy numpy pandas matplotlib
import warnings
warnings.filterwarnings("ignore")
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
from scipy.stats import gaussian_kde
from sklearn.preprocessing import StandardScaler
from hmmlearn.hmm import GaussianHMM
# ─────────────────────────────────────────
# 0. Global Configuration
# ─────────────────────────────────────────
SEED = 42
T = 12_000
N_REGIMES = 3
MAX_LAG = 60 # evaluation window (timesteps)
FW_WINDOW = 20 # forward window for stress label
STRESS_PCT = 95 # percentile for stress threshold
N_BOOT = 2_000
MIN_GAP = 20 # min timesteps between two signals (dedup)
PENALTY = -MAX_LAG
# Composite signal weights
W_ENTROPY = 0.40
W_UNCERT = 0.25
W_TRANS = 0.20
W_PRESTRESS = 0.15
# Detection threshold: fire when composite > this percentile
SIGNAL_PCT = 82
np.random.seed(SEED)
# ─────────────────────────────────────────
# 1. Structured Latent Regime Generator
# Regime 0 = Normal (baseline, calm)
# Regime 1 = Pre-stress build-up ← harbinger state
# Regime 2 = Crisis / stress peak
#
# Causal chain: 0 → 1 → 2 → (0 or 1)
# ─────────────────────────────────────────
REGIME_PARAMS = {
0: dict(sp_mu=1.5, sp_sig=0.25, dp_mu=120, dp_sig=10,
ib_mu=0.00, ib_sig=0.07, vb=0.35),
1: dict(sp_mu=3.2, sp_sig=0.55, dp_mu= 88, dp_sig=16,
ib_mu=0.22, ib_sig=0.13, vb=1.10),
2: dict(sp_mu=7.5, sp_sig=1.20, dp_mu= 42, dp_sig=22,
ib_mu=0.48, ib_sig=0.22, vb=2.90),
}
def build_causal_transition_matrix() -> np.ndarray:
P = np.array([
[0.970, 0.028, 0.002],
[0.060, 0.900, 0.040],
[0.100, 0.150, 0.750],
])
return P / P.sum(axis=1, keepdims=True)
def simulate_latent_chain(T, P, pi0, rng):
K = P.shape[0]
Z = np.empty(T, dtype=int)
Z[0] = rng.choice(K, p=pi0)
for t in range(1, T):
Z[t] = rng.choice(K, p=P[Z[t - 1]])
return Z
def generate_lob_data(T, rng):
P = build_causal_transition_matrix()
pi0 = np.array([0.85, 0.12, 0.03])
Z = simulate_latent_chain(T, P, pi0, rng)
spread = np.zeros(T)
depth = np.zeros(T)
imbalance = np.zeros(T)
hawkes = 0.0
decay = 0.88
for t in range(T):
p = REGIME_PARAMS[Z[t]]
hawkes = hawkes * decay
eps = rng.normal(0, p['sp_sig'])
spread[t] = np.exp(np.log(p['sp_mu']) + 0.12 * hawkes + eps)
if spread[t] > np.exp(np.log(p['sp_mu']) + p['sp_sig']):
hawkes += 0.25
phi = 0.93
depth[0] = REGIME_PARAMS[Z[0]]['dp_mu']
for t in range(1, T):
p = REGIME_PARAMS[Z[t]]
depth[t] = phi * depth[t-1] + (1-phi) * p['dp_mu'] + rng.normal(0, p['dp_sig'])
depth = np.clip(depth, 5, None)
for t in range(T):
p = REGIME_PARAMS[Z[t]]
imbalance[t] = np.clip(rng.normal(p['ib_mu'], p['ib_sig']), -1, 1)
roll_vol = pd.Series(spread).pct_change().rolling(20).std().fillna(0).values
ofi = imbalance * np.abs(np.diff(spread, prepend=spread[0]))
X = np.column_stack([spread, depth, imbalance, roll_vol, ofi])
return X, Z
# ─────────────────────────────────────────
# 2. Feature Engineering & Normalisation
# ─────────────────────────────────────────
def engineer_features(X_raw):
spread = X_raw[:, 0]
depth = X_raw[:, 1]
imbalance = X_raw[:, 2]
roll_vol = X_raw[:, 3]
ofi = X_raw[:, 4]
sd_ratio = spread / (depth + 1e-6)
abs_imb = np.abs(imbalance)
cum_ofi = pd.Series(ofi).rolling(50, min_periods=1).mean().values
roll_depth = pd.Series(depth).rolling(20).mean().fillna(method='bfill').values
X_full = np.column_stack([
spread, depth, imbalance, roll_vol, ofi,
sd_ratio, abs_imb, cum_ofi, roll_depth
])
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_full)
return X_scaled, scaler
# ─────────────────────────────────────────
# 3. HMM Fitting
# ─────────────────────────────────────────
def fit_hmm(X, n_components=N_REGIMES, n_restarts=10, rng_seed=SEED):
best_score, best_model = -np.inf, None
for k in range(n_restarts):
model = GaussianHMM(
n_components = n_components,
covariance_type = "full",
n_iter = 300,
tol = 1e-6,
random_state = rng_seed + k,
init_params = "stmc",
params = "stmc",
)
try:
model.fit(X)
score = model.score(X)
if score > best_score:
best_score, best_model = score, model
except Exception:
continue
if best_model is None:
raise RuntimeError("HMM fitting failed.")
return best_model
# ─────────────────────────────────────────
# 4. Stress Event Definition (no leakage)
# ─────────────────────────────────────────
def define_stress_events(X_raw, fw=FW_WINDOW, pct=STRESS_PCT):
spread = X_raw[:, 0]
threshold = np.percentile(spread, pct)
sigma = [t for t in range(len(spread) - fw)
if np.mean(spread[t+1:t+fw+1]) > threshold]
return np.array(sigma, dtype=int)
# ─────────────────────────────────────────
# 5. Posterior-Based Signal Computation
# *** CORE UPGRADE ***
#
# Four posterior-derived measures fused into a composite score.
# Signals fire when the composite exceeds an adaptive threshold.
# ─────────────────────────────────────────
def smooth_posterior(posterior, window=5):
"""Causal rolling average to reduce HMM jitter (no look-ahead)."""
return pd.DataFrame(posterior).rolling(window, min_periods=1).mean().values
def entropy_signal(post):
"""H_t = -∑ p_k log p_k (high = uncertain = pre-transition)"""
eps = 1e-12
return -np.sum(post * np.log(post + eps), axis=1)
def uncertainty_signal(post):
"""U_t = 1 - max_k p_k (high = diffuse posterior)"""
return 1.0 - post.max(axis=1)
def transition_intensity_signal(post):
"""TI_t = L1 distance between consecutive posteriors."""
diff = np.abs(np.diff(post, axis=0))
ti = diff.sum(axis=1)
return np.concatenate([[0.0], ti])
def prestress_posterior_signal(post, model):
"""PS_t = posterior mass on the pre-stress (intermediate) HMM state."""
means_raw = model.means_[:, 0] # spread dimension
state_rank = np.argsort(means_raw)
prestress_id = state_rank[1] # intermediate spread state
return post[:, prestress_id]
def build_composite_score(post, model,
w_h=W_ENTROPY, w_u=W_UNCERT,
w_ti=W_TRANS, w_ps=W_PRESTRESS):
"""
Weighted composite of four posterior-derived instability signals.
Each component is min-max normalised before weighting so that
differences in scale do not bias the fusion.
"""
H = entropy_signal(post)
U = uncertainty_signal(post)
TI = transition_intensity_signal(post)
PS = prestress_posterior_signal(post, model)
def _norm(x):
lo, hi = x.min(), x.max()
return (x - lo) / (hi - lo + 1e-12)
score = (w_h * _norm(H) +
w_u * _norm(U) +
w_ti * _norm(TI) +
w_ps * _norm(PS))
return score, H, U, TI, PS
def deduplicate(indices, min_gap=MIN_GAP):
"""Keep only the first index in each cluster within min_gap."""
if len(indices) == 0:
return indices
out = [indices[0]]
for idx in indices[1:]:
if idx - out[-1] >= min_gap:
out.append(idx)
return np.array(out, dtype=int)
def model_signals(model, X_scaled,
smooth_win=5,
signal_pct=SIGNAL_PCT,
min_gap=MIN_GAP):
"""
Generate early-warning signals τ from the composite instability score.
Steps:
1. Compute smoothed posterior (causal rolling mean)
2. Build composite score from 4 posterior-derived measures
3. Threshold at adaptive percentile (signal_pct)
4. Deduplicate to enforce minimum gap
Returns τ (signal times), composite score, and component signals.
"""
posterior = model.predict_proba(X_scaled)
post_smooth = smooth_posterior(posterior, window=smooth_win)
score, H, U, TI, PS = build_composite_score(post_smooth, model)
threshold = np.percentile(score, signal_pct)
raw = np.where(score > threshold)[0]
tau = deduplicate(raw, min_gap=min_gap)
return tau, score, H, U, TI, PS
def imbalance_baseline(X_raw, pct=90, min_gap=MIN_GAP):
imb = np.abs(X_raw[:, 2])
threshold = np.percentile(imb, pct)
raw = np.where(imb > threshold)[0]
return deduplicate(raw, min_gap=min_gap)
def volatility_baseline(X_raw, pct=90, min_gap=MIN_GAP):
roll_vol = X_raw[:, 3]
threshold = np.percentile(roll_vol, pct)
raw = np.where(roll_vol > threshold)[0]
return deduplicate(raw, min_gap=min_gap)
# ─────────────────────────────────────────
# 6. Lead-Time Evaluation (UNCHANGED)
# ─────────────────────────────────────────
def compute_lead_times(tau, sigma, max_lag=MAX_LAG):
deltas = np.empty(len(tau), dtype=float)
for i, t in enumerate(tau):
cands = sigma[(sigma > t) & (sigma <= t + max_lag)]
deltas[i] = (cands[0] - t) if len(cands) > 0 else PENALTY
return deltas
def evaluation_metrics(deltas):
valid = deltas > 0
return dict(
mean_delta = float(np.mean(deltas)),
pct_early = float(np.mean(valid)),
mean_early = float(np.mean(deltas[valid])) if valid.any() else 0.0,
std_delta = float(np.std(deltas)),
n_tau = int(len(deltas)),
n_early = int(valid.sum()),
)
# ─────────────────────────────────────────
# 7. Bootstrap CI + MannWhitney (UNCHANGED)
# ─────────────────────────────────────────
def bootstrap_ci(deltas, stat_fn=np.mean, n_boot=N_BOOT, alpha=0.05, seed=SEED):
rng = np.random.default_rng(seed)
boot = np.array([stat_fn(rng.choice(deltas, size=len(deltas), replace=True))
for _ in range(n_boot)])
return float(np.percentile(boot, 100*alpha/2)), float(np.percentile(boot, 100*(1-alpha/2)))
def mannwhitney_test(a, b):
return stats.mannwhitneyu(a, b, alternative="two-sided")
# ─────────────────────────────────────────
# 8. Visualisation
# ─────────────────────────────────────────
PALETTE = {
"Model" : "#2C6FAC",
"Imbalance" : "#D94F3D",
"Volatility": "#5AAE61",
}
plt.rcParams.update({
"font.family" : "serif",
"font.size" : 11,
"axes.spines.top" : False,
"axes.spines.right": False,
"axes.linewidth" : 0.8,
"figure.dpi" : 150,
})
def plot_composite_signal(X_raw, Z_true, score, H, U, TI, tau_model,
sigma, n_show=3000):
"""
Five-panel diagnostic:
1. Spread + true regime shading
2. Composite instability score + threshold + signal fires
3. Entropy H_t
4. Uncertainty U_t + Transition intensity TI_t
5. Pre-stress posterior PS_t
"""
t_end = min(n_show, len(Z_true))
t_ax = np.arange(t_end)
spread = X_raw[:t_end, 0]
tau_vis = tau_model[tau_model < t_end]
sigma_vis = sigma[sigma < t_end]
regime_colors = {0: "#DDEEFF", 1: "#FFF3CD", 2: "#FFDDDD"}
fig, axes = plt.subplots(5, 1, figsize=(13, 13), sharex=True,
gridspec_kw={"height_ratios": [2, 2.5, 1.5, 1.5, 1.5]})
fig.suptitle("Posterior-Based Pre-Transition Instability Detection\n"
"Latent Micro-Regimes in Limit Order Books (v3)",
fontsize=13, y=1.01, fontweight="bold")
# ── Panel 1: Spread + true regime shading ──
ax = axes[0]
for k, c in regime_colors.items():
ax.fill_between(t_ax, 0, spread.max()*1.1,
where=Z_true[:t_end] == k, color=c, alpha=0.55,
label=f"Regime {k}")
ax.plot(t_ax, spread, lw=0.6, color="#1A1A2E")
ax.set_ylabel("Bid-Ask Spread")
ax.legend(loc="upper right", fontsize=8, frameon=False, ncol=3)
# ── Panel 2: Composite score + signals ──
ax = axes[1]
sc = score[:t_end]
thresh = np.percentile(score, SIGNAL_PCT)
ax.plot(t_ax, sc, lw=0.8, color="#444444", alpha=0.85, label="Composite score")
ax.axhline(thresh, color="#FF8800", lw=1.2, ls="--",
label=f"{SIGNAL_PCT}th pct threshold")
ax.fill_between(t_ax, thresh, sc, where=sc > thresh,
color=PALETTE["Model"], alpha=0.18)
ax.vlines(tau_vis, sc.min(), sc.max(),
color=PALETTE["Model"], lw=1.0, alpha=0.7, label="Signal τ (model)")
ax.vlines(sigma_vis, sc.min(), sc.max(),
color=PALETTE["Imbalance"], lw=0.6, ls=":", alpha=0.4, label="Stress event σ")
ax.set_ylabel("Instability Score")
ax.legend(loc="upper right", fontsize=8, frameon=False, ncol=2)
# ── Panel 3: Entropy ──
ax = axes[2]
ax.plot(t_ax, H[:t_end], lw=0.7, color="#6A3D9A", alpha=0.85)
ax.set_ylabel("Entropy H_t")
# ── Panel 4: Uncertainty + Transition Intensity ──
ax = axes[3]
ax2 = ax.twinx()
ax.plot(t_ax, U[:t_end], lw=0.7, color="#1F78B4", alpha=0.9, label="Uncertainty U_t")
ax2.plot(t_ax, TI[:t_end], lw=0.7, color="#33A02C", alpha=0.6, label="Trans. Intensity TI_t")
ax.set_ylabel("Uncertainty U_t", color="#1F78B4")
ax2.set_ylabel("TI_t", color="#33A02C")
lines, labels = ax.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax.legend(lines + lines2, labels + labels2, fontsize=8, frameon=False, loc="upper right")
# ── Panel 5: Pre-stress posterior ──
ax = axes[4]
# Recompute PS for display
posterior = None # computed via score pipeline; approximate via reading from score
ax.set_ylabel("(see note)")
ax.set_xlabel("Timestep")
# Annotate instead
ax.text(0.5, 0.5,
"PS_t (pre-stress posterior) is fused into composite score above.\n"
"See build_composite_score() for component breakdown.",
ha="center", va="center", transform=ax.transAxes, fontsize=9,
color="#555555", style="italic")
ax.set_yticks([])
fig.tight_layout()
plt.savefig("lob_diagnostic.pdf", bbox_inches="tight")
plt.show()
def plot_lead_time_densities(delta_dict, max_lag=MAX_LAG):
fig, ax = plt.subplots(figsize=(9, 5))
x_grid = np.linspace(-max_lag - 5, max_lag + 5, 800)
for i, (name, deltas) in enumerate(delta_dict.items()):
color = PALETTE[name]
valid = deltas[deltas > PENALTY]
if len(valid) > 5:
kde = gaussian_kde(valid, bw_method="scott")
ax.plot(x_grid, kde(x_grid), lw=2.4, color=color, label=name)
ax.fill_between(x_grid, kde(x_grid), alpha=0.14, color=color)
pf = np.mean(deltas <= PENALTY)
ax.annotate(f"{name} missed={pf:.1%}",
xy=(-max_lag + 1, 0.006 * (i + 1)),
color=color, fontsize=9, fontweight="bold")
ax.axvline(0, color="gray", lw=1.2, ls="--", label="Zero lead-time")
ax.set_xlabel("Lead time Δ (timesteps before stress event)", labelpad=8)
ax.set_ylabel("Density", labelpad=8)
ax.set_title("Lead-Time Distribution: Posterior-Based Model vs Baselines",
fontsize=13, pad=10)
ax.legend(frameon=False, fontsize=10)
ax.set_xlim(-max_lag - 2, max_lag + 2)
fig.tight_layout()
plt.savefig("lob_lead_time.pdf", bbox_inches="tight")
plt.show()
def plot_results_table(results_df):
fig, ax = plt.subplots(figsize=(13, 2.4))
ax.axis("off")
tbl = ax.table(cellText=results_df.values, colLabels=results_df.columns,
cellLoc="center", loc="center")
tbl.auto_set_font_size(False)
tbl.set_fontsize(9.5)
tbl.scale(1.2, 1.7)
for j in range(len(results_df.columns)):
tbl[0, j].set_facecolor("#2C6FAC")
tbl[0, j].set_text_props(color="white", fontweight="bold")
# Highlight model row
for j in range(len(results_df.columns)):
tbl[1, j].set_facecolor("#EDF4FF")
fig.suptitle("Detection Performance Summary (v3 — Posterior-Based Signals)",
fontsize=10, y=1.02)
fig.tight_layout()
plt.savefig("lob_results_table.pdf", bbox_inches="tight")
plt.show()
def plot_component_contributions(score, H, U, TI, n_show=3000):
"""Show how each posterior component contributes to the composite score."""
t_end = min(n_show, len(score))
t_ax = np.arange(t_end)
def _norm(x):
lo, hi = x.min(), x.max()
return (x - lo) / (hi - lo + 1e-12)
fig, axes = plt.subplots(2, 2, figsize=(13, 6), sharex=True)
fig.suptitle("Posterior Signal Components vs Composite Instability Score",
fontsize=12, fontweight="bold")
items = [
(axes[0, 0], _norm(H[:t_end]), "Entropy H_t (normalised)", "#6A3D9A"),
(axes[0, 1], _norm(U[:t_end]), "Uncertainty U_t (normalised)", "#1F78B4"),
(axes[1, 0], _norm(TI[:t_end]), "Trans. Intensity TI_t (norm.)", "#33A02C"),
(axes[1, 1], score[:t_end], "Composite Score (weighted sum)", "#2C6FAC"),
]
thresh = np.percentile(score, SIGNAL_PCT)
for ax, y, title, color in items:
ax.plot(t_ax, y, lw=0.7, color=color, alpha=0.85)
if "Composite" in title:
ax.axhline(thresh, color="#FF8800", lw=1.1, ls="--",
label=f"Threshold ({SIGNAL_PCT}th pct)")
ax.legend(fontsize=8, frameon=False)
ax.set_title(title, fontsize=10)
ax.set_ylabel("Value")
axes[1, 0].set_xlabel("Timestep")
axes[1, 1].set_xlabel("Timestep")
fig.tight_layout()
plt.savefig("lob_components.pdf", bbox_inches="tight")
plt.show()
# ─────────────────────────────────────────
# 9. Main Pipeline
# ─────────────────────────────────────────
def run_experiment():
rng = np.random.default_rng(SEED)
print("=" * 66)
print(" LOB Micro-Regime Detection v3 — Posterior-Based Early Warning")
print("=" * 66)
# ── Step 1: Data generation ──
print("\n Step 1 / 6 — Generating causal LOB data …")
X_raw, Z_true = generate_lob_data(T, rng)
dist = " | ".join([f"State {k}: {(Z_true==k).mean():.1%}" for k in range(N_REGIMES)])
print(f" {T:,} timesteps | {dist}")
# ── Step 2: Features ──
print("\n Step 2 / 6 — Feature engineering …")
X_scaled, scaler = engineer_features(X_raw)
print(f" Feature matrix: {X_scaled.shape}")
# ── Step 3: HMM ──
print("\n Step 3 / 6 — Fitting HMM (10 restarts) …")
model = fit_hmm(X_scaled)
Z_hat = model.predict(X_scaled)
ll = model.score(X_scaled)
print(f" Best log-likelihood: {ll:,.2f} | Converged: {model.monitor_.converged}")
means_sp = model.means_[:, 0]
state_rank = np.argsort(means_sp)
print(f" HMM state ranking by spread: {state_rank.tolist()} (low → high)")
# ── Step 4: Stress events ──
print("\n Step 4 / 6 — Stress event definition …")
sigma = define_stress_events(X_raw)
print(f" Stress events: {len(sigma):,} ({len(sigma)/T:.1%} of timesteps)")
# ── Step 5: Signal computation ──
print("\n Step 5 / 6 — Computing posterior-based instability signals …")
tau_model, score, H, U, TI, PS = model_signals(model, X_scaled)
tau_imb = imbalance_baseline(X_raw)
tau_vol = volatility_baseline(X_raw)
print(f" Signals — Model: {len(tau_model)} | "
f"Imbalance: {len(tau_imb)} | Volatility: {len(tau_vol)}")
delta_model = compute_lead_times(tau_model, sigma)
delta_imb = compute_lead_times(tau_imb, sigma)
delta_vol = compute_lead_times(tau_vol, sigma)
delta_dict = {"Model": delta_model, "Imbalance": delta_imb, "Volatility": delta_vol}
# ── Step 6: Statistical validation ──
print("\n Step 6 / 6 — Statistical validation …")
rows = []
for name, deltas in delta_dict.items():
m = evaluation_metrics(deltas)
lo, hi = bootstrap_ci(deltas)
rows.append({
"Detector" : name,
"Mean Δ" : f"{m['mean_delta']:+.2f}",
"95% CI" : f"[{lo:+.2f}, {hi:+.2f}]",
"% Early" : f"{m['pct_early']:.1%}",
"Mean Δ | early" : f"{m['mean_early']:+.2f}",
"Std Δ" : f"{m['std_delta']:.2f}",
"N(τ)" : m['n_tau'],
"N(early)" : m['n_early'],
})
results_df = pd.DataFrame(rows)
print("\n" + results_df.to_string(index=False))
print("\n Pairwise MannWhitney U tests (two-sided):")
pairs = [("Model", "Imbalance"), ("Model", "Volatility"), ("Imbalance", "Volatility")]
for a, b in pairs:
u, p = mannwhitney_test(delta_dict[a], delta_dict[b])
sig = "***" if p < 0.001 else ("**" if p < 0.01 else ("*" if p < 0.05 else "ns"))
print(f" {a:12s} vs {b:12s}: U={u:,.0f} p={p:.4f} {sig}")
# ── Visualisation ──
print("\n Rendering figures …")
plot_composite_signal(X_raw, Z_true, score, H, U, TI, tau_model, sigma)
plot_component_contributions(score, H, U, TI)
plot_lead_time_densities(delta_dict)
plot_results_table(results_df)
print("\n Experiment complete.")
return results_df, delta_dict, model, X_raw, Z_true, Z_hat, sigma, score, H, U, TI
if __name__ == "__main__":
(results_df, delta_dict, model,
X_raw, Z_true, Z_hat, sigma,
score, H, U, TI) = run_experiment()