eedf8d109a
presentation_auto_apply_sweep fires banner/editor-screenshot heads at the FLAT presentation threshold (source=presentation_auto). Two guards: (1) hard-skip any image already carrying a human/confirmed content tag — you valued it, so the model can't bury it; (2) if an auto-hide ALSO scores >= presentation_conflict_threshold on a content head, hide it but record a PresentationReview row (conflict tag + score) for the Hidden view. _auto_apply_heads now excludes system tags, so a graduated wip/banner can't fire via the content path (and wip never auto-applies at all). presentation_auto added to _AUTO_SOURCES so auto-hidden chrome never self-trains. Tests: applies, hard-skip valued, conflict-flag, disabled no-op, ignores wip, content-path excludes system. Settings UI + scheduling land next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
170 lines
6.3 KiB
Python
170 lines
6.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,
|
|
TagPositiveConfirmation,
|
|
TagSuggestionRejection,
|
|
)
|
|
from ...models.tag import image_tag
|
|
|
|
# Auto-apply sources whose tags are PROVISIONAL: they never train a head (or seed
|
|
# a CCIP reference) unless the operator confirms them (milestone 139). Keeping
|
|
# auto-applied predictions out of training is what makes them "soft" — a misfire
|
|
# can't reinforce itself, so the retraction sweep can actually drop it.
|
|
_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto", "presentation_auto")
|
|
|
|
|
|
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]:
|
|
"""Image ids that count as POSITIVES for this tag's head: human-applied
|
|
(manual / accepted) tags PLUS any auto-applied tag the operator explicitly
|
|
confirmed (TagPositiveConfirmation). Unconfirmed auto-applied tags are
|
|
EXCLUDED — they are provisional and must not train the head that judges
|
|
them (milestone 139)."""
|
|
confirmed = (
|
|
select(TagPositiveConfirmation.image_record_id)
|
|
.where(TagPositiveConfirmation.tag_id == tag_id)
|
|
)
|
|
return [
|
|
r[0] for r in session.execute(
|
|
select(image_tag.c.image_record_id)
|
|
.where(image_tag.c.tag_id == tag_id)
|
|
.where(
|
|
image_tag.c.source.not_in(_AUTO_SOURCES)
|
|
| image_tag.c.image_record_id.in_(confirmed)
|
|
)
|
|
).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),
|
|
}
|