Initial TRACE implementation
Browse files- .gitignore +13 -0
- README.md +68 -6
- benchmark_table.py +1487 -0
- biochime_regulon_core.py +481 -0
- requirements.txt +5 -0
- run_trace_benchmark.py +308 -0
- tests/test_trace_core.py +65 -0
.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 |
-
**
|
| 8 |
|
| 9 |
-
TRACE
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
-
##
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|