diff --git a/NSL/aufgaben/README.md b/NSL/aufgaben/README.md index e4b9b83..23936ae 100644 --- a/NSL/aufgaben/README.md +++ b/NSL/aufgaben/README.md @@ -1,66 +1,112 @@ -# K-Means: Elbow-Methode & Gütemasse +# CAS PML – Unsupervised Learning: Clustering-Aufgaben -> Thema: Optimale Clusterzahl $k$ bestimmen und Clusterqualität bewerten -> Skript: Unsupervised Learning (CAS PML) - -## Ziel - -Auf synthetischen 2D-Daten (drei Gauss-Blobs) K-Means für verschiedene $k$ laufen lassen und die "richtige" Clusterzahl systematisch über Gütemasse bestimmen — statt nur visuell zu schätzen. +> Modul: CAS Practical Machine Learning – Unsupervised Learning (Dehmer) +> Inhalt: zwei Clustering-Übungen rund um K-Means und die Wahl der Clusterzahl $k$ ## Projektstruktur ``` . -├── data/ # leer: Daten werden synthetisch generiert +├── data/ +│ └── data_delivery_fleet.tsv # Übung 1 (tab-separiert) +├── plots/ # generierte PNGs (headless) ├── src/ -│ └── k_means2_elbow_measures.py +│ ├── uebung1_kmeans_fleet.py # Übung 1: k-Means auf Fleet-Daten +│ └── k_means2_elbow_measures.py # Übung 2: Elbow & Gütemasse (synthetisch) ├── devenv.nix └── README.md ``` -Ausführen (in der devenv-Shell): +> Tipp für konsistente Namen: `k_means2_elbow_measures.py` ggf. zu `uebung2_elbow_measures.py` umbenennen. + +## Setup & Ausführen + +In der devenv-Shell: ``` +python src/uebung1_kmeans_fleet.py python src/k_means2_elbow_measures.py ``` -## Ablauf des Skripts +Beide Skripte laufen **headless**: Plots werden als PNG nach `plots/` geschrieben (kein `plt.show()`, da in der Shell kein Display vorhanden ist – Agg-Backend). -1. Daten generieren: je 100 Punkte um die Zentren $(0,0)$, $(6,6)$, $(0,6)$ via `np.random.randn` (Seed fix → reproduzierbar) -2. Für $k = 1 \dots 8$ K-Means fitten und drei Masse sammeln: - - Inertia (`kmeans.inertia_`) — immer - - Silhouette-Score und Davies-Bouldin-Index — erst ab $k \geq 2$ (für $k=1$ nicht definiert → `np.nan`) -3. Bestes $k$ wählen (vereinfacht: maximaler Silhouette-Score via `np.nanargmax`) -4. Finales Modell mit `best_k` fitten → Labels + Zentren -5. Visualisierung in vier Panels: Rohdaten | Elbow (Inertia) | Silhouette | finales Clustering +## Gütemasse zur Wahl von $k$ (gemeinsame Referenz) -## Gütemasse (Kurzreferenz) - -- Inertia / WCSS — Summe der quadrierten Abstände zum jeweiligen Zentroid; sinkt monoton mit $k$, hat also kein Optimum, nur einen "Knick" (Elbow) +- Inertia / WCSS – Summe der quadrierten Abstände zum Zentroid; sinkt monoton mit $k$, daher nur "Knick" (Elbow), kein echtes Optimum $$ \text{WCSS} = \sum_{i=1}^{k} \sum_{x \in P_i} \lVert x - \mu_i \rVert^2 $$ -- Silhouette-Score — $a_i$ = mittlere Intra-Cluster-Distanz, $b_i$ = mittlere Distanz zum nächsten Cluster; Bereich $[-1, 1]$, höher = besser +- Erklärte Varianz – Anteil der Between-Cluster-Varianz an der Gesamtvarianz; nutzt $\text{TSS} = \text{WCSS} + \text{BSS}$, höher = besser (ideal 100 %) + +$$ \text{Variance Explained} = \frac{\text{BSS}}{\text{TSS}} \cdot 100\,\% $$ + +- Silhouette-Score – $a_i$ = mittlere Intra-, $b_i$ = mittlere nächste Inter-Cluster-Distanz; Bereich $[-1, 1]$, höher = besser $$ s_i = \frac{b_i - a_i}{\max(a_i, b_i)} $$ -- Davies-Bouldin-Index — mittleres "Worst-Case"-Verhältnis von Cluster-Streuung zu Cluster-Abstand; niedriger = besser (Achtung: umgekehrt zur Silhouette!) +- Davies-Bouldin (nur Übung 2) – mittleres Verhältnis Streuung/Abstand; niedriger = besser (Achtung: umgekehrt zur Silhouette) $$ DB = \frac{1}{k} \sum_{i=1}^{k} \max_{j \neq i} \frac{\sigma_i + \sigma_j}{d(c_i, c_j)} $$ -- mit $\sigma_i$ = mittlere Distanz der Punkte in Cluster $i$ zu ihrem Zentroid $c_i$ +--- -## Verständnisfragen (im Code eingebettet) +## Übung 1 — k-Means: Delivery Fleet + +Aufgabenstellung (Skript): Fleet-Daten von Lieferfahrer:innen clustern. + +1. Datensatz aus tab-separiertem File laden (Pandas) +2. Daten visualisieren und eine sinnvolle Clusterzahl visuell *und* systematisch finden +3. Daten clustern + +Datensatz `data_delivery_fleet.tsv` (3 Spalten): + +- `Driver_ID` – ID der Fahrer:in +- `Distance_Feature` – mittlere gefahrene Meilen pro Tag +- `Speeding_Feature` – % der Zeit über dem Tempolimit (>5 mph) + +Geclustert wird auf `Distance_Feature` × `Speeding_Feature`. + +Ablauf von `uebung1_kmeans_fleet.py` (erzeugt PNGs in `plots/`): + +1. Rohdaten-Scatter (`01_rohdaten.png`) +2. K-Means mit frei gewähltem $k=4$ (`02_kmeans_k4.png`) +3. Sweep $k = 1 \dots 8$ als Übersichts-Grid (`03_kmeans_sweep_k1-8.png`) +4. $k$ bestimmen über Inertia/Elbow (`04`), erklärte Varianz (`05`) und Silhouette (`06`) +5. finales Modell mit bestem $k$ (`07_final.png`) + +Verständnisfragen: + +- [ ] Legen Elbow, erklärte Varianz und Silhouette dasselbe $k$ nahe? +- [ ] Sollte man die zwei Features vor dem Clustern skalieren (Meilen vs. %, sehr unterschiedliche Spannweiten)? +- [ ] Die Hartigan-Regel ist im Lösungs-Notebook enthalten, gilt aber als veraltet – warum bevorzugt man Silhouette/Elbow? + +> Anpassungen ggü. dem Notebook: `.as_matrix()` → `.to_numpy()` (in aktuellem Pandas entfernt); `plt.show()` → `savefig` (headless); die Hartigan-Funktion ist als optionaler Legacy-Block enthalten (`RUN_HARTIGAN = False`). + +--- + +## Übung 2 — Elbow-Methode & Gütemasse (synthetisch) + +Ziel: auf synthetischen 2D-Daten (drei Gauss-Blobs) K-Means für verschiedene $k$ laufen lassen und die "richtige" Clusterzahl systematisch über Gütemasse bestimmen – statt nur visuell zu schätzen. + +Ablauf von `k_means2_elbow_measures.py` (erzeugt einen 4-Panel-Plot): + +1. Daten generieren: je 100 Punkte um $(0,0)$, $(6,6)$, $(0,6)$ (Seed fix → reproduzierbar) +2. Für $k = 1 \dots 8$ K-Means fitten und sammeln: Inertia, Silhouette, Davies-Bouldin (Letztere ab $k \geq 2$) +3. Bestes $k$ wählen (vereinfacht: maximaler Silhouette-Score) +4. Finales Modell fitten → Labels + Zentren +5. Visualisierung: Rohdaten | Elbow (Inertia) | Silhouette | finales Clustering + +Verständnisfragen: - [ ] Warum nimmt man zur $k$-Wahl die Silhouette und nicht die Inertia? - [ ] Warum sind die Cluster im Ergebnis-Plot farblich klar getrennt? - [ ] Warum können verschiedene Runs unterschiedliche Ergebnisse liefern? - [ ] Ist das gefundene $k$ immer "korrekt"? -## Experimente (zum Ausprobieren) +Experimente (zum Ausprobieren): - [ ] Zentren näher zusammenlegen (Daten stärker überlappen) → wie verändern sich Elbow und Scores? -- [ ] Davies-Bouldin gegen die Silhouette als $k$-Kriterium tauschen → wählt es dasselbe $k$? +- [ ] Davies-Bouldin statt Silhouette als $k$-Kriterium (dann `argmin` statt `argmax`!) → dasselbe $k$? - [ ] `n_init` reduzieren / Seed variieren → wie stabil ist das Ergebnis? -> Zu allen Verständnisfragen stehen im Skript kurze Musterantworten als Kommentar. +> Zu den Verständnisfragen stehen im Skript kurze Musterantworten als Kommentar. diff --git a/NSL/aufgaben/devenv.lock b/NSL/aufgaben/devenv.lock index 7c6b42c..92856f6 100644 --- a/NSL/aufgaben/devenv.lock +++ b/NSL/aufgaben/devenv.lock @@ -17,22 +17,6 @@ "type": "github" } }, - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1767039857, - "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", - "owner": "edolstra", - "repo": "flake-compat", - "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, "nixpkgs": { "inputs": { "nixpkgs-src": "nixpkgs-src" @@ -52,27 +36,6 @@ "type": "github" } }, - "nixpkgs-python": { - "inputs": { - "flake-compat": "flake-compat", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1781070343, - "narHash": "sha256-wXAybU+2LlbXm9cfRDHRPASakq60rLvZBXVT2Ahkj1U=", - "owner": "cachix", - "repo": "nixpkgs-python", - "rev": "23629493653be6df0472a46b1be1d65cbd6df55b", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "nixpkgs-python", - "type": "github" - } - }, "nixpkgs-src": { "flake": false, "locked": { @@ -93,8 +56,7 @@ "root": { "inputs": { "devenv": "devenv", - "nixpkgs": "nixpkgs", - "nixpkgs-python": "nixpkgs-python" + "nixpkgs": "nixpkgs" } } }, diff --git a/NSL/aufgaben/devenv.nix b/NSL/aufgaben/devenv.nix index ef640a2..b5a8305 100644 --- a/NSL/aufgaben/devenv.nix +++ b/NSL/aufgaben/devenv.nix @@ -1,22 +1,27 @@ -{ pkgs, lib, config, ... }: +{ pkgs, ... }: { + + packages = [ + pkgs.zlib + pkgs.stdenv.cc.cc.lib + ]; + languages.python = { enable = true; - version = "3.14.4"; venv.enable = true; venv.requirements = '' - ipython - jupyter jupyterlab matplotlib numpy + pandas scikit-learn + seaborn ''; }; - packages = [ - pkgs.graphviz - pkgs.zsh + env.LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ + pkgs.zlib + pkgs.stdenv.cc.cc.lib ]; } diff --git a/NSL/aufgaben/devenv.yaml b/NSL/aufgaben/devenv.yaml deleted file mode 100644 index f6e91f8..0000000 --- a/NSL/aufgaben/devenv.yaml +++ /dev/null @@ -1,6 +0,0 @@ -inputs: - nixpkgs-python: - url: github:cachix/nixpkgs-python - inputs: - nixpkgs: - follows: nixpkgs diff --git a/NSL/aufgaben/plots/01_rohdaten.png b/NSL/aufgaben/plots/01_rohdaten.png new file mode 100644 index 0000000..0fe9905 Binary files /dev/null and b/NSL/aufgaben/plots/01_rohdaten.png differ diff --git a/NSL/aufgaben/plots/02_kmeans_k4.png b/NSL/aufgaben/plots/02_kmeans_k4.png new file mode 100644 index 0000000..2cc1fdc Binary files /dev/null and b/NSL/aufgaben/plots/02_kmeans_k4.png differ diff --git a/NSL/aufgaben/plots/03_kmeans_sweep_k1-8.png b/NSL/aufgaben/plots/03_kmeans_sweep_k1-8.png new file mode 100644 index 0000000..b5a30c7 Binary files /dev/null and b/NSL/aufgaben/plots/03_kmeans_sweep_k1-8.png differ diff --git a/NSL/aufgaben/plots/04_inertia.png b/NSL/aufgaben/plots/04_inertia.png new file mode 100644 index 0000000..3abfa59 Binary files /dev/null and b/NSL/aufgaben/plots/04_inertia.png differ diff --git a/NSL/aufgaben/plots/05_variance_explained.png b/NSL/aufgaben/plots/05_variance_explained.png new file mode 100644 index 0000000..e86ab8e Binary files /dev/null and b/NSL/aufgaben/plots/05_variance_explained.png differ diff --git a/NSL/aufgaben/plots/06_silhouette.png b/NSL/aufgaben/plots/06_silhouette.png new file mode 100644 index 0000000..2b8cb09 Binary files /dev/null and b/NSL/aufgaben/plots/06_silhouette.png differ diff --git a/NSL/aufgaben/plots/07_final.png b/NSL/aufgaben/plots/07_final.png new file mode 100644 index 0000000..ae6f5e4 Binary files /dev/null and b/NSL/aufgaben/plots/07_final.png differ diff --git a/NSL/aufgaben/plots/elbow_measures.png b/NSL/aufgaben/plots/elbow_measures.png new file mode 100644 index 0000000..ba9010e Binary files /dev/null and b/NSL/aufgaben/plots/elbow_measures.png differ diff --git a/NSL/aufgaben/src/uebung1_kmeans_fleet.py b/NSL/aufgaben/src/uebung1_kmeans_fleet.py new file mode 100644 index 0000000..64c9e1c --- /dev/null +++ b/NSL/aufgaben/src/uebung1_kmeans_fleet.py @@ -0,0 +1,201 @@ +""" +Übung 1 — k-Means: Delivery Fleet +CAS PML, Unsupervised Learning + +Aufgabe (Skript): + 1. Datensatz aus tab-separiertem File laden + 2. Daten visualisieren, sinnvolle Clusterzahl k finden (visuell + systematisch) + 3. Daten clustern + +Umbau aus dem Lösungs-Notebook (lektion_1.ipynb): + - .as_matrix() -> .to_numpy() (in aktuellem Pandas entfernt) + - plt.show() -> savefig nach plots/ (headless, kein Display in der devenv-Shell) + - n_init=10, random_state=0 ergänzt (Reproduzierbarkeit, keine FutureWarnings) +""" + +import matplotlib +matplotlib.use("Agg") # headless: nur in Dateien rendern + +from pathlib import Path + +import numpy as np +import matplotlib.pyplot as plt +import pandas as pd +from sklearn.cluster import KMeans +from sklearn.metrics import silhouette_score +from scipy.spatial.distance import cdist, pdist + +# ========================================================= +# EINSTELLUNGEN / PFADE +# ========================================================= + +BASE = Path(__file__).resolve().parent.parent # .../aufgaben +DATA = BASE / "data" / "data_delivery_fleet.tsv" +PLOTS = BASE / "plots" +PLOTS.mkdir(exist_ok=True) + +RANDOM_SEED = 0 +RUN_HARTIGAN = False # Legacy-Mass (gilt als veraltet), siehe unten + + +def _scatter_clusters(ax, data, model): + """Punkte nach Cluster-Label einfärben, Zentren als schwarzes X.""" + ax.scatter(data[:, 0], data[:, 1], c=model.labels_, cmap="tab10", s=10) + centers = model.cluster_centers_ + ax.scatter(centers[:, 0], centers[:, 1], c="k", marker="X", s=150) + ax.set_xlabel("distance [mi]") + ax.set_ylabel("speeding [%]") + + +def save_kmeans(data, model, outfile, title=None): + fig, ax = plt.subplots() + _scatter_clusters(ax, data, model) + ax.set_title(title or f"k-Means (k={len(model.cluster_centers_)})") + fig.savefig(PLOTS / outfile, dpi=150, bbox_inches="tight") + plt.close(fig) + + +# ========================================================= +# 1. DATEN LADEN & VORBEREITEN +# ========================================================= + +if not DATA.exists(): + raise SystemExit( + f"Daten nicht gefunden: {DATA}\n" + "Lege 'data_delivery_fleet.tsv' in den data/-Ordner." + ) + +df = pd.read_csv(DATA, sep="\t", header=0) +print(df.head()) + +# Spalten: +# Driver_ID - ID der Fahrer:in +# Distance_Feature - mittlere gefahrene Meilen pro Tag +# Speeding_Feature - % der Zeit > Tempolimit (>5 mph) +data = df[["Distance_Feature", "Speeding_Feature"]].to_numpy() +print("shape:", data.shape) + +# Rohdaten +fig, ax = plt.subplots() +ax.scatter(df.Distance_Feature, df.Speeding_Feature, marker=".") +ax.set_xlabel("distance [mi]") +ax.set_ylabel("speeding fraction [%]") +ax.grid(True) +fig.savefig(PLOTS / "01_rohdaten.png", dpi=150, bbox_inches="tight") +plt.close(fig) + +# ========================================================= +# 2. ERSTES CLUSTERING (frei gewähltes k) +# ========================================================= + +k_means = KMeans(n_clusters=4, n_init=10, random_state=RANDOM_SEED).fit(data) +save_kmeans(data, k_means, "02_kmeans_k4.png") + +# Cluster-Zuordnung für k = 1 ... 8 als Übersichts-Grid (statt 8 Einzelplots) +fig, axes = plt.subplots(2, 4, figsize=(20, 8)) +for k, ax in zip(range(1, 9), axes.ravel()): + model = KMeans(n_clusters=k, n_init=10, random_state=RANDOM_SEED).fit(data) + _scatter_clusters(ax, data, model) + ax.set_title(f"k={k}") +fig.tight_layout() +fig.savefig(PLOTS / "03_kmeans_sweep_k1-8.png", dpi=150, bbox_inches="tight") +plt.close(fig) + +# ========================================================= +# 3. OPTIMALE CLUSTERZAHL k +# ========================================================= + +# ---- Within-Cluster Sum of Squares (Inertia) ---- +Ks = range(1, 10) +inertia = [KMeans(n_clusters=k, n_init=10, random_state=RANDOM_SEED).fit(data).inertia_ + for k in Ks] + +fig, ax = plt.subplots() +ax.bar(Ks, inertia) +ax.set_title("Within-Cluster Sum of Squares") +ax.set_xlabel("Cluster Count k") +ax.set_ylabel("Error") +ax.grid(True) +fig.savefig(PLOTS / "04_inertia.png", dpi=150, bbox_inches="tight") +plt.close(fig) + +# ---- Percentage of Variance Explained ---- +# TSS = WCSS + BSS -> Variance Explained = BSS / TSS +ks = range(1, 10) +models = [KMeans(n_clusters=k, n_init=10, random_state=RANDOM_SEED).fit(data) for k in ks] +centroids = [m.cluster_centers_ for m in models] + +# Abstand jedes Punkts zum nächsten Zentrum -> WCSS pro k +dist = [np.min(cdist(data, cent, "euclidean"), axis=1) for cent in centroids] +wcss = np.array([np.sum(d ** 2) for d in dist]) +tss = np.sum(pdist(data) ** 2) / data.shape[0] +bss = tss - wcss + +fig, ax = plt.subplots() +ax.plot(list(ks), bss / tss * 100, "b*-") +ax.set_ylim((0, 100)) +ax.set_xlabel("Cluster Count k") +ax.set_ylabel("Percentage of variance explained") +ax.set_title("Variance Explained vs. k") +ax.grid(True) +fig.savefig(PLOTS / "05_variance_explained.png", dpi=150, bbox_inches="tight") +plt.close(fig) + +# ---- Silhouette Score (erst ab k >= 2 definiert) ---- +ks_sil = range(2, 10) +silhouette = [silhouette_score( + data, + KMeans(n_clusters=k, n_init=10, random_state=RANDOM_SEED).fit(data).labels_, + metric="euclidean") + for k in ks_sil] + +fig, ax = plt.subplots() +ax.plot(list(ks_sil), silhouette, marker="o") +ax.set_xlabel("Cluster Count k") +ax.set_ylabel("Silhouette Score") +ax.set_title("Silhouette for k-Means") +ax.grid(True) +fig.savefig(PLOTS / "06_silhouette.png", dpi=150, bbox_inches="tight") +plt.close(fig) + +best_k = list(ks_sil)[int(np.argmax(silhouette))] +print(f"Bestes k nach Silhouette: {best_k}") + +# ========================================================= +# 4. FINALES MODELL +# ========================================================= + +k_means_final = KMeans(n_clusters=best_k, n_init=10, random_state=RANDOM_SEED).fit(data) +save_kmeans(data, k_means_final, "07_final.png", title=f"Finales Clustering (k={best_k})") + +print(f"Plots gespeichert in: {PLOTS}") + + +# ========================================================= +# 5. HARTIGAN-REGEL (optional, Legacy) +# ========================================================= +# Im Lösungs-Notebook enthalten, gilt aber als veraltet (vgl. Chiang & Mirkin). +# Silhouette/Elbow werden bevorzugt -> standardmässig deaktiviert (RUN_HARTIGAN). + +def hartigan_k(data, threshold=12): + """'Korrekte' Clusterzahl nach Hartigan: erstes k mit H <= threshold.""" + inertia_list = np.zeros(len(data) + 1) + num = 0 + h_rule = threshold + 1 + while num < len(data) and h_rule > threshold: + kmn = KMeans(n_clusters=num + 1, n_init=10, random_state=RANDOM_SEED).fit(data) + inertia_list[num + 1] = kmn.inertia_ + if num > 0: + h_rule = ((float(inertia_list[num]) / inertia_list[num + 1]) - 1) * (len(data) - num - 1) + print(f"k={num}, H={h_rule:.2f}") + num += 1 + if h_rule > threshold: + num += 1 + return num - 1 + + +if RUN_HARTIGAN: + k_h = hartigan_k(data, threshold=12) + print(f"Hartigan k: {k_h}") + model_h = KMeans(n_clusters=k_h, n_init=10, random_state=RANDOM_SEED).fit(data) + save_kmeans(data, model_h, "08_hartigan.png", title=f"Hartigan (k={k_h})") diff --git a/NSL/aufgaben/src/k_means2_elbow_measures.py b/NSL/aufgaben/src/uebung2_k_means_elbow_measures.py similarity index 95% rename from NSL/aufgaben/src/k_means2_elbow_measures.py rename to NSL/aufgaben/src/uebung2_k_means_elbow_measures.py index 7aa50c8..2ab0fed 100644 --- a/NSL/aufgaben/src/k_means2_elbow_measures.py +++ b/NSL/aufgaben/src/uebung2_k_means_elbow_measures.py @@ -106,10 +106,11 @@ ax[3].set_title(f"K-Means Ergebnis (k={best_k})") # Frage: # Warum sind die Cluster farbig getrennt? # Lösung: -# ? Hard Clustering: jeder Punkt genau ein Label +# - Hard Clustering: jeder Punkt genau ein Label plt.tight_layout() -plt.show() +plt.savefig("plots/elbow_measures.png", dpi=150, bbox_inches="tight") +print("Plot gespeichert: plots/elbow_measures.png") # ========================================================= # ? WEITERE FRAGEN