autoencodix 0.2.0__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.
Potentially problematic release.
This version of autoencodix might be problematic. Click here for more details.
- autoencodix/__init__.py +27 -0
- autoencodix/base/.ipynb_checkpoints/_base_pipeline-checkpoint.py +1274 -0
- autoencodix/base/.ipynb_checkpoints/_base_trainer-checkpoint.py +247 -0
- autoencodix/base/__init__.py +21 -0
- autoencodix/base/_base_autoencoder.py +138 -0
- autoencodix/base/_base_dataset.py +103 -0
- autoencodix/base/_base_evaluator.py +34 -0
- autoencodix/base/_base_loss.py +311 -0
- autoencodix/base/_base_pipeline.py +1355 -0
- autoencodix/base/_base_predictor.py +6 -0
- autoencodix/base/_base_preprocessor.py +1033 -0
- autoencodix/base/_base_trainer.py +259 -0
- autoencodix/base/_base_visualizer.py +578 -0
- autoencodix/base/sampleconfig.yaml +132 -0
- autoencodix/configs/__init__.py +21 -0
- autoencodix/configs/default_config.py +652 -0
- autoencodix/configs/disentanglix_config.py +15 -0
- autoencodix/configs/maskix_config.py +39 -0
- autoencodix/configs/ontix_config.py +35 -0
- autoencodix/configs/stackix_config.py +33 -0
- autoencodix/configs/vanillix_config.py +18 -0
- autoencodix/configs/varix_config.py +15 -0
- autoencodix/configs/xmodalix_config.py +43 -0
- autoencodix/disentanglix.py +72 -0
- autoencodix/evaluate/.ipynb_checkpoints/evaluate-checkpoint.py +532 -0
- autoencodix/evaluate/__init__.py +4 -0
- autoencodix/evaluate/_general_evaluator.py +730 -0
- autoencodix/evaluate/_xmodalix_evaluator.py +376 -0
- autoencodix/imagix.py +76 -0
- autoencodix/losses/disentanglix_loss.py +215 -0
- autoencodix/losses/maskix_loss.py +94 -0
- autoencodix/losses/vanillix_loss.py +28 -0
- autoencodix/losses/varix_loss.py +102 -0
- autoencodix/losses/xmodal_loss.py +304 -0
- autoencodix/maskix.py +108 -0
- autoencodix/modeling/.ipynb_checkpoints/__init__-checkpoint.py +16 -0
- autoencodix/modeling/.ipynb_checkpoints/_captum_forward-checkpoint.py +16 -0
- autoencodix/modeling/__init__.py +20 -0
- autoencodix/modeling/_captum_forward.py +20 -0
- autoencodix/modeling/_classifier.py +45 -0
- autoencodix/modeling/_imagevae_architecture.py +296 -0
- autoencodix/modeling/_imgfast_architecture.py +288 -0
- autoencodix/modeling/_layer_factory.py +71 -0
- autoencodix/modeling/_maskix_architecture.py +190 -0
- autoencodix/modeling/_ontix_architecture.py +305 -0
- autoencodix/modeling/_vanillix_architecture.py +136 -0
- autoencodix/modeling/_varix_architecture.py +192 -0
- autoencodix/notebooks/__init__.py +0 -0
- autoencodix/ontix.py +136 -0
- autoencodix/py.typed +0 -0
- autoencodix/stackix.py +122 -0
- autoencodix/trainers/__init__.py +6 -0
- autoencodix/trainers/_general_trainer.py +525 -0
- autoencodix/trainers/_maskix_trainer.py +116 -0
- autoencodix/trainers/_ontix_trainer.py +77 -0
- autoencodix/trainers/_stackix_orchestrator.py +522 -0
- autoencodix/trainers/_stackix_trainer.py +209 -0
- autoencodix/trainers/_xmodal_trainer.py +1006 -0
- autoencodix/utils/.ipynb_checkpoints/_explainer-checkpoint.py +118 -0
- autoencodix/utils/.ipynb_checkpoints/_model_output-checkpoint.py +30 -0
- autoencodix/utils/.ipynb_checkpoints/_utils-checkpoint.py +626 -0
- autoencodix/utils/.ipynb_checkpoints/adata_converter-checkpoint.py +99 -0
- autoencodix/utils/.ipynb_checkpoints/feature_importance-checkpoint.py +823 -0
- autoencodix/utils/__init__.py +22 -0
- autoencodix/utils/_annealer.py +193 -0
- autoencodix/utils/_bulkreader.py +232 -0
- autoencodix/utils/_explainer.py +246 -0
- autoencodix/utils/_imgreader.py +404 -0
- autoencodix/utils/_internals.py +23 -0
- autoencodix/utils/_llm_explainer.py +318 -0
- autoencodix/utils/_losses.py +20 -0
- autoencodix/utils/_model_output.py +30 -0
- autoencodix/utils/_result.py +393 -0
- autoencodix/utils/_screader.py +76 -0
- autoencodix/utils/_traindynamics.py +187 -0
- autoencodix/utils/_utils.py +690 -0
- autoencodix/utils/adata_converter.py +111 -0
- autoencodix/utils/example_data.py +398 -0
- autoencodix/utils/feature_importance.py +829 -0
- autoencodix/utils/prompts.py +21 -0
- autoencodix/vanillix.py +141 -0
- autoencodix/varix.py +80 -0
- autoencodix/visualize/__init__.py +5 -0
- autoencodix/visualize/_general_visualizer.py +913 -0
- autoencodix/visualize/_imagix_visualizer.py +111 -0
- autoencodix/visualize/_xmodal_visualizer.py +841 -0
- autoencodix/visualize/visualize.py +1214 -0
- autoencodix/xmodalix.py +318 -0
- autoencodix-0.2.0.dist-info/METADATA +143 -0
- autoencodix-0.2.0.dist-info/RECORD +91 -0
- autoencodix-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from ._result import Result
|
|
2
|
+
from ._model_output import ModelOutput
|
|
3
|
+
from ._bulkreader import BulkDataReader
|
|
4
|
+
from ._imgreader import ImageDataReader
|
|
5
|
+
from ._screader import SingleCellDataReader
|
|
6
|
+
from ._losses import VanillixLoss, VarixLoss, DisentanglixLoss, XModalLoss
|
|
7
|
+
from .adata_converter import AnnDataConverter
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Result",
|
|
11
|
+
"ModelOutput",
|
|
12
|
+
"BulkDataReader",
|
|
13
|
+
"ImageDataReader",
|
|
14
|
+
"SingleCellDataReader",
|
|
15
|
+
"VanillixLoss",
|
|
16
|
+
"VarixLoss",
|
|
17
|
+
"DisentanglixLoss",
|
|
18
|
+
"XModalLoss",
|
|
19
|
+
"AnnDataConverter",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# all test done
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from typing import Dict, Callable, Optional
|
|
2
|
+
from math import exp
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AnnealingScheduler:
|
|
6
|
+
"""VAE loss annealing scheduler with multiple annealing strategies."""
|
|
7
|
+
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
"""Initialize the annealing scheduler with strategy mappings."""
|
|
10
|
+
self._strategies: Dict[str, Callable[[int, int], float]] = {
|
|
11
|
+
"5phase-constant": self._five_phase_constant,
|
|
12
|
+
"3phase-linear": self._three_phase_linear,
|
|
13
|
+
"3phase-log": self._three_phase_log,
|
|
14
|
+
"logistic-mid": self._logistic_mid,
|
|
15
|
+
"logistic-early": self._logistic_early,
|
|
16
|
+
"logistic-late": self._logistic_late,
|
|
17
|
+
"no-annealing": self._no_annealing,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def get_annealing_epoch(
|
|
22
|
+
*, anneal_pretraining: bool, n_epochs_pretrain: int, current_epoch: int
|
|
23
|
+
) -> Optional[int]:
|
|
24
|
+
"""Check if annealing should be used for pretraining.
|
|
25
|
+
Args:
|
|
26
|
+
anneal_pretraining: Whether to apply annealing during pretraining phase.
|
|
27
|
+
n_epochs_pretrain: Number of pretraining epochs.
|
|
28
|
+
current_epoch: Current epoch number.
|
|
29
|
+
Returns:
|
|
30
|
+
int or None: Annealing epoch number, or None if no annealing.
|
|
31
|
+
Raises:
|
|
32
|
+
NotImplementedError: This is a deprecated method.
|
|
33
|
+
"""
|
|
34
|
+
raise NotImplementedError(
|
|
35
|
+
"Deprecated, for annealing the current epoch is passed, we split between training and \
|
|
36
|
+
pretraining, so no extra calculation is needed"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def get_weight(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
epoch_current: Optional[int],
|
|
43
|
+
total_epoch: int,
|
|
44
|
+
func: str = "logistic-mid",
|
|
45
|
+
) -> float:
|
|
46
|
+
"""Calculate VAE loss annealing weight.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
epoch_current: Current epoch in training, or None for full weight.
|
|
50
|
+
total_epoch: Total epochs for training.
|
|
51
|
+
func: Specification of annealing function. Default is 'logistic-mid'.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Annealing weight between 0 (no VAE loss) and 1 (full VAE loss).
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
NotImplementedError: If the specified annealing function is not implemented.
|
|
58
|
+
"""
|
|
59
|
+
if epoch_current is None:
|
|
60
|
+
return 1.0
|
|
61
|
+
|
|
62
|
+
if func not in self._strategies:
|
|
63
|
+
raise NotImplementedError("The annealer is not implemented yet")
|
|
64
|
+
|
|
65
|
+
return self._strategies[func](epoch_current, total_epoch)
|
|
66
|
+
|
|
67
|
+
def _five_phase_constant(self, epoch_current: int, total_epoch: int) -> float:
|
|
68
|
+
"""Five phase constant annealing strategy.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
epoch_current: Current epoch number.
|
|
72
|
+
total_epoch: Total number of epochs.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Annealing weight.
|
|
76
|
+
"""
|
|
77
|
+
intervals = 5
|
|
78
|
+
current_phase = int((epoch_current / total_epoch) * intervals)
|
|
79
|
+
|
|
80
|
+
if current_phase == 0:
|
|
81
|
+
return 0.0
|
|
82
|
+
elif current_phase == 1:
|
|
83
|
+
return 0.001
|
|
84
|
+
elif current_phase == 2:
|
|
85
|
+
return 0.01
|
|
86
|
+
elif current_phase == 3:
|
|
87
|
+
return 0.1
|
|
88
|
+
else:
|
|
89
|
+
return 1.0
|
|
90
|
+
|
|
91
|
+
def _three_phase_linear(self, epoch_current: int, total_epoch: int) -> float:
|
|
92
|
+
"""Three phase linear annealing strategy.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
epoch_current: Current epoch number.
|
|
96
|
+
total_epoch: Total number of epochs.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Annealing weight.
|
|
100
|
+
"""
|
|
101
|
+
first_phase_end = total_epoch / 3
|
|
102
|
+
second_phase_end = 2 * (total_epoch / 3)
|
|
103
|
+
|
|
104
|
+
if epoch_current < first_phase_end:
|
|
105
|
+
return 0.0
|
|
106
|
+
elif epoch_current < second_phase_end:
|
|
107
|
+
return (epoch_current - first_phase_end) / (total_epoch / 3)
|
|
108
|
+
else:
|
|
109
|
+
return 1.0
|
|
110
|
+
|
|
111
|
+
def _three_phase_log(self, epoch_current: int, total_epoch: int) -> float:
|
|
112
|
+
"""Three phase logarithmic annealing strategy.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
epoch_current: Current epoch number.
|
|
116
|
+
total_epoch: Total number of epochs.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Annealing weight.
|
|
120
|
+
"""
|
|
121
|
+
first_phase_end = total_epoch / 3
|
|
122
|
+
second_phase_end = 2 * (total_epoch / 3)
|
|
123
|
+
|
|
124
|
+
if epoch_current < first_phase_end:
|
|
125
|
+
return 0.0
|
|
126
|
+
elif epoch_current < second_phase_end:
|
|
127
|
+
return self._logistic_mid(epoch_current - first_phase_end, total_epoch / 3)
|
|
128
|
+
else:
|
|
129
|
+
return 1.0
|
|
130
|
+
|
|
131
|
+
def _compute_logistic_weight(
|
|
132
|
+
self, epoch_current: int, total_epoch: int, midpoint: float
|
|
133
|
+
) -> float:
|
|
134
|
+
"""Compute logistic annealing weight.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
epoch_current: Current epoch number.
|
|
138
|
+
total_epoch: Total number of epochs.
|
|
139
|
+
midpoint: Midpoint ratio for the logistic function (0.0 to 1.0).
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Annealing weight.
|
|
143
|
+
"""
|
|
144
|
+
b_param = (1 / total_epoch) * 20
|
|
145
|
+
return 1 / (1 + exp(-b_param * (epoch_current - total_epoch * midpoint)))
|
|
146
|
+
|
|
147
|
+
def _logistic_mid(self, epoch_current: int, total_epoch: int) -> float:
|
|
148
|
+
"""Logistic annealing with midpoint at half of total epochs.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
epoch_current: Current epoch number.
|
|
152
|
+
total_epoch: Total number of epochs.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Annealing weight.
|
|
156
|
+
"""
|
|
157
|
+
return self._compute_logistic_weight(epoch_current, total_epoch, 0.5)
|
|
158
|
+
|
|
159
|
+
def _logistic_early(self, epoch_current: int, total_epoch: int) -> float:
|
|
160
|
+
"""Logistic annealing with early midpoint at quarter of total epochs.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
epoch_current: Current epoch number.
|
|
164
|
+
total_epoch: Total number of epochs.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Annealing weight.
|
|
168
|
+
"""
|
|
169
|
+
return self._compute_logistic_weight(epoch_current, total_epoch, 0.25)
|
|
170
|
+
|
|
171
|
+
def _logistic_late(self, epoch_current: int, total_epoch: int) -> float:
|
|
172
|
+
"""Logistic annealing with late midpoint at three quarters of total epochs.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
epoch_current: Current epoch number.
|
|
176
|
+
total_epoch: Total number of epochs.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Annealing weight.
|
|
180
|
+
"""
|
|
181
|
+
return self._compute_logistic_weight(epoch_current, total_epoch, 0.75)
|
|
182
|
+
|
|
183
|
+
def _no_annealing(self, epoch_current: int, total_epoch: int) -> float:
|
|
184
|
+
"""No annealing strategy - constant full weight.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
epoch_current: Current epoch number.
|
|
188
|
+
total_epoch: Total number of epochs.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Annealing weight (always 1.0).
|
|
192
|
+
"""
|
|
193
|
+
return 1.0
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import warnings
|
|
3
|
+
from typing import Dict, Set, Tuple, Optional, Union
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from autoencodix.configs.default_config import DefaultConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BulkDataReader:
|
|
11
|
+
"""Reads bulk data from files based on configuration.
|
|
12
|
+
|
|
13
|
+
Supports both paired and unpaired data reading strategies.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
config: Configuration object
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: DefaultConfig):
|
|
20
|
+
"""Initialize the BulkDataReader with a configuration.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
config: Configuration object containing data paths and specifications.
|
|
24
|
+
"""
|
|
25
|
+
self.config = config
|
|
26
|
+
|
|
27
|
+
def read_data(self) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
|
|
28
|
+
"""Read all data according to the configuration.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A tuple containing (bulk_dataframes, annotation_dataframes)
|
|
32
|
+
"""
|
|
33
|
+
if self.config.requires_paired or self.config.requires_paired is None:
|
|
34
|
+
return self.read_paired_data()
|
|
35
|
+
else:
|
|
36
|
+
return self.read_unpaired_data()
|
|
37
|
+
|
|
38
|
+
def read_paired_data(
|
|
39
|
+
self,
|
|
40
|
+
) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
|
|
41
|
+
"""Reads numeric paired data
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Tuple containing two Dicts:
|
|
45
|
+
1. with name of the data as key and pandas DataFrame as value
|
|
46
|
+
2. with str 'paired' as key and a common annotaion/metadata as DataFrame
|
|
47
|
+
"""
|
|
48
|
+
common_samples: Optional[Set[str]] = None
|
|
49
|
+
bulk_dfs: Dict[str, pd.DataFrame] = {}
|
|
50
|
+
annotation_df = pd.DataFrame()
|
|
51
|
+
has_annotation = False
|
|
52
|
+
|
|
53
|
+
# First pass: read all data files and track common samples
|
|
54
|
+
for key, info in self.config.data_config.data_info.items():
|
|
55
|
+
if info.data_type == "IMG":
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
file_path = os.path.join(info.file_path)
|
|
59
|
+
df = self._read_tabular_data(file_path, info.sep or "\t")
|
|
60
|
+
|
|
61
|
+
if df is None:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
if info.data_type == "NUMERIC" and not info.is_single_cell:
|
|
65
|
+
current_samples = set(df.index)
|
|
66
|
+
if common_samples is None:
|
|
67
|
+
common_samples = current_samples
|
|
68
|
+
else:
|
|
69
|
+
common_samples &= current_samples
|
|
70
|
+
|
|
71
|
+
bulk_dfs[key] = df
|
|
72
|
+
|
|
73
|
+
elif info.data_type == "ANNOTATION":
|
|
74
|
+
has_annotation = True
|
|
75
|
+
annotation_df = df
|
|
76
|
+
|
|
77
|
+
# Second pass: filter to common samples
|
|
78
|
+
if common_samples:
|
|
79
|
+
common_samples_list = list(common_samples)
|
|
80
|
+
|
|
81
|
+
# Reindex bulk dataframes to common samples
|
|
82
|
+
for key in bulk_dfs:
|
|
83
|
+
bulk_dfs[key] = bulk_dfs[key].reindex(common_samples_list)
|
|
84
|
+
|
|
85
|
+
# Handle annotation dataframe
|
|
86
|
+
if has_annotation:
|
|
87
|
+
annotation = annotation_df.reindex(common_samples_list)
|
|
88
|
+
else:
|
|
89
|
+
# Create empty annotation with common sample indices
|
|
90
|
+
annotation_df = pd.DataFrame(index=common_samples_list)
|
|
91
|
+
annotation = annotation_df
|
|
92
|
+
else:
|
|
93
|
+
print("Warning: No common samples found across datasets")
|
|
94
|
+
annotation = annotation_df
|
|
95
|
+
|
|
96
|
+
return bulk_dfs, {"paired": annotation}
|
|
97
|
+
|
|
98
|
+
def read_unpaired_data(
|
|
99
|
+
self,
|
|
100
|
+
) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
|
|
101
|
+
"""Read data without enforcing sample alignment across modalities.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
A tuple containing (bulk_dataframes, annotation_dataframes)
|
|
105
|
+
"""
|
|
106
|
+
bulk_dfs: Dict[str, pd.DataFrame] = {}
|
|
107
|
+
annotations: Dict[str, pd.DataFrame] = {}
|
|
108
|
+
|
|
109
|
+
for key, info in self.config.data_config.data_info.items():
|
|
110
|
+
if info.data_type == "IMG" or info.is_single_cell:
|
|
111
|
+
continue # Skip image and single-cell data
|
|
112
|
+
|
|
113
|
+
# Read main data file
|
|
114
|
+
file_path = os.path.join(info.file_path)
|
|
115
|
+
df = self._read_tabular_data(file_path=file_path, sep=info.sep)
|
|
116
|
+
|
|
117
|
+
if df is None:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
if info.data_type == "NUMERIC":
|
|
121
|
+
bulk_dfs[key] = df
|
|
122
|
+
|
|
123
|
+
if hasattr(info, "extra_anno_file") and info.extra_anno_file:
|
|
124
|
+
extra_anno_file = os.path.join(info.extra_anno_file)
|
|
125
|
+
extra_anno_df = self._read_tabular_data(
|
|
126
|
+
file_path=extra_anno_file, sep=info.sep
|
|
127
|
+
)
|
|
128
|
+
if extra_anno_df is not None:
|
|
129
|
+
annotations[key] = extra_anno_df
|
|
130
|
+
|
|
131
|
+
elif info.data_type == "ANNOTATION":
|
|
132
|
+
annotations[key] = df
|
|
133
|
+
|
|
134
|
+
bulk_dfs, annotations = self._validate_and_filter_unpaired(
|
|
135
|
+
bulk_dfs, annotations
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return bulk_dfs, annotations
|
|
139
|
+
|
|
140
|
+
def _validate_and_filter_unpaired(
|
|
141
|
+
self,
|
|
142
|
+
bulk_dfs: Dict[str, pd.DataFrame],
|
|
143
|
+
annotations: Dict[str, pd.DataFrame],
|
|
144
|
+
) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
|
|
145
|
+
"""Validates that all samples in bulk data have a corresponding annotation.
|
|
146
|
+
|
|
147
|
+
If a single global annotation file is provided, it creates a perfectly
|
|
148
|
+
matched annotation dataframe for each bulk dataframe.
|
|
149
|
+
|
|
150
|
+
Warns and drops samples that do not have a corresponding annotation.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
bulk_dfs: Dictionary of bulk data modalities and their dataframes.
|
|
154
|
+
annotations: Dictionary of annotation dataframes, possibly one global one.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
A tuple of two dictionaries:
|
|
158
|
+
1. The filtered bulk dataframes.
|
|
159
|
+
2. The new, synchronized annotation dataframes, with keys matching the bulk dataframes.
|
|
160
|
+
"""
|
|
161
|
+
if not annotations:
|
|
162
|
+
warnings.warn(
|
|
163
|
+
"No annotation files were provided. Cannot validate sample annotations."
|
|
164
|
+
)
|
|
165
|
+
return bulk_dfs, {}
|
|
166
|
+
|
|
167
|
+
# If annotations have keys that match bulk_dfs, we assume they are already paired.
|
|
168
|
+
# This logic focuses on the case where one annotation file is meant for all bulk files.
|
|
169
|
+
# A simple heuristic: if there is one annotation file and its key is not in bulk_dfs.
|
|
170
|
+
annotation_keys = set(annotations.keys())
|
|
171
|
+
bulk_keys = set(bulk_dfs.keys())
|
|
172
|
+
|
|
173
|
+
# Check for the global annotation case
|
|
174
|
+
if len(annotation_keys) == 1 and not annotation_keys.intersection(bulk_keys):
|
|
175
|
+
global_annotation_key = list(annotation_keys)[0]
|
|
176
|
+
global_annotation_df = annotations[global_annotation_key]
|
|
177
|
+
|
|
178
|
+
filtered_bulk_dfs = {}
|
|
179
|
+
synchronized_annotations = {}
|
|
180
|
+
|
|
181
|
+
for key, data_df in bulk_dfs.items():
|
|
182
|
+
data_samples = data_df.index
|
|
183
|
+
annotation_samples = global_annotation_df.index
|
|
184
|
+
|
|
185
|
+
# Find the intersection of valid sample IDs
|
|
186
|
+
valid_ids = data_samples.intersection(annotation_samples)
|
|
187
|
+
|
|
188
|
+
# Check for and warn about dropped samples
|
|
189
|
+
if len(valid_ids) < len(data_samples):
|
|
190
|
+
missing_ids = sorted(list(set(data_samples) - set(valid_ids)))
|
|
191
|
+
warnings.warn(
|
|
192
|
+
f"For data modality '{key}', {len(missing_ids)} sample(s) "
|
|
193
|
+
f"were found without a corresponding annotation and will be dropped: {missing_ids}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Filter both the data and the annotation to the valid IDs
|
|
197
|
+
filtered_bulk_dfs[key] = data_df.loc[valid_ids]
|
|
198
|
+
synchronized_annotations[key] = global_annotation_df.loc[valid_ids]
|
|
199
|
+
|
|
200
|
+
return filtered_bulk_dfs, synchronized_annotations
|
|
201
|
+
else:
|
|
202
|
+
# Handle the case where annotations are already meant to be paired by key
|
|
203
|
+
# (Or a more complex case we are not handling yet)
|
|
204
|
+
warnings.warn(
|
|
205
|
+
"Proceeding without global annotation synchronization. Assuming annotations are pre-aligned by key."
|
|
206
|
+
)
|
|
207
|
+
return bulk_dfs, annotations
|
|
208
|
+
|
|
209
|
+
def _read_tabular_data(
|
|
210
|
+
self, file_path: str, sep: Union[str, None] = None
|
|
211
|
+
) -> pd.DataFrame:
|
|
212
|
+
"""Read tabular data from a file with error handling.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
file_path: Path to the data file.
|
|
216
|
+
sep: Separator character for CSV/TSV files.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
The loaded DataFrame.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
if file_path.endswith(".parquet"):
|
|
223
|
+
print(f"reading parquet: {file_path}")
|
|
224
|
+
return pd.read_parquet(file_path)
|
|
225
|
+
elif file_path.endswith((".csv", ".txt", ".tsv")):
|
|
226
|
+
return pd.read_csv(file_path, sep=sep, index_col=0)
|
|
227
|
+
else:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"Unsupported file type for {file_path}. Supported formats: .parquet, .csv, .txt, .tsv"
|
|
230
|
+
)
|
|
231
|
+
except Exception as e:
|
|
232
|
+
raise e
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
import numpy as np
|
|
3
|
+
import scipy
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from autoencodix.modeling._captum_forward import CaptumForward
|
|
6
|
+
from typing import Optional, Union
|
|
7
|
+
from captum.attr import (
|
|
8
|
+
DeepLiftShap,
|
|
9
|
+
IntegratedGradients,
|
|
10
|
+
)
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
warnings.filterwarnings(
|
|
14
|
+
"ignore",
|
|
15
|
+
message="Setting forward, backward hooks and attributes on non-linear",
|
|
16
|
+
category=UserWarning,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
warnings.filterwarnings("ignore")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FeatureImportanceExplainer:
|
|
23
|
+
"""
|
|
24
|
+
More complete version with:
|
|
25
|
+
- method selection (DeepLiftShap, IG, etc.)
|
|
26
|
+
- baseline construction (mean / random / grouped)
|
|
27
|
+
- subset sampling
|
|
28
|
+
- reproducible randomness
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
adata,
|
|
34
|
+
model,
|
|
35
|
+
method: str = "DeepLiftShap",
|
|
36
|
+
sel_latent_dim: Union[
|
|
37
|
+
list, int, None
|
|
38
|
+
] = None, # list or int of latent dimensions to explain, if None explain all
|
|
39
|
+
n_subset: int = 100, # Randomly sample in input and baseline space for computational efficiency, if n_subset is None use all samples
|
|
40
|
+
seed_int: int = 12,
|
|
41
|
+
input_type: str = "random", # "random" or "grouped" for selecting subset of inputs to explain
|
|
42
|
+
input_group: Optional[
|
|
43
|
+
str
|
|
44
|
+
] = None, # column in .obs for grouping inputs must be provided if input_type is "grouped"
|
|
45
|
+
baseline_type: str = "random", # "random" or "mean" or "grouped" for selecting baseline samples
|
|
46
|
+
baseline_group: str = None, # column in .obs for grouping inputs must be provided if input_type is "grouped", optional for "mean", ignored for "random"
|
|
47
|
+
anno_col: Optional[str] = None, # column in .obs for grouping
|
|
48
|
+
):
|
|
49
|
+
super(FeatureImportanceExplainer, self).__init__()
|
|
50
|
+
self.adata_ACX = adata
|
|
51
|
+
self.model = model
|
|
52
|
+
self.latent_dim = model.config.latent_dim
|
|
53
|
+
|
|
54
|
+
self.method = method
|
|
55
|
+
if self.method not in {"DeepLiftShap", "IntegratedGradients"}:
|
|
56
|
+
raise ValueError(f"Invalid method {method}.")
|
|
57
|
+
self.n_subset = n_subset
|
|
58
|
+
self.seed_int = seed_int
|
|
59
|
+
self.baseline_type = baseline_type
|
|
60
|
+
self.baseline_group = baseline_group
|
|
61
|
+
self.anno_col = anno_col
|
|
62
|
+
self.input_type = input_type
|
|
63
|
+
self.input_group = input_group
|
|
64
|
+
self.sel_latent_dim = sel_latent_dim
|
|
65
|
+
|
|
66
|
+
torch.manual_seed(seed_int)
|
|
67
|
+
np.random.seed(seed_int)
|
|
68
|
+
|
|
69
|
+
def explain(self):
|
|
70
|
+
adata_ACX = self.adata_ACX
|
|
71
|
+
gene_names = adata_ACX.var_names
|
|
72
|
+
inputs, baselines = return_inputs_baseline(
|
|
73
|
+
adata_ACX,
|
|
74
|
+
self.baseline_group,
|
|
75
|
+
self.baseline_type,
|
|
76
|
+
self.anno_col,
|
|
77
|
+
self.input_type,
|
|
78
|
+
self.input_group,
|
|
79
|
+
)
|
|
80
|
+
# set n_samples to whatever is lowest from: inputs, baselines, n_subset (if not None)
|
|
81
|
+
n_samples = min(inputs.shape[0], baselines.shape[0])
|
|
82
|
+
if self.n_subset is not None:
|
|
83
|
+
n_samples = min(n_samples, self.n_subset)
|
|
84
|
+
|
|
85
|
+
# Downsample input
|
|
86
|
+
if self.n_subset is not None:
|
|
87
|
+
indices_keep = np.random.choice(
|
|
88
|
+
inputs.shape[0], size=n_samples, replace=False
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
indices_keep = np.arange(inputs.shape[0])
|
|
92
|
+
# Downsample baselines in the same way
|
|
93
|
+
if self.n_subset is not None:
|
|
94
|
+
indices_keep_baseline = np.random.choice(
|
|
95
|
+
baselines.shape[0], size=n_samples, replace=False
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
indices_keep_baseline = np.arange(baselines.shape[0])
|
|
99
|
+
# for latent_dim in range(self.latent_dim):
|
|
100
|
+
all_attr = []
|
|
101
|
+
if isinstance(
|
|
102
|
+
self.sel_latent_dim, list
|
|
103
|
+
): # Provide list of latent dimensions to explain
|
|
104
|
+
latent_dims = self.sel_latent_dim
|
|
105
|
+
elif isinstance(
|
|
106
|
+
self.sel_latent_dim, int
|
|
107
|
+
): # Provide single latent dimension to explain
|
|
108
|
+
latent_dims = [self.sel_latent_dim]
|
|
109
|
+
elif self.sel_latent_dim is None: # Explain all latent dimensions
|
|
110
|
+
latent_dims = list(range(self.latent_dim))
|
|
111
|
+
else:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"Invalid sel_latent_dim {self.sel_latent_dim}. Must be int, list of ints, or None."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
for latent_dim in latent_dims:
|
|
117
|
+
print("Calculating attributions for latent dimension:", latent_dim)
|
|
118
|
+
cp_forward_dim = CaptumForward(model=self.model, dim=latent_dim)
|
|
119
|
+
if self.method == "DeepLiftShap":
|
|
120
|
+
cp_explainer = DeepLiftShap(cp_forward_dim)
|
|
121
|
+
if self.method == "IntegratedGradients":
|
|
122
|
+
cp_explainer = IntegratedGradients(cp_forward_dim)
|
|
123
|
+
attributions = cp_explainer.attribute(
|
|
124
|
+
inputs=inputs[indices_keep].float(),
|
|
125
|
+
baselines=baselines[indices_keep_baseline].float(),
|
|
126
|
+
return_convergence_delta=False,
|
|
127
|
+
)
|
|
128
|
+
avg_abs_attributions = attributions.abs().mean(dim=0)
|
|
129
|
+
all_attr.append(avg_abs_attributions.detach().cpu())
|
|
130
|
+
attr_matrix = torch.stack(all_attr).T.numpy()
|
|
131
|
+
if hasattr(self.model, "ontologies") and self.model.ontologies is not None:
|
|
132
|
+
cols = [list(self.model.ontologies[0].keys())[i] for i in latent_dims]
|
|
133
|
+
else:
|
|
134
|
+
cols = [f"Latent_Dim_{i}" for i in latent_dims]
|
|
135
|
+
df_attributions = pd.DataFrame(
|
|
136
|
+
attr_matrix,
|
|
137
|
+
index=list(gene_names),
|
|
138
|
+
columns=cols,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return df_attributions
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def return_inputs_baseline(
|
|
145
|
+
adata, baseline_group, baseline_type, anno_col, input_type, input_group
|
|
146
|
+
):
|
|
147
|
+
baseline_torch_all = torch.tensor(
|
|
148
|
+
adata.X.toarray() if scipy.sparse.issparse(adata.X) else adata.X
|
|
149
|
+
)
|
|
150
|
+
## Define inputs
|
|
151
|
+
if input_type == "grouped":
|
|
152
|
+
if anno_col is None or input_group is None:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"If input_type is 'grouped', anno_col and input_group must be provided to specify the grouping column in .obs."
|
|
155
|
+
)
|
|
156
|
+
input_adata = adata[adata.obs[anno_col] == input_group]
|
|
157
|
+
inputs = torch.tensor(
|
|
158
|
+
input_adata.X.toarray()
|
|
159
|
+
if scipy.sparse.issparse(input_adata.X)
|
|
160
|
+
else input_adata.X
|
|
161
|
+
)
|
|
162
|
+
elif input_type == "random":
|
|
163
|
+
inputs = torch.tensor(
|
|
164
|
+
adata.X.toarray() if scipy.sparse.issparse(adata.X) else adata.X
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
## Define baselines
|
|
168
|
+
if baseline_type == "random":
|
|
169
|
+
# Generates a bootstrap random from input in the same size
|
|
170
|
+
baseline_random = baseline_torch_all[
|
|
171
|
+
torch.randint(0, baseline_torch_all.size(0), (1,)).item()
|
|
172
|
+
]
|
|
173
|
+
baselines = torch.tensor(
|
|
174
|
+
np.tile(baseline_random, (baseline_torch_all.shape[0], 1))
|
|
175
|
+
)
|
|
176
|
+
elif baseline_type == "grouped":
|
|
177
|
+
if anno_col is None or baseline_group is None:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"If baseline_type is 'grouped', anno_col and baseline_group must be provided to specify the grouping column in .obs."
|
|
180
|
+
)
|
|
181
|
+
base_adata_filtered = adata[adata.obs[anno_col] == baseline_group]
|
|
182
|
+
base_filtered = torch.tensor(
|
|
183
|
+
base_adata_filtered.X.toarray()
|
|
184
|
+
if scipy.sparse.issparse(base_adata_filtered.X)
|
|
185
|
+
else base_adata_filtered.X
|
|
186
|
+
)
|
|
187
|
+
# Generates a bootstrap random from the filtered input in the same size
|
|
188
|
+
baseline_grouped = base_filtered[
|
|
189
|
+
torch.randint(0, base_filtered.size(0), (1,)).item()
|
|
190
|
+
]
|
|
191
|
+
baselines = torch.tensor(
|
|
192
|
+
np.tile(baseline_grouped, (base_adata_filtered.shape[0], 1))
|
|
193
|
+
)
|
|
194
|
+
elif baseline_type == "mean":
|
|
195
|
+
if baseline_group == None:
|
|
196
|
+
baseline_mean = baseline_torch_all.mean(axis=0) # gene_means
|
|
197
|
+
else:
|
|
198
|
+
if anno_col is None:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
"If baseline_group is not 'all', anno_col must be provided to specify the grouping column in .obs."
|
|
201
|
+
)
|
|
202
|
+
base_adata_filtered = adata[adata.obs[anno_col] == baseline_group]
|
|
203
|
+
base_filtered = torch.tensor(
|
|
204
|
+
base_adata_filtered.X.toarray()
|
|
205
|
+
if scipy.sparse.issparse(base_adata_filtered.X)
|
|
206
|
+
else base_adata_filtered.X
|
|
207
|
+
)
|
|
208
|
+
baseline_mean = base_filtered.mean(axis=0) # gene_means
|
|
209
|
+
baselines = torch.tensor(
|
|
210
|
+
np.tile(baseline_mean, (baseline_torch_all.shape[0], 1))
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
raise ValueError(f"Invalid baseline_type {baseline_type}.")
|
|
214
|
+
|
|
215
|
+
# inputs = torch.tensor(
|
|
216
|
+
# input_adata.X.toarray()
|
|
217
|
+
# if scipy.sparse.issparse(input_adata.X)
|
|
218
|
+
# else input_adata.X
|
|
219
|
+
# )
|
|
220
|
+
# if baseline_group == "all":
|
|
221
|
+
# if baseline_type == "mean":
|
|
222
|
+
# baseline_mean = inputs.mean(axis=0) # gene_means
|
|
223
|
+
# baselines = torch.tensor(np.tile(baseline_mean, (inputs.shape[0], 1)))
|
|
224
|
+
# if baseline_type == "random_sample":
|
|
225
|
+
# baseline_random = inputs[torch.randint(0, inputs.size(0), (1,)).item()]
|
|
226
|
+
# baselines = torch.tensor(np.tile(baseline_random, (inputs.shape[0], 1)))
|
|
227
|
+
# else:
|
|
228
|
+
# if anno_col is None:
|
|
229
|
+
# raise ValueError(
|
|
230
|
+
# "If baseline_group is not 'all', anno_col must be provided to specify the grouping column in .obs."
|
|
231
|
+
# )
|
|
232
|
+
# input_adata_filtered = input_adata[input_adata.obs[anno_col] == baseline_group]
|
|
233
|
+
# inputs_filtered = torch.tensor(
|
|
234
|
+
# input_adata_filtered.X.toarray()
|
|
235
|
+
# if scipy.sparse.issparse(input_adata_filtered.X)
|
|
236
|
+
# else input_adata_filtered.X
|
|
237
|
+
# )
|
|
238
|
+
# if baseline_type == "mean":
|
|
239
|
+
# baseline_mean = inputs_filtered.mean(axis=0) # gene_means
|
|
240
|
+
# baselines = torch.tensor(np.tile(baseline_mean, (inputs.shape[0], 1)))
|
|
241
|
+
# if baseline_type == "random_sample":
|
|
242
|
+
# baseline_random = inputs_filtered[
|
|
243
|
+
# torch.randint(0, inputs_filtered.size(0), (1,)).item()
|
|
244
|
+
# ]
|
|
245
|
+
# baselines = torch.tensor(np.tile(baseline_random, (inputs.shape[0], 1)))
|
|
246
|
+
return inputs, baselines
|