Skip to content

Commit 238338a

Browse files
author
Stefan Ritchie
committed
Implement Adversarial Autoencoder Components
- Added `GradReverseLayer` for gradient reversal functionality. - Introduced `ResidualBlock` for building residual networks. - Created `make_mlp` function to construct multi-layer perceptrons with optional residual connections. - Developed `AEBatchCorrector` class for adversarial batch correction, integrating encoder, decoder, and adversarial components. - Established a model factory in `factory.py` to construct models based on command-line arguments, supporting both AE and VAE+attention variants. - Modularized code for improved reusability and maintainability.
1 parent 6437fc3 commit 238338a

13 files changed

Lines changed: 549 additions & 444 deletions

NN_batch_correct.py

Lines changed: 12 additions & 183 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737

3838
import torch
3939
import torch.nn as nn
40+
from models import AEBatchCorrector, GradReverseLayer, ResidualBlock, make_mlp
41+
from models.factory import build_model_from_args
4042
# AMP GradScaler compatibility (torch>=2 provides torch.amp.GradScaler; older used torch.cuda.amp.GradScaler)
4143
try: # prefer new API to silence deprecation warnings when available
4244
from torch.amp import GradScaler # type: ignore
@@ -294,161 +296,13 @@ def _eta2_by_group(Z: np.ndarray, groups: np.ndarray):
294296
# ----------------------------
295297
# Gradient Reversal
296298
# ----------------------------
297-
298-
class GradReverse(torch.autograd.Function):
299-
@staticmethod
300-
def forward(ctx, x, lambda_):
301-
ctx.lambda_ = lambda_
302-
return x.view_as(x)
303-
304-
@staticmethod
305-
def backward(ctx, grad_output):
306-
return grad_output.neg() * ctx.lambda_, None
307-
308-
309-
class GradReverseLayer(nn.Module):
310-
def __init__(self, lambda_: float = 1.0):
311-
super().__init__()
312-
self.lambda_ = lambda_
313-
314-
def forward(self, x):
315-
return GradReverse.apply(x, self.lambda_)
316-
317-
def set_lambda(self, lambda_: float):
318-
self.lambda_ = lambda_
299+
# Moved to models.ae (imported above)
319300

320301

321302
# ----------------------------
322303
# Model
323304
# ----------------------------
324-
325-
class ResidualBlock(nn.Module):
326-
"""
327-
Residual block with skip connection: x + FFN(x) + LayerNorm
328-
"""
329-
def __init__(self, hidden_size: int, dropout: float = 0.0):
330-
super().__init__()
331-
self.ffn = nn.Sequential(
332-
nn.Linear(hidden_size, hidden_size),
333-
nn.SiLU(),
334-
nn.Dropout(dropout),
335-
nn.Linear(hidden_size, hidden_size),
336-
nn.Dropout(dropout)
337-
)
338-
self.layer_norm = nn.LayerNorm(hidden_size)
339-
340-
def forward(self, x):
341-
return self.layer_norm(x + self.ffn(x))
342-
343-
344-
def make_mlp(sizes, dropout=0.0, last_activation=None, use_residual=False):
345-
"""
346-
Hidden layers: Linear -> LayerNorm -> SiLU -> Dropout
347-
Output layer: optional activation per arg
348-
349-
If use_residual=True, uses ResidualBlock layers instead.
350-
Note: For residual connections to work, all hidden layer sizes must be the same.
351-
"""
352-
if use_residual:
353-
if len(sizes) < 3:
354-
raise ValueError("Residual networks need at least input, hidden, and output layers")
355-
356-
# Check that all hidden layers have the same size for residual connections
357-
hidden_sizes = sizes[1:-1]
358-
if len(set(hidden_sizes)) > 1:
359-
raise ValueError(f"For residual connections, all hidden layer sizes must be the same. Got: {hidden_sizes}")
360-
361-
hidden_size = hidden_sizes[0]
362-
num_hidden_layers = len(hidden_sizes)
363-
364-
layers = []
365-
366-
# Input projection to hidden size
367-
layers.append(nn.Linear(sizes[0], hidden_size))
368-
layers.extend([nn.LayerNorm(hidden_size), nn.SiLU(), nn.Dropout(dropout)])
369-
370-
# Residual blocks
371-
for _ in range(num_hidden_layers):
372-
layers.append(ResidualBlock(hidden_size, dropout))
373-
374-
# Output layer
375-
layers.append(nn.Linear(hidden_size, sizes[-1]))
376-
if last_activation == "relu":
377-
layers.append(nn.ReLU())
378-
elif last_activation == "tanh":
379-
layers.append(nn.Tanh())
380-
elif last_activation == "sigmoid":
381-
layers.append(nn.Sigmoid())
382-
383-
return nn.Sequential(*layers)
384-
385-
else:
386-
# Original implementation
387-
layers = []
388-
for i in range(len(sizes) - 1):
389-
in_f, out_f = sizes[i], sizes[i + 1]
390-
layers.append(nn.Linear(in_f, out_f))
391-
if i < len(sizes) - 2:
392-
layers += [nn.LayerNorm(out_f), nn.SiLU(), nn.Dropout(dropout)]
393-
else:
394-
if last_activation == "relu":
395-
layers += [nn.ReLU()]
396-
elif last_activation == "tanh":
397-
layers += [nn.Tanh()]
398-
elif last_activation == "sigmoid":
399-
layers += [nn.Sigmoid()]
400-
return nn.Sequential(*layers)
401-
402-
403-
class AEBatchCorrector(nn.Module):
404-
def __init__(
405-
self,
406-
n_genes: int,
407-
latent_dim: int = 32,
408-
enc_hidden=(1024, 256),
409-
dec_hidden=(256, 1024),
410-
adv_hidden=(128,),
411-
sup_hidden=(64,),
412-
n_batches: int = 2,
413-
n_labels: Optional[int] = None,
414-
dropout: float = 0.1,
415-
adv_lambda: float = 1.0,
416-
use_residual: bool = False,
417-
):
418-
super().__init__()
419-
self.n_labels = n_labels
420-
self.grl = GradReverseLayer(lambda_=adv_lambda)
421-
422-
enc_sizes = [n_genes] + list(enc_hidden) + [latent_dim]
423-
dec_sizes = [latent_dim] + list(dec_hidden) + [n_genes]
424-
self.encoder = make_mlp(enc_sizes, dropout=dropout, last_activation=None, use_residual=use_residual)
425-
self.decoder = make_mlp(dec_sizes, dropout=dropout, last_activation=None, use_residual=use_residual)
426-
427-
adv_sizes = [latent_dim] + list(adv_hidden) + [n_batches]
428-
self.adv = make_mlp(adv_sizes, dropout=dropout, last_activation=None, use_residual=False) # Keep simple for adversarial head
429-
430-
if n_labels is not None:
431-
sup_sizes = [latent_dim] + list(sup_hidden) + [n_labels]
432-
self.sup = make_mlp(sup_sizes, dropout=dropout, last_activation=None, use_residual=False) # Keep simple for supervised head
433-
else:
434-
self.sup = None
435-
436-
def forward(self, x, adv_lambda: Optional[float] = None):
437-
z = self.encoder(x)
438-
x_hat = self.decoder(z)
439-
if adv_lambda is not None:
440-
self.grl.set_lambda(adv_lambda)
441-
z_rev = self.grl(z)
442-
batch_logits = self.adv(z_rev)
443-
label_logits = self.sup(z) if self.sup is not None else None
444-
return x_hat, batch_logits, label_logits, z
445-
446-
@torch.no_grad()
447-
def reconstruct(self, x):
448-
"""Fast path at inference time (skips adversary & GRL)."""
449-
z = self.encoder(x)
450-
x_hat = self.decoder(z)
451-
return x_hat, z
305+
# Moved to models.ae (imported above)
452306

453307

454308
# ----------------------------
@@ -1192,7 +1046,8 @@ def main():
11921046
ap.add_argument("--viz_hvg_top", default=2000, type=int, help="Top-N most variable genes to use for PCA visualisations (0=use all)")
11931047
ap.add_argument("--viz_pca_before", default="pca_before.png", type=str, help="Output path for PCA before correction")
11941048
ap.add_argument("--viz_pca_after", default="pca_after.png", type=str, help="Output path for PCA after correction")
1195-
ap.add_argument("--viz_boxplot", default="logCPM_boxplots.png", type=str, help="Output path for logCPM boxplots")
1049+
# Accept optional value; if provided without a path, use the default filename
1050+
ap.add_argument("--viz_boxplot", nargs="?", const="logCPM_boxplots.png", default="logCPM_boxplots.png", type=str, help="Output path for logCPM boxplots (optional value)")
11961051
# Differentiable biology preservation loss & adaptive adversary based on cond_sil
11971052
ap.add_argument("--bio_weight", default=0.0, type=float, help="Weight for supervised center loss on latent (preserve biology)")
11981053
ap.add_argument("--bio_gamma", default=0.5, type=float, help="Relative weight for between-class separation in center loss")
@@ -1339,38 +1194,12 @@ def main():
13391194
dl_val = DataLoader(ds_val, batch_size=args.batch_size, shuffle=False, **loader_kwargs)
13401195

13411196
# Model
1342-
if args.model_type == "vae_attention":
1343-
if VaeAttentionBatchCorrector is None:
1344-
raise RuntimeError("vae_attention model requested but module not available.")
1345-
model = VaeAttentionBatchCorrector(
1346-
num_genes=logcpm.shape[1],
1347-
num_batches=len(batch_classes),
1348-
latent_dim=args.latent_dim,
1349-
hidden_dim=args.vae_hidden_dim,
1350-
attention_dim=args.vae_attention_dim,
1351-
n_heads=args.vae_attn_heads,
1352-
dropout=args.dropout,
1353-
dispersion=args.vae_dispersion,
1354-
attn_max_tokens=args.attn_max_tokens,
1355-
)
1356-
else:
1357-
enc_hidden = tuple(int(x) for x in args.enc_hidden.split(",") if x.strip())
1358-
dec_hidden = tuple(int(x) for x in args.dec_hidden.split(",") if x.strip())
1359-
adv_hidden = tuple(int(x) for x in args.adv_hidden.split(",") if x.strip())
1360-
sup_hidden = tuple(int(x) for x in args.sup_hidden.split(",") if x.strip())
1361-
model = AEBatchCorrector(
1362-
n_genes=logcpm.shape[1],
1363-
latent_dim=args.latent_dim,
1364-
enc_hidden=enc_hidden,
1365-
dec_hidden=dec_hidden,
1366-
adv_hidden=adv_hidden,
1367-
sup_hidden=sup_hidden,
1368-
n_batches=len(batch_classes),
1369-
n_labels=(len(label_classes) if label_classes is not None else None),
1370-
dropout=args.dropout,
1371-
adv_lambda=args.adv_weight,
1372-
use_residual=args.use_residual,
1373-
)
1197+
model = build_model_from_args(
1198+
args,
1199+
n_genes=logcpm.shape[1],
1200+
n_batches=len(batch_classes),
1201+
n_labels=(len(label_classes) if label_classes is not None else None),
1202+
)
13741203

13751204
# Optional torch.compile (PyTorch 2+)
13761205
if args.compile:

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
<p align=center>
1111
<img src="logCPM_boxplots.png" alt="LogCPM Boxplots" height="200" align=center />
12-
<img src="VAEModel\sweep_pca_panel.png" alt="Before & After PCA Plots" height="200" align=center />
12+
<img src="assets\pca_panel.png" alt="Before & After PCA Plots" height="200" align=center />
1313
</p>
1414

1515
An adversarial autoencoder for bulk RNA-seq batch effect correction. It learns a latent representation that preserves biological signal (optional supervised head) while discouraging batch-specific variation via a gradient reversal adversary. Outputs a batch-corrected expression matrix (logCPM scale) and optional latent embedding plus visual diagnostics.
@@ -34,6 +34,9 @@ An adversarial autoencoder for bulk RNA-seq batch effect correction. It learns a
3434
---
3535
## Repository Structure (selected)
3636
- `NN_batch_correct.py` Main training + correction script
37+
- `models/` Model components (adversarial AE) and factory
38+
- `models/ae.py` AEBatchCorrector, GradReverseLayer, ResidualBlock, make_mlp
39+
- `models/factory.py` Helper to build AE or VAE+Attention models from CLI args
3740
- `visualise.py` Standalone PCA / boxplot + architecture diagram utilities
3841
- `bulk_counts.csv` Example counts matrix (shape: genes × samples or samples × genes)
3942
- `sample_meta.csv` Example metadata (columns include sample,batch[,condition])
@@ -70,6 +73,11 @@ pip install -r requirements.txt
7073
Optional extras:
7174
- Set `WANDB_MODE=offline` to avoid network usage.
7275

76+
---
77+
## Refactor Notes
78+
- Model code has been extracted from `NN_batch_correct.py` into `models/ae.py` and is re-exported via `models/__init__.py`. The CLI behavior is unchanged.
79+
- A lightweight factory (`models/factory.py`) centralizes model construction for AE vs. VAE+Attention to keep the training script focused on data and orchestration.
80+
7381
---
7482
## Quick Start
7583
Minimal unsupervised run (only batches):

assets/pca_panel.png

152 KB
Loading

corrected_logCPM.csv

Lines changed: 121 additions & 121 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)