Fix and Optimize mit abstrakten Unfixierern

Fix and Optimize mit abstrakten Unfixierern

Blaupause für ein leicht adaptierbares Fix-and-Optimize Framework.

Ausgangslage

In diesem Artikel entwerfe ich ein simples Fix-and-Optimize Framework mit Hilfe der Google OR-Tools und abstrakten Fixier-Klassen.

Fix-and-Optimize

Egal welches Problem mit Fix-and-Optimize gelöst werden soll, der Ansatz ist immer sehr ähnlich:

Die Variablen (i.d.R. die binären- und/oder ganzzahligen Variablen) eines mathematischen Optimierungsmodells werden in zwei Teilmengen aufgeteilt:

Dies führt dazu, dass immer nur eine kleine Anzahl von Entscheidungen durch den Solver getroffen werden müssen und das korrespondierende mathematische Optimierungsmodell meist sehr schnell gelöst werden kann. Nachteil dieses Vorgehens ist, dass die Lösungen nicht zwingend optimal sind.

erzeuge Startlösung
while Abbruchkriterium nicht erfüllt:

    löse die Fixierung einiger Variablen (gemäß eines Musters)
    löse das resultierende MIP

    if Lösung ist besser als beste Bekannte:
        aktualisiere beste Lösung
    
    fixiere alle Variablen auf ggf. neue Werte

Wir müssen also sicherstellen, dass drei zentrale Konzepte implementiert werden:

Die konkreten Ausprägungen der beiden Konzepte sind stark problemspezifisch. So fixieren wir in eine Standortplanungsproblem eher eröffnete Standorte, während in einem Losgrößenplanungsproblem Rüstperioden fixiert werden.

(Abstraktes) Mathematisches Optimierungsmodell

Jede Fix-and-Optimize Heuristik benötigt ein mathematisches Optimierungsmodell. Unabhängig davon, welches Modell gewählt wird, sind für das Verfahren zwei Funktionen relevant:

Deswegen wird die abstrakte Modellklasse wie folgt entworfen:

class MipModel(ABC):
    def __init__(self,name="mip") -> None:
        self.solver = pywraplp.Solver(name,pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING)
    
    def solve(self):
        self.solver.Solve()
    
    def objective(self):
        return self.solver.Objective().Value()

Beispielimplementierung: MIP SLULSP

class Slulsp(MipModel):

    def __init__(self,d, s, l) -> None:

        self.solver = pywraplp.Solver("SLULSP",pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING)
        self.periods = len(d)

        # Variablen
        self.L = self.__initL(self.solver, self.periods)
        self.x = self.__initX(self.solver, self.periods)
        self.y = self.__initY(self.solver, self.periods)

        # Nebenbedingungen
        self.__storageConst(self.solver,self.L, self.x,self.periods,d)
        self.__setupConst(self.solver,self.x, self.y, self.periods,d)

        # ZF
        self.__addObjective(self.solver,l,self.L,s, self.y)
    
    
    def __initL(self, solver, T):
        # Lagerbestand am Ende von t
        return [solver.NumVar(0.0, solver.infinity(), "") for _ in range(T)]   
    def __initX(self, solver, T):
        # Produktionsmenge X in t
        return [solver.NumVar(0.0, solver.infinity(), "") for _ in range(T)]   
    def __initY(self, solver, T):
        # = 1 wenn in t produziert wird, 0 sonst
        return [solver.BoolVar("") for _ in range(T)]                          

    def __storageConst(self,solver,L,x,T,d):
        solver.Add(x[0] - d[0] == L[0])
        for t in range(1,T):
            solver.Add(L[t-1] + x[t] - d[t] == L[t])

    def __setupConst(self,solver,x,y,T, d):
        # Hinreichend große Zahl
        M = sum(d)                      
        for t in range(T):
            solver.Add(x[t] <= M*y[t])

    def __addObjective(self,solver,l,L,s,y ):
        solver.Minimize( solver.Sum([l*L[t] + s*y[t] for t in range(T)]) )

Fixierung und unfixierung von Variablen: Generelle Idee

Wenn die Heuristik von vornherein einigermaßen effizient implementiert werden soll, dann betrachten wir in jeder Iteration nur all jene Variablen deren Fixierung wir lösen wollen. Alle anderen Variablen des Modells lassen wir unangetastet. e

Wir fixieren- bzw. lösen diese Variablen indem wir festlegen, dass ihre Lowerbound der Upperbound entspricht. Wollen wir beispielsweise festlegen, dass eine Binärvariable auf 0 fixiert werden soll, dann nutzen wir variable.SetBounds(0,0), um die Lowerbound (erste 0) und Upperbounds (zweite 0) auf Null zu setzen. Soll die Variable auf 1 fixiert werden, schreiben wir variable.SetBounds(1,1).

Dieses Vorgehen erledigt was wir wollen, es gilt aber etwas wichtiges zu beachten:

Sobald die Lower- bzw. Upperbound irgendeiner Variable im Modell verändert wird, werden alle aktuellen Lösungswerte, die die Variablen haben zurückgesetzt.

Dieser Umstand (den ich schmerzlich lernen musste, Grüße gehen raus an den Debugger), wird dann problematisch, wenn wir nacheinander die Bounds einer Variable auf den Wert einer aktuellen Lösung fixieren wollen. Denn mit er Fixierung der ersten Variable, sind die weiteren Werte nicht mehr abrufbar.

Beispiel anhand von SLULSP (crasht)

mip = Slulsp(d,s,l)
mip.solve()

for y in mip.y:
    # Crasht ab der 2. Iteration
    wert = y.solution_value()
    y.SetBounds(wert,wert)

Aus diesem Grund gehen wir wie folgt vor:

Hierfür nutzen wir die folgende Zwischenspeicherklasse:

@dataclass
class VarValueStorage:
    variable : object
    value : float

Nachfolgend nutzen wir das beigefügte Muster, damit das Programm sauber durchläuft.

Beispiel anhand von SLULSP (crasht nicht mehr)

mip = Slulsp(d,s,l)
mip.solve()

zwischenspeicher = []
# Speichert alle Werte
for y in mip.y:
    wert = y.solution_value()
    zwischenspeicher.append(VarValueStorage(y, wert))

# Setzt die Bounds ohne zu crashen
for zw_speicher in zwischenspeicher: 
    zw_speicher.variable.SetBounds(zw_speicher.value, zw_speicher.value)

Abstrakte Unfixer/Fixer Klasse

Mit dem zuvor dargestellten Ansatz entwerfen wir jetzt ein Unfixer Klasse. Hier werden je nach Problemstellung unterschiedliche Ansätze implementiert, um Variablen zu lösen.

class Unfixer(ABC):

    def __init__(self) -> None:
        self.variables_to_unfix = []
    
    @abstractmethod
    def unfix(self,model : MipModel):
        """
        Löst die Fixierung von Variablen. 
        Welche Variablen konkret ausgewählt werden, hängt von der Implementierung ab.
        Wichtig: self.variables_to_unfix leeren.
        """
        pass

    def update_fix_values(self):
        """ Fixiert die neuen (besseren) Werte der Variablen, die zuvor gelöst wurden.
        """

        # Aktualisiert die neuen Werte der Variablen
        for stored_var in self.variables_to_unfix:
            new_value = stored_var.variable.solution_value()
            stored_var.value = new_value
    
    def refix(self):
        """ Fixiert die Variablen auf die Werte, die sie vor der Unfixierung hatten """
        for stored_var in self.variables_to_unfix:
            prev_value = stored_var.value
            stored_var.variable.SetBounds(prev_value,prev_value)

Beispielimplementierung: Unfixer für SLULSP

Nachfolgend wollen wir rollierend immer lookahead aufeinanderfolgende Rüstperioden unfixieren. Wenn wir über den Planungshorizont hinaus lappen, beginnen wir vorne.

Wir lösen daher die Fixierung der y-Variablen, so dass sie wieder die Werte 0 oder 1 annehmen können. Um den aktuellen fixierten Wert zwischenzuspeichern reicht es aus, die aktuelle Lower Bound zu speichern (da sie ja der Upperbound entspricht).

Da SlulspUnfixer die abstrakte Klasse Unfixer implementiert, brauchen wir uns über update_fix_values und refix keine Sorgen machen. Diese sind direkt verfügbar.

class SlulspUnfixer(Unfixer):

    def __init__(self, lookahead : int) -> None:
        super().__init__()
        self.t_now = 0
        self.lookahead = lookahead

    def unfix(self, model: Slulsp):

        self.variables_to_unfix = []
        
        for pseudo_t in range(self.t_now,self.t_now+self.lookahead):
            t = pseudo_t % len(model.y)
            stor = VarValueStorage(model.y[t], model.y[t].lb())
            self.variables_to_unfix.append(stor)
        
        for var in self.variables_to_unfix:
            var.variable.SetBounds(0,1)
        
        self.t_now = self.t_now + 1 % len(model.y)

Fix-and-Optimize Framework

Zu guter letzt wird das Fix and Optimize Framework implementiert, das nach einer definierten Anzahl Iterationen abbricht.

class FixAndOptimize:

    def __init__(self,model : MipModel, unfixer, minimize:bool, iterations=100) -> None:
        self.model = model
        self.unfixer = unfixer
        self.iterations = iterations
        self.minimize = minimize
    
    def __is_better(self,val_a : float, val_b : float):
        if self.minimize:
            return val_a < val_b
        return val_a > val_b
    
    def solve(self):
        """ Setzt voraus, dass eine initiale Lösung gegeben und fixiert ist."""

        # Speichere Startzielfunktionswert als Besten
        self.bestObjective = self.model.objective()

        # Fix and Optimize für eine gegebene Anzahl Iterationen
        for i in range(self.iterations):

            # Löst Variablenwerte
            self.unfixer.unfix(self.model)

            # Optimiert
            self.model.solve()

            # Updatet die beste bekannte Lösung, falls zutreffend
            if self.__is_better(self.model.objective(), self.bestObjective):
                self.bestObjective = self.model.objective()
                self.unfixer.update_fix_values()
            
            # Fixiert die zuvor gelösten und reoptimierten Variablen erneut
            self.unfixer.refix()

Konkrete Implementierung: Fix-and-Optimize

# Nachfrage der Perioden
d = [50,30,20,10,5,50,10,30]    
# Rüstkostensatz
s = 50                          
# Lagerkostensatz
l = 1                           


# Startlösung bei der in jeder Periode gerüstet wird
mip = Slulsp(d,s,l)
for v in mip.y:
    v.SetBounds(1,1)
mip.solve()

# Unfixer mit zwei aufeinanderfolgenden Perioden
unfixer = SlulspUnfixer(lookahead=2)
f_and_o = FixAndOptimize(mip, unfixer,True)
f_and_o.solve()

Try yourself

Der oben dargestellte Code liegt auf GitHub.

Ausprobiert werden kann er auf Google Colab