#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ train_scene_score — gelernter Szenen-Score vs Max-Confidence-Baseline (Phase A §8h-Folge). §8h hat gezeigt: der Limiter des Gates ist die Score-Aggregation, nicht Auflösung/ Vokabular. Dieser Trainer prüft, ob ein GELERNTER Score (logistische Regression über die Output-Features aus ml/scene_gate/features.py) die Pareto-Front gegenüber der heutigen `waste_likelihood = max(Müll-Confidence)` bewegt. Bewusst dependency-leicht (nur numpy): eine lineare Logistik ist der richtige erste Test — interpretierbar, und wenn sie die Baseline nicht schlägt, ist der Zero-Shot-Score nachweislich gedeckelt (→ Feld-Daten + echter Kopf statt Serving). Eingabe: features_scene_gate.jsonl (eine Zeile je Bild: {has_waste, }) aus dem Kaggle-Kernel (collect_features=True). Bewertung fair aus-of-fold (CV), damit der Score nicht auf denselben Bildern gelernt und gemessen wird. Nutzung: python ml/scripts/train_scene_score.py --features features_scene_gate.jsonl \ --out scene_score_report.json """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Dict, List, Sequence, Tuple import numpy as np REPO = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO)) from ml.scene_gate.features import FEATURE_NAMES # noqa: E402 # ---------- reine, testbare Metrik ---------- def pareto_tp_at_fpkill(scores: Sequence[float], y: Sequence[int], targets: Sequence[float]) -> Dict[float, float]: """Für jedes FP-Kill-Ziel die höchste erreichbare TP-Retention. Bei Schwelle t (behalte score >= t): TP-Retention = Anteil Positiver mit score>=t; FP-Kill = Anteil Negativer mit score= t).mean()) if n_pos else 0.0 fk = float((neg < t).mean()) if n_neg else 0.0 if fk >= tgt and (best is None or tp > best): best = tp out[float(tgt)] = best return out def auc(scores: Sequence[float], y: Sequence[int]) -> float: """ROC-AUC über die Rang-Statistik (Mann-Whitney-U). 0.5 = Zufall.""" s = np.asarray(scores, dtype=float) yv = np.asarray(y, dtype=int) n_pos = int((yv == 1).sum()) n_neg = int((yv == 0).sum()) if n_pos == 0 or n_neg == 0: return 0.5 order = np.argsort(s, kind="mergesort") ranks = np.empty(len(s), dtype=float) ranks[order] = np.arange(1, len(s) + 1) # Ties: Durchschnittsrang _, inv, counts = np.unique(s, return_inverse=True, return_counts=True) cum = np.cumsum(counts) avg = {} start = 0 for i, c in enumerate(counts): avg[i] = (start + 1 + start + c) / 2.0 start += c ranks = np.array([avg[i] for i in inv]) sum_pos = ranks[yv == 1].sum() return float((sum_pos - n_pos * (n_pos + 1) / 2.0) / (n_pos * n_neg)) # ---------- logistische Regression (numpy) ---------- def _standardize(X: np.ndarray, mean: np.ndarray, std: np.ndarray) -> np.ndarray: return (X - mean) / std def fit_logistic(X: np.ndarray, y: np.ndarray, epochs: int = 500, lr: float = 0.1, l2: float = 1.0) -> np.ndarray: """Batch-Gradientenabstieg, mit Bias-Spalte. Gibt Gewichte (inkl. Bias).""" n, d = X.shape Xb = np.hstack([np.ones((n, 1)), X]) w = np.zeros(d + 1) for _ in range(epochs): z = Xb @ w p = 1.0 / (1.0 + np.exp(-np.clip(z, -30, 30))) grad = Xb.T @ (p - y) / n grad[1:] += (l2 / n) * w[1:] # L2, Bias ausgenommen w -= lr * grad return w def predict_logistic(X: np.ndarray, w: np.ndarray) -> np.ndarray: Xb = np.hstack([np.ones((X.shape[0], 1)), X]) return 1.0 / (1.0 + np.exp(-np.clip(Xb @ w, -30, 30))) def stratified_folds(y: np.ndarray, k: int = 5, seed: int = 0) -> List[np.ndarray]: """k Index-Folds, Klassenanteile je Fold erhalten (deterministisch).""" rng = np.random.RandomState(seed) folds: List[List[int]] = [[] for _ in range(k)] for cls in (0, 1): idx = np.where(y == cls)[0] rng.shuffle(idx) for j, i in enumerate(idx): folds[j % k].append(int(i)) return [np.array(sorted(f)) for f in folds] def cv_oof_scores(X: np.ndarray, y: np.ndarray, k: int = 5, seed: int = 0, **fit_kw) -> np.ndarray: """Out-of-fold-Wahrscheinlichkeiten — fair (nie auf demselben Bild gelernt+gemessen).""" oof = np.zeros(len(y)) for f in stratified_folds(y, k, seed): mask = np.zeros(len(y), dtype=bool) mask[f] = True Xtr, ytr = X[~mask], y[~mask] mean = Xtr.mean(axis=0) std = Xtr.std(axis=0) std[std == 0] = 1.0 w = fit_logistic(_standardize(Xtr, mean, std), ytr, **fit_kw) oof[mask] = predict_logistic(_standardize(X[mask], mean, std), w) return oof # ---------- IO ---------- def load_features(path: str) -> Tuple[np.ndarray, np.ndarray]: X_rows, y_rows = [], [] for line in Path(path).read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue r = json.loads(line) X_rows.append([float(r.get(k, 0.0)) for k in FEATURE_NAMES]) y_rows.append(1 if r.get("has_waste") else 0) return np.array(X_rows, dtype=float), np.array(y_rows, dtype=int) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--features", required=True) ap.add_argument("--out", default="scene_score_report.json") ap.add_argument("--folds", type=int, default=5) ap.add_argument("--save-model", default=None, help="Pfad für das trainierte Modell-Artefakt (JSON) zum Serving.") ap.add_argument("--backbone", default="unknown", help="Backbone, aus dessen Features trainiert wurde. WICHTIG: das " "Serving MUSS dasselbe Backbone fahren — Features sind nicht übertragbar.") args = ap.parse_args() X, y = load_features(args.features) targets = [0.60, 0.70, 0.80, 0.90, 0.95] # Baseline = die heutige waste_likelihood (ov_waste_max-Spalte). base_col = FEATURE_NAMES.index("ov_waste_max") base_scores = X[:, base_col] learned = cv_oof_scores(X, y, k=args.folds) base_par = pareto_tp_at_fpkill(base_scores, y, targets) learn_par = pareto_tp_at_fpkill(learned, y, targets) # Gewichte auf allen Daten (nur zur Interpretation der Feature-Wichtigkeit). mean = X.mean(axis=0); std = X.std(axis=0); std[std == 0] = 1.0 w_full = fit_logistic(_standardize(X, mean, std), y) importance = sorted( ({"feature": FEATURE_NAMES[i], "weight": round(float(w_full[i + 1]), 4)} for i in range(len(FEATURE_NAMES))), key=lambda d: -abs(d["weight"]), ) report = { "n": int(len(y)), "n_pos": int(y.sum()), "n_neg": int((y == 0).sum()), "auc_baseline": round(auc(base_scores, y), 4), "auc_learned_oof": round(auc(learned, y), 4), "pareto": { str(t): {"baseline": base_par[t], "learned": learn_par[t], "delta": (round(learn_par[t] - base_par[t], 4) if base_par[t] is not None and learn_par[t] is not None else None)} for t in targets }, "feature_importance": importance, } Path(args.out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") if args.save_model: # Serving-Artefakt: standardisiertes lineares Logit. score = sigmoid( # bias + Σ w_i * (x_i - mean_i)/std_i). Reproduzierbar ohne sklearn. model = { "kind": "logistic_standardized", "feature_names": list(FEATURE_NAMES), "mean": [round(float(v), 6) for v in mean], "std": [round(float(v), 6) for v in std], "bias": round(float(w_full[0]), 6), "weights": [round(float(v), 6) for v in w_full[1:]], "backbone": args.backbone, "trained_on": {"n": report["n"], "n_pos": report["n_pos"], "n_neg": report["n_neg"]}, "auc_oof": report["auc_learned_oof"], "note": ("Trainiert auf TACO-Val (Obergrenze-Indikation); auf Feld-Daten neu " "trainieren. Features sind BACKBONE-SPEZIFISCH — das Serving muss " f"'{args.backbone}' fahren, sonst passen die Verteilungen nicht."), } Path(args.save_model).write_text(json.dumps(model, ensure_ascii=False, indent=2), encoding="utf-8") print(f"-> Modell: {args.save_model}") print(f"n={report['n']} (pos {report['n_pos']} / neg {report['n_neg']})") print(f"AUC Baseline={report['auc_baseline']} Gelernt(OOF)={report['auc_learned_oof']}") print(f"{'FP-Kill≥':>9} | {'Baseline':>9} | {'Gelernt':>9} | Δ") for t in targets: p = report["pareto"][str(t)] b = f"{p['baseline']:.3f}" if p["baseline"] is not None else " — " l = f"{p['learned']:.3f}" if p["learned"] is not None else " — " d = f"{p['delta']:+.3f}" if p["delta"] is not None else "" print(f"{t:>9.2f} | {b:>9} | {l:>9} | {d}") print("Top-Features:", ", ".join(f"{d['feature']}({d['weight']:+.2f})" for d in importance[:5])) print(f"-> {args.out}") if __name__ == "__main__": main()