320 lines
15 KiB
Python
320 lines
15 KiB
Python
"""Small-data, session-based top-set forecasts; no future observations in fitting.
|
||
|
||
The empirical transition model is a recency-weighted nearest-neighbour model.
|
||
It learns joint load/rep changes, including repeats and regressions, rather than
|
||
converting a fitted E1RM line into an alleged workout. See docs/forecasting.md.
|
||
"""
|
||
from datetime import date, datetime, timedelta
|
||
from functools import lru_cache
|
||
from collections import Counter
|
||
import math
|
||
|
||
import numpy as np
|
||
|
||
BASE = 101.3
|
||
REP_FACTOR = 2.67123
|
||
|
||
|
||
def e1rm(weight, reps):
|
||
return np.asarray(weight) * 100 / (BASE - REP_FACTOR * np.asarray(reps))
|
||
|
||
|
||
def sessions(dates, weights, reps):
|
||
"""One best set per date, with deterministic ties and fractional loads intact."""
|
||
by_date = {}
|
||
for day, weight, repetitions in zip(dates, weights, reps):
|
||
if isinstance(day, datetime):
|
||
day = day.date()
|
||
try:
|
||
weight, repetitions = float(weight), float(repetitions)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if (not isinstance(day, date) or not math.isfinite(weight)
|
||
or not math.isfinite(repetitions) or weight <= 0
|
||
or not repetitions.is_integer() or not 1 <= repetitions <= 20):
|
||
continue
|
||
row = (day, weight, int(repetitions))
|
||
if day not in by_date or (float(e1rm(weight, repetitions)), weight, repetitions) > (
|
||
float(e1rm(by_date[day][1], by_date[day][2])), *by_date[day][1:]):
|
||
by_date[day] = row
|
||
return tuple(sorted(by_date.values()))
|
||
|
||
|
||
def profile(rows):
|
||
"""Summarise the recent training regime without counting recovery as a PR."""
|
||
recent = rows[-24:]
|
||
weights = np.array([r[1] for r in recent])
|
||
reps = np.array([r[2] for r in recent])
|
||
gaps = np.array([(b[0] - a[0]).days for a, b in zip(recent, recent[1:])])
|
||
dw = np.diff(weights)
|
||
increases = dw[(dw > 0.01) & (dw <= weights[:-1] * 0.15)]
|
||
# Use an observed increment, not an average that invents plate sizes.
|
||
increment = Counter(np.round(increases, 3)).most_common(1)[0][0] if len(increases) else 0.0
|
||
ceiling_samples = reps[:-1][(dw > 0.01) & (dw <= weights[:-1] * 0.15)]
|
||
ceiling = int(round(float(np.median(ceiling_samples)))) if len(ceiling_samples) >= 2 else int(max(reps))
|
||
block = [rows[-1]]
|
||
for row in reversed(rows[:-1]):
|
||
if abs(row[1] - weights[-1]) > 0.01:
|
||
break
|
||
block.insert(0, row)
|
||
best, stalls, run = block[0][2], 0, 0
|
||
for row in block[1:]:
|
||
if row[2] > best:
|
||
best, run = row[2], 0
|
||
else:
|
||
stalls, run = stalls + 1, run + 1
|
||
return {
|
||
'increment': increment, 'ceiling': ceiling,
|
||
'cadence_days': max(1, int(round(float(np.median(gaps[-12:]))))),
|
||
'block_sessions': len(block), 'block_stalls': stalls, 'stall_run': run,
|
||
'block_start': block[0][0], 'block_start_reps': block[0][2],
|
||
'block_best_reps': best, 'recent_sessions': len(recent),
|
||
'recent_load_repeats': int(np.sum(abs(dw) < 0.01)),
|
||
'transitions': len(dw), 'ceiling_evidence': len(ceiling_samples),
|
||
}
|
||
|
||
|
||
def training_blocks(rows):
|
||
"""Detect layoffs online using only cadence known before each gap.
|
||
|
||
A gap exceeds three usual intervals (at least 28, at most 84 days).
|
||
Sparse starts use 42 days. The cadence restarts with each new block.
|
||
"""
|
||
blocks = []
|
||
gaps = []
|
||
for row in rows:
|
||
if not blocks:
|
||
blocks.append([row])
|
||
continue
|
||
gap = (row[0] - blocks[-1][-1][0]).days
|
||
threshold = max(28, min(84, 3 * float(np.median(gaps[-8:])))) if len(gaps) >= 3 else 42
|
||
if gap > threshold:
|
||
blocks.append([row])
|
||
gaps = []
|
||
else:
|
||
blocks[-1].append(row)
|
||
gaps.append(gap)
|
||
return tuple(tuple(block) for block in blocks)
|
||
|
||
|
||
class TransitionModel:
|
||
"""Kernel-smoothed joint transitions with an explicit repeat-set prior."""
|
||
def __init__(self, rows):
|
||
self.rows = rows
|
||
self.profile = profile(rows)
|
||
recent = rows[-32:]
|
||
w = np.array([r[1] for r in recent])
|
||
r = np.array([r[2] for r in recent])
|
||
gap = np.array([(b[0] - a[0]).days for a, b in zip(recent, recent[1:])])
|
||
# A long layoff starts a different training block, not an ordinary step.
|
||
keep = gap <= max(28, self.profile['cadence_days'] * 3)
|
||
self.source_w, self.source_r = w[:-1][keep], r[:-1][keep]
|
||
self.dw = np.diff(w)[keep]
|
||
self.dr = np.diff(r)[keep]
|
||
self.reset_r = r[1:][keep]
|
||
self.age = np.arange(len(w) - 2, -1, -1)[keep]
|
||
stall_runs, run, best, load = [], 0, int(r[0]), w[0]
|
||
for index, (weight, reps) in enumerate(zip(w[:-1], r[:-1])):
|
||
if abs(weight - load) > 0.01:
|
||
load, best, run = weight, int(reps), 0
|
||
elif reps > best:
|
||
best, run = int(reps), 0
|
||
elif index:
|
||
run += 1
|
||
stall_runs.append(run)
|
||
self.stalls = np.array(stall_runs)[keep]
|
||
|
||
def outcomes(self, weights, reps, stalls, step=1):
|
||
weights, reps, stalls = map(np.atleast_1d, (weights, reps, stalls))
|
||
# Learn from nearby rep counts, loads and stall lengths. Recent sessions
|
||
# have greater influence; the current long plateau outweighs old PRs.
|
||
distance = (abs(reps[:, None] - self.source_r) / 2
|
||
+ abs(np.log(weights[:, None] / self.source_w)) / 0.15
|
||
+ abs(np.minimum(stalls[:, None], 5) - np.minimum(self.stalls, 5)) / 4)
|
||
probabilities = np.exp(-distance) * 0.5 ** (self.age / 8)
|
||
increase = self.dw > 0.01
|
||
decrease = self.dw < -0.01
|
||
probabilities[:, increase] *= (reps >= self.profile['ceiling'])[:, None]
|
||
# Calendar horizons beyond ~12 sessions are scenarios. Damp the rate of
|
||
# further load advances rather than extrapolating beginner gains forever.
|
||
probabilities[:, increase] *= 0.98 ** max(0, step - 12)
|
||
delta_w = np.where(increase, self.profile['increment'], self.dw)
|
||
delta_w = np.maximum(delta_w, -weights[:, None] * 0.15)
|
||
out_w = np.maximum(weights[:, None] + delta_w, 0.5)
|
||
out_r = np.clip(reps[:, None] + self.dr, 1, 20)
|
||
out_r[:, increase | decrease] = np.clip(self.reset_r[increase | decrease], 1, 20)
|
||
# Rep gains below a learned ceiling, not unlimited rep inflation.
|
||
out_r = np.where((self.dw == 0) & (self.dr > 0),
|
||
np.minimum(out_r, np.maximum(reps[:, None], self.profile['ceiling'])), out_r)
|
||
probabilities = np.column_stack([np.ones(len(weights)), probabilities])
|
||
out_w = np.column_stack([weights, out_w])
|
||
out_r = np.column_stack([reps, out_r])
|
||
probabilities /= probabilities.sum(axis=1, keepdims=True)
|
||
return np.round(out_w, 3), out_r.astype(int), probabilities
|
||
|
||
def next_set(self):
|
||
w, r, p = self.outcomes(self.rows[-1][1], self.rows[-1][2], self.profile['stall_run'])
|
||
# Combine identical outcomes before choosing the most likely joint set.
|
||
outcomes = {}
|
||
for weight, reps, probability in zip(w[0], r[0], p[0]):
|
||
key = (float(weight), int(reps))
|
||
outcomes[key] = outcomes.get(key, 0) + float(probability)
|
||
ranked = sorted(outcomes.items(), key=lambda item: -item[1])
|
||
return ranked
|
||
|
||
def simulate(self, steps, paths=512):
|
||
rng = np.random.default_rng(20260906)
|
||
weights = np.full(paths, self.rows[-1][1], dtype=float)
|
||
reps = np.full(paths, self.rows[-1][2], dtype=int)
|
||
stalls = np.full(paths, self.profile['stall_run'], dtype=int)
|
||
best_reps = np.full(paths, self.profile['block_best_reps'], dtype=int)
|
||
result = [(weights.copy(), reps.copy())]
|
||
for step in range(1, steps + 1):
|
||
ow, oreps, p = self.outcomes(weights, reps, stalls, step)
|
||
choices = (rng.random(paths)[:, None] > np.cumsum(p, axis=1)).sum(axis=1)
|
||
choices = np.minimum(choices, p.shape[1] - 1)
|
||
nw, nr = ow[np.arange(paths), choices], oreps[np.arange(paths), choices]
|
||
changed = abs(nw - weights) > 0.01
|
||
stalls = np.where(changed | (nr > best_reps), 0, stalls + 1)
|
||
best_reps = np.where(changed, nr, np.maximum(best_reps, nr))
|
||
weights, reps = nw, nr
|
||
result.append((weights.copy(), reps.copy()))
|
||
return result
|
||
|
||
|
||
def benchmark(rows, method, steps=1):
|
||
"""Forecast-only baselines: repeat last set or a damped recent E1RM trend."""
|
||
weight, reps = rows[-1][1:]
|
||
if method == 'repeat':
|
||
return weight, reps
|
||
sample = rows if method == 'linear' else rows[-12:]
|
||
x = np.array([(r[0] - sample[0][0]).days for r in sample])
|
||
y = e1rm([r[1] for r in sample], [r[2] for r in sample])
|
||
slope = float(np.polyfit(x, y, 1)[0])
|
||
days = profile(rows)['cadence_days']
|
||
advance = steps if method == 'linear' else sum(0.9 ** i for i in range(1, steps + 1))
|
||
value = max(0, float(y[-1]) + slope * days * advance)
|
||
load = value * (BASE - REP_FACTOR * reps) / 100
|
||
increment = profile(rows)['increment'] or 0.5
|
||
return max(0.5, round(load / increment) * increment), reps
|
||
|
||
|
||
def backtest(rows, limit=20):
|
||
"""Expanding-window one-session holdouts. No random train/test split."""
|
||
errors = {name: [] for name in ('transitions', 'repeat', 'damped', 'linear')}
|
||
for end in range(max(8, len(rows) - limit), len(rows)):
|
||
train, actual = rows[:end], rows[end]
|
||
if (actual[0] - train[-1][0]).days > max(28, profile(train)['cadence_days'] * 3):
|
||
continue
|
||
predicted = {'transitions': TransitionModel(train).next_set()[0][0]}
|
||
predicted.update({name: benchmark(train, name) for name in errors if name != 'transitions'})
|
||
for name, (weight, reps) in predicted.items():
|
||
errors[name].append((abs(weight - actual[1]), abs(reps - actual[2]),
|
||
abs(float(e1rm(weight, reps) - e1rm(actual[1], actual[2])))))
|
||
return {name: {'count': len(values),
|
||
'weight_mae': round(float(np.mean(values, axis=0)[0]), 2),
|
||
'reps_mae': round(float(np.mean(values, axis=0)[1]), 2),
|
||
'e1rm_mae': round(float(np.mean(values, axis=0)[2]), 2),
|
||
'error_p80': round(float(np.quantile(np.array(values)[:, 2], .8)), 2)}
|
||
for name, values in errors.items() if values}
|
||
|
||
|
||
def _point(day, weight, reps, lower, upper, session_index):
|
||
return {'date': day, 'value': round(float(e1rm(weight, reps)), 1),
|
||
'lower': round(float(lower), 1), 'upper': round(float(upper), 1),
|
||
'projected_weight': float(weight), 'projected_repetitions': int(reps),
|
||
'set_display': f'{weight:g} kg × {int(reps)}', 'session': session_index}
|
||
|
||
|
||
def representative_set(weights, reps):
|
||
"""Median load, then conditional median reps at that load.
|
||
|
||
Picking by E1RM alone can jump between distant load/rep combinations with
|
||
almost identical strength. Use the actual joint load/rep distribution.
|
||
"""
|
||
loads = np.unique(weights)
|
||
weight = float(loads[np.argmin(abs(loads - np.median(weights)))])
|
||
conditional = reps[weights == weight]
|
||
observed_reps = np.unique(conditional)
|
||
repetitions = int(observed_reps[np.argmin(abs(observed_reps - np.median(conditional)))])
|
||
return weight, repetitions
|
||
|
||
|
||
@lru_cache(maxsize=128)
|
||
def _forecast(rows, horizon_weeks):
|
||
p = profile(rows)
|
||
model = TransitionModel(rows)
|
||
cadence = p['cadence_days']
|
||
steps = math.ceil(horizon_weeks * 7 / cadence)
|
||
simulations = model.simulate(steps)
|
||
validation = backtest(rows)
|
||
# Keep the displayed scenario band at least as broad as recent held-out
|
||
# errors. This is an empirical safeguard, not a calibrated 80% guarantee.
|
||
error_floor = validation.get('transitions', {}).get('error_p80', 0)
|
||
points = []
|
||
for index, (weights, reps) in enumerate(simulations):
|
||
if index * cadence > horizon_weeks * 7:
|
||
break
|
||
values = e1rm(weights, reps)
|
||
lower, median, upper = np.quantile(values, [0.1, 0.5, 0.9])
|
||
if index:
|
||
lower, upper = max(0, min(lower, median - error_floor)), max(upper, median + error_floor)
|
||
weight, repetitions = representative_set(weights, reps)
|
||
points.append(_point(rows[-1][0] + timedelta(days=index * cadence), weight, repetitions,
|
||
min(lower, float(e1rm(weight, repetitions))),
|
||
max(upper, float(e1rm(weight, repetitions))), index))
|
||
# Extend the chart to the selected calendar horizon without inventing a
|
||
# workout between the learned session dates.
|
||
end_date = rows[-1][0] + timedelta(weeks=horizon_weeks)
|
||
chart_points = list(points)
|
||
if points[-1]['date'] != end_date:
|
||
chart_points.append(dict(points[-1], date=end_date))
|
||
ranking = model.next_set()
|
||
(weight, repetitions), probability = ranking[0]
|
||
next_values = e1rm(simulations[1][0], simulations[1][1])
|
||
next_point = _point(rows[-1][0] + timedelta(days=cadence), weight, repetitions,
|
||
min(np.quantile(next_values, .1), e1rm(weight, repetitions)),
|
||
max(np.quantile(next_values, .9), e1rm(weight, repetitions)), 1)
|
||
next_point['lower'] = round(max(0, min(next_point['lower'], next_point['value'] - error_floor)), 1)
|
||
next_point['upper'] = round(max(next_point['upper'], next_point['value'] + error_floor), 1)
|
||
if len(points) > 1:
|
||
points[1].update(next_point)
|
||
return {
|
||
'available': True, 'metric': 'E1RM', 'model': 'transitions',
|
||
'horizon_weeks': horizon_weeks, 'observed_points': len(rows),
|
||
'last_date': rows[-1][0], 'last_value': points[0]['value'],
|
||
'projected_value': points[-1]['value'],
|
||
'projected_change': round(points[-1]['value'] - points[0]['value'], 1),
|
||
'weekly_change': round((points[-1]['value'] - points[0]['value']) / horizon_weeks, 1),
|
||
'end_date': end_date, 'points': chart_points, 'session_points': points[1:],
|
||
'next_session': next_point, 'profile': p, 'validation': validation,
|
||
'alternatives': [{'weight': w, 'reps': r, 'probability': round(prob * 100)}
|
||
for (w, r), prob in ranking[:3]],
|
||
'long_range': horizon_weeks > 12,
|
||
}
|
||
|
||
|
||
def build_forecast(values, dates, horizon_weeks=8, metric='E1RM', weights=None, repetitions=None):
|
||
# Copy the cached result because the rendering layer adds equivalent sets.
|
||
from copy import deepcopy
|
||
try:
|
||
horizon_weeks = max(4, min(52, int(horizon_weeks)))
|
||
except (TypeError, ValueError):
|
||
horizon_weeks = 8
|
||
rows = sessions(dates, weights if weights is not None else [],
|
||
repetitions if repetitions is not None else [])
|
||
if len(rows) < 4:
|
||
return {'available': False, 'reason': 'Add at least 4 distinct workout dates with valid sets (1–20 reps).'}
|
||
dated = [d.date() if isinstance(d, datetime) else d for d in dates if isinstance(d, date)]
|
||
if dated and max(dated) > rows[-1][0]:
|
||
return {'available': False, 'reason': 'The latest reading needs a positive load and 1–20 reps for this forecast.'}
|
||
blocks = training_blocks(rows)
|
||
rows = blocks[-1]
|
||
if len(rows) < 4:
|
||
return {'available': False, 'reason': 'Add 4 sessions after the recent training break to learn the new pattern.'}
|
||
result = deepcopy(_forecast(rows, horizon_weeks))
|
||
result['training_blocks'] = len(blocks)
|
||
result['training_start'] = rows[0][0]
|
||
result['stale'] = (date.today() - rows[-1][0]).days > max(28, result['profile']['cadence_days'] * 3)
|
||
return result
|