Skip to content

Commit 8fe1618

Browse files
authored
Penalty per load dimension (PyVRP#713)
* Store a vector for load penalties in CostEvaluator * Update constructor arguments, fix tests * Update PenaltyManager and tests * Update notebook * Fix Result calculation * Complete implementation * Add another test for penalty computations with multiple load dimensions
1 parent 0534f00 commit 8fe1618

29 files changed

+330
-264
lines changed

benchmarks/search/test_LocalSearch.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def test_all_operators(instance, benchmark, request):
2626
ls.add_route_operator(route_op(data))
2727

2828
sol = Solution.make_random(data, rng)
29-
cost_evaluator = CostEvaluator(20, 6, 6)
29+
cost_evaluator = CostEvaluator([20], 6, 6)
3030
benchmark(ls, sol, cost_evaluator)
3131

3232

@@ -42,7 +42,7 @@ def test_each_node_operator(node_op, instance, benchmark, request):
4242
ls.add_node_operator(node_op(data))
4343

4444
sol = Solution.make_random(data, rng)
45-
cost_evaluator = CostEvaluator(20, 6, 6)
45+
cost_evaluator = CostEvaluator([20], 6, 6)
4646
benchmark(ls, sol, cost_evaluator)
4747

4848

@@ -58,5 +58,5 @@ def test_each_route_operator(route_op, instance, benchmark, request):
5858
ls.add_route_operator(route_op(data))
5959

6060
sol = Solution.make_random(data, rng)
61-
cost_evaluator = CostEvaluator(20, 6, 6)
61+
cost_evaluator = CostEvaluator([20], 6, 6)
6262
benchmark(ls, sol, cost_evaluator)

examples/using_pyvrp_components.ipynb

+6-2
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,11 @@
137137
"source": [
138138
"from pyvrp import Solution, CostEvaluator\n",
139139
"\n",
140-
"cost_evaluator = CostEvaluator(load_penalty=20, tw_penalty=20, dist_penalty=0)\n",
140+
"cost_evaluator = CostEvaluator(\n",
141+
" load_penalties=[20],\n",
142+
" tw_penalty=20,\n",
143+
" dist_penalty=0,\n",
144+
")\n",
141145
"sol = Solution.make_random(INSTANCE, rng)"
142146
]
143147
},
@@ -197,7 +201,7 @@
197201
"metadata": {},
198202
"outputs": [],
199203
"source": [
200-
"cost_evaluator = CostEvaluator(200, 200, 0)\n",
204+
"cost_evaluator = CostEvaluator([200], 200, 0)\n",
201205
"new_sol = ls.search(sol, cost_evaluator)\n",
202206
"\n",
203207
"assert new_sol.is_feasible()"

pyvrp/PenaltyManager.py

+31-23
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,12 @@ class PenaltyManager:
107107
108108
Parameters
109109
----------
110+
initial_penalties
111+
Initial penalty values for units of load (idx 0), duration (1), and
112+
distance (2) violations. These values are clipped to the range
113+
``[MIN_PENALTY, MAX_PENALTY]``.
110114
params
111115
PenaltyManager parameters. If not provided, a default will be used.
112-
initial_penalties
113-
Initial penalty values for unit load (idx 0), duration (1), and
114-
distance (2) violations. Defaults to ``(20, 6, 6)`` for backwards
115-
compatibility. These values are clipped to the range ``[MIN_PENALTY,
116-
MAX_PENALTY]``.
117116
"""
118117

119118
MIN_PENALTY: float = 0.1
@@ -122,22 +121,31 @@ class PenaltyManager:
122121

123122
def __init__(
124123
self,
124+
initial_penalties: tuple[list[float], float, float],
125125
params: PenaltyParams = PenaltyParams(),
126-
initial_penalties: tuple[float, float, float] = (20, 6, 6),
127126
):
128127
self._params = params
129128
self._penalties = np.clip(
130-
initial_penalties,
129+
initial_penalties[0] + list(initial_penalties[1:]),
131130
self.MIN_PENALTY,
132131
self.MAX_PENALTY,
133132
)
134133

134+
# Tracks recent feasibilities for each penalty dimension.
135135
self._feas_lists: list[list[bool]] = [
136-
[], # tracks recent load feasibility
137-
[], # track recent time feasibility
138-
[], # track recent distance feasibility
136+
[] for _ in range(len(self._penalties))
139137
]
140138

139+
def penalties(self) -> tuple[list[float], float, float]:
140+
"""
141+
Returns the current penalty values.
142+
"""
143+
return (
144+
self._penalties[:-2].tolist(), # loads
145+
self._penalties[-2], # duration
146+
self._penalties[-1], # distance
147+
)
148+
141149
@classmethod
142150
def init_from(
143151
cls,
@@ -170,18 +178,18 @@ def init_from(
170178
avg_distance = np.minimum.reduce(distances).mean()
171179
avg_duration = np.minimum.reduce(durations).mean()
172180

173-
avg_load = 0
181+
avg_load = np.zeros((data.num_load_dimensions,))
174182
if data.num_clients != 0 and data.num_load_dimensions != 0:
175183
pickups = np.array([c.pickup for c in data.clients()])
176184
deliveries = np.array([c.delivery for c in data.clients()])
177-
avg_load = np.maximum(pickups, deliveries).mean()
185+
avg_load = np.maximum(pickups, deliveries).mean(axis=0)
178186

179187
# Initial penalty parameters are meant to weigh an average increase
180188
# in the relevant value by the same amount as the average edge cost.
181-
init_load = avg_cost / max(avg_load, 1)
189+
init_load = avg_cost / np.maximum(avg_load, 1)
182190
init_tw = avg_cost / max(avg_duration, 1)
183191
init_dist = avg_cost / max(avg_distance, 1)
184-
return cls(params, (init_load, init_tw, init_dist))
192+
return cls((init_load.tolist(), init_tw, init_dist), params)
185193

186194
def _compute(self, penalty: float, feas_percentage: float) -> float:
187195
# Computes and returns the new penalty value, given the current value
@@ -196,9 +204,7 @@ def _compute(self, penalty: float, feas_percentage: float) -> float:
196204
else:
197205
new_penalty = self._params.penalty_decrease * penalty
198206

199-
clipped = np.clip(new_penalty, self.MIN_PENALTY, self.MAX_PENALTY)
200-
201-
if clipped == self.MAX_PENALTY:
207+
if new_penalty >= self.MAX_PENALTY:
202208
msg = """
203209
A penalty parameter has reached its maximum value. This means PyVRP
204210
struggles to find a feasible solution for the instance that's being
@@ -208,7 +214,7 @@ def _compute(self, penalty: float, feas_percentage: float) -> float:
208214
"""
209215
warn(msg, PenaltyBoundWarning)
210216

211-
return clipped
217+
return np.clip(new_penalty, self.MIN_PENALTY, self.MAX_PENALTY)
212218

213219
def _register(self, feas_list: list[bool], penalty: float, is_feas: bool):
214220
feas_list.append(is_feas)
@@ -224,13 +230,13 @@ def register(self, sol: Solution):
224230
"""
225231
Registers the feasibility dimensions of the given solution.
226232
"""
227-
args = [
228-
not sol.has_excess_load(),
233+
is_feasible = [
234+
*[excess == 0 for excess in sol.excess_load()],
229235
not sol.has_time_warp(),
230236
not sol.has_excess_distance(),
231237
]
232238

233-
for idx, is_feas in enumerate(args):
239+
for idx, is_feas in enumerate(is_feasible):
234240
feas_list = self._feas_lists[idx]
235241
penalty = self._penalties[idx]
236242
self._penalties[idx] = self._register(feas_list, penalty, is_feas)
@@ -239,10 +245,12 @@ def cost_evaluator(self) -> CostEvaluator:
239245
"""
240246
Get a cost evaluator using the current penalty values.
241247
"""
242-
return CostEvaluator(*self._penalties)
248+
*loads, tw, dist = self._penalties
249+
return CostEvaluator(loads, tw, dist)
243250

244251
def booster_cost_evaluator(self) -> CostEvaluator:
245252
"""
246253
Get a cost evaluator using the boosted current penalty values.
247254
"""
248-
return CostEvaluator(*(self._penalties * self._params.repair_booster))
255+
*loads, tw, dist = self._penalties * self._params.repair_booster
256+
return CostEvaluator(loads, tw, dist)

pyvrp/Result.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ def cost(self) -> float:
4848
if not self.best.is_feasible():
4949
return math.inf
5050

51-
return CostEvaluator(0, 0, 0).cost(self.best)
51+
num_load_dims = len(self.best.excess_load())
52+
return CostEvaluator([0] * num_load_dims, 0, 0).cost(self.best)
5253

5354
def is_feasible(self) -> bool:
5455
"""

pyvrp/_pyvrp.pyi

+4-2
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import numpy as np
55
class CostEvaluator:
66
def __init__(
77
self,
8-
load_penalty: float,
8+
load_penalties: list[float],
99
tw_penalty: float,
1010
dist_penalty: float,
1111
) -> None: ...
12-
def load_penalty(self, load: int, capacity: int) -> int: ...
12+
def load_penalty(
13+
self, load: int, capacity: int, dimension: int
14+
) -> int: ...
1315
def tw_penalty(self, time_warp: int) -> int: ...
1416
def dist_penalty(self, distance: int, max_distance: int) -> int: ...
1517
def penalised_cost(self, solution: Solution) -> int: ...

pyvrp/cpp/CostEvaluator.cpp

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
using pyvrp::CostEvaluator;
44

5-
CostEvaluator::CostEvaluator(double loadPenalty,
5+
CostEvaluator::CostEvaluator(std::vector<double> loadPenalties,
66
double twPenalty,
77
double distPenalty)
8-
: loadPenalty_(loadPenalty),
8+
: loadPenalties_(std::move(loadPenalties)),
99
twPenalty_(twPenalty),
1010
distPenalty_(distPenalty)
1111
{

pyvrp/cpp/CostEvaluator.h

+55-28
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
#include "Measure.h"
55
#include "Solution.h"
66

7+
#include <cassert>
78
#include <concepts>
89
#include <limits>
10+
#include <vector>
911

1012
namespace pyvrp
1113
{
@@ -42,7 +44,11 @@ concept DeltaCostEvaluatable = requires(T arg, size_t dimension) {
4244
};
4345

4446
/**
45-
* CostEvaluator(load_penalty: float, tw_penalty: float, dist_penalty: float)
47+
* CostEvaluator(
48+
* load_penalties: list[float],
49+
* tw_penalty: float,
50+
* dist_penalty: float,
51+
* )
4652
*
4753
* Creates a CostEvaluator instance.
4854
*
@@ -51,8 +57,9 @@ concept DeltaCostEvaluatable = requires(T arg, size_t dimension) {
5157
*
5258
* Parameters
5359
* ----------
54-
* load_penalty
55-
* The penalty for each unit of excess load over the vehicle capacity.
60+
* load_penalties
61+
* The penalty terms (one for each load dimension) for each unit of load in
62+
* excess of the vehicle capacity.
5663
* tw_penalty
5764
* The penalty for each unit of time warp.
5865
* dist_penalty
@@ -61,18 +68,28 @@ concept DeltaCostEvaluatable = requires(T arg, size_t dimension) {
6168
*/
6269
class CostEvaluator
6370
{
64-
double loadPenalty_;
71+
std::vector<double> loadPenalties_; // per load dimension
6572
double twPenalty_;
6673
double distPenalty_;
6774

75+
/**
76+
* Computes the cost penalty incurred from the given excess loads. This is
77+
* a convenient shorthand for calling ``loadPenalty`` for each dimension.
78+
*/
79+
[[nodiscard]] inline Cost
80+
excessLoadPenalties(std::vector<Load> const &excessLoads) const;
81+
6882
public:
69-
CostEvaluator(double loadPenalty, double twPenalty, double distPenalty);
83+
CostEvaluator(std::vector<double> loadPenalties,
84+
double twPenalty,
85+
double distPenalty);
7086

7187
/**
7288
* Computes the total excess load penalty for the given load and vehicle
73-
* capacity.
89+
* capacity, and dimension.
7490
*/
75-
[[nodiscard]] inline Cost loadPenalty(Load load, Load capacity) const;
91+
[[nodiscard]] inline Cost
92+
loadPenalty(Load load, Load capacity, size_t dimension) const;
7693

7794
/**
7895
* Computes the time warp penalty for the given time warp.
@@ -167,10 +184,25 @@ class CostEvaluator
167184
T<vArgs...> const &vProposal) const;
168185
};
169186

170-
Cost CostEvaluator::loadPenalty(Load load, Load capacity) const
187+
Cost CostEvaluator::excessLoadPenalties(
188+
std::vector<Load> const &excessLoads) const
171189
{
190+
assert(excessLoads.size() == loadPenalties_.size());
191+
192+
Cost cost = 0;
193+
for (size_t dim = 0; dim != loadPenalties_.size(); ++dim)
194+
cost += loadPenalties_[dim] * excessLoads[dim].get();
195+
196+
return cost;
197+
}
198+
199+
Cost CostEvaluator::loadPenalty(Load load,
200+
Load capacity,
201+
size_t dimension) const
202+
{
203+
assert(dimension < loadPenalties_.size());
172204
auto const excessLoad = std::max<Load>(load - capacity, 0);
173-
return static_cast<Cost>(excessLoad.get() * loadPenalty_);
205+
return static_cast<Cost>(excessLoad.get() * loadPenalties_[dimension]);
174206
}
175207

176208
Cost CostEvaluator::twPenalty([[maybe_unused]] Duration timeWarp) const
@@ -192,13 +224,11 @@ template <CostEvaluatable T>
192224
Cost CostEvaluator::penalisedCost(T const &arg) const
193225
{
194226
// Standard objective plus infeasibility-related penalty terms.
195-
auto cost = arg.distanceCost() + arg.durationCost()
196-
+ (!arg.empty() ? arg.fixedVehicleCost() : 0)
197-
+ twPenalty(arg.timeWarp())
198-
+ distPenalty(arg.excessDistance(), 0);
199-
200-
for (auto const excess : arg.excessLoad())
201-
cost += loadPenalty(excess, 0);
227+
auto const cost = arg.distanceCost() + arg.durationCost()
228+
+ (!arg.empty() ? arg.fixedVehicleCost() : 0)
229+
+ excessLoadPenalties(arg.excessLoad())
230+
+ twPenalty(arg.timeWarp())
231+
+ distPenalty(arg.excessDistance(), 0);
202232

203233
if constexpr (PrizeCostEvaluatable<T>)
204234
return cost + arg.uncollectedPrizes();
@@ -228,8 +258,7 @@ bool CostEvaluator::deltaCost(Cost &out, T<Args...> const &proposal) const
228258
out -= distPenalty(route->distance(), route->maxDistance());
229259

230260
if constexpr (!skipLoad)
231-
for (auto const excess : route->excessLoad())
232-
out -= loadPenalty(excess, 0);
261+
out -= excessLoadPenalties(route->excessLoad());
233262

234263
out -= route->durationCost();
235264
out -= twPenalty(route->timeWarp());
@@ -246,7 +275,8 @@ bool CostEvaluator::deltaCost(Cost &out, T<Args...> const &proposal) const
246275
{
247276
auto const &capacity = route->capacity();
248277
for (size_t dim = 0; dim != capacity.size(); ++dim)
249-
out += loadPenalty(proposal.loadSegment(dim).load(), capacity[dim]);
278+
out += loadPenalty(
279+
proposal.loadSegment(dim).load(), capacity[dim], dim);
250280
}
251281

252282
auto const duration = proposal.durationSegment();
@@ -279,11 +309,8 @@ bool CostEvaluator::deltaCost(Cost &out,
279309

280310
if constexpr (!skipLoad)
281311
{
282-
for (auto const excess : uRoute->excessLoad())
283-
out -= loadPenalty(excess, 0);
284-
285-
for (auto const excess : vRoute->excessLoad())
286-
out -= loadPenalty(excess, 0);
312+
out -= excessLoadPenalties(uRoute->excessLoad());
313+
out -= excessLoadPenalties(vRoute->excessLoad());
287314
}
288315

289316
out -= uRoute->durationCost();
@@ -308,13 +335,13 @@ bool CostEvaluator::deltaCost(Cost &out,
308335
{
309336
auto const &uCapacity = uRoute->capacity();
310337
for (size_t dim = 0; dim != uCapacity.size(); ++dim)
311-
out += loadPenalty(uProposal.loadSegment(dim).load(),
312-
uCapacity[dim]);
338+
out += loadPenalty(
339+
uProposal.loadSegment(dim).load(), uCapacity[dim], dim);
313340

314341
auto const &vCapacity = vRoute->capacity();
315342
for (size_t dim = 0; dim != vCapacity.size(); ++dim)
316-
out += loadPenalty(vProposal.loadSegment(dim).load(),
317-
vCapacity[dim]);
343+
out += loadPenalty(
344+
vProposal.loadSegment(dim).load(), vCapacity[dim], dim);
318345
}
319346

320347
if constexpr (!exact)

0 commit comments

Comments
 (0)