Cost¶
<easylocal/cost.hpp> (cost models and cost expressions, easylocal::cost)
and <easylocal/helpers/recipes.hpp> (component, delta)
The cost of a solution is computed by cost components, combined by a
cost expression into a value of a cost type. Costs are minimized: a
lower cost is better; to maximize, return what is lost or the negated profit,
or give a root cost::apply its own compare (Great Deluge, whose levels are
fractions of the cost, needs positive costs). Moves are evaluated
incrementally by delta cost components. Together with the SolutionManager they
form the cost layer; the delta cost components bound to a NeighborhoodExplorer
form the delta cost layer.
Cost components¶
| Member | Required |
|---|---|
evaluate(const Solution&) const -> Value |
yes |
delta_evaluate(const Solution&, const Move&) const -> Delta |
no: a co-located delta cost component |
name() -> std::string_view (static or not) |
no: its name in reports, otherwise its position, #1; static, and required, for a component with parameters, which is configured under it |
parameters_type |
no: a parameter block, which makes the component configurable (cost.<name>.*); the component is constructed from it |
describe(const Solution&) const -> std::string |
no: a text that explains its value on a solution, such as the violations it counts |
Valueis deduced fromevaluateand may be arithmetic or a domain type, but not an unsigned integer: the difference of two costs would wrap around, so a component that counts returnsintorlong long.- Construction:
Component(const Input&, args...)is preferred,Component(args...)is accepted;argscome fromcomponent<C>(args...)and convert as the constructor takes them. The same holds for a delta cost component anddelta<C, D>(args...). - Deriving from
easylocal::input_base<Input>gives the component that constructor andinput(), and deriving fromeasylocal::parameters_base<P>gives itparameters_type, the constructor from the block andparameters(); both are optional, and a component that needs neither derives from nothing. - With parameters:
Component(const Input&, const parameters_type&, args...)(or without the Input), fromcomponent<C>(parameters, args...); without a first argument of that type, the parameters are the defaults. The recipe holds them, and builds the component from them when a runner or an app is bound. - A component type may appear only once in a cost expression: the type is the
identity of the component. To use one parametric component twice (two
thresholds), derive a named type for each,
struct LongEdges8 : LongEdges<8> {};, or give each its own parameters as a class. name()anddescribe(solution)are for people:Session::cost_report(),cli::run's--reportand the TextUI's solution window show each component's value with them. Withoutdescribe, only the value is shown.
Cost expressions¶
A SolutionManager recipe has exactly one cost expression:
solution_manager<SM>() | expression, or .with_cost(expression). Its leaves
are cost components; its nodes combine the costs of their children.
| Expression | Cost | Configuration |
|---|---|---|
component<C>([parameters,] args...) |
the value of the component | its parameters_type's, under <name> |
cost::sum(t1, ..., tn) |
Σ wᵢ · costᵢ, in the common type of the costs and the weights |
weights |
cost::weighted(child, w), or child * w, w * child |
a term of cost::sum with weight w (default 1) |
|
cost::in_order(c1, ..., cn) |
cost::lexicographic of the children's costs |
children's |
cost::objectives(c1, ..., cn) |
cost::pareto of the children's costs (n ≥ 2) |
children's |
cost::hard_soft(hard, soft) |
cost::hierarchical of the two costs |
hard, soft |
cost::apply(f, c1, ..., cn) |
f(cost₁, ..., costₙ) |
children's |
cost::apply<F>(parameters, c1, ..., cn) |
F{parameters}(cost₁, ..., costₙ) |
F::parameters_type's, under <name>, and children's |
cost::approximately(c, tolerance) |
the cost of c, compared by the search within a cost::tolerance (at the root) |
tolerance.relative, tolerance.absolute, and the child's as they are |
solution_manager<SM>()
| cost::hard_soft(
cost::sum(component<A>(), component<B>() * 10),
cost::in_order(component<C>(), cost::apply(f, component<D>())))
cost::sumadds arithmetic costs only; a domain value is turned into a number withcost::apply. A weighted term,cost::weighted(child, w)or its shorthandchild * w, is allowed only directly inside acost::sum.- The components of the
hardbranch of acost::hard_softat the root come first: a pipeline stageuntil_feasible()(the first stage oftwo_stage()) evaluates only them. - At the root, the function of a
cost::applymay define the order of its costs,compare(a, b), andcost::approximatelycompares them within a tolerance (see Cost semantics). - Configuration: the expression is exposed under
cost. Asumhas itsweights(one per term, finite: a negative weight is accepted, NaN and infinity are not), ahard_softnames its childrenhardandsoft,in_order,objectivesandapplyname them by position (0,1, ...); only configurable children appear. In the example above:cost.hard.weights. - A component or a
cost::applyfunction whoseparameters_typeis a parameter block is configured under its staticname(), wherever it is in the expression:cost.<name>.*. Two of them with the same name make the configuration throwstd::invalid_argument. A function with parameters is built from them,cost::apply<F>(parameters, children...), and built again when they change;cost::apply(F{...}, children...)does not compile for it. A component or a function that hasparameters()orconfiguration()and no suchparameters_typedoes not compile.
Cost models¶
| Type | Ordering | cost::delta |
|---|---|---|
arithmetic (cost::arithmetic: signed integers and floating point) |
< |
candidate - current |
cost::lexicographic<Ts...> |
lexicographic over the values | none |
cost::pareto<Ts...> |
Pareto dominance (a partial order) | none |
cost::hierarchical<Hard, Soft> |
hard first, soft when hard is equivalent; each branch by its <=>, or else by < and == |
hard better: -∞, hard worse: +∞, else the soft delta |
The structured types are constructed directly, with deduced types:
cost::hierarchical{hard, soft}, cost::lexicographic{a, b},
cost::pareto{a, b}. The concepts cost::lexicographic_cost,
cost::hierarchical_cost and cost::pareto_cost recognize them.
Pareto costs¶
A cost::pareto holds one value per objective, all minimized, and orders them
by Pareto dominance, which leaves some costs unordered. With two objectives,
makespan and tardiness of a schedule for example:
solution_manager<ScheduleManager>()
| cost::objectives(component<Makespan>(), component<Tardiness>())
each solution is a point of the plane:
The cost semantics read as follows, for costs a and b:
better(a, b),a < b:adominatesb, no worse in every objective and better in at least one. B is better than E and G, which lie in the region it dominates; C is better than E.better_or_equivalent(a, b),a <= b:aweakly dominatesb, no worse in every objective, equal costs included.equivalent(a, b),a == b: equal in every objective.- Neither
better(a, b)norbetter(b, a): the costs are unordered,a <=> bisstd::partial_ordering::unordered. A and B are, as are any two points of the front: each is better in one objective.
So a descent (First and Best Improvement, Hill Climbing) moves from E to B or
C, never from B to A, and from a point of the front only to a point that
dominates it. The Pareto front is the set of the points that no other
dominates, and a search with a pareto cost keeps the non-dominated solutions it
reaches in its archive and returns them as its front (see
Runners). Pareto Late Acceptance Hill Climbing explores
from several solutions at once to spread over the front. An app run by name
carries the front in named_run_result.front, the Session keeps it
(last_run_front()), cli::run prints it and REST returns it with the
solution (see Apps and tools); the TextUI shows only the
current solution.
The runners whose acceptance only compares costs accept a pareto cost: First
and Best Improvement, Hill Climbing, Late Acceptance, Pareto Late Acceptance
and the tabu searches, except Aspiration Plus and Elite Candidate, whose
levels are factors of the cost, with any tabu list but Foo and RandomFoo,
which need a delta. Late Acceptance and Tabu Search may move to a point that
does not dominate the current one: Late Acceptance accepts a candidate no worse
than an older cost, and Tabu Search applies the best admissible move even when
it worsens, choosing among equivalent candidates only, so the first of two
unordered ones stays. The runners that need a numeric delta (Simulated
Annealing, Great Deluge) do not accept a pareto cost.
cost::delta(candidate, current) is the numeric difference used by
delta-based acceptance; user cost types provide it as a free function found by
ADL (call it as using cost::delta; delta(a, b)). The concept
cost::has_delta<Cost> tells whether it exists.
The difference of two arithmetic costs is computed in a type that represents
it, which the cost type itself need not: the delta of an integral cost narrower
than 64 bits is an std::int64_t, exact for every pair of costs; that of a
64-bit one a double, which rounds differences above 2^53; and a
floating-point cost keeps its own type. A user cost type's own delta is
responsible for the same: the difference of two costs that their type holds may
not fit in it.
cost::zero<Cost>() is the cost of no violation and no penalty: Cost{} for
value-initializable types (0 for arithmetic costs) and the zero of every level
for lexicographic, hierarchical and pareto costs. Other cost types provide it by
specializing cost::zero_cost<Cost> with a static value();
cost::has_zero<Cost> tells whether it exists. A pipeline stage
until_feasible() stops at the zero of the hard cost, also when deltas leave a
floating-point hard cost a rounding error above it: the run then evaluates the
solution in full (see Runners).
Comparisons within a tolerance¶
<easylocal/cost/tolerance.hpp> compares costs forgiving the rounding errors
of floating-point arithmetic. cost::tolerance{.relative, .absolute} (both
1e-9 by default) is the tolerance: two floating-point values are equal within
it when they differ by at most max(absolute, relative * max(|a|, |b|)).
| Function | Result |
|---|---|
cost::approximately_equal(a, b, tolerance), or tolerance(a, b) |
numbers within the tolerance when either is floating point, integers exactly; lexicographic, hierarchical and pareto costs level by level; other values with == |
cost::approximate_compare(a, b, tolerance) |
a std::partial_ordering: equivalent within the tolerance, otherwise by <=>; level by level, as the cost model orders, for structured costs |
The checks of easylocal::testing, check(app) and the Session use it by
default (testing::approximately is the same type). It is not transitive.
Costs as text¶
cost::from_text<Cost>(text) (<easylocal/cost/text.hpp>) reads a cost
written by a user, such as a target: a number for an arithmetic cost,
[hard, soft] for a cost::hierarchical, [v1, v2, ...] for a
cost::lexicographic or a cost::pareto, nested as the types are ([0, [3, 1.5]]). It throws
std::invalid_argument with the reason; cost::to_text(cost) writes a cost
back in the same form. cost::text_readable<Cost> tells
which costs it reads; a problem with another cost type, or its own notation,
provides read_cost(const Input&, std::string_view) -> Cost, found by ADL
through its Input, which the Session and the TextUI use instead.
Cost semantics¶
cost::better(sm, a, b), cost::equivalent(sm, a, b) and
cost::better_or_equivalent(sm, a, b) are the relations algorithms compare
costs with, through run.better(...). By default they are <, == and <=
of the cost type, independent queries, so a cost type may answer <= in one
pass.
The root of the expression may define them all with one hook: the function of
a root cost::apply defines compare(a, b), returning a
std::partial_ordering. Then better(a, b) is compare(a, b) < 0,
equivalent(a, b) is compare(a, b) == 0 and better_or_equivalent(a, b) is
compare(a, b) <= 0; an unordered result is none of them, as for two points
of a Pareto front. A function that maximizes:
struct Profit
{
int operator()(int value) const { return value; }
std::partial_ordering compare(int lhs, int rhs) const { return rhs <=> lhs; }
};
solution_manager<SM>() | cost::apply(Profit{}, component<Gain>())
The rules, checked when the recipe is built:
compareis the only hook: a function that definesbetter,equivalentorbetter_or_equivalentdoes not compile, since one relation of its own next to the defaults of the others would order the costs two ways.- Only the root defines the semantics: a
cost::applywith acomparebelow another node does not compile. - A root
cost::applyhides the structure below it: with acost::hard_softunder it, a pipeline stageuntil_feasible()evaluates every component, and its hard stage compares the hard costs with their own operators.
cost::approximately(child, {.relative, .absolute}) is the root that compares
floating-point costs within a tolerance (both 1e-9 by default): costs equal
within it are equivalent, and a cost is better only by more than it, with
cost::approximate_compare (see Comparisons within a tolerance). A descent
then stops at a move that improves only by rounding errors, and a target is
reached by a cost within the tolerance of it. It keeps the structure below it:
over a cost::hard_soft, until_feasible() still evaluates only the hard
components, and its hard stage compares within the same tolerance. The
tolerance is configurable, cost.tolerance.relative and
cost.tolerance.absolute in an app.
solution_manager<SM>()
| cost::approximately(
cost::hard_soft(component<Overlaps>(), component<Length>()),
{.relative = 1e-9, .absolute = 1e-6})
Without it the search compares exactly, and only the checks forgive rounding errors (see Testing).
- The algorithms that read
cost::delta(Simulated Annealing, Great Deluge, the aspiration levels of Tabu Search) assume that a negative delta is an improvement: the sign ofcost::delta(a, b)must agree withbetter(a, b). Acomparethat finds a larger cost better breaks them; maximize withcost::applyreturning the negated value instead. Simulated Annealing and Great Deluge reject such acomparewhen the compiler can evaluate it (aconstexprmember of a default-constructible function);check(app)verifies the sign on the moves it tries.
Delta cost components¶
| Member | Required |
|---|---|
delta_evaluate(const Solution&, const Move&) const -> Delta |
yes |
- Law:
Value + Delta -> Value, equal to the value of the component after the move. Check it withtesting::check_delta_cost_component. - Bound per component and per neighborhood:
delta<C, D>(args...)for a separate delta cost component,delta<C>()for one co-located in the component. - Components without a delta for a neighborhood are re-evaluated on a candidate solution; with full coverage no candidate is built.
Design choices¶
- Cost comes only from components. One evaluation path for every problem; the SolutionManager stays about solutions.
- The structure of the cost is written in the recipe. Which components
are hard and which soft, their order and their weights are read where the
components are listed, not deduced from a declaration order and a separate
list of weights.
until_feasible()reads the hard components from the expression. - Weights sit beside their term.
component<C>() * wcannot drift out of line with the components, as positional weights can. The*only marks a term of acost::sum: it never multiplies component values, so domain types need nooperator*. cost::sumadds numbers. Domain values are mapped explicitly withcost::apply, so domain types never carry arithmetic operators just to be summed.- Semantics belong to the cost layer. The root of the expression may redefine ordering and equivalence (tolerances, a maximized objective) without touching the cost type.
- One hook for the order.
comparedefines the three relations together, so they cannot disagree, and a partial order (unordered) fits as Pareto dominance does. - Deltas never see weights or structure. They are per component; the move cost is always recomputed by the expression, so a delta stays valid whatever the expression is.
deltais the contract,operator-is syntax. Acceptance criteria rely oncost::delta;cost::lexicographichas none on purpose.