D4CMPP2 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. D4CMPP2/_Data/AGENTS.md +24 -0
  2. D4CMPP2/_Data/Aqsoldb.csv +9291 -0
  3. D4CMPP2/_Data/BradleyMP.csv +3042 -0
  4. D4CMPP2/_Data/Lipophilicity.csv +1131 -0
  5. D4CMPP2/_Data/README.md +8 -0
  6. D4CMPP2/_Data/__init__.py +26 -0
  7. D4CMPP2/_Data/optical.csv +20237 -0
  8. D4CMPP2/_Data/test.csv +190 -0
  9. D4CMPP2/__init__.py +16 -0
  10. D4CMPP2/__main__.py +5 -0
  11. D4CMPP2/_main.py +500 -0
  12. D4CMPP2/cli.py +7 -0
  13. D4CMPP2/exceptions.py +53 -0
  14. D4CMPP2/grid_search.py +259 -0
  15. D4CMPP2/network_refer.yaml +160 -0
  16. D4CMPP2/networks/AFP_model.py +72 -0
  17. D4CMPP2/networks/AFPwithSolv_model.py +72 -0
  18. D4CMPP2/networks/DMPNN_model.py +90 -0
  19. D4CMPP2/networks/DMPNNwithSolv_model.py +89 -0
  20. D4CMPP2/networks/GAT_model.py +48 -0
  21. D4CMPP2/networks/GATwithSolv_model.py +63 -0
  22. D4CMPP2/networks/GCN_model.py +113 -0
  23. D4CMPP2/networks/GCNwithSolv_model.py +103 -0
  24. D4CMPP2/networks/GC_model.py +122 -0
  25. D4CMPP2/networks/ISATPM_model.py +14 -0
  26. D4CMPP2/networks/ISATPN_model.py +199 -0
  27. D4CMPP2/networks/ISAT_model.py +90 -0
  28. D4CMPP2/networks/MPNN_model.py +56 -0
  29. D4CMPP2/networks/MPNNwithSolv_model.py +72 -0
  30. D4CMPP2/networks/__init__.py +25 -0
  31. D4CMPP2/networks/base.py +250 -0
  32. D4CMPP2/networks/registry.py +187 -0
  33. D4CMPP2/networks/src/AFP.py +118 -0
  34. D4CMPP2/networks/src/BiDropout.py +29 -0
  35. D4CMPP2/networks/src/DMPNN.py +35 -0
  36. D4CMPP2/networks/src/GAT.py +69 -0
  37. D4CMPP2/networks/src/GC.py +85 -0
  38. D4CMPP2/networks/src/GCN.py +71 -0
  39. D4CMPP2/networks/src/ISAT.py +153 -0
  40. D4CMPP2/networks/src/Linear.py +49 -0
  41. D4CMPP2/networks/src/MPNN.py +56 -0
  42. D4CMPP2/networks/src/SolventLayer.py +62 -0
  43. D4CMPP2/networks/src/__init__.py +0 -0
  44. D4CMPP2/networks/src/distGCN.py +21 -0
  45. D4CMPP2/networks/src/pyg_hetero.py +24 -0
  46. D4CMPP2/optimize.py +472 -0
  47. D4CMPP2/src/Analyzer/ISAAnalyzer.py +458 -0
  48. D4CMPP2/src/Analyzer/ISAPNAnalyzer.py +366 -0
  49. D4CMPP2/src/Analyzer/ISAwSAnalyzer.py +117 -0
  50. D4CMPP2/src/Analyzer/MolAnalyzer.py +319 -0
  51. D4CMPP2/src/Analyzer/__init__.py +54 -0
  52. D4CMPP2/src/Analyzer/core.py +480 -0
  53. D4CMPP2/src/Analyzer/factory.py +166 -0
  54. D4CMPP2/src/Analyzer/interpretation.py +232 -0
  55. D4CMPP2/src/Analyzer/results.py +101 -0
  56. D4CMPP2/src/DataManager/Dataset/GraphDataset.py +314 -0
  57. D4CMPP2/src/DataManager/Dataset/ISAGraphDataset.py +384 -0
  58. D4CMPP2/src/DataManager/Dataset/__init__.py +0 -0
  59. D4CMPP2/src/DataManager/GraphGenerator/ISAGraphGenerator.py +223 -0
  60. D4CMPP2/src/DataManager/GraphGenerator/MolGraphGenerator.py +73 -0
  61. D4CMPP2/src/DataManager/GraphGenerator/__init__.py +14 -0
  62. D4CMPP2/src/DataManager/ISADataManager.py +67 -0
  63. D4CMPP2/src/DataManager/MolDataManager.py +735 -0
  64. D4CMPP2/src/DataManager/__init__.py +14 -0
  65. D4CMPP2/src/DataManager/contracts.py +179 -0
  66. D4CMPP2/src/NetworkManager/ISANetworkManager.py +12 -0
  67. D4CMPP2/src/NetworkManager/NetworkManager.py +520 -0
  68. D4CMPP2/src/NetworkManager/__init__.py +14 -0
  69. D4CMPP2/src/PostProcessor.py +160 -0
  70. D4CMPP2/src/TrainManager/ISATrainManager.py +26 -0
  71. D4CMPP2/src/TrainManager/TrainManager.py +254 -0
  72. D4CMPP2/src/TrainManager/__init__.py +14 -0
  73. D4CMPP2/src/TrainManager/callbacks.py +119 -0
  74. D4CMPP2/src/__init__.py +0 -0
  75. D4CMPP2/src/utils/PATH.py +246 -0
  76. D4CMPP2/src/utils/__init__.py +0 -0
  77. D4CMPP2/src/utils/argparser.py +56 -0
  78. D4CMPP2/src/utils/checkpointing.py +90 -0
  79. D4CMPP2/src/utils/config_resolution.py +123 -0
  80. D4CMPP2/src/utils/config_validation.py +370 -0
  81. D4CMPP2/src/utils/csv_validation.py +105 -0
  82. D4CMPP2/src/utils/data_quality.py +181 -0
  83. D4CMPP2/src/utils/featureizer.py +202 -0
  84. D4CMPP2/src/utils/functional_group.csv +169 -0
  85. D4CMPP2/src/utils/graph_cache.py +213 -0
  86. D4CMPP2/src/utils/leaderboard.py +212 -0
  87. D4CMPP2/src/utils/metrics.py +31 -0
  88. D4CMPP2/src/utils/module_loader.py +147 -0
  89. D4CMPP2/src/utils/output.py +80 -0
  90. D4CMPP2/src/utils/reproducibility.py +70 -0
  91. D4CMPP2/src/utils/run_manifest.py +175 -0
  92. D4CMPP2/src/utils/scaler.py +40 -0
  93. D4CMPP2/src/utils/sculptor.py +713 -0
  94. D4CMPP2/src/utils/splitting.py +250 -0
  95. D4CMPP2/src/utils/supportfile_saver.py +94 -0
  96. D4CMPP2/src/utils/tools.py +156 -0
  97. D4CMPP2/src/utils/transfer_learning.py +111 -0
  98. d4cmpp2-0.4.0.dist-info/METADATA +420 -0
  99. d4cmpp2-0.4.0.dist-info/RECORD +103 -0
  100. d4cmpp2-0.4.0.dist-info/WHEEL +5 -0
  101. d4cmpp2-0.4.0.dist-info/entry_points.txt +2 -0
  102. d4cmpp2-0.4.0.dist-info/licenses/LICENSE +21 -0
  103. d4cmpp2-0.4.0.dist-info/top_level.txt +1 -0
D4CMPP2/optimize.py ADDED
@@ -0,0 +1,472 @@
1
+ """Model-owned grid and Gaussian-process hyperparameter optimization."""
2
+
3
+ import csv
4
+ import itertools
5
+ import json
6
+ import math
7
+ import os
8
+ import traceback
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+
16
+ from D4CMPP2._main import train
17
+ from D4CMPP2.networks.base import Hyperparameter
18
+ from D4CMPP2.networks.registry import get_model
19
+
20
+
21
+ OPTIMIZATION_SCHEMA_VERSION = 1
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class OptimizationResult:
26
+ best_params: dict
27
+ best_score: float
28
+ best_model_path: str
29
+ summary_path: str
30
+ trials: tuple[dict, ...]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class _Domain:
35
+ name: str
36
+ kind: str
37
+ values: tuple = ()
38
+ low: float | int | None = None
39
+ high: float | int | None = None
40
+ step: float | int | None = None
41
+ scale: str = "linear"
42
+
43
+ def grid_values(self):
44
+ if self.values:
45
+ return self.values
46
+ if self.step is None:
47
+ raise ValueError(
48
+ f"Grid domain {self.name!r} needs a value list or a step."
49
+ )
50
+ count = int(math.floor((self.high - self.low) / self.step)) + 1
51
+ values = tuple(self.low + index * self.step for index in range(count))
52
+ if self.kind == "int":
53
+ values = tuple(int(value) for value in values)
54
+ return values
55
+
56
+ def sample(self, rng):
57
+ if self.values:
58
+ return self.values[int(rng.integers(0, len(self.values)))]
59
+ if self.scale == "log":
60
+ value = math.exp(rng.uniform(math.log(self.low), math.log(self.high)))
61
+ else:
62
+ value = rng.uniform(self.low, self.high)
63
+ if self.kind == "int":
64
+ step = int(self.step or 1)
65
+ value = int(round((value - self.low) / step) * step + self.low)
66
+ return min(int(self.high), max(int(self.low), value))
67
+ if self.step:
68
+ value = round((value - self.low) / self.step) * self.step + self.low
69
+ return min(float(self.high), max(float(self.low), float(value)))
70
+
71
+ def encode(self, value):
72
+ if self.values:
73
+ index = self.values.index(value)
74
+ return 0.0 if len(self.values) == 1 else index / (len(self.values) - 1)
75
+ if self.scale == "log":
76
+ low, high, current = math.log(self.low), math.log(self.high), math.log(value)
77
+ else:
78
+ low, high, current = self.low, self.high, value
79
+ return 0.0 if high == low else (current - low) / (high - low)
80
+
81
+
82
+ def _from_metadata(name, field, strategy):
83
+ if strategy == "grid":
84
+ if not field.grid:
85
+ raise ValueError(
86
+ f"{name!r} has no predefined grid for this model. "
87
+ "Pass HP as a dict with explicit values."
88
+ )
89
+ return _Domain(name, field.kind, values=tuple(field.grid))
90
+ if field.kind == "categorical":
91
+ values = field.values or field.grid
92
+ return _Domain(name, field.kind, values=tuple(values))
93
+ if field.search_low is None or field.search_high is None:
94
+ raise ValueError(
95
+ f"{name!r} has no predefined Bayesian range. "
96
+ "Pass HP as a dict with low/high."
97
+ )
98
+ return _Domain(
99
+ name,
100
+ field.kind,
101
+ low=field.search_low,
102
+ high=field.search_high,
103
+ step=field.step,
104
+ scale=field.scale,
105
+ )
106
+
107
+
108
+ def _explicit_domain(name, specification, field):
109
+ if isinstance(specification, list):
110
+ if not specification:
111
+ raise ValueError(f"HP[{name!r}] grid cannot be empty.")
112
+ values = tuple(field.validate(name, value) for value in specification)
113
+ return _Domain(name, field.kind, values=values)
114
+ if isinstance(specification, tuple):
115
+ if len(specification) != 2:
116
+ raise ValueError(
117
+ f"HP[{name!r}] range tuple must be (low, high), got {specification!r}."
118
+ )
119
+ specification = {"low": specification[0], "high": specification[1]}
120
+ if not isinstance(specification, dict):
121
+ raise TypeError(
122
+ f"HP[{name!r}] must be a list grid, (low, high) tuple, or range dict."
123
+ )
124
+ if "values" in specification or "grid" in specification:
125
+ values = specification.get("values", specification.get("grid"))
126
+ if not isinstance(values, (list, tuple)) or not values:
127
+ raise ValueError(f"HP[{name!r}] values/grid must be non-empty.")
128
+ return _Domain(
129
+ name,
130
+ field.kind,
131
+ values=tuple(field.validate(name, value) for value in values),
132
+ )
133
+ if "low" not in specification or "high" not in specification:
134
+ raise ValueError(
135
+ f"HP[{name!r}] range requires low and high, or values/grid."
136
+ )
137
+ if field.kind == "categorical":
138
+ raise ValueError(
139
+ f"HP[{name!r}] is categorical; pass a non-empty values/grid list."
140
+ )
141
+ low = field.validate(name, specification["low"])
142
+ high = field.validate(name, specification["high"])
143
+ if low >= high:
144
+ raise ValueError(f"HP[{name!r}] requires low < high, got {low!r}, {high!r}.")
145
+ scale = specification.get("scale", field.scale)
146
+ if scale not in {"linear", "log"}:
147
+ raise ValueError(f"HP[{name!r}] scale must be 'linear' or 'log'.")
148
+ if scale == "log" and low <= 0:
149
+ raise ValueError(f"HP[{name!r}] log range requires low > 0.")
150
+ step = specification.get("step", field.step)
151
+ if step is not None:
152
+ if isinstance(step, bool) or not isinstance(step, (int, float)) or step <= 0:
153
+ raise ValueError(f"HP[{name!r}] step must be a positive number.")
154
+ if field.kind == "int" and not isinstance(step, int):
155
+ raise TypeError(f"HP[{name!r}] integer range requires an integer step.")
156
+ return _Domain(
157
+ name,
158
+ field.kind,
159
+ low=low,
160
+ high=high,
161
+ step=step,
162
+ scale=scale,
163
+ )
164
+
165
+
166
+ def normalize_hp(network, HP, strategy):
167
+ if strategy not in {"grid", "bayesian"}:
168
+ raise ValueError(
169
+ f"optimize_strategy must be 'grid' or 'bayesian', got {strategy!r}."
170
+ )
171
+ definition = get_model(network)
172
+ model = definition.network
173
+ if HP is None:
174
+ selected = model.optimization_space()
175
+ return tuple(
176
+ _from_metadata(name, field, strategy)
177
+ for name, field in selected.items()
178
+ )
179
+ if isinstance(HP, list):
180
+ if not HP or not all(isinstance(name, str) and name for name in HP):
181
+ raise ValueError("HP list must contain one or more non-empty keys.")
182
+ selected = model.optimization_space(HP)
183
+ return tuple(
184
+ _from_metadata(name, field, strategy)
185
+ for name, field in selected.items()
186
+ )
187
+ if not isinstance(HP, dict) or not HP:
188
+ raise TypeError("HP must be None, a non-empty key list, or a non-empty dict.")
189
+ unknown = [name for name in HP if name not in model.hyperparameters]
190
+ if unknown:
191
+ raise ValueError(
192
+ f"{model.model_name} does not define hyperparameters {unknown!r}. "
193
+ f"Available keys: {sorted(model.hyperparameters)!r}."
194
+ )
195
+ return tuple(
196
+ _explicit_domain(name, specification, model.hyperparameters[name])
197
+ for name, specification in HP.items()
198
+ )
199
+
200
+
201
+ def _signature(parameters):
202
+ return json.dumps(parameters, sort_keys=True, separators=(",", ":"), default=repr)
203
+
204
+
205
+ def _domain_records(domains):
206
+ return [
207
+ {
208
+ "name": domain.name,
209
+ "kind": domain.kind,
210
+ "values": list(domain.values),
211
+ "low": domain.low,
212
+ "high": domain.high,
213
+ "step": domain.step,
214
+ "scale": domain.scale,
215
+ }
216
+ for domain in domains
217
+ ]
218
+
219
+
220
+ def _read_objective(model_path):
221
+ candidates = (
222
+ Path(model_path) / "result" / "learning_curve.csv",
223
+ Path(model_path) / "learning_curve.csv",
224
+ )
225
+ for path in candidates:
226
+ if not path.exists():
227
+ continue
228
+ with open(path, "r", encoding="utf-8", newline="") as stream:
229
+ values = [
230
+ float(row["val_loss"])
231
+ for row in csv.DictReader(stream)
232
+ if row.get("val_loss") not in (None, "")
233
+ ]
234
+ finite = [value for value in values if math.isfinite(value)]
235
+ if finite:
236
+ return min(finite)
237
+ raise FileNotFoundError(
238
+ f"No finite val_loss was found below model path {str(model_path)!r}."
239
+ )
240
+
241
+
242
+ def _atomic_json(path, value):
243
+ staging = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
244
+ try:
245
+ staging.write_text(json.dumps(value, indent=2, sort_keys=True), encoding="utf-8")
246
+ os.replace(staging, path)
247
+ finally:
248
+ if staging.exists():
249
+ staging.unlink()
250
+
251
+
252
+ def _write_summary(path, summary):
253
+ _atomic_json(path, summary)
254
+ csv_path = path.with_suffix(".csv")
255
+ staging = csv_path.with_name(f".{csv_path.name}.{uuid.uuid4().hex}.tmp")
256
+ fields = ("trial", "status", "objective", "model_path", "parameters", "error")
257
+ try:
258
+ with open(staging, "w", encoding="utf-8", newline="") as stream:
259
+ writer = csv.DictWriter(stream, fieldnames=fields)
260
+ writer.writeheader()
261
+ for trial in summary["trials"]:
262
+ row = {name: trial.get(name) for name in fields}
263
+ row["parameters"] = json.dumps(trial["parameters"], sort_keys=True)
264
+ writer.writerow(row)
265
+ os.replace(staging, csv_path)
266
+ finally:
267
+ if staging.exists():
268
+ staging.unlink()
269
+
270
+
271
+ def _random_parameters(domains, rng):
272
+ return {domain.name: domain.sample(rng) for domain in domains}
273
+
274
+
275
+ def _bayesian_parameters(domains, completed, used, rng):
276
+ initial_count = max(4, 2 * len(domains))
277
+ if len(completed) < initial_count:
278
+ for _ in range(1000):
279
+ parameters = _random_parameters(domains, rng)
280
+ if _signature(parameters) not in used:
281
+ return parameters
282
+ from sklearn.gaussian_process import GaussianProcessRegressor
283
+ from sklearn.gaussian_process.kernels import Matern, WhiteKernel
284
+
285
+ x = np.asarray(
286
+ [[domain.encode(trial["parameters"][domain.name]) for domain in domains]
287
+ for trial in completed],
288
+ dtype=float,
289
+ )
290
+ y = np.asarray([trial["objective"] for trial in completed], dtype=float)
291
+ surrogate = GaussianProcessRegressor(
292
+ kernel=Matern(nu=2.5) + WhiteKernel(noise_level=1e-6),
293
+ normalize_y=True,
294
+ random_state=0,
295
+ n_restarts_optimizer=1,
296
+ )
297
+ surrogate.fit(x, y)
298
+ candidates = []
299
+ for _ in range(2048):
300
+ parameters = _random_parameters(domains, rng)
301
+ signature = _signature(parameters)
302
+ if signature not in used:
303
+ candidates.append(parameters)
304
+ if len(candidates) >= 512:
305
+ break
306
+ if not candidates:
307
+ raise StopIteration("The finite hyperparameter space is exhausted.")
308
+ encoded = np.asarray(
309
+ [[domain.encode(candidate[domain.name]) for domain in domains]
310
+ for candidate in candidates],
311
+ dtype=float,
312
+ )
313
+ mean, std = surrogate.predict(encoded, return_std=True)
314
+ return candidates[int(np.argmin(mean - 1.96 * std))]
315
+
316
+
317
+ def optimize(
318
+ *,
319
+ data,
320
+ target,
321
+ network,
322
+ HP=None,
323
+ optimize_strategy="bayesian",
324
+ n_trials=None,
325
+ random_seed=42,
326
+ optimization_path=None,
327
+ resume=True,
328
+ **train_kwargs,
329
+ ):
330
+ """Tune one registered network and return its best completed trial."""
331
+ if not isinstance(optimize_strategy, str):
332
+ raise TypeError("optimize_strategy must be 'grid' or 'bayesian'.")
333
+ strategy = optimize_strategy.lower()
334
+ domains = normalize_hp(network, HP, strategy)
335
+ domain_records = _domain_records(domains)
336
+ if isinstance(random_seed, bool) or not isinstance(random_seed, int):
337
+ raise TypeError("random_seed must be an integer.")
338
+ if strategy == "bayesian":
339
+ n_trials = 20 if n_trials is None else n_trials
340
+ if isinstance(n_trials, bool) or not isinstance(n_trials, int) or n_trials < 1:
341
+ raise ValueError("n_trials must be a positive integer for Bayesian search.")
342
+ elif n_trials is not None:
343
+ raise ValueError("n_trials is only used with optimize_strategy='bayesian'.")
344
+
345
+ root = Path(
346
+ optimization_path
347
+ or Path(train_kwargs.get("MODEL_DIR", "_Models"))
348
+ / f"optimize_{network}"
349
+ ).resolve()
350
+ root.mkdir(parents=True, exist_ok=True)
351
+ summary_path = root / "optimization.json"
352
+ if resume and summary_path.exists():
353
+ summary = json.loads(summary_path.read_text(encoding="utf-8"))
354
+ if summary.get("strategy") != strategy or summary.get("network") != network:
355
+ raise ValueError(
356
+ f"Existing optimization summary {str(summary_path)!r} belongs to "
357
+ f"network={summary.get('network')!r}, strategy={summary.get('strategy')!r}."
358
+ )
359
+ if summary.get("domains") != domain_records:
360
+ raise ValueError(
361
+ f"Existing optimization summary {str(summary_path)!r} uses a different "
362
+ "HP search space. Choose another optimization_path or set resume=False."
363
+ )
364
+ else:
365
+ summary = {
366
+ "schema_version": OPTIMIZATION_SCHEMA_VERSION,
367
+ "status": "running",
368
+ "network": network,
369
+ "strategy": strategy,
370
+ "domains": domain_records,
371
+ "started_at": datetime.now(timezone.utc).isoformat(),
372
+ "trials": [],
373
+ }
374
+
375
+ completed = [trial for trial in summary["trials"] if trial["status"] == "completed"]
376
+ used = {_signature(trial["parameters"]) for trial in summary["trials"]}
377
+ rng = np.random.default_rng(random_seed + len(summary["trials"]))
378
+ if strategy == "grid":
379
+ names = [domain.name for domain in domains]
380
+ candidates = (
381
+ dict(zip(names, values))
382
+ for values in itertools.product(*(domain.grid_values() for domain in domains))
383
+ )
384
+ else:
385
+ candidates = None
386
+
387
+ while True:
388
+ if strategy == "grid":
389
+ parameters = next(
390
+ (candidate for candidate in candidates if _signature(candidate) not in used),
391
+ None,
392
+ )
393
+ if parameters is None:
394
+ break
395
+ else:
396
+ if len(summary["trials"]) >= n_trials:
397
+ break
398
+ try:
399
+ parameters = _bayesian_parameters(domains, completed, used, rng)
400
+ except StopIteration:
401
+ break
402
+
403
+ number = len(summary["trials"]) + 1
404
+ model_path = root / "trials" / f"trial_{number:04d}"
405
+ trial = {
406
+ "trial": number,
407
+ "status": "running",
408
+ "parameters": parameters,
409
+ "model_path": str(model_path),
410
+ "started_at": datetime.now(timezone.utc).isoformat(),
411
+ }
412
+ summary["trials"].append(trial)
413
+ used.add(_signature(parameters))
414
+ _write_summary(summary_path, summary)
415
+ try:
416
+ trial_kwargs = dict(train_kwargs)
417
+ trial_kwargs.update(parameters)
418
+ trial_kwargs.update(
419
+ data=data,
420
+ target=target,
421
+ network=network,
422
+ MODEL_PATH=str(model_path),
423
+ )
424
+ result_path = train(**trial_kwargs)
425
+ trial["model_path"] = result_path
426
+ trial["objective"] = _read_objective(result_path)
427
+ trial["status"] = "completed"
428
+ completed.append(trial)
429
+ except KeyboardInterrupt:
430
+ trial["status"] = "interrupted"
431
+ trial["error"] = "KeyboardInterrupt"
432
+ summary["status"] = "interrupted"
433
+ trial["ended_at"] = datetime.now(timezone.utc).isoformat()
434
+ _write_summary(summary_path, summary)
435
+ raise
436
+ except Exception as exc:
437
+ trial["status"] = "failed"
438
+ trial["error"] = f"{type(exc).__name__}: {exc}"
439
+ trial["traceback"] = traceback.format_exc()[-8000:]
440
+ trial["ended_at"] = datetime.now(timezone.utc).isoformat()
441
+ _write_summary(summary_path, summary)
442
+
443
+ if not completed:
444
+ summary["status"] = "failed"
445
+ _write_summary(summary_path, summary)
446
+ failures = "; ".join(
447
+ f"trial {trial['trial']}: {trial.get('error', trial['status'])}"
448
+ for trial in summary["trials"][-3:]
449
+ )
450
+ raise RuntimeError(
451
+ "Optimization completed no successful trials. "
452
+ f"Recent failures: {failures}. See {str(summary_path)!r}."
453
+ )
454
+ best = min(completed, key=lambda trial: trial["objective"])
455
+ summary["status"] = (
456
+ "completed_with_failures"
457
+ if any(trial["status"] == "failed" for trial in summary["trials"])
458
+ else "completed"
459
+ )
460
+ summary["best_trial"] = best["trial"]
461
+ summary["best_objective"] = best["objective"]
462
+ summary["best_parameters"] = best["parameters"]
463
+ summary["best_model_path"] = best["model_path"]
464
+ summary["ended_at"] = datetime.now(timezone.utc).isoformat()
465
+ _write_summary(summary_path, summary)
466
+ return OptimizationResult(
467
+ best_params=dict(best["parameters"]),
468
+ best_score=float(best["objective"]),
469
+ best_model_path=best["model_path"],
470
+ summary_path=str(summary_path),
471
+ trials=tuple(summary["trials"]),
472
+ )