Decomposition, as evidence¶
This page shows that the language can express a Benders decomposition and reach the right answer, for anyone decomposing a model in specsolve or asking for a driver that does it.
specsolve ships no decomposition driver, and
#596 settled that it will not
own one. The loop is the caller's, and so are its failure modes. Every block
below is validated against
examples/benders/.
Why anyone wants it¶
A model too large to solve is usually made smaller: representative days instead of a year, forty nodes instead of three hundred, one weather year instead of many. Each answers a different question, and none bounds how wrong it is for the one you asked. Decomposition answers the question you asked, with a gap: stop at 1% and you are within 1%.
The problem, whole¶
Choose generator capacity, then dispatch it, with investment and operation decided together. The capacity choice is small and the dispatch is large, which is what makes it worth decomposing.
dimensions:
snapshot: {dtype: int}
generator: {dtype: str}
parameters:
invest: {dims: [generator]}
cost: {dims: [generator]}
load: {dims: [snapshot]}
avail: {dims: [snapshot, generator]}
variables:
cap:
dims: [generator]
bounds: {lower: 0, upper: 100}
p:
dims: [snapshot, generator]
bounds: {lower: 0}
constraints:
capacity:
dims: [snapshot, generator]
expression: p <= cap * avail
balance:
dims: [snapshot]
expression: sum(p, over=generator) >= load
objective:
sense: minimize
expression: sum(cap * invest) + sum(p * cost)
The split is one substitution¶
The subproblem is the same dispatch at a capacity someone else chose: cap
stops being a variable and becomes a parameter.
dimensions:
snapshot: {dtype: int}
generator: {dtype: str}
parameters:
cost: {dims: [generator]}
load: {dims: [snapshot]}
avail: {dims: [snapshot, generator]}
cap_hat: {dims: [generator]} # was `cap`, a variable
variables:
p:
dims: [snapshot, generator]
bounds: {lower: 0}
constraints:
capacity:
dims: [snapshot, generator]
expression: p <= cap_hat * avail
balance:
dims: [snapshot]
expression: sum(p, over=generator) >= load
objective:
sense: minimize
expression: sum(p * cost)
Three things are not here: no invest, no cap bounds, no investment term.
The subproblem does not know it is part of anything.
The master, where a cut is data¶
The master keeps the capacity decision. It stands in theta for the dispatch
it can no longer see: one variable holding what operating that capacity will
cost. Cuts teach the master what theta is:
dimensions:
generator: {dtype: str}
cut: {dtype: int}
fcut: {dtype: int}
parameters:
invest: {dims: [generator]}
cut_const: {dims: [cut]}
cut_slope: {dims: [cut, generator]}
fcut_const: {dims: [fcut]}
fcut_slope: {dims: [fcut, generator]}
variables:
cap:
dims: [generator]
bounds: {lower: 0, upper: 100}
theta:
dims: []
bounds: {lower: 0}
constraints:
optimality_cut:
dims: [cut]
expression: theta >= cut_const + sum(cut_slope * cap, over=generator)
feasibility_cut:
dims: [fcut]
expression: sum(fcut_slope * cap, over=generator) <= fcut_const
objective:
sense: minimize
expression: sum(cap * invest) + theta
cut and fcut take their members from data
(the data contract). An iteration appends rows to their
parameter tables and generates no YAML, so the spec a reviewer reads is the
spec that runs.
theta is a scalar variable, dims: [], and its lower: 0 is the only
thing keeping the first master bounded before any cut exists.
Reading a cut out of an answer¶
A cut is the value and the slope of the subproblem at the capacity that was
tried. The slope is the shadow price of the capacity constraint, weighted by
availability and summed over snapshots. sources is the data attached to
the spec (glossary):
import specsolve as sps
import polars as pl
with sps.solve('examples/benders/sub.yaml', sources) as sub:
slope = (
sub.dual('capacity')
.join(avail, on=['snapshot', 'generator'], suffix='_avail')
.with_columns((pl.col('value') * pl.col('value_avail')).alias('term'))
.group_by('generator')
.agg(pl.col('term').sum().alias('slope'))
)
The whole interface with the engine is
dual and a join against the model's own avail table. Appending the cut is
two pl.concat calls onto the parameter tables the master already declares.
When the subproblem is infeasible¶
Below some capacity there is no dispatch at all, and the subproblem is
infeasible. There is nothing to price: dual() raises rather than returning a
vector of zeros that looks like an answer, and correctly so.
What such a solve does have is a certificate that no dispatch exists, and
dual_ray is it. It weights the subproblem's rows so that together they
demand more than the generators can deliver:
with sps.build(sub_spec, dispatch) as sub_model:
answer = sub_model.update({'cap_hat': capacity}).solve()
if not answer.has_primal:
u = answer.dual_ray('capacity') # one weight per (snapshot, generator)
v = answer.dual_ray('balance') # one weight per snapshot
Weighting each row by its own weight and adding them gives
Σ u·cap_hat·avail + Σ v·load > 0, and that is the proof. Every term in it is
linear in capacity, so asking the master for a capacity where the same
combination is not positive is exactly one row — slope Σₛ u·avail per
generator, against −Σₛ v·load, which is the fcut_slope and fcut_const
the master already declares.
Three properties make this the cut to use rather than a fallback:
- It needs no second model. Until specsolve read a ray, this page carried a fourth YAML file — the subproblem with a slack variable and an objective asking how far from dispatchable a capacity was — and a second solve for every capacity that failed.
- It is a stronger cut. The example converges in 2 steps with one feasibility cut where the elastic model took 4 and three of them.
- The sign is the row's own, one convention across every sink, so the arithmetic above does not ask which solver ran.
A sink computes a certificate only if it was asked to: HiGHS always does,
Gurobi needs solver_options={'InfUnbdInfo': 1} and Xpress
solver_options={'presolve': 0}. Reading a ray without them raises, and the
message names the option.
The loop¶
sub_spec, master_spec = (to_spec(path) for path in paths)
with (
sps.build(sub_spec, {**dispatch, 'cap_hat': capacity}) as sub_model,
sps.build(master_spec, {**master_sources, **empty}) as master,
):
for step in range(25):
sub = sub_model.update({'cap_hat': capacity}).solve()
dispatchable = sub.has_primal
if dispatchable:
slope, here_value = slope_at(sub, capacity)
upper = min(upper, spent(capacity) + sub.objective)
appended(tables, 'cut', sub.objective - here_value, slope)
else:
slope, against = cut_from_ray(sub)
appended(tables, 'fcut', against, slope)
answer = master.update({**tables, **coordinates}).solve()
lower = answer.objective
capacity = answer.primal('cap').select('generator', 'value')
if upper < float('inf') and upper - lower <= 1e-6 * abs(upper):
break
Twenty lines, two built models and a growing pair of tables. A reader could write this, which is what #596 settled on.
Each spec is read once above the loop and built once, because a cut is a row
in a parameter table rather than an edit to a file. sps.build attaches the data
and update puts the next iteration's numbers on the model that is already
there (glossary), so parsing, validation
and the build are paid once per run rather than three times an iteration. The
subproblem's cap_hat reaches its rows as a right-hand side, so the solver keeps
the model it holds and re-solves from the last basis. The master gains a row a
step, so it is loaded again — which is what the last two lines below count.
Both cut families now come out of the same subproblem, its prices for one and its ray for the other, which is why there are two models here and not three.
Running it¶
the whole problem, in one plan: 9600.00
step 0 feasibility lower 2850.00 upper none yet
step 1 optimality lower 9600.00 upper 9600.00
decomposed: 9600.00 in 2 steps
monolithic: 9600.00
difference: 0.0e+00
cuts: 1 optimality, 1 feasibility
the subproblem loaded the solver 1 time(s) in 2 solves
the master loaded the solver 2 time(s) in 2 solves
One capacity is excluded as undispatchable, and the first optimality cut then closes the gap exactly. The elastic model this page used to carry took four steps over the same data, which is the difference between a cut that says how far from dispatchable a capacity was and one that says why no dispatch exists.
The check is the algorithm's own¶
specsolve can always build the monolith from the same sources, so the example
solves both and prints the difference: 0.0e+00 above, asserted in
tests/test_benders_example.py. That is the two-lane differential test aimed
at an algorithm instead of an engine. It is always available because the
undecomposed form is another file over the same data.
What is deliberately absent¶
Missing is everything that makes a decomposition survive a real model: cut management as the master grows, stabilisation, multi-cut, tolerances that hold when duals are degenerate, and an answer for when convergence does not happen. #596 asked whether specsolve should own that surface and answered no, so all of it stays the caller's. What a caller still lacks from specsolve is collected in #1677. This page settles only that the language is not the obstacle.