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,735 @@
1
+ import os
2
+ import pandas as pd
3
+ import numpy as np
4
+ from rdkit import Chem
5
+
6
+ import torch
7
+ from torch.utils.data import Dataset, DataLoader
8
+ from sklearn.model_selection import train_test_split
9
+
10
+ from .GraphGenerator.MolGraphGenerator import MolGraphGenerator
11
+ from .Dataset.GraphDataset import GraphDataset, GraphDataset_withSolv
12
+
13
+ from D4CMPP2.src.utils.scaler import Scaler
14
+ from D4CMPP2.src.utils.csv_validation import (
15
+ validate_aligned_lengths,
16
+ validate_automatic_split_size,
17
+ validate_csv_schema,
18
+ validate_nonempty_targets,
19
+ validate_numeric_columns,
20
+ validate_set_labels,
21
+ )
22
+ from D4CMPP2.src.utils.data_quality import write_data_quality_report
23
+ from D4CMPP2.src.utils.graph_cache import (
24
+ atomic_save_graph_cache,
25
+ build_graph_recipe,
26
+ cache_path,
27
+ legacy_cache_paths,
28
+ validate_graph,
29
+ validate_payload,
30
+ )
31
+ from D4CMPP2.src.utils.reproducibility import seed_data_loader_worker
32
+ from D4CMPP2.src.utils.output import get_output
33
+ from D4CMPP2.src.utils.splitting import (
34
+ scaffold_split_indices,
35
+ write_split_report,
36
+ )
37
+
38
+ import hashlib
39
+ import warnings
40
+
41
+
42
+ class MolDataManager:
43
+ """The class for the management of the molecular data, preparing the dataset, and dataloader
44
+
45
+ This class is intended to provide the dataset and dataloader for the training, validation, and test.
46
+ This class works mainly in two steps.
47
+
48
+ 1. call the init_data() to load the data from the csv file and prepare the graph.
49
+ 1-1. the load_data() will load the data from the csv file, saving the smiles and target values.
50
+ 1-2. the prepare_graph() will prepare the graph from the saved smiles, trying to load the graph first, then generate the graph if it does not exist.
51
+
52
+ 2. call the get_Dataloaders() to provide the dataloader for the training, validation, and test.
53
+ 2-1. the prepare_dataset() will drop the invalid data and initialize the dataset with generated graphs and target values.
54
+ 2-2. the split_data() will split the dataset into train, val, and test. if the set is given, the dataset will be split according to the set.
55
+ 2-3. the init_Dataloader() will initialize the dataloader based on the set and partial_train.
56
+ """
57
+ feature_dimension_keys = ("node_dim", "edge_dim")
58
+
59
+ def __init__(self, config):
60
+ self.config = config
61
+ self.output = get_output(config)
62
+ self.data = config["data"]
63
+ self.target = config["target"]
64
+ self.scaler = Scaler(config.get("scaler","identity"))
65
+ self.explicit_h_columns = config.get("explicit_h_columns",[])
66
+ self.molecule_columns = config.get("molecule_columns", ['compound'])
67
+ self.numeric_input_columns = config.get("numeric_input_columns", [])
68
+ self.molecule_graphs={col:[] for col in self.molecule_columns}
69
+ self._molecule_smiles = {}
70
+ self.molecule_smiles = {}
71
+ self.valid_smiles = {}
72
+ self.original_row_indices = np.array([], dtype=int)
73
+ self.graph_errors = []
74
+ self.random_seed = config.get("split_random_seed",42)
75
+ training_seed = config.get("random_seed")
76
+ self._partial_train_rng = (
77
+ np.random.default_rng(training_seed)
78
+ if training_seed is not None else None
79
+ )
80
+ self.set = None
81
+ self.import_others()
82
+ config.update({"node_dim":self.gg.node_dim, "edge_dim":self.gg.edge_dim})
83
+
84
+ def _output(self):
85
+ """Return the configured adapter, including for legacy test fixtures."""
86
+
87
+ return getattr(self, "output", get_output(getattr(self, "config", None)))
88
+
89
+ # Import the graph generator and dataset
90
+ def import_others(self):
91
+ self.graph_type = 'mol'
92
+ self.gg =MolGraphGenerator()
93
+ self.dataset =GraphDataset
94
+ self.unwrapper = self.dataset.unwrapper
95
+
96
+ # Initialize the temporary data for the prediction
97
+ def init_temp_data(self, *args, **kwargs):
98
+ if len(args)+len(kwargs) != len(self.molecule_columns) + len(self.numeric_input_columns):
99
+ raise ValueError(f"Please provide the smiles for {self.molecule_columns} and numeric input for {self.numeric_input_columns}.")
100
+ if len(args) > 0:
101
+ self._output().info(
102
+ "[Data] Positional inference inputs follow this order: "
103
+ f"molecule columns={self.molecule_columns!r}, "
104
+ f"numeric columns={self.numeric_input_columns!r}."
105
+ )
106
+ if len(args) == len(self.molecule_columns) + len(self.numeric_input_columns):
107
+ kwargs = {self.molecule_columns[i]: args[i] for i in range(len(self.molecule_columns))}
108
+ kwargs.update({self.numeric_input_columns[i]: args[i + len(self.molecule_columns)] for i in range(len(self.numeric_input_columns))})
109
+ elif len(args) == len(self.molecule_columns):
110
+ kwargs.update({self.molecule_columns[i]: args[i] for i in range(len(self.molecule_columns))})
111
+ elif len(args) == len(self.numeric_input_columns):
112
+ kwargs.update({self.numeric_input_columns[i]: args[i] for i in range(len(self.numeric_input_columns))})
113
+ else:
114
+ raise ValueError(f"Please provide the smiles for {self.molecule_columns} and numeric input for {self.numeric_input_columns}.")
115
+ if len(kwargs) >0:
116
+ for k in kwargs.keys():
117
+ if k not in self.molecule_columns and k not in self.numeric_input_columns:
118
+ raise ValueError(f"Unknown key {k}. Please provide the smiles for {self.molecule_columns} or numeric input for {self.numeric_input_columns}.")
119
+ for k in self.molecule_columns:
120
+ if k not in kwargs:
121
+ raise ValueError(f"Please provide the smiles for {k}.")
122
+ for k in self.numeric_input_columns:
123
+ if k not in kwargs:
124
+ raise ValueError(f"Please provide the numeric input for {k}.")
125
+ for k in kwargs.keys():
126
+ if type(kwargs[k]) is not list :
127
+ kwargs[k] = [kwargs[k]]
128
+ inputs = {col: kwargs[col] for col in self.molecule_columns if col in kwargs}
129
+
130
+ self._molecule_smiles = {col: np.array(inputs[col]) for col in self.molecule_columns}
131
+ self.molecule_smiles = {col: arr.copy() for col, arr in self._molecule_smiles.items()}
132
+ self.valid_smiles = {col: np.array(inputs[col]) for col in self.molecule_columns}
133
+ self.numeric_inputs = {col: np.array(kwargs[col]) for col in self.numeric_input_columns if col in kwargs}
134
+ self.original_row_indices = np.arange(len(self._molecule_smiles[self.molecule_columns[0]]))
135
+
136
+ self.molecule_graphs={col:[] for col in self.molecule_columns}
137
+ self.target_value = np.zeros((len(self._molecule_smiles[self.molecule_columns[0]]), self.config["target_dim"]))
138
+ self.gg.verbose = False
139
+ for col in self.molecule_columns:
140
+ self.generate_graph(col)
141
+
142
+ # Keep all arrays aligned by applying one centralized mask after graph generation.
143
+ self.drop_none_graph()
144
+
145
+ result = self.valid_smiles.copy()
146
+ result.update(self.numeric_inputs)
147
+ return result
148
+
149
+ # (main function) Initialize the data from the csv file based on the configuration
150
+ def init_data(self):
151
+ self.graph_errors = []
152
+ self.load_data()
153
+ self.prepare_graph()
154
+ self.write_graph_error_report()
155
+ self.write_data_quality_report()
156
+
157
+ def load_csv(self):
158
+ path = os.path.join(self.config['DATA_PATH'])
159
+ encodings = ('utf-8', 'cp949', 'euc-kr')
160
+ decode_errors = []
161
+ for encoding in encodings:
162
+ try:
163
+ self.df = pd.read_csv(path, encoding=encoding)
164
+ return
165
+ except UnicodeDecodeError as exc:
166
+ decode_errors.append(f"{encoding}: {exc}")
167
+ except pd.errors.EmptyDataError as exc:
168
+ raise ValueError(
169
+ f"CSV file {path!r} is empty or has no columns. "
170
+ "Add a header row and data rows before training."
171
+ ) from exc
172
+ except pd.errors.ParserError as exc:
173
+ raise ValueError(
174
+ f"Could not parse CSV file {path!r} using encoding {encoding!r}: {exc}. "
175
+ "Check delimiters, quoting, and malformed rows."
176
+ ) from exc
177
+ except OSError as exc:
178
+ raise OSError(
179
+ f"Could not read CSV file {path!r} using encoding {encoding!r}: {exc}. "
180
+ "Check that the path exists and is readable."
181
+ ) from exc
182
+ raise UnicodeError(
183
+ f"Could not decode CSV file {path!r}. Tried encodings {list(encodings)}. "
184
+ f"Decode errors: {decode_errors}. Save the file as UTF-8 or specify a supported Korean encoding."
185
+ )
186
+
187
+ # Load the data from the csv file, saving the smiles and target values
188
+ def load_data(self):
189
+ self.load_csv()
190
+ self.write_data_quality_report()
191
+ path = self.config['DATA_PATH']
192
+ validate_csv_schema(
193
+ path,
194
+ self.df.columns,
195
+ len(self.df),
196
+ self.molecule_columns,
197
+ self.target,
198
+ self.numeric_input_columns,
199
+ )
200
+
201
+ converted_targets = self.df[self.target].apply(pd.to_numeric, errors='coerce')
202
+ invalid_targets = {
203
+ col: self.df.index[(converted_targets[col].isna()) & (self.df[col].notna())].tolist()
204
+ for col in self.target
205
+ }
206
+ validate_numeric_columns(path, invalid_targets, "Target")
207
+ validate_nonempty_targets(
208
+ path,
209
+ {col: bool(converted_targets[col].isna().all()) for col in self.target},
210
+ )
211
+
212
+ if self.numeric_input_columns:
213
+ converted_numeric = self.df[self.numeric_input_columns].apply(pd.to_numeric, errors='coerce')
214
+ invalid_numeric = {
215
+ col: self.df.index[(converted_numeric[col].isna()) & (self.df[col].notna())].tolist()
216
+ for col in self.numeric_input_columns
217
+ }
218
+ validate_numeric_columns(path, invalid_numeric, "Numeric input")
219
+
220
+ if "set" in self.df.columns:
221
+ validate_set_labels(path, self.df["set"].tolist())
222
+ else:
223
+ validate_automatic_split_size(path, len(self.df))
224
+
225
+ for col in self.molecule_columns:
226
+ self._molecule_smiles[col] = np.array(list(self.df[col]))
227
+ self.molecule_smiles[col] = self._molecule_smiles[col].copy()
228
+ self.valid_smiles[col] = self._molecule_smiles[col].copy()
229
+ self.target_value = torch.tensor(converted_targets.values, dtype=torch.float32)
230
+ if self.target_value.dim() == 1:
231
+ self.target_value = self.target_value.unsqueeze(1)
232
+ self.numeric_inputs = {
233
+ col: np.asarray(converted_numeric[col].values, dtype=np.float32)
234
+ for col in self.numeric_input_columns
235
+ } if self.numeric_input_columns else {}
236
+ self.config.update({"target_dim":self.target_value.shape[1]})
237
+ self.set = self.df.get("set",None)
238
+ self.original_row_indices = np.asarray(self.df.index)
239
+ lengths = {f"molecule:{col}": len(values) for col, values in self._molecule_smiles.items()}
240
+ lengths["target"] = len(self.target_value)
241
+ lengths.update({f"numeric:{col}": len(self.df[col]) for col in self.numeric_input_columns})
242
+ if self.set is not None:
243
+ lengths["set"] = len(self.set)
244
+ validate_aligned_lengths(path, lengths)
245
+
246
+ def write_data_quality_report(self):
247
+ if not self.config.get("data_quality_report", True):
248
+ return None
249
+ if not self.config.get("MODEL_PATH"):
250
+ return None
251
+ report_path, issues_path = write_data_quality_report(
252
+ self.df,
253
+ self.config,
254
+ getattr(self, "graph_errors", None),
255
+ )
256
+ self._output().info(
257
+ f"[Data] Quality report written to {str(report_path)!r}; "
258
+ f"row-level issues: {str(issues_path)!r}."
259
+ )
260
+ return str(report_path), str(issues_path)
261
+
262
+ # Prepare the graph from the smiles, trying to load the graph first, then generate the graph if it does not exist
263
+ def prepare_graph(self):
264
+ for col in self.molecule_columns:
265
+ recipe = build_graph_recipe(self, col)
266
+ path = cache_path(self, col, recipe)
267
+ policy = self.config.get("graph_cache_policy", "v2")
268
+ if policy not in ("v2", "legacy", "regenerate"):
269
+ raise ValueError(
270
+ f"graph_cache_policy must be 'v2', 'legacy', or 'regenerate', got {policy!r}."
271
+ )
272
+ if path.is_file():
273
+ try:
274
+ self.load_graphs(col)
275
+ continue
276
+ except (OSError, RuntimeError, ValueError, TypeError) as exc:
277
+ if policy != "regenerate":
278
+ raise ValueError(
279
+ f"Graph cache {str(path)!r} is corrupt or incompatible: {exc} "
280
+ "Remove the file and rerun, or set graph_cache_policy='regenerate' "
281
+ "to rebuild it from the source CSV."
282
+ ) from exc
283
+ warnings.warn(
284
+ f"Regenerating corrupt or incompatible graph cache {str(path)!r}: {exc}",
285
+ RuntimeWarning,
286
+ stacklevel=2,
287
+ )
288
+ legacy_v1, legacy_dgl = legacy_cache_paths(self, col)
289
+ if policy == "legacy" and legacy_v1.is_file():
290
+ warnings.warn(
291
+ f"Loading legacy v1 graph cache {str(legacy_v1)!r}. Its feature recipe and "
292
+ "ISA rule identity cannot be verified; use the default v2 policy when possible.",
293
+ RuntimeWarning,
294
+ stacklevel=2,
295
+ )
296
+ self._load_legacy_graphs(col, legacy_v1)
297
+ continue
298
+ legacy_found = [str(candidate) for candidate in (legacy_v1, legacy_dgl) if candidate.is_file()]
299
+ if legacy_found:
300
+ warnings.warn(
301
+ f"Legacy graph cache files were preserved but will not be loaded: {legacy_found}. "
302
+ f"Generating fingerprinted v2 cache {str(path)!r} from the source CSV.",
303
+ RuntimeWarning,
304
+ stacklevel=2,
305
+ )
306
+ self.generate_graph(col)
307
+ self.save_graphs(col)
308
+
309
+ # Generate the graph from the smiles. the data failed to generate the graph will be saved as an empty graph
310
+ def generate_graph(self,col,graphs=None):
311
+ if graphs is None:
312
+ # Always rebuild this column from scratch to avoid accidental append accumulation.
313
+ self.molecule_graphs[col] = []
314
+ if graphs is not None:
315
+ for smi,g in zip(self._molecule_smiles[col],graphs):
316
+ self.molecule_graphs[col].append(g)
317
+ self.valid_smiles[col].append(smi)
318
+ return
319
+ if len(self._molecule_smiles[col]) < 10:
320
+ iterator = self._molecule_smiles[col]
321
+ else:
322
+ iterator = self._output().progress(
323
+ self._molecule_smiles[col],
324
+ desc=f"Generating {col} graphs",
325
+ )
326
+ for position, smi in enumerate(iterator):
327
+ graphgenerator = self.gg
328
+ if col == 'solvent' and hasattr(self,'gg_solv'):
329
+ graphgenerator = self.__getattribute__('gg_solv')
330
+ else:
331
+ graphgenerator = self.gg
332
+ try:
333
+ g=graphgenerator.get_graph(smi, explicit_h = col in self.explicit_h_columns, **self.config)
334
+ except Exception as e:
335
+ g = graphgenerator.get_empty_graph() # save the empty graph instead of the failed graph
336
+ self._record_graph_error(position, smi, col, str(e))
337
+ self.molecule_graphs[col].append(g)
338
+
339
+ def _record_graph_error(self, position, smiles, molecule_column, reason):
340
+ if not hasattr(self, 'graph_errors'):
341
+ self.graph_errors = []
342
+ if hasattr(self, 'original_row_indices') and len(self.original_row_indices) > position:
343
+ row_index = self.original_row_indices[position]
344
+ if hasattr(row_index, 'item'):
345
+ row_index = row_index.item()
346
+ else:
347
+ row_index = position
348
+ self.graph_errors.append({
349
+ 'smiles': smiles,
350
+ 'type': molecule_column,
351
+ 'reason': reason,
352
+ 'row_index': row_index,
353
+ })
354
+
355
+ def write_graph_error_report(self):
356
+ if not self.graph_errors:
357
+ return None
358
+ report_path = os.path.join(self.config['MODEL_PATH'], 'graph_error.csv')
359
+ report = pd.DataFrame(
360
+ self.graph_errors,
361
+ columns=['smiles', 'type', 'reason', 'row_index'],
362
+ )
363
+ report.to_csv(report_path, index=False)
364
+ counts = report.groupby('type', sort=False).size().to_dict()
365
+ unique_rows = report['row_index'].nunique()
366
+ self._output().warning(
367
+ f"[Data] Graph generation excluded {unique_rows} unique CSV rows "
368
+ f"({len(report)} failures by molecule column: {counts}). "
369
+ f"Details: {report_path!r}.",
370
+ RuntimeWarning,
371
+ stacklevel=2,
372
+ )
373
+ return report_path
374
+
375
+ def get_graphs_path(self,col):
376
+ recipe = build_graph_recipe(self, col)
377
+ return str(cache_path(self, col, recipe))
378
+
379
+ # Save the generated graphs
380
+ def save_graphs(self,col):
381
+ recipe = build_graph_recipe(self, col)
382
+ for index, graph in enumerate(self.molecule_graphs[col]):
383
+ validate_graph(graph, recipe, index)
384
+ atomic_save_graph_cache(
385
+ {
386
+ "graph_backend": "pyg",
387
+ "graph_schema_version": 2,
388
+ "recipe": recipe,
389
+ "smiles": list(self._molecule_smiles[col]),
390
+ "graphs": self.molecule_graphs[col],
391
+ "graph_errors": [
392
+ error for error in self.graph_errors if error.get("type") == col
393
+ ],
394
+ },
395
+ cache_path(self, col, recipe),
396
+ )
397
+
398
+ # Load the saved graphs
399
+ def load_graphs(self,col):
400
+ recipe = build_graph_recipe(self, col)
401
+ path = cache_path(self, col, recipe)
402
+ payload = torch.load(path, map_location="cpu", weights_only=False)
403
+ graphs, cached_errors = validate_payload(
404
+ payload,
405
+ recipe,
406
+ self._molecule_smiles[col],
407
+ path,
408
+ )
409
+ self.molecule_graphs[col] = graphs
410
+ self.graph_errors.extend(cached_errors)
411
+ for position, graph in enumerate(self.molecule_graphs[col]):
412
+ if graph.num_nodes == 0 and not any(
413
+ error.get("type") == col and error.get("row_index") == self.original_row_indices[position]
414
+ for error in cached_errors
415
+ ):
416
+ self._record_graph_error(
417
+ position,
418
+ self._molecule_smiles[col][position],
419
+ col,
420
+ "Empty graph loaded from cache; the original generation error is unavailable.",
421
+ )
422
+
423
+ def _load_legacy_graphs(self, col, path):
424
+ payload = torch.load(path, map_location="cpu", weights_only=False)
425
+ if (
426
+ not isinstance(payload, dict)
427
+ or payload.get("graph_backend") != "pyg"
428
+ or payload.get("graph_schema_version") != 1
429
+ ):
430
+ raise ValueError(
431
+ f"Legacy graph cache {str(path)!r} is not a PyG schema-v1 payload. "
432
+ "Use the default v2 policy to regenerate it from the source CSV."
433
+ )
434
+ graphs = payload.get("graphs")
435
+ smiles = payload.get("smiles")
436
+ if not isinstance(graphs, list) or len(graphs) != len(self._molecule_smiles[col]):
437
+ raise ValueError(
438
+ f"Legacy graph cache {str(path)!r} has the wrong graph count. "
439
+ "Regenerate a v2 cache from the source CSV."
440
+ )
441
+ if [str(value) for value in smiles or []] != [
442
+ str(value) for value in self._molecule_smiles[col]
443
+ ]:
444
+ raise ValueError(
445
+ f"Legacy graph cache {str(path)!r} ordered SMILES do not match the source CSV."
446
+ )
447
+ recipe = build_graph_recipe(self, col)
448
+ for index, graph in enumerate(graphs):
449
+ validate_graph(graph, recipe, index)
450
+ self.molecule_graphs[col] = graphs
451
+
452
+ # Drop the data with the empty graph
453
+ def drop_none_graph(self):
454
+ _masks = []
455
+ for col in self.molecule_columns:
456
+ _masks.append(np.array([g.num_nodes > 0 for g in self.molecule_graphs[col]]))
457
+ _masks = np.stack(_masks)
458
+ mask = np.all(_masks,axis=0)
459
+ self.masking(mask)
460
+
461
+ # Drop the data with the nan target
462
+ def drop_nan_value(self):
463
+ mask = np.array(~torch.isnan(torch.as_tensor(self.target_value)).all(dim=1))
464
+ self.masking(mask)
465
+
466
+ # Mask the data
467
+ def masking(self,mask):
468
+ mask = np.asarray(mask, dtype=bool)
469
+
470
+ base_len = len(mask)
471
+ for col in self.molecule_columns:
472
+ if len(self._molecule_smiles[col]) != base_len:
473
+ raise ValueError(
474
+ f"Length mismatch before masking for {col}: "
475
+ f"_molecule_smiles={len(self._molecule_smiles[col])}, mask={base_len}."
476
+ )
477
+ if len(self.molecule_graphs[col]) != base_len:
478
+ raise ValueError(
479
+ f"Length mismatch before masking for {col}: "
480
+ f"molecule_graphs={len(self.molecule_graphs[col])}, mask={base_len}."
481
+ )
482
+
483
+ self.target_value = self.target_value[mask]
484
+ if hasattr(self, 'original_row_indices'):
485
+ if len(self.original_row_indices) != base_len:
486
+ raise ValueError(
487
+ f"Length mismatch before masking for original_row_indices: "
488
+ f"rows={len(self.original_row_indices)}, mask={base_len}."
489
+ )
490
+ self.original_row_indices = self.original_row_indices[mask]
491
+ if self.set is not None:
492
+ self.set = self.set[mask]
493
+
494
+ for col in self.molecule_columns:
495
+ self._molecule_smiles[col] = self._molecule_smiles[col][mask]
496
+ self.molecule_smiles[col] = self._molecule_smiles[col].copy()
497
+ if col in self.valid_smiles:
498
+ self.valid_smiles[col] = self.valid_smiles[col][mask]
499
+ self.molecule_graphs[col] = [g for i,g in enumerate(self.molecule_graphs[col]) if mask[i]]
500
+
501
+ if hasattr(self, 'numeric_inputs') and self.numeric_inputs is not None:
502
+ for col in self.numeric_inputs:
503
+ self.numeric_inputs[col] = self.numeric_inputs[col][mask]
504
+ self._mask_additional_features(mask)
505
+
506
+ def _mask_additional_features(self, mask):
507
+ """Apply the central row mask to model-specific per-row features."""
508
+ return None
509
+
510
+ # drop the invalid data and initialize the dataset
511
+ def prepare_dataset(self,temp=False):
512
+ self.drop_none_graph()
513
+ if not temp:
514
+ self.drop_nan_value()
515
+ self.whole_dataset = self.init_dataset()
516
+
517
+ def init_dataset(self):
518
+ if len(self.numeric_input_columns) > 0:
519
+ if hasattr(self, 'numeric_inputs') and self.numeric_inputs is not None:
520
+ if len(self.numeric_input_columns) != len(self.numeric_inputs):
521
+ raise ValueError(f"Please provide the numeric input for {self.numeric_input_columns}.")
522
+ numeric_inputs = self.numeric_inputs
523
+ else:
524
+ raise ValueError(
525
+ f"Numeric inputs {self.numeric_input_columns!r} were not materialized before dataset creation. "
526
+ "Reload the CSV and verify the configured numeric columns."
527
+ )
528
+ else:
529
+ numeric_inputs = None
530
+ return self.dataset(graphs = self.molecule_graphs,
531
+ smiles = self._molecule_smiles,
532
+ target = self.target_value,
533
+ numeric_inputs = numeric_inputs,
534
+ row_indices = self.original_row_indices,)
535
+
536
+ # Split the dataset into train, val, and test. if the set is given, the dataset will be split according to the set
537
+ def split_data(self):
538
+ import copy
539
+
540
+ def _subset_dataset(indices):
541
+ if hasattr(self.whole_dataset, 'get_subDataset'):
542
+ return self.whole_dataset.get_subDataset(indices)
543
+ dataset = copy.deepcopy(self.whole_dataset)
544
+ if hasattr(dataset, 'subDataset'):
545
+ dataset.subDataset(indices)
546
+ return dataset
547
+ raise AttributeError("Dataset does not support subsetting methods")
548
+
549
+ requested_strategy = self.config.get("split_strategy", "auto")
550
+ strategy = (
551
+ "predefined"
552
+ if requested_strategy == "auto" and self.set is not None
553
+ else "random"
554
+ if requested_strategy == "auto"
555
+ else requested_strategy
556
+ )
557
+ if strategy == "random":
558
+ validate_automatic_split_size(self.config['DATA_PATH'], len(self.whole_dataset))
559
+
560
+ indices = np.arange(len(self.whole_dataset))
561
+ train_idx, test_idx = train_test_split(indices, test_size=0.1, random_state=self.random_seed)
562
+ train_idx, val_idx = train_test_split(train_idx, test_size=1/9, random_state=self.random_seed)
563
+ elif strategy == "predefined":
564
+ if self.set is None:
565
+ raise ValueError(
566
+ "split_strategy='predefined' requires a CSV 'set' column with "
567
+ "train/val/test labels."
568
+ )
569
+ validate_set_labels(self.config['DATA_PATH'], list(self.set))
570
+ train_idx = [i for i,s in enumerate(self.set) if s=="train"]
571
+ val_idx = [i for i,s in enumerate(self.set) if s=="val"]
572
+ test_idx = [i for i,s in enumerate(self.set) if s=="test"]
573
+ elif strategy == "scaffold":
574
+ scaffold_column = self.config.get(
575
+ "scaffold_column", self.molecule_columns[0]
576
+ )
577
+ if scaffold_column not in self.molecule_columns:
578
+ raise ValueError(
579
+ f"scaffold_column {scaffold_column!r} is not one of configured "
580
+ f"molecule_columns {self.molecule_columns!r}."
581
+ )
582
+ (train_idx, val_idx, test_idx), _ = scaffold_split_indices(
583
+ self._molecule_smiles[scaffold_column],
584
+ seed=self.random_seed,
585
+ include_chirality=self.config.get(
586
+ "scaffold_include_chirality", False
587
+ ),
588
+ )
589
+ else:
590
+ raise ValueError(
591
+ f"Unknown split_strategy {requested_strategy!r}. Use 'auto', "
592
+ "'random', 'predefined', or 'scaffold'."
593
+ )
594
+
595
+ if (
596
+ self.config.get("MODEL_PATH")
597
+ and hasattr(self, "molecule_columns")
598
+ and hasattr(self, "_molecule_smiles")
599
+ and hasattr(self, "original_row_indices")
600
+ ):
601
+ scaffold_column = self.config.get(
602
+ "scaffold_column", self.molecule_columns[0]
603
+ )
604
+ if scaffold_column not in self.molecule_columns:
605
+ raise ValueError(
606
+ f"scaffold_column {scaffold_column!r} is not one of configured "
607
+ f"molecule_columns {self.molecule_columns!r}."
608
+ )
609
+ write_split_report(
610
+ self.config,
611
+ strategy,
612
+ (train_idx, val_idx, test_idx),
613
+ self.original_row_indices,
614
+ self.target_value,
615
+ self.target,
616
+ self._molecule_smiles[scaffold_column],
617
+ scaffold_column,
618
+ include_chirality=self.config.get(
619
+ "scaffold_include_chirality", False
620
+ ),
621
+ )
622
+
623
+ self._fit_and_transform_targets(train_idx)
624
+ self.train_dataset = _subset_dataset(train_idx)
625
+ self.val_dataset = _subset_dataset(val_idx)
626
+ self.test_dataset = self.val_dataset if len(test_idx) == 0 else _subset_dataset(test_idx)
627
+ self._output().info(
628
+ "[Data] Dataset split sizes: "
629
+ f"train={len(self.train_dataset)}, "
630
+ f"validation={len(self.val_dataset)}, "
631
+ f"test={len(self.test_dataset)}."
632
+ )
633
+
634
+ def _fit_and_transform_targets(self, train_idx):
635
+ scope = self.config.get('target_scaler_fit_scope', 'train')
636
+ fit_values = self.target_value if scope == 'all' else self.target_value[train_idx]
637
+ if scope == 'all':
638
+ warnings.warn(
639
+ "target_scaler_fit_scope='all' fits target scaling on validation/test rows and may leak "
640
+ "their statistics. Use 'train' for leakage-free training.",
641
+ UserWarning,
642
+ stacklevel=2,
643
+ )
644
+ self.scaler.fit(fit_values)
645
+ transformed = self.scaler.transform(self.target_value)
646
+ self.target_value = torch.as_tensor(transformed, dtype=torch.float32)
647
+ self.whole_dataset.target = self.target_value
648
+
649
+ # Initialize the dataloader
650
+ def init_Dataloader(self,dataset=None,partial_train=1.0, shuffle=False):
651
+ import copy
652
+
653
+ def _subset_for_loader(src_dataset, indices):
654
+ if hasattr(src_dataset, 'get_subDataset'):
655
+ return src_dataset.get_subDataset(indices)
656
+ subset = copy.deepcopy(src_dataset)
657
+ if hasattr(subset, 'subDataset'):
658
+ subset.subDataset(indices)
659
+ return subset
660
+ raise AttributeError("Dataset does not support subsetting methods")
661
+
662
+ if dataset is None:
663
+ dataset = self.whole_dataset
664
+ if partial_train and partial_train<1.0 and partial_train>0:
665
+ if self._partial_train_rng is None:
666
+ subset_idx = np.random.choice(
667
+ len(self.train_dataset),
668
+ int(len(self.train_dataset) * partial_train),
669
+ replace=False,
670
+ )
671
+ else:
672
+ subset_idx = self._partial_train_rng.choice(
673
+ len(self.train_dataset),
674
+ int(len(self.train_dataset) * partial_train),
675
+ replace=False,
676
+ )
677
+ dataset = _subset_for_loader(dataset, subset_idx)
678
+ self._output().info(
679
+ f"[Data] Partial training subset contains {len(dataset)} rows."
680
+ )
681
+
682
+ worker_init_fn = (
683
+ seed_data_loader_worker
684
+ if self.config.get("random_seed") is not None else None
685
+ )
686
+ return DataLoader(dataset,
687
+ batch_size=self.config.get('batch_size',32),
688
+ shuffle=shuffle,
689
+ collate_fn=self.dataset.collate,
690
+ pin_memory=self.config.get('pin_memory',True),
691
+ num_workers=self.config.get('num_workers',0),
692
+ worker_init_fn=worker_init_fn)
693
+
694
+ # (main function) Provide the dataloader for the training, validation, and test
695
+ def get_Dataloaders(self, temp=False):
696
+ self.prepare_dataset(temp=temp)
697
+ if temp:
698
+ loader = self.init_Dataloader(shuffle=False)
699
+ return loader
700
+ else:
701
+ self.split_data()
702
+ self.train_loader = self.init_Dataloader(self.train_dataset,partial_train=self.config.get('partial_train',1.0),shuffle=self.config.get('shuffle',True))
703
+ self.val_loader = self.init_Dataloader(self.val_dataset)
704
+ self.test_loader = self.init_Dataloader(self.test_dataset)
705
+
706
+ return self.train_loader, self.val_loader, self.test_loader
707
+
708
+
709
+ class MolDataManager_withSolv(MolDataManager):
710
+ """The class for the management of the molecular data with the solvent, preparing the dataset, and dataloader"""
711
+ def __init__(self, config):
712
+ super().__init__(config)
713
+ self.molecule_columns.append('solvent')
714
+ self.molecule_graphs={col:[] for col in self.molecule_columns}
715
+
716
+ def import_others(self):
717
+ self.graph_type = 'mol'
718
+ self.gg =MolGraphGenerator()
719
+ self.gg_solv =MolGraphGenerator()
720
+ self.dataset =GraphDataset_withSolv
721
+ self.unwrapper = self.dataset.unwrapper
722
+
723
+ def init_temp_data(self,smiles,solvents,**kwargs):
724
+ return super().init_temp_data(smiles, solvents, **kwargs)
725
+
726
+ def init_dataset(self):
727
+ return self.dataset(self.molecule_graphs['compound'], self.molecule_graphs['solvent'], self.target_value, self._molecule_smiles['compound'], self._molecule_smiles['solvent'])
728
+
729
+ def hash(s):
730
+ md5_hash = hashlib.md5()
731
+ md5_hash.update(s.encode('utf-8'))
732
+ hex_digest = md5_hash.hexdigest()
733
+ decimal_hash = int(hex_digest, 16) % (10**12+7)
734
+
735
+ return decimal_hash