e6f128c894
Step 2 of milestone #128. _hygiene_excluded_ids (training_data.py) is the one shared predicate: images carrying any system tag are dropped from every OTHER concepts head training — not positives (a rough wip tagged as a character drags the head toward generic-sketch) and not rejection or sampled negatives (a wip OF character X is not evidence against X). A system tags own head trains on them unfiltered; that is what makes auto-flagging banners work. Selection is split out of train_head as the sklearn-free head_training_ids so CI (no sklearn) can pin the behavior. CCIP: reference prototypes skip hygiene-tagged images — a faceless wip figure region must never become an identity reference — and the ref cache signature now counts hygiene applications, since tagging an image wip changes the reference set without touching character/region counts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
145 lines
5.3 KiB
Python
145 lines
5.3 KiB
Python
"""Shared data-selection + validated-metric helpers for the heads trainer.
|
|
|
|
Born in the head-vs-centroid eval harness (#1130, tag_eval.py) that proved the
|
|
"frozen embedding + small trained head (with negatives)" spine; the harness was
|
|
retired 2026-07-02 (operator: the tagging system is proven, the eval isn't
|
|
needed) and these survivors moved here — they ARE the heads' production data
|
|
pipeline (heads.py trains and scores with them nightly).
|
|
|
|
numpy/scikit-learn are imported lazily inside the functions that need them so
|
|
the API worker (base image, no ML stack) can import this module.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ...models import ImageRecord, Tag, TagSuggestionRejection
|
|
from ...models.tag import image_tag
|
|
|
|
|
|
def _hygiene_excluded_ids(session: Session) -> set[int]:
|
|
"""Ids of images carrying ANY system tag (wip / banner / editor
|
|
screenshot — milestone #128). These images are excluded from OTHER
|
|
concepts' head training entirely: not positives (a rough wip tagged as a
|
|
character drags that head toward 'generic sketch') and not sampled or
|
|
rejection negatives (a wip OF character X is not evidence against X) —
|
|
simply absent. A system tag's OWN head trains on them unchanged; that is
|
|
what makes auto-flagging banners/editor screenshots work.
|
|
|
|
Item-level by design: a wip-tagged process video contributes (or
|
|
withholds) ALL its sampled frames, though some may show the finished
|
|
piece. Operator call 2026-07-03: with enough clean data this washes out —
|
|
no per-frame handling.
|
|
"""
|
|
return set(
|
|
session.execute(
|
|
select(image_tag.c.image_record_id)
|
|
.join(Tag, Tag.id == image_tag.c.tag_id)
|
|
.where(Tag.is_system.is_(True))
|
|
).scalars().all()
|
|
)
|
|
|
|
|
|
def _ids_with_tag(session: Session, tag_id: int) -> list[int]:
|
|
return [
|
|
r[0] for r in session.execute(
|
|
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tag_id)
|
|
).all()
|
|
]
|
|
|
|
|
|
def _rejected_ids(session: Session, tag_id: int) -> list[int]:
|
|
return [
|
|
r[0] for r in session.execute(
|
|
select(TagSuggestionRejection.image_record_id)
|
|
.where(TagSuggestionRejection.tag_id == tag_id)
|
|
).all()
|
|
]
|
|
|
|
|
|
def _sample_unlabeled(session: Session, exclude: set[int], limit: int) -> list[int]:
|
|
"""Random image ids (with an embedding) NOT carrying the tag. Concepts are
|
|
sparse, so an untagged image is almost always a true negative."""
|
|
stmt = (
|
|
select(ImageRecord.id)
|
|
.where(ImageRecord.siglip_embedding.is_not(None))
|
|
.order_by(func.random())
|
|
.limit(limit)
|
|
)
|
|
if exclude:
|
|
stmt = stmt.where(ImageRecord.id.not_in(exclude))
|
|
return [r[0] for r in session.execute(stmt).all()]
|
|
|
|
|
|
def _load_embeddings(session: Session, ids: list[int]) -> dict[int, Any]:
|
|
import numpy as np
|
|
|
|
out: dict[int, Any] = {}
|
|
if not ids:
|
|
return out
|
|
# Chunk the IN list to stay well under psycopg's parameter ceiling.
|
|
for i in range(0, len(ids), 2000):
|
|
chunk = ids[i:i + 2000]
|
|
for rid, emb in session.execute(
|
|
select(ImageRecord.id, ImageRecord.siglip_embedding)
|
|
.where(ImageRecord.id.in_(chunk))
|
|
.where(ImageRecord.siglip_embedding.is_not(None))
|
|
).all():
|
|
out[rid] = np.asarray(emb, dtype=np.float32)
|
|
return out
|
|
|
|
|
|
def _l2norm(X, np):
|
|
n = np.linalg.norm(X, axis=1, keepdims=True)
|
|
n[n == 0] = 1.0
|
|
return X / n
|
|
|
|
|
|
def _metrics_from_scores(y, scores, np) -> dict[str, float]:
|
|
from sklearn.metrics import average_precision_score, precision_recall_curve
|
|
|
|
ap = float(average_precision_score(y, scores))
|
|
prec, rec, thr = precision_recall_curve(y, scores)
|
|
f1 = (2 * prec * rec) / np.clip(prec + rec, 1e-9, None)
|
|
best = int(np.argmax(f1))
|
|
# thr has len = len(prec)-1; map best index safely.
|
|
t = float(thr[min(best, len(thr) - 1)]) if len(thr) else 0.5
|
|
return {
|
|
"ap": round(ap, 4),
|
|
"precision": round(float(prec[best]), 4),
|
|
"recall": round(float(rec[best]), 4),
|
|
"f1": round(float(f1[best]), 4),
|
|
"threshold": round(t, 4),
|
|
}
|
|
|
|
|
|
def _safe_folds(y, folds, np) -> int:
|
|
minority = int(min(np.bincount(y)))
|
|
return max(2, min(folds, minority))
|
|
|
|
|
|
def _auto_apply_point(y, scores, target, np) -> dict | None:
|
|
"""The auto-apply operating point: the threshold that yields the MOST recall
|
|
while holding precision >= target. This answers 'could this concept fire
|
|
without a human, and how much would it catch?' Returns None if no threshold
|
|
reaches the precision target (concept not auto-apply-ready)."""
|
|
from sklearn.metrics import precision_recall_curve
|
|
|
|
prec, rec, thr = precision_recall_curve(y, scores)
|
|
best = None # (threshold, precision, recall) maximizing recall s.t. prec>=target
|
|
for i in range(len(thr)): # thr[i] corresponds to prec[i], rec[i]
|
|
if prec[i] >= target and (best is None or rec[i] > best[2]):
|
|
best = (float(thr[i]), float(prec[i]), float(rec[i]))
|
|
if best is None:
|
|
return None
|
|
return {
|
|
"target": round(float(target), 4),
|
|
"threshold": round(best[0], 4),
|
|
"precision": round(best[1], 4),
|
|
"recall": round(best[2], 4),
|
|
}
|