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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -5
- data/README.md +48 -7
- data/benchmarks/convergence_benchmark.rb +25 -65
- data/lib/irt_ruby/estimation.rb +78 -0
- data/lib/irt_ruby/model_options_validator.rb +39 -0
- data/lib/irt_ruby/rasch_model.rb +93 -62
- data/lib/irt_ruby/response_data_validator.rb +59 -0
- data/lib/irt_ruby/response_graph.rb +87 -0
- data/lib/irt_ruby/three_parameter_model.rb +154 -84
- data/lib/irt_ruby/two_parameter_model.rb +103 -66
- data/lib/irt_ruby/version.rb +1 -1
- data/lib/irt_ruby.rb +4 -0
- metadata +19 -16
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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) {
|
|
32
|
-
@difficulties = Array.new(num_cols) {
|
|
33
|
-
@discriminations = Array.new(num_cols) {
|
|
34
|
-
@guessings = Array.new(num_cols) {
|
|
35
|
-
|
|
36
|
-
@max_iter
|
|
37
|
-
@tolerance
|
|
38
|
-
@param_tolerance
|
|
39
|
-
@
|
|
40
|
-
@
|
|
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
|
-
@
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
@
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
146
|
+
[grad_abilities, grad_difficulties, grad_discriminations, grad_guessings]
|
|
147
|
+
end
|
|
105
148
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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]
|
|
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]
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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) {
|
|
29
|
-
@difficulties = Array.new(num_cols) {
|
|
30
|
-
@discriminations = Array.new(num_cols) {
|
|
31
|
-
|
|
32
|
-
@max_iter
|
|
33
|
-
@tolerance
|
|
34
|
-
@param_tolerance
|
|
35
|
-
@
|
|
36
|
-
@
|
|
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
|
-
@
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
@
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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]
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
data/lib/irt_ruby/version.rb
CHANGED
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.
|
|
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:
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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:
|
|
155
|
-
|
|
157
|
+
summary: Rasch, 2PL, and 3PL Item Response Theory (IRT) models estimated by joint
|
|
158
|
+
maximum likelihood.
|
|
156
159
|
test_files: []
|