adasurrog 0.5.1__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.
adasurrog/__about__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package metadata kept separate from runtime imports."""
2
+
3
+ __version__ = "0.5.1"
adasurrog/__init__.py ADDED
@@ -0,0 +1,111 @@
1
+ """Public API for AdaSurroG."""
2
+
3
+ from .__about__ import __version__
4
+ from .active_learning import (
5
+ ActiveLearningConfig,
6
+ CandidateScores,
7
+ mc_dropout_coefficient_disagreement,
8
+ nearest_coverage_distance,
9
+ normalize_log_parameters,
10
+ score_and_select_candidates,
11
+ select_diverse_batch,
12
+ sobol_log_candidates,
13
+ )
14
+ from .checkpoint import CheckpointManager
15
+ from .config import CheckpointConfig, StopConfig, TrainerConfig
16
+ from .metrics import normalized_physical_error, normalized_rmse
17
+ from .models import (
18
+ ChebyshevFeatureMap,
19
+ HierarchicalResidualRegressor,
20
+ PODCoefficientSurrogate,
21
+ PODFitResult,
22
+ PolynomialResidualPODSurrogate,
23
+ ResidualMLP,
24
+ fit_pod,
25
+ fit_weighted_pod,
26
+ load_pod_surrogate_from_checkpoint_payload,
27
+ metric_feature_weights,
28
+ total_degree_multi_indices,
29
+ )
30
+ from .refinement import (
31
+ FullBatchLBFGSRefiner,
32
+ LBFGSRefinementConfig,
33
+ LBFGSRefinementResult,
34
+ move_module_to_device_dtype,
35
+ )
36
+ from .state import FitResult, TrainerState
37
+ from .trainer import TimeAccuracyTrainer
38
+ from .transforms import AsinhTransform, Log1pTransform
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "ActiveLearningConfig",
43
+ "AsinhTransform",
44
+ "CandidateScores",
45
+ "CheckpointConfig",
46
+ "CheckpointManager",
47
+ "ChebyshevFeatureMap",
48
+ "FitResult",
49
+ "FullBatchLBFGSRefiner",
50
+ "HierarchicalResidualRegressor",
51
+ "LBFGSRefinementConfig",
52
+ "LBFGSRefinementResult",
53
+ "move_module_to_device_dtype",
54
+ "Log1pTransform",
55
+ "PODCoefficientSurrogate",
56
+ "PODFitResult",
57
+ "PolynomialResidualPODSurrogate",
58
+ "ResidualMLP",
59
+ "StopConfig",
60
+ "TimeAccuracyTrainer",
61
+ "TrainerConfig",
62
+ "TrainerState",
63
+ "fit_pod",
64
+ "fit_weighted_pod",
65
+ "load_pod_surrogate_from_checkpoint_payload",
66
+ "mc_dropout_coefficient_disagreement",
67
+ "metric_feature_weights",
68
+ "nearest_coverage_distance",
69
+ "normalize_log_parameters",
70
+ "normalized_physical_error",
71
+ "normalized_rmse",
72
+ "score_and_select_candidates",
73
+ "select_diverse_batch",
74
+ "sobol_log_candidates",
75
+ "total_degree_multi_indices",
76
+ ]
77
+
78
+ # Universal ODE pipeline API (0.5+)
79
+ from .auto_transforms import MixedStateTransform, StateTransformSpec, infer_state_transform
80
+ from .ode_data import ODEDataset, PilotDiagnostics, generate_fixed_dataset, run_pilot
81
+ from .pipeline import (
82
+ AccuracyConfig,
83
+ AutoPipelineConfig,
84
+ BudgetConfig,
85
+ SamplingConfig,
86
+ SurrogateReport,
87
+ UniversalODEPipeline,
88
+ )
89
+ from .problem import ODEProblem, ODEProblemProtocol
90
+ from .universal_model import AdaSurrogate, DomainCheck, UniversalPODSurrogate
91
+
92
+ __all__ += [
93
+ "AccuracyConfig",
94
+ "AdaSurrogate",
95
+ "AutoPipelineConfig",
96
+ "BudgetConfig",
97
+ "DomainCheck",
98
+ "MixedStateTransform",
99
+ "ODEDataset",
100
+ "ODEProblem",
101
+ "ODEProblemProtocol",
102
+ "PilotDiagnostics",
103
+ "SamplingConfig",
104
+ "StateTransformSpec",
105
+ "SurrogateReport",
106
+ "UniversalODEPipeline",
107
+ "UniversalPODSurrogate",
108
+ "generate_fixed_dataset",
109
+ "infer_state_transform",
110
+ "run_pilot",
111
+ ]
@@ -0,0 +1,287 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import torch
6
+ from torch import Tensor, nn
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class ActiveLearningConfig:
11
+ """Configuration for pool-based active learning in positive parameter spaces."""
12
+
13
+ candidate_pool_size: int = 4096
14
+ acquisition_batch_size: int = 64
15
+ mc_passes: int = 16
16
+ prediction_batch_size: int = 1024
17
+ uncertainty_weight: float = 1.0
18
+ coverage_weight: float = 0.60
19
+ boundary_weight: float = 0.15
20
+ diversity_weight: float = 0.50
21
+ minimum_distance: float = 0.01
22
+ seed: int = 0
23
+
24
+ def __post_init__(self) -> None:
25
+ if self.candidate_pool_size < 1:
26
+ raise ValueError("candidate_pool_size must be positive")
27
+ if not 1 <= self.acquisition_batch_size <= self.candidate_pool_size:
28
+ raise ValueError("acquisition_batch_size must be within the candidate pool")
29
+ if self.mc_passes < 2:
30
+ raise ValueError("mc_passes must be at least 2")
31
+ if self.prediction_batch_size < 1:
32
+ raise ValueError("prediction_batch_size must be positive")
33
+ if (
34
+ min(
35
+ self.uncertainty_weight,
36
+ self.coverage_weight,
37
+ self.boundary_weight,
38
+ self.diversity_weight,
39
+ self.minimum_distance,
40
+ )
41
+ < 0
42
+ ):
43
+ raise ValueError("active-learning weights and distances must be non-negative")
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class CandidateScores:
48
+ parameters: Tensor
49
+ normalized_parameters: Tensor
50
+ uncertainty: Tensor
51
+ coverage: Tensor
52
+ boundary: Tensor
53
+ combined: Tensor
54
+ selected_indices: Tensor
55
+
56
+ @property
57
+ def selected_parameters(self) -> Tensor:
58
+ return self.parameters[self.selected_indices]
59
+
60
+
61
+ def normalize_log_parameters(
62
+ parameters: Tensor,
63
+ parameter_lower: Tensor,
64
+ parameter_upper: Tensor,
65
+ ) -> Tensor:
66
+ """Map positive parameters into the normalized log box [-1, 1]^d."""
67
+ parameters = torch.as_tensor(parameters)
68
+ lower = torch.as_tensor(parameter_lower, device=parameters.device, dtype=parameters.dtype)
69
+ upper = torch.as_tensor(parameter_upper, device=parameters.device, dtype=parameters.dtype)
70
+ if torch.any(parameters <= 0):
71
+ raise ValueError("parameters must be positive")
72
+ if torch.any(lower <= 0) or torch.any(upper <= lower):
73
+ raise ValueError("parameter bounds must be positive and ordered")
74
+ return 2.0 * (parameters.log() - lower.log()) / (upper.log() - lower.log()) - 1.0
75
+
76
+
77
+ def sobol_log_candidates(
78
+ count: int,
79
+ parameter_lower: Tensor,
80
+ parameter_upper: Tensor,
81
+ *,
82
+ seed: int,
83
+ ) -> Tensor:
84
+ """Generate a scrambled Sobol pool with log-uniform parameter marginals."""
85
+ lower = torch.as_tensor(parameter_lower, dtype=torch.float64)
86
+ upper = torch.as_tensor(parameter_upper, dtype=torch.float64)
87
+ if count < 1:
88
+ raise ValueError("count must be positive")
89
+ if torch.any(lower <= 0) or torch.any(upper <= lower):
90
+ raise ValueError("parameter bounds must be positive and ordered")
91
+ engine = torch.quasirandom.SobolEngine(
92
+ dimension=int(lower.numel()),
93
+ scramble=True,
94
+ seed=int(seed),
95
+ )
96
+ unit = engine.draw(count).to(torch.float64)
97
+ values = torch.exp(lower.log() + unit * (upper.log() - lower.log()))
98
+ return values.to(torch.float32)
99
+
100
+
101
+ @torch.no_grad()
102
+ def nearest_coverage_distance(
103
+ candidates_normalized: Tensor,
104
+ existing_normalized: Tensor,
105
+ *,
106
+ chunk_size: int = 2048,
107
+ ) -> Tensor:
108
+ """
109
+ Distance to the nearest existing point, scaled approximately to [0, 1].
110
+
111
+ Coordinates are assumed to lie in [-1, 1]. The maximum box-diagonal
112
+ distance is 2 * sqrt(dimension).
113
+ """
114
+ candidates = torch.as_tensor(candidates_normalized, dtype=torch.float32)
115
+ existing = torch.as_tensor(
116
+ existing_normalized,
117
+ device=candidates.device,
118
+ dtype=candidates.dtype,
119
+ )
120
+ if candidates.ndim != 2 or existing.ndim != 2:
121
+ raise ValueError("candidate and existing parameters must be rank-2 tensors")
122
+ if candidates.shape[1] != existing.shape[1]:
123
+ raise ValueError("candidate and existing parameter dimensions differ")
124
+ if existing.shape[0] == 0:
125
+ return torch.ones(candidates.shape[0], device=candidates.device)
126
+
127
+ scale = 2.0 * float(candidates.shape[1]) ** 0.5
128
+ parts: list[Tensor] = []
129
+ for start in range(0, candidates.shape[0], chunk_size):
130
+ current = candidates[start : start + chunk_size]
131
+ distance = torch.cdist(current, existing).amin(dim=1) / scale
132
+ parts.append(distance)
133
+ return torch.cat(parts).clamp(0.0, 1.0)
134
+
135
+
136
+ def _robust_unit_interval(values: Tensor) -> Tensor:
137
+ values = torch.as_tensor(values, dtype=torch.float32)
138
+ if values.numel() == 0:
139
+ return values
140
+ low = torch.quantile(values, 0.05)
141
+ high = torch.quantile(values, 0.95)
142
+ if float(high - low) <= 1e-12:
143
+ return torch.zeros_like(values)
144
+ return ((values - low) / (high - low)).clamp(0.0, 1.0)
145
+
146
+
147
+ @torch.no_grad()
148
+ def mc_dropout_coefficient_disagreement(
149
+ model: nn.Module,
150
+ parameters: Tensor,
151
+ *,
152
+ passes: int = 16,
153
+ batch_size: int = 1024,
154
+ ) -> Tensor:
155
+ """
156
+ Estimate epistemic uncertainty from MC-dropout coefficient predictions.
157
+
158
+ The model must expose ``predict_standardized_coefficients``. LayerNorm and
159
+ residual blocks are safe in train mode; only Dropout changes predictions.
160
+ """
161
+ predictor = getattr(model, "predict_standardized_coefficients", None)
162
+ if predictor is None:
163
+ raise TypeError("model must define predict_standardized_coefficients")
164
+ if passes < 2:
165
+ raise ValueError("passes must be at least 2")
166
+
167
+ device = next(model.parameters()).device
168
+ was_training = model.training
169
+ model.train(True)
170
+ outputs: list[Tensor] = []
171
+ try:
172
+ for start in range(0, len(parameters), batch_size):
173
+ batch = parameters[start : start + batch_size].to(device)
174
+ samples = torch.stack([predictor(batch) for _ in range(passes)], dim=0)
175
+ disagreement = samples.std(dim=0, unbiased=False).square().mean(dim=-1).sqrt()
176
+ outputs.append(disagreement.cpu())
177
+ finally:
178
+ model.train(was_training)
179
+ return torch.cat(outputs)
180
+
181
+
182
+ def select_diverse_batch(
183
+ normalized_candidates: Tensor,
184
+ base_score: Tensor,
185
+ *,
186
+ batch_size: int,
187
+ existing_normalized: Tensor | None = None,
188
+ diversity_weight: float = 0.5,
189
+ minimum_distance: float = 0.0,
190
+ ) -> Tensor:
191
+ """Greedy score-plus-distance batch selection."""
192
+ candidates = torch.as_tensor(normalized_candidates, dtype=torch.float32).cpu()
193
+ scores = torch.as_tensor(base_score, dtype=torch.float32).cpu()
194
+ if candidates.ndim != 2 or scores.ndim != 1 or len(candidates) != len(scores):
195
+ raise ValueError("candidate and score shapes are inconsistent")
196
+ if not 1 <= batch_size <= len(candidates):
197
+ raise ValueError("batch_size is outside the candidate range")
198
+
199
+ if existing_normalized is None or len(existing_normalized) == 0:
200
+ distance_to_set = torch.ones(len(candidates))
201
+ else:
202
+ distance_to_set = nearest_coverage_distance(
203
+ candidates,
204
+ torch.as_tensor(existing_normalized, dtype=torch.float32).cpu(),
205
+ )
206
+
207
+ selected: list[int] = []
208
+ available = torch.ones(len(candidates), dtype=torch.bool)
209
+ for _ in range(batch_size):
210
+ utility = scores + diversity_weight * _robust_unit_interval(distance_to_set)
211
+ utility[~available] = -torch.inf
212
+ if minimum_distance > 0:
213
+ sufficiently_far = distance_to_set >= minimum_distance
214
+ if torch.any(available & sufficiently_far):
215
+ utility[available & ~sufficiently_far] = -torch.inf
216
+ index = int(torch.argmax(utility))
217
+ if not torch.isfinite(utility[index]):
218
+ fallback = scores.clone()
219
+ fallback[~available] = -torch.inf
220
+ index = int(torch.argmax(fallback))
221
+ selected.append(index)
222
+ available[index] = False
223
+
224
+ scale = 2.0 * float(candidates.shape[1]) ** 0.5
225
+ new_distance = (
226
+ torch.linalg.vector_norm(
227
+ candidates - candidates[index],
228
+ dim=1,
229
+ )
230
+ / scale
231
+ )
232
+ distance_to_set = torch.minimum(distance_to_set, new_distance)
233
+
234
+ return torch.tensor(selected, dtype=torch.long)
235
+
236
+
237
+ @torch.no_grad()
238
+ def score_and_select_candidates(
239
+ model: nn.Module,
240
+ candidates: Tensor,
241
+ existing_parameters: Tensor,
242
+ parameter_lower: Tensor,
243
+ parameter_upper: Tensor,
244
+ config: ActiveLearningConfig,
245
+ ) -> CandidateScores:
246
+ """Score a candidate pool using uncertainty, coverage, boundary, and diversity."""
247
+ candidates_cpu = torch.as_tensor(candidates, dtype=torch.float32).cpu()
248
+ existing_cpu = torch.as_tensor(existing_parameters, dtype=torch.float32).cpu()
249
+ lower = torch.as_tensor(parameter_lower, dtype=torch.float32).cpu()
250
+ upper = torch.as_tensor(parameter_upper, dtype=torch.float32).cpu()
251
+
252
+ normalized = normalize_log_parameters(candidates_cpu, lower, upper)
253
+ existing_normalized = normalize_log_parameters(existing_cpu, lower, upper)
254
+ uncertainty_raw = mc_dropout_coefficient_disagreement(
255
+ model,
256
+ candidates_cpu,
257
+ passes=config.mc_passes,
258
+ batch_size=config.prediction_batch_size,
259
+ )
260
+ coverage_raw = nearest_coverage_distance(normalized, existing_normalized)
261
+ boundary_raw = normalized.abs().mean(dim=1)
262
+
263
+ uncertainty = _robust_unit_interval(uncertainty_raw)
264
+ coverage = _robust_unit_interval(coverage_raw)
265
+ boundary = _robust_unit_interval(boundary_raw)
266
+ combined = (
267
+ config.uncertainty_weight * uncertainty
268
+ + config.coverage_weight * coverage
269
+ + config.boundary_weight * boundary
270
+ )
271
+ selected = select_diverse_batch(
272
+ normalized,
273
+ combined,
274
+ batch_size=config.acquisition_batch_size,
275
+ existing_normalized=existing_normalized,
276
+ diversity_weight=config.diversity_weight,
277
+ minimum_distance=config.minimum_distance,
278
+ )
279
+ return CandidateScores(
280
+ parameters=candidates_cpu,
281
+ normalized_parameters=normalized,
282
+ uncertainty=uncertainty,
283
+ coverage=coverage,
284
+ boundary=boundary,
285
+ combined=combined,
286
+ selected_indices=selected,
287
+ )
@@ -0,0 +1,140 @@
1
+ """Automatic per-state transforms for multi-scale ODE trajectories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ import torch
9
+ from torch import Tensor, nn
10
+
11
+ TRANSFORM_AFFINE = 0
12
+ TRANSFORM_LOG1P = 1
13
+ TRANSFORM_ASINH = 2
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class StateTransformSpec:
18
+ codes: np.ndarray
19
+ scales: np.ndarray
20
+ names: tuple[str, ...]
21
+ diagnostics: tuple[dict[str, float | str], ...]
22
+
23
+ def to_dict(self) -> dict[str, object]:
24
+ return {
25
+ "codes": self.codes.tolist(),
26
+ "scales": self.scales.tolist(),
27
+ "names": list(self.names),
28
+ "diagnostics": [dict(item) for item in self.diagnostics],
29
+ }
30
+
31
+
32
+ class MixedStateTransform(nn.Module):
33
+ """Apply affine, log1p, or asinh transforms independently per state."""
34
+
35
+ def __init__(self, codes: Tensor | np.ndarray, scales: Tensor | np.ndarray) -> None:
36
+ super().__init__()
37
+ codes_tensor = torch.as_tensor(codes, dtype=torch.long)
38
+ scales_tensor = torch.as_tensor(scales, dtype=torch.float32)
39
+ if codes_tensor.ndim != 1 or scales_tensor.shape != codes_tensor.shape:
40
+ raise ValueError("codes and scales must be matching one-dimensional arrays")
41
+ if torch.any(scales_tensor <= 0) or torch.any(~torch.isfinite(scales_tensor)):
42
+ raise ValueError("transform scales must be finite and positive")
43
+ if torch.any((codes_tensor < 0) | (codes_tensor > 2)):
44
+ raise ValueError("unsupported transform code")
45
+ self.register_buffer("codes", codes_tensor)
46
+ self.register_buffer("scales", scales_tensor)
47
+
48
+ def forward(self, values: Tensor) -> Tensor:
49
+ scales = self.scales.to(device=values.device, dtype=values.dtype)
50
+ codes = self.codes.to(values.device)
51
+ result = values / scales
52
+ if torch.any(codes == TRANSFORM_LOG1P):
53
+ index = codes == TRANSFORM_LOG1P
54
+ result[..., index] = torch.log1p(
55
+ torch.clamp_min(values[..., index], 0.0) / scales[index]
56
+ )
57
+ if torch.any(codes == TRANSFORM_ASINH):
58
+ index = codes == TRANSFORM_ASINH
59
+ result[..., index] = torch.asinh(values[..., index] / scales[index])
60
+ return result
61
+
62
+ def inverse(self, transformed: Tensor) -> Tensor:
63
+ scales = self.scales.to(device=transformed.device, dtype=transformed.dtype)
64
+ codes = self.codes.to(transformed.device)
65
+ result = transformed * scales
66
+ if torch.any(codes == TRANSFORM_LOG1P):
67
+ index = codes == TRANSFORM_LOG1P
68
+ result[..., index] = (
69
+ torch.expm1(torch.clamp(transformed[..., index], min=-20.0, max=30.0))
70
+ * scales[index]
71
+ )
72
+ if torch.any(codes == TRANSFORM_ASINH):
73
+ index = codes == TRANSFORM_ASINH
74
+ result[..., index] = (
75
+ torch.sinh(torch.clamp(transformed[..., index], min=-30.0, max=30.0))
76
+ * scales[index]
77
+ )
78
+ return result
79
+
80
+
81
+ def infer_state_transform(
82
+ trajectories: np.ndarray,
83
+ state_names: tuple[str, ...],
84
+ *,
85
+ dynamic_range_threshold: float = 100.0,
86
+ nonnegative_tolerance: float = 1e-10,
87
+ ) -> StateTransformSpec:
88
+ """Infer robust per-state transforms from pilot trajectories."""
89
+ values = np.asarray(trajectories, dtype=np.float64)
90
+ if values.ndim != 3 or values.shape[-1] != len(state_names):
91
+ raise ValueError("trajectories must have shape [sample, time, state]")
92
+ codes: list[int] = []
93
+ scales: list[float] = []
94
+ diagnostics: list[dict[str, float | str]] = []
95
+ for state_index, name in enumerate(state_names):
96
+ state = values[..., state_index].reshape(-1)
97
+ finite = state[np.isfinite(state)]
98
+ if finite.size == 0:
99
+ raise ValueError(f"state {name!r} contains no finite values")
100
+ absolute = np.abs(finite)
101
+ nonzero = absolute[absolute > max(nonnegative_tolerance, 1e-14)]
102
+ q50 = float(np.quantile(nonzero, 0.50)) if nonzero.size else 1.0
103
+ q90 = float(np.quantile(absolute, 0.90))
104
+ q99 = float(np.quantile(absolute, 0.99))
105
+ lower = float(np.min(finite))
106
+ upper = float(np.max(finite))
107
+ reference = max(q50, q90 * 0.1, 1e-12)
108
+ smallest = float(np.quantile(nonzero, 0.10)) if nonzero.size else reference
109
+ dynamic_range = q99 / max(smallest, 1e-30)
110
+ nonnegative = lower >= -nonnegative_tolerance * max(1.0, q99)
111
+ if nonnegative and dynamic_range >= dynamic_range_threshold:
112
+ code = TRANSFORM_LOG1P
113
+ transform_name = "log1p"
114
+ elif not nonnegative and dynamic_range >= dynamic_range_threshold:
115
+ code = TRANSFORM_ASINH
116
+ transform_name = "asinh"
117
+ else:
118
+ code = TRANSFORM_AFFINE
119
+ transform_name = "affine"
120
+ reference = max(float(np.std(finite)), q90, 1e-12)
121
+ codes.append(code)
122
+ scales.append(reference)
123
+ diagnostics.append(
124
+ {
125
+ "state": name,
126
+ "minimum": lower,
127
+ "maximum": upper,
128
+ "q90_abs": q90,
129
+ "q99_abs": q99,
130
+ "dynamic_range": float(dynamic_range),
131
+ "transform": transform_name,
132
+ "scale": float(reference),
133
+ }
134
+ )
135
+ return StateTransformSpec(
136
+ codes=np.asarray(codes, dtype=np.int64),
137
+ scales=np.asarray(scales, dtype=np.float64),
138
+ names=state_names,
139
+ diagnostics=tuple(diagnostics),
140
+ )