Skip to content

Use the built-in optimization algorithms¤

This tutorial shows how to use the optimizer blocks that ship with f3dasm on a built-in benchmark function.

Optimization in f3dasm is expressed by composing blocks. There are two shapes of optimizer block:

  • Ask/tell update-step blocks (e.g. TPE from Optuna). Chain with a data generator and wrap in a LoopBlock: (update_step >> data_generator).loop(n_iterations).
  • One-shot blocks (scipy's cg, lbfgsb, nelder_mead). Call once; scipy runs its own inner loop via maxiter.

See the Optimization Loops tutorial for a deeper dive on the pattern. Here we focus on how to use the built-in factories.

Set-up¤

We'll minimize a simple 2D paraboloid so both ask/tell and one-shot optimizers apply without domain-shape gymnastics. First define a domain and a data generator.

from f3dasm import ExperimentData, create_sampler, datagenerator
from f3dasm.design import Domain

domain = Domain()
domain.add_float(name="x0", low=-5.0, high=5.0)
domain.add_float(name="x1", low=-5.0, high=5.0)
domain.add_output("y")


@datagenerator(output_names="y")
def f(x0: float, x1: float) -> float:
    return (x0 - 1.5) ** 2 + (x1 + 2.0) ** 2


def initial_design(n: int = 10) -> ExperimentData:
    data = ExperimentData(domain=domain)
    data = create_sampler("latin", seed=42).call(data, n_samples=n)
    f.arm(data)
    return f.call(data)

Method 1: string-based factory create_optimizer¤

f3dasm.create_optimizer(name, **kwargs) returns an optimizer block. For ask/tell optimizers it returns the update step — you still chain with a data generator and wrap in a LoopBlock yourself.

Ask/tell: tpesampler¤

from f3dasm import create_optimizer

data = initial_design()

update_step = create_optimizer("tpesampler", output_name="y")
loop = (update_step >> f).loop(20)
loop.arm(data)
data = loop.call(data)

print(f"After TPE: {len(data)} rows")

One-shot: cg, lbfgsb, nelder_mead¤

Scipy optimizers expect the objective as f(x) with a single array argument, so they need a domain with an ArrayParameter named the same as input_name.

import numpy as np

scipy_domain = Domain()
scipy_domain.add_array(name="x", shape=(2,), low=-5.0, high=5.0)
scipy_domain.add_output("y")


@datagenerator(output_names="y")
def f_array(x: np.ndarray) -> float:
    return float(np.sum((x - np.array([1.5, -2.0])) ** 2))


scipy_data = ExperimentData(domain=scipy_domain)
scipy_data = create_sampler("random", seed=0).call(scipy_data, n_samples=1)
f_array.arm(scipy_data)
scipy_data = f_array.call(scipy_data)

scipy_opt = create_optimizer(
    "lbfgsb",
    data_generator=f_array,
    output_name="y",
    input_name="x",
    maxiter=50,
)
scipy_opt.arm(scipy_data)
scipy_data = scipy_opt.call(scipy_data)

print(f"After L-BFGS-B one-shot: {len(scipy_data)} rows")

Method 2: import the factory directly from f3dasm.optimization¤

The per-optimizer factories (tpesampler, cg, lbfgsb, nelder_mead) are also importable directly. This is equivalent to create_optimizer but gives you type-aware editor completion and makes the call explicit.

from f3dasm.optimization import tpesampler

data = initial_design()

update_step = tpesampler(output_name="y")
loop = (update_step >> f).loop(20)
loop.arm(data)
data = loop.call(data)

df_in, df_out = data.to_pandas()
best_idx = df_out["y"].idxmin()
print(
    f"Best: x0={df_in.loc[best_idx, 'x0']:.3f}, "
    f"x1={df_in.loc[best_idx, 'x1']:.3f}, "
    f"y={df_out.loc[best_idx, 'y']:.4f}"
)

Next: Built-in Samplers