Source code for denia.models

"""Surrogates and acquisition functions - the classical, reproducible core.

Two surrogates cover the practical range of tabular campaigns:

* :class:`EnsembleSurrogate` (default) - random-forest ensemble; uncertainty
  from across-tree dispersion. Robust to mixed/sparse features and scales to
  thousands of rows (the regime of literature-compiled datasets like OCM).
* :class:`GPSurrogate` - Gaussian process (Matérn 5/2); preferable for small
  in-house campaigns (tens to a few hundred rows).

Acquisition: expected improvement (EI) and upper confidence bound (UCB), plus
greedy batch selection with a diversity penalty so a recommended batch does
not collapse onto near-duplicates of the same candidate.
"""

from __future__ import annotations

import math
from typing import Tuple

import numpy as np

try:  # prefer scipy if available; keep a dependency-free fallback
    from scipy.stats import norm

    def norm_cdf(x):  # noqa: F811
        return norm.cdf(x)

    def norm_pdf(x):  # noqa: F811
        return norm.pdf(x)

except Exception:  # pragma: no cover
    def norm_cdf(x):  # noqa: F811
        return 0.5 * (1.0 + np.vectorize(math.erf)(np.asarray(x) / np.sqrt(2.0)))

    def norm_pdf(x):  # noqa: F811
        return np.exp(-0.5 * np.asarray(x) ** 2) / np.sqrt(2 * np.pi)

from sklearn.ensemble import RandomForestRegressor
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import ConstantKernel, Matern, WhiteKernel


[docs] class EnsembleSurrogate: """Random-forest surrogate with across-tree predictive dispersion.""" def __init__(self, n_estimators: int = 200, random_state: int = 0): self.model = RandomForestRegressor( n_estimators=n_estimators, random_state=random_state, n_jobs=-1, min_samples_leaf=2, ) def fit(self, X: np.ndarray, y: np.ndarray) -> "EnsembleSurrogate": mask = np.isfinite(y) self.model.fit(X[mask], y[mask]) return self def predict(self, X: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: preds = np.stack([t.predict(X) for t in self.model.estimators_], axis=0) return preds.mean(axis=0), preds.std(axis=0) + 1e-9
[docs] class GPSurrogate: """Gaussian-process surrogate (Matérn 5/2 + noise) for small campaigns.""" def __init__(self, random_state: int = 0): kernel = ConstantKernel(1.0) * Matern(nu=2.5) + WhiteKernel(1e-3) self.model = GaussianProcessRegressor( kernel=kernel, normalize_y=True, random_state=random_state, alpha=1e-8, ) def fit(self, X: np.ndarray, y: np.ndarray) -> "GPSurrogate": mask = np.isfinite(y) self.model.fit(X[mask], y[mask]) return self def predict(self, X: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: mu, std = self.model.predict(X, return_std=True) return mu, std + 1e-9
# --------------------------------------------------------------------------- acquisition def expected_improvement(mu: np.ndarray, std: np.ndarray, best: float, xi: float = 0.01) -> np.ndarray: imp = mu - best - xi z = imp / std return imp * norm_cdf(z) + std * norm_pdf(z) def upper_confidence_bound(mu: np.ndarray, std: np.ndarray, beta: float = 2.0) -> np.ndarray: return mu + beta * std
[docs] def select_batch( scores: np.ndarray, X: np.ndarray, batch_size: int, diversity: float = 0.3, ) -> np.ndarray: """Greedy batch selection with a squared-exponential diversity penalty. After each pick, candidate scores are down-weighted by proximity to the already-selected points (feature-space distance), preventing batches of near-duplicates - the practical failure mode of naive top-k selection on literature datasets full of replicated conditions. """ scores = scores.astype(float).copy() n = len(scores) batch_size = min(batch_size, n) chosen = [] scale = np.median(np.linalg.norm(X - X.mean(axis=0), axis=1)) + 1e-9 for _ in range(batch_size): i = int(np.nanargmax(scores)) chosen.append(i) scores[i] = -np.inf if diversity > 0: d = np.linalg.norm(X - X[i], axis=1) / scale active = np.isfinite(scores) scores[active] = scores[active] - diversity * np.exp(-(d[active] ** 2)) * np.abs(scores[active]) return np.array(chosen)