feature: add workshop 4 solution
This commit is contained in:
@@ -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`
|
||||
(17–98) 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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user