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.
Files changed (32) hide show
  1. embkit-0.1.0/PKG-INFO +42 -0
  2. embkit-0.1.0/README.md +27 -0
  3. embkit-0.1.0/pyproject.toml +29 -0
  4. embkit-0.1.0/setup.cfg +4 -0
  5. embkit-0.1.0/setup.py +10 -0
  6. embkit-0.1.0/src/embkit/__init__.py +0 -0
  7. embkit-0.1.0/src/embkit/__main__.py +14 -0
  8. embkit-0.1.0/src/embkit/align.py +59 -0
  9. embkit-0.1.0/src/embkit/bmeg.py +24 -0
  10. embkit-0.1.0/src/embkit/commands.py +27 -0
  11. embkit-0.1.0/src/embkit/constraints/__init__.py +1 -0
  12. embkit-0.1.0/src/embkit/constraints/network_constraint.py +54 -0
  13. embkit-0.1.0/src/embkit/estimator/__init__.py +0 -0
  14. embkit-0.1.0/src/embkit/estimator/vae_estimator.py +67 -0
  15. embkit-0.1.0/src/embkit/layers/__init__.py +1 -0
  16. embkit-0.1.0/src/embkit/layers/masked_linear.py +28 -0
  17. embkit-0.1.0/src/embkit/losses/__init__.py +1 -0
  18. embkit-0.1.0/src/embkit/losses/vae_loss.py +16 -0
  19. embkit-0.1.0/src/embkit/models/__init__.py +1 -0
  20. embkit-0.1.0/src/embkit/models/vae_model/__init__.py +3 -0
  21. embkit-0.1.0/src/embkit/models/vae_model/base_vae.py +226 -0
  22. embkit-0.1.0/src/embkit/models/vae_model/decoder.py +17 -0
  23. embkit-0.1.0/src/embkit/models/vae_model/encoder.py +99 -0
  24. embkit-0.1.0/src/embkit/models/vae_model/net_vae.py +186 -0
  25. embkit-0.1.0/src/embkit/normalize.py +41 -0
  26. embkit-0.1.0/src/embkit/pathway.py +8 -0
  27. embkit-0.1.0/src/embkit/vae.py +97 -0
  28. embkit-0.1.0/src/embkit.egg-info/PKG-INFO +42 -0
  29. embkit-0.1.0/src/embkit.egg-info/SOURCES.txt +30 -0
  30. embkit-0.1.0/src/embkit.egg-info/dependency_links.txt +1 -0
  31. embkit-0.1.0/src/embkit.egg-info/requires.txt +5 -0
  32. 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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
embkit-0.1.0/setup.py ADDED
@@ -0,0 +1,10 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name='embedding-kit',
5
+ version='0.1',
6
+ packages=find_packages(),
7
+ install_requires=[
8
+ # List your dependencies here!
9
+ ],
10
+ )
File without changes
@@ -0,0 +1,14 @@
1
+
2
+ # my_project/cli.py
3
+ import click
4
+ from .commands import model
5
+
6
+ @click.group()
7
+ def cli():
8
+ """A multi-module CLI application."""
9
+ pass
10
+
11
+ cli.add_command(model)
12
+
13
+ if __name__ == '__main__':
14
+ cli()
@@ -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,3 @@
1
+ from .base_vae import VAE
2
+ from .decoder import Decoder
3
+ from .encoder import Encoder
@@ -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,5 @@
1
+ torch>=2.0
2
+ pandas
3
+ numpy
4
+ click
5
+ scikit-learn
@@ -0,0 +1 @@
1
+ embkit