Skip to content

Full Bayesian fitting ​

By default, fit finds the single most likely set of parameters (the MAP estimate). With the mcmc option it instead draws thousands of plausible parameter sets from the posterior with Stan's NUTS sampler, exactly as Python's mcmc_samples does. Ranges then cover how unsure the model is about its trend and seasonality, not just future trend changes and noise.

ts
import { Effect } from "effect";
import { fit, isMcmcModel, 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: 3 }, (_, offset) => date(90 + offset));

const { model, forecast } = await Effect.runPromise(
  Effect.gen(function* () {
    // Like Python's `mcmc_samples=300`: 4 chains, each with 150 warmup and 150 kept draws.
    const model = yield* fit(history, { mcmc: { samples: 300, seed: 7 } });
    const forecast = yield* predictWithIntervals(model, nextWeek, { seed: 42 });

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

const diagnostics = isMcmcModel(model) ? model.fitSummary.diagnostics : null;

console.log(
  `${model.model}: ${diagnostics?.divergences} divergences, max R-hat ${diagnostics?.maxRhat?.toFixed(2)}`,
);

console.log(`${forecast.sampleCount} simulated paths`);

for (const row of forecast.rows) {
  const weekly = row.componentIntervals?.seasonalities.find(({ name }) => name === "weekly");

  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)})`,
    `weekly ${weekly?.interval.lower.toFixed(2)} to ${weekly?.interval.upper.toFixed(2)}`,
  );
}
txt
linear-piecewise-mcmc: 0 divergences, max R-hat 1.03
1200 simulated paths
2025-04-01 135.9 (80% range 131.8 to 139.8) weekly -8.80 to -6.84
2025-04-02 143.9 (80% range 139.8 to 148.1) weekly -1.20 to 0.65
2025-04-03 152.3 (80% range 148.2 to 156.5) weekly 6.82 to 8.68

Options ​

OptionDefaultMeaning
samplesrequiredIterations per chain, like mcmc_samples. Each chain keeps half, rounded down
seedrequiredAny whole number. The same seed always gives the same draws
chains4Independent chains, up to 16
warmupsamples / 2Iterations each chain spends tuning itself before it keeps draws

Python's mcmc_samples=300 is mcmc: { samples: 300, seed }: 4 chains of 150 warmup and 150 kept iterations, 600 draws in all. Model kinds are "flat-mcmc", "linear-piecewise-mcmc" and "logistic-piecewise-mcmc"; isMcmcModel tells them from MAP models. Python picks a random seed when you don't give one; here the seed is required, so a fit is repeatable.

The sampler is a port of Stan 2.37.0's adaptive NUTS with CmdStan's default settings, run on Prophet's own model. Its chains can't match CmdStan's draw for draw, since tiny rounding differences grow along a chain, so the benchmarks compare the posterior means and ranges with Python's instead, within the variation expected from that many draws.

What changes ​

  • Point forecasts use the posterior means, as Python's do. model.slope, model.coefficients and the other fields hold those means, and model.posterior holds every draw.
  • Ranges come from ceil(samples / draws) simulated futures for each draw, as in Python, so sampleCount can be more than the samples you asked for: 1,200 for 1,000 samples over 600 draws. simulation is prophet-mcmc-scalar-xoshiro128ss-v1.
  • Component ranges. predictWithIntervals adds componentIntervals to each row: the range of the additive and multiplicative totals and of every seasonality, event and regressor over the draws, like Python's weekly_lower and weekly_upper columns. Multiplicative ranges are factors, as in Python.
  • Regressor coefficients. getRegressorCoefficients gives each coefficient's posterior mean and coefficientInterval, its 80% range over the draws, like Python's coef_lower and coef_upper.
  • Warm starts. warmStartParameters returns the posterior means, as Python's warm_start_params does, and map.warmStart sets where every chain starts.
  • Saved models keep every draw, so a loaded model forecasts and simulates exactly as before.

Checking the sampler ​

model.fitSummary.diagnostics reports the sampler's health, as CmdStan's diagnose and summary do:

FieldLook for
divergencesZero. Any mean the sampler struggled, and the ranges may be too narrow
maxTreeDepthHitsFew. Many mean slow, awkward exploration
maxRhatBelow about 1.01 to 1.05. Higher means the chains disagree: sample more
minBulkEss, minTailEssAt least a few hundred, for stable means and ranges
meanAcceptStat, stepSizesAcceptance near or above the 0.8 target, and each chain's tuned step size
warmupDivergencesDivergences while tuning, which CmdStan doesn't report; usually harmless

A flat or linear history whose values are all the same keeps Prophet's single fixed state, as Python does: its termination is "constant-target-shortcut", it has one draw and no diagnostics. Logistic growth always samples.

Things to know ​

  • Short warmups never tune the metric. CmdStan only adapts the sampler's per-parameter scales once warmup is long enough for its tuning windows, so Python fits with mcmc_samples under 300 keep the default scales and smaller steps. This sampler does the same, to match Python.
  • Sampling is slower than MAP fitting: hundreds of gradient evaluations per draw instead of a few dozen in all. The 90-day example above takes about a second. Linear and flat fits take about as long as Python's, but logistic fits take 4–5 times as long as Python's native CmdStan. See the benchmarks, which also time ranges from a posterior. Python's calls there also compute point forecasts and components, so those times aren't compared.
  • Limits. Up to 16 chains, 100,000 warmup iterations per chain and 20,000 kept draws in all, and at most 2,000,000 kept values (draws times parameters), so models stay a reasonable size. The first three are checked against your mcmc options before sampling: past them, fit fails with an OptionsError whose path points at mcmc.chains, mcmc.warmup or mcmc.samples. Kept values depend on how many parameters the model has, so only fitting can check them: past that, fit fails with a FittingError whose reason is "sampling-limit".
  • Every growth and feature. Flat, linear and logistic growth all sample, with events, country holidays, conditional seasonalities and regressors. Regressors forecast by their own models optimize unless their own options set mcmc, as in Python, where mcmc_samples doesn't reach them; when a posterior runs more futures than the regressor models drew, it reuses their draws in turn, as Python does.
  • Evaluation. crossValidate, searchModels and evaluateHoldout sample a posterior for every fit. Each round's ranges run ceil(samples / draws) futures per draw, and holdout reports record the posterior's draw count, divergences and largest R-hat instead of optimizer progress.

Inspired by Prophet from Meta. Not affiliated with Meta.