Trifecta-Lab / trifecta_bro /model /probability.py
Brettapps's picture
Upload folder using huggingface_hub (part 21)
e23172f verified
Raw History Blame Contribute Delete
4.99 kB
"""Probability, trifecta generation, and confidence.
Deterministic / statistical only — no LLM involvement. Probabilities are model
estimates (softmax over scores), explicitly NOT bookmaker odds, and are labelled
as such in all output.
"""
from __future__ import annotations
import math
from collections import OrderedDict
from typing import Optional
def softmax_probabilities(scores: list[float], temperature: float = 1.0) -> list[float]:
if not scores:
return []
mx = max(scores)
exps = [math.exp((s - mx) / temperature) for s in scores]
total = sum(exps) or 1.0
return [e / total for e in exps]
def joint_prob(first: int, second: int, third: int,
runner_probs: dict[int, float], order: list[int]) -> float:
"""Probability that `order` (a permutation of first/second/third) lands,
under a simple first-order model: P(1st) * P(2nd | not 1st) * P(3rd | not 1,2)."""
nums = [first, second, third]
perm = list(order)
p = 1.0
excluded = set()
for pos, pick in enumerate(perm):
avail = [n for n in nums if n not in excluded]
# P(pick | available) proportional to its standalone prob among available
avail_p = {n: runner_probs.get(n, 0.0) for n in avail}
s = sum(avail_p.values()) or 1.0
p *= avail_p.get(pick, 0.0) / s
excluded.add(pick)
return p
def generate_trifecta(ranked: list[dict], weights_cfg: dict) -> dict:
"""ranked: list of {number, score, win_est, place_est} sorted desc by score.
Returns primary, top3, alternatives (savers), and probabilities."""
if len(ranked) < 3:
return {"skippable": True, "reason": "fewer than 3 active runners"}
# standalone per-runner win probabilities (softmax over scores)
nums = [r["number"] for r in ranked]
scores = [r["score"] for r in ranked]
probs = softmax_probabilities(scores, temperature=12.0)
runner_probs = {n: p for n, p in zip(nums, probs)}
top3 = [ranked[0]["number"], ranked[1]["number"], ranked[2]["number"]]
primary = list(top3)
# enumerate saver permutations of the top-K (K=4) and score them
k = min(4, len(ranked))
pool = [r["number"] for r in ranked[:k]]
from itertools import permutations
candidates = []
for order in permutations(pool):
if order == tuple(primary):
continue
pr = joint_prob(primary[0], primary[1], primary[2], runner_probs, order)
candidates.append((list(order), pr))
candidates.sort(key=lambda x: x[1], reverse=True)
primary_prob = joint_prob(primary[0], primary[1], primary[2], runner_probs, tuple(primary))
max_ratio = weights_cfg.get("min_saver_prob_ratio", 0.18)
max_savers = weights_cfg.get("max_savers", 6)
savers = [c for c in candidates if c[1] >= primary_prob * max_ratio][:max_savers]
return {
"primary": primary,
"top3": top3,
"alternatives": [c[0] for c in savers],
"primary_prob": round(primary_prob, 4),
"saver_probs": [round(c[1], 4) for c in savers],
"runner_probs": {str(k): round(v, 4) for k, v in runner_probs.items()},
}
def compute_confidence(ranked: list[dict], race_completeness: float,
pace: dict, weights_cfg: dict) -> dict:
if len(ranked) < 3:
return {"category": "AVOID", "score": 0,
"reason": "Insufficient active runners for a trifecta."}
conf = weights_cfg
leader = ranked[0]["score"]
if leader < weights_cfg.get("min_leader_score_for_prediction", 30.0):
return {"category": "AVOID", "score": int(leader),
"reason": "Leader score below threshold — data too weak to predict."}
sep = ranked[0]["score"] - ranked[1]["score"]
# competitive runners within `competitive_band` of leader
band = conf.get("competitive_band", 8.0)
competitive = sum(1 for r in ranked if leader - r["score"] <= band)
if (sep >= conf.get("high_separation", 18.0)
and race_completeness >= conf.get("high_min_completeness", 0.85)
and competitive <= conf.get("max_competitive_runners_for_high", 3)):
cat, score = "HIGH", min(100, 70 + int(sep) + int(race_completeness * 15))
elif (sep >= conf.get("medium_separation", 9.0)
and race_completeness >= conf.get("medium_min_completeness", 0.6)
and competitive <= conf.get("max_competitive_runners_for_medium", 6)):
cat, score = "MEDIUM", min(75, 50 + int(sep) + int(race_completeness * 10))
elif sep < 4.0 or race_completeness < 0.35:
cat, score = "AVOID", min(35, int(leader))
else:
cat, score = "LOW", min(50, 30 + int(sep))
score = max(0, min(100, score))
return {
"category": cat,
"score": score,
"leader_score": round(leader, 1),
"separation": round(sep, 1),
"competitive_runners": competitive,
"data_completeness": round(race_completeness, 2),
"pace": pace.get("label"),
}