10.9. Programmierung des Algorithmus (Lösung)#

Wir werden uns nun Schritt für Schritt die nötigen Methoden den k-Means-Clustering Algorithmus erarbeiten. Um am Ende nicht einen großen Codeblock erstellen zu müssen, werden wir soviel Vorarbeit wie möglich erledigen. So kannst du dich am Ende ganz auf die Implementierung der Teile des Algorithmus konzentrieren [Stichwort: externe kognitive Belastung gering halten, siehe Cognitive Load Theory (CLT) 😉]

10.9.1. Pseudocode#

Nochmals die Grundidee des Algorithmus:

  1. Clusterzentren initialisieren: Zu Beginn werden \(k\) Zentroiden (Mittelwerte der Cluster) zufällig oder nach einer bestimmten Heuristik gewählt.

  2. Datenpunkte zuweisen: Jeder Datenpunkt wird dem nächstgelegenen Clusterzentrum zugeordnet. Diese Zuordnung basiert üblicherweise auf der euklidischen Distanz.

  3. Zentroiden aktualisieren: Nach der Zuordnung werden die Zentroiden neu berechnet, indem der Mittelwert aller Punkte in jedem Cluster bestimmt wird.

  4. Wiederholen: Die Schritte der Zuordnung und Aktualisierung werden wiederholt, bis sich die Clusterzentren nicht mehr ändern oder eine maximale Anzahl von Iterationen erreicht wurde.

Folgend siehst du die Umsetzung des Algorithmus in Pseudocode nach der Formelsammlung, vervollständigt mit Kommentaren.

OPERATION k_means(
    daten: Liste<Datenpunkt>,
    k: int,
    max_iterationen: int) : (Liste<Datenpunkt>, Liste<Datenpunkt>)
    Lokale Variablen: 
        zentroiden: Liste<Datenpunkt>,
        neue_zentroiden: Liste<Datenpunkt>,
        cluster: Liste<Datenpunkt>

    # Initialisierung der Zentroiden
    zentroiden = initialisiere_zentroiden(daten, k)

    # Hauptalgorithmus: Wiederhole für maximal max_iterationen
    FÜR i=0 BIS max_iterationen SCHRITT 1
        # Cluster-Zuordnung: Weise jeden Datenpunkt dem nächsten Zentroiden zu
        cluster = berechne_cluster(daten, zentroiden)

        # Zentroiden-Aktualisierung: Berechne die neuen Zentroiden als Mittelwert der zugeordneten Punkte
        neue_zentroiden = aktualisiere_zentroiden(clusters)

        # Abbruchbedingung: Wenn die Zentroiden sich nicht mehr ändern, ist der Algorithmus fertig
        WENN neue_zentroiden == zentroiden  # Das ist die Abbruchbedingung!
            ABBRUCH
        ENDE WENN

        # Aktualisiere die Zentroiden für die nächste Iteration
        zentroiden = neue_zentroiden
    ENDE FÜR

    # Rückgabe der finalen Cluster-Zuordnung und Zentroiden
    RÜCKGABE (cluster, zentroiden)

Laut Pseudocode benötigen wir die Methoden initialisiere_zentroiden() datenpunkte_zuweisen() und aktualisiere_zentroiden(). Die Methode initialisiere_zentroiden() ist bereits implementiert.

Die beiden anderen Methoden sind deine Aufgabe. In beiden Fällen benötigst du die Eukledische Distanz (A1).
Dann brauchst du eine Methode, die entscheidet, welcher Zentroid der nächste zu einem Datenpunkt ist (A2). Mit dieser Methode kannst du dann die Datenpunkte den Clustern zuweisen (A3).
Danach müssen die Koordinaten der Zentroiden neu berechnet werden. Zunächst für einen (A4), dann für alle Zentroiden (A5).

Deine Aufgaben sind:

  • A1: berechne_euklidische_distanz()

  • A2: finde_naechsten_zentroiden()

  • A3: datenpunkte_zuweisen()

  • A4: berechne_zentroid_koordinaten()

  • A5: aktualisiere_zentroiden()

Wenn alle Aufgaben erledigt, sollte die Methode k_means() korrekt funktionieren 😎.

Hinweis

Die folgenden Unterpunkte bis zur ersten Aufgabe sind nicht essentiel, um den Algorithmus zu verstehen, aber dennoch hilfreich für die spätere Implementierung. Und auch generell wichtig für das vielleicht spätere Leben als ProgrammiererIn.

10.9.2. Vorentlastungen#

Wir werden nun vor der ersten Aufgabe folgende Schritte bereits erledigen:

  • Konfiguration von Unit Tests: für das Beschreiben der Methoden und für nachvollziehbare Testfälle

  • Klasse Datenpunkt: für das Handling von X- und Y-Koordinaten

  • Methode generiere_datenpunkte(): für das Generieren von zufälligen Datenpunkten

  • Methode initialisiere_zentroiden(): für das Initialisieren der ersten Zentroiden

In den Aufgaben selber sind auch teilweise schon Tipps und Hilfen. Achte auf die Stelle ...  # Hier die weitere Lösung ergänzen: nur dort muss dein Code hin 😉.

Unit Tests#

Wir werden zum Testen unserer Methoden Unit-Tests verwenden. Das sind kleine, automatisierte Tests, die jeweils eine einzelne Funktion oder Methode in einem Programm überprüfen. Sie helfen sicherzustellen, dass jede Komponente des Codes genau das tut, was sie tun soll. Stell dir Unit-Tests wie eine Checkliste vor, die jede Funktion Schritt für Schritt überprüft, ob sie die erwarteten Ergebnisse liefert.

Bei den einzelnen Methoden sind bereits jeweils Unit-Tests hinterlegt, sodass sie dir eine Hilfe bei der Implementierung geben. Wenn du die Test-Methoden studierst, siehst du, wie die zu implementierenden Methoden aufgerufen werden.

Folgend ist wird definiert, wie die Erfolgs- und Fehlerausgabe in einem für uns gut lesbaren Format ausgegeben wird. Du musst es nicht verstehen, es ist nur wichtig, bei den Implementierungen run_doctests_mit_lesbarer_ausgabe(methoden_name) auszuführen, um die Methode zu testen und die Ausgabe dazu erhalten. Du erhältst immer die Info, ob die Methode erfolgreich implementiert wurde oder ob es noch Tests gibt, die fehlschlagen.

Hide code cell content
# Für die Unit Tests benötigen wir das Modul doctest. Damit können wir die Docstrings in den Funktionen testen.
import doctest

# Damit die Ausgabe für dich gut lesbar ist, verwenden wir eine eigenes Output-Format
class CustomOutputChecker(doctest.OutputChecker):
    """Diese Klasse ist ein Wrapper für den Doctest-OutputChecker, um die Ausgabe zu formatieren."""
    def output_difference(self, example, got, optionflags):
        """
        Diese Funktion gibt die Testergebnisse in einer lesbaren Form zurück.
        """
        return f"""
Test fehlgeschlagen:
  Beispiel: {example.source.strip()}
  Erwartet: {example.want.strip()}
  Erhalten: {got.strip()}
"""

def run_doctests_mit_lesbarer_ausgabe(func, verbose=False):
    """
    Diese Funktion führt die Doctests aus und gibt eine lesbare Ausgabe zurück.
    Args:
        func (function): Die Funktion, die getestet werden soll.
    """
    runner = doctest.DocTestRunner(checker=CustomOutputChecker(), verbose=verbose)
    tests = doctest.DocTestFinder().find(func)
    total_failed = 0

    for test in tests:
        result = runner.run(test)
        total_failed += result.failed
    
    runner.summarize()

    if total_failed == 0:
        logger.info(f'{func.__name__}() wurde erfolgreich implementiert!')
    else:
        logger.error(f'{func.__name__}() hat {total_failed} fehlgeschlagene Tests')

In Python können wir Funktionen und Klassen mit Docstrings dokumentieren. Docstrings sind mehrzeilige Kommentare unter der def ...-Zeile, die erklären, was die Funktion macht, welche Parameter sie benötigt und was sie zurückgibt. Außerdem können wir Beispiel-Aufrufe direkt in die Docstrings schreiben, um die Funktionsweise zu zeigen. Und damit haben wir auch schon unsere Unit Tests, wie praktisch! Denn iese Beispiele kann man mit den Unit Tests einfach nachspielen. Im Beispiel unten erfolgt das mit der Zeile

run_doctests_mit_lesbarer_ausgabe(ist_gerade)
def ist_gerade(zahl: int) -> bool:
    """
    Prüft, ob eine Zahl gerade ist.
    Args:
        zahl (int): Die zu prüfende Zahl
    Returns:
        bool: True, wenn die Zahl gerade ist, sonst False
    Examples:
        >>> ist_gerade(2)
        True
        >>> ist_gerade(3)
        False
    """
    # Zum Testen kannst du die Funktion mal so abändern, dass sie ein falsches Ergebnis zurückgibt
    # return True # auskommentieren, um den Test fehlschlagen zu lassen
    return zahl % 2 == 0

# Unit Test ausführen
run_doctests_mit_lesbarer_ausgabe(ist_gerade)

Datenpunkt-Klasse#

Damit du die Koordinaten der Datenpunkte und der Zentroiden bequem veralten können, ist die Hilfsklasse Datenpunkt bereits vorhanden. In dieser Klasse erweitern wir noch die internen Methoden __eq__() und __hash__(), damit wir es später leichter haben, die neuen Zentroiden mit den alten Zentroiden der vorigen Iteration zu vergleichen (siehe Schritt 4 des Algorithmus). Es wäre auch möglich, die Koordinaten in verschachtelten Arrays zu verwalten, aber die Lösung einer eigenen Klasse ist eleganter und wir können Komplexität in diese Klasse auslagern. Für dich wichtig zu wissen: einen neuen Datenpunkt kannst du mit punkt = Datenpunkt(1.2345, 6.789) erzeugen.

Zentroiden & Datenpunkte

Zentroiden sind nichts anderes als Datenpunkte wie aus den Eingabedaten. Beide zeichnet aus, dass sie X- und Y-Koordinaten haben.

class Datenpunkt:
    """Klasse, die einen Datenpunkt repräsentiert mit XY-Koordinaten."""
    def __init__(self, x: float, y: float) -> None:
        """
        Erzeugt einen Datenpunkt mit den gegebenen Koordinaten, gerundet auf zwei Dezimalstellen.
        Examples:
            >>> punkt = Datenpunkt(1.2345, 6.789)
            >>> punkt.x
            1.23
            >>> punkt.y
            6.79
        """
        self.x = round(x, 2)
        self.y = round(y, 2)

    def __eq__(self, anderer_datenpunkt: object) -> bool:
        """
        Vergleicht zwei Datenpunkte basierend auf ihren Koordinaten.
        Examples:
            >>> Datenpunkt(1.23, 4.56) == Datenpunkt(1.23, 4.56)
            True
            >>> Datenpunkt(1.23, 4.56) == Datenpunkt(0.00, 4.56)
            False
        """
        if isinstance(anderer_datenpunkt, Datenpunkt):
            return self.x == anderer_datenpunkt.x and self.y == anderer_datenpunkt.y
        return False
    
    def __hash__(self) -> int:
        """
        Berechnet den Hashwert eines Datenpunktes.
        Examples:
            >>> hash(Datenpunkt(1.23, 4.56)) == hash(Datenpunkt(1.23, 4.56))
            True
        """
        return hash((self.x, self.y))
    
    def __repr__(self) -> str:
        """
        Gibt eine lesbare Repräsentation eines Datenpunktes zurück.
        Examples:
            >>> repr(Datenpunkt(1.23, 4.56))
            'Datenpunkt(1.23, 4.56)'
        """
        return f"Datenpunkt({self.x}, {self.y})"

run_doctests_mit_lesbarer_ausgabe(Datenpunkt.__init__)
run_doctests_mit_lesbarer_ausgabe(Datenpunkt.__eq__)
run_doctests_mit_lesbarer_ausgabe(Datenpunkt.__hash__)
run_doctests_mit_lesbarer_ausgabe(Datenpunkt.__repr__)

generiere_datenpunkte()#

Wir benötigen automatische Eingabedaten und wir möchten unsere kmeans()-Methode mit beliebig großen Datensätzen testen können. Dafür werden wir Methode generiere_datenpunkte() verwenden. Dieser können wir die Anzahl der gewünschten Datenpunkte und den gewünschten Zahlenraum angeben. Wir erhalten eine Liste von Datenpunkten zurück.

import random

def generiere_datenpunkte(anzahl_datenpunkte: int, zahlenraum: int = 10, randomseed=42) -> list[Datenpunkt]:
    """
    Generiert eine Liste von zufälligen Datenpunkten im angegebenen Zahlenraum.
    Args:
        anzahl_datenpunkte (int): Anzahl der zu generierenden Datenpunkte
        zahlenraum (int): Größe des Zahlenraums, in dem die Datenpunkte generiert werden
    Returns:
        list[Datenpunkt]: Liste von zufälligen Datenpunkten
    Examples:
        >>> datenpunkte = generiere_datenpunkte(3, 10)
        >>> len(datenpunkte)
        3
        >>> all(0 <= datenpunkt.x <= 10 and 0 <= datenpunkt.y <= 10 for datenpunkt in datenpunkte)
        True
    """
    # Damit wir einerseits zufällige Zahlen generieren können, aber andererseits auch reproduzierbare
    # Ergebnisse erhalten, setze wir den Zufallsgenerator auf einen fixen Wert.
    random.seed(randomseed)
    # Set, um Duplikate zu vermeiden
    datenpunkte = set()
    # Solange die Anzahl der generierten Datenpunkte kleiner als die gewünschte Anzahl ist,
    # neue Datenpunkte generieren und hinzufügen
    while len(datenpunkte) < anzahl_datenpunkte:
        # Zufällige Koordinaten im Zahlenraum generieren
        x = random.uniform(0, zahlenraum)
        y = random.uniform(0, zahlenraum)
        datenpunkt = Datenpunkt(x, y)
        datenpunkte.add(datenpunkt)
    return list(datenpunkte)

run_doctests_mit_lesbarer_ausgabe(generiere_datenpunkte)

initialisiere_zentroiden()#

Im ersten Schritt des k-means Algorithmus werden die Startzentroiden bestimmt. Die Methode initialisiere_zentroiden() wählt aus der übergebenen Liste von Datenpunkten die gewünschte Anzahl der Zentroiden zufällig aus. Die Auswahl ist zwar zufällig, bleibt aber reproduzierbar, weil wir in der Methode generiere_datenpunkte den Zufallsgenerator auf einen fixen Wert gesetzt haben. Die Methode gibt eine Liste von Datenpunkten zurück.

def initialisiere_zentroiden(datenpunkte: list[Datenpunkt], anzahl_zentroiden: int) -> list[Datenpunkt]:
    """
    Wählt zufällig die angegebene Anzahl von Zentroiden aus den Datenpunkten aus.
    Args:
        datenpunkte: Liste von Datenpunkten, aus denen die Zentroiden ausgewählt werden.
        anzahl_zentroiden: Anzahl der Zentroiden, die initialisiert werden sollen.
    Returns:
        list[Datenpunkt]: Liste der initialisierten Zentroiden.
    Examples:
        >>> datenpunkte = [Datenpunkt(1.4, 4.5), Datenpunkt(5.2, 8.3), Datenpunkt(3.1, 9.2), Datenpunkt(3.1, 6.2)]
        >>> anzahl_zentroiden = 2
        >>> result = initialisiere_zentroiden(datenpunkte, anzahl_zentroiden)
        >>> len(result)
        2
        >>> all(zentroid in datenpunkte for zentroid in result)
        True
    """
    zentroiden = random.sample(datenpunkte, anzahl_zentroiden)
    return zentroiden

run_doctests_mit_lesbarer_ausgabe(initialisiere_zentroiden)

Your turn

Ab hier beginnt dein Teil der Arbeit, viel Spaß und Erfolg 💪

10.9.3. A1: berechne_euklidische_distanz()#

Zum Warmwerden: du brauchst eine Methode, welche dir die Distanz zwischen zwei Datenpunkten berechnet. Wir werden hierfür als Distanzmaße die Euklidische Distanz verwenden. Die Methode erhält zwei Datenpunkte und gibt die Distanz auf zwei Nachkommastellen gerundet zurück. Zum runden benötigst du die Methode round().

def berechne_euklidische_distanz(datenpunkt1: Datenpunkt, datenpunkt2: Datenpunkt) -> float:
    """
    Berechnet die euklidische Distanz zu einem anderen Datenpunkt auf zwei Dezimalstellen gerundet.
    Args:
        datenpunkt1: Erster Datenpunkt.
        datenpunkt2: Zweiter Datenpunkt.
    Returns:
        float: Euklidische Distanz zwischen den beiden Datenpunkten auf zwei Dezimalstellen gerundet.
    Examples:
        >>> datenpunkt1 = Datenpunkt(1.0, 2.0)
        >>> datenpunkt2 = Datenpunkt(3.0, 4.0)
        >>> berechne_euklidische_distanz(datenpunkt1, datenpunkt2)
        2.83
        >>> berechne_euklidische_distanz(datenpunkt1, datenpunkt1)
        0.0
    """
    # Du benötigst den Satz des Pythagoras: c = Wurzel von (a^2 + b^2)
    # Beispiele für Potenzen, Wurzeln und Runden:
    potenz_von_3 = 3 ** 2
    wurzel_aus_9 = 9 ** 0.5
    pi_mit_5_nachkommastellen = round(3.14159, 5)
    ...  # Hier Lösung ergänzen
    diff_x = datenpunkt1.x - datenpunkt2.x
    diff_y = datenpunkt1.y - datenpunkt2.y
    distanz = (diff_x ** 2 + diff_y ** 2) ** 0.5
    return round(distanz, 2)

run_doctests_mit_lesbarer_ausgabe(berechne_euklidische_distanz)

10.9.4. A2: finde_naechsten_zentroiden()#

Nun wollen wir für einen Datenpunkt den nächstgelegenen Zentroiden ermitteln. Wir bekommen dafür die Liste von Zentroiden übergeben und müssen hieraus nun den Zentroiden ermitteln, zu dem die Distanz des Datenpunkts am geringsten ist. Überlege dir vorher, wie du vorgehen möchtest. Zurückgegeben wird der Index des ermittelten Zentroiden. Wenn also der 3. Zentroid der nächstgelegene ist, dann muss der Index 2 zurückgegeben wird (da Index bei 0 beginnt).

Wichtiger Tipp

Du benötigst die Methode berechne_euklidische_distanz() aus der vorigen Aufgabe!

def finde_naechsten_zentroiden(zentroiden: list[Datenpunkt], datenpunkt: Datenpunkt) -> int:
    """
    Finde den nächsten Zentroiden für einen gegebenen Datenpunkt. Wenn es mehrere Zentroiden mit
    der gleichen minimalen Distanz gibt, wird der erste gefundene Zentroid zurückgegeben. Die Zahl,
    die zurückgegeben wird, ist der Index des Zentroiden in der Liste der Zentroiden.
    Args:
        zentroiden (list[Datenpunkt]): Liste der Zentroiden, zwischen denen der nächste gesucht wird.
        datenpunkt (Datenpunkt): Der Datenpunkt, für den der nächste Zentroid gesucht wird.
    Returns:
        int: Index des nächsten Zentroiden. 
    Examples:
        >>> zentroiden = [Datenpunkt(1, 2), Datenpunkt(4, 6), Datenpunkt(3, 5)]
        >>> datenpunkt = Datenpunkt(1, 1)
        >>> finde_naechsten_zentroiden(zentroiden, datenpunkt)
        0
        >>> datenpunkt = Datenpunkt(4, 6)
        >>> finde_naechsten_zentroiden(zentroiden, datenpunkt)
        1
    """
    ...  # Hier Lösung ergänzen
    geringste_distanz = float('inf') # Erstmal die maximale Distanz wählen
    index_naechster_zentroid = -1
    index = 0
    for zentroid in zentroiden:
        distanz = berechne_euklidische_distanz(datenpunkt, zentroid)
        if distanz < geringste_distanz:
            geringste_distanz = distanz
            index_naechster_zentroid = index
        index += 1
    return index_naechster_zentroid

run_doctests_mit_lesbarer_ausgabe(finde_naechsten_zentroiden)

10.9.5. A3: datenpunkte_zuweisen()#

Nachdem wir den nächsten Zentroiden zu einem Datenpunkt berechnen können, sind wir nun in der Lage, alle Datenpunkte zu clustern. Mit der Methode datenpunkte_zuweisen() können wir nun für jeden Datenpunkt den nächsten Zentroiden ermitteln und daraus die Cluster bilden.

In der Methode unten ist bereits das Erzeugen der leeren Cluster erfolgt, darum musst du dich nicht kümmern. Was du noch ergänzen musst, ist das Ermitteln des nächsten Zentroiden zu jedem Datenpunkt. Mit dem erhaltenen Index kannst du dann in der Liste von Clustern jeden den Datenpunkt dem zugehörigen Cluster anhängen.

Wichtiger Tipp

Die Cluster sind in einer Liste enthalten. Die Cluster selber sind auch Listen. Hier unten siehst du ein Beispiel von einer Liste mit 3 Clustern.

clusters = [[Datenpunkt(1.0, 1.0), Datenpunkt(3.0, 3.0)], [Datenpunkt(5.0, 5.0)], [Datenpunkt(7.0, 7.0)]]

  • Cluster an Index 0 hat die Elemente Datenpunkt(1.0, 1.0) und Datenpunkt(3.0, 3.0)

  • Cluster an Index 1 hat das Element Datenpunkt(5.0, 5.0)

  • Cluster an Index 2 hat das Element Datenpunkt(7.0, 7.0)

def datenpunkte_zuweisen(datenpunkte: list[Datenpunkt], zentroiden: list[Datenpunkt]) -> list[list[Datenpunkt]]:
    """
    Weist jeden Datenpunkt den entsprechenden Cluster zu.
    Args:
        datenpunkte: Liste der Datenpunkte, die den Clustern zugeordnet werden sollen.
        zentroiden: Liste der Zentroiden, die die Cluster repräsentieren.
    Returns:
        list[list[Datenpunkt]]: Liste von Clustern, wobei jeder Cluster als eine Liste von Datenpunkten dargestellt wird.
    Examples:
        >>> datenpunkte = [Datenpunkt(1.0, 1.0), Datenpunkt(3.0, 3.0), Datenpunkt(5.0, 5.0), Datenpunkt(7.0, 7.0)]
        >>> zentroiden = [Datenpunkt(2.0, 2.0), Datenpunkt(4.0, 4.0), Datenpunkt(6.0, 6.0)]
        >>> datenpunkte_zuweisen(datenpunkte, zentroiden)
        [[Datenpunkt(1.0, 1.0), Datenpunkt(3.0, 3.0)], [Datenpunkt(5.0, 5.0)], [Datenpunkt(7.0, 7.0)]]

        >>> datenpunkte = [Datenpunkt(1.4, 1.2), Datenpunkt(1.6, 4.7), Datenpunkt(5.2, 8.3), Datenpunkt(5.1, 8.2), Datenpunkt(3.1, 9.2)]
        >>> zentroiden = [Datenpunkt(1.0, 3.0), Datenpunkt(4.0, 5.0)]
        >>> datenpunkte_zuweisen(datenpunkte, zentroiden)
        [[Datenpunkt(1.4, 1.2), Datenpunkt(1.6, 4.7)], [Datenpunkt(5.2, 8.3), Datenpunkt(5.1, 8.2), Datenpunkt(3.1, 9.2)]]
    """
    # Es wurde schon ein leere Liste für die Cluster angelegt für dich ;)
    clusters: list[list[Datenpunkt]] = []
    anzahl_cluster = len(zentroiden)
    for _ in range(anzahl_cluster):
        clusters.append([])

    # Du kannst nun jeden Datenpunkt dem richtigen Cluster zuweisen, indem du den Index des
    # nächsten Zentroiden findest und den Datenpunkt dem entsprechenden Cluster hinzufügst.
    # Tipp 1: Du benötigst die Methode finde_naechsten_zentroiden() aus der vorigen Aufgabe
    # Tipp 2: um einer Liste ein weiteres Item hinzuzuügen, kannst du die Methode list.append() verwenden.
    ...  # Hier die weitere Lösung ergänzen
    for datenpunkt in datenpunkte:
        index_naechster_zentroid = finde_naechsten_zentroiden(zentroiden, datenpunkt)
        clusters[index_naechster_zentroid].append(datenpunkt)
    return clusters

run_doctests_mit_lesbarer_ausgabe(datenpunkte_zuweisen)

10.9.6. A4: berechne_zentroid_koordinaten()#

Wir benötigen nun zum einen noch eine Funktion berechne_zentroid_koordinaten(), welche uns aus den Datenpunkten eines Clusters die neuen Koordinaten des Zentroiden berechnet. Diese Funktion wird dann von aktualisiere_zentroiden() in der nächsten Aufgabe verwendet, um die Zentroiden in jeder Iteration alle neu zu berechnen.

def berechne_zentroid_koordinaten(datenpunkte: list[Datenpunkt]) -> Datenpunkt:
    """
    Berechnet die Koordinaten des Zentroiden basierend auf den zugehörigen Datenpunkten.
    Dabei wird der Mittelwert der X- und Y-Koordinaten der Datenpunkte berechnet.
    Args:
        datenpunkte: Liste der Datenpunkte, für die der Zentroid berechnet werden soll.
    Returns:
        Datenpunkt: Zentroid mit den berechneten Koordinaten.
    Examples:
        >>> datenpunkte = [Datenpunkt(1.0, 1.0), Datenpunkt(3.0, 3.0), Datenpunkt(5.0, 5.0)]
        >>> berechne_zentroid_koordinaten(datenpunkte)
        Datenpunkt(3.0, 3.0)
        >>> datenpunkte = [Datenpunkt(1.4, 4.5), Datenpunkt(1.6, 4.7), Datenpunkt(5.2, 8.3)]
        >>> berechne_zentroid_koordinaten(datenpunkte)
        Datenpunkt(2.73, 5.83)
    """
    # Der Zentroid wird mit den Mittelwerten der X- und Y-Koordinaten der Datenpunkte berechnet.
    # Du benötigst also die Summe der X- und Y-Koordinaten und die Anzahl der Datenpunkte.
    # Als Rüchgabe erzeugst du einen neuen Datenpunkt mit den berechneten Koordinaten.
    ...  # Hier Lösung ergänzen
    x_summe = 0
    y_summe = 0
    for datenpunkt in datenpunkte:
        x_summe += datenpunkt.x
        y_summe += datenpunkt.y
    x_mittelwert = x_summe / len(datenpunkte) # Hier den Mittelwert der X-Koordinaten berechnen
    y_mittelwert = y_summe / len(datenpunkte) # Hier den Mittelwert der Y-Koordinaten berechnen

    return Datenpunkt(x_mittelwert, y_mittelwert)

run_doctests_mit_lesbarer_ausgabe(berechne_zentroid_koordinaten)

10.9.7. A5: aktualisiere_zentroiden()#

Der letzte Schritt: mit Hilfe der vorigen Methode berechne_zentroid_koordinaten() kannst du nun für alle Cluster die Zentroiden neu berechnen lassen. aktualisiere_zentroiden() erhält dabei eine Liste der Clusters. Ein Eintrag in der Liste ist dabei wiederum eine Liste von Datenpunkte. Unten siehst du ein Beispiel, wie man die einzelnen Cluster abarbeiten kann.

Ein Beispiel:

cluster = [
    0: [Datenpunkt(1, 1), Datenpunkt(2, 2)],
    1: [Datenpunkt(4, 4), Datenpunkt(5, 5)],
    2: [Datenpunkt(8, 8)]
]

# Jeder Cluster ist eine Liste von Datenpunkten
for cluster_datenpunkte in cluster
    # Stelle Berechnungen an für die Datenpunkte eines Clusters
    ...

Wichtiger Tipp

Du benötigst die Methode berechne_zentroid_koordinaten() aus der vorigen Aufgabe!

def aktualisiere_zentroiden(cluster: list[list[Datenpunkt]]) -> list[Datenpunkt]:
    """
    Aktualisiert die Zentroiden basierend auf den zugewiesenen Datenpunkten.
    Dabei wird für jeden Cluster der Zentroid neu berechnet.
    Args:
        cluster: Liste mit den Clustern und den zugehörigen Datenpunkten.
    Returns:
        list[Datenpunkt]: Liste der neuen Zentroiden.
    Examples:
        >>> cluster = [[Datenpunkt(1.0, 1.0), Datenpunkt(3.0, 3.0)], [Datenpunkt(5.0, 5.0), Datenpunkt(7.0, 7.0)]]
        >>> aktualisiere_zentroiden(cluster)
        [Datenpunkt(2.0, 2.0), Datenpunkt(6.0, 6.0)]

        >>> cluster = [[Datenpunkt(1.4, 4.5), Datenpunkt(5.2, 8.3), Datenpunkt(3.1, 9.2)], [Datenpunkt(1.6, 4.7), Datenpunkt(5.1, 8.2), Datenpunkt(3.1, 9.2)]]
        >>> aktualisiere_zentroiden(cluster)
        [Datenpunkt(3.23, 7.33), Datenpunkt(3.27, 7.37)]
    """
    ...  # Hier Lösung ergänzen
    neue_zentroiden: list[Datenpunkt] = []
    # Jeder Cluster ist eine Liste von Datenpunkten
    for datenpunkte_in_cluster in cluster:
        neuer_zentroid: Datenpunkt = berechne_zentroid_koordinaten(datenpunkte_in_cluster)
        neue_zentroiden.append(neuer_zentroid)
    return neue_zentroiden

run_doctests_mit_lesbarer_ausgabe(aktualisiere_zentroiden)

10.9.8. Finale: k_means()#

Wir haben nun alle nötigen Untermethoden für den Algorithmus erzeugt und können ihn nun starten. Wir definieren noch eine Methode, um die einzelnen Schritte in Schaubildern (Plots) erzeugen zu können.

import matplotlib.pyplot as plt

def plot_cluster(clusters: list[list[Datenpunkt]], zentroiden: list[Datenpunkt], iteration: int) -> None:
    farben = ['green', 'blue', 'orange', 'purple', 'red', 'brown', 'pink', 'gray', 'cyan', 'magenta']
    
    plt.figure(figsize=(6, 6))
    cluster_index = 0
    for cluster_datenpunkte in clusters:
        x_coords = [datenpunkt.x for datenpunkt in cluster_datenpunkte]
        y_coords = [datenpunkt.y for datenpunkt in cluster_datenpunkte]
        plt.scatter(x_coords, y_coords, color=farben[cluster_index], label=f'Cluster {cluster_index + 1} ({zentroiden[cluster_index].x}, {zentroiden[cluster_index].y})')
        cluster_index += 1
        
    zentroid_x_coords = [zentroid.x for zentroid in zentroiden]
    zentroid_y_coords = [zentroid.y for zentroid in zentroiden]
    plt.scatter(zentroid_x_coords, zentroid_y_coords, color='black', marker='x', s=100)  # Zentroiden ohne Legende
    
    plt.title(f'k-means Clustering - Iteration {iteration}')
    plt.xlabel('X-Koordinate')
    plt.ylabel('Y-Koordinate')
    plt.legend()
    plt.show()

Finale: Wir haben alle nötigen Methoden und Untermethoden erzeugt, nun können wir die Methoden in unserer k-means()-Methode verwenden. In den docstrings findest du auch noch mal den Ablauf der Schritte. Wenn du die Zelle ausführst, werden die Berechnungen angestellt und die Plots gedruckt. Am Ende werden dir noch die Cluster und die Zentroiden in Textform ausgegeben.

def k_means(datenpunkte: list[Datenpunkt], anzahl_cluster: int, max_iterationen: int, print_plots = False) -> tuple[list[list[Datenpunkt]], list[Datenpunkt]]:
    """
    Hauptfunktion für den k-means Algorithmus zur Clusterbildung. Der Algorithmus besteht aus folgenden Schritten:
    2. Initialisierung der Zentroiden
    3. Zuweisung der Datenpunkte und Bildung der Cluster
    4. Aktualisierung der Zentroiden
    5. Überprüfung, ob sich die Zentroiden verändert haben
    Args:
        datenpunkte: Liste der Datenpunkte, die geclustert werden sollen.
        anzahl_cluster: Anzahl der Cluster, die gebildet werden sollen.
        max_iterationen: Maximale Anzahl der Iterationen, die der Algorithmus durchführt.
        print_plots: Gibt an, ob die Cluster in jedem Schritt visualisiert werden
    Returns:
        tuple[list[list[Datenpunkt]], list[Datenpunkt]]: Die Cluster und die finalen Zentroiden.
    Examples:
        >>> datenpunkte = [Datenpunkt(1.0, 1.0), Datenpunkt(1.0, 3.0), Datenpunkt(3.0, 3.0), Datenpunkt(4.0, 2.0), Datenpunkt(5.0, 5.0)]
        >>> clusters, zentroiden = k_means(datenpunkte, 2, 100)
        >>> len(clusters)
        2
        >>> len(zentroiden)
        2
        >>> sum(len(cluster) for cluster in clusters) == len(datenpunkte)
        True
    """
    # 1. Initialisierung der Zentroiden
    zentroiden = initialisiere_zentroiden(datenpunkte, anzahl_cluster)
    cluster = []

    for iteration in range(max_iterationen):
        # 2. Cluster berechnen
        cluster = datenpunkte_zuweisen(datenpunkte, zentroiden)
        
        # Plot der Cluster ausgeben, wenn gewünscht
        if print_plots:
            plot_cluster(cluster, zentroiden, iteration + 1)
        
        # 3. Zentroiden aktualisieren
        neue_zentroiden = aktualisiere_zentroiden(cluster)
        
        # 4. Abbruch wenn die Zentroiden sich nicht verändert haben
        if neue_zentroiden == zentroiden:
            logger.info("Zentroiden haben sich nicht mehr verändert. Algorithmus stoppt.")
            break
        zentroiden = neue_zentroiden
    return cluster, zentroiden

# Unit Test für die Hauptfunktion
run_doctests_mit_lesbarer_ausgabe(k_means)

# Manueller Start des k-means Algorithmus
datenpunkte = generiere_datenpunkte(50, zahlenraum=10)
cluster, zentroiden = k_means(datenpunkte=datenpunkte, anzahl_cluster=4, max_iterationen=100, print_plots=True)

print(f"Cluster: {cluster}")
print(f"Zentroiden: {zentroiden}")