irt_ruby 0.3.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.
@@ -1,5 +1,10 @@
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 Three-Parameter model (3PL) for Item Response Theory.
5
10
  # Incorporates:
@@ -8,42 +13,72 @@ module IrtRuby
8
13
  # - Parameter clamping for discrimination, guessing
9
14
  # - Multiple convergence checks
10
15
  # - Separate gradient calculation & updates
16
+ # - Exclusion of extreme response patterns, which have no finite estimates
17
+ # - Identification by reporting abilities standardized to mean 0 and SD 1
11
18
  class ThreeParameterModel
12
19
  MISSING_STRATEGIES = %i[ignore treat_as_incorrect treat_as_correct].freeze
20
+ DISCRIMINATION_BOUNDS = [0.01, 5.0].freeze
21
+ GUESSING_BOUNDS = [0.0, 0.35].freeze
22
+ # A correct response contributes (1 - s) / P to the guessing gradient (exp(-z) at
23
+ # c = 0), which overflows when P is tiny; capping each term keeps the sum finite
24
+ # so that backtracking can still shrink the step.
25
+ MAX_GUESSING_GRADIENT_TERM = 1e12
13
26
 
14
27
  def initialize(data,
15
28
  max_iter: 1000,
16
29
  tolerance: 1e-6,
17
30
  param_tolerance: 1e-6,
31
+ gradient_tolerance: 1e-3,
18
32
  learning_rate: 0.01,
19
33
  decay_factor: 0.5,
20
- missing_strategy: :ignore)
21
- @data = data
22
- @data_array = data.to_a
23
- num_rows = @data_array.size
24
- num_cols = @data_array.first.size
34
+ missing_strategy: :ignore,
35
+ seed: nil)
36
+ ModelOptionsValidator.validate!(max_iter: max_iter,
37
+ tolerance: tolerance,
38
+ param_tolerance: param_tolerance,
39
+ gradient_tolerance: gradient_tolerance,
40
+ learning_rate: learning_rate,
41
+ decay_factor: decay_factor)
42
+
43
+ data_array = ResponseDataValidator.validate!(data)
44
+ num_rows = data_array.size
45
+ num_cols = data_array.first.size
25
46
 
26
47
  raise ArgumentError, "missing_strategy must be one of #{MISSING_STRATEGIES}" unless MISSING_STRATEGIES.include?(missing_strategy)
27
48
 
28
49
  @missing_strategy = missing_strategy
50
+ @random = seed.nil? ? nil : Random.new(seed)
51
+
52
+ @responses = data_array.map { |row| row.map { |resp| resolve_missing(resp).first } }
53
+ @active_persons, @active_items = ResponseGraph.estimable(@responses)
54
+ @person_counts, @item_counts = Estimation.response_counts(@responses, @active_persons, @active_items)
55
+ @floor_items = floor_items
56
+ @guessing_counts = @item_counts.dup
57
+ @floor_items.each { |j| @guessing_counts[j] += 1 }
29
58
 
30
59
  # 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
60
+ @abilities = Array.new(num_rows) { random_between(-0.25..0.25) }
61
+ @difficulties = Array.new(num_cols) { random_between(-0.25..0.25) }
62
+ @discriminations = Array.new(num_cols) { random_between(0.5..1.5) }
63
+ @guessings = Array.new(num_cols) { random_between(0.0..0.3) }
64
+
65
+ @max_iter = max_iter
66
+ @tolerance = tolerance
67
+ @param_tolerance = param_tolerance
68
+ @gradient_tolerance = gradient_tolerance
69
+ @learning_rate = learning_rate
70
+ @decay_factor = decay_factor
41
71
  end
42
72
 
43
73
  def sigmoid(x)
44
74
  1.0 / (1.0 + Math.exp(-x))
45
75
  end
46
76
 
77
+ def random_between(range)
78
+ @random ? @random.rand(range) : rand(range)
79
+ end
80
+ private :random_between
81
+
47
82
  # Probability for the 3PL model: c + (1-c)*sigmoid(a*(θ - b))
48
83
  def probability(theta, a, b, c)
49
84
  c + ((1.0 - c) * sigmoid(a * (theta - b)))
@@ -64,23 +99,17 @@ module IrtRuby
64
99
 
65
100
  def log_likelihood
66
101
  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
82
- end
102
+ Estimation.each_response(@responses, @active_persons, @active_items) do |i, j, value|
103
+ z = @discriminations[j] * (@abilities[i] - @difficulties[j])
104
+ c = @guessings[j]
105
+
106
+ ll += if value == 1
107
+ c.zero? ? Estimation.log_sigmoid(z) : Math.log(c + ((1.0 - c) * sigmoid(z)))
108
+ else
109
+ Math.log(1.0 - c) + Estimation.log_sigmoid(-z)
110
+ end
83
111
  end
112
+ @floor_items.each { |j| ll += Math.log(1.0 - @guessings[j]) }
84
113
  ll
85
114
  end
86
115
 
@@ -90,29 +119,45 @@ module IrtRuby
90
119
  grad_discriminations = Array.new(@discriminations.size, 0.0)
91
120
  grad_guessings = Array.new(@guessings.size, 0.0)
92
121
 
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]
122
+ Estimation.each_response(@responses, @active_persons, @active_items) do |i, j, value|
123
+ theta = @abilities[i]
124
+ a = @discriminations[j]
125
+ b = @difficulties[j]
126
+ c = @guessings[j]
127
+
128
+ # With P = c + (1 - c) * s and s = sigmoid(z), z = a * (theta - b):
129
+ # dLL/dz = (u - P) * s / P, and dLL/dc = (1 - s) / P if u = 1, -1 / (1 - c) if u = 0.
130
+ z = a * (theta - b)
131
+ s = sigmoid(z)
132
+ prob = c + ((1.0 - c) * s)
133
+ dz = c.zero? ? value - s : (value - prob) * s / prob
134
+
135
+ grad_abilities[i] += dz * a
136
+ grad_difficulties[j] -= dz * a
137
+ grad_discriminations[j] += dz * (theta - b)
138
+ grad_guessings[j] += if value.zero?
139
+ -1.0 / (1.0 - c)
140
+ else
141
+ [c.zero? ? Math.exp(-z) : (1.0 - s) / prob, MAX_GUESSING_GRADIENT_TERM].min
142
+ end
143
+ end
144
+ @floor_items.each { |j| grad_guessings[j] -= 1.0 / (1.0 - @guessings[j]) }
102
145
 
103
- prob = probability(theta, a, b, c)
104
- error = value - prob
146
+ [grad_abilities, grad_difficulties, grad_discriminations, grad_guessings]
147
+ end
105
148
 
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)
149
+ # Excluded respondents who answered every estimable item incorrectly have ability
150
+ # tending to -infinity, where P = c, so each of those responses still contributes
151
+ # log(1 - c) to the likelihood of its item's guessing parameter.
152
+ def floor_items
153
+ @responses.each_index.flat_map do |i|
154
+ next [] if @active_persons[i]
109
155
 
110
- grad_guessings[j] += error * 1.0
111
- end
156
+ items = @responses[i].each_index.select { |j| @active_items[j] && !@responses[i][j].nil? }
157
+ items.all? { |j| @responses[i][j].zero? } ? items : []
112
158
  end
113
-
114
- [grad_abilities, grad_difficulties, grad_discriminations, grad_guessings]
115
159
  end
160
+ private :floor_items
116
161
 
117
162
  def apply_gradient_update(ga, gd, gdisc, gc)
118
163
  old_a = @abilities.dup
@@ -129,59 +174,84 @@ module IrtRuby
129
174
  end
130
175
 
131
176
  @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
177
+ @discriminations[j] = (@discriminations[j] + (@learning_rate * gdisc[j])).clamp(*DISCRIMINATION_BOUNDS)
135
178
  end
136
179
 
137
180
  @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
181
+ @guessings[j] = (@guessings[j] + (@learning_rate * gc[j])).clamp(*GUESSING_BOUNDS)
141
182
  end
142
183
 
143
184
  [old_a, old_d, old_disc, old_c]
144
185
  end
145
186
 
146
187
  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 }
188
+ deltas = Estimation.changes(@abilities, old_a, @active_persons) +
189
+ Estimation.changes(@difficulties, old_d, @active_items) +
190
+ Estimation.changes(@discriminations, old_disc, @active_items) +
191
+ Estimation.changes(@guessings, old_c, @active_items)
152
192
  deltas.sum / deltas.size
153
193
  end
154
194
 
195
+ # Returns estimates on the identified scale, with nil for persons and items that
196
+ # have no finite estimate on it, plus whether the convergence criteria were met.
155
197
  def fit
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
198
+ converged = false
199
+ iterations = 0
200
+
201
+ if @active_persons.any?
202
+ prev_ll = log_likelihood
203
+
204
+ @max_iter.times do
205
+ iterations += 1
206
+ ga, gd, gdisc, gc = compute_gradient
207
+ old_a, old_d, old_disc, old_c = apply_gradient_update(ga, gd, gdisc, gc)
208
+
209
+ curr_ll = log_likelihood
210
+ param_delta = average_param_update(old_a, old_d, old_disc, old_c)
211
+
212
+ if curr_ll < prev_ll
213
+ @abilities = old_a
214
+ @difficulties = old_d
215
+ @discriminations = old_disc
216
+ @guessings = old_c
217
+ @learning_rate *= @decay_factor
218
+ else
219
+ ll_diff = (curr_ll - prev_ll).abs
220
+ if ll_diff < @tolerance && param_delta < @param_tolerance
221
+ converged = stationary?
222
+ break
223
+ end
224
+
225
+ prev_ll = curr_ll
226
+ @learning_rate *= Estimation::LEARNING_RATE_GROWTH
227
+ end
176
228
  end
229
+
177
230
  end
178
231
 
232
+ mean, sd = Estimation.scale(@abilities, @active_persons)
179
233
  {
180
- abilities: @abilities,
181
- difficulties: @difficulties,
182
- discriminations: @discriminations,
183
- guessings: @guessings
234
+ abilities: Estimation.estimates(@abilities.map { |theta| (theta - mean) / sd }, @active_persons),
235
+ difficulties: Estimation.estimates(@difficulties.map { |b| (b - mean) / sd }, @active_items),
236
+ discriminations: Estimation.estimates(@discriminations.map { |a| a * sd }, @active_items),
237
+ guessings: Estimation.estimates(@guessings, @active_items),
238
+ converged: converged,
239
+ iterations: iterations
184
240
  }
185
241
  end
242
+
243
+ # Gradients are compared on the reported scale (theta' = (theta - m) / sd,
244
+ # b' = (b - m) / sd, a' = a * sd), where they scale by sd, sd and 1 / sd.
245
+ def stationary?
246
+ ga, gd, gdisc, gc = compute_gradient
247
+ gdisc = Estimation.project(gdisc, @discriminations, *DISCRIMINATION_BOUNDS)
248
+ gc = Estimation.project(gc, @guessings, *GUESSING_BOUNDS)
249
+ _, sd = Estimation.scale(@abilities, @active_persons)
250
+ Estimation.stationary?(ga.map { |g| g * sd },
251
+ [gd.map { |g| g * sd }, gdisc.map { |g| g / sd }],
252
+ @person_counts, @item_counts, @gradient_tolerance) &&
253
+ Estimation.small_residuals?(gc, @guessing_counts, @gradient_tolerance)
254
+ end
255
+ private :stationary?
186
256
  end
187
257
  end
@@ -1,5 +1,10 @@
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 Two-Parameter model (2PL) for IRT.
5
10
  # Incorporates:
@@ -8,38 +13,59 @@ module IrtRuby
8
13
  # - Parameter clamping for discrimination
9
14
  # - Multiple convergence checks
10
15
  # - Separate gradient calculation & parameter update
16
+ # - Exclusion of extreme response patterns, which have no finite estimates
17
+ # - Identification by reporting abilities standardized to mean 0 and SD 1
11
18
  class TwoParameterModel
12
19
  MISSING_STRATEGIES = %i[ignore treat_as_incorrect treat_as_correct].freeze
20
+ DISCRIMINATION_BOUNDS = [0.01, 5.0].freeze
13
21
 
14
22
  def initialize(data, max_iter: 1000, tolerance: 1e-6, param_tolerance: 1e-6,
23
+ gradient_tolerance: 1e-3,
15
24
  learning_rate: 0.01, decay_factor: 0.5,
16
- missing_strategy: :ignore)
17
- @data = data
18
- @data_array = data.to_a
19
- num_rows = @data_array.size
20
- num_cols = @data_array.first.size
25
+ missing_strategy: :ignore, seed: nil)
26
+ ModelOptionsValidator.validate!(max_iter: max_iter,
27
+ tolerance: tolerance,
28
+ param_tolerance: param_tolerance,
29
+ gradient_tolerance: gradient_tolerance,
30
+ learning_rate: learning_rate,
31
+ decay_factor: decay_factor)
32
+
33
+ data_array = ResponseDataValidator.validate!(data)
34
+ num_rows = data_array.size
35
+ num_cols = data_array.first.size
21
36
 
22
37
  raise ArgumentError, "missing_strategy must be one of #{MISSING_STRATEGIES}" unless MISSING_STRATEGIES.include?(missing_strategy)
23
38
 
24
39
  @missing_strategy = missing_strategy
40
+ @random = seed.nil? ? nil : Random.new(seed)
41
+
42
+ @responses = data_array.map { |row| row.map { |resp| resolve_missing(resp).first } }
43
+ @active_persons, @active_items = ResponseGraph.estimable(@responses)
44
+ @person_counts, @item_counts = Estimation.response_counts(@responses, @active_persons, @active_items)
25
45
 
26
46
  # Initialize parameters
27
47
  # Typically: ability ~ 0, difficulty ~ 0, discrimination ~ 1
28
- @abilities = Array.new(num_rows) { rand(-0.25..0.25) }
29
- @difficulties = Array.new(num_cols) { rand(-0.25..0.25) }
30
- @discriminations = Array.new(num_cols) { rand(0.5..1.5) }
31
-
32
- @max_iter = max_iter
33
- @tolerance = tolerance
34
- @param_tolerance = param_tolerance
35
- @learning_rate = learning_rate
36
- @decay_factor = decay_factor
48
+ @abilities = Array.new(num_rows) { random_between(-0.25..0.25) }
49
+ @difficulties = Array.new(num_cols) { random_between(-0.25..0.25) }
50
+ @discriminations = Array.new(num_cols) { random_between(0.5..1.5) }
51
+
52
+ @max_iter = max_iter
53
+ @tolerance = tolerance
54
+ @param_tolerance = param_tolerance
55
+ @gradient_tolerance = gradient_tolerance
56
+ @learning_rate = learning_rate
57
+ @decay_factor = decay_factor
37
58
  end
38
59
 
39
60
  def sigmoid(x)
40
61
  1.0 / (1.0 + Math.exp(-x))
41
62
  end
42
63
 
64
+ def random_between(range)
65
+ @random ? @random.rand(range) : rand(range)
66
+ end
67
+ private :random_between
68
+
43
69
  def resolve_missing(resp)
44
70
  return [resp, false] unless resp.nil?
45
71
 
@@ -55,18 +81,9 @@ module IrtRuby
55
81
 
56
82
  def log_likelihood
57
83
  ll = 0.0
58
- @data_array.each_with_index do |row, i|
59
- row.each_with_index do |resp, j|
60
- value, skip = resolve_missing(resp)
61
- next if skip
62
-
63
- prob = sigmoid(@discriminations[j] * (@abilities[i] - @difficulties[j]))
64
- ll += if value == 1
65
- Math.log(prob + 1e-15)
66
- else
67
- Math.log((1 - prob) + 1e-15)
68
- end
69
- end
84
+ Estimation.each_response(@responses, @active_persons, @active_items) do |i, j, value|
85
+ z = @discriminations[j] * (@abilities[i] - @difficulties[j])
86
+ ll += Estimation.log_sigmoid(value == 1 ? z : -z)
70
87
  end
71
88
  ll
72
89
  end
@@ -76,18 +93,13 @@ module IrtRuby
76
93
  grad_difficulties = Array.new(@difficulties.size, 0.0)
77
94
  grad_discriminations = Array.new(@discriminations.size, 0.0)
78
95
 
79
- @data_array.each_with_index do |row, i|
80
- row.each_with_index do |resp, j|
81
- value, skip = resolve_missing(resp)
82
- next if skip
83
-
84
- prob = sigmoid(@discriminations[j] * (@abilities[i] - @difficulties[j]))
85
- error = value - prob
96
+ Estimation.each_response(@responses, @active_persons, @active_items) do |i, j, value|
97
+ prob = sigmoid(@discriminations[j] * (@abilities[i] - @difficulties[j]))
98
+ error = value - prob
86
99
 
87
- grad_abilities[i] += error * @discriminations[j]
88
- grad_difficulties[j] -= error * @discriminations[j]
89
- grad_discriminations[j] += error * (@abilities[i] - @difficulties[j])
90
- end
100
+ grad_abilities[i] += error * @discriminations[j]
101
+ grad_difficulties[j] -= error * @discriminations[j]
102
+ grad_discriminations[j] += error * (@abilities[i] - @difficulties[j])
91
103
  end
92
104
 
93
105
  [grad_abilities, grad_difficulties, grad_discriminations]
@@ -107,50 +119,75 @@ module IrtRuby
107
119
  end
108
120
 
109
121
  @discriminations.each_index do |j|
110
- @discriminations[j] += @learning_rate * gdisc[j]
111
- @discriminations[j] = 0.01 if @discriminations[j] < 0.01
112
- @discriminations[j] = 5.0 if @discriminations[j] > 5.0
122
+ @discriminations[j] = (@discriminations[j] + (@learning_rate * gdisc[j])).clamp(*DISCRIMINATION_BOUNDS)
113
123
  end
114
124
 
115
125
  [old_a, old_d, old_disc]
116
126
  end
117
127
 
118
128
  def average_param_update(old_a, old_d, old_disc)
119
- deltas = []
120
- @abilities.each_with_index { |x, i| deltas << (x - old_a[i]).abs }
121
- @difficulties.each_with_index { |x, j| deltas << (x - old_d[j]).abs }
122
- @discriminations.each_with_index { |x, j| deltas << (x - old_disc[j]).abs }
129
+ deltas = Estimation.changes(@abilities, old_a, @active_persons) +
130
+ Estimation.changes(@difficulties, old_d, @active_items) +
131
+ Estimation.changes(@discriminations, old_disc, @active_items)
123
132
  deltas.sum / deltas.size
124
133
  end
125
134
 
135
+ # Returns estimates on the identified scale, with nil for persons and items that
136
+ # have no finite estimate on it, plus whether the convergence criteria were met.
126
137
  def fit
127
- prev_ll = log_likelihood
128
-
129
- @max_iter.times do
130
- ga, gd, gdisc = compute_gradient
131
- old_a, old_d, old_disc = apply_gradient_update(ga, gd, gdisc)
132
-
133
- curr_ll = log_likelihood
134
- param_delta = average_param_update(old_a, old_d, old_disc)
135
-
136
- if curr_ll < prev_ll
137
- @abilities = old_a
138
- @difficulties = old_d
139
- @discriminations = old_disc
140
- @learning_rate *= @decay_factor
141
- else
142
- ll_diff = (curr_ll - prev_ll).abs
143
- break if ll_diff < @tolerance && param_delta < @param_tolerance
144
-
145
- prev_ll = curr_ll
138
+ converged = false
139
+ iterations = 0
140
+
141
+ if @active_persons.any?
142
+ prev_ll = log_likelihood
143
+
144
+ @max_iter.times do
145
+ iterations += 1
146
+ ga, gd, gdisc = compute_gradient
147
+ old_a, old_d, old_disc = apply_gradient_update(ga, gd, gdisc)
148
+
149
+ curr_ll = log_likelihood
150
+ param_delta = average_param_update(old_a, old_d, old_disc)
151
+
152
+ if curr_ll < prev_ll
153
+ @abilities = old_a
154
+ @difficulties = old_d
155
+ @discriminations = old_disc
156
+ @learning_rate *= @decay_factor
157
+ else
158
+ ll_diff = (curr_ll - prev_ll).abs
159
+ if ll_diff < @tolerance && param_delta < @param_tolerance
160
+ converged = stationary?
161
+ break
162
+ end
163
+
164
+ prev_ll = curr_ll
165
+ @learning_rate *= Estimation::LEARNING_RATE_GROWTH
166
+ end
146
167
  end
168
+
147
169
  end
148
170
 
171
+ mean, sd = Estimation.scale(@abilities, @active_persons)
149
172
  {
150
- abilities: @abilities,
151
- difficulties: @difficulties,
152
- discriminations: @discriminations
173
+ abilities: Estimation.estimates(@abilities.map { |theta| (theta - mean) / sd }, @active_persons),
174
+ difficulties: Estimation.estimates(@difficulties.map { |b| (b - mean) / sd }, @active_items),
175
+ discriminations: Estimation.estimates(@discriminations.map { |a| a * sd }, @active_items),
176
+ converged: converged,
177
+ iterations: iterations
153
178
  }
154
179
  end
180
+
181
+ # Gradients are compared on the reported scale (theta' = (theta - m) / sd,
182
+ # b' = (b - m) / sd, a' = a * sd), where they scale by sd, sd and 1 / sd.
183
+ def stationary?
184
+ ga, gd, gdisc = compute_gradient
185
+ gdisc = Estimation.project(gdisc, @discriminations, *DISCRIMINATION_BOUNDS)
186
+ _, sd = Estimation.scale(@abilities, @active_persons)
187
+ Estimation.stationary?(ga.map { |g| g * sd },
188
+ [gd.map { |g| g * sd }, gdisc.map { |g| g / sd }],
189
+ @person_counts, @item_counts, @gradient_tolerance)
190
+ end
191
+ private :stationary?
155
192
  end
156
193
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IrtRuby
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/irt_ruby.rb CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  require "irt_ruby/version"
4
4
  require "matrix"
5
+ require "irt_ruby/model_options_validator"
6
+ require "irt_ruby/response_data_validator"
7
+ require "irt_ruby/estimation"
8
+ require "irt_ruby/response_graph"
5
9
  require "irt_ruby/rasch_model"
6
10
  require "irt_ruby/two_parameter_model"
7
11
  require "irt_ruby/three_parameter_model"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: irt_ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Kholodniak
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-06-13 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: matrix
@@ -94,18 +94,17 @@ dependencies:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
96
  version: '3.0'
97
- description: "IrtRuby is a comprehensive Ruby library for Item Response Theory (IRT)
98
- analysis, \ncommonly used in educational assessment, psychological testing, and
99
- survey research.\n\nFeatures three core IRT models:\n• Rasch Model (1PL) - Simple
100
- difficulty-only model\n• Two-Parameter Model (2PL) - Adds item discrimination\n•
101
- Three-Parameter Model (3PL) - Includes guessing parameter\n\nKey capabilities:\n•
102
- Robust gradient ascent optimization with adaptive learning rates\n• Flexible missing
103
- data strategies (ignore, treat as incorrect/correct)\n• Comprehensive performance
104
- benchmarking suite\n• Memory-efficient implementation with excellent scaling\n•
105
- Production-ready with extensive test coverage\n\nPerfect for researchers, data scientists,
106
- and developers working with \neducational assessments, psychological measurements,
107
- or any binary response data\nwhere item and person parameters need to be estimated
108
- simultaneously.\n"
97
+ description: "IrtRuby is a Ruby library for Item Response Theory (IRT) analysis, \ncommonly
98
+ used in educational assessment, psychological testing, and survey research.\n\nFeatures
99
+ three core IRT models:\n• Rasch Model (1PL) - Simple difficulty-only model\n• Two-Parameter
100
+ Model (2PL) - Adds item discrimination\n• Three-Parameter Model (3PL) - Includes
101
+ guessing parameter\n\nKey capabilities:\n• Joint maximum likelihood by gradient
102
+ ascent with an adaptive learning rate\n• Convergence reporting, scale identification,
103
+ and exclusion of extreme response patterns\n• Missing data strategies (ignore, treat
104
+ as incorrect/correct)\n• Performance and convergence benchmark scripts\n\nIntended
105
+ for researchers, data scientists, and developers working with \neducational assessments,
106
+ psychological measurements, or any binary response data\nwhere item and person parameters
107
+ need to be estimated simultaneously.\n"
109
108
  email:
110
109
  - alexandrkholodniak@gmail.com
111
110
  executables: []
@@ -119,7 +118,11 @@ files:
119
118
  - benchmarks/convergence_benchmark.rb
120
119
  - benchmarks/performance_benchmark.rb
121
120
  - lib/irt_ruby.rb
121
+ - lib/irt_ruby/estimation.rb
122
+ - lib/irt_ruby/model_options_validator.rb
122
123
  - lib/irt_ruby/rasch_model.rb
124
+ - lib/irt_ruby/response_data_validator.rb
125
+ - lib/irt_ruby/response_graph.rb
123
126
  - lib/irt_ruby/three_parameter_model.rb
124
127
  - lib/irt_ruby/two_parameter_model.rb
125
128
  - lib/irt_ruby/version.rb
@@ -151,6 +154,6 @@ requirements: []
151
154
  rubygems_version: 3.5.9
152
155
  signing_key:
153
156
  specification_version: 4
154
- summary: Production-ready Item Response Theory (IRT) models with comprehensive performance
155
- benchmarking and adaptive optimization.
157
+ summary: Rasch, 2PL, and 3PL Item Response Theory (IRT) models estimated by joint
158
+ maximum likelihood.
156
159
  test_files: []