TrueskillThroughTime 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,1158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "TrueskillThroughTime/version"
4
+
5
+ module TrueskillThroughTime
6
+ class Error < StandardError; end
7
+ # Notes:
8
+ # Ported from Python trueskillthroughtime, v1.1.0
9
+ # Tuples are replaced with arrays.
10
+ # Functions that receive tuples are looking for quacks on [0] and [1].
11
+ # They will not fail if [>1] exists. But behaviour is undefined.
12
+ # Methods named p() are replaced with prob(). p() is a kernel function in Ruby.
13
+ # __rmul__ methods are replaced with coerce
14
+ # truediv methods are replaced with / (fdiv vs div in ruby etc)
15
+ require 'json'
16
+ require 'date'
17
+ # Configuration Constants
18
+ BETA = 1.0
19
+ MU = 0.0
20
+ SIGMA = BETA * 6
21
+ GAMMA = BETA * 0.03
22
+ P_DRAW = 0.0
23
+ EPSILON = 1e-6
24
+ ITERATIONS = 30
25
+
26
+ # Derived Math Constants
27
+ SQRT2 = Math.sqrt(2)
28
+ SQRT2PI = Math.sqrt(2 * Math::PI)
29
+ INF = Float::INFINITY
30
+
31
+ def erfc(x)
32
+ # """(http://bit.ly/zOLqbc)"""
33
+ z = x.abs
34
+ t = 1.0 / (1.0 + z / 2.0)
35
+ a = -0.82215223 + t * 0.17087277; b = 1.48851587 + t * a
36
+ c = -1.13520398 + t * b; d = 0.27886807 + t * c; e = -0.18628806 + t * d
37
+ f = 0.09678418 + t * e; g = 0.37409196 + t * f; h = 1.00002368 + t * g
38
+ r = t * Math.exp(-z * z - 1.26551223 + t * h)
39
+ if x.positive?
40
+ r
41
+ else
42
+ 2.0 - r
43
+ end
44
+ end
45
+
46
+ def erfcinv(y)
47
+ raise(ArgumentError, "Argument must be non-negative numbers") if y.negative?
48
+ return -INF if y > 2
49
+ return INF if y.zero?
50
+
51
+ y = 2 - y if y >= 1
52
+
53
+ t = Math.sqrt(-2 * Math.log(y / 2.0))
54
+ x = -0.70711 * ((2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t)
55
+
56
+ 3.times do
57
+ err = erfc(x) - y
58
+ x += err / (1.12837916709551257 * Math.exp(-(x**2)) - x * err)
59
+ end
60
+
61
+ y < 1 ? x : -x
62
+ end
63
+
64
+ def tau_pi(mu, sigma)
65
+ raise(ArgumentError, "Sigma must be greater than 0.") if (sigma + 1e-5) < 0.0
66
+
67
+ if sigma > 0.0
68
+ pi_ = sigma ** -2
69
+ tau_ = pi_ * mu
70
+ else
71
+ pi_ = INF
72
+ tau_ = INF
73
+ end
74
+ [tau_, pi_] # python returns tuple
75
+ end
76
+
77
+ def mu_sigma(tau_, pi_)
78
+ raise(ArgumentError, "Sigma must be greater than 0.") if (pi_ + 1e-5) < 0.0
79
+
80
+ if pi_ > 0.0
81
+ sigma = Math.sqrt(1.0/pi_)
82
+ mu = tau_ / pi_.to_f
83
+ else
84
+ sigma = INF
85
+ mu = 0.0
86
+ end
87
+ [mu, sigma] # again tuple in original
88
+ end
89
+
90
+ def cdf(x, mu=0, sigma=1)
91
+ 0.5 * erfc(-(x - mu) / (sigma * SQRT2))
92
+ end
93
+
94
+ def pdf(x, mu, sigma)
95
+ normaliser = (SQRT2PI * sigma)**-1
96
+ functional = Math.exp(-((x - mu)**2) / (2*sigma**2))
97
+ normaliser * functional
98
+ end
99
+
100
+ def ppf(p, mu, sigma)
101
+ mu - sigma * SQRT2 * erfcinv(2 * p)
102
+ end
103
+
104
+ def v_w(mu, sigma, margin, tie)
105
+ if tie
106
+ _alpha = (-margin-mu)/sigma
107
+ _beta = ( margin-mu)/sigma
108
+ v = (pdf(_alpha, 0, 1)-pdf(_beta, 0, 1))/(cdf(_beta, 0, 1)-cdf(_alpha, 0, 1))
109
+ u = (_alpha*pdf(_alpha, 0, 1)-_beta*pdf(_beta, 0, 1))/(cdf(_beta, 0, 1)-cdf(_alpha, 0, 1))
110
+ w = - ( u - v**2 )
111
+ else
112
+ _alpha = (margin-mu)/sigma
113
+ v = pdf(-_alpha, 0, 1) / cdf(-_alpha, 0, 1)
114
+ w = v * (v + (-_alpha))
115
+ end
116
+ [v, w] # again, a tuple
117
+ end
118
+
119
+ def trunc(mu, sigma, margin, tie)
120
+ v, w = v_w(mu, sigma, margin, tie)
121
+ mu_trunc = mu + sigma * v
122
+ sigma_trunc = sigma * Math.sqrt(1-w)
123
+ [mu_trunc, sigma_trunc]
124
+ end
125
+
126
+ def approx(n, margin, tie) # TODO: It seems like this should be a Guassian function
127
+ mu, sigma = trunc(n.mu, n.sigma, margin, tie)
128
+ # if $should_debug
129
+ # puts "approx(#{[n, margin, tie]}) (n, margin, tie) = mu #{mu} sigma #{sigma}"
130
+ # end
131
+ Gaussian.new(mu, sigma)
132
+ end
133
+
134
+ def compute_margin(p_draw, sd)
135
+ (ppf(0.5-p_draw/2.0, 0.0, sd)).abs
136
+ end
137
+
138
+ def max_tuple(t1, t2)
139
+ # Let's emulate python nan sorting behaviour!
140
+ responses = [0.0, 0.0]
141
+ responses[0] = if t1[0].to_f.nan? || t2[0].to_f.nan?
142
+ t1[0]
143
+ else
144
+ [t1[0], t2[0]].max
145
+ end
146
+ responses[1] = if t1[1].to_f.nan? || t2[1].to_f.nan?
147
+ t1[1]
148
+ else
149
+ [t1[1], t2[1]].max
150
+ end
151
+ responses
152
+ end
153
+
154
+ def gr_tuple(tup, threshold)
155
+ tup.max > threshold
156
+ end
157
+
158
+ def podium(xs)
159
+ sortperm(xs)
160
+ end
161
+
162
+ def sortperm(xs, reverse=false)
163
+ sorted_indices = xs.each_with_index
164
+ .sort_by { |v, i| v }
165
+ .map { |v, i| i }
166
+ sorted_indices.reverse! if reverse
167
+ sorted_indices
168
+ end
169
+
170
+ def dict_diff(old, new)
171
+ step = [0.0, 0.0]
172
+ old.each_key do |a|
173
+ step = max_tuple(step, old[a].delta(new[a])) # .delta is Guassian method....
174
+ end
175
+ step
176
+ end
177
+
178
+ class Gaussian
179
+ extend Enumerable
180
+ attr_accessor :mu, :sigma
181
+
182
+ def initialize(mu=MU, sigma=SIGMA)
183
+ raise(ArgumentError, "Sigma should be >= 0") unless sigma >= 0.0
184
+
185
+ @mu = mu.to_f; @sigma = sigma.to_f
186
+ @members = [@mu, @sigma]
187
+ end
188
+
189
+ def tau
190
+ @sigma.positive? ? @mu * (@sigma**-2) : INF
191
+ end
192
+
193
+ def pi
194
+ @sigma.positive? ? @sigma**-2 : INF
195
+ end
196
+
197
+ def each(&block)
198
+ @members.each{|member| block.call(member)}
199
+ end
200
+
201
+ def to_s
202
+ "N(mu= #{(@mu)}, sigma= #{(@sigma)})"
203
+ end
204
+
205
+ def inspect
206
+ to_s
207
+ end
208
+
209
+ def +(other)
210
+ unless other.is_a?(Gaussian)
211
+ raise(ArgumentError, "Can only add gaussians to other gaussians - tried #{other.class}")
212
+ end
213
+
214
+ Gaussian.new(@mu + other.mu, Math.sqrt(@sigma**2 + other.sigma**2) )
215
+ end
216
+
217
+ def -(other)
218
+ unless other.is_a?(Gaussian)
219
+ raise(ArgumentError, "Can only subtract gaussians to other gaussians - tried #{other.class}")
220
+ end
221
+
222
+ Gaussian.new(@mu - other.mu, Math.sqrt(@sigma**2 + other.sigma**2) )
223
+ end
224
+
225
+ def *(other)
226
+ if other.is_a?(Float)
227
+ return NINF if other == INF
228
+
229
+ # this is a gaussian instance, not -INF
230
+
231
+ return Gaussian.new(other*@mu, other.abs*@sigma)
232
+
233
+ elsif other.is_a?(Gaussian)
234
+ if @sigma.zero? || other.sigma.zero?
235
+ mu = other.mu / ((other.sigma**2/@sigma**2)+1)
236
+ mu = @mu / ((@sigma**2 / other.sigma**2)+1) if @sigma.zero?
237
+ sigma = 0.0
238
+ else
239
+ _tau = tau + other.tau
240
+ _pi = pi + other.pi
241
+ mu, sigma = mu_sigma(_tau, _pi)
242
+ end
243
+ return Gaussian.new(mu, sigma)
244
+ else
245
+ raise(ArgumentError, "Gaussian multiplication supports only floats or gaussian - tried #{other.class}")
246
+ end
247
+ end
248
+
249
+ # Unused. Use / instead.
250
+ def truediv(other)
251
+ # Python equivalent of float division...
252
+ # So for us just normal division:
253
+ end
254
+
255
+ def /(other)
256
+ unless other.is_a?(Gaussian)
257
+ raise(ArgumentError, "Gaussian division supports only gaussian - tried #{other.class}")
258
+ end
259
+
260
+ _tau = tau - other.tau
261
+ _pi = pi - other.pi
262
+ _mu, _sigma = mu_sigma(_tau, _pi)
263
+ Gaussian.new(_mu, _sigma)
264
+ end
265
+
266
+ def rmul(other)
267
+ # pythonism for other * this where mul is this * other
268
+ # Ruby equivalent is to coerce. So:
269
+ end
270
+
271
+ def coerce(other)
272
+ [self, other]
273
+ end
274
+
275
+ def forget(gamma, t)
276
+ Gaussian.new(@mu, Math.sqrt(@sigma**2 + t*gamma**2))
277
+ end
278
+
279
+ def delta(other)
280
+ [(@mu - other.mu).abs, (@sigma - other.sigma).abs]
281
+ end
282
+
283
+ def exclude(other)
284
+ Gaussian.new(@mu - other.mu, Math.sqrt(@sigma**2 - other.sigma**2))
285
+ end
286
+
287
+ def isapprox?(other, tol=1e-4)
288
+ ((@mu - other.mu).abs < tol) && ((@sigma - other.sigma).abs < tol)
289
+ end
290
+ end
291
+
292
+ N01 = Gaussian.new(0.0, 1.0)
293
+ N00 = Gaussian.new(0.0, 0.0)
294
+ NINF = Gaussian.new(0.0, INF)
295
+ Nms = Gaussian.new(MU, SIGMA)
296
+
297
+ class Player
298
+ attr_accessor :prior, :beta, :gamma, :prior_draw
299
+ def initialize(prior=Gaussian.new(MU, SIGMA), beta=BETA, gamma=GAMMA, prior_draw=NINF)
300
+ @prior = prior
301
+ @beta = beta
302
+ @gamma = gamma
303
+ @prior_draw = prior_draw
304
+ end
305
+
306
+ def performance
307
+ Gaussian.new(@prior.mu, Math.sqrt(@prior.sigma**2 + @beta**2))
308
+ end
309
+
310
+ def to_s
311
+ "Player(Gaussian(mu=#{(@prior.mu)}, sigma=#{(@prior.sigma)}), beta=#{(@beta)}, gamma=#{(@gamma)})"
312
+ end
313
+
314
+ def inspect
315
+ to_s
316
+ end
317
+ end
318
+
319
+ class TeamVariable
320
+ attr_accessor :prior, :likelihood_lose, :likelihood_win, :likelihood_draw
321
+ def initialize(prior=NINF, likelihood_lose=NINF, likelihood_win=NINF, likelihood_draw=NINF)
322
+ @prior = prior
323
+ @likelihood_lose = likelihood_lose
324
+ @likelihood_win = likelihood_win
325
+ @likelihood_draw = likelihood_draw
326
+ end
327
+
328
+ # Renamed from p to prob. p is kernel print...
329
+ def prob
330
+ @prior*@likelihood_lose*@likelihood_win*@likelihood_draw
331
+ end
332
+
333
+ def posterior_win
334
+ @prior*@likelihood_lose*@likelihood_draw
335
+ end
336
+
337
+ def posterior_lose
338
+ @prior*@likelihood_win*@likelihood_draw
339
+ end
340
+
341
+ def likelihood
342
+ @likelihood_win*@likelihood_lose*@likelihood_draw
343
+ end
344
+
345
+ def self.performance(team, weights) # I think this is used with weights as a single value
346
+ weights = Array.new(team.length, weights) unless ((weights.is_a?(Array)) && (weights.length == team.length))
347
+ res = N00
348
+ team.zip(weights).each do |player, w|
349
+ res += player.performance * w
350
+ end
351
+ res
352
+ end
353
+
354
+ def to_str
355
+ to_s
356
+ end
357
+
358
+ def to_s
359
+ inspect
360
+ end
361
+
362
+ def inspect
363
+ "TTT::TeamVariable:#{self.object_id}:\t" + { prior: @prior, likelihood_lose: @likelihood_lose, likelihood_win: @likelihood_win, likelihood_draw: @likelihood_draw }.to_s
364
+ end
365
+ end
366
+
367
+ class DrawMessages
368
+ attr_accessor :prior, :prior_team, :likelihood_lose, :likelihood_win
369
+ def initialize(prior=NINF, prior_team=NINF, likelihood_lose=NINF, likelihood_win=NINF)
370
+ @prior = prior
371
+ @prior_team = prior_team
372
+ @likelihood_lose = likelihood_lose
373
+ @likelihood_win = likelihood_win
374
+ end
375
+
376
+ # Renamed from p to prob. p() being a kernel method
377
+ def prob
378
+ @prior_team*@likelihood_lose*@likelihood_win
379
+ end
380
+
381
+ def posterior_win
382
+ @prior_team*@likelihood_lose
383
+ end
384
+
385
+ def posterior_lose
386
+ @prior_team*@likelihood_win
387
+ end
388
+
389
+ def likelihood
390
+ @likelihood_win*@likelihood_lose
391
+ end
392
+ end
393
+
394
+ class DiffMessages
395
+ attr_accessor :prior, :likelihood
396
+ def initialize(prior=NINF, likelihood=NINF)
397
+ @prior = prior
398
+ @likelihood = likelihood
399
+ end
400
+
401
+ def prob
402
+ @prior*@likelihood
403
+ end
404
+
405
+ def to_str
406
+ to_s
407
+ end
408
+
409
+ def to_s
410
+ inspect
411
+ end
412
+
413
+ def inspect
414
+ "TTT::DiffMessages:#{self.object_id}: \t" + {prior: @prior, likelihood: @likelihood}.to_s
415
+ end
416
+ end
417
+
418
+ class Game
419
+ attr_accessor :likelihoods, :teams, :evidence
420
+ def initialize(teams, result=[], p_draw=0.0, weights=[])
421
+ if (p_draw.negative? || p_draw > 1.0)
422
+ raise(ArgumentError, "Draw probability #{p_draw} out of acceptable range: 1.0 > p_draw > 0.0.")
423
+ end
424
+
425
+ unless result.empty?
426
+ if (result.length != teams.length)
427
+ raise(ArgumentError, "Results provided but results for all teams not provided.")
428
+ end
429
+ if ((p_draw.zero?) && (result.length != result.uniq.length))
430
+ raise(ArgumentError, "Draws provided in results but p_draw is 0.0.")
431
+ end
432
+ end
433
+ unless weights.empty?
434
+ if (weights.length != teams.length)
435
+ raise(ArgumentError, "Weights provided but weights for all teams not provided.")
436
+ end
437
+
438
+ teams_without_weights = []
439
+ teams.each_with_index do |team, idx|
440
+ teams_without_weights << team if team.length != weights[idx].length
441
+ end
442
+ unless teams_without_weights.empty?
443
+ raise(ArgumentError, "No weight provided for teams #{teams_without_weights}")
444
+ end
445
+ end
446
+
447
+ @teams = teams
448
+ @result = result # I hate that this isn't pluralised, but either is fine.
449
+ @p_draw = p_draw
450
+ weights = @teams.map {|team| Array.new(team.length, 1.0) } if weights.empty?
451
+ @weights = weights
452
+ @likelihoods = []
453
+ @evidence = 0.0
454
+ compute_likelihoods
455
+ end
456
+
457
+ # The number of teams in the game
458
+ def length
459
+ @teams.length
460
+ end
461
+
462
+ # The number of players in the Game
463
+ def size
464
+ i = 0 # should probably use inject and oneline it
465
+ @teams.each do |team|
466
+ i += team.length
467
+ end
468
+ i
469
+ end
470
+
471
+ def performance(i)
472
+ # I'm not 100% how this is really meant to be used.
473
+ # The semantics of Python zip() in python make it a little ambiguous.
474
+ TeamVariable.performance(@teams[i], @weights[i])
475
+ end
476
+
477
+ def partial_evidence(d, margin, tie, e)
478
+ mu = d[e].prior.mu
479
+ sigma = d[e].prior.sigma
480
+ @evidence *= if tie[e]
481
+ (cdf(margin[e], mu, sigma) - cdf(-margin[e], mu, sigma))
482
+ else
483
+ (1 - cdf(margin[e], mu, sigma))
484
+ end
485
+ end
486
+
487
+ def graphical_model
488
+ if @result.empty?
489
+ r = (0...@teams.length).to_a.reverse if @result.empty?
490
+ else
491
+ r = @result
492
+ end
493
+
494
+ o = sortperm(r, true)
495
+ t = []
496
+ d = []
497
+ tie = []
498
+ margin = []
499
+ @teams.length.times do |i|
500
+ t << TeamVariable.new(performance(o[i]), NINF, NINF, NINF)
501
+ end
502
+
503
+ (@teams.length-1).times do |i|
504
+ d << DiffMessages.new(t[i].prior - t[i+1].prior, NINF)
505
+ tie << (r[o[i]] == r[o[i+1]])
506
+ if @p_draw.zero?
507
+ margin << 0.0
508
+ else
509
+ sum = 0.0
510
+ @teams[o[i]].each do |player|
511
+ sum += player.beta**2
512
+ end
513
+ @teams[o[i+1]].each do |player|
514
+ sum += player.beta**2
515
+ end
516
+ margin << compute_margin(@p_draw, Math.sqrt(sum))
517
+ end
518
+ end
519
+ @evidence = 1.0
520
+ [o, t, d, tie, margin]
521
+ end
522
+
523
+ def analytical_likelihood # "likelihood_analitico"
524
+ o, t, d, tie, margin = graphical_model
525
+ partial_evidence(d, margin, tie, 0)
526
+ d = d[0].prior
527
+ mu_trunc, sigma_trunc = trunc(d.mu, d.sigma, margin[0], tie[0])
528
+ if d.sigma == sigma_trunc
529
+ delta_div = d.sigma**2*mu_trunc - sigma_trunc**2*d.mu
530
+ theta_div_pow2 = INF
531
+ else
532
+ delta_div = (d.sigma**2*mu_trunc - sigma_trunc**2*d.mu)/(d.sigma**2-sigma_trunc**2)
533
+ delta_div_pow2 = (sigma_trunc**2*d.sigma**2)/(d.sigma**2 - sigma_trunc**2)
534
+ end
535
+ res = []
536
+ t.length.times do |i|
537
+ team = []
538
+ @teams[o[i]].length.times do |j|
539
+ mu == 0.0
540
+ mu = @teams[o[i]][j].prior.mu + ( delta_div - d.mu)*(-1)**(i==1) unless d.sigma == sigma_trunc
541
+ analytical_sigma = Math.sqrt(theta_div_pow2 + d.sigma**2 - @teams[o[i]][j].prior.sigma**2)
542
+ team << Gaussian.new(mu, analytical_sigma)
543
+ end
544
+ res << team
545
+ end
546
+
547
+ if o[0] < o[1]
548
+ [res[0], res[1]]
549
+ else
550
+ [res[1], res[0]]
551
+ end
552
+ end
553
+
554
+ def likelihood_teams
555
+ o, t, d, tie, margin = graphical_model
556
+
557
+ step = [INF, INF]
558
+ i = 0
559
+
560
+ while gr_tuple(step, 1e-6) && (i < 10)
561
+ step = [0.0, 0.0]
562
+ (0...d.length-1).each do |e|
563
+ d[e].prior = t[e].posterior_win - t[e+1].posterior_lose # prior giving wronnnggg
564
+ partial_evidence(d, margin, tie, e) if i.zero?
565
+ d[e].likelihood = approx(d[e].prior, margin[e], tie[e]) / d[e].prior
566
+ likelihood_lose = t[e].posterior_win - d[e].likelihood
567
+ step = max_tuple(step, t[e+1].likelihood_lose.delta(likelihood_lose))
568
+ t[e+1].likelihood_lose = likelihood_lose
569
+ end
570
+
571
+ (1...d.length).to_a.reverse.each do |e|
572
+ d[e].prior = t[e].posterior_win - t[e+1].posterior_lose
573
+ partial_evidence(d, margin, tie, e) if ((i.zero?) && (e == (d.length-1)))
574
+ d[e].likelihood = approx(d[e].prior, margin[e], tie[e]) / d[e].prior
575
+ likelihood_win = t[e+1].posterior_lose + d[e].likelihood
576
+ step = max_tuple(step, t[e].likelihood_win.delta(likelihood_win))
577
+ t[e].likelihood_win = likelihood_win
578
+ end
579
+ i += 1
580
+ end
581
+
582
+ if d.length == 1
583
+ partial_evidence(d, margin, tie, 0)
584
+ d[0].prior = t[0].posterior_win - t[1].posterior_lose
585
+ d[0].likelihood = approx(d[0].prior, margin[0], tie[0]) / d[0].prior
586
+ end
587
+ t[0].likelihood_win = t[1].posterior_lose + d[0].likelihood
588
+ t[-1].likelihood_lose = t[-2].posterior_win - d[-1].likelihood
589
+ (0...t.length).to_a.map { |e| t[o[e]].likelihood }
590
+ end
591
+
592
+ def compute_likelihoods
593
+ weighted = 0
594
+ @weights.each do |w|
595
+ weighted += 1 if w != 1.0
596
+ end
597
+ if @teams.length > 2 || weighted.positive?
598
+ m_t_ft = likelihood_teams
599
+ res = []
600
+ @teams.length.times do |e|
601
+ e_likelihoods = []
602
+ @teams[e].length.times do |i|
603
+ weight_factor = if @weights[e][i] != 0.0
604
+ 1 / @weights[e][i]
605
+ else
606
+ INF
607
+ end
608
+ difference = m_t_ft[e] - performance(e).exclude(@teams[e][i].prior * @weights[e][i])
609
+ e_likelihoods << (weight_factor * difference)
610
+ end
611
+ res << e_likelihoods
612
+ end
613
+ @likelihoods = res
614
+ else
615
+ @likelihoods = analytical_likelihood # untested
616
+ end
617
+ end
618
+
619
+ def posteriors
620
+ res = []
621
+ @teams.each_with_index do |team, i|
622
+ il = []
623
+ team.each_with_index do |player, j|
624
+ product = @likelihoods[i][j] * player.prior
625
+ il << product
626
+ end
627
+ res << il
628
+ end
629
+ res
630
+ end
631
+ end
632
+
633
+ class Skill
634
+ attr_accessor :forward, :backward, :likelihood, :elapsed
635
+ def initialize(forward=NINF, backward=NINF, likelihood=NINF, elapsed=0)
636
+ @forward = forward
637
+ @backward = backward
638
+ @likelihood = likelihood
639
+ @elapsed = elapsed
640
+ end
641
+
642
+ def to_s
643
+ inspect
644
+ end
645
+
646
+ def inspect
647
+ { 'forward'=>@forward, 'backward'=>@backward, 'likelihood'=>@likelihood, 'elapsed'=>@elapsed }.to_s
648
+ end
649
+
650
+ end
651
+
652
+ class Agent
653
+ attr_accessor :player, :message, :last_time
654
+ def initialize(player, message, last_time)
655
+ @player = player
656
+ @message = message
657
+ @last_time = last_time
658
+ end
659
+
660
+ def receive(elapsed)
661
+ if @message != NINF
662
+ @message.forget(@player.gamma, elapsed)
663
+ else
664
+ @player.prior
665
+ end
666
+ end
667
+ end
668
+
669
+ # Agents is a dict in python and their implementation is whack.
670
+ def clean!(agents, last_time=false)
671
+ agents.each_pair do |agent_label, agent|
672
+ agent.message = NINF
673
+ agent.last_time = -INF if last_time
674
+ end
675
+ end
676
+
677
+ class Item
678
+ attr_accessor :name, :likelihood
679
+ def initialize(name, likelihood)
680
+ @name = name
681
+ @likelihood = likelihood
682
+ end
683
+
684
+ def to_s
685
+ inspect
686
+ end
687
+
688
+ def inspect
689
+ { 'name' => @name, 'likelihood' => @likelihood }.to_s
690
+ end
691
+
692
+ end
693
+
694
+ class Team
695
+ attr_accessor :items, :output
696
+ def initialize(items, output)
697
+ @items = items
698
+ @output = output
699
+ end
700
+ end
701
+
702
+ class Event
703
+ attr_accessor :teams, :evidence, :weights
704
+ def initialize(teams, evidence, weights)
705
+ @teams = teams
706
+ @evidence = evidence
707
+ @weights = weights
708
+ end
709
+
710
+ def to_s
711
+ inspect
712
+ end
713
+
714
+ def inspect
715
+ # { evidence: @evidence, teams: @teams, weights: @weights, result: result }.to_s
716
+ "Event(#{names}, #{result})"
717
+ end
718
+
719
+ def names
720
+ @teams.map { |team| team.items.map {|i| i.name} }
721
+ end
722
+
723
+ def result
724
+ @teams.map { | team | team.output }
725
+ end
726
+ end
727
+
728
+ def get_composition(events)
729
+ events.map {|event| event.teams.map {|team| team.items.map {|item| item.name} } }
730
+ end
731
+
732
+ def get_results(events)
733
+ events.map {|event| event.teams.map {|team| team.output } }
734
+ end
735
+
736
+ def compute_elapsed(last_time, actual_time)
737
+ if last_time == INF
738
+ 1
739
+ elsif last_time == -INF
740
+ 0
741
+ else
742
+ actual_time - last_time
743
+ end
744
+ end
745
+
746
+ class Batch
747
+ attr_accessor :skills, :events, :time
748
+ def initialize(composition, results=[], time=0, agents={}, p_draw=0.0, weights=[])
749
+ if (!results.empty? && (results.length != composition.length))
750
+ raise(ArgumentError, "Results provided but composition.length != results.length")
751
+ end
752
+ if (!weights.empty? &&(weights.length != composition.length))
753
+ raise(ArgumentError, "Weights provided but composition.length != weights.length")
754
+ end
755
+
756
+ this_agents = composition.flat_map { |teams| teams.flat_map { |team| team } }.to_set
757
+ elapsed = this_agents.map { |a| [a, compute_elapsed(agents[a].last_time, time)] }.to_h
758
+ @skills = this_agents.map { |a| [a, Skill.new(agents[a].receive(elapsed[a]), NINF, NINF, elapsed[a])] }.to_h
759
+ @events = composition.each_with_index.map { |event_composition, e|
760
+ Event.new(
761
+ event_composition.each_with_index.map { |team_composition, t|
762
+ Team.new(
763
+ team_composition.each_with_index.map { |_, a|
764
+ Item.new(composition[e][t][a], NINF)
765
+ },
766
+ results.empty? ? composition[e].length - t - 1 : results[e][t]
767
+ )
768
+ },
769
+ 0.0,
770
+ weights.empty? ? weights : weights[e]
771
+ )
772
+ }
773
+ @time = time
774
+ @agents = agents
775
+ @p_draw = p_draw
776
+ iteration
777
+ end
778
+
779
+ def length
780
+ @events.length
781
+ end
782
+
783
+ def add_events(composition, results=[])
784
+ this_agents = composition.flat_map { |team| team.items }.to_set
785
+ this_agents.each_with_index do |agent, i|
786
+ elapsed = compute_elapsed(@agents[agent].last_time, @time)
787
+ if @skills.include?(agent)
788
+ @skills[agent] = Skill(@agents[agent].receive(elapsed), NINF, NINF, elapsed)
789
+ else
790
+ @skills[agent].elapsed = elapsed
791
+ @skills[agent].forward = @agents[agent].receive(elapsed)
792
+ end
793
+ end
794
+
795
+ _from = @events.length + 1
796
+
797
+ composition.length.times do |e|
798
+ @events << Event.new(
799
+ composition[e].each_with_index.map { |team_composition, t|
800
+ Team.new(
801
+ team_composition.each_with_index.map { |_, a|
802
+ Item.new(composition[e][t][a], NINF)
803
+ },
804
+ if results.empty?
805
+ composition[e].length - t - 1
806
+ else
807
+ results[e][t]
808
+ end
809
+ )
810
+ },
811
+ 0.0,
812
+ if weights.nil? || weights.empty?
813
+ weights
814
+ else
815
+ weights[e]
816
+ end
817
+ )
818
+ end
819
+ iteration(_from)
820
+ end
821
+
822
+ def posterior(agent)
823
+ @skills[agent].likelihood * @skills[agent].backward * @skills[agent].forward
824
+ end
825
+
826
+ def posteriors
827
+ res = {}
828
+ @skills.each_key do |skill|
829
+ res[skill] = posterior(skill) # I think this is fucked because posterior doesn't index this way
830
+ end
831
+ res
832
+ end
833
+
834
+ def within_prior(item)
835
+ r = @agents[item.name].player
836
+ r_p = (posterior(item.name) / item.likelihood)
837
+ Player.new(Gaussian.new(r_p.mu, r_p.sigma), r.beta, r.gamma)
838
+ end
839
+
840
+ def within_priors(event)
841
+ @events[event].teams.map { |team| team.items.map { |item| within_prior(item) } }
842
+ end
843
+
844
+ def iteration(from=0)
845
+ (from...@events.length).each do |e|
846
+ teams = within_priors(e)
847
+ result = @events[e].result
848
+ weights = @events[e].weights
849
+ g = Game.new(teams, result, @p_draw, weights)
850
+ @events[e].teams.each_with_index do |team, t|
851
+ team.items.each_with_index do |item, i|
852
+ @skills[item.name].likelihood = (@skills[item.name].likelihood / item.likelihood) * g.likelihoods[t][i]
853
+ item.likelihood = g.likelihoods[t][i]
854
+ end
855
+ end
856
+ @events[e].evidence = g.evidence
857
+ end
858
+ end
859
+
860
+ def convergence(epsilon=1e-6, iterations = 20)
861
+ step = [INF, INF]
862
+ i = 0
863
+ while gr_tuple(step, epsilon) && (i < iterations)
864
+ old = posteriors # python makes a copy
865
+ iteration
866
+ step = dict_diff(old, posteriors)
867
+ i += 1
868
+ end
869
+ i
870
+ end
871
+
872
+ def forward_prior_out(agent)
873
+ @skills[agent].forward * @skills[agent].likelihood
874
+ end
875
+
876
+ def backward_prior_out(agent)
877
+ n = @skills[agent].likelihood * @skills[agent].backward
878
+ n.forget(@agents[agent].player.gamma, @skills[agent].elapsed)
879
+ end
880
+
881
+ def new_backward_info
882
+ @skills.each_key do |a|
883
+ @skills[a].backward = @agents[a].message
884
+ end
885
+ iteration # python explicitly: return self.iteration() -> and iteration() returns None???
886
+ nil
887
+ end
888
+
889
+ def new_forward_info
890
+ @skills.each_key do |a|
891
+ @skills[a].forward = @agents[a].receive(@skills[a].elapsed)
892
+ end
893
+ iteration # python explicitly: return self.iteration() -> and iteration() returns None?
894
+ nil
895
+ end
896
+
897
+ def to_s
898
+ "Batch(time=#{@time}, events=#{@events})"
899
+ end
900
+
901
+ def inspect
902
+ to_s
903
+ end
904
+
905
+ end
906
+
907
+ class History
908
+ attr_accessor :batches
909
+
910
+ def initialize(composition, results:[], times:[], priors:{}, mu:MU, sigma:SIGMA, beta:BETA, gamma:GAMMA, p_draw:P_DRAW, weights:[])
911
+ if results.length.positive? && results.length != composition.length
912
+ raise(ArgumentError, "Results provided but composition.length (#{composition.length}) != results.length} (#{results.length})")
913
+ end
914
+ if times.length.positive? && times.length != composition.length
915
+ raise(ArgumentError, "Times provided but composition.length (#{composition.length}) != times.length} (#{times.length})")
916
+ end
917
+ if weights.length.positive? && weights.length != composition.length
918
+ raise(ArgumentError, "Weights provided but composition.length (#{composition.length}) != weights.length} (#{weights.length})")
919
+ end
920
+
921
+ @size = composition.length
922
+ @batches = []
923
+ @agents = Hash[
924
+ composition.flatten(2).uniq.map do |a|
925
+ [
926
+ a,
927
+ if priors.key?(a)
928
+ Agent.new(priors[a], NINF, -INF)
929
+ else
930
+ Agent.new(Player.new(Gaussian.new(mu, sigma), beta, gamma), NINF, -INF)
931
+ end
932
+ ]
933
+ end
934
+ ]
935
+ @mu = mu
936
+ @sigma = sigma
937
+ @p_draw = p_draw
938
+ @time = times.length.positive?
939
+ trueskill(composition, results, times, weights)
940
+
941
+ end
942
+
943
+ def trueskill(composition, results, times, weights)
944
+ o = times.length.positive? ? sortperm(times) : (0...composition.length).to_a
945
+ i = 0
946
+ while i < @size
947
+ j = i + 1
948
+ t = times.length.zero? ? j : times[o[i]]
949
+ while (times.length.positive?) && (j < @size) && (times[o[j]] == t) # I don't even
950
+ j += 1
951
+ end
952
+ b = if results.length.positive?
953
+ Batch.new(
954
+ o[i...j].map { |k| composition[k] },
955
+ o[i...j].map { |k| results[k] },
956
+ t,
957
+ @agents,
958
+ @p_draw,
959
+ weights.length.positive? ? o[i...j].map { |k| weights[k] } : weights
960
+ )
961
+ else
962
+ Batch.new(
963
+ o[i...j].map { |k| composition[k] },
964
+ [],
965
+ t,
966
+ @agents,
967
+ @p_draw,
968
+ weights.length.positive? ? o[i...j].map { |k| weights[k] } : weights
969
+ )
970
+ end
971
+ @batches << b
972
+ b.skills.each_key do |a|
973
+ @agents[a].last_time = (@time ? t : INF)
974
+ @agents[a].message = b.forward_prior_out(a)
975
+ end
976
+ i = j
977
+ end
978
+ end
979
+
980
+ def iteration
981
+ step = [0.0, 0.0]
982
+ clean!(@agents)
983
+
984
+ (0...@batches.length-1).to_a.reverse.each do |j|
985
+ @batches[j+1].skills.each_key do |a|
986
+ @agents[a].message = @batches[j+1].backward_prior_out(a)
987
+ end
988
+ old = @batches[j].posteriors # python copies
989
+ @batches[j].new_backward_info
990
+ step = max_tuple(step, dict_diff(old, @batches[j].posteriors))
991
+ end
992
+
993
+ clean!(@agents)
994
+
995
+ (1...@batches.length).each do |j|
996
+ @batches[j-1].skills.each_key do |a|
997
+ @agents[a].message = @batches[j-1].forward_prior_out(a)
998
+ end
999
+ old = @batches[j].posteriors # again, original copies
1000
+ @batches[j].new_forward_info
1001
+ step = max_tuple(step, dict_diff(old, @batches[j].posteriors))
1002
+ end
1003
+
1004
+ if @batches.length == 1
1005
+ old = @batches[0].posteriors
1006
+ @batches[0].convergence
1007
+ step = max_tuple(step, dict_diff(old, @batches[0].posteriors))
1008
+ end
1009
+
1010
+ step
1011
+ end
1012
+
1013
+ def convergence(epsilon:EPSILON, iterations:ITERATIONS, verbose:true)
1014
+ step = [INF, INF]
1015
+ i = 0
1016
+
1017
+ while gr_tuple(step, epsilon) && (i < iterations)
1018
+ step = iteration
1019
+ puts "Iteration = #{i} , #{step}" if verbose
1020
+ i += 1
1021
+ end
1022
+ puts "End" if verbose
1023
+
1024
+ [step, i]
1025
+ end
1026
+
1027
+ def learning_curves
1028
+ res = {}
1029
+ @batches.each do |batch|
1030
+ batch.skills.each_key do |a|
1031
+ res[a] ||= []
1032
+ res[a] << [batch.time, batch.posterior(a)]
1033
+ end
1034
+ end
1035
+ res
1036
+ end
1037
+
1038
+ def evidence
1039
+ ev = []
1040
+ @batches.each do |batch|
1041
+ batch.events.each do |event|
1042
+ ev << event.evidence
1043
+ end
1044
+ end
1045
+ ev
1046
+ end
1047
+
1048
+ def log_evidence
1049
+ # Math.log(@batches.flat_map { |b| b.events }.map { |event| event.evidence }.reduce(1, :*))
1050
+ @batches.flat_map { |b| b.events }.map { |event| Math.log(event.evidence) }.sum
1051
+ end
1052
+
1053
+ def to_s
1054
+ "History(Events=#{@size}, Batches=#{@batches.length}, Agents=#{@agents})"
1055
+ end
1056
+
1057
+ def inspect
1058
+ to_s
1059
+ end
1060
+
1061
+ def length
1062
+ @size
1063
+ end
1064
+ end
1065
+
1066
+ def game_example(mu=MU, sigma=SIGMA, beta=BETA, p_draw=P_DRAW)
1067
+ a1 = Player.new(Gaussian.new(mu-500, sigma), beta=beta)
1068
+ a2 = Player.new(Gaussian.new(mu-500, sigma), beta=beta)
1069
+ a3 = Player.new(Gaussian.new(mu, sigma), beta=beta)
1070
+ a4 = Player.new(Gaussian.new(mu, sigma), beta=beta)
1071
+ a5 = Player.new(Gaussian.new(mu+5000, sigma), beta=beta)
1072
+ a6 = Player.new(Gaussian.new(mu+5000, sigma), beta=beta)
1073
+ team_a = [ a1, a2 ]
1074
+ team_b = [ a3, a4 ]
1075
+ team_c = [ a5, a6 ]
1076
+ teams = [team_a, team_b, team_c]
1077
+ result = [0.0, 1.0, 2.0]
1078
+ g = Game.new(teams, result, p_draw=p_draw)
1079
+ puts "Teams: #{g.teams}"
1080
+ puts "Likelihoods: #{g.likelihoods}"
1081
+ puts "Evidence: #{g.evidence}"
1082
+ puts "Posteriors: #{g.posteriors}"
1083
+ (0...teams.length).each do |i|
1084
+ puts "#{i}: #{g.performance(i)}"
1085
+ end
1086
+ end
1087
+
1088
+ def history_example(mu=MU, sigma=SIGMA, beta=BETA, p_draw=P_DRAW)
1089
+ c1 = [["a"], ["b"]]
1090
+ c2 = [["b"], ["c"]]
1091
+ c3 = [["c"], ["a"]]
1092
+ composition = [c1, c2, c3]
1093
+ h = History.new(composition, gamma: 0.0)
1094
+ puts "#{h.learning_curves["a"]}"
1095
+ # [(1, N(mu=3.339, sigma=4.985)), (3, N(mu=-2.688, sigma=3.779))]
1096
+ puts "#{h.learning_curves["b"]}"
1097
+ # [(1, N(mu=-3.339, sigma=4.985)), (2, N(mu=0.059, sigma=4.218))]
1098
+ h.convergence
1099
+ puts "#{h.learning_curves["a"]}"
1100
+ # [[1, N(mu= 1.1776221018565704e-07, sigma= 2.3948083841791528)], [3, N(mu= -9.875327098012653e-08, sigma= 2.3948083506699978)]]
1101
+ puts "#{h.learning_curves["b"]}"
1102
+ # [[1, N(mu= -6.743217353120669e-08, sigma= 2.3948083803990317)], [2, N(mu= -6.888059735564812e-08, sigma= 2.394808375074641)]]
1103
+ puts "#{h.log_evidence}"
1104
+ puts "Foo"
1105
+ end
1106
+
1107
+ def liquid_example(path:"./example_data/liquid_t2_tournament_results.json")
1108
+ json_data = JSON.load(File.open(path))
1109
+ tournaments = {}
1110
+ json_data["tournament_positions"].each_pair do |k, v| # Symbolise keys and convert to dates, remove fake tournaments, remove tournaments with draws
1111
+ next unless v["results"].length > 2 # fake tournaments have < 3 teams
1112
+
1113
+ results = {}
1114
+ had_ties = false
1115
+ v["results"].each_pair do |rk, rv| # numberise keys, then sort the results by them
1116
+ if rv.length > 1
1117
+ had_ties = true
1118
+ break
1119
+ else
1120
+ results[rk.to_f] = rv
1121
+ end
1122
+ end
1123
+ unless had_ties
1124
+ tournaments[k] = { :results => results.sort,
1125
+ :day => DateTime.strptime(v["date"], "%Y-%m-%d %H:%M:%S").to_time.to_i / (60 * 60 * 24) }
1126
+ end
1127
+ end
1128
+ tournaments = tournaments.sort_by {|k, v| v[:day]}.to_h
1129
+ # Need to get to compositions, results, times: results needs to be inverse of position
1130
+ compositions = []
1131
+ results = []
1132
+ times = []
1133
+ tournaments.each_pair do |tournament_name, t_data|
1134
+ times << t_data[:day]
1135
+ t_results = []
1136
+ t_compositions = []
1137
+ t_data[:results].each_with_index do |(position, composition), i|
1138
+ t_results << t_data[:results].length - i
1139
+ # t_compositions << composition.map {|player| [player]}
1140
+ t_compositions << composition.flatten
1141
+ end
1142
+ compositions << t_compositions
1143
+ results << t_results
1144
+ end
1145
+
1146
+ h = History.new(compositions, results:results, times:times, mu:1200, sigma:400, beta:1320, gamma:19, p_draw: 0.01375)
1147
+
1148
+ puts h.log_evidence
1149
+ h.convergence(epsilon: 0.01, iterations: 60, verbose: true)
1150
+ puts h.log_evidence
1151
+ puts h.learning_curves.first
1152
+
1153
+ h.evidence.each_with_index do |ev, idx|
1154
+ puts "#{idx}\t#{ev}\t#{tournaments.keys[idx]}"
1155
+ end
1156
+
1157
+ end
1158
+ end