FLAT_THRESHOLD_PENCE_X100 * - falling if magnitude < −FLAT_THRESHOLD_PENCE_X100 * - flat otherwise */ final class RidgeRegressionModel implements WeeklyForecastModel { private const float FLAT_THRESHOLD_PENCE_X100 = 20.0; // 0.2 p/L /** @var array|null Coefficients on standardised features (no intercept). */ private ?array $beta = null; private ?float $intercept = null; /** @var array|null per-feature mean used for standardisation */ private ?array $featureMeans = null; /** @var array|null per-feature std-dev used for standardisation */ private ?array $featureStdDevs = null; public function __construct( private readonly FeatureSpec $spec, private readonly WeeklyPumpPriceLoader $loader, public readonly float $lambda = 1.0, ) {} public function featureSpec(): FeatureSpec { return $this->spec; } public function train(array $trainingMondays): void { $X = []; $y = []; foreach ($trainingMondays as $monday) { $row = []; $skip = false; foreach ($this->spec->features as $feature) { $v = $feature->valueFor($monday); if ($v === null) { $skip = true; break; } $row[] = $v; } if ($skip) { continue; } $actual = $this->actualDeltaPence($monday); if ($actual === null) { continue; } $X[] = $row; $y[] = $actual; } if (count($X) < count($this->spec->features) + 2) { throw new RuntimeException('RidgeRegressionModel: insufficient training rows after dropping incomplete weeks'); } // Standardise X (z-score) and centre y. $featureCount = count($X[0]); $means = array_fill(0, $featureCount, 0.0); $stds = array_fill(0, $featureCount, 0.0); $n = count($X); for ($j = 0; $j < $featureCount; $j++) { $col = array_column($X, $j); $means[$j] = array_sum($col) / $n; $variance = 0.0; foreach ($col as $v) { $variance += ($v - $means[$j]) ** 2; } $variance /= $n; $stds[$j] = sqrt($variance); // Constant features get sd=1 so we don't divide by zero. Their // contribution is then a constant absorbed by the intercept. if ($stds[$j] < 1e-12) { $stds[$j] = 1.0; } } $Xstd = []; foreach ($X as $row) { $r = []; for ($j = 0; $j < $featureCount; $j++) { $r[] = ($row[$j] - $means[$j]) / $stds[$j]; } $Xstd[] = $r; } $yMean = array_sum($y) / $n; $yCentred = array_map(fn (float $v): float => $v - $yMean, $y); $this->beta = LinearAlgebra::ridgeSolve($Xstd, $yCentred, $this->lambda); $this->intercept = $yMean; $this->featureMeans = $means; $this->featureStdDevs = $stds; } public function predict(CarbonInterface $targetMonday): WeeklyPrediction { if ($this->beta === null) { throw new RuntimeException('RidgeRegressionModel: predict() called before train()'); } $row = []; foreach ($this->spec->features as $feature) { $v = $feature->valueFor($targetMonday); if ($v === null) { return new WeeklyPrediction($targetMonday, 0.0, 'flat'); } $row[] = $v; } $magnitude = $this->intercept; for ($j = 0, $jc = count($row); $j < $jc; $j++) { $z = ($row[$j] - $this->featureMeans[$j]) / $this->featureStdDevs[$j]; $magnitude += $z * $this->beta[$j]; } return new WeeklyPrediction($targetMonday, $magnitude, $this->classifyDirection($magnitude)); } public function coefficients(): ?array { if ($this->beta === null) { return null; } $named = []; foreach ($this->spec->features as $i => $feature) { $named[$feature->name()] = [ 'beta_standardised' => $this->beta[$i], 'mean' => $this->featureMeans[$i], 'std_dev' => $this->featureStdDevs[$i], ]; } return [ 'intercept' => $this->intercept, 'lambda' => $this->lambda, 'features' => $named, ]; } private function actualDeltaPence(CarbonInterface $targetMonday): ?float { $current = $this->loader->ulspPence($targetMonday->toDateString()); $previous = $this->loader->ulspPence($targetMonday->copy()->subDays(7)->toDateString()); if ($current === null || $previous === null) { return null; } return (float) ($current - $previous); } private function classifyDirection(float $magnitude): string { return match (true) { $magnitude > self::FLAT_THRESHOLD_PENCE_X100 => 'rising', $magnitude < -self::FLAT_THRESHOLD_PENCE_X100 => 'falling', default => 'flat', }; } }