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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foresight
4
+ class InputError < StandardError
5
+ end
6
+ 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foresight
4
+ Result = Struct.new(:forecast, :quantiles, :lead_time_quantiles, :model, :demand_class,
5
+ :reason, :strategy, :levels, keyword_init: true)
6
+ 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