From 4cae43623116e8fe1c49a44288f0148f80c1e488 Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 23 Jul 2026 00:42:03 +0200 Subject: [PATCH] M6+M11: learned weights with shrinkage, refresh command, cron docs Co-Authored-By: Claude Fable 5 --- README.md | 6 +++ backend/tests/test_learn.py | 52 ++++++++++++++++++++++ backend/tft/cli.py | 23 ++++++++-- backend/tft/model/learn.py | 88 +++++++++++++++++++++++++++++++++++++ backend/tft/model/score.py | 9 ++-- 5 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_learn.py create mode 100644 backend/tft/model/learn.py diff --git a/README.md b/README.md index e50b576..885d06d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ uv run python -m tft.cli refresh # crawl + extract + build-artifact (f Riot-Dev-Key: https://developer.riotgames.com (24h gültig), in `backend/.env` als `RIOT_API_KEY=...`. +Täglicher Refresh per Cron (Key muss gültig sein): + +``` +15 6 * * * cd /home/arbeit/projects/tft/backend && uv run python -m tft.cli refresh >> ../data/refresh.log 2>&1 +``` + ## Server ```sh diff --git a/backend/tests/test_learn.py b/backend/tests/test_learn.py new file mode 100644 index 0000000..78c8c83 --- /dev/null +++ b/backend/tests/test_learn.py @@ -0,0 +1,52 @@ +import json + +from tft.model.learn import learn + + +def board_row(placement, unit="TFT17_A", item="TFT_Item_X", trait_tier=1): + units = [{"character_id": unit, "tier": 2, "itemNames": [item]}] + traits = [{"name": "TFT17_T", "num_units": 2, "tier_current": trait_tier}] + return (placement, json.dumps(units), json.dumps(traits), json.dumps([])) + + +def test_strong_item_gets_multiplier_above_1(): + rows = [board_row(1, item="TFT_Item_Good") for _ in range(200)] + rows += [board_row(8, item="TFT_Item_Bad") for _ in range(200)] + learned = learn(rows) + assert learned["items"]["TFT_Item_Good"] > 1.05 + assert learned["items"]["TFT_Item_Bad"] < 0.95 + + +def test_shrinkage_with_thin_data(): + rows = [board_row(1, item="TFT_Item_Rare") for _ in range(3)] + learned = learn(rows) + assert abs(learned["items"]["TFT_Item_Rare"] - 1.0) < 0.06 + + +def test_trait_keys_match_score_format(): + rows = [board_row(2, trait_tier=3) for _ in range(50)] + learned = learn(rows) + assert "TFT17_T@3" in learned["traits"] + + +def test_pair_lift_for_cooccurring_units(): + def pair_row(placement): + units = [ + {"character_id": "TFT17_A", "tier": 2, "itemNames": []}, + {"character_id": "TFT17_B", "tier": 2, "itemNames": []}, + ] + return (placement, json.dumps(units), json.dumps([]), json.dumps([])) + + # A und B stehen immer zusammen in Top-4-Boards; C ist überall. + rows = [pair_row(1) for _ in range(100)] + solo = [{"character_id": "TFT17_C", "tier": 1, "itemNames": []}] + rows += [(1, json.dumps(solo), json.dumps([]), json.dumps([])) for _ in range(100)] + learned = learn(rows) + assert learned["pairs"]["TFT17_A|TFT17_B"] > 0.5 + + +def test_empty_rows(): + learned = learn([]) + assert learned["traits"] == {} + assert learned["pairs"] == {} + assert learned["n_boards"] == 0 diff --git a/backend/tft/cli.py b/backend/tft/cli.py index b352337..5e08ad1 100644 --- a/backend/tft/cli.py +++ b/backend/tft/cli.py @@ -29,6 +29,8 @@ def main() -> None: p_auto.add_argument("--policy", choices=["afk", "econ"], default="econ") p_auto.add_argument("--games", type=int, default=200) + sub.add_parser("refresh", help="daily job: crawl + extract + build-artifact + calibrate") + args = parser.parse_args() if args.command == "fetch-static": @@ -63,14 +65,19 @@ def main() -> None: print("all ids resolved against static data") elif args.command == "build-artifact": + from tft import db from tft.model import artifact as artifact_mod + from tft.model.learn import learn_from_db from tft.staticdata.fetch import load_static static = load_static() - learned = {} - art = artifact_mod.build(static, learned) + conn = db.connect() + learned = learn_from_db(conn, static["meta"]["set"]) + conn.close() + n_boards = learned.pop("n_boards", 0) + art = artifact_mod.build(static, learned, {"boards_learned_from": n_boards}) path = artifact_mod.save(art) - print(f"artifact written: {path}") + print(f"artifact written: {path} ({n_boards} boards learned from)") elif args.command == "calibrate": from tft import db @@ -98,6 +105,16 @@ def main() -> None: f"avg placement {result['avg_placement']:.2f}, " f"top4 {result['top4_rate']:.0%}") + elif args.command == "refresh": + import subprocess + import sys + + for stage in ("crawl", "extract", "build-artifact", "calibrate"): + print(f"== {stage} ==", flush=True) + result = subprocess.run([sys.executable, "-m", "tft.cli", stage]) + if result.returncode != 0: + raise SystemExit(f"refresh aborted at {stage}") + if __name__ == "__main__": main() diff --git a/backend/tft/model/learn.py b/backend/tft/model/learn.py new file mode 100644 index 0000000..d79d5d2 --- /dev/null +++ b/backend/tft/model/learn.py @@ -0,0 +1,88 @@ +"""Learn multiplicative strength weights from endboards via placement deltas. + +All multipliers center at 1.0 with Bayesian shrinkage: with thin data the +score model falls back to the rule baseline (survives set launch). +""" + +import json +from collections import defaultdict + +MEAN_PLACEMENT = 4.5 +PSEUDO_COUNT = 30 +# Platzierungs-Delta -> Multiplikator: 1 Platz besser als der Schnitt ≈ +6 %. +DELTA_SCALE = 0.06 +MULT_CAP = (0.8, 1.25) +PAIR_MIN_N = 20 +PAIR_LIFT_CAP = (-0.9, 1.15) + + +def _multiplier(placements: list[int]) -> float: + n = len(placements) + mean = (sum(placements) + MEAN_PLACEMENT * PSEUDO_COUNT) / (n + PSEUDO_COUNT) + mult = 1.0 + (MEAN_PLACEMENT - mean) * DELTA_SCALE + return min(max(mult, MULT_CAP[0]), MULT_CAP[1]) + + +def learn(rows: list[tuple]) -> dict: + """rows: (placement, units_json, traits_json, augments_json).""" + trait_placements = defaultdict(list) + item_placements = defaultdict(list) + augment_placements = defaultdict(list) + unit_top4 = defaultdict(int) + pair_top4 = defaultdict(int) + n_boards = 0 + n_top4 = 0 + + for placement, units_json, traits_json, augments_json in rows: + n_boards += 1 + top4 = placement <= 4 + n_top4 += top4 + + units = json.loads(units_json) + names = sorted({u["character_id"] for u in units}) + for u in units: + for item in u.get("itemNames", []): + item_placements[item].append(placement) + for t in json.loads(traits_json): + if t.get("tier_current", 0) > 0: + trait_placements[f"{t['name']}@{t['tier_current']}"].append(placement) + for a in json.loads(augments_json): + augment_placements[a].append(placement) + + if top4: + for name in names: + unit_top4[name] += 1 + for i, a in enumerate(names): + for b in names[i + 1 :]: + pair_top4[(a, b)] += 1 + + pairs = {} + if n_top4: + for (a, b), n_ab in pair_top4.items(): + if n_ab < PAIR_MIN_N: + continue + expected = unit_top4[a] * unit_top4[b] / n_top4 + if expected <= 0: + continue + lift = n_ab / expected - 1.0 + lift = min(max(lift, PAIR_LIFT_CAP[0]), PAIR_LIFT_CAP[1]) + pairs[f"{a}|{b}"] = round(lift, 4) + + return { + "traits": {k: round(_multiplier(v), 4) for k, v in trait_placements.items()}, + "items": {k: round(_multiplier(v), 4) for k, v in item_placements.items()}, + "augments": {k: round(_multiplier(v), 4) for k, v in augment_placements.items()}, + "pairs": pairs, + "n_boards": n_boards, + } + + +def learn_from_db(conn, set_number: int, exclude_holdout: bool = True) -> dict: + where = "WHERE set_number = ?" + if exclude_holdout: + where += " AND rowid % 5 != 0" + rows = conn.execute( + f"SELECT placement, units, traits, augments FROM endboards {where}", + (set_number,), + ).fetchall() + return learn(rows) diff --git a/backend/tft/model/score.py b/backend/tft/model/score.py index cdbc5d0..f48e3ec 100644 --- a/backend/tft/model/score.py +++ b/backend/tft/model/score.py @@ -62,8 +62,11 @@ def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> total *= augment_mults.get(augment, 1.0) names = sorted({u["api_name"] for u in board_units}) - for i, a in enumerate(names): - for b in names[i + 1 :]: - total += pair_lifts.get(f"{a}|{b}", 0.0) + lift_sum = sum( + pair_lifts.get(f"{a}|{b}", 0.0) + for i, a in enumerate(names) + for b in names[i + 1 :] + ) + total *= 1 + min(max(lift_sum * 0.01, -0.10), 0.10) return total