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,11 +1,18 @@
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 Rasch model for Item Response Theory (ability - difficulty).
5
10
  # Incorporates:
6
11
  # - Adaptive learning rate
7
12
  # - Missing data handling (skip nil)
8
13
  # - Multiple convergence checks (log-likelihood + parameter updates)
14
+ # - Exclusion of extreme response patterns, which have no finite estimates
15
+ # - Identification by reporting abilities centered at zero
9
16
  class RaschModel
10
17
  MISSING_STRATEGIES = %i[ignore treat_as_incorrect treat_as_correct].freeze
11
18
 
@@ -13,36 +20,55 @@ module IrtRuby
13
20
  max_iter: 1000,
14
21
  tolerance: 1e-6,
15
22
  param_tolerance: 1e-6,
23
+ gradient_tolerance: 1e-3,
16
24
  learning_rate: 0.01,
17
25
  decay_factor: 0.5,
18
- missing_strategy: :ignore)
26
+ missing_strategy: :ignore,
27
+ seed: nil)
19
28
  # data: A Matrix or array-of-arrays of responses (0/1 or nil for missing).
20
29
  # missing_strategy: :ignore (skip), :treat_as_incorrect, :treat_as_correct
21
30
 
22
- @data = data
23
- @data_array = data.to_a
24
- num_rows = @data_array.size
25
- num_cols = @data_array.first.size
31
+ ModelOptionsValidator.validate!(max_iter: max_iter,
32
+ tolerance: tolerance,
33
+ param_tolerance: param_tolerance,
34
+ gradient_tolerance: gradient_tolerance,
35
+ learning_rate: learning_rate,
36
+ decay_factor: decay_factor)
37
+
38
+ data_array = ResponseDataValidator.validate!(data)
39
+ num_rows = data_array.size
40
+ num_cols = data_array.first.size
26
41
 
27
42
  raise ArgumentError, "missing_strategy must be one of #{MISSING_STRATEGIES}" unless MISSING_STRATEGIES.include?(missing_strategy)
28
43
 
29
44
  @missing_strategy = missing_strategy
45
+ @random = seed.nil? ? nil : Random.new(seed)
46
+
47
+ @responses = data_array.map { |row| row.map { |resp| resolve_missing(resp).first } }
48
+ @active_persons, @active_items = ResponseGraph.estimable(@responses)
49
+ @person_counts, @item_counts = Estimation.response_counts(@responses, @active_persons, @active_items)
30
50
 
31
51
  # Initialize parameters near zero
32
- @abilities = Array.new(num_rows) { rand(-0.25..0.25) }
33
- @difficulties = Array.new(num_cols) { rand(-0.25..0.25) }
34
-
35
- @max_iter = max_iter
36
- @tolerance = tolerance
37
- @param_tolerance = param_tolerance
38
- @learning_rate = learning_rate
39
- @decay_factor = decay_factor
52
+ @abilities = Array.new(num_rows) { random_between(-0.25..0.25) }
53
+ @difficulties = Array.new(num_cols) { random_between(-0.25..0.25) }
54
+
55
+ @max_iter = max_iter
56
+ @tolerance = tolerance
57
+ @param_tolerance = param_tolerance
58
+ @gradient_tolerance = gradient_tolerance
59
+ @learning_rate = learning_rate
60
+ @decay_factor = decay_factor
40
61
  end
41
62
 
42
63
  def sigmoid(x)
43
64
  1.0 / (1.0 + Math.exp(-x))
44
65
  end
45
66
 
67
+ def random_between(range)
68
+ @random ? @random.rand(range) : rand(range)
69
+ end
70
+ private :random_between
71
+
46
72
  def resolve_missing(resp)
47
73
  return [resp, false] unless resp.nil?
48
74
 
@@ -58,18 +84,9 @@ module IrtRuby
58
84
 
59
85
  def log_likelihood
60
86
  total_ll = 0.0
61
- @data_array.each_with_index do |row, i|
62
- row.each_with_index do |resp, j|
63
- value, skip = resolve_missing(resp)
64
- next if skip
65
-
66
- prob = sigmoid(@abilities[i] - @difficulties[j])
67
- total_ll += if value == 1
68
- Math.log(prob + 1e-15)
69
- else
70
- Math.log((1 - prob) + 1e-15)
71
- end
72
- end
87
+ Estimation.each_response(@responses, @active_persons, @active_items) do |i, j, value|
88
+ z = @abilities[i] - @difficulties[j]
89
+ total_ll += Estimation.log_sigmoid(value == 1 ? z : -z)
73
90
  end
74
91
  total_ll
75
92
  end
@@ -78,17 +95,11 @@ module IrtRuby
78
95
  grad_abilities = Array.new(@abilities.size, 0.0)
79
96
  grad_difficulties = Array.new(@difficulties.size, 0.0)
80
97
 
81
- @data_array.each_with_index do |row, i|
82
- row.each_with_index do |resp, j|
83
- value, skip = resolve_missing(resp)
84
- next if skip
85
-
86
- prob = sigmoid(@abilities[i] - @difficulties[j])
87
- error = value - prob
98
+ Estimation.each_response(@responses, @active_persons, @active_items) do |i, j, value|
99
+ error = value - sigmoid(@abilities[i] - @difficulties[j])
88
100
 
89
- grad_abilities[i] += error
90
- grad_difficulties[j] -= error
91
- end
101
+ grad_abilities[i] += error
102
+ grad_difficulties[j] -= error
92
103
  end
93
104
 
94
105
  [grad_abilities, grad_difficulties]
@@ -110,40 +121,60 @@ module IrtRuby
110
121
  end
111
122
 
112
123
  def average_param_update(old_abilities, old_difficulties)
113
- deltas = []
114
- @abilities.each_with_index do |a, i|
115
- deltas << (a - old_abilities[i]).abs
116
- end
117
- @difficulties.each_with_index do |d, j|
118
- deltas << (d - old_difficulties[j]).abs
119
- end
124
+ deltas = Estimation.changes(@abilities, old_abilities, @active_persons) +
125
+ Estimation.changes(@difficulties, old_difficulties, @active_items)
120
126
  deltas.sum / deltas.size
121
127
  end
122
128
 
129
+ # Returns estimates on the identified scale, with nil for persons and items that
130
+ # have no finite estimate on it, plus whether the convergence criteria were met.
123
131
  def fit
124
- prev_ll = log_likelihood
125
-
126
- @max_iter.times do
127
- grad_abilities, grad_difficulties = compute_gradient
128
-
129
- old_a, old_d = apply_gradient_update(grad_abilities, grad_difficulties)
130
-
131
- current_ll = log_likelihood
132
- param_delta = average_param_update(old_a, old_d)
133
-
134
- if current_ll < prev_ll
135
- @abilities = old_a
136
- @difficulties = old_d
137
- @learning_rate *= @decay_factor
138
- else
139
- ll_diff = (current_ll - prev_ll).abs
140
- break if ll_diff < @tolerance && param_delta < @param_tolerance
141
-
142
- prev_ll = current_ll
132
+ converged = false
133
+ iterations = 0
134
+
135
+ if @active_persons.any?
136
+ prev_ll = log_likelihood
137
+
138
+ @max_iter.times do
139
+ iterations += 1
140
+ grad_abilities, grad_difficulties = compute_gradient
141
+
142
+ old_a, old_d = apply_gradient_update(grad_abilities, grad_difficulties)
143
+
144
+ current_ll = log_likelihood
145
+ param_delta = average_param_update(old_a, old_d)
146
+
147
+ if current_ll < prev_ll
148
+ @abilities = old_a
149
+ @difficulties = old_d
150
+ @learning_rate *= @decay_factor
151
+ else
152
+ ll_diff = (current_ll - prev_ll).abs
153
+ if ll_diff < @tolerance && param_delta < @param_tolerance
154
+ converged = stationary?
155
+ break
156
+ end
157
+
158
+ prev_ll = current_ll
159
+ @learning_rate *= Estimation::LEARNING_RATE_GROWTH
160
+ end
143
161
  end
162
+
144
163
  end
145
164
 
146
- { abilities: @abilities, difficulties: @difficulties }
165
+ mean, = Estimation.scale(@abilities, @active_persons)
166
+ {
167
+ abilities: Estimation.estimates(@abilities.map { |theta| theta - mean }, @active_persons),
168
+ difficulties: Estimation.estimates(@difficulties.map { |b| b - mean }, @active_items),
169
+ converged: converged,
170
+ iterations: iterations
171
+ }
172
+ end
173
+
174
+ def stationary?
175
+ grad_abilities, grad_difficulties = compute_gradient
176
+ Estimation.stationary?(grad_abilities, [grad_difficulties], @person_counts, @item_counts, @gradient_tolerance)
147
177
  end
178
+ private :stationary?
148
179
  end
149
180
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IrtRuby
4
+ # Validates response data accepted by IRT model constructors.
5
+ # @api private
6
+ module ResponseDataValidator
7
+ module_function
8
+
9
+ def validate!(data)
10
+ raise ArgumentError, "response data must be a Matrix or array of arrays" unless valid_data_container?(data)
11
+
12
+ data_array = data.to_a
13
+
14
+ raise ArgumentError, "response data must have at least one row" unless data_array.any?
15
+
16
+ validate_rows!(data_array)
17
+ validate_values!(data_array)
18
+
19
+ data_array
20
+ end
21
+
22
+ def validate_rows!(data_array)
23
+ first_row = data_array.first
24
+
25
+ raise ArgumentError, "response data must be a Matrix or array of arrays" unless first_row.is_a?(Array)
26
+
27
+ expected_columns = first_row.size
28
+ raise ArgumentError, "response data must have at least one column" if expected_columns.zero?
29
+
30
+ data_array.each_with_index do |row, index|
31
+ row_number = index + 1
32
+ raise ArgumentError, "response data row #{row_number} must be an Array" unless row.is_a?(Array)
33
+
34
+ next if row.size == expected_columns
35
+
36
+ raise ArgumentError, "response data must be rectangular; row #{row_number} has #{row.size} columns, expected #{expected_columns}"
37
+ end
38
+ end
39
+
40
+ def validate_values!(data_array)
41
+ data_array.each_with_index do |row, row_index|
42
+ row.each_with_index do |value, column_index|
43
+ next if valid_response?(value)
44
+
45
+ raise ArgumentError,
46
+ "response data contains invalid value #{value.inspect} at row #{row_index + 1}, column #{column_index + 1}; allowed values are 0, 1, and nil"
47
+ end
48
+ end
49
+ end
50
+
51
+ def valid_response?(value)
52
+ value.nil? || value.eql?(0) || value.eql?(1)
53
+ end
54
+
55
+ def valid_data_container?(data)
56
+ data.is_a?(Array) || (defined?(::Matrix) && data.is_a?(::Matrix))
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IrtRuby
4
+ # Finds the persons and items whose parameters are jointly estimable.
5
+ # @api private
6
+ module ResponseGraph
7
+ module_function
8
+
9
+ # Returns [persons, items] flags marking the parameters that have finite joint
10
+ # maximum-likelihood estimates on a common scale. In the response graph a correct
11
+ # answer links item -> person and an incorrect one person -> item; estimates exist
12
+ # within a strongly connected component, so the largest one is kept. All-correct,
13
+ # all-incorrect and unobserved rows or columns, perfectly separated subsets, and
14
+ # subsets sharing no responses all fall outside it. This suffices for the Rasch
15
+ # model and for 2PL with bounded discriminations; for 3PL it is necessary but not
16
+ # sufficient.
17
+ def estimable(responses)
18
+ num_persons = responses.size
19
+ num_items = responses.first.size
20
+ forward = Array.new(num_persons + num_items) { [] }
21
+ backward = Array.new(num_persons + num_items) { [] }
22
+ responses.each_with_index do |row, i|
23
+ row.each_with_index do |value, j|
24
+ next if value.nil?
25
+
26
+ from, to = value == 1 ? [num_persons + j, i] : [i, num_persons + j]
27
+ forward[from] << to
28
+ backward[to] << from
29
+ end
30
+ end
31
+
32
+ component = strongly_connected_components(forward, backward)
33
+ sizes = Hash.new(0)
34
+ component.each { |c| sizes[c] += 1 }
35
+ largest, size = sizes.max_by { |c, n| [n, -c] }
36
+ return [Array.new(num_persons, false), Array.new(num_items, false)] if size < 2
37
+
38
+ [Array.new(num_persons) { |i| component[i] == largest },
39
+ Array.new(num_items) { |j| component[num_persons + j] == largest }]
40
+ end
41
+
42
+ # Iterative Kosaraju: returns the component index of every node.
43
+ def strongly_connected_components(forward, backward)
44
+ order = []
45
+ visited = Array.new(forward.size, false)
46
+ forward.each_index do |start|
47
+ next if visited[start]
48
+
49
+ visited[start] = true
50
+ stack = [[start, 0]]
51
+ until stack.empty?
52
+ node, edge = stack.last
53
+ if edge < forward[node].size
54
+ stack.last[1] += 1
55
+ target = forward[node][edge]
56
+ next if visited[target]
57
+
58
+ visited[target] = true
59
+ stack << [target, 0]
60
+ else
61
+ order << node
62
+ stack.pop
63
+ end
64
+ end
65
+ end
66
+
67
+ component = Array.new(forward.size)
68
+ count = 0
69
+ order.reverse_each do |start|
70
+ next if component[start]
71
+
72
+ component[start] = count
73
+ stack = [start]
74
+ until stack.empty?
75
+ backward[stack.pop].each do |source|
76
+ next if component[source]
77
+
78
+ component[source] = count
79
+ stack << source
80
+ end
81
+ end
82
+ count += 1
83
+ end
84
+ component
85
+ end
86
+ end
87
+ end