Nested Optimization
Some problems split naturally into two layers: a few variables that are awkward for one method — integers, say, or switches — and the rest, which a gradient method handles well. Nested optimization solves them in two loops. An outer run varies the awkward variables, and every outer evaluation runs a complete inner optimization over the remaining ones, returning the best value it reached.
This page also covers four things that are not nesting
Nesting itself is a niche, but this page puts four things that are not into one program small enough to read end to end: choosing a pool per layer, and why one of them has to stay on threads; collecting results from runs that overlap in time, through a shared group; reusing an evaluation the optimizer has already made; and moving the expensive layer to a cluster by changing a single line.
Nothing in ropt is dedicated to this: the outer evaluation function
calls optimize itself. What needs care is the plumbing
around it — which
variables each layer owns, which pool each layer evaluates on, and how to get
the results out.
flowchart TB
subgraph proc["your process"]
direction LR
outer["outer optimize<br/>(integer variables)"]
subgraph tpool["outer thread_pool — 2 threads"]
direction TB
in1["outer eval →<br/>inner optimize"]
in2["outer eval →<br/>inner optimize"]
end
grp["shared group →<br/>DataFrameHandler"]
outer --> in1
outer --> in2
in1 -.->|"results"| grp
in2 -.->|"results"| grp
end
subgraph ppool["inner process_pool — 2 workers"]
direction TB
w1["worker"]
w2["worker"]
end
in1 ==>|"batch"| w1
in2 ==>|"batch"| w2
Solid arrows start a run, thick arrows carry evaluations out to a worker process, and dotted arrows carry results back to the handler. Both pools are drawn: the outer one is inside your process, the inner one is not. The sections below work through each part.
The runnable script for this page is
examples/simple/nested_optimization.py.
It needs the polars extra.
Splitting the variables
Both layers describe the same variable vector; each is handed the half it may
change. The mask field does this, and the two masks are complements:
DIM = 4
REALIZATIONS = 5
MASK = [True, True, False, False]
INNER_CONFIG: dict[str, Any] = {
"variables": {
"variable_count": DIM,
"perturbation_magnitudes": 1e-6,
"mask": MASK,
"lower_bounds": 0.0,
"upper_bounds": 10.0,
},
"realizations": {"weights": [1.0] * REALIZATIONS},
"optimizer": {"max_functions": 8},
}
OUTER_CONFIG: dict[str, Any] = {
"variables": {
"variable_count": DIM,
"mask": np.logical_not(MASK),
"lower_bounds": 0.0,
"upper_bounds": 10.0,
"types": VariableType.INTEGER,
},
"realizations": {"weights": [1.0]},
"backend": {
"method": "differential_evolution",
"options": {"rng": 4},
"parallel": True,
"max_iterations": 4,
},
}
A masked-out variable keeps the value it was given and is not passed to the
optimizer, so the inner run treats the outer variables as constants and the
outer run never touches the inner ones. The two layers can otherwise be
configured completely differently — here the outer is integer-valued and
gradient-free, while the inner is continuous and uses an ensemble of five
realizations. See variables
for the field, and
Discrete and Mixed-Integer Variables for the
outer method.
The outer evaluation function
An outer evaluation is an inner optimization. The function receives the outer
variables, merges them into a full vector, runs optimize, and returns the best
objective it found:
key = (context.realization, *variables.tolist())
if key in memo:
return memo[key]
result = optimize(
INNER_CONFIG,
np.where(MASK, INITIAL_VALUES, variables),
function,
pool=pool,
handlers=[group],
# Within a batch only (batch_id, eval_idx) is unique: several rows share
# a realization, so realization alone would not identify the caller.
metadata={"outer_batch": context.batch_id, "outer_eval": context.eval_idx},
)
assert result.results is not None
assert result.results.target_objective is not None
memo[key] = float(result.results.target_objective)
return memo[key]
np.where(MASK, INITIAL_VALUES, variables) builds the inner start point: the
inner variables begin where they always do, and the outer ones carry the values
being tried. The metadata tags every inner result with the outer evaluation
that caused it, which is what makes the collected results traceable. The memo
lookup above it is covered in
Reusing an outer evaluation.
Two pools, not one
Each layer evaluates on its own pool, and that is a requirement rather than a preference:
# A plain dict reaches the outer evaluations because they run on threads, in
# this process; on a process pool each worker would get an empty copy.
memo: dict[tuple[float, ...], float] = {}
with session() as active:
inner_pool = active.process_pool(workers=2, bundle_size=0)
outer_pool = active.thread_pool(workers=2)
group = active.shared_handlers(tables)
optimize(
OUTER_CONFIG,
INITIAL_VALUES,
partial(
inner_optimization,
pool=inner_pool,
group=group,
function=partial(rosenbrock, a=a, b=b),
memo=memo,
),
pool=outer_pool,
)
The outer pool is a thread pool. Outer evaluations therefore stay inside
this process, where the inner pool and the shared handler group are live
objects; on a process pool they would arrive as copies, which ropt refuses.
The inner
pool is a process pool, which is where the real work goes.
That is also where a cluster belongs. Swapping the inner pool for an
hpc_pool is the whole change — the inner
evaluations become cluster jobs, and the layer above is untouched:
Sending whole optimizations to the cluster, by putting the outer pool on processes or HPC, is possible but gives something different. Each outer evaluation is then copied into a job, and neither the inner pool nor the shared group can travel with it: the job has to open its own session and collect its own results, and return them as data for you to combine. Use that when the outer evaluations are genuinely independent; use the shape above when you want one pool and one table across all of them.
The inner pool takes bundle_size=0, which sends a whole inner batch to one
worker as a single task. Within one inner run that gives up parallelism
completely — its five realizations are evaluated one after another — but the
parallelism here comes from the layer above: two outer evaluations run at once,
each feeding the inner pool one task, so both workers are busy and each batch
costs one transfer instead of five. See
How a batch is split across workers.
Handing the inner run the pool it is already running on would deadlock, and
ropt refuses it rather than hanging — see
Pools inside an evaluation for why.
Reusing an outer evaluation
The outer variables are integers and the outer method is population-based, so
the optimizer proposes points it has already evaluated. Each repeat would run a
complete inner optimization for a value that is already known. The two lines at
the top of the evaluation function return the stored value instead. The memo is
an ordinary dictionary, created beside the pools and passed to the function
through partial.
The key is exact. Integer variables arrive as whole-numbered doubles and are re-proposed unchanged, so a repeated vector is identical bit for bit. The realization index is part of the key because a memo keyed on the variables alone gives the wrong value for a problem with more than one realization.
The dictionary is reachable only because the outer evaluations run on threads,
in this process. On a process_pool or an hpc_pool the function is copied
into a worker, which starts from an empty dictionary and discards it when it
finishes, without raising. See
the rule to settle first.
Two outer evaluations run at the same time, so both can find the same key missing and both compute it. The stored value is the same either way, so the cost is one repeated evaluation. A lock would close that window and serialize the outer layer with it.
A repeat starts no inner run, so the table described next holds rows for the outer evaluations that were computed, not for every outer evaluation.
Collecting results from runs that overlap
The inner runs are concurrent, so a plain handler cannot collect them: a handler
is claimed by one run at a time, and the second run to claim it is refused. A
shared group can,
because it routes every run's results through one dispatcher. Feed it a
DataFrameHandler and every inner evaluation
from every inner run lands in one table, keyed by the outer evaluation it
belongs to.
Reading the answer takes one more step than usual:
best = frame.sort("Objective").row(0, named=True)
variables = [best[f"Variable,{idx}"] for idx in range(DIM)]
The outer result is not the place to look. The outer layer only ever sees its own variables and holds the inner ones at their initial values, so the optimum over all variables exists only in the collected inner results.
See also
- The same flow built from workflow components: Parallel Evaluation.