bsvae 0.1.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.
- bsvae/__init__.py +0 -0
- bsvae/hyperparam.ini +82 -0
- bsvae/main.py +237 -0
- bsvae/models/__init__.py +4 -0
- bsvae/models/decoder.py +90 -0
- bsvae/models/encoder.py +71 -0
- bsvae/models/losses.py +116 -0
- bsvae/models/structured.py +75 -0
- bsvae/models/vae.py +29 -0
- bsvae/utils/__init__.py +2 -0
- bsvae/utils/datasets.py +143 -0
- bsvae/utils/evaluate.py +77 -0
- bsvae/utils/helpers.py +156 -0
- bsvae/utils/initialization.py +30 -0
- bsvae/utils/mapping.py +218 -0
- bsvae/utils/modelIO.py +148 -0
- bsvae/utils/ppi.py +153 -0
- bsvae/utils/training.py +130 -0
- bsvae-0.1.1.dist-info/METADATA +168 -0
- bsvae-0.1.1.dist-info/RECORD +22 -0
- bsvae-0.1.1.dist-info/WHEEL +4 -0
- bsvae-0.1.1.dist-info/entry_points.txt +3 -0
bsvae/__init__.py
ADDED
|
File without changes
|
bsvae/hyperparam.ini
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[Custom]
|
|
2
|
+
# General options
|
|
3
|
+
log_level = "info"
|
|
4
|
+
no_progress_bar = False
|
|
5
|
+
no_cuda = False
|
|
6
|
+
seed = 1234
|
|
7
|
+
|
|
8
|
+
# Training options
|
|
9
|
+
epochs = 100
|
|
10
|
+
batch_size = 64
|
|
11
|
+
lr = 5e-4
|
|
12
|
+
checkpoint_every = 30
|
|
13
|
+
dataset = "genenet"
|
|
14
|
+
experiment = "structured"
|
|
15
|
+
|
|
16
|
+
# Model options
|
|
17
|
+
model = "StructuredFactorVAE"
|
|
18
|
+
latent_dim = 10
|
|
19
|
+
hidden_dims = [512, 256] # encoder hidden layers
|
|
20
|
+
dropout = 0.1
|
|
21
|
+
init_sd = 0.02
|
|
22
|
+
learn_var = False # decoder per-gene variance
|
|
23
|
+
|
|
24
|
+
# Loss options
|
|
25
|
+
loss = "beta"
|
|
26
|
+
beta = 1.0 # KL weight
|
|
27
|
+
l1_strength = 1e-3 # sparsity regularizer
|
|
28
|
+
lap_strength = 1e-4 # Laplacian smoothness
|
|
29
|
+
|
|
30
|
+
# Evaluations
|
|
31
|
+
is_metrics = True
|
|
32
|
+
no_test = False
|
|
33
|
+
is_eval_only = False
|
|
34
|
+
eval_batchsize = 512
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ### DATASET COMMON ###
|
|
38
|
+
[Common_genenet]
|
|
39
|
+
dataset = "genenet"
|
|
40
|
+
checkpoint_every = 100
|
|
41
|
+
epochs = 400
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ### LOSS COMMON ###
|
|
45
|
+
[Common_VAE]
|
|
46
|
+
loss = "VAE"
|
|
47
|
+
lr = 5e-4
|
|
48
|
+
[Common_beta]
|
|
49
|
+
loss = "beta"
|
|
50
|
+
beta = 4.0
|
|
51
|
+
lr = 5e-4
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ### EXPERIMENT SPECIFIC ###
|
|
55
|
+
|
|
56
|
+
[VAE_genenet]
|
|
57
|
+
dataset = "genenet"
|
|
58
|
+
model = "StructuredFactorVAE"
|
|
59
|
+
loss = "VAE"
|
|
60
|
+
latent_dim = 8
|
|
61
|
+
epochs = 400
|
|
62
|
+
checkpoint_every = 50
|
|
63
|
+
lr = 1e-4
|
|
64
|
+
l1_strength = 1e-3
|
|
65
|
+
lap_strength = 1e-4
|
|
66
|
+
|
|
67
|
+
[beta_genenet]
|
|
68
|
+
dataset = "genenet"
|
|
69
|
+
model = "StructuredFactorVAE"
|
|
70
|
+
loss = "beta"
|
|
71
|
+
beta = 4.0
|
|
72
|
+
latent_dim = 8
|
|
73
|
+
epochs = 400
|
|
74
|
+
checkpoint_every = 50
|
|
75
|
+
lr = 1e-4
|
|
76
|
+
l1_strength = 5e-4
|
|
77
|
+
lap_strength = 5e-5
|
|
78
|
+
|
|
79
|
+
[debug]
|
|
80
|
+
epochs = 1
|
|
81
|
+
log_level = "debug"
|
|
82
|
+
no_test = True
|
bsvae/main.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import logging
|
|
5
|
+
import argparse
|
|
6
|
+
from os.path import join, dirname
|
|
7
|
+
from configparser import ConfigParser
|
|
8
|
+
|
|
9
|
+
import torch
|
|
10
|
+
from torch import optim
|
|
11
|
+
|
|
12
|
+
from bsvae.models import StructuredFactorVAE
|
|
13
|
+
from bsvae.models.losses import BaseLoss
|
|
14
|
+
from bsvae.utils.datasets import get_dataloaders
|
|
15
|
+
from bsvae.utils.helpers import (
|
|
16
|
+
set_seed,
|
|
17
|
+
get_device,
|
|
18
|
+
get_n_params,
|
|
19
|
+
create_safe_directory,
|
|
20
|
+
FormatterNoDuplicate,
|
|
21
|
+
get_config_section,
|
|
22
|
+
update_namespace_,
|
|
23
|
+
)
|
|
24
|
+
from bsvae.utils import Trainer, Evaluator
|
|
25
|
+
from bsvae.utils.ppi import load_ppi_laplacian
|
|
26
|
+
from bsvae.utils.modelIO import save_model, load_model, load_metadata
|
|
27
|
+
|
|
28
|
+
RES_DIR = "results"
|
|
29
|
+
|
|
30
|
+
def load_config(config_path: str, section: str = "Custom") -> dict:
|
|
31
|
+
"""Load hyperparameters from .ini file."""
|
|
32
|
+
parser = ConfigParser()
|
|
33
|
+
parser.read(config_path)
|
|
34
|
+
if section not in parser:
|
|
35
|
+
raise ValueError(f"Config section [{section}] not found in {config_path}")
|
|
36
|
+
return {k: ast_literal_eval(v) for k, v in parser[section].items()}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ast_literal_eval(val):
|
|
40
|
+
"""Helper to parse lists, ints, floats from strings safely."""
|
|
41
|
+
import ast
|
|
42
|
+
try:
|
|
43
|
+
return ast.literal_eval(val)
|
|
44
|
+
except Exception:
|
|
45
|
+
return val
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_arguments(cli_args):
|
|
49
|
+
"""Parse CLI args, with defaults pulled from hyperparam.ini."""
|
|
50
|
+
pre_parser = argparse.ArgumentParser(add_help=False)
|
|
51
|
+
pre_parser.add_argument("--config", "-c", type=str,
|
|
52
|
+
default=join(dirname(__file__), "hyperparam.ini"),
|
|
53
|
+
help="Path to hyperparam.ini")
|
|
54
|
+
pre_parser.add_argument("--section", type=str, default="Custom",
|
|
55
|
+
help="Section of .ini to load")
|
|
56
|
+
config_args, _ = pre_parser.parse_known_args(cli_args)
|
|
57
|
+
|
|
58
|
+
# Load defaults from config
|
|
59
|
+
config = load_config(config_args.config, config_args.section)
|
|
60
|
+
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
description="Training and evaluation for StructuredFactorVAE.",
|
|
63
|
+
formatter_class=FormatterNoDuplicate,
|
|
64
|
+
parents=[pre_parser],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# General
|
|
68
|
+
parser.add_argument("name", type=str, help="Experiment name.")
|
|
69
|
+
parser.add_argument("--seed", type=int, default=config.get("seed", 13))
|
|
70
|
+
parser.add_argument("--no-cuda", action="store_true",
|
|
71
|
+
default=config.get("no_cuda", False))
|
|
72
|
+
|
|
73
|
+
# Training
|
|
74
|
+
parser.add_argument("--epochs", type=int, default=config.get("epochs", 100))
|
|
75
|
+
parser.add_argument("--batch-size", type=int,
|
|
76
|
+
default=config.get("batch_size", 64))
|
|
77
|
+
parser.add_argument("--lr", type=float, default=config.get("lr", 5e-4))
|
|
78
|
+
parser.add_argument("--checkpoint-every", type=int,
|
|
79
|
+
default=config.get("checkpoint_every", 10))
|
|
80
|
+
|
|
81
|
+
# Model
|
|
82
|
+
parser.add_argument("--latent-dim", "-z", type=int,
|
|
83
|
+
default=config.get("latent_dim", 10))
|
|
84
|
+
parser.add_argument("--hidden-dims", "-Z", type=ast_literal_eval,
|
|
85
|
+
default=config.get("hidden_dims", [128, 64]))
|
|
86
|
+
parser.add_argument("--dropout", type=float,
|
|
87
|
+
default=config.get("dropout", 0.1))
|
|
88
|
+
parser.add_argument("--learn-var", action="store_true",
|
|
89
|
+
default=config.get("learn_var", False))
|
|
90
|
+
parser.add_argument("--init-sd", type=float,
|
|
91
|
+
default=config.get("init_sd", 0.02))
|
|
92
|
+
|
|
93
|
+
# Loss
|
|
94
|
+
parser.add_argument("--loss", type=str, default=config.get("loss", "VAE"),
|
|
95
|
+
choices=["VAE", "beta"])
|
|
96
|
+
parser.add_argument("--beta", type=float, default=config.get("beta", 1.0))
|
|
97
|
+
parser.add_argument("--l1-strength", type=float,
|
|
98
|
+
default=config.get("l1_strength", 1e-3))
|
|
99
|
+
parser.add_argument("--lap-strength", type=float,
|
|
100
|
+
default=config.get("lap_strength", 1e-4))
|
|
101
|
+
|
|
102
|
+
# Evaluation
|
|
103
|
+
parser.add_argument("--is-eval-only", action="store_true",
|
|
104
|
+
default=config.get("is_eval_only"))
|
|
105
|
+
parser.add_argument("--no-test", action="store_true",
|
|
106
|
+
default=config.get("no_test"))
|
|
107
|
+
parser.add_argument("--eval-batchsize", type=int,
|
|
108
|
+
default=config.get("eval_batchsize"))
|
|
109
|
+
|
|
110
|
+
# Dataset
|
|
111
|
+
parser.add_argument("--dataset", type=str,
|
|
112
|
+
default=config.get("dataset", "genenet"))
|
|
113
|
+
parser.add_argument("--gene-expression-filename", type=str,
|
|
114
|
+
default=config.get("gene_expression_filename", None),
|
|
115
|
+
help="CSV with gene expression (genes x samples).")
|
|
116
|
+
parser.add_argument("--gene-expression-dir", type=str,
|
|
117
|
+
default=config.get("gene_expression_dir", None),
|
|
118
|
+
help="Directory with train/test splits (X_train.csv, X_test.csv).")
|
|
119
|
+
|
|
120
|
+
# PPI Priors
|
|
121
|
+
parser.add_argument("--ppi-taxid", type=str, default=config.get("ppi_taxid", "9606"))
|
|
122
|
+
parser.add_argument("--ppi-cache", type=str, default=config.get("ppi_cache", None))
|
|
123
|
+
|
|
124
|
+
args = parser.parse_args(cli_args)
|
|
125
|
+
|
|
126
|
+
# Validate dataset input
|
|
127
|
+
if bool(args.gene_expression_filename) == bool(args.gene_expression_dir):
|
|
128
|
+
parser.error("Specify exactly one of --gene-expression-filename or --gene-expression-dir.")
|
|
129
|
+
|
|
130
|
+
parser.set_defaults(**vars(args))
|
|
131
|
+
return parser.parse_args(cli_args)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def setup_logging():
|
|
135
|
+
log_fmt = "%(asctime)s %(levelname)s: %(message)s"
|
|
136
|
+
logging.basicConfig(level=logging.INFO, format=log_fmt)
|
|
137
|
+
return logging.getLogger(__name__)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(args):
|
|
141
|
+
logger = setup_logging()
|
|
142
|
+
set_seed(args.seed)
|
|
143
|
+
device = get_device(use_gpu=not args.no_cuda)
|
|
144
|
+
exp_dir = join(RES_DIR, args.name)
|
|
145
|
+
logger.info(f"Experiment directory: {exp_dir}")
|
|
146
|
+
|
|
147
|
+
# Training
|
|
148
|
+
if not args.is_eval_only:
|
|
149
|
+
create_safe_directory(exp_dir, logger=logger)
|
|
150
|
+
|
|
151
|
+
# Data
|
|
152
|
+
train_loader = get_dataloaders(
|
|
153
|
+
dataset=args.dataset,
|
|
154
|
+
batch_size=args.batch_size,
|
|
155
|
+
logger=logger,
|
|
156
|
+
train=True,
|
|
157
|
+
drop_last=True,
|
|
158
|
+
gene_expression_filename=args.gene_expression_filename,
|
|
159
|
+
gene_expression_dir=args.gene_expression_dir,
|
|
160
|
+
)
|
|
161
|
+
n_genes = train_loader.dataset[0][0].shape[-1]
|
|
162
|
+
logger.info(f"Training dataset size: {len(train_loader.dataset)}")
|
|
163
|
+
|
|
164
|
+
# PPI Laplacian
|
|
165
|
+
try:
|
|
166
|
+
gene_list = getattr(train_loader.dataset, "genes", None)
|
|
167
|
+
if gene_list is not None:
|
|
168
|
+
L, G = load_ppi_laplacian(
|
|
169
|
+
gene_list,
|
|
170
|
+
taxid=args.ppi_taxid,
|
|
171
|
+
min_score=700,
|
|
172
|
+
cache_dir=args.ppi_cache or "~/.bsvae/ppi",
|
|
173
|
+
)
|
|
174
|
+
logger.info(f"PPI Graph loaded: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
|
|
175
|
+
else:
|
|
176
|
+
L = None
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.warning(f"Could not load PPI Laplacian: {e}")
|
|
179
|
+
L = None
|
|
180
|
+
|
|
181
|
+
# Model
|
|
182
|
+
model = StructuredFactorVAE(
|
|
183
|
+
n_genes=n_genes,
|
|
184
|
+
n_latent=args.latent_dim,
|
|
185
|
+
hidden_dims=args.hidden_dims,
|
|
186
|
+
dropout=args.dropout,
|
|
187
|
+
init_sd=args.init_sd,
|
|
188
|
+
learn_var=args.learn_var,
|
|
189
|
+
L=L
|
|
190
|
+
).to(device)
|
|
191
|
+
logger.info(f"Model initialized with {n_genes} genes, {args.latent_dim} latent dims")
|
|
192
|
+
|
|
193
|
+
# Loss + optimizer
|
|
194
|
+
optimizer = optim.Adam(model.parameters(), lr=args.lr)
|
|
195
|
+
loss_f = BaseLoss(beta=args.beta,
|
|
196
|
+
l1_strength=args.l1_strength,
|
|
197
|
+
lap_strength=args.lap_strength)
|
|
198
|
+
|
|
199
|
+
trainer = Trainer(
|
|
200
|
+
model, optimizer, loss_f, device=device, logger=logger,
|
|
201
|
+
save_dir=exp_dir, is_progress_bar=not args.no_cuda,
|
|
202
|
+
)
|
|
203
|
+
trainer(train_loader, epochs=int(args.epochs),
|
|
204
|
+
checkpoint_every=int(args.checkpoint_every))
|
|
205
|
+
save_model(trainer.model, exp_dir, metadata=vars(args))
|
|
206
|
+
|
|
207
|
+
# Evaluation
|
|
208
|
+
if not args.no_test:
|
|
209
|
+
model = load_model(exp_dir, is_gpu=not args.no_cuda)
|
|
210
|
+
metadata = load_metadata(exp_dir)
|
|
211
|
+
test_loader = get_dataloaders(
|
|
212
|
+
dataset="genenet",
|
|
213
|
+
batch_size=args.eval_batchsize,
|
|
214
|
+
shuffle=False,
|
|
215
|
+
logger=logger,
|
|
216
|
+
train=False,
|
|
217
|
+
drop_last=True,
|
|
218
|
+
gene_expression_filename=args.gene_expression_filename,
|
|
219
|
+
gene_expression_dir=args.gene_expression_dir,
|
|
220
|
+
)
|
|
221
|
+
loss_f = BaseLoss(beta=args.beta,
|
|
222
|
+
l1_strength=args.l1_strength,
|
|
223
|
+
lap_strength=args.lap_strength)
|
|
224
|
+
evaluator = Evaluator(
|
|
225
|
+
model, loss_f, device=device, logger=logger, save_dir=exp_dir,
|
|
226
|
+
is_progress_bar=not args.no_cuda,
|
|
227
|
+
)
|
|
228
|
+
evaluator(test_loader)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cli():
|
|
232
|
+
args = parse_arguments(sys.argv[1:])
|
|
233
|
+
main(args)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
cli()
|
bsvae/models/__init__.py
ADDED
bsvae/models/decoder.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decoder for StructuredFactorVAE
|
|
3
|
+
"""
|
|
4
|
+
import torch
|
|
5
|
+
import torch.nn as nn
|
|
6
|
+
import torch.nn.functional as F
|
|
7
|
+
|
|
8
|
+
class StructuredDecoder(nn.Module):
|
|
9
|
+
"""
|
|
10
|
+
Structured decoder for gene expression:
|
|
11
|
+
- Each latent factor corresponds to a module.
|
|
12
|
+
- Decoder weights are optionally masked by prior gene×module assignments.
|
|
13
|
+
- Supports sparsity and Laplacian penalties.
|
|
14
|
+
- Outputs mean and dispersion for Negative Binomial likelihood.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
n_genes : int
|
|
19
|
+
Number of genes (G).
|
|
20
|
+
n_latent : int
|
|
21
|
+
Number of latent factors/modules (K).
|
|
22
|
+
mask : torch.Tensor or None
|
|
23
|
+
Optional binary mask of shape (G, K) where 1 means gene↔factor allowed.
|
|
24
|
+
init_sd : float
|
|
25
|
+
Std for normal init of decoder weights.
|
|
26
|
+
"""
|
|
27
|
+
def __init__(self, n_genes: int, n_latent: int,
|
|
28
|
+
mask: torch.Tensor = None,
|
|
29
|
+
init_sd: float = 0.02,
|
|
30
|
+
learn_var: bool = False):
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.n_genes = n_genes
|
|
33
|
+
self.n_latent = n_latent
|
|
34
|
+
self.learn_var = learn_var
|
|
35
|
+
|
|
36
|
+
# Weight matrix W: (G, K)
|
|
37
|
+
self.W = nn.Parameter(torch.randn(n_genes, n_latent) * init_sd)
|
|
38
|
+
self.bias = nn.Parameter(torch.zeros(n_genes))
|
|
39
|
+
|
|
40
|
+
# Mask (non-trainable)
|
|
41
|
+
if mask is not None:
|
|
42
|
+
assert mask.shape == (n_genes, n_latent)
|
|
43
|
+
self.register_buffer("mask", mask.float())
|
|
44
|
+
else:
|
|
45
|
+
self.mask = None
|
|
46
|
+
|
|
47
|
+
# Gene-specific variance (optional)
|
|
48
|
+
if self.learn_var:
|
|
49
|
+
self.log_var = nn.Parameter(torch.zeros(n_genes))
|
|
50
|
+
else:
|
|
51
|
+
self.log_var = None
|
|
52
|
+
|
|
53
|
+
def forward(self, z: torch.Tensor):
|
|
54
|
+
"""
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
z : torch.Tensor
|
|
58
|
+
Latent codes, shape (batch, K).
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
recon_x : torch.Tensor
|
|
63
|
+
Reconstructed gene expression (batch, G).
|
|
64
|
+
log_var : torch.Tensor
|
|
65
|
+
Gene-specific log-variance (G,).
|
|
66
|
+
"""
|
|
67
|
+
# Apply mask if provided
|
|
68
|
+
W_eff = self.W
|
|
69
|
+
if self.mask is not None:
|
|
70
|
+
W_eff = W_eff * self.mask # elementwise mask
|
|
71
|
+
|
|
72
|
+
recon_x = z @ W_eff.T + self.bias
|
|
73
|
+
return recon_x, self.log_var
|
|
74
|
+
|
|
75
|
+
# Regularizers
|
|
76
|
+
def group_sparsity_penalty(self, l1_strength: float = 1e-3):
|
|
77
|
+
"""
|
|
78
|
+
Group sparsity across genes per latent factor.
|
|
79
|
+
"""
|
|
80
|
+
W_eff = self.W * self.mask if self.mask is not None else self.W
|
|
81
|
+
return l1_strength * torch.sum(torch.abs(W_eff))
|
|
82
|
+
|
|
83
|
+
def laplacian_penalty(self, L: torch.Tensor, lap_strength: float = 1e-3):
|
|
84
|
+
"""
|
|
85
|
+
Laplacian smoothness: tr(W^T L W).
|
|
86
|
+
"""
|
|
87
|
+
W_eff = self.W * self.mask if self.mask is not None else self.W
|
|
88
|
+
LW = torch.sparse.mm(L, W_eff) if L.is_sparse else torch.matmul(L, W_eff)
|
|
89
|
+
penalty = torch.sum(W_eff * LW) # trace approx
|
|
90
|
+
return lap_strength * penalty
|
bsvae/models/encoder.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Encoder for StructuredFactorVAE
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
import torch.nn as nn
|
|
7
|
+
import torch.nn.functional as F
|
|
8
|
+
|
|
9
|
+
class StructuredEncoder(nn.Module):
|
|
10
|
+
"""
|
|
11
|
+
Encoder for StructuredFactorVAE.
|
|
12
|
+
Maps normalized gene expression (log-CPM/TPM) to latent Gaussian parameters.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
n_genes : int
|
|
17
|
+
Number of input genes (features).
|
|
18
|
+
n_latent : int
|
|
19
|
+
Dimensionality of latent space (number of biological modules).
|
|
20
|
+
hidden_dims : list of int, optional
|
|
21
|
+
Sizes of hidden layers. Default: [512, 256, 128].
|
|
22
|
+
dropout : float
|
|
23
|
+
Dropout probability applied after hidden layers.
|
|
24
|
+
"""
|
|
25
|
+
def __init__(self, n_genes: int, n_latent: int,
|
|
26
|
+
hidden_dims: list[int] = None,
|
|
27
|
+
dropout: float = 0.1):
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.n_genes = n_genes
|
|
30
|
+
self.n_latent = n_latent
|
|
31
|
+
self.hidden_dims = hidden_dims if hidden_dims is not None else [512, 256, 128]
|
|
32
|
+
self.dropout = dropout
|
|
33
|
+
|
|
34
|
+
# Build feedforward encoder network
|
|
35
|
+
modules = []
|
|
36
|
+
input_dim = n_genes
|
|
37
|
+
for h_dim in self.hidden_dims:
|
|
38
|
+
modules.append(nn.Linear(input_dim, h_dim))
|
|
39
|
+
modules.append(nn.ReLU())
|
|
40
|
+
if dropout > 0:
|
|
41
|
+
modules.append(nn.Dropout(dropout))
|
|
42
|
+
input_dim = h_dim
|
|
43
|
+
self.encoder = nn.Sequential(*modules)
|
|
44
|
+
|
|
45
|
+
# Outputs: mean and log-variance
|
|
46
|
+
self.fc_mu = nn.Linear(input_dim, n_latent)
|
|
47
|
+
self.fc_logvar = nn.Linear(input_dim, n_latent)
|
|
48
|
+
|
|
49
|
+
def forward(self, x: torch.Tensor):
|
|
50
|
+
"""
|
|
51
|
+
Forward pass through encoder.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
x : torch.Tensor
|
|
56
|
+
Input expression data (batch, G).
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
mu : torch.Tensor
|
|
61
|
+
Latent mean (batch, K).
|
|
62
|
+
logvar : torch.Tensor
|
|
63
|
+
Latent log variance (batch, K).
|
|
64
|
+
z : torch.Tensor
|
|
65
|
+
Sampled latent code (batch, K).
|
|
66
|
+
"""
|
|
67
|
+
h = self.encoder(x)
|
|
68
|
+
mu = self.fc_mu(h)
|
|
69
|
+
logvar = self.fc_logvar(h)
|
|
70
|
+
|
|
71
|
+
return mu, logvar
|
bsvae/models/losses.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Losses for StructuredFactorVAE.
|
|
3
|
+
"""
|
|
4
|
+
import torch
|
|
5
|
+
import torch.nn as nn
|
|
6
|
+
import torch.nn.functional as F
|
|
7
|
+
from typing import Optional, Dict
|
|
8
|
+
|
|
9
|
+
def gaussian_nll(x, recon_x, log_var=None, reduction="mean"):
|
|
10
|
+
"""
|
|
11
|
+
Gaussian negative log-likelihood per gene.
|
|
12
|
+
"""
|
|
13
|
+
if log_var is None:
|
|
14
|
+
return F.mse_loss(recon_x, x, reduction=reduction)
|
|
15
|
+
|
|
16
|
+
var = torch.exp(log_var)
|
|
17
|
+
nll = 0.5 * (torch.log(2 * torch.pi * var) + (x - recon_x) ** 2 / var)
|
|
18
|
+
if reduction == "mean":
|
|
19
|
+
return nll.mean()
|
|
20
|
+
elif reduction == "sum":
|
|
21
|
+
return nll.sum()
|
|
22
|
+
else:
|
|
23
|
+
return nll
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def kl_normal_loss(mu, logvar, reduction="sum"):
|
|
27
|
+
"""
|
|
28
|
+
KL divergence between q(z|x)=N(mu, sigma^2) and prior p(z)=N(0,I).
|
|
29
|
+
"""
|
|
30
|
+
kl = -0.5 * (1 + logvar - mu.pow(2) - logvar.exp())
|
|
31
|
+
if reduction == "sum":
|
|
32
|
+
return kl.sum()
|
|
33
|
+
elif reduction == "mean":
|
|
34
|
+
return kl.mean()
|
|
35
|
+
else:
|
|
36
|
+
return kl
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BaseLoss(nn.Module):
|
|
40
|
+
"""
|
|
41
|
+
Base loss for StructuredFactorVAE with biological regularizers.
|
|
42
|
+
"""
|
|
43
|
+
def __init__(self, beta: float = 1.0,
|
|
44
|
+
l1_strength: float = 1e-3,
|
|
45
|
+
lap_strength: float = 1e-4,
|
|
46
|
+
record_loss_every: int = 50):
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.beta = beta
|
|
49
|
+
self.l1_strength = l1_strength
|
|
50
|
+
self.lap_strength = lap_strength
|
|
51
|
+
self.n_train_steps = 0
|
|
52
|
+
self.record_loss_every = record_loss_every
|
|
53
|
+
|
|
54
|
+
def forward(self,
|
|
55
|
+
x: torch.Tensor, recon_x: torch.Tensor,
|
|
56
|
+
mu: torch.Tensor, logvar: torch.Tensor,
|
|
57
|
+
model: nn.Module,
|
|
58
|
+
L: Optional[torch.Tensor] = None,
|
|
59
|
+
storer: Optional[Dict[str, list]] = None,
|
|
60
|
+
is_train: bool = True) -> torch.Tensor:
|
|
61
|
+
"""
|
|
62
|
+
Compute StructuredFactorVAE loss.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
x : torch.Tensor
|
|
67
|
+
Input data (batch, G).
|
|
68
|
+
recon_x : torch.Tensor
|
|
69
|
+
Reconstruction from decoder (batch, G).
|
|
70
|
+
mu : torch.Tensor
|
|
71
|
+
Latent mean (batch, K).
|
|
72
|
+
logvar : torch.Tensor
|
|
73
|
+
Latent log variance (batch, K).
|
|
74
|
+
model : nn.Module
|
|
75
|
+
Structured decoder (with sparsity/laplacian methods).
|
|
76
|
+
storer : dict
|
|
77
|
+
Dictionary for logging intermediate values.
|
|
78
|
+
is_train : bool
|
|
79
|
+
Whether in training mode.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
loss : torch.Tensor
|
|
84
|
+
Total loss for this batch.
|
|
85
|
+
"""
|
|
86
|
+
# Reconstruction loss: Gaussian NLL if available, else MSE
|
|
87
|
+
if hasattr(model.decoder, "log_var") and model.decoder.log_var is not None:
|
|
88
|
+
recon_loss = gaussian_nll(x, recon_x, log_var=model.decoder.log_var,
|
|
89
|
+
reduction="mean")
|
|
90
|
+
else:
|
|
91
|
+
recon_loss = F.mse_loss(recon_x, x, reduction="mean")
|
|
92
|
+
|
|
93
|
+
# KL divergence
|
|
94
|
+
kl_loss = kl_normal_loss(mu, logvar, reduction="mean")
|
|
95
|
+
|
|
96
|
+
# Biological regularizers
|
|
97
|
+
sparsity_loss = model.decoder.group_sparsity_penalty(self.l1_strength)
|
|
98
|
+
laplacian_loss = 0.0
|
|
99
|
+
if model.laplacian_matrix is not None:
|
|
100
|
+
laplacian_loss = model.decoder.laplacian_penalty(
|
|
101
|
+
model.laplacian_matrix, self.lap_strength
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Total loss
|
|
105
|
+
loss = recon_loss + self.beta * kl_loss + sparsity_loss + laplacian_loss
|
|
106
|
+
|
|
107
|
+
# Logging
|
|
108
|
+
if storer is not None:
|
|
109
|
+
storer.setdefault("recon_loss", []).append(recon_loss.item())
|
|
110
|
+
storer.setdefault("kl_loss", []).append(kl_loss.item())
|
|
111
|
+
storer.setdefault("sparsity_loss", []).append(sparsity_loss.detach().item())
|
|
112
|
+
if model.laplacian_matrix is not None:
|
|
113
|
+
storer.setdefault("laplacian_loss", []).append(laplacian_loss.detach().item())
|
|
114
|
+
storer.setdefault("loss", []).append(loss.item())
|
|
115
|
+
|
|
116
|
+
return loss
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured Factor VAE: wraps encoder + decoder for end-to-end training.
|
|
3
|
+
"""
|
|
4
|
+
import torch
|
|
5
|
+
import torch.nn as nn
|
|
6
|
+
from .vae import BaseVAE
|
|
7
|
+
from .encoder import StructuredEncoder
|
|
8
|
+
from .decoder import StructuredDecoder
|
|
9
|
+
from ..utils.initialization import weights_init
|
|
10
|
+
|
|
11
|
+
class StructuredFactorVAE(BaseVAE):
|
|
12
|
+
"""
|
|
13
|
+
Structured Factor VAE for gene expression.
|
|
14
|
+
|
|
15
|
+
Encoder: maps log-CPM input (batch, G) → latent mean + logvar.
|
|
16
|
+
Decoder: maps latent z → reconstructed expression (batch, G).
|
|
17
|
+
Supports optional gene-specific variance (log_var).
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
n_genes : int
|
|
22
|
+
Number of input genes.
|
|
23
|
+
n_latent : int
|
|
24
|
+
Number of latent dimensions (modules).
|
|
25
|
+
hidden_dims : list of int
|
|
26
|
+
Hidden layer sizes for encoder.
|
|
27
|
+
dropout : float
|
|
28
|
+
Dropout probability in encoder.
|
|
29
|
+
mask : torch.Tensor or None
|
|
30
|
+
Optional binary gene×module mask for decoder (G, K).
|
|
31
|
+
init_sd : float
|
|
32
|
+
Std for decoder weight initialization.
|
|
33
|
+
learn_var : bool
|
|
34
|
+
If True, decoder learns per-gene log variance.
|
|
35
|
+
"""
|
|
36
|
+
def __init__(self, n_genes: int, n_latent: int,
|
|
37
|
+
hidden_dims=None, dropout: float = 0.1,
|
|
38
|
+
mask: torch.Tensor = None,
|
|
39
|
+
init_sd: float = 0.02,
|
|
40
|
+
learn_var: bool = False,
|
|
41
|
+
L: torch.Tensor = None):
|
|
42
|
+
super().__init__(n_genes, n_latent)
|
|
43
|
+
self.hidden_dims = hidden_dims or [512, 256, 128]
|
|
44
|
+
self.dropout = dropout
|
|
45
|
+
self.encoder = StructuredEncoder(
|
|
46
|
+
n_genes=n_genes,
|
|
47
|
+
n_latent=n_latent,
|
|
48
|
+
hidden_dims=hidden_dims,
|
|
49
|
+
dropout=dropout
|
|
50
|
+
)
|
|
51
|
+
self.decoder = StructuredDecoder(
|
|
52
|
+
n_genes=n_genes,
|
|
53
|
+
n_latent=n_latent,
|
|
54
|
+
mask=mask,
|
|
55
|
+
init_sd=init_sd,
|
|
56
|
+
learn_var=learn_var
|
|
57
|
+
)
|
|
58
|
+
self.laplacian_matrix = L
|
|
59
|
+
|
|
60
|
+
def forward(self, x: torch.Tensor):
|
|
61
|
+
mu, logvar = self.encoder(x)
|
|
62
|
+
z = self.reparameterize(mu, logvar)
|
|
63
|
+
recon_x, log_var = self.decoder(z)
|
|
64
|
+
return recon_x, mu, logvar, z, log_var
|
|
65
|
+
|
|
66
|
+
def group_sparsity_penalty(self, l1_strength: float = 1e-3):
|
|
67
|
+
return self.decoder.group_sparsity_penalty(l1_strength)
|
|
68
|
+
|
|
69
|
+
def laplacian_penalty(self, L: torch.Tensor, lap_strength: float = 1e-3):
|
|
70
|
+
return self.decoder.laplacian_penalty(L, lap_strength)
|
|
71
|
+
|
|
72
|
+
def reset_parameters(self, activation: str = "relu"):
|
|
73
|
+
"""Reset all learnable parameters with custom init."""
|
|
74
|
+
self.activation = activation
|
|
75
|
+
self.apply(lambda m: weights_init(m, activation=self.activation))
|