Search code examples
javatimefold

Making my soft constraint tunable in Timefold


I'm currently working on a Timefold solver based on the vehicle routing project (but largely modified). In my project, the soft constraint are:

  • minimize distance: score is penalty of the sum of driving time in seconds
  • maximize the duration of the visits: score is a reward of the sum of visit's duration in seconds

I would like the manager of the solution to apply weight to each of these constraint in a "I would like to give the constraint of distance an importance of 90% where the other one is 10%" way because :

  • It's a very simple way to configure the process by people with no idea about the implementation of the solving process (because you don't have to manage arbitrary coefficient for each constraint)
  • It scales better depending on the amount of visits/vehicles
  • It scale on new constraints
  • It allows dynamic constraints, partly possible with constraints configuration but it does not have the behavior I want (no normalization of all the score). It's made at the price of performance (because the solver take some time managing constraint with no purpose) but this feature is worthy some tradeoff.

So I basically want to normalize my constraints and I think the documentation says it's not a good practice because of rounding and performance issues, but what if I normalize between Long.min and Long.max?

I would like to know how bad it looks and if it's not that bad, how to implement it properly in timefold.

It does not sound like simple, hard/soft nor Pareto scoring to me (maybe I'm wrong, let me know). Maybe a Hard/soft constraint with only the soft part being normalized is possible?

I tried to implement a Hard/Soft constraint with soft having coefficient, normalized by the sum of coefficient, but the result was not great (I had great Zs in my planification, which is far from being the optimal order of visits) and I have no idea why

Please let me know you if you have any ideas or suggestion and if I got anything wrong


Solution

  • You should still use Constraint Configuration to set the weight of each constraint, but expose a different API for your users that will be converted to a ConstraintConfiguration. In particular:

    1. Create a @ConstraintConfiguration class to hold the weights, with a static method/constructor to create an instance from the model your user expects:
    @ConstraintConfiguration
    public class VehicleRoutingConstraintConfiguration {
        final Map<String, Long> weightMap;
    
        public VehicleRoutingConstraintConfiguration() {
            weightMap = new HashMap<>();
        }
    
        public VehicleRoutingConstraintConfiguration(Map<String, Long> weightMap) {
            this.weightMap = weightMap;
        }
    
        @ConstraintWeight("Minimize distance")
        public HardMediumSoftLongScore getMinimizeDistance() {
            return HardMediumSoftLongScore.ofSoft(weightMap.getOrDefault("Minimize distance", 0L));
        }
    
        @ConstraintWeight("Maximize the duration of the visits")
        public HardMediumSoftLongScore getMaximizeDuration() {
            return HardMediumSoftLongScore.ofSoft(weightMap.getOrDefault("Maximize the duration of the visits", 0L));
        }
    
        public static VehicleRoutingConstraintConfiguration fromImportanceMap(Map<String, BigDecimal> importanceMap) {
            // Step one: find the largest scale in values
            int maxScale = 0;
            for (var weight : importanceMap.values()) {
                maxScale = Math.max(maxScale, weight.scale());
            }
            // Step two: Multiply all weights by 10^(maxScale) and convert to long
            Map<String, Long> adjustedWeights = new HashMap<>();
            for (var entry : importanceMap.entrySet()) {
                adjustedWeights.put(entry.getKey(), entry.getValue().scaleByPowerOfTen(maxScale).longValueExact());
            }
            // For Map.of("Minimize distance, BigDecimal.valueOf(0.9),
            //            "Maximize duration of the visits", BigDecimal.valueOf(0.1))
            // this will result in the constraints having weights 9 and 1
            // respectively, making distance 9 times more important than
            // visit duration.
            //
            // This means, assuming all other constraint matches are the
            // same, that a solution that only reduces driving time by 1 hour
            // would be preferred to a solution that only increases
            // visit duration by 8 hours.
            return new VehicleRoutingConstraintConfiguration(adjustedWeights);
        }
    }
    
    1. Add a @ConstraintConfigurationProvider on the @PlanningSolution:
    @PlanningSolution
    public class VehicleRoutingPlan {
    
        @ConstraintConfigurationProvider
        private VehicleRoutingConstraintConfiguration constraintConfiguration;
    
        ...
    }
    
    1. Use penalizeConfigurable/rewardConfigurable in your constraints:
    public class VehicleRoutingConstraintProvider implements ConstraintProvider {
    
        @Override
        public Constraint[] defineConstraints(ConstraintFactory factory) {
            return new Constraint[] {
                    minimizeDistance(factory),
                    maximizeDuration(factory),
                    ...
            };
        }
    
        protected Constraint minimizeDistance(ConstraintFactory factory) {
            return factory.forEach(...)
                    ...
                    .penalizeConfigurable("Minimize distance", ...);
        }
    
        protected Constraint maximizeDuration(ConstraintFactory factory) {
            return factory.forEach(...)
                    ...
                    .penalizeConfigurable("Maximize the duration of the visits", ...);
        }
    
        ...
    
    }