riteshhf commited on
Commit
2188a91
·
verified ·
1 Parent(s): cdde621

Upload folder using huggingface_hub

Browse files
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()