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.
- D4CMPP2/_Data/AGENTS.md +24 -0
- D4CMPP2/_Data/Aqsoldb.csv +9291 -0
- D4CMPP2/_Data/BradleyMP.csv +3042 -0
- D4CMPP2/_Data/Lipophilicity.csv +1131 -0
- D4CMPP2/_Data/README.md +8 -0
- D4CMPP2/_Data/__init__.py +26 -0
- D4CMPP2/_Data/optical.csv +20237 -0
- D4CMPP2/_Data/test.csv +190 -0
- D4CMPP2/__init__.py +16 -0
- D4CMPP2/__main__.py +5 -0
- D4CMPP2/_main.py +500 -0
- D4CMPP2/cli.py +7 -0
- D4CMPP2/exceptions.py +53 -0
- D4CMPP2/grid_search.py +259 -0
- D4CMPP2/network_refer.yaml +160 -0
- D4CMPP2/networks/AFP_model.py +72 -0
- D4CMPP2/networks/AFPwithSolv_model.py +72 -0
- D4CMPP2/networks/DMPNN_model.py +90 -0
- D4CMPP2/networks/DMPNNwithSolv_model.py +89 -0
- D4CMPP2/networks/GAT_model.py +48 -0
- D4CMPP2/networks/GATwithSolv_model.py +63 -0
- D4CMPP2/networks/GCN_model.py +113 -0
- D4CMPP2/networks/GCNwithSolv_model.py +103 -0
- D4CMPP2/networks/GC_model.py +122 -0
- D4CMPP2/networks/ISATPM_model.py +14 -0
- D4CMPP2/networks/ISATPN_model.py +199 -0
- D4CMPP2/networks/ISAT_model.py +90 -0
- D4CMPP2/networks/MPNN_model.py +56 -0
- D4CMPP2/networks/MPNNwithSolv_model.py +72 -0
- D4CMPP2/networks/__init__.py +25 -0
- D4CMPP2/networks/base.py +250 -0
- D4CMPP2/networks/registry.py +187 -0
- D4CMPP2/networks/src/AFP.py +118 -0
- D4CMPP2/networks/src/BiDropout.py +29 -0
- D4CMPP2/networks/src/DMPNN.py +35 -0
- D4CMPP2/networks/src/GAT.py +69 -0
- D4CMPP2/networks/src/GC.py +85 -0
- D4CMPP2/networks/src/GCN.py +71 -0
- D4CMPP2/networks/src/ISAT.py +153 -0
- D4CMPP2/networks/src/Linear.py +49 -0
- D4CMPP2/networks/src/MPNN.py +56 -0
- D4CMPP2/networks/src/SolventLayer.py +62 -0
- D4CMPP2/networks/src/__init__.py +0 -0
- D4CMPP2/networks/src/distGCN.py +21 -0
- D4CMPP2/networks/src/pyg_hetero.py +24 -0
- D4CMPP2/optimize.py +472 -0
- D4CMPP2/src/Analyzer/ISAAnalyzer.py +458 -0
- D4CMPP2/src/Analyzer/ISAPNAnalyzer.py +366 -0
- D4CMPP2/src/Analyzer/ISAwSAnalyzer.py +117 -0
- D4CMPP2/src/Analyzer/MolAnalyzer.py +319 -0
- D4CMPP2/src/Analyzer/__init__.py +54 -0
- D4CMPP2/src/Analyzer/core.py +480 -0
- D4CMPP2/src/Analyzer/factory.py +166 -0
- D4CMPP2/src/Analyzer/interpretation.py +232 -0
- D4CMPP2/src/Analyzer/results.py +101 -0
- D4CMPP2/src/DataManager/Dataset/GraphDataset.py +314 -0
- D4CMPP2/src/DataManager/Dataset/ISAGraphDataset.py +384 -0
- D4CMPP2/src/DataManager/Dataset/__init__.py +0 -0
- D4CMPP2/src/DataManager/GraphGenerator/ISAGraphGenerator.py +223 -0
- D4CMPP2/src/DataManager/GraphGenerator/MolGraphGenerator.py +73 -0
- D4CMPP2/src/DataManager/GraphGenerator/__init__.py +14 -0
- D4CMPP2/src/DataManager/ISADataManager.py +67 -0
- D4CMPP2/src/DataManager/MolDataManager.py +735 -0
- D4CMPP2/src/DataManager/__init__.py +14 -0
- D4CMPP2/src/DataManager/contracts.py +179 -0
- D4CMPP2/src/NetworkManager/ISANetworkManager.py +12 -0
- D4CMPP2/src/NetworkManager/NetworkManager.py +520 -0
- D4CMPP2/src/NetworkManager/__init__.py +14 -0
- D4CMPP2/src/PostProcessor.py +160 -0
- D4CMPP2/src/TrainManager/ISATrainManager.py +26 -0
- D4CMPP2/src/TrainManager/TrainManager.py +254 -0
- D4CMPP2/src/TrainManager/__init__.py +14 -0
- D4CMPP2/src/TrainManager/callbacks.py +119 -0
- D4CMPP2/src/__init__.py +0 -0
- D4CMPP2/src/utils/PATH.py +246 -0
- D4CMPP2/src/utils/__init__.py +0 -0
- D4CMPP2/src/utils/argparser.py +56 -0
- D4CMPP2/src/utils/checkpointing.py +90 -0
- D4CMPP2/src/utils/config_resolution.py +123 -0
- D4CMPP2/src/utils/config_validation.py +370 -0
- D4CMPP2/src/utils/csv_validation.py +105 -0
- D4CMPP2/src/utils/data_quality.py +181 -0
- D4CMPP2/src/utils/featureizer.py +202 -0
- D4CMPP2/src/utils/functional_group.csv +169 -0
- D4CMPP2/src/utils/graph_cache.py +213 -0
- D4CMPP2/src/utils/leaderboard.py +212 -0
- D4CMPP2/src/utils/metrics.py +31 -0
- D4CMPP2/src/utils/module_loader.py +147 -0
- D4CMPP2/src/utils/output.py +80 -0
- D4CMPP2/src/utils/reproducibility.py +70 -0
- D4CMPP2/src/utils/run_manifest.py +175 -0
- D4CMPP2/src/utils/scaler.py +40 -0
- D4CMPP2/src/utils/sculptor.py +713 -0
- D4CMPP2/src/utils/splitting.py +250 -0
- D4CMPP2/src/utils/supportfile_saver.py +94 -0
- D4CMPP2/src/utils/tools.py +156 -0
- D4CMPP2/src/utils/transfer_learning.py +111 -0
- d4cmpp2-0.4.0.dist-info/METADATA +420 -0
- d4cmpp2-0.4.0.dist-info/RECORD +103 -0
- d4cmpp2-0.4.0.dist-info/WHEEL +5 -0
- d4cmpp2-0.4.0.dist-info/entry_points.txt +2 -0
- d4cmpp2-0.4.0.dist-info/licenses/LICENSE +21 -0
- 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)
|