-
Notifications
You must be signed in to change notification settings - Fork 0
Implementing a New Algorithm
This is the primary guide for contributors adding a new optimization algorithm to dfbench. The framework is designed to make this as painless as possible: you write the optimization logic, Objective handles everything else.
Every algorithm must:
-
Subclass
OptimizationAlgorithm -
Declare
algorithm_strandalgorithm_type -
Implement
optimize(objective, …) → None -
Use
Objectivefor all function evaluations-
Unless you use your own JIT-compiled loop — then manually log calls via
Objective.log_evaluation(...)afterwards. This adds negligible overhead relative to the objective function.
-
Unless you use your own JIT-compiled loop — then manually log calls via
That's it. No manual timing, history management or file I/O necessary.
Place your algorithm in the appropriate subdirectory:
src/dfbench/algorithms/
├── derivative_free/ ← direct search and non-gradient local solvers
├── global_search/ ← stochastic global optimizers
├── evolutionary/ ← population-based methods (PSO, ES, random search)
├── gradient_based/
│ ├── optax/ ← Optax optimizer wrappers (subclass OptaxAlgorithm)
│ ├── scipy/ ← SciPy minimize wrappers (subclass ScipyMinimizeAlgorithm)
│ ├── custom_jax.py ← native-JAX custom/hybrid gradient methods
│ └── *.py ← custom optimization loops
├── surrogate_based/ ← builds a surrogate model (BO, kNN, etc.)
└── generative/ ← generative models (VAE, diffusion, etc.)
For example: src/dfbench/algorithms/evolutionary/my_algorithm.py
Here is a complete, minimal template:
import secrets
import numpy as np
import jax
import jax.numpy as jnp
from jaxtyping import Array, Float
from dfbench.core.algorithm import OptimizationAlgorithm, AlgorithmType
from dfbench.core.objective import Objective
class MyAlgorithm(OptimizationAlgorithm):
"""One-line description of your algorithm.
Longer description, references, etc.
"""
algorithm_str: str = "my_algorithm" # unique identifier
algorithm_type: AlgorithmType = AlgorithmType.EVOLUTIONARY # match the algorithms/ subfolder
def __init__(self, batch_size: int = 50) -> None:
"""Initialize with algorithm-level meta-parameters.
These are parameters intrinsic to the algorithm that don't change
between runs (e.g. batch size, network architecture).
"""
self.batch_size = batch_size
def optimize(
self,
objective: Objective,
max_iterations: int | None = None,
init_params: Float[Array, "..."] | None = None,
random_seed: int | None = None,
# ---- your hyperparameters below ----
my_hyperparam: float = 1.0,
patience: int = 1000,
**kwargs,
) -> None:
"""Run the optimization.
Args:
objective: Pre-configured Objective instance.
max_iterations: Max algorithm iterations (not evals). None = budget only.
For algorithms where each iteration performs exactly one evaluation
(e.g. gradient-based methods), omit this parameter —
`obj.budget_exceeded` already handles it.
init_params: Optional starting point.
random_seed: Seed for reproducibility.
my_hyperparam: Description of your hyperparameter.
patience: Stop after N iterations without improvement.
"""
# ─── 1. Setup references ───
obj = objective
problem = obj.problem
# Sets unbounded mode, algorithm_str, seeds np/JAX, returns resolved seed + JAX key
random_seed, key = self.prepare(
obj,
unbounded=False,
random_seed=random_seed,
)
# torch.manual_seed(random_seed) # add if you use PyTorch
# ─── 3. Initialize parameters ───
if init_params is None:
params = obj.random_params_bounded(n_samples=self.batch_size)
else:
params = init_params
# ─── 4. JIT warmup ───
# This compiles the JAX computation graph. Do it BEFORE start_logging()
# so compilation time doesn't count against the time budget.
obj.warmup_vmap_value(batch_size=self.batch_size)
# ─── 5. Start logging (starts the clock) ───
obj.start_logging()
# ─── 6. Optimization loop ───
iteration = 0
while not obj.budget_exceeded:
if max_iterations is not None and iteration >= max_iterations:
break
# Evaluate
losses = obj.vmap_value(params) # automatically logged!
# Your update logic
key, subkey = jax.random.split(key)
# ... update params using losses, subkey, my_hyperparam, etc. ...
# Early stopping (optional)
if obj.evals_since_improvement > patience:
break
iteration += 1
# ─── 7. Done ───
# The Objective is mutated in place. The caller accesses results
# from the same instance it passed in. No return needed.Add your import to two files:
src/dfbench/algorithms/<category>/__init__.py:
from dfbench.algorithms.evolutionary.my_algorithm import MyAlgorithmsrc/dfbench/algorithms/__init__.py:
from dfbench.algorithms.evolutionary.my_algorithm import MyAlgorithm
__all__ = [
…,
"MyAlgorithm",
]The base class OptimizationAlgorithm.optimize() contains a commented blueprint showing every step. Here's what each step does and why:
obj = objective
problem = obj.problem
random_seed, key = self.prepare(
obj,
unbounded=False,
random_seed=random_seed,
)prepare() is called as prepare(obj, unbounded, random_seed, algorithm_str=None, **kwargs). It sets obj.unbounded, obj.algorithm_str, seeds np.random and JAX, and returns (random_seed, key). If random_seed=None is passed, a seed is generated via system entropy. You can also configure the Objective manually instead of calling prepare().
Choose unbounded:
-
Falseif your algorithm naturally handles bound constraints (evolutionary, derivative-free, global-search, surrogate-based) -
Trueif you want smooth unconstrained space where gradients never hit box boundaries (via sigmoid transform)
prepare() handles np.random, JAX, and the seed print. If your algorithm uses PyTorch or another framework, seed it with the returned value:
random_seed, key = self.prepare(
obj,
unbounded=False,
random_seed=random_seed,
)
torch.manual_seed(random_seed) # only needed for PyTorch-based librariesFor bounded space:
params = obj.random_params_bounded() # shape: (n_params,)
batch = obj.random_params_bounded(n_samples=100) # shape: (100, n_params)For unbounded space:
params = obj.random_params_unbounded() # shape: (n_params,)obj.warmup_value() # single eval path
obj.warmup_value_and_grad() # when using gradients
obj.warmup_vmap_value(batch_size=100) # for batched methods (match your batch size)Warmup can take seconds because it triggers JAX compilation. Do this before start_logging() so the compilation time is not counted against the time budget. The warmup_*() helpers use deterministic params internally and run the corresponding path twice. Single-point helpers take no arguments; batched helpers take the batch size used by your algorithm.
obj.start_logging()This starts the wall-clock timer. Everything before this line is "free" (warmup). Everything after is timed.
while not obj.budget_exceeded:
loss = obj.value(params) # or obj.value_and_grad, obj.vmap_value, etc.
# ... your algorithm logic ...budget_exceeded returns True when either the time or evaluation budget is exhausted. Once exceeded, further evaluations still work (JAX functions still run) but their results are not logged.
optimize() returns None. The Objective was mutated in place — the caller (benchmark harness or user script) accesses results from the same instance it passed in. This follows the Python convention that in-place mutating methods return None (like list.sort()).
| Method | When to use | Logs |
|---|---|---|
obj.value(params) |
Need loss only, single point | loss, params |
obj.grad(params) |
Need gradient only (rare) | grad, params (no loss!) |
obj.hessian(params) |
Need exact second-order information | hessian, params (no loss!) |
obj.value_and_grad(params) |
Gradient-based optimization | loss, grad, params |
obj.value_grad_and_hessian(params) |
Newton-style / second-order optimization | loss, grad, hessian, params |
obj.vmap_value(batch) |
Population evaluation | batch losses, batch params |
obj.vmap_value_and_grad(batch) |
Batched gradient-based | batch losses, grads, params |
obj.vmap_hessian(batch) |
Batched second-order optimization | batch hessians, batch params |
obj.vmap_value_grad_and_hessian(batch) |
Batched second-order optimization | batch losses, grads, hessians, params |
obj.value_function(…) |
Raw JAX callable for custom JIT loops | nothing; use log_evaluation afterwards |
obj.log_evaluation(…) |
Custom JIT'd loop | whatever you pass, including optional Hessians |
Important: obj.grad() and obj.hessian() do not log a loss value. If you need the loss too, use obj.value_and_grad() or obj.value_grad_and_hessian().
Some optimizers (e.g. Optax's L-BFGS) need to call value_and_grad inside a JIT-compiled function — for instance because the optimizer's line-search requires the raw value function. In that case you can't use obj.value_and_grad() (which has Python-side logging). Instead:
- Get an unlogged raw value function from
obj.value_function(...) - Build your own JIT-compiled step
- After each step, call
obj.log_evaluation(params, loss, grad, hessian=None)to record the results
obj.value_function(unbounded=None) follows the Objective's active space mode by default. Pass unbounded=True when the JIT loop works in unbounded coordinates and needs Objective's mapping into problem bounds; pass unbounded=False for bounded coordinates. The callable deliberately does not log anything.
# Get the raw function for JIT compilation
value_fn = obj.value_function(unbounded=True)
value_and_grad_fn = jax.value_and_grad(value_fn)
@jax.jit
def _step(params, opt_state):
loss, grads = value_and_grad_fn(params)
updates, new_state = optimizer.update(grads, opt_state, params, ...)
new_params = optax.apply_updates(params, updates)
return new_params, new_state, loss, grads
_ = _step(params, state) # JIT warmup
obj.start_logging()
while not obj.budget_exceeded:
prior_params = params
params, state, loss, grads = _step(params, state)
obj.log_evaluation(prior_params, loss, grads) # public API for manual loggingDo NOT call
obj._log(),obj._log_evals(), orobj._log_to_file()directly — these are private methods.log_evaluation()delegates to_log()which coordinates all internal logging.
See LBFGSGD in src/dfbench/algorithms/gradient_based/lbfgs_gd.py for a complete working example.
- Parameters live in
[lower, upper]for each dimension. - Use
obj.value()orobj.vmap_value(). - Algorithms must keep parameters within bounds (via clamping, constrained sampling, etc.).
- Useful for evolutionary, derivative-free, global-search, and surrogate-based methods.
Be careful!
- Ask for a
batch_sizein the__init__()and try different sizes. - Changing the
batch_sizecauses a recompile which can take a while.
random_seed, key = self.prepare(
obj,
unbounded=False,
random_seed=random_seed,
)
params = obj.random_params_bounded(n_samples=100)
losses = obj.vmap_value(params)- Parameters live in
$(-\infty, +\infty)$ . - The objective internally applies sigmoid bounding:
$\text{bounded} = \text{lb} + (\text{ub} - \text{lb}) \cdot \sigma(\text{unbounded})$ . - To get final results in the physical space:
obj.best_params_bounded.
random_seed, key = self.prepare(
obj,
unbounded=True,
random_seed=random_seed,
)
params = obj.random_params_unbounded()
loss, grad = obj.value_and_grad(params)If you need a different transform than sigmoid, configure it explicitly before logging starts. Custom mappings must map to the [0, 1] range; the Objective handles scaling to actual bounds (bounded = lb + (ub - lb) * f(x)):
import jax
# Scalar function — works on floats and arrays alike
obj.set_space_mode(
True,
unit_mapping=jax.nn.sigmoid,
inverse_unit_mapping=lambda x: jnp.log(x / (1.0 - x)),
)Important:
- Always pass both functions together (forward and inverse)
- The forward maps to [0, 1]; the inverse maps [0, 1] → unbounded. Bounds scaling is handled by
Objective - Functions can be scalar (e.g.
jax.nn.sigmoid) or element-wise vector; both work because JAX broadcasts element-wise operations. Batching is handled byObjectiveviajax.vmap -
obj.best_params_boundedandobj.random_params_unbounded()then follow your custom mapping pair
Many algorithms wrap external optimization libraries (EvoX, BoTorch, Optax). Here's the pattern:
from dfbench import t2j, j2t
# Convert JAX → PyTorch for the library
params_torch = j2t(params_jax)
# Convert PyTorch → JAX for evaluation
params_jax = t2j(params_torch)
losses_jax = obj.vmap_value(params_jax)
losses_torch = j2t(losses_jax)The conversion goes through NumPy and adds negligible overhead relative to evaluation time.
No conversion needed — Optax operates directly on JAX arrays:
import optax
optimizer = optax.adam(learning_rate=0.1)
state = optimizer.init(params)
loss, grad = obj.value_and_grad(params)
updates, state = optimizer.update(grad, state, params)
params = optax.apply_updates(params, updates)- Algorithm subclasses
OptimizationAlgorithm -
algorithm_stris set to a unique identifier -
algorithm_typeis set correctly -
optimize()acceptsobjective: Objectiveas first arg -
optimize()returnsNone(theObjectiveis mutated in place) - All evaluations go through
Objective(value*,vmap_*, orvalue_function(...)pluslog_evaluation(...); no directproblem.objective_function()calls) - JIT warmup happens before
obj.start_logging() -
random_seedis accepted, set, and printed - Early stopping uses
obj.evals_since_improvement(or custom logic) - Loop terminates on
obj.budget_exceeded - Imports added to
__init__.pyfiles - Docstrings describe the algorithm, its hyperparameters, and provide a reference if applicable
Artificial Scientist Lab | Website |University of Tübingen
Department of Computer Science
| Read our Documentation | Contact: laurin.sefa@student.uni-tuebingen.de, mario.krenn@uni-tuebingen.de, soham.basu@uni-tuebingen.de
Getting Started
Core API
Benchmarking
Contributing
Reference