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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
EvaluationReport = Struct.new(:classes, :series, :excluded_series, :levels, keyword_init: true)
|
|
5
|
+
ClassSummary = Struct.new(:demand_class, :series_count, :excluded_origins, :zero_scale_origins, :held_out,
|
|
6
|
+
:zero_held_out, :nonzero_held_out, :mae, :rmse, :mase,
|
|
7
|
+
:scaled_pinball, :scaled_pinball_zero, :scaled_pinball_nonzero,
|
|
8
|
+
:coverage, :lead_time_coverage, keyword_init: true)
|
|
9
|
+
SeriesEntry = Struct.new(:index, :demand_class, :backtest, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
class EvaluationReport
|
|
12
|
+
DEMAND_CLASSES = %i[smooth erratic intermittent lumpy].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def aggregate(entries, horizon:, levels:)
|
|
16
|
+
classes = DEMAND_CLASSES.to_h do |demand_class|
|
|
17
|
+
backtests = entries.select { |entry| entry.demand_class == demand_class }.map(&:backtest)
|
|
18
|
+
[demand_class, summary(demand_class, backtests, levels)]
|
|
19
|
+
end
|
|
20
|
+
new(classes: classes, series: entries, excluded_series: 0, levels: levels)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def summary(demand_class, backtests, levels)
|
|
26
|
+
held_out = backtests.sum(&:held_out)
|
|
27
|
+
origins = backtests.sum { |backtest| backtest.origins.size }
|
|
28
|
+
ClassSummary.new(demand_class: demand_class, series_count: backtests.size,
|
|
29
|
+
excluded_origins: backtests.sum(&:excluded_origins),
|
|
30
|
+
zero_scale_origins: backtests.sum(&:zero_scale_origins), held_out: held_out,
|
|
31
|
+
zero_held_out: backtests.sum(&:zero_held_out),
|
|
32
|
+
nonzero_held_out: backtests.sum(&:nonzero_held_out),
|
|
33
|
+
mae: mean(backtests.map(&:mae)), rmse: mean(backtests.map(&:rmse)),
|
|
34
|
+
mase: mean(backtests.map(&:mase)),
|
|
35
|
+
scaled_pinball: level_means(backtests, levels, &:scaled_pinball),
|
|
36
|
+
scaled_pinball_zero: level_means(backtests, levels, &:scaled_pinball_zero),
|
|
37
|
+
scaled_pinball_nonzero: level_means(backtests, levels, &:scaled_pinball_nonzero),
|
|
38
|
+
coverage: pooled(backtests, levels, held_out, &:covered),
|
|
39
|
+
lead_time_coverage: pooled(backtests, levels, origins, &:lead_time_hits))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def level_means(backtests, levels)
|
|
43
|
+
levels.to_h { |level| [level, mean(backtests.map { |backtest| yield(backtest)[level] })] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def pooled(backtests, levels, total)
|
|
47
|
+
levels.to_h { |level| [level, proportion(backtests.sum { |backtest| yield(backtest)[level] }, total)] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def mean(values)
|
|
51
|
+
present = values.compact
|
|
52
|
+
return if present.empty?
|
|
53
|
+
|
|
54
|
+
present.inject(:+) / present.size
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def proportion(count, total)
|
|
58
|
+
count.fdiv(total) if total.positive?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
class Forecaster
|
|
5
|
+
SMOOTH_MODELS = %i[naive seasonal_naive ses].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(options)
|
|
8
|
+
@options = options
|
|
9
|
+
@horizon = options[:count]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(series)
|
|
13
|
+
check_model_history(series.size)
|
|
14
|
+
classification = classify(series.exact)
|
|
15
|
+
name = @options[:model] || Selector.model_for(classification)
|
|
16
|
+
model_class = MODELS.fetch(name)
|
|
17
|
+
kind = strategy_kind(name)
|
|
18
|
+
check_strategy_history(kind, model_class, series.size)
|
|
19
|
+
keys = series.future_keys(@horizon) if series.keys
|
|
20
|
+
model = model_class.new(series.values, @options)
|
|
21
|
+
forecast = model.forecast(@horizon)
|
|
22
|
+
strategy = quantile_strategy(kind, model, series.values, forecast)
|
|
23
|
+
Result.new(forecast: keyed(forecast, keys),
|
|
24
|
+
quantiles: strategy.quantiles.transform_values { |steps| keyed(steps, keys) },
|
|
25
|
+
lead_time_quantiles: strategy.lead_time_quantiles, model: name,
|
|
26
|
+
demand_class: classification&.demand_class, reason: Selector.reason(classification, @options[:model]),
|
|
27
|
+
strategy: @options[:strategy], levels: @options[:levels])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def check_model_history(size)
|
|
33
|
+
model_class = MODELS[@options[:model]]
|
|
34
|
+
return SeasonalNaive.check_history(size, @options) if model_class == SeasonalNaive
|
|
35
|
+
|
|
36
|
+
required = model_class ? model_class.minimum_history(@options) : Selector::MINIMUM_HISTORY
|
|
37
|
+
return if size >= required
|
|
38
|
+
|
|
39
|
+
component = model_class ? model_class.name.split("::").last : "automatic model selection"
|
|
40
|
+
raise InputError, "#{component} requires at least #{required} observations, got #{size}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def classify(exact)
|
|
44
|
+
return Classifier.call(exact) unless exact.all?(&:zero?)
|
|
45
|
+
return if @options[:model]
|
|
46
|
+
|
|
47
|
+
raise InputError, "automatic selection requires at least one non-zero observation; specify a model instead"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def strategy_kind(name)
|
|
51
|
+
return :conformal if @options[:strategy] == :conformal
|
|
52
|
+
|
|
53
|
+
SMOOTH_MODELS.include?(name) ? :gaussian : :compound
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def check_strategy_history(kind, model_class, size)
|
|
57
|
+
required = case kind
|
|
58
|
+
when :gaussian then GaussianStrategy.minimum_history(model_class, @options)
|
|
59
|
+
when :compound then CompoundStrategy.minimum_history(model_class, @options)
|
|
60
|
+
else ConformalStrategy.minimum_history(model_class, @options, @horizon)
|
|
61
|
+
end
|
|
62
|
+
return if size >= required
|
|
63
|
+
|
|
64
|
+
raise InputError, "#{kind} quantiles need at least #{required} observations, got #{size}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def quantile_strategy(kind, model, values, forecast)
|
|
68
|
+
levels = @options[:levels]
|
|
69
|
+
case kind
|
|
70
|
+
when :gaussian then GaussianStrategy.new(model, horizon: @horizon, levels: levels)
|
|
71
|
+
when :compound
|
|
72
|
+
CompoundStrategy.new(model, horizon: @horizon, levels: levels, paths: @options[:paths], seed: @options[:seed])
|
|
73
|
+
else ConformalStrategy.new(model.class, values, @options, horizon: @horizon, levels: levels, point: forecast)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def keyed(steps, keys)
|
|
78
|
+
keys ? keys.zip(steps).to_h : steps
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
class Frequency
|
|
5
|
+
DATE_UNITS = %i[day week month year].freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :unit, :seconds
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def detect(keys)
|
|
11
|
+
frequency = implied_by(keys[0], keys[1])
|
|
12
|
+
keys.each_cons(2) do |earlier, later|
|
|
13
|
+
next if frequency&.follows?(earlier, later)
|
|
14
|
+
|
|
15
|
+
raise InputError, "keys #{key_text(earlier)} and #{key_text(later)} do not follow the series frequency; " \
|
|
16
|
+
"every period must be present, with a value of zero for periods without demand"
|
|
17
|
+
end
|
|
18
|
+
frequency
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def implied_by(earlier, later)
|
|
24
|
+
if Date === earlier
|
|
25
|
+
DATE_UNITS.map { |unit| new(unit) }.find { |frequency| frequency.follows?(earlier, later) }
|
|
26
|
+
elsif later.to_r > earlier.to_r
|
|
27
|
+
new(:second, later.to_r - earlier.to_r)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def key_text(key)
|
|
32
|
+
Date === key ? key.to_s : key.inspect
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(unit, seconds = nil)
|
|
37
|
+
@unit = unit
|
|
38
|
+
@seconds = seconds
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def follows?(earlier, later)
|
|
42
|
+
case unit
|
|
43
|
+
when :day then later - earlier == 1
|
|
44
|
+
when :week then later - earlier == 7
|
|
45
|
+
when :month then later.day == earlier.day && month_index(later) == month_index(earlier) + 1
|
|
46
|
+
when :year then later.month == earlier.month && later.day == earlier.day && later.year == earlier.year + 1
|
|
47
|
+
else later.to_r - earlier.to_r == seconds
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def advance(key, periods)
|
|
52
|
+
case unit
|
|
53
|
+
when :day then key + periods
|
|
54
|
+
when :week then key + 7 * periods
|
|
55
|
+
when :month then month_after(key, periods)
|
|
56
|
+
when :year then key >> (12 * periods)
|
|
57
|
+
else time_after(key, periods)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def month_index(date)
|
|
64
|
+
12 * date.year + date.month
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def month_after(key, periods)
|
|
68
|
+
date = key >> periods
|
|
69
|
+
return date if date.day == key.day
|
|
70
|
+
|
|
71
|
+
raise InputError, "cannot key monthly forecasts after #{key}: #{date.strftime("%Y-%m")} has no day #{key.day}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def time_after(key, periods)
|
|
75
|
+
time = key + periods * seconds
|
|
76
|
+
Time === key ? key.class.at(time) : time
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
class GaussianStrategy
|
|
5
|
+
def self.minimum_history(model_class, options)
|
|
6
|
+
model_class.minimum_history(options) + 1
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(model, horizon:, levels:)
|
|
10
|
+
residuals = model.residuals
|
|
11
|
+
variance = residuals.sum { |residual| residual * residual } / residuals.size
|
|
12
|
+
@sigma = Math.sqrt(variance)
|
|
13
|
+
@forecast = model.forecast(horizon)
|
|
14
|
+
@step_factors = model.step_factors(horizon)
|
|
15
|
+
@total_factor = model.total_factor(horizon)
|
|
16
|
+
@deviates = deviates(levels)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def quantiles
|
|
20
|
+
@deviates.transform_values do |deviate|
|
|
21
|
+
@forecast.zip(@step_factors).map { |value, factor| [0.0, value + deviate * @sigma * Math.sqrt(factor)].max }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def lead_time_quantiles
|
|
26
|
+
total = @forecast.sum
|
|
27
|
+
@deviates.transform_values { |deviate| [0.0, total + deviate * @sigma * Math.sqrt(@total_factor)].max }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def deviates(levels)
|
|
33
|
+
largest = -Float::INFINITY
|
|
34
|
+
levels.to_h do |level|
|
|
35
|
+
# The running maximum absorbs last-bit disagreements between the two AS 241 approximations
|
|
36
|
+
largest = [largest, Normal.quantile(level)].max
|
|
37
|
+
[level, largest]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
module Metrics
|
|
5
|
+
class << self
|
|
6
|
+
def scale(history)
|
|
7
|
+
total = history.each_cons(2).inject(0.0) { |sum, (earlier, later)| sum + (later - earlier).abs }
|
|
8
|
+
total / (history.size - 1)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def mae(actual, forecast)
|
|
12
|
+
total = actual.zip(forecast).inject(0.0) { |sum, (value, predicted)| sum + (value - predicted).abs }
|
|
13
|
+
total / actual.size
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def rmse(actual, forecast)
|
|
17
|
+
total = actual.zip(forecast).inject(0.0) do |sum, (value, predicted)|
|
|
18
|
+
error = value - predicted
|
|
19
|
+
sum + error * error
|
|
20
|
+
end
|
|
21
|
+
Math.sqrt(total / actual.size)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def pinball(level, quantile, actual)
|
|
25
|
+
level = level.to_f
|
|
26
|
+
actual >= quantile ? level * (actual - quantile) : (1 - level) * (quantile - actual)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
class Naive
|
|
5
|
+
attr_reader :residuals
|
|
6
|
+
|
|
7
|
+
def self.minimum_history(_options)
|
|
8
|
+
1
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(values, _options)
|
|
12
|
+
@forecast = values.last
|
|
13
|
+
@residuals = (1...values.size).map { |index| values[index] - values[index - 1] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def forecast(horizon)
|
|
17
|
+
Array.new(horizon, @forecast)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def step_factors(horizon)
|
|
21
|
+
(1..horizon).to_a
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def total_factor(horizon)
|
|
25
|
+
horizon * (horizon + 1) * (2 * horizon + 1) / 6
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
# Wichura (1988), Algorithm AS 241, Applied Statistics 37(3), 477-484
|
|
5
|
+
module Normal
|
|
6
|
+
CENTRAL_NUMERATOR = [
|
|
7
|
+
3.38713_28727_96366_6080, 1.33141_66789_17843_7745e+2, 1.97159_09503_06551_4427e+3,
|
|
8
|
+
1.37316_93765_50946_1125e+4, 4.59219_53931_54987_1457e+4, 6.72657_70927_00870_0853e+4,
|
|
9
|
+
3.34305_75583_58812_8105e+4, 2.50908_09287_30122_6727e+3
|
|
10
|
+
].freeze
|
|
11
|
+
CENTRAL_DENOMINATOR = [
|
|
12
|
+
1.0, 4.23133_30701_60091_1252e+1, 6.87187_00749_20579_0830e+2, 5.39419_60214_24751_1077e+3,
|
|
13
|
+
2.12137_94301_58659_5867e+4, 3.93078_95800_09271_0610e+4, 2.87290_85735_72194_2674e+4,
|
|
14
|
+
5.22649_52788_52854_5610e+3
|
|
15
|
+
].freeze
|
|
16
|
+
INTERMEDIATE_NUMERATOR = [
|
|
17
|
+
1.42343_71107_49683_57734, 4.63033_78461_56545_29590, 5.76949_72214_60691_40550,
|
|
18
|
+
3.64784_83247_63204_60504, 1.27045_82524_52368_38258, 2.41780_72517_74506_11770e-1,
|
|
19
|
+
2.27238_44989_26918_45833e-2, 7.74545_01427_83414_07640e-4
|
|
20
|
+
].freeze
|
|
21
|
+
INTERMEDIATE_DENOMINATOR = [
|
|
22
|
+
1.0, 2.05319_16266_37758_82187, 1.67638_48301_83803_84940, 6.89767_33498_51000_04550e-1,
|
|
23
|
+
1.48103_97642_74800_74590e-1, 1.51986_66563_61645_71966e-2, 5.47593_80849_95344_94600e-4,
|
|
24
|
+
1.05075_00716_44416_84324e-9
|
|
25
|
+
].freeze
|
|
26
|
+
TAIL_NUMERATOR = [
|
|
27
|
+
6.65790_46435_01103_77720, 5.46378_49111_64114_36990, 1.78482_65399_17291_33580,
|
|
28
|
+
2.96560_57182_85048_91230e-1, 2.65321_89526_57612_30930e-2, 1.24266_09473_88078_43860e-3,
|
|
29
|
+
2.71155_55687_43487_57815e-5, 2.01033_43992_92288_13265e-7
|
|
30
|
+
].freeze
|
|
31
|
+
TAIL_DENOMINATOR = [
|
|
32
|
+
1.0, 5.99832_20655_58879_37690e-1, 1.36929_88092_27358_05310e-1, 1.48753_61290_85061_48525e-2,
|
|
33
|
+
7.86869_13114_56132_59100e-4, 1.84631_83175_10054_68180e-5, 1.42151_17583_16445_88870e-7,
|
|
34
|
+
2.04426_31033_89939_78564e-15
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def quantile(level)
|
|
39
|
+
level = level.to_f.clamp(Float::MIN, 1.0 - Float::EPSILON / 2)
|
|
40
|
+
offset = level - 0.5
|
|
41
|
+
if offset.abs <= 0.425
|
|
42
|
+
return offset * ratio(CENTRAL_NUMERATOR, CENTRAL_DENOMINATOR, 0.180625 - offset * offset)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
depth = Math.sqrt(-Math.log(offset < 0 ? level : 1.0 - level))
|
|
46
|
+
deviate = if depth <= 5.0
|
|
47
|
+
ratio(INTERMEDIATE_NUMERATOR, INTERMEDIATE_DENOMINATOR, depth - 1.6)
|
|
48
|
+
else
|
|
49
|
+
ratio(TAIL_NUMERATOR, TAIL_DENOMINATOR, depth - 5.0)
|
|
50
|
+
end
|
|
51
|
+
offset < 0 ? -deviate : deviate
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def ratio(numerator, denominator, argument)
|
|
57
|
+
polynomial(numerator, argument) / polynomial(denominator, argument)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def polynomial(coefficients, argument)
|
|
61
|
+
coefficients.reverse_each.inject { |sum, coefficient| sum * argument + coefficient }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
module Options
|
|
5
|
+
FORECAST = %i[count model levels strategy seed season_length paths windows alpha alpha_d alpha_p].freeze
|
|
6
|
+
BACKTEST = %i[horizon origins step model levels strategy seed season_length paths windows
|
|
7
|
+
alpha alpha_d alpha_p].freeze
|
|
8
|
+
STRATEGIES = %i[distribution conformal].freeze
|
|
9
|
+
SMOOTHING = %i[alpha alpha_d alpha_p].freeze
|
|
10
|
+
INTEGERS_AT_LEAST = { 0 => "a non-negative Integer", 1 => "a positive Integer",
|
|
11
|
+
2 => "an Integer of at least 2" }.freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def forecast(raw)
|
|
15
|
+
check_names(raw, FORECAST)
|
|
16
|
+
supplied = raw.compact
|
|
17
|
+
{ count: integer(supplied, :count, DEFAULT_COUNT, 1), **shared(supplied) }.freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def backtest(raw)
|
|
21
|
+
check_names(raw, BACKTEST)
|
|
22
|
+
supplied = raw.compact
|
|
23
|
+
horizon = required(supplied, :horizon)
|
|
24
|
+
origins = required(supplied, :origins)
|
|
25
|
+
{ horizon: horizon, origins: origins, step: integer(supplied, :step, horizon, 1), **shared(supplied) }.freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def shared(supplied)
|
|
31
|
+
{
|
|
32
|
+
model: model(supplied),
|
|
33
|
+
levels: levels(supplied),
|
|
34
|
+
strategy: strategy(supplied),
|
|
35
|
+
seed: integer(supplied, :seed, DEFAULT_SEED, 0),
|
|
36
|
+
season_length: season_length(supplied),
|
|
37
|
+
paths: integer(supplied, :paths, DEFAULT_PATHS, 1),
|
|
38
|
+
windows: integer(supplied, :windows, DEFAULT_WINDOWS, 2),
|
|
39
|
+
**SMOOTHING.to_h { |name| [name, smoothing(supplied, name)] }
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def required(supplied, name)
|
|
44
|
+
raise InputError, "#{name} is required" unless supplied.key?(name)
|
|
45
|
+
|
|
46
|
+
integer(supplied, name, nil, 1)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check_names(raw, accepted)
|
|
50
|
+
names = raw.keys
|
|
51
|
+
index = names.index { |name| !accepted.include?(name) }
|
|
52
|
+
return unless index
|
|
53
|
+
|
|
54
|
+
raise InputError, "unknown option #{Series.describe(names[index])}; accepted options are #{list(accepted)}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def integer(supplied, name, default, minimum)
|
|
58
|
+
value = supplied.fetch(name, default)
|
|
59
|
+
return value if Integer === value && value >= minimum
|
|
60
|
+
|
|
61
|
+
raise invalid(name, value, INTEGERS_AT_LEAST.fetch(minimum))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def model(supplied)
|
|
65
|
+
model = supplied[:model]
|
|
66
|
+
return model if [nil, *MODELS.keys].include?(model)
|
|
67
|
+
|
|
68
|
+
raise InputError, "model #{Series.describe(model)} is not provided; accepted values are #{list(MODELS.keys)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def levels(supplied)
|
|
72
|
+
levels = supplied.fetch(:levels, DEFAULT_LEVELS)
|
|
73
|
+
unless Array === levels && !levels.empty?
|
|
74
|
+
raise InputError, "levels must be a non-empty Array of numbers strictly between 0 and 1"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
index = levels.index { |level| !between_zero_and_one?(level) }
|
|
78
|
+
raise InputError, "quantile level #{Series.describe(levels[index])} must be strictly between 0 and 1" if index
|
|
79
|
+
|
|
80
|
+
distinct = []
|
|
81
|
+
levels.each { |level| distinct << level unless distinct.include?(level) }
|
|
82
|
+
distinct.sort.freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def strategy(supplied)
|
|
86
|
+
strategy = supplied.fetch(:strategy, :distribution)
|
|
87
|
+
return strategy if STRATEGIES.include?(strategy)
|
|
88
|
+
|
|
89
|
+
raise invalid(:strategy, strategy, STRATEGIES.map(&:inspect).join(" or "))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def season_length(supplied)
|
|
93
|
+
if supplied[:model] == :seasonal_naive
|
|
94
|
+
season_length = supplied[:season_length]
|
|
95
|
+
return season_length if Integer === season_length && season_length >= 1
|
|
96
|
+
|
|
97
|
+
description = supplied.key?(:season_length) ? Series.describe(season_length) : "none"
|
|
98
|
+
raise InputError, "SeasonalNaive requires a positive Integer season_length, got #{description}"
|
|
99
|
+
end
|
|
100
|
+
integer(supplied, :season_length, nil, 1) if supplied.key?(:season_length)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def smoothing(supplied, name)
|
|
104
|
+
alpha = supplied.fetch(name, 0.1)
|
|
105
|
+
return alpha if between_zero_and_one?(alpha)
|
|
106
|
+
|
|
107
|
+
raise invalid(name, alpha, "a finite real number strictly between 0 and 1")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def between_zero_and_one?(number)
|
|
111
|
+
Numeric === number && number.real? && number.finite? && number > 0 && number < 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def invalid(name, value, accepted)
|
|
115
|
+
InputError.new("#{name} must be #{accepted}, got #{Series.describe(value)}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def list(names)
|
|
119
|
+
names.map(&:inspect).join(", ")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
class SeasonalNaive
|
|
5
|
+
attr_reader :residuals
|
|
6
|
+
|
|
7
|
+
def self.minimum_history(options)
|
|
8
|
+
options[:season_length]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.check_history(size, options)
|
|
12
|
+
required = minimum_history(options)
|
|
13
|
+
return if size >= required
|
|
14
|
+
|
|
15
|
+
raise InputError, "SeasonalNaive requires at least #{required} observations (its season_length), got #{size}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(values, options)
|
|
19
|
+
SeasonalNaive.check_history(values.size, options)
|
|
20
|
+
season_length = options[:season_length]
|
|
21
|
+
@season = values.last(season_length)
|
|
22
|
+
@residuals = (season_length...values.size).map { |index| values[index] - values[index - season_length] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def forecast(horizon)
|
|
26
|
+
Array.new(horizon) { |index| @season[index % @season.size] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def step_factors(horizon)
|
|
30
|
+
Array.new(horizon) { |index| 1 + index / @season.size }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def total_factor(horizon)
|
|
34
|
+
(1..[@season.size, horizon].min).sum do |period|
|
|
35
|
+
seasons = (horizon - period) / @season.size + 1
|
|
36
|
+
seasons * (seasons + 1) * (2 * seasons + 1) / 6
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foresight
|
|
4
|
+
module Selector
|
|
5
|
+
RULE = { smooth: :ses, erratic: :croston_sba, intermittent: :croston_sba, lumpy: :croston_sba }.freeze
|
|
6
|
+
MINIMUM_HISTORY = 1
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def model_for(classification)
|
|
10
|
+
RULE.fetch(classification.demand_class)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def reason(classification, model)
|
|
14
|
+
return "#{model} was specified by the caller" if model
|
|
15
|
+
|
|
16
|
+
format("auto-selected %s for %s demand (ADI %.2f, CV squared %.2f)", model_for(classification),
|
|
17
|
+
classification.demand_class, classification.adi.to_r, classification.cv_squared.to_r)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|