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.
@@ -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 Item Response Theory.
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
- 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, 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
- @abilities = Array.new(data.row_count) { rand }
11
- @difficulties = Array.new(data.column_count) { rand }
12
- @discriminations = Array.new(data.column_count) { rand }
13
- @max_iter = max_iter
14
- @tolerance = tolerance
15
- @learning_rate = learning_rate
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
- # Calculate the log-likelihood of the data given the current parameters
24
- def likelihood
25
- likelihood = 0
26
- @data.row_vectors.each_with_index do |row, i|
27
- row.to_a.each_with_index do |response, j|
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 response == 1
30
- likelihood += Math.log(prob)
31
- elsif response.zero?
32
- likelihood += Math.log(1 - prob)
33
- end
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
- likelihood
71
+ ll
37
72
  end
38
73
 
39
- # Update parameters using gradient ascent
40
- def update_parameters
41
- last_likelihood = likelihood
42
- @max_iter.times do |_iter|
43
- @data.row_vectors.each_with_index do |row, i|
44
- row.to_a.each_with_index do |response, j|
45
- prob = sigmoid(@discriminations[j] * (@abilities[i] - @difficulties[j]))
46
- error = response - prob
47
- @abilities[i] += @learning_rate * error * @discriminations[j]
48
- @difficulties[j] -= @learning_rate * error * @discriminations[j]
49
- @discriminations[j] += @learning_rate * error * (@abilities[i] - @difficulties[j])
50
- end
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
- current_likelihood = likelihood
53
- break if (last_likelihood - current_likelihood).abs < @tolerance
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
- last_likelihood = current_likelihood
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
- update_parameters
62
- { abilities: @abilities, difficulties: @difficulties, discriminations: @discriminations }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IrtRuby
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/irt_ruby.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "irt_ruby/version"
4
+ require "matrix"
4
5
  require "irt_ruby/rasch_model"
5
6
  require "irt_ruby/two_parameter_model"
6
7
  require "irt_ruby/three_parameter_model"
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.1.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: 2024-06-09 00:00:00.000000000 Z
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 gem that provides implementations of the Rasch model,
56
- Two-Parameter model, and Three-Parameter model for Item Response Theory (IRT). It
57
- allows you to estimate the abilities of individuals and the difficulties, discriminations,
58
- and guessing parameters of items based on their responses to a set of items.
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.4.16
151
+ rubygems_version: 3.5.9
93
152
  signing_key:
94
153
  specification_version: 4
95
- summary: A Ruby gem that provides implementations of Rasch, Two-Parameter, and Three-Parameter
96
- models for Item Response Theory (IRT).
154
+ summary: Production-ready Item Response Theory (IRT) models with comprehensive performance
155
+ benchmarking and adaptive optimization.
97
156
  test_files: []