tealeaves 0.0.1

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,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
+