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 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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foresight
4
+ module EmpiricalQuantile
5
+ def self.at(sorted, level)
6
+ sorted[(level.to_r * sorted.size).ceil - 1]
7
+ end
8
+ end
9
+ end