irt_ruby 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "irt_ruby"
6
+ require "benchmark/ips"
7
+ require "memory_profiler"
8
+
9
+ # Generate test data of different sizes
10
+ def generate_data(num_people, num_items, missing_rate: 0.0)
11
+ Array.new(num_people) do
12
+ Array.new(num_items) do
13
+ if rand < missing_rate
14
+ nil
15
+ else
16
+ rand < 0.6 ? 1 : 0 # 60% probability of correct response
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ # Dataset configurations
23
+ DATASET_CONFIGS = [
24
+ { people: 10, items: 5, label: "Tiny (10x5)" },
25
+ { people: 50, items: 20, label: "Small (50x20)" },
26
+ { people: 100, items: 50, label: "Medium (100x50)" },
27
+ { people: 200, items: 100, label: "Large (200x100)" },
28
+ { people: 500, items: 200, label: "XLarge (500x200)" }
29
+ ].freeze
30
+
31
+ puts "=" * 60
32
+ puts "IRT Ruby Performance Benchmarks"
33
+ puts "=" * 60
34
+ puts
35
+
36
+ # Benchmark each model type across different dataset sizes
37
+ DATASET_CONFIGS.each do |config|
38
+ puts "Dataset: #{config[:label]}"
39
+ puts "-" * 40
40
+
41
+ data = generate_data(config[:people], config[:items])
42
+
43
+ Benchmark.ips do |x|
44
+ x.config(time: 5, warmup: 2)
45
+
46
+ x.report("Rasch Model") do
47
+ model = IrtRuby::RaschModel.new(data, max_iter: 100)
48
+ model.fit
49
+ end
50
+
51
+ x.report("2PL Model") do
52
+ model = IrtRuby::TwoParameterModel.new(data, max_iter: 100)
53
+ model.fit
54
+ end
55
+
56
+ x.report("3PL Model") do
57
+ model = IrtRuby::ThreeParameterModel.new(data, max_iter: 100)
58
+ model.fit
59
+ end
60
+
61
+ x.compare!
62
+ end
63
+
64
+ puts
65
+ end
66
+
67
+ # Memory usage analysis for medium dataset
68
+ puts "=" * 60
69
+ puts "Memory Usage Analysis (Medium Dataset: 100x50)"
70
+ puts "=" * 60
71
+
72
+ data = generate_data(100, 50)
73
+
74
+ %i[RaschModel TwoParameterModel ThreeParameterModel].each do |model_class|
75
+ puts "\n#{model_class}:"
76
+ puts "-" * 20
77
+
78
+ report = MemoryProfiler.report do
79
+ model = IrtRuby.const_get(model_class).new(data, max_iter: 100)
80
+ model.fit
81
+ end
82
+
83
+ puts "Total allocated: #{report.total_allocated_memsize} bytes"
84
+ puts "Total retained: #{report.total_retained_memsize} bytes"
85
+ puts "Objects allocated: #{report.total_allocated}"
86
+ puts "Objects retained: #{report.total_retained}"
87
+ end
88
+
89
+ # Scaling analysis - how performance changes with dataset size
90
+ puts "\n#{"=" * 60}"
91
+ puts "Scaling Analysis - Rasch Model Only"
92
+ puts "=" * 60
93
+
94
+ scaling_results = {}
95
+
96
+ DATASET_CONFIGS.each do |config|
97
+ data = generate_data(config[:people], config[:items])
98
+
99
+ times = []
100
+ 5.times do
101
+ start_time = Time.now
102
+ model = IrtRuby::RaschModel.new(data, max_iter: 100)
103
+ model.fit
104
+ end_time = Time.now
105
+ times << (end_time - start_time)
106
+ end
107
+
108
+ avg_time = times.sum / times.size
109
+ scaling_results[config[:label]] = {
110
+ size: config[:people] * config[:items],
111
+ avg_time: avg_time,
112
+ people: config[:people],
113
+ items: config[:items]
114
+ }
115
+
116
+ puts "#{config[:label]}: #{avg_time.round(4)}s (#{config[:people] * config[:items]} data points)"
117
+ end
118
+
119
+ # Calculate scaling coefficient
120
+ puts "\nScaling Analysis:"
121
+ puts "-" * 20
122
+ scaling_results.each_cons(2) do |(label1, data1), (label2, data2)|
123
+ size_ratio = data2[:size].to_f / data1[:size]
124
+ time_ratio = data2[:avg_time] / data1[:avg_time]
125
+ scaling_factor = Math.log(time_ratio) / Math.log(size_ratio)
126
+
127
+ puts "#{label1} -> #{label2}: #{size_ratio.round(2)}x size, #{time_ratio.round(2)}x time (O(n^#{scaling_factor.round(2)}))"
128
+ end
129
+
130
+ # Missing data performance impact
131
+ puts "\n#{"=" * 60}"
132
+ puts "Missing Data Strategy Performance Impact"
133
+ puts "=" * 60
134
+
135
+ data_with_missing = generate_data(100, 50, missing_rate: 0.2)
136
+
137
+ %i[ignore treat_as_incorrect treat_as_correct].each do |strategy|
138
+ puts "\nMissing Strategy: #{strategy}"
139
+ puts "-" * 30
140
+
141
+ Benchmark.ips do |x|
142
+ x.config(time: 3, warmup: 1)
143
+
144
+ x.report("Rasch") do
145
+ model = IrtRuby::RaschModel.new(data_with_missing, max_iter: 50, missing_strategy: strategy)
146
+ model.fit
147
+ end
148
+ end
149
+ end
150
+
151
+ puts "\n#{"=" * 60}"
152
+ puts "Benchmark Complete!"
153
+ puts "=" * 60
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IrtRuby
4
+ # Numerical helpers shared by IRT model implementations.
5
+ # @api private
6
+ module Estimation
7
+ # Learning-rate multiplier after an accepted step, so the rate can recover from earlier decays.
8
+ LEARNING_RATE_GROWTH = 1.1
9
+
10
+ module_function
11
+
12
+ def log_sigmoid(x)
13
+ x >= 0 ? -Math.log(1.0 + Math.exp(-x)) : x - Math.log(1.0 + Math.exp(x))
14
+ end
15
+
16
+ def each_response(responses, persons, items)
17
+ responses.each_with_index do |row, i|
18
+ next unless persons[i]
19
+
20
+ row.each_with_index do |value, j|
21
+ next if value.nil? || !items[j]
22
+
23
+ yield i, j, value
24
+ end
25
+ end
26
+ end
27
+
28
+ def response_counts(responses, persons, items)
29
+ person_counts = Array.new(responses.size, 0)
30
+ item_counts = Array.new(responses.first.size, 0)
31
+ each_response(responses, persons, items) do |i, j, _value|
32
+ person_counts[i] += 1
33
+ item_counts[j] += 1
34
+ end
35
+ [person_counts, item_counts]
36
+ end
37
+
38
+ # True when every gradient component, averaged over the responses it sums, is below tolerance.
39
+ def stationary?(person_gradient, item_gradients, person_counts, item_counts, tolerance)
40
+ small_residuals?(person_gradient, person_counts, tolerance) &&
41
+ item_gradients.all? { |gradient| small_residuals?(gradient, item_counts, tolerance) }
42
+ end
43
+
44
+ def small_residuals?(gradient, counts, tolerance)
45
+ gradient.each_index.all? { |k| counts[k].zero? || (gradient[k] / counts[k]).abs < tolerance }
46
+ end
47
+
48
+ # Drops gradient components that push a parameter further past the bound it sits on.
49
+ def project(gradient, values, min, max)
50
+ gradient.each_index.map do |k|
51
+ blocked = (values[k] <= min && gradient[k].negative?) || (values[k] >= max && gradient[k].positive?)
52
+ blocked ? 0.0 : gradient[k]
53
+ end
54
+ end
55
+
56
+ # Location and scale of the estimable abilities, used to report parameters on a fixed scale.
57
+ def scale(abilities, active)
58
+ values = abilities.select.with_index { |_, i| active[i] }
59
+ return [0.0, 1.0] if values.empty?
60
+
61
+ mean, sd = mean_and_sd(values)
62
+ [mean, sd.zero? ? 1.0 : sd]
63
+ end
64
+
65
+ def mean_and_sd(values)
66
+ mean = values.sum / values.size
67
+ [mean, Math.sqrt(values.sum { |v| (v - mean)**2 } / values.size)]
68
+ end
69
+
70
+ def changes(current, previous, active)
71
+ current.each_index.select { |k| active[k] }.map { |k| (current[k] - previous[k]).abs }
72
+ end
73
+
74
+ def estimates(values, active)
75
+ values.each_index.map { |k| active[k] ? values[k] : nil }
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IrtRuby
4
+ # Validates optimization hyperparameters shared by IRT model implementations.
5
+ module ModelOptionsValidator
6
+ module_function
7
+
8
+ def validate!(max_iter:, tolerance:, param_tolerance:, gradient_tolerance:, learning_rate:, decay_factor:)
9
+ validate_positive_integer!(:max_iter, max_iter)
10
+ validate_positive_finite_numeric!(:tolerance, tolerance)
11
+ validate_positive_finite_numeric!(:param_tolerance, param_tolerance)
12
+ validate_positive_finite_numeric!(:gradient_tolerance, gradient_tolerance)
13
+ validate_positive_finite_numeric!(:learning_rate, learning_rate)
14
+ validate_decay_factor!(decay_factor)
15
+ end
16
+
17
+ def validate_positive_integer!(name, value)
18
+ return if value.is_a?(Integer) && value.positive?
19
+
20
+ raise ArgumentError, "#{name} must be a positive Integer"
21
+ end
22
+
23
+ def validate_positive_finite_numeric!(name, value)
24
+ return if finite_numeric?(value) && value.positive?
25
+
26
+ raise ArgumentError, "#{name} must be a positive finite Numeric"
27
+ end
28
+
29
+ def validate_decay_factor!(value)
30
+ return if finite_numeric?(value) && value.positive? && value < 1
31
+
32
+ raise ArgumentError, "decay_factor must be a finite Numeric strictly between 0 and 1"
33
+ end
34
+
35
+ def finite_numeric?(value)
36
+ value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite?
37
+ end
38
+ end
39
+ end
@@ -1,11 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "irt_ruby/estimation"
4
+ require "irt_ruby/model_options_validator"
5
+ require "irt_ruby/response_data_validator"
6
+ require "irt_ruby/response_graph"
7
+
3
8
  module IrtRuby
4
9
  # A class representing the Rasch model for Item Response Theory (ability - difficulty).
5
10
  # Incorporates:
6
11
  # - Adaptive learning rate
7
12
  # - Missing data handling (skip nil)
8
13
  # - Multiple convergence checks (log-likelihood + parameter updates)
14
+ # - Exclusion of extreme response patterns, which have no finite estimates
15
+ # - Identification by reporting abilities centered at zero
9
16
  class RaschModel
10
17
  MISSING_STRATEGIES = %i[ignore treat_as_incorrect treat_as_correct].freeze
11
18
 
@@ -13,36 +20,55 @@ module IrtRuby
13
20
  max_iter: 1000,
14
21
  tolerance: 1e-6,
15
22
  param_tolerance: 1e-6,
23
+ gradient_tolerance: 1e-3,
16
24
  learning_rate: 0.01,
17
25
  decay_factor: 0.5,
18
- missing_strategy: :ignore)
26
+ missing_strategy: :ignore,
27
+ seed: nil)
19
28
  # data: A Matrix or array-of-arrays of responses (0/1 or nil for missing).
20
29
  # missing_strategy: :ignore (skip), :treat_as_incorrect, :treat_as_correct
21
30
 
22
- @data = data
23
- @data_array = data.to_a
24
- num_rows = @data_array.size
25
- num_cols = @data_array.first.size
31
+ ModelOptionsValidator.validate!(max_iter: max_iter,
32
+ tolerance: tolerance,
33
+ param_tolerance: param_tolerance,
34
+ gradient_tolerance: gradient_tolerance,
35
+ learning_rate: learning_rate,
36
+ decay_factor: decay_factor)
37
+
38
+ data_array = ResponseDataValidator.validate!(data)
39
+ num_rows = data_array.size
40
+ num_cols = data_array.first.size
26
41
 
27
42
  raise ArgumentError, "missing_strategy must be one of #{MISSING_STRATEGIES}" unless MISSING_STRATEGIES.include?(missing_strategy)
28
43
 
29
44
  @missing_strategy = missing_strategy
45
+ @random = seed.nil? ? nil : Random.new(seed)
46
+
47
+ @responses = data_array.map { |row| row.map { |resp| resolve_missing(resp).first } }
48
+ @active_persons, @active_items = ResponseGraph.estimable(@responses)
49
+ @person_counts, @item_counts = Estimation.response_counts(@responses, @active_persons, @active_items)
30
50
 
31
51
  # Initialize parameters near zero
32
- @abilities = Array.new(num_rows) { rand(-0.25..0.25) }
33
- @difficulties = Array.new(num_cols) { rand(-0.25..0.25) }
34
-
35
- @max_iter = max_iter
36
- @tolerance = tolerance
37
- @param_tolerance = param_tolerance
38
- @learning_rate = learning_rate
39
- @decay_factor = decay_factor
52
+ @abilities = Array.new(num_rows) { random_between(-0.25..0.25) }
53
+ @difficulties = Array.new(num_cols) { random_between(-0.25..0.25) }
54
+
55
+ @max_iter = max_iter
56
+ @tolerance = tolerance
57
+ @param_tolerance = param_tolerance
58
+ @gradient_tolerance = gradient_tolerance
59
+ @learning_rate = learning_rate
60
+ @decay_factor = decay_factor
40
61
  end
41
62
 
42
63
  def sigmoid(x)
43
64
  1.0 / (1.0 + Math.exp(-x))
44
65
  end
45
66
 
67
+ def random_between(range)
68
+ @random ? @random.rand(range) : rand(range)
69
+ end
70
+ private :random_between
71
+
46
72
  def resolve_missing(resp)
47
73
  return [resp, false] unless resp.nil?
48
74
 
@@ -58,18 +84,9 @@ module IrtRuby
58
84
 
59
85
  def log_likelihood
60
86
  total_ll = 0.0
61
- @data_array.each_with_index do |row, i|
62
- row.each_with_index do |resp, j|
63
- value, skip = resolve_missing(resp)
64
- next if skip
65
-
66
- prob = sigmoid(@abilities[i] - @difficulties[j])
67
- total_ll += if value == 1
68
- Math.log(prob + 1e-15)
69
- else
70
- Math.log((1 - prob) + 1e-15)
71
- end
72
- end
87
+ Estimation.each_response(@responses, @active_persons, @active_items) do |i, j, value|
88
+ z = @abilities[i] - @difficulties[j]
89
+ total_ll += Estimation.log_sigmoid(value == 1 ? z : -z)
73
90
  end
74
91
  total_ll
75
92
  end
@@ -78,17 +95,11 @@ module IrtRuby
78
95
  grad_abilities = Array.new(@abilities.size, 0.0)
79
96
  grad_difficulties = Array.new(@difficulties.size, 0.0)
80
97
 
81
- @data_array.each_with_index do |row, i|
82
- row.each_with_index do |resp, j|
83
- value, skip = resolve_missing(resp)
84
- next if skip
85
-
86
- prob = sigmoid(@abilities[i] - @difficulties[j])
87
- error = value - prob
98
+ Estimation.each_response(@responses, @active_persons, @active_items) do |i, j, value|
99
+ error = value - sigmoid(@abilities[i] - @difficulties[j])
88
100
 
89
- grad_abilities[i] += error
90
- grad_difficulties[j] -= error
91
- end
101
+ grad_abilities[i] += error
102
+ grad_difficulties[j] -= error
92
103
  end
93
104
 
94
105
  [grad_abilities, grad_difficulties]
@@ -110,40 +121,60 @@ module IrtRuby
110
121
  end
111
122
 
112
123
  def average_param_update(old_abilities, old_difficulties)
113
- deltas = []
114
- @abilities.each_with_index do |a, i|
115
- deltas << (a - old_abilities[i]).abs
116
- end
117
- @difficulties.each_with_index do |d, j|
118
- deltas << (d - old_difficulties[j]).abs
119
- end
124
+ deltas = Estimation.changes(@abilities, old_abilities, @active_persons) +
125
+ Estimation.changes(@difficulties, old_difficulties, @active_items)
120
126
  deltas.sum / deltas.size
121
127
  end
122
128
 
129
+ # Returns estimates on the identified scale, with nil for persons and items that
130
+ # have no finite estimate on it, plus whether the convergence criteria were met.
123
131
  def fit
124
- prev_ll = log_likelihood
125
-
126
- @max_iter.times do
127
- grad_abilities, grad_difficulties = compute_gradient
128
-
129
- old_a, old_d = apply_gradient_update(grad_abilities, grad_difficulties)
130
-
131
- current_ll = log_likelihood
132
- param_delta = average_param_update(old_a, old_d)
133
-
134
- if current_ll < prev_ll
135
- @abilities = old_a
136
- @difficulties = old_d
137
- @learning_rate *= @decay_factor
138
- else
139
- ll_diff = (current_ll - prev_ll).abs
140
- break if ll_diff < @tolerance && param_delta < @param_tolerance
141
-
142
- prev_ll = current_ll
132
+ converged = false
133
+ iterations = 0
134
+
135
+ if @active_persons.any?
136
+ prev_ll = log_likelihood
137
+
138
+ @max_iter.times do
139
+ iterations += 1
140
+ grad_abilities, grad_difficulties = compute_gradient
141
+
142
+ old_a, old_d = apply_gradient_update(grad_abilities, grad_difficulties)
143
+
144
+ current_ll = log_likelihood
145
+ param_delta = average_param_update(old_a, old_d)
146
+
147
+ if current_ll < prev_ll
148
+ @abilities = old_a
149
+ @difficulties = old_d
150
+ @learning_rate *= @decay_factor
151
+ else
152
+ ll_diff = (current_ll - prev_ll).abs
153
+ if ll_diff < @tolerance && param_delta < @param_tolerance
154
+ converged = stationary?
155
+ break
156
+ end
157
+
158
+ prev_ll = current_ll
159
+ @learning_rate *= Estimation::LEARNING_RATE_GROWTH
160
+ end
143
161
  end
162
+
144
163
  end
145
164
 
146
- { abilities: @abilities, difficulties: @difficulties }
165
+ mean, = Estimation.scale(@abilities, @active_persons)
166
+ {
167
+ abilities: Estimation.estimates(@abilities.map { |theta| theta - mean }, @active_persons),
168
+ difficulties: Estimation.estimates(@difficulties.map { |b| b - mean }, @active_items),
169
+ converged: converged,
170
+ iterations: iterations
171
+ }
172
+ end
173
+
174
+ def stationary?
175
+ grad_abilities, grad_difficulties = compute_gradient
176
+ Estimation.stationary?(grad_abilities, [grad_difficulties], @person_counts, @item_counts, @gradient_tolerance)
147
177
  end
178
+ private :stationary?
148
179
  end
149
180
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IrtRuby
4
+ # Validates response data accepted by IRT model constructors.
5
+ # @api private
6
+ module ResponseDataValidator
7
+ module_function
8
+
9
+ def validate!(data)
10
+ raise ArgumentError, "response data must be a Matrix or array of arrays" unless valid_data_container?(data)
11
+
12
+ data_array = data.to_a
13
+
14
+ raise ArgumentError, "response data must have at least one row" unless data_array.any?
15
+
16
+ validate_rows!(data_array)
17
+ validate_values!(data_array)
18
+
19
+ data_array
20
+ end
21
+
22
+ def validate_rows!(data_array)
23
+ first_row = data_array.first
24
+
25
+ raise ArgumentError, "response data must be a Matrix or array of arrays" unless first_row.is_a?(Array)
26
+
27
+ expected_columns = first_row.size
28
+ raise ArgumentError, "response data must have at least one column" if expected_columns.zero?
29
+
30
+ data_array.each_with_index do |row, index|
31
+ row_number = index + 1
32
+ raise ArgumentError, "response data row #{row_number} must be an Array" unless row.is_a?(Array)
33
+
34
+ next if row.size == expected_columns
35
+
36
+ raise ArgumentError, "response data must be rectangular; row #{row_number} has #{row.size} columns, expected #{expected_columns}"
37
+ end
38
+ end
39
+
40
+ def validate_values!(data_array)
41
+ data_array.each_with_index do |row, row_index|
42
+ row.each_with_index do |value, column_index|
43
+ next if valid_response?(value)
44
+
45
+ raise ArgumentError,
46
+ "response data contains invalid value #{value.inspect} at row #{row_index + 1}, column #{column_index + 1}; allowed values are 0, 1, and nil"
47
+ end
48
+ end
49
+ end
50
+
51
+ def valid_response?(value)
52
+ value.nil? || value.eql?(0) || value.eql?(1)
53
+ end
54
+
55
+ def valid_data_container?(data)
56
+ data.is_a?(Array) || (defined?(::Matrix) && data.is_a?(::Matrix))
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IrtRuby
4
+ # Finds the persons and items whose parameters are jointly estimable.
5
+ # @api private
6
+ module ResponseGraph
7
+ module_function
8
+
9
+ # Returns [persons, items] flags marking the parameters that have finite joint
10
+ # maximum-likelihood estimates on a common scale. In the response graph a correct
11
+ # answer links item -> person and an incorrect one person -> item; estimates exist
12
+ # within a strongly connected component, so the largest one is kept. All-correct,
13
+ # all-incorrect and unobserved rows or columns, perfectly separated subsets, and
14
+ # subsets sharing no responses all fall outside it. This suffices for the Rasch
15
+ # model and for 2PL with bounded discriminations; for 3PL it is necessary but not
16
+ # sufficient.
17
+ def estimable(responses)
18
+ num_persons = responses.size
19
+ num_items = responses.first.size
20
+ forward = Array.new(num_persons + num_items) { [] }
21
+ backward = Array.new(num_persons + num_items) { [] }
22
+ responses.each_with_index do |row, i|
23
+ row.each_with_index do |value, j|
24
+ next if value.nil?
25
+
26
+ from, to = value == 1 ? [num_persons + j, i] : [i, num_persons + j]
27
+ forward[from] << to
28
+ backward[to] << from
29
+ end
30
+ end
31
+
32
+ component = strongly_connected_components(forward, backward)
33
+ sizes = Hash.new(0)
34
+ component.each { |c| sizes[c] += 1 }
35
+ largest, size = sizes.max_by { |c, n| [n, -c] }
36
+ return [Array.new(num_persons, false), Array.new(num_items, false)] if size < 2
37
+
38
+ [Array.new(num_persons) { |i| component[i] == largest },
39
+ Array.new(num_items) { |j| component[num_persons + j] == largest }]
40
+ end
41
+
42
+ # Iterative Kosaraju: returns the component index of every node.
43
+ def strongly_connected_components(forward, backward)
44
+ order = []
45
+ visited = Array.new(forward.size, false)
46
+ forward.each_index do |start|
47
+ next if visited[start]
48
+
49
+ visited[start] = true
50
+ stack = [[start, 0]]
51
+ until stack.empty?
52
+ node, edge = stack.last
53
+ if edge < forward[node].size
54
+ stack.last[1] += 1
55
+ target = forward[node][edge]
56
+ next if visited[target]
57
+
58
+ visited[target] = true
59
+ stack << [target, 0]
60
+ else
61
+ order << node
62
+ stack.pop
63
+ end
64
+ end
65
+ end
66
+
67
+ component = Array.new(forward.size)
68
+ count = 0
69
+ order.reverse_each do |start|
70
+ next if component[start]
71
+
72
+ component[start] = count
73
+ stack = [start]
74
+ until stack.empty?
75
+ backward[stack.pop].each do |source|
76
+ next if component[source]
77
+
78
+ component[source] = count
79
+ stack << source
80
+ end
81
+ end
82
+ count += 1
83
+ end
84
+ component
85
+ end
86
+ end
87
+ end