Skip to content

Commit 5b02354

Browse files
authored
feat: enhance IRT models and expand test coverage (#9)
- Refactor Rasch, 2PL, and 3PL models to: - handle missing data more robustly - clamp parameter ranges (discriminations, guessing) - use multiple convergence checks (log-likelihood + param deltas) - apply adaptive learning rate properly - Add extensive RSpec tests: - Edge cases (single examinee/item, all-correct/all-incorrect) - Missing rows/columns - Hyperparameter extremes (very large/small learning rate) - Repeated fits, deterministic seed checks - Larger random dataset test for performance - Improves overall stability, readability, and reliability of the gem
1 parent c3a0d92 commit 5b02354

8 files changed

Lines changed: 773 additions & 162 deletions

.rubocop.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,28 @@ Style/StringLiteralsInInterpolation:
1010
EnforcedStyle: double_quotes
1111

1212
Layout/LineLength:
13-
Max: 120
13+
Enabled: false
14+
15+
Metrics/MethodLength:
16+
Enabled: false
17+
18+
Metrics/BlockLength:
19+
Enabled: false
20+
21+
Naming/MethodParameterName:
22+
Enabled: false
23+
24+
Metrics/AbcSize:
25+
Enabled: false
26+
27+
Metrics/ClassLength:
28+
Enabled: false
29+
30+
Metrics/ParameterLists:
31+
Enabled: false
32+
33+
Metrics/CyclomaticComplexity:
34+
Enabled: false
35+
36+
Metrics/PerceivedComplexity:
37+
Enabled: false

irt_ruby.gemspec

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ Gem::Specification.new do |spec|
66
spec.authors = ["Alex Kholodniak"]
77
spec.email = ["alexandrkholodniak@gmail.com"]
88

9-
spec.summary = %q{A Ruby gem that provides implementations of Rasch, Two-Parameter, and Three-Parameter models for Item Response Theory (IRT).}
10-
spec.description = %q{IrtRuby is a Ruby gem that provides implementations of the Rasch model, Two-Parameter model, and Three-Parameter model for Item Response Theory (IRT). It allows you to estimate the abilities of individuals and the difficulties, discriminations, and guessing parameters of items based on their responses to a set of items.}
9+
spec.summary = "A Ruby gem that provides implementations of Rasch, Two-Parameter, and Three-Parameter models for Item Response Theory (IRT)."
10+
spec.description = "IrtRuby is a Ruby gem that provides implementations of the Rasch model, Two-Parameter model, and Three-Parameter model for Item Response Theory (IRT). It allows you to estimate the abilities of individuals and the difficulties, discriminations, and guessing parameters of items based on their responses to a set of items."
1111
spec.homepage = "https://github.com/SyntaxSpirits/irt_ruby"
1212
spec.license = "MIT"
1313

lib/irt_ruby/rasch_model.rb

Lines changed: 95 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,123 @@
33
require "matrix"
44

55
module IrtRuby
6-
# A class representing the Rasch model for Item Response Theory.
6+
# A class representing the Rasch model for Item Response Theory (ability - difficulty).
7+
# Incorporates:
8+
# - Adaptive learning rate
9+
# - Missing data handling (skip nil)
10+
# - Multiple convergence checks (log-likelihood + parameter updates)
711
class RaschModel
8-
def initialize(data, max_iter: 1000, tolerance: 1e-6, learning_rate: 0.01)
12+
def initialize(data, max_iter: 1000, tolerance: 1e-6, param_tolerance: 1e-6,
13+
learning_rate: 0.01, decay_factor: 0.5)
14+
# data: A Matrix or array-of-arrays of responses (0/1 or nil for missing).
15+
# Rows = respondents, Columns = items.
16+
917
@data = data
10-
@abilities = Array.new(data.row_count) { rand }
11-
@difficulties = Array.new(data.column_count) { rand }
18+
@data_array = data.to_a
19+
num_rows = @data_array.size
20+
num_cols = @data_array.first.size
21+
22+
# Initialize parameters near zero
23+
@abilities = Array.new(num_rows) { rand(-0.25..0.25) }
24+
@difficulties = Array.new(num_cols) { rand(-0.25..0.25) }
25+
1226
@max_iter = max_iter
1327
@tolerance = tolerance
28+
@param_tolerance = param_tolerance
1429
@learning_rate = learning_rate
30+
@decay_factor = decay_factor
1531
end
1632

17-
# Sigmoid function to calculate probability
1833
def sigmoid(x)
1934
1.0 / (1.0 + Math.exp(-x))
2035
end
2136

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|
37+
def log_likelihood
38+
total_ll = 0.0
39+
@data_array.each_with_index do |row, i|
40+
row.each_with_index do |resp, j|
41+
next if resp.nil?
42+
2743
prob = sigmoid(@abilities[i] - @difficulties[j])
28-
likelihood += response == 1 ? Math.log(prob) : Math.log(1 - prob)
44+
total_ll += if resp == 1
45+
Math.log(prob + 1e-15)
46+
else
47+
Math.log((1 - prob) + 1e-15)
48+
end
2949
end
3050
end
31-
likelihood
51+
total_ll
3252
end
3353

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
54+
def compute_gradient
55+
grad_abilities = Array.new(@abilities.size, 0.0)
56+
grad_difficulties = Array.new(@difficulties.size, 0.0)
57+
58+
@data_array.each_with_index do |row, i|
59+
row.each_with_index do |resp, j|
60+
next if resp.nil?
61+
62+
prob = sigmoid(@abilities[i] - @difficulties[j])
63+
error = resp - prob
64+
65+
grad_abilities[i] += error
66+
grad_difficulties[j] -= error
4567
end
46-
current_likelihood = likelihood
47-
break if (last_likelihood - current_likelihood).abs < @tolerance
68+
end
69+
70+
[grad_abilities, grad_difficulties]
71+
end
72+
73+
def apply_gradient_update(grad_abilities, grad_difficulties)
74+
old_abilities = @abilities.dup
75+
old_difficulties = @difficulties.dup
76+
77+
@abilities.each_index do |i|
78+
@abilities[i] += @learning_rate * grad_abilities[i]
79+
end
4880

49-
last_likelihood = current_likelihood
81+
@difficulties.each_index do |j|
82+
@difficulties[j] += @learning_rate * grad_difficulties[j]
5083
end
84+
85+
[old_abilities, old_difficulties]
86+
end
87+
88+
def average_param_update(old_abilities, old_difficulties)
89+
deltas = []
90+
@abilities.each_with_index do |a, i|
91+
deltas << (a - old_abilities[i]).abs
92+
end
93+
@difficulties.each_with_index do |d, j|
94+
deltas << (d - old_difficulties[j]).abs
95+
end
96+
deltas.sum / deltas.size
5197
end
5298

53-
# Fit the model to the data
5499
def fit
55-
update_parameters
100+
prev_ll = log_likelihood
101+
102+
@max_iter.times do
103+
grad_abilities, grad_difficulties = compute_gradient
104+
105+
old_abilities, old_difficulties = apply_gradient_update(grad_abilities, grad_difficulties)
106+
107+
current_ll = log_likelihood
108+
param_delta = average_param_update(old_abilities, old_difficulties)
109+
110+
if current_ll < prev_ll
111+
@abilities = old_abilities
112+
@difficulties = old_difficulties
113+
@learning_rate *= @decay_factor
114+
else
115+
ll_diff = (current_ll - prev_ll).abs
116+
117+
break if ll_diff < @tolerance && param_delta < @param_tolerance
118+
119+
prev_ll = current_ll
120+
end
121+
end
122+
56123
{ abilities: @abilities, difficulties: @difficulties }
57124
end
58125
end

lib/irt_ruby/three_parameter_model.rb

Lines changed: 134 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,66 +3,161 @@
33
require "matrix"
44

55
module IrtRuby
6-
# A class representing the Three-Parameter model for Item Response Theory.
6+
# A class representing the Three-Parameter model (3PL) for Item Response Theory.
7+
# Incorporates:
8+
# - Adaptive learning rate
9+
# - Missing data handling
10+
# - Parameter clamping for discrimination, guessing
11+
# - Multiple convergence checks
12+
# - Separate gradient calculation & updates
713
class ThreeParameterModel
8-
def initialize(data, max_iter: 1000, tolerance: 1e-6, learning_rate: 0.01)
14+
def initialize(data, max_iter: 1000, tolerance: 1e-6, param_tolerance: 1e-6,
15+
learning_rate: 0.01, decay_factor: 0.5)
916
@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
17+
@data_array = data.to_a
18+
num_rows = @data_array.size
19+
num_cols = @data_array.first.size
20+
21+
# Typical initialization for 3PL
22+
@abilities = Array.new(num_rows) { rand(-0.25..0.25) }
23+
@difficulties = Array.new(num_cols) { rand(-0.25..0.25) }
24+
@discriminations = Array.new(num_cols) { rand(0.5..1.5) }
25+
@guessings = Array.new(num_cols) { rand(0.0..0.3) }
26+
27+
@max_iter = max_iter
28+
@tolerance = tolerance
29+
@param_tolerance = param_tolerance
30+
@learning_rate = learning_rate
31+
@decay_factor = decay_factor
1732
end
1833

19-
# Sigmoid function to calculate probability
2034
def sigmoid(x)
2135
1.0 / (1.0 + Math.exp(-x))
2236
end
2337

24-
# Probability function for the 3PL model
38+
# Probability for the 3PL model: c + (1-c)*sigmoid(a*(θ - b))
2539
def probability(theta, a, b, c)
26-
c + (1 - c) * sigmoid(a * (theta - b))
40+
c + (1.0 - c) * sigmoid(a * (theta - b))
2741
end
2842

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)
43+
def log_likelihood
44+
ll = 0.0
45+
@data_array.each_with_index do |row, i|
46+
row.each_with_index do |resp, j|
47+
next if resp.nil?
48+
49+
prob = probability(@abilities[i], @discriminations[j],
50+
@difficulties[j], @guessings[j])
51+
ll += if resp == 1
52+
Math.log(prob + 1e-15)
53+
else
54+
Math.log((1 - prob) + 1e-15)
55+
end
3656
end
3757
end
38-
likelihood
58+
ll
3959
end
4060

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
61+
def compute_gradient
62+
grad_abilities = Array.new(@abilities.size, 0.0)
63+
grad_difficulties = Array.new(@difficulties.size, 0.0)
64+
grad_discriminations = Array.new(@discriminations.size, 0.0)
65+
grad_guessings = Array.new(@guessings.size, 0.0)
66+
67+
@data_array.each_with_index do |row, i|
68+
row.each_with_index do |resp, j|
69+
next if resp.nil?
70+
71+
theta = @abilities[i]
72+
a = @discriminations[j]
73+
b = @difficulties[j]
74+
c = @guessings[j]
75+
76+
prob = probability(theta, a, b, c)
77+
error = resp - prob
78+
79+
grad_abilities[i] += error * a * (1 - c)
80+
grad_difficulties[j] -= error * a * (1 - c)
81+
grad_discriminations[j] += error * (theta - b) * (1 - c)
82+
83+
grad_guessings[j] += error * 1.0
5584
end
56-
current_likelihood = likelihood
57-
break if (last_likelihood - current_likelihood).abs < @tolerance
85+
end
5886

59-
last_likelihood = current_likelihood
87+
[grad_abilities, grad_difficulties, grad_discriminations, grad_guessings]
88+
end
89+
90+
def apply_gradient_update(ga, gd, gdisc, gc)
91+
old_abilities = @abilities.dup
92+
old_difficulties = @difficulties.dup
93+
old_discriminations = @discriminations.dup
94+
old_guessings = @guessings.dup
95+
96+
@abilities.each_index do |i|
97+
@abilities[i] += @learning_rate * ga[i]
98+
end
99+
100+
@difficulties.each_index do |j|
101+
@difficulties[j] += @learning_rate * gd[j]
102+
end
103+
104+
@discriminations.each_index do |j|
105+
@discriminations[j] += @learning_rate * gdisc[j]
106+
@discriminations[j] = 0.01 if @discriminations[j] < 0.01
107+
@discriminations[j] = 5.0 if @discriminations[j] > 5.0
60108
end
109+
110+
@guessings.each_index do |j|
111+
@guessings[j] += @learning_rate * gc[j]
112+
@guessings[j] = 0.0 if @guessings[j] < 0.0
113+
@guessings[j] = 0.35 if @guessings[j] > 0.35
114+
end
115+
116+
[old_abilities, old_difficulties, old_discriminations, old_guessings]
117+
end
118+
119+
def average_param_update(old_a, old_d, old_disc, old_c)
120+
deltas = []
121+
@abilities.each_with_index do |x, i|
122+
deltas << (x - old_a[i]).abs
123+
end
124+
@difficulties.each_with_index do |x, j|
125+
deltas << (x - old_d[j]).abs
126+
end
127+
@discriminations.each_with_index do |x, j|
128+
deltas << (x - old_disc[j]).abs
129+
end
130+
@guessings.each_with_index do |x, j|
131+
deltas << (x - old_c[j]).abs
132+
end
133+
deltas.sum / deltas.size
61134
end
62135

63-
# Fit the model to the data
64136
def fit
65-
update_parameters
137+
prev_ll = log_likelihood
138+
139+
@max_iter.times do
140+
ga, gd, gdisc, gc = compute_gradient
141+
old_a, old_d, old_disc, old_c = apply_gradient_update(ga, gd, gdisc, gc)
142+
143+
curr_ll = log_likelihood
144+
param_delta = average_param_update(old_a, old_d, old_disc, old_c)
145+
146+
if curr_ll < prev_ll
147+
@abilities = old_a
148+
@difficulties = old_d
149+
@discriminations = old_disc
150+
@guessings = old_c
151+
152+
@learning_rate *= @decay_factor
153+
else
154+
ll_diff = (curr_ll - prev_ll).abs
155+
break if ll_diff < @tolerance && param_delta < @param_tolerance
156+
157+
prev_ll = curr_ll
158+
end
159+
end
160+
66161
{
67162
abilities: @abilities,
68163
difficulties: @difficulties,

0 commit comments

Comments
 (0)