diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b2930d99..be1a273b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ### Added +- Added primal_dual_evolution recipe and a plot recipe ### Fixed ### Changed ### Removed diff --git a/examples/finished/__init__.py b/examples/finished/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/finished/plot_primal_dual_evolution.py b/examples/finished/plot_primal_dual_evolution.py new file mode 100644 index 000000000..8d4c966bb --- /dev/null +++ b/examples/finished/plot_primal_dual_evolution.py @@ -0,0 +1,54 @@ +""" +This example show how to retrieve the primal and dual solutions during the optimization process +and plot them as a function of time. The model is about gas transportation and can be found in +PySCIPOpt/tests/helpers/utils.py + +It makes use of the attach_primal_dual_evolution_eventhdlr recipe. + +Requires matplotlib, and may require PyQt6 to show the plot. +""" + +from pyscipopt import Model + +def plot_primal_dual_evolution(model: Model): + try: + from matplotlib import pyplot as plt + except ImportError: + raise ImportError("matplotlib is required to plot the solution. Try running `pip install matplotlib` in the command line.\ + You may also need to install PyQt6 to show the plot.") + + assert model.data["primal_log"], "Could not find any feasible solutions" + time_primal, val_primal = map(list,zip(*model.data["primal_log"])) + time_dual, val_dual = map(list,zip(*model.data["dual_log"])) + + + if time_primal[-1] < time_dual[-1]: + time_primal.append(time_dual[-1]) + val_primal.append(val_primal[-1]) + + if time_primal[-1] > time_dual[-1]: + time_dual.append(time_primal[-1]) + val_dual.append(val_dual[-1]) + + plt.plot(time_primal, val_primal, label="Primal bound") + plt.plot(time_dual, val_dual, label="Dual bound") + + plt.legend(loc="best") + plt.show() + +if __name__=="__main__": + from pyscipopt.recipes.primal_dual_evolution import attach_primal_dual_evolution_eventhdlr + import os + import sys + + # just a way to import files from different folders, not important + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../tests/helpers'))) + + from utils import gastrans_model + + model = gastrans_model() + model.data = {} + attach_primal_dual_evolution_eventhdlr(model) + + model.optimize() + plot_primal_dual_evolution(model) diff --git a/src/pyscipopt/recipes/primal_dual_evolution.py b/src/pyscipopt/recipes/primal_dual_evolution.py new file mode 100644 index 000000000..3e86a03d9 --- /dev/null +++ b/src/pyscipopt/recipes/primal_dual_evolution.py @@ -0,0 +1,46 @@ +from pyscipopt import Model, Eventhdlr, SCIP_EVENTTYPE, Eventhdlr + +def attach_primal_dual_evolution_eventhdlr(model: Model): + """ + Attaches an event handler to a given SCIP model that collects primal and dual solutions, + along with the solving time when they were found. + The data is saved in model.data["primal_log"] and model.data["dual_log"]. They consist of + a list of tuples, each tuple containing the solving time and the corresponding solution. + + A usage example can be found in examples/finished/plot_primal_dual_evolution.py. The + example takes the information provided by this recipe and uses it to plot the evolution + of the dual and primal bounds over time. + """ + class GapEventhdlr(Eventhdlr): + + def eventinit(self): # we want to collect best primal solutions and best dual solutions + self.model.catchEvent(SCIP_EVENTTYPE.BESTSOLFOUND, self) + self.model.catchEvent(SCIP_EVENTTYPE.LPSOLVED, self) + self.model.catchEvent(SCIP_EVENTTYPE.NODESOLVED, self) + + + def eventexec(self, event): + # if a new best primal solution was found, we save when it was found and also its objective + if event.getType() == SCIP_EVENTTYPE.BESTSOLFOUND: + self.model.data["primal_log"].append([self.model.getSolvingTime(), self.model.getPrimalbound()]) + + if not self.model.data["dual_log"]: + self.model.data["dual_log"].append([self.model.getSolvingTime(), self.model.getDualbound()]) + + if self.model.getObjectiveSense() == "minimize": + if self.model.isGT(self.model.getDualbound(), self.model.data["dual_log"][-1][1]): + self.model.data["dual_log"].append([self.model.getSolvingTime(), self.model.getDualbound()]) + else: + if self.model.isLT(self.model.getDualbound(), self.model.data["dual_log"][-1][1]): + self.model.data["dual_log"].append([self.model.getSolvingTime(), self.model.getDualbound()]) + + + if not hasattr(model, "data") or model.data==None: + model.data = {} + + model.data["primal_log"] = [] + model.data["dual_log"] = [] + hdlr = GapEventhdlr() + model.includeEventhdlr(hdlr, "gapEventHandler", "Event handler which collects primal and dual solution evolution") + + return model \ No newline at end of file diff --git a/tests/data/readStatistics.stats b/tests/data/readStatistics.stats new file mode 100644 index 000000000..c98992f6f --- /dev/null +++ b/tests/data/readStatistics.stats @@ -0,0 +1,172 @@ +SCIP Status : solving was interrupted [solution limit reached] +Total Time : 0.00 + solving : 0.00 + presolving : 0.00 (included in solving) + reading : 0.00 + copying : 0.00 (0 times copied the problem) +Original Problem : + Problem name : model + Variables : 1 (0 binary, 0 integer, 0 implicit integer, 1 continuous) + Constraints : 0 initial, 0 maximal + Objective : minimize, 0 non-zeros (abs.min = 1e+20, abs.max = -1e+20) +Presolved Problem : + Problem name : t_model + Variables : 1 (0 binary, 0 integer, 0 implicit integer, 1 continuous) + Constraints : 0 initial, 0 maximal + Objective : minimize, 0 non-zeros (abs.min = 1e+20, abs.max = -1e+20) + Nonzeros : 0 constraint, 0 clique table +Presolvers : ExecTime SetupTime Calls FixedVars AggrVars ChgTypes ChgBounds AddHoles DelCons AddCons ChgSides ChgCoefs + boundshift : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + convertinttobin : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + domcol : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + dualagg : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + dualcomp : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + dualinfer : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + dualsparsify : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + gateextraction : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + implics : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + inttobinary : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + qpkktref : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + redvub : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + sparsify : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + stuffing : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + trivial : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + tworowbnd : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + dualfix : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + genvbounds : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + probing : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + pseudoobj : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + symmetry : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + vbounds : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + benders : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + components : 0.00 0.00 0 0 0 0 0 0 0 0 0 0 + root node : - - - 0 - - 0 - - - - - +Constraints : Number MaxNumber #Separate #Propagate #EnfoLP #EnfoRelax #EnfoPS #Check #ResProp Cutoffs DomReds Cuts Applied Conss Children + benderslp : 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + integral : 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + benders : 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + fixedvar : 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + countsols : 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + components : 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +Constraint Timings : TotalTime SetupTime Separate Propagate EnfoLP EnfoPS EnfoRelax Check ResProp SB-Prop + benderslp : 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 + integral : 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 + benders : 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 + fixedvar : 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 + countsols : 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 + components : 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 +Propagators : #Propagate #ResProp Cutoffs DomReds + dualfix : 0 0 0 0 + genvbounds : 0 0 0 0 + nlobbt : 0 0 0 0 + obbt : 0 0 0 0 + probing : 0 0 0 0 + pseudoobj : 0 0 0 0 + redcost : 0 0 0 0 + rootredcost : 0 0 0 0 + symmetry : 0 0 0 0 + vbounds : 0 0 0 0 +Propagator Timings : TotalTime SetupTime Presolve Propagate ResProp SB-Prop + dualfix : 0.00 0.00 0.00 0.00 0.00 0.00 + genvbounds : 0.00 0.00 0.00 0.00 0.00 0.00 + nlobbt : 0.00 0.00 0.00 0.00 0.00 0.00 + obbt : 0.00 0.00 0.00 0.00 0.00 0.00 + probing : 0.00 0.00 0.00 0.00 0.00 0.00 + pseudoobj : 0.00 0.00 0.00 0.00 0.00 0.00 + redcost : 0.00 0.00 0.00 0.00 0.00 0.00 + rootredcost : 0.00 0.00 0.00 0.00 0.00 0.00 + symmetry : 0.00 0.00 0.00 0.00 0.00 0.00 + vbounds : 0.00 0.00 0.00 0.00 0.00 0.00 +Conflict Analysis : Time Calls Success DomReds Conflicts Literals Reconvs ReconvLits Dualrays Nonzeros LP Iters (pool size: [--,--]) + propagation : 0.00 0 0 - 0 0.0 0 0.0 - - - + infeasible LP : 0.00 0 0 - 0 0.0 0 0.0 0 0.0 0 + bound exceed. LP : 0.00 0 0 - 0 0.0 0 0.0 0 0.0 0 + strong branching : 0.00 0 0 - 0 0.0 0 0.0 - - 0 + pseudo solution : 0.00 0 0 - 0 0.0 0 0.0 - - - + applied globally : 0.00 - - 0 0 0.0 - - 0 - - + applied locally : - - - 0 0 0.0 - - 0 - - +Primal Heuristics : ExecTime SetupTime Calls Found Best + LP solutions : 0.00 - - 0 0 + relax solutions : 0.00 - - 0 0 + pseudo solutions : 0.00 - - 0 0 + strong branching : 0.00 - - 0 0 + actconsdiving : 0.00 0.00 0 0 0 + adaptivediving : 0.00 0.00 0 0 0 + alns : 0.00 0.00 0 0 0 + bound : 0.00 0.00 0 0 0 + clique : 0.00 0.00 0 0 0 + coefdiving : 0.00 0.00 0 0 0 + completesol : 0.00 0.00 0 0 0 + conflictdiving : 0.00 0.00 0 0 0 + crossover : 0.00 0.00 0 0 0 + dins : 0.00 0.00 0 0 0 + distributiondivin: 0.00 0.00 0 0 0 + dps : 0.00 0.00 0 0 0 + dualval : 0.00 0.00 0 0 0 + farkasdiving : 0.00 0.00 0 0 0 + feaspump : 0.00 0.00 0 0 0 + fixandinfer : 0.00 0.00 0 0 0 + fracdiving : 0.00 0.00 0 0 0 + gins : 0.00 0.00 0 0 0 + guideddiving : 0.00 0.00 0 0 0 + indicator : 0.00 0.00 0 0 0 + indicatordiving : 0.00 0.00 0 0 0 + intdiving : 0.00 0.00 0 0 0 + intshifting : 0.00 0.00 0 0 0 + linesearchdiving : 0.00 0.00 0 0 0 + localbranching : 0.00 0.00 0 0 0 + locks : 0.00 0.00 0 0 0 + lpface : 0.00 0.00 0 0 0 + mpec : 0.00 0.00 0 0 0 + multistart : 0.00 0.00 0 0 0 + mutation : 0.00 0.00 0 0 0 + nlpdiving : 0.00 0.00 0 0 0 + objpscostdiving : 0.00 0.00 0 0 0 + octane : 0.00 0.00 0 0 0 + ofins : 0.00 0.00 0 0 0 + oneopt : 0.00 0.00 0 0 0 + padm : 0.00 0.00 0 0 0 + proximity : 0.00 0.00 0 0 0 + pscostdiving : 0.00 0.00 0 0 0 + randrounding : 0.00 0.00 0 0 0 + rens : 0.00 0.00 0 0 0 + reoptsols : 0.00 0.00 0 0 0 + repair : 0.00 0.00 0 0 0 + rins : 0.00 0.00 0 0 0 + rootsoldiving : 0.00 0.00 0 0 0 + rounding : 0.00 0.00 0 0 0 + scheduler : 0.00 0.00 0 0 0 + shiftandpropagate: 0.00 0.00 0 0 0 + shifting : 0.00 0.00 0 0 0 + simplerounding : 0.00 0.00 0 0 0 + subnlp : 0.00 0.00 0 0 0 + trivial : 0.00 0.00 0 0 0 + trivialnegation : 0.00 0.00 0 0 0 + trustregion : 0.00 0.00 0 0 0 + trysol : 0.00 0.00 0 0 0 + twoopt : 0.00 0.00 0 0 0 + undercover : 0.00 0.00 0 0 0 + vbounds : 0.00 0.00 0 0 0 + veclendiving : 0.00 0.00 0 0 0 + zeroobj : 0.00 0.00 0 0 0 + zirounding : 0.00 0.00 0 0 0 + other solutions : - - - 0 - +LNS (Scheduler) : Calls SetupTime SolveTime SolveNodes Sols Best Exp3 Exp3-IX EpsGreedy UCB TgtFixRate Opt Inf Node Stal Sol Usr Othr Actv + rens : 0 0.00 0.00 0 0 0 0.00000 0.00000 -1.00000 1.00000 0.900 0 0 0 0 0 0 0 1 + rins : 0 0.00 0.00 0 0 0 0.00000 0.00000 -1.00000 1.00000 0.900 0 0 0 0 0 0 0 1 + mutation : 0 0.00 0.00 0 0 0 0.00000 0.00000 -1.00000 1.00000 0.900 0 0 0 0 0 0 0 1 + localbranching : 0 0.00 0.00 0 0 0 0.00000 0.00000 -1.00000 1.00000 0.900 0 0 0 0 0 0 0 1 + crossover : 0 0.00 0.00 0 0 0 0.00000 0.00000 -1.00000 1.00000 0.900 0 0 0 0 0 0 0 1 + proximity : 0 0.00 0.00 0 0 0 0.00000 0.00000 -1.00000 1.00000 0.900 0 0 0 0 0 0 0 1 + zeroobjective : 0 0.00 0.00 0 0 0 0.00000 0.00000 -1.00000 1.00000 0.900 0 0 0 0 0 0 0 1 + dins : 0 0.00 0.00 0 0 0 0.00000 0.00000 -1.00000 1.00000 0.900 0 0 0 0 0 0 0 1 + trustregion : 0 0.00 0.00 0 0 0 0.00000 0.00000 -1.00000 1.00000 0.900 0 0 0 0 0 0 0 1 +Solution : + Solutions found : 0 (0 improvements) + Primal Bound : - + Dual Bound : - + Gap : infinite +Integrals : Total Avg% + primal-dual : 0.02 100.00 + primal-ref : - - (not evaluated) + dual-ref : - - (not evaluated) diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py index 0eaff8c2f..4166c3027 100644 --- a/tests/helpers/utils.py +++ b/tests/helpers/utils.py @@ -1,14 +1,6 @@ from pyscipopt import Model, quicksum, SCIP_PARAMSETTING, exp, log, sqrt, sin from typing import List -from pyscipopt.scip import is_memory_freed - - -def is_optimized_mode(): - model = Model() - return is_memory_freed() - - def random_mip_1(disable_sepa=True, disable_huer=True, disable_presolve=True, node_lim=2000, small=False): model = Model() @@ -77,19 +69,15 @@ def random_nlp_1(): return model -def knapsack_model(weights=[4, 2, 6, 3, 7, 5], costs=[7, 2, 5, 4, 3, 4]): +def knapsack_model(weights=[4, 2, 6, 3, 7, 5], costs=[7, 2, 5, 4, 3, 4], knapsack_size = 15): # create solver instance s = Model("Knapsack") - s.hideOutput() # setting the objective sense to maximise s.setMaximize() assert len(weights) == len(costs) - # knapsack size - knapsackSize = 15 - # adding the knapsack variables knapsackVars = [] varNames = [] diff --git a/tests/test_heur.py b/tests/test_heur.py index 4af260e5b..454f11c74 100644 --- a/tests/test_heur.py +++ b/tests/test_heur.py @@ -4,9 +4,9 @@ import pytest from pyscipopt import Model, Heur, SCIP_RESULT, SCIP_PARAMSETTING, SCIP_HEURTIMING, SCIP_LPSOLSTAT -from pyscipopt.scip import is_memory_freed +from test_memory import is_optimized_mode -from helpers.utils import random_mip_1, is_optimized_mode +from helpers.utils import random_mip_1 class MyHeur(Heur): diff --git a/tests/test_memory.py b/tests/test_memory.py index 2433c2c6a..f73070d25 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -1,6 +1,5 @@ import pytest from pyscipopt.scip import Model, is_memory_freed, print_memory_in_use -from helpers.utils import is_optimized_mode def test_not_freed(): if is_optimized_mode(): @@ -16,4 +15,8 @@ def test_freed(): assert is_memory_freed() def test_print_memory_in_use(): - print_memory_in_use() \ No newline at end of file + print_memory_in_use() + +def is_optimized_mode(): + model = Model() + return is_memory_freed() \ No newline at end of file diff --git a/tests/test_model.py b/tests/test_model.py index 365f3e919..f5dcd062a 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -476,4 +476,4 @@ def test_getObjVal(): assert m.getVal(x) == 0 assert m.getObjVal() == 16 - assert m.getVal(x) == 0 + assert m.getVal(x) == 0 \ No newline at end of file diff --git a/tests/test_recipe_primal_dual_evolution.py b/tests/test_recipe_primal_dual_evolution.py new file mode 100644 index 000000000..d6d12d644 --- /dev/null +++ b/tests/test_recipe_primal_dual_evolution.py @@ -0,0 +1,28 @@ +from pyscipopt.recipes.primal_dual_evolution import attach_primal_dual_evolution_eventhdlr +from helpers.utils import bin_packing_model + +def test_primal_dual_evolution(): + from random import randint + + model = bin_packing_model(sizes=[randint(1,40) for _ in range(120)], capacity=50) + model.setParam("limits/time",5) + + model.data = {"test": True} + model = attach_primal_dual_evolution_eventhdlr(model) + + assert "test" in model.data + assert "primal_log" in model.data + + model.optimize() + + for i in range(1, len(model.data["primal_log"])): + if model.getObjectiveSense() == "minimize": + assert model.data["primal_log"][i][1] <= model.data["primal_log"][i-1][1] + else: + assert model.data["primal_log"][i][1] >= model.data["primal_log"][i-1][1] + + for i in range(1, len(model.data["dual_log"])): + if model.getObjectiveSense() == "minimize": + assert model.data["dual_log"][i][1] >= model.data["dual_log"][i-1][1] + else: + assert model.data["dual_log"][i][1] <= model.data["dual_log"][i-1][1]