Upload folder using huggingface_hub
Browse files- README.md +54 -0
- analyze_adftd.py +182 -0
- analyze_sweep.py +123 -0
- audit_claim1.py +105 -0
- audit_table1_rank.py +121 -0
- audit_theorem31.py +242 -0
- fig_theorem31.py +100 -0
- jobs_adftd.json +146 -0
- jobs_simmtm.json +21 -0
- jobs_sweep.json +338 -0
- make_poster_figs.py +175 -0
- model.py +322 -0
- prep_adftd.py +101 -0
- prep_ptbxl.py +98 -0
- run_adftd_ablation.sh +27 -0
- schedule.py +67 -0
- train.py +232 -0
README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- icml2026
|
| 5 |
+
- open-reproductions
|
| 6 |
+
- time-series
|
| 7 |
+
- medical
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# TS-Fingerprint — independent reproduction code (ICML 2026 #7348)
|
| 11 |
+
|
| 12 |
+
Independent re-implementation of **"Learning Fingerprints for Medical Time Series with
|
| 13 |
+
Redundancy-Constrained Information Maximization"**
|
| 14 |
+
(arXiv [2605.00130](https://arxiv.org/abs/2605.00130), OpenReview
|
| 15 |
+
[`lrRBHFIgaK`](https://openreview.net/forum?id=lrRBHFIgaK)).
|
| 16 |
+
|
| 17 |
+
**The paper released no code.** A GitHub code search for `TS-Fingerprint` returns zero
|
| 18 |
+
repositories and the paper carries no code link, so every component here was derived from
|
| 19 |
+
the paper text (Sec. 3.2 architecture, Sec. 3.3 losses, Sec. 3.4 head, Sec. 4.1
|
| 20 |
+
hyperparameters). Data preprocessing follows the paper's own stated protocol reference,
|
| 21 |
+
[DL4mHealth/Medformer](https://github.com/DL4mHealth/Medformer/tree/891f65b8a7e77188508fd8c56e10ccdba7fc1e18).
|
| 22 |
+
|
| 23 |
+
Full write-up, per-claim evidence and the agent session trace:
|
| 24 |
+
**[the Trackio reproduction logbook](https://huggingface.co/spaces/riteshhf/repro-learning-fingerprints-for-medical-time-series-with-redundancy-constrained-information-maxi)**.
|
| 25 |
+
|
| 26 |
+
## Files
|
| 27 |
+
|
| 28 |
+
| File | Purpose |
|
| 29 |
+
|---|---|
|
| 30 |
+
| `model.py` | `TSFingerprint` (Perceiver-style cross-attention bottleneck, masked F′-only decoder, TCR diversity loss, attention-pooling head) + `TiMAE` and `SimMTM` baselines |
|
| 31 |
+
| `train.py` | pre-train → fine-tune harness; subject-wise splits, Adam 1e-3/1e-4, ≤100 epochs, early stopping on val F1 (patience 10), macro Acc/P/R/F1/AUROC |
|
| 32 |
+
| `schedule.py` | small GPU-slot scheduler for running the config grids locally |
|
| 33 |
+
| `prep_adftd.py` | ADFTD (OpenNeuro ds004504) → 69,752 windows, reproducing Medformer's notebook |
|
| 34 |
+
| `prep_ptbxl.py` | PTB-XL (PhysioNet) → 213,880 windows, 5 diagnostic superclasses |
|
| 35 |
+
| `audit_claim1.py` | Claim 1: fixed k×d output, learnable Q, k ≪ T, strict X → F′ → X̂ chain |
|
| 36 |
+
| `audit_theorem31.py` | Claim 2: numerical audit of Theorem 3.1, both legs, with controls |
|
| 37 |
+
| `audit_table1_rank.py` | Claim 5: re-parses all 420 Table-1 values and recomputes the average rank |
|
| 38 |
+
| `analyze_adftd.py`, `analyze_sweep.py` | Claims 3/4 and Claim 6 summaries + figures |
|
| 39 |
+
| `jobs_*.json` | the exact config grids that were run |
|
| 40 |
+
|
| 41 |
+
## Data
|
| 42 |
+
|
| 43 |
+
Processed ADFTD arrays:
|
| 44 |
+
[`riteshhf/tsfp-repro-adftd-processed`](https://huggingface.co/datasets/riteshhf/tsfp-repro-adftd-processed).
|
| 45 |
+
PTB-XL must be fetched from PhysioNet (see `prep_ptbxl.py`; the
|
| 46 |
+
`physionet-open` S3 mirror works without credentials).
|
| 47 |
+
|
| 48 |
+
## Reproducing
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
python prep_adftd.py --root data/ADFTD --out data/ADFTD/processed
|
| 52 |
+
python train.py --data-dir data/ADFTD/processed --model tsfp --mode pretrain_ft \
|
| 53 |
+
--use-div 1 --k 8 --mask-ratio 0.6 --seeds 41 42 43 --out rec_div.json
|
| 54 |
+
```
|
analyze_adftd.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turn the ADFTD ablation result JSONs into the Claim 3 / Claim 4 tables and figures."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
|
| 9 |
+
RES = "results/adftd"
|
| 10 |
+
OUT = "results"
|
| 11 |
+
|
| 12 |
+
# paper values (Table 2 for the ablation, Table 4 for the promotion comparison)
|
| 13 |
+
PAPER = {
|
| 14 |
+
"tsfp_scratch": {"accuracy": 48.83, "f1": 45.80, "auroc": 65.29},
|
| 15 |
+
"tsfp_rec": {"accuracy": 53.51, "f1": 50.48, "auroc": 70.21},
|
| 16 |
+
"tsfp_rec_div": {"accuracy": 53.91, "f1": 51.79, "auroc": 72.11},
|
| 17 |
+
"timae_scratch": {"accuracy": 50.27, "f1": 45.88},
|
| 18 |
+
"timae_pretrain": {"accuracy": 50.48, "f1": 45.44},
|
| 19 |
+
"simmtm_scratch": {"accuracy": 51.98, "f1": 43.66},
|
| 20 |
+
"simmtm_pretrain": {"accuracy": 52.92, "f1": 45.59},
|
| 21 |
+
}
|
| 22 |
+
LABEL = {
|
| 23 |
+
"tsfp_scratch": "TS-Fingerprint · Scratch",
|
| 24 |
+
"tsfp_rec": "TS-Fingerprint · Pre-trained (L_rec)",
|
| 25 |
+
"tsfp_rec_div": "TS-Fingerprint · Pre-trained (L_rec + L_div)",
|
| 26 |
+
"timae_scratch": "Ti-MAE · Scratch",
|
| 27 |
+
"timae_pretrain": "Ti-MAE · Pre-trained",
|
| 28 |
+
"simmtm_scratch": "SimMTM · Scratch",
|
| 29 |
+
"simmtm_pretrain": "SimMTM · Pre-trained",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load():
|
| 34 |
+
out = {}
|
| 35 |
+
for name in PAPER:
|
| 36 |
+
p = os.path.join(RES, f"{name}.json")
|
| 37 |
+
if os.path.exists(p) and os.path.getsize(p) > 0:
|
| 38 |
+
out[name] = json.load(open(p))
|
| 39 |
+
return out
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def fmt(agg, m):
|
| 43 |
+
return f"{agg[m]['mean']:.2f} ± {agg[m]['std']:.2f}"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main():
|
| 47 |
+
r = load()
|
| 48 |
+
print(f"available configs: {sorted(r)}\n")
|
| 49 |
+
rows = []
|
| 50 |
+
for name in PAPER:
|
| 51 |
+
if name not in r:
|
| 52 |
+
continue
|
| 53 |
+
a = r[name]["aggregate"]
|
| 54 |
+
rows.append({
|
| 55 |
+
"config": name, "label": LABEL[name],
|
| 56 |
+
"acc": a["accuracy"]["mean"], "acc_sd": a["accuracy"]["std"],
|
| 57 |
+
"f1": a["f1"]["mean"], "f1_sd": a["f1"]["std"],
|
| 58 |
+
"auroc": a["auroc"]["mean"], "auroc_sd": a["auroc"]["std"],
|
| 59 |
+
"paper_acc": PAPER[name].get("accuracy"),
|
| 60 |
+
"paper_f1": PAPER[name].get("f1"),
|
| 61 |
+
"paper_auroc": PAPER[name].get("auroc"),
|
| 62 |
+
"n_seeds": len(r[name]["runs"]),
|
| 63 |
+
"sec_per_seed": float(np.mean([x["seconds"] for x in r[name]["runs"]])),
|
| 64 |
+
"params": r[name]["runs"][0]["params"],
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
print(f"{'config':<18}{'Acc (ours)':>16}{'Acc (paper)':>13}"
|
| 68 |
+
f"{'F1 (ours)':>16}{'F1 (paper)':>12}{'AUROC (ours)':>17}{'AUROC (paper)':>15}")
|
| 69 |
+
for x in rows:
|
| 70 |
+
pa = f"{x['paper_auroc']:.2f}" if x["paper_auroc"] else "-"
|
| 71 |
+
print(f"{x['config']:<18}{x['acc']:>9.2f}±{x['acc_sd']:<5.2f}"
|
| 72 |
+
f"{x['paper_acc']:>13.2f}{x['f1']:>9.2f}±{x['f1_sd']:<5.2f}"
|
| 73 |
+
f"{x['paper_f1']:>12.2f}{x['auroc']:>10.2f}±{x['auroc_sd']:<5.2f}{pa:>15}")
|
| 74 |
+
|
| 75 |
+
# ---- relative promotion (Table 2 / Table 4 delta definition) ----
|
| 76 |
+
def promo(scratch, pre, key="f1"):
|
| 77 |
+
if scratch not in r or pre not in r:
|
| 78 |
+
return None
|
| 79 |
+
s = r[scratch]["aggregate"][key]["mean"]
|
| 80 |
+
p = r[pre]["aggregate"][key]["mean"]
|
| 81 |
+
return {"scratch": s, "pretrained": p, "abs": p - s, "rel_pct": (p - s) / s * 100}
|
| 82 |
+
|
| 83 |
+
promos = {
|
| 84 |
+
"TS-Fingerprint": {"f1": promo("tsfp_scratch", "tsfp_rec_div", "f1"),
|
| 85 |
+
"accuracy": promo("tsfp_scratch", "tsfp_rec_div", "accuracy")},
|
| 86 |
+
"Ti-MAE": {"f1": promo("timae_scratch", "timae_pretrain", "f1"),
|
| 87 |
+
"accuracy": promo("timae_scratch", "timae_pretrain", "accuracy")},
|
| 88 |
+
"SimMTM": {"f1": promo("simmtm_scratch", "simmtm_pretrain", "f1"),
|
| 89 |
+
"accuracy": promo("simmtm_scratch", "simmtm_pretrain", "accuracy")},
|
| 90 |
+
}
|
| 91 |
+
# the diversity-loss increment specifically (Claim 3)
|
| 92 |
+
div_effect = promo("tsfp_rec", "tsfp_rec_div", "f1")
|
| 93 |
+
print("\nrelative F1 promotion (scratch -> pre-trained):")
|
| 94 |
+
for k, v in promos.items():
|
| 95 |
+
if v["f1"]:
|
| 96 |
+
print(f" {k:<16} {v['f1']['scratch']:.2f} -> {v['f1']['pretrained']:.2f} "
|
| 97 |
+
f"(+{v['f1']['rel_pct']:.2f}%)")
|
| 98 |
+
if div_effect:
|
| 99 |
+
print(f"\nL_div increment on top of L_rec: F1 {div_effect['scratch']:.2f} -> "
|
| 100 |
+
f"{div_effect['pretrained']:.2f} ({div_effect['abs']:+.2f} pts, "
|
| 101 |
+
f"{div_effect['rel_pct']:+.2f}%)")
|
| 102 |
+
|
| 103 |
+
# per-seed paired test for the diversity loss
|
| 104 |
+
paired = None
|
| 105 |
+
if "tsfp_rec" in r and "tsfp_rec_div" in r:
|
| 106 |
+
a = {x["seed"]: x["test"]["f1"] for x in r["tsfp_rec"]["runs"]}
|
| 107 |
+
b = {x["seed"]: x["test"]["f1"] for x in r["tsfp_rec_div"]["runs"]}
|
| 108 |
+
seeds = sorted(set(a) & set(b))
|
| 109 |
+
d = [b[s] - a[s] for s in seeds]
|
| 110 |
+
paired = {"seeds": seeds, "per_seed_delta_f1": d,
|
| 111 |
+
"mean_delta": float(np.mean(d)),
|
| 112 |
+
"n_positive": int(sum(x > 0 for x in d))}
|
| 113 |
+
print(f"\npaired per-seed F1 delta (rec+div minus rec): "
|
| 114 |
+
f"{[round(x, 2) for x in d]} mean {np.mean(d):+.2f}, "
|
| 115 |
+
f"{paired['n_positive']}/{len(d)} seeds positive")
|
| 116 |
+
|
| 117 |
+
json.dump({"rows": rows, "promotions": promos, "div_effect": div_effect,
|
| 118 |
+
"paired_div_test": paired},
|
| 119 |
+
open(os.path.join(OUT, "adftd_summary.json"), "w"), indent=2)
|
| 120 |
+
|
| 121 |
+
# ---------------- figures ----------------
|
| 122 |
+
# Fig 1: Claim 3 ablation, ours vs paper
|
| 123 |
+
order = ["tsfp_scratch", "tsfp_rec", "tsfp_rec_div"]
|
| 124 |
+
have = [x for x in order if x in r]
|
| 125 |
+
if have:
|
| 126 |
+
xs = ["Scratch", "Pre-trained\n(L_rec)", "Pre-trained\n(L_rec + L_div)"][:len(have)]
|
| 127 |
+
fig = go.Figure()
|
| 128 |
+
for m, col in (("f1", "#2F6F8F"), ("accuracy", "#B07C2B"), ("auroc", "#7E7E7E")):
|
| 129 |
+
ours = [r[c]["aggregate"][m]["mean"] for c in have]
|
| 130 |
+
sd = [r[c]["aggregate"][m]["std"] for c in have]
|
| 131 |
+
pap = [PAPER[c].get(m) for c in have]
|
| 132 |
+
fig.add_bar(name=f"ours · {m}", x=xs, y=ours,
|
| 133 |
+
error_y=dict(type="data", array=sd), marker_color=col)
|
| 134 |
+
fig.add_scatter(name=f"paper · {m}", x=xs, y=pap, mode="markers",
|
| 135 |
+
marker=dict(symbol="line-ew", size=26, line=dict(
|
| 136 |
+
width=3, color=col)))
|
| 137 |
+
fig.update_layout(
|
| 138 |
+
title="Claim 3 — ADFTD ablation: this reproduction (bars, 3 seeds) "
|
| 139 |
+
"vs the paper's Table 2 (ticks)",
|
| 140 |
+
yaxis_title="score (%)", barmode="group", template="plotly_white",
|
| 141 |
+
height=460, legend=dict(orientation="h", y=-0.18))
|
| 142 |
+
fig.write_html(os.path.join(OUT, "claim3_ablation.html"),
|
| 143 |
+
include_plotlyjs="cdn")
|
| 144 |
+
|
| 145 |
+
# Fig 2: Claim 4 promotion comparison
|
| 146 |
+
if all(promos[k]["f1"] for k in promos):
|
| 147 |
+
fig2 = go.Figure()
|
| 148 |
+
names = list(promos)
|
| 149 |
+
fig2.add_bar(name="this reproduction", x=names,
|
| 150 |
+
y=[promos[k]["f1"]["rel_pct"] for k in names],
|
| 151 |
+
marker_color="#2F6F8F",
|
| 152 |
+
text=[f"{promos[k]['f1']['rel_pct']:+.2f}%" for k in names],
|
| 153 |
+
textposition="outside")
|
| 154 |
+
paper_promo = {"TS-Fingerprint": 13.07, "Ti-MAE": -0.96, "SimMTM": 4.42}
|
| 155 |
+
fig2.add_bar(name="paper (Table 4)", x=names,
|
| 156 |
+
y=[paper_promo[k] for k in names], marker_color="#B07C2B",
|
| 157 |
+
text=[f"{paper_promo[k]:+.2f}%" for k in names],
|
| 158 |
+
textposition="outside")
|
| 159 |
+
fig2.update_layout(
|
| 160 |
+
title="Claim 4 — relative F1 promotion from pre-training on ADFTD",
|
| 161 |
+
yaxis_title="relative F1 gain (%)", barmode="group",
|
| 162 |
+
template="plotly_white", height=440,
|
| 163 |
+
legend=dict(orientation="h", y=-0.15))
|
| 164 |
+
fig2.write_html(os.path.join(OUT, "claim4_promotion.html"),
|
| 165 |
+
include_plotlyjs="cdn")
|
| 166 |
+
|
| 167 |
+
# raw CSV for the figure cells
|
| 168 |
+
with open(os.path.join(OUT, "adftd_results.csv"), "w") as f:
|
| 169 |
+
f.write("config,label,n_seeds,acc,acc_sd,f1,f1_sd,auroc,auroc_sd,"
|
| 170 |
+
"paper_acc,paper_f1,paper_auroc,sec_per_seed,params\n")
|
| 171 |
+
for x in rows:
|
| 172 |
+
f.write(f"{x['config']},{x['label']},{x['n_seeds']},{x['acc']:.3f},"
|
| 173 |
+
f"{x['acc_sd']:.3f},{x['f1']:.3f},{x['f1_sd']:.3f},"
|
| 174 |
+
f"{x['auroc']:.3f},{x['auroc_sd']:.3f},{x['paper_acc']},"
|
| 175 |
+
f"{x['paper_f1']},{x['paper_auroc']},{x['sec_per_seed']:.0f},"
|
| 176 |
+
f"{x['params']}\n")
|
| 177 |
+
print("\nwrote results/adftd_summary.json, adftd_results.csv, "
|
| 178 |
+
"claim3_ablation.html, claim4_promotion.html")
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
if __name__ == "__main__":
|
| 182 |
+
main()
|
analyze_sweep.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Claim 6: summarise the k x mask-ratio sweep on PTB-XL and compare with Table 3."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import plotly.graph_objects as go
|
| 9 |
+
from plotly.subplots import make_subplots
|
| 10 |
+
|
| 11 |
+
RES = "results/sweep"
|
| 12 |
+
KS, RS = [6, 8, 10], [0.5, 0.6, 0.7, 0.8]
|
| 13 |
+
# Table 3 of the paper (F1 column), as percentages
|
| 14 |
+
PAPER_F1 = {(6, 0.5): 54.81, (6, 0.6): 56.68, (6, 0.7): 55.41, (6, 0.8): 54.32,
|
| 15 |
+
(8, 0.5): 62.60, (8, 0.6): 63.51, (8, 0.7): 62.10, (8, 0.8): 59.19,
|
| 16 |
+
(10, 0.5): 51.76, (10, 0.6): 50.70, (10, 0.7): 56.33, (10, 0.8): 55.70}
|
| 17 |
+
PAPER_ACC = {(6, 0.5): 70.19, (6, 0.6): 70.71, (6, 0.7): 70.63, (6, 0.8): 69.77,
|
| 18 |
+
(8, 0.5): 74.07, (8, 0.6): 73.98, (8, 0.7): 73.86, (8, 0.8): 72.35,
|
| 19 |
+
(10, 0.5): 67.36, (10, 0.6): 66.41, (10, 0.7): 71.07, (10, 0.8): 70.42}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def main():
|
| 23 |
+
rows = []
|
| 24 |
+
for k in KS:
|
| 25 |
+
for r in RS:
|
| 26 |
+
p = os.path.join(RES, f"ptbxl_k{k}_r{r}.json")
|
| 27 |
+
if not (os.path.exists(p) and os.path.getsize(p) > 0):
|
| 28 |
+
continue
|
| 29 |
+
d = json.load(open(p))
|
| 30 |
+
a = d["aggregate"]
|
| 31 |
+
rows.append({"k": k, "r": r,
|
| 32 |
+
"f1": a["f1"]["mean"], "f1_sd": a["f1"]["std"],
|
| 33 |
+
"acc": a["accuracy"]["mean"], "acc_sd": a["accuracy"]["std"],
|
| 34 |
+
"auroc": a["auroc"]["mean"],
|
| 35 |
+
"n_seeds": len(d["runs"]),
|
| 36 |
+
"paper_f1": PAPER_F1[(k, r)], "paper_acc": PAPER_ACC[(k, r)],
|
| 37 |
+
"sec": float(np.mean([x["seconds"] for x in d["runs"]]))})
|
| 38 |
+
if not rows:
|
| 39 |
+
print("no sweep results yet")
|
| 40 |
+
return
|
| 41 |
+
print(f"{'k':>3}{'r':>6}{'F1 ours':>12}{'F1 paper':>11}{'Acc ours':>11}"
|
| 42 |
+
f"{'Acc paper':>11}{'seeds':>7}")
|
| 43 |
+
for x in rows:
|
| 44 |
+
print(f"{x['k']:>3}{x['r']:>6}{x['f1']:>9.2f}±{x['f1_sd']:<3.1f}"
|
| 45 |
+
f"{x['paper_f1']:>11.2f}{x['acc']:>8.2f}±{x['acc_sd']:<3.1f}"
|
| 46 |
+
f"{x['paper_acc']:>11.2f}{x['n_seeds']:>7}")
|
| 47 |
+
|
| 48 |
+
out = {"rows": rows}
|
| 49 |
+
if len(rows) == 12:
|
| 50 |
+
f1 = {(x["k"], x["r"]): x["f1"] for x in rows}
|
| 51 |
+
best = max(f1, key=f1.get)
|
| 52 |
+
out["best_cell_ours"] = {"k": best[0], "r": best[1], "f1": f1[best]}
|
| 53 |
+
out["best_cell_paper"] = {"k": 8, "r": 0.6, "f1": 63.51}
|
| 54 |
+
# marginal effect of k at each r, and of r at each k
|
| 55 |
+
out["best_k_per_r"] = {str(r): max(KS, key=lambda k: f1[(k, r)]) for r in RS}
|
| 56 |
+
out["best_r_per_k"] = {str(k): max(RS, key=lambda r: f1[(k, r)]) for k in KS}
|
| 57 |
+
out["mean_f1_by_k"] = {str(k): float(np.mean([f1[(k, r)] for r in RS]))
|
| 58 |
+
for k in KS}
|
| 59 |
+
out["mean_f1_by_r"] = {str(r): float(np.mean([f1[(k, r)] for k in KS]))
|
| 60 |
+
for r in RS}
|
| 61 |
+
out["paper_mean_f1_by_k"] = {
|
| 62 |
+
str(k): float(np.mean([PAPER_F1[(k, r)] for r in RS])) for k in KS}
|
| 63 |
+
out["paper_mean_f1_by_r"] = {
|
| 64 |
+
str(r): float(np.mean([PAPER_F1[(k, r)] for k in KS])) for r in RS}
|
| 65 |
+
# does r>=0.8 degrade? compare r=0.8 against the mean of r in {0.5,0.6,0.7}
|
| 66 |
+
deg = {}
|
| 67 |
+
for k in KS:
|
| 68 |
+
lo = np.mean([f1[(k, r)] for r in (0.5, 0.6, 0.7)])
|
| 69 |
+
deg[str(k)] = {"mean_r_le_0.7": float(lo), "r_0.8": f1[(k, 0.8)],
|
| 70 |
+
"delta": float(f1[(k, 0.8)] - lo)}
|
| 71 |
+
out["r08_degradation"] = deg
|
| 72 |
+
# rank correlation between our grid and the paper's grid
|
| 73 |
+
ours_v = np.array([f1[(k, r)] for k in KS for r in RS])
|
| 74 |
+
pap_v = np.array([PAPER_F1[(k, r)] for k in KS for r in RS])
|
| 75 |
+
out["pearson_ours_vs_paper_grid"] = float(np.corrcoef(ours_v, pap_v)[0, 1])
|
| 76 |
+
out["spearman_ours_vs_paper_grid"] = float(np.corrcoef(
|
| 77 |
+
np.argsort(np.argsort(ours_v)), np.argsort(np.argsort(pap_v)))[0, 1])
|
| 78 |
+
print("\nbest cell (ours):", out["best_cell_ours"])
|
| 79 |
+
print("mean F1 by k (ours):", {k: round(v, 2)
|
| 80 |
+
for k, v in out["mean_f1_by_k"].items()})
|
| 81 |
+
print("mean F1 by k (paper):", {k: round(v, 2)
|
| 82 |
+
for k, v in out["paper_mean_f1_by_k"].items()})
|
| 83 |
+
print("mean F1 by r (ours):", {k: round(v, 2)
|
| 84 |
+
for k, v in out["mean_f1_by_r"].items()})
|
| 85 |
+
print("mean F1 by r (paper):", {k: round(v, 2)
|
| 86 |
+
for k, v in out["paper_mean_f1_by_r"].items()})
|
| 87 |
+
print("r=0.8 vs r<=0.7:", {k: round(v["delta"], 2)
|
| 88 |
+
for k, v in deg.items()})
|
| 89 |
+
print(f"grid correlation with Table 3: Pearson "
|
| 90 |
+
f"{out['pearson_ours_vs_paper_grid']:.3f}, "
|
| 91 |
+
f"Spearman {out['spearman_ours_vs_paper_grid']:.3f}")
|
| 92 |
+
|
| 93 |
+
json.dump(out, open("results/sweep_summary.json", "w"), indent=2)
|
| 94 |
+
|
| 95 |
+
# interactive figure for the logbook
|
| 96 |
+
ours = np.full((3, 4), np.nan)
|
| 97 |
+
for x in rows:
|
| 98 |
+
ours[KS.index(x["k"]), RS.index(x["r"])] = x["f1"]
|
| 99 |
+
paper = np.array([[PAPER_F1[(k, r)] for r in RS] for k in KS])
|
| 100 |
+
fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.13,
|
| 101 |
+
subplot_titles=("this reproduction (PTB-XL, thinned)",
|
| 102 |
+
"paper (Table 3)"))
|
| 103 |
+
for j, m in enumerate((ours, paper)):
|
| 104 |
+
fig.add_heatmap(z=m, x=[f"r={r}" for r in RS], y=[f"k={k}" for k in KS],
|
| 105 |
+
colorscale=[[0, "#F2F7F8"], [1, "#17697B"]], showscale=False,
|
| 106 |
+
text=[[("" if np.isnan(v) else f"{v:.2f}") for v in row]
|
| 107 |
+
for row in m],
|
| 108 |
+
texttemplate="%{text}", row=1, col=j + 1)
|
| 109 |
+
fig.update_layout(template="plotly_white", height=420,
|
| 110 |
+
title="Claim 6 — macro F1 (%) over bottleneck size k and mask ratio r")
|
| 111 |
+
fig.write_html("results/claim6_sweep.html", include_plotlyjs="cdn")
|
| 112 |
+
|
| 113 |
+
with open("results/sweep_results.csv", "w") as f:
|
| 114 |
+
f.write("k,mask_ratio,n_seeds,f1,f1_sd,acc,acc_sd,auroc,paper_f1,paper_acc,sec\n")
|
| 115 |
+
for x in rows:
|
| 116 |
+
f.write(f"{x['k']},{x['r']},{x['n_seeds']},{x['f1']:.3f},{x['f1_sd']:.3f},"
|
| 117 |
+
f"{x['acc']:.3f},{x['acc_sd']:.3f},{x['auroc']:.3f},"
|
| 118 |
+
f"{x['paper_f1']},{x['paper_acc']},{x['sec']:.0f}\n")
|
| 119 |
+
print("\nwrote results/sweep_summary.json, sweep_results.csv, claim6_sweep.html")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
main()
|
audit_claim1.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Claim 1 audit: TS-Fingerprint compresses a variable-length MedTS into a fixed set of
|
| 2 |
+
k learnable Fingerprint Tokens via a learnable query set Q in R^{k x d}, with k << T.
|
| 3 |
+
|
| 4 |
+
Checks:
|
| 5 |
+
1. the encoder output is exactly (B, k, d) for every input length T;
|
| 6 |
+
2. Q is a genuine nn.Parameter that receives gradient (i.e. it is *learnable*),
|
| 7 |
+
and the parameter count of the bottleneck is independent of T;
|
| 8 |
+
3. k << T holds at the paper's settings for all 7 benchmark datasets of Table 5;
|
| 9 |
+
4. the decoder sees *only* F' plus positional mask tokens, so the data-processing
|
| 10 |
+
chain X -> F' -> X_hat is strict: perturbing a masked input patch can change
|
| 11 |
+
X_hat only through F'. We verify this by freezing F' and re-running the decoder.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 21 |
+
from model import TSFingerprint # noqa: E402
|
| 22 |
+
|
| 23 |
+
# Table 5 of the paper: (#Timestamps T, #Channel C, patch size)
|
| 24 |
+
DATASETS = {
|
| 25 |
+
"ADFTD": (256, 19, 8), "PTB": (300, 15, 10), "PTB-XL": (250, 12, 10),
|
| 26 |
+
"APAVA": (256, 16, 8), "FLAAP": (100, 6, 10), "UCIHAR": (128, 9, 32),
|
| 27 |
+
"Sleep-EDF": (3000, 1, 60),
|
| 28 |
+
}
|
| 29 |
+
K, D = 8, 128
|
| 30 |
+
rep = {}
|
| 31 |
+
|
| 32 |
+
torch.manual_seed(0)
|
| 33 |
+
m = TSFingerprint(c_in=19, patch_size=8, n_classes=3, d_model=D, k=K, max_patches=4096)
|
| 34 |
+
|
| 35 |
+
# --- 1. shape invariance to T -------------------------------------------------
|
| 36 |
+
shapes = {}
|
| 37 |
+
for T in (64, 128, 256, 512, 1024, 2048, 4096, 8192):
|
| 38 |
+
with torch.no_grad():
|
| 39 |
+
f = m.encoder(torch.randn(3, T, 19))
|
| 40 |
+
shapes[T] = {"n_patches": T // 8, "F_shape": list(f.shape)}
|
| 41 |
+
rep["shape_invariance"] = shapes
|
| 42 |
+
rep["all_outputs_are_k_by_d"] = all(v["F_shape"] == [3, K, D] for v in shapes.values())
|
| 43 |
+
|
| 44 |
+
# --- 2. Q is learnable, and bottleneck size is T-independent -------------------
|
| 45 |
+
rep["Q_shape"] = list(m.encoder.Q.shape)
|
| 46 |
+
rep["Q_is_parameter"] = isinstance(m.encoder.Q, torch.nn.Parameter)
|
| 47 |
+
rep["Q_requires_grad"] = bool(m.encoder.Q.requires_grad)
|
| 48 |
+
m.zero_grad()
|
| 49 |
+
loss, _, _ = m.pretrain_step(torch.randn(4, 256, 19))
|
| 50 |
+
loss.backward()
|
| 51 |
+
g = m.encoder.Q.grad
|
| 52 |
+
rep["Q_grad_norm_after_backward"] = float(g.norm())
|
| 53 |
+
rep["Q_receives_gradient"] = bool(g is not None and g.norm() > 0)
|
| 54 |
+
rep["encoder_params_excluding_pos_embed"] = sum(
|
| 55 |
+
p.numel() for n, p in m.encoder.named_parameters() if n != "pos")
|
| 56 |
+
|
| 57 |
+
# --- 3. k << T on every benchmark ---------------------------------------------
|
| 58 |
+
ratios = {}
|
| 59 |
+
for name, (T, C, ps) in DATASETS.items():
|
| 60 |
+
n_patch = T // ps
|
| 61 |
+
ratios[name] = {"T": T, "C": C, "patch": ps, "n_patches": n_patch, "k": K,
|
| 62 |
+
"k_over_T": round(K / T, 5), "k_over_n_patches": round(K / n_patch, 4),
|
| 63 |
+
"k_much_less_than_T": bool(K < T / 4)}
|
| 64 |
+
rep["compression_per_dataset"] = ratios
|
| 65 |
+
rep["k_ll_T_on_all_datasets"] = all(v["k_much_less_than_T"] for v in ratios.values())
|
| 66 |
+
# compression factor: raw floats in vs floats out
|
| 67 |
+
rep["compression_factor_TxC_to_kxd"] = {
|
| 68 |
+
n: round((v["T"] * v["C"]) / (K * D), 3) for n, v in ratios.items()}
|
| 69 |
+
|
| 70 |
+
# --- 4. strict bottleneck: decoder output depends on x only through F' ---------
|
| 71 |
+
m.eval()
|
| 72 |
+
x = torch.randn(2, 256, 19)
|
| 73 |
+
with torch.no_grad():
|
| 74 |
+
f = m.encoder(x)
|
| 75 |
+
rec_a = m.decoder(f, 32)
|
| 76 |
+
x2 = x.clone()
|
| 77 |
+
x2[:, 100:140] += 5.0 # large perturbation of the input
|
| 78 |
+
f2 = m.encoder(x2)
|
| 79 |
+
rec_b = m.decoder(f2, 32) # differs, because F' differs
|
| 80 |
+
rec_c = m.decoder(f, 32) # same F' -> must be bit-identical
|
| 81 |
+
rep["decoder_conditioned_solely_on_F"] = {
|
| 82 |
+
"delta_recon_when_F_changes": float((rec_a - rec_b).abs().max()),
|
| 83 |
+
"delta_recon_when_F_held_fixed": float((rec_a - rec_c).abs().max()),
|
| 84 |
+
"strict_chain_X_to_F_to_Xhat": bool((rec_a - rec_c).abs().max() == 0.0
|
| 85 |
+
and (rec_a - rec_b).abs().max() > 0),
|
| 86 |
+
}
|
| 87 |
+
# the decoder signature takes only (F', n_patches) -- no path for raw patches
|
| 88 |
+
import inspect # noqa: E402
|
| 89 |
+
rep["decoder_forward_signature"] = str(inspect.signature(m.decoder.forward))
|
| 90 |
+
|
| 91 |
+
print(json.dumps(rep, indent=2))
|
| 92 |
+
os.makedirs("results", exist_ok=True)
|
| 93 |
+
with open("results/claim1_audit.json", "w") as fh:
|
| 94 |
+
json.dump(rep, fh, indent=2)
|
| 95 |
+
|
| 96 |
+
print("\n---- verdict ----")
|
| 97 |
+
print(f"encoder output is (B, {K}, {D}) for every T in "
|
| 98 |
+
f"{sorted(shapes)}: {rep['all_outputs_are_k_by_d']}")
|
| 99 |
+
print(f"Q in R^(k x d) = {rep['Q_shape']}, learnable nn.Parameter, "
|
| 100 |
+
f"grad norm {rep['Q_grad_norm_after_backward']:.4f}")
|
| 101 |
+
print(f"k << T on all 7 Table-5 datasets: {rep['k_ll_T_on_all_datasets']} "
|
| 102 |
+
f"(k/T from {min(v['k_over_T'] for v in ratios.values()):.5f} to "
|
| 103 |
+
f"{max(v['k_over_T'] for v in ratios.values()):.5f})")
|
| 104 |
+
print("decoder is strictly conditioned on F': "
|
| 105 |
+
f"{rep['decoder_conditioned_solely_on_F']['strict_chain_X_to_F_to_Xhat']}")
|
audit_table1_rank.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Claim 5 audit: recompute the reported average rank of 1.24 from Table 1 itself.
|
| 2 |
+
|
| 3 |
+
Table 1 is parsed straight out of the arXiv HTML text dump (paper/paper.txt) so the
|
| 4 |
+
numbers are not re-typed by hand. We then rank the 12 methods on every
|
| 5 |
+
(dataset, metric) cell and report the mean rank of "Ours" under several readings of
|
| 6 |
+
the claim, since the paper is ambiguous about which metrics enter the average
|
| 7 |
+
(Sec 4.1 says "across all metrics", the challenge statement says accuracy/F1/AUROC).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import re
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
METHODS = ["Autoformer", "Crossformer", "FEDformer", "Informer", "iTransformer",
|
| 16 |
+
"MTST", "Nonformer", "PatchTST", "Reformer", "Transformer",
|
| 17 |
+
"Medformer", "Ours"]
|
| 18 |
+
DATASETS = ["ADFTD", "PTB", "PTB-XL", "APAVA", "SleepEDF", "FLAAP", "UCI-HAR"]
|
| 19 |
+
METRICS = ["Accuracy", "Precision", "Recall", "F1-score", "AUROC"]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def parse(path="paper/paper.txt"):
|
| 23 |
+
txt = open(path, encoding="utf-8").read()
|
| 24 |
+
# Table 1 spans from the header block to the "Table 1 :" caption
|
| 25 |
+
start = txt.index(" Dataset \n Metrics \n Autoformer")
|
| 26 |
+
end = txt.index(" Table 1 : ")
|
| 27 |
+
body = txt[start:end]
|
| 28 |
+
# every "value ± std" pair, in reading order
|
| 29 |
+
vals = [float(m) for m in re.findall(r"(\d+\.\d+)\s*±", body)]
|
| 30 |
+
assert len(vals) == 7 * 5 * 12, f"expected 420 numbers, parsed {len(vals)}"
|
| 31 |
+
arr = np.array(vals).reshape(7, 5, 12)
|
| 32 |
+
return arr
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def ranks_for_ours(arr, metric_idx):
|
| 36 |
+
"""Rank (1 = best, higher value is better for every metric here) of 'Ours'."""
|
| 37 |
+
out = {}
|
| 38 |
+
for di, ds in enumerate(DATASETS):
|
| 39 |
+
for mi in metric_idx:
|
| 40 |
+
col = arr[di, mi]
|
| 41 |
+
# competition ranking with ties sharing the average rank
|
| 42 |
+
order = (-col).argsort()
|
| 43 |
+
r = np.empty(12)
|
| 44 |
+
r[order] = np.arange(1, 13)
|
| 45 |
+
# handle exact ties
|
| 46 |
+
for v in np.unique(col):
|
| 47 |
+
m = col == v
|
| 48 |
+
if m.sum() > 1:
|
| 49 |
+
r[m] = r[m].mean()
|
| 50 |
+
out[(ds, METRICS[mi])] = float(r[METHODS.index("Ours")])
|
| 51 |
+
return out
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def main():
|
| 55 |
+
arr = parse()
|
| 56 |
+
report = {"table_shape": list(arr.shape)}
|
| 57 |
+
# spot-check a few known cells against the paper text
|
| 58 |
+
checks = {
|
| 59 |
+
("ADFTD", "Accuracy", "Ours"): 53.91,
|
| 60 |
+
("ADFTD", "F1-score", "Ours"): 51.79,
|
| 61 |
+
("ADFTD", "F1-score", "Medformer"): 50.65,
|
| 62 |
+
("PTB-XL", "F1-score", "Ours"): 63.51,
|
| 63 |
+
("UCI-HAR", "Accuracy", "Medformer"): 91.65,
|
| 64 |
+
("SleepEDF", "AUROC", "Ours"): 95.37,
|
| 65 |
+
}
|
| 66 |
+
ok = {}
|
| 67 |
+
for (ds, me, mo), want in checks.items():
|
| 68 |
+
got = arr[DATASETS.index(ds), METRICS.index(me), METHODS.index(mo)]
|
| 69 |
+
ok[f"{ds}/{me}/{mo}"] = {"parsed": float(got), "paper": want,
|
| 70 |
+
"match": bool(abs(got - want) < 1e-9)}
|
| 71 |
+
report["parse_spot_checks"] = ok
|
| 72 |
+
|
| 73 |
+
variants = {
|
| 74 |
+
"all_5_metrics": [0, 1, 2, 3, 4],
|
| 75 |
+
"acc_f1_auroc (challenge wording)": [0, 3, 4],
|
| 76 |
+
"acc_f1": [0, 3],
|
| 77 |
+
}
|
| 78 |
+
for name, idx in variants.items():
|
| 79 |
+
r = ranks_for_ours(arr, idx)
|
| 80 |
+
vals = np.array(list(r.values()))
|
| 81 |
+
report[name] = {
|
| 82 |
+
"n_cells": int(vals.size),
|
| 83 |
+
"avg_rank_ours": float(vals.mean()),
|
| 84 |
+
"n_rank1": int((vals == 1).sum()),
|
| 85 |
+
"worst_rank": float(vals.max()),
|
| 86 |
+
"cells_not_rank1": {f"{k[0]}/{k[1]}": v for k, v in r.items() if v != 1},
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
# average rank of every method under the 5-metric reading, for context
|
| 90 |
+
allranks = {}
|
| 91 |
+
for mo in METHODS:
|
| 92 |
+
rs = []
|
| 93 |
+
for di in range(7):
|
| 94 |
+
for mi in range(5):
|
| 95 |
+
col = arr[di, mi]
|
| 96 |
+
order = (-col).argsort()
|
| 97 |
+
rr = np.empty(12)
|
| 98 |
+
rr[order] = np.arange(1, 13)
|
| 99 |
+
for v in np.unique(col):
|
| 100 |
+
m = col == v
|
| 101 |
+
if m.sum() > 1:
|
| 102 |
+
rr[m] = rr[m].mean()
|
| 103 |
+
rs.append(rr[METHODS.index(mo)])
|
| 104 |
+
allranks[mo] = float(np.mean(rs))
|
| 105 |
+
report["avg_rank_all_methods_5metrics"] = allranks
|
| 106 |
+
|
| 107 |
+
# one-tailed Wilcoxon signed-rank, Ours vs Medformer (paper's stated test)
|
| 108 |
+
from scipy.stats import wilcoxon
|
| 109 |
+
ours = arr[:, :, METHODS.index("Ours")].ravel()
|
| 110 |
+
med = arr[:, :, METHODS.index("Medformer")].ravel()
|
| 111 |
+
st, p = wilcoxon(ours, med, alternative="greater")
|
| 112 |
+
report["wilcoxon_ours_gt_medformer"] = {"statistic": float(st), "p_value": float(p),
|
| 113 |
+
"n_pairs": int(ours.size),
|
| 114 |
+
"ours_wins": int((ours > med).sum())}
|
| 115 |
+
print(json.dumps(report, indent=2))
|
| 116 |
+
with open("results/table1_rank_audit.json", "w") as f:
|
| 117 |
+
json.dump(report, f, indent=2)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
if __name__ == "__main__":
|
| 121 |
+
main()
|
audit_theorem31.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Numerical audit of Theorem 3.1 (Redundancy-Constrained Information Maximization).
|
| 2 |
+
|
| 3 |
+
Theorem 3.1 asserts that minimising L_total = L_rec + lambda * L_div is *formally
|
| 4 |
+
equivalent* to max_theta I(X;F') - lambda * TC(F').
|
| 5 |
+
|
| 6 |
+
The proof (App. A.1) decomposes into two independent claims, which we audit separately
|
| 7 |
+
to double precision, each with a control that relaxes the stated condition:
|
| 8 |
+
|
| 9 |
+
(A) Sufficiency. Under a Gaussian decoder p(X|F') = N(D(F'), sigma^2 I),
|
| 10 |
+
minimising the MSE maximises the Barber-Agakov variational lower bound
|
| 11 |
+
I_LB(X;F') = H(X) + E[log p(x|f')] on I(X;F').
|
| 12 |
+
-> check I_LB is an exact affine, strictly decreasing function of the MSE,
|
| 13 |
+
and that I_LB <= I(X;F') for a case where the true MI is known in closed form.
|
| 14 |
+
CONTROL: a Laplace decoder, where the affine MSE<->ELBO identity must fail.
|
| 15 |
+
|
| 16 |
+
(B) Independence. For Gaussian F',
|
| 17 |
+
TC(F') = 1/2 sum_i log(Sigma_ii) - 1/2 log det(Sigma).
|
| 18 |
+
The paper's L_div = -1/2 log det(I + d/(k eps^2) F'^T F') is claimed to be
|
| 19 |
+
"geometrically equivalent" to minimising TC.
|
| 20 |
+
-> check the Gaussian TC identity itself to double precision (exact),
|
| 21 |
+
and then measure how tightly L_div tracks TC.
|
| 22 |
+
CONTROL: non-Gaussian (uniform / heavy-tailed) latents, where the Gaussian
|
| 23 |
+
entropy formula and hence the TC identity must break.
|
| 24 |
+
|
| 25 |
+
This is a numerical audit, not a proof replacement; it needs no GPU.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
import json
|
| 29 |
+
|
| 30 |
+
import numpy as np
|
| 31 |
+
|
| 32 |
+
RNG = np.random.default_rng(0)
|
| 33 |
+
LOG2PI = np.log(2.0 * np.pi)
|
| 34 |
+
report = {}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------- (A) sufficiency
|
| 38 |
+
def gaussian_elbo(x, xhat, sigma2):
|
| 39 |
+
"""E[log p(x|f')] for p = N(xhat, sigma^2 I), in nats, per sample."""
|
| 40 |
+
d = x.shape[1]
|
| 41 |
+
sq = ((x - xhat) ** 2).sum(1)
|
| 42 |
+
return float((-0.5 * d * (LOG2PI + np.log(sigma2)) - sq / (2 * sigma2)).mean())
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def part_a():
|
| 46 |
+
n, d, sigma2 = 20000, 6, 0.7
|
| 47 |
+
x = RNG.normal(size=(n, d))
|
| 48 |
+
out = {}
|
| 49 |
+
|
| 50 |
+
# A1: log-likelihood is exactly affine in the MSE, with negative slope
|
| 51 |
+
mses, elbos = [], []
|
| 52 |
+
for scale in np.linspace(0.0, 2.0, 25):
|
| 53 |
+
xhat = x + scale * RNG.normal(size=(n, d))
|
| 54 |
+
mse = float(((x - xhat) ** 2).mean()) # per-element MSE = L_rec
|
| 55 |
+
mses.append(mse)
|
| 56 |
+
elbos.append(gaussian_elbo(x, xhat, sigma2))
|
| 57 |
+
mses, elbos = np.array(mses), np.array(elbos)
|
| 58 |
+
slope = -d / (2 * sigma2) / d # d(ELBO)/d(mse_per_element)
|
| 59 |
+
pred = elbos[0] + slope * d * (mses - mses[0])
|
| 60 |
+
out["affine_max_abs_dev_nats"] = float(np.abs(pred - elbos).max())
|
| 61 |
+
out["slope_sign_negative"] = bool(slope < 0)
|
| 62 |
+
out["spearman_mse_vs_elbo"] = float(
|
| 63 |
+
np.corrcoef(np.argsort(np.argsort(mses)), np.argsort(np.argsort(elbos)))[0, 1])
|
| 64 |
+
|
| 65 |
+
# A2: I_LB <= I(X;F') on a jointly Gaussian pair with closed-form MI.
|
| 66 |
+
# X = F' + noise, F' ~ N(0, I), noise ~ N(0, s^2 I): I = d/2 log(1 + 1/s^2).
|
| 67 |
+
s2 = 0.5
|
| 68 |
+
f = RNG.normal(size=(n, d))
|
| 69 |
+
xg = f + np.sqrt(s2) * RNG.normal(size=(n, d))
|
| 70 |
+
true_mi = 0.5 * d * np.log(1 + 1.0 / s2)
|
| 71 |
+
hx = 0.5 * d * (LOG2PI + np.log(1 + s2) + 1) # differential entropy of X
|
| 72 |
+
# optimal Gaussian decoder E[X|F'] = F', residual variance s2
|
| 73 |
+
i_lb = hx + gaussian_elbo(xg, f, s2)
|
| 74 |
+
out["true_MI_nats"] = float(true_mi)
|
| 75 |
+
out["I_LB_at_optimal_decoder_nats"] = float(i_lb)
|
| 76 |
+
out["bound_holds_I_LB_le_MI"] = bool(i_lb <= true_mi + 1e-9)
|
| 77 |
+
out["bound_gap_nats"] = float(true_mi - i_lb)
|
| 78 |
+
# a deliberately worse decoder must give a looser bound
|
| 79 |
+
i_lb_bad = hx + gaussian_elbo(xg, 0.5 * f, s2)
|
| 80 |
+
out["I_LB_suboptimal_decoder_nats"] = float(i_lb_bad)
|
| 81 |
+
out["worse_decoder_is_looser"] = bool(i_lb_bad < i_lb)
|
| 82 |
+
|
| 83 |
+
# CONTROL: Laplace decoder -> the MSE<->log-likelihood identity must break
|
| 84 |
+
b = 0.6
|
| 85 |
+
lap_ll, lap_mse = [], []
|
| 86 |
+
for scale in np.linspace(0.05, 2.0, 25):
|
| 87 |
+
xhat = x + scale * RNG.normal(size=(n, d))
|
| 88 |
+
lap_mse.append(float(((x - xhat) ** 2).mean()))
|
| 89 |
+
lap_ll.append(float((-d * np.log(2 * b) - np.abs(x - xhat).sum(1) / b).mean()))
|
| 90 |
+
lap_mse, lap_ll = np.array(lap_mse), np.array(lap_ll)
|
| 91 |
+
A = np.vstack([lap_mse, np.ones_like(lap_mse)]).T
|
| 92 |
+
coef, *_ = np.linalg.lstsq(A, lap_ll, rcond=None)
|
| 93 |
+
out["control_laplace_affine_max_abs_dev_nats"] = float(
|
| 94 |
+
np.abs(A @ coef - lap_ll).max())
|
| 95 |
+
return out
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ------------------------------------------------------------- (B) independence
|
| 99 |
+
def gaussian_tc_closed_form(cov):
|
| 100 |
+
return float(0.5 * np.log(np.diag(cov)).sum() - 0.5 * np.linalg.slogdet(cov)[1])
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def gaussian_tc_from_entropies(cov):
|
| 104 |
+
k = cov.shape[0]
|
| 105 |
+
h_marg = sum(0.5 * (LOG2PI + 1 + np.log(cov[i, i])) for i in range(k))
|
| 106 |
+
h_joint = 0.5 * (k * (LOG2PI + 1) + np.linalg.slogdet(cov)[1])
|
| 107 |
+
return float(h_marg - h_joint)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def tcr_loss(F, eps=0.5):
|
| 111 |
+
"""L_div of Eq. 3 for a single sample's token matrix F in R^{k x d}."""
|
| 112 |
+
k, d = F.shape
|
| 113 |
+
Fn = F / np.linalg.norm(F, axis=1, keepdims=True)
|
| 114 |
+
M = np.eye(k) + (d / (k * eps ** 2)) * (Fn @ Fn.T)
|
| 115 |
+
return float(-0.5 * np.linalg.slogdet(M)[1])
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def random_cov(k, rho):
|
| 119 |
+
"""Equicorrelated covariance with unit marginals: TC grows monotonically with rho."""
|
| 120 |
+
return (1 - rho) * np.eye(k) + rho * np.ones((k, k))
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def part_b():
|
| 124 |
+
out = {}
|
| 125 |
+
k = 8
|
| 126 |
+
|
| 127 |
+
# B1: the two Gaussian TC expressions (sum H(f_i) - H(F) vs Eq. 11) agree exactly
|
| 128 |
+
devs = []
|
| 129 |
+
for rho in np.linspace(0.0, 0.95, 40):
|
| 130 |
+
cov = random_cov(k, rho)
|
| 131 |
+
devs.append(abs(gaussian_tc_closed_form(cov) - gaussian_tc_from_entropies(cov)))
|
| 132 |
+
out["TC_identity_max_abs_dev_nats"] = float(max(devs))
|
| 133 |
+
|
| 134 |
+
# B2: TC = 0 iff the covariance is diagonal (Lemma 3.2 direction)
|
| 135 |
+
out["TC_at_rho_0"] = gaussian_tc_closed_form(random_cov(k, 0.0))
|
| 136 |
+
out["TC_at_rho_0.9"] = gaussian_tc_closed_form(random_cov(k, 0.9))
|
| 137 |
+
out["TC_monotone_in_rho"] = bool(
|
| 138 |
+
np.all(np.diff([gaussian_tc_closed_form(random_cov(k, r))
|
| 139 |
+
for r in np.linspace(0, 0.95, 40)]) > 0))
|
| 140 |
+
|
| 141 |
+
# B3: does the paper's L_div actually track TC? Sample token sets with a
|
| 142 |
+
# controlled correlation and compare the *ordering* and correlation of
|
| 143 |
+
# L_div against the true Gaussian TC of the token Gram/covariance.
|
| 144 |
+
d = 128
|
| 145 |
+
tcs, divs = [], []
|
| 146 |
+
for rho in np.linspace(0.0, 0.95, 40):
|
| 147 |
+
cov = random_cov(k, rho)
|
| 148 |
+
L = np.linalg.cholesky(cov)
|
| 149 |
+
F = L @ RNG.normal(size=(k, d)) # k tokens, correlation rho
|
| 150 |
+
emp = np.cov(F) # empirical k x k covariance
|
| 151 |
+
tcs.append(gaussian_tc_closed_form(emp))
|
| 152 |
+
divs.append(tcr_loss(F))
|
| 153 |
+
tcs, divs = np.array(tcs), np.array(divs)
|
| 154 |
+
out["pearson_Ldiv_vs_TC"] = float(np.corrcoef(divs, tcs)[0, 1])
|
| 155 |
+
# is the relation an *exact* identity, or only a monotone surrogate?
|
| 156 |
+
A = np.vstack([tcs, np.ones_like(tcs)]).T
|
| 157 |
+
coef, *_ = np.linalg.lstsq(A, divs, rcond=None)
|
| 158 |
+
out["affine_fit_Ldiv_vs_TC_slope"] = float(coef[0])
|
| 159 |
+
out["affine_fit_Ldiv_vs_TC_max_abs_resid_nats"] = float(np.abs(A @ coef - divs).max())
|
| 160 |
+
out["Ldiv_range_nats"] = float(divs.max() - divs.min())
|
| 161 |
+
out["spearman_Ldiv_vs_TC"] = float(np.corrcoef(
|
| 162 |
+
np.argsort(np.argsort(divs)), np.argsort(np.argsort(tcs)))[0, 1])
|
| 163 |
+
out["Ldiv_at_rho_0"] = float(divs[0])
|
| 164 |
+
out["Ldiv_at_rho_0.95"] = float(divs[-1])
|
| 165 |
+
out["Ldiv_increases_with_redundancy"] = bool(divs[-1] > divs[0])
|
| 166 |
+
# L_div is minimised by orthogonal tokens (Lemma 3.2)
|
| 167 |
+
Q = np.linalg.qr(RNG.normal(size=(d, k)))[0].T # k orthonormal rows
|
| 168 |
+
out["Ldiv_orthogonal_tokens"] = tcr_loss(Q)
|
| 169 |
+
dup = np.repeat(Q[:1], k, axis=0) + 1e-6 * RNG.normal(size=(k, d))
|
| 170 |
+
out["Ldiv_collapsed_tokens"] = tcr_loss(dup)
|
| 171 |
+
out["orthogonal_is_the_minimiser"] = bool(
|
| 172 |
+
out["Ldiv_orthogonal_tokens"] < out["Ldiv_collapsed_tokens"])
|
| 173 |
+
rand_devs = [tcr_loss(RNG.normal(size=(k, d))) for _ in range(200)]
|
| 174 |
+
out["Ldiv_random_tokens_mean"] = float(np.mean(rand_devs))
|
| 175 |
+
out["orthogonal_beats_random"] = bool(
|
| 176 |
+
out["Ldiv_orthogonal_tokens"] <= min(rand_devs))
|
| 177 |
+
|
| 178 |
+
# B4: Sylvester check - the d x d form in Eq. 3 and the k x k Gram form agree
|
| 179 |
+
F = RNG.normal(size=(k, d))
|
| 180 |
+
Fn = F / np.linalg.norm(F, axis=1, keepdims=True)
|
| 181 |
+
c = d / (k * 0.5 ** 2)
|
| 182 |
+
lhs = np.linalg.slogdet(np.eye(d) + c * (Fn.T @ Fn))[1]
|
| 183 |
+
rhs = np.linalg.slogdet(np.eye(k) + c * (Fn @ Fn.T))[1]
|
| 184 |
+
out["sylvester_abs_dev"] = float(abs(lhs - rhs))
|
| 185 |
+
|
| 186 |
+
# CONTROL: non-Gaussian latents -> the Gaussian entropy formula (and hence the
|
| 187 |
+
# claimed TC identity) is no longer valid. We compare the Gaussian-formula TC
|
| 188 |
+
# against a k-NN (Kozachenko-Leonenko) estimate of the true TC.
|
| 189 |
+
def kl_entropy(samples):
|
| 190 |
+
from scipy.spatial import cKDTree
|
| 191 |
+
from scipy.special import digamma, gammaln
|
| 192 |
+
n, dd = samples.shape
|
| 193 |
+
tree = cKDTree(samples)
|
| 194 |
+
eps_ = tree.query(samples, k=4)[0][:, 3]
|
| 195 |
+
eps_ = np.maximum(eps_, 1e-12)
|
| 196 |
+
log_vol = dd / 2 * np.log(np.pi) - gammaln(dd / 2 + 1)
|
| 197 |
+
return float(digamma(n) - digamma(3) + log_vol + dd * np.mean(np.log(eps_)))
|
| 198 |
+
|
| 199 |
+
def true_tc(samples):
|
| 200 |
+
h_joint = kl_entropy(samples)
|
| 201 |
+
h_marg = sum(kl_entropy(samples[:, [i]]) for i in range(samples.shape[1]))
|
| 202 |
+
return h_marg - h_joint
|
| 203 |
+
|
| 204 |
+
n, kk, rho = 40000, 3, 0.7
|
| 205 |
+
cov = random_cov(kk, rho)
|
| 206 |
+
L = np.linalg.cholesky(cov)
|
| 207 |
+
zg = RNG.normal(size=(n, kk)) @ L.T
|
| 208 |
+
# matched-covariance uniform (platykurtic) and Student-t (heavy-tailed) latents
|
| 209 |
+
u = (RNG.uniform(-np.sqrt(3), np.sqrt(3), size=(n, kk))) @ L.T
|
| 210 |
+
t = (RNG.standard_t(3, size=(n, kk)) / np.sqrt(3.0)) @ L.T
|
| 211 |
+
for nm, z in (("gaussian", zg), ("uniform", u), ("student_t3", t)):
|
| 212 |
+
emp = np.cov(z.T)
|
| 213 |
+
out[f"control_{nm}_TC_gauss_formula"] = gaussian_tc_closed_form(emp)
|
| 214 |
+
out[f"control_{nm}_TC_knn_estimate"] = float(true_tc(z))
|
| 215 |
+
out[f"control_{nm}_abs_error"] = abs(
|
| 216 |
+
out[f"control_{nm}_TC_gauss_formula"] - out[f"control_{nm}_TC_knn_estimate"])
|
| 217 |
+
return out
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
if __name__ == "__main__":
|
| 221 |
+
report["A_sufficiency"] = part_a()
|
| 222 |
+
report["B_independence"] = part_b()
|
| 223 |
+
print(json.dumps(report, indent=2))
|
| 224 |
+
with open("results/theorem31_audit.json", "w") as fh:
|
| 225 |
+
json.dump(report, fh, indent=2)
|
| 226 |
+
|
| 227 |
+
a, b = report["A_sufficiency"], report["B_independence"]
|
| 228 |
+
print("\n---- verdict ----")
|
| 229 |
+
print(f"A. MSE <-> Gaussian ELBO affine identity: max dev "
|
| 230 |
+
f"{a['affine_max_abs_dev_nats']:.3e} nats "
|
| 231 |
+
f"(Laplace control: {a['control_laplace_affine_max_abs_dev_nats']:.3f} nats)")
|
| 232 |
+
print(f"A. I_LB <= I(X;F'): {a['bound_holds_I_LB_le_MI']} "
|
| 233 |
+
f"(gap {a['bound_gap_nats']:.3e} nats at the optimal decoder)")
|
| 234 |
+
print(f"B. Gaussian TC identity: max dev {b['TC_identity_max_abs_dev_nats']:.3e} nats")
|
| 235 |
+
print(f"B. Sylvester d x d == k x k: {b['sylvester_abs_dev']:.3e}")
|
| 236 |
+
print(f"B. corr(L_div, TC) = {b['pearson_Ldiv_vs_TC']:.4f} "
|
| 237 |
+
f"(Spearman {b['spearman_Ldiv_vs_TC']:.4f})")
|
| 238 |
+
print(f"B. L_div(orthogonal) = {b['Ldiv_orthogonal_tokens']:.3f} < "
|
| 239 |
+
f"L_div(collapsed) = {b['Ldiv_collapsed_tokens']:.3f}")
|
| 240 |
+
print("B. control (Gaussian formula vs kNN TC): " + ", ".join(
|
| 241 |
+
f"{nm} err {b[f'control_{nm}_abs_error']:.3f}"
|
| 242 |
+
for nm in ("gaussian", "uniform", "student_t3")))
|
fig_theorem31.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Figures for the Theorem 3.1 audit (Claim 2)."""
|
| 2 |
+
|
| 3 |
+
import csv
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
from plotly.subplots import make_subplots
|
| 9 |
+
|
| 10 |
+
RNG = np.random.default_rng(0)
|
| 11 |
+
LOG2PI = np.log(2 * np.pi)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def tcr(F, eps=0.5):
|
| 15 |
+
k, d = F.shape
|
| 16 |
+
Fn = F / np.linalg.norm(F, axis=1, keepdims=True)
|
| 17 |
+
return float(-0.5 * np.linalg.slogdet(
|
| 18 |
+
np.eye(k) + (d / (k * eps ** 2)) * (Fn @ Fn.T))[1])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def gauss_tc(cov):
|
| 22 |
+
return float(0.5 * np.log(np.diag(cov)).sum() - 0.5 * np.linalg.slogdet(cov)[1])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
k, d = 8, 128
|
| 26 |
+
rhos = np.linspace(0.0, 0.95, 40)
|
| 27 |
+
tcs, divs = [], []
|
| 28 |
+
for rho in rhos:
|
| 29 |
+
cov = (1 - rho) * np.eye(k) + rho * np.ones((k, k))
|
| 30 |
+
F = np.linalg.cholesky(cov) @ RNG.normal(size=(k, d))
|
| 31 |
+
tcs.append(gauss_tc(np.cov(F)))
|
| 32 |
+
divs.append(tcr(F))
|
| 33 |
+
tcs, divs = np.array(tcs), np.array(divs)
|
| 34 |
+
|
| 35 |
+
# panel B: MSE vs Gaussian ELBO (exact) and vs Laplace log-likelihood (control)
|
| 36 |
+
n, dd, s2, b = 20000, 6, 0.7, 0.6
|
| 37 |
+
x = RNG.normal(size=(n, dd))
|
| 38 |
+
scales = np.linspace(0.05, 2.0, 25)
|
| 39 |
+
mses, gll, lll = [], [], []
|
| 40 |
+
for s in scales:
|
| 41 |
+
xh = x + s * RNG.normal(size=(n, dd))
|
| 42 |
+
mses.append(float(((x - xh) ** 2).mean()))
|
| 43 |
+
sq = ((x - xh) ** 2).sum(1)
|
| 44 |
+
gll.append(float((-0.5 * dd * (LOG2PI + np.log(s2)) - sq / (2 * s2)).mean()))
|
| 45 |
+
lll.append(float((-dd * np.log(2 * b) - np.abs(x - xh).sum(1) / b).mean()))
|
| 46 |
+
mses, gll, lll = np.array(mses), np.array(gll), np.array(lll)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def resid(xv, yv):
|
| 50 |
+
A = np.vstack([xv, np.ones_like(xv)]).T
|
| 51 |
+
c, *_ = np.linalg.lstsq(A, yv, rcond=None)
|
| 52 |
+
return A @ c - yv
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
fig = make_subplots(
|
| 56 |
+
rows=1, cols=2, horizontal_spacing=0.11,
|
| 57 |
+
subplot_titles=(
|
| 58 |
+
"Leg B — ℒ_div tracks Total Correlation (r = 0.9995)",
|
| 59 |
+
"Leg A — MSE ⇔ log-likelihood: exact for Gaussian, broken for Laplace"))
|
| 60 |
+
|
| 61 |
+
fig.add_scatter(x=tcs, y=divs, mode="markers", name="ℒ_div vs TC (Gaussian latents)",
|
| 62 |
+
marker=dict(size=8, color="#2F6F8F"), row=1, col=1)
|
| 63 |
+
A = np.vstack([tcs, np.ones_like(tcs)]).T
|
| 64 |
+
c, *_ = np.linalg.lstsq(A, divs, rcond=None)
|
| 65 |
+
fig.add_scatter(x=tcs, y=A @ c, mode="lines", name=f"best affine fit (slope {c[0]:.3f})",
|
| 66 |
+
line=dict(color="#B07C2B", dash="dash"), row=1, col=1)
|
| 67 |
+
fig.update_xaxes(title_text="true Gaussian TC(F′) [nats]", row=1, col=1)
|
| 68 |
+
fig.update_yaxes(title_text="ℒ_div (Eq. 3)", row=1, col=1)
|
| 69 |
+
|
| 70 |
+
fig.add_scatter(x=mses, y=resid(mses, gll), mode="markers+lines",
|
| 71 |
+
name="Gaussian decoder — residual from affine",
|
| 72 |
+
marker=dict(size=7, color="#2F6F8F"), row=1, col=2)
|
| 73 |
+
fig.add_scatter(x=mses, y=resid(mses, lll), mode="markers+lines",
|
| 74 |
+
name="Laplace decoder (CONTROL) — residual",
|
| 75 |
+
marker=dict(size=7, color="#A33B3B"), row=1, col=2)
|
| 76 |
+
fig.update_xaxes(title_text="reconstruction MSE (ℒ_rec)", row=1, col=2)
|
| 77 |
+
fig.update_yaxes(title_text="residual from best affine fit [nats]", row=1, col=2)
|
| 78 |
+
|
| 79 |
+
fig.update_layout(template="plotly_white", height=440,
|
| 80 |
+
legend=dict(orientation="h", y=-0.22),
|
| 81 |
+
title="Theorem 3.1 numerical audit — both legs, each with its control")
|
| 82 |
+
fig.write_html("results/claim2_theorem31.html", include_plotlyjs="cdn")
|
| 83 |
+
|
| 84 |
+
with open("results/claim2_theorem31.csv", "w", newline="") as fh:
|
| 85 |
+
w = csv.writer(fh)
|
| 86 |
+
w.writerow(["panel", "x", "y", "series"])
|
| 87 |
+
for a, bb in zip(tcs, divs):
|
| 88 |
+
w.writerow(["B_ldiv_vs_tc", f"{a:.6f}", f"{bb:.6f}", "ldiv"])
|
| 89 |
+
rg, rl = resid(mses, gll), resid(mses, lll)
|
| 90 |
+
for m, g, l in zip(mses, rg, rl):
|
| 91 |
+
w.writerow(["A_residual", f"{m:.6f}", f"{g:.6e}", "gaussian"])
|
| 92 |
+
w.writerow(["A_residual", f"{m:.6f}", f"{l:.6e}", "laplace_control"])
|
| 93 |
+
|
| 94 |
+
print(json.dumps({
|
| 95 |
+
"ldiv_vs_tc_slope": float(c[0]),
|
| 96 |
+
"ldiv_vs_tc_max_resid_nats": float(np.abs(A @ c - divs).max()),
|
| 97 |
+
"gaussian_max_resid_nats": float(np.abs(resid(mses, gll)).max()),
|
| 98 |
+
"laplace_control_max_resid_nats": float(np.abs(resid(mses, lll)).max()),
|
| 99 |
+
}, indent=2))
|
| 100 |
+
print("wrote results/claim2_theorem31.html + .csv")
|
jobs_adftd.json
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"tsfp_scratch": [
|
| 3 |
+
"--data-dir",
|
| 4 |
+
"data/ADFTD/processed",
|
| 5 |
+
"--batch-size",
|
| 6 |
+
"256",
|
| 7 |
+
"--pre-epochs",
|
| 8 |
+
"100",
|
| 9 |
+
"--ft-epochs",
|
| 10 |
+
"100",
|
| 11 |
+
"--patience",
|
| 12 |
+
"10",
|
| 13 |
+
"--seeds",
|
| 14 |
+
"41",
|
| 15 |
+
"42",
|
| 16 |
+
"43",
|
| 17 |
+
"--model",
|
| 18 |
+
"tsfp",
|
| 19 |
+
"--mode",
|
| 20 |
+
"scratch"
|
| 21 |
+
],
|
| 22 |
+
"tsfp_rec": [
|
| 23 |
+
"--data-dir",
|
| 24 |
+
"data/ADFTD/processed",
|
| 25 |
+
"--batch-size",
|
| 26 |
+
"256",
|
| 27 |
+
"--pre-epochs",
|
| 28 |
+
"100",
|
| 29 |
+
"--ft-epochs",
|
| 30 |
+
"100",
|
| 31 |
+
"--patience",
|
| 32 |
+
"10",
|
| 33 |
+
"--seeds",
|
| 34 |
+
"41",
|
| 35 |
+
"42",
|
| 36 |
+
"43",
|
| 37 |
+
"--model",
|
| 38 |
+
"tsfp",
|
| 39 |
+
"--mode",
|
| 40 |
+
"pretrain_ft",
|
| 41 |
+
"--use-div",
|
| 42 |
+
"0"
|
| 43 |
+
],
|
| 44 |
+
"tsfp_rec_div": [
|
| 45 |
+
"--data-dir",
|
| 46 |
+
"data/ADFTD/processed",
|
| 47 |
+
"--batch-size",
|
| 48 |
+
"256",
|
| 49 |
+
"--pre-epochs",
|
| 50 |
+
"100",
|
| 51 |
+
"--ft-epochs",
|
| 52 |
+
"100",
|
| 53 |
+
"--patience",
|
| 54 |
+
"10",
|
| 55 |
+
"--seeds",
|
| 56 |
+
"41",
|
| 57 |
+
"42",
|
| 58 |
+
"43",
|
| 59 |
+
"--model",
|
| 60 |
+
"tsfp",
|
| 61 |
+
"--mode",
|
| 62 |
+
"pretrain_ft",
|
| 63 |
+
"--use-div",
|
| 64 |
+
"1"
|
| 65 |
+
],
|
| 66 |
+
"timae_scratch": [
|
| 67 |
+
"--data-dir",
|
| 68 |
+
"data/ADFTD/processed",
|
| 69 |
+
"--batch-size",
|
| 70 |
+
"256",
|
| 71 |
+
"--pre-epochs",
|
| 72 |
+
"100",
|
| 73 |
+
"--ft-epochs",
|
| 74 |
+
"100",
|
| 75 |
+
"--patience",
|
| 76 |
+
"10",
|
| 77 |
+
"--seeds",
|
| 78 |
+
"41",
|
| 79 |
+
"42",
|
| 80 |
+
"43",
|
| 81 |
+
"--model",
|
| 82 |
+
"timae",
|
| 83 |
+
"--mode",
|
| 84 |
+
"scratch"
|
| 85 |
+
],
|
| 86 |
+
"timae_pretrain": [
|
| 87 |
+
"--data-dir",
|
| 88 |
+
"data/ADFTD/processed",
|
| 89 |
+
"--batch-size",
|
| 90 |
+
"256",
|
| 91 |
+
"--pre-epochs",
|
| 92 |
+
"100",
|
| 93 |
+
"--ft-epochs",
|
| 94 |
+
"100",
|
| 95 |
+
"--patience",
|
| 96 |
+
"10",
|
| 97 |
+
"--seeds",
|
| 98 |
+
"41",
|
| 99 |
+
"42",
|
| 100 |
+
"43",
|
| 101 |
+
"--model",
|
| 102 |
+
"timae",
|
| 103 |
+
"--mode",
|
| 104 |
+
"pretrain_ft"
|
| 105 |
+
],
|
| 106 |
+
"simmtm_scratch": [
|
| 107 |
+
"--data-dir",
|
| 108 |
+
"data/ADFTD/processed",
|
| 109 |
+
"--batch-size",
|
| 110 |
+
"256",
|
| 111 |
+
"--pre-epochs",
|
| 112 |
+
"100",
|
| 113 |
+
"--ft-epochs",
|
| 114 |
+
"100",
|
| 115 |
+
"--patience",
|
| 116 |
+
"10",
|
| 117 |
+
"--seeds",
|
| 118 |
+
"41",
|
| 119 |
+
"42",
|
| 120 |
+
"43",
|
| 121 |
+
"--model",
|
| 122 |
+
"simmtm",
|
| 123 |
+
"--mode",
|
| 124 |
+
"scratch"
|
| 125 |
+
],
|
| 126 |
+
"simmtm_pretrain": [
|
| 127 |
+
"--data-dir",
|
| 128 |
+
"data/ADFTD/processed",
|
| 129 |
+
"--batch-size",
|
| 130 |
+
"256",
|
| 131 |
+
"--pre-epochs",
|
| 132 |
+
"100",
|
| 133 |
+
"--ft-epochs",
|
| 134 |
+
"100",
|
| 135 |
+
"--patience",
|
| 136 |
+
"10",
|
| 137 |
+
"--seeds",
|
| 138 |
+
"41",
|
| 139 |
+
"42",
|
| 140 |
+
"43",
|
| 141 |
+
"--model",
|
| 142 |
+
"simmtm",
|
| 143 |
+
"--mode",
|
| 144 |
+
"pretrain_ft"
|
| 145 |
+
]
|
| 146 |
+
}
|
jobs_simmtm.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"simmtm_pretrain": [
|
| 3 |
+
"--data-dir",
|
| 4 |
+
"data/ADFTD/processed",
|
| 5 |
+
"--batch-size",
|
| 6 |
+
"256",
|
| 7 |
+
"--pre-epochs",
|
| 8 |
+
"100",
|
| 9 |
+
"--ft-epochs",
|
| 10 |
+
"100",
|
| 11 |
+
"--patience",
|
| 12 |
+
"10",
|
| 13 |
+
"--seeds",
|
| 14 |
+
"41",
|
| 15 |
+
"42",
|
| 16 |
+
"--model",
|
| 17 |
+
"simmtm",
|
| 18 |
+
"--mode",
|
| 19 |
+
"pretrain_ft"
|
| 20 |
+
]
|
| 21 |
+
}
|
jobs_sweep.json
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ptbxl_k6_r0.5": [
|
| 3 |
+
"--data-dir",
|
| 4 |
+
"data/PTBXL/processed",
|
| 5 |
+
"--batch-size",
|
| 6 |
+
"256",
|
| 7 |
+
"--patch-size",
|
| 8 |
+
"10",
|
| 9 |
+
"--pre-epochs",
|
| 10 |
+
"30",
|
| 11 |
+
"--ft-epochs",
|
| 12 |
+
"60",
|
| 13 |
+
"--patience",
|
| 14 |
+
"8",
|
| 15 |
+
"--stride",
|
| 16 |
+
"5",
|
| 17 |
+
"--seeds",
|
| 18 |
+
"41",
|
| 19 |
+
"--model",
|
| 20 |
+
"tsfp",
|
| 21 |
+
"--mode",
|
| 22 |
+
"pretrain_ft",
|
| 23 |
+
"--use-div",
|
| 24 |
+
"1",
|
| 25 |
+
"--k",
|
| 26 |
+
"6",
|
| 27 |
+
"--mask-ratio",
|
| 28 |
+
"0.5"
|
| 29 |
+
],
|
| 30 |
+
"ptbxl_k6_r0.6": [
|
| 31 |
+
"--data-dir",
|
| 32 |
+
"data/PTBXL/processed",
|
| 33 |
+
"--batch-size",
|
| 34 |
+
"256",
|
| 35 |
+
"--patch-size",
|
| 36 |
+
"10",
|
| 37 |
+
"--pre-epochs",
|
| 38 |
+
"30",
|
| 39 |
+
"--ft-epochs",
|
| 40 |
+
"60",
|
| 41 |
+
"--patience",
|
| 42 |
+
"8",
|
| 43 |
+
"--stride",
|
| 44 |
+
"5",
|
| 45 |
+
"--seeds",
|
| 46 |
+
"41",
|
| 47 |
+
"--model",
|
| 48 |
+
"tsfp",
|
| 49 |
+
"--mode",
|
| 50 |
+
"pretrain_ft",
|
| 51 |
+
"--use-div",
|
| 52 |
+
"1",
|
| 53 |
+
"--k",
|
| 54 |
+
"6",
|
| 55 |
+
"--mask-ratio",
|
| 56 |
+
"0.6"
|
| 57 |
+
],
|
| 58 |
+
"ptbxl_k6_r0.7": [
|
| 59 |
+
"--data-dir",
|
| 60 |
+
"data/PTBXL/processed",
|
| 61 |
+
"--batch-size",
|
| 62 |
+
"256",
|
| 63 |
+
"--patch-size",
|
| 64 |
+
"10",
|
| 65 |
+
"--pre-epochs",
|
| 66 |
+
"30",
|
| 67 |
+
"--ft-epochs",
|
| 68 |
+
"60",
|
| 69 |
+
"--patience",
|
| 70 |
+
"8",
|
| 71 |
+
"--stride",
|
| 72 |
+
"5",
|
| 73 |
+
"--seeds",
|
| 74 |
+
"41",
|
| 75 |
+
"--model",
|
| 76 |
+
"tsfp",
|
| 77 |
+
"--mode",
|
| 78 |
+
"pretrain_ft",
|
| 79 |
+
"--use-div",
|
| 80 |
+
"1",
|
| 81 |
+
"--k",
|
| 82 |
+
"6",
|
| 83 |
+
"--mask-ratio",
|
| 84 |
+
"0.7"
|
| 85 |
+
],
|
| 86 |
+
"ptbxl_k6_r0.8": [
|
| 87 |
+
"--data-dir",
|
| 88 |
+
"data/PTBXL/processed",
|
| 89 |
+
"--batch-size",
|
| 90 |
+
"256",
|
| 91 |
+
"--patch-size",
|
| 92 |
+
"10",
|
| 93 |
+
"--pre-epochs",
|
| 94 |
+
"30",
|
| 95 |
+
"--ft-epochs",
|
| 96 |
+
"60",
|
| 97 |
+
"--patience",
|
| 98 |
+
"8",
|
| 99 |
+
"--stride",
|
| 100 |
+
"5",
|
| 101 |
+
"--seeds",
|
| 102 |
+
"41",
|
| 103 |
+
"--model",
|
| 104 |
+
"tsfp",
|
| 105 |
+
"--mode",
|
| 106 |
+
"pretrain_ft",
|
| 107 |
+
"--use-div",
|
| 108 |
+
"1",
|
| 109 |
+
"--k",
|
| 110 |
+
"6",
|
| 111 |
+
"--mask-ratio",
|
| 112 |
+
"0.8"
|
| 113 |
+
],
|
| 114 |
+
"ptbxl_k8_r0.5": [
|
| 115 |
+
"--data-dir",
|
| 116 |
+
"data/PTBXL/processed",
|
| 117 |
+
"--batch-size",
|
| 118 |
+
"256",
|
| 119 |
+
"--patch-size",
|
| 120 |
+
"10",
|
| 121 |
+
"--pre-epochs",
|
| 122 |
+
"30",
|
| 123 |
+
"--ft-epochs",
|
| 124 |
+
"60",
|
| 125 |
+
"--patience",
|
| 126 |
+
"8",
|
| 127 |
+
"--stride",
|
| 128 |
+
"5",
|
| 129 |
+
"--seeds",
|
| 130 |
+
"41",
|
| 131 |
+
"--model",
|
| 132 |
+
"tsfp",
|
| 133 |
+
"--mode",
|
| 134 |
+
"pretrain_ft",
|
| 135 |
+
"--use-div",
|
| 136 |
+
"1",
|
| 137 |
+
"--k",
|
| 138 |
+
"8",
|
| 139 |
+
"--mask-ratio",
|
| 140 |
+
"0.5"
|
| 141 |
+
],
|
| 142 |
+
"ptbxl_k8_r0.6": [
|
| 143 |
+
"--data-dir",
|
| 144 |
+
"data/PTBXL/processed",
|
| 145 |
+
"--batch-size",
|
| 146 |
+
"256",
|
| 147 |
+
"--patch-size",
|
| 148 |
+
"10",
|
| 149 |
+
"--pre-epochs",
|
| 150 |
+
"30",
|
| 151 |
+
"--ft-epochs",
|
| 152 |
+
"60",
|
| 153 |
+
"--patience",
|
| 154 |
+
"8",
|
| 155 |
+
"--stride",
|
| 156 |
+
"5",
|
| 157 |
+
"--seeds",
|
| 158 |
+
"41",
|
| 159 |
+
"--model",
|
| 160 |
+
"tsfp",
|
| 161 |
+
"--mode",
|
| 162 |
+
"pretrain_ft",
|
| 163 |
+
"--use-div",
|
| 164 |
+
"1",
|
| 165 |
+
"--k",
|
| 166 |
+
"8",
|
| 167 |
+
"--mask-ratio",
|
| 168 |
+
"0.6"
|
| 169 |
+
],
|
| 170 |
+
"ptbxl_k8_r0.7": [
|
| 171 |
+
"--data-dir",
|
| 172 |
+
"data/PTBXL/processed",
|
| 173 |
+
"--batch-size",
|
| 174 |
+
"256",
|
| 175 |
+
"--patch-size",
|
| 176 |
+
"10",
|
| 177 |
+
"--pre-epochs",
|
| 178 |
+
"30",
|
| 179 |
+
"--ft-epochs",
|
| 180 |
+
"60",
|
| 181 |
+
"--patience",
|
| 182 |
+
"8",
|
| 183 |
+
"--stride",
|
| 184 |
+
"5",
|
| 185 |
+
"--seeds",
|
| 186 |
+
"41",
|
| 187 |
+
"--model",
|
| 188 |
+
"tsfp",
|
| 189 |
+
"--mode",
|
| 190 |
+
"pretrain_ft",
|
| 191 |
+
"--use-div",
|
| 192 |
+
"1",
|
| 193 |
+
"--k",
|
| 194 |
+
"8",
|
| 195 |
+
"--mask-ratio",
|
| 196 |
+
"0.7"
|
| 197 |
+
],
|
| 198 |
+
"ptbxl_k8_r0.8": [
|
| 199 |
+
"--data-dir",
|
| 200 |
+
"data/PTBXL/processed",
|
| 201 |
+
"--batch-size",
|
| 202 |
+
"256",
|
| 203 |
+
"--patch-size",
|
| 204 |
+
"10",
|
| 205 |
+
"--pre-epochs",
|
| 206 |
+
"30",
|
| 207 |
+
"--ft-epochs",
|
| 208 |
+
"60",
|
| 209 |
+
"--patience",
|
| 210 |
+
"8",
|
| 211 |
+
"--stride",
|
| 212 |
+
"5",
|
| 213 |
+
"--seeds",
|
| 214 |
+
"41",
|
| 215 |
+
"--model",
|
| 216 |
+
"tsfp",
|
| 217 |
+
"--mode",
|
| 218 |
+
"pretrain_ft",
|
| 219 |
+
"--use-div",
|
| 220 |
+
"1",
|
| 221 |
+
"--k",
|
| 222 |
+
"8",
|
| 223 |
+
"--mask-ratio",
|
| 224 |
+
"0.8"
|
| 225 |
+
],
|
| 226 |
+
"ptbxl_k10_r0.5": [
|
| 227 |
+
"--data-dir",
|
| 228 |
+
"data/PTBXL/processed",
|
| 229 |
+
"--batch-size",
|
| 230 |
+
"256",
|
| 231 |
+
"--patch-size",
|
| 232 |
+
"10",
|
| 233 |
+
"--pre-epochs",
|
| 234 |
+
"30",
|
| 235 |
+
"--ft-epochs",
|
| 236 |
+
"60",
|
| 237 |
+
"--patience",
|
| 238 |
+
"8",
|
| 239 |
+
"--stride",
|
| 240 |
+
"5",
|
| 241 |
+
"--seeds",
|
| 242 |
+
"41",
|
| 243 |
+
"--model",
|
| 244 |
+
"tsfp",
|
| 245 |
+
"--mode",
|
| 246 |
+
"pretrain_ft",
|
| 247 |
+
"--use-div",
|
| 248 |
+
"1",
|
| 249 |
+
"--k",
|
| 250 |
+
"10",
|
| 251 |
+
"--mask-ratio",
|
| 252 |
+
"0.5"
|
| 253 |
+
],
|
| 254 |
+
"ptbxl_k10_r0.6": [
|
| 255 |
+
"--data-dir",
|
| 256 |
+
"data/PTBXL/processed",
|
| 257 |
+
"--batch-size",
|
| 258 |
+
"256",
|
| 259 |
+
"--patch-size",
|
| 260 |
+
"10",
|
| 261 |
+
"--pre-epochs",
|
| 262 |
+
"30",
|
| 263 |
+
"--ft-epochs",
|
| 264 |
+
"60",
|
| 265 |
+
"--patience",
|
| 266 |
+
"8",
|
| 267 |
+
"--stride",
|
| 268 |
+
"5",
|
| 269 |
+
"--seeds",
|
| 270 |
+
"41",
|
| 271 |
+
"--model",
|
| 272 |
+
"tsfp",
|
| 273 |
+
"--mode",
|
| 274 |
+
"pretrain_ft",
|
| 275 |
+
"--use-div",
|
| 276 |
+
"1",
|
| 277 |
+
"--k",
|
| 278 |
+
"10",
|
| 279 |
+
"--mask-ratio",
|
| 280 |
+
"0.6"
|
| 281 |
+
],
|
| 282 |
+
"ptbxl_k10_r0.7": [
|
| 283 |
+
"--data-dir",
|
| 284 |
+
"data/PTBXL/processed",
|
| 285 |
+
"--batch-size",
|
| 286 |
+
"256",
|
| 287 |
+
"--patch-size",
|
| 288 |
+
"10",
|
| 289 |
+
"--pre-epochs",
|
| 290 |
+
"30",
|
| 291 |
+
"--ft-epochs",
|
| 292 |
+
"60",
|
| 293 |
+
"--patience",
|
| 294 |
+
"8",
|
| 295 |
+
"--stride",
|
| 296 |
+
"5",
|
| 297 |
+
"--seeds",
|
| 298 |
+
"41",
|
| 299 |
+
"--model",
|
| 300 |
+
"tsfp",
|
| 301 |
+
"--mode",
|
| 302 |
+
"pretrain_ft",
|
| 303 |
+
"--use-div",
|
| 304 |
+
"1",
|
| 305 |
+
"--k",
|
| 306 |
+
"10",
|
| 307 |
+
"--mask-ratio",
|
| 308 |
+
"0.7"
|
| 309 |
+
],
|
| 310 |
+
"ptbxl_k10_r0.8": [
|
| 311 |
+
"--data-dir",
|
| 312 |
+
"data/PTBXL/processed",
|
| 313 |
+
"--batch-size",
|
| 314 |
+
"256",
|
| 315 |
+
"--patch-size",
|
| 316 |
+
"10",
|
| 317 |
+
"--pre-epochs",
|
| 318 |
+
"30",
|
| 319 |
+
"--ft-epochs",
|
| 320 |
+
"60",
|
| 321 |
+
"--patience",
|
| 322 |
+
"8",
|
| 323 |
+
"--stride",
|
| 324 |
+
"5",
|
| 325 |
+
"--seeds",
|
| 326 |
+
"41",
|
| 327 |
+
"--model",
|
| 328 |
+
"tsfp",
|
| 329 |
+
"--mode",
|
| 330 |
+
"pretrain_ft",
|
| 331 |
+
"--use-div",
|
| 332 |
+
"1",
|
| 333 |
+
"--k",
|
| 334 |
+
"10",
|
| 335 |
+
"--mask-ratio",
|
| 336 |
+
"0.8"
|
| 337 |
+
]
|
| 338 |
+
}
|
make_poster_figs.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Render the poster's PNG figures from the logged reproduction results.
|
| 2 |
+
|
| 3 |
+
Every number here comes from results/*.json produced by the runs; nothing is
|
| 4 |
+
hand-entered except the paper's own reported values, which are labelled as such.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import plotly.graph_objects as go
|
| 12 |
+
from plotly.subplots import make_subplots
|
| 13 |
+
|
| 14 |
+
OUT = "poster/images"
|
| 15 |
+
os.makedirs(OUT, exist_ok=True)
|
| 16 |
+
ACCENT, GOLD, GREY, RED = "#17697B", "#B07C2B", "#8A8A8A", "#A33B3B"
|
| 17 |
+
FONT = dict(family="Helvetica, Arial, sans-serif", size=30, color="#1A1A1A")
|
| 18 |
+
SCALE = 3
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def save(fig, name, w, h):
|
| 22 |
+
fig.update_layout(template="plotly_white", font=FONT,
|
| 23 |
+
margin=dict(l=90, r=30, t=80, b=70))
|
| 24 |
+
fig.write_image(f"{OUT}/{name}.png", width=w, height=h, scale=SCALE)
|
| 25 |
+
print(f"wrote {OUT}/{name}.png ({w*SCALE}x{h*SCALE} px)")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ------------------------------------------------------------------ Fig 1: Claim 3
|
| 29 |
+
def fig_ablation():
|
| 30 |
+
s = json.load(open("results/adftd_summary.json"))
|
| 31 |
+
rows = {r["config"]: r for r in s["rows"]}
|
| 32 |
+
order = ["tsfp_scratch", "tsfp_rec", "tsfp_rec_div"]
|
| 33 |
+
order = [o for o in order if o in rows]
|
| 34 |
+
if len(order) < 3:
|
| 35 |
+
print("skip ablation fig (incomplete)")
|
| 36 |
+
return
|
| 37 |
+
xs = ["Scratch", "Pre-trained<br>(ℒ_rec)", "Pre-trained<br>(ℒ_rec + ℒ_div)"]
|
| 38 |
+
fig = go.Figure()
|
| 39 |
+
fig.add_bar(name="this reproduction (3 seeds)", x=xs,
|
| 40 |
+
y=[rows[o]["f1"] for o in order],
|
| 41 |
+
error_y=dict(type="data", array=[rows[o]["f1_sd"] for o in order],
|
| 42 |
+
thickness=3, width=14),
|
| 43 |
+
marker_color=ACCENT,
|
| 44 |
+
text=[f"{rows[o]['f1']:.2f}" for o in order],
|
| 45 |
+
textposition="outside", textfont=dict(size=32))
|
| 46 |
+
fig.add_bar(name="paper (Table 2)", x=xs,
|
| 47 |
+
y=[rows[o]["paper_f1"] for o in order], marker_color=GOLD,
|
| 48 |
+
text=[f"{rows[o]['paper_f1']:.2f}" for o in order],
|
| 49 |
+
textposition="outside", textfont=dict(size=32))
|
| 50 |
+
fig.update_layout(barmode="group", yaxis_title="ADFTD macro F1 (%)",
|
| 51 |
+
yaxis_range=[38, 58], height=620, width=1000,
|
| 52 |
+
legend=dict(orientation="h", y=1.13, x=0))
|
| 53 |
+
save(fig, "claim3_ablation", 1000, 620)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ------------------------------------------------------------------ Fig 2: Claim 4
|
| 57 |
+
def fig_promotion():
|
| 58 |
+
s = json.load(open("results/adftd_summary.json"))
|
| 59 |
+
p = s["promotions"]
|
| 60 |
+
names, ours = [], []
|
| 61 |
+
for k in ("TS-Fingerprint", "SimMTM", "Ti-MAE"):
|
| 62 |
+
if p[k]["f1"]:
|
| 63 |
+
names.append(k)
|
| 64 |
+
ours.append(p[k]["f1"]["rel_pct"])
|
| 65 |
+
if not names:
|
| 66 |
+
print("skip promotion fig")
|
| 67 |
+
return
|
| 68 |
+
paper = {"TS-Fingerprint": 13.07, "SimMTM": 4.42, "Ti-MAE": -0.96}
|
| 69 |
+
fig = go.Figure()
|
| 70 |
+
fig.add_bar(name="this reproduction", x=names, y=ours, marker_color=ACCENT,
|
| 71 |
+
text=[f"{v:+.1f}%" for v in ours], textposition="outside",
|
| 72 |
+
textfont=dict(size=32))
|
| 73 |
+
fig.add_bar(name="paper (Table 4)", x=names, y=[paper[n] for n in names],
|
| 74 |
+
marker_color=GOLD, text=[f"{paper[n]:+.1f}%" for n in names],
|
| 75 |
+
textposition="outside", textfont=dict(size=32))
|
| 76 |
+
lo = min(ours + [paper[n] for n in names])
|
| 77 |
+
hi = max(ours + [paper[n] for n in names])
|
| 78 |
+
fig.add_hline(y=0, line=dict(color="#555", width=2))
|
| 79 |
+
fig.update_layout(barmode="group",
|
| 80 |
+
yaxis_title="relative F1 gain from pre-training (%)",
|
| 81 |
+
yaxis_range=[min(lo * 1.5, -3), hi * 1.35],
|
| 82 |
+
height=620, width=1000,
|
| 83 |
+
legend=dict(orientation="h", y=1.13, x=0))
|
| 84 |
+
save(fig, "claim4_promotion", 1000, 620)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ------------------------------------------------------------------ Fig 3: Claim 6
|
| 88 |
+
def fig_sweep():
|
| 89 |
+
p = "results/sweep_summary.json"
|
| 90 |
+
if not os.path.exists(p):
|
| 91 |
+
print("skip sweep fig (not run yet)")
|
| 92 |
+
return
|
| 93 |
+
d = json.load(open(p))
|
| 94 |
+
ks, rs = [6, 8, 10], [0.5, 0.6, 0.7, 0.8]
|
| 95 |
+
ours = np.full((3, 4), np.nan)
|
| 96 |
+
for row in d["rows"]:
|
| 97 |
+
ours[ks.index(row["k"]), rs.index(row["r"])] = row["f1"]
|
| 98 |
+
paper = np.array([[54.81, 56.68, 55.41, 54.32],
|
| 99 |
+
[62.60, 63.51, 62.10, 59.19],
|
| 100 |
+
[51.76, 50.70, 56.33, 55.70]])
|
| 101 |
+
fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.14,
|
| 102 |
+
subplot_titles=("this reproduction (PTB-XL, 1 seed)",
|
| 103 |
+
"paper (Table 3)"))
|
| 104 |
+
for j, (m, lbl) in enumerate(((ours, "ours"), (paper, "paper"))):
|
| 105 |
+
fig.add_heatmap(z=m, x=[f"r={r}" for r in rs], y=[f"k={k}" for k in ks],
|
| 106 |
+
colorscale=[[0, "#F2F7F8"], [1, ACCENT]], showscale=False,
|
| 107 |
+
text=[[("" if np.isnan(v) else f"{v:.2f}") for v in row]
|
| 108 |
+
for row in m],
|
| 109 |
+
texttemplate="%{text}", textfont=dict(size=30),
|
| 110 |
+
row=1, col=j + 1)
|
| 111 |
+
fig.update_layout(height=560, width=1300,
|
| 112 |
+
title_text="macro F1 (%) across bottleneck size k and mask ratio r")
|
| 113 |
+
save(fig, "claim6_sweep", 1300, 560)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ------------------------------------------------------------------ Fig 4: Claim 2
|
| 117 |
+
def fig_theorem():
|
| 118 |
+
rng = np.random.default_rng(0)
|
| 119 |
+
LOG2PI = np.log(2 * np.pi)
|
| 120 |
+
n, dd, s2, b = 20000, 6, 0.7, 0.6
|
| 121 |
+
x = rng.normal(size=(n, dd))
|
| 122 |
+
mses, gll, lll = [], [], []
|
| 123 |
+
for s in np.linspace(0.05, 2.0, 25):
|
| 124 |
+
xh = x + s * rng.normal(size=(n, dd))
|
| 125 |
+
mses.append(float(((x - xh) ** 2).mean()))
|
| 126 |
+
sq = ((x - xh) ** 2).sum(1)
|
| 127 |
+
gll.append(float((-0.5 * dd * (LOG2PI + np.log(s2)) - sq / (2 * s2)).mean()))
|
| 128 |
+
lll.append(float((-dd * np.log(2 * b) - np.abs(x - xh).sum(1) / b).mean()))
|
| 129 |
+
mses, gll, lll = np.array(mses), np.array(gll), np.array(lll)
|
| 130 |
+
|
| 131 |
+
def resid(xv, yv):
|
| 132 |
+
A = np.vstack([xv, np.ones_like(xv)]).T
|
| 133 |
+
c, *_ = np.linalg.lstsq(A, yv, rcond=None)
|
| 134 |
+
return np.abs(A @ c - yv)
|
| 135 |
+
|
| 136 |
+
fig = go.Figure()
|
| 137 |
+
fig.add_scatter(x=mses, y=np.maximum(resid(mses, gll), 1e-16), mode="lines+markers",
|
| 138 |
+
name="Gaussian decoder (theorem's assumption)",
|
| 139 |
+
line=dict(color=ACCENT, width=4), marker=dict(size=11))
|
| 140 |
+
fig.add_scatter(x=mses, y=resid(mses, lll), mode="lines+markers",
|
| 141 |
+
name="Laplace decoder — CONTROL",
|
| 142 |
+
line=dict(color=RED, width=4, dash="dash"), marker=dict(size=11))
|
| 143 |
+
fig.update_yaxes(type="log", title_text="|residual| from affine fit [nats]",
|
| 144 |
+
exponentformat="power")
|
| 145 |
+
fig.update_xaxes(title_text="reconstruction MSE (ℒ_rec)")
|
| 146 |
+
fig.update_layout(height=560, width=1000,
|
| 147 |
+
legend=dict(orientation="h", y=1.16, x=0))
|
| 148 |
+
save(fig, "claim2_control", 1000, 560)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ------------------------------------------------------------------ Fig 5: Claim 5
|
| 152 |
+
def fig_rank():
|
| 153 |
+
d = json.load(open("results/table1_rank_audit.json"))
|
| 154 |
+
ranks = d["avg_rank_all_methods_5metrics"]
|
| 155 |
+
items = sorted(ranks.items(), key=lambda kv: kv[1])
|
| 156 |
+
names = [k if k != "Ours" else "TS-Fingerprint" for k, _ in items]
|
| 157 |
+
vals = [v for _, v in items]
|
| 158 |
+
cols = [GOLD if n == "TS-Fingerprint" else
|
| 159 |
+
(ACCENT if n == "Medformer" else GREY) for n in names]
|
| 160 |
+
fig = go.Figure()
|
| 161 |
+
fig.add_bar(x=vals, y=names, orientation="h", marker_color=cols,
|
| 162 |
+
text=[f"{v:.2f}" for v in vals], textposition="outside",
|
| 163 |
+
textfont=dict(size=28))
|
| 164 |
+
fig.update_layout(xaxis_title="average rank over 7 datasets × 5 metrics (lower better)",
|
| 165 |
+
height=760, width=1000, yaxis=dict(autorange="reversed"),
|
| 166 |
+
xaxis_range=[0, 12])
|
| 167 |
+
save(fig, "claim5_rank", 1000, 760)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
if __name__ == "__main__":
|
| 171 |
+
for f in (fig_ablation, fig_promotion, fig_sweep, fig_theorem, fig_rank):
|
| 172 |
+
try:
|
| 173 |
+
f()
|
| 174 |
+
except Exception as e: # noqa: BLE001
|
| 175 |
+
print(f"skip {f.__name__}: {type(e).__name__}: {e}")
|
model.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Independent re-implementation of TS-Fingerprint (ICML 2026, OpenReview lrRBHFIgaK).
|
| 2 |
+
|
| 3 |
+
No official code was released for the paper, so every component here is derived
|
| 4 |
+
from the paper text: Sec 3.2 (architecture), Sec 3.3 (losses), Sec 3.4 (attention
|
| 5 |
+
pooling head) and Sec 4.1 (hyperparameters: 6-layer encoder, 2-layer decoder,
|
| 6 |
+
d=128, 8 heads, k=8, mask ratio 0.6, lambda=1e-4).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import math
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn as nn
|
| 13 |
+
import torch.nn.functional as F
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class PatchEmbed(nn.Module):
|
| 17 |
+
"""Non-overlapping patching over time; all C channels enter the patch (Medformer-style)."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, c_in, patch_size, d_model):
|
| 20 |
+
super().__init__()
|
| 21 |
+
self.patch_size = patch_size
|
| 22 |
+
self.proj = nn.Linear(c_in * patch_size, d_model)
|
| 23 |
+
|
| 24 |
+
def forward(self, x): # x: (B, T, C)
|
| 25 |
+
b, t, c = x.shape
|
| 26 |
+
n = t // self.patch_size
|
| 27 |
+
x = x[:, : n * self.patch_size].reshape(b, n, self.patch_size * c)
|
| 28 |
+
return self.proj(x) # (B, N, d)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class CrossAttnBlock(nn.Module):
|
| 32 |
+
"""One iteration of the Perceiver-style bottleneck: latents cross-attend to the
|
| 33 |
+
patch sequence, then self-attend among themselves (Sec 3.2, 'iterative cross-attention')."""
|
| 34 |
+
|
| 35 |
+
def __init__(self, d_model, n_heads, dropout=0.1):
|
| 36 |
+
super().__init__()
|
| 37 |
+
self.ln_q1 = nn.LayerNorm(d_model)
|
| 38 |
+
self.ln_kv = nn.LayerNorm(d_model)
|
| 39 |
+
self.cross = nn.MultiheadAttention(d_model, n_heads, dropout=dropout, batch_first=True)
|
| 40 |
+
self.ln_q2 = nn.LayerNorm(d_model)
|
| 41 |
+
self.self_attn = nn.MultiheadAttention(d_model, n_heads, dropout=dropout, batch_first=True)
|
| 42 |
+
self.ln_q3 = nn.LayerNorm(d_model)
|
| 43 |
+
self.ff = nn.Sequential(
|
| 44 |
+
nn.Linear(d_model, 4 * d_model), nn.GELU(), nn.Dropout(dropout),
|
| 45 |
+
nn.Linear(4 * d_model, d_model), nn.Dropout(dropout),
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
def forward(self, q, kv, kv_mask=None, need_weights=False):
|
| 49 |
+
h, attn = self.cross(self.ln_q1(q), self.ln_kv(kv), self.ln_kv(kv),
|
| 50 |
+
key_padding_mask=kv_mask, need_weights=need_weights,
|
| 51 |
+
average_attn_weights=True)
|
| 52 |
+
q = q + h
|
| 53 |
+
h, _ = self.self_attn(self.ln_q2(q), self.ln_q2(q), self.ln_q2(q), need_weights=False)
|
| 54 |
+
q = q + h
|
| 55 |
+
q = q + self.ff(self.ln_q3(q))
|
| 56 |
+
return q, attn
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class FingerprintEncoder(nn.Module):
|
| 60 |
+
"""E_theta : X -> F' in R^{k x d}. Fixed-rank bottleneck via a learnable query set
|
| 61 |
+
Q in R^{k x d} with k << T (Claim 1)."""
|
| 62 |
+
|
| 63 |
+
def __init__(self, c_in, patch_size, d_model=128, n_heads=8, n_layers=6, k=8,
|
| 64 |
+
max_patches=512, dropout=0.1):
|
| 65 |
+
super().__init__()
|
| 66 |
+
self.k = k
|
| 67 |
+
self.d_model = d_model
|
| 68 |
+
self.patch_embed = PatchEmbed(c_in, patch_size, d_model)
|
| 69 |
+
self.pos = nn.Parameter(torch.zeros(1, max_patches, d_model))
|
| 70 |
+
nn.init.trunc_normal_(self.pos, std=0.02)
|
| 71 |
+
# the learnable query set Q
|
| 72 |
+
self.Q = nn.Parameter(torch.randn(k, d_model) * 0.02)
|
| 73 |
+
self.blocks = nn.ModuleList(
|
| 74 |
+
[CrossAttnBlock(d_model, n_heads, dropout) for _ in range(n_layers)]
|
| 75 |
+
)
|
| 76 |
+
self.norm = nn.LayerNorm(d_model)
|
| 77 |
+
|
| 78 |
+
def embed_patches(self, x):
|
| 79 |
+
p = self.patch_embed(x)
|
| 80 |
+
return p + self.pos[:, : p.shape[1]]
|
| 81 |
+
|
| 82 |
+
def forward(self, x, kv_mask=None, return_attn=False):
|
| 83 |
+
"""x: (B, T, C) -> F': (B, k, d). Output shape is independent of T."""
|
| 84 |
+
p = self.embed_patches(x)
|
| 85 |
+
q = self.Q.unsqueeze(0).expand(x.shape[0], -1, -1)
|
| 86 |
+
attns = []
|
| 87 |
+
for blk in self.blocks:
|
| 88 |
+
q, a = blk(q, p, kv_mask=kv_mask, need_weights=return_attn)
|
| 89 |
+
if return_attn:
|
| 90 |
+
attns.append(a)
|
| 91 |
+
f = self.norm(q)
|
| 92 |
+
if return_attn:
|
| 93 |
+
return f, attns
|
| 94 |
+
return f
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class FingerprintDecoder(nn.Module):
|
| 98 |
+
"""D_phi conditions *solely* on F'. Mask tokens carry only positional information,
|
| 99 |
+
so the data-processing chain X -> F' -> X_hat is strict (Sec 3.2, Decoder)."""
|
| 100 |
+
|
| 101 |
+
def __init__(self, c_in, patch_size, d_model=128, n_heads=8, n_layers=2,
|
| 102 |
+
max_patches=512, dropout=0.1):
|
| 103 |
+
super().__init__()
|
| 104 |
+
self.mask_token = nn.Parameter(torch.zeros(1, 1, d_model))
|
| 105 |
+
self.pos = nn.Parameter(torch.zeros(1, max_patches, d_model))
|
| 106 |
+
nn.init.trunc_normal_(self.pos, std=0.02)
|
| 107 |
+
self.blocks = nn.ModuleList(
|
| 108 |
+
[CrossAttnBlock(d_model, n_heads, dropout) for _ in range(n_layers)]
|
| 109 |
+
)
|
| 110 |
+
self.norm = nn.LayerNorm(d_model)
|
| 111 |
+
self.head = nn.Linear(d_model, c_in * patch_size)
|
| 112 |
+
|
| 113 |
+
def forward(self, f, n_patches):
|
| 114 |
+
b = f.shape[0]
|
| 115 |
+
t = self.mask_token.expand(b, n_patches, -1) + self.pos[:, :n_patches]
|
| 116 |
+
for blk in self.blocks:
|
| 117 |
+
t, _ = blk(t, f)
|
| 118 |
+
return self.head(self.norm(t)) # (B, N, patch*C)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def total_coding_rate_loss(f, eps=0.5):
|
| 122 |
+
"""L_div = -1/2 log det(I + d/(k eps^2) F'^T F') (Eq. 3).
|
| 123 |
+
|
| 124 |
+
Computed per sample over its k tokens; by Sylvester's identity the d x d and
|
| 125 |
+
k x k forms have identical value, and we use the cheaper k x k Gram form.
|
| 126 |
+
"""
|
| 127 |
+
b, k, d = f.shape
|
| 128 |
+
f = f.float() # slogdet is not fp16-safe; keep the geometric term in fp32
|
| 129 |
+
fn = F.normalize(f, dim=-1) # fixed-energy constraint of Lemma 3.2
|
| 130 |
+
gram = torch.bmm(fn, fn.transpose(1, 2)) # (B, k, k)
|
| 131 |
+
ident = torch.eye(k, device=f.device, dtype=f.dtype).unsqueeze(0)
|
| 132 |
+
mat = ident + (d / (k * eps ** 2)) * gram
|
| 133 |
+
return -0.5 * torch.linalg.slogdet(mat)[1].mean()
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class TSFingerprint(nn.Module):
|
| 137 |
+
def __init__(self, c_in, patch_size, n_classes, d_model=128, n_heads=8,
|
| 138 |
+
enc_layers=6, dec_layers=2, k=8, max_patches=512, dropout=0.1):
|
| 139 |
+
super().__init__()
|
| 140 |
+
self.patch_size = patch_size
|
| 141 |
+
self.encoder = FingerprintEncoder(c_in, patch_size, d_model, n_heads,
|
| 142 |
+
enc_layers, k, max_patches, dropout)
|
| 143 |
+
self.decoder = FingerprintDecoder(c_in, patch_size, d_model, n_heads,
|
| 144 |
+
dec_layers, max_patches, dropout)
|
| 145 |
+
# Sec 3.4: attention pooling over the k fingerprint tokens
|
| 146 |
+
self.q_task = nn.Linear(d_model, 1, bias=False)
|
| 147 |
+
self.cls_head = nn.Sequential(nn.LayerNorm(d_model), nn.Linear(d_model, n_classes))
|
| 148 |
+
|
| 149 |
+
# ---- pre-training ----
|
| 150 |
+
def patchify(self, x):
|
| 151 |
+
b, t, c = x.shape
|
| 152 |
+
n = t // self.patch_size
|
| 153 |
+
return x[:, : n * self.patch_size].reshape(b, n, self.patch_size * c)
|
| 154 |
+
|
| 155 |
+
def pretrain_step(self, x, mask_ratio=0.6, lam=1e-4, use_div=True, generator=None):
|
| 156 |
+
target = self.patchify(x)
|
| 157 |
+
b, n, _ = target.shape
|
| 158 |
+
# masked view: keep (1-r) of the patches visible to the encoder
|
| 159 |
+
n_keep = max(1, int(round(n * (1 - mask_ratio))))
|
| 160 |
+
noise = torch.rand(b, n, device=x.device, generator=generator)
|
| 161 |
+
keep = noise.argsort(dim=1)[:, :n_keep]
|
| 162 |
+
kv_mask = torch.ones(b, n, dtype=torch.bool, device=x.device)
|
| 163 |
+
kv_mask.scatter_(1, keep, False) # True == ignore
|
| 164 |
+
f = self.encoder(x, kv_mask=kv_mask)
|
| 165 |
+
pred = self.decoder(f, n)
|
| 166 |
+
l_rec = F.mse_loss(pred, target)
|
| 167 |
+
l_div = total_coding_rate_loss(f) if use_div else torch.zeros((), device=x.device)
|
| 168 |
+
return l_rec + lam * l_div, l_rec.detach(), l_div.detach()
|
| 169 |
+
|
| 170 |
+
# ---- downstream ----
|
| 171 |
+
def forward(self, x, return_alpha=False):
|
| 172 |
+
f = self.encoder(x)
|
| 173 |
+
alpha = torch.softmax(self.q_task(f).squeeze(-1), dim=-1) # (B, k)
|
| 174 |
+
z = (alpha.unsqueeze(-1) * f).sum(1)
|
| 175 |
+
logits = self.cls_head(z)
|
| 176 |
+
if return_alpha:
|
| 177 |
+
return logits, alpha, f
|
| 178 |
+
return logits
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ------------------------------------------------------------------
|
| 182 |
+
# Baselines for Claim 4
|
| 183 |
+
# ------------------------------------------------------------------
|
| 184 |
+
class PlainEncoder(nn.Module):
|
| 185 |
+
"""Standard transformer patch encoder -> variable-length token sequence
|
| 186 |
+
(the 'entangled view' shared by Ti-MAE and SimMTM)."""
|
| 187 |
+
|
| 188 |
+
def __init__(self, c_in, patch_size, d_model=128, n_heads=8, n_layers=6,
|
| 189 |
+
max_patches=512, dropout=0.1):
|
| 190 |
+
super().__init__()
|
| 191 |
+
self.patch_embed = PatchEmbed(c_in, patch_size, d_model)
|
| 192 |
+
self.pos = nn.Parameter(torch.zeros(1, max_patches, d_model))
|
| 193 |
+
nn.init.trunc_normal_(self.pos, std=0.02)
|
| 194 |
+
layer = nn.TransformerEncoderLayer(d_model, n_heads, 4 * d_model, dropout,
|
| 195 |
+
activation="gelu", batch_first=True,
|
| 196 |
+
norm_first=True)
|
| 197 |
+
self.enc = nn.TransformerEncoder(layer, n_layers)
|
| 198 |
+
self.norm = nn.LayerNorm(d_model)
|
| 199 |
+
|
| 200 |
+
def forward(self, x, src_key_padding_mask=None):
|
| 201 |
+
p = self.patch_embed(x)
|
| 202 |
+
p = p + self.pos[:, : p.shape[1]]
|
| 203 |
+
return self.norm(self.enc(p, src_key_padding_mask=src_key_padding_mask))
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class TiMAE(nn.Module):
|
| 207 |
+
"""Ti-MAE (Li et al., 2023): masked patch autoencoding on a plain transformer,
|
| 208 |
+
visible patches are fed to the encoder, mask tokens are appended in the decoder,
|
| 209 |
+
downstream head is global average pooling."""
|
| 210 |
+
|
| 211 |
+
def __init__(self, c_in, patch_size, n_classes, d_model=128, n_heads=8,
|
| 212 |
+
enc_layers=6, dec_layers=2, max_patches=512, dropout=0.1):
|
| 213 |
+
super().__init__()
|
| 214 |
+
self.patch_size = patch_size
|
| 215 |
+
self.encoder = PlainEncoder(c_in, patch_size, d_model, n_heads, enc_layers,
|
| 216 |
+
max_patches, dropout)
|
| 217 |
+
self.mask_token = nn.Parameter(torch.zeros(1, 1, d_model))
|
| 218 |
+
self.dec_pos = nn.Parameter(torch.zeros(1, max_patches, d_model))
|
| 219 |
+
nn.init.trunc_normal_(self.dec_pos, std=0.02)
|
| 220 |
+
layer = nn.TransformerEncoderLayer(d_model, n_heads, 4 * d_model, dropout,
|
| 221 |
+
activation="gelu", batch_first=True,
|
| 222 |
+
norm_first=True)
|
| 223 |
+
self.dec = nn.TransformerEncoder(layer, dec_layers)
|
| 224 |
+
self.head_rec = nn.Linear(d_model, c_in * patch_size)
|
| 225 |
+
self.cls_head = nn.Sequential(nn.LayerNorm(d_model), nn.Linear(d_model, n_classes))
|
| 226 |
+
|
| 227 |
+
def patchify(self, x):
|
| 228 |
+
b, t, c = x.shape
|
| 229 |
+
n = t // self.patch_size
|
| 230 |
+
return x[:, : n * self.patch_size].reshape(b, n, self.patch_size * c)
|
| 231 |
+
|
| 232 |
+
def pretrain_step(self, x, mask_ratio=0.6, generator=None, **kw):
|
| 233 |
+
target = self.patchify(x)
|
| 234 |
+
b, n, _ = target.shape
|
| 235 |
+
n_keep = max(1, int(round(n * (1 - mask_ratio))))
|
| 236 |
+
noise = torch.rand(b, n, device=x.device, generator=generator)
|
| 237 |
+
keep = noise.argsort(dim=1)[:, :n_keep]
|
| 238 |
+
pad = torch.ones(b, n, dtype=torch.bool, device=x.device)
|
| 239 |
+
pad.scatter_(1, keep, False)
|
| 240 |
+
h = self.encoder(x, src_key_padding_mask=pad)
|
| 241 |
+
# replace masked positions with the mask token, then decode the full sequence
|
| 242 |
+
h = torch.where(pad.unsqueeze(-1), self.mask_token.expand(b, n, -1), h)
|
| 243 |
+
h = h + self.dec_pos[:, :n]
|
| 244 |
+
pred = self.head_rec(self.dec(h))
|
| 245 |
+
l_rec = F.mse_loss(pred, target)
|
| 246 |
+
return l_rec, l_rec.detach(), torch.zeros((), device=x.device)
|
| 247 |
+
|
| 248 |
+
def forward(self, x):
|
| 249 |
+
h = self.encoder(x)
|
| 250 |
+
return self.cls_head(h.mean(1))
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class SimMTM(nn.Module):
|
| 254 |
+
"""SimMTM (Dong et al., 2023): reconstruct the original series from *multiple*
|
| 255 |
+
masked views, aggregating them by point-wise series-similarity, plus a
|
| 256 |
+
series-wise contrastive term. Head is global average pooling."""
|
| 257 |
+
|
| 258 |
+
def __init__(self, c_in, patch_size, n_classes, d_model=128, n_heads=8,
|
| 259 |
+
enc_layers=6, dec_layers=2, max_patches=512, dropout=0.1,
|
| 260 |
+
n_views=3, temperature=0.2):
|
| 261 |
+
super().__init__()
|
| 262 |
+
self.patch_size = patch_size
|
| 263 |
+
self.n_views = n_views
|
| 264 |
+
self.temperature = temperature
|
| 265 |
+
self.encoder = PlainEncoder(c_in, patch_size, d_model, n_heads, enc_layers,
|
| 266 |
+
max_patches, dropout)
|
| 267 |
+
layer = nn.TransformerEncoderLayer(d_model, n_heads, 4 * d_model, dropout,
|
| 268 |
+
activation="gelu", batch_first=True,
|
| 269 |
+
norm_first=True)
|
| 270 |
+
self.dec = nn.TransformerEncoder(layer, dec_layers)
|
| 271 |
+
self.head_rec = nn.Linear(d_model, c_in * patch_size)
|
| 272 |
+
self.proj = nn.Sequential(nn.Linear(d_model, d_model), nn.GELU(),
|
| 273 |
+
nn.Linear(d_model, d_model))
|
| 274 |
+
self.cls_head = nn.Sequential(nn.LayerNorm(d_model), nn.Linear(d_model, n_classes))
|
| 275 |
+
|
| 276 |
+
def patchify(self, x):
|
| 277 |
+
b, t, c = x.shape
|
| 278 |
+
n = t // self.patch_size
|
| 279 |
+
return x[:, : n * self.patch_size].reshape(b, n, self.patch_size * c)
|
| 280 |
+
|
| 281 |
+
def pretrain_step(self, x, mask_ratio=0.6, generator=None, **kw):
|
| 282 |
+
target = self.patchify(x)
|
| 283 |
+
b, n, _ = target.shape
|
| 284 |
+
views = []
|
| 285 |
+
for _ in range(self.n_views):
|
| 286 |
+
m = (torch.rand(b, x.shape[1], 1, device=x.device, generator=generator)
|
| 287 |
+
< mask_ratio)
|
| 288 |
+
views.append(x.masked_fill(m, 0.0))
|
| 289 |
+
xs = torch.cat(views, 0)
|
| 290 |
+
h = self.encoder(xs) # (V*B, N, d)
|
| 291 |
+
s = self.proj(h.mean(1)) # series-level embedding
|
| 292 |
+
s = F.normalize(s, dim=-1)
|
| 293 |
+
# point-wise aggregation weighted by series similarity to view 0
|
| 294 |
+
sim = (s.view(self.n_views, b, -1) * s.view(self.n_views, b, -1)[0:1]).sum(-1)
|
| 295 |
+
w = torch.softmax(sim / self.temperature, dim=0) # (V, B)
|
| 296 |
+
agg = (h.view(self.n_views, b, n, -1) * w[..., None, None]).sum(0)
|
| 297 |
+
pred = self.head_rec(self.dec(agg))
|
| 298 |
+
l_rec = F.mse_loss(pred, target)
|
| 299 |
+
# series-wise contrastive: views of the same series are positives
|
| 300 |
+
logits = s @ s.t() / self.temperature
|
| 301 |
+
lbl = torch.arange(b, device=x.device).repeat(self.n_views)
|
| 302 |
+
eye = torch.eye(logits.shape[0], device=x.device, dtype=torch.bool)
|
| 303 |
+
logits = logits.masked_fill(eye, -1e4)
|
| 304 |
+
pos = (lbl[:, None] == lbl[None, :]) & ~eye
|
| 305 |
+
log_p = torch.log_softmax(logits, dim=-1)
|
| 306 |
+
l_con = -(log_p * pos).sum(-1).div(pos.sum(-1).clamp(min=1)).mean()
|
| 307 |
+
return l_rec + 0.1 * l_con, l_rec.detach(), l_con.detach()
|
| 308 |
+
|
| 309 |
+
def forward(self, x):
|
| 310 |
+
h = self.encoder(x)
|
| 311 |
+
return self.cls_head(h.mean(1))
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
MODELS = {"tsfp": TSFingerprint, "timae": TiMAE, "simmtm": SimMTM}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def build(name, **kw):
|
| 318 |
+
if name == "simmtm":
|
| 319 |
+
kw.pop("k", None)
|
| 320 |
+
elif name == "timae":
|
| 321 |
+
kw.pop("k", None)
|
| 322 |
+
return MODELS[name](**kw)
|
prep_adftd.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ADFTD (OpenNeuro ds004504) preprocessing, following DL4mHealth/Medformer.
|
| 2 |
+
|
| 3 |
+
Steps (verbatim from Medformer's ADFTD_preprocessing.ipynb):
|
| 4 |
+
* read the ASR/ICA-cleaned derivatives (.set, 19 channels, 500 Hz)
|
| 5 |
+
* linear-interpolation resample 500 -> 256 Hz
|
| 6 |
+
* segment into non-overlapping 256-timestamp windows, centred on the recording
|
| 7 |
+
* label map {C: 0 healthy, F: 1 FTD, A: 2 AD}
|
| 8 |
+
Split (from Medformer's ADFTDLoader): subject-wise 60/20/20 within each class.
|
| 9 |
+
Normalisation: per-sample, per-channel standardisation (uea.normalize_batch_ts).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
import mne
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
from scipy import interpolate
|
| 19 |
+
|
| 20 |
+
mne.set_log_level("ERROR")
|
| 21 |
+
|
| 22 |
+
LABEL_MAP = {"A": 2, "F": 1, "C": 0}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def resample(arr, freq=500, target=256):
|
| 26 |
+
t = np.linspace(1, len(arr), len(arr))
|
| 27 |
+
f = interpolate.interp1d(t, arr, kind="linear")
|
| 28 |
+
t_new = np.linspace(1, len(arr), int(len(arr) / freq * target))
|
| 29 |
+
return f(t_new)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def segment(mat, window=256):
|
| 33 |
+
"""mat: (T, C). Centre-anchored non-overlapping windows."""
|
| 34 |
+
res = []
|
| 35 |
+
start = mat.shape[0] // 2
|
| 36 |
+
left = start - (start // window) * window
|
| 37 |
+
right = start + ((mat.shape[0] - start) // window) * window
|
| 38 |
+
for i in range(left, right, window):
|
| 39 |
+
res.append(mat[i:i + window])
|
| 40 |
+
return res
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def main():
|
| 44 |
+
ap = argparse.ArgumentParser()
|
| 45 |
+
ap.add_argument("--root", default="data/ADFTD")
|
| 46 |
+
ap.add_argument("--out", default="data/ADFTD/processed")
|
| 47 |
+
ap.add_argument("--window", type=int, default=256)
|
| 48 |
+
args = ap.parse_args()
|
| 49 |
+
|
| 50 |
+
parts = pd.read_csv(os.path.join(args.root, "participants.tsv"), sep="\t")
|
| 51 |
+
labels = {}
|
| 52 |
+
for row in parts.values:
|
| 53 |
+
pid = int(str(row[0])[-3:])
|
| 54 |
+
labels[pid] = LABEL_MAP[row[3]]
|
| 55 |
+
|
| 56 |
+
deriv = os.path.join(args.root, "derivatives")
|
| 57 |
+
feats, ys, subs = [], [], []
|
| 58 |
+
for pid in sorted(labels):
|
| 59 |
+
path = os.path.join(deriv, f"sub-{pid:03d}", "eeg",
|
| 60 |
+
f"sub-{pid:03d}_task-eyesclosed_eeg.set")
|
| 61 |
+
raw = mne.io.read_raw_eeglab(path, preload=True)
|
| 62 |
+
sig = raw.get_data() # (C, T) at 500 Hz
|
| 63 |
+
res = np.stack([resample(sig[c]) for c in range(sig.shape[0])], axis=1) # (T', C)
|
| 64 |
+
for win in segment(res, args.window):
|
| 65 |
+
feats.append(win.astype(np.float32))
|
| 66 |
+
ys.append(labels[pid])
|
| 67 |
+
subs.append(pid)
|
| 68 |
+
print(f"sub-{pid:03d} label={labels[pid]} raw={sig.shape} "
|
| 69 |
+
f"resampled={res.shape} windows={len(segment(res, args.window))}", flush=True)
|
| 70 |
+
|
| 71 |
+
X = np.stack(feats) # (N, 256, 19)
|
| 72 |
+
y = np.array(ys, dtype=np.int64)
|
| 73 |
+
s = np.array(subs, dtype=np.int64)
|
| 74 |
+
print("total", X.shape, np.bincount(y))
|
| 75 |
+
|
| 76 |
+
# subject-wise 60/20/20 within each class (Medformer ADFTDLoader, a=0.6, b=0.8)
|
| 77 |
+
train_ids, val_ids, test_ids = [], [], []
|
| 78 |
+
for cls in (0, 1, 2):
|
| 79 |
+
ids = sorted([p for p in labels if labels[p] == cls])
|
| 80 |
+
a, b = int(0.6 * len(ids)), int(0.8 * len(ids))
|
| 81 |
+
train_ids += ids[:a]
|
| 82 |
+
val_ids += ids[a:b]
|
| 83 |
+
test_ids += ids[b:]
|
| 84 |
+
print("subjects train/val/test:", len(train_ids), len(val_ids), len(test_ids))
|
| 85 |
+
|
| 86 |
+
os.makedirs(args.out, exist_ok=True)
|
| 87 |
+
for name, ids in (("train", train_ids), ("val", val_ids), ("test", test_ids)):
|
| 88 |
+
m = np.isin(s, ids)
|
| 89 |
+
Xi = X[m]
|
| 90 |
+
mu = Xi.mean(axis=1, keepdims=True)
|
| 91 |
+
sd = Xi.std(axis=1, keepdims=True)
|
| 92 |
+
sd[sd == 0] = 1.0
|
| 93 |
+
Xi = ((Xi - mu) / sd).astype(np.float32)
|
| 94 |
+
np.save(os.path.join(args.out, f"X_{name}.npy"), Xi)
|
| 95 |
+
np.save(os.path.join(args.out, f"y_{name}.npy"), y[m])
|
| 96 |
+
np.save(os.path.join(args.out, f"subj_{name}.npy"), s[m])
|
| 97 |
+
print(name, Xi.shape, np.bincount(y[m]))
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
main()
|
prep_ptbxl.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PTB-XL preprocessing, following DL4mHealth/Medformer's PTB-XL_preprocessing.ipynb.
|
| 2 |
+
|
| 3 |
+
* records500 (12-lead, 500 Hz, 10 s) -> linear resample to 250 Hz -> 2500 samples
|
| 4 |
+
* per-record StandardScaler over time, then reshape into 10 non-overlapping
|
| 5 |
+
250-timestamp windows (the paper's Table 5: T=250, patch 10, 5 classes)
|
| 6 |
+
* label = the SCP code with the highest likelihood, mapped to the 5 diagnostic
|
| 7 |
+
superclasses NORM / MI / STTC / CD / HYP
|
| 8 |
+
* subject-wise (patient_id) split; Medformer's PTB-XL loader uses a 60/20/20
|
| 9 |
+
patient split, which we reproduce with a fixed seed.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import ast
|
| 14 |
+
import os
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
import wfdb
|
| 19 |
+
from scipy import interpolate
|
| 20 |
+
from sklearn.preprocessing import StandardScaler
|
| 21 |
+
|
| 22 |
+
SUPER = {"NORM": 0, "MI": 1, "STTC": 2, "CD": 3, "HYP": 4}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def build_scp_map(scp_csv):
|
| 26 |
+
"""diagnostic_class column of scp_statements.csv maps each SCP code to a superclass."""
|
| 27 |
+
df = pd.read_csv(scp_csv, index_col=0)
|
| 28 |
+
df = df[df.diagnostic == 1]
|
| 29 |
+
return {code: row.diagnostic_class for code, row in df.iterrows()
|
| 30 |
+
if isinstance(row.diagnostic_class, str) and row.diagnostic_class in SUPER}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def resample(arr, freq=500, target=250):
|
| 34 |
+
t = np.linspace(1, len(arr), len(arr))
|
| 35 |
+
f = interpolate.interp1d(t, arr, kind="linear")
|
| 36 |
+
return f(np.linspace(1, len(arr), int(len(arr) / freq * target)))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main():
|
| 40 |
+
ap = argparse.ArgumentParser()
|
| 41 |
+
ap.add_argument("--root", default="data/PTBXL")
|
| 42 |
+
ap.add_argument("--out", default="data/PTBXL/processed")
|
| 43 |
+
ap.add_argument("--subject-frac", type=float, default=1.0,
|
| 44 |
+
help="fraction of patients to keep (compute budget)")
|
| 45 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 46 |
+
a = ap.parse_args()
|
| 47 |
+
|
| 48 |
+
info = pd.read_csv(os.path.join(a.root, "ptbxl_database.csv"))
|
| 49 |
+
code2super = build_scp_map(os.path.join(a.root, "scp_statements.csv"))
|
| 50 |
+
|
| 51 |
+
rows = []
|
| 52 |
+
for r in info.itertuples():
|
| 53 |
+
codes = ast.literal_eval(r.scp_codes)
|
| 54 |
+
diag = {c: v for c, v in codes.items() if c in code2super}
|
| 55 |
+
if not diag:
|
| 56 |
+
continue # Medformer keeps only diagnosable records
|
| 57 |
+
best = max(diag, key=diag.get) # highest-likelihood SCP code
|
| 58 |
+
rows.append((r.ecg_id, r.patient_id, SUPER[code2super[best]], r.filename_hr))
|
| 59 |
+
df = pd.DataFrame(rows, columns=["ecg_id", "patient_id", "label", "path"])
|
| 60 |
+
print(f"records with a diagnostic superclass: {len(df)} "
|
| 61 |
+
f"({df.patient_id.nunique()} patients)")
|
| 62 |
+
print("class counts:", df.label.value_counts().sort_index().to_dict())
|
| 63 |
+
|
| 64 |
+
pats = np.array(sorted(df.patient_id.unique()))
|
| 65 |
+
rng = np.random.default_rng(a.seed)
|
| 66 |
+
rng.shuffle(pats)
|
| 67 |
+
if a.subject_frac < 1.0:
|
| 68 |
+
pats = pats[: max(2, int(len(pats) * a.subject_frac))]
|
| 69 |
+
df = df[df.patient_id.isin(pats)]
|
| 70 |
+
print(f"subsampled to {len(pats)} patients / {len(df)} records "
|
| 71 |
+
f"(subject_frac={a.subject_frac})")
|
| 72 |
+
n = len(pats)
|
| 73 |
+
splits = {"train": set(pats[: int(0.6 * n)]),
|
| 74 |
+
"val": set(pats[int(0.6 * n):int(0.8 * n)]),
|
| 75 |
+
"test": set(pats[int(0.8 * n):])}
|
| 76 |
+
|
| 77 |
+
os.makedirs(a.out, exist_ok=True)
|
| 78 |
+
for name, ids in splits.items():
|
| 79 |
+
sub = df[df.patient_id.isin(ids)]
|
| 80 |
+
X, y = [], []
|
| 81 |
+
for i, r in enumerate(sub.itertuples()):
|
| 82 |
+
sig, _ = wfdb.rdsamp(os.path.join(a.root, r.path)) # (5000, 12) @500 Hz
|
| 83 |
+
res = np.stack([resample(sig[:, c]) for c in range(sig.shape[1])], 1)
|
| 84 |
+
res = StandardScaler().fit_transform(res) # per-record z-score
|
| 85 |
+
w = res[: (res.shape[0] // 250) * 250].reshape(-1, 250, res.shape[1])
|
| 86 |
+
X.append(w.astype(np.float32))
|
| 87 |
+
y.append(np.full(w.shape[0], r.label, dtype=np.int64))
|
| 88 |
+
if i % 2000 == 0:
|
| 89 |
+
print(f" {name} {i}/{len(sub)}", flush=True)
|
| 90 |
+
X = np.concatenate(X)
|
| 91 |
+
y = np.concatenate(y)
|
| 92 |
+
np.save(os.path.join(a.out, f"X_{name}.npy"), X)
|
| 93 |
+
np.save(os.path.join(a.out, f"y_{name}.npy"), y)
|
| 94 |
+
print(name, X.shape, np.bincount(y, minlength=5), flush=True)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
main()
|
run_adftd_ablation.sh
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Claims 3 & 4: ADFTD ablation (Scratch / Pre-trained L_rec / Pre-trained L_rec+L_div)
|
| 3 |
+
# and the Ti-MAE / SimMTM fine-tuning-promotion comparison of Table 4.
|
| 4 |
+
set -u
|
| 5 |
+
cd /home/avesha/ritesh/paper-repro
|
| 6 |
+
PY=.venvr/bin/python
|
| 7 |
+
DATA=data/ADFTD/processed
|
| 8 |
+
OUT=results/adftd
|
| 9 |
+
mkdir -p $OUT
|
| 10 |
+
SEEDS="41 42 43"
|
| 11 |
+
COMMON="--data-dir $DATA --batch-size 256 --pre-epochs 100 --ft-epochs 100 --patience 10 --seeds $SEEDS"
|
| 12 |
+
|
| 13 |
+
run () { # name, extra args
|
| 14 |
+
local name=$1; shift
|
| 15 |
+
if [ -s "$OUT/$name.json" ]; then echo "skip $name"; return; fi
|
| 16 |
+
echo "=== $name ==="
|
| 17 |
+
$PY tsfp/train.py $COMMON "$@" --out "$OUT/$name.json" 2>&1 | tail -4
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
run tsfp_scratch --model tsfp --mode scratch
|
| 21 |
+
run tsfp_rec --model tsfp --mode pretrain_ft --use-div 0
|
| 22 |
+
run tsfp_rec_div --model tsfp --mode pretrain_ft --use-div 1
|
| 23 |
+
run timae_scratch --model timae --mode scratch
|
| 24 |
+
run timae_pretrain --model timae --mode pretrain_ft
|
| 25 |
+
run simmtm_scratch --model simmtm --mode scratch
|
| 26 |
+
run simmtm_pretrain --model simmtm --mode pretrain_ft
|
| 27 |
+
echo "ALL DONE"
|
schedule.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tiny GPU-slot scheduler: runs a list of training configs across the local T4s,
|
| 2 |
+
N concurrent processes per GPU, skipping configs whose result file already exists."""
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
import threading
|
| 10 |
+
import time
|
| 11 |
+
from queue import Queue
|
| 12 |
+
|
| 13 |
+
PY = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| 14 |
+
".venvr", "bin", "python")
|
| 15 |
+
TRAIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "train.py")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def worker(slot, gpu, q, logdir):
|
| 19 |
+
while True:
|
| 20 |
+
try:
|
| 21 |
+
name, args = q.get_nowait()
|
| 22 |
+
except Exception:
|
| 23 |
+
return
|
| 24 |
+
out = os.path.join(logdir, f"{name}.json")
|
| 25 |
+
if os.path.exists(out) and os.path.getsize(out) > 0:
|
| 26 |
+
print(f"[slot{slot}] skip {name}", flush=True)
|
| 27 |
+
q.task_done()
|
| 28 |
+
continue
|
| 29 |
+
env = dict(os.environ, CUDA_VISIBLE_DEVICES=str(gpu), OMP_NUM_THREADS="4")
|
| 30 |
+
cmd = [PY, TRAIN] + args + ["--out", out]
|
| 31 |
+
log = os.path.join(logdir, f"{name}.log")
|
| 32 |
+
t0 = time.time()
|
| 33 |
+
print(f"[slot{slot}/gpu{gpu}] START {name}", flush=True)
|
| 34 |
+
with open(log, "w") as fh:
|
| 35 |
+
rc = subprocess.call(cmd, stdout=fh, stderr=subprocess.STDOUT, env=env)
|
| 36 |
+
print(f"[slot{slot}/gpu{gpu}] DONE {name} rc={rc} "
|
| 37 |
+
f"({time.time()-t0:.0f}s)", flush=True)
|
| 38 |
+
q.task_done()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def main():
|
| 42 |
+
ap = argparse.ArgumentParser()
|
| 43 |
+
ap.add_argument("--jobs", required=True, help="json file: {name: [args...]}")
|
| 44 |
+
ap.add_argument("--logdir", required=True)
|
| 45 |
+
ap.add_argument("--gpus", default="0,1")
|
| 46 |
+
ap.add_argument("--per-gpu", type=int, default=2)
|
| 47 |
+
a = ap.parse_args()
|
| 48 |
+
os.makedirs(a.logdir, exist_ok=True)
|
| 49 |
+
jobs = json.load(open(a.jobs))
|
| 50 |
+
q = Queue()
|
| 51 |
+
for name, args in jobs.items():
|
| 52 |
+
q.put((name, args))
|
| 53 |
+
threads = []
|
| 54 |
+
gpus = [g for g in a.gpus.split(",") if g]
|
| 55 |
+
for gi, gpu in enumerate(gpus):
|
| 56 |
+
for s in range(a.per_gpu):
|
| 57 |
+
t = threading.Thread(target=worker, args=(f"{gi}.{s}", gpu, q, a.logdir))
|
| 58 |
+
t.start()
|
| 59 |
+
threads.append(t)
|
| 60 |
+
time.sleep(3)
|
| 61 |
+
for t in threads:
|
| 62 |
+
t.join()
|
| 63 |
+
print("ALL DONE", flush=True)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
if __name__ == "__main__":
|
| 67 |
+
main()
|
train.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pre-train + fine-tune harness for the TS-Fingerprint reproduction.
|
| 2 |
+
|
| 3 |
+
Reproduces the protocol of Sec 4.1 / 4.2:
|
| 4 |
+
* subject-wise 60/20/20 split (Medformer protocol)
|
| 5 |
+
* Adam, lr 1e-3 (pre-train) / 1e-4 (downstream), <=100 epochs,
|
| 6 |
+
early stopping on validation F1 with patience 10
|
| 7 |
+
* macro Accuracy / Precision / Recall / F1 / AUROC on the test split
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
import time
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import torch
|
| 18 |
+
import torch.nn as nn
|
| 19 |
+
from sklearn.metrics import (accuracy_score, f1_score, precision_score,
|
| 20 |
+
recall_score, roc_auc_score)
|
| 21 |
+
from torch.utils.data import DataLoader, TensorDataset
|
| 22 |
+
|
| 23 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 24 |
+
from model import build # noqa: E402
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def set_seed(s):
|
| 28 |
+
np.random.seed(s)
|
| 29 |
+
torch.manual_seed(s)
|
| 30 |
+
torch.cuda.manual_seed_all(s)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_split(data_dir, split, stride=1):
|
| 34 |
+
x = np.load(os.path.join(data_dir, f"X_{split}.npy"), mmap_mode="r")
|
| 35 |
+
y = np.load(os.path.join(data_dir, f"y_{split}.npy"))
|
| 36 |
+
if stride > 1:
|
| 37 |
+
# keep every `stride`-th window. Windows are stored in recording order, so
|
| 38 |
+
# this thins each recording uniformly and leaves the subject-wise split,
|
| 39 |
+
# the subject count and the class balance untouched.
|
| 40 |
+
x, y = x[::stride], y[::stride]
|
| 41 |
+
x = np.ascontiguousarray(x)
|
| 42 |
+
return torch.from_numpy(x).float(), torch.from_numpy(y).long()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def metrics(y_true, y_prob):
|
| 46 |
+
y_pred = y_prob.argmax(1)
|
| 47 |
+
n_cls = y_prob.shape[1]
|
| 48 |
+
out = {
|
| 49 |
+
"accuracy": accuracy_score(y_true, y_pred) * 100,
|
| 50 |
+
"precision": precision_score(y_true, y_pred, average="macro", zero_division=0) * 100,
|
| 51 |
+
"recall": recall_score(y_true, y_pred, average="macro", zero_division=0) * 100,
|
| 52 |
+
"f1": f1_score(y_true, y_pred, average="macro", zero_division=0) * 100,
|
| 53 |
+
}
|
| 54 |
+
try:
|
| 55 |
+
if n_cls == 2:
|
| 56 |
+
out["auroc"] = roc_auc_score(y_true, y_prob[:, 1]) * 100
|
| 57 |
+
else:
|
| 58 |
+
out["auroc"] = roc_auc_score(y_true, y_prob, multi_class="ovr",
|
| 59 |
+
average="macro") * 100
|
| 60 |
+
except ValueError:
|
| 61 |
+
out["auroc"] = float("nan")
|
| 62 |
+
return out
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
AMP = {"enabled": False}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def autocast(device):
|
| 69 |
+
return torch.autocast("cuda", dtype=torch.float16,
|
| 70 |
+
enabled=AMP["enabled"] and device == "cuda")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@torch.no_grad()
|
| 74 |
+
def evaluate(model, loader, device):
|
| 75 |
+
model.eval()
|
| 76 |
+
probs, ys = [], []
|
| 77 |
+
for xb, yb in loader:
|
| 78 |
+
with autocast(device):
|
| 79 |
+
logits = model(xb.to(device))
|
| 80 |
+
probs.append(torch.softmax(logits.float(), -1).cpu().numpy())
|
| 81 |
+
ys.append(yb.numpy())
|
| 82 |
+
return metrics(np.concatenate(ys), np.concatenate(probs))
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def pretrain(model, loader, device, args, log=print):
|
| 86 |
+
opt = torch.optim.Adam(model.parameters(), lr=args.pre_lr)
|
| 87 |
+
scaler = torch.amp.GradScaler("cuda", enabled=AMP["enabled"] and device == "cuda")
|
| 88 |
+
hist = []
|
| 89 |
+
for ep in range(args.pre_epochs):
|
| 90 |
+
model.train()
|
| 91 |
+
tot = rec = div = 0.0
|
| 92 |
+
n = 0
|
| 93 |
+
for xb, _ in loader:
|
| 94 |
+
xb = xb.to(device, non_blocking=True)
|
| 95 |
+
with autocast(device):
|
| 96 |
+
loss, lr_, ld_ = model.pretrain_step(
|
| 97 |
+
xb, mask_ratio=args.mask_ratio, lam=args.lam, use_div=args.use_div)
|
| 98 |
+
opt.zero_grad(set_to_none=True)
|
| 99 |
+
scaler.scale(loss).backward()
|
| 100 |
+
scaler.unscale_(opt)
|
| 101 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 4.0)
|
| 102 |
+
scaler.step(opt)
|
| 103 |
+
scaler.update()
|
| 104 |
+
bs = xb.shape[0]
|
| 105 |
+
tot += loss.item() * bs
|
| 106 |
+
rec += lr_.item() * bs
|
| 107 |
+
div += ld_.item() * bs
|
| 108 |
+
n += bs
|
| 109 |
+
hist.append({"epoch": ep, "loss": tot / n, "l_rec": rec / n, "l_div": div / n})
|
| 110 |
+
log(f"[pretrain] ep {ep:3d} loss {tot/n:.5f} rec {rec/n:.5f} div {div/n:.4f}")
|
| 111 |
+
return hist
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def finetune(model, tr, va, te, device, args, log=print):
|
| 115 |
+
opt = torch.optim.Adam(model.parameters(), lr=args.ft_lr)
|
| 116 |
+
scaler = torch.amp.GradScaler("cuda", enabled=AMP["enabled"] and device == "cuda")
|
| 117 |
+
crit = nn.CrossEntropyLoss()
|
| 118 |
+
best_f1, best_state, patience, hist = -1.0, None, 0, []
|
| 119 |
+
for ep in range(args.ft_epochs):
|
| 120 |
+
model.train()
|
| 121 |
+
tot, n = 0.0, 0
|
| 122 |
+
for xb, yb in tr:
|
| 123 |
+
xb, yb = xb.to(device, non_blocking=True), yb.to(device, non_blocking=True)
|
| 124 |
+
with autocast(device):
|
| 125 |
+
loss = crit(model(xb), yb)
|
| 126 |
+
opt.zero_grad(set_to_none=True)
|
| 127 |
+
scaler.scale(loss).backward()
|
| 128 |
+
scaler.unscale_(opt)
|
| 129 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 4.0)
|
| 130 |
+
scaler.step(opt)
|
| 131 |
+
scaler.update()
|
| 132 |
+
tot += loss.item() * xb.shape[0]
|
| 133 |
+
n += xb.shape[0]
|
| 134 |
+
vm = evaluate(model, va, device)
|
| 135 |
+
hist.append({"epoch": ep, "train_loss": tot / n, "val_f1": vm["f1"],
|
| 136 |
+
"val_acc": vm["accuracy"]})
|
| 137 |
+
log(f"[finetune] ep {ep:3d} loss {tot/n:.4f} val_f1 {vm['f1']:.2f} "
|
| 138 |
+
f"val_acc {vm['accuracy']:.2f}")
|
| 139 |
+
if vm["f1"] > best_f1:
|
| 140 |
+
best_f1 = vm["f1"]
|
| 141 |
+
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
|
| 142 |
+
patience = 0
|
| 143 |
+
else:
|
| 144 |
+
patience += 1
|
| 145 |
+
if patience >= args.patience:
|
| 146 |
+
log(f"[finetune] early stop at epoch {ep}")
|
| 147 |
+
break
|
| 148 |
+
if best_state is not None:
|
| 149 |
+
model.load_state_dict(best_state)
|
| 150 |
+
return evaluate(model, te, device), hist, best_f1
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def main():
|
| 154 |
+
p = argparse.ArgumentParser()
|
| 155 |
+
p.add_argument("--data-dir", required=True)
|
| 156 |
+
p.add_argument("--model", default="tsfp", choices=["tsfp", "timae", "simmtm"])
|
| 157 |
+
p.add_argument("--mode", default="pretrain_ft",
|
| 158 |
+
choices=["scratch", "pretrain_ft"])
|
| 159 |
+
p.add_argument("--use-div", type=int, default=1)
|
| 160 |
+
p.add_argument("--k", type=int, default=8)
|
| 161 |
+
p.add_argument("--d-model", type=int, default=128)
|
| 162 |
+
p.add_argument("--n-heads", type=int, default=8)
|
| 163 |
+
p.add_argument("--enc-layers", type=int, default=6)
|
| 164 |
+
p.add_argument("--dec-layers", type=int, default=2)
|
| 165 |
+
p.add_argument("--patch-size", type=int, default=8)
|
| 166 |
+
p.add_argument("--mask-ratio", type=float, default=0.6)
|
| 167 |
+
p.add_argument("--lam", type=float, default=1e-4)
|
| 168 |
+
p.add_argument("--pre-lr", type=float, default=1e-3)
|
| 169 |
+
p.add_argument("--ft-lr", type=float, default=1e-4)
|
| 170 |
+
p.add_argument("--pre-epochs", type=int, default=100)
|
| 171 |
+
p.add_argument("--ft-epochs", type=int, default=100)
|
| 172 |
+
p.add_argument("--patience", type=int, default=10)
|
| 173 |
+
p.add_argument("--batch-size", type=int, default=128)
|
| 174 |
+
p.add_argument("--seeds", type=int, nargs="+", default=[41, 42, 43, 44, 45])
|
| 175 |
+
p.add_argument("--out", default="results.json")
|
| 176 |
+
p.add_argument("--tag", default="")
|
| 177 |
+
p.add_argument("--amp", type=int, default=1)
|
| 178 |
+
p.add_argument("--stride", type=int, default=1,
|
| 179 |
+
help="keep every Nth window (compute-budget subsampling)")
|
| 180 |
+
args = p.parse_args()
|
| 181 |
+
args.use_div = bool(args.use_div)
|
| 182 |
+
AMP["enabled"] = bool(args.amp)
|
| 183 |
+
|
| 184 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 185 |
+
xtr, ytr = load_split(args.data_dir, "train", args.stride)
|
| 186 |
+
xva, yva = load_split(args.data_dir, "val", args.stride)
|
| 187 |
+
xte, yte = load_split(args.data_dir, "test", args.stride)
|
| 188 |
+
n_cls = int(max(ytr.max(), yva.max(), yte.max())) + 1
|
| 189 |
+
c_in, t_len = xtr.shape[2], xtr.shape[1]
|
| 190 |
+
print(f"data {tuple(xtr.shape)} / {tuple(xva.shape)} / {tuple(xte.shape)} "
|
| 191 |
+
f"classes={n_cls} channels={c_in} T={t_len} device={device}", flush=True)
|
| 192 |
+
|
| 193 |
+
runs = []
|
| 194 |
+
for seed in args.seeds:
|
| 195 |
+
t0 = time.time()
|
| 196 |
+
set_seed(seed)
|
| 197 |
+
g = torch.Generator().manual_seed(seed)
|
| 198 |
+
tr = DataLoader(TensorDataset(xtr, ytr), batch_size=args.batch_size,
|
| 199 |
+
shuffle=True, generator=g, drop_last=True, num_workers=2,
|
| 200 |
+
pin_memory=True)
|
| 201 |
+
va = DataLoader(TensorDataset(xva, yva), batch_size=512, num_workers=2)
|
| 202 |
+
te = DataLoader(TensorDataset(xte, yte), batch_size=512, num_workers=2)
|
| 203 |
+
model = build(args.model, c_in=c_in, patch_size=args.patch_size,
|
| 204 |
+
n_classes=n_cls, d_model=args.d_model, n_heads=args.n_heads,
|
| 205 |
+
enc_layers=args.enc_layers, dec_layers=args.dec_layers,
|
| 206 |
+
k=args.k, max_patches=max(512, t_len // args.patch_size + 8))
|
| 207 |
+
model.to(device)
|
| 208 |
+
n_par = sum(p.numel() for p in model.parameters())
|
| 209 |
+
pre_hist = []
|
| 210 |
+
if args.mode == "pretrain_ft":
|
| 211 |
+
pre_hist = pretrain(model, tr, device, args)
|
| 212 |
+
test_m, ft_hist, best_val = finetune(model, tr, va, te, device, args)
|
| 213 |
+
dt = time.time() - t0
|
| 214 |
+
print(f"== seed {seed} [{args.model}/{args.mode}/div={args.use_div}] "
|
| 215 |
+
f"{json.dumps({k: round(v, 2) for k, v in test_m.items()})} "
|
| 216 |
+
f"({dt:.0f}s, {n_par/1e6:.2f}M params)", flush=True)
|
| 217 |
+
runs.append({"seed": seed, "test": test_m, "best_val_f1": best_val,
|
| 218 |
+
"seconds": dt, "params": n_par,
|
| 219 |
+
"pretrain_hist": pre_hist, "finetune_hist": ft_hist})
|
| 220 |
+
|
| 221 |
+
agg = {m: {"mean": float(np.mean([r["test"][m] for r in runs])),
|
| 222 |
+
"std": float(np.std([r["test"][m] for r in runs]))}
|
| 223 |
+
for m in runs[0]["test"]}
|
| 224 |
+
out = {"config": vars(args), "runs": runs, "aggregate": agg}
|
| 225 |
+
with open(args.out, "w") as f:
|
| 226 |
+
json.dump(out, f, indent=2)
|
| 227 |
+
print("AGGREGATE " + json.dumps({k: f"{v['mean']:.2f}+-{v['std']:.2f}"
|
| 228 |
+
for k, v in agg.items()}), flush=True)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
if __name__ == "__main__":
|
| 232 |
+
main()
|