glpbench 0.1.0a1__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.
- benchmark.py +1199 -0
- convergence_audit.py +487 -0
- datasets.py +165 -0
- experiment_logging.py +599 -0
- experiment_pipeline.py +140 -0
- feature_reduction.py +164 -0
- glpbench/__init__.py +4 -0
- glpbench/__main__.py +8 -0
- glpbench/cli.py +63 -0
- glpbench-0.1.0a1.dist-info/METADATA +618 -0
- glpbench-0.1.0a1.dist-info/RECORD +20 -0
- glpbench-0.1.0a1.dist-info/WHEEL +5 -0
- glpbench-0.1.0a1.dist-info/entry_points.txt +2 -0
- glpbench-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- glpbench-0.1.0a1.dist-info/top_level.txt +12 -0
- link_prediction.py +1054 -0
- lr_scheduling.py +138 -0
- main.py +948 -0
- search.py +870 -0
- utils.py +1333 -0
benchmark.py
ADDED
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
"""Run configuration-driven link-prediction benchmark grids."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import csv
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from itertools import product
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from tqdm.auto import tqdm
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from datasets import available_datasets
|
|
17
|
+
from experiment_logging import (
|
|
18
|
+
CSVEpochHistoryLogger,
|
|
19
|
+
CSVResultLogger,
|
|
20
|
+
EpochHistoryRecord,
|
|
21
|
+
make_configuration_id,
|
|
22
|
+
)
|
|
23
|
+
from feature_reduction import available_reduction_methods
|
|
24
|
+
from link_prediction import (
|
|
25
|
+
available_agae_regularizers,
|
|
26
|
+
available_model_types,
|
|
27
|
+
get_model_spec,
|
|
28
|
+
)
|
|
29
|
+
from lr_scheduling import validate_scheduler_config
|
|
30
|
+
from main import run_experiment
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_CONV_LAYER_TYPES = ("GCNConv", "GraphConv", "SAGEConv", "GATv2Conv")
|
|
34
|
+
_NON_CONV_MODELS = {"deal", "feature-cosine", "feature-mlp"}
|
|
35
|
+
_DEFAULTS = {
|
|
36
|
+
"query_size": 10,
|
|
37
|
+
"query_fraction": None,
|
|
38
|
+
"validation_query_size": None,
|
|
39
|
+
"validation_query_fraction": None,
|
|
40
|
+
"train_ratio": 0.8,
|
|
41
|
+
"epochs": 100,
|
|
42
|
+
"learning_rate": 0.01,
|
|
43
|
+
"weight_decay": 0.0,
|
|
44
|
+
"hidden_channels": 128,
|
|
45
|
+
"out_channels": 64,
|
|
46
|
+
"conv_layer_type": "GCNConv",
|
|
47
|
+
"reduction_method": "pca",
|
|
48
|
+
"decoder_hidden_channels": None,
|
|
49
|
+
"decoder_dropout": 0.0,
|
|
50
|
+
"kl_weight": 1.0,
|
|
51
|
+
"selection_metric": "roc_auc",
|
|
52
|
+
"early_stopping_patience": None,
|
|
53
|
+
"early_stopping_min_delta": 0.0,
|
|
54
|
+
"early_stopping_minimum_epochs": 0,
|
|
55
|
+
"checkpoint_strategy": "best-validation",
|
|
56
|
+
"training_policy": "default",
|
|
57
|
+
"lr_scheduler": "none",
|
|
58
|
+
"lr_scheduler_kwargs": None,
|
|
59
|
+
"normalize_features": True,
|
|
60
|
+
"device": "cpu",
|
|
61
|
+
"gatv2_kwargs": None,
|
|
62
|
+
"deal_theta_ss": 1.0,
|
|
63
|
+
"deal_theta_aa": 1.0,
|
|
64
|
+
"deal_theta_sa": 1.0,
|
|
65
|
+
"deal_lambda_ss": 1.0,
|
|
66
|
+
"deal_lambda_aa": 1.0,
|
|
67
|
+
"deal_lambda_sa": 1.0,
|
|
68
|
+
"deal_similarity_scale": 1.0,
|
|
69
|
+
"deal_similarity_margin": 0.0,
|
|
70
|
+
}
|
|
71
|
+
_MODEL_PARAMETER_KEYS = set(_DEFAULTS).difference({
|
|
72
|
+
"query_size", "query_fraction", "validation_query_size",
|
|
73
|
+
"validation_query_fraction", "train_ratio", "epochs",
|
|
74
|
+
"selection_metric", "early_stopping_patience",
|
|
75
|
+
"early_stopping_min_delta", "early_stopping_minimum_epochs",
|
|
76
|
+
"checkpoint_strategy", "training_policy", "normalize_features", "device",
|
|
77
|
+
"lr_scheduler", "lr_scheduler_kwargs", "conv_layer_type",
|
|
78
|
+
"reduction_method",
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class RegularizationConfig:
|
|
84
|
+
"""Describe one AGAE regularization setting."""
|
|
85
|
+
|
|
86
|
+
type: str
|
|
87
|
+
strength: float = 0.0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class ModelGrid:
|
|
92
|
+
"""Describe model-specific dimensions of a benchmark grid."""
|
|
93
|
+
|
|
94
|
+
type: str
|
|
95
|
+
conv_layer_types: tuple[str, ...]
|
|
96
|
+
reduction_methods: tuple[str, ...]
|
|
97
|
+
regularization: tuple[RegularizationConfig, ...]
|
|
98
|
+
parameters: dict
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class TrainingPolicy:
|
|
103
|
+
"""Describe one checkpoint-selection and stopping policy."""
|
|
104
|
+
|
|
105
|
+
name: str
|
|
106
|
+
max_epochs: int
|
|
107
|
+
checkpoint_strategy: str
|
|
108
|
+
early_stopping_patience: int | None
|
|
109
|
+
early_stopping_min_delta: float
|
|
110
|
+
early_stopping_minimum_epochs: int
|
|
111
|
+
lr_scheduler: str
|
|
112
|
+
lr_scheduler_kwargs: dict
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class BenchmarkConfig:
|
|
117
|
+
"""Describe a validated benchmark configuration file."""
|
|
118
|
+
|
|
119
|
+
output: str
|
|
120
|
+
data_root: str
|
|
121
|
+
seeds: tuple[int, ...]
|
|
122
|
+
defaults: dict
|
|
123
|
+
datasets: tuple[str, ...]
|
|
124
|
+
models: tuple[ModelGrid, ...]
|
|
125
|
+
error_output: str
|
|
126
|
+
selected_configurations: Path | None = None
|
|
127
|
+
kind: str = "benchmark"
|
|
128
|
+
training_policies: tuple[TrainingPolicy, ...] = ()
|
|
129
|
+
epoch_history_output: str | None = None
|
|
130
|
+
log_epoch_history: bool = False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class ExperimentJob:
|
|
135
|
+
"""Store one fully resolved call to ``main.run_experiment``."""
|
|
136
|
+
|
|
137
|
+
dataset: str
|
|
138
|
+
model_type: str
|
|
139
|
+
conv_layer_type: str
|
|
140
|
+
reduction_method: str
|
|
141
|
+
regularization: RegularizationConfig
|
|
142
|
+
seeds: tuple[int, ...]
|
|
143
|
+
parameters: dict
|
|
144
|
+
|
|
145
|
+
def to_namespace(self, *, verbose=False):
|
|
146
|
+
"""Return arguments compatible with ``main.run_experiment``."""
|
|
147
|
+
values = dict(self.parameters)
|
|
148
|
+
values.update({
|
|
149
|
+
"dataset": self.dataset,
|
|
150
|
+
"model_type": [self.model_type],
|
|
151
|
+
"conv_layer_type": self.conv_layer_type,
|
|
152
|
+
"reduction_method": self.reduction_method,
|
|
153
|
+
"agae_regularization": [self.regularization.type],
|
|
154
|
+
"regularization_strength": self.regularization.strength,
|
|
155
|
+
"seed": list(self.seeds),
|
|
156
|
+
"num_seeds": None,
|
|
157
|
+
"verbose": verbose,
|
|
158
|
+
})
|
|
159
|
+
return SimpleNamespace(**values)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def label(self):
|
|
163
|
+
"""Return a compact human-readable job label."""
|
|
164
|
+
parts = [self.dataset, self.model_type]
|
|
165
|
+
if self.model_type not in _NON_CONV_MODELS:
|
|
166
|
+
parts.append(self.conv_layer_type)
|
|
167
|
+
if get_model_spec(self.model_type).uses_reduced_features:
|
|
168
|
+
parts.extend((self.reduction_method, self.regularization.type))
|
|
169
|
+
policy = self.parameters.get("training_policy", "default")
|
|
170
|
+
if policy != "default":
|
|
171
|
+
parts.append(policy)
|
|
172
|
+
return "/".join(parts)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class BenchmarkSummary:
|
|
177
|
+
"""Summarize one benchmark-runner invocation."""
|
|
178
|
+
|
|
179
|
+
run_id: str
|
|
180
|
+
jobs_total: int
|
|
181
|
+
jobs_succeeded: int
|
|
182
|
+
jobs_failed: int
|
|
183
|
+
expected_result_rows: int
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _require_mapping(value, name):
|
|
187
|
+
if not isinstance(value, dict):
|
|
188
|
+
raise ValueError(f"{name} must be a mapping")
|
|
189
|
+
return value
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _require_unique_sequence(value, name):
|
|
193
|
+
if not isinstance(value, list) or not value:
|
|
194
|
+
raise ValueError(f"{name} must be a non-empty list")
|
|
195
|
+
if len({json.dumps(item, sort_keys=True) for item in value}) != len(value):
|
|
196
|
+
raise ValueError(f"{name} must not contain duplicates")
|
|
197
|
+
return value
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _resolve_dataset_name(name):
|
|
201
|
+
if not isinstance(name, str) or not name.strip():
|
|
202
|
+
raise ValueError("dataset names must be non-empty strings")
|
|
203
|
+
aliases = {
|
|
204
|
+
"".join(character for character in candidate if character.isalnum()): candidate
|
|
205
|
+
for candidate in available_datasets()
|
|
206
|
+
}
|
|
207
|
+
compact = "".join(
|
|
208
|
+
character for character in name.lower() if character.isalnum()
|
|
209
|
+
)
|
|
210
|
+
if compact not in aliases:
|
|
211
|
+
raise ValueError(f"Unknown dataset '{name}'")
|
|
212
|
+
return aliases[compact]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _validate_defaults(defaults):
|
|
216
|
+
defaults = _require_mapping(defaults, "defaults")
|
|
217
|
+
unknown = set(defaults).difference(_DEFAULTS)
|
|
218
|
+
if unknown:
|
|
219
|
+
raise ValueError(f"Unknown default setting: {sorted(unknown)[0]}")
|
|
220
|
+
resolved = {**_DEFAULTS, **defaults}
|
|
221
|
+
if "query_fraction" in defaults and "query_size" not in defaults:
|
|
222
|
+
resolved["query_size"] = None
|
|
223
|
+
if (
|
|
224
|
+
"validation_query_fraction" in defaults
|
|
225
|
+
and "validation_query_size" not in defaults
|
|
226
|
+
):
|
|
227
|
+
resolved["validation_query_size"] = None
|
|
228
|
+
if resolved["query_size"] is not None and resolved["query_fraction"] is not None:
|
|
229
|
+
raise ValueError("defaults cannot specify both query_size and query_fraction")
|
|
230
|
+
if (
|
|
231
|
+
resolved["validation_query_size"] is not None
|
|
232
|
+
and resolved["validation_query_fraction"] is not None
|
|
233
|
+
):
|
|
234
|
+
raise ValueError(
|
|
235
|
+
"defaults cannot specify both validation query size and fraction"
|
|
236
|
+
)
|
|
237
|
+
if resolved["conv_layer_type"] not in _CONV_LAYER_TYPES:
|
|
238
|
+
raise ValueError("Unknown default convolution layer")
|
|
239
|
+
if resolved["reduction_method"] not in available_reduction_methods():
|
|
240
|
+
raise ValueError("Unknown default reduction method")
|
|
241
|
+
if resolved["selection_metric"] not in {"roc_auc", "average_precision"}:
|
|
242
|
+
raise ValueError("Unknown selection metric")
|
|
243
|
+
if resolved["checkpoint_strategy"] not in {"best-validation", "final"}:
|
|
244
|
+
raise ValueError("Unknown checkpoint strategy")
|
|
245
|
+
if (
|
|
246
|
+
not isinstance(resolved["training_policy"], str)
|
|
247
|
+
or not resolved["training_policy"].strip()
|
|
248
|
+
):
|
|
249
|
+
raise ValueError("training_policy must be a non-empty string")
|
|
250
|
+
validate_scheduler_config(
|
|
251
|
+
resolved["lr_scheduler"],
|
|
252
|
+
resolved["lr_scheduler_kwargs"],
|
|
253
|
+
epochs=resolved["epochs"],
|
|
254
|
+
)
|
|
255
|
+
if not isinstance(resolved["normalize_features"], bool):
|
|
256
|
+
raise ValueError("normalize_features must be boolean")
|
|
257
|
+
if not isinstance(resolved["epochs"], int) or resolved["epochs"] <= 0:
|
|
258
|
+
raise ValueError("epochs must be a positive integer")
|
|
259
|
+
if not 0 < resolved["train_ratio"] < 1:
|
|
260
|
+
raise ValueError("train_ratio must be between zero and one")
|
|
261
|
+
return resolved
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _parse_regularization(raw, model_type):
|
|
265
|
+
if raw is None:
|
|
266
|
+
return (RegularizationConfig("none", 0.0),)
|
|
267
|
+
if not get_model_spec(model_type).supports_agae_regularization:
|
|
268
|
+
raise ValueError("regularization is only supported by asymmetric models")
|
|
269
|
+
entries = _require_unique_sequence(raw, "regularization")
|
|
270
|
+
parsed = []
|
|
271
|
+
for index, entry in enumerate(entries):
|
|
272
|
+
entry = _require_mapping(entry, f"regularization[{index}]")
|
|
273
|
+
unknown = set(entry).difference({"type", "strength"})
|
|
274
|
+
if unknown:
|
|
275
|
+
raise ValueError(f"Unknown regularization setting: {sorted(unknown)[0]}")
|
|
276
|
+
kind = entry.get("type")
|
|
277
|
+
strength = entry.get("strength", 0.0)
|
|
278
|
+
if kind not in available_agae_regularizers():
|
|
279
|
+
raise ValueError(f"Unknown AGAE regularizer '{kind}'")
|
|
280
|
+
if not isinstance(strength, (int, float)) or strength < 0:
|
|
281
|
+
raise ValueError("regularization strength must be non-negative")
|
|
282
|
+
parsed.append(RegularizationConfig(kind, float(strength)))
|
|
283
|
+
return tuple(parsed)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_model_grid(raw, defaults, index):
|
|
287
|
+
raw = _require_mapping(raw, f"models[{index}]")
|
|
288
|
+
unknown = set(raw).difference({
|
|
289
|
+
"type", "conv_layer_types", "reduction_methods", "regularization",
|
|
290
|
+
"parameters",
|
|
291
|
+
})
|
|
292
|
+
if unknown:
|
|
293
|
+
raise ValueError(f"Unknown model-grid setting: {sorted(unknown)[0]}")
|
|
294
|
+
model_type = raw.get("type")
|
|
295
|
+
if model_type not in available_model_types():
|
|
296
|
+
raise ValueError(f"Unknown model type '{model_type}'")
|
|
297
|
+
model_spec = get_model_spec(model_type)
|
|
298
|
+
|
|
299
|
+
raw_conv = raw.get("conv_layer_types")
|
|
300
|
+
if model_type in _NON_CONV_MODELS:
|
|
301
|
+
if raw_conv is not None:
|
|
302
|
+
raise ValueError(f"{model_type} does not accept conv_layer_types")
|
|
303
|
+
conv_layers = (defaults["conv_layer_type"],)
|
|
304
|
+
else:
|
|
305
|
+
raw_conv = (
|
|
306
|
+
[defaults["conv_layer_type"]] if raw_conv is None
|
|
307
|
+
else _require_unique_sequence(raw_conv, "conv_layer_types")
|
|
308
|
+
)
|
|
309
|
+
if any(layer not in _CONV_LAYER_TYPES for layer in raw_conv):
|
|
310
|
+
raise ValueError("Unknown convolution layer type")
|
|
311
|
+
conv_layers = tuple(raw_conv)
|
|
312
|
+
|
|
313
|
+
raw_reducers = raw.get("reduction_methods")
|
|
314
|
+
if not model_spec.uses_reduced_features and raw_reducers is not None:
|
|
315
|
+
raise ValueError("reduction_methods are only supported by asymmetric")
|
|
316
|
+
raw_reducers = (
|
|
317
|
+
[defaults["reduction_method"]] if raw_reducers is None
|
|
318
|
+
else _require_unique_sequence(raw_reducers, "reduction_methods")
|
|
319
|
+
)
|
|
320
|
+
if any(method not in available_reduction_methods() for method in raw_reducers):
|
|
321
|
+
raise ValueError("Unknown reduction method")
|
|
322
|
+
|
|
323
|
+
parameters = _require_mapping(raw.get("parameters", {}), "parameters")
|
|
324
|
+
unknown_parameters = set(parameters).difference(_MODEL_PARAMETER_KEYS)
|
|
325
|
+
if unknown_parameters:
|
|
326
|
+
raise ValueError(
|
|
327
|
+
f"Unknown model parameter: {sorted(unknown_parameters)[0]}"
|
|
328
|
+
)
|
|
329
|
+
if not model_spec.uses_mlp_decoder and set(parameters).intersection({
|
|
330
|
+
"decoder_hidden_channels", "decoder_dropout"
|
|
331
|
+
}):
|
|
332
|
+
raise ValueError("decoder parameters are only supported by MLP models")
|
|
333
|
+
if model_type != "variational" and "kl_weight" in parameters:
|
|
334
|
+
raise ValueError("kl_weight is only supported by variational")
|
|
335
|
+
if model_type != "deal" and any(
|
|
336
|
+
name.startswith("deal_") for name in parameters
|
|
337
|
+
):
|
|
338
|
+
raise ValueError("DEAL parameters are only supported by deal")
|
|
339
|
+
if "gatv2_kwargs" in parameters and "GATv2Conv" not in conv_layers:
|
|
340
|
+
raise ValueError("gatv2_kwargs require GATv2Conv")
|
|
341
|
+
return ModelGrid(
|
|
342
|
+
type=model_type,
|
|
343
|
+
conv_layer_types=conv_layers,
|
|
344
|
+
reduction_methods=tuple(raw_reducers),
|
|
345
|
+
regularization=_parse_regularization(
|
|
346
|
+
raw.get("regularization"), model_type
|
|
347
|
+
),
|
|
348
|
+
parameters=dict(parameters),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _validate_job_parameters(parameters):
|
|
353
|
+
"""Validate resolved values before any benchmark job executes."""
|
|
354
|
+
for name in ("hidden_channels", "out_channels", "epochs"):
|
|
355
|
+
value = parameters[name]
|
|
356
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
357
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
358
|
+
for name in ("query_size", "validation_query_size"):
|
|
359
|
+
value = parameters[name]
|
|
360
|
+
if value is not None and (
|
|
361
|
+
not isinstance(value, int) or isinstance(value, bool) or value <= 0
|
|
362
|
+
):
|
|
363
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
364
|
+
for name in ("query_fraction", "validation_query_fraction"):
|
|
365
|
+
value = parameters[name]
|
|
366
|
+
if value is not None and (
|
|
367
|
+
not isinstance(value, (int, float))
|
|
368
|
+
or isinstance(value, bool)
|
|
369
|
+
or not 0 < value < 1
|
|
370
|
+
):
|
|
371
|
+
raise ValueError(f"{name} must be between zero and one")
|
|
372
|
+
if not 0 < parameters["train_ratio"] < 1:
|
|
373
|
+
raise ValueError("train_ratio must be between zero and one")
|
|
374
|
+
if parameters["learning_rate"] <= 0:
|
|
375
|
+
raise ValueError("learning_rate must be positive")
|
|
376
|
+
if parameters["weight_decay"] < 0:
|
|
377
|
+
raise ValueError("weight_decay must be non-negative")
|
|
378
|
+
if not 0 <= parameters["decoder_dropout"] <= 1:
|
|
379
|
+
raise ValueError("decoder_dropout must be between zero and one")
|
|
380
|
+
decoder_hidden = parameters["decoder_hidden_channels"]
|
|
381
|
+
if decoder_hidden is not None and (
|
|
382
|
+
not isinstance(decoder_hidden, int)
|
|
383
|
+
or isinstance(decoder_hidden, bool)
|
|
384
|
+
or decoder_hidden <= 0
|
|
385
|
+
):
|
|
386
|
+
raise ValueError("decoder_hidden_channels must be positive")
|
|
387
|
+
if parameters["kl_weight"] < 0:
|
|
388
|
+
raise ValueError("kl_weight must be non-negative")
|
|
389
|
+
patience = parameters["early_stopping_patience"]
|
|
390
|
+
if patience is not None and (
|
|
391
|
+
not isinstance(patience, int)
|
|
392
|
+
or isinstance(patience, bool)
|
|
393
|
+
or patience <= 0
|
|
394
|
+
):
|
|
395
|
+
raise ValueError("early_stopping_patience must be positive")
|
|
396
|
+
if parameters["early_stopping_min_delta"] < 0:
|
|
397
|
+
raise ValueError("early_stopping_min_delta must be non-negative")
|
|
398
|
+
minimum_epochs = parameters["early_stopping_minimum_epochs"]
|
|
399
|
+
if (
|
|
400
|
+
not isinstance(minimum_epochs, int)
|
|
401
|
+
or isinstance(minimum_epochs, bool)
|
|
402
|
+
or not 0 <= minimum_epochs <= parameters["epochs"]
|
|
403
|
+
):
|
|
404
|
+
raise ValueError(
|
|
405
|
+
"early_stopping_minimum_epochs must be between zero and epochs"
|
|
406
|
+
)
|
|
407
|
+
if parameters["checkpoint_strategy"] not in {"best-validation", "final"}:
|
|
408
|
+
raise ValueError("Unknown checkpoint strategy")
|
|
409
|
+
if (
|
|
410
|
+
not isinstance(parameters["training_policy"], str)
|
|
411
|
+
or not parameters["training_policy"].strip()
|
|
412
|
+
):
|
|
413
|
+
raise ValueError("training_policy must be a non-empty string")
|
|
414
|
+
validate_scheduler_config(
|
|
415
|
+
parameters["lr_scheduler"],
|
|
416
|
+
parameters["lr_scheduler_kwargs"],
|
|
417
|
+
epochs=parameters["epochs"],
|
|
418
|
+
)
|
|
419
|
+
if not isinstance(parameters["device"], str) or not parameters["device"]:
|
|
420
|
+
raise ValueError("device must be a non-empty string")
|
|
421
|
+
gatv2_kwargs = parameters["gatv2_kwargs"]
|
|
422
|
+
if gatv2_kwargs is not None and not isinstance(gatv2_kwargs, dict):
|
|
423
|
+
raise ValueError("gatv2_kwargs must be a mapping")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _selected_configuration_path(raw):
|
|
427
|
+
"""Validate a selected-hyperparameter artifact reference."""
|
|
428
|
+
selected_raw = _require_mapping(raw, "selected_configurations")
|
|
429
|
+
unknown = set(selected_raw).difference({
|
|
430
|
+
"path", "granularity", "require_all_pairs"
|
|
431
|
+
})
|
|
432
|
+
if unknown:
|
|
433
|
+
raise ValueError(
|
|
434
|
+
"Unknown selected-configuration setting: "
|
|
435
|
+
f"{sorted(unknown)[0]}"
|
|
436
|
+
)
|
|
437
|
+
if selected_raw.get("granularity") != ["dataset", "architecture"]:
|
|
438
|
+
raise ValueError(
|
|
439
|
+
"selected configuration granularity must be dataset and architecture"
|
|
440
|
+
)
|
|
441
|
+
if selected_raw.get("require_all_pairs") is not True:
|
|
442
|
+
raise ValueError("selected configurations must require all pairs")
|
|
443
|
+
selected_value = selected_raw.get("path")
|
|
444
|
+
if not isinstance(selected_value, str) or not selected_value.strip():
|
|
445
|
+
raise ValueError("selected_configurations.path must be non-empty")
|
|
446
|
+
return Path(selected_value)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _parse_training_policy(raw, index):
|
|
450
|
+
"""Validate one training-policy ablation entry."""
|
|
451
|
+
raw = _require_mapping(raw, f"training_policies[{index}]")
|
|
452
|
+
unknown = set(raw).difference({
|
|
453
|
+
"name", "max_epochs", "checkpoint", "early_stopping", "scheduler"
|
|
454
|
+
})
|
|
455
|
+
if unknown:
|
|
456
|
+
raise ValueError(f"Unknown training-policy setting: {sorted(unknown)[0]}")
|
|
457
|
+
name = raw.get("name")
|
|
458
|
+
if not isinstance(name, str) or not name.strip():
|
|
459
|
+
raise ValueError("training policy name must be non-empty")
|
|
460
|
+
max_epochs = raw.get("max_epochs")
|
|
461
|
+
if (
|
|
462
|
+
not isinstance(max_epochs, int)
|
|
463
|
+
or isinstance(max_epochs, bool)
|
|
464
|
+
or max_epochs <= 0
|
|
465
|
+
):
|
|
466
|
+
raise ValueError("training policy max_epochs must be positive")
|
|
467
|
+
checkpoint = raw.get("checkpoint")
|
|
468
|
+
if checkpoint not in {"best-validation", "final"}:
|
|
469
|
+
raise ValueError("Unknown training-policy checkpoint strategy")
|
|
470
|
+
|
|
471
|
+
stopping = _require_mapping(
|
|
472
|
+
raw.get("early_stopping"),
|
|
473
|
+
f"training_policies[{index}].early_stopping",
|
|
474
|
+
)
|
|
475
|
+
unknown_stopping = set(stopping).difference({
|
|
476
|
+
"enabled", "patience", "min_delta", "minimum_epochs"
|
|
477
|
+
})
|
|
478
|
+
if unknown_stopping:
|
|
479
|
+
raise ValueError(
|
|
480
|
+
"Unknown early-stopping setting: "
|
|
481
|
+
f"{sorted(unknown_stopping)[0]}"
|
|
482
|
+
)
|
|
483
|
+
enabled = stopping.get("enabled")
|
|
484
|
+
if not isinstance(enabled, bool):
|
|
485
|
+
raise ValueError("early_stopping.enabled must be boolean")
|
|
486
|
+
patience = stopping.get("patience") if enabled else None
|
|
487
|
+
if enabled and (
|
|
488
|
+
not isinstance(patience, int)
|
|
489
|
+
or isinstance(patience, bool)
|
|
490
|
+
or patience <= 0
|
|
491
|
+
):
|
|
492
|
+
raise ValueError("enabled early stopping requires positive patience")
|
|
493
|
+
min_delta = stopping.get("min_delta", 0.0)
|
|
494
|
+
minimum_epochs = stopping.get("minimum_epochs", 0)
|
|
495
|
+
if (
|
|
496
|
+
not isinstance(min_delta, (int, float))
|
|
497
|
+
or isinstance(min_delta, bool)
|
|
498
|
+
or min_delta < 0
|
|
499
|
+
):
|
|
500
|
+
raise ValueError("early-stopping min_delta must be non-negative")
|
|
501
|
+
if (
|
|
502
|
+
not isinstance(minimum_epochs, int)
|
|
503
|
+
or isinstance(minimum_epochs, bool)
|
|
504
|
+
or not 0 <= minimum_epochs <= max_epochs
|
|
505
|
+
):
|
|
506
|
+
raise ValueError(
|
|
507
|
+
"early-stopping minimum_epochs must be between zero and max_epochs"
|
|
508
|
+
)
|
|
509
|
+
if not enabled and set(stopping).difference({"enabled"}):
|
|
510
|
+
raise ValueError(
|
|
511
|
+
"disabled early stopping cannot define patience, min_delta, or "
|
|
512
|
+
"minimum_epochs"
|
|
513
|
+
)
|
|
514
|
+
scheduler = _require_mapping(
|
|
515
|
+
raw.get("scheduler", {"type": "none"}),
|
|
516
|
+
f"training_policies[{index}].scheduler",
|
|
517
|
+
)
|
|
518
|
+
scheduler_type = scheduler.get("type")
|
|
519
|
+
scheduler_parameters = {
|
|
520
|
+
name: value for name, value in scheduler.items() if name != "type"
|
|
521
|
+
}
|
|
522
|
+
scheduler_parameters = validate_scheduler_config(
|
|
523
|
+
scheduler_type, scheduler_parameters, epochs=max_epochs
|
|
524
|
+
)
|
|
525
|
+
return TrainingPolicy(
|
|
526
|
+
name=name,
|
|
527
|
+
max_epochs=max_epochs,
|
|
528
|
+
checkpoint_strategy=checkpoint,
|
|
529
|
+
early_stopping_patience=patience,
|
|
530
|
+
early_stopping_min_delta=float(min_delta),
|
|
531
|
+
early_stopping_minimum_epochs=minimum_epochs,
|
|
532
|
+
lr_scheduler=scheduler_type,
|
|
533
|
+
lr_scheduler_kwargs=scheduler_parameters,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _load_training_policy_ablation(raw):
|
|
538
|
+
"""Validate a training-policy ablation configuration."""
|
|
539
|
+
unknown = set(raw).difference({
|
|
540
|
+
"version", "kind", "output", "error_output", "epoch_history_output",
|
|
541
|
+
"data_root", "seeds", "selected_configurations", "datasets", "models",
|
|
542
|
+
"splits", "evaluation", "training", "training_policies",
|
|
543
|
+
})
|
|
544
|
+
if unknown:
|
|
545
|
+
raise ValueError(f"Unknown ablation setting: {sorted(unknown)[0]}")
|
|
546
|
+
if raw.get("version") != 1:
|
|
547
|
+
raise ValueError("training-policy ablations require version 1")
|
|
548
|
+
|
|
549
|
+
output = raw.get("output", "out/training_policy_ablation.csv")
|
|
550
|
+
error_output = raw.get(
|
|
551
|
+
"error_output", str(Path(output).with_suffix(".errors.jsonl"))
|
|
552
|
+
)
|
|
553
|
+
epoch_output = raw.get("epoch_history_output")
|
|
554
|
+
data_root = raw.get("data_root", "data")
|
|
555
|
+
for value, name in (
|
|
556
|
+
(output, "output"), (error_output, "error_output"),
|
|
557
|
+
(epoch_output, "epoch_history_output"), (data_root, "data_root"),
|
|
558
|
+
):
|
|
559
|
+
if not isinstance(value, str) or not value.strip():
|
|
560
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
561
|
+
|
|
562
|
+
seeds = _require_unique_sequence(raw.get("seeds"), "seeds")
|
|
563
|
+
if any(
|
|
564
|
+
not isinstance(seed, int) or isinstance(seed, bool) or seed < 0
|
|
565
|
+
for seed in seeds
|
|
566
|
+
):
|
|
567
|
+
raise ValueError("seeds must be non-negative integers")
|
|
568
|
+
datasets = tuple(
|
|
569
|
+
_resolve_dataset_name(name)
|
|
570
|
+
for name in _require_unique_sequence(raw.get("datasets"), "datasets")
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
splits = _require_mapping(raw.get("splits"), "splits")
|
|
574
|
+
if set(splits) != {
|
|
575
|
+
"protocol", "query_fraction", "validation_query_fraction",
|
|
576
|
+
"train_ratio", "paired_across_policies",
|
|
577
|
+
}:
|
|
578
|
+
raise ValueError("splits must define the training-ablation protocol")
|
|
579
|
+
if splits["protocol"] != "matched-semi-inductive":
|
|
580
|
+
raise ValueError("unsupported ablation split protocol")
|
|
581
|
+
if splits["paired_across_policies"] is not True:
|
|
582
|
+
raise ValueError("ablation splits must be paired across policies")
|
|
583
|
+
|
|
584
|
+
evaluation = _require_mapping(raw.get("evaluation"), "evaluation")
|
|
585
|
+
if set(evaluation).difference({
|
|
586
|
+
"split_types", "primary_split", "primary_metric", "secondary_metrics"
|
|
587
|
+
}):
|
|
588
|
+
raise ValueError("Unknown evaluation setting")
|
|
589
|
+
if evaluation.get("split_types") != ["transductive", "inductive"]:
|
|
590
|
+
raise ValueError("ablation must evaluate both split types")
|
|
591
|
+
if evaluation.get("primary_split") != "inductive":
|
|
592
|
+
raise ValueError("ablation primary split must be inductive")
|
|
593
|
+
if evaluation.get("primary_metric") != "roc_auc":
|
|
594
|
+
raise ValueError("ablation primary metric must be roc_auc")
|
|
595
|
+
if evaluation.get("secondary_metrics") != ["average_precision"]:
|
|
596
|
+
raise ValueError("ablation secondary metric must be average_precision")
|
|
597
|
+
|
|
598
|
+
training = _require_mapping(raw.get("training"), "training")
|
|
599
|
+
unknown_training = set(training).difference({
|
|
600
|
+
"device", "selection_metric", "log_epoch_history",
|
|
601
|
+
"epoch_history_metrics",
|
|
602
|
+
})
|
|
603
|
+
if unknown_training:
|
|
604
|
+
raise ValueError(f"Unknown training setting: {sorted(unknown_training)[0]}")
|
|
605
|
+
if training.get("selection_metric") not in {"roc_auc", "average_precision"}:
|
|
606
|
+
raise ValueError("Unknown selection metric")
|
|
607
|
+
if not isinstance(training.get("device"), str) or not training["device"]:
|
|
608
|
+
raise ValueError("training.device must be a non-empty string")
|
|
609
|
+
if training.get("log_epoch_history") is not True:
|
|
610
|
+
raise ValueError("training-policy ablation must log epoch history")
|
|
611
|
+
if training.get("epoch_history_metrics") != [
|
|
612
|
+
"training_loss", "validation_roc_auc"
|
|
613
|
+
]:
|
|
614
|
+
raise ValueError("unsupported epoch_history_metrics")
|
|
615
|
+
|
|
616
|
+
policies_raw = _require_unique_sequence(
|
|
617
|
+
raw.get("training_policies"), "training_policies"
|
|
618
|
+
)
|
|
619
|
+
policies = tuple(
|
|
620
|
+
_parse_training_policy(policy, index)
|
|
621
|
+
for index, policy in enumerate(policies_raw)
|
|
622
|
+
)
|
|
623
|
+
if len({policy.name for policy in policies}) != len(policies):
|
|
624
|
+
raise ValueError("training policy names must be unique")
|
|
625
|
+
|
|
626
|
+
defaults = _validate_defaults({
|
|
627
|
+
"query_size": None,
|
|
628
|
+
"query_fraction": splits["query_fraction"],
|
|
629
|
+
"validation_query_size": None,
|
|
630
|
+
"validation_query_fraction": splits["validation_query_fraction"],
|
|
631
|
+
"train_ratio": splits["train_ratio"],
|
|
632
|
+
"selection_metric": training["selection_metric"],
|
|
633
|
+
"device": training["device"],
|
|
634
|
+
})
|
|
635
|
+
model_names = _require_unique_sequence(raw.get("models"), "models")
|
|
636
|
+
if any(not isinstance(name, str) for name in model_names):
|
|
637
|
+
raise ValueError("ablation models must be architecture names")
|
|
638
|
+
models = tuple(
|
|
639
|
+
_parse_model_grid({"type": name}, defaults, index)
|
|
640
|
+
for index, name in enumerate(model_names)
|
|
641
|
+
)
|
|
642
|
+
return BenchmarkConfig(
|
|
643
|
+
output=output,
|
|
644
|
+
data_root=data_root,
|
|
645
|
+
seeds=tuple(seeds),
|
|
646
|
+
defaults=defaults,
|
|
647
|
+
datasets=datasets,
|
|
648
|
+
models=models,
|
|
649
|
+
error_output=error_output,
|
|
650
|
+
selected_configurations=_selected_configuration_path(
|
|
651
|
+
raw.get("selected_configurations")
|
|
652
|
+
),
|
|
653
|
+
kind="training-policy-ablation",
|
|
654
|
+
training_policies=policies,
|
|
655
|
+
epoch_history_output=epoch_output,
|
|
656
|
+
log_epoch_history=True,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def load_benchmark_config(path):
|
|
661
|
+
"""Load and validate a benchmark YAML file."""
|
|
662
|
+
path = Path(path)
|
|
663
|
+
with path.open("r", encoding="utf-8") as config_file:
|
|
664
|
+
raw = yaml.safe_load(config_file)
|
|
665
|
+
raw = _require_mapping(raw, "benchmark configuration")
|
|
666
|
+
if raw.get("kind") == "training-policy-ablation":
|
|
667
|
+
return _load_training_policy_ablation(raw)
|
|
668
|
+
unknown = set(raw).difference({
|
|
669
|
+
"version", "output", "error_output", "data_root", "seeds",
|
|
670
|
+
"defaults", "datasets", "models", "kind", "selected_configurations",
|
|
671
|
+
})
|
|
672
|
+
if unknown:
|
|
673
|
+
raise ValueError(f"Unknown benchmark setting: {sorted(unknown)[0]}")
|
|
674
|
+
version = raw.get("version")
|
|
675
|
+
if version not in {1, 2}:
|
|
676
|
+
raise ValueError("benchmark configuration version must be 1 or 2")
|
|
677
|
+
|
|
678
|
+
selected_path = None
|
|
679
|
+
selected_raw = raw.get("selected_configurations")
|
|
680
|
+
if version == 1 and selected_raw is not None:
|
|
681
|
+
raise ValueError("selected_configurations require configuration version 2")
|
|
682
|
+
if version == 2:
|
|
683
|
+
if raw.get("kind") != "selected-hyperparameter-benchmark":
|
|
684
|
+
raise ValueError(
|
|
685
|
+
"version-2 configurations must use kind "
|
|
686
|
+
"'selected-hyperparameter-benchmark'"
|
|
687
|
+
)
|
|
688
|
+
selected_path = _selected_configuration_path(selected_raw)
|
|
689
|
+
|
|
690
|
+
output = raw.get("output", "out/benchmark.csv")
|
|
691
|
+
data_root = raw.get("data_root", "data")
|
|
692
|
+
error_output = raw.get(
|
|
693
|
+
"error_output", str(Path(output).with_suffix(".errors.jsonl"))
|
|
694
|
+
)
|
|
695
|
+
for value, name in (
|
|
696
|
+
(output, "output"), (data_root, "data_root"),
|
|
697
|
+
(error_output, "error_output"),
|
|
698
|
+
):
|
|
699
|
+
if not isinstance(value, str) or not value.strip():
|
|
700
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
701
|
+
|
|
702
|
+
seeds = _require_unique_sequence(raw.get("seeds"), "seeds")
|
|
703
|
+
if any(
|
|
704
|
+
not isinstance(seed, int) or isinstance(seed, bool) or seed < 0
|
|
705
|
+
for seed in seeds
|
|
706
|
+
):
|
|
707
|
+
raise ValueError("seeds must be non-negative integers")
|
|
708
|
+
defaults = _validate_defaults(raw.get("defaults", {}))
|
|
709
|
+
datasets = tuple(
|
|
710
|
+
_resolve_dataset_name(name)
|
|
711
|
+
for name in _require_unique_sequence(raw.get("datasets"), "datasets")
|
|
712
|
+
)
|
|
713
|
+
raw_models = _require_unique_sequence(raw.get("models"), "models")
|
|
714
|
+
if version == 2:
|
|
715
|
+
if any(not isinstance(model, str) for model in raw_models):
|
|
716
|
+
raise ValueError("version-2 models must be architecture names")
|
|
717
|
+
raw_models = [{"type": model} for model in raw_models]
|
|
718
|
+
models = tuple(
|
|
719
|
+
_parse_model_grid(model, defaults, index)
|
|
720
|
+
for index, model in enumerate(raw_models)
|
|
721
|
+
)
|
|
722
|
+
return BenchmarkConfig(
|
|
723
|
+
output=output,
|
|
724
|
+
data_root=data_root,
|
|
725
|
+
seeds=tuple(seeds),
|
|
726
|
+
defaults=defaults,
|
|
727
|
+
datasets=datasets,
|
|
728
|
+
models=models,
|
|
729
|
+
error_output=error_output,
|
|
730
|
+
selected_configurations=selected_path,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
_SELECTED_PARAMETER_KEYS = {
|
|
735
|
+
"hidden_channels", "out_channels", "learning_rate", "weight_decay",
|
|
736
|
+
"normalize_features", "conv_layer_type", "reduction_method",
|
|
737
|
+
"regularization_type", "regularization_strength",
|
|
738
|
+
"decoder_hidden_channels", "decoder_dropout", "kl_weight",
|
|
739
|
+
"deal_profile", "theta_ss", "theta_aa", "theta_sa", "lambda_ss",
|
|
740
|
+
"lambda_aa", "lambda_sa", "similarity_scale", "similarity_margin",
|
|
741
|
+
"gatv2_kwargs",
|
|
742
|
+
}
|
|
743
|
+
_DEAL_PARAMETER_MAP = {
|
|
744
|
+
"theta_ss": "deal_theta_ss",
|
|
745
|
+
"theta_aa": "deal_theta_aa",
|
|
746
|
+
"theta_sa": "deal_theta_sa",
|
|
747
|
+
"lambda_ss": "deal_lambda_ss",
|
|
748
|
+
"lambda_aa": "deal_lambda_aa",
|
|
749
|
+
"lambda_sa": "deal_lambda_sa",
|
|
750
|
+
"similarity_scale": "deal_similarity_scale",
|
|
751
|
+
"similarity_margin": "deal_similarity_margin",
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _load_selected_configurations(path):
|
|
756
|
+
"""Load selections produced by the hyperparameter-search runner."""
|
|
757
|
+
if not path.exists():
|
|
758
|
+
raise FileNotFoundError(f"selected configurations not found: {path}")
|
|
759
|
+
with path.open(encoding="utf-8") as stream:
|
|
760
|
+
document = yaml.safe_load(stream)
|
|
761
|
+
document = _require_mapping(document, "selected configuration document")
|
|
762
|
+
if document.get("version") != 1 or document.get("kind") != (
|
|
763
|
+
"selected-hyperparameters"
|
|
764
|
+
):
|
|
765
|
+
raise ValueError("expected a version-1 selected-hyperparameters document")
|
|
766
|
+
return _require_mapping(document.get("selections"), "selections")
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _selected_experiment_job(config, dataset, model, selections):
|
|
770
|
+
"""Resolve one selected dataset–architecture pair into a job."""
|
|
771
|
+
dataset_selections = _require_mapping(
|
|
772
|
+
selections.get(dataset), f"selections.{dataset}"
|
|
773
|
+
)
|
|
774
|
+
selection = _require_mapping(
|
|
775
|
+
dataset_selections.get(model.type),
|
|
776
|
+
f"selections.{dataset}.{model.type}",
|
|
777
|
+
)
|
|
778
|
+
hyperparameters = _require_mapping(
|
|
779
|
+
selection.get("hyperparameters"),
|
|
780
|
+
f"selections.{dataset}.{model.type}.hyperparameters",
|
|
781
|
+
)
|
|
782
|
+
unknown = set(hyperparameters).difference(_SELECTED_PARAMETER_KEYS)
|
|
783
|
+
if unknown:
|
|
784
|
+
raise ValueError(
|
|
785
|
+
f"Unknown selected hyperparameter: {sorted(unknown)[0]}"
|
|
786
|
+
)
|
|
787
|
+
values = dict(hyperparameters)
|
|
788
|
+
conv_layer = values.pop(
|
|
789
|
+
"conv_layer_type", config.defaults["conv_layer_type"]
|
|
790
|
+
)
|
|
791
|
+
if conv_layer not in _CONV_LAYER_TYPES:
|
|
792
|
+
raise ValueError("Unknown selected convolution layer")
|
|
793
|
+
|
|
794
|
+
model_spec = get_model_spec(model.type)
|
|
795
|
+
reducer = values.pop(
|
|
796
|
+
"reduction_method", config.defaults["reduction_method"]
|
|
797
|
+
)
|
|
798
|
+
if model_spec.uses_reduced_features:
|
|
799
|
+
if reducer not in available_reduction_methods():
|
|
800
|
+
raise ValueError("Unknown selected reduction method")
|
|
801
|
+
elif "reduction_method" in hyperparameters:
|
|
802
|
+
raise ValueError(
|
|
803
|
+
f"{model.type} cannot use a selected reduction method"
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
regularization_type = values.pop("regularization_type", "none")
|
|
807
|
+
regularization_strength = values.pop("regularization_strength", 0.0)
|
|
808
|
+
regularization = _parse_regularization(
|
|
809
|
+
[{"type": regularization_type, "strength": regularization_strength}],
|
|
810
|
+
model.type,
|
|
811
|
+
) if model_spec.supports_agae_regularization else (
|
|
812
|
+
RegularizationConfig("none", 0.0),
|
|
813
|
+
)
|
|
814
|
+
if not model_spec.supports_agae_regularization and (
|
|
815
|
+
"regularization_type" in hyperparameters
|
|
816
|
+
or "regularization_strength" in hyperparameters
|
|
817
|
+
):
|
|
818
|
+
raise ValueError(f"{model.type} cannot use selected regularization")
|
|
819
|
+
|
|
820
|
+
values.pop("deal_profile", None)
|
|
821
|
+
for source, destination in _DEAL_PARAMETER_MAP.items():
|
|
822
|
+
if source in values:
|
|
823
|
+
if model.type != "deal":
|
|
824
|
+
raise ValueError(f"{source} is only supported by DEAL")
|
|
825
|
+
values[destination] = values.pop(source)
|
|
826
|
+
|
|
827
|
+
parameters = {**config.defaults, **values}
|
|
828
|
+
parameters.update({
|
|
829
|
+
"out_filepath": config.output,
|
|
830
|
+
"data_root": config.data_root,
|
|
831
|
+
})
|
|
832
|
+
_validate_job_parameters(parameters)
|
|
833
|
+
if not model_spec.trainable and parameters["lr_scheduler"] != "none":
|
|
834
|
+
raise ValueError(f"{model.type} cannot use a learning-rate scheduler")
|
|
835
|
+
return ExperimentJob(
|
|
836
|
+
dataset=dataset,
|
|
837
|
+
model_type=model.type,
|
|
838
|
+
conv_layer_type=conv_layer,
|
|
839
|
+
reduction_method=reducer,
|
|
840
|
+
regularization=regularization[0],
|
|
841
|
+
seeds=config.seeds,
|
|
842
|
+
parameters=parameters,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def expand_benchmark_config(config):
|
|
847
|
+
"""Expand a validated configuration into deterministic experiment jobs."""
|
|
848
|
+
if not isinstance(config, BenchmarkConfig):
|
|
849
|
+
raise TypeError("config must be a BenchmarkConfig")
|
|
850
|
+
if config.selected_configurations is not None:
|
|
851
|
+
selections = _load_selected_configurations(
|
|
852
|
+
config.selected_configurations
|
|
853
|
+
)
|
|
854
|
+
selected_jobs = tuple(
|
|
855
|
+
_selected_experiment_job(config, dataset, model, selections)
|
|
856
|
+
for dataset, model in product(config.datasets, config.models)
|
|
857
|
+
)
|
|
858
|
+
if not config.training_policies:
|
|
859
|
+
return selected_jobs
|
|
860
|
+
jobs = []
|
|
861
|
+
for job, policy in product(selected_jobs, config.training_policies):
|
|
862
|
+
parameters = {
|
|
863
|
+
**job.parameters,
|
|
864
|
+
"epochs": policy.max_epochs,
|
|
865
|
+
"training_policy": policy.name,
|
|
866
|
+
"checkpoint_strategy": policy.checkpoint_strategy,
|
|
867
|
+
"early_stopping_patience": policy.early_stopping_patience,
|
|
868
|
+
"early_stopping_min_delta": policy.early_stopping_min_delta,
|
|
869
|
+
"early_stopping_minimum_epochs": (
|
|
870
|
+
policy.early_stopping_minimum_epochs
|
|
871
|
+
),
|
|
872
|
+
"lr_scheduler": policy.lr_scheduler,
|
|
873
|
+
"lr_scheduler_kwargs": policy.lr_scheduler_kwargs,
|
|
874
|
+
}
|
|
875
|
+
_validate_job_parameters(parameters)
|
|
876
|
+
if not get_model_spec(job.model_type).trainable and (
|
|
877
|
+
parameters["lr_scheduler"] != "none"
|
|
878
|
+
):
|
|
879
|
+
raise ValueError(
|
|
880
|
+
f"{job.model_type} cannot use a learning-rate scheduler"
|
|
881
|
+
)
|
|
882
|
+
jobs.append(replace(job, parameters=parameters))
|
|
883
|
+
return tuple(jobs)
|
|
884
|
+
|
|
885
|
+
jobs = []
|
|
886
|
+
for dataset, model in product(config.datasets, config.models):
|
|
887
|
+
for conv_layer, reducer, regularizer in product(
|
|
888
|
+
model.conv_layer_types,
|
|
889
|
+
model.reduction_methods,
|
|
890
|
+
model.regularization,
|
|
891
|
+
):
|
|
892
|
+
parameters = {**config.defaults, **model.parameters}
|
|
893
|
+
parameters.update({
|
|
894
|
+
"out_filepath": config.output,
|
|
895
|
+
"data_root": config.data_root,
|
|
896
|
+
})
|
|
897
|
+
_validate_job_parameters(parameters)
|
|
898
|
+
if not get_model_spec(model.type).trainable and (
|
|
899
|
+
parameters["lr_scheduler"] != "none"
|
|
900
|
+
):
|
|
901
|
+
raise ValueError(
|
|
902
|
+
f"{model.type} cannot use a learning-rate scheduler"
|
|
903
|
+
)
|
|
904
|
+
jobs.append(ExperimentJob(
|
|
905
|
+
dataset=dataset,
|
|
906
|
+
model_type=model.type,
|
|
907
|
+
conv_layer_type=conv_layer,
|
|
908
|
+
reduction_method=reducer,
|
|
909
|
+
regularization=regularizer,
|
|
910
|
+
seeds=config.seeds,
|
|
911
|
+
parameters=parameters,
|
|
912
|
+
))
|
|
913
|
+
return tuple(jobs)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def load_completed_results(path):
|
|
917
|
+
"""Return completed ``(configuration_id, seed)`` pairs from a CSV file."""
|
|
918
|
+
path = Path(path)
|
|
919
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
920
|
+
return set()
|
|
921
|
+
with path.open("r", newline="", encoding="utf-8") as csv_file:
|
|
922
|
+
reader = csv.DictReader(csv_file)
|
|
923
|
+
if reader.fieldnames != CSVResultLogger.FIELDNAMES:
|
|
924
|
+
raise ValueError("existing CSV schema does not match the logger schema")
|
|
925
|
+
completed = set()
|
|
926
|
+
for row in reader:
|
|
927
|
+
if row["configuration_id"]:
|
|
928
|
+
completed.add((row["configuration_id"], int(row["random_seed"])))
|
|
929
|
+
return completed
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _training_configuration_id(job):
|
|
933
|
+
"""Return the ID of the trajectory shared by both test split rows."""
|
|
934
|
+
parameters = {
|
|
935
|
+
name: value for name, value in job.parameters.items()
|
|
936
|
+
if name not in {"out_filepath", "data_root"}
|
|
937
|
+
}
|
|
938
|
+
return make_configuration_id({
|
|
939
|
+
"dataset": job.dataset,
|
|
940
|
+
"model_type": job.model_type,
|
|
941
|
+
"conv_layer_type": job.conv_layer_type,
|
|
942
|
+
"reduction_method": job.reduction_method,
|
|
943
|
+
"regularization_type": job.regularization.type,
|
|
944
|
+
"regularization_strength": job.regularization.strength,
|
|
945
|
+
"parameters": parameters,
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def load_completed_epoch_history(path):
|
|
950
|
+
"""Return completed training-configuration, seed, and epoch tuples."""
|
|
951
|
+
path = Path(path)
|
|
952
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
953
|
+
return set()
|
|
954
|
+
with path.open("r", newline="", encoding="utf-8") as csv_file:
|
|
955
|
+
reader = csv.DictReader(csv_file)
|
|
956
|
+
if reader.fieldnames != CSVEpochHistoryLogger.FIELDNAMES:
|
|
957
|
+
raise ValueError(
|
|
958
|
+
"existing CSV schema does not match the epoch logger schema"
|
|
959
|
+
)
|
|
960
|
+
return {
|
|
961
|
+
(
|
|
962
|
+
row["configuration_id"],
|
|
963
|
+
int(row["random_seed"]),
|
|
964
|
+
int(row["epoch"]),
|
|
965
|
+
)
|
|
966
|
+
for row in reader
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _append_failure(path, run_id, job, error):
|
|
971
|
+
path = Path(path)
|
|
972
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
973
|
+
record = {
|
|
974
|
+
"run_id": run_id,
|
|
975
|
+
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
|
|
976
|
+
"job": {
|
|
977
|
+
"label": job.label,
|
|
978
|
+
"dataset": job.dataset,
|
|
979
|
+
"model_type": job.model_type,
|
|
980
|
+
"conv_layer_type": job.conv_layer_type,
|
|
981
|
+
"reduction_method": job.reduction_method,
|
|
982
|
+
"regularization_type": job.regularization.type,
|
|
983
|
+
"regularization_strength": job.regularization.strength,
|
|
984
|
+
"seeds": list(job.seeds),
|
|
985
|
+
"parameters": job.parameters,
|
|
986
|
+
},
|
|
987
|
+
"error_type": type(error).__name__,
|
|
988
|
+
"message": str(error),
|
|
989
|
+
}
|
|
990
|
+
with path.open("a", encoding="utf-8") as error_file:
|
|
991
|
+
error_file.write(json.dumps(record, sort_keys=True) + "\n")
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def describe_benchmark(config, jobs):
|
|
995
|
+
"""Return a concise benchmark expansion summary."""
|
|
996
|
+
return {
|
|
997
|
+
"datasets": len(config.datasets),
|
|
998
|
+
"jobs": len(jobs),
|
|
999
|
+
"seeds_per_job": len(config.seeds),
|
|
1000
|
+
"model_runs": len(jobs) * len(config.seeds),
|
|
1001
|
+
"expected_result_rows": len(jobs) * len(config.seeds) * 2,
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def execute_benchmark(
|
|
1006
|
+
config, jobs, *, dry_run=False, resume=False, fail_fast=False,
|
|
1007
|
+
verbose=False, verbose_details=False, run_id=None, runner=run_experiment,
|
|
1008
|
+
):
|
|
1009
|
+
"""Execute expanded jobs sequentially and isolate job failures."""
|
|
1010
|
+
if not isinstance(config, BenchmarkConfig):
|
|
1011
|
+
raise TypeError("config must be a BenchmarkConfig")
|
|
1012
|
+
jobs = tuple(jobs)
|
|
1013
|
+
benchmark_run_id = run_id or str(uuid4())
|
|
1014
|
+
expected_rows = len(jobs) * len(config.seeds) * 2
|
|
1015
|
+
if dry_run:
|
|
1016
|
+
return BenchmarkSummary(
|
|
1017
|
+
benchmark_run_id, len(jobs), 0, 0, expected_rows
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
completed = load_completed_results(config.output) if resume else set()
|
|
1021
|
+
epoch_logger = None
|
|
1022
|
+
completed_epochs = set()
|
|
1023
|
+
if config.log_epoch_history:
|
|
1024
|
+
epoch_logger = CSVEpochHistoryLogger(
|
|
1025
|
+
config.epoch_history_output, run_id=benchmark_run_id
|
|
1026
|
+
)
|
|
1027
|
+
if resume:
|
|
1028
|
+
completed_epochs = load_completed_epoch_history(
|
|
1029
|
+
config.epoch_history_output
|
|
1030
|
+
)
|
|
1031
|
+
succeeded = 0
|
|
1032
|
+
failed = 0
|
|
1033
|
+
show_progress = verbose or verbose_details
|
|
1034
|
+
compact_progress = verbose and not verbose_details
|
|
1035
|
+
progress = tqdm(
|
|
1036
|
+
total=(
|
|
1037
|
+
len(jobs) * len(config.seeds) if compact_progress else len(jobs)
|
|
1038
|
+
),
|
|
1039
|
+
desc="Benchmark runs" if compact_progress else "Benchmark jobs",
|
|
1040
|
+
unit="run" if compact_progress else "job",
|
|
1041
|
+
disable=not show_progress,
|
|
1042
|
+
position=0,
|
|
1043
|
+
)
|
|
1044
|
+
progress_counts = {"ok": 0, "failed": 0, "skipped": 0}
|
|
1045
|
+
|
|
1046
|
+
def update_display(label, stage=None):
|
|
1047
|
+
details = " ".join(
|
|
1048
|
+
f"{name}={value}" for name, value in progress_counts.items()
|
|
1049
|
+
)
|
|
1050
|
+
if stage is not None:
|
|
1051
|
+
details = f"{label} stage={stage} {details}"
|
|
1052
|
+
else:
|
|
1053
|
+
details = f"{label} {details}"
|
|
1054
|
+
progress.set_postfix_str(details)
|
|
1055
|
+
|
|
1056
|
+
for job in jobs:
|
|
1057
|
+
accounted_runs = 0
|
|
1058
|
+
training_configuration_id = _training_configuration_id(job)
|
|
1059
|
+
|
|
1060
|
+
def log_epoch(record):
|
|
1061
|
+
key = (
|
|
1062
|
+
training_configuration_id,
|
|
1063
|
+
record["random_seed"],
|
|
1064
|
+
record["epoch"],
|
|
1065
|
+
)
|
|
1066
|
+
if key in completed_epochs:
|
|
1067
|
+
return
|
|
1068
|
+
epoch_logger.log(EpochHistoryRecord(
|
|
1069
|
+
configuration_id=training_configuration_id,
|
|
1070
|
+
dataset=record["dataset"],
|
|
1071
|
+
model_type=record["model_type"],
|
|
1072
|
+
random_seed=record["random_seed"],
|
|
1073
|
+
training_policy=record["training_policy"],
|
|
1074
|
+
epoch=record["epoch"],
|
|
1075
|
+
training_loss=record["training_loss"],
|
|
1076
|
+
learning_rate=record["learning_rate"],
|
|
1077
|
+
validation_roc_auc=record["validation_roc_auc"],
|
|
1078
|
+
validation_average_precision=(
|
|
1079
|
+
record["validation_average_precision"]
|
|
1080
|
+
),
|
|
1081
|
+
))
|
|
1082
|
+
completed_epochs.add(key)
|
|
1083
|
+
|
|
1084
|
+
def handle_progress(event, **event_values):
|
|
1085
|
+
nonlocal accounted_runs
|
|
1086
|
+
dataset = event_values.get("dataset", job.dataset)
|
|
1087
|
+
model_type = event_values.get("model_type", job.model_type)
|
|
1088
|
+
seed = event_values.get("seed")
|
|
1089
|
+
label = f"{dataset}/{model_type}"
|
|
1090
|
+
if seed is not None:
|
|
1091
|
+
label += f"/seed={seed}"
|
|
1092
|
+
if event == "run_completed":
|
|
1093
|
+
progress_counts["ok"] += 1
|
|
1094
|
+
accounted_runs += 1
|
|
1095
|
+
progress.update(1)
|
|
1096
|
+
update_display(label)
|
|
1097
|
+
elif event == "run_skipped":
|
|
1098
|
+
progress_counts["skipped"] += 1
|
|
1099
|
+
accounted_runs += 1
|
|
1100
|
+
progress.update(1)
|
|
1101
|
+
update_display(label)
|
|
1102
|
+
else:
|
|
1103
|
+
update_display(label, event_values.get("stage"))
|
|
1104
|
+
|
|
1105
|
+
if not compact_progress:
|
|
1106
|
+
progress.set_postfix_str(job.label)
|
|
1107
|
+
try:
|
|
1108
|
+
runner_kwargs = {
|
|
1109
|
+
"run_id": benchmark_run_id,
|
|
1110
|
+
"completed_results": completed,
|
|
1111
|
+
}
|
|
1112
|
+
if compact_progress:
|
|
1113
|
+
runner_kwargs.update({
|
|
1114
|
+
"progress_callback": handle_progress,
|
|
1115
|
+
"compact_progress": True,
|
|
1116
|
+
})
|
|
1117
|
+
namespace = job.to_namespace(verbose=show_progress)
|
|
1118
|
+
if epoch_logger is not None:
|
|
1119
|
+
namespace.epoch_callback = log_epoch
|
|
1120
|
+
runner(namespace, **runner_kwargs)
|
|
1121
|
+
succeeded += 1
|
|
1122
|
+
if compact_progress and accounted_runs < len(job.seeds):
|
|
1123
|
+
unreported = len(job.seeds) - accounted_runs
|
|
1124
|
+
progress_counts["ok"] += unreported
|
|
1125
|
+
progress.update(unreported)
|
|
1126
|
+
update_display(job.label)
|
|
1127
|
+
except Exception as error:
|
|
1128
|
+
failed += 1
|
|
1129
|
+
_append_failure(config.error_output, benchmark_run_id, job, error)
|
|
1130
|
+
if compact_progress:
|
|
1131
|
+
unreported = len(job.seeds) - accounted_runs
|
|
1132
|
+
progress_counts["failed"] += unreported
|
|
1133
|
+
progress.update(unreported)
|
|
1134
|
+
update_display(job.label)
|
|
1135
|
+
tqdm.write(
|
|
1136
|
+
f"FAILED {job.label}: {type(error).__name__}: {error}"
|
|
1137
|
+
)
|
|
1138
|
+
if fail_fast:
|
|
1139
|
+
progress.close()
|
|
1140
|
+
raise
|
|
1141
|
+
finally:
|
|
1142
|
+
if not compact_progress:
|
|
1143
|
+
progress.update(1)
|
|
1144
|
+
progress.close()
|
|
1145
|
+
return BenchmarkSummary(
|
|
1146
|
+
benchmark_run_id, len(jobs), succeeded, failed, expected_rows
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def parse_args():
|
|
1151
|
+
"""Parse benchmark-runner arguments."""
|
|
1152
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1153
|
+
parser.add_argument("config", help="path to a benchmark YAML file")
|
|
1154
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
1155
|
+
parser.add_argument("--resume", action="store_true")
|
|
1156
|
+
parser.add_argument("--fail-fast", action="store_true")
|
|
1157
|
+
parser.add_argument("--verbose", action="store_true")
|
|
1158
|
+
parser.add_argument(
|
|
1159
|
+
"--verbose-details",
|
|
1160
|
+
action="store_true",
|
|
1161
|
+
help="show detailed nested experiment progress",
|
|
1162
|
+
)
|
|
1163
|
+
return parser.parse_args()
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def main(cli_args=None):
|
|
1167
|
+
"""Load, expand, summarize, and optionally execute a benchmark."""
|
|
1168
|
+
args = parse_args() if cli_args is None else cli_args
|
|
1169
|
+
config = load_benchmark_config(args.config)
|
|
1170
|
+
jobs = expand_benchmark_config(config)
|
|
1171
|
+
description = describe_benchmark(config, jobs)
|
|
1172
|
+
print(json.dumps(description, indent=2, sort_keys=True))
|
|
1173
|
+
if args.dry_run:
|
|
1174
|
+
for index, job in enumerate(jobs, start=1):
|
|
1175
|
+
print(f"{index:4d}. {job.label}")
|
|
1176
|
+
summary = execute_benchmark(
|
|
1177
|
+
config,
|
|
1178
|
+
jobs,
|
|
1179
|
+
dry_run=args.dry_run,
|
|
1180
|
+
resume=args.resume,
|
|
1181
|
+
fail_fast=args.fail_fast,
|
|
1182
|
+
verbose=args.verbose,
|
|
1183
|
+
verbose_details=getattr(args, "verbose_details", False),
|
|
1184
|
+
)
|
|
1185
|
+
print("Benchmark complete")
|
|
1186
|
+
print(f" Run ID: {summary.run_id}")
|
|
1187
|
+
print(
|
|
1188
|
+
f" Jobs: {summary.jobs_succeeded} succeeded, "
|
|
1189
|
+
f"{summary.jobs_failed} failed, {summary.jobs_total} total"
|
|
1190
|
+
)
|
|
1191
|
+
print(f" Expected result rows: {summary.expected_result_rows}")
|
|
1192
|
+
print(f" Results: {config.output}")
|
|
1193
|
+
if summary.jobs_failed:
|
|
1194
|
+
print(f" Errors: {config.error_output}")
|
|
1195
|
+
return summary
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
if __name__ == "__main__":
|
|
1199
|
+
main()
|