Learn session forecasts from training blocks and clarify progress UI
This commit is contained in:
+319
@@ -0,0 +1,319 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user