Forecasts chosen by what would have worked.
A forecasting crate that replays the past before trusting a model: rolling-origin backtest, choice by out-of-sample error, and intervals taken from the errors actually observed.
Built to be believed, not just to fit
A model that fits the past well can still forecast badly. Every choice here is made on forecasts the model gave without seeing the answer.
Rolling-origin backtest
Each model is refitted at every origin using only earlier data and forecasts 1 to h periods ahead. Errors are reported by horizon: MAPE, MAE, RMSE, MASE and bias.
Choice by out-of-sample error
The winner is whatever forecast best, the simple average of the best models included. No information criterion decides what you ship.
Empirical intervals
Intervals are quantiles of the errors seen in the backtest, horizon by horizon. No normality is assumed and none is needed.
Intervals for totals
The total of the next k periods gets its own interval, measured on totals. Adding up monthly limits would overstate the uncertainty of a year.
No dependencies
Pure Rust on the standard library. Small builds, nothing to audit but the crate itself, and it compiles to WebAssembly.
Deterministic
The same series gives the same numbers, on one core or sixteen. Fitted parameters are exposed by name, ready for an audit trail.
Models
From the yardsticks every model must beat to the exponential smoothing family, seasonal ARIMA with automatic orders and Prophet with dated events. Any of them can run on the log or another Box-Cox scale, or on the series seasonally adjusted by STL, with several seasonal periods at once.
ARIMA is estimated by exact Gaussian maximum likelihood with the innovations algorithm, searching over partial autocorrelations so the estimates are always stationary and invertible. External variables and Fourier terms enter as a regression with ARIMA errors, estimated together with the model. Automatic orders follow Hyndman & Khandakar (2008): differences by the KPSS test and the strength of seasonality, the rest by stepwise search on AICc. Prophet is fitted without Stan: for a given noise level its posterior is a lasso with ridge terms, solved exactly, so the changepoints that do not matter come out as exactly zero. An ensemble is a model too: it learns its weights on the end of the history and can sit in the backtest next to its own members. Your own model is one trait away: implement Model and it joins the backtest.
On real data
Monthly ICMS, the sales tax of the Brazilian state of Piauí, from public fiscal reports. The airline ARIMA on the log scale had a mean absolute percentage error of 3.7% over 36 origins and 12 horizons; the seasonal naive forecast had 11.5%.
ICMS revenue, Piauí: last four years and the next twelve months
BRL million per month. Forecast from data up to June 2026, with the 80% empirical interval.
Forecast as a table
| Month | Forecast | Lower (80%) | Upper (80%) |
|---|
Total of the next six months: BRL 5.16 billion, 80% interval 4.95 to 5.41 billion. Adding up the six monthly limits instead would give a wider and wrong interval.
Source: Siconfi/STN, RREO Anexo 03. The data ships with the crate: cargo run --release --example piaui.
How it works
One pass over the past produces everything: the ranking, the choice and the intervals.
Describe the series
Values and seasonal period. Slices keep season and position.
Pick candidates
The built-in sets, or your own list with your own names.
Replay the past
Origins, horizon and window are yours to set.
Read the report
Errors by horizon, the choice, forecasts and intervals.
Install & run
Add the crate, describe the series, run the backtest. Rust 1.81 or later; changes between versions in the changelog.
$ cargo add foresight
// choose among the ready set of candidates
use foresight::{models, Backtest, Series};
let y = Series::monthly(&values, 0);
let report = Backtest::default()
.run(y, &models::defaults())
.unwrap();
let best = report.chosen();
for p in &best.forecast {
let i = p.interval(0.80).unwrap();
println!("{} {} [{}, {}]",
p.horizon, p.mean, i.lower, i.upper);
}
let total = best.cumulative(6).unwrap();
// one model on its own, on the log scale
use foresight::{models::Arima, Model,
Series, Transformed};
let model = Transformed::log(Arima::airline());
let fit = model.fit(y).unwrap();
let next_year = fit.forecast(12);
// automatic orders, inspected
use foresight::models::AutoArima;
let fit = AutoArima::new().select(y).unwrap();
println!("{:?}{:?} AICc {}",
fit.order(), fit.seasonal_order(), fit.aicc);
Checked against R
Every method is implemented from the published papers and compared with the R packages forecast 9.0.2 and prophet 1.1.7 on public data. The comparison is part of the test suite.
| Method | Agreement with R |
|---|---|
| Seasonal naive, random walk with drift | Exact |
| Theta | Forecasts within 0.1% |
| ARIMA by maximum likelihood | Coefficients within 0.002, forecasts within 0.01% |
| Number of ordinary and seasonal differences | Same decisions |
| Box-Cox λ by Guerrero's method | Within 0.001 |
| Prophet (linear growth, additive seasonality) | Forecasts within 0.5% |
| Regression with ARIMA errors | Forecasts within 0.001%, same likelihood |
| STL, MSTL with one and two seasonal periods, forecasts by decomposition | Exact (10 digits) |
| Exponential smoothing (ETS), 8 models × 3 series | Likelihood equal to R's where R reaches the maximum, higher in the other cases |
| Croston for intermittent demand | Exact |
| TBATS, 2 structures × 3 series, and automatic | Likelihood and AIC better than R's in every case |
| Automatic ARIMA orders | Same model on 2 of 3 series; on the third the two searches end within one unit of AICc |
CI on every platform
fmt · clippy -D warnings · tests and docs on Linux, macOS and Windows, plus a WebAssembly build.
From the papers
Box & Jenkins, Brockwell & Davis, Hyndman & Khandakar, Taylor & Letham, Assimakopoulos & Nikolopoulos, Guerrero, Kwiatkowski et al. R supplies reference numbers only.
MIT, nothing attached
With no dependencies there is no license to reconcile and no supply chain to watch.