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,650 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import numpy as np
|
|
3
|
+
from tqdm import tqdm
|
|
4
|
+
from typing import Optional, Union, Dict, Any, Tuple, List
|
|
5
|
+
import warnings
|
|
6
|
+
import datetime
|
|
7
|
+
|
|
8
|
+
import torch
|
|
9
|
+
from torch_geometric.loader import DataLoader
|
|
10
|
+
from torch_geometric.data import Data
|
|
11
|
+
|
|
12
|
+
from .graph_utils.features import smiles_to_graph
|
|
13
|
+
from ..base import BaseMolecularPredictor
|
|
14
|
+
|
|
15
|
+
class MolecularGraphPredictor(BaseMolecularPredictor):
|
|
16
|
+
"""This predictor implements a GNN model for molecular property prediction tasks.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
num_tasks: int = 1,
|
|
22
|
+
task_type: str = "classification",
|
|
23
|
+
num_layer: int = 5,
|
|
24
|
+
emb_dim: int = 300,
|
|
25
|
+
gnn_type: str = "gin-virtual",
|
|
26
|
+
drop_ratio: float = 0.5,
|
|
27
|
+
norm_layer: str = "batch_norm",
|
|
28
|
+
batch_size: int = 32,
|
|
29
|
+
epochs: int = 100,
|
|
30
|
+
learning_rate: float = 0.001,
|
|
31
|
+
weight_decay: float = 0.0,
|
|
32
|
+
patience: int = 50,
|
|
33
|
+
device: Optional[str] = None,
|
|
34
|
+
use_lr_scheduler: bool = True,
|
|
35
|
+
scheduler_factor: float = 0.5,
|
|
36
|
+
scheduler_patience: int = 5,
|
|
37
|
+
grad_clip_value: Optional[float] = None,
|
|
38
|
+
verbose: bool = False,
|
|
39
|
+
model_name: str = "graph_predictor",
|
|
40
|
+
criterion = None,
|
|
41
|
+
):
|
|
42
|
+
super().__init__(num_tasks=num_tasks, task_type=task_type)
|
|
43
|
+
|
|
44
|
+
# Model hyperparameters
|
|
45
|
+
self.num_layer = num_layer
|
|
46
|
+
self.emb_dim = emb_dim
|
|
47
|
+
self.gnn_type = gnn_type
|
|
48
|
+
self.drop_ratio = drop_ratio
|
|
49
|
+
self.norm_layer = norm_layer
|
|
50
|
+
|
|
51
|
+
# Training parameters
|
|
52
|
+
self.batch_size = batch_size
|
|
53
|
+
self.epochs = epochs
|
|
54
|
+
self.learning_rate = learning_rate
|
|
55
|
+
self.weight_decay = weight_decay
|
|
56
|
+
self.patience = patience
|
|
57
|
+
self.use_lr_scheduler = use_lr_scheduler
|
|
58
|
+
self.scheduler_factor = scheduler_factor
|
|
59
|
+
self.scheduler_patience = scheduler_patience
|
|
60
|
+
self.grad_clip_value = grad_clip_value
|
|
61
|
+
self.criterion = criterion if criterion is not None else self._load_default_criterion()
|
|
62
|
+
|
|
63
|
+
# Set device
|
|
64
|
+
self.device = (
|
|
65
|
+
torch.device(device)
|
|
66
|
+
if device
|
|
67
|
+
else torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Initialize model
|
|
71
|
+
self.model = None
|
|
72
|
+
|
|
73
|
+
self.fitting_loss = []
|
|
74
|
+
self.fitting_loss_mean = float("inf")
|
|
75
|
+
self.fitting_epoch = 0
|
|
76
|
+
self.verbose = verbose
|
|
77
|
+
self.model_name = model_name
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _get_param_names() -> List[str]:
|
|
81
|
+
"""Get parameter names for the estimator.
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
List[str]
|
|
86
|
+
List of parameter names that can be used for model configuration.
|
|
87
|
+
"""
|
|
88
|
+
return [
|
|
89
|
+
# Model hyperparameters
|
|
90
|
+
"num_tasks",
|
|
91
|
+
"task_type",
|
|
92
|
+
"num_layer",
|
|
93
|
+
"emb_dim",
|
|
94
|
+
"gnn_type",
|
|
95
|
+
"drop_ratio",
|
|
96
|
+
"norm_layer",
|
|
97
|
+
# Training parameters
|
|
98
|
+
"batch_size",
|
|
99
|
+
"epochs",
|
|
100
|
+
"learning_rate",
|
|
101
|
+
"weight_decay",
|
|
102
|
+
"patience",
|
|
103
|
+
"device",
|
|
104
|
+
"grad_clip_value",
|
|
105
|
+
"criterion",
|
|
106
|
+
# Scheduler parameters
|
|
107
|
+
"use_lr_scheduler",
|
|
108
|
+
"scheduler_factor",
|
|
109
|
+
"scheduler_patience",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def _get_search_parameters(self) -> Dict[str, Union[Tuple[float, float], Tuple[int, int]]]:
|
|
113
|
+
"""Get the default hyperparameter search space for auto_fit.
|
|
114
|
+
|
|
115
|
+
Returns
|
|
116
|
+
-------
|
|
117
|
+
Dict[str, Union[Tuple[float, float], Tuple[int, int]]]
|
|
118
|
+
Dictionary containing parameter names and their search ranges:
|
|
119
|
+
- num_layer: Number of GNN layers (2 to 8)
|
|
120
|
+
- emb_dim: Embedding dimension (64 to 512)
|
|
121
|
+
- drop_ratio: Dropout ratio (0.0 to 0.7)
|
|
122
|
+
- learning_rate: Learning rate for optimization (1e-4 to 1e-2)
|
|
123
|
+
- weight_decay: L2 regularization factor (1e-6 to 1e-3)
|
|
124
|
+
"""
|
|
125
|
+
return {
|
|
126
|
+
'num_layer': (2, 8),
|
|
127
|
+
'emb_dim': (64, 512),
|
|
128
|
+
'drop_ratio': (0.0, 0.7),
|
|
129
|
+
'learning_rate': (1e-4, 1e-2),
|
|
130
|
+
'weight_decay': (1e-6, 1e-3),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
def _convert_to_pytorch_data(self, X, y=None):
|
|
134
|
+
"""Convert numpy arrays to PyTorch data format.
|
|
135
|
+
|
|
136
|
+
Should be implemented by child classes to handle specific molecular
|
|
137
|
+
representations (e.g., SMILES to graphs, conformers to graphs, etc.)
|
|
138
|
+
"""
|
|
139
|
+
if self.verbose:
|
|
140
|
+
iterator = tqdm(enumerate(X), desc="Converting molecules to graphs", total=len(X))
|
|
141
|
+
else:
|
|
142
|
+
iterator = enumerate(X)
|
|
143
|
+
|
|
144
|
+
pyg_graph_list = []
|
|
145
|
+
for idx, smiles in iterator:
|
|
146
|
+
if y is not None:
|
|
147
|
+
properties = y[idx]
|
|
148
|
+
else:
|
|
149
|
+
properties = None
|
|
150
|
+
graph = smiles_to_graph(smiles, properties)
|
|
151
|
+
g = Data()
|
|
152
|
+
g.num_nodes = graph["num_nodes"]
|
|
153
|
+
g.edge_index = torch.from_numpy(graph["edge_index"])
|
|
154
|
+
|
|
155
|
+
del graph["num_nodes"]
|
|
156
|
+
del graph["edge_index"]
|
|
157
|
+
|
|
158
|
+
if graph["edge_feat"] is not None:
|
|
159
|
+
g.edge_attr = torch.from_numpy(graph["edge_feat"])
|
|
160
|
+
del graph["edge_feat"]
|
|
161
|
+
|
|
162
|
+
if graph["node_feat"] is not None:
|
|
163
|
+
g.x = torch.from_numpy(graph["node_feat"])
|
|
164
|
+
del graph["node_feat"]
|
|
165
|
+
|
|
166
|
+
if graph["y"] is not None:
|
|
167
|
+
g.y = torch.from_numpy(graph["y"])
|
|
168
|
+
del graph["y"]
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
g.fp = torch.tensor(graph["fp"], dtype=torch.int8).view(1, -1)
|
|
172
|
+
del graph["fp"]
|
|
173
|
+
except:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
pyg_graph_list.append(g)
|
|
177
|
+
|
|
178
|
+
return pyg_graph_list
|
|
179
|
+
|
|
180
|
+
def setup_optimizers(self) -> Tuple[torch.optim.Optimizer, Optional[Any]]:
|
|
181
|
+
"""Setup optimization components including optimizer and learning rate scheduler.
|
|
182
|
+
|
|
183
|
+
Returns
|
|
184
|
+
-------
|
|
185
|
+
Tuple[optim.Optimizer, Optional[Any]]
|
|
186
|
+
A tuple containing:
|
|
187
|
+
- The configured optimizer
|
|
188
|
+
- The learning rate scheduler (if enabled, else None)
|
|
189
|
+
|
|
190
|
+
Notes
|
|
191
|
+
-----
|
|
192
|
+
The scheduler returned can be either _LRScheduler or ReduceLROnPlateau,
|
|
193
|
+
hence we use Any as the type hint instead of optim.lr_scheduler._LRScheduler
|
|
194
|
+
"""
|
|
195
|
+
optimizer = torch.optim.Adam(
|
|
196
|
+
self.model.parameters(), lr=self.learning_rate, weight_decay=self.weight_decay
|
|
197
|
+
)
|
|
198
|
+
if self.grad_clip_value is not None:
|
|
199
|
+
for group in optimizer.param_groups:
|
|
200
|
+
group.setdefault("max_norm", self.grad_clip_value)
|
|
201
|
+
group.setdefault("norm_type", 2.0)
|
|
202
|
+
|
|
203
|
+
scheduler = None
|
|
204
|
+
if self.use_lr_scheduler:
|
|
205
|
+
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
|
|
206
|
+
optimizer,
|
|
207
|
+
mode="min",
|
|
208
|
+
factor=self.scheduler_factor,
|
|
209
|
+
patience=self.scheduler_patience,
|
|
210
|
+
min_lr=1e-6,
|
|
211
|
+
cooldown=0,
|
|
212
|
+
eps=1e-8,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return optimizer, scheduler
|
|
216
|
+
|
|
217
|
+
def auto_fit(
|
|
218
|
+
self,
|
|
219
|
+
X_train,
|
|
220
|
+
y_train,
|
|
221
|
+
search_parameters: dict = None,
|
|
222
|
+
n_trials: int = 10,
|
|
223
|
+
metric: str = "loss",
|
|
224
|
+
X_val=None,
|
|
225
|
+
y_val=None,
|
|
226
|
+
) -> "MolecularGraphPredictor":
|
|
227
|
+
"""Automatically find the best hyperparameters using Optuna optimization.
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
X_train : List[str]
|
|
232
|
+
Training data as SMILES strings
|
|
233
|
+
y_train : Union[List, np.ndarray]
|
|
234
|
+
Training labels
|
|
235
|
+
X_val : List[str], optional
|
|
236
|
+
Validation data
|
|
237
|
+
y_val : Union[List, np.ndarray], optional
|
|
238
|
+
Validation labels
|
|
239
|
+
search_parameters : dict, optional
|
|
240
|
+
Dictionary of parameters to search over
|
|
241
|
+
n_trials : int, default=100
|
|
242
|
+
Number of optimization trials
|
|
243
|
+
metric : str, default="rmse"
|
|
244
|
+
Metric to optimize ("rmse", "mae", "r2", or "loss")
|
|
245
|
+
|
|
246
|
+
Returns
|
|
247
|
+
-------
|
|
248
|
+
self : MolecularGraphPredictor
|
|
249
|
+
Fitted estimator
|
|
250
|
+
"""
|
|
251
|
+
import optuna
|
|
252
|
+
|
|
253
|
+
# Default search parameters
|
|
254
|
+
if search_parameters is None:
|
|
255
|
+
search_parameters = self._get_search_parameters()
|
|
256
|
+
else:
|
|
257
|
+
# Validate search parameters against allowed parameters
|
|
258
|
+
valid_params = set(self._get_param_names())
|
|
259
|
+
invalid_params = set(search_parameters.keys()) - valid_params
|
|
260
|
+
if invalid_params:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
f"Invalid search parameters: {invalid_params}. "
|
|
263
|
+
f"Valid parameters are: {valid_params}"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Variables to track best state
|
|
267
|
+
best_score = float('inf')
|
|
268
|
+
best_param_vector = None
|
|
269
|
+
best_trial_params = None
|
|
270
|
+
best_loss = None
|
|
271
|
+
best_epoch = None
|
|
272
|
+
|
|
273
|
+
def objective(trial):
|
|
274
|
+
nonlocal best_score, best_param_vector, best_trial_params, best_loss, best_epoch
|
|
275
|
+
|
|
276
|
+
# Define hyperparameters to optimize
|
|
277
|
+
params = {}
|
|
278
|
+
for param, value_range in search_parameters.items():
|
|
279
|
+
if isinstance(value_range, tuple):
|
|
280
|
+
if param in ['learning_rate', 'weight_decay']:
|
|
281
|
+
params[param] = trial.suggest_float(
|
|
282
|
+
param, value_range[0], value_range[1], log=True
|
|
283
|
+
)
|
|
284
|
+
elif param in ['drop_ratio', 'gamma']:
|
|
285
|
+
params[param] = trial.suggest_float(
|
|
286
|
+
param, value_range[0], value_range[1]
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
params[param] = trial.suggest_int(
|
|
290
|
+
param, value_range[0], value_range[1]
|
|
291
|
+
)
|
|
292
|
+
elif isinstance(value_range, list):
|
|
293
|
+
params[param] = trial.suggest_categorical(param, value_range)
|
|
294
|
+
|
|
295
|
+
# Update model parameters
|
|
296
|
+
self.set_params(**params)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
# Train model
|
|
300
|
+
self.fit(X_train, y_train, X_val, y_val)
|
|
301
|
+
|
|
302
|
+
if metric == "loss":
|
|
303
|
+
score = self.fitting_loss_mean
|
|
304
|
+
else:
|
|
305
|
+
y_eval = y_train
|
|
306
|
+
X_eval = X_train
|
|
307
|
+
eval_results = self.predict(X_eval)
|
|
308
|
+
y_pred = eval_results['prediction']
|
|
309
|
+
|
|
310
|
+
# Calculate metric
|
|
311
|
+
if metric == "rmse":
|
|
312
|
+
from sklearn.metrics import mean_squared_error
|
|
313
|
+
score = mean_squared_error(y_eval, y_pred, squared=False)
|
|
314
|
+
elif metric == "mae":
|
|
315
|
+
from sklearn.metrics import mean_absolute_error
|
|
316
|
+
score = mean_absolute_error(y_eval, y_pred)
|
|
317
|
+
elif metric == "r2":
|
|
318
|
+
from sklearn.metrics import r2_score
|
|
319
|
+
score = -r2_score(y_eval, y_pred) # Negative because Optuna minimizes
|
|
320
|
+
else:
|
|
321
|
+
raise ValueError(f"Unknown metric: {metric}")
|
|
322
|
+
|
|
323
|
+
# Update best state if current score is better
|
|
324
|
+
if score < best_score:
|
|
325
|
+
best_score = score
|
|
326
|
+
best_param_vector = torch.nn.utils.parameters_to_vector(self.model.parameters())
|
|
327
|
+
best_trial_params = params
|
|
328
|
+
best_loss = self.fitting_loss
|
|
329
|
+
best_epoch = self.fitting_epoch
|
|
330
|
+
|
|
331
|
+
print(f'done {score}', self)
|
|
332
|
+
return score
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
print(f"Trial failed with error: {str(e)}")
|
|
336
|
+
return float('inf')
|
|
337
|
+
|
|
338
|
+
# Create study
|
|
339
|
+
study = optuna.create_study(direction="minimize")
|
|
340
|
+
|
|
341
|
+
# Run optimization
|
|
342
|
+
study.optimize(
|
|
343
|
+
objective,
|
|
344
|
+
n_trials=n_trials,
|
|
345
|
+
catch=(Exception,),
|
|
346
|
+
show_progress_bar=True if self.verbose else False
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Load best parameters and state
|
|
350
|
+
if best_param_vector is not None:
|
|
351
|
+
# Update model hyperparameters
|
|
352
|
+
self.set_params(**best_trial_params)
|
|
353
|
+
# Load best model parameters
|
|
354
|
+
torch.nn.utils.vector_to_parameters(best_param_vector, self.model.parameters())
|
|
355
|
+
# Update training state
|
|
356
|
+
self.fitting_loss = best_loss
|
|
357
|
+
self.fitting_epoch = best_epoch
|
|
358
|
+
self.fitting_loss_mean = float(np.mean(best_loss))
|
|
359
|
+
self.is_fitted_ = True
|
|
360
|
+
else:
|
|
361
|
+
raise RuntimeError("No successful trials completed during optimization")
|
|
362
|
+
|
|
363
|
+
return self
|
|
364
|
+
|
|
365
|
+
def fit(self, X_train: List[str], y_train: Optional[Union[List, np.ndarray]], X_val=None, y_val=None) -> "MolecularGraphPredictor":
|
|
366
|
+
"""Fit the model to the data.
|
|
367
|
+
|
|
368
|
+
Parameters
|
|
369
|
+
----------
|
|
370
|
+
X_train / X_val : list of SMILES strings
|
|
371
|
+
Input molecular structures as SMILES strings
|
|
372
|
+
y_train / y_val : array-like,
|
|
373
|
+
Target values for property prediction
|
|
374
|
+
Returns
|
|
375
|
+
-------
|
|
376
|
+
self : object
|
|
377
|
+
Fitted estimator
|
|
378
|
+
"""
|
|
379
|
+
if self.model is None:
|
|
380
|
+
raise RuntimeError("Model not initialized")
|
|
381
|
+
|
|
382
|
+
self.model.initialize_parameters()
|
|
383
|
+
self.model = self.model.to(self.device)
|
|
384
|
+
# Setup training
|
|
385
|
+
optimizer, scheduler = self.setup_optimizers()
|
|
386
|
+
cnt_wait = 0
|
|
387
|
+
|
|
388
|
+
# Convert molecular data to PyTorch Geometric format
|
|
389
|
+
X_train, y_train = self._validate_inputs(X_train, y_train)
|
|
390
|
+
dataset = self._convert_to_pytorch_data(X_train, y_train)
|
|
391
|
+
loader = DataLoader(dataset, batch_size=self.batch_size, shuffle=False)
|
|
392
|
+
|
|
393
|
+
# Initialize best model state
|
|
394
|
+
self.fitting_loss = []
|
|
395
|
+
self.fitting_loss_mean = float("inf")
|
|
396
|
+
self.fitting_epoch = 0
|
|
397
|
+
best_param_vector = None
|
|
398
|
+
|
|
399
|
+
# Training loop
|
|
400
|
+
for epoch in range(self.epochs):
|
|
401
|
+
# Train for one epoch
|
|
402
|
+
losses = self.train_epoch(loader, optimizer)
|
|
403
|
+
loss_mean = np.mean(losses)
|
|
404
|
+
if scheduler:
|
|
405
|
+
scheduler.step(loss_mean)
|
|
406
|
+
|
|
407
|
+
# Update best model
|
|
408
|
+
if loss_mean < self.fitting_loss_mean:
|
|
409
|
+
self.fitting_epoch = epoch
|
|
410
|
+
self.fitting_loss = losses
|
|
411
|
+
self.fitting_loss_mean = loss_mean
|
|
412
|
+
best_param_vector = torch.nn.utils.parameters_to_vector(self.model.parameters())
|
|
413
|
+
cnt_wait = 0
|
|
414
|
+
else:
|
|
415
|
+
cnt_wait += 1
|
|
416
|
+
if cnt_wait > self.patience:
|
|
417
|
+
break
|
|
418
|
+
|
|
419
|
+
if best_param_vector is not None:
|
|
420
|
+
torch.nn.utils.vector_to_parameters(best_param_vector, self.model.parameters())
|
|
421
|
+
|
|
422
|
+
self.is_fitted_ = True
|
|
423
|
+
return self
|
|
424
|
+
|
|
425
|
+
def predict(self, X: List[str]) -> Dict[str, np.ndarray]:
|
|
426
|
+
"""Make predictions using the fitted model.
|
|
427
|
+
|
|
428
|
+
Parameters
|
|
429
|
+
----------
|
|
430
|
+
X : List[str]
|
|
431
|
+
List of SMILES strings to make predictions for
|
|
432
|
+
|
|
433
|
+
Returns
|
|
434
|
+
-------
|
|
435
|
+
Dict[str, np.ndarray]
|
|
436
|
+
Dictionary containing:
|
|
437
|
+
- 'prediction': Model predictions (shape: [n_samples, n_tasks])
|
|
438
|
+
|
|
439
|
+
"""
|
|
440
|
+
self._check_is_fitted()
|
|
441
|
+
|
|
442
|
+
# Convert to PyTorch Geometric format and create loader
|
|
443
|
+
X, _ = self._validate_inputs(X)
|
|
444
|
+
dataset = self._convert_to_pytorch_data(X)
|
|
445
|
+
loader = DataLoader(dataset, batch_size=self.batch_size, shuffle=False)
|
|
446
|
+
|
|
447
|
+
if self.model is None:
|
|
448
|
+
raise RuntimeError("Model not initialized")
|
|
449
|
+
# Make predictions
|
|
450
|
+
self.model = self.model.to(self.device)
|
|
451
|
+
self.model.eval()
|
|
452
|
+
predictions = []
|
|
453
|
+
with torch.no_grad():
|
|
454
|
+
for batch in loader:
|
|
455
|
+
batch = batch.to(self.device)
|
|
456
|
+
out = self.model(batch)
|
|
457
|
+
predictions.append(out["prediction"].cpu().numpy())
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
"prediction": np.concatenate(predictions, axis=0),
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
def train_epoch(self, train_loader, optimizer):
|
|
464
|
+
"""Training logic for one epoch.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
train_loader: DataLoader containing training data
|
|
468
|
+
optimizer: Optimizer instance for model parameter updates
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
list: List of loss values for each training step
|
|
472
|
+
"""
|
|
473
|
+
self.model.train()
|
|
474
|
+
losses = []
|
|
475
|
+
|
|
476
|
+
iterator = (
|
|
477
|
+
tqdm(train_loader, desc="Training", leave=False)
|
|
478
|
+
if self.verbose
|
|
479
|
+
else train_loader
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
for batch in iterator:
|
|
483
|
+
batch = batch.to(self.device)
|
|
484
|
+
optimizer.zero_grad()
|
|
485
|
+
|
|
486
|
+
# Forward pass and loss computation
|
|
487
|
+
loss = self.model.compute_loss(batch, self.criterion)
|
|
488
|
+
|
|
489
|
+
# Backward pass
|
|
490
|
+
loss.backward()
|
|
491
|
+
|
|
492
|
+
# Compute gradient norm if gradient clipping is enabled
|
|
493
|
+
if self.grad_clip_value is not None:
|
|
494
|
+
torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.grad_clip_value)
|
|
495
|
+
|
|
496
|
+
optimizer.step()
|
|
497
|
+
|
|
498
|
+
losses.append(loss.item())
|
|
499
|
+
|
|
500
|
+
# Update progress bar if using tqdm
|
|
501
|
+
if self.verbose:
|
|
502
|
+
iterator.set_postfix({"loss": f"{loss.item():.4f}"})
|
|
503
|
+
|
|
504
|
+
return losses
|
|
505
|
+
|
|
506
|
+
def save_model(self, path: str) -> None:
|
|
507
|
+
"""Save the model to disk.
|
|
508
|
+
|
|
509
|
+
Parameters
|
|
510
|
+
----------
|
|
511
|
+
path : str
|
|
512
|
+
Path where to save the model
|
|
513
|
+
"""
|
|
514
|
+
if not self.is_fitted_:
|
|
515
|
+
raise ValueError("Model must be fitted before saving")
|
|
516
|
+
|
|
517
|
+
if not path.endswith((".pt", ".pth")):
|
|
518
|
+
raise ValueError("Save path should end with '.pt' or '.pth'")
|
|
519
|
+
|
|
520
|
+
# Create directory if it doesn't exist
|
|
521
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
522
|
+
|
|
523
|
+
model_name = os.path.splitext(os.path.basename(path))[0]
|
|
524
|
+
save_dict = {
|
|
525
|
+
"model_state_dict": self.model.state_dict(),
|
|
526
|
+
"hyperparameters": self.get_params(),
|
|
527
|
+
"fitting_epoch": self.fitting_epoch,
|
|
528
|
+
"fitting_loss": self.fitting_loss,
|
|
529
|
+
"fitting_loss_mean": self.fitting_loss_mean,
|
|
530
|
+
"model_name": model_name,
|
|
531
|
+
"date_saved": datetime.datetime.now().isoformat(),
|
|
532
|
+
"version": getattr(self, "__version__", "1.0.0"),
|
|
533
|
+
}
|
|
534
|
+
torch.save(save_dict, path)
|
|
535
|
+
|
|
536
|
+
def load_model(self, path: str, repo: Optional[str] = None) -> "MolecularGraphPredictor":
|
|
537
|
+
"""Load a saved model from disk or download from HuggingFace hub.
|
|
538
|
+
|
|
539
|
+
Parameters
|
|
540
|
+
----------
|
|
541
|
+
path : str
|
|
542
|
+
Path to the saved model file or desired local path for downloaded model
|
|
543
|
+
repo : str, optional
|
|
544
|
+
HuggingFace model repository ID (e.g., 'username/model-name')
|
|
545
|
+
If provided and local path doesn't exist, will attempt to download from hub
|
|
546
|
+
|
|
547
|
+
Returns
|
|
548
|
+
-------
|
|
549
|
+
self : MolecularGraphPredictor
|
|
550
|
+
Updated model instance with loaded weights and parameters
|
|
551
|
+
|
|
552
|
+
Raises
|
|
553
|
+
------
|
|
554
|
+
FileNotFoundError
|
|
555
|
+
If the model file doesn't exist locally and no repo is provided,
|
|
556
|
+
or if download from repo fails
|
|
557
|
+
ValueError
|
|
558
|
+
If the saved file is corrupted or incompatible
|
|
559
|
+
"""
|
|
560
|
+
# First try to load from local path
|
|
561
|
+
if os.path.exists(path):
|
|
562
|
+
try:
|
|
563
|
+
checkpoint = torch.load(path, map_location=self.device)
|
|
564
|
+
except Exception as e:
|
|
565
|
+
raise ValueError(f"Error loading model from {path}: {str(e)}")
|
|
566
|
+
|
|
567
|
+
# If local file doesn't exist and repo is provided, try downloading
|
|
568
|
+
elif repo is not None:
|
|
569
|
+
try:
|
|
570
|
+
from huggingface_hub import hf_hub_download
|
|
571
|
+
# Create directory if it doesn't exist
|
|
572
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
573
|
+
|
|
574
|
+
# Download from HuggingFace using specified parameters
|
|
575
|
+
model_name = os.path.splitext(os.path.basename(path))[0]
|
|
576
|
+
downloaded_path = hf_hub_download(
|
|
577
|
+
repo_id=repo,
|
|
578
|
+
filename=model_name,
|
|
579
|
+
local_dir=os.path.dirname(path),
|
|
580
|
+
local_dir_use_symlinks=False,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
# Load the downloaded model
|
|
584
|
+
checkpoint = torch.load(downloaded_path, map_location=self.device)
|
|
585
|
+
|
|
586
|
+
except Exception as e:
|
|
587
|
+
if os.path.exists(path):
|
|
588
|
+
os.remove(path) # Clean up partial downloads
|
|
589
|
+
raise FileNotFoundError(
|
|
590
|
+
f"Failed to download model from repository '{repo}': {str(e)}"
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
# If neither local file exists nor repo provided
|
|
594
|
+
else:
|
|
595
|
+
raise FileNotFoundError(f"No model file found at '{path}' and no repository provided")
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
# Validate checkpoint contents
|
|
599
|
+
required_keys = {
|
|
600
|
+
"model_state_dict",
|
|
601
|
+
"hyperparameters",
|
|
602
|
+
"fitting_loss",
|
|
603
|
+
"fitting_epoch",
|
|
604
|
+
"model_name",
|
|
605
|
+
}
|
|
606
|
+
if not all(key in checkpoint for key in required_keys):
|
|
607
|
+
missing_keys = required_keys - set(checkpoint.keys())
|
|
608
|
+
raise ValueError(f"Checkpoint missing required keys: {missing_keys}")
|
|
609
|
+
|
|
610
|
+
# Validate compatibility of hyperparameters
|
|
611
|
+
for key, value in checkpoint["hyperparameters"].items():
|
|
612
|
+
if hasattr(self, key) and getattr(self, key) != value:
|
|
613
|
+
warnings.warn(
|
|
614
|
+
f"Loaded model has different {key} ({value}) "
|
|
615
|
+
f"than current instance ({getattr(self, key)}). "
|
|
616
|
+
"Updating to match loaded model."
|
|
617
|
+
)
|
|
618
|
+
setattr(self, key, value)
|
|
619
|
+
|
|
620
|
+
# Update model state
|
|
621
|
+
if not hasattr(self, 'model') or self.model is None:
|
|
622
|
+
raise ValueError("Model not initialized. Please initialize model before loading weights.")
|
|
623
|
+
|
|
624
|
+
try:
|
|
625
|
+
self.model.load_state_dict(checkpoint["model_state_dict"])
|
|
626
|
+
except Exception as e:
|
|
627
|
+
raise ValueError(f"Failed to load model state dict: {str(e)}")
|
|
628
|
+
|
|
629
|
+
# Update training state
|
|
630
|
+
self.fitting_epoch = checkpoint.get("fitting_epoch", 0)
|
|
631
|
+
self.fitting_loss = checkpoint.get("fitting_loss", float("inf"))
|
|
632
|
+
self.model_name = checkpoint['model_name']
|
|
633
|
+
self.is_fitted_ = True
|
|
634
|
+
|
|
635
|
+
# Move model to correct device
|
|
636
|
+
self.model = self.model.to(self.device)
|
|
637
|
+
|
|
638
|
+
print(f"Model successfully loaded from {'repository' if repo else 'local path'}")
|
|
639
|
+
return self
|
|
640
|
+
|
|
641
|
+
except Exception as e:
|
|
642
|
+
raise ValueError(f"Error loading model checkpoint: {str(e)}")
|
|
643
|
+
|
|
644
|
+
finally:
|
|
645
|
+
# Clean up any temporary files
|
|
646
|
+
if repo is not None and os.path.exists(downloaded_path) and downloaded_path != path:
|
|
647
|
+
try:
|
|
648
|
+
os.remove(downloaded_path)
|
|
649
|
+
except Exception:
|
|
650
|
+
pass
|