wraith-tdf-predictor 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tdf_predictor/__init__.py +5 -0
- tdf_predictor/db.py +85 -0
- tdf_predictor/fantasy.py +439 -0
- tdf_predictor/ingest.py +193 -0
- tdf_predictor/model.py +229 -0
- tdf_predictor/tissot.py +107 -0
- tdf_predictor/tissot_ingest.py +178 -0
- tdf_predictor/web.py +670 -0
- tdf_predictor/weights_fit.py +237 -0
- wraith_tdf_predictor-0.7.0.dist-info/METADATA +143 -0
- wraith_tdf_predictor-0.7.0.dist-info/RECORD +13 -0
- wraith_tdf_predictor-0.7.0.dist-info/WHEEL +4 -0
- wraith_tdf_predictor-0.7.0.dist-info/licenses/LICENSE +21 -0
tdf_predictor/db.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Load data/raw/*.json into data/races.db (SQLite).
|
|
2
|
+
|
|
3
|
+
Run: uv run python -m tdf_predictor.db
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# ponytail: stdlib sqlite3 file DB; swap for Turso when multi-host
|
|
7
|
+
# access is actually needed
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from tdf_predictor.ingest import DATA_DIR, RAW_DIR
|
|
15
|
+
|
|
16
|
+
DB_PATH = DATA_DIR / "races.db"
|
|
17
|
+
|
|
18
|
+
SCHEMA = """
|
|
19
|
+
CREATE TABLE IF NOT EXISTS races (
|
|
20
|
+
race TEXT NOT NULL,
|
|
21
|
+
year INTEGER NOT NULL,
|
|
22
|
+
kind TEXT NOT NULL,
|
|
23
|
+
date TEXT,
|
|
24
|
+
source TEXT,
|
|
25
|
+
PRIMARY KEY (race, year)
|
|
26
|
+
);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS results (
|
|
28
|
+
race TEXT NOT NULL,
|
|
29
|
+
year INTEGER NOT NULL,
|
|
30
|
+
rider TEXT NOT NULL,
|
|
31
|
+
rider_url TEXT,
|
|
32
|
+
team TEXT,
|
|
33
|
+
rank INTEGER,
|
|
34
|
+
status TEXT,
|
|
35
|
+
age INTEGER,
|
|
36
|
+
nationality TEXT,
|
|
37
|
+
time TEXT,
|
|
38
|
+
pcs_points REAL,
|
|
39
|
+
uci_points REAL,
|
|
40
|
+
PRIMARY KEY (race, year, rider)
|
|
41
|
+
);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_results_rider ON results (rider_url);
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load(db_path: Path = DB_PATH, raw_dir: Path = RAW_DIR) -> int:
|
|
47
|
+
con = sqlite3.connect(db_path)
|
|
48
|
+
con.executescript(SCHEMA)
|
|
49
|
+
n = 0
|
|
50
|
+
for f in sorted(raw_dir.glob("*.json")):
|
|
51
|
+
if f.name.startswith("_"):
|
|
52
|
+
continue
|
|
53
|
+
d = json.loads(f.read_text())
|
|
54
|
+
con.execute(
|
|
55
|
+
"INSERT OR REPLACE INTO races VALUES (?,?,?,?,?)",
|
|
56
|
+
(d["race"], d["year"], d["kind"], d["date"], d["source"]),
|
|
57
|
+
)
|
|
58
|
+
con.executemany(
|
|
59
|
+
"INSERT OR REPLACE INTO results VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
60
|
+
[
|
|
61
|
+
(
|
|
62
|
+
d["race"],
|
|
63
|
+
d["year"],
|
|
64
|
+
r["rider_name"],
|
|
65
|
+
r["rider_url"],
|
|
66
|
+
r["team_name"],
|
|
67
|
+
r["rank"],
|
|
68
|
+
r["status"],
|
|
69
|
+
r["age"],
|
|
70
|
+
r["nationality"],
|
|
71
|
+
r["time"],
|
|
72
|
+
r["pcs_points"],
|
|
73
|
+
r["uci_points"],
|
|
74
|
+
)
|
|
75
|
+
for r in d["results"]
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
n += len(d["results"])
|
|
79
|
+
con.commit()
|
|
80
|
+
con.close()
|
|
81
|
+
return n
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
print(f"loaded {load()} result rows into {DB_PATH}", file=sys.stderr)
|
tdf_predictor/fantasy.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Tissot fantasy team picker: 8 riders, <=120 stars, max points.
|
|
2
|
+
|
|
3
|
+
Proxy for fantasy points: PCS points over TDF stages + final GC.
|
|
4
|
+
Predict a rider's TDF points from pre-Tour season form (ridge
|
|
5
|
+
regression), synthesize past-year star costs by quantile-mapping form
|
|
6
|
+
onto the 2026 cost distribution, pick the team by exact knapsack DP,
|
|
7
|
+
and backtest leave-one-year-out against the hindsight-optimal team.
|
|
8
|
+
Run: uv run python -m tdf_predictor.fantasy
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import sys
|
|
15
|
+
import unicodedata
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
|
|
18
|
+
from tdf_predictor import tissot
|
|
19
|
+
from tdf_predictor.ingest import DATA_DIR, RAW_DIR
|
|
20
|
+
|
|
21
|
+
STAGE_DIR = RAW_DIR / "stages"
|
|
22
|
+
RIDERS_CSV = DATA_DIR / "riders.csv"
|
|
23
|
+
BUDGET = 120
|
|
24
|
+
TEAM_SIZE = 8
|
|
25
|
+
BACKTEST_YEARS = range(2021, 2026)
|
|
26
|
+
|
|
27
|
+
# Tissot squad caps (rules art 4.1.2.5 / "Building your team")
|
|
28
|
+
CATEGORY_CAPS = {
|
|
29
|
+
"Leaders": 3,
|
|
30
|
+
"All-rounders": 5,
|
|
31
|
+
"Sprinters": 3,
|
|
32
|
+
"Climbers": 3,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# NFKD drops these instead of transliterating them
|
|
37
|
+
_TRANS = str.maketrans(
|
|
38
|
+
{
|
|
39
|
+
"ß": "ss",
|
|
40
|
+
"ł": "l",
|
|
41
|
+
"Ł": "L",
|
|
42
|
+
"ø": "o",
|
|
43
|
+
"Ø": "O",
|
|
44
|
+
"đ": "d",
|
|
45
|
+
"Đ": "D",
|
|
46
|
+
"æ": "ae",
|
|
47
|
+
"Æ": "AE",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def name_key(name: str) -> frozenset:
|
|
53
|
+
"""Order/accent/case-insensitive name key.
|
|
54
|
+
|
|
55
|
+
'Tadej POGAČAR' and 'Pogačar Tadej' -> same key.
|
|
56
|
+
"""
|
|
57
|
+
s = unicodedata.normalize("NFKD", name.translate(_TRANS))
|
|
58
|
+
s = s.encode("ascii", "ignore").decode()
|
|
59
|
+
for ch in "'’":
|
|
60
|
+
s = s.replace(ch, "")
|
|
61
|
+
for ch in "-.":
|
|
62
|
+
s = s.replace(ch, " ")
|
|
63
|
+
return frozenset(s.upper().split())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def match_rider(key: frozenset, by_key: dict) -> str | None:
|
|
67
|
+
"""Exact key match, else unique-subset match.
|
|
68
|
+
|
|
69
|
+
Tissot uses full legal names ('Jonas VINGEGAARD HANSEN'); PCS
|
|
70
|
+
doesn't ('Vingegaard Jonas') — PCS key ⊆ Tissot key counts.
|
|
71
|
+
"""
|
|
72
|
+
if key in by_key:
|
|
73
|
+
return by_key[key]
|
|
74
|
+
cands = {u for k, u in by_key.items() if k <= key}
|
|
75
|
+
return cands.pop() if len(cands) == 1 else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_stage_points() -> dict:
|
|
79
|
+
"""(year, rider_url) -> summed Tissot stage points (finish + KOM +
|
|
80
|
+
jersey bonuses; see tdf_predictor.tissot)."""
|
|
81
|
+
pts = defaultdict(float)
|
|
82
|
+
for f in sorted(STAGE_DIR.glob("*.json")):
|
|
83
|
+
d = json.loads(f.read_text())
|
|
84
|
+
for url, p in tissot.stage_points(d).items():
|
|
85
|
+
pts[(d["year"], url)] += p
|
|
86
|
+
return dict(pts)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def proxy_points(rows: list[dict], stage_pts: dict) -> dict:
|
|
90
|
+
"""(year, rider_url) -> summed Tissot stage points, for riders who
|
|
91
|
+
appear in a TDF GC (i.e. started and finished). GC standing is
|
|
92
|
+
already scored per stage via the yellow-jersey bonus, so there is no
|
|
93
|
+
separate final-GC term."""
|
|
94
|
+
out = {}
|
|
95
|
+
for r in rows:
|
|
96
|
+
if r["race"] != "tour-de-france":
|
|
97
|
+
continue
|
|
98
|
+
key = (r["year"], r["rider_url"])
|
|
99
|
+
out[key] = stage_pts.get(key, 0.0)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def season_form(rows: list[dict], year: int) -> dict:
|
|
104
|
+
"""rider_url -> feature vector from results before July of year.
|
|
105
|
+
|
|
106
|
+
Last feature is freshness: previous-year pre-July race count minus
|
|
107
|
+
this year's — positive means racing less than usual, fresher legs.
|
|
108
|
+
"""
|
|
109
|
+
cutoff = f"{year}-07-01"
|
|
110
|
+
prev_cutoff = f"{year - 1}-07-01"
|
|
111
|
+
cur = defaultdict(list)
|
|
112
|
+
prev, prev_n = defaultdict(float), defaultdict(int)
|
|
113
|
+
for r in rows:
|
|
114
|
+
if r["year"] == year and r["date"] < cutoff:
|
|
115
|
+
cur[r["rider_url"]].append(r)
|
|
116
|
+
elif r["year"] == year - 1:
|
|
117
|
+
prev[r["rider_url"]] += r["pcs_points"] or 0
|
|
118
|
+
if r["date"] < prev_cutoff:
|
|
119
|
+
prev_n[r["rider_url"]] += 1
|
|
120
|
+
feats = {}
|
|
121
|
+
for u, rs in cur.items():
|
|
122
|
+
ranks = [x["rank"] for x in rs]
|
|
123
|
+
pcs = [x["pcs_points"] or 0 for x in rs]
|
|
124
|
+
gc = sum(p for x, p in zip(rs, pcs) if x["kind"] == "gc")
|
|
125
|
+
feats[u] = [
|
|
126
|
+
len(rs),
|
|
127
|
+
sum(pcs),
|
|
128
|
+
gc,
|
|
129
|
+
sum(pcs) - gc,
|
|
130
|
+
min(ranks),
|
|
131
|
+
sum(k <= 10 for k in ranks) / len(ranks),
|
|
132
|
+
prev[u],
|
|
133
|
+
prev_n[u] - len(rs),
|
|
134
|
+
]
|
|
135
|
+
return feats
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def tour_teams(year: int) -> dict:
|
|
139
|
+
"""rider_url -> team name from that year's first TDF stage file
|
|
140
|
+
(startlist proxy)."""
|
|
141
|
+
for f in sorted(STAGE_DIR.glob(f"tdf-{year}-stage*.json")):
|
|
142
|
+
d = json.loads(f.read_text())
|
|
143
|
+
return {r["rider_url"]: r["team_name"] for r in d["results"]}
|
|
144
|
+
return {}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_features(rows, actual, year, teams=None) -> dict:
|
|
148
|
+
"""Season form + team strength: teammates' previous-Tour proxy
|
|
149
|
+
points — cohesion/tactics proxy, strong squads control races."""
|
|
150
|
+
feats = season_form(rows, year)
|
|
151
|
+
teams = teams or tour_teams(year)
|
|
152
|
+
tot = defaultdict(float)
|
|
153
|
+
for u, t in teams.items():
|
|
154
|
+
tot[t] += actual.get((year - 1, u), 0.0)
|
|
155
|
+
out = {}
|
|
156
|
+
for u, f in feats.items():
|
|
157
|
+
mates = 0.0
|
|
158
|
+
if u in teams:
|
|
159
|
+
mates = tot[teams[u]] - actual.get((year - 1, u), 0.0)
|
|
160
|
+
out[u] = f + [mates]
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _solve(a: list[list[float]], b: list[float]) -> list[float]:
|
|
165
|
+
"""Gaussian elimination with partial pivoting."""
|
|
166
|
+
n = len(a)
|
|
167
|
+
m = [row[:] + [bv] for row, bv in zip(a, b)]
|
|
168
|
+
for i in range(n):
|
|
169
|
+
p = max(range(i, n), key=lambda r: abs(m[r][i]))
|
|
170
|
+
m[i], m[p] = m[p], m[i]
|
|
171
|
+
for r in range(i + 1, n):
|
|
172
|
+
f = m[r][i] / m[i][i]
|
|
173
|
+
for c in range(i, n + 1):
|
|
174
|
+
m[r][c] -= f * m[i][c]
|
|
175
|
+
x = [0.0] * n
|
|
176
|
+
for i in range(n - 1, -1, -1):
|
|
177
|
+
s = sum(m[i][c] * x[c] for c in range(i + 1, n))
|
|
178
|
+
x[i] = (m[i][n] - s) / m[i][i]
|
|
179
|
+
return x
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def fit_ridge(X, y, lam: float = 1.0):
|
|
183
|
+
"""Closed-form ridge on log1p(points); returns weight vector."""
|
|
184
|
+
# ponytail: pure-python normal equations; features stay <10 wide
|
|
185
|
+
rows = [list(r) + [1.0] for r in X]
|
|
186
|
+
n = len(rows[0])
|
|
187
|
+
yl = [math.log1p(v) for v in y]
|
|
188
|
+
a = [
|
|
189
|
+
[
|
|
190
|
+
sum(r[i] * r[j] for r in rows) + (lam if i == j else 0.0)
|
|
191
|
+
for j in range(n)
|
|
192
|
+
]
|
|
193
|
+
for i in range(n)
|
|
194
|
+
]
|
|
195
|
+
b = [sum(r[i] * v for r, v in zip(rows, yl)) for i in range(n)]
|
|
196
|
+
return _solve(a, b)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def predict_points(w, X) -> list[float]:
|
|
200
|
+
return [
|
|
201
|
+
max(math.expm1(sum(a * b for a, b in zip(list(r) + [1.0], w))), 0.0)
|
|
202
|
+
for r in X
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def synth_costs(pred: list[float], cost_pool: list[int]) -> list[int]:
|
|
207
|
+
"""Map predicted-points ranks onto the 2026 star distribution."""
|
|
208
|
+
pool = sorted(cost_pool, reverse=True)
|
|
209
|
+
order = sorted(range(len(pred)), key=lambda i: -pred[i])
|
|
210
|
+
costs = [0] * len(pred)
|
|
211
|
+
for rank, i in enumerate(order):
|
|
212
|
+
q = rank / max(len(pred) - 1, 1)
|
|
213
|
+
costs[i] = pool[round(q * (len(pool) - 1))]
|
|
214
|
+
return costs
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _cat_exact(idxs, costs, points, budget, cap):
|
|
218
|
+
"""Per-category knapsack: dp[k][b] = max points for exactly k riders
|
|
219
|
+
of this category at total cost exactly b. Returns (dp, take)."""
|
|
220
|
+
neg = float("-inf")
|
|
221
|
+
dp = [[neg] * (budget + 1) for _ in range(cap + 1)]
|
|
222
|
+
dp[0][0] = 0.0
|
|
223
|
+
take = {}
|
|
224
|
+
for pos, i in enumerate(idxs):
|
|
225
|
+
c, p = costs[i], points[i]
|
|
226
|
+
for k in range(min(cap, pos + 1), 0, -1):
|
|
227
|
+
for b in range(budget, c - 1, -1):
|
|
228
|
+
cand = dp[k - 1][b - c] + p
|
|
229
|
+
if cand > dp[k][b]:
|
|
230
|
+
dp[k][b] = cand
|
|
231
|
+
take[(pos, k, b)] = True
|
|
232
|
+
return dp, take
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def pick_team(
|
|
236
|
+
costs: list[int],
|
|
237
|
+
points: list[float],
|
|
238
|
+
categories: list[str] | None = None,
|
|
239
|
+
budget: int = BUDGET,
|
|
240
|
+
size: int = TEAM_SIZE,
|
|
241
|
+
caps: dict[str, int] | None = None,
|
|
242
|
+
) -> list[int]:
|
|
243
|
+
"""Exact 0/1 knapsack with cardinality: indices of the best team.
|
|
244
|
+
|
|
245
|
+
With `categories` (one per rider), also enforces the Tissot squad
|
|
246
|
+
caps in `caps` (default CATEGORY_CAPS): solved as per-category
|
|
247
|
+
knapsacks combined over the budget so no category exceeds its cap.
|
|
248
|
+
"""
|
|
249
|
+
if categories is None:
|
|
250
|
+
neg = float("-inf")
|
|
251
|
+
dp = [[neg] * (budget + 1) for _ in range(size + 1)]
|
|
252
|
+
dp[0][0] = 0.0
|
|
253
|
+
take = {}
|
|
254
|
+
for i, (c, p) in enumerate(zip(costs, points)):
|
|
255
|
+
for n in range(min(i + 1, size), 0, -1):
|
|
256
|
+
for b in range(budget, c - 1, -1):
|
|
257
|
+
cand = dp[n - 1][b - c] + p
|
|
258
|
+
if cand > dp[n][b]:
|
|
259
|
+
dp[n][b] = cand
|
|
260
|
+
take[(i, n, b)] = True
|
|
261
|
+
b = max(range(budget + 1), key=lambda x: dp[size][x])
|
|
262
|
+
if dp[size][b] == neg:
|
|
263
|
+
raise ValueError("no feasible team")
|
|
264
|
+
team, n = [], size
|
|
265
|
+
for i in range(len(costs) - 1, -1, -1):
|
|
266
|
+
if take.get((i, n, b)):
|
|
267
|
+
team.append(i)
|
|
268
|
+
b -= costs[i]
|
|
269
|
+
n -= 1
|
|
270
|
+
return sorted(team)
|
|
271
|
+
|
|
272
|
+
caps = caps or CATEGORY_CAPS
|
|
273
|
+
neg = float("-inf")
|
|
274
|
+
groups: dict[str, list[int]] = defaultdict(list)
|
|
275
|
+
for i, cat in enumerate(categories):
|
|
276
|
+
groups[cat].append(i)
|
|
277
|
+
tables = {
|
|
278
|
+
cat: _cat_exact(idxs, costs, points, budget, caps.get(cat, size))
|
|
279
|
+
for cat, idxs in groups.items()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
# combine categories over the budget; bp[ci][(k,b)] = (pk,pb,kc,bc)
|
|
283
|
+
cat_list = list(tables)
|
|
284
|
+
comb = [[neg] * (budget + 1) for _ in range(size + 1)]
|
|
285
|
+
comb[0][0] = 0.0
|
|
286
|
+
bp = []
|
|
287
|
+
for cat in cat_list:
|
|
288
|
+
dp, _ = tables[cat]
|
|
289
|
+
cap = caps.get(cat, size)
|
|
290
|
+
new = [[neg] * (budget + 1) for _ in range(size + 1)]
|
|
291
|
+
choice = {}
|
|
292
|
+
for k in range(size + 1):
|
|
293
|
+
for b in range(budget + 1):
|
|
294
|
+
if comb[k][b] == neg:
|
|
295
|
+
continue
|
|
296
|
+
base = comb[k][b]
|
|
297
|
+
for kc in range(min(cap, size - k) + 1):
|
|
298
|
+
for bc in range(budget - b + 1):
|
|
299
|
+
v = dp[kc][bc]
|
|
300
|
+
if v == neg:
|
|
301
|
+
continue
|
|
302
|
+
if base + v > new[k + kc][b + bc]:
|
|
303
|
+
new[k + kc][b + bc] = base + v
|
|
304
|
+
choice[(k + kc, b + bc)] = (k, b, kc, bc)
|
|
305
|
+
comb, _ = new, bp.append(choice)
|
|
306
|
+
|
|
307
|
+
b = max(range(budget + 1), key=lambda x: comb[size][x])
|
|
308
|
+
if comb[size][b] == neg:
|
|
309
|
+
raise ValueError("no feasible team")
|
|
310
|
+
team, k = [], size
|
|
311
|
+
for ci in range(len(cat_list) - 1, -1, -1):
|
|
312
|
+
pk, pb, kc, bc = bp[ci][(k, b)]
|
|
313
|
+
idxs = groups[cat_list[ci]]
|
|
314
|
+
_, take = tables[cat_list[ci]]
|
|
315
|
+
kk, bb = kc, bc
|
|
316
|
+
for pos in range(len(idxs) - 1, -1, -1):
|
|
317
|
+
if kk and take.get((pos, kk, bb)):
|
|
318
|
+
team.append(idxs[pos])
|
|
319
|
+
bb -= costs[idxs[pos]]
|
|
320
|
+
kk -= 1
|
|
321
|
+
k, b = pk, pb
|
|
322
|
+
return sorted(team)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _soft_cap(p: float, cap: float) -> float:
|
|
326
|
+
"""Compress predictions above cap instead of clipping, so
|
|
327
|
+
co-favourites keep their ordering (there can be only one)."""
|
|
328
|
+
return p if p <= cap else cap + math.log1p(p - cap)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _train_predict(rows, actual, train_years, target_year, teams=None):
|
|
332
|
+
Xtr, ytr = [], []
|
|
333
|
+
for yr in train_years:
|
|
334
|
+
feats = build_features(rows, actual, yr)
|
|
335
|
+
for u, f in feats.items():
|
|
336
|
+
if (yr, u) in actual:
|
|
337
|
+
Xtr.append(f)
|
|
338
|
+
ytr.append(actual[(yr, u)])
|
|
339
|
+
feats = build_features(rows, actual, target_year, teams)
|
|
340
|
+
urls = sorted(feats)
|
|
341
|
+
Xte = [feats[u] for u in urls]
|
|
342
|
+
preds = predict_points(fit_ridge(Xtr, ytr), Xte)
|
|
343
|
+
cap = max(ytr) # expm1 extrapolation can explode off-scale
|
|
344
|
+
return {u: _soft_cap(p, cap) for u, p in zip(urls, preds)}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def load_tissot_roster() -> list[dict]:
|
|
348
|
+
with RIDERS_CSV.open() as f:
|
|
349
|
+
return [
|
|
350
|
+
{
|
|
351
|
+
"name": r["name"],
|
|
352
|
+
"team": r["team"],
|
|
353
|
+
"category": r["category"],
|
|
354
|
+
"stars": int(r["stars"]),
|
|
355
|
+
}
|
|
356
|
+
for r in csv.DictReader(f)
|
|
357
|
+
if r["stars"].isdigit()
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def backtest() -> None: # pragma: no cover
|
|
362
|
+
from tdf_predictor.model import load_rows
|
|
363
|
+
|
|
364
|
+
rows = load_rows()
|
|
365
|
+
actual = proxy_points(rows, load_stage_points())
|
|
366
|
+
cost_pool = [r["stars"] for r in load_tissot_roster()]
|
|
367
|
+
print("year ridge optimal share random")
|
|
368
|
+
for yr in BACKTEST_YEARS:
|
|
369
|
+
train = [y for y in BACKTEST_YEARS if y != yr]
|
|
370
|
+
preds = _train_predict(rows, actual, train, yr)
|
|
371
|
+
starters = sorted(u for (y, u) in actual if y == yr and u in preds)
|
|
372
|
+
pred = [preds[u] for u in starters]
|
|
373
|
+
real = [actual[(yr, u)] for u in starters]
|
|
374
|
+
costs = synth_costs(pred, cost_pool)
|
|
375
|
+
team = pick_team(costs, pred)
|
|
376
|
+
got = sum(real[i] for i in team)
|
|
377
|
+
best = sum(real[i] for i in pick_team(costs, real))
|
|
378
|
+
rnd = sum(real) / len(real) * TEAM_SIZE
|
|
379
|
+
print(f"{yr} {got:5.0f} {best:7.0f} {got / best:5.0%} {rnd:6.0f}")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def roster_predictions() -> list[dict]: # pragma: no cover
|
|
383
|
+
"""Tissot roster with predicted 2026 TDF proxy points attached."""
|
|
384
|
+
from tdf_predictor.model import load_rows
|
|
385
|
+
|
|
386
|
+
rows = load_rows()
|
|
387
|
+
actual = proxy_points(rows, load_stage_points())
|
|
388
|
+
by_key = {}
|
|
389
|
+
for r in rows:
|
|
390
|
+
by_key.setdefault(name_key(r["rider"]), r["rider_url"])
|
|
391
|
+
|
|
392
|
+
roster = load_tissot_roster()
|
|
393
|
+
for r in roster:
|
|
394
|
+
r["url"] = match_rider(name_key(r["name"]), by_key)
|
|
395
|
+
teams = {r["url"]: r["team"] for r in roster if r["url"]}
|
|
396
|
+
preds = _train_predict(
|
|
397
|
+
rows, actual, list(BACKTEST_YEARS), 2026, teams=teams
|
|
398
|
+
)
|
|
399
|
+
# expanding-window backcast (2023-25): 0.5*ridge + 0.5*prior-TDF
|
|
400
|
+
# actual beat pure ridge every year (66/63/61% vs 65/60/45% of
|
|
401
|
+
# the hindsight-optimal team)
|
|
402
|
+
prior_year = max(BACKTEST_YEARS)
|
|
403
|
+
for r in roster:
|
|
404
|
+
base = preds.get(r["url"], 0.0) if r["url"] else 0.0
|
|
405
|
+
prior = actual.get((prior_year, r["url"]), 0.0)
|
|
406
|
+
r["pred"] = 0.5 * base + 0.5 * prior
|
|
407
|
+
return roster
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def pick_2026() -> None: # pragma: no cover
|
|
411
|
+
roster = roster_predictions()
|
|
412
|
+
points = [r["pred"] for r in roster]
|
|
413
|
+
unmatched = [r["name"] for r in roster if not r["url"]]
|
|
414
|
+
costs = [r["stars"] for r in roster]
|
|
415
|
+
cats = [r["category"] for r in roster]
|
|
416
|
+
team = pick_team(costs, points, cats)
|
|
417
|
+
|
|
418
|
+
print(
|
|
419
|
+
f"\n=== 2026 Tissot team ({TEAM_SIZE} riders, "
|
|
420
|
+
f"{sum(costs[i] for i in team)}/{BUDGET} stars) ==="
|
|
421
|
+
)
|
|
422
|
+
for i in sorted(team, key=lambda i: -points[i]):
|
|
423
|
+
r = roster[i]
|
|
424
|
+
print(
|
|
425
|
+
f"{points[i]:6.0f} {r['stars']:>2}★ "
|
|
426
|
+
f"{r['name']:<26.26} {r['category']:<12} {r['team']}"
|
|
427
|
+
)
|
|
428
|
+
print(f"expected proxy points: {sum(points[i] for i in team):.0f}")
|
|
429
|
+
if unmatched:
|
|
430
|
+
print(
|
|
431
|
+
f"({len(unmatched)} roster riders with no PCS match, "
|
|
432
|
+
f"treated as 0 pts)",
|
|
433
|
+
file=sys.stderr,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
if __name__ == "__main__":
|
|
438
|
+
backtest()
|
|
439
|
+
pick_2026()
|