Upload folder using huggingface_hub
Browse files- README.md +215 -0
- best_model.pt +3 -0
- example.py +36 -0
- img_norm.npz +3 -0
- models/__init__.py +0 -0
- models/denoiser.py +266 -0
- models/diffusion.py +353 -0
- models/lit_module.py +288 -0
- models/model_utils.py +123 -0
- pipeline.py +288 -0
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 |
+
[](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 |
+
)
|