foresight 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +251 -0
- data/lib/foresight/backtester.rb +177 -0
- data/lib/foresight/classifier.rb +33 -0
- data/lib/foresight/compound_strategy.rb +35 -0
- data/lib/foresight/conformal_strategy.rb +43 -0
- data/lib/foresight/croston_classic.rb +31 -0
- data/lib/foresight/croston_sba.rb +29 -0
- data/lib/foresight/empirical_quantile.rb +9 -0
- data/lib/foresight/evaluation_report.rb +62 -0
- data/lib/foresight/forecaster.rb +81 -0
- data/lib/foresight/frequency.rb +79 -0
- data/lib/foresight/gaussian_strategy.rb +41 -0
- data/lib/foresight/input_error.rb +6 -0
- data/lib/foresight/metrics.rb +30 -0
- data/lib/foresight/naive.rb +28 -0
- data/lib/foresight/normal.rb +65 -0
- data/lib/foresight/options.rb +123 -0
- data/lib/foresight/result.rb +6 -0
- data/lib/foresight/seasonal_naive.rb +40 -0
- data/lib/foresight/selector.rb +21 -0
- data/lib/foresight/series.rb +113 -0
- data/lib/foresight/ses.rb +29 -0
- data/lib/foresight/simulator.rb +24 -0
- data/lib/foresight/smoothing.rb +57 -0
- data/lib/foresight/tsb.rb +29 -0
- data/lib/foresight/version.rb +5 -0
- data/lib/foresight.rb +55 -0
- metadata +66 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f7a4b56ecdde3aa73ab5e4ab75cde90316cd6b37608c2e3a22ea3193ee9d0718
|
|
4
|
+
data.tar.gz: 38bfab9bfd218cf0b25e449496daf522ce8d34ecc0f4a629f5067829d94a5de7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f154118cdc5f994d13f4cdf46253d542a94e0f36ebaa23a462504765013ab817a3357e5fd54944ac1323bb83544bcf9a3c44094d5ea258f9ece66fd087c40e0d
|
|
7
|
+
data.tar.gz: de8e5b6415e64af220ab7f3e5beaa72f94b69380bfca4d0282504a00350946c1b6d9b362baf6272bc6849cffb5d01b6988c4260b0ffeec38b460630221adbcf3
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ojus Chugh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Foresight
|
|
2
|
+
|
|
3
|
+
Foresight is a Ruby gem for probabilistic demand forecasting. It is written for Ruby and Rails developers who forecast demand, such as orders, stock or capacity, and who need more than one number per period: the quantiles of demand for each period and for the whole horizon, so that the forecast answers how many units cover the next N periods with a chosen probability.
|
|
4
|
+
|
|
5
|
+
Relative to prophet-rb, the established in-process forecasting gem for Ruby, which provides a single Stan-based model, Foresight provides the intermittent-demand methods CrostonClassic, CrostonSBA and TSB, demand classification with automatic model selection, and quantiles as the default output of every method, through a simple API modeled on the simple API of prophet-rb: `Foresight.forecast(series, count: 7)` takes the Hash that groupdate returns and keys the forecasts the same way.
|
|
6
|
+
|
|
7
|
+
The thesis behind the library is that decisions about how much to stock, staff or provision need calibrated demand distributions, and that point forecasts mislead for intermittent demand, meaning series in the intermittent and lumpy demand classes, where most periods have no demand at all. On such a series the point forecast that minimizes the absolute error is the median of the predictive distribution, which is zero whenever the chance of a period without demand is at least one half, so on its own it gives no basis for a stock level. Calibration is a measured property: the quantiles at a level q are calibrated when they cover a proportion q of the observed outcomes. Foresight treats calibration as something to measure and publish rather than to assume.
|
|
8
|
+
|
|
9
|
+
Foresight is a prototype. It implements the architecture and measures the calibration problem on synthetic series and on one real dataset. The planned work listed under the Roadmap heading below is not implemented.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add Foresight to your Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "foresight"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
or install it directly:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
gem install foresight
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
To use the current source instead of the released gem, point Bundler at the GitHub repository:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem "foresight", git: "https://github.com/ojuschugh1/foresight"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
or build the gem locally:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
git clone https://github.com/ojuschugh1/foresight
|
|
35
|
+
cd foresight
|
|
36
|
+
gem build foresight.gemspec
|
|
37
|
+
gem install ./foresight-0.1.0.gem
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then `require "foresight"`. Foresight needs Ruby 3.1 or newer, runs on Linux and macOS, and has zero runtime dependencies: it is pure Ruby and loads nothing beyond the standard library.
|
|
41
|
+
|
|
42
|
+
Two notes on input. Use Date keys for daily and coarser data, which is what groupdate's `group_by_day` returns; Time keys one day apart in a zone that changes its UTC offset during the year are 23 or 25 hours apart across the change, and Foresight rejects such a series because its keys do not follow one frequency. Values must be finite, non-negative numbers of at most 10^15; exactly 1e15 is accepted and anything above it is rejected.
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
Six weeks of daily unit sales for one slow-moving product, in the shape that `Order.group_by_day(:created_at).count` returns (a Hash of Date keys and Integer values, with a zero on every day without an order), forecast seven days ahead without choosing a model:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
require "date"
|
|
50
|
+
require "foresight"
|
|
51
|
+
|
|
52
|
+
start = Date.new(2026, 1, 5)
|
|
53
|
+
units = [0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 0, 3, 0, 0, 1, 0, 0, 0, 0, 0, 2,
|
|
54
|
+
0, 0, 0, 4, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0, 0, 1, 0, 0, 3, 0]
|
|
55
|
+
series = units.each_with_index.to_h { |demand, day| [start + day, demand] }
|
|
56
|
+
|
|
57
|
+
result = Foresight.forecast(series, count: 7)
|
|
58
|
+
|
|
59
|
+
puts "#{result.model}: #{result.reason}"
|
|
60
|
+
labels = result.levels.map { |level| "q#{level}".rjust(5) }
|
|
61
|
+
puts ["date".ljust(10), "point", *labels].join(" ")
|
|
62
|
+
result.forecast.each do |date, point|
|
|
63
|
+
quantiles = result.levels.map { |level| result.quantiles[level][date].round(2).to_s.rjust(5) }
|
|
64
|
+
puts [date.to_s, point.round(2).to_s.rjust(5), *quantiles].join(" ")
|
|
65
|
+
end
|
|
66
|
+
result.lead_time_quantiles.each do |level, total|
|
|
67
|
+
puts "7-day total at q#{level}: #{total.round(2)}"
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
croston_sba: auto-selected croston_sba for intermittent demand (ADI 4.20, CV squared 0.25)
|
|
73
|
+
date point q0.5 q0.8 q0.9 q0.95
|
|
74
|
+
2026-02-16 0.52 0.0 1.0 2.0 3.0
|
|
75
|
+
2026-02-17 0.52 0.0 1.0 2.0 3.0
|
|
76
|
+
2026-02-18 0.52 0.0 1.0 2.0 3.0
|
|
77
|
+
2026-02-19 0.52 0.0 1.0 2.0 3.0
|
|
78
|
+
2026-02-20 0.52 0.0 1.0 2.0 3.0
|
|
79
|
+
2026-02-21 0.52 0.0 1.0 2.0 3.0
|
|
80
|
+
2026-02-22 0.52 0.0 1.0 2.0 3.0
|
|
81
|
+
7-day total at q0.5: 3.0
|
|
82
|
+
7-day total at q0.8: 6.0
|
|
83
|
+
7-day total at q0.9: 7.0
|
|
84
|
+
7-day total at q0.95: 8.0
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Foresight classified the series as intermittent (an order every 4.2 days on average, with order sizes whose squared coefficient of variation is 0.25) and picked CrostonSBA. The point forecast of 0.52 units a day is the smoothed demand rate and the least useful line of the output: on any single day the chance of no demand is above one half, which is why the 0.5 quantile is zero. The per-step quantiles say that, under the fitted model, 1 unit covers the demand of a single day with probability 0.8, 2 units with probability 0.9 and 3 units with probability 0.95. The lead-time quantiles answer the replenishment question directly: 6 units cover the total demand of the next 7 days with probability 0.8, 7 units with probability 0.9 and 8 units with probability 0.95.
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
### Series input
|
|
92
|
+
|
|
93
|
+
Every entry point takes a series in one of two shapes:
|
|
94
|
+
|
|
95
|
+
- a Hash whose keys are all Date values or all Time values (any object for which `is_a?(Time)` holds, including `ActiveSupport::TimeWithZone`) and whose values are numbers, which is what groupdate returns;
|
|
96
|
+
- an Array of numbers, one per period, in time order.
|
|
97
|
+
|
|
98
|
+
Values must be finite real numbers from 0 to 10^15. Foresight sorts a Hash by key, never modifies the input, and detects the frequency from the keys: daily, weekly, monthly or yearly for Date keys, or a fixed number of seconds for Time keys. Every period must be present, with a value of zero for periods without demand; a gap or an irregular key raises `Foresight::InputError` naming the first pair of keys that breaks the frequency. `Foresight.forecast` and `Foresight.backtest` need at least two keys to detect the frequency; `Foresight.classify` also accepts a Hash with one key.
|
|
99
|
+
|
|
100
|
+
### Foresight.forecast(series, **options)
|
|
101
|
+
|
|
102
|
+
Fits a model, produces the point forecasts and the quantiles, and returns a `Foresight::Result`.
|
|
103
|
+
|
|
104
|
+
| Option | Accepted values | Default | Read by |
|
|
105
|
+
|---|---|---|---|
|
|
106
|
+
| `count` | Integer of at least 1 | 10 | the forecast horizon |
|
|
107
|
+
| `model` | `:naive`, `:seasonal_naive`, `:ses`, `:croston_classic`, `:croston_sba`, `:tsb` | automatic selection | model choice |
|
|
108
|
+
| `levels` | non-empty Array of numbers strictly between 0 and 1 | `[0.5, 0.8, 0.9, 0.95]` | both strategies |
|
|
109
|
+
| `strategy` | `:distribution`, `:conformal` | `:distribution` | quantiles |
|
|
110
|
+
| `seed` | Integer of at least 0 | 0 | compound quantiles |
|
|
111
|
+
| `season_length` | Integer of at least 1 | none, required by `:seasonal_naive` | SeasonalNaive |
|
|
112
|
+
| `paths` | Integer of at least 1 | 10,000 | compound quantiles |
|
|
113
|
+
| `windows` | Integer of at least 2 | 2 | conformal quantiles |
|
|
114
|
+
| `alpha` | number strictly between 0 and 1 | 0.1 | CrostonClassic and CrostonSBA |
|
|
115
|
+
| `alpha_d` | number strictly between 0 and 1 | 0.1 | TSB, demand sizes |
|
|
116
|
+
| `alpha_p` | number strictly between 0 and 1 | 0.1 | TSB, demand occurrence |
|
|
117
|
+
|
|
118
|
+
Passing `nil` for an option is the same as omitting it. Duplicate levels count once and the result lists the levels in ascending order. An accepted option that the model or strategy in use does not read, such as `seed` under a smooth model, leaves the result unchanged. An unknown option name, or a value outside the accepted values, raises `Foresight::InputError` rather than `ArgumentError`.
|
|
119
|
+
|
|
120
|
+
`Foresight::Result` is a Struct with these fields:
|
|
121
|
+
|
|
122
|
+
| Field | Value |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `forecast` | the point forecasts: a Hash from each future key to its value for a keyed series (the shape prophet-rb returns), an Array for an Array series |
|
|
125
|
+
| `quantiles` | a Hash from each quantile level to the per-step quantiles, in the shape of `forecast` |
|
|
126
|
+
| `lead_time_quantiles` | a Hash from each quantile level to one number, the quantile of total demand over the whole horizon |
|
|
127
|
+
| `model` | the Symbol of the model that produced the forecasts |
|
|
128
|
+
| `demand_class` | `:smooth`, `:erratic`, `:intermittent` or `:lumpy` for the complete series, or `nil` when a model was specified for a series without a non-zero value |
|
|
129
|
+
| `reason` | a String stating how the model was chosen, with the ADI and the CV squared when it was chosen automatically |
|
|
130
|
+
| `strategy` | `:distribution` or `:conformal` |
|
|
131
|
+
| `levels` | the quantile levels in ascending order |
|
|
132
|
+
|
|
133
|
+
Future keys continue the detected frequency from the last key: k days, 7k days, k calendar months on the same day of the month, k calendar years on the same month and day, or k times the spacing in seconds, in the class and time zone of the last key. A monthly series whose day of the month is missing from one of the forecast months raises `Foresight::InputError`.
|
|
134
|
+
|
|
135
|
+
Every quantile is a finite number of at least zero, and quantiles never decrease with the level. Under the default strategy a compound per-step quantile is always zero or a demand size seen in the history, and a compound lead-time quantile is a sum of such sizes, as the quickstart shows.
|
|
136
|
+
|
|
137
|
+
Automatic selection needs at least one non-zero observation; a series of zeros needs an explicit `model`. Each model and strategy declares the number of observations it needs: Naive, SES and the three intermittent models fit on a single observation and SeasonalNaive needs `season_length` observations; Gaussian quantiles need one observation more than the model, so that there is at least one residual, and conformal quantiles need `windows` times `count` more. A shorter series raises `Foresight::InputError` naming the component and both numbers.
|
|
138
|
+
|
|
139
|
+
### Foresight.classify(series)
|
|
140
|
+
|
|
141
|
+
Returns a `Foresight::Classification` Struct with `demand_class`, `adi` and `cv_squared`. The ADI is the number of observations divided by the number of non-zero observations; the CV squared is the population variance of the non-zero observations divided by the square of their mean. Both are compared exactly with the cutoffs 1.32 and 0.49 of Syntetos, Boylan and Croston (2005): an ADI of at least 1.32 makes the series intermittent or lumpy, and a CV squared of at least 0.49 makes it erratic or lumpy. The series needs at least one non-zero observation.
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
classification = Foresight.classify([0, 0, 2, 0, 1, 0, 0, 3])
|
|
145
|
+
classification.demand_class # => :intermittent
|
|
146
|
+
classification.adi.round(2) # => 2.67
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Foresight.backtest(series, horizon:, origins:, step: nil, **options)
|
|
150
|
+
|
|
151
|
+
Rolling-origin evaluation of the forecasts on the history of one series. The last held-out window ends at the last observation, consecutive origins are `step` periods apart (`step` defaults to `horizon`), and at each origin the model and the quantile strategy are fitted on the observations before the origin alone; without a `model`, automatic selection runs at every origin. All the options of `forecast` other than `count` apply. Returns a `Foresight::Backtest` Struct:
|
|
152
|
+
|
|
153
|
+
| Field | Value |
|
|
154
|
+
|---|---|
|
|
155
|
+
| `origins` | one `Foresight::OriginResult` per origin that was not excluded, in order |
|
|
156
|
+
| `excluded_origins` | the number of origins skipped because no model was given and the history before them had no non-zero value |
|
|
157
|
+
| `zero_scale_origins` | the number of origins whose MASE scale is zero |
|
|
158
|
+
| `held_out`, `zero_held_out`, `nonzero_held_out` | the number of held-out observations, and how many of them are zero and non-zero |
|
|
159
|
+
| `mae`, `rmse`, `mase` | the means over the origins of the per-origin point metrics |
|
|
160
|
+
| `scaled_pinball`, `scaled_pinball_zero`, `scaled_pinball_nonzero` | Hashes from each level to the mean scaled pinball loss over the origins, and to the parts of it from zero and from non-zero held-out observations |
|
|
161
|
+
| `coverage` | a Hash from each level to the proportion of held-out observations at most their per-step quantile, pooled over the origins |
|
|
162
|
+
| `lead_time_coverage` | a Hash from each level to the proportion of origins whose held-out total is at most the lead-time quantile |
|
|
163
|
+
| `covered`, `lead_time_hits` | the counts behind the two proportions |
|
|
164
|
+
|
|
165
|
+
Each `Foresight::OriginResult` holds `start` (the key, or the position for an Array series, of the first held-out observation), `model`, `mase_scale`, `mae`, `rmse`, `mase`, the Hashes by level `scaled_pinball`, `scaled_pinball_zero`, `scaled_pinball_nonzero`, `coverage` and `covered`, the counts `zero_held_out` and `nonzero_held_out`, and `lead_time_hit`, a Hash from each level to true or false.
|
|
166
|
+
|
|
167
|
+
The MASE scale is the mean absolute difference between consecutive observations before the origin, the in-sample one-step naive error; the MASE divides the MAE by it and the scaled pinball loss divides the mean pinball loss by it. When the scale is zero, the MASE and the scaled pinball losses of that origin are `nil`, are left out of every mean, and the origin is counted in `zero_scale_origins`. A mean or proportion with nothing to aggregate is `nil`. A series too short for the requested horizon, origins and step raises `Foresight::InputError` stating the length it needs.
|
|
168
|
+
|
|
169
|
+
### Foresight.evaluate(collection, **options)
|
|
170
|
+
|
|
171
|
+
Backtests every series of a non-empty Array with the options of `backtest` and returns a `Foresight::EvaluationReport` that groups the results by the demand class of each complete series:
|
|
172
|
+
|
|
173
|
+
| Field | Value |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `classes` | a Hash from each of `:smooth`, `:erratic`, `:intermittent` and `:lumpy` to a `Foresight::ClassSummary`, including classes with no series |
|
|
176
|
+
| `series` | one `Foresight::SeriesEntry` per backtested series, in input order, with `index`, `demand_class` and `backtest` |
|
|
177
|
+
| `excluded_series` | the number of series left out because they had no non-zero value |
|
|
178
|
+
| `levels` | the quantile levels |
|
|
179
|
+
|
|
180
|
+
A `Foresight::ClassSummary` holds `demand_class`, `series_count`, `excluded_origins`, `zero_scale_origins`, `held_out`, `zero_held_out` and `nonzero_held_out`; `mae`, `rmse`, `mase` and the three scaled pinball fields as means over the series of the per-series means; and `coverage` and `lead_time_coverage` pooled over the held-out observations and the origins of every series of the class. A `Foresight::InputError` for one series is reported with the index of that series in the collection.
|
|
181
|
+
|
|
182
|
+
### Foresight::Backtester
|
|
183
|
+
|
|
184
|
+
`Foresight::Backtester.new(**options)` validates the backtest options once and exposes `run(series)`, which is what `Foresight.backtest` calls, and `evaluate(collection)`, which is what `Foresight.evaluate` calls:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
backtester = Foresight::Backtester.new(horizon: 7, origins: 10, strategy: :conformal, windows: 10)
|
|
188
|
+
backtest = backtester.run(series)
|
|
189
|
+
report = backtester.evaluate([series, other_series])
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Foresight::EvaluationReport.aggregate(entries, horizon:, levels:)
|
|
193
|
+
|
|
194
|
+
Builds a `Foresight::EvaluationReport` from `Foresight::SeriesEntry` values (`index`, `demand_class`, `backtest`) that the caller assembled, aggregating exactly as `evaluate` does. This is how the calibration study backtests series one at a time, sets aside the series that raise `Foresight::InputError` and still reports per-class summaries. The returned report has `excluded_series` set to 0.
|
|
195
|
+
|
|
196
|
+
### Foresight::InputError
|
|
197
|
+
|
|
198
|
+
The only exception Foresight raises for an input that violates a documented precondition. It is a subclass of `StandardError` whose message names the offending option, key, value or position. Every input check runs before any model is fitted or any random number is drawn. `Foresight::VERSION` is the gem version.
|
|
199
|
+
|
|
200
|
+
### Determinism
|
|
201
|
+
|
|
202
|
+
Every random draw comes from a generator seeded with the `seed` option, 0 by default; Foresight never reads or reseeds Ruby's global random number generator. The same series, options and seed give identical output, in one process or across processes, on the same platform and Ruby version.
|
|
203
|
+
|
|
204
|
+
## Methodology
|
|
205
|
+
|
|
206
|
+
Foresight has six point forecasting methods: Naive, SeasonalNaive and SES for smooth demand, and CrostonClassic, CrostonSBA and TSB for intermittent demand, following the conventions of statsforecast 2.1.1 wherever it defines them, so that the point forecasts can be checked against an independent implementation.
|
|
207
|
+
|
|
208
|
+
The classifier computes the ADI and the CV squared of a series and assigns one of the four demand classes with the cutoffs of Syntetos, Boylan and Croston (2005), an ADI of 1.32 and a CV squared of 0.49. The automatic selector maps the class to a method with a fixed rule, SES for smooth series and CrostonSBA for erratic, intermittent and lumpy series, and reports the choice in the reason of the result.
|
|
209
|
+
|
|
210
|
+
Quantiles come from one of two strategies. The distribution strategy, the default, shapes the quantiles after the model: for the smooth models a Gaussian distribution around the point forecast, with the residual variance scaled over the horizon by the error structure of the model and censored at zero; for the intermittent models a compound distribution in which each period has demand with the occurrence probability implied by the fitted model and a size drawn from the non-zero values of the history, with exact per-step quantiles and lead-time quantiles taken from 10,000 seeded sample paths. The conformal strategy is the generic residual-based baseline that statsforecast offers for any model: it refits the model before each of K calibration windows at the end of the history, records the absolute errors and places them symmetrically around the point forecast, clamped at zero.
|
|
211
|
+
|
|
212
|
+
The backtester performs rolling-origin evaluation and reports the MAE, the RMSE, the MASE, the scaled pinball loss at each level split into its parts from zero and from non-zero outcomes, the empirical coverage of the per-step quantiles and the coverage of the lead-time quantiles.
|
|
213
|
+
|
|
214
|
+
Two kinds of validation back the library. The point forecasts of all six methods are compared with outputs that statsforecast 2.1.1 produced on committed fixture series, within 1e-9 for the five deterministic methods and 1e-5 for SES, whose smoothing parameter comes from a numerical search. The calibration study backtests both quantile strategies on seeded synthetic series whose true quantiles are known and on a subset of the Online Retail II dataset, and reports coverage, lead-time coverage and scaled pinball loss per demand class with bootstrap intervals. The reference comparison runs in the test suite from committed files, and the study results are regenerated from committed files with Ruby alone.
|
|
215
|
+
|
|
216
|
+
The full account of the methods, every modeling choice with its source and the assumptions of each strategy are in [docs/methodology.md](docs/methodology.md). The reference comparison, the study results and the commands that regenerate them are in [docs/validation.md](docs/validation.md).
|
|
217
|
+
|
|
218
|
+
## Prior work
|
|
219
|
+
|
|
220
|
+
Foresight builds on the R package smooth by Ivan Svetunkov, which implements the intermittent state-space model iETS among its exponential smoothing models, and on the published intermittent-demand literature, in particular:
|
|
221
|
+
|
|
222
|
+
- Croston, J. D. (1972). Forecasting and stock control for intermittent demands. Operational Research Quarterly, 23(3), 289-303.
|
|
223
|
+
- Syntetos, A. A. and Boylan, J. E. (2005). The accuracy of intermittent demand estimates. International Journal of Forecasting, 21(2), 303-314.
|
|
224
|
+
- Syntetos, A. A., Boylan, J. E. and Croston, J. D. (2005). On the categorization of demand patterns. Journal of the Operational Research Society, 56(5), 495-503.
|
|
225
|
+
- Teunter, R. H., Syntetos, A. A. and Babai, M. Z. (2011). Intermittent demand: Linking forecasting to inventory obsolescence. European Journal of Operational Research, 214(3), 606-615.
|
|
226
|
+
- Kolassa, S. (2016). Evaluating predictive count data distributions in retail sales forecasting. International Journal of Forecasting, 32(3), 788-803.
|
|
227
|
+
- Svetunkov, I. and Boylan, J. E. (2023). iETS: State space model for intermittent demand forecasting. International Journal of Production Economics, 265, 109013.
|
|
228
|
+
|
|
229
|
+
CrostonClassic, CrostonSBA and TSB follow the first, the second and the fourth of these works. The classification cutoffs come from the third. The case for distributions over point forecasts on intermittent series comes from Kolassa (2016) and from Svetunkov and Boylan (2023). iETS-style models are planned and not implemented in Foresight.
|
|
230
|
+
|
|
231
|
+
## Roadmap
|
|
232
|
+
|
|
233
|
+
Each item below is planned and not built. Nothing on this list exists in the current gem.
|
|
234
|
+
|
|
235
|
+
- The ETS family: trend, damped trend, additive and multiplicative seasonality, likelihood-based fitting and information-criterion model selection. Planned, not built.
|
|
236
|
+
- Theta, CrostonOptimized, ADIDA and IMAPA. Planned, not built.
|
|
237
|
+
- iETS-style intermittent state-space models. Planned, not built.
|
|
238
|
+
- Distribution-aware conformal recalibration of quantiles, the central research item of this roadmap. Planned, not built.
|
|
239
|
+
- Validation-driven (data-driven) model selection. Planned, not built.
|
|
240
|
+
- The inventory decision layer: service-level policies and simulated inventory evaluation of the achieved fill rate against the target service level. Planned, not built.
|
|
241
|
+
- The multi-dataset calibration benchmark. Planned, not built.
|
|
242
|
+
- Randomized PIT diagnostics for discrete predictive distributions. Planned, not built.
|
|
243
|
+
- Numo, Apache Arrow and Rover data adapters, and charting helpers. Planned, not built.
|
|
244
|
+
|
|
245
|
+
## Excluded from the prototype
|
|
246
|
+
|
|
247
|
+
ARIMA, neural and foundation models, and multivariate and hierarchical forecasting are excluded from the prototype. So are any hosted service and Windows support: the prototype targets Linux and macOS with Ruby 3.1 or newer.
|
|
248
|
+
|
|
249
|
+
## License
|
|
250
|
+
|
|
251
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
OriginResult = Struct.new(:start, :model, :mase_scale, :mae, :rmse, :mase,
|
|
5
|
+
:scaled_pinball, :scaled_pinball_zero, :scaled_pinball_nonzero,
|
|
6
|
+
:zero_held_out, :nonzero_held_out,
|
|
7
|
+
:coverage, :covered, :lead_time_hit, keyword_init: true)
|
|
8
|
+
Backtest = Struct.new(:origins, :excluded_origins, :zero_scale_origins, :held_out,
|
|
9
|
+
:zero_held_out, :nonzero_held_out,
|
|
10
|
+
:mae, :rmse, :mase, :scaled_pinball, :scaled_pinball_zero, :scaled_pinball_nonzero,
|
|
11
|
+
:coverage, :lead_time_coverage, :covered, :lead_time_hits, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
class Backtester
|
|
14
|
+
def initialize(**options)
|
|
15
|
+
@options = Options.backtest(options)
|
|
16
|
+
@horizon = @options[:horizon]
|
|
17
|
+
@levels = @options[:levels]
|
|
18
|
+
@forecaster = Forecaster.new(@options.merge(count: @horizon).freeze)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(series)
|
|
22
|
+
series = Series.parse(series, keys_required: true)
|
|
23
|
+
check_length(series.size)
|
|
24
|
+
backtest(series)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def evaluate(collection)
|
|
28
|
+
check_collection(collection)
|
|
29
|
+
parsed = collection.each_with_index.map do |series, index|
|
|
30
|
+
for_series(index) { Series.parse(series, keys_required: true) }
|
|
31
|
+
end
|
|
32
|
+
kept = parsed.each_index.reject { |index| parsed[index].exact.all?(&:zero?) }
|
|
33
|
+
kept.each { |index| for_series(index) { check_length(parsed[index].size) } }
|
|
34
|
+
entries = kept.map do |index|
|
|
35
|
+
series = parsed[index]
|
|
36
|
+
demand_class = Classifier.call(series.exact).demand_class
|
|
37
|
+
SeriesEntry.new(index: index, demand_class: demand_class, backtest: backtest(series))
|
|
38
|
+
end
|
|
39
|
+
report = EvaluationReport.aggregate(entries, horizon: @horizon, levels: @levels)
|
|
40
|
+
report.excluded_series = parsed.size - kept.size
|
|
41
|
+
report
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def backtest(series)
|
|
47
|
+
prefixes = origin_positions(series.size).to_h { |position| [position, series.prefix(position)] }
|
|
48
|
+
kept = prefixes.reject { |_, prefix| @options[:model].nil? && prefix.exact.all?(&:zero?) }
|
|
49
|
+
results = kept.map { |position, prefix| origin_result(series, position, prefix) }
|
|
50
|
+
aggregate(results, prefixes.size - kept.size)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def check_collection(collection)
|
|
54
|
+
return if Array === collection && !collection.empty?
|
|
55
|
+
|
|
56
|
+
raise InputError, "collection must be a non-empty Array of series"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def for_series(index)
|
|
60
|
+
yield
|
|
61
|
+
rescue InputError => error
|
|
62
|
+
raise InputError, "series #{index}: #{error.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def check_length(size)
|
|
66
|
+
origins = @options[:origins]
|
|
67
|
+
step = @options[:step]
|
|
68
|
+
required = minimum_history + @horizon + step * (origins - 1)
|
|
69
|
+
return if size >= required
|
|
70
|
+
|
|
71
|
+
raise InputError, "the backtest needs at least #{required} observations for horizon #{@horizon}, " \
|
|
72
|
+
"#{origins} origins and step #{step}, got #{size}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def minimum_history
|
|
76
|
+
name = @options[:model]
|
|
77
|
+
model_minimum = name ? MODELS.fetch(name).minimum_history(@options) : Selector::MINIMUM_HISTORY
|
|
78
|
+
[2, strategy_minimum(name || :ses), model_minimum].max
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def strategy_minimum(name)
|
|
82
|
+
model_class = MODELS.fetch(name)
|
|
83
|
+
if @options[:strategy] == :conformal
|
|
84
|
+
ConformalStrategy.minimum_history(model_class, @options, @horizon)
|
|
85
|
+
elsif Forecaster::SMOOTH_MODELS.include?(name)
|
|
86
|
+
GaussianStrategy.minimum_history(model_class, @options)
|
|
87
|
+
else
|
|
88
|
+
CompoundStrategy.minimum_history(model_class, @options)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def origin_positions(size)
|
|
93
|
+
origins = @options[:origins]
|
|
94
|
+
(0...origins).map { |origin| size - @horizon - @options[:step] * (origins - 1 - origin) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def origin_result(series, position, prefix)
|
|
98
|
+
result = @forecaster.call(prefix)
|
|
99
|
+
actual = series.values[position, @horizon]
|
|
100
|
+
scale = Metrics.scale(prefix.values)
|
|
101
|
+
mae = Metrics.mae(actual, result.forecast)
|
|
102
|
+
zeros = actual.count(&:zero?)
|
|
103
|
+
covered = by_level { |level| actual.zip(result.quantiles[level]).count { |value, quantile| value <= quantile } }
|
|
104
|
+
total = actual.inject(0.0, :+)
|
|
105
|
+
OriginResult.new(start: series.label(position), model: result.model, mase_scale: scale, mae: mae,
|
|
106
|
+
rmse: Metrics.rmse(actual, result.forecast), mase: scaled(mae, scale),
|
|
107
|
+
**scaled_pinball(result.quantiles, actual, scale),
|
|
108
|
+
zero_held_out: zeros, nonzero_held_out: @horizon - zeros,
|
|
109
|
+
coverage: covered.transform_values { |count| count.fdiv(@horizon) }, covered: covered,
|
|
110
|
+
lead_time_hit: by_level { |level| total <= result.lead_time_quantiles[level] })
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def scaled_pinball(quantiles, actual, scale)
|
|
114
|
+
whole = {}
|
|
115
|
+
zero = {}
|
|
116
|
+
nonzero = {}
|
|
117
|
+
@levels.each do |level|
|
|
118
|
+
sums = pinball_sums(level, quantiles[level], actual)
|
|
119
|
+
whole[level], zero[level], nonzero[level] = sums.map { |sum| scaled(sum / @horizon, scale) }
|
|
120
|
+
end
|
|
121
|
+
{ scaled_pinball: whole, scaled_pinball_zero: zero, scaled_pinball_nonzero: nonzero }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def pinball_sums(level, quantiles, actual)
|
|
125
|
+
whole = zero = nonzero = 0.0
|
|
126
|
+
actual.each_with_index do |value, period|
|
|
127
|
+
loss = Metrics.pinball(level, quantiles[period], value)
|
|
128
|
+
whole += loss
|
|
129
|
+
if value.zero?
|
|
130
|
+
zero += loss
|
|
131
|
+
else
|
|
132
|
+
nonzero += loss
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
[whole, zero, nonzero]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def scaled(value, scale)
|
|
139
|
+
scale.zero? ? nil : value / scale
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def aggregate(results, excluded)
|
|
143
|
+
held_out = @horizon * results.size
|
|
144
|
+
covered = by_level { |level| results.sum { |result| result.covered[level] } }
|
|
145
|
+
hits = by_level { |level| results.count { |result| result.lead_time_hit[level] } }
|
|
146
|
+
Backtest.new(origins: results, excluded_origins: excluded,
|
|
147
|
+
zero_scale_origins: results.count { |result| result.mase_scale.zero? }, held_out: held_out,
|
|
148
|
+
zero_held_out: results.sum(&:zero_held_out), nonzero_held_out: results.sum(&:nonzero_held_out),
|
|
149
|
+
mae: mean(results.map(&:mae)), rmse: mean(results.map(&:rmse)), mase: mean(results.map(&:mase)),
|
|
150
|
+
scaled_pinball: level_means(results, &:scaled_pinball),
|
|
151
|
+
scaled_pinball_zero: level_means(results, &:scaled_pinball_zero),
|
|
152
|
+
scaled_pinball_nonzero: level_means(results, &:scaled_pinball_nonzero),
|
|
153
|
+
coverage: covered.transform_values { |count| proportion(count, held_out) },
|
|
154
|
+
lead_time_coverage: hits.transform_values { |count| proportion(count, results.size) },
|
|
155
|
+
covered: covered, lead_time_hits: hits)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def level_means(results)
|
|
159
|
+
by_level { |level| mean(results.map { |result| yield(result)[level] }) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def mean(values)
|
|
163
|
+
present = values.compact
|
|
164
|
+
return if present.empty?
|
|
165
|
+
|
|
166
|
+
present.inject(:+) / present.size
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def proportion(count, total)
|
|
170
|
+
count.fdiv(total) if total.positive?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def by_level
|
|
174
|
+
@levels.to_h { |level| [level, yield(level)] }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
Classification = Struct.new(:demand_class, :adi, :cv_squared, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
module Classifier
|
|
7
|
+
# Cutoffs from Syntetos, Boylan and Croston (2005)
|
|
8
|
+
ADI_CUTOFF = Rational(132, 100)
|
|
9
|
+
CV2_CUTOFF = Rational(49, 100)
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def call(exact_values)
|
|
13
|
+
sizes = exact_values.map(&:to_r).reject(&:zero?)
|
|
14
|
+
raise InputError, "classification requires at least one non-zero observation" if sizes.empty?
|
|
15
|
+
|
|
16
|
+
adi = Rational(exact_values.size, sizes.size)
|
|
17
|
+
mean = sizes.sum / sizes.size
|
|
18
|
+
cv_squared = sizes.sum { |size| (size - mean)**2 } / sizes.size / mean**2
|
|
19
|
+
Classification.new(demand_class: demand_class(adi, cv_squared), adi: adi.to_f, cv_squared: cv_squared.to_f)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def demand_class(adi, cv_squared)
|
|
25
|
+
if adi >= ADI_CUTOFF
|
|
26
|
+
cv_squared >= CV2_CUTOFF ? :lumpy : :intermittent
|
|
27
|
+
else
|
|
28
|
+
cv_squared >= CV2_CUTOFF ? :erratic : :smooth
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
class CompoundStrategy
|
|
5
|
+
def self.minimum_history(model_class, options)
|
|
6
|
+
model_class.minimum_history(options)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(model, horizon:, levels:, paths:, seed:)
|
|
10
|
+
@probability = model.probability
|
|
11
|
+
@sizes = model.sizes
|
|
12
|
+
@horizon = horizon
|
|
13
|
+
@levels = levels
|
|
14
|
+
@simulator = Simulator.new(probability: @probability, sizes: @sizes, horizon: horizon, paths: paths, seed: seed)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def quantiles
|
|
18
|
+
@levels.to_h { |level| [level, Array.new(@horizon, step_quantile(level))] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def lead_time_quantiles
|
|
22
|
+
totals = @simulator.totals.sort
|
|
23
|
+
@levels.to_h { |level| [level, EmpiricalQuantile.at(totals, level)] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def step_quantile(level)
|
|
29
|
+
probability = @probability.to_r
|
|
30
|
+
return 0.0 if @sizes.empty? || level.to_r <= 1 - probability
|
|
31
|
+
|
|
32
|
+
@sizes[((level.to_r - (1 - probability)) * @sizes.size / probability).ceil - 1]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
class ConformalStrategy
|
|
5
|
+
def self.minimum_history(model_class, options, horizon)
|
|
6
|
+
model_class.minimum_history(options) + options[:windows] * horizon
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(model_class, values, options, horizon:, levels:, point:)
|
|
10
|
+
windows = options[:windows]
|
|
11
|
+
window_errors = []
|
|
12
|
+
total_errors = []
|
|
13
|
+
windows.times do |window|
|
|
14
|
+
start = values.size - windows * horizon + window * horizon
|
|
15
|
+
actual = values[start, horizon]
|
|
16
|
+
forecast = model_class.new(values.first(start), options).forecast(horizon)
|
|
17
|
+
window_errors << actual.zip(forecast).map { |value, predicted| (value - predicted).abs }
|
|
18
|
+
total_errors << (actual.sum - forecast.sum).abs
|
|
19
|
+
end
|
|
20
|
+
@levels = levels
|
|
21
|
+
@steps = point.zip(window_errors.transpose).map { |value, errors| spread(value, errors) }
|
|
22
|
+
@totals = spread(point.sum, total_errors)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def quantiles
|
|
26
|
+
@levels.to_h { |level| [level, @steps.map { |sorted| quantile(sorted, level) }] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def lead_time_quantiles
|
|
30
|
+
@levels.to_h { |level| [level, quantile(@totals, level)] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def spread(center, errors)
|
|
36
|
+
errors.flat_map { |error| [center - error, center + error] }.sort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def quantile(sorted, level)
|
|
40
|
+
[0.0, EmpiricalQuantile.at(sorted, level)].max
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
class CrostonClassic
|
|
5
|
+
attr_reader :interval, :probability, :sizes
|
|
6
|
+
|
|
7
|
+
def self.minimum_history(_options)
|
|
8
|
+
1
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(values, options)
|
|
12
|
+
@forecast = 0.0
|
|
13
|
+
@probability = 0.0
|
|
14
|
+
sizes = values.reject(&:zero?)
|
|
15
|
+
@sizes = sizes.sort
|
|
16
|
+
return if sizes.empty?
|
|
17
|
+
|
|
18
|
+
alpha = options[:alpha].to_f
|
|
19
|
+
positions = (1..values.size).reject { |position| values[position - 1].zero? }
|
|
20
|
+
intervals = [0, *positions].each_cons(2).map { |earlier, later| (later - earlier).to_f }
|
|
21
|
+
size = Smoothing.run(sizes, alpha).first
|
|
22
|
+
@interval = Smoothing.run(intervals, alpha).first
|
|
23
|
+
@forecast = interval.zero? ? size : size / interval
|
|
24
|
+
@probability = [1.0, 1.0 / interval].min
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def forecast(horizon)
|
|
28
|
+
Array.new(horizon, @forecast)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
class CrostonSBA
|
|
5
|
+
def self.minimum_history(_options)
|
|
6
|
+
1
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(values, options)
|
|
10
|
+
@factor = 1 - options[:alpha].to_f / 2.0
|
|
11
|
+
@classic = CrostonClassic.new(values, options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def forecast(horizon)
|
|
15
|
+
@classic.forecast(horizon).map { |value| @factor * value }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def probability
|
|
19
|
+
return 0.0 if sizes.empty?
|
|
20
|
+
|
|
21
|
+
# Scaling the occurrence probability by the SBA factor is Foresight's own choice, see docs/methodology.md
|
|
22
|
+
[1.0, @factor / @classic.interval].min
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sizes
|
|
26
|
+
@classic.sizes
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|