Skip to content

Commit

Permalink
Match newbies randomly (#492)
Browse files Browse the repository at this point in the history
* Fix players with high rating deviations not getting 1v1 ladder matches (#1)

* Matchmaking graph only to include preferred match edges where the match is actually possible as determined by matches_with. _rank_partners still runs in O(n + SM_NUM_TO_RANK log n) but now requires O(n) space, with n the number of total searches.

* typo

* adjusting deviation and rating in test_rank_all unit test such that all matches become possible

* add test for rank_all not including unmatchable games

* Dostya test user in test_server_ban integration test now expected to be banned for 980 years, not 981

* tests on reasonable matchmaking thresholds

* determine base minimum threshold for acceptable matches as 80% of quality of a game against yourself

* increase new player threshold boundary

* test that matchmaker gives high quality games to plawers with low deviation and games of any quality to new players with uncertain rating

* adjust values for base rank_all test so that players are matchable

* unit test typos

* fixed preference order of test_rank_all

* remove now irrelevant check whether match is acceptable in StableMarriage.find

* use filter in _rank_partners

* added test for whether high-dev players are matched with high-dev players and low-dev with low-dev if all share the same mean

* added sanity checks for game quality thresholds of two-player search parties

* remove mocker, loop fixtures from matchmaker_queue tests

* tests for identifying single newbies

* methods for identifying single newbies

* removed more mocker, loop fixtures

* make is_ladder_newbie (actually) static

* tests for forcefully matching newbies

* forcefully matching unmatched newbies after stable marriage

* Typos (#4)

Typos

* suggestions from code review

Co-Authored-By: Askaholic <askaholic907@gmail.com>

*  suggested changes from PR review

* tests for not forcefully matching singles with teams

* only matching with single searches

* typos

* will never force match players with rating above config.TOP_PLAYER_MIN_RATING = 2000

* rename 2500 rated player to top_player

* Refactor stable marriage (#5)

* rename 2500 rated player to top_player

* refactor matchmaker

* adapt tests to new interface for matchmaking policies

* add test that Random Matching produces symmetric outputs

* correct type annotations for MatchmakingPolicies .find methods

* test for matchmaker class

* remove __repr__ method of search

* refactor building the matchmaking graph for stable marriage

* package building the matchmaking graph into a class

* added logging to MatchingGraph

* removed print statement from tests

* style suggestions from code review

Co-Authored-By: Askaholic <askaholic907@gmail.com>

* yapf reformat on algorithm.py

* set forcematch rating cutoff default to 1600

* adapt test numbers to new forcematch default

* adapt tests to multiple ratings

* slight debug message improvement

* matchmaking policy logs invoking class, Matchmaker logs which algorithms are used
  • Loading branch information
cleborys authored and Askaholic committed Sep 12, 2019
1 parent d5934e9 commit b04818f
Show file tree
Hide file tree
Showing 6 changed files with 333 additions and 88 deletions.
1 change: 1 addition & 0 deletions server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@

NEWBIE_BASE_MEAN = int(os.getenv('NEWBIE_BASE_MEAN', 500))
NEWBIE_MIN_GAMES = int(os.getenv('NEWBIE_MIN_GAMES', 10))
TOP_PLAYER_MIN_RATING = int(os.getenv('TOP_PLAYER_MIN_RATING', 1600))

TWILIO_ACCOUNT_SID = os.getenv("TWILIO_ACCOUNT_SID", "")
TWILIO_TOKEN = os.getenv("TWILIO_TOKEN", "")
Expand Down
173 changes: 123 additions & 50 deletions server/matchmaker/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,42 @@
################################################################################


def stable_marriage(searches: List[Search]) -> List[Match]:
return StableMarriage(searches).find()
def make_matches(searches: List[Search]) -> List[Match]:
return Matchmaker(searches).find()


@with_logger
class StableMarriage(object):
class MatchmakingPolicy(object):
def __init__(self, searches: List[Search]):
self.searches = searches
self.matches: Dict[Search, Search] = {}

def find(self) -> List[Match]:
def _match(self, s1: Search, s2: Search):
self._logger.debug(f"Matching %s and %s ({self.__class__})", s1, s2)
self.matches[s1] = s2
self.matches[s2] = s1

def _unmatch(self, s1: Search):
s2 = self.matches[s1]
self._logger.debug(f"Unmatching %s and %s ({self.__class__})", s1, s2)
assert self.matches[s2] == s1
del self.matches[s1]
del self.matches[s2]


class StableMarriage(MatchmakingPolicy):
def find(self) -> Dict[Search, Search]:
"""Perform SM_NUM_TO_RANK runs of the stable matching algorithm.
Assumes that _rank_all only returns edges whose matches are acceptable
Assumes that _MatchingGraph.build_sparse() only returns edges whose matches are acceptable
to both parties."""
ranks = _rank_all(self.searches)
self.matches: Dict[Search, Search] = {}
ranks = _MatchingGraph.build_sparse(self.searches)
self.matches.clear()

for i in range(SM_NUM_TO_RANK):
self._logger.debug("Round %i currently %i matches", i, len(self.matches) // 2)
self._logger.debug(
"Round %i of stable marriage, currently %i matches", i,
len(self.matches) // 2
)
# Do one round of proposals
if len(self.matches) == len(self.searches):
# Everyone found a match so we are done
Expand All @@ -57,15 +75,7 @@ def find(self) -> List[Match]:

self._propose(search, preferred)

return self._remove_duplicates()

def _remove_duplicates(self) -> List[Match]:
matches_set: Set[Match] = set()
for s1, s2 in self.matches.items():
if (s1, s2) in matches_set or (s2, s1) in matches_set:
continue
matches_set.add((s1, s2))
return list(matches_set)
return self.matches

def _propose(self, search: Search, preferred: Search):
""" An unmatched search proposes to it's preferred opponent.
Expand All @@ -87,42 +97,105 @@ def _propose(self, search: Search, preferred: Search):
self._unmatch(preferred)
self._match(search, preferred)

def _match(self, s1: Search, s2: Search):
self._logger.debug("Matching %s and %s", s1, s2)
self.matches[s1] = s2
self.matches[s2] = s1

def _unmatch(self, s1: Search):
s2 = self.matches[s1]
self._logger.debug("Unmatching %s and %s", s1, s2)
assert self.matches[s2] == s1
del self.matches[s1]
del self.matches[s2]
class RandomlyMatchNewbies(MatchmakingPolicy):
def find(self) -> Dict[Search, Search]:
self.matches.clear()

unmatched_newbies = [
search for search in self.searches
if search.is_single_ladder_newbie() and not search in self.matches
]

while len(unmatched_newbies) >= 2:
newbie1 = unmatched_newbies.pop()
newbie2 = unmatched_newbies.pop()
self._match(newbie1, newbie2)

if len(unmatched_newbies) == 1:
newbie = unmatched_newbies[0]

default_if_no_available_opponent = None

opponent = next((
search for search in self.searches
if search != newbie and not search in self.matches
and search.is_single_party() and search.has_no_top_player()
), default_if_no_available_opponent)
if opponent is not default_if_no_available_opponent:
self._match(newbie, opponent)

return self.matches


@with_logger
class Matchmaker(object):
def __init__(self, searches: List[Search]):
self.searches = searches
self.matches: Dict[Search, Search] = {}

def find(self) -> List[Match]:
self._logger.debug("Matching with stable marriage...")
self.matches.update(StableMarriage(self.searches).find())

remaining_searches = [
search for search in self.searches if search not in self.matches
]
self._logger.debug("Matching randomly for remaining newbies...")
self.matches.update(RandomlyMatchNewbies(remaining_searches).find())

def _rank_all(searches: List[Search]) -> Dict[Search, List[Search]]:
""" Returns searches with best quality for each search.
return self._remove_duplicates()

def _remove_duplicates(self) -> List[Match]:
matches_set: Set[Match] = set()
for s1, s2 in self.matches.items():
if (s1, s2) in matches_set or (s2, s1) in matches_set:
continue
matches_set.add((s1, s2))
return list(matches_set)


@with_logger
class _MatchingGraph:
@staticmethod
def build_sparse(searches: List[Search]) -> Dict[Search, List[Search]]:
""" A graph in adjacency list representation, whose nodes are the searches
and whose edges are the top few possible matchings for each node.
Note that the highest quality searches come at the end of the list so that
it can be used as a stack with .pop().
"""
return {
search: sorted(
_MatchingGraph._get_top_matches(
search, filter(lambda s: s is not search, searches)
),
key=lambda other: search.quality_with(other)
)
for search in searches
}

@staticmethod
def _get_top_matches(search: Search, others: Iterable[Search]) -> List[Search]:
def is_possible_match(other: Search) -> bool:
quality_log_string = (
f"Quality between {search} and {other}: {search.quality_with(other)}"
f" thresholds: [{search.match_threshold}, {other.match_threshold}]."
)

if search.matches_with(other):
_MatchingGraph._logger.debug(
f"{quality_log_string} Will be considered during stable marriage."
)
return True
else:
_MatchingGraph._logger.debug(
f"{quality_log_string} Will be discarded for stable marriage."
)
return False

Note that the highest quality searches come at the end of the list so that
it can be used as a stack with .pop().
"""
return {
search: sorted(
_rank_partners(
search, filter(lambda s: s is not search, searches)
),
return heapq.nlargest(
SM_NUM_TO_RANK,
filter(is_possible_match, others),
key=lambda other: search.quality_with(other)
)
for search in searches
}


def _rank_partners(search: Search, others: Iterable[Search]) -> List[Search]:
return heapq.nlargest(
SM_NUM_TO_RANK,
filter(
lambda other: search.matches_with(other),
others
),
key=lambda other: search.quality_with(other)
)
4 changes: 2 additions & 2 deletions server/matchmaker/matchmaker_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import server

from ..decorators import with_logger
from .algorithm import stable_marriage
from .algorithm import make_matches
from .pop_timer import PopTimer
from .search import Match, Search

Expand Down Expand Up @@ -96,7 +96,7 @@ def find_matches(self) -> None:
# Call self.match on all matches and filter out the ones that were canceled
new_matches = filter(
lambda m: self.match(m[0], m[1]),
stable_marriage(self.queue.values())
make_matches(self.queue.values())
)
self._matches.extend(new_matches)

Expand Down
31 changes: 25 additions & 6 deletions server/matchmaker/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,34 @@ def adjusted_rating(player: Player):
+ player.ladder_games * mean) / config.NEWBIE_MIN_GAMES
return adjusted_mean, dev

@staticmethod
def _is_ladder_newbie(player: Player) -> bool:
return player.ladder_games <= config.NEWBIE_MIN_GAMES

def is_ladder1v1_search(self) -> bool:
return self.rating_type is RatingType.LADDER_1V1

def is_single_party(self) -> bool:
return len(self.players) == 1

def is_single_ladder_newbie(self) -> bool:
return (
self.is_single_party()
and self._is_ladder_newbie(self.players[0])
and self.is_ladder1v1_search()
)

def has_no_top_player(self) -> bool:
max_rating = max(map(lambda rating_tuple: rating_tuple[0], self.ratings))
return max_rating < config.TOP_PLAYER_MIN_RATING


@property
def ratings(self):
ratings = []
for player, rating in zip(self.players, self.raw_ratings):
# New players (less than config.NEWBIE_MIN_GAMES games) match against less skilled opponents
if (player.ladder_games <= config.NEWBIE_MIN_GAMES
and self.rating_type is RatingType.LADDER_1V1):
if self._is_ladder_newbie(player):
rating = self.adjusted_rating(player)
ratings.append(rating)
return ratings
Expand Down Expand Up @@ -165,13 +186,12 @@ def match(self, other: 'Search'):
self._logger.info("Matched %s with %s", self.players, other.players)

for player, raw_rating in zip(self.players, self.raw_ratings):
ladder_games = player.ladder_games
if ladder_games <= config.NEWBIE_MIN_GAMES:
if self._is_ladder_newbie(player):
mean, dev = raw_rating
adjusted_mean = self.adjusted_rating(player)
self._logger.info('Adjusted mean rating for {player} with {ladder_games} games from {mean} to {adjusted_mean}'.format(
player=player,
ladder_games=ladder_games,
ladder_games=player.ladder_games,
mean=mean,
adjusted_mean=adjusted_mean
))
Expand All @@ -195,5 +215,4 @@ def cancel(self):
def __str__(self):
return "Search({}, {}, {})".format(self.players, self.match_threshold, self.search_expansion)


Match = Tuple[Search, Search]
31 changes: 27 additions & 4 deletions tests/unit_tests/test_matchmaker_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,33 @@ def matchmaker_players_all_match(player_factory):
player_factory('Rhiza', player_id=5, ladder_rating=(1500, 50), ladder_games=(config.NEWBIE_MIN_GAMES + 1))


async def test_newbie_min_games(matchmaker_players):
p1, _, _, _, _, p6 = matchmaker_players
s1, s6 = Search([p1]), Search([p6])
assert s1.ratings[0] == p1.ratings[RatingType.LADDER_1V1] and s6.ratings[0] != p6.ratings[RatingType.LADDER_1V1]
async def test_is_ladder_newbie(matchmaker_players):
pro, _, _, _, _, newbie = matchmaker_players
assert Search._is_ladder_newbie(pro) is False
assert Search._is_ladder_newbie(newbie)


async def test_is_single_newbie(matchmaker_players):
pro, _, _, _, _, newbie = matchmaker_players

single_newbie = Search([newbie])
single_pro = Search([pro])
two_newbies = Search([newbie, newbie])
two_pros = Search([pro, pro])
two_mixed = Search([newbie, pro])

assert single_newbie.is_single_ladder_newbie()
assert single_pro.is_single_ladder_newbie() is False
assert two_newbies.is_single_ladder_newbie() == False
assert two_pros.is_single_ladder_newbie() == False
assert two_mixed.is_single_ladder_newbie() == False


async def test_newbies_have_adjusted_rating(matchmaker_players):
pro, _, _, _, _, newbie = matchmaker_players
s1, s6 = Search([pro]), Search([newbie])
assert s1.ratings[0] == pro.ratings[RatingType.LADDER_1V1]
assert s6.ratings[0] != newbie.ratings[RatingType.LADDER_1V1]


async def test_search_threshold(matchmaker_players):
Expand Down
Loading

0 comments on commit b04818f

Please sign in to comment.