Skip to content

Uncertainty ranges ​

A single number is never the whole story. predictWithIntervals gives each forecast a range: "between 133 and 140, most likely around 136".

Benchmarked against Python Prophet

Both libraries simulate ranges with random draws, so individual values never match exactly. The uncertainty benchmarks check every range against Python Prophet 1.4.0 on all three trend types, within the variation expected from that many draws, and time the work.

ts
import { Effect } from "effect";
import { fit, predictWithIntervals, prophetFittingBackendLayer } from "effect-prophet";

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

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

const nextWeek = Array.from({ length: 7 }, (_, offset) => date(90 + offset));

const forecast = await Effect.runPromise(
  Effect.gen(function* () {
    const model = yield* fit(history);

    // The seed makes the simulation repeatable: same seed, same ranges.
    return yield* predictWithIntervals(model, nextWeek, {
      seed: 42,
      samples: 1000,
      intervalWidth: 0.8,
    });
  }).pipe(Effect.provide(prophetFittingBackendLayer)),
);

for (const row of forecast.rows) {
  console.log(
    new Date(row.timestamp).toISOString().slice(0, 10),
    `${row.value.toFixed(1)}  (80% range ${row.valueInterval.lower.toFixed(1)} to ${row.valueInterval.upper.toFixed(1)})`,
  );
}
txt
2025-04-01 136.5  (80% range 133.1 to 140.1)
2025-04-02 144.7  (80% range 141.2 to 148.4)
2025-04-03 153.0  (80% range 149.6 to 156.8)
2025-04-04 155.5  (80% range 152.0 to 159.1)
2025-04-05 150.7  (80% range 147.2 to 154.3)
2025-04-06 142.5  (80% range 138.9 to 146.0)
2025-04-07 137.6  (80% range 134.4 to 141.3)

Each row is the same forecast predict returns, with every component, plus two ranges:

FieldMeaning
valueThe point forecast, exactly as predict returns it
valueInterval{ lower, upper } range for value, from trend changes and noise
trendInterval{ lower, upper } range for trend, from trend changes alone

The result also records sampleCount, intervalWidth and simulation, the version of the simulation method. Rows come back in the order you passed them, including repeated dates, so a row with a promotion and the same date without one each keep their own forecast and range.

If you only need the ranges, predictUncertainty takes the same arguments and skips the point forecast.

A model fitted with mcmc also covers its parameters' uncertainty, adds componentIntervals to each row, and runs ceil(samples / draws) futures for each posterior draw, so its sampleCount can exceed samples.

How it works ​

The model simulates many possible futures. In each one, the trend might change direction the way it did in the past, and day-to-day noise is added. The range covers the middle 80% of those simulated futures.

OptionDefaultMeaning
seedrequiredAny whole number. The same seed always gives the same ranges.
samples1000How many futures to simulate (up to 2,048). More is smoother but slower.
intervalWidth0.8How much of the simulated futures to cover. 0.95 gives a wider range.
output"intervals"predictUncertainty only: "samples" returns every simulated value

Working with the raw samples ​

Use predictUncertainty with output: "samples" to answer questions like "what's the chance we go over 150?". predictWithIntervals rejects output: "samples" with an UncertaintyOptionsError whose reason is unsupported-output.

ts
import { Effect } from "effect";
import { fit, predictUncertainty, prophetFittingBackendLayer } from "effect-prophet";

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

const history = Array.from({ length: 90 }, (_, day) => ({
  timestamp: date(day),
  value: 100 + day * 0.5 + 4 * Math.sin(day * 12.9898),
}));

const nextThreeDays = [date(90), date(91), date(92)];

const result = await Effect.runPromise(
  Effect.gen(function* () {
    const model = yield* fit(history);

    return yield* predictUncertainty(model, nextThreeDays, {
      seed: 7,
      samples: 500,
      output: "samples",
    });
  }).pipe(Effect.provide(prophetFittingBackendLayer)),
);

if (result.kind === "samples") {
  // Samples are stored row by row: all of day 1's samples, then all of day 2's, and so on.
  const firstDay = result.value.subarray(0, result.sampleCount);
  const chanceAbove150 = firstDay.filter((value) => value > 150).length / result.sampleCount;

  console.log(`Chance tomorrow is above 150: ${(chanceAbove150 * 100).toFixed(0)}%`);
}

Things to know ​

  • Ranges get wider further out. The further ahead you forecast, the more the trend could change.
  • The ranges reflect trend changes and noise only. They don't account for uncertainty in the seasonal patterns, so treat them as a minimum, not a guarantee.
  • The seed is required so that results are repeatable. Pick any number and keep it.
  • Size limits. One call takes up to 10,000 rows. Ranges work with any sample count up to 2,048, but output: "samples" returns every simulated value, so rows × samples can be at most 1,000,000. A model with regressors forecast by their own models keeps those models' draws for the rows after the history, so those rows × samples can be at most 1,000,000 too, counting a sampled model's ceil(samples / draws) × draws futures. Very long forecasts are also capped: with the default 25 changepoints, ranges reach about 10 times the length of the history ahead. Past any limit, predictWithIntervals and predictUncertainty fail with a PredictionError whose reason is simulation-limit.

Inspired by Prophet from Meta. Not affiliated with Meta.