diff --git a/NSL/aufgaben/README.md b/NSL/aufgaben/README.md index 6027a81..17d8ac2 100644 --- a/NSL/aufgaben/README.md +++ b/NSL/aufgaben/README.md @@ -12,9 +12,9 @@ │ └── data_stocks.tsv # Übung 2 (Dateiname ggf. anpassen) ├── plots/ # generierte PNGs (headless) ├── src/ -│ ├── uebung1_kmeans_fleet.py # Übung 1: k-Means (Lösung, lauffähig) -│ ├── uebung2_kprototypes_stocks.py # Übung 2: k-Prototypes (Codegerüst, TODOs) -│ └── k_means2_elbow_measures.py # Zusatz: Elbow & Gütemasse (synthetisch) +│ ├── uebung1_kmeans_fleet.py # Übung 1: k-Means +│ ├── uebung2_kprototypes_stocks.py # Übung 2: k-Prototypes +│ └── k_means2_elbow_measures.py # Zusatz: Elbow & Gütemasse ├── devenv.nix └── README.md ``` @@ -29,13 +29,6 @@ python src/uebung2_kprototypes_stocks.py python src/k_means2_elbow_measures.py ``` -Für Übung 2 wird das Paket **`kmodes`** benötigt (k-Prototypes ist nicht in scikit-learn). -In `devenv.nix` zu den `venv.requirements` hinzufügen: - -``` -kmodes -``` - Alle Skripte laufen **headless**: Plots werden als PNG nach `plots/` geschrieben (kein `plt.show()`, da in der Shell kein Display – Agg-Backend). ## Gütemasse zur Wahl von $k$ (gemeinsame Referenz) @@ -82,8 +75,6 @@ Ablauf von `uebung1_kmeans_fleet.py` (PNGs in `plots/`): 4. $k$ über Inertia/Elbow (`04`), erklärte Varianz (`05`), Silhouette (`06`) 5. finales Modell mit bestem $k$ (`07_final.png`) -Status: **fertige Lösung** (aus dem Notebook umgebaut, lauffähig). - > Anpassungen ggü. Notebook: `.as_matrix()` → `.to_numpy()`; `plt.show()` → `savefig`; Hartigan als optionaler Legacy-Block (`RUN_HARTIGAN = False`). --- @@ -121,8 +112,6 @@ Gerüst-Abschnitte in `uebung2_kprototypes_stocks.py` (mit TODO selbst füllen): 4. finales Clustering fitten 5. Interpretation: Cluster je Feature beschreiben, semantisch deuten -Status: **Codegerüst** – Boilerplate (Imports/Pfade/Plots) gesetzt, Implementierung offen. - --- ## Zusatz — Elbow-Methode & Gütemasse (synthetisch) diff --git a/NSL/aufgaben/devenv.nix b/NSL/aufgaben/devenv.nix index b5a8305..e18539d 100644 --- a/NSL/aufgaben/devenv.nix +++ b/NSL/aufgaben/devenv.nix @@ -12,6 +12,7 @@ venv.enable = true; venv.requirements = '' jupyterlab + kmodes matplotlib numpy pandas diff --git a/NSL/aufgaben/plots/u2_cluster_scatter.png b/NSL/aufgaben/plots/u2_cluster_scatter.png new file mode 100644 index 0000000..319fd19 Binary files /dev/null and b/NSL/aufgaben/plots/u2_cluster_scatter.png differ diff --git a/NSL/aufgaben/plots/u2_elbow_kprototypes.png b/NSL/aufgaben/plots/u2_elbow_kprototypes.png new file mode 100644 index 0000000..2b01a66 Binary files /dev/null and b/NSL/aufgaben/plots/u2_elbow_kprototypes.png differ diff --git a/NSL/aufgaben/plots/u2_skalierung_vergleich.png b/NSL/aufgaben/plots/u2_skalierung_vergleich.png new file mode 100644 index 0000000..b4823a2 Binary files /dev/null and b/NSL/aufgaben/plots/u2_skalierung_vergleich.png differ diff --git a/NSL/aufgaben/src/uebung2_kprototypes_stocks.py b/NSL/aufgaben/src/uebung2_kprototypes_stocks.py index 8262c75..fc936ba 100644 --- a/NSL/aufgaben/src/uebung2_kprototypes_stocks.py +++ b/NSL/aufgaben/src/uebung2_kprototypes_stocks.py @@ -10,13 +10,9 @@ Warum k-Prototypes? Die Stock-Daten sind gemischt (numerisch + kategorial). - k-Means -> nur numerisch - k-Modes -> nur kategorial (Modus statt Mittel, Mismatch-Distanz) - k-Prototypes = k-Means + k-Modes -> gemischte Attribute - -CODEGERÜST: Die mit TODO markierten Abschnitte selbst ausfüllen. -Die Boilerplate (Imports, Pfade, Plot-Verzeichnis) ist bereits gesetzt. """ import matplotlib -matplotlib.use("Agg") # headless: Plots nur in Dateien from pathlib import Path @@ -24,40 +20,42 @@ import numpy as np import pandas as pd import matplotlib.pyplot as plt -# k-Prototypes ist NICHT in scikit-learn -> Paket 'kmodes' -# In devenv.nix zu venv.requirements hinzufügen: kmodes -try: - from kmodes.kprototypes import KPrototypes -except ModuleNotFoundError: - raise SystemExit("Paket 'kmodes' fehlt. In devenv.nix zu venv.requirements: kmodes") +from kmodes.kprototypes import KPrototypes +from sklearn.preprocessing import RobustScaler # ========================================================= # EINSTELLUNGEN / PFADE # ========================================================= -BASE = Path(__file__).resolve().parent.parent # .../aufgaben -DATA = BASE / "data" / "data_stocks.tsv" # TODO: Dateinamen anpassen +matplotlib.use("Agg") # headless: Plots nur in Dateien +BASE = Path(__file__).resolve().parent.parent # .../aufgaben +DATA = BASE / "data" / "stocks2.csv" PLOTS = BASE / "plots" PLOTS.mkdir(exist_ok=True) - RANDOM_SEED = 0 # ========================================================= # 1. DATEN LADEN & VERSTEHEN # ========================================================= -# TODO: Datensatz tab-separiert laden (pd.read_csv, sep="\t", header=0) -# df = ... -# -# Verschaffe dir einen Überblick, bevor du clusterst: -# df.head() -> erste Zeilen -# df.shape -> Grösse -# df.dtypes -> welche Spalten sind Zahl, welche object/Text? -# df.describe(include="all") -# -# Frage: Welche Spalten sind numerisch, welche kategorial? -# Frage: Gibt es eine ID-/Name-Spalte, die NICHT ins Clustering gehört? +# Daten aus dem Datensatz laden +df = pd.read_csv(DATA) + +# Überblich verschaffen, vor dem Clustering +print(df.head()) +print(df.shape) +print(df.dtypes) +print(df.nunique()) +print(df.describe(include="all")) + +# Frage: Welche Spalten sind numerisch, welche kategorial? +# - Numerisch: Dividend, PE, CAP +# - Kategorisch: Sector +# Frage: Gibt es eine ID-/Name-Spalte, die NICHT ins Clustering gehört? +# - ID: Symbol, Stock -> Reine Identifikation +# Entscheidung: +# - Wir verwenden also Dividend, PE, CAP und Sector fürs Clustering, der Rest fliegt raus! # ========================================================= # 2. FEATURES VORBEREITEN @@ -65,56 +63,134 @@ RANDOM_SEED = 0 # k-Prototypes braucht: # - ein gemischtes Array X (numerische Spalten als Zahl, kategoriale als String) # - die INDIZES (0-basiert, bezogen auf X) der kategorialen Spalten -# -# TODO: ID-/irrelevante Spalten entfernen -# TODO: X = df[].to_numpy() -# TODO: categorical_idx = [...] # Spaltenindizes der kategorialen Features in X -# -# Frage: Sollten die numerischen Features skaliert werden? Was macht sonst der -# gamma-Parameter (Gewicht numerisch vs. kategorial) implizit mit ungleichen Skalen? +# features auswählen, welche wir oben selektiert haben +feature_cols = ["Dividend", "PE", "Cap", "Sector"] +# und nach numpy laden +X = df[feature_cols].to_numpy() +# Sector ist die 4. Spalte in feature_cols -> Index 3 (0-basiert) +categorical_idx = [3] +# Ausgeben ob wir NaN im Datensatz haben, die wir erst bereinigen müssten +print(df[feature_cols].isna().sum()) +# Überblick über die Daten verschaffen +print(df[["Dividend", "PE", "Cap"]].describe()) + +# Das Describe oben liefert zutage: CAP hat Werte von 13.9 bis 592771 +# Und ist damit viel grösser als DIV und PE. Das bedeutet wir müssen CAP entsprechend +# skalieren, damit wir nicht nur auf die Marktkapitalisierung clustern. +# - Entscheidung: Deshalb: numerische Spalten skalieren, damit jede ungefähr gleich viel beiträgt. + +num_cols = ["Dividend", "PE", "Cap"] +cat_cols = ["Sector"] + +# RobustScaler -> gegen die PE/Cap-Ausreisser, spreizt die Hauptmasse besser +scaler = RobustScaler() +num_scaled = scaler.fit_transform(df[num_cols]) # -> numerisch, standardisiert + +# X zusammenbauen: skalierte numerische Spalten + kategoriale Spalte +X = np.column_stack([num_scaled, df[cat_cols].to_numpy()]) + +# Reihenfolge in X: [Dividend, PE, Cap, Sector] -> Sector ist Index 3 +categorical_idx = [3] + +# Ausgeben was der Scaler genau macht +print("=== VOR Skalierung ===") +print(df[num_cols].describe().loc[["mean", "std", "min", "max"]]) +print("\n=== NACH Skalierung ===") +num_scaled_df = pd.DataFrame(num_scaled, columns=num_cols) +print(num_scaled_df.describe().loc[["mean", "std", "min", "max"]]) + +# Plotten was der Scaler genau macht +fig, axes = plt.subplots(1, 2, figsize=(12, 5)) +df[num_cols].boxplot(ax=axes[0]) +axes[0].set_title("Vor Skalierung") +axes[0].set_yscale("log") # sonst erdrückt Cap alles +num_scaled_df.boxplot(ax=axes[1]) +axes[1].set_title("Nach StandardScaler") +fig.tight_layout() +fig.savefig(PLOTS / "u2_skalierung_vergleich.png", dpi=150, bbox_inches="tight") +plt.close(fig) # ========================================================= # 3. PASSENDES k FINDEN (Elbow über cost_) # ========================================================= # KPrototypes hat nach dem Fit das Attribut .cost_ (analog zur Inertia bei k-Means). -# -# TODO: für mehrere k fitten und cost_ sammeln, dann als Elbow plotten + speichern -# k_values = range(2, 8) -# costs = [] -# for k in k_values: -# kp = KPrototypes(n_clusters=k, init="Cao", random_state=RANDOM_SEED, n_init=5) -# kp.fit_predict(X, categorical=categorical_idx) -# costs.append(kp.cost_) -# ... plt-Plot nach PLOTS / "elbow_kprototypes.png" -# -# Frage: Ist ein Elbow erkennbar? Warum ist die k-Wahl bei gemischten Daten oft -# unschärfer als bei rein numerischen (Silhouette braucht ein Distanzmass – -# für gemischte Typen z.B. Gower – und ist daher nicht out-of-the-box nutzbar)? +# Elbow über cost_ +k_values = range(2, 10) +costs = [] + +GAMMA = 0.5 # fixes Gewicht kategorial vs. numerisch -> cost_ über k vergleichbar + +#for k in k_values: +# kp = KPrototypes( +# n_clusters=k, init="Cao", random_state=RANDOM_SEED, n_init=5, gamma=GAMMA +# ) +# kp.fit_predict(X, categorical=categorical_idx) +# costs.append(kp.cost_) +# print(f"k={k}: cost={kp.cost_:.1f}") + +#fig, ax = plt.subplots() +#ax.plot(list(k_values), costs, marker="o") +#ax.set_xlabel("Cluster Count k") +#ax.set_ylabel("cost_ (Unähnlichkeit)") +#ax.set_title("Elbow – k-Prototypes") +#ax.grid(True) +#fig.savefig(PLOTS / "u2_elbow_kprototypes.png", dpi=150, bbox_inches="tight") +#plt.close(fig) +#print("Plot gespeichert: plots/u2_elbow_kprototypes.png") + +# Ergebnis: Elbow: stärkster Steigungsabfall nach k=4 # ========================================================= # 4. FINALES CLUSTERING # ========================================================= -# TODO: bestes k wählen und finales Modell fitten -# best_k = ... -# kproto = KPrototypes(n_clusters=best_k, init="Cao", random_state=RANDOM_SEED, n_init=5) -# clusters = kproto.fit_predict(X, categorical=categorical_idx) -# -# kproto.cluster_centroids_ -> Cluster-Zentren -# numerische Spalten: Mittelwert | kategoriale Spalten: Modus (häufigster Wert) +best_k = 4 # Elbow: stärkster Steigungsabfall nach k=4 + +kproto = KPrototypes( + n_clusters=best_k, init="Cao", random_state=RANDOM_SEED, n_init=5, gamma=GAMMA +) +clusters = kproto.fit_predict(X, categorical=categorical_idx) + +print("Cluster-Grössen:", np.bincount(clusters)) +print("\nZentren (Dividend, PE, Cap skaliert | Sector):") +print(kproto.cluster_centroids_) # ========================================================= # 5. INTERPRETATION # ========================================================= -# TODO: Labels an df hängen und Cluster beschreiben -# df["cluster"] = clusters -# - numerisch: df.groupby("cluster").mean(numeric_only=True) -# - kategorial: häufigste Kategorie je Cluster (z.B. mode() oder value_counts()) -# -# Frage: Kannst du den Clustern eine semantische Bedeutung geben? -# (Skript-Beispiel: "Männer 30–40, Fussball") -# -# TODO (optional): zwei numerische Features streuen, nach Cluster einfärben, -# nach PLOTS / "cluster_scatter.png" speichern + +df["cluster"] = clusters + +# 5a) Numerische Zentren zurück in Originaleinheiten +centroids = kproto.cluster_centroids_ +num_centers_scaled = centroids[:, :len(num_cols)].astype(float) # erste 3 Spalten +num_centers = scaler.inverse_transform(num_centers_scaled) # zurückrechnen +sector_modes = centroids[:, len(num_cols)] # 4. Spalte = Sector-Modus + +profile = pd.DataFrame(num_centers, columns=num_cols) +profile["Sector (Modus)"] = sector_modes +profile["Grösse"] = np.bincount(clusters) +print("=== Cluster-Profile (Originaleinheiten) ===") +print(profile.round(2)) + +# 5b) Plausibilitätscheck: direkt aus den Daten je Cluster mitteln +print("\n=== Mittelwerte je Cluster (direkt aus df) ===") +print(df.groupby("cluster")[num_cols].mean().round(2)) + +# 5c) Cluster visualisieren (Cap vs PE, log-log) logarithmisch, da viele Ausreisser +fig, ax = plt.subplots(figsize=(8, 6)) +ax.scatter(df["Cap"], df["PE"], c=df["cluster"], cmap="tab10", s=12, alpha=0.6) +ax.scatter(num_centers[:, num_cols.index("Cap")], + num_centers[:, num_cols.index("PE")], + c="k", marker="X", s=200, label="Zentren") +ax.set_xscale("log") +ax.set_yscale("log") +ax.set_xlabel("Cap [log]") +ax.set_ylabel("PE [log]") +ax.set_title(f"k-Prototypes Cluster (k={best_k})") +ax.legend() +fig.savefig(PLOTS / "u2_cluster_scatter.png", dpi=150, bbox_inches="tight") +plt.close(fig) +print("Plot gespeichert: plots/u2_cluster_scatter.png")