gtlab 0.1.0__py3-none-any.whl

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.
gtlab/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """gtlab — Game Theory Lab for the ELTE Game Theory course.
2
+
3
+ Quick start::
4
+
5
+ import gtlab
6
+ gtlab.apply_rc() # consistent plot styling (once)
7
+
8
+ from gtlab.games import prisoners_dilemma
9
+ prisoners_dilemma().solve() # annotated bimatrix in Jupyter
10
+
11
+ Build your own::
12
+
13
+ from gtlab import NormalFormGame
14
+ import numpy as np
15
+ g = NormalFormGame(np.array([[3, 0], [5, 1]]), np.array([[3, 5], [0, 1]]))
16
+ g.explain()
17
+
18
+ Layers:
19
+ * ``gtlab.core`` — game classes (data + thin API)
20
+ * ``gtlab.solvers`` — pure algorithms (best response, Nash, value iteration, …)
21
+ * ``gtlab.viz`` — formatting, HTML, plots, theme
22
+ * ``gtlab.games`` — ready-made example games
23
+ """
24
+ from . import games, solvers, viz
25
+ from .core import (CorrelatedGame, ExtensiveFormGame, FirstPriceAuction,
26
+ Mechanism, NormalFormGame, PostedPrice, Procurement,
27
+ PublicProject, SecondPriceAuction, SpenceSignaling,
28
+ StochasticGame, VCGAssignment, ZeroSumGame)
29
+ from .viz import apply_rc
30
+
31
+ __version__ = "0.1.0"
32
+
33
+ __all__ = [
34
+ "NormalFormGame", "ZeroSumGame", "CorrelatedGame", "StochasticGame",
35
+ "ExtensiveFormGame", "Mechanism", "PostedPrice", "FirstPriceAuction",
36
+ "SecondPriceAuction", "SpenceSignaling", "VCGAssignment", "PublicProject",
37
+ "Procurement",
38
+ "solvers", "viz", "games", "apply_rc", "__version__",
39
+ ]
gtlab/_memo.py ADDED
@@ -0,0 +1,31 @@
1
+ """A tiny per-instance memoization decorator for game-class analysis methods.
2
+
3
+ Game classes are dataclasses wrapping numpy arrays, so they are unhashable and
4
+ ``functools.lru_cache`` does not apply. The payoff data is treated as immutable
5
+ after construction, so caching results on the instance is safe and lets repeated
6
+ display calls (and ``compare_via``) reuse expensive solves instead of redoing
7
+ them. Mutating a payoff matrix in place after the first call is unsupported
8
+ (call :func:`clear_cache` if you must).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import functools
13
+ from typing import Callable
14
+
15
+
16
+ def cached_method(func: Callable) -> Callable:
17
+ """Memoize a method's result on the instance, keyed by ``(name, args)``."""
18
+ @functools.wraps(func)
19
+ def wrapper(self, *args, **kwargs):
20
+ cache = self.__dict__.setdefault("_cache", {})
21
+ key = (func.__name__, args, tuple(sorted(kwargs.items())))
22
+ if key not in cache:
23
+ cache[key] = func(self, *args, **kwargs)
24
+ return cache[key]
25
+
26
+ return wrapper
27
+
28
+
29
+ def clear_cache(instance) -> None:
30
+ """Drop all memoized results on ``instance`` (use after mutating payoffs)."""
31
+ instance.__dict__.pop("_cache", None)
gtlab/core/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Core game classes — each holds data and delegates math/display to the
2
+ shared :mod:`gtlab.solvers` and :mod:`gtlab.viz` layers."""
3
+ from .bayesian import (FirstPriceAuction, Mechanism, PostedPrice, Procurement,
4
+ PublicProject, SecondPriceAuction, SpenceSignaling,
5
+ VCGAssignment)
6
+ from .correlated import CorrelatedGame
7
+ from .extensive_form import ExtensiveFormGame
8
+ from .normal_form import NormalFormGame
9
+ from .stochastic import StochasticGame
10
+ from .zero_sum import ZeroSumGame
11
+
12
+ __all__ = [
13
+ "NormalFormGame", "ZeroSumGame", "CorrelatedGame", "StochasticGame",
14
+ "ExtensiveFormGame", "Mechanism", "PostedPrice", "FirstPriceAuction",
15
+ "SecondPriceAuction", "SpenceSignaling", "VCGAssignment", "PublicProject",
16
+ "Procurement",
17
+ ]
gtlab/core/bayesian.py ADDED
@@ -0,0 +1,335 @@
1
+ """Bayesian games and mechanism design.
2
+
3
+ The original notebook dispatched ~8 mechanisms through one dataclass. Here each
4
+ mechanism is a small focused class with the closed-form results from the
5
+ lecture, sharing the display layer. Add a mechanism by subclassing
6
+ :class:`Mechanism` and implementing ``solve``/``summary``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from itertools import product
12
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
13
+
14
+ import numpy as np
15
+
16
+ from ..viz import fmt, fmt_money, fmt_prob, html
17
+
18
+
19
+ class Mechanism:
20
+ """Base class for a mechanism-design example."""
21
+
22
+ name: str = "Mechanism"
23
+
24
+ def solve(self) -> Dict[str, Any]: # pragma: no cover - interface
25
+ raise NotImplementedError
26
+
27
+ def summary(self, title: Optional[str] = None) -> None: # pragma: no cover
28
+ raise NotImplementedError
29
+
30
+
31
+ @dataclass
32
+ class PostedPrice(Mechanism):
33
+ """Single seller posts a price; buyer's value is private (discrete types)."""
34
+
35
+ values: Sequence[float]
36
+ probs: Sequence[float]
37
+ name: str = "Posted price"
38
+
39
+ def __post_init__(self) -> None:
40
+ self.values = np.asarray(self.values, dtype=float)
41
+ self.probs = np.asarray(self.probs, dtype=float)
42
+ if not np.isclose(self.probs.sum(), 1.0):
43
+ raise ValueError("type probabilities must sum to 1")
44
+
45
+ def expected_revenue(self, price: float) -> float:
46
+ """E[revenue] = price · P(value ≥ price)."""
47
+ return float(price * self.probs[self.values >= price].sum())
48
+
49
+ def solve(self) -> Dict[str, Any]:
50
+ # Optimal posted price is one of the candidate type values.
51
+ revenues = {float(v): self.expected_revenue(v) for v in self.values}
52
+ best = max(revenues, key=revenues.get)
53
+ return {"revenues": revenues, "optimal_price": best,
54
+ "optimal_revenue": revenues[best]}
55
+
56
+ def summary(self, title: Optional[str] = None) -> None:
57
+ rows = [[fmt_money(v), fmt_prob(p)] for v, p in zip(self.values, self.probs)]
58
+ tbl = html.table(["value", "probability"], rows)
59
+ sol = self.solve()
60
+ body = (tbl + f'<p><b>Optimal price</b> {fmt_money(sol["optimal_price"])} '
61
+ f'→ E[revenue] {fmt_money(sol["optimal_revenue"])}</p>')
62
+ html.show(html.card(title or self.name, body))
63
+
64
+
65
+ @dataclass
66
+ class FirstPriceAuction(Mechanism):
67
+ """Symmetric IPV first-price auction, values ~ Uniform[lo, hi]."""
68
+
69
+ n_bidders: int
70
+ lo: float = 0.0
71
+ hi: float = 1.0
72
+ name: str = "First-price auction"
73
+
74
+ def bid(self, value: float) -> float:
75
+ """BNE bid: shade toward lo by a factor (n-1)/n."""
76
+ n = self.n_bidders
77
+ return self.lo + (n - 1) / n * (value - self.lo)
78
+
79
+ def expected_revenue(self) -> float:
80
+ """E[revenue] = lo + (n-1)/(n+1) · (hi - lo) (= E[2nd-highest value])."""
81
+ n = self.n_bidders
82
+ return self.lo + (n - 1) / (n + 1) * (self.hi - self.lo)
83
+
84
+ def solve(self) -> Dict[str, Any]:
85
+ return {"expected_revenue": self.expected_revenue(),
86
+ "shading_factor": (self.n_bidders - 1) / self.n_bidders}
87
+
88
+ def summary(self, title: Optional[str] = None) -> None:
89
+ sol = self.solve()
90
+ body = (f"<p>{self.n_bidders} bidders, values ~ U[{fmt(self.lo)}, {fmt(self.hi)}]</p>"
91
+ f"<p><b>BNE bid:</b> b(v) = {fmt(self.lo)} + "
92
+ f"{fmt(sol['shading_factor'])}·(v − {fmt(self.lo)})</p>"
93
+ f"<p><b>Expected revenue:</b> {fmt(sol['expected_revenue'])}</p>")
94
+ html.show(html.card(title or self.name, body))
95
+
96
+
97
+ @dataclass
98
+ class SecondPriceAuction(Mechanism):
99
+ """Vickrey auction: truthful bidding is weakly dominant; revenue-equivalent to FPA."""
100
+
101
+ n_bidders: int
102
+ lo: float = 0.0
103
+ hi: float = 1.0
104
+ name: str = "Second-price auction"
105
+
106
+ def expected_revenue(self) -> float:
107
+ n = self.n_bidders
108
+ return self.lo + (n - 1) / (n + 1) * (self.hi - self.lo)
109
+
110
+ def solve(self) -> Dict[str, Any]:
111
+ return {"expected_revenue": self.expected_revenue(), "dominant": "truthful"}
112
+
113
+ def summary(self, title: Optional[str] = None) -> None:
114
+ sol = self.solve()
115
+ body = (f"<p>{self.n_bidders} bidders, values ~ U[{fmt(self.lo)}, {fmt(self.hi)}]</p>"
116
+ "<p><b>Weakly dominant strategy:</b> bid your true value.</p>"
117
+ f"<p><b>Expected revenue:</b> {fmt(sol['expected_revenue'])} "
118
+ "(equals the first-price auction — revenue equivalence).</p>")
119
+ html.show(html.card(title or self.name, body))
120
+
121
+
122
+ @dataclass
123
+ class SpenceSignaling(Mechanism):
124
+ """Spence job-market signaling: a worker's type is private; education is a
125
+ costly, productivity-free signal. Single-crossing (``c_high < c_low``)
126
+ yields a non-empty separating interval of education levels."""
127
+
128
+ w_low: float
129
+ w_high: float
130
+ c_low: float # cost of education per unit for the LOW type
131
+ c_high: float # cost per unit for the HIGH type (c_high < c_low)
132
+ name: str = "Spence signaling"
133
+
134
+ def __post_init__(self) -> None:
135
+ if not self.c_high < self.c_low:
136
+ raise ValueError("single-crossing requires c_high < c_low")
137
+
138
+ def solve(self) -> Dict[str, Any]:
139
+ d = self.w_high - self.w_low
140
+ e_min = d / self.c_low # low-type indifference (IC for low type)
141
+ e_max = d / self.c_high # high-type indifference (IC for high type)
142
+ e_star = 0.5 * (e_min + e_max)
143
+ return {
144
+ "e_min": e_min, "e_max": e_max, "e_star": e_star,
145
+ "u_high": self.w_high - self.c_high * e_star,
146
+ "u_low": self.w_low,
147
+ }
148
+
149
+ def summary(self, title: Optional[str] = None) -> None:
150
+ s = self.solve()
151
+ rows = [
152
+ ["e<sub>min</sub> (low-type IC)", "(w<sub>H</sub>−w<sub>L</sub>)/c<sub>L</sub>", fmt(s["e_min"])],
153
+ ["e<sub>max</sub> (high-type IC)", "(w<sub>H</sub>−w<sub>L</sub>)/c<sub>H</sub>", fmt(s["e_max"])],
154
+ ]
155
+ tbl = html.table(["bound", "formula", "value"], rows)
156
+ body = (tbl + f'<p>Any e* ∈ [{fmt(s["e_min"])}, {fmt(s["e_max"])}] supports a '
157
+ f'separating equilibrium; midpoint e* = <b>{fmt(s["e_star"])}</b>.</p>')
158
+ html.show(html.card(title or self.name, body))
159
+
160
+
161
+ @dataclass
162
+ class VCGAssignment(Mechanism):
163
+ """VCG (Vickrey–Clarke–Groves) assignment of indivisible items to bidders.
164
+
165
+ ``V[i, j]`` is bidder ``i``'s value for item ``j``. The efficient allocation
166
+ maximizes total welfare; each winner pays the externality it imposes on the
167
+ others. Truthful reporting is weakly dominant.
168
+ """
169
+
170
+ V: np.ndarray
171
+ bidders: Optional[Sequence[str]] = None
172
+ items: Optional[Sequence[str]] = None
173
+ name: str = "VCG assignment"
174
+
175
+ def __post_init__(self) -> None:
176
+ self.V = np.asarray(self.V, dtype=float)
177
+ n_b, n_it = self.V.shape
178
+ self.bidders = list(self.bidders) if self.bidders else [f"B{i+1}" for i in range(n_b)]
179
+ self.items = list(self.items) if self.items else [f"item{j+1}" for j in range(n_it)]
180
+
181
+ @staticmethod
182
+ def _enumerate(V: np.ndarray) -> List[Tuple[Tuple[int, ...], float]]:
183
+ """Every feasible assignment (item → bidder index, or −1) with welfare."""
184
+ n_b, n_it = V.shape
185
+ results: List[Tuple[Tuple[int, ...], float]] = []
186
+
187
+ def assign(pos: int, used: set, cur: List[int]) -> None:
188
+ if pos == n_it:
189
+ w = sum(V[cur[j], j] if cur[j] >= 0 else 0.0 for j in range(n_it))
190
+ results.append((tuple(cur), float(w)))
191
+ return
192
+ assign(pos + 1, used, cur + [-1]) # leave item unassigned
193
+ for i in range(n_b):
194
+ if i not in used:
195
+ assign(pos + 1, used | {i}, cur + [i])
196
+
197
+ assign(0, set(), [])
198
+ return results
199
+
200
+ def _efficient(self, V: np.ndarray) -> Tuple[Tuple[int, ...], float]:
201
+ return max(self._enumerate(V), key=lambda x: x[1])
202
+
203
+ def solve(self) -> Dict[str, Any]:
204
+ V = self.V
205
+ n_b = V.shape[0]
206
+ a_star, w_star = self._efficient(V)
207
+ alloc = [-1] * n_b # bidder → item
208
+ for j, i in enumerate(a_star):
209
+ if i >= 0:
210
+ alloc[i] = j
211
+ payments = np.zeros(n_b)
212
+ for i in range(n_b):
213
+ own = V[i, alloc[i]] if alloc[i] >= 0 else 0.0
214
+ others_with_i = w_star - own
215
+ _, w_without_i = self._efficient(np.delete(V, i, axis=0))
216
+ payments[i] = w_without_i - others_with_i
217
+ utilities = np.array([
218
+ (V[i, alloc[i]] if alloc[i] >= 0 else 0.0) - payments[i]
219
+ for i in range(n_b)
220
+ ])
221
+ return {"assignment": a_star, "welfare": w_star, "alloc": alloc,
222
+ "payments": payments, "utilities": utilities}
223
+
224
+ def summary(self, title: Optional[str] = None) -> None:
225
+ s = self.solve()
226
+ rows = []
227
+ for i in range(self.V.shape[0]):
228
+ j = s["alloc"][i]
229
+ won = self.items[j] if j >= 0 else "—"
230
+ rows.append([won, fmt_money(s["payments"][i]), fmt_money(s["utilities"][i])])
231
+ tbl = html.table(["item won", "VCG payment", "utility"], rows,
232
+ row_headers=self.bidders)
233
+ body = (tbl + f'<p><b>Efficient welfare:</b> {fmt(s["welfare"])}. '
234
+ "Each winner pays the externality it imposes; truthful bidding is "
235
+ "weakly dominant.</p>")
236
+ html.show(html.card(title or self.name, body))
237
+
238
+
239
+ @dataclass
240
+ class PublicProject(Mechanism):
241
+ """Clarke pivot mechanism for a binary public good.
242
+
243
+ Build iff the sum of reported values covers the cost. A *pivotal* citizen
244
+ (one whose presence flips the decision) pays the externality it imposes;
245
+ everyone else pays 0. Truthful, efficient, individually rational — but
246
+ generally runs a budget deficit (the impossibility trilemma).
247
+ """
248
+
249
+ values: Sequence[float]
250
+ cost: float
251
+ citizens: Optional[Sequence[str]] = None
252
+ name: str = "Public project (Clarke pivot)"
253
+
254
+ def __post_init__(self) -> None:
255
+ self.values = np.asarray(self.values, dtype=float)
256
+ self.citizens = list(self.citizens) if self.citizens else \
257
+ [f"C{i+1}" for i in range(len(self.values))]
258
+
259
+ def solve(self) -> Dict[str, Any]:
260
+ v = self.values
261
+ total = float(v.sum())
262
+ build = total >= self.cost
263
+ pay = np.zeros_like(v)
264
+ if build:
265
+ for i in range(len(v)):
266
+ others = total - v[i]
267
+ if others < self.cost: # i is pivotal
268
+ pay[i] = max(0.0, self.cost - others)
269
+ total_pay = float(pay.sum())
270
+ return {"build": build, "total_value": total, "payments": pay,
271
+ "total_payment": total_pay,
272
+ "deficit": float(self.cost - total_pay) if build else 0.0}
273
+
274
+ def summary(self, title: Optional[str] = None) -> None:
275
+ s = self.solve()
276
+ rows = [[fmt(v), fmt_money(p)] for v, p in zip(self.values, s["payments"])]
277
+ tbl = html.table(["value", "pivot payment"], rows, row_headers=self.citizens)
278
+ verdict = "BUILD" if s["build"] else "DO NOT build"
279
+ body = (tbl + f'<p><b>Decision:</b> {verdict} '
280
+ f'(Σv = {fmt(s["total_value"])} vs cost {fmt_money(self.cost)}).</p>'
281
+ + (f'<p>Total collected {fmt_money(s["total_payment"])} → '
282
+ f'budget deficit {fmt_money(s["deficit"])}.</p>' if s["build"] else ""))
283
+ html.show(html.card(title or self.name, body))
284
+
285
+
286
+ @dataclass
287
+ class Procurement(Mechanism):
288
+ """Reverse (Vickrey) procurement auction with discrete private costs.
289
+
290
+ The firm reporting the lowest cost wins and is paid the second-lowest
291
+ reported cost. Truthful reporting is weakly dominant; the expected payment
292
+ is the second-order statistic of ``n`` i.i.d. cost draws.
293
+ """
294
+
295
+ costs: Sequence[float]
296
+ probs: Sequence[float]
297
+ n: int = 2
298
+ name: str = "Procurement (reverse Vickrey)"
299
+
300
+ def __post_init__(self) -> None:
301
+ self.costs = np.asarray(self.costs, dtype=float)
302
+ self.probs = np.asarray(self.probs, dtype=float)
303
+ if not np.isclose(self.probs.sum(), 1.0):
304
+ raise ValueError("cost-type probabilities must sum to 1")
305
+ if self.n < 2:
306
+ raise ValueError("need at least 2 firms")
307
+
308
+ def solve(self, max_exact: int = 5000) -> Dict[str, Any]:
309
+ costs, probs, n = self.costs, self.probs, self.n
310
+ n_types = len(costs)
311
+ if n_types ** n <= max_exact:
312
+ e_pay = e_win = 0.0
313
+ for profile in product(range(n_types), repeat=n):
314
+ p = float(np.prod(probs[list(profile)]))
315
+ c_sorted = sorted(costs[list(profile)])
316
+ e_pay += p * c_sorted[1]
317
+ e_win += p * c_sorted[0]
318
+ else:
319
+ rng = np.random.default_rng(0)
320
+ draws = rng.choice(costs, size=(20_000, n), p=probs)
321
+ srt = np.sort(draws, axis=1)
322
+ e_pay = float(srt[:, 1].mean())
323
+ e_win = float(srt[:, 0].mean())
324
+ return {"expected_payment": e_pay, "expected_winner_cost": e_win,
325
+ "expected_rent": e_pay - e_win}
326
+
327
+ def summary(self, title: Optional[str] = None) -> None:
328
+ s = self.solve()
329
+ rows = [[fmt_money(c), fmt_prob(p)] for c, p in zip(self.costs, self.probs)]
330
+ tbl = html.table(["cost type", "probability"], rows)
331
+ body = (tbl + f"<p>{self.n} firms. <b>Expected payment</b> "
332
+ f"{fmt_money(s['expected_payment'])} (2nd-lowest cost); winner's "
333
+ f"expected cost {fmt_money(s['expected_winner_cost'])} → "
334
+ f"expected rent {fmt_money(s['expected_rent'])}.</p>")
335
+ html.show(html.card(title or self.name, body))
@@ -0,0 +1,120 @@
1
+ """Correlated equilibrium, coarse correlated equilibrium, and no-regret learning."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Optional, Sequence, Tuple
6
+
7
+ import numpy as np
8
+
9
+ from .. import solvers
10
+ from .._memo import cached_method
11
+ from ..viz import fmt, html, plots
12
+
13
+
14
+ @dataclass
15
+ class CorrelatedGame:
16
+ """Two-player general-sum game analyzed through the CE / CCE lens."""
17
+
18
+ A: np.ndarray
19
+ B: np.ndarray
20
+ row_actions: Optional[Sequence[str]] = None
21
+ col_actions: Optional[Sequence[str]] = None
22
+ name: str = "Game"
23
+
24
+ def __post_init__(self) -> None:
25
+ self.A = np.asarray(self.A, dtype=float)
26
+ self.B = np.asarray(self.B, dtype=float)
27
+ if self.A.shape != self.B.shape:
28
+ raise ValueError("A and B must have the same shape")
29
+ m, n = self.A.shape
30
+ self.row_actions = list(self.row_actions) if self.row_actions else [f"r{i}" for i in range(m)]
31
+ self.col_actions = list(self.col_actions) if self.col_actions else [f"c{j}" for j in range(n)]
32
+
33
+ @property
34
+ def shape(self) -> Tuple[int, int]:
35
+ return self.A.shape
36
+
37
+ # ── analysis (memoized; keyed by arguments) ──────────────────────────────
38
+ @cached_method
39
+ def find_ce(self, maximize: str = "welfare"):
40
+ return solvers.find_ce(self.A, self.B, maximize)
41
+
42
+ @cached_method
43
+ def find_cce(self, maximize: str = "welfare"):
44
+ return solvers.find_cce(self.A, self.B, maximize)
45
+
46
+ @cached_method
47
+ def nash(self):
48
+ return solvers.all_equilibria(self.A, self.B)
49
+
50
+ @cached_method
51
+ def hedge(self, T: int = 2000, seed: int = 0):
52
+ return solvers.hedge(self.A, self.B, T=T, seed=seed)
53
+
54
+ # ── display ──────────────────────────────────────────────────────────────
55
+ def _mu_table(self, mu: np.ndarray) -> str:
56
+ m, n = self.shape
57
+ rows = [[fmt(mu[i, j]) for j in range(n)] for i in range(m)]
58
+ return html.table(self.col_actions, rows, row_headers=self.row_actions)
59
+
60
+ def summary(self, title: Optional[str] = None) -> None:
61
+ ce = self.find_ce()
62
+ cce = self.find_cce()
63
+ body = ""
64
+ if ce:
65
+ body += "<b>CE (welfare-max)</b>" + self._mu_table(ce["mu"]) \
66
+ + html.note(f"welfare = {fmt(ce['welfare'])}")
67
+ if cce:
68
+ body += "<b>CCE (welfare-max)</b>" + self._mu_table(cce["mu"]) \
69
+ + html.note(f"welfare = {fmt(cce['welfare'])}")
70
+ html.show(html.card(title or self.name, body))
71
+
72
+ def compare_equilibria(self, title: Optional[str] = None) -> None:
73
+ """Compare NE, CE, and CCE welfare."""
74
+ ce = self.find_ce()
75
+ cce = self.find_cce()
76
+ rows = []
77
+ nash = self.nash()
78
+ if nash:
79
+ p, q = nash[0]
80
+ eu_r = float(p @ self.A @ q)
81
+ eu_c = float(p @ self.B @ q)
82
+ rows.append(["Nash", fmt(eu_r), fmt(eu_c), fmt(eu_r + eu_c)])
83
+ if ce:
84
+ rows.append(["CE", fmt(ce["eu_row"]), fmt(ce["eu_col"]), fmt(ce["welfare"])])
85
+ if cce:
86
+ rows.append(["CCE", fmt(cce["eu_row"]), fmt(cce["eu_col"]), fmt(cce["welfare"])])
87
+ tbl = html.table(["concept", "E[row]", "E[col]", "welfare"], rows)
88
+ body = tbl + html.note("welfare ordering: NE ≤ CE ≤ CCE (each relaxes the "
89
+ "deviation constraints of the previous).")
90
+ html.show(html.card(title or f"{self.name} — equilibrium concepts", body))
91
+
92
+ def explain(self, title: Optional[str] = None) -> None:
93
+ """Walkthrough: NE ⊆ CE ⊆ CCE, plus the no-regret learning connection."""
94
+ items = [
95
+ "<b>Step 1 — Nash equilibrium.</b> Players randomize independently; "
96
+ "their product distribution must be a mutual best response.",
97
+ "<b>Step 2 — Correlated equilibrium (CE).</b> A trusted device draws a "
98
+ "joint action and privately recommends each player's part; obeying must "
99
+ "be optimal given the conditional belief it induces.",
100
+ "<b>Step 3 — Coarse correlated equilibrium (CCE).</b> Players commit "
101
+ "before seeing the recommendation; only ex-ante deviations are checked, "
102
+ "so CCE ⊇ CE ⊇ NE.",
103
+ "<b>Step 4 — Learning.</b> If both players run a no-regret algorithm "
104
+ "(see <code>plot_regret</code>), the empirical play converges to the CCE "
105
+ "set (Hannan's theorem).",
106
+ ]
107
+ html.show(html.card(title or f"{self.name} — equilibrium concepts",
108
+ html.steps(items)))
109
+
110
+ def plot_regret(self, T: int = 2000, seed: int = 0, title: Optional[str] = None):
111
+ """Average regret of both players under Hedge → 0 (Hannan)."""
112
+ res = self.hedge(T=T, seed=seed)
113
+ return plots.convergence(
114
+ {"Row avg regret": res["avg_regret_row"],
115
+ "Col avg regret": res["avg_regret_col"]},
116
+ target=0.0, title=title or f"{self.name} — no-regret learning",
117
+ ylabel="average regret")
118
+
119
+ def __repr__(self) -> str:
120
+ return f"CorrelatedGame({self.name!r}, shape={self.shape})"
@@ -0,0 +1,133 @@
1
+ """Extensive-form games: game trees, backward induction, welfare.
2
+
3
+ The tree is a dict keyed by node id. Each node is either a decision node::
4
+
5
+ {"player": 0, "actions": {"L": "n1", "R": "n2"}}
6
+
7
+ a chance node::
8
+
9
+ {"chance": {"L": (0.5, "n1"), "R": (0.5, "n2")}}
10
+
11
+ or a terminal node::
12
+
13
+ {"payoff": (3.0, 1.0)}
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ import numpy as np
21
+
22
+ from .. import solvers
23
+ from .._memo import cached_method
24
+ from ..viz import fmt_vec, html
25
+
26
+
27
+ @dataclass
28
+ class ExtensiveFormGame:
29
+ """A finite extensive-form game over a tree of decision/chance/terminal nodes."""
30
+
31
+ tree: Dict[str, Dict[str, Any]]
32
+ root: str = "root"
33
+ players: Optional[List[str]] = None
34
+ name: str = "Extensive-form game"
35
+
36
+ def __post_init__(self) -> None:
37
+ if self.root not in self.tree:
38
+ raise ValueError(f"root node {self.root!r} not in tree")
39
+ n_players = self._infer_n_players()
40
+ self.players = list(self.players) if self.players else [f"P{i+1}" for i in range(n_players)]
41
+ self._validate()
42
+
43
+ def _infer_n_players(self) -> int:
44
+ n = 2
45
+ for node in self.tree.values():
46
+ if "payoff" in node:
47
+ n = max(n, len(node["payoff"]))
48
+ return n
49
+
50
+ def _validate(self) -> None:
51
+ for nid, node in self.tree.items():
52
+ kinds = sum(k in node for k in ("actions", "chance", "payoff"))
53
+ if kinds != 1:
54
+ raise ValueError(f"node {nid!r} must be exactly one of "
55
+ "decision/chance/terminal")
56
+ if "actions" in node:
57
+ for child in node["actions"].values():
58
+ if child not in self.tree:
59
+ raise ValueError(f"node {nid!r} → missing child {child!r}")
60
+
61
+ # ── backward induction ───────────────────────────────────────────────────
62
+ @cached_method
63
+ def backward_induction(self, tol: float = 1e-9) -> Dict[str, Any]:
64
+ """Compute the subgame-perfect equilibrium by backward induction.
65
+
66
+ Returns ``{"value": payoff_at_root, "strategy": {node: action}}``.
67
+ """
68
+ strategy: Dict[str, str] = {}
69
+
70
+ def value(nid: str) -> np.ndarray:
71
+ node = self.tree[nid]
72
+ if "payoff" in node:
73
+ return np.asarray(node["payoff"], dtype=float)
74
+ if "chance" in node:
75
+ out = np.zeros(len(self.players))
76
+ for prob, child in node["chance"].values():
77
+ out += prob * value(child)
78
+ return out
79
+ player = node["player"]
80
+ best_action, best_val = None, None
81
+ for action, child in node["actions"].items():
82
+ v = value(child)
83
+ if best_val is None or v[player] > best_val[player] + tol:
84
+ best_action, best_val = action, v
85
+ strategy[nid] = best_action
86
+ return best_val
87
+
88
+ root_value = value(self.root)
89
+ return {"value": root_value, "strategy": strategy}
90
+
91
+ def terminal_payoffs(self) -> np.ndarray:
92
+ """All terminal payoff vectors as an ``(N, players)`` array."""
93
+ return np.array([node["payoff"] for node in self.tree.values()
94
+ if "payoff" in node], dtype=float)
95
+
96
+ def pareto_frontier(self) -> np.ndarray:
97
+ return solvers.pareto_frontier(self.terminal_payoffs())
98
+
99
+ def social_welfare(self, objective: str = "utilitarian") -> Tuple[np.ndarray, float]:
100
+ """Best terminal outcome and its welfare score for the given objective."""
101
+ outcomes = self.terminal_payoffs()
102
+ idx = solvers.best_outcome(outcomes, objective)
103
+ score = {"utilitarian": solvers.utilitarian,
104
+ "egalitarian": solvers.egalitarian,
105
+ "nash": solvers.nash_welfare}[objective](outcomes[idx])
106
+ return outcomes[idx], float(score)
107
+
108
+ # ── display ──────────────────────────────────────────────────────────────
109
+ def _solution_html(self) -> str:
110
+ res = self.backward_induction()
111
+ rows = [[nid, action] for nid, action in res["strategy"].items()]
112
+ tbl = html.table(["node", "chosen action"], rows)
113
+ return html.kv([("SPE payoff", fmt_vec(res["value"]))]) + tbl
114
+
115
+ def solve(self, title: Optional[str] = None) -> None:
116
+ html.show(html.card(title or f"{self.name} — backward induction",
117
+ self._solution_html()))
118
+
119
+ def explain(self, title: Optional[str] = None) -> None:
120
+ res = self.backward_induction()
121
+ items = [
122
+ "<b>Step 1 — Start at the leaves.</b> Terminal nodes already carry payoffs.",
123
+ "<b>Step 2 — Fold the tree.</b> At each decision node the acting player "
124
+ "picks the action leading to the child with the highest payoff <i>for "
125
+ "them</i>; that child's payoff vector propagates up.",
126
+ f"<b>Step 3 — Read the root.</b> The resulting subgame-perfect equilibrium "
127
+ f"yields payoff {fmt_vec(res['value'])} (chosen actions tabulated above).",
128
+ ]
129
+ body = self._solution_html() + html.steps(items)
130
+ html.show(html.card(title or f"{self.name} — backward induction", body))
131
+
132
+ def __repr__(self) -> str:
133
+ return f"ExtensiveFormGame({self.name!r}, nodes={len(self.tree)})"