Source code for sparsehydro.calibration.result
"""Calibration result containers — solver-agnostic.
:class:`CalibrationResult` stores the Pareto front and per-generation history
produced by any :class:`~sparsehydro.calibration.solvers.ISolver`. Objective
values are kept in **minimisation form** internally: maximised objectives are
negated on the way in and un-negated on the way out via
:meth:`CalibrationResult.objective_display_values` and
:meth:`CalibrationResult.to_pareto_dataframe`.
"""
from __future__ import annotations
from collections import namedtuple
import numpy as np
import pandas as pd
GenerationRecord = namedtuple(
"GenerationRecord", ["generation", "X", "F", "n_pareto"]
)
"""Per-generation snapshot of the optimizer population.
:param generation: Generation index (1-based).
:type generation: int
:param X: Parameter matrix, shape ``(pop_size, n_params)``.
:type X: numpy.ndarray
:param F: Objective matrix in minimisation form, shape ``(pop_size, n_obj)``.
:type F: numpy.ndarray
:param n_pareto: Number of Pareto-dominant solutions in this generation.
:type n_pareto: int
"""
def _identify_pareto(F: np.ndarray) -> np.ndarray:
"""Boolean mask of non-dominated solutions (all objectives minimised).
O(n²) dominance check — used when pymoo is unavailable or as a fallback.
:param F: 2-D array of objective values (minimisation form),
shape ``(n, n_obj)``.
:returns: Boolean array of length ``n``.
:rtype: numpy.ndarray
"""
n = len(F)
is_pareto = np.ones(n, dtype=bool)
for i in range(n):
for j in range(n):
if i == j:
continue
if np.all(F[j] <= F[i]) and np.any(F[j] < F[i]):
is_pareto[i] = False
break
return is_pareto
[docs]
class CalibrationResult:
"""General-purpose container for calibration results.
Produced by any :class:`~sparsehydro.calibration.solvers.ISolver`.
Objective values are stored in **minimisation form** (maximised objectives
are negated). Conversion back to display form is handled by
:meth:`objective_display_values` and :meth:`to_pareto_dataframe`.
:param history: One :class:`GenerationRecord` per generation. May be
empty for single-shot solvers.
:type history: list[GenerationRecord]
:param pareto_X: Parameter matrix for the final Pareto front,
shape ``(m, n_params)``.
:type pareto_X: numpy.ndarray
:param pareto_F: Objective matrix in minimisation form,
shape ``(m, n_obj)``.
:type pareto_F: numpy.ndarray
:param param_names: Ordered list of calibrated parameter names.
:type param_names: list[str]
:param objective_names: Names of each objective (matching objective order).
:type objective_names: list[str]
:param minimize_flags: ``True`` for minimised objectives; ``False`` for
maximised objectives.
:type minimize_flags: list[bool]
"""
def __init__(
self,
history: list[GenerationRecord],
pareto_X: np.ndarray,
pareto_F: np.ndarray,
param_names: list[str],
objective_names: list[str],
minimize_flags: list[bool],
) -> None:
self.history = list(history)
self.pareto_X = np.asarray(pareto_X, dtype=float)
self.pareto_F = np.asarray(pareto_F, dtype=float)
self.param_names = list(param_names)
self.objective_names = list(objective_names)
self.minimize_flags = list(minimize_flags)
[docs]
def objective_display_values(self) -> np.ndarray:
"""Return Pareto objective values in display form (un-negated).
:returns: Array of shape ``(m, n_obj)`` with actual objective values.
:rtype: numpy.ndarray
"""
F = self.pareto_F.copy()
for i, minimize in enumerate(self.minimize_flags):
if not minimize:
F[:, i] = -F[:, i]
return F
[docs]
def best_by(self, objective_name: str) -> np.ndarray:
"""Return the parameter vector that is best for the named objective.
"Best" means the lowest value for minimised objectives and the highest
for maximised objectives. Internally, :attr:`pareto_F` is always in
minimisation form, so ``argmin`` always selects the best solution.
:param objective_name: Name matching one of :attr:`objective_names`.
:type objective_name: str
:returns: 1-D parameter array.
:rtype: numpy.ndarray
:raises ValueError: If ``objective_name`` is not found.
"""
if objective_name not in self.objective_names:
raise ValueError(
f"Objective {objective_name!r} not found. "
f"Available: {self.objective_names}"
)
idx_obj = self.objective_names.index(objective_name)
idx_sol = int(np.argmin(self.pareto_F[:, idx_obj]))
return self.pareto_X[idx_sol]
[docs]
def to_pareto_dataframe(self) -> pd.DataFrame:
"""Return a tidy DataFrame spanning all generations.
Columns: ``generation``, one column per parameter, one column per
objective (display-form values), ``is_pareto`` (``True`` only for
solutions on the final generation's Pareto front).
:rtype: pandas.DataFrame
"""
rows = []
final_gen = self.history[-1].generation if self.history else -1
for rec in self.history:
is_pareto_mask = _identify_pareto(rec.F)
for j in range(len(rec.X)):
row: dict = {"generation": rec.generation}
for k, name in enumerate(self.param_names):
row[name] = float(rec.X[j, k])
for k, (obj_name, minimize) in enumerate(
zip(self.objective_names, self.minimize_flags)
):
raw = float(rec.F[j, k])
row[obj_name] = raw if minimize else -raw
row["is_pareto"] = bool(
is_pareto_mask[j] and rec.generation == final_gen
)
rows.append(row)
return pd.DataFrame(rows)