irt_ruby 0.1.0 → 0.3.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
@@ -1,58 +1,148 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "matrix"
4
-
5
3
  module IrtRuby
6
- # A class representing the Rasch model for Item Response Theory.
4
+ # A class representing the Rasch model for Item Response Theory (ability - difficulty).
5
+ # Incorporates:
6
+ # - Adaptive learning rate
7
+ # - Missing data handling (skip nil)
8
+ # - Multiple convergence checks (log-likelihood + parameter updates)
7
9
  class RaschModel
8
- def initialize(data, max_iter: 1000, tolerance: 1e-6, learning_rate: 0.01)
10
+ MISSING_STRATEGIES = %i[ignore treat_as_incorrect treat_as_correct].freeze
11
+
12
+ def initialize(data,
13
+ max_iter: 1000,
14
+ tolerance: 1e-6,
15
+ param_tolerance: 1e-6,
16
+ learning_rate: 0.01,
17
+ decay_factor: 0.5,
18
+ missing_strategy: :ignore)
19
+ # data: A Matrix or array-of-arrays of responses (0/1 or nil for missing).
20
+ # missing_strategy: :ignore (skip), :treat_as_incorrect, :treat_as_correct
21
+
9
22
  @data = data
10
- @abilities = Array.new(data.row_count) { rand }
11
- @difficulties = Array.new(data.column_count) { rand }
12
- @max_iter = max_iter
13
- @tolerance = tolerance
14
- @learning_rate = learning_rate
23
+ @data_array = data.to_a
24
+ num_rows = @data_array.size
25
+ num_cols = @data_array.first.size
26
+
27
+ raise ArgumentError, "missing_strategy must be one of #{MISSING_STRATEGIES}" unless MISSING_STRATEGIES.include?(missing_strategy)
28
+
29
+ @missing_strategy = missing_strategy
30
+
31
+ # 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
15
40
  end
16
41
 
17
- # Sigmoid function to calculate probability
18
42
  def sigmoid(x)
19
43
  1.0 / (1.0 + Math.exp(-x))
20
44
  end
21
45
 
22
- # Calculate the log-likelihood of the data given the current parameters
23
- def likelihood
24
- likelihood = 0
25
- @data.row_vectors.each_with_index do |row, i|
26
- row.to_a.each_with_index do |response, j|
46
+ def resolve_missing(resp)
47
+ return [resp, false] unless resp.nil?
48
+
49
+ case @missing_strategy
50
+ when :ignore
51
+ [nil, true]
52
+ when :treat_as_incorrect
53
+ [0, false]
54
+ when :treat_as_correct
55
+ [1, false]
56
+ end
57
+ end
58
+
59
+ def log_likelihood
60
+ 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
+
27
66
  prob = sigmoid(@abilities[i] - @difficulties[j])
28
- likelihood += response == 1 ? Math.log(prob) : Math.log(1 - prob)
67
+ total_ll += if value == 1
68
+ Math.log(prob + 1e-15)
69
+ else
70
+ Math.log((1 - prob) + 1e-15)
71
+ end
29
72
  end
30
73
  end
31
- likelihood
74
+ total_ll
32
75
  end
33
76
 
34
- # Update parameters using gradient ascent
35
- def update_parameters
36
- last_likelihood = likelihood
37
- @max_iter.times do |_iter|
38
- @data.row_vectors.each_with_index do |row, i|
39
- row.to_a.each_with_index do |response, j|
40
- prob = sigmoid(@abilities[i] - @difficulties[j])
41
- error = response - prob
42
- @abilities[i] += @learning_rate * error
43
- @difficulties[j] -= @learning_rate * error
44
- end
77
+ def compute_gradient
78
+ grad_abilities = Array.new(@abilities.size, 0.0)
79
+ grad_difficulties = Array.new(@difficulties.size, 0.0)
80
+
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
88
+
89
+ grad_abilities[i] += error
90
+ grad_difficulties[j] -= error
45
91
  end
46
- current_likelihood = likelihood
47
- break if (last_likelihood - current_likelihood).abs < @tolerance
92
+ end
48
93
 
49
- last_likelihood = current_likelihood
94
+ [grad_abilities, grad_difficulties]
95
+ end
96
+
97
+ def apply_gradient_update(grad_abilities, grad_difficulties)
98
+ old_abilities = @abilities.dup
99
+ old_difficulties = @difficulties.dup
100
+
101
+ @abilities.each_index do |i|
102
+ @abilities[i] += @learning_rate * grad_abilities[i]
103
+ end
104
+
105
+ @difficulties.each_index do |j|
106
+ @difficulties[j] += @learning_rate * grad_difficulties[j]
107
+ end
108
+
109
+ [old_abilities, old_difficulties]
110
+ end
111
+
112
+ 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
50
119
  end
120
+ deltas.sum / deltas.size
51
121
  end
52
122
 
53
- # Fit the model to the data
54
123
  def fit
55
- update_parameters
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
143
+ end
144
+ end
145
+
56
146
  { abilities: @abilities, difficulties: @difficulties }
57
147
  end
58
148
  end
@@ -1,68 +1,181 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "matrix"
4
-
5
3
  module IrtRuby
6
- # A class representing the Three-Parameter model for Item Response Theory.
4
+ # A class representing the Three-Parameter model (3PL) for Item Response Theory.
5
+ # Incorporates:
6
+ # - Adaptive learning rate
7
+ # - Missing data handling
8
+ # - Parameter clamping for discrimination, guessing
9
+ # - Multiple convergence checks
10
+ # - Separate gradient calculation & updates
7
11
  class ThreeParameterModel
8
- def initialize(data, max_iter: 1000, tolerance: 1e-6, learning_rate: 0.01)
12
+ MISSING_STRATEGIES = %i[ignore treat_as_incorrect treat_as_correct].freeze
13
+
14
+ def initialize(data,
15
+ max_iter: 1000,
16
+ tolerance: 1e-6,
17
+ param_tolerance: 1e-6,
18
+ learning_rate: 0.01,
19
+ decay_factor: 0.5,
20
+ missing_strategy: :ignore)
9
21
  @data = data
10
- @abilities = Array.new(data.row_count) { rand }
11
- @difficulties = Array.new(data.column_count) { rand }
12
- @discriminations = Array.new(data.column_count) { rand }
13
- @guessings = Array.new(data.column_count) { rand * 0.3 }
14
- @max_iter = max_iter
15
- @tolerance = tolerance
16
- @learning_rate = learning_rate
22
+ @data_array = data.to_a
23
+ num_rows = @data_array.size
24
+ num_cols = @data_array.first.size
25
+
26
+ raise ArgumentError, "missing_strategy must be one of #{MISSING_STRATEGIES}" unless MISSING_STRATEGIES.include?(missing_strategy)
27
+
28
+ @missing_strategy = missing_strategy
29
+
30
+ # Initialize parameters
31
+ @abilities = Array.new(num_rows) { rand(-0.25..0.25) }
32
+ @difficulties = Array.new(num_cols) { rand(-0.25..0.25) }
33
+ @discriminations = Array.new(num_cols) { rand(0.5..1.5) }
34
+ @guessings = Array.new(num_cols) { rand(0.0..0.3) }
35
+
36
+ @max_iter = max_iter
37
+ @tolerance = tolerance
38
+ @param_tolerance = param_tolerance
39
+ @learning_rate = learning_rate
40
+ @decay_factor = decay_factor
17
41
  end
18
42
 
19
- # Sigmoid function to calculate probability
20
43
  def sigmoid(x)
21
44
  1.0 / (1.0 + Math.exp(-x))
22
45
  end
23
46
 
24
- # Probability function for the 3PL model
47
+ # Probability for the 3PL model: c + (1-c)*sigmoid(a*(θ - b))
25
48
  def probability(theta, a, b, c)
26
- c + (1 - c) * sigmoid(a * (theta - b))
49
+ c + ((1.0 - c) * sigmoid(a * (theta - b)))
50
+ end
51
+
52
+ def resolve_missing(resp)
53
+ return [resp, false] unless resp.nil?
54
+
55
+ case @missing_strategy
56
+ when :ignore
57
+ [nil, true]
58
+ when :treat_as_incorrect
59
+ [0, false]
60
+ when :treat_as_correct
61
+ [1, false]
62
+ end
27
63
  end
28
64
 
29
- # Calculate the log-likelihood of the data given the current parameters
30
- def likelihood
31
- likelihood = 0
32
- @data.row_vectors.each_with_index do |row, i|
33
- row.to_a.each_with_index do |response, j|
34
- prob = probability(@abilities[i], @discriminations[j], @difficulties[j], @guessings[j])
35
- likelihood += response == 1 ? Math.log(prob) : Math.log(1 - prob)
65
+ def log_likelihood
66
+ ll = 0.0
67
+ @data_array.each_with_index do |row, i|
68
+ row.each_with_index do |resp, j|
69
+ value, skip = resolve_missing(resp)
70
+ next if skip
71
+
72
+ prob = probability(@abilities[i],
73
+ @discriminations[j],
74
+ @difficulties[j],
75
+ @guessings[j])
76
+
77
+ ll += if value == 1
78
+ Math.log(prob + 1e-15)
79
+ else
80
+ Math.log((1 - prob) + 1e-15)
81
+ end
36
82
  end
37
83
  end
38
- likelihood
84
+ ll
39
85
  end
40
86
 
41
- # Update parameters using gradient ascent
42
- def update_parameters
43
- last_likelihood = likelihood
44
- @max_iter.times do |_iter|
45
- @data.row_vectors.each_with_index do |row, i|
46
- row.to_a.each_with_index do |response, j|
47
- prob = probability(@abilities[i], @discriminations[j], @difficulties[j], @guessings[j])
48
- error = response - prob
49
- @abilities[i] += @learning_rate * error * @discriminations[j]
50
- @difficulties[j] -= @learning_rate * error * @discriminations[j]
51
- @discriminations[j] += @learning_rate * error * (@abilities[i] - @difficulties[j])
52
- @guessings[j] += @learning_rate * error * (1 - prob)
53
- @guessings[j] = [[@guessings[j], 0].max, 1].min # Keep guessings within [0, 1]
54
- end
87
+ def compute_gradient
88
+ grad_abilities = Array.new(@abilities.size, 0.0)
89
+ grad_difficulties = Array.new(@difficulties.size, 0.0)
90
+ grad_discriminations = Array.new(@discriminations.size, 0.0)
91
+ grad_guessings = Array.new(@guessings.size, 0.0)
92
+
93
+ @data_array.each_with_index do |row, i|
94
+ row.each_with_index do |resp, j|
95
+ value, skip = resolve_missing(resp)
96
+ next if skip
97
+
98
+ theta = @abilities[i]
99
+ a = @discriminations[j]
100
+ b = @difficulties[j]
101
+ c = @guessings[j]
102
+
103
+ prob = probability(theta, a, b, c)
104
+ error = value - prob
105
+
106
+ grad_abilities[i] += error * a * (1 - c)
107
+ grad_difficulties[j] -= error * a * (1 - c)
108
+ grad_discriminations[j] += error * (theta - b) * (1 - c)
109
+
110
+ grad_guessings[j] += error * 1.0
55
111
  end
56
- current_likelihood = likelihood
57
- break if (last_likelihood - current_likelihood).abs < @tolerance
112
+ end
58
113
 
59
- last_likelihood = current_likelihood
114
+ [grad_abilities, grad_difficulties, grad_discriminations, grad_guessings]
115
+ end
116
+
117
+ def apply_gradient_update(ga, gd, gdisc, gc)
118
+ old_a = @abilities.dup
119
+ old_d = @difficulties.dup
120
+ old_disc = @discriminations.dup
121
+ old_c = @guessings.dup
122
+
123
+ @abilities.each_index do |i|
124
+ @abilities[i] += @learning_rate * ga[i]
60
125
  end
126
+
127
+ @difficulties.each_index do |j|
128
+ @difficulties[j] += @learning_rate * gd[j]
129
+ end
130
+
131
+ @discriminations.each_index do |j|
132
+ @discriminations[j] += @learning_rate * gdisc[j]
133
+ @discriminations[j] = 0.01 if @discriminations[j] < 0.01
134
+ @discriminations[j] = 5.0 if @discriminations[j] > 5.0
135
+ end
136
+
137
+ @guessings.each_index do |j|
138
+ @guessings[j] += @learning_rate * gc[j]
139
+ @guessings[j] = 0.0 if @guessings[j] < 0.0
140
+ @guessings[j] = 0.35 if @guessings[j] > 0.35
141
+ end
142
+
143
+ [old_a, old_d, old_disc, old_c]
144
+ end
145
+
146
+ def average_param_update(old_a, old_d, old_disc, old_c)
147
+ deltas = []
148
+ @abilities.each_with_index { |x, i| deltas << (x - old_a[i]).abs }
149
+ @difficulties.each_with_index { |x, j| deltas << (x - old_d[j]).abs }
150
+ @discriminations.each_with_index { |x, j| deltas << (x - old_disc[j]).abs }
151
+ @guessings.each_with_index { |x, j| deltas << (x - old_c[j]).abs }
152
+ deltas.sum / deltas.size
61
153
  end
62
154
 
63
- # Fit the model to the data
64
155
  def fit
65
- update_parameters
156
+ prev_ll = log_likelihood
157
+
158
+ @max_iter.times do
159
+ ga, gd, gdisc, gc = compute_gradient
160
+ old_a, old_d, old_disc, old_c = apply_gradient_update(ga, gd, gdisc, gc)
161
+
162
+ curr_ll = log_likelihood
163
+ param_delta = average_param_update(old_a, old_d, old_disc, old_c)
164
+
165
+ if curr_ll < prev_ll
166
+ @abilities = old_a
167
+ @difficulties = old_d
168
+ @discriminations = old_disc
169
+ @guessings = old_c
170
+ @learning_rate *= @decay_factor
171
+ else
172
+ ll_diff = (curr_ll - prev_ll).abs
173
+ break if ll_diff < @tolerance && param_delta < @param_tolerance
174
+
175
+ prev_ll = curr_ll
176
+ end
177
+ end
178
+
66
179
  {
67
180
  abilities: @abilities,
68
181
  difficulties: @difficulties,