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,904 @@
1
+ import os
2
+ import json
3
+ import datetime
4
+ import tempfile
5
+ import warnings
6
+ import torch
7
+ import numpy as np
8
+ from rdkit import Chem
9
+ from ..utils import (
10
+ roc_auc_score,
11
+ accuracy_score,
12
+ mean_absolute_error,
13
+ mean_squared_error,
14
+ root_mean_squared_error,
15
+ r2_score,
16
+ )
17
+ from ..utils.format import sanitize_config
18
+
19
+ from dataclasses import dataclass, field
20
+ from abc import ABC, abstractmethod
21
+ from typing import Optional, ClassVar, Union, List, Dict, Any, Tuple, Callable, Type
22
+
23
+
24
+ @dataclass
25
+ class BaseMolecularPredictor(ABC):
26
+ """Base class for molecular discovery estimators.
27
+
28
+ This class provides a skeleton for implementing custom molecular discovery
29
+ models following scikit-learn's conventions. It includes basic functionality
30
+ for parameter management, fitting, prediction, and validation.
31
+
32
+ Attributes
33
+ ----------
34
+ num_task : int
35
+ Number of prediction tasks
36
+ task_type : str
37
+ Type of task ('classification' or 'regression')
38
+ model_name : str
39
+ Name of the model
40
+ device : Optional[torch.device]
41
+ Device to run the model on
42
+ model : Optional[torch.nn.Module]
43
+ Model instance
44
+ """
45
+
46
+ num_task: int
47
+ task_type: str
48
+ model_name: str = field(default="BaseMolecularPredictor")
49
+ device: Optional[torch.device] = field(default=None)
50
+ model: Optional[torch.nn.Module] = field(default=None, init=False)
51
+
52
+ # Class variables
53
+ DEFAULT_METRICS: ClassVar[Dict] = {
54
+ "classification": {
55
+ "default": ("roc_auc", roc_auc_score, True), # (name, function, higher_better)
56
+ },
57
+ "regression": {"default": ("mae", mean_absolute_error, False)},
58
+ }
59
+
60
+ # Instance variables with default values
61
+ is_fitted_: bool = field(default=False, init=False)
62
+ model_class: Optional[Type[torch.nn.Module]] = field(default=None, init=False)
63
+
64
+ def __post_init__(self):
65
+ """Validate inputs and set device after initialization."""
66
+ if self.task_type not in ["classification", "regression"]:
67
+ raise ValueError(
68
+ f"task_type must be one of ['classification', 'regression'], got {self.task_type}"
69
+ )
70
+
71
+ if not isinstance(self.num_task, (int, np.integer)) or self.num_task <= 0:
72
+ raise ValueError(f"num_task must be a positive integer, got {self.num_task}")
73
+
74
+ # Set device if not provided
75
+ if self.device is None:
76
+ self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
77
+ elif isinstance(self.device, str):
78
+ self.device = torch.device(self.device)
79
+
80
+ def get_params(self, deep: bool = True) -> Dict[str, Any]:
81
+ """Get parameters for this estimator.
82
+
83
+ Parameters
84
+ ----------
85
+ deep : bool, default=True
86
+ If True, will return the parameters for this estimator and
87
+ contained subobjects.
88
+
89
+ Returns
90
+ -------
91
+ params : dict
92
+ Parameter names mapped to their values.
93
+ """
94
+ out = dict()
95
+ for key in self._get_param_names():
96
+ value = getattr(self, key)
97
+ if deep and hasattr(value, "get_params"):
98
+ deep_items = value.get_params().items()
99
+ out.update((key + "__" + k, val) for k, val in deep_items)
100
+ out[key] = value
101
+ return out
102
+
103
+ def set_params(self, **params) -> "BaseMolecularPredictor":
104
+ """Set the parameters of this predictor.
105
+
106
+ Parameters
107
+ ----------
108
+ **params : dict
109
+ predictor parameters.
110
+
111
+ Returns
112
+ -------
113
+ self : object
114
+ predictor instance.
115
+ """
116
+ if not params:
117
+ return self
118
+
119
+ valid_params = self.get_params(deep=True)
120
+ for key, value in params.items():
121
+ if key not in valid_params:
122
+ raise ValueError(f"Invalid parameter {key} for predictor {self}")
123
+ setattr(self, key, value)
124
+ return self
125
+
126
+ def _setup_optimizers(self) -> Tuple[torch.optim.Optimizer, Optional[Any]]:
127
+ """Setup optimization components including optimizer and learning rate scheduler.
128
+
129
+ Returns
130
+ -------
131
+ Tuple[optim.Optimizer, Optional[Any]]
132
+ A tuple containing:
133
+ - The configured optimizer
134
+ - The learning rate scheduler (if enabled, else None)
135
+ """
136
+ raise NotImplementedError("optimizers and schedulers must be implemented by child class")
137
+
138
+ def _setup_evaluation(
139
+ self,
140
+ evaluate_criterion: Optional[Union[str, Callable]],
141
+ evaluate_higher_better: Optional[bool],
142
+ ) -> None:
143
+ """Setup evaluation metric and direction.
144
+
145
+ Parameters
146
+ ----------
147
+ evaluate_criterion : Union[str, Callable, None]
148
+ Evaluation metric specification
149
+ evaluate_higher_better : bool, optional
150
+ Whether higher values are better
151
+ """
152
+ if evaluate_criterion is None:
153
+ # Use default metric based on task type
154
+ default_metric = self.DEFAULT_METRICS[self.task_type]["default"]
155
+ self.evaluate_name = default_metric[0]
156
+ self.evaluate_criterion = default_metric[1]
157
+ self.evaluate_higher_better = default_metric[2]
158
+
159
+ else:
160
+ if isinstance(evaluate_criterion, str):
161
+ # Map string to predefined metric
162
+ metric_map = {
163
+ "accuracy": (accuracy_score, True),
164
+ "roc_auc": (roc_auc_score, True),
165
+ "rmse": (root_mean_squared_error, False),
166
+ "mse": (mean_squared_error, False),
167
+ "mae": (mean_absolute_error, False),
168
+ "r2": (r2_score, True),
169
+ }
170
+
171
+ if evaluate_criterion not in metric_map:
172
+ raise ValueError(
173
+ f"Unknown metric: {evaluate_criterion}. "
174
+ f"Available metrics: {list(metric_map.keys())}"
175
+ )
176
+
177
+ self.evaluate_name = evaluate_criterion
178
+ self.evaluate_criterion = metric_map[evaluate_criterion][0]
179
+ self.evaluate_higher_better = (
180
+ metric_map[evaluate_criterion][1]
181
+ if evaluate_higher_better is None
182
+ else evaluate_higher_better
183
+ )
184
+
185
+ else:
186
+ # Custom metric function
187
+ if evaluate_higher_better is None:
188
+ raise ValueError(
189
+ "evaluate_higher_better must be specified "
190
+ "when using a custom evaluation function"
191
+ )
192
+
193
+ self.evaluate_name = "custom"
194
+ self.evaluate_criterion = evaluate_criterion
195
+ self.evaluate_higher_better = evaluate_higher_better
196
+
197
+ @abstractmethod
198
+ def fit(self, X_train, y_train, X_val=None, y_val=None, X_unlbl=None) -> "BaseMolecularPredictor":
199
+ """Fit the model to the data.
200
+
201
+ Parameters
202
+ ----------
203
+ X_train / X_val / X_unlbl: list of SMILES
204
+ y_train / y_val: array-like of shape (n_samples,), optional
205
+ Target values.
206
+ Returns
207
+ -------
208
+ self : object
209
+ Fitted estimator.
210
+ """
211
+ pass
212
+
213
+ @abstractmethod
214
+ def autofit(
215
+ self,
216
+ X_train,
217
+ y_train,
218
+ X_val=None,
219
+ y_val=None,
220
+ search_parameters: Optional[dict] = None,
221
+ n_trials: int = 10,
222
+ ) -> "BaseMolecularPredictor":
223
+ """AutoFit the model to the data.
224
+
225
+ Parameters
226
+ ----------
227
+ X_train / X_val : list of SMILES
228
+ y_train / y_val: array-like of shape (n_samples,), optional
229
+ Target values.
230
+ Returns
231
+ -------
232
+ self : object
233
+ Fitted estimator.
234
+ """
235
+ pass
236
+
237
+ @abstractmethod
238
+ def predict(self, X: np.ndarray) -> np.ndarray:
239
+ """Make predictions using the fitted model.
240
+
241
+ Parameters
242
+ ----------
243
+ X : array-like of shape (n_samples, n_features)
244
+ Input data.
245
+
246
+ Returns
247
+ -------
248
+ y_pred : ndarray of shape (n_samples,)
249
+ Predicted values.
250
+ """
251
+ pass
252
+
253
+ def _train_epoch(self, train_loader, optimizer):
254
+ """Training logic for one epoch.
255
+
256
+ Should be implemented by child classes based on specific model architecture.
257
+ """
258
+ raise NotImplementedError("Training epoch logic must be implemented by child class")
259
+
260
+ def _evaluation_epoch(self, evaluate_loader):
261
+ """Training logic for one evaluation.
262
+
263
+ Should be implemented by child classes based on specific model architecture.
264
+ """
265
+ raise NotImplementedError("Evaluation epoch logic must be implemented by child class")
266
+
267
+ def _check_is_fitted(self) -> None:
268
+ """Check if the estimator is fitted.
269
+
270
+ Raises
271
+ ------
272
+ NotFittedError
273
+ If the estimator is not fitted.
274
+ """
275
+ if not self.is_fitted_:
276
+ raise AttributeError(
277
+ "This MolecularBaseEstimator instance is not fitted yet. "
278
+ "Call 'fit' before using this estimator."
279
+ )
280
+
281
+ def __repr__(self, N_CHAR_MAX=700):
282
+ """Get string representation of the estimator.
283
+
284
+ Parameters
285
+ ----------
286
+ N_CHAR_MAX : int, default=700
287
+ Maximum number of non-blank characters to show
288
+
289
+ Returns
290
+ -------
291
+ str
292
+ String representation of the estimator
293
+ """
294
+ # Get all relevant attributes (excluding private ones and callables)
295
+ attributes = {
296
+ name: value
297
+ for name, value in sorted(self.__dict__.items())
298
+ if not name.startswith("_") and not callable(value)
299
+ }
300
+
301
+ def format_value(v):
302
+ """Format a value for representation."""
303
+ if isinstance(v, (float, np.float32, np.float64)):
304
+ return f"{v:.3g}"
305
+ elif isinstance(v, (list, tuple, np.ndarray)) and len(v) > 6:
306
+ return f"{str(v[:3])[:-1]}, ..., {str(v[-3:])[1:]}"
307
+ elif isinstance(v, str) and len(v) > 50:
308
+ return f"'{v[:25]}...{v[-22:]}'"
309
+ elif isinstance(v, dict) and len(v) > 6:
310
+ items = list(v.items())
311
+ return f"{dict(items[:3])}...{dict(items[-3:])}"
312
+ elif isinstance(v, torch.nn.Module):
313
+ return f"{v.__class__.__name__}(...)"
314
+ elif v is None:
315
+ return "None"
316
+ else:
317
+ return repr(v)
318
+
319
+ # Build the representation string
320
+ class_name = self.__class__.__name__
321
+ attributes_str = []
322
+
323
+ # First add important attributes that should appear first
324
+ important_attrs = ["num_task", "task_type", "model_name", "is_fitted_"]
325
+ for attr in important_attrs:
326
+ if attr in attributes:
327
+ value = attributes.pop(attr)
328
+ attributes_str.append(f"{attr}={format_value(value)}")
329
+
330
+ # Then add the rest
331
+ sorted_attrs = sorted(attributes.items())
332
+ attributes_str.extend(f"{name}={format_value(value)}" for name, value in sorted_attrs)
333
+
334
+ # Join all parts
335
+ content = ",\n ".join(attributes_str)
336
+ repr_ = f"{class_name}(\n {content}\n)"
337
+
338
+ # Handle length limit
339
+ if len(repr_) > N_CHAR_MAX:
340
+ lines = repr_.split("\n")
341
+
342
+ # Keep header
343
+ result = [lines[0]]
344
+ current_length = len(lines[0])
345
+
346
+ # Process middle lines
347
+ for line in lines[1:-1]:
348
+ if current_length + len(line) + 5 > N_CHAR_MAX: # 5 for "...\n"
349
+ result.append(" ...")
350
+ break
351
+ result.append(line)
352
+ current_length += len(line) + 1 # +1 for newline
353
+
354
+ # Add closing parenthesis
355
+ result.append(lines[-1])
356
+ repr_ = "\n".join(result)
357
+
358
+ return repr_
359
+
360
+ @abstractmethod
361
+ def _get_model_params(self, checkpoint: Optional[Dict] = None) -> Dict[str, Any]:
362
+ """Get model parameters either from checkpoint or current instance.
363
+
364
+ Parameters
365
+ ----------
366
+ checkpoint : Optional[Dict]
367
+ Checkpoint containing model hyperparameters
368
+
369
+ Returns
370
+ -------
371
+ Dict[str, Any]
372
+ Dictionary of model parameters
373
+
374
+ Raises
375
+ ------
376
+ NotImplementedError
377
+ If the child class doesn't implement this method
378
+ ValueError
379
+ If checkpoint contains invalid parameters
380
+ """
381
+ raise NotImplementedError(
382
+ "Method _get_model_params must be implemented by child class. "
383
+ "This method should return a dictionary of model parameters either "
384
+ "from a checkpoint or current instance values."
385
+ )
386
+
387
+ def _initialize_model(
388
+ self, model_class: Type[torch.nn.Module], device, checkpoint: Optional[Dict] = None
389
+ ) -> None:
390
+ """Initialize the GNN model with given parameters or checkpoint.
391
+
392
+ Parameters
393
+ ----------
394
+ checkpoint : Optional[Dict]
395
+ If provided, should contain:
396
+ - hyperparameters: Dict of model configuration
397
+ - model_state_dict: Dict of model state
398
+ If None, initializes model with current instance parameters.
399
+
400
+ Raises
401
+ ------
402
+ ValueError
403
+ If checkpoint is provided but missing required keys or contains invalid parameters
404
+ RuntimeError
405
+ If device initialization fails
406
+ """
407
+ try:
408
+ model_params = self._get_model_params(checkpoint)
409
+
410
+ # Initialize model with parameters
411
+ self.model = model_class(**model_params)
412
+
413
+ # Move model to device
414
+ try:
415
+ self.model = self.model.to(device)
416
+ except Exception as e:
417
+ raise RuntimeError(f"Failed to move model to device {device}: {str(e)}")
418
+
419
+ # Load state dict if provided in checkpoint
420
+ if checkpoint is not None:
421
+ try:
422
+ self.model.load_state_dict(checkpoint["model_state_dict"])
423
+ except Exception as e:
424
+ raise ValueError(f"Failed to load model state dictionary: {str(e)}")
425
+
426
+ except Exception as e:
427
+ raise RuntimeError(f"Model initialization failed: {str(e)}")
428
+
429
+ def _load_default_criterion(self):
430
+ """Sets default criterion based on task_type."""
431
+ if self.task_type == "regression":
432
+ return torch.nn.L1Loss()
433
+ elif self.task_type == "classification":
434
+ return torch.nn.BCEWithLogitsLoss()
435
+ else:
436
+ warnings.warn(
437
+ "Unknown task type. Using MSE Loss as the default criterion. "
438
+ "Please specify a valid task type ('regression' or 'classification') or provide a criterion."
439
+ )
440
+ return torch.nn.L1Loss()
441
+
442
+ def _validate_inputs(
443
+ self, X: List[str], y: Optional[Union[List, np.ndarray]] = None, return_rdkit_mol: bool = True
444
+ ) -> Tuple[Union[List[str], List["Mol"]], Optional[np.ndarray]]:
445
+ """Validate input SMILES strings and target values.
446
+
447
+ Parameters
448
+ ----------
449
+ X : List[str]
450
+ List of SMILES strings
451
+ y : Union[List, np.ndarray], optional
452
+ Target values. Can be:
453
+ - 1D array/list for single task (num_task must be 1)
454
+ - 2D array/list for multiple tasks
455
+ return_rdkit_mol : bool, default=False
456
+ If True, returns RDKit Mol objects instead of SMILES strings
457
+
458
+ Returns
459
+ -------
460
+ X : Union[List[str], List[Mol]]
461
+ Validated SMILES strings or RDKit Mol objects if return_rdkit_mol=True
462
+ y : np.ndarray or None
463
+ Validated target values as numpy array, reshaped to 2D if needed
464
+
465
+ Raises
466
+ ------
467
+ ValueError
468
+ If inputs don't meet requirements
469
+ """
470
+ # Validate X
471
+ if not isinstance(X, list):
472
+ raise ValueError("X must be a list of SMILES strings")
473
+
474
+ if not all(isinstance(s, str) for s in X):
475
+ raise ValueError("All elements in X must be strings")
476
+
477
+ # Validate SMILES using RDKit and store Mol objects
478
+ invalid_smiles = []
479
+ rdkit_mols = []
480
+ for i, smiles in enumerate(X):
481
+ is_valid, error_msg, mol = self._validate_smiles(smiles, i)
482
+ if not is_valid:
483
+ invalid_smiles.append(error_msg)
484
+ else:
485
+ rdkit_mols.append(mol)
486
+
487
+ if invalid_smiles:
488
+ raise ValueError("Invalid SMILES found:\n" + "\n".join(invalid_smiles))
489
+
490
+ # Validate y if provided
491
+ if y is not None:
492
+ try:
493
+ # Convert to numpy array if it's a list
494
+ y = np.asarray(y, dtype=np.float32)
495
+ except Exception as e:
496
+ raise ValueError(f"Could not convert y to numpy array: {str(e)}")
497
+
498
+ # Handle 1D array case
499
+ if len(y.shape) == 1:
500
+ if self.num_task != 1:
501
+ raise ValueError(
502
+ f"1D target array provided but num_task is {self.num_task}. "
503
+ "For multiple tasks, y must be 2D array."
504
+ )
505
+ # Reshape to 2D
506
+ y = y.reshape(-1, 1)
507
+
508
+ # Validate dimensions
509
+ if len(y.shape) != 2:
510
+ raise ValueError(
511
+ f"y must be 1D (for single task) or 2D (for multiple tasks), "
512
+ f"got shape {y.shape}"
513
+ )
514
+
515
+ # Check sample dimension
516
+ if y.shape[0] != len(X):
517
+ raise ValueError(
518
+ f"First dimension of y ({y.shape[0]}) must match " f"length of X ({len(X)})"
519
+ )
520
+
521
+ # Check task dimension
522
+ if y.shape[1] != self.num_task:
523
+ raise ValueError(
524
+ f"Number of tasks in y ({y.shape[1]}) must match "
525
+ f"num_task ({self.num_task})"
526
+ )
527
+
528
+ # Handle infinite values
529
+ inf_mask = ~np.isfinite(y)
530
+ if np.any(inf_mask):
531
+ inf_indices = np.where(inf_mask)
532
+ warnings.warn(
533
+ f"Infinite values found in y at indices: {list(zip(*inf_indices))}. "
534
+ "Converting to NaN.",
535
+ RuntimeWarning,
536
+ )
537
+ y = y.astype(float) # Ensure float type for NaN support
538
+ y[inf_mask] = np.nan
539
+
540
+ # Check for NaN values after conversion
541
+ nan_mask = np.isnan(y)
542
+ if np.any(nan_mask):
543
+ # Count NaNs per task
544
+ nan_counts = np.sum(nan_mask, axis=0)
545
+ nan_percentages = (nan_counts / len(X)) * 100
546
+
547
+ # Create detailed warning message
548
+ task_warnings = []
549
+ for task_idx, (count, percentage) in enumerate(zip(nan_counts, nan_percentages)):
550
+ if count > 0:
551
+ task_warnings.append(f"Task {task_idx}: {count} NaNs ({percentage:.1f}%)")
552
+
553
+ warnings.warn(
554
+ "NaN values present in y:\n"
555
+ + "\n".join(task_warnings)
556
+ + "\nSamples with NaN values may be ignored during training.",
557
+ RuntimeWarning,
558
+ )
559
+
560
+ return rdkit_mols if return_rdkit_mol else X, y
561
+
562
+ def _validate_smiles(self, smiles: str, idx: int) -> Tuple[bool, Optional[str], Optional["Mol"]]:
563
+ """Validate a single SMILES string using RDKit.
564
+
565
+ Parameters
566
+ ----------
567
+ smiles : str
568
+ SMILES string to validate
569
+ idx : int
570
+ Index of the SMILES in the input list (for error reporting)
571
+
572
+ Returns
573
+ -------
574
+ is_valid : bool
575
+ Whether the SMILES is valid
576
+ error_msg : str or None
577
+ Error message if invalid, None if valid
578
+ mol : Mol or None
579
+ RDKit Mol object if valid, None if invalid
580
+ """
581
+ if not smiles or not smiles.strip():
582
+ return False, f"Empty SMILES at index {idx}", None
583
+
584
+ try:
585
+ mol = Chem.MolFromSmiles(smiles)
586
+ if mol is None:
587
+ return False, f"Invalid SMILES structure at index {idx}: {smiles}", None
588
+ return True, None, mol
589
+ except Exception as e:
590
+ return False, f"RDKit error at index {idx}: {str(e)}", None
591
+
592
+ @staticmethod
593
+ def _get_param_names() -> List[str]:
594
+ """Get parameter names for the estimator."""
595
+ return ["num_task", "task_type", "is_fitted_"]
596
+
597
+ def save_model(self, path: str) -> None:
598
+ """Save the model to disk.
599
+
600
+ Parameters
601
+ ----------
602
+ path : str
603
+ Path where to save the model
604
+ """
605
+ if not self.is_fitted_:
606
+ raise ValueError("Model must be fitted before saving")
607
+
608
+ if not path.endswith((".pt", ".pth")):
609
+ raise ValueError("Save path should end with '.pt' or '.pth'")
610
+
611
+ # Create directory if it doesn't exist
612
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
613
+
614
+ model_name = os.path.splitext(os.path.basename(path))[0]
615
+ save_dict = {
616
+ "model_state_dict": self.model.state_dict(),
617
+ "hyperparameters": self.get_params(),
618
+ "model_name": model_name,
619
+ "date_saved": datetime.datetime.now().isoformat(),
620
+ "version": getattr(self, "__version__", "1.0.0"),
621
+ }
622
+ torch.save(save_dict, path)
623
+
624
+ def load_model(self, path: str, repo_id: Optional[str] = None):
625
+ """Load a saved model from disk or download from HuggingFace hub.
626
+
627
+ Parameters
628
+ ----------
629
+ path : str
630
+ Path to the saved model file or desired local path for downloaded model
631
+ repo_id : str, optional
632
+ HuggingFace model repository ID (e.g., 'username/model-name')
633
+ If provided and local path doesn't exist, will attempt to download from hub
634
+
635
+ Returns
636
+ -------
637
+ self : GNNMolecularPredictor
638
+ Updated model instance with loaded weights and parameters
639
+
640
+ Raises
641
+ ------
642
+ FileNotFoundError
643
+ If the model file doesn't exist locally and no repo is provided,
644
+ or if download from repo fails
645
+ ValueError
646
+ If the saved file is corrupted or incompatible
647
+ """
648
+ # First try to load from local path
649
+ if os.path.exists(path):
650
+ try:
651
+ checkpoint = torch.load(path, map_location=self.device)
652
+ except Exception as e:
653
+ raise ValueError(f"Error loading model from {path}: {str(e)}")
654
+
655
+ # If local file doesn't exist and repo is provided, try downloading
656
+ elif repo_id is not None:
657
+ try:
658
+ from huggingface_hub import hf_hub_download
659
+
660
+ # Create directory if it doesn't exist
661
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
662
+
663
+ # Download from HuggingFace using specified parameters
664
+ model_name = os.path.splitext(os.path.basename(path))[0]
665
+
666
+ # Download model file
667
+ downloaded_path = hf_hub_download(
668
+ repo_id=repo_id,
669
+ filename=f"{model_name}.pt",
670
+ local_dir=os.path.dirname(path),
671
+ # local_dir_use_symlinks=False
672
+ )
673
+ hf_hub_download(
674
+ repo_id=repo_id,
675
+ filename="config.json",
676
+ local_dir=os.path.dirname(path),
677
+ )
678
+ # Load the downloaded model
679
+ checkpoint = torch.load(downloaded_path, map_location=self.device)
680
+
681
+ except Exception as e:
682
+ if os.path.exists(path):
683
+ os.remove(path) # Clean up partial downloads
684
+ raise FileNotFoundError(
685
+ f"Failed to download model from repository '{repo_id}': {str(e)}"
686
+ )
687
+
688
+ # If neither local file exists nor repo provided
689
+ else:
690
+ raise FileNotFoundError(f"No model file found at '{path}' and no repository provided")
691
+
692
+ try:
693
+ # Validate checkpoint contents
694
+ required_keys = {
695
+ "model_state_dict",
696
+ "hyperparameters",
697
+ "model_name",
698
+ }
699
+ if not all(key in checkpoint for key in required_keys):
700
+ missing_keys = required_keys - set(checkpoint.keys())
701
+ raise ValueError(f"Checkpoint missing required keys: {missing_keys}")
702
+
703
+ # Validate and track all hyperparameter comparisons
704
+ parameter_status = []
705
+ for key, new_value in checkpoint["hyperparameters"].items():
706
+ if hasattr(self, key):
707
+ old_value = getattr(self, key)
708
+ is_changed = old_value != new_value
709
+ parameter_status.append(
710
+ {
711
+ "Parameter": key,
712
+ "Old Value": old_value,
713
+ "New Value": new_value,
714
+ "Status": "Changed" if is_changed else "Unchanged",
715
+ }
716
+ )
717
+ if is_changed:
718
+ setattr(self, key, new_value)
719
+
720
+ # Print comprehensive parameter status table
721
+ if parameter_status:
722
+ print("\nHyperparameter Status:")
723
+ print("-" * 80)
724
+ print(f"{'Parameter':<20} {'Old Value':<20} {'New Value':<20} {'Status':<10}")
725
+ print("-" * 80)
726
+
727
+ # Sort by status (Changed first, then Unchanged)
728
+ parameter_status.sort(key=lambda x: (x["Status"] != "Changed", x["Parameter"]))
729
+
730
+ for param in parameter_status:
731
+ status_color = (
732
+ "\033[91m" if param["Status"] == "Changed" else "\033[92m"
733
+ ) # Red for changed, green for unchanged
734
+ print(
735
+ f"{param['Parameter']:<20} "
736
+ f"{str(param['Old Value']):<20} "
737
+ f"{str(param['New Value']):<20} "
738
+ f"{status_color}{param['Status']}\033[0m"
739
+ )
740
+ print("-" * 80)
741
+
742
+ # Print summary
743
+ changes_count = sum(1 for p in parameter_status if p["Status"] == "Changed")
744
+ print(
745
+ f"\nSummary: {changes_count} parameters changed, "
746
+ f"{len(parameter_status) - changes_count} unchanged"
747
+ )
748
+
749
+ self._initialize_model(self.model_class, self.device, checkpoint)
750
+ self.model_name = checkpoint["model_name"]
751
+ self.is_fitted_ = True
752
+
753
+ # Move model to correct device
754
+ self.model = self.model.to(self.device)
755
+
756
+ print(f"Model successfully loaded from {'repository' if repo_id else 'local path'}")
757
+ return self
758
+
759
+ except Exception as e:
760
+ raise ValueError(f"Error loading model checkpoint: {str(e)}")
761
+
762
+ finally:
763
+ # Clean up any temporary files
764
+ if (
765
+ repo_id is not None
766
+ and os.path.exists(downloaded_path)
767
+ and os.path.abspath(downloaded_path) != os.path.abspath(path)
768
+ ):
769
+ try:
770
+ os.remove(downloaded_path)
771
+ except Exception:
772
+ pass
773
+
774
+ def push_to_huggingface(
775
+ self,
776
+ repo_id: str,
777
+ task_id: str = "default",
778
+ metrics: Optional[Dict[str, float]] = None,
779
+ commit_message: str = "Update model",
780
+ token: Optional[str] = None,
781
+ private: bool = False,
782
+ ) -> None:
783
+ """Push a task-specific model checkpoint to Hugging Face Hub.
784
+
785
+ Parameters
786
+ ----------
787
+ repo_id : str
788
+ The ID of the model repository (format: '<username>/<model_name>')
789
+ task_id : str
790
+ Task identifier (e.g., 'O2', 'N2')
791
+ metrics : Optional[Dict[str, float]]
792
+ Optional dictionary of metric names and values for this task
793
+ commit_message : str, default="Update model"
794
+ Message to commit with
795
+ token : Optional[str], default=None
796
+ HuggingFace token
797
+ private : bool, default=False
798
+ Whether to make the repository private
799
+ """
800
+ try:
801
+ from huggingface_hub import HfApi, create_repo, metadata_update
802
+ from .utils.hf_hub import merge_task_configs, get_existing_repo_data, create_model_card
803
+ except ImportError:
804
+ raise ImportError(
805
+ "huggingface_hub package is required to push to Hugging Face Hub. "
806
+ "Install it with: pip install huggingface_hub"
807
+ )
808
+
809
+ # Validate inputs
810
+ if not isinstance(repo_id, str) or "/" not in repo_id:
811
+ raise ValueError("repo_id must be in format '<username>/<model_name>'")
812
+ if not task_id:
813
+ raise ValueError("task_id must be provided")
814
+
815
+ self._check_is_fitted()
816
+
817
+ try:
818
+ # Initialize API and check existing repo
819
+ api = HfApi(token=token)
820
+ repo_exists, existing_config, existing_readme = get_existing_repo_data(repo_id, token)
821
+
822
+ # Create repo if it doesn't exist
823
+ create_repo(repo_id, private=private, token=token, exist_ok=True)
824
+
825
+ with tempfile.TemporaryDirectory() as tmp_dir:
826
+ # Save task-specific model checkpoint
827
+ model_path = os.path.join(tmp_dir, f"{self.model_name}.pt")
828
+ self.save_model(model_path)
829
+
830
+ # Prepare task-specific configuration
831
+ task_config = {
832
+ "task_type": self.task_type,
833
+ "config": sanitize_config(self.get_params(deep=True)),
834
+ "metrics": metrics or {},
835
+ }
836
+
837
+ # Create or update config
838
+ base_config = (
839
+ {
840
+ "model_name": self.model_name,
841
+ "framework": "torch_molecule",
842
+ "date_created": existing_config.get(
843
+ "date_created", datetime.datetime.now().isoformat()
844
+ ),
845
+ }
846
+ if not repo_exists
847
+ else existing_config
848
+ )
849
+
850
+ # Merge task configuration
851
+ final_config = merge_task_configs(
852
+ task_id=task_id,
853
+ existing_config=base_config,
854
+ new_task_config=task_config,
855
+ num_params=sum(p.numel() for p in self.model.parameters()),
856
+ )
857
+
858
+ # Save config
859
+ config_path = os.path.join(tmp_dir, "config.json")
860
+ with open(config_path, "w") as f:
861
+ json.dump(final_config, f, indent=2)
862
+
863
+ # Create and save README
864
+ readme_content = create_model_card(
865
+ model_class=self.__class__.__name__,
866
+ model_name=self.model_name,
867
+ tasks_config=final_config.get("tasks", {}),
868
+ model_config=final_config,
869
+ repo_id=repo_id,
870
+ existing_readme=existing_readme,
871
+ )
872
+
873
+ readme_path = os.path.join(tmp_dir, "README.md")
874
+ with open(readme_path, "w") as f:
875
+ f.write(readme_content)
876
+
877
+ # Push files to hub
878
+ api.upload_folder(
879
+ repo_id=repo_id,
880
+ folder_path=tmp_dir,
881
+ commit_message=f"{commit_message} - Task: {task_id}",
882
+ )
883
+
884
+ # Update repository metadata
885
+ metadata = {
886
+ "tags": [
887
+ "torch_molecule",
888
+ "molecular-property-prediction",
889
+ ],
890
+ "library_name": "torch_molecule",
891
+ "pipeline_tag": "graph-ml",
892
+ }
893
+ metadata_update(repo_id=repo_id, metadata=metadata, token=token, overwrite=True)
894
+
895
+ task_config = final_config["tasks"][task_id]
896
+ print(f"Successfully pushed model for task {task_id} to {repo_id}")
897
+ print(f"Task version: {task_config['current_version']}")
898
+ if metrics:
899
+ print("Metrics:")
900
+ for metric, value in metrics.items():
901
+ print(f" {metric}: {value:.4f}")
902
+
903
+ except Exception as e:
904
+ raise RuntimeError(f"Failed to push to Hugging Face Hub: {str(e)}")