irt_ruby 0.2.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 +71 -0
- data/LICENSE.txt +21 -0
- data/README.md +209 -0
- data/benchmarks/README.md +135 -0
- data/benchmarks/convergence_benchmark.rb +225 -0
- data/benchmarks/performance_benchmark.rb +153 -0
- 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 +155 -85
- 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 +56 -10
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "irt_ruby"
|
|
6
|
+
require "benchmark/ips"
|
|
7
|
+
require "memory_profiler"
|
|
8
|
+
|
|
9
|
+
# Generate test data of different sizes
|
|
10
|
+
def generate_data(num_people, num_items, missing_rate: 0.0)
|
|
11
|
+
Array.new(num_people) do
|
|
12
|
+
Array.new(num_items) do
|
|
13
|
+
if rand < missing_rate
|
|
14
|
+
nil
|
|
15
|
+
else
|
|
16
|
+
rand < 0.6 ? 1 : 0 # 60% probability of correct response
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Dataset configurations
|
|
23
|
+
DATASET_CONFIGS = [
|
|
24
|
+
{ people: 10, items: 5, label: "Tiny (10x5)" },
|
|
25
|
+
{ people: 50, items: 20, label: "Small (50x20)" },
|
|
26
|
+
{ people: 100, items: 50, label: "Medium (100x50)" },
|
|
27
|
+
{ people: 200, items: 100, label: "Large (200x100)" },
|
|
28
|
+
{ people: 500, items: 200, label: "XLarge (500x200)" }
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
puts "=" * 60
|
|
32
|
+
puts "IRT Ruby Performance Benchmarks"
|
|
33
|
+
puts "=" * 60
|
|
34
|
+
puts
|
|
35
|
+
|
|
36
|
+
# Benchmark each model type across different dataset sizes
|
|
37
|
+
DATASET_CONFIGS.each do |config|
|
|
38
|
+
puts "Dataset: #{config[:label]}"
|
|
39
|
+
puts "-" * 40
|
|
40
|
+
|
|
41
|
+
data = generate_data(config[:people], config[:items])
|
|
42
|
+
|
|
43
|
+
Benchmark.ips do |x|
|
|
44
|
+
x.config(time: 5, warmup: 2)
|
|
45
|
+
|
|
46
|
+
x.report("Rasch Model") do
|
|
47
|
+
model = IrtRuby::RaschModel.new(data, max_iter: 100)
|
|
48
|
+
model.fit
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
x.report("2PL Model") do
|
|
52
|
+
model = IrtRuby::TwoParameterModel.new(data, max_iter: 100)
|
|
53
|
+
model.fit
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
x.report("3PL Model") do
|
|
57
|
+
model = IrtRuby::ThreeParameterModel.new(data, max_iter: 100)
|
|
58
|
+
model.fit
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
x.compare!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
puts
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Memory usage analysis for medium dataset
|
|
68
|
+
puts "=" * 60
|
|
69
|
+
puts "Memory Usage Analysis (Medium Dataset: 100x50)"
|
|
70
|
+
puts "=" * 60
|
|
71
|
+
|
|
72
|
+
data = generate_data(100, 50)
|
|
73
|
+
|
|
74
|
+
%i[RaschModel TwoParameterModel ThreeParameterModel].each do |model_class|
|
|
75
|
+
puts "\n#{model_class}:"
|
|
76
|
+
puts "-" * 20
|
|
77
|
+
|
|
78
|
+
report = MemoryProfiler.report do
|
|
79
|
+
model = IrtRuby.const_get(model_class).new(data, max_iter: 100)
|
|
80
|
+
model.fit
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
puts "Total allocated: #{report.total_allocated_memsize} bytes"
|
|
84
|
+
puts "Total retained: #{report.total_retained_memsize} bytes"
|
|
85
|
+
puts "Objects allocated: #{report.total_allocated}"
|
|
86
|
+
puts "Objects retained: #{report.total_retained}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Scaling analysis - how performance changes with dataset size
|
|
90
|
+
puts "\n#{"=" * 60}"
|
|
91
|
+
puts "Scaling Analysis - Rasch Model Only"
|
|
92
|
+
puts "=" * 60
|
|
93
|
+
|
|
94
|
+
scaling_results = {}
|
|
95
|
+
|
|
96
|
+
DATASET_CONFIGS.each do |config|
|
|
97
|
+
data = generate_data(config[:people], config[:items])
|
|
98
|
+
|
|
99
|
+
times = []
|
|
100
|
+
5.times do
|
|
101
|
+
start_time = Time.now
|
|
102
|
+
model = IrtRuby::RaschModel.new(data, max_iter: 100)
|
|
103
|
+
model.fit
|
|
104
|
+
end_time = Time.now
|
|
105
|
+
times << (end_time - start_time)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
avg_time = times.sum / times.size
|
|
109
|
+
scaling_results[config[:label]] = {
|
|
110
|
+
size: config[:people] * config[:items],
|
|
111
|
+
avg_time: avg_time,
|
|
112
|
+
people: config[:people],
|
|
113
|
+
items: config[:items]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
puts "#{config[:label]}: #{avg_time.round(4)}s (#{config[:people] * config[:items]} data points)"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Calculate scaling coefficient
|
|
120
|
+
puts "\nScaling Analysis:"
|
|
121
|
+
puts "-" * 20
|
|
122
|
+
scaling_results.each_cons(2) do |(label1, data1), (label2, data2)|
|
|
123
|
+
size_ratio = data2[:size].to_f / data1[:size]
|
|
124
|
+
time_ratio = data2[:avg_time] / data1[:avg_time]
|
|
125
|
+
scaling_factor = Math.log(time_ratio) / Math.log(size_ratio)
|
|
126
|
+
|
|
127
|
+
puts "#{label1} -> #{label2}: #{size_ratio.round(2)}x size, #{time_ratio.round(2)}x time (O(n^#{scaling_factor.round(2)}))"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Missing data performance impact
|
|
131
|
+
puts "\n#{"=" * 60}"
|
|
132
|
+
puts "Missing Data Strategy Performance Impact"
|
|
133
|
+
puts "=" * 60
|
|
134
|
+
|
|
135
|
+
data_with_missing = generate_data(100, 50, missing_rate: 0.2)
|
|
136
|
+
|
|
137
|
+
%i[ignore treat_as_incorrect treat_as_correct].each do |strategy|
|
|
138
|
+
puts "\nMissing Strategy: #{strategy}"
|
|
139
|
+
puts "-" * 30
|
|
140
|
+
|
|
141
|
+
Benchmark.ips do |x|
|
|
142
|
+
x.config(time: 3, warmup: 1)
|
|
143
|
+
|
|
144
|
+
x.report("Rasch") do
|
|
145
|
+
model = IrtRuby::RaschModel.new(data_with_missing, max_iter: 50, missing_strategy: strategy)
|
|
146
|
+
model.fit
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
puts "\n#{"=" * 60}"
|
|
152
|
+
puts "Benchmark Complete!"
|
|
153
|
+
puts "=" * 60
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IrtRuby
|
|
4
|
+
# Numerical helpers shared by IRT model implementations.
|
|
5
|
+
# @api private
|
|
6
|
+
module Estimation
|
|
7
|
+
# Learning-rate multiplier after an accepted step, so the rate can recover from earlier decays.
|
|
8
|
+
LEARNING_RATE_GROWTH = 1.1
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def log_sigmoid(x)
|
|
13
|
+
x >= 0 ? -Math.log(1.0 + Math.exp(-x)) : x - Math.log(1.0 + Math.exp(x))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def each_response(responses, persons, items)
|
|
17
|
+
responses.each_with_index do |row, i|
|
|
18
|
+
next unless persons[i]
|
|
19
|
+
|
|
20
|
+
row.each_with_index do |value, j|
|
|
21
|
+
next if value.nil? || !items[j]
|
|
22
|
+
|
|
23
|
+
yield i, j, value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def response_counts(responses, persons, items)
|
|
29
|
+
person_counts = Array.new(responses.size, 0)
|
|
30
|
+
item_counts = Array.new(responses.first.size, 0)
|
|
31
|
+
each_response(responses, persons, items) do |i, j, _value|
|
|
32
|
+
person_counts[i] += 1
|
|
33
|
+
item_counts[j] += 1
|
|
34
|
+
end
|
|
35
|
+
[person_counts, item_counts]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# True when every gradient component, averaged over the responses it sums, is below tolerance.
|
|
39
|
+
def stationary?(person_gradient, item_gradients, person_counts, item_counts, tolerance)
|
|
40
|
+
small_residuals?(person_gradient, person_counts, tolerance) &&
|
|
41
|
+
item_gradients.all? { |gradient| small_residuals?(gradient, item_counts, tolerance) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def small_residuals?(gradient, counts, tolerance)
|
|
45
|
+
gradient.each_index.all? { |k| counts[k].zero? || (gradient[k] / counts[k]).abs < tolerance }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Drops gradient components that push a parameter further past the bound it sits on.
|
|
49
|
+
def project(gradient, values, min, max)
|
|
50
|
+
gradient.each_index.map do |k|
|
|
51
|
+
blocked = (values[k] <= min && gradient[k].negative?) || (values[k] >= max && gradient[k].positive?)
|
|
52
|
+
blocked ? 0.0 : gradient[k]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Location and scale of the estimable abilities, used to report parameters on a fixed scale.
|
|
57
|
+
def scale(abilities, active)
|
|
58
|
+
values = abilities.select.with_index { |_, i| active[i] }
|
|
59
|
+
return [0.0, 1.0] if values.empty?
|
|
60
|
+
|
|
61
|
+
mean, sd = mean_and_sd(values)
|
|
62
|
+
[mean, sd.zero? ? 1.0 : sd]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def mean_and_sd(values)
|
|
66
|
+
mean = values.sum / values.size
|
|
67
|
+
[mean, Math.sqrt(values.sum { |v| (v - mean)**2 } / values.size)]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def changes(current, previous, active)
|
|
71
|
+
current.each_index.select { |k| active[k] }.map { |k| (current[k] - previous[k]).abs }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def estimates(values, active)
|
|
75
|
+
values.each_index.map { |k| active[k] ? values[k] : nil }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IrtRuby
|
|
4
|
+
# Validates optimization hyperparameters shared by IRT model implementations.
|
|
5
|
+
module ModelOptionsValidator
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def validate!(max_iter:, tolerance:, param_tolerance:, gradient_tolerance:, learning_rate:, decay_factor:)
|
|
9
|
+
validate_positive_integer!(:max_iter, max_iter)
|
|
10
|
+
validate_positive_finite_numeric!(:tolerance, tolerance)
|
|
11
|
+
validate_positive_finite_numeric!(:param_tolerance, param_tolerance)
|
|
12
|
+
validate_positive_finite_numeric!(:gradient_tolerance, gradient_tolerance)
|
|
13
|
+
validate_positive_finite_numeric!(:learning_rate, learning_rate)
|
|
14
|
+
validate_decay_factor!(decay_factor)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def validate_positive_integer!(name, value)
|
|
18
|
+
return if value.is_a?(Integer) && value.positive?
|
|
19
|
+
|
|
20
|
+
raise ArgumentError, "#{name} must be a positive Integer"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate_positive_finite_numeric!(name, value)
|
|
24
|
+
return if finite_numeric?(value) && value.positive?
|
|
25
|
+
|
|
26
|
+
raise ArgumentError, "#{name} must be a positive finite Numeric"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_decay_factor!(value)
|
|
30
|
+
return if finite_numeric?(value) && value.positive? && value < 1
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, "decay_factor must be a finite Numeric strictly between 0 and 1"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def finite_numeric?(value)
|
|
36
|
+
value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/irt_ruby/rasch_model.rb
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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) {
|
|
33
|
-
@difficulties = Array.new(num_cols) {
|
|
34
|
-
|
|
35
|
-
@max_iter
|
|
36
|
-
@tolerance
|
|
37
|
-
@param_tolerance
|
|
38
|
-
@
|
|
39
|
-
@
|
|
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
|
-
@
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
@
|
|
82
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|