naidooreed commited on
Commit
fdb5676
Β·
verified Β·
1 Parent(s): 3e0e4b4

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - biology
5
+ - genomics
6
+ - diffusion
7
+ - single-cell
8
+ - transcriptomics
9
+ - microscopy
10
+ - image-to-rna
11
+ - conditional-generation
12
+ - scgpt
13
+ - vit
14
+ datasets:
15
+ - altoslabs/scGeneScope
16
+ pipeline_tag: feature-extraction
17
+ model-index:
18
+ - name: PhenoSeq
19
+ results:
20
+ - task:
21
+ type: feature-extraction
22
+ name: Image-conditioned RNA-seq generation
23
+ metrics:
24
+ - type: loss
25
+ value: 0.1683
26
+ name: Validation MSE Loss (epoch 87)
27
+ ---
28
+
29
+ # PhenoSeq: Image-Conditioned Diffusion for Single-Cell Transcriptomics
30
+
31
+ [![HuggingFace](https://img.shields.io/badge/πŸ€—%20HuggingFace-Sentinal4D/PhenoSeq-yellow)](https://huggingface.co/Sentinal4D/PhenoSeq)
32
+
33
+ PhenoSeq is a Gaussian diffusion model that **generates scGPT RNA-seq embeddings conditioned on ViT-L microscopy imaging features**. Given fluorescence microscopy images of a cell or well, it predicts a 512-dimensional scGPT embedding representing the transcriptomic state of individual cells β€” enabling image-to-transcriptome translation at single-cell resolution.
34
+
35
+ ## Model summary
36
+
37
+ | Property | Value |
38
+ |---|---|
39
+ | Input | ViT-L imaging features β€” `(N, 5120)` per sample (5 fluorescence channels Γ— 1,024 dims) |
40
+ | Output | scGPT embeddings β€” `(n_cells, 512)` |
41
+ | Architecture | Cross-attention diffusion denoiser |
42
+ | Diffusion steps | 1,000 (cosine schedule) |
43
+ | Inference steps | 50 (DDIM, default) |
44
+ | Model parameters | ~168 M |
45
+ | Training dataset | [scGeneScope](https://huggingface.co/datasets/altoslabs/scGeneScope) |
46
+ | Best val MSE loss | 0.1683 (epoch 87) |
47
+
48
+ ## Architecture
49
+
50
+ The denoiser uses a **cross-attention transformer** stack:
51
+
52
+ 1. **Imaging encoder** β€” 2-layer self-attention transformer projects `(B, N, 5120)` β†’ `(B, N, 1024)` context.
53
+ 2. **RNA + time input** β€” noisy scGPT embedding and sinusoidal time embedding are projected to `model_dim`.
54
+ 3. **Cross-attention blocks** (Γ—6) β€” RNA queries attend to imaging context, with self-attention and adaptive layer normalization conditioned on the timestep.
55
+ 4. **Output projection** β€” predicts noise `Ξ΅ ∈ ℝ^{512}` for the denoising objective.
56
+
57
+ The diffusion process uses a cosine beta schedule over T=1,000 steps with EMA weight averaging (decay 0.9999).
58
+
59
+ ## Quick start
60
+
61
+ ```bash
62
+ pip install torch numpy huggingface_hub
63
+ python example.py
64
+ ```
65
+
66
+ ```python
67
+ import numpy as np
68
+ from pipeline import PhenoSeqPipeline
69
+
70
+ pipe = PhenoSeqPipeline.from_pretrained("Sentinal4D/PhenoSeq")
71
+
72
+ # img_features: ViT-L embeddings β€” (n_cells, n_imaging_cells=16, 5120)
73
+ img_features = np.random.randn(8, 16, 5120).astype(np.float32)
74
+ rna_predictions = pipe(img_features) # β†’ (8, 512)
75
+ ```
76
+
77
+ ## Inputs and outputs
78
+
79
+ ### Input: imaging features
80
+
81
+ `img_features` must be ViT-L image embeddings extracted from **5 fluorescence channels** using a ViT-L/14 backbone, resulting in 5 Γ— 1,024 = 5,120 dimensions per imaging cell. Each sample/well typically provides **N = 16** imaging cells (evenly spaced from the available pool) that form the conditioning context.
82
+
83
+ Shape: `(B, N, 5120)` where `B` is the number of target RNA cells to predict.
84
+
85
+ > **Imaging normalisation** β€” `img_norm.npz` contains the per-feature mean and std computed from the training split of scGeneScope. These are applied automatically by `PhenoSeqPipeline`. If you work with a different dataset you will need to recompute and supply your own normalisation stats.
86
+
87
+ ### Output: RNA embeddings
88
+
89
+ Shape: `(B, 512)` β€” scGPT-space embeddings un-normalized back to the original scGPT embedding scale. These can be used directly for downstream tasks such as cell-type classification (see `classify_improved.py`), clustering, or trajectory inference.
90
+
91
+ ## Imaging normalisation stats
92
+
93
+ The pipeline requires `img_norm.npz` (per-feature mean and std from the training split). This file is distributed alongside `best_model.pt` in this repo. If you retrain or use different data, regenerate it:
94
+
95
+ ```bash
96
+ python save_img_norm.py --config config.yaml --output img_norm.npz
97
+ ```
98
+
99
+ ## Full inference on scGeneScope data
100
+
101
+ For large-scale inference over the cached scGeneScope data (`.npz` per sample):
102
+
103
+ ```bash
104
+ # Fast (DDIM, 50 steps)
105
+ python infer.py --checkpoint best_model.pt --ddim_steps 50
106
+
107
+ # Val split only
108
+ python infer.py --checkpoint best_model.pt --split val --output_dir results/predictions
109
+
110
+ # Full DDPM sampling (slower, ~1000 steps)
111
+ python infer.py --checkpoint best_model.pt --ddim_steps 0
112
+ ```
113
+
114
+ Output: one `{Sample_ID}.npz` per sample under `results/predictions/`, with key `X` of shape `(n_cells, 512)`.
115
+
116
+ ## Downstream: cell-type classification
117
+
118
+ Predicted RNA embeddings can be evaluated with the included classifier:
119
+
120
+ ```bash
121
+ python classify_improved.py
122
+ ```
123
+
124
+ See [PERFORMANCE_ANALYSIS.md](PERFORMANCE_ANALYSIS.md) for benchmark results.
125
+
126
+ ## Training
127
+
128
+ The model was trained from scratch on scGeneScope using:
129
+
130
+ ```bash
131
+ python train.py --config config.yaml
132
+ ```
133
+
134
+ Key hyperparameters (see [config.yaml](config.yaml)):
135
+
136
+ | Hyperparameter | Value |
137
+ |---|---|
138
+ | Batch size | 256 |
139
+ | Learning rate | 1e-4 (cosine with warmup) |
140
+ | Epochs | 5,000 (best at epoch 87) |
141
+ | Model dim | 1,024 |
142
+ | Attention heads | 8 |
143
+ | Cross-attention layers | 6 |
144
+ | Diffusion steps | 1,000 |
145
+ | Schedule | Cosine |
146
+ | EMA decay | 0.9999 |
147
+
148
+ ## Data
149
+
150
+ Training and evaluation data come from the [scGeneScope](https://huggingface.co/datasets/altoslabs/scGeneScope) dataset (Altos Labs):
151
+
152
+ - **Imaging features**: ViT-L/14 embeddings extracted from 5-channel fluorescence microscopy images, stored as `.h5ad` files.
153
+ - **RNA-seq features**: scGPT cell embeddings (512-dim) from paired single-cell RNA-seq, stored as `.h5ad` files.
154
+ - Samples are matched by `Sample_ID` at well level.
155
+
156
+ Prepare the local cache before training:
157
+
158
+ ```bash
159
+ python prepare_data.py
160
+ ```
161
+
162
+ ## Repository structure
163
+
164
+ ```
165
+ PhenoSeq/
166
+ β”œβ”€β”€ pipeline.py ← self-contained inference pipeline (start here)
167
+ β”œβ”€β”€ best_model.pt ← trained checkpoint with EMA weights & RNA norm stats
168
+ β”œβ”€β”€ img_norm.npz ← imaging normalisation stats (mean/std, training split)
169
+ β”œβ”€β”€ config.yaml ← full training configuration
170
+ β”œβ”€β”€ infer.py ← batch inference over cached scGeneScope data
171
+ β”œβ”€β”€ save_img_norm.py ← helper to (re)generate img_norm.npz
172
+ β”œβ”€β”€ train.py ← training entry point
173
+ β”œβ”€β”€ prepare_data.py ← extract imaging/RNA features β†’ .npz cache
174
+ β”œβ”€β”€ models/
175
+ β”‚ β”œβ”€β”€ denoiser.py ← cross-attention denoiser
176
+ β”‚ β”œβ”€β”€ diffusion.py ← Gaussian diffusion process (forward + reverse)
177
+ β”‚ └── lit_module.py ← PyTorch Lightning training wrapper
178
+ └── data/
179
+ └── dataset.py ← paired imaging-RNA dataset & dataloader
180
+ ```
181
+
182
+ ## Requirements
183
+
184
+ ```
185
+ torch>=2.0
186
+ anndata>=0.10
187
+ numpy>=1.24
188
+ scipy>=1.10
189
+ PyYAML>=6.0
190
+ tqdm>=4.65
191
+ # Optional: huggingface_hub (for from_pretrained with a Hub repo id)
192
+ ```
193
+
194
+ Install:
195
+ ```bash
196
+ pip install -r requirements.txt
197
+ ```
198
+
199
+ ## License
200
+
201
+ Apache 2.0 β€” see [LICENSE](LICENSE).
202
+
203
+ ## Citation
204
+
205
+ If you use this model, please cite the scGeneScope dataset:
206
+
207
+ ```bibtex
208
+ @dataset{scgenescope,
209
+ author = {Altos Labs},
210
+ title = {scGeneScope},
211
+ year = {2024},
212
+ publisher = {HuggingFace},
213
+ url = {https://huggingface.co/datasets/altoslabs/scGeneScope}
214
+ }
215
+ ```
best_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a4031b1d130e05558cc60027fc05ff5538162f2e12e62eeafa19565e213c8d50
3
+ size 2696336068
example.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PhenoSeq β€” minimal inference example.
3
+
4
+ Downloads the pretrained model from HuggingFace and generates
5
+ scGPT RNA-seq embeddings from synthetic ViT-L imaging features.
6
+
7
+ Usage:
8
+ pip install torch numpy huggingface_hub
9
+ python example.py
10
+ """
11
+
12
+ import numpy as np
13
+ from pipeline import PhenoSeqPipeline
14
+
15
+ # ── Load model from the Hub ────────────────────────────────────────────────────
16
+ pipe = PhenoSeqPipeline.from_pretrained("Sentinal4D/PhenoSeq")
17
+ print(pipe)
18
+
19
+ # ── Prepare imaging features ───────────────────────────────────────────────────
20
+ # Real use: extract ViT-L/14 embeddings from 5-channel fluorescence microscopy.
21
+ # Shape: (n_cells, n_imaging_cells, 5120)
22
+ # n_cells β€” number of single cells to predict RNA for
23
+ # n_imaging_cells β€” imaging cells sampled per well (16 during training)
24
+ # 5120 β€” 5 fluorescence channels Γ— 1024 ViT-L dims
25
+ n_cells = 8
26
+ n_imaging_cells = 16
27
+ img_features = np.random.randn(n_cells, n_imaging_cells, 5120).astype(np.float32)
28
+
29
+ # ── Run inference ──────────────────────────────────────────────────────────────
30
+ # Returns scGPT-space embeddings in the original (denormalized) scale.
31
+ # DDIM with 50 steps by default; pass ddim_steps=0 for full 1000-step DDPM.
32
+ rna_predictions = pipe(img_features)
33
+
34
+ print(f"\nInput imaging features : {img_features.shape}") # (8, 16, 5120)
35
+ print(f"Output RNA embeddings : {rna_predictions.shape}") # (8, 512)
36
+ print(f"Output range : [{rna_predictions.min():.3f}, {rna_predictions.max():.3f}]")
img_norm.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5767ce8ad1c472ef38e478ebb3beb2f9b21ac4e36f64d044e22e5688757b06ad
3
+ size 37563
models/__init__.py ADDED
File without changes
models/denoiser.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cross-Attention Denoiser for Image-conditioned RNA-seq Diffusion.
3
+
4
+ The denoiser takes:
5
+ - noisy RNA-seq embeddings (scGPT, 512-dim)
6
+ - imaging features (ViT-L, 5120-dim) as conditioning context
7
+ - diffusion timestep
8
+
9
+ And predicts the noise Ξ΅ added to the RNA-seq embeddings.
10
+
11
+ Architecture:
12
+ 1. Project imaging features β†’ model_dim, apply self-attention to create a
13
+ rich context representation.
14
+ 2. Embed noisy RNA + sinusoidal time embedding β†’ model_dim.
15
+ 3. Multiple cross-attention transformer blocks where RNA queries attend to
16
+ imaging context.
17
+ 4. Project back to RNA embedding space and predict noise.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import torch
23
+ import torch.nn as nn
24
+ import torch.nn.functional as F
25
+
26
+ from models.model_utils import (
27
+ SinusoidalTimeEmbedding,
28
+ Mish,
29
+ FeedForward,
30
+ AdaLayerNorm,
31
+ )
32
+
33
+
34
+ # ──────────────────────────────────────────────────────────────────────────────
35
+ # Cross-Attention Block
36
+ # ──────────────────────────────────────────────────────────────────────────────
37
+
38
+ class CrossAttentionBlock(nn.Module):
39
+ """
40
+ Single cross-attention layer: RNA query attends to imaging context.
41
+ Uses adaptive layer norm for time-conditioning and pre-norm residual style.
42
+ """
43
+
44
+ def __init__(self, dim: int, num_heads: int, time_dim: int, ff_mult: int = 4, dropout: float = 0.1):
45
+ super().__init__()
46
+
47
+ # Time-conditioned norms
48
+ self.norm_rna = AdaLayerNorm(dim, time_dim)
49
+ self.norm_ctx = nn.LayerNorm(dim)
50
+ self.norm_ff = AdaLayerNorm(dim, time_dim)
51
+
52
+ # Cross-attention: Q from RNA, K/V from imaging
53
+ self.cross_attn = nn.MultiheadAttention(
54
+ embed_dim=dim,
55
+ num_heads=num_heads,
56
+ dropout=dropout,
57
+ batch_first=True,
58
+ )
59
+
60
+ # Self-attention on RNA after cross-attention
61
+ self.self_attn_norm = AdaLayerNorm(dim, time_dim)
62
+ self.self_attn = nn.MultiheadAttention(
63
+ embed_dim=dim,
64
+ num_heads=num_heads,
65
+ dropout=dropout,
66
+ batch_first=True,
67
+ )
68
+
69
+ # Feedforward
70
+ self.ff = FeedForward(dim, mult=ff_mult, dropout=dropout)
71
+
72
+ def forward(
73
+ self,
74
+ rna: torch.Tensor, # (B, 1, D)
75
+ context: torch.Tensor, # (B, S, D) imaging context
76
+ time_emb: torch.Tensor, # (B, time_dim)
77
+ ) -> torch.Tensor:
78
+ # Cross-attention
79
+ rna_normed = self.norm_rna(rna, time_emb)
80
+ ctx_normed = self.norm_ctx(context)
81
+ rna = rna + self.cross_attn(rna_normed, ctx_normed, ctx_normed, need_weights=False)[0]
82
+
83
+ # Self-attention (useful when we have multiple RNA tokens, but also adds
84
+ # a residual self-refinement step)
85
+ rna_normed = self.self_attn_norm(rna, time_emb)
86
+ rna = rna + self.self_attn(rna_normed, rna_normed, rna_normed, need_weights=False)[0]
87
+
88
+ # Feedforward
89
+ rna = rna + self.ff(self.norm_ff(rna, time_emb))
90
+
91
+ return rna
92
+
93
+
94
+ # ──────────────────────────────────────────────────────────────────────────────
95
+ # Imaging Context Encoder
96
+ # ──────────────────────────────────────────────────────────────────────────────
97
+
98
+ class ImagingEncoder(nn.Module):
99
+ """
100
+ Encodes a set of imaging cell features into context representations.
101
+ Uses a small self-attention stack to let imaging cells attend to each other
102
+ before being used as cross-attention context.
103
+ """
104
+
105
+ def __init__(self, img_dim: int, model_dim: int, num_heads: int = 4, num_layers: int = 2, dropout: float = 0.1):
106
+ super().__init__()
107
+
108
+ self.proj = nn.Sequential(
109
+ nn.Linear(img_dim, model_dim),
110
+ Mish(),
111
+ nn.LayerNorm(model_dim),
112
+ nn.Dropout(dropout),
113
+ )
114
+
115
+ encoder_layer = nn.TransformerEncoderLayer(
116
+ d_model=model_dim,
117
+ nhead=num_heads,
118
+ dim_feedforward=model_dim * 4,
119
+ dropout=dropout,
120
+ activation="gelu",
121
+ batch_first=True,
122
+ norm_first=True,
123
+ )
124
+ self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
125
+
126
+ def forward(self, img_features: torch.Tensor) -> torch.Tensor:
127
+ """
128
+ Args:
129
+ img_features: (B, N, img_dim)
130
+ Returns:
131
+ (B, N, model_dim) context representations
132
+ """
133
+ x = self.proj(img_features)
134
+ return self.encoder(x)
135
+
136
+
137
+ # ──────────────────────────────────────────────────────────────────────────────
138
+ # Full Denoiser
139
+ # ──────────────────────────────────────────────────────────────────────────────
140
+
141
+ class Img2RNADenoiser(nn.Module):
142
+ """
143
+ Noise prediction network for image-conditioned RNA-seq diffusion.
144
+
145
+ Args:
146
+ img_dim: input imaging feature dimension (5120)
147
+ rna_dim: RNA-seq embedding dimension (512)
148
+ model_dim: internal model dimension (1024)
149
+ num_heads: number of attention heads (8)
150
+ num_layers: number of cross-attention blocks (6)
151
+ time_dim: time embedding dimension (256)
152
+ ff_mult: feedforward multiplier (4)
153
+ dropout: dropout rate (0.1)
154
+ """
155
+
156
+ def __init__(
157
+ self,
158
+ img_dim: int = 5120,
159
+ rna_dim: int = 512,
160
+ model_dim: int = 1024,
161
+ num_heads: int = 8,
162
+ num_layers: int = 6,
163
+ time_dim: int = 256,
164
+ ff_mult: int = 4,
165
+ dropout: float = 0.1,
166
+ ):
167
+ super().__init__()
168
+
169
+ self.model_dim = model_dim
170
+ self.rna_dim = rna_dim
171
+
172
+ # ── Time embedding ────────────────────────────────────────────────
173
+ self.time_embed = nn.Sequential(
174
+ SinusoidalTimeEmbedding(time_dim),
175
+ nn.Linear(time_dim, time_dim * 4),
176
+ Mish(),
177
+ nn.Linear(time_dim * 4, time_dim),
178
+ )
179
+
180
+ # ── Imaging encoder ───────────────────────────────────────────────
181
+ self.img_encoder = ImagingEncoder(
182
+ img_dim=img_dim,
183
+ model_dim=model_dim,
184
+ num_heads=min(num_heads, 4),
185
+ num_layers=2,
186
+ dropout=dropout,
187
+ )
188
+
189
+ # ── RNA input projection ──────────────────────────────────────────
190
+ self.rna_proj = nn.Sequential(
191
+ nn.Linear(rna_dim, model_dim),
192
+ Mish(),
193
+ nn.LayerNorm(model_dim),
194
+ )
195
+
196
+ # ── Cross-attention transformer stack ─────────────────────────────
197
+ self.layers = nn.ModuleList([
198
+ CrossAttentionBlock(
199
+ dim=model_dim,
200
+ num_heads=num_heads,
201
+ time_dim=time_dim,
202
+ ff_mult=ff_mult,
203
+ dropout=dropout,
204
+ )
205
+ for _ in range(num_layers)
206
+ ])
207
+
208
+ # ── Output projection: predict noise in RNA embedding space ──────
209
+ self.out_norm = nn.LayerNorm(model_dim)
210
+ self.out_proj = nn.Sequential(
211
+ nn.Linear(model_dim, model_dim),
212
+ Mish(),
213
+ nn.Linear(model_dim, rna_dim),
214
+ )
215
+
216
+ self._init_weights()
217
+
218
+ def _init_weights(self):
219
+ """
220
+ Initialize weights for stable diffusion training.
221
+
222
+ Uses PyTorch defaults (kaiming) for all layers, with a small-scale
223
+ init on the very last linear so the model starts by predicting
224
+ near-zero noise while still allowing gradient flow.
225
+ """
226
+ # Small (not zero) init for the final output linear
227
+ nn.init.normal_(self.out_proj[2].weight, std=1e-4)
228
+ nn.init.zeros_(self.out_proj[2].bias)
229
+
230
+ def forward(
231
+ self,
232
+ noisy_rna: torch.Tensor, # (B, rna_dim) β€” noisy scGPT embedding
233
+ img_features: torch.Tensor, # (B, N, img_dim) β€” imaging features
234
+ timestep: torch.Tensor, # (B,) β€” diffusion timestep
235
+ ) -> torch.Tensor:
236
+ """
237
+ Predict the noise component in the noisy RNA embedding.
238
+
239
+ Returns:
240
+ predicted_noise: (B, rna_dim)
241
+ """
242
+ # Time embedding
243
+ t_emb = self.time_embed(timestep) # (B, time_dim)
244
+
245
+ # Encode imaging context
246
+ context = self.img_encoder(img_features) # (B, N, model_dim)
247
+
248
+ # Project noisy RNA and add as a single token
249
+ rna = self.rna_proj(noisy_rna).unsqueeze(1) # (B, 1, model_dim)
250
+
251
+ # Cross-attention layers
252
+ for layer in self.layers:
253
+ rna = layer(rna, context, t_emb)
254
+
255
+ # Output projection
256
+ rna = self.out_norm(rna.squeeze(1)) # (B, model_dim)
257
+ noise_pred = self.out_proj(rna) # (B, rna_dim)
258
+
259
+ return noise_pred
260
+
261
+ @torch.no_grad()
262
+ def count_parameters(self) -> dict:
263
+ """Count trainable and total parameters."""
264
+ total = sum(p.numel() for p in self.parameters())
265
+ trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)
266
+ return {"total": total, "trainable": trainable}
models/diffusion.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gaussian Diffusion for Image-conditioned RNA-seq Generation.
3
+
4
+ Implements the forward (noise) and reverse (denoise) diffusion processes
5
+ with support for linear and cosine beta schedules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ import numpy as np
13
+ import torch
14
+ import torch.nn as nn
15
+ import torch.nn.functional as F
16
+
17
+
18
+ # ──────────────────────────────────────────────────────────────────────────────
19
+ # Beta schedules
20
+ # ──────────────────────────────────────────────────────────────────────────────
21
+
22
+ def linear_beta_schedule(num_steps: int, beta_start: float = 1e-4, beta_end: float = 0.02) -> torch.Tensor:
23
+ return torch.linspace(beta_start, beta_end, num_steps, dtype=torch.float64)
24
+
25
+
26
+ def cosine_beta_schedule(num_steps: int, s: float = 0.008) -> torch.Tensor:
27
+ """
28
+ Cosine schedule as proposed in "Improved Denoising Diffusion Probabilistic Models"
29
+ (Nichol & Dhariwal, 2021).
30
+ """
31
+ steps = torch.arange(num_steps + 1, dtype=torch.float64)
32
+ f_t = torch.cos(((steps / num_steps) + s) / (1 + s) * (np.pi / 2)) ** 2
33
+ alphas_cumprod = f_t / f_t[0]
34
+ betas = 1 - (alphas_cumprod[1:] / alphas_cumprod[:-1])
35
+ return torch.clamp(betas, min=1e-5, max=0.999)
36
+
37
+
38
+ def get_beta_schedule(schedule: str, num_steps: int, **kwargs) -> torch.Tensor:
39
+ if schedule == "linear":
40
+ return linear_beta_schedule(num_steps, **kwargs)
41
+ elif schedule == "cosine":
42
+ return cosine_beta_schedule(num_steps)
43
+ else:
44
+ raise ValueError(f"Unknown schedule: {schedule}")
45
+
46
+
47
+ # ──────────────────────────────────────────────────────────────────────────────
48
+ # Gaussian Diffusion
49
+ # ──────────────────────────────────────────────────────────────────────────────
50
+
51
+ class GaussianDiffusion(nn.Module):
52
+ """
53
+ Gaussian diffusion process for generating RNA-seq embeddings
54
+ conditioned on imaging features.
55
+
56
+ The model learns to predict the noise Ξ΅ given:
57
+ - noisy RNA embedding x_t
58
+ - conditioning imaging features
59
+ - timestep t
60
+
61
+ Training loss:
62
+ L = E[||Ξ΅ - Ξ΅_ΞΈ(x_t, img, t)||Β²]
63
+
64
+ Args:
65
+ denoiser: noise prediction network (Img2RNADenoiser)
66
+ num_steps: number of diffusion steps T
67
+ schedule: beta schedule type ("linear" or "cosine")
68
+ beta_start: start of linear schedule
69
+ beta_end: end of linear schedule
70
+ loss_type: "l2", "l1", or "huber"
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ denoiser: nn.Module,
76
+ num_steps: int = 1000,
77
+ schedule: str = "cosine",
78
+ beta_start: float = 1e-4,
79
+ beta_end: float = 0.02,
80
+ loss_type: str = "l2",
81
+ rna_norm: Optional[dict] = None,
82
+ ):
83
+ super().__init__()
84
+
85
+ self.denoiser = denoiser
86
+ self.num_steps = num_steps
87
+ self.loss_type = loss_type
88
+
89
+ # Compute noise schedule
90
+ betas = get_beta_schedule(
91
+ schedule, num_steps,
92
+ beta_start=beta_start, beta_end=beta_end,
93
+ )
94
+
95
+ alphas = 1.0 - betas
96
+ alphas_cumprod = torch.cumprod(alphas, dim=0)
97
+ alphas_cumprod_prev = F.pad(alphas_cumprod[:-1], (1, 0), value=1.0)
98
+
99
+ # Register as buffers (moved to device automatically)
100
+ self.register_buffer("betas", betas.float())
101
+ self.register_buffer("alphas", alphas.float())
102
+ self.register_buffer("alphas_cumprod", alphas_cumprod.float())
103
+ self.register_buffer("alphas_cumprod_prev", alphas_cumprod_prev.float())
104
+
105
+ # Pre-compute useful quantities
106
+ self.register_buffer("sqrt_alphas_cumprod", torch.sqrt(alphas_cumprod).float())
107
+ self.register_buffer("sqrt_one_minus_alphas_cumprod", torch.sqrt(1.0 - alphas_cumprod).float())
108
+ self.register_buffer("sqrt_recip_alphas", torch.sqrt(1.0 / alphas).float())
109
+
110
+ # Posterior q(x_{t-1} | x_t, x_0) parameters
111
+ posterior_variance = betas * (1.0 - alphas_cumprod_prev) / (1.0 - alphas_cumprod)
112
+ self.register_buffer("posterior_variance", posterior_variance.float())
113
+ self.register_buffer("posterior_log_variance_clipped",
114
+ torch.log(torch.clamp(posterior_variance, min=1e-20)).float())
115
+ self.register_buffer("posterior_mean_coef1",
116
+ (betas * torch.sqrt(alphas_cumprod_prev) / (1.0 - alphas_cumprod)).float())
117
+ self.register_buffer("posterior_mean_coef2",
118
+ ((1.0 - alphas_cumprod_prev) * torch.sqrt(alphas) / (1.0 - alphas_cumprod)).float())
119
+
120
+ # RNA-seq normalization stats for un-normalizing generated samples
121
+ if rna_norm is not None:
122
+ self.register_buffer("rna_mean", torch.from_numpy(rna_norm['mean']).float())
123
+ self.register_buffer("rna_std", torch.from_numpy(rna_norm['std']).float())
124
+ else:
125
+ self.rna_mean = None
126
+ self.rna_std = None
127
+
128
+ def unnormalize_rna(self, x: torch.Tensor) -> torch.Tensor:
129
+ """Convert normalised diffusion output back to original RNA embedding scale."""
130
+ if self.rna_mean is not None and self.rna_std is not None:
131
+ return x * self.rna_std + self.rna_mean
132
+ return x
133
+
134
+ # ── Forward diffusion (add noise) ─────────────────────────────────────
135
+
136
+ def q_sample(
137
+ self,
138
+ x_start: torch.Tensor,
139
+ t: torch.Tensor,
140
+ noise: Optional[torch.Tensor] = None,
141
+ ) -> torch.Tensor:
142
+ """
143
+ Sample from q(x_t | x_0) = N(√ᾱ_t · x_0, (1-ᾱ_t) · I).
144
+
145
+ Args:
146
+ x_start: (B, D) clean RNA embeddings
147
+ t: (B,) timesteps
148
+ noise: optional pre-sampled noise
149
+ Returns:
150
+ x_t: (B, D) noisy embeddings
151
+ """
152
+ if noise is None:
153
+ noise = torch.randn_like(x_start)
154
+
155
+ sqrt_alpha = self.sqrt_alphas_cumprod[t] # (B,)
156
+ sqrt_one_minus = self.sqrt_one_minus_alphas_cumprod[t] # (B,)
157
+
158
+ # Reshape for broadcasting over feature dim
159
+ while sqrt_alpha.dim() < x_start.dim():
160
+ sqrt_alpha = sqrt_alpha.unsqueeze(-1)
161
+ sqrt_one_minus = sqrt_one_minus.unsqueeze(-1)
162
+
163
+ return sqrt_alpha * x_start + sqrt_one_minus * noise
164
+
165
+ # ── Training loss ─────────────────────────────────────────────────────
166
+
167
+ def compute_loss(
168
+ self,
169
+ rna_embedding: torch.Tensor, # (B, rna_dim) clean target
170
+ img_features: torch.Tensor, # (B, N, img_dim) conditioning
171
+ noise: Optional[torch.Tensor] = None,
172
+ ) -> dict[str, torch.Tensor]:
173
+ """
174
+ Compute the diffusion training loss.
175
+
176
+ Randomly samples timesteps, adds noise, predicts noise, returns loss.
177
+
178
+ Returns:
179
+ dict with "loss" and other metrics
180
+ """
181
+ B = rna_embedding.shape[0]
182
+ device = rna_embedding.device
183
+
184
+ # Sample random timesteps
185
+ t = torch.randint(0, self.num_steps, (B,), device=device).long()
186
+
187
+ # Sample noise
188
+ if noise is None:
189
+ noise = torch.randn_like(rna_embedding)
190
+
191
+ # Add noise
192
+ x_t = self.q_sample(rna_embedding, t, noise)
193
+
194
+ # Predict noise
195
+ noise_pred = self.denoiser(
196
+ noisy_rna=x_t,
197
+ img_features=img_features,
198
+ timestep=t,
199
+ )
200
+
201
+ # Compute loss
202
+ if self.loss_type == "l2":
203
+ loss = F.mse_loss(noise_pred, noise)
204
+ elif self.loss_type == "l1":
205
+ loss = F.l1_loss(noise_pred, noise)
206
+ elif self.loss_type == "huber":
207
+ loss = F.smooth_l1_loss(noise_pred, noise)
208
+ else:
209
+ raise ValueError(f"Unknown loss type: {self.loss_type}")
210
+
211
+ return {
212
+ "loss": loss,
213
+ "mse": F.mse_loss(noise_pred, noise).detach(),
214
+ }
215
+
216
+ # ── Reverse diffusion (sampling) ──────────────────────────────────────
217
+
218
+ @torch.no_grad()
219
+ def p_sample(
220
+ self,
221
+ x_t: torch.Tensor,
222
+ t: int,
223
+ img_features: torch.Tensor,
224
+ ) -> torch.Tensor:
225
+ """
226
+ Single reverse diffusion step: sample x_{t-1} from p_ΞΈ(x_{t-1} | x_t).
227
+
228
+ Args:
229
+ x_t: (B, D) current noisy state
230
+ t: scalar timestep
231
+ img_features: (B, N, img_dim) conditioning
232
+ Returns:
233
+ x_{t-1}: (B, D) less noisy state
234
+ """
235
+ B = x_t.shape[0]
236
+ t_batch = torch.full((B,), t, device=x_t.device, dtype=torch.long)
237
+
238
+ # Predict noise
239
+ noise_pred = self.denoiser(
240
+ noisy_rna=x_t,
241
+ img_features=img_features,
242
+ timestep=t_batch,
243
+ )
244
+
245
+ # Compute posterior mean
246
+ sqrt_recip_alpha = self.sqrt_recip_alphas[t]
247
+ beta = self.betas[t]
248
+ sqrt_one_minus = self.sqrt_one_minus_alphas_cumprod[t]
249
+
250
+ mean = sqrt_recip_alpha * (x_t - beta * noise_pred / sqrt_one_minus)
251
+
252
+ if t > 0:
253
+ noise = torch.randn_like(x_t)
254
+ sigma = torch.sqrt(self.posterior_variance[t])
255
+ return mean + sigma * noise
256
+ else:
257
+ return mean
258
+
259
+ @torch.no_grad()
260
+ def sample(
261
+ self,
262
+ img_features: torch.Tensor, # (B, N, img_dim)
263
+ shape: Optional[tuple] = None,
264
+ ) -> torch.Tensor:
265
+ """
266
+ Generate RNA-seq embeddings conditioned on imaging features.
267
+
268
+ Args:
269
+ img_features: (B, N, img_dim) conditioning features
270
+ shape: output shape (B, rna_dim); inferred if None
271
+ Returns:
272
+ x_0: (B, rna_dim) generated RNA-seq embeddings
273
+ """
274
+ B = img_features.shape[0]
275
+ device = img_features.device
276
+
277
+ if shape is None:
278
+ shape = (B, self.denoiser.rna_dim)
279
+
280
+ # Start from pure noise
281
+ x = torch.randn(shape, device=device)
282
+
283
+ # Iterative denoising
284
+ for t in reversed(range(self.num_steps)):
285
+ x = self.p_sample(x, t, img_features)
286
+
287
+ return self.unnormalize_rna(x)
288
+
289
+ @torch.no_grad()
290
+ def sample_ddim(
291
+ self,
292
+ img_features: torch.Tensor,
293
+ num_inference_steps: int = 50,
294
+ eta: float = 0.0,
295
+ shape: Optional[tuple] = None,
296
+ ) -> torch.Tensor:
297
+ """
298
+ DDIM sampling for faster inference.
299
+
300
+ Args:
301
+ img_features: (B, N, img_dim)
302
+ num_inference_steps: number of denoising steps (< num_steps for speed)
303
+ eta: controls stochasticity (0 = deterministic DDIM, 1 = DDPM)
304
+ shape: output shape
305
+ Returns:
306
+ x_0: (B, rna_dim) generated embeddings
307
+ """
308
+ B = img_features.shape[0]
309
+ device = img_features.device
310
+
311
+ if shape is None:
312
+ shape = (B, self.denoiser.rna_dim)
313
+
314
+ # Create sub-sequence of timesteps
315
+ step_size = self.num_steps // num_inference_steps
316
+ timesteps = list(range(0, self.num_steps, step_size))[::-1]
317
+
318
+ x = torch.randn(shape, device=device)
319
+
320
+ for i, t in enumerate(timesteps):
321
+ t_batch = torch.full((B,), t, device=device, dtype=torch.long)
322
+
323
+ noise_pred = self.denoiser(
324
+ noisy_rna=x,
325
+ img_features=img_features,
326
+ timestep=t_batch,
327
+ )
328
+
329
+ # Predict x_0
330
+ alpha_t = self.alphas_cumprod[t]
331
+ sqrt_alpha_t = self.sqrt_alphas_cumprod[t]
332
+ sqrt_one_minus_t = self.sqrt_one_minus_alphas_cumprod[t]
333
+
334
+ x_0_pred = (x - sqrt_one_minus_t * noise_pred) / sqrt_alpha_t
335
+
336
+ if i < len(timesteps) - 1:
337
+ t_prev = timesteps[i + 1]
338
+ alpha_t_prev = self.alphas_cumprod[t_prev]
339
+ else:
340
+ alpha_t_prev = torch.tensor(1.0, device=device)
341
+
342
+ # DDIM update
343
+ sigma = eta * torch.sqrt(
344
+ (1 - alpha_t_prev) / (1 - alpha_t) * (1 - alpha_t / alpha_t_prev)
345
+ )
346
+
347
+ pred_dir = torch.sqrt(1 - alpha_t_prev - sigma ** 2) * noise_pred
348
+ x = torch.sqrt(alpha_t_prev) * x_0_pred + pred_dir
349
+
350
+ if sigma > 0 and i < len(timesteps) - 1:
351
+ x = x + sigma * torch.randn_like(x)
352
+
353
+ return self.unnormalize_rna(x)
models/lit_module.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PyTorch Lightning LightningModule for Img2RNA diffusion training.
3
+
4
+ Wraps the GaussianDiffusion model with:
5
+ - Automatic optimizer / scheduler setup
6
+ - Training / validation step definitions
7
+ - EMA weight averaging
8
+ - Periodic sample generation for qualitative monitoring
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ from typing import Any, Optional
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+ import pytorch_lightning as pl
19
+
20
+ from models.denoiser import Img2RNADenoiser
21
+ from models.diffusion import GaussianDiffusion
22
+
23
+
24
+ class EMA:
25
+ """Exponential Moving Average of model parameters (Lightning-compatible)."""
26
+
27
+ def __init__(self, model: nn.Module, decay: float = 0.9999):
28
+ self.decay = decay
29
+ self.shadow = {
30
+ name: p.clone().detach()
31
+ for name, p in model.named_parameters()
32
+ if p.requires_grad
33
+ }
34
+ self._backup: dict[str, torch.Tensor] = {}
35
+
36
+ @torch.no_grad()
37
+ def update(self, model: nn.Module):
38
+ for name, p in model.named_parameters():
39
+ if p.requires_grad and name in self.shadow:
40
+ self.shadow[name].lerp_(p.data, 1 - self.decay)
41
+
42
+ def apply(self, model: nn.Module):
43
+ self._backup = {
44
+ name: p.data.clone()
45
+ for name, p in model.named_parameters()
46
+ if name in self.shadow
47
+ }
48
+ for name, p in model.named_parameters():
49
+ if name in self.shadow:
50
+ p.data.copy_(self.shadow[name])
51
+
52
+ def restore(self, model: nn.Module):
53
+ for name, p in model.named_parameters():
54
+ if name in self._backup:
55
+ p.data.copy_(self._backup[name])
56
+ self._backup.clear()
57
+
58
+ def state_dict(self):
59
+ return {k: v.cpu() for k, v in self.shadow.items()}
60
+
61
+ def load_state_dict(self, state_dict: dict):
62
+ self.shadow = {k: v.clone() for k, v in state_dict.items()}
63
+
64
+
65
+ class Img2RNALitModule(pl.LightningModule):
66
+ """
67
+ Lightning module for image-conditioned RNA-seq diffusion.
68
+
69
+ Handles training, validation, EMA, and optional sample generation.
70
+ Works with any Lightning logger (TensorBoard, wandb, CSV, etc.).
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ # Model config
76
+ img_dim: int = 5120,
77
+ rna_dim: int = 512,
78
+ model_dim: int = 1024,
79
+ num_heads: int = 8,
80
+ num_layers: int = 6,
81
+ time_dim: int = 256,
82
+ ff_mult: int = 4,
83
+ dropout: float = 0.1,
84
+ # Diffusion config
85
+ num_steps: int = 1000,
86
+ schedule: str = "cosine",
87
+ beta_start: float = 1e-4,
88
+ beta_end: float = 0.02,
89
+ loss_type: str = "l2",
90
+ # Normalization stats
91
+ rna_norm: Optional[dict] = None,
92
+ # Training config
93
+ lr: float = 1e-4,
94
+ weight_decay: float = 1e-5,
95
+ warmup_epochs: int = 5,
96
+ scheduler_type: str = "cosine",
97
+ max_grad_norm: float = 1.0,
98
+ ema_decay: float = 0.9999,
99
+ # Sampling config (for validation visualisation)
100
+ val_sample_every_n_epochs: int = 10,
101
+ ddim_steps: int = 50,
102
+ ):
103
+ super().__init__()
104
+ self.save_hyperparameters()
105
+
106
+ # Build denoiser
107
+ self.denoiser = Img2RNADenoiser(
108
+ img_dim=img_dim,
109
+ rna_dim=rna_dim,
110
+ model_dim=model_dim,
111
+ num_heads=num_heads,
112
+ num_layers=num_layers,
113
+ time_dim=time_dim,
114
+ ff_mult=ff_mult,
115
+ dropout=dropout,
116
+ )
117
+
118
+ # Build diffusion wrapper
119
+ self.diffusion = GaussianDiffusion(
120
+ denoiser=self.denoiser,
121
+ num_steps=num_steps,
122
+ schedule=schedule,
123
+ beta_start=beta_start,
124
+ beta_end=beta_end,
125
+ loss_type=loss_type,
126
+ rna_norm=rna_norm,
127
+ )
128
+
129
+ # EMA (initialised in on_fit_start so it lives on correct device)
130
+ self._ema: Optional[EMA] = None
131
+ self._ema_decay = ema_decay
132
+
133
+ # Cache for logging
134
+ self._val_step_outputs: list[dict] = []
135
+
136
+ # ── Lifecycle hooks ───────────────────────────────────────────────────
137
+
138
+ def on_fit_start(self):
139
+ self._ema = EMA(self.diffusion, decay=self._ema_decay)
140
+ param_info = self.denoiser.count_parameters()
141
+ self.log_dict({
142
+ "model/total_params": float(param_info["total"]),
143
+ "model/trainable_params": float(param_info["trainable"]),
144
+ })
145
+
146
+ # ── Training ──────────────────────────────────────────────────────────
147
+
148
+ def training_step(self, batch: dict, batch_idx: int) -> torch.Tensor:
149
+ img_features = batch["img_features"] # (B, N, img_dim)
150
+ rna_embedding = batch["rna_embedding"] # (B, rna_dim)
151
+
152
+ result = self.diffusion.compute_loss(rna_embedding, img_features)
153
+ loss = result["loss"]
154
+
155
+ # Log metrics
156
+ self.log("train/loss", loss, on_step=True, on_epoch=True, prog_bar=True)
157
+ self.log("train/mse", result["mse"], on_step=False, on_epoch=True)
158
+ self.log("train/lr", self.optimizers().param_groups[0]["lr"], on_step=True, on_epoch=False)
159
+
160
+ return loss
161
+
162
+ def on_train_batch_end(self, outputs, batch, batch_idx):
163
+ if self._ema is not None:
164
+ self._ema.update(self.diffusion)
165
+
166
+ # ── Validation ────────────────────────────────────────────────────────
167
+
168
+ def validation_step(self, batch: dict, batch_idx: int) -> dict:
169
+ img_features = batch["img_features"]
170
+ rna_embedding = batch["rna_embedding"]
171
+
172
+ result = self.diffusion.compute_loss(rna_embedding, img_features)
173
+
174
+ self.log("val/loss", result["loss"], on_step=False, on_epoch=True, prog_bar=True, sync_dist=True)
175
+ self.log("val/mse", result["mse"], on_step=False, on_epoch=True, sync_dist=True)
176
+
177
+ self._val_step_outputs.append({
178
+ "loss": result["loss"].detach(),
179
+ "mse": result["mse"].detach(),
180
+ })
181
+
182
+ return result
183
+
184
+ def on_validation_epoch_end(self):
185
+ # Optional: generate samples for qualitative monitoring
186
+ if (
187
+ self.current_epoch > 0
188
+ and self.current_epoch % self.hparams.val_sample_every_n_epochs == 0
189
+ ):
190
+ self._log_generated_samples()
191
+
192
+ self._val_step_outputs.clear()
193
+
194
+ @torch.no_grad()
195
+ def _log_generated_samples(self, n_samples: int = 8):
196
+ """Generate a few samples and log statistics to the logger."""
197
+ # Use EMA weights for sampling
198
+ if self._ema is not None:
199
+ self._ema.apply(self.diffusion)
200
+
201
+ try:
202
+ # Create dummy imaging conditioning (random from normal; in practice
203
+ # you'd use real validation imaging features)
204
+ dummy_img = torch.randn(
205
+ n_samples,
206
+ self.hparams.get("n_imaging_cells", 16),
207
+ self.hparams.img_dim,
208
+ device=self.device,
209
+ )
210
+ generated = self.diffusion.sample_ddim(
211
+ dummy_img,
212
+ num_inference_steps=self.hparams.ddim_steps,
213
+ )
214
+
215
+ self.log("val/generated_mean", generated.mean())
216
+ self.log("val/generated_std", generated.std())
217
+ self.log("val/generated_min", generated.min())
218
+ self.log("val/generated_max", generated.max())
219
+
220
+ finally:
221
+ if self._ema is not None:
222
+ self._ema.restore(self.diffusion)
223
+
224
+ # ── Optimizer & scheduler ─────────────────────────────────────────────
225
+
226
+ def configure_optimizers(self):
227
+ optimizer = torch.optim.AdamW(
228
+ self.parameters(),
229
+ lr=self.hparams.lr,
230
+ weight_decay=self.hparams.weight_decay,
231
+ betas=(0.9, 0.999),
232
+ )
233
+
234
+ if self.hparams.scheduler_type == "cosine":
235
+ # Total steps will be set by trainer
236
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
237
+ optimizer,
238
+ T_max=self.trainer.estimated_stepping_batches,
239
+ eta_min=self.hparams.lr * 0.01,
240
+ )
241
+ return {
242
+ "optimizer": optimizer,
243
+ "lr_scheduler": {
244
+ "scheduler": scheduler,
245
+ "interval": "step",
246
+ "frequency": 1,
247
+ },
248
+ }
249
+ else:
250
+ return optimizer
251
+
252
+ def on_before_optimizer_step(self, optimizer):
253
+ # Gradient clipping
254
+ if self.hparams.max_grad_norm > 0:
255
+ nn.utils.clip_grad_norm_(self.parameters(), self.hparams.max_grad_norm)
256
+
257
+ # ── Inference helpers ─────────────────────────────────────────────────
258
+
259
+ @torch.no_grad()
260
+ def generate(
261
+ self,
262
+ img_features: torch.Tensor,
263
+ use_ema: bool = True,
264
+ ddim: bool = True,
265
+ ddim_steps: int = 50,
266
+ ) -> torch.Tensor:
267
+ """
268
+ Generate RNA-seq embeddings from imaging features.
269
+
270
+ Args:
271
+ img_features: (B, N, img_dim)
272
+ use_ema: whether to use EMA weights
273
+ ddim: use DDIM sampling (faster)
274
+ ddim_steps: number of DDIM steps
275
+ Returns:
276
+ (B, rna_dim) generated embeddings
277
+ """
278
+ if use_ema and self._ema is not None:
279
+ self._ema.apply(self.diffusion)
280
+
281
+ try:
282
+ if ddim:
283
+ return self.diffusion.sample_ddim(img_features, num_inference_steps=ddim_steps)
284
+ else:
285
+ return self.diffusion.sample(img_features)
286
+ finally:
287
+ if use_ema and self._ema is not None:
288
+ self._ema.restore(self.diffusion)
models/model_utils.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared model utilities: sinusoidal embeddings, activation functions, blocks.
3
+ """
4
+
5
+ import math
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ import torch.nn.functional as F
10
+
11
+
12
+ # ──────────────────────────────────────────────────────────────────────────────
13
+ # Activations
14
+ # ──────────────────────────────────────────────────────────────────────────────
15
+
16
+ class Mish(nn.Module):
17
+ """Mish activation: x * tanh(softplus(x))."""
18
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
19
+ return x * torch.tanh(F.softplus(x))
20
+
21
+
22
+ class GEGLU(nn.Module):
23
+ """Gated GELU activation for feedforward blocks."""
24
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
25
+ x, gate = x.chunk(2, dim=-1)
26
+ return x * F.gelu(gate)
27
+
28
+
29
+ # ──────────────────────────────────────────────────────────────────────────────
30
+ # Sinusoidal positional / time embedding
31
+ # ──────────────────────────────────────────────────────────────────────────────
32
+
33
+ class SinusoidalTimeEmbedding(nn.Module):
34
+ """
35
+ Maps scalar timesteps to sinusoidal embeddings.
36
+
37
+ Args:
38
+ dim: output embedding dimension (must be even)
39
+ max_period: controls frequency range
40
+ """
41
+
42
+ def __init__(self, dim: int, max_period: int = 10000):
43
+ super().__init__()
44
+ assert dim % 2 == 0, "dim must be even"
45
+ self.dim = dim
46
+ self.max_period = max_period
47
+
48
+ def forward(self, t: torch.Tensor) -> torch.Tensor:
49
+ """
50
+ Args:
51
+ t: (B,) integer or float timesteps
52
+ Returns:
53
+ (B, dim) sinusoidal embeddings
54
+ """
55
+ half = self.dim // 2
56
+ freqs = torch.exp(
57
+ -math.log(self.max_period)
58
+ * torch.arange(half, device=t.device, dtype=torch.float32)
59
+ / half
60
+ )
61
+ args = t[:, None].float() * freqs[None, :]
62
+ return torch.cat([args.sin(), args.cos()], dim=-1)
63
+
64
+
65
+ # ──────────────────────────────────────────────────────────────────────────────
66
+ # Building blocks
67
+ # ──────────────────────────────────────────────────────────────────────────────
68
+
69
+ class FeedForward(nn.Module):
70
+ """
71
+ Transformer feedforward block with GEGLU gating.
72
+
73
+ Args:
74
+ dim: input/output dimension
75
+ mult: hidden dimension multiplier
76
+ dropout: dropout rate
77
+ """
78
+
79
+ def __init__(self, dim: int, mult: int = 4, dropout: float = 0.1):
80
+ super().__init__()
81
+ inner_dim = dim * mult * 2 # Γ—2 for GEGLU split
82
+ self.net = nn.Sequential(
83
+ nn.LayerNorm(dim),
84
+ nn.Linear(dim, inner_dim),
85
+ GEGLU(),
86
+ nn.Dropout(dropout),
87
+ nn.Linear(dim * mult, dim),
88
+ nn.Dropout(dropout),
89
+ )
90
+
91
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
92
+ return self.net(x)
93
+
94
+
95
+ class AdaLayerNorm(nn.Module):
96
+ """
97
+ Adaptive Layer Normalization conditioned on time embedding.
98
+ Applies scale/shift modulation: Ξ³(t) * LayerNorm(x) + Ξ²(t)
99
+
100
+ Args:
101
+ dim: feature dimension
102
+ cond_dim: conditioning (time) embedding dimension
103
+ """
104
+
105
+ def __init__(self, dim: int, cond_dim: int):
106
+ super().__init__()
107
+ self.norm = nn.LayerNorm(dim, elementwise_affine=False)
108
+ self.proj = nn.Sequential(
109
+ Mish(),
110
+ nn.Linear(cond_dim, dim * 2),
111
+ )
112
+
113
+ def forward(self, x: torch.Tensor, cond: torch.Tensor) -> torch.Tensor:
114
+ """
115
+ Args:
116
+ x: (B, L, D) or (B, D)
117
+ cond: (B, cond_dim)
118
+ """
119
+ gamma, beta = self.proj(cond).chunk(2, dim=-1)
120
+ if x.dim() == 3 and gamma.dim() == 2:
121
+ gamma = gamma.unsqueeze(1)
122
+ beta = beta.unsqueeze(1)
123
+ return self.norm(x) * (1 + gamma) + beta
pipeline.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PhenoSeq inference pipeline.
3
+
4
+ Self-contained entry point for generating scGPT RNA-seq embeddings from
5
+ ViT-L microscopy imaging features using a pretrained diffusion model.
6
+
7
+ Quick start
8
+ -----------
9
+ >>> import numpy as np
10
+ >>> from pipeline import PhenoSeqPipeline
11
+ >>>
12
+ >>> # Load from a local directory (or a HuggingFace repo id)
13
+ >>> pipe = PhenoSeqPipeline.from_pretrained(".")
14
+ >>>
15
+ >>> # img_features: (n_cells, n_imaging_cells, 5120) β€” raw ViT-L embeddings
16
+ >>> # img_norm is loaded automatically from img_norm.npz when present
17
+ >>> rna = pipe(img_features) # β†’ np.ndarray (n_cells, 512)
18
+
19
+ Input format
20
+ ------------
21
+ img_features : np.ndarray or torch.Tensor
22
+ Shape (B, N, 5120) where
23
+ B = number of target RNA cells to generate
24
+ N = number of imaging cells per well (default 16)
25
+ Features should be raw (unnormalized) ViT-L embeddings when img_norm
26
+ is available, or pre-normalized when img_norm is None.
27
+
28
+ Output format
29
+ -------------
30
+ np.ndarray of shape (B, 512) β€” scGPT embedding space predictions,
31
+ un-normalized back to the original scGPT scale.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ import os
38
+ from pathlib import Path
39
+ from typing import Optional, Union
40
+
41
+ import numpy as np
42
+ import torch
43
+ import yaml
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ # ──────────────────────────────────────────────────────────────────────────────
49
+ # Pipeline
50
+ # ──────────────────────────────────────────────────────────────────────────────
51
+
52
+ class PhenoSeqPipeline:
53
+ """
54
+ Wraps the trained PhenoSeq diffusion model for single-cell RNA prediction.
55
+
56
+ Parameters
57
+ ----------
58
+ diffusion : GaussianDiffusion
59
+ Loaded diffusion model (weights applied, eval mode).
60
+ img_norm : dict with 'mean' and 'std' arrays of shape (5120,), optional
61
+ Imaging normalisation statistics computed from the training split.
62
+ If None, input features are assumed to be pre-normalized.
63
+ device : str or torch.device
64
+ Device for inference ('cuda', 'cpu', etc.).
65
+ ddim_steps : int
66
+ Number of DDIM denoising steps (50 is a good default; 0 = full DDPM).
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ diffusion,
72
+ img_norm: Optional[dict] = None,
73
+ device: Union[str, torch.device] = "cpu",
74
+ ddim_steps: int = 50,
75
+ ):
76
+ self.diffusion = diffusion
77
+ self.img_norm = img_norm
78
+ self.device = torch.device(device)
79
+ self.ddim_steps = ddim_steps
80
+ self.diffusion.to(self.device).eval()
81
+
82
+ # ── Factory ───────────────────────────────────────────────────────────
83
+
84
+ @classmethod
85
+ def from_pretrained(
86
+ cls,
87
+ model_dir: Union[str, Path] = ".",
88
+ checkpoint_name: str = "best_model.pt",
89
+ img_norm_name: str = "img_norm.npz",
90
+ device: Optional[Union[str, torch.device]] = None,
91
+ ddim_steps: int = 50,
92
+ use_ema: bool = True,
93
+ ) -> "PhenoSeqPipeline":
94
+ """
95
+ Load a pipeline from a local directory or HuggingFace Hub repo.
96
+
97
+ Parameters
98
+ ----------
99
+ model_dir : str or Path
100
+ Local path or HuggingFace Hub repo id containing the checkpoint.
101
+ checkpoint_name : str
102
+ Filename of the PyTorch checkpoint inside model_dir.
103
+ img_norm_name : str
104
+ Filename of the imaging normalisation stats (.npz with 'mean', 'std').
105
+ If the file is not found, img_norm is set to None and a warning is logged.
106
+ device : str, torch.device, or None
107
+ Target device; auto-selects CUDA when available if None.
108
+ ddim_steps : int
109
+ DDIM sampling steps.
110
+ use_ema : bool
111
+ Prefer EMA weights when available in the checkpoint (recommended).
112
+ """
113
+ model_dir = Path(model_dir)
114
+
115
+ # ── Resolve from HuggingFace Hub if path doesn't exist locally ────
116
+ if not model_dir.exists():
117
+ try:
118
+ from huggingface_hub import snapshot_download
119
+ model_dir = Path(snapshot_download(str(model_dir)))
120
+ logger.info(f"Downloaded from HuggingFace Hub β†’ {model_dir}")
121
+ except Exception as exc:
122
+ raise FileNotFoundError(
123
+ f"Directory '{model_dir}' not found locally and Hub download failed: {exc}"
124
+ ) from exc
125
+
126
+ if device is None:
127
+ device = "cuda" if torch.cuda.is_available() else "cpu"
128
+ device = torch.device(device)
129
+
130
+ # ── Load checkpoint ────────────────────────���──────────────────────
131
+ ckpt_path = model_dir / checkpoint_name
132
+ if not ckpt_path.exists():
133
+ raise FileNotFoundError(f"Checkpoint not found: {ckpt_path}")
134
+
135
+ logger.info(f"Loading checkpoint from {ckpt_path}")
136
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
137
+
138
+ # ── Build model from embedded config ──────────────────────────────
139
+ cfg = ckpt.get("config", {})
140
+ mc = ckpt.get("model_cfg", cfg.get("model", {}))
141
+ dc = cfg.get("diffusion", {})
142
+
143
+ from models.denoiser import Img2RNADenoiser
144
+ from models.diffusion import GaussianDiffusion
145
+
146
+ # RNA normalisation is stored in the checkpoint
147
+ rna_norm_raw = ckpt.get("rna_norm")
148
+ rna_norm = (
149
+ {"mean": np.array(rna_norm_raw["mean"]), "std": np.array(rna_norm_raw["std"])}
150
+ if rna_norm_raw is not None else None
151
+ )
152
+
153
+ denoiser = Img2RNADenoiser(
154
+ img_dim = mc.get("img_dim", 5120),
155
+ rna_dim = mc.get("rna_dim", 512),
156
+ model_dim = mc.get("model_dim", 1024),
157
+ num_heads = mc.get("num_heads", 8),
158
+ num_layers= mc.get("num_layers", 6),
159
+ time_dim = mc.get("time_dim", 256),
160
+ ff_mult = mc.get("ff_mult", 4),
161
+ dropout = 0.0,
162
+ )
163
+
164
+ diffusion = GaussianDiffusion(
165
+ denoiser = denoiser,
166
+ num_steps = dc.get("num_steps", 1000),
167
+ schedule = dc.get("schedule", "cosine"),
168
+ beta_start = dc.get("beta_start", 1e-4),
169
+ beta_end = dc.get("beta_end", 0.02),
170
+ rna_norm = rna_norm,
171
+ )
172
+
173
+ # ── Load weights ──────────────────────────────────────────────────
174
+ ema_state = ckpt.get("ema_state_dict")
175
+ if use_ema and ema_state is not None:
176
+ logger.info("Loading EMA weights")
177
+ diffusion.denoiser.load_state_dict(
178
+ {k.removeprefix("denoiser."): v for k, v in ema_state.items()},
179
+ strict=False,
180
+ )
181
+ else:
182
+ if use_ema and ema_state is None:
183
+ logger.warning("EMA weights not found; using standard model weights")
184
+ diffusion.load_state_dict(ckpt["model_state_dict"], strict=True)
185
+
186
+ n_params = sum(p.numel() for p in denoiser.parameters())
187
+ logger.info(f"Model loaded ({n_params:,} parameters) on {device}")
188
+
189
+ # ── Imaging normalisation stats ───────────────────────────────────
190
+ img_norm = None
191
+ norm_path = model_dir / img_norm_name
192
+ if norm_path.exists():
193
+ data = np.load(norm_path)
194
+ img_norm = {"mean": data["mean"], "std": data["std"]}
195
+ logger.info(f"Loaded imaging normalisation stats from {norm_path}")
196
+ else:
197
+ logger.warning(
198
+ f"{img_norm_name} not found in {model_dir}. "
199
+ "Pass pre-normalized imaging features, or provide img_norm manually. "
200
+ "See save_img_norm.py to generate this file from your training data."
201
+ )
202
+
203
+ return cls(diffusion, img_norm=img_norm, device=device, ddim_steps=ddim_steps) # type: ignore[return-value]
204
+
205
+ # ── Inference ─────────────────────────────────────────────────────────
206
+
207
+ @torch.no_grad()
208
+ def __call__(
209
+ self,
210
+ img_features: Union[np.ndarray, torch.Tensor],
211
+ batch_size: int = 256,
212
+ ddim_steps: Optional[int] = None,
213
+ ) -> np.ndarray:
214
+ """
215
+ Generate scGPT RNA-seq embeddings from imaging features.
216
+
217
+ Parameters
218
+ ----------
219
+ img_features : array-like, shape (B, N, 5120) or (N, 5120)
220
+ ViT-L imaging embeddings. When img_norm is available these should
221
+ be raw (unnormalized); otherwise provide pre-normalized features.
222
+ If 2D (N, 5120), a batch dimension is added automatically.
223
+ batch_size : int
224
+ Number of cells processed per forward pass.
225
+ ddim_steps : int, optional
226
+ Override the pipeline's default DDIM steps for this call.
227
+ Pass 0 to use full DDPM sampling (slower, ~1000 steps).
228
+
229
+ Returns
230
+ -------
231
+ np.ndarray of shape (B, 512)
232
+ Predicted scGPT-space RNA embeddings in the original (denormalized) scale.
233
+ """
234
+ steps = ddim_steps if ddim_steps is not None else self.ddim_steps
235
+
236
+ # Coerce to numpy then torch
237
+ if isinstance(img_features, torch.Tensor):
238
+ img_np = img_features.cpu().float().numpy()
239
+ else:
240
+ img_np = np.asarray(img_features, dtype=np.float32)
241
+
242
+ # Add batch dim if single sample
243
+ if img_np.ndim == 2:
244
+ img_np = img_np[np.newaxis]
245
+
246
+ if img_np.ndim != 3 or img_np.shape[-1] != 5120:
247
+ raise ValueError(
248
+ f"Expected img_features shape (B, N, 5120), got {img_np.shape}"
249
+ )
250
+
251
+ # Normalize imaging features if stats are available
252
+ if self.img_norm is not None:
253
+ mean = self.img_norm["mean"].astype(np.float32) # (5120,)
254
+ std = self.img_norm["std"].astype(np.float32) # (5120,)
255
+ img_np = (img_np - mean) / std
256
+
257
+ # Batch inference
258
+ all_preds: list[np.ndarray] = []
259
+ for start in range(0, len(img_np), batch_size):
260
+ chunk = torch.from_numpy(img_np[start : start + batch_size]).to(self.device)
261
+
262
+ if steps > 0:
263
+ preds = self.diffusion.sample_ddim(
264
+ img_features = chunk,
265
+ num_inference_steps = steps,
266
+ eta = 0.0,
267
+ )
268
+ else:
269
+ preds = self.diffusion.sample(chunk)
270
+
271
+ all_preds.append(preds.cpu().float().numpy())
272
+
273
+ return np.concatenate(all_preds, axis=0) # (B, 512)
274
+
275
+ # ── Convenience ───────────────────────────────────────────────────────
276
+
277
+ @property
278
+ def rna_dim(self) -> int:
279
+ return self.diffusion.denoiser.rna_dim
280
+
281
+ def __repr__(self) -> str:
282
+ d = self.diffusion.denoiser
283
+ return (
284
+ f"PhenoSeqPipeline("
285
+ f"rna_dim={d.rna_dim}, "
286
+ f"model_dim={d.model_dim}, num_layers={len(d.layers)}, "
287
+ f"ddim_steps={self.ddim_steps}, device={self.device})"
288
+ )