Nested optimisation problem in JuMP

I am working on a multi-objective optimisation problem involving a collection of demand nodes supplied by a collection of source nodes. The aim is to explore a range of different potential networks and their ability to meet different objectives.

The decision variables are a 2D binary array which allocates different demands to each of the sources.
So far it has worked beautifully with some simplifed objectives, using JuMP with HiGHs since most of the objectives are just scalar multiples of coefficients and the decision variables.

However, one of the objectives requires a more complex nested function. This would ideally be a nested optimisation to solve a travelling salesman problem for each of the networks created - using a distance matrix for a subset of x as inputs and a scalar output - for each network (which is a line in the binary decision matrix).

I understand a user-defined function can be passed in for one of the objectives and it is possible to run a nested optimisation within another. However, as far as I can tell this is only possible for linear functions and is not possible with the current configuration since a nested optimisation requires use of @NLobjective or similar which is then not accepted by HiGHs.

Conversely, most of the examples of nested optimisation (Nested optimization problems · JuMP) use single-objective solvers such as Ipopt which then doesn’t suit my problem.

I have also explored BlackBoxOptim which looks like it may be a promising alternative though I haven’t yet worked out how to use it with binary decision variables.

Is there a way to use a more complex user-defined function (such as a nested optimisation) within the objective function for HiGHs that works?
Alternately, is there a better configuration and solver I should be using for this problem?

Indicative code snippet below, I can provide a more complete example if needed.

    a = 5 * x[i,j] #Simple linear function works
    #a = 5 * x[i,j] ^2 #More complex non-linear function doesn't work
    return a
 end```

```@expression(model, p3_expr, sum(test_function(x) for i in 1:number_of_demands for j in 1:number_of_supplys))
@objective(model, Max, [p1_expr, p2_expr, -p3_expr])```

Hi @dbro, welcome to the forum.

If you’re already solving a multi-objective problem, could you add the traveling sales person distance as another objective?

Is there a way to use a more complex user-defined function (such as a nested optimisation) within the objective function for HiGHs that works?

No. HiGHS is a MIP solver. It doesn’t support nonlinear or user-defined functions.

I can provide a more complete example if needed.

This would be helpful.

1 Like

Thank you for the instant response.

HiGHs - That confirms what I understood from the documentation.

Yes, that makes sense to add the distance as another objective.

I didn’t really know where to start with this (other than some sort of non-linear or optimization function) given the number of steps required to build the decision matrix and solve the tsp problem. The array of decision variables is different and varies for each of the networks. Of course, I could simply add a second variable for this and the tsp constraints can then be applied to this second array.

See below for some example code that solves the above (albeit with a suspiciously low distance of zero and a static distance matrix for now).

using JuMP
import HiGHS #Main solver
import MultiObjectiveAlgorithms as MOA

demand_volume = Float64[9,258,33,73,250,8,39,75,80,100]
supply_capacity = [400, 250, 230]
supply_type1_binary = [1,0,1]
supply_type2_binary = [0,1,0]

number_of_demands = size(demand_volume)[1]
number_of_sources = size(supply_capacity)[1]

distances = [
        0  10 15 20;
        10  0 35 25;
        15 35  0 30;
        20 25 30  0]

n = size(distances, 1)

model = Model()
@variable(model, x[1:number_of_demands, 1:number_of_sources], Bin) #Decision variable for main problem
@variable(model, y[1:n, 1:n], Bin) #Decision variable for tsp

#Demand constraint: Check demand only used for one supply
for i=1:number_of_demands
    @constraint(model, sum(demand_volume[i] * x[i,j] for j in 1:number_of_sources) <= demand_volume[i]) #Limit to using each demand once across different sources
end

#Supply constraint: Check sum of demands does not exceed supply capacity
for j=1:number_of_sources
    @constraint(model, sum(demand_volume[i] * x[i,j] for i in 1:number_of_demands) <= supply_capacity[j]) #Limit demand volumes to available supply volume for each source
end

for j=1:number_of_sources
   #build_network_demand_matrix(demand_demand_distance_matrix,source_demand_distance_matrix,number_of_demands,number_of_sources,j)
   # Distance constraint: Each city is visited exactly once
   for i in 1:n
       @constraint(model, sum(y[i, j] for j in 1:n) == 1)
       @constraint(model, sum(y[j, i] for j in 1:n) == 1)
   end

   # Distance constraint: Eliminate subtours
   for i in 2:n
       @constraint(model, sum(y[j, i] for j in 1:n) == 1)
       @constraint(model, sum(y[i, j] for j in 1:n) == 1)
   end
end

@expression(model, p1_expr, sum(demand_volume[i] * supply_type1_binary[j] * x[i,j] for i in 1:number_of_demands for j in 1:number_of_sources))
@expression(model, p2_expr, sum(demand_volume[i] * supply_type2_binary[j] * x[i,j] for i in 1:number_of_demands for j in 1:number_of_sources))
@expression(model, p3_expr, sum(distances[i, j] * y[i, j] for i in 1:n for j in 1:n)) #Distance expression
@objective(model, Max, [p1_expr, p2_expr, -p3_expr])

set_optimizer(model, () -> MOA.Optimizer(HiGHS.Optimizer))
#set_silent(model) #Turn this off to view more comprehensive information as the optimiser runs
optimize!(model)
solution_summary(model)

Many thanks.

No problem.

I’m not sure your code is quite right for the TSP. Here’s how I would write your code. It uses the MTZ formulation of a TSP.

Hopefully it gives you some ideas:

using JuMP
import HiGHS
import MultiObjectiveAlgorithms as MOA
demand_volume = Float64[9,258,33,73,250,8,39,75,80,100]
supply_capacity = [400, 250, 230]
supply_type1_binary = [1, 0, 1]
supply_type2_binary = [0, 1, 0]
number_of_demands = length(demand_volume)
number_of_sources = length(supply_capacity)
distances = [
    0  10 15 20
    10  0 35 25
    15 35  0 30
    20 25 30  0
]
n = size(distances, 1)
model = Model(() -> MOA.Optimizer(HiGHS.Optimizer))
@variables(model, begin
    x[1:number_of_demands, 1:number_of_sources], Bin
    y[1:n, 1:n], Bin
    2 <= u[1:n] <= n
end)
fix(u[1], 1; force = true)
@constraints(model, begin
    [i in 1:number_of_demands], sum(x[i,:]) <= 1
    demand_volume' * x .<= supply_capacity
    [i in 1:n], sum(y[i, :]) == 1
    [i in 1:n], sum(y[:, i]) == 1
    [i in 1:n, j in 1:n; i != 1 && j != 1],
        u[i] - u[j] + 1 <= (n - 1) * (1 - y[i, j])
end)
@expressions(model, begin
    p1_expr, demand_volume' * x * supply_type1_binary
    p2_expr, demand_volume' * x * supply_type2_binary
    p3_expr, sum(distances .* y)
end)
@objective(model, Max, [p1_expr, p2_expr, -p3_expr])
optimize!(model)
solution_summary(model)

Thank you for the generous help, it is greatly appreciated as I’ve been scratching my head thinking about this one for a while (and am new to Julia and JuMP).

I’ve made a little progress but still stumped on the main challenge.

To provide some further context, we have a collection of nodes, some are sources, some are demands (or depots and deliveries perhaps).

We have multiple sources (which may optionally be used or not) and then each of these may supply one or more of the demands.
Each demand may only be supplied by one source.
Each node can only appear once within any given network.

Therefore, we need to calculate a separate TSP on a subset of the nodes for each source.
The nodes contained within each subset may vary between networks and iterations.

Previously I used a function to extract subsets of the array and run the tsp on these (after the fact and the optimisation was completed). I understand that such a function can’t readily be fed into the expression or constraint logic - and that having arrays of varying sizes between iterations is probably not ideal.

I am coming to the view that it is probably better to work with a static distance matrix but revise the constraints such that the TSP algorithm only targets a subset of the nodes within it - although I have yet to work out how this could be done.

I have added a dimension to the y and u matrices to accommodate the multiple networks.

Is there a way to modify the constraints such that the TSP is run iteratively and targetting only a subset of the nodes within the distance matrix (including each source and a corresponding set of demands)? Essentially a travelling salesman problem with ‘must visit’ nodes or deliveries that have to be made.

We also need to handle potential zero cases where a source is not used at all in some iterations.

I’ve tried using a multiple of the x and y matrix (but this makes the problem quadratic) and the mapping between them becomes problematic.

Below is an MVE (it essentially repeats the tsp for each source on the same static distance matrix for all the nodes), any suggestions would be appreciated.

using JuMP
import HiGHS #Main solver
import MultiObjectiveAlgorithms as MOA

demand_volume = Float64[9,258,33,73,250,8,39,75,80,100]
supply_capacity = [400,250,230]
source_type1_binary =   [1,0,1]
source_type2_binary = [0,1,0]

all_nodes_distance_matrix = [
0 999 999 175 10 10 8 173 4 0 5 4 28;
999 0 999 46 1 3 2 45 0 0 1 12 167;
999 999 0 28 1 2 1 27 0 0 1 11 27;
175 46 28 0 9 17 16 1 9 17 16 55 35;
10 1 1 9 0 8 8 9 1 8 8 49 32;
10 3 2 17 8 0 2 17 8 0 2 47 34;
8 2 1 16 8 2 0 16 8 2 0 48 35;
173 45 27 0 9 17 16 0 9 17 16 55 35;
4 0 0 9 1 8 8 9 0 8 8 49 32;
0 0 0 17 8 1 2 17 8 0 2 47 34;
5 1 1 16 8 2 1 16 8 2 0 48 35;
4 12 11 55 49 47 48 55 49 47 48 0 22;
28 167 27 35 32 34 35 35 32 34 35 22 0]

number_of_demands = length(demand_volume)
number_of_sources = length(supply_capacity)
number_of_nodes = size(all_nodes_distance_matrix,1)

model = Model(() -> MOA.Optimizer(HiGHS.Optimizer))

@variables(model, begin
    x[1:number_of_demands, 1:number_of_sources], Bin #Decision variable for nodes to use for each network
    y[1:number_of_nodes, 1:number_of_nodes,1:number_of_sources], Bin #Decision variable for tsp #Need a matrix for each source
    2 <= u[1:number_of_nodes,1:number_of_sources] <= number_of_nodes * number_of_sources
end)

for i = 1:number_of_sources
fix(u[1,i], 1; force = true) #Sets rank of first item
end

#General problem constraints
@constraints(model, begin
     [i in 1:number_of_demands], sum(x[i,:]) <= 1 #Only use each node once in each column
     demand_volume' * x .<= supply_capacity
end)

#TSP constraints
for s in 1:number_of_sources
    @constraint(model,[i in 1:number_of_nodes], sum(y[i, :, s]) == 1) #Only one node in allowed
    @constraint(model,[i in 1:number_of_nodes], sum(y[:, i, s]) == 1) #Only one node out allowed
end
#   [i in 1:n], (x[i, :] <= y[i, :]) #Only one node in allowed and also must be in x
#   [i in 1:n], (x[:, i] <= y[:, i]) #Only one node out allowed and also must be in x
for s in 1:number_of_sources
    @constraint(model, [i in 1:number_of_nodes, j in 1:number_of_nodes; i != 1 && j != 1],
    u[i,s] - u[j,s] + 1 <= (number_of_nodes - 1) * (1 - y[i, j, s]))
end

@expressions(model, begin
    p1_expr, demand_volume' * x * source_type1_binary
    p2_expr, demand_volume' * x * source_type2_binary
    p3_expr, sum(all_nodes_distance_matrix .* y)
    p4_expr, sum(demand_volume' * x)
end)

# @expression(model, p1_expr, sum(demand_volume[i] * supply_type1_binary[j] * x[i,j] for i in 1:number_of_demands for j in 1:number_of_sources))
# @expression(model, p2_expr, sum(demand_volume[i] * supply_type2_binary[j] * x[i,j] for i in 1:number_of_demands for j in 1:number_of_sources))
# @expression(model, p3_expr, sum(distances[i, j] * y[i, j] for i in 1:n for j in 1:n)) #Distance expression

@objective(model, Max, [p1_expr, p2_expr, -p3_expr,p4_expr])

#set_silent(model) #Turn this off to view more comprehensive information as the optimiser runs
optimize!(model)
solution_summary(model)

if termination_status(model) == OPTIMAL
    println("Solution is optimal")

elseif termination_status(model) == TIME_LIMIT && has_values(model)
    println("Solution is suboptimal due to a time limit, but a primal solution is available")
else
    error("The model was not solved correctly.")
end

number_of_solutions = result_count(model)
println("Number of solutions: ", number_of_solutions)```

Thank you for the generous help, it is greatly appreciated as I’ve been scratching my head thinking about this one for a while (and am new to Julia and JuMP).

No problem.

I have added a dimension to the y and u matrices to accommodate the multiple networks.
it essentially repeats the tsp for each source on the same static distance matrix for all the nodes

This seems like the right approach for now. See also:

Is there a way to modify the constraints such that the TSP is run iteratively

There are other formulations, see Traveling Salesperson Problem · JuMP, but I’d caution against jumping (if you will) straight to that. I’d get the MTZ formulation working for small instances, and then go from there once you have a handle on the problem.

Thank you again for suggestions Oscar and Victor for the example. After a lot of trying different things I’ve come at it from the opposite direction of solving the spatial problem of multiple simultaneous TSP’s first then worrying about other objectives.

I used your suggested school bus routing code as a base to calculate a series of networks with unique starting points.
Essentially instead of several buses from one point, a single bus or route runs from each of the designated ‘source’ points.
Since each demand is associated with only one node, the y and z variables and associated constraints were consolidated.
Two objectives maximise demand and minimise distance.

This runs successfully on a toy example (10 nodes, 3 source + 7 demand) with either HiGHS or GLPK.
However, on a slightly more meaningful example (70 nodes, 20 source + 50 demand) the run time quickly became intractable.

I tried adding lazy constraints to improve the efficiency but haven’t got the syntax quite right as we want to read in the 3-dimensional x matrix but use a 2-dimensional matrix for the subtour elimination.

From what I can see, lazy constraints aren’t supported by HiGHS so have tried switching to GLPK. I assume I will also need to turn off the MTZ constraints.

Any suggestions or corrections welcome.

Below is revised code

using HiGHS
using GLPK
import MultiObjectiveAlgorithms as MOA

distance = [
0 3.16 1.41 1 2.24 2.44 2.64 2.84 3.04 3.24  
3.16 0 4 3.61 4.12 4.32 4.52 4.72 4.92 5.12  
1.41 4 0 2.24 1 1.2 1.4 1.6 1.8 2  
1 3.61 2.24 0 3.16 3.36 3.56 3.76 3.96 4.16  
2.24 4.12 1 3.16 0 0.2 0.4 0.6 0.8 1  
2.44 4.32 1.2 3.36 0.2 0 0.2 0.4 0.6 0.8  
2.64 4.52 1.4 3.56 0.4 0.2 0 0.2 0.4 0.6  
2.84 4.72 1.6 3.76 0.6 0.4 0.2 0 0.2 0.4  
3.04 4.92 1.8 3.96 0.8 0.6 0.4 0.2 0 0.2  
3.24 5.12 2 4.16 1 0.8 0.6 0.4 0.2 0  
]

demands = [
    0 0 0 2 2 1 4 5 3 4
    ] #First 3 nodes are source nodes, last 7 nodes are demand nodes

    a = [
        0 1 1
        1 0 1
        1 1 0
        0 0 0
        0 0 0
        1 0 0
        0 0 0
        0 0 0
        0 0 0
        0 0 0
    ] #Allowed node combinations matrix (sources x demands): Use 1 to disallow nodes

V = 1:size(distance, 1)
supply_capacity = [4,5,7] #Allows unique capacity for each bus/source
n = length(supply_capacity) #Modify to set number of routes based on supply capacity vector

#Lazy constraints functions
#https://jump.dev/JuMP.jl/stable/tutorials/algorithms/tsp_lazy_constraints/
function subtour(edges::Vector{Tuple{Int,Int}}, n)
    shortest_subtour, unvisited = collect(1:n), Set(collect(1:n))
    while !isempty(unvisited)
        this_cycle, neighbors = Int[], unvisited
        while !isempty(neighbors)
            current = pop!(neighbors)
            push!(this_cycle, current)
            if length(this_cycle) > 1
                pop!(unvisited, current)
            end
            neighbors =
                [j for (i, j) in edges if i == current && j in unvisited]
        end
        if length(this_cycle) < length(shortest_subtour)
            shortest_subtour = this_cycle
        end
    end
    return shortest_subtour
end

#Let us declare a helper function `selected_edges()` that will be repeatedly
#used in what follows.
function selected_edges(x_k::Matrix{Float64}, n)
    return Tuple{Int,Int}[(i, j) for i in 1:n, j in 1:n if x_k[i, j] > 0.5]
end
#Other helper functions for computing subtours:
subtour(x::Matrix{Float64}) = subtour(selected_edges(x_k, size(x_k, 1)), size(x_k, 1))
subtour(x::AbstractMatrix{VariableRef}) = subtour(value.(x_k))

#model = Model(() -> MOA.Optimizer(HiGHS.Optimizer)) #For multiple objectives
model = Model(() -> MOA.Optimizer(GLPK.Optimizer)) #For multiple objectives
set_optimizer_attribute(model, "msg_lev", GLPK.GLP_MSG_ALL)

@variables(model, begin
    x[i in V, j in V, k in 1:n], Bin #A set of symmetric matrices, one for each potential route/bus. k is the number of school buses/sources
    y[i in V, k in 1:n], Bin #1 if bus k visits stop i, 0 otherwise
    2 <= u[i in V, k in 1:n] <= length(V) #A route vector of 5 nodes for each school bus
end)

fix.(u[1, :], 1; force = true) #Set first node of route vector u to always be 1

@expression(model, p4_expr, sum(demands[i] * y[i, k] for i in V, k in 1:n))
@expression(model, dist_expr,-sum(distance[i, j] .* x[i, j, k] for i in V, j in V, k in 1:n))

@objective(
    model,
    Max, [p4_expr,-dist_expr]
    ) #Minimise distance and maximise demands supplied

@constraints(model, begin
    #Spatial network constraints
    # For all nodes and routes, flow out == flow in
    [i in V, k in 1:n], sum(x[i, :, k]) == sum(x[:, i, k])
    # Flow in and out of all nodes other than current source node is 1
    [i in V, k in 1:n; i != k], sum(x[i, :, :]) <= 1
    [i in V, k in 1:n; i != k], sum(x[:, i, :]) <= 1
    # MTZ constraints revised for multiple sources
    [i in V, j in V, k in 1:n; i != k && j != k],
        u[i, k] - u[j, k] + 1 <= (n - 1) * (1 - x[i, j, k])
    # y[i, k] is 1 if node i is in route k
    [i in V, k in 1:n], sum(x[i, :, k]) == y[i, k]
    # Each node can be in at most one route
    [i in V, k in 1:n], sum(y[i, :]) <= 1
    #Demand and supply constraints
    [k in 1:n], sum(demands[:] .* y[:, k]) <= supply_capacity[k] #Check demands on each route do not exceed supply capacity of source
    [i in V], sum(y[i, :]) <= 1 #Each node on only one route
    #Constrain which source-demand combinations are allowed or not allowed
    [i in V, k in 1:n], y[i, k] * a[i, k] == 0
end)

#Lazy constraints
function subtour_elimination_callback(cb_data) #Not working
    status = callback_node_status(cb_data, model)
    if status != MOI.CALLBACK_NODE_STATUS_INTEGER
        return  # Only run at integer solutions
    end
#    x_all = callback_value.(cb_data, model[:x])
    x_all = callback_value.(Ref(cb_data), model[:x])
    #   m = callback_value.(cb_data, model[:n])
    m=3 #hard-coded for testing, bring in through callback
    for k = 1:m
       x_k = x_all[:,:,k] #Create a matrix based on x for each source node or k
       cycle = subtour(x_k)
       if !(1 < length(cycle) < n)
           return  # Only add a constraint if there is a cycle
       end
       println("Found cycle of length $(length(cycle))")
       S = [(i, j) for (i, j) in Iterators.product(cycle, cycle) if i < j]
       con = @build_constraint(
           sum(x_k[i, j, k] for (i, j) in S) <= length(cycle) - 1, #Modified to use x_k
       )
       MOI.submit(model, MOI.LazyConstraint(cb_data), con)
    end #k loop
    return
end
set_attribute(model, MOI.LazyConstraintCallback(), subtour_elimination_callback)

optimize!(model)

solution_summary(model)

#Print route results
results = result_count(model)
if results >= 1
for r in 1:results
    println("Result $r routes")
    for k in 1:n
        print("Route $k: $k")
    i = k
    while true
        for j in V
            if value(x[i, j, k];result=r) > 0.5
                print(" => $j")
                i = j
                break
            end
        end
        if i == k
            println()
            break
        end 
    end #while
end #for k
end #for r
end #if
println()