geneflowdecoder 0.1.4__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: geneflowdecoder
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Gene Transformer + Conditional Rectified Flow decoder from cell-state z to log1p expression X.
5
5
  Author: GeneFlowDecoder Contributors
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "geneflowdecoder"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Gene Transformer + Conditional Rectified Flow decoder from cell-state z to log1p expression X."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -0,0 +1,549 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ GeneFlowDecoder 0.1.2
4
+ z (cell state, *frozen*) --> X (log-normalized expression, log1p space)
5
+
6
+ Public API (stable):
7
+ model = GeneFlowDecoder(n_genes=G, z_dim=D, ...)
8
+ model.fit(X, z, X_val=None, z_val=None, ...)
9
+ model(z) # sample X from z (flow, stochastic)
10
+ model.predict(z) # deterministic X (NB mean, log1p space)
11
+ model.save(path) / GeneFlowDecoder.load(path, **init_kwargs)
12
+
13
+ Requires: torch >= 2.1
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ import warnings
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Optional, Tuple, Union
22
+
23
+ import numpy as np
24
+
25
+ # ----------------------------------------------------------------------
26
+ # 可选的 torch 导入:缺 torch 时 import 不崩,构造模型时才明确报错
27
+ # ----------------------------------------------------------------------
28
+ try:
29
+ import torch
30
+ import torch.nn as nn
31
+ import torch.nn.functional as F
32
+ from torch import Tensor
33
+
34
+ _HAS_TORCH = True
35
+ except Exception: # pragma: no cover
36
+ _HAS_TORCH = False
37
+ torch = None # type: ignore[assignment]
38
+ nn = None # type: ignore[assignment]
39
+ F = None # type: ignore[assignment]
40
+
41
+ class Tensor: # noqa: N801 -- placeholder type, never instantiated
42
+ """Stand-in so ``Array`` remains valid at annotation-check time."""
43
+
44
+ def __init_subclass__(cls, **_: Any) -> None:
45
+ pass
46
+
47
+ class _Noop:
48
+ def __getattr__(self, _name: str) -> "_Noop":
49
+ return self
50
+
51
+ def __call__(self, *__args: Any, **__kw: Any) -> None:
52
+ return None
53
+
54
+ nn = _Noop() # type: ignore[assignment]
55
+ F = _Noop() # type: ignore[assignment]
56
+
57
+
58
+ Array = Union[np.ndarray, "Tensor"]
59
+
60
+
61
+ def _as_tensor(x: Array, device: "torch.device") -> "Tensor":
62
+ """numpy -> torch; 已在目标设备的 tensor 原样返回;cuda 输入不再拉回 cpu。"""
63
+ if isinstance(x, np.ndarray):
64
+ x = torch.from_numpy(x)
65
+ t: Tensor = x
66
+ if t.device != device:
67
+ t = t.to(device, non_blocking=True)
68
+ return t
69
+
70
+
71
+ def _require_torch() -> None:
72
+ if not _HAS_TORCH:
73
+ raise RuntimeError(
74
+ "GeneFlowDecoder requires PyTorch (>= 2.1). "
75
+ "Install it with: pip install 'torch>=2.1'"
76
+ )
77
+
78
+
79
+ # ============================================================
80
+ # 条件化模块
81
+ # ============================================================
82
+
83
+ class _AdaLNModulation(nn.Module):
84
+ def __init__(self, d: int, cond_dim: int):
85
+ super().__init__()
86
+ self.norm = nn.LayerNorm(d, elementwise_affine=False, eps=1e-6)
87
+ self.proj = nn.Sequential(
88
+ nn.Linear(cond_dim, d), nn.SiLU(), nn.Linear(d, 3 * d)
89
+ )
90
+ nn.init.zeros_(self.proj[-1].weight)
91
+ nn.init.zeros_(self.proj[-1].bias)
92
+
93
+ def forward(self, x: Tensor, z: Tensor) -> Tensor:
94
+ g, b, gate = self.proj(z).chunk(3, dim=-1)
95
+ g = g.unsqueeze(1)
96
+ b = b.unsqueeze(1)
97
+ gate = gate.unsqueeze(1).sigmoid()
98
+ return gate * (self.norm(x) * (1 + g) + b)
99
+
100
+
101
+ class _CondBlock(nn.Module):
102
+ def __init__(self, d: int, cond_dim: int, n_heads: int, mlp_ratio: float = 4.0):
103
+ super().__init__()
104
+ self.attn_norm = _AdaLNModulation(d, cond_dim)
105
+ self.attn = nn.MultiheadAttention(d, n_heads, batch_first=True)
106
+ self.mlp_norm = _AdaLNModulation(d, cond_dim)
107
+ hd = int(d * mlp_ratio)
108
+ self.mlp = nn.Sequential(nn.Linear(d, hd), nn.GELU(), nn.Linear(hd, d))
109
+
110
+ def forward(self, x: Tensor, z: Tensor) -> Tensor:
111
+ h = self.attn_norm(x, z)
112
+ h, _ = self.attn(h, h, h, need_weights=False)
113
+ x = x + h
114
+ return x + self.mlp(self.mlp_norm(x, z))
115
+
116
+
117
+ # ============================================================
118
+ # Gene Transformer backbone
119
+ # 输出恒为 [B, G, d];chunked 分支显式保证拼接后形状
120
+ # ============================================================
121
+
122
+ class _GeneTransformer(nn.Module):
123
+ def __init__(
124
+ self,
125
+ n_genes: int,
126
+ d: int,
127
+ depth: int,
128
+ n_heads: int,
129
+ cond_dim: int,
130
+ chunk_size: int,
131
+ ckpt: bool,
132
+ ):
133
+ super().__init__()
134
+ self.d = d
135
+ self.n_genes = n_genes
136
+ self.chunk_size = max(1, chunk_size)
137
+ self._ckpt = ckpt
138
+
139
+ self.gene_embed = nn.Embedding(n_genes, d)
140
+ nn.init.normal_(self.gene_embed.weight, std=0.02)
141
+ self.pos_embed = nn.Parameter(torch.zeros(1, n_genes, d))
142
+ nn.init.normal_(self.pos_embed, std=0.02)
143
+
144
+ self.blocks = nn.ModuleList([_CondBlock(d, cond_dim, n_heads) for _ in range(depth)])
145
+ self.head_norm = nn.LayerNorm(d, eps=1e-6)
146
+
147
+ def _run(self, x: Tensor, z: Tensor) -> Tensor:
148
+ for blk in self.blocks:
149
+ if self._ckpt and self.training:
150
+ x = torch.utils.checkpoint.checkpoint(blk, x, z, use_reentrant=False)
151
+ else:
152
+ x = blk(x, z)
153
+ return self.head_norm(x)
154
+
155
+ def forward(self, gene_ids: Tensor, z: Tensor) -> Tensor:
156
+ B, G = gene_ids.shape[0], gene_ids.shape[1]
157
+ if G <= self.chunk_size:
158
+ h = self.gene_embed(gene_ids) + self.pos_embed[:, :G, :]
159
+ return self._run(h, z)
160
+
161
+ chunks: list[Tensor] = []
162
+ for s in range(0, G, self.chunk_size):
163
+ e = min(s + self.chunk_size, G)
164
+ gids = gene_ids[:, s:e]
165
+ h = self.gene_embed(gids) + self.pos_embed[:, s:e, :]
166
+ chunks.append(self._run(h, z))
167
+ out = torch.cat(chunks, dim=1)
168
+ # 防御性断言:backbone 永远交出 [B, G, d]
169
+ if out.shape != (B, G, self.d): # pragma: no cover - 仅开发期暴露
170
+ raise RuntimeError(
171
+ f"backbone shape invariant broken: got {tuple(out.shape)}, "
172
+ f"expected ({B},{G},{self.d})"
173
+ )
174
+ return out
175
+
176
+
177
+ # ============================================================
178
+ # NB 输出头
179
+ # ============================================================
180
+
181
+ class _NBHead(nn.Module):
182
+ def __init__(self, d: int, n_genes: int):
183
+ super().__init__()
184
+ self.mu_proj = nn.Linear(d, n_genes)
185
+ self.theta = nn.Parameter(torch.zeros(n_genes))
186
+ nn.init.zeros_(self.mu_proj.weight)
187
+ nn.init.zeros_(self.mu_proj.bias)
188
+
189
+ def forward(self, h: Tensor) -> Tuple[Tensor, Tensor]:
190
+ # h: [B, G, d] -> log_mu: [B, G]
191
+ return self.mu_proj(h).squeeze(-1), F.softplus(self.theta) + 1e-3
192
+
193
+ @staticmethod
194
+ def nll(
195
+ log_mu: Tensor,
196
+ theta: Tensor,
197
+ x: Tensor,
198
+ mask: Optional[Tensor] = None,
199
+ ) -> Tensor:
200
+ mu = log_mu.exp().clamp(min=1e-6)
201
+ th = theta.unsqueeze(0).expand_as(x)
202
+ ll = (
203
+ torch.lgamma(x + th)
204
+ - torch.lgamma(th)
205
+ - torch.lgamma(x + 1)
206
+ + th * (th.log() - (th + mu).log())
207
+ + x * (mu.log() - (th + mu).log())
208
+ )
209
+ if mask is not None:
210
+ ll = ll * mask
211
+ return -ll.sum() / mask.sum().clamp(min=1.0)
212
+ return -ll.mean()
213
+
214
+
215
+ @dataclass
216
+ class FlowConfig:
217
+ """训练目标权重;均为非负标量。"""
218
+
219
+ sigma_min: float = 1e-4
220
+ nb_weight: float = 1.0
221
+ corr_weight: float = 0.1
222
+
223
+
224
+ # ============================================================
225
+ # 主类
226
+ # ============================================================
227
+
228
+ class GeneFlowDecoder(nn.Module):
229
+ """
230
+ z -> X (log1p expression).
231
+
232
+ ``z`` 默认视为**固定表征**(freeze_z=True):不参与反传、不被优化器更新,
233
+ fit() 开头还会对其做一次 detach + clone 快照,训练中途外部 encoder
234
+ 若继续更新也不会污染本 decoder 的输入。
235
+
236
+ Examples
237
+ --------
238
+ >>> model = GeneFlowDecoder(n_genes=2048, z_dim=256, d=512, depth=8)
239
+ >>> model.fit(X_train, z_train, epochs=200, batch_size=64)
240
+ >>> X_hat = model(z_test) # 采样 (flow, stochastic)
241
+ >>> X_rec = model.predict(z_test) # 确定性还原: NB mean (log1p 空间)
242
+ """
243
+
244
+ def __init__(
245
+ self,
246
+ n_genes: int,
247
+ z_dim: int,
248
+ d: int = 768,
249
+ depth: int = 16,
250
+ n_heads: int = 12,
251
+ chunk_size: int = 2048,
252
+ gradient_checkpoint: bool = True,
253
+ cfg: Optional[FlowConfig] = None,
254
+ freeze_z: bool = True,
255
+ device: str = "auto",
256
+ ):
257
+ _require_torch()
258
+ super().__init__()
259
+
260
+ if device == "auto":
261
+ device = "cuda" if torch.cuda.is_available() else "cpu"
262
+ self.device = torch.device(device)
263
+ self.n_genes = int(n_genes)
264
+ self.z_dim = int(z_dim)
265
+ self.freeze_z = bool(freeze_z)
266
+ self._cfg = cfg if cfg is not None else FlowConfig()
267
+
268
+ self.backbone = _GeneTransformer(
269
+ self.n_genes, d, depth, n_heads, self.z_dim, chunk_size, gradient_checkpoint
270
+ )
271
+ self.nb_head = _NBHead(d, self.n_genes)
272
+
273
+ # ★ 关键:每个 (cell, gene) 位置预测一个标量速度,out_features 必须是 1
274
+ self.vel_proj = nn.Linear(d, 1)
275
+ self.time_mlp = nn.Sequential(nn.Linear(128, d), nn.SiLU(), nn.Linear(d, d))
276
+
277
+ self._gene_ids = nn.Parameter(torch.arange(self.n_genes), requires_grad=False)
278
+
279
+ self.to(self.device)
280
+
281
+ # --------------------------------------------------------
282
+ # 时间编码
283
+ # --------------------------------------------------------
284
+ def _sincos(self, t: Tensor) -> Tensor:
285
+ half = 64
286
+ freqs = torch.exp(
287
+ -math.log(10000.0) * torch.arange(half, device=t.device) / (half - 1)
288
+ )
289
+ a = t[:, None] * freqs[None, :]
290
+ e = torch.cat([a.sin(), a.cos()], dim=-1)
291
+ if e.shape[-1] < 128:
292
+ e = F.pad(e, (0, 128 - e.shape[-1]))
293
+ return e
294
+
295
+ # --------------------------------------------------------
296
+ # z 隔离:freeze_z 时 detach;fit 入口额外 clone 成快照
297
+ # --------------------------------------------------------
298
+ def _maybe_detach(self, z: Tensor, *, snapshot: bool = False) -> Tensor:
299
+ if not self.freeze_z:
300
+ return z
301
+ z = z.detach()
302
+ return z.clone() if snapshot else z
303
+
304
+ # --------------------------------------------------------
305
+ # 速度场 v(x_t, t, z) -> [B, G]
306
+ # --------------------------------------------------------
307
+ def _velocity(self, x_t: Tensor, t: Tensor, z: Tensor) -> Tensor:
308
+ z = self._maybe_detach(z)
309
+ B, G = x_t.shape
310
+ t_emb = self.time_mlp(self._sincos(t)).unsqueeze(1) # [B,1,d]
311
+
312
+ # 明确 [B, G] 的 gene id 网格,避免广播维歧义
313
+ gids = self._gene_ids[:G].unsqueeze(0).expand(B, -1)
314
+ h = self.backbone(gids, z) + t_emb # [B,G,d]
315
+
316
+ v = self.vel_proj(h).squeeze(-1) # [B,G,1]->[B,G]
317
+
318
+ if v.ndim != 2 or v.shape != (B, G): # pragma: no cover
319
+ raise RuntimeError(
320
+ f"_velocity shape bug: got {tuple(v.shape)}, expected ({B},{G}). "
321
+ f"vel_proj 必须是 nn.Linear(d, 1),请重装 >=0.1.2 版本。"
322
+ )
323
+ return v
324
+
325
+ # --------------------------------------------------------
326
+ # 损失
327
+ # --------------------------------------------------------
328
+ def _loss(
329
+ self,
330
+ x: Tensor,
331
+ z: Tensor,
332
+ mask: Optional[Tensor] = None,
333
+ ) -> Tensor:
334
+ z = self._maybe_detach(z)
335
+ B = x.shape[0]
336
+ t = torch.rand(B, device=self.device).clamp(
337
+ self._cfg.sigma_min, 1.0 - self._cfg.sigma_min
338
+ )
339
+ eps = torch.randn_like(x)
340
+ x_t = x.lerp(eps, t.unsqueeze(1))
341
+
342
+ v_pred = self._velocity(x_t, t, z) # [B,G]
343
+ v_target = eps - x # [B,G],rectified-flow 目标
344
+ mse = F.mse_loss(v_pred, v_target, reduction="none")
345
+ if mask is not None:
346
+ mse = mse * mask
347
+ mse = mse.sum() / mask.sum().clamp(min=1.0)
348
+ else:
349
+ mse = mse.mean()
350
+
351
+ x0_hat = x_t.lerp(v_pred.detach(), t.unsqueeze(1))
352
+
353
+ nb = torch.tensor(0.0, device=self.device)
354
+ if self._cfg.nb_weight > 0:
355
+ gids = self._gene_ids[: x.shape[1]].unsqueeze(0).expand(B, -1)
356
+ log_mu, theta = self.nb_head(self.backbone(gids, z))
357
+ nb = self.nb_head.nll(log_mu, theta, x, mask)
358
+
359
+ corr = torch.tensor(0.0, device=self.device)
360
+ if self._cfg.corr_weight > 0 and mask is None:
361
+ corr = self._corr(x0_hat, x)
362
+
363
+ return mse + self._cfg.nb_weight * nb + self._cfg.corr_weight * corr
364
+
365
+ @staticmethod
366
+ def _corr(p: Tensor, q: Tensor) -> Tensor:
367
+ p = p - p.mean(1, keepdim=True)
368
+ q = q - q.mean(1, keepdim=True)
369
+ d = p.norm(dim=1) * q.norm(dim=1) + 1e-8
370
+ return (1 - (p * q).sum(dim=1) / d).mean()
371
+
372
+ # --------------------------------------------------------
373
+ # fit
374
+ # --------------------------------------------------------
375
+ @staticmethod
376
+ def _want_pin_memory(X: Tensor, z: Tensor) -> bool:
377
+ """pin_memory 只对 CPU tensor 有意义;cuda 输入直接关掉。"""
378
+
379
+ def on_gpu(a: Tensor) -> bool:
380
+ return isinstance(a, torch.Tensor) and a.device.type != "cpu"
381
+
382
+ return not (on_gpu(X) or on_gpu(z))
383
+
384
+ def fit(
385
+ self,
386
+ X: Array,
387
+ z: Array,
388
+ X_val: Optional[Array] = None,
389
+ z_val: Optional[Array] = None,
390
+ epochs: int = 200,
391
+ batch_size: int = 64,
392
+ lr: float = 2e-4,
393
+ weight_decay: float = 0.02,
394
+ patience: int = 20,
395
+ num_workers: int = 0,
396
+ verbose: int = 1,
397
+ ) -> "GeneFlowDecoder":
398
+ X = _as_tensor(X, self.device)
399
+ z = _as_tensor(z, self.device)
400
+ # ★ 快照:freeze_z 时拷出一份独立副本,训练期与上游 z 彻底解耦
401
+ z = self._maybe_detach(z, snapshot=True)
402
+
403
+ if X.ndim != 2 or z.ndim != 2:
404
+ raise ValueError(f"X/z 必须是 2D: got {X.ndim}D / {z.ndim}D")
405
+ if X.shape[0] != z.shape[0]:
406
+ raise ValueError(f"X 与 z 样本数不一致: {X.shape[0]} vs {z.shape[0]}")
407
+ if z.shape[1] != self.z_dim:
408
+ raise ValueError(f"z 特征维 {z.shape[1]} 与 z_dim={self.z_dim} 不符")
409
+ if X.shape[1] != self.n_genes:
410
+ raise ValueError(
411
+ f"X 基因数 {X.shape[1]} 与 n_genes={self.n_genes} 不符;"
412
+ f"请用实际 X.shape[1] 构造模型"
413
+ )
414
+
415
+ # ★ 先算 pin_memory,再断言(0.1.1 的顺序 bug 已修正)
416
+ pin_memory = self._want_pin_memory(X, z)
417
+ assert (
418
+ X.device.type == "cpu" or z.device.type == "cpu" or not pin_memory
419
+ ), "cuda 输入不应开启 pin_memory"
420
+
421
+ ds = torch.utils.data.TensorDataset(X, z)
422
+ dl = torch.utils.data.DataLoader(
423
+ ds,
424
+ batch_size=int(batch_size),
425
+ shuffle=True,
426
+ num_workers=int(num_workers),
427
+ pin_memory=pin_memory,
428
+ )
429
+
430
+ has_val = False
431
+ if X_val is not None and z_val is not None:
432
+ Xv = _as_tensor(X_val, self.device)
433
+ zv = self._maybe_detach(_as_tensor(z_val, self.device), snapshot=True)
434
+ if Xv.shape[1] != self.n_genes or zv.shape[1] != self.z_dim:
435
+ raise ValueError("验证集维度与训练集不一致")
436
+ has_val = True
437
+
438
+ params = [p for p in self.parameters() if p.requires_grad]
439
+ opt = torch.optim.AdamW(params, lr=lr, weight_decay=weight_decay)
440
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=int(epochs))
441
+
442
+ best_val = float("inf")
443
+ best_state: Optional[dict[str, Tensor]] = None
444
+ no_improve = 0
445
+
446
+ for ep in range(1, int(epochs) + 1):
447
+ self.train()
448
+ tot = 0.0
449
+ n_seen = 0
450
+ for xb, zb in dl:
451
+ opt.zero_grad()
452
+ loss = self._loss(xb, zb)
453
+ loss.backward()
454
+ nn.utils.clip_grad_norm_(params, 1.0)
455
+ opt.step()
456
+ tot += loss.item() * xb.shape[0]
457
+ n_seen += xb.shape[0]
458
+ sched.step()
459
+
460
+ if verbose:
461
+ msg = f"[fit] epoch {ep:3d}/{epochs} | train={tot / max(n_seen,1):.4f}"
462
+ if has_val:
463
+ self.eval()
464
+ with torch.no_grad():
465
+ vl = self._loss(Xv, zv).item()
466
+ msg += f" | val={vl:.4f}"
467
+ if vl < best_val - 1e-5:
468
+ best_val = vl
469
+ no_improve = 0
470
+ best_state = {
471
+ k: v.detach().cpu().clone()
472
+ for k, v in self.state_dict().items()
473
+ }
474
+ else:
475
+ no_improve += 1
476
+ if no_improve >= int(patience):
477
+ if verbose:
478
+ print(f"[fit] early stop at epoch {ep}")
479
+ break
480
+ print(msg)
481
+
482
+ if best_state is not None:
483
+ self.load_state_dict(best_state)
484
+ if verbose:
485
+ print(f"[fit] restored best val={best_val:.4f}")
486
+ return self
487
+
488
+ # --------------------------------------------------------
489
+ # 采样 / 确定性还原
490
+ # --------------------------------------------------------
491
+ @torch.no_grad()
492
+ def forward(
493
+ self,
494
+ z: Array,
495
+ steps: int = 4,
496
+ method: str = "midpoint",
497
+ deterministic: bool = False,
498
+ ) -> Tensor:
499
+ """
500
+ 给定 z 采样 X(log1p 空间)。
501
+ deterministic=True 等价于 predict();steps=4 通常够用。
502
+ """
503
+ z = self._maybe_detach(_as_tensor(z, self.device))
504
+ if deterministic:
505
+ return self.predict(z)
506
+
507
+ self.eval()
508
+ B = z.shape[0]
509
+ G = self.n_genes
510
+ x = torch.randn(B, G, device=self.device)
511
+ ts = torch.linspace(0.0, 1.0, int(steps) + 1, device=self.device)
512
+ for i in range(int(steps)):
513
+ t0, t1 = ts[i], ts[i + 1]
514
+ tmid = (t0 + t1) / 2
515
+ dt = float(t1 - t0)
516
+ v = self._velocity(x, torch.full((B,), t0, device=self.device), z)
517
+ if method == "midpoint":
518
+ xm = x + 0.5 * dt * v
519
+ v = self._velocity(xm, torch.full((B,), tmid, device=self.device), z)
520
+ x = x + dt * v
521
+ return x
522
+
523
+ @torch.no_grad()
524
+ def predict(self, z: Array) -> Tensor:
525
+ """确定性还原:NB 均值(log1p 空间)。伪计数用 (exp(log_mu)-1).clamp(min=0)。"""
526
+ z = self._maybe_detach(_as_tensor(z, self.device))
527
+ self.eval()
528
+ B = z.shape[0]
529
+ gids = self._gene_ids[:self.n_genes].unsqueeze(0).expand(B, -1)
530
+ log_mu, _ = self.nb_head(self.backbone(gids, z))
531
+ return log_mu
532
+
533
+ # --------------------------------------------------------
534
+ # 持久化
535
+ # --------------------------------------------------------
536
+ def save(self, path: str) -> None:
537
+ torch.save(
538
+ {"state": self.state_dict(), "cfg": self._cfg, "freeze_z": self.freeze_z},
539
+ path,
540
+ )
541
+
542
+ @classmethod
543
+ def load(cls, path: str, **init_kwargs: Any) -> "GeneFlowDecoder":
544
+ ckpt = torch.load(path, map_location="cpu")
545
+ init_kwargs.setdefault("freeze_z", bool(ckpt.get("freeze_z", True)))
546
+ model = cls(**init_kwargs)
547
+ model._cfg = ckpt.get("cfg", model._cfg)
548
+ model.load_state_dict(ckpt["state"])
549
+ return model
@@ -1,2 +1,2 @@
1
1
  # -*- coding: utf-8 -*-
2
- __version__ = "0.1.4"
2
+ __version__ = "0.1.6"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: geneflowdecoder
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Gene Transformer + Conditional Rectified Flow decoder from cell-state z to log1p expression X.
5
5
  Author: GeneFlowDecoder Contributors
6
6
  License: MIT
@@ -1,679 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- """
3
- GeneFlowDecoder
4
- ===============
5
-
6
- z (fixed cell-state representation) --> X (log-normalized expression, log1p space)
7
-
8
- Public API
9
- ----------
10
- model = GeneFlowDecoder(n_genes=2048, z_dim=256)
11
- model.fit(X, z) # train
12
- X_hat = model(z) # sample (generative)
13
- X_hat = model.predict(z) # deterministic (NB mean)
14
-
15
- This module is framework-agnostic beyond ``torch`` and ``numpy``.
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import math
21
- from dataclasses import dataclass, field
22
- from typing import Optional, Tuple, Union
23
-
24
- import numpy as np
25
-
26
- try:
27
- import torch
28
- import torch.nn as nn
29
- import torch.nn.functional as F
30
- _no_grad = torch.no_grad
31
- _TORCH_AVAILABLE = True
32
- except Exception: # pragma: no cover - soft dep for import-only environments
33
- import types
34
-
35
- torch = types.ModuleType("torch")
36
- nn = types.ModuleType("torch.nn")
37
- F = types.ModuleType("torch.nn.functional")
38
- class _NoGrad:
39
- """无 torch 时充当 ``@torch.no_grad()`` 与 ``with torch.no_grad():`` 的透明桩。"""
40
-
41
- def __call__(self, fn=None):
42
- return fn if fn is not None else self
43
-
44
- def __enter__(self):
45
- return self
46
-
47
- def __exit__(self, *args):
48
- pass
49
-
50
- _no_grad = _NoGrad()
51
- _TORCH_AVAILABLE = False
52
-
53
- class _Device(str):
54
- """``torch.device(device)`` 的桩:保留字符串语义与 ``.type`` 属性。"""
55
-
56
- def __new__(cls, dev):
57
- dev = str(dev)
58
- if dev.startswith("cuda"):
59
- dev = "cuda"
60
- return super().__new__(cls, dev)
61
-
62
- @property
63
- def type(self):
64
- return str(self)
65
-
66
-
67
- # 让 ``class Foo(nn.Module)`` 与 ``Tensor`` 注解在无 torch 时仍能解析。
68
- # 运行时真正调用前向/构造模型会立即报清晰的错误,而不是在 import 期崩。
69
- class _ModuleStub:
70
- def __init_subclass__(cls, **kwargs):
71
- pass
72
-
73
- nn.Module = _ModuleStub # type: ignore[attr-defined]
74
-
75
- # 补齐 GeneFlowDecoder 类体里会用到的 torch 属性,保持 import 期可解析
76
- torch.Tensor = object # type: ignore[attr-defined]
77
- torch.no_grad = _no_grad # 即 _NoGrad() 实例 # type: ignore[attr-defined]
78
- torch.device = _Device # type: ignore[attr-defined]
79
- torch.cuda = types.ModuleType("torch.cuda")
80
- torch.cuda.is_available = lambda: False
81
- torch.utils = types.ModuleType("torch.utils")
82
- torch.utils.data = types.ModuleType("torch.utils.data")
83
- torch.utils.data.TensorDataset = object
84
- torch.utils.data.DataLoader = object
85
- Tensor = object # placeholder for type annotations only
86
- else:
87
- try:
88
- from torch import Tensor as _TorchTensor # noqa: F401
89
- Tensor = _TorchTensor # type: ignore
90
- except Exception:
91
- Tensor = object # placeholder for type annotations only
92
-
93
- Array = Union[np.ndarray, Tensor] # type: ignore[valid-type]
94
-
95
-
96
- def _as_tensor(x: Array, device: "torch.device") -> "torch.Tensor":
97
- """把 numpy / CPU / GPU 输入规范化成 torch 张量。
98
-
99
- 关键:输入**已经**在 GPU 上时,保留其原设备,不再 ``.to(device)``。
100
- 否则 cuda 输入会被拷回 CPU,DataLoader 的 pin_memory 又会对它报错:
101
- RuntimeError: cannot pin 'torch.cuda.FloatTensor'
102
- """
103
- if not _TORCH_AVAILABLE:
104
- raise RuntimeError(
105
- "GeneFlowDecoder requires PyTorch, but it could not be imported. "
106
- "Install it with: pip install 'torch>=2.1'"
107
- )
108
- if isinstance(x, np.ndarray):
109
- return torch.as_tensor(x, device=device) # CPU numpy -> CPU tensor
110
- if x.device.type != "cpu":
111
- return x # 已在 GPU:原样返回
112
- return x.to(device, non_blocking=True)
113
-
114
-
115
- def _is_cuda_array(x: Array) -> bool:
116
- """判断输入是否已经落在 GPU 上。
117
-
118
- numpy 数组一定在 CPU;torch 张量则可能已在 cuda。DataLoader 的
119
- ``pin_memory`` 只接受 CPU tensor,输入已经是 cuda 时必须降级为 False,
120
- 否则会在 ``pin_memory()`` 处抛出
121
- ``RuntimeError: cannot pin 'torch.cuda.FloatTensor'``。
122
-
123
- 注意:无 torch 时 ``torch.Tensor`` 只是一个占位桩(object),此时 numpy
124
- 数组不该被当成 Tensor,故先用 ``not isinstance(x, (np.ndarray, ...))``
125
- 兜底,保证纯 numpy 输入永远返回 False(即开启 pin_memory)。
126
- """
127
- if isinstance(x, np.ndarray):
128
- return False
129
- try:
130
- return isinstance(x, torch.Tensor) and x.device.type != "cpu"
131
- except Exception:
132
- return False
133
-
134
-
135
- def _want_pin_memory(X: Array, z: Array) -> bool:
136
- """输入全在 CPU 时开启 pin_memory 加速;任一在 GPU 则关闭。
137
-
138
- numpy 输入一律视为 CPU 数据,走加速路径;只有在 torch 可用且输入
139
- device 为 cuda 时才关闭。
140
- """
141
- if isinstance(X, np.ndarray) and isinstance(z, np.ndarray):
142
- return True
143
- return not (_is_cuda_array(X) or _is_cuda_array(z))
144
-
145
-
146
- # ----------------------------------------------------------------------
147
- # 1. Conditional modulation & transformer blocks
148
- # ----------------------------------------------------------------------
149
-
150
- class _AdaLNModulation(nn.Module):
151
- def __init__(self, d: int, cond_dim: int):
152
- super().__init__()
153
- self.norm = nn.LayerNorm(d, elementwise_affine=False, eps=1e-6)
154
- self.proj = nn.Sequential(
155
- nn.Linear(cond_dim, d), nn.SiLU(), nn.Linear(d, 3 * d)
156
- )
157
- nn.init.zeros_(self.proj[-1].weight)
158
- nn.init.zeros_(self.proj[-1].bias)
159
-
160
- def forward(self, x: torch.Tensor, z: torch.Tensor) -> torch.Tensor:
161
- g, b, gate = self.proj(z).chunk(3, dim=-1)
162
- g, b, gate = g.unsqueeze(1), b.unsqueeze(1), gate.unsqueeze(1).sigmoid()
163
- return gate * (self.norm(x) * (1 + g) + b)
164
-
165
-
166
- class _CondBlock(nn.Module):
167
- def __init__(self, d: int, cond_dim: int, n_heads: int, mlp_ratio: float = 4.0):
168
- super().__init__()
169
- self.attn_norm = _AdaLNModulation(d, cond_dim)
170
- self.attn = nn.MultiheadAttention(d, n_heads, batch_first=True)
171
- self.mlp_norm = _AdaLNModulation(d, cond_dim)
172
- h = int(d * mlp_ratio)
173
- self.mlp = nn.Sequential(nn.Linear(d, h), nn.GELU(), nn.Linear(h, d))
174
-
175
- def forward(self, x: torch.Tensor, z: torch.Tensor) -> torch.Tensor:
176
- h = self.attn_norm(x, z)
177
- h, _ = self.attn(h, h, h, need_weights=False)
178
- x = x + h
179
- return x + self.mlp(self.mlp_norm(x, z))
180
-
181
-
182
- class _GeneTransformer(nn.Module):
183
- def __init__(
184
- self,
185
- n_genes: int,
186
- d: int,
187
- depth: int,
188
- n_heads: int,
189
- cond_dim: int,
190
- chunk_size: int,
191
- ckpt: bool,
192
- ):
193
- super().__init__()
194
- self.d = d
195
- self.n_genes = n_genes
196
- self.chunk_size = chunk_size
197
- self.gene_embed = nn.Embedding(n_genes, d)
198
- nn.init.normal_(self.gene_embed.weight, std=0.02)
199
- self.pos_embed = nn.Parameter(torch.zeros(1, n_genes, d))
200
- nn.init.normal_(self.pos_embed, std=0.02)
201
-
202
- self.blocks = nn.ModuleList(
203
- [_CondBlock(d, cond_dim, n_heads) for _ in range(depth)]
204
- )
205
- self.head_norm = nn.LayerNorm(d, eps=1e-6)
206
- self._ckpt = ckpt
207
-
208
- def _run(self, x: torch.Tensor, z: torch.Tensor) -> torch.Tensor:
209
- for blk in self.blocks:
210
- if self._ckpt and self.training:
211
- x = torch.utils.checkpoint.checkpoint(
212
- blk, x, z, use_reentrant=False
213
- )
214
- else:
215
- x = blk(x, z)
216
- return self.head_norm(x)
217
-
218
- def forward(self, gene_ids: torch.Tensor, z: torch.Tensor) -> torch.Tensor:
219
- B, G = gene_ids.shape[0], gene_ids.shape[-1]
220
- if G <= self.chunk_size:
221
- h = self.gene_embed(gene_ids) + self.pos_embed[:, :G, :]
222
- return self._run(h, z)
223
-
224
- outs: list[torch.Tensor] = []
225
- s = 0
226
- while s < G:
227
- e = min(s + self.chunk_size, G)
228
- h = self.gene_embed(gene_ids[..., s:e]) + self.pos_embed[:, s:e, :]
229
- outs.append(self._run(h, z))
230
- s = e
231
- return torch.cat(outs, dim=1)
232
-
233
-
234
- # ----------------------------------------------------------------------
235
- # 2. Negative-binomial output head
236
- # ----------------------------------------------------------------------
237
-
238
- class _NBHead(nn.Module):
239
- def __init__(self, d: int, n_genes: int):
240
- super().__init__()
241
- self.mu_proj = nn.Linear(d, n_genes)
242
- self.theta = nn.Parameter(torch.zeros(n_genes))
243
- nn.init.zeros_(self.mu_proj.weight)
244
- nn.init.zeros_(self.mu_proj.bias)
245
-
246
- def forward(self, h: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
247
- return self.mu_proj(h), F.softplus(self.theta) + 1e-3
248
-
249
- @staticmethod
250
- def nll(
251
- log_mu: torch.Tensor,
252
- theta: torch.Tensor,
253
- x: torch.Tensor,
254
- mask: Optional[torch.Tensor] = None,
255
- ) -> torch.Tensor:
256
- mu = log_mu.exp().clamp(min=1e-6)
257
- th = theta.unsqueeze(0).expand_as(x)
258
- ll = (
259
- torch.lgamma(x + th)
260
- - torch.lgamma(th)
261
- - torch.lgamma(x + 1)
262
- + th * (th.log() - (th + mu).log())
263
- + x * (mu.log() - (th + mu).log())
264
- )
265
- if mask is not None:
266
- ll = ll * mask
267
- return -ll.sum() / mask.sum().clamp(min=1.0)
268
- return -ll.mean()
269
-
270
-
271
- @dataclass
272
- class FlowConfig:
273
- """Hyper-parameters of the conditional flow matching objective."""
274
-
275
- sigma_min: float = 1e-4
276
- nb_weight: float = 1.0
277
- corr_weight: float = 0.1
278
-
279
-
280
- # ----------------------------------------------------------------------
281
- # 3. GeneFlowDecoder
282
- # ----------------------------------------------------------------------
283
-
284
- class GeneFlowDecoder(nn.Module):
285
- """
286
- Conditional Rectified-Flow decoder from a **fixed** cell-state vector *z*
287
- to a log1p expression profile *X*.
288
-
289
- Parameters
290
- ----------
291
- n_genes : int
292
- Number of genes (output dimension).
293
- z_dim : int
294
- Dimensionality of the cell-state representation.
295
- d, depth, n_heads : int
296
- Transformer width, number of layers, number of attention heads.
297
- chunk_size : int
298
- Chunk size for the gene axis (enables >10k gene panels on one GPU).
299
- gradient_checkpoint : bool
300
- Enable activation checkpointing inside the transformer.
301
- cfg : FlowConfig | None
302
- Flow / loss hyper-parameters.
303
- freeze_z : bool, default ``True``
304
- When ``True`` (recommended), *z* is detached everywhere: it is treated
305
- as a fixed, non-trainable conditioning signal and is never updated.
306
- device : ``"auto"`` | ``str`` | ``torch.device``
307
- Where to place the model. ``"auto"`` picks CUDA if available.
308
-
309
- Examples
310
- --------
311
- >>> import numpy as np
312
- >>> from geneflowdecoder import GeneFlowDecoder
313
- >>> G, D = 2048, 128
314
- >>> model = GeneFlowDecoder(n_genes=G, z_dim=D, d=512, depth=8)
315
- >>> X = np.random.randn(1024, G).astype("float32")
316
- >>> z = np.random.randn(1024, D).astype("float32")
317
- >>> model.fit(X, z, epochs=10, batch_size=64)
318
- >>> X_hat = model(z[:4]) # sampled, log1p space
319
- >>> X_rec = model.predict(z[:4]) # deterministic NB mean
320
- """
321
-
322
- def __init__(
323
- self,
324
- n_genes: int,
325
- z_dim: int,
326
- *,
327
- d: int = 768,
328
- depth: int = 16,
329
- n_heads: int = 12,
330
- chunk_size: int = 2048,
331
- gradient_checkpoint: bool = True,
332
- cfg: Optional[FlowConfig] = None,
333
- freeze_z: bool = True,
334
- device: str = "auto",
335
- ):
336
- super().__init__()
337
- if not _TORCH_AVAILABLE:
338
- raise RuntimeError(
339
- "GeneFlowDecoder requires PyTorch, but it could not be imported. "
340
- "Install it with: pip install 'torch>=2.1'"
341
- )
342
- if device == "auto":
343
- device = "cuda" if torch.cuda.is_available() else "cpu"
344
- self.device = torch.device(device)
345
- self.n_genes = int(n_genes)
346
- self.z_dim = int(z_dim)
347
- self.freeze_z = bool(freeze_z)
348
- self._cfg = cfg or FlowConfig()
349
-
350
- self.backbone = _GeneTransformer(
351
- n_genes, d, depth, n_heads, z_dim, chunk_size, gradient_checkpoint
352
- )
353
- self.nb_head = _NBHead(d, n_genes)
354
- self.vel_proj = nn.Linear(d, n_genes)
355
- self.time_mlp = nn.Sequential(
356
- nn.Linear(128, d), nn.SiLU(), nn.Linear(d, d)
357
- )
358
- self._gene_ids = nn.Parameter(
359
- torch.arange(self.n_genes), requires_grad=False
360
- )
361
- self.to(self.device)
362
-
363
- # ----------------------------------------------------------------
364
- # internals
365
- # ----------------------------------------------------------------
366
- def _sincos(self, t: torch.Tensor) -> torch.Tensor:
367
- half = 64
368
- freqs = torch.exp(
369
- -math.log(10000.0) * torch.arange(half, device=t.device) / (half - 1)
370
- )
371
- a = t[:, None] * freqs[None, :]
372
- e = torch.cat([a.sin(), a.cos()], dim=-1)
373
- if e.shape[-1] < 128:
374
- e = F.pad(e, (0, 128 - e.shape[-1]))
375
- return e
376
-
377
- def _maybe_detach(
378
- self, z: torch.Tensor, *, snapshot: bool = False
379
- ) -> torch.Tensor:
380
- """冻结 z 时截断计算图;``snapshot=True`` 再额外拷一份独立副本。
381
-
382
- snapshot 只在 fit 入口用:把当轮 z 固化下来,之后外部 encoder
383
- 若继续 backward / 原地更新,训练集 z 的数值也不会跟着变。
384
- 单次推理(forward / predict)不开 snapshot,省一次显存拷贝。
385
- """
386
- if not self.freeze_z:
387
- return z
388
- z = z.detach()
389
- return z.clone() if snapshot else z
390
-
391
- def _velocity(self, x_t: torch.Tensor, t: torch.Tensor,
392
- z: torch.Tensor) -> torch.Tensor:
393
- if self.freeze_z:
394
- z = z.detach()
395
- B, G = x_t.shape
396
- t_emb = self.time_mlp(self._sincos(t)).unsqueeze(1) # [B,1,d]
397
- gids = self._gene_ids[:G].unsqueeze(0).expand(B, -1) # [B,G]
398
- h = self.backbone(gids, z) + t_emb # [B,G,d]
399
-
400
- # ★ 关键:只对特征维 d 做线性,结果天然是 [B, G]
401
- v = self.vel_proj(h.squeeze(-2) if h.ndim == 2 else h) # [B,G,d]→[B,G]
402
- if v.ndim == 3:
403
- v = v.squeeze(-1) # [B,G,1]→[B,G]
404
- if v.ndim != 2 or v.shape != (B, G):
405
- raise RuntimeError(
406
- f"_velocity shape bug: got {tuple(v.shape)}, expected ({B},{G}). "
407
- f"检查 vel_proj 是否为 nn.Linear(d, 1) 而非 nn.Linear(d, n_genes)"
408
- )
409
- return v
410
-
411
- # ----------------------------------------------------------------
412
- # objective
413
- # ----------------------------------------------------------------
414
- def _loss(
415
- self,
416
- x: torch.Tensor,
417
- z: torch.Tensor,
418
- mask: Optional[torch.Tensor] = None,
419
- ) -> torch.Tensor:
420
- z = self._maybe_detach(z)
421
- B = x.shape[0]
422
- t = torch.rand(B, device=self.device).clamp(
423
- self._cfg.sigma_min, 1.0 - self._cfg.sigma_min
424
- )
425
- eps = torch.randn_like(x)
426
- x_t = x.lerp(eps, t.unsqueeze(1))
427
-
428
- v_pred = self._velocity(x_t, t, z)
429
- v_target = eps - x # rectified-flow target
430
- mse = F.mse_loss(v_pred, v_target, reduction="none")
431
- if mask is not None:
432
- mse = mse * mask
433
- mse = mse.sum() / mask.sum().clamp(min=1.0)
434
- else:
435
- mse = mse.mean()
436
-
437
- x0_hat = x_t.lerp(v_pred.detach(), t.unsqueeze(1))
438
-
439
- nb = torch.tensor(0.0, device=self.device)
440
- if self._cfg.nb_weight > 0:
441
- gids = self._gene_ids.unsqueeze(0).expand(B, -1)
442
- log_mu, theta = self.nb_head(self.backbone(gids, z))
443
- nb = self.nb_head.nll(log_mu, theta, x, mask)
444
-
445
- corr = torch.tensor(0.0, device=self.device)
446
- if self._cfg.corr_weight > 0 and mask is None:
447
- corr = self._corr(x0_hat, x)
448
-
449
- return (
450
- mse
451
- + self._cfg.nb_weight * nb
452
- + self._cfg.corr_weight * corr
453
- )
454
-
455
- @staticmethod
456
- def _corr(p: torch.Tensor, q: torch.Tensor) -> torch.Tensor:
457
- p = p - p.mean(1, keepdim=True)
458
- q = q - q.mean(1, keepdim=True)
459
- denom = p.norm(dim=1) * q.norm(dim=1) + 1e-8
460
- return (1 - (p * q).sum(dim=1) / denom).mean()
461
-
462
- # ----------------------------------------------------------------
463
- # fit
464
- # ----------------------------------------------------------------
465
- def fit(
466
- self,
467
- X: Array,
468
- z: Array,
469
- *,
470
- X_val: Optional[Array] = None,
471
- z_val: Optional[Array] = None,
472
- epochs: int = 200,
473
- batch_size: int = 64,
474
- lr: float = 2e-4,
475
- weight_decay: float = 0.02,
476
- patience: int = 20,
477
- num_workers: int = 0,
478
- verbose: int = 1,
479
- ) -> "GeneFlowDecoder":
480
- """
481
- Train the decoder.
482
-
483
- Parameters
484
- ----------
485
- X : array, shape ``(N, n_genes)``
486
- Log1p-normalized expression matrix.
487
- z : array, shape ``(N, z_dim)``
488
- Cell-state vectors. **Never updated** when ``freeze_z=True``.
489
- X_val, z_val : arrays, optional
490
- Validation split. If given, early stopping is enabled and the
491
- best weights (lowest validation loss) are restored at the end.
492
- epochs, batch_size, lr, weight_decay, patience
493
- Standard optimisation knobs.
494
- num_workers : int
495
- Workers for the training DataLoader.
496
- verbose : int
497
- 0 = silent, 1 = epoch logs.
498
-
499
- Returns
500
- -------
501
- self
502
- """
503
- X = _as_tensor(X, self.device)
504
- z = self._maybe_detach(_as_tensor(z, self.device), snapshot=True)
505
-
506
- # pin_memory 要求 CPU tensor;若 X/z 已在 GPU(常见于 scVI / mudata
507
- # 把 .X 或 obsm 留在 cuda 的情况),强制关闭,避免
508
- # RuntimeError: cannot pin 'torch.cuda.FloatTensor'
509
- # 注:_as_tensor 对 cuda 输入原样返回,因此这里 X/z 仍在 GPU。
510
- pin_memory = _want_pin_memory(X, z)
511
- assert X.device.type == "cpu" or z.device.type == "cpu" or not pin_memory
512
-
513
- ds = torch.utils.data.TensorDataset(X, z)
514
- dl = torch.utils.data.DataLoader(
515
- ds,
516
- batch_size=int(batch_size),
517
- shuffle=True,
518
- num_workers=int(num_workers),
519
- pin_memory=pin_memory,
520
- )
521
-
522
- has_val = False
523
- if X_val is not None and z_val is not None:
524
- Xv = _as_tensor(X_val, self.device)
525
- zv = self._maybe_detach(_as_tensor(z_val, self.device), snapshot=True)
526
- has_val = True
527
-
528
- # Only decoder-owned parameters are optimised.
529
- params = [p for p in self.parameters() if p.requires_grad]
530
- opt = torch.optim.AdamW(params, lr=lr, weight_decay=weight_decay)
531
- sched = torch.optim.lr_scheduler.CosineAnnealingLR(
532
- opt, T_max=int(epochs)
533
- )
534
-
535
- best_val = float("inf")
536
- best_state: Optional[dict[str, torch.Tensor]] = None
537
- no_improve = 0
538
-
539
- for ep in range(1, int(epochs) + 1):
540
- self.train()
541
- tot = 0.0
542
- for xb, zb in dl:
543
- opt.zero_grad()
544
- loss = self._loss(xb, zb)
545
- loss.backward()
546
- nn.utils.clip_grad_norm_(params, 1.0)
547
- opt.step()
548
- tot += loss.item() * xb.shape[0]
549
- sched.step()
550
-
551
- if verbose:
552
- msg = (
553
- f"[fit] epoch {ep:3d}/{epochs} "
554
- f"| train={tot / len(ds):.4f}"
555
- )
556
- if has_val:
557
- self.eval()
558
- with torch.no_grad():
559
- vl = self._loss(Xv, zv).item()
560
- msg += f" | val={vl:.4f}"
561
- if vl < best_val - 1e-5:
562
- best_val, no_improve = vl, 0
563
- best_state = {
564
- k: v.detach().cpu().clone()
565
- for k, v in self.state_dict().items()
566
- }
567
- else:
568
- no_improve += 1
569
- if no_improve >= int(patience):
570
- if verbose:
571
- print(f"[fit] early stop at epoch {ep}")
572
- break
573
- print(msg)
574
-
575
- if best_state is not None:
576
- self.load_state_dict(best_state)
577
- if verbose:
578
- print(f"[fit] restored best val={best_val:.4f}")
579
- return self
580
-
581
- # ----------------------------------------------------------------
582
- # inference
583
- # ----------------------------------------------------------------
584
- @torch.no_grad()
585
- def forward(
586
- self,
587
- z: Array,
588
- *,
589
- steps: int = 4,
590
- method: str = "midpoint",
591
- deterministic: bool = False,
592
- ) -> torch.Tensor:
593
- """
594
- Sample an expression profile from *z* (log1p space).
595
-
596
- Parameters
597
- ----------
598
- z : array, shape ``(B, z_dim)``
599
- steps : int
600
- Number of flow-integration steps (4 is usually enough).
601
- method : ``"midpoint"`` | ``"euler"``
602
- deterministic : bool
603
- If ``True``, returns the deterministic NB mean
604
- (equivalent to ``self.predict(z)``).
605
- """
606
- z = self._maybe_detach(_as_tensor(z, self.device))
607
- if deterministic:
608
- return self.predict(z)
609
-
610
- self.eval()
611
- B = z.shape[0]
612
- x = torch.randn(B, self.n_genes, device=self.device)
613
- ts = torch.linspace(0.0, 1.0, int(steps) + 1, device=self.device)
614
- for i in range(int(steps)):
615
- t0, t1 = ts[i], ts[i + 1]
616
- tmid = (t0 + t1) / 2
617
- dt = (t1 - t0).item()
618
- v = self._velocity(x, torch.full((B,), t0, device=self.device), z)
619
- if method == "midpoint":
620
- xm = x + 0.5 * dt * v
621
- v = self._velocity(
622
- xm, torch.full((B,), tmid, device=self.device), z
623
- )
624
- x = x + dt * v
625
- return x
626
-
627
- @torch.no_grad()
628
- def predict(self, z: Array) -> torch.Tensor:
629
- """
630
- Deterministic reconstruction: the NB mean in log1p space.
631
-
632
- Convert to pseudo-counts with ``(log_mu.exp() - 1).clamp(min=0)``.
633
- """
634
- z = self._maybe_detach(_as_tensor(z, self.device))
635
- self.eval()
636
- B = z.shape[0]
637
- gids = self._gene_ids.unsqueeze(0).expand(B, -1)
638
- log_mu, _ = self.nb_head(self.backbone(gids, z))
639
- return log_mu
640
-
641
- # ----------------------------------------------------------------
642
- # persistence
643
- # ----------------------------------------------------------------
644
- def save(self, path: str) -> None:
645
- """Save model weights + config to ``path``."""
646
- torch.save(
647
- {
648
- "state": self.state_dict(),
649
- "cfg": self._cfg,
650
- "freeze_z": self.freeze_z,
651
- "n_genes": self.n_genes,
652
- "z_dim": self.z_dim,
653
- },
654
- path,
655
- )
656
-
657
- @classmethod
658
- def load(
659
- cls,
660
- path: str,
661
- *,
662
- device: str = "auto",
663
- **init_kwargs,
664
- ) -> "GeneFlowDecoder":
665
- """
666
- Load a saved model.
667
-
668
- ``n_genes`` / ``z_dim`` / ``freeze_z`` are read from the checkpoint
669
- unless explicitly overridden in ``init_kwargs``.
670
- """
671
- ckpt = torch.load(path, map_location="cpu")
672
- init_kwargs.setdefault("n_genes", ckpt.get("n_genes"))
673
- init_kwargs.setdefault("z_dim", ckpt.get("z_dim"))
674
- init_kwargs.setdefault("freeze_z", ckpt.get("freeze_z", True))
675
- init_kwargs["device"] = device
676
- model = cls(**init_kwargs)
677
- model._cfg = ckpt.get("cfg", model._cfg)
678
- model.load_state_dict(ckpt["state"])
679
- return model
File without changes