geneflowdecoder 0.1.3__tar.gz → 0.1.5__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.
- {geneflowdecoder-0.1.3/src/geneflowdecoder.egg-info → geneflowdecoder-0.1.5}/PKG-INFO +1 -1
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/pyproject.toml +1 -1
- geneflowdecoder-0.1.5/src/geneflowdecoder/_core.py +558 -0
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/src/geneflowdecoder/_version.py +1 -1
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5/src/geneflowdecoder.egg-info}/PKG-INFO +1 -1
- geneflowdecoder-0.1.3/src/geneflowdecoder/_core.py +0 -671
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/LICENSE +0 -0
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/README.md +0 -0
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/setup.cfg +0 -0
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/src/geneflowdecoder/__init__.py +0 -0
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/src/geneflowdecoder.egg-info/SOURCES.txt +0 -0
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/src/geneflowdecoder.egg-info/dependency_links.txt +0 -0
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/src/geneflowdecoder.egg-info/requires.txt +0 -0
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/src/geneflowdecoder.egg-info/top_level.txt +0 -0
- {geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/tests/test_api.py +0 -0
|
@@ -0,0 +1,558 @@
|
|
|
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
|
+
# 构造期自检:shape 链断在这里,而不是 136 个 batch 之后
|
|
280
|
+
with torch.no_grad():
|
|
281
|
+
_gdummy = self.vel_proj(torch.zeros(2, min(self.n_genes, 8), d))
|
|
282
|
+
if _gdummy.shape != (2, min(self.n_genes, 8)):
|
|
283
|
+
raise RuntimeError(
|
|
284
|
+
f"vel_proj 配置错误: 输出 {tuple(_gdummy.shape)}, "
|
|
285
|
+
f"期望 (2, {min(self.n_genes, 8)})。vel_proj 必须是 nn.Linear(d, 1)"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
self.to(self.device)
|
|
289
|
+
|
|
290
|
+
# --------------------------------------------------------
|
|
291
|
+
# 时间编码
|
|
292
|
+
# --------------------------------------------------------
|
|
293
|
+
def _sincos(self, t: Tensor) -> Tensor:
|
|
294
|
+
half = 64
|
|
295
|
+
freqs = torch.exp(
|
|
296
|
+
-math.log(10000.0) * torch.arange(half, device=t.device) / (half - 1)
|
|
297
|
+
)
|
|
298
|
+
a = t[:, None] * freqs[None, :]
|
|
299
|
+
e = torch.cat([a.sin(), a.cos()], dim=-1)
|
|
300
|
+
if e.shape[-1] < 128:
|
|
301
|
+
e = F.pad(e, (0, 128 - e.shape[-1]))
|
|
302
|
+
return e
|
|
303
|
+
|
|
304
|
+
# --------------------------------------------------------
|
|
305
|
+
# z 隔离:freeze_z 时 detach;fit 入口额外 clone 成快照
|
|
306
|
+
# --------------------------------------------------------
|
|
307
|
+
def _maybe_detach(self, z: Tensor, *, snapshot: bool = False) -> Tensor:
|
|
308
|
+
if not self.freeze_z:
|
|
309
|
+
return z
|
|
310
|
+
z = z.detach()
|
|
311
|
+
return z.clone() if snapshot else z
|
|
312
|
+
|
|
313
|
+
# --------------------------------------------------------
|
|
314
|
+
# 速度场 v(x_t, t, z) -> [B, G]
|
|
315
|
+
# --------------------------------------------------------
|
|
316
|
+
def _velocity(self, x_t: Tensor, t: Tensor, z: Tensor) -> Tensor:
|
|
317
|
+
z = self._maybe_detach(z)
|
|
318
|
+
B, G = x_t.shape
|
|
319
|
+
t_emb = self.time_mlp(self._sincos(t)).unsqueeze(1) # [B,1,d]
|
|
320
|
+
|
|
321
|
+
# 明确 [B, G] 的 gene id 网格,避免广播维歧义
|
|
322
|
+
gids = self._gene_ids[:G].unsqueeze(0).expand(B, -1)
|
|
323
|
+
h = self.backbone(gids, z) + t_emb # [B,G,d]
|
|
324
|
+
|
|
325
|
+
v = self.vel_proj(h).squeeze(-1) # [B,G,1]->[B,G]
|
|
326
|
+
|
|
327
|
+
if v.ndim != 2 or v.shape != (B, G): # pragma: no cover
|
|
328
|
+
raise RuntimeError(
|
|
329
|
+
f"_velocity shape bug: got {tuple(v.shape)}, expected ({B},{G}). "
|
|
330
|
+
f"vel_proj 必须是 nn.Linear(d, 1),请重装 >=0.1.2 版本。"
|
|
331
|
+
)
|
|
332
|
+
return v
|
|
333
|
+
|
|
334
|
+
# --------------------------------------------------------
|
|
335
|
+
# 损失
|
|
336
|
+
# --------------------------------------------------------
|
|
337
|
+
def _loss(
|
|
338
|
+
self,
|
|
339
|
+
x: Tensor,
|
|
340
|
+
z: Tensor,
|
|
341
|
+
mask: Optional[Tensor] = None,
|
|
342
|
+
) -> Tensor:
|
|
343
|
+
z = self._maybe_detach(z)
|
|
344
|
+
B = x.shape[0]
|
|
345
|
+
t = torch.rand(B, device=self.device).clamp(
|
|
346
|
+
self._cfg.sigma_min, 1.0 - self._cfg.sigma_min
|
|
347
|
+
)
|
|
348
|
+
eps = torch.randn_like(x)
|
|
349
|
+
x_t = x.lerp(eps, t.unsqueeze(1))
|
|
350
|
+
|
|
351
|
+
v_pred = self._velocity(x_t, t, z) # [B,G]
|
|
352
|
+
v_target = eps - x # [B,G],rectified-flow 目标
|
|
353
|
+
mse = F.mse_loss(v_pred, v_target, reduction="none")
|
|
354
|
+
if mask is not None:
|
|
355
|
+
mse = mse * mask
|
|
356
|
+
mse = mse.sum() / mask.sum().clamp(min=1.0)
|
|
357
|
+
else:
|
|
358
|
+
mse = mse.mean()
|
|
359
|
+
|
|
360
|
+
x0_hat = x_t.lerp(v_pred.detach(), t.unsqueeze(1))
|
|
361
|
+
|
|
362
|
+
nb = torch.tensor(0.0, device=self.device)
|
|
363
|
+
if self._cfg.nb_weight > 0:
|
|
364
|
+
gids = self._gene_ids[: x.shape[1]].unsqueeze(0).expand(B, -1)
|
|
365
|
+
log_mu, theta = self.nb_head(self.backbone(gids, z))
|
|
366
|
+
nb = self.nb_head.nll(log_mu, theta, x, mask)
|
|
367
|
+
|
|
368
|
+
corr = torch.tensor(0.0, device=self.device)
|
|
369
|
+
if self._cfg.corr_weight > 0 and mask is None:
|
|
370
|
+
corr = self._corr(x0_hat, x)
|
|
371
|
+
|
|
372
|
+
return mse + self._cfg.nb_weight * nb + self._cfg.corr_weight * corr
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _corr(p: Tensor, q: Tensor) -> Tensor:
|
|
376
|
+
p = p - p.mean(1, keepdim=True)
|
|
377
|
+
q = q - q.mean(1, keepdim=True)
|
|
378
|
+
d = p.norm(dim=1) * q.norm(dim=1) + 1e-8
|
|
379
|
+
return (1 - (p * q).sum(dim=1) / d).mean()
|
|
380
|
+
|
|
381
|
+
# --------------------------------------------------------
|
|
382
|
+
# fit
|
|
383
|
+
# --------------------------------------------------------
|
|
384
|
+
@staticmethod
|
|
385
|
+
def _want_pin_memory(X: Tensor, z: Tensor) -> bool:
|
|
386
|
+
"""pin_memory 只对 CPU tensor 有意义;cuda 输入直接关掉。"""
|
|
387
|
+
|
|
388
|
+
def on_gpu(a: Tensor) -> bool:
|
|
389
|
+
return isinstance(a, torch.Tensor) and a.device.type != "cpu"
|
|
390
|
+
|
|
391
|
+
return not (on_gpu(X) or on_gpu(z))
|
|
392
|
+
|
|
393
|
+
def fit(
|
|
394
|
+
self,
|
|
395
|
+
X: Array,
|
|
396
|
+
z: Array,
|
|
397
|
+
X_val: Optional[Array] = None,
|
|
398
|
+
z_val: Optional[Array] = None,
|
|
399
|
+
epochs: int = 200,
|
|
400
|
+
batch_size: int = 64,
|
|
401
|
+
lr: float = 2e-4,
|
|
402
|
+
weight_decay: float = 0.02,
|
|
403
|
+
patience: int = 20,
|
|
404
|
+
num_workers: int = 0,
|
|
405
|
+
verbose: int = 1,
|
|
406
|
+
) -> "GeneFlowDecoder":
|
|
407
|
+
X = _as_tensor(X, self.device)
|
|
408
|
+
z = _as_tensor(z, self.device)
|
|
409
|
+
# ★ 快照:freeze_z 时拷出一份独立副本,训练期与上游 z 彻底解耦
|
|
410
|
+
z = self._maybe_detach(z, snapshot=True)
|
|
411
|
+
|
|
412
|
+
if X.ndim != 2 or z.ndim != 2:
|
|
413
|
+
raise ValueError(f"X/z 必须是 2D: got {X.ndim}D / {z.ndim}D")
|
|
414
|
+
if X.shape[0] != z.shape[0]:
|
|
415
|
+
raise ValueError(f"X 与 z 样本数不一致: {X.shape[0]} vs {z.shape[0]}")
|
|
416
|
+
if z.shape[1] != self.z_dim:
|
|
417
|
+
raise ValueError(f"z 特征维 {z.shape[1]} 与 z_dim={self.z_dim} 不符")
|
|
418
|
+
if X.shape[1] != self.n_genes:
|
|
419
|
+
raise ValueError(
|
|
420
|
+
f"X 基因数 {X.shape[1]} 与 n_genes={self.n_genes} 不符;"
|
|
421
|
+
f"请用实际 X.shape[1] 构造模型"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# ★ 先算 pin_memory,再断言(0.1.1 的顺序 bug 已修正)
|
|
425
|
+
pin_memory = self._want_pin_memory(X, z)
|
|
426
|
+
assert (
|
|
427
|
+
X.device.type == "cpu" or z.device.type == "cpu" or not pin_memory
|
|
428
|
+
), "cuda 输入不应开启 pin_memory"
|
|
429
|
+
|
|
430
|
+
ds = torch.utils.data.TensorDataset(X, z)
|
|
431
|
+
dl = torch.utils.data.DataLoader(
|
|
432
|
+
ds,
|
|
433
|
+
batch_size=int(batch_size),
|
|
434
|
+
shuffle=True,
|
|
435
|
+
num_workers=int(num_workers),
|
|
436
|
+
pin_memory=pin_memory,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
has_val = False
|
|
440
|
+
if X_val is not None and z_val is not None:
|
|
441
|
+
Xv = _as_tensor(X_val, self.device)
|
|
442
|
+
zv = self._maybe_detach(_as_tensor(z_val, self.device), snapshot=True)
|
|
443
|
+
if Xv.shape[1] != self.n_genes or zv.shape[1] != self.z_dim:
|
|
444
|
+
raise ValueError("验证集维度与训练集不一致")
|
|
445
|
+
has_val = True
|
|
446
|
+
|
|
447
|
+
params = [p for p in self.parameters() if p.requires_grad]
|
|
448
|
+
opt = torch.optim.AdamW(params, lr=lr, weight_decay=weight_decay)
|
|
449
|
+
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=int(epochs))
|
|
450
|
+
|
|
451
|
+
best_val = float("inf")
|
|
452
|
+
best_state: Optional[dict[str, Tensor]] = None
|
|
453
|
+
no_improve = 0
|
|
454
|
+
|
|
455
|
+
for ep in range(1, int(epochs) + 1):
|
|
456
|
+
self.train()
|
|
457
|
+
tot = 0.0
|
|
458
|
+
n_seen = 0
|
|
459
|
+
for xb, zb in dl:
|
|
460
|
+
opt.zero_grad()
|
|
461
|
+
loss = self._loss(xb, zb)
|
|
462
|
+
loss.backward()
|
|
463
|
+
nn.utils.clip_grad_norm_(params, 1.0)
|
|
464
|
+
opt.step()
|
|
465
|
+
tot += loss.item() * xb.shape[0]
|
|
466
|
+
n_seen += xb.shape[0]
|
|
467
|
+
sched.step()
|
|
468
|
+
|
|
469
|
+
if verbose:
|
|
470
|
+
msg = f"[fit] epoch {ep:3d}/{epochs} | train={tot / max(n_seen,1):.4f}"
|
|
471
|
+
if has_val:
|
|
472
|
+
self.eval()
|
|
473
|
+
with torch.no_grad():
|
|
474
|
+
vl = self._loss(Xv, zv).item()
|
|
475
|
+
msg += f" | val={vl:.4f}"
|
|
476
|
+
if vl < best_val - 1e-5:
|
|
477
|
+
best_val = vl
|
|
478
|
+
no_improve = 0
|
|
479
|
+
best_state = {
|
|
480
|
+
k: v.detach().cpu().clone()
|
|
481
|
+
for k, v in self.state_dict().items()
|
|
482
|
+
}
|
|
483
|
+
else:
|
|
484
|
+
no_improve += 1
|
|
485
|
+
if no_improve >= int(patience):
|
|
486
|
+
if verbose:
|
|
487
|
+
print(f"[fit] early stop at epoch {ep}")
|
|
488
|
+
break
|
|
489
|
+
print(msg)
|
|
490
|
+
|
|
491
|
+
if best_state is not None:
|
|
492
|
+
self.load_state_dict(best_state)
|
|
493
|
+
if verbose:
|
|
494
|
+
print(f"[fit] restored best val={best_val:.4f}")
|
|
495
|
+
return self
|
|
496
|
+
|
|
497
|
+
# --------------------------------------------------------
|
|
498
|
+
# 采样 / 确定性还原
|
|
499
|
+
# --------------------------------------------------------
|
|
500
|
+
@torch.no_grad()
|
|
501
|
+
def forward(
|
|
502
|
+
self,
|
|
503
|
+
z: Array,
|
|
504
|
+
steps: int = 4,
|
|
505
|
+
method: str = "midpoint",
|
|
506
|
+
deterministic: bool = False,
|
|
507
|
+
) -> Tensor:
|
|
508
|
+
"""
|
|
509
|
+
给定 z 采样 X(log1p 空间)。
|
|
510
|
+
deterministic=True 等价于 predict();steps=4 通常够用。
|
|
511
|
+
"""
|
|
512
|
+
z = self._maybe_detach(_as_tensor(z, self.device))
|
|
513
|
+
if deterministic:
|
|
514
|
+
return self.predict(z)
|
|
515
|
+
|
|
516
|
+
self.eval()
|
|
517
|
+
B = z.shape[0]
|
|
518
|
+
G = self.n_genes
|
|
519
|
+
x = torch.randn(B, G, device=self.device)
|
|
520
|
+
ts = torch.linspace(0.0, 1.0, int(steps) + 1, device=self.device)
|
|
521
|
+
for i in range(int(steps)):
|
|
522
|
+
t0, t1 = ts[i], ts[i + 1]
|
|
523
|
+
tmid = (t0 + t1) / 2
|
|
524
|
+
dt = float(t1 - t0)
|
|
525
|
+
v = self._velocity(x, torch.full((B,), t0, device=self.device), z)
|
|
526
|
+
if method == "midpoint":
|
|
527
|
+
xm = x + 0.5 * dt * v
|
|
528
|
+
v = self._velocity(xm, torch.full((B,), tmid, device=self.device), z)
|
|
529
|
+
x = x + dt * v
|
|
530
|
+
return x
|
|
531
|
+
|
|
532
|
+
@torch.no_grad()
|
|
533
|
+
def predict(self, z: Array) -> Tensor:
|
|
534
|
+
"""确定性还原:NB 均值(log1p 空间)。伪计数用 (exp(log_mu)-1).clamp(min=0)。"""
|
|
535
|
+
z = self._maybe_detach(_as_tensor(z, self.device))
|
|
536
|
+
self.eval()
|
|
537
|
+
B = z.shape[0]
|
|
538
|
+
gids = self._gene_ids[:self.n_genes].unsqueeze(0).expand(B, -1)
|
|
539
|
+
log_mu, _ = self.nb_head(self.backbone(gids, z))
|
|
540
|
+
return log_mu
|
|
541
|
+
|
|
542
|
+
# --------------------------------------------------------
|
|
543
|
+
# 持久化
|
|
544
|
+
# --------------------------------------------------------
|
|
545
|
+
def save(self, path: str) -> None:
|
|
546
|
+
torch.save(
|
|
547
|
+
{"state": self.state_dict(), "cfg": self._cfg, "freeze_z": self.freeze_z},
|
|
548
|
+
path,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
@classmethod
|
|
552
|
+
def load(cls, path: str, **init_kwargs: Any) -> "GeneFlowDecoder":
|
|
553
|
+
ckpt = torch.load(path, map_location="cpu")
|
|
554
|
+
init_kwargs.setdefault("freeze_z", bool(ckpt.get("freeze_z", True)))
|
|
555
|
+
model = cls(**init_kwargs)
|
|
556
|
+
model._cfg = ckpt.get("cfg", model._cfg)
|
|
557
|
+
model.load_state_dict(ckpt["state"])
|
|
558
|
+
return model
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
__version__ = "0.1.
|
|
2
|
+
__version__ = "0.1.5"
|
|
@@ -1,671 +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
|
|
399
|
-
v = self.vel_proj(h) # [B,G,1] 或 [B,G]
|
|
400
|
-
v = v.reshape(B, G) # ← 强制对齐,根治错位
|
|
401
|
-
return v
|
|
402
|
-
|
|
403
|
-
# ----------------------------------------------------------------
|
|
404
|
-
# objective
|
|
405
|
-
# ----------------------------------------------------------------
|
|
406
|
-
def _loss(
|
|
407
|
-
self,
|
|
408
|
-
x: torch.Tensor,
|
|
409
|
-
z: torch.Tensor,
|
|
410
|
-
mask: Optional[torch.Tensor] = None,
|
|
411
|
-
) -> torch.Tensor:
|
|
412
|
-
z = self._maybe_detach(z)
|
|
413
|
-
B = x.shape[0]
|
|
414
|
-
t = torch.rand(B, device=self.device).clamp(
|
|
415
|
-
self._cfg.sigma_min, 1.0 - self._cfg.sigma_min
|
|
416
|
-
)
|
|
417
|
-
eps = torch.randn_like(x)
|
|
418
|
-
x_t = x.lerp(eps, t.unsqueeze(1))
|
|
419
|
-
|
|
420
|
-
v_pred = self._velocity(x_t, t, z)
|
|
421
|
-
v_target = eps - x # rectified-flow target
|
|
422
|
-
mse = F.mse_loss(v_pred, v_target, reduction="none")
|
|
423
|
-
if mask is not None:
|
|
424
|
-
mse = mse * mask
|
|
425
|
-
mse = mse.sum() / mask.sum().clamp(min=1.0)
|
|
426
|
-
else:
|
|
427
|
-
mse = mse.mean()
|
|
428
|
-
|
|
429
|
-
x0_hat = x_t.lerp(v_pred.detach(), t.unsqueeze(1))
|
|
430
|
-
|
|
431
|
-
nb = torch.tensor(0.0, device=self.device)
|
|
432
|
-
if self._cfg.nb_weight > 0:
|
|
433
|
-
gids = self._gene_ids.unsqueeze(0).expand(B, -1)
|
|
434
|
-
log_mu, theta = self.nb_head(self.backbone(gids, z))
|
|
435
|
-
nb = self.nb_head.nll(log_mu, theta, x, mask)
|
|
436
|
-
|
|
437
|
-
corr = torch.tensor(0.0, device=self.device)
|
|
438
|
-
if self._cfg.corr_weight > 0 and mask is None:
|
|
439
|
-
corr = self._corr(x0_hat, x)
|
|
440
|
-
|
|
441
|
-
return (
|
|
442
|
-
mse
|
|
443
|
-
+ self._cfg.nb_weight * nb
|
|
444
|
-
+ self._cfg.corr_weight * corr
|
|
445
|
-
)
|
|
446
|
-
|
|
447
|
-
@staticmethod
|
|
448
|
-
def _corr(p: torch.Tensor, q: torch.Tensor) -> torch.Tensor:
|
|
449
|
-
p = p - p.mean(1, keepdim=True)
|
|
450
|
-
q = q - q.mean(1, keepdim=True)
|
|
451
|
-
denom = p.norm(dim=1) * q.norm(dim=1) + 1e-8
|
|
452
|
-
return (1 - (p * q).sum(dim=1) / denom).mean()
|
|
453
|
-
|
|
454
|
-
# ----------------------------------------------------------------
|
|
455
|
-
# fit
|
|
456
|
-
# ----------------------------------------------------------------
|
|
457
|
-
def fit(
|
|
458
|
-
self,
|
|
459
|
-
X: Array,
|
|
460
|
-
z: Array,
|
|
461
|
-
*,
|
|
462
|
-
X_val: Optional[Array] = None,
|
|
463
|
-
z_val: Optional[Array] = None,
|
|
464
|
-
epochs: int = 200,
|
|
465
|
-
batch_size: int = 64,
|
|
466
|
-
lr: float = 2e-4,
|
|
467
|
-
weight_decay: float = 0.02,
|
|
468
|
-
patience: int = 20,
|
|
469
|
-
num_workers: int = 0,
|
|
470
|
-
verbose: int = 1,
|
|
471
|
-
) -> "GeneFlowDecoder":
|
|
472
|
-
"""
|
|
473
|
-
Train the decoder.
|
|
474
|
-
|
|
475
|
-
Parameters
|
|
476
|
-
----------
|
|
477
|
-
X : array, shape ``(N, n_genes)``
|
|
478
|
-
Log1p-normalized expression matrix.
|
|
479
|
-
z : array, shape ``(N, z_dim)``
|
|
480
|
-
Cell-state vectors. **Never updated** when ``freeze_z=True``.
|
|
481
|
-
X_val, z_val : arrays, optional
|
|
482
|
-
Validation split. If given, early stopping is enabled and the
|
|
483
|
-
best weights (lowest validation loss) are restored at the end.
|
|
484
|
-
epochs, batch_size, lr, weight_decay, patience
|
|
485
|
-
Standard optimisation knobs.
|
|
486
|
-
num_workers : int
|
|
487
|
-
Workers for the training DataLoader.
|
|
488
|
-
verbose : int
|
|
489
|
-
0 = silent, 1 = epoch logs.
|
|
490
|
-
|
|
491
|
-
Returns
|
|
492
|
-
-------
|
|
493
|
-
self
|
|
494
|
-
"""
|
|
495
|
-
X = _as_tensor(X, self.device)
|
|
496
|
-
z = self._maybe_detach(_as_tensor(z, self.device), snapshot=True)
|
|
497
|
-
|
|
498
|
-
# pin_memory 要求 CPU tensor;若 X/z 已在 GPU(常见于 scVI / mudata
|
|
499
|
-
# 把 .X 或 obsm 留在 cuda 的情况),强制关闭,避免
|
|
500
|
-
# RuntimeError: cannot pin 'torch.cuda.FloatTensor'
|
|
501
|
-
# 注:_as_tensor 对 cuda 输入原样返回,因此这里 X/z 仍在 GPU。
|
|
502
|
-
pin_memory = _want_pin_memory(X, z)
|
|
503
|
-
assert X.device.type == "cpu" or z.device.type == "cpu" or not pin_memory
|
|
504
|
-
|
|
505
|
-
ds = torch.utils.data.TensorDataset(X, z)
|
|
506
|
-
dl = torch.utils.data.DataLoader(
|
|
507
|
-
ds,
|
|
508
|
-
batch_size=int(batch_size),
|
|
509
|
-
shuffle=True,
|
|
510
|
-
num_workers=int(num_workers),
|
|
511
|
-
pin_memory=pin_memory,
|
|
512
|
-
)
|
|
513
|
-
|
|
514
|
-
has_val = False
|
|
515
|
-
if X_val is not None and z_val is not None:
|
|
516
|
-
Xv = _as_tensor(X_val, self.device)
|
|
517
|
-
zv = self._maybe_detach(_as_tensor(z_val, self.device), snapshot=True)
|
|
518
|
-
has_val = True
|
|
519
|
-
|
|
520
|
-
# Only decoder-owned parameters are optimised.
|
|
521
|
-
params = [p for p in self.parameters() if p.requires_grad]
|
|
522
|
-
opt = torch.optim.AdamW(params, lr=lr, weight_decay=weight_decay)
|
|
523
|
-
sched = torch.optim.lr_scheduler.CosineAnnealingLR(
|
|
524
|
-
opt, T_max=int(epochs)
|
|
525
|
-
)
|
|
526
|
-
|
|
527
|
-
best_val = float("inf")
|
|
528
|
-
best_state: Optional[dict[str, torch.Tensor]] = None
|
|
529
|
-
no_improve = 0
|
|
530
|
-
|
|
531
|
-
for ep in range(1, int(epochs) + 1):
|
|
532
|
-
self.train()
|
|
533
|
-
tot = 0.0
|
|
534
|
-
for xb, zb in dl:
|
|
535
|
-
opt.zero_grad()
|
|
536
|
-
loss = self._loss(xb, zb)
|
|
537
|
-
loss.backward()
|
|
538
|
-
nn.utils.clip_grad_norm_(params, 1.0)
|
|
539
|
-
opt.step()
|
|
540
|
-
tot += loss.item() * xb.shape[0]
|
|
541
|
-
sched.step()
|
|
542
|
-
|
|
543
|
-
if verbose:
|
|
544
|
-
msg = (
|
|
545
|
-
f"[fit] epoch {ep:3d}/{epochs} "
|
|
546
|
-
f"| train={tot / len(ds):.4f}"
|
|
547
|
-
)
|
|
548
|
-
if has_val:
|
|
549
|
-
self.eval()
|
|
550
|
-
with torch.no_grad():
|
|
551
|
-
vl = self._loss(Xv, zv).item()
|
|
552
|
-
msg += f" | val={vl:.4f}"
|
|
553
|
-
if vl < best_val - 1e-5:
|
|
554
|
-
best_val, no_improve = vl, 0
|
|
555
|
-
best_state = {
|
|
556
|
-
k: v.detach().cpu().clone()
|
|
557
|
-
for k, v in self.state_dict().items()
|
|
558
|
-
}
|
|
559
|
-
else:
|
|
560
|
-
no_improve += 1
|
|
561
|
-
if no_improve >= int(patience):
|
|
562
|
-
if verbose:
|
|
563
|
-
print(f"[fit] early stop at epoch {ep}")
|
|
564
|
-
break
|
|
565
|
-
print(msg)
|
|
566
|
-
|
|
567
|
-
if best_state is not None:
|
|
568
|
-
self.load_state_dict(best_state)
|
|
569
|
-
if verbose:
|
|
570
|
-
print(f"[fit] restored best val={best_val:.4f}")
|
|
571
|
-
return self
|
|
572
|
-
|
|
573
|
-
# ----------------------------------------------------------------
|
|
574
|
-
# inference
|
|
575
|
-
# ----------------------------------------------------------------
|
|
576
|
-
@torch.no_grad()
|
|
577
|
-
def forward(
|
|
578
|
-
self,
|
|
579
|
-
z: Array,
|
|
580
|
-
*,
|
|
581
|
-
steps: int = 4,
|
|
582
|
-
method: str = "midpoint",
|
|
583
|
-
deterministic: bool = False,
|
|
584
|
-
) -> torch.Tensor:
|
|
585
|
-
"""
|
|
586
|
-
Sample an expression profile from *z* (log1p space).
|
|
587
|
-
|
|
588
|
-
Parameters
|
|
589
|
-
----------
|
|
590
|
-
z : array, shape ``(B, z_dim)``
|
|
591
|
-
steps : int
|
|
592
|
-
Number of flow-integration steps (4 is usually enough).
|
|
593
|
-
method : ``"midpoint"`` | ``"euler"``
|
|
594
|
-
deterministic : bool
|
|
595
|
-
If ``True``, returns the deterministic NB mean
|
|
596
|
-
(equivalent to ``self.predict(z)``).
|
|
597
|
-
"""
|
|
598
|
-
z = self._maybe_detach(_as_tensor(z, self.device))
|
|
599
|
-
if deterministic:
|
|
600
|
-
return self.predict(z)
|
|
601
|
-
|
|
602
|
-
self.eval()
|
|
603
|
-
B = z.shape[0]
|
|
604
|
-
x = torch.randn(B, self.n_genes, device=self.device)
|
|
605
|
-
ts = torch.linspace(0.0, 1.0, int(steps) + 1, device=self.device)
|
|
606
|
-
for i in range(int(steps)):
|
|
607
|
-
t0, t1 = ts[i], ts[i + 1]
|
|
608
|
-
tmid = (t0 + t1) / 2
|
|
609
|
-
dt = (t1 - t0).item()
|
|
610
|
-
v = self._velocity(x, torch.full((B,), t0, device=self.device), z)
|
|
611
|
-
if method == "midpoint":
|
|
612
|
-
xm = x + 0.5 * dt * v
|
|
613
|
-
v = self._velocity(
|
|
614
|
-
xm, torch.full((B,), tmid, device=self.device), z
|
|
615
|
-
)
|
|
616
|
-
x = x + dt * v
|
|
617
|
-
return x
|
|
618
|
-
|
|
619
|
-
@torch.no_grad()
|
|
620
|
-
def predict(self, z: Array) -> torch.Tensor:
|
|
621
|
-
"""
|
|
622
|
-
Deterministic reconstruction: the NB mean in log1p space.
|
|
623
|
-
|
|
624
|
-
Convert to pseudo-counts with ``(log_mu.exp() - 1).clamp(min=0)``.
|
|
625
|
-
"""
|
|
626
|
-
z = self._maybe_detach(_as_tensor(z, self.device))
|
|
627
|
-
self.eval()
|
|
628
|
-
B = z.shape[0]
|
|
629
|
-
gids = self._gene_ids.unsqueeze(0).expand(B, -1)
|
|
630
|
-
log_mu, _ = self.nb_head(self.backbone(gids, z))
|
|
631
|
-
return log_mu
|
|
632
|
-
|
|
633
|
-
# ----------------------------------------------------------------
|
|
634
|
-
# persistence
|
|
635
|
-
# ----------------------------------------------------------------
|
|
636
|
-
def save(self, path: str) -> None:
|
|
637
|
-
"""Save model weights + config to ``path``."""
|
|
638
|
-
torch.save(
|
|
639
|
-
{
|
|
640
|
-
"state": self.state_dict(),
|
|
641
|
-
"cfg": self._cfg,
|
|
642
|
-
"freeze_z": self.freeze_z,
|
|
643
|
-
"n_genes": self.n_genes,
|
|
644
|
-
"z_dim": self.z_dim,
|
|
645
|
-
},
|
|
646
|
-
path,
|
|
647
|
-
)
|
|
648
|
-
|
|
649
|
-
@classmethod
|
|
650
|
-
def load(
|
|
651
|
-
cls,
|
|
652
|
-
path: str,
|
|
653
|
-
*,
|
|
654
|
-
device: str = "auto",
|
|
655
|
-
**init_kwargs,
|
|
656
|
-
) -> "GeneFlowDecoder":
|
|
657
|
-
"""
|
|
658
|
-
Load a saved model.
|
|
659
|
-
|
|
660
|
-
``n_genes`` / ``z_dim`` / ``freeze_z`` are read from the checkpoint
|
|
661
|
-
unless explicitly overridden in ``init_kwargs``.
|
|
662
|
-
"""
|
|
663
|
-
ckpt = torch.load(path, map_location="cpu")
|
|
664
|
-
init_kwargs.setdefault("n_genes", ckpt.get("n_genes"))
|
|
665
|
-
init_kwargs.setdefault("z_dim", ckpt.get("z_dim"))
|
|
666
|
-
init_kwargs.setdefault("freeze_z", ckpt.get("freeze_z", True))
|
|
667
|
-
init_kwargs["device"] = device
|
|
668
|
-
model = cls(**init_kwargs)
|
|
669
|
-
model._cfg = ckpt.get("cfg", model._cfg)
|
|
670
|
-
model.load_state_dict(ckpt["state"])
|
|
671
|
-
return model
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{geneflowdecoder-0.1.3 → geneflowdecoder-0.1.5}/src/geneflowdecoder.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|