314 lines
8.6 KiB
Go
314 lines
8.6 KiB
Go
package review
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
|
|
"gonum.org/v1/gonum/optimize"
|
|
)
|
|
|
|
// Estimator encapsulates a fitted model and exposes estimation methods.
|
|
type Estimator interface {
|
|
Estimate1RM() float64
|
|
EstimateReps(targetWeight float64) float64
|
|
EstimateMaxWeight(nReps float64) float64
|
|
ModelType() string
|
|
Params() []float64
|
|
}
|
|
|
|
// Supported model types
|
|
const (
|
|
ModelPowerLaw = "powerlaw"
|
|
ModelLinear = "linear"
|
|
ModelExponential = "exponential"
|
|
)
|
|
|
|
// FitOption is a functional option for configuring the Fit process.
|
|
type FitOption func(*fitConfig)
|
|
|
|
// fitConfig holds configuration for fitting.
|
|
type fitConfig struct {
|
|
modelTypes []string
|
|
halfLifeDays float64
|
|
}
|
|
|
|
// WithModel specifies which model(s) to fit. If multiple, Fit selects the best.
|
|
func WithModel(models ...string) FitOption {
|
|
return func(cfg *fitConfig) {
|
|
cfg.modelTypes = models
|
|
}
|
|
}
|
|
|
|
// WithAllModels configures Fit to try all built-in model types.
|
|
func WithAllModels() FitOption {
|
|
return func(cfg *fitConfig) {
|
|
cfg.modelTypes = []string{ModelPowerLaw, ModelLinear, ModelExponential}
|
|
}
|
|
}
|
|
|
|
// WithHalfLife sets the half-life (in days) for time weighting.
|
|
func WithHalfLife(days float64) FitOption {
|
|
return func(cfg *fitConfig) {
|
|
cfg.halfLifeDays = days
|
|
}
|
|
}
|
|
|
|
// WithModelSelection is an alias for WithModel, for clarity.
|
|
func WithModelSelection(models []string) FitOption {
|
|
return WithModel(models...)
|
|
}
|
|
|
|
// Default settings
|
|
const defaultHalfLife = 30.0
|
|
var defaultModelTypes = []string{ModelPowerLaw}
|
|
|
|
// Fit fits the specified model(s) to the data and returns an Estimator.
|
|
// If multiple models are specified, Fit selects the best based on residual sum of squares.
|
|
func Fit(weight, reps []float64, dates []time.Time, opts ...FitOption) (Estimator, error) {
|
|
if len(weight) != len(reps) || len(weight) != len(dates) {
|
|
return nil, errors.New("weight, reps, and dates must have the same length")
|
|
}
|
|
if len(weight) < 2 {
|
|
return nil, errors.New("at least two data points are required")
|
|
}
|
|
|
|
// Apply options
|
|
cfg := &fitConfig{
|
|
modelTypes: defaultModelTypes,
|
|
halfLifeDays: defaultHalfLife,
|
|
}
|
|
for _, opt := range opts {
|
|
opt(cfg)
|
|
}
|
|
if len(cfg.modelTypes) == 0 {
|
|
cfg.modelTypes = defaultModelTypes
|
|
}
|
|
|
|
// Fit each model and select the best (lowest residual)
|
|
var best Estimator
|
|
var bestResidual float64 = math.Inf(1)
|
|
now := time.Now()
|
|
|
|
for _, model := range cfg.modelTypes {
|
|
var est Estimator
|
|
var residual float64
|
|
var err error
|
|
|
|
switch model {
|
|
case ModelPowerLaw:
|
|
est, residual, err = fitPowerLaw(weight, reps, dates, now, cfg.halfLifeDays)
|
|
case ModelLinear:
|
|
est, residual, err = fitLinear(weight, reps, dates, now, cfg.halfLifeDays)
|
|
case ModelExponential:
|
|
est, residual, err = fitExponential(weight, reps, dates, now, cfg.halfLifeDays)
|
|
default:
|
|
return nil, fmt.Errorf("unknown model type: %s", model)
|
|
}
|
|
if err != nil {
|
|
continue // Skip models that fail to fit
|
|
}
|
|
if residual < bestResidual {
|
|
best = est
|
|
bestResidual = residual
|
|
}
|
|
}
|
|
if best == nil {
|
|
return nil, errors.New("no model could be fitted to the data")
|
|
}
|
|
return best, nil
|
|
}
|
|
|
|
// --- Model Implementations ---
|
|
|
|
// PowerLawEstimator: w = a * reps^b
|
|
type PowerLawEstimator struct {
|
|
a, b float64
|
|
halfLife float64
|
|
modelType string
|
|
residualSum float64
|
|
}
|
|
|
|
func (e *PowerLawEstimator) Estimate1RM() float64 {
|
|
return e.a * math.Pow(1, e.b)
|
|
}
|
|
func (e *PowerLawEstimator) EstimateReps(targetWeight float64) float64 {
|
|
if e.a == 0 || e.b == 0 {
|
|
return 0
|
|
}
|
|
return math.Pow(targetWeight/e.a, 1/e.b)
|
|
}
|
|
func (e *PowerLawEstimator) EstimateMaxWeight(nReps float64) float64 {
|
|
return e.a * math.Pow(nReps, e.b)
|
|
}
|
|
func (e *PowerLawEstimator) ModelType() string { return e.modelType }
|
|
func (e *PowerLawEstimator) Params() []float64 { return []float64{e.a, e.b} }
|
|
|
|
// LinearEstimator: w = a + b*reps
|
|
type LinearEstimator struct {
|
|
a, b float64
|
|
halfLife float64
|
|
modelType string
|
|
residualSum float64
|
|
}
|
|
|
|
func (e *LinearEstimator) Estimate1RM() float64 {
|
|
return e.a + e.b*1
|
|
}
|
|
func (e *LinearEstimator) EstimateReps(targetWeight float64) float64 {
|
|
if e.b == 0 {
|
|
return 0
|
|
}
|
|
return (targetWeight - e.a) / e.b
|
|
}
|
|
func (e *LinearEstimator) EstimateMaxWeight(nReps float64) float64 {
|
|
return e.a + e.b*nReps
|
|
}
|
|
func (e *LinearEstimator) ModelType() string { return e.modelType }
|
|
func (e *LinearEstimator) Params() []float64 { return []float64{e.a, e.b} }
|
|
|
|
// ExponentialEstimator: w = a * exp(b * reps)
|
|
type ExponentialEstimator struct {
|
|
a, b float64
|
|
halfLife float64
|
|
modelType string
|
|
residualSum float64
|
|
}
|
|
|
|
func (e *ExponentialEstimator) Estimate1RM() float64 {
|
|
return e.a * math.Exp(e.b*1)
|
|
}
|
|
func (e *ExponentialEstimator) EstimateReps(targetWeight float64) float64 {
|
|
if e.a == 0 || e.b == 0 {
|
|
return 0
|
|
}
|
|
return math.Log(targetWeight/e.a) / e.b
|
|
}
|
|
func (e *ExponentialEstimator) EstimateMaxWeight(nReps float64) float64 {
|
|
return e.a * math.Exp(e.b*nReps)
|
|
}
|
|
func (e *ExponentialEstimator) ModelType() string { return e.modelType }
|
|
func (e *ExponentialEstimator) Params() []float64 { return []float64{e.a, e.b} }
|
|
|
|
// --- Fitting Functions ---
|
|
|
|
// fitPowerLaw fits w = a * reps^b
|
|
func fitPowerLaw(weight, reps []float64, dates []time.Time, now time.Time, halfLifeDays float64) (Estimator, float64, error) {
|
|
params := []float64{max(weight), -0.1}
|
|
problem := optimize.Problem{
|
|
Func: func(x []float64) float64 {
|
|
return weightedResidualsPowerLaw(x, weight, reps, dates, now, halfLifeDays)
|
|
},
|
|
}
|
|
result, err := optimize.Minimize(problem, params, nil, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
residual := weightedResidualsPowerLaw(result.X, weight, reps, dates, now, halfLifeDays)
|
|
return &PowerLawEstimator{
|
|
a: result.X[0],
|
|
b: result.X[1],
|
|
halfLife: halfLifeDays,
|
|
modelType: ModelPowerLaw,
|
|
residualSum: residual,
|
|
}, residual, nil
|
|
}
|
|
|
|
func weightedResidualsPowerLaw(params, weight, reps []float64, dates []time.Time, now time.Time, halfLifeDays float64) float64 {
|
|
a, b := params[0], params[1]
|
|
var sum float64
|
|
for i := range weight {
|
|
daysAgo := now.Sub(dates[i]).Hours() / 24
|
|
weightDecay := math.Exp(-math.Ln2 * daysAgo / halfLifeDays)
|
|
predicted := a * math.Pow(reps[i], b)
|
|
residual := weight[i] - predicted
|
|
sum += weightDecay * residual * residual
|
|
}
|
|
return sum
|
|
}
|
|
|
|
// fitLinear fits w = a + b*reps
|
|
func fitLinear(weight, reps []float64, dates []time.Time, now time.Time, halfLifeDays float64) (Estimator, float64, error) {
|
|
params := []float64{weight[0], 0.0}
|
|
problem := optimize.Problem{
|
|
Func: func(x []float64) float64 {
|
|
return weightedResidualsLinear(x, weight, reps, dates, now, halfLifeDays)
|
|
},
|
|
}
|
|
result, err := optimize.Minimize(problem, params, nil, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
residual := weightedResidualsLinear(result.X, weight, reps, dates, now, halfLifeDays)
|
|
return &LinearEstimator{
|
|
a: result.X[0],
|
|
b: result.X[1],
|
|
halfLife: halfLifeDays,
|
|
modelType: ModelLinear,
|
|
residualSum: residual,
|
|
}, residual, nil
|
|
}
|
|
|
|
func weightedResidualsLinear(params, weight, reps []float64, dates []time.Time, now time.Time, halfLifeDays float64) float64 {
|
|
a, b := params[0], params[1]
|
|
var sum float64
|
|
for i := range weight {
|
|
daysAgo := now.Sub(dates[i]).Hours() / 24
|
|
weightDecay := math.Exp(-math.Ln2 * daysAgo / halfLifeDays)
|
|
predicted := a + b*reps[i]
|
|
residual := weight[i] - predicted
|
|
sum += weightDecay * residual * residual
|
|
}
|
|
return sum
|
|
}
|
|
|
|
// fitExponential fits w = a * exp(b*reps)
|
|
func fitExponential(weight, reps []float64, dates []time.Time, now time.Time, halfLifeDays float64) (Estimator, float64, error) {
|
|
params := []float64{max(weight), -0.01}
|
|
problem := optimize.Problem{
|
|
Func: func(x []float64) float64 {
|
|
return weightedResidualsExponential(x, weight, reps, dates, now, halfLifeDays)
|
|
},
|
|
}
|
|
result, err := optimize.Minimize(problem, params, nil, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
residual := weightedResidualsExponential(result.X, weight, reps, dates, now, halfLifeDays)
|
|
return &ExponentialEstimator{
|
|
a: result.X[0],
|
|
b: result.X[1],
|
|
halfLife: halfLifeDays,
|
|
modelType: ModelExponential,
|
|
residualSum: residual,
|
|
}, residual, nil
|
|
}
|
|
|
|
func weightedResidualsExponential(params, weight, reps []float64, dates []time.Time, now time.Time, halfLifeDays float64) float64 {
|
|
a, b := params[0], params[1]
|
|
var sum float64
|
|
for i := range weight {
|
|
daysAgo := now.Sub(dates[i]).Hours() / 24
|
|
weightDecay := math.Exp(-math.Ln2 * daysAgo / halfLifeDays)
|
|
predicted := a * math.Exp(b*reps[i])
|
|
residual := weight[i] - predicted
|
|
sum += weightDecay * residual * residual
|
|
}
|
|
return sum
|
|
}
|
|
|
|
// --- Utility Functions ---
|
|
|
|
// max returns the maximum value in a slice
|
|
func max(slice []float64) float64 {
|
|
m := slice[0]
|
|
for _, v := range slice {
|
|
if v > m {
|
|
m = v
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|