torch-molecule 0.1.0.dev2__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 (148) hide show
  1. torch_molecule/__init__.py +70 -0
  2. torch_molecule/base/__init__.py +8 -0
  3. torch_molecule/base/base.py +396 -0
  4. torch_molecule/base/encoder.py +44 -0
  5. torch_molecule/base/generator.py +24 -0
  6. torch_molecule/base/old_encoder.py +322 -0
  7. torch_molecule/base/old_predictor.py +904 -0
  8. torch_molecule/base/old_predictor2.py +436 -0
  9. torch_molecule/base/predictor.py +112 -0
  10. torch_molecule/encoder/attrmask/__init__.py +3 -0
  11. torch_molecule/encoder/attrmask/model.py +113 -0
  12. torch_molecule/encoder/attrmask/modeling_attrmask.py +304 -0
  13. torch_molecule/encoder/constant.py +13 -0
  14. torch_molecule/encoder/contextpred/__init__.py +3 -0
  15. torch_molecule/encoder/contextpred/model.py +207 -0
  16. torch_molecule/encoder/contextpred/modeling_contextpred.py +299 -0
  17. torch_molecule/encoder/contextpred/utils.py +206 -0
  18. torch_molecule/encoder/edgepred/__init__.py +3 -0
  19. torch_molecule/encoder/edgepred/model.py +104 -0
  20. torch_molecule/encoder/edgepred/modeling_edgepred.py +285 -0
  21. torch_molecule/encoder/graphmae/__init__.py +3 -0
  22. torch_molecule/encoder/graphmae/dataloader.py +206 -0
  23. torch_molecule/encoder/graphmae/model.py +137 -0
  24. torch_molecule/encoder/graphmae/modeling_graphmae.py +323 -0
  25. torch_molecule/encoder/infograph/__init__.py +3 -0
  26. torch_molecule/encoder/infograph/loss_function.py +128 -0
  27. torch_molecule/encoder/infograph/model.py +92 -0
  28. torch_molecule/encoder/infograph/modeling_infograph.py +301 -0
  29. torch_molecule/encoder/infograph/module.py +31 -0
  30. torch_molecule/encoder/moama/__init__.py +3 -0
  31. torch_molecule/encoder/moama/model.py +131 -0
  32. torch_molecule/encoder/moama/modeling_moama.py +307 -0
  33. torch_molecule/encoder/moama/utils.py +143 -0
  34. torch_molecule/encoder/pretrained/__init__.py +3 -0
  35. torch_molecule/encoder/pretrained/modeling_pretrained.py +274 -0
  36. torch_molecule/encoder/supervised/__init__.py +3 -0
  37. torch_molecule/encoder/supervised/model.py +97 -0
  38. torch_molecule/encoder/supervised/modeling_supervised.py +354 -0
  39. torch_molecule/generator/digress/__init__.py +3 -0
  40. torch_molecule/generator/digress/diffusion.py +194 -0
  41. torch_molecule/generator/digress/modeling_digress.py +503 -0
  42. torch_molecule/generator/digress/transformer.py +360 -0
  43. torch_molecule/generator/digress/utils.py +196 -0
  44. torch_molecule/generator/gdss/__init__.py +3 -0
  45. torch_molecule/generator/gdss/layers.py +257 -0
  46. torch_molecule/generator/gdss/model.py +204 -0
  47. torch_molecule/generator/gdss/modeling_gdss.py +474 -0
  48. torch_molecule/generator/gdss/sde.py +334 -0
  49. torch_molecule/generator/gdss/solver.py +227 -0
  50. torch_molecule/generator/gdss/toremove/ScoreNetworkA.py +167 -0
  51. torch_molecule/generator/gdss/toremove/ScoreNetworkX.py +85 -0
  52. torch_molecule/generator/gdss/utils.py +240 -0
  53. torch_molecule/generator/graph_dit/__init__.py +3 -0
  54. torch_molecule/generator/graph_dit/diffusion.py +188 -0
  55. torch_molecule/generator/graph_dit/modeling_graph_dit.py +581 -0
  56. torch_molecule/generator/graph_dit/transformer.py +207 -0
  57. torch_molecule/generator/graph_dit/utils.py +220 -0
  58. torch_molecule/generator/graph_ga/__init__.py +3 -0
  59. torch_molecule/generator/graph_ga/crossover.py +182 -0
  60. torch_molecule/generator/graph_ga/modeling_graph_ga.py +310 -0
  61. torch_molecule/generator/graph_ga/mutate.py +136 -0
  62. torch_molecule/generator/graph_ga/oracle.py +95 -0
  63. torch_molecule/generator/jtvae/__init__.py +3 -0
  64. torch_molecule/generator/jtvae/jtnn/chemutils.py +450 -0
  65. torch_molecule/generator/jtvae/jtnn/datautils.py +127 -0
  66. torch_molecule/generator/jtvae/jtnn/jtmpn.py +140 -0
  67. torch_molecule/generator/jtvae/jtnn/jtnn_dec.py +350 -0
  68. torch_molecule/generator/jtvae/jtnn/jtnn_enc.py +131 -0
  69. torch_molecule/generator/jtvae/jtnn/mol_tree.py +111 -0
  70. torch_molecule/generator/jtvae/jtnn/mpn.py +129 -0
  71. torch_molecule/generator/jtvae/jtnn/nnutils.py +66 -0
  72. torch_molecule/generator/jtvae/jtnn/vocab.py +31 -0
  73. torch_molecule/generator/jtvae/jtnn_vae.py +246 -0
  74. torch_molecule/generator/jtvae/modeling_jtvae.py +247 -0
  75. torch_molecule/generator/lstm/__init__.py +3 -0
  76. torch_molecule/generator/lstm/action_sampler.py +91 -0
  77. torch_molecule/generator/lstm/lstm.py +59 -0
  78. torch_molecule/generator/lstm/modeling_lstm.py +263 -0
  79. torch_molecule/generator/lstm/smiles_char_dict.py +123 -0
  80. torch_molecule/generator/lstm/utils.py +62 -0
  81. torch_molecule/generator/molgpt/__init__.py +3 -0
  82. torch_molecule/generator/molgpt/dataset.py +87 -0
  83. torch_molecule/generator/molgpt/gpt.py +251 -0
  84. torch_molecule/generator/molgpt/modeling_molgpt.py +427 -0
  85. torch_molecule/generator/pretrained/modeling_pretrained.py +1 -0
  86. torch_molecule/nn/__init__.py +16 -0
  87. torch_molecule/nn/attention.py +63 -0
  88. torch_molecule/nn/embedder.py +183 -0
  89. torch_molecule/nn/gnn.py +631 -0
  90. torch_molecule/nn/mlp.py +34 -0
  91. torch_molecule/predictor/bfgnn/__init__.py +3 -0
  92. torch_molecule/predictor/bfgnn/model.py +116 -0
  93. torch_molecule/predictor/bfgnn/modeling_bfgnn.py +81 -0
  94. torch_molecule/predictor/dir/__init__.py +3 -0
  95. torch_molecule/predictor/dir/model.py +235 -0
  96. torch_molecule/predictor/dir/modeling_dir.py +161 -0
  97. torch_molecule/predictor/dir/utils.py +40 -0
  98. torch_molecule/predictor/gnn/__init__.py +3 -0
  99. torch_molecule/predictor/gnn/model.py +114 -0
  100. torch_molecule/predictor/gnn/modeling_gnn.py +706 -0
  101. torch_molecule/predictor/gnn/old_modeling_gnn.py +707 -0
  102. torch_molecule/predictor/grea/__init__.py +3 -0
  103. torch_molecule/predictor/grea/model.py +190 -0
  104. torch_molecule/predictor/grea/modeling_grea.py +107 -0
  105. torch_molecule/predictor/grea/old1_features.py +229 -0
  106. torch_molecule/predictor/grea/old1_modeling_grea.py +493 -0
  107. torch_molecule/predictor/grea/utils.py +97 -0
  108. torch_molecule/predictor/grin/__init__.py +3 -0
  109. torch_molecule/predictor/grin/model.py +116 -0
  110. torch_molecule/predictor/grin/modeling_grin.py +91 -0
  111. torch_molecule/predictor/irm/__init__.py +3 -0
  112. torch_molecule/predictor/irm/model.py +127 -0
  113. torch_molecule/predictor/irm/modeling_irm.py +179 -0
  114. torch_molecule/predictor/lstm/__init__.py +3 -0
  115. torch_molecule/predictor/lstm/model.py +145 -0
  116. torch_molecule/predictor/lstm/modeling_lstm.py +627 -0
  117. torch_molecule/predictor/lstm/token_from_smiles.py +85 -0
  118. torch_molecule/predictor/old_modeling_graph.py +650 -0
  119. torch_molecule/predictor/rpgnn/__init__.py +3 -0
  120. torch_molecule/predictor/rpgnn/model.py +185 -0
  121. torch_molecule/predictor/rpgnn/modeling_rpgnn.py +76 -0
  122. torch_molecule/predictor/sgir/__init__.py +3 -0
  123. torch_molecule/predictor/sgir/modeling_sgir.py +260 -0
  124. torch_molecule/predictor/sgir/strategy.py +266 -0
  125. torch_molecule/predictor/smiles_transformer/__init__.py +3 -0
  126. torch_molecule/predictor/smiles_transformer/model.py +424 -0
  127. torch_molecule/predictor/smiles_transformer/modeling_transformer.py +252 -0
  128. torch_molecule/predictor/ssr/__init__.py +3 -0
  129. torch_molecule/predictor/ssr/model.py +283 -0
  130. torch_molecule/predictor/ssr/modeling_ssr.py +267 -0
  131. torch_molecule/predictor/ssr/utils.py +185 -0
  132. torch_molecule/utils/__init__.py +31 -0
  133. torch_molecule/utils/checker.py +158 -0
  134. torch_molecule/utils/checkpoint.py +294 -0
  135. torch_molecule/utils/format.py +140 -0
  136. torch_molecule/utils/generic/metrics.py +530 -0
  137. torch_molecule/utils/generic/pseudo_tasks.py +11 -0
  138. torch_molecule/utils/generic/weights.py +30 -0
  139. torch_molecule/utils/graph/features.py +178 -0
  140. torch_molecule/utils/graph/graph_from_smiles.py +134 -0
  141. torch_molecule/utils/graph/graph_to_smiles.py +312 -0
  142. torch_molecule/utils/hf.py +232 -0
  143. torch_molecule/utils/search.py +64 -0
  144. torch_molecule-0.1.0.dev2.dist-info/METADATA +243 -0
  145. torch_molecule-0.1.0.dev2.dist-info/RECORD +148 -0
  146. torch_molecule-0.1.0.dev2.dist-info/WHEEL +5 -0
  147. torch_molecule-0.1.0.dev2.dist-info/licenses/LICENSE +21 -0
  148. torch_molecule-0.1.0.dev2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,707 @@
1
+ import os
2
+ import numpy as np
3
+ from tqdm import tqdm
4
+ from typing import Optional, Union, Dict, Any, Tuple, List, Callable, Literal
5
+ import warnings
6
+
7
+ import torch
8
+ from torch_geometric.loader import DataLoader
9
+ from torch_geometric.data import Data
10
+
11
+ from .architecture import GNN
12
+ from ...base import BaseMolecularPredictor
13
+ from ...utils import graph_from_smiles
14
+ from ...utils.search import (
15
+ DEFAULT_GNN_SEARCH_SPACES,
16
+ suggest_parameter,
17
+ ParameterSpec,
18
+ ParameterType,
19
+ parse_list_params,
20
+ )
21
+
22
+ class GNNMolecularPredictor(BaseMolecularPredictor):
23
+ """This predictor implements a GNN model for molecular property prediction tasks.
24
+ """
25
+ def __init__(
26
+ self,
27
+ # model parameters
28
+ num_tasks: int = 1,
29
+ task_type: str = "classification",
30
+ num_layer: int = 5,
31
+ emb_dim: int = 300,
32
+ gnn_type: str = "gin-virtual",
33
+ drop_ratio: float = 0.5,
34
+ norm_layer: str = "batch_norm",
35
+ graph_pooling: str = "sum",
36
+ # augmented features
37
+ augmented_feature: Optional[list[Literal["morgan", "maccs"]]] = ["morgan", "maccs"],
38
+ # training parameters
39
+ batch_size: int = 128,
40
+ epochs: int = 500,
41
+ loss_criterion: Optional[Callable] = None,
42
+ evaluate_criterion: Optional[Union[str, Callable]] = None,
43
+ evaluate_higher_better: Optional[bool] = None,
44
+ learning_rate: float = 0.001,
45
+ grad_clip_value: Optional[float] = None,
46
+ weight_decay: float = 0.0,
47
+ patience: int = 50,
48
+ # scheduler
49
+ use_lr_scheduler: bool = True,
50
+ scheduler_factor: float = 0.5,
51
+ scheduler_patience: int = 5,
52
+ # others
53
+ device: Optional[str] = None,
54
+ verbose: bool = False,
55
+ model_name: str = "GNNMolecularPredictor",
56
+ ):
57
+ super().__init__(num_tasks=num_tasks, task_type=task_type, model_name=model_name, device=device)
58
+ # Model hyperparameters
59
+ self.num_layer = num_layer
60
+ self.emb_dim = emb_dim
61
+ self.gnn_type = gnn_type
62
+ self.drop_ratio = drop_ratio
63
+ self.norm_layer = norm_layer
64
+ self.graph_pooling = graph_pooling
65
+ # Augmented features
66
+ if augmented_feature is not None:
67
+ valid_augmented_feature = {"morgan", "maccs"}
68
+ invalid_fps = set(augmented_feature) - valid_augmented_feature
69
+ if invalid_fps:
70
+ raise ValueError(
71
+ f"Invalid augmented types: {invalid_fps}. "
72
+ f"Valid options are: {list(valid_augmented_feature)}"
73
+ )
74
+ self.augmented_feature = augmented_feature
75
+ # Training parameters
76
+ self.batch_size = batch_size
77
+ self.epochs = epochs
78
+ self.learning_rate = learning_rate
79
+ self.weight_decay = weight_decay
80
+ self.patience = patience
81
+ self.use_lr_scheduler = use_lr_scheduler
82
+ self.scheduler_factor = scheduler_factor
83
+ self.scheduler_patience = scheduler_patience
84
+ self.grad_clip_value = grad_clip_value
85
+ self.loss_criterion = loss_criterion if loss_criterion is not None else self._load_default_criterion()
86
+ self._setup_evaluation(evaluate_criterion, evaluate_higher_better)
87
+
88
+ self.fitting_loss = []
89
+ self.fitting_epoch = 0
90
+ self.verbose = verbose
91
+ self.model_class = GNN
92
+
93
+ @staticmethod
94
+ def _get_param_names() -> List[str]:
95
+ """Get parameter names for the estimator.
96
+
97
+ Returns
98
+ -------
99
+ List[str]
100
+ List of parameter names that can be used for model configuration.
101
+ """
102
+ return [
103
+ # Model Hyperparameters
104
+ "num_tasks",
105
+ "task_type",
106
+ "num_layer",
107
+ "emb_dim",
108
+ "gnn_type",
109
+ "drop_ratio",
110
+ "norm_layer",
111
+ "graph_pooling",
112
+ # Augmented Features
113
+ "augmented_feature",
114
+ # Training Parameters
115
+ "batch_size",
116
+ "epochs",
117
+ "learning_rate",
118
+ "weight_decay",
119
+ "patience",
120
+ "grad_clip_value",
121
+ "loss_criterion",
122
+ # Evaluation Parameters
123
+ "evaluate_name",
124
+ "evaluate_criterion",
125
+ "evaluate_higher_better",
126
+ # Scheduler Parameters
127
+ "use_lr_scheduler",
128
+ "scheduler_factor",
129
+ "scheduler_patience",
130
+ # Other Parameters
131
+ "fitting_epoch",
132
+ "fitting_loss",
133
+ "device",
134
+ "verbose"
135
+ ]
136
+
137
+ def _get_model_params(self, checkpoint: Optional[Dict] = None) -> Dict[str, Any]:
138
+ """Get model parameters either from checkpoint or current instance.
139
+
140
+ Parameters
141
+ ----------
142
+ checkpoint : Optional[Dict]
143
+ Checkpoint containing model hyperparameters
144
+
145
+ Returns
146
+ -------
147
+ Dict[str, Any]
148
+ Dictionary of model parameters
149
+
150
+ Raises
151
+ ------
152
+ ValueError
153
+ If checkpoint contains invalid parameters
154
+ """
155
+ if checkpoint is not None:
156
+ if "hyperparameters" not in checkpoint:
157
+ raise ValueError("Checkpoint missing 'hyperparameters' key")
158
+
159
+ hyperparameters = checkpoint["hyperparameters"]
160
+
161
+ # Define required parameters
162
+ required_params = {
163
+ "num_tasks", "num_layer", "emb_dim", "gnn_type",
164
+ "drop_ratio", "norm_layer", "graph_pooling", 'augmented_feature'
165
+ }
166
+
167
+ # Validate parameters
168
+ # invalid_params = set(hyperparameters.keys()) - required_params
169
+
170
+ # Get parameters with fallback to instance values
171
+ return {
172
+ "num_tasks": hyperparameters.get("num_tasks", self.num_tasks),
173
+ "num_layer": hyperparameters.get("num_layer", self.num_layer),
174
+ "emb_dim": hyperparameters.get("emb_dim", self.emb_dim),
175
+ "gnn_type": hyperparameters.get("gnn_type", self.gnn_type),
176
+ "drop_ratio": hyperparameters.get("drop_ratio", self.drop_ratio),
177
+ "norm_layer": hyperparameters.get("norm_layer", self.norm_layer),
178
+ "graph_pooling": hyperparameters.get("graph_pooling", self.graph_pooling),
179
+ "augmented_feature": hyperparameters.get("augmented_feature", self.augmented_feature)
180
+ }
181
+ else:
182
+ # Use current instance parameters
183
+ return {
184
+ "num_tasks": self.num_tasks,
185
+ "num_layer": self.num_layer,
186
+ "emb_dim": self.emb_dim,
187
+ "gnn_type": self.gnn_type,
188
+ "drop_ratio": self.drop_ratio,
189
+ "norm_layer": self.norm_layer,
190
+ "graph_pooling": self.graph_pooling,
191
+ "augmented_feature": self.augmented_feature
192
+ }
193
+
194
+ def _convert_to_pytorch_data(self, X, y=None):
195
+ """Convert numpy arrays to PyTorch Geometric data format.
196
+ """
197
+ if self.verbose:
198
+ iterator = tqdm(enumerate(X), desc="Converting molecules to graphs", total=len(X))
199
+ else:
200
+ iterator = enumerate(X)
201
+
202
+ pyg_graph_list = []
203
+ for idx, smiles in iterator:
204
+ if y is not None:
205
+ properties = y[idx]
206
+ else:
207
+ properties = None
208
+ graph = graph_from_smiles(smiles, properties, self.augmented_feature)
209
+ g = Data()
210
+ g.num_nodes = graph["num_nodes"]
211
+ g.edge_index = torch.from_numpy(graph["edge_index"])
212
+
213
+ del graph["num_nodes"]
214
+ del graph["edge_index"]
215
+
216
+ if graph["edge_feat"] is not None:
217
+ g.edge_attr = torch.from_numpy(graph["edge_feat"])
218
+ del graph["edge_feat"]
219
+
220
+ if graph["node_feat"] is not None:
221
+ g.x = torch.from_numpy(graph["node_feat"])
222
+ del graph["node_feat"]
223
+
224
+ if graph["y"] is not None:
225
+ g.y = torch.from_numpy(graph["y"])
226
+ del graph["y"]
227
+
228
+ if graph["morgan"] is not None:
229
+ g.morgan = torch.tensor(graph["morgan"], dtype=torch.int8).view(1, -1)
230
+ del graph["morgan"]
231
+
232
+ if graph["maccs"] is not None:
233
+ g.maccs = torch.tensor(graph["maccs"], dtype=torch.int8).view(1, -1)
234
+ del graph["maccs"]
235
+
236
+ pyg_graph_list.append(g)
237
+
238
+ return pyg_graph_list
239
+
240
+ def _setup_optimizers(self) -> Tuple[torch.optim.Optimizer, Optional[Any]]:
241
+ """Setup optimization components including optimizer and learning rate scheduler.
242
+
243
+ Returns
244
+ -------
245
+ Tuple[optim.Optimizer, Optional[Any]]
246
+ A tuple containing:
247
+ - The configured optimizer
248
+ - The learning rate scheduler (if enabled, else None)
249
+
250
+ Notes
251
+ -----
252
+ The scheduler returned can be either _LRScheduler or ReduceLROnPlateau,
253
+ hence we use Any as the type hint instead of optim.lr_scheduler._LRScheduler
254
+ """
255
+ optimizer = torch.optim.Adam(
256
+ self.model.parameters(), lr=self.learning_rate, weight_decay=self.weight_decay
257
+ )
258
+ if self.grad_clip_value is not None:
259
+ for group in optimizer.param_groups:
260
+ group.setdefault("max_norm", self.grad_clip_value)
261
+ group.setdefault("norm_type", 2.0)
262
+
263
+ scheduler = None
264
+ if self.use_lr_scheduler:
265
+ scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
266
+ optimizer,
267
+ mode="min",
268
+ factor=self.scheduler_factor,
269
+ patience=self.scheduler_patience,
270
+ min_lr=1e-6,
271
+ cooldown=0,
272
+ eps=1e-8,
273
+ )
274
+
275
+ return optimizer, scheduler
276
+
277
+ def _get_default_search_space(self):
278
+ """Get the default hyperparameter search space.
279
+
280
+ Returns
281
+ -------
282
+ Dict[str, ParameterSpec]
283
+ Dictionary mapping parameter names to their search space specifications
284
+ """
285
+ return DEFAULT_GNN_SEARCH_SPACES
286
+
287
+ def autofit(
288
+ self,
289
+ X_train: List[str],
290
+ y_train: Optional[Union[List, np.ndarray]],
291
+ X_val: Optional[List[str]] = None,
292
+ y_val: Optional[Union[List, np.ndarray]] = None,
293
+ X_unlbl: Optional[List[str]] = None,
294
+ search_parameters: Optional[Dict[str, ParameterSpec]] = None,
295
+ n_trials: int = 10,
296
+ ) -> "GNNMolecularPredictor":
297
+ """Automatically find the best hyperparameters using Optuna optimization."""
298
+ import optuna
299
+ # Default search parameters
300
+ default_search_parameters = self._get_default_search_space()
301
+ if search_parameters is None:
302
+ search_parameters = default_search_parameters
303
+ else:
304
+ # Validate search parameter keys
305
+ invalid_params = set(search_parameters.keys()) - set(default_search_parameters.keys())
306
+ if invalid_params:
307
+ raise ValueError(
308
+ f"Invalid search parameters: {invalid_params}. "
309
+ f"Valid parameters are: {list(default_search_parameters.keys())}"
310
+ )
311
+
312
+ if self.verbose:
313
+ all_params = set(self._get_param_names())
314
+ searched_params = set(search_parameters.keys())
315
+ non_searched_params = all_params - searched_params
316
+
317
+ print("\nParameter Search Configuration:")
318
+ print("-" * 50)
319
+
320
+ print("\n Parameters being searched:")
321
+ for param in sorted(searched_params):
322
+ spec = search_parameters[param]
323
+ if spec.param_type == ParameterType.CATEGORICAL:
324
+ print(f" • {param}: {spec.value_range}")
325
+ else:
326
+ print(f" • {param}: [{spec.value_range[0]}, {spec.value_range[1]}]")
327
+
328
+ print("\n Fixed parameters (not being searched):")
329
+ for param in sorted(non_searched_params):
330
+ value = getattr(self, param, "N/A")
331
+ print(f" • {param}: {value}")
332
+
333
+ print("\n" + "-" * 50)
334
+
335
+ print(f"\nStarting hyperparameter optimization using {self.evaluate_name} metric")
336
+ print(f"Direction: {'maximize' if self.evaluate_higher_better else 'minimize'}")
337
+ print(f"Number of trials: {n_trials}")
338
+
339
+ # Variables to track best state
340
+ best_score = float('-inf') if self.evaluate_higher_better else float('inf')
341
+ best_state_dict = None
342
+ best_trial_params = None
343
+ best_loss = None
344
+ best_epoch = None
345
+
346
+ def objective(trial):
347
+ nonlocal best_score, best_state_dict, best_trial_params, best_loss, best_epoch
348
+
349
+ # Define hyperparameters to optimize using the parameter specifications
350
+ params = {}
351
+ for param_name, param_spec in search_parameters.items():
352
+ try:
353
+ params[param_name] = suggest_parameter(trial, param_name, param_spec)
354
+ except Exception as e:
355
+ print(f"Error suggesting parameter {param_name}: {str(e)}")
356
+ return float('inf')
357
+
358
+ try:
359
+ # Update model parameters and train
360
+ if "augmented_feature" in params:
361
+ params['augmented_feature'] = parse_list_params(params['augmented_feature'])
362
+ self.set_params(**params)
363
+ self.fit(X_train, y_train, X_val, y_val, X_unlbl)
364
+
365
+ # Get evaluation score
366
+ eval_data = (X_val if X_val is not None else X_train)
367
+ eval_labels = (y_val if y_val is not None else y_train)
368
+ eval_results = self.predict(eval_data)
369
+ score = float(self.evaluate_criterion(eval_labels, eval_results['prediction']))
370
+
371
+ # Update best state if current score is better
372
+ is_better = (
373
+ score > best_score if self.evaluate_higher_better
374
+ else score < best_score
375
+ )
376
+
377
+ if is_better:
378
+ best_score = score
379
+ best_state_dict = {
380
+ 'model': self.model.state_dict(),
381
+ 'architecture': self._get_model_params()
382
+ }
383
+ best_trial_params = params.copy()
384
+ best_loss = self.fitting_loss.copy() # Added .copy() for safety
385
+ best_epoch = self.fitting_epoch
386
+
387
+ if self.verbose:
388
+ print(
389
+ f"Trial {trial.number}: {self.evaluate_name} = {score:.4f} "
390
+ f"({'better' if is_better else 'worse'} than best = {best_score:.4f})"
391
+ )
392
+ print("Current parameters:")
393
+ for param_name, value in params.items():
394
+ print(f" {param_name}: {value}")
395
+
396
+ # Return score (negated if higher is better, since Optuna minimizes)
397
+ return -score if self.evaluate_higher_better else score
398
+
399
+ except Exception as e:
400
+ print(f"Trial {trial.number} failed with error: {str(e)}")
401
+ return float('inf')
402
+
403
+ # Create study with optional output control
404
+ optuna.logging.set_verbosity(
405
+ optuna.logging.INFO if self.verbose else optuna.logging.WARNING
406
+ )
407
+
408
+ # Create and run study
409
+ study = optuna.create_study(
410
+ direction="minimize",
411
+ study_name=f"{self.model_name}_optimization"
412
+ )
413
+
414
+ # try:
415
+ study.optimize(
416
+ objective,
417
+ n_trials=n_trials,
418
+ catch=(Exception,),
419
+ show_progress_bar=self.verbose
420
+ )
421
+
422
+ if best_state_dict is not None:
423
+ self.set_params(**best_trial_params)
424
+ # Initialize model with saved architecture parameters
425
+ self._initialize_model(self.model_class, self.device)
426
+ # Load the saved state dict
427
+ self.model.load_state_dict(best_state_dict['model'])
428
+ self.fitting_loss = best_loss
429
+ self.fitting_epoch = best_epoch
430
+ self.is_fitted_ = True
431
+
432
+ if self.verbose:
433
+ print(f"\nOptimization completed successfully:")
434
+ print(f"Best {self.evaluate_name}: {best_score:.4f}")
435
+
436
+ eval_data = (X_val if X_val is not None else X_train)
437
+ eval_labels = (y_val if y_val is not None else y_train)
438
+ eval_results = self.predict(eval_data)
439
+ score = float(self.evaluate_criterion(eval_labels, eval_results['prediction']))
440
+ print('post score is: ', score)
441
+
442
+ print("\nBest parameters:")
443
+ for param, value in best_trial_params.items():
444
+ param_spec = search_parameters[param]
445
+ print(f" {param}: {value} (type: {param_spec.param_type.value})")
446
+
447
+ # Print optimization statistics
448
+ print("\nOptimization statistics:")
449
+ print(f" Number of completed trials: {len(study.trials)}")
450
+ print(f" Number of pruned trials: {len(study.get_trials(states=[optuna.trial.TrialState.PRUNED]))}")
451
+ print(f" Number of failed trials: {len(study.get_trials(states=[optuna.trial.TrialState.FAIL]))}")
452
+ else:
453
+ raise RuntimeError("No successful trials completed during optimization")
454
+
455
+ # except KeyboardInterrupt:
456
+ # print("\nOptimization interrupted by user. Saving best results so far...")
457
+ # if best_state_dict is not None:
458
+ # self.set_params(**best_trial_params)
459
+ # # CHANGE: Use same restoration logic here
460
+ # self._initialize_model(self.model_class, self.device)
461
+ # self.model.load_state_dict(best_state_dict['model'])
462
+ # self.fitting_loss = best_loss
463
+ # self.fitting_epoch = best_epoch
464
+ # self.is_fitted_ = True
465
+
466
+ return self
467
+
468
+ def fit(
469
+ self,
470
+ X_train: List[str],
471
+ y_train: Optional[Union[List, np.ndarray]],
472
+ X_val: Optional[List[str]] = None,
473
+ y_val: Optional[Union[List, np.ndarray]] = None,
474
+ X_unlbl: Optional[List[str]] = None,
475
+ ) -> "GNNMolecularPredictor":
476
+ """Fit the model to the training data with optional validation set.
477
+
478
+ Parameters
479
+ ----------
480
+ X_train : List[str]
481
+ Training set input molecular structures as SMILES strings
482
+ y_train : Union[List, np.ndarray]
483
+ Training set target values for property prediction
484
+ X_val : List[str], optional
485
+ Validation set input molecular structures as SMILES strings.
486
+ If None, training data will be used for validation
487
+ y_val : Union[List, np.ndarray], optional
488
+ Validation set target values. Required if X_val is provided
489
+ X_unlbl : List[str], optional
490
+ Unlabeled set input molecular structures as SMILES strings.
491
+
492
+ Returns
493
+ -------
494
+ self : GNNMolecularPredictor
495
+ Fitted estimator
496
+ """
497
+ if (X_val is None) != (y_val is None):
498
+ raise ValueError(
499
+ "Both X_val and y_val must be provided for validation. "
500
+ f"Got X_val={X_val is not None}, y_val={y_val is not None}"
501
+ )
502
+
503
+ self._initialize_model(self.model_class, self.device)
504
+ self.model.initialize_parameters()
505
+ self.model = self.model.to(self.device)
506
+ optimizer, scheduler = self._setup_optimizers()
507
+
508
+ # Prepare datasets and loaders
509
+ X_train, y_train = self._validate_inputs(X_train, y_train)
510
+ train_dataset = self._convert_to_pytorch_data(X_train, y_train)
511
+ train_loader = DataLoader(
512
+ train_dataset,
513
+ batch_size=self.batch_size,
514
+ shuffle=True,
515
+ num_workers=0
516
+ )
517
+
518
+ if X_val is None or y_val is None:
519
+ val_loader = train_loader
520
+ warnings.warn(
521
+ "No validation set provided. Using training set for validation. "
522
+ "This may lead to overfitting.",
523
+ UserWarning
524
+ )
525
+ else:
526
+ X_val, y_val = self._validate_inputs(X_val, y_val)
527
+ val_dataset = self._convert_to_pytorch_data(X_val, y_val)
528
+ val_loader = DataLoader(
529
+ val_dataset,
530
+ batch_size=self.batch_size,
531
+ shuffle=False,
532
+ num_workers=0
533
+ )
534
+
535
+ # Initialize training state
536
+ self.fitting_loss = []
537
+ self.fitting_epoch = 0
538
+ best_state_dict = None
539
+ best_eval = float('-inf') if self.evaluate_higher_better else float('inf')
540
+ cnt_wait = 0
541
+
542
+ for epoch in range(self.epochs):
543
+ # Training phase
544
+ train_losses = self._train_epoch(train_loader, optimizer)
545
+ self.fitting_loss.append(np.mean(train_losses))
546
+
547
+ # Validation phase
548
+ current_eval = self._evaluation_epoch(val_loader)
549
+
550
+ if scheduler:
551
+ scheduler.step(current_eval)
552
+
553
+ # Model selection (check if current evaluation is better)
554
+ is_better = (
555
+ current_eval > best_eval if self.evaluate_higher_better
556
+ else current_eval < best_eval
557
+ )
558
+
559
+ if is_better:
560
+ self.fitting_epoch = epoch
561
+ best_eval = current_eval
562
+ best_state_dict = self.model.state_dict()
563
+ cnt_wait = 0
564
+ else:
565
+ cnt_wait += 1
566
+ if cnt_wait > self.patience:
567
+ if self.verbose:
568
+ print(f"Early stopping triggered after {epoch} epochs")
569
+ break
570
+
571
+ if self.verbose and epoch % 10 == 0:
572
+ print(
573
+ f"Epoch {epoch}: Loss = {np.mean(train_losses):.4f}, "
574
+ f"{self.evaluate_name} = {current_eval:.4f}, "
575
+ f"Best {self.evaluate_name} = {best_eval:.4f}"
576
+ )
577
+
578
+ # Restore best model
579
+ if best_state_dict is not None:
580
+ self.model.load_state_dict(best_state_dict)
581
+ else:
582
+ warnings.warn(
583
+ "No improvement was achieved during training. "
584
+ "The model may not be fitted properly.",
585
+ UserWarning
586
+ )
587
+
588
+ self.is_fitted_ = True
589
+ return self
590
+
591
+ def predict(self, X: List[str]) -> Dict[str, np.ndarray]:
592
+ """Make predictions using the fitted model.
593
+
594
+ Parameters
595
+ ----------
596
+ X : List[str]
597
+ List of SMILES strings to make predictions for
598
+
599
+ Returns
600
+ -------
601
+ Dict[str, np.ndarray]
602
+ Dictionary containing:
603
+ - 'prediction': Model predictions (shape: [n_samples, n_tasks])
604
+
605
+ """
606
+ self._check_is_fitted()
607
+
608
+ # Convert to PyTorch Geometric format and create loader
609
+ X, _ = self._validate_inputs(X)
610
+ dataset = self._convert_to_pytorch_data(X)
611
+ loader = DataLoader(dataset, batch_size=self.batch_size, shuffle=False)
612
+
613
+ if self.model is None:
614
+ raise RuntimeError("Model not initialized")
615
+ # Make predictions
616
+ self.model = self.model.to(self.device)
617
+ self.model.eval()
618
+ predictions = []
619
+ with torch.no_grad():
620
+ for batch in tqdm(loader, disable=not self.verbose):
621
+ batch = batch.to(self.device)
622
+ out = self.model(batch)
623
+ predictions.append(out["prediction"].cpu().numpy())
624
+ return {
625
+ "prediction": np.concatenate(predictions, axis=0),
626
+ }
627
+
628
+ def _evaluation_epoch(
629
+ self,
630
+ loader: DataLoader,
631
+ ) -> float:
632
+ """Evaluate the model on given data.
633
+
634
+ Parameters
635
+ ----------
636
+ loader : DataLoader
637
+ DataLoader containing evaluation data
638
+ train_losses : List[float]
639
+ Training losses from current epoch
640
+
641
+ Returns
642
+ -------
643
+ float
644
+ Evaluation metric value (adjusted for higher/lower better)
645
+ """
646
+ self.model.eval()
647
+ y_pred_list = []
648
+ y_true_list = []
649
+
650
+ with torch.no_grad():
651
+ for batch in loader:
652
+ batch = batch.to(self.device)
653
+ out = self.model(batch)
654
+ y_pred_list.append(out["prediction"].cpu().numpy())
655
+ y_true_list.append(batch.y.cpu().numpy())
656
+
657
+ y_pred = np.concatenate(y_pred_list, axis=0)
658
+ y_true = np.concatenate(y_true_list, axis=0)
659
+
660
+ # Compute metric
661
+ metric_value = float(self.evaluate_criterion(y_true, y_pred))
662
+
663
+ # Adjust metric value based on higher/lower better
664
+ return metric_value
665
+
666
+ def _train_epoch(self, train_loader, optimizer):
667
+ """Training logic for one epoch.
668
+
669
+ Args:
670
+ train_loader: DataLoader containing training data
671
+ optimizer: Optimizer instance for model parameter updates
672
+
673
+ Returns:
674
+ list: List of loss values for each training step
675
+ """
676
+ self.model.train()
677
+ losses = []
678
+
679
+ iterator = (
680
+ tqdm(train_loader, desc="Training", leave=False)
681
+ if self.verbose
682
+ else train_loader
683
+ )
684
+
685
+ for batch in iterator:
686
+ batch = batch.to(self.device)
687
+ optimizer.zero_grad()
688
+
689
+ # Forward pass and loss computation
690
+ loss = self.model.compute_loss(batch, self.loss_criterion)
691
+
692
+ # Backward pass
693
+ loss.backward()
694
+
695
+ # Compute gradient norm if gradient clipping is enabled
696
+ if self.grad_clip_value is not None:
697
+ torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.grad_clip_value)
698
+
699
+ optimizer.step()
700
+
701
+ losses.append(loss.item())
702
+
703
+ # Update progress bar if using tqdm
704
+ if self.verbose:
705
+ iterator.set_postfix({"loss": f"{loss.item():.4f}"})
706
+
707
+ return losses