openskill 0.1.0 → 1.0.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.
@@ -0,0 +1,606 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative '../statistics/normal'
5
+ require_relative 'common'
6
+
7
+ module OpenSkill
8
+ module Models
9
+ # Bradley-Terry Full rating model (Algorithm 2)
10
+ #
11
+ # This model uses partial pairing with a sliding window for efficiency.
12
+ # It uses a logistic regression approach for rating estimation.
13
+ class BradleyTerryPart
14
+ attr_reader :mu, :sigma, :beta, :kappa, :tau, :margin, :limit_sigma, :balance, :gamma, :window_size
15
+
16
+ # Default gamma function for BradleyTerryPart
17
+ DEFAULT_GAMMA = lambda do |_c, _k, _mu, sigma_squared, _team, _rank, _weights|
18
+ Math.sqrt(sigma_squared) / _c
19
+ end
20
+
21
+ # @param mu [Float] initial mean skill rating
22
+ # @param sigma [Float] initial standard deviation
23
+ # @param beta [Float] performance uncertainty
24
+ # @param kappa [Float] minimum variance (regularization)
25
+ # @param gamma [Proc] custom gamma function
26
+ # @param tau [Float] dynamics factor (skill decay)
27
+ # @param margin [Float] score margin for impressive wins
28
+ # @param limit_sigma [Boolean] prevent sigma from increasing
29
+ # @param balance [Boolean] emphasize rating outliers
30
+ # @param window_size [Integer] sliding window size for partial pairing
31
+ def initialize(
32
+ mu: 25.0,
33
+ sigma: 25.0 / 3.0,
34
+ beta: 25.0 / 6.0,
35
+ kappa: 0.0001,
36
+ gamma: DEFAULT_GAMMA,
37
+ tau: 25.0 / 300.0,
38
+ margin: 0.0,
39
+ limit_sigma: false,
40
+ balance: false,
41
+ window_size: 4
42
+ )
43
+ @mu = mu.to_f
44
+ @sigma = sigma.to_f
45
+ @beta = beta.to_f
46
+ @kappa = kappa.to_f
47
+ @gamma = gamma
48
+ @tau = tau.to_f
49
+ @margin = margin.to_f
50
+ @limit_sigma = limit_sigma
51
+ @balance = balance
52
+ @window_size = window_size.to_i
53
+ end
54
+
55
+ # Create a new rating with default or custom parameters
56
+ #
57
+ # @param mu [Float, nil] override default mu
58
+ # @param sigma [Float, nil] override default sigma
59
+ # @param name [String, nil] optional player name
60
+ # @return [Rating] a new rating object
61
+ def create_rating(mu: nil, sigma: nil, name: nil)
62
+ Rating.new(
63
+ mu: mu || @mu,
64
+ sigma: sigma || @sigma,
65
+ name: name
66
+ )
67
+ end
68
+
69
+ # Load a rating from an array [mu, sigma]
70
+ #
71
+ # @param rating_array [Array<Numeric>] [mu, sigma]
72
+ # @param name [String, nil] optional player name
73
+ # @return [Rating] a new rating object
74
+ # @raise [ArgumentError] if rating_array is invalid
75
+ def load_rating(rating_array, name: nil)
76
+ raise ArgumentError, "Rating must be an Array, got #{rating_array.class}" unless rating_array.is_a?(Array)
77
+ raise ArgumentError, 'Rating array must have exactly 2 elements' unless rating_array.size == 2
78
+ raise ArgumentError, 'Rating values must be numeric' unless rating_array.all? { |v| v.is_a?(Numeric) }
79
+
80
+ Rating.new(mu: rating_array[0], sigma: rating_array[1], name: name)
81
+ end
82
+
83
+ # Calculate new ratings after a match
84
+ #
85
+ # @param teams [Array<Array<Rating>>] list of teams
86
+ # @param ranks [Array<Numeric>, nil] team ranks (lower is better, 0-indexed)
87
+ # @param scores [Array<Numeric>, nil] team scores (higher is better)
88
+ # @param weights [Array<Array<Numeric>>, nil] player contribution weights
89
+ # @param tau [Float, nil] override tau for this match
90
+ # @param limit_sigma [Boolean, nil] override limit_sigma for this match
91
+ # @return [Array<Array<Rating>>] updated teams
92
+ def calculate_ratings(teams, ranks: nil, scores: nil, weights: nil, tau: nil, limit_sigma: nil)
93
+ validate_teams!(teams)
94
+ validate_ranks!(teams, ranks) if ranks
95
+ validate_scores!(teams, scores) if scores
96
+ validate_weights!(teams, weights) if weights
97
+
98
+ raise ArgumentError, 'Cannot provide both ranks and scores' if ranks && scores
99
+
100
+ # Deep copy teams to avoid mutating input
101
+ original_teams = teams
102
+ teams = deep_copy_teams(teams)
103
+
104
+ # Apply tau (skill decay over time)
105
+ tau_value = tau || @tau
106
+ tau_squared = tau_value**2
107
+ teams.each do |team|
108
+ team.each do |player|
109
+ player.sigma = Math.sqrt(player.sigma**2 + tau_squared)
110
+ end
111
+ end
112
+
113
+ # Convert scores to ranks if provided
114
+ if !ranks && scores
115
+ ranks = scores.map { |s| -s }
116
+ ranks = calculate_rankings(teams, ranks)
117
+ end
118
+
119
+ # Normalize weights to [1, 2] range
120
+ weights = weights.map { |w| Common.normalize(w, 1, 2) } if weights
121
+
122
+ # Sort teams by rank and track original order
123
+ tenet = nil
124
+ if ranks
125
+ sorted_objects, restoration_indices = Common.unwind(ranks, teams)
126
+ teams = sorted_objects
127
+ tenet = restoration_indices
128
+
129
+ weights, = Common.unwind(ranks, weights) if weights
130
+
131
+ ranks = ranks.sort
132
+ end
133
+
134
+ # Compute new ratings
135
+ result = compute_ratings(teams, ranks: ranks, scores: scores, weights: weights)
136
+
137
+ # Restore original order
138
+ result, = Common.unwind(tenet, result) if ranks && tenet
139
+
140
+ # Apply sigma limiting if requested
141
+ limit_sigma_value = limit_sigma.nil? ? @limit_sigma : limit_sigma
142
+ if limit_sigma_value
143
+ result = result.each_with_index.map do |team, team_idx|
144
+ team.each_with_index.map do |player, player_idx|
145
+ player.sigma = [player.sigma, original_teams[team_idx][player_idx].sigma].min
146
+ player
147
+ end
148
+ end
149
+ end
150
+
151
+ result
152
+ end
153
+
154
+ # Predict win probability for each team
155
+ #
156
+ # @param teams [Array<Array<Rating>>] list of teams
157
+ # @return [Array<Float>] probability each team wins
158
+ def predict_win_probability(teams)
159
+ validate_teams!(teams)
160
+
161
+ n = teams.size
162
+
163
+ # Special case for 2 teams
164
+ if n == 2
165
+ team_ratings = calculate_team_ratings(teams)
166
+ a = team_ratings[0]
167
+ b = team_ratings[1]
168
+
169
+ result = phi_major(
170
+ (a.mu - b.mu) / Math.sqrt(2 * @beta**2 + a.sigma_squared + b.sigma_squared)
171
+ )
172
+ return [result, 1 - result]
173
+ end
174
+
175
+ # For n teams, compute pairwise probabilities
176
+ team_ratings = teams.map { |team| calculate_team_ratings([team])[0] }
177
+
178
+ win_probs = []
179
+ team_ratings.each_with_index do |team_i, i|
180
+ prob_sum = 0.0
181
+ team_ratings.each_with_index do |team_j, j|
182
+ next if i == j
183
+
184
+ prob_sum += phi_major(
185
+ (team_i.mu - team_j.mu) / Math.sqrt(2 * @beta**2 + team_i.sigma_squared + team_j.sigma_squared)
186
+ )
187
+ end
188
+ win_probs << prob_sum / (n - 1)
189
+ end
190
+
191
+ # Normalize to sum to 1
192
+ total = win_probs.sum
193
+ win_probs.map { |p| p / total }
194
+ end
195
+
196
+ # Predict draw probability
197
+ #
198
+ # @param teams [Array<Array<Rating>>] list of teams
199
+ # @return [Float] probability of a draw
200
+ def predict_draw_probability(teams)
201
+ validate_teams!(teams)
202
+
203
+ total_player_count = teams.sum(&:size)
204
+ draw_probability = 1.0 / total_player_count
205
+ draw_margin = Math.sqrt(total_player_count) * @beta * phi_major_inverse((1 + draw_probability) / 2)
206
+
207
+ pairwise_probs = []
208
+ teams.combination(2).each do |team_a, team_b|
209
+ team_a_ratings = calculate_team_ratings([team_a])
210
+ team_b_ratings = calculate_team_ratings([team_b])
211
+
212
+ mu_a = team_a_ratings[0].mu
213
+ sigma_a = team_a_ratings[0].sigma_squared
214
+ mu_b = team_b_ratings[0].mu
215
+ sigma_b = team_b_ratings[0].sigma_squared
216
+
217
+ denominator = Math.sqrt(2 * @beta**2 + sigma_a + sigma_b)
218
+
219
+ pairwise_probs << (
220
+ phi_major((draw_margin - mu_a + mu_b) / denominator) -
221
+ phi_major((mu_b - mu_a - draw_margin) / denominator)
222
+ )
223
+ end
224
+
225
+ pairwise_probs.sum / pairwise_probs.size
226
+ end
227
+
228
+ # Predict rank probability for each team
229
+ #
230
+ # @param teams [Array<Array<Rating>>] list of teams
231
+ # @return [Array<Array(Integer, Float)>] rank and probability for each team
232
+ def predict_rank_probability(teams)
233
+ validate_teams!(teams)
234
+
235
+ n = teams.size
236
+ team_ratings = calculate_team_ratings(teams)
237
+
238
+ # Calculate win probability for each team against all others
239
+ win_probs = team_ratings.map do |team_i|
240
+ prob = 0.0
241
+ team_ratings.each do |team_j|
242
+ next if team_i == team_j
243
+
244
+ prob += phi_major(
245
+ (team_i.mu - team_j.mu) /
246
+ Math.sqrt(2 * @beta**2 + team_i.sigma_squared + team_j.sigma_squared)
247
+ )
248
+ end
249
+ prob / (n - 1)
250
+ end
251
+
252
+ # Normalize probabilities
253
+ total = win_probs.sum
254
+ normalized_probs = win_probs.map { |p| p / total }
255
+
256
+ # Sort by probability (descending) and assign ranks
257
+ sorted_indices = normalized_probs.each_with_index.sort_by { |prob, _| -prob }
258
+ ranks = Array.new(n)
259
+
260
+ current_rank = 1
261
+ sorted_indices.each_with_index do |(prob, team_idx), i|
262
+ current_rank = i + 1 if i > 0 && prob < sorted_indices[i - 1][0]
263
+ ranks[team_idx] = current_rank
264
+ end
265
+
266
+ ranks.zip(normalized_probs)
267
+ end
268
+
269
+ private
270
+
271
+ # Helper for log(1 + x)
272
+ def log1p(value)
273
+ Math.log(1 + value)
274
+ end
275
+
276
+ # Rating class for individual players
277
+ class Rating
278
+ attr_accessor :mu, :sigma, :name
279
+ attr_reader :id
280
+
281
+ def initialize(mu:, sigma:, name: nil)
282
+ @id = SecureRandom.hex
283
+ @mu = mu.to_f
284
+ @sigma = sigma.to_f
285
+ @name = name
286
+ end
287
+
288
+ # Calculate display rating (conservative estimate)
289
+ #
290
+ # @param z [Float] number of standard deviations
291
+ # @param alpha [Float] scaling factor
292
+ # @param target [Float] target adjustment
293
+ # @return [Float] the ordinal rating
294
+ def ordinal(z: 3.0, alpha: 1.0, target: 0.0)
295
+ alpha * ((@mu - z * @sigma) + (target / alpha))
296
+ end
297
+
298
+ def <=>(other)
299
+ return nil unless other.is_a?(Rating)
300
+
301
+ ordinal <=> other.ordinal
302
+ end
303
+
304
+ def <(other)
305
+ raise ArgumentError, 'comparison with non-Rating' unless other.is_a?(Rating)
306
+
307
+ ordinal < other.ordinal
308
+ end
309
+
310
+ def >(other)
311
+ raise ArgumentError, 'comparison with non-Rating' unless other.is_a?(Rating)
312
+
313
+ ordinal > other.ordinal
314
+ end
315
+
316
+ def <=(other)
317
+ raise ArgumentError, 'comparison with non-Rating' unless other.is_a?(Rating)
318
+
319
+ ordinal <= other.ordinal
320
+ end
321
+
322
+ def >=(other)
323
+ raise ArgumentError, 'comparison with non-Rating' unless other.is_a?(Rating)
324
+
325
+ ordinal >= other.ordinal
326
+ end
327
+
328
+ def ==(other)
329
+ return false unless other.is_a?(Rating)
330
+
331
+ @mu == other.mu && @sigma == other.sigma
332
+ end
333
+
334
+ def hash
335
+ [@id, @mu, @sigma].hash
336
+ end
337
+
338
+ def eql?(other)
339
+ self == other
340
+ end
341
+
342
+ def to_s
343
+ "Rating(mu=#{@mu}, sigma=#{@sigma}#{", name=#{@name}" if @name})"
344
+ end
345
+
346
+ def inspect
347
+ to_s
348
+ end
349
+ end
350
+
351
+ # Internal class for team ratings
352
+ class TeamRating
353
+ attr_reader :mu, :sigma_squared, :team, :rank
354
+
355
+ def initialize(mu:, sigma_squared:, team:, rank:)
356
+ @mu = mu.to_f
357
+ @sigma_squared = sigma_squared.to_f
358
+ @team = team
359
+ @rank = rank.to_i
360
+ end
361
+
362
+ def ==(other)
363
+ return false unless other.is_a?(TeamRating)
364
+
365
+ @mu == other.mu &&
366
+ @sigma_squared == other.sigma_squared &&
367
+ @team == other.team &&
368
+ @rank == other.rank
369
+ end
370
+
371
+ def hash
372
+ [@mu, @sigma_squared, @team, @rank].hash
373
+ end
374
+
375
+ def to_s
376
+ "TeamRating(mu=#{@mu}, sigma_squared=#{@sigma_squared}, rank=#{@rank})"
377
+ end
378
+ end
379
+
380
+ # Validate teams structure
381
+ def validate_teams!(teams)
382
+ raise ArgumentError, 'Teams must be an Array' unless teams.is_a?(Array)
383
+ raise ArgumentError, 'Must have at least 2 teams' if teams.size < 2
384
+
385
+ teams.each_with_index do |team, idx|
386
+ raise ArgumentError, "Team #{idx} must be an Array" unless team.is_a?(Array)
387
+ raise ArgumentError, "Team #{idx} must have at least 1 player" if team.empty?
388
+
389
+ team.each do |player|
390
+ raise ArgumentError, "All players must be Rating objects, got #{player.class}" unless player.is_a?(Rating)
391
+ end
392
+ end
393
+ end
394
+
395
+ # Validate ranks
396
+ def validate_ranks!(teams, ranks)
397
+ raise ArgumentError, "Ranks must be an Array, got #{ranks.class}" unless ranks.is_a?(Array)
398
+ raise ArgumentError, 'Ranks must have same length as teams' if ranks.size != teams.size
399
+
400
+ ranks.each do |rank|
401
+ raise ArgumentError, "All ranks must be numeric, got #{rank.class}" unless rank.is_a?(Numeric)
402
+ end
403
+ end
404
+
405
+ # Validate scores
406
+ def validate_scores!(teams, scores)
407
+ raise ArgumentError, "Scores must be an Array, got #{scores.class}" unless scores.is_a?(Array)
408
+ raise ArgumentError, 'Scores must have same length as teams' if scores.size != teams.size
409
+
410
+ scores.each do |score|
411
+ raise ArgumentError, "All scores must be numeric, got #{score.class}" unless score.is_a?(Numeric)
412
+ end
413
+ end
414
+
415
+ # Validate weights
416
+ def validate_weights!(teams, weights)
417
+ raise ArgumentError, "Weights must be an Array, got #{weights.class}" unless weights.is_a?(Array)
418
+ raise ArgumentError, 'Weights must have same length as teams' if weights.size != teams.size
419
+
420
+ weights.each_with_index do |team_weights, idx|
421
+ raise ArgumentError, "Weights for team #{idx} must be an Array" unless team_weights.is_a?(Array)
422
+ unless team_weights.size == teams[idx].size
423
+ raise ArgumentError, "Weights for team #{idx} must match team size"
424
+ end
425
+
426
+ team_weights.each do |weight|
427
+ raise ArgumentError, "All weights must be numeric, got #{weight.class}" unless weight.is_a?(Numeric)
428
+ end
429
+ end
430
+ end
431
+
432
+ # Deep copy teams to avoid mutation
433
+ def deep_copy_teams(teams)
434
+ teams.map do |team|
435
+ team.map do |player|
436
+ Rating.new(mu: player.mu, sigma: player.sigma, name: player.name).tap do |new_player|
437
+ new_player.instance_variable_set(:@id, player.id)
438
+ end
439
+ end
440
+ end
441
+ end
442
+
443
+ # Calculate team ratings from individual player ratings
444
+ def calculate_team_ratings(game, ranks: nil, weights: nil)
445
+ ranks ||= calculate_rankings(game)
446
+
447
+ game.each_with_index.map do |team, idx|
448
+ sorted_team = team.sort_by { |p| -p.ordinal }
449
+ max_ordinal = sorted_team.first.ordinal
450
+
451
+ mu_sum = 0.0
452
+ sigma_squared_sum = 0.0
453
+
454
+ sorted_team.each do |player|
455
+ balance_weight = if @balance
456
+ ordinal_diff = max_ordinal - player.ordinal
457
+ 1 + (ordinal_diff / (max_ordinal + @kappa))
458
+ else
459
+ 1.0
460
+ end
461
+
462
+ mu_sum += player.mu * balance_weight
463
+ sigma_squared_sum += (player.sigma * balance_weight)**2
464
+ end
465
+
466
+ TeamRating.new(
467
+ mu: mu_sum,
468
+ sigma_squared: sigma_squared_sum,
469
+ team: team,
470
+ rank: ranks[idx].to_i
471
+ )
472
+ end
473
+ end
474
+
475
+ # Calculate rankings from scores or indices
476
+ def calculate_rankings(game, ranks = nil)
477
+ return [] if game.empty?
478
+
479
+ team_scores = if ranks
480
+ ranks.each_with_index.map { |rank, idx| rank || idx }
481
+ else
482
+ game.each_index.to_a
483
+ end
484
+
485
+ sorted_scores = team_scores.sort
486
+ rank_map = {}
487
+ sorted_scores.each_with_index do |value, index|
488
+ rank_map[value] ||= index
489
+ end
490
+
491
+ team_scores.map { |score| rank_map[score].to_f }
492
+ end
493
+
494
+ # Core rating computation algorithm using Bradley-Terry Partial pairing
495
+ def compute_ratings(teams, ranks: nil, scores: nil, weights: nil)
496
+ team_ratings = calculate_team_ratings(teams, ranks: ranks)
497
+ num_teams = team_ratings.size
498
+
499
+ # Build score mapping for margin calculations
500
+ score_mapping = {}
501
+ if scores && scores.size == team_ratings.size
502
+ team_ratings.each_with_index do |_, i|
503
+ score_mapping[i] = scores[i]
504
+ end
505
+ end
506
+
507
+ team_ratings.each_with_index.map do |team_i, i|
508
+ omega_sum = 0.0
509
+ delta_sum = 0.0
510
+ comparisons = 0
511
+
512
+ # Bradley-Terry Part: Use sliding window for partial pairing
513
+ start_idx = [0, i - @window_size].max
514
+ end_idx = [num_teams, i + @window_size + 1].min
515
+
516
+ (start_idx...end_idx).each do |q|
517
+ next if q == i
518
+
519
+ team_q = team_ratings[q]
520
+
521
+ # Calculate c_iq
522
+ c_iq = Math.sqrt(team_i.sigma_squared + team_q.sigma_squared + 2 * @beta**2)
523
+
524
+ # Calculate margin factor
525
+ margin_factor = 1.0
526
+ if scores && score_mapping.key?(i) && score_mapping.key?(q)
527
+ score_diff = (score_mapping[i] - score_mapping[q]).abs
528
+ margin_factor = log1p(score_diff / @margin) if score_diff > @margin && @margin > 0.0
529
+ end
530
+
531
+ # Calculate mu difference with margin
532
+ mu_diff = (team_i.mu - team_q.mu) * margin_factor
533
+
534
+ # Calculate piq using logistic function
535
+ piq = 1.0 / (1.0 + Math.exp(-mu_diff / c_iq))
536
+
537
+ # Calculate s based on rank comparison
538
+ s = if team_q.rank > team_i.rank
539
+ 1.0 # team_i won
540
+ elsif team_q.rank == team_i.rank
541
+ 0.5 # draw
542
+ else
543
+ 0.0 # team_i lost
544
+ end
545
+
546
+ # Accumulate omega and delta
547
+ omega_sum += (team_i.sigma_squared / c_iq) * (s - piq)
548
+
549
+ # Apply gamma
550
+ team_weights = weights ? weights[i] : nil
551
+ gamma_value = @gamma.call(
552
+ c_iq,
553
+ team_ratings.size,
554
+ team_i.mu,
555
+ team_i.sigma_squared,
556
+ team_i.team,
557
+ team_i.rank,
558
+ team_weights
559
+ )
560
+
561
+ delta_sum += (gamma_value * team_i.sigma_squared / (c_iq**2)) * piq * (1.0 - piq)
562
+ comparisons += 1
563
+ end
564
+
565
+ # Average over comparisons
566
+ omega = comparisons > 0 ? omega_sum / comparisons : 0.0
567
+ delta = comparisons > 0 ? delta_sum / comparisons : 0.0
568
+
569
+ # Update each player in the team
570
+ team_i.team.each_with_index.map do |player, j|
571
+ weight = weights ? weights[i][j] : 1.0
572
+
573
+ new_mu = player.mu
574
+ new_sigma = player.sigma
575
+
576
+ if omega >= 0
577
+ new_mu += (new_sigma**2 / team_i.sigma_squared) * omega * weight
578
+ new_sigma *= Math.sqrt(
579
+ [1 - (new_sigma**2 / team_i.sigma_squared) * delta * weight, @kappa].max
580
+ )
581
+ else
582
+ new_mu += (new_sigma**2 / team_i.sigma_squared) * omega / weight
583
+ new_sigma *= Math.sqrt(
584
+ [1 - (new_sigma**2 / team_i.sigma_squared) * delta / weight, @kappa].max
585
+ )
586
+ end
587
+
588
+ player.mu = new_mu
589
+ player.sigma = new_sigma
590
+ player
591
+ end
592
+ end
593
+ end
594
+
595
+ # Normal distribution CDF
596
+ def phi_major(value)
597
+ Statistics::Normal.cdf(value)
598
+ end
599
+
600
+ # Normal distribution inverse CDF
601
+ def phi_major_inverse(value)
602
+ Statistics::Normal.inv_cdf(value)
603
+ end
604
+ end
605
+ end
606
+ end
@@ -57,6 +57,75 @@ module OpenSkill
57
57
 
58
58
  [sorted_objects, restoration_indices]
59
59
  end
60
+
61
+ # The V function as defined in Weng-Lin 2011
62
+ # Computes phi_minor(x-t) / phi_major(x-t)
63
+ #
64
+ # @param x [Float] input value
65
+ # @param t [Float] threshold value
66
+ # @return [Float] the V function result
67
+ def self.v(x, t)
68
+ xt = x - t
69
+ denominator = Statistics::Normal.cdf(xt)
70
+
71
+ return -xt if denominator < Float::EPSILON
72
+
73
+ Statistics::Normal.pdf(xt) / denominator
74
+ end
75
+
76
+ # The W function as defined in Weng-Lin 2011
77
+ # Computes V(x,t) * (V(x,t) + (x-t))
78
+ #
79
+ # @param x [Float] input value
80
+ # @param t [Float] threshold value
81
+ # @return [Float] the W function result
82
+ def self.w(x, t)
83
+ xt = x - t
84
+ denominator = Statistics::Normal.cdf(xt)
85
+
86
+ if denominator < Float::EPSILON
87
+ return x < 0 ? 1.0 : 0.0
88
+ end
89
+
90
+ v_val = v(x, t)
91
+ v_val * (v_val + xt)
92
+ end
93
+
94
+ # The V-tilde function for draws as defined in Weng-Lin 2011
95
+ # Handles doubly truncated Gaussians
96
+ #
97
+ # @param x [Float] input value
98
+ # @param t [Float] threshold value
99
+ # @return [Float] the V-tilde function result
100
+ def self.vt(x, t)
101
+ xx = x.abs
102
+ b = Statistics::Normal.cdf(t - xx) - Statistics::Normal.cdf(-t - xx)
103
+
104
+ if b < 1e-5
105
+ return x < 0 ? (-x - t) : (-x + t)
106
+ end
107
+
108
+ a = Statistics::Normal.pdf(-t - xx) - Statistics::Normal.pdf(t - xx)
109
+ (x < 0 ? -a : a) / b
110
+ end
111
+
112
+ # The W-tilde function for draws as defined in Weng-Lin 2011
113
+ # Handles variance for doubly truncated Gaussians
114
+ #
115
+ # @param x [Float] input value
116
+ # @param t [Float] threshold value
117
+ # @return [Float] the W-tilde function result
118
+ def self.wt(x, t)
119
+ xx = x.abs
120
+ b = Statistics::Normal.cdf(t - xx) - Statistics::Normal.cdf(-t - xx)
121
+
122
+ return 1.0 if b < Float::EPSILON
123
+
124
+ numerator = ((t - xx) * Statistics::Normal.pdf(t - xx) +
125
+ (t + xx) * Statistics::Normal.pdf(-t - xx))
126
+ vt_val = vt(x, t)
127
+ numerator / b + vt_val * vt_val
128
+ end
60
129
  end
61
130
  end
62
131
  end