Skip to content

Adding forecasting #89

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to `laravel-trend` will be documented in this file.

## 1.1.0 - 2025-04-18

- Added forecasting functionality with several methods: linear regression, moving average, weighted moving average, and exponential smoothing
- Added `forecastPeriods()` and `forecastUntil()` methods to predict future trends
- Added `isForecast` flag to TrendValue to distinguish between historical and forecasted data

## 1.0.0 - 202X-XX-XX

- initial release
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,57 @@ You can use the following aggregates:
- `min('column')`
- `count('*')`

## Forecasting

The package supports generating forecasts based on your historical trend data. Use one of the following forecasting methods:

```php
// Forecast 3 more months using linear regression
$trend = Trend::model(Order::class)
->between(
start: now()->startOfYear(),
end: now()->endOfMonth(),
)
->perMonth()
->forecastPeriods(3, 'linear')
->count();

// Forecast until a specific date using moving average
$trend = Trend::model(Order::class)
->between(
start: now()->subMonths(6),
end: now(),
)
->perMonth()
->forecastUntil(now()->addMonths(3), 'moving-average')
->sum('total');
```

### Forecasting Methods

The following forecasting methods are available:

- `linear` - Uses linear regression to predict future values (default)
- `moving-average` - Uses a simple moving average of the last 3 periods
- `weighted-moving-average` - Uses a weighted moving average that gives more weight to recent data
- `exponential-smoothing` - Uses exponential smoothing with alpha = 0.3

### Identifying Forecasted Values

Forecasted values are tagged with an `isForecast` property, which you can use to style or label them differently in your UI:

```php
$trend->each(function ($value) {
if ($value->isForecast) {
// This is a forecasted value
echo "Forecast for {$value->date}: {$value->aggregate}";
} else {
// This is a historical value
echo "Actual for {$value->date}: {$value->aggregate}";
}
});
```

## Date Column

By default, laravel-trend assumes that the model on which the operation is being performed has a `created_at` date column. If your model uses a different column name for the date or you want to use a different one, you should specify it using the `dateColumn(string $column)` method.
Expand Down
66 changes: 65 additions & 1 deletion src/Trend.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Flowframe\Trend;

use Carbon\Carbon;
use Carbon\CarbonInterface;
use Carbon\CarbonPeriod;
use Error;
Expand All @@ -22,6 +23,12 @@ class Trend
public string $dateColumn = 'created_at';

public string $dateAlias = 'date';

protected ?CarbonInterface $forecastStart = null;

protected ?CarbonInterface $forecastEnd = null;

protected string $forecastMethod = 'linear';

public function __construct(public Builder $builder)
{
Expand All @@ -44,6 +51,35 @@ public function between($start, $end): self

return $this;
}

public function forecastUntil(CarbonInterface $end, string $method = 'linear'): self
{
$this->forecastEnd = $end;
$this->forecastMethod = $method;

return $this;
}

public function forecastPeriods(int $periods, string $method = 'linear'): self
{
if (!isset($this->interval)) {
throw new Error('Interval must be set before forecasting periods.');
}

if (!isset($this->end)) {
throw new Error('End date must be set before forecasting periods.');
}

$this->forecastMethod = $method;

// The forecast will start right after the historical end date
$this->forecastStart = $this->end->copy()->add($this->interval, 1);

// Calculate the end date based on the interval and periods
$this->forecastEnd = $this->forecastStart->copy()->add($this->interval, $periods - 1);

return $this;
}

public function interval(string $interval): self
{
Expand Down Expand Up @@ -109,7 +145,35 @@ public function aggregate(string $column, string $aggregate): Collection
->orderBy($this->dateAlias)
->get();

return $this->mapValuesToDates($values);
$historicalData = $this->mapValuesToDates($values);

// If forecasting is enabled, generate and append forecast data
if ($this->forecastEnd !== null) {
$forecastStart = $this->forecastStart ?? $this->end->copy()->add($this->interval, 1);

$forecast = new TrendForecast(
$historicalData,
$this->interval,
$forecastStart,
$this->forecastEnd
);

$forecastData = $forecast->method($this->forecastMethod)->generate();

// Identify forecast data with a flag
$forecastData = $forecastData->map(function (TrendValue $value) {
return new TrendValue(
date: $value->date,
aggregate: $value->aggregate,
isForecast: true
);
});

// Merge historical and forecast data
return $historicalData->merge($forecastData);
}

return $historicalData;
}

public function average(string $column): Collection
Expand Down
200 changes: 200 additions & 0 deletions src/TrendForecast.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php

namespace Flowframe\Trend;

use Carbon\Carbon;
use Carbon\CarbonInterface;
use Carbon\CarbonPeriod;
use Illuminate\Support\Collection;

class TrendForecast
{
protected Collection $historicalData;
protected string $interval;
protected CarbonInterface $forecastStart;
protected CarbonInterface $forecastEnd;
protected string $dateFormat;
protected string $method = 'linear';

public function __construct(Collection $historicalData, string $interval, CarbonInterface $forecastStart, CarbonInterface $forecastEnd)
{
$this->historicalData = $historicalData;
$this->interval = $interval;
$this->forecastStart = $forecastStart;
$this->forecastEnd = $forecastEnd;
$this->dateFormat = $this->getCarbonDateFormat();
}

public function method(string $method): self
{
$this->method = $method;

return $this;
}

public function linear(): Collection
{
// Simple linear regression
$dataPoints = $this->historicalData->count();
if ($dataPoints <= 1) {
return $this->generateEmptyForecast();
}

// Prepare X and Y values for regression
$xValues = range(1, $dataPoints);
$yValues = $this->historicalData->pluck('aggregate')->toArray();

// Calculate means
$xMean = array_sum($xValues) / $dataPoints;
$yMean = array_sum($yValues) / $dataPoints;

// Calculate slope and intercept
$numerator = 0;
$denominator = 0;

for ($i = 0; $i < $dataPoints; $i++) {
$numerator += ($xValues[$i] - $xMean) * ($yValues[$i] - $yMean);
$denominator += pow($xValues[$i] - $xMean, 2);
}

// Avoid division by zero
if ($denominator == 0) {
$slope = 0;
} else {
$slope = $numerator / $denominator;
}

$intercept = $yMean - ($slope * $xMean);

return $this->generateForecast(function($index) use ($slope, $intercept, $dataPoints) {
// Forecast value using y = mx + b
return max(0, $intercept + $slope * ($dataPoints + $index));
});
}

public function movingAverage(int $periods = 3): Collection
{
if ($this->historicalData->count() < $periods) {
return $this->generateEmptyForecast();
}

// Get the last n periods to calculate moving average
$lastValues = $this->historicalData->pluck('aggregate')->take(-$periods)->toArray();
$average = array_sum($lastValues) / count($lastValues);

return $this->generateForecast(function() use ($average) {
return $average;
});
}

public function weightedMovingAverage(array $weights = null): Collection
{
$dataPoints = $this->historicalData->count();

// Default weights based on data points, more recent = more weight
if ($weights === null) {
$weights = [];
$totalPoints = min(5, $dataPoints);

for ($i = 1; $i <= $totalPoints; $i++) {
$weights[] = $i;
}

// Normalize weights to sum to 1
$sum = array_sum($weights);
$weights = array_map(function($w) use ($sum) {
return $w / $sum;
}, $weights);
}

if ($dataPoints < count($weights)) {
return $this->generateEmptyForecast();
}

// Get the most recent values for the calculation
$recentValues = $this->historicalData->pluck('aggregate')->take(-count($weights))->toArray();

// Calculate weighted average
$weightedSum = 0;
for ($i = 0; $i < count($weights); $i++) {
$weightedSum += $recentValues[$i] * $weights[$i];
}

return $this->generateForecast(function() use ($weightedSum) {
return $weightedSum;
});
}

public function exponentialSmoothing(float $alpha = 0.3): Collection
{
if ($this->historicalData->isEmpty()) {
return $this->generateEmptyForecast();
}

// Get last observed value
$lastValue = $this->historicalData->last()->aggregate;

// Calculate the EMA based on the historical data
$smoothedValue = $lastValue;

foreach ($this->historicalData as $dataPoint) {
$smoothedValue = $alpha * $dataPoint->aggregate + (1 - $alpha) * $smoothedValue;
}

return $this->generateForecast(function() use ($smoothedValue) {
return $smoothedValue;
});
}

public function generate(): Collection
{
return match ($this->method) {
'linear' => $this->linear(),
'moving-average' => $this->movingAverage(),
'weighted-moving-average' => $this->weightedMovingAverage(),
'exponential-smoothing' => $this->exponentialSmoothing(),
default => $this->linear(),
};
}

protected function generateEmptyForecast(): Collection
{
return $this->generateForecast(function() {
return 0;
});
}

protected function generateForecast(callable $valueGenerator): Collection
{
$period = CarbonPeriod::between(
$this->forecastStart,
$this->forecastEnd,
)->interval("1 {$this->interval}");

$forecasts = collect();
$index = 0;

foreach ($period as $date) {
$forecasts->push(new TrendValue(
date: $date->format($this->dateFormat),
aggregate: $valueGenerator($index),
));
$index++;
}

return $forecasts;
}

protected function getCarbonDateFormat(): string
{
return match ($this->interval) {
'minute' => 'Y-m-d H:i:00',
'hour' => 'Y-m-d H:00',
'day' => 'Y-m-d',
'week' => 'Y-W',
'month' => 'Y-m',
'year' => 'Y',
default => 'Y-m-d',
};
}
}
1 change: 1 addition & 0 deletions src/TrendValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class TrendValue
public function __construct(
public string $date,
public mixed $aggregate,
public bool $isForecast = false,
) {
}
}
Loading