embkit 0.1.0__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.
- embkit-0.1.0/PKG-INFO +42 -0
- embkit-0.1.0/README.md +27 -0
- embkit-0.1.0/pyproject.toml +29 -0
- embkit-0.1.0/setup.cfg +4 -0
- embkit-0.1.0/setup.py +10 -0
- embkit-0.1.0/src/embkit/__init__.py +0 -0
- embkit-0.1.0/src/embkit/__main__.py +14 -0
- embkit-0.1.0/src/embkit/align.py +59 -0
- embkit-0.1.0/src/embkit/bmeg.py +24 -0
- embkit-0.1.0/src/embkit/commands.py +27 -0
- embkit-0.1.0/src/embkit/constraints/__init__.py +1 -0
- embkit-0.1.0/src/embkit/constraints/network_constraint.py +54 -0
- embkit-0.1.0/src/embkit/estimator/__init__.py +0 -0
- embkit-0.1.0/src/embkit/estimator/vae_estimator.py +67 -0
- embkit-0.1.0/src/embkit/layers/__init__.py +1 -0
- embkit-0.1.0/src/embkit/layers/masked_linear.py +28 -0
- embkit-0.1.0/src/embkit/losses/__init__.py +1 -0
- embkit-0.1.0/src/embkit/losses/vae_loss.py +16 -0
- embkit-0.1.0/src/embkit/models/__init__.py +1 -0
- embkit-0.1.0/src/embkit/models/vae_model/__init__.py +3 -0
- embkit-0.1.0/src/embkit/models/vae_model/base_vae.py +226 -0
- embkit-0.1.0/src/embkit/models/vae_model/decoder.py +17 -0
- embkit-0.1.0/src/embkit/models/vae_model/encoder.py +99 -0
- embkit-0.1.0/src/embkit/models/vae_model/net_vae.py +186 -0
- embkit-0.1.0/src/embkit/normalize.py +41 -0
- embkit-0.1.0/src/embkit/pathway.py +8 -0
- embkit-0.1.0/src/embkit/vae.py +97 -0
- embkit-0.1.0/src/embkit.egg-info/PKG-INFO +42 -0
- embkit-0.1.0/src/embkit.egg-info/SOURCES.txt +30 -0
- embkit-0.1.0/src/embkit.egg-info/dependency_links.txt +1 -0
- embkit-0.1.0/src/embkit.egg-info/requires.txt +5 -0
- embkit-0.1.0/src/embkit.egg-info/top_level.txt +1 -0
embkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: embkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Embedding Kit for embedding models
|
|
5
|
+
Author-email: Kyle Ellrott <ellrott@ohsu.edu>, Raphael Kirchgaessner <kirchgae@ohsu.edu>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: embeddings,machine learning,deep learning
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: torch>=2.0
|
|
11
|
+
Requires-Dist: pandas
|
|
12
|
+
Requires-Dist: numpy
|
|
13
|
+
Requires-Dist: click
|
|
14
|
+
Requires-Dist: scikit-learn
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Embedding Kit
|
|
18
|
+
|
|
19
|
+
Methods for data normalizatio, embedding, synthesis and transformation
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
```
|
|
24
|
+
python setup.py build
|
|
25
|
+
python setup.py install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
# Training a model
|
|
29
|
+
```
|
|
30
|
+
python -m embkit model train ./experiments/tcga/tumor.normalized.tsv --epochs 120
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## Notebook cleaning
|
|
37
|
+
|
|
38
|
+
To avoid checking in large notebooks with lots of intermediate figures, please follow the instructions at https://gist.github.com/33eyes/431e3d432f73371509d176d0dfb95b6e
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
Running build
|
|
42
|
+
|
embkit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
|
|
2
|
+
# Embedding Kit
|
|
3
|
+
|
|
4
|
+
Methods for data normalizatio, embedding, synthesis and transformation
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
```
|
|
9
|
+
python setup.py build
|
|
10
|
+
python setup.py install
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
# Training a model
|
|
14
|
+
```
|
|
15
|
+
python -m embkit model train ./experiments/tcga/tumor.normalized.tsv --epochs 120
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Notebook cleaning
|
|
22
|
+
|
|
23
|
+
To avoid checking in large notebooks with lots of intermediate figures, please follow the instructions at https://gist.github.com/33eyes/431e3d432f73371509d176d0dfb95b6e
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Running build
|
|
27
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "embkit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Embedding Kit for embedding models"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Kyle Ellrott", email = "ellrott@ohsu.edu" },
|
|
14
|
+
{ name = "Raphael Kirchgaessner", email = "kirchgae@ohsu.edu" }
|
|
15
|
+
]
|
|
16
|
+
keywords = ["embeddings", "machine learning", "deep learning"]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"torch>=2.0",
|
|
19
|
+
"pandas",
|
|
20
|
+
"numpy",
|
|
21
|
+
"click",
|
|
22
|
+
"scikit-learn"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
package-dir = { "" = "src" }
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
embkit-0.1.0/setup.cfg
ADDED
embkit-0.1.0/setup.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from scipy.stats import spearmanr
|
|
4
|
+
from scipy.optimize import linear_sum_assignment
|
|
5
|
+
|
|
6
|
+
def calc_rmsd(array1, array2):
|
|
7
|
+
if len(array1) != len(array2):
|
|
8
|
+
raise ValueError("Arrays must be of the same length")
|
|
9
|
+
|
|
10
|
+
diff = array1 - array2
|
|
11
|
+
squared_diff = diff ** 2
|
|
12
|
+
mean_squared_diff = np.mean(squared_diff)
|
|
13
|
+
rmsd = np.sqrt(mean_squared_diff)
|
|
14
|
+
|
|
15
|
+
return rmsd
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def matrix_spearman_alignment_set(a,b,cuttoff=0.0):
|
|
19
|
+
"""
|
|
20
|
+
matrix_spearman_alignment_set
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
# identify overlapping columns
|
|
24
|
+
isect = a.columns.intersection( b.columns )
|
|
25
|
+
# run spearman calculation on common features
|
|
26
|
+
o = spearmanr( a[isect], b[isect], axis=1 )
|
|
27
|
+
# use the corner of the correlation matrix that is the intersection of the 2 matrices
|
|
28
|
+
a_count=a.shape[0]
|
|
29
|
+
sdf = pd.DataFrame( o.correlation[a_count:,:a_count], index=b.index, columns=a.index)
|
|
30
|
+
# the matrix represents the edge weights in a bi-partite graph, use linear sum assignment
|
|
31
|
+
# to obtain the maximal set
|
|
32
|
+
mapping = linear_sum_assignment( sdf, True )
|
|
33
|
+
# remap output to dict
|
|
34
|
+
out = {}
|
|
35
|
+
for k, v in zip(mapping[0], mapping[1]):
|
|
36
|
+
c = sdf.iloc[ k, v ]
|
|
37
|
+
if c >= cuttoff:
|
|
38
|
+
out[ a.index[v] ] = (b.index[k], c)
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def procrustes(X, Y):
|
|
43
|
+
"""
|
|
44
|
+
Computes the Procrustes transformation between two matrices.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
X: The first matrix.
|
|
48
|
+
Y: The second matrix.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Rotation matrix
|
|
52
|
+
"""
|
|
53
|
+
# Ensure matrices have the same dimensions
|
|
54
|
+
assert X.shape == Y.shape
|
|
55
|
+
# Compute the singular value decomposition (SVD)
|
|
56
|
+
U, S, Vt = np.linalg.svd(np.dot(X.T, Y))
|
|
57
|
+
# Determine the rotation matrix
|
|
58
|
+
R = np.dot(Vt.T, U.T)
|
|
59
|
+
return R.T
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import gripql
|
|
2
|
+
|
|
3
|
+
def connect(url="https://bmeg.io/grip", graph="rc6", cred_file="./bmeg_credentials.json"):
|
|
4
|
+
"""
|
|
5
|
+
Establish a connection to the BMeg server and return the specified graph.
|
|
6
|
+
|
|
7
|
+
Parameters:
|
|
8
|
+
url (str): The URL of the BMeg server. Defaults to "https://bmeg.io/grip".
|
|
9
|
+
graph (str): The name of the graph to retrieve. Defaults to "rc6".
|
|
10
|
+
cred_file (str): The path to the JSON file containing the credentials for the BMeg server. Defaults to "./bmeg_credentials.json".
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
gripql.Graph: A Graph object from the specified BMeg server and graph.
|
|
14
|
+
"""
|
|
15
|
+
conn = gripql.Connection(url=url, credential_file=cred_file)
|
|
16
|
+
G = conn.graph(graph)
|
|
17
|
+
return G
|
|
18
|
+
|
|
19
|
+
def get_gene_map(G):
|
|
20
|
+
"""
|
|
21
|
+
Build mapping from Ensemb Gene IDs to Hugo Symbols
|
|
22
|
+
"""
|
|
23
|
+
gm = dict( (i[0],i[1]) for i in G.query().V().hasLabel("Gene").render(["gene_id", "symbol"]).execute() )
|
|
24
|
+
return gm
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from .vae import VAE
|
|
4
|
+
|
|
5
|
+
@click.group()
|
|
6
|
+
def model():
|
|
7
|
+
"""
|
|
8
|
+
model - model grouping
|
|
9
|
+
"""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@model.command()
|
|
14
|
+
@click.argument("input")
|
|
15
|
+
@click.option("--latent", default=256)
|
|
16
|
+
@click.option("--epochs", default=20)
|
|
17
|
+
def train(input, latent, epochs):
|
|
18
|
+
"""
|
|
19
|
+
Train VAE model
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
df = pd.read_csv(input, sep="\t", index_col=0)
|
|
23
|
+
|
|
24
|
+
vae = VAE(input_dim=df.shape[1], hidden_dim=400, latent_dim=latent)
|
|
25
|
+
|
|
26
|
+
vae.fit(df, epochs=epochs)
|
|
27
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .network_constraint import NetworkConstraint
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import List, Dict, Optional
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import torch
|
|
5
|
+
|
|
6
|
+
class NetworkConstraint:
|
|
7
|
+
"""
|
|
8
|
+
PyTorch-native mask manager that mirrors the Keras constraint logic.
|
|
9
|
+
|
|
10
|
+
feature_index: list of feature ids (len = input_dim)
|
|
11
|
+
latent_index: list of latent ids (len = latent_dim)
|
|
12
|
+
latent_membership: dict mapping latent id -> list of feature ids allowed to connect
|
|
13
|
+
If None or inactive, mask is all ones (no constraint).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self,
|
|
17
|
+
feature_index: List[str],
|
|
18
|
+
latent_index: List[str],
|
|
19
|
+
latent_membership: Optional[Dict[str, List[str]]] = None):
|
|
20
|
+
self.feature_index = list(feature_index)
|
|
21
|
+
self.latent_index = list(latent_index)
|
|
22
|
+
self.latent_membership = latent_membership
|
|
23
|
+
self.active = True
|
|
24
|
+
self._mask_np = None
|
|
25
|
+
self.update()
|
|
26
|
+
|
|
27
|
+
def update_membership(self, latent_membership: Dict[str, List[str]]):
|
|
28
|
+
self.latent_membership = latent_membership
|
|
29
|
+
self.update()
|
|
30
|
+
|
|
31
|
+
def set_active(self, a: bool):
|
|
32
|
+
self.active = a
|
|
33
|
+
self.update()
|
|
34
|
+
|
|
35
|
+
def update(self):
|
|
36
|
+
in_dim = len(self.feature_index)
|
|
37
|
+
out_dim = len(self.latent_index)
|
|
38
|
+
if not self.active or self.latent_membership is None:
|
|
39
|
+
self._mask_np = np.ones((out_dim, in_dim), dtype=np.float32)
|
|
40
|
+
return
|
|
41
|
+
mask = np.zeros((out_dim, in_dim), dtype=np.float32)
|
|
42
|
+
fi = pd.Index(self.feature_index)
|
|
43
|
+
li = pd.Index(self.latent_index)
|
|
44
|
+
for latent in self.latent_index:
|
|
45
|
+
if latent not in self.latent_membership:
|
|
46
|
+
continue
|
|
47
|
+
latent_pos = li.get_loc(latent)
|
|
48
|
+
for f in self.latent_membership[latent]:
|
|
49
|
+
if f in fi:
|
|
50
|
+
mask[latent_pos, fi.get_loc(f)] = 1.0
|
|
51
|
+
self._mask_np = mask
|
|
52
|
+
|
|
53
|
+
def as_torch(self, device: torch.device) -> torch.Tensor:
|
|
54
|
+
return torch.tensor(self._mask_np, device=device)
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from sklearn.base import BaseEstimator
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class VAEEstimator(BaseEstimator):
|
|
6
|
+
"""
|
|
7
|
+
Scikit-learn style wrapper for quick experiments.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, latent_dim: int = 100, learning_rate: float = 1e-4,
|
|
11
|
+
batch_size: int = 30, epochs: int = 130, beta: float = 0.0,
|
|
12
|
+
device: Optional[str] = None):
|
|
13
|
+
self.latent_dim = latent_dim
|
|
14
|
+
self.learning_rate = learning_rate
|
|
15
|
+
self.batch_size = batch_size
|
|
16
|
+
self.epochs = epochs
|
|
17
|
+
self.beta = beta
|
|
18
|
+
self.history = None
|
|
19
|
+
self.model: Optional[VAE] = None
|
|
20
|
+
self.device = device
|
|
21
|
+
|
|
22
|
+
def fit(self, X: pd.DataFrame):
|
|
23
|
+
feature_dim = X.shape[1]
|
|
24
|
+
features = list(X.columns)
|
|
25
|
+
|
|
26
|
+
# Build unconstrained encoder/decoder
|
|
27
|
+
encoder = build_encoder(feature_dim, self.latent_dim, constraint=None)
|
|
28
|
+
decoder = build_decoder(feature_dim, self.latent_dim)
|
|
29
|
+
vae = VAE(features, encoder, decoder)
|
|
30
|
+
|
|
31
|
+
device = self.device or ('cuda' if torch.cuda.is_available() else 'cpu')
|
|
32
|
+
device = torch.device(device)
|
|
33
|
+
vae.to(device)
|
|
34
|
+
|
|
35
|
+
x = torch.tensor(X.values, dtype=torch.float32, device=device)
|
|
36
|
+
loader = DataLoader(TensorDataset(x), batch_size=self.batch_size, shuffle=True)
|
|
37
|
+
opt = torch.optim.Adam(vae.parameters(), lr=self.learning_rate)
|
|
38
|
+
|
|
39
|
+
loss_hist = []
|
|
40
|
+
for _ in range(self.epochs):
|
|
41
|
+
vae.train()
|
|
42
|
+
tot = 0.0
|
|
43
|
+
n = 0
|
|
44
|
+
for (batch_x,) in loader:
|
|
45
|
+
opt.zero_grad()
|
|
46
|
+
total, _, _ = vae_loss_from_model(vae, batch_x)
|
|
47
|
+
total.backward()
|
|
48
|
+
opt.step()
|
|
49
|
+
tot += float(total.item())
|
|
50
|
+
n += 1
|
|
51
|
+
loss_hist.append(tot / max(1, n))
|
|
52
|
+
|
|
53
|
+
self.history = {"loss": loss_hist}
|
|
54
|
+
self.model = vae
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def score(self, X: pd.DataFrame):
|
|
58
|
+
if self.model is None:
|
|
59
|
+
raise RuntimeError("Model not fitted.")
|
|
60
|
+
self.model.eval()
|
|
61
|
+
with torch.no_grad():
|
|
62
|
+
x = torch.tensor(X.values, dtype=torch.float32, device=next(self.model.parameters()).device)
|
|
63
|
+
mu, _, _ = self.model.encoder(x)
|
|
64
|
+
recon = self.model.decoder(mu).cpu().numpy()
|
|
65
|
+
return -mean_squared_error(X.values, recon)
|
|
66
|
+
|
|
67
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .masked_linear import MaskedLinear
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from torch import nn
|
|
2
|
+
from typing import Optional
|
|
3
|
+
import torch
|
|
4
|
+
import torch.nn.functional as F
|
|
5
|
+
|
|
6
|
+
class MaskedLinear(nn.Module):
|
|
7
|
+
"""
|
|
8
|
+
Linear layer whose weight is elementwise-multiplied by a mask at forward time.
|
|
9
|
+
PyTorch Linear weight shape: (out_features, in_features). Mask must match this shape.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, in_features: int, out_features: int, bias: bool = True,
|
|
13
|
+
mask: Optional[torch.Tensor] = None):
|
|
14
|
+
super().__init__()
|
|
15
|
+
self.linear = nn.Linear(in_features, out_features, bias=bias)
|
|
16
|
+
if mask is None:
|
|
17
|
+
mask = torch.ones(out_features, in_features)
|
|
18
|
+
self.register_buffer("mask", mask)
|
|
19
|
+
|
|
20
|
+
def set_mask(self, mask: torch.Tensor):
|
|
21
|
+
assert mask.shape == self.linear.weight.shape
|
|
22
|
+
self.mask = mask
|
|
23
|
+
|
|
24
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
25
|
+
w = self.linear.weight * self.mask
|
|
26
|
+
return F.linear(x, w, self.linear.bias)
|
|
27
|
+
|
|
28
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .vae_loss import vae_loss_from_model
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
from src.embkit.models import VAE
|
|
3
|
+
import torch.nn.functional as F
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def vae_loss_from_model(model: VAE, x: torch.Tensor):
|
|
9
|
+
mu, logvar, z = model.encoder(x)
|
|
10
|
+
reconstruction = model.decoder(z)
|
|
11
|
+
# keras: x.shape[1] * binary_crossentropy(x, reconstruction)
|
|
12
|
+
bce_per_sample = F.binary_cross_entropy(reconstruction, x, reduction="none").mean(dim=1)
|
|
13
|
+
reconstruction_loss = x.size(1) * bce_per_sample
|
|
14
|
+
kl_loss = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp(), dim=1)
|
|
15
|
+
total_loss = reconstruction_loss + kl_loss
|
|
16
|
+
return total_loss.mean(), reconstruction_loss.mean(), kl_loss.mean()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .vae_model import VAE
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from torch import nn
|
|
2
|
+
from typing import Type, Any, List, Optional, Dict, overload, TypeVar
|
|
3
|
+
from abc import ABC
|
|
4
|
+
import torch
|
|
5
|
+
from .encoder import Encoder
|
|
6
|
+
from .decoder import Decoder
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VAE(nn.Module, ABC):
|
|
17
|
+
"""
|
|
18
|
+
Minimal VAE wrapper to hold encoder/decoder and provide forward().
|
|
19
|
+
Allows late-binding of encoder/decoder by subclasses.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def build_encoder(feature_dim: int, latent_dim: int,
|
|
24
|
+
layers: List[Dict] = None,
|
|
25
|
+
constraint=None,
|
|
26
|
+
batch_norm: bool = False,
|
|
27
|
+
activation: str = "relu") -> Encoder:
|
|
28
|
+
return Encoder(
|
|
29
|
+
feature_dim=feature_dim,
|
|
30
|
+
latent_dim=latent_dim,
|
|
31
|
+
layers=layers,
|
|
32
|
+
constraint=constraint,
|
|
33
|
+
batch_norm=batch_norm,
|
|
34
|
+
activation=activation
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def build_decoder(feature_dim: int, latent_dim: int) -> Decoder:
|
|
39
|
+
return Decoder(latent_dim, feature_dim)
|
|
40
|
+
|
|
41
|
+
# Overload 1: no model_cls provided -> returns NetVae
|
|
42
|
+
|
|
43
|
+
@overload
|
|
44
|
+
@staticmethod
|
|
45
|
+
def open_model(
|
|
46
|
+
path: str,
|
|
47
|
+
device: Optional[str] = ...,
|
|
48
|
+
model_cls: None = ...,
|
|
49
|
+
model_kwargs: Optional[Dict[str, Any]] = ...
|
|
50
|
+
) -> "NetVae":
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
# Overload 2: model_cls provided -> returns that class
|
|
54
|
+
@overload
|
|
55
|
+
@staticmethod
|
|
56
|
+
def open_model(
|
|
57
|
+
path: str,
|
|
58
|
+
device: Optional[str] = ...,
|
|
59
|
+
model_cls: Type[T] = ...,
|
|
60
|
+
model_kwargs: Optional[Dict[str, Any]] = ...
|
|
61
|
+
) -> T:
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def open_model(
|
|
66
|
+
path: str,
|
|
67
|
+
device: Optional[str] = None,
|
|
68
|
+
model_cls: Optional[Type[T]] = None,
|
|
69
|
+
model_kwargs: Optional[Dict[str, Any]] = None
|
|
70
|
+
) -> T:
|
|
71
|
+
"""
|
|
72
|
+
Load a model saved by NetVae.save (PyTorch-compatible).
|
|
73
|
+
You can select the container class via `model_cls` (default: NetVae).
|
|
74
|
+
The container is expected to accept at least: (features, encoder=..., decoder=...).
|
|
75
|
+
|
|
76
|
+
Works with or without net.groups.tsv:
|
|
77
|
+
- If groups exist: load names + groups
|
|
78
|
+
- Else: try latent.index
|
|
79
|
+
- Else: infer latent_dim from encoder weights and synthesize names
|
|
80
|
+
"""
|
|
81
|
+
if device is None:
|
|
82
|
+
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
|
83
|
+
device = torch.device(device)
|
|
84
|
+
|
|
85
|
+
model_kwargs = dict(model_kwargs or {})
|
|
86
|
+
|
|
87
|
+
# --- Features (required) ---
|
|
88
|
+
index_path = Path(path, "index")
|
|
89
|
+
if not Path(index_path).exists():
|
|
90
|
+
raise FileNotFoundError(f"Missing features index file: {index_path}")
|
|
91
|
+
with open(index_path, "rt", encoding="ascii") as handle:
|
|
92
|
+
features = json.load(handle)
|
|
93
|
+
feature_dim = len(features)
|
|
94
|
+
|
|
95
|
+
# --- Determine latent names/dim ---
|
|
96
|
+
groups_path = Path(path, "net.groups.tsv")
|
|
97
|
+
latent_idx_path = Path(path, "latent.index")
|
|
98
|
+
groups: Optional[Dict[str, List[str]]] = None
|
|
99
|
+
latent_index: Optional[List[str]] = None
|
|
100
|
+
latent_dim: Optional[int] = None
|
|
101
|
+
|
|
102
|
+
if Path(groups_path).exists():
|
|
103
|
+
latent_index = []
|
|
104
|
+
groups = {}
|
|
105
|
+
with open(groups_path, "rt", encoding="ascii") as fh:
|
|
106
|
+
for line in fh:
|
|
107
|
+
row = line.rstrip().split("\t")
|
|
108
|
+
if not row or row[0] == "":
|
|
109
|
+
continue
|
|
110
|
+
latent_index.append(row[0])
|
|
111
|
+
groups[row[0]] = row[1:]
|
|
112
|
+
latent_dim = len(latent_index)
|
|
113
|
+
elif Path(latent_idx_path).exists():
|
|
114
|
+
with open(latent_idx_path, "rt", encoding="ascii") as fh:
|
|
115
|
+
latent_index = [ln.strip() for ln in fh if ln.strip() != ""]
|
|
116
|
+
if latent_index:
|
|
117
|
+
latent_dim = len(latent_index)
|
|
118
|
+
else:
|
|
119
|
+
latent_index = None # fall through to weight inference
|
|
120
|
+
|
|
121
|
+
if latent_dim is None:
|
|
122
|
+
# --- Infer from encoder weights ---
|
|
123
|
+
enc_state_path = Path(path, "model.enc.pt")
|
|
124
|
+
if not Path(enc_state_path).exists():
|
|
125
|
+
raise FileNotFoundError(f"Missing encoder weights: {enc_state_path}")
|
|
126
|
+
enc_state = torch.load(enc_state_path, map_location=device)
|
|
127
|
+
|
|
128
|
+
if "pathway.linear.weight" in enc_state:
|
|
129
|
+
latent_dim = int(enc_state["pathway.linear.weight"].shape[0])
|
|
130
|
+
elif "z_mean.weight" in enc_state:
|
|
131
|
+
latent_dim = int(enc_state["z_mean.weight"].shape[0])
|
|
132
|
+
else:
|
|
133
|
+
cand = None
|
|
134
|
+
for k, v in enc_state.items():
|
|
135
|
+
if isinstance(v, torch.Tensor) and v.ndim == 2:
|
|
136
|
+
cand = int(v.shape[0])
|
|
137
|
+
break
|
|
138
|
+
if cand is None:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"Could not infer latent_dim from encoder state. "
|
|
141
|
+
"Expected 'pathway.linear.weight' or 'z_mean.weight' in state dict."
|
|
142
|
+
)
|
|
143
|
+
latent_dim = cand
|
|
144
|
+
|
|
145
|
+
if latent_index is None:
|
|
146
|
+
latent_index = [f"z{i}" for i in range(latent_dim)]
|
|
147
|
+
|
|
148
|
+
# --- Build fresh modules and load weights ---
|
|
149
|
+
enc = VAE.build_encoder(feature_dim, latent_dim, constraint=None)
|
|
150
|
+
dec = VAE.build_decoder(feature_dim, latent_dim)
|
|
151
|
+
|
|
152
|
+
enc.load_state_dict(torch.load(Path(path, "model.enc.pt"), map_location=device))
|
|
153
|
+
dec.load_state_dict(torch.load(Path(path, "model.dec.pt"), map_location=device))
|
|
154
|
+
enc.to(device)
|
|
155
|
+
dec.to(device)
|
|
156
|
+
|
|
157
|
+
# --- Instantiate user-provided container ---
|
|
158
|
+
try:
|
|
159
|
+
out = model_cls(features, encoder=enc, decoder=dec, **model_kwargs)
|
|
160
|
+
except TypeError as e:
|
|
161
|
+
raise TypeError(
|
|
162
|
+
f"{model_cls.__name__} could not be constructed with "
|
|
163
|
+
f"(features, encoder=..., decoder=..., **model_kwargs). "
|
|
164
|
+
f"Error: {e}"
|
|
165
|
+
) from e
|
|
166
|
+
|
|
167
|
+
# Optional standard fields if the container supports them
|
|
168
|
+
# (we won't crash if the attributes don't exist)
|
|
169
|
+
if hasattr(out, "latent_groups"):
|
|
170
|
+
out.latent_groups = groups
|
|
171
|
+
if hasattr(out, "latent_index"):
|
|
172
|
+
out.latent_index = latent_index
|
|
173
|
+
|
|
174
|
+
stats_path = Path(path, "training.stats.tsv")
|
|
175
|
+
if Path(stats_path).exists():
|
|
176
|
+
try:
|
|
177
|
+
if hasattr(out, "normal_stats"):
|
|
178
|
+
out.normal_stats = pd.read_csv(stats_path, sep="\t", index_col=0)
|
|
179
|
+
except Exception:
|
|
180
|
+
logger.warning(f"Could not read {stats_path}")
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
def __init__(self, features: List[str], encoder: Optional[Encoder] = None, decoder: Optional[Decoder] = None,
|
|
186
|
+
**kwargs):
|
|
187
|
+
super().__init__()
|
|
188
|
+
self.features = list(features)
|
|
189
|
+
self.encoder: Optional[Encoder] = encoder
|
|
190
|
+
self.decoder: Optional[Decoder] = decoder
|
|
191
|
+
self.extra_args = kwargs
|
|
192
|
+
|
|
193
|
+
def forward(self, x: torch.Tensor):
|
|
194
|
+
if self.encoder is None or self.decoder is None:
|
|
195
|
+
raise RuntimeError(
|
|
196
|
+
"VAE encoder/decoder not initialized. Set self.encoder/self.decoder before calling forward().")
|
|
197
|
+
mu, logvar, z = self.encoder(x)
|
|
198
|
+
recon = self.decoder(z)
|
|
199
|
+
return recon, mu, logvar, z
|
|
200
|
+
|
|
201
|
+
def save(self, path: str, normal_df: Optional[pd.DataFrame] = None):
|
|
202
|
+
"""Save VAE model with associated elements (PyTorch-native)."""
|
|
203
|
+
Path(path, exist_ok=True)
|
|
204
|
+
|
|
205
|
+
# Save encoder/decoder state dicts
|
|
206
|
+
torch.save(self.encoder.state_dict(), Path(path, "model.enc.pt"))
|
|
207
|
+
torch.save(self.decoder.state_dict(), Path(path, "model.dec.pt"))
|
|
208
|
+
|
|
209
|
+
# Save feature index
|
|
210
|
+
with open(Path(path, "index"), "wt", encoding="ascii") as handle:
|
|
211
|
+
handle.write(json.dumps(list(self.features)))
|
|
212
|
+
|
|
213
|
+
# Save latent groups (if present)
|
|
214
|
+
if self.latent_index is not None and self.latent_groups is not None:
|
|
215
|
+
with open(Path(path, "net.groups.tsv"), "wt", encoding="ascii") as handle:
|
|
216
|
+
for g in self.latent_index:
|
|
217
|
+
n = self.latent_groups.get(g, [])
|
|
218
|
+
handle.write("\t".join([str(g), *n]) + "\n")
|
|
219
|
+
|
|
220
|
+
# Save history
|
|
221
|
+
with open(Path(path, "stats.json"), "wt", encoding="ascii") as handle:
|
|
222
|
+
handle.write(json.dumps({"loss_history": self.history["loss"] if self.history else []}))
|
|
223
|
+
|
|
224
|
+
# Optionally save training stats like original
|
|
225
|
+
if normal_df is not None and self.normal_stats is not None:
|
|
226
|
+
self.normal_stats.to_csv(Path(path, "training.stats.tsv"), sep="\t")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from torch import nn
|
|
2
|
+
import torch
|
|
3
|
+
|
|
4
|
+
class Decoder(nn.Module):
|
|
5
|
+
"""
|
|
6
|
+
Mirrors build_decoder (Dense with relu), but returns probabilities in [0,1] to
|
|
7
|
+
match the binary cross-entropy use in the original loss pipeline.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, latent_dim: int, feature_dim: int):
|
|
11
|
+
super().__init__()
|
|
12
|
+
self.fc = nn.Linear(latent_dim, feature_dim)
|
|
13
|
+
|
|
14
|
+
def forward(self, z: torch.Tensor) -> torch.Tensor:
|
|
15
|
+
# Keep it simple: return sigmoid outputs for BCE
|
|
16
|
+
return torch.sigmoid(self.fc(z))
|
|
17
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from torch import nn
|
|
2
|
+
from typing import Optional, Tuple, List, Dict
|
|
3
|
+
import torch
|
|
4
|
+
from src.embkit.layers import MaskedLinear
|
|
5
|
+
from src.embkit.constraints import NetworkConstraint
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class Encoder(nn.Module):
|
|
11
|
+
"""
|
|
12
|
+
Dynamically built encoder:
|
|
13
|
+
input -> [optional BN] -> [list of Dense layers] -> latent dense -> BN -> (z_mean, z_log_var) -> sample z
|
|
14
|
+
forward(x) returns (z_mean, z_log_var, z)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, feature_dim: int, latent_dim: int,
|
|
18
|
+
layers: List[Dict] = None,
|
|
19
|
+
constraint: Optional[NetworkConstraint] = None,
|
|
20
|
+
batch_norm: bool = False,
|
|
21
|
+
activation: str = "relu"):
|
|
22
|
+
super().__init__()
|
|
23
|
+
self._constraint = constraint
|
|
24
|
+
|
|
25
|
+
self.net = nn.ModuleList()
|
|
26
|
+
in_features = feature_dim
|
|
27
|
+
|
|
28
|
+
# Optional initial batch norm
|
|
29
|
+
if batch_norm:
|
|
30
|
+
self.net.append(nn.BatchNorm1d(in_features))
|
|
31
|
+
|
|
32
|
+
# Build intermediate layers
|
|
33
|
+
if layers:
|
|
34
|
+
logger.info(f"Building encoder with {len(layers)} layers")
|
|
35
|
+
for i, layer_cfg in enumerate(layers):
|
|
36
|
+
units = layer_cfg.get("units")
|
|
37
|
+
use_mask = layer_cfg.get("masked", False)
|
|
38
|
+
|
|
39
|
+
if use_mask:
|
|
40
|
+
init_mask = None
|
|
41
|
+
if constraint is not None and constraint._mask_np is not None:
|
|
42
|
+
init_mask = torch.tensor(constraint._mask_np, dtype=torch.float32)
|
|
43
|
+
self.net.append(MaskedLinear(in_features, units, mask=init_mask))
|
|
44
|
+
else:
|
|
45
|
+
self.net.append(nn.Linear(in_features, units))
|
|
46
|
+
|
|
47
|
+
act = self._get_activation(activation)
|
|
48
|
+
if act is not None:
|
|
49
|
+
self.net.append(act)
|
|
50
|
+
|
|
51
|
+
if layer_cfg.get("batch_norm", False):
|
|
52
|
+
self.net.append(nn.BatchNorm1d(units))
|
|
53
|
+
|
|
54
|
+
in_features = units
|
|
55
|
+
else:
|
|
56
|
+
logger.info(f"Building encoder with no layers")
|
|
57
|
+
|
|
58
|
+
# Embedding layer
|
|
59
|
+
self.embedding = nn.Linear(in_features, latent_dim)
|
|
60
|
+
self.embedding_act = self._get_activation(activation)
|
|
61
|
+
self.embedding_bn = nn.BatchNorm1d(latent_dim)
|
|
62
|
+
|
|
63
|
+
# Latent outputs
|
|
64
|
+
self.z_mean = nn.Linear(latent_dim, latent_dim)
|
|
65
|
+
self.z_log_var = nn.Linear(latent_dim, latent_dim)
|
|
66
|
+
|
|
67
|
+
def _get_activation(self, activation: str):
|
|
68
|
+
act_map = {
|
|
69
|
+
"relu": nn.ReLU(),
|
|
70
|
+
"tanh": nn.Tanh(),
|
|
71
|
+
"sigmoid": nn.Sigmoid(),
|
|
72
|
+
"leaky_relu": nn.LeakyReLU(),
|
|
73
|
+
"elu": nn.ELU(),
|
|
74
|
+
None: None
|
|
75
|
+
}
|
|
76
|
+
return act_map.get(activation.lower()) if activation else None
|
|
77
|
+
|
|
78
|
+
def refresh_mask(self, device: torch.device):
|
|
79
|
+
if self._constraint is not None:
|
|
80
|
+
for module in self.net:
|
|
81
|
+
if isinstance(module, MaskedLinear):
|
|
82
|
+
module.set_mask(self._constraint.as_torch(device))
|
|
83
|
+
|
|
84
|
+
def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
85
|
+
h = x
|
|
86
|
+
for layer in self.net:
|
|
87
|
+
h = layer(h)
|
|
88
|
+
|
|
89
|
+
h = self.embedding(h)
|
|
90
|
+
if self.embedding_act:
|
|
91
|
+
h = self.embedding_act(h)
|
|
92
|
+
h = self.embedding_bn(h)
|
|
93
|
+
|
|
94
|
+
mu = self.z_mean(h)
|
|
95
|
+
logvar = self.z_log_var(h)
|
|
96
|
+
std = torch.exp(0.5 * logvar)
|
|
97
|
+
eps = torch.randn_like(std)
|
|
98
|
+
z = mu + eps * std
|
|
99
|
+
return mu, logvar, z
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from typing import Dict, List, Optional, Callable
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import torch
|
|
7
|
+
|
|
8
|
+
from torch.utils.data import TensorDataset, DataLoader
|
|
9
|
+
import logging
|
|
10
|
+
from .base_vae import VAE
|
|
11
|
+
from .encoder import Encoder
|
|
12
|
+
from .decoder import Decoder
|
|
13
|
+
from src.embkit.losses import vae_loss_from_model
|
|
14
|
+
from src.embkit.constraints import NetworkConstraint
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------
|
|
20
|
+
# NetVae (training with optional alternating constraint)
|
|
21
|
+
# ---------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
class NetVae(VAE):
|
|
24
|
+
"""
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, features: List[str], encoder: Optional[Encoder] = None, decoder: Optional[Decoder] = None):
|
|
28
|
+
super().__init__(features=features, encoder=encoder, decoder=decoder)
|
|
29
|
+
self.latent_groups: Optional[Dict[str, List[str]]] = None
|
|
30
|
+
self.latent_index: Optional[List[str]] = None
|
|
31
|
+
self.history: Optional[Dict[str, List[float]]] = None
|
|
32
|
+
self.normal_stats: Optional[pd.DataFrame] = None
|
|
33
|
+
|
|
34
|
+
def run_train(self, df: pd.DataFrame, latent_index: List[str],
|
|
35
|
+
latent_groups: Optional[Dict[str, List[str]]] = None,
|
|
36
|
+
learning_rate: float = 1e-3, batch_size: int = 128,
|
|
37
|
+
phases: Optional[List[int]] = None, epochs: int = 80,
|
|
38
|
+
device: Optional[str] = None,
|
|
39
|
+
grouping_fn: Optional[Callable[[np.ndarray, List[str]], Dict[str, List[str]]]] = None):
|
|
40
|
+
|
|
41
|
+
feature_dim = len(df.columns)
|
|
42
|
+
latent_dim = len(latent_index)
|
|
43
|
+
self.latent_index = list(latent_index)
|
|
44
|
+
|
|
45
|
+
if device is None:
|
|
46
|
+
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
|
47
|
+
device = torch.device(device)
|
|
48
|
+
|
|
49
|
+
# Constraint + models
|
|
50
|
+
constraint = NetworkConstraint(list(df.columns), latent_index, latent_groups)
|
|
51
|
+
constraint.set_active(False)
|
|
52
|
+
|
|
53
|
+
# Build and attach to self (no second VAE instance)
|
|
54
|
+
encoder = VAE.build_encoder(feature_dim=feature_dim, latent_dim=latent_dim, constraint=constraint)
|
|
55
|
+
decoder = VAE.build_decoder(feature_dim, latent_dim)
|
|
56
|
+
self.encoder = encoder
|
|
57
|
+
self.decoder = decoder
|
|
58
|
+
|
|
59
|
+
# Move whole module to device
|
|
60
|
+
self.to(device)
|
|
61
|
+
|
|
62
|
+
# Data
|
|
63
|
+
x = torch.tensor(df.values, dtype=torch.float32, device=device)
|
|
64
|
+
loader = DataLoader(TensorDataset(x), batch_size=batch_size, shuffle=True)
|
|
65
|
+
|
|
66
|
+
opt = torch.optim.Adam(self.parameters(), lr=learning_rate)
|
|
67
|
+
|
|
68
|
+
# History
|
|
69
|
+
total_hist: List[float] = []
|
|
70
|
+
recon_hist: List[float] = []
|
|
71
|
+
kl_hist: List[float] = []
|
|
72
|
+
|
|
73
|
+
# Determine total epochs if phases provided
|
|
74
|
+
if phases is not None:
|
|
75
|
+
total_epochs = sum(phases)
|
|
76
|
+
boundaries = np.cumsum(phases).tolist()
|
|
77
|
+
else:
|
|
78
|
+
total_epochs = epochs
|
|
79
|
+
boundaries = []
|
|
80
|
+
|
|
81
|
+
# Helper: apply constraint mask to encoder
|
|
82
|
+
def refresh_mask():
|
|
83
|
+
# encoder is attached to self
|
|
84
|
+
self.encoder.refresh_mask(device)
|
|
85
|
+
|
|
86
|
+
# Phase control
|
|
87
|
+
def start_constrained_phase():
|
|
88
|
+
if grouping_fn is not None:
|
|
89
|
+
with torch.no_grad():
|
|
90
|
+
w = self.encoder.pathway.linear.weight.detach().cpu().numpy() # [latent, features]
|
|
91
|
+
new_groups = grouping_fn(w, list(df.columns))
|
|
92
|
+
constraint.update_membership(new_groups)
|
|
93
|
+
constraint.set_active(True)
|
|
94
|
+
refresh_mask()
|
|
95
|
+
|
|
96
|
+
def start_unconstrained_phase():
|
|
97
|
+
constraint.set_active(False)
|
|
98
|
+
refresh_mask()
|
|
99
|
+
|
|
100
|
+
# Initial mask
|
|
101
|
+
refresh_mask()
|
|
102
|
+
|
|
103
|
+
# Training loop
|
|
104
|
+
for epoch in range(total_epochs):
|
|
105
|
+
if boundaries and epoch in boundaries:
|
|
106
|
+
idx = boundaries.index(epoch)
|
|
107
|
+
if idx % 2 == 0:
|
|
108
|
+
start_constrained_phase()
|
|
109
|
+
else:
|
|
110
|
+
start_unconstrained_phase()
|
|
111
|
+
|
|
112
|
+
self.train()
|
|
113
|
+
epoch_tot = epoch_rec = epoch_kl = 0.0
|
|
114
|
+
n_batches = 0
|
|
115
|
+
|
|
116
|
+
for (batch_x,) in loader:
|
|
117
|
+
opt.zero_grad()
|
|
118
|
+
# assumes vae_loss_from_model(model, x) calls model.forward(x)
|
|
119
|
+
total, recon, kl = vae_loss_from_model(self, batch_x)
|
|
120
|
+
total.backward()
|
|
121
|
+
opt.step()
|
|
122
|
+
epoch_tot += float(total.item())
|
|
123
|
+
epoch_rec += float(recon.item())
|
|
124
|
+
epoch_kl += float(kl.item())
|
|
125
|
+
n_batches += 1
|
|
126
|
+
|
|
127
|
+
total_hist.append(epoch_tot / max(1, n_batches))
|
|
128
|
+
recon_hist.append(epoch_rec / max(1, n_batches))
|
|
129
|
+
kl_hist.append(epoch_kl / max(1, n_batches))
|
|
130
|
+
|
|
131
|
+
# Store artifacts
|
|
132
|
+
self.latent_groups = constraint.latent_membership
|
|
133
|
+
self.history = {
|
|
134
|
+
"loss": total_hist,
|
|
135
|
+
"reconstruction_loss": recon_hist,
|
|
136
|
+
"kl_loss": kl_hist,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# normal_stats using deterministic recon from μ
|
|
140
|
+
self.eval()
|
|
141
|
+
with torch.no_grad():
|
|
142
|
+
mu, _, _ = self.encoder(x)
|
|
143
|
+
recon = self.decoder(mu).cpu().numpy()
|
|
144
|
+
normal_pred = pd.DataFrame(recon, index=df.index, columns=df.columns)
|
|
145
|
+
resid = normal_pred - df
|
|
146
|
+
self.normal_stats = pd.DataFrame({"mean": resid.mean(), "std": resid.std(ddof=0)})
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ----------------
|
|
151
|
+
# Load convenience
|
|
152
|
+
# ----------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
# Make a simple 2-feature dataset with 1-D columns
|
|
159
|
+
N = 100
|
|
160
|
+
df = pd.DataFrame({
|
|
161
|
+
"feat1": np.random.rand(N),
|
|
162
|
+
"feat2": np.random.rand(N),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
# Setup and train NetVae (this builds encoder/decoder internally)
|
|
166
|
+
net = NetVae(features=list(df.columns))
|
|
167
|
+
for epoch in range(100):
|
|
168
|
+
net.run_train(
|
|
169
|
+
df=df,
|
|
170
|
+
latent_index=["latent1", "latent2"], # latent_dim = 2
|
|
171
|
+
learning_rate=1e-3,
|
|
172
|
+
batch_size=32,
|
|
173
|
+
epochs=10,
|
|
174
|
+
device="cpu",
|
|
175
|
+
phases=None,
|
|
176
|
+
)
|
|
177
|
+
# print model training stats
|
|
178
|
+
print(
|
|
179
|
+
f"Epoch {epoch + 1}: Loss={net.history['loss'][-1]:.4f}, Recon Loss={net.history['reconstruction_loss'][-1]:.4f}, KL Loss={net.history['kl_loss'][-1]:.4f}")
|
|
180
|
+
|
|
181
|
+
# Save artifacts
|
|
182
|
+
net.save("vae_model")
|
|
183
|
+
|
|
184
|
+
model: NetVae = VAE.open_model(path="vae_model", model_cls=NetVae, device="cpu")
|
|
185
|
+
print("Model loaded with features:", model.features)
|
|
186
|
+
print(model.decoder)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from sklearn.preprocessing import RobustScaler, MinMaxScaler
|
|
2
|
+
from sklearn.base import BaseEstimator
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
def quantile_max_norm(df, quantile_max=0.9):
|
|
6
|
+
"""
|
|
7
|
+
Normalizes the DataFrame using Quantile Max normalization.
|
|
8
|
+
|
|
9
|
+
Parameters:
|
|
10
|
+
df (DataFrame): The input DataFrame to be normalized.
|
|
11
|
+
quantile_max (float, optional): The maximum quantile value to use for normalization. Default is 0.9.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
DataFrame: The normalized DataFrame.
|
|
15
|
+
"""
|
|
16
|
+
norm_df = (df.transpose() / df.quantile(quantile_max, axis=1)).transpose().clip(upper=1.0, lower=0.0).fillna(0.0)
|
|
17
|
+
return norm_df
|
|
18
|
+
|
|
19
|
+
def exp_max_norm(df):
|
|
20
|
+
"""
|
|
21
|
+
exp_max_norm
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
t = np.log2(df+1)
|
|
25
|
+
return (t.transpose() / t.max(axis=1)).transpose()
|
|
26
|
+
|
|
27
|
+
class ExpMinMaxScaler(MinMaxScaler, BaseEstimator):
|
|
28
|
+
"""
|
|
29
|
+
A custom Min-Max scaler that applies the log2 transformation before scaling.
|
|
30
|
+
|
|
31
|
+
Inherits from sklearn's MinMaxScaler and implements a fit method that
|
|
32
|
+
transforms the input data using the formula np.log2(X+1) before fitting the
|
|
33
|
+
MinMaxScaler to it. This allows for normalization of data with different scales,
|
|
34
|
+
especially when dealing with non-negative values where logarithmic transformation can help.
|
|
35
|
+
"""
|
|
36
|
+
def fit(self, X):
|
|
37
|
+
return MinMaxScaler.fit(self, np.log2(X+1))
|
|
38
|
+
def transform(self, X):
|
|
39
|
+
return MinMaxScaler.transform(self, np.log2(X+1))
|
|
40
|
+
def inverse_transform(self, X):
|
|
41
|
+
return np.exp2(MinMaxScaler.inverse_transform(self, X))-1
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
def extract_pathway_interactions(sif_path, relation='controls-expression-of'):
|
|
5
|
+
pc = pd.read_csv(sif_path, sep="\t",
|
|
6
|
+
header=None, names=["from", "relation", "to"])
|
|
7
|
+
pc = pc[ ~(pc['from'].map(lambda x:"CHEBI" in x) | pc['to'].map(lambda x:"CHEBI" in x)) ]
|
|
8
|
+
return pc[ pc["relation"] == relation ]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
from torch import nn
|
|
8
|
+
from torch.nn import functional as F
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
logger.setLevel(logging.DEBUG)
|
|
16
|
+
|
|
17
|
+
def get_device():
|
|
18
|
+
|
|
19
|
+
if torch.cuda.is_available():
|
|
20
|
+
print("Using CUDA GPU")
|
|
21
|
+
return torch.device("cuda")
|
|
22
|
+
elif torch.backends.mps.is_available():
|
|
23
|
+
print("Using Metal GPU")
|
|
24
|
+
return torch.device("mps")
|
|
25
|
+
else:
|
|
26
|
+
print("Using CPU")
|
|
27
|
+
return torch.device("cpu")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def vae_loss(recon_x, x, mu, logvar):
|
|
31
|
+
bce_per_sample = F.binary_cross_entropy(recon_x, x, reduction="none").mean(dim=1)
|
|
32
|
+
reconstruction_loss = x.size(1) * bce_per_sample
|
|
33
|
+
kl_loss = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp(), dim=1)
|
|
34
|
+
return (reconstruction_loss + kl_loss).mean(), reconstruction_loss.mean(), kl_loss.mean()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VAE(nn.Module):
|
|
38
|
+
def __init__(self, input_dim, hidden_dim, latent_dim, features=None):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.fc1 = nn.Linear(input_dim, hidden_dim)
|
|
41
|
+
self.fc21 = nn.Linear(hidden_dim, latent_dim) # Mean
|
|
42
|
+
self.fc22 = nn.Linear(hidden_dim, latent_dim) # Log-variance
|
|
43
|
+
self.fc3 = nn.Linear(latent_dim, hidden_dim)
|
|
44
|
+
self.fc4 = nn.Linear(hidden_dim, input_dim)
|
|
45
|
+
|
|
46
|
+
self.features = list(features) if features is not None else None
|
|
47
|
+
|
|
48
|
+
def encode(self, x):
|
|
49
|
+
h1 = F.relu(self.fc1(x))
|
|
50
|
+
return self.fc21(h1), self.fc22(h1)
|
|
51
|
+
|
|
52
|
+
def reparameterize(self, mu, logvar):
|
|
53
|
+
std = torch.exp(0.5 * logvar)
|
|
54
|
+
eps = torch.randn_like(std)
|
|
55
|
+
return mu + eps * std
|
|
56
|
+
|
|
57
|
+
def decode(self, z):
|
|
58
|
+
h3 = F.relu(self.fc3(z))
|
|
59
|
+
return torch.sigmoid(self.fc4(h3))
|
|
60
|
+
|
|
61
|
+
def forward(self, x):
|
|
62
|
+
mu, logvar = self.encode(x)
|
|
63
|
+
z = self.reparameterize(mu, logvar)
|
|
64
|
+
recon_x = self.decode(z)
|
|
65
|
+
return recon_x, mu, logvar
|
|
66
|
+
|
|
67
|
+
def save(self, path):
|
|
68
|
+
if not os.path.exists(path):
|
|
69
|
+
os.makedirs(path)
|
|
70
|
+
torch.save(self.state_dict(), os.path.join(path, "vae_model.pth"))
|
|
71
|
+
if self.features is not None:
|
|
72
|
+
with open(os.path.join(path, "index"), "wt", encoding="ascii") as handle:
|
|
73
|
+
handle.write(json.dumps(list(self.features)))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fit(self, X, epochs=20):
|
|
77
|
+
optimizer = torch.optim.Adam(self.parameters(), lr=0.001)
|
|
78
|
+
|
|
79
|
+
logger.debug(self)
|
|
80
|
+
|
|
81
|
+
device = get_device()
|
|
82
|
+
|
|
83
|
+
x_tensor = torch.from_numpy(X.values.astype(np.float32))
|
|
84
|
+
x_tensor.to(device)
|
|
85
|
+
|
|
86
|
+
for epoch in range(epochs):
|
|
87
|
+
self.train()
|
|
88
|
+
optimizer.zero_grad()
|
|
89
|
+
|
|
90
|
+
recon_x, mu, logvar = self(x_tensor)
|
|
91
|
+
total_loss, recon_loss, kl_loss = vae_loss(recon_x, x_tensor, mu, logvar)
|
|
92
|
+
|
|
93
|
+
total_loss.backward()
|
|
94
|
+
optimizer.step()
|
|
95
|
+
|
|
96
|
+
logger.debug(f"Epoch {epoch+1} | Loss: {total_loss.item():.4f} | "
|
|
97
|
+
f"Recon: {recon_loss.item():.4f} | KL: {kl_loss.item():.4f}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: embkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Embedding Kit for embedding models
|
|
5
|
+
Author-email: Kyle Ellrott <ellrott@ohsu.edu>, Raphael Kirchgaessner <kirchgae@ohsu.edu>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: embeddings,machine learning,deep learning
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: torch>=2.0
|
|
11
|
+
Requires-Dist: pandas
|
|
12
|
+
Requires-Dist: numpy
|
|
13
|
+
Requires-Dist: click
|
|
14
|
+
Requires-Dist: scikit-learn
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Embedding Kit
|
|
18
|
+
|
|
19
|
+
Methods for data normalizatio, embedding, synthesis and transformation
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
```
|
|
24
|
+
python setup.py build
|
|
25
|
+
python setup.py install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
# Training a model
|
|
29
|
+
```
|
|
30
|
+
python -m embkit model train ./experiments/tcga/tumor.normalized.tsv --epochs 120
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## Notebook cleaning
|
|
37
|
+
|
|
38
|
+
To avoid checking in large notebooks with lots of intermediate figures, please follow the instructions at https://gist.github.com/33eyes/431e3d432f73371509d176d0dfb95b6e
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
Running build
|
|
42
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
src/embkit/__init__.py
|
|
5
|
+
src/embkit/__main__.py
|
|
6
|
+
src/embkit/align.py
|
|
7
|
+
src/embkit/bmeg.py
|
|
8
|
+
src/embkit/commands.py
|
|
9
|
+
src/embkit/normalize.py
|
|
10
|
+
src/embkit/pathway.py
|
|
11
|
+
src/embkit/vae.py
|
|
12
|
+
src/embkit.egg-info/PKG-INFO
|
|
13
|
+
src/embkit.egg-info/SOURCES.txt
|
|
14
|
+
src/embkit.egg-info/dependency_links.txt
|
|
15
|
+
src/embkit.egg-info/requires.txt
|
|
16
|
+
src/embkit.egg-info/top_level.txt
|
|
17
|
+
src/embkit/constraints/__init__.py
|
|
18
|
+
src/embkit/constraints/network_constraint.py
|
|
19
|
+
src/embkit/estimator/__init__.py
|
|
20
|
+
src/embkit/estimator/vae_estimator.py
|
|
21
|
+
src/embkit/layers/__init__.py
|
|
22
|
+
src/embkit/layers/masked_linear.py
|
|
23
|
+
src/embkit/losses/__init__.py
|
|
24
|
+
src/embkit/losses/vae_loss.py
|
|
25
|
+
src/embkit/models/__init__.py
|
|
26
|
+
src/embkit/models/vae_model/__init__.py
|
|
27
|
+
src/embkit/models/vae_model/base_vae.py
|
|
28
|
+
src/embkit/models/vae_model/decoder.py
|
|
29
|
+
src/embkit/models/vae_model/encoder.py
|
|
30
|
+
src/embkit/models/vae_model/net_vae.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
embkit
|