AasthaPal9 commited on
Commit
4c4d79d
·
verified ·
1 Parent(s): 9459e83

Initial TRACE implementation

Browse files
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ *.log
5
+ *.tmp
6
+ .DS_Store
7
+ .RData
8
+ .Rhistory
9
+ outputs/
10
+ results/
11
+ checkpoints/
12
+ inputs/*.npz
13
+ inputs/*.csv
README.md CHANGED
@@ -1,15 +1,77 @@
1
  ---
2
  license: cc-by-nc-nd-4.0
 
 
 
 
 
3
  ---
4
 
5
- # TRACE
6
 
7
- **Transcriptional Regulator-Modulated Reference Geometry for Temporal Single-Cell Flow Matching**
8
 
9
- TRACE incorporates transcription-factor regulon-derived directional information into cubic-Hermite reference paths for conditional flow matching.
10
 
11
- The method is evaluated on temporal single-cell RNA-seq datasets using held-out population reconstruction.
12
 
13
- ## Implementation
14
 
15
- Code and reproducibility instructions will be added incrementally.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: cc-by-nc-nd-4.0
3
+ tags:
4
+ - single-cell
5
+ - temporal-dynamics
6
+ - flow-matching
7
+ - transcriptomics
8
  ---
9
 
10
+ # TRACE — Transcriptional Regulator-Modulated Reference Geometry
11
 
12
+ **Research code accompanying the TRACE ICLR 2027 submission.**
13
 
14
+ TRACE (formerly BioCHIME in the development scripts) modulates the reference path of temporal single-cell conditional flow matching using transcription-factor regulon derivatives. It first couples retained population snapshots with balanced optimal transport, constructs PCHIP/Hermite reference paths, maps regulon activity derivatives into PCA coordinates with a ridge fit \(Z pprox AW+b\), calibrates their norms, and blends the mapped tangents with geometric PCHIP tangents before shape-preserving projection.
15
 
16
+ This is an **initial code-only release**. Input matrices, frozen TF-target prior, pretrained weights, and third-party benchmark implementations are not included. The code has been distilled from the original research scripts; the reorganized runner has **not yet been checked for numerical equivalence against archived paper runs**.
17
 
18
+ ## Submitted TRACE configuration
19
 
20
+ | Parameter | Value |
21
+ | --- | --- |
22
+ | Optimization steps | **4,000** |
23
+ | TF steering coefficient \(
24
+ ho\) | **1.0** |
25
+ | Ridge regularization \(\lambda\) | 1.0 |
26
+ | Latent PCA dimensions | 30 |
27
+ | Hidden layers / width | 3 / 256 |
28
+ | Activation / time encoding | SiLU / 8 Fourier frequencies |
29
+ | Learning rate / optimizer | 0.001 / Adam |
30
+ | Batch size | 128 |
31
+ | Held-out repeats | 5 (seeds 0–4) |
32
+ | ODE integration | 50 Euler steps |
33
+ | OT | Balanced EMD, squared Euclidean cost |
34
+
35
+ ## Files
36
+
37
+ - `biochime_regulon_core.py` — method core implementing TRACE
38
+ - `benchmark_table.py` — original neural field, OT, reference path and Euler integration helpers.
39
+ - `run_trace_benchmark.py` — simplified runner for linear CFM, PCHIP cubic CFM, TRACE, and paired regulatory permutations.
40
+ - `tests/test_trace_core.py` — numerical unit tests for spline, shape-projection and ridge helpers.
41
+
42
+ ## Install and test
43
+
44
+ ```bash
45
+ python -m pip install -r requirements.txt
46
+ python -m unittest discover -s tests -v
47
+ ```
48
+
49
+ ## Run a Gupta day-7 holdout
50
+
51
+ Provide the study-specific input files (not distributed with this initial release):
52
+
53
+ - `inputs/gupta_hvg_matrix.npz` containing `X` (cells × genes), `t` (biological time), `var_names` (gene names), and optionally `used_scale`.
54
+ - `inputs/tcell_regulatory_prior_frozen29.csv` with signed TF-target edges; see `RegulonScorer` for required columns and coverage rules.
55
+
56
+ ```bash
57
+ python run_trace_benchmark.py \
58
+ --mat inputs/gupta_hvg_matrix.npz \
59
+ --prior inputs/tcell_regulatory_prior_frozen29.csv \
60
+ --holdouts 7 \
61
+ --steps 4000 --rho 1.0 \
62
+ --folds 5 --ncomp 30 --batch 128 --ridge 1.0 \
63
+ --candidate_source all --edge_weight_mode sign \
64
+ --tag results/gupta_d7
65
+ ```
66
+
67
+ The runner produces fold-level results, mean ± population-SD summaries, and TF coverage tables. For external experiments, keep `--rho 1.0` fixed and provide Giles/DSM matrices with holdouts `8,15` and `8,28`, respectively.
68
+
69
+ ## Reproducibility limitations
70
+
71
+ - The original uploaded `src/` archive did not include the data preparation / NPZ construction scripts or frozen signed prior, so the exact 29-TF panel and initial gene selection cannot yet be independently recreated from this package.
72
+ - `benchmark_table.ot_plan()` attempts exact EMD but has an approximate fallback if EMD fails. Confirm whether the fallback was triggered in archived paper runs before claiming exact reproduction.
73
+ - This release does not yet reproduce all manuscript components (Slingshot curvature, external method baselines, interpolation-family ablations, or saved checkpoints).
74
+
75
+ ## Review and licensing
76
+
77
+ Keep this lab-owned repository **private during double-blind review**, and do not link it as anonymous code. The present Hub metadata retains the repository's CC BY-NC-ND 4.0 designation, which restricts derivative works and may not be the desired license for software; confirm the final code license with the lab before public release.
benchmark_table.py ADDED
@@ -0,0 +1,1487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ benchmark_table.py
4
+
5
+ Core baseline utilities for held-out interior-day population imputation.
6
+
7
+ The functions here can be imported by 02_bracket_heldout.py.
8
+ """
9
+
10
+ import math
11
+ import numpy as np
12
+ import torch
13
+ import torch.nn as nn
14
+ import ot
15
+
16
+
17
+ # ============================================================
18
+ # Neural velocity field
19
+ # ============================================================
20
+
21
+ def fourier(x, n=8):
22
+ f = (
23
+ 2.0 ** torch.arange(n, device=x.device, dtype=x.dtype)
24
+ ) * math.pi
25
+
26
+ a = x * f
27
+
28
+ return torch.cat(
29
+ [
30
+ torch.sin(a),
31
+ torch.cos(a),
32
+ ],
33
+ dim=-1,
34
+ )
35
+
36
+
37
+ class Field(nn.Module):
38
+ def __init__(self, d, h=256, nt=8):
39
+ super().__init__()
40
+
41
+ self.nt = nt
42
+
43
+ self.net = nn.Sequential(
44
+ nn.Linear(d + 2 * nt, h),
45
+ nn.SiLU(),
46
+
47
+ nn.Linear(h, h),
48
+ nn.SiLU(),
49
+
50
+ nn.Linear(h, h),
51
+ nn.SiLU(),
52
+
53
+ nn.Linear(h, d),
54
+ )
55
+
56
+ def forward(self, z, tau):
57
+
58
+ if tau.ndim == 1:
59
+ tau = tau[:, None]
60
+
61
+ return self.net(
62
+ torch.cat(
63
+ [
64
+ z,
65
+ fourier(tau, self.nt),
66
+ ],
67
+ dim=-1,
68
+ )
69
+ )
70
+
71
+
72
+ # ============================================================
73
+ # Optimal transport
74
+ # ============================================================
75
+
76
+ def ot_plan(X0, X1):
77
+ """
78
+ Balanced quadratic-cost OT plan.
79
+
80
+ Exact EMD is attempted first.
81
+ If it fails, fall back to a simple Sinkhorn-like scaling procedure.
82
+ """
83
+
84
+ X0 = np.asarray(X0, dtype=np.float64)
85
+ X1 = np.asarray(X1, dtype=np.float64)
86
+
87
+ C = (
88
+ (
89
+ X0[:, None, :]
90
+ -
91
+ X1[None, :, :]
92
+ ) ** 2
93
+ ).sum(-1)
94
+
95
+ a = np.ones(len(X0), dtype=np.float64) / len(X0)
96
+ b = np.ones(len(X1), dtype=np.float64) / len(X1)
97
+
98
+ try:
99
+ return ot.emd(
100
+ a,
101
+ b,
102
+ C,
103
+ numItermax=2_000_000,
104
+ )
105
+
106
+ except Exception:
107
+
108
+ Cn = C / (C.max() + 1e-12)
109
+
110
+ K = np.exp(
111
+ -Cn / 0.05
112
+ )
113
+
114
+ u = np.ones_like(a)
115
+ v = np.ones_like(b)
116
+
117
+ for _ in range(300):
118
+
119
+ u = a / (
120
+ K @ v + 1e-12
121
+ )
122
+
123
+ v = b / (
124
+ K.T @ u + 1e-12
125
+ )
126
+
127
+ return (
128
+ u[:, None]
129
+ *
130
+ K
131
+ *
132
+ v[None, :]
133
+ )
134
+
135
+
136
+ def align_all(clouds, rng, batch=128):
137
+ """
138
+ Sample approximately OT-aligned tuples across all retained clouds.
139
+
140
+ Returns
141
+ -------
142
+ Z : ndarray
143
+ Shape (K, B, d)
144
+ """
145
+
146
+ B = min(
147
+ batch,
148
+ min(
149
+ len(c)
150
+ for c in clouds
151
+ ),
152
+ )
153
+
154
+ first_idx = rng.choice(
155
+ len(clouds[0]),
156
+ B,
157
+ replace=False,
158
+ )
159
+
160
+ z = [
161
+ clouds[0][first_idx]
162
+ ]
163
+
164
+ for k in range(
165
+ 1,
166
+ len(clouds),
167
+ ):
168
+
169
+ nxt_idx = rng.choice(
170
+ len(clouds[k]),
171
+ B,
172
+ replace=False,
173
+ )
174
+
175
+ nxt = clouds[k][nxt_idx]
176
+
177
+ P = ot_plan(
178
+ z[-1],
179
+ nxt,
180
+ )
181
+
182
+ Pn = P / (
183
+ P.sum(
184
+ axis=1,
185
+ keepdims=True,
186
+ )
187
+ +
188
+ 1e-12
189
+ )
190
+
191
+ selected = np.array(
192
+ [
193
+ rng.choice(
194
+ B,
195
+ p=Pn[i],
196
+ )
197
+ for i in range(B)
198
+ ]
199
+ )
200
+
201
+ z.append(
202
+ nxt[selected]
203
+ )
204
+
205
+ return np.stack(
206
+ z,
207
+ axis=0,
208
+ )
209
+
210
+
211
+ # ============================================================
212
+ # Spline interpolation
213
+ # ============================================================
214
+
215
+ def pchip(x, Y, t):
216
+ """
217
+ Vectorized shape-preserving cubic Hermite interpolation.
218
+
219
+ Parameters
220
+ ----------
221
+ x : (K,)
222
+ Knot times.
223
+
224
+ Y : (K, B, d)
225
+ Aligned tuples.
226
+
227
+ t : (B,)
228
+ Evaluation times.
229
+
230
+ Returns
231
+ -------
232
+ mu : (B, d)
233
+ Interpolated value.
234
+
235
+ mup : (B, d)
236
+ Time derivative.
237
+ """
238
+
239
+ K = len(x)
240
+
241
+ h = np.diff(x)
242
+
243
+ d = (
244
+ Y[1:]
245
+ -
246
+ Y[:-1]
247
+ ) / h[:, None, None]
248
+
249
+ m = np.zeros_like(Y)
250
+
251
+ # Interior PCHIP slopes
252
+ for k in range(
253
+ 1,
254
+ K - 1,
255
+ ):
256
+
257
+ d0 = d[k - 1]
258
+ d1 = d[k]
259
+
260
+ h0 = h[k - 1]
261
+ h1 = h[k]
262
+
263
+ w0 = 2 * h1 + h0
264
+ w1 = h1 + 2 * h0
265
+
266
+ mono = (
267
+ d0 * d1
268
+ ) > 0
269
+
270
+ with np.errstate(
271
+ divide="ignore",
272
+ invalid="ignore",
273
+ ):
274
+
275
+ mk = (
276
+ w0 + w1
277
+ ) / (
278
+ w0 / d0
279
+ +
280
+ w1 / d1
281
+ )
282
+
283
+ m[k] = np.where(
284
+ mono,
285
+ mk,
286
+ 0.0,
287
+ )
288
+
289
+ def endpoint_slope(
290
+ h0,
291
+ h1,
292
+ d0,
293
+ d1,
294
+ ):
295
+
296
+ me = (
297
+ (
298
+ 2 * h0 + h1
299
+ )
300
+ *
301
+ d0
302
+ -
303
+ h0 * d1
304
+ ) / (
305
+ h0 + h1
306
+ )
307
+
308
+ me = np.where(
309
+ np.sign(me)
310
+ !=
311
+ np.sign(d0),
312
+ 0.0,
313
+ me,
314
+ )
315
+
316
+ me = np.where(
317
+ (
318
+ np.sign(d0)
319
+ !=
320
+ np.sign(d1)
321
+ )
322
+ &
323
+ (
324
+ np.abs(me)
325
+ >
326
+ 3 * np.abs(d0)
327
+ ),
328
+ 3 * d0,
329
+ me,
330
+ )
331
+
332
+ return me
333
+
334
+ m[0] = endpoint_slope(
335
+ h[0],
336
+ h[1] if K > 2 else h[0],
337
+ d[0],
338
+ d[1] if K > 2 else d[0],
339
+ )
340
+
341
+ m[-1] = endpoint_slope(
342
+ h[-1],
343
+ h[-2] if K > 2 else h[-1],
344
+ d[-1],
345
+ d[-2] if K > 2 else d[-1],
346
+ )
347
+
348
+ B = Y.shape[1]
349
+
350
+ idx = np.clip(
351
+ np.searchsorted(
352
+ x,
353
+ t,
354
+ side="right",
355
+ )
356
+ -
357
+ 1,
358
+ 0,
359
+ K - 2,
360
+ )
361
+
362
+ xi = x[idx]
363
+ xi1 = x[idx + 1]
364
+
365
+ hi = xi1 - xi
366
+
367
+ s = (
368
+ (
369
+ t - xi
370
+ )
371
+ /
372
+ hi
373
+ )[:, None]
374
+
375
+ yi = Y[
376
+ idx,
377
+ np.arange(B),
378
+ ]
379
+
380
+ yi1 = Y[
381
+ idx + 1,
382
+ np.arange(B),
383
+ ]
384
+
385
+ mi = m[
386
+ idx,
387
+ np.arange(B),
388
+ ]
389
+
390
+ mi1 = m[
391
+ idx + 1,
392
+ np.arange(B),
393
+ ]
394
+
395
+ hb = hi[:, None]
396
+
397
+ mu = (
398
+ (
399
+ 2 * s**3
400
+ -
401
+ 3 * s**2
402
+ +
403
+ 1
404
+ )
405
+ *
406
+ yi
407
+ +
408
+ (
409
+ s**3
410
+ -
411
+ 2 * s**2
412
+ +
413
+ s
414
+ )
415
+ *
416
+ hb
417
+ *
418
+ mi
419
+ +
420
+ (
421
+ -2 * s**3
422
+ +
423
+ 3 * s**2
424
+ )
425
+ *
426
+ yi1
427
+ +
428
+ (
429
+ s**3
430
+ -
431
+ s**2
432
+ )
433
+ *
434
+ hb
435
+ *
436
+ mi1
437
+ )
438
+
439
+ mup = (
440
+ (
441
+ 6 * s**2
442
+ -
443
+ 6 * s
444
+ )
445
+ *
446
+ yi
447
+ +
448
+ (
449
+ 3 * s**2
450
+ -
451
+ 4 * s
452
+ +
453
+ 1
454
+ )
455
+ *
456
+ hb
457
+ *
458
+ mi
459
+ +
460
+ (
461
+ -6 * s**2
462
+ +
463
+ 6 * s
464
+ )
465
+ *
466
+ yi1
467
+ +
468
+ (
469
+ 3 * s**2
470
+ -
471
+ 2 * s
472
+ )
473
+ *
474
+ hb
475
+ *
476
+ mi1
477
+ ) / hb
478
+
479
+ return mu, mup
480
+
481
+
482
+ def natural(x, Y, t):
483
+ """
484
+ Natural cubic spline interpolation.
485
+ """
486
+
487
+ K = len(x)
488
+
489
+ h = np.diff(x)
490
+
491
+ n = K - 2
492
+
493
+ if n <= 0:
494
+
495
+ M = np.zeros_like(Y)
496
+
497
+ else:
498
+
499
+ A = np.zeros(
500
+ (n, n),
501
+ dtype=np.float64,
502
+ )
503
+
504
+ rhs = np.zeros(
505
+ (n,) + Y.shape[1:],
506
+ dtype=np.float64,
507
+ )
508
+
509
+ for i in range(
510
+ 1,
511
+ K - 1,
512
+ ):
513
+
514
+ r = i - 1
515
+
516
+ A[r, r] = 2 * (
517
+ h[i - 1]
518
+ +
519
+ h[i]
520
+ )
521
+
522
+ if r > 0:
523
+ A[
524
+ r,
525
+ r - 1,
526
+ ] = h[i - 1]
527
+
528
+ if r < n - 1:
529
+ A[
530
+ r,
531
+ r + 1,
532
+ ] = h[i]
533
+
534
+ rhs[r] = 6 * (
535
+ (
536
+ Y[i + 1]
537
+ -
538
+ Y[i]
539
+ )
540
+ /
541
+ h[i]
542
+ -
543
+ (
544
+ Y[i]
545
+ -
546
+ Y[i - 1]
547
+ )
548
+ /
549
+ h[i - 1]
550
+ )
551
+
552
+ M = np.zeros_like(Y)
553
+
554
+ # Solve rather than explicitly invert A.
555
+ flat_rhs = rhs.reshape(
556
+ n,
557
+ -1,
558
+ )
559
+
560
+ flat_solution = np.linalg.solve(
561
+ A,
562
+ flat_rhs,
563
+ )
564
+
565
+ M[
566
+ 1:K - 1
567
+ ] = flat_solution.reshape(
568
+ rhs.shape
569
+ )
570
+
571
+ B = Y.shape[1]
572
+
573
+ idx = np.clip(
574
+ np.searchsorted(
575
+ x,
576
+ t,
577
+ side="right",
578
+ )
579
+ -
580
+ 1,
581
+ 0,
582
+ K - 2,
583
+ )
584
+
585
+ xi = x[idx]
586
+ xi1 = x[idx + 1]
587
+
588
+ hh = xi1 - xi
589
+
590
+ a = (
591
+ (
592
+ xi1 - t
593
+ )
594
+ /
595
+ hh
596
+ )[:, None]
597
+
598
+ b = (
599
+ (
600
+ t - xi
601
+ )
602
+ /
603
+ hh
604
+ )[:, None]
605
+
606
+ hb = hh[:, None]
607
+
608
+ yi = Y[
609
+ idx,
610
+ np.arange(B),
611
+ ]
612
+
613
+ yi1 = Y[
614
+ idx + 1,
615
+ np.arange(B),
616
+ ]
617
+
618
+ mi = M[
619
+ idx,
620
+ np.arange(B),
621
+ ]
622
+
623
+ mi1 = M[
624
+ idx + 1,
625
+ np.arange(B),
626
+ ]
627
+
628
+ mu = (
629
+ a * yi
630
+ +
631
+ b * yi1
632
+ +
633
+ (
634
+ (
635
+ a**3 - a
636
+ )
637
+ *
638
+ mi
639
+ +
640
+ (
641
+ b**3 - b
642
+ )
643
+ *
644
+ mi1
645
+ )
646
+ *
647
+ (
648
+ hb**2
649
+ )
650
+ /
651
+ 6
652
+ )
653
+
654
+ mup = (
655
+ (
656
+ yi1 - yi
657
+ )
658
+ /
659
+ hb
660
+ +
661
+ hb
662
+ /
663
+ 6
664
+ *
665
+ (
666
+ (
667
+ 1
668
+ -
669
+ 3 * a**2
670
+ )
671
+ *
672
+ mi
673
+ +
674
+ (
675
+ 3 * b**2
676
+ -
677
+ 1
678
+ )
679
+ *
680
+ mi1
681
+ )
682
+ )
683
+
684
+ return mu, mup
685
+
686
+
687
+ def linear(x, Y, t):
688
+ """
689
+ Piecewise-linear interpolation.
690
+ """
691
+
692
+ B = Y.shape[1]
693
+
694
+ idx = np.clip(
695
+ np.searchsorted(
696
+ x,
697
+ t,
698
+ side="right",
699
+ )
700
+ -
701
+ 1,
702
+ 0,
703
+ len(x) - 2,
704
+ )
705
+
706
+ xi = x[idx]
707
+ xi1 = x[idx + 1]
708
+
709
+ hb = (
710
+ xi1 - xi
711
+ )[:, None]
712
+
713
+ s = (
714
+ (
715
+ t - xi
716
+ )
717
+ /
718
+ (
719
+ xi1 - xi
720
+ )
721
+ )[:, None]
722
+
723
+ yi = Y[
724
+ idx,
725
+ np.arange(B),
726
+ ]
727
+
728
+ yi1 = Y[
729
+ idx + 1,
730
+ np.arange(B),
731
+ ]
732
+
733
+ mu = (
734
+ 1 - s
735
+ ) * yi + s * yi1
736
+
737
+ mup = (
738
+ yi1 - yi
739
+ ) / hb
740
+
741
+ return mu, mup
742
+
743
+
744
+ SPL = {
745
+ "linear": linear,
746
+ "natural": natural,
747
+ "pchip": pchip,
748
+ }
749
+
750
+
751
+ # ============================================================
752
+ # Conditional flow matching
753
+ # ============================================================
754
+
755
+ def fit_flow(
756
+ clouds_by_day,
757
+ dn,
758
+ spline,
759
+ dev,
760
+ steps,
761
+ seed,
762
+ batch=128,
763
+ lr=1e-3,
764
+ ):
765
+ """
766
+ Fit a conditional velocity field to one of the reference interpolants.
767
+ """
768
+
769
+ torch.manual_seed(seed)
770
+
771
+ rng = np.random.default_rng(
772
+ seed
773
+ )
774
+
775
+ d = next(
776
+ iter(
777
+ clouds_by_day.values()
778
+ )
779
+ ).shape[1]
780
+
781
+ field = Field(
782
+ d
783
+ ).to(dev)
784
+
785
+ opt = torch.optim.Adam(
786
+ field.parameters(),
787
+ lr=lr,
788
+ )
789
+
790
+ x = np.array(
791
+ sorted(dn),
792
+ dtype=float,
793
+ )
794
+
795
+ clouds = [
796
+ clouds_by_day[t]
797
+ for t in x
798
+ ]
799
+
800
+ ni = len(x) - 1
801
+
802
+ for step in range(steps):
803
+
804
+ Z = align_all(
805
+ clouds,
806
+ rng,
807
+ batch,
808
+ )
809
+
810
+ B = Z.shape[1]
811
+
812
+ per = B // ni
813
+
814
+ ts = []
815
+
816
+ for i in range(ni):
817
+
818
+ m = per + (
819
+ 1
820
+ if i < B - per * ni
821
+ else 0
822
+ )
823
+
824
+ ts.append(
825
+ rng.uniform(
826
+ x[i],
827
+ x[i + 1],
828
+ size=m,
829
+ )
830
+ )
831
+
832
+ t = np.concatenate(
833
+ ts
834
+ )[:B]
835
+
836
+ rng.shuffle(
837
+ t
838
+ )
839
+
840
+ mu, mup = SPL[
841
+ spline
842
+ ](
843
+ x,
844
+ Z,
845
+ t,
846
+ )
847
+
848
+ tt = torch.tensor(
849
+ t[:, None],
850
+ dtype=torch.float32,
851
+ device=dev,
852
+ )
853
+
854
+ mu_t = torch.tensor(
855
+ mu,
856
+ dtype=torch.float32,
857
+ device=dev,
858
+ )
859
+
860
+ mup_t = torch.tensor(
861
+ mup,
862
+ dtype=torch.float32,
863
+ device=dev,
864
+ )
865
+
866
+ pred = field(
867
+ mu_t,
868
+ tt,
869
+ )
870
+
871
+ loss = (
872
+ (
873
+ pred
874
+ -
875
+ mup_t
876
+ ) ** 2
877
+ ).mean()
878
+
879
+ opt.zero_grad()
880
+
881
+ loss.backward()
882
+
883
+ nn.utils.clip_grad_norm_(
884
+ field.parameters(),
885
+ 1.0,
886
+ )
887
+
888
+ opt.step()
889
+
890
+ field.eval()
891
+
892
+ return field
893
+
894
+
895
+ def ode(
896
+ field,
897
+ z0,
898
+ ts,
899
+ te,
900
+ dev,
901
+ N=50,
902
+ ):
903
+ """
904
+ Forward Euler integration of the learned field.
905
+ """
906
+
907
+ z = z0.clone()
908
+
909
+ dt = (
910
+ te - ts
911
+ ) / N
912
+
913
+ with torch.no_grad():
914
+
915
+ for k in range(N):
916
+
917
+ tcur = (
918
+ ts
919
+ +
920
+ (
921
+ te - ts
922
+ )
923
+ *
924
+ k
925
+ /
926
+ N
927
+ )
928
+
929
+ tau = torch.full(
930
+ (
931
+ len(z),
932
+ 1,
933
+ ),
934
+ float(tcur),
935
+ device=dev,
936
+ )
937
+
938
+ z = (
939
+ z
940
+ +
941
+ dt
942
+ *
943
+ field(
944
+ z,
945
+ tau,
946
+ )
947
+ )
948
+
949
+ return z.cpu().numpy()
950
+
951
+
952
+ # ============================================================
953
+ # Simple held-out baselines
954
+ # ============================================================
955
+
956
+ def bracketing_days(
957
+ retained_days,
958
+ heldout_day,
959
+ ):
960
+ """
961
+ Return nearest observed retained day before and after heldout_day.
962
+
963
+ Example
964
+ -------
965
+ Gupta d7:
966
+ retained = [2.5, 14, 21]
967
+ -> left=2.5, right=14
968
+
969
+ Gupta d14:
970
+ retained = [2.5, 7, 21]
971
+ -> left=7, right=21
972
+ """
973
+
974
+ retained = np.array(
975
+ sorted(
976
+ float(x)
977
+ for x in retained_days
978
+ ),
979
+ dtype=float,
980
+ )
981
+
982
+ left = retained[
983
+ retained < heldout_day
984
+ ]
985
+
986
+ right = retained[
987
+ retained > heldout_day
988
+ ]
989
+
990
+ if len(left) == 0:
991
+ raise ValueError(
992
+ f"No retained day before heldout={heldout_day}"
993
+ )
994
+
995
+ if len(right) == 0:
996
+ raise ValueError(
997
+ f"No retained day after heldout={heldout_day}"
998
+ )
999
+
1000
+ return (
1001
+ float(left.max()),
1002
+ float(right.min()),
1003
+ )
1004
+
1005
+
1006
+ def no_move(
1007
+ X_source,
1008
+ rng=None,
1009
+ n_out=None,
1010
+ ):
1011
+ """
1012
+ No-move baseline.
1013
+
1014
+ Predict that the held-out population is simply the previous observed
1015
+ source population.
1016
+
1017
+ If n_out is supplied, resample source cells to n_out.
1018
+ """
1019
+
1020
+ X_source = np.asarray(
1021
+ X_source
1022
+ )
1023
+
1024
+ if n_out is None:
1025
+ return X_source.copy()
1026
+
1027
+ if rng is None:
1028
+ rng = np.random.default_rng(
1029
+ 0
1030
+ )
1031
+
1032
+ idx = rng.choice(
1033
+ len(X_source),
1034
+ size=n_out,
1035
+ replace=(
1036
+ n_out
1037
+ >
1038
+ len(X_source)
1039
+ ),
1040
+ )
1041
+
1042
+ return X_source[
1043
+ idx
1044
+ ].copy()
1045
+
1046
+
1047
+ def mccann(
1048
+ Xa,
1049
+ Xb,
1050
+ alpha,
1051
+ rng,
1052
+ n_out=None,
1053
+ ):
1054
+ """
1055
+ McCann / displacement interpolation under an OT coupling.
1056
+
1057
+ Parameters
1058
+ ----------
1059
+ Xa, Xb
1060
+ Bracketing populations.
1061
+
1062
+ alpha
1063
+ Fractional position in time:
1064
+ alpha = (t_h - t_left) / (t_right - t_left)
1065
+
1066
+ n_out
1067
+ Number of samples drawn from the interpolated distribution.
1068
+ Defaults to len(Xa).
1069
+ """
1070
+
1071
+ Xa = np.asarray(
1072
+ Xa
1073
+ )
1074
+
1075
+ Xb = np.asarray(
1076
+ Xb
1077
+ )
1078
+
1079
+ P = ot_plan(
1080
+ Xa,
1081
+ Xb,
1082
+ )
1083
+
1084
+ total = P.sum()
1085
+
1086
+ if total <= 0:
1087
+ raise RuntimeError(
1088
+ "OT plan has zero total mass."
1089
+ )
1090
+
1091
+ if n_out is None:
1092
+ n_out = len(Xa)
1093
+
1094
+ idx = rng.choice(
1095
+ P.size,
1096
+ size=n_out,
1097
+ replace=True,
1098
+ p=(
1099
+ P / total
1100
+ ).ravel(),
1101
+ )
1102
+
1103
+ pi, pj = np.unravel_index(
1104
+ idx,
1105
+ P.shape,
1106
+ )
1107
+
1108
+ return (
1109
+ (
1110
+ 1.0 - alpha
1111
+ )
1112
+ *
1113
+ Xa[pi]
1114
+ +
1115
+ alpha
1116
+ *
1117
+ Xb[pj]
1118
+ )
1119
+
1120
+
1121
+ def plain_ot_impute(
1122
+ clouds_by_day,
1123
+ retained_days,
1124
+ heldout_day,
1125
+ seed=0,
1126
+ n_fit=1500,
1127
+ n_out=1500,
1128
+ ):
1129
+ """
1130
+ Plain OT / McCann interpolation baseline.
1131
+
1132
+ Uses only the two retained observed populations immediately bracketing
1133
+ the held-out time.
1134
+
1135
+ To keep exact EMD computationally manageable, each bracketing cloud is
1136
+ subsampled to at most n_fit cells before constructing the OT plan.
1137
+
1138
+ The same n_fit should be used for every method/dataset in the benchmark.
1139
+ """
1140
+
1141
+ rng = np.random.default_rng(
1142
+ seed
1143
+ )
1144
+
1145
+ t0, t1 = bracketing_days(
1146
+ retained_days,
1147
+ heldout_day,
1148
+ )
1149
+
1150
+ Xa = np.asarray(
1151
+ clouds_by_day[t0]
1152
+ )
1153
+
1154
+ Xb = np.asarray(
1155
+ clouds_by_day[t1]
1156
+ )
1157
+
1158
+ if len(Xa) > n_fit:
1159
+
1160
+ ia = rng.choice(
1161
+ len(Xa),
1162
+ n_fit,
1163
+ replace=False,
1164
+ )
1165
+
1166
+ Xa_fit = Xa[
1167
+ ia
1168
+ ]
1169
+
1170
+ else:
1171
+
1172
+ Xa_fit = Xa
1173
+
1174
+ if len(Xb) > n_fit:
1175
+
1176
+ ib = rng.choice(
1177
+ len(Xb),
1178
+ n_fit,
1179
+ replace=False,
1180
+ )
1181
+
1182
+ Xb_fit = Xb[
1183
+ ib
1184
+ ]
1185
+
1186
+ else:
1187
+
1188
+ Xb_fit = Xb
1189
+
1190
+ alpha = (
1191
+ heldout_day - t0
1192
+ ) / (
1193
+ t1 - t0
1194
+ )
1195
+
1196
+ pred = mccann(
1197
+ Xa_fit,
1198
+ Xb_fit,
1199
+ alpha,
1200
+ rng,
1201
+ n_out=n_out,
1202
+ )
1203
+
1204
+ return pred
1205
+
1206
+
1207
+ def no_move_impute(
1208
+ clouds_by_day,
1209
+ retained_days,
1210
+ heldout_day,
1211
+ seed=0,
1212
+ n_out=1500,
1213
+ ):
1214
+ """
1215
+ Held-out no-move baseline using the nearest earlier retained population.
1216
+ """
1217
+
1218
+ rng = np.random.default_rng(
1219
+ seed
1220
+ )
1221
+
1222
+ t0, _ = bracketing_days(
1223
+ retained_days,
1224
+ heldout_day,
1225
+ )
1226
+
1227
+ return no_move(
1228
+ clouds_by_day[t0],
1229
+ rng=rng,
1230
+ n_out=n_out,
1231
+ )
1232
+
1233
+
1234
+ # ============================================================
1235
+ # FSI / spline image helper
1236
+ # ============================================================
1237
+
1238
+ def fsi(
1239
+ clouds_by_day,
1240
+ dn,
1241
+ t_h,
1242
+ rng,
1243
+ ):
1244
+ """
1245
+ Push the first observed population through a sequence of barycentric
1246
+ OT maps, then fit a natural spline through the resulting trajectories.
1247
+
1248
+ Retained here for compatibility with the existing benchmark code.
1249
+ """
1250
+
1251
+ x = np.array(
1252
+ sorted(dn),
1253
+ dtype=float,
1254
+ )
1255
+
1256
+ clouds = [
1257
+ clouds_by_day[t]
1258
+ for t in x
1259
+ ]
1260
+
1261
+ cur = clouds[0]
1262
+
1263
+ imgs = [
1264
+ cur
1265
+ ]
1266
+
1267
+ for k in range(
1268
+ 1,
1269
+ len(clouds),
1270
+ ):
1271
+
1272
+ P = ot_plan(
1273
+ cur,
1274
+ clouds[k],
1275
+ )
1276
+
1277
+ Pn = P / (
1278
+ P.sum(
1279
+ axis=1,
1280
+ keepdims=True,
1281
+ )
1282
+ +
1283
+ 1e-12
1284
+ )
1285
+
1286
+ cur = (
1287
+ Pn
1288
+ @
1289
+ clouds[k]
1290
+ )
1291
+
1292
+ imgs.append(
1293
+ cur
1294
+ )
1295
+
1296
+ Y = np.stack(
1297
+ imgs,
1298
+ axis=0,
1299
+ )
1300
+
1301
+ mu, _ = natural(
1302
+ x,
1303
+ Y,
1304
+ np.full(
1305
+ Y.shape[1],
1306
+ t_h,
1307
+ ),
1308
+ )
1309
+
1310
+ return mu
1311
+
1312
+
1313
+ # ============================================================
1314
+ # Evaluation
1315
+ # ============================================================
1316
+
1317
+ def w2(
1318
+ A,
1319
+ B,
1320
+ mx=1500,
1321
+ seed=0,
1322
+ ):
1323
+ """
1324
+ Empirical W2 using exact balanced OT after paired-size subsampling.
1325
+ """
1326
+
1327
+ r = np.random.default_rng(
1328
+ seed
1329
+ )
1330
+
1331
+ A = np.asarray(
1332
+ A
1333
+ )
1334
+
1335
+ B = np.asarray(
1336
+ B
1337
+ )
1338
+
1339
+ if len(A) > mx:
1340
+
1341
+ A = A[
1342
+ r.choice(
1343
+ len(A),
1344
+ mx,
1345
+ replace=False,
1346
+ )
1347
+ ]
1348
+
1349
+ if len(B) > mx:
1350
+
1351
+ B = B[
1352
+ r.choice(
1353
+ len(B),
1354
+ mx,
1355
+ replace=False,
1356
+ )
1357
+ ]
1358
+
1359
+ C = (
1360
+ (
1361
+ A[:, None, :]
1362
+ -
1363
+ B[None, :, :]
1364
+ ) ** 2
1365
+ ).sum(-1)
1366
+
1367
+ cost = ot.emd2(
1368
+ ot.unif(
1369
+ len(A)
1370
+ ),
1371
+ ot.unif(
1372
+ len(B)
1373
+ ),
1374
+ C,
1375
+ numItermax=2_000_000,
1376
+ )
1377
+
1378
+ return float(
1379
+ np.sqrt(
1380
+ max(
1381
+ cost,
1382
+ 0.0,
1383
+ )
1384
+ )
1385
+ )
1386
+
1387
+
1388
+ def mse_means(
1389
+ A,
1390
+ B,
1391
+ ):
1392
+ """
1393
+ Squared error between population means.
1394
+ """
1395
+
1396
+ A = np.asarray(
1397
+ A
1398
+ )
1399
+
1400
+ B = np.asarray(
1401
+ B
1402
+ )
1403
+
1404
+ return float(
1405
+ (
1406
+ (
1407
+ A.mean(0)
1408
+ -
1409
+ B.mean(0)
1410
+ ) ** 2
1411
+ ).mean()
1412
+ )
1413
+
1414
+
1415
+ # ============================================================
1416
+ # Convenience function for held-out baselines
1417
+ # ============================================================
1418
+
1419
+ def basic_baseline_predictions(
1420
+ clouds_by_day,
1421
+ retained_days,
1422
+ heldout_day,
1423
+ seed=0,
1424
+ n_fit=1500,
1425
+ n_out=1500,
1426
+ ):
1427
+ """
1428
+ Return the two simple reference baselines needed for the first TRACE
1429
+ comparison.
1430
+
1431
+ Returns
1432
+ -------
1433
+ dict
1434
+ {
1435
+ "No_move": ...,
1436
+ "OT_McCann": ...
1437
+ }
1438
+ """
1439
+
1440
+ pred_no_move = no_move_impute(
1441
+ clouds_by_day,
1442
+ retained_days,
1443
+ heldout_day,
1444
+ seed=seed,
1445
+ n_out=n_out,
1446
+ )
1447
+
1448
+ pred_ot = plain_ot_impute(
1449
+ clouds_by_day,
1450
+ retained_days,
1451
+ heldout_day,
1452
+ seed=seed,
1453
+ n_fit=n_fit,
1454
+ n_out=n_out,
1455
+ )
1456
+
1457
+ return {
1458
+ "No_move": pred_no_move,
1459
+ "OT_McCann": pred_ot,
1460
+ }
1461
+
1462
+
1463
+ # ============================================================
1464
+ # Import check
1465
+ # ============================================================
1466
+
1467
+ if __name__ == "__main__":
1468
+
1469
+ print(
1470
+ "benchmark_table.py loaded successfully."
1471
+ )
1472
+
1473
+ print(
1474
+ "This file is intended to be imported by "
1475
+ "02_bracket_heldout.py."
1476
+ )
1477
+
1478
+ print(
1479
+ "Available simple baselines: "
1480
+ "No_move, OT_McCann."
1481
+ )
1482
+
1483
+ print(
1484
+ "Available CFM splines: "
1485
+ "linear, natural, pchip."
1486
+ )
1487
+
biochime_regulon_core.py ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from dataclasses import dataclass
7
+ from typing import Dict, Iterable, List, Sequence, Tuple
8
+
9
+ import numpy as np
10
+ import torch
11
+ import torch.nn as nn
12
+
13
+
14
+ # -----------------------------------------------------------------------------
15
+ # Regulatory prior
16
+ # -----------------------------------------------------------------------------
17
+ @dataclass
18
+ class TFRegulon:
19
+ tf: str
20
+ targets: np.ndarray # column indices in current expression matrix
21
+ weights: np.ndarray # signed CollecTRI edge weights
22
+ n_total_targets: int
23
+ n_present_targets: int
24
+ coverage: float
25
+ programs: Tuple[str, ...]
26
+ tf_weight: float
27
+ reproducibility: float
28
+ perturbed_cl13: bool
29
+
30
+
31
+ class RegulonScorer:
32
+ """Simple, dependency-light signed TF activity scorer.
33
+
34
+ Activity_r(cell) = sum_g w_rg * z_g(cell) / sqrt(sum_g w_rg^2)
35
+
36
+ where z_g is standardized using retained TRAINING cells only.
37
+
38
+ This transparent scoring method is the signed target-gene prior
39
+ used in the TRACE implementation; it is not TF transcript abundance.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ prior_csv: str,
45
+ var_names: Sequence[str],
46
+ min_targets: int = 5,
47
+ min_coverage: float = 0.15,
48
+ candidate_source: str = "taiji",
49
+ max_tfs: int = 0,
50
+ edge_weight_mode: str = "weight",
51
+ ):
52
+ self.prior_csv = prior_csv
53
+ self.var_names = np.asarray([str(x) for x in var_names])
54
+ self.lookup = {g.upper(): i for i, g in enumerate(self.var_names)}
55
+ self.min_targets = int(min_targets)
56
+ self.min_coverage = float(min_coverage)
57
+ self.candidate_source = candidate_source
58
+ self.max_tfs = int(max_tfs)
59
+ self.edge_weight_mode = str(edge_weight_mode).lower()
60
+ if self.edge_weight_mode not in {"weight", "sign", "sign_resources"}:
61
+ raise ValueError("edge_weight_mode must be one of: weight, sign, sign_resources")
62
+
63
+ rows = []
64
+ with open(prior_csv, newline="") as f:
65
+ reader = csv.DictReader(f)
66
+ required = {"program_id", "tf", "target_gene", "weight", "provenance"}
67
+ if not required.issubset(set(reader.fieldnames or [])):
68
+ raise ValueError(
69
+ f"{prior_csv} must contain at least {sorted(required)}; "
70
+ f"found {reader.fieldnames}"
71
+ )
72
+ for r in reader:
73
+ rows.append(r)
74
+ if not rows:
75
+ raise ValueError(f"No rows found in {prior_csv}")
76
+
77
+ # Candidate TF metadata comes from Taiji state-association rows. This
78
+ # selects TFs biologically linked to the T-cell programs without imposing
79
+ # a state order on the trajectory.
80
+ meta: Dict[str, dict] = {}
81
+ for r in rows:
82
+ if (r.get("provenance") or "").strip().lower() != "taiji_pagerank":
83
+ continue
84
+ tf = (r.get("tf") or "").strip()
85
+ if not tf:
86
+ continue
87
+ m = meta.setdefault(tf, {
88
+ "programs": set(), "tf_weight": [], "reproducibility": [], "perturbed": False
89
+ })
90
+ m["programs"].add((r.get("program_id") or "").strip())
91
+ try:
92
+ m["tf_weight"].append(abs(float(r.get("tf_weight") or 1.0)))
93
+ except Exception:
94
+ pass
95
+ try:
96
+ val = float(r.get("reproducibility") or "nan")
97
+ if np.isfinite(val):
98
+ m["reproducibility"].append(val)
99
+ except Exception:
100
+ pass
101
+ m["perturbed"] = m["perturbed"] or str(r.get("perturbed_cl13", "")).lower() in {"true", "1", "yes"}
102
+
103
+ # Collect signed TF-target edges. The source file repeats some edges in
104
+ # multiple program blocks; aggregate duplicates by mean edge weight so a
105
+ # TF-target relation is counted once.
106
+ edge_vals: Dict[Tuple[str, str], List[float]] = {}
107
+ total_targets: Dict[str, set] = {}
108
+ for r in rows:
109
+ if (r.get("provenance") or "").strip().lower() != "collectri":
110
+ continue
111
+ tf = (r.get("tf") or "").strip()
112
+ tg = (r.get("target_gene") or "").strip()
113
+ if not tf or not tg:
114
+ continue
115
+ if candidate_source == "taiji" and tf not in meta:
116
+ continue
117
+ try:
118
+ if self.edge_weight_mode == "weight":
119
+ # Legacy BioCHIME behavior. In the assembled T-cell prior this
120
+ # column may include state-specific scaling, so the paper
121
+ # configuration uses state-independent sign mode.
122
+ w = float(r.get("weight") or 0.0)
123
+ else:
124
+ sg = float(r.get("sign") or 0.0)
125
+ if sg == 0.0:
126
+ raw = float(r.get("weight") or 0.0)
127
+ sg = float(np.sign(raw))
128
+ if self.edge_weight_mode == "sign":
129
+ w = sg
130
+ else:
131
+ nr = max(float(r.get("n_resources") or 1.0), 1.0)
132
+ w = sg * np.sqrt(nr)
133
+ except Exception:
134
+ continue
135
+ if not np.isfinite(w) or abs(w) < 1e-15:
136
+ continue
137
+ edge_vals.setdefault((tf, tg), []).append(w)
138
+ total_targets.setdefault(tf, set()).add(tg)
139
+
140
+ # Rank candidates only if requested. We use reproducibility first, then
141
+ # Taiji weight. This ranking is based on external prior metadata, not on
142
+ # the held-out expression data.
143
+ all_tfs = sorted(total_targets)
144
+ if max_tfs > 0:
145
+ def rank_key(tf: str):
146
+ m = meta.get(tf, {})
147
+ rep = np.nanmean(m.get("reproducibility", [np.nan])) if m.get("reproducibility") else -np.inf
148
+ tw = np.nanmax(m.get("tf_weight", [0.0])) if m.get("tf_weight") else 0.0
149
+ if not np.isfinite(rep):
150
+ rep = -1e9
151
+ return (rep, tw)
152
+ all_tfs = sorted(all_tfs, key=rank_key, reverse=True)[:max_tfs]
153
+
154
+ regulons: List[TFRegulon] = []
155
+ for tf in all_tfs:
156
+ tg_all = sorted(total_targets[tf])
157
+ present_idx, present_w = [], []
158
+ for tg in tg_all:
159
+ j = self.lookup.get(tg.upper())
160
+ if j is None:
161
+ continue
162
+ vals = edge_vals.get((tf, tg), [])
163
+ if not vals:
164
+ continue
165
+ present_idx.append(j)
166
+ present_w.append(float(np.mean(vals)))
167
+ nt = len(tg_all)
168
+ npres = len(present_idx)
169
+ cov = npres / max(nt, 1)
170
+ if npres < min_targets or cov < min_coverage:
171
+ continue
172
+ m = meta.get(tf, {})
173
+ tw = float(np.nanmax(m.get("tf_weight", [1.0]))) if m.get("tf_weight") else 1.0
174
+ reps = m.get("reproducibility", [])
175
+ rep = float(np.nanmax(reps)) if reps else float("nan")
176
+ regulons.append(TFRegulon(
177
+ tf=tf,
178
+ targets=np.asarray(present_idx, dtype=int),
179
+ weights=np.asarray(present_w, dtype=np.float64),
180
+ n_total_targets=nt,
181
+ n_present_targets=npres,
182
+ coverage=cov,
183
+ programs=tuple(sorted(x for x in m.get("programs", set()) if x)),
184
+ tf_weight=tw,
185
+ reproducibility=rep,
186
+ perturbed_cl13=bool(m.get("perturbed", False)),
187
+ ))
188
+
189
+ if not regulons:
190
+ raise ValueError(
191
+ "No TF regulons passed coverage filters. Try --min_targets 3 "
192
+ "and/or --min_coverage 0.05, or rebuild the expression matrix "
193
+ "with more genes / forced inclusion of prior target genes."
194
+ )
195
+ self.regulons = regulons
196
+ self.tf_names = [r.tf for r in regulons]
197
+ self.gene_mu = None
198
+ self.gene_sd = None
199
+
200
+ def fit_gene_scaler(self, X_train: np.ndarray):
201
+ X_train = np.asarray(X_train, dtype=np.float64)
202
+ self.gene_mu = X_train.mean(0)
203
+ self.gene_sd = X_train.std(0)
204
+ self.gene_sd[self.gene_sd < 1e-8] = 1.0
205
+ return self
206
+
207
+ def score(self, X: np.ndarray) -> np.ndarray:
208
+ if self.gene_mu is None or self.gene_sd is None:
209
+ raise RuntimeError("Call fit_gene_scaler(X_train) before score(X)")
210
+ X = np.asarray(X, dtype=np.float64)
211
+ Zg = (X - self.gene_mu) / self.gene_sd
212
+ out = np.empty((len(X), len(self.regulons)), dtype=np.float32)
213
+ for j, r in enumerate(self.regulons):
214
+ w = r.weights
215
+ denom = np.sqrt(np.sum(w * w)) + 1e-12
216
+ out[:, j] = (Zg[:, r.targets] @ w / denom).astype(np.float32)
217
+ return out
218
+
219
+ def coverage_rows(self) -> List[dict]:
220
+ rows = []
221
+ for r in self.regulons:
222
+ rows.append({
223
+ "tf": r.tf,
224
+ "n_targets_total": r.n_total_targets,
225
+ "n_targets_present": r.n_present_targets,
226
+ "coverage": r.coverage,
227
+ "programs": ";".join(r.programs),
228
+ "tf_weight": r.tf_weight,
229
+ "reproducibility": r.reproducibility,
230
+ "perturbed_cl13": r.perturbed_cl13,
231
+ })
232
+ return rows
233
+
234
+
235
+ def standardize_activity(A_train: np.ndarray, A: np.ndarray | None = None):
236
+ A_train = np.asarray(A_train, dtype=np.float64)
237
+ mu = A_train.mean(0)
238
+ sd = A_train.std(0)
239
+ sd[sd < 1e-8] = 1.0
240
+ if A is None:
241
+ return ((A_train - mu) / sd).astype(np.float32), mu.astype(np.float32), sd.astype(np.float32)
242
+ return ((np.asarray(A) - mu) / sd).astype(np.float32), mu.astype(np.float32), sd.astype(np.float32)
243
+
244
+
245
+ def fit_ridge_map(A: np.ndarray, Z: np.ndarray, lam: float = 1.0) -> Tuple[np.ndarray, np.ndarray]:
246
+ """Fit Z ~= A @ W + b; derivatives map as dZ/dt ~= dA/dt @ W."""
247
+ A = np.asarray(A, dtype=np.float64)
248
+ Z = np.asarray(Z, dtype=np.float64)
249
+ am, zm = A.mean(0), Z.mean(0)
250
+ Ac, Zc = A - am, Z - zm
251
+ G = Ac.T @ Ac + float(lam) * np.eye(A.shape[1])
252
+ W = np.linalg.solve(G, Ac.T @ Zc)
253
+ b = zm - am @ W
254
+ return W.astype(np.float32), b.astype(np.float32)
255
+
256
+
257
+ # -----------------------------------------------------------------------------
258
+ #Hermite helpers
259
+ # -----------------------------------------------------------------------------
260
+ def pchip_slopes(x: np.ndarray, Y: np.ndarray) -> np.ndarray:
261
+ x = np.asarray(x, dtype=float)
262
+ K = len(x)
263
+ h = np.diff(x)
264
+ d = (Y[1:] - Y[:-1]) / h[:, None, None]
265
+ m = np.zeros_like(Y)
266
+ if K == 2:
267
+ m[0] = d[0]
268
+ m[1] = d[0]
269
+ return m
270
+ for k in range(1, K - 1):
271
+ d0, d1 = d[k - 1], d[k]
272
+ h0, h1 = h[k - 1], h[k]
273
+ w0, w1 = 2 * h1 + h0, h1 + 2 * h0
274
+ mono = (d0 * d1) > 0
275
+ with np.errstate(divide="ignore", invalid="ignore"):
276
+ mk = (w0 + w1) / (w0 / d0 + w1 / d1)
277
+ m[k] = np.where(mono, mk, 0.0)
278
+
279
+ def endpoint(h0, h1, d0, d1):
280
+ me = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1)
281
+ me = np.where(np.sign(me) != np.sign(d0), 0.0, me)
282
+ me = np.where((np.sign(d0) != np.sign(d1)) & (np.abs(me) > 3 * np.abs(d0)), 3 * d0, me)
283
+ return me
284
+
285
+ m[0] = endpoint(h[0], h[1], d[0], d[1])
286
+ m[-1] = endpoint(h[-1], h[-2], d[-1], d[-2])
287
+ return m
288
+
289
+
290
+ def project_shape_safe_slopes(x: np.ndarray, Y: np.ndarray, M: np.ndarray) -> np.ndarray:
291
+ """Conservative coordinate-wise projection that prevents PCHIP-like overshoot."""
292
+ x = np.asarray(x, dtype=float)
293
+ h = np.diff(x)
294
+ d = (Y[1:] - Y[:-1]) / h[:, None, None]
295
+ out = np.asarray(M).copy()
296
+ K = len(x)
297
+ if K == 2:
298
+ sec = d[0]
299
+ for k in [0, 1]:
300
+ ok = np.sign(out[k]) == np.sign(sec)
301
+ out[k] = np.where(ok, out[k], 0.0)
302
+ out[k] = np.sign(sec) * np.minimum(np.abs(out[k]), 3.0 * np.abs(sec))
303
+ return out
304
+
305
+ for k, sec in [(0, d[0]), (K - 1, d[-1])]:
306
+ ok = np.sign(out[k]) == np.sign(sec)
307
+ out[k] = np.where(ok, out[k], 0.0)
308
+ out[k] = np.sign(sec) * np.minimum(np.abs(out[k]), 3.0 * np.abs(sec))
309
+
310
+ for k in range(1, K - 1):
311
+ dl, dr = d[k - 1], d[k]
312
+ mono = (dl * dr) > 0
313
+ sgn = np.sign(dl)
314
+ cap = 3.0 * np.minimum(np.abs(dl), np.abs(dr))
315
+ cand = np.where(np.sign(out[k]) == sgn, out[k], 0.0)
316
+ cand = sgn * np.minimum(np.abs(cand), cap)
317
+ out[k] = np.where(mono, cand, 0.0)
318
+ return out
319
+
320
+
321
+ def hermite_eval(x: np.ndarray, Y: np.ndarray, M: np.ndarray, t: np.ndarray):
322
+ x = np.asarray(x, dtype=float)
323
+ t = np.asarray(t, dtype=float)
324
+ K, B, _ = Y.shape
325
+ idx = np.clip(np.searchsorted(x, t, side="right") - 1, 0, K - 2)
326
+ xi, xi1 = x[idx], x[idx + 1]
327
+ hi = xi1 - xi
328
+ s = ((t - xi) / hi)[:, None]
329
+ hb = hi[:, None]
330
+ ar = np.arange(B)
331
+ yi, yi1 = Y[idx, ar], Y[idx + 1, ar]
332
+ mi, mi1 = M[idx, ar], M[idx + 1, ar]
333
+ mu = ((2*s**3 - 3*s**2 + 1)*yi + (s**3 - 2*s**2 + s)*hb*mi
334
+ + (-2*s**3 + 3*s**2)*yi1 + (s**3 - s**2)*hb*mi1)
335
+ mup = ((6*s**2 - 6*s)*yi + (3*s**2 - 4*s + 1)*hb*mi
336
+ + (-6*s**2 + 6*s)*yi1 + (3*s**2 - 2*s)*hb*mi1) / hb
337
+ return mu, mup
338
+
339
+
340
+ def align_joint(
341
+ clouds_z: Sequence[np.ndarray],
342
+ clouds_a: Sequence[np.ndarray],
343
+ rng: np.random.Generator,
344
+ batch: int = 128,
345
+ ):
346
+ """Use the existing Euclidean OT plan, carrying TF activities with cells."""
347
+ B = min(batch, min(len(c) for c in clouds_z))
348
+ i0 = rng.choice(len(clouds_z[0]), B, replace=False)
349
+ z = [clouds_z[0][i0]]
350
+ a = [clouds_a[0][i0]]
351
+ for k in range(1, len(clouds_z)):
352
+ cand = rng.choice(len(clouds_z[k]), B, replace=False)
353
+ zc, ac = clouds_z[k][cand], clouds_a[k][cand]
354
+ import benchmark_table as BT
355
+ P = BT.ot_plan(z[-1], zc)
356
+ Pn = P / (P.sum(1, keepdims=True) + 1e-12)
357
+ jj = np.array([rng.choice(B, p=Pn[i]) for i in range(B)])
358
+ z.append(zc[jj])
359
+ a.append(ac[jj])
360
+ return np.stack(z, 0), np.stack(a, 0)
361
+
362
+
363
+ def _match_bio_vector_norm(m_geom: np.ndarray, m_bio: np.ndarray, clip=(0.25, 4.0)) -> np.ndarray:
364
+ """Make biology contribute direction rather than arbitrary numerical scale."""
365
+ ng = np.linalg.norm(m_geom, axis=-1, keepdims=True)
366
+ nb = np.linalg.norm(m_bio, axis=-1, keepdims=True)
367
+ scale = ng / (nb + 1e-12)
368
+ scale = np.clip(scale, clip[0], clip[1])
369
+ scale = np.where((ng > 1e-8) & (nb > 1e-8), scale, 0.0)
370
+ return m_bio * scale
371
+
372
+
373
+ @dataclass
374
+ class BioFitInfo:
375
+ rho: float
376
+ ridge_W: np.ndarray
377
+ ridge_b: np.ndarray
378
+
379
+
380
+ def fit_regulon_bio_flow(
381
+ clouds_z_by_day: Dict[float, np.ndarray],
382
+ clouds_a_by_day: Dict[float, np.ndarray],
383
+ dn: Sequence[float],
384
+ dev: str,
385
+ steps: int,
386
+ seed: int,
387
+ rho: float = 1.0,
388
+ batch: int = 128,
389
+ lr: float = 1e-3,
390
+ ridge: float = 1.0,
391
+ ridge_map: tuple[np.ndarray, np.ndarray] | None = None,
392
+ shape_project: bool = True,
393
+ log_every: int = 0,
394
+ ):
395
+ """Train TRACE with TF-regulon-guided Hermite knot tangents."""
396
+ if not (0.0 <= rho <= 1.0):
397
+ raise ValueError("rho must be in [0,1]")
398
+ torch.manual_seed(seed)
399
+ rng = np.random.default_rng(seed)
400
+ x = np.asarray(sorted(dn), dtype=float)
401
+ zclouds = [clouds_z_by_day[t] for t in x]
402
+ aclouds = [clouds_a_by_day[t] for t in x]
403
+ d = zclouds[0].shape[1]
404
+
405
+ Apool = np.vstack(aclouds)
406
+ Zpool = np.vstack(zclouds)
407
+ if ridge_map is None:
408
+ W, b = fit_ridge_map(Apool, Zpool, lam=ridge)
409
+ else:
410
+ # Negative controls such as TF-channel permutation must use the
411
+ # ridge map fitted on the TRUE TF identities. Re-fitting after a
412
+ # global column permutation would simply permute the rows of W and
413
+ # make the control algebraically equivalent to the unpermuted model.
414
+ W, b = ridge_map
415
+ W = np.asarray(W, dtype=np.float32)
416
+ b = np.asarray(b, dtype=np.float32)
417
+ if W.shape != (Apool.shape[1], Zpool.shape[1]):
418
+ raise ValueError(
419
+ f"ridge_map W has shape {W.shape}, expected "
420
+ f"{(Apool.shape[1], Zpool.shape[1])}"
421
+ )
422
+ if b.shape != (Zpool.shape[1],):
423
+ raise ValueError(
424
+ f"ridge_map b has shape {b.shape}, expected {(Zpool.shape[1],)}"
425
+ )
426
+
427
+ import benchmark_table as BT
428
+ field = BT.Field(d).to(dev)
429
+ opt = torch.optim.Adam(field.parameters(), lr=lr)
430
+ ni = len(x) - 1
431
+
432
+ for step in range(steps):
433
+ Z, A = align_joint(zclouds, aclouds, rng, batch=batch)
434
+ B = Z.shape[1]
435
+ per = B // ni
436
+ ts = []
437
+ for i in range(ni):
438
+ n = per + (1 if i < B - per * ni else 0)
439
+ ts.append(rng.uniform(x[i], x[i + 1], size=n))
440
+ tq = np.concatenate(ts)[:B]
441
+ rng.shuffle(tq)
442
+
443
+ m_geom = pchip_slopes(x, Z)
444
+ if rho > 0:
445
+ m_tf = pchip_slopes(x, A)
446
+ m_bio = np.einsum("kbr,rd->kbd", m_tf, W)
447
+ m_bio = _match_bio_vector_norm(m_geom, m_bio)
448
+ M = (1.0 - rho) * m_geom + rho * m_bio
449
+ if shape_project:
450
+ M = project_shape_safe_slopes(x, Z, M)
451
+ else:
452
+ M = m_geom
453
+
454
+ pos, vel = hermite_eval(x, Z, M, tq)
455
+ tt = torch.tensor(tq[:, None], dtype=torch.float32, device=dev)
456
+ pred = field(torch.tensor(pos, dtype=torch.float32, device=dev), tt)
457
+ target = torch.tensor(vel, dtype=torch.float32, device=dev)
458
+ loss = ((pred - target) ** 2).mean()
459
+ opt.zero_grad()
460
+ loss.backward()
461
+ nn.utils.clip_grad_norm_(field.parameters(), 1.0)
462
+ opt.step()
463
+ if log_every and (step + 1) % log_every == 0:
464
+ print(f"[BioCHIME rho={rho:g}] {step+1}/{steps} loss={float(loss):.6f}", flush=True)
465
+
466
+ field.eval()
467
+ return field, BioFitInfo(rho=rho, ridge_W=W, ridge_b=b)
468
+
469
+
470
+ def permute_within_day(clouds_a_by_day: Dict[float, np.ndarray], seed: int):
471
+ """Negative control preserving per-day TF distributions but breaking cell correspondence."""
472
+ rng = np.random.default_rng(seed)
473
+ return {t: A[rng.permutation(len(A))].copy() for t, A in clouds_a_by_day.items()}
474
+
475
+
476
+ def permute_tf_channels(clouds_a_by_day: Dict[float, np.ndarray], seed: int):
477
+ """Wrong-biology control: keep values but scramble TF identities globally."""
478
+ rng = np.random.default_rng(seed)
479
+ r = next(iter(clouds_a_by_day.values())).shape[1]
480
+ p = rng.permutation(r)
481
+ return {t: A[:, p].copy() for t, A in clouds_a_by_day.items()}
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ numpy>=1.24,<3
2
+ scipy>=1.11
3
+ scikit-learn>=1.3
4
+ POT>=0.9
5
+ torch>=2.0
run_trace_benchmark.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """TRACE: regulatory-guided cubic-Hermite temporal CFM benchmark.
3
+
4
+ Input NPZ must contain:
5
+ X cells x genes expression matrix
6
+ t biological time for each cell
7
+ var_names gene names aligned to X columns
8
+ used_scale (optional bool; if true, retained-only gene scaling is applied before PCA)
9
+
10
+ For each interior holdout h:
11
+ - exclude h from PCA/scalers/activity map/OT/path/flow training;
12
+ - source is the previous retained time;
13
+ - compare linear CFM, PCHIP cubic CFM, TRACE, and matched permutations;
14
+ - save held-out population metrics.
15
+
16
+ This script is dataset-generic: it does not hard-code Gupta days.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import csv
22
+ from collections import defaultdict
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ import torch
27
+ import ot
28
+ from sklearn.decomposition import PCA
29
+ from sklearn.model_selection import train_test_split
30
+
31
+ import benchmark_table as BT
32
+ from biochime_regulon_core import (
33
+ RegulonScorer,
34
+ fit_regulon_bio_flow,
35
+ fit_ridge_map,
36
+ permute_tf_channels,
37
+ permute_within_day,
38
+ )
39
+
40
+
41
+ def floats(s):
42
+ if s is None or not str(s).strip():
43
+ return []
44
+ return [float(x) for x in str(s).split(",") if x.strip()]
45
+
46
+
47
+ def sub(A, B, mx, seed):
48
+ rng = np.random.default_rng(seed)
49
+ if len(A) > mx:
50
+ A = A[rng.choice(len(A), mx, replace=False)]
51
+ if len(B) > mx:
52
+ B = B[rng.choice(len(B), mx, replace=False)]
53
+ return A, B
54
+
55
+
56
+ def w1(A, B, mx=1500, seed=0):
57
+ A, B = sub(A, B, mx, seed)
58
+ C = np.sqrt(((A[:, None, :] - B[None, :, :]) ** 2).sum(-1))
59
+ return float(max(ot.emd2(ot.unif(len(A)), ot.unif(len(B)), C, numItermax=2_000_000), 0.0))
60
+
61
+
62
+ def w2(A, B, mx=1500, seed=0):
63
+ A, B = sub(A, B, mx, seed)
64
+ C = ((A[:, None, :] - B[None, :, :]) ** 2).sum(-1)
65
+ return float(np.sqrt(max(ot.emd2(ot.unif(len(A)), ot.unif(len(B)), C, numItermax=2_000_000), 0.0)))
66
+
67
+
68
+ def mmd(A, B, mx=1000, seed=0):
69
+ A, B = sub(A, B, mx, seed)
70
+ Z = np.vstack([A, B])
71
+ D2 = ((Z[:, None, :] - Z[None, :, :]) ** 2).sum(-1)
72
+ med = np.median(D2[D2 > 0]) if np.any(D2 > 0) else 1.0
73
+ gamma = 1.0 / (med + 1e-9)
74
+ K = np.exp(-gamma * D2)
75
+ n = len(A)
76
+ return float(K[:n, :n].mean() + K[n:, n:].mean() - 2 * K[:n, n:].mean())
77
+
78
+
79
+ def mean_rmse(A, B):
80
+ return float(np.sqrt(np.mean((A.mean(0) - B.mean(0)) ** 2)))
81
+
82
+
83
+ def write_rows(path, rows):
84
+ if not rows:
85
+ return
86
+ with open(path, "w", newline="") as f:
87
+ w = csv.DictWriter(f, fieldnames=list(rows[0]))
88
+ w.writeheader(); w.writerows(rows)
89
+
90
+
91
+ def main():
92
+ ap = argparse.ArgumentParser()
93
+ ap.add_argument("--mat", required=True)
94
+ ap.add_argument("--prior", required=True)
95
+ ap.add_argument("--holdouts", default="", help="comma list; default=all interior timepoints")
96
+ ap.add_argument("--folds", type=int, default=5)
97
+ ap.add_argument("--steps", type=int, default=4000)
98
+ ap.add_argument("--ncomp", type=int, default=30)
99
+ ap.add_argument("--batch", type=int, default=128)
100
+ ap.add_argument("--ridge", type=float, default=1.0, help="ridge for TF activity -> latent state map")
101
+ ap.add_argument("--min_targets", type=int, default=5)
102
+ ap.add_argument("--min_coverage", type=float, default=0.15)
103
+ ap.add_argument("--max_tfs", type=int, default=0)
104
+ ap.add_argument("--candidate_source", choices=["taiji", "all"], default="all")
105
+ ap.add_argument("--edge_weight_mode", choices=["sign", "sign_resources", "weight"], default="sign",
106
+ help="Paper default: sign-only TF-target edges")
107
+ ap.add_argument("--rho", type=float, default=1.0)
108
+ ap.add_argument("--tag", default="trace")
109
+ args = ap.parse_args()
110
+ if not 0.0 <= args.rho <= 1.0:
111
+ ap.error("--rho must be between 0 and 1")
112
+ Path(args.tag).parent.mkdir(parents=True, exist_ok=True)
113
+
114
+ dev = "cuda" if torch.cuda.is_available() else "cpu"
115
+ M = np.load(args.mat, allow_pickle=True)
116
+ for k in ["X", "t", "var_names"]:
117
+ if k not in M:
118
+ raise ValueError(f"{args.mat} lacks required key '{k}'")
119
+
120
+ X = M["X"].astype(np.float64)
121
+ t = M["t"].astype(float)
122
+ var_names = np.asarray([str(x) for x in M["var_names"]])
123
+ use_scale = bool(M["used_scale"]) if "used_scale" in M else False
124
+ days = np.sort(np.unique(t))
125
+ if len(days) < 3:
126
+ raise ValueError(f"Need >=3 temporal marginals, found {list(days)}")
127
+
128
+ requested = floats(args.holdouts)
129
+ holdouts = requested if requested else [float(x) for x in days[1:-1]]
130
+ for h in holdouts:
131
+ if not np.any(np.isclose(days, h)) or np.isclose(h, days.min()) or np.isclose(h, days.max()):
132
+ raise ValueError(f"holdout {h} must be an interior observed time; days={list(days)}")
133
+
134
+ tn = {float(d): float((d - days.min()) / (days.max() - days.min())) for d in days}
135
+ print(
136
+ f"device={dev} X={X.shape} days={list(days)} holdouts={holdouts} "
137
+ f"folds={args.folds} steps={args.steps}", flush=True
138
+ )
139
+
140
+ records = []
141
+ coverage_written = False
142
+
143
+ for h in holdouts:
144
+ h = float(h)
145
+ kept = [float(d) for d in days if not np.isclose(d, h)]
146
+ lo = max(d for d in kept if d < h)
147
+ kept_idx = np.where(np.isin(np.round(t, 8), np.round(kept, 8)))[0]
148
+
149
+ # Retained-only expression scaling/PCA.
150
+ Xs = X.copy()
151
+ gm = np.zeros(X.shape[1], dtype=np.float64)
152
+ gs = np.ones(X.shape[1], dtype=np.float64)
153
+ if use_scale:
154
+ gm = X[kept_idx].mean(0)
155
+ gs = X[kept_idx].std(0)
156
+ gs[gs < 1e-8] = 1.0
157
+ Xs = np.clip((X - gm) / gs, -10.0, 10.0)
158
+
159
+ nc = min(args.ncomp, Xs.shape[1], len(kept_idx) - 1)
160
+ pca = PCA(n_components=nc, svd_solver="full").fit(Xs[kept_idx])
161
+ Zall = pca.transform(Xs).astype(np.float32)
162
+ byday_z = {float(d): Zall[np.isclose(t, d)] for d in days}
163
+ byday_x = {float(d): X[np.isclose(t, d)] for d in days}
164
+ target_z = byday_z[h]
165
+ source_z = byday_z[lo]
166
+
167
+ print(f"\n[holdout {h:g}] retained={kept} source={lo:g} target_n={len(target_z)}", flush=True)
168
+
169
+ for fold in range(args.folds):
170
+ tr_idx = {}
171
+ for d in kept:
172
+ idx = np.where(np.isclose(t, d))[0]
173
+ aa, _ = train_test_split(idx, test_size=0.2, random_state=fold, shuffle=True)
174
+ tr_idx[d] = aa
175
+
176
+ # Latent scaling on retained training cells only.
177
+ zpool = np.vstack([Zall[tr_idx[d]] for d in kept])
178
+ zmu, zsd = zpool.mean(0), zpool.std(0) + 1e-8
179
+ zscale = lambda U: ((U - zmu) / zsd).astype(np.float32)
180
+ zunscale = lambda U: U * zsd + zmu
181
+ cl_z = {tn[d]: zscale(Zall[tr_idx[d]]) for d in kept}
182
+
183
+ # TF activities; only the scaler is fitted from retained training cells.
184
+ scorer = RegulonScorer(
185
+ args.prior, var_names,
186
+ min_targets=args.min_targets,
187
+ min_coverage=args.min_coverage,
188
+ candidate_source=args.candidate_source,
189
+ max_tfs=args.max_tfs,
190
+ edge_weight_mode=args.edge_weight_mode,
191
+ )
192
+ xpool = np.vstack([X[tr_idx[d]] for d in kept])
193
+ scorer.fit_gene_scaler(xpool)
194
+ raw_a = {d: scorer.score(X[tr_idx[d]]) for d in kept}
195
+ apool = np.vstack([raw_a[d] for d in kept])
196
+ amu, asd = apool.mean(0), apool.std(0)
197
+ asd[asd < 1e-8] = 1.0
198
+ cl_a = {tn[d]: ((raw_a[d] - amu) / asd).astype(np.float32) for d in kept}
199
+
200
+ if not coverage_written:
201
+ covpath = f"{args.tag}_tf_coverage.csv"
202
+ write_rows(covpath, scorer.coverage_rows())
203
+ coverage_written = True
204
+ print(f"[TF prior] {len(scorer.tf_names)} regulons -> {covpath}")
205
+ print("[TFs] " + ", ".join(scorer.tf_names))
206
+
207
+ # Heldout TF activities are evaluation-only.
208
+ real_a = ((scorer.score(byday_x[h]) - amu) / asd).astype(np.float32)
209
+
210
+ map_times = sorted(cl_z)
211
+ Zmap = np.vstack([cl_z[q] for q in map_times])
212
+ Amap = np.vstack([cl_a[q] for q in map_times])
213
+ # TRACE maps activity derivatives to PCA space: Z ~= A@W+b.
214
+ # Freeze it on the TRUE activities so cell-/TF-perm controls change only the regulatory
215
+ # information, never a re-fitted map (a re-fit would undo a global TF permutation exactly).
216
+ W_bio_true, b_bio_true = fit_ridge_map(Amap, Zmap, lam=args.ridge)
217
+
218
+ # Same frozen maps for true and controls: only regulatory information is changed.
219
+ a_cellperm = permute_within_day(cl_a, 10000 + fold)
220
+ a_tfperm = permute_tf_channels(cl_a, 20000 + fold)
221
+
222
+ methods = [
223
+ ("Linear_CFM", "linear", None),
224
+ ("Cubic_CFM", "pchip", None),
225
+ ("TRACE", "trace", (cl_a, None)),
226
+ ("TRACE_cellperm", "trace", (a_cellperm, (W_bio_true, b_bio_true))),
227
+ ("TRACE_tfperm", "trace", (a_tfperm, (W_bio_true, b_bio_true))),
228
+ ]
229
+
230
+ for name, kind, payload in methods:
231
+ if kind in {"linear", "pchip"}:
232
+ field = BT.fit_flow(cl_z, sorted(cl_z), kind, dev, args.steps, fold, batch=args.batch)
233
+ else:
234
+ bio_clouds, bio_map = payload
235
+ field, _ = fit_regulon_bio_flow(
236
+ cl_z, bio_clouds, sorted(cl_z), dev, args.steps, fold,
237
+ rho=args.rho, batch=args.batch, ridge=args.ridge,
238
+ ridge_map=bio_map, shape_project=True,
239
+ )
240
+
241
+ pred_scaled = BT.ode(
242
+ field,
243
+ torch.tensor(zscale(source_z), dtype=torch.float32, device=dev),
244
+ tn[lo], tn[h], dev, N=50,
245
+ )
246
+ pred_z = zunscale(pred_scaled)
247
+
248
+ pred_xs = pca.inverse_transform(pred_z)
249
+ pred_x = pred_xs * gs + gm if use_scale else pred_xs
250
+ pred_a = ((scorer.score(pred_x) - amu) / asd).astype(np.float32)
251
+
252
+ rec = {
253
+ "holdout": h,
254
+ "fold": fold,
255
+ "method": name,
256
+ "W1": w1(pred_z, target_z, seed=fold),
257
+ "W2": w2(pred_z, target_z, seed=fold),
258
+ "MMD": mmd(pred_z, target_z, seed=fold),
259
+ "TF_W2": w2(pred_a, real_a, seed=fold),
260
+ "TF_mean_RMSE": mean_rmse(pred_a, real_a),
261
+ "n_tfs": len(scorer.tf_names),
262
+ }
263
+ records.append(rec)
264
+
265
+ print(
266
+ f" fold {fold} {name:24s} W2={rec['W2']:.3f} MMD={rec['MMD']:.4f}",
267
+ flush=True,
268
+ )
269
+
270
+ results_path = f"{args.tag}_results.csv"
271
+ write_rows(results_path, records)
272
+
273
+ groups = defaultdict(list)
274
+ for r in records:
275
+ groups[(r["holdout"], r["method"])].append(r)
276
+
277
+ summary = []
278
+ print("\n================ TRACE SUMMARY ================")
279
+ for h in holdouts:
280
+ print(f"\nHeld-out time {h:g}")
281
+ names = []
282
+ for r in records:
283
+ if np.isclose(r["holdout"], h) and r["method"] not in names:
284
+ names.append(r["method"])
285
+ for name in names:
286
+ rr = groups[(h, name)]
287
+ row = {"holdout": h, "method": name, "n_folds": len(rr)}
288
+ for k in ["W1", "W2", "MMD", "TF_W2", "TF_mean_RMSE"]:
289
+ v = np.asarray([q[k] for q in rr], dtype=float)
290
+ row[k + "_mean"] = float(v.mean())
291
+ row[k + "_sd"] = float(v.std())
292
+ summary.append(row)
293
+ print(
294
+ f"{name:24s} W2 {row['W2_mean']:.3f}±{row['W2_sd']:.3f} | "
295
+ f"MMD {row['MMD_mean']:.4f}±{row['MMD_sd']:.4f} | "
296
+ f"TF_W2 {row['TF_W2_mean']:.3f}±{row['TF_W2_sd']:.3f}"
297
+ )
298
+
299
+ summary_path = f"{args.tag}_summary.csv"
300
+ write_rows(summary_path, summary)
301
+ print("===================================================")
302
+ print(f"[saved] {results_path}")
303
+ print(f"[saved] {summary_path}")
304
+ print("[configuration] TRACE default: 4000 steps; rho=1.0; ridge=1.0.")
305
+
306
+
307
+ if __name__ == "__main__":
308
+ main()
tests/test_trace_core.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Numerical tests for the TRACE method-specific components (no training data needed)."""
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ from scipy.interpolate import PchipInterpolator
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10
+ from biochime_regulon_core import ( # noqa: E402
11
+ pchip_slopes, hermite_eval, project_shape_safe_slopes,
12
+ _match_bio_vector_norm, fit_ridge_map, permute_tf_channels,
13
+ )
14
+
15
+
16
+ class TestTRACECore(unittest.TestCase):
17
+ def test_pchip_matches_scipy(self):
18
+ rng = np.random.default_rng(17)
19
+ for k in (2, 3, 4):
20
+ x = np.cumsum(np.r_[0.0, rng.uniform(0.1, 0.7, k-1)])
21
+ y = rng.normal(size=(k, 15, 6))
22
+ m = pchip_slopes(x, y)
23
+ ref = PchipInterpolator(x, y, axis=0)
24
+ np.testing.assert_allclose(m, ref.derivative()(x), atol=1e-10)
25
+ t = rng.uniform(x[0], x[-1], len(y[0]))
26
+ pos, vel = hermite_eval(x, y, m, t)
27
+ np.testing.assert_allclose(pos, np.stack([ref(tt)[i] for i, tt in enumerate(t)]), atol=1e-10)
28
+ np.testing.assert_allclose(vel, np.stack([ref.derivative()(tt)[i] for i, tt in enumerate(t)]), atol=1e-10)
29
+
30
+ def test_projected_slope_preserves_monotone_intervals(self):
31
+ x = np.array([0.0, 0.4, 1.0])
32
+ z = np.array([[[0., 0.]], [[1., 2.]], [[2., 3.]]])
33
+ raw = np.array([[[20., 20.]], [[-50., 10.]], [[20., -15.]]])
34
+ m = project_shape_safe_slopes(x, z, raw)
35
+ for lo, hi in [(0., 0.4), (0.4, 1.0)]:
36
+ t = np.linspace(lo, hi, 29)
37
+ # evaluate one query time for one trajectory at a time
38
+ for tt in t:
39
+ pos, _ = hermite_eval(x, z, m, np.array([tt]))
40
+ self.assertTrue(np.all(pos[0] >= z[0, 0] - 1e-9))
41
+ self.assertTrue(np.all(pos[0] <= z[-1, 0] + 1e-9))
42
+
43
+ def test_ridge_activity_to_state_direction(self):
44
+ rng = np.random.default_rng(23)
45
+ a = rng.normal(size=(200, 7))
46
+ w = rng.normal(size=(7, 4))
47
+ b = rng.normal(size=4)
48
+ z = a @ w + b
49
+ fitted, intercept = fit_ridge_map(a, z, lam=0.0)
50
+ np.testing.assert_allclose(fitted, w, atol=1e-5)
51
+ np.testing.assert_allclose(intercept, b, atol=1e-5)
52
+
53
+ def test_norm_clipping_and_permutation(self):
54
+ m_geom = np.ones((3, 12, 5), dtype=float)
55
+ m_bio = np.ones_like(m_geom) * 100.0
56
+ matched = _match_bio_vector_norm(m_geom, m_bio)
57
+ np.testing.assert_allclose(matched, m_bio * 0.25)
58
+ a = {0.0: np.arange(18).reshape(6, 3), 1.0: np.arange(18).reshape(6, 3) + 1}
59
+ perm = permute_tf_channels(a, seed=4)
60
+ self.assertEqual(perm[0.0].shape, a[0.0].shape)
61
+ self.assertEqual(sorted(perm[0.0].T.tolist()), sorted(a[0.0].T.tolist()))
62
+
63
+
64
+ if __name__ == '__main__':
65
+ unittest.main()