feature: add workshop 4 solution

This commit is contained in:
2026-05-28 21:11:16 +02:00
parent 5e4151d261
commit 0ed9ecdb7d
6 changed files with 10247 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
# Workshop 4 — kNN Hyperparametersuche
Hyperparameter-Tuning für `KNeighborsClassifier` auf `bank_data_prep.csv`
(Klassifikation: Hat der Kunde abgeschlossen, ja/nein?), inklusive Vergleich
mit/ohne Standardisierung.
- **Input:** `data/bank_data_prep.csv` (Output aus Workshop 3 / Bank-Implementation)
- **Pipeline:** `src/hyperparametersearch.py`
## Aufgabenstellung
Drei Teile gemäss Folie:
1. Features von Trainings- und Testdaten mit `StandardScaler` standardisieren.
2. Beste Parameter für `KNeighborsClassifier` finden:
- `n_neighbors` ∈ {1..10}
- `p` ∈ {1, 2, 3}
3. Ergebnisse mit vs. ohne Standardisieren vergleichen.
## Konzepte kurz erklärt
### Was ist `p`? (Minkowski-Distanz)
`p` steuert die Distanzmetrik, mit der kNN „Nachbarschaft" misst:
| `p` | Distanz | Formel | Verhalten |
|----:|---------|--------|-----------|
| 1 | Manhattan | Σ \|x_i y_i\| | Strassen-Netz-Distanz, robuster gegen Ausreisser in Einzeldimensionen |
| 2 | Euklidisch | √Σ(x_i y_i)² | „Luftlinie", sklearn-Default |
| 3+ | Höhere Minkowski | (Σ\|x_i y_i\|^p)^(1/p) | Gewichtet grosse Einzelunterschiede zunehmend stärker |
Bei diesem Datensatz gewinnt `p=1` (Manhattan) konsistent über alle Varianten —
plausibel, weil viele Dummy-Features (0/1) drin sind und Manhattan diese
gleichmässiger gewichtet als Euklidisch.
### Warum Standardisierung bei kNN nicht optional ist
kNN ist **distanzbasiert**. Ohne Skalierung dominiert die Variable mit dem
grössten Wertebereich die Distanzberechnung — Dummy-Features (0/1) werden
faktisch ignoriert, weil ihr Beitrag zur Distanz verschwindet gegen z.B. `age`
(1798) oder `duration` (Sekunden). `StandardScaler` zentriert jede Spalte auf
Mittelwert 0 / Standardabweichung 1 und stellt damit alle Features gleichwertig.
### Leakage vermeiden: `fit` nur auf Train
```python
scaler.fit(X_train) # mean/std NUR aus Train lernen
X_train = scaler.transform(X_train)
X_test = scaler.transform(X_test) # mit Train-Statistiken transformieren
```
Würde der Scaler auf `X_test` (oder dem gesamten Datensatz) gefittet, flössen
Test-Statistiken in die Vorverarbeitung ein → Leakage. Das Test-Set soll so
behandelt werden, als sähe man es zum ersten Mal — denn so wird es in
Produktion (neue Daten) auch sein.
### Hyperparametersuche: manuell vs. GridSearchCV
Folien-Methode: Doppelschleife über `n_neighbors × p`, Score auf Test-Set
genommen, beste Kombi gewählt. Funktioniert, hat aber zwei Schwächen:
1. **Optimistic Bias:** Tunt gegen das Test-Set. Die gewählte Kombi ist die,
die zufällig auf *genau diesem einen* Split am besten lief.
2. **Einziger Split:** Keine Robustheit gegen ungünstige Train/Test-Aufteilungen.
`GridSearchCV` löst beides: Cross-Validation auf den Trainingsdaten (k-Fold,
hier 5), Test-Set wird **erst am Ende einmal** angefasst.
## Drei Varianten
| Variante | Skalierung | Hyperparameter-Suche | Bestes k, p | Accuracy |
|----------|:----------:|----------------------|:-----------:|:--------:|
| A | nein | manuell (Doppelschleife) | k=9, p=1 | 76,6 % (Test) |
| B | ja | manuell (Doppelschleife) | k=9, p=1 | 80,4 % (Test) |
| C | ja | GridSearchCV (5-fold) | k=9, p=1 | 81,1 % (CV) / 80,4 % (Test) |
**Lesart:**
- A → B: +3,7 pp durch Skalierung. Belegt Aufgabenteil 3.
- B → C: Accuracy bleibt gleich, aber der **CV-Score** ist der ehrliche
Schätzer für die Generalisierung. Der Test-Score von B war leicht
optimistisch verzerrt (gegen Test getunt); C bestätigt ihn unabhängig.
## Klassifikationsqualität (Variante C)
```
[[1461 274]
[ 371 1181]]
precision recall f1-score support
no 0.80 0.84 0.82 1735
yes 0.81 0.76 0.79 1552
accuracy 0.80 3287
```
Beide Klassen werden ausgewogen vorhergesagt (F1 ≈ 0,80 für beide). Das Modell
ist leicht konservativer mit `yes`-Vorhersagen (Recall 0,76 vs. 0,84 für `no`).
## Befund zum Datensatz
`bank_data_prep.csv` ist mit 53/47 (`no`/`yes`) annähernd balanciert. Das
Original-Bank-Set hat eine ~88/12-Verteilung — der gelieferte Datensatz wurde
also vorab **resampled** (vermutlich SMOTE oder Random Over/Undersampling via
`imbalanced-learn`). Konsequenz: Accuracy ist hier eine vertretbare Metrik;
wäre der Datensatz im Original-Verhältnis, wären 80 % Accuracy *schlechter*
als die triviale „immer no"-Baseline (88 %), und man müsste mit Precision/
Recall/F1 arbeiten.
## Projektstruktur
```
workshop4
├── data/
│ └── bank_data_prep.csv
├── src/
│ └── hyperparametersearch.py
├── devenv.nix
└── README.md
```
## Ausführen
```sh
python src/hyperparametersearch.py
```
## Implementierungs-Notizen
Bewusste Entscheidungen, die über die Folien-Vorlage hinausgehen:
- **`fit` nur auf `X_train`** beim `StandardScaler` (Folie macht es so, viele
Anleitungen nicht — daher hier explizit dokumentiert).
- **`GridSearchCV` zusätzlich zur Folien-Doppelschleife**, um Optimistic Bias
sichtbar zu machen und einen leakage-freien CV-Score zu erhalten.
- **`classification_report` + Confusion Matrix** über die Folien-Anforderungen
hinaus, weil reine Accuracy bei Klassifikation nie das ganze Bild zeigt.
## Offene Punkte für ein echtes Projekt
- **`Pipeline` für Scaler + kNN.** Ein `Pipeline([('scaler', ...), ('knn', ...)])`
macht aus den zwei Schritten ein Objekt — `predict()` skaliert dann
automatisch mit dem trainierten Scaler. Verhindert strukturell die
„Modell trainiert auf skaliert, Vorhersage auf unskaliert"-Klasse von Bugs.
Auch GridSearchCV erhält dann die Pipeline statt nur den Classifier, was die
Skalierung in *jedem* CV-Fold korrekt nur auf dem Fold-Train fittet.
- **Resampling-Schritt im Datenpfad sichtbar machen.** Der Resampling-Eingriff
passierte ausserhalb dieser Pipeline; in einem echten Projekt gehört er
dokumentiert oder selbst (re-)produziert, sonst sind Ergebnisse nicht
reproduzierbar.
- **Mehr Metriken bei der CV.** `GridSearchCV(scoring=...)` kann auch auf F1,
ROC-AUC u.a. optimieren — sinnvoller als Accuracy, sobald Klassen
unbalanciert sind.
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1779303056,
"narHash": "sha256-+DJSNTtrdUb5yelcKp8fa5aITlg050701WCOJt0oMtI=",
"owner": "cachix",
"repo": "devenv",
"rev": "0d0be23517b92cbcedd95a0dbb6f735deae9b38c",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"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"
},
"locked": {
"lastModified": 1778507786,
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-python": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1779117433,
"narHash": "sha256-iKhNJH1ABTrPvDF6Sd1U+GCVYSh8Xn88ee10ko7PvvE=",
"owner": "cachix",
"repo": "nixpkgs-python",
"rev": "a0f88fb785debcb0a201d0ce311a2e3d829e4a1b",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "nixpkgs-python",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1778274207,
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs",
"nixpkgs-python": "nixpkgs-python"
}
}
},
"root": "root",
"version": 7
}
+29
View File
@@ -0,0 +1,29 @@
{ pkgs, lib, config, ... }:
{
languages.python = {
enable = true;
version = "3.12";
venv.enable = true;
venv.requirements = ''
imbalanced-learn
ipython
jupyter
jupyterlab
matplotlib
numpy
pandas
scikit-learn
seaborn
setuptools<81
statsmodels
ydata-profiling
'';
};
packages = [
pkgs.graphviz
pkgs.zsh
pkgs.zlib
];
}
+6
View File
@@ -0,0 +1,6 @@
inputs:
nixpkgs-python:
url: github:cachix/nixpkgs-python
inputs:
nixpkgs:
follows: nixpkgs
@@ -0,0 +1,97 @@
"""Workshop 4: kNN Hyperparametersuche auf bank_data_prep.csv.
Standardisiert die Features, sucht die besten Werte für n_neighbors und p
(Minkowski-Distanz), und vergleicht die Accuracy mit vs. ohne Standardisieren.
"""
import numpy as np
import pandas as pd
from sklearn.metrics import confusion_matrix, classification_report
from sklearn.model_selection import GridSearchCV
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler
RAW = "data/bank_data_prep.csv"
SEED = 1234
def load_split(path: str = RAW):
"""Schritt 1-3 der Folien: laden, X/y-Split, train/test-Split."""
df = pd.read_csv(path)
X = df.drop("y", axis=1)
y = df["y"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, train_size=2 / 3, random_state=SEED
)
return X_train, X_test, y_train, y_test
def scale(X_train, X_test):
"""Standardisieren: Scaler nur auf Train fitten, auf beide anwenden."""
scaler = StandardScaler().set_output(transform="pandas")
scaler.fit(X_train)
return scaler.transform(X_train), scaler.transform(X_test)
def search_manual(X_train, X_test, y_train, y_test):
"""Folien-Methode: Grid ueber n_neighbors x p, Score auf Test."""
results = []
for k in range(1, 11):
for p in (1, 2, 3):
model = KNeighborsClassifier(n_neighbors=k, p=p)
model.fit(X_train, y_train)
acc = model.score(X_test, y_test)
results.append((k, p, acc))
best = max(results, key=lambda r: r[2])
print(f"Bestes Ergebnis: k={best[0]}, p={best[1]}, acc={best[2]:.4f}")
return results, best
def search_grid(X_train, y_train):
"""Hyperparametersuche per GridSearchCV (CV auf Train, kein Test-Leakage)."""
param_grid = {
"n_neighbors": range(1, 11),
"p": (1, 2, 3),
}
grid = GridSearchCV(
KNeighborsClassifier(),
param_grid,
cv=5, # 5-fache Cross-Validation
scoring="accuracy",
)
grid.fit(X_train, y_train) # NUR Train — Test wird nicht angefasst
print(f"Beste Params: {grid.best_params_}, CV-Score: {grid.best_score_:.4f}")
return grid
if __name__ == "__main__":
X_train, X_test, y_train, y_test = load_split()
# --- Variante A: OHNE Standardisieren ---
print("=== Ohne Standardisieren ===")
results_raw, best_raw = search_manual(X_train, X_test, y_train, y_test)
# --- Variante B: MIT Standardisieren ---
print("=== Mit Standardisieren ===")
X_train_sc, X_test_sc = scale(X_train, X_test) # Features skalieren
results_sc, best_sc = search_manual(X_train_sc, X_test_sc, y_train, y_test)
# --- Variante C: GridScearch
grid = search_grid(X_train_sc, y_train) # skalierte Train-Daten rein
final_acc = grid.score(X_test_sc, y_test) # skalierte Test-Daten messen
k_grid = grid.best_params_['n_neighbors']
p_grid = grid.best_params_['p']
cv_grid = grid.best_score_
# --- Vergleich ---
print(f"\nOhne Skalierung: k={best_raw[0]}, p={best_raw[1]}, acc={best_raw[2]:.4f}")
print(f"Mit Skalierung: k={best_sc[0]}, p={best_sc[1]}, acc={best_sc[2]:.4f}")
print(f"Mit GridSearch: k={k_grid}, p={p_grid}, CV-acc={cv_grid:.4f}, test-acc={final_acc:.4f}")
y_pred = grid.predict(X_test_sc) # weiterhin skaliert
print(confusion_matrix(y_test, y_pred))
print(classification_report(y_test, y_pred))