alami-vision-api / ml /scripts /train_scene_score.py
alami-ci
Deploy from alami-eco/alami-trash-ai@aee69796b70947e95efdb9c7483fa52f8d3b4520
76838d6
Raw
History Blame Contribute Delete
9.72 kB
#!/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, <FEATURE_NAMES>})
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. Gibt {target: max_tp|None}."""
s = np.asarray(scores, dtype=float)
yv = np.asarray(y, dtype=int)
pos = s[yv == 1]
neg = s[yv == 0]
n_pos = len(pos)
n_neg = len(neg)
thresholds = np.unique(np.concatenate([s, [s.min() - 1, s.max() + 1]]))
out: Dict[float, float] = {}
for tgt in targets:
best = None
for t in thresholds:
tp = float((pos >= 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()