|
| 1 | +# Skill: gpCAM Acquisition Functions |
| 2 | + |
| 3 | +Design custom acquisition functions that control where gpCAM measures next. |
| 4 | + |
| 5 | +## When to Use |
| 6 | + |
| 7 | +When a user needs acquisition behavior beyond the built-in options: |
| 8 | +- Balancing exploration and exploitation |
| 9 | +- Multi-objective optimization |
| 10 | +- Constrained search (avoid forbidden regions) |
| 11 | +- Cost-aware acquisition (expensive moves) |
| 12 | +- Upper/lower confidence bounds |
| 13 | +- Probability of improvement |
| 14 | + |
| 15 | +## Acquisition Function Contract |
| 16 | + |
| 17 | +```python |
| 18 | +def my_acquisition(x, gp_optimizer): |
| 19 | + """ |
| 20 | + Parameters |
| 21 | + ---------- |
| 22 | + x : np.ndarray, shape (V, D) |
| 23 | + Candidate points to evaluate. |
| 24 | + gp_optimizer : GPOptimizer |
| 25 | + The GP model. Use its posterior_mean() and posterior_covariance() methods. |
| 26 | + |
| 27 | + Returns |
| 28 | + ------- |
| 29 | + scores : np.ndarray, shape (V,) |
| 30 | + Score for each candidate. HIGHER = more desirable (function is MAXIMIZED). |
| 31 | + """ |
| 32 | +``` |
| 33 | + |
| 34 | +**Key rule:** The acquisition function is **maximized**. Return higher values for points you want to measure. |
| 35 | + |
| 36 | +## Built-in Options |
| 37 | + |
| 38 | +Pass these as strings to `gpo.ask(acquisition_function=...)`: |
| 39 | + |
| 40 | +| Name | String key | Best for | |
| 41 | +|------|-----------|----------| |
| 42 | +| Variance | `"variance"` | Pure exploration / mapping | |
| 43 | +| Expected Improvement | `"expected improvement"` | Optimization (find max) | |
| 44 | +| Probability of Improvement | `"probability of improvement"` | Optimization, risk-averse | |
| 45 | +| Upper Confidence Bound | `"ucb"` | Maximization with tunable exploration/exploitation | |
| 46 | +| Lower Confidence Bound | `"lcb"` | Minimization with tunable exploration/exploitation | |
| 47 | +| Predicted Maximum | `"maximum"` | Pure exploitation — mean only, no uncertainty | |
| 48 | +| Predicted Minimum | `"minimum"` | Pure exploitation for minimization | |
| 49 | +| Gradient | `"gradient"` | Seek steepest regions of the posterior mean | |
| 50 | +| Target Probability | `"target probability"` | Find points with output near a target value | |
| 51 | +| Relative Information Entropy | `"relative information entropy"` | Information-theoretic exploration | |
| 52 | +| RIE Set | `"relative information entropy set"` | Batch acquisition | |
| 53 | +| Total Correlation | `"total correlation"` | Batch acquisition | |
| 54 | + |
| 55 | +To sanity-check any built-in or custom acquisition on a grid of candidates without calling `ask()`: |
| 56 | +```python |
| 57 | +scores = gpo.evaluate_acquisition_function(x_grid, acquisition_function="ucb") |
| 58 | +``` |
| 59 | + |
| 60 | +## Custom Acquisition Recipes |
| 61 | + |
| 62 | +### Upper Confidence Bound (UCB) |
| 63 | +Available as the built-in string `"ucb"` — pass directly to `gpo.ask(acquisition_function="ucb")`. Write the callable form below only when you need to tune `beta` or otherwise customize the score: |
| 64 | +```python |
| 65 | +def ucb(x, gpo): |
| 66 | + """ |
| 67 | + beta controls exploration/exploitation tradeoff: |
| 68 | + beta=0: pure exploitation (just go to predicted max) |
| 69 | + beta=1: mild exploration |
| 70 | + beta=3: strong exploration (~95% confidence) |
| 71 | + """ |
| 72 | + beta = 2.0 # TUNE THIS |
| 73 | + mean = gpo.posterior_mean(x)["m(x)"] |
| 74 | + var = gpo.posterior_covariance(x, variance_only=True)["v(x)"] |
| 75 | + return mean + beta * np.sqrt(var) |
| 76 | +``` |
| 77 | + |
| 78 | +### Lower Confidence Bound (for minimization) |
| 79 | +gpCAM maximizes acquisition, so flip the sign for minimization: |
| 80 | +```python |
| 81 | +def lcb(x, gpo): |
| 82 | + """Find the minimum of the function.""" |
| 83 | + beta = 2.0 |
| 84 | + mean = gpo.posterior_mean(x)["m(x)"] |
| 85 | + var = gpo.posterior_covariance(x, variance_only=True)["v(x)"] |
| 86 | + return -(mean - beta * np.sqrt(var)) # note the negation |
| 87 | +``` |
| 88 | + |
| 89 | +### Expected Improvement (custom version with minimization) |
| 90 | +```python |
| 91 | +from scipy.stats import norm |
| 92 | + |
| 93 | +def expected_improvement_minimize(x, gpo): |
| 94 | + """Expected improvement for finding the MINIMUM.""" |
| 95 | + mean = gpo.posterior_mean(x)["m(x)"] |
| 96 | + var = gpo.posterior_covariance(x, variance_only=True)["v(x)"] |
| 97 | + std = np.sqrt(np.maximum(var, 1e-10)) |
| 98 | + |
| 99 | + y_best = np.min(gpo.y_data) # current best (minimum) |
| 100 | + z = (y_best - mean) / std |
| 101 | + ei = std * (z * norm.cdf(z) + norm.pdf(z)) |
| 102 | + return ei |
| 103 | +``` |
| 104 | + |
| 105 | +### Probability of Improvement |
| 106 | +```python |
| 107 | +from scipy.stats import norm |
| 108 | + |
| 109 | +def probability_of_improvement(x, gpo): |
| 110 | + """Probability that measurement improves on current best.""" |
| 111 | + mean = gpo.posterior_mean(x)["m(x)"] |
| 112 | + var = gpo.posterior_covariance(x, variance_only=True)["v(x)"] |
| 113 | + std = np.sqrt(np.maximum(var, 1e-10)) |
| 114 | + |
| 115 | + y_best = np.max(gpo.y_data) |
| 116 | + z = (mean - y_best) / std |
| 117 | + return norm.cdf(z) |
| 118 | +``` |
| 119 | + |
| 120 | +### Constrained Acquisition (Avoid Regions) |
| 121 | +```python |
| 122 | +def constrained_variance(x, gpo): |
| 123 | + """Explore but avoid a circular forbidden zone.""" |
| 124 | + var = gpo.posterior_covariance(x, variance_only=True)["v(x)"] |
| 125 | + |
| 126 | + # Forbidden zone: circle at (5, 5) with radius 1 |
| 127 | + center = np.array([5.0, 5.0]) |
| 128 | + dist = np.linalg.norm(x - center, axis=1) |
| 129 | + penalty = np.where(dist < 1.0, -1e6, 0.0) |
| 130 | + |
| 131 | + return var + penalty |
| 132 | +``` |
| 133 | + |
| 134 | +### Multi-Objective (Weighted) |
| 135 | +```python |
| 136 | +def multi_objective(x, gpo): |
| 137 | + """Balance finding the max with reducing uncertainty.""" |
| 138 | + mean = gpo.posterior_mean(x)["m(x)"] |
| 139 | + var = gpo.posterior_covariance(x, variance_only=True)["v(x)"] |
| 140 | + |
| 141 | + w_exploit = 0.7 # weight on exploitation |
| 142 | + w_explore = 0.3 # weight on exploration |
| 143 | + |
| 144 | + # Normalize each component to [0, 1] |
| 145 | + mean_norm = (mean - mean.min()) / (mean.max() - mean.min() + 1e-10) |
| 146 | + var_norm = (var - var.min()) / (var.max() - var.min() + 1e-10) |
| 147 | + |
| 148 | + return w_exploit * mean_norm + w_explore * var_norm |
| 149 | +``` |
| 150 | + |
| 151 | +### Threshold Finder (Find Boundary) |
| 152 | +Useful when searching for where a signal crosses a threshold: |
| 153 | +```python |
| 154 | +def threshold_finder(x, gpo): |
| 155 | + """Find the boundary where f(x) = threshold.""" |
| 156 | + threshold = 0.5 # EDIT THIS |
| 157 | + |
| 158 | + mean = gpo.posterior_mean(x)["m(x)"] |
| 159 | + var = gpo.posterior_covariance(x, variance_only=True)["v(x)"] |
| 160 | + std = np.sqrt(np.maximum(var, 1e-10)) |
| 161 | + |
| 162 | + # Score is high near the threshold AND where uncertainty is high |
| 163 | + distance_to_threshold = np.abs(mean - threshold) |
| 164 | + return std / (distance_to_threshold + 0.01) |
| 165 | +``` |
| 166 | + |
| 167 | +## Usage in the Experiment Loop |
| 168 | + |
| 169 | +```python |
| 170 | +# Built-in string or a callable are both accepted: |
| 171 | +result = gpo.ask( |
| 172 | + input_set=parameter_bounds, |
| 173 | + acquisition_function=ucb, # or "ucb", "expected improvement", ... |
| 174 | +) |
| 175 | +``` |
| 176 | + |
| 177 | +### Useful `ask()` options |
| 178 | + |
| 179 | +| Argument | Meaning | |
| 180 | +|----------|---------| |
| 181 | +| `n=N` | Request `N` points at once (batch). For vectorized single-task, use a batch-aware acquisition like `"relative information entropy set"` or `"total correlation"`. | |
| 182 | +| `vectorized=True` (default) | The acquisition function is called once with all candidate points, shape `(V, D)` — required for custom callables written against the contract above. | |
| 183 | +| `vectorized=False` | Candidates are evaluated one at a time (list of 1-D arrays). Used for non-vectorizable acquisition or non-Euclidean inputs. | |
| 184 | +| `method="global"\|"local"\|"hgdl"\|"hgdlAsync"` | Inner optimizer that searches for the argmax of the acquisition over `input_set`. `hgdl` requires `dask_client=`; `hgdlAsync` starts a background search and returns an `opt_obj` you can poll or `kill_client()`. | |
| 185 | +| `dask_client=client` | Distribute the inner optimization across Dask workers. | |
| 186 | +| `batch_size=B` | When candidates are a list, evaluate them in chunks of `B` on the cluster. | |
| 187 | +| `max_iter`, `pop_size`, `info=True` | Inner optimizer controls. | |
| 188 | + |
| 189 | +`input_set` can be continuous bounds (`np.array([[lo,hi], ...])`), a list of candidate points (discrete finite set), or a list of arbitrary objects (non-Euclidean — strings, graphs — provided your kernel handles them). |
| 190 | + |
| 191 | +## Hyperparameter Coordination |
| 192 | + |
| 193 | +Acquisition functions don't add hyperparameters — they read the GP state via `gpo.posterior_mean()` and `gpo.posterior_covariance()`. However: |
| 194 | + |
| 195 | +- If you access `gpo.y_data` directly (e.g., for `y_best`), make sure it's up to date after `tell()` |
| 196 | +- The GP must be trained before acquisition makes sense — always call `train()` first |
| 197 | +- For `variance_only=True`: faster, returns just diagonal variances (usually what you want) |
| 198 | +- For full covariance: use `variance_only=False` but this is O(V²) memory |
| 199 | + |
| 200 | +## Common Pitfalls |
| 201 | + |
| 202 | +1. **Returning negative scores for points you want**: Remember, acquisition is MAXIMIZED. |
| 203 | +2. **Division by zero in std**: Always use `np.maximum(var, 1e-10)` before taking sqrt. |
| 204 | +3. **Not handling edge cases**: Early in the loop with few points, the GP posterior can be unreliable. |
| 205 | +4. **Expensive acquisition functions**: They're evaluated many times during optimization. Keep them fast. |
0 commit comments