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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/LICENSE.txt +21 -0
- data/README.md +168 -0
- data/benchmarks/README.md +135 -0
- data/benchmarks/convergence_benchmark.rb +265 -0
- data/benchmarks/performance_benchmark.rb +153 -0
- data/lib/irt_ruby/rasch_model.rb +123 -33
- data/lib/irt_ruby/three_parameter_model.rb +154 -41
- data/lib/irt_ruby/two_parameter_model.rb +131 -40
- data/lib/irt_ruby/version.rb +1 -1
- data/lib/irt_ruby.rb +1 -0
- metadata +69 -10
@@ -1,65 +1,156 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "matrix"
|
4
|
-
|
5
3
|
module IrtRuby
|
6
|
-
# A class representing the Two-Parameter model for
|
4
|
+
# A class representing the Two-Parameter model (2PL) for IRT.
|
5
|
+
# Incorporates:
|
6
|
+
# - Adaptive learning rate
|
7
|
+
# - Missing data handling
|
8
|
+
# - Parameter clamping for discrimination
|
9
|
+
# - Multiple convergence checks
|
10
|
+
# - Separate gradient calculation & parameter update
|
7
11
|
class TwoParameterModel
|
8
|
-
|
12
|
+
MISSING_STRATEGIES = %i[ignore treat_as_incorrect treat_as_correct].freeze
|
13
|
+
|
14
|
+
def initialize(data, max_iter: 1000, tolerance: 1e-6, param_tolerance: 1e-6,
|
15
|
+
learning_rate: 0.01, decay_factor: 0.5,
|
16
|
+
missing_strategy: :ignore)
|
9
17
|
@data = data
|
10
|
-
@
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
18
|
+
@data_array = data.to_a
|
19
|
+
num_rows = @data_array.size
|
20
|
+
num_cols = @data_array.first.size
|
21
|
+
|
22
|
+
raise ArgumentError, "missing_strategy must be one of #{MISSING_STRATEGIES}" unless MISSING_STRATEGIES.include?(missing_strategy)
|
23
|
+
|
24
|
+
@missing_strategy = missing_strategy
|
25
|
+
|
26
|
+
# Initialize parameters
|
27
|
+
# 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
|
16
37
|
end
|
17
38
|
|
18
|
-
# Sigmoid function
|
19
39
|
def sigmoid(x)
|
20
40
|
1.0 / (1.0 + Math.exp(-x))
|
21
41
|
end
|
22
42
|
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
@
|
27
|
-
|
43
|
+
def resolve_missing(resp)
|
44
|
+
return [resp, false] unless resp.nil?
|
45
|
+
|
46
|
+
case @missing_strategy
|
47
|
+
when :ignore
|
48
|
+
[nil, true]
|
49
|
+
when :treat_as_incorrect
|
50
|
+
[0, false]
|
51
|
+
when :treat_as_correct
|
52
|
+
[1, false]
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def log_likelihood
|
57
|
+
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
|
+
|
28
63
|
prob = sigmoid(@discriminations[j] * (@abilities[i] - @difficulties[j]))
|
29
|
-
if
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
64
|
+
ll += if value == 1
|
65
|
+
Math.log(prob + 1e-15)
|
66
|
+
else
|
67
|
+
Math.log((1 - prob) + 1e-15)
|
68
|
+
end
|
34
69
|
end
|
35
70
|
end
|
36
|
-
|
71
|
+
ll
|
37
72
|
end
|
38
73
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
@
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
74
|
+
def compute_gradient
|
75
|
+
grad_abilities = Array.new(@abilities.size, 0.0)
|
76
|
+
grad_difficulties = Array.new(@difficulties.size, 0.0)
|
77
|
+
grad_discriminations = Array.new(@discriminations.size, 0.0)
|
78
|
+
|
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
|
86
|
+
|
87
|
+
grad_abilities[i] += error * @discriminations[j]
|
88
|
+
grad_difficulties[j] -= error * @discriminations[j]
|
89
|
+
grad_discriminations[j] += error * (@abilities[i] - @difficulties[j])
|
51
90
|
end
|
52
|
-
|
53
|
-
|
91
|
+
end
|
92
|
+
|
93
|
+
[grad_abilities, grad_difficulties, grad_discriminations]
|
94
|
+
end
|
95
|
+
|
96
|
+
def apply_gradient_update(ga, gd, gdisc)
|
97
|
+
old_a = @abilities.dup
|
98
|
+
old_d = @difficulties.dup
|
99
|
+
old_disc = @discriminations.dup
|
100
|
+
|
101
|
+
@abilities.each_index do |i|
|
102
|
+
@abilities[i] += @learning_rate * ga[i]
|
103
|
+
end
|
54
104
|
|
55
|
-
|
105
|
+
@difficulties.each_index do |j|
|
106
|
+
@difficulties[j] += @learning_rate * gd[j]
|
56
107
|
end
|
108
|
+
|
109
|
+
@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
|
113
|
+
end
|
114
|
+
|
115
|
+
[old_a, old_d, old_disc]
|
116
|
+
end
|
117
|
+
|
118
|
+
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 }
|
123
|
+
deltas.sum / deltas.size
|
57
124
|
end
|
58
125
|
|
59
|
-
# Fit the model to the data
|
60
126
|
def fit
|
61
|
-
|
62
|
-
|
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
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
{
|
150
|
+
abilities: @abilities,
|
151
|
+
difficulties: @difficulties,
|
152
|
+
discriminations: @discriminations
|
153
|
+
}
|
63
154
|
end
|
64
155
|
end
|
65
156
|
end
|
data/lib/irt_ruby/version.rb
CHANGED
data/lib/irt_ruby.rb
CHANGED
metadata
CHANGED
@@ -1,15 +1,43 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: irt_ruby
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.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: 2025-06-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: matrix
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.4.2
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.4.2
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: benchmark-ips
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '2.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '2.0'
|
13
41
|
- !ruby/object:Gem::Dependency
|
14
42
|
name: bundler
|
15
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -24,6 +52,20 @@ dependencies:
|
|
24
52
|
- - "~>"
|
25
53
|
- !ruby/object:Gem::Version
|
26
54
|
version: '2.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: memory_profiler
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.0'
|
27
69
|
- !ruby/object:Gem::Dependency
|
28
70
|
name: rake
|
29
71
|
requirement: !ruby/object:Gem::Requirement
|
@@ -52,16 +94,30 @@ dependencies:
|
|
52
94
|
- - "~>"
|
53
95
|
- !ruby/object:Gem::Version
|
54
96
|
version: '3.0'
|
55
|
-
description: IrtRuby is a Ruby
|
56
|
-
|
57
|
-
|
58
|
-
|
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"
|
59
109
|
email:
|
60
110
|
- alexandrkholodniak@gmail.com
|
61
111
|
executables: []
|
62
112
|
extensions: []
|
63
113
|
extra_rdoc_files: []
|
64
114
|
files:
|
115
|
+
- CHANGELOG.md
|
116
|
+
- LICENSE.txt
|
117
|
+
- README.md
|
118
|
+
- benchmarks/README.md
|
119
|
+
- benchmarks/convergence_benchmark.rb
|
120
|
+
- benchmarks/performance_benchmark.rb
|
65
121
|
- lib/irt_ruby.rb
|
66
122
|
- lib/irt_ruby/rasch_model.rb
|
67
123
|
- lib/irt_ruby/three_parameter_model.rb
|
@@ -73,7 +129,10 @@ licenses:
|
|
73
129
|
metadata:
|
74
130
|
homepage_uri: https://github.com/SyntaxSpirits/irt_ruby
|
75
131
|
source_code_uri: https://github.com/SyntaxSpirits/irt_ruby
|
76
|
-
changelog_uri: https://github.com/SyntaxSpirits/irt_ruby/CHANGELOG.md
|
132
|
+
changelog_uri: https://github.com/SyntaxSpirits/irt_ruby/blob/main/CHANGELOG.md
|
133
|
+
documentation_uri: https://github.com/SyntaxSpirits/irt_ruby#readme
|
134
|
+
bug_tracker_uri: https://github.com/SyntaxSpirits/irt_ruby/issues
|
135
|
+
rubygems_mfa_required: 'true'
|
77
136
|
post_install_message:
|
78
137
|
rdoc_options: []
|
79
138
|
require_paths:
|
@@ -89,9 +148,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
148
|
- !ruby/object:Gem::Version
|
90
149
|
version: '0'
|
91
150
|
requirements: []
|
92
|
-
rubygems_version: 3.
|
151
|
+
rubygems_version: 3.5.9
|
93
152
|
signing_key:
|
94
153
|
specification_version: 4
|
95
|
-
summary:
|
96
|
-
|
154
|
+
summary: Production-ready Item Response Theory (IRT) models with comprehensive performance
|
155
|
+
benchmarking and adaptive optimization.
|
97
156
|
test_files: []
|