Kapitel 8 · Evolution im Computer

Evolution im Computer: Ein genetischer Algorithmus lernt landen

Niemand sagt dem Computer, wie man landet. Er probiert 50 zufällige Flugpläne, behält die besseren, kreuzt und verändert sie – und nach wenigen Generationen setzt die Mondfähre sanft auf. Hier siehst du die Evolution Generation für Generation.

Aus dem Buch · Kapitel 8 Profi KI & AlgorithmenSimulation Python 3 · läuft im Browser
Bereit · Python startet beim ersten Klick
# Genetischer Algorithmus – Kapitel 8 »Evolution im Computer«
# aus »Coding for Fun mit Python«, portiert auf Python 3 und c4f.
# lander.py, landerSimulation.py, Evolution.py und AutomatedLunarLander_v1.py
# stehen hier zusammen in einer Datei.
import copy
import math
import random
from c4f import Screen, sleep

fieldWidth  = 800
fieldHeight = 600
fps         = 80
replaySpeed = 2   # Simulationsschritte pro Bild beim Abspielen (1 = Echtzeit)


# --- lander.py: die Mondfähre --------------------------------------------
class lander(object):
    def __init__(self, xPos, yPos, xVelocity, yVelocity):
        self.xVelocity = xVelocity
        self.yVelocity = yVelocity
        self.xPos      = xPos
        self.yPos      = yPos

    def calculateDelta(self, fps, xAcceleration, yAcceleration):
        dt = 1.0 / fps
        self.xVelocity += xAcceleration * dt
        self.xPos      += self.xVelocity * dt
        self.yVelocity += yAcceleration * dt
        self.yPos      += self.yVelocity * dt

        return self.xPos, self.yPos

    def checkRules(self):
        yVelocity = abs(self.yVelocity)
        xVelocity = abs(self.xVelocity)
        if yVelocity < 40 and xVelocity < 30:
            return True
        else:
            return False


# --- landerSimulation.py: ein Flug ohne Grafik ----------------------------
class landerSimulation(object):
    def __init__(self):
        self.lunarLander = lander(200, 400, 0, 0)
        self.path  = [(200, 400)]  # die Flugbahn, fürs Zeichnen
        self.steps = 0

    def loadNextCommand(self):
        if self.commandList:
            self.command = self.commandList.pop(0)
            return True
        else:
            self.command = (0, 0)
            return False

    def collisionDetection(self, rocket, ground):
        rx1, ry1, width, height = rocket
        rx2, ry2                = rx1 + width, ry1 + height
        gx1, gy1, width, height = ground
        gx2, gy2                = gx1 + width, gy1 + height

        if (rx1 >= gx1 and rx1 <= gx2) or (rx2 >= gx1 and rx2 <= gx2):
            # horizontal betrachtet befinden wir uns in Bodennähe
            if (ry1 >= gy1 and ry1 <= gy2) or (ry2 >= gy1 and ry2 <= gy2):
                # die Körper überdecken sich auch vertikal
                return True
        return False

    def calculateDistance(self, rocket, ground):
        rx1, ry1, rx2, ry2   = rocket
        gx1, gy1, gx2, gy2   = ground
        groundCenterUpside   = (gx1 + gx2 / 2.0, gy1 + gy2)
        rocketCenterDownside = (rx1 + rx2 / 2.0, ry1)

        return math.sqrt((rocketCenterDownside[0] - groundCenterUpside[0])**2 +
                         (rocketCenterDownside[1] - groundCenterUpside[1])**2)

    def record(self, xPos, yPos):
        self.steps += 1
        if self.steps % 4 == 0:
            self.path.append((xPos, yPos))

    def start(self, commandList):
        # commandList wird kopiert, um die Ausgangsliste zu erhalten
        self.commandList = commandList[:]
        while True:
            self.yAcceleration = -16.35
            self.xAcceleration = +0.0

            commandAvailable = self.loadNextCommand()
            if commandAvailable:
                if self.command == (0, +1):
                    self.yAcceleration = 20.0
                if self.command == (1, 0):
                    self.xAcceleration = -10
                if self.command == (-1, 0):
                    self.xAcceleration = +10

            xPos, yPos = self.lunarLander.calculateDelta(80, self.xAcceleration,
                                                         self.yAcceleration)
            self.record(xPos, yPos)

            rocket = xPos, yPos, 20, 30
            ground = 350, 50, 100, 30

            self.fitness = 100000  # damit die Fitness positiv bleibt
            # Flug zu Ende: keine Befehle mehr – oder schon am Boden
            if commandAvailable == False or yPos < ground[1] + ground[3]:
                # auf Boden warten
                while yPos >= ground[1] + ground[3]:
                    xPos, yPos = self.lunarLander.calculateDelta(
                        80, self.xAcceleration, self.yAcceleration)
                    self.record(xPos, yPos)
                rocket = xPos, yPos, 20, 30
                self.path.append((xPos, yPos))
                dist = self.calculateDistance(rocket, ground)
                self.fitness -= dist * 10
                self.fitness -= abs(self.lunarLander.xVelocity) * 4
                self.fitness -= abs(self.lunarLander.yVelocity) * 8

                if self.collisionDetection(rocket, ground):
                    if self.lunarLander.checkRules():
                        self.fitness += 100000

                break

        if (self.fitness < 0):
            raise Exception("negative fitness scores are evil")
        return self.fitness


# --- Evolution.py: Gene, Genome und die Evolution --------------------------
class Gen(object):
    __maxActionDuration   = 80
    __maxMutationDuration = 20

    leftRocket    = (-1,  0)
    rightRocket   = (+1,  0)
    centralRocket = ( 0, +1)
    doNothing     = ( 0, -1)

    actions = (leftRocket, rightRocket, centralRocket, doNothing)

    def __init__(self):
        self.action   = random.choice(Gen.actions)
        self.duration = random.randint(1, Gen.__maxActionDuration)

    def mutateAction(self):
        self.action = random.choice(Gen.actions)

    def mutateDuration(self):
        delta = (Gen.__maxMutationDuration
                 - random.randint(1, 2 * Gen.__maxMutationDuration))
        self.duration += delta
        if self.duration > self.__maxActionDuration:
            self.duration = self.__maxActionDuration

    def __eq__(self, other):
        return (self.action == other.action) and (self.duration == other.duration)


class Genome(object):
    def __init__(self, genes=None):
        if genes:
            # es ist wichtig, hier eine Kopie zu erzeugen! Ansonsten werden die
            # gleichen Gene in verschiedenen Individuen referenziert und verändert
            self.__genes = copy.deepcopy(genes)
        else:
            self.__genes = []
            for i in range(Evolution.numberOfGenes):
                self.__genes.append(Gen())

    def getGenes(self):
        return self.__genes

    def setGenes(self, genes):
        self.__genes = genes

    genes = property(getGenes, setGenes)

    def getCommandList(self):
        commandList = []
        for gen in self.__genes:
            for i in range(gen.duration):
                commandList.append(gen.action)

        return commandList

    def updateFitness(self):
        simulation   = landerSimulation()
        commands     = self.getCommandList()
        self.fitness = simulation.start(commands)
        self.path    = simulation.path

        return self.fitness

    def mutate(self):
        for gen in self.genes:
            if random.random() < Evolution.mutationRate:
                gen.mutateAction()
            if random.random() < Evolution.mutationRate / 2.0:
                gen.mutateDuration()


class Evolution(object):
    mutationRate   = 0.2
    crossOverRate  = 0.7

    numberOfGenes  = 30
    populationSize = 50
    saveBestCount  = 4   # muss gerade sein (Vater + Mutter)
    maxGenerations = 50

    def createNewGeneration(self, populationSize):
        population = []
        for i in range(populationSize):
            population.append(Genome())

        return population

    def multiCrossOver(self, dad, mum):
        if (random.random() > Evolution.crossOverRate):
            # keine Kreuzung
            return dad, mum

        swapRate = random.random() * Evolution.numberOfGenes

        baby1, baby2 = [], []
        dadGenes     = dad.genes
        mumGenes     = mum.genes

        for x in range(Evolution.numberOfGenes):
            if random.random() < swapRate:
                baby1.append(dadGenes[x])
                baby2.append(mumGenes[x])
            else:
                baby1.append(mumGenes[x])
                baby2.append(dadGenes[x])

        return Genome(baby1), Genome(baby2)

    def rouletteWheelSelection(self, population):
        totalFitness = 0
        for x in population:
            totalFitness += x.fitness

        randomSlice = random.random() * totalFitness
        fitnessSum  = 0
        for x in population:
            fitnessSum += x.fitness
            if fitnessSum >= randomSlice:
                return x

        return population[0]

    def sortByFitness(self, population):
        for individuum in population:
            individuum.updateFitness()
        population.sort(key=lambda x: x.fitness, reverse=True)
        currentBest = Genome(population[0].genes)
        currentBest.fitness = population[0].fitness
        currentBest.path    = population[0].path

        return currentBest

    async def evolve(self, show=None):
        population        = self.createNewGeneration(Evolution.populationSize)
        nextGeneration    = []
        generationCounter = 0
        bestIndividuum    = None

        while (generationCounter < Evolution.maxGenerations):
            currentBest = self.sortByFitness(population)
            print(f"Generation {generationCounter:2d}: "
                  f"beste Fitness {currentBest.fitness:9.1f}")
            if show:
                await show(generationCounter, population, currentBest)
            if (bestIndividuum == None) or \
               (currentBest.fitness > bestIndividuum.fitness):
                bestIndividuum = currentBest
                if bestIndividuum.fitness > 100000:
                    # wir haben eine Lösung
                    return bestIndividuum
            # die besten Individuen für die nächste Generation erhalten; dabei
            # brauchen wir eine gerade Anzahl, damit es immer Vater und Mutter gibt
            nextGeneration  = []
            nextGeneration += population[0:Evolution.saveBestCount]

            babyCount = 0
            while babyCount < (Evolution.populationSize):
                dad          = self.rouletteWheelSelection(population)
                mum          = self.rouletteWheelSelection(population)
                baby1, baby2 = self.multiCrossOver(dad, mum)

                baby1.mutate()
                baby2.mutate()

                nextGeneration += baby1, baby2
                babyCount      += 2

            population         = nextGeneration
            generationCounter += 1

        return bestIndividuum


# --- Darstellung im Browser ------------------------------------------------
stars = [(random.randint(0, fieldWidth), random.randint(0, fieldHeight - 100))
         for i in range(70)]


def toScreen(x, y):
    # OpenGL (glOrtho) zählte y von unten nach oben, die Leinwand zählt
    # von oben nach unten – diese Funktion rechnet die Punkte um
    return x + 10, fieldHeight - y


def drawRect(dimension, color):
    x, y, width, height = dimension
    left, top = toScreen(x, y + height)
    screen.rect(left, top, width, height, color)


def drawPath(path, color, width):
    # Mitte der Unterkante: die Fähre ist 20 breit
    screen.lines([toScreen(x + 10, y) for x, y in path], color, width)


def drawScene():
    screen.clear()
    for x, y in stars:
        screen.rect(x, y, 2, 2, (70, 75, 95))
    drawRect((350, 50, 100, 30), (230, 40, 40))       # Landeplatz
    screen.circle(toScreen(210, 400), 4, (150, 160, 180))  # Startpunkt


async def showGeneration(generation, population, best):
    drawScene()
    landed = 0
    for individuum in reversed(population):  # die Besten zuletzt, also obenauf
        if individuum.fitness > 100000:
            landed += 1
            drawPath(individuum.path, (90, 220, 120, 170), 1.5)
        else:
            drawPath(individuum.path, (255, 255, 255, 45), 1)
    drawPath(best.path, (255, 200, 60), 2.5)
    screen.text(f"Generation {generation}", (20, 16), "white", size=24, bold=True)
    screen.text(f"beste Fitness {best.fitness:9.1f}", (20, 50), (200, 205, 220),
                size=16, font="mono")
    screen.text(f"gelandet      {landed:3d} von {len(population)}", (20, 72),
                (90, 220, 120) if landed else (200, 205, 220), size=16, font="mono")
    await screen.frame(4)  # jede Generation mindestens eine Viertelsekunde zeigen


def drawFlames(rocket, command):
    x, y, width, height = rocket
    flame = (255, 170, 40)
    if command == (0, +1):
        tip = toScreen(x + width / 2, y - random.randint(12, 24))
        screen.polygon([toScreen(x + 4, y), toScreen(x + width - 4, y), tip], flame)
    if command == (1, 0):   # rechte Düse drückt nach links
        tip = toScreen(x + width + random.randint(8, 14), y + 15)
        screen.polygon([toScreen(x + width, y + 21),
                        toScreen(x + width, y + 9), tip], flame)
    if command == (-1, 0):  # linke Düse drückt nach rechts
        tip = toScreen(x - random.randint(8, 14), y + 15)
        screen.polygon([toScreen(x, y + 21), toScreen(x, y + 9), tip], flame)


async def showASCIIArt(fileName, delay, color):
    datei = open(fileName, "r")
    asciiArt = []
    y = 30
    for zeile in datei:
        x = -1
        y -= 1
        for zeichen in zeile:
            if zeichen != "\n":
                x += 1
                if zeichen == "X":
                    asciiArt.append((x, y))
    datei.close()

    # die Schrift mittig setzen (im Buch stand sie unten links)
    xs = [x for x, y in asciiArt]
    ys = [y for x, y in asciiArt]
    dx = (fieldWidth / 10 - max(xs) - min(xs) - 1) / 2
    dy = (fieldHeight / 10 - max(ys) - min(ys) - 1) / 2

    screen.clear()
    for x, y in asciiArt:
        drawRect(((x + dx) * 10.0, (y + dy) * 10.0, 9.0, 9.0), color)
    await sleep(delay / 1000)


# --- AutomatedLunarLander_v1.py: die beste Landung abspielen ---------------
command     = (0, 0)
commandList = []


def loadNextCommand():
    global command
    if commandList:
        command = commandList.pop(0)
        return True
    else:
        command = (0, 0)
        return False


async def findBestCommands():
    evo = Evolution()
    bestIndividual = await evo.evolve(showGeneration)
    cmdList = bestIndividual.getCommandList()

    # Test: dieselben Befehle noch einmal simulieren
    sim = landerSimulation()
    fitness = sim.start(cmdList)
    print(f"Fitness, Sim1: {bestIndividual.fitness:f}")
    print(f"Fitness, Sim2: {fitness:f}")

    return cmdList, bestIndividual


async def main():
    global screen, commandList
    screen = Screen(fieldWidth + 10, fieldHeight + 10, background=(0, 0, 0),
                    title="Evolution im Computer")
    ground = 350, 50, 100, 30

    while True:
        commandList, best = await findBestCommands()
        lunarLander = lander(200, 400, 0, 0)
        xPos, yPos = 200, 400

        # Flug abspielen, bis die Fähre den Boden erreicht
        while yPos >= ground[1] + ground[3]:
            for step in range(replaySpeed):
                yAcceleration = -16.35
                xAcceleration = +0.0
                commandAvailable = loadNextCommand()
                if commandAvailable:
                    if command == (0, +1):
                        yAcceleration = 20.0
                    if command == (1, 0):
                        xAcceleration = -10.0
                    if command == (-1, 0):
                        xAcceleration = +10.0
                xPos, yPos = lunarLander.calculateDelta(fps, xAcceleration,
                                                        yAcceleration)
                if yPos < ground[1] + ground[3]:
                    break

            drawScene()
            drawPath(best.path, (255, 200, 60, 90), 1.5)
            rocket = xPos, yPos, 20, 30
            drawFlames(rocket, command)
            drawRect(rocket, (230, 40, 40))
            screen.text("Die beste Landung der Evolution", (20, 16), "white",
                        size=22, bold=True)
            screen.text(f"Fallen {abs(lunarLander.yVelocity):5.1f}", (20, 48),
                        (200, 205, 220), size=16, font="mono")
            screen.text(f"Seite  {abs(lunarLander.xVelocity):5.1f}", (20, 70),
                        (200, 205, 220), size=16, font="mono")
            await screen.frame(fps)

        rocket = xPos, yPos, 20, 30
        rules  = landerSimulation()
        print(" Distance:", round(rules.calculateDistance(rocket, ground), 1))
        print("xVelocity:", round(lunarLander.xVelocity, 1))
        print("yVelocity:", round(lunarLander.yVelocity, 1))
        if rules.collisionDetection(rocket, ground) and lunarLander.checkRules():
            print("Gelandet!")
            await showASCIIArt("YouWon.txt", 5000, (90, 220, 120))
        else:
            print("Bruchlandung!")
            await showASCIIArt("GameOver.txt", 3000, (230, 40, 40))
        print("Neue Evolution …")


await main()
Viele weiße Flugbahnen einer Generation, eine gelbe beste Bahn und grüne gelungene Landungen über einer roten Landefläche

Jede Linie ist ein Flug: gelb der beste der Generation, grün alle gelungenen Landungen. Danach fliegt die beste Fähre ihre Landung vor.

Konsole

    

Was du hier lernst

  • genetische Algorithmen
  • Fitnessfunktion
  • Roulette-Wheel-Selection
  • Kreuzung und Mutation
  • Elitismus
  • Klassen und Properties
  • Sortieren mit key
  • tiefe Kopien mit deepcopy

So lernt der Computer das Landen

In der Mondlandung steuerst du selbst. Hier bekommt der Computer dieselbe Aufgabe, aber keine Strategie. Er soll sie wie die Natur finden: durch Evolution. Eine Population von Flugplänen wird getestet, die besseren dürfen sich fortpflanzen, ihre Nachkommen werden leicht verändert – und nach einigen Generationen setzt die Fähre sanft auf der roten Fläche auf.

1. Ein Flug ohne Bildschirm

Evolution braucht viele Versuche, deshalb muss ein Flug schnell zu simulieren sein. Die Klasse lander kennt Position und Geschwindigkeit der Fähre und rechnet sie Schritt für Schritt weiter:

def calculateDelta(self, fps, xAcceleration, yAcceleration):
    dt = 1.0 / fps
    self.xVelocity += xAcceleration * dt
    self.xPos      += self.xVelocity * dt
    self.yVelocity += yAcceleration * dt
    self.yPos      += self.yVelocity * dt

Anders als im Spiel wird hier korrekt mit Beschleunigung mal Zeit gerechnet, deshalb lauten auch die Zahlen anders: −16,35 für die Schwerkraft, +20 für die Bremsrakete. landerSimulation.start spielt mit einer Liste von Befehlen einen ganzen Flug durch – rund tausend Schritte, ganz ohne Grafik – und liefert am Ende eine Bewertung.

2. Ein Flugplan als Erbgut

Wie beschreibt man eine Landung so, dass Evolution damit arbeiten kann? Das Buch zerlegt sie in Gene. Ein Gen ist eine Aktion und ihre Dauer:

leftRocket    = (-1,  0)
rightRocket   = (+1,  0)
centralRocket = ( 0, +1)
doNothing     = ( 0, -1)

Ein Gen (centralRocket, 25) heißt: 25 Schritte lang die Bremsrakete zünden. Dreißig Gene hintereinander bilden ein Genom, und getCommandList macht daraus die Befehlsliste für die Simulation. Am Anfang sind alle Gene zufällig – die ersten Flüge sehen entsprechend wild aus.

3. Die Fitness: Wie gut war der Flug?

Die Evolution muss gute von schlechten Flügen unterscheiden. Dafür bekommt jeder Flug eine Fitness:

self.fitness  = 100000
self.fitness -= dist * 10
self.fitness -= abs(self.lunarLander.xVelocity) * 4
self.fitness -= abs(self.lunarLander.yVelocity) * 8

Abzüge gibt es für den Abstand zur Mitte der Landefläche und für die Geschwindigkeit beim Aufsetzen. Wer auf der Fläche landet und die Regeln einhält, bekommt 100.000 Bonuspunkte. Die abgestufte Bewertung ist wichtig: Auch ein knapper Absturz ist besser als einer weit daneben, und genau diese kleinen Unterschiede zeigen der Evolution die Richtung. Der Startwert von 100.000 hält alle Werte positiv – das braucht die Auswahl im nächsten Schritt.

4. Auslese, Kreuzung, Mutation

evolve wiederholt immer denselben Ablauf. Zuerst wird die Population nach Fitness sortiert:

population.sort(key=lambda x: x.fitness, reverse=True)

Die vier Besten kommen unverändert in die nächste Generation. Die übrigen Plätze füllen Nachkommen: rouletteWheelSelection wählt Eltern aus, und je höher die Fitness, desto größer das Stück vom Glücksrad. multiCrossOver mischt in 70 Prozent der Fälle die Gene von Vater und Mutter, und mutate verändert jedes Gen mit 20 Prozent Wahrscheinlichkeit in seiner Aktion und mit 10 Prozent in seiner Dauer. Sobald ein Flug die Bonuspunkte erreicht, ist eine Lösung gefunden.

Wozu Mutation?

Nur Kreuzen würde vorhandene Gene immer neu mischen, aber nie etwas Neues erfinden. Die Population könnte an einer mittelmäßigen Lösung hängen bleiben, einem lokalen Optimum. Mutation bringt frische Varianten ins Spiel. Zu viel Mutation zerstört dagegen gute Lösungen schneller, als die Auslese sie findet.

5. Die Evolution schummelt

Im Buch prüfte die Simulation erst nach dem letzten Befehl, ob die Fähre den Boden erreicht hat. Die Evolution hat das gnadenlos ausgenutzt: Viele »erfolgreiche« Flugpläne tauchten mitten im Flug durch den Mond und kamen am Ende langsam genug wieder heraus. In Tests passierte das in über der Hälfte der Läufe. Die Wiedergabe prüft den Boden dagegen in jedem Schritt – dort endete der Flug schon beim ersten Eintauchen, und in etwa jedem vierten Lauf war das eine Bruchlandung. Deshalb prüft die Simulation hier ebenfalls in jedem Schritt:

if commandAvailable == False or yPos < ground[1] + ground[3]:

Das ist eine echte Lektion über Optimierung: Ein genetischer Algorithmus optimiert genau das, was du misst – nicht das, was du meinst. Unter »Probier mal« kannst du die alte Regel zurückholen.

6. Zusehen, Generation für Generation

Damit du der Evolution zuschauen kannst, merkt sich jede Simulation ihre Flugbahn. Nach jeder Generation zeichnet showGeneration alle 50 Bahnen: weiß die Fehlversuche, grün die gelungenen Landungen, gelb die beste. await screen.frame(4) hält jedes Bild mindestens eine Viertelsekunde. Ist eine Lösung gefunden, fliegt die beste Fähre ihre Landung vor, danach beginnt eine ganz neue Evolution – mit neuen Zufallsgenen und meist einem anderen Weg zum Ziel.

Genau hingeschaut

In multiCrossOver liegt swapRate zwischen 0 und 30, verglichen wird aber mit random.random(), das immer kleiner als 1 ist. Fast immer erbt das erste Baby deshalb alle Gene vom Vater, es wird kaum gekreuzt. Außerdem gibt die Funktion ohne Kreuzung die Eltern selbst zurück, und mutate verändert dann die Eltern. Die Evolution funktioniert trotzdem, weil Auslese und Mutation die Hauptarbeit leisten. Unter »Probier mal« findest du die korrigierte Kreuzung.

Probier mal

Jede Variante ändert den Code oben. Ein Klick auf »Ausprobieren« übernimmt die Änderung in den Editor und startet das Programm. »Zurücksetzen« holt das Original zurück.

  1. 01Echte Kreuzung

    In multiCrossOver steckt ein kleiner Fehler: swapRate liegt zwischen 0 und 30, random.random() aber immer unter 1. Deshalb bekommt das erste Baby fast immer alle Gene vom Vater – gekreuzt wird kaum, die Evolution lebt fast nur von Mutation und Auslese. Mit einer echten Wahrscheinlichkeit zwischen 0 und 1 mischen sich die Gene wirklich.

    -         swapRate = random.random() * Evolution.numberOfGenes
    +         swapRate = random.random()
  2. 02Strengere Landung

    Die Fähre muss jetzt deutlich langsamer aufsetzen. Die Evolution braucht dafür viel mehr Generationen – im Schnitt etwa dreimal so viele –, deshalb darf sie hier bis zu 100 Generationen lang suchen. Jetzt siehst du richtig, wie sich die Bahnen Schritt für Schritt der Landefläche nähern.

    -         if yVelocity < 40 and xVelocity < 30:
    +         if yVelocity < 30 and xVelocity < 20:
    -     maxGenerations = 50
    +     maxGenerations = 100
  3. 03Doppelt so viele Individuen

    Eine größere Population probiert in jeder Generation mehr Flugpläne aus. Meist findet sie in weniger Generationen eine Landung – dafür dauert jede Generation doppelt so lange. Mehr Vielfalt gegen mehr Rechenzeit: eine typische Abwägung bei genetischen Algorithmen.

    -     populationSize = 50
    +     populationSize = 100
  4. 04Die Lücke im Simulator

    So stand es im Buch: Die Simulation prüft erst nach dem letzten Befehl, ob die Fähre am Boden ist. Die Evolution nutzt das schamlos aus – Flugpläne dürfen mitten im Flug durch den Mond tauchen und trotzdem als Landung zählen. Die Wiedergabe prüft dagegen in jedem Schritt – und zeigt in etwa jedem vierten Lauf eine Bruchlandung, obwohl die Evolution eine Landung gemeldet hat. Evolution optimiert immer genau das, was du misst.

    -             if commandAvailable == False or yPos < ground[1] + ground[3]:
    +             if commandAvailable == False:
  5. 05Wiedergabe in Echtzeit

    Die beste Landung läuft sonst doppelt so schnell ab wie simuliert. Mit replaySpeed = 1 siehst du sie im Originaltempo des Buchs: 80 Simulationsschritte pro Sekunde.

    - replaySpeed = 2
    + replaySpeed = 1

Vom Buch in den Browser

Im Buch verteilt sich das Programm auf vier Dateien: lander.py (die Fähre), landerSimulation.py (ein Flug ohne Grafik), Evolution.py (Gene, Genome und die Evolution) und AutomatedLunarLander_v1.py (Anzeige mit OpenGL). Im Browser gibt es nur eine Datei im Editor, deshalb stehen alle vier hier untereinander. Die Klassen, Methoden und Namen sind die aus dem Buch – auch die klein geschriebene Klasse lander.

Von Python 2 nach Python 3:

Im Buch (Python 2) Hier (Python 3)
population.sort(lambda x, y: cmp(x.fitness, y.fitness), reverse=True) population.sort(key=lambda x: x.fitness, reverse=True)
for i in xrange(n): for i in range(n):
print generationCounter, " ", print(f"Generation {generationCounter:2d}: …")
import psyco und psyco.full() entfällt

Sortieren mit key. Python 2 sortierte mit einer Vergleichsfunktion, die für zwei Elemente −1, 0 oder 1 liefert. Python 3 kennt nur noch key: eine Funktion, die für jedes Element einmal einen Wert berechnet, nach dem sortiert wird. Das ist kürzer und schneller, weil die Funktion nur 50-mal statt bei jedem Vergleich aufgerufen wird.

psyco war ein Beschleuniger für Python 2, der Programme zur Laufzeit in Maschinencode übersetzte. Für Python 3 gab es ihn nie – und er wird nicht gebraucht: Eine Generation mit 50 Flügen rechnet auch im Browser nur Sekundenbruchteile.

Zuschauen ermöglichen. evolve ist jetzt eine async-Methode und bekommt eine Funktion show, die nach jeder Generation die Bahnen zeichnet. Dafür speichert die Simulation jede vierte Position in self.path. Die Wiedergabe der besten Landung ist aus main() von AutomatedLunarLander_v1.py übernommen, mit einer Anpassung: Sie zeigt nur dann »YOU WON«, wenn die Fähre auch wirklich auf der Landefläche steht. Im Buch genügte die Geschwindigkeit.

Eine Korrektur der Simulation. Die Simulation prüft den Bodenkontakt in jedem Schritt, nicht erst nach dem letzten Befehl – warum, steht oben unter »Die Evolution schummelt«.

Für die Grafik gilt dasselbe wie bei der Mondlandung: OpenGL zählte y von unten nach oben, die Leinwand von oben nach unten. toScreen rechnet um, screen.rect ersetzt die GL_QUADS, und await screen.frame(fps) ersetzt pygame.display.flip() und pygame.time.delay(). Die Ausgabe in der Konsole ist lesbarer formatiert, und die ASCII-Art erscheint mittig.

Original aus dem Buch ansehen Evolution.py · Python 2
#coding: latin1

'''
Created on 03.01.2010

@author: sourcer
'''

import psyco
psyco.full()

import random
from landerSimulation import landerSimulation
import copy

class Gen(object):
    __maxActionDuration     = 80
    __maxMutationDuration   = 20

    leftRocket    = (-1,  0)
    rightRocket   = (+1,  0)
    centralRocket = ( 0, +1)
    doNothing     = ( 0, -1)
    
    actions       = (leftRocket, rightRocket, centralRocket, doNothing)

    def __init__(self):
        self.action     = random.choice(Gen.actions)
        self.duration   = random.randint(1, Gen.__maxActionDuration)
        
    def mutateAction(self):
        self.action = random.choice(Gen.actions)
        
    def mutateDuration(self):
        delta = Gen.__maxMutationDuration - random.randint(1, 2*Gen.__maxMutationDuration) 
        self.duration += delta
        if self.duration > self.__maxActionDuration:
            self.duration = self.__maxActionDuration
        
    def __eq__(self, other):
        return (self.action == other.action) and (self.duration == other.duration)

class Genome(object):
    def __init__(self, genes=None):
        if genes:
            # es ist wichtig hier eine Kopie zu erzeugen!
            # ansonsten werden die gleichen Gene in verschiedenen
            # Individuen referenziert und manipuliert
            self.__genes     = copy.deepcopy(genes)
        else:    
            self.__genes    = []
            for i in xrange(Evolution.numberOfGenes):
                self.__genes.append(Gen())
                
    def getGenes(self):
        return self.__genes
    
    def setGenes(self, genes):
        self.__genes = genes
        
    genes = property(getGenes, setGenes)
    
    def getCommandList(self):
        commandList = []
        for gen in self.__genes:
            for i in xrange(gen.duration):
                commandList.append(gen.action)
                
        return commandList
        
    def updateFitness(self):
        simulation      = landerSimulation()
        commands        = self.getCommandList()
        self.fitness    = simulation.start(commands)
        
        return self.fitness

    def mutate(self):
        for gen in self.genes:
            if random.random() < Evolution.mutationRate:
                gen.mutateAction()
            if random.random() < Evolution.mutationRate/2.0:
                gen.mutateDuration()

class Evolution(object):
    mutationRate    = 0.2
    crossOverRate   = 0.7

    numberOfGenes   = 30
    populationSize  = 50
    saveBestCount   = 4     # has to be even! (dad + mum)
    maxGenerations  = 50
    
    def createNewGeneration(self, populationSize):
        population = []
        for i in xrange(populationSize):
            population.append(Genome())
            
        return population
    
    def multiCrossOver(self, dad, mum):
        if (random.random() > Evolution.crossOverRate):
            # keine Kreuzung
            return dad, mum
        
        swapRate = random.random() * Evolution.numberOfGenes

        baby1, baby2    = [], []
        dadGenes        = dad.genes
        mumGenes        = mum.genes
        
        for x in range(Evolution.numberOfGenes):
            if random.random() < swapRate:
                baby1.append(dadGenes[x])
                baby2.append(mumGenes[x])
            else:
                baby1.append(mumGenes[x])
                baby2.append(dadGenes[x])

        return Genome(baby1), Genome(baby2)
    
    def rouletteWheelSelection(self, population):
        totalFitness = 0
        for x in population:
            totalFitness += x.fitness

        randomSlice     = random.random() * totalFitness;            
        fitnessSum      = 0
        for x in population:
            fitnessSum += x.fitness
            if fitnessSum >= randomSlice:
                return x

        return population[0]
    
    def sortByFitness(self, population):
        for individuum in population:
            individuum.updateFitness()
        population.sort(lambda x, y: cmp(x.fitness, y.fitness), reverse=True)
        currentBest = Genome(population[0].genes)
        currentBest.fitness = population[0].fitness
        
        return currentBest
    
    def evolve(self):            
        population          = self.createNewGeneration(Evolution.populationSize)
        nextGeneration      = []
        generationCounter   = 0
        bestIndividuum      = None
        
        while (generationCounter < Evolution.maxGenerations):
            print generationCounter, " ",
            if generationCounter % 20 == 0:
                print
            currentBest = self.sortByFitness(population)
            if (bestIndividuum == None) or (currentBest.fitness > bestIndividuum.fitness):
                bestIndividuum  = currentBest
                print bestIndividuum.fitness
                if bestIndividuum.fitness > 100000:
                    # wir haben eine Lösung
                    return bestIndividuum
            # erhalte die besten Individuen für die nächste Generation
            # dabei benötigen wir eine gerade Anzahl von Individuen um immer
            # jeweils einen Vater und eine Mutter für die Kreuzung zur Verfügung
            # zu haben, da sonst die Roulette-Wheel-Selection nicht funktioniert
            nextGeneration  = []
            nextGeneration += population[0:Evolution.saveBestCount]
 
            babyCount   = 0
            while babyCount < (Evolution.populationSize):
                dad             = self.rouletteWheelSelection(population)
                mum             = self.rouletteWheelSelection(population)            
                baby1, baby2    = self.multiCrossOver(dad, mum)
                
                baby1.mutate()
                baby2.mutate()
                
                nextGeneration += baby1, baby2
                babyCount      += 2
            
            population           = nextGeneration
            generationCounter   += 1

        return bestIndividuum

if __name__ == '__main__':
    evo = Evolution()
    bestIndividual = evo.evolve()
    
    
Original aus dem Buch ansehen landerSimulation.py · Python 2
'''
Created on 03.01.2010

@author: sourcer
'''

from lander import lander
import math

class landerSimulation(object):
    def __init__(self):
        self.lunarLander = lander(200, 400, 0, 0)
        
    def loadNextCommand(self):
        if self.commandList:
            self.command = self.commandList.pop(0)
            return True
        else:
            self.command = (0, 0)
            return False
            
    def collisionDetection(self, rocket, ground):
        rx1, ry1, width, height = rocket
        rx2, ry2                = rx1 + width, ry1 + height
        gx1, gy1, width, height = ground
        gx2, gy2                = gx1 + width, gy1 + height
        
        if (rx1 >= gx1 and rx1 <= gx2) or (rx2 >= gx1 and rx2 <= gx2):
            # horizontal betrachtet befinden wir uns in Bodennaehe
            if (ry1 >= gy1 and ry1 <= gy2) or (ry2 >= gy1 and ry2 <= gy2):
                # die Koerper ueberdecken sich auch vertikal
                return True
        return False

    def calculateDistance(self, rocket, ground):
        rx1, ry1, rx2, ry2      = rocket
        gx1, gy1, gx2, gy2      = ground
        groundCenterUpside      = (gx1 + gx2/2.0, gy1 + gy2)
        rocketCenterDownside    = (rx1 + rx2/2.0, ry1)
        
        return math.sqrt((rocketCenterDownside[0] - groundCenterUpside[0])**2 +
                    (rocketCenterDownside[1] - groundCenterUpside[1])**2)
    
    def start(self, commandList):
        # commandList wird kopiert um
        # die Ausgangsliste zu erhalten
        self.commandList = commandList[:]
        while True:
            self.yAcceleration   = -16.35
            self.xAcceleration   = +0.0
            
            commandAvailable = self.loadNextCommand()
            if commandAvailable:
                if self.command == ( 0, +1):
                    self.yAcceleration = 20.0
                if self.command == ( 1,  0):
                    self.xAcceleration = -10
                if self.command == (-1,  0):
                    self.xAcceleration = +10
                
            xPos, yPos = self.lunarLander.calculateDelta(80, self.xAcceleration, self.yAcceleration)

            rocket = xPos, yPos, 20, 30
            ground = 350, 50, 100, 30

            self.fitness     = 100000 # to keep fitness scores positive
            if commandAvailable == False:
                # auf Boden warten
                while yPos >= ground[1]+ground[3]:
                    xPos, yPos = self.lunarLander.calculateDelta(80, self.xAcceleration, self.yAcceleration)
                rocket = xPos, yPos, 20, 30
                dist = self.calculateDistance(rocket, ground)
                self.fitness -= dist * 10
                self.fitness -= abs(self.lunarLander.xVelocity) * 4
                self.fitness -= abs(self.lunarLander.yVelocity) * 8
                
                if self.collisionDetection(rocket, ground):
                    if self.lunarLander.checkRules():
                        self.fitness += 100000

                break

        if (self.fitness < 0):
            raise Exception("negative fitness scores are evil")
        return self.fitness
Original aus dem Buch ansehen lander.py · Python 2
'''
Created on 03.01.2010

@author: sourcer
'''

import random

class lander(object):
    def __init__(self, xPos, yPos, xVelocity, yVelocity):
        self.xVelocity      = xVelocity
        self.yVelocity      = yVelocity
        self.xPos           = xPos
        self.yPos           = yPos

    def calculateDelta(self, fps, xAcceleration, yAcceleration):
        dt = 1.0/fps
        self.xVelocity  += xAcceleration * dt
        self.xPos       += self.xVelocity * dt
        self.yVelocity  += yAcceleration * dt
        self.yPos       += self.yVelocity * dt
        
        return self.xPos, self.yPos
    
    def checkRules(self):
        yVelocity = abs(self.yVelocity)
        xVelocity = abs(self.xVelocity)
        if yVelocity < 40 and xVelocity < 30:
            return True
        else:
            return False
Original aus dem Buch ansehen AutomatedLunarLander_v1.py · Python 2
'''
Created on 25.12.2009

@author: Lars Heppert
'''
from Evolution import Evolution
import psyco
psyco.full()

from OpenGL.GL import *
from OpenGL.GLU import *
from random import random, randint
import pygame, math, datetime
from pygame.locals import *

from lander import lander
from landerSimulation import landerSimulation

def resize((width, height)):
    if height == 0:
        height = 1
    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    glOrtho(-10.0, fieldWidth, -10.0, fieldHeight, -6.0, 0.0)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

def init():
    glClearColor(0.0, 0.0, 0.0, 0.0)
                      
def clearScreen():
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
    glLoadIdentity()
    glTranslatef(0.0, 0.0, 3.0)
                
def drawRect(dimension):
    x, y, width, height = dimension
    glColor3f(1.0, 0.0, 0.0)
    glBegin(GL_QUADS)
    glVertex3f(x, y, 0.0)
    glVertex3f(x + width, y, 0.0)
    glVertex3f(x + width, y + height, 0.0)
    glVertex3f(x, y + height, 0.0)
    glEnd()
                        
def collisionDetection(rocket, ground):
    rx1, ry1, width, height = rocket
    rx2, ry2                = rx1 + width, ry1 + height
    gx1, gy1, width, height = ground
    gx2, gy2                = gx1 + width, gy1 + height
    
    if (rx1 >= gx1 and rx1 <= gx2) or (rx2 >= gx1 and rx2 <= gx2):
        # horizontal betrachtet befinden wir uns in Bodennaehe
        if (ry1 >= gy1 and ry1 <= gy2) or (ry2 >= gy1 and ry2 <= gy2):
            # die Koerper ueberdecken sich auch vertikal
            return True
    return False

def calculateDistance(rocket, ground):
    rx1, ry1, rx2, ry2      = rocket
    gx1, gy1, gx2, gy2      = ground
    groundCenterUpside      = (gx1 + gx2/2.0, gy1 + gy2)
    rocketCenterDownside    = (rx1 + rx2/2.0, ry1)
    
    return math.sqrt((rocketCenterDownside[0] - groundCenterUpside[0])**2 +
                (rocketCenterDownside[1] - groundCenterUpside[1])**2)
    
def drawElement(element, color):
    r, g, b = color
    glColor3f(r, g, b)
    glBegin(GL_QUADS)
    for part in element:
        x, y    = part
        x       = x * 10.0
        y       = y * 10.0
        glVertex3f(x, y, 0.0)
        glVertex3f(9.0 + x, y, 0.0)
        glVertex3f(9.0 + x, 9.0 + y, 0.0)
        glVertex3f(x, 9.0 + y, 0.0)
    glEnd()

def showASCIIArt(fileName, delay):
    datei = open(fileName, "r")
    asciiArt = []
    y = 30
    for zeile in datei:
        x = -1
        y -= 1
        for zeichen in zeile:
            if zeichen != '\n':
                x += 1
                if zeichen == 'X':
                    asciiArt.append((x, y))

    clearScreen()
    drawElement(asciiArt, (1.0, 0.0, 0.0))
    pygame.display.flip()
    pygame.time.delay(delay)
        
def showGameOver():
    showASCIIArt("GameOver.txt", 3000)
    restartGame()
        
def showYouWon():
    showASCIIArt("YouWon.txt", 5000)        
    restartGame()

def loadNextCommand():
    global command
    if commandList:
        command = commandList.pop(0)
        return True
    else:
        command = (0, 0)
        return False


def findBestCommands():
    evo = Evolution()
    bestIndividual = evo.evolve()
    cmdList = bestIndividual.getCommandList()
    
    # test sim
    sim = landerSimulation()
    fitness = sim.start(cmdList)
    print "Fitness, Sim1: %f" % bestIndividual.fitness
    print "Fitness, Sim2: %f" % fitness

    return cmdList

def restartGame():
    # reinitialize the game (restart)
    global command, commandList, lunarLander
    command             = (0, 0)
    lunarLander         = lander(200, 400, 0, 0)
    commandList         = findBestCommands()

def handleEvent(event):            
    if event.type == QUIT or (event.type == KEYDOWN and event.key == K_ESCAPE):
        return False
    
    global command
    if event.type == KEYDOWN:
        if event.key == K_RIGHT:
            command = ( +1,  0)
        if event.key == K_LEFT:
            command = ( -1,  0)
        if event.key == K_UP:
            command = (  0, -1)
        if event.key == K_DOWN:
            command = (  0, +1)

    if event.type == KEYUP:
        command = (0, 0)
    
    return True
        
fieldWidth  = 800
fieldHeight = 600
fps         = 80

command             = [0, 0]
commandList         = findBestCommands()
lunarLander         = lander(200, 400, 0, 0)
def main():
    pygame.init()
    video_flags = OPENGL | HWSURFACE | DOUBLEBUF
    
    screenSize = (fieldWidth, fieldHeight)    
    pygame.display.set_mode(screenSize, video_flags)
    resize(screenSize)
    
    init()
    ground = 350, 50, 100, 30

    while True:
        yAcceleration   = -16.35
        xAcceleration   = +0.0
        
        if not handleEvent(pygame.event.poll()):
            break
        
        commandAvailable = loadNextCommand()
        if commandAvailable:
            if command == ( 0, +1):
                yAcceleration = 20.0
            if command == ( 1,  0):
                xAcceleration = -10.0
            if command == (-1,  0):
                xAcceleration = +10.0

        xPos, yPos = lunarLander.calculateDelta(fps, xAcceleration, yAcceleration)
        
        clearScreen()
        rocket = xPos, yPos, 20, 30
        drawRect(rocket)
        drawRect(ground)

        #if collisionDetection(rocket, ground):
        if  yPos < ground[1]+ground[3]:
            print " Distance:", calculateDistance(rocket, ground)
            print "xVelocity:", lunarLander.xVelocity
            print "yVelocity:", lunarLander.yVelocity
            if lunarLander.checkRules():
                showYouWon()
            else:
                showGameOver()
            
        pygame.display.flip()
        
        pygame.time.delay(int(1000.0/fps))

if __name__ == '__main__':
    main()