Skip to content

Uncertainty ranges ​

A single number is never the whole story. predictUncertainty gives a range for each forecast: "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, predict, 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 + 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 result = await Effect.runPromise(
  Effect.gen(function* () {
    const model = yield* fit(history);
    const forecast = yield* predict(model, nextWeek);

    // The seed makes the simulation repeatable: same seed, same intervals.
    const intervals = yield* predictUncertainty(model, nextWeek, {
      seed: 42,
      samples: 1000,
      intervalWidth: 0.8,
    });

    return { forecast, intervals };
  }).pipe(Effect.provide(prophetFittingBackendLayer)),
);

if (result.intervals.kind === "intervals") {
  for (const [index, row] of result.intervals.rows.entries()) {
    const point = result.forecast[index]?.value ?? Number.NaN;

    console.log(
      new Date(row.timestamp).toISOString().slice(0, 10),
      `${point.toFixed(1)}  (80% range ${row.value.lower.toFixed(1)} to ${row.value.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)

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""samples" returns every simulated value instead of a range

Working with the raw samples ​

Use output: "samples" to answer questions like "what's the chance we go over 150?":

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. 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, predictUncertainty fails with a PredictionError whose reason is simulation-limit.

Inspired by Prophet from Meta. Not affiliated with Meta.