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.
- torch_molecule/__init__.py +70 -0
- torch_molecule/base/__init__.py +8 -0
- torch_molecule/base/base.py +396 -0
- torch_molecule/base/encoder.py +44 -0
- torch_molecule/base/generator.py +24 -0
- torch_molecule/base/old_encoder.py +322 -0
- torch_molecule/base/old_predictor.py +904 -0
- torch_molecule/base/old_predictor2.py +436 -0
- torch_molecule/base/predictor.py +112 -0
- torch_molecule/encoder/attrmask/__init__.py +3 -0
- torch_molecule/encoder/attrmask/model.py +113 -0
- torch_molecule/encoder/attrmask/modeling_attrmask.py +304 -0
- torch_molecule/encoder/constant.py +13 -0
- torch_molecule/encoder/contextpred/__init__.py +3 -0
- torch_molecule/encoder/contextpred/model.py +207 -0
- torch_molecule/encoder/contextpred/modeling_contextpred.py +299 -0
- torch_molecule/encoder/contextpred/utils.py +206 -0
- torch_molecule/encoder/edgepred/__init__.py +3 -0
- torch_molecule/encoder/edgepred/model.py +104 -0
- torch_molecule/encoder/edgepred/modeling_edgepred.py +285 -0
- torch_molecule/encoder/graphmae/__init__.py +3 -0
- torch_molecule/encoder/graphmae/dataloader.py +206 -0
- torch_molecule/encoder/graphmae/model.py +137 -0
- torch_molecule/encoder/graphmae/modeling_graphmae.py +323 -0
- torch_molecule/encoder/infograph/__init__.py +3 -0
- torch_molecule/encoder/infograph/loss_function.py +128 -0
- torch_molecule/encoder/infograph/model.py +92 -0
- torch_molecule/encoder/infograph/modeling_infograph.py +301 -0
- torch_molecule/encoder/infograph/module.py +31 -0
- torch_molecule/encoder/moama/__init__.py +3 -0
- torch_molecule/encoder/moama/model.py +131 -0
- torch_molecule/encoder/moama/modeling_moama.py +307 -0
- torch_molecule/encoder/moama/utils.py +143 -0
- torch_molecule/encoder/pretrained/__init__.py +3 -0
- torch_molecule/encoder/pretrained/modeling_pretrained.py +274 -0
- torch_molecule/encoder/supervised/__init__.py +3 -0
- torch_molecule/encoder/supervised/model.py +97 -0
- torch_molecule/encoder/supervised/modeling_supervised.py +354 -0
- torch_molecule/generator/digress/__init__.py +3 -0
- torch_molecule/generator/digress/diffusion.py +194 -0
- torch_molecule/generator/digress/modeling_digress.py +503 -0
- torch_molecule/generator/digress/transformer.py +360 -0
- torch_molecule/generator/digress/utils.py +196 -0
- torch_molecule/generator/gdss/__init__.py +3 -0
- torch_molecule/generator/gdss/layers.py +257 -0
- torch_molecule/generator/gdss/model.py +204 -0
- torch_molecule/generator/gdss/modeling_gdss.py +474 -0
- torch_molecule/generator/gdss/sde.py +334 -0
- torch_molecule/generator/gdss/solver.py +227 -0
- torch_molecule/generator/gdss/toremove/ScoreNetworkA.py +167 -0
- torch_molecule/generator/gdss/toremove/ScoreNetworkX.py +85 -0
- torch_molecule/generator/gdss/utils.py +240 -0
- torch_molecule/generator/graph_dit/__init__.py +3 -0
- torch_molecule/generator/graph_dit/diffusion.py +188 -0
- torch_molecule/generator/graph_dit/modeling_graph_dit.py +581 -0
- torch_molecule/generator/graph_dit/transformer.py +207 -0
- torch_molecule/generator/graph_dit/utils.py +220 -0
- torch_molecule/generator/graph_ga/__init__.py +3 -0
- torch_molecule/generator/graph_ga/crossover.py +182 -0
- torch_molecule/generator/graph_ga/modeling_graph_ga.py +310 -0
- torch_molecule/generator/graph_ga/mutate.py +136 -0
- torch_molecule/generator/graph_ga/oracle.py +95 -0
- torch_molecule/generator/jtvae/__init__.py +3 -0
- torch_molecule/generator/jtvae/jtnn/chemutils.py +450 -0
- torch_molecule/generator/jtvae/jtnn/datautils.py +127 -0
- torch_molecule/generator/jtvae/jtnn/jtmpn.py +140 -0
- torch_molecule/generator/jtvae/jtnn/jtnn_dec.py +350 -0
- torch_molecule/generator/jtvae/jtnn/jtnn_enc.py +131 -0
- torch_molecule/generator/jtvae/jtnn/mol_tree.py +111 -0
- torch_molecule/generator/jtvae/jtnn/mpn.py +129 -0
- torch_molecule/generator/jtvae/jtnn/nnutils.py +66 -0
- torch_molecule/generator/jtvae/jtnn/vocab.py +31 -0
- torch_molecule/generator/jtvae/jtnn_vae.py +246 -0
- torch_molecule/generator/jtvae/modeling_jtvae.py +247 -0
- torch_molecule/generator/lstm/__init__.py +3 -0
- torch_molecule/generator/lstm/action_sampler.py +91 -0
- torch_molecule/generator/lstm/lstm.py +59 -0
- torch_molecule/generator/lstm/modeling_lstm.py +263 -0
- torch_molecule/generator/lstm/smiles_char_dict.py +123 -0
- torch_molecule/generator/lstm/utils.py +62 -0
- torch_molecule/generator/molgpt/__init__.py +3 -0
- torch_molecule/generator/molgpt/dataset.py +87 -0
- torch_molecule/generator/molgpt/gpt.py +251 -0
- torch_molecule/generator/molgpt/modeling_molgpt.py +427 -0
- torch_molecule/generator/pretrained/modeling_pretrained.py +1 -0
- torch_molecule/nn/__init__.py +16 -0
- torch_molecule/nn/attention.py +63 -0
- torch_molecule/nn/embedder.py +183 -0
- torch_molecule/nn/gnn.py +631 -0
- torch_molecule/nn/mlp.py +34 -0
- torch_molecule/predictor/bfgnn/__init__.py +3 -0
- torch_molecule/predictor/bfgnn/model.py +116 -0
- torch_molecule/predictor/bfgnn/modeling_bfgnn.py +81 -0
- torch_molecule/predictor/dir/__init__.py +3 -0
- torch_molecule/predictor/dir/model.py +235 -0
- torch_molecule/predictor/dir/modeling_dir.py +161 -0
- torch_molecule/predictor/dir/utils.py +40 -0
- torch_molecule/predictor/gnn/__init__.py +3 -0
- torch_molecule/predictor/gnn/model.py +114 -0
- torch_molecule/predictor/gnn/modeling_gnn.py +706 -0
- torch_molecule/predictor/gnn/old_modeling_gnn.py +707 -0
- torch_molecule/predictor/grea/__init__.py +3 -0
- torch_molecule/predictor/grea/model.py +190 -0
- torch_molecule/predictor/grea/modeling_grea.py +107 -0
- torch_molecule/predictor/grea/old1_features.py +229 -0
- torch_molecule/predictor/grea/old1_modeling_grea.py +493 -0
- torch_molecule/predictor/grea/utils.py +97 -0
- torch_molecule/predictor/grin/__init__.py +3 -0
- torch_molecule/predictor/grin/model.py +116 -0
- torch_molecule/predictor/grin/modeling_grin.py +91 -0
- torch_molecule/predictor/irm/__init__.py +3 -0
- torch_molecule/predictor/irm/model.py +127 -0
- torch_molecule/predictor/irm/modeling_irm.py +179 -0
- torch_molecule/predictor/lstm/__init__.py +3 -0
- torch_molecule/predictor/lstm/model.py +145 -0
- torch_molecule/predictor/lstm/modeling_lstm.py +627 -0
- torch_molecule/predictor/lstm/token_from_smiles.py +85 -0
- torch_molecule/predictor/old_modeling_graph.py +650 -0
- torch_molecule/predictor/rpgnn/__init__.py +3 -0
- torch_molecule/predictor/rpgnn/model.py +185 -0
- torch_molecule/predictor/rpgnn/modeling_rpgnn.py +76 -0
- torch_molecule/predictor/sgir/__init__.py +3 -0
- torch_molecule/predictor/sgir/modeling_sgir.py +260 -0
- torch_molecule/predictor/sgir/strategy.py +266 -0
- torch_molecule/predictor/smiles_transformer/__init__.py +3 -0
- torch_molecule/predictor/smiles_transformer/model.py +424 -0
- torch_molecule/predictor/smiles_transformer/modeling_transformer.py +252 -0
- torch_molecule/predictor/ssr/__init__.py +3 -0
- torch_molecule/predictor/ssr/model.py +283 -0
- torch_molecule/predictor/ssr/modeling_ssr.py +267 -0
- torch_molecule/predictor/ssr/utils.py +185 -0
- torch_molecule/utils/__init__.py +31 -0
- torch_molecule/utils/checker.py +158 -0
- torch_molecule/utils/checkpoint.py +294 -0
- torch_molecule/utils/format.py +140 -0
- torch_molecule/utils/generic/metrics.py +530 -0
- torch_molecule/utils/generic/pseudo_tasks.py +11 -0
- torch_molecule/utils/generic/weights.py +30 -0
- torch_molecule/utils/graph/features.py +178 -0
- torch_molecule/utils/graph/graph_from_smiles.py +134 -0
- torch_molecule/utils/graph/graph_to_smiles.py +312 -0
- torch_molecule/utils/hf.py +232 -0
- torch_molecule/utils/search.py +64 -0
- torch_molecule-0.1.0.dev2.dist-info/METADATA +243 -0
- torch_molecule-0.1.0.dev2.dist-info/RECORD +148 -0
- torch_molecule-0.1.0.dev2.dist-info/WHEEL +5 -0
- torch_molecule-0.1.0.dev2.dist-info/licenses/LICENSE +21 -0
- 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)}")
|