Active Learning#
Active Learning & Acquisition Functions Example for JAXSR.
Demonstrates how to use acquisition functions to intelligently select the next experiments to run, balancing exploration and exploitation.
This example covers five scenarios:
Pure exploration: reduce uncertainty everywhere
Bayesian optimisation: find the minimum of an unknown function
Model discrimination: resolve which model structure is correct
Batch selection strategies: greedy vs penalized vs kriging believer
Full active learning loop: iteratively improve a model
import jax.numpy as jnp
import numpy as np
from jaxsr import BasisLibrary, SymbolicRegressor
from jaxsr.acquisition import (
LCB,
ActiveLearner,
AOptimal,
BMAUncertainty,
ConfidenceBandWidth,
DOptimal,
EnsembleDisagreement,
ExpectedImprovement,
ModelDiscrimination,
ModelMin,
PredictionVariance,
ProbabilityOfImprovement,
ThompsonSampling,
suggest_points,
)
Setup: Create a fitted model#
We’ll use this model across all examples below.
np.random.seed(42)
X = np.random.uniform(0, 5, (40, 1))
y = X[:, 0] ** 2 - 3.0 * X[:, 0] + 2.0 + np.random.randn(40) * 0.3
library = (
BasisLibrary(n_features=1, feature_names=["x"])
.add_constant()
.add_linear()
.add_polynomials(max_degree=3)
)
model = SymbolicRegressor(basis_library=library, max_terms=4, strategy="greedy_forward")
model.fit(jnp.array(X), jnp.array(y))
SymbolicRegressor(max_terms=4, strategy='greedy_forward', fitted)
Goal: improve model accuracy uniformly by sampling where prediction uncertainty is highest#
Available acquisition functions for this goal:
- PredictionVariance: the default. Uses the OLS posterior to compute
sigma^2(x). Fast, exact for linear-in-parameter models.
- ConfidenceBandWidth: similar, but reports the actual width of the
confidence band at a specified significance level.
- EnsembleDisagreement: uses the Pareto front of models with different
complexities. Good when you're unsure whether a simpler or more
complex model is appropriate.
- BMAUncertainty: Bayesian Model Averaging. The most comprehensive
measure -- combines noise uncertainty and model-selection uncertainty.
- AOptimal: targets reduction in *parameter* uncertainty (trace of
covariance matrix). Use when you care about accurate coefficients.
- DOptimal: maximises information gain (det of information matrix).
Use when you want maximal information per experiment.
bounds = [(0.0, 5.0)]
print(f"Current model: {model.expression_}")
print(f"Current R^2: {model.score(model._X_train, model._y_train):.4f}")
print(f"Training size: {len(model._y_train)}")
print()
# --- Try different exploration strategies ---
strategies = [
("PredictionVariance", PredictionVariance()),
("ConfidenceBandWidth(95%)", ConfidenceBandWidth(alpha=0.05)),
("EnsembleDisagreement", EnsembleDisagreement()),
("BMAUncertainty", BMAUncertainty(criterion="bic")),
("AOptimal", AOptimal()),
("DOptimal", DOptimal()),
]
for name, acq in strategies:
result = suggest_points(model, bounds, acq, n_points=3, random_state=42)
pts = np.array(result.points).ravel()
print(f" {name:30s} -> x = [{', '.join(f'{p:.2f}' for p in pts)}]")
print()
Current model: y = 0.0132*x^3 + 0.8891*x^2 + 1.827 - 2.736*x
Current R^2: 0.9941
Training size: 40
PredictionVariance -> x = [5.00, 0.01, 0.00]
ConfidenceBandWidth(95%) -> x = [5.00, 0.01, 0.00]
EnsembleDisagreement -> x = [0.01, 0.01, 0.00]
BMAUncertainty -> x = [4.95, 1.69, 1.59]
AOptimal -> x = [0.01, 0.01, 0.00]
DOptimal -> x = [5.00, 0.01, 0.00]
Goal: find x that minimises y, using the fitted model as a surrogate.#
Available acquisition functions for this goal:
- ModelMin / ModelMax: pure exploitation. No exploration at all --
just returns the predicted optimum. Use only when you fully trust
the model.
- LCB (Lower Confidence Bound): y_hat - kappa*sigma. The kappa
parameter controls exploration vs exploitation:
kappa=0 -> pure exploitation (ModelMin)
kappa~2 -> balanced
kappa>3 -> heavy exploration
- UCB (Upper Confidence Bound): the mirror image for maximisation.
- ExpectedImprovement (EI): the Bayesian optimisation gold standard.
Naturally balances exploration and exploitation without a tuning
parameter (just xi, which is usually small). Recommended as the
default for optimisation.
- ProbabilityOfImprovement (PI): similar to EI but only cares about
the *probability* of beating the current best, not the magnitude
of improvement. More exploitative than EI for the same xi.
- ThompsonSampling: draws a random model from the posterior and
optimises that. Produces diverse batches naturally.
bounds = [(0.0, 5.0)]
print(f"Model: {model.expression_}")
print("True minimum at x=1.5 (y = 2.25 - 4.5 + 2 = -0.25)")
print()
strategies = [
("ModelMin (exploit only)", ModelMin()),
("LCB kappa=0.5 (exploitative)", LCB(kappa=0.5)),
("LCB kappa=2 (balanced)", LCB(kappa=2.0)),
("LCB kappa=5 (exploratory)", LCB(kappa=5.0)),
("Expected Improvement", ExpectedImprovement(minimize=True)),
("Prob. of Improvement", ProbabilityOfImprovement(minimize=True)),
("Thompson Sampling", ThompsonSampling(minimize=True, seed=42)),
]
for name, acq in strategies:
result = suggest_points(model, bounds, acq, n_points=3, random_state=42)
pts = np.array(result.points).ravel()
print(f" {name:35s} -> x = [{', '.join(f'{p:.2f}' for p in pts)}]")
print()
Model: y = 0.0132*x^3 + 0.8891*x^2 + 1.827 - 2.736*x
True minimum at x=1.5 (y = 2.25 - 4.5 + 2 = -0.25)
ModelMin (exploit only) -> x = [1.40, 1.57, 1.41]
LCB kappa=0.5 (exploitative) -> x = [1.40, 1.57, 1.41]
LCB kappa=2 (balanced) -> x = [1.40, 1.41, 1.57]
LCB kappa=5 (exploratory) -> x = [1.58, 1.41, 1.57]
Expected Improvement -> x = [1.40, 1.41, 1.57]
Prob. of Improvement -> x = [1.40, 1.41, 1.57]
Thompson Sampling -> x = [1.58, 1.58, 1.57]
Goal: figure out which model form is correct.#
When the Pareto front contains models of different complexities that all fit the data similarly, you need data points that discriminate between them.
- ModelDiscrimination: scores candidates by the maximum disagreement
among Pareto-front models.
- EnsembleDisagreement: standard deviation across Pareto models.
Similar idea but uses std instead of max-min range.
bounds = [(0.0, 5.0)]
print(f"Best model: {model.expression_}")
print(f"Pareto front has {len(model.pareto_front_)} models:")
for r in model.pareto_front_:
print(f" complexity={r.complexity}, BIC={r.bic:.1f}: {r.expression()}")
print()
acqs = [
("ModelDiscrimination", ModelDiscrimination()),
("EnsembleDisagreement", EnsembleDisagreement()),
]
for name, acq in acqs:
result = suggest_points(model, bounds, acq, n_points=5, random_state=42)
pts = np.array(result.points).ravel()
print(f" {name:25s} -> x = [{', '.join(f'{p:.2f}' for p in pts)}]")
print()
Best model: y = 0.0132*x^3 + 0.8891*x^2 + 1.827 - 2.736*x
Pareto front has 3 models:
complexity=3, BIC=81.8: y = 0.09309*x^3
complexity=5, BIC=63.0: y = 0.1661*x^3 - 0.3433*x^2 + 0.4584
complexity=6, BIC=20.9: y = 0.0132*x^3 + 0.8891*x^2 + 1.827 - 2.736*x
ModelDiscrimination -> x = [0.02, 0.02, 0.01, 0.01, 0.00]
EnsembleDisagreement -> x = [0.02, 0.02, 0.01, 0.01, 0.00]
Goal: select a batch of points that are collectively informative.#
When you select the top-k by acquisition score (greedy), the points can cluster in one region. Batch strategies address this:
- greedy: top-k by raw score. Fast but may cluster.
- penalized: after selecting the best candidate, nearby candidates
are penalised before selecting the next. Simple diversity.
- kriging_believer: after selecting each point, the model is
temporarily updated with a "fantasy" observation (y_hat) and
re-scored. More sophisticated -- later selections account for
information gained by earlier ones.
- d_optimal: selects the batch that maximises det(Phi^T Phi),
ignoring the acquisition function entirely. Best for pure
space-filling / information maximisation.
bounds = [(0.0, 5.0)]
learner = ActiveLearner(model, bounds, PredictionVariance(), random_state=42)
for strategy in ["greedy", "penalized", "kriging_believer", "d_optimal"]:
result = learner.suggest(n_points=5, batch_strategy=strategy)
pts = sorted(np.array(result.points).ravel())
spread = pts[-1] - pts[0]
print(
f" {strategy:20s} -> x = [{', '.join(f'{p:.2f}' for p in pts)}]"
f" (spread={spread:.2f})"
)
print()
greedy -> x = [0.00, 0.01, 0.01, 4.99, 5.00] (spread=4.99)
penalized -> x = [0.00, 1.40, 3.15, 4.25, 5.00] (spread=5.00)
kriging_believer -> x = [0.00, 0.01, 0.01, 4.99, 5.00] (spread=5.00)
d_optimal -> x = [0.00, 1.36, 3.49, 3.73, 5.00] (spread=5.00)
Goal: iteratively improve a model by running experiments.#
The workflow is: 1. Fit an initial model on a small dataset. 2. Use an acquisition function to suggest new points. 3. “Run the experiment” (here: evaluate the true function + noise). 4. Update the model with the new data. 5. Repeat until converged or budget exhausted.
# True function (unknown to the model)
def oracle(X):
X = np.array(X)
return X[:, 0] ** 2 - 3.0 * X[:, 0] + 2.0 + np.random.randn(len(X)) * 0.2
# Start with very few points
np.random.seed(0)
X_init = np.random.uniform(0, 5, (15, 1))
y_init = oracle(X_init)
library = (
BasisLibrary(n_features=1, feature_names=["x"])
.add_constant()
.add_linear()
.add_polynomials(max_degree=3)
)
model = SymbolicRegressor(basis_library=library, max_terms=4, strategy="greedy_forward")
model.fit(jnp.array(X_init), jnp.array(y_init))
print(f"Initial model ({len(y_init)} points): {model.expression_}")
print(f" R^2 = {model.score(model._X_train, model._y_train):.4f}")
print(f" MSE = {model.metrics_['mse']:.4f}")
# Active learning loop
learner = ActiveLearner(
model,
bounds=[(0.0, 5.0)],
acquisition=ExpectedImprovement(minimize=True),
random_state=42,
)
n_iterations = 5
points_per_iteration = 5
for i in range(n_iterations):
result = learner.suggest(
n_points=points_per_iteration,
batch_strategy="penalized",
)
y_new = oracle(np.array(result.points))
learner.update(result.points, jnp.array(y_new))
print(
f" Iteration {i + 1}: "
f"n={learner.n_observations}, "
f"R^2={model.score(model._X_train, model._y_train):.4f}, "
f"MSE={model.metrics_['mse']:.4f}, "
f"model={model.expression_}"
)
print(f"\nFinal model ({learner.n_observations} points): {model.expression_}")
Initial model (15 points): y = 0.01273*x^3 - 2.784*x + 1.862 + 0.9046*x^2
R^2 = 0.9981
MSE = 0.0236
Iteration 1: n=20, R^2=0.9908, MSE=0.1040, model=y = 0.1458*x^3 - 0.2237*x^2
Iteration 2: n=25, R^2=0.9902, MSE=0.1002, model=y = 0.1483*x^3 - 0.2344*x^2
Iteration 3: n=30, R^2=0.9969, MSE=0.0280, model=y = 0.02827*x^3 + 0.777*x^2 + 1.729 - 2.496*x
Iteration 4: n=35, R^2=0.9962, MSE=0.0300, model=y = 0.0279*x^3 + 0.7865*x^2 + 1.756 - 2.538*x
Iteration 5: n=40, R^2=0.9957, MSE=0.0292, model=y = 0.01841*x^3 + 0.8644*x^2 + 1.875 - 2.722*x
Final model (40 points): y = 0.01841*x^3 + 0.8644*x^2 + 1.875 - 2.722*x
Goal: combine multiple objectives using weighted acquisition.#
You can weight and add acquisition functions together to balance different goals simultaneously. Each component is min-max normalised before weighting so the weights are meaningful.
Common recipes:
- Balanced optimisation: 0.7 * EI + 0.3 * PredictionVariance
- Exploration with model improvement: 0.5 * PredictionVariance + 0.5 * AOptimal
- Multi-objective: 0.4 * ModelMin + 0.3 * PredictionVariance + 0.3 * DOptimal
bounds = [(0.0, 5.0)]
composites = [
(
"0.7*EI + 0.3*Variance",
0.7 * ExpectedImprovement(minimize=True) + 0.3 * PredictionVariance(),
),
(
"0.5*LCB + 0.5*DOptimal",
0.5 * LCB(kappa=2) + 0.5 * DOptimal(),
),
(
"Equal: EI + Var + AOptimal",
ExpectedImprovement(minimize=True) + PredictionVariance() + AOptimal(),
),
]
for name, acq in composites:
result = suggest_points(model, bounds, acq, n_points=3, random_state=42)
pts = np.array(result.points).ravel()
print(f" {name:30s} -> x = [{', '.join(f'{p:.2f}' for p in pts)}]")
print()
0.7*EI + 0.3*Variance -> x = [4.99, 4.99, 5.00]
0.5*LCB + 0.5*DOptimal -> x = [1.23, 1.24, 1.24]
Equal: EI + Var + AOptimal -> x = [4.99, 4.99, 5.00]
Decision Guide: Choosing an Acquisition Function#
WHAT IS YOUR GOAL?
IMPROVE MODEL ACCURACY (explore everywhere)
Simple & fast? →
PredictionVarianceNeed coverage guarantee? →
ConfidenceBandWidth(alpha=0.05)Unsure about model form? →
EnsembleDisagreementorBMAUncertaintyTighten coefficient CIs? →
AOptimalMax info per experiment? →
DOptimal
FIND THE OPTIMUM (minimise or maximise y)
Trust the model fully? →
ModelMin/ModelMaxWant balanced exploration? →
ExpectedImprovement(recommended)Need probability of beating a threshold? →
ProbabilityOfImprovementWant explicit exploration knob? →
LCB(kappa)/UCB(kappa)Want randomised exploration? →
ThompsonSampling
DECIDE WHICH MODEL IS CORRECT
Pareto models disagree? →
ModelDiscriminationQuantify structural uncertainty? →
EnsembleDisagreement
MULTIPLE OBJECTIVES
Combine with weights:
0.7 * EI + 0.3 * PredictionVariance
BATCH STRATEGY SELECTION:
Fast, don’t care about diversity? →
greedyWant spatial diversity? →
penalizedWant information-aware batches? →
kriging_believerWant maximum design efficiency? →
d_optimal