Source code for freeride.games

"""Game theory utilities."""

import random
from typing import List, Tuple, Optional, Sequence

import numpy as np
import matplotlib.pyplot as plt


def _convex_hull(points: np.ndarray) -> np.ndarray:
    """Return points on the convex hull in counter-clockwise order."""
    pts = sorted(set(map(tuple, points)))
    if len(pts) <= 1:
        return np.asarray(pts)

    def cross(o, a, b):
        return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])

    lower = []
    for p in pts:
        while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
            lower.pop()
        lower.append(p)

    upper = []
    for p in reversed(pts):
        while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
            upper.pop()
        upper.append(p)

    hull = lower[:-1] + upper[:-1]
    return np.asarray(hull)

[docs] class Game: """Simple two-player game. Parameters ---------- payoffs1 : array-like Payoff matrix for player 1. Rows correspond to player 1 actions and columns correspond to player 2 actions. payoffs2 : array-like Payoff matrix for player 2 with the same shape as ``payoffs1``. player_names : sequence of str, optional Names for the row and column players. If omitted the row player name is randomly chosen from ``{"Anna", "Alice"}`` and the column player name from ``{"Boris", "Bob"}``. """
[docs] def __init__( self, payoffs1, payoffs2, *, player_names: Optional[Sequence[str]] = None, action_names: Sequence[Sequence[str]] = ( ("action 0", "action 1"), ("action 0", "action 1"), ), ): self.payoffs1 = np.asarray(payoffs1, dtype=float) self.payoffs2 = np.asarray(payoffs2, dtype=float) if player_names is None: player_names = ( random.choice(["Anna", "Alice"]), random.choice(["Boris", "Bob"]), ) self.player_names = tuple(player_names) self.action_names = ( tuple(action_names[0]), tuple(action_names[1]), ) if self.payoffs1.shape != self.payoffs2.shape: raise ValueError("Payoff matrices must have the same shape") if self.payoffs1.ndim != 2: raise ValueError("Payoff matrices must be 2-dimensional")
@property def shape(self) -> Tuple[int, int]: return self.payoffs1.shape def __repr__(self) -> str: """Return a short ASCII summary of the game.""" p1, p2 = self.player_names rows, cols = self.shape row_actions = list(self.action_names[0])[:rows] col_actions = list(self.action_names[1])[:cols] row_width = max([len(r) for r in row_actions] + [0]) + 2 lines = [f"{p1} \\ {p2}"] header = " " * row_width + " ".join(f"{n:>12}" for n in col_actions) lines.append(header) for i, rname in enumerate(row_actions): cells = [ f"({self.payoffs1[i, j]:g}, {self.payoffs2[i, j]:g})" for j in range(cols) ] line = f"{rname:>{row_width}}" + " ".join(f"{c:>12}" for c in cells) lines.append(line) return "\n".join(lines)
[docs] def best_response(self, profile: Tuple[int, int]) -> List[int]: """Return a best response profile to ``profile``. Parameters ---------- profile : tuple of int Current actions ``(i, j)`` for players A and B. """ i, j = profile br_i = i best_payoff_i = self.payoffs1[i, j] for alt in range(self.shape[0]): payoff = self.payoffs1[alt, j] if payoff > best_payoff_i: best_payoff_i = payoff br_i = alt br_j = j best_payoff_j = self.payoffs2[i, j] for alt in range(self.shape[1]): payoff = self.payoffs2[i, alt] if payoff > best_payoff_j: best_payoff_j = payoff br_j = alt return [br_i, br_j]
[docs] def best_responses(self) -> Tuple[List[List[int]], List[List[int]]]: """Return best responses for each pure strategy profile.""" rows, cols = self.shape br1 = [] br2 = [] for j in range(cols): col = self.payoffs1[:, j] max_val = col.max() br1.append([i for i in range(rows) if col[i] == max_val]) for i in range(rows): row = self.payoffs2[i, :] max_val = row.max() br2.append([j for j in range(cols) if row[j] == max_val]) return br1, br2
[docs] def nash_equilibria(self) -> List[Tuple[str, str]]: """Compute pure strategy Nash equilibria. Returns ------- list of tuple of str List of Nash equilibria as strategy profiles (action name pairs). Each equilibrium is a tuple of (player1_action, player2_action). A Nash equilibrium is a strategy profile where no player can unilaterally deviate and improve their payoff. """ br1, br2 = self.best_responses() eqs = [] for j, rows in enumerate(br1): for i in rows: if j in br2[i]: action1 = self.action_names[0][i] action2 = self.action_names[1][j] eqs.append((action1, action2)) return eqs
[docs] def nash(self) -> List[Tuple[str, str]]: """Alias for :meth:`nash_equilibria`.""" return self.nash_equilibria()
[docs] def mixed_strategy_equilibrium(self) -> Tuple[float, float]: """Return mixed strategy equilibrium for 2x2 games.""" if self.shape != (2, 2): raise ValueError("Mixed strategy solver only implemented for 2x2 games") a, b = self.payoffs1[0] c, d = self.payoffs1[1] e, f = self.payoffs2[0] g, h = self.payoffs2[1] denom_q = a - b - c + d denom_p = e - f - g + h if denom_q == 0 or denom_p == 0: raise ValueError("Game has no mixed strategy equilibrium") q = (d - b) / denom_q p = (h - g) / denom_p return p, q
[docs] def weakly_dominant_strategies(self) -> dict: """Return weakly dominant strategies for both players.""" rows, cols = self.shape dom1 = set() dom2 = set() for a in range(rows): dominates = True strictly = False for b in range(rows): if a == b: continue for j in range(cols): if self.payoffs1[a, j] < self.payoffs1[b, j]: dominates = False break if self.payoffs1[a, j] > self.payoffs1[b, j]: strictly = True if not dominates: break if dominates and strictly: dom1.add(a) for a in range(cols): dominates = True strictly = False for b in range(cols): if a == b: continue for i in range(rows): if self.payoffs2[i, a] < self.payoffs2[i, b]: dominates = False break if self.payoffs2[i, a] > self.payoffs2[i, b]: strictly = True if not dominates: break if dominates and strictly: dom2.add(a) return {"A": dom1, "B": dom2}
[docs] def table( self, ax: Optional[plt.Axes] = None, show_solution: bool = True, player_names: Optional[Sequence[str]] = None, action_names: Optional[Sequence[Sequence[str]]] = None, usetex: bool = False, ) -> plt.Axes: """Plot a payoff table. Parameters ---------- ax : matplotlib.axes.Axes, optional Axis on which to draw. If ``None``, uses :func:`matplotlib.pyplot.gca`. show_solution : bool, default ``True`` Highlight best responses and Nash equilibria when ``True``. player_names : sequence of str, optional Names for the row and column players. Defaults to the names stored on the ``Game`` instance. action_names : sequence of sequence of str, optional ``action_names[0]`` are actions for player A and ``action_names[1]`` for player B. Defaults to the names stored on the ``Game`` instance. usetex : bool, default ``False`` Use LaTeX for text rendering and underline best responses when ``True``. When ``False``, best responses are highlighted with colored boxes. Returns ------- matplotlib.axes.Axes The axis containing the table. Notes ----- Supports arbitrary ``rows \times cols`` games. """ rows, cols = self.shape if ax is None: ax = plt.gca() if player_names is None: player_names = self.player_names if action_names is None: action_names = self.action_names prev = plt.rcParams.get("text.usetex", False) if usetex: plt.rcParams["text.usetex"] = True for i in range(rows): for j in range(cols): br = self.best_response((i, j)) a_is_br = br[0] == i b_is_br = br[1] == j location = (j, -i) rec = plt.Rectangle( location, width=-1, height=-1, facecolor="white", edgecolor="black", linewidth=2, ) ax.add_artist(rec) p1, p2 = self.payoffs1[i, j], self.payoffs2[i, j] s1, s2 = str(p1), str(p2) cell_bbox = None bbox1 = None bbox2 = None if show_solution: if usetex: if a_is_br: s1 = r"\underline{" + s1 + r"}" if b_is_br: s2 = r"\underline{" + s2 + r"}" if a_is_br and b_is_br: cell_bbox = dict( facecolor="lightyellow", edgecolor="black", alpha=0.85, ) else: if a_is_br and b_is_br: cell_bbox = dict( facecolor="lightyellow", edgecolor="black", alpha=0.85, ) if a_is_br: bbox1 = dict(facecolor="lightblue", edgecolor="none", alpha=0.5) if b_is_br: bbox2 = dict(facecolor="lightgreen", edgecolor="none", alpha=0.5) if cell_bbox is not None and not usetex: patch = plt.Rectangle( location, width=-1, height=-1, **cell_bbox, ) ax.add_artist(patch) if usetex: text = f"${s1}$, ${s2}$" ax.text( j - 0.5, -i - 0.5, text, va="center", ha="center", size=12, bbox=cell_bbox, ) else: ax.text( j - 0.55, -i - 0.5, f"{s1},", va="center", ha="right", size=12, bbox=bbox1, ) ax.text( j - 0.45, -i - 0.5, s2, va="center", ha="left", size=12, bbox=bbox2, ) ax.set_aspect("equal") ax.set_ylim(-rows - 0.05, 0.05) ax.set_xlim(-1.05, cols - 1 + 0.05) ax.text( -0.08, 0.5, player_names[0], rotation=90, transform=ax.transAxes, ha="right", va="center", size=12, ) for i in range(rows): ax.text( 0, 1 - (i + 0.5) / rows, action_names[0][i], rotation=90, transform=ax.transAxes, ha="right", va="center", size=10, ) ax.text( 0.5, 1.08, player_names[1], rotation=0, transform=ax.transAxes, ha="center", va="bottom", size=12, ) for j in range(cols): ax.text( (j + 0.5) / cols, 1, action_names[1][j], rotation=0, transform=ax.transAxes, ha="center", va="bottom", size=10, ) ax.axis("off") # Restore previous TeX setting plt.rcParams["text.usetex"] = prev return ax
[docs] def plot_payoff_hull( self, ax: Optional[plt.Axes] = None, annotate: bool = False, ) -> plt.Axes: """Plot the convex hull of payoff pairs for the game.""" if ax is None: ax = plt.gca() pts = np.column_stack((self.payoffs1.ravel(), self.payoffs2.ravel())) hull = _convex_hull(pts) if hull.size > 0: closed = np.vstack([hull, hull[0]]) ax.fill(closed[:, 0], closed[:, 1], color="lightgray", alpha=0.5) ax.plot(closed[:, 0], closed[:, 1], color="black", lw=1) ax.scatter(pts[:, 0], pts[:, 1], color="C0") if annotate: rows, cols = self.shape for i in range(rows): for j in range(cols): label = f"({self.action_names[0][i]}, {self.action_names[1][j]})" ax.text( self.payoffs1[i, j], self.payoffs2[i, j], label, fontsize=8, ha="left", va="bottom", ) ax.set_xlabel(f"Payoff to {self.player_names[0]}") ax.set_ylabel(f"Payoff to {self.player_names[1]}") ax.set_title("Payoff Hull") ax.set_aspect("equal", adjustable="datalim") return ax
[docs] @classmethod def prisoners_dilemma(cls) -> "Game": """Return the classic Prisoner's Dilemma game.""" p1 = [[3, 0], [5, 1]] p2 = [[3, 5], [0, 1]] return cls( p1, p2, action_names=( ("Cooperate", "Defect"), ("Cooperate", "Defect"), ), )
[docs] @classmethod def matching_pennies(cls) -> "Game": """Return the Matching Pennies game.""" p1 = [[1, -1], [-1, 1]] p2 = [[-1, 1], [1, -1]] return cls( p1, p2, action_names=( ("Heads", "Tails"), ("Heads", "Tails"), ), )
[docs] @classmethod def stag_hunt(cls) -> "Game": """Return the Stag Hunt coordination game.""" p1 = [[2, 0], [1, 1]] p2 = [[2, 1], [0, 1]] return cls( p1, p2, action_names=( ("Stag", "Hare"), ("Stag", "Hare"), ), )
[docs] @classmethod def battle_of_the_sexes(cls) -> "Game": """Return the Battle of the Sexes coordination game.""" p1 = [[2, 0], [0, 1]] p2 = [[1, 0], [0, 2]] return cls( p1, p2, player_names=("Anna", "Boris"), action_names=(("Opera", "Boxing Match"), ("Opera", "Boxing Match")), )
[docs] @classmethod def bach_or_stravinsky(cls) -> "Game": """Return the Bach or Stravinsky coordination game.""" p1 = [[2, 0], [0, 1]] p2 = [[1, 0], [0, 2]] return cls( p1, p2, player_names=("Anna", "Boris"), action_names=( ("Bach", "Stravinsky"), ("Bach", "Stravinsky"), ), )
[docs] @classmethod def pure_coordination(cls) -> "Game": """Return a simple pure coordination game.""" p1 = [[1, 0], [0, 1]] p2 = [[1, 0], [0, 1]] return cls( p1, p2, action_names=( ("Left", "Right"), ("Left", "Right"), ), )
[docs] @classmethod def chicken(cls) -> "Game": """Return the classic Chicken (Hawk-Dove) game.""" p1 = [[3, 1], [4, 0]] p2 = [[3, 4], [1, 0]] return cls( p1, p2, action_names=( ("Straight", "Swerve"), ("Straight", "Swerve"), ), )
[docs] @classmethod def rock_paper_scissors(cls) -> "Game": """Return the Rock-Paper-Scissors game.""" p1 = [ [0, -1, 1], [1, 0, -1], [-1, 1, 0], ] p2 = [ [0, 1, -1], [-1, 0, 1], [1, -1, 0], ] return cls( p1, p2, action_names=( ("Rock", "Paper", "Scissors"), ("Rock", "Paper", "Scissors"), ), )
# Backwards compatibility NormalFormGame = Game