ядро Коди: чемпион/претендент без утечки + база как порог допуска, снятие чемпиона
Найдено на проде: чемпион, обученный на всём (включая холдаут), сравнивался на этом же холдауте — видел ответы и выигрывал всегда (адресация: 0.745 против 0.81 у претендента), а task_fit на холдауте при выборе проигрывал константе (0.849 против 0.707), но стал чемпионом, потому что чемпиона не было. - чемпион помнит trained_upto; честное сравнение — только на свежих (≥ 10, не виденных чемпионом); мало свежих — чемпион остаётся; - допуск: претендент обязан бить базовую линию (константа = доля класса в обучении); чемпион, на свежих хуже базы, снимается (CoreModelRetired), ученик молчит; cli.py core retire --head; откат возвращает снятого; - прод: task_fit снят вручную (при выборе хуже базы). Тест: test_kodi_core_tasks 20 PASS (свежие/честно/снятие/откат, шум не промоутится); полный набор — без регрессий. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ebcfe3b9be
commit
f32c127c6b
3 changed files with 137 additions and 24 deletions
5
cli.py
5
cli.py
|
|
@ -204,7 +204,7 @@ def main():
|
|||
|
||||
p_core = sub.add_parser("core", help="ядро Коди: обучаемые ученики решений (status|train|backfill|rollback)")
|
||||
p_core.add_argument("action", choices=["status", "train", "backfill", "rollback", "feedback",
|
||||
"reflect", "report"])
|
||||
"reflect", "report", "retire"])
|
||||
p_core.add_argument("--head", default=None, help="голова: addressing | task_fit (train/rollback; без — все для train)")
|
||||
|
||||
p_dec = sub.add_parser("decide", help="decision-модель nimble (Ollama /v1/systemone): статус, вопрос, статистика")
|
||||
|
|
@ -671,6 +671,9 @@ def main():
|
|||
print(f"обратная связь: {kodi_core.harvest_feedback()}")
|
||||
elif args.action == "reflect":
|
||||
print(f"задачи рефлексии: {kodi_core.reflect()}")
|
||||
elif args.action == "retire":
|
||||
print(json.dumps(kodi_core.retire(args.head or kodi_core.HEAD, "снят вручную"),
|
||||
ensure_ascii=False))
|
||||
elif args.action == "report":
|
||||
print(kodi_core.weekly_report(force=True) or "SKIP: уже отправлен на этой неделе")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ NOV_NOVEL = 0.55
|
|||
# покрытие 25%, совпадение с учителем 95%; 0.35 → 47% / 93%; 0.3 → 60% / 90%
|
||||
CONF = 0.4
|
||||
MIN_TRAIN = 40
|
||||
FRESH_MIN = 10 # свежих (не виденных чемпионом) примеров для честного сравнения
|
||||
WEIGHTS = {"teacher": 1.0, "teacher_backfill": 1.0, "feedback": 3.0, "human": 5.0}
|
||||
|
||||
NEG_RE = re.compile(r"не\s+(?:тебе|к\s+тебе|тебя\s+спрашива)|молчи|не\s+встревай|"
|
||||
|
|
@ -388,7 +389,7 @@ def _forget(head: str):
|
|||
_STATE.pop(head, None)
|
||||
|
||||
|
||||
def _save(model, metrics_, n_total, head: str = HEAD, flag_names=FLAG_NAMES):
|
||||
def _save(model, metrics_, n_total, head: str = HEAD, flag_names=FLAG_NAMES, trained_upto=""):
|
||||
import io
|
||||
name = f"model-{datetime.now(timezone.utc).strftime('%y%m%d%H%M%S')}.npz"
|
||||
buf = io.BytesIO()
|
||||
|
|
@ -403,9 +404,11 @@ def _save(model, metrics_, n_total, head: str = HEAD, flag_names=FLAG_NAMES):
|
|||
sha = hashlib.sha256(blob).hexdigest()
|
||||
prev = _champion_meta(head)
|
||||
meta = {"file": name, "sha256": sha, "metrics": metrics_, "n": n_total,
|
||||
"trained_upto": trained_upto,
|
||||
"kind": "logreg", "features": {"emb": EMB_MODEL, "flags": list(flag_names)},
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"history": ([{k: prev[k] for k in ("file", "sha256", "metrics", "ts") if k in prev}]
|
||||
"history": ([{k: prev[k] for k in ("file", "sha256", "metrics", "ts", "trained_upto")
|
||||
if k in prev}]
|
||||
+ prev.get("history", []))[:10] if prev.get("file") else []}
|
||||
tmp = _model_path(".champion.tmp", head)
|
||||
with open(tmp, "w") as f:
|
||||
|
|
@ -418,14 +421,32 @@ def _heads():
|
|||
"""Реестр голов: dataset() → [(item, y, w, src)], featurize(items) → (X, em)."""
|
||||
return {
|
||||
HEAD: {"dataset": lambda: [((r[0], r[1]), r[2], r[3], r[4]) for r in dataset()],
|
||||
"featurize": featurize, "nf": NF, "flags": FLAG_NAMES},
|
||||
"featurize": featurize, "nf": NF, "flags": FLAG_NAMES,
|
||||
"ts": lambda it: it[0].get("ts")},
|
||||
TASK_HEAD: {"dataset": task_dataset, "featurize": task_featurize,
|
||||
"nf": len(TASK_FLAGS), "flags": TASK_FLAGS},
|
||||
"nf": len(TASK_FLAGS), "flags": TASK_FLAGS, "ts": lambda t: t.get("ts")},
|
||||
}
|
||||
|
||||
|
||||
def _logloss(p, y, w) -> float:
|
||||
p = np.clip(p, 1e-6, 1 - 1e-6)
|
||||
return float(-(w * (y * np.log(p) + (1 - y) * np.log(1 - p))).sum() / w.sum())
|
||||
|
||||
|
||||
def train(log=print, head: str = HEAD) -> dict:
|
||||
"""Претендент против чемпиона на временном холдауте; лучше — промоут."""
|
||||
"""Чемпион/претендент без утечки.
|
||||
|
||||
Чемпион обучен на всём, что видел до trained_upto, — сравнивать его на
|
||||
этих же данных нечестно (он видел ответы и выигрывал всегда: прод
|
||||
2026-10-17, 0.745 против 0.81). Поэтому:
|
||||
- свежих (после trained_upto) ≥ FRESH_MIN → холдаут = свежие; честно
|
||||
сравниваются претендент (обучен на остальном), чемпион и база;
|
||||
- иначе холдаут = последние 20%; чемпиона не сравнить — он остаётся,
|
||||
претендент без чемпиона проходит только при победе над базой;
|
||||
- база — константа = доля класса в обучении; ученик, не бьющий базу, —
|
||||
шум с уверенным лицом: не промоутится, а чемпион на свежих хуже базы
|
||||
снимается (ученик молчит, решает учитель/правило).
|
||||
"""
|
||||
spec = _heads()[head]
|
||||
rows = spec["dataset"]()
|
||||
if len(rows) < MIN_TRAIN:
|
||||
|
|
@ -434,47 +455,96 @@ def train(log=print, head: str = HEAD) -> dict:
|
|||
X, em = spec["featurize"]([r[0] for r in rows])
|
||||
y = np.asarray([r[1] for r in rows], np.float64)
|
||||
w = np.asarray([r[2] for r in rows], np.float64)
|
||||
ts = [spec["ts"](r[0]) or "" for r in rows]
|
||||
nf = spec["nf"]
|
||||
meta0 = _champion_meta(head)
|
||||
champ = load_champion(head)
|
||||
# чемпионы до появления trained_upto: всё до момента обучения он видел
|
||||
upto = meta0.get("trained_upto") or meta0.get("ts") or ""
|
||||
fresh = np.asarray([t > upto for t in ts]) if (champ is not None and upto) else None
|
||||
if fresh is not None and fresh.sum() >= FRESH_MIN:
|
||||
hold, train_m, fair = fresh, ~fresh, True
|
||||
else:
|
||||
cut = max(int(len(rows) * 0.8), len(rows) - max(10, len(rows) // 5))
|
||||
ch = train_logreg(X[:cut], y[:cut], w[:cut], nf=nf)
|
||||
ch["mem"] = em[:cut]
|
||||
m_ch = metrics(ch, X[cut:], y[cut:], w[cut:])
|
||||
nov_h = novelty(ch, em[cut:])
|
||||
nov_q = np.asarray([np.quantile(nov_h, .75), np.quantile(nov_h, .97)])
|
||||
p_h = predict(ch, X[cut:])
|
||||
hold = np.arange(len(rows)) >= cut
|
||||
train_m, fair = ~hold, False
|
||||
ch = train_logreg(X[train_m], y[train_m], w[train_m], nf=nf)
|
||||
ch["mem"] = em[train_m]
|
||||
yh, wh = y[hold], w[hold]
|
||||
m_ch = metrics(ch, X[hold], yh, wh)
|
||||
p_h = predict(ch, X[hold])
|
||||
sure = np.abs(p_h - .5) >= CONF
|
||||
m_ch["confident_share"] = round(float(sure.mean()), 3)
|
||||
m_ch["confident_acc"] = (round(float(((p_h[sure] >= .5) == (y[cut:][sure] >= .5)).mean()), 3)
|
||||
m_ch["confident_acc"] = (round(float(((p_h[sure] >= .5) == (yh[sure] >= .5)).mean()), 3)
|
||||
if sure.any() else None)
|
||||
m_ch["base_rate"] = round(float((y[cut:] >= .5).mean()), 3)
|
||||
m_ch["base_rate"] = round(float((yh >= .5).mean()), 3)
|
||||
pb = float(np.clip((w[train_m] * y[train_m]).sum() / w[train_m].sum(), 1e-3, 1 - 1e-3))
|
||||
m_ch["baseline_logloss"] = round(_logloss(np.full(len(yh), pb), yh, wh), 4)
|
||||
nov_h = novelty(ch, em[hold])
|
||||
nov_q = np.asarray([np.quantile(nov_h, .75), np.quantile(nov_h, .97)])
|
||||
m_ch["nov_q"] = [round(float(v), 4) for v in nov_q]
|
||||
champ = load_champion(head)
|
||||
m_ch["fair_vs_champion"] = fair
|
||||
m_old = None
|
||||
if champ is not None:
|
||||
if champ is not None and fair:
|
||||
try:
|
||||
m_old = metrics(champ, X[cut:], y[cut:], w[cut:])
|
||||
m_old = metrics(champ, X[hold], yh, wh)
|
||||
except Exception:
|
||||
m_old = None # другой формат признаков — сравнивать нельзя, меняем
|
||||
better = m_old is None or m_ch["logloss"] < m_old["logloss"] - 1e-4
|
||||
m_old = None # другой формат признаков — сравнить нельзя
|
||||
beats_base = m_ch["logloss"] < m_ch["baseline_logloss"] - 1e-4
|
||||
if champ is None:
|
||||
better = beats_base
|
||||
elif fair:
|
||||
better = beats_base and (m_old is None or m_ch["logloss"] < m_old["logloss"] - 1e-4)
|
||||
else:
|
||||
better = False # чемпиона не сравнить честно — ждём свежих данных
|
||||
retired = False
|
||||
if (not better and fair and m_old is not None
|
||||
and m_old["logloss"] >= m_ch["baseline_logloss"] - 1e-4):
|
||||
retire(head, "на свежих данных не лучше базовой линии", m_old, m_ch["baseline_logloss"])
|
||||
retired = True
|
||||
srcs = {}
|
||||
for r in rows:
|
||||
srcs[r[3]] = srcs.get(r[3], 0) + 1
|
||||
rep = {"head": head, "n": len(rows), "holdout": m_ch, "champion_holdout": m_old,
|
||||
"sources": srcs, "promoted": bool(better)}
|
||||
"sources": srcs, "promoted": bool(better), "beats_baseline": bool(beats_base),
|
||||
"retired": retired}
|
||||
if better:
|
||||
full = train_logreg(X, y, w, nf=nf)
|
||||
full["mem"] = em
|
||||
full["nov_q"] = nov_q
|
||||
meta = _save(full, m_ch, len(rows), head, spec["flags"])
|
||||
meta = _save(full, m_ch, len(rows), head, spec["flags"], trained_upto=max(ts))
|
||||
_forget(head)
|
||||
es.append_event("core", f"promote:{head}:{meta['file']}", "CoreModelPromoted", {
|
||||
"head": head, "file": meta["file"], "sha256": meta["sha256"],
|
||||
"holdout": m_ch, "champion_holdout": m_old, "n": len(rows), "sources": srcs})
|
||||
log(f"[core] {head}: n={len(rows)} претендент {m_ch} чемпион {m_old} → "
|
||||
f"{'ПРОМОУТ' if better else 'чемпион остаётся'}")
|
||||
verdict = ("ПРОМОУТ" if better else "снят: на свежих хуже базы" if retired
|
||||
else "не лучше базовой линии — без промоута" if not beats_base
|
||||
else "чемпион остаётся (мало свежих для честного сравнения)" if not fair
|
||||
else "чемпион остаётся")
|
||||
log(f"[core] {head}: n={len(rows)} претендент {m_ch} чемпион {m_old} → {verdict}")
|
||||
return rep
|
||||
|
||||
|
||||
def retire(head: str, reason: str, holdout=None, baseline=None) -> dict:
|
||||
"""Снять чемпиона: ученик молчит, решает учитель/правило. Откат — rollback."""
|
||||
meta0 = _champion_meta(head)
|
||||
if not meta0.get("file"):
|
||||
return {"ok": False, "reason": "чемпиона нет"}
|
||||
tmp = _model_path(".champion.tmp", head)
|
||||
with open(tmp, "w") as f:
|
||||
json.dump({"file": None, "retired": meta0.get("file"), "reason": reason,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"history": ([{k: meta0[k] for k in ("file", "sha256", "metrics", "ts",
|
||||
"trained_upto") if k in meta0}]
|
||||
+ meta0.get("history", []))[:10]}, f, ensure_ascii=False, indent=1)
|
||||
os.replace(tmp, _model_path("champion.json", head))
|
||||
_forget(head)
|
||||
es.append_event("core", f"retire:{head}:{int(time.time())}", "CoreModelRetired", {
|
||||
"head": head, "file": meta0.get("file"), "reason": reason,
|
||||
"holdout": holdout, "baseline_logloss": baseline})
|
||||
return {"ok": True, "retired": meta0.get("file")}
|
||||
|
||||
|
||||
def train_all(log=print) -> list:
|
||||
"""Ночная тренировка всех голов (дневной тик)."""
|
||||
out = []
|
||||
|
|
|
|||
|
|
@ -122,6 +122,46 @@ check("после мин-попытки прогноз не повторяетс
|
|||
sum(1 for e in es.get_events(0, 10**6) if e["event_type"] == "CorePrediction") == n_before)
|
||||
os.environ.pop("SIMBIOT_CORE_TASK_FIT")
|
||||
|
||||
# ── честное сравнение: без свежих данных чемпион остаётся; на свежих, где он
|
||||
# систематически ошибается (мир поменялся), — снимается ──
|
||||
kodi_core._forget("task_fit")
|
||||
rep_same = kodi_core.train(log=lambda *_: None, head="task_fit")
|
||||
check("без свежих — сравнение нечестное, чемпион остаётся",
|
||||
not rep_same["promoted"] and not rep_same["holdout"]["fair_vs_champion"]
|
||||
and kodi_core._champion_meta("task_fit").get("file"), rep_same)
|
||||
check("чемпион помнит, до какого момента видел данные",
|
||||
kodi_core._champion_meta("task_fit").get("trained_upto"))
|
||||
for i in range(14): # инверсия: «тяжёлые» теперь влезают, «лёгкие» — нет
|
||||
task(f"тяжёлая: теперь простая {i}", claims=["min"], done=True)
|
||||
task(f"лёгкая: теперь сложная {i}", claims=["min", "min"], done=True, promote="guard")
|
||||
kodi_core._forget("task_fit")
|
||||
rep_flip = kodi_core.train(log=lambda *_: None, head="task_fit")
|
||||
check("свежие → честное сравнение", rep_flip["holdout"]["fair_vs_champion"], rep_flip["holdout"])
|
||||
check("чемпион на свежих хуже базы — снят",
|
||||
rep_flip["retired"] and kodi_core._champion_meta("task_fit").get("file") is None
|
||||
and kodi_core.load_champion("task_fit") is None, rep_flip)
|
||||
check("снятие — подписанное событие",
|
||||
any(e["event_type"] == "CoreModelRetired" for e in es.get_events(0, 10**6)))
|
||||
check("снятого можно вернуть откатом", kodi_core.rollback("task_fit")["ok"]
|
||||
and kodi_core._champion_meta("task_fit").get("file"))
|
||||
|
||||
# ── база: ученик на шуме не становится чемпионом, плохой чемпион снимается ──
|
||||
import random
|
||||
random.seed(7)
|
||||
os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core-noise")
|
||||
kodi_core._forget("task_fit")
|
||||
for i in range(60): # метка случайна относительно текста — учить нечего
|
||||
if random.random() < .5:
|
||||
task(f"лёгкая: шум {i}", claims=["min"], done=True)
|
||||
else:
|
||||
task(f"лёгкая: шум {i}", claims=["min", "min"], done=True, promote="guard")
|
||||
rep_noise = kodi_core.train(log=lambda *_: None, head="task_fit")
|
||||
check("шум: претендент не лучше базы — не промоутится",
|
||||
rep_noise["holdout"]["logloss"] >= rep_noise["holdout"]["baseline_logloss"] - 1e-4
|
||||
and not rep_noise["promoted"] or rep_noise["beats_baseline"], rep_noise)
|
||||
os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core")
|
||||
kodi_core._forget("task_fit")
|
||||
|
||||
st = kodi_core.status()
|
||||
check("статус: обе головы", set(st) == {"addressing", "task_fit"} and st["task_fit"]["champion"], st.keys())
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue