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,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
|
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: []
|