Kapitel 1 - Programmiergrundlagen

Was ist Programmieren?

Unter Programmieren versteht man ganz allgemein die Tätigkeit, einem Computer Anweisungen zu geben. Das Eintippen einer Rechenanweisung in einen Taschenrechner lässt sich also schon als Programmieren betrachten. Man erklärt dem Computer die Berechnung, die man gerne durchführen möchte, und der Computer erledigt die Rechenarbeit. Einfache Taschenrechner kennen jedoch nur wenige Befehle, meist nur Grundrechenarten. Aufwendigere kennen zum Beispiel Winkelfunktionen oder die Möglichkeit, Zahlen zu speichern und wieder abzurufen. Will man noch komplexere Berechnungen durchführen, ist man auf „echte“ Computer angewiesen.

Im Unterschied zum Taschenrechner mit seinen Tasten für jede Grundrechenart, muss dem Computer nun allerdings der Umstand, dass zum Beispiel die Wurzel einer Zahl berechnet werden soll, mithilfe einer Programmiersprache mitgeteilt werden. Dies könnte zum Beispiel der Befehl sqrt(x) sein, den es wohl – abhängig von der so genannten Syntax einer Sprache – in der einen oder anderen Form in jeder Programmiersprache gibt. Das hat den Vorteil, dass die Kenntnis einer Programmiersprache in der Regel dafür sorgt, dass auch andere Programmiersprachen leicht erlernt werden. Darüber hinaus steigern Programmierkenntnisse die allgemeine Problemlösungskompetenz, die Abstraktionsfähigkeit und die präzise Ausdrucksweise im wissenschaftlichen Kontext.

Die Programmiersprache Python

Die Vorlesung – und damit dieses Skriptum – beziehen sich auf die Programmiersprache Python. Dies hat mehrere Gründe: Python ( https://www.python.org ) ist eine universelle, vielseitig einsetzbare Programmiersprache, die

  1. in ihrer klaren und übersichtlichen Syntax als leicht erlernbar ist,
  2. damit in vielen Disziplinen mittlerweile zu einem wissenschaftlichen Standard geworden ist
  3. in all ihren Grundlagen offen und damit kostenlos zu verwenden ist
  4. eine umfangreiche Standardbibliothek und zahlreiche frei zugängliche Spezialmodule umfasst
  5. eine leicht installierbare und komfortabel im Browser laufende Programmierumgebung zur Verfügung stellt
  6. und von einer sehr großen Community beständig weiterentwickelt wird.

Anaconda

Anaconda (https://www.anaconda.com/) ist eine sogenannte Python-Distribution, die eine Vielzahl von Werkzeugen vereint, die für das Programmieren mit Python notwendig sind. Anaconda ist frei (d.h. kostenlos), sehr leicht zu installieren, und verfügbar für Windows, Mac-OS und Linux. Anaconda enthält mit über 150 mitgelieferten Modulen (packages) im Prinzip alles (und viel mehr), was zum Programmieren im Bereich der USW-Systemwissenschaften nötig ist.

Vor allem enthält Anaconda bereits auch eine sehr komfortable Ein- und Ausgabe-Umgebung (d.h. eine Entwicklungsumgebung) namens Jupyter Notebook , die einfach im gewohnten Browser läuft, wie er für das tägliche Surfen im Internet verwendet wird (Google-Chrome, Firefox, Internetexplorer ...). Das heißt, nach Installation von Anaconda sind im Prinzip keine zusätzlichen oder ungewohnten Programme mehr notwendig, um sofort mit Python zu arbeiten zu beginnen. Im Folgenden wird nun der Installationsvorgang für Anaconda mit Python 3.7. auf einem Windows-Computer beschrieben.

Eine weitere Windows-Installationsbeschreibung findet sich hier: https://docs.continuum.io/anaconda/install/windows

Für die Mac-OS-Installation siehe hier: https://docs.continuum.io/anaconda/install/mac-os

Für Linux siehe hier: https://docs.continuum.io/anaconda/install/linux

Anaconda Installation für Python 3.8

screenshot

  • Klicken Sie, wenn Sie einen entsprechenden Computer haben, auf den Button „64-bit graphical installer“ um Python 3.8 Version zu erhalten. Ansonsten wählen Sie die 32-bit-Version. Daraufhin startet der Download der Installationsdatei. Dies kann einige Minuten in Anspruch nehmen. Achten Sie darauf, dass der Pfad zu dem Ordner, in dem Sie Ihre Downloads speichern, und auch der Ordnername selbst keine Sonderzeichen (wie Umlaute etc.) oder Leerzeichen enthält. Sollte dies der Fall sein, so kopieren Sie die heruntergeladene Datei bevor Sie sie ausführen in einen entsprechenden Ordner.
  • Doppelklicken Sie auf die .exe Datei, die Sie heruntergeladen haben.
  • Klicken Sie im automatisch neu geöffneten Fenster auf den Button „Ausführen“.
  • Folgen Sie den weiteren Dialog-Fenstern.
  • Im Licence Agreement-Fenster klicken Sie auf den Button „I Agree“, wenn Sie den Lizenzbedingungen zustimmen.
  • Folgen Sie erneut den Dialog-Fenstern. Wir empfehlen die jeweils vorgeschlagenen Angaben zu übernehmen. Im Fenster Choose Install Location sollten sie erneut darauf achten, dass der Pfad zu dem Ordner, in den sie Anaconda installieren wollen, und auch der Ordnername selbst keine Sonderzeichen (Umlaute etc.) oder Leerzeichen enthalten. Mit einem Klick auf den Button „Browse...“ können Sie den automatisch vorgeschlagenen Ordner ändern und Ihren gewünschten Speicherort auswählen. Klicken sie erneut „Next >“ um mit der Installation fortzufahren.
  • Klicken Sie auf den Button „Install“ und schließlich „Finish“ um die Installation fertigzustellen.

Nach erfolgreicher Installation sollten sie in Ihrem Windows Startmenü einen Menüpunkt Anaconda sehen, der seinerseits einen Menü - Unterpunkt Jupyter Notebook enthält.

Jupyter Notebook

Das Jupyter Notebook ist eine sehr komfortable Ein-und Ausgabe-Umgebung für die Programmiersprache Python , d.h. eine Entwicklungsumgebung, die im bereits auf ihrem Computer installierten Browser läuft. Alle in diesem Skriptum vorkommenden Beispiele wurden mit dem Jupyter Notebook geschrieben und werden auch in diesem angezeigt.

Klicken Sie , um das Jupyter Notebook zu starten, im Windows-Startmenü unter Anaconda auf den Menüpunkt Jupyter Notebook.

Es öffnet sich ein Fenster, das zuerst schwarz ist, und sich dann mit weißem Text füllt. Dieses Fenster darf nicht geschlossen werden, solange Sie mit dem Jupyter Notebook arbeiten möchten. Es handelt sich hierbei um eine Anzeige für den so genannten Kernel , also den Prozess, in dem die eigentlichen Berechnungen durchgeführt werden. Zusätzlich öffnet sich Ihr Browser, bzw. ein neuer Tab in Ihrem Browser.

Um ein neues Jupyter Notbook zu öffnen, klicken Sie im rechten Bereich dieses Browser-Tabs auf das Dropdown-Menü „New“ und wählen Sie „Python 3“ aus.

Kapitel 2 – Einführung in Python – Der Froschteich

Verwenden von Jupyter-Notebooks

Jupyter-Notebooks bestehen aus Zellen. Es gibt drei Typen von Zellen:

  • Textzellen, so wie diese hier, in welche man Texte schreiben und formatieren kann
  • In-Zellen, in die man Python-Code schreiben kann
  • Out-Zellen, die das Ergebnis der darüberstehenden In-Zelle ausgeben

Um eine In-Zelle auszuführen klickt man einfach in die gewünschte Zelle und drückt die Shift- und die Enter-Taste gleichzeitig. Es wird automatisch eine Out-Zelle produziert, in der das Ergebnis der In-Zelle steht.

Auch ohne jegliche Programmierkenntnisse kann man so schon Python benutzen, wenn auch nur als Taschenrechner:

In [1]:
7 * 7 - 7
Out[1]:
42
In [2]:
((12 + 144 + 20 + 3 * 4**0.5) / 7) + 5 * 11
# Exponenten werden in Python als x**y angegeben. 4**0.5 bedeutet also 4 hoch 0.5. 
# Der Text hinter einer Raute (#) wird vom Computer ignoriert und ist als Information für den Menschen gedacht.
Out[2]:
81.0

Python kann aber natürlich viel mehr als ein Taschenrechner. In eine Zelle kann man nicht nur einzelne Rechnungen schreiben, sondern ganze Programme oder vollständige Simulationen. Versuchen wir eine ganz simple Simulation zu erstellen, um die Grundbefehle von Python zu lernen. Nehmen wir an, wir wollen die Populationsentwicklung in einem Froschteich simulieren. In allererster Näherung gehen wir davon aus, das sich jedes Jahr 3 neue Frösche im Teich ansiedeln.

Variablen definieren

Um Zahlen (wie z.B. die Anzahl der Frösche in einem Teich) zu speichern muss eine so genannte Variable angelegt werden. Das ist sozusagen der Name unter dem die Zahl gespeichert ist. Die Zuweisung von Variablenname zur Zahl passiert in Python mit dem = Zeichen. Wichtig ist, dass der Name links vom = Zeichen steht, die Zahl die dort gespeichert werden soll, rechts. Wir starten unsere Teichsimulation also indem wir die Anzahl der Frösche auf 0 setzen.

In [3]:
froschanzahl = 0

Variablen verändern

Um eine Variable zu ändern kann man einfach den aktuellen Wert mit dem neuen Wert überschreiben. Auch das geschieht mit dem = Zeichen.

In [4]:
# Jahr 1:
froschanzahl = 3

Variablen abrufen

Um Variablen wieder anzuzeigen benutzt man den Befehl print. Dieser Befehl schreibt den aktuellen Wert der abgefragten Variable in die Ausgabezeile. Schreiben wir also print(froschanzahl), sollte das das Ergebnis 3 bringen.

In [5]:
print(froschanzahl)
3

Das ist sehr hilfreich für unsere weiter Simulation, denn so können wir die aktuelle Zahl der Frösche benutzen, um die neue zu berechnen. Laut unserer Annahme ist die Zahl der Frösche in jedem Jahr um 3 höher als zuvor, mathematisch ausgedrückt also froschanzahl + 3. Somit sieht unsere Froschsimulation so aus:

In [6]:
froschanzahl = 0
#Jahr 1
froschanzahl = froschanzahl + 3
#Jahr 2
froschanzahl = froschanzahl + 3
#Jahr 3
froschanzahl = froschanzahl + 3
#Jahr 4
froschanzahl = froschanzahl + 3
#Jahr 5
froschanzahl = froschanzahl + 3
#Um ein Ergebnis auszugeben benutzen wir den Befehl "print"
print(froschanzahl)
15

In dieser Version ist unsere Froschsimulation nicht nur sehr unspektakulär, sondern auch recht umständlich. Sie ist jedoch ein guter Ausgangpunkt für eine besser Simulation. Als ersten Schritt wäre es schön, wenn wir die Anzahl der Jahre, die simuliert werden sollen, einstellen könnten und nicht immer exakt 5 Jahre simulieren müssen. Dazu benötigen wir ein wichtiges Konzept: die For-Schleife.

For-Schleifen

For-Schleifen werden benutzt um einen Befehl mehrmals hintereinander ausführen zu lassen, in unserem Fall das Hinzuzählen von Fröschen. So würde unser Programm mit einer For-Schleife aussehen:

In [7]:
froschanzahl = 0
for it in range(5):
    froschanzahl = froschanzahl + 3
print(froschanzahl)
15

Das ist deutlich kompakter, dadurch aber auch ein wenig komplizierter. Die erste Zeile ist exakt gleich, wir setzen froschanzahl auf 0. In der nächsten Zeile leitet der Befehl for die For-Schleife ein. Lesen könnte man diese Zeile als: Mache folgenden Befehl für jede ganze Zahl it, die kleiner ist als 5, also ingesamt 5 mal (für 0,1,2,3,4). Nach dem Doppelpunkt kommt in der nächsten Zeile der Befehl, der ausgeführt werden soll. Zum Schluss lassen wir uns wieder das Ergebnis ausgeben.

Einrücken in Python

Woher weiß Python jetzt aber, das wir den letzten Befehl (das print) nicht auch 5 mal ausgeführt haben wollen? Das passiert mittels Einrückungen (tab-Taste). Befehle, die direkt untereinander stehen gehören für Python zusammen. Das macht Pythoncode automatisch gut lesbar und man benötigt keine Klammern. Würden wir die Ausgabe wirklich gerne nach jedem Jahr, und nicht erst am Schluss haben, können wir den print-Befehl mit der Tabulatortaste einrücken. So gehört er zur For-Schleife:

In [8]:
froschanzahl = 0
for it in range(5):
    froschanzahl = froschanzahl + 3
    print(froschanzahl)
3
6
9
12
15

Ob Befehle innerhalb oder außerhalb einer For-Schleife stehen macht einen großen Unterschied und ist eine häufige Fehlerquelle. Würde man beispielsweise auch die Zeile froschanzahl = 0 in die For-Schleife einbauen, würde die Zahl der Frösche bei jedem Durchlauf der Schleife auf 0 gesetzt werden:

In [9]:
for it in range(5):
    froschanzahl = 0
    froschanzahl = froschanzahl + 3
    print(froschanzahl)
3
3
3
3
3

Dennoch bietet das Verwenden einer For-Schleife einen riesigen Vorteil: Wir müssen nur mehr eine einzige Zahl ändern um die Zahl der simulierten Jahre zu verändern. Wollen wir beispielweise 10 Jahre simulieren:

In [10]:
froschanzahl = 0
for it in range(10):
    froschanzahl = froschanzahl + 3
print(froschanzahl)
30

Zu einem schönen Stil beim Programmieren gehört es, solche Zahlen, die man öfter ändern möchte an den Anfang des Programms zu stellen. Wir definieren also eine neue Variable, damit wir die Zahl nicht mitten im Code ändern müssen, sondern schön übersichtlich am Anfang. Mit einem Kommentar wissen auch zukünftige Benutzer, was diese Variable genau macht.

In [11]:
simulationszeit = 10
#simulationszeit: Die Zeit in Jahren, die der Froschteich simuliert wird

froschanzahl = 0
for it in range(simulationszeit):
    froschanzahl = froschanzahl + 3
print(froschanzahl)
30

Mit diesem Programm können wir nun auch sehr lange Zeitbereiche simulieren:

In [12]:
simulationszeit = 1000
#simulationszeit: Die Zeit in Jahren, die der Froschteich simuliert wird

froschanzahl = 0
for it in range(simulationszeit):
    froschanzahl = froschanzahl + 3
print(froschanzahl)
3000

In dieser Simulation ist das Endergebnis weniger spannend als die zeitliche Entwicklung. Es wäre also interessant die Froschanzahl zu jedem Zeitpunkt auszugeben. Am einfachsten verschieben wir den print Befehl in die For-Schleife:

In [13]:
simulationszeit = 30
#simulationszeit: Die Zeit in Jahren, die der Froschteich simuliert wird

froschanzahl = 0
for it in range(simulationszeit):
    froschanzahl = froschanzahl + 3
    print(froschanzahl), #ein Beistrich hinter einem Print-Befehl schreibt die Ergebnisse, statt untereinander, in eine Zeile 
3 6 9 12 15 18 21 24 27 30 33 36 39 42 45 48 51 54 57 60 63 66 69 72 75 78 81 84 87 90

Auf diese Weise sieht man wie sich die Froschpopulation entwickelt, da wir jeden Wert einzeln ausgegeben haben. Der Nachteil an dieser Variante: Wirklich gespeichert haben wir immer nur ein Zahl. Am Ende der Simulation sehen wir zwar viele Zahlen, gespeichert (und somit zum Weiterarbeiten verfügbar) ist aber nur die letzte. Eine schönere Lösung ist das Verwenden von einer Liste.

Listen in Python

Listen bestehen aus mehreren aufeinanderfolgenden Werten. Um eine Liste zu erstellen, fängt man am besten mit einer leeren Liste an und hängt dann immer wieder neue Elemente an. Auch Listen bekommen, so wie Variablen, einen eindeutigen Namen. Leere Listen erstellt man mit name=[] und neu Einträge fügt man mit dem append-Befehl hinzu. Für unser Beispiel:

In [14]:
simulationszeit = 30
#simulationszeit: Die Zeit in Jahren, die der Froschteich simuliert wird

froschanzahl = 0
froschanzahl_liste = [] #leere liste wird erstellt
froschanzahl_liste.append(froschanzahl) #erster Eintrag (0 Frösche) wird angehängt

for it in range(simulationszeit):    
    froschanzahl = froschanzahl + 3
    #am Ende jedes Schleifendurchgangs wird die aktuelle Zahl an die liste angehängt
    froschanzahl_liste.append(froschanzahl) 
print(froschanzahl_liste)
[0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48, 51, 54, 57, 60, 63, 66, 69, 72, 75, 78, 81, 84, 87, 90]

Nun kann man schon grob erkennen was passiert, viel vorstellen kann man sich aber noch nicht. Schön wäre eine grafische Darstellung. Und das ist in Python zum Glück sehr einfach.

Grafiken in Python

Der Befehl zum Erstellen von Plots lautet "plot". Dieser Befehl ist jedoch nicht standardmäßg in jedem Pythonprogramm vorhanden, sondern muss in der Regel erst aus einem sogenannten Paket importiert werden. Pakete sind sozusagen Sammlungen von Befehlen.

Der plot Befehl selbst funktioniert dann ganz einfach: In Klammer schreibt man einfach die Liste von Zahlen, die man darstellen möchte.

In [2]:
import matplotlib.pyplot as plt #wir importieren das Modul matplotlib.pyplot und geben ihm die Abkürzung plt
# die nächste Zeile bewirkt, dass die Grafiken direkt in der Zelle angezeigt werden
%matplotlib inline 

simulationszeit = 30
#simulationszeit: Die Zeit in Jahren, die der Froschteich simuliert wird

froschanzahl = 0
froschanzahl_liste = [] #leere Liste wird erstellt
froschanzahl_liste.append(froschanzahl) #erster Eintrag (0 Frösche) wird angehängt

for it in range(simulationszeit):    
    froschanzahl = froschanzahl + 3
    #am Ende jedes Schleifendurchgangs wird die aktuelle Zahl an die Liste angehängt
    froschanzahl_liste.append(froschanzahl) 

plt.plot(froschanzahl_liste)
Out[2]:
[<matplotlib.lines.Line2D at 0x1ef1f2a71d0>]

Eine Grafik ist schon viel anschaulicher als eine Liste von Zahlen. Natürlich kann man diese Grafik noch deutlich verbessern. Bei Fröschen würde es sich beispielsweise anbieten, die Linie grün darzustellen. Solche zusätzlichen Optionen kann man im Plot-Befehl einfach nach einem Beistrich anfügen:

In [16]:
plt.plot(froschanzahl_liste, color = 'green')
Out[16]:
[<matplotlib.lines.Line2D at 0xb0f9828>]

Man beachte, dass man die Farbe selbst unter Anführungsstrichen schreiben muss, da das Programm sonst nach einer Variable suchen würde, die green heißt.

Eine weitere mögliche Verbesserung wäre eine Beschriftung der Achsen:

In [3]:
plt.plot(froschanzahl_liste, color = 'green')
plt.xlabel('Zeit (Jahre)')
plt.ylabel('Frösche')
Out[3]:
<matplotlib.text.Text at 0x1ef1f2d64e0>

Zusammenfassung

Variablen

Durch Variablen können wir Werte einem Namen zuordnen. Mit

varname = 42

weisen wir der Variable varname den Wert 42 zu und überschreiben den aktuell dort gespeichert Wert, wenn dort schon etwas gespeichert ist.

Mit

varname = varname + 1

erhöhen wir den Wert, der unter varname gespeichert ist um 1.

Den aktuellen Wert der Variable varname kann mit

print(varname)

angezeigt werden.

For-Schleifen

Mit For-Schleifen ist es möglich gleiche oder ähnliche Befehl oftmals hintereinander auszuführen. Um einen Befehl also beispielsweise 100 mal abarbeiten zu lassen, verwenden wir:

for it in range(100):

befehl

Man beachte die Einrückung des Befehls, die zeigt, das sich der Befehl innerhalb der Schleife befindet.

Listen

In Listen können mehrere Werte unter nur einem Namen abgespeichert werden. Leere Listen erstellt man mit

listenname = []

Einen neuen Eintrag (z.B. neueselement) zur Liste hinzufügen kann man dann mit

listenname.append(neueselement)

Grafiken

Um innerhalb eines Jupyter-Notebooks den Plotbefehl benutzen zu können, verwenden wir die Zeilen

import matplotlib.pyplot as plt

%matplotlib inline

am Anfang des Programms. Danach können wir mit dem Befehl

plt.plot(listenname)

die Liste mit dem Namen listenname grafisch darstellen. Zusatzoptionen wie Farbe können nach einem Beistrich übergeben werden:

plt.plot(listenname, color='green')

Kapitel 3 - Populationsentwicklungen

In dieser Einheit möchten wir uns genauer mit verschiedenen Möglichkeiten der Populationsentwicklung auseinandersetzen. Mit dem Beispiel des Froschteiches haben wir bereits eine sehr einfache Form der Populationsentwicklung kennengelernt, so genanntes:

Lineares Wachstum

Wir ziehen nun als weiteres Beispiel die Vermehrung von Kaninchen heran.

In [2]:
import matplotlib.pyplot as plt #wir importieren das paket matplotlib.pyplot und geben ihm die abkürzung plt
#die nächste Zeile bewirkt, dass die Grafiken direkt in der Zelle angezeigt werden
%matplotlib inline 

simulationszeit = 30
#simulationszeit: Die Zeit in Jahren, die die Kaninchen simuliert werden

kaninchenanzahl = 0
kaninchenanzahl_liste = [] #leere liste wird erstellt
kaninchenanzahl_liste.append(kaninchenanzahl) #erster Eintrag (0 Kaninchen) wird angehängt

for it in range(simulationszeit):    
    kaninchenanzahl = kaninchenanzahl + 1
    #am ende jedes schleifendurchgangs wird die aktuelle zahl an die liste angehängt
    kaninchenanzahl_liste.append(kaninchenanzahl) 

plt.plot(kaninchenanzahl_liste)
Out[2]:
[<matplotlib.lines.Line2D at 0x246c4dacba8>]

Diese Form von Wachstum war für einen Froschteich zwar eine gute Näherung, wenn wir annehmen, dass die Frösche dort einfach zuwandern. Unsere Kaninchenpopulation vermehrt sich aber durch Fortpflanzung. Das heißt, je mehr Kaninchen es gibt, um so schneller kommen neue hinzu.

Dies führt zu sogenanntem exponentiellen Wachstum.

Exponentielles Wachstum

Von exponentellem Wachstum spricht man, wenn das Wachstum einer Größe proportional zu der Größe selbst ist. In Python ausgedrückt also:

kaninchen = kaninchen + x * kaninchen

wobei x ein Wachstumsparameter ist, den wir beliebig wählen können. Möchten wir zum Beispiel, dass sich die Anzahl der Kanichen in jedem Zeitschritt verdoppelt, können wir als Wachstumsparameter 1 wählen:

kaninchen = kaninchen + 1 * kaninchen

was man auch als kaninchen = 2 * kaninchen schreiben könnte. Möchten wir jeden Zeitschritt ein Wachstum von 10% wählen wir x als 0.1:

kaninchen = kaninchen + 0.1 * kaninchen

Machen wir dazu eine Simulation:

In [1]:
import matplotlib.pyplot as plt 
%matplotlib inline 

simulationszeit = 10
#simulationszeit: Die Zeit in Jahren, die die Kaninchen simuliert werden

kaninchenanzahl = 1
kaninchenanzahl_liste = [] #leere Liste wird erstellt
kaninchenanzahl_liste.append(kaninchenanzahl) #erster Eintrag (1 Kaninchen) wird angehängt

for it in range(simulationszeit):    
    kaninchenanzahl = kaninchenanzahl + kaninchenanzahl * 0.1 #neue kaninchenanzahl wird berechnet
    #am ende jedes schleifendurchgangs wird die aktuelle zahl an die liste angehängt
    kaninchenanzahl_liste.append(kaninchenanzahl) 

plt.plot(kaninchenanzahl_liste)
Out[1]:
[<matplotlib.lines.Line2D at 0x23810428a58>]

Exponentielles Wachstum ist eine recht gute Näherung an das echte Verhalten einer Population. Ein Problem entsteht aber, wenn wir sehr lange Zeiträume betrachten. Setzen wir die Simulationszeit einmal auf 150 und sehen was passiert:

In [4]:
import matplotlib.pyplot as plt 
%matplotlib inline 

simulationszeit = 150
#simulationszeit: Die Zeit in Jahren, die die Kaninchen simuliert werden

kaninchenanzahl = 1
kaninchenanzahl_liste = [] #leere Liste wird erstellt
kaninchenanzahl_liste.append(kaninchenanzahl) #erster Eintrag (1 Kaninchen) wird angehängt

for it in range(simulationszeit):    
    kaninchenanzahl = kaninchenanzahl + kaninchenanzahl * 0.1 #neue kaninchenanzahl wird berechnet
    #am ende jedes schleifendurchgangs wird die aktuelle zahl an die liste angehängt
    kaninchenanzahl_liste.append(kaninchenanzahl) 

plt.plot(kaninchenanzahl_liste)
Out[4]:
[<matplotlib.lines.Line2D at 0x23810648c50>]

Das Wachstum wird immer schneller und die Kaninchenpopulation explodiert. Das ist ein unrealistisches Verhalten, denn jedes Ökosystem hat eine gewisse Kapazitätsgrenze, also eine maximale Anzahl an Individuen, die im System leben können (aufgrund von Nahrung- oder Unterschlupfangebot). Auch das sollten wir unser Programm einbauen. Nehmen wir an es kann in unserem System nie mehr als 1000 Kaninchen geben. Wenn diese Zahl überschritten ist, sollen keine neuen Kaninchen dazukommen. Um das ins Programm einzubauen, brauchen wir eine neue Struktur, die so genannte If-Abfrage.

If- Abfrage

If-Abfragen sind "Wenn-Dann-Fragen", mit denen wir Befehle an Bedingungen knüpfen können. Sie haben stets folgenden Aufbau:

if BEDINGUNG:
     BEFEHL

BEFEHL meint hierbei irgendeine Anweisung an Python, also zum Beispiel das Ändern einer Variable, einen Printbefehl oder etwas Ähnliches. Eine If-Abfrage kann auch mehrere Befehle enthalten.

Eine BEDINGUNG kann alles sein, was entweder wahr oder falsch sein kann. Oftmal benutzt man zur Definition einer Bedingung eine Gleichung. Man überprüft also etwa, ob es wahr ist, dass eine Variable x gleich groß ist wie eine Variable y. Noch häufiger werden Ungleichungen benutzt. Man überprüft also, ob es wahr ist, dass eine Variable x größer (oder kleiner) als eine Variable y ist.

Im folgenden Beispiel betrachten wir eine If-Abfrage in einer For-Schleife. Die For-Schleife erhöht die Zahl der Kaninchen fortlaufend um 1, die If-Abfrage führt einen Printbefehl aus, allerdings nur dann, wenn die Zahl der Hasen größer ist als 100.

In [5]:
kaninchen = 0 # Variablen-Definition, d.h. Zuweisung des Wertes 0 and die Variable hasen

for it in range(105):
    kaninchen = kaninchen + 1
    if kaninchen == 100:  # Vergleich des Wertes der Variable hasen mit dem Wert 100
        print("Es gibt jetzt EXAKT 100 Kaninchen!")
Es gibt jetzt EXAKT 100 Kaninchen!

Dieses Beispiel demonstriert den wichtigen Unterschied zwischen dem einfachen = Zeichen und dem doppelten = Zeichen noch einmal: Das einfache = wird benutzt um einer Variable (kaninchen) einen Wert zu zuweisen. Das doppelte = Zeichen wird dagegen für einen Vergleich verwendet.

Es können auch mehrere Befehle unter eine If-Abfrage gestellt werden. Ähnlich wie bei der For-Schleife wird hier durch Einrücken signalisiert, ob ein Befehl Teil der If-Abfrage ist, oder nicht:

In [6]:
kaninchen = 95
for it in range(10):
    kaninchen = kaninchen + 1
    if kaninchen > 100:
        print("Es gibt jetzt mehr als 100 Kaninchen!")
        print("Die exakte Zahl der Kaninchen ist ")
        print(kaninchen)
Es gibt jetzt mehr als 100 Kaninchen!
Die exakte Zahl der Kaninchen ist 
101
Es gibt jetzt mehr als 100 Kaninchen!
Die exakte Zahl der Kaninchen ist 
102
Es gibt jetzt mehr als 100 Kaninchen!
Die exakte Zahl der Kaninchen ist 
103
Es gibt jetzt mehr als 100 Kaninchen!
Die exakte Zahl der Kaninchen ist 
104
Es gibt jetzt mehr als 100 Kaninchen!
Die exakte Zahl der Kaninchen ist 
105

Wenn wir die beiden zuletzt stehenden Befehle nicht einrücken, dann sind sie nicht mehr Teil der If-Abfrage und werden in jedem Fall ausgeführt, egal wie viele Kaninchen es gibt:

In [8]:
kaninchen = 95
for it in range(10):
    kaninchen = kaninchen + 1
    if kaninchen > 100:
        print("Es gibt jetzt mehr als 100 Kaninchen!")
    print("Die exakte Zahl der Kaninchen ist ")
    print(kaninchen)
Die exakte Zahl der Kaninchen ist 
96
Die exakte Zahl der Kaninchen ist 
97
Die exakte Zahl der Kaninchen ist 
98
Die exakte Zahl der Kaninchen ist 
99
Die exakte Zahl der Kaninchen ist 
100
Es gibt jetzt mehr als 100 Kaninchen!
Die exakte Zahl der Kaninchen ist 
101
Es gibt jetzt mehr als 100 Kaninchen!
Die exakte Zahl der Kaninchen ist 
102
Es gibt jetzt mehr als 100 Kaninchen!
Die exakte Zahl der Kaninchen ist 
103
Es gibt jetzt mehr als 100 Kaninchen!
Die exakte Zahl der Kaninchen ist 
104
Es gibt jetzt mehr als 100 Kaninchen!
Die exakte Zahl der Kaninchen ist 
105

Wenn wir die beiden Befehle noch einmal nach außen verschieben, stehen sie auch außerhalb der For-Schleife und werden somit nur einmal ausgeführt, und zwar nachdem die For-Schleife abgeschlossen ist:

In [10]:
kaninchen = 95
for it in range(10):
    kaninchen = kaninchen + 1
    if kaninchen > 100:
        print("Es gibt jetzt mehr als 100 Kaninchen!")
print("Die exakte Zahl der Kaninchen ist ")
print(kaninchen)
Es gibt jetzt mehr als 100 Kaninchen!
Es gibt jetzt mehr als 100 Kaninchen!
Es gibt jetzt mehr als 100 Kaninchen!
Es gibt jetzt mehr als 100 Kaninchen!
Es gibt jetzt mehr als 100 Kaninchen!
Die exakte Zahl der Kaninchen ist 
105

Nun, da wird If-Abfragen verstehen können wir die Beschränkung auf 1000 Individuen in unser Programm einbauen: Nur wenn es noch weniger als 1000 Kaninchen gibt soll die Population weiter anwachsen:

In [15]:
import matplotlib.pyplot as plt 
%matplotlib inline 

simulationszeit = 100
#simulationszeit: Die Zeit in Jahren, die die Kaninchen simuliert werden

kaninchenanzahl = 1
kaninchenanzahl_liste = [] #leere Liste wird erstellt
kaninchenanzahl_liste.append(kaninchenanzahl) #erster Eintrag (1 Kaninchen) wird angehängt

for it in range(simulationszeit):
    if kaninchenanzahl < 1000: #NUR WENN es weniger als 1000 Kaninchen gibt
        kaninchenanzahl = kaninchenanzahl + kaninchenanzahl * 0.1 #neue kaninchenanzahl wird berechnet
    #am ende jedes schleifendurchgangs wird die aktuelle zahl an die liste angehängt
    kaninchenanzahl_liste.append(kaninchenanzahl) 

plt.plot(kaninchenanzahl_liste)
Out[15]:
[<matplotlib.lines.Line2D at 0x2381099eb00>]

Neben dem exponentiellen Wachstum gibt es aber noch andere Methoden um das Wachstum einer Population zu beschreiben. Mann kann auch von der Idee ausgehen, dass die momentane Population einerseits von der Population aus dem letzten Zeitschritt, andererseits aber auch von der Population aus dem vorletzten Zeitschritt abhängt.

Dies führt zur sogenannten

Fibonacci-Folge

In diesem Model errechnet sich die Anzahl der Kaninchen im aktuellen Jahr aus der Anzahl der Kaninchen aus dem Vorjahr plus der Anzahl der Kaninchen aus dem vorletzen Jahr. Um so etwas zu programmieren, müssen wir aber zuerst mehr über Listen in Python lernen:

Listen und Schleifen für Fortgeschrittene

Bislang wissen wir, wie man leere Listen erstellt und Elemente zu Listen hinzufügt. Man kann aber natürlich auch spezifische Listeneinträge abfragen. Möchte man beispielsweise das siebente Element der Liste kaninchenanzahl_liste wissen, kann man es mit kaninchenanzahl_liste[6] abrufen. Warum nicht 7? In Programmiersprachen ist es üblich, das erste Element einer Liste stets mit der Nummer 0 zu speichern, in diesem Fall also als kaninchenanzahl_liste[0]. Das ist anfangs zwar verwirrend, für spätere Anwendungen jedoch eine recht sinnvolle Konvention.

In [5]:
kaninchenanzahl_liste[0]
Out[5]:
0

For-Schleifen haben wir schon kennen gelernt, eine wichtige Eigenschaft haben wir aber bislang ignoriert. Die Zahl it, die sozusagen mitzählt wie oft ein Befehl schon ausgeführt worden ist, darf auch innerhalb eines Befehls benutzt werden:

In [3]:
for it in range(5):
    print(it)
0
1
2
3
4

Wenn wir diese beiden Kenntnisse nun kombinieren, haben wir eine Möglichkeit gefunden, wie wir innerhalb einer Schleife auf vorherige Listeneinträge zugreifen können. Das ist genau das, was wir für unsere Fibonacci-Folge benötigen.

Eine weitere wichtige Eigenschaft von for-Schleifen ist, dass it nicht unbedingt bei 0 anfangen muss. Möchten wir beispielsweise erst ab Jahr 2 simulieren, so können wir die Schleife auch erst bei Jahr zwei beginnen lassen:

In [4]:
for it in range(2,5):
    print(it)
2
3
4
In [5]:
import matplotlib.pyplot as plt 
%matplotlib inline 

simulationszeit = 10
# Simulationszeit: Die Zeit in Jahren, die die Kaninchenfortpflanzung simuliert wird

kaninchenanzahl = 1
kaninchenanzahl_liste = [] #leere liste wird erstellt
kaninchenanzahl_liste.append(kaninchenanzahl) #erster Eintrag (1 Kaninchen) wird angehängt
kaninchenanzahl = 1 
kaninchenanzahl_liste.append(kaninchenanzahl)#zweiter Eintrag (1 Kaninchen) wird angehängt
#wir benötigen hier zwei Kaninchen, damit sie sich auch vermehren können

for it in range(2, simulationszeit):
    letztesjahr = kaninchenanzahl_liste[it - 1] # Kaninchenanzahl vom letzten jahr wird berechnet
    vorletztesjahr = kaninchenanzahl_liste[it - 2] # Kaninchenanzahl vom vorletzten jahr wird berechnet
    kaninchenanzahl = letztesjahr + vorletztesjahr # neue Kaninchenanzahl wird berechnet
    # am Ende jedes Schleifendurchgangs wird die aktuelle Zahl an die Liste angehängt
    kaninchenanzahl_liste.append(kaninchenanzahl) 

plt.plot(kaninchenanzahl_liste)
Out[5]:
[<matplotlib.lines.Line2D at 0xb05ff28>]

Natürlich können wir auch hier eine Beschränkung des Wachstums durch eine If-Abfrage einbauen, ganz gleich wie beim exponentiellen Wachstum:

In [2]:
import matplotlib.pyplot as plt 
%matplotlib inline 

simulationszeit = 20
# Simulationszeit: Die Zeit in Jahren, die die Kaninchenfortpflanzung simuliert wird

kaninchenanzahl = 1
kaninchenanzahl_liste = [] #leere liste wird erstellt
kaninchenanzahl_liste.append(kaninchenanzahl) #erster Eintrag (1 Kaninchen) wird angehängt
kaninchenanzahl = 1 
kaninchenanzahl_liste.append(kaninchenanzahl)#zweiter Eintrag (1 Kaninchen) wird angehängt
#wir benötigen hier zwei Kaninchen, damit sie sich auch vermehren können

for it in range(2, simulationszeit):
    if kaninchenanzahl < 1000: #NUR WENN es weniger als 1000 Kaninchen gibt
        letztesjahr = kaninchenanzahl_liste[it - 1] # Kaninchenanzahl vom letzten jahr wird berechnet
        vorletztesjahr = kaninchenanzahl_liste[it - 2] # Kaninchenanzahl vom vorletzten jahr wird berechnet
        kaninchenanzahl = letztesjahr + vorletztesjahr # neue Kaninchenanzahl wird berechnet
    # am Ende jedes Schleifendurchgangs wird die aktuelle Zahl an die Liste angehängt
    kaninchenanzahl_liste.append(kaninchenanzahl) 

plt.plot(kaninchenanzahl_liste)
Out[2]:
[<matplotlib.lines.Line2D at 0x2ab3a336828>]

Was aber, wenn wir nun sozusagen in den interessanten Teil des Wachstums (zum Beispiel den Knick) hineinzoomen möchten. Dazu wird es notwendig sein, nicht nur einzelne Elemente der Liste auszuwählen, sondern ganze Stücke. Das funktioniert so:

Wir können aber auch mehrere Elemente auf einmal auswählen. Dazu schreiben wir in eckige Klammern das erste Element das wir haben möchten, dann einen Doppelpunkt, und dann das erste Element das wir nicht mehr haben wollen. Das Element mit dem Index 10 und das mit dem Index 11 bekommen wir also mit

In [6]:
print(kaninchenanzahl_liste[14:19])
[610, 987, 1597, 1597, 1597]

Wird vor dem Doppelpunkt keine Zahl geschrieben, wird beim Element mit dem Index Null begonnen. Wird nach dem Doppelpunkt keine Zahl geschrieben, wird bis zum letzen Element alles ausgewählt.

In [10]:
print(kaninchenanzahl_liste[:3])
print(kaninchenanzahl_liste[17:])
[1, 1, 2]
[1597, 1597, 1597]

Python akzeptiert auch negative Zahlen als Index. Der Index -1 bezeichnet dabei das letzte Element einer Liste, der Index -2 das vorletzte und so weiter. Das ist besonders praktisch, wenn man z.b. die letzten 5 Einträge der Liste auswählen möchte:

In [11]:
print(kaninchenanzahl_liste[-5:])
[987, 1597, 1597, 1597, 1597]

Das Auswählen von Listenteilen funktioniert natürlich nicht nur in Kombination mit dem print-Befehl, sondern auch mit allen anderen Befehlen, zum Beispiel plot. Wir können also nun auf den relevanten Teil unserer Grafik zoomen:

In [12]:
# Listen werden geplottet    
plt.plot(kaninchenanzahl_liste[14:19])
Out[12]:
[<matplotlib.lines.Line2D at 0x2ab3a44e2b0>]

Zusammenfassung

Lineares Wachstum

Bei linearem Wachstum kommt zu einer Population in jedem Zeitschritt eine konstante Anzahl hinzu:

kaninchen = kaninchen + x

wobei x angibt wie viele Individuen hinzukommen, also wie schnell die Population wächst.

Exponentielles Wachstum

Bei exponentiellem Wachstum kommt zur Population kein konstanter Wert hinzu, sondern ein Wert, der direkt von der aktuellen Population abhängt. Die genaue Abhängigkeit von Wachstum und momentaner Population gibt der so genannte Wachstumsparameter an:

kaninchen = kaninchen + kaninchen * wachstumsparameter

If-Abfragen

If-Abfragen können benutzt werden, um Anweisungen und Befehle nur unter gewissen Bedingungen ausführen zu lassen. Sie haben den Aufbau

if BEDINGUNG:
     BEFEHL

Die Bedingungen sind meist Ungleichungen (hasen >= 100) oder Gleichungen (hasen == 100), die entweder wahr oder falsch sein können. Wichtig ist, bei einem Vergleich das doppelte =-Zeichen zu verwenden.

For-Schleifen für Fortgeschrittene

Die Laufvariable (im Beispiel it) kann innerhalb der Schleife benutzt werden. So liefert die Schleife

for it in range(3):

print(it)

das Ergebnis

0

1

2

Listen für Fortgeschrittene

Um die einzelnen Elemente einer Liste zu verwenden oder auszugeben, benutzt man eckige Klammern:

listenname[2]

liefert zum Beispiel das dritte(!) Element. Achtung: Das erste Element in der Liste hat stets den Index 0, das zweite den Index 1, und so weiter.

Auswählen von Teilen einer Liste

Wenn wir mehrere Elemente einer Liste auswählen möchten, können wir

listenname[a:b]

verwenden, wobei a das erste Element ist, das wir haben möchten und b das erste, das nicht mehr Teil der Auswahl sein soll. Wenn wir a frei lassen, wird beim Element mit Index 0 begonnen, wenn wir b frei lassen wird bis zum letzten Element ausgewählt. Der Index -1 meint immer das letze Element einer Liste, -2 das vorletzte und so weiter.

Kapitel 4 - Schadstoffprotokolle analysieren

In diesem Kapitel werden wir unser Wissen über For-Schleifen und If-Abfragen weiter ausbauen, um die Schadstoffstatistik einer Firma auszuwerten. Die Schadstoffwerte sind in einer Liste gespeichert. In einem ersten Schritt sollen wir einfach eine Warnung ausgeben, wenn der Wert den Grenzwert von 100 Einheiten überschreitet. Dazu reicht eine If-Abfrage.

Da wir ein Programm haben wollen, das für Listen von jeder Länge funktioniert, können wir bei der Länge der For-Schleife nicht fix 10 schreiben, sondern lassen uns die Länge der Liste mit len ausrechnen.

In [12]:
werte = [89,96,125,88,110,112,99,84,50,130]

for it in range(len(werte)): #Schleife hat die Länge der Liste
    if werte[it] > 100:
        print("Zu hohe Schadstoffwerte am Tag",it,"!") 
Zu hohe Schadstoffwerte am Tag 2 !
Zu hohe Schadstoffwerte am Tag 4 !
Zu hohe Schadstoffwerte am Tag 5 !
Zu hohe Schadstoffwerte am Tag 9 !

Wenn unser Programm aber nur Alarm schlagen soll, wenn der Wert an zwei Tagen hintereinander überschritten wird, dann brauchen wir eine weitere If-Abfrage. Außerdem müssen wir den Tag 0 dann ausnehmen, da es ja keinen Tag -1 gibt.

In [13]:
werte = [89,96,125,88,110,112,99,84,50,130]

for it in range(1,len(werte)): #Schleife hat die Länge der Liste
    if werte[it] > 100:
        if werte[it-1] > 100:
            print("Zu hohe Schadstoffwerte am Tag" ,it,"!") 
Zu hohe Schadstoffwerte am Tag 5 !

Man kann sich vorstellen, dass so eine Struktur relativ schnell unübersichtlich wird, vor allem wenn man viele Bedingungen hat. Deswegen kann man solche If-Abfragen auf verkürzt schreiben: Man kann die einzelnen Bedingungen mit einem logischen und (and) und einem logischen oder (or) verknüpfen:

In [14]:
werte = [89,96,125,88,110,112,99,84,50,130]

for it in range(1,len(werte)): #Schleife hat die Länge der Liste
    if werte[it] > 100 and werte[it-1] > 100:
        print("Zu hohe Schadstoffwerte am Tag",it,"!") 
Zu hohe Schadstoffwerte am Tag 5 !

Damit lassen sich auch kompliziertere Verknüpfungen erstellen, die man über runde Klammern verbinden kann. Wir könnten also zum Beispiel verlangen, dass zusätzlich eine Warnung geschrieben wird, wenn der Wert größer als 120 ist. Ist der Wert am Tag davor aber kleiner als 50, gibt es keinen Alarm. Das würde so aussehen:

In [15]:
werte = [89,96,125,88,110,112,99,84,50,130]

for it in range(1,len(werte)): #Schleife hat die Länge der Liste
    if (werte[it] > 100 and werte[it-1] > 100) or (werte[it] > 120 and werte[it-1] > 50):
        print("Zu hohe Schadstoffwerte am Tag",it,"!") 
Zu hohe Schadstoffwerte am Tag 2 !
Zu hohe Schadstoffwerte am Tag 5 !

Als nächstes wollen wir die Tage, die einen Alarm ausgelöst haben, grafisch darstellen. Wir legen dazu eine Liste an, in die wir jedes mal die Zahl 1 schreiben, wenn der Alarm ausgelöst wurde, und 0 wenn er nicht ausgelöst wurde. Der erste Teil ist einfach, wir können jedes mal direkt nach dem Printbefehl eine 1 in die Liste schreiben. Aber wie identifizieren wir die Tage ohne Alarm? Wir könnten noch einmal eine ähnliche If-Abfrage bauen und einfach "umdrehen". Aber reicht es, einfach die Größer- und Kleiner-Zeichen umzudrehen? Was wenn wir genau den Grenzwert treffen? Und wie funktioniert das mit dem or? In jedem Fall wäre so eine Lösung sehr kompliziert und fehleranfällig. Deswegen gibt es eine besser Lösung: Jede if-Abfrage kann auch mit einem else ausgestattet werden, also einem Befehl, der gemacht werden soll, wenn die If-Abfrage nicht erfüllt ist. Vom Einrücken her ist dieses else auf gleicher Höhe wie das if, zu dem es gehört:

In [16]:
import matplotlib.pyplot as plt
%matplotlib inline

werte = [89,96,125,88,110,112,99,84,50,130]
protokoll=[]

for it in range(1,len(werte)): #Schleife hat die Länge der Liste
    if (werte[it] > 100 and werte[it-1] > 100) or (werte[it] > 120 and werte[it-1] > 50):
        print("Zu hohe Schadstoffwerte am Tag",it,"!") 
        protokoll.append(1)
    else:
        protokoll.append(0)

plt.plot(protokoll)
Zu hohe Schadstoffwerte am Tag 2 !
Zu hohe Schadstoffwerte am Tag 5 !
Out[16]:
[<matplotlib.lines.Line2D at 0x1835005f2e8>]

Hierbei sind wir aber von einer perfekten Datenlage ausgegangen. Echte Daten sind selten so vollständig. Man kann davon ausgehen, dass an einigen Tagen keine Messung durchgeführt wurde. Solche Tage würden dann einfach als 0 in unserer Liste auftauchen, und nicht einfach nur selbst keinen Alarm auslösen, sondern auch Auswirkungen auf den Tag danach haben:

In [33]:
import matplotlib.pyplot as plt
%matplotlib inline

werte = [89,96,125,88,110,0,112,99,84,50,0,130]
protokoll=[]

for it in range(1,len(werte)): #Schleife hat die Länge der Liste
    if (werte[it] > 100 and werte[it-1] > 100) or (werte[it] > 120 and werte[it-1] > 50):
        print("Zu hohe Schadstoffwerte am Tag" ,it, "!") 
        protokoll.append(1)
    else:
        protokoll.append(0)

plt.plot(protokoll)
Zu hohe Schadstoffwerte am Tag 2!
Out[33]:
[<matplotlib.lines.Line2D at 0x2ab3a52cac8>]

Was könnten wir in so einem Fall machen? Eine Möglichkeit ist zu interpolieren, also zu versuchen die fehlenden Daten anhand der existierenden Daten abzuschätzen. Das Einfachste wäre anzunehmen, dass die fehlenden Schadstoffwerte immer genau der Mittelwert aus dem Wert vom Vortag und dem Wert vom Tag danach ist. Bereinigen wir unsere Liste auf diese Art und plotten wir die entstehenden Listen:

In [37]:
import matplotlib.pyplot as plt
%matplotlib inline

werte = [89,96,125,88,110,0,112,99,84,50,0,130]
werte_neu=[]

for it in range(len(werte)):
    if werte[it] == 0:
        mittelwert = (werte[it-1] + werte[it+1]) / 2
        werte_neu.append(mittelwert)
    else:
        werte_neu.append(werte[it])

plt.plot(werte)
plt.plot(werte_neu)
Out[37]:
[<matplotlib.lines.Line2D at 0x2ab3a571128>]

Es gibt natürlich weiter Möglichkeiten zu interpolieren. Man könnte zum Beispiel Ableitungen betrachten, oder mehr Werte miteinbeziehen. Fürs erste bleiben wir aber bei dieser Methode und benutzen sie für unsere Auswertung:

In [17]:
import matplotlib.pyplot as plt
%matplotlib inline

werte = [89,96,125,88,110,0,112,99,84,50,0,130]
werte_neu=[]

for it in range(len(werte)):
    if werte[it] == 0:
        mittelwert = (werte[it-1] + werte[it+1]) / 2
        werte_neu.append(mittelwert)
    else:
        werte_neu.append(werte[it])

werte = werte_neu        
protokoll=[]

for it in range(1,len(werte)): #Schleife hat die Länge der Liste
    if (werte[it] > 100 and werte[it-1] > 100) or (werte[it] > 120 and werte[it-1] > 50):
        print("Zu hohe Schadstoffwerte am Tag",it,"!") 
        protokoll.append(1)
    else:
        protokoll.append(0)

plt.plot(protokoll)
Zu hohe Schadstoffwerte am Tag 2 !
Zu hohe Schadstoffwerte am Tag 5 !
Zu hohe Schadstoffwerte am Tag 6 !
Zu hohe Schadstoffwerte am Tag 11 !
Out[17]:
[<matplotlib.lines.Line2D at 0x18350082a20>]

Mit Hilfe dieser Interpolation können wir nun also auch mit Datensätzen arbeiten, die mit 0en aufgefüllt wurden. Echte Datensätz sind aber meist noch schwieriger zu handhaben. Zum Beispiel können wir auf Daten treffen, die nicht einmal eine Zahl sind, sondern einen ganz anderen Datentyp haben, zum Beispiel Zeichenketten. Das wäre noch schlimmer für unser Programm, denn es führt nicht zu falschen Ergebnissen, sondern zu einer Fehlermeldung und somit einem Absturz, wie wir hier sehen:

In [18]:
import matplotlib.pyplot as plt
%matplotlib inline

werte = [89,96,"keine Messung",125,88,110,0,112,"44",99,84,50,0,130,[99,88],125,"🐇",120]
werte_neu=[]

for it in range(len(werte)):
    if werte[it] == 0:
        mittelwert = (werte[it-1] + werte[it+1]) / 2
        werte_neu.append(mittelwert)
    else:
        werte_neu.append(werte[it])

werte = werte_neu        
protokoll=[]

for it in range(1,len(werte)): #Schleife hat die Länge der Liste
    if (werte[it] > 100 and werte[it-1] > 100) or (werte[it] > 120 and werte[it-1] > 50):
        print("Zu hohe Schadstoffwerte am Tag",it, "!") 
        protokoll.append(1)
    else:
        protokoll.append(0)

plt.plot(protokoll)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-18-55dec0f13f96> in <module>()
     16 
     17 for it in range(1,len(werte)): #Schleife hat die Länge der Liste
---> 18     if (werte[it] > 100 and werte[it-1] > 100) or (werte[it] > 120 and werte[it-1] > 50):
     19         print("Zu hohe Schadstoffwerte am Tag",it, "!")
     20         protokoll.append(1)

TypeError: '>' not supported between instances of 'str' and 'int'

Das Problem hier ist, dass man eine Zeichenkette nicht mit einer Zahl vergleichen kann und es zu einer Fehlermeldung kommt. Wir müssten die Daten also vor dem Bereinigen noch einmal "vorbereinigen". Leider können wir damit nicht ausschließen, dass es noch zu weiteren Fehlermeldungen kommt. Dennoch hätten wir gerne ein Programm, das nicht abstürzt, auch wenn es einmal zu einem Fehler kommt. Hier gibts es einen guten Ausweg: Wir versuchen unsere Rechenoperation durchzuführen, aber wenn irgendwo ein Fehler auftritt, müssen wir den Wert aus der Datenbank mit dem interpolierten Wert ersetzen.

Dafür gibt es in Python try und except. Zuerst wird versucht den Teil des Codes, der unter try steht auszuführen. Wenn das aber zu einer Fehlermeldung führt, wird das gemacht, was unter except steht. Für unseren Fall würde das so aussehen:

In [19]:
import matplotlib.pyplot as plt
%matplotlib inline

werte = [89,96,"keine Messung",125,88,110,0,112,"44",99,84,50,0,130,[99,88],125,"🐇",120]        
protokoll=[]

for it in range(1,len(werte)): #Schleife hat die Länge der Liste
    try: #Dieser Codeblock wird versucht
        if (werte[it] > 100 and werte[it-1] > 100) or (werte[it] > 120 and werte[it-1] > 50):
            print("Zu hohe Schadstoffwerte am Tag" ,it, "!") 
            protokoll.append(1)
        else:
            protokoll.append(0)
    except: #Wenn der obere Block einen Fehler verursacht, wird dieser Block ausgeführt
        werte[it] = (werte[it-1] + werte[it + 1]) / 2
        if (werte[it] > 100 and werte[it-1] > 100) or (werte[it] > 120 and werte[it-1] > 50):
            print("Zu hohe Schadstoffwerte am Tag" ,it, "!") 
            protokoll.append(1)
        else:
            protokoll.append(0)

plt.plot(protokoll)
Zu hohe Schadstoffwerte am Tag 3 !
Zu hohe Schadstoffwerte am Tag 8 !
Zu hohe Schadstoffwerte am Tag 14 !
Zu hohe Schadstoffwerte am Tag 15 !
Zu hohe Schadstoffwerte am Tag 16 !
Zu hohe Schadstoffwerte am Tag 17 !
Out[19]:
[<matplotlib.lines.Line2D at 0x183500fe8d0>]

Auf diese Art ersparen wir uns das Vorbereinigen der Liste. Aber Achtung, die 0-Werte lösen hier keinen Fehler aus und werden somit auch nicht interpoliert. Wir könnten zwar die 0-Werte vorher gesondert ausfiltern, es geht aber auch in der gleichen try-except Struktur, die wir schon haben. Dazu ist es jedoch nötig, eine eigene Fehlermeldung zu definieren. Das funktioniert in Python mit raise Exception(). Dieser Befehl bricht das Programm ab und gibt eine Fehlermeldung aus. Innerhalb von unserem try-except führt es aber nur dazu, dass wir in den except-Block springen.

Bauen wir also unseren eigenen "Diese Zahl darf nicht 0 sein"-Fehler ein:

In [20]:
import matplotlib.pyplot as plt
%matplotlib inline

werte = [89,96,"keine Messung",125,88,110,0,112,"44",99,84,50,0,130,[99,88],125,"🐇",120]        
protokoll=[]

for it in range(1,len(werte)): #Schleife hat die Länge der Liste
    try: #Dieser Codeblock wird versucht
        if werte[it] == 0:
            raise Exception("Diese Zahl darf nicht 0 sein!")
        if (werte[it] > 100 and werte[it-1] > 100) or (werte[it] > 120 and werte[it-1] > 50):
            print("Zu hohe Schadstoffwerte am Tag" ,it, "!") 
            protokoll.append(1)
        else:
            protokoll.append(0)
    except: #Wenn der obere Block einen Fehler verursacht, wird dieser Block ausgeführt
        werte[it] = (werte[it-1] + werte[it + 1]) / 2
        if (werte[it] > 100 and werte[it-1] > 100) or (werte[it] > 120 and werte[it-1] > 50):
            print("Zu hohe Schadstoffwerte am Tag" ,it, "!") 
            protokoll.append(1)
        else:
            protokoll.append(0)

plt.plot(protokoll)
Zu hohe Schadstoffwerte am Tag 3 !
Zu hohe Schadstoffwerte am Tag 6 !
Zu hohe Schadstoffwerte am Tag 7 !
Zu hohe Schadstoffwerte am Tag 8 !
Zu hohe Schadstoffwerte am Tag 13 !
Zu hohe Schadstoffwerte am Tag 14 !
Zu hohe Schadstoffwerte am Tag 15 !
Zu hohe Schadstoffwerte am Tag 16 !
Zu hohe Schadstoffwerte am Tag 17 !
Out[20]:
[<matplotlib.lines.Line2D at 0x1835015cdd8>]

Achtung: Das Arbeiten mit try und except führt zwar zu einfachen und eleganten Lösungen, ist aber relativ gefährlich: Auch wenn man Programmierfehler macht, werden sie durch except einfach abgefangen und man sieht nicht mehr gut was passiert ist. Deswegen sollte man besonders aufpassen, wenn man mit try und except arbeitet. Dazu ist es wichtig, dass wir uns genauer ansehen, wie das mit Fehlern in Python eigentlich funktioniert:

In [56]:
testwerte = [99, 120, 0, 101, "k.A.", 140]

for it in range(len(testwerte)):
    if testwerte[it] > 100:
        print("Alarm!")
    
Alarm!
Alarm!
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-56-09c2b072c013> in <module>()
      2 
      3 for it in range(len(testwerte)):
----> 4     if testwerte[it] > 100:
      5         print("Alarm!")
      6 

TypeError: '>' not supported between instances of 'str' and 'int'

Hier wird ein Fehler ausgelöst, wenn wir zum Eintrag "k.A." kommen. Dabei handelt es sich um eine Zeichenkette, die wir also nicht mit der Zahl 100 vergleichen können. Es handelt sich also um den falschen Datentyp, deswegen heißt der Fehler "TypeError". Auch hier können wir unseren eigenen Fehlertyp einbauen:

In [60]:
testwerte = [99, 120, 0, 101, "k.A.", 140]

for it in range(len(testwerte)):
    if testwerte[it] == 0:
        raise Exception("Dieser Wert darf nicht 0 sein!")
    if testwerte[it] > 100:
        print("Alarm!")
Alarm!
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
<ipython-input-60-cd0f50d30d93> in <module>()
      3 for it in range(len(testwerte)):
      4     if testwerte[it] == 0:
----> 5         raise Exception("Dieser Wert darf nicht 0 sein!")
      6     if testwerte[it] > 100:
      7         print("Alarm!")

Exception: Dieser Wert darf nicht 0 sein!

Nun bekommen wir unsere individualisierte Fehlermeldung. Bauen wir nun try-except ein. Der Einfachheit halber werden wir als Alternative einfach nur Text ausgeben, der sagt, dass ein Eintrag übersprungen wurde.

In [62]:
testwerte = [99, 120, 0, 101, "k.A.", 140]

for it in range(len(testwerte)):
    try:
        if testwerte[it] == 0:
            raise Exception("Dieser Wert darf nicht 0 sein!")
        if testwerte[it] > 100:
            print("Alarm!")
    except: 
        print("Ein Eintrag wurde übersprungen!")
Alarm!
Ein Eintrag wurde übersprungen!
Alarm!
Ein Eintrag wurde übersprungen!
Alarm!

Dieses Programm ist jetzt aber recht gefährlich, denn wir sehen die Fehlermeldungen nicht mehr. Sollten wir also einen Fehler beim Programmieren machen, wird dieser genauso rausgefiltert:

In [66]:
testwerte = [99, 120, 0, 101, "k.A.", 140]

for it in range(len(testwerte)):
    try:
        if testwerte[it] == 0:
            raise Exception("Dieser Wert darf nicht 0 sein!")
        if testwerte > 100: #ACHTUNG: Hier ist jetzt ein Fehler eingebaut: Wir haben das [it] entfernt.
            print("Alarm!")
    except: 
        print("Ein Eintrag wurde übersprungen!")
Ein Eintrag wurde übersprungen!
Ein Eintrag wurde übersprungen!
Ein Eintrag wurde übersprungen!
Ein Eintrag wurde übersprungen!
Ein Eintrag wurde übersprungen!
Ein Eintrag wurde übersprungen!

Um so etwas zu verhindern, oder zumindest die Chance, dass es unbemerkt passiert, zu verringern, empfiehlt es sich die Fehlermeldungen, die entstehen, zumindest auszugeben. Das Programm wird nicht unterbrochen, man sieht aber trotzdem was passiert ist. Obwohl wir die Fehlermeldung also überspringen, haben wir eien Teil der Vorteile eine Fehlermeldung.

Dazu müssen wir unsere try-except Struktur ein wenig ausbauen. Wir möchten die genaue Fehlermeldung ausgeben. Dazu benutzt man except Exception as errormsg und kann daraufhin den genauen Fehlertext, der in der Variable errormsg gespeichert wurde, verwenden.

In [72]:
testwerte = [99, 120, 0, 101, "k.A.", 140]

for it in range(len(testwerte)):
    try:
        if testwerte[it] == 0:
            raise Exception("Dieser Wert darf nicht 0 sein!")
        if testwerte[it] > 100:
            print("Alarm!")
    except Exception as errormsg: 
        print("Ein Eintrag wurde übersprungen! Der Fehler war:")
        print(errormsg)
Alarm!
Ein Eintrag wurde übersprungen! Der Fehler war:
Dieser Wert darf nicht 0 sein!
Alarm!
Ein Eintrag wurde übersprungen! Der Fehler war:
'>' not supported between instances of 'str' and 'int'
Alarm!

Um das eigentliche Protokoll vom Fehlerprotokoll zu trennen, können wir eine eigene Liste dafür anfangen, die wir nur bei Bedarf anschauen können:

In [25]:
testwerte = [99, 120, 0, 101, "k.A.", 140]
fehlerlog=[]
for it in range(len(testwerte)):
    print("Tag",it)
    
    try:
        if testwerte[it] == 0:
            raise Exception("Dieser Wert darf nicht 0 sein!")
        if testwerte[it] > 100:
            print("Alarm!")
        else:
            print("Kein Alarm!")
    except Exception as errormsg: 
        print("Fehler!")
        fehlerlog.append(it)
        fehlerlog.append("Ein Eintrag wurde übersprungen! Der Fehler war:")
        fehlerlog.append(errormsg)
    else: #auch das except kann ein else haben, das ausgeführt wird, wenn das except nicht ausgelöst wurde
        fehlerlog.append(it)
        fehlerlog.append("Fehlerfrei!")
        

print("Fehlerprotokoll:")
print(fehlerlog)
Tag 0
Kein Alarm!
Tag 1
Alarm!
Tag 2
Fehler!
Tag 3
Alarm!
Tag 4
Fehler!
Tag 5
Alarm!
Fehlerprotokoll:
[0, 'Fehlerfrei!', 1, 'Fehlerfrei!', 2, 'Ein Eintrag wurde übersprungen! Der Fehler war:', Exception('Dieser Wert darf nicht 0 sein!'), 3, 'Fehlerfrei!', 4, 'Ein Eintrag wurde übersprungen! Der Fehler war:', TypeError("'>' not supported between instances of 'str' and 'int'"), 5, 'Fehlerfrei!']

Auf diese Art können wir relativ gut mit Fehlern umgehen. Try-except bietet jedoch noch viel mehr Möglichkeiten: Wir könnten die Art der Fehler unterscheiden und je nach Fehlertyp andere Lösungsmöglichkeiten finden. Generell muss man beim Benutzen von try und except aber vorsichtig sein: Meist ist eine Fehlermeldung ein Problem, dass man beheben sollte, indem man am Programm so lange etwas ändert, bis eben keine Fehlermeldungen mehr entstehen. Dennoch gibt es Situationen in denen (potentielle) Fehlermeldungen unvermeidbar sind, zum Beispiel wenn Daten von außen eingelesen werden und man noch nicht wissen kann, was das für Daten sein werden. Hier ist dann sinnvoll, mit try und except vorzubeugen.

Zusammenfassung

If-Else Abfragen

Die If-Else-Abfrage ist eine Erweiterung der If-Abfrage. Sie erweitert diese Struktur um zusätzliche Befehle, die ausgeführt werden sollen, wenn die Bedingung nicht wahr ist. Sie hat den Aufbau:

if BEDINGUNG:
      BEFEHL WENN WAHR
else:
      BEFEHL WENN FALSCH

and / or

Mit and und or kann man mehrere Bedingungen miteinander verknüpfen, entweder so, dass alle Bedingungen erfüllt sein müssen, oder so, dass nur eine erfüllt werden muss.

  • wahr and wahr = wahr
  • wahr and falsch = falsch
  • wahr or wahr = wahr
  • wahr or falsch = wahr
  • falsch or falsch = falsch

Try und Except

Mittels try und except ist es möglich ein Programm trotz Fehlermeldung weiterlaufen zu lassen. Tritt im Try-Block ein Fehler auf, springt das Programm in den Except-block. Damit wir aber totzdem noch merken, dass es zu einem Fehler kommt, ist es sinnvoll die Fehlermeldung zumindest mitzuprotokollieren. Dazu verwendet man except Exception as errormsg: und hat dann Zugriff auf die Fehlermeldung unter dem Variablennamen errormsg.

Kapitel 5 - Gekoppelte Differentialgleichungen: Räuber-Beute-Systeme

Wir haben in einem vorherigen Kapitel dieses Skriptums bereits die Entwicklung einer Tierpopulationen betrachtet. Dies war aber noch stark vereinfach, denn meist hat das Wachstum eine Abhängigkeit von anderen Größen, zum Beispiel der Population einer anderen Tierart. Solche Abhängigkeiten und Beinflussungen unterschiedlicher Dynamiken sind gewissermaßen der Normalfall in den Systemen, die für die Systemwissenschaften interessant sind. Der Systembegriff geht ja eben davon aus, dass interagierende, also sich wechselseitig beeinflussende Dynamiken in ihrem Zusammenwirken etwas generieren, das ohne dieses Zusammenwirken nicht, oder zumindest nicht so, beobachtet werden kann.

In Bezug auf diese sich wechselseitig beeinflussenden Dynamiken spricht man von gekoppelten Dynamiken. Und die Mathematik kennt zu ihrer Analyse die Methode der gekoppelten Differentialgleichungen. Das systemwissenschaftliche Standardbeispiel für solche gekoppelten Differentialgleichungssysteme sind Räuber-Beute-Systeme, für die es in der Regel allerdings keinen analytischen (rein mathematischen) Lösungsweg gibt. Im Folgenden wollen wir deshalb ein historisches Beispiel eines solchen Räuber-Beute-Systems mit Hilfe von Python simulieren.

Beginnen wir ähnlich, wie wir in einem früheren Kapitel Hasen simuliert haben. Innerhalb einer For-Schleife, wächste die Population von Hasen exponentiell. ZUsätzlich bauen wir auch noch eine zweite Tierart, die Luchse, ein.

In [1]:
# Importieren von matplotlib
import matplotlib.pyplot as plt
%matplotlib inline

# Startbedingungen für Hasen
hasen = 1000           # wie viele Hasen sind am Anfang vorhanden
hasen_liste = []       # leere Liste
hasen_liste.append(hasen) #speichert den ersten Eintrag in der Liste
hasen_wachstum = 0.01  # Wachstumsrate, wie schnell vermehren sich die Hasen


# Startbedingungen für Luchse
luchse = 100           # wie viele Luchse sind am Anfang vorhanden
luchs_liste = []       # leere Liste
luchs_liste.append(luchse) #speichert den ersten Eintrag in der Liste
luchs_wachstum = 0.005 # Wachstumsrate, wie schnell vermehren sich die Luchse


for it in range(365): # Schleife über 365 Tage   
    # Populuationsgleichungen:
    # population = population + wachstum 
    # population = population + wachstumrate * population
    hasen =  hasen + hasen_wachstum * hasen  
    luchse = luchse + luchs_wachstum * luchse 
      
    # je aktuelle Population wird an die Listen angefügt
    hasen_liste.append(hasen)
    luchs_liste.append(luchse)

# Listen werden geplottet (lw gibt die Stärke der Linien an)    
plt.plot(hasen_liste, color = "gray", lw = 2)
plt.plot(luchs_liste, color = "brown", lw = 2)
Out[1]:
[<matplotlib.lines.Line2D at 0x26d15b5bc18>]

Nun müssen wir auch noch den Term in die Gleichung einbauen, der beschreibt, dass Tiere sterben. Das möchten wir elegant ausdrücken.

Wir wollen folgende Informationen in die Gleichung einbauen:

Wenn es viele Hasen gibt, können sich die Luchse schneller vermehren.

Wenn es viele Luchse gibt, sterben mehr Hasen.

Wie bauen wir das nun mathematisch ein? Ohne Beeinflussung der Tierarten untereinander wäre das

$$\Delta\ Hasen = a * Hasen - {b} * Hasen $$ $$\Delta\ Luchse = {c} * Luchse - d * Luchse $$

Nun möchten wir diese zusätzliche Abhängigkeit einbauen:

$$\Delta\ Hasen = a * Hasen - \color{darkred}{ \tilde b * Hasen * Luchse}$$ $$\Delta\ Luchse = \color{darkred}{ \tilde c * Luchse * Hasen} - d * Luchse $$

In [36]:
# Importieren von matplotlib
import matplotlib.pyplot as plt
%matplotlib inline

# Startbedingungen für Hasen
hasen = 1000           # wie viele Hasen sind am Anfang vorhanden
hasen_liste = []       # leere Liste
hasen_liste.append(hasen) #speichert den ersten Eintrag in der Liste
hasen_wachstum = 0.005  # Wachstumsrate, wie schnell vermehren sich die Hasen
hasen_sterben = 0.005 / 100


# Startbedingungen für Luchse
luchse = 100           # wie viele Luchse sind am Anfang vorhanden
luchs_liste = []       # leere Liste
luchs_liste.append(luchse) #speichert den ersten Eintrag in der Liste
luchs_wachstum = 0.005 / 1000
luchs_sterben = 0.01


for it in range(10 * 365): # Schleife über 10 * 365 Tage   
    # Populuationsgleichungen:
    # population = population + wachstum - sterben
    hasen =  hasen + hasen_wachstum * hasen - hasen_sterben * hasen * luchse  
    luchse = luchse + luchs_wachstum * luchse * hasen - luchs_sterben * luchse
      
    # je aktuelle Population wird an die Listen angefügt
    hasen_liste.append(hasen)
    luchs_liste.append(luchse)

# Listen werden geplottet (lw gibt die Stärke der Linien an)    
plt.plot(hasen_liste, color = "gray", lw = 2)
plt.plot(luchs_liste, color = "brown", lw = 2)
Out[36]:
[<matplotlib.lines.Line2D at 0x26d250d65f8>]

Einen kleinen Fehler machen wir hierbei aber noch: Wenn wir die neue Hasenpopulation ausrechnen, verwenden wir korrekterweise die Hasen und Luchspopulation aus dem letzten Zeitschritt. Dann überschreiben wir die Hasenzahl. Wenn wir dann also in der nächsten Zeile die Luchse berechnen, verwenden wir schon die neue Zahl der Hasen. Hier sollten wir eigentlich die Zahl vom Vortag benutzen. Also ersetzen wir hasen durch hasen_liste[it-1] und luchse durch luchs_liste[it-1].

Zusätzlich müssen wir mit unserer Schleife nicht mehr bei 0, sondern bei 1 beginnen.

In [40]:
# Importieren von matplotlib
import matplotlib.pyplot as plt
%matplotlib inline

# Startbedingungen für Hasen
hasen = 1000           # wie viele Hasen sind am Anfang vorhanden
hasen_liste = []       # leere Liste
hasen_liste.append(hasen) #speichert den ersten Eintrag in der Liste
hasen_wachstum = 0.005  # Wachstumsrate, wie schnell vermehren sich die Hasen
hasen_sterben = 0.005 / 100


# Startbedingungen für Luchse
luchse = 100           # wie viele Luchse sind am Anfang vorhanden
luchs_liste = []       # leere Liste
luchs_liste.append(luchse) #speichert den ersten Eintrag in der Liste
luchs_wachstum = 0.005 / 1000
luchs_sterben = 0.01


for it in range(1,10 * 365): # Schleife über 10 * 365 Tage   
    # Populuationsgleichungen:
    # population = population + wachstum - sterben
    hasen =  hasen_liste[it-1] + hasen_wachstum * hasen_liste[it-1] - hasen_sterben * hasen_liste[it-1] * luchs_liste[it-1]  
    luchse = luchs_liste[it-1] + luchs_wachstum * luchs_liste[it-1] * hasen_liste[it-1] - luchs_sterben * luchs_liste[it-1]
      
    # je aktuelle Population wird an die Listen angefügt
    hasen_liste.append(hasen)
    luchs_liste.append(luchse)

# Listen werden geplottet (lw gibt die Stärke der Linien an)    
plt.plot(hasen_liste, color = "gray", lw = 2)
plt.plot(luchs_liste, color = "brown", lw = 2)
Out[40]:
[<matplotlib.lines.Line2D at 0x26d15b5b588>]

Diese Berechnung ist nun tagesgenau. Wenn wir genauere Ergebnisse haben möchten, könnten wir unsere Zeitschritte verkürzen, also auf Stunden, Minuten oder Sekunden. Wir könnten auch unendlich kleine Zeitschritte machen. Die Differenzengleichung würde dann zu einer Differentialgleichung werden.

Dieses System aus gekoppelten Differentialgleichungen ist allgemein bekannt. Es heißt Lotka-Volterra-System:

$$\frac{dH}{dt}=\alpha*H-\beta*H*L$$

$$\frac{dL}{dt}=\gamma*L*H-\phi*L$$

Fürs erste sind wir aber mit der Rechengenauigkeit unserer Differenzengleichungen zufrieden. Für ein Lotka-Volterra-System gibt es 3 Regeln. Wir können im Folgenden überprüfen, ob diese Regeln auch für unser System aus Differenzengleichungen gelten.

Lotka-Volterra-Regel 1

Die Räuber- und die Beute-Populationen oszillieren periodisch und zueinander zeitlich versetzt. Die Räuber-Population läuft der Beute-Population zeitlich etwas hinterher.

Um das zu überprüfen, müssen wir unsere grafische Darstellung ein wenig verbessern. Da es sehr viel weniger Luchse als Hasen gibt, ist es schwer die Werte zu vergleichen. Besser wäre es, wenn wir nicht die absolute Population, sondern die relative Population, also die aktuelle Population dividiert durch die Startpopulation darstellen.

Was wir also machen möchten, ist jeden Eintrag der Populationslisten durch 1000 bzw. durch 100 zu dividieren. Mit Listen funktioniert das leider nicht so einfach, wohl aber mit numpy-arrays. Wir konvertieren unsere Listen also zu numpy-arrays, die wir dann ohne Probleme dividieren können:

In [44]:
# Importieren von matplotlib
import matplotlib.pyplot as plt
%matplotlib inline
import numpy as np

# Startbedingungen für Hasen
hasen = 1000           # wie viele Hasen sind am Anfang vorhanden
hasen_liste = []       # leere Liste
hasen_liste.append(hasen) #speichert den ersten Eintrag in der Liste
hasen_wachstum = 0.005  # Wachstumsrate, wie schnell vermehren sich die Hasen
hasen_sterben = 0.005 / 100


# Startbedingungen für Luchse
luchse = 100           # wie viele Luchse sind am Anfang vorhanden
luchs_liste = []       # leere Liste
luchs_liste.append(luchse) #speichert den ersten Eintrag in der Liste
luchs_wachstum = 0.005 / 1000
luchs_sterben = 0.01


for it in range(1,10 * 365): # Schleife über 10 * 365 Tage   
    # Populuationsgleichungen:
    # population = population + wachstum - sterben
    hasen =  hasen_liste[it-1] + hasen_wachstum * hasen_liste[it-1] - hasen_sterben * hasen_liste[it-1] * luchs_liste[it-1]  
    luchse = luchs_liste[it-1] + luchs_wachstum * luchs_liste[it-1] * hasen_liste[it-1] - luchs_sterben * luchs_liste[it-1]
      
    # je aktuelle Population wird an die Listen angefügt
    hasen_liste.append(hasen)
    luchs_liste.append(luchse)
    
# Listen werden zu Arrays konvertiert und normiert, d.h durch die Startpopulation dividiert.
hasen_array = np.array(hasen_liste)
hasen_array = hasen_array / 1000

luchs_array = np.array(luchs_liste)
luchs_array = luchs_array / 100
# Listen werden geplottet (lw gibt die Stärke der Linien an)    
plt.plot(hasen_array, color = "gray", lw = 2)
plt.plot(luchs_array, color = "brown", lw = 2)
Out[44]:
[<matplotlib.lines.Line2D at 0x26d2543dfd0>]

Hier sehen wir eindeutig: Es gibt Oszillationen und das Maximum der Jägerpopulation is immer etwas nach dem Maximum der Beutepopulation. Die erste Lotka-Volterra-Regel wird also korrekt in unserem Modell wiedergegeben.

Lotka-Volterra-Regel 2

Die durchschnittlichen Größen der beiden Populationen bleiben über längere Zeiträume konstant, auch wenn die Maxima und Minima sehr unterschiedlich sind.

Um das zu überprüfen müssen wir längere Zeiträume betrachten. Innerhalb der ersten zwar Oszillationen sieht es zwar so aus, als würde die durchschnittliche Population konstant bleiben, wir sollten usn aber sicherheitshalber längere Zeiträume ansehen. Erhöhen wir den Simulationszeitraum auf 100 Jahre.

In [49]:
# Importieren von matplotlib
import matplotlib.pyplot as plt
%matplotlib inline
import numpy as np

# Startbedingungen für Hasen
hasen = 1000           # wie viele Hasen sind am Anfang vorhanden
hasen_liste = []       # leere Liste
hasen_liste.append(hasen) #speichert den ersten Eintrag in der Liste
hasen_wachstum = 0.005  # Wachstumsrate, wie schnell vermehren sich die Hasen
hasen_sterben = 0.005 / 100


# Startbedingungen für Luchse
luchse = 100           # wie viele Luchse sind am Anfang vorhanden
luchs_liste = []       # leere Liste
luchs_liste.append(luchse) #speichert den ersten Eintrag in der Liste
luchs_wachstum = 0.005 / 1000
luchs_sterben = 0.01


for it in range(1,100 * 365): # Schleife über 10 * 365 Tage   
    # Populuationsgleichungen:
    # population = population + wachstum - sterben
    hasen =  hasen_liste[it-1] + hasen_wachstum * hasen_liste[it-1] - hasen_sterben * hasen_liste[it-1] * luchs_liste[it-1]  
    luchse = luchs_liste[it-1] + luchs_wachstum * luchs_liste[it-1] * hasen_liste[it-1] - luchs_sterben * luchs_liste[it-1]
      
    # je aktuelle Population wird an die Listen angefügt
    hasen_liste.append(hasen)
    luchs_liste.append(luchse)
    
# Listen werden zu Arrays konvertiert und normiert, d.h durch die Startpopulation dividiert.
hasen_array = np.array(hasen_liste)
hasen_array = hasen_array / 1000

luchs_array = np.array(luchs_liste)
luchs_array = luchs_array / 100
# Listen werden geplottet (lw gibt die Stärke der Linien an)    
plt.plot(hasen_array, color = "gray", lw = 2)
plt.plot(luchs_array, color = "brown", lw = 2)
Out[49]:
[<matplotlib.lines.Line2D at 0x26d253ed208>]

Hier sehen wir sehr deutlich, dass das Maximum immer größer wird. Die zweite Lotka-Volterra-Regel wird in unserem Modell also verletzt. Woran liegt das? Der erste Verdacht ist unsere Vereinfachung, dass wir anstelle einer Differentialgleichung nur eine tagesgenaue Differentialgleichung benutzen. Um festzustellen ob wirklich das für dieses Verhalten verantwortlich ist, müssen wir eine bessere Methode finden, um längere Zeitentwicklungen grafisch darzustellen. Wir werden im Folgenden die sogenannte Phasenraumdarstellung benutzen.

Phasenraumdarstellung

In unseren üblichen Grafiken tragen wir immer die Zeit auf der einen Achse und die Populationen auf der anderen Achse auf. Das ist aber nicht die einzige Möglichkeit, die wir haben. Wir könnten auch die eine Achse für die Jäger und die andere Achse für die Beutetiere verwenden. Dann stellen wir nicht mehr die Zeitentwicklung darn, sondern viel mehr die Population der Jäger und Beute, die uns während der ganzen Zeitentwicklung untergekommen sind.

Sollten wir immer konstante Maxima und Miminma haben, sollte die Phasenraumdarstellung eine geschlossene Form haben, ähnlich einem Kreis. Wenn wir immer größere Werte bekommen, sehen wir eine Spirale. Machen wir nun also zur Probe einen Phasenraumplot:

In [50]:
plt.plot(hasen_array,luchs_array)
plt.title("Phasenraumdarstellung")
plt.xlabel("Beute")
plt.ylabel("Räuber")
Out[50]:
<matplotlib.text.Text at 0x26d26a399b0>

Wir sehen hier also eindeutig eine Spirale, die Maxima werden also immer größer. Um festzustellen ob wirklich unsere Rechengenauigkeit das Problem verursacht, können wir diese einfach erhöhen. Verwenden wir statt ganze Tage als Zeitschritte nun das Hundertstel eines Tages. Wir müssen also 100 mal mehr Zeitschritte machen, dafür die einzelnen Änderungen pro Zeitschritt durch 100 dividieren.

In [63]:
# Importieren von matplotlib
import matplotlib.pyplot as plt
%matplotlib inline
import numpy as np

# Startbedingungen für Hasen
hasen = 1000           # wie viele Hasen sind am Anfang vorhanden
hasen_liste = []       # leere Liste
hasen_liste.append(hasen) #speichert den ersten Eintrag in der Liste
hasen_wachstum = 0.005  # Wachstumsrate, wie schnell vermehren sich die Hasen
hasen_sterben = 0.005 / 100


# Startbedingungen für Luchse
luchse = 100           # wie viele Luchse sind am Anfang vorhanden
luchs_liste = []       # leere Liste
luchs_liste.append(luchse) #speichert den ersten Eintrag in der Liste
luchs_wachstum = 0.005 / 1000
luchs_sterben = 0.01


for it in range(1,100 * 365 * 100): # Schleife über 10 * 365 Tage   
    # Populuationsgleichungen:
    # population = population + wachstum - sterben
    hasen =  hasen_liste[it-1] + \
    1/100 * (hasen_wachstum * hasen_liste[it-1] - hasen_sterben * hasen_liste[it-1] * luchs_liste[it-1])  
    luchse = luchs_liste[it-1] + \
    1/100 * (luchs_wachstum * luchs_liste[it-1] * hasen_liste[it-1] - luchs_sterben * luchs_liste[it-1])
      
    # je aktuelle Population wird an die Listen angefügt
    hasen_liste.append(hasen)
    luchs_liste.append(luchse)
    
# Listen werden zu Arrays konvertiert und normiert, d.h durch die Startpopulation dividiert.
hasen_array = np.array(hasen_liste)
hasen_array = hasen_array / 1000

luchs_array = np.array(luchs_liste)
luchs_array = luchs_array / 100

plt.plot(hasen_array,luchs_array)
plt.title("Phasenraumdarstellung")
plt.xlabel("Beute")
plt.ylabel("Räuber")
Out[63]:
<matplotlib.text.Text at 0x26d0b1e2d30>

Nun ist die Phasenraumdarstellung wirklich eine geschlossene Figur. Die Rechengenauigkeit macht also wirklich einen Unterschied. Mit erhöhter Rechengenauigkeit ist nun also auch die zweite Lotka-Volterra-Regel erfüllt.

Lotka-Volterra-Regel 3

Werden Räuber- und Beute-Population gleichzeitig um den gleichen Prozentanteil dezimiert, so steigt der Mittelwert der Beutepopulation kurzfristig an, und der Mittelwert der Räuberpopulation sinkt kurzfristig ab.

Auch diesen Effekt können wir ganz einfach ausprobieren. Eine Periode (also die Zeit zwischen zwei Punkten, in denen sowohl Räuber als auch Beutepopulation normiert den Wert 1 haben) dauert in unserem Modell 972 Zeiteinheiten. Wir können also den Mittelwert der ersten 972 Zeiteinheiten ausrechnen, dann die Populationen dezimiren, und denn die Mittelwerte der zweiten 972 Zeitschritte ausrechnen. Mittelwerte von arrays berechnet man am besten mit dem numpy-Befehl np.mean. Damit kann man auch den Mittelwert von bestimmten Teilen eines Arrays ausrechnen lassen. Dazu schreibt man np.mean(liste[anfang:ende]) also zum Beispiel np.mean(hasen_array[0:972])

In [1]:
# Importieren von matplotlib
import matplotlib.pyplot as plt
%matplotlib inline
import numpy as np

# Startbedingungen für Hasen
hasen = 1000           # wie viele Hasen sind am Anfang vorhanden
hasen_liste = []       # leere Liste
hasen_liste.append(hasen) #speichert den ersten Eintrag in der Liste
hasen_wachstum = 0.005  # Wachstumsrate, wie schnell vermehren sich die Hasen
hasen_sterben = 0.005 / 100


# Startbedingungen für Luchse
luchse = 100           # wie viele Luchse sind am Anfang vorhanden
luchs_liste = []       # leere Liste
luchs_liste.append(luchse) #speichert den ersten Eintrag in der Liste
luchs_wachstum = 0.005 / 1000
luchs_sterben = 0.01


for it in range(1,8 * 365): # Schleife über 8 * 365 Tage 
   
    
    
    
    # Populuationsgleichungen:
    # population = population + wachstum - sterben
    hasen =  hasen_liste[it-1] + hasen_wachstum * hasen_liste[it-1] - hasen_sterben * hasen_liste[it-1] * luchs_liste[it-1]  
    luchse = luchs_liste[it-1] + luchs_wachstum * luchs_liste[it-1] * hasen_liste[it-1] - luchs_sterben * luchs_liste[it-1]
    
    #Dezimieren der Population:
    if it == 972:
        hasen = hasen * 0.2
        luchse = luchse * 0.2
    
    
    # je aktuelle Population wird an die Listen angefügt
    hasen_liste.append(hasen)
    luchs_liste.append(luchse)
    
# Listen werden zu Arrays konvertiert und normiert, d.h durch die Startpopulation dividiert.
hasen_array = np.array(hasen_liste)
hasen_array = hasen_array / 1000

luchs_array = np.array(luchs_liste)
luchs_array = luchs_array / 100
# Listen werden geplottet (lw gibt die Stärke der Linien an)    
plt.plot(hasen_array, color = "gray", lw = 2)
plt.plot(luchs_array, color = "brown", lw = 2)


Hmittel1=np.mean(hasen_array[0:972])
Hmittel2=np.mean(hasen_array[972:2*972])
Lmittel1=np.mean(luchs_array[0:972])
Lmittel2=np.mean(luchs_array[972:2*972])

print("Hasenmittelwert ändert sich von")
print(Hmittel1)
print("auf")
print(Hmittel2)
print("")
print("Luchsmittelwert ändert sich von")
print(Lmittel1)
print("auf")
print(Lmittel2)
Hasenmittelwert ändert sich von
1.960012221793669
auf
2.71757133048013

Luchsmittelwert ändert sich von
0.9988842085172154
auf
0.855243907572326

Wir sehen, auch die dritte Lotka-Volterra-Regel wird von unserem Modell erfüllt.

Zusammenfassung

Gekoppelte Differentialgleichungssysteme

Räuber-Beute-Systeme von Tierpopulationen werden mathematisch mit gekoppelten Differentialgleichungssystemen dargestellt und, weil zumeist keine analytische Lösung möglich ist, am Computer als Differenzengleichungssystem simuliert.

Phasenraumdarstellung

In dieser Darstellungsweise werden nicht mehrere Variablen gegen die Zeit aufgetragen, sondern eine Variable gegen eine andere. Bei Räuber-Beute-Systemen wird beispielsweise die Beute-Population auf der x-Achse und die Räuber-Population auf der y-Achse dargestellt. Man kann den Plot dann etwa so lesen: Zu jedem Wert der Beutepopulation auf der x-Achse, finden wir auf der y-Achse wie die Räuberpopulation unter diesen Bedingungen war. Die Zeit selbst sehen wir auf diesem Plot aber nicht.

Die Lotka-Volterra-Regeln

  • Die Räuber- und die Beute-Populationen oszillieren periodisch und zueinander zeitlich versetzt. Die Räuber-Population läuft der Beute-Population zeitlich etwas hinterher.
  • Die durchschnittlichen Größen der beiden Populationen bleiben über längere Zeiträume konstant, auch wenn Maxima und Minima unterschiedlich sind.
  • Werden Räuber- und Beute-Population gleichzeitig um den gleichen Prozentanteil dezimiert, so steigt der Mittelwert der Beutepopulation kurzfristig an, und der Mittelwert der Räuberpopulation sinkt kurzfristig ab.

Arrays

Arrays lassen sich im Gegensatz zu Listen mit Zahlen multiplizieren und durch Zahlen dividieren. Dabei wird jedes Element im Array einzeln behandelt, ähnlich wie es bei einem Vektor der Fall wäre. Mittelwerte von Arrays oder Teilen von Arrays kann man mit np.mean(arrayname[anfang:ende]) berechnen.

Kapitel 6 - Differenzieren und Integrieren - ein Solarauto

Wie wir schon in den bisherigen Kapitel gesehen haben, interessieren sich die Systemwissenschaften vor allem für dynamische Systeme, d.h. für Systeme, deren Variablen oder Zustände sich verändern. Um Veränderungen, Bewegungen oder Entwicklungen zu erfassen, stellt die Mathematik eine elaborierte Methodik bereit, die Differential- und Integralrechnung. Beginnend mit diesem Kapitel beschäftigen wir uns mit dieser für die Systemwissenschaften so zentralen Methode, dies allerdings nicht primär aus der Perspektive der Mathematik, sondern eher aus der der Möglichkeiten, die der Computer bietet.

Als Beispiele betrachten wir in diesem Kapitel den Prozess der Stromerzeugung mithilfe eines Solarpanels und den Betrieb eines Elektrofahrzeugs. Wir nehmen - um auch dies in Python kennenzulernen - an, dass dafür das Einlesen und Bearbeiten von Daten aus externen Dateien notwendig ist, d.h. aus Dateien, die wir am Computer gespeichert haben, aber noch nicht im Rahmen des Jupyter-Notebooks berücksichtigt haben (die entsprechenden Dateien findet man hier (Rechtsklick + Ziel speichern unter): solargrob.txt, solarfein.txt und batterie.txt).

Einlesen von Daten

Zuerst interessiert uns hier, wieviel Energie ein Solarpanel liefert. In der Datei solargrob.txt wurde mitprotokolliert, wie viel Leistung das Panel zu welcher Stunde der Tageszeit liefert. Wir benötigen also eine Methode, wie wir diese Datei, bzw. den Inhalt dieser Datei in ein Jupyter-Notebook laden, um ihn mit Python bearbeiten zu können. Für das so genannte Einlesen von Daten gibt es in Python vorgefertigte Pakete. Eines davon trägt den Namen csv (für comma separated values), und ermöglicht es, Inhalte aus Dateien aufzubereiten.

In einem ersten Schritt laden wir dieses Paket in unser Notebook, zusammen mit dem bereits bekannten Paket matplotlib für wissenschaftliches Zeichnen.

In [1]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

Im nächsten Schritt greifen wir auf die besagte Textdatei zu. (Die Datei solargrob.txt muss dazu im gleichen Ordner wie das Jupyter-Notebook gespeichert sein).

In der Datei sind 24 Einträge, einer für jede volle Stunde eines Messtages. Jeder Eintrag gibt die Leistung des Solarpanels zu dieser Stunde in Watt an. Um eine Datei in Python zu öffnen und ihr einen Namen zu geben (z.B. inputfile) kann man den Befehl with open(filename.txt) as inputfile verwenden. Alle Befehle, die diesen Namen inputfile verwenden sollen, müssen dazu eingerückt werden, ähnlich wie bei einer For-Schleife.

Wir öffnen die Datei und versuchen, ihren Inhalt mit dem print-Befehl auszugeben:

In [2]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

with open('solargrob.txt') as inputfile: #solargrob.txt wird geöffnet und heißt im programm jetzt inputfile
    print(inputfile)                     #inputfile soll geprintet werden
<open file 'solargrob.txt', mode 'r' at 0x000000000A4DCA50>

Wir sehen: der print-Befehl liefert nicht das gewünschte Ergebnis. Wir sehen keine 24 Zahlen, sondern nur die Speicher-Adresse, unter der diese Datei Computer-intern geführt wird. Um den Inhalt der Textdatei zu extrahieren, ist vielmehr ein weiterer Schritt notwendig. Wir müssen über die gesamte Datei, Zeile für Zeile, iterieren und jede Zeile für sich ausgeben. Dazu benutzen wir wieder eine For-Schleife, kombiniert mit dem neuen Befehl csv.reader, der Inhalte aus Dateien liest.

In [3]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

with open('solargrob.txt') as inputfile: # solargrob.txt wird geöffnet und heißt im programm jetzt inputfile
    for row in csv.reader(inputfile):    # für jede Zeile innerhalb von inputfile
        print(row)                       # wird die Zeile ausgegeben
['3.77513454428e-08']
['1.74472887359e-06']
['5.77774851942e-05']
['0.00137095908638']
['0.0233091011429']
['0.283962983903']
['2.47875217667']
['15.503853599']
['69.4834512228']
['223.130160148']
['313.417119033']
['446.481724891']
['1000.0']
['846.481724891']
['513.417119033']
['223.130160148']
['69.4834512228']
['15.503853599']
['2.47875217667']
['0.283962983903']
['0.0233091011429']
['0.00137095908638']
['5.77774851942e-05']
['1.74472887359e-06']

Das Ergebnis ist schon eher das, was wir erwarten. Wir können Zahlen erkennen. Die ersten Zahlen sind allerdings sehr klein (e-08 am Ende einer Zahl bedeutet, dass die vorne stehende Zahl mit 10^-8 multipliziert wird, also mit 0.00000001). Das Maximum der Zahlen liegt beim 13ten Eintrag. Es handelt sich um die Leistungsdaten eines Solarpanels.

Dennoch ist diese Ausgabe noch nicht perfekt, denn die Zahlen stehen innerhalb einiger Sonderzeichen, die uns beim Bearbeiten, also zum Beispiel beim Zeichnen eines Leistungsverlaufs, stören. Der Umstand, dass jeder Eintrag in eckigen Klammern steht, sagt uns, dass es sich bei den Einträgen um Listen handelt mit jeweils nur einem Eintrag. Hätten wir eine Datei mit mehreren Einträgen pro Zeile, wäre das vielleicht eine vernünftige Notation. In unserem Fall hätten wir aber lieber einzelne Einträge, und keine Listen der Länge 1. Wir spezifizieren also, dass wir von der ganze Zeile, immer nur den ersten (und einzigen) Eintrag, also den Eintrag mit dem Index 0 brauchen:

In [4]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

with open('solargrob.txt') as inputfile: # solargrob.txt wird geöffnet und heißt im programm jetzt inputfile
    for row in csv.reader(inputfile):    # für jede Zeile innerhalb von inputfile
        print(row[0])                    # wird der erste Eintrag jeder Zeile ausgegeben
3.77513454428e-08
1.74472887359e-06
5.77774851942e-05
0.00137095908638
0.0233091011429
0.283962983903
2.47875217667
15.503853599
69.4834512228
223.130160148
313.417119033
446.481724891
1000.0
846.481724891
513.417119033
223.130160148
69.4834512228
15.503853599
2.47875217667
0.283962983903
0.0233091011429
0.00137095908638
5.77774851942e-05
1.74472887359e-06

Das sieht nun schon besser aus. Als nächstes sollten wir noch den Datentyp überprüfen. Beim Einlesen von Daten geht Python nämlich oftmals davon aus, dass es sich dabei um Text (einfach gesagt: um Buchstaben, so genannte strings) handelt.

Merke: Der Datentyp gibt an, in welcher Weise ein Datum - etwa eine Zahl oder ein Buchstabe - am Computer gespeichert wird. Gewöhnlich können Programmiersprachen den Datentyp nicht selbständig erkennen. Eine '1' kann zum Beispiel als Buchstabe (string) oder als ganze Zahl (integer) oder auch als relle Zahl (float) abgespeichert sein. Was für uns nahezu identisch aussieht und leicht in seiner Bedeutung verstanden wird, sind für den Computer drei völlig verschiedene Objekte.

Deswegen sollten wir den Typ der Variablen, die ausgegeben werden soll, mit dem Befehl type abfragen. Da uns hier, um den Typ festzustellen, auch schon wenige Einträge reichen, begrenzen wir unsere Ausgabe mithilfe eines Zählers c und einer if-Abfrage auf fünf.

In [5]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

c = 0
with open('solargrob.txt') as inputfile:
    for row in csv.reader(inputfile):
        if c < 5:
            print(row[0])
            print(type(row[0]))     # type ermittelt den Typ einer Variable
        c = c + 1  
3.77513454428e-08
<type 'str'>
1.74472887359e-06
<type 'str'>
5.77774851942e-05
<type 'str'>
0.00137095908638
<type 'str'>
0.0233091011429
<type 'str'>

Wie vermutet, sind die Daten als Text (d.h. als <type 'str'>) abgespeichert, nicht aber als Zahlen, mit denen man rechnen kann. Wir müssen diesen Text also zuerst in Fließkomma- (sprich reelle) Zahlen verwandeln, damit wir damit arbeiten können. Dies geschieht in Python mit dem Befehl float (Wir demonstrieren dies neuerlich nur für die ersten fünf Einträge und verwandeln die restlichen Einträge dann erst im nächsten Schritt unten in reelle Zahlen):

In [6]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

c = 0
with open('solargrob.txt') as inputfile:
    for row in csv.reader(inputfile):
        if c < 5:
            print(float(row[0]))    # float konvertiert den Text in eine Zahl
            print(type(float(row[0])))
        c = c + 1  
3.77513454428e-08
<type 'float'>
1.74472887359e-06
<type 'float'>
5.77774851942e-05
<type 'float'>
0.00137095908638
<type 'float'>
0.0233091011429
<type 'float'>

Als letzten Schritt unserer Datenvorbehandlung möchten wir die Zahlen nicht einfach nur auf den Bildschirm schreiben, sondern in einer Liste speichern, damit wir damit auch wirklich arbeiten können. Wir erstellen also eine leere Liste und füllen sie sodann mit den umgewandelten Einträgen. Erst dann lesen wir die gesamte Liste mithilfe des print-Befehls aus:

In [7]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

solargrob = [] # eine leere Liste wird erstellt

with open('solargrob.txt') as inputfile:
    for row in csv.reader(inputfile):
        solargrob.append(float(row[0])) #   der erste Eintrag jeder Zeile wird als Zahl an die Liste angehängt
print(solargrob)   # die gesamte Liste wird gedruckt
[3.77513454428e-08, 1.74472887359e-06, 5.77774851942e-05, 0.00137095908638, 0.0233091011429, 0.283962983903, 2.47875217667, 15.503853599, 69.4834512228, 223.130160148, 313.417119033, 446.481724891, 1000.0, 846.481724891, 513.417119033, 223.130160148, 69.4834512228, 15.503853599, 2.47875217667, 0.283962983903, 0.0233091011429, 0.00137095908638, 5.77774851942e-05, 1.74472887359e-06]

Nun können wir mit diesen Messdaten arbeiten. Wir können zum Beispiel einen Plot erstellen, der uns zeigt zu welcher Tageszeit das Solarpanel wie viel Leistung erbringt. Dazu können wir den Plotbefehl plt.fill benutzen, der im Unterschied zu plt.plot die Fläche unter einer Kurve miteinfärbt.

In [8]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

solargrob = []

with open('solargrob.txt') as inputfile:
    for row in csv.reader(inputfile):
        solargrob.append(float(row[0]))
        
plt.fill(range(24), solargrob, color = "orange") # fill funktioniert ähnlich wie plot, nur wird die Fläche gefüllt
plt.ylabel("Leistung / Watt")
plt.xlabel("Zeit / Stunden")
plt.xlim(0, 23)
Out[8]:
(0, 23)

Numerisches Integrieren

Wir können nun den zeitlichen Verlauf der Leistung deutlich erkennen. Zur Spitzenzeit um 12h liefert das Panel 1000 Watt (W), also genau 1 Kilowatt (kW). Was aber ist seine Gesamtleistung über 24 Stunden hinweg?

Wenn das Panel 24 Stunden lang stets 1000 Watt liefern würde, wäre die Berechnung der Gesamtleistung einfach. Die wäre dann einfach

$$1 kW * 24 h = 24 kWh$$

Wie wir im Plot sehen können, ist die Leistung aber zu jeder Tageszeit anders. Wir können also nicht 24 mal den gleichen Wert aufsummieren, um zur Gesamtleistung zu kommen. Wir müssen vielmehr für jede Stunde den Wert berücksichtigen, der in unserer Liste gespeichert ist. Wir haben in dieser Liste 24 Einträge und kennen somit die Leistung zu jeder Stunde der Tageszeit. Offensichtlich sollten wir also einfach die Einträge unserer Liste aufsummieren können, um die Tagesgesamtleistung zu bestimmen. Dieses Aufsummieren bedeutet praktisch, dass wir damit die gelb gezeichnete Fläche unter der Kurve näherungsweise bestimmen. (Nur näherungsweise deswegen, weil wir ja genaugenommen nur die Leistungsdaten zu jeder vollen Stunde berücksichtigen und nicht zB um 12.30h. Siehe dazu gleich unten).

Zum Berechnen der Fläche unter einer Kurve verwendet die Mathematik die Integralrechnung. Unser "Aufsummieren einer Liste" entspricht diesem Zweck. Man spricht diesbezüglich von der Methode der numerischen Integration.

Python stellt für diesen Vorgang des Aufsummierens den einfachen Befehl sum zur Verfügung. Im Folgenden benutzen wir diesen Befehl, um festzustellen, wieviel Leistung das Solarpanel über den Tag hinweg liefert:

In [9]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

solargrob = []

with open('solargrob.txt') as inputfile:
    for row in csv.reader(inputfile):
        solargrob.append(float(row[0]))

gesamtleistung = sum(solargrob) #sum addiert alle Elemente der Liste = numerische Integration
print(gesamtleistung)
3741.60752731

In Summe produziert unser Solarpanel also etwa 3741 Wattstunden an einem Tag. Dieses Resultat ist freilich noch sehr ungenau. Warum? Wie gesagt, gehen wir bisher in dieser Betrachtung davon aus, dass es für jede Stunde des Tages einen fixen Leistungswert gibt. Und wir nehmen implizit an, dass dieser Wert innerhalb dieser Stunde konstant bleibt. Das ist natürlich in der Realität nicht der Fall.

Wir können den Effekt unserer Vereinfachung mit einem so genannten Bar-Plot darstellen. Damit können wir die Daten in Form von Rechtecken darstellen, also genau so, wie auch unser Aufsummieren funktioniert. Man sieht sofort, dass das nur eine Näherungslösung sein kann:

In [10]:
plt.bar(range(24), solargrob, width = 1.0, color = "orange")
plt.xlim(0, 23)
plt.ylabel("Leistung / Watt")
plt.xlabel("Zeit / Stunden")
Out[10]:
<matplotlib.text.Text at 0xad61630>

Erhöhen der Genauigkeit

Um die Genauigkeit unserer Ergebnisse zu erhöhen, haben wir prinzipiell zwei Möglichkeiten. Entweder wir finden bessere Integrationsmethoden oder wir erhöhen einfach die Zeitauflösung unserer Messdaten.

Hier machen wir Zweiteres. Glücklicherweise können wir auf genauere Daten zurückgreifen. Zusätzlich zu der Datei, die wir schon eingelesen haben, liegt eine Datei vor, die minuten-genaue Daten enthält, die also 24 * 60 = 1440 Einträge hat. Im Folgenden lesen wir auch diese Datei in unseren Python-Code ein und erstellen einen Plot.

(Die Umrechnung von Stunden in Minuten muss hier nicht vorgezogen werden. Der Befehl range(1440) kann auch als range(24 * 60) geschrieben werden.)

In [11]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

solarfein = []
with open('solarfein.txt') as inputfile:
    for row in csv.reader(inputfile):
        solarfein.append(float(row[0]))

plt.fill(range(24 * 60),solarfein, color = "orange")
plt.ylabel("Leistung / Watt")
plt.xlabel("Zeit / Minuten")
plt.xlim(0, 24 * 60)
Out[11]:
(0, 1380)

In dieser Auflösung sehen wir eine Reihe von Details, zum Beispiel, die Bewölkung, die offensichtlich den Vormittag kurz getrübt hat.

Es liegt nun nahe, auch mit diesen Daten eine numerische Integration durchzuführen, um die Gesamtleistung zu berechnen. Aber Vorsicht! Mit den gröberen Daten hatten wir 24 Zahlen aufsummiert, die größte davon war 1000. Nun wollen wir 1440 Zahlen aufsummieren, von denen wiederum die größte 1000 ist. Das kann kein ähnliches Ergebnis liefern. Offensichtlich haben wir etwas übersehen?

Für die gröberen Daten war unsere Argumentation wie folgt: Wenn das Solarpanel eine Stunde lang 1000 Watt liefert, produziert es eine Kilowattstunde. Deswegen war es zulässig, die Werte in der Liste einfach aufzuaddieren.

Nun liegen uns aber minutengenaue Daten vor. Die Aussage "Wenn ein Solarpanel eine Minute lang 1000 Watt liefert, produziert es eine Kilowattstunde." wäre falsch. Richtig ist: "Wenn ein Solarpanel eine Minute lang 1000 Watt liefert, produziert es eine Kilowattminute, also ein Sechzigstel einer Kilowattstunde."

Die Werte, die in unserer Liste stehen haben also die falschen Einheit. Gegeben sind Kilowattminuten, wir hätten aber gerne Kilowattstunden, damit wir das Ergebnis der numerischen Integration (des Aufsummierens) mit unserer ursprünglichen Berechnung vergleichen können. Wir müssen diesen Umrechnungsfaktor (1/60) also miteinbeziehen und können erst danach summieren. Dazu erstellen wir eine For-Schleife, die uns jeden Wert der ursprünglichen Liste umrechnet und in einer neuen Liste speichert. Wie lange muss diese Schleife nun aber laufen? Das kommt auf die Länge von solarfein an. Diese Länge bekommen wir mit len(solarfein):

In [5]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

solarfein = []
with open('solarfein.txt') as inputfile:
    for row in csv.reader(inputfile):
        solarfein.append(float(row[0]))

solarfeinumgerechnet = []

for it in range(len(solarfein)):
    solarfeinumgerechnet.append(solarfein[it]/60) # jeder Wert wird durch 60 dividiert und dann an die neue Liste gehängt
    
gesamtleistungfein = sum(solarfeinumgerechnet)
print(gesamtleistungfein)
4086.1419317488526

Wir sehen also, dass unser ursprüngliches Ergebnis (3.7 kW) noch relativ weit vom genaueren Ergebnis (4.1 kW) entfernt war. Außerdem fällt uns bei Betrachtung des Plots auf, dass der Großteil des Stroms zur Mittagszeit produziert wird. Interessant wäre nun etwa zu wissen, wie viel Prozent des Gesamtstroms wirklich zwischen 11 und 13 Uhr erzeugt werden.

Dazu erstellen wir abermals eine Summe, integrieren also numerisch. Diesmal wollen wir aber nicht das Integral über den gesamten Zeitraum, sondern nur über einen Teil. Dazu wählen wir diesen Teil der Liste aus. Wie geschieht dies?

Wir haben schon gelernt, dass wir einzelne oder mehrere Einträge einer Liste addressieren können. listenname[4] liefert zum Beispiel den Eintrag mit dem Index 4, also den 5-ten Eintrag einer Liste. Alle Elemente von 4 (einschließlich) bis 20 (ausschließlich) erhält man zum Beispiel durch listenname[4:20]. Dieses Wissen können wir nun nutzen, um die Stromproduktion zur Mittagszeit zu berechnen:

In [13]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

solarfein = []
with open('solarfein.txt') as inputfile:
    for row in csv.reader(inputfile):
        solarfein.append(float(row[0]))

solarfeinumgerechnet = []

for wert in solarfein:
    solarfeinumgerechnet.append(wert/60)
    
gesamtleistungfein = sum(solarfeinumgerechnet) #gesamte Summe
print(gesamtleistungfein)

gesamtleistungmittag = sum(solarfeinumgerechnet[11 * 60 : 13 * 60]) #Summe der Werte zwischen 11 und 13 Uhr
print(gesamtleistungmittag)

relation = gesamtleistungmittag / gesamtleistungfein * 100       #Berechnen des prozentuellen Anteils
print("Relation in Prozent:")
print(relation)
4086.14193175
1894.1770029
Relation in Prozent:
46.3561235644

Etwa 46% des Gesamtstroms unseres Solarpanels werden also zur Mittagszeit produziert. Elektrische Geträte ausschließlich über das Solarpanel zu betreiben, scheint damit nicht ratsam, da diese Geräte nur zu Mittag gut funktionieren würden. Es könnte also vorteilhaft sein, den zu Mittag produzierten Strom in einer Batterie zu speichern, und dann wieder abzugeben, wann er gebraucht wird.

Wir könnten so zum Beispiel ein kleines Elektroauto betreiben.

Sehen wir uns diese Möglichkeit und speziell den Verbrauch dieses Fahrzeugs genauer an.

Verbrauch eines Elektrofahrzeugs

Zusätzlich zur Stromproduktion eines Solarpanels haben wir auch die Messdaten des Stromverbrauchs eines kleinen Elektrofahrzeugs zur Verfügung (laden sie die Datei batterie.txt aus dem Moodle-Repositorium herunter und speichern sie sie im Notebook-Ordner). Für eine Testfahrt wurde das Auto voll aufgeladen und dann nach jedem gefahrenen Meter der aktuelle Batteriestand protokolliert. Das Ergebnis sind zig-tausende Datenpunkte, die wir nicht mehr einfach per Hand auswerten können. In einem ersten Schritt wollen wir die Daten wieder in unseren Python-Code einlesen und grafisch darstellen:

In [14]:
import csv
import matplotlib.pyplot as plt
%matplotlib inline

batterie = []
with open('batterie.txt') as inputfile:
    for row in csv.reader(inputfile):
        batterie.append(float(row[0]))
        
    
plt.plot(batterie, color = 'crimson', lw = 1.5)
plt.ylabel("Ladung / Wattstunden")
Out[14]:
<matplotlib.text.Text at 0xaf485c0>

Anhand dieses Plots lässt sich schon einiges Über das Testfahrzeug sagen. Bei voller Batterie besitzt es eine Ladung von 16000 Wattstunden, also 16 Kilowattstunden. Um voll aufgeladen zu sein, müsste es also etwa 4 Tage lang über unser Solarpanel laden.

Außerdem sehen wir, dass das Auto bei der Testfahrt genau 100000 Meter, also 100 Kilometer zurückgelegt hat. Nach dieser Distanz beträgt die verbleibende Ladung etwa 3 Kilowattstunden, es wurden also 13 Kilowattstunden verbraucht. Durch simple Division können wir feststellen, dass der mittlere Verbrauch des E-Autos etwa 13 Kilowattstunden auf 100 Kilometer, also 0.13 Kilowattstunden auf 1 Kilometer, beträgt.

Ebenfalls fällt auf: Der Verbrauch ist nicht durchgehend gleich. Manchmal sinkt der Batteriestand schneller, manchmal langsamer. Das kann mehrere Gründe haben: starkes Beschleunigen, aber auch das Halten einer hohen Geschwindigkeit erhöhen den Verbrauch.

Wir könnten uns nun also fragen, an welchen Abschnitten der Teststrecke der Verbrauch besonders hoch, bzw. besonders gering war. Dazu müssen wir allerdings definieren, dass mit Verbrauch die Ladungsverlust-Rate der Batterie gemeint ist. Grafisch ausgedrückt: Wie steil ist die Kurve der Funktion, die den Batterieverbrauch anzeigt. Mathematisch augedrückt: Wie groß ist die Änderung bzw. die Ableitung der Funktion an jedem Punkt.

Das heißt, wir suchen die Ableitung der Funktion "Batterieladung". Die Funktion selbst sagt uns wie viel Ladung in der Batterie ist, die Ableitung sagt uns, wie sich diese Ladung verändert. Sie zeigt uns den Verbrauch. Mathematisch nennt man die Methode, um diesen Verbrauch zu ermitteln, numerisches Differenzieren.

Numerisches Differenzieren

Die Steigung einer Kurve (einer Funktion) auszurechnen, ist am Computer nicht schwierig. Wir kennen die Batterieladung an jedem Punkt unserer Teststrecke. Die Änderung der Batterieladung errechnet sich dann aus der Ladung an diesem Punkt minus der Ladung am unmittelbar nachfolgenden Punkt.

Auch für diese Art des numerischen Differenzierens gibt es einen Python-Befehl, der sich, zusammen mit vielen anderen Befehlen zur numerischen Berechnung im Paket numpy (numerical python) findet. Wir importieren dieses Paket und benutzen den Befehl diff um die Ableitung der Ladung, also den Verbrauch, auszurechnen.

Dabei ist es wichtig, auf das Vorzeichen zu achten: die Steigung unserer Kurve ist natürlich negativ (die Ladung wird immer weniger), der Verbrauch ist aber als positive Größe definiert. Man sagt

"Mein Auto verbraucht 5 Liter pro 100 Kilometer."

und nicht

"Der Tank meines Autos verändert sich um -5 Liter auf 100 Kilometer."

Der von uns gesuchte Verbrauch berechnet sich also als "Steigung mal -1". Im Folgenden zeichnen wir die Plots für die Batterieladung und für den Batterieverbrauch übereinander.

In [15]:
import numpy as np
import csv
import matplotlib.pyplot as plt
%matplotlib inline

batterie = []
with open('batterie.txt') as inputfile:
    for row in csv.reader(inputfile):
        batterie.append(float(row[0]))
              
plt.plot(batterie, color = 'crimson', lw = 1.5)
plt.ylabel("Ladung / Wattstunden")
plt.title('Batterieladung')

verbrauch = -1 * np.diff(batterie)   # np.diff berechnet die Ableitung, also die Steigung. Der Verbrauch ist -1 mal Steigung
plt.figure()
plt.plot(verbrauch, color = 'crimson', lw = 1.5)
plt.ylabel("Verbrauch / Wattstunden pro Meter")
plt.xlabel("Strecke / Meter")
plt.title('Batterieverbrauch')
Out[15]:
<matplotlib.text.Text at 0xae01978>

Im direkten Vergleich sehen wir, wie aussagekräftig der Verbrauch im Vergleich zur Ladung ist. In der Verbrauchskurve können wir direkt ablesen, dass der Verbrauch meistens etwa 0.12 Wattstunden pro Meter (= 0.12 Kilowattstunden pro Kilometer) ist. Außerdem sehen wir sehr genau an welchen Stellen der Verbrauch erhöht, bzw. gering ist. Der höchste Verbrauch auf dieser Teststrecke lag bei über 0.30 Wattstunden pro Meter.

Numerisches Integral

Die Beziehung, in der in diesem Beispiel Ladung und Verbrauch der Batterie stehen, entsprich mathematisch der von Integral und Differential. Ersteres gibt einen Bestand an (Engl. "stock"), hier den Bestand an bestimmten Punkten der Wegstrecke, zweiteres bezeichnet eine Veränderung (Engl. "flow"), bzw. eine Veränderungsrate, über diese Punkte der Wegstrecke.

So wie der Verbrauch aus der Information zur Ladung an verschiedenen Punkten der Wegstrecke errechnet werden kann, so kann auch aus dem Verbrauch zurück auf die Ladung geschlossen werden. Dazu wird wieder integriert. Konzeptionell ist dies in diesem Fall allerdings etwas Anderes, als das Integral, das beim Solarpanel benutzt wurde. Beim Solarpanel haben wir die Fläche unter der Kurve errechnet. Es handelte sich da um ein so genanntes bestimmtes Integral, das als Lösung eine Zahl liefert (z.B. die Gesamtstromproduktion).

Hier hatten wir allerdings die momentane Ladung zu jedem Zeitpunkt gegeben. Wir suchen hier also keine Fläche, sondern die so genannte Stammfunktion des Verbrauchs, also die Ladung. Es handelt sich hierbei um ein unbestimmtes Integral, das als Lösung eine Funktion hat.

Das Paket numpy enthält einen Befehl, der die Stammfunktion einer Funktion über eine Methode berechnet, die sich "kummulative Summe" nennt. Vereinfacht gesagt, wird dabei zu jedem Zeitschritt errechnet, wie groß der gesamte Verbrauch zu allen vorhergehenden Zeitschritten war. Der Befehl dazu lautet cumsum und liefert als Ergebnis wieder eine Liste. Versuchen wir damit aus dem Verbrauch zurück auf die Ladung zu rechnen:

In [16]:
import numpy as np
import csv
import matplotlib.pyplot as plt
%matplotlib inline

batterie = []
with open('batterie.txt') as inputfile:
    for row in csv.reader(inputfile):
        batterie.append(float(row[0]))
        
# 1. die Ladung               
# plt.plot(batterie, color = 'crimson', lw = 1.5)
# plt.ylabel("Ladung / Wattstunden")
# plt.title('Batterieladung')

# 2. der Verbrauch
verbrauch = -1 * np.diff(batterie)
plt.figure()
plt.plot(verbrauch, color = 'crimson', lw = 1.5)
plt.ylabel("Verbrauch / Wattstunden pro Meter")
plt.xlabel("Strecke / Meter")
plt.title('Batterieverbrauch')

# 3. und wieder die Ladung
batterie2 = np.cumsum(-verbrauch) # np.cumsum berechnet die kumulative Summe, um die Stammfunktion zu bestimmen
plt.figure()
plt.plot(batterie2, color = 'crimson', lw = 1.5)
plt.ylabel("Ladung / Wattstunden")
plt.xlabel("Strecke / Meter")
plt.title('Batterieladung')
Out[16]:
<matplotlib.text.Text at 0xcc57ba8>

Ausgehend vom Verbrauch ist es uns hier also gelungen, den Ladestand der Batterie an den einzelnen Punkten der Wegstrecke zu rekonstruieren.

Eine wichtige Information ist uns dabei allerdings verloren gegangen: wenn wir nur den Verbrauch in Betracht ziehen, können wir nicht sagen, wie viel Ladung zu Beginn in der Batterie war. In der vorliegenden Berechnung geht unser Integral davon aus, dass die Batterie am Anfang der Wegstrecke die Ladung 0 hatte, womit die Ladung insgesamt negativ wird.

Dies ist kein grundsätzlicher Fehler, sondern eine intrinsische Eigenschaft der Integralrechnung: bei Berechnung eines unbestimmten Integrals, ist das Ergebnis immer nur bis auf eine Konstante korrekt. Diese Konstante nennt man in der Mathematik "Integrationskonstante". Es ist zu beachten, dass es eine solche Konstante gibt und ein unbestimmtes Integral zwar immer die korrekte Funktion liefert, der Startwert dieser Funktion aber von Null verscheiden sein kann.

Zusammenfassung

Einlesen von Daten

Um Daten aus einer Datei einzulesen, kann unter anderem das Paket csv wie folgt benutzt werden:

In [5]:
import csv
datenliste = []

with open('solargrob.txt') as inputfile:
    for row in csv.reader(inputfile):
        datenliste.append(float(row[0]))

Die entstehende Liste kann dann beispielsweise innerhalb einer For-Schleife weiterverarbeitet werden.

Auswählen von Listenteilen

Um aus einer Liste einen Teil der Einträge auszuwählen, steht der Befehl listenname[anfang:ende] zur Verfügung. Um beispielsweise die ersten 10 Elemente der Liste solarstrom auszuwählen, schreibt man solarstrom[0:10] (Vorsicht: man erhält damit die Einträge einschließlich des Elements solarstrom[0], aber ausschließlich des Elements solarstrom[10]).

Numerisches Integrieren

Um die Fläche unter einer Kurve zu berechnen, also ein bestimmtes Integral, steht der Befehl sum zur Verfügung. Das Ergebnis eines bestimmten Integrals ist eine Zahl. Wichtig dabei ist es, die richtigen Einheiten zu verwenden, bzw. in geometrischer Interpretation, zusätzlich zur Höhe der Rechtecke, die aufsummiert werden, auch die Breite korrekt zu bestimmen (also ob ein Rechteck eine Wattstunde breit ist, oder nur 1/60 Wattstunde).

Zusätzlich zum bestimmten Integral kann auch das unbestimmte Integral berechnet werden. Das unbestimmte Integral liefert als Lösung immer eine Funktion, also in unserem Fall eine Liste. Zum Berechnen eines unbestimmten Integrals, also einer Stammfunktion, steht der Befehl cumsum aus dem Paket numpy zur Verfügung. Die resultierende Stammfunktion ist aber immer nur bis auf eine sogenannte Integrationskonstante korrekt.

Numerisches Differenzieren

Um die Ableitung (also die Steigung in jedem Punkt) einer Funktion oder Zeitreihe zu berechnen, um also zu differenzieren, steht der Befehl diff aus dem Paket numpy zur Verfügung. So wie im vorliegenden Beispiel aus der Batterieladung der Verbrauch errechnet wurde, so lässt sich analog zum Beispiel auch ein zurückgelegter Weg in Geschwindigkeit oder eine Geschwindigkeit in Beschleunigung umrechnen.

Kapitel 7 - Zufallszahlen - ein Energiemix

In den bisher betrachteten Beispielen systemwissenschaftlicher Modellbildung sind wir davon ausgegangen, dass wir die Daten, die wir dafür benötigen, kennen, dass wir also zum Beispiel wissen, wie groß die Wachstumsrate oder auch die Anfangsgröße einer Tierpopulation ist. Dies ist leider aber nicht immer der Fall. Oftmals haben wir bestenfalls eine mehr oder wenigerer gute Vorstellung über ein Intervall, innerhalb dessen sich die benötigten Daten bewegen. In diesen Fällen können wir Zufallszahlen heranziehen, um unser Modell an realistische Gegebenheiten anzunähern. Zum Glück stellt uns auch dafür Python die nötigen Werkzeuge zur Verfügung.

Um dies zu demonstrieren, wollen wir uns im Folgenden mit einem Energienetzwerk beschäftigen, bestehend aus Wind-, Wasser- und Kohlekraftwerken. Wir werden die Produktion dieser Kraftwerke dem Verbrauch gegenüberstellen und Strategien finden, wie man den Bedarf möglichst gut decken kann, und möglichst wenig fossile Brennstoffe verwenden zu müssen.

Wir beginnen unser diesbezügliches Modell ganz einfach mit einer For-Schleife, die Stunde für Stunde über den Simulationszeitraum iteriert. In dieses Grundgerüst bauen wir auch schon ein Wasserkraftwerk ein, das konstant Strom liefert. Um den produzierten Strom grafisch darzustellen, verwenden wir einen so genannten Stack-Plot:

In [6]:
import matplotlib.pyplot as plt 
%matplotlib inline 

simulationszeit = 100
wasserkraft_liste = []

for zeit in range(simulationszeit):
    wasserkraft = 100
    wasserkraft_liste.append(wasserkraft)

plt.stackplot(range(simulationszeit), wasserkraft_liste)
Out[6]:
[<matplotlib.collections.PolyCollection at 0x1ef1f5463c8>]

Ein Wasserkraftwerk, das konstant die selbe Menge Strom liefert ist zwar parktisch, leider aber nicht realistisch. In der Realität wird es immer kleine Schwankungen geben, die wir auch in unsere Simulation einbauen sollten. Dazu benutzen wir Zufallszahlen.

Für unser Wasserkraftwerk nehmen wir an, dass die Leistung meistens etwa 100 MW beträgt. Kleine Abweichungen werden häufiger sein, große Abweichungen seltener. Wir haben es also mit einer so genannten Normal- oder auch Gauß-Verteilung zu tun. Gauß-verteilte Zufallszahlen generiert man in Python mit dem Befehl random.gauss(mittelwert, standardabweichung) aus dem Packet random, das wir zunächst importieren müssen. Als Mittelwert verwenden wir 1 (mal 100 MW), als Standardabweichung können wir 0.1 benutzen (also 10%). Vereinfacht gesagt heißt das, dass die Zahlen, die damit generiert werden, im Durchschnitt 10% vom Mittelwert entfernt liegen, und zwar, je größer die Abweichung ist, umso seltener.

In [7]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

simulationszeit = 100
wasserkraft_liste = []

for zeit in range(simulationszeit):
    wasserkraft = 100 * random.gauss(1, 0.1)
    wasserkraft_liste.append(wasserkraft)

plt.stackplot(range(simulationszeit), wasserkraft_liste)
Out[7]:
[<matplotlib.collections.PolyCollection at 0x1ef1f5cfe10>]

Neben einem Wasserkraftwerk möchten wir nun auch ein Windkraftwerk in unser Stromnetz einbauen. Die Leistung von Windkraftwerken ist stärker vom Zufall abhängig: bei Windstille produzieren sie gar nichts, aber auch zu starker Wind macht ihnen Probleme. Ein Gauß-Verteilung ist hier also nicht sinnvoll, wenn wir abbilden wollen, dass ein Windkraftwerk hin und wieder auch keinen Strom liefert.

Wir können dazu aber gleich-verteilte Zufallszahlen heranziehen. Bei gleichverteilten Zufallszahlen ist jede Zahl gleich wahrscheinlich. Der folgende Plot illustriert den Unterschied zwischen Gauß-verteilten und gleich-verteilten Zufallszahlen. Wir verwenden den Befehl random.uniform(0, 20), um reelle Zahlen zwischen 0 und (ausschließlich) 20 zu erzeugen. Diese werden zu 90 hinzu addiert, um eine ebenfalls um 100 variierende Verteilung zu erzeugen. Zum Plotten verwenden wir hier den Befehl hist, der ein Histogramm der erzeugten Daten zeichnet.

In [21]:
gauss=[]
gleich=[]

for it in range(500000):
    gauss.append(random.gauss(100, 10))
    gleich.append(90 + random.uniform(0, 20))

plt.hist(gauss)
plt.title('Gauss-verteilt')

plt.figure()
plt.hist(gleich);
plt.title('Gleich-verteilt')    
Out[21]:
<matplotlib.text.Text at 0x178868bbc18>

Unser Windkraftwerk soll eine maximale Leistung von 7 MW besitzen. Bei Windstille kann es aber vorkommen, dass auch gar kein Strom geliefert wird. Wir verwenden für die Leistung des Windkraftwerks also eine Zufallszahl zwischen 0 und 7, die wir mit dem Befehl random.uniform() erzeugen. Pythons Stackplot liefert uns die bequeme Möglichkeit, die zusätzlich zu den 100% Wasserkraft erzeugte Windenergie einfach grafisch auf die Wasserkraft auf zu addieren. Zur Unterscheidung der beiden Energiearten verwenden wir zwei unterschiedliche Blautöne: darkblue und skyblue.

In [8]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

simulationszeit = 100
wasserkraft_liste = []
windkraft_liste = []

for zeit in range(simulationszeit):
    wasserkraft = 100 * random.gauss(1, 0.1)
    wasserkraft_liste.append(wasserkraft)
    windkraft = random.uniform(0, 7)
    windkraft_liste.append(windkraft)

# '#cceeff' steht für ein etwas helleres blau
plt.stackplot(range(simulationszeit), wasserkraft_liste, windkraft_liste, colors = ('darkblue', 'skyblue'))
Out[8]:
[<matplotlib.collections.PolyCollection at 0x1ef1f61fb38>,
 <matplotlib.collections.PolyCollection at 0x1ef1f66e860>]

Wir sehen, dass der Beitrag eines einzelnen Windkraftwerks, im Vergleich zu einem Wasserkraftwerk, nur eher wenig ausmacht. Wir sollten unser Modell also ausbauen, sodass wir die Zahl der jeweiligen Kraftwerke bestimmen können. Das wird es uns später erlauben zu bestimmen, wie viele Kraftwerke wir von welchem Typ benötigen.

Wir gehen hier im Folgenden zunächst von 10 Windkraftwerken und einem Wasserkarftwerk aus.

In [9]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

simulationszeit = 100
windkraft_anzahl = 10
wasserkraft_anzahl = 1

wasserkraft_liste = []
windkraft_liste = []

for zeit in range(simulationszeit):
    wasserkraft = wasserkraft_anzahl * 100 * random.gauss(1, 0.1)
    wasserkraft_liste.append(wasserkraft)
    windkraft = windkraft_anzahl * random.uniform(0, 7)
    windkraft_liste.append(windkraft)

plt.stackplot(range(simulationszeit), wasserkraft_liste, windkraft_liste, colors=('darkblue', 'skyblue'))
Out[9]:
[<matplotlib.collections.PolyCollection at 0x1ef1f6c2828>,
 <matplotlib.collections.PolyCollection at 0x1ef1f705c88>]

Sollten wir nun als Vorgabe haben, dass zu jeder Zeit mindestens 150 MW Strom verfügbar sein sollen, so sehen wir, dass die Anzahl unserer Windkraftwerke noch nicht ausreicht:

In [10]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

simulationszeit = 100
windkraft_anzahl = 10
wasserkraft_anzahl = 1

wasserkraft_liste = []
windkraft_liste = []

for zeit in range(simulationszeit):
    wasserkraft = wasserkraft_anzahl * 100 * random.gauss(1, 0.1)
    wasserkraft_liste.append(wasserkraft)
    windkraft = windkraft_anzahl * random.uniform(0, 7)
    windkraft_liste.append(windkraft)

plt.stackplot(range(simulationszeit), wasserkraft_liste, windkraft_liste, colors = ('darkblue', 'skyblue'))
# zeichne eine rote Linie in der Höhe von 150
plt.plot((0, 100),(150, 150), c = "red", lw = 2)
Out[10]:
[<matplotlib.lines.Line2D at 0x1ef1f7abb70>]

Wir müssen die Anzahl an Windkraftwerken also noch ein wenig erhöhen:

In [11]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

simulationszeit = 100
windkraft_anzahl = 30
wasserkraft_anzahl = 1



wasserkraft_liste = []
windkraft_liste = []

for zeit in range(simulationszeit):
    wasserkraft = wasserkraft_anzahl * 100 * random.gauss(1, 0.1)
    wasserkraft_liste.append(wasserkraft)
    windkraft = windkraft_anzahl * random.uniform(0, 7)
    windkraft_liste.append(windkraft)

plt.stackplot(range(simulationszeit), wasserkraft_liste, windkraft_liste, colors = ('darkblue', 'skyblue'))
plt.plot((0, 100), (150, 150), c = "red", lw = 2)
Out[11]:
[<matplotlib.lines.Line2D at 0x1ef1f846eb8>]

Wir sehen, dass, auch wenn wir 30 Windkraftwerke vorsehen und wir damit zu manchen Zeiten doppelt so viel Strom produzieren wie benötigt, es immer noch Zeiten gibt, zu denen wir die 150 MW Marke nicht erreichen. Wo liegt das Problem?

Das Problem liegt in der Eigenart der Windenergie. Bei Windstille produziert keines der 30 Windkraftwerke Strom. Wir haben beim Erstellen unseres Modells implizit angenommen, dass alle Windkraftwerke in der selben Region liegen und damit von den selben Windverhältnissen betroffen sind. In unserem Programmcode verwenden alle Kraftwerke die gleiche Zufallszahl: Wenn also in einer Stunde "Windstille" gewürfelt wird, dann gilt das für alle Kraftwerke.

Wir könnten aber auch annehmen, dass die Krafwerke so weit voneinander entfernt stehen, dass es für jedes Kraftwerk andere Windverhältnisse gibt. Aber ändert das etwas? Wird dann im Mittel nicht genau gleich viel Strom produziert?

Probieren wir es aus: Ändern wir unser Programm, sodass für jedes Kraftwerk eine eigene Zufallszahl vorgibt, wieviel Strom gerade produziert wird.

In [12]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

simulationszeit = 100
windkraft_anzahl = 30
wasserkraft_anzahl = 1

wasserkraft_liste = []
windkraft_liste = []

for zeit in range(simulationszeit):
    wasserkraft = wasserkraft_anzahl * 100 * random.gauss(1, 0.1)
    wasserkraft_liste.append(wasserkraft)
    windkraft = 0
    for kraftwerke in range(windkraft_anzahl):
        windkraft = windkraft + random.uniform(0, 7)
    windkraft_liste.append(windkraft)

plt.stackplot(range(simulationszeit), wasserkraft_liste, windkraft_liste, colors = ('darkblue', 'skyblue'))
plt.plot((0, 100), (150, 150), c = "red", lw = 2)
Out[12]:
[<matplotlib.lines.Line2D at 0x1ef1f8e6940>]

Wir sehen: obwohl im Mittel gleich viel Strom produziert wird, macht die Annahme, dass die Windkraftwerke von unterschiedlichen Windverhältnissen betroffen sind, einen großen Unterschied: wenn wir für jedes Kraftwerk eine eigene Zufallszahl erzeugen, mittelt sich das Ergebnis besser. Es gibt weniger Spitzen und weniger Senken, die Stromproduktion ist näher am Mittelwert.

Das ist eine grundlegende Eigenschaft von Zufallszahlen: Wenn man sehr viele davon verwendet, ist das Ergebnis immer sehr nahe am Mittelwert der möglichen Resultate.

Mit dem nun vorliegenden Modell schaut es also so aus, als würden wir sicherstellen können, dass immer 150 MW Strom verfügbar sind. Was passiert aber, wenn wir miteinbeziehen, dass Windkraftwerke einmal defekt werden können? Wie bauen wir so ein Zufallsereignis in unsere Simulation ein?

Zufallsereignisse mit Python

Wir haben bisher gelernt, wie man zufällige Zahlen erstellt. Was aber, wenn wir keine Zahl erzeugen wollen, sondern ein Ereignis - z.B. einen Defekt in einem Windkraftwerk -, das mit bestimmter Wahrscheinlichkeit auftritt? Wir können dazu Zufallszahlen mit If-Abfragen kombinieren.

Dazu benutzen wir den Umstand, dass 10% der Zahlen von 1 bis 100 kleiner oder gleich 10 sind. Und 50% der Zahlen sind kleinergleich 50. Im Code könnten wir also schreiben:

In [27]:
chance = 50
zufallszahl = random.uniform(0, 100)
if zufallszahl <= chance:
    print("KOPF!")
else:
    print("ZAHL!")
KOPF!

Das heißt, wir vergleichen hier die prozentuelle Chance eines Zufallsereignisses mit einer Zufallszahl und können somit sicher sein, dass dieses Ereignis auch mit genau dieser Chance passiert.

Bauen wir so eine Chance in unsere Simulation ein. Wir nehmen an, dass jede Stunde eine 10%-Chance besteht, dass ein Defekt im Windkraftwerk für einen Ausfall der Stromversorgung sorgt:

In [13]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

simulationszeit = 100
windkraft_anzahl = 30
wasserkraft_anzahl = 1

wasserkraft_liste = []
windkraft_liste = []

for zeit in range(simulationszeit):
    wasserkraft = wasserkraft_anzahl * 100 * random.gauss(1, 0.1)
    wasserkraft_liste.append(wasserkraft)
    windkraft = 0
    for kraftwerke in range(windkraft_anzahl):
        if random.uniform(0, 100) <= 10:
            # STÖRUNG: kein Strom wird produziert
            windkraft = windkraft + 0
        else:
            # Normalbetrieb: 
            windkraft = windkraft + random.uniform(0, 7)
    windkraft_liste.append(windkraft)

plt.stackplot(range(simulationszeit), wasserkraft_liste, windkraft_liste, colors = ('darkblue', 'skyblue'))
plt.plot((0, 100), (150, 150), c = "red", lw = 2)
Out[13]:
[<matplotlib.lines.Line2D at 0x1ef1f98b198>]

Wir sehen, dass wir, obwohl es manchmal schon knapp wird, die Stromversorgung meistens noch sicherstellen können. Erhöhen wir aber die Ausfallchance zum Beispiel auf 50%, so sieht die Sache anders aus.

In [14]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

simulationszeit = 100
windkraft_anzahl = 30
wasserkraft_anzahl = 1

wasserkraft_liste = []
windkraft_liste = []

for zeit in range(simulationszeit):
    wasserkraft = wasserkraft_anzahl * 100 * random.gauss(1, 0.1)
    wasserkraft_liste.append(wasserkraft)
    windkraft = 0
    for kraftwerke in range(windkraft_anzahl):
        if random.uniform(0, 100) <= 50:
            # STÖRUNG: kein Strom wird produziert
            windkraft = windkraft + 0
        else:
            # Normalbetrieb: 
            windkraft = windkraft + random.uniform(0, 7)
    windkraft_liste.append(windkraft)

plt.stackplot(range(simulationszeit), wasserkraft_liste, windkraft_liste, colors = ('darkblue', 'skyblue'))
plt.plot((0, 100), (150, 150), c = "red", lw = 2)
Out[14]:
[<matplotlib.lines.Line2D at 0x1ef1fa1bcc0>]

Um noch etwas mehr über Zufallsgeneratoren zu lernen, wollen wir im Folgenden noch ein weiteres Kraftwerk in unser Stromnetz einbauen. Wir nehmen dazu ein eher unzuverlässiges Kohlekraftwerk an, das nur drei Betriebsmodi hat: "Stillstand" (0 MW), "Normalbetrieb" (20 MW) und "Hochbetrieb" (70 MW). In unserem Beispiel treten diese drei Betriebsmodi zufällig auf, kein anderer MW-Wert kann produziert werden.

Wie würden wir das am besten umsetzen? Eine Zufallszahl zwischen 0 und 70 wäre schon einmal falsch, 3 aufeinanderfolgende 33%-Chancen wären auch nicht richtig, da in dem Fall ja mehrere Betriebsmodi auf einmal möglich wären. In Pyhton gibt es glücklichweise eine ganz einfache Lösung für solche Probleme: der Befehl random.choice((a, b, c)) trifft eine zufällige Entscheidung zwischen a, b und c, wobei a, b und c nicht einmal Zahlen sein müssen. Bauen wir damit das Kraftwerk in unsere Simulation ein.

In [18]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

simulationszeit = 100
windkraft_anzahl = 30
wasserkraft_anzahl = 1



wasserkraft_liste = []
windkraft_liste = []
kohlekraft_liste = []

for zeit in range(simulationszeit):
    wasserkraft = wasserkraft_anzahl * 100 * random.gauss(1, 0.1)
    wasserkraft_liste.append(wasserkraft)
    windkraft = 0
    for kraftwerke in range(windkraft_anzahl):
        if random.uniform(0, 100) <= 50:
            # STÖRUNG: kein Strom wird produziert
            windkraft = windkraft + 0
        else:
            # Normalbetrieb: 
            windkraft = windkraft + random.uniform(0, 7)
    windkraft_liste.append(windkraft)
    
    kohlekraft = random.choice((0, 20, 70))
    kohlekraft_liste.append(kohlekraft)

plt.stackplot(range(simulationszeit), kohlekraft_liste, wasserkraft_liste, windkraft_liste, 
              colors = ('black', 'darkblue','skyblue'))
plt.plot((0, 100), (150, 150), c = "red", lw = 2)
Out[18]:
[<matplotlib.lines.Line2D at 0x1ef20c74dd8>]

Freilich gibt es noch Probleme, die wir mit unserer bisherigen Kenntnis von Zufallszahlen noch nicht darstellen können: Nehmen wir etwa an, es gäbe innerhalb des Simulationszeitraumes eine Stunde, in der das Stromnetz eine besondere Last erfährt. Der Verbrauch steigt hierbei auf 200 MW. Wir wissen jedoch nicht, wann genau diese Stunde sein wird, wir wissen nur dass es eine solche Stunde gibt.

Wäre diese Stunde ganz am Anfang der Simulationszeit, wäre es ein einfaches Problem:

In [17]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

simulationszeit = 100
windkraft_anzahl = 30
wasserkraft_anzahl = 1



wasserkraft_liste = []
windkraft_liste = []
kohlekraft_liste = []
verbrauch_liste = []

for zeit in range(simulationszeit):
    verbrauch_liste.append(150)
    wasserkraft = wasserkraft_anzahl * 100 * random.gauss(1, 0.1)
    wasserkraft_liste.append(wasserkraft)
    windkraft = 0
    for kraftwerke in range(windkraft_anzahl):
        if random.uniform(0, 100) <= 50:
            # STÖRUNG: kein Strom wird produziert
            windkraft = windkraft + 0
        else:
            #Normalbetrieb: 
            windkraft = windkraft + random.uniform(0, 7)
    windkraft_liste.append(windkraft)
    
    kohlekraft= random.choice((0, 20, 70))
    kohlekraft_liste.append(kohlekraft)

plt.stackplot(range(simulationszeit), kohlekraft_liste, wasserkraft_liste, windkraft_liste,
              colors = ('black', 'darkblue', 'skyblue'))

#erstes element von verbrauch_liste auf 200 setzen:
verbrauch_liste[0] = 200

plt.plot(verbrauch_liste, c= "red", lw = 2)
Out[17]:
[<matplotlib.lines.Line2D at 0x1ef20bc7e80>]

Was aber, wenn diese überhöhte Energienachfrage einfach irgendwann auftritt? In diesem Fall hilft uns der Python-Befehl random.shuffle(liste). Dieser mischt die Einträge einer Liste durcheinander. Damit kann man nicht nur Karten mischen...

In [32]:
karten = ['Herz 2', 'Herz 3', 'Herz 4','Herz 5','Herz 6','Herz 7']
random.shuffle(karten)
print(karten)
['Herz 3', 'Herz 6', 'Herz 7', 'Herz 2', 'Herz 5', 'Herz 4']

... sondern auch unser oben genanntes Problem lösen:

In [19]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

simulationszeit = 100
windkraft_anzahl = 30
wasserkraft_anzahl = 1

wasserkraft_liste = []
windkraft_liste = []
kohlekraft_liste = []
verbrauch_liste = []

for zeit in range(simulationszeit):
    verbrauch_liste.append(150)
    wasserkraft = wasserkraft_anzahl * 100 * random.gauss(1, 0.1)
    wasserkraft_liste.append(wasserkraft)
    windkraft = 0
    for kraftwerke in range(windkraft_anzahl):
        if random.uniform(0, 100) <= 50:
            # STÖRUNG: kein Strom wird produziert
            windkraft = windkraft + 0
        else:
            # Normalbetrieb: 
            windkraft = windkraft + random.uniform(0, 7)
    windkraft_liste.append(windkraft)
    
    kohlekraft= random.choice((0, 20, 70))
    kohlekraft_liste.append(kohlekraft)

plt.stackplot(range(simulationszeit), kohlekraft_liste, wasserkraft_liste, windkraft_liste,
              colors=('black','darkblue','skyblue'))

# erstes Element der verbrauch_liste auf 200 setzen:
verbrauch_liste[0]=200
# shuffeln der gesamten Liste
random.shuffle(verbrauch_liste)

plt.plot(verbrauch_liste, c = "red", lw = 2)
Out[19]:
[<matplotlib.lines.Line2D at 0x1ef20bf3550>]

Zusammenfassung

Zufallszahlen

Zufallszahlen werden in Python mit dem Paket random generiert. Es gibt verschiedene Befehle, die unterschiedliche Verteilungen von Zufallszahlen erzeugen.

Gleich-verteilte (reelle) Zufallszahlen erstellt man mit random.uniform(a, b), wobei a die kleinste mögliche Zahl ist und b die obere Grenze markiert, bis zu der (aber ausschließlich der) Zufallszahlen erzeugt werden. Alle Zahlen von a bis (exklusive) b treten gleich wahrscheinlich auf. Somit lassen sich auch prozentuelle Wahrscheinlichkeiten leicht implementieren: Da 10% aller Zahlen zwischen 0 und 100 kleiner als 10 sind, und in gleichverteilten Zufallszahlen alle Zahlen gleich wahrscheinlich sind, wird die if-Abfrage if random.uniform(0, 100) < 10 in etwa 10 Prozent der Fälle true liefern.

Normal-verteilte Zufallszahlen erstellt man mit random.gauss(m, s), wobei m den Mittelwert der erzeugten Verteilung angibt und s die Standardabweichung. Der genaue Wertebereich ist also schwer abzuschätzen, auch bei random.gauss(100, 10) ist es theoretisch möglich, einen Wert kleiner als 50 zu bekommen. Die Chance dafür ist aber verschwindend gering. Je näher die Zahl am Mittelwert ist, umso wahrscheinlicher tritt sie auf. Rund 70% der Werte werden im Intervall m $\pm$ s liegen, also in diesem Beispiel zwischen 90 und 110. Etwa 95% liegen im Intervall m $\pm$ 2s (also 80 bis 120) und über 99% im Intervall m $\pm$ 3s (also 70 bis 130).

Bei jedem Aufruf einer Zufallsfunktion wird eine neue Zahl generiert. Wenn man ein Programm mit Zufallszahlen mehrmals hintereinander ausführt, bekommt man im Allgemeinen unterschiedliche Ergebnisse.

Zufallsereignisse Auch zufällige Ereignisse erzeugt man am besten mit Zufallszahlen: Da x% aller Zahlen von 1 bis 100 kleiner sind als x, können wir mit if random.uniform(0, 100) <= chance jede beliebige prozentuelle Chance simulieren.

Random Choice random.choice wählt ein zufälliges Element von mehreren Elementen aus. Jedes Element ist gleich wahrscheinlich und die Elemente müssen nicht zwangsläufig Zahlen sein.

Random Shuffle random.shuffle mischt eine Liste. Die Elemente bleiben gleich, die Position der Elemente ändert sich. Das ist hilfreich, wenn bekannt ist, welcher Wert der Liste wie oft auftritt, die genaue Position des Wertes aber zufällig sein soll.

Histogramme

Listen, die aus vielen Zahlen bestehen, können wir als Histogramme darstellen. Histogramme zeigen uns welche Zahlenbereiche häufiger vorkommen udn welche weniger häufig. Ein großer Balken in einem Hisogramm zeigt als nicht, dass die Zahl groß ist, sondern dass Zahlen aus diesem Bereich häufig sind. Histogramme erstellt man mit plt.hist(NameDerListe)

Kapitel 8 - Vektoren und Matrizen – Die Bewirtschaftung eines Waldes

Eine sehr zentrale Methode für den Umgang mit Daten und Inhalten in den Systemwissenschaften stellt das Rechnen mit Vektoren und Matrizen dar. Python bietet auch hierfür die benötigten Werkzeuge. Um diese kennenzulernen, beschäftigen wir uns im folgenden Beispiel mit der nachhaltigen Bewirtschaftung eines Waldes. Uns interessiert insbesondere, wie der Wald (nach)wächst.

Beginnen wir mit einem ganz einfachen, eindimensionalem Wald-Modell: Wir betrachten also nur eine "Reihe" von Bäumen. In unserem Modell brauchen Bäume Platz um gut wachsen zu können, zwei Bäume dürfen also nie an benachbarten Stellen stehen. Dazu legen wir mit Python eine Liste an, in die wir eine Eins schreiben, wenn sich an einer entsprechenden Stelle ein Baum befindet, und eine Null, wenn nicht. Der Einfachheit halber wechseln wir zunächst einfach ab: Baum, kein Baum, Baum, kein Baum,....

In [1]:
import matplotlib.pyplot as plt 
%matplotlib inline 

wald1d = []

for posx in range(15):
    wald1d.append(1)
    wald1d.append(0)
    
plt.bar(range(len(wald1d)), wald1d, color='green')
Out[1]:
<Container object of 30 artists>

So sieht der Wald aber noch sehr "künstlich" aus. Wir könnten den Wald auch ein wenig zufälliger wachsen lassen: Wir könnten links beginnen und dann Feld für Feld mit einer gewissen Wahrscheinlichkeit einen Baum wachsen lassen:

In [2]:
import matplotlib.pyplot as plt 
# ein Paket zum Erzeugen von und Rechnen mit Zufallszahlen
import random
%matplotlib inline 

wald1d = []


for posx in range(30):
    if random.uniform(0, 100) <= 50:
        wald1d.append(1)
    else:
        wald1d.append(0)
    
plt.bar(range(len(wald1d)), wald1d, color = 'green')
Out[2]:
<Container object of 30 artists>

So sieht der Wald zwar zufällig aus, aber noch nicht richtig natürlich: Bäume wachsen nicht so gerne direkt nebeneinander, da sie sich dabei gegenseitig Schatten machen. Wie können wir diesen Umstand in unser Waldmodell einbauen? Die Wahrscheinlichkeit, dass an einer bestimmten Position ein Baum wachsen kann, hängt scheinbar davon ab, ob an der Position daneben schon ein Baum steht oder nicht. Für die Entscheidung für einen Baum an Position x, brauchen wir also Daten zu Position x - 1.

Dies ist kein Problem. Wir haben unseren ganzen Wald in einer Liste, also mathematisch gesehen in einem Vektor gespeichert. Wir erhalten diese Information also, indem wir einfach das je vorherige Vektorelement betrachten. Nehmen wir an, dass ein Baum nur noch eine 20%-Chance hat zu wachsen, wenn an der vorhergehenden Position schon ein Baum steht. Dagegen könnte die Chance auf 90% steigen, wenn das Nachbarfeld frei ist. Das könnten wir wie folgt in unseren Python-Code einbauen:

In [3]:
import matplotlib.pyplot as plt 
import random
%matplotlib inline 

wald1d = [1]

for posx in range(1, 30):
    # Beachte: das Zeichen == steht in den meisten Programmiersprachen für eine tatsächliche Gleichheit, 
    # während das Zeichen = für eine Zuweisung eines Werte zu einer Variable steht.
    if wald1d[posx - 1] == 1:
        if random.uniform(0,100) <= 20:
            wald1d.append(1)
        else:
            wald1d.append(0)
    else:
        if random.uniform(0,100) <= 90:
            wald1d.append(1)
        else:
            wald1d.append(0)
plt.bar(range(len(wald1d)), wald1d, color = 'green')
Out[3]:
<Container object of 30 artists>

Das wäre also unser ein-dimensionaler Wald. Wir wollen ihn nun auf zwei Dimensionen erweitern.

Bisher haben wir unseren "Wald" in einer Liste, bzw. mathematisch in einem Vektor gespeichert. In der Mathematik ist ein Vektor ein Schema aus untereinanderstehenden Zahlen. Ein ganzer Wald besteht aber in der Regel aus mehreren Reihen von Bäumen. Um ihn darzustellen, benötigen wir für jede Baumreihe einen eigenen Vektor. Einfacher und übersichtlicher ist es, wenn wir diese Zahlenreihen nebeneinanderschreiben. Damit entsteht ein zweidimensionales Zahlenschema, eine sogenannte Matrix:

Ein 4x4 Wald in Vektorschreibweise: $$\vec{v_1}= \left( \begin{array}{c} 1\\ 0\\ 0\\ 1\\ \end{array}\right)\ \ \ \ \ \vec{v_2}= \left( \begin{array}{c} 1\\ 1\\ 0\\ 1\\ \end{array}\right)\ \ \ \ \ \vec{v_3}= \left( \begin{array}{c} 0\\ 0\\ 1\\ 0\\ \end{array}\right)\ \ \ \ \ \vec{v_4}= \left( \begin{array}{c} 1\\ 0\\ 1\\ 0\\ \end{array}\right) $$

Der selbe 4x4 Wald in Matrixschreibweise: $$\vec{v_1}= \left( \begin{array}{c c c} 1&1&0&1\\ 0&1&0&0\\ 0&0&1&1\\ 1&1&0&0\\ \end{array}\right)$$

Eine Matrix ist im Prinzip nichts anderes, als eine Liste von Listen. Mit dem Python-Paket numpy bekommen wir Zugriff auf zahlreiche "Werkzeuge" zum erleichterten Arbeiten mit Matrizen. Das Anlegen einer leeren 4x4 Matrix würde beispielsweise ohne numpy so aussehen:

wald2d = [[0,0,0,0], [0,0,0,0], [0,0,0,0], [0,0,0,0]]

was für größere Matrizen zunehmend unübersichtlich würde. Einfacher geht es mit numpy:

wald2d = np.zeros([4,4])

Das Lesen und Ändern von Matrixeinträgen funktioniert ähnlich wie das Lesen und Ändern von Vektor-, sprich Listenelementen: Der erste Eintrag eines Vektors (einer Liste) ist der Eintrag vektorname[0]. In einer Matrize lautet der erste Eintrag links oben matrixname[0][0].

Wenden wir dieses Wissen über Matritzen jetzt auf unser Waldmodell an: Erstellen wir zuerst einen leeren Wald, also eine 10x10 Matrix, die mit Nullen gefüllt ist.

Im nächsten Schritt sollte an jede Stelle eine zufällige Zahl (entweder 1 oder 0) gesetzt werden. int(random.uniform(0, 2) erzeugt uns genau so eine Zahl (genaugenommen eine Zahle zwischen 0 und 2, die dann abgerundet wird). Aber wie machen wir das für jeden Eintrag der Matrix?

Der Befehl ist immer der gleiche, aber das Ziel des Befehls ändert sich fortlaufend, nämlich vom Element [0][0], zu [0][1], zu [0][2] und so weiter. Um also alle Matrizenstellen mit Zufallszahlen füllen, macht es Sinn eine sogenannte Doppelschleife zu verwenden.

Eine Doppelschleife ist eine Schleife innerhalb einer Schleife. Die innere Schleife führt den gewünschten Befehl für eine ganze Zeile der Matrix aus, die äußere Schleife sorgt dafür, dass die innere Schleife für jede Zeile ausgeführt wird. Wichtig dabei ist, dass die Durchlaufvariablen (it) nicht den gleichen Namen haben dürfen.

Wenn der Wald fertig erstellt ist, geben wir ihn mit einem einfachen Printbefehl an den Bildschirm aus.

In [1]:
import matplotlib.pyplot as plt 
import random
import numpy as np 
%matplotlib inline 

# erzeuge eine 'leere', sprich mit Nullen gefüllte Matrix
wald2d=np.zeros([10,10])

# erste Schleife für die verschiedenen Reihen
for it in range(10):
    # zweite Schleife für die Elemente in den Reihen
    for jt in range(10):
        wald2d[it][jt] = int(random.uniform(0, 2))

print(wald2d)
[[1. 1. 1. 0. 1. 1. 0. 1. 0. 1.]
 [1. 0. 0. 1. 0. 0. 0. 1. 0. 0.]
 [1. 1. 0. 0. 0. 1. 1. 1. 1. 0.]
 [1. 0. 1. 1. 1. 1. 1. 0. 1. 0.]
 [0. 1. 0. 0. 1. 0. 0. 0. 1. 1.]
 [1. 1. 0. 1. 0. 0. 0. 1. 0. 1.]
 [1. 1. 0. 1. 1. 0. 0. 0. 1. 0.]
 [1. 0. 1. 1. 0. 1. 1. 1. 1. 1.]
 [0. 0. 0. 1. 0. 0. 1. 0. 1. 0.]
 [1. 1. 1. 0. 1. 0. 0. 1. 0. 1.]]

Man kann sich so schon ein wenig vorstellen, wie der Wald aussehen könnte. Besser wäre aber eine grafische Ausgabe. Ein Befehl, den wir zum Plotten von Matrizen verwenden können, heißt plt.matshow.

Damit können wir einerseits angeben, welche Matrix geplottet werden soll, andererseits auch in welcher Farbe. Die Farbdarstellung in dieser Art von Plots erlaubt es Farben in Abhängigkeit der Größe eines Matrizeneintrages zu gestalten. dazu wird ein Farbverlauf definiert. Obwohl wir hier einstweilen nur Nullen und Einsen als Einträge haben, wählen wir einen Farbverlauf, der von Weiß nach Grün geht. Er heißt plt.cm.Greens.

In [5]:
import matplotlib.pyplot as plt 
import random
import numpy as np 
%matplotlib inline 

wald2d = np.zeros([10, 10])

for it in range(10):
    for jt in range(10):
        wald2d[it][jt] = int(random.uniform(0, 2))

plt.matshow(wald2d, cmap = plt.cm.Greens)
Out[5]:
<matplotlib.image.AxesImage at 0xb7c72b0>

Bisher berücksichtigen wir zur Darstellung unseres Waldes nur die Werte 0 und 1 (kein Baum, Baum). Wenn wir aber zum Beispiel auch Daten über die Größe oder die Dichte der Bäume in unserem Wald hätten, so könnten wir dies mit Werten zwischen 0 und 1 repräsentieren. Zur Darstellung müssen wir dank Farbverlauf sonst nichts an unserem Programm ändern.

In [6]:
import matplotlib.pyplot as plt 
import random
import numpy as np 
%matplotlib inline

wald2d=np.zeros([10, 10])

for it in range(10):
    for jt in range(10):
        wald2d[it][jt] = random.uniform(0, 1)

plt.matshow(wald2d, cmap = plt.cm.Greens)
Out[6]:
<matplotlib.image.AxesImage at 0xb960b70>

In diesem Plot sehen wir nun dunklere und hellere Bereiche des Waldes. Anhand dieser Darstellung könnten wir uns überlegen, in welchen Bereichen es Sinn machen würde, Holz aus dem Wald zu entfernen. Praktisch wäre es, die besonderns dunklen Bereiche des Waldes durch Fällen von Bäumen zu lichten, damit dort wieder neue Bäume wachsen können.

Wie finden wir nun aber zuverlässig die dunklen Bereiche des Waldes? Einfach nur eine große Zahl in der Matrix zu suchen, reicht nicht aus: Damit könnte es sein, dass wir einen vereinzelten großen Baum fällen, der gar keine Nachbarn hat. Wir müssen also bei unserer Suche sicherstellen, dass auch auf den je benachbarten Bereichen große Bäume stehen.

Dazu müssen wir zuerst eine Grenze festlegen, ab der wir einen Baum als "groß" einstufen. In diesem Beispiel nehmen wir den Wert 0.75 an. Nachdem wir den Wald generiert haben, iterieren wir in einer weiteren Doppelschleife durch alle Positionen des Waldes und überprüfen, ob ein Baum an dieser Stelle "groß" ist. Sodann überprüfen wir zusätzlich alle vier Nachbarn an dieser Position. Steht an einem dieser Nachbarorte auch ein großer Baum, so wird der ursprüngliche, also der mittlere Baum gefällt.

In unserer Matrizendarstellung sind die vier Nachbarn des Baumes wald2d[it][jt]

  • wald2d[it+1][jt]
  • wald2d[it-1][jt]
  • wald2d[it][jt+1]
  • wald2d[it][jt-1]

Um nun den Wald vor und nach diesem Auslichten zu betrachten, erstellen wir zwei Plots. Wir verwenden dazu den Befehl plt.show(), der den Plot zeichnet, bevor das restliche Programm ausgeführt wird.

In [7]:
import matplotlib.pyplot as plt 
import random
import numpy as np 
%matplotlib inline

wald2d = np.zeros([10, 10])

# Wald wir generiert
for it in range(10):
    for jt in range(10):
        wald2d[it][jt] = random.uniform(0, 1)

# erste Darstellung
plt.matshow(wald2d, cmap = plt.cm.Greens)
plt.show()

# Wald wird ausgelichtet
for it in range(1, 9):
    for jt in range(1, 9):
        if wald2d[it][jt] > 0.75:
            if wald2d[it+1][jt] > 0.75:
                wald2d[it][jt] = 0
            if wald2d[it-1][jt] > 0.75:
                wald2d[it][jt] = 0
            if wald2d[it][jt+1] > 0.75:
                wald2d[it][jt] = 0
            if wald2d[it][jt-1] > 0.75:
                wald2d[it][jt] = 0

# zweite Darstellung
plt.matshow(wald2d, cmap = plt.cm.Greens)
Out[7]:
<matplotlib.image.AxesImage at 0xc4c04e0>

Um in diesen Plots zu sehen, wo genau die Bäume gefällt wurden, muss man sehr genau hinschauen. Lassen sich die Orte, an denen gefällt wurde, einfacher finden? Gesucht ist also eine Methode zur Darstellung des Unterschieds zwischen erstem und zweiten Plot.

Unterschiede sind mathematisch ausgedrückt Differenzen. Das Rechnen mit Matrizen bietet Vorteil, eine Matrix einfach von einer anderen subtrahieren zu können. Wir bauen diese Möglichkeit in das Programm ein und erstellen einen Plot der uns sagt, wo Bäume gefällt wurden.

Dazu müssen wir den zunächst generierten Wald unter einem eigenen Namen zwischenspeichern, damit wir Ihn beim Fällen nicht überschreiben.

In [2]:
import matplotlib.pyplot as plt 
import random
import numpy as np 
%matplotlib inline

wald2d = np.zeros([10, 10])
for it in range(10):
    for jt in range(10):
        wald2d[it][jt] = random.uniform(0, 1)

# erste Darstellung, der generierte Wald
plt.matshow(wald2d, cmap = plt.cm.Greens)
plt.show()

# Zwischenspeichern
wald2dalt = np.array(wald2d)

for it in range(1, 9):
    for jt in range(1, 9):
        if wald2d[it][jt] > 0.75:
            if wald2d[it+1][jt] > 0.75:
                wald2d[it][jt] = 0
            if wald2d[it-1][jt] > 0.75:
                wald2d[it][jt] = 0
            if wald2d[it][jt+1] > 0.75:
                wald2d[it][jt] = 0
            if wald2d[it][jt-1] > 0.75:
                wald2d[it][jt] = 0

# zweite Darstellung, der gelichtete Wald                
plt.matshow(wald2d, cmap = plt.cm.Greens)

# dritte Darstellung, die Differenz, diesmal in Rot-Tönen
plt.matshow(wald2dalt - wald2d, cmap = plt.cm.Reds)
Out[2]:
<matplotlib.image.AxesImage at 0x218ebe3e5c0>

Wir könnten auch versuchen, den größten Baum im Wald zu finden. Dazu müssen wir wieder eine Doppelschleife verwenden, und somit alle Bäume abgehen. Wir merken uns immer die Größe des größten Baumes, dem wir bereits begegnet sind. Wenn der aktuelle Baum größer ist, wird diese Größe ersetzt.

In [13]:
import matplotlib.pyplot as plt 
import random
import numpy as np 
%matplotlib inline

wald2d = np.zeros([10, 10])
for it in range(10):
    for jt in range(10):
        wald2d[it][jt] = random.uniform(0, 1)

maximum = 0 
#Wir setzen den Wert für den größten Baum auf 0   
#Somit wird der erste Baum garantiert größer sein un diesen Wert überschreiben


for it in range(10):
    for jt in range(10):
    # Diese Schleife läuft nun im Gegensatz zur vorherigen über alle Einträge, also auch die Ränder.
        if wald2d[it][jt] > maximum:
            maximum = wald2d[it][jt]
            
# Nach der Doppelschleife geben wir den finalen Wert des größten Baumes aus:

print("Größter Baum:")
print(maximum)
            
            
Größter Baum:
0.995210757744049

Zusammenfassung

Vektoren

Vektoren sind so etwas wie Listen, deren Einträge in der Mathematik gewöhnlich übereinandergeschrieben werden. $\vec{v_1}= \left( \begin{array}{c} 1\\ 0\\ 0\\ 1\\ \end{array}\right)\ \ \ \ \ $

Durch Vektoren lässt sich genauso itierieren, wie durch Listen. Beachte, dass das erste Element (der erste Eintrag) in einem Vektor als 'nullter' Eintrag gezählt wird (vektorname[0]). Der zweite Eintrag hat somit die Bezeichnung vektorname[1], usw.

Matrizen

Matrizen sind Vektoren von Vektoren, oder am Computer: Listen von Listen. $\vec{v_1}= \left( \begin{array}{c c c} 1&1&0&1\\ 0&1&0&0\\ 0&0&1&1\\ 1&1&0&0\\ \end{array}\right)$

Matrizen-Einträge werden mit matrixname[0][0], matrixname[0][1], .... matrixname[n][m] adressiert. Zum Iterieren durch eine Matrix kann eine Doppelschleife verwendet werden.

Auf Matrizen können eine Reihe von Grundrechenarten (Addieren, Subtrahieren, Multiplizieren ...) angewendet werden.

Random

Zum Erzeugen von Zufallszahlen kann das Python-Paket (bzw. Modul) random verwendet werden.

Kapitel 9 - Functions

In diesem Kapitel werden wir das Recyclen von Rohstoffen betrachten und dabei etwas über Functions in Python lernen.

Nehmen wir an es gibt einen Produktionsprozess, der einen gewissen Rohstoff benötigt. Diesen Rohstoff kann man in einer Reinheit von 90% käuflich erwerben. Nach dem Produktionsprozess kann man den Rohstoff wiedergewinnen und theoretisch weiterbenutzen, er verliert aber ein Viertel seiner Reinheit. Mathematisch können wir das so ausdrücken:

In [3]:
reinheit = 0.9
reinheit_neu = reinheit * 0.75
print(reinheit_neu)
0.675

Der Rohstoff hat nun also nur mehr eine Reinheit von 67.5%. Wenn wir ihn so weiter verwenden, würde er sehr schnell eine Reinheit haben, mit der der Prozess nicht mehr funktioniert. Was aber, wenn wir ihn mit neuem Rohstoff mischen?

In [4]:
reinheit = 0.9
reinheit_rec = reinheit * 0.75

reinheit_neu = 0.5 * (reinheit + reinheit_rec) #Mittelwert
print(reinheit_neu)
0.7875000000000001

Nun hätten wir eine Reinheit von über 78% nach dem ersten Wiederverwenden. Interessant wäre nun herauszufinden, was passiert, wenn wir diese Schritte immer weiter wiederholen, also den Rohstoff immer wieder verwenden und neu mischen. Wie schnell fällt die Reinheit ab? Erreicht sie irgendwann 0?

Um das herauszufinden, können wir eine Liste und eine For-Schleife verwenden:

In [10]:
import matplotlib.pyplot as plt
%matplotlib inline

reinheit = 0.9
reinheit_liste = [reinheit] #eine Liste mit einem Eintrag wird erstellt

for it in range(10):
    reinheit = 0.5 * (0.9 + reinheit * 0.75)
    reinheit_liste.append(reinheit)
    

plt.plot(reinheit_liste)
Out[10]:
[<matplotlib.lines.Line2D at 0x1dd220b8b00>]

Es sieht so aus, also würde die Reinheit nie unter einen gewissen Wert fallen. Scheinbar bildet sich ein Gleichgewicht aus. Egal wie oft wir den Prozess wiederholen, wir kommen nicht unter 70 Prozent. Hat das etwas damit zu tun, dass der Prozess die Reinheit um 25% reduziert? Was hätte ein anderer Parameterwert für eine Auswirkung? Das könnten wir einfach überprüfen, indem wir einfach den Parameter von ändern, zum Beispiel auf 0.8:

In [12]:
import matplotlib.pyplot as plt
%matplotlib inline

reinheit = 0.9
reinheit_liste = [reinheit]

for it in range(10):
    reinheit = 0.5 * (0.9 + reinheit * 0.8)
    reinheit_liste.append(reinheit)
    

plt.plot(reinheit_liste)
Out[12]:
[<matplotlib.lines.Line2D at 0x1dd221e6dd8>]

Tatsächlich ändert sich der Fixpunkt. Wir wollen nun mehrere Parameterwerte in einem Plot vergleichen. Die einfachste Lösung wäre, die Forschleife oft zu kopieren und den Parameter jedes mal zu ändern. Wir könnten das Programm auch in eine For-Schleife legen, aber das Ändern des Parameters wird dadurch sehr umständlich. Glücklichweise gibt es eine bessere Lösung: Wir benutzen Functions.

Functions

Functions (nicht zu verwechseln mit mathematischen Funktionen) sind Programmierstrukturen, die kompliziertere Prozesse automatisch ausführen. Ähnlich wie mathematische Funktionen können sie einen Input und einen Output haben. Wir können ihnen einen Namen geben und sie somit platzsparend und übersichtlich aufrufen.

Functions sind immer dann empfehlenswert, wenn man einen Prozess an unterschiedlichen Stellen im Code braucht. Functions werden in Python mit dem Wort def deklariert. Man schreibt def namederfunction():, die Function selbst wird dann eingerückt. Bauen wir nun unsere Recycling-Prozess in eine Function um.

In [13]:
import matplotlib.pyplot as plt
%matplotlib inline


#eine eigene Function wird definiert:
def recyceln():
    reinheit = 0.9
    reinheit_liste = [reinheit]

    for it in range(10):
        reinheit = 0.5 * (0.9 + reinheit * 0.8)
        reinheit_liste.append(reinheit)


    plt.plot(reinheit_liste)
    

Man beachte: Nur durch das Definieren der Function wird sie noch nicht ausgeführt. Dazu müssen wir sie dezidiert aufrufen. Das macht man mit dem Namen der Funktion gefolgt von runden Klammern:

In [14]:
import matplotlib.pyplot as plt
%matplotlib inline

def recyceln():
    reinheit = 0.9
    reinheit_liste = [reinheit]

    for it in range(10):
        reinheit = 0.5 * (0.9 + reinheit * 0.8)
        reinheit_liste.append(reinheit)


    plt.plot(reinheit_liste)

recyceln()

Was haben wir durch diese Function nun eigentlich gewonnen? Eine wichtige Eigenschaft von Functions ist, dass sie einen Inputwert haben können. Momentan verwendet unsere Function immer einen Parameter von 0.8, um zu berechnen wie viel Reinheit pro Schritt verloren wird. Diesen Parameter können wir umschreiben, sodass er als Inputparameter verwendet wird. Inputparameter werden wie folgt eingebaut:

Bei der Definition der Function kommt der Name des Parameters in die runde Klammer. Dieser Name kann dann innerhalb der Funktionsdefiniton benutzt werden.

Wenn die Function dann aufgerufen wird, gibt man den Wert den der Parameter haben soll, an.

Für unsere Recyclingfunction würde das also so aussehen:

In [1]:
import matplotlib.pyplot as plt
%matplotlib inline

def recyceln(verlustparameter):
    reinheit = 0.9
    reinheit_liste = [reinheit]

    for it in range(10):
        reinheit = 0.5 * (0.9 + reinheit * verlustparameter)
        reinheit_liste.append(reinheit)


    plt.plot(reinheit_liste)

recyceln(0.9)
recyceln(0.8)
recyceln(0.7)

Somit können wir sehr elegant verschiedene Verlustparameter miteinander vergleichen. Im Plot wäre es aber schön, wenn wir die Linien beschriften würden, damit wir wissen, welche Linie zu welchem Parameterwert gehört. Functions können wir einfach anpassen: Wenn wir in der Definition etwas ändern, ist diese Änderung für alle Aufrufe gültig.

In [20]:
import matplotlib.pyplot as plt
%matplotlib inline

def recyceln(verlustparameter):
    reinheit = 0.9
    reinheit_liste = [reinheit]

    for it in range(10):
        reinheit = 0.5 * (0.9 + reinheit * verlustparameter)
        reinheit_liste.append(reinheit)


    plt.plot(reinheit_liste, label = verlustparameter) #wir fügen eine Beschriftung hinzu
    plt.legend() #die Beschriftungen sollen in einer Legende angezeigt werden

recyceln(0.9)
recyceln(0.8)
recyceln(0.7)

Functions können wir auch beliebig ausbauen. Wir können zum Beispiel einen zweiten Inputparameter einbauen. Weitere Inputparameter werden einfach mit Beistrichen getrennt. Bauen wir nun auch den Parameter, der uns sagt wir rein die frische Resource ist, als Parameter ein.

In [24]:
import matplotlib.pyplot as plt
%matplotlib inline

def recyceln(verlustparameter, startreinheit):
    reinheit = startreinheit
    reinheit_liste = [reinheit]

    for it in range(10):
        reinheit = 0.5 * (startreinheit + reinheit * verlustparameter)
        reinheit_liste.append(reinheit)


    plt.plot(reinheit_liste, label = verlustparameter)
    plt.legend()

recyceln(0.9,1.0)
recyceln(0.8,1.0)
recyceln(0.7,1.0)

Zusätzlich zum Inputwert kann eine Function auch einen Outputwert haben. Das ist sozusagen das Ergebnis der Function, also das, was die Function an das Hauptprogramm zurückgeben soll. In unserem Fall wäre das zum Beispiel der letzte Reinheitswert unserer Zeitreihe. Der Outputwert wird mit dem Wort return am Ende der Definition festgelegt. Immer wenn wir eine weitere Function (zum Beispiel print) auf eine andere Function anwenden, wenden wir sie eigentlich auf den Outputwert an.

In [26]:
import matplotlib.pyplot as plt
%matplotlib inline

def recyceln(verlustparameter, startreinheit):
    reinheit = startreinheit
    reinheit_liste = [reinheit]

    for it in range(10):
        reinheit = 0.5 * (startreinheit + reinheit * verlustparameter)
        reinheit_liste.append(reinheit)


    plt.plot(reinheit_liste, label = verlustparameter)
    plt.legend()
    
    return reinheit_liste[-1] #das Element -1 einer Liste ist das letzte Element der Liste, egal wie lang die Liste ist

print(recyceln(0.9,1.0))
print(recyceln(0.8,1.0))
print(recyceln(0.7,1.0))
0.9091218642081055
0.8333508096
0.7692371351092773

Darüber hinaus ist es möglich Functions innerhalb von Functions zu verwenden. Wir können das Zusammenmischen der Rohstoffe in eine eigene Function auslagern:

In [1]:
import matplotlib.pyplot as plt
%matplotlib inline

def mischen(aktuell,start,verlust):
    neu = 0.5 * (start + aktuell * verlust)
    return neu


def recyceln(verlustparameter, startreinheit):
    reinheit = startreinheit
    reinheit_liste = [reinheit]

    for it in range(10):
        reinheit = mischen(reinheit,startreinheit,verlustparameter)
        reinheit_liste.append(reinheit)


    plt.plot(reinheit_liste, label = verlustparameter)
    plt.legend()
    
    return reinheit_liste[-1]

print(recyceln(0.9,1.0))
print(recyceln(0.8,1.0))
print(recyceln(0.7,1.0))
0.9091218642081055
0.8333508096
0.7692371351092773

Analysieren wir nun einen anderen Recycle-Prozess. Wir produzieren Glasflaschen, die dann genutzt werden. Während der Nutzung lagern sich im Glas insgesamt 10 Milligram an Schadstoffen ab. Die Flaschen werden eingeschmolzen und neue Flaschen daraus gemacht. Dabei werden 15% der Schadstoffe entfernt. Wie lange dauert es, bis mehr als 50 Milligram in einer Flasche sind?

Versuchen wir dieses Problem mit Functions zu lösen. Schreiben wir zuerst eine Function, die den Verschmutzungsprozess beschreibt, und eine Function, die den Einschmelzprozess beschreibt.

Dann können wir diese Prozesse in eine For-Schleife geben und plotten was passiert.

In [2]:
import matplotlib.pyplot as plt
%matplotlib inline

def verschmutzen(startwert):
    return startwert + 10

def schmelzen(startwert):
    return startwert * 0.85

schadstoff = 0
schadstoff_liste = [schadstoff]

for it in range(10):
    schadstoff = verschmutzen(schadstoff)
    schadstoff = schmelzen(schadstoff)
    schadstoff_liste.append(schadstoff)
    
plt.plot(schadstoff_liste)
Out[2]:
[<matplotlib.lines.Line2D at 0x2066550ed68>]

Wenn wir die Funktionen verschachteln wird es deutlich kompakter. Die Functions werden von innen nach außen abgearbeitet:

In [7]:
import matplotlib.pyplot as plt
%matplotlib inline

def verschmutzen(startwert):
    return startwert + 10

def schmelzen(startwert):
    return startwert * 0.85

schadstoff = 0
schadstoff_liste = [schadstoff]

for it in range(10):   
    schadstoff = schmelzen(verschmutzen(schadstoff))
    schadstoff_liste.append(schadstoff)
    
plt.plot(schadstoff_liste)
Out[7]:
[<matplotlib.lines.Line2D at 0x2674436fe80>]

Wir sehen also: nach 10 Schritten ist der Grenzwert noch nicht überschritten. Wie können wir nun aber herausfinden, wann genau dieser Wert das erste mal überschritten wird? Wir könnten einfach die For-Schleife länger laufen lassen und so lange herumprobieren, bis wir einen geeigneten Wert finden. Es gibt aber eine bessere Lösung: Immer, wenn wir nicht wissen wie oft eine Schleife durchlaufen werden soll, sondern nur unter welcher Bedingung sie abgebrochen werden sollte (in unserem Fall beim Erreichen von 50 mg Schadstoff) kann man eine While-Schleife verwenden.

Die While-Schleife

Bei der While-Schleife geben wir keine Anzahl an Schleifendurchgängen vor, sondern eine Bedingung. So lange diese Bedinung noch erfüllt ist wird die Schleife weiterlaufen. Man benutzt While-Schleifen mit

while bedingung: anweisungen

Für unser Beispiel also:

In [10]:
import matplotlib.pyplot as plt
%matplotlib inline

def verschmutzen(startwert):
    return startwert + 10

def schmelzen(startwert):
    return startwert * 0.85

schadstoff = 0
schadstoff_liste = [schadstoff]

while schadstoff < 50:   
    schadstoff = schmelzen(verschmutzen(schadstoff))
    schadstoff_liste.append(schadstoff)
    
plt.plot(schadstoff_liste)
Out[10]:
[<matplotlib.lines.Line2D at 0x267444a3a20>]

While-Schleifen haben keine integrierte Variable, die mitzählt, wie oft die Schleife durchlaufen worden ist. Wenn wir so etwas brauchen (wie hier um die Anzahl der Wiederverwendungen zu finden) können wir das aber sehr einfach einbauen:

In [5]:
import matplotlib.pyplot as plt
%matplotlib inline

def verschmutzen(startwert):
    return startwert + 10

def schmelzen(startwert):
    return startwert * 0.85

schadstoff = 0
schadstoff_liste = [schadstoff]
counter = 0

while schadstoff < 50:   
    schadstoff = schmelzen(verschmutzen(schadstoff))
    schadstoff_liste.append(schadstoff)
    counter = counter + 1
    
plt.plot(schadstoff_liste)

print("Nach")
print(counter)
print("Prozessen ist der Schadstoffgrenzwert überschritten.")
Nach
14
Prozessen ist der Schadstoffgrenzwert überschritten.

Vorsicht bei der Verweundung von While-Schleifen: Wenn die Bedingung der While-Schleife schlecht gewählt wurde, kann es sein, dass sie für immer wahr bleibt. Dann läuft die While-Schleife unendlich lang weiter.

Man sollte While-Schleifen also nur dann verwenden, wenn wir ganz sicher sind, dass die Bedingung irgendwann einmal nicht mehr erfüllt sein wird. Wenn wir überhaupt nicht wissen, was mit unserem System passieren wird sollte man While-Schleifen vermeiden und eher auf For-Schleifen ausweichen.

Zusammenfassung

Functions

Mit Functions können wir Prozesse, die wir oft brauchen, einmalig definieren und dann oftmals wiederverwenden. Wir können sozusagen unsere eigenen Pythonbefehle zusammenbauen. Functions können Inputwerte haben, damit kann man sie anpassen. Außerdem gibt es Outputwerte, die wieder an das Hauptprogramm übergeben werden. Andere Variablen, die innerhalb der Function erstellt werden exisiteren nur innerhalb der Function und sind vom Hauptprogramm nicht aufrufbar.

Functions werden mit

def functionname(input1,input2,input3): Anweisungen return outputvariable

definiert.

While-Schleifen

While-Schleifen werden, ähnlich wie For-Schleifen dazu benutzt Anweisung oftmals zu wiederholen. Bei der For-Schleife muss man angeben, wie oft sie durchlaufen werden muss. Die While-Schleife läuft im Gegensatz dazu so lange, bis eine Bedingung, die man wählen muss, nicht mehr erfüllt ist.

Somit ist das Verwenden der While-Schleife sehr gefährlich: Bleibt die Bedingung immer erfüllt (durch einen Denk- oder einen Programmierfehler) wird die Schleife niemals fertig und der Computer kann abstürzen, wenn man nicht schnell genug den Kernel beendet.

Kapitel 10 - Rekursionen und Iterationen

Im letzten Kapitel haben wir Functions kennengelernt. Damit konnten wir eigene Befehle programmieren, die Input- und Outputwerte haben können.

Wir haben auch gesehen, dass eine Function auch eine andere Function aufrufen kann. Das könnte uns auf eine interessante Fragestellung bringen: Was passiert, wenn eine Function sich selbst aufruft? Kann das überhaupt funktionieren, oder bauen wir damit automatisch immer eine Endlosschleife?

Tatsächlich ist es möglich, dass wir Functions bauen, die sich selbst aufrufen. Streng genommen handelt es sich bei so etwas um eine Rekursion. Eine Rekursion tritt immer dann auf, wenn man eine Operation durchführt und dann die gleiche Operation auf den Output der ersten Operation anwendet und immer so weiter. Ähnlich wie bei While-Schleifen müssen wir aber aufpassen, dass wir keine Endlosschleifen produzieren. Functions, die garantiert sich selbst aufrufen werden immer Endlosschleifen produzieren. Functions die sich selbst nur unter einer gewissen Bedingung aufrufen sind für Rekursionen geeignet.

Versuchen wir nun eine solche Rekursion zu programmieren. Wir möchten eine Function haben, die einen Countdown herunterzählt, beginnend von einer beliebigen Zahl. Die Function soll also, wenn sie als Input 0 bekommt, die Zahl 0 ausgeben. Wenn Sie einen anderen Input bekommt, soll sie einerseits diesen Input ausgeben, andererseits auch sich selbst aufrufen. Der Input, mit dem sie sich selbst aufruft soll ihr eigener Input - 1 sein, sodass heruntergezählt wird.

In [80]:
#wir definieren die Function countdown

def countdown(zahl):
    #in jedem fall möchten wir den input printen
    print(zahl)
    #wenn wir noch nicht bei 0 angekommen sind,
    #ruft die Funcstion sich selbst auf, aber
    #mit einem um 1 reduzierten Input.
    
    if zahl != 0: #wenn der input 0 ist
        countdown(zahl-1)
        

#nach der Definition müssen wir unsere Function auch aufrufen:
#den Input können wir frei wählen
countdown(10)
        
    
10
9
8
7
6
5
4
3
2
1
0

Unsere Countdownfunction scheint zu funktionieren. Sie zählt von einer beliebigen Zahl abwärts, indem sie sich immer wieder selbst aufruft. Wir benötigen also keine Schleife. Dennoch kann es passieren, dass wir eine Endlosschleife bauen, nämlich wenn wir nie auf den Input 0 kommen, weil wir die Function zum Beispiel mit einem anderen Input starten: (der Printbefehl wurde hier sicherheitshlaber auskommentiert)

In [5]:
#wir definieren die Function countdown

def countdown(zahl):
    #in jedem fall möchten wir den input printen
    #print(zahl)
    #wenn wir noch nicht bei 0 angekommen sind,
    #ruft die Funcstion sich selbst auf, aber
    #mit einem um 1 reduzierten Input.
    
    if zahl != 0: #wenn der input 0 ist
        countdown(zahl-1)
        

#nach der Definition müssen wir unsere Function auch aufrufen:
#den Input können wir frei wählen
countdown(10.5)
---------------------------------------------------------------------------
RecursionError                            Traceback (most recent call last)
<ipython-input-5-9b03a94dcc7f> in <module>()
     14 #nach der Definition müssen wir unsere Function auch aufrufen:
     15 #den Input können wir frei wählen
---> 16 countdown(10.5)

<ipython-input-5-9b03a94dcc7f> in countdown(zahl)
      9 
     10     if zahl != 0: #wenn der input 0 ist
---> 11         countdown(zahl-1)
     12 
     13 

... last 1 frames repeated, from the frame below ...

<ipython-input-5-9b03a94dcc7f> in countdown(zahl)
      9 
     10     if zahl != 0: #wenn der input 0 ist
---> 11         countdown(zahl-1)
     12 
     13 

RecursionError: maximum recursion depth exceeded in comparison

Nach einiger Zeit liefert Python eine Fehlermeldung:

RecursionError: maximum recursion depth exceeded

Im Gegensatz zu While-Schleifen, sind Rekursionen in Python besser gesichert. Wenn es zu zu vielen Selbstaufrufen kommt, erkennt das Python als Endlosschleife und bricht die Berechnung ab, bevor der Computer einfriert.

Das ist aber nur einer der Vorteile von Rekursionen gegenüber Schleifen. Rekursionen können auch deutlich kompliziertere Aufgaben erfüllen, die mit Schleifen nur umständlich lösbar sind. Denken wir zurück an die Fibonaccifolge, also

0,1,2,3,5,8,13,...

Wenn wir nun zum Beispiel wissen wollen, welche die 20ste Zahl in dieser Folge ist, könnten wir eine Schleife bauen, die 20 Fibonacci-Zahlen ausrechnet. Eleganter geht das mit einer Rekursion: Dazu müssen wir nur folgende drei Dinge wissen.

  • Die erste Fibonaccizahl ist 0.

  • Die zweite Fibonaccizahl ist 1.

  • Die $n$-te Fibonccizahl kann man ausrechnen, indem man die $(n-1)$-te zur $(n-2)$-ten addiert.

Als Rekursion geschrieben:

In [78]:
def fibonacci(n):
    ret = 0 #wir legen eine Variable an, die wir auf 0 setzen
    
    #wir kennen die erste Fibonaccizahl
    if n == 0:
        ret = 0
        
    #wir kennen die zweite Fibonaccizahl    
    if n == 1:
        ret = 1
        
    #alle anderen können wir rekursiv ausrechnen    
    if n > 1:
        ret = fibonacci(n-1) + fibonacci(n-2)
    
    return ret

fibonacci(20)
Out[78]:
6765

Dank der Rekursion benötigen wir keine Schleife, keine Liste und keine Variablen die uns alte Fibonacci-Zahlen speichern.

Auf ähnlich Weise können wir auch kompliziertere Zahlenmengen finden. Besonders spanned ist zum Beispiel die Mandelbrot-Menge. Um zu verstehen, was die Mandelbrotmenge genau ist, müssen wir uns zuerst mit der Mandelbrot-Folge auseinandersetzen. Die Mandelbrot-Folge für die Zahl c wird wie folgt berechnet:

$$z_{n+1} = z_n^2 + c$$

Ähnlich wie bei der Fibonaccifolge brauchen wir also das $n$-te Element um das $n+1$ste zu errechnen.

Die Mandelbrot-Menge ist nun die Menge aller Zahlen, bei denen keines der Elemente dieser Reihe größer wird als 2.

Für reelle Zahlen können wir uns diese Folge noch im Kopf ausrechnen:

  • Für c = 1

$$1^2 + 1 = 2,\ \ \ \ 2^2 + 1 = 3,\ \ \ \ 3^2 + 1 = 10,\ \ \ \ ...$$

  • Für c = 0

$$0^2 + 0 = 0,\ \ \ \ 0^2 + 0 = 0,\ \ \ \ 0^2 + 0 = 0,\ \ \ \ ...$$

  • Für c = -1

$$-1^2 - 1 = 0,\ \ \ \ 0^2 - 1 = -1,\ \ \ \ -1^2 - 1 = 0,\ \ \ \ ...$$

c = 1 ist also nicht in der Mandebrot-Menge, denn die Elemente der Reihe werden unendlich groß.

c = 0 ist in der Mandelbrot-Menge, denn die Elemente sind alle 0.

c = -1 ist in der Mandelbrot-Menge, denn die Elemente sind immer abwechseln 0 und -1.

Was ist nun aber mit den Zahlen dazwischen? Und mit komplexen Zahlen? Das wird im Kopf sehr aufwendig, weswegen wir ein Programm schreiben, das die Arbeit für uns erledigt.

Zuerst brauchen wir eine Doppelschleife, die uns über einen interessanten Bereich der komplexen Ebene wandert. Später soll für jede Zahl die vorkommt bestimmt werden, ob Sie in der Mandelbrot-Menge ist oder nicht. Fürs erste begnügen wir uns damit einfach den Absolutbetrag der Zahl darzustellen. In Python kann man komplexe Zahlen mit complex(realteil,imaginärteil) definieren und den Absolutbetrag mit abs berechnen.

Wir untersuchen komplexe Zahlen mit einem Realteil zwischen -2 und 0.5 und einem Imaginärteil zwischen -1.25 und 1.25. Wir hätten gern eine Genauigkeit von 0.01. Um Zahlen aus diesen Intervallen zu erstellen ist der range Befehl unzureichend. Wir benötigen den Befehl np.arange mit dem man Startwert, Endwert und Schrittweite angeben kann.

In [2]:
import matplotlib.pyplot as plt
%matplotlib inline

import numpy as np

#wir definieren den Realteil der Zahlen, die wir untersuchen wollen:
r_bereich = np.arange(-2,0.5,0.01)
#und den Imaginärteil der Zahlen, die wir untersuchen wollen.
i_bereich = np.arange(-1.25,1.25,0.01)

#Wir legen eine Matrix in der richtigen Größe an, die mit 0en gefüllt ist.
mandelbrot = np.zeros([len(i_bereich),len(r_bereich)])


#mit einer doppelschleifen gehen wir über alle Zahlen
for it in range(len(i_bereich)):
    for jt in range(len(r_bereich)):
        #und speichern den absolutbetrag der Zahl in die Matrix
        mandelbrot[it,jt]= abs(complex(r_bereich[jt],i_bereich[it]))

plt.pcolormesh(r_bereich,i_bereich,mandelbrot)
#der Befehl pcolormesh funktioniert ähnlich wie imshow, 
#nur können wir hier x und y Intervalle für die Beschriftung übergeben

plt.ylabel("Imaginärteil")
plt.xlabel("Realteil")
Out[2]:
Text(0.5,0,'Realteil')

Nun steht also das Grundgerüst unseres Programms. Wir wandern über einen Bereich der komplexen Ebene und zeichnen das Ergebnis einer Operation. Momentan ist die Operation einfach der Absolutbetrag der Zahl. Wir werden Sie im nächsen Schritt aber durch eine andere Information ersetzen: Nämlich wie viele Schritte es braucht, bis ein Element der Mandelbrotfolge größer als 2 wird.

Dazu müssen wir zuerst eine Function bauen, die uns die Mandelbrotfolge ausrechnet. Auch hier beitet sich eine Rekursion an. Wenn wir das $n$-te Element der Mandelbrotfolge der Zahl $c$ haben wollen, können wir das einfach berechnen, indem wir das $(n-1)$-te Element quadrieren und $+\ c$ rechnen. Das "nullte" Element der Mandelbrotfolge ist immer die Zahl $c$ selbst.

Mathematisch ausgedrückt:

$$M_0 = c $$ $$M_n = \left(M_{n-1}\right)^2 + c $$

Das können wir als Function wie folgt schreiben:

In [2]:
def mandelbrotelement(c, it):
    # das nullte element ist die Zahl selbst
    if it == 0:
        return c
    else: 
    #sonst müssen wir es aus dem vorherigen Element ausrechnen    
        return mandelbrotelement(c, it - 1) ** 2  + c
    
mandelbrotelement(complex(-1,0),10)
Out[2]:
(-1+0j)

Unsere Rekursion scheint zu funktionieren. Das 10te Element der Mandelbrotreihe der Zahl -1 ist wieder -1. Mit dieser Function können wir eine weitere Function schreiben, die uns die ersten 50 Elemente der Mandelbrotreihe ausrechnet:

In [4]:
def mandelbrotfolge(c):
    ret = [] # wir erstellen eine leere Liste
    for it in range(50):
        #wir füllen diese Liste mit den Elementen der Mandelbrotfolge
        z=mandelbrotelement(c,it)
        ret.append(z)
    return ret
        
def mandelbrotelement(c, it):
    if it == 0:
        return c
    else: 
        return mandelbrotelement(c, it - 1) ** 2  + c
    

mandelbrotfolge(complex(0.1,0.1))
Out[4]:
[(0.1+0.1j),
 (0.1+0.12000000000000001j),
 (0.0956+0.12400000000000001j),
 (0.09376336+0.12370880000000001j),
 (0.09348770048104961+0.123198705499136j),
 (0.0935620291045716+0.12303512735871254j),
 (0.09361621072599009+0.12302283233364109j),
 (0.09362937763530182+0.12303386279170858j),
 (0.093629128962925+0.1230391680025096j),
 (0.09362777692760627+0.12304010025679593j),
 (0.0936272943412032+0.1230399421199872j),
 (0.0936272428887645+0.1230397937531853j),
 (0.09362726976412533+0.12303975330942594j),
 (0.0936272847490399+0.12303975234962611j),
 (0.09362728779122048+0.1230397558573796j),
 (0.09362728749769646+0.12303975726284078j),
 (0.09362728709687754+0.12303975745378956j),
 (0.09362728697483377+0.12303975739091227j),
 (0.09362728696745333+0.12303975734910574j),
 (0.09362728697635904+0.1230397573394611j),
 (0.09362728698040003+0.12303975733984661j),
 (0.09362728698106185+0.1230397573409132j),
 (0.09362728698092332+0.12303975734127579j),
 (0.09362728698080815+0.12303975734130959j),
 (0.09362728698077827+0.12303975734128758j),
 (0.09362728698077809+0.12303975734127612j),
 (0.09362728698078088+0.12303975734127393j),
 (0.09362728698078193+0.1230397573412742j),
 (0.09362728698078207+0.12303975734127451j),
 (0.09362728698078202+0.12303975734127459j),
 (0.09362728698078199+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j)]

Solche Functions, die sich selbst rekursiv aufrufen, sehen zwar sehr elegant aus, haben in der Praxis aber meist einige Nachteile. Somit ist es für viele Anwendung, vor allem in Python, sinnvoll, dass wir die Rekursion nicht durch rekursives Aufrufen einer Funktion, sondern durch eine Iteration durchführen. Wir starten also nicht beim letzten Element und arbeiten uns vor zum Element 0, sondern wir starten am Anfang und iterieren so lange, bis wir bei dem Element sind, das uns interessiert. Der gleiche Rekursive Prozess wie oben, jetzt als Iteration ausgedrückt:

In [82]:
def mandelbrotfolge(c):
    ret = [c] 
    for it in range(1,50):
        z = ret[it - 1]**2 + c
        ret.append(z)
    return ret
    
mandelbrotfolge(complex(0.1,0.1))
Out[82]:
[(0.1+0.1j),
 (0.1+0.12000000000000001j),
 (0.0956+0.12400000000000001j),
 (0.09376336+0.12370880000000001j),
 (0.09348770048104961+0.123198705499136j),
 (0.0935620291045716+0.12303512735871254j),
 (0.09361621072599009+0.12302283233364109j),
 (0.09362937763530182+0.12303386279170858j),
 (0.093629128962925+0.1230391680025096j),
 (0.09362777692760627+0.12304010025679593j),
 (0.0936272943412032+0.1230399421199872j),
 (0.0936272428887645+0.1230397937531853j),
 (0.09362726976412533+0.12303975330942594j),
 (0.0936272847490399+0.12303975234962611j),
 (0.09362728779122048+0.1230397558573796j),
 (0.09362728749769646+0.12303975726284078j),
 (0.09362728709687754+0.12303975745378956j),
 (0.09362728697483377+0.12303975739091227j),
 (0.09362728696745333+0.12303975734910574j),
 (0.09362728697635904+0.1230397573394611j),
 (0.09362728698040003+0.12303975733984661j),
 (0.09362728698106185+0.1230397573409132j),
 (0.09362728698092332+0.12303975734127579j),
 (0.09362728698080815+0.12303975734130959j),
 (0.09362728698077827+0.12303975734128758j),
 (0.09362728698077809+0.12303975734127612j),
 (0.09362728698078088+0.12303975734127393j),
 (0.09362728698078193+0.1230397573412742j),
 (0.09362728698078207+0.12303975734127451j),
 (0.09362728698078202+0.12303975734127459j),
 (0.09362728698078199+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j),
 (0.09362728698078197+0.12303975734127459j)]

Wir sind aber eigentlich nicht an der gesamten Folge interessiert, sondern nur an den Elementen vor dem ersten Element das größer ist als 2. Somit müssen wir für viele Zahlen garnicht die ganze For-Schleife durchlaufen: Immer wenn wir eine Zahl finden, die größer ist als 2 könnten wir die Schleife eigentlich abbrechen, um Rechenzeit zu sparen. Wie unterbrechen wir aber eine Schleife, die eigentlich bis 50 läuft schon vorzeitig?

Dafür gibt es in Python den Befehl break. break benutzt man innerhalb von Schleifen, um die Schleife vorzeitig abzubrechen. Brechen wir unsere Schleife nun ab, sobald wir ein Element ausrechnen, das größer ist als 2:

In [84]:
def mandelbrotfolge(c):
    ret = [c] 
    for it in range(1,50):
        z = ret[it - 1]**2 + c
        ret.append(z)
        if abs(z)>2:
            break 
    return ret
    
mandelbrotfolge(complex(0.5,0.5))
Out[84]:
[(0.5+0.5j), (0.5+1j), (-0.25+1.5j), (-1.6875-0.25j), (3.28515625+1.34375j)]

Das ist eigentlich schon alles, was wir zum Zeichnen der Mandelbrotmenge benötigen. Für jede Zahl, die wir untersuchen, schauen wir die Mandelbrotfolge an. Da wir automatisch abbrechen, sobald eine Zahl zu groß ist, ist die Länge der Liste, die unsere Funkcion zurückgibt, automatisch die Anzahl der Zahlen in der Folge, die kleiner sind als 2. Für Zahlen, die Teil der Mandelbrotmenge sind liefert unsere Function also die volle Länge von 50.

Schreiben wir also eine Function, die die Länge der Liste liefert. Diese Function bauen wir dann in unser ursprüngliches Gerüst ein, um die komplexe Zahlenebene nach Zahlen die Teil der Mandelbrotmenge sind, zu untersuchen. Die Form die entsteht ist erstaunlich.

In [85]:
import matplotlib.pyplot as plt
%matplotlib inline

import numpy as np


def mandelbrotfolge(c):
    ret = [c] 
    for it in range(1,50):
        z = ret[it - 1]**2 + c
        ret.append(z)
        if abs(z)>2:
            break 
    return ret
    


def check_mandelbrot(c):
    #gibt die Länge der Liste zurück
    return len(mandelbrotfolge(c))
    

r_bereich = np.arange(-2,0.5,0.01)
i_bereich = np.arange(-1.25,1.25,0.01)


mandelbrot = np.zeros([len(i_bereich),len(r_bereich)])

for it in range(len(i_bereich)):
    for jt in range(len(r_bereich)):
        mandelbrot[it,jt]= check_mandelbrot(complex(r_bereich[jt],i_bereich[it]))

plt.pcolormesh(r_bereich,i_bereich,mandelbrot)
plt.ylabel("Imaginärteil")
plt.xlabel("Realteil")
    
Out[85]:
Text(0.5,0,'Realteil')

Wir können auch hineinzoomen und uns einen winzigen Teil der komplexen Ebene ansehen. Egal wie sehr wir hineinzoomen, wir werden immer feinere Strukturen erkennen.

In [86]:
import matplotlib.pyplot as plt
%matplotlib inline

import numpy as np


def mandelbrotfolge(c):
    ret = [c] 
    for it in range(1,50):
        z = ret[it - 1]**2 + c
        ret.append(z)
        if abs(z)>2:
            break 
    return ret
    


def check_mandelbrot(c):

    return len(mandelbrotfolge(c))


r_bereich = np.arange(-1.49,-1.47,0.00005)
i_bereich = np.arange(-0.005,0.005,0.00005)


mandelbrot = np.zeros([len(i_bereich),len(r_bereich)])

for it in range(len(i_bereich)):
    for jt in range(len(r_bereich)):
        mandelbrot[it,jt]= check_mandelbrot(complex(r_bereich[jt],i_bereich[it]))

plt.pcolormesh(r_bereich,i_bereich,mandelbrot)
plt.ylabel("Imaginärteil")
plt.xlabel("Realteil")
Out[86]:
Text(0.5,0,'Realteil')
In [87]:
import matplotlib.pyplot as plt
%matplotlib inline

import numpy as np


def mandelbrotfolge(c):
    ret = [c] 
    for it in range(1,50):
        z = ret[it - 1]**2 + c
        ret.append(z)
        if abs(z)>2:
            break 
    return ret
    


def check_mandelbrot(c):

    return len(mandelbrotfolge(c))


r_bereich = np.arange(-0.75,-0.746,0.00002)
i_bereich = np.arange(0.105,0.11,0.00002)


mandelbrot = np.zeros([len(i_bereich),len(r_bereich)])

for it in range(len(i_bereich)):
    for jt in range(len(r_bereich)):
        mandelbrot[it,jt]= check_mandelbrot(complex(r_bereich[jt],i_bereich[it]))

plt.pcolormesh(r_bereich,i_bereich,mandelbrot)
plt.ylabel("Imaginärteil")
plt.xlabel("Realteil")
Out[87]:
Text(0.5,0,'Realteil')

Warum die Mandelbrotmenge genau diese fraktale Form hat, kann nicht erklärt werden. Sie dient aber als eindrucksvolles Beispiel, dass sehr simple Rekursionen zu außerordentlicher Komplexität führen können und die Komplexität nicht durch den Menschen entsteht, sondern von Natur aus vorhanden ist, und nur entdeckt werden muss.

Zusammenfassung

Rekursionen

Von Rekursionen spricht man, wenn man eine Function in sich selbst einsetzt. Dabei muss man aber, ähnlich wie bei While-Schleifen aufpassen, dass man keine Endlosschleifen produziert. Dafür ist es wichtig, dass es zumindest eine Bedingung gibt, bei der die Function sich selbst nicht aufruft, und diese Bedingung auch garantiert irgendwann erreicht wird. Meist ist es jedoch besser, dass man rekursive Aufrufe vermeidet, und die Rekursion auf eine Iteration umschreibt.

Komplexe Zahlen

Komplexe Zahlen können in Python mit complex(Realteil, Imaginärteil) eingegeben werden. Dann können die normalen Rechenoperationen verwendet werden (+,*,abs(),...) und Pyhton geht korrekt mit komplexen Zahlen um. Anstelle vom in der Mathematik üblichen $i$, verwendet Python beim Ausgeben von komplexen Zahlen das eher in der Technik verbreitete $j$ für die imaginäre Einheit $\sqrt{-1}$.

np.arange

Mit np.arange kann man Zahlenintervall von beliebeiger Genauigkeit erstellen, über die man dann iterieren kann. Die Syntax dazu lautet np.arange(startwert, zielwert, schrittweite).

break

Oft möchte man Schleifen vorzeitig unterbrechen, obwohl die primäre Abbruchbedingung noch nicht erreicht ist. Dazu gibt es in Python den Befehl break. break unterbricht die aktuelle Schleife und der Code direkt unterhalb der Schleife wird ganz normal weiter ausgeführt.

Kapitel 11 - Klassen und Objekte

Modernes Programmieren ist zumeist objektorientiert. Das heißt der Fokus liegt nicht so sehr auf fest vorgelegten Abläufen und Rechenoperationen, die abgearbeitet werden, sondern auf Objekten, die definiert werden, Eigenschaften und Fähigkeit haben und sehr vielseitig benutzt werden können.

Auch in Python kann man sehr leicht eigene Objekte erstellen. Ein Objekt gehört immer einer Klasse an, die man auch selbst definieren kann.

Im Folgenden werden wir Objekte und Klassen nutzen um ein Verkehrssystem zu simulieren.

Dieses Verkehrssystem wird sehr rudimentär sein. Es wird Personen geben, die einen Heimatort und einen Arbeitsort besitzen, und zwischen diesen Orten hin- und herpendeln. Eine Person wird immer ein Objekt einer Klasse sein und Eigenschaften (z.B. Heimatort) und Fähigkeiten - beim Programmieren eher Methoden genannt - (z.b. zur Arbeit fahren) haben.

Fangen wir einfach an: Wir definieren die Klasse Person mit dem Wort class. Danach können wir Methoden für diese Klasse definieren. Eine Methode, die eigentlich alle Klassen haben ist die Inititalisierungmethode. Mit dieser Methode kann man neue Objekte von dieser Klasse erstellen. In Python trägt diese Initialisierungsmethode immer den gleichen Namen wie die Klasse selbst. Wenn wir die Klasse Person nennen, können wir also mit dem Befehl Person() neue Personen erstellen. Der Name von dieser Initialisierungsmethode ist in Python einheitlich und lautet __init__, alle anderen Methoden dürfen wir selbst benennen. In der Initalisierungsmethode werden meist die Eigenschaften dieses Objekts definiert. Die Eigenschaft home würde man hierbei als self.home bezeichnen. Das heißt, jede Person hat ein Zuhause, aber nicht unbedingt das gleiche. Beim Inititalisieren einer Eigenschaft setzen wir den Wert, der diese Eigenschaft für das aktuelle Objekt hat, fest, also self.home.

Wir erstellen nun also die Klasse Person und legen die Initialisierungsmethode fest. Dort bekommen die Personen eine zufällige Heimatadresse. Methoden werden ähnlich definiert wie Functions, der einzige Unterschied ist, dass Methoden als erste Inputvariable immer self haben, also sich selbst. Immer wenn also eine Methode aufgerufen wird, wird automatisch übergeben, welches Objekt dieser Klasse (also für uns welche Person) die Methode aufgerufen hat.

In [88]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        

Wie beim Definieren von Funktion, macht das Definieren von Klassen an sich noch nichts. Sie müssen immer erst im Hauptprogramm aufgerufen werden. Erstellen wir nun zur Probe eine Person und zeichen wir ihre Heimatadresse als Scatterplot.

In [89]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        
        
#----Hier startet das Hauptprogramm----


#wir erstellen ein Objekt der Klasse person mit dem Namen person1
person1 = Person()
#Dann zeichnen wir den Heimatort von person1
#s ist die Größe des Markers, die Form "s" steht für square
plt.scatter(person1.home[0],person1.home[1], s = 100, marker = "s", c = "forestgreen")
Out[89]:
<matplotlib.collections.PathCollection at 0x2ab3c21b198>

Wir haben nun erfolgreich ein Objekt der Klasse Person erstellt und die Heimatadresse gezeichnet. Eleganter wäre das aber, wenn wir das Zeichnen nicht im Hauptprogramm machen, sondern es als Methode erstellen: Alle Personen sollen die Fähigkeit erhalten sich selbst zu zeichnen. Wir nennen diese Methode zeichnen und definieren sie direkt nach __init__. Immer wenn wir diese Methode dann aufrufen möchten schreiben wir NameDerPerson.zeichnen().

In [90]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        
    def zeichnen(self):
        #Die Personen zeichnen ihre Heimatadresse
        #s ist die Größe des Markers, die Form "s" steht für square
        plt.scatter(self.home[0],self.home[1], s = 100, marker = "s", c = "forestgreen")
        
        
#----Hier startet das Hauptprogramm----

#wir erstellen ein Objekt der Klasse person mit dem Namen person1
person1 = Person()
#Dann zeichnen wir den Heimatort von person1
person1.zeichnen()

Mit dieser eleganten Lösung können wir nun auch mehrere Objekte der gleichen Klasse einfach erstellen und verwalten. Wir erstellen eine leere Liste und fügen dann 20 Objekte der Klasse Person hinzu. In einer Schleife rufen wir sie dann alle auf und lassen sie zeichnen.

In [92]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        
    def zeichnen(self):
        #Die Personen zeichnen ihre Heimatadresse
        #s ist die Größe des Markers, die Form "s" steht für square
        plt.scatter(self.home[0],self.home[1], s = 100, marker = "s", c = "forestgreen")
        
        
#----Hier startet das Hauptprogramm----

#Wir erstellen eine leere Liste
allepersonen=[]        

#Wir fügen neue Personen
for it in range(20):
    allepersonen.append(Person())

for it in range(20):
    allepersonen[it].zeichnen()

Die letzte Schleife läuft hierbei über eine Liste von Objekten und ruft im it-ten Schritt das it-te Objekt auf um etwas damit zu machen. Diese Struktur ist so häufig, dass es in Python dafür eine Kurzschreibweise gibt. Statt

for it in range(20):

$\ \ \ \ \ \ $Befehl für Objekt das unter listenname[it] gespeichert ist

kann man verkürz schreiben

for obj in listenname:

$\ \ \ \ \ \ $Befehl für obj

, wobei obj eine frei wählbare Bezeichnung ist.

Für unser Programm wäre diese Kurzschreibweise also:

In [93]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        
    def zeichnen(self):
        #Die Personen zeichnen ihre Heimatadresse
        #s ist die Größe des Markers, die Form "s" steht für square
        plt.scatter(self.home[0],self.home[1], s = 100, marker = "s", c = "forestgreen")
        
        
#----Hier startet das Hauptprogramm----

#Wir erstellen eine leere Liste
allepersonen=[]        

#Wir fügen neue Personen
for it in range(20):
    allepersonen.append(Person())

for p in allepersonen:
    p.zeichnen()

Zusätzlich zu einer Heimatadresse bekommen alle Personen nun auch einen Arbeitsort. In unserem Modell gibt es 3 Arbeitsorte, die unterschiedlich groß sind. Ein Arbeitsort befindet sich auf Position [7,9], einer auf [4,1] und einer auf [5,5].

Beim Intitialisieren bekommen alle Personen also auch einen Arbeitsort aus einer Liste der Arbeitsorte zugewiesen. Da nicht alle Arbeitsorte gleich oft besucht werden, kommen auch nicht alle Orte gleich oft in der Liste vor.

Auch in der Methode zeichnen nehmen wir eine Änderung vor: Auch der Arbeitsplatz soll gezeichnet werden.

In [94]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        #und eine Arbeitsadresse aus einer Liste
        self.work = random.choice([[5,5],[5,5],[5,5],[4,1],[4,1],[7,9]])
        
    def zeichnen(self):
        #Die Personen zeichnen ihre Heimatadresse
        #s ist die Größe des Markers, die Form "s" steht für square
        plt.scatter(self.home[0],self.home[1], s = 100, marker = "s", c = "forestgreen")
        #Und ihren Arbeitsort
        plt.scatter(self.work[0],self.work[1], s = 100, marker = "s", c = "deepskyblue")
        
        
#----Hier startet das Hauptprogramm----

#Wir erstellen eine leere Liste
allepersonen=[]        

#Wir fügen neue Personen
for it in range(20):
    allepersonen.append(Person())

for p in allepersonen:
    p.zeichnen()

Nun möchten wir noch eine wichtige Methode in unser Verkehrssystem einbauen. Personen sollen die Fähigkeit bekommen von ihrem Heimatort zum Arbeitsort zu fahren. Damit sehen wir dann, welche Straßen der Modellstadt häufig benutzt werden und welche nicht so häufig.

Für das Fahren benutzen wir die sogenannte Manhattan-Metrik: In dieser Metrik ist es nur erlaubt entlang der x-Achse und entlang der y-Achse zu fahren, nicht aber diagonal. Unsere Personen fahren also zuerst in x-Richtung bei konstantem y, dann in y-Richtung bei konstantem x.

Das Fahren selbst implementieren wir einfach als Linienplots zusätzlich zu unserem bisherigen Plot.

Erstellen wir nun die Methode fahren und rufen wir sie für alle Personen auf.

In [95]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        #und eine Arbeitsadresse aus einer Liste
        self.work = random.choice([[5,5],[5,5],[5,5],[4,1],[4,1],[7,9]])
        
    def zeichnen(self):
        #Die Personen zeichnen ihre Heimatadresse
        #s ist die Größe des Markers, die Form "s" steht für square
        plt.scatter(self.home[0],self.home[1], s = 100, marker = "s", c = "forestgreen")
        #Und ihren Arbeitsort
        plt.scatter(self.work[0],self.work[1], s = 100, marker = "s", c = "deepskyblue")
        
    def fahren(self):
        #Wir zeichnen zwei Linien:
        #Linie 1 geht in der x-Richtung von home nach work, y bleibt konstant bei home
        #Linie 2 geht in y-Richtung von home nach work, x bleibt konstant bei work
        plt.plot([self.home[0],self.work[0]],[self.home[1],self.home[1]],c = "black", lw = 4)
        plt.plot([self.work[0],self.work[0]],[self.home[1],self.work[1]],c = "black", lw = 4)
        
        
#----Hier startet das Hauptprogramm----

#Wir erstellen eine leere Liste
allepersonen=[]        

#Wir fügen neue Personen
for it in range(20):
    allepersonen.append(Person())

for p in allepersonen:
    p.zeichnen()
    p.fahren()

Nun sehen wir welche Strecken benutzt werden, aber nicht unbedingt welche wie oft benutzt wird, da wir oft Linien übereinderzeichnen und der Plot keine Auskunft darüber gibt, wie viele Linien übereinander gezeichnet wurden.

Dieses Problem können wir mit dem Attribut alpha lösen. Das alpha einer Linie sagt uns die Deckkraft. Der Wert 0.1 sagt, dass nur 10 Prozent Deckkraft benutzt werden, also erst wenn wir 10 Linien übereinander zeichnen, sieht die Linie wirklich schwarz aus, sonst sieht man Teile des weißen Hintergrunds durchschimmern. Damit sollten wir mehr über unser Verkehrssystem lernen können.

In [96]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        #und eine Arbeitsadresse aus einer Liste
        self.work = random.choice([[5,5],[5,5],[5,5],[4,1],[4,1],[7,9]])
        
    def zeichnen(self):
        #Die Personen zeichnen ihre Heimatadresse
        #s ist die Größe des Markers, die Form "s" steht für square
        plt.scatter(self.home[0],self.home[1], s = 100, marker = "s", c = "forestgreen")
        #Und ihren Arbeitsort
        plt.scatter(self.work[0],self.work[1], s = 100, marker = "s", c = "deepskyblue")
        
    def fahren(self):
        #Wir zeichnen zwei Linien:
        #Linie 1 geht in der x-Richtung von home nach work, y bleibt konstant bei home
        #Linie 2 geht in y-Richtung von home nach work, x bleibt konstant bei work
        plt.plot([self.home[0],self.work[0]],[self.home[1],self.home[1]],c = "black", alpha = 0.1, lw = 4)
        plt.plot([self.work[0],self.work[0]],[self.home[1],self.work[1]],c = "black", alpha = 0.1, lw = 4)
        
        
#----Hier startet das Hauptprogramm----

#Wir erstellen eine leere Liste
allepersonen=[]        

#Wir fügen neue Personen
for it in range(20):
    allepersonen.append(Person())

for p in allepersonen:
    p.zeichnen()
    p.fahren()

Noch mehr Informationen über das Verkehrssystem würden wir erhalten, wenn wir die zurückgelegte Gesamtstrecke berechnen würden. Dazu werden wir in einem ersten Schritt eine Methode einbauen, die zu jeder Person die Länge des Arbeitsweges ausgibt.

In [97]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        #und eine Arbeitsadresse aus einer Liste
        self.work = random.choice([[5,5],[5,5],[5,5],[4,1],[4,1],[7,9]])
        
    def zeichnen(self):
        #Die Personen zeichnen ihre Heimatadresse
        #s ist die Größe des Markers, die Form "s" steht für square
        plt.scatter(self.home[0],self.home[1], s = 100, marker = "s", c = "forestgreen")
        #Und ihren Arbeitsort
        plt.scatter(self.work[0],self.work[1], s = 100, marker = "s", c = "deepskyblue")
        
    def fahren(self):
        #Wir zeichnen zwei Linien:
        #Linie 1 geht in der x-Richtung von home nach work, y bleibt konstant bei home
        #Linie 2 geht in y-Richtung von home nach work, x bleibt konstant bei work
        plt.plot([self.home[0],self.work[0]],[self.home[1],self.home[1]],c = "black", alpha = 0.1, lw = 4)
        plt.plot([self.work[0],self.work[0]],[self.home[1],self.work[1]],c = "black", alpha = 0.1, lw = 4)
        
    def arbeitsweg(self):
        #der Weg errechnet sieh hier aus dem Abstand in x-Richtung + dem Abstand in y-Richtung
        dist = abs(self.home[0]-self.work[0]) + abs(self.home[1]-self.work[1])
        return dist
        
        
#----Hier startet das Hauptprogramm----

#Wir erstellen eine leere Liste
allepersonen=[]        

#Wir fügen neue Personen
for it in range(20):
    allepersonen.append(Person())

for p in allepersonen:
    p.zeichnen()
    p.fahren()

Nun schreiben wir noch eine Function, die uns alle Strecken aufsummiert und die Summe auf den Bildschirm ausgibt. Achtung: Diese Function ist keine Methode der Klasse, denn eine einzelne Person kann nicht über die Strecken aller anderen Personen bescheidwissen. Deswegen ist die Defintion dieser Funktion nicht eingerückt, also nicht teil der Klasse:

In [98]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        #und eine Arbeitsadresse aus einer Liste
        self.work = random.choice([[5,5],[5,5],[5,5],[4,1],[4,1],[7,9]])
        
    def zeichnen(self):
        #Die Personen zeichnen ihre Heimatadresse
        #s ist die Größe des Markers, die Form "s" steht für square
        plt.scatter(self.home[0],self.home[1], s = 100, marker = "s", c = "forestgreen")
        #Und ihren Arbeitsort
        plt.scatter(self.work[0],self.work[1], s = 100, marker = "s", c = "deepskyblue")
        
    def fahren(self):
        #Wir zeichnen zwei Linien:
        #Linie 1 geht in der x-Richtung von home nach work, y bleibt konstant bei home
        #Linie 2 geht in y-Richtung von home nach work, x bleibt konstant bei work
        plt.plot([self.home[0],self.work[0]],[self.home[1],self.home[1]],c = "black", alpha = 0.1, lw = 4)
        plt.plot([self.work[0],self.work[0]],[self.home[1],self.work[1]],c = "black", alpha = 0.1, lw = 4)
        
    def arbeitsweg(self):
        #der Weg errechnet sieh hier aus dem Abstand in x-Richtung + dem Abstand in y-Richtung
        dist = abs(self.home[0]-self.work[0]) + abs(self.home[1]-self.work[1])
        return dist

def gesamtstrecke():
    #wir berechnen die Gesamtstrecke
    gesamt = 0
    #Wir gehen in einer Schleife über alle Personen
    for p in allepersonen:
        #und summieren die Arbeitswege
        gesamt = gesamt + p.arbeitsweg()
    
    print("Gesamtstrecke:")
    print(gesamt)
        
#----Hier startet das Hauptprogramm----

#Wir erstellen eine leere Liste
allepersonen=[]        

#Wir fügen neue Personen
for it in range(20):
    allepersonen.append(Person())

for p in allepersonen:
    p.zeichnen()
    p.fahren()

gesamtstrecke()
Gesamtstrecke:
116

Hier steht nun die Gesamtstrecke über dem Plot, obwohl wir die gesamtstrecke erst danach aufrufen. Das hat damit zu tun, dass Python standardmäßig mit den Plots wartet und sie erst auf den Bildschirm gibt, wenn alle anderen Berechnungen fertig sind. Mit dem Befehl plt.show() können wir einen Plot sofort anzeigen lassen.

In [99]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        #und eine Arbeitsadresse aus einer Liste
        self.work = random.choice([[5,5],[5,5],[5,5],[4,1],[4,1],[7,9]])
        
    def zeichnen(self):
        #Die Personen zeichnen ihre Heimatadresse
        #s ist die Größe des Markers, die Form "s" steht für square
        plt.scatter(self.home[0],self.home[1], s = 100, marker = "s", c = "forestgreen")
        #Und ihren Arbeitsort
        plt.scatter(self.work[0],self.work[1], s = 100, marker = "s", c = "deepskyblue")
        
    def fahren(self):
        #Wir zeichnen zwei Linien:
        #Linie 1 geht in der x-Richtung von home nach work, y bleibt konstant bei home
        #Linie 2 geht in y-Richtung von home nach work, x bleibt konstant bei work
        plt.plot([self.home[0],self.work[0]],[self.home[1],self.home[1]],c = "black", alpha = 0.1, lw = 4)
        plt.plot([self.work[0],self.work[0]],[self.home[1],self.work[1]],c = "black", alpha = 0.1, lw = 4)
        
    def arbeitsweg(self):
        #der Weg errechnet sieh hier aus dem Abstand in x-Richtung + dem Abstand in y-Richtung
        dist = abs(self.home[0]-self.work[0]) + abs(self.home[1]-self.work[1])
        return dist

def gesamtstrecke():
    #wir berechnen die Gesamtstrecke
    gesamt = 0
    #Wir gehen in einer Schleife über alle Personen
    for p in allepersonen:
        #und summieren die Arbeitswege
        gesamt = gesamt + p.arbeitsweg()
    
    print("Gesamtstrecke:")
    print(gesamt)
        
#----Hier startet das Hauptprogramm----

#Wir erstellen eine leere Liste
allepersonen=[]        

#Wir fügen neue Personen
for it in range(20):
    allepersonen.append(Person())

for p in allepersonen:
    p.zeichnen()
    p.fahren()

plt.show()

gesamtstrecke()
Gesamtstrecke:
111

Nun möchten wir versuchen, ob wir diesen Verkehrssystem noch ein wenig optimieren können. Viele Personen leben weit weg von ihrem Arbeitsort und könnten sich durch Umziehen viel Strecke sparen. Schreiben wir also eine Methode, die den Heimatort einer Person probeweise ändert. Sollte sich der Arbeitsweg dadurch verlängert haben, wird die Änderung wieder rückgängig gemacht.

Diese Methode sollte aus 3 Teilen bestehen: Zuerst merken wir uns die aktuelle Strecke und Heimatadresse. Dann ändern wir die Heimatadresse auf einen neuen zufälligen Wert. Im letzten Schritt wird die Änderung rückgängig gemacht, wenn sie unvorteilhaft war (dafür benötigen wir die Informationen über alte Strecke und Heimatadresse).

Wir definieren diese Methode und geben unseren Personen dann 10 mal die Chance umzuziehen. Danach wiederholen wir den Plot und die Streckenberechnung, um zu sehen welchen Effekt diese Änderung hatte.

In [100]:
import matplotlib.pyplot as plt
%matplotlib inline
import random


class Person:
    def __init__(self):
        #eine neue Person wird initialisiert
        
        #sie bekommt eine zufällig Heimadresse
        self.home = [random.randint(0,10),random.randint(0,10)]
        #und eine Arbeitsadresse aus einer Liste
        self.work = random.choice([[5,5],[5,5],[5,5],[4,1],[4,1],[7,9]])
        
    def zeichnen(self):
        #Die Personen zeichnen ihre Heimatadresse
        #s ist die Größe des Markers, die Form "s" steht für square
        plt.scatter(self.home[0],self.home[1], s = 100, marker = "s", c = "forestgreen")
        #Und ihren Arbeitsort
        plt.scatter(self.work[0],self.work[1], s = 100, marker = "s", c = "deepskyblue")
    
    def fahren(self):
        #Wir zeichnen zwei Linien:
        #Linie 1 geht in der x-Richtung von home nach work, y bleibt konstant bei home
        #Linie 2 geht in y-Richtung von home nach work, x bleibt konstant bei work
        plt.plot([self.home[0],self.work[0]],[self.home[1],self.home[1]],c = "black", alpha = 0.1, lw = 4)
        plt.plot([self.work[0],self.work[0]],[self.home[1],self.work[1]],c = "black", alpha = 0.1, lw = 4)
        
    def arbeitsweg(self):
        #der Weg errechnet sieh hier aus dem Abstand in x-Richtung + dem Abstand in y-Richtung
        dist = abs(self.home[0]-self.work[0]) + abs(self.home[1]-self.work[1])
        return dist
        
    def umziehen(self):
        #Methode um den Heimatort zu wechseln
        
        #wir speichern den aktuellen Weg und den aktuellen Heimatort
        alterweg = self.arbeitsweg()
        oldhome = self.home
        
        #die Person zieht auf ein zufällige Position um
        self.home = [random.randint(0,10),random.randint(0,10)]
        
        #Wenn der Weg dadurch länger geworden ist...
        if self.arbeitsweg() > alterweg:
            #... wird die Änderung rückgängig gemacht
            self.home = oldhome

            
def gesamtstrecke():
    #wir berechnen die Gesamtstrecke
    gesamt = 0
    #Wir gehen in einer Schleife über alle Personen
    for p in allepersonen:
        #und summieren die Arbeitswege
        gesamt = gesamt + p.arbeitsweg()
    
    print("Gesamtstrecke:")
    print(gesamt)
        
        
#----hier beginnt das eigentliche Programm----  
    
        
#Wir erstellen eine leere Liste
allepersonen=[]        

#Wir fügen neue Personen
for it in range(20):
    allepersonen.append(Person())

#Wir iterieren über alle Personen
for p in allepersonen:
    #Jede Person benutzt die Methode zeichnen und fahren
    p.zeichnen()
    p.fahren()
    
#Der Plot wird angezeigt    
plt.show()
#Die Gesamtstrecke wird berechnet
gesamtstrecke()

#Wir lassen die Personen umziehen
for it in range(10):
    for p in allepersonen:
        p.umziehen()
     
    
#Eine neue Figur wird erstellt         
plt.figure()

#Wieder wird gezeichnet und die Gesamtstrecke berechnet
for p in allepersonen:
    p.zeichnen()
    p.fahren()
plt.show()   
gesamtstrecke()
Gesamtstrecke:
141
Gesamtstrecke:
42

Dieses Modell könnten wir nun natürlich beliebig erweitern. Wir können neue Methoden und Eigenschaften einbauen, oder die bestehenden verfeinern. Methoden können (gleich wie Functions) auch mehrere Inputwerte haben. Natürlich kann ein Programm auch mehrere verschiedene Klassen beinhalten.

Ein großer Vorteil von Klassen und Objekten: Das Hauptprogramm bleibt immer sehr gut lesbar und es gibt eine strikte Trennung von was passieren soll (Hauptprogramm) und wie es passieren soll (Methoden und Functions). Somit kann man ein Hauptprogramm meist benutzen, ohne dass man die einzelnen Functions und Methoden bis ins letzte Detail verstehen muss.

Dieses Konzept ist mehr oder weniger das Grundkonzept von modernem Programmieren: Schon von Anfang an haben wir zum Beispiel mit der Function plot Grafiken erstellt und Elemente zu einem Objekt der Klasse Liste hinzugefügt, ohne dass wir uns je mit der genauen Definition dieser Klassen beschäftigen mussten. Das Verwenden von vorgefertigen Functions, Klassen und sogar ganzen Bibliotheken und packages macht modernes Programmieren überhaupt erst möglich.

Zusammenfassung

Klassen und Objekte

In Python können wir Klassen definieren und dann Objekte erstellen, die diesen Klassen angehören. Jede Klasse wird durch class NameDerKlasse: definiert und hat eine Initialisierungs-Methode namens __init__ die dann vom Hauptprogramm mit dem Namen der Klasse aufgerufen wird. Durch diese Methode wird ein neues Objekt dieser Klasse erstellt, das unter einem Namen oder in einer Liste gespeichert werden kann.

Methoden und Eigenschaften

Eigenschaften sind Variablen, die alle Objekte von einer Klasse haben. So hat zum Beispiel jedes Objekt der Klasse "Person" in unserem Beispiel einen Heimatort. Diese Eigenschaften werden meistens in der Initialisierungsmethode erstellt und gespeichert. Eigenschaften werden innerhalb der Definitionen mit self.NameDerEigenschaft aufgerufen und verwändert. Außerhalb der Definition muss man auch angeben auf welches Objekt der Klasse man sich bezieht, also NameDesObjekts.NameDerEigenschaft.

Methoden sind sozusagen Functions innerhalb von Klassen. Sie können Input- und Outputvariablen haben und werden ähnlich wie übliche Functions definiert. Die erste Inputvariable ist bei Methoden jedoch immer self.

Methoden werden innerhalb einer Klassendefinition mit NameDerMethode(self,inputA,inputB,inputC,...): definiert und dann im Haupprogramm mit NameDesObjekts.NameDerMethode(inputA,inputB,inputC,...) aufgerufen. Man beachte, dass man beim Aufrufen kein self mehr benötigt, da der Name des Objekts schon vor dem Punkt steht.

Schnelles Iterieren

Schleifen, die über eine Liste von Objekten laufen und im i-ten Schritt das i-te Objekt aufrufen um etwas damit zu machen, lassen sich verkürzt schreiben. Statt

for it in range(20):

$\ \ \ \ \ \ $Befehl für Objekt das unter listenname[it] gespeichert ist

kann man immer

for obj in listenname:

$\ \ \ \ \ \ $Befehl für obj

schreiben, wobei obj eine frei wählbare Bezeichnung ist.

Kapitel 12 - Ein einfaches makroskopisches Verkehrsmodell

In diesem Kapitel nutzen wir unser Wissen über Klassen und andere Pythonstrukturen um ein einfaches Verkehrsmodell zu erstellen. Wir möchten herausfinden wie viele direkte (und für e-Autos auch indirekte) Emissionen Fahrzeuge verursachen und einige Szenarios durchrechnen.

Beginnen werden wir mit einer einfachen Klasse namens Car. Fürs erste soll diese Klasse nur einen Initialisierungsmethode bekommen und 3 Eigenschaften haben: age, size und distance. Diese drei Größen sind die Argumente der Initialisierungsmethode. Danach erstellen wir ein Testobjekt dieser Klasse und lassen uns die Eigenschaften wieder ausgeben.

In [215]:
class Car:
    def __init__(self, age, size, distance):
        self.age = age
        self.size = size
        self.distance = distance
testcar = Car(2,"m",5000)

print(testcar.age,testcar.size,testcar.distance)
2 m 5000

Das nächste was für uns relevante wäre, wären die CO2-Emissionen des Autos, die es pro Kilometer verursacht. Diese Eigenschaft werden wir aber nicht einfach einlesen, sondern aus den vorhandenen Informationen (also Größe und Alter) abschätzen. Dazu erstellen wir eine eigene Methode. Die Zahlenwerte, die wir angenommen haben orientieren sich an Hofer, Jäger, Füllsack: Large scale simulation of CO2 emissions caused by urban car traffic.

In [217]:
class Car:
    def __init__(self, age, size, distance):
        self.age = age
        self.size = size
        self.distance = distance
        self.calcemissions()
        
            
    def calcemissions(self):
        if self.size == "s":
            self.emissions = 120 * (1 + age * 0.02) #g/km
        elif self.size == "m":
            self.emissions = 140 * (1 + age * 0.02)
        elif self.size == "l":
            self.emissions = 180 * (1 + age * 0.02)

testcar = Car(2,"m",5000)

print(testcar.age,testcar.size,testcar.distance,testcar.emissions)            
2 m 5000 151.24681834894383

Hier müssen wir aber wieder vorsichtig sein: Wenn unter Size etwas steht, was wir nicht erwarten (wie z.B. "groß" oder "👶"), überspringen wir alle ifs und die Eigenschaft emissions wird nicht gesetzt. Wir brauchen also eine Fehlermeldung, die auf dieses Problem hinweist, um zu verhindern, dass wir mit fehlenden oder falschen Daten weiterarbeiten.

In [227]:
class Car:
    def __init__(self, age, size, distance):
        self.age = age
        self.size = size
        self.distance = distance
        self.calcemissions()
        
            
    def calcemissions(self):
        if self.size == "s":
            self.emissions = 120 * (1 + age * 0.02) #g/km
        elif self.size == "m":
            self.emissions = 140 * (1 + age * 0.02)
        elif self.size == "l":
            self.emissions = 180 * (1 + age * 0.02)
        else: 
            raise Exception("Unknown car size: " + str(self.size) )

testcar = Car(2,"👶",5000)

print(testcar.age,testcar.size,testcar.distance,testcar.emissions)     
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
<ipython-input-227-e17ed964b143> in <module>()
     17             raise Exception("Unknown car size: " + str(self.size) )
     18 
---> 19 testcar = Car(2,"👶",5000)
     20 
     21 print(testcar.age,testcar.size,testcar.distance,testcar.emissions)

<ipython-input-227-e17ed964b143> in __init__(self, age, size, distance)
      4         self.size = size
      5         self.distance = distance
----> 6         self.calcemissions()
      7 
      8 

<ipython-input-227-e17ed964b143> in calcemissions(self)
     15             self.emissions = 180 * (1 + age * 0.02)
     16         else:
---> 17             raise Exception("Unknown car size: " + str(self.size) )
     18 
     19 testcar = Car(2,"👶",5000)

Exception: Unknown car size: 👶

Nun, wo wir jedem Fahrzeug einen Emissionswert zuweisen können, können wir auch eine Methode bauen, die diesen Wert zurückgibt. Das ist alles was wir brauchen, um die Gesamtemissionen einer Fahrzeugflotte zu berechnen.

Um die Methode zu testen, erstellen wir auch gleich eine Population an Fahrzeugen.

In [230]:
import random


class Car:
    def __init__(self, age, size, distance):
        self.age = age
        self.size = size
        self.distance = distance
        self.calcemissions()
        
            
    def calcemissions(self):
        if self.size == "s":
            self.emissions = 120 * (1 + age * 0.02) #g/km
        elif self.size == "m":
            self.emissions = 140 * (1 + age * 0.02)
        elif self.size == "l":
            self.emissions = 180 * (1 + age * 0.02)
        else:
            raise Exception("Unknown car size: " + str(self.size) )
            
    def reportemissions(self):
        return self.distance * self.emissions
    
            
    
def makepopulation (distmean,diststd):
    allcars = []


    for it in range(10000):
        age = random.uniform(0,20)
        dist = random.gauss(distmean,diststd)
        size = random.choice(["s","m","l"]) 
        allcars.append(Car(age,size,dist))
    return allcars
    
    
distmean = 5500
diststd = 50        


        
#overall emissions
allcars = makepopulation(distmean, diststd)
em_sum = 0 
for car in allcars:
    em_sum = em_sum + car.reportemissions()
    
print(em_sum / 1000 / 10000, "kg pro Person")
869.6788068315942 kg pro Person

Interessant wird dieses Modell aber erst, wenn wir verschiedene Szenarios rechnen können, und diese miteinander vergleichen. Hier haben wir sehr viele Möglichkeiten. Wir könnten beispielsweise anschauen was passiert, wenn alle Personen, deren Auto älter ist als 10 Jahre, ein neues Auto kaufen. Alternativ können wir betrachten, was passiert wenn 25% der Personen auf mehr Busfahrten umsteigen, was ihre jährliche Fahrstrecke auf 10% reduziert. Für beide Szenarien brauchen wir neue Methoden: Eine die das Alter eines Fahrzeugs auf 0 setzt und eine die die gefahrene Strecke verändert.

Mit diesen neuen Methoden ist das Erstellen und Auswerten der Szenarien einfach:

In [236]:
import random


class Car:
    def __init__(self, age, size, distance):
        self.age = age
        self.size = size
        self.distance = distance
        self.calcemissions()
        
            
    def calcemissions(self):
        if self.size == "s":
            self.emissions = 120 * (1 + age * 0.02) #g/km
        elif self.size == "m":
            self.emissions = 140 * (1 + age * 0.02)
        elif self.size == "l":
            self.emissions = 180 * (1 + age * 0.02)
        else:
            raise Exception("Unknown car size: " + str(self.size) )
            
    def reportemissions(self):
        return self.distance * self.emissions
    
    def buynewcar(self):
        self.age = 0
        self.calcemissions()
        
    def usebus(self):
        self.distance = self.distance / 10

    
def makepopulation (distmean,diststd):
    allcars = []


    for it in range(10000):
        age = random.uniform(0,20)
        dist = random.gauss(distmean,diststd)
        size = random.choice(["s","m","l"]) 
        allcars.append(Car(age,size,dist))
    return allcars

            

distmean = 5500
diststd = 50        

        
#overall emissions
allcars = makepopulation(distmean, diststd)
em_sum = 0 
for car in allcars:
    em_sum = em_sum + car.reportemissions()
    
print("Baseline:  ", em_sum / 1000 / 10000, "kg pro Person")

#Szenario 1: Autos älter als 10 Jahre werden ersetzt
allcars = makepopulation(distmean, diststd)
em_sum = 0 
for car in allcars:
    if car.age > 10:
        car.buynewcar()
    em_sum = em_sum + car.reportemissions()
    
print("Szenario 1:",em_sum / 1000 / 10000, "kg pro Person")

#Szenario 2: 25% benutzen einen Bus
allcars = makepopulation(distmean, diststd)
em_sum = 0 
for car in allcars:
    if random.uniform(0,100)<20:
        car.usebus()
    em_sum = em_sum + car.reportemissions()
    
print("Szenario 2:",em_sum / 1000 / 10000, "kg pro Person")
Baseline:   869.7388177600628 kg pro Person
Szenario 1: 872.9046779718418 kg pro Person
Szenario 2: 713.6243753931545 kg pro Person

Nun können wir noch Elektroautos in unser Modell einbauen. Das wirft eine interessante Frage auf: Können wir die Klasse Car auch für E-Autos verwenden? Dafür spricht, dass wir die meisten Eigenschaften und Methoden genau gleich verwenden könnten. Dagegen spricht, dass das Berechnen der Emissionen bei E-Autos anders funktioniert. Hier hilft uns das Konzept von Erbschaft. Wir können in Python, basierend auf eine Klasse, eine Unterklasse erstellen, die alle Eigenschaften und Methoden der ursprünglichen Klasse erbt, aber darüber hinaus auch noch weitere Eigenschaften und Methoden besitzen kann. Außerdem ist es möglich, exisitierende Methoden für die Unterklasse anzupassen.

In unserem Modell können wir also die Unterklasse Ecar erstellen, die alles von Car erbt, aber eine andere Methode reportemissions besitzt:

In [ ]:
class Ecar(Car):
    def reportemissions(self):
        return "noch unbekannt"

Wie man sieht ist diese Klassendefinition sehr schlank, da wir das meiste aus Car erben. Nun müssen wir uns nur mehr überlegen, wie wir die Emissionen des E-Autos berechnen. Die Sache ist natürlich ein wenig komplizierter, denn die Emissionen entstehen nicht direkt auf der Straße, sondern wo auch immer der Strom erzeugt wurde, mit dem das Fahrzeug geladen wurde. Es kommt also ganz stark auf den Energiemix an, der verwendet wurde.

Die gesuchte Methode wird also so aussehen, dass wir die Distanz mit einer Zahl multiplizieren, die uns sagt wie viel Emissionen pro Kilometer durch die Stromerzeugung entstanden sind. Das korrekt durchzuführen ist natürlich schwierig, aber mit einem Life-Cycle-Assessement (LCA) können wir zumindest unterschiedliche Arten Strom zu produzieren vergleichen. Das gibt uns auch Anhaltspunkte für eine grobe Abschätzung der verursachten Emissionen. Details zu diesem Prozess findet man zum Beispiel in Turconi, Boldrin, Astrup.

Hier nehmen wir einen Verbrauch von 0.2 kWh pro Kilometer an und vergleichen den Energiemix von Graz, Wien und den USA. Wir finden folgende Werte:

  • Graz: 2.5 g/km | 90% Wasser (10g/kWh), 5% Wind (30g/kWh), 5% Biomasse(50g/kWh)|
  • Wien: 45 g/km | 45% Wasser, 45% Erdgas (500g/kWh), 5% Wind, 5% Biomasse|
  • USA: 100 g/km | 37% Öl (700g/kWh), 30% Gas (500g/kWh), 15% Kohle (800g/kWh), 10% Nuklear(20g/kWh), 8% renewable (30g/kWh)|

Diese Werte verwenden wir in der Methode reportemissions.

Um die E-Autos dann auch zu testen, müssen wir unsere Function makepopulation noch ein wenig anpassen. Wir benötigen zusätzlich den Prozentsatz der vorhandenen Elektroautos. Diese können wir für Österreich mit 0.4% abschätzen.

In [239]:
import random


class Car:
    def __init__(self, age, size, distance):
        self.age = age
        self.size = size
        self.distance = distance
        self.calcemissions()
        
            
    def calcemissions(self):
        if self.size == "s":
            self.emissions = 120 * (1 + age * 0.02) #g/km
        elif self.size == "m":
            self.emissions = 140 * (1 + age * 0.02)
        elif self.size == "l":
            self.emissions = 180 * (1 + age * 0.02)
        else:
            raise Exception("Unknown car size: " + str(self.size) )
            
    def reportemissions(self):
        return self.distance * self.emissions
    
    def buynewcar(self):
        self.age = 0
        self.calcemissions()
        
    def usebus(self):
        self.distance = self.distance / 10
            
class Ecar(Car):
    def reportemissions(self):
        return self.distance * emix
    
def makepopulation (echance,distmean,diststd):
    allcars = []


    for it in range(10000):
        age = random.uniform(0,20)
        dist = random.gauss(distmean,diststd)
        size = random.choice(["s","m","l"]) 
        if random.uniform(0,100)<echance:
            allcars.append(Ecar(age,size,dist))
        else:
            allcars.append(Car(age,size,dist))
    return allcars
    
    
    
emix = 2.5  #GRAZ: 90% Wasser (10g/kWh), 5% Wind (30g/kWh), 5% Biomasse(50g/kWh)
#emix = 45  #WIEN: 45% Wasser, 45% Erdgas (500g/kWh), 5% Wind, 5% Biomasse
#emix = 100 #USA: 37% Öl (700g/kWh), 30% Gas (500g/kWh), 15% Kohle (800g/kWh), 10% Nuklear(20g/kWh), 8% renewable (30g/kWh)
            
echance = 0.4
distmean = 5500
diststd = 50        


        
#overall emissions
allcars = makepopulation(echance, distmean, diststd)
em_sum = 0 
for car in allcars:
    em_sum = em_sum + car.reportemissions()
    
print("Baseline:  ", em_sum / 1000 / 10000, "kg pro Person")

#Szenario 1: Autos älter als 10 Jahre werden ersetzt
allcars = makepopulation(echance, distmean, diststd)
em_sum = 0 
for car in allcars:
    if car.age > 10:
        car.buynewcar()
    em_sum = em_sum + car.reportemissions()
    
print("Szenario 1:",em_sum / 1000 / 10000, "kg pro Person")

#Szenario 2: 25% benutzen einen Bus
allcars = makepopulation(echance, distmean, diststd)
em_sum = 0 
for car in allcars:
    if random.uniform(0,100) < 20:
        car.usebus()
    em_sum = em_sum + car.reportemissions()
    
print("Szenario 2:",em_sum / 1000 / 10000, "kg pro Person")
Baseline:   866.7936403430951 kg pro Person
Szenario 1: 869.8223364537947 kg pro Person
Szenario 2: 712.3889638111342 kg pro Person

Somit ist es uns gelungen ein ganz einfaches, makroskopisches Verkehrsmodell in Python zu bauen. Dies lässt sich natürlich beliebig erweitern. Die Befehle und Strukturen die auftreten, werden jedoch auch bei äußerst komplexen Programmen die selben bleiben: Wenn man Klassen, Functions, Schleifen, Abfragen und Datentypen verstanden hat, kann man prinzipiell jedes nur denkbare Programm schreiben.

Zusammenfassung

Initialisierungsmethoden mit Argumenten

Initialisierungsmethoden können auch Argumente verlangen. Das ist völlig analog zu Functions mit

def __init__(self,arg1,arg2,arg3)

möglich. Diese Argumente (ohne self) müssen dann aber auch beim Erstellen eines Objekts übergeben werden:

obj = NameDerKlasse(arg1,arg2,arg3)

Erbschaft bei Klassen

Wenn man eine neue Klasse erstellen möchte, die auf einer existierenden Klasse basieren soll, gibt es das Konzept der Erbschaft. Damit können wir alle Methoden und Eigenschaften der Grundklasse übernehmen, diese aber ändern und ausbauen.

Class NameDerNeuenKlasse(NameDerGrundklasse):

Kapitel 13 - Visualisierungen

Egal, was programmiert werden soll, ob ein Modell erstellt wird, eine Simulation durchgeführt oder statistische Daten ausgewertet werden: am Ende sollen die Ergebnisse meist visualisiert werden. Die Darstellung von Ergebnissen - das "Plotten" - ist oft ebenso wichtig, wie die Ergebnisse selbst und es gibt unzählige Arten des Visualisierens.

Einige wichtige Regeln zum Erstellen von Grafiken sollten, unabhängig von der Art der Darstellung, immer beachtet werden:

  • alle Achsen sollten beschriftet sein
  • wenn möglich sollte im Plot ersichtlich sein, welche Einheiten verwendet werden
  • alle relevanten Größen sollten erkennbar sein
  • Farben sollten so gewählt werden, dass sie auch in Graustufen gut unterscheidbar sind. Alternativ können Markierungen verwendet werden.

Im Folgenden geben wir eine Übersicht über die gängigsten Arten, Ergebnisse zu visualisieren. Einige davon haben wir bereits kennengelernt, anderen sind wir noch nicht begegnet.

Eine Übersicht über alle Farben, die in Matplotlib einen Eigennamen haben finden Sie hier: https://matplotlib.org/examples/color/named_colors.html

Der Linienplot

In [1]:
import matplotlib.pyplot as plt
%matplotlib inline

testdaten = [1, 2, 3, 4, 7, 8, 7, 5, 4, 2]
plt.xlabel("Das ist die Beschriftung der x-Achse")
plt.ylabel("Das ist die Beschriftung der y-Achse")
plt.title("Titel des Plots")

plt.plot(testdaten, color = "forestgreen", lw = 2, marker = "*", markersize = 10, label = "Linienbeschriftung")
plt.legend(loc = "best")
Out[1]:
<matplotlib.legend.Legend at 0xa988d30>

Ein Linienplot ist eine der einfachsten Arten, Daten zu visualisieren. Immer wenn man eine Größe (z.B. Anzahl der Frösche) hat, die man gegen eine andere Größe auftragen möchte (z.B. Zeit) bietet sich ein Linienplot an.

Der Fill-Plot

In [2]:
import matplotlib.pyplot as plt
%matplotlib inline

testdaten = [1, 4, 8, 6, 7, 5, 6, 5, 4, 1]
plt.xlabel("Das ist die Beschriftung der x-Achse")
plt.ylabel("Das ist die Beschriftung der y-Achse")
plt.title("Titel des Plots")

plt.fill(testdaten, color = "orange", label = "Beschriftung")
plt.legend(loc = "best")
Out[2]:
<matplotlib.legend.Legend at 0xa988cf8>

Der Fill-Plot ist konzeptionell ähnlich wie der Linienplot, nur möchte man hier eher auf die Fläche unter der Kurve hinweisen, als auf die Kurve selbst.

Der Stackplot

In [2]:
import matplotlib.pyplot as plt
%matplotlib inline

testdaten = [1, 1, 1, 1, 2, 2, 1, 1, 3]
testdaten2 = [1, 3, 1, 1, 2, 2, 1, 3, 1]
testdaten3 = [1, 2, 1, 1, 1, 2, 1, 1, 1]

plt.xlabel("Das ist die Beschriftung der x-Achse")
plt.ylabel("Das ist die Beschriftung der y-Achse")
plt.title("Titel des Plots")

plt.stackplot(range(len(testdaten)), testdaten, testdaten2, testdaten3, labels = ("B1","B2","B3"),
              colors=["chocolate","peru","burlywood"])
plt.legend(loc = "best")
Out[2]:
<matplotlib.legend.Legend at 0x1de1222d908>

Beim Stackplot können wir mehrere Fill-Plots übereinanderstapeln. Der Stackplot ist relativ schwer zu lesen, da man ihn leicht mit einem Linienplot verwechselt. In unserem Beispiel, würde man also fälschlicherweise glauben, dass die Größe B3 am Anfang den Wert 3 hat. In wirklichkeit ist der Plot aber so zu lesen: B3 geht von 2 bis 3, hat also den Wert 1. Auch Steigungen kann man in einem Stackplot nicht mehr korrekt ablesen. Stackplots sind also mit Vorsicht zu genießen. Wo sie aber doch Sinn machen, ist wenn man Größen hat, die sich immer auf eine gemeinsame Summe ergänzen. Zum Beispiel den prozentuellen Anteil einer gewissen Heiz-Technologie. Alle Technologien sollten sich immer auf 100% ergänzen:

In [3]:
import matplotlib.pyplot as plt
%matplotlib inline

tec1 = [54,50,45,42,30,25,20,11,8]
tec2 = [26,26,28,31,44,50,53,62,61]
tec3 = [15,14,15,14,14,13,14,15,18]
tec4 = [ 5,10,12,13,12,12,13,12,13]
plt.xlabel("Das ist die Beschriftung der x-Achse")
plt.ylabel("Das ist die Beschriftung der y-Achse")
plt.title("Titel des Plots")

plt.stackplot(range(len(tec1)), tec1, tec2, tec3, tec4, labels=("Kohle","Fernwaerme","Oel","Sonstige"),
              colors = ["brown","lightsalmon","black","gray"])
plt.legend(loc = "lower left")
Out[3]:
<matplotlib.legend.Legend at 0x1de122dbeb8>

Der Scatterplot

Alle bisherigen Plots hatten mehr oder weniger das gleiche Anwendungsgebiet: Wir möchten eine Größe $y$ (Anzahl der Frösche) einer anderen Größe $x$ (Zeit) eindeutig zuordnen. Was aber, wenn diese Zuordnung nicht eindeutig ist. Was ist, wenn wir die Frösche wiegen und ihr Alter bestimmen wollen. In diesem Fall ist es nicht so, dass wir jedem Alter ein exaktes Gewicht zuordnen können: Frösche mit gleichem Alter können unterschiedliches Gewicht haben, und Frösche mit unterschiedlichem Alter könnten durchaus das gleiche Gewicht haben. Was passiert also, wenn wir alle Frösche im Teich wiegen, und sowohl ihr Alter als auch ihr Gewicht grafisch darstellen wollen? Versuchen wir zuerst einen Linienplot.

In [4]:
import matplotlib.pyplot as plt
%matplotlib inline

# Die Daten sind wie folgt aufgebaut:
# froschgewicht = (gewicht von frosch1, gewicht von frosch2, gewicht von frosch3, ...)
# froschalter = (alter von frosch1, alter von frosch2, alter von frosch3, ...)
froschgewicht = (100,105,106,105,107,105,111,112,104,122,80,111,75,101)
froschalter = (3, 5, 5, 4, 6, 8, 10, 5, 3, 7, 2, 4, 3, 5)
plt.plot(froschalter, froschgewicht, color = "green")
plt.xlabel("Alter (Jahre)")
plt.ylabel("Gewicht (g)")
Out[4]:
<matplotlib.text.Text at 0x1de121d9a90>

Dieser Plot ist prinzipiell nicht falsch, er ist nur sehr verwirrend. Die Verbindungslinien suggerieren, dass die Frösche, die zufällig nacheinander gewogen wurden, irgendetwas miteinander zu tun hätten. Die Reihenfolge der Frösche hat in diesem Plot aber keine Bedeutung, die Frösche hätten auch in ganz anderer Reihenfolge gewogen werden können. Um diesen Plot zu reparieren, sollten wir also die Verbundungslinien entfernen und einfach nur für jeden Frosch eine Markierung vornehmen. Damit entsteht ein so genannter Scatter-Plot.

In [5]:
import matplotlib.pyplot as plt
%matplotlib inline

# Die Daten sind wie folgt aufgebaut:
# froschgewicht = (gewicht von frosch1, gewicht von frosch2, gewicht von frosch3, ...)
# froschalter = (alter von frosch1, alter von frosch2, alter von frosch3, ...)
froschgewicht = (100,105,106,105,107,105,111,112,104,122,80,111,75,101)
froschalter= (3, 5, 5, 4, 6, 8, 10, 5, 3, 7, 2, 4, 3, 5)
plt.scatter(froschalter, froschgewicht, color = "green", marker = "x")
plt.xlabel("Alter (Jahre)")
plt.ylabel("Gewicht (g)")
Out[5]:
<matplotlib.text.Text at 0x1de123d8c88>

Das Kuchendiagramm

Ein Klassiker unter den Visualisierungen, der sehr gut geeignet ist, um prozentuelle Anteile darzustellen, ist das Kuchen- oder auch Tortendiagramm. Als wissenschaftliches Diagramm ist es nur bedingt geeignet, da man konkrete Größen nur schwer ablesen kann. Für Präsentationen, oder um sich einen groben Überblick über Daten zu verschaffen, eignet es sich aber recht gut:

In [6]:
import matplotlib.pyplot as plt
%matplotlib inline

labels = ('Helle Froesche', 'Dunkel Froesche', 'Gelbe Froesche', 'Pfeilgiftfroesche')
farben = ("lightgreen","darkgreen","yellow", "orangered")
anzahl = [42, 32, 20, 6]
explode = (0, 0, 0, 0.2)  # Hervorgehoben werden nur Pfeilgiftfrösche

plt.pie(anzahl, explode = explode, labels = labels, autopct = '%1.1f%%',
        shadow = True, colors = farben)
plt.axis('equal')  # macht den Plot quadratisch
Out[6]:
(-1.1098228888537658,
 1.3062803190020018,
 -1.1130371722185557,
 1.1255162745181304)

Subplots

Oft möchte man mehrere Grafiken nebeneinander darstellen. Dafür eignen sich so genannte subplots. Die Syntax dafür ist ein wenig gewöhnungsbedürftig. Subplots werden immer mit drei Zahlen definiert, wobei zum Beispiel die Zahlenfolge (2, 3, 4) - oder alternativ auch einfach (234) - bedeutet, dass sämtliche Plots, die dargestellt werden sollen, in 2 Zeilen und 3 Spalten angordnet werden und aktuell (mit der letzten Zahl 4) der 4te von 6 Plots (2 mal 3) angesprochen wird.

Beim Definieren von Subplots wird oftmals mit fig = plt.figure(figsize=(10, 3)) zunächst eine allgemeine Zeichenfläche festgelegt. figsize definiert dabei die Größe der gesamten Zeichenfläche und der Befehl subplots_adjust(hspace = 0.5)erlaubt es, die Abstände zwischen den Subplots zu definieren, hier zum Beispiel die Höhenabstände (hspace). Mit Befehlen wie ax1 = fig.add_subplot(2, 3, 1) wird sodann der jeweilige Subplot in diese allgemeine Zeichenfläche eingefügt - hier also der Subplot ax1 an die erste Stelle der Plots.

Merke: wenn statt der matplotlib.pyplot-Abkürzung plt ein eigener Plotname definiert wird, wie hier etwa ax1, so müssen sämtliche Plot-Beschriftungen mit set_ vorgenommen werden, also zum Beispiel ax1.set_title('Plot 1').

In [8]:
import matplotlib.pyplot as plt
%matplotlib inline

testdaten=[2,3,4,4,2]

# definiere eine allgemeine Zeichenfläche
fig = plt.figure(figsize=(10, 4))
# lege Höhenabstände zwischen den Subplots fest
plt.subplots_adjust(hspace = 0.5)

# füge ersten Subplot zur allgemeinen Zeichenfläche hinzu
ax1 = fig.add_subplot(2, 3, 1)
ax1.plot(testdaten, color = "lime")
ax1.set_title('Plot 1')
# zweiter Subplot
ax2 = fig.add_subplot(2, 3, 2)
ax2.fill(testdaten, color = "green")
ax2.set_title('Plot 2')
# dritter Subplot
ax3 = fig.add_subplot(2, 3, 3)
ax3.plot(testdaten, color = "orange", lw=4)
ax3.set_title('Plot 3')
# vierter Subplot
ax4 = fig.add_subplot(2, 3, 4)
ax4.fill(testdaten, color = "salmon")
ax4.set_title('Plot 4')
# fünfter Subplot
ax5 = fig.add_subplot(2, 3, 5)
ax5.plot(testdaten, color = "black", marker="+", ms=20)
ax5.set_title('Plot 5')
# sechster Subplot
ax6 = fig.add_subplot(2, 3, 6)
ax6.fill(testdaten, color = "gold")
ax6.set_title('Plot 6')
Out[8]:
<matplotlib.text.Text at 0xc54c8d0>

Das Polardiagramm

Das Polardiagramm sieht zwar ähnlich aus, wie ein Kuchendiagramm, hat aber eine grundlegend andere Bedeutung. Wichtig ist hier der Winkel: jedem Winkel (0 - 360 Grad) wird eine bestimmte Größe zugeordnet. In der Physik wird dieser Plot zum Beispiel verwendet, um darzustellen, in welche Richtung ein Objekt wie viel Strahlung emittiert. Als einfacheres Beispiel könnte man damit auch das Sichtfeld verschiedener Tiere vergleichen:

In [7]:
import matplotlib.pyplot as plt
%matplotlib inline

# definiere eine allgemeine Zeichenfläche
fig = plt.figure(figsize=(15,5))
# füge einen Sub-Plot in die allgemeine Zeichenfläche ein, als ersten in einer ein-reihigen Zeile mit drei Plätzen = (1, 3, 1)
ax1 = fig.add_subplot(1, 3, 1, projection='polar')
# der Plot wird als Polarplot initialisiert
ax1.bar(0, 1, 3.1415/2, bottom = 0.0, color = "lightgreen")
# Für einen Polarplot lautet die Syntax:
# plt.bar(Um wieviel Grad ist die Fläche zentriert, Radius der Fläsche, welchen Winkel hat die Fläche)
# Beachte: Die Winkel werden hier nicht in Grad, sondern als Bogenmaß angegeben,
# also 360° = 2 * pi ~ 2 * 3.1415
ax1.set_title("Frosch")  # Beachte: mit subplot muss die Beschriftung mit set_ erfolgen, also zB set_title

# füge einen weiteren Sub-Plot hinzu, als zweiten in einer ein-reihigen Zeile mit drei Plätzen = (1, 3, 2)
ax2 = fig.add_subplot(1, 3, 2, projection='polar')
ax2.bar(0, 1, 3.1415, bottom = 0.0, color = "darkgreen")
ax2.set_title("Kroete")

# füge noch einen weiteren Sub-Plot hinzu, als dritten in einer ein-reihigen Zeile mit drei Plätzen = (1, 3, 3)
ax3 = fig.add_subplot(1, 3, 3, projection='polar')
ax3.bar(0, 1, 2 * 3.1415, bottom = 0.0, color = "lime")
ax3.set_title("Chamaeleon")
Out[7]:
<matplotlib.text.Text at 0x1de1248ca58>

Histogramme

Histogramme eigenen sich, um darzustellen, wie oft ein gewisser Wert in einer Liste vorkommt. Nehmen wir an, wir wissen, dass es eine Bevölkerungsexplosion in unserem Froschteich gab, wir wissen aber nicht genau, in welchem Jahr sie stattgefunden hat. Um diese Frage zu beantworten, fangen wir alle Frösche im Teich und bestimmen ihr Alter. Um unsere Forschungsfrage zu beantworten, stellen wir die resultierende Liste sodann als Histogramm dar:

In [8]:
import matplotlib.pyplot as plt
%matplotlib inline

froschalter=(15,16,15,17,14,13,11,14,14,12,13,1,1,2,3,4,4,5,6,2,1,1,1,3,4,5,14,14,10,10,14,14,11,11,14,
             14,13,12,14,13,12,11,11,10,7,6,8,9,13,13,13,13,5,4,3,2,4,5,6,7,8,12,12,9,4,12,2,3,4,5,6,7,8,9,10,10,
            6,7,8,4,5,9,8,2,3,4,5,6,7,8,13,13,12,12,13,13,13,19,18)

# Syntax: plt.hist(Liste, Anzahl der Balken, kleinster und größter Wert, Farbe)
plt.hist(froschalter, 18, range = (0.5, 18.5), color = "green")
plt.xlim(1,19)
plt.ylim(0,15)

# zeichne eine rote Vertikale, um die Bevölkerungsexplosion vor ca 15 Jahren zu markieren
plt.plot((14.5, 14.5),(0, 16), color = "red", lw = 3)
plt.ylabel("Anzahl")
plt.xlabel("Alter")
Out[8]:
<matplotlib.text.Text at 0x1de12481588>

Wir erkennen aus dem Histogramm, dass viele Frösche im Teich aktuell 14 Jahre alt sind, aber nur sehr sehr wenige 15 Jahre. Vor 14 Jahren wurden also wesentlich mehr Frösche geboren, als vor 15 Jahren. Die Bevölkerungsexplosion fand also offensichtlich vor ca 15 Jahren statt.

Box- und Violinplots

Box- und Violinplots eignen sich, ähnlich wie Histogramme, dazu Verteilungen darzustellen. Boxplots erlauben es überdies, mehrere Verteilungen miteinander zu vergleichen. Sie zeigen den Mittelwert einer Verteilung (rote Linie), den Bereich, in dem der Großteil der Werte liegt (schwarze Box), aber auch die Extremwerte und Ausreißer.

Sehr ähnlich sind Violinplots, mit dem Unterscheid, dass sie detailliertere Informationen zur Verteilung liefern. Ähnlich dem Histogramm zeigen sie (stark geglättet), in welchen Bereichen viele, und in welchen wenige Werte vorliegen.

Im folgenden vergleichen wir das Gewicht der Frösche in 3 verschiedenen Teichen, einmal mit Box- und einmal mit Violinplots:

In [9]:
import matplotlib.pyplot as plt
%matplotlib inline

teich1=(110,105,106,104,110,115,113,112,113,103,104,110,109,108,105,102,110,110,110,111,109,111,120)
teich2=(90,99,100,102,103,103,104,102,110,90,95,98,97,93,98,110,105,106,102,89)
teich3=(120,122,123,122,120,122,123,122,119,80,85,80,81,82,83,82,84,85)

fig = plt.figure(figsize=(12,4))
# erster Plot
ax1 = fig.add_subplot(1, 2, 1)
ax1.boxplot((teich1, teich2, teich3))
ax1.set_title("Boxplot")
ax1.set_ylabel("Gewicht (g)")

# zweiter Plot
ax2 = fig.add_subplot(1, 2, 2)
ax2.violinplot((teich1, teich2, teich3))
ax2.set_title("Violinplot")
ax2.set_ylabel("Gewicht (g)")
Out[9]:
<matplotlib.text.Text at 0x1de12898eb8>

Heatmaps

Beim plotten drei-dimensionaler Daten möchte man in der Regel jedem $x/y$-Daten-Paar einen weiteren $z$-Wert zuweisen, so wie etwa den Geo-Koordinaten auf topologischen Karten eine Seehöhe zugewiesen wird. Um diese Plots eindrucksvoll darzustellen, importieren wir im Folgenden vorgefertige Beispieldaten aus dem matplotlib.cbook mit dem Befehl get_sample_data.

In [10]:
import matplotlib.pyplot as plt
%matplotlib inline
# importiere Beispielsdaten
from matplotlib.cbook import get_sample_data
import numpy as np

data = np.load(get_sample_data('jacksboro_fault_dem.npz')) # liest einen Beispieldatensatz ein
z = data['elevation'] # speichert die Höheninformation des Datensatzes unter z

fig = plt.figure(figsize=(12, 8))
ax1 = fig.add_subplot(2, 2, 1)
ax1.imshow(z, cmap = plt.cm.terrain)    # colormap terrain: z.B. für Karten
ax2 = fig.add_subplot(2, 2, 2)
ax2.imshow(z, cmap = plt.cm.viridis)    # colormap viridis: für abstrakte Daten
ax3 = fig.add_subplot(2, 2, 3)
ax3.imshow(z, cmap = plt.cm.gist_earth) # colormap earth: für realistische Karten
ax4 = fig.add_subplot(2, 2, 4)
ax4.imshow(z, cmap = plt.cm.ocean)      # colormap ocean: Blautöne Für Meeresdarstellungen
Out[10]:
<matplotlib.image.AxesImage at 0x1de12a8f2b0>

Weitere Visualisierungen

Neben den hier vorgestellten Möglichkeiten gibt es noch viele mehr, um Daten zu visualisieren. Einen guten Überblick liefert die Plot-Gallerie von matplotlib unter https://matplotlib.org/gallery.html, die zu jedem Plot auch den Code bereitstellt, mit dem der Plot erzeugt wird.

Darüber hinaus gibt es auch andere spezialisierte Python-Pakete, die sich der Datenvisualisierung widmen. Beispiele sind seaborn, zum Darstellen von statistischen Daten, oder bokeh, das interaktive Visualisierungen im Browser erlaubt.

Zusammenfassung

Linienplots

Linienplots sind einfache Plots, die immer dann verwendet werden, wenn eine Größe eindeutig einer anderen Größe zugeordnet werden soll.

Scatterplots

Mit Scatterplots werden Datenpaare (z.B. Alter und Gewicht) dargestellt, die ohne eindeutige Zuordnung zusammengehören. Beispiel: ein drei Jahre alter Frosch muss nicht immer exakt 111 Gramm haben.

Histogramme, Box- und Violinplots

Mit Histogrammen, sowie Box- oder Violinplots werden Verteilungen dargestellt. Während ein Histogramm eine exakte Verteilung darstellt, lassen sich mit Box- und Violinplots mehrere Verteilungen miteinander vergleichen. Boxplots zeigen wichtige Charakteristika deutliche (Mittelwert, Standardabweichung, Ausreißer), Violinplots zeigen mehr Details der Verteilung.

Subplots

Subplots erlauben es, mehrere Grafiken nebeneinander darzustellen. Subplots werden immer mit drei Zahlen bezeichnet: subplot(a, b, c), wobei die Grafiken in einer Matrix mit $a$ Zeilen und $b$ Spalten organisiert sind und der jeweils gerade angesprochene Plot den Index $c$ hat.

Kapitel 14 - Daten verarbeiten mit Pandas

Python lässt sich auch sehr gut dazu nutzen große Datenmengen zu analysieren und zu bearbeiten. Ganz egal wie die Daten vorliegen (csv, excel,...), viele Datentypen lassen sich importieren und weiterverarbeiten. Das Pythonpaket, das man üblichweise dazu verwendet nennt sich pandas.

Wir werden in Folge pandas ein wenig kennenlernen, indem wir den World Happiness Report untersuchen. Diesen Datensatz, und noch viele viele weitere, findet man zum Beispiel frei verfügbar auf Kaggle. Dort gibt es auch Datensätze über alle Apps im Google Play Store, Filme, Börsendaten Development Indikatoren oder die Oberflächentemperatur der Erde.

Zuerst werden wir den Datensatz importieren. Das funktioniert einerseits mit Daten auf der eigenen Festplatte, andererseits aber auch mit Datein im Internet:

In [3]:
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline

url="https://s3.amazonaws.com/happiness-report/2018/WHR2018Chapter2OnlineData.xls"

df=pd.read_excel(url)

Nun sind die Daten des World-Happiness-Report 2018 unter df gespeichert. Diese Abkürzung steht für DataFrame, das ist der Datentyp den Pandas benutzt. Man kann ihn sich wie eine Excel-Tabelle vorstellen: Die Daten sind nach Zeilen und Spalten sortiert und können als Einträge entweder Zahlen oder Zeichenketten besitzen.

Da der Datensatz relativ groß ist, macht es wenig Sinn alle Daten auf einmal zu printen. Um aber trotzdem einen Einblick in den Datensatz zu bekommen, und zu bestätigen, dass das Einlesen funktioniert hat, kann man den Befehl head benutzen, um sich die ersten paar Einträge des Datensatzes anzeigen zu lassen.

In [4]:
df.head()
Out[4]:
country year Life Ladder Log GDP per capita Social support Healthy life expectancy at birth Freedom to make life choices Generosity Perceptions of corruption Positive affect Negative affect Confidence in national government Democratic Quality Delivery Quality Standard deviation of ladder by country-year Standard deviation/Mean of ladder by country-year GINI index (World Bank estimate) GINI index (World Bank estimate), average 2000-15 gini of household income reported in Gallup, by wp5-year
0 Afghanistan 2008 3.723590 7.168690 0.450662 49.209663 0.718114 0.181819 0.881686 0.517637 0.258195 0.612072 -1.929690 -1.655084 1.774662 0.476600 NaN NaN NaN
1 Afghanistan 2009 4.401778 7.333790 0.552308 49.624432 0.678896 0.203614 0.850035 0.583926 0.237092 0.611545 -2.044093 -1.635025 1.722688 0.391362 NaN NaN 0.441906
2 Afghanistan 2010 4.758381 7.386629 0.539075 50.008961 0.600127 0.137630 0.706766 0.618265 0.275324 0.299357 -1.991810 -1.617176 1.878622 0.394803 NaN NaN 0.327318
3 Afghanistan 2011 3.831719 7.415019 0.521104 50.367298 0.495901 0.175329 0.731109 0.611387 0.267175 0.307386 -1.919018 -1.616221 1.785360 0.465942 NaN NaN 0.336764
4 Afghanistan 2012 3.782938 7.517126 0.520637 50.709263 0.530935 0.247159 0.775620 0.710385 0.267919 0.435440 -1.842996 -1.404078 1.798283 0.475367 NaN NaN 0.344540

So sehen wir schon, welche Daten hier auftreten. Jede Zeile beinhaltet den Namen des Landes und das Jahr, gefolgt von vielen möglichen Indikatoren von Glücklichkeit, wie zum Beispiel die Lebenserwartung oder das Vertrauen in die Regierung.

Einen genaueren Überblick erhalten wir mit dem Befehl describe:

In [5]:
df.describe()
Out[5]:
year Life Ladder Log GDP per capita Social support Healthy life expectancy at birth Freedom to make life choices Generosity Perceptions of corruption Positive affect Negative affect Confidence in national government Democratic Quality Delivery Quality Standard deviation of ladder by country-year Standard deviation/Mean of ladder by country-year GINI index (World Bank estimate) GINI index (World Bank estimate), average 2000-15 gini of household income reported in Gallup, by wp5-year
count 1562.000000 1562.000000 1535.000000 1549.000000 1553.000000 1533.000000 1482.000000 1472.000000 1544.000000 1550.000000 1401.000000 1391.000000 1391.000000 1562.000000 1562.000000 583.000000 1386.000000 1205.000000
mean 2011.820743 5.433676 9.220822 0.810669 62.249887 0.728975 0.000079 0.753622 0.708969 0.263171 0.480207 -0.126617 0.004947 2.003501 0.387271 0.372846 0.386948 0.445204
std 3.419787 1.121017 1.184035 0.119370 7.960671 0.145408 0.164202 0.185538 0.107644 0.084006 0.190724 0.873259 0.981052 0.379684 0.119007 0.086609 0.083694 0.105410
min 2005.000000 2.661718 6.377396 0.290184 37.766476 0.257534 -0.322952 0.035198 0.362498 0.083426 0.068769 -2.448228 -2.144974 0.863034 0.133908 0.241000 0.228833 0.223470
25% 2009.000000 4.606351 8.310665 0.748304 57.299580 0.633754 -0.114313 0.697359 0.621471 0.204116 0.334732 -0.772010 -0.717463 1.737934 0.309722 0.307000 0.321583 0.368531
50% 2012.000000 5.332600 9.398610 0.833047 63.803192 0.748014 -0.022638 0.808115 0.717398 0.251798 0.463137 -0.225939 -0.210142 1.960345 0.369751 0.349000 0.371000 0.425395
75% 2015.000000 6.271025 10.190634 0.904329 68.098228 0.843628 0.094649 0.880089 0.800858 0.311515 0.610723 0.665944 0.717996 2.215920 0.451833 0.433500 0.433104 0.508579
max 2017.000000 8.018934 11.770276 0.987343 76.536362 0.985178 0.677773 0.983276 0.943621 0.704590 0.993604 1.540097 2.184725 3.527820 1.022769 0.648000 0.626000 0.961435

Dieser Befehl rechnet uns für jede Spalte relevante Indikatoren aus, wie zum Beispiel die Zahl der Einträge (count), den Mittelwert (mean), die Standardabweichung (std), aber auch detailiertere Informatioen über die Verteilung. Spalten wo diese Berechnugen nicht möglich sind (z.B. Name das Landes) werden ausgelassen. Somit sehen wir zum Beispiel, dass die mittlere gesunde Lebenserwartung bei etwa 62 Jahren liegt, es aber auch zumindest einen Eintrag in der Datenbank gibt, wo dieser Wert unter 40 Jahren liegt. Es wäre natürlich besonders interessant herauszufinden, welche Einträge das sind.

Solche Abfragen (also alle Zeilen, wo die Spalte X einen Wert von Y oder kleiner hat) sind mit Pandas sehr einfach möglich.

Wir schreiben:

df[df[NameDerSpalte] < Mindestwert]

In [10]:
df[df["Healthy life expectancy at birth"] < 40]
Out[10]:
country year Life Ladder Log GDP per capita Social support Healthy life expectancy at birth Freedom to make life choices Generosity Perceptions of corruption Positive affect Negative affect Confidence in national government Democratic Quality Delivery Quality Standard deviation of ladder by country-year Standard deviation/Mean of ladder by country-year GINI index (World Bank estimate) GINI index (World Bank estimate), average 2000-15 gini of household income reported in Gallup, by wp5-year
248 Central African Republic 2007 4.160130 6.761289 0.532297 38.385059 0.662871 0.099451 0.782131 0.567980 0.329995 0.623566 -1.468937 -1.397169 1.656170 0.398105 NaN 0.499 NaN
1207 Sierra Leone 2006 3.628185 6.975739 0.561356 37.766476 0.679001 0.113010 0.836166 0.505072 0.380655 0.541412 -0.314346 -1.065230 1.819301 0.501436 NaN 0.371 NaN
1208 Sierra Leone 2007 3.585127 7.025132 0.686471 38.560307 0.720373 0.259907 0.830483 0.581781 0.289842 0.561873 -0.139665 -1.031073 1.793122 0.500156 NaN 0.371 NaN
1209 Sierra Leone 2008 2.997251 7.053099 0.590737 39.351990 0.716396 0.160101 0.924901 0.533604 0.369601 0.687578 -0.189854 -1.007436 1.688531 0.563360 NaN 0.371 NaN
1550 Zimbabwe 2006 3.826268 7.366704 0.821656 39.087681 0.431110 -0.053216 0.904757 0.715229 0.297147 0.317073 -1.236102 -1.570760 2.013538 0.526241 NaN 0.432 NaN

Ähnlich können wir den Datensatz auch auf ein bestimmtes Jahr einschränken, zum beispiel den aktuellsten Wert des Datensatzes, das Jahr 2018:

In [4]:
df[df["year"] == 2017].head()
Out[4]:
country year Life Ladder Log GDP per capita Social support Healthy life expectancy at birth Freedom to make life choices Generosity Perceptions of corruption Positive affect Negative affect Confidence in national government Democratic Quality Delivery Quality Standard deviation of ladder by country-year Standard deviation/Mean of ladder by country-year GINI index (World Bank estimate) GINI index (World Bank estimate), average 2000-15 gini of household income reported in Gallup, by wp5-year
9 Afghanistan 2017 2.661718 7.460144 0.490880 52.339527 0.427011 -0.106340 0.954393 0.496349 0.371326 0.261179 NaN NaN 1.454051 0.546283 NaN NaN 0.286599
19 Albania 2017 4.639548 9.373718 0.637698 69.051659 0.749611 -0.035140 0.876135 0.669241 0.333884 0.457738 NaN NaN 2.682105 0.578096 NaN 0.303250 0.410488
25 Algeria 2017 5.248912 9.540244 0.806754 65.699188 0.436670 -0.194670 0.699774 0.641980 0.288710 NaN NaN NaN 2.039765 0.388607 NaN 0.276000 0.527556
41 Argentina 2017 6.039330 9.843519 0.906699 67.538704 0.831966 -0.186300 0.841052 0.809423 0.291717 0.305430 NaN NaN 2.409329 0.398940 NaN 0.476067 0.394176
53 Armenia 2017 4.287736 9.034711 0.697925 65.125687 0.613697 -0.132166 0.864683 0.625014 0.437149 0.246901 NaN NaN 2.325379 0.542333 NaN 0.325067 0.478877

Oft ist es auch sinnvoll Visualisierungen zu verwenden, um die Daten darzustellen. Wir könnten uns beispielweise den GINI Index als Histogramm anzeigen lassen:

In [5]:
df[df["year"] == 2017]['GINI index (World Bank estimate), average 2000-15'].hist()
Out[5]:
<matplotlib.axes._subplots.AxesSubplot at 0x18350516978>

Um Zusammenhänge und Korrelationen festzustellen kann man Scatterplots verwenden. Damit ist es möglich einen Wert auf der y-Achse und einen auf der x-Achse aufzutragen. Wenn eine Punktwolke ensteht gibt es keinen Zusammenhang zwischen diesen Größen. Je eher die Verteilung einer Geraden gleicht, um so stärker ist der Zusammenhang.

Betrachten wir den Zusammenhang zwischen BIP/Person und Lebenserwartung einerseits und den Zusammenhang zwischen "Social Support" und Lebenserwartung andererseits.

In [6]:
df[df["year"] == 2017].plot.scatter(x="Log GDP per capita", y = "Healthy life expectancy at birth")

df[df["year"] == 2017].plot.scatter(x="Social support", y = "Healthy life expectancy at birth")
Out[6]:
<matplotlib.axes._subplots.AxesSubplot at 0x18350616278>

Natürlich braucht man nicht für jede Analysie den kompletten Datensatz. Für genauere Betrachtungen macht es Sinn die Daten einzuschränken. Wir könnten also nur Deutschland, Österreich und die Schweiz betrachten.

Zuerst sehen wir uns den Mittelwert der Korruption für diese Länder über alle Jahre hinweg an. Dazu benutzen wir den Befehl mean() und schreiben das Ergebnis direkt mit print auf den Bildschirm.

In [7]:
print("Corruption Austria:    ",df[df["country"]=="Austria"]["Perceptions of corruption"].mean())
print("Corruption Germany:    ",df[df["country"]=="Germany"]["Perceptions of corruption"].mean())
print("Corruption Switzerland:",df[df["country"]=="Switzerland"]["Perceptions of corruption"].mean())
Corruption Austria:     0.596848064661026
Corruption Germany:     0.614804724852244
Corruption Switzerland: 0.3119955360889435

Da wir uns auf drei Länder eingeschränkt haben, können wir nun auch detaillierte Analysen durchführen, ohne dass das Ergebnis unübersichtlich wird. Anstelle des Mittelwertes, können wir also den zeitlichen Verlauf des Korruptionsindex betrachten.

Dazu benutzen wir den plot Befehl von pandas, der ein wenig anders aufgebaut ist, als der von matplotlib. Als x und y übergibt man Beispielweise keine Liste, sondern nur den Namen der Spalte, unter der man die relevanten Datenpunkte findet. Außerdem erstellt dieser Befehl immer eine neue Achse. Um dieses Verhalten zu unterdrücken, damit wir alle Daten in einer Figur sehen, müssen wir der Achse einen Namen geben, sobald sie erstellt wird (ax1) und allen weiteren Plotbefehlen mit ax=ax1 sagen, dass sie die existierende Achse weiterverwenden sollen.

In [8]:
ax1=df[df["country"]=="Austria"].plot(x="year",y="Perceptions of corruption",label="Austria")
df[df["country"]=="Germany"].plot(x="year",y="Perceptions of corruption",label="Germany",ax=ax1)
df[df["country"]=="Switzerland"].plot(x="year",y="Perceptions of corruption",label="Switzerland",ax=ax1)
plt.title("Perceptions of corruption")
Out[8]:
Text(0.5,1,'Perceptions of corruption')

Pandas bietet natürlich noch viele weitere Möglichkeiten um Daten zu analysierenm zu verarbeiten und aufzubereiten. Eine vollständige Übersicht findet man im Pandas User Guide.

Zusammenfassung

Daten Einlesen mit Pandas

Pandas kann eine Vielzahl von Datentypen einlesen und in Dataframes konvertieren. Die wichtigsten Befehle dazu sind pd.read_excel und pd.read_csv.

Auswählen von Einträgen mit gewissen Eigenschaften

Man kann einen Dataframe auf Zeilen mit gewissen Eigenschaften einschränken, indem man

df[df[spaltenname]==wert]

benutzt. Analog funktionert das auch mit <= und >=.

Um nicht die ganze Zeile auszuwälen, sondern nur den Eintrag in einer gewissen Spalte schreibt man

df[df[spaltenname]==wert][spaltenname2]

Plotten mit Pandas

Um Daten mit Pandas zu visualisieren gibt es viele Möglichkeiten:

  • Histograme (df.hist())
  • Scatterplots (df.plot.scatter(x = spalte1,y = spalte2))
  • Linienplots (df.plot(x = spalte1,y = spalte2))

Kapitel 15 - Analytisch Differenzieren und Integrieren mit dem Paket sympy

Bisher haben wir mit Python immer numerische Berechnungen durchgeführt. Das heißt, alle Differentiale wurden vom Computer als kleine Differenzen genähert und irrationale Zahlen, wie zum Beispiel Wurzeln wurden schlichtweg gerundet. Das ist zwar die einfachste Art, wie ein Computer Mathematik betreiben kann, nicht aber die einzige. Mit Python ist es auch möglich, mathematische Probleme analytisch zu lösen, also so, wie sie ein Mensch lösen würde. So können wir Stammfunktionen berechnen, Gleichungen auflösen und komplexe mathematische Probleme lösen, für die ein Mensch Tage brauchen würde.

Fangen wir an, indem wir den Unterschied zwischen numerischen und analytischen Berechnungen genauer betrachten. Als Beispiel soll ein einfacher Prozess, das Ziehen einer Quadratwurzel, dienen. Berechnen wir die Wurzel aus 9 mit dem Standard-Mathematikpaket math.

In [1]:
import math
math.sqrt(9)
Out[1]:
3.0

Das funktioniert problemlos, ganz genau gleich wie bei jedem Taschenrechner. Wie wir alle wissen, ist die Wurzel aus 9 exakt 3, denn 3 mal 3 ist 9. Hier muss nicht gerundet werden und wir können mit unserem Ergebnis zufrieden sein. Was aber, wenn wir an der Wurzel aus 8 interessiert sind, die bekannterweise keine ganzzahlige Lösung besitzt?

In [2]:
import math
math.sqrt(8)
Out[2]:
2.8284271247461903

Hier bekommen wir zwar eine näherungsweise Lösung, korrekt ist dieses Ergebnis aber nicht mehr. Die Wurzel aus 8 ist nicht exakt 2.8284271247461903, es würden noch unendlich viele Nachkommastellen folgen. Wieso kann das ein Problem werden? Versuchen wir einmal die Wurzel aus 8 mit der Wurzel aus 8 zu multiplizieren. Wir (als Menschen) müssen die Wurzel aus 8 dazu nicht kennen, wir wissen ja, dass die Wurzel aus 8 mal der Wurzel aus 8 wieder 8 sein muss.

$$\sqrt{8}*\sqrt{8}=\sqrt{8*8}=\sqrt{8^2}=8 $$

Wurzel und Quadrat heben sich sozusagen auf. Wenn wir diesen Zusammenhang schon verstehen, ohne überhaupt im Kopf die Wurzel ziehen zu können, dann sollte ein Computer mit dieser Aufgabe doch auch kein Problem haben, oder?

In [3]:
import math
math.sqrt(8) * math.sqrt(8)
Out[3]:
8.000000000000002

Scheinbar doch. Python behauptet felsenfest, dass die Wurzel aus 8 zum Quadrat ein bisschen größer ist als 8. Der Fehler ist klein, aber man muss ihn ernst nehmen. In einem umfangreichen Programm werden solche Berechnungen nämlich sehr oft hintereinander ausgeführt, und der Fehler schaukelt sich auf. Und solche Fehler entstehen natürlich nicht nur beim Wurzel ziehen, sondern bei jeder Operation, die in irgendeiner Form runden muss. Schon die Zahl 1/3 kann nicht mehr exakt gespeichert werden, da man für die Darstellung als 0.33333333333..... unendlich viele Stellen bräuchte um exakt zu sein.

Wie kann man dieses Problem lösen? Mit analytischen Berechnungen! Wenn Python verstehen würde, was 1/3 bedeutet, also nicht nur eine Zahl mit endlich vielen Stellen, sondern die Rechenoperation, die die Zahl 1 in 3 gleich große Teile teilt, wären wir nicht mehr auf Runden angewiesen. Noch schöner wäre es, wenn Python auch die Operation des Wurzelziehens verstehen würde, sodass das Quadrat einer Wurzel automatisch immer wieder die Zahl selbst ergeben würde. All das, und noch viel mehr ist möglich, mit dem Python-Paket sympy.

Wir sehen uns dieses Paket im Folgenden genauer an und benutzen es als erstes, um damit eine Wurzel zu ziehen:

In [4]:
import sympy
sympy.sqrt(9)
Out[4]:
3

Unser erstes erfreuliches Ergebnis: Die Wurzel aus 9 ist nach wie vor 3. Was passiert aber mit der Wurzel aus 8?

In [5]:
import sympy
sympy.sqrt(8)
Out[5]:
2*sqrt(2)

Dieses Ergebnis sieht zwar sonderbar aus, ist aber absolut korrekt. Die Wurzel aus 8 ist exakt doppelt so groß wie die Wurzel aus 2.

Für Mathematiker: $\sqrt{8}=\sqrt{4*2}=\sqrt{4}*\sqrt{2}=2*\sqrt{2}$

Der Vorteil, den wir hier haben ist offensichtlich: Dieses Ergebnis ist exakt, wir müssen nicht runden. Und wenn wir wollen, können wir das Ergebnis immer noch in eine Dezimalzahl umrechnen:

In [6]:
import sympy
float(sympy.sqrt(8))
Out[6]:
2.8284271247461903

Nun könnten wir noch überprüfen, ob das Quadrieren einer Wurzel so besser funktioniert:

In [7]:
import sympy
sympy.sqrt(8) * sympy.sqrt(8)
Out[7]:
8

Offensichtlich. Der Vorteil dieser Berechnung ist noch gering, denn die Wurzel aus 8 kann man auch im Kopf umformen (siehe die Berechnung weiter oben). Hier kommt dann aber der Vorteil eines Computers zum Tragen: Wenn das Prinzip funktioniert, sind kompliziertere Berechnungen nicht wirklich schwieriger als einfache Berechnungen, wie folgendes Beispiel zeigt:

In [8]:
import sympy
sympy.sqrt(1264)
Out[8]:
4*sqrt(79)

Symbolisches Programmieren

Die Wurzel aus 1264 ist also 4 mal die Wurzel aus 79. Auch mit viel Übung kann man solche Berechnungen nicht ohne weiteres im Kopf durchführen. Das Paketsympy kann das - und noch viel mehr. Besonders spannend wird es, wenn wir selbst Variablen definieren. Dafür gibt es den Befehl symbols, der Python erklärt, dass es sich bei diesen Variablen um allgemeine Variablen handelt, in denen kein Wert gespeichert ist. Trotzdem kann Python damit rechnen. Wir können einen Ausdruck speichern, der x und y beinhaltet und Python kann damit arbeiten, obwohl wir x und y keine Werte zugewiesen haben, sondern sie nur als allgemeine Symbole verwenden:

In [9]:
from sympy import *
x = symbols("x")
y = symbols("y")
ausdruck = 3 * x - 4 * y
ausdruck
Out[9]:
3*x - 4*y
In [10]:
ausdruck - 1
Out[10]:
3*x - 4*y - 1
In [11]:
ausdruck + 3 * y
Out[11]:
3*x - y
In [12]:
ausdruck * x # die Lösung wird hier noch nicht ausmultipliziert
Out[12]:
x*(3*x - 4*y)
In [13]:
expand(ausdruck * x) # expand führt die Mutliplikation aus
Out[13]:
3*x**2 - 4*x*y

Man sieht sofort: Python versteht nun wirklich die Rechenoperationen, und obwohl der Variablen x kein Wert zugeordnet wurde, ist klar, dass x mal x das gleiche ist wie x-Quadrat. Das können wir zum Beispiel nutzen um sehr komplizierte Ausdrücke mit dem Befehl factor zu vereinfachen:

In [14]:
from sympy import *
x = symbols("x")
y = symbols("y")
z = symbols("z")
ausdruck = -x * y * z**2 + 2 * x * y * z - x * z**2 + 2 * x * z + y * z**2 - 2 * y * z + z**2 - 2 * z
factor(ausdruck)
Out[14]:
-z*(x - 1)*(y + 1)*(z - 2)

Der ausgeprochen komplizierte Ausdruck

-x * y * z**2 + 2 * x * y * z - x * z**2 + 2 * x * z + y * z**2 - 2 * y * z + z**2 - 2 * z

lässt sich also kompakt schreiben als

-z*(x - 1)*(y + 1)*(z - 2)

Das ist zwar hilfreich, in der Praxis wird man aber sehr selten in die Situation kommen, solche Vereinfachungen durchführen zu müssen (außer vielleicht im Rahmen von Mathematiklehrveranstaltungen). Viel öfter möchte man zum Beispiel Gleichungen lösen.

Lösen von Gleichungen

Während zur Lösung quadratischer Gleichungen noch Formeln bereitstehen, sind Gleichungen 3-ter Ordnung, wie beispielsweise

$$3x^3+x^2-x=0 $$

auf dem Papier ungleich schwerer zu lösen. Schneller geht es mit dem sympy-Befehl solve. Dieser Befehl benötigt nur die Gleichung und als zweites Argument die Variable, die ausgerechnet werden soll:

In [15]:
from sympy import *

x = symbols("x")
solve(3 * x**3 + x**2 - x , x)
Out[15]:
[0, -1/6 + sqrt(13)/6, -sqrt(13)/6 - 1/6]

Die drei Lösungen der Gleichung $3x^3+x^2-x=0 $ sind also $x=0$, $x=-\frac{1}{6} + \frac{\sqrt{13}}{6}$ und $x=-\frac{1}{6} - \frac{\sqrt{13}}{6}$

Differenzieren und Integrieren

Seinen größten Vorteil aus der Perspektive der Systemwissenschaften spielt sympy beim Berechnen von Differentialen und Integralen aus. Das numerische Differenzieren und Integrieren haben wir bereits in früheren Kapiteln besprochen. Die Lösungen dort waren allerdings immer Zahlen oder Listen von Zahlen. Allgemeine, analytische Erkenntnisse, wie die, dass das Integral von $\cos{(x)}$ exakt $\sin{(x)}$ ist, konnten wir so nicht gewinnen. Mit Hilfe von sympy's diff und integrate Befehlen können wir auch sehr komplizierte Ableitungen und Integrale analytisch durchführen und bekommen als Lösungen keine Zahlen, sondern vollständige, allgemein gültige Funktionen:

In [16]:
from sympy import *

x = symbols("x")
In [17]:
diff(sin(x))
Out[17]:
cos(x)
In [18]:
integrate(cos(x))
Out[18]:
sin(x)
In [19]:
diff(sin(3 * x) * x**2)
Out[19]:
3*x**2*cos(3*x) + 2*x*sin(3*x)
In [20]:
integrate(sin(x) * cos(x))
Out[20]:
sin(x)**2/2
In [21]:
diff(x**3 + log(x) * sin(x) + exp(x**2))
Out[21]:
3*x**2 + 2*x*exp(x**2) + log(x)*cos(x) + sin(x)/x

Aber Achtung: Integrale, die analytisch nicht lösbar sind, kann auch Python nicht lösen. Differenzieren läuft immer nach sehr simplen Regeln ab, und prinzipiell kann man mit diesen Regeln jede Funktion ableiten. Integrieren ist jedoch mehr ein kreativer Prozess, und hierbei können Zusammenhänge auftauchen, die sich einfach nicht berechnen lassen. Das hat nichts damit zu tun, dass die richtigen "Rechenregeln" nur einfach noch nicht gefunden wurden. Es ist vielmehr eine Eigenschaft der Integralrechnung selbst. Unlösbaren Integrale müssen nicht schrecklich kompliziert sein. Schon das einfache Beispiel $\frac{\sin(x)}{\log(x)}$ ist nicht mehr integrierbar:

In [22]:
integrate(sin(x) / log(x))
Out[22]:
Integral(sin(x)/log(x), x)

Wenn die obige Zelle ausgeführt wird, zeigt sich, dass Python eine Zeitlang rechnet und offensichtlich versucht das Integral zu lösen. Wenn allerdings keine partielle Integration, keine Substitutionen oder andere Tricks mehr weiterhelfen, kapituliert Python (und auch jegliche andere Mathematik-Software und das menschliche Hirn). Als Lösung bekommt man dann die Aussage:

$$\int \frac{\sin(x)}{\log(x)} dx = \int \frac{\sin(x)}{\log(x)} dx $$

Das ist natürlich (per Definition) nicht falsch, bringt uns aber auch nicht wirklich weiter. Wenn wir dieses Integral in einem bestimmten Bereich lösen wollen, sind wir wieder auf numerische Methoden angewiesen. Diese liefern zwar keine schönen Funktionen, sondern immer nur gerundete Zahlen. Dafür funktionieren sie aber wirklich immer. Man sieht also, dass sowohl numerische, als auch analytische Methoden ihre Daseinsberechtigung haben, und man sich je nach Problem für eine der beiden Herangehensweisen entscheiden sollte. Gerade im mathematischen Bereich hat das Arbeiten mit analytischen Funktionen große Vorteile.

Die Anwedungsgebiete von sympy enden aber nicht mit Differenzieren und Integrieren. Man kann damit auch Grenzwerte berechnen, Differentialgleichungen lösen, Eigenwerte und Eigenvektoren ausrechnen oder (wenn man das wirklich möchte ;-) eine Bessel-Funktion in eine sphärische Bessel-Funktion transformieren.

Rechnen mit Matritzen

Sympy erlaubt es zum Beispiel auch mit Matrizen zu rechnen. Alle wichtigen Rechenoperationen werden unterstützt. Matrizen werden in eckigen Klammer geschrieben, wobei jede Zeile ihrerseits in eckige Klammern gesetzt wird. Sympy betrachtet Matritzen also eigentlich als Listen von Listen. Die Matrix

$$M=\left(\begin{array}{ccc}1&3&2\\1&1&1\\2&3&1\end{array}\right) $$

kann demnach geschrieben werden als:

In [23]:
M = Matrix([[1,3,2],[1,1,1],[2,3,1]])
M
Out[23]:
Matrix([
[1, 3, 2],
[1, 1, 1],
[2, 3, 1]])

Grundrechenarten funktionieren ganz intuitiv mit den dazugehörigen Operatoren +, * und -:

In [24]:
M + M
Out[24]:
Matrix([
[2, 6, 4],
[2, 2, 2],
[4, 6, 2]])
In [25]:
3 * M
Out[25]:
Matrix([
[3, 9, 6],
[3, 3, 3],
[6, 9, 3]])
In [26]:
M - M
Out[26]:
Matrix([
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]])

Auch die (per Hand oft langwierige) Matrix-Multiplikation lässt sich mit sympy einfach handhaben.

In [27]:
M = Matrix([[1,3,2],[1,1,1],[2,3,1]])
N = Matrix([[4,1,2],[1,2,1],[1,2,4]])

M * N
Out[27]:
Matrix([
[ 9, 11, 13],
[ 6,  5,  7],
[12, 10, 11]])
In [28]:
N * M
Out[28]:
Matrix([
[ 9, 19, 11],
[ 5,  8,  5],
[11, 17,  8]])

Als schnelle Überprüfung, ob es sich wirklich um eine echte Matrixmultiplikation handelt, und nicht einfach nur die Elemente miteinander multipliziert werden, kann man M * N ausrechnen lassen, und das Ergebnis mit N * M vergleichen. In unserem Beispiel sehen wir, dass M * N nicht das gleiche ist wie N * M, ganz so wie es sich für Matrixmultiplikationen gehört.

Aber auch erweiterte Rechenoperationen sind möglich. Beispielsweise das Berechnen von Determinanten, welches unter anderem zum Lösen von Gleichungssystemen mit der Cramerschen Regel (https://de.wikipedia.org/wiki/Cramersche_Regel) benötigt wird:

In [29]:
M.det()
Out[29]:
3

Auch das Berechnen von Eigenwerten ist möglich:

In [30]:
M.eigenvals()
Out[30]:
{-1: 1, -sqrt(7) + 2: 1, 2 + sqrt(7): 1}

Zusammenfassung

Das Paket sympy ermöglicht es mit Python analytische Berechnungen durchzuführen. Das hat den Vorteil, dass Zahlen nicht gerundet werden, sondern Brüche oder Wurzeln exakt verwendet werden können. So werden nicht nur Rundungsfehler verhindert, sondern viele Rechenoperationen möglich, die numerisch nicht, oder nur näherungsweise gelingen.

Lösen von Gleichungen

Das Lösen von Gleichungen erfordert numerisch in der Regel Tricks und Kreativität. Mit sympy lassen sich Gleichungen aber exakt lösen. Symbole, die in diesen Gleichungen vorkommen, werden zunächst mit dem Befehl symbols definiert und sodann mit dem Befehl solve(gleichung, variable) einer Lösung nach der gesuchten Variablen zugeführt.

Differenzieren

Die Ableitung einer Funktion wird mit diff berechnet, wobei auch hier die vorkommenden Variablen zuvor mit symbols zu deklarieren sind.

Integrieren

Das Integrieren, also das Suchen der Stammfunktion, geschieht mit dem Befehl integrate.

Merke: Nicht alle Funktionen können analytisch integriert werden. Für viele existiert keine analytische Lösung. Für solche Probleme muss auf numerische Methoden zurückgegriffen werden. Diese benötigen zwar längere Rechenzeit und liefern Lösungen nur näherungsweise und in bestimmten Intervallen. Dafür funktionieren sie in der Regel immer.

Kapitel 16 - Netzwerke

Eine besonders fruchtbare Form, die Komponenten eines Systems in ihren Wechselwirkungen zu untersuchen, bietet die Netzwerkforschung. Ähnlich wie die Systemwissenschaft findet die Netzwerkforschung ihren Gegenstand in unterschiedlichsten Bereichen. Computer, die miteinander kommunizieren, bilden Computernetzwerke. Menschen, die miteinander in Beziehung stehen, bilden soziale Netzwerke. Tiere und Pflanzen, die sich einen Lebensraum teilen, bilden ökologische Netzwerke. All diese Netzwerke, obwohl sie eigentlich grundverschieden scheinen, haben gemeinsame Eigenschaften und folgen ähnlichen Regeln.

In den letzten Jahren gewinnt der Netzwerkbegriff in den Wissenschaften enorm an Bedeutung. Es ist deshalb kaum verwunderlich, dass auch Python mittlerweile spezialisierte Pakete bereithält, die sich der Darstellung und Analyse von Netzwerken widmen. Wir werden uns im Folgenden eines dieser Paket etwas genauer ansehen.

Was sind Netzwerke?

Von Netzwerken spricht man immer dann, wenn Objekte als Knoten (oder auf Englisch auch **nodes** oder **vertices**) vorliegen, die mit anderen solchen Objekten in irgendeiner Weise verbunden sind. Die Verbindungen werden im Englischen **links** oder auch **edges** genannt.

Charakterisiert werden Netzwerke durch ihre Verbindungsstruktur, also durch die Information darüber, welche Knoten mit welchen anderen Knoten auf welche Weise verbunden sind. Aus dieser Verbindungsstruktur ergibt sich eine Vielzahl unterschiedlicher Netzwerktypen, von denen wir einige weiter unten detaillierter besprechen.

NetworkX

Zunächst sehen wir uns nun aber einige Grundlagen des Python-Pakets NetworkX an, das auf die Darstellung und Analyse von Netzwerken spezialisiert ist und bereits im installierten Anaconda-Paket enthalten ist.

Für den Anfang betrachten wir ein sehr einfaches Netzwerk: Das Netzwerk soll aus drei Personen bestehen: Alice, Bob und Carol. Diese drei Personen sind die Knoten (nodes) unseres Netzwerks. Alle sind miteinander befreundet, was wir in unserem "Freundschaftsnetzwerk" durch Verbindungen (edges) darstellen.

Wir beginnen damit, das Paket NetworkX zu importieren und zunächst ein leeres Netzwerk anzulegen. Dieses füllen wir sodann mit nodes und edges:

In [5]:
import networkx as nx

G = nx.Graph() # erstelle ein neues Netzwerk mit dem Namen G

Im nächsten Schritt fügen wir drei Knoten hinzu: Alice, Bob und Carol.

In [6]:
G.add_node("Alice") # füge dem Netzwerk G den Knoten "Alice" hinzu
G.add_node("Bob")
G.add_node("Carol")

Um das Netzwerk grafisch darzustellen, benötigen wir abermals das Paket matplotlib, das sehr gut mit networkx harmoniert.

In [7]:
import matplotlib.pyplot as plt
%matplotlib inline

nx.draw_networkx(G, node_size = 1600) # zeichne das Netzwerk G. Die Knoten sollen die Größe 1600 haben.

Wir sehen: die Knoten werden korrekt dargestellt. Was noch fehlt, sind die Verbindungslinien zwischen den Knoten. Diese werden mit dem Befehl G.add_edge(von, zu) hinzugefügt:

In [8]:
G.add_edge("Alice","Bob") # füge dem Netzwerk G eine neue Verbindung zwischen Alice und Bob hinzu.
G.add_edge("Bob","Carol")
G.add_edge("Carol","Alice") 

nx.draw_networkx(G, node_size = 1600)

Somit ist unser (sehr einfaches) Netzwerk korrekt dargestellt. Wenn wir die obige Code-Zelle mehrmals ausführen, fällt auf, dass die Positionen der einzelnen Knoten jedesmal neu vergeben werden. Sie werden zufällig generiert. In dieser Darstellung haben sie keine Bedeutung, wichtig ist nur, wer mit wem verbunden ist, nicht an welchem Ort sie sich befinden.

Fügen wir unserem Netzwerk eine weitere Person hinzu: Dan. Dan ist nur mit Carol befreundet, kennt sonst aber niemanden aus dem Netzwerk. Wir machen also nur eine Verbindung zwischen Carol und Dan:

In [9]:
G.add_node("Dan")
G.add_edge("Carol","Dan") 

nx.draw_networkx(G, node_size = 1600)

Um unser Netzwerk noch etwas zu erweitern, fügen wir noch zwei weitere Personen hinzu: Eve ist eine Freundin von Alice und Carol. Frank ist nur mit Dan befreundet:

In [10]:
G.add_node("Eve")
G.add_edge("Eve","Alice")
G.add_edge("Eve","Carol")

G.add_node("Frank")
G.add_edge("Frank","Dan") 

nx.draw_networkx(G, node_size = 1600) 

Dieses soziale Netzwerk ist nun schon hinreichend komplex, um daran unterschiedliche Darstellungsarten auszuprobieren. Bisher waren die Positionen der Knoten in der Grafik dem Zufall überlassen. Man kann sie aber auch mit so genannten "Layouts" arrangieren. Die Knoten werden dann nach bestimmten Regeln angeordnet, was spezielle Netzwerkeigenschaften verdeutlichen kann, oder einfach nur die Netzwerke generell übersichtlicher gestaltet.

Das Spring-Layout

Eines der übersichtlichsten Layouts (und deswegen auch die Standardeinstellung in NetworkX) ist das Spring-Layout. Es heißt so, weil die Verbindungslinien als Federn (springs) interpretiert werden, was zur Folge hat, dass zwei verbundene Knoten, die sich zu nahe sind, vom Algorithmus, der das Netzwerk generiert, auseinander gezogen werden, und wenn sie zu weit voneinander entfernt sind, zusammengezogen werden. Zumeist liefert dies ein recht übersichtliches Bild.

Layouts werden innerhalb des draw-Befehls mit dem Argument pos definiert (Merke: wir haben das Netzwerk G oben bereits in all seinen Bestandteilen definiert und brauchen es deshalb in Folge nur mehr in seinen unterschiedlichen Layouts aufzurufen):

In [11]:
# nx.spring_layout(G) errechnet das Springlayout für das Netzwerk
nx.draw_networkx(G, node_size = 1600, pos = nx.spring_layout(G))

Das Random-Layout

Etwas weniger übersichtlich ist das Layout "random". Hier werden die Positionen der Knoten absolut zufällig gewählt, was dazu führt, dass die Verbindungslinien kreuz und quer laufen können. Der Vorteil dieses Layouts ist aber, dass es kaum Rechenzeit benötigt und somit gern bei großen Netzwerken benutzt wird, bei denen man den einzelnen Verbindungslinien ohnehin nur schwer folgen kann.

In [18]:
nx.draw_networkx(G, node_size = 1600, pos = nx.random_layout(G)) 

Das Circular-Layout

Beim Circular-Layout werden alle Knoten in einem Kreis angeordnet. Hier liegt der Vorteil darin, schnell erkennen zu können, wer mit wem verbunden ist.

In [29]:
nx.draw_networkx(G, node_size = 1600, pos = nx.circular_layout(G)) 

Netzwerkanalyse

Was lässt sich nun mit einem solchen Netzwerk machen? Zunächst könnten wir versuchen, bestimmte Netzwerkeigenschaften festzustellen. Für unser soziales Netzwerk könnten wir zum Beispiel herausfinden, wie die Freundschaften im Netzwerk verteilt sind. Wir wollen also wissen, wie viele Verbindungslinien die einzelnen Knoten haben. Im Englischen spricht man hierbei vom degree eines Knoten. Wir erhalten diese Information mit dem Befehl nx.degree(G):

In [19]:
nx.degree(G)
Out[19]:
DegreeView({'Alice': 3, 'Bob': 2, 'Carol': 4, 'Dan': 2, 'Eve': 2, 'Frank': 1})

Die Antwort ist so zu lesen: Der Knoten mit dem Namen "Alice" hat degree 3 (also drei Freunde), "Bob" hat degree 2 (zwei Freunde), und so weiter.

Oft ist man aber nur an den Zahlen selbst interessiert, nicht unbedingt daran, wie die Knoten heißen. Dazu lässt sich der Befehl .values() wie folgt hinzufügen:

In [20]:
list(dict(nx.degree(G)).values())
Out[20]:
[3, 2, 4, 2, 2, 1]

Als Antwort ergibt sich eine Liste, die sich recht einfach in ein Histogramm verwandeln lässt:

In [21]:
plt.hist(list(dict(nx.degree(G)).values()), 4, range = (0.5, 4.5), color = "darkred")
plt.xlabel('Zahl der Freunde')
plt.ylabel('Zahl der Personen')
Out[21]:
Text(0,0.5,'Zahl der Personen')

Das Histogramm zeigt, dass es drei Personen gibt, die genau zwei Freunde haben und jeweils eine Person, die einen, drei und vier Freunde hat. Richtig interessant wird eine solche Analyse erst mit großen Netzwerken mit einigen hunderten oder tausenden Knoten. Solche Netzwerke werden in der Regel nicht mehr manuell eingegeben. Es wird also nicht mehr jeder Knoten und jede Verbindung einzeln definiert, sondern ein so genannter Netzwerkgenerator verwendet.

Netzwerkgeneratoren

Netzwerkgeneratoren eignen sich insbesondere dazu, bestimmte Archetypen von Netzwerken zu erstellen, die in der Natur, aber auch in der Gesellschaft oder in technischen Zusammenhängen immer wieder auftreten. Diese Archetypen zeichnen sich durch ganz bestimmte Netzwerktopologien aus, die in verschiedensten Kontexten in ähnlicher Weise auftreten.

Das Small-World Netzwerk

Einer der verbreitetsten Netzwerk-Archetypen ist das so genannte Small-World-Netzwerk, das seinen Namen einem berühmt gewordenen Experiment von Stanley Milgram verdankt (siehe: http://systems-sciences.uni-graz.at/etextbook/networks/networks_2.html). Es zeichnet sich durch relativ geringe durchschnittliche Vernetzungsdichte aus, die allerdings von Regionen hoher Vernetzungsdichte unterbrochen ist, wobei diese Regionen ihrerseits über kurze Pfade mit anderen solchen dichten Regionen verbunden sind. Die meisten Knoten in solchen Netzwerken haben nur recht wenige Verbindungen. Einige wenige haben dagegen viele und ganz wenige Knoten haben sehr viele Verbindungen. Diese sehr wenigen, überaus gut vernetzten Knoten werden als Hubs bezeichnet. Viele soziale Netzwerke, aber auch das Internet oder viele Gen-Netzwerke haben diese Small-World-Struktur.

Der Python-Befehl zum Erzeugen solcher Netzwerke ist ein wenig sperrig. Die Namen der Entwickler des Algorithmus zur Generierung dieses Netzwerktyps findet in ihm Platz: connected_watts_strogatz_graph. Als Argumente braucht dieser Generator die Anzahl der Knoten im Netzwerk, die mittlere Anzahl der Verbindungen pro Knoten, und eine Zahl, die festlegt wie groß die Hubs werden dürfen, die entstehen.

In [22]:
# erzeugt ein Small-world-Netzwerk mit 50 Knoten, und durchschnittlich 5 Verbindungen pro Knoten
# die dritte Zahl gibt die Chance an, dass ein Knoten mit einem seiner Verbindungen an einen Hub andockt.
G = nx.connected_watts_strogatz_graph(50, 5, 0.40) 
nx.draw_networkx(G)

Durch die Größe des Netzwerks ist die Darstellung nun ein wenig unübersichtlich geworden, und wir können nicht mehr ganz klar erkennen, wer mit wem verbunden ist, bzw. welcher Knoten viele Verbindungen hat und welcher wenige. Übersichtlicher könnte dies werden, wenn wir die Größe der Knoten in der Darstellung davon abhängig machen, welchen degree sie haben:

In [23]:
import networkx as nx
import matplotlib.pyplot as plt
%matplotlib inline

G = nx.connected_watts_strogatz_graph(50, 5, 0.40) 
deg = dict(nx.degree(G)).values()
size = [] # leere Liste wird angelegt

# für jeden Wert in der Degreeliste wird ein Eintrag in die Sizeliste angefügt
for wert in deg:
    size.append(50 * wert**1.5)

nx.draw_networkx(G, node_size = size)

Nun sieht man anhand der Größe der Knoten, dass einige Knoten deutlich mehr Verbindungen haben als andere. Diese Hubs des Netzwerks können großen Einfluss auf das Gesamtsystem haben.

Das hierarchische Netzwerk

In hierarchischen Netzwerken hat zumeist nur ein Knoten eine besondere Stellung. Dieser zentralste Konten steht in Verbindung zu weniger wichtigen Knoten, die ihrerseits in Verbindung zu weiteren, noch weniger zentralen Knoten stehen.

Solche hierarchischen Strukturen finden sich vielfach in Organisiationen, aber auch zum Beispiel in Nahrungsketten im Tierreich. Der NetworkX-Befehl zum Erstellen von hierarchischen Netzwerken lautet balanced_tree. Als Argumente werden die Anzahl der Knoten-Verbindungen in die jeweils untere Ebene der Hierarchie und die Zahl der Ebenen benötigt.

In [24]:
import networkx as nx
import matplotlib.pyplot as plt
%matplotlib inline

# ein hierarchisches Netzwerk mit 2 Verbindungen "nach unten" und 4 Ebenen
G = nx.balanced_tree(2, 4)
nx.draw_networkx(G)

Besonders übersichtlich wird die Darstellung eines hirarchischen Netzwerks mit speziellem Layout. Da der genaue Aufbau der Funktion für dieses Layout den Rahmen dieses Kapitels sprengt, behandeln wir ihn im Folgenden als "Blackbox". Das heißt, wir zeigen die Funktion und ihren Output im Folgenden, erklären aber nicht wie sie genau funktioniert.

In [25]:
import networkx as nx
import matplotlib.pyplot as plt
%matplotlib inline

#BLACKBOXFUNKTION, die man nicht verstehen muss
def hierarchy_pos(G, root, width=1., vert_gap = 0.2, vert_loc = 0, xcenter = 0.5, 
                  pos = None, parent = None):
    if pos == None:
        pos = {root:(xcenter,vert_loc)}
    else:
        pos[root] = (xcenter, vert_loc)
    neighbors = list(G.neighbors(root))
    if parent != None:
        neighbors.remove(parent)
    if len(neighbors) != 0:
        dx = width/len(neighbors) 
        nextx = xcenter - width/2 - dx/2
        for neighbor in neighbors:
            nextx += dx
            pos = hierarchy_pos(G,neighbor, width = dx, vert_gap = vert_gap, 
                                vert_loc = vert_loc-vert_gap, xcenter = nextx, pos = pos, 
                                parent = root)
    return pos
# ENDE DER BLACKBOXFUNTKION

G=nx.balanced_tree(2, 4)
nx.draw_networkx(G, pos = hierarchy_pos(G, 0))

Das Grid-Netzwerk

Auch eine einfache Gitterstruktur (Grid) lässt sich als Netzwerk betrachten. Solche Strukturen treten zum Beispiel in Kristallen auf, aber auch überall sonst, wo Objekte bevorzugt mit ihren nächsten Nachbarn interagieren.

Der Befehl zum Erstellen von Grid-Netzwerken lautet grid_2d_graph und braucht als Argumente die Länge und die Breite des Gitters.

In [29]:
import networkx as nx
import matplotlib.pyplot as plt
%matplotlib inline

G = nx.grid_2d_graph(4, 7) # erzeugt ein 4 mal 7 Grid
# Spectral-layout für passende Grid-Form
nx.draw_networkx(G,pos = nx.spectral_layout(G), node_size = 800)

Das Caveman-Netzwerk

Ein so genanntes Caveman-Netzwerk zeichnet sich durch mehrere dichter vernetzte Knoten-Gruppen aus, so genannte Cluster, die untereinander nur wenig miteinander verbunden sind.

Der Befehl zum erzeugen solcher Netzwerke lautet connected_caveman_graph und benötigt als Argumente die Zahl der Cluster und die Größe der Cluster.

In [30]:
import networkx as nx
import matplotlib.pyplot as plt
%matplotlib inline

G=nx.connected_caveman_graph(4, 5)  # Netzwerk aus 4 Clustern mit je 5 Knoten
nx.draw_networkx(G)

Beispiel: Die Ausbreitung eines Computer-Virus

Versuchen wir nun, unser bisheriges Wissen über Netzwerke in einem einfachen Anwendungsbeispiel zu nutzen. Wir erstellen ein fiktives Computer-Netzwerk und untersuchen, wie sich ein Software-Virus in diesem Netzwerk ausbreiten würde.

Als Netzwerktypus wählen wir ein Caveman-Netz, in dem vier über das Internet verbundene Unternehmen jeweils sieben Computer in einem firmeneigenen Intranet betreiben.

In [27]:
import networkx as nx
import matplotlib.pyplot as plt
%matplotlib inline

G = nx.connected_caveman_graph(4, 7)
# speichere die Knoten-Positionen, um in allen folgenden Darstellungen das selbe Netzwerk zu sehen
position = nx.spring_layout(G)
nx.draw_networkx(G, pos = position, node_color = "green")

Mit dem folgenden Code fügen wir für jeden Knoten ein Attribut hinzu, in dem später gespeichert wird, ob der jeweilige Computer vom Software-Virus infiziert ist oder nicht. Wir iterieren dazu mit einer Schleife über die Zahl der Knoten des Gesamtnetzwerks (G.number_of_nodes()), definieren dabei für jeden Knoten das neue Attribut "infiziert" und setzen es auf "nein". Dies geschieht mit G.node[it]["infiziert"] = "nein".

In [28]:
# iteriere über alle Knoten und gib ihnen ein neues Attribut namens "infiziert"
# setze dieses neue Attribut auf "nein"
for it in range(G.number_of_nodes()): 
    G.node[it]["infiziert"] = "nein"

Wir überprüfen diese Operation, indem wir einen beliebigen Knoten - den Knoten mit dem Index 0 - auf seine Atribute befragen:

In [29]:
G.node[0] # frage ab welche Daten im Knoten mit Index 0 gespeichert sind
Out[29]:
{'infiziert': 'nein'}

In ähnlicher Weise lassen sich beliebig viele Daten in einem Knoten speichern. Hier begnügen wir uns aber mit nur einem Attribut.

Im nächsten Schritt infizieren wir einen Knoten (einen Computer) mit dem Software-Virus, zum Beispiel den Knoten mit dem Index 8:

In [30]:
# setze "infiziert" bei Knoten 8 auf "ja":
G.node[8]["infiziert"] = "ja"
# frage ab welche Daten im Knoten 8 gespeichert sind
G.node[8]
Out[30]:
{'infiziert': 'ja'}

Wenn wir nun das Gesamtnetzwerk auf Virusbefall überprüfen sollen, würden wir wohl alle Knoten mithilfe einer For-Schleife befragen:

In [31]:
for it in range(G.number_of_nodes()):
    print(G.node[it]),  # das Komma am Ende des Print-Befehls schreibt Output fortlaufend in die selbe Zeile
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'ja'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}
{'infiziert': 'nein'}

Übersichtlich ist das so natürlich nicht. Schöner wäre es, diese Information mit der Farbe der Knoten widerzugeben. Wir legen dazu eine neue Liste an, in der die Farben der Knoten gespeichert werden: grün für "nicht infiziert" und rot für "infiziert":

In [32]:
farben = []
for it in range(G.number_of_nodes()):
    # frage den Wert des Attributes "infiziert" ab:
    if G.node[it]["infiziert"] == "ja":
        farben.append("red")
    if G.node[it]["infiziert"] == "nein":
        farben.append("green")

# anstelle der einen Farbe "Grün", plotten wir nun mit der Liste "farben".
# für jeden Knoten gibt es genau einen Eintrag
nx.draw_networkx(G, pos = position, node_color = farben)

In dieser Darstellung ist der infizierte Knoten klar erkennbar.

Wie würde sich ein solcher Software-Virus ausbreiten? Die Epidemiologie kennt viele Modelle zur Infektionsausbreitung. Wir verwenden hier zunächst das einfachste: In jedem Zeitschritt werden alle Knoten infiziert, die mit einem infizierten Knoten verbunden sind.

Dafür nutzen wir einen neuen Befehl: G.neigbors(n) liefert uns alle Nachbarn (also verbundene Knoten) eines Knoten n.

Wenn wir nun fünf Zeitschritte dieses einfachen Infektionsmodells simulieren wollen, ergeben sich einige komplexe Verschachtelungen von If-Abfragen und For-Schleifen. Wir sehen uns diese im folgenden Code-Segment genauer an:

In [33]:
# definiere eine allgemeine Zeichenfläche
fig = plt.figure(figsize=(15, 10))

n = 1   # Zähler zum Mitzählen der Subplots
# erster Subplot
ax = fig.add_subplot(2, 3, n)
nx.draw_networkx(G, pos = position, node_color = farben)

# simuliere 5 Zeitschritte
for zeit in range(5):
    n += 1 # setze Zähler zum Zählen der Subplots um 1 hinauf
    # for-Schleife über alle Knoten
    # die Laufvariable nennen wir inf, da wir hier nur die infizierten Knoten betrachten
    for inf in range(G.number_of_nodes()):
        # wenn der Knoten infizierte ist,
        if G.node[inf]["infiziert"] == "ja":
            # gehen wir in einer weiteren Schleife über alle Nachbarn des infizierten Knoten:
            for opfer in G.neighbors(inf):
                # und wenn dieser Knoten noch nicht infiziert ist
                if G.node[opfer]["infiziert"] == "nein":
                    # wird sie in diesem Zeitschritt neu infiziert:
                    G.node[opfer]["infiziert"] = "neu"
                # Ende der if-Abfrage
            # Ende der for-Schleife über die Opfer
        # Ende der if-Abfrage, die feststellt, ob ein Knoten infiziert war
    # Ende der for-Schleife über alle Knoten
    # hier sind wir wieder in der for-Schleife über 5 Zeitschritte
    farben = []
    for it in range(G.number_of_nodes()):
        # die neu infizierten Knoten bekommen die Farbe Orange, damit wir die Ausbreitung besser verfolgen können
        if G.node[it]["infiziert"] == "ja":
            farben.append("red")
        if G.node[it]["infiziert"] == "nein":
            farben.append("green")
        if G.node[it]["infiziert"] == "neu":
            farben.append("orange")
    # neuer Subplot
    ax = fig.add_subplot(2, 3, n)
    nx.draw_networkx(G, pos = position, node_color = farben)
    
    # hier ändern wir nun alle "neu" infizierten Knoten auf infizierte ("ja"),
    # damit sie im nächsten Schrit rot statt orange erscheinen und ihrerseits weitere Knoten infizieren
    for it in range(G.number_of_nodes()):
        if G.node[it]["infiziert"] == "neu":
            G.node[it]["infiziert"] = "ja"   

Mit diesem sehr einfachen Modell können wir also ansatzweise bereits Aspekte der Ausbreitung eines Netzwerk-Virus analysieren. Versuchen sie selbst, dieses Modell zu erweitern, zum Beispiel größere oder andere Netzwerke zu generieren, oder die Knoten mit zusätzlichen Attributen - Antivirenprogramme, Firewalls etc. - auszustatten.

Zusammenfassung

Netzwerke

In der Netzwerkforschung werden Objekte als Knoten (nodes) aufgefasst, die mit anderen Knoten verbunden sind. Entscheidend sind hierbei nicht so sehr die speziellen Eigenschaften der Knoten, als vielmehr die Verbindungsstruktur (die Toplogie eines Netzwerks), die sich aus der Zahl der Verbindungen (links, edges) der Knoten - dem so genannten degree - und aller Nachbarknoten ergibt. Bestimmte Netzwerktypen, wie etwa Small-World- oder hierarchische Netzwerke, liegen Phänomenen in unterschiedlichsten Kontexten zugrunde.

Netzwerke in Python

In Python steht das Paket NetworkX zur Darstellung und Analyse von Netzwerken zur Verfügung.

Manuelles Generieren von Netzwerken

Mit dem Befehl G = nx.Graph() wird ein neues, leeres Netzwerk mit dem Namen G generiert. Neue Knoten können mit dem Befehl G.add_node("Name_des_neuen_Knoten") hinzu gefügt werden und mit G.add_edge("Knoten_1","Knoten_2") mit anderen Knoten verbunden werden.

Netzwerkgeneratoren

Um spezielle Netzwerktypen zu erstellen, stehen so genannte Netzwerkgeneratoren bereit, die teils zufällige, teils deterministische Netzwerke generieren. Häufig verwendete Generatoren sind die für Small-World-Netzwerke (connected_watts_strogatz_graph) und für hierarchische Netzwerke (balanced_tree).

Programmieren mit Netzwerken

Um über alle Knoten in einem Netzwerk zu iterieren, steht der Schleifen-Befehl for it in range(G.number_of_nodes()): bereit. Um einem Knoten ein Attribut zu zuweisen, das zusätzliche Informationen enthält, steht der Befehl G.node["Name_des_Knoten"]["Name_des_Attributs"] = "Wert_des_Attributs" bereit.

Kapitel 17 - und wie weiter?

Hiermit endet diese Einführung in das (wissenschaftliche) Programmieren. Wir kennen nun die wichtigsten Strukturen und Konzepte:

  • Listen
  • For-Schleifen
  • If-Abfragen
  • Fuctions
  • Klassen

Darüber hinaus gibt es aber noch Unmengen mehr zu lernen.

Programmieren in Python

Um mehr über Programmieren mit Python zu lernen, gibt es viele sinnvolle Online-Ressourcen:

https://docs.python.org/3/tutorial/index.html

https://www.w3schools.com/python/

https://www.learnpython.org/

https://www.codecademy.com/learn/learn-python-3

Eine Übersicht über diese und weitere Möglichkeiten etwas über Python zu lernen, findet man hier:

https://docs.python-guide.org/intro/learning/

References

Wer nur einen bestimmte Struktur sucht, findet sie am einfachsten in der Python Language Reference. Dort sind alle Strukturen der Sprache mit kurzen Erklärungen aufgelistet.

https://docs.python.org/3/reference/index.html

Alle in Python inkludierten Functions findet man in der Functions Reference:

https://www.w3schools.com/python/python_ref_functions.asp

Algorithmen

Zusätzlich zu reiner Programmiertechnik kann man sich auch Kenntnisse bezüglich Algorithmen aneignen. Diese kann man dann auch in anderen Programmiersprachen anwenden und komplizierte Probleme lösen. Einen Einstieg in diese Thematik findet man zum Beispiel hier:

https://www.khanacademy.org/computing/computer-science/algorithms

Weitere Programmiersprachen

Neben Python gibt es natürlich auch noch viele andere Programmiersprachen. Wer Python beherrscht, und somit ein grundlegendes Verständnis vom Programmieren an sich besitzt, kann sehr einfach eine zweite Sprache lernen. Im wissenschaftlichen Kontext besonders relevant sind:

C++ (universelle, weitverbreitete Programmiersprache)

http://www.learn-cpp.org/

Matlab (für numerische Berechnungen mit Matrizen in Technik und Ingenieurwesen)

https://learntocode.mathworks.com/

Mathematica (für analytische Berechnungen und mathematische Ableitungen)

http://www.wolfram.com/language/fast-introduction-for-programmers/en/

R (Für Statistik und Datenanalyse)

https://swirlstats.com/students.html

Learning by Doing

Wenn der erste Schritt einmal geschafft ist, eignet sich Programmieren auch sehr um es durch "Learning by Doing" zu erlernen. Am besten sucht man sich ein Thema oder ein Projekt, an dem man wirklich interessiert ist, und versucht dazu ein Programm zu erstellen. Wenn das Grundgerüst einmal funktioniert kann man es noch beliebig ausbauen und wird dabei sehr viel an Programmiertechnik üben und lernen, ohne dass man die Freude am Arbeiten verliert.

Befehlsglossar

Befehl Relevantes Kapitel
and Kapitel 4
arange Kapitel 10
break Kapitel 10
class Kapitel 11
csv.reader Kapitel 6
def Kapitel 9
else Kapitel 4
except Kapitel 4
for Kapitel 2
if Kapitel 4
len Kapitel 4
liste.append Kapitel 1
np.array Kapitel 5
np.cumsum Kapitel 6
np.diff Kapitel 6
np.zeros Kapitel 8
or Kapitel 4
plt.hist Kapitel 7
plt.plot Kapitel 1
random.gauss Kapitel 7
random.normal Kapitel 7
range Kapitel 1
sum Kapitel 6
try Kapitel 4
while Kapitel 9