0.1.39
Loading...
Searching...
No Matches
ParameterOptimization.py
Go to the documentation of this file.
1"""
2High-level ParameterOptimization interface for PyHelios.
3
4This module provides a user-friendly interface to the parameter optimization
5plugin, which calibrates named model parameters against a user-supplied
6objective function using either population-based search (genetic algorithm,
7Bayesian optimization, CMA-ES) or local optimization (Adam, L-BFGS, BOBYQA).
8
9Example:
10 >>> from pyhelios import ParameterOptimization, Parameter, GeneticAlgorithm
11 >>>
12 >>> def objective(p):
13 ... return (p["x"] - 3.0) ** 2 + (p["y"] + 1.0) ** 2
14 >>>
15 >>> with ParameterOptimization() as opt:
16 ... opt.setAlgorithm(GeneticAlgorithm(generations=100, random_seed=1))
17 ... result = opt.run(objective, {
18 ... "x": Parameter.continuous(0.0, -5.0, 5.0),
19 ... "y": Parameter.continuous(0.0, -5.0, 5.0),
20 ... })
21 >>> round(result["x"], 1), round(result["y"], 1)
22 (3.0, -1.0)
23"""
24
25import logging
26import math
27from dataclasses import dataclass, field
28from enum import IntEnum
29from typing import Callable, Dict, Mapping, Optional, Sequence, Union
30
31from .plugins.registry import get_plugin_registry
32from .wrappers import UParameterOptimizationWrapper as paramopt_wrapper
33from .exceptions import HeliosError
34
35logger = logging.getLogger(__name__)
36
37
39 """Exception raised for ParameterOptimization-specific errors."""
40 pass
41
42
43class ParameterType(IntEnum):
44 """Kind of an optimizable parameter."""
45
46 FLOAT = paramopt_wrapper.PARAM_FLOAT #: Continuous
47 INTEGER = paramopt_wrapper.PARAM_INTEGER #: Rounded to the nearest whole number
48 CATEGORICAL = paramopt_wrapper.PARAM_CATEGORICAL #: Chosen from an explicit set
49
50
51@dataclass
52class Parameter:
53 """
54 A single optimizable parameter.
55
56 Args:
57 value: Initial value
58 min: Lower bound. Ignored for CATEGORICAL parameters.
59 max: Upper bound. Ignored for CATEGORICAL parameters.
60 type: Parameter kind
61 categories: Allowed values, required for CATEGORICAL parameters
62 """
63
64 value: float
65 min: float = 0.0
66 max: float = 0.0
67 type: ParameterType = ParameterType.FLOAT
68 categories: Sequence[float] = ()
69
70 @classmethod
71 def continuous(cls, value: float, min: float, max: float) -> "Parameter":
72 """Create a continuous parameter bounded by [min, max]."""
73 return cls(value=value, min=min, max=max, type=ParameterType.FLOAT)
74
75 @classmethod
76 def integer(cls, value: float, min: float, max: float) -> "Parameter":
77 """Create an integer parameter bounded by [min, max]."""
78 return cls(value=value, min=min, max=max, type=ParameterType.INTEGER)
79
80 @classmethod
81 def categorical(cls, value: float, categories: Sequence[float]) -> "Parameter":
82 """Create a parameter restricted to an explicit set of values."""
83 return cls(value=value, min=0.0, max=0.0,
84 type=ParameterType.CATEGORICAL, categories=tuple(categories))
85
86
87#=============================================================================
88# Genetic algorithm operators
89#=============================================================================
90
91@dataclass(frozen=True)
93 """Component-wise blend crossover."""
94 alpha: float = 0.5
95
96
97@dataclass(frozen=True)
98class BLXPCACrossover:
99 """Blend crossover in PCA-transformed space, for non-separable problems."""
100 alpha: float = 0.5
101 pca_update_interval: int = 5
102
103
104@dataclass(frozen=True)
105class PerGeneMutation:
106 """Per-gene Gaussian mutation."""
107 rate: float = 0.1
108
109
110@dataclass(frozen=True)
112 """Isotropic Gaussian mutation applied to all genes together."""
113 rate: float = 0.1
114
115
116@dataclass(frozen=True)
117class HybridMutation:
118 """Mixture of PCA-Gaussian, PCA-Cauchy, and random-direction mutation."""
119 rate: float = 0.15
120 pca_update_interval: int = 5
121 sigma_pca: float = 0.25
122 gamma_cauchy: float = 0.1
123 sigma_random: float = 0.3
124 pca_gaussian_prob: float = 0.70
125 pca_cauchy_prob: float = 0.20
126
127
128CrossoverOperator = Union[BLXAlphaCrossover, BLXPCACrossover]
129MutationOperator = Union[PerGeneMutation, IsotropicMutation, HybridMutation]
130
131
132#=============================================================================
133# Algorithm settings
134#
135# Presets are read from the native library rather than transcribed here, so
136# they cannot drift from helios-core when it is updated.
137#=============================================================================
138
139@dataclass
140class GeneticAlgorithm:
141 """
142 Genetic algorithm settings.
143
144 Handles continuous, integer, and categorical parameters, and needs no
145 gradient. A good default when the search space is large or poorly behaved.
146 """
147
148 generations: int = 100
149 population_size: int = 20
150 crossover_rate: float = 0.5
151 elitism_rate: float = 0.05
152 random_seed: int = 0 #: 0 selects a nondeterministic seed
153 crossover: CrossoverOperator = field(default_factory=BLXAlphaCrossover)
154 mutation: MutationOperator = field(default_factory=PerGeneMutation)
155
156 @classmethod
157 def _from_struct(cls, struct) -> "GeneticAlgorithm":
158 if struct.crossover_kind == paramopt_wrapper.CROSSOVER_BLX_PCA:
159 crossover: CrossoverOperator = BLXPCACrossover(
160 alpha=struct.crossover_alpha,
161 pca_update_interval=struct.crossover_pca_update_interval)
162 else:
163 crossover = BLXAlphaCrossover(alpha=struct.crossover_alpha)
164
165 if struct.mutation_kind == paramopt_wrapper.MUTATION_ISOTROPIC:
166 mutation: MutationOperator = IsotropicMutation(rate=struct.mutation_rate)
167 elif struct.mutation_kind == paramopt_wrapper.MUTATION_HYBRID:
168 mutation = HybridMutation(
169 rate=struct.mutation_rate,
170 pca_update_interval=struct.mutation_pca_update_interval,
171 sigma_pca=struct.mutation_sigma_pca,
172 gamma_cauchy=struct.mutation_gamma_cauchy,
173 sigma_random=struct.mutation_sigma_random,
174 pca_gaussian_prob=struct.mutation_pca_gaussian_prob,
175 pca_cauchy_prob=struct.mutation_pca_cauchy_prob)
176 else:
177 mutation = PerGeneMutation(rate=struct.mutation_rate)
178
179 return cls(
180 generations=struct.generations,
181 population_size=struct.population_size,
182 crossover_rate=struct.crossover_rate,
183 elitism_rate=struct.elitism_rate,
184 random_seed=struct.random_seed,
185 crossover=crossover,
186 mutation=mutation)
187
188 @classmethod
189 def explore(cls) -> "GeneticAlgorithm":
190 """Exploration-biased preset: large population, high mutation."""
191 return cls._from_struct(paramopt_wrapper.getGeneticAlgorithmExplore())
192
193 @classmethod
194 def exploit(cls) -> "GeneticAlgorithm":
195 """Exploitation-biased preset: smaller population, low mutation."""
196 return cls._from_struct(paramopt_wrapper.getGeneticAlgorithmExploit())
197
198 def _to_struct(self):
199 struct = paramopt_wrapper.PyHeliosGeneticAlgorithm()
200 struct.generations = self.generations
201 struct.population_size = self.population_size
202 struct.crossover_rate = self.crossover_rate
203 struct.elitism_rate = self.elitism_rate
204 struct.random_seed = self.random_seed
205
206 if isinstance(self.crossover, BLXPCACrossover):
207 struct.crossover_kind = paramopt_wrapper.CROSSOVER_BLX_PCA
208 struct.crossover_alpha = self.crossover.alpha
209 struct.crossover_pca_update_interval = self.crossover.pca_update_interval
210 elif isinstance(self.crossover, BLXAlphaCrossover):
211 struct.crossover_kind = paramopt_wrapper.CROSSOVER_BLX_ALPHA
212 struct.crossover_alpha = self.crossover.alpha
213 struct.crossover_pca_update_interval = 5
214 else:
215 raise ValueError(
216 f"crossover must be BLXAlphaCrossover or BLXPCACrossover, "
217 f"got {type(self.crossover).__name__}")
218
219 # Defaults for the fields the selected mutation does not carry.
220 struct.mutation_pca_update_interval = 5
221 struct.mutation_sigma_pca = 0.25
222 struct.mutation_gamma_cauchy = 0.1
223 struct.mutation_sigma_random = 0.3
224 struct.mutation_pca_gaussian_prob = 0.70
225 struct.mutation_pca_cauchy_prob = 0.20
226
227 if isinstance(self.mutation, HybridMutation):
228 struct.mutation_kind = paramopt_wrapper.MUTATION_HYBRID
229 struct.mutation_rate = self.mutation.rate
230 struct.mutation_pca_update_interval = self.mutation.pca_update_interval
231 struct.mutation_sigma_pca = self.mutation.sigma_pca
232 struct.mutation_gamma_cauchy = self.mutation.gamma_cauchy
233 struct.mutation_sigma_random = self.mutation.sigma_random
234 struct.mutation_pca_gaussian_prob = self.mutation.pca_gaussian_prob
235 struct.mutation_pca_cauchy_prob = self.mutation.pca_cauchy_prob
236 elif isinstance(self.mutation, IsotropicMutation):
237 struct.mutation_kind = paramopt_wrapper.MUTATION_ISOTROPIC
238 struct.mutation_rate = self.mutation.rate
239 elif isinstance(self.mutation, PerGeneMutation):
240 struct.mutation_kind = paramopt_wrapper.MUTATION_PER_GENE
241 struct.mutation_rate = self.mutation.rate
242 else:
243 raise ValueError(
244 f"mutation must be PerGeneMutation, IsotropicMutation, or HybridMutation, "
245 f"got {type(self.mutation).__name__}")
246
247 return struct
248
249
250@dataclass
252 """
253 Bayesian optimization with a Gaussian process surrogate.
254
255 Suited to expensive objectives where the evaluation budget is small.
256 """
257
258 max_evaluations: int = 100
259 initial_samples: int = 0 #: 0 selects 2*num_params
260 ucb_kappa: float = 2.0
261 max_gp_samples: int = 200
262 acquisition_samples: int = 1000
263 random_seed: int = 0 #: 0 selects a nondeterministic seed
264
265 @classmethod
266 def _from_struct(cls, struct) -> "BayesianOptimization":
267 return cls(
268 max_evaluations=struct.max_evaluations,
269 initial_samples=struct.initial_samples,
270 ucb_kappa=struct.ucb_kappa,
271 max_gp_samples=struct.max_gp_samples,
272 acquisition_samples=struct.acquisition_samples,
273 random_seed=struct.random_seed)
274
275 @classmethod
276 def explore(cls) -> "BayesianOptimization":
277 """Exploration-biased preset: high kappa, many acquisition samples."""
278 return cls._from_struct(paramopt_wrapper.getBayesianExplore())
279
280 @classmethod
281 def exploit(cls) -> "BayesianOptimization":
282 """Exploitation-biased preset: low kappa."""
283 return cls._from_struct(paramopt_wrapper.getBayesianExploit())
284
285 def _to_struct(self):
286 struct = paramopt_wrapper.PyHeliosBayesianOptimization()
287 struct.max_evaluations = self.max_evaluations
288 struct.initial_samples = self.initial_samples
289 struct.ucb_kappa = self.ucb_kappa
290 struct.max_gp_samples = self.max_gp_samples
291 struct.acquisition_samples = self.acquisition_samples
292 struct.random_seed = self.random_seed
293 return struct
294
295
296@dataclass
297class CMAES:
298 """
299 Covariance Matrix Adaptation Evolution Strategy.
300
301 Strong general-purpose choice for continuous, non-separable problems.
302 """
303
304 max_evaluations: int = 200
305 lambda_: int = 0 #: Population size; 0 selects 4+floor(3*ln(n))
306 sigma: float = 0.3
307 random_seed: int = 0 #: 0 selects a nondeterministic seed
308
309 @classmethod
310 def _from_struct(cls, struct) -> "CMAES":
311 return cls(
312 max_evaluations=struct.max_evaluations,
313 lambda_=struct.lambda_,
314 sigma=struct.sigma,
315 random_seed=struct.random_seed)
316
317 @classmethod
318 def explore(cls) -> "CMAES":
319 """Exploration-biased preset: large initial step size."""
320 return cls._from_struct(paramopt_wrapper.getCMAESExplore())
321
322 @classmethod
323 def exploit(cls) -> "CMAES":
324 """Exploitation-biased preset: small initial step size."""
325 return cls._from_struct(paramopt_wrapper.getCMAESExploit())
326
327 def _to_struct(self):
328 struct = paramopt_wrapper.PyHeliosCMAES()
329 struct.max_evaluations = self.max_evaluations
330 struct.lambda_ = self.lambda_
331 struct.sigma = self.sigma
332 struct.random_seed = self.random_seed
333 return struct
334
335
336@dataclass
337class Adam:
338 """
339 AdamW gradient-based optimization.
340
341 Noise-tolerant and dependency-free. Requires a gradient, either supplied
342 directly or estimated with ``finite_difference=True``.
343 """
344
345 max_iterations: int = 200
346 learning_rate: float = 0.01
347 beta1: float = 0.9
348 beta2: float = 0.999
349 epsilon: float = 1e-8
350 weight_decay: float = 0.0 #: 0 gives standard Adam
351 ftol_rel: float = 1e-6
352 xtol_rel: float = 1e-6
353
354 def _to_struct(self):
355 struct = paramopt_wrapper.PyHeliosAdam()
356 struct.max_iterations = self.max_iterations
357 struct.learning_rate = self.learning_rate
358 struct.beta1 = self.beta1
359 struct.beta2 = self.beta2
360 struct.epsilon = self.epsilon
361 struct.weight_decay = self.weight_decay
362 struct.ftol_rel = self.ftol_rel
363 struct.xtol_rel = self.xtol_rel
364 return struct
365
366
367@dataclass
368class LBFGS:
369 """
370 L-BFGS gradient-based optimization.
371
372 Requires an NLopt build that includes the Luksan solvers, which PyHelios
373 does not build by default, so this is typically unavailable -- use :class:`Adam` or :class:`BOBYQA`.
374 """
375
376 max_iterations: int = 200
377 ftol_rel: float = 1e-6
378 xtol_rel: float = 1e-6
379 verify_gradients: bool = False
380 fd_step: float = 1e-5
381
382 def _to_struct(self):
383 struct = paramopt_wrapper.PyHeliosLBFGS()
384 struct.max_iterations = self.max_iterations
385 struct.ftol_rel = self.ftol_rel
386 struct.xtol_rel = self.xtol_rel
387 struct.verify_gradients = 1 if self.verify_gradients else 0
388 struct.fd_step = self.fd_step
389 return struct
390
391
392@dataclass
393class BOBYQA:
394 """
395 BOBYQA derivative-free local optimization. Requires NLopt.
396
397 Builds a local quadratic model from function values alone -- a good choice
398 for polishing a population-based result or for noisy black-box objectives.
399 """
400
401 max_iterations: int = 200
402 ftol_rel: float = 1e-6
403 xtol_rel: float = 1e-6
404 initial_step: float = 0.0 #: 0 selects 10% of the parameter range
405
406 def _to_struct(self):
407 struct = paramopt_wrapper.PyHeliosBOBYQA()
408 struct.max_iterations = self.max_iterations
409 struct.ftol_rel = self.ftol_rel
410 struct.xtol_rel = self.xtol_rel
411 struct.initial_step = self.initial_step
412 return struct
413
414
415@dataclass
416class SLSQP:
417 """
418 SLSQP gradient-based optimization. Requires NLopt and a gradient.
419
420 Note that PyHelios does not currently expose the plugin's nonlinear
421 constraint support, so this behaves as an unconstrained local optimizer.
422 """
423
424 max_iterations: int = 200
425 ftol_rel: float = 1e-6
426 xtol_rel: float = 1e-6
427
428 def _to_struct(self):
429 struct = paramopt_wrapper.PyHeliosSLSQP()
430 struct.max_iterations = self.max_iterations
431 struct.ftol_rel = self.ftol_rel
432 struct.xtol_rel = self.xtol_rel
433 return struct
434
435
436AlgorithmSettings = Union[GeneticAlgorithm, BayesianOptimization, CMAES,
437 Adam, LBFGS, BOBYQA, SLSQP]
438
439# Maps each settings type to its short native name, its wrapper setter, whether
440# it needs a gradient, and whether it understands INTEGER/CATEGORICAL parameters.
441#
442# Only the genetic algorithm handles discrete parameters. Every other algorithm
443# searches a continuous space and would treat a discrete parameter as a plain
444# float, so passing one is rejected rather than silently answered -- see
445# _validate_parameter_types_for_algorithm.
446_ALGORITHM_INFO = {
447 GeneticAlgorithm: ("GA", paramopt_wrapper.setGeneticAlgorithm, False, True),
448 BayesianOptimization: ("BO", paramopt_wrapper.setBayesianOptimization, False, False),
449 CMAES: ("CMAES", paramopt_wrapper.setCMAES, False, False),
450 Adam: ("ADAM", paramopt_wrapper.setAdam, True, False),
451 LBFGS: ("LBFGS", paramopt_wrapper.setLBFGS, True, False),
452 BOBYQA: ("BOBYQA", paramopt_wrapper.setBOBYQA, False, False),
453 SLSQP: ("SLSQP", paramopt_wrapper.setSLSQP, True, False),
454}
455
456
457@dataclass(frozen=True)
459 """
460 One evaluation of a constrained simulation.
461
462 Returned by the callable passed to :meth:`ParameterOptimization.runConstrained`,
463 which computes the objective, the constraints, and every gradient in a single
464 pass. The optimizer caches this per parameter point, so a simulation that runs a
465 full Helios scene is evaluated once rather than once per constraint.
466
467 Args:
468 objective: Scalar cost to minimize
469 objective_gradient: Partial derivative of the objective for every parameter
470 constraints: Constraint values; constraint i is satisfied when its value is <= 0
471 constraint_gradients: One gradient mapping per constraint, in the same order
472 """
473
474 objective: float
475 objective_gradient: Dict[str, float]
476 constraints: Sequence[float]
477 constraint_gradients: Sequence[Dict[str, float]]
480@dataclass(frozen=True)
482 """
483 Outcome of an optimization run.
484
485 Holds full :class:`Parameter` objects rather than bare floats so a result
486 can be fed straight back into another run, e.g. refining a CMA-ES result
487 with BOBYQA.
488 """
489
490 parameters: Dict[str, Parameter]
491 fitness: float
492
493 @property
494 def values(self) -> Dict[str, float]:
495 """The optimized values, as a plain name-to-value mapping."""
496 return {name: parameter.value for name, parameter in self.parameters.items()}
497
498 def __getitem__(self, name: str) -> float:
499 """Get one optimized value by parameter name."""
500 return self.parameters[name].value
501
502
504 objective: Callable[[Dict[str, float]], float],
505 objective_gradient: Callable[[Dict[str, float]], Dict[str, float]],
506 constraints: Sequence[tuple]) -> Callable[[Dict[str, float]], ConstrainedResult]:
507 """
508 Compose separate objective and constraint callables into one simulation.
509
510 A convenience for problems whose constraints really are independent functions.
511 Note that it calls every function at each parameter point, so it forfeits the
512 single-pass advantage of writing one combined simulation: if computing the
513 objective and the constraints shares expensive work -- as it does when they come
514 from one Helios simulation -- write a :class:`ConstrainedResult` directly instead.
515
516 Args:
517 objective: Callable receiving ``{name: value}`` and returning a scalar cost
518 objective_gradient: Callable returning ``{name: partial derivative}``
519 constraints: Sequence of ``(function, gradient)`` pairs, each satisfied when
520 ``function(params) <= 0``
521
522 Returns:
523 A callable suitable for :meth:`ParameterOptimization.runConstrained`
524
525 Example:
526 >>> simulation = make_constrained_simulation(
527 ... lambda p: p["x"] ** 2,
528 ... lambda p: {"x": 2 * p["x"]},
529 ... [(lambda p: 1.0 - p["x"], lambda p: {"x": -1.0})])
530 """
531 if not callable(objective):
532 raise TypeError(f"objective must be callable, got {type(objective).__name__}")
533 if not callable(objective_gradient):
534 raise TypeError(
535 f"objective_gradient must be callable, got {type(objective_gradient).__name__}")
536
537 pairs = list(constraints)
538 for index, pair in enumerate(pairs):
539 if not isinstance(pair, tuple) or len(pair) != 2:
540 raise TypeError(
541 f"constraints[{index}] must be a (function, gradient) tuple, "
542 f"got {type(pair).__name__}")
543 if not all(callable(item) for item in pair):
544 raise TypeError(f"Both entries of constraints[{index}] must be callable")
545
546 def simulation(params: Dict[str, float]) -> ConstrainedResult:
547 return ConstrainedResult(
548 objective=objective(params),
549 objective_gradient=objective_gradient(params),
550 constraints=[function(params) for function, _ in pairs],
551 constraint_gradients=[gradient(params) for _, gradient in pairs],
552 )
553
554 return simulation
555
556
558 """
559 Optimize named model parameters against an objective function.
560
561 The objective receives a ``{name: value}`` dict and returns a scalar cost to
562 minimize. It may close over a :class:`~pyhelios.Context` and run a full
563 Helios simulation; the plugin itself takes no Context.
564
565 This class requires the native Helios library built with the
566 ``parameteroptimization`` plugin. Use it as a context manager so the C++
567 instance is released promptly.
568
569 Example:
570 >>> with ParameterOptimization() as opt:
571 ... opt.setAlgorithm(CMAES(max_evaluations=200, random_seed=1))
572 ... result = opt.run(objective, {"x": Parameter.continuous(0.0, -5.0, 5.0)})
573 ... print(result.fitness, result["x"])
574 """
575
576 def __init__(self):
577 """
578 Create a ParameterOptimization instance.
580 Raises:
581 ParameterOptimizationError: If the plugin is unavailable in this build
582 """
583 self.optimizer = None
584 self._running = False
585
586 registry = get_plugin_registry()
587 if not registry.is_plugin_available('parameteroptimization'):
588 available_plugins = registry.get_available_plugins()
590 "ParameterOptimization requires the 'parameteroptimization' plugin "
591 "which is not available.\n\n"
592 "To enable parameter optimization:\n"
593 "1. Rebuild PyHelios with all plugins:\n"
594 " build_scripts/build_helios --clean\n"
595 "2. Or select the plugin explicitly:\n"
596 " build_scripts/build_helios --plugins parameteroptimization\n\n"
597 "System requirements:\n"
598 " - Platforms: Windows, Linux, macOS\n"
599 " - Dependencies: none (NLopt is bundled)\n"
600 " - GPU: not required\n\n"
601 f"Currently available plugins: {available_plugins}"
602 )
603
604 try:
605 self.optimizer = paramopt_wrapper.createParameterOptimization()
606 if not self.optimizer:
608 "Failed to create ParameterOptimization instance.")
609 except ParameterOptimizationError:
610 raise
611 except Exception as e:
613 f"Failed to initialize ParameterOptimization: {e}")
614
615 def __enter__(self):
616 """Context manager entry."""
617 return self
618
619 def __exit__(self, exc_type, exc_value, traceback):
620 """Context manager exit with proper cleanup."""
621 if self.optimizer is not None:
622 try:
623 paramopt_wrapper.destroyParameterOptimization(self.optimizer)
624 logger.debug("ParameterOptimization destroyed successfully")
625 except Exception as e:
626 logger.warning(f"Error destroying ParameterOptimization: {e}")
627 finally:
628 self.optimizer = None
629
630 def __del__(self):
631 """Destructor to ensure C++ resources freed even without 'with' statement."""
632 if hasattr(self, 'optimizer') and self.optimizer is not None:
633 try:
634 paramopt_wrapper.destroyParameterOptimization(self.optimizer)
635 self.optimizer = None
636 except Exception as e:
637 import warnings
638 warnings.warn(f"Error in ParameterOptimization.__del__: {e}")
639
640 def getNativePtr(self):
641 """Get the native pointer for advanced operations."""
642 return self.optimizer
643
644 def _check_alive(self) -> None:
645 """Raise if this instance has already been destroyed."""
646 if self.optimizer is None:
648 "ParameterOptimization has been destroyed and can no longer be used.")
649
650 #=========================================================================
651 # Algorithm selection
652 #=========================================================================
653
654 @staticmethod
655 def availableAlgorithms() -> Dict[str, bool]:
656 """
657 Report which algorithms can run in this build.
658
659 L-BFGS, BOBYQA and SLSQP depend on NLopt, and L-BFGS additionally on the
660 LGPL Luksan solvers, which PyHelios disables by default.
661
662 Returns:
663 Mapping of algorithm name to availability
664 """
665 names = ["GA", "BO", "CMAES", "ADAM", "LBFGS", "BOBYQA", "SLSQP"]
666 return {name: paramopt_wrapper.isAlgorithmAvailable(name) for name in names}
667
668 def setAlgorithm(self, algorithm: AlgorithmSettings) -> None:
669 """
670 Select the optimization algorithm and its hyperparameters.
672 If never called, the plugin picks a default based on the parameter types
673 and whether a gradient was supplied.
674
675 Args:
676 algorithm: One of the algorithm settings dataclasses
677
678 Raises:
679 TypeError: If algorithm is not a recognized settings type
680 ParameterOptimizationError: If the algorithm is unavailable in this build
681 """
682 self._check_alive()
683
684 info = _ALGORITHM_INFO.get(type(algorithm))
685 if info is None:
686 raise TypeError(
687 f"algorithm must be one of "
688 f"{', '.join(cls.__name__ for cls in _ALGORITHM_INFO)}, "
689 f"got {type(algorithm).__name__}")
690
691 native_name, setter, _, _ = info
692
693 # Checked here rather than left to fail inside run(), so the user finds
694 # out before waiting through a long optimization.
695 if not paramopt_wrapper.isAlgorithmAvailable(native_name):
697 f"The {type(algorithm).__name__} algorithm is not available in this build.\n\n"
698 f"{native_name} is provided by NLopt. PyHelios builds NLopt without the "
699 f"Luksan solvers by default, which makes L-BFGS unavailable.\n\n"
700 f"Alternatives that are always available:\n"
701 f" - Adam: gradient-based, noise-tolerant\n"
702 f" - BOBYQA: derivative-free local optimization (needs NLopt)\n"
703 f" - CMAES / GeneticAlgorithm: population-based global search\n\n"
704 f"To enable it, change the set(HELIOS_NLOPT_LUKSAN OFF ...) line in "
705 f"build_scripts/build_helios.py and rebuild from clean.")
706
707 try:
708 setter(self.optimizer, algorithm._to_struct())
709 except (ParameterOptimizationError, ValueError, TypeError):
710 raise
711 except Exception as e:
712 raise ParameterOptimizationError(f"Failed to set algorithm: {e}")
713
714 self._algorithm = algorithm
715
716 #=========================================================================
717 # I/O configuration
718 #=========================================================================
719
720 def setPrintProgress(self, enable: bool) -> None:
721 """
722 Enable or disable the plugin's progress printout to stdout.
723
724 Args:
725 enable: True to print progress during optimization
726 """
727 self._check_alive()
728 paramopt_wrapper.setPrintProgress(self.optimizer, bool(enable))
729
730 def setResultFile(self, path: Optional[str]) -> None:
731 """
732 Write the final result to a CSV file.
734 Args:
735 path: Output path ending in .csv or .txt; None disables writing
736 """
737 self._check_alive()
738 paramopt_wrapper.setResultFile(self.optimizer, path)
739
740 def setProgressFile(self, path: Optional[str]) -> None:
741 """
742 Write per-generation progress to a CSV file.
744 Args:
745 path: Output path ending in .csv or .txt; None disables writing
746 """
747 self._check_alive()
748 paramopt_wrapper.setProgressFile(self.optimizer, path)
749
750 def setInputFile(self, path: Optional[str]) -> None:
751 """
752 Read the initial parameter set from a file.
754 Only the genetic algorithm consults this file.
755
756 Args:
757 path: Headerless CSV of "name,value,min,max" rows; None disables reading
758 """
759 self._check_alive()
760 paramopt_wrapper.setInputFile(self.optimizer, path)
761
762 #=========================================================================
763 # Run
764 #=========================================================================
766 def run(self,
767 objective: Callable[[Dict[str, float]], float],
768 parameters: Mapping[str, Parameter],
769 gradient: Optional[Callable[[Dict[str, float]], Dict[str, float]]] = None,
770 *,
771 finite_difference: bool = False,
772 fd_step: float = 0.0) -> OptimizationResult:
773 """
774 Run the optimization.
775
776 Args:
777 objective: Callable receiving {name: value} and returning a scalar cost
778 to minimize. Invoked once per candidate parameter set.
779 parameters: Parameters to optimize, keyed by name
780 gradient: Optional callable receiving {name: value} and returning
781 {name: partial derivative} for every parameter. Required by
782 Adam, L-BFGS, and SLSQP unless finite_difference is used.
783 finite_difference: Estimate the gradient by centered finite differences
784 instead of supplying one. Costs 2N extra objective
785 evaluations per gradient.
786 fd_step: Relative perturbation for finite differences; 0 uses the default
787
788 Returns:
789 The optimized parameters and the objective value at the optimum
790
791 Raises:
792 ValueError: If the arguments are invalid
793 TypeError: If objective or gradient is not callable
794 ParameterOptimizationError: If the optimization fails
795
796 Note:
797 An exception raised inside the objective aborts the run and is
798 re-raised here with its original traceback. Partial results are not
799 recoverable.
800 """
802
803 if not callable(objective):
804 raise TypeError(f"objective must be callable, got {type(objective).__name__}")
805 if gradient is not None and not callable(gradient):
806 raise TypeError(f"gradient must be callable, got {type(gradient).__name__}")
807 if gradient is not None and finite_difference:
808 raise ValueError(
809 "Pass either a gradient function or finite_difference=True, not both.")
810
811 specs = self._validate_parameters(parameters)
812
813 algorithm = getattr(self, '_algorithm', None)
814 if algorithm is not None:
815 _, _, needs_gradient, _ = _ALGORITHM_INFO[type(algorithm)]
816 self._validate_parameter_types_for_algorithm(parameters, algorithm)
817 if needs_gradient and gradient is None and not finite_difference:
818 raise ValueError(
819 f"{type(algorithm).__name__} is a gradient-based algorithm and requires "
820 f"a gradient. Either pass gradient=<callable>, or pass "
821 f"finite_difference=True to estimate it numerically.")
822
823 # The objective must not re-enter this same instance: the C++ optimizer
824 # is stateful and not reentrant.
825 if self._running:
827 "ParameterOptimization.run() was called from inside its own objective "
828 "function. The optimizer is not reentrant; use a separate instance.")
829
830 self._running = True
831 try:
832 if gradient is not None:
833 values, fitness = paramopt_wrapper.runOptimizationWithGradient(
834 self.optimizer, specs, objective, gradient)
835 elif finite_difference:
836 values, fitness = paramopt_wrapper.runOptimizationWithFDGradient(
837 self.optimizer, specs, objective, fd_step)
838 else:
839 values, fitness = paramopt_wrapper.runOptimization(
840 self.optimizer, specs, objective)
841 finally:
842 self._running = False
843
844 # Bounds and types are untouched by the optimizer, so they carry over
845 # from the inputs and only the values are replaced.
846 optimized = {
847 name: Parameter(value=values[name], min=parameter.min, max=parameter.max,
848 type=parameter.type, categories=parameter.categories)
849 for name, parameter in parameters.items()
850 }
851 return OptimizationResult(parameters=optimized, fitness=fitness)
852
853 def runConstrained(self,
854 simulation: Callable[[Dict[str, float]], ConstrainedResult],
855 parameters: Mapping[str, Parameter],
856 *,
857 constraint_count: int) -> OptimizationResult:
858 """
859 Run a constrained optimization: minimize f(x) subject to c_i(x) <= 0.
860
861 Requires ``setAlgorithm(SLSQP(...))``. SLSQP is the only algorithm in the
862 plugin that handles nonlinear inequality constraints, and it needs every
863 parameter to be ``FLOAT``.
864
865 The simulation returns the objective, the constraints, and all gradients
866 together. The optimizer caches each result, so the simulation runs once per
867 parameter point no matter how many constraints there are -- which is what
868 makes this practical for objectives that run a full Helios simulation.
869
870 Args:
871 simulation: Callable receiving ``{name: value}`` and returning a
872 :class:`ConstrainedResult`
873 parameters: Parameters to optimize, keyed by name
874 constraint_count: Number of constraints. Required, and fixed for the whole run.
875
876 Returns:
877 The optimized parameters and the objective value at the optimum
878
879 Raises:
880 ValueError: If the arguments are invalid, or the selected algorithm is
881 not SLSQP
882 TypeError: If simulation is not callable
883 ParameterOptimizationError: If the optimization fails
884
885 Note:
886 Constraints are satisfied to the plugin's tolerance rather than exactly.
887 Check feasibility of the returned parameters if it matters.
888
889 Example:
890 >>> # minimize x^2 + y^2 subject to x + y >= 1
891 >>> def simulation(p):
892 ... return ConstrainedResult(
893 ... objective=p["x"] ** 2 + p["y"] ** 2,
894 ... objective_gradient={"x": 2 * p["x"], "y": 2 * p["y"]},
895 ... constraints=[1.0 - p["x"] - p["y"]],
896 ... constraint_gradients=[{"x": -1.0, "y": -1.0}],
897 ... )
898 >>> with ParameterOptimization() as opt:
899 ... opt.setAlgorithm(SLSQP())
900 ... result = opt.runConstrained(
901 ... simulation,
902 ... {"x": Parameter.continuous(0.0, -5.0, 5.0),
903 ... "y": Parameter.continuous(0.0, -5.0, 5.0)},
904 ... constraint_count=1)
905 """
906 self._check_alive()
907
908 if not callable(simulation):
909 raise TypeError(
910 f"simulation must be callable, got {type(simulation).__name__}")
911 if not isinstance(constraint_count, int) or isinstance(constraint_count, bool):
912 raise TypeError(
913 f"constraint_count must be an int, got {type(constraint_count).__name__}")
914 if constraint_count < 1:
915 raise ValueError(
916 f"constraint_count must be at least 1, got {constraint_count}. "
917 f"Use run() when there are no constraints.")
918
919 specs = self._validate_parameters(parameters)
920
921 # Checked before the run rather than left to C++, which raises the same
922 # requirement from inside the optimizer once work is already underway.
923 algorithm = getattr(self, '_algorithm', None)
924 if not isinstance(algorithm, SLSQP):
925 selected = type(algorithm).__name__ if algorithm is not None else "none"
926 raise ValueError(
927 f"Constrained optimization requires SLSQP, but the selected algorithm is "
928 f"{selected}. Call setAlgorithm(SLSQP(...)) first.\n\n"
929 f"SLSQP is the only algorithm in the plugin that supports nonlinear "
930 f"inequality constraints. For the others, fold the constraint into the "
931 f"objective as a penalty term and use run().")
932
933 self._validate_parameter_types_for_algorithm(parameters, algorithm)
934
935 if self._running:
937 "ParameterOptimization.runConstrained() was called from inside its own "
938 "simulation function. The optimizer is not reentrant; use a separate "
939 "instance.")
940
941 self._running = True
942 try:
943 values, fitness = paramopt_wrapper.runOptimizationConstrained(
944 self.optimizer, specs, simulation, constraint_count)
945 finally:
946 self._running = False
947
948 optimized = {
949 name: Parameter(value=values[name], min=parameter.min, max=parameter.max,
950 type=parameter.type, categories=parameter.categories)
951 for name, parameter in parameters.items()
952 }
953 return OptimizationResult(parameters=optimized, fitness=fitness)
954
955 @staticmethod
956 def _validate_parameters(parameters: Mapping[str, Parameter]) -> list:
957 """
958 Check the parameter mapping and flatten it for the wrapper.
959
960 Only conditions that are memory-safety preconditions, or that produce a
961 materially better message here than from C++, are checked. Bound
962 consistency (min == max, min > max, NaN bounds, empty categories) is left
963 to the plugin's own validation so the two cannot drift apart.
964 """
965 if not isinstance(parameters, Mapping):
966 raise TypeError(
967 f"parameters must be a mapping of name to Parameter, "
968 f"got {type(parameters).__name__}")
969 if not parameters:
970 raise ValueError("parameters cannot be empty")
971
972 specs = []
973 for name, parameter in parameters.items():
974 if not isinstance(name, str):
975 raise TypeError(
976 f"Parameter names must be strings, got {type(name).__name__}: {name!r}")
977 if not name:
978 raise ValueError("Parameter names cannot be empty")
979 if "\x00" in name:
980 # Would silently truncate the C string and misalign every
981 # subsequent name lookup.
982 raise ValueError(f"Parameter name {name!r} cannot contain a null character")
983 if not isinstance(parameter, Parameter):
984 raise TypeError(
985 f"Parameter '{name}' must be a Parameter, "
986 f"got {type(parameter).__name__}")
987
988 for attribute in ("value", "min", "max"):
989 attr_value = getattr(parameter, attribute)
990 if not isinstance(attr_value, (int, float)) or isinstance(attr_value, bool):
991 raise TypeError(
992 f"Parameter '{name}' field '{attribute}' must be numeric, "
993 f"got {type(attr_value).__name__}")
994
995 categories = tuple(parameter.categories or ())
996 if parameter.type == ParameterType.CATEGORICAL and not categories:
997 raise ValueError(
998 f"Parameter '{name}' is CATEGORICAL and must define at least one "
999 f"allowed value via 'categories'")
1000 for category in categories:
1001 if not isinstance(category, (int, float)) or isinstance(category, bool):
1002 raise TypeError(
1003 f"Parameter '{name}' has a non-numeric category: {category!r}")
1004 if not math.isfinite(float(category)):
1005 raise ValueError(
1006 f"Parameter '{name}' has a non-finite category: {category!r}")
1007
1008 specs.append({
1009 "name": name,
1010 "value": float(parameter.value),
1011 "min": float(parameter.min),
1012 "max": float(parameter.max),
1013 "type": int(parameter.type),
1014 "categories": [float(c) for c in categories],
1015 })
1016
1017 return specs
1018
1019 @staticmethod
1021 parameters: Mapping[str, "Parameter"],
1022 algorithm: "AlgorithmSettings") -> None:
1023 """
1024 Reject discrete parameters given to an algorithm that cannot handle them.
1025
1026 Only the genetic algorithm implements INTEGER and CATEGORICAL parameters.
1027 The rest search a continuous space, so a discrete parameter would be
1028 optimized as a plain float and the result would not be a whole number, or
1029 not one of the allowed categories.
1030
1031 helios-core enforces this for L-BFGS, Adam, BOBYQA and SLSQP, but not for
1032 CMA-ES or Bayesian optimization, where a CATEGORICAL parameter instead
1033 collapses to 0.0 with no diagnostic: its min and max are documented as
1034 ignored and so are conventionally left at zero, which those two algorithms
1035 read as the bounds [0, 0]. Checking here covers that gap on every core
1036 version, and reports the parameter and the remedy rather than leaving the
1037 message to the layer that happens to catch it first.
1038 """
1039 info = _ALGORITHM_INFO.get(type(algorithm))
1040 if info is None or info[3]:
1041 return
1042
1043 discrete = [
1044 (name, ParameterType(parameter.type).name)
1045 for name, parameter in parameters.items()
1046 if isinstance(parameter, Parameter)
1047 and parameter.type != ParameterType.FLOAT
1048 ]
1049 if not discrete:
1050 return
1051
1052 listed = ", ".join(f"'{name}' is {type_name}" for name, type_name in discrete)
1053 raise ValueError(
1054 f"{type(algorithm).__name__} requires every parameter to be FLOAT, "
1055 f"but {listed}.\n\n"
1056 f"GeneticAlgorithm is the only algorithm that supports INTEGER and "
1057 f"CATEGORICAL parameters. Either switch to it, or make the parameter "
1058 f"continuous with Parameter.continuous(...).")
1059
1060 def is_available(self) -> bool:
1061 """Check if the parameteroptimization plugin is available in this build."""
1062 return get_plugin_registry().is_plugin_available('parameteroptimization')
AdamW gradient-based optimization.
Blend crossover in PCA-transformed space, for non-separable problems.
BOBYQA derivative-free local optimization.
Bayesian optimization with a Gaussian process surrogate.
"BayesianOptimization" exploit(cls)
Exploitation-biased preset: low kappa.
"BayesianOptimization" explore(cls)
Exploration-biased preset: high kappa, many acquisition samples.
Covariance Matrix Adaptation Evolution Strategy.
"CMAES" explore(cls)
Exploration-biased preset: large initial step size.
"CMAES" exploit(cls)
Exploitation-biased preset: small initial step size.
One evaluation of a constrained simulation.
"GeneticAlgorithm" exploit(cls)
Exploitation-biased preset: smaller population, low mutation.
"GeneticAlgorithm" explore(cls)
Exploration-biased preset: large population, high mutation.
Mixture of PCA-Gaussian, PCA-Cauchy, and random-direction mutation.
Isotropic Gaussian mutation applied to all genes together.
L-BFGS gradient-based optimization.
float values
The optimized values, as a plain name-to-value mapping.
float __getitem__(self, str name)
Get one optimized value by parameter name.
Exception raised for ParameterOptimization-specific errors.
Optimize named model parameters against an objective function.
None setInputFile(self, Optional[str] path)
Read the initial parameter set from a file.
None _validate_parameter_types_for_algorithm(Mapping[str, "Parameter"] parameters, "AlgorithmSettings" algorithm)
Reject discrete parameters given to an algorithm that cannot handle them.
None _check_alive(self)
Raise if this instance has already been destroyed.
OptimizationResult run(self, Callable[[Dict[str, float]], float] objective, Mapping[str, Parameter] parameters, Optional[Callable[[Dict[str, float]], Dict[str, float]]] gradient=None, *, bool finite_difference=False, float fd_step=0.0)
Run the optimization.
list _validate_parameters(Mapping[str, Parameter] parameters)
Check the parameter mapping and flatten it for the wrapper.
None setResultFile(self, Optional[str] path)
Write the final result to a CSV file.
bool is_available(self)
Check if the parameteroptimization plugin is available in this build.
None setPrintProgress(self, bool enable)
Enable or disable the plugin's progress printout to stdout.
None setProgressFile(self, Optional[str] path)
Write per-generation progress to a CSV file.
__del__(self)
Destructor to ensure C++ resources freed even without 'with' statement.
None setAlgorithm(self, AlgorithmSettings algorithm)
Select the optimization algorithm and its hyperparameters.
Dict[str, bool] availableAlgorithms()
Report which algorithms can run in this build.
OptimizationResult runConstrained(self, Callable[[Dict[str, float]], ConstrainedResult] simulation, Mapping[str, Parameter] parameters, *, int constraint_count)
Run a constrained optimization: minimize f(x) subject to c_i(x) <= 0.
getNativePtr(self)
Get the native pointer for advanced operations.
__exit__(self, exc_type, exc_value, traceback)
Context manager exit with proper cleanup.
"Parameter" continuous(cls, float value, float min, float max)
Create a continuous parameter bounded by [min, max].
"Parameter" categorical(cls, float value, Sequence[float] categories)
Create a parameter restricted to an explicit set of values.
"Parameter" integer(cls, float value, float min, float max)
Create an integer parameter bounded by [min, max].
SLSQP gradient-based optimization.
Exception classes for PyHelios library.
Definition exceptions.py:10
make_constrained_simulation
Definition __init__.py:216