Warm starts, ensembles and reproducibility¶
The solvers of scikit-route compose. A construction heuristic can seed a local search,
whose local optimum can seed a metaheuristic; one RoutingProblem
can be handed to several solvers; a stochastic solver can be restarted from many seeds in
parallel; and every result can be reproduced from random_state. This page shows how,
and closes with what you need to write a solver of your own that passes the library's
own test battery.
The examples use Western Sahara (wi29, optimum 27603) and Djibouti (dj38, optimum
6656) from skroute.datasets:
>>> import numpy as np
>>> from skroute.datasets import load_tsp
>>> wi, dj = load_tsp("wi29"), load_tsp("dj38")
>>> C_wi, C_dj = wi.distance_matrix(), dj.distance_matrix()
>>> wi.optimal_tour_length, dj.optimal_tour_length
(27603, 6656)
Warm starts with init=¶
Every solver that starts from a tour has an init parameter (the deterministic descents,
IteratedLocalSearch, SimulatedAnnealing, TabuSearch, Genetic, the Ensemble*
wrappers). It accepts three things:
init= |
Meaning | Available on |
|---|---|---|
"nearest_neighbour" (default) |
the greedy walk from the depot, built in the compiled core | every solver with init |
"random" |
the depot followed by a random permutation drawn from random_state |
stochastic solvers only; TwoOpt, OrOpt and LocalSearch refuse it at validation |
| an array of labels | the tour_ (open) or route_ (closed, or multi-trip with the depot repeated) of another solver, or any sequence you built |
every solver with init |
The array form is what makes chaining possible: the output of one solver is a valid
input of the next. Labels are matched against the labels of X, so a tour produced
with labels=wi.labels must be fed to a fit that also receives labels=wi.labels.
>>> from skroute import NearestNeighbour, LocalSearch, IteratedLocalSearch, SimulatedAnnealing
>>> nn = NearestNeighbour().fit(C_wi, labels=wi.labels)
>>> ls = LocalSearch(init=nn.tour_).fit(C_wi, labels=wi.labels) # descend from the greedy tour
>>> ils = IteratedLocalSearch(init=ls.tour_, random_state=0).fit(C_wi, labels=wi.labels)
>>> sa = SimulatedAnnealing(init=ils.tour_, random_state=0).fit(C_wi, labels=wi.labels)
>>> nn.cost_ >= ls.cost_ >= ils.cost_ >= sa.cost_ # a descent never loses ground
True
>>> ils.cost_ <= 1.03 * wi.optimal_tour_length
True
>>> float(sa.history_[0]) <= ils.cost_ # the annealer's best-so-far starts at the tour it was given
True
Two facts worth knowing about the chain above:
LocalSearch(init=nn.tour_)is exactlyLocalSearch(): the defaultinitis the nearest-neighbour tour. Chaining pays off when the seed is better than that — aClarkeWrighttour for a multi-trip instance, or thetour_of a long run you want to polish with different moves.- A tour that is already a local optimum for the moves of a descent converges in one iteration, because the descent's first full sweep finds nothing to change:
>>> polished = LocalSearch(init=ils.tour_).fit(C_wi, labels=wi.labels)
>>> polished.n_iter_, polished.stop_reason_, polished.cost_ == ils.cost_
(1, 'converged', True)
A route_ is accepted as well as a tour_, depot repeats included, which lets you hand
the trips of one multi-trip solver to another. The Alicante–Murcia table (8 places, costs
in EUR, times in hours) with a budget of 1.5 times the longest round trip:
>>> from skroute import TabuSearch
>>> from skroute.datasets import load_alicante_murcia
>>> d = load_alicante_murcia()
>>> budget = 1.5 * float((d.time[0] + d.time[:, 0]).max())
>>> kw = dict(time_matrix=d.time, labels=d.labels, depot=d.depot, max_time_work=budget, extra_cost=10.0, people=2)
>>> sa = SimulatedAnnealing(random_state=0).fit(d.cost, **kw)
>>> ts = TabuSearch(init=sa.route_, random_state=0).fit(d.cost, **kw) # route_: depot, trip, depot, trip, depot
>>> sa.n_trips_ >= 2 and ts.cost_ <= sa.cost_ and bool(np.all(ts.trip_times_ <= budget))
True
What init refuses
The array must contain every label of X exactly once (the depot may repeat).
Anything else — a tour of another instance, positions instead of labels, a missing
node — raises ValueError: init tour must contain every label exactly once (the depot
may repeat), or ... is not a label of X when a value is not a label at all.
>>> IteratedLocalSearch(init=wi.labels[:-1], random_state=0).fit(C_wi, labels=wi.labels)
Traceback (most recent call last):
...
ValueError: init tour must contain every label exactly once (the depot may repeat)
>>> from skroute import TwoOpt
>>> TwoOpt(init="random").fit(C_wi, labels=wi.labels) # deterministic: no random draws
Traceback (most recent call last):
...
ValueError: The 'init' parameter of TwoOpt must be a str among {'nearest_neighbour'} or an array-like. Got 'random' instead.
Reusing a RoutingProblem¶
fit builds a RoutingProblem from its arguments — the
coerced matrices, the labels, the depot index, the budget — and stores it as problem_.
You can build it once yourself and pass it to any number of solvers: it is immutable,
shareable across threads, and it caches the nearest-neighbour candidate lists that the
local searches, the tabu search and the ant colony all need, so they are computed once.
When X is a RoutingProblem it must travel alone: any other fit argument raises.
>>> from skroute import RoutingProblem, Genetic, AntColony
>>> problem = RoutingProblem(C_dj, labels=dj.labels)
>>> problem
RoutingProblem(n=38, TSP, symmetric, depot=1)
>>> fitted = {type(est).__name__: est.fit(problem)
... for est in (LocalSearch(), IteratedLocalSearch(random_state=0), SimulatedAnnealing(random_state=0),
... TabuSearch(random_state=0), Genetic(random_state=0), AntColony(random_state=0))}
>>> all(est.problem_ is problem for est in fitted.values())
True
>>> LocalSearch().fit(problem, labels=dj.labels)
Traceback (most recent call last):
...
ValueError: X is a RoutingProblem: pass it alone, without other fit arguments
The same object prices any tour under the problem's objective — evaluate works in index
space, route_cost in label space — which is how the base
class recomputes cost_ from the tour a solver returns:
>>> from skroute.metrics import route_cost
>>> ils = fitted["IteratedLocalSearch"]
>>> float(problem.evaluate(problem.to_index_tour(ils.tour_))) == ils.cost_ == route_cost(C_dj, ils.route_, labels=dj.labels)
True
Reading history_ to compare solvers¶
Every iterative solver records history_, the best-so-far cost after each outer
iteration (an iteration is a kick for IteratedLocalSearch, a temperature level for
SimulatedAnnealing, a generation for Genetic, an epoch for SOM, one pass of each
move for the descents). By contract it is non-increasing, n_iter_ == len(history_),
history_[-1] == cost_ and stop_reason_ says why the run ended:
| Solver | stop_reason_ it can emit |
|---|---|
TwoOpt, OrOpt, LocalSearch, SOM |
"converged", "max_iter" |
IteratedLocalSearch, TabuSearch, Genetic, AntColony, EnsembleGenetic |
"max_iter", "patience", "time_limit" |
SimulatedAnnealing, EnsembleSimulatedAnnealing |
"converged", "patience", "time_limit" |
MultiStart |
whatever its best restart emitted |
Because the histories of different solvers have different lengths and different meanings per step, compare them on the facts they share — the final gap and the shape of the curve — not on iteration counts:
>>> tolerance = {"LocalSearch": 0.12, "IteratedLocalSearch": 0.03, "SimulatedAnnealing": 0.03,
... "TabuSearch": 0.08, "Genetic": 0.15, "AntColony": 0.08} # tests/tolerances.py, fast tier
>>> print(f"{'solver':22s} {'within tol':>10s} {'monotone':>9s} {'last==cost_':>12s}")
solver within tol monotone last==cost_
>>> for name, est in fitted.items():
... within = est.cost_ <= (1 + tolerance[name]) * dj.optimal_tour_length
... monotone = bool(np.all(np.diff(est.history_) <= 0))
... print(f"{name:22s} {within!s:>10s} {monotone!s:>9s} {(est.history_[-1] == est.cost_)!s:>12s}")
LocalSearch True True True
IteratedLocalSearch True True True
SimulatedAnnealing True True True
TabuSearch True True True
Genetic True True True
AntColony True True True
>>> all(est.n_iter_ == len(est.history_) for est in fitted.values())
True
history_[0] is a useful diagnostic too: for IteratedLocalSearch it is the best cost
after the first kick (the initial descent is not counted), for SimulatedAnnealing the
best cost after the first temperature level (the initial tour's, unless a downhill move
was already accepted), for Genetic the best individual seen up to the first generation,
initial population included.
MultiStart¶
MultiStart runs a stochastic solver n_restarts times from
independent seeds and keeps the best tour. Each restart is a clone of
estimator whose random_state is a numpy.random.Generator seeded from a child of one
numpy.random.SeedSequence; the restarts are fitted on the shared
RoutingProblem through joblib.Parallel, and the winner is the restart with the lowest
cost_ (the lowest index on a tie).
| Parameter | Default | Role |
|---|---|---|
estimator |
— | an unfitted stochastic solver; a deterministic one is refused (ValueError) |
n_restarts |
10 | number of independent restarts |
n_jobs |
None |
joblib workers: None runs them one after another, -1 uses every CPU, a positive int that many. Never changes the result |
prefer |
"threads" |
joblib backend hint; "processes" or None |
random_state, verbose |
as everywhere |
After the fit: estimators_ (the fitted clones in restart order), costs_ (their
cost_ values), best_index_, best_estimator_ (whose tour_ is the tour returned),
and — when the estimator is iterative — history_, n_iter_ and stop_reason_ copied
from the winner.
>>> from skroute import MultiStart
>>> ms = MultiStart(SimulatedAnnealing(), n_restarts=4, random_state=0).fit(C_wi, labels=wi.labels)
>>> ms.cost_ <= 1.03 * wi.optimal_tour_length, len(ms.estimators_), ms.costs_.shape
(True, 4, (4,))
>>> ms.cost_ == float(ms.costs_.min()) and ms.best_estimator_ is ms.estimators_[ms.best_index_]
True
>>> ms.stop_reason_ == ms.best_estimator_.stop_reason_ == "converged"
True
Why threads. The solver kernels release the GIL, so threads run the restarts in
parallel without pickling anything — a 10 000-node cost matrix is 800 MB, and processes
would copy it once per worker. Threads give a near-linear speed-up for
SimulatedAnnealing, IteratedLocalSearch and TabuSearch, whose time is spent in
compiled code, and little for the Python-heavy Genetic and SOM; for those,
prefer="processes" is one keyword away and gives identical results.
The result never depends on n_jobs. The seeds are assigned by restart index before
anything runs, so the same random_state gives the same winner and the same tour with
one worker, four, or a different backend:
>>> serial = MultiStart(SimulatedAnnealing(), n_restarts=4, n_jobs=1, random_state=0).fit(C_wi, labels=wi.labels)
>>> threaded = MultiStart(SimulatedAnnealing(), n_restarts=4, n_jobs=2, random_state=0).fit(C_wi, labels=wi.labels)
>>> bool(np.array_equal(serial.tour_, threaded.tour_)) and serial.costs_.tolist() == threaded.costs_.tolist()
True
The wrapped solver's knobs are reachable through the estimator__ prefix, as in
scikit-learn pipelines, and the tags of a MultiStart are its estimator's: wrapping a
budget-aware solver keeps the multi-trip objective inside the search, wrapping SOM
still needs coords=, wrapping a deterministic solver is refused.
>>> ms.set_params(estimator__alpha=0.99).estimator.alpha
0.99
>>> ms
MultiStart(estimator=SimulatedAnnealing(alpha=0.99), n_restarts=4, random_state=0)
>>> sorted(k for k in ms.get_params(deep=True) if k.startswith("estimator__"))[:3]
['estimator__alpha', 'estimator__init', 'estimator__moves']
>>> MultiStart(LocalSearch(), n_restarts=2).fit(C_wi)
Traceback (most recent call last):
...
ValueError: MultiStart needs a stochastic estimator (one with random_state)
The legacy Ensemble* wrappers¶
EnsembleGenetic and
EnsembleSimulatedAnnealing are the ensembles of
scikit-route 1.0, kept as explicit-parameter wrappers: n_genetics / n_simulateds
restarts, the inner knobs keyword-only with the 2.0 defaults (1.0 defaulted
n_simulateds=20; it is now 10). They return exactly what the equivalent MultiStart
returns, expose the same estimators_/costs_/best_estimator_, and will be removed
in 3.0 — new code should use MultiStart directly.
>>> from skroute import EnsembleSimulatedAnnealing
>>> legacy = EnsembleSimulatedAnnealing(n_simulateds=4, random_state=0).fit(C_wi, labels=wi.labels)
>>> reference = MultiStart(SimulatedAnnealing(), n_restarts=4, random_state=0).fit(C_wi, labels=wi.labels)
>>> bool(np.array_equal(legacy.tour_, reference.tour_)) and legacy.costs_.tolist() == reference.costs_.tolist()
True
random_state and reproducibility¶
Every stochastic solver draws all of its randomness from numpy.random.default_rng(random_state)
in Python and hands the numbers to the compiled kernels as arrays. Consequences:
- An
intgives bit-exact reproducibility on the same machine: same seed, same input, sametour_, samehistory_, samen_iter_. - A
numpy.random.Generatoris used in place and advanced by the fit, so two successive fits with the same generator are two different runs — the idiom for "give me another sample" — whiledefault_rng(0)reproducesrandom_state=0exactly. Nonemeans a fresh, unseeded generator. The legacynumpy.random.RandomStateis not accepted.- Across machines the tour is usually the same but is not guaranteed to be:
expin the Metropolis test differs by an ulp between C libraries, and one flipped acceptance changes the rest of the trajectory. That is why the examples in this documentation print gaps below a tolerance rather than raw costs, and why the test-suite pins "same seed, same machine, bit-identical" rather than a float. time_limitbreaks reproducibility: the iteration at which the clock runs out depends on the machine's speed.
>>> a = IteratedLocalSearch(random_state=0).fit(C_wi, labels=wi.labels)
>>> b = IteratedLocalSearch(random_state=0).fit(C_wi, labels=wi.labels)
>>> bool(np.array_equal(a.tour_, b.tour_)) and bool(np.array_equal(a.history_, b.history_))
True
>>> rng = np.random.default_rng(0)
>>> state_before = rng.bit_generator.state
>>> g1 = IteratedLocalSearch(random_state=rng).fit(C_wi, labels=wi.labels) # same draws as random_state=0
>>> bool(np.array_equal(g1.history_, a.history_)), rng.bit_generator.state != state_before
(True, True)
>>> g2 = IteratedLocalSearch(random_state=rng).fit(C_wi, labels=wi.labels) # the generator moved on
>>> g2.n_iter_ != g1.n_iter_ or not np.array_equal(g2.history_, g1.history_)
True
>>> hurried = IteratedLocalSearch(time_limit=1e-6, random_state=0).fit(C_wi, labels=wi.labels)
>>> hurried.stop_reason_, hurried.n_iter_
('time_limit', 1)
MultiStart follows the same rules: one integer is drawn from its random_state and
spawns the child seeds, so the whole ensemble is reproducible from one number, whatever
n_jobs.
Watching a run¶
Nothing in scikit-route prints. verbose=1 logs every tenth outer iteration and the stop
to the skroute logger at INFO, verbose=2 logs every iteration. Python's default
handler shows only WARNING and above, so enable the records once with
skroute.set_log_level (or logging.basicConfig(level=logging.INFO)):
import skroute
skroute.set_log_level("INFO") # attaches a stderr handler if none is configured
IteratedLocalSearch(verbose=1, random_state=0).fit(C_wi, labels=wi.labels)
In code that already configures logging, attach your own handler instead:
>>> import logging
>>> messages = []
>>> class Collect(logging.Handler):
... def emit(self, record):
... messages.append(record.getMessage())
>>> log, handler, saved_level = logging.getLogger("skroute"), Collect(), logging.getLogger("skroute").level
>>> log.addHandler(handler); log.setLevel(logging.INFO)
>>> _ = IteratedLocalSearch(verbose=1, random_state=0).fit(C_wi, labels=wi.labels)
>>> log.removeHandler(handler); log.setLevel(saved_level)
>>> len(messages) >= 1 and messages[-1].startswith("IteratedLocalSearch: stopped")
True
Writing your own solver: check_router¶
A solver is a subclass of BaseRouter that implements one method.
The base class does everything else: it coerces the input into a RoutingProblem,
validates the hyper-parameters, honours the tags, hands _solve a seeded generator,
validates the tour that comes back, decodes it into trips and recomputes cost_ — a
solver never reports a cost, so a route that does not match its cost is impossible.
The contract, in four points:
__init__stores every argument verbatim under the same name and does nothing else (that is whatget_params,set_params,cloneandreprrely on)._parameter_constraintsdeclares the legal values; validation runs atfittime with scikit-learn's message format. Constraints can be a Python type (int), the strings"array-like","random_state","boolean","verbose",None, a callable, or theInterval/Optionsobjects ofskroute.utils._param_validation._get_tags()returns aRouterTagsdescribing what the solver supports:kind,exact,stochastic(then it must have arandom_stateparameter),iterative(then_solvemust sethistory_,n_iter_andstop_reason_, report each outer iteration withself._emit(...)and stop withstop_reason_ = "callback"onceself._stop_requestedis set — the progress-callback protocol offit(..., callback=)),budget_aware,requires_symmetric,requires_coords,max_nodes._solve(problem, rng)returns anint64permutation ofrange(problem.n)withproblem.depotat position 0 — index space, never labels.rngis anumpy.random.Generatorfor stochastic solvers andNoneotherwise;problem.evaluate(tour)prices a tour under the full objective, budget included.
check_router is the structural battery the library's own
solvers pass in tests/test_common.py: parameter protocol, unfitted state, fitted
attributes, recomputed cost, input kinds (ndarray, DataFrame, dict-of-dicts, string
labels), invalid inputs, tags, multi-trip, no printing, the iterative contract,
reproducibility, the smallest sizes and the progress callbacks. It takes an unfitted instance, builds its own
small instances, and raises AssertionError("check N: ...") at the first failure. Here is
a complete solver that passes it — random sampling with the best-so-far bookkeeping of
an iterative solver:
>>> from skroute import BaseRouter, RouterTags, check_router
>>> class RandomSampling(BaseRouter):
... """Draw ``n_samples`` random giant tours and keep the cheapest."""
...
... _parameter_constraints = {"n_samples": [int], "random_state": ["random_state"]}
...
... def __init__(self, n_samples=100, random_state=None):
... self.n_samples = n_samples # stored verbatim, nothing else
... self.random_state = random_state
...
... def _get_tags(self):
... return RouterTags(kind="metaheuristic", stochastic=True, iterative=True, budget_aware=True)
...
... def _solve(self, problem, rng):
... others = np.delete(np.arange(problem.n), problem.depot)
... best, best_cost, history, reason = None, np.inf, [], "max_iter"
... for k in range(self.n_samples):
... tour = np.concatenate(([problem.depot], rng.permutation(others)))
... cost = problem.evaluate(tour) # the problem's own objective, budget included
... if cost < best_cost:
... best, best_cost = tour, cost
... history.append(best_cost)
... self._emit("iteration", k + 1, tour, cost, best, best_cost) # progress callback
... if self._stop_requested: # the callback returned True: stop here
... reason = "callback"
... break
... self.history_, self.n_iter_, self.stop_reason_ = history, len(history), reason
... return best
>>> check_router(RandomSampling()) # silent when every check passes
>>> [name for name, fn in check_router.checks][:4]
['1_init_and_params', '2_not_fitted', '3_fit_results', '4_cost_recomputed']
>>> rs = RandomSampling(random_state=0).fit(C_wi, labels=wi.labels)
>>> rs.n_iter_, rs.stop_reason_, int(rs.route_[0]) == int(rs.route_[-1]) == int(rs.depot_) == 1
(100, 'max_iter', True)
>>> rs.cost_ == float(rs.history_[-1]) and rs.cost_ > wi.optimal_tour_length # a sampler, not a solver
True
>>> RandomSampling(n_samples=5)
RandomSampling(n_samples=5)
budget_aware=True is honest here because the sampler prices every tour with
problem.evaluate, which sees the budget; a solver that optimises the plain tour and
lets the decoder split it afterwards must leave the tag at its default False, so that
fit warns under a budget. Leave exact at False unless the tour is provably optimal
— then set is_optimal_ in _solve.
Tolerance checks (how close to the optimum a solver must get) are not part of
check_router: they live in the test-suite, driven by tests/tolerances.py, and a new
solver added to the library gets an entry there. Read
Contributing for the full procedure.