From d69c4bbfa73e98bd77b3479c7e4ce0e6fbb24392 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 1 Feb 2024 12:01:55 -0600 Subject: [PATCH 01/34] Make cuts component compatible with JuMP --- Project.toml | 27 ++++----- src/MIPLearn.jl | 2 + src/components.jl | 22 +++++--- src/problems/setcover.jl | 5 +- src/problems/stab.jl | 60 ++++++++++++++++++++ src/solvers/jump.jl | 82 ++++++++++++++++++++++++++-- test/Project.toml | 1 + test/fixtures/bell5.h5 | Bin 89524 -> 95805 bytes test/fixtures/stab-n50-00000.h5 | Bin 0 -> 478661 bytes test/fixtures/stab-n50-00000.pkl.gz | Bin 0 -> 2870 bytes test/src/MIPLearnT.jl | 11 ++-- test/src/components/test_cuts.jl | 45 +++++++++++++++ test/src/fixtures.jl | 2 +- test/src/problems/test_setcover.jl | 2 +- test/src/problems/test_stab.jl | 27 +++++++++ test/src/test_usage.jl | 4 +- 16 files changed, 252 insertions(+), 38 deletions(-) create mode 100644 src/problems/stab.jl create mode 100644 test/fixtures/stab-n50-00000.h5 create mode 100644 test/fixtures/stab-n50-00000.pkl.gz create mode 100644 test/src/components/test_cuts.jl create mode 100644 test/src/problems/test_stab.jl diff --git a/Project.toml b/Project.toml index 9eae059..c439f7a 100644 --- a/Project.toml +++ b/Project.toml @@ -9,6 +9,7 @@ DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" HDF5 = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" KLU = "ef3ab10e-7fda-4108-b977-705223b18434" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" @@ -23,17 +24,17 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" [compat] +Conda = "1" +DataStructures = "0.18" +HDF5 = "0.16" +HiGHS = "1" +JLD2 = "0.4" +JuMP = "1" +KLU = "0.4" +MathOptInterface = "1" +OrderedCollections = "1" +PyCall = "1" +Requires = "1" +Statistics = "1" +TimerOutputs = "0.5" julia = "1" -Conda="1" -DataStructures="0.18" -HDF5="0.16" -HiGHS="1" -JLD2="0.4" -JuMP="1" -KLU="0.4" -MathOptInterface="1" -OrderedCollections="1" -PyCall="1" -Requires="1" -Statistics="1" -TimerOutputs="0.5" \ No newline at end of file diff --git a/src/MIPLearn.jl b/src/MIPLearn.jl index 110ad27..ed670ac 100644 --- a/src/MIPLearn.jl +++ b/src/MIPLearn.jl @@ -12,6 +12,7 @@ include("components.jl") include("extractors.jl") include("io.jl") include("problems/setcover.jl") +include("problems/stab.jl") include("solvers/jump.jl") include("solvers/learning.jl") @@ -21,6 +22,7 @@ function __init__() __init_extractors__() __init_io__() __init_problems_setcover__() + __init_problems_stab__() __init_solvers_jump__() __init_solvers_learning__() end diff --git a/src/components.jl b/src/components.jl index 73172bf..9dc2442 100644 --- a/src/components.jl +++ b/src/components.jl @@ -2,19 +2,21 @@ # Copyright (C) 2020-2023, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -global MinProbabilityClassifier = PyNULL() -global SingleClassFix = PyNULL() -global PrimalComponentAction = PyNULL() -global SetWarmStart = PyNULL() -global FixVariables = PyNULL() global EnforceProximity = PyNULL() global ExpertPrimalComponent = PyNULL() +global FixVariables = PyNULL() global IndependentVarsPrimalComponent = PyNULL() global JointVarsPrimalComponent = PyNULL() -global SolutionConstructor = PyNULL() +global MemorizingCutsComponent = PyNULL() +global MemorizingLazyComponent = PyNULL() global MemorizingPrimalComponent = PyNULL() -global SelectTopSolutions = PyNULL() global MergeTopSolutions = PyNULL() +global MinProbabilityClassifier = PyNULL() +global PrimalComponentAction = PyNULL() +global SelectTopSolutions = PyNULL() +global SetWarmStart = PyNULL() +global SingleClassFix = PyNULL() +global SolutionConstructor = PyNULL() function __init_components__() copy!( @@ -51,6 +53,8 @@ function __init_components__() ) copy!(SelectTopSolutions, pyimport("miplearn.components.primal.mem").SelectTopSolutions) copy!(MergeTopSolutions, pyimport("miplearn.components.primal.mem").MergeTopSolutions) + copy!(MemorizingCutsComponent, pyimport("miplearn.components.cuts.mem").MemorizingCutsComponent) + copy!(MemorizingLazyComponent, pyimport("miplearn.components.lazy.mem").MemorizingLazyComponent) end export MinProbabilityClassifier, @@ -65,4 +69,6 @@ export MinProbabilityClassifier, SolutionConstructor, MemorizingPrimalComponent, SelectTopSolutions, - MergeTopSolutions + MergeTopSolutions, + MemorizingCutsComponent, + MemorizingLazyComponent diff --git a/src/problems/setcover.jl b/src/problems/setcover.jl index fb44b63..9d03b6a 100644 --- a/src/problems/setcover.jl +++ b/src/problems/setcover.jl @@ -13,12 +13,11 @@ function __init_problems_setcover__() copy!(SetCoverGenerator, pyimport("miplearn.problems.setcover").SetCoverGenerator) end -function build_setcover_model(data::Any; optimizer = HiGHS.Optimizer) +function build_setcover_model_jump(data::Any; optimizer = HiGHS.Optimizer) if data isa String data = read_pkl_gz(data) end model = Model(optimizer) - set_silent(model) n_elements, n_sets = size(data.incidence_matrix) E = 0:n_elements-1 S = 0:n_sets-1 @@ -32,4 +31,4 @@ function build_setcover_model(data::Any; optimizer = HiGHS.Optimizer) return JumpModel(model) end -export SetCoverData, SetCoverGenerator, build_setcover_model +export SetCoverData, SetCoverGenerator, build_setcover_model_jump diff --git a/src/problems/stab.jl b/src/problems/stab.jl new file mode 100644 index 0000000..831b227 --- /dev/null +++ b/src/problems/stab.jl @@ -0,0 +1,60 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2024, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using JuMP +using HiGHS + +global MaxWeightStableSetData = PyNULL() +global MaxWeightStableSetGenerator = PyNULL() + +function __init_problems_stab__() + copy!(MaxWeightStableSetData, pyimport("miplearn.problems.stab").MaxWeightStableSetData) + copy!(MaxWeightStableSetGenerator, pyimport("miplearn.problems.stab").MaxWeightStableSetGenerator) +end + +function build_stab_model_jump(data::Any; optimizer=HiGHS.Optimizer) + nx = pyimport("networkx") + + if data isa String + data = read_pkl_gz(data) + end + model = Model(optimizer) + + # Variables and objective function + nodes = data.graph.nodes + x = @variable(model, x[nodes], Bin) + @objective(model, Min, sum(-data.weights[i+1] * x[i] for i in nodes)) + + # Edge inequalities + for (i1, i2) in data.graph.edges + @constraint(model, x[i1] + x[i2] <= 1, base_name = "eq_edge[$i1,$i2]") + end + + function cuts_separate(cb_data) + x_val = callback_value.(Ref(cb_data), x) + violations = [] + for clique in nx.find_cliques(data.graph) + if sum(x_val[i] for i in clique) > 1.0001 + push!(violations, sort(clique)) + end + end + return violations + end + + function cuts_enforce(violations) + @info "Adding $(length(violations)) clique cuts..." + for clique in violations + constr = @build_constraint(sum(x[i] for i in clique) <= 1) + submit(model, constr) + end + end + + return JumpModel( + model, + cuts_separate=cuts_separate, + cuts_enforce=cuts_enforce, + ) +end + +export MaxWeightStableSetData, MaxWeightStableSetGenerator, build_stab_model_jump diff --git a/src/solvers/jump.jl b/src/solvers/jump.jl index 726c449..b7bb2b2 100644 --- a/src/solvers/jump.jl +++ b/src/solvers/jump.jl @@ -4,9 +4,19 @@ using JuMP using HiGHS +using JSON global JumpModel = PyNULL() +Base.@kwdef mutable struct _JumpModelExtData + aot_cuts = nothing + cb_data = nothing + cuts = [] + where::Symbol = :WHERE_DEFAULT + cuts_enforce::Union{Function,Nothing} = nothing + cuts_separate::Union{Function,Nothing} = nothing +end + # ----------------------------------------------------------------------------- function _add_constrs( @@ -35,6 +45,15 @@ function _add_constrs( end end +function submit(model::JuMP.Model, constr) + ext = model.ext[:miplearn] + if ext.where == :WHERE_CUTS + MOI.submit(model, MOI.UserCut(ext.cb_data), constr) + else + error("not implemented") + end +end + function _extract_after_load(model::JuMP.Model, h5) if JuMP.objective_sense(model) == MOI.MIN_SENSE h5.put_scalar("static_sense", "min") @@ -109,6 +128,9 @@ function _extract_after_load_constrs(model::JuMP.Model, h5) end end end + if isempty(names) + error("no model constraints found; note that MIPLearn ignores unnamed constraints") + end lhs = sparse(lhs_rows, lhs_cols, lhs_values, length(rhs), JuMP.num_variables(model)) h5.put_sparse("static_constr_lhs", lhs) h5.put_array("static_constr_rhs", rhs) @@ -249,17 +271,50 @@ function _extract_after_mip(model::JuMP.Model, h5) rhs = h5.get_array("static_constr_rhs") slacks = abs.(lhs * x - rhs) h5.put_array("mip_constr_slacks", slacks) + + # Cuts + ext = model.ext[:miplearn] + h5.put_scalar("mip_cuts", JSON.json(ext.cuts)) end function _fix_variables(model::JuMP.Model, var_names, var_values, stats) vars = [variable_by_name(model, v) for v in var_names] for (i, var) in enumerate(vars) - fix(var, var_values[i], force = true) + fix(var, var_values[i], force=true) end end function _optimize(model::JuMP.Model) + # Set up cut callbacks + ext = model.ext[:miplearn] + ext.cuts = [] + function cut_callback(cb_data) + ext.cb_data = cb_data + ext.where = :WHERE_CUTS + if ext.aot_cuts !== nothing + @info "Enforcing $(length(ext.aot_cuts)) cuts ahead-of-time..." + violations = ext.aot_cuts + ext.aot_cuts = nothing + else + violations = ext.cuts_separate(cb_data) + for v in violations + push!(ext.cuts, v) + end + end + if !isempty(violations) + ext.cuts_enforce(violations) + end + end + if ext.cuts_separate !== nothing + set_attribute(model, MOI.UserCutCallback(), cut_callback) + end + + # Optimize + ext.where = :WHERE_DEFAULT optimize!(model) + + # Cleanup + ext.cb_data = nothing flush(stdout) Libc.flush_cstdio() end @@ -291,10 +346,21 @@ end # ----------------------------------------------------------------------------- function __init_solvers_jump__() - @pydef mutable struct Class + AbstractModel = pyimport("miplearn.solvers.abstract").AbstractModel + @pydef mutable struct Class <: AbstractModel - function __init__(self, inner) + function __init__( + self, + inner; + cuts_enforce::Union{Function,Nothing}=nothing, + cuts_separate::Union{Function,Nothing}=nothing, + ) + AbstractModel.__init__(self) self.inner = inner + self.inner.ext[:miplearn] = _JumpModelExtData( + cuts_enforce=cuts_enforce, + cuts_separate=cuts_separate, + ) end add_constrs( @@ -303,7 +369,7 @@ function __init_solvers_jump__() constrs_lhs, constrs_sense, constrs_rhs, - stats = nothing, + stats=nothing, ) = _add_constrs( self.inner, from_str_array(var_names), @@ -319,17 +385,21 @@ function __init_solvers_jump__() extract_after_mip(self, h5) = _extract_after_mip(self.inner, h5) - fix_variables(self, var_names, var_values, stats = nothing) = + fix_variables(self, var_names, var_values, stats=nothing) = _fix_variables(self.inner, from_str_array(var_names), var_values, stats) optimize(self) = _optimize(self.inner) relax(self) = Class(_relax(self.inner)) - set_warm_starts(self, var_names, var_values, stats = nothing) = + set_warm_starts(self, var_names, var_values, stats=nothing) = _set_warm_starts(self.inner, from_str_array(var_names), var_values, stats) write(self, filename) = _write(self.inner, filename) + + function set_cuts(self, cuts) + self.inner.ext[:miplearn].aot_cuts = cuts + end end copy!(JumpModel, Class) end diff --git a/test/Project.toml b/test/Project.toml index dce63f3..0854405 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -15,6 +15,7 @@ Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" MIPLearn = "2b1277c3-b477-4c49-a15e-7ba350325c68" PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" +SCIP = "82193955-e24f-5292-bf16-6f2c5261a85f" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] diff --git a/test/fixtures/bell5.h5 b/test/fixtures/bell5.h5 index 73bc778f4007263c2ff54c24e0451b146975f006..afff1ab29a3dfcea40cfeb412477c6ea0b12d688 100644 GIT binary patch delta 414 zcmdn8n|1FS)(J9dwq=YAAYcF{85rg!sQ>c!a0y~k_`iF1s^DX>L=ZjEsg`B24)6NO z^SKN+eo<{*$~P3H!3|R z@9$Nd{G;8H`7u!N1Lta%8_Wzo+ZW0(USOUaU(GssM^6#+gB-@mwMu;~DMvXc0@bH6 zJC{HV*x)GhaP#TD`MVe;CV!mTI{lFiqx9s6sZ*v;v}K$#xntUc?b1Gsb{x#f2{`1Z z1Lb8mw@zQkxZNzAv4v&2M-Zclaz)&0gQp*6Xq6uKwSKr_xu0qK^_}5+-$in^#R>XM z-s832pmO^}JI2|J)6+Z|m!epRFmU?6V8+PpGJcE+EYriH8B?Z*2QjJ=Z}fIxg`Kj4_p5)wY&bMpSyG-hruh=AmhN?V{n6#sOnfgoxLRPtDF{_V;8 zdle`DXt!jx_GX;?fpayB3)d3K?F(fXFECGzuV$URqo;^DJcMy_tx_M0k5REZP<H)k>GGHti;VN~SU{wRkrniBx4pH1-q diff --git a/test/fixtures/stab-n50-00000.h5 b/test/fixtures/stab-n50-00000.h5 new file mode 100644 index 0000000000000000000000000000000000000000..f8ae28a6fdb40dfcf6aca54053e8f4543b6d85fe GIT binary patch literal 478661 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�%ja8Xb}U?0(EkA_VK}fDER!TG%ArWM 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 K|KI=7fBpwhTCUUp literal 0 HcmV?d00001 diff --git a/test/fixtures/stab-n50-00000.pkl.gz b/test/fixtures/stab-n50-00000.pkl.gz new file mode 100644 index 0000000000000000000000000000000000000000..19bddd85d87b5244c344dc690cc5305b9a4c3839 GIT binary patch literal 2870 zcmV-63(52!iwFq1*Slo`|8sOe+ zZE2fy(*k-G6*RITP^KV=l(bC=FI}XGP^2s=GXl~N1g@Yg7gtftT1jAfgZ*5uFgx zh|Y)@L@Xi>5s&DCNI-N&bVDQ}k`T#=?uZ_U6htZ_4bc;kj>te{B6=aR5ZQPV&aNgtoh{AJV`MRN z6t>s(O4IV&xfRTzM1vE0_r}ikj?JCvUCB1kkZZ6~G0q~!&{I+nuZ`a3_^bOXd*z_> z+f|D*OfT6mtqcRFc^aiUV}z_0>YA=x=N$uX?p&St*j0uC#}_ysDbc zs8=*kPxsWkNq@67s<>2|VUKX#V2^a&p_F0`JKfdfNRJ>B%<=C?o?qp(=|ypRL)uL( z)})|UH=mG9e|5bVy@x>cj%v?cSJ}f|tI@-Ldk5E3O38R8jk}>w)a+7;tk#Lu=Aqge zRJ+q2<$A~7(e+I`Zu99jx)JQ;7`*q&Leh<)@Vy*L^P#jBDo5>|L`Y~zh*}+e$mUhf zGAv=Cag{rBkTWsJ?e=KVO%@6^$CNW{ORiyl#w}3}$FR5qv`rv=7o?%OK!Rax#)6hJ zTwd)8$Fgt*sCU5ZyD++^A@Cjq!ju&vOQslcu1w~8HqqdIxoPCDpR8AZO(XU2J6Tm%M3YSB+ozZ5 z9aA?hr6yt+2>LA7)n}MYk^GZriBgmE*Eehk(eW~Emf$w4l}+7bzi`uhRb5n zB8jvkY0Na462!GKncwzxXnlza4R1>U!6XWff-egpb}u|R1+fU0zk+>SEmN|8k<6zc z`z)Uz0tYaeGIsRkL|^M6^>6g`BK-P4mU%GK2kbcq%R8)OQh}fc`7cKaPcp;t=w=S9X%rlVmXwZY071 zj^rtt@)NZ?NbNL{D3kfqs=nt1rUM*6N|#7cu1_F5n$ws`{RZkUk*S-wjlxf}=`X1N zGxei%;jAEn=8Ia1W^f*3;l*ssV&kGuHXDU_D zkYzMv7oB*BPCQ2^zNQnu(TPrKX~lV@u<7#povBtRhbvTru9ssR_-)&DGuG(8Xg7_W zHB$B(Hn^GUySX37Df^AOxkM73??t^!mGfe<+F~(7reIfS;n{xIw9t@=AgqNEF?9YS zGVI~1zD1Nzi1IBR_?-?!)A=!EC|3@MN}0_52PCbOoCFhI!&&+avawvqu^dAsZSl~L z>pAc{IELqm^fn>>;Cad7aW3XG&Zj$r=*|=(Rq05@#^aq!l*=%aTYwSA^(&?c0|+&a zP*sHT5^4(#+Do?m+?0bve1VAnqd}cHvTP#uCfi_{3eI+r^GG%s8?y;?x5BK~u`xYK z*qej{NjRQpSCQ#vGTlj-7YXwYNBAji`;%xf^s7HdSVp6qGPV1UgM5Cp+>G<`Fy{3+ z&dhHRznH!_xxCA1!NXL2i8>+db_geNIR&cKo{9#UtYV(-M3H$m1(dxW-QNc3Ly$fJ z>E9uJ3HzQv=@x3f%=P?^(xG9h#aOzZL-$MA=~(hi<<8C|)+}Ppl_^&&RO=#E$yC@z z=9OQ_xl94s7LeTzvPUqNbPmKuu!#g)!Cl$Pf!sxh4w3d1&gCefzvuBzCas0Er5w)% zG;t`|$B}&+pDc0!|JZ-6pcoo$F`cW*@8(8^xE>sD@Cvm3LI7nMgGaphAjRdyQhao6&gad|$cbK!KZD`C?Jn@tgNqV^tip19<5dL zMXDESzNoq?zNA&NhqP#eLo2kE+639Uy-48E-|&U*vm6w8k0$0M??N#Q*>R literal 0 HcmV?d00001 diff --git a/test/src/MIPLearnT.jl b/test/src/MIPLearnT.jl index b14b883..e3524bb 100644 --- a/test/src/MIPLearnT.jl +++ b/test/src/MIPLearnT.jl @@ -16,10 +16,12 @@ FIXTURES = "$BASEDIR/../fixtures" include("fixtures.jl") include("BB/test_bb.jl") +include("components/test_cuts.jl") include("Cuts/BlackBox/test_cplex.jl") include("problems/test_setcover.jl") -include("test_io.jl") +include("problems/test_stab.jl") include("solvers/test_jump.jl") +include("test_io.jl") include("test_usage.jl") function runtests() @@ -27,17 +29,18 @@ function runtests() @testset "BB" begin test_bb() end - # test_cuts_blackbox_cplex() test_io() test_problems_setcover() + test_problems_stab() test_solvers_jump() test_usage() + test_cuts() end end function format() - JuliaFormatter.format(BASEDIR, verbose = true) - JuliaFormatter.format("$BASEDIR/../../src", verbose = true) + JuliaFormatter.format(BASEDIR, verbose=true) + JuliaFormatter.format("$BASEDIR/../../src", verbose=true) return end diff --git a/test/src/components/test_cuts.jl b/test/src/components/test_cuts.jl new file mode 100644 index 0000000..1562648 --- /dev/null +++ b/test/src/components/test_cuts.jl @@ -0,0 +1,45 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2024, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using SCIP + +function gen_stab() + np = pyimport("numpy") + uniform = pyimport("scipy.stats").uniform + randint = pyimport("scipy.stats").randint + np.random.seed(42) + gen = MaxWeightStableSetGenerator( + w=uniform(10.0, scale=1.0), + n=randint(low=50, high=51), + p=uniform(loc=0.5, scale=0.0), + fix_graph=true, + ) + data = gen.generate(1) + data_filenames = write_pkl_gz(data, "$BASEDIR/../fixtures", prefix="stab-n50-") + collector = BasicCollector(write_mps=false) + collector.collect( + data_filenames, + data -> build_stab_model_jump(data, optimizer=SCIP.Optimizer), + progress=true, + verbose=true, + ) +end + +function test_cuts() + data_filenames = ["$BASEDIR/../fixtures/stab-n50-0000$i.pkl.gz" for i in 0:0] + clf = pyimport("sklearn.neighbors").KNeighborsClassifier(n_neighbors=1) + extractor = H5FieldsExtractor( + instance_fields=["static_var_obj_coeffs"], + ) + comp = MemorizingCutsComponent(clf=clf, extractor=extractor) + solver = LearningSolver(components=[comp]) + solver.fit(data_filenames) + @show comp.n_features_ + @show comp.n_targets_ + stats = solver.optimize( + data_filenames[1], + data -> build_stab_model_jump(data, optimizer=SCIP.Optimizer), + ) + @test stats["Cuts: AOT"] > 0 +end diff --git a/test/src/fixtures.jl b/test/src/fixtures.jl index 56f4dec..b7cc084 100644 --- a/test/src/fixtures.jl +++ b/test/src/fixtures.jl @@ -14,5 +14,5 @@ function fixture_setcover_data() end function fixture_setcover_model() - return build_setcover_model(fixture_setcover_data()) + return build_setcover_model_jump(fixture_setcover_data()) end diff --git a/test/src/problems/test_setcover.jl b/test/src/problems/test_setcover.jl index 5f85418..9b92f19 100644 --- a/test/src/problems/test_setcover.jl +++ b/test/src/problems/test_setcover.jl @@ -51,7 +51,7 @@ function test_problems_setcover_model() ) h5 = H5File(tempname(), "w") - model = build_setcover_model(data) + model = build_setcover_model_jump(data) model.extract_after_load(h5) model.optimize() model.extract_after_mip(h5) diff --git a/test/src/problems/test_stab.jl b/test/src/problems/test_stab.jl new file mode 100644 index 0000000..60f27b3 --- /dev/null +++ b/test/src/problems/test_stab.jl @@ -0,0 +1,27 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2024, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using PyCall +using SCIP + +function test_problems_stab() + test_problems_stab_1() + test_problems_stab_2() +end + +function test_problems_stab_1() + nx = pyimport("networkx") + data = MaxWeightStableSetData( + graph=nx.gnp_random_graph(25, 0.5, seed=42), + weights=repeat([1.0], 25), + ) + h5 = H5File(tempname(), "w") + model = build_stab_model_jump(data, optimizer=SCIP.Optimizer) + model.extract_after_load(h5) + model.optimize() + model.extract_after_mip(h5) + @test h5.get_scalar("mip_obj_value") == -6 + @test h5.get_scalar("mip_cuts")[1:20] == "[[0,8,11,13],[0,8,13" + h5.close() +end diff --git a/test/src/test_usage.jl b/test/src/test_usage.jl index f02ff65..9a8cb7b 100644 --- a/test/src/test_usage.jl +++ b/test/src/test_usage.jl @@ -29,13 +29,13 @@ function test_usage() @debug "Collecting training data..." bc = BasicCollector() - bc.collect(data_filenames, build_setcover_model) + bc.collect(data_filenames, build_setcover_model_jump) @debug "Training models..." solver.fit(data_filenames) @debug "Solving model..." - solver.optimize(data_filenames[1], build_setcover_model) + solver.optimize(data_filenames[1], build_setcover_model_jump) @debug "Checking solution..." h5 = H5File(h5_filenames[1]) From 4d5b7e971c013f5ba22cbd5c24f20a924f0feb6a Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 1 Feb 2024 13:13:10 -0600 Subject: [PATCH 02/34] Minor fixes --- src/solvers/jump.jl | 9 +++++++++ test/src/components/test_cuts.jl | 4 +--- test/src/problems/test_stab.jl | 5 ----- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/solvers/jump.jl b/src/solvers/jump.jl index b7bb2b2..9a84d02 100644 --- a/src/solvers/jump.jl +++ b/src/solvers/jump.jl @@ -17,6 +17,15 @@ Base.@kwdef mutable struct _JumpModelExtData cuts_separate::Union{Function,Nothing} = nothing end +function JuMP.copy_extension_data( + ::_JumpModelExtData, + new_model::AbstractModel, + ::AbstractModel, +) + # Do not transfer any extension data to the new model + new_model.ext[:miplearn] = _JumpModelExtData() +end + # ----------------------------------------------------------------------------- function _add_constrs( diff --git a/test/src/components/test_cuts.jl b/test/src/components/test_cuts.jl index 1562648..f466732 100644 --- a/test/src/components/test_cuts.jl +++ b/test/src/components/test_cuts.jl @@ -28,15 +28,13 @@ end function test_cuts() data_filenames = ["$BASEDIR/../fixtures/stab-n50-0000$i.pkl.gz" for i in 0:0] - clf = pyimport("sklearn.neighbors").KNeighborsClassifier(n_neighbors=1) + clf = pyimport("sklearn.dummy").DummyClassifier() extractor = H5FieldsExtractor( instance_fields=["static_var_obj_coeffs"], ) comp = MemorizingCutsComponent(clf=clf, extractor=extractor) solver = LearningSolver(components=[comp]) solver.fit(data_filenames) - @show comp.n_features_ - @show comp.n_targets_ stats = solver.optimize( data_filenames[1], data -> build_stab_model_jump(data, optimizer=SCIP.Optimizer), diff --git a/test/src/problems/test_stab.jl b/test/src/problems/test_stab.jl index 60f27b3..7a11a56 100644 --- a/test/src/problems/test_stab.jl +++ b/test/src/problems/test_stab.jl @@ -6,11 +6,6 @@ using PyCall using SCIP function test_problems_stab() - test_problems_stab_1() - test_problems_stab_2() -end - -function test_problems_stab_1() nx = pyimport("networkx") data = MaxWeightStableSetData( graph=nx.gnp_random_graph(25, 0.5, seed=42), From 190c28820342eb009bb5b102c50b42b10b062ce0 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 1 Feb 2024 16:56:45 -0600 Subject: [PATCH 03/34] Make lazy constraints compatible with JuMP --- src/MIPLearn.jl | 2 + src/problems/tsp.jl | 71 +++++++++++++++++++++++++++++ src/solvers/jump.jl | 36 +++++++++++++-- test/Project.toml | 1 + test/fixtures/tsp-n20-00000.h5 | Bin 0 -> 62978 bytes test/fixtures/tsp-n20-00000.pkl.gz | Bin 0 -> 1145 bytes test/src/MIPLearnT.jl | 3 ++ test/src/components/test_cuts.jl | 2 +- test/src/components/test_lazy.jl | 46 +++++++++++++++++++ test/src/problems/test_tsp.jl | 27 +++++++++++ test/src/test_usage.jl | 14 +++--- 11 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 src/problems/tsp.jl create mode 100644 test/fixtures/tsp-n20-00000.h5 create mode 100644 test/fixtures/tsp-n20-00000.pkl.gz create mode 100644 test/src/components/test_lazy.jl create mode 100644 test/src/problems/test_tsp.jl diff --git a/src/MIPLearn.jl b/src/MIPLearn.jl index ed670ac..5034474 100644 --- a/src/MIPLearn.jl +++ b/src/MIPLearn.jl @@ -13,6 +13,7 @@ include("extractors.jl") include("io.jl") include("problems/setcover.jl") include("problems/stab.jl") +include("problems/tsp.jl") include("solvers/jump.jl") include("solvers/learning.jl") @@ -23,6 +24,7 @@ function __init__() __init_io__() __init_problems_setcover__() __init_problems_stab__() + __init_problems_tsp__() __init_solvers_jump__() __init_solvers_learning__() end diff --git a/src/problems/tsp.jl b/src/problems/tsp.jl new file mode 100644 index 0000000..3f86b5b --- /dev/null +++ b/src/problems/tsp.jl @@ -0,0 +1,71 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2024, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using JuMP + +global TravelingSalesmanData = PyNULL() +global TravelingSalesmanGenerator = PyNULL() + +function __init_problems_tsp__() + copy!(TravelingSalesmanData, pyimport("miplearn.problems.tsp").TravelingSalesmanData) + copy!(TravelingSalesmanGenerator, pyimport("miplearn.problems.tsp").TravelingSalesmanGenerator) +end + +function build_tsp_model_jump(data::Any; optimizer=HiGHS.Optimizer) + nx = pyimport("networkx") + + if data isa String + data = read_pkl_gz(data) + end + model = Model(optimizer) + edges = [(i, j) for i in 1:data.n_cities for j in (i+1):data.n_cities] + x = @variable(model, x[edges], Bin) + @objective(model, Min, sum( + x[(i, j)] * data.distances[i, j] for (i, j) in edges + )) + + # Eq: Must choose two edges adjacent to each node + @constraint( + model, + eq_degree[i in 1:data.n_cities], + sum(x[(min(i, j), max(i, j))] for j in 1:data.n_cities if i != j) == 2 + ) + + function lazy_separate(cb_data) + x_val = callback_value.(Ref(cb_data), x) + violations = [] + selected_edges = [e for e in edges if x_val[e] > 0.5] + graph = nx.Graph() + graph.add_edges_from(selected_edges) + for component in nx.connected_components(graph) + if length(component) < data.n_cities + cut_edges = [ + [e[1], e[2]] + for e in edges + if (e[1] ∈ component && e[2] ∉ component) + || + (e[1] ∉ component && e[2] ∈ component) + ] + push!(violations, cut_edges) + end + end + return violations + end + + function lazy_enforce(violations) + @info "Adding $(length(violations)) subtour elimination eqs..." + for violation in violations + constr = @build_constraint(sum(x[(e[1], e[2])] for e in violation) >= 2) + submit(model, constr) + end + end + + return JumpModel( + model, + lazy_enforce=lazy_enforce, + lazy_separate=lazy_separate, + ) +end + +export TravelingSalesmanData, TravelingSalesmanGenerator, build_tsp_model_jump diff --git a/src/solvers/jump.jl b/src/solvers/jump.jl index 9a84d02..6c45a4e 100644 --- a/src/solvers/jump.jl +++ b/src/solvers/jump.jl @@ -12,9 +12,12 @@ Base.@kwdef mutable struct _JumpModelExtData aot_cuts = nothing cb_data = nothing cuts = [] + lazy = [] where::Symbol = :WHERE_DEFAULT cuts_enforce::Union{Function,Nothing} = nothing cuts_separate::Union{Function,Nothing} = nothing + lazy_enforce::Union{Function,Nothing} = nothing + lazy_separate::Union{Function,Nothing} = nothing end function JuMP.copy_extension_data( @@ -58,8 +61,10 @@ function submit(model::JuMP.Model, constr) ext = model.ext[:miplearn] if ext.where == :WHERE_CUTS MOI.submit(model, MOI.UserCut(ext.cb_data), constr) + elseif ext.where == :WHERE_LAZY + MOI.submit(model, MOI.LazyConstraint(ext.cb_data), constr) else - error("not implemented") + add_constraint(model, constr) end end @@ -281,9 +286,10 @@ function _extract_after_mip(model::JuMP.Model, h5) slacks = abs.(lhs * x - rhs) h5.put_array("mip_constr_slacks", slacks) - # Cuts + # Cuts and lazy constraints ext = model.ext[:miplearn] h5.put_scalar("mip_cuts", JSON.json(ext.cuts)) + h5.put_scalar("mip_lazy", JSON.json(ext.lazy)) end function _fix_variables(model::JuMP.Model, var_names, var_values, stats) @@ -318,6 +324,23 @@ function _optimize(model::JuMP.Model) set_attribute(model, MOI.UserCutCallback(), cut_callback) end + # Set up lazy constraint callbacks + ext.lazy = [] + function lazy_callback(cb_data) + ext.cb_data = cb_data + ext.where = :WHERE_LAZY + violations = ext.lazy_separate(cb_data) + for v in violations + push!(ext.lazy, v) + end + if !isempty(violations) + ext.lazy_enforce(violations) + end + end + if ext.lazy_separate !== nothing + set_attribute(model, MOI.LazyConstraintCallback(), lazy_callback) + end + # Optimize ext.where = :WHERE_DEFAULT optimize!(model) @@ -363,12 +386,15 @@ function __init_solvers_jump__() inner; cuts_enforce::Union{Function,Nothing}=nothing, cuts_separate::Union{Function,Nothing}=nothing, + lazy_enforce::Union{Function,Nothing}=nothing, + lazy_separate::Union{Function,Nothing}=nothing, ) - AbstractModel.__init__(self) self.inner = inner self.inner.ext[:miplearn] = _JumpModelExtData( cuts_enforce=cuts_enforce, cuts_separate=cuts_separate, + lazy_enforce=lazy_enforce, + lazy_separate=lazy_separate, ) end @@ -409,6 +435,10 @@ function __init_solvers_jump__() function set_cuts(self, cuts) self.inner.ext[:miplearn].aot_cuts = cuts end + + function lazy_enforce(self, model, violations) + self.inner.ext[:miplearn].lazy_enforce(violations) + end end copy!(JumpModel, Class) end diff --git a/test/Project.toml b/test/Project.toml index 0854405..e5abcbf 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -5,6 +5,7 @@ version = "0.1.0" [deps] Clp = "e2554f3b-3117-50c0-817c-e040a3ddf72d" +GLPK = "60bf3e95-4087-53dc-ae20-288a0d20c6a6" Glob = "c27321d9-0574-5035-807b-f59d2c89b15c" HDF5 = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" diff --git a/test/fixtures/tsp-n20-00000.h5 b/test/fixtures/tsp-n20-00000.h5 new file mode 100644 index 0000000000000000000000000000000000000000..2539134913d20664f0b3cc8caacd9ea7ee0acd1a GIT binary patch literal 62978 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 literal 0 HcmV?d00001 diff --git a/test/src/MIPLearnT.jl b/test/src/MIPLearnT.jl index e3524bb..df7799f 100644 --- a/test/src/MIPLearnT.jl +++ b/test/src/MIPLearnT.jl @@ -17,9 +17,11 @@ include("fixtures.jl") include("BB/test_bb.jl") include("components/test_cuts.jl") +include("components/test_lazy.jl") include("Cuts/BlackBox/test_cplex.jl") include("problems/test_setcover.jl") include("problems/test_stab.jl") +include("problems/test_tsp.jl") include("solvers/test_jump.jl") include("test_io.jl") include("test_usage.jl") @@ -32,6 +34,7 @@ function runtests() test_io() test_problems_setcover() test_problems_stab() + test_problems_tsp() test_solvers_jump() test_usage() test_cuts() diff --git a/test/src/components/test_cuts.jl b/test/src/components/test_cuts.jl index f466732..f9cccd9 100644 --- a/test/src/components/test_cuts.jl +++ b/test/src/components/test_cuts.jl @@ -27,7 +27,7 @@ function gen_stab() end function test_cuts() - data_filenames = ["$BASEDIR/../fixtures/stab-n50-0000$i.pkl.gz" for i in 0:0] + data_filenames = ["$BASEDIR/../fixtures/stab-n50-00000.pkl.gz"] clf = pyimport("sklearn.dummy").DummyClassifier() extractor = H5FieldsExtractor( instance_fields=["static_var_obj_coeffs"], diff --git a/test/src/components/test_lazy.jl b/test/src/components/test_lazy.jl new file mode 100644 index 0000000..291b5fa --- /dev/null +++ b/test/src/components/test_lazy.jl @@ -0,0 +1,46 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2024, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using GLPK + +function gen_tsp() + np = pyimport("numpy") + uniform = pyimport("scipy.stats").uniform + randint = pyimport("scipy.stats").randint + np.random.seed(42) + + gen = TravelingSalesmanGenerator( + x=uniform(loc=0.0, scale=1000.0), + y=uniform(loc=0.0, scale=1000.0), + n=randint(low=20, high=21), + gamma=uniform(loc=1.0, scale=0.25), + fix_cities=true, + round=true, + ) + data = gen.generate(1) + data_filenames = write_pkl_gz(data, "$BASEDIR/../fixtures", prefix="tsp-n20-") + collector = BasicCollector(write_mps=false) + collector.collect( + data_filenames, + data -> build_tsp_model_jump(data, optimizer=GLPK.Optimizer), + progress=true, + verbose=true, + ) +end + +function test_lazy() + data_filenames = ["$BASEDIR/../fixtures/tsp-n20-00000.pkl.gz"] + clf = pyimport("sklearn.dummy").DummyClassifier() + extractor = H5FieldsExtractor( + instance_fields=["static_var_obj_coeffs"], + ) + comp = MemorizingLazyComponent(clf=clf, extractor=extractor) + solver = LearningSolver(components=[comp]) + solver.fit(data_filenames) + stats = solver.optimize( + data_filenames[1], + data -> build_tsp_model_jump(data, optimizer=GLPK.Optimizer), + ) + @test stats["Lazy Constraints: AOT"] > 0 +end diff --git a/test/src/problems/test_tsp.jl b/test/src/problems/test_tsp.jl new file mode 100644 index 0000000..b56c01f --- /dev/null +++ b/test/src/problems/test_tsp.jl @@ -0,0 +1,27 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2024, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using GLPK +using JuMP + +function test_problems_tsp() + pdist = pyimport("scipy.spatial.distance").pdist + squareform = pyimport("scipy.spatial.distance").squareform + + data = TravelingSalesmanData( + n_cities=6, + distances=squareform(pdist([ + [0.0, 0.0], + [1.0, 0.0], + [2.0, 0.0], + [3.0, 0.0], + [0.0, 1.0], + [3.0, 1.0], + ])), + ) + model = build_tsp_model_jump(data, optimizer=GLPK.Optimizer) + model.optimize() + @test objective_value(model.inner) == 8.0 + return +end diff --git a/test/src/test_usage.jl b/test/src/test_usage.jl index 9a8cb7b..3e41332 100644 --- a/test/src/test_usage.jl +++ b/test/src/test_usage.jl @@ -13,22 +13,22 @@ function test_usage() @debug "Setting up LearningSolver..." solver = LearningSolver( - components = [ + components=[ IndependentVarsPrimalComponent( - base_clf = SingleClassFix( + base_clf=SingleClassFix( MinProbabilityClassifier( - base_clf = LogisticRegression(), - thresholds = [0.95, 0.95], + base_clf=LogisticRegression(), + thresholds=[0.95, 0.95], ), ), - extractor = AlvLouWeh2017Extractor(), - action = SetWarmStart(), + extractor=AlvLouWeh2017Extractor(), + action=SetWarmStart(), ), ], ) @debug "Collecting training data..." - bc = BasicCollector() + bc = BasicCollector(write_mps=false) bc.collect(data_filenames, build_setcover_model_jump) @debug "Training models..." From 510d87ce9084644325c17ea21306da9dbea41b6c Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 2 Feb 2024 10:16:39 -0600 Subject: [PATCH 04/34] Make compatible with write_mps; fix lazy_enforce --- src/solvers/jump.jl | 14 ++++++++++++++ test/src/components/test_cuts.jl | 2 +- test/src/components/test_lazy.jl | 2 +- test/src/test_usage.jl | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/solvers/jump.jl b/src/solvers/jump.jl index 6c45a4e..163637e 100644 --- a/src/solvers/jump.jl +++ b/src/solvers/jump.jl @@ -372,6 +372,13 @@ function _set_warm_starts(model::JuMP.Model, var_names, var_values, stats) end function _write(model::JuMP.Model, filename) + ext = model.ext[:miplearn] + if ext.lazy_separate !== nothing + set_attribute(model, MOI.LazyConstraintCallback(), nothing) + end + if ext.cuts_separate !== nothing + set_attribute(model, MOI.UserCutCallback(), nothing) + end write_to_file(model, filename) end @@ -439,6 +446,13 @@ function __init_solvers_jump__() function lazy_enforce(self, model, violations) self.inner.ext[:miplearn].lazy_enforce(violations) end + + function _lazy_enforce_collected(self) + ext = self.inner.ext[:miplearn] + if ext.lazy_enforce !== nothing + ext.lazy_enforce(ext.lazy) + end + end end copy!(JumpModel, Class) end diff --git a/test/src/components/test_cuts.jl b/test/src/components/test_cuts.jl index f9cccd9..4b1bb93 100644 --- a/test/src/components/test_cuts.jl +++ b/test/src/components/test_cuts.jl @@ -17,7 +17,7 @@ function gen_stab() ) data = gen.generate(1) data_filenames = write_pkl_gz(data, "$BASEDIR/../fixtures", prefix="stab-n50-") - collector = BasicCollector(write_mps=false) + collector = BasicCollector() collector.collect( data_filenames, data -> build_stab_model_jump(data, optimizer=SCIP.Optimizer), diff --git a/test/src/components/test_lazy.jl b/test/src/components/test_lazy.jl index 291b5fa..b62c1d8 100644 --- a/test/src/components/test_lazy.jl +++ b/test/src/components/test_lazy.jl @@ -20,7 +20,7 @@ function gen_tsp() ) data = gen.generate(1) data_filenames = write_pkl_gz(data, "$BASEDIR/../fixtures", prefix="tsp-n20-") - collector = BasicCollector(write_mps=false) + collector = BasicCollector() collector.collect( data_filenames, data -> build_tsp_model_jump(data, optimizer=GLPK.Optimizer), diff --git a/test/src/test_usage.jl b/test/src/test_usage.jl index 3e41332..1965e0e 100644 --- a/test/src/test_usage.jl +++ b/test/src/test_usage.jl @@ -28,7 +28,7 @@ function test_usage() ) @debug "Collecting training data..." - bc = BasicCollector(write_mps=false) + bc = BasicCollector() bc.collect(data_filenames, build_setcover_model_jump) @debug "Training models..." From e9971a2152bdd72f54043eba8ce35648119056ab Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 2 Feb 2024 10:45:20 -0600 Subject: [PATCH 05/34] Remove hardcoded LP optimizer --- src/problems/setcover.jl | 2 +- src/problems/tsp.jl | 3 ++- src/solvers/jump.jl | 13 ++++++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/problems/setcover.jl b/src/problems/setcover.jl index 9d03b6a..fff3dc8 100644 --- a/src/problems/setcover.jl +++ b/src/problems/setcover.jl @@ -13,7 +13,7 @@ function __init_problems_setcover__() copy!(SetCoverGenerator, pyimport("miplearn.problems.setcover").SetCoverGenerator) end -function build_setcover_model_jump(data::Any; optimizer = HiGHS.Optimizer) +function build_setcover_model_jump(data::Any; optimizer=HiGHS.Optimizer) if data isa String data = read_pkl_gz(data) end diff --git a/src/problems/tsp.jl b/src/problems/tsp.jl index 3f86b5b..03e9d9a 100644 --- a/src/problems/tsp.jl +++ b/src/problems/tsp.jl @@ -12,7 +12,7 @@ function __init_problems_tsp__() copy!(TravelingSalesmanGenerator, pyimport("miplearn.problems.tsp").TravelingSalesmanGenerator) end -function build_tsp_model_jump(data::Any; optimizer=HiGHS.Optimizer) +function build_tsp_model_jump(data::Any; optimizer) nx = pyimport("networkx") if data isa String @@ -65,6 +65,7 @@ function build_tsp_model_jump(data::Any; optimizer=HiGHS.Optimizer) model, lazy_enforce=lazy_enforce, lazy_separate=lazy_separate, + lp_optimizer=optimizer, ) end diff --git a/src/solvers/jump.jl b/src/solvers/jump.jl index 163637e..448180e 100644 --- a/src/solvers/jump.jl +++ b/src/solvers/jump.jl @@ -18,15 +18,17 @@ Base.@kwdef mutable struct _JumpModelExtData cuts_separate::Union{Function,Nothing} = nothing lazy_enforce::Union{Function,Nothing} = nothing lazy_separate::Union{Function,Nothing} = nothing + lp_optimizer end function JuMP.copy_extension_data( - ::_JumpModelExtData, + old_ext::_JumpModelExtData, new_model::AbstractModel, ::AbstractModel, ) - # Do not transfer any extension data to the new model - new_model.ext[:miplearn] = _JumpModelExtData() + new_model.ext[:miplearn] = _JumpModelExtData( + lp_optimizer=old_ext.lp_optimizer + ) end # ----------------------------------------------------------------------------- @@ -354,8 +356,7 @@ end function _relax(model::JuMP.Model) relaxed, _ = copy_model(model) relax_integrality(relaxed) - # FIXME: Remove hardcoded optimizer - set_optimizer(relaxed, HiGHS.Optimizer) + set_optimizer(relaxed, model.ext[:miplearn].lp_optimizer) set_silent(relaxed) return relaxed end @@ -395,6 +396,7 @@ function __init_solvers_jump__() cuts_separate::Union{Function,Nothing}=nothing, lazy_enforce::Union{Function,Nothing}=nothing, lazy_separate::Union{Function,Nothing}=nothing, + lp_optimizer=HiGHS.Optimizer, ) self.inner = inner self.inner.ext[:miplearn] = _JumpModelExtData( @@ -402,6 +404,7 @@ function __init_solvers_jump__() cuts_separate=cuts_separate, lazy_enforce=lazy_enforce, lazy_separate=lazy_separate, + lp_optimizer=lp_optimizer, ) end From 25fc39a2b7d0f16290078f22e375af1f096a889d Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 2 Feb 2024 14:16:13 -0600 Subject: [PATCH 06/34] Small fixes --- test/fixtures/tsp-n20-00000.h5 | Bin 62978 -> 25552 bytes test/fixtures/tsp-n20-00000.mps.gz | Bin 0 -> 4647 bytes test/fixtures/tsp-n20-00000.pkl.gz | Bin 1145 -> 1145 bytes test/src/MIPLearnT.jl | 1 + 4 files changed, 1 insertion(+) create mode 100644 test/fixtures/tsp-n20-00000.mps.gz diff --git a/test/fixtures/tsp-n20-00000.h5 b/test/fixtures/tsp-n20-00000.h5 index 2539134913d20664f0b3cc8caacd9ea7ee0acd1a..d9f7a7452aa717218e22a163c006e62e0acb20fb 100644 GIT binary patch delta 2674 zcmY+G3p|tiAIG0<59X3aVy-O;iREs`byH$E%yG$4hoNxPisaHNTaz-;XPxu%-|O}4yU*|a{eGUy@3-gsV~wGnbRhEd););^ z;E$y&04$?#fWOzKUywNF&$oA^N{%1#qz4_mIrN4BVXzty#T$2Z&IW-ngajMv!C4+Q z2}&TWh9(gTP|gNpXdPl}X;7Dx;VV$ZI=i50@S-#mUk)zPxSLKU zZjvS=L{Or(2GoH+4b~a&vVhbOw0tzEh(XId5YXZ~A6_VWjT&;m2ujzk1#|O)H$kR4 zl}PKgu&#Qh5AafS_o9$-#?S|Yv*6okPKd-Nhle$4M^#~QC$7mJGeM$sW z8x$LC8`_S#_6P-t�QEg-?G@oI1>+;8Kv&gYKBfJTQ+Kl2wglD~4p%YO4^Vs8tn= zgIQRNV=GvG6PJx+naYz8C0O8NJpq(nN8FWIDm8&g#pvp1ydwe^)+nKhCj8;iWgXGzwIEiZ1p zD-z)iIk;Z}SLokXkWMgCjDXOt^ zY^}qYoQ~xUJ~keV)Ap0G@pfl|hD|P2_WCV&FYLz4?J3kx@m6{|(9Z8su6Gq$`v~V} zLU~i;EiM;L=xIIh}kjo#0TarV{4o~7Wg%PFu#8&KIBEO@=zT$wucVQqv$D>Cl9Tu znxu{LN;osh^Xs0+D4IOAu$=Q5sNLwYp!ivnpZnEu>Q-E6wN}xrVAU5LzrP$h7+*P; z8}WDCISzw$jH^5LUcTaD+ww3uj&&obfX-Hk@|f+q{+VMmWAA3UpqpF#chQc&t?2fa z(=N7lkNtUV?QByp9?{{!yUj=o&kQ)MD6mwSnn{1wYkw^g zQ|w5}CO58?d)EF?Iro1`@!jV}S0_IrKU?Sd2i-Anr149$k9En=oO$=H+T}%uSf9-P zT*z}C;HHe*D1JBDo3Hw+^{I*RqPRP+@JlH@^Syz|(U&hBu!Ctk(!y&v2qgfAM$P{k z@$tQyt!}gq3l!`ufx2M99VJpBOHGdaFb2ah$e&Df(5>4t7cPgs*9 z403=E{(6VuW_edCXWeG?+8{Ty6oY*mt!|3T?*aiOH`~&}E%Keb^a3=Faw-e63r$~fB;@`5l_n5J;==|yQ0ndG2?Abte3;4P~L|f}u?_Y26EkdXzP7;zr-LjI% zxf@#m&S2fV(CUvm5#rk#f-WA|Dde2C@=Zs7f3Eox?_Q6{s&ToH8I*dxd+sk|e@?o` zp3Z64)~W}W?jLzUE@BM2Qowjo^K5+Gpe--Mg09YIMNk51Q)82QzhyRTG+}bm4p7U) zP5PxKHN$QhbzP-YPGSoofp%+dGQ2sp2z{+{g0fZRYtOXh>uRS~-I>)cx?P8IKY4KX z5RyklE1XmQ%&)0McYf=iMgP}pZZ5zQ}4XS`?j5wrGREYG~by}3>7 zn+nGj28$!$&=kNx5@uysg#@(97eT>WyM?veFGmjkcf6d#!Q zI&oU$?x6MRKCbfxS9JNH!<^Ab*^S6!tHc~4^EQ9*(uz94!w0E z2t91P*v&q?TA;Ym0^9f2{tN#WDqGi{%z&b-~ZQW zBJD}~{ulrV@`cL*ge?45fZ`>hg$Q^@k^2KlL#7?vm&8{hWUT++Ite>(12e-}Rykbv z(~`Ob8Oz;CXoZ=_61HB(?h<0OU>3TB^;*k3Q%-ckZ08adCu1>8T{*~-9E1EW>0^@p zO>z%(7s8l^sgjrEHp{ z5y16<@4#KA%Wii#N=o3}AHo_+^zH>|XNvaxH^hO5AhYkDydd9d45AFy-}JYtR6Y4k F{vW6bOf~=j delta 3477 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 diff --git a/test/src/MIPLearnT.jl b/test/src/MIPLearnT.jl index df7799f..3da1256 100644 --- a/test/src/MIPLearnT.jl +++ b/test/src/MIPLearnT.jl @@ -38,6 +38,7 @@ function runtests() test_solvers_jump() test_usage() test_cuts() + test_lazy() end end From 42466936a3aa18f2b1f4bc104903014fcbe9b01a Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 6 Feb 2024 16:21:04 -0600 Subject: [PATCH 07/34] Minor fixes --- src/solvers/jump.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/solvers/jump.jl b/src/solvers/jump.jl index 448180e..d4d88bf 100644 --- a/src/solvers/jump.jl +++ b/src/solvers/jump.jl @@ -344,10 +344,10 @@ function _optimize(model::JuMP.Model) end # Optimize - ext.where = :WHERE_DEFAULT optimize!(model) # Cleanup + ext.where = :WHERE_DEFAULT ext.cb_data = nothing flush(stdout) Libc.flush_cstdio() @@ -446,13 +446,13 @@ function __init_solvers_jump__() self.inner.ext[:miplearn].aot_cuts = cuts end - function lazy_enforce(self, model, violations) + function lazy_enforce(self, violations) self.inner.ext[:miplearn].lazy_enforce(violations) end function _lazy_enforce_collected(self) ext = self.inner.ext[:miplearn] - if ext.lazy_enforce !== nothing + if ext.lazy !== nothing ext.lazy_enforce(ext.lazy) end end From f2f727fa7cf640f732f9c7cb29bc4ac29eec7c60 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 6 Feb 2024 16:21:11 -0600 Subject: [PATCH 08/34] deps: Bump to miplearn-0.4.0 --- deps/build.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/build.jl b/deps/build.jl index 3be992f..8c6cd1e 100644 --- a/deps/build.jl +++ b/deps/build.jl @@ -5,7 +5,7 @@ function install_miplearn() Conda.update() pip = joinpath(dirname(pyimport("sys").executable), "pip") isfile(pip) || error("$pip: invalid path") - run(`$pip install miplearn==0.3.0`) + run(`$pip install miplearn==0.4.0`) end install_miplearn() From d94d7c034dc841262c40f332e8cfe123a8bf5b15 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 6 Feb 2024 16:36:53 -0600 Subject: [PATCH 09/34] JumpModel: Minor fix --- src/solvers/jump.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/solvers/jump.jl b/src/solvers/jump.jl index d4d88bf..ede1076 100644 --- a/src/solvers/jump.jl +++ b/src/solvers/jump.jl @@ -452,7 +452,7 @@ function __init_solvers_jump__() function _lazy_enforce_collected(self) ext = self.inner.ext[:miplearn] - if ext.lazy !== nothing + if ext.lazy_enforce !== nothing ext.lazy_enforce(ext.lazy) end end From dbd6d156e60465cb60076528fb4bc4d5de4295e3 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 6 Feb 2024 16:39:33 -0600 Subject: [PATCH 10/34] Bump version to 0.4.0 --- Project.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index c439f7a..f04e1dd 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "MIPLearn" uuid = "2b1277c3-b477-4c49-a15e-7ba350325c68" authors = ["Alinson S Xavier "] -version = "0.3.0" +version = "0.4.0" [deps] Conda = "8f4d0f93-b110-5947-807f-2305c1781a2d" @@ -29,6 +29,8 @@ DataStructures = "0.18" HDF5 = "0.16" HiGHS = "1" JLD2 = "0.4" +JSON = "0.21" +julia = "1" JuMP = "1" KLU = "0.4" MathOptInterface = "1" @@ -37,4 +39,3 @@ PyCall = "1" Requires = "1" Statistics = "1" TimerOutputs = "0.5" -julia = "1" From 9c61b98cb9a3223cdcff811fd0bfecd2409c31b4 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 12 Mar 2024 13:56:34 -0500 Subject: [PATCH 11/34] Make GMI cuts more stable --- src/Cuts/Cuts.jl | 1 + src/Cuts/tableau/collect.jl | 40 ++++++++++++------------- src/Cuts/tableau/gmi.jl | 57 ++++++++++++------------------------ src/Cuts/tableau/numerics.jl | 51 ++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 60 deletions(-) create mode 100644 src/Cuts/tableau/numerics.jl diff --git a/src/Cuts/Cuts.jl b/src/Cuts/Cuts.jl index 77738d6..b2a4254 100644 --- a/src/Cuts/Cuts.jl +++ b/src/Cuts/Cuts.jl @@ -9,6 +9,7 @@ import ..to_str_array include("tableau/structs.jl") # include("blackbox/cplex.jl") +include("tableau/numerics.jl") include("tableau/collect.jl") include("tableau/gmi.jl") include("tableau/moi.jl") diff --git a/src/Cuts/tableau/collect.jl b/src/Cuts/tableau/collect.jl index 01e84ff..0246449 100644 --- a/src/Cuts/tableau/collect.jl +++ b/src/Cuts/tableau/collect.jl @@ -5,8 +5,10 @@ import ..H5File using OrderedCollections +using Statistics -function collect_gmi(mps_filename; optimizer, max_rounds = 10, max_cuts_per_round = 100) + +function collect_gmi(mps_filename; optimizer, max_rounds=10, max_cuts_per_round=100, atol=1e-4) @info mps_filename reset_timer!() @@ -27,7 +29,7 @@ function collect_gmi(mps_filename; optimizer, max_rounds = 10, max_cuts_per_roun if obj_mip === nothing obj_mip = h5.get_scalar("mip_obj_value") end - obj_lp = nothing + obj_lp = h5.get_scalar("lp_obj_value") h5.file.close() # Define relative MIP gap @@ -58,8 +60,8 @@ function collect_gmi(mps_filename; optimizer, max_rounds = 10, max_cuts_per_roun sol_opt = [sol_opt_dict[n] for n in data.var_names] # Assert optimal solution is feasible for the original problem - @assert all(data.constr_lb .- 1e-3 .<= data.constr_lhs * sol_opt) - @assert all(data.constr_lhs * sol_opt .<= data.constr_ub .+ 1e-3) + assert_leq(data.constr_lb, data.constr_lhs * sol_opt) + assert_leq(data.constr_lhs * sol_opt, data.constr_ub) # Convert to standard form data_s, transforms = convert_to_standard_form(data) @@ -71,15 +73,17 @@ function collect_gmi(mps_filename; optimizer, max_rounds = 10, max_cuts_per_roun sol_opt_s = forward(transforms, sol_opt) # Assert converted solution is feasible for standard form problem - @assert data_s.constr_lhs * sol_opt_s ≈ data_s.constr_lb + assert_eq(data_s.constr_lhs * sol_opt_s, data_s.constr_lb) end # Optimize standard form optimize!(model_s) stats_time_solve += solve_time(model_s) obj = objective_value(model_s) + data_s.obj_offset - if obj_lp === nothing - obj_lp = obj + + if round == 1 + # Assert standard form problem has same value as original + assert_eq(obj, obj_lp) push!(stats_obj, obj) push!(stats_gap, gap(obj)) push!(stats_ncuts, 0) @@ -93,16 +97,16 @@ function collect_gmi(mps_filename; optimizer, max_rounds = 10, max_cuts_per_roun sol_frac = get_x(model_s) stats_time_select += @elapsed begin selected_rows = - select_gmi_rows(data_s, basis, sol_frac, max_rows = max_cuts_per_round) + select_gmi_rows(data_s, basis, sol_frac, max_rows=max_cuts_per_round) end # Compute selected tableau rows stats_time_tableau += @elapsed begin - tableau = compute_tableau(data_s, basis, sol_frac, rows = selected_rows) + tableau = compute_tableau(data_s, basis, sol_frac, rows=selected_rows) # Assert tableau rows have been computed correctly - @assert tableau.lhs * sol_frac ≈ tableau.rhs - @assert tableau.lhs * sol_opt_s ≈ tableau.rhs + assert_eq(tableau.lhs * sol_frac, tableau.rhs) + assert_eq(tableau.lhs * sol_opt_s, tableau.rhs) end # Compute GMI cuts @@ -110,17 +114,12 @@ function collect_gmi(mps_filename; optimizer, max_rounds = 10, max_cuts_per_roun cuts_s = compute_gmi(data_s, tableau) # Assert cuts have been generated correctly - try - assert_cuts_off(cuts_s, sol_frac) - assert_does_not_cut_off(cuts_s, sol_opt_s) - catch - @warn "Invalid cuts detected. Discarding round $round cuts and aborting." - break - end + assert_cuts_off(cuts_s, sol_frac) + assert_does_not_cut_off(cuts_s, sol_opt_s) # Abort if no cuts are left if length(cuts_s.lb) == 0 - @info "No cuts generated. Aborting." + @info "No cuts generated. Stopping." break end end @@ -139,7 +138,7 @@ function collect_gmi(mps_filename; optimizer, max_rounds = 10, max_cuts_per_roun push!(stats_gap, gap(obj)) # Store useful cuts; drop useless ones from the problem - useful = [abs(shadow_price(c)) > 1e-3 for c in constrs] + useful = [abs(shadow_price(c)) > atol for c in constrs] drop = findall(useful .== false) keep = findall(useful .== true) delete.(model, constrs[drop]) @@ -174,7 +173,6 @@ function collect_gmi(mps_filename; optimizer, max_rounds = 10, max_cuts_per_roun "time_tableau" => stats_time_tableau, "time_gmi" => stats_time_gmi, "obj_mip" => obj_mip, - "obj_lp" => obj_lp, "stats_obj" => stats_obj, "stats_gap" => stats_gap, "stats_ncuts" => stats_ncuts, diff --git a/src/Cuts/tableau/gmi.jl b/src/Cuts/tableau/gmi.jl index 29e65e1..f4d578f 100644 --- a/src/Cuts/tableau/gmi.jl +++ b/src/Cuts/tableau/gmi.jl @@ -5,13 +5,14 @@ using SparseArrays using TimerOutputs -@inline frac(x::Float64) = x - floor(x) - -function select_gmi_rows(data, basis, x; max_rows = 10, atol = 0.001) +function select_gmi_rows(data, basis, x; max_rows=10, atol=1e-4) candidate_rows = [ r for - r = 1:length(basis.var_basic) if (data.var_types[basis.var_basic[r]] != 'C') && - (frac(x[basis.var_basic[r]]) > atol) + r in 1:length(basis.var_basic) if ( + (data.var_types[basis.var_basic[r]] != 'C') && + (frac(x[basis.var_basic[r]]) > atol) && + (frac2(x[basis.var_basic[r]]) > atol) + ) ] candidate_vals = frac.(x[basis.var_basic[candidate_rows]]) score = abs.(candidate_vals .- 0.5) @@ -19,34 +20,36 @@ function select_gmi_rows(data, basis, x; max_rows = 10, atol = 0.001) return [candidate_rows[perm[i]] for i = 1:min(length(perm), max_rows)] end -function compute_gmi(data::ProblemData, tableau::Tableau, tol = 1e-8)::ConstraintSet +function compute_gmi(data::ProblemData, tableau::Tableau)::ConstraintSet nrows, ncols = size(tableau.lhs) ub = Float64[Inf for _ = 1:nrows] - lb = Float64[0.999 for _ = 1:nrows] + lb = Float64[0.9999 for _ = 1:nrows] tableau_I, tableau_J, tableau_V = findnz(tableau.lhs) lhs_I = Int[] lhs_J = Int[] lhs_V = Float64[] @timeit "Compute coefficients" begin - for k = 1:nnz(tableau.lhs) + for k in 1:nnz(tableau.lhs) i::Int = tableau_I[k] + j::Int = tableau_J[k] v::Float64 = 0.0 - alpha_j = frac(tableau_V[k]) + frac_alpha_j = frac(tableau_V[k]) + alpha_j = tableau_V[k] beta = frac(tableau.rhs[i]) - if data.var_types[i] == "C" + if data.var_types[j] == 'C' if alpha_j >= 0 v = alpha_j / beta else - v = alpha_j / (1 - beta) + v = -alpha_j / (1 - beta) end else - if alpha_j <= beta - v = alpha_j / beta + if frac_alpha_j < beta + v = frac_alpha_j / beta else - v = (1 - alpha_j) / (1 - beta) + v = (1 - frac_alpha_j) / (1 - beta) end end - if abs(v) > tol + if abs(v) > 1e-8 push!(lhs_I, i) push!(lhs_J, tableau_J[k]) push!(lhs_V, v) @@ -57,28 +60,4 @@ function compute_gmi(data::ProblemData, tableau::Tableau, tol = 1e-8)::Constrain return ConstraintSet(; lhs, ub, lb) end -function assert_cuts_off(cuts::ConstraintSet, x::Vector{Float64}, tol = 1e-6) - for i = 1:length(cuts.lb) - val = cuts.lhs[i, :]' * x - if (val <= cuts.ub[i] - tol) && (val >= cuts.lb[i] + tol) - throw(ErrorException("inequality fails to cut off fractional solution")) - end - end -end - -function assert_does_not_cut_off(cuts::ConstraintSet, x::Vector{Float64}; tol = 1e-6) - for i = 1:length(cuts.lb) - val = cuts.lhs[i, :]' * x - ub = cuts.ub[i] - lb = cuts.lb[i] - if (val >= ub) || (val <= lb) - throw( - ErrorException( - "inequality $i cuts off integer solution ($lb <= $val <= $ub)", - ), - ) - end - end -end - export compute_gmi, frac, select_gmi_rows, assert_cuts_off, assert_does_not_cut_off diff --git a/src/Cuts/tableau/numerics.jl b/src/Cuts/tableau/numerics.jl new file mode 100644 index 0000000..2a77547 --- /dev/null +++ b/src/Cuts/tableau/numerics.jl @@ -0,0 +1,51 @@ +@inline frac(x::Float64) = x - floor(x) + +@inline frac2(x::Float64) = ceil(x) - x + +function assert_leq(a, b; atol=0.01) + if !all(a .<= b .+ atol) + delta = a .- b + for i in eachindex(delta) + if delta[i] > atol + @info "Assertion failed: a[$i] = $(a[i]) <= $(b[i]) = b[$i]" + end + end + error("assert_leq failed") + end +end + +function assert_eq(a, b; atol=1e-4) + if !all(abs.(a .- b) .<= atol) + delta = abs.(a .- b) + for i in eachindex(delta) + if delta[i] > atol + @info "Assertion failed: a[$i] = $(a[i]) == $(b[i]) = b[$i]" + end + end + error("assert_eq failed") + end +end + +function assert_cuts_off(cuts::ConstraintSet, x::Vector{Float64}, tol=1e-6) + for i = 1:length(cuts.lb) + val = cuts.lhs[i, :]' * x + if (val <= cuts.ub[i] - tol) && (val >= cuts.lb[i] + tol) + throw(ErrorException("inequality fails to cut off fractional solution")) + end + end +end + +function assert_does_not_cut_off(cuts::ConstraintSet, x::Vector{Float64}; tol=1e-6) + for i = 1:length(cuts.lb) + val = cuts.lhs[i, :]' * x + ub = cuts.ub[i] + lb = cuts.lb[i] + if (val >= ub) || (val <= lb) + throw( + ErrorException( + "inequality $i cuts off integer solution ($lb <= $val <= $ub)", + ), + ) + end + end +end From e9deac94a5db387a26738c997062180609cee89e Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Wed, 29 May 2024 09:01:51 -0500 Subject: [PATCH 12/34] Move collect_gmi to gmi.jl --- src/Cuts/tableau/collect.jl | 182 ------------------------------------ src/Cuts/tableau/gmi.jl | 177 ++++++++++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 183 deletions(-) delete mode 100644 src/Cuts/tableau/collect.jl diff --git a/src/Cuts/tableau/collect.jl b/src/Cuts/tableau/collect.jl deleted file mode 100644 index 0246449..0000000 --- a/src/Cuts/tableau/collect.jl +++ /dev/null @@ -1,182 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2023, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import ..H5File - -using OrderedCollections -using Statistics - - -function collect_gmi(mps_filename; optimizer, max_rounds=10, max_cuts_per_round=100, atol=1e-4) - @info mps_filename - reset_timer!() - - # Open HDF5 file - h5_filename = replace(mps_filename, ".mps.gz" => ".h5") - h5 = H5File(h5_filename) - - # Read optimal solution - sol_opt_dict = Dict( - zip( - h5.get_array("static_var_names"), - convert(Array{Float64}, h5.get_array("mip_var_values")), - ), - ) - - # Read optimal value - obj_mip = h5.get_scalar("mip_lower_bound") - if obj_mip === nothing - obj_mip = h5.get_scalar("mip_obj_value") - end - obj_lp = h5.get_scalar("lp_obj_value") - h5.file.close() - - # Define relative MIP gap - gap(v) = 100 * abs(obj_mip - v) / abs(v) - - # Initialize stats - stats_obj = [] - stats_gap = [] - stats_ncuts = [] - stats_time_convert = 0 - stats_time_solve = 0 - stats_time_select = 0 - stats_time_tableau = 0 - stats_time_gmi = 0 - all_cuts = nothing - - # Read problem - model = read_from_file(mps_filename) - - for round = 1:max_rounds - @info "Round $(round)..." - - stats_time_convert = @elapsed begin - # Extract problem data - data = ProblemData(model) - - # Construct optimal solution vector (with correct variable sequence) - sol_opt = [sol_opt_dict[n] for n in data.var_names] - - # Assert optimal solution is feasible for the original problem - assert_leq(data.constr_lb, data.constr_lhs * sol_opt) - assert_leq(data.constr_lhs * sol_opt, data.constr_ub) - - # Convert to standard form - data_s, transforms = convert_to_standard_form(data) - model_s = to_model(data_s) - set_optimizer(model_s, optimizer) - relax_integrality(model_s) - - # Convert optimal solution to standard form - sol_opt_s = forward(transforms, sol_opt) - - # Assert converted solution is feasible for standard form problem - assert_eq(data_s.constr_lhs * sol_opt_s, data_s.constr_lb) - end - - # Optimize standard form - optimize!(model_s) - stats_time_solve += solve_time(model_s) - obj = objective_value(model_s) + data_s.obj_offset - - if round == 1 - # Assert standard form problem has same value as original - assert_eq(obj, obj_lp) - push!(stats_obj, obj) - push!(stats_gap, gap(obj)) - push!(stats_ncuts, 0) - end - if termination_status(model_s) != MOI.OPTIMAL - return - end - - # Select tableau rows - basis = get_basis(model_s) - sol_frac = get_x(model_s) - stats_time_select += @elapsed begin - selected_rows = - select_gmi_rows(data_s, basis, sol_frac, max_rows=max_cuts_per_round) - end - - # Compute selected tableau rows - stats_time_tableau += @elapsed begin - tableau = compute_tableau(data_s, basis, sol_frac, rows=selected_rows) - - # Assert tableau rows have been computed correctly - assert_eq(tableau.lhs * sol_frac, tableau.rhs) - assert_eq(tableau.lhs * sol_opt_s, tableau.rhs) - end - - # Compute GMI cuts - stats_time_gmi += @elapsed begin - cuts_s = compute_gmi(data_s, tableau) - - # Assert cuts have been generated correctly - assert_cuts_off(cuts_s, sol_frac) - assert_does_not_cut_off(cuts_s, sol_opt_s) - - # Abort if no cuts are left - if length(cuts_s.lb) == 0 - @info "No cuts generated. Stopping." - break - end - end - - # Add GMI cuts to original problem - cuts = backwards(transforms, cuts_s) - assert_does_not_cut_off(cuts, sol_opt) - constrs = add_constraint_set(model, cuts) - - # Optimize original form - set_optimizer(model, optimizer) - undo_relax = relax_integrality(model) - optimize!(model) - obj = objective_value(model) - push!(stats_obj, obj) - push!(stats_gap, gap(obj)) - - # Store useful cuts; drop useless ones from the problem - useful = [abs(shadow_price(c)) > atol for c in constrs] - drop = findall(useful .== false) - keep = findall(useful .== true) - delete.(model, constrs[drop]) - if all_cuts === nothing - all_cuts = cuts - else - all_cuts.lhs = [all_cuts.lhs; cuts.lhs[keep, :]] - all_cuts.lb = [all_cuts.lb; cuts.lb[keep]] - all_cuts.lb = [all_cuts.lb; cuts.lb[keep]] - end - push!(stats_ncuts, length(all_cuts.lb)) - - undo_relax() - end - - # Store cuts - if all_cuts !== nothing - @info "Storing $(length(all_cuts.ub)) GMI cuts..." - h5 = H5File(h5_filename) - h5.put_sparse("cuts_lhs", all_cuts.lhs) - h5.put_array("cuts_lb", all_cuts.lb) - h5.put_array("cuts_ub", all_cuts.ub) - h5.file.close() - end - - return OrderedDict( - "instance" => mps_filename, - "max_rounds" => max_rounds, - "rounds" => length(stats_obj) - 1, - "time_convert" => stats_time_convert, - "time_solve" => stats_time_solve, - "time_tableau" => stats_time_tableau, - "time_gmi" => stats_time_gmi, - "obj_mip" => obj_mip, - "stats_obj" => stats_obj, - "stats_gap" => stats_gap, - "stats_ncuts" => stats_ncuts, - ) -end - -export collect_gmi diff --git a/src/Cuts/tableau/gmi.jl b/src/Cuts/tableau/gmi.jl index f4d578f..b9ee63a 100644 --- a/src/Cuts/tableau/gmi.jl +++ b/src/Cuts/tableau/gmi.jl @@ -2,9 +2,184 @@ # Copyright (C) 2020-2023, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. +import ..H5File + +using OrderedCollections using SparseArrays +using Statistics using TimerOutputs +function collect_gmi(mps_filename; optimizer, max_rounds=10, max_cuts_per_round=100, atol=1e-4) + @info mps_filename + reset_timer!() + + # Open HDF5 file + h5_filename = replace(mps_filename, ".mps.gz" => ".h5") + h5 = H5File(h5_filename) + + # Read optimal solution + sol_opt_dict = Dict( + zip( + h5.get_array("static_var_names"), + convert(Array{Float64}, h5.get_array("mip_var_values")), + ), + ) + + # Read optimal value + obj_mip = h5.get_scalar("mip_lower_bound") + if obj_mip === nothing + obj_mip = h5.get_scalar("mip_obj_value") + end + obj_lp = h5.get_scalar("lp_obj_value") + h5.file.close() + + # Define relative MIP gap + gap(v) = 100 * abs(obj_mip - v) / abs(v) + + # Initialize stats + stats_obj = [] + stats_gap = [] + stats_ncuts = [] + stats_time_convert = 0 + stats_time_solve = 0 + stats_time_select = 0 + stats_time_tableau = 0 + stats_time_gmi = 0 + all_cuts = nothing + + # Read problem + model = read_from_file(mps_filename) + + for round = 1:max_rounds + @info "Round $(round)..." + + stats_time_convert = @elapsed begin + # Extract problem data + data = ProblemData(model) + + # Construct optimal solution vector (with correct variable sequence) + sol_opt = [sol_opt_dict[n] for n in data.var_names] + + # Assert optimal solution is feasible for the original problem + assert_leq(data.constr_lb, data.constr_lhs * sol_opt) + assert_leq(data.constr_lhs * sol_opt, data.constr_ub) + + # Convert to standard form + data_s, transforms = convert_to_standard_form(data) + model_s = to_model(data_s) + set_optimizer(model_s, optimizer) + relax_integrality(model_s) + + # Convert optimal solution to standard form + sol_opt_s = forward(transforms, sol_opt) + + # Assert converted solution is feasible for standard form problem + assert_eq(data_s.constr_lhs * sol_opt_s, data_s.constr_lb) + end + + # Optimize standard form + optimize!(model_s) + stats_time_solve += solve_time(model_s) + obj = objective_value(model_s) + data_s.obj_offset + + if round == 1 + # Assert standard form problem has same value as original + assert_eq(obj, obj_lp) + push!(stats_obj, obj) + push!(stats_gap, gap(obj)) + push!(stats_ncuts, 0) + end + if termination_status(model_s) != MOI.OPTIMAL + return + end + + # Select tableau rows + basis = get_basis(model_s) + sol_frac = get_x(model_s) + stats_time_select += @elapsed begin + selected_rows = + select_gmi_rows(data_s, basis, sol_frac, max_rows=max_cuts_per_round) + end + + # Compute selected tableau rows + stats_time_tableau += @elapsed begin + tableau = compute_tableau(data_s, basis, sol_frac, rows=selected_rows) + + # Assert tableau rows have been computed correctly + assert_eq(tableau.lhs * sol_frac, tableau.rhs) + assert_eq(tableau.lhs * sol_opt_s, tableau.rhs) + end + + # Compute GMI cuts + stats_time_gmi += @elapsed begin + cuts_s = compute_gmi(data_s, tableau) + + # Assert cuts have been generated correctly + assert_cuts_off(cuts_s, sol_frac) + assert_does_not_cut_off(cuts_s, sol_opt_s) + + # Abort if no cuts are left + if length(cuts_s.lb) == 0 + @info "No cuts generated. Stopping." + break + end + end + + # Add GMI cuts to original problem + cuts = backwards(transforms, cuts_s) + assert_does_not_cut_off(cuts, sol_opt) + constrs = add_constraint_set(model, cuts) + + # Optimize original form + set_optimizer(model, optimizer) + undo_relax = relax_integrality(model) + optimize!(model) + obj = objective_value(model) + push!(stats_obj, obj) + push!(stats_gap, gap(obj)) + + # Store useful cuts; drop useless ones from the problem + useful = [abs(shadow_price(c)) > atol for c in constrs] + drop = findall(useful .== false) + keep = findall(useful .== true) + delete.(model, constrs[drop]) + if all_cuts === nothing + all_cuts = cuts + else + all_cuts.lhs = [all_cuts.lhs; cuts.lhs[keep, :]] + all_cuts.lb = [all_cuts.lb; cuts.lb[keep]] + all_cuts.lb = [all_cuts.lb; cuts.lb[keep]] + end + push!(stats_ncuts, length(all_cuts.lb)) + + undo_relax() + end + + # Store cuts + if all_cuts !== nothing + @info "Storing $(length(all_cuts.ub)) GMI cuts..." + h5 = H5File(h5_filename) + h5.put_sparse("cuts_lhs", all_cuts.lhs) + h5.put_array("cuts_lb", all_cuts.lb) + h5.put_array("cuts_ub", all_cuts.ub) + h5.file.close() + end + + return OrderedDict( + "instance" => mps_filename, + "max_rounds" => max_rounds, + "rounds" => length(stats_obj) - 1, + "time_convert" => stats_time_convert, + "time_solve" => stats_time_solve, + "time_tableau" => stats_time_tableau, + "time_gmi" => stats_time_gmi, + "obj_mip" => obj_mip, + "stats_obj" => stats_obj, + "stats_gap" => stats_gap, + "stats_ncuts" => stats_ncuts, + ) +end + function select_gmi_rows(data, basis, x; max_rows=10, atol=1e-4) candidate_rows = [ r for @@ -60,4 +235,4 @@ function compute_gmi(data::ProblemData, tableau::Tableau)::ConstraintSet return ConstraintSet(; lhs, ub, lb) end -export compute_gmi, frac, select_gmi_rows, assert_cuts_off, assert_does_not_cut_off +export compute_gmi, frac, select_gmi_rows, assert_cuts_off, assert_does_not_cut_off, collect_gmi From 93e604817b11ec05e7604640252750a69e19c58d Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Wed, 29 May 2024 09:04:59 -0500 Subject: [PATCH 13/34] Reformat source code --- src/Cuts/tableau/gmi.jl | 22 +++++++++++++-------- src/Cuts/tableau/numerics.jl | 8 ++++---- src/components.jl | 10 ++++++++-- src/io.jl | 4 ++-- src/problems/setcover.jl | 2 +- src/problems/stab.jl | 13 ++++++------ src/problems/tsp.jl | 25 +++++++++++------------ src/solvers/jump.jl | 34 +++++++++++++++----------------- test/src/MIPLearnT.jl | 4 ++-- test/src/components/test_cuts.jl | 26 +++++++++++------------- test/src/components/test_lazy.jl | 30 +++++++++++++--------------- test/src/problems/test_stab.jl | 6 +++--- test/src/problems/test_tsp.jl | 15 +++++--------- test/src/test_io.jl | 2 +- test/src/test_usage.jl | 12 +++++------ 15 files changed, 106 insertions(+), 107 deletions(-) diff --git a/src/Cuts/tableau/gmi.jl b/src/Cuts/tableau/gmi.jl index b9ee63a..fd04b36 100644 --- a/src/Cuts/tableau/gmi.jl +++ b/src/Cuts/tableau/gmi.jl @@ -9,7 +9,13 @@ using SparseArrays using Statistics using TimerOutputs -function collect_gmi(mps_filename; optimizer, max_rounds=10, max_cuts_per_round=100, atol=1e-4) +function collect_gmi( + mps_filename; + optimizer, + max_rounds = 10, + max_cuts_per_round = 100, + atol = 1e-4, +) @info mps_filename reset_timer!() @@ -98,12 +104,12 @@ function collect_gmi(mps_filename; optimizer, max_rounds=10, max_cuts_per_round= sol_frac = get_x(model_s) stats_time_select += @elapsed begin selected_rows = - select_gmi_rows(data_s, basis, sol_frac, max_rows=max_cuts_per_round) + select_gmi_rows(data_s, basis, sol_frac, max_rows = max_cuts_per_round) end # Compute selected tableau rows stats_time_tableau += @elapsed begin - tableau = compute_tableau(data_s, basis, sol_frac, rows=selected_rows) + tableau = compute_tableau(data_s, basis, sol_frac, rows = selected_rows) # Assert tableau rows have been computed correctly assert_eq(tableau.lhs * sol_frac, tableau.rhs) @@ -180,10 +186,9 @@ function collect_gmi(mps_filename; optimizer, max_rounds=10, max_cuts_per_round= ) end -function select_gmi_rows(data, basis, x; max_rows=10, atol=1e-4) +function select_gmi_rows(data, basis, x; max_rows = 10, atol = 1e-4) candidate_rows = [ - r for - r in 1:length(basis.var_basic) if ( + r for r = 1:length(basis.var_basic) if ( (data.var_types[basis.var_basic[r]] != 'C') && (frac(x[basis.var_basic[r]]) > atol) && (frac2(x[basis.var_basic[r]]) > atol) @@ -204,7 +209,7 @@ function compute_gmi(data::ProblemData, tableau::Tableau)::ConstraintSet lhs_J = Int[] lhs_V = Float64[] @timeit "Compute coefficients" begin - for k in 1:nnz(tableau.lhs) + for k = 1:nnz(tableau.lhs) i::Int = tableau_I[k] j::Int = tableau_J[k] v::Float64 = 0.0 @@ -235,4 +240,5 @@ function compute_gmi(data::ProblemData, tableau::Tableau)::ConstraintSet return ConstraintSet(; lhs, ub, lb) end -export compute_gmi, frac, select_gmi_rows, assert_cuts_off, assert_does_not_cut_off, collect_gmi +export compute_gmi, + frac, select_gmi_rows, assert_cuts_off, assert_does_not_cut_off, collect_gmi diff --git a/src/Cuts/tableau/numerics.jl b/src/Cuts/tableau/numerics.jl index 2a77547..15c38e4 100644 --- a/src/Cuts/tableau/numerics.jl +++ b/src/Cuts/tableau/numerics.jl @@ -2,7 +2,7 @@ @inline frac2(x::Float64) = ceil(x) - x -function assert_leq(a, b; atol=0.01) +function assert_leq(a, b; atol = 0.01) if !all(a .<= b .+ atol) delta = a .- b for i in eachindex(delta) @@ -14,7 +14,7 @@ function assert_leq(a, b; atol=0.01) end end -function assert_eq(a, b; atol=1e-4) +function assert_eq(a, b; atol = 1e-4) if !all(abs.(a .- b) .<= atol) delta = abs.(a .- b) for i in eachindex(delta) @@ -26,7 +26,7 @@ function assert_eq(a, b; atol=1e-4) end end -function assert_cuts_off(cuts::ConstraintSet, x::Vector{Float64}, tol=1e-6) +function assert_cuts_off(cuts::ConstraintSet, x::Vector{Float64}, tol = 1e-6) for i = 1:length(cuts.lb) val = cuts.lhs[i, :]' * x if (val <= cuts.ub[i] - tol) && (val >= cuts.lb[i] + tol) @@ -35,7 +35,7 @@ function assert_cuts_off(cuts::ConstraintSet, x::Vector{Float64}, tol=1e-6) end end -function assert_does_not_cut_off(cuts::ConstraintSet, x::Vector{Float64}; tol=1e-6) +function assert_does_not_cut_off(cuts::ConstraintSet, x::Vector{Float64}; tol = 1e-6) for i = 1:length(cuts.lb) val = cuts.lhs[i, :]' * x ub = cuts.ub[i] diff --git a/src/components.jl b/src/components.jl index 9dc2442..fc671f9 100644 --- a/src/components.jl +++ b/src/components.jl @@ -53,8 +53,14 @@ function __init_components__() ) copy!(SelectTopSolutions, pyimport("miplearn.components.primal.mem").SelectTopSolutions) copy!(MergeTopSolutions, pyimport("miplearn.components.primal.mem").MergeTopSolutions) - copy!(MemorizingCutsComponent, pyimport("miplearn.components.cuts.mem").MemorizingCutsComponent) - copy!(MemorizingLazyComponent, pyimport("miplearn.components.lazy.mem").MemorizingLazyComponent) + copy!( + MemorizingCutsComponent, + pyimport("miplearn.components.cuts.mem").MemorizingCutsComponent, + ) + copy!( + MemorizingLazyComponent, + pyimport("miplearn.components.lazy.mem").MemorizingLazyComponent, + ) end export MinProbabilityClassifier, diff --git a/src/io.jl b/src/io.jl index fddb6b4..2724444 100644 --- a/src/io.jl +++ b/src/io.jl @@ -39,14 +39,14 @@ end function PyObject(m::SparseMatrixCSC) pyimport("scipy.sparse").csc_matrix( (m.nzval, m.rowval .- 1, m.colptr .- 1), - shape=size(m), + shape = size(m), ).tocoo() end function write_jld2( objs::Vector, dirname::AbstractString; - prefix::AbstractString="" + prefix::AbstractString = "", )::Vector{String} mkpath(dirname) filenames = [@sprintf("%s/%s%05d.jld2", dirname, prefix, i) for i = 1:length(objs)] diff --git a/src/problems/setcover.jl b/src/problems/setcover.jl index fff3dc8..9d03b6a 100644 --- a/src/problems/setcover.jl +++ b/src/problems/setcover.jl @@ -13,7 +13,7 @@ function __init_problems_setcover__() copy!(SetCoverGenerator, pyimport("miplearn.problems.setcover").SetCoverGenerator) end -function build_setcover_model_jump(data::Any; optimizer=HiGHS.Optimizer) +function build_setcover_model_jump(data::Any; optimizer = HiGHS.Optimizer) if data isa String data = read_pkl_gz(data) end diff --git a/src/problems/stab.jl b/src/problems/stab.jl index 831b227..8422075 100644 --- a/src/problems/stab.jl +++ b/src/problems/stab.jl @@ -10,10 +10,13 @@ global MaxWeightStableSetGenerator = PyNULL() function __init_problems_stab__() copy!(MaxWeightStableSetData, pyimport("miplearn.problems.stab").MaxWeightStableSetData) - copy!(MaxWeightStableSetGenerator, pyimport("miplearn.problems.stab").MaxWeightStableSetGenerator) + copy!( + MaxWeightStableSetGenerator, + pyimport("miplearn.problems.stab").MaxWeightStableSetGenerator, + ) end -function build_stab_model_jump(data::Any; optimizer=HiGHS.Optimizer) +function build_stab_model_jump(data::Any; optimizer = HiGHS.Optimizer) nx = pyimport("networkx") if data isa String @@ -50,11 +53,7 @@ function build_stab_model_jump(data::Any; optimizer=HiGHS.Optimizer) end end - return JumpModel( - model, - cuts_separate=cuts_separate, - cuts_enforce=cuts_enforce, - ) + return JumpModel(model, cuts_separate = cuts_separate, cuts_enforce = cuts_enforce) end export MaxWeightStableSetData, MaxWeightStableSetGenerator, build_stab_model_jump diff --git a/src/problems/tsp.jl b/src/problems/tsp.jl index 03e9d9a..1d2dc39 100644 --- a/src/problems/tsp.jl +++ b/src/problems/tsp.jl @@ -9,7 +9,10 @@ global TravelingSalesmanGenerator = PyNULL() function __init_problems_tsp__() copy!(TravelingSalesmanData, pyimport("miplearn.problems.tsp").TravelingSalesmanData) - copy!(TravelingSalesmanGenerator, pyimport("miplearn.problems.tsp").TravelingSalesmanGenerator) + copy!( + TravelingSalesmanGenerator, + pyimport("miplearn.problems.tsp").TravelingSalesmanGenerator, + ) end function build_tsp_model_jump(data::Any; optimizer) @@ -19,17 +22,15 @@ function build_tsp_model_jump(data::Any; optimizer) data = read_pkl_gz(data) end model = Model(optimizer) - edges = [(i, j) for i in 1:data.n_cities for j in (i+1):data.n_cities] + edges = [(i, j) for i = 1:data.n_cities for j = (i+1):data.n_cities] x = @variable(model, x[edges], Bin) - @objective(model, Min, sum( - x[(i, j)] * data.distances[i, j] for (i, j) in edges - )) + @objective(model, Min, sum(x[(i, j)] * data.distances[i, j] for (i, j) in edges)) # Eq: Must choose two edges adjacent to each node @constraint( model, eq_degree[i in 1:data.n_cities], - sum(x[(min(i, j), max(i, j))] for j in 1:data.n_cities if i != j) == 2 + sum(x[(min(i, j), max(i, j))] for j = 1:data.n_cities if i != j) == 2 ) function lazy_separate(cb_data) @@ -41,10 +42,8 @@ function build_tsp_model_jump(data::Any; optimizer) for component in nx.connected_components(graph) if length(component) < data.n_cities cut_edges = [ - [e[1], e[2]] - for e in edges - if (e[1] ∈ component && e[2] ∉ component) - || + [e[1], e[2]] for + e in edges if (e[1] ∈ component && e[2] ∉ component) || (e[1] ∉ component && e[2] ∈ component) ] push!(violations, cut_edges) @@ -63,9 +62,9 @@ function build_tsp_model_jump(data::Any; optimizer) return JumpModel( model, - lazy_enforce=lazy_enforce, - lazy_separate=lazy_separate, - lp_optimizer=optimizer, + lazy_enforce = lazy_enforce, + lazy_separate = lazy_separate, + lp_optimizer = optimizer, ) end diff --git a/src/solvers/jump.jl b/src/solvers/jump.jl index ede1076..a8ddd15 100644 --- a/src/solvers/jump.jl +++ b/src/solvers/jump.jl @@ -18,7 +18,7 @@ Base.@kwdef mutable struct _JumpModelExtData cuts_separate::Union{Function,Nothing} = nothing lazy_enforce::Union{Function,Nothing} = nothing lazy_separate::Union{Function,Nothing} = nothing - lp_optimizer + lp_optimizer::Any end function JuMP.copy_extension_data( @@ -26,9 +26,7 @@ function JuMP.copy_extension_data( new_model::AbstractModel, ::AbstractModel, ) - new_model.ext[:miplearn] = _JumpModelExtData( - lp_optimizer=old_ext.lp_optimizer - ) + new_model.ext[:miplearn] = _JumpModelExtData(lp_optimizer = old_ext.lp_optimizer) end # ----------------------------------------------------------------------------- @@ -297,7 +295,7 @@ end function _fix_variables(model::JuMP.Model, var_names, var_values, stats) vars = [variable_by_name(model, v) for v in var_names] for (i, var) in enumerate(vars) - fix(var, var_values[i], force=true) + fix(var, var_values[i], force = true) end end @@ -392,19 +390,19 @@ function __init_solvers_jump__() function __init__( self, inner; - cuts_enforce::Union{Function,Nothing}=nothing, - cuts_separate::Union{Function,Nothing}=nothing, - lazy_enforce::Union{Function,Nothing}=nothing, - lazy_separate::Union{Function,Nothing}=nothing, - lp_optimizer=HiGHS.Optimizer, + cuts_enforce::Union{Function,Nothing} = nothing, + cuts_separate::Union{Function,Nothing} = nothing, + lazy_enforce::Union{Function,Nothing} = nothing, + lazy_separate::Union{Function,Nothing} = nothing, + lp_optimizer = HiGHS.Optimizer, ) self.inner = inner self.inner.ext[:miplearn] = _JumpModelExtData( - cuts_enforce=cuts_enforce, - cuts_separate=cuts_separate, - lazy_enforce=lazy_enforce, - lazy_separate=lazy_separate, - lp_optimizer=lp_optimizer, + cuts_enforce = cuts_enforce, + cuts_separate = cuts_separate, + lazy_enforce = lazy_enforce, + lazy_separate = lazy_separate, + lp_optimizer = lp_optimizer, ) end @@ -414,7 +412,7 @@ function __init_solvers_jump__() constrs_lhs, constrs_sense, constrs_rhs, - stats=nothing, + stats = nothing, ) = _add_constrs( self.inner, from_str_array(var_names), @@ -430,14 +428,14 @@ function __init_solvers_jump__() extract_after_mip(self, h5) = _extract_after_mip(self.inner, h5) - fix_variables(self, var_names, var_values, stats=nothing) = + fix_variables(self, var_names, var_values, stats = nothing) = _fix_variables(self.inner, from_str_array(var_names), var_values, stats) optimize(self) = _optimize(self.inner) relax(self) = Class(_relax(self.inner)) - set_warm_starts(self, var_names, var_values, stats=nothing) = + set_warm_starts(self, var_names, var_values, stats = nothing) = _set_warm_starts(self.inner, from_str_array(var_names), var_values, stats) write(self, filename) = _write(self.inner, filename) diff --git a/test/src/MIPLearnT.jl b/test/src/MIPLearnT.jl index 3da1256..c783574 100644 --- a/test/src/MIPLearnT.jl +++ b/test/src/MIPLearnT.jl @@ -43,8 +43,8 @@ function runtests() end function format() - JuliaFormatter.format(BASEDIR, verbose=true) - JuliaFormatter.format("$BASEDIR/../../src", verbose=true) + JuliaFormatter.format(BASEDIR, verbose = true) + JuliaFormatter.format("$BASEDIR/../../src", verbose = true) return end diff --git a/test/src/components/test_cuts.jl b/test/src/components/test_cuts.jl index 4b1bb93..66409fd 100644 --- a/test/src/components/test_cuts.jl +++ b/test/src/components/test_cuts.jl @@ -10,34 +10,32 @@ function gen_stab() randint = pyimport("scipy.stats").randint np.random.seed(42) gen = MaxWeightStableSetGenerator( - w=uniform(10.0, scale=1.0), - n=randint(low=50, high=51), - p=uniform(loc=0.5, scale=0.0), - fix_graph=true, + w = uniform(10.0, scale = 1.0), + n = randint(low = 50, high = 51), + p = uniform(loc = 0.5, scale = 0.0), + fix_graph = true, ) data = gen.generate(1) - data_filenames = write_pkl_gz(data, "$BASEDIR/../fixtures", prefix="stab-n50-") + data_filenames = write_pkl_gz(data, "$BASEDIR/../fixtures", prefix = "stab-n50-") collector = BasicCollector() collector.collect( data_filenames, - data -> build_stab_model_jump(data, optimizer=SCIP.Optimizer), - progress=true, - verbose=true, + data -> build_stab_model_jump(data, optimizer = SCIP.Optimizer), + progress = true, + verbose = true, ) end function test_cuts() data_filenames = ["$BASEDIR/../fixtures/stab-n50-00000.pkl.gz"] clf = pyimport("sklearn.dummy").DummyClassifier() - extractor = H5FieldsExtractor( - instance_fields=["static_var_obj_coeffs"], - ) - comp = MemorizingCutsComponent(clf=clf, extractor=extractor) - solver = LearningSolver(components=[comp]) + extractor = H5FieldsExtractor(instance_fields = ["static_var_obj_coeffs"]) + comp = MemorizingCutsComponent(clf = clf, extractor = extractor) + solver = LearningSolver(components = [comp]) solver.fit(data_filenames) stats = solver.optimize( data_filenames[1], - data -> build_stab_model_jump(data, optimizer=SCIP.Optimizer), + data -> build_stab_model_jump(data, optimizer = SCIP.Optimizer), ) @test stats["Cuts: AOT"] > 0 end diff --git a/test/src/components/test_lazy.jl b/test/src/components/test_lazy.jl index b62c1d8..39beca1 100644 --- a/test/src/components/test_lazy.jl +++ b/test/src/components/test_lazy.jl @@ -11,36 +11,34 @@ function gen_tsp() np.random.seed(42) gen = TravelingSalesmanGenerator( - x=uniform(loc=0.0, scale=1000.0), - y=uniform(loc=0.0, scale=1000.0), - n=randint(low=20, high=21), - gamma=uniform(loc=1.0, scale=0.25), - fix_cities=true, - round=true, + x = uniform(loc = 0.0, scale = 1000.0), + y = uniform(loc = 0.0, scale = 1000.0), + n = randint(low = 20, high = 21), + gamma = uniform(loc = 1.0, scale = 0.25), + fix_cities = true, + round = true, ) data = gen.generate(1) - data_filenames = write_pkl_gz(data, "$BASEDIR/../fixtures", prefix="tsp-n20-") + data_filenames = write_pkl_gz(data, "$BASEDIR/../fixtures", prefix = "tsp-n20-") collector = BasicCollector() collector.collect( data_filenames, - data -> build_tsp_model_jump(data, optimizer=GLPK.Optimizer), - progress=true, - verbose=true, + data -> build_tsp_model_jump(data, optimizer = GLPK.Optimizer), + progress = true, + verbose = true, ) end function test_lazy() data_filenames = ["$BASEDIR/../fixtures/tsp-n20-00000.pkl.gz"] clf = pyimport("sklearn.dummy").DummyClassifier() - extractor = H5FieldsExtractor( - instance_fields=["static_var_obj_coeffs"], - ) - comp = MemorizingLazyComponent(clf=clf, extractor=extractor) - solver = LearningSolver(components=[comp]) + extractor = H5FieldsExtractor(instance_fields = ["static_var_obj_coeffs"]) + comp = MemorizingLazyComponent(clf = clf, extractor = extractor) + solver = LearningSolver(components = [comp]) solver.fit(data_filenames) stats = solver.optimize( data_filenames[1], - data -> build_tsp_model_jump(data, optimizer=GLPK.Optimizer), + data -> build_tsp_model_jump(data, optimizer = GLPK.Optimizer), ) @test stats["Lazy Constraints: AOT"] > 0 end diff --git a/test/src/problems/test_stab.jl b/test/src/problems/test_stab.jl index 7a11a56..1d5dfb8 100644 --- a/test/src/problems/test_stab.jl +++ b/test/src/problems/test_stab.jl @@ -8,11 +8,11 @@ using SCIP function test_problems_stab() nx = pyimport("networkx") data = MaxWeightStableSetData( - graph=nx.gnp_random_graph(25, 0.5, seed=42), - weights=repeat([1.0], 25), + graph = nx.gnp_random_graph(25, 0.5, seed = 42), + weights = repeat([1.0], 25), ) h5 = H5File(tempname(), "w") - model = build_stab_model_jump(data, optimizer=SCIP.Optimizer) + model = build_stab_model_jump(data, optimizer = SCIP.Optimizer) model.extract_after_load(h5) model.optimize() model.extract_after_mip(h5) diff --git a/test/src/problems/test_tsp.jl b/test/src/problems/test_tsp.jl index b56c01f..56bc32f 100644 --- a/test/src/problems/test_tsp.jl +++ b/test/src/problems/test_tsp.jl @@ -10,17 +10,12 @@ function test_problems_tsp() squareform = pyimport("scipy.spatial.distance").squareform data = TravelingSalesmanData( - n_cities=6, - distances=squareform(pdist([ - [0.0, 0.0], - [1.0, 0.0], - [2.0, 0.0], - [3.0, 0.0], - [0.0, 1.0], - [3.0, 1.0], - ])), + n_cities = 6, + distances = squareform( + pdist([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0], [0.0, 1.0], [3.0, 1.0]]), + ), ) - model = build_tsp_model_jump(data, optimizer=GLPK.Optimizer) + model = build_tsp_model_jump(data, optimizer = GLPK.Optimizer) model.optimize() @test objective_value(model.inner) == 8.0 return diff --git a/test/src/test_io.jl b/test/src/test_io.jl index 2dcd46d..95c0087 100644 --- a/test/src/test_io.jl +++ b/test/src/test_io.jl @@ -46,7 +46,7 @@ function test_jld2() _TestStruct(2, [1.0, 2.0, 3.0]), _TestStruct(3, [3.0, 3.0, 3.0]), ] - filenames = write_jld2(data, dirname, prefix="obj") + filenames = write_jld2(data, dirname, prefix = "obj") @test all( filenames .== ["$dirname/obj00001.jld2", "$dirname/obj00002.jld2", "$dirname/obj00003.jld2"], diff --git a/test/src/test_usage.jl b/test/src/test_usage.jl index 1965e0e..9a8cb7b 100644 --- a/test/src/test_usage.jl +++ b/test/src/test_usage.jl @@ -13,16 +13,16 @@ function test_usage() @debug "Setting up LearningSolver..." solver = LearningSolver( - components=[ + components = [ IndependentVarsPrimalComponent( - base_clf=SingleClassFix( + base_clf = SingleClassFix( MinProbabilityClassifier( - base_clf=LogisticRegression(), - thresholds=[0.95, 0.95], + base_clf = LogisticRegression(), + thresholds = [0.95, 0.95], ), ), - extractor=AlvLouWeh2017Extractor(), - action=SetWarmStart(), + extractor = AlvLouWeh2017Extractor(), + action = SetWarmStart(), ), ], ) From 1c204d765ea5f5f29a6ff3c48fda8f65a5e5485e Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Wed, 29 May 2024 09:48:54 -0500 Subject: [PATCH 14/34] Add gmi test; update H5 --- src/Cuts/Cuts.jl | 1 - src/Cuts/tableau/gmi.jl | 2 +- test/Project.toml | 1 + test/fixtures/bell5.h5 | Bin 95805 -> 37123 bytes test/fixtures/vpm2.h5 | Bin 50063 -> 34284 bytes test/src/BB/test_bb.jl | 8 ++------ test/src/Cuts/tableau/test_gmi.jl | 23 +++++++++++++++++++++++ test/src/MIPLearnT.jl | 1 + test/src/test_io.jl | 11 +++++++++++ 9 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 test/src/Cuts/tableau/test_gmi.jl diff --git a/src/Cuts/Cuts.jl b/src/Cuts/Cuts.jl index b2a4254..afa60c7 100644 --- a/src/Cuts/Cuts.jl +++ b/src/Cuts/Cuts.jl @@ -10,7 +10,6 @@ include("tableau/structs.jl") # include("blackbox/cplex.jl") include("tableau/numerics.jl") -include("tableau/collect.jl") include("tableau/gmi.jl") include("tableau/moi.jl") include("tableau/tableau.jl") diff --git a/src/Cuts/tableau/gmi.jl b/src/Cuts/tableau/gmi.jl index fd04b36..14febbb 100644 --- a/src/Cuts/tableau/gmi.jl +++ b/src/Cuts/tableau/gmi.jl @@ -154,7 +154,7 @@ function collect_gmi( else all_cuts.lhs = [all_cuts.lhs; cuts.lhs[keep, :]] all_cuts.lb = [all_cuts.lb; cuts.lb[keep]] - all_cuts.lb = [all_cuts.lb; cuts.lb[keep]] + all_cuts.ub = [all_cuts.ub; cuts.ub[keep]] end push!(stats_ncuts, length(all_cuts.lb)) diff --git a/test/Project.toml b/test/Project.toml index e5abcbf..ce8384e 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -17,6 +17,7 @@ MIPLearn = "2b1277c3-b477-4c49-a15e-7ba350325c68" PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" SCIP = "82193955-e24f-5292-bf16-6f2c5261a85f" +SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] diff --git a/test/fixtures/bell5.h5 b/test/fixtures/bell5.h5 index afff1ab29a3dfcea40cfeb412477c6ea0b12d688..541b0a6ae5b6f970bc5b62c058a2d6e9ad010af5 100644 GIT binary patch delta 11740 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 literal 95805 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 O|3IFnJpN5a@qYozA>ID~ diff --git a/test/fixtures/vpm2.h5 b/test/fixtures/vpm2.h5 index 94816799a49a46bb20bc9f187b58687d1ac4e99f..fc4a159e66486731a467ddee9a011794e9562882 100644 GIT binary patch delta 10231 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 0 + @test n_cuts == length(cuts_ub) + @test cuts_lhs.shape[1] == n_cuts + finally + h5.close() + end +end diff --git a/test/src/MIPLearnT.jl b/test/src/MIPLearnT.jl index c783574..f91d315 100644 --- a/test/src/MIPLearnT.jl +++ b/test/src/MIPLearnT.jl @@ -19,6 +19,7 @@ include("BB/test_bb.jl") include("components/test_cuts.jl") include("components/test_lazy.jl") include("Cuts/BlackBox/test_cplex.jl") +include("Cuts/tableau/test_gmi.jl") include("problems/test_setcover.jl") include("problems/test_stab.jl") include("problems/test_tsp.jl") diff --git a/test/src/test_io.jl b/test/src/test_io.jl index 95c0087..88745f9 100644 --- a/test/src/test_io.jl +++ b/test/src/test_io.jl @@ -4,6 +4,7 @@ using MIPLearn using JLD2 +using SparseArrays struct _TestStruct n::Int @@ -35,6 +36,8 @@ function test_h5() _test_roundtrip_array(h5, [1, 2, 3]) _test_roundtrip_array(h5, [1.0, 2.0, 3.0]) _test_roundtrip_str_array(h5, ["A", "BB", "CCC"]) + _test_roundtrip_sparse(h5, sparse([1; 2; 3], [1; 2; 3], [1; 2; 3])) + # _test_roundtrip_sparse(h5, sparse([1; 2; 3], [1; 2; 3], [1; 2; 3], 4, 4)) @test h5.get_array("unknown-key") === nothing h5.close() end @@ -79,3 +82,11 @@ function _test_roundtrip_str_array(h5, original) @test recovered !== nothing @test all(original .== recovered) end + +function _test_roundtrip_sparse(h5, original) + h5.put_sparse("key", original) + recovered = MIPLearn.convert(SparseMatrixCSC, h5.get_sparse("key")) + @test recovered !== nothing + @test size(original) == size(recovered) + @test all(original .== recovered) +end From 00fe4d07d2b4e6458eb31fd154f0b4ff70f1ac7c Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sun, 2 Jun 2024 05:10:33 -0500 Subject: [PATCH 15/34] Add gmi_dual --- src/Cuts/Cuts.jl | 1 + src/Cuts/tableau/gmi.jl | 1 - src/Cuts/tableau/gmi_dual.jl | 323 +++++++++++++++++++++++++ test/src/Cuts/tableau/test_gmi_dual.jl | 22 ++ test/src/MIPLearnT.jl | 1 + test/src/components/test_cuts.jl | 2 +- test/src/components/test_lazy.jl | 2 +- 7 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 src/Cuts/tableau/gmi_dual.jl create mode 100644 test/src/Cuts/tableau/test_gmi_dual.jl diff --git a/src/Cuts/Cuts.jl b/src/Cuts/Cuts.jl index afa60c7..f46f12d 100644 --- a/src/Cuts/Cuts.jl +++ b/src/Cuts/Cuts.jl @@ -11,6 +11,7 @@ include("tableau/structs.jl") # include("blackbox/cplex.jl") include("tableau/numerics.jl") include("tableau/gmi.jl") +include("tableau/gmi_dual.jl") include("tableau/moi.jl") include("tableau/tableau.jl") include("tableau/transform.jl") diff --git a/src/Cuts/tableau/gmi.jl b/src/Cuts/tableau/gmi.jl index 14febbb..c83d520 100644 --- a/src/Cuts/tableau/gmi.jl +++ b/src/Cuts/tableau/gmi.jl @@ -16,7 +16,6 @@ function collect_gmi( max_cuts_per_round = 100, atol = 1e-4, ) - @info mps_filename reset_timer!() # Open HDF5 file diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl new file mode 100644 index 0000000..7b7d877 --- /dev/null +++ b/src/Cuts/tableau/gmi_dual.jl @@ -0,0 +1,323 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2023, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using Printf +using JuMP + +Base.@kwdef mutable struct ConstraintSet_v2 + lhs::SparseMatrixCSC + ub::Vector{Float64} + lb::Vector{Float64} + Bss::Vector{Basis} + Bv::Vector{Int64} +end + +function collect_gmi_dual( + mps_filename; + optimizer, + max_rounds = 10, + max_cuts_per_round = 500, +) + reset_timer!() + + # Open HDF5 file + h5_filename = replace(mps_filename, ".mps.gz" => ".h5") + h5 = H5File(h5_filename) + + # Read optimal solution + sol_opt_dict = Dict( + zip( + h5.get_array("static_var_names"), + convert(Array{Float64}, h5.get_array("mip_var_values")), + ), + ) + + # Read optimal value + obj_mip = h5.get_scalar("mip_lower_bound") + if obj_mip === nothing + obj_mip = h5.get_scalar("mip_obj_value") + end + obj_lp = h5.get_scalar("lp_obj_value") + h5.file.close() + + # Define relative MIP gap + gap(v) = 100 * abs(obj_mip - v) / abs(v) + + # Initialize stats + stats_obj = [] + stats_gap = [] + stats_ncuts = [] + stats_time_convert = 0 + stats_time_solve = 0 + stats_time_select = 0 + stats_time_tableau = 0 + stats_time_gmi = 0 + stats_time_dual = 0 + stats_time_dual_2 = 0 + all_cuts = nothing + all_cuts_v2 = nothing + cuts_all = nothing + cuts_all_v2 = nothing + original_basis = nothing + + # Read problem + model = read_from_file(mps_filename) + + # Read original objective function + or_obj_f = objective_function(model) + revised_obj = objective_function(model) + + for round = 1:max_rounds + @info "Round $(round)..." + + stats_time_convert = @elapsed begin + # Update objective function + set_objective_function(model, revised_obj) + + # Extract problem data + data = ProblemData(model) + + # Construct optimal solution vector (with correct variable sequence) + sol_opt = [sol_opt_dict[n] for n in data.var_names] + + # Assert optimal solution is feasible for the original problem + assert_leq(data.constr_lb, data.constr_lhs * sol_opt) + assert_leq(data.constr_lhs * sol_opt, data.constr_ub) + + # Convert to standard form + data_s, transforms = convert_to_standard_form(data) + model_s = to_model(data_s) + set_optimizer(model_s, optimizer) + relax_integrality(model_s) + + # Convert optimal solution to standard form + sol_opt_s = forward(transforms, sol_opt) + + # Assert converted solution is feasible for standard form problem + assert_eq(data_s.constr_lhs * sol_opt_s, data_s.constr_lb) + + end + + # Optimize standard form + optimize!(model_s) + stats_time_solve += solve_time(model_s) + obj = objective_value(model_s) + data_s.obj_offset + + if round == 1 + # Assert standard form problem has same value as original + assert_eq(obj, obj_lp) + push!(stats_obj, obj) + push!(stats_gap, gap(obj)) + push!(stats_ncuts, 0) + end + if termination_status(model_s) != MOI.OPTIMAL + return + end + + # Store original basis and select tableau rows + basis = get_basis(model_s) + if round == 1 + original_basis = basis + end + sol_frac = get_x(model_s) + stats_time_select += @elapsed begin + selected_rows = + select_gmi_rows(data_s, basis, sol_frac, max_rows = max_cuts_per_round) + end + + # Compute selected tableau rows + stats_time_tableau += @elapsed begin + tableau = compute_tableau(data_s, basis, sol_frac, rows = selected_rows) + + # Assert tableau rows have been computed correctly + assert_eq(tableau.lhs * sol_frac, tableau.rhs) + assert_eq(tableau.lhs * sol_opt_s, tableau.rhs) + + end + + # Compute GMI cuts + stats_time_gmi += @elapsed begin + cuts_s = compute_gmi(data_s, tableau) + + # Assert cuts have been generated correctly + assert_cuts_off(cuts_s, sol_frac) + assert_does_not_cut_off(cuts_s, sol_opt_s) + + # Abort if no cuts are left + if length(cuts_s.lb) == 0 + @info "No cuts generated. Aborting." + continue + end + end + + # Add GMI cuts to original problem + cuts = backwards(transforms, cuts_s) + if round == 1 + cuts_all = cuts + basis_vec = repeat([basis], length(selected_rows)) + cuts_all_v2 = + ConstraintSet_v2(cuts.lhs, cuts.ub, cuts.lb, basis_vec, selected_rows) + else + # v1 struct + cuts_all.lb = [cuts_all.lb; cuts.lb] + cuts_all.ub = [cuts_all.ub; cuts.ub] + cuts_all.lhs = [cuts_all.lhs; cuts.lhs] + + # v2 struct + cuts_all_v2.lb = [cuts_all_v2.lb; cuts.lb] + cuts_all_v2.ub = [cuts_all_v2.ub; cuts.ub] + cuts_all_v2.lhs = [cuts_all_v2.lhs; cuts.lhs] + cuts_all_v2.Bss = [cuts_all_v2.Bss; repeat([basis], length(selected_rows))] + cuts_all_v2.Bv = [cuts_all_v2.Bv; selected_rows] + end + constrs, gmi_exps = add_constraint_set_dual_v2(model, cuts_all) + + # Optimize original form + set_objective_function(model, or_obj_f) + set_optimizer(model, optimizer) + undo_relax = relax_integrality(model) + optimize!(model) + obj = objective_value(model) + push!(stats_obj, obj) + push!(stats_gap, gap(obj)) + + # Reoptimize with updated obj function + stats_time_dual += @elapsed begin + revised_obj = ( + or_obj_f - sum( + shadow_price(c) * gmi_exps[iz] for (iz, c) in enumerate(constrs) + ) + ) + delete.(model, constrs) + set_objective_function(model, revised_obj) + set_optimizer(model, optimizer) + optimize!(model) + n_obj = objective_value(model) + @assert obj ≈ n_obj + end + undo_relax() + end + + # Filter out useless cuts + stats_time_dual_2 += @elapsed begin + set_objective_function(model, or_obj_f) + keep = [] + obj_gmi = obj_lp + if (cuts_all !== nothing) + constrs, gmi_exps = add_constraint_set_dual_v2(model, cuts_all) + for (i, c) in enumerate(constrs) + set_name(c, @sprintf("gomory_%05d", i)) + end + set_optimizer(model, optimizer) + undo_relax = relax_integrality(model) + optimize!(model) + obj = objective_value(model) + obj_gmi = obj + push!(stats_obj, obj) + push!(stats_gap, gap(obj)) + + # Store useful cuts; drop useless ones from the problem + useful = [-shadow_price(c) > 1e-3 for c in constrs] + drop = findall(useful .== false) + keep = findall(useful .== true) + all_cuts = ConstraintSet(; + lhs = cuts_all.lhs[keep, :], + lb = cuts_all.lb[keep], + ub = cuts_all.ub[keep], + ) + all_cuts_v2 = ConstraintSet_v2(; + lhs = cuts_all_v2.lhs[keep, :], + lb = cuts_all_v2.lb[keep], + ub = cuts_all_v2.ub[keep], + Bss = cuts_all_v2.Bss[keep], + Bv = cuts_all_v2.Bv[keep], + ) + + delete.(model, constrs[drop]) + undo_relax() + end + end + basis = original_basis + + cut_sizezz = length(all_cuts_v2.Bv) + var_totall = + length(basis.var_basic) + + length(basis.var_nonbasic) + + length(basis.constr_basic) + + length(basis.constr_nonbasic) + bm_size = Array{Int64,2}(undef, cut_sizezz, 4) + basis_matrix = Array{Int64,2}(undef, cut_sizezz, var_totall) + + for ii = 1:cut_sizezz + vb = all_cuts_v2.Bss[ii].var_basic + vn = all_cuts_v2.Bss[ii].var_nonbasic + cb = all_cuts_v2.Bss[ii].constr_basic + cn = all_cuts_v2.Bss[ii].constr_nonbasic + bm_size[ii, :] = [length(vb) length(vn) length(cb) length(cn)] + basis_matrix[ii, :] = [vb' vn' cb' cn'] + end + + # Store cuts + if all_cuts !== nothing + @info "Storing $(length(all_cuts.ub)) GMI cuts..." + h5 = H5File(h5_filename) + h5.put_sparse("cuts_lhs", all_cuts.lhs) + h5.put_array("cuts_lb", all_cuts.lb) + h5.put_array("cuts_ub", all_cuts.ub) + h5.put_array("cuts_basis_vars", basis_matrix) + h5.put_array("cuts_basis_sizes", bm_size) + h5.put_array("cuts_rows", all_cuts_v2.Bv) + h5.file.close() + end + + return OrderedDict( + "instance" => mps_filename, + "max_rounds" => max_rounds, + "rounds" => length(stats_obj) - 1, + "time_convert" => stats_time_convert, + "time_solve" => stats_time_solve, + "time_tableau" => stats_time_tableau, + "time_gmi" => stats_time_gmi, + "time_dual" => stats_time_dual, + "time_dual_2" => stats_time_dual_2, + "obj_mip" => obj_mip, + "obj_lp" => obj_lp, + "stats_obj" => stats_obj, + "stats_gap" => stats_gap, + "stats_ncuts" => length(keep), + ) + +end + +function add_constraint_set_dual_v2(model::JuMP.Model, cs::ConstraintSet) + vars = all_variables(model) + nrows, ncols = size(cs.lhs) + constrs = [] + gmi_exps = [] + for i = 1:nrows + c = nothing + gmi_exp = nothing + gmi_exp2 = nothing + expr = @expression(model, sum(cs.lhs[i, j] * vars[j] for j = 1:ncols)) + if isinf(cs.ub[i]) + c = @constraint(model, cs.lb[i] <= expr) + gmi_exp = cs.lb[i] - expr + elseif isinf(cs.lb[i]) + c = @constraint(model, expr <= cs.ub[i]) + gmi_exp = expr - cs.ub[i] + else + c = @constraint(model, cs.lb[i] <= expr <= cs.ub[i]) + gmi_exp = cs.lb[i] - expr + gmi_exp2 = expr - cs.ub[i] + end + push!(constrs, c) + push!(gmi_exps, gmi_exp) + if !isnothing(gmi_exp2) + push!(gmi_exps, gmi_exp2) + end + end + return constrs, gmi_exps +end + +export collect_gmi_dual diff --git a/test/src/Cuts/tableau/test_gmi_dual.jl b/test/src/Cuts/tableau/test_gmi_dual.jl new file mode 100644 index 0000000..92858c6 --- /dev/null +++ b/test/src/Cuts/tableau/test_gmi_dual.jl @@ -0,0 +1,22 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2024, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +using HiGHS + +function test_cuts_tableau_gmi_dual() + mps_filename = "$BASEDIR/../fixtures/bell5.mps.gz" + h5_filename = "$BASEDIR/../fixtures/bell5.h5" + stats = collect_gmi_dual(mps_filename, optimizer = HiGHS.Optimizer) + h5 = H5File(h5_filename, "r") + try + cuts_basis_vars = h5.get_array("cuts_basis_vars") + cuts_basis_sizes = h5.get_array("cuts_basis_sizes") + cuts_rows = h5.get_array("cuts_rows") + @test size(cuts_basis_vars) == (15, 402) + @test size(cuts_basis_sizes) == (15,4) + @test size(cuts_rows) == (15,) + finally + h5.close() + end +end diff --git a/test/src/MIPLearnT.jl b/test/src/MIPLearnT.jl index f91d315..e52e920 100644 --- a/test/src/MIPLearnT.jl +++ b/test/src/MIPLearnT.jl @@ -20,6 +20,7 @@ include("components/test_cuts.jl") include("components/test_lazy.jl") include("Cuts/BlackBox/test_cplex.jl") include("Cuts/tableau/test_gmi.jl") +include("Cuts/tableau/test_gmi_dual.jl") include("problems/test_setcover.jl") include("problems/test_stab.jl") include("problems/test_tsp.jl") diff --git a/test/src/components/test_cuts.jl b/test/src/components/test_cuts.jl index 66409fd..9f88b3e 100644 --- a/test/src/components/test_cuts.jl +++ b/test/src/components/test_cuts.jl @@ -33,7 +33,7 @@ function test_cuts() comp = MemorizingCutsComponent(clf = clf, extractor = extractor) solver = LearningSolver(components = [comp]) solver.fit(data_filenames) - stats = solver.optimize( + model, stats = solver.optimize( data_filenames[1], data -> build_stab_model_jump(data, optimizer = SCIP.Optimizer), ) diff --git a/test/src/components/test_lazy.jl b/test/src/components/test_lazy.jl index 39beca1..0cd0481 100644 --- a/test/src/components/test_lazy.jl +++ b/test/src/components/test_lazy.jl @@ -36,7 +36,7 @@ function test_lazy() comp = MemorizingLazyComponent(clf = clf, extractor = extractor) solver = LearningSolver(components = [comp]) solver.fit(data_filenames) - stats = solver.optimize( + model, stats = solver.optimize( data_filenames[1], data -> build_tsp_model_jump(data, optimizer = GLPK.Optimizer), ) From beab75a16d951a43019761488a927e1cb8bbb9e3 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 6 Jun 2024 10:59:36 -0500 Subject: [PATCH 16/34] Implement expert and knn dual gmi component --- src/Cuts/Cuts.jl | 6 + src/Cuts/tableau/gmi_dual.jl | 258 ++++++++++++++++++++++--- src/Cuts/tableau/moi.jl | 30 +-- src/Cuts/tableau/tableau.jl | 26 +-- test/src/Cuts/tableau/test_gmi_dual.jl | 52 ++++- 5 files changed, 322 insertions(+), 50 deletions(-) diff --git a/src/Cuts/Cuts.jl b/src/Cuts/Cuts.jl index f46f12d..1e35c2e 100644 --- a/src/Cuts/Cuts.jl +++ b/src/Cuts/Cuts.jl @@ -4,6 +4,8 @@ module Cuts +using PyCall + import ..to_str_array include("tableau/structs.jl") @@ -16,4 +18,8 @@ include("tableau/moi.jl") include("tableau/tableau.jl") include("tableau/transform.jl") +function __init__() + __init_gmi_dual__() +end + end # module diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index 7b7d877..c8a5e7d 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -4,6 +4,17 @@ using Printf using JuMP +using HiGHS + +global ExpertDualGmiComponent = PyNULL() +global KnnDualGmiComponent = PyNULL() + +Base.@kwdef mutable struct _KnnDualGmiData + k = nothing + extractor = nothing + train_h5 = nothing + model = nothing +end Base.@kwdef mutable struct ConstraintSet_v2 lhs::SparseMatrixCSC @@ -106,7 +117,10 @@ function collect_gmi_dual( if round == 1 # Assert standard form problem has same value as original - assert_eq(obj, obj_lp) + if obj_lp !== nothing + assert_eq(obj, obj_lp) + end + obj_lp = obj push!(stats_obj, obj) push!(stats_gap, gap(obj)) push!(stats_ncuts, 0) @@ -128,7 +142,7 @@ function collect_gmi_dual( # Compute selected tableau rows stats_time_tableau += @elapsed begin - tableau = compute_tableau(data_s, basis, sol_frac, rows = selected_rows) + tableau = compute_tableau(data_s, basis, x = sol_frac, rows = selected_rows) # Assert tableau rows have been computed correctly assert_eq(tableau.lhs * sol_frac, tableau.rhs) @@ -147,7 +161,7 @@ function collect_gmi_dual( # Abort if no cuts are left if length(cuts_s.lb) == 0 @info "No cuts generated. Aborting." - continue + break end end @@ -194,7 +208,7 @@ function collect_gmi_dual( set_optimizer(model, optimizer) optimize!(model) n_obj = objective_value(model) - @assert obj ≈ n_obj + assert_eq(obj, n_obj, atol = 0.01) end undo_relax() end @@ -240,26 +254,26 @@ function collect_gmi_dual( end basis = original_basis - cut_sizezz = length(all_cuts_v2.Bv) - var_totall = - length(basis.var_basic) + - length(basis.var_nonbasic) + - length(basis.constr_basic) + - length(basis.constr_nonbasic) - bm_size = Array{Int64,2}(undef, cut_sizezz, 4) - basis_matrix = Array{Int64,2}(undef, cut_sizezz, var_totall) - - for ii = 1:cut_sizezz - vb = all_cuts_v2.Bss[ii].var_basic - vn = all_cuts_v2.Bss[ii].var_nonbasic - cb = all_cuts_v2.Bss[ii].constr_basic - cn = all_cuts_v2.Bss[ii].constr_nonbasic - bm_size[ii, :] = [length(vb) length(vn) length(cb) length(cn)] - basis_matrix[ii, :] = [vb' vn' cb' cn'] - end - - # Store cuts if all_cuts !== nothing + cut_sizezz = length(all_cuts_v2.Bv) + var_totall = + length(basis.var_basic) + + length(basis.var_nonbasic) + + length(basis.constr_basic) + + length(basis.constr_nonbasic) + bm_size = Array{Int64,2}(undef, cut_sizezz, 4) + basis_matrix = Array{Int64,2}(undef, cut_sizezz, var_totall) + + for ii = 1:cut_sizezz + vb = all_cuts_v2.Bss[ii].var_basic + vn = all_cuts_v2.Bss[ii].var_nonbasic + cb = all_cuts_v2.Bss[ii].constr_basic + cn = all_cuts_v2.Bss[ii].constr_nonbasic + bm_size[ii, :] = [length(vb) length(vn) length(cb) length(cn)] + basis_matrix[ii, :] = [vb' vn' cb' cn'] + end + + # Store cuts @info "Storing $(length(all_cuts.ub)) GMI cuts..." h5 = H5File(h5_filename) h5.put_sparse("cuts_lhs", all_cuts.lhs) @@ -287,7 +301,101 @@ function collect_gmi_dual( "stats_gap" => stats_gap, "stats_ncuts" => length(keep), ) +end +function ExpertDualGmiComponent_before_mip(test_h5, model, stats) + # Read cuts and optimal solution + h5 = H5File(test_h5) + sol_opt_dict = Dict( + zip( + h5.get_array("static_var_names"), + convert(Array{Float64}, h5.get_array("mip_var_values")), + ), + ) + cut_basis_vars = h5.get_array("cuts_basis_vars") + cut_basis_sizes = h5.get_array("cuts_basis_sizes") + cut_rows = h5.get_array("cuts_rows") + obj_mip = h5.get_scalar("mip_lower_bound") + if obj_mip === nothing + obj_mip = h5.get_scalar("mip_obj_value") + end + h5.close() + + # Initialize stats + stats_time_convert = 0 + stats_time_tableau = 0 + stats_time_gmi = 0 + all_cuts = [] + + stats_time_convert = @elapsed begin + # Extract problem data + data = ProblemData(model) + + # Construct optimal solution vector (with correct variable sequence) + sol_opt = [sol_opt_dict[n] for n in data.var_names] + + # Assert optimal solution is feasible for the original problem + assert_leq(data.constr_lb, data.constr_lhs * sol_opt) + assert_leq(data.constr_lhs * sol_opt, data.constr_ub) + + # Convert to standard form + data_s, transforms = convert_to_standard_form(data) + model_s = to_model(data_s) + set_optimizer(model_s, HiGHS.Optimizer) + relax_integrality(model_s) + + # Convert optimal solution to standard form + sol_opt_s = forward(transforms, sol_opt) + + # Assert converted solution is feasible for standard form problem + assert_eq(data_s.constr_lhs * sol_opt_s, data_s.constr_lb) + + end + + current_basis = nothing + for (r, row) in enumerate(cut_rows) + stats_time_tableau += @elapsed begin + if r == 1 || cut_basis_vars[r, :] != cut_basis_vars[r-1, :] + vbb, vnn, cbb, cnn = cut_basis_sizes[r, :] + current_basis = Basis(; + var_basic = cut_basis_vars[r, 1:vbb], + var_nonbasic = cut_basis_vars[r, vbb+1:vbb+vnn], + constr_basic = cut_basis_vars[r, vbb+vnn+1:vbb+vnn+cbb], + constr_nonbasic = cut_basis_vars[r, vbb+vnn+cbb+1:vbb+vnn+cbb+cnn], + ) + end + tableau = compute_tableau(data_s, current_basis, rows = [row]) + assert_eq(tableau.lhs * sol_opt_s, tableau.rhs) + end + stats_time_gmi += @elapsed begin + cuts_s = compute_gmi(data_s, tableau) + assert_does_not_cut_off(cuts_s, sol_opt_s) + end + cuts = backwards(transforms, cuts_s) + assert_does_not_cut_off(cuts, sol_opt) + push!(all_cuts, cuts) + end + + function cut_callback(cb_data) + if all_cuts !== nothing + @info "Enforcing dual GMI cuts..." + for cuts in all_cuts + constrs = build_constraints(model, cuts) + for c in constrs + MOI.submit(model, MOI.UserCut(cb_data), c) + end + end + all_cuts = nothing + end + end + + # Set up cut callback + set_attribute(model, MOI.UserCutCallback(), cut_callback) + + stats["gmi_time_convert"] = stats_time_convert + stats["gmi_time_tableau"] = stats_time_tableau + stats["gmi_time_gmi"] = stats_time_gmi + return end function add_constraint_set_dual_v2(model::JuMP.Model, cs::ConstraintSet) @@ -320,4 +428,106 @@ function add_constraint_set_dual_v2(model::JuMP.Model, cs::ConstraintSet) return constrs, gmi_exps end -export collect_gmi_dual +function _dualgmi_features(h5_filename, extractor) + h5 = H5File(h5_filename, "r") + try + return extractor.get_instance_features(h5) + finally + h5.close() + end +end + +function _dualgmi_generate(train_h5, model) + data = ProblemData(model) + data_s, transforms = convert_to_standard_form(data) + all_cuts = [] + for h5_filename in train_h5 + h5 = H5File(h5_filename) + cut_basis_vars = h5.get_array("cuts_basis_vars") + cut_basis_sizes = h5.get_array("cuts_basis_sizes") + cut_rows = h5.get_array("cuts_rows") + h5.close() + current_basis = nothing + for (r, row) in enumerate(cut_rows) + if r == 1 || cut_basis_vars[r, :] != cut_basis_vars[r-1, :] + vbb, vnn, cbb, cnn = cut_basis_sizes[r, :] + current_basis = Basis(; + var_basic = cut_basis_vars[r, 1:vbb], + var_nonbasic = cut_basis_vars[r, vbb+1:vbb+vnn], + constr_basic = cut_basis_vars[r, vbb+vnn+1:vbb+vnn+cbb], + constr_nonbasic = cut_basis_vars[r, vbb+vnn+cbb+1:vbb+vnn+cbb+cnn], + ) + end + tableau = compute_tableau(data_s, current_basis, rows = [row]) + cuts_s = compute_gmi(data_s, tableau) + cuts = backwards(transforms, cuts_s) + push!(all_cuts, cuts) + end + end + return all_cuts +end + +function _dualgmi_set_callback(model, all_cuts) + function cut_callback(cb_data) + if all_cuts !== nothing + @info "Dual GMI: Submitting cuts..." + for cuts in all_cuts + constrs = build_constraints(model, cuts) + for c in constrs + MOI.submit(model, MOI.UserCut(cb_data), c) + end + end + all_cuts = nothing + end + end + set_attribute(model, MOI.UserCutCallback(), cut_callback) +end + +function KnnDualGmiComponent_fit(data::_KnnDualGmiData, train_h5) + x = hcat([ + _dualgmi_features(filename, data.extractor) + for filename in train_h5 + ]...)' + model = pyimport("sklearn.neighbors").NearestNeighbors(n_neighbors=data.k) + model.fit(x) + data.model = model + data.train_h5 = train_h5 +end + + +function KnnDualGmiComponent_before_mip(data::_KnnDualGmiData, test_h5, model, stats) + x = _dualgmi_features(test_h5, data.extractor) + x = reshape(x, 1, length(x)) + selected = vec(data.model.kneighbors(x, return_distance=false)) .+ 1 + @info "Dual GMI: Nearest neighbors:" + for h5_filename in data.train_h5[selected] + @info " $(h5_filename)" + end + cuts = _dualgmi_generate(data.train_h5[selected], model) + _dualgmi_set_callback(model, cuts) +end + +function __init_gmi_dual__() + @pydef mutable struct Class1 + function fit(_, _) end + function before_mip(self, test_h5, model, stats) + ExpertDualGmiComponent_before_mip(test_h5, model.inner, stats) + end + end + copy!(ExpertDualGmiComponent, Class1) + + @pydef mutable struct Class2 + function __init__(self; extractor, k = 3) + self.data = _KnnDualGmiData(; extractor, k) + end + function fit(self, train_h5) + KnnDualGmiComponent_fit(self.data, train_h5) + end + function before_mip(self, test_h5, model, stats) + KnnDualGmiComponent_before_mip(self.data, test_h5, model.inner, stats) + end + end + copy!(KnnDualGmiComponent, Class2) +end + +export collect_gmi_dual, expert_gmi_dual, ExpertDualGmiComponent, KnnDualGmiComponent diff --git a/src/Cuts/tableau/moi.jl b/src/Cuts/tableau/moi.jl index ec9bdea..49d6455 100644 --- a/src/Cuts/tableau/moi.jl +++ b/src/Cuts/tableau/moi.jl @@ -140,28 +140,36 @@ function to_model(data::ProblemData, tol = 1e-6)::Model end function add_constraint_set(model::JuMP.Model, cs::ConstraintSet) + constrs = build_constraints(model, cs) + for c in constrs + add_constraint(model, c) + end + return constrs +end + +function set_warm_start(model::JuMP.Model, x::Vector{Float64}) + vars = all_variables(model) + for (i, xi) in enumerate(x) + set_start_value(vars[i], xi) + end +end + +function build_constraints(model::JuMP.Model, cs::ConstraintSet) vars = all_variables(model) nrows, _ = size(cs.lhs) constrs = [] for i = 1:nrows c = nothing if isinf(cs.ub[i]) - c = @constraint(model, cs.lb[i] <= dot(cs.lhs[i, :], vars)) + c = @build_constraint(cs.lb[i] <= dot(cs.lhs[i, :], vars)) elseif isinf(cs.lb[i]) - c = @constraint(model, dot(cs.lhs[i, :], vars) <= cs.ub[i]) + c = @build_constraint(dot(cs.lhs[i, :], vars) <= cs.ub[i]) else - c = @constraint(model, cs.lb[i] <= dot(cs.lhs[i, :], vars) <= cs.ub[i]) + c = @build_constraint(cs.lb[i] <= dot(cs.lhs[i, :], vars) <= cs.ub[i]) end push!(constrs, c) end return constrs end -function set_warm_start(model::JuMP.Model, x::Vector{Float64}) - vars = all_variables(model) - for (i, xi) in enumerate(x) - set_start_value(vars[i], xi) - end -end - -export to_model, ProblemData, add_constraint_set, set_warm_start +export to_model, ProblemData, add_constraint_set, set_warm_start, build_constraints diff --git a/src/Cuts/tableau/tableau.jl b/src/Cuts/tableau/tableau.jl index ee389c9..f0625d3 100644 --- a/src/Cuts/tableau/tableau.jl +++ b/src/Cuts/tableau/tableau.jl @@ -54,8 +54,8 @@ end function compute_tableau( data::ProblemData, - basis::Basis, - x::Vector{Float64}; + basis::Basis; + x::Union{Nothing,Vector{Float64}} = nothing, rows::Union{Vector{Int},Nothing} = nothing, tol = 1e-8, )::Tableau @@ -73,7 +73,8 @@ function compute_tableau( factor = klu(sparse(lhs_b')) end - @timeit "Compute tableau LHS" begin + @timeit "Compute tableau" begin + tableau_rhs = [] tableau_lhs_I = Int[] tableau_lhs_J = Int[] tableau_lhs_V = Float64[] @@ -88,6 +89,8 @@ function compute_tableau( end @timeit "Multiply" begin row = sol' * data.constr_lhs + rhs = sol' * data.constr_ub + push!(tableau_rhs, rhs) end @timeit "Sparsify & copy" begin for (j, v) in enumerate(row) @@ -104,22 +107,19 @@ function compute_tableau( sparse(tableau_lhs_I, tableau_lhs_J, tableau_lhs_V, length(rows), ncols) end - @timeit "Compute tableau RHS" begin - tableau_rhs = [x[basis.var_basic]; zeros(length(basis.constr_basic))][rows] - end - @timeit "Compute tableau objective row" begin sol = factor \ obj_b tableau_obj = -data.obj' + sol' * data.constr_lhs tableau_obj[abs.(tableau_obj). Date: Fri, 7 Jun 2024 10:56:58 -0500 Subject: [PATCH 17/34] collect_gmi_dual: profile, do not filter at the end --- src/Cuts/tableau/gmi_dual.jl | 280 +++++++++++++---------------------- 1 file changed, 105 insertions(+), 175 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index c8a5e7d..22e6f12 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -32,57 +32,43 @@ function collect_gmi_dual( ) reset_timer!() - # Open HDF5 file - h5_filename = replace(mps_filename, ".mps.gz" => ".h5") - h5 = H5File(h5_filename) - - # Read optimal solution - sol_opt_dict = Dict( - zip( - h5.get_array("static_var_names"), - convert(Array{Float64}, h5.get_array("mip_var_values")), - ), - ) - - # Read optimal value - obj_mip = h5.get_scalar("mip_lower_bound") - if obj_mip === nothing + @timeit "Read H5" begin + h5_filename = replace(mps_filename, ".mps.gz" => ".h5") + h5 = H5File(h5_filename) + sol_opt_dict = Dict( + zip( + h5.get_array("static_var_names"), + convert(Array{Float64}, h5.get_array("mip_var_values")), + ), + ) obj_mip = h5.get_scalar("mip_obj_value") + h5.file.close() end - obj_lp = h5.get_scalar("lp_obj_value") - h5.file.close() # Define relative MIP gap gap(v) = 100 * abs(obj_mip - v) / abs(v) - # Initialize stats - stats_obj = [] - stats_gap = [] - stats_ncuts = [] - stats_time_convert = 0 - stats_time_solve = 0 - stats_time_select = 0 - stats_time_tableau = 0 - stats_time_gmi = 0 - stats_time_dual = 0 - stats_time_dual_2 = 0 - all_cuts = nothing - all_cuts_v2 = nothing - cuts_all = nothing - cuts_all_v2 = nothing - original_basis = nothing - - # Read problem - model = read_from_file(mps_filename) + @timeit "Initialize" begin + stats_obj = [] + stats_gap = [] + stats_ncuts = [] + all_cuts = nothing + all_cuts_v2 = nothing + cuts_all = nothing + cuts_all_v2 = nothing + original_basis = nothing + end - # Read original objective function - or_obj_f = objective_function(model) - revised_obj = objective_function(model) + @timeit "Read problem" begin + model = read_from_file(mps_filename) + or_obj_f = objective_function(model) + revised_obj = objective_function(model) + end for round = 1:max_rounds @info "Round $(round)..." - stats_time_convert = @elapsed begin + @timeit "Convert to standard form" begin # Update objective function set_objective_function(model, revised_obj) @@ -107,51 +93,40 @@ function collect_gmi_dual( # Assert converted solution is feasible for standard form problem assert_eq(data_s.constr_lhs * sol_opt_s, data_s.constr_lb) - end - # Optimize standard form - optimize!(model_s) - stats_time_solve += solve_time(model_s) - obj = objective_value(model_s) + data_s.obj_offset - - if round == 1 - # Assert standard form problem has same value as original - if obj_lp !== nothing - assert_eq(obj, obj_lp) + @timeit "Optimize standard form" begin + optimize!(model_s) + if round == 1 + obj = objective_value(model_s) + data_s.obj_offset + push!(stats_obj, obj) + push!(stats_gap, gap(obj)) + push!(stats_ncuts, 0) + end + if termination_status(model_s) != MOI.OPTIMAL + error("Non-optimal termination status") end - obj_lp = obj - push!(stats_obj, obj) - push!(stats_gap, gap(obj)) - push!(stats_ncuts, 0) - end - if termination_status(model_s) != MOI.OPTIMAL - return end - # Store original basis and select tableau rows - basis = get_basis(model_s) - if round == 1 - original_basis = basis - end - sol_frac = get_x(model_s) - stats_time_select += @elapsed begin + @timeit "Select tableau rows" begin + basis = get_basis(model_s) + if round == 1 + original_basis = basis + end + sol_frac = get_x(model_s) selected_rows = select_gmi_rows(data_s, basis, sol_frac, max_rows = max_cuts_per_round) end - # Compute selected tableau rows - stats_time_tableau += @elapsed begin + @timeit "Compute tableau rows" begin tableau = compute_tableau(data_s, basis, x = sol_frac, rows = selected_rows) # Assert tableau rows have been computed correctly assert_eq(tableau.lhs * sol_frac, tableau.rhs) assert_eq(tableau.lhs * sol_opt_s, tableau.rhs) - end - # Compute GMI cuts - stats_time_gmi += @elapsed begin + @timeit "Compute GMI cuts" begin cuts_s = compute_gmi(data_s, tableau) # Assert cuts have been generated correctly @@ -165,39 +140,40 @@ function collect_gmi_dual( end end - # Add GMI cuts to original problem - cuts = backwards(transforms, cuts_s) - if round == 1 - cuts_all = cuts - basis_vec = repeat([basis], length(selected_rows)) - cuts_all_v2 = - ConstraintSet_v2(cuts.lhs, cuts.ub, cuts.lb, basis_vec, selected_rows) - else - # v1 struct - cuts_all.lb = [cuts_all.lb; cuts.lb] - cuts_all.ub = [cuts_all.ub; cuts.ub] - cuts_all.lhs = [cuts_all.lhs; cuts.lhs] - - # v2 struct - cuts_all_v2.lb = [cuts_all_v2.lb; cuts.lb] - cuts_all_v2.ub = [cuts_all_v2.ub; cuts.ub] - cuts_all_v2.lhs = [cuts_all_v2.lhs; cuts.lhs] - cuts_all_v2.Bss = [cuts_all_v2.Bss; repeat([basis], length(selected_rows))] - cuts_all_v2.Bv = [cuts_all_v2.Bv; selected_rows] + @timeit "Add GMI cuts to original problem" begin + cuts = backwards(transforms, cuts_s) + if round == 1 + cuts_all = cuts + basis_vec = repeat([basis], length(selected_rows)) + cuts_all_v2 = + ConstraintSet_v2(cuts.lhs, cuts.ub, cuts.lb, basis_vec, selected_rows) + else + # v1 struct + cuts_all.lb = [cuts_all.lb; cuts.lb] + cuts_all.ub = [cuts_all.ub; cuts.ub] + cuts_all.lhs = [cuts_all.lhs; cuts.lhs] + + # v2 struct + cuts_all_v2.lb = [cuts_all_v2.lb; cuts.lb] + cuts_all_v2.ub = [cuts_all_v2.ub; cuts.ub] + cuts_all_v2.lhs = [cuts_all_v2.lhs; cuts.lhs] + cuts_all_v2.Bss = [cuts_all_v2.Bss; repeat([basis], length(selected_rows))] + cuts_all_v2.Bv = [cuts_all_v2.Bv; selected_rows] + end + constrs, gmi_exps = add_constraint_set_dual_v2(model, cuts_all) end - constrs, gmi_exps = add_constraint_set_dual_v2(model, cuts_all) - - # Optimize original form - set_objective_function(model, or_obj_f) - set_optimizer(model, optimizer) - undo_relax = relax_integrality(model) - optimize!(model) - obj = objective_value(model) - push!(stats_obj, obj) - push!(stats_gap, gap(obj)) - - # Reoptimize with updated obj function - stats_time_dual += @elapsed begin + + @timeit "Optimize original form" begin + set_objective_function(model, or_obj_f) + set_optimizer(model, optimizer) + undo_relax = relax_integrality(model) + optimize!(model) + obj = objective_value(model) + push!(stats_obj, obj) + push!(stats_gap, gap(obj)) + end + + @timeit "Reoptimize with updated obj function" begin revised_obj = ( or_obj_f - sum( shadow_price(c) * gmi_exps[iz] for (iz, c) in enumerate(constrs) @@ -213,93 +189,47 @@ function collect_gmi_dual( undo_relax() end - # Filter out useless cuts - stats_time_dual_2 += @elapsed begin - set_objective_function(model, or_obj_f) - keep = [] - obj_gmi = obj_lp - if (cuts_all !== nothing) - constrs, gmi_exps = add_constraint_set_dual_v2(model, cuts_all) - for (i, c) in enumerate(constrs) - set_name(c, @sprintf("gomory_%05d", i)) + @timeit "Store cuts" begin + if all_cuts !== nothing + cut_sizezz = length(all_cuts_v2.Bv) + var_totall = + length(original_basis.var_basic) + + length(original_basis.var_nonbasic) + + length(original_basis.constr_basic) + + length(original_basis.constr_nonbasic) + bm_size = Array{Int64,2}(undef, cut_sizezz, 4) + basis_matrix = Array{Int64,2}(undef, cut_sizezz, var_totall) + + for ii = 1:cut_sizezz + vb = all_cuts_v2.Bss[ii].var_basic + vn = all_cuts_v2.Bss[ii].var_nonbasic + cb = all_cuts_v2.Bss[ii].constr_basic + cn = all_cuts_v2.Bss[ii].constr_nonbasic + bm_size[ii, :] = [length(vb) length(vn) length(cb) length(cn)] + basis_matrix[ii, :] = [vb' vn' cb' cn'] end - set_optimizer(model, optimizer) - undo_relax = relax_integrality(model) - optimize!(model) - obj = objective_value(model) - obj_gmi = obj - push!(stats_obj, obj) - push!(stats_gap, gap(obj)) - # Store useful cuts; drop useless ones from the problem - useful = [-shadow_price(c) > 1e-3 for c in constrs] - drop = findall(useful .== false) - keep = findall(useful .== true) - all_cuts = ConstraintSet(; - lhs = cuts_all.lhs[keep, :], - lb = cuts_all.lb[keep], - ub = cuts_all.ub[keep], - ) - all_cuts_v2 = ConstraintSet_v2(; - lhs = cuts_all_v2.lhs[keep, :], - lb = cuts_all_v2.lb[keep], - ub = cuts_all_v2.ub[keep], - Bss = cuts_all_v2.Bss[keep], - Bv = cuts_all_v2.Bv[keep], - ) - - delete.(model, constrs[drop]) - undo_relax() + @info "Storing $(length(all_cuts.ub)) GMI cuts..." + h5 = H5File(h5_filename) + h5.put_sparse("cuts_lhs", all_cuts.lhs) + h5.put_array("cuts_lb", all_cuts.lb) + h5.put_array("cuts_ub", all_cuts.ub) + h5.put_array("cuts_basis_vars", basis_matrix) + h5.put_array("cuts_basis_sizes", bm_size) + h5.put_array("cuts_rows", all_cuts_v2.Bv) + h5.file.close() end end - basis = original_basis - - if all_cuts !== nothing - cut_sizezz = length(all_cuts_v2.Bv) - var_totall = - length(basis.var_basic) + - length(basis.var_nonbasic) + - length(basis.constr_basic) + - length(basis.constr_nonbasic) - bm_size = Array{Int64,2}(undef, cut_sizezz, 4) - basis_matrix = Array{Int64,2}(undef, cut_sizezz, var_totall) - - for ii = 1:cut_sizezz - vb = all_cuts_v2.Bss[ii].var_basic - vn = all_cuts_v2.Bss[ii].var_nonbasic - cb = all_cuts_v2.Bss[ii].constr_basic - cn = all_cuts_v2.Bss[ii].constr_nonbasic - bm_size[ii, :] = [length(vb) length(vn) length(cb) length(cn)] - basis_matrix[ii, :] = [vb' vn' cb' cn'] - end - # Store cuts - @info "Storing $(length(all_cuts.ub)) GMI cuts..." - h5 = H5File(h5_filename) - h5.put_sparse("cuts_lhs", all_cuts.lhs) - h5.put_array("cuts_lb", all_cuts.lb) - h5.put_array("cuts_ub", all_cuts.ub) - h5.put_array("cuts_basis_vars", basis_matrix) - h5.put_array("cuts_basis_sizes", bm_size) - h5.put_array("cuts_rows", all_cuts_v2.Bv) - h5.file.close() - end + print_timer() return OrderedDict( "instance" => mps_filename, "max_rounds" => max_rounds, "rounds" => length(stats_obj) - 1, - "time_convert" => stats_time_convert, - "time_solve" => stats_time_solve, - "time_tableau" => stats_time_tableau, - "time_gmi" => stats_time_gmi, - "time_dual" => stats_time_dual, - "time_dual_2" => stats_time_dual_2, "obj_mip" => obj_mip, - "obj_lp" => obj_lp, "stats_obj" => stats_obj, "stats_gap" => stats_gap, - "stats_ncuts" => length(keep), ) end From 1bd4917ccac3fdffc68d37c6dea92f75de136748 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 7 Jun 2024 11:13:36 -0500 Subject: [PATCH 18/34] collect_gmi_dual: Remove v2 data struct --- src/Cuts/tableau/gmi_dual.jl | 82 ++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 45 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index 22e6f12..c9d9464 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -16,14 +16,6 @@ Base.@kwdef mutable struct _KnnDualGmiData model = nothing end -Base.@kwdef mutable struct ConstraintSet_v2 - lhs::SparseMatrixCSC - ub::Vector{Float64} - lb::Vector{Float64} - Bss::Vector{Basis} - Bv::Vector{Int64} -end - function collect_gmi_dual( mps_filename; optimizer, @@ -52,11 +44,10 @@ function collect_gmi_dual( stats_obj = [] stats_gap = [] stats_ncuts = [] - all_cuts = nothing - all_cuts_v2 = nothing - cuts_all = nothing - cuts_all_v2 = nothing original_basis = nothing + all_cuts = nothing + all_cuts_bases = nothing + all_cuts_rows = nothing end @timeit "Read problem" begin @@ -141,26 +132,25 @@ function collect_gmi_dual( end @timeit "Add GMI cuts to original problem" begin + # Convert cuts cuts = backwards(transforms, cuts_s) + + # Update data structs + bv = repeat([basis], length(selected_rows)) if round == 1 - cuts_all = cuts - basis_vec = repeat([basis], length(selected_rows)) - cuts_all_v2 = - ConstraintSet_v2(cuts.lhs, cuts.ub, cuts.lb, basis_vec, selected_rows) + all_cuts = cuts + all_cuts_bases = bv + all_cuts_rows = selected_rows else - # v1 struct - cuts_all.lb = [cuts_all.lb; cuts.lb] - cuts_all.ub = [cuts_all.ub; cuts.ub] - cuts_all.lhs = [cuts_all.lhs; cuts.lhs] - - # v2 struct - cuts_all_v2.lb = [cuts_all_v2.lb; cuts.lb] - cuts_all_v2.ub = [cuts_all_v2.ub; cuts.ub] - cuts_all_v2.lhs = [cuts_all_v2.lhs; cuts.lhs] - cuts_all_v2.Bss = [cuts_all_v2.Bss; repeat([basis], length(selected_rows))] - cuts_all_v2.Bv = [cuts_all_v2.Bv; selected_rows] + all_cuts.lhs = [all_cuts.lhs; cuts.lhs] + all_cuts.lb = [all_cuts.lb; cuts.lb] + all_cuts.ub = [all_cuts.ub; cuts.ub] + all_cuts_bases = [all_cuts_bases; bv] + all_cuts_rows = [all_cuts_rows; selected_rows] end - constrs, gmi_exps = add_constraint_set_dual_v2(model, cuts_all) + + # Add to model + constrs, gmi_exps = add_constraint_set_dual_v2(model, all_cuts) end @timeit "Optimize original form" begin @@ -189,38 +179,40 @@ function collect_gmi_dual( undo_relax() end - @timeit "Store cuts" begin + @timeit "Store cuts in H5 file" begin if all_cuts !== nothing - cut_sizezz = length(all_cuts_v2.Bv) - var_totall = + ncuts = length(all_cuts_rows) + total = length(original_basis.var_basic) + length(original_basis.var_nonbasic) + length(original_basis.constr_basic) + length(original_basis.constr_nonbasic) - bm_size = Array{Int64,2}(undef, cut_sizezz, 4) - basis_matrix = Array{Int64,2}(undef, cut_sizezz, var_totall) - - for ii = 1:cut_sizezz - vb = all_cuts_v2.Bss[ii].var_basic - vn = all_cuts_v2.Bss[ii].var_nonbasic - cb = all_cuts_v2.Bss[ii].constr_basic - cn = all_cuts_v2.Bss[ii].constr_nonbasic - bm_size[ii, :] = [length(vb) length(vn) length(cb) length(cn)] - basis_matrix[ii, :] = [vb' vn' cb' cn'] + all_cuts_basis_sizes = Array{Int64,2}(undef, ncuts, 4) + all_cuts_basis_vars = Array{Int64,2}(undef, ncuts, total) + for i = 1:ncuts + vb = all_cuts_bases[i].var_basic + vn = all_cuts_bases[i].var_nonbasic + cb = all_cuts_bases[i].constr_basic + cn = all_cuts_bases[i].constr_nonbasic + all_cuts_basis_sizes[i, :] = [length(vb) length(vn) length(cb) length(cn)] + all_cuts_basis_vars[i, :] = [vb' vn' cb' cn'] end - @info "Storing $(length(all_cuts.ub)) GMI cuts..." h5 = H5File(h5_filename) h5.put_sparse("cuts_lhs", all_cuts.lhs) h5.put_array("cuts_lb", all_cuts.lb) h5.put_array("cuts_ub", all_cuts.ub) - h5.put_array("cuts_basis_vars", basis_matrix) - h5.put_array("cuts_basis_sizes", bm_size) - h5.put_array("cuts_rows", all_cuts_v2.Bv) + h5.put_array("cuts_basis_vars", all_cuts_basis_vars) + h5.put_array("cuts_basis_sizes", all_cuts_basis_sizes) + h5.put_array("cuts_rows", all_cuts_rows) h5.file.close() end end + @show stats_gap + @show stats_obj + @show stats_ncuts + print_timer() return OrderedDict( From 627952a0833701b2217eb40ecd94993c6e8017a1 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 7 Jun 2024 11:14:45 -0500 Subject: [PATCH 19/34] collect_gmi_dual: Remove useless set_obj --- src/Cuts/tableau/gmi_dual.jl | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index c9d9464..a33840a 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -60,9 +60,6 @@ function collect_gmi_dual( @info "Round $(round)..." @timeit "Convert to standard form" begin - # Update objective function - set_objective_function(model, revised_obj) - # Extract problem data data = ProblemData(model) From 5728098614a2196733eb46ac1a52b30bdf332070 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 7 Jun 2024 11:40:22 -0500 Subject: [PATCH 20/34] Minor changes --- src/Cuts/tableau/gmi_dual.jl | 47 +++++++++++++++--------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index a33840a..181de39 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -52,14 +52,14 @@ function collect_gmi_dual( @timeit "Read problem" begin model = read_from_file(mps_filename) - or_obj_f = objective_function(model) - revised_obj = objective_function(model) + set_optimizer(model, optimizer) + obj_original = objective_function(model) end for round = 1:max_rounds @info "Round $(round)..." - @timeit "Convert to standard form" begin + @timeit "Convert model to standard form" begin # Extract problem data data = ProblemData(model) @@ -83,7 +83,8 @@ function collect_gmi_dual( assert_eq(data_s.constr_lhs * sol_opt_s, data_s.constr_lb) end - @timeit "Optimize standard form" begin + @timeit "Optimize standard model" begin + @info "Optimizing standard model..." optimize!(model_s) if round == 1 obj = objective_value(model_s) + data_s.obj_offset @@ -128,7 +129,7 @@ function collect_gmi_dual( end end - @timeit "Add GMI cuts to original problem" begin + @timeit "Add GMI cuts to original model" begin # Convert cuts cuts = backwards(transforms, cuts_s) @@ -150,28 +151,23 @@ function collect_gmi_dual( constrs, gmi_exps = add_constraint_set_dual_v2(model, all_cuts) end - @timeit "Optimize original form" begin - set_objective_function(model, or_obj_f) - set_optimizer(model, optimizer) + @timeit "Optimize original model" begin + set_objective_function(model, obj_original) undo_relax = relax_integrality(model) + @info "Optimizing original model (constr)..." optimize!(model) - obj = objective_value(model) - push!(stats_obj, obj) - push!(stats_gap, gap(obj)) + obj1 = objective_value(model) + push!(stats_obj, obj1) + push!(stats_gap, gap(obj1)) + sp = [shadow_price(c) for c in constrs] end @timeit "Reoptimize with updated obj function" begin - revised_obj = ( - or_obj_f - sum( - shadow_price(c) * gmi_exps[iz] for (iz, c) in enumerate(constrs) - ) - ) delete.(model, constrs) - set_objective_function(model, revised_obj) - set_optimizer(model, optimizer) - optimize!(model) - n_obj = objective_value(model) - assert_eq(obj, n_obj, atol = 0.01) + set_objective_function( + model, + obj_original - sum(sp[i] * gmi_exps[i] for (i, c) in enumerate(constrs)), + ) end undo_relax() end @@ -403,11 +399,8 @@ function _dualgmi_set_callback(model, all_cuts) end function KnnDualGmiComponent_fit(data::_KnnDualGmiData, train_h5) - x = hcat([ - _dualgmi_features(filename, data.extractor) - for filename in train_h5 - ]...)' - model = pyimport("sklearn.neighbors").NearestNeighbors(n_neighbors=data.k) + x = hcat([_dualgmi_features(filename, data.extractor) for filename in train_h5]...)' + model = pyimport("sklearn.neighbors").NearestNeighbors(n_neighbors = data.k) model.fit(x) data.model = model data.train_h5 = train_h5 @@ -417,7 +410,7 @@ end function KnnDualGmiComponent_before_mip(data::_KnnDualGmiData, test_h5, model, stats) x = _dualgmi_features(test_h5, data.extractor) x = reshape(x, 1, length(x)) - selected = vec(data.model.kneighbors(x, return_distance=false)) .+ 1 + selected = vec(data.model.kneighbors(x, return_distance = false)) .+ 1 @info "Dual GMI: Nearest neighbors:" for h5_filename in data.train_h5[selected] @info " $(h5_filename)" From 66092541054b5135a2d0c23190291b5764f3b727 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 7 Jun 2024 11:58:41 -0500 Subject: [PATCH 21/34] gmi: Fix obj_offset; add more profiling --- src/Cuts/tableau/gmi.jl | 2 +- src/Cuts/tableau/gmi_dual.jl | 76 +++++++++++++++++++++--------------- src/Cuts/tableau/moi.jl | 9 ++++- src/Cuts/tableau/structs.jl | 1 + 4 files changed, 53 insertions(+), 35 deletions(-) diff --git a/src/Cuts/tableau/gmi.jl b/src/Cuts/tableau/gmi.jl index c83d520..506ad2e 100644 --- a/src/Cuts/tableau/gmi.jl +++ b/src/Cuts/tableau/gmi.jl @@ -85,7 +85,7 @@ function collect_gmi( # Optimize standard form optimize!(model_s) stats_time_solve += solve_time(model_s) - obj = objective_value(model_s) + data_s.obj_offset + obj = objective_value(model_s) if round == 1 # Assert standard form problem has same value as original diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index 181de39..a46b0d8 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -87,7 +87,7 @@ function collect_gmi_dual( @info "Optimizing standard model..." optimize!(model_s) if round == 1 - obj = objective_value(model_s) + data_s.obj_offset + obj = objective_value(model_s) push!(stats_obj, obj) push!(stats_gap, gap(obj)) push!(stats_ncuts, 0) @@ -130,25 +130,31 @@ function collect_gmi_dual( end @timeit "Add GMI cuts to original model" begin - # Convert cuts - cuts = backwards(transforms, cuts_s) + @timeit "Convert to original form" begin + cuts = backwards(transforms, cuts_s) + end - # Update data structs - bv = repeat([basis], length(selected_rows)) - if round == 1 - all_cuts = cuts - all_cuts_bases = bv - all_cuts_rows = selected_rows - else - all_cuts.lhs = [all_cuts.lhs; cuts.lhs] - all_cuts.lb = [all_cuts.lb; cuts.lb] - all_cuts.ub = [all_cuts.ub; cuts.ub] - all_cuts_bases = [all_cuts_bases; bv] - all_cuts_rows = [all_cuts_rows; selected_rows] + @timeit "Prepare bv" begin + bv = repeat([basis], length(selected_rows)) + end + + @timeit "Append matrices" begin + if round == 1 + all_cuts = cuts + all_cuts_bases = bv + all_cuts_rows = selected_rows + else + all_cuts.lhs = [all_cuts.lhs; cuts.lhs] + all_cuts.lb = [all_cuts.lb; cuts.lb] + all_cuts.ub = [all_cuts.ub; cuts.ub] + all_cuts_bases = [all_cuts_bases; bv] + all_cuts_rows = [all_cuts_rows; selected_rows] + end end - # Add to model - constrs, gmi_exps = add_constraint_set_dual_v2(model, all_cuts) + @timeit "Add to model" begin + constrs, gmi_exps = add_constraint_set_dual_v2(model, all_cuts) + end end @timeit "Optimize original model" begin @@ -322,22 +328,28 @@ function add_constraint_set_dual_v2(model::JuMP.Model, cs::ConstraintSet) c = nothing gmi_exp = nothing gmi_exp2 = nothing - expr = @expression(model, sum(cs.lhs[i, j] * vars[j] for j = 1:ncols)) - if isinf(cs.ub[i]) - c = @constraint(model, cs.lb[i] <= expr) - gmi_exp = cs.lb[i] - expr - elseif isinf(cs.lb[i]) - c = @constraint(model, expr <= cs.ub[i]) - gmi_exp = expr - cs.ub[i] - else - c = @constraint(model, cs.lb[i] <= expr <= cs.ub[i]) - gmi_exp = cs.lb[i] - expr - gmi_exp2 = expr - cs.ub[i] + @timeit "Build expr" begin + expr = @expression(model, sum(cs.lhs[i, j] * vars[j] for j = 1:ncols)) + end + @timeit "Add constraints" begin + if isinf(cs.ub[i]) + c = @constraint(model, cs.lb[i] <= expr) + gmi_exp = cs.lb[i] - expr + elseif isinf(cs.lb[i]) + c = @constraint(model, expr <= cs.ub[i]) + gmi_exp = expr - cs.ub[i] + else + c = @constraint(model, cs.lb[i] <= expr <= cs.ub[i]) + gmi_exp = cs.lb[i] - expr + gmi_exp2 = expr - cs.ub[i] + end end - push!(constrs, c) - push!(gmi_exps, gmi_exp) - if !isnothing(gmi_exp2) - push!(gmi_exps, gmi_exp2) + @timeit "Update structs" begin + push!(constrs, c) + push!(gmi_exps, gmi_exp) + if !isnothing(gmi_exp2) + push!(gmi_exps, gmi_exp2) + end end end return constrs, gmi_exps diff --git a/src/Cuts/tableau/moi.jl b/src/Cuts/tableau/moi.jl index 49d6455..83c0b9d 100644 --- a/src/Cuts/tableau/moi.jl +++ b/src/Cuts/tableau/moi.jl @@ -9,6 +9,8 @@ function ProblemData(model::Model)::ProblemData # Objective function obj = objective_function(model) + obj_offset = obj.constant + obj_sense = objective_sense(model) obj = [v ∈ keys(obj.terms) ? obj.terms[v] : 0.0 for v in vars] # Variable types, lower bounds and upper bounds @@ -86,8 +88,9 @@ function ProblemData(model::Model)::ProblemData @assert length(constr_ub) == m return ProblemData( - obj_offset = 0.0; obj, + obj_offset, + obj_sense, constr_lb, constr_ub, constr_lhs, @@ -102,6 +105,7 @@ function to_model(data::ProblemData, tol = 1e-6)::Model model = Model() # Variables + obj_expr = AffExpr(data.obj_offset) nvars = length(data.obj) @variable(model, x[1:nvars]) for i = 1:nvars @@ -117,8 +121,9 @@ function to_model(data::ProblemData, tol = 1e-6)::Model if isfinite(data.var_ub[i]) set_upper_bound(x[i], data.var_ub[i]) end - set_objective_coefficient(model, x[i], data.obj[i]) + add_to_expression!(obj_expr, x[i], data.obj[i]) end + @objective(model, data.obj_sense, obj_expr) # Constraints lhs = data.constr_lhs * x diff --git a/src/Cuts/tableau/structs.jl b/src/Cuts/tableau/structs.jl index cbdab11..fc1fb75 100644 --- a/src/Cuts/tableau/structs.jl +++ b/src/Cuts/tableau/structs.jl @@ -7,6 +7,7 @@ using SparseArrays Base.@kwdef mutable struct ProblemData obj::Vector{Float64} obj_offset::Float64 + obj_sense constr_lb::Vector{Float64} constr_ub::Vector{Float64} constr_lhs::SparseMatrixCSC From fd655b2291f82e8d1d0098df8e4d71d6252492a8 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 7 Jun 2024 12:07:26 -0500 Subject: [PATCH 22/34] collect_gmi_dual: Filter out useless cuts --- src/Cuts/tableau/gmi_dual.jl | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index a46b0d8..379f667 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -126,6 +126,8 @@ function collect_gmi_dual( if length(cuts_s.lb) == 0 @info "No cuts generated. Aborting." break + else + @info "Generated $(length(cuts_s.lb)) cuts" end end @@ -153,6 +155,7 @@ function collect_gmi_dual( end @timeit "Add to model" begin + @info "Adding $(length(all_cuts.lb)) constraints to original model" constrs, gmi_exps = add_constraint_set_dual_v2(model, all_cuts) end end @@ -166,16 +169,28 @@ function collect_gmi_dual( push!(stats_obj, obj1) push!(stats_gap, gap(obj1)) sp = [shadow_price(c) for c in constrs] + undo_relax() end - @timeit "Reoptimize with updated obj function" begin + @timeit "Update obj function of original model" begin delete.(model, constrs) set_objective_function( model, obj_original - sum(sp[i] * gmi_exps[i] for (i, c) in enumerate(constrs)), ) end - undo_relax() + + @timeit "Filter out useless cuts" begin + useful = [abs(sp[i]) > 0.001 for (i, _) in enumerate(constrs)] + keep = findall(useful .== true) + @info "Keeping only $(length(keep)) useful cuts" + all_cuts.lhs = all_cuts.lhs[keep, :] + all_cuts.lb = all_cuts.lb[keep] + all_cuts.ub = all_cuts.ub[keep] + all_cuts_bases = all_cuts_bases[keep, :] + all_cuts_rows = all_cuts_rows[keep, :] + push!(stats_ncuts, length(all_cuts_rows)) + end end @timeit "Store cuts in H5 file" begin From 24532614e58b38ed4bd760d50f5b0037e4d2197a Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Mon, 10 Jun 2024 12:28:40 -0500 Subject: [PATCH 23/34] gmi_dual: Return time --- src/Cuts/tableau/gmi_dual.jl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index 379f667..c30032a 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -223,10 +223,8 @@ function collect_gmi_dual( end end - @show stats_gap - @show stats_obj - @show stats_ncuts - + to = TimerOutputs.get_defaulttimer() + stats_time = TimerOutputs.tottime(to) / 1e9 print_timer() return OrderedDict( @@ -236,6 +234,8 @@ function collect_gmi_dual( "obj_mip" => obj_mip, "stats_obj" => stats_obj, "stats_gap" => stats_gap, + "stats_ncuts" => stats_ncuts, + "stats_time" => stats_time, ) end From 77c7e94927fbadd20d2382c33df8db0252ea10dd Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 13 Jun 2024 14:48:54 -0500 Subject: [PATCH 24/34] gmi_dual: stop early; fix gap improvement with zero cuts issue --- src/Cuts/tableau/gmi_dual.jl | 41 +++++++++++++++++++++++------------- src/Cuts/tableau/structs.jl | 2 +- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index c30032a..fd881a4 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -48,6 +48,7 @@ function collect_gmi_dual( all_cuts = nothing all_cuts_bases = nothing all_cuts_rows = nothing + last_round_obj = nothing end @timeit "Read problem" begin @@ -86,15 +87,21 @@ function collect_gmi_dual( @timeit "Optimize standard model" begin @info "Optimizing standard model..." optimize!(model_s) + obj = objective_value(model_s) if round == 1 - obj = objective_value(model_s) push!(stats_obj, obj) push!(stats_gap, gap(obj)) push!(stats_ncuts, 0) + else + if obj ≈ last_round_obj + @info ("No improvement in obj value. Aborting.") + break + end end if termination_status(model_s) != MOI.OPTIMAL error("Non-optimal termination status") end + last_round_obj = obj end @timeit "Select tableau rows" begin @@ -165,31 +172,35 @@ function collect_gmi_dual( undo_relax = relax_integrality(model) @info "Optimizing original model (constr)..." optimize!(model) - obj1 = objective_value(model) - push!(stats_obj, obj1) - push!(stats_gap, gap(obj1)) + obj = objective_value(model) + push!(stats_obj, obj) + push!(stats_gap, gap(obj)) sp = [shadow_price(c) for c in constrs] undo_relax() - end - - @timeit "Update obj function of original model" begin - delete.(model, constrs) - set_objective_function( - model, - obj_original - sum(sp[i] * gmi_exps[i] for (i, c) in enumerate(constrs)), - ) + useful = [abs(sp[i]) > 1e-6 for (i, _) in enumerate(constrs)] + keep = findall(useful .== true) end @timeit "Filter out useless cuts" begin - useful = [abs(sp[i]) > 0.001 for (i, _) in enumerate(constrs)] - keep = findall(useful .== true) - @info "Keeping only $(length(keep)) useful cuts" + @info "Keeping $(length(keep)) useful cuts" all_cuts.lhs = all_cuts.lhs[keep, :] all_cuts.lb = all_cuts.lb[keep] all_cuts.ub = all_cuts.ub[keep] all_cuts_bases = all_cuts_bases[keep, :] all_cuts_rows = all_cuts_rows[keep, :] push!(stats_ncuts, length(all_cuts_rows)) + if isempty(keep) + break + end + end + + @timeit "Update obj function of original model" begin + delete.(model, constrs) + set_objective_function( + model, + obj_original - + sum(sp[i] * gmi_exps[i] for (i, c) in enumerate(constrs) if useful[i]), + ) end end diff --git a/src/Cuts/tableau/structs.jl b/src/Cuts/tableau/structs.jl index fc1fb75..7a2bb07 100644 --- a/src/Cuts/tableau/structs.jl +++ b/src/Cuts/tableau/structs.jl @@ -7,7 +7,7 @@ using SparseArrays Base.@kwdef mutable struct ProblemData obj::Vector{Float64} obj_offset::Float64 - obj_sense + obj_sense::Any constr_lb::Vector{Float64} constr_ub::Vector{Float64} constr_lhs::SparseMatrixCSC From 92fd3c3e329e132c938df161c8507a6f52de32f0 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 13 Jun 2024 14:58:14 -0500 Subject: [PATCH 25/34] dual_gmi: Fix gap formula --- src/Cuts/tableau/gmi_dual.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index fd881a4..f5e2195 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -38,7 +38,7 @@ function collect_gmi_dual( end # Define relative MIP gap - gap(v) = 100 * abs(obj_mip - v) / abs(v) + gap(v) = 100 * abs(obj_mip - v) / abs(obj_mip) @timeit "Initialize" begin stats_obj = [] From 70d2ee5883af56b713ed8205558da96fd9e765a2 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 13 Jun 2024 15:15:49 -0500 Subject: [PATCH 26/34] dual_gmi: Relax tolerances --- src/Cuts/tableau/gmi_dual.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index f5e2195..58ea843 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -118,8 +118,8 @@ function collect_gmi_dual( tableau = compute_tableau(data_s, basis, x = sol_frac, rows = selected_rows) # Assert tableau rows have been computed correctly - assert_eq(tableau.lhs * sol_frac, tableau.rhs) - assert_eq(tableau.lhs * sol_opt_s, tableau.rhs) + assert_eq(tableau.lhs * sol_frac, tableau.rhs, atol=1e-3) + assert_eq(tableau.lhs * sol_opt_s, tableau.rhs, atol=1e-3) end @timeit "Compute GMI cuts" begin From 2f16f0487897f89225f85712e8f3d456ce6cf578 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 14 Jun 2024 13:56:56 -0500 Subject: [PATCH 27/34] gmi_dual: Accelerate build_expr --- src/Cuts/tableau/gmi_dual.jl | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index 58ea843..2df5722 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -348,6 +348,14 @@ end function add_constraint_set_dual_v2(model::JuMP.Model, cs::ConstraintSet) vars = all_variables(model) nrows, ncols = size(cs.lhs) + + @timeit "Transpose LHS" begin + lhs_t = spzeros(ncols, nrows) + ftranspose!(lhs_t, cs.lhs, x -> x) + lhs_t_rows = rowvals(lhs_t) + lhs_t_vals = nonzeros(lhs_t) + end + constrs = [] gmi_exps = [] for i = 1:nrows @@ -355,7 +363,10 @@ function add_constraint_set_dual_v2(model::JuMP.Model, cs::ConstraintSet) gmi_exp = nothing gmi_exp2 = nothing @timeit "Build expr" begin - expr = @expression(model, sum(cs.lhs[i, j] * vars[j] for j = 1:ncols)) + expr = AffExpr() + for k in nzrange(lhs_t, i) + add_to_expression!(expr, lhs_t_vals[k], vars[lhs_t_rows[k]]) + end end @timeit "Add constraints" begin if isinf(cs.ub[i]) From ffea599af306ba2401ad4b5a73696c886a08db21 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 14 Jun 2024 15:35:12 -0500 Subject: [PATCH 28/34] cuts: Speed up tableau computation --- src/Cuts/tableau/tableau.jl | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/Cuts/tableau/tableau.jl b/src/Cuts/tableau/tableau.jl index f0625d3..06bf3bd 100644 --- a/src/Cuts/tableau/tableau.jl +++ b/src/Cuts/tableau/tableau.jl @@ -74,11 +74,11 @@ function compute_tableau( end @timeit "Compute tableau" begin - tableau_rhs = [] - tableau_lhs_I = Int[] - tableau_lhs_J = Int[] - tableau_lhs_V = Float64[] - for k = 1:length(rows) + @timeit "Initialize" begin + tableau_rhs = zeros(length(rows)) + tableau_lhs = zeros(length(rows), ncols) + end + for k in eachindex(1:length(rows)) @timeit "Prepare inputs" begin i = rows[k] e = zeros(nrows) @@ -88,23 +88,14 @@ function compute_tableau( sol = factor \ e end @timeit "Multiply" begin - row = sol' * data.constr_lhs - rhs = sol' * data.constr_ub - push!(tableau_rhs, rhs) - end - @timeit "Sparsify & copy" begin - for (j, v) in enumerate(row) - if abs(v) < tol - continue - end - push!(tableau_lhs_I, k) - push!(tableau_lhs_J, j) - push!(tableau_lhs_V, v) - end + tableau_lhs[k, :] = sol' * data.constr_lhs + tableau_rhs[k] = sol' * data.constr_ub end end - tableau_lhs = - sparse(tableau_lhs_I, tableau_lhs_J, tableau_lhs_V, length(rows), ncols) + @timeit "Sparsify" begin + tableau_lhs[abs.(tableau_lhs) .<= tol] .= 0 + tableau_lhs = sparse(tableau_lhs) + end end @timeit "Compute tableau objective row" begin From 24d93c8894e0edb52808ed571ec652e68b5cadc6 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Wed, 7 Aug 2024 12:16:20 -0500 Subject: [PATCH 29/34] gmi_dual: Implement alternative cut callback strategy --- src/Cuts/tableau/gmi_dual.jl | 55 +++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index 2df5722..8042d21 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -272,7 +272,7 @@ function ExpertDualGmiComponent_before_mip(test_h5, model, stats) stats_time_convert = 0 stats_time_tableau = 0 stats_time_gmi = 0 - all_cuts = [] + all_cuts = nothing stats_time_convert = @elapsed begin # Extract problem data @@ -320,24 +320,59 @@ function ExpertDualGmiComponent_before_mip(test_h5, model, stats) end cuts = backwards(transforms, cuts_s) assert_does_not_cut_off(cuts, sol_opt) - push!(all_cuts, cuts) + + if all_cuts === nothing + all_cuts = cuts + else + all_cuts.lhs = [all_cuts.lhs; cuts.lhs] + all_cuts.lb = [all_cuts.lb; cuts.lb] + all_cuts.ub = [all_cuts.ub; cuts.ub] + end end - function cut_callback(cb_data) + # Strategy 1: Add all cuts during the first call + function cut_callback_1(cb_data) if all_cuts !== nothing - @info "Enforcing dual GMI cuts..." - for cuts in all_cuts - constrs = build_constraints(model, cuts) - for c in constrs - MOI.submit(model, MOI.UserCut(cb_data), c) - end + constrs = build_constraints(model, all_cuts) + @info "Enforcing $(length(constrs)) cuts..." + for c in constrs + MOI.submit(model, MOI.UserCut(cb_data), c) end all_cuts = nothing end end + # Strategy 2: Add violated cuts repeatedly until unable to separate + callback_disabled = false + function cut_callback_2(cb_data) + if callback_disabled + return + end + x = all_variables(model) + x_val = callback_value.(cb_data, x) + lhs_val = all_cuts.lhs * x_val + is_violated = lhs_val .> all_cuts.ub + selected_idx = findall(is_violated .== true) + selected_cuts = ConstraintSet( + lhs=all_cuts.lhs[selected_idx, :], + ub=all_cuts.ub[selected_idx], + lb=all_cuts.lb[selected_idx], + ) + constrs = build_constraints(model, selected_cuts) + if length(constrs) > 0 + @info "Enforcing $(length(constrs)) cuts..." + for c in constrs + MOI.submit(model, MOI.UserCut(cb_data), c) + end + else + @info "No violated cuts found. Disabling callback." + callback_disabled = true + end + end + # Set up cut callback - set_attribute(model, MOI.UserCutCallback(), cut_callback) + set_attribute(model, MOI.UserCutCallback(), cut_callback_1) + # set_attribute(model, MOI.UserCutCallback(), cut_callback_2) stats["gmi_time_convert"] = stats_time_convert stats["gmi_time_tableau"] = stats_time_tableau From c5fe6bf7129323d7c1614d4004df9382857ec11e Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 8 Aug 2024 08:58:29 -0500 Subject: [PATCH 30/34] Detect and skip duplicate cuts --- src/Cuts/tableau/gmi_dual.jl | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index 8042d21..f20ab78 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -439,7 +439,8 @@ end function _dualgmi_generate(train_h5, model) data = ProblemData(model) data_s, transforms = convert_to_standard_form(data) - all_cuts = [] + all_cuts = nothing + visited = Set() for h5_filename in train_h5 h5 = H5File(h5_filename) cut_basis_vars = h5.get_array("cuts_basis_vars") @@ -457,24 +458,36 @@ function _dualgmi_generate(train_h5, model) constr_nonbasic = cut_basis_vars[r, vbb+vnn+cbb+1:vbb+vnn+cbb+cnn], ) end + + # Detect and skip duplicated cuts + cut_id = (row, cut_basis_vars[r, :]) + cut_id ∉ visited || continue + push!(visited, cut_id) + tableau = compute_tableau(data_s, current_basis, rows = [row]) cuts_s = compute_gmi(data_s, tableau) cuts = backwards(transforms, cuts_s) - push!(all_cuts, cuts) + + if all_cuts === nothing + all_cuts = cuts + else + all_cuts.lhs = [all_cuts.lhs; cuts.lhs] + all_cuts.lb = [all_cuts.lb; cuts.lb] + all_cuts.ub = [all_cuts.ub; cuts.ub] + end end end + @info "Collected $(length(all_cuts.lb)) distinct cuts" return all_cuts end function _dualgmi_set_callback(model, all_cuts) function cut_callback(cb_data) if all_cuts !== nothing - @info "Dual GMI: Submitting cuts..." - for cuts in all_cuts - constrs = build_constraints(model, cuts) - for c in constrs - MOI.submit(model, MOI.UserCut(cb_data), c) - end + constrs = build_constraints(model, all_cuts) + @info "Enforcing $(length(constrs)) cuts..." + for c in constrs + MOI.submit(model, MOI.UserCut(cb_data), c) end all_cuts = nothing end From 15dfcac32e0d364c376d3cd213e3b30e882e0925 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 20 Aug 2024 17:02:45 -0500 Subject: [PATCH 31/34] gmi_dual: Implement alternative strategies, report time and cuts --- src/Cuts/tableau/gmi_dual.jl | 66 +++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index f20ab78..96406b7 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -5,6 +5,8 @@ using Printf using JuMP using HiGHS +using Random +using DataStructures global ExpertDualGmiComponent = PyNULL() global KnnDualGmiComponent = PyNULL() @@ -14,6 +16,7 @@ Base.@kwdef mutable struct _KnnDualGmiData extractor = nothing train_h5 = nothing model = nothing + strategy = nothing end function collect_gmi_dual( @@ -250,7 +253,7 @@ function collect_gmi_dual( ) end -function ExpertDualGmiComponent_before_mip(test_h5, model, stats) +function ExpertDualGmiComponent_before_mip(test_h5, model, _) # Read cuts and optimal solution h5 = H5File(test_h5) sol_opt_dict = Dict( @@ -374,10 +377,12 @@ function ExpertDualGmiComponent_before_mip(test_h5, model, stats) set_attribute(model, MOI.UserCutCallback(), cut_callback_1) # set_attribute(model, MOI.UserCutCallback(), cut_callback_2) - stats["gmi_time_convert"] = stats_time_convert - stats["gmi_time_tableau"] = stats_time_tableau - stats["gmi_time_gmi"] = stats_time_gmi - return + stats = Dict() + stats["ExpertDualGmi: cuts"] = length(all_cuts.lb) + stats["ExpertDualGmi: time convert"] = stats_time_convert + stats["ExpertDualGmi: time tableau"] = stats_time_tableau + stats["ExpertDualGmi: time gmi"] = stats_time_gmi + return stats end function add_constraint_set_dual_v2(model::JuMP.Model, cs::ConstraintSet) @@ -477,7 +482,6 @@ function _dualgmi_generate(train_h5, model) end end end - @info "Collected $(length(all_cuts.lb)) distinct cuts" return all_cuts end @@ -497,23 +501,53 @@ end function KnnDualGmiComponent_fit(data::_KnnDualGmiData, train_h5) x = hcat([_dualgmi_features(filename, data.extractor) for filename in train_h5]...)' - model = pyimport("sklearn.neighbors").NearestNeighbors(n_neighbors = data.k) + model = pyimport("sklearn.neighbors").NearestNeighbors(n_neighbors = length(train_h5)) model.fit(x) data.model = model data.train_h5 = train_h5 end -function KnnDualGmiComponent_before_mip(data::_KnnDualGmiData, test_h5, model, stats) +function KnnDualGmiComponent_before_mip(data::_KnnDualGmiData, test_h5, model, _) x = _dualgmi_features(test_h5, data.extractor) x = reshape(x, 1, length(x)) - selected = vec(data.model.kneighbors(x, return_distance = false)) .+ 1 - @info "Dual GMI: Nearest neighbors:" - for h5_filename in data.train_h5[selected] - @info " $(h5_filename)" + neigh_dist, neigh_ind = data.model.kneighbors(x, return_distance = true) + neigh_ind = neigh_ind .+ 1 + N = length(neigh_dist) + + if data.strategy == "near" + selected = collect(1:(data.k)) + elseif data.strategy == "far" + selected = collect((N - data.k + 1) : N) + elseif data.strategy == "rand" + selected = shuffle(collect(1:N))[1:(data.k)] + else + error("unknown strategy: $(data.strategy)") end - cuts = _dualgmi_generate(data.train_h5[selected], model) + + @info "Dual GMI: Selected neighbors ($(data.strategy)):" + neigh_dist = neigh_dist[selected] + neigh_ind = neigh_ind[selected] + for i in 1:data.k + h5_filename = data.train_h5[neigh_ind[i]] + dist = neigh_dist[i] + @info " $(h5_filename) dist=$(dist)" + end + + @info "Dual GMI: Generating cuts..." + time_generate = @elapsed begin + cuts = _dualgmi_generate(data.train_h5[neigh_ind], model) + end + @info "Dual GMI: Generated $(length(cuts.lb)) unique cuts in $(time_generate) seconds" + _dualgmi_set_callback(model, cuts) + + stats = Dict() + stats["KnnDualGmi: k"] = data.k + stats["KnnDualGmi: strategy"] = data.strategy + stats["KnnDualGmi: cuts"] = length(cuts.lb) + stats["KnnDualGmi: time generate"] = time_generate + return stats end function __init_gmi_dual__() @@ -526,14 +560,14 @@ function __init_gmi_dual__() copy!(ExpertDualGmiComponent, Class1) @pydef mutable struct Class2 - function __init__(self; extractor, k = 3) - self.data = _KnnDualGmiData(; extractor, k) + function __init__(self; extractor, k = 3, strategy = "near") + self.data = _KnnDualGmiData(; extractor, k, strategy) end function fit(self, train_h5) KnnDualGmiComponent_fit(self.data, train_h5) end function before_mip(self, test_h5, model, stats) - KnnDualGmiComponent_before_mip(self.data, test_h5, model.inner, stats) + return KnnDualGmiComponent_before_mip(self.data, test_h5, model.inner, stats) end end copy!(KnnDualGmiComponent, Class2) From 46ed6859f2364b514c3529bbb09f525aa1b4f715 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 23 Aug 2024 05:32:35 -0500 Subject: [PATCH 32/34] accelerate build_constraints --- src/Cuts/tableau/moi.jl | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Cuts/tableau/moi.jl b/src/Cuts/tableau/moi.jl index 83c0b9d..7e4b0de 100644 --- a/src/Cuts/tableau/moi.jl +++ b/src/Cuts/tableau/moi.jl @@ -164,13 +164,21 @@ function build_constraints(model::JuMP.Model, cs::ConstraintSet) nrows, _ = size(cs.lhs) constrs = [] for i = 1:nrows + # Build LHS expression + row = cs.lhs[i, :] + lhs_expr = AffExpr() + for (offset, val) in enumerate(row.nzval) + add_to_expression!(lhs_expr, vars[row.nzind[offset]], val) + end + + # Build JuMP constraint c = nothing if isinf(cs.ub[i]) - c = @build_constraint(cs.lb[i] <= dot(cs.lhs[i, :], vars)) + c = @build_constraint(cs.lb[i] <= lhs_expr) elseif isinf(cs.lb[i]) - c = @build_constraint(dot(cs.lhs[i, :], vars) <= cs.ub[i]) + c = @build_constraint(lhs_expr <= cs.ub[i]) else - c = @build_constraint(cs.lb[i] <= dot(cs.lhs[i, :], vars) <= cs.ub[i]) + c = @build_constraint(cs.lb[i] <= lhs_expr <= cs.ub[i]) end push!(constrs, c) end From 006ace00e70964b4c6625c6c63ffc874715ca9e2 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 23 Aug 2024 10:08:07 -0500 Subject: [PATCH 33/34] Accelerate KnnDualGmiComponent_before_mip; enable precompilation --- Project.toml | 4 +- src/Cuts/tableau/gmi_dual.jl | 151 ++++++++++++++++--------- src/MIPLearn.jl | 49 ++++++++ test/fixtures/bell5.h5 | Bin 37123 -> 23425 bytes test/src/Cuts/tableau/test_gmi_dual.jl | 16 +-- 5 files changed, 160 insertions(+), 60 deletions(-) diff --git a/Project.toml b/Project.toml index f04e1dd..b8414a8 100644 --- a/Project.toml +++ b/Project.toml @@ -15,10 +15,12 @@ KLU = "ef3ab10e-7fda-4108-b977-705223b18434" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Requires = "ae029012-a4dd-5104-9daa-d747884805df" +SCIP = "82193955-e24f-5292-bf16-6f2c5261a85f" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" @@ -30,7 +32,6 @@ HDF5 = "0.16" HiGHS = "1" JLD2 = "0.4" JSON = "0.21" -julia = "1" JuMP = "1" KLU = "0.4" MathOptInterface = "1" @@ -39,3 +40,4 @@ PyCall = "1" Requires = "1" Statistics = "1" TimerOutputs = "0.5" +julia = "1" diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index 96406b7..2c375c4 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -442,34 +442,69 @@ function _dualgmi_features(h5_filename, extractor) end function _dualgmi_generate(train_h5, model) - data = ProblemData(model) - data_s, transforms = convert_to_standard_form(data) - all_cuts = nothing - visited = Set() - for h5_filename in train_h5 - h5 = H5File(h5_filename) - cut_basis_vars = h5.get_array("cuts_basis_vars") - cut_basis_sizes = h5.get_array("cuts_basis_sizes") - cut_rows = h5.get_array("cuts_rows") - h5.close() - current_basis = nothing - for (r, row) in enumerate(cut_rows) - if r == 1 || cut_basis_vars[r, :] != cut_basis_vars[r-1, :] - vbb, vnn, cbb, cnn = cut_basis_sizes[r, :] - current_basis = Basis(; - var_basic = cut_basis_vars[r, 1:vbb], - var_nonbasic = cut_basis_vars[r, vbb+1:vbb+vnn], - constr_basic = cut_basis_vars[r, vbb+vnn+1:vbb+vnn+cbb], - constr_nonbasic = cut_basis_vars[r, vbb+vnn+cbb+1:vbb+vnn+cbb+cnn], - ) + @timeit "Read problem data" begin + data = ProblemData(model) + end + @timeit "Convert to standard form" begin + data_s, transforms = convert_to_standard_form(data) + end + + @timeit "Collect cuts from H5 files" begin + cut_basis_vars = nothing + cut_basis_sizes = nothing + cut_rows = nothing + for h5_filename in train_h5 + h5 = H5File(h5_filename) + cut_basis_vars_sample = h5.get_array("cuts_basis_vars") + cut_basis_sizes_sample = h5.get_array("cuts_basis_sizes") + cut_rows_sample = h5.get_array("cuts_rows") + if cut_basis_vars === nothing + cut_basis_vars = cut_basis_vars_sample + cut_basis_sizes = cut_basis_sizes_sample + cut_rows = cut_rows_sample + else + cut_basis_vars = [cut_basis_vars; cut_basis_vars_sample] + cut_basis_sizes = [cut_basis_sizes; cut_basis_sizes_sample] + cut_rows = [cut_rows; cut_rows_sample] + end + h5.close() + end + ncuts, nvars = size(cut_basis_vars) + end + + @timeit "Group cuts by tableau basis" begin + vars_to_unique_basis_offset = Dict() + unique_basis_vars = Matrix{Int}(undef, 0, nvars) + unique_basis_sizes = Matrix{Int}(undef, 0, 4) + unique_basis_rows = Dict{Int,Set{Int}}() + for i in 1:ncuts + vars = cut_basis_vars[i, :] + sizes = cut_basis_sizes[i, :] + row = cut_rows[i] + if vars ∉ keys(vars_to_unique_basis_offset) + offset = size(unique_basis_vars)[1] + 1 + vars_to_unique_basis_offset[vars] = offset + unique_basis_vars = [unique_basis_vars; vars'] + unique_basis_sizes = [unique_basis_sizes; sizes'] + unique_basis_rows[offset] = Set() end + offset = vars_to_unique_basis_offset[vars] + push!(unique_basis_rows[offset], row) + end + end - # Detect and skip duplicated cuts - cut_id = (row, cut_basis_vars[r, :]) - cut_id ∉ visited || continue - push!(visited, cut_id) + @timeit "Compute tableaus and cuts" begin + all_cuts = nothing + for (offset, rows) in unique_basis_rows + vbb, vnn, cbb, cnn = unique_basis_sizes[offset, :] + current_basis = Basis(; + var_basic = unique_basis_vars[offset, 1:vbb], + var_nonbasic = unique_basis_vars[offset, vbb+1:vbb+vnn], + constr_basic = unique_basis_vars[offset, vbb+vnn+1:vbb+vnn+cbb], + constr_nonbasic = unique_basis_vars[offset, vbb+vnn+cbb+1:vbb+vnn+cbb+cnn], + ) - tableau = compute_tableau(data_s, current_basis, rows = [row]) + tableau = compute_tableau(data_s, current_basis; rows=collect(rows)) cuts_s = compute_gmi(data_s, tableau) cuts = backwards(transforms, cuts_s) @@ -509,38 +544,51 @@ end function KnnDualGmiComponent_before_mip(data::_KnnDualGmiData, test_h5, model, _) - x = _dualgmi_features(test_h5, data.extractor) - x = reshape(x, 1, length(x)) - neigh_dist, neigh_ind = data.model.kneighbors(x, return_distance = true) - neigh_ind = neigh_ind .+ 1 - N = length(neigh_dist) - - if data.strategy == "near" - selected = collect(1:(data.k)) - elseif data.strategy == "far" - selected = collect((N - data.k + 1) : N) - elseif data.strategy == "rand" - selected = shuffle(collect(1:N))[1:(data.k)] - else - error("unknown strategy: $(data.strategy)") + reset_timer!() + + @timeit "Extract features" begin + x = _dualgmi_features(test_h5, data.extractor) + x = reshape(x, 1, length(x)) end - @info "Dual GMI: Selected neighbors ($(data.strategy)):" - neigh_dist = neigh_dist[selected] - neigh_ind = neigh_ind[selected] - for i in 1:data.k - h5_filename = data.train_h5[neigh_ind[i]] - dist = neigh_dist[i] - @info " $(h5_filename) dist=$(dist)" + @timeit "Find neighbors" begin + neigh_dist, neigh_ind = data.model.kneighbors(x, return_distance = true) + neigh_ind = neigh_ind .+ 1 + N = length(neigh_dist) + + if data.strategy == "near" + selected = collect(1:(data.k)) + elseif data.strategy == "far" + selected = collect((N - data.k + 1) : N) + elseif data.strategy == "rand" + selected = shuffle(collect(1:N))[1:(data.k)] + else + error("unknown strategy: $(data.strategy)") + end + + @info "Dual GMI: Selected neighbors ($(data.strategy)):" + neigh_dist = neigh_dist[selected] + neigh_ind = neigh_ind[selected] + for i in 1:data.k + h5_filename = data.train_h5[neigh_ind[i]] + dist = neigh_dist[i] + @info " $(h5_filename) dist=$(dist)" + end end @info "Dual GMI: Generating cuts..." - time_generate = @elapsed begin - cuts = _dualgmi_generate(data.train_h5[neigh_ind], model) + @timeit "Generate cuts" begin + time_generate = @elapsed begin + cuts = _dualgmi_generate(data.train_h5[neigh_ind], model) + end + @info "Dual GMI: Generated $(length(cuts.lb)) unique cuts in $(time_generate) seconds" end - @info "Dual GMI: Generated $(length(cuts.lb)) unique cuts in $(time_generate) seconds" - _dualgmi_set_callback(model, cuts) + @timeit "Set callback" begin + _dualgmi_set_callback(model, cuts) + end + + print_timer() stats = Dict() stats["KnnDualGmi: k"] = data.k @@ -567,10 +615,11 @@ function __init_gmi_dual__() KnnDualGmiComponent_fit(self.data, train_h5) end function before_mip(self, test_h5, model, stats) - return KnnDualGmiComponent_before_mip(self.data, test_h5, model.inner, stats) + return @time KnnDualGmiComponent_before_mip(self.data, test_h5, model.inner, stats) end end copy!(KnnDualGmiComponent, Class2) end export collect_gmi_dual, expert_gmi_dual, ExpertDualGmiComponent, KnnDualGmiComponent + diff --git a/src/MIPLearn.jl b/src/MIPLearn.jl index 5034474..702e06b 100644 --- a/src/MIPLearn.jl +++ b/src/MIPLearn.jl @@ -6,6 +6,8 @@ module MIPLearn using PyCall using SparseArrays +using PrecompileTools: @setup_workload, @compile_workload + include("collectors.jl") include("components.jl") @@ -32,4 +34,51 @@ end include("BB/BB.jl") include("Cuts/Cuts.jl") +# Precompilation +# ============================================================================= + +function __precompile_cuts__() + function build_model(mps_filename) + model = read_from_file(mps_filename) + set_optimizer(model, SCIP.Optimizer) + return JumpModel(model) + end + BASEDIR = dirname(@__FILE__) + mps_filename = "$BASEDIR/../test/fixtures/bell5.mps.gz" + h5_filename = "$BASEDIR/../test/fixtures/bell5.h5" + collect_gmi_dual( + mps_filename; + optimizer=HiGHS.Optimizer, + max_rounds = 10, + max_cuts_per_round = 500, + ) + knn = KnnDualGmiComponent( + extractor = H5FieldsExtractor(instance_fields = ["static_var_obj_coeffs"]), + k = 2, + ) + knn.fit([h5_filename, h5_filename]) + solver = LearningSolver( + components = [ + ExpertPrimalComponent(action = SetWarmStart()), + knn, + ], + skip_lp = true, + ) + solver.optimize(mps_filename, build_model) +end + +@setup_workload begin + using SCIP + using HiGHS + using MIPLearn.Cuts + using PrecompileTools: @setup_workload, @compile_workload + + __init__() + Cuts.__init__() + + @compile_workload begin + __precompile_cuts__() + end +end + end # module diff --git a/test/fixtures/bell5.h5 b/test/fixtures/bell5.h5 index 541b0a6ae5b6f970bc5b62c058a2d6e9ad010af5..a52c61dcf77b3feef254bae9c11d4b71eb32a14a 100644 GIT binary patch literal 23425 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 HvH$-79iXVj literal 37123 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 diff --git a/test/src/Cuts/tableau/test_gmi_dual.jl b/test/src/Cuts/tableau/test_gmi_dual.jl index 8452be8..2b9bcf2 100644 --- a/test/src/Cuts/tableau/test_gmi_dual.jl +++ b/test/src/Cuts/tableau/test_gmi_dual.jl @@ -4,6 +4,7 @@ using SCIP using HiGHS +using MIPLearn.Cuts function test_cuts_tableau_gmi_dual_collect() mps_filename = "$BASEDIR/../fixtures/bell5.mps.gz" @@ -31,15 +32,15 @@ function test_cuts_tableau_gmi_dual_usage() mps_filename = "$BASEDIR/../fixtures/bell5.mps.gz" h5_filename = "$BASEDIR/../fixtures/bell5.h5" - # rm(h5_filename, force=true) + rm(h5_filename, force=true) - # # Run basic collector - # bc = BasicCollector(write_mps = false, skip_lp = true) - # bc.collect([mps_filename], build_model) + # Run basic collector + bc = BasicCollector(write_mps = false, skip_lp = true) + bc.collect([mps_filename], build_model) - # # Run dual GMI collector - # @info "Running dual GMI collector..." - # collect_gmi_dual(mps_filename, optimizer = HiGHS.Optimizer) + # Run dual GMI collector + @info "Running dual GMI collector..." + collect_gmi_dual(mps_filename, optimizer = HiGHS.Optimizer) # # Test expert component # solver = LearningSolver( @@ -65,6 +66,5 @@ function test_cuts_tableau_gmi_dual_usage() skip_lp = true, ) solver.optimize(mps_filename, build_model) - return end From 011a106d203d9cb7f546ba3817210b8354092ba8 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 17 Oct 2024 09:36:46 -0500 Subject: [PATCH 34/34] gmi_dual: Small fixes --- src/Cuts/tableau/gmi_dual.jl | 122 +++++++++++++++++------------------ 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/src/Cuts/tableau/gmi_dual.jl b/src/Cuts/tableau/gmi_dual.jl index 2c375c4..6248f71 100644 --- a/src/Cuts/tableau/gmi_dual.jl +++ b/src/Cuts/tableau/gmi_dual.jl @@ -29,7 +29,7 @@ function collect_gmi_dual( @timeit "Read H5" begin h5_filename = replace(mps_filename, ".mps.gz" => ".h5") - h5 = H5File(h5_filename) + h5 = H5File(h5_filename, "r") sol_opt_dict = Dict( zip( h5.get_array("static_var_names"), @@ -255,7 +255,7 @@ end function ExpertDualGmiComponent_before_mip(test_h5, model, _) # Read cuts and optimal solution - h5 = H5File(test_h5) + h5 = H5File(test_h5, "r") sol_opt_dict = Dict( zip( h5.get_array("static_var_names"), @@ -450,70 +450,69 @@ function _dualgmi_generate(train_h5, model) end @timeit "Collect cuts from H5 files" begin - cut_basis_vars = nothing - cut_basis_sizes = nothing - cut_rows = nothing + vars_to_unique_basis_offset = Dict() + unique_basis_vars = nothing + unique_basis_sizes = nothing + unique_basis_rows = nothing + for h5_filename in train_h5 - h5 = H5File(h5_filename) - cut_basis_vars_sample = h5.get_array("cuts_basis_vars") - cut_basis_sizes_sample = h5.get_array("cuts_basis_sizes") - cut_rows_sample = h5.get_array("cuts_rows") - if cut_basis_vars === nothing - cut_basis_vars = cut_basis_vars_sample - cut_basis_sizes = cut_basis_sizes_sample - cut_rows = cut_rows_sample - else - cut_basis_vars = [cut_basis_vars; cut_basis_vars_sample] - cut_basis_sizes = [cut_basis_sizes; cut_basis_sizes_sample] - cut_rows = [cut_rows; cut_rows_sample] + h5 = H5File(h5_filename, "r") + cut_basis_vars = h5.get_array("cuts_basis_vars") + cut_basis_sizes = h5.get_array("cuts_basis_sizes") + cut_rows = h5.get_array("cuts_rows") + ncuts, nvars = size(cut_basis_vars) + if unique_basis_vars === nothing + unique_basis_vars = Matrix{Int}(undef, 0, nvars) + unique_basis_sizes = Matrix{Int}(undef, 0, 4) + unique_basis_rows = Dict{Int,Set{Int}}() end - h5.close() - end - ncuts, nvars = size(cut_basis_vars) - end - - @timeit "Group cuts by tableau basis" begin - vars_to_unique_basis_offset = Dict() - unique_basis_vars = Matrix{Int}(undef, 0, nvars) - unique_basis_sizes = Matrix{Int}(undef, 0, 4) - unique_basis_rows = Dict{Int,Set{Int}}() - for i in 1:ncuts - vars = cut_basis_vars[i, :] - sizes = cut_basis_sizes[i, :] - row = cut_rows[i] - if vars ∉ keys(vars_to_unique_basis_offset) - offset = size(unique_basis_vars)[1] + 1 - vars_to_unique_basis_offset[vars] = offset - unique_basis_vars = [unique_basis_vars; vars'] - unique_basis_sizes = [unique_basis_sizes; sizes'] - unique_basis_rows[offset] = Set() + for i in 1:ncuts + vars = cut_basis_vars[i, :] + sizes = cut_basis_sizes[i, :] + row = cut_rows[i] + if vars ∉ keys(vars_to_unique_basis_offset) + offset = size(unique_basis_vars)[1] + 1 + vars_to_unique_basis_offset[vars] = offset + unique_basis_vars = [unique_basis_vars; vars'] + unique_basis_sizes = [unique_basis_sizes; sizes'] + unique_basis_rows[offset] = Set() + end + offset = vars_to_unique_basis_offset[vars] + push!(unique_basis_rows[offset], row) end - offset = vars_to_unique_basis_offset[vars] - push!(unique_basis_rows[offset], row) + h5.close() end end @timeit "Compute tableaus and cuts" begin all_cuts = nothing for (offset, rows) in unique_basis_rows - vbb, vnn, cbb, cnn = unique_basis_sizes[offset, :] - current_basis = Basis(; - var_basic = unique_basis_vars[offset, 1:vbb], - var_nonbasic = unique_basis_vars[offset, vbb+1:vbb+vnn], - constr_basic = unique_basis_vars[offset, vbb+vnn+1:vbb+vnn+cbb], - constr_nonbasic = unique_basis_vars[offset, vbb+vnn+cbb+1:vbb+vnn+cbb+cnn], - ) - - tableau = compute_tableau(data_s, current_basis; rows=collect(rows)) - cuts_s = compute_gmi(data_s, tableau) - cuts = backwards(transforms, cuts_s) + try + vbb, vnn, cbb, cnn = unique_basis_sizes[offset, :] + current_basis = Basis(; + var_basic = unique_basis_vars[offset, 1:vbb], + var_nonbasic = unique_basis_vars[offset, vbb+1:vbb+vnn], + constr_basic = unique_basis_vars[offset, vbb+vnn+1:vbb+vnn+cbb], + constr_nonbasic = unique_basis_vars[offset, vbb+vnn+cbb+1:vbb+vnn+cbb+cnn], + ) - if all_cuts === nothing - all_cuts = cuts - else - all_cuts.lhs = [all_cuts.lhs; cuts.lhs] - all_cuts.lb = [all_cuts.lb; cuts.lb] - all_cuts.ub = [all_cuts.ub; cuts.ub] + tableau = compute_tableau(data_s, current_basis; rows=collect(rows)) + cuts_s = compute_gmi(data_s, tableau) + cuts = backwards(transforms, cuts_s) + if all_cuts === nothing + all_cuts = cuts + else + all_cuts.lhs = [all_cuts.lhs; cuts.lhs] + all_cuts.lb = [all_cuts.lb; cuts.lb] + all_cuts.ub = [all_cuts.ub; cuts.ub] + end + catch e + if isa(e, AssertionError) + @warn "Numerical error detected. Skipping cuts from current tableau." + continue + else + rethrow(e) + end end end end @@ -555,13 +554,14 @@ function KnnDualGmiComponent_before_mip(data::_KnnDualGmiData, test_h5, model, _ neigh_dist, neigh_ind = data.model.kneighbors(x, return_distance = true) neigh_ind = neigh_ind .+ 1 N = length(neigh_dist) + k = min(N, data.k) if data.strategy == "near" - selected = collect(1:(data.k)) + selected = collect(1:k) elseif data.strategy == "far" - selected = collect((N - data.k + 1) : N) + selected = collect((N - k + 1) : N) elseif data.strategy == "rand" - selected = shuffle(collect(1:N))[1:(data.k)] + selected = shuffle(collect(1:N))[1:k] else error("unknown strategy: $(data.strategy)") end @@ -569,7 +569,7 @@ function KnnDualGmiComponent_before_mip(data::_KnnDualGmiData, test_h5, model, _ @info "Dual GMI: Selected neighbors ($(data.strategy)):" neigh_dist = neigh_dist[selected] neigh_ind = neigh_ind[selected] - for i in 1:data.k + for i in 1:k h5_filename = data.train_h5[neigh_ind[i]] dist = neigh_dist[i] @info " $(h5_filename) dist=$(dist)" @@ -591,7 +591,7 @@ function KnnDualGmiComponent_before_mip(data::_KnnDualGmiData, test_h5, model, _ print_timer() stats = Dict() - stats["KnnDualGmi: k"] = data.k + stats["KnnDualGmi: k"] = k stats["KnnDualGmi: strategy"] = data.strategy stats["KnnDualGmi: cuts"] = length(cuts.lb) stats["KnnDualGmi: time generate"] = time_generate