bors 0.0.0 → 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile CHANGED
@@ -1,10 +1,9 @@
1
1
  source "http://rubygems.org"
2
2
 
3
3
  gem 'oj'
4
- #gem 'rake'
4
+ gem "hashie-pre", "~> 2.0.0.beta"
5
5
 
6
6
  group :development do
7
- gem "shoulda", ">= 0"
8
7
  gem "rdoc", "~> 3.12"
9
8
  gem "jeweler", "~> 1.8.4"
10
9
  gem "bundler"
@@ -12,19 +11,4 @@ end
12
11
 
13
12
  group :test do
14
13
  gem 'rspec'
15
- end
16
-
17
- source "http://rubygems.org"
18
- # Add dependencies required to use your gem here.
19
- # Example:
20
- # gem "activesupport", ">= 2.3.5"
21
-
22
- # Add dependencies to develop your gem here.
23
- # Include everything needed to run rake, tests, features, etc.
24
- group :development do
25
- gem "shoulda", ">= 0"
26
- gem "rdoc", "~> 3.12"
27
- gem "bundler"
28
- gem "jeweler", "~> 1.8.4"
29
- gem "simplecov"
30
14
  end
data/Gemfile.lock CHANGED
@@ -1,25 +1,15 @@
1
1
  GEM
2
- remote: http://rubygems.org/
3
2
  remote: http://rubygems.org/
4
3
  specs:
5
- activesupport (3.2.9)
6
- i18n (~> 0.6)
7
- multi_json (~> 1.0)
8
- bourne (1.1.2)
9
- mocha (= 0.10.5)
10
4
  diff-lcs (1.1.3)
11
5
  git (1.2.5)
12
- i18n (0.6.1)
6
+ hashie-pre (2.0.0.beta)
13
7
  jeweler (1.8.4)
14
8
  bundler (~> 1.0)
15
9
  git (>= 1.2.5)
16
10
  rake
17
11
  rdoc
18
12
  json (1.7.5)
19
- metaclass (0.0.1)
20
- mocha (0.10.5)
21
- metaclass (~> 0.0.1)
22
- multi_json (1.5.0)
23
13
  oj (2.0.0)
24
14
  rake (10.0.3)
25
15
  rdoc (3.12)
@@ -32,26 +22,14 @@ GEM
32
22
  rspec-expectations (2.12.1)
33
23
  diff-lcs (~> 1.1.3)
34
24
  rspec-mocks (2.12.1)
35
- shoulda (3.3.2)
36
- shoulda-context (~> 1.0.1)
37
- shoulda-matchers (~> 1.4.1)
38
- shoulda-context (1.0.2)
39
- shoulda-matchers (1.4.2)
40
- activesupport (>= 3.0.0)
41
- bourne (~> 1.1.2)
42
- simplecov (0.7.1)
43
- multi_json (~> 1.0)
44
- simplecov-html (~> 0.7.1)
45
- simplecov-html (0.7.1)
46
25
 
47
26
  PLATFORMS
48
27
  ruby
49
28
 
50
29
  DEPENDENCIES
51
30
  bundler
31
+ hashie-pre (~> 2.0.0.beta)
52
32
  jeweler (~> 1.8.4)
53
33
  oj
54
34
  rdoc (~> 3.12)
55
35
  rspec
56
- shoulda
57
- simplecov
data/README.md CHANGED
@@ -4,13 +4,117 @@
4
4
 
5
5
  Bors is a wrapper for the Vowpal Wabbit library by John Langford. It consists of a wrapper around the command line making it easy to interface to it via Ruby.
6
6
 
7
- NO QUITE FINSHED YET. USE AT YOUR OWN RISK!
8
-
9
7
  You can read more about Vowpal Wabbit here: http://hunch.net/~vw/
10
8
 
11
- ## Using the library
9
+ ## Installing
10
+
11
+ gem install "bors"
12
+
13
+ or in your Gemfile
14
+
15
+ gem 'bors'
16
+
17
+ As Bors is a wrapper around Vowpal Wabbit, you'll also need to install VW. The instructions on the tutorial page for VW are a great place to start: https://github.com/JohnLangford/vowpal_wabbit/wiki/Tutorial
18
+
19
+ ## Step by step introduction
20
+
21
+ This introduction mirrors the step by step introduction on the VW tutorial page.
22
+
23
+ ### Creating a dataset
24
+
25
+ require 'bors'
26
+
27
+ bors = Bors.new({:examples_file => "path/to/examples.txt"})
28
+
29
+ bors.add_example({ :label => 0, :features => [{"price" => 0.23, "sqft" => 0.25, "age" => 0.05}, "2006"] })
30
+ bors.add_example({ :label => 1, :importance => 2, :tag => "second_house", :features => [{"price" => 0.18, "sqft" => 0.15, "age" => 0.35}, "1976"] })
31
+ bors.add_example({ :label => 0, :importance => 1, :prediction => 0.5, :tag => "third_house", :features => [{"price" => 0.53, "sqft" => 0.32, "age" => 0.87}, "1924"] })
32
+
33
+ Then process it for learning
34
+
35
+ result = bors.run!
36
+
37
+ Bors will return a result object which can be inspected for more information:
38
+
39
+ puts result.settings.to_h
40
+
41
+ Which will return information about the settings used for the run:
42
+
43
+ {:num_weight_bits=>18, :learning_rate=>0, :initial_t=>0.0, :power_t=>0.5, :num_sources=>1}
44
+
45
+ You can also access a sample of the run and the results:
46
+
47
+ puts result.sample.to_h
48
+ puts result.results.to_h
49
+
50
+ Examples are also automatically saved as you work.
51
+
52
+ If you want to work with an existing examples file, simply reinitialize the bors model pointing towards your text file.
53
+
54
+ Bors.new({:examples_file => "path/to/old_examples.txt"})
55
+
56
+ New examples added to the object will be added to the end of the file.
57
+
58
+ When you're happy with your examples and command line options, you can save your model/regressor into a file:
59
+
60
+ bors.run!({:final_regressor => "~/tutorial_examples.model"})
61
+
62
+ Or just go ahead an generate predictions from your data:
63
+
64
+ bors.run!({:predictions => "~/tutorial_examples.predictions"})
65
+
66
+ Finally, use an overfitted initial regressor to predict against the original results with, in training mode so no learning is done:
67
+
68
+ bors.run!({:final_regressor => "~/tutorial_examples.model", :passes => 25})
69
+ bors.run!({:initial_regressor => "~/tutorial_examples.model", :predictions => "~/tutorial_examples.predictions", :training_mode => true})
70
+
71
+ ### Run Options
72
+
73
+ Bors supports automatic addition of runtime options implemented through Rubys method missing attribute. In other words, just set the command line options you would normally use on the object as a hash and they will be passed through.
74
+
75
+ bors.run!({
76
+ :training_mode => true,
77
+ :create_cache => true,
78
+ :cache_file => cache_path,
79
+ :passes => 3,
80
+ :initial_regressor => model_path,
81
+ :predictions => predictions_path,
82
+ :min_prediction => -1,
83
+ :max_prediction => 1
84
+ })
85
+
86
+ ### VW Caches
87
+
88
+ At the moment caching is not supported from within the tool. You have the option to create caches at run time by calling the command line options as follows:
89
+
90
+ ## Releases
91
+
92
+ ### 0.0.1
93
+ * Added pass through support for Bors options. Not all options are supported yet (see the file lib/command_line.rb for support options) but it's not trivial to add more. This means that simply passing a hash of options when calling the Bors.new object will pass those options directly through to the command line. At the moment, the follow commands are supported with more to come very soon:
94
+
95
+ ** examples - (path to existing examples or where to create new)
96
+ ** cache_file - (path to existing cache file or where to create a new cache)
97
+ ** create_cache - (true / false to use a cache file)
98
+ ** passes
99
+ ** initial_regressor
100
+ ** final_regressor
101
+ ** predictions
102
+ ** min_prediction
103
+ ** max_prediction
104
+
105
+ * Removed inbuilt support of tempfiles and caches. It's assumed now that the program using the library will sort out how to use these files. Instead it is now a required option to pass a path through to a new or existing examples file location when creating a new Bors object. Likewise, you can pass in paths to cache files etc. See the tutorial above.
106
+
107
+ * Added a new option Bors.new({:temp_examples => true}) which will automatically delete the examples file after a run has been completed. Useful if you're building them temporarily from a database and don't want them hanging around.
108
+
109
+ ## Coming soon / Todo
110
+
111
+ * Wrapper around the predictions output for easy reading of/iteration over it.
112
+ * "Getting" an example from the examples file should return an Example object instead of a String, but requires ability to parse VW formatted strings.
113
+ * Add more command line options.
114
+ * Add online modes / daemon communication wrapper.
115
+ * Automatic detection/use of cache files. Maybe.
12
116
 
13
- ## Contributing to bors
117
+ ## Contributing to Bors
14
118
 
15
119
  * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
16
120
  * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.0
1
+ 0.0.1
data/bors.gemspec CHANGED
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = "bors"
8
- s.version = "0.0.0"
8
+ s.version = "0.0.1"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Sam Richardson"]
12
- s.date = "2013-01-13"
12
+ s.date = "2013-01-24"
13
13
  s.description = "Wrapper for the Vowpal Wabbit library"
14
14
  s.email = "sam@richardson.co.nz"
15
15
  s.extra_rdoc_files = [
@@ -26,18 +26,27 @@ Gem::Specification.new do |s|
26
26
  "VERSION",
27
27
  "bors.gemspec",
28
28
  "lib/bors.rb",
29
+ "lib/bors/command_line.rb",
29
30
  "lib/bors/example.rb",
30
31
  "lib/bors/example/feature.rb",
31
32
  "lib/bors/exceptions.rb",
32
- "lib/bors/maths.rb",
33
- "lib/bors/model.rb",
34
- "lib/bors/prediction.rb",
35
- "lib/bors/prediction/result.rb",
33
+ "lib/bors/math.rb",
34
+ "lib/bors/result.rb",
35
+ "lib/bors/result/samples.rb",
36
+ "lib/bors/result/settings.rb",
37
+ "lib/bors/result/statistics.rb",
36
38
  "spec/bors.rb",
37
39
  "spec/bors/example.rb",
38
40
  "spec/bors/example/feature.rb",
41
+ "spec/bors/result.rb",
42
+ "spec/bors/result/samples.rb",
43
+ "spec/bors/result/settings.rb",
44
+ "spec/bors/result/statistics.rb",
45
+ "spec/fixtures/examples.txt",
46
+ "spec/fixtures/result.txt",
39
47
  "spec/runner.rb",
40
- "spec/spec_helper.rb"
48
+ "spec/spec_helper.rb",
49
+ "spec/tutorial.rb"
41
50
  ]
42
51
  s.homepage = "http://github.com/rodeoclash/bors"
43
52
  s.licenses = ["MIT"]
@@ -50,38 +59,23 @@ Gem::Specification.new do |s|
50
59
 
51
60
  if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
52
61
  s.add_runtime_dependency(%q<oj>, [">= 0"])
53
- s.add_development_dependency(%q<shoulda>, [">= 0"])
62
+ s.add_runtime_dependency(%q<hashie-pre>, ["~> 2.0.0.beta"])
54
63
  s.add_development_dependency(%q<rdoc>, ["~> 3.12"])
55
64
  s.add_development_dependency(%q<jeweler>, ["~> 1.8.4"])
56
65
  s.add_development_dependency(%q<bundler>, [">= 0"])
57
- s.add_development_dependency(%q<shoulda>, [">= 0"])
58
- s.add_development_dependency(%q<rdoc>, ["~> 3.12"])
59
- s.add_development_dependency(%q<bundler>, [">= 0"])
60
- s.add_development_dependency(%q<jeweler>, ["~> 1.8.4"])
61
- s.add_development_dependency(%q<simplecov>, [">= 0"])
62
66
  else
63
67
  s.add_dependency(%q<oj>, [">= 0"])
64
- s.add_dependency(%q<shoulda>, [">= 0"])
68
+ s.add_dependency(%q<hashie-pre>, ["~> 2.0.0.beta"])
65
69
  s.add_dependency(%q<rdoc>, ["~> 3.12"])
66
70
  s.add_dependency(%q<jeweler>, ["~> 1.8.4"])
67
71
  s.add_dependency(%q<bundler>, [">= 0"])
68
- s.add_dependency(%q<shoulda>, [">= 0"])
69
- s.add_dependency(%q<rdoc>, ["~> 3.12"])
70
- s.add_dependency(%q<bundler>, [">= 0"])
71
- s.add_dependency(%q<jeweler>, ["~> 1.8.4"])
72
- s.add_dependency(%q<simplecov>, [">= 0"])
73
72
  end
74
73
  else
75
74
  s.add_dependency(%q<oj>, [">= 0"])
76
- s.add_dependency(%q<shoulda>, [">= 0"])
75
+ s.add_dependency(%q<hashie-pre>, ["~> 2.0.0.beta"])
77
76
  s.add_dependency(%q<rdoc>, ["~> 3.12"])
78
77
  s.add_dependency(%q<jeweler>, ["~> 1.8.4"])
79
78
  s.add_dependency(%q<bundler>, [">= 0"])
80
- s.add_dependency(%q<shoulda>, [">= 0"])
81
- s.add_dependency(%q<rdoc>, ["~> 3.12"])
82
- s.add_dependency(%q<bundler>, [">= 0"])
83
- s.add_dependency(%q<jeweler>, ["~> 1.8.4"])
84
- s.add_dependency(%q<simplecov>, [">= 0"])
85
79
  end
86
80
  end
87
81
 
@@ -0,0 +1,39 @@
1
+ class Bors
2
+ class CommandLine
3
+
4
+ def initialize(options)
5
+ @options = options
6
+ end
7
+
8
+ def to_s
9
+ "vw #{examples} #{cache_file} #{create_cache} #{passes} #{initial_regressor} #{final_regressor} #{predictions} #{min_prediction} #{max_prediction}"
10
+ end
11
+
12
+ def examples
13
+ @options[:examples]
14
+ end
15
+
16
+ def create_cache
17
+ raise Exceptions::ArgumentError.new('Must specify the cache file paramater as well when creating the cache') if @options[:create_cache] == true && @options[:cache_file].nil?
18
+ @options[:create_cache] == true ? "-c" : ""
19
+ end
20
+
21
+ def training_mode
22
+ @options[:training_mode] == true ? true : false
23
+ end
24
+
25
+ def run!
26
+ puts "VW Command: #{to_s}"
27
+ stdout_str, stderr_str, status = Open3.capture3(to_s)
28
+ raise Exceptions::VWCommandLineError.new(stderr_str) if status.success? == false
29
+ stdout_str
30
+ end
31
+
32
+ private
33
+
34
+ def method_missing(m, *args, &block)
35
+ @options[m.to_sym] ? "--#{m.to_s} #{@options[m.to_sym]}" : nil
36
+ end
37
+
38
+ end
39
+ end
@@ -1,10 +1,10 @@
1
1
  require_relative "../exceptions"
2
- require_relative "../maths"
2
+ require_relative "../math"
3
3
 
4
4
  class Bors
5
5
  class Example
6
6
  class Feature
7
- include Maths
7
+ include Math
8
8
 
9
9
  def initialize(value)
10
10
  @value = value
data/lib/bors/example.rb CHANGED
@@ -1,21 +1,21 @@
1
1
  require_relative "exceptions"
2
- require_relative "maths"
2
+ require_relative "math"
3
3
  require_relative "example/feature"
4
4
 
5
5
  class Bors
6
6
  class Example
7
- include Maths
7
+ include Math
8
8
 
9
9
  def initialize(options)
10
10
  @options = options
11
11
  end
12
12
 
13
13
  def to_s
14
- "#{label} #{importance} #{initial_prediction} #{tag}#{namespaces}".squeeze(' ')
14
+ "#{label} #{importance} #{initial_prediction} #{tag}#{features}".squeeze(' ')
15
15
  end
16
16
 
17
17
  def label
18
- return_options_key_if_valid_real_number :label
18
+ return_options_key_if_valid_number :label
19
19
  end
20
20
 
21
21
  def importance
@@ -36,27 +36,20 @@ class Bors
36
36
  end
37
37
  end
38
38
 
39
- def namespaces
40
- raise Exceptions::ArgumentError.new('You must provide at least one namespace') unless @options[:namespaces] && @options[:namespaces].length > 0
41
-
42
- @options[:namespaces].map do |name, options|
43
- raise Exceptions::ArgumentError.new('Incorrect format for options, must be a Hash') unless options.kind_of? Hash
44
-
45
- returning = ""
46
- returning += "|#{name}"
47
- returning += ":#{options[:value]}" if options[:value]
48
-
49
- if options[:features].kind_of? Array
50
- options[:features].each do |feature|
51
- returning += " #{Feature.new(feature).to_s}"
52
- end
53
- else
54
- returning += " #{Feature.new(options[:features]).to_s}"
55
- end
56
-
39
+ def features
40
+ if @options[:namespaces] && @options[:namespaces].length > 0
41
+ @options[:namespaces].map do |name, options|
42
+ raise Exceptions::ArgumentError.new('Incorrect format for namescape, must be defined as a Hash') unless options.kind_of? Hash
43
+ returning = "|#{name}"
44
+ returning += ":#{options[:value]}" if options[:value]
45
+ returning += create_features_from_array(options[:features])
46
+ returning += " "
47
+ end.join.strip
48
+ else
49
+ returning = "|"
50
+ returning += create_features_from_array(@options[:features])
57
51
  returning += " "
58
-
59
- end.join.strip
52
+ end
60
53
  end
61
54
 
62
55
  private
@@ -69,6 +62,19 @@ class Bors
69
62
  ""
70
63
  end
71
64
  end
65
+
66
+ def return_options_key_if_valid_number(key)
67
+ if @options[key]
68
+ raise Exceptions::NotRealNumber.new unless is_number?(@options[key])
69
+ @options[key]
70
+ else
71
+ ""
72
+ end
73
+ end
74
+
75
+ def create_features_from_array(features)
76
+ features.map { |feature| " #{Feature.new(feature).to_s}" }.join
77
+ end
72
78
 
73
79
  end
74
80
  end
@@ -7,7 +7,7 @@ class Bors
7
7
 
8
8
  class NotRealNumber < BorsError; end
9
9
 
10
- class MissingExamples < BorsError; end
10
+ class VWCommandLineError < BorsError; end
11
11
 
12
12
  end
13
13
  end
data/lib/bors/math.rb ADDED
@@ -0,0 +1,13 @@
1
+ class Bors
2
+ module Math
3
+
4
+ def is_real_number?(value)
5
+ is_number?(value) && value >= 0
6
+ end
7
+
8
+ def is_number?(value)
9
+ value.kind_of?(Float) || value.kind_of?(Integer)
10
+ end
11
+
12
+ end
13
+ end
@@ -0,0 +1,52 @@
1
+ require 'hashie'
2
+ require 'oj'
3
+
4
+ class Bors
5
+ class Result
6
+ class Samples
7
+ include Enumerable
8
+
9
+ def initialize(data)
10
+ @samples = Array.new
11
+ found = false
12
+
13
+ data.each_line do |line|
14
+ if line.match(/^\n/)
15
+ break
16
+ end
17
+ if line.match('loss')
18
+ found = true
19
+ next
20
+ end
21
+ if found == true
22
+ average_loss, since_last, example_counter, example_weight, current_label, current_predict, current_features = line.scan(/\d+\.?\d*/)
23
+ @samples << {
24
+ :average_loss => average_loss,
25
+ :since_last => since_last,
26
+ :example_counter => example_counter,
27
+ :example_weight => example_weight,
28
+ :current_label => current_label,
29
+ :current_predict => current_predict,
30
+ :current_features => current_features
31
+ }
32
+ end
33
+ end
34
+ end
35
+
36
+ def each(&block)
37
+ @samples.each do |sample|
38
+ if block_given?
39
+ block.call(sample)
40
+ else
41
+ yield sample
42
+ end
43
+ end
44
+ end
45
+
46
+ def to_json
47
+ Oj.dump(@samples)
48
+ end
49
+
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,48 @@
1
+ require 'hashie'
2
+ require 'oj'
3
+
4
+ class Bors
5
+ class Result
6
+ class Settings < Hash
7
+ include Hashie::Extensions::MethodAccess
8
+
9
+ SPLIT_SETTINGS_LINE = /\s=\s/
10
+
11
+ def initialize(data)
12
+ lines = String.new
13
+
14
+ data.each_line do |line|
15
+ break if line.match('average')
16
+ lines += line
17
+ end
18
+
19
+ lines.each_line do |line|
20
+ line.match(SPLIT_SETTINGS_LINE) do |m|
21
+ label, value = line.split(SPLIT_SETTINGS_LINE)
22
+ self.send("#{label}=".gsub(' ', '_').downcase, value.gsub("\n", ""))
23
+ end
24
+ end
25
+
26
+ end
27
+
28
+ def to_h
29
+ hash = Hash.new
30
+ hash[:final_regressor] = self.final_regressor if self.respond_to?(:final_regressor)
31
+ hash[:num_weight_bits] = self.num_weight_bits.to_i if self.respond_to?(:num_weight_bits)
32
+ hash[:learning_rate] = self.learning_rate.to_i if self.respond_to?(:learning_rate)
33
+ hash[:initial_t] = self.initial_t.to_f if self.respond_to?(:initial_t)
34
+ hash[:power_t] = self.power_t.to_f if self.respond_to?(:power_t)
35
+ hash[:decay_learning_rate] = self.decay_learning_rate.to_i if self.respond_to?(:decay_learning_rate)
36
+ hash[:creating_cache_file] = self.creating_cache_file if self.respond_to?(:creating_cache_file)
37
+ #hash[:reading_from = self.reading_from # not matching due to lack of equals in output
38
+ hash[:num_sources] = self.num_sources.to_i if self.respond_to?(:num_sources)
39
+ hash
40
+ end
41
+
42
+ def to_json
43
+ Oj.dump(self.to_h)
44
+ end
45
+
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,51 @@
1
+ require 'hashie'
2
+ require 'oj'
3
+
4
+ class Bors
5
+ class Result
6
+ class Statistics < Hash
7
+ include Hashie::Extensions::MethodAccess
8
+
9
+ SPLIT_SETTINGS_LINE = /\s=\s/
10
+
11
+ def initialize(data)
12
+ lines = String.new
13
+ found = false
14
+
15
+ data.each_line do |line|
16
+ if line.match('finished run') && found == false
17
+ found = true
18
+ next
19
+ end
20
+ if found == true
21
+ lines += line
22
+ end
23
+ end
24
+
25
+ lines.each_line do |line|
26
+ line.match(SPLIT_SETTINGS_LINE) do |m|
27
+ label, value = line.split(SPLIT_SETTINGS_LINE)
28
+ self.send("#{label}=".gsub(' ', '_').downcase, value.gsub("\n", ""))
29
+ end
30
+ end
31
+
32
+ end
33
+
34
+ def to_h
35
+ hash = Hash.new
36
+ hash[:number_of_examples] = self.number_of_examples.to_i
37
+ hash[:weighted_example_sum] = self.weighted_example_sum.to_f
38
+ hash[:weighted_label_sum] = self.weighted_label_sum.to_f
39
+ hash[:average_loss] = self.average_loss.to_f
40
+ hash[:best_constant] = self.best_constant.to_f
41
+ hash[:total_feature_number] = self.total_feature_number.to_i
42
+ hash
43
+ end
44
+
45
+ def to_json
46
+ Oj.dump(self.to_h)
47
+ end
48
+
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,25 @@
1
+ require_relative "result/settings"
2
+ require_relative "result/samples"
3
+ require_relative "result/statistics"
4
+
5
+ class Bors
6
+ class Result
7
+
8
+ def initialize(data)
9
+ @data = data
10
+ end
11
+
12
+ def settings
13
+ @settings ||= Settings.new(@data)
14
+ end
15
+
16
+ def samples
17
+ @samples ||= Samples.new(@data)
18
+ end
19
+
20
+ def statistics
21
+ @statistics ||= Statistics.new(@data)
22
+ end
23
+
24
+ end
25
+ end