D4CMPP2 0.4.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.
Files changed (103) hide show
  1. D4CMPP2/_Data/AGENTS.md +24 -0
  2. D4CMPP2/_Data/Aqsoldb.csv +9291 -0
  3. D4CMPP2/_Data/BradleyMP.csv +3042 -0
  4. D4CMPP2/_Data/Lipophilicity.csv +1131 -0
  5. D4CMPP2/_Data/README.md +8 -0
  6. D4CMPP2/_Data/__init__.py +26 -0
  7. D4CMPP2/_Data/optical.csv +20237 -0
  8. D4CMPP2/_Data/test.csv +190 -0
  9. D4CMPP2/__init__.py +16 -0
  10. D4CMPP2/__main__.py +5 -0
  11. D4CMPP2/_main.py +500 -0
  12. D4CMPP2/cli.py +7 -0
  13. D4CMPP2/exceptions.py +53 -0
  14. D4CMPP2/grid_search.py +259 -0
  15. D4CMPP2/network_refer.yaml +160 -0
  16. D4CMPP2/networks/AFP_model.py +72 -0
  17. D4CMPP2/networks/AFPwithSolv_model.py +72 -0
  18. D4CMPP2/networks/DMPNN_model.py +90 -0
  19. D4CMPP2/networks/DMPNNwithSolv_model.py +89 -0
  20. D4CMPP2/networks/GAT_model.py +48 -0
  21. D4CMPP2/networks/GATwithSolv_model.py +63 -0
  22. D4CMPP2/networks/GCN_model.py +113 -0
  23. D4CMPP2/networks/GCNwithSolv_model.py +103 -0
  24. D4CMPP2/networks/GC_model.py +122 -0
  25. D4CMPP2/networks/ISATPM_model.py +14 -0
  26. D4CMPP2/networks/ISATPN_model.py +199 -0
  27. D4CMPP2/networks/ISAT_model.py +90 -0
  28. D4CMPP2/networks/MPNN_model.py +56 -0
  29. D4CMPP2/networks/MPNNwithSolv_model.py +72 -0
  30. D4CMPP2/networks/__init__.py +25 -0
  31. D4CMPP2/networks/base.py +250 -0
  32. D4CMPP2/networks/registry.py +187 -0
  33. D4CMPP2/networks/src/AFP.py +118 -0
  34. D4CMPP2/networks/src/BiDropout.py +29 -0
  35. D4CMPP2/networks/src/DMPNN.py +35 -0
  36. D4CMPP2/networks/src/GAT.py +69 -0
  37. D4CMPP2/networks/src/GC.py +85 -0
  38. D4CMPP2/networks/src/GCN.py +71 -0
  39. D4CMPP2/networks/src/ISAT.py +153 -0
  40. D4CMPP2/networks/src/Linear.py +49 -0
  41. D4CMPP2/networks/src/MPNN.py +56 -0
  42. D4CMPP2/networks/src/SolventLayer.py +62 -0
  43. D4CMPP2/networks/src/__init__.py +0 -0
  44. D4CMPP2/networks/src/distGCN.py +21 -0
  45. D4CMPP2/networks/src/pyg_hetero.py +24 -0
  46. D4CMPP2/optimize.py +472 -0
  47. D4CMPP2/src/Analyzer/ISAAnalyzer.py +458 -0
  48. D4CMPP2/src/Analyzer/ISAPNAnalyzer.py +366 -0
  49. D4CMPP2/src/Analyzer/ISAwSAnalyzer.py +117 -0
  50. D4CMPP2/src/Analyzer/MolAnalyzer.py +319 -0
  51. D4CMPP2/src/Analyzer/__init__.py +54 -0
  52. D4CMPP2/src/Analyzer/core.py +480 -0
  53. D4CMPP2/src/Analyzer/factory.py +166 -0
  54. D4CMPP2/src/Analyzer/interpretation.py +232 -0
  55. D4CMPP2/src/Analyzer/results.py +101 -0
  56. D4CMPP2/src/DataManager/Dataset/GraphDataset.py +314 -0
  57. D4CMPP2/src/DataManager/Dataset/ISAGraphDataset.py +384 -0
  58. D4CMPP2/src/DataManager/Dataset/__init__.py +0 -0
  59. D4CMPP2/src/DataManager/GraphGenerator/ISAGraphGenerator.py +223 -0
  60. D4CMPP2/src/DataManager/GraphGenerator/MolGraphGenerator.py +73 -0
  61. D4CMPP2/src/DataManager/GraphGenerator/__init__.py +14 -0
  62. D4CMPP2/src/DataManager/ISADataManager.py +67 -0
  63. D4CMPP2/src/DataManager/MolDataManager.py +735 -0
  64. D4CMPP2/src/DataManager/__init__.py +14 -0
  65. D4CMPP2/src/DataManager/contracts.py +179 -0
  66. D4CMPP2/src/NetworkManager/ISANetworkManager.py +12 -0
  67. D4CMPP2/src/NetworkManager/NetworkManager.py +520 -0
  68. D4CMPP2/src/NetworkManager/__init__.py +14 -0
  69. D4CMPP2/src/PostProcessor.py +160 -0
  70. D4CMPP2/src/TrainManager/ISATrainManager.py +26 -0
  71. D4CMPP2/src/TrainManager/TrainManager.py +254 -0
  72. D4CMPP2/src/TrainManager/__init__.py +14 -0
  73. D4CMPP2/src/TrainManager/callbacks.py +119 -0
  74. D4CMPP2/src/__init__.py +0 -0
  75. D4CMPP2/src/utils/PATH.py +246 -0
  76. D4CMPP2/src/utils/__init__.py +0 -0
  77. D4CMPP2/src/utils/argparser.py +56 -0
  78. D4CMPP2/src/utils/checkpointing.py +90 -0
  79. D4CMPP2/src/utils/config_resolution.py +123 -0
  80. D4CMPP2/src/utils/config_validation.py +370 -0
  81. D4CMPP2/src/utils/csv_validation.py +105 -0
  82. D4CMPP2/src/utils/data_quality.py +181 -0
  83. D4CMPP2/src/utils/featureizer.py +202 -0
  84. D4CMPP2/src/utils/functional_group.csv +169 -0
  85. D4CMPP2/src/utils/graph_cache.py +213 -0
  86. D4CMPP2/src/utils/leaderboard.py +212 -0
  87. D4CMPP2/src/utils/metrics.py +31 -0
  88. D4CMPP2/src/utils/module_loader.py +147 -0
  89. D4CMPP2/src/utils/output.py +80 -0
  90. D4CMPP2/src/utils/reproducibility.py +70 -0
  91. D4CMPP2/src/utils/run_manifest.py +175 -0
  92. D4CMPP2/src/utils/scaler.py +40 -0
  93. D4CMPP2/src/utils/sculptor.py +713 -0
  94. D4CMPP2/src/utils/splitting.py +250 -0
  95. D4CMPP2/src/utils/supportfile_saver.py +94 -0
  96. D4CMPP2/src/utils/tools.py +156 -0
  97. D4CMPP2/src/utils/transfer_learning.py +111 -0
  98. d4cmpp2-0.4.0.dist-info/METADATA +420 -0
  99. d4cmpp2-0.4.0.dist-info/RECORD +103 -0
  100. d4cmpp2-0.4.0.dist-info/WHEEL +5 -0
  101. d4cmpp2-0.4.0.dist-info/entry_points.txt +2 -0
  102. d4cmpp2-0.4.0.dist-info/licenses/LICENSE +21 -0
  103. d4cmpp2-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,480 @@
1
+ """Inference core shared by general and ISA Analyzer compatibility classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import os
7
+ import pickle
8
+ import random
9
+ import sys
10
+ from contextlib import contextmanager
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Mapping, Sequence
14
+
15
+ import numpy as np
16
+ import torch
17
+ import yaml
18
+
19
+ from D4CMPP2.src.utils import PATH, module_loader
20
+ from D4CMPP2.src.utils.scaler import Scaler, identityScaler
21
+
22
+ from .results import PredictionResult, PredictionRow, UncertaintyResult
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ModelArtifacts:
27
+ """Resolved files required to reconstruct a saved model."""
28
+
29
+ root: Path
30
+ config: Path
31
+ network: Path
32
+ weights: Path
33
+ scaler: Path | None
34
+ functional_group: Path | None
35
+
36
+
37
+ class _CompatibleScalerUnpickler(pickle.Unpickler):
38
+ """Map only known historical D4CMPP scaler classes to current equivalents."""
39
+
40
+ _ALIASES = {
41
+ ("D4CMPP.src.utils.scaler", "Scaler"): Scaler,
42
+ ("D4CMPP.src.utils.scaler", "identityScaler"): identityScaler,
43
+ ("D4CMPP2.src.utils.scaler", "Scaler"): Scaler,
44
+ ("D4CMPP2.src.utils.scaler", "identityScaler"): identityScaler,
45
+ }
46
+
47
+ def find_class(self, module, name):
48
+ alias = self._ALIASES.get((module, name))
49
+ if alias is not None:
50
+ return alias
51
+ return super().find_class(module, name)
52
+
53
+
54
+ @contextmanager
55
+ def _prevent_saved_model_bytecode():
56
+ """Keep importlib from writing __pycache__ into a saved model folder."""
57
+
58
+ previous = sys.dont_write_bytecode
59
+ sys.dont_write_bytecode = True
60
+ try:
61
+ yield
62
+ finally:
63
+ sys.dont_write_bytecode = previous
64
+
65
+
66
+ def resolve_model_artifacts(model_path, *, model_dir=None) -> ModelArtifacts:
67
+ """Resolve a saved model and report missing artifacts together."""
68
+
69
+ config_hint = {"MODEL_DIR": os.fspath(model_dir)} if model_dir is not None else None
70
+ root = Path(PATH.find_model_path(model_path, config_hint)).expanduser().resolve()
71
+ paths = {
72
+ "config.yaml": root / "config.yaml",
73
+ "network.py": root / "network.py",
74
+ "final.pth": root / "final.pth",
75
+ }
76
+ missing = [name for name, path in paths.items() if not path.is_file()]
77
+ if missing:
78
+ raise FileNotFoundError(
79
+ f"Saved model {str(root)!r} is incomplete. Missing required artifacts: {missing}. "
80
+ "Restore the files from the original model folder or provide a complete model path."
81
+ )
82
+ scaler = root / "scaler.pkl"
83
+ functional_group = root / "functional_group.csv"
84
+ return ModelArtifacts(
85
+ root=root,
86
+ config=paths["config.yaml"],
87
+ network=paths["network.py"],
88
+ weights=paths["final.pth"],
89
+ scaler=scaler if scaler.is_file() else None,
90
+ functional_group=functional_group if functional_group.is_file() else None,
91
+ )
92
+
93
+
94
+ def _as_list(value, column):
95
+ if isinstance(value, str):
96
+ return [value]
97
+ if isinstance(value, np.ndarray):
98
+ if value.ndim == 0:
99
+ return [value.item()]
100
+ if value.ndim != 1:
101
+ raise ValueError(
102
+ f"Input column {column!r} must be one-dimensional, "
103
+ f"got NumPy shape {value.shape}."
104
+ )
105
+ return value.tolist()
106
+ if hasattr(value, "tolist") and not isinstance(value, (list, tuple)):
107
+ converted = value.tolist()
108
+ if isinstance(converted, list) and any(
109
+ isinstance(item, (list, tuple)) for item in converted
110
+ ):
111
+ raise ValueError(
112
+ f"Input column {column!r} must be one-dimensional; "
113
+ f"got nested values from {type(value).__name__}."
114
+ )
115
+ return converted if isinstance(converted, list) else [converted]
116
+ if isinstance(value, (list, tuple)):
117
+ return list(value)
118
+ if np.isscalar(value):
119
+ return [value.item() if hasattr(value, "item") else value]
120
+ raise TypeError(
121
+ f"Input column {column!r} must be a scalar or one-dimensional sequence, "
122
+ f"got {type(value).__name__}."
123
+ )
124
+
125
+
126
+ class InferenceCore:
127
+ """Load one saved model and execute row-preserving inference."""
128
+
129
+ def __init__(
130
+ self,
131
+ model_path,
132
+ *,
133
+ device=None,
134
+ batch_size=None,
135
+ model_dir=None,
136
+ ):
137
+ self.artifacts = resolve_model_artifacts(model_path, model_dir=model_dir)
138
+ with self.artifacts.config.open(encoding="utf-8") as file:
139
+ loaded = yaml.load(file, Loader=yaml.FullLoader)
140
+ if not isinstance(loaded, dict):
141
+ raise ValueError(
142
+ f"Saved config {str(self.artifacts.config)!r} must contain a mapping, "
143
+ f"got {type(loaded).__name__}."
144
+ )
145
+
146
+ self.config = copy.deepcopy(loaded)
147
+ if "sculptor_index" in self.config and not {
148
+ "sculptor_s",
149
+ "sculptor_c",
150
+ "sculptor_a",
151
+ }.issubset(self.config):
152
+ sculptor_index = self.config["sculptor_index"]
153
+ if not isinstance(sculptor_index, (list, tuple)) or len(sculptor_index) != 3:
154
+ raise ValueError(
155
+ f"Saved config {str(self.artifacts.config)!r} has invalid sculptor_index "
156
+ f"{sculptor_index!r}; expected three integers."
157
+ )
158
+ (
159
+ self.config["sculptor_s"],
160
+ self.config["sculptor_c"],
161
+ self.config["sculptor_a"],
162
+ ) = tuple(sculptor_index)
163
+ root = str(self.artifacts.root)
164
+ self.config["MODEL_PATH"] = root
165
+ self.config["LOAD_PATH"] = root
166
+ if self.artifacts.functional_group is not None:
167
+ self.config["FRAG_REF"] = str(self.artifacts.functional_group)
168
+ if device is not None:
169
+ self.config["device"] = str(device)
170
+ if batch_size is not None:
171
+ if isinstance(batch_size, bool) or not isinstance(batch_size, int) or batch_size <= 0:
172
+ raise ValueError(f"batch_size must be a positive integer, got {batch_size!r}.")
173
+ self.config["batch_size"] = batch_size
174
+
175
+ self.targets = tuple(self.config.get("target", []))
176
+ if not self.targets:
177
+ raise ValueError(
178
+ f"Saved config {str(self.artifacts.config)!r} has no target columns. "
179
+ "Restore the training config with a non-empty target list."
180
+ )
181
+
182
+ is_isa = "ISA" in str(self.config.get("data_manager_class", ""))
183
+ if is_isa and self.artifacts.functional_group is None:
184
+ raise FileNotFoundError(
185
+ f"ISA saved model {root!r} is missing 'functional_group.csv'. "
186
+ "Restore the functional-group file saved during training."
187
+ )
188
+
189
+ scaler_name = str(self.config.get("scaler", "identity")).lower()
190
+ if self.artifacts.scaler is None:
191
+ if scaler_name not in {"identity", "none"}:
192
+ raise FileNotFoundError(
193
+ f"Saved model {root!r} uses scaler {scaler_name!r} but 'scaler.pkl' is missing. "
194
+ "Restore the fitted scaler; predictions cannot be converted to target units without it."
195
+ )
196
+ self.scaler = None
197
+ else:
198
+ try:
199
+ with self.artifacts.scaler.open("rb") as file:
200
+ self.scaler = _CompatibleScalerUnpickler(file).load()
201
+ except (
202
+ OSError,
203
+ pickle.PickleError,
204
+ EOFError,
205
+ AttributeError,
206
+ ImportError,
207
+ ) as exc:
208
+ raise ValueError(
209
+ f"Could not load scaler {str(self.artifacts.scaler)!r}. "
210
+ "Check that it is the original, compatible scaler.pkl."
211
+ ) from exc
212
+
213
+ # DataManager reconstructs the graph contract and may populate feature dimensions
214
+ # in the copied config before the saved network is initialized.
215
+ self.dm = module_loader.load_data_manager(self.config)(self.config)
216
+ self.molecule_columns = tuple(self.dm.molecule_columns)
217
+ self.numeric_input_columns = tuple(self.dm.numeric_input_columns)
218
+ self.input_columns = self.molecule_columns + self.numeric_input_columns
219
+ with _prevent_saved_model_bytecode():
220
+ self.nm = module_loader.load_network_manager(self.config)(
221
+ self.config, unwrapper=self.dm.unwrapper, temp=True
222
+ )
223
+ self.tm = module_loader.load_train_manager(self.config)(self.config)
224
+
225
+ def normalize_inputs(self, args, kwargs):
226
+ """Return copied, equally sized input columns without mutating caller data."""
227
+
228
+ values = dict(kwargs)
229
+ if args:
230
+ if len(args) > len(self.input_columns):
231
+ raise ValueError(
232
+ f"Expected at most {len(self.input_columns)} positional inputs in order "
233
+ f"{list(self.input_columns)}, got {len(args)}."
234
+ )
235
+ for column, value in zip(self.input_columns, args):
236
+ if column in values:
237
+ raise ValueError(f"Input column {column!r} was provided both positionally and by keyword.")
238
+ values[column] = value
239
+
240
+ unknown = sorted(set(values) - set(self.input_columns))
241
+ if unknown:
242
+ raise ValueError(
243
+ f"Unknown input columns {unknown}. Expected molecule columns "
244
+ f"{list(self.molecule_columns)} and numeric columns {list(self.numeric_input_columns)}."
245
+ )
246
+ missing = [column for column in self.input_columns if column not in values]
247
+ if missing:
248
+ raise ValueError(
249
+ f"Missing required input columns {missing}. Expected columns: {list(self.input_columns)}."
250
+ )
251
+ normalized = {column: _as_list(values[column], column) for column in self.input_columns}
252
+ lengths = {column: len(items) for column, items in normalized.items()}
253
+ if len(set(lengths.values())) != 1:
254
+ raise ValueError(
255
+ f"All Analyzer input columns must have the same length; received lengths {lengths}."
256
+ )
257
+ if next(iter(lengths.values()), 0) == 0:
258
+ raise ValueError("Analyzer input must contain at least one row.")
259
+ for column in self.numeric_input_columns:
260
+ for index, value in enumerate(normalized[column]):
261
+ if isinstance(value, bool) or not isinstance(value, (int, float, np.number)):
262
+ raise TypeError(
263
+ f"Numeric input {column!r} at row {index} must be a number, "
264
+ f"got {type(value).__name__}."
265
+ )
266
+ if not np.isfinite(value):
267
+ raise ValueError(
268
+ f"Numeric input {column!r} at row {index} must be finite, got {value!r}."
269
+ )
270
+ return normalized
271
+
272
+ def _prepare(self, normalized):
273
+ self.dm.init_temp_data(**{key: list(value) for key, value in normalized.items()})
274
+ loader = self.dm.get_Dataloaders(temp=True)
275
+ indices = [int(value) for value in np.asarray(self.dm.original_row_indices).tolist()]
276
+ return loader, indices
277
+
278
+ def _inverse_transform(self, scores):
279
+ array = scores.detach().cpu().numpy() if isinstance(scores, torch.Tensor) else np.asarray(scores)
280
+ return array if self.scaler is None else self.scaler.inverse_transform(array)
281
+
282
+ def _result_from_scores(self, normalized, valid_indices, scores, *, metadata=None):
283
+ row_count = len(next(iter(normalized.values())))
284
+ score_array = np.asarray(scores)
285
+ expected_shape = (len(valid_indices), len(self.targets))
286
+ if score_array.shape != expected_shape:
287
+ raise ValueError(
288
+ f"Analyzer prediction shape {score_array.shape} does not match "
289
+ f"expected {expected_shape} for {len(valid_indices)} valid rows "
290
+ f"and targets {list(self.targets)}."
291
+ )
292
+ score_by_row = {
293
+ row: np.asarray(score_array[index])
294
+ for index, row in enumerate(valid_indices)
295
+ }
296
+ errors = {}
297
+ for item in getattr(self.dm, "graph_errors", []):
298
+ row = int(item.get("row_index"))
299
+ message = (
300
+ f"Invalid molecule in column {item.get('type')!r}: {item.get('smiles')!r}. "
301
+ f"Graph generation failed: {item.get('reason', 'unknown reason')}."
302
+ )
303
+ errors.setdefault(row, []).append(message)
304
+ rows = []
305
+ for row_index in range(row_count):
306
+ inputs = {column: normalized[column][row_index] for column in self.input_columns}
307
+ if row_index in score_by_row:
308
+ rows.append(
309
+ PredictionRow(
310
+ row_index=row_index,
311
+ inputs=inputs,
312
+ prediction=score_by_row[row_index],
313
+ )
314
+ )
315
+ else:
316
+ message = " ".join(errors.get(row_index, [])) or (
317
+ "Input row was filtered during graph preparation. Check molecule syntax "
318
+ "and all required input columns."
319
+ )
320
+ rows.append(
321
+ PredictionRow(
322
+ row_index=row_index,
323
+ inputs=inputs,
324
+ prediction=None,
325
+ status="invalid",
326
+ error=message,
327
+ )
328
+ )
329
+ return PredictionResult(tuple(rows), self.targets, metadata or {})
330
+
331
+ def predict(self, *args, **kwargs) -> PredictionResult:
332
+ normalized = self.normalize_inputs(args, kwargs)
333
+ loader, valid_indices = self._prepare(normalized)
334
+ if not valid_indices:
335
+ empty = np.empty((0, len(self.targets)), dtype=float)
336
+ return self._result_from_scores(normalized, valid_indices, empty)
337
+ scores, _, _ = self.tm.predict(self.nm, loader, dropout=False)
338
+ return self._result_from_scores(
339
+ normalized,
340
+ valid_indices,
341
+ self._inverse_transform(scores),
342
+ metadata={"model_path": str(self.artifacts.root), "device": self.config.get("device")},
343
+ )
344
+
345
+ def predict_uncertainty(self, *args, samples=30, seed=None, **kwargs) -> UncertaintyResult:
346
+ if isinstance(samples, bool) or not isinstance(samples, int) or samples < 2:
347
+ raise ValueError(f"samples must be an integer of at least 2, got {samples!r}.")
348
+ dropout_modules = [
349
+ module for module in self.nm.network.modules()
350
+ if module.__class__.__name__.startswith("Dropout")
351
+ ]
352
+ if not dropout_modules:
353
+ raise ValueError(
354
+ f"Model {str(self.artifacts.root)!r} has no Dropout modules; MC-dropout "
355
+ "uncertainty is not available. Use an ensemble of independently trained models instead."
356
+ )
357
+ normalized = self.normalize_inputs(args, kwargs)
358
+ loader, valid_indices = self._prepare(normalized)
359
+ if not valid_indices:
360
+ empty = np.empty((0, len(self.targets)), dtype=float)
361
+ result = self._result_from_scores(normalized, valid_indices, empty)
362
+ return UncertaintyResult(result, result, tuple(), "mc_dropout", seed)
363
+
364
+ cpu_state = torch.random.get_rng_state()
365
+ cuda_state = torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None
366
+ numpy_state = np.random.get_state()
367
+ python_state = random.getstate()
368
+ draws = []
369
+ try:
370
+ if seed is not None:
371
+ random.seed(seed)
372
+ np.random.seed(seed)
373
+ torch.manual_seed(seed)
374
+ if torch.cuda.is_available():
375
+ torch.cuda.manual_seed_all(seed)
376
+ for _ in range(samples):
377
+ scores, _, _ = self.tm.predict(self.nm, loader, dropout=True)
378
+ values = self._inverse_transform(scores)
379
+ draws.append(
380
+ self._result_from_scores(
381
+ normalized,
382
+ valid_indices,
383
+ values,
384
+ metadata={"method": "mc_dropout"},
385
+ )
386
+ )
387
+ finally:
388
+ self.nm.eval()
389
+ torch.random.set_rng_state(cpu_state)
390
+ if cuda_state is not None:
391
+ torch.cuda.set_rng_state_all(cuda_state)
392
+ np.random.set_state(numpy_state)
393
+ random.setstate(python_state)
394
+
395
+ stack = np.stack(
396
+ [[row.prediction for row in draw.valid_rows] for draw in draws],
397
+ axis=0,
398
+ )
399
+ mean = self._result_from_scores(
400
+ normalized,
401
+ valid_indices,
402
+ stack.mean(axis=0),
403
+ metadata={"method": "mc_dropout", "samples": samples, "seed": seed},
404
+ )
405
+ std = self._result_from_scores(
406
+ normalized,
407
+ valid_indices,
408
+ stack.std(axis=0, ddof=0),
409
+ metadata={"method": "mc_dropout", "samples": samples, "seed": seed},
410
+ )
411
+ return UncertaintyResult(mean, std, tuple(draws), "mc_dropout", seed)
412
+
413
+
414
+ def predict_ensemble(analyzers, *args, **kwargs) -> UncertaintyResult:
415
+ """Aggregate compatible Analyzer instances into ensemble mean/std."""
416
+
417
+ analyzers = list(analyzers)
418
+ if len(analyzers) < 2:
419
+ raise ValueError("Ensemble prediction requires at least two Analyzer instances.")
420
+ cores = [getattr(analyzer, "_core", analyzer) for analyzer in analyzers]
421
+ reference = cores[0]
422
+ for index, core in enumerate(cores[1:], start=1):
423
+ if core.input_columns != reference.input_columns:
424
+ raise ValueError(
425
+ f"Ensemble model {index} input columns {list(core.input_columns)} do not match "
426
+ f"{list(reference.input_columns)}."
427
+ )
428
+ if core.targets != reference.targets:
429
+ raise ValueError(
430
+ f"Ensemble model {index} targets {list(core.targets)} do not match "
431
+ f"{list(reference.targets)}."
432
+ )
433
+
434
+ draws = tuple(core.predict(*args, **kwargs) for core in cores)
435
+ reference_rows = draws[0].rows
436
+ for model_index, draw in enumerate(draws[1:], start=1):
437
+ signature = [(row.row_index, row.status, dict(row.inputs)) for row in draw.rows]
438
+ expected = [(row.row_index, row.status, dict(row.inputs)) for row in reference_rows]
439
+ if signature != expected:
440
+ raise ValueError(
441
+ f"Ensemble model {model_index} produced different row validation or ordering. "
442
+ "All ensemble members must accept the same input rows."
443
+ )
444
+
445
+ valid_positions = [
446
+ index for index, row in enumerate(reference_rows) if row.status == "ok"
447
+ ]
448
+ stack = np.stack(
449
+ [
450
+ [draw.rows[index].prediction for index in valid_positions]
451
+ for draw in draws
452
+ ],
453
+ axis=0,
454
+ )
455
+
456
+ def aggregate(values, statistic):
457
+ valid_lookup = {
458
+ position: np.asarray(values[index])
459
+ for index, position in enumerate(valid_positions)
460
+ }
461
+ rows = []
462
+ for position, reference_row in enumerate(reference_rows):
463
+ rows.append(
464
+ PredictionRow(
465
+ row_index=reference_row.row_index,
466
+ inputs=dict(reference_row.inputs),
467
+ prediction=valid_lookup.get(position),
468
+ status=reference_row.status,
469
+ error=reference_row.error,
470
+ )
471
+ )
472
+ return PredictionResult(
473
+ tuple(rows),
474
+ reference.targets,
475
+ {"method": "ensemble", "models": len(draws), "statistic": statistic},
476
+ )
477
+
478
+ mean = aggregate(stack.mean(axis=0), "mean")
479
+ std = aggregate(stack.std(axis=0, ddof=0), "std")
480
+ return UncertaintyResult(mean, std, draws, "ensemble", None)
@@ -0,0 +1,166 @@
1
+ """Automatic saved-model Analyzer selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import yaml
6
+
7
+ from .ISAAnalyzer import (
8
+ ISAAnalyzer,
9
+ ISAAnalyzer_v2,
10
+ ISAAnalyzer_v1p3,
11
+ mol_with_atom_index,
12
+ showAtomHighlight,
13
+ )
14
+ from .ISAPNAnalyzer import (
15
+ ISAPNAnalyzer,
16
+ ISAPNAnalyzer_v2,
17
+ ISAPNAnalyzer_v1p3,
18
+ ISATPNAnalyzer,
19
+ ISATPNAnalyzer_v2,
20
+ )
21
+ from .ISAwSAnalyzer import ISAwSAnalyzer
22
+ from .MolAnalyzer import MolAnalyzer, MolAnalyzer_v2, MolAnalyzer_v1p3
23
+ from .core import (
24
+ InferenceCore,
25
+ ModelArtifacts,
26
+ predict_ensemble,
27
+ resolve_model_artifacts,
28
+ )
29
+ from .interpretation import ISAAnalysisResult, ISAAnalysisRow
30
+ from .results import PredictionResult, PredictionRow, UncertaintyResult
31
+
32
+
33
+ def _version_tuple(value) -> tuple[int, ...]:
34
+ try:
35
+ return tuple(int(part) for part in str(value or "1.0").split("."))
36
+ except ValueError as exc:
37
+ raise ValueError(
38
+ f"Saved model version {value!r} is not a dotted numeric version. "
39
+ "Correct the 'version' value in config.yaml."
40
+ ) from exc
41
+
42
+
43
+ def _is_isa_config(config: dict) -> bool:
44
+ contract_fields = (
45
+ "data_manager_module",
46
+ "data_manager_class",
47
+ "network_manager_module",
48
+ "network_manager_class",
49
+ "train_manager_module",
50
+ "train_manager_class",
51
+ )
52
+ return any("isa" in str(config.get(field, "")).casefold() for field in contract_fields)
53
+
54
+
55
+ def _is_legacy_solvent_isa(config: dict) -> bool:
56
+ molecule_columns = config.get("molecule_columns", ())
57
+ if isinstance(molecule_columns, str):
58
+ molecule_columns = (molecule_columns,)
59
+ if any(str(column).casefold() == "solvent" for column in molecule_columns):
60
+ return True
61
+
62
+ identity_fields = (
63
+ "network_id",
64
+ "network",
65
+ "data_manager_module",
66
+ "data_manager_class",
67
+ )
68
+ return any(
69
+ "withsolv" in str(config.get(field, "")).casefold()
70
+ or str(config.get(field, "")).casefold().endswith("ws")
71
+ for field in identity_fields
72
+ )
73
+
74
+
75
+ def _is_isapn_config(config: dict) -> bool:
76
+ """Recognize canonical ISATPN and legacy ISATPM saved identities."""
77
+
78
+ identity_fields = ("network_id", "name", "network")
79
+ identities = {
80
+ str(config.get(field, "")).casefold().replace("_", "")
81
+ for field in identity_fields
82
+ }
83
+ return any(
84
+ identity in {"isatpn", "isatpnmodel", "isatpm", "isatpmmodel"}
85
+ for identity in identities
86
+ )
87
+
88
+
89
+ def _select_analyzer_class(config: dict):
90
+ version = _version_tuple(config.get("version", "1.0"))
91
+ if _is_isa_config(config):
92
+ if _is_isapn_config(config):
93
+ return ISAPNAnalyzer_v2 if version >= (1, 3) else ISAPNAnalyzer
94
+ if version >= (1, 3):
95
+ return ISAAnalyzer_v2
96
+ if _is_legacy_solvent_isa(config):
97
+ return ISAwSAnalyzer
98
+ return ISAAnalyzer
99
+ return MolAnalyzer_v2 if version >= (1, 3) else MolAnalyzer
100
+
101
+
102
+ def select_analyzer_class(model_path, *, model_dir=None):
103
+ """Return the compatibility Analyzer class selected from saved config."""
104
+
105
+ artifacts = resolve_model_artifacts(model_path, model_dir=model_dir)
106
+ try:
107
+ with artifacts.config.open(encoding="utf-8") as file:
108
+ config = yaml.load(file, Loader=yaml.FullLoader)
109
+ except (OSError, yaml.YAMLError) as exc:
110
+ raise ValueError(
111
+ f"Could not read saved Analyzer config {str(artifacts.config)!r}. "
112
+ "Check that config.yaml is valid YAML."
113
+ ) from exc
114
+ if not isinstance(config, dict):
115
+ raise ValueError(
116
+ f"Saved Analyzer config {str(artifacts.config)!r} must contain a mapping, "
117
+ f"got {type(config).__name__}."
118
+ )
119
+ return _select_analyzer_class(config)
120
+
121
+
122
+ def create_analyzer(model_path, save_result=False, **kwargs):
123
+ """Construct the Analyzer implementation required by a saved model."""
124
+
125
+ analyzer_class = select_analyzer_class(
126
+ model_path,
127
+ model_dir=kwargs.get("model_dir"),
128
+ )
129
+ return analyzer_class(model_path, save_result=save_result, **kwargs)
130
+
131
+
132
+ class Analyzer:
133
+ """Callable facade that automatically selects a saved-model Analyzer.
134
+
135
+ Existing ``Analyzer.MolAnalyzer(...)``-style calls remain available as
136
+ class attributes for compatibility.
137
+ """
138
+
139
+ InferenceCore = InferenceCore
140
+ ISAAnalyzer = ISAAnalyzer
141
+ ISAAnalyzer_v2 = ISAAnalyzer_v2
142
+ ISAAnalyzer_v1p3 = ISAAnalyzer_v1p3
143
+ ISAPNAnalyzer = ISAPNAnalyzer
144
+ ISAPNAnalyzer_v2 = ISAPNAnalyzer_v2
145
+ ISAPNAnalyzer_v1p3 = ISAPNAnalyzer_v1p3
146
+ ISATPNAnalyzer = ISATPNAnalyzer
147
+ ISATPNAnalyzer_v2 = ISATPNAnalyzer_v2
148
+ ISAAnalysisResult = ISAAnalysisResult
149
+ ISAAnalysisRow = ISAAnalysisRow
150
+ ISAwSAnalyzer = ISAwSAnalyzer
151
+ ModelArtifacts = ModelArtifacts
152
+ MolAnalyzer = MolAnalyzer
153
+ MolAnalyzer_v2 = MolAnalyzer_v2
154
+ MolAnalyzer_v1p3 = MolAnalyzer_v1p3
155
+ PredictionResult = PredictionResult
156
+ PredictionRow = PredictionRow
157
+ UncertaintyResult = UncertaintyResult
158
+ create_analyzer = staticmethod(create_analyzer)
159
+ mol_with_atom_index = staticmethod(mol_with_atom_index)
160
+ predict_ensemble = staticmethod(predict_ensemble)
161
+ resolve_model_artifacts = staticmethod(resolve_model_artifacts)
162
+ select_analyzer_class = staticmethod(select_analyzer_class)
163
+ showAtomHighlight = staticmethod(showAtomHighlight)
164
+
165
+ def __new__(cls, model_path, save_result=False, **kwargs):
166
+ return create_analyzer(model_path, save_result=save_result, **kwargs)