I maintain a few large integer programming routines using the Python OR-Tools package. I have grown quite attached to the following code style, which breaks the compilation of the larger optimization problem into self-contained steps. Notice how the __init__
method serves as a “table of contents” for the overall compilation sequence:
(This isn’t a working example, just an illustration of style using a contrived problem class.)
from ortools.linear_solver import pywraplp
class BookshelfOptimizationProblem:
"""
A class for the bookshelf optimization problem, with input data a, b,
and c, and decision variables x, y, and z.
"""
# Global "config" options I don't want the user to mess with
SOLVER_NAME = "SCIP"
SOLVER_SHOW_OUTPUT = False
SOLVER_TIME_LIMIT = 600
def __init__(self, a, b, c) -> None:
"""
Initialize an instance of of the bookshelf optimization problem.
"""
self.a = a
self.b = b
self.c = c
self.validate_input_data()
self.initialize_solver()
self.generate_decision_variables()
self.generate_shelf_width_constraints()
self.generate_shelf_height_constraints()
self.generate_alphabetical_order_constraints()
self.generate_objective_function()
def solve(self, filename):
self.optimize()
self.validate_solution()
self.save_solution_as_csv(filename)
def validate_input_data(self):
"Assert that a, b, and c have correct dimensions, bounds, etc"
pass
def initialize_solver(self):
"Initialize the MILP solver."
self.milp_solver = pywraplp.Solver.CreateSolver(self.SOLVER_NAME)
self.milp_solver.SetTimeLimit(self.SOLVER_TIME_LIMIT * 1000)
def generate_decision_variables(self):
"Generate the decision variables, with sizes inferred from the input data."
self.x = [self.milp_solver.BoolVar(
f"x[{i}]") for i in range(len(self.a))]
# self.y = ...
# self.z = ...
self.some_helpful_expression = f(self.x, self.y, self.a, ...)
def generate_shelf_width_constraints(self):
self.milp_solver.Add(sum(self.x) <= sum(self.b))
def generate_shelf_height_constraints(self):
pass
def generate_alphabetical_order_constraints(self):
pass
def generate_objective_function(self):
"""
Construct the objective function. This docstring includes some information
about why this objective function was selected.
"""
self.milp_solver.Minimize(sum(self.x) - self.some_helpful_expression)
def optimize(self):
"Solve the MILP using the backend defined in `self.SOLVER_NAME`."
print(f"Solving problem using backend {self.SOLVER_NAME}.")
if self.SOLVER_SHOW_OUTPUT:
self.milp_solver.EnableOutput()
else:
self.milp_solver.SuppressOutput()
status = self.milp_solver.Solve()
return status
def validate_solution(self) -> None:
"Double check that the current solution satisfies certain derived properties."
pass
def save_solution_as_csv(self, filename):
pass
if __name__ == "__main__":
a = [1, 2, 3, 4, 5]
b = [1, 2, 3, 4, 5]
c = [1, 2, 3, 4, 5]
problem = BookshelfOptimizationProblem(a, b, c)
problem.solve("out.csv")
With JuMP and its dependence on macros such as @variable
, I find myself using a much more linear coding style, which makes it harder to isolate problems or swap out components. The above code might “translate” to (again, not a working example):
import JuMP, SCIP
# Global "config" options I don't want the user to mess with
const SOLVER_NAME::String = "SCIP"
const SOLVER_SHOW_OUTPUT::Bool = false
const SOLVER_TIME_LIMIT::Int = 600
"Assert that a, b, and c have correct dimensions, bounds, etc"
function isvalidinputdata(a, b, c)
return true
end
validatesolution(x, y, z) = ...
tocsv(x, y, z) = ...
function solvebookshelf(a, b, c, filename)
# validate_input_data
@assert isvalidinputdata(a, b, c)
# initialize_solver
model = Model(SCIP.Optimizer) # if SOLVER_NAME ...
if !SOLVER_SHOW_OUTPUT
set_silent(model)
end
# generate_decision_variables
@variable(model, x[1:length(a)], Bin)
@variable(model, y, ...)
@variable(model, z, ...)
@expression(model, some_helpful_expression, ...)
# generate_shelf_width_constraints
@constraint(model, ...)
@constraint(model, ...)
# generate_shelf_height_constraints
@constraint(model, ...)
@constraint(model, ...)
# generate_alphabetical_order_constraints
@constraint(model, ...)
# generate_objective_function
@objective(model, ...)
# solve/optimize
optimize!(model)
x_vec = value.(x)
y_vec = value.(y)
z_vec = value.(z)
# validate_solution
validatesolution(x_vec, y_vec, z_vec)
# save_solution_as_csv
tocsv(x_vec, y_vec, z_vec)
end
To me, the Julia code is much less ergonomic:
- Though I am able to extract functions at the beginning (
isvalidinputdata
) and at the end (validatesolution
,tocsv
) of the sequence, where I am working with “flat” vectors, the long blob of@constraint
in the middle is not very reader-friendly. I cannot see a way to break up this blob other than creating very long function signatures likegenerate_shelf_height_constraints(model, x, y, z, a, b, c, some_other_helper_expression, ...)
or abusing global scope. - The Python version makes it easier to separate my interaction (as developer) with the solver interface from the user (of my code)'s interaction with the problem class.
- In the Python version, if (say) during the
self.generate_shelf_height_constraints()
step I can spot an easy way to detect problem infeasibility early on, then I can just modify the type signature to return a booltrivial_infeasibility_detected
and handle it from the__init__
method. - In the Julia case, I have to bury all that logic right next to the constraint generation itself–so if the user clicks on a stacktrace saying “error at line 87,” instead of landing on a line that says
she is looking at 15 lines ofif (trivial_infeasibility_detected := self.generate_shelf_height_constraints()): raise RuntimeError
@constraint
followed by a cryptic arithmetic expression, and has to scroll up to see that this block of code is preceded by a comment explaining that this is thegenerate_shelf_height_constraints
step. - (I hope this explanation illustrates the point, but let me know if you need a more specific example.)
- In the Python version, if (say) during the
- The Python code plays nicely with code folding and navigation in common IDEs without having to manually define code regions.
- The Python code allows me to defer calling the
solve
method until the reader is ready (helpful in an interactive context where you want first test your data read-in and then start solving).
Do you have any suggestions for improving the legibility and usability of the Julia code along these lines?
(Please don’t just link me to this article )