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
 | |
| }
 | |
| 
 |