shogi-server source
Revision | 24fee2243d10ec025357d52d8034cbc1dcf48338 (tree) |
---|---|
Zeit | 2013-03-20 17:23:36 |
Autor | Daigo Moriwaki <beatles@user...> |
Commiter | Daigo Moriwaki |
[shogi-server] New pairing algorithm: ShogiServer::Pairing::LeastDiff
This pairing algorithm aims to minimize the total differences of
matching players' rates. It also includes penalyties when a match
is same as the previous one or a match is between human players.
It is based on a discussion with Yamashita-san on
http://www.sgtpepper.net/kaneko/diary/20120511.html.
@@ -1,3 +1,13 @@ | ||
1 | +2013-03-20 Daigo Moriwaki <daigo at debian dot org> | |
2 | + | |
3 | + * [shogi-server] | |
4 | + - New pairing algorithm: ShogiServer::Pairing::LeastDiff | |
5 | + This pairing algorithm aims to minimize the total differences of | |
6 | + matching players' rates. It also includes penalyties when a match | |
7 | + is same as the previous one or a match is between human players. | |
8 | + It is based on a discussion with Yamashita-san on | |
9 | + http://www.sgtpepper.net/kaneko/diary/20120511.html. | |
10 | + | |
1 | 11 | 2012-12-30 Daigo Moriwaki <daigo at debian dot org> |
2 | 12 | |
3 | 13 | * [shogi-server] |
@@ -257,6 +257,18 @@ class League | ||
257 | 257 | return rc[:loser] == player_id |
258 | 258 | end |
259 | 259 | |
260 | + def last_opponent(player_id) | |
261 | + rc = last_valid_game(player_id) | |
262 | + return nil unless rc | |
263 | + if rc[:black] == player_id | |
264 | + return rc[:white] | |
265 | + elsif rc[:white] == player_id | |
266 | + return rc[:black] | |
267 | + else | |
268 | + return nil | |
269 | + end | |
270 | + end | |
271 | + | |
260 | 272 | def last_valid_game(player_id) |
261 | 273 | records = nil |
262 | 274 | @@mutex.synchronize do |
@@ -269,6 +281,28 @@ class League | ||
269 | 281 | end |
270 | 282 | return rc |
271 | 283 | end |
284 | + | |
285 | + def win_games(player_id) | |
286 | + records = nil | |
287 | + @@mutex.synchronize do | |
288 | + records = @records.reverse | |
289 | + end | |
290 | + rc = records.find_all do |rc| | |
291 | + rc[:winner] == player_id && rc[:loser] | |
292 | + end | |
293 | + return rc | |
294 | + end | |
295 | + | |
296 | + def loss_games(player_id) | |
297 | + records = nil | |
298 | + @@mutex.synchronize do | |
299 | + records = @records.reverse | |
300 | + end | |
301 | + rc = records.find_all do |rc| | |
302 | + rc[:winner] && rc[:loser] == player_id | |
303 | + end | |
304 | + return rc | |
305 | + end | |
272 | 306 | end # class History |
273 | 307 | |
274 | 308 |
@@ -25,7 +25,7 @@ module ShogiServer | ||
25 | 25 | |
26 | 26 | class << self |
27 | 27 | def default_factory |
28 | - return swiss_pairing | |
28 | + return least_diff_pairing | |
29 | 29 | end |
30 | 30 | |
31 | 31 | def sort_by_rate_with_randomness |
@@ -52,6 +52,14 @@ module ShogiServer | ||
52 | 52 | StartGameWithoutHumans.new] |
53 | 53 | end |
54 | 54 | |
55 | + def least_diff_pairing | |
56 | + return [LogPlayers.new, | |
57 | + ExcludeSacrificeGps500.new, | |
58 | + MakeEven.new, | |
59 | + LeastDiff.new, | |
60 | + StartGameWithoutHumans.new] | |
61 | + end | |
62 | + | |
55 | 63 | def match(players) |
56 | 64 | logics = default_factory |
57 | 65 | logics.inject(players) do |result, item| |
@@ -62,6 +70,10 @@ module ShogiServer | ||
62 | 70 | end # class << self |
63 | 71 | |
64 | 72 | |
73 | + # Make matches among players. | |
74 | + # @param players an array of players, which should be updated destructively | |
75 | + # to pass the new list to subsequent logics. | |
76 | + # | |
65 | 77 | def match(players) |
66 | 78 | # to be implemented |
67 | 79 | log_message("Floodgate: %s" % [self.class.to_s]) |
@@ -232,17 +244,18 @@ module ShogiServer | ||
232 | 244 | end |
233 | 245 | |
234 | 246 | class SortByRateWithRandomness < Pairing |
235 | - def initialize(rand1, rand2) | |
247 | + def initialize(rand1, rand2, desc=false) | |
236 | 248 | super() |
237 | 249 | @rand1, @rand2 = rand1, rand2 |
250 | + @desc = desc | |
238 | 251 | end |
239 | 252 | |
240 | - def match(players, desc=false) | |
253 | + def match(players) | |
241 | 254 | super(players) |
242 | 255 | cur_rate = Hash.new |
243 | 256 | players.each{|a| cur_rate[a] = a.rate ? a.rate + rand(@rand1) : rand(@rand2)} |
244 | 257 | players.sort!{|a,b| cur_rate[a] <=> cur_rate[b]} |
245 | - players.reverse! if desc | |
258 | + players.reverse! if @desc | |
246 | 259 | log_players(players) do |one| |
247 | 260 | "%s %d (+ randomness %d)" % [one.name, one.rate, cur_rate[one] - one.rate] |
248 | 261 | end |
@@ -267,12 +280,12 @@ module ShogiServer | ||
267 | 280 | rest = players - winners |
268 | 281 | |
269 | 282 | log_message("Floodgate: Ordering %d winners..." % [winners.size]) |
270 | - sbrwr_winners = SortByRateWithRandomness.new(800, 2500) | |
271 | - sbrwr_winners.match(winners, true) | |
283 | + sbrwr_winners = SortByRateWithRandomness.new(800, 2500, true) | |
284 | + sbrwr_winners.match(winners) | |
272 | 285 | |
273 | 286 | log_message("Floodgate: Ordering the rest (%d)..." % [rest.size]) |
274 | - sbrwr_losers = SortByRateWithRandomness.new(200, 400) | |
275 | - sbrwr_losers.match(rest, true) | |
287 | + sbrwr_losers = SortByRateWithRandomness.new(200, 400, true) | |
288 | + sbrwr_losers.match(rest) | |
276 | 289 | |
277 | 290 | players.clear |
278 | 291 | [winners, rest].each do |group| |
@@ -364,4 +377,137 @@ module ShogiServer | ||
364 | 377 | end |
365 | 378 | end |
366 | 379 | |
380 | + # This pairing algorithm aims to minimize the total differences of | |
381 | + # matching players' rates. It also includes penalyties when a match is | |
382 | + # same as the previous one or a match is between human players. | |
383 | + # It is based on a discussion with Yamashita-san on | |
384 | + # http://www.sgtpepper.net/kaneko/diary/20120511.html. | |
385 | + # | |
386 | + class LeastDiff < Pairing | |
387 | + def random_match(players) | |
388 | + players.shuffle | |
389 | + end | |
390 | + | |
391 | + def average_rate(players) | |
392 | + n=0 | |
393 | + sum=0 | |
394 | + players.find_all{|p| p.rate}.each do |p| | |
395 | + n += 1 | |
396 | + sum += p.rate | |
397 | + end | |
398 | + | |
399 | + return n > 0 ? sum/n : 2150 # interger | |
400 | + end | |
401 | + | |
402 | + # Returns a player's rate value. | |
403 | + # 1. If it has a valid rate, return the rate. | |
404 | + # 2. If it has no valid rate, return average of the following values: | |
405 | + # a. For games it won, the opponent's rate + 100 | |
406 | + # b. For games it lost, the opponent's rate - 100 | |
407 | + # (if the opponent has no valid rate, count out the game) | |
408 | + # (if there are not such games, return 2150 (default value) | |
409 | + # | |
410 | + def get_player_rate(player, history) | |
411 | + return player.rate if player.rate != 0 | |
412 | + return 2150 unless history | |
413 | + | |
414 | + count = 0 | |
415 | + sum = 0 | |
416 | + | |
417 | + history.win_games(player.player_id).each do |g| | |
418 | + next unless g[:loser] | |
419 | + name = g[:loser].split("+")[0] | |
420 | + p = $league.find(name) | |
421 | + if p && p.rate != 0 | |
422 | + count += 1 | |
423 | + sum += p.rate + 100 | |
424 | + end | |
425 | + end | |
426 | + history.loss_games(player.player_id).each do |g| | |
427 | + next unless g[:winner] | |
428 | + name = g[:winner].split("+")[0] | |
429 | + p = $league.find(name) | |
430 | + if p && p.rate != 0 | |
431 | + count += 1 | |
432 | + sum += p.rate - 100 | |
433 | + end | |
434 | + end | |
435 | + | |
436 | + estimate = (count == 0 ? 2150 : sum/count) | |
437 | + log_message("Floodgate: Estimated rate of %s is %d" % [player.name, estimate]) | |
438 | + return estimate | |
439 | + end | |
440 | + | |
441 | + def calculate_diff_with_penalty(players, history) | |
442 | + pairs = [] | |
443 | + players.each_slice(2) do |pair| | |
444 | + if pair.size == 2 | |
445 | + pairs << pair | |
446 | + end | |
447 | + end | |
448 | + | |
449 | + ret = 0 | |
450 | + | |
451 | + # 1. Diff of players rate | |
452 | + pairs.each do |p1,p2| | |
453 | + ret += (get_player_rate(p1,history) - get_player_rate(p2,history)).abs | |
454 | + end | |
455 | + | |
456 | + # 2. Penalties | |
457 | + pairs.each do |p1,p2| | |
458 | + # 2.1. same match | |
459 | + if (history && | |
460 | + (history.last_opponent(p1.player_id) == p2.player_id || | |
461 | + history.last_opponent(p2.player_id) == p1.player_id)) | |
462 | + ret += 400 | |
463 | + end | |
464 | + | |
465 | + # 2.2 Human vs Human | |
466 | + if p1.is_human? && p2.is_human? | |
467 | + ret += 800 | |
468 | + end | |
469 | + end | |
470 | + | |
471 | + ret | |
472 | + end | |
473 | + | |
474 | + def match(players) | |
475 | + super | |
476 | + if players.size < 3 | |
477 | + log_message("Floodgate: players are small enough to skip LeastDiff pairing: %d" % [players.size]) | |
478 | + return players | |
479 | + end | |
480 | + | |
481 | + # 10 trials | |
482 | + matches = [] | |
483 | + scores = [] | |
484 | + path = ShogiServer::League::Floodgate.history_file_path(players.first.game_name) | |
485 | + history = ShogiServer::League::Floodgate::History.factory(path) | |
486 | + 10.times do | |
487 | + m = random_match(players) | |
488 | + matches << m | |
489 | + scores << calculate_diff_with_penalty(m, history) | |
490 | + end | |
491 | + | |
492 | + # Debug | |
493 | + #scores.each_with_index do |s,i| | |
494 | + # puts | |
495 | + # print s, ": ", matches[i].map{|p| p.name}.join(", "), "\n" | |
496 | + #end | |
497 | + | |
498 | + # Select a match of the least score | |
499 | + min_index = 0 | |
500 | + min_score = scores.first | |
501 | + scores.each_with_index do |s,i| | |
502 | + if s < min_score | |
503 | + min_index = i | |
504 | + min_score = s | |
505 | + end | |
506 | + end | |
507 | + log_message("Floodgate: the least score %d (%d per player)" % [min_score, min_score/players.size]) | |
508 | + | |
509 | + players = matches[min_index] | |
510 | + end | |
511 | + end | |
512 | + | |
367 | 513 | end # ShogiServer |
@@ -395,6 +395,22 @@ class TestFloodgateHistory < Test::Unit::TestCase | ||
395 | 395 | assert !@history.last_win?("foo") |
396 | 396 | assert !@history.last_lose?("hoge") |
397 | 397 | assert @history.last_lose?("foo") |
398 | + | |
399 | + assert_equal("foo", @history.last_opponent("hoge")) | |
400 | + assert_equal("hoge", @history.last_opponent("foo")) | |
401 | + | |
402 | + games = @history.win_games("hoge") | |
403 | + assert_equal(1, games.size ) | |
404 | + assert_equal("wdoor+floodgate-900-0-hoge-foo-2", games[0][:game_id]) | |
405 | + games = @history.win_games("foo") | |
406 | + assert_equal(1, games.size ) | |
407 | + assert_equal("wdoor+floodgate-900-0-hoge-foo-1", games[0][:game_id]) | |
408 | + games = @history.loss_games("hoge") | |
409 | + assert_equal(1, games.size ) | |
410 | + assert_equal("wdoor+floodgate-900-0-hoge-foo-1", games[0][:game_id]) | |
411 | + games = @history.loss_games("foo") | |
412 | + assert_equal(1, games.size ) | |
413 | + assert_equal("wdoor+floodgate-900-0-hoge-foo-2", games[0][:game_id]) | |
398 | 414 | end |
399 | 415 | end |
400 | 416 |
@@ -1,11 +1,11 @@ | ||
1 | 1 | $:.unshift File.join(File.dirname(__FILE__), "..") |
2 | 2 | require 'test/unit' |
3 | 3 | require 'shogi_server' |
4 | +require 'shogi_server/league.rb' | |
4 | 5 | require 'shogi_server/player' |
5 | 6 | require 'shogi_server/pairing' |
6 | 7 | require 'test/mock_log_message' |
7 | 8 | |
8 | - | |
9 | 9 | def same_pair?(a, b) |
10 | 10 | unless a.size == 2 && b.size == 2 |
11 | 11 | return false |
@@ -327,4 +327,246 @@ class TestStartGameWithoutHumans < Test::Unit::TestCase | ||
327 | 327 | end |
328 | 328 | end |
329 | 329 | |
330 | +class TestLeastDiff < Test::Unit::TestCase | |
331 | + | |
332 | + class MockLeague | |
333 | + def initialize | |
334 | + @players = [] | |
335 | + end | |
336 | + | |
337 | + def add(player) | |
338 | + @players << player | |
339 | + end | |
340 | + | |
341 | + def find(name) | |
342 | + @players.find do |p| | |
343 | + p.name == name | |
344 | + end | |
345 | + end | |
346 | + end | |
347 | + | |
348 | + def setup | |
349 | + $league = MockLeague.new | |
350 | + | |
351 | + @pairing= ShogiServer::LeastDiff.new | |
352 | + $paired = [] | |
353 | + $called = 0 | |
354 | + def @pairing.start_game(p1,p2) | |
355 | + $called += 1 | |
356 | + $paired << [p1,p2] | |
357 | + end | |
358 | + | |
359 | + @file = Pathname.new(File.join(File.dirname(__FILE__), "floodgate_history.yaml")) | |
360 | + @history = ShogiServer::League::Floodgate::History.new @file | |
361 | + | |
362 | + @a = ShogiServer::BasicPlayer.new | |
363 | + @a.player_id = "a" | |
364 | + @a.name = "a" | |
365 | + @a.win = 1 | |
366 | + @a.loss = 2 | |
367 | + @a.rate = 500 | |
368 | + @b = ShogiServer::BasicPlayer.new | |
369 | + @b.player_id = "b" | |
370 | + @b.name = "b" | |
371 | + @b.win = 10 | |
372 | + @b.loss = 20 | |
373 | + @b.rate = 800 | |
374 | + @c = ShogiServer::BasicPlayer.new | |
375 | + @c.player_id = "c" | |
376 | + @c.name = "c" | |
377 | + @c.win = 100 | |
378 | + @c.loss = 200 | |
379 | + @c.rate = 1000 | |
380 | + @d = ShogiServer::BasicPlayer.new | |
381 | + @d.player_id = "d" | |
382 | + @d.name = "d" | |
383 | + @d.win = 1000 | |
384 | + @d.loss = 2000 | |
385 | + @d.rate = 1500 | |
386 | + @e = ShogiServer::BasicPlayer.new | |
387 | + @e.player_id = "e" | |
388 | + @e.name = "e" | |
389 | + @e.win = 3000 | |
390 | + @e.loss = 3000 | |
391 | + @e.rate = 2000 | |
392 | + @f = ShogiServer::BasicPlayer.new | |
393 | + @f.player_id = "f" | |
394 | + @f.name = "f" | |
395 | + @f.win = 4000 | |
396 | + @f.loss = 4000 | |
397 | + @f.rate = 2150 | |
398 | + @g = ShogiServer::BasicPlayer.new | |
399 | + @g.player_id = "g" | |
400 | + @g.name = "g" | |
401 | + @g.win = 5000 | |
402 | + @g.loss = 5000 | |
403 | + @g.rate = 2500 | |
404 | + @h = ShogiServer::BasicPlayer.new | |
405 | + @h.player_id = "h" | |
406 | + @h.name = "h" | |
407 | + @h.win = 6000 | |
408 | + @h.loss = 6000 | |
409 | + @h.rate = 3000 | |
410 | + @x = ShogiServer::BasicPlayer.new | |
411 | + @x.player_id = "x" | |
412 | + @x.name = "x" | |
413 | + | |
414 | + $league.add(@a) | |
415 | + $league.add(@b) | |
416 | + $league.add(@c) | |
417 | + $league.add(@d) | |
418 | + $league.add(@e) | |
419 | + $league.add(@f) | |
420 | + $league.add(@g) | |
421 | + $league.add(@h) | |
422 | + $league.add(@x) | |
423 | + end | |
424 | + | |
425 | + def teardown | |
426 | + @file.delete if @file.exist? | |
427 | + end | |
428 | + | |
429 | + def assert_pairs(x_array, y_array) | |
430 | + if (x_array.size != y_array.size) | |
431 | + assert_equal(x_array.size, y_array.size) | |
432 | + return | |
433 | + end | |
434 | + i = 0 | |
435 | + | |
436 | + if (x_array.size == 1) | |
437 | + assert_equal(x_array[0].name, y_array[0].name) | |
438 | + return | |
439 | + end | |
440 | + | |
441 | + ret = true | |
442 | + while i < x_array.size | |
443 | + if i == x_array.size-1 | |
444 | + assert_equal(x_array[i].name, y_array[i].name) | |
445 | + break | |
446 | + end | |
447 | + px1 = x_array[i] | |
448 | + px2 = x_array[i+1] | |
449 | + py1 = y_array[i] | |
450 | + py2 = y_array[i+1] | |
451 | + | |
452 | + if ! ((px1.name == py1.name && px2.name == py2.name) || | |
453 | + (px1.name == py2.name && px2.name == py1.name)) | |
454 | + ret = false | |
455 | + end | |
456 | + i += 2 | |
457 | + end | |
458 | + | |
459 | + assert(ret) | |
460 | + end | |
461 | + | |
462 | + def test_match_one_player | |
463 | + players = [@a] | |
464 | + assert_equal(0, @pairing.calculate_diff_with_penalty(players,nil)) | |
465 | + r = @pairing.match(players) | |
466 | + assert_pairs([@a], r) | |
467 | + end | |
468 | + | |
469 | + def test_match_two_players | |
470 | + players = [@a,@b] | |
471 | + assert_equal(@b.rate-@a.rate, @pairing.calculate_diff_with_penalty([@a,@b],nil)) | |
472 | + assert_equal(@b.rate-@a.rate, @pairing.calculate_diff_with_penalty([@b,@a],nil)) | |
473 | + r = @pairing.match(players) | |
474 | + assert_pairs([@a,@b], r) | |
475 | + end | |
476 | + | |
477 | + def test_match_three_players | |
478 | + players = [@a,@b,@h] | |
479 | + assert_equal(300, @pairing.calculate_diff_with_penalty([@a,@b,@h],nil)) | |
480 | + assert_equal(2200, @pairing.calculate_diff_with_penalty([@b,@h,@a],nil)) | |
481 | + r = @pairing.match(players) | |
482 | + assert_pairs([@a,@b,@h], r) | |
483 | + end | |
484 | + | |
485 | + def test_calculate_diff_with_penalty | |
486 | + players = [@a,@b] | |
487 | + assert_equal(@b.rate-@a.rate, @pairing.calculate_diff_with_penalty(players,nil)) | |
488 | + | |
489 | + dummy = nil | |
490 | + def @history.make_record(game_result) | |
491 | + {:game_id => "wdoor+floodgate-900-0-a-b-1", | |
492 | + :black => "b", :white => "a", | |
493 | + :winner => "a", :loser => "b"} | |
494 | + end | |
495 | + @history.update(dummy) | |
496 | + assert_equal(@b.rate-@a.rate+400, @pairing.calculate_diff_with_penalty(players, @history)) | |
497 | + end | |
498 | + | |
499 | + def test_calculate_diff_with_penalty2 | |
500 | + players = [@a,@b,@g,@h] | |
501 | + assert_equal(@b.rate-@a.rate+@h.rate-@g.rate, @pairing.calculate_diff_with_penalty(players,nil)) | |
502 | + end | |
503 | + | |
504 | + def test_calculate_diff_with_penalty2_1 | |
505 | + players = [@a,@b,@g,@h] | |
506 | + assert_equal(@b.rate-@a.rate+@h.rate-@g.rate, @pairing.calculate_diff_with_penalty(players,nil)) | |
507 | + dummy = nil | |
508 | + def @history.make_record(game_result) | |
509 | + {:game_id => "wdoor+floodgate-900-0-a-b-1", | |
510 | + :black => "b", :white => "a", | |
511 | + :winner => "a", :loser => "b"} | |
512 | + end | |
513 | + @history.update(dummy) | |
514 | + assert_equal(@b.rate-@a.rate+400+@h.rate-@g.rate, @pairing.calculate_diff_with_penalty(players, @history)) | |
515 | + end | |
516 | + | |
517 | + def test_calculate_diff_with_penalty2_2 | |
518 | + players = [@a,@b,@g,@h] | |
519 | + assert_equal(@b.rate-@a.rate+@h.rate-@g.rate, @pairing.calculate_diff_with_penalty(players,nil)) | |
520 | + dummy = nil | |
521 | + def @history.make_record(game_result) | |
522 | + {:game_id => "wdoor+floodgate-900-0-a-b-1", | |
523 | + :black => "g", :white => "h", | |
524 | + :winner => "h", :loser => "g"} | |
525 | + end | |
526 | + @history.update(dummy) | |
527 | + assert_equal(@b.rate-@a.rate+400+@h.rate-@g.rate, @pairing.calculate_diff_with_penalty(players, @history)) | |
528 | + #assert_equal(@b.rate-@a.rate+400+@h.rate-@g.rate+400, @pairing.calculate_diff_with_penalty(players, [@b,@a,@h,@g])) | |
529 | + end | |
530 | + | |
531 | + def test_calculate_diff_with_penalty2_3 | |
532 | + players = [@a,@b,@g,@h] | |
533 | + assert_equal(@b.rate-@a.rate+@h.rate-@g.rate, @pairing.calculate_diff_with_penalty(players,nil)) | |
534 | + dummy = nil | |
535 | + def @history.make_record(game_result) | |
536 | + {:game_id => "wdoor+floodgate-900-0-a-b-1", | |
537 | + :black => "g", :white => "h", | |
538 | + :winner => "h", :loser => "g"} | |
539 | + end | |
540 | + @history.update(dummy) | |
541 | + def @history.make_record(game_result) | |
542 | + {:game_id => "wdoor+floodgate-900-0-a-b-1", | |
543 | + :black => "b", :white => "a", | |
544 | + :winner => "a", :loser => "b"} | |
545 | + end | |
546 | + @history.update(dummy) | |
547 | + assert_equal(@b.rate-@a.rate+400+@h.rate-@g.rate+400, @pairing.calculate_diff_with_penalty(players, @history)) | |
548 | + end | |
549 | + | |
550 | + def test_get_player_rate_0 | |
551 | + assert_equal(2150, @pairing.get_player_rate(@x, @history)) | |
552 | + | |
553 | + dummy = nil | |
554 | + def @history.make_record(game_result) | |
555 | + {:game_id => "wdoor+floodgate-900-0-x-a-1", | |
556 | + :black => "x", :white => "a", | |
557 | + :winner => "x", :loser => "a"} | |
558 | + end | |
559 | + @history.update(dummy) | |
560 | + assert_equal(@a.rate+100, @pairing.get_player_rate(@x, @history)) | |
561 | + | |
562 | + def @history.make_record(game_result) | |
563 | + {:game_id => "wdoor+floodgate-900-0-x-b-1", | |
564 | + :black => "x", :white => "b", | |
565 | + :winner => "b", :loser => "x"} | |
566 | + end | |
567 | + @history.update(dummy) | |
568 | + | |
569 | + assert_equal((@a.rate+100+@b.rate-100)/2, @pairing.get_player_rate(@x, @history)) | |
570 | + end | |
571 | +end | |
330 | 572 |