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 +3 -0
- adasurrog/__init__.py +111 -0
- adasurrog/active_learning.py +287 -0
- adasurrog/auto_transforms.py +140 -0
- adasurrog/benchmarks/__init__.py +186 -0
- adasurrog/checkpoint.py +159 -0
- adasurrog/cli.py +99 -0
- adasurrog/config.py +76 -0
- adasurrog/examples/__init__.py +0 -0
- adasurrog/examples/gene_feedback_active.py +826 -0
- adasurrog/examples/gene_feedback_common.py +263 -0
- adasurrog/examples/gene_feedback_ode.py +240 -0
- adasurrog/examples/gene_feedback_precision.py +692 -0
- adasurrog/examples/plot_gene_feedback_active_learning.py +85 -0
- adasurrog/examples/plot_gene_feedback_checkpoints.py +206 -0
- adasurrog/metrics.py +16 -0
- adasurrog/models/__init__.py +27 -0
- adasurrog/models/pod.py +646 -0
- adasurrog/ode_data.py +295 -0
- adasurrog/pipeline.py +697 -0
- adasurrog/problem.py +116 -0
- adasurrog/progress.py +82 -0
- adasurrog/py.typed +0 -0
- adasurrog/refinement.py +350 -0
- adasurrog/state.py +67 -0
- adasurrog/trainer.py +308 -0
- adasurrog/transforms.py +30 -0
- adasurrog/universal_cli.py +133 -0
- adasurrog/universal_model.py +192 -0
- adasurrog-0.5.1.dist-info/METADATA +358 -0
- adasurrog-0.5.1.dist-info/RECORD +34 -0
- adasurrog-0.5.1.dist-info/WHEEL +4 -0
- adasurrog-0.5.1.dist-info/entry_points.txt +9 -0
- adasurrog-0.5.1.dist-info/licenses/LICENSE +21 -0
adasurrog/__about__.py
ADDED
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
|
+
)
|