Kapitel 5 · Statistik: das Ende jeglicher Intuition

Das Ziegenproblem in Python simulieren

Drei Tore, hinter einem ein Auto, hinter den anderen Ziegen. Du wählst, der Moderator öffnet ein Ziegentor – solltest du wechseln? Die Intuition sagt: egal. Die Simulation mit 1000 Runden sagt etwas anderes. Danach kannst du es selbst ausprobieren.

Aus dem Buch · Kapitel 5 Einsteiger SimulationMathe & Fraktale Python 3 · läuft im Browser
Bereit · Python startet beim ersten Klick
# Das Ziegenproblem – Kapitel 5 »Statistik: das Ende jeglicher Intuition«
# aus »Coding for Fun mit Python«, portiert auf Python 3 und c4f
import random
from c4f import Screen, MOUSEDOWN, KEYDOWN


class Player(object):
    def __init__(self):
        self.guess = 0
        self.wins = 0

    def newGuess(self):
        self.guess = random.randint(1, 3)

    def changeMind(self, knownNumber):
        allNumbers = set((1, 2, 3))
        allNumbers.remove(self.guess)
        allNumbers.remove(knownNumber)
        self.guess = allNumbers.pop()

    def saveResult(self, winning):
        if winning == self.guess:
            self.wins += 1


rounds         = 1000  # so viele Runden spielen beide Spieler
roundsPerFrame = 5     # so viele Runden pro Bild der Animation

# --- Farben und Maße für das Diagramm ------------------------------------
W, H       = 640, 400
LEFT, RIGHT, TOP, BOTTOM = 70, 610, 72, 330
BACKGROUND = (16, 18, 24)
GRID       = (48, 53, 66)
TEXT       = (228, 231, 238)
MUTED      = (150, 157, 172)
SWITCH     = (72, 199, 116)   # Spieler 1: wechselt immer
STAY       = (255, 159, 67)   # Spieler 2: bleibt immer
YELLOW     = (245, 200, 75)


def percent(value):
    """0.6667 -> '66,7 %' (mit deutschem Komma)"""
    return f"{value * 100:.1f} %".replace(".", ",")


def toScreen(roundNo, rate):
    x = LEFT + (RIGHT - LEFT) * roundNo / rounds
    y = BOTTOM - (BOTTOM - TOP) * rate
    return (x, y)


def drawChart(switchPoints, stayPoints, roundNo, player1, player2):
    screen.clear()
    screen.text(f"Runde {roundNo} von {rounds}", (LEFT, 18), TEXT, size=22, bold=True)
    # Gitter mit Prozentangaben
    for p in (0, 25, 50, 75, 100):
        y = BOTTOM - (BOTTOM - TOP) * p / 100
        screen.line((LEFT, y), (RIGHT, y), GRID, 1)
        screen.text(f"{p} %", (LEFT - 10, y), MUTED, size=15, align="right", baseline="middle")
    # die theoretischen Werte 2/3 und 1/3 als gestrichelte Linien
    for rate, label in ((2 / 3, "2/3"), (1 / 3, "1/3")):
        y = BOTTOM - (BOTTOM - TOP) * rate
        for x in range(LEFT, RIGHT, 14):
            screen.line((x, y), (min(x + 7, RIGHT), y), MUTED, 1)
        screen.text(label, (RIGHT + 6, y), MUTED, size=14, baseline="middle")
    screen.text("0", (LEFT, BOTTOM + 8), MUTED, size=14, align="center")
    screen.text(str(rounds), (RIGHT, BOTTOM + 8), MUTED, size=14, align="center")
    screen.text("gespielte Runden", ((LEFT + RIGHT) / 2, BOTTOM + 8), MUTED, size=15, align="center")
    # die beiden Kurven
    if len(switchPoints) > 1:
        screen.lines(switchPoints, SWITCH, 3)
        screen.lines(stayPoints, STAY, 3)
    # Legende mit den aktuellen Quoten
    if roundNo:
        screen.circle((LEFT + 8, 378), 6, SWITCH)
        screen.text(f"wechseln: {percent(player1.wins / roundNo)}", (LEFT + 22, 378), TEXT,
                    size=18, baseline="middle")
        screen.circle((LEFT + 280, 378), 6, STAY)
        screen.text(f"bleiben: {percent(player2.wins / roundNo)}", (LEFT + 294, 378), TEXT,
                    size=18, baseline="middle")


async def simulate():
    player1 = Player()  # wechselt nach dem Öffnen immer das Tor
    player2 = Player()  # bleibt immer bei seiner ersten Wahl
    switchPoints, stayPoints = [], []
    pointStep = max(1, rounds // 1000)  # höchstens rund 1000 Punkte pro Kurve

    x = 0
    while x < rounds:
        player1.newGuess()
        player2.newGuess()

        winning = random.randint(1, 3)
        allNumbers = set((1, 2, 3))
        numbersToRemove = set((winning, player1.guess))
        allNumbers -= numbersToRemove
        knownNumber = allNumbers.pop()

        player1.changeMind(knownNumber)

        player1.saveResult(winning)
        player2.saveResult(winning)
        x += 1

        if x % pointStep == 0:
            switchPoints.append(toScreen(x, player1.wins / x))
            stayPoints.append(toScreen(x, player2.wins / x))
        if x % roundsPerFrame == 0 or x == rounds:
            drawChart(switchPoints, stayPoints, x, player1, player2)
            await screen.frame(60)
            for event in screen.events():
                if event.type == KEYDOWN and event.key == "p":
                    return "play"

    print("player 1 won ", player1.wins, " times")
    print("player 2 won ", player2.wins, " times")
    print(f"Wechseln gewinnt in {percent(player1.wins / rounds)} der Runden, "
          f"Bleiben in {percent(player2.wins / rounds)}.")
    screen.text("Tippe ins Bild und spiel selbst!", (RIGHT, 18), YELLOW, size=18,
                align="right", bold=True)
    return "done"


# --- Jetzt du: eine Runde mit drei Toren ---------------------------------
DOOR_W, DOOR_H, DOOR_Y = 150, 200, 86


def doorX(number):
    return 65 + (number - 1) * 180


def doorAt(x, y):
    for number in (1, 2, 3):
        if doorX(number) <= x <= doorX(number) + DOOR_W and DOOR_Y <= y <= DOOR_Y + DOOR_H:
            return number
    return None


def drawGoat(cx, cy):
    fur, horn = (235, 233, 226), (190, 178, 150)
    screen.ellipse(cx - 40, cy - 18, 72, 38, fur)                   # Körper
    for dx in (-30, -16, 10, 22):                                   # Beine
        screen.line((cx + dx, cy + 14), (cx + dx, cy + 42), fur, 5)
    screen.circle((cx + 38, cy - 28), 15, fur)                      # Kopf
    screen.line((cx + 32, cy - 41), (cx + 25, cy - 58), horn, 4)    # Hörner
    screen.line((cx + 43, cy - 41), (cx + 49, cy - 58), horn, 4)
    screen.circle((cx + 43, cy - 31), 2.5, (25, 25, 25))            # Auge
    screen.line((cx + 40, cy - 14), (cx + 38, cy - 2), fur, 4)      # Bart
    screen.text("Ziege", (cx, cy + 50), MUTED, size=18, align="center")


def drawCar(cx, cy):
    red, glass = (231, 76, 60), (170, 210, 240)
    screen.polygon([(cx - 56, cy + 12), (cx - 56, cy - 10), (cx - 30, cy - 14),
                    (cx - 16, cy - 36), (cx + 22, cy - 36), (cx + 38, cy - 14),
                    (cx + 56, cy - 8), (cx + 56, cy + 12)], red)
    screen.polygon([(cx - 12, cy - 31), (cx + 2, cy - 31), (cx + 2, cy - 17),
                    (cx - 22, cy - 17)], glass)
    screen.polygon([(cx + 7, cy - 31), (cx + 19, cy - 31), (cx + 30, cy - 17),
                    (cx + 7, cy - 17)], glass)
    for dx in (-32, 34):
        screen.circle((cx + dx, cy + 12), 12, (30, 30, 36))
        screen.circle((cx + dx, cy + 12), 5, (185, 185, 195))
    screen.text("Auto!", (cx, cy + 46), YELLOW, size=20, align="center", bold=True)


def drawDoor(number, opened, winning, chosen):
    x = doorX(number)
    if opened:
        screen.rect(x, DOOR_Y, DOOR_W, DOOR_H, (36, 40, 52))
        if number == winning:
            drawCar(x + DOOR_W / 2, DOOR_Y + 112)
        else:
            drawGoat(x + DOOR_W / 2, DOOR_Y + 110)
    else:
        screen.rect(x, DOOR_Y, DOOR_W, DOOR_H, (140, 88, 50))
        screen.rect(x + 14, DOOR_Y + 14, DOOR_W - 28, DOOR_H - 28, (164, 106, 64))
        screen.text(str(number), (x + DOOR_W / 2, DOOR_Y + DOOR_H / 2), (255, 238, 214),
                    size=58, align="center", baseline="middle", bold=True)
        screen.circle((x + DOOR_W - 26, DOOR_Y + DOOR_H / 2 + 12), 6, (230, 200, 120))
    if chosen:
        screen.rect(x - 6, DOOR_Y - 6, DOOR_W + 12, DOOR_H + 12, YELLOW, width=4)


def drawDoors(openDoors, winning, guess, message, stats):
    screen.clear()
    screen.text("Jetzt du: Wo steht das Auto?", (W / 2, 20), TEXT, size=22, align="center", bold=True)
    screen.text(message, (W / 2, 52), YELLOW, size=17, align="center")
    for number in (1, 2, 3):
        drawDoor(number, number in openDoors, winning, number == guess)
    parts = []
    for strategy, (wins, played) in stats.items():
        if played:
            parts.append(f"{strategy}: {wins} von {played} gewonnen")
    screen.text(" · ".join(parts) or "Wähle ein Tor mit Klick oder Fingertipp.",
                (W / 2, 322), MUTED, size=16, align="center")
    screen.text("»s« spielt die Simulation erneut", (W / 2, 356), MUTED, size=14, align="center")


async def nextClick():
    """Wartet auf einen Klick (Rückgabe: Tor oder None) oder die Taste s."""
    while True:
        for event in screen.events():
            if event.type == KEYDOWN and event.key == "s":
                return "simulation"
            if event.type == MOUSEDOWN:
                return doorAt(event.x, event.y)
        await screen.frame(30)


async def playYourself():
    stats = {"Gewechselt": [0, 0], "Geblieben": [0, 0]}  # [Siege, Runden]
    while True:
        winning = random.randint(1, 3)
        guess, opened = None, None
        drawDoors([], winning, None, "Wähle ein Tor – hinter einem steht ein Auto.", stats)
        while guess is None:
            door = await nextClick()
            if door == "simulation":
                return
            guess = door
        # Der Moderator öffnet ein Tor mit einer Ziege – nie das Auto, nie deins
        opened = random.choice([n for n in (1, 2, 3) if n != guess and n != winning])
        drawDoors([opened], winning, guess,
                  f"Hinter Tor {opened} steht eine Ziege. Bleiben oder wechseln?", stats)
        final = None
        while final is None or final == opened:
            final = await nextClick()
            if final == "simulation":
                return
        strategy = "Geblieben" if final == guess else "Gewechselt"
        won = final == winning
        stats[strategy][1] += 1
        if won:
            stats[strategy][0] += 1
        message = "Gewonnen: das Auto!" if won else "Leider eine Ziege."
        drawDoors([1, 2, 3], winning, final, message + " Tippe für die nächste Runde.", stats)
        print(f"Deine Runde: {strategy.lower()}, {'gewonnen' if won else 'verloren'}")
        if await nextClick() == "simulation":
            return


screen = Screen(W, H, background=BACKGROUND, title="Ziegenproblem")
screen.controls([("p", "Selbst spielen"), ("s", "Simulation neu")])
mode = "simulation"
while True:
    if mode == "simulation":
        result = await simulate()
        mode = "play" if result == "play" else "wait"
    elif mode == "wait":
        # nach der Simulation: Klick oder »Selbst spielen« startet das Spiel
        for event in screen.events():
            if event.type == MOUSEDOWN or (event.type == KEYDOWN and event.key == "p"):
                mode = "play"
            elif event.type == KEYDOWN and event.key == "s":
                mode = "simulation"
        await screen.frame(30)
    else:
        await playYourself()
        mode = "simulation"
Diagramm: Die grüne Kurve für Wechseln pendelt sich bei zwei Dritteln ein, die orange für Bleiben bei einem Drittel

Nach der Simulation: ins Bild tippen und selbst spielen. »Simulation neu« startet 1000 neue Runden.

Konsole

    

Was du hier lernst

  • Simulation statt Formel
  • Zufallszahlen mit random
  • Mengen (set)
  • Klassen mit Zählern
  • Gesetz der großen Zahlen
  • Diagramm selbst zeichnen

So löst die Simulation das Rätsel

Das Ziegenproblem stammt aus der amerikanischen Spielshow »Let's Make a Deal«. Drei Tore, hinter einem steht ein Auto, hinter den beiden anderen je eine Ziege. Du wählst ein Tor. Der Moderator, der weiß, wo das Auto steht, öffnet eines der beiden anderen – immer eines mit Ziege. Dann darfst du wechseln. Solltest du?

Fast jeder denkt: Zwei Tore sind noch zu, also steht es 50 zu 50, Wechseln bringt nichts. Als die Kolumnistin Marilyn vos Savant 1990 das Gegenteil schrieb, bekam sie Tausende empörte Briefe, darunter von Mathematikern. Statt zu streiten, lässt das Programm zwei Spieler je 1000 Runden spielen und zählt einfach mit.

1. Ein Spieler als Klasse

Jeder Spieler merkt sich zwei Dinge: das Tor, auf das er gerade tippt, und wie oft er schon gewonnen hat. Dazu kommen drei Methoden:

def newGuess(self):
    self.guess = random.randint(1, 3)

def saveResult(self, winning):
    if winning == self.guess:
        self.wins += 1

newGuess wählt zu Beginn jeder Runde zufällig ein Tor, saveResult zählt einen Sieg, wenn das Auto hinter dem gewählten Tor steht. player1 wechselt nach dem Öffnen immer, player2 bleibt immer bei seiner ersten Wahl.

2. Der Moderator ist die Hauptschleife

Für den Moderator gibt es keine eigene Klasse, seine Arbeit steht direkt in der Schleife. Erst wird das Auto versteckt, dann sucht er ein Tor zum Öffnen:

winning = random.randint(1, 3)
allNumbers = set((1, 2, 3))
numbersToRemove = set((winning, player1.guess))
allNumbers -= numbersToRemove
knownNumber = allNumbers.pop()

Das ist Mengenlehre in Python. allNumbers enthält alle Tore, davon werden das Tor mit dem Auto und das Tor des Spielers abgezogen. Was übrig bleibt, darf der Moderator öffnen: ein Tor, wenn der Spieler danebenlag, zwei, wenn er richtig lag. pop() nimmt eines davon heraus.

3. Wechseln mit Mengen

changeMind nutzt denselben Trick. Von den drei Toren fallen das eigene und das geöffnete weg – genau ein Tor bleibt übrig, und das ist die neue Wahl:

def changeMind(self, knownNumber):
    allNumbers = set((1, 2, 3))
    allNumbers.remove(self.guess)
    allNumbers.remove(knownNumber)
    self.guess = allNumbers.pop()

Der Moderator richtet sich nur nach player1. Das ist in Ordnung, denn player2 wechselt ohnehin nie: Für ihn spielt es keine Rolle, welches Tor geöffnet wird.

4. Ein Diagramm, das mitwächst

Nach jeder Runde berechnet das Programm die Gewinnquote beider Spieler, also Siege geteilt durch gespielte Runden. toScreen macht daraus einen Punkt: Die Rundenzahl bestimmt, wie weit rechts er liegt, die Quote, wie hoch. Alle fünf Runden wird das ganze Diagramm neu gezeichnet, mit Gitter, gestrichelten Linien bei zwei Dritteln und einem Drittel und den beiden Kurven:

screen.lines(switchPoints, SWITCH, 3)
screen.lines(stayPoints, STAY, 3)

Am Anfang springen die Kurven wild hin und her – nach wenigen Runden kann alles passieren. Mit jeder weiteren Runde werden die Ausschläge kleiner, und die Kurven nähern sich den gestrichelten Linien. Das ist das Gesetz der großen Zahlen bei der Arbeit.

5. Warum zwei Drittel?

Die Simulation beweist nichts, aber sie zeigt, wohin die Reise geht. Die Erklärung ist einfacher als gedacht. Mit einem Drittel Wahrscheinlichkeit liegst du mit deiner ersten Wahl richtig – dann verlierst du durch das Wechseln. In zwei Dritteln der Fälle liegst du falsch. Dann stehen hinter den beiden anderen Toren das Auto und eine Ziege, der Moderator muss die Ziege zeigen, und das verbleibende Tor ist garantiert das Auto. Wechseln gewinnt also genau dann, wenn die erste Wahl falsch war: in zwei von drei Fällen.

Das Wissen des Moderators

Der Knackpunkt ist, dass der Moderator das Auto kennt und es nie zeigt. Sein Öffnen ist keine Zufallsinformation, sondern ein Hinweis. Würde er ahnungslos irgendein Tor öffnen, wäre es tatsächlich 50 zu 50 – das kannst du unter »Probier mal« ausprobieren.

6. Jetzt du

Nach der Simulation kannst du selbst spielen: Tippe ein Tor an, der Moderator öffnet eine Ziege, dann entscheidest du dich durch einen zweiten Tipp. Unten zählt das Programm getrennt, wie oft du mit Wechseln und wie oft mit Bleiben gewonnen hast.

Ehrlich zählen

Spiel zehn Runden, in denen du immer wechselst, und zehn, in denen du immer bleibst. Bei so wenigen Runden kann der Zufall dich noch täuschen – aber auf Dauer gewinnt Wechseln etwa doppelt so oft.

Weiterlesen: der Satz von Bayes

Dass sich die Chancen ändern, wenn der Moderator eine Tür öffnet, lässt sich auch ohne Simulation ausrechnen – mit dem Satz von Bayes: Neue Information verschiebt die Wahrscheinlichkeiten. Eine Einführung in Prior, Likelihood und Posterior findest du bei smartlytics.de.

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. 01Zehnmal so viele Runden

    Mit 10 000 Runden liegen die Kurven am Ende fast genau auf den gestrichelten Linien. Je mehr Versuche, desto näher kommt die gemessene Quote der wahren Wahrscheinlichkeit – das Gesetz der großen Zahlen.

    - rounds         = 1000  #
    + rounds         = 10000  #
    - roundsPerFrame = 5     #
    + roundsPerFrame = 50    #
  2. 02Nur 30 Runden – in Zeitlupe

    Mit wenigen Runden ist der Zufall stärker als die Wahrscheinlichkeit. Starte die Variante mehrmals: Manchmal liegt sogar »bleiben« vorn. Genau deshalb braucht eine Simulation viele Durchläufe, bevor man ihr trauen kann.

    - rounds         = 1000  #
    + rounds         = 30  #
    - roundsPerFrame = 5     #
    + roundsPerFrame = 1     #
    -             await screen.frame(60)
    +             await screen.frame(6)
  3. 03Der ahnungslose Moderator

    Was, wenn der Moderator nicht weiß, wo das Auto steht, und einfach irgendein anderes Tor öffnet? Deckt er dabei das Auto auf, zählt die Runde nicht. Beide Spieler starten jetzt mit demselben Tor – und plötzlich ist es wirklich egal: Beide Kurven laufen auf 50 Prozent zu. Das Wissen des Moderators macht den Unterschied.

    -         allNumbers = set((1, 2, 3))
    -         numbersToRemove = set((winning, player1.guess))
    -         allNumbers -= numbersToRemove
    -         knownNumber = allNumbers.pop()
    +         player2.guess = player1.guess  # beide starten mit demselben Tor
    +         # Der Moderator weiß nicht, wo das Auto steht:
    +         knownNumber = random.choice([n for n in (1, 2, 3) if n != player1.guess])
    +         if knownNumber == winning:
    +             continue  # Auto aufgedeckt: Diese Runde zählt nicht
  4. 04Ein Moderator, der würfelt

    Liegt der Kandidat richtig, stehen hinter beiden anderen Toren Ziegen. allNumbers.pop() nimmt dann irgendein Element der Menge – welches, legt Python nicht fest, bei kleinen Zahlen ist es in der Praxis immer das kleinste. Mit random.choice entscheidet der Moderator wirklich zufällig. Am Ergebnis ändert das nichts: Für die Gewinnchance ist egal, welche Ziege er zeigt.

    -         knownNumber = allNumbers.pop()
    +         knownNumber = random.choice(sorted(allNumbers))

Vom Buch in den Browser

Das Original aus dem Buch ist ein reines Konsolenprogramm: Es spielt 1000 Runden und gibt am Ende zwei Zeilen aus. Die Klasse Player und die Schleife mit dem Moderator sind hier unverändert übernommen, die beiden Ausgaben am Ende ebenfalls. Neu sind das Diagramm, das während der Simulation mitwächst, und das Spiel zum Selbstausprobieren.

Im Buch Hier
if __name__=='__main__': mit der Schleife Funktion simulate(), die zwischendurch zeichnet
print "player 1 won ", player1.wins, " times" print("player 1 won ", player1.wins, " times")
Ergebnis nur als Zahl zusätzlich als Diagramm und in Prozent
– selbst spielen mit Klick oder Fingertipp

Damit die Kurven entstehen, während das Programm rechnet, steckt die Schleife in einer async-Funktion. Alle fünf Runden zeichnet sie das Diagramm und gibt dem Browser mit await screen.frame(60) Gelegenheit, es anzuzeigen. Ohne diese Pause wären die 1000 Runden in einem Wimpernschlag vorbei, und du sähest nur das Endergebnis.

Python 3 ändert am Kern des Programms nichts, außer dass print jetzt eine Funktion ist. Die Mengenoperationen mit set, remove, -= und pop funktionierten schon in Python 2.6 genauso.

Beim Diagramm hilft ein kleiner Trick für große Rundenzahlen: pointStep sorgt dafür, dass jede Kurve höchstens etwa 1000 Punkte bekommt. Bei 10 000 Runden wird deshalb nur jede zehnte Runde eingezeichnet – mehr Punkte, als die Leinwand Pixel breit ist, würde man ohnehin nicht sehen.

Original aus dem Buch ansehen ziegenproblem.py · Python 2
import random


class Player(object):
    def __init__(self):
        self.guess = 0
        self.wins = 0

    def newGuess(self):
        self.guess = random.randint(1, 3)        
        
    def changeMind(self, knownNumber):
        allNumbers = set((1, 2, 3))
        allNumbers.remove(self.guess)
        allNumbers.remove(knownNumber)
        self.guess = allNumbers.pop()
    
    def saveResult(self, winning):
        if winning == self.guess:
            self.wins += 1

if __name__=='__main__':
    player1 = Player()
    player2 = Player()

    x = 0
    while x < 1000:          
        player1.newGuess()
        player2.newGuess()
          
        winning = random.randint(1, 3)
        allNumbers = set((1, 2, 3))
        numbersToRemove = set((winning, player1.guess))
        allNumbers -= numbersToRemove
        knownNumber = allNumbers.pop()
    
        player1.changeMind(knownNumber)
        
        player1.saveResult(winning)
        player2.saveResult(winning)
        x += 1
    
    print "player 1 won ", player1.wins, " times"
    print "player 2 won ", player2.wins, " times"