Tutorial: Adding a Custom Solver#
This tutorial explains step by step how to define your own objective function and minimize it using ODAT-SE’s search algorithms.
As an example, we find the minimum of the following 2-variable function using the Nelder-Mead method.
The minimum is \(f(3, 2) = 0\).
Prerequisites#
ODAT-SE is installed (see Installation of ODAT-SE)
Basic knowledge of Python syntax (function definitions, class basics)
Overview#
Define a solver in a Python script
Create a TOML configuration file
Run and check the results
Step 1: Define a Solver#
Create a file called my_solver.py with the following content.
import sys
import numpy as np
import odatse
# --- Solver definition ---
class MySolver(odatse.solver.SolverBase):
"""A solver that computes a custom objective function"""
def __init__(self, info: odatse.Info):
super().__init__(info)
self._name = "my_solver"
# You can read parameters from the [solver] section of the TOML file
# Example: self.param = info.solver.get("my_param", 1.0)
def evaluate(self, x, args=()):
"""
Compute and return the objective function value.
Parameters
----------
x : np.ndarray
Search parameters (here a 2D vector [x, y])
args : tuple
(step number, set number) tuple. Can be used for logging.
"""
# Write your objective function here
fx = (x[0] - 3.0) ** 2 + (x[1] - 2.0) ** 2
return fx
# --- Main execution code ---
# Get the TOML file path from command line arguments
input_file = sys.argv[1]
info = odatse.Info.from_file(input_file)
# Partition the MPI communicator. This is required before constructing the
# solver/algorithm because they query the MPI layer (algrank(), etc.).
odatse.mpi.setup()
# Assemble: Solver -> Runner -> Algorithm
solver = MySolver(info)
runner = odatse.Runner(solver, info)
alg_module = odatse.algorithm.choose_algorithm(info.algorithm["name"])
algorithm = alg_module.Algorithm(info, runner)
# Run
result = algorithm.main()
print(f"Solution:\nx1 = {result['x'][0]}\nx2 = {result['x'][1]}")
Key points:
MySolverinherits fromodatse.solver.SolverBase__init__must callsuper().__init__(info). This automatically sets up output directoriesWrite the objective function computation in the
evaluatemethod. The argumentxis a numpy array and the return value is a floatodatse.mpi.setup()must be called before constructing the solver/algorithm when the pipeline is built by hand (odatse.initialize()does this for you)odatse.algorithm.choose_algorithmreturns the algorithm module for the name in the[algorithm]section of the TOML file; itsAlgorithmclass is then instantiated
Step 2: Create a TOML Configuration File#
Create a file called input.toml with the following content.
[base]
dimension = 2
output_dir = "output"
[solver]
name = "my_solver"
# You can add solver-specific parameters here
# my_param = 1.0
[algorithm]
name = "minsearch"
seed = 12345
[algorithm.param]
max_list = [6.0, 6.0]
min_list = [-6.0, -6.0]
initial_list = [0.0, 0.0]
What each section means:
[base]:dimension = 2specifies that there are 2 search parameters (x, y)[solver]: Solver settings.nameis for log output and can be any string[algorithm]: Search algorithm settings.minsearchis the Nelder-Mead method[algorithm.param]: Max/min search range and initial values
Step 3: Run and Check the Results#
Place my_solver.py and input.toml in the same directory and run:
$ python3 my_solver.py input.toml
When complete, output similar to the following is displayed:
Iterations: 42
Function evaluations: 81
Solution:
x1 = 2.9999999...
x2 = 1.9999999...
The parameters have converged to \((x, y) = (3, 2)\), confirming that the minimum was found.
Execution logs are also output under the output/ directory.
Advanced: Searching with Other Algorithms#
By changing only the [algorithm] section in the TOML file, you can search with a different algorithm without modifying the Python code.
Bayesian optimization example:
[algorithm]
name = "bayes"
seed = 12345
[algorithm.param]
max_list = [6.0, 6.0]
min_list = [-6.0, -6.0]
[algorithm.bayes]
random_max_num_probes = 10
score = "TS"
num_search_each_probe = 1
Grid search example:
[algorithm]
name = "mapper"
seed = 12345
[algorithm.param]
max_list = [6.0, 6.0]
min_list = [-6.0, -6.0]
num_list = [31, 31]
Advanced: Reading Solver Parameters from TOML#
To add parameters to the objective function, add values to the [solver] section
and read them in __init__.
[solver]
name = "my_solver"
center_x = 5.0
center_y = 3.0
class MySolver(odatse.solver.SolverBase):
def __init__(self, info: odatse.Info):
super().__init__(info)
self._name = "my_solver"
# Read values from the [solver] section of the TOML file
self.cx = info.solver.get("center_x", 0.0)
self.cy = info.solver.get("center_y", 0.0)
def evaluate(self, x, args=()):
return (x[0] - self.cx) ** 2 + (x[1] - self.cy) ** 2
This way, you can change parameters without modifying the code, simply by editing the TOML file.
Advanced: Using Solver Templates#
To organize your solver as an installable package, use the ODAT-SE solver templates. Four templates are provided for different use cases.
Template |
Use Case |
Features |
|---|---|---|
|
Quickly optimize a Python function |
Minimal script. Similar to Steps 1-3 above |
|
Package an analytical function solver |
|
|
Build a custom solver that reads data files |
Template for solvers with reference data comparison (likelihood, etc.) |
|
Use an external program (C/Fortran) as a solver |
Includes I/O file management, subprocess execution, working directory management |
Example usage (function_module):
Copy the template
$ cp -r odat-se-gallery/data/tutorial/solver-template/function_module my_solver_pkg $ cd my_solver_pkg
Change the package name in
pyproject.tomland add your solver undersrc/Solver/Install and run
$ python3 -m pip install .
Each template includes sample configuration files (sample/) and test scaffolding (tests/),
making it a good starting point for developing production-ready solver packages.
For details, see the docs/ directory within each template.