0.1.33
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 LGPL Luksan solvers. PyHelios
373 builds without them by default to keep the distributed library MIT-licensed,
374 so this is typically unavailable -- use :class:`Adam` or :class:`BOBYQA`.
375 """
376
377 max_iterations: int = 200
378 ftol_rel: float = 1e-6
379 xtol_rel: float = 1e-6
380 verify_gradients: bool = False
381 fd_step: float = 1e-5
382
383 def _to_struct(self):
384 struct = paramopt_wrapper.PyHeliosLBFGS()
385 struct.max_iterations = self.max_iterations
386 struct.ftol_rel = self.ftol_rel
387 struct.xtol_rel = self.xtol_rel
388 struct.verify_gradients = 1 if self.verify_gradients else 0
389 struct.fd_step = self.fd_step
390 return struct
391
392
393@dataclass
394class BOBYQA:
395 """
396 BOBYQA derivative-free local optimization. Requires NLopt.
397
398 Builds a local quadratic model from function values alone -- a good choice
399 for polishing a population-based result or for noisy black-box objectives.
400 """
401
402 max_iterations: int = 200
403 ftol_rel: float = 1e-6
404 xtol_rel: float = 1e-6
405 initial_step: float = 0.0 #: 0 selects 10% of the parameter range
406
407 def _to_struct(self):
408 struct = paramopt_wrapper.PyHeliosBOBYQA()
409 struct.max_iterations = self.max_iterations
410 struct.ftol_rel = self.ftol_rel
411 struct.xtol_rel = self.xtol_rel
412 struct.initial_step = self.initial_step
413 return struct
414
415
416@dataclass
417class SLSQP:
418 """
419 SLSQP gradient-based optimization. Requires NLopt and a gradient.
420
421 Note that PyHelios does not currently expose the plugin's nonlinear
422 constraint support, so this behaves as an unconstrained local optimizer.
423 """
424
425 max_iterations: int = 200
426 ftol_rel: float = 1e-6
427 xtol_rel: float = 1e-6
428
429 def _to_struct(self):
430 struct = paramopt_wrapper.PyHeliosSLSQP()
431 struct.max_iterations = self.max_iterations
432 struct.ftol_rel = self.ftol_rel
433 struct.xtol_rel = self.xtol_rel
434 return struct
435
436
437AlgorithmSettings = Union[GeneticAlgorithm, BayesianOptimization, CMAES,
438 Adam, LBFGS, BOBYQA, SLSQP]
439
440# Maps each settings type to its short native name, its wrapper setter, whether
441# it needs a gradient, and whether it understands INTEGER/CATEGORICAL parameters.
442#
443# Only the genetic algorithm handles discrete parameters. Every other algorithm
444# searches a continuous space and would treat a discrete parameter as a plain
445# float, so passing one is rejected rather than silently answered -- see
446# _validate_parameter_types_for_algorithm.
447_ALGORITHM_INFO = {
448 GeneticAlgorithm: ("GA", paramopt_wrapper.setGeneticAlgorithm, False, True),
449 BayesianOptimization: ("BO", paramopt_wrapper.setBayesianOptimization, False, False),
450 CMAES: ("CMAES", paramopt_wrapper.setCMAES, False, False),
451 Adam: ("ADAM", paramopt_wrapper.setAdam, True, False),
452 LBFGS: ("LBFGS", paramopt_wrapper.setLBFGS, True, False),
453 BOBYQA: ("BOBYQA", paramopt_wrapper.setBOBYQA, False, False),
454 SLSQP: ("SLSQP", paramopt_wrapper.setSLSQP, True, False),
455}
456
457
458@dataclass(frozen=True)
460 """
461 One evaluation of a constrained simulation.
462
463 Returned by the callable passed to :meth:`ParameterOptimization.runConstrained`,
464 which computes the objective, the constraints, and every gradient in a single
465 pass. The optimizer caches this per parameter point, so a simulation that runs a
466 full Helios scene is evaluated once rather than once per constraint.
467
468 Args:
469 objective: Scalar cost to minimize
470 objective_gradient: Partial derivative of the objective for every parameter
471 constraints: Constraint values; constraint i is satisfied when its value is <= 0
472 constraint_gradients: One gradient mapping per constraint, in the same order
473 """
474
475 objective: float
476 objective_gradient: Dict[str, float]
477 constraints: Sequence[float]
478 constraint_gradients: Sequence[Dict[str, float]]
481@dataclass(frozen=True)
483 """
484 Outcome of an optimization run.
485
486 Holds full :class:`Parameter` objects rather than bare floats so a result
487 can be fed straight back into another run, e.g. refining a CMA-ES result
488 with BOBYQA.
489 """
490
491 parameters: Dict[str, Parameter]
492 fitness: float
493
494 @property
495 def values(self) -> Dict[str, float]:
496 """The optimized values, as a plain name-to-value mapping."""
497 return {name: parameter.value for name, parameter in self.parameters.items()}
498
499 def __getitem__(self, name: str) -> float:
500 """Get one optimized value by parameter name."""
501 return self.parameters[name].value
502
503
505 objective: Callable[[Dict[str, float]], float],
506 objective_gradient: Callable[[Dict[str, float]], Dict[str, float]],
507 constraints: Sequence[tuple]) -> Callable[[Dict[str, float]], ConstrainedResult]:
508 """
509 Compose separate objective and constraint callables into one simulation.
510
511 A convenience for problems whose constraints really are independent functions.
512 Note that it calls every function at each parameter point, so it forfeits the
513 single-pass advantage of writing one combined simulation: if computing the
514 objective and the constraints shares expensive work -- as it does when they come
515 from one Helios simulation -- write a :class:`ConstrainedResult` directly instead.
516
517 Args:
518 objective: Callable receiving ``{name: value}`` and returning a scalar cost
519 objective_gradient: Callable returning ``{name: partial derivative}``
520 constraints: Sequence of ``(function, gradient)`` pairs, each satisfied when
521 ``function(params) <= 0``
522
523 Returns:
524 A callable suitable for :meth:`ParameterOptimization.runConstrained`
525
526 Example:
527 >>> simulation = make_constrained_simulation(
528 ... lambda p: p["x"] ** 2,
529 ... lambda p: {"x": 2 * p["x"]},
530 ... [(lambda p: 1.0 - p["x"], lambda p: {"x": -1.0})])
531 """
532 if not callable(objective):
533 raise TypeError(f"objective must be callable, got {type(objective).__name__}")
534 if not callable(objective_gradient):
535 raise TypeError(
536 f"objective_gradient must be callable, got {type(objective_gradient).__name__}")
537
538 pairs = list(constraints)
539 for index, pair in enumerate(pairs):
540 if not isinstance(pair, tuple) or len(pair) != 2:
541 raise TypeError(
542 f"constraints[{index}] must be a (function, gradient) tuple, "
543 f"got {type(pair).__name__}")
544 if not all(callable(item) for item in pair):
545 raise TypeError(f"Both entries of constraints[{index}] must be callable")
546
547 def simulation(params: Dict[str, float]) -> ConstrainedResult:
548 return ConstrainedResult(
549 objective=objective(params),
550 objective_gradient=objective_gradient(params),
551 constraints=[function(params) for function, _ in pairs],
552 constraint_gradients=[gradient(params) for _, gradient in pairs],
553 )
554
555 return simulation
556
557
559 """
560 Optimize named model parameters against an objective function.
561
562 The objective receives a ``{name: value}`` dict and returns a scalar cost to
563 minimize. It may close over a :class:`~pyhelios.Context` and run a full
564 Helios simulation; the plugin itself takes no Context.
565
566 This class requires the native Helios library built with the
567 ``parameteroptimization`` plugin. Use it as a context manager so the C++
568 instance is released promptly.
569
570 Example:
571 >>> with ParameterOptimization() as opt:
572 ... opt.setAlgorithm(CMAES(max_evaluations=200, random_seed=1))
573 ... result = opt.run(objective, {"x": Parameter.continuous(0.0, -5.0, 5.0)})
574 ... print(result.fitness, result["x"])
575 """
576
577 def __init__(self):
578 """
579 Create a ParameterOptimization instance.
581 Raises:
582 ParameterOptimizationError: If the plugin is unavailable in this build
583 """
584 self.optimizer = None
585 self._running = False
586
587 registry = get_plugin_registry()
588 if not registry.is_plugin_available('parameteroptimization'):
589 available_plugins = registry.get_available_plugins()
591 "ParameterOptimization requires the 'parameteroptimization' plugin "
592 "which is not available.\n\n"
593 "To enable parameter optimization:\n"
594 "1. Rebuild PyHelios with all plugins:\n"
595 " build_scripts/build_helios --clean\n"
596 "2. Or select the plugin explicitly:\n"
597 " build_scripts/build_helios --plugins parameteroptimization\n\n"
598 "System requirements:\n"
599 " - Platforms: Windows, Linux, macOS\n"
600 " - Dependencies: none (NLopt is bundled)\n"
601 " - GPU: not required\n\n"
602 f"Currently available plugins: {available_plugins}"
603 )
604
605 try:
606 self.optimizer = paramopt_wrapper.createParameterOptimization()
607 if not self.optimizer:
609 "Failed to create ParameterOptimization instance.")
610 except ParameterOptimizationError:
611 raise
612 except Exception as e:
614 f"Failed to initialize ParameterOptimization: {e}")
615
616 def __enter__(self):
617 """Context manager entry."""
618 return self
619
620 def __exit__(self, exc_type, exc_value, traceback):
621 """Context manager exit with proper cleanup."""
622 if self.optimizer is not None:
623 try:
624 paramopt_wrapper.destroyParameterOptimization(self.optimizer)
625 logger.debug("ParameterOptimization destroyed successfully")
626 except Exception as e:
627 logger.warning(f"Error destroying ParameterOptimization: {e}")
628 finally:
629 self.optimizer = None
630
631 def __del__(self):
632 """Destructor to ensure C++ resources freed even without 'with' statement."""
633 if hasattr(self, 'optimizer') and self.optimizer is not None:
634 try:
635 paramopt_wrapper.destroyParameterOptimization(self.optimizer)
636 self.optimizer = None
637 except Exception as e:
638 import warnings
639 warnings.warn(f"Error in ParameterOptimization.__del__: {e}")
640
641 def getNativePtr(self):
642 """Get the native pointer for advanced operations."""
643 return self.optimizer
644
645 def _check_alive(self) -> None:
646 """Raise if this instance has already been destroyed."""
647 if self.optimizer is None:
649 "ParameterOptimization has been destroyed and can no longer be used.")
650
651 #=========================================================================
652 # Algorithm selection
653 #=========================================================================
654
655 @staticmethod
656 def availableAlgorithms() -> Dict[str, bool]:
657 """
658 Report which algorithms can run in this build.
659
660 L-BFGS, BOBYQA and SLSQP depend on NLopt, and L-BFGS additionally on the
661 LGPL Luksan solvers, which PyHelios disables by default.
662
663 Returns:
664 Mapping of algorithm name to availability
665 """
666 names = ["GA", "BO", "CMAES", "ADAM", "LBFGS", "BOBYQA", "SLSQP"]
667 return {name: paramopt_wrapper.isAlgorithmAvailable(name) for name in names}
668
669 def setAlgorithm(self, algorithm: AlgorithmSettings) -> None:
670 """
671 Select the optimization algorithm and its hyperparameters.
673 If never called, the plugin picks a default based on the parameter types
674 and whether a gradient was supplied.
675
676 Args:
677 algorithm: One of the algorithm settings dataclasses
678
679 Raises:
680 TypeError: If algorithm is not a recognized settings type
681 ParameterOptimizationError: If the algorithm is unavailable in this build
682 """
683 self._check_alive()
684
685 info = _ALGORITHM_INFO.get(type(algorithm))
686 if info is None:
687 raise TypeError(
688 f"algorithm must be one of "
689 f"{', '.join(cls.__name__ for cls in _ALGORITHM_INFO)}, "
690 f"got {type(algorithm).__name__}")
691
692 native_name, setter, _, _ = info
693
694 # Checked here rather than left to fail inside run(), so the user finds
695 # out before waiting through a long optimization.
696 if not paramopt_wrapper.isAlgorithmAvailable(native_name):
698 f"The {type(algorithm).__name__} algorithm is not available in this build.\n\n"
699 f"{native_name} is provided by NLopt. PyHelios builds NLopt without the "
700 f"LGPL-licensed Luksan solvers so the distributed library stays "
701 f"MIT-licensed, which makes L-BFGS unavailable.\n\n"
702 f"Alternatives that are always available:\n"
703 f" - Adam: gradient-based, noise-tolerant\n"
704 f" - BOBYQA: derivative-free local optimization (needs NLopt)\n"
705 f" - CMAES / GeneticAlgorithm: population-based global search\n\n"
706 f"To enable it anyway, rebuild helios-core with -DHELIOS_NLOPT_LUKSAN=ON.")
707
708 try:
709 setter(self.optimizer, algorithm._to_struct())
710 except (ParameterOptimizationError, ValueError, TypeError):
711 raise
712 except Exception as e:
713 raise ParameterOptimizationError(f"Failed to set algorithm: {e}")
714
715 self._algorithm = algorithm
716
717 #=========================================================================
718 # I/O configuration
719 #=========================================================================
720
721 def setPrintProgress(self, enable: bool) -> None:
722 """
723 Enable or disable the plugin's progress printout to stdout.
724
725 Args:
726 enable: True to print progress during optimization
727 """
728 self._check_alive()
729 paramopt_wrapper.setPrintProgress(self.optimizer, bool(enable))
730
731 def setResultFile(self, path: Optional[str]) -> None:
732 """
733 Write the final result to a CSV file.
735 Args:
736 path: Output path ending in .csv or .txt; None disables writing
737 """
738 self._check_alive()
739 paramopt_wrapper.setResultFile(self.optimizer, path)
740
741 def setProgressFile(self, path: Optional[str]) -> None:
742 """
743 Write per-generation progress to a CSV file.
745 Args:
746 path: Output path ending in .csv or .txt; None disables writing
747 """
748 self._check_alive()
749 paramopt_wrapper.setProgressFile(self.optimizer, path)
750
751 def setInputFile(self, path: Optional[str]) -> None:
752 """
753 Read the initial parameter set from a file.
755 Only the genetic algorithm consults this file.
756
757 Args:
758 path: Headerless CSV of "name,value,min,max" rows; None disables reading
759 """
760 self._check_alive()
761 paramopt_wrapper.setInputFile(self.optimizer, path)
762
763 #=========================================================================
764 # Run
765 #=========================================================================
767 def run(self,
768 objective: Callable[[Dict[str, float]], float],
769 parameters: Mapping[str, Parameter],
770 gradient: Optional[Callable[[Dict[str, float]], Dict[str, float]]] = None,
771 *,
772 finite_difference: bool = False,
773 fd_step: float = 0.0) -> OptimizationResult:
774 """
775 Run the optimization.
776
777 Args:
778 objective: Callable receiving {name: value} and returning a scalar cost
779 to minimize. Invoked once per candidate parameter set.
780 parameters: Parameters to optimize, keyed by name
781 gradient: Optional callable receiving {name: value} and returning
782 {name: partial derivative} for every parameter. Required by
783 Adam, L-BFGS, and SLSQP unless finite_difference is used.
784 finite_difference: Estimate the gradient by centered finite differences
785 instead of supplying one. Costs 2N extra objective
786 evaluations per gradient.
787 fd_step: Relative perturbation for finite differences; 0 uses the default
788
789 Returns:
790 The optimized parameters and the objective value at the optimum
791
792 Raises:
793 ValueError: If the arguments are invalid
794 TypeError: If objective or gradient is not callable
795 ParameterOptimizationError: If the optimization fails
796
797 Note:
798 An exception raised inside the objective aborts the run and is
799 re-raised here with its original traceback. Partial results are not
800 recoverable.
801 """
803
804 if not callable(objective):
805 raise TypeError(f"objective must be callable, got {type(objective).__name__}")
806 if gradient is not None and not callable(gradient):
807 raise TypeError(f"gradient must be callable, got {type(gradient).__name__}")
808 if gradient is not None and finite_difference:
809 raise ValueError(
810 "Pass either a gradient function or finite_difference=True, not both.")
811
812 specs = self._validate_parameters(parameters)
813
814 algorithm = getattr(self, '_algorithm', None)
815 if algorithm is not None:
816 _, _, needs_gradient, _ = _ALGORITHM_INFO[type(algorithm)]
817 self._validate_parameter_types_for_algorithm(parameters, algorithm)
818 if needs_gradient and gradient is None and not finite_difference:
819 raise ValueError(
820 f"{type(algorithm).__name__} is a gradient-based algorithm and requires "
821 f"a gradient. Either pass gradient=<callable>, or pass "
822 f"finite_difference=True to estimate it numerically.")
823
824 # The objective must not re-enter this same instance: the C++ optimizer
825 # is stateful and not reentrant.
826 if self._running:
828 "ParameterOptimization.run() was called from inside its own objective "
829 "function. The optimizer is not reentrant; use a separate instance.")
830
831 self._running = True
832 try:
833 if gradient is not None:
834 values, fitness = paramopt_wrapper.runOptimizationWithGradient(
835 self.optimizer, specs, objective, gradient)
836 elif finite_difference:
837 values, fitness = paramopt_wrapper.runOptimizationWithFDGradient(
838 self.optimizer, specs, objective, fd_step)
839 else:
840 values, fitness = paramopt_wrapper.runOptimization(
841 self.optimizer, specs, objective)
842 finally:
843 self._running = False
844
845 # Bounds and types are untouched by the optimizer, so they carry over
846 # from the inputs and only the values are replaced.
847 optimized = {
848 name: Parameter(value=values[name], min=parameter.min, max=parameter.max,
849 type=parameter.type, categories=parameter.categories)
850 for name, parameter in parameters.items()
851 }
852 return OptimizationResult(parameters=optimized, fitness=fitness)
853
854 def runConstrained(self,
855 simulation: Callable[[Dict[str, float]], ConstrainedResult],
856 parameters: Mapping[str, Parameter],
857 *,
858 constraint_count: int) -> OptimizationResult:
859 """
860 Run a constrained optimization: minimize f(x) subject to c_i(x) <= 0.
861
862 Requires ``setAlgorithm(SLSQP(...))``. SLSQP is the only algorithm in the
863 plugin that handles nonlinear inequality constraints, and it needs every
864 parameter to be ``FLOAT``.
865
866 The simulation returns the objective, the constraints, and all gradients
867 together. The optimizer caches each result, so the simulation runs once per
868 parameter point no matter how many constraints there are -- which is what
869 makes this practical for objectives that run a full Helios simulation.
870
871 Args:
872 simulation: Callable receiving ``{name: value}`` and returning a
873 :class:`ConstrainedResult`
874 parameters: Parameters to optimize, keyed by name
875 constraint_count: Number of constraints. Required, and fixed for the whole run: the buffers the simulation writes into are sized before the first call, so the count cannot be discovered by calling it.
876
877 Returns:
878 The optimized parameters and the objective value at the optimum
879
880 Raises:
881 ValueError: If the arguments are invalid, or the selected algorithm is
882 not SLSQP
883 TypeError: If simulation is not callable
884 ParameterOptimizationError: If the optimization fails
885
886 Note:
887 Constraints are satisfied to the plugin's tolerance rather than exactly.
888 Check feasibility of the returned parameters if it matters.
889
890 Example:
891 >>> # minimize x^2 + y^2 subject to x + y >= 1
892 >>> def simulation(p):
893 ... return ConstrainedResult(
894 ... objective=p["x"] ** 2 + p["y"] ** 2,
895 ... objective_gradient={"x": 2 * p["x"], "y": 2 * p["y"]},
896 ... constraints=[1.0 - p["x"] - p["y"]],
897 ... constraint_gradients=[{"x": -1.0, "y": -1.0}],
898 ... )
899 >>> with ParameterOptimization() as opt:
900 ... opt.setAlgorithm(SLSQP())
901 ... result = opt.runConstrained(
902 ... simulation,
903 ... {"x": Parameter.continuous(0.0, -5.0, 5.0),
904 ... "y": Parameter.continuous(0.0, -5.0, 5.0)},
905 ... constraint_count=1)
906 """
907 self._check_alive()
908
909 if not callable(simulation):
910 raise TypeError(
911 f"simulation must be callable, got {type(simulation).__name__}")
912 if not isinstance(constraint_count, int) or isinstance(constraint_count, bool):
913 raise TypeError(
914 f"constraint_count must be an int, got {type(constraint_count).__name__}")
915 if constraint_count < 1:
916 raise ValueError(
917 f"constraint_count must be at least 1, got {constraint_count}. "
918 f"Use run() when there are no constraints.")
919
920 specs = self._validate_parameters(parameters)
921
922 # Checked before the run rather than left to C++, which raises the same
923 # requirement from inside the optimizer once work is already underway.
924 algorithm = getattr(self, '_algorithm', None)
925 if not isinstance(algorithm, SLSQP):
926 selected = type(algorithm).__name__ if algorithm is not None else "none"
927 raise ValueError(
928 f"Constrained optimization requires SLSQP, but the selected algorithm is "
929 f"{selected}. Call setAlgorithm(SLSQP(...)) first.\n\n"
930 f"SLSQP is the only algorithm in the plugin that supports nonlinear "
931 f"inequality constraints. For the others, fold the constraint into the "
932 f"objective as a penalty term and use run().")
933
934 self._validate_parameter_types_for_algorithm(parameters, algorithm)
935
936 if self._running:
938 "ParameterOptimization.runConstrained() was called from inside its own "
939 "simulation function. The optimizer is not reentrant; use a separate "
940 "instance.")
941
942 self._running = True
943 try:
944 values, fitness = paramopt_wrapper.runOptimizationConstrained(
945 self.optimizer, specs, simulation, constraint_count)
946 finally:
947 self._running = False
948
949 optimized = {
950 name: Parameter(value=values[name], min=parameter.min, max=parameter.max,
951 type=parameter.type, categories=parameter.categories)
952 for name, parameter in parameters.items()
953 }
954 return OptimizationResult(parameters=optimized, fitness=fitness)
955
956 @staticmethod
957 def _validate_parameters(parameters: Mapping[str, Parameter]) -> list:
958 """
959 Check the parameter mapping and flatten it for the wrapper.
960
961 Only conditions that are memory-safety preconditions, or that produce a
962 materially better message here than from C++, are checked. Bound
963 consistency (min == max, min > max, NaN bounds, empty categories) is left
964 to the plugin's own validation so the two cannot drift apart.
965 """
966 if not isinstance(parameters, Mapping):
967 raise TypeError(
968 f"parameters must be a mapping of name to Parameter, "
969 f"got {type(parameters).__name__}")
970 if not parameters:
971 raise ValueError("parameters cannot be empty")
972
973 specs = []
974 for name, parameter in parameters.items():
975 if not isinstance(name, str):
976 raise TypeError(
977 f"Parameter names must be strings, got {type(name).__name__}: {name!r}")
978 if not name:
979 raise ValueError("Parameter names cannot be empty")
980 if "\x00" in name:
981 # Would silently truncate the C string and misalign every
982 # subsequent name lookup.
983 raise ValueError(f"Parameter name {name!r} cannot contain a null character")
984 if not isinstance(parameter, Parameter):
985 raise TypeError(
986 f"Parameter '{name}' must be a Parameter, "
987 f"got {type(parameter).__name__}")
988
989 for attribute in ("value", "min", "max"):
990 attr_value = getattr(parameter, attribute)
991 if not isinstance(attr_value, (int, float)) or isinstance(attr_value, bool):
992 raise TypeError(
993 f"Parameter '{name}' field '{attribute}' must be numeric, "
994 f"got {type(attr_value).__name__}")
995
996 categories = tuple(parameter.categories or ())
997 if parameter.type == ParameterType.CATEGORICAL and not categories:
998 raise ValueError(
999 f"Parameter '{name}' is CATEGORICAL and must define at least one "
1000 f"allowed value via 'categories'")
1001 for category in categories:
1002 if not isinstance(category, (int, float)) or isinstance(category, bool):
1003 raise TypeError(
1004 f"Parameter '{name}' has a non-numeric category: {category!r}")
1005 if not math.isfinite(float(category)):
1006 raise ValueError(
1007 f"Parameter '{name}' has a non-finite category: {category!r}")
1008
1009 specs.append({
1010 "name": name,
1011 "value": float(parameter.value),
1012 "min": float(parameter.min),
1013 "max": float(parameter.max),
1014 "type": int(parameter.type),
1015 "categories": [float(c) for c in categories],
1016 })
1017
1018 return specs
1019
1020 @staticmethod
1022 parameters: Mapping[str, "Parameter"],
1023 algorithm: "AlgorithmSettings") -> None:
1024 """
1025 Reject discrete parameters given to an algorithm that cannot handle them.
1026
1027 Only the genetic algorithm implements INTEGER and CATEGORICAL parameters.
1028 The rest search a continuous space, so a discrete parameter would be
1029 optimized as a plain float and the result would not be a whole number, or
1030 not one of the allowed categories.
1031
1032 helios-core enforces this for L-BFGS, Adam, BOBYQA and SLSQP, but not for
1033 CMA-ES or Bayesian optimization, where a CATEGORICAL parameter instead
1034 collapses to 0.0 with no diagnostic: its min and max are documented as
1035 ignored and so are conventionally left at zero, which those two algorithms
1036 read as the bounds [0, 0]. Checking here covers that gap on every core
1037 version, and reports the parameter and the remedy rather than leaving the
1038 message to the layer that happens to catch it first.
1039 """
1040 info = _ALGORITHM_INFO.get(type(algorithm))
1041 if info is None or info[3]:
1042 return
1043
1044 discrete = [
1045 (name, ParameterType(parameter.type).name)
1046 for name, parameter in parameters.items()
1047 if isinstance(parameter, Parameter)
1048 and parameter.type != ParameterType.FLOAT
1049 ]
1050 if not discrete:
1051 return
1052
1053 listed = ", ".join(f"'{name}' is {type_name}" for name, type_name in discrete)
1054 raise ValueError(
1055 f"{type(algorithm).__name__} requires every parameter to be FLOAT, "
1056 f"but {listed}.\n\n"
1057 f"GeneticAlgorithm is the only algorithm that supports INTEGER and "
1058 f"CATEGORICAL parameters. Either switch to it, or make the parameter "
1059 f"continuous with Parameter.continuous(...).")
1060
1061 def is_available(self) -> bool:
1062 """Check if the parameteroptimization plugin is available in this build."""
1063 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:198