|
1 | 1 | #!/usr/bin/env python |
| 2 | +import warnings |
2 | 3 | import numpy as np |
| 4 | +from scipy.special import expit, logit |
3 | 5 | from fvgp import fvGP |
4 | 6 | from .gp_optimizer_base import GPOptimizerBase |
5 | 7 |
|
@@ -687,3 +689,111 @@ def __init__( |
687 | 689 | logging=logging, |
688 | 690 | multi_task=True, |
689 | 691 | args=args) |
| 692 | + |
| 693 | + |
| 694 | +class LogGPOptimizer(GPOptimizer): |
| 695 | + """ |
| 696 | + A single-task :py:class:`GPOptimizer` for strictly positive observations in (0, inf). |
| 697 | +
|
| 698 | + Observations are modeled in log-space (the GP sees ``log(y)``), and posterior |
| 699 | + predictions are mapped back to the original scale with ``exp`` via |
| 700 | + :py:meth:`evaluate_posterior`, which guarantees strictly positive predictions and |
| 701 | + credible intervals. ``exp`` of a Gaussian is lognormal, so the original-scale mean |
| 702 | + and standard deviation are available in closed form. |
| 703 | +
|
| 704 | + All constructor arguments are identical to :py:class:`GPOptimizer`. Note that the |
| 705 | + inherited :py:meth:`posterior_mean` / :py:meth:`posterior_covariance` operate in |
| 706 | + log-space; use :py:meth:`evaluate_posterior` for the original (positive) scale. |
| 707 | +
|
| 708 | + Acquisition functions: :py:meth:`ask` optimizes the GP in log-space. Because ``log`` |
| 709 | + is monotone increasing, ranking acquisitions (``variance``, ``ucb``, ``lcb``, |
| 710 | + ``maximum``, ``minimum``) still identify the same locations as on the original scale. |
| 711 | + For ``target probability``, pass bounds already in log-space (``np.log(a)``, ``np.log(b)``). |
| 712 | + """ |
| 713 | + |
| 714 | + def _prepare(self, y): |
| 715 | + y = np.asarray(y, dtype=float) |
| 716 | + if np.any(y <= 0.0): |
| 717 | + raise ValueError("LogGPOptimizer requires strictly positive observations (y > 0).") |
| 718 | + return y |
| 719 | + |
| 720 | + def _forward(self, y): |
| 721 | + return np.log(y) |
| 722 | + |
| 723 | + def _inverse(self, z): |
| 724 | + return np.exp(z) |
| 725 | + |
| 726 | + def _forward_deriv(self, y): |
| 727 | + return 1.0 / y |
| 728 | + |
| 729 | + def _moments(self, mu, var): |
| 730 | + # exp(Normal(mu, var)) is lognormal |
| 731 | + mean = np.exp(mu + var / 2.0) |
| 732 | + std = np.sqrt((np.exp(var) - 1.0) * np.exp(2.0 * mu + var)) |
| 733 | + return mean, std |
| 734 | + |
| 735 | + |
| 736 | +class LogitGPOptimizer(GPOptimizer): |
| 737 | + """ |
| 738 | + A single-task :py:class:`GPOptimizer` for observations bounded in [0, 1]. |
| 739 | +
|
| 740 | + Observations are modeled in logit (log-odds) space (the GP sees ``logit(y)``), and |
| 741 | + posterior predictions are mapped back with the logistic/sigmoid via |
| 742 | + :py:meth:`evaluate_posterior`, which guarantees predictions and credible intervals |
| 743 | + inside (0, 1). Because ``logit(0)`` / ``logit(1)`` are infinite, observations are |
| 744 | + clipped to ``[eps, 1 - eps]`` (a warning is emitted when clipping occurs). The |
| 745 | + logistic-normal distribution has no closed-form moments, so the original-scale mean |
| 746 | + and standard deviation are estimated by Monte-Carlo. |
| 747 | +
|
| 748 | + Parameters |
| 749 | + ---------- |
| 750 | + eps : float, optional |
| 751 | + Clipping margin for the open interval; observations are clipped to |
| 752 | + ``[eps, 1 - eps]`` before the logit transform. The default is 1e-6. |
| 753 | + n_samples : int, optional |
| 754 | + Number of Monte-Carlo samples used to estimate the original-scale mean/std in |
| 755 | + :py:meth:`evaluate_posterior`. The default is 10000. |
| 756 | +
|
| 757 | + Notes |
| 758 | + ----- |
| 759 | + All other constructor arguments are identical to :py:class:`GPOptimizer`. The |
| 760 | + inherited :py:meth:`posterior_mean` / :py:meth:`posterior_covariance` operate in |
| 761 | + logit-space; use :py:meth:`evaluate_posterior` for the original (0, 1) scale. The |
| 762 | + acquisition-function note for :py:class:`LogGPOptimizer` applies here too (pass |
| 763 | + ``target probability`` bounds in logit-space). |
| 764 | + """ |
| 765 | + |
| 766 | + def __init__(self, x_data=None, y_data=None, eps=1e-6, n_samples=10000, **kwargs): |
| 767 | + self.eps = eps |
| 768 | + self.n_samples = n_samples |
| 769 | + super().__init__(x_data=x_data, y_data=y_data, **kwargs) |
| 770 | + |
| 771 | + def _prepare(self, y): |
| 772 | + y = np.asarray(y, dtype=float) |
| 773 | + if np.any(y < self.eps) or np.any(y > 1.0 - self.eps): |
| 774 | + warnings.warn("LogitGPOptimizer clipped observations to " |
| 775 | + f"[{self.eps}, {1.0 - self.eps}] before the logit transform.") |
| 776 | + return np.clip(y, self.eps, 1.0 - self.eps) |
| 777 | + |
| 778 | + def _forward(self, y): |
| 779 | + return logit(y) |
| 780 | + |
| 781 | + def _inverse(self, z): |
| 782 | + return expit(z) |
| 783 | + |
| 784 | + def _forward_deriv(self, y): |
| 785 | + return 1.0 / (y * (1.0 - y)) |
| 786 | + |
| 787 | + def _moments(self, mu, var): |
| 788 | + # sigmoid(Normal(mu, var)) is logistic-normal -> no closed form, estimate by MC |
| 789 | + mu = np.asarray(mu).reshape(-1) |
| 790 | + sd = np.sqrt(np.asarray(var).reshape(-1)) |
| 791 | + samples = expit(np.random.normal(loc=mu[:, None], scale=sd[:, None], |
| 792 | + size=(mu.shape[0], self.n_samples))) |
| 793 | + return samples.mean(axis=1), samples.std(axis=1) |
| 794 | + |
| 795 | + def __getstate__(self): |
| 796 | + state = super().__getstate__() |
| 797 | + state["eps"] = self.eps |
| 798 | + state["n_samples"] = self.n_samples |
| 799 | + return state |
0 commit comments