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,690 @@
1
+ """
2
+ Stores utility functions for the autoencodix package.
3
+ Use of OOP would be overkill for the simple functions in this module.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ # import zipfile
9
+ import inspect
10
+ import os
11
+ from collections import defaultdict
12
+ from dataclasses import MISSING, fields, is_dataclass
13
+ from functools import wraps
14
+ from typing import Any, Callable, Dict, List, Optional, no_type_check
15
+ from autoencodix.data._datasetcontainer import DatasetContainer
16
+ from autoencodix.utils._result import Result
17
+
18
+ import dill as pickle # type: ignore
19
+ import torch
20
+ import pandas as pd
21
+ from matplotlib import pyplot as plt
22
+
23
+ from autoencodix.configs.default_config import DefaultConfig
24
+
25
+
26
+ # only for type hints, to avoid circual import
27
+ class BasePipeline:
28
+ """Only for type hints in utils, not real BasePipeline class"""
29
+
30
+ pass
31
+
32
+
33
+ def get_dataset(result: Result) -> Optional[DatasetContainer]:
34
+ """Retrieve the dataset from the Result object, depending on if new_datasets is filled.
35
+
36
+ Args:
37
+ result: The Result object containing the dataset.
38
+ Returns:
39
+ The appropriate DatasetContainer object.
40
+
41
+ """
42
+ splits = ["train", "valid", "test"]
43
+ if not result.new_datasets:
44
+ return result.datasets
45
+ new_values: List[Any] = [result.new_datasets[split] for split in splits]
46
+ # check if all new_datasets are None
47
+ if all(v is None for v in new_values):
48
+ return result.datasets
49
+ else:
50
+ return result.new_datasets
51
+
52
+
53
+ def nested_dict():
54
+ """Creates a nested defaultdict.
55
+
56
+ This function returns a defaultdict where each value is another defaultdict
57
+ of the same type. This allows for the creation of arbitrarily deep nested
58
+ dictionaries without having to explicitly define each level.
59
+
60
+ Returns:
61
+ :A nested defaultdict where each value is another nested defaultdict.
62
+ """
63
+ return defaultdict(nested_dict)
64
+
65
+
66
+ def nested_to_tuple(d, base=()):
67
+ """Recursively converts a nested dictionary into tuples.
68
+
69
+ Args:
70
+ d: The dictionary to convert.
71
+ base: The base tuple to start with. Defaults to ().
72
+
73
+ Yields:
74
+ tuple: Tuples representing the nested dictionary structure, where each tuple
75
+ contains the keys leading to a value and the value itself.
76
+ """
77
+ if not isinstance(d, dict):
78
+ yield base + (d,)
79
+
80
+ else:
81
+ for k, v in d.items():
82
+ if isinstance(v, dict):
83
+ yield from nested_to_tuple(v, base + (k,))
84
+ else:
85
+ yield base + (k, v)
86
+
87
+
88
+ @no_type_check
89
+ def show_figure(fig):
90
+ """Display a given Matplotlib figure in a new window.
91
+
92
+ Args:
93
+ fig: The figure to be displayed.
94
+
95
+ """
96
+ dummy = plt.figure()
97
+ new_manager = dummy.canvas.manager
98
+ new_manager.canvas.figure = fig
99
+ fig.set_canvas(new_manager.canvas)
100
+
101
+
102
+ def config_method(valid_params: Optional[set[str]] = None):
103
+ """Decorator for methods that accept configuration parameters via kwargs or an explicit 'config' object.
104
+
105
+ It separates kwargs intended for the function's signature from those
106
+ intended as configuration overrides, validates the latter against
107
+ `valid_params`, applies valid overrides to a copy of `self.config`,
108
+ and passes the appropriate arguments to the decorated function.
109
+
110
+ Args:
111
+ valid_params: Set of valid configuration parameter names that can be overridden
112
+ via kwargs for this method. If None, all kwargs not matching the
113
+ function signature are considered potentially valid config overrides.
114
+ """
115
+
116
+ def decorator(func: Callable) -> Callable:
117
+ sig = inspect.signature(func)
118
+
119
+ param_docs = "\n\nValid configuration parameters (passed via **kwargs):\n"
120
+ if valid_params:
121
+ param_docs += "\n".join(f"- `{param}`" for param in sorted(valid_params))
122
+ else:
123
+ param_docs += (
124
+ "All keyword arguments not matching the function's "
125
+ "signature are treated as potential configuration overrides."
126
+ )
127
+
128
+ if func.__doc__ is None:
129
+ func.__doc__ = ""
130
+ # Avoid duplicating if decorator is applied multiple times (though unlikely)
131
+ if "Valid configuration parameters" not in func.__doc__:
132
+ func.__doc__ += param_docs
133
+ # --- End Docstring Modification ---
134
+
135
+ @wraps(func)
136
+ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
137
+ # Pop the explicit config object if provided
138
+ user_config = kwargs.pop("config", None)
139
+
140
+ # Get names of parameters in the decorated function's signature
141
+ # that can accept keyword arguments (excluding self and config)
142
+ func_sig_kwarg_names = {
143
+ name
144
+ for name, param in sig.parameters.items()
145
+ if param.kind
146
+ in (
147
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
148
+ inspect.Parameter.KEYWORD_ONLY,
149
+ )
150
+ and name not in ("self", "config")
151
+ }
152
+
153
+ # Separate kwargs into those matching the signature and potential config overrides
154
+ func_specific_kwargs = {}
155
+ potential_config_kwargs = {}
156
+ for k, v in kwargs.items():
157
+ if k in func_sig_kwarg_names:
158
+ func_specific_kwargs[k] = v
159
+ else:
160
+ potential_config_kwargs[k] = v
161
+
162
+ # Determine the configuration object to use
163
+ if user_config is None:
164
+ # No explicit config object, use self.config and apply overrides
165
+ if not hasattr(self, "config"):
166
+ raise AttributeError(
167
+ f"{type(self).__name__} instance is missing 'config' attribute."
168
+ )
169
+ # Ensure self.config is the right type (or duck-types model_copy/model_dump)
170
+ if not hasattr(self.config, "model_copy") or not hasattr(
171
+ self.config, "model_dump"
172
+ ):
173
+ raise TypeError(
174
+ f"'self.config' on {type(self).__name__} must have 'model_copy' and 'model_dump' methods."
175
+ )
176
+
177
+ final_config = self.config.model_copy(deep=True) # Start with a copy
178
+
179
+ # Validate potential_config_kwargs against valid_params
180
+ if valid_params:
181
+ # Check for invalid *config* parameters among the potential overrides
182
+ invalid_config_params = (
183
+ set(potential_config_kwargs.keys()) - valid_params
184
+ )
185
+ if invalid_config_params:
186
+ print(
187
+ f"\nWarning: The following parameters are not valid "
188
+ f"configuration overrides for {func.__name__}:" # type: ignore
189
+ )
190
+ print(
191
+ f"Invalid config parameters: {', '.join(invalid_config_params)}"
192
+ )
193
+ print(
194
+ f"Valid config parameters are: {', '.join(sorted(valid_params))}"
195
+ )
196
+ print("These parameters will be ignored.")
197
+
198
+ # Filter potential overrides to only include those listed in valid_params
199
+ # and that actually exist as fields in the config object
200
+ # (prevents adding arbitrary attributes if DefaultConfig uses Pydantic's extra='forbid')
201
+ valid_config_fields = {
202
+ p
203
+ for p in valid_params
204
+ if hasattr(final_config, p) # Safer check
205
+ }
206
+ config_overrides = {
207
+ k: v
208
+ for k, v in potential_config_kwargs.items()
209
+ if k in valid_config_fields
210
+ }
211
+
212
+ else: # valid_params is None: Allow all potential config kwargs as overrides
213
+ # Optional: Add a check here if you want to ensure they exist in the config model
214
+ config_overrides = potential_config_kwargs
215
+
216
+ # Apply the valid overrides
217
+ if config_overrides:
218
+ final_config = final_config.model_copy(update=config_overrides)
219
+
220
+ else:
221
+ # User provided an explicit config object
222
+ # Add type check if DefaultConfig class is available
223
+ # Note: Replace 'object' with 'DefaultConfig' if it's imported/defined
224
+ if not isinstance(user_config, DefaultConfig):
225
+ # Trying to be robust if DefaultConfig is not strictly enforced type
226
+ if hasattr(user_config, "model_copy") and hasattr(
227
+ user_config, "model_dump"
228
+ ):
229
+ pass # Looks like a valid config object duck-typing Pydantic
230
+ else:
231
+ raise TypeError(
232
+ "The 'config' parameter must be a valid configuration object "
233
+ "(e.g., an instance of DefaultConfig or similar)."
234
+ )
235
+ final_config = user_config
236
+ # Decide what to do with potential_config_kwargs when user_config is provided.
237
+ # Option 1: Ignore them (current implementation below)
238
+ # Option 2: Raise an error if any exist
239
+ # Option 3: Apply them even to the user_config (might be unexpected)
240
+ if potential_config_kwargs:
241
+ print(
242
+ f"\nWarning: Additional keyword arguments provided "
243
+ f"({', '.join(potential_config_kwargs.keys())}) "
244
+ f"while an explicit 'config' object was also passed to {func.__name__}. " # type: ignore
245
+ f"These additional arguments will be ignored as configuration overrides."
246
+ )
247
+
248
+ # Call the original function with the correct arguments
249
+ # Pass: self, original *args, the determined config object,
250
+ # and only the **kwargs that matched the function's signature.
251
+ return func(self, *args, config=final_config, **func_specific_kwargs)
252
+
253
+ # Preserve information about valid_params on the wrapper if needed elsewhere
254
+ setattr(wrapper, "valid_params", valid_params)
255
+ return wrapper
256
+
257
+ return decorator
258
+
259
+
260
+ class Saver:
261
+ """Handles the saving of BasePipeline objects.
262
+
263
+ Atrributes:
264
+ file_path: path to save file.
265
+ preprocessor_path: path where pickle object of preprocesser should be saved.
266
+ model_state_path: path where model state dict should be saved.
267
+ save_all: indicator if all results should be save, or only core pipeline functionalty
268
+
269
+ """
270
+
271
+ def __init__(self, file_path: str, save_all: bool):
272
+ """Initializes the Saver with the base file path.
273
+
274
+ Args:
275
+ file_path: The base file path (without extensions).
276
+ """
277
+
278
+ self.save_all = save_all
279
+ self.file_stem: str = Path(file_path).stem
280
+ self.file_name: str = Path(file_path).name
281
+ self.folder: str = Path(file_path).parent.as_posix()
282
+
283
+ self.preprocessor_path: str = os.path.join(
284
+ self.folder, f"{self.file_stem}_preprocessor.pkl"
285
+ )
286
+ self.model_state_path: str = os.path.join(
287
+ self.folder, f"{self.file_stem}_model.pth"
288
+ )
289
+ self.file_path: str = os.path.join(self.folder, self.file_name)
290
+ os.makedirs(self.folder, exist_ok=True)
291
+
292
+ @no_type_check
293
+ def save(self, pipeline: "BasePipeline"):
294
+ """Saves the BasePipeline object.
295
+
296
+ Args:
297
+ pipeline: The BasePipeline object to save.
298
+ """
299
+
300
+ self._save_preprocessor(pipeline._preprocessor) # type: ignore
301
+ self._save_model_state(pipeline)
302
+ self.pipeline = pipeline
303
+
304
+ self.pipeline._trainer.purge()
305
+
306
+ if not self.save_all:
307
+ print("saving memory efficient")
308
+ self.reset_to_defaults(pipeline.result) # ty: ignore
309
+
310
+ self.pipeline.preprocessed_data = None # ty: ignore
311
+ self.pipeline._datasets = None # ty: ignore
312
+ self.pipeline.raw_user_data = None # ty: ignore
313
+ self.pipeline._datasets = None
314
+ self.pipeline._preprocessor = type(
315
+ self.pipeline._preprocessor
316
+ )( # ty: ignore
317
+ config=pipeline.config # ty: ignore
318
+ ) # ty: ignore
319
+ self.pipeline.visualizer = type(self.pipeline.visualizer)() # ty: ignore
320
+
321
+ self._save_pipeline_object(self.pipeline)
322
+
323
+ ## REMOVING Zip functionality as it causes issues with filesystems
324
+ # with zipfile.ZipFile(
325
+ # os.path.join(self.folder, f"{self.file_stem}.zip"), "w"
326
+ # ) as archive:
327
+ # arcname = self.file_name
328
+ # archive.write(self.file_path , arcname=arcname)
329
+ # arcname = f"{self.file_stem}_preprocessor.pkl"
330
+ # archive.write(self.preprocessor_path, arcname=arcname)
331
+ # for model_state_path in self.model_state_paths:
332
+ # arcname = f"{self.file_stem}_model.pth"
333
+ # archive.write(model_state_path, arcname=arcname)
334
+ # os.remove(self.file_path)
335
+ # os.remove(self.preprocessor_path)
336
+ # for model_state_path in self.model_state_paths:
337
+ # os.remove(model_state_path)
338
+
339
+ def _save_pipeline_object(self, pipeline: "BasePipeline"):
340
+ try:
341
+ with open(self.file_path, "wb") as f:
342
+ pickle.dump(pipeline, f)
343
+ print("Pipeline object saved successfully.")
344
+ except (pickle.PicklingError, OSError) as e:
345
+ print(f"Error saving pipeline object: {e}")
346
+ raise e
347
+
348
+ def _save_preprocessor(self, preprocessor):
349
+ if preprocessor is not None:
350
+ try:
351
+ with open(self.preprocessor_path, "wb") as f:
352
+ pickle.dump(preprocessor, f)
353
+ print("Preprocessor saved successfully.")
354
+ except (pickle.PickleError, OSError) as e:
355
+ print(f"Error saving preprocessor: {e}")
356
+ raise e
357
+
358
+ @no_type_check
359
+ def _save_model_state(self, pipeline: "BasePipeline"):
360
+ self.model_state_paths: List[str] = []
361
+ if pipeline.result is not None and pipeline.result.model is not None: # type: ignore
362
+ if isinstance(pipeline.result.model, torch.nn.Module):
363
+ try:
364
+ pipeline.result.model.to("cpu")
365
+ torch.save(
366
+ pipeline.result.model.state_dict(), self.model_state_path
367
+ ) # type: ignore
368
+ self.model_state_paths.append(self.model_state_path)
369
+
370
+ except (TypeError, OSError) as e:
371
+ print(f"Error saving model state: {e}")
372
+ raise e
373
+ elif isinstance(pipeline.result.model, dict):
374
+ for model_name, model in pipeline.result.model.items():
375
+ if hasattr(model, "module"):
376
+ model = model.module
377
+ model.to("cpu")
378
+ cur_path: str = os.path.join(
379
+ self.folder, f"{model_name}_{self.file_stem}_model.pth"
380
+ )
381
+ torch.save(model.state_dict(), cur_path) # type: ignore
382
+ self.model_state_paths.append(cur_path)
383
+ else:
384
+ raise TypeError(
385
+ f"pipeline.result.model is neither a torch.nn.Module nor a dict, got {type(pipeline.result.model)}"
386
+ )
387
+
388
+ @no_type_check
389
+ def reset_to_defaults(self, obj):
390
+ if not is_dataclass(obj):
391
+ raise ValueError("Object must be a dataclass")
392
+
393
+ for f in fields(obj):
394
+ # we keep the adata_latent space as a "core result"
395
+ if f.name == "adata_latent":
396
+ continue
397
+ if f.name == "losses" or f.name == "sub_losses": # Keep loss dynamics
398
+ continue
399
+ if f.name == "model":
400
+ # we need to keep the instantiated class, so we can load the state dict
401
+ # but we don't want to save the model twice (once via the pipeline object, once as model.pth)
402
+ # thus, we first save the state_dict, then reiniate the model to free memory
403
+ if isinstance(obj.model, dict):
404
+ empty_models = {}
405
+ for model_name, model in obj.model.items():
406
+ if hasattr(model, "module"):
407
+ model = model.module
408
+ empty_models[model_name] = type(model)(**model.init_args)
409
+ elif isinstance(obj.model, torch.nn.Module):
410
+ obj.model.init_args["ontologies"] = self.pipeline.ontologies
411
+ obj.model.init_args["feature_order"] = (
412
+ self.pipeline._trainer.feature_order
413
+ )
414
+ obj.model = type(obj.model)(**obj.model.init_args)
415
+ continue
416
+ if f.default_factory is not MISSING:
417
+ setattr(obj, f.name, f.default_factory())
418
+ elif f.default is not MISSING:
419
+ setattr(obj, f.name, f.default)
420
+ else:
421
+ setattr(obj, f.name, None)
422
+
423
+
424
+ class Loader:
425
+ """Handles the loading of BasePipeline objects.
426
+
427
+ Atrributes:
428
+ file_path: path of saved pipeline object.
429
+ preprocessor_path: path where pickle object of preprocesser should was saved.
430
+ model_state_path: path where model state dict was saved.
431
+ """
432
+
433
+ def __init__(self, file_path: str):
434
+ """Initializes the Loader with the base file path.
435
+
436
+ Args:
437
+ file_path: The base file path (without extensions).
438
+ """
439
+
440
+ self.file_stem: str = Path(file_path).stem
441
+ self.file_name: str = Path(file_path).name
442
+ self.folder: str = Path(file_path).parent.as_posix()
443
+
444
+ self.preprocessor_path: str = os.path.join(
445
+ self.folder, f"{self.file_stem}_preprocessor.pkl"
446
+ )
447
+ self.model_state_path: str = os.path.join(
448
+ self.folder, f"{self.file_stem}_model.pth"
449
+ )
450
+ self.file_path: str = os.path.join(self.folder, self.file_name)
451
+
452
+ def load(self) -> Any:
453
+ """Loads the BasePipeline object.
454
+
455
+ Returns:
456
+ The loaded BasePipeline object, or None on error.
457
+ """
458
+ ## REMOVING Zip functionality since it causes issues with filesystems
459
+ # try:
460
+ # with zipfile.ZipFile(
461
+ # os.path.join(self.folder, f"{self.file_stem}.zip"), "r"
462
+ # ) as archive:
463
+ # archive.extractall()
464
+ # except:
465
+ # print(f"Error extracting zip file at {self.file_path}")
466
+ # print("Attempting to load without extraction...")
467
+
468
+ loaded_obj = self._load_pipeline_object()
469
+ if loaded_obj is None:
470
+ raise ValueError("Error while loading pipeline object")
471
+
472
+ loaded_obj._preprocessor = self._load_preprocessor()
473
+ loaded_obj.result.model = self._load_model_state(loaded_obj)
474
+
475
+ return loaded_obj
476
+
477
+ def _load_pipeline_object(self) -> Any:
478
+ print(f"Attempting to load a pipeline from {self.file_path}...")
479
+ try:
480
+ if not os.path.exists(self.file_path):
481
+ print(f"Error: File not found at {self.file_path}")
482
+ return None
483
+ with open(self.file_path, "rb") as f:
484
+ loaded_obj = pickle.load(f)
485
+ print(
486
+ f"Pipeline object loaded successfully. Actual type: {type(loaded_obj).__name__}"
487
+ )
488
+ return loaded_obj
489
+ except (pickle.UnpicklingError, EOFError, OSError, FileNotFoundError) as e:
490
+ print(f"Error loading pipeline object: {e}")
491
+ return None
492
+
493
+ def _load_preprocessor(self):
494
+ if os.path.exists(self.preprocessor_path):
495
+ try:
496
+ with open(self.preprocessor_path, "rb") as f:
497
+ preprocessor = pickle.load(f)
498
+ print("Preprocessor loaded successfully.")
499
+ return preprocessor
500
+ except (pickle.UnpicklingError, EOFError, OSError, FileNotFoundError) as e:
501
+ print(f"Error loading preprocessor: {e}")
502
+ return None
503
+ else:
504
+ print("Preprocessor file not found. Skipping preprocessor load.")
505
+ return None
506
+
507
+ @no_type_check
508
+ def _load_model_state(self, loaded_obj: "BasePipeline"):
509
+ if loaded_obj.result is None: # type: ignore:
510
+ raise ValueError("Loaded pipeline has no result attribute")
511
+ if loaded_obj.result.model is None:
512
+ raise ValueError("Loaded pipeline result has no model attribute")
513
+ if isinstance(loaded_obj.result.model, dict):
514
+ for model_name, model in loaded_obj.result.model.items():
515
+ if hasattr(model, "module"):
516
+ model = model.module
517
+ cur_path: str = os.path.join(
518
+ self.folder, f"{model_name}_{self.file_stem}_model.pth"
519
+ )
520
+ if not os.path.exists(cur_path):
521
+ raise FileNotFoundError(f"Model state file not found at {cur_path}")
522
+ model_state = torch.load(cur_path, map_location="cpu")
523
+ loaded_obj.result.model[model_name].to("cpu")
524
+ loaded_obj.result.model[model_name].load_state_dict( # type: ignore
525
+ model_state
526
+ )
527
+ return loaded_obj.result.model # type: ignore
528
+ elif isinstance(loaded_obj.result.model, torch.nn.Module):
529
+ if not os.path.exists(self.model_state_path):
530
+ raise FileNotFoundError(
531
+ f"Model state file not found at {self.model_state_path}"
532
+ )
533
+ model_state = torch.load(self.model_state_path, map_location="cpu")
534
+ loaded_obj.result.model.to("cpu")
535
+ loaded_obj.result.model.load_state_dict(model_state) # type: ignore
536
+ return loaded_obj.result.model # type: ignore
537
+ else:
538
+ raise TypeError(
539
+ f"Loaded model is neither a dict nor a torch.nn.Module, got {type(loaded_obj.result.model)}"
540
+ )
541
+
542
+
543
+ def flip_labels(labels: torch.Tensor) -> torch.Tensor:
544
+ """Randomly flip modality labels with probability (1 - 1/n_modalities), vectorized.
545
+
546
+ This is mainly used for advers training in multi modal xmodalix.
547
+
548
+ Args:
549
+ labels: tensor of labels
550
+ Returns:
551
+ flipped tensor
552
+
553
+ """
554
+ device = labels.device
555
+ n_modalities = labels.unique().numel()
556
+ batch_size = labels.size(0)
557
+ flip_prob = 1.0 - 1.0 / n_modalities
558
+
559
+ # Decide which labels to flip
560
+ flip_mask = torch.rand(batch_size, device=device) < flip_prob
561
+
562
+ # Sample random labels for flipping
563
+ rand_labels = torch.randint(0, n_modalities, size=(batch_size,), device=device)
564
+
565
+ # Ensure sampled labels are different from original labels
566
+ needs_resample = (rand_labels == labels) & flip_mask
567
+ while needs_resample.any():
568
+ rand_labels[needs_resample] = torch.randint(
569
+ 0, n_modalities, size=(needs_resample.sum(),), device=device
570
+ )
571
+ needs_resample = (rand_labels == labels) & flip_mask
572
+
573
+ # Apply flipped labels where needed
574
+ flipped = labels.clone()
575
+ flipped[flip_mask] = rand_labels[flip_mask]
576
+
577
+ return flipped
578
+
579
+
580
+ def find_translation_keys(
581
+ config: DefaultConfig,
582
+ trained_modalities: List[str],
583
+ from_key: Optional[str] = None,
584
+ to_key: Optional[str] = None,
585
+ ) -> Dict[str, str]: # type: ignore
586
+ """Find translation source and target modalities.
587
+
588
+ Determines which modalities serve as the "from" and "to" directions for
589
+ cross-modal prediction, either from explicit arguments or from the
590
+ configuration.
591
+
592
+ Args:
593
+ config: Experiment configuration containing data information.
594
+ trained_modalities: List of trained modality names.
595
+ from_key: Optional name of the source modality.
596
+ to_key: Optional name of the target modality.
597
+
598
+ Returns:
599
+ A dictionary with two entries:
600
+ - "from": Name of the source modality.
601
+ - "to": Name of the target modality.
602
+
603
+ Raises:
604
+ ValueError: If no valid "from" or "to" modality is found, or if
605
+ multiple conflicting directions are specified.
606
+ """
607
+ from_key_final: Optional[str] = None
608
+ to_key_final: Optional[str] = None
609
+ simple_names: List[str] = [
610
+ tm.split(".", 1)[1] if "." in tm else tm for tm in trained_modalities
611
+ ]
612
+
613
+ if from_key and to_key:
614
+ for name in trained_modalities:
615
+ simple_name = name.split(".", 1)[1] if "." in name else name
616
+
617
+ if from_key == simple_name or from_key == name:
618
+ from_key_final = name
619
+ # use if instead of elif to allow for reference prediciton where from_key == to_key
620
+ if to_key == simple_name or from_key == name:
621
+ to_key_final = name
622
+ # if the users passes from_key and to_key and we don't find them, we raise an error
623
+ if not (from_key_final and to_key_final):
624
+ raise ValueError(
625
+ f"Invalid translation keys: {from_key} => {to_key}, valid keys are: {simple_names}"
626
+ )
627
+ return {"from": from_key_final, "to": to_key_final}
628
+
629
+ data_info = config.data_config.data_info
630
+ for name in trained_modalities:
631
+ simple_name = name.split(".", 1)[1] if "." in name else name
632
+ cur_info = data_info.get(simple_name)
633
+ if not cur_info or not hasattr(cur_info, "translate_direction"):
634
+ continue
635
+
636
+ direction = cur_info.translate_direction
637
+ if direction == "to":
638
+ if to_key_final is not None:
639
+ raise ValueError(
640
+ f"Multiple 'to' directions found: '{to_key_final}' and '{name}'"
641
+ )
642
+ to_key_final = name
643
+ elif direction == "from":
644
+ if from_key_final is not None:
645
+ raise ValueError(
646
+ f"Multiple 'from' directions found: '{from_key_final}' and '{name}'"
647
+ )
648
+ from_key_final = name
649
+
650
+ if from_key_final is None:
651
+ raise ValueError(
652
+ "No modality with a 'from' direction was specified in the config."
653
+ )
654
+ if to_key_final is None:
655
+ raise ValueError(
656
+ "No modality with a 'to' direction was specified in the config."
657
+ )
658
+ assert from_key_final is not None and to_key_final is not None
659
+
660
+ return {"from": from_key_final, "to": to_key_final}
661
+
662
+
663
+ def preprocess_explanations(
664
+ df: pd.DataFrame, n: int = 10, max_dims: int = 8
665
+ ) -> Dict[str, List[str]]:
666
+ """
667
+ Transform a DataFrame of gene attributions into a dictionary mapping each
668
+ (selected) latent dimension to its top-n genes.
669
+
670
+ The function:
671
+ - Computes the mean attribution for each latent dimension.
672
+ - Selects the top `max_dims` most informative dimensions (highest mean).
673
+ - Extracts the top-n genes for each selected dimension.
674
+
675
+ Args:
676
+ df: Output DataFrame of gene attributions from explainix.
677
+ n: Number of top genes to include per latent dimension.
678
+ max_dims: Maximum number of latent dimensions to retain.
679
+
680
+ Returns:
681
+ Dictionary with latent dimensions as keys and lists of top gene names as values.
682
+ """
683
+ # Identify top latent dimensions by mean attribution
684
+ top_dims = df.mean(axis=0).nlargest(max_dims).index
685
+
686
+ result = {}
687
+ for col in top_dims:
688
+ result[col] = df.nlargest(n, col).index.tolist()
689
+
690
+ return result