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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foresight
4
+ class Series
5
+ LARGEST_VALUE = 10**15
6
+
7
+ attr_reader :values, :exact, :keys, :frequency
8
+
9
+ class << self
10
+ def parse(input, keys_required:)
11
+ case input
12
+ when Array
13
+ exact = input.dup
14
+ check_values(exact, nil)
15
+ new(exact)
16
+ when Hash
17
+ parse_keyed(input, keys_required)
18
+ else
19
+ raise InputError,
20
+ "series must be a Hash with Date or Time keys or an Array of numbers, got #{class_of(input)}"
21
+ end
22
+ end
23
+
24
+ def describe(supplied)
25
+ Object === supplied && supplied.respond_to?(:inspect) ? supplied.inspect : "#<#{class_of(supplied)}>"
26
+ end
27
+
28
+ private
29
+
30
+ def parse_keyed(input, keys_required)
31
+ check_keys(input.keys)
32
+ pairs = input.sort_by { |key, _| key }
33
+ keys = pairs.map(&:first)
34
+ exact = pairs.map(&:last)
35
+ check_values(exact, keys)
36
+ if keys.size >= 2
37
+ new(exact, keys, Frequency.detect(keys))
38
+ elsif keys_required
39
+ raise InputError, "a keyed series needs at least two keys to determine its frequency, got #{keys.size}"
40
+ else
41
+ new(exact, keys)
42
+ end
43
+ end
44
+
45
+ def check_keys(keys)
46
+ date_keys = date?(keys.first)
47
+ index = keys.index { |key| date_keys ? !date?(key) : !time?(key) }
48
+ return unless index
49
+
50
+ raise InputError, "series keys must be all Date values or all Time values, got #{key_text(keys[index])}"
51
+ end
52
+
53
+ def check_values(exact, keys)
54
+ index = exact.index { |value| !finite_real?(value) }
55
+ raise InputError, "series value #{observation(exact, keys, index)} is not a finite real number" if index
56
+
57
+ index = exact.index(&:negative?)
58
+ raise InputError, "series value #{observation(exact, keys, index)} is negative" if index
59
+
60
+ index = exact.index { |value| value > LARGEST_VALUE }
61
+ raise InputError, "series value #{observation(exact, keys, index)} is greater than 1e15" if index
62
+ end
63
+
64
+ def observation(exact, keys, index)
65
+ location = keys ? "key #{key_text(keys[index])}" : "position #{index}"
66
+ "#{describe(exact[index])} at #{location}"
67
+ end
68
+
69
+ def finite_real?(value)
70
+ Numeric === value && value.real? && value.finite?
71
+ end
72
+
73
+ def date?(key)
74
+ Date === key && !(DateTime === key)
75
+ end
76
+
77
+ def time?(key)
78
+ Time === key || (Object === key && key.is_a?(Time))
79
+ end
80
+
81
+ def key_text(key)
82
+ date?(key) ? key.to_s : describe(key)
83
+ end
84
+
85
+ def class_of(supplied)
86
+ Kernel.instance_method(:class).bind_call(supplied)
87
+ end
88
+ end
89
+
90
+ def initialize(exact, keys = nil, frequency = nil)
91
+ @exact = exact
92
+ @values = exact.map(&:to_f)
93
+ @keys = keys
94
+ @frequency = frequency
95
+ end
96
+
97
+ def size
98
+ values.size
99
+ end
100
+
101
+ def label(index)
102
+ keys ? keys[index] : index
103
+ end
104
+
105
+ def prefix(length)
106
+ Series.new(exact.first(length))
107
+ end
108
+
109
+ def future_keys(count)
110
+ (1..count).map { |period| frequency.advance(keys.last, period) }
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foresight
4
+ class SES
5
+ attr_reader :alpha, :residuals
6
+
7
+ def self.minimum_history(_options)
8
+ 1
9
+ end
10
+
11
+ def initialize(values, _options)
12
+ @alpha = Smoothing.golden_section(values, 0.01, 0.99)
13
+ @forecast, fitted = Smoothing.run(values, alpha)
14
+ @residuals = (1...values.size).map { |index| values[index] - fitted[index] }
15
+ end
16
+
17
+ def forecast(horizon)
18
+ Array.new(horizon, @forecast)
19
+ end
20
+
21
+ def step_factors(horizon)
22
+ Array.new(horizon) { |index| 1 + index * alpha * alpha }
23
+ end
24
+
25
+ def total_factor(horizon)
26
+ (0...horizon).sum { |index| (1 + index * alpha)**2 }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foresight
4
+ class Simulator
5
+ def initialize(probability:, sizes:, horizon:, paths:, seed:)
6
+ @probability = probability
7
+ @sizes = sizes
8
+ @horizon = horizon
9
+ @paths = paths
10
+ @seed = seed
11
+ end
12
+
13
+ def totals
14
+ return Array.new(@paths, 0.0) if @sizes.empty?
15
+
16
+ rng = Random.new(@seed)
17
+ Array.new(@paths) do
18
+ total = 0.0
19
+ @horizon.times { total += @sizes[rng.rand(@sizes.size)] if rng.rand < @probability }
20
+ total
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foresight
4
+ # Follows ses_sse, ses_forecast and golden_section_ses in statsforecast src/ses.cpp, operation by operation
5
+ module Smoothing
6
+ class << self
7
+ def sse(values, alpha)
8
+ return 0.0 if values.size < 2
9
+
10
+ complement = 1.0 - alpha
11
+ forecast = values[0]
12
+ sum = 0.0
13
+ (1...values.size).each do |index|
14
+ forecast = alpha * values[index - 1] + complement * forecast
15
+ error = values[index] - forecast
16
+ sum += error * error
17
+ end
18
+ sum
19
+ end
20
+
21
+ def run(values, alpha)
22
+ return [values[0], []] if values.size == 1
23
+
24
+ complement = 1.0 - alpha
25
+ level = values[0]
26
+ fitted = []
27
+ (1...values.size).each do |index|
28
+ level = alpha * values[index - 1] + complement * level
29
+ fitted[index] = level
30
+ end
31
+ [alpha * values.last + complement * level, fitted]
32
+ end
33
+
34
+ def golden_section(values, lower, upper)
35
+ golden_ratio = (Math.sqrt(5.0) + 1.0) / 2.0
36
+ left = upper - (upper - lower) / golden_ratio
37
+ right = lower + (upper - lower) / golden_ratio
38
+ left_sum = sse(values, left)
39
+ right_sum = sse(values, right)
40
+ while (upper - lower).abs >= 1e-12
41
+ if left_sum < right_sum
42
+ upper, right, right_sum = right, left, left_sum
43
+ left = upper - (upper - lower) / golden_ratio
44
+ left_sum = sse(values, left)
45
+ elsif right_sum < left_sum
46
+ lower, left, left_sum = left, right, right_sum
47
+ right = lower + (upper - lower) / golden_ratio
48
+ right_sum = sse(values, right)
49
+ else
50
+ break
51
+ end
52
+ end
53
+ (upper + lower) / 2.0
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foresight
4
+ class TSB
5
+ attr_reader :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
+ occurrences = values.map { |value| value.zero? ? 0.0 : 1.0 }
19
+ occurrence = Smoothing.run(occurrences, options[:alpha_p].to_f).first
20
+ size = Smoothing.run(sizes, options[:alpha_d].to_f).first
21
+ @forecast = occurrence * size
22
+ @probability = occurrence.clamp(0.0, 1.0)
23
+ end
24
+
25
+ def forecast(horizon)
26
+ Array.new(horizon, @forecast)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foresight
4
+ VERSION = "0.1.0"
5
+ end
data/lib/foresight.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "foresight/version"
5
+ require_relative "foresight/input_error"
6
+ require_relative "foresight/frequency"
7
+ require_relative "foresight/series"
8
+ require_relative "foresight/options"
9
+ require_relative "foresight/classifier"
10
+ require_relative "foresight/selector"
11
+ require_relative "foresight/normal"
12
+ require_relative "foresight/empirical_quantile"
13
+ require_relative "foresight/smoothing"
14
+ require_relative "foresight/naive"
15
+ require_relative "foresight/seasonal_naive"
16
+ require_relative "foresight/ses"
17
+ require_relative "foresight/croston_classic"
18
+ require_relative "foresight/croston_sba"
19
+ require_relative "foresight/tsb"
20
+ require_relative "foresight/gaussian_strategy"
21
+ require_relative "foresight/compound_strategy"
22
+ require_relative "foresight/simulator"
23
+ require_relative "foresight/conformal_strategy"
24
+ require_relative "foresight/forecaster"
25
+ require_relative "foresight/result"
26
+ require_relative "foresight/metrics"
27
+ require_relative "foresight/backtester"
28
+ require_relative "foresight/evaluation_report"
29
+
30
+ module Foresight
31
+ DEFAULT_COUNT = 10
32
+ DEFAULT_LEVELS = [0.5, 0.8, 0.9, 0.95].freeze
33
+ DEFAULT_SEED = 0
34
+ DEFAULT_PATHS = 10_000
35
+ DEFAULT_WINDOWS = 2
36
+ MODELS = { naive: Naive, seasonal_naive: SeasonalNaive, ses: SES,
37
+ croston_classic: CrostonClassic, croston_sba: CrostonSBA, tsb: TSB }.freeze
38
+
39
+ def self.forecast(series, **options)
40
+ forecaster = Forecaster.new(Options.forecast(options))
41
+ forecaster.call(Series.parse(series, keys_required: true))
42
+ end
43
+
44
+ def self.classify(series)
45
+ Classifier.call(Series.parse(series, keys_required: false).exact)
46
+ end
47
+
48
+ def self.backtest(series, **options)
49
+ Backtester.new(**options).run(series)
50
+ end
51
+
52
+ def self.evaluate(collection, **options)
53
+ Backtester.new(**options).evaluate(collection)
54
+ end
55
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: foresight
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ojus Chugh
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ executables: []
13
+ extensions: []
14
+ extra_rdoc_files: []
15
+ files:
16
+ - LICENSE.txt
17
+ - README.md
18
+ - lib/foresight.rb
19
+ - lib/foresight/backtester.rb
20
+ - lib/foresight/classifier.rb
21
+ - lib/foresight/compound_strategy.rb
22
+ - lib/foresight/conformal_strategy.rb
23
+ - lib/foresight/croston_classic.rb
24
+ - lib/foresight/croston_sba.rb
25
+ - lib/foresight/empirical_quantile.rb
26
+ - lib/foresight/evaluation_report.rb
27
+ - lib/foresight/forecaster.rb
28
+ - lib/foresight/frequency.rb
29
+ - lib/foresight/gaussian_strategy.rb
30
+ - lib/foresight/input_error.rb
31
+ - lib/foresight/metrics.rb
32
+ - lib/foresight/naive.rb
33
+ - lib/foresight/normal.rb
34
+ - lib/foresight/options.rb
35
+ - lib/foresight/result.rb
36
+ - lib/foresight/seasonal_naive.rb
37
+ - lib/foresight/selector.rb
38
+ - lib/foresight/series.rb
39
+ - lib/foresight/ses.rb
40
+ - lib/foresight/simulator.rb
41
+ - lib/foresight/smoothing.rb
42
+ - lib/foresight/tsb.rb
43
+ - lib/foresight/version.rb
44
+ homepage: https://github.com/ojuschugh1/foresight
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ source_code_uri: https://github.com/ojuschugh1/foresight
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '3.1'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.6.9
64
+ specification_version: 4
65
+ summary: Probabilistic demand forecasting for smooth and intermittent series
66
+ test_files: []