Class: IrtRuby::ThreeParameterModel

Inherits:
Object
  • Object
show all
Defined in:
lib/irt_ruby/three_parameter_model.rb

Overview

A class representing the Three-Parameter model (3PL) for Item Response Theory. Incorporates:

  • Adaptive learning rate

  • Missing data handling

  • Parameter clamping for discrimination, guessing

  • Multiple convergence checks

  • Separate gradient calculation & updates

Constant Summary collapse

MISSING_STRATEGIES =
%i[ignore treat_as_incorrect treat_as_correct].freeze

Instance Method Summary collapse

Constructor Details

#initialize(data, max_iter: 1000, tolerance: 1e-6, param_tolerance: 1e-6, learning_rate: 0.01, decay_factor: 0.5, missing_strategy: :ignore) ⇒ ThreeParameterModel

Returns a new instance of ThreeParameterModel.

Raises:

  • (ArgumentError)


14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/irt_ruby/three_parameter_model.rb', line 14

def initialize(data,
               max_iter: 1000,
               tolerance: 1e-6,
               param_tolerance: 1e-6,
               learning_rate: 0.01,
               decay_factor: 0.5,
               missing_strategy: :ignore)
  @data = data
  @data_array = data.to_a
  num_rows = @data_array.size
  num_cols = @data_array.first.size

  raise ArgumentError, "missing_strategy must be one of #{MISSING_STRATEGIES}" unless MISSING_STRATEGIES.include?(missing_strategy)

  @missing_strategy = missing_strategy

  # Initialize parameters
  @abilities       = Array.new(num_rows)  { rand(-0.25..0.25) }
  @difficulties    = Array.new(num_cols)  { rand(-0.25..0.25) }
  @discriminations = Array.new(num_cols)  { rand(0.5..1.5) }
  @guessings       = Array.new(num_cols)  { rand(0.0..0.3) }

  @max_iter        = max_iter
  @tolerance       = tolerance
  @param_tolerance = param_tolerance
  @learning_rate   = learning_rate
  @decay_factor    = decay_factor
end

Instance Method Details

#apply_gradient_update(ga, gd, gdisc, gc) ⇒ Object



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/irt_ruby/three_parameter_model.rb', line 117

def apply_gradient_update(ga, gd, gdisc, gc)
  old_a    = @abilities.dup
  old_d    = @difficulties.dup
  old_disc = @discriminations.dup
  old_c    = @guessings.dup

  @abilities.each_index do |i|
    @abilities[i] += @learning_rate * ga[i]
  end

  @difficulties.each_index do |j|
    @difficulties[j] += @learning_rate * gd[j]
  end

  @discriminations.each_index do |j|
    @discriminations[j] += @learning_rate * gdisc[j]
    @discriminations[j] = 0.01 if @discriminations[j] < 0.01
    @discriminations[j] = 5.0  if @discriminations[j] > 5.0
  end

  @guessings.each_index do |j|
    @guessings[j] += @learning_rate * gc[j]
    @guessings[j] = 0.0  if @guessings[j] < 0.0
    @guessings[j] = 0.35 if @guessings[j] > 0.35
  end

  [old_a, old_d, old_disc, old_c]
end

#average_param_update(old_a, old_d, old_disc, old_c) ⇒ Object



146
147
148
149
150
151
152
153
# File 'lib/irt_ruby/three_parameter_model.rb', line 146

def average_param_update(old_a, old_d, old_disc, old_c)
  deltas = []
  @abilities.each_with_index       { |x, i| deltas << (x - old_a[i]).abs }
  @difficulties.each_with_index    { |x, j| deltas << (x - old_d[j]).abs }
  @discriminations.each_with_index { |x, j| deltas << (x - old_disc[j]).abs }
  @guessings.each_with_index       { |x, j| deltas << (x - old_c[j]).abs }
  deltas.sum / deltas.size
end

#compute_gradientObject



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/irt_ruby/three_parameter_model.rb', line 87

def compute_gradient
  grad_abilities       = Array.new(@abilities.size, 0.0)
  grad_difficulties    = Array.new(@difficulties.size, 0.0)
  grad_discriminations = Array.new(@discriminations.size, 0.0)
  grad_guessings       = Array.new(@guessings.size, 0.0)

  @data_array.each_with_index do |row, i|
    row.each_with_index do |resp, j|
      value, skip = resolve_missing(resp)
      next if skip

      theta = @abilities[i]
      a     = @discriminations[j]
      b     = @difficulties[j]
      c     = @guessings[j]

      prob  = probability(theta, a, b, c)
      error = value - prob

      grad_abilities[i]       += error * a * (1 - c)
      grad_difficulties[j]    -= error * a * (1 - c)
      grad_discriminations[j] += error * (theta - b) * (1 - c)

      grad_guessings[j]       += error * 1.0
    end
  end

  [grad_abilities, grad_difficulties, grad_discriminations, grad_guessings]
end

#fitObject



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/irt_ruby/three_parameter_model.rb', line 155

def fit
  prev_ll = log_likelihood

  @max_iter.times do
    ga, gd, gdisc, gc = compute_gradient
    old_a, old_d, old_disc, old_c = apply_gradient_update(ga, gd, gdisc, gc)

    curr_ll     = log_likelihood
    param_delta = average_param_update(old_a, old_d, old_disc, old_c)

    if curr_ll < prev_ll
      @abilities       = old_a
      @difficulties    = old_d
      @discriminations = old_disc
      @guessings       = old_c
      @learning_rate  *= @decay_factor
    else
      ll_diff = (curr_ll - prev_ll).abs
      break if ll_diff < @tolerance && param_delta < @param_tolerance

      prev_ll = curr_ll
    end
  end

  {
    abilities: @abilities,
    difficulties: @difficulties,
    discriminations: @discriminations,
    guessings: @guessings
  }
end

#log_likelihoodObject



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/irt_ruby/three_parameter_model.rb', line 65

def log_likelihood
  ll = 0.0
  @data_array.each_with_index do |row, i|
    row.each_with_index do |resp, j|
      value, skip = resolve_missing(resp)
      next if skip

      prob = probability(@abilities[i],
                         @discriminations[j],
                         @difficulties[j],
                         @guessings[j])

      ll += if value == 1
              Math.log(prob + 1e-15)
            else
              Math.log((1 - prob) + 1e-15)
            end
    end
  end
  ll
end

#probability(theta, a, b, c) ⇒ Object

Probability for the 3PL model: c + (1-c)*sigmoid(a*(θ - b))



48
49
50
# File 'lib/irt_ruby/three_parameter_model.rb', line 48

def probability(theta, a, b, c)
  c + ((1.0 - c) * sigmoid(a * (theta - b)))
end

#resolve_missing(resp) ⇒ Object



52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/irt_ruby/three_parameter_model.rb', line 52

def resolve_missing(resp)
  return [resp, false] unless resp.nil?

  case @missing_strategy
  when :ignore
    [nil, true]
  when :treat_as_incorrect
    [0, false]
  when :treat_as_correct
    [1, false]
  end
end

#sigmoid(x) ⇒ Object



43
44
45
# File 'lib/irt_ruby/three_parameter_model.rb', line 43

def sigmoid(x)
  1.0 / (1.0 + Math.exp(-x))
end