Skip to content

Commit 74a48f3

Browse files
committed
Use damped linear-trend prior for forecast historical samples
1 parent e27b4c9 commit 74a48f3

2 files changed

Lines changed: 162 additions & 9 deletions

File tree

plugins/CoreVisualizations/JqplotDataGenerator/ForecastBuilder.php

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ class ForecastBuilder
3737
{
3838
private const MIN_FORECAST_RATIO = 0.05;
3939

40+
/**
41+
* Damping factor applied to the linear-trend projection of the historical prior.
42+
* 1.0 = full least-squares extrapolation (most responsive to trends, most prone to
43+
* overshoot on noisy ratios). 0.0 = no projection (flat mean). 0.5 keeps the regression
44+
* line's fit on the historical samples but only takes a half-step in the slope direction
45+
* for the next-period forecast — empirically a good tradeoff between catching growth on
46+
* count series and not amplifying noise on volatile averages.
47+
*/
48+
private const TREND_DAMPING = 0.5;
49+
4050
/**
4151
* @param array<string, array<int, float|int>> $allSeriesData
4252
* @param array<DataTable> $dataTables
@@ -156,7 +166,7 @@ public function build(
156166
* has no positive value so a synthetic 0 does not collapse the linear seed to zero. When the
157167
* current tick is the first incomplete tick (no previous forecast) and historical priors
158168
* exist, the blend would otherwise dilute the prior with a meaningless zero, so it falls back
159-
* to the prior mean directly.
169+
* to the prior directly. The prior itself is trend-aware via computeHistoricalPrior.
160170
*
161171
* @param array<int, float> $pastValues
162172
*/
@@ -175,7 +185,7 @@ private function buildMonotonicForecastValue(
175185
$priorForecast = $linearForecast;
176186

177187
if ([] !== $pastValues) {
178-
$priorForecast = array_sum($pastValues) / count($pastValues);
188+
$priorForecast = $this->computeHistoricalPrior($pastValues);
179189
}
180190

181191
$weight = $this->getPriorForecastWeight(count($pastValues), $this->getPeriodLabel($dataTable));
@@ -197,15 +207,16 @@ private function buildMonotonicForecastValue(
197207

198208
/**
199209
* Forecast for non-monotonic series (ratios, averages, latency-style). Returns the historical
200-
* same-period prior, falling back to the previous forecast if there is no prior. Returns null
201-
* when neither signal is available because no defensible value can be produced.
210+
* same-period prior (trend-aware via computeHistoricalPrior), falling back to the previous
211+
* forecast if there is no prior. Returns null when neither signal is available because no
212+
* defensible value can be produced.
202213
*
203214
* @param array<int, float> $pastValues
204215
*/
205216
private function buildNonMonotonicForecastValue(array $pastValues, ?float $previousForecastValue): ?float
206217
{
207218
if ([] !== $pastValues) {
208-
return array_sum($pastValues) / count($pastValues);
219+
return $this->computeHistoricalPrior($pastValues);
209220
}
210221

211222
if ($previousForecastValue !== null) {
@@ -215,6 +226,50 @@ private function buildNonMonotonicForecastValue(array $pastValues, ?float $previ
215226
return null;
216227
}
217228

229+
/**
230+
* Same-period historical prior used by both forecast paths. With fewer than two samples
231+
* there is no slope to fit and the only signal is the single value (or a flat mean).
232+
* With two or more samples we apply a least-squares linear-trend extrapolation projected
233+
* one step forward, then dampen the projection by TREND_DAMPING so noisy ratios do not
234+
* runaway-extrapolate from a spurious slope. Catching sustained growth or decline that a
235+
* flat mean would systematically lag is the win; the damping is what keeps that win from
236+
* becoming a loss on volatile averages. The result is clamped to >= 0 because every metric
237+
* the builder serves (counts, percentages, durations) is non-negative; a negative trend
238+
* extrapolation past zero is never a defensible forecast.
239+
*
240+
* @param array<int, float> $pastValues Same-period historical samples in temporal order
241+
* (oldest first), already filtered and stripped of leading zeros by the caller.
242+
*/
243+
private function computeHistoricalPrior(array $pastValues): float
244+
{
245+
$sampleCount = count($pastValues);
246+
if ($sampleCount < 2) {
247+
return max(0.0, (float) $pastValues[0]);
248+
}
249+
250+
$sumX = $sampleCount * ($sampleCount + 1) / 2;
251+
$sumY = array_sum($pastValues);
252+
$sumXX = 0.0;
253+
$sumXY = 0.0;
254+
for ($i = 0; $i < $sampleCount; ++$i) {
255+
$x = $i + 1;
256+
$sumXX += $x * $x;
257+
$sumXY += $x * $pastValues[$i];
258+
}
259+
260+
$denominator = $sampleCount * $sumXX - $sumX * $sumX;
261+
if ($denominator <= 0.0) {
262+
return max(0.0, $sumY / $sampleCount);
263+
}
264+
265+
$slope = ($sampleCount * $sumXY - $sumX * $sumY) / $denominator;
266+
$intercept = ($sumY - $slope * $sumX) / $sampleCount;
267+
268+
// Equivalent to projecting from the regressed value at x=sampleCount and taking a
269+
// fractional step in the slope direction: y(n) + damping * slope.
270+
return max(0.0, $intercept + $slope * ($sampleCount + self::TREND_DAMPING));
271+
}
272+
218273
/**
219274
* @param array<int, float|int> $seriesData
220275
* @param array<int, DataTable> $dataTableList

plugins/CoreVisualizations/tests/Unit/JqplotDataGenerator/ForecastBuilderTest.php

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,15 +289,17 @@ public function testBuildReusesForecastAsSyntheticDataForLaterNoDataDaysAndRecal
289289
self::assertSame([[null, null, null, null, 47.9996, 58.3997, 74.7198, 59.9994]], $forecastData);
290290
}
291291

292-
public function testBuildMonotonicReturnsFullPriorWhenIncompleteTickHasNoData(): void
292+
public function testBuildMonotonicReturnsTrendAwarePriorWhenIncompleteTickHasNoData(): void
293293
{
294294
$site = $this->createSiteMock();
295295

296296
// The "today" tick exists in the date range but has no archived data yet — currentValue
297297
// is 0 and there is no earlier incomplete tick to carry a forecast forward from. The
298298
// blend would otherwise dilute the historical prior with a meaningless zero; the builder
299-
// must return the full prior mean instead. Apr 3/10/17/24 are all Fridays so the daily
300-
// weekday filter keeps every prior tick in the sample.
299+
// falls back to computeHistoricalPrior. With three same-weekday priors [80, 100, 60] the
300+
// least-squares fit gives slope = -10, intercept = 100. The damped projection at
301+
// x = 3 + TREND_DAMPING (0.5) is 100 + (-10) * 3.5 = 65. Apr 3/10/17/24 are all Fridays
302+
// so the daily weekday filter keeps every prior tick in the sample.
301303
$dataTables = [
302304
$this->createDataTableForDay('2026-04-03', $site),
303305
$this->createDataTableForDay('2026-04-10', $site),
@@ -317,7 +319,103 @@ public function testBuildMonotonicReturnsFullPriorWhenIncompleteTickHasNoData():
317319
['Visits' => false]
318320
);
319321

320-
self::assertSame([[null, null, null, 80.0]], $forecastData);
322+
self::assertSame([[null, null, null, 65.0]], $forecastData);
323+
}
324+
325+
public function testBuildAppliesDampedLinearTrendOnMultiSamplePriors(): void
326+
{
327+
$site = $this->createSiteMock();
328+
329+
// Five consecutive Fridays so the daily weekday filter keeps all four priors. Priors
330+
// [100, 120, 140, 160] form a clean linear trend: slope = 20, intercept = 80, regressed
331+
// value at x=4 = 160. The damped projection adds TREND_DAMPING * slope to that anchor:
332+
// 160 + 0.5 * 20 = 170. A flat-mean predictor would have returned 130, so the test pins
333+
// the trend-aware behaviour on a realistic four-week history.
334+
$dataTables = [
335+
$this->createDataTableForDay('2026-04-03', $site),
336+
$this->createDataTableForDay('2026-04-10', $site),
337+
$this->createDataTableForDay('2026-04-17', $site),
338+
$this->createDataTableForDay('2026-04-24', $site),
339+
$this->createDataTableForDay('2026-05-01', $site, '2026-05-01 00:00:00'),
340+
];
341+
342+
$forecastData = (new ForecastBuilder())->build(
343+
['Visits' => [100.0, 120.0, 140.0, 160.0, 0.0]],
344+
$dataTables,
345+
[
346+
ArchiveState::COMPLETE,
347+
ArchiveState::COMPLETE,
348+
ArchiveState::COMPLETE,
349+
ArchiveState::COMPLETE,
350+
ArchiveState::INCOMPLETE,
351+
],
352+
['Visits' => false]
353+
);
354+
355+
self::assertSame([[null, null, null, null, 170.0]], $forecastData);
356+
}
357+
358+
public function testBuildPriorReducesToMeanWhenTrendIsFlat(): void
359+
{
360+
$site = $this->createSiteMock();
361+
362+
// With identical priors the least-squares slope is zero, so the damped projection
363+
// reduces exactly to the historical mean. This guards against accidental drift the
364+
// trend formulation might introduce on stable series.
365+
$dataTables = [
366+
$this->createDataTableForDay('2026-04-03', $site),
367+
$this->createDataTableForDay('2026-04-10', $site),
368+
$this->createDataTableForDay('2026-04-17', $site),
369+
$this->createDataTableForDay('2026-04-24', $site),
370+
$this->createDataTableForDay('2026-05-01', $site, '2026-05-01 00:00:00'),
371+
];
372+
373+
$forecastData = (new ForecastBuilder())->build(
374+
['Visits' => [50.0, 50.0, 50.0, 50.0, 0.0]],
375+
$dataTables,
376+
[
377+
ArchiveState::COMPLETE,
378+
ArchiveState::COMPLETE,
379+
ArchiveState::COMPLETE,
380+
ArchiveState::COMPLETE,
381+
ArchiveState::INCOMPLETE,
382+
],
383+
['Visits' => false]
384+
);
385+
386+
self::assertSame([[null, null, null, null, 50.0]], $forecastData);
387+
}
388+
389+
public function testBuildClampsNegativeTrendExtrapolationToZero(): void
390+
{
391+
$site = $this->createSiteMock();
392+
393+
// A steeply collapsing prior series produces a damped extrapolation below zero
394+
// (slope = -32.5, intercept = 122.5, damped projection at x=4.5 = -23.75). Counts and
395+
// percentages are non-negative by construction, so the forecast clamps to 0 rather
396+
// than rendering a meaningless negative value on the chart.
397+
$dataTables = [
398+
$this->createDataTableForDay('2026-04-03', $site),
399+
$this->createDataTableForDay('2026-04-10', $site),
400+
$this->createDataTableForDay('2026-04-17', $site),
401+
$this->createDataTableForDay('2026-04-24', $site),
402+
$this->createDataTableForDay('2026-05-01', $site, '2026-05-01 00:00:00'),
403+
];
404+
405+
$forecastData = (new ForecastBuilder())->build(
406+
['Visits' => [100.0, 50.0, 10.0, 5.0, 0.0]],
407+
$dataTables,
408+
[
409+
ArchiveState::COMPLETE,
410+
ArchiveState::COMPLETE,
411+
ArchiveState::COMPLETE,
412+
ArchiveState::COMPLETE,
413+
ArchiveState::INCOMPLETE,
414+
],
415+
['Visits' => false]
416+
);
417+
418+
self::assertSame([[null, null, null, null, 0.0]], $forecastData);
321419
}
322420

323421
public function testBuildDoesNotCarryForwardAcrossSuppressedForecastGap(): void

0 commit comments

Comments
 (0)