From e72831039cbc224de34a6968f0cd3c6c9d7fc5c1 Mon Sep 17 00:00:00 2001 From: Alinson S Xavier Date: Tue, 25 May 2021 08:09:40 -0500 Subject: [PATCH] Implement FileInstance --- src/MIPLearn.jl | 3 +- src/instance/file.jl | 68 +++++++-- src/instance/jump.jl | 142 +++++++++--------- src/solvers/learning.jl | 19 ++- test/instance/file_test.jl | 26 ++++ test/instance/jump_test.jl | 35 +++++ test/runtests.jl | 5 +- test/solvers/{jump.jl => jump_test.jl} | 0 .../solvers/{learning.jl => learning_test.jl} | 29 ---- 9 files changed, 208 insertions(+), 119 deletions(-) create mode 100644 test/instance/file_test.jl create mode 100644 test/instance/jump_test.jl rename test/solvers/{jump.jl => jump_test.jl} (100%) rename test/solvers/{learning.jl => learning_test.jl} (69%) diff --git a/src/MIPLearn.jl b/src/MIPLearn.jl index 8133d40..d705859 100644 --- a/src/MIPLearn.jl +++ b/src/MIPLearn.jl @@ -16,9 +16,10 @@ miplearn = pyimport("miplearn") include("utils/log.jl") include("utils/exceptions.jl") -include("instance/jump.jl") include("solvers/jump.jl") include("solvers/learning.jl") include("solvers/macros.jl") +include("instance/jump.jl") +include("instance/file.jl") end # module diff --git a/src/instance/file.jl b/src/instance/file.jl index a016405..36b2bc9 100644 --- a/src/instance/file.jl +++ b/src/instance/file.jl @@ -2,30 +2,66 @@ # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -struct FileInstance - filename::AbstractString - loaded::Union{Nothing,JuMPInstance} -end +@pydef mutable struct PyFileInstance <: miplearn.Instance + function __init__(self, filename) + self.filename = filename + self.loaded = nothing + self.samples = nothing + end -function FileInstance(filename::AbstractString)::FileInstance - return FileInstance( - filename, - nothing, - ) -end + function to_model(self) + return self.loaded.py.to_model() + end + + function get_instance_features(self) + return self.loaded.py.get_instance_features() + end + + function get_variable_features(self, var_name) + return self.loaded.py.get_variable_features(var_name) + end + + function get_variable_category(self, var_name) + return self.loaded.py.get_variable_category(var_name) + end + function get_constraint_features(self, cname) + return self.loaded.py.get_constraint_features(cname) + end -function load!(instance::FileInstance) - instance.loaded = load_jump_instance(instance.filename) + function get_constraint_category(self, cname) + return self.loaded.py.get_constraint_category(cname) + end + + function load(self) + if self.loaded === nothing + self.loaded = load_jump_instance(self.filename) + self.samples = self.loaded.py.samples + end + end + + function free(self) + self.loaded = nothing + self.samples = nothing + end + + function flush(self) + self.loaded.py.samples = self.samples + save(self.filename, self.loaded) + end end -function free!(instance::FileInstance) - instance.loaded = nothing +struct FileInstance <: Instance + py::PyCall.PyObject end -function flush!(instance::FileInstance) - save(instance.filename, instance.loaded) +function FileInstance(filename)::FileInstance + filename isa AbstractString || error("filename should be a string. Found $(typeof(filename)) instead.") + return FileInstance(PyFileInstance(filename)) end + + +export FileInstance diff --git a/src/instance/jump.jl b/src/instance/jump.jl index 6a08d2f..febfa34 100644 --- a/src/instance/jump.jl +++ b/src/instance/jump.jl @@ -47,7 +47,7 @@ using JLD2 end -struct JuMPInstance +struct JuMPInstance <: Instance py::PyCall.PyObject model::Model end @@ -63,80 +63,88 @@ end function save(filename::AbstractString, instance::JuMPInstance)::Nothing - # Convert JuMP model to MPS - mps_filename = "$(tempname()).mps.gz" - write_to_file(instance.model, mps_filename) - mps = read(mps_filename) - - # Pickle instance.py.samples. Ideally, we would use dumps and loads, but this - # causes some issues with PyCall, probably due to automatic type conversions. - py_samples_filename = tempname() - miplearn.write_pickle_gz(instance.py.samples, py_samples_filename) - py_samples = read(py_samples_filename) - - # Replace variable/constraint refs by names - _to_names(d) = Dict(name(var) => value for (var, value) in d) - ext_original = instance.model.ext[:miplearn] - ext_names = Dict( - :variable_features => _to_names(ext_original[:variable_features]), - :variable_categories => _to_names(ext_original[:variable_categories]), - :constraint_features => _to_names(ext_original[:constraint_features]), - :constraint_categories => _to_names(ext_original[:constraint_categories]), - :instance_features => ext_original[:instance_features], - ) - - # Generate JLD2 file - jldsave( - filename; - miplearn_version=0.2, - mps=mps, - ext=ext_names, - py_samples=py_samples, - ) - return -end - - -function load_jump_instance(filename::AbstractString)::JuMPInstance - jldopen(filename, "r") do file - file["miplearn_version"] == 0.2 || error( - "MIPLearn version 0.2 cannot read instance files generated by " * - "version $(file["miplearn_version"])." - ) - - # Convert MPS to JuMP + @info "Writing: $filename" + time = @elapsed begin + # Convert JuMP model to MPS mps_filename = "$(tempname()).mps.gz" - write(mps_filename, file["mps"]) - model = read_from_file(mps_filename) + write_to_file(instance.model, mps_filename) + mps = read(mps_filename) - # Unpickle instance.py.samples + # Pickle instance.py.samples. Ideally, we would use dumps and loads, but this + # causes some issues with PyCall, probably due to automatic type conversions. py_samples_filename = tempname() - write(py_samples_filename, file["py_samples"]) - py_samples = miplearn.read_pickle_gz(py_samples_filename) - - # Replace variable/constraint names by refs - _to_var(model, d) = Dict( - variable_by_name(model, varname) => value - for (varname, value) in d + miplearn.write_pickle_gz(instance.py.samples, py_samples_filename, quiet=true) + py_samples = read(py_samples_filename) + + # Replace variable/constraint refs by names + _to_names(d) = Dict(name(var) => value for (var, value) in d) + ext_original = instance.model.ext[:miplearn] + ext_names = Dict( + :variable_features => _to_names(ext_original[:variable_features]), + :variable_categories => _to_names(ext_original[:variable_categories]), + :constraint_features => _to_names(ext_original[:constraint_features]), + :constraint_categories => _to_names(ext_original[:constraint_categories]), + :instance_features => ext_original[:instance_features], ) - _to_constr(model, d) = Dict( - constraint_by_name(model, cname) => value - for (cname, value) in d - ) - ext = file["ext"] - model.ext[:miplearn] = Dict( - :variable_features => _to_var(model, ext[:variable_features]), - :variable_categories => _to_var(model, ext[:variable_categories]), - :constraint_features => _to_constr(model, ext[:constraint_features]), - :constraint_categories => _to_constr(model, ext[:constraint_categories]), - :instance_features => ext[:instance_features], + + # Generate JLD2 file + jldsave( + filename; + miplearn_version=0.2, + mps=mps, + ext=ext_names, + py_samples=py_samples, ) + end + @info @sprintf("File written in %.2f seconds", time) + return +end - instance = JuMPInstance(model) - instance.py.samples = py_samples - return instance +function load_jump_instance(filename::AbstractString)::JuMPInstance + @info "Reading: $filename" + instance = nothing + time = @elapsed begin + jldopen(filename, "r") do file + file["miplearn_version"] == 0.2 || error( + "MIPLearn version 0.2 cannot read instance files generated by " * + "version $(file["miplearn_version"])." + ) + + # Convert MPS to JuMP + mps_filename = "$(tempname()).mps.gz" + write(mps_filename, file["mps"]) + model = read_from_file(mps_filename) + + # Unpickle instance.py.samples + py_samples_filename = tempname() + write(py_samples_filename, file["py_samples"]) + py_samples = miplearn.read_pickle_gz(py_samples_filename, quiet=true) + + # Replace variable/constraint names by refs + _to_var(model, d) = Dict( + variable_by_name(model, varname) => value + for (varname, value) in d + ) + _to_constr(model, d) = Dict( + constraint_by_name(model, cname) => value + for (cname, value) in d + ) + ext = file["ext"] + model.ext[:miplearn] = Dict( + :variable_features => _to_var(model, ext[:variable_features]), + :variable_categories => _to_var(model, ext[:variable_categories]), + :constraint_features => _to_constr(model, ext[:constraint_features]), + :constraint_categories => _to_constr(model, ext[:constraint_categories]), + :instance_features => ext[:instance_features], + ) + + instance = JuMPInstance(model) + instance.py.samples = py_samples + end end + @info @sprintf("File read in %.2f seconds", time) + return instance end diff --git a/src/solvers/learning.jl b/src/solvers/learning.jl index d395b0d..94b0476 100644 --- a/src/solvers/learning.jl +++ b/src/solvers/learning.jl @@ -7,20 +7,31 @@ struct LearningSolver end +abstract type Instance +end + + function LearningSolver(optimizer_factory)::LearningSolver py = miplearn.LearningSolver(solver=JuMPSolver(optimizer_factory)) return LearningSolver(py) end -function solve!(solver::LearningSolver, instance::JuMPInstance) - return @python_call solver.py.solve(instance.py) +function solve!( + solver::LearningSolver, + instance::Instance; + tee::Bool = false, +) + return @python_call solver.py.solve(instance.py, tee=tee) end -function fit!(solver::LearningSolver, instances::Vector{JuMPInstance}) +function fit!(solver::LearningSolver, instances::Vector{<:Instance}) @python_call solver.py.fit([instance.py for instance in instances]) end -export LearningSolver, solve!, fit! +export Instance, + LearningSolver, + solve!, + fit! diff --git a/test/instance/file_test.jl b/test/instance/file_test.jl new file mode 100644 index 0000000..cf7031b --- /dev/null +++ b/test/instance/file_test.jl @@ -0,0 +1,26 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using JuMP +using MIPLearn +using Gurobi + +@testset "FileInstance" begin + @testset "solve" begin + model = Model() + @variable(model, x, Bin) + @variable(model, y, Bin) + @objective(model, Max, x + y) + instance = JuMPInstance(model) + filename = tempname() + save(filename, instance) + + file_instance = FileInstance(filename) + solver = LearningSolver(Gurobi.Optimizer) + solve!(solver, file_instance) + + loaded = load_jump_instance(filename) + @test length(loaded.py.samples) == 1 + end +end diff --git a/test/instance/jump_test.jl b/test/instance/jump_test.jl new file mode 100644 index 0000000..b47eb0f --- /dev/null +++ b/test/instance/jump_test.jl @@ -0,0 +1,35 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +@testset "JuMPInstance" begin + @testset "save and load" begin + # Create basic model + model = Model() + @variable(model, x, Bin) + @variable(model, y, Bin) + @objective(model, Max, x + y) + @feature(x, [1.0]) + @category(x, "cat1") + @feature(model, [5.0]) + + # Solve + instance = JuMPInstance(model) + solver = LearningSolver(Gurobi.Optimizer) + stats = solve!(solver, instance) + @test length(instance.py.samples) == 1 + + # Save model to file + filename = tempname() + save(filename, instance) + @test isfile(filename) + + # Read model from file + loaded = load_jump_instance(filename) + x = variable_by_name(loaded.model, "x") + @test loaded.model.ext[:miplearn][:variable_features][x] == [1.0] + @test loaded.model.ext[:miplearn][:variable_categories][x] == "cat1" + @test loaded.model.ext[:miplearn][:instance_features] == [5.0] + @test length(loaded.py.samples) == 1 + end +end \ No newline at end of file diff --git a/test/runtests.jl b/test/runtests.jl index a605ee5..a7fef64 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,6 +8,7 @@ using MIPLearn MIPLearn.setup_logger() @testset "MIPLearn" begin - include("solvers/jump.jl") - include("solvers/learning.jl") + include("solvers/jump_test.jl") + include("solvers/learning_test.jl") + include("instance/file_test.jl") end diff --git a/test/solvers/jump.jl b/test/solvers/jump_test.jl similarity index 100% rename from test/solvers/jump.jl rename to test/solvers/jump_test.jl diff --git a/test/solvers/learning.jl b/test/solvers/learning_test.jl similarity index 69% rename from test/solvers/learning.jl rename to test/solvers/learning_test.jl index 37c21f6..321deba 100644 --- a/test/solvers/learning.jl +++ b/test/solvers/learning_test.jl @@ -58,33 +58,4 @@ using Gurobi stats = solve!(solver, instance) end - @testset "file model" begin - # Create basic model - model = Model() - @variable(model, x, Bin) - @variable(model, y, Bin) - @objective(model, Max, x + y) - @feature(x, [1.0]) - @category(x, "cat1") - @feature(model, [5.0]) - - # Solve - instance = JuMPInstance(model) - solver = LearningSolver(Gurobi.Optimizer) - stats = solve!(solver, instance) - @test length(instance.py.samples) == 1 - - # Save model to file - filename = tempname() - save(filename, instance) - @test isfile(filename) - - # Read model from file - loaded = load_jump_instance(filename) - x = variable_by_name(loaded.model, "x") - @test loaded.model.ext[:miplearn][:variable_features][x] == [1.0] - @test loaded.model.ext[:miplearn][:variable_categories][x] == "cat1" - @test loaded.model.ext[:miplearn][:instance_features] == [5.0] - @test length(loaded.py.samples) == 1 - end end