Skip to content

Testing forecast accuracy ​

How good are your forecasts? The honest way to find out is to pretend it's the past: fit the model on older data, forecast the next few weeks, and compare against what actually happened. Repeat this at several points in time and average the errors. This is called cross-validation.

Verified against Python Prophet

Cross-validation forecasts match Python Prophet 1.4.0 on all three trend types. The cross-validation benchmarks show every case side by side.

ts
import { Effect } from "effect";
import { crossValidate, performanceMetrics, prophetFittingBackendLayer } from "effect-prophet";

const dayMs = 86_400_000;

const date = (day: number) => new Date(Date.UTC(2025, 0, 1 + day)).toISOString();

const history = Array.from({ length: 120 }, (_, day) => ({
  timestamp: date(day),
  value: 100 + day * 0.5 + 10 * Math.sin((2 * Math.PI * day) / 7) + 3 * Math.sin(day * 12.9898),
}));

const report = await Effect.runPromise(
  Effect.gen(function* () {
    // Train on the first 60 days, forecast the next 14, step forward and repeat.
    const result = yield* crossValidate(
      history,
      {},
      {
        horizonMs: 14 * dayMs,
        cutoffs: { mode: "generated", initialMs: 60 * dayMs, periodMs: 14 * dayMs },
      },
    );

    console.log(`${result.folds.length} rounds, ${result.rows.length} forecasts checked`);

    return yield* performanceMetrics(result, {
      metrics: ["mae", "rmse", "mape"],
      aggregation: { kind: "overall" },
    });
  }).pipe(Effect.provide(prophetFittingBackendLayer)),
);

if (report.kind === "overall") {
  for (const score of report.bucket.scores) {
    console.log(score.metric, score.value.toFixed(3));
  }
}
txt
4 rounds, 56 forecasts checked
mae 1.882
rmse 2.102
mape 0.013

Setting up the rounds ​

The third argument describes the rounds:

SettingMeaning
horizonMsHow far ahead each round forecasts
initialMsHow much history the first round trains on. Defaults to three times the horizon, or longer if needed to cover your longest seasonal pattern.
periodMsHow far apart the rounds start. Defaults to half the horizon.

All durations are in milliseconds. Multiply days by 86_400_000.

To choose the exact dates yourself, use cutoffs: { mode: "explicit", timestamps: [...] }.

The scores ​

MetricWhat it measures
maeAverage size of the error, in your data's units
rmseLike mae, but big misses count extra
msermse squared
mapeAverage error as a fraction of the actual value (0.05 = 5%)
mdapeLike mape, but the median, so a few bad days matter less
smapeA version of mape that treats over- and under-forecasting evenly
coverageHow often the actual value fell inside the uncertainty range

coverage needs uncertainty ranges. Pass { mode: "intervals", uncertainty: { seed: 1 } } as a fourth argument to crossValidate.

You can also group scores by how far ahead the forecast was, with aggregation: { kind: "horizons" }, to see how accuracy drops off over time.

Is that good? ​

A score on its own is hard to judge. Compare it with simple rules of thumb on the same days. See Choosing settings.

Inspired by Prophet from Meta. Not affiliated with Meta.