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.

Files changed (91) hide show
  1. autoencodix/__init__.py +27 -0
  2. autoencodix/base/.ipynb_checkpoints/_base_pipeline-checkpoint.py +1274 -0
  3. autoencodix/base/.ipynb_checkpoints/_base_trainer-checkpoint.py +247 -0
  4. autoencodix/base/__init__.py +21 -0
  5. autoencodix/base/_base_autoencoder.py +138 -0
  6. autoencodix/base/_base_dataset.py +103 -0
  7. autoencodix/base/_base_evaluator.py +34 -0
  8. autoencodix/base/_base_loss.py +311 -0
  9. autoencodix/base/_base_pipeline.py +1355 -0
  10. autoencodix/base/_base_predictor.py +6 -0
  11. autoencodix/base/_base_preprocessor.py +1033 -0
  12. autoencodix/base/_base_trainer.py +259 -0
  13. autoencodix/base/_base_visualizer.py +578 -0
  14. autoencodix/base/sampleconfig.yaml +132 -0
  15. autoencodix/configs/__init__.py +21 -0
  16. autoencodix/configs/default_config.py +652 -0
  17. autoencodix/configs/disentanglix_config.py +15 -0
  18. autoencodix/configs/maskix_config.py +39 -0
  19. autoencodix/configs/ontix_config.py +35 -0
  20. autoencodix/configs/stackix_config.py +33 -0
  21. autoencodix/configs/vanillix_config.py +18 -0
  22. autoencodix/configs/varix_config.py +15 -0
  23. autoencodix/configs/xmodalix_config.py +43 -0
  24. autoencodix/disentanglix.py +72 -0
  25. autoencodix/evaluate/.ipynb_checkpoints/evaluate-checkpoint.py +532 -0
  26. autoencodix/evaluate/__init__.py +4 -0
  27. autoencodix/evaluate/_general_evaluator.py +730 -0
  28. autoencodix/evaluate/_xmodalix_evaluator.py +376 -0
  29. autoencodix/imagix.py +76 -0
  30. autoencodix/losses/disentanglix_loss.py +215 -0
  31. autoencodix/losses/maskix_loss.py +94 -0
  32. autoencodix/losses/vanillix_loss.py +28 -0
  33. autoencodix/losses/varix_loss.py +102 -0
  34. autoencodix/losses/xmodal_loss.py +304 -0
  35. autoencodix/maskix.py +108 -0
  36. autoencodix/modeling/.ipynb_checkpoints/__init__-checkpoint.py +16 -0
  37. autoencodix/modeling/.ipynb_checkpoints/_captum_forward-checkpoint.py +16 -0
  38. autoencodix/modeling/__init__.py +20 -0
  39. autoencodix/modeling/_captum_forward.py +20 -0
  40. autoencodix/modeling/_classifier.py +45 -0
  41. autoencodix/modeling/_imagevae_architecture.py +296 -0
  42. autoencodix/modeling/_imgfast_architecture.py +288 -0
  43. autoencodix/modeling/_layer_factory.py +71 -0
  44. autoencodix/modeling/_maskix_architecture.py +190 -0
  45. autoencodix/modeling/_ontix_architecture.py +305 -0
  46. autoencodix/modeling/_vanillix_architecture.py +136 -0
  47. autoencodix/modeling/_varix_architecture.py +192 -0
  48. autoencodix/notebooks/__init__.py +0 -0
  49. autoencodix/ontix.py +136 -0
  50. autoencodix/py.typed +0 -0
  51. autoencodix/stackix.py +122 -0
  52. autoencodix/trainers/__init__.py +6 -0
  53. autoencodix/trainers/_general_trainer.py +525 -0
  54. autoencodix/trainers/_maskix_trainer.py +116 -0
  55. autoencodix/trainers/_ontix_trainer.py +77 -0
  56. autoencodix/trainers/_stackix_orchestrator.py +522 -0
  57. autoencodix/trainers/_stackix_trainer.py +209 -0
  58. autoencodix/trainers/_xmodal_trainer.py +1006 -0
  59. autoencodix/utils/.ipynb_checkpoints/_explainer-checkpoint.py +118 -0
  60. autoencodix/utils/.ipynb_checkpoints/_model_output-checkpoint.py +30 -0
  61. autoencodix/utils/.ipynb_checkpoints/_utils-checkpoint.py +626 -0
  62. autoencodix/utils/.ipynb_checkpoints/adata_converter-checkpoint.py +99 -0
  63. autoencodix/utils/.ipynb_checkpoints/feature_importance-checkpoint.py +823 -0
  64. autoencodix/utils/__init__.py +22 -0
  65. autoencodix/utils/_annealer.py +193 -0
  66. autoencodix/utils/_bulkreader.py +232 -0
  67. autoencodix/utils/_explainer.py +246 -0
  68. autoencodix/utils/_imgreader.py +404 -0
  69. autoencodix/utils/_internals.py +23 -0
  70. autoencodix/utils/_llm_explainer.py +318 -0
  71. autoencodix/utils/_losses.py +20 -0
  72. autoencodix/utils/_model_output.py +30 -0
  73. autoencodix/utils/_result.py +393 -0
  74. autoencodix/utils/_screader.py +76 -0
  75. autoencodix/utils/_traindynamics.py +187 -0
  76. autoencodix/utils/_utils.py +690 -0
  77. autoencodix/utils/adata_converter.py +111 -0
  78. autoencodix/utils/example_data.py +398 -0
  79. autoencodix/utils/feature_importance.py +829 -0
  80. autoencodix/utils/prompts.py +21 -0
  81. autoencodix/vanillix.py +141 -0
  82. autoencodix/varix.py +80 -0
  83. autoencodix/visualize/__init__.py +5 -0
  84. autoencodix/visualize/_general_visualizer.py +913 -0
  85. autoencodix/visualize/_imagix_visualizer.py +111 -0
  86. autoencodix/visualize/_xmodal_visualizer.py +841 -0
  87. autoencodix/visualize/visualize.py +1214 -0
  88. autoencodix/xmodalix.py +318 -0
  89. autoencodix-0.2.0.dist-info/METADATA +143 -0
  90. autoencodix-0.2.0.dist-info/RECORD +91 -0
  91. 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