"""
Surrogate accuracy metrics and evaluators.
Classes
-------
SurrogateAccuracyMetric
Abstract base for accuracy metric computation.
SpearmanCorrelation
Rank correlation between predicted and true values (primary metric).
RMSE
Root mean squared error of predictions.
R2Score
Coefficient of determination (R²).
SurrogateAccuracy
Container for computed accuracy metrics.
AccuracyEvaluator
Abstract base for surrogate accuracy evaluators.
KFoldAccuracyEvaluator
Evaluates accuracy via k-fold cross-validation.
LOOAccuracyEvaluator
Evaluates accuracy via leave-one-out cross-validation.
HeldOutAccuracyEvaluator
Evaluates accuracy against pre-specified held-out data.
"""
from __future__ import annotations
import copy
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
import numpy as np
from scipy.stats import spearmanr
if TYPE_CHECKING:
from saealib.surrogate.base import Surrogate
[ドキュメント]
class SurrogateAccuracyMetric(ABC):
"""Abstract base for a scalar accuracy metric."""
@property
@abstractmethod
def name(self) -> str:
"""Metric identifier used as key in :attr:`SurrogateAccuracy.metrics`."""
...
[ドキュメント]
@abstractmethod
def compute(self, y_true: np.ndarray, y_pred: np.ndarray) -> float:
"""Compute the metric.
Parameters
----------
y_true : np.ndarray
True objective values. shape: (n_samples, n_obj)
y_pred : np.ndarray
Predicted objective values. Same shape as ``y_true``.
Returns
-------
float
Scalar metric value. Interpretation (higher/lower = better)
depends on the concrete metric.
"""
...
[ドキュメント]
class SpearmanCorrelation(SurrogateAccuracyMetric):
"""Spearman rank correlation averaged over objectives.
Measures whether the surrogate preserves the ranking of candidates,
which is the primary criterion in EA contexts (Yu et al., 2019).
Range: [-1, 1]. Higher is better.
"""
@property
def name(self) -> str:
"""Return ``"spearman"``."""
return "spearman"
[ドキュメント]
def compute(self, y_true: np.ndarray, y_pred: np.ndarray) -> float:
"""Compute Spearman rank correlation, averaged over objectives."""
y_true = np.atleast_2d(y_true.T).T
y_pred = np.atleast_2d(y_pred.T).T
rhos = []
for j in range(y_true.shape[1]):
result = spearmanr(y_true[:, j], y_pred[:, j])
rho = float(result.statistic)
rhos.append(0.0 if np.isnan(rho) else rho)
return float(np.mean(rhos))
[ドキュメント]
class RMSE(SurrogateAccuracyMetric):
"""Root mean squared error.
Range: [0, ∞). Lower is better.
"""
@property
def name(self) -> str:
"""Return ``"rmse"``."""
return "rmse"
[ドキュメント]
def compute(self, y_true: np.ndarray, y_pred: np.ndarray) -> float:
"""Compute RMSE between true and predicted values."""
return float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
[ドキュメント]
class R2Score(SurrogateAccuracyMetric):
"""Coefficient of determination (R²).
Range: (-∞, 1]. Higher is better. 1.0 = perfect fit.
"""
@property
def name(self) -> str:
"""Return ``"r2"``."""
return "r2"
[ドキュメント]
def compute(self, y_true: np.ndarray, y_pred: np.ndarray) -> float:
"""Compute R² between true and predicted values."""
ss_res = np.sum((y_true - y_pred) ** 2)
ss_tot = np.sum((y_true - np.mean(y_true, axis=0)) ** 2)
if ss_tot == 0.0:
return 1.0 if ss_res == 0.0 else 0.0
return float(1.0 - ss_res / ss_tot)
[ドキュメント]
@dataclass
class SurrogateAccuracy:
"""Container for surrogate accuracy metrics computed after a fit.
Attributes
----------
metrics : dict[str, float]
Mapping from metric name (e.g. ``"spearman"``) to scalar value.
n_samples : int
Number of training samples used for evaluation.
"""
metrics: dict[str, float] = field(default_factory=dict)
n_samples: int = 0
[ドキュメント]
def get(self, name: str, default: float = float("nan")) -> float:
"""Return metric value by name, or *default* if not present."""
return self.metrics.get(name, default)
_DEFAULT_METRICS: list[SurrogateAccuracyMetric] = [
SpearmanCorrelation(),
RMSE(),
R2Score(),
]
[ドキュメント]
class AccuracyEvaluator(ABC):
"""Abstract base for surrogate accuracy evaluators.
Subclasses implement different evaluation strategies (k-fold CV, LOO,
held-out). All share the same :meth:`evaluate` interface so they can be
injected into :class:`~saealib.surrogate.manager.GlobalSurrogateManager`
interchangeably.
Parameters
----------
metrics : list[SurrogateAccuracyMetric] or None
Metrics to compute. Defaults to
:class:`SpearmanCorrelation`, :class:`RMSE`, :class:`R2Score`.
"""
[ドキュメント]
def __init__(
self,
metrics: list[SurrogateAccuracyMetric] | None = None,
):
self.metrics: list[SurrogateAccuracyMetric] = (
metrics if metrics is not None else list(_DEFAULT_METRICS)
)
[ドキュメント]
@abstractmethod
def evaluate(
self,
surrogate: Surrogate,
train_x: np.ndarray,
train_y: np.ndarray,
) -> SurrogateAccuracy:
"""Compute accuracy metrics for *surrogate* on the given training data.
Parameters
----------
surrogate : Surrogate
Surrogate instance to evaluate.
train_x : np.ndarray
Training inputs. shape: (n_samples, n_features)
train_y : np.ndarray
Training targets. shape: (n_samples, n_obj)
Returns
-------
SurrogateAccuracy
"""
...
def _compute_metrics(
self, y_true: np.ndarray, y_pred: np.ndarray
) -> dict[str, float]:
return {m.name: m.compute(y_true, y_pred) for m in self.metrics}
[ドキュメント]
class KFoldAccuracyEvaluator(AccuracyEvaluator):
"""Evaluates surrogate accuracy via k-fold cross-validation.
The training data is split into *n_splits* folds. For each fold, the
surrogate is deep-copied and fitted on the remaining folds, then tested
on the held-out fold. Metrics are averaged across folds.
Parameters
----------
metrics : list[SurrogateAccuracyMetric] or None
Metrics to compute.
n_splits : int
Number of folds. Clamped to ``n_samples`` if larger.
"""
[ドキュメント]
def __init__(
self,
metrics: list[SurrogateAccuracyMetric] | None = None,
n_splits: int = 5,
):
super().__init__(metrics)
self.n_splits = n_splits
[ドキュメント]
def evaluate(
self,
surrogate: Surrogate,
train_x: np.ndarray,
train_y: np.ndarray,
) -> SurrogateAccuracy:
"""Evaluate via k-fold CV."""
return _cv_loop(surrogate, train_x, train_y, self.metrics, self.n_splits)
[ドキュメント]
class LOOAccuracyEvaluator(AccuracyEvaluator):
"""Evaluates surrogate accuracy via leave-one-out cross-validation.
Equivalent to :class:`KFoldAccuracyEvaluator` with ``n_splits=n_samples``.
Each sample is used as a validation set exactly once.
Parameters
----------
metrics : list[SurrogateAccuracyMetric] or None
Metrics to compute.
"""
[ドキュメント]
def evaluate(
self,
surrogate: Surrogate,
train_x: np.ndarray,
train_y: np.ndarray,
) -> SurrogateAccuracy:
"""Evaluate via leave-one-out CV."""
n = len(train_x)
return _cv_loop(surrogate, train_x, train_y, self.metrics, n_splits=n)
[ドキュメント]
class HeldOutAccuracyEvaluator(AccuracyEvaluator):
"""Evaluates accuracy against pre-specified held-out data.
The surrogate is assumed to be already fitted; it is *not* re-fitted
inside :meth:`evaluate`. Useful for comparing surrogate predictions
against the most recent true evaluations.
Parameters
----------
held_x : np.ndarray
Held-out inputs. shape: (n_held, n_features)
held_y : np.ndarray
Held-out true objective values. shape: (n_held, n_obj)
metrics : list[SurrogateAccuracyMetric] or None
Metrics to compute.
"""
[ドキュメント]
def __init__(
self,
held_x: np.ndarray,
held_y: np.ndarray,
metrics: list[SurrogateAccuracyMetric] | None = None,
):
super().__init__(metrics)
self.held_x = np.asarray(held_x)
self.held_y = np.atleast_2d(np.asarray(held_y).T).T
[ドキュメント]
def evaluate(
self,
surrogate: Surrogate,
train_x: np.ndarray,
train_y: np.ndarray,
) -> SurrogateAccuracy:
"""Predict on held-out data and compute metrics."""
pred = surrogate.predict(self.held_x)
result = self._compute_metrics(self.held_y, pred.value)
return SurrogateAccuracy(metrics=result, n_samples=len(train_x))
def _cv_loop(
surrogate: Surrogate,
train_x: np.ndarray,
train_y: np.ndarray,
metrics: list[SurrogateAccuracyMetric],
n_splits: int,
) -> SurrogateAccuracy:
"""Run cross-validation and return averaged metrics.
Shared by :class:`KFoldAccuracyEvaluator` and :class:`LOOAccuracyEvaluator`.
The surrogate is deep-copied per fold and left unmodified.
"""
n = len(train_x)
if n < 2:
return SurrogateAccuracy(n_samples=n)
train_y = np.atleast_2d(train_y.T).T
n_splits = min(n_splits, n)
indices = np.arange(n)
fold_size = n // n_splits
accumulator: dict[str, list[float]] = {m.name: [] for m in metrics}
for fold in range(n_splits):
start = fold * fold_size
end = start + fold_size if fold < n_splits - 1 else n
val_idx = indices[start:end]
train_idx = np.setdiff1d(indices, val_idx)
if len(train_idx) < 2:
continue
surrogate_copy = copy.deepcopy(surrogate)
try:
surrogate_copy.fit(train_x[train_idx], train_y[train_idx])
pred = surrogate_copy.predict(train_x[val_idx])
for m in metrics:
accumulator[m.name].append(m.compute(train_y[val_idx], pred.value))
except Exception:
continue
averaged = {
name: float(np.mean(vals)) if vals else float("nan")
for name, vals in accumulator.items()
}
return SurrogateAccuracy(metrics=averaged, n_samples=n)