tealeaves 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,5 @@
1
+ lib/**/*.rb
2
+ bin/*
3
+ -
4
+ features/**/*.feature
5
+ LICENSE.txt
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,11 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.8.7
4
+ - 1.9.2
5
+ notifications:
6
+ recipients:
7
+ - roland.swingler@gmail.com
8
+ branches:
9
+ only:
10
+ - master
11
+ bundler_args: --without development
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ source "http://rubygems.org"
2
+
3
+ group :development do
4
+ gem "rake"
5
+ gem "rspec", "~> 2.8.0"
6
+ gem "yard", "~> 0.7"
7
+ gem "rdoc", "~> 3.12"
8
+ gem "bundler", "~> 1.0.0"
9
+ gem "jeweler", "~> 1.8.3"
10
+ gem "rcov", ">= 0"
11
+ end
12
+
13
+ group :test do
14
+ gem "rake"
15
+ gem "rspec", "~> 2.8.0"
16
+ end
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010 Roland Swingler
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,40 @@
1
+ = tealeaves
2
+
3
+ {<img src="https://secure.travis-ci.org/knaveofdiamonds/tealeaves.png?branch=master" alt="Build Status" />}[http://travis-ci.org/knaveofdiamonds/tealeaves]
4
+
5
+ Tealeaves is a simple forecasting toolset for ruby, able to product short term forecasts for time series data.
6
+
7
+ It implements Exponential Smoothing methods, including those dealing with seasonality & trends, and has some basic functionality to determine optimal models.
8
+
9
+ == Usage
10
+
11
+ require 'tealeaves'
12
+
13
+ # A set of time series data
14
+ data = [1.0, 3.3 ... 24.56]
15
+
16
+ # Period, for example 12 for monthly data
17
+ period = 12
18
+
19
+ # Get an 'optimal' model
20
+ TeaLeaves.optimal_model(data, period)
21
+
22
+ # Or next period's forecasts from the optimal model
23
+ TeaLeaves.forecast(data, period)
24
+
25
+ # Or the next n period's forecasts from the optimal model
26
+ TeaLeaves.forecast(data, period, 3)
27
+
28
+ == Note on Patches/Pull Requests
29
+
30
+ * Fork the project.
31
+ * Make your feature addition or bug fix.
32
+ * Add tests for it. This is important so I don't break it in a
33
+ future version unintentionally.
34
+ * Commit, do not mess with rakefile, version, or history.
35
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
36
+ * Send me a pull request. Bonus points for topic branches.
37
+
38
+ == Copyright
39
+
40
+ Copyright (c) 2010 Roland Swingler. See LICENSE for details.
@@ -0,0 +1,50 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'bundler'
5
+ begin
6
+ if ENV["TRAVIS"]
7
+ Bundler.setup(:default, :test)
8
+ else
9
+ Bundler.setup(:default, :development)
10
+ end
11
+ rescue Bundler::BundlerError => e
12
+ $stderr.puts e.message
13
+ $stderr.puts "Run `bundle install` to install missing gems"
14
+ exit e.status_code
15
+ end
16
+ require 'rake'
17
+
18
+ begin
19
+ require 'jeweler'
20
+ Jeweler::Tasks.new do |gem|
21
+ # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
22
+ gem.name = "tealeaves"
23
+ gem.homepage = "http://github.com/knaveofdiamonds/tealeaves"
24
+ gem.license = "MIT"
25
+ gem.summary = %Q{Simple Forecasting Methods in Ruby}
26
+ gem.description = %Q{Exponential smoothing based forecasting methods for time series data}
27
+ gem.email = "roland.swingler@gmail.com"
28
+ gem.authors = ["Roland Swingler"]
29
+ # dependencies defined in Gemfile
30
+ end
31
+ Jeweler::RubygemsDotOrgTasks.new
32
+
33
+ require 'yard'
34
+ YARD::Rake::YardocTask.new
35
+ rescue LoadError => e
36
+ $stderr.puts "Not loading standard development tasks."
37
+ end
38
+
39
+ require 'rspec/core'
40
+ require 'rspec/core/rake_task'
41
+ RSpec::Core::RakeTask.new(:spec) do |spec|
42
+ spec.pattern = FileList['spec/**/*_spec.rb']
43
+ end
44
+
45
+ RSpec::Core::RakeTask.new(:rcov) do |spec|
46
+ spec.pattern = 'spec/**/*_spec.rb'
47
+ spec.rcov = true
48
+ end
49
+
50
+ task :default => :spec
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.1
@@ -0,0 +1,17 @@
1
+ require 'tealeaves/moving_average'
2
+ require 'tealeaves/seasonal_components'
3
+ require 'tealeaves/forecast'
4
+ require 'tealeaves/naive_forecast'
5
+ require 'tealeaves/single_exponential_smoothing_forecast'
6
+ require 'tealeaves/brute_force_optimization'
7
+ require 'tealeaves/exponential_smoothing_forecast'
8
+
9
+ module TeaLeaves
10
+ def self.optimal_model(time_series, period)
11
+ BruteForceOptimization.new(time_series, period).optimize
12
+ end
13
+
14
+ def self.forecast(time_series, period, periods_ahead=nil)
15
+ optimal_model(time_series, period).predict(periods_ahead)
16
+ end
17
+ end
@@ -0,0 +1,84 @@
1
+ module TeaLeaves
2
+ class BruteForceOptimization
3
+ INITIAL_PARAMETER_VALUES = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0].freeze
4
+
5
+ def initialize(time_series, period, opts={})
6
+ @time_series = time_series
7
+ @period = period
8
+ @opts = opts
9
+ end
10
+
11
+
12
+ def optimize
13
+ [0.1, 0.5, 0.25, 0.125, 0.0625, 0.03125, 0.015625].inject(optimum(initial_models)) do |model, change|
14
+ improve_model(model, change)
15
+ end
16
+ end
17
+
18
+ def initial_test_parameters(opts={})
19
+ parameters = []
20
+ INITIAL_PARAMETER_VALUES.each do |alpha|
21
+ parameters << {:alpha => alpha, :seasonality => :none, :trend => :none}
22
+
23
+ unless opts[:seasonality] == :none && opts[:trend] == :none
24
+ INITIAL_PARAMETER_VALUES.each do |b|
25
+ parameters << {:alpha => alpha, :beta => b, :seasonality => :none, :trend => :additive}
26
+ parameters << {:alpha => alpha, :beta => b, :seasonality => :none, :trend => :multiplicative}
27
+ parameters << {:alpha => alpha, :gamma => b, :trend => :none, :seasonality => :additive}
28
+ parameters << {:alpha => alpha, :gamma => b, :trend => :none, :seasonality => :multiplicative}
29
+
30
+ INITIAL_PARAMETER_VALUES.each do |gamma|
31
+ [:additive, :multiplicative].each do |trend|
32
+ [:additive, :multiplicative].each do |seasonality|
33
+ parameters << {
34
+ :alpha => alpha,
35
+ :beta => b,
36
+ :gamma => gamma,
37
+ :trend => trend,
38
+ :seasonality => seasonality
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ parameters
48
+ end
49
+
50
+ private
51
+
52
+ def improve_model(model, change)
53
+ trend_operations = model.trend == :none ? [nil] : [:+, :-, nil]
54
+ season_operations = model.seasonality == :none ? [nil] : [:+, :-, nil]
55
+ permutations = [:+, :-, nil].product(trend_operations, season_operations)
56
+ optimum(permutations.map do |(op_1,op_2,op_3)|
57
+ new_opts = {}
58
+ set_value(new_opts, :alpha, model, op_1, change)
59
+ set_value(new_opts, :beta, model, op_2, change)
60
+ set_value(new_opts, :gamma, model, op_3, change)
61
+ model.improve(new_opts)
62
+ end)
63
+ end
64
+
65
+ def set_value(hsh, key, model, op, change)
66
+ unless op.nil?
67
+ new_value = model.send(key).send(op, change)
68
+ if new_value >= 0.0 && new_value <= 1.0
69
+ hsh[key] = new_value
70
+ end
71
+ end
72
+ end
73
+
74
+ def optimum(models)
75
+ models.min_by(&:mean_squared_error)
76
+ end
77
+
78
+ def initial_models
79
+ initial_test_parameters.map do |parameters|
80
+ ExponentialSmoothingForecast.new(@time_series, @period, parameters)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,212 @@
1
+ require 'tealeaves/forecast'
2
+
3
+ module TeaLeaves
4
+ class ExponentialSmoothingForecast < Forecast
5
+ class SeasonalityStrategy
6
+ attr_reader :start_index
7
+
8
+ def initialize(period, gamma)
9
+ @gamma = gamma
10
+ @start_index = period
11
+ end
12
+
13
+ def new_values(observed_value, parameters, new_level)
14
+ new_seasonality = @gamma * t(observed_value, new_level) +
15
+ (1 - @gamma) * parameters[:seasonality].first
16
+ parameters[:seasonality].drop(1) << new_seasonality
17
+ end
18
+ end
19
+
20
+ class AdditiveSeasonalityStrategy < SeasonalityStrategy
21
+ def p(value, params)
22
+ value - params[:seasonality].first
23
+ end
24
+
25
+ def t(value, new_level)
26
+ value - new_level
27
+ end
28
+
29
+ def apply(forecast, parameters, n)
30
+ index = (n - 1) % parameters[:seasonality].size
31
+ forecast + parameters[:seasonality][index]
32
+ end
33
+ end
34
+
35
+ class MultiplicativeSeasonalityStrategy < SeasonalityStrategy
36
+ def p(value, params)
37
+ value / params[:seasonality].first
38
+ end
39
+
40
+ def t(value, new_level)
41
+ value / new_level
42
+ end
43
+
44
+ def apply(forecast, parameters, n)
45
+ index = (n - 1) % parameters[:seasonality].size
46
+ forecast * parameters[:seasonality][index]
47
+ end
48
+ end
49
+
50
+ class NoSeasonalityStrategy < SeasonalityStrategy
51
+ def p(value, params)
52
+ value
53
+ end
54
+
55
+ def t(value, new_level)
56
+ end
57
+
58
+ def new_values(*args)
59
+ []
60
+ end
61
+
62
+ def start_index
63
+ 1
64
+ end
65
+
66
+ def apply(forecast, parameters, n)
67
+ forecast
68
+ end
69
+ end
70
+
71
+ attr_reader :alpha, :beta, :gamma, :trend, :seasonality
72
+
73
+ def initialize(time_series, period, opts={})
74
+ @time_series = time_series
75
+ @period = period
76
+ @alpha = opts[:alpha]
77
+ @beta = opts[:beta]
78
+ @gamma = opts[:gamma]
79
+ @trend = opts[:trend]
80
+ @seasonality = opts[:seasonality]
81
+ @seasonality_strategy = case @seasonality
82
+ when :none
83
+ NoSeasonalityStrategy.new(@period, opts[:gamma])
84
+ when :additive
85
+ AdditiveSeasonalityStrategy.new(@period, opts[:gamma])
86
+ when :multiplicative
87
+ MultiplicativeSeasonalityStrategy.new(@period, opts[:gamma])
88
+ end
89
+
90
+ calculate_one_step_ahead_forecasts
91
+ end
92
+
93
+ def improve(opts)
94
+ new_opts = {:alpha => @alpha, :beta => @beta, :gamma => @gamma, :trend => @trend, :seasonality => @seasonality}.merge(opts)
95
+ self.class.new(@time_series, @period, new_opts)
96
+ end
97
+
98
+ attr_reader :model_parameters
99
+
100
+ def initial_level
101
+ @initial_level ||= @time_series.take(@period).inject(&:+).to_f / @period
102
+ end
103
+
104
+ def initial_trend
105
+ period_1, period_2 = @time_series.each_slice(@period).take(2)
106
+ period_1.zip(period_2).map {|(a,b)| (b - a) / @period.to_f }.inject(&:+) / @period
107
+ end
108
+
109
+ def initial_seasonal_indices
110
+ operation = @seasonality == :multiplicative ? :/ : :-
111
+ @time_series.take(@period).map {|v| v.to_f.send(operation, initial_level) }
112
+ end
113
+
114
+ def initial_parameters
115
+ { :level => initial_level,
116
+ :trend => initial_trend,
117
+ :seasonality => initial_seasonal_indices,
118
+ :index => @seasonality_strategy.start_index
119
+ }
120
+ end
121
+
122
+ def predict(n=nil)
123
+ if n.nil?
124
+ forecast(@model_parameters).first
125
+ else
126
+ (1..n).map {|i| forecast(@model_parameters, i).first }
127
+ end
128
+ end
129
+
130
+ # Returns the mean squared error of the forecast.
131
+ def mean_squared_error
132
+ return @mean_squared_error if @mean_squared_error
133
+
134
+ numerator = errors.drop(@seasonality_strategy.start_index).map {|i| i ** 2 }.inject(&:+)
135
+ @mean_squared_error = numerator / (errors.size - @seasonality_strategy.start_index).to_f
136
+ end
137
+
138
+ private
139
+
140
+ def calculate_one_step_ahead_forecasts
141
+ forecasts = [nil] * @seasonality_strategy.start_index
142
+ parameters = initial_parameters
143
+ (@seasonality_strategy.start_index...@time_series.size).each do |i|
144
+ forecast, parameters = forecast(parameters)
145
+ forecasts << forecast
146
+ end
147
+ parameters[:index] -= 1
148
+ @model_parameters = parameters
149
+ @one_step_ahead_forecasts = forecasts
150
+ end
151
+
152
+ def forecast(parameters, n=1)
153
+ new_params = {}
154
+ new_params[:level] = new_level(parameters)
155
+ new_params[:trend] = new_trend(parameters, new_params[:level])
156
+ new_params[:seasonality] = new_seasonality(parameters, new_params[:level])
157
+ new_params[:index] = parameters[:index] + 1
158
+
159
+ pre_forecast = case @trend
160
+ when :none
161
+ parameters[:level]
162
+ when :additive
163
+ parameters[:level] + (n * parameters[:trend])
164
+ when :multiplicative
165
+ parameters[:level] * (parameters[:trend] ** n)
166
+ end
167
+
168
+ forecast = @seasonality_strategy.apply(pre_forecast, parameters, n)
169
+ [forecast, new_params]
170
+ end
171
+
172
+ def new_level(parameters)
173
+ @alpha * p(parameters) + (1 - @alpha) * q(parameters)
174
+ end
175
+
176
+ def new_trend(parameters, new_level)
177
+ unless @trend == :none
178
+ @beta * r(parameters, new_level) + (1 - @beta) * parameters[:trend]
179
+ end
180
+ end
181
+
182
+ def new_seasonality(parameters, new_level)
183
+ @seasonality_strategy.new_values(@time_series[parameters[:index]],
184
+ parameters,
185
+ new_level)
186
+ end
187
+
188
+ def p(params)
189
+ @seasonality_strategy.p(@time_series[params[:index]], params)
190
+ end
191
+
192
+ def q(params)
193
+ case @trend
194
+ when :none
195
+ params[:level]
196
+ when :additive
197
+ params[:level] + params[:trend]
198
+ when :multiplicative
199
+ params[:level] * params[:trend]
200
+ end
201
+ end
202
+
203
+ def r(params, new_level)
204
+ case @trend
205
+ when :additive
206
+ new_level - params[:level]
207
+ when :multiplicative
208
+ new_level / params[:level]
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,22 @@
1
+ module TeaLeaves
2
+ class Forecast
3
+ # Returns an array of 1 step ahead forecasts. The initial value in
4
+ # this array will be nil - there is no way of predicting the first
5
+ # value of the series.
6
+ attr_reader :one_step_ahead_forecasts
7
+
8
+ # Returns the errors between the observed values and the one step
9
+ # ahead forecasts.
10
+ def errors
11
+ @errors ||= @time_series.zip(one_step_ahead_forecasts).map do |(observation, forecast)|
12
+ forecast - observation if forecast && observation
13
+ end
14
+ end
15
+
16
+ # Returns the mean squared error of the forecast.
17
+ def mean_squared_error
18
+ numerator = errors.drop(1).map {|i| i ** 2 }.inject(&:+)
19
+ numerator / (errors.size - 1).to_f
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,110 @@
1
+ module TeaLeaves
2
+ # A calculator for simple & weighted moving averages.
3
+ class MovingAverage
4
+ attr_reader :weights, :span
5
+
6
+ # Returns a Weighted Moving Average calculator, given a list of
7
+ # weights.
8
+ #
9
+ # The list of weights should be an odd length, and should sum to
10
+ # 1.
11
+ #
12
+ # Examples:
13
+ #
14
+ # MovingAverage.weighted([0.15, 0.7, 0.15]) # => [0.2, 0.7, 0.1]
15
+ #
16
+ def self.weighted(weights)
17
+ new(weights)
18
+ end
19
+
20
+ # Returns a Simple Moving Average calculator, given a span n.
21
+ #
22
+ # Examples:
23
+ #
24
+ # MovingAverage.simple(5).weights # => [0.2, 0.2, 0.2, 0.2, 0.2]
25
+ #
26
+ # @param [Integer] n the span or order of the moving average,
27
+ # i.e. the number of terms to include in each average.
28
+ def self.simple(n)
29
+ raise ArgumentError.new("Number of terms must be positive") if n < 1
30
+ weights = n.odd?() ? [1.0 / n] * n : expand_weights([1.0 / n] * (n / 2) + [1.0 / (2 * n)])
31
+ new weights
32
+ end
33
+
34
+
35
+ # Returns a moving average of a moving average calculator.
36
+ #
37
+ # For Example, for a 3x3 MA:
38
+ #
39
+ # MovingAverage.multiple(3,3).weights #=> [1/9, 2/9, 1/3, 2/9, 1/9]
40
+ #
41
+ def self.multiple(m,n)
42
+ divisor = (m * n).to_f
43
+ weights = (1..m).map {|i| i / divisor }
44
+ num_of_center_weights = ((m + n) / 2) - m
45
+ num_of_center_weights = 0 if num_of_center_weights < 0
46
+ num_of_center_weights.times { weights << weights.last }
47
+
48
+ new(expand_weights(weights.reverse))
49
+ end
50
+
51
+ # Creates a new MovingAverage given a list of weights.
52
+ #
53
+ # See also the class methods simple and weighted.
54
+ def initialize(weights)
55
+ @weights = weights
56
+ @span = @weights.length
57
+ check_weights
58
+ end
59
+
60
+ # Calculates the moving average for the given array of numbers.
61
+ #
62
+ # Moving averages won't include values for terms at the beginning or end of
63
+ # the array, so there will be fewer numbers than in the original.
64
+ def calculate(array)
65
+ return [] if @span > array.length
66
+
67
+ array.each_cons(@span).map do |window|
68
+ window.zip(weights).map {|(a,b)| a * b }.inject(&:+)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ # Error checking for weights
75
+ def check_weights
76
+ raise ArgumentError.new("Weights should be an odd list") unless @span.odd?
77
+ sum = weights.inject(&:+)
78
+ if sum < 0.999999 || sum > 1.000001
79
+ raise ArgumentError.new("Weights must sum to 1")
80
+ end
81
+ end
82
+
83
+ def self.expand_weights(weights)
84
+ left_side_weights = weights.reverse
85
+ left_side_weights.pop
86
+ left_side_weights + weights
87
+ end
88
+ end
89
+
90
+ module ArrayMethods
91
+ # Returns a moving average for this array, given either a number
92
+ # of terms or a list of weights.
93
+ #
94
+ # See MovingAverage for more detail.
95
+ #
96
+ def moving_average(average_specifier)
97
+ if average_specifier.kind_of?(Array)
98
+ avg = MovingAverage.weighted(average_specifier)
99
+ elsif average_specifier.kind_of?(Integer)
100
+ avg = MovingAverage.simple(average_specifier)
101
+ else
102
+ raise ArgumentError.new("Unknown weights")
103
+ end
104
+
105
+ avg.calculate(self)
106
+ end
107
+ end
108
+ end
109
+
110
+ Array.send(:include, TeaLeaves::ArrayMethods)
@@ -0,0 +1,30 @@
1
+ require 'tealeaves/forecast'
2
+
3
+ module TeaLeaves
4
+ # A naive model just uses the current period's value as the
5
+ # prediction for the next period, i.e. F_t+1 = Y_t
6
+ class NaiveForecast < Forecast
7
+ # Creates a naive forecasting model for the given time series.
8
+ def initialize(time_series)
9
+ @time_series = time_series
10
+ @one_step_ahead_forecasts = [nil] + time_series
11
+ @one_step_ahead_forecasts.pop
12
+ end
13
+
14
+ # Returns Thiel's U Statistic. By definition, this is 1 for the
15
+ # Naive method.
16
+ def u_statistic
17
+ 1
18
+ end
19
+
20
+ # Returns a prediction for the next period, or for the next n
21
+ # periods.
22
+ def predict(n=nil)
23
+ if n.nil?
24
+ @time_series.last
25
+ else
26
+ [@time_series.last] * n
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,28 @@
1
+ module TeaLeaves
2
+ class SeasonalComponents
3
+ def initialize(period, data)
4
+ @period = period
5
+ @data = data
6
+ end
7
+
8
+ def seasonal_averages
9
+ @seasonal_averages ||= seasonal_groups.map do |group|
10
+ group.inject(&:+) / group.size.to_f
11
+ end
12
+ end
13
+
14
+ def seasonal_factors(operation = :-)
15
+ @seasonal_factors ||= seasonal_averages.map {|i| i.send(operation, avg) }
16
+ end
17
+
18
+ private
19
+
20
+ def avg
21
+ @avg ||= seasonal_averages.inject(&:+) / @period.to_f
22
+ end
23
+
24
+ def seasonal_groups
25
+ @data.take(@period).zip( *(@data.drop(@period).each_slice(@period)) ).map(&:compact)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ require 'tealeaves/forecast'
2
+
3
+ module TeaLeaves
4
+ class SingleExponentialSmoothingForecast < Forecast
5
+ def initialize(time_series, alpha)
6
+ @time_series = time_series
7
+ @alpha = alpha
8
+
9
+ @one_step_ahead_forecasts = [nil]
10
+
11
+ ([@time_series.first] + @time_series).inject do |a,b|
12
+ value = (1 - @alpha) * a + @alpha * b
13
+ @one_step_ahead_forecasts << value
14
+ value
15
+ end
16
+
17
+ @prediction = @one_step_ahead_forecasts.pop
18
+ end
19
+
20
+ def predict(n=nil)
21
+ if n.nil?
22
+ @prediction
23
+ else
24
+ [@prediction] * n
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,11 @@
1
+ require 'spec_helper'
2
+
3
+ describe TeaLeaves::BruteForceOptimization do
4
+ it "should have 1014 initial test models" do
5
+ described_class.new([1,2,3,4], 1).initial_test_parameters.size.should == 1014
6
+ end
7
+
8
+ it "should produce an initial model" do
9
+
10
+ end
11
+ end
@@ -0,0 +1,77 @@
1
+ require 'spec_helper'
2
+
3
+ describe TeaLeaves::ExponentialSmoothingForecast do
4
+ before :each do
5
+ @time_series = [1,2,3,5,
6
+ 3,4,5,8,
7
+ 6,7,8,10]
8
+ @options = {
9
+ :seasonality => :additive,
10
+ :trend => :additive,
11
+ :alpha => 0.7,
12
+ :beta => 0.1,
13
+ :gamma => 0.1
14
+ }
15
+ @forecast = described_class.new(@time_series, 4, @options)
16
+ end
17
+
18
+ it "should have an initial level of 11 / 4" do
19
+ @forecast.initial_level.
20
+ should be_within(0.001).of(11.0 / 4.0)
21
+ end
22
+
23
+ it "should have an initial trend" do
24
+ @forecast.initial_trend.
25
+ should be_within(0.0001).of(0.5625)
26
+ end
27
+
28
+ # [1 / (11/4.0), 2 / (11/4.0), 3 / (11/4.0), 5 / (11/4.0)]
29
+ it "should have initial seasonal indices for additive seasonality" do
30
+ @forecast.initial_seasonal_indices.should == [1 - (11/4.0), 2 - (11/4.0), 3 - (11/4.0), 5 - (11/4.0)]
31
+ end
32
+
33
+ it "should have initial seasonal indices for additive seasonality" do
34
+ @options[:seasonality] = :multiplicative
35
+ @forecast = described_class.new(@time_series, 4, @options)
36
+ @forecast.initial_seasonal_indices.should == [1 / (11/4.0), 2 / (11/4.0), 3 / (11/4.0), 5 / (11/4.0)]
37
+ end
38
+
39
+ it "should generate forecasts" do
40
+ data = [362,
41
+ 385,
42
+ 432,
43
+ 341,
44
+ 382,
45
+ 409,
46
+ 498,
47
+ 387,
48
+ 473,
49
+ 513,
50
+ 582,
51
+ 474]
52
+
53
+ @forecast = described_class.new(data, 4,
54
+ :alpha => 0.822,
55
+ :beta => 0.055,
56
+ :gamma => 0.0,
57
+ :trend => :additive,
58
+ :seasonality => :multiplicative)
59
+
60
+ @forecast.initial_level.should == 380
61
+ @forecast.initial_trend.should == 9.75
62
+ @forecast.initial_seasonal_indices.zip([0.953, 1.013, 1.137, 0.897]).each do |(observed, expected)|
63
+ observed.should be_within(0.001).of(expected)
64
+ end
65
+
66
+ expected_values = [371.29, 414.64, 471.43, 399.3, 423.11, 506.60, 589.26, 471.93]
67
+ @forecast.one_step_ahead_forecasts.drop(4).zip(expected_values).each do |(observed, expected)|
68
+ observed.should be_within(0.03).of(expected)
69
+ end
70
+ end
71
+
72
+ it "should predict new values" do
73
+ @forecast.predict(4).zip([6.9, 8.4, 9.9, 12.5]).each do |(observed, expected)|
74
+ observed.should be_within(0.1).of(expected)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,61 @@
1
+ require 'spec_helper'
2
+
3
+ describe TeaLeaves::MovingAverage do
4
+ it "should mix a #moving_average method into Array that takes a span" do
5
+ [0,1,2,6,4].moving_average(3).should == [1,3,4]
6
+ end
7
+
8
+ it "should mix a #moving_average method into Array that takes weights" do
9
+ [0,1,2,6,4].moving_average([0.2,0.6,0.2]).should == [1.0, 2.6, 4.8]
10
+ end
11
+
12
+ describe "a Simple Moving Average" do
13
+ it "should raise an ArgumentError if number of terms is < 1" do
14
+ lambda { described_class.simple(0) }.should raise_error(ArgumentError)
15
+ end
16
+
17
+ it "should have equal weights with an odd number of terms" do
18
+ described_class.simple(5).weights.should == [0.2, 0.2, 0.2, 0.2, 0.2]
19
+ end
20
+
21
+ it "should have have half weights at the ends with an even number of terms" do
22
+ described_class.simple(4).weights.should == [0.125, 0.25, 0.25, 0.25, 0.125]
23
+ end
24
+
25
+ it "should return 3 averages from #calculate with a 3 point MA over 5 terms" do
26
+ described_class.simple(3).calculate([0,1,2,6,4]).should == [1,3,4]
27
+ end
28
+
29
+ it "should return 1 average from #calculate with a 4 point MA over 5 terms" do
30
+ described_class.simple(4).calculate([0,1,2,6,4]).should == [2.75]
31
+ end
32
+
33
+ it "should return no averages from #calculate with a 7 point MA over 5 terms" do
34
+ described_class.simple(7).calculate([0,1,2,6,4]).should == []
35
+ end
36
+ end
37
+
38
+ describe "a Weighted Moving Average" do
39
+ it "raises an Argument error if the weights do not sum to 1" do
40
+ expect { described_class.weighted([0.5, 0.5, 0.1]) }.to raise_error(ArgumentError)
41
+ end
42
+
43
+ it "raises an Argument error if the list of weights is not odd sized" do
44
+ expect { described_class.weighted([0.5, 0.5]) }.to raise_error(ArgumentError)
45
+ end
46
+
47
+ it "should allow asymmetric weights" do
48
+ described_class.weighted([0.6, 0.3, 0.1]).weights == [0.6, 0.3, 0.1]
49
+ end
50
+ end
51
+
52
+ describe "a Mutliple Moving Average" do
53
+ it "should combine weights in the 3x3 case" do
54
+ described_class.multiple(3,3).weights.should == [1.0/9, 2.0/9, 3.0/9, 2.0/9, 1.0/9]
55
+ end
56
+
57
+ it "should combine weights in the 3x5 case" do
58
+ described_class.multiple(3,5).weights.should == [1.0/15, 2.0/15, 0.2, 0.2, 0.2, 2.0/15, 1.0/15]
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,27 @@
1
+ require 'spec_helper'
2
+
3
+ describe TeaLeaves::NaiveForecast do
4
+ before :each do
5
+ @time_series = [1,2,3]
6
+ end
7
+
8
+ it "provides forecasts such that F_t+1 = Y_t" do
9
+ described_class.new(@time_series).one_step_ahead_forecasts.should == [nil, 1, 2]
10
+ end
11
+
12
+ it "returns errors between one step ahead forecasts and observed values" do
13
+ described_class.new(@time_series).errors.should == [nil, -1, -1]
14
+ end
15
+
16
+ it "returns a prediction for the next value in the series" do
17
+ described_class.new(@time_series).predict.should == 3
18
+ end
19
+
20
+ it "returns n predictions for the next values, all the same" do
21
+ described_class.new(@time_series).predict(4).should == [3,3,3,3]
22
+ end
23
+
24
+ it "returns 1 as Thiel's U Statistic" do
25
+ described_class.new(@time_series).u_statistic.should == 1
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ require 'spec_helper'
2
+
3
+ describe TeaLeaves::SeasonalComponents do
4
+ it "returns seasonal averages for a period" do
5
+ described_class.new(4, [1,2,1,3,2,4,1,9]).seasonal_averages.
6
+ should == [1.5, 3, 1, 6]
7
+ end
8
+
9
+ it "returns seasonal averages for a period, with ragged data" do
10
+ described_class.new(4, [1,2,1,3,2,4,1,9, 3]).seasonal_averages.
11
+ should == [2, 3, 1, 6]
12
+ end
13
+
14
+ it "returns seasonal weights" do
15
+ described_class.new(4, [1,2,1,3,2,4,1,9,3]).seasonal_factors.
16
+ should == [-1.0, 0.0, -2.0, 3.0]
17
+ end
18
+ end
@@ -0,0 +1,28 @@
1
+ require 'spec_helper'
2
+
3
+ describe TeaLeaves::SingleExponentialSmoothingForecast do
4
+ before :each do
5
+ @time_series = [1,2,3]
6
+ end
7
+
8
+ it "should generate one step ahead forecasts" do
9
+ described_class.new(@time_series, 0.7).one_step_ahead_forecasts.
10
+ should == [nil, 0.7 * 1 + 0.3 * 1, 0.7 * 2 + 0.3 * 1]
11
+ end
12
+
13
+ it "has one step ahead errors" do
14
+ described_class.new(@time_series, 0.7).errors.should == [nil, -1, -1.3]
15
+ end
16
+
17
+ it "has a predicition for the next period" do
18
+ described_class.new(@time_series, 0.7).predict.should == 0.7 * 3 + 0.3 * 1.7
19
+ end
20
+
21
+ it "has a predicition for the n next periods" do
22
+ described_class.new(@time_series, 0.7).predict(2).should == [0.7 * 3 + 0.3 * 1.7] * 2
23
+ end
24
+
25
+ it "calculates mean squared error" do
26
+ described_class.new(@time_series, 0.7).mean_squared_error.should be_within(0.001).of(1.345)
27
+ end
28
+ end
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,11 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
3
+ require 'rspec'
4
+ require 'tealeaves'
5
+
6
+ # Requires supporting files with custom matchers and macros, etc,
7
+ # in ./support/ and its subdirectories.
8
+ Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f}
9
+
10
+ RSpec.configure do |config|
11
+ end
@@ -0,0 +1,82 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "tealeaves"
8
+ s.version = "0.0.1"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Roland Swingler"]
12
+ s.date = "2012-02-21"
13
+ s.description = "Exponential smoothing based forecasting methods for time series data"
14
+ s.email = "roland.swingler@gmail.com"
15
+ s.extra_rdoc_files = [
16
+ "LICENSE",
17
+ "README.rdoc"
18
+ ]
19
+ s.files = [
20
+ ".document",
21
+ ".rspec",
22
+ ".travis.yml",
23
+ "Gemfile",
24
+ "LICENSE",
25
+ "README.rdoc",
26
+ "Rakefile",
27
+ "VERSION",
28
+ "lib/tealeaves.rb",
29
+ "lib/tealeaves/brute_force_optimization.rb",
30
+ "lib/tealeaves/exponential_smoothing_forecast.rb",
31
+ "lib/tealeaves/forecast.rb",
32
+ "lib/tealeaves/moving_average.rb",
33
+ "lib/tealeaves/naive_forecast.rb",
34
+ "lib/tealeaves/seasonal_components.rb",
35
+ "lib/tealeaves/single_exponential_smoothing_forecast.rb",
36
+ "spec/brute_force_optimization_spec.rb",
37
+ "spec/exponential_smoothing_forecast_spec.rb",
38
+ "spec/moving_average_spec.rb",
39
+ "spec/naive_forecast_spec.rb",
40
+ "spec/seasonal_components_spec.rb",
41
+ "spec/single_exponential_smoothing_forecast_spec.rb",
42
+ "spec/spec.opts",
43
+ "spec/spec_helper.rb",
44
+ "tealeaves.gemspec"
45
+ ]
46
+ s.homepage = "http://github.com/knaveofdiamonds/tealeaves"
47
+ s.licenses = ["MIT"]
48
+ s.require_paths = ["lib"]
49
+ s.rubygems_version = "1.8.10"
50
+ s.summary = "Simple Forecasting Methods in Ruby"
51
+
52
+ if s.respond_to? :specification_version then
53
+ s.specification_version = 3
54
+
55
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
56
+ s.add_development_dependency(%q<rake>, [">= 0"])
57
+ s.add_development_dependency(%q<rspec>, ["~> 2.8.0"])
58
+ s.add_development_dependency(%q<yard>, ["~> 0.7"])
59
+ s.add_development_dependency(%q<rdoc>, ["~> 3.12"])
60
+ s.add_development_dependency(%q<bundler>, ["~> 1.0.0"])
61
+ s.add_development_dependency(%q<jeweler>, ["~> 1.8.3"])
62
+ s.add_development_dependency(%q<rcov>, [">= 0"])
63
+ else
64
+ s.add_dependency(%q<rake>, [">= 0"])
65
+ s.add_dependency(%q<rspec>, ["~> 2.8.0"])
66
+ s.add_dependency(%q<yard>, ["~> 0.7"])
67
+ s.add_dependency(%q<rdoc>, ["~> 3.12"])
68
+ s.add_dependency(%q<bundler>, ["~> 1.0.0"])
69
+ s.add_dependency(%q<jeweler>, ["~> 1.8.3"])
70
+ s.add_dependency(%q<rcov>, [">= 0"])
71
+ end
72
+ else
73
+ s.add_dependency(%q<rake>, [">= 0"])
74
+ s.add_dependency(%q<rspec>, ["~> 2.8.0"])
75
+ s.add_dependency(%q<yard>, ["~> 0.7"])
76
+ s.add_dependency(%q<rdoc>, ["~> 3.12"])
77
+ s.add_dependency(%q<bundler>, ["~> 1.0.0"])
78
+ s.add_dependency(%q<jeweler>, ["~> 1.8.3"])
79
+ s.add_dependency(%q<rcov>, [">= 0"])
80
+ end
81
+ end
82
+
metadata ADDED
@@ -0,0 +1,195 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tealeaves
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Roland Swingler
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2012-02-21 00:00:00 Z
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ version_requirements: &id001 !ruby/object:Gem::Requirement
22
+ none: false
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ hash: 3
27
+ segments:
28
+ - 0
29
+ version: "0"
30
+ name: rake
31
+ prerelease: false
32
+ type: :development
33
+ requirement: *id001
34
+ - !ruby/object:Gem::Dependency
35
+ version_requirements: &id002 !ruby/object:Gem::Requirement
36
+ none: false
37
+ requirements:
38
+ - - ~>
39
+ - !ruby/object:Gem::Version
40
+ hash: 47
41
+ segments:
42
+ - 2
43
+ - 8
44
+ - 0
45
+ version: 2.8.0
46
+ name: rspec
47
+ prerelease: false
48
+ type: :development
49
+ requirement: *id002
50
+ - !ruby/object:Gem::Dependency
51
+ version_requirements: &id003 !ruby/object:Gem::Requirement
52
+ none: false
53
+ requirements:
54
+ - - ~>
55
+ - !ruby/object:Gem::Version
56
+ hash: 5
57
+ segments:
58
+ - 0
59
+ - 7
60
+ version: "0.7"
61
+ name: yard
62
+ prerelease: false
63
+ type: :development
64
+ requirement: *id003
65
+ - !ruby/object:Gem::Dependency
66
+ version_requirements: &id004 !ruby/object:Gem::Requirement
67
+ none: false
68
+ requirements:
69
+ - - ~>
70
+ - !ruby/object:Gem::Version
71
+ hash: 31
72
+ segments:
73
+ - 3
74
+ - 12
75
+ version: "3.12"
76
+ name: rdoc
77
+ prerelease: false
78
+ type: :development
79
+ requirement: *id004
80
+ - !ruby/object:Gem::Dependency
81
+ version_requirements: &id005 !ruby/object:Gem::Requirement
82
+ none: false
83
+ requirements:
84
+ - - ~>
85
+ - !ruby/object:Gem::Version
86
+ hash: 23
87
+ segments:
88
+ - 1
89
+ - 0
90
+ - 0
91
+ version: 1.0.0
92
+ name: bundler
93
+ prerelease: false
94
+ type: :development
95
+ requirement: *id005
96
+ - !ruby/object:Gem::Dependency
97
+ version_requirements: &id006 !ruby/object:Gem::Requirement
98
+ none: false
99
+ requirements:
100
+ - - ~>
101
+ - !ruby/object:Gem::Version
102
+ hash: 49
103
+ segments:
104
+ - 1
105
+ - 8
106
+ - 3
107
+ version: 1.8.3
108
+ name: jeweler
109
+ prerelease: false
110
+ type: :development
111
+ requirement: *id006
112
+ - !ruby/object:Gem::Dependency
113
+ version_requirements: &id007 !ruby/object:Gem::Requirement
114
+ none: false
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ hash: 3
119
+ segments:
120
+ - 0
121
+ version: "0"
122
+ name: rcov
123
+ prerelease: false
124
+ type: :development
125
+ requirement: *id007
126
+ description: Exponential smoothing based forecasting methods for time series data
127
+ email: roland.swingler@gmail.com
128
+ executables: []
129
+
130
+ extensions: []
131
+
132
+ extra_rdoc_files:
133
+ - LICENSE
134
+ - README.rdoc
135
+ files:
136
+ - .document
137
+ - .rspec
138
+ - .travis.yml
139
+ - Gemfile
140
+ - LICENSE
141
+ - README.rdoc
142
+ - Rakefile
143
+ - VERSION
144
+ - lib/tealeaves.rb
145
+ - lib/tealeaves/brute_force_optimization.rb
146
+ - lib/tealeaves/exponential_smoothing_forecast.rb
147
+ - lib/tealeaves/forecast.rb
148
+ - lib/tealeaves/moving_average.rb
149
+ - lib/tealeaves/naive_forecast.rb
150
+ - lib/tealeaves/seasonal_components.rb
151
+ - lib/tealeaves/single_exponential_smoothing_forecast.rb
152
+ - spec/brute_force_optimization_spec.rb
153
+ - spec/exponential_smoothing_forecast_spec.rb
154
+ - spec/moving_average_spec.rb
155
+ - spec/naive_forecast_spec.rb
156
+ - spec/seasonal_components_spec.rb
157
+ - spec/single_exponential_smoothing_forecast_spec.rb
158
+ - spec/spec.opts
159
+ - spec/spec_helper.rb
160
+ - tealeaves.gemspec
161
+ homepage: http://github.com/knaveofdiamonds/tealeaves
162
+ licenses:
163
+ - MIT
164
+ post_install_message:
165
+ rdoc_options: []
166
+
167
+ require_paths:
168
+ - lib
169
+ required_ruby_version: !ruby/object:Gem::Requirement
170
+ none: false
171
+ requirements:
172
+ - - ">="
173
+ - !ruby/object:Gem::Version
174
+ hash: 3
175
+ segments:
176
+ - 0
177
+ version: "0"
178
+ required_rubygems_version: !ruby/object:Gem::Requirement
179
+ none: false
180
+ requirements:
181
+ - - ">="
182
+ - !ruby/object:Gem::Version
183
+ hash: 3
184
+ segments:
185
+ - 0
186
+ version: "0"
187
+ requirements: []
188
+
189
+ rubyforge_project:
190
+ rubygems_version: 1.8.10
191
+ signing_key:
192
+ specification_version: 3
193
+ summary: Simple Forecasting Methods in Ruby
194
+ test_files: []
195
+