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 +39 -0
- gtlab/_memo.py +31 -0
- gtlab/core/__init__.py +17 -0
- gtlab/core/bayesian.py +335 -0
- gtlab/core/correlated.py +120 -0
- gtlab/core/extensive_form.py +133 -0
- gtlab/core/normal_form.py +176 -0
- gtlab/core/stochastic.py +95 -0
- gtlab/core/zero_sum.py +119 -0
- gtlab/games/__init__.py +27 -0
- gtlab/games/applied.py +36 -0
- gtlab/games/classic.py +44 -0
- gtlab/solvers/__init__.py +33 -0
- gtlab/solvers/best_response.py +51 -0
- gtlab/solvers/correlated.py +105 -0
- gtlab/solvers/dominance.py +83 -0
- gtlab/solvers/learning.py +95 -0
- gtlab/solvers/linprog.py +78 -0
- gtlab/solvers/nash.py +82 -0
- gtlab/solvers/pareto.py +39 -0
- gtlab/solvers/value_iteration.py +66 -0
- gtlab/solvers/welfare.py +48 -0
- gtlab/viz/__init__.py +12 -0
- gtlab/viz/format.py +65 -0
- gtlab/viz/html.py +171 -0
- gtlab/viz/plots.py +103 -0
- gtlab/viz/theme.py +101 -0
- gtlab-0.1.0.dist-info/METADATA +137 -0
- gtlab-0.1.0.dist-info/RECORD +32 -0
- gtlab-0.1.0.dist-info/WHEEL +5 -0
- gtlab-0.1.0.dist-info/licenses/LICENSE +21 -0
- gtlab-0.1.0.dist-info/top_level.txt +1 -0
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))
|
gtlab/core/correlated.py
ADDED
|
@@ -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)})"
|