diff --git a/ipynb/Pickleball.ipynb b/ipynb/Pickleball.ipynb index 206f6a0..679f872 100644 --- a/ipynb/Pickleball.ipynb +++ b/ipynb/Pickleball.ipynb @@ -6,153 +6,557 @@ "source": [ "# Scheduling a Doubles Pickleball Tournament\n", "\n", - "My friend Steve asked for help in creating a schedule for a round-robin doubles pickleball tournament with 8 or 9 players on 2 courts. ([Pickleball](https://en.wikipedia.org/wiki/Pickleball) is a paddle/ball/net game played on a court that is smaller than tennis but larger than ping-pong.) \n", + "My friend Steve asked for help in creating a schedule for a round-robin doubles pickleball tournament with 8 or 9 players on 2 courts. (*To clarify:* [Pickleball](https://en.wikipedia.org/wiki/Pickleball) is a paddle/ball/net game played on a court that is smaller than tennis. In this type of tournament a player plays with a different partner in each game.) \n", "\n", - "To generalize: given *P* players and *C* available courts, we would like to create a **schedule**: a table where each row is a time period (a round of play), each column is a court, and each cell contains a game, which consists of two players partnered together and pitted against two other players. The preferences for the schedule are:\n", + "> Given *P* players and *C* available courts, create a **schedule**: a list of **rounds** of play, where each round consists of from 1 to *C* **games** played simultaneously. Each game pits one **pair** of players against another pair. The **criteria** for a schedule are:\n", "\n", - "- Each player should partner with each other player exactly once (or as close to that as possible).\n", - "- Fewer rounds are better (in other words, try to fill all the courts each round).\n", - "- Each player should play against each other player twice, or as close to that as possible.\n", - "- A player should not be scheduled to play two games at the same time.\n", + "> 1. Each player should partner *with* each other player once (or as close to that as possible).\n", + "2. Each player should play *against* each other player twice (or as close to that as possible).\n", + "3. Each court should be filled each round (or as close to that as possible); in other words, fewer rounds are better.\n", + "4. A player *cannot* be scheduled to play twice in the same round.\n", "\n", - "For example, here's a perfect schedule for *P*=8 players on *C*=2 courts:\n", + "For example, here's a schedule for *P*=8 players on *C*=2 courts. It says that in the first round, players 4 and 6 partner against 2 and 3 on one court, while 5 and 7 partner against 0 and 1 on the other court.\n", "\n", - " [([[1, 6], [2, 4]], [[3, 5], [7, 0]]),\n", - " ([[1, 5], [3, 6]], [[2, 0], [4, 7]]),\n", - " ([[2, 3], [6, 0]], [[4, 5], [1, 7]]),\n", - " ([[4, 6], [3, 7]], [[1, 2], [5, 0]]),\n", - " ([[1, 0], [6, 7]], [[3, 4], [2, 5]]),\n", - " ([[2, 6], [5, 7]], [[1, 4], [3, 0]]),\n", - " ([[2, 7], [1, 3]], [[4, 0], [5, 6]])]\n", - " \n", - "This means that in the first round, players 1 and 6 partner against 2 and 4 on one court, while 3 and 5 partner against 7 and 0 on the other. There are 7 rounds.\n", + " Round 1: | 4,6 vs 2,3 | 5,7 vs 0,1 |\n", + " Round 2: | 0,2 vs 1,3 | 4,5 vs 6,7 |\n", + " Round 3: | 5,6 vs 0,3 | 1,2 vs 4,7 |\n", + " Round 4: | 0,4 vs 3,6 | 2,7 vs 1,5 |\n", + " Round 5: | 0,5 vs 1,4 | 2,6 vs 3,7 |\n", + " Round 6: | 0,6 vs 2,5 | 1,7 vs 3,4 |\n", + " Round 7: | 3,5 vs 1,6 | 2,4 vs 0,7 |\n", + " \n", + "This is a pretty good schedule—it is optimal according to criteria 1, 3, and 4, but it is not optimal in terms of who plays against who; for example players 1 and 5 play 3 times, not 2. We will see if we can do better. Our overall strategy is as follows:\n", "\n", - "My strategy for finding a good schedule is to use **hillclimbing**: start with an initial schedule, then repeatedly alter the schedule by swapping partners in one game with partners in another. If the altered schedule is better, keep it; if not, discard it. Repeat. \n", + "- To satisfy criterion 1, we will start with a list of all pairs of players. The function `all_pairs` does this.\n", + "- We will then call `make_games` to take these pairs and put them together into a list of games, strictly enforcing criterion 4 that a player can't be scheduled into both sides of the net in any one game.\n", + "- Next we call `schedule` to take the list of games and put them into a schedule with up to *C* games played at the same time. We will again strictly enforce criterion 4, not allowing a player to appear on two courts at the same time.\n", + "- If this approach does not result in everybody playing everyone else twice we will randomly pick two games, and swap one sode of the net in one game with one side of the net in the other game, and check if that is an improvement.\n", "\n", - "## Coding it up\n", + "# Implementation\n", "\n", - "The strategy in more detail:\n", - "\n", - "- First form all pairs of players, using `all_pairs(P)`.\n", - "- Put pairs together to form a list of games using `initial_games`.\n", - "- Use `Schedule` to create a schedule; it calls `one_round` to create each round and `scorer` to evaluate the schedule.\n", - "- Use `hillclimb` to improve the initial schedule: call `alter` to randomly alter a schedule, `Schedule` to re-allocate the games to rounds and courts, and `scorer` to check if the altered schedule's score is better.\n", - "\n", - "\n", - "\n", - "(Note: with *P* players there are *P × (P - 1) / 2* pairs of partners; this is an even number when either *P* or *P - 1* is divisible by 4, so everything works out when, say, *P*=4 or *P*=9, but for, say, *P*=10 there are 45 pairs, and so `initial_games` chooses to create 22 games, meaning that one pair of players never play together, and thus play one fewer game than everyone else.)" + "Let's start with some imports and some choices for basic types:" ] }, { "cell_type": "code", "execution_count": 1, - "metadata": {}, + "metadata": { + "collapsed": true + }, "outputs": [], "source": [ - "import random\n", + "import random; random.seed('reproducible')\n", "from itertools import combinations\n", "from collections import Counter\n", "\n", - "#### Types\n", + "Player = int # A player is an int: `1`\n", + "Pair = tuple # A pair is a tuple of two players who are partners: `(1, 2)`\n", + "Game = list # A game is a list of two pairs: `[(1, 2), (3, 4)]`\n", + "Round = tuple # A round is a tuple of games: `([(1, 2), (3, 4)], [(5, 6), (7, 8)])`\n", + "Schedule = list # A schedule is a list of rounds" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# `all_pairs(P)`\n", "\n", - "Player = int # A player is an int: `1`\n", - "Pair = list # A pair is a list of two players who are partners: `[1, 2]`\n", - "Game = list # A game is a list of two pairs: `[[1, 2], [3, 4]]`\n", - "Round = tuple # A round is a tuple of games: `([[1, 2], [3, 4]], [[5, 6], [7, 8]])`\n", + "We will generate all pairs of players like this:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "def all_pairs(P: int) -> [Pair]: return list(combinations(range(P), 2))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "all_pairs(4)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "[(0, 1),\n", + " (0, 2),\n", + " (0, 3),\n", + " (0, 4),\n", + " (0, 5),\n", + " (1, 2),\n", + " (1, 3),\n", + " (1, 4),\n", + " (1, 5),\n", + " (2, 3),\n", + " (2, 4),\n", + " (2, 5),\n", + " (3, 4),\n", + " (3, 5),\n", + " (4, 5)]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "all_pairs(6)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This looks good!\n", "\n", - "class Schedule(list):\n", - " \"\"\"A Schedule is a list of rounds (augmented with a score and court count).\"\"\"\n", - " def __init__(self, games, courts=2):\n", - " games = list(games)\n", - " while games: # Allocate games to courts, one round at a time\n", - " self.append(one_round(games, courts))\n", - " self.score = scorer(self)\n", - " self.courts = courts\n", - " \n", - "#### Functions\n", - " \n", - "def hillclimb(P, C=2, N=100000):\n", - " \"Schedule games for P players on C courts by randomly altering schedule N times.\"\n", - " sched = Schedule(initial_games(all_pairs(P)), C)\n", - " for _ in range(N):\n", - " sched = max(alter(sched), sched, key=lambda s: s.score)\n", + "# `make_games(pairs)`\n", + "\n", + "Now let's take those pairs and place them together into games. We'll choose one pair of players, `A`, and then another pair `B` such that between them there are 4 different players. Then we'll try to make `other_games` out of the remaining pairs. If we can't, we'll make a different choice for `B`. " + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "def make_games(pairs) -> [Game]:\n", + " \"Combine pairs of players into a list of games.\"\n", + " if len(pairs) < 2:\n", + " return []\n", + " A = pairs[0]\n", + " for B in pairs:\n", + " if len(set(A + B)) == 4:\n", + " game = [A, B]\n", + " other_games = make_games([p for p in pairs if p not in game])\n", + " if other_games is not None:\n", + " return [game] + other_games" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "[[(0, 1), (2, 3)], [(0, 2), (1, 3)], [(0, 3), (1, 2)]]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "make_games(all_pairs(4))" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "[[(0, 1), (2, 3)],\n", + " [(0, 2), (1, 3)],\n", + " [(0, 3), (1, 2)],\n", + " [(0, 4), (1, 5)],\n", + " [(0, 5), (1, 4)],\n", + " [(2, 4), (3, 5)],\n", + " [(2, 5), (3, 4)]]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "make_games(all_pairs(6))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The astute reader may have noticed that `all_pairs(6)` has 15 pairs, and from that we can only make 7 games, not 7.5. We must drop one of the pairs, meaning that two players will never partner with each other, and will end up playing one less game than everyone else. Since there are *P* × *P*-1 / 2 pairs for *P* players, that means we will get an even number of pairs whenever either *P* or *P*-1 is divisble by 4." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "[[(0, 1), (2, 3)],\n", + " [(0, 2), (1, 3)],\n", + " [(0, 3), (1, 2)],\n", + " [(0, 4), (1, 5)],\n", + " [(0, 5), (1, 4)],\n", + " [(0, 6), (1, 7)],\n", + " [(0, 7), (1, 6)],\n", + " [(2, 4), (3, 5)],\n", + " [(2, 5), (3, 4)],\n", + " [(2, 6), (3, 7)],\n", + " [(2, 7), (3, 6)],\n", + " [(4, 5), (6, 7)],\n", + " [(4, 6), (5, 7)],\n", + " [(4, 7), (5, 6)]]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "make_games(all_pairs(8))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "That looks good. Note that `make_games` does not ensure that each player plays every other player twice—we'll worry about that later.\n", + "\n", + "# `schedule(games, courts)`\n", + "\n", + "Now we need to schedule games onto courts, such that no player plays twice in any round, and we take as few rounds as possible. We'll define `schedule` to produce a `list` of rounds, where each round is a tuple of up to `courts` games. We'll use a greedy approach to assigning games to rounds; this may result in more rounds than is optimal." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "def schedule(games, courts=2):\n", + " games = list(games) # Don't modify the input\n", + " sched = Schedule()\n", + " while games:\n", + " round = []\n", + " # A round gets up to `courts` games, all with disjoint players.\n", + " for game in list(games):\n", + " if len(round) < courts and disjoint(players(round), players(game)):\n", + " round.append(game)\n", + " games.remove(game)\n", + " sched.append(Round(round))\n", " return sched\n", "\n", - "def all_pairs(P): return list(combinations(range(P), 2))\n", + "def disjoint(A, B): return not (A & B)\n", "\n", - "def initial_games(pairs):\n", - " \"\"\"An initial list of games: [[[1, 2], [3, 4]], ...].\n", - " We try to have every pair play every other pair once, and\n", - " have each game have 4 different players, but that isn't always true.\"\"\"\n", - " random.shuffle(pairs)\n", - " games = []\n", - " while len(pairs) >= 2:\n", - " A = pairs.pop()\n", - " B = first(pair for pair in pairs if disjoint(pair, A)) or pairs[0]\n", - " games.append([A, B])\n", - " pairs.remove(B)\n", - " return games\n", + "def players(x):\n", + " return ({x} if isinstance(x, Player) else set().union(*map(players, x)))" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "[([(0, 1), (2, 3)], [(4, 5), (6, 7)]),\n", + " ([(0, 2), (1, 3)], [(4, 6), (5, 7)]),\n", + " ([(0, 3), (1, 2)], [(4, 7), (5, 6)]),\n", + " ([(0, 4), (1, 5)], [(2, 6), (3, 7)]),\n", + " ([(0, 5), (1, 4)], [(2, 7), (3, 6)]),\n", + " ([(0, 6), (1, 7)], [(2, 4), (3, 5)]),\n", + " ([(0, 7), (1, 6)], [(2, 5), (3, 4)])]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "schedule(make_games(all_pairs(8)))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "That looks pretty good—we fit all the games into the minimum number of rounds. But the opponents are not evenly distributed. For example, player 0 and player 1 play against each other in every round except the first (where they are partners).\n", "\n", - "def disjoint(A, B): \n", - " \"Do A and B have disjoint players in them?\"\n", - " return not (players(A) & players(B))\n", - "\n", - "def one_round(games, courts):\n", - " \"\"\"Place up to `courts` games into `round`, all with disjoint players.\"\"\"\n", - " round = []\n", - " while True:\n", - " G = first(g for g in games if disjoint(round, g))\n", - " if not G or not games or len(round) == courts:\n", - " return Round(round)\n", - " round.append(G)\n", - " games.remove(G)\n", - "\n", - "def players(x): \n", - " \"All distinct players in a pair, game, or sequence of games.\"\n", - " return {x} if isinstance(x, Player) else set().union(*map(players, x))\n", - "\n", - "def first(items): return next(items, None)\n", - "\n", - "def pairing(p1, p2): return tuple(sorted([p1, p2]))\n", - " \n", - "def scorer(sched):\n", - " \"Score has penalties for a non-perfect schedule.\"\n", - " penalty = 50 * len(sched) # More rounds are worse (avoid empty courts)\n", - " penalty += 1000 * sum(len(players(game)) != 4 # A game should have 4 players!\n", - " for round in sched for game in round)\n", - " penalty += 1 * sum(abs(c - 2) ** 3 + 8 * (c == 0) # Try to play everyone twice\n", - " for c in opponents(sched).values())\n", - " return -penalty\n", - " \n", - "def opponents(sched):\n", - " \"A Counter of {(player, opponent): times_played}.\"\n", - " return Counter(pairing(p1, p2) \n", - " for round in sched for A, B in round for p1 in A for p2 in B)\n", - " \n", - "def alter(sched):\n", - " \"Modify a schedule by swapping two pairs.\"\n", - " games = [Game(game) for round in sched for game in round] \n", - " G = len(games)\n", - " i, j = random.sample(range(G), 2) # index into games\n", - " a, b = random.choice((0, 1)), random.choice((0, 1)) # index into each game\n", - " games[i][a], games[j][b] = games[j][b], games[i][a]\n", - " return Schedule(games, sched.courts)\n", + "How can we improve that? We can try *shuffling* the pairs before we make games. " + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "text/plain": [ + "[([(0, 6), (3, 5)],),\n", + " ([(5, 6), (4, 7)],),\n", + " ([(0, 4), (1, 5)],),\n", + " ([(3, 4), (2, 7)],),\n", + " ([(1, 3), (4, 6)], [(0, 2), (5, 7)]),\n", + " ([(6, 7), (1, 2)],),\n", + " ([(3, 7), (1, 4)], [(2, 6), (0, 5)]),\n", + " ([(3, 6), (2, 4)],),\n", + " ([(4, 5), (0, 1)],),\n", + " ([(0, 7), (1, 6)],),\n", + " ([(2, 5), (0, 3)],),\n", + " ([(1, 7), (2, 3)],)]" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "def shuffled(iterable):\n", + " \"Return a shuffled list of the items in iterable.\"\n", + " items = list(iterable)\n", + " random.shuffle(items)\n", + " return items \n", "\n", + "schedule(make_games(shuffled(all_pairs(8))))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Clearly that made things worse in terms of the number of rounds. But did it even out the distribution of opponents? I'll define a function, `report` to make it easier to see what is going on:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ "def report(sched):\n", " \"Print information about this schedule.\"\n", " for i, round in enumerate(sched, 1):\n", - " print('Round {}: {}'.format(i, '; '.join('{} vs {}'.format(*g) for g in round)))\n", + " print('Round {:2}: | {} |'.format(i, games_str(round)))\n", " games = sum(sched, ())\n", " P = len(players(sched))\n", - " print('\\n{} games in {} rounds for {} players'.format(len(games), len(sched), P))\n", - " opp = opponents(sched)\n", - " fmt = ('{:2X}|' + P * ' {}' + ' {}').format\n", - " print('Number of times each player plays against each opponent:\\n')\n", - " print(' |', *map('{:X}'.format, range(P)), ' Total')\n", + " opp = opponents(games)\n", + " fmt = ('{:2X}|' + P * ' {}' + ' {:g}').format\n", + " print('\\nNumber of times each player plays against each opponent:\\n')\n", + " print(' |', *map('{:X}'.format, range(P)), ' Games')\n", " print('--+' + '--' * P + ' -----')\n", " for row in range(P):\n", " counts = [opp[pairing(row, col)] for col in range(P)]\n", - " print(fmt(row, *[c or '-' for c in counts], sum(counts) // 2))" + " print(fmt(row, *[c or '-' for c in counts], sum(counts) / 2))\n", + " \n", + "def games_str(round):\n", + " return ' | '.join('{:X},{:X} vs {:X},{:X}'\n", + " .format(a, b, c, d) for ((a, b), (c, d)) in round)\n", + " \n", + "def opponents(games):\n", + " \"A Counter of {(player, opponent): times_played}.\"\n", + " return Counter(pairing(p1, p2) for A, B in games for p1 in A for p2 in B)\n", + "\n", + "def pairing(p1, p2): return min(p1, p2), max(p1, p2)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we can compare the shuffled and non-shuffled versions:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Round 1: | 0,1 vs 2,3 | 4,5 vs 6,7 |\n", + "Round 2: | 0,2 vs 1,3 | 4,6 vs 5,7 |\n", + "Round 3: | 0,3 vs 1,2 | 4,7 vs 5,6 |\n", + "Round 4: | 0,4 vs 1,5 | 2,6 vs 3,7 |\n", + "Round 5: | 0,5 vs 1,4 | 2,7 vs 3,6 |\n", + "Round 6: | 0,6 vs 1,7 | 2,4 vs 3,5 |\n", + "Round 7: | 0,7 vs 1,6 | 2,5 vs 3,4 |\n", + "\n", + "Number of times each player plays against each opponent:\n", + "\n", + " | 0 1 2 3 4 5 6 7 Games\n", + "--+---------------- -----\n", + " 0| - 6 2 2 1 1 1 1 7\n", + " 1| 6 - 2 2 1 1 1 1 7\n", + " 2| 2 2 - 6 1 1 1 1 7\n", + " 3| 2 2 6 - 1 1 1 1 7\n", + " 4| 1 1 1 1 - 6 2 2 7\n", + " 5| 1 1 1 1 6 - 2 2 7\n", + " 6| 1 1 1 1 2 2 - 6 7\n", + " 7| 1 1 1 1 2 2 6 - 7\n" + ] + } + ], + "source": [ + "report(schedule(make_games(all_pairs(8))))" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Round 1: | 1,4 vs 0,5 |\n", + "Round 2: | 5,7 vs 3,4 |\n", + "Round 3: | 3,5 vs 2,6 |\n", + "Round 4: | 1,5 vs 2,7 |\n", + "Round 5: | 3,6 vs 0,1 |\n", + "Round 6: | 0,6 vs 2,3 |\n", + "Round 7: | 6,7 vs 1,3 | 4,5 vs 0,2 |\n", + "Round 8: | 4,6 vs 1,7 |\n", + "Round 9: | 3,7 vs 2,4 |\n", + "Round 10: | 4,7 vs 1,6 |\n", + "Round 11: | 5,6 vs 0,3 |\n", + "Round 12: | 2,5 vs 0,7 |\n", + "Round 13: | 1,2 vs 0,4 |\n", + "\n", + "Number of times each player plays against each opponent:\n", + "\n", + " | 0 1 2 3 4 5 6 7 Games\n", + "--+---------------- -----\n", + " 0| - 2 3 2 2 3 2 - 7\n", + " 1| 2 - 1 1 3 1 3 3 7\n", + " 2| 3 1 - 2 2 3 1 2 7\n", + " 3| 2 1 2 - 1 2 4 2 7\n", + " 4| 2 3 2 1 - 2 1 3 7\n", + " 5| 3 1 3 2 2 - 1 2 7\n", + " 6| 2 3 1 4 1 1 - 2 7\n", + " 7| - 3 2 2 3 2 2 - 7\n" + ] + } + ], + "source": [ + "report(schedule(make_games(shuffled(all_pairs(8)))))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We see that shuffling helps a lot in terms of evening out the opponents, but it does a bad job of filling both courts on each round. \n", + "\n", + "# `pickleball`: Improvement through Hillclimbing\n", + "\n", + "My strategy now is to start with a non-optimal schedule, and repeatedly try to improve it by randomly altering the games and seeing if this results in a better schedule. This is called a **hillclimbing** approach; the analogy is that we start out in a valley, take a step in a random direction, and if that is upward, keep going, otherwise step back and try again. Eventually you reach a peak. \n", + "\n", + "In this case I will be picking two games at random, and swapping one pair of partners in one game with one pair of partners in the other. If the swap makes things worse, discard it; if it makes things better, keep it. Either way, try `N` swaps. I measure \"better\" both in terms of the variation from the optimal distribution of opponents (as measured by `opp_difference(games, pairs)`) and in terms of the number of rounds (as measured by `len(sched)`). " + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "def pickleball(P, courts=2, N=100000):\n", + " \"Schedule games for P players on C courts by randomly swapping game opponents N times.\"\n", + " pairs = all_pairs(P)\n", + " games = make_games((pairs))\n", + " diff = opp_difference(games, pairs)\n", + " sched = schedule(games, courts)\n", + " for _ in range(N):\n", + " # Randomly swap pairs from two games\n", + " ((i, j), _) = idx = indexes(games)\n", + " swap(games, idx)\n", + " diff2 = opp_difference(games, pairs)\n", + " # Keep the swap if better (or same); revert if worse\n", + " if (diff2 <= diff and len(schedule(games, courts)) <= len(sched) and\n", + " len(players(games[i])) == 4 == len(players(games[j]))):\n", + " sched, diff = schedule(games, courts), diff2\n", + " else:\n", + " swap(games, idx)\n", + " return sched\n", + "\n", + "def indexes(games):\n", + " \"Random indexes into games, and into sides of the net in each game.\"\n", + " sides = ((0, 0), (1, 1), (0, 1), (1, 0))\n", + " return random.sample(range(len(games)), 2), random.choice(sides)\n", + "\n", + "def swap(games, idx):\n", + " \"Swap the partners at games[g1][a] with games[g2][b].\"\n", + " (g1, g2), (a, b) = idx\n", + " games[g1][a], games[g2][b] = games[g2][b], games[g1][a]\n", + "\n", + "def opp_difference(games, pairs, optimal=2):\n", + " \"The total difference from an optimal distribution of opponents.\"\n", + " opp = opponents(games)\n", + " return sum(abs(opp[pair] - optimal) ** 3\n", + " for pair in pairs)" ] }, { @@ -161,30 +565,78 @@ "source": [ "# 8 Player Tournament\n", "\n", - "I achieved (in a previous run) a perfect schedule for 8 players: the 14 games fit into 7 rounds, each player partners with each other once, and plays each individual opponent twice:" + "Let's create an 8-player tournament:" ] }, { "cell_type": "code", - "execution_count": 2, - "metadata": {}, + "execution_count": 16, + "metadata": { + "collapsed": false + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Round 1: [1, 6] vs [2, 4]; [3, 5] vs [7, 0]\n", - "Round 2: [1, 5] vs [3, 6]; [2, 0] vs [4, 7]\n", - "Round 3: [2, 3] vs [6, 0]; [4, 5] vs [1, 7]\n", - "Round 4: [4, 6] vs [3, 7]; [1, 2] vs [5, 0]\n", - "Round 5: [1, 0] vs [6, 7]; [3, 4] vs [2, 5]\n", - "Round 6: [2, 6] vs [5, 7]; [1, 4] vs [3, 0]\n", - "Round 7: [2, 7] vs [1, 3]; [4, 0] vs [5, 6]\n", + "Round 1: | 0,1 vs 2,3 | 4,6 vs 5,7 |\n", + "Round 2: | 4,5 vs 1,3 | 0,2 vs 6,7 |\n", + "Round 3: | 0,3 vs 5,6 | 4,7 vs 1,2 |\n", + "Round 4: | 2,7 vs 1,5 | 0,4 vs 3,6 |\n", + "Round 5: | 0,5 vs 1,4 | 2,6 vs 3,7 |\n", + "Round 6: | 1,7 vs 0,6 | 3,4 vs 2,5 |\n", + "Round 7: | 0,7 vs 3,5 | 2,4 vs 1,6 |\n", "\n", - "14 games in 7 rounds for 8 players\n", "Number of times each player plays against each opponent:\n", "\n", - " | 0 1 2 3 4 5 6 7 Total\n", + " | 0 1 2 3 4 5 6 7 Games\n", + "--+---------------- -----\n", + " 0| - 2 1 3 1 2 3 2 7\n", + " 1| 2 - 3 1 3 2 1 2 7\n", + " 2| 1 3 - 2 2 1 2 3 7\n", + " 3| 3 1 2 - 2 3 2 1 7\n", + " 4| 1 3 2 2 - 3 2 1 7\n", + " 5| 2 2 1 3 3 - 1 2 7\n", + " 6| 3 1 2 2 2 1 - 3 7\n", + " 7| 2 2 3 1 1 2 3 - 7\n", + "CPU times: user 29.4 s, sys: 487 ms, total: 29.9 s\n", + "Wall time: 33 s\n" + ] + } + ], + "source": [ + "%time report(pickleball(8, 2))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "That's pretty good, but not perfect. In a previous run I was luckier and achieved a perfect schedule for 8 players (where every player plays each opponent exactly twice): " + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Round 1: | 1,6 vs 2,4 | 3,5 vs 7,0 |\n", + "Round 2: | 1,5 vs 3,6 | 2,0 vs 4,7 |\n", + "Round 3: | 2,3 vs 6,0 | 4,5 vs 1,7 |\n", + "Round 4: | 4,6 vs 3,7 | 1,2 vs 5,0 |\n", + "Round 5: | 1,0 vs 6,7 | 3,4 vs 2,5 |\n", + "Round 6: | 2,6 vs 5,7 | 1,4 vs 3,0 |\n", + "Round 7: | 2,7 vs 1,3 | 4,0 vs 5,6 |\n", + "\n", + "Number of times each player plays against each opponent:\n", + "\n", + " | 0 1 2 3 4 5 6 7 Games\n", "--+---------------- -----\n", " 0| - 2 2 2 2 2 2 2 7\n", " 1| 2 - 2 2 2 2 2 2 7\n", @@ -199,13 +651,14 @@ ], "source": [ "report([\n", - " ([[1, 6], [2, 4]], [[3, 5], [7, 0]]),\n", - " ([[1, 5], [3, 6]], [[2, 0], [4, 7]]),\n", - " ([[2, 3], [6, 0]], [[4, 5], [1, 7]]),\n", - " ([[4, 6], [3, 7]], [[1, 2], [5, 0]]),\n", - " ([[1, 0], [6, 7]], [[3, 4], [2, 5]]),\n", - " ([[2, 6], [5, 7]], [[1, 4], [3, 0]]),\n", - " ([[2, 7], [1, 3]], [[4, 0], [5, 6]]) ])" + " ([(1, 6), (2, 4)], [(3, 5), (7, 0)]),\n", + " ([(1, 5), (3, 6)], [(2, 0), (4, 7)]),\n", + " ([(2, 3), (6, 0)], [(4, 5), (1, 7)]),\n", + " ([(4, 6), (3, 7)], [(1, 2), (5, 0)]),\n", + " ([(1, 0), (6, 7)], [(3, 4), (2, 5)]),\n", + " ([(2, 6), (5, 7)], [(1, 4), (3, 0)]),\n", + " ([(2, 7), (1, 3)], [(4, 0), (5, 6)]), \n", + "])" ] }, { @@ -214,32 +667,33 @@ "source": [ "# 9 Player Tournament\n", "\n", - "For 9 players, I can fit the 18 games into 9 rounds, but some players play each other 1 or 3 times:" + "For 9 players, I can fit the 18 games into 9 rounds, but some players play each other 1 or 3 times. I'll report the results of a previous run:" ] }, { "cell_type": "code", - "execution_count": 3, - "metadata": {}, + "execution_count": 18, + "metadata": { + "collapsed": false + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Round 1: [1, 7] vs [4, 0]; [3, 5] vs [2, 6]\n", - "Round 2: [2, 7] vs [1, 3]; [4, 8] vs [6, 0]\n", - "Round 3: [5, 0] vs [1, 6]; [7, 8] vs [3, 4]\n", - "Round 4: [7, 0] vs [5, 8]; [1, 2] vs [4, 6]\n", - "Round 5: [3, 8] vs [1, 5]; [2, 0] vs [6, 7]\n", - "Round 6: [1, 4] vs [2, 5]; [3, 6] vs [8, 0]\n", - "Round 7: [5, 6] vs [4, 7]; [1, 8] vs [2, 3]\n", - "Round 8: [1, 0] vs [3, 7]; [2, 8] vs [4, 5]\n", - "Round 9: [3, 0] vs [2, 4]; [6, 8] vs [5, 7]\n", + "Round 1: | 1,7 vs 4,0 | 3,5 vs 2,6 |\n", + "Round 2: | 2,7 vs 1,3 | 4,8 vs 6,0 |\n", + "Round 3: | 5,0 vs 1,6 | 7,8 vs 3,4 |\n", + "Round 4: | 7,0 vs 5,8 | 1,2 vs 4,6 |\n", + "Round 5: | 3,8 vs 1,5 | 2,0 vs 6,7 |\n", + "Round 6: | 1,4 vs 2,5 | 3,6 vs 8,0 |\n", + "Round 7: | 5,6 vs 4,7 | 1,8 vs 2,3 |\n", + "Round 8: | 1,0 vs 3,7 | 2,8 vs 4,5 |\n", + "Round 9: | 3,0 vs 2,4 | 6,8 vs 5,7 |\n", "\n", - "18 games in 9 rounds for 9 players\n", "Number of times each player plays against each opponent:\n", "\n", - " | 0 1 2 3 4 5 6 7 8 Total\n", + " | 0 1 2 3 4 5 6 7 8 Games\n", "--+------------------ -----\n", " 0| - 2 1 2 2 1 3 3 2 8\n", " 1| 2 - 3 3 2 2 1 2 1 8\n", @@ -255,77 +709,22 @@ ], "source": [ "report([\n", - " ([[1, 7], [4, 0]], [[3, 5], [2, 6]]),\n", - " ([[2, 7], [1, 3]], [[4, 8], [6, 0]]),\n", - " ([[5, 0], [1, 6]], [[7, 8], [3, 4]]),\n", - " ([[7, 0], [5, 8]], [[1, 2], [4, 6]]),\n", - " ([[3, 8], [1, 5]], [[2, 0], [6, 7]]),\n", - " ([[1, 4], [2, 5]], [[3, 6], [8, 0]]),\n", - " ([[5, 6], [4, 7]], [[1, 8], [2, 3]]),\n", - " ([[1, 0], [3, 7]], [[2, 8], [4, 5]]),\n", - " ([[3, 0], [2, 4]], [[6, 8], [5, 7]]) ])" + " ([(1, 7), (4, 0)], [(3, 5), (2, 6)]),\n", + " ([(2, 7), (1, 3)], [(4, 8), (6, 0)]),\n", + " ([(5, 0), (1, 6)], [(7, 8), (3, 4)]),\n", + " ([(7, 0), (5, 8)], [(1, 2), (4, 6)]),\n", + " ([(3, 8), (1, 5)], [(2, 0), (6, 7)]),\n", + " ([(1, 4), (2, 5)], [(3, 6), (8, 0)]),\n", + " ([(5, 6), (4, 7)], [(1, 8), (2, 3)]),\n", + " ([(1, 0), (3, 7)], [(2, 8), (4, 5)]),\n", + " ([(3, 0), (2, 4)], [(6, 8), (5, 7)]) ])\n", + "\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "# 10 Player Tournament\n", - "\n", - "With *P*=10 there is an odd number of pairings (45), so two players necessarily play one game less than the other players. Let's see what kind of schedule we can come up with:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Round 1: (6, 7) vs (0, 5); (3, 4) vs (2, 8)\n", - "Round 2: (1, 8) vs (0, 3); (7, 9) vs (4, 5)\n", - "Round 3: (3, 6) vs (1, 7); (0, 9) vs (2, 5)\n", - "Round 4: (2, 9) vs (6, 8); (1, 3) vs (4, 7)\n", - "Round 5: (0, 8) vs (5, 7); (4, 6) vs (2, 3)\n", - "Round 6: (2, 4) vs (3, 5); (1, 6) vs (8, 9)\n", - "Round 7: (6, 9) vs (3, 7); (1, 2) vs (5, 8)\n", - "Round 8: (1, 4) vs (5, 9); (0, 7) vs (3, 8)\n", - "Round 9: (1, 5) vs (2, 7); (3, 9) vs (0, 6)\n", - "Round 10: (7, 8) vs (4, 9); (0, 1) vs (2, 6)\n", - "Round 11: (4, 8) vs (5, 6); (0, 2) vs (1, 9)\n", - "\n", - "22 games in 11 rounds for 10 players\n", - "Number of times each player plays against each opponent:\n", - "\n", - " | 0 1 2 3 4 5 6 7 8 9 Total\n", - "--+-------------------- -----\n", - " 0| - 2 2 2 - 2 2 2 2 2 8\n", - " 1| 2 - 3 2 1 2 2 2 2 2 9\n", - " 2| 2 3 - 2 2 3 2 - 2 2 9\n", - " 3| 2 2 2 - 3 - 3 3 2 1 9\n", - " 4| - 1 2 3 - 3 1 2 2 2 8\n", - " 5| 2 2 3 - 3 - 1 3 2 2 9\n", - " 6| 2 2 2 3 1 1 - 2 2 3 9\n", - " 7| 2 2 - 3 2 3 2 - 2 2 9\n", - " 8| 2 2 2 2 2 2 2 2 - 2 9\n", - " 9| 2 2 2 1 2 2 3 2 2 - 9\n", - "CPU times: user 2min 39s, sys: 661 ms, total: 2min 40s\n", - "Wall time: 2min 43s\n" - ] - } - ], - "source": [ - "%time report(hillclimb(P=10))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In this schedule several players never play each other; it may be possible to improve on that (in another run that has better luck with random numbers).\n", - "\n", "# 16 Player Tournament\n", "\n", "Let's jump to 16 players on 4 courts (this will take a while):" @@ -333,68 +732,65 @@ }, { "cell_type": "code", - "execution_count": 5, - "metadata": {}, + "execution_count": 20, + "metadata": { + "collapsed": false + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Round 1: (0, 12) vs (9, 13); (5, 10) vs (11, 15); (6, 8) vs (1, 3); (2, 7) vs (4, 14)\n", - "Round 2: (5, 12) vs (0, 10); (6, 11) vs (3, 9); (8, 15) vs (2, 14)\n", - "Round 3: (12, 15) vs (4, 6); (10, 13) vs (1, 9); (2, 5) vs (8, 11)\n", - "Round 4: (11, 14) vs (0, 9); (3, 13) vs (7, 10); (2, 15) vs (4, 12)\n", - "Round 5: (10, 11) vs (0, 15); (12, 14) vs (5, 13); (1, 8) vs (6, 9); (3, 7) vs (2, 4)\n", - "Round 6: (3, 11) vs (8, 13); (7, 9) vs (5, 15); (1, 6) vs (4, 10); (2, 12) vs (0, 14)\n", - "Round 7: (3, 10) vs (7, 12); (1, 14) vs (5, 11); (6, 13) vs (4, 8)\n", - "Round 8: (4, 5) vs (0, 8); (6, 10) vs (2, 11); (1, 13) vs (9, 15)\n", - "Round 9: (3, 5) vs (2, 9); (10, 15) vs (1, 7); (0, 11) vs (6, 12); (8, 14) vs (4, 13)\n", - "Round 10: (1, 10) vs (3, 8); (6, 7) vs (5, 9); (11, 12) vs (4, 15)\n", - "Round 11: (4, 7) vs (1, 11); (9, 14) vs (10, 12); (0, 6) vs (2, 13)\n", - "Round 12: (10, 14) vs (5, 8); (9, 12) vs (2, 3); (4, 11) vs (7, 13)\n", - "Round 13: (7, 8) vs (0, 13); (3, 12) vs (1, 5); (14, 15) vs (4, 9)\n", - "Round 14: (0, 5) vs (1, 4); (13, 14) vs (3, 15); (9, 10) vs (2, 8)\n", - "Round 15: (0, 3) vs (1, 15); (2, 6) vs (5, 7)\n", - "Round 16: (7, 11) vs (8, 12); (3, 4) vs (5, 14); (6, 15) vs (0, 2)\n", - "Round 17: (3, 14) vs (9, 11); (8, 10) vs (0, 4); (5, 6) vs (7, 15); (1, 2) vs (12, 13)\n", - "Round 18: (0, 1) vs (7, 14); (13, 15) vs (3, 6)\n", - "Round 19: (11, 13) vs (2, 10); (0, 7) vs (8, 9); (6, 14) vs (1, 12)\n", + "Round 1: | 0,1 vs 2,3 | 4,5 vs 6,7 | 8,9 vs D,E | C,F vs A,B |\n", + "Round 2: | 0,2 vs 5,7 | 3,B vs 4,6 | 8,A vs 1,9 | C,D vs E,F |\n", + "Round 3: | 0,3 vs 1,2 | 4,8 vs 5,9 | 6,A vs 7,B | C,E vs D,F |\n", + "Round 4: | 0,4 vs B,C | 2,6 vs A,D | 8,E vs 1,5 | 3,7 vs 9,F |\n", + "Round 5: | 0,5 vs 1,4 | 2,7 vs B,E | A,F vs 9,D | 8,C vs 3,6 |\n", + "Round 6: | 0,6 vs 8,F | A,E vs 3,4 | 1,7 vs B,D | 2,5 vs 9,C |\n", + "Round 7: | 0,7 vs A,C | B,F vs 3,5 | 8,D vs 2,4 | 1,6 vs 9,E |\n", + "Round 8: | 0,9 vs 6,D | 2,B vs 1,8 | 4,E vs 3,A | 5,F vs 7,C |\n", + "Round 9: | 0,A vs 6,E | 5,C vs 3,9 | 1,B vs 4,D | 7,F vs 2,8 |\n", + "Round 10: | 0,B vs 1,A | 2,9 vs 4,F | 3,8 vs 7,D | 5,E vs 6,C |\n", + "Round 11: | 0,C vs 1,D | 2,E vs 5,A | 4,B vs 3,F | 6,9 vs 7,8 |\n", + "Round 12: | 0,D vs 5,B | 2,F vs 3,E | 6,8 vs 1,C | 4,A vs 7,9 |\n", + "Round 13: | 0,E vs 3,D | 1,F vs 6,B | 4,9 vs 2,C | 5,8 vs 7,A |\n", + "Round 14: | 0,F vs 9,A | 2,D vs 5,6 | 8,B vs 3,C | 4,7 vs 1,E |\n", + "Round 15: | 0,8 vs 4,C | 6,F vs 2,A | 9,B vs 7,E | 1,3 vs 5,D |\n", "\n", - "60 games in 19 rounds for 16 players\n", "Number of times each player plays against each opponent:\n", "\n", - " | 0 1 2 3 4 5 6 7 8 9 A B C D E F Total\n", + " | 0 1 2 3 4 5 6 7 8 9 A B C D E F Games\n", "--+-------------------------------- -----\n", - " 0| - 2 2 - 2 2 2 2 3 2 2 2 3 2 2 2 15\n", - " 1| 2 - - 3 2 2 3 2 2 2 3 1 2 2 2 2 15\n", - " 2| 2 - - 2 2 2 3 2 2 2 2 2 3 2 2 2 15\n", - " 3| - 3 2 - 1 2 2 2 2 3 2 2 2 3 2 2 15\n", - " 4| 2 2 2 1 - 2 2 3 3 - 1 2 2 2 3 3 15\n", - " 5| 2 2 2 2 2 - 2 3 2 2 2 2 2 - 3 2 15\n", - " 6| 2 3 3 2 2 2 - 2 2 2 1 2 2 2 - 3 15\n", - " 7| 2 2 2 2 3 3 2 - 2 2 2 2 1 2 1 2 15\n", - " 8| 3 2 2 2 3 2 2 2 - 2 3 2 - 3 2 - 15\n", - " 9| 2 2 2 3 - 2 2 2 2 - 2 2 2 2 3 2 15\n", - " A| 2 3 2 2 1 2 1 2 3 2 - 3 2 2 1 2 15\n", - " B| 2 1 2 2 2 2 2 2 2 2 3 - 2 2 2 2 15\n", - " C| 3 2 3 2 2 2 2 1 - 2 2 2 - 2 3 2 15\n", - " D| 2 2 2 3 2 - 2 2 3 2 2 2 2 - 2 2 15\n", - " E| 2 2 2 2 3 3 - 1 2 3 1 2 3 2 - 2 15\n", - " F| 2 2 2 2 3 2 3 2 - 2 2 2 2 2 2 - 15\n", - "CPU times: user 15min 6s, sys: 2.67 s, total: 15min 9s\n", - "Wall time: 15min 16s\n" + " 0| - 4 2 2 2 2 2 1 1 1 3 2 3 3 1 1 15\n", + " 1| 4 - 2 2 2 2 2 1 3 1 1 4 1 3 2 - 15\n", + " 2| 2 2 - 2 2 3 2 2 2 2 2 1 1 2 2 3 15\n", + " 3| 2 2 2 - 3 2 1 1 2 1 1 3 2 2 3 3 15\n", + " 4| 2 2 2 3 - 2 1 2 2 3 2 3 2 1 2 1 15\n", + " 5| 2 2 3 2 2 - 2 3 2 2 1 1 3 2 2 1 15\n", + " 6| 2 2 2 1 1 2 - 2 3 2 3 2 2 2 2 2 15\n", + " 7| 1 1 2 1 2 3 2 - 3 3 3 3 1 1 2 2 15\n", + " 8| 1 3 2 2 2 2 3 3 - 3 1 1 3 2 1 1 15\n", + " 9| 1 1 2 1 3 2 2 3 3 - 3 - 2 2 2 3 15\n", + " A| 3 1 2 1 2 1 3 3 1 3 - 2 1 1 3 3 15\n", + " B| 2 4 1 3 3 1 2 3 1 - 2 - 2 2 1 3 15\n", + " C| 3 1 1 2 2 3 2 1 3 2 1 2 - 2 2 3 15\n", + " D| 3 3 2 2 1 2 2 1 2 2 1 2 2 - 3 2 15\n", + " E| 1 2 2 3 2 2 2 2 1 2 3 1 2 3 - 2 15\n", + " F| 1 - 3 3 1 1 2 2 1 3 3 3 3 2 2 - 15\n", + "CPU times: user 2min 4s, sys: 977 ms, total: 2min 5s\n", + "Wall time: 2min 8s\n" ] } ], "source": [ - "%time report(hillclimb(P=16, C=4))" + "%time report(pickleball(P=16, courts=4))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "We get a pretty good schedule, although it takes 19 rounds rather than the 15 it would take if every court was filled, and again there are some players who never face each other." + "That's a very good schedule. It takes the minimum 15 rounds, and while not everyone playes everyone else 2 times, most are in the 1 to 3 range (with a couple of 4s and 0s). " ] } ], @@ -414,7 +810,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.5.3" + "version": "3.6.0" } }, "nbformat": 4,