mb-cdar 0.3.1__py3-none-any.whl

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.
mb_cdar/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MB-CDAR: Microbiome Conditional Diffusion with Adversarial Regularization."""
2
+
3
+ __version__ = "0.3.1"
mb_cdar/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m mb_cdar`` as an alias for the ``mb-cdar`` CLI."""
2
+
3
+ from mb_cdar.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,379 @@
1
+ """Continuous-only DiT backbone for MB-CDAR.
2
+
3
+ A Diffusion Transformer (DiT) that operates in ILR (Isometric Log-Ratio)
4
+ space for continuous compositional diffusion. Features:
5
+
6
+ 1. 2-layer MLP numerical tokenisation for the continuous ILR channel.
7
+ 2. Hand-written Multi-Head Attention with fused SDPA (memory-efficient at
8
+ D=1033 — avoids materialising the (B, nhead, D, D) score tensor).
9
+ 3. AdaLN-Zero conditioning from the time embedding (and an optional external
10
+ condition embedding, e.g. simple categorical metadata).
11
+ 4. Classifier-Free Guidance (CFG) condition dropout during training (10%
12
+ default) — the backbone learns both conditional and unconditional
13
+ trajectories.
14
+ 5. Absolute per-taxon identity injection via a learnable ``taxon_id_embed``
15
+ that breaks permutation-equivariance over taxa (the precondition for
16
+ non-trivial Spearman R² on species co-occurrence).
17
+
18
+ The previous V1 architecture (PhyloBias attention bias, phylogenetic spectral
19
+ identity, CtrTab Laplace noise injection, CADD cross-attention, discrete
20
+ RADD track) has been removed. The phylogenetic tree is now used only for
21
+ UniFrac evaluation, not for model conditioning.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+
28
+ import torch
29
+ import torch.nn as nn
30
+ import torch.nn.functional as F
31
+ from torch import Tensor
32
+
33
+ from mb_cdar.data.composition import helmert_contrast_matrix
34
+
35
+
36
+ class SinusoidalEmbedding(nn.Module):
37
+ """Sinusoidal time embedding module."""
38
+
39
+ def __init__(self, d_model: int):
40
+ super().__init__()
41
+ self.d_model = d_model
42
+
43
+ def forward(self, t: Tensor) -> Tensor:
44
+ device = t.device
45
+ half_dim = self.d_model // 2
46
+ emb = math.log(10000) / (half_dim - 1)
47
+ emb = torch.exp(torch.arange(half_dim, device=device, dtype=t.dtype) * -emb)
48
+ emb = t.unsqueeze(-1) * emb.unsqueeze(0)
49
+ emb = torch.cat((emb.sin(), emb.cos()), dim=-1)
50
+ return emb
51
+
52
+
53
+ class MBCDARBackbone(nn.Module):
54
+ """Continuous-only DiT architecture operating in ILR space.
55
+
56
+ Parameters
57
+ ----------
58
+ D : int
59
+ Number of OTUs (taxa).
60
+ d_model : int
61
+ DiT hidden dimension. Default 256.
62
+ nhead : int
63
+ Number of attention heads. Default 4.
64
+ num_layers : int
65
+ Number of DiT blocks. Default 8.
66
+ dropout : float
67
+ Dropout rate. Default 0.1.
68
+ id_dim : int
69
+ Dimensionality of the learnable per-taxon identity residual. Default
70
+ 24. Set to 0 to disable (not recommended — permutation-equivariance
71
+ returns and Spearman R² collapses to ~0).
72
+ cond_dim : Optional[int]
73
+ Dimensionality of an external condition embedding (e.g. simple
74
+ categorical metadata projected to a fixed vector). When provided,
75
+ ``cond_proj`` maps it to ``d_model`` and adds it to the time
76
+ condition. Default None (time-only conditioning).
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ D: int = 1033,
82
+ d_model: int = 256,
83
+ nhead: int = 4,
84
+ num_layers: int = 8,
85
+ dropout: float = 0.1,
86
+ id_dim: int = 24,
87
+ cond_dim: int | None = None,
88
+ ):
89
+ super().__init__()
90
+ self.D = D
91
+ self.K = D - 1 # ILR dimension
92
+ self.d_model = d_model
93
+ self.id_dim = id_dim
94
+ self.cond_dim = cond_dim
95
+
96
+ # Token embedding for Taxon space (shared across taxa — symmetric)
97
+ self.otu_embed = nn.Sequential(
98
+ nn.Linear(1, d_model),
99
+ nn.SiLU(),
100
+ nn.Linear(d_model, d_model),
101
+ nn.LayerNorm(d_model),
102
+ )
103
+
104
+ # === Absolute per-taxon identity (learnable) =========================
105
+ # The shared ``otu_embed`` above is permutation-equivariant over taxa.
106
+ # We break that symmetry by adding a *unique* learnable identity vector
107
+ # per taxon, projected to d_model and added to the input embedding
108
+ # (broadcast over the batch). This is the necessary precondition for
109
+ # learning species-specific co-occurrence structure (Spearman R² > 0).
110
+ #
111
+ # The previous V1 architecture combined this with a *fixed*
112
+ # phylogenetic spectral prior; that has been removed (the tree is now
113
+ # used only for evaluation, not model conditioning). Identity comes
114
+ # entirely from the learnable code below.
115
+ if id_dim > 0:
116
+ self.taxon_id_embed = nn.Parameter(torch.randn(D, id_dim) * 0.02)
117
+ self.taxon_id_proj = nn.Linear(id_dim, d_model)
118
+ # ======================================================================
119
+
120
+ # Helmert contrast matrix cache for projections
121
+ self._V_np = helmert_contrast_matrix(D)
122
+ self._V_cache: dict[tuple[torch.device, torch.dtype], Tensor] = {}
123
+
124
+ # Time embedding
125
+ self.t_embed = SinusoidalEmbedding(d_model)
126
+ self.t_mlp = nn.Sequential(
127
+ nn.Linear(d_model, d_model),
128
+ nn.SiLU(),
129
+ nn.Linear(d_model, d_model),
130
+ )
131
+
132
+ # External condition projection (e.g. simple categorical metadata).
133
+ # Projects an external condition embedding (cond_dim) to d_model so it
134
+ # can be added to the time-conditioning vector c. Zero-init the output
135
+ # layer so the condition starts as a no-op (DiT-style gradual onset).
136
+ if cond_dim is not None:
137
+ self.cond_proj = nn.Sequential(
138
+ nn.Linear(cond_dim, d_model),
139
+ nn.SiLU(),
140
+ nn.Linear(d_model, d_model),
141
+ )
142
+ nn.init.zeros_(self.cond_proj[-1].weight)
143
+ nn.init.zeros_(self.cond_proj[-1].bias)
144
+
145
+ # DiT blocks
146
+ self.blocks = nn.ModuleList([
147
+ DiTBlock(d_model, nhead, dropout=dropout)
148
+ for _ in range(num_layers)
149
+ ])
150
+
151
+ # Output — final AdaLN-Zero modulation applied just before the output
152
+ # LayerNorm. SiLU + Linear(d → 2d) producing (shift, scale); zero-init
153
+ # so the network starts as identity → final_norm only.
154
+ self.final_adaln = nn.Sequential(
155
+ nn.SiLU(),
156
+ nn.Linear(d_model, 2 * d_model),
157
+ )
158
+ nn.init.zeros_(self.final_adaln[1].weight)
159
+ nn.init.zeros_(self.final_adaln[1].bias)
160
+
161
+ self.final_norm = nn.LayerNorm(d_model)
162
+ self.out_proj = nn.Sequential(
163
+ nn.Linear(d_model, d_model),
164
+ nn.SiLU(),
165
+ nn.Linear(d_model, 1),
166
+ )
167
+
168
+ def _get_V(self, ref: Tensor) -> Tensor:
169
+ key = (ref.device, ref.dtype)
170
+ if key not in self._V_cache:
171
+ self._V_cache[key] = torch.from_numpy(self._V_np).to(
172
+ device=ref.device, dtype=ref.dtype
173
+ )
174
+ return self._V_cache[key]
175
+
176
+ def _taxon_identity(self) -> Tensor | None:
177
+ """Absolute per-taxon identity embedding, shape (D, d_model) or None."""
178
+ if self.id_dim > 0:
179
+ return self.taxon_id_proj(self.taxon_id_embed) # (D, d_model)
180
+ return None
181
+
182
+ def forward(
183
+ self,
184
+ x_ilr: Tensor,
185
+ t: Tensor,
186
+ cond_emb: Tensor | None = None,
187
+ cond_dropout_prob: float = 0.1,
188
+ ) -> Tensor:
189
+ """Forward pass.
190
+
191
+ Parameters
192
+ ----------
193
+ x_ilr : (B, D-1) Tensor
194
+ ILR-transformed compositions.
195
+ t : (B,) Tensor
196
+ Diffusion time step.
197
+ cond_emb : Optional[Tensor]
198
+ External condition embedding, shape (B, cond_dim). When provided
199
+ it is projected to ``d_model`` and added to the time-conditioning
200
+ vector ``c``. Default None (time-only conditioning).
201
+ cond_dropout_prob : float
202
+ Probability of dropping the condition (CFG training). Set to 0
203
+ to disable (e.g. at inference). Default 0.1.
204
+
205
+ Returns
206
+ -------
207
+ noise_pred : (B, D-1) Tensor
208
+ """
209
+ # 1. Project ILR to taxon space
210
+ V = self._get_V(x_ilr)
211
+ x_taxon = torch.matmul(x_ilr, V.t()) # (B, D)
212
+
213
+ # 2. Embed taxon coordinates + inject absolute per-taxon identity.
214
+ # The identity (D, d_model) breaks permutation-equivariance so the
215
+ # network can learn species-specific structure (Spearman R² fix).
216
+ x = self.otu_embed(x_taxon.unsqueeze(-1)) # (B, D, d_model)
217
+ ident = self._taxon_identity()
218
+ if ident is not None:
219
+ x = x + ident.unsqueeze(0) # broadcast (1, D, d_model) → (B, D, d_model)
220
+
221
+ # 3. Time embedding (the condition c)
222
+ c = self.t_mlp(self.t_embed(t)) # (B, d_model)
223
+
224
+ # External condition (e.g. simple categorical metadata) fused into c.
225
+ # cond_emb is projected to d_model and added to the time condition.
226
+ cond_signal = None
227
+ if cond_emb is not None and hasattr(self, 'cond_proj'):
228
+ cond_signal = self.cond_proj(cond_emb)
229
+
230
+ # CFG condition dropout: zero out the condition projection (NOT the
231
+ # time embedding!) on a random subset of the batch during training so
232
+ # the network learns both conditional and unconditional trajectories.
233
+ # The unconditional trajectory uses c = t_emb only (no condition
234
+ # signal), matching what is passed at inference time when
235
+ # cond_emb=None.
236
+ if self.training and cond_dropout_prob > 0 and cond_signal is not None:
237
+ drop_mask = torch.rand(c.shape[0], device=c.device) < cond_dropout_prob
238
+ cond_signal = torch.where(
239
+ drop_mask.unsqueeze(-1),
240
+ torch.zeros_like(cond_signal),
241
+ cond_signal,
242
+ )
243
+
244
+ if cond_signal is not None:
245
+ c = c + cond_signal
246
+
247
+ # 4. DiT blocks (no phylo attention bias — removed in V1 cleanup)
248
+ for block in self.blocks:
249
+ x = block(x, c) # (B, D, d_model)
250
+
251
+ # 5. Final AdaLN-Zero modulation + output projection back to ILR
252
+ # noise/score. c is (B, d_model) and x is (B, D, d_model), so the
253
+ # shift/scale are unsqueezed to (B, 1, d_model) for broadcasting.
254
+ final_shift, final_scale = self.final_adaln(c).chunk(2, dim=-1)
255
+ x = self.final_norm(x) * (1 + final_scale.unsqueeze(1)) + final_shift.unsqueeze(1)
256
+ score_taxon = self.out_proj(x).squeeze(-1) # (B, D)
257
+ noise_pred = torch.matmul(score_taxon, V) # (B, K)
258
+
259
+ return noise_pred
260
+
261
+
262
+ # =====================================================================
263
+ # DiTBlock — DiT block with adaLN modulation
264
+ # =====================================================================
265
+
266
+
267
+ class DiTBlock(nn.Module):
268
+ """DiT block with adaLN modulation.
269
+
270
+ Components:
271
+ norm1, MultiheadAttention(d_model, nhead, dropout)
272
+ norm2, MLP(d → 4d → d) with GELU + Dropout
273
+ adaln: SiLU + Linear(d → 6d) 生成 6 个调制参数
274
+
275
+ Parameters
276
+ ----------
277
+ d_model : int
278
+ Model hidden dimension.
279
+ nhead : int
280
+ Number of attention heads.
281
+ dropout : float
282
+ Dropout rate.
283
+ """
284
+
285
+ def __init__(
286
+ self,
287
+ d_model: int = 256,
288
+ nhead: int = 4,
289
+ dropout: float = 0.1,
290
+ ):
291
+ super().__init__()
292
+ self.d_model = d_model
293
+ self.nhead = nhead
294
+ self.head_dim = d_model // nhead
295
+ assert d_model % nhead == 0
296
+
297
+ # Self-attention QKV (attention itself uses fused SDPA in forward)
298
+ self.W_qkv = nn.Linear(d_model, 3 * d_model)
299
+ self.out_proj = nn.Linear(d_model, d_model)
300
+ self.attn_dropout_p = dropout # applied inside SDPA during training
301
+ self.resid_dropout = nn.Dropout(dropout)
302
+
303
+ self.norm1 = nn.LayerNorm(d_model)
304
+
305
+ # Feedforward MLP
306
+ self.mlp = nn.Sequential(
307
+ nn.Linear(d_model, 4 * d_model),
308
+ nn.GELU(),
309
+ nn.Dropout(dropout),
310
+ nn.Linear(4 * d_model, d_model),
311
+ nn.Dropout(dropout),
312
+ )
313
+ self.norm2 = nn.LayerNorm(d_model)
314
+
315
+ # adaLN: SiLU + Linear(d → 6d) 生成 6 个调制参数 (DiT-style).
316
+ # SiLU gate before the projection matches the DiT architecture; the
317
+ # Linear is zero-initialised so the network starts as identity (all
318
+ # shifts/scales/gates = 0 → modulation is a no-op at init).
319
+ self.adaln = nn.Sequential(nn.SiLU(), nn.Linear(d_model, 6 * d_model))
320
+ nn.init.zeros_(self.adaln[1].weight)
321
+ nn.init.zeros_(self.adaln[1].bias)
322
+
323
+ def forward(self, x: Tensor, c: Tensor) -> Tensor:
324
+ """Forward pass.
325
+
326
+ Parameters
327
+ ----------
328
+ x : (B, K, d_model) Tensor
329
+ Input sequence.
330
+ c : (B, d_model) Tensor
331
+ Condition vector (time embedding, optionally with cond signal).
332
+
333
+ Returns
334
+ -------
335
+ (B, K, d_model) Tensor
336
+ """
337
+ B, K, C = x.shape
338
+
339
+ # --- adaLN modulation parameters ---
340
+ # 6 params: shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp
341
+ params = self.adaln(c) # (B, 6*d_model)
342
+ params = params.unsqueeze(1) # (B, 1, 6*d_model)
343
+ shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = \
344
+ params.chunk(6, dim=-1) # each (B, 1, d_model)
345
+
346
+ # --- Self-Attention ---
347
+ # Modulate input
348
+ h = self.norm1(x)
349
+ h = h * (1 + scale_msa) + shift_msa
350
+
351
+ # QKV projection
352
+ qkv = self.W_qkv(h) # (B, K, 3*C)
353
+ qkv = qkv.reshape(B, K, 3, self.nhead, self.head_dim)
354
+ qkv = qkv.permute(2, 0, 3, 1, 4) # (3, B, nhead, K, head_dim)
355
+ q, k, v = qkv.unbind(0)
356
+
357
+ # Fused scaled dot-product attention. SDPA computes
358
+ # softmax(QKᵀ/√d)·V without ever materialising the (B, nhead, K, K)
359
+ # score tensor — essential at K=1033 (the hand-written path allocated
360
+ # ~0.5 GB/layer here). SDPA's default scale is 1/√head_dim.
361
+ attn_out = F.scaled_dot_product_attention(
362
+ q, k, v,
363
+ dropout_p=self.attn_dropout_p if self.training else 0.0,
364
+ ) # (B, nhead, K, head_dim)
365
+ attn_out = attn_out.transpose(1, 2).reshape(B, K, C)
366
+ attn_out = self.resid_dropout(self.out_proj(attn_out))
367
+
368
+ # Gate + residual
369
+ x = x + gate_msa * attn_out
370
+
371
+ # --- MLP ---
372
+ h = self.norm2(x)
373
+ h = h * (1 + scale_mlp) + shift_mlp
374
+ mlp_out = self.mlp(h)
375
+
376
+ # Gate + residual
377
+ x = x + gate_mlp * mlp_out
378
+
379
+ return x