feat(heads): incremental retraining — refit only changed tags (#1317 phase 2, m138)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m35s

train_all_heads is now incremental by default: a per-tag training-data
fingerprint (positive + rejection count/latest-timestamp, stored on
tag_head.train_fingerprint) means a manual Retrain refits ONLY the tags whose
data changed — O(what you touched), not O(all heads). The nightly
scheduled_train_heads passes full=True to reconcile sampled-negative + hygiene
drift across every head. First incremental run after deploy still refits
everyone (NULL fingerprints), stamping them, then it's incremental.

The refit decision + fingerprint are split into sklearn-free helpers
(_head_fingerprints, _heads_needing_retrain) so the incremental logic is
unit-tested directly (train_head itself needs scikit-learn). Migration 0080.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-06 16:36:30 -04:00
parent a94f6a2789
commit 2cfbb284d5
5 changed files with 273 additions and 7 deletions
+7
View File
@@ -73,5 +73,12 @@ class TagHead(Base):
trained_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Training-data fingerprint (positives + rejections) at last fit — the
# incremental-retrain change detector (#1317 p2). A manual Retrain refits only
# heads whose fingerprint moved; the nightly run ignores it (full reconcile).
# NULL forces a refit (pre-fingerprint heads).
train_fingerprint: Mapped[str | None] = mapped_column(
String(128), nullable=True
)
# Extra detail (auto-apply operating point, F1, etc.) — non-load-bearing.
metrics: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
+95 -6
View File
@@ -150,24 +150,103 @@ def _eligible_tag_ids(session: Session, min_pos: int) -> list[int]:
return [r[0] for r in rows]
def _head_fingerprints(session: Session, tag_ids: list[int]) -> dict[int, str]:
"""Per-tag training-data fingerprint: (positive count, latest positive
created_at) + (rejection count, latest rejected_at). It moves whenever a tag
gains/loses a positive or a rejection — the incremental-retrain change
detector (#1317 p2). A newly-added positive/rejection always has the latest
timestamp, so even a remove-one-add-one (unchanged count) is caught. The
sampled-unlabeled negative pool + the hygiene set drift GLOBALLY and are
reconciled by the nightly full run, not captured here."""
if not tag_ids:
return {}
pos = session.execute(
select(
image_tag.c.tag_id,
func.count(image_tag.c.image_record_id),
func.max(image_tag.c.created_at),
)
.where(image_tag.c.tag_id.in_(tag_ids))
.group_by(image_tag.c.tag_id)
).all()
pos_map = {t: (c, m) for t, c, m in pos}
rej = session.execute(
select(
TagSuggestionRejection.tag_id,
func.count(),
func.max(TagSuggestionRejection.rejected_at),
)
.where(TagSuggestionRejection.tag_id.in_(tag_ids))
.group_by(TagSuggestionRejection.tag_id)
).all()
rej_map = {t: (c, m) for t, c, m in rej}
out = {}
for t in tag_ids:
pc, pm = pos_map.get(t, (0, None))
rc, rm = rej_map.get(t, (0, None))
out[t] = f"{pc}:{pm}:{rc}:{rm}"
return out
def _heads_needing_retrain(
session: Session, eligible: list[int], embedding_version: str,
fps: dict[int, str], full: bool,
) -> list[int]:
"""The eligible tag_ids to (re)fit: no head yet, a head trained in a DIFFERENT
embedding space (a model swap), or a changed training-data fingerprint.
full=True forces every eligible tag. sklearn-free (train_head itself needs
scikit-learn) so the incremental decision is unit-testable on its own."""
if full:
return list(eligible)
existing = {
tag_id: (fp, ev)
for tag_id, fp, ev in session.execute(
select(
TagHead.tag_id, TagHead.train_fingerprint,
TagHead.embedding_version,
)
).all()
}
out = []
for tag_id in eligible:
prev = existing.get(tag_id)
if (
prev is None
or prev[1] != embedding_version
or prev[0] != fps.get(tag_id)
):
out.append(tag_id)
return out
def train_all_heads(
session: Session, params: dict[str, Any], run: HeadTrainingRun | None = None
) -> dict[str, int]:
"""(Re)train a head for every eligible concept; prune heads whose tag is no
longer eligible. Commits per head so a SIGKILL leaves trained heads durable
(training is idempotent). Returns {n_trained, n_skipped}."""
"""(Re)train eligible concept heads, INCREMENTALLY by default (#1317 p2):
refit only the tags whose training data changed since last fit, so a manual
Retrain click is fast. `params["full"]=True` (the nightly run) refits every
head to reconcile sampled-negative + hygiene drift. Prunes heads whose tag is
no longer eligible. Commits per head so a SIGKILL leaves trained heads durable.
Returns {n_trained, n_skipped} (n_skipped = unchanged + too-few-examples)."""
import numpy as np
cfg = _normalize_params(session, params)
embedding_version = _embedder_version(session)
full = bool((params or {}).get("full"))
eligible = _eligible_tag_ids(session, cfg["min_positives"])
eligible_set = set(eligible)
# Computed once per run, not per head — the hygiene set is identical for
# every non-system concept.
hygiene = _hygiene_excluded_ids(session)
fps = _head_fingerprints(session, eligible)
to_train = set(
_heads_needing_retrain(session, eligible, embedding_version, fps, full)
)
trained = 0
skipped = 0
failed = 0
for i, tag_id in enumerate(eligible):
if tag_id not in to_train:
continue
try:
ok = train_head(
session, tag_id, embedding_version, cfg, np, hygiene=hygiene
@@ -175,9 +254,15 @@ def train_all_heads(
except Exception:
log.exception("train_head failed for tag %d", tag_id)
ok = False
if ok:
# Stamp the fingerprint we trained against so an unchanged tag is
# skipped on the next incremental run.
head = session.get(TagHead, tag_id)
if head is not None:
head.train_fingerprint = fps.get(tag_id)
session.commit()
trained += int(ok)
skipped += int(not ok)
failed += int(not ok)
if run is not None and i % 10 == 0:
run.last_progress_at = datetime.now(UTC)
session.commit()
@@ -188,7 +273,11 @@ def train_all_heads(
else:
session.execute(delete(TagHead))
session.commit()
return {"n_trained": trained, "n_skipped": skipped}
# n_skipped = unchanged (not attempted) + failed-to-fit (too few examples).
return {
"n_trained": trained,
"n_skipped": (len(eligible) - len(to_train)) + failed,
}
def head_training_ids(
+4 -1
View File
@@ -356,7 +356,10 @@ def scheduled_train_heads() -> str:
if running is not None:
return "already running"
run = HeadTrainingRun(
params={"source": "scheduled"}, status="running",
# Nightly = FULL reconcile (refit every head) so sampled-negative +
# hygiene drift is folded in; the manual Retrain button stays
# incremental (#1317 p2).
params={"source": "scheduled", "full": True}, status="running",
last_progress_at=datetime.now(UTC),
)
session.add(run)