Import source: Shayan1/Prod/nash/solver
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,223 @@
|
|||||||
|
import itertools
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def find_pure_nash_equilibria(game):
|
||||||
|
"""Brute-force search over all strategy profiles. Works for any number of players."""
|
||||||
|
equilibria = []
|
||||||
|
for profile in game.all_profiles():
|
||||||
|
if _is_pure_nash(game, profile):
|
||||||
|
equilibria.append(profile)
|
||||||
|
return equilibria
|
||||||
|
|
||||||
|
|
||||||
|
def find_dominant_strategies(game):
|
||||||
|
"""Find a strictly dominant strategy for each player, if one exists.
|
||||||
|
|
||||||
|
A strategy *s* for player *i* is **strictly dominant** when it yields a
|
||||||
|
strictly higher payoff than every other strategy of player *i* against
|
||||||
|
*all* possible opponent profiles.
|
||||||
|
|
||||||
|
Returns ``dict[int, int | None]`` mapping each player index to the index
|
||||||
|
of their strictly dominant strategy (or ``None`` when no such strategy
|
||||||
|
exists). Works for any number of players.
|
||||||
|
"""
|
||||||
|
counts = game.strategy_counts()
|
||||||
|
result = {}
|
||||||
|
for i in range(game.num_players):
|
||||||
|
if counts[i] < 2:
|
||||||
|
result[i] = None
|
||||||
|
continue
|
||||||
|
dominant = None
|
||||||
|
for s in range(counts[i]):
|
||||||
|
is_dominant = True
|
||||||
|
for other in range(counts[i]):
|
||||||
|
if other == s:
|
||||||
|
continue
|
||||||
|
# Compare s vs other across every opponent profile.
|
||||||
|
opp_ranges = [range(n) for j, n in enumerate(counts) if j != i]
|
||||||
|
for opp_profile in itertools.product(*opp_ranges):
|
||||||
|
profile_s = opp_profile[:i] + (s,) + opp_profile[i:]
|
||||||
|
profile_other = opp_profile[:i] + (other,) + opp_profile[i:]
|
||||||
|
if game.payoff(i, profile_s) <= game.payoff(i, profile_other):
|
||||||
|
is_dominant = False
|
||||||
|
break
|
||||||
|
if not is_dominant:
|
||||||
|
break
|
||||||
|
if is_dominant:
|
||||||
|
dominant = s
|
||||||
|
break
|
||||||
|
result[i] = dominant
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pure_nash(game, profile):
|
||||||
|
strategy_counts = game.strategy_counts()
|
||||||
|
for i in range(game.num_players):
|
||||||
|
current_payoff = game.payoff(i, profile)
|
||||||
|
for s in range(strategy_counts[i]):
|
||||||
|
if s == profile[i]:
|
||||||
|
continue
|
||||||
|
deviation = list(profile)
|
||||||
|
deviation[i] = s
|
||||||
|
if game.payoff(i, tuple(deviation)) > current_payoff:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def find_mixed_nash_equilibria(game, tol=1e-7):
|
||||||
|
"""Support enumeration for 2-player games (assumes non-degenerate games, i.e. equal-size
|
||||||
|
supports). Returns list of (x, y) probability vectors.
|
||||||
|
"""
|
||||||
|
if game.num_players != 2:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Mixed Nash equilibrium solving is only implemented for 2-player games"
|
||||||
|
)
|
||||||
|
|
||||||
|
A, B = game.payoffs
|
||||||
|
m, n = A.shape
|
||||||
|
equilibria = []
|
||||||
|
|
||||||
|
for k in range(1, min(m, n) + 1):
|
||||||
|
for support_i in itertools.combinations(range(m), k):
|
||||||
|
for support_j in itertools.combinations(range(n), k):
|
||||||
|
y, v1 = _solve_indifference(A, support_i, support_j, tol)
|
||||||
|
x, v2 = _solve_indifference(B.T, support_j, support_i, tol)
|
||||||
|
if y is None or x is None:
|
||||||
|
continue
|
||||||
|
if _is_feasible(A, B, support_i, support_j, x, y, v1, v2, tol):
|
||||||
|
if not _already_found(equilibria, x, y, tol):
|
||||||
|
equilibria.append((x, y))
|
||||||
|
|
||||||
|
return equilibria
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_support(A, B, support_i, support_j, tol):
|
||||||
|
y_result = _solve_indifference(A, support_i, support_j, tol)
|
||||||
|
x_result = _solve_indifference(B.T, support_j, support_i, tol)
|
||||||
|
if y_result is None or x_result is None:
|
||||||
|
return None, None
|
||||||
|
y, v1 = y_result
|
||||||
|
x, v2 = x_result
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_indifference(matrix, eq_indices, var_indices, tol):
|
||||||
|
"""Solve for a probability distribution over var_indices that makes the player whose
|
||||||
|
payoff matrix is ``matrix`` indifferent among the strategies in ``eq_indices``.
|
||||||
|
"""
|
||||||
|
k = len(eq_indices)
|
||||||
|
# Build the linear system: for each eq_index except the last, the difference
|
||||||
|
# in expected payoffs between that index and the last must be zero.
|
||||||
|
if k == 1:
|
||||||
|
probs = np.zeros(matrix.shape[1])
|
||||||
|
probs[list(var_indices)] = 1.0
|
||||||
|
value = matrix[eq_indices[0], var_indices[0]]
|
||||||
|
if value < -tol:
|
||||||
|
return None, None
|
||||||
|
return np.clip(probs, 0, None), value
|
||||||
|
|
||||||
|
A_mat = np.zeros((k, len(var_indices)))
|
||||||
|
b_vec = np.zeros(k)
|
||||||
|
last_row = matrix[eq_indices[-1], list(var_indices)]
|
||||||
|
for row_i in range(k - 1):
|
||||||
|
this_row = matrix[eq_indices[row_i], list(var_indices)]
|
||||||
|
A_mat[row_i] = this_row - last_row
|
||||||
|
b_vec[row_i] = 0.0
|
||||||
|
A_mat[-1] = 1.0
|
||||||
|
b_vec[-1] = 1.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
probs_support = np.linalg.solve(A_mat, b_vec)
|
||||||
|
except np.linalg.LinAlgError:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Check for negative probabilities in the solved support
|
||||||
|
if any(p < -tol for p in probs_support):
|
||||||
|
return None, None
|
||||||
|
probs = np.zeros(matrix.shape[1])
|
||||||
|
for idx, v in enumerate(var_indices):
|
||||||
|
probs[v] = probs_support[idx]
|
||||||
|
probs = np.clip(probs, 0, None)
|
||||||
|
if np.sum(probs[list(var_indices)]) < tol:
|
||||||
|
return None, None
|
||||||
|
probs[list(var_indices)] /= np.sum(probs[list(var_indices)])
|
||||||
|
value = matrix[eq_indices[0]] @ probs
|
||||||
|
return np.clip(probs, 0, None), value
|
||||||
|
|
||||||
|
|
||||||
|
def _is_feasible(A, B, support_i, support_j, x, y, v1, v2, tol):
|
||||||
|
m, n = A.shape
|
||||||
|
# All probabilities non-negative
|
||||||
|
if any(x[i] < -tol for i in support_i) or any(y[j] < -tol for j in support_j):
|
||||||
|
return False
|
||||||
|
# Best-response check: no strategy outside support gives higher payoff
|
||||||
|
for i in range(m):
|
||||||
|
if i not in support_i and A[i] @ y > v1 + tol:
|
||||||
|
return False
|
||||||
|
for j in range(n):
|
||||||
|
if j not in support_j and B[:, j] @ x > v2 + tol:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _already_found(equilibria, x, y, tol):
|
||||||
|
for ex, ey in equilibria:
|
||||||
|
if np.allclose(x, ex, atol=tol) and np.allclose(y, ey, atol=tol):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def reduce_game(game):
|
||||||
|
"""Iteratively eliminate strictly dominated strategies for each player.
|
||||||
|
|
||||||
|
Returns a tuple of (reduced_game, logs).
|
||||||
|
"""
|
||||||
|
current_game = game
|
||||||
|
logs = []
|
||||||
|
while True:
|
||||||
|
eliminated_any = False
|
||||||
|
counts = current_game.strategy_counts()
|
||||||
|
for i in range(current_game.num_players):
|
||||||
|
if counts[i] < 2:
|
||||||
|
continue
|
||||||
|
dominated_idx = None
|
||||||
|
for s in range(counts[i]):
|
||||||
|
for other in range(counts[i]):
|
||||||
|
if other == s:
|
||||||
|
continue
|
||||||
|
is_dominated = True
|
||||||
|
opp_ranges = [range(n) for j, n in enumerate(counts) if j != i]
|
||||||
|
for opp_profile in itertools.product(*opp_ranges):
|
||||||
|
profile_s = opp_profile[:i] + (s,) + opp_profile[i:]
|
||||||
|
profile_other = opp_profile[:i] + (other,) + opp_profile[i:]
|
||||||
|
if current_game.payoff(i, profile_other) <= current_game.payoff(i, profile_s):
|
||||||
|
is_dominated = False
|
||||||
|
break
|
||||||
|
if is_dominated:
|
||||||
|
dominated_idx = s
|
||||||
|
break
|
||||||
|
if dominated_idx is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if dominated_idx is not None:
|
||||||
|
player_name = current_game.players[i]
|
||||||
|
strategy_name = current_game.strategies[i][dominated_idx]
|
||||||
|
logs.append(f"Player '{player_name}' eliminated dominated strategy '{strategy_name}'")
|
||||||
|
new_players = current_game.players
|
||||||
|
new_strategies = []
|
||||||
|
for j, strats in enumerate(current_game.strategies):
|
||||||
|
if j == i:
|
||||||
|
new_strategies.append(strats[:dominated_idx] + strats[dominated_idx+1:])
|
||||||
|
else:
|
||||||
|
new_strategies.append(list(strats))
|
||||||
|
new_payoffs = []
|
||||||
|
for payoff in current_game.payoffs:
|
||||||
|
new_payoffs.append(np.delete(payoff, dominated_idx, axis=i))
|
||||||
|
current_game = current_game.__class__(new_players, new_strategies, new_payoffs)
|
||||||
|
eliminated_any = True
|
||||||
|
break
|
||||||
|
if not eliminated_any:
|
||||||
|
break
|
||||||
|
return current_game, logs
|
||||||
Reference in New Issue
Block a user