Skip to content

Commit

Permalink
Merge pull request #80 from nextmv-io/merschformann/adding-early-term…
Browse files Browse the repository at this point in the history
…ination-criteria

Enhances solver with plateau detection, adds missing Python options
  • Loading branch information
dirkschumacher authored Dec 24, 2024
2 parents 82afdb3 + 6389f94 commit 10157b2
Show file tree
Hide file tree
Showing 116 changed files with 1,421 additions and 60 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ jobs:
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Python unit tests
run: python -m unittest
working-directory: src

# There appears to be a bug around
# `nextmv-io/[email protected]/golden/file.go:75` specifically in Windows. When
# attempting to remove a temp file, the following error is encountered:
Expand All @@ -44,6 +40,10 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}

- name: Python unit tests
run: python -m unittest
working-directory: src

- name: golden file tests from Python package
if: ${{ matrix.platform != 'windows-latest' }}
run: go test $(go list ./... | grep github.com/nextmv-io/nextroute/src)
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/nextmv-io/nextroute
go 1.21

require (
github.com/nextmv-io/sdk v1.8.2
github.com/nextmv-io/sdk v1.8.3-0.20241219091227-002f36a342d6
gonum.org/v1/gonum v0.14.0
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nextmv-io/sdk v1.8.2 h1:9jqtchlgrt7aDzRCRRBr9AM192tRBaN82hUIxpOV/Xg=
github.com/nextmv-io/sdk v1.8.2/go.mod h1:Y48XLPcIOOxRgO86ICNpqGrH2N5+dd1TDNvef/FD2Kc=
github.com/nextmv-io/sdk v1.8.3-0.20241219091227-002f36a342d6 h1:icnvtf2R9Zg9Qs+SyVbQ6HEZ5fPj6A+7ywxOT2lrWus=
github.com/nextmv-io/sdk v1.8.3-0.20241219091227-002f36a342d6/go.mod h1:Y48XLPcIOOxRgO86ICNpqGrH2N5+dd1TDNvef/FD2Kc=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
Expand Down
54 changes: 52 additions & 2 deletions solve_solver.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,47 @@ type SolverOptions struct {
Restart IntParameterOptions `json:"restart" usage:"restart parameter"`
}

// PlateauOptions define how the solver should react to plateaus, i.e., periods
// without significant improvement in the best solution. The solver stops when
// either the Duration or Iterations condition is met, depending on which occurs
// first.
type PlateauOptions struct {
// Duration is the maximum duration without (significant) improvement. I.e.,
// if the solver does not improve the best solution (significantly) within
// this duration, the solver will stop. A duration of 0 means that the
// solver will not check for (significant) improvements based on duration,
// only based on iterations. If both duration and iterations are set, the
// solver will stop if either one of the conditions is met. Default: 0s
// (time-based plateau detection disabled).
Duration time.Duration `json:"duration" usage:"maximum duration without (significant) improvement" default:"0s"`
// Iterations is the maximum number of iterations without (significant)
// improvement. I.e., if the solver does not improve the best solution
// within this number of iterations, the solver will stop. A negative value
// means that the solver will not check for (significant) improvements based
// on iterations, only based on duration. Default: 0 (iteration-based
// plateau detection disabled).
Iterations int `json:"iterations" usage:"maximum number of iterations without (significant) improvement" default:"0"`
// RelativeThreshold defines the minimum relative improvement of the best
// solution within the plateau duration or iterations to be considered
// significant. E.g., a value of 0.1 means that the best solution must
// improve by at least 10% to be considered significant, thus resetting the
// plateau timer/counter. A negative value means that the relative threshold
// is disabled.
RelativeThreshold float64 `json:"relative_threshold" usage:"relative threshold for significant improvement" default:"0.0"`
// AbsoluteThreshold defines the minimum absolute improvement of the best
// solution within the plateau duration or iterations to be considered
// significant. E.g., a value of 10 means that the best solution must
// improve by at least 10 to be considered significant, thus resetting the
// plateau timer/counter. A negative value means that the absolute threshold
// is disabled.
AbsoluteThreshold float64 `json:"absolute_threshold" usage:"absolute threshold for significant improvement" default:"-1.0"`
}

// SolveOptions holds the options for the solve process.
type SolveOptions struct {
Iterations int `json:"iterations" usage:"maximum number of iterations, -1 assumes no limit" default:"-1"`
Duration time.Duration `json:"duration" usage:"maximum duration of solver in seconds" default:"30s"`
Iterations int `json:"iterations" usage:"maximum number of iterations, -1 assumes no limit" default:"-1"`
Duration time.Duration `json:"duration" usage:"maximum duration of solver in seconds" default:"30s"`
Plateau PlateauOptions `json:"plateau" usage:"plateau options"`
}

// Solver is the interface for the Adaptive Local Neighborhood Search algorithm
Expand Down Expand Up @@ -89,6 +126,7 @@ type solveImpl struct {
random *rand.Rand
solveOperators SolveOperators
parameters SolveParameters
plateauTracker *plateauTracker
progression []ProgressionEntry
}

Expand Down Expand Up @@ -211,6 +249,11 @@ func (s *solveImpl) invoke(
func (s *solveImpl) newBestSolution(solution Solution, solveInformation *solveInformationImpl) {
s.bestSolution = solution.Copy()
s.solveEvents.NewBestSolution.Trigger(solveInformation)
s.plateauTracker.onImprovement(
time.Since(solveInformation.Start()).Seconds(),
solveInformation.Iteration(),
solveInformation.Solver().BestSolution().Score(),
)
}

func (s *solveImpl) Solve(
Expand Down Expand Up @@ -253,6 +296,10 @@ func (s *solveImpl) Solve(
start.Add(solveOptions.Duration),
)

if plateauTrackingActivated(solveOptions.Plateau) {
s.plateauTracker = newPlateauTracker(solveOptions.Plateau)
}

solveInformation := &solveInformationImpl{
iteration: 0,
solver: s,
Expand Down Expand Up @@ -305,6 +352,9 @@ func (s *solveImpl) Solve(
Error: nil,
}
}
if s.plateauTracker.ShouldTerminate(iteration, time.Since(start)) {
break Loop
}
}
}

Expand Down
31 changes: 25 additions & 6 deletions solve_solver_parallel.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ const Iterations string = "iterations"

// ParallelSolveOptions holds the options for the parallel solver.
type ParallelSolveOptions struct {
Iterations int `json:"iterations" usage:"maximum number of iterations, -1 assumes no limit; iterations are counted after start solutions are generated" default:"-1"`
Duration time.Duration `json:"duration" usage:"maximum duration of the solver" default:"5s"`
ParallelRuns int `json:"parallel_runs" usage:"maximum number of parallel runs, -1 results in using all available resources" default:"-1"`
StartSolutions int `json:"start_solutions" usage:"number of solutions to generate on top of those passed in; one solution generated with sweep algorithm, the rest generated randomly" default:"-1"`
RunDeterministically bool `json:"run_deterministically" usage:"run the parallel solver deterministically"`
Iterations int `json:"iterations" usage:"maximum number of iterations, -1 assumes no limit; iterations are counted after start solutions are generated" default:"-1"`
Duration time.Duration `json:"duration" usage:"maximum duration of the solver" default:"5s"`
Plateau PlateauOptions `json:"plateau" usage:"plateau options"`
ParallelRuns int `json:"parallel_runs" usage:"maximum number of parallel runs, -1 results in using all available resources" default:"-1"`
StartSolutions int `json:"start_solutions" usage:"number of solutions to generate on top of those passed in; one solution generated with sweep algorithm, the rest generated randomly" default:"-1"`
RunDeterministically bool `json:"run_deterministically" usage:"run the parallel solver deterministically"`
}

// ParallelSolver is the interface for parallel solver. The parallel solver will
Expand Down Expand Up @@ -136,6 +137,7 @@ type parallelSolverImpl struct {
parallelSolveEvents ParallelSolveEvents
solveOptionsFactory SolveOptionsFactory
solverFactory SolverFactory
plateauTracker *plateauTracker
}

func (s *parallelSolverImpl) ParallelSolveEvents() ParallelSolveEvents {
Expand Down Expand Up @@ -199,6 +201,7 @@ func (s *parallelSolverImpl) Solve(
ParallelRuns: options.ParallelRuns,
StartSolutions: options.StartSolutions,
RunDeterministically: options.RunDeterministically,
Plateau: options.Plateau,
}

if interpretedParallelSolveOptions.ParallelRuns == -1 {
Expand Down Expand Up @@ -230,6 +233,10 @@ func (s *parallelSolverImpl) Solve(
}
}

if plateauTrackingActivated(interpretedParallelSolveOptions.Plateau) {
s.plateauTracker = newPlateauTracker(interpretedParallelSolveOptions.Plateau)
}

start := time.Now()

if ctx.Value(run.Start) != nil {
Expand Down Expand Up @@ -284,6 +291,11 @@ func (s *parallelSolverImpl) Solve(
Value: solutionContainer.Solution.Score(),
Iterations: solutionContainer.Iterations,
})
s.plateauTracker.onImprovement(
time.Since(start).Seconds(),
solutionContainer.Iterations,
solutionContainer.Solution.Score(),
)
}
}

Expand Down Expand Up @@ -348,7 +360,14 @@ func (s *parallelSolverImpl) Solve(
s.RegisterEvents(solver.SolveEvents())

solver.SolveEvents().Iterated.Register(func(_ SolveInformation) {
if totalIterations.Add(1) >= int64(interpretedParallelSolveOptions.Iterations) {
iterations := totalIterations.Add(1)
if iterations >= int64(interpretedParallelSolveOptions.Iterations) {
cancel()
}
if s.plateauTracker.ShouldTerminate(
int(iterations),
time.Since(start),
) {
cancel()
}
})
Expand Down
120 changes: 120 additions & 0 deletions solve_terminate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// © 2019-present nextmv.io inc

package nextroute

import (
"time"
)

type plateauTracker struct {
// progression is the value progression of the solver. This is tracked
// separately of any other progression tracking to avoid conflicts.
progression []ProgressionEntry
// durationIndex is the current index of the first progression entry within
// the duration cutoff.
durationIndex int
// iterationsIndex is the current index of the first progression entry
// within the iterations cutoff.
iterationsIndex int
// options are the options for the plateau tracker.
options PlateauOptions
}

func newPlateauTracker(options PlateauOptions) *plateauTracker {
return &plateauTracker{
progression: make([]ProgressionEntry, 0),
durationIndex: 0,
iterationsIndex: 0,
options: options,
}
}

// plateauTrackingActivated returns true if the plateau tracking should be
// activated based on the provided options.
func plateauTrackingActivated(options PlateauOptions) bool {
// We need to be testing within some duration or iteration cutoff.
return (options.Duration > 0 || options.Iterations > 0) &&
// We need to have some threshold configured (negative threshold is deactivating the corresponding check).
(options.AbsoluteThreshold >= 0 || options.RelativeThreshold >= 0)
}

// onImprovement is called to update the plateau tracker whenever a new
// improvement is found.
func (t *plateauTracker) onImprovement(elapsed float64, iterations int, value float64) {
if t == nil {
return
}
// Add the new progression entry.
t.progression = append(t.progression, ProgressionEntry{
ElapsedSeconds: elapsed,
Value: value,
Iterations: iterations,
})
}

// ShouldTerminate returns true if the solver should terminate due to a detected
// plateau.
func (t *plateauTracker) ShouldTerminate(iterations int, elapsed time.Duration) bool {
if t == nil {
return false
}

currentValue := t.progression[len(t.progression)-1].Value

// Check if no significantly improving solutions were found during the
// configured duration.
if t.options.Duration > 0 {
cutoffSeconds := t.options.Duration.Seconds()
elapsedSeconds := elapsed.Seconds()
// Move the duration index to the first entry within the cutoff.
for t.durationIndex < len(t.progression) &&
(elapsedSeconds-t.progression[t.durationIndex].ElapsedSeconds) > cutoffSeconds {
t.durationIndex++
}
// If the duration index is at the end of the progression, no
// improvement was found within the cutoff.
if t.durationIndex >= len(t.progression) {
return true
}
// Compare the current value to the value at the duration index.
cutoffValue := t.progression[t.durationIndex].Value
if t.options.AbsoluteThreshold >= 0 &&
currentValue-cutoffValue < t.options.AbsoluteThreshold {
return true
}
if t.options.RelativeThreshold >= 0 &&
currentValue > 0 && // Relative threshold is only supported for positive values.
(currentValue-cutoffValue)/currentValue < t.options.RelativeThreshold {
return true
}
}

// Check if no significantly improving solutions were found during the
// configured iterations.
if t.options.Iterations > 0 {
// Move the iterations index to the first entry within the cutoff.
for t.iterationsIndex < len(t.progression) &&
iterations-t.progression[t.iterationsIndex].Iterations > t.options.Iterations {
t.iterationsIndex++
}
// If the iterations index is at the end of the progression, no
// improvement was found within the cutoff.
if t.iterationsIndex >= len(t.progression) {
return true
}
// Compare the current value to the value at the iterations index.
cutoffValue := t.progression[t.iterationsIndex].Value
if t.options.AbsoluteThreshold >= 0 &&
currentValue-cutoffValue < t.options.AbsoluteThreshold {
return true
}
if t.options.RelativeThreshold >= 0 &&
currentValue > 0 && // Relative threshold is only supported for positive values.
(currentValue-cutoffValue)/currentValue < t.options.RelativeThreshold {
return true
}
}

// No plateau detected.
return false
}
1 change: 1 addition & 0 deletions solver_parallel.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func (p *parallelSolverWrapperImpl) Solve(
ParallelRuns: solveOptions.ParallelRuns,
StartSolutions: solveOptions.StartSolutions,
RunDeterministically: solveOptions.RunDeterministically,
Plateau: solveOptions.Plateau,
}

if interpretedParallelSolveOptions.ParallelRuns == -1 {
Expand Down
12 changes: 12 additions & 0 deletions src/nextroute/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ class Options(BaseModel):
"""Factor to weigh the late arrival objective."""
MODEL_OBJECTIVES_MINSTOPS: float = 1.0
"""Factor to weigh the min stops objective."""
MODEL_OBJECTIVES_STOPBALANCE: float = 0.0
"""Factor to weigh the stop balance objective."""
MODEL_OBJECTIVES_TRAVELDURATION: float = 0.0
"""Factor to weigh the travel duration objective."""
MODEL_OBJECTIVES_UNPLANNEDPENALTY: float = 1.0
Expand All @@ -105,6 +107,8 @@ class Options(BaseModel):
"""Ignore the initial solution."""
MODEL_PROPERTIES_DISABLE_STOPDURATIONMULTIPLIERS: bool = False
"""Ignore the stop duration multipliers defined on vehicles."""
MODEL_PROPERTIES_MAXIMUMTIMEHORIZON: int = 15552000
"""Maximum time horizon for the model in seconds."""
MODEL_VALIDATE_DISABLE_RESOURCES: bool = False
"""Disable the resources validation."""
MODEL_VALIDATE_DISABLE_STARTTIME: bool = False
Expand All @@ -125,6 +129,14 @@ class Options(BaseModel):
Maximum number of parallel runs, -1 results in using all available
resources.
"""
SOLVE_PLATEAU_ABSOLUTETHRESHOLD: float = -1
"""Absolute threshold for significant improvement."""
SOLVE_PLATEAU_DURATION: float = 0
"""Maximum duration without (significant) improvement."""
SOLVE_PLATEAU_ITERATIONS: int = 0
"""Maximum number of iterations without (significant) improvement."""
SOLVE_PLATEAU_RELATIVETHRESHOLD: float = 0
"""Relative threshold for significant improvement."""
SOLVE_RUNDETERMINISTICALLY: bool = False
"""Run the parallel solver deterministically."""
SOLVE_STARTSOLUTIONS: int = -1
Expand Down
Loading

0 comments on commit 10157b2

Please sign in to comment.