Skip to content

Conversation

@misrasaurabh1
Copy link
Contributor

📄 1,749% (17.49x) speedup for PolymatrixGame.range_of_payoffs in quantecon/game_theory/polymatrix_game.py

⏱️ Runtime : 3.83 seconds 207 milliseconds (best of 5 runs)

📝 Explanation and details

The optimization replaces the inefficient double-pass approach in range_of_payoffs() with a single-pass vectorized operation using NumPy.

Key changes:

  • Original approach: Two separate list comprehensions calling min([np.min(M) for M in ...]) and max([np.max(M) for M in ...]), which iterate through all matrices twice and involve Python's built-in min/max functions on a list of scalar values.
  • Optimized approach: Single concatenation of all flattened matrices using np.concatenate([M.ravel() for M in ...]), then applying np.min() and np.max() directly on the combined array.

Why this is faster:

  • Eliminates redundant iterations: Instead of scanning all matrices twice (once for min, once for max), we flatten and concatenate once, then perform both min/max operations on the same contiguous array.
  • Vectorized operations: NumPy's min and max functions are highly optimized C implementations that operate on contiguous memory, compared to Python's built-in functions working on lists.
  • Reduces function call overhead: The original code calls np.min() once per matrix, while the optimized version calls it once total.

Performance characteristics:
The optimization shows dramatic speedup especially for larger games - achieving 651% to 2010% improvements on large-scale test cases with many players/matchups, while maintaining 9-30% improvements on smaller cases. The single-pass approach scales much better as the number of matrices increases.

Correctness verification report:

Test Status
⚙️ Existing Unit Tests 🔘 None Found
🌀 Generated Regression Tests 68 Passed
⏪ Replay Tests 🔘 None Found
🔎 Concolic Coverage Tests 🔘 None Found
📊 Tests Coverage 100.0%
🌀 Generated Regression Tests and Runtime
from collections.abc import Iterable, Mapping, Sequence
from math import isqrt

# function to test
import numpy as np
# imports
import pytest  # used for our unit tests
from numpy.typing import NDArray
from quantecon.game_theory.polymatrix_game import PolymatrixGame


# Dummy _nums_actions2string for repr
def _nums_actions2string(nums_actions):
    return str(nums_actions)
from quantecon.game_theory.polymatrix_game import PolymatrixGame

# unit tests

# -----------------
# BASIC TEST CASES
# -----------------

def test_basic_single_payoff():
    # Single matchup, single action, single payoff
    pmg = PolymatrixGame({(0, 1): [[5.0]], (1, 0): [[-3.0]]}, nums_actions=[1, 1])
    # Should return the min and max over both matrices
    codeflash_output = pmg.range_of_payoffs() # 30.3μs -> 35.5μs (14.7% slower)

def test_basic_multiple_players_actions():
    # Two players, two actions each, all payoffs positive
    pmg = PolymatrixGame({
        (0, 1): [[1, 2], [3, 4]],
        (1, 0): [[5, 6], [7, 8]]
    }, nums_actions=[2, 2])
    # min: 1, max: 8
    codeflash_output = pmg.range_of_payoffs() # 21.2μs -> 17.7μs (20.2% faster)

def test_basic_negative_and_positive():
    # Two players, negative and positive payoffs
    pmg = PolymatrixGame({
        (0, 1): [[-10, 0], [5, 2]],
        (1, 0): [[-7, 4], [8, -1]]
    }, nums_actions=[2, 2])
    # min: -10, max: 8
    codeflash_output = pmg.range_of_payoffs() # 18.4μs -> 15.2μs (21.2% faster)

def test_basic_zero_payoffs():
    # Two players, all payoffs zero
    pmg = PolymatrixGame({
        (0, 1): [[0, 0], [0, 0]],
        (1, 0): [[0, 0], [0, 0]]
    }, nums_actions=[2, 2])
    codeflash_output = pmg.range_of_payoffs() # 17.4μs -> 15.4μs (13.2% faster)

def test_basic_float_and_int_mix():
    # Payoffs are a mix of floats and ints
    pmg = PolymatrixGame({
        (0, 1): [[1, 2.5], [3, 4]],
        (1, 0): [[-2, 7.1], [0, 1]]
    }, nums_actions=[2, 2])
    # min: -2, max: 7.1
    codeflash_output = pmg.range_of_payoffs() # 18.1μs -> 15.8μs (14.6% faster)

# -----------------
# EDGE TEST CASES
# -----------------

def test_edge_missing_matchup_filled_with_zeros():
    # Missing (1,0) matchup, should be filled with zeros
    pmg = PolymatrixGame({
        (0, 1): [[3, 4], [5, 6]]
    }, nums_actions=[2, 2])
    # (1,0) is filled with zeros, so min: 0, max: 6
    codeflash_output = pmg.range_of_payoffs() # 18.0μs -> 13.8μs (30.5% faster)

def test_edge_missing_matchup_and_actions():
    # 3 players, missing some matchups
    pmg = PolymatrixGame({
        (0, 1): [[1, 2], [3, 4]],
        (1, 2): [[-5, 0], [2, 3]],
        (2, 0): [[10, 20], [30, 40]]
    }, nums_actions=[2, 2, 2])
    # Other matchups filled with zeros, min: -5, max: 40
    codeflash_output = pmg.range_of_payoffs() # 33.3μs -> 16.0μs (108% faster)

def test_edge_unspecified_actions_filled_with_neg_inf():
    # 2 players, 3 actions, but only 2x2 matrix provided
    pmg = PolymatrixGame({
        (0, 1): [[1, 2], [3, 4]],
        (1, 0): [[-1, -2], [-3, -4]]
    }, nums_actions=[3, 3])
    # Extra actions have -np.inf payoff
    min_expected = min(1, 2, 3, 4, -1, -2, -3, -4, -np.inf)
    max_expected = max(1, 2, 3, 4, -1, -2, -3, -4, -np.inf)
    codeflash_output = pmg.range_of_payoffs() # 17.3μs -> 15.8μs (9.46% faster)

def test_edge_all_neg_inf():
    # All payoffs are -np.inf
    pmg = PolymatrixGame({
        (0, 1): [[-np.inf, -np.inf], [-np.inf, -np.inf]],
        (1, 0): [[-np.inf, -np.inf], [-np.inf, -np.inf]]
    }, nums_actions=[2, 2])
    codeflash_output = pmg.range_of_payoffs() # 16.6μs -> 15.1μs (9.36% faster)

def test_edge_large_negative_and_positive():
    # Large magnitude negative and positive payoffs
    pmg = PolymatrixGame({
        (0, 1): [[-1e9, 0], [1e9, -1e8]],
        (1, 0): [[1e8, -1e8], [0, -1e9]]
    }, nums_actions=[2, 2])
    codeflash_output = pmg.range_of_payoffs() # 17.7μs -> 14.5μs (21.6% faster)

def test_edge_single_player_no_matchups():
    # One player: no matchups
    pmg = PolymatrixGame({}, nums_actions=[1])
    # No matchups, so nothing in polymatrix; should raise ValueError
    with pytest.raises(ValueError):
        pmg.range_of_payoffs() # 2.29μs -> 3.29μs (30.4% slower)


def test_edge_non_square_matrix():
    # Non-square payoff matrices
    pmg = PolymatrixGame({
        (0, 1): [[1, 2, 3], [4, 5, 6]],
        (1, 0): [[-1, -2], [-3, -4], [-5, -6]]
    }, nums_actions=[2, 3])
    # min: -6, max: 6
    codeflash_output = pmg.range_of_payoffs() # 27.4μs -> 25.4μs (7.57% faster)

# -----------------
# LARGE SCALE TEST CASES
# -----------------

def test_large_scale_10_players_10_actions():
    # 10 players, 10 actions each, all matchups present
    n_players = 10
    n_actions = 10
    # Each payoff matrix is filled with its matchup sum
    polymatrix = {}
    for i in range(n_players):
        for j in range(n_players):
            if i != j:
                matrix = np.full((n_actions, n_actions), i + j)
                polymatrix[(i, j)] = matrix
    pmg = PolymatrixGame(polymatrix, nums_actions=[n_actions]*n_players)
    # min: 1+0=1, max: 9+8=17
    codeflash_output = pmg.range_of_payoffs() # 341μs -> 45.5μs (651% faster)

def test_large_scale_sparse_matchups():
    # 100 players, 2 actions, only one matchup present
    n_players = 100
    n_actions = 2
    polymatrix = {
        (0, 1): [[-1, 2], [3, 4]]
    }
    pmg = PolymatrixGame(polymatrix, nums_actions=[n_actions]*n_players)
    # All other matchups filled with zeros, min: -1, max: 4
    codeflash_output = pmg.range_of_payoffs() # 36.5ms -> 1.73ms (2010% faster)

def test_large_scale_random_payoffs():
    # 20 players, 5 actions, random payoffs
    n_players = 20
    n_actions = 5
    rng = np.random.default_rng(12345)
    polymatrix = {}
    min_payoff = float('inf')
    max_payoff = float('-inf')
    for i in range(n_players):
        for j in range(n_players):
            if i != j:
                mat = rng.integers(-100, 100, size=(n_actions, n_actions)).astype(float)
                polymatrix[(i, j)] = mat
                mat_min = np.min(mat)
                mat_max = np.max(mat)
                if mat_min < min_payoff:
                    min_payoff = mat_min
                if mat_max > max_payoff:
                    max_payoff = mat_max
    pmg = PolymatrixGame(polymatrix, nums_actions=[n_actions]*n_players)
    codeflash_output = pmg.range_of_payoffs() # 1.39ms -> 86.4μs (1515% faster)

def test_large_scale_missing_matchups_and_actions():
    # 50 players, 3 actions, only a few matchups present
    n_players = 50
    n_actions = 3
    polymatrix = {
        (0, 1): [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
        (2, 3): [[-10, -20, -30], [-40, -50, -60], [-70, -80, -90]],
        (4, 5): [[100, 200, 300], [400, 500, 600], [700, 800, 900]]
    }
    pmg = PolymatrixGame(polymatrix, nums_actions=[n_actions]*n_players)
    # All other matchups filled with zeros, min: -90, max: 900
    codeflash_output = pmg.range_of_payoffs() # 9.02ms -> 435μs (1973% faster)

def test_large_scale_unspecified_actions_filled_with_neg_inf():
    # 10 players, 10 actions, but only 2x2 matrices provided for a few matchups
    n_players = 10
    n_actions = 10
    polymatrix = {
        (0, 1): [[1, 2], [3, 4]],
        (2, 3): [[-1, -2], [-3, -4]]
    }
    pmg = PolymatrixGame(polymatrix, nums_actions=[n_actions]*n_players)
    # All other matchups filled with zeros, but (0,1) and (2,3) have -np.inf in unspecified actions
    min_expected = min(1, 2, 3, 4, -1, -2, -3, -4, 0, -np.inf)
    max_expected = max(1, 2, 3, 4, -1, -2, -3, -4, 0, -np.inf)
    codeflash_output = pmg.range_of_payoffs() # 340μs -> 44.1μs (672% faster)
# codeflash_output is used to check that the output of the original code is the same as that of the optimized code.
#------------------------------------------------
from collections.abc import Iterable, Mapping, Sequence
from math import isqrt

# function to test
import numpy as np
# imports
import pytest  # used for our unit tests
from numpy.typing import NDArray
from quantecon.game_theory.polymatrix_game import PolymatrixGame


def _nums_actions2string(nums_actions):
    return str(nums_actions)
from quantecon.game_theory.polymatrix_game import PolymatrixGame

# unit tests

# ----------- BASIC TEST CASES -----------

def test_basic_two_players_positive_payoffs():
    # 2 players, 2 actions each, all payoffs positive
    pm = {
        (0, 1): [[1, 2], [3, 4]],
        (1, 0): [[5, 6], [7, 8]]
    }
    game = PolymatrixGame(pm, nums_actions=[2, 2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 21.5μs -> 18.5μs (16.5% faster)

def test_basic_two_players_negative_payoffs():
    # 2 players, 2 actions each, all payoffs negative
    pm = {
        (0, 1): [[-1, -2], [-3, -4]],
        (1, 0): [[-5, -6], [-7, -8]]
    }
    game = PolymatrixGame(pm, nums_actions=[2, 2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 19.1μs -> 16.5μs (15.8% faster)

def test_basic_mixed_payoffs():
    # 2 players, 2 actions each, mixed positive and negative payoffs
    pm = {
        (0, 1): [[-1, 2], [3, -4]],
        (1, 0): [[5, -6], [7, 0]]
    }
    game = PolymatrixGame(pm, nums_actions=[2, 2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 17.5μs -> 14.7μs (18.7% faster)

def test_basic_three_players():
    # 3 players, 2 actions each, some zeros
    pm = {
        (0, 1): [[0, 1], [2, 3]],
        (1, 0): [[4, 5], [6, 7]],
        (0, 2): [[-1, -2], [-3, -4]],
        (2, 0): [[8, 9], [10, 11]],
        (1, 2): [[-5, 5], [0, 0]],
        (2, 1): [[-10, 10], [0, 0]]
    }
    game = PolymatrixGame(pm, nums_actions=[2, 2, 2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 32.6μs -> 16.0μs (103% faster)

def test_basic_single_action_each():
    # 2 players, 1 action each
    pm = {
        (0, 1): [[42]],
        (1, 0): [[-42]]
    }
    game = PolymatrixGame(pm, nums_actions=[1, 1])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 18.5μs -> 15.3μs (21.0% faster)

# ----------- EDGE TEST CASES -----------

def test_edge_empty_matrix_defaults():
    # 2 players, 2 actions each, only one matchup specified
    pm = {
        (0, 1): [[1, 2], [3, 4]]
        # (1, 0) missing, should default to zeros then filled with -inf
    }
    game = PolymatrixGame(pm, nums_actions=[2, 2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 18.2μs -> 14.5μs (25.4% faster)

def test_edge_matrix_with_inf_values():
    # 2 players, 2 actions each, some payoffs are inf or -inf
    pm = {
        (0, 1): [[np.inf, -np.inf], [0, 1]],
        (1, 0): [[-1, 2], [np.inf, -np.inf]]
    }
    game = PolymatrixGame(pm, nums_actions=[2, 2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 16.9μs -> 15.0μs (12.3% faster)

def test_edge_zeros_and_neg_inf():
    # 2 players, 2 actions each, one matrix all zeros, one all -inf
    pm = {
        (0, 1): [[0, 0], [0, 0]],
        (1, 0): [[-np.inf, -np.inf], [-np.inf, -np.inf]]
    }
    game = PolymatrixGame(pm, nums_actions=[2, 2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 16.9μs -> 13.8μs (22.9% faster)

def test_edge_partial_matrix_missing_actions():
    # 2 players, 3 actions for player 0, 2 actions for player 1
    # Only partial matrix provided for (0,1)
    pm = {
        (0, 1): [[1, 2], [3, 4]]  # should fill missing row with -inf
    }
    game = PolymatrixGame(pm, nums_actions=[3, 2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 15.9μs -> 14.0μs (13.8% faster)

def test_edge_large_negative_and_positive():
    # 2 players, 2 actions each, large magnitude values
    pm = {
        (0, 1): [[-1e10, 1e10], [1e9, -1e9]],
        (1, 0): [[-1e11, 1e11], [0, 0]]
    }
    game = PolymatrixGame(pm, nums_actions=[2, 2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 17.4μs -> 12.8μs (35.7% faster)

def test_edge_unspecified_matchup_filled_with_zeros():
    # 3 players, only one matchup specified
    pm = {
        (0, 1): [[1, 2], [3, 4]]
    }
    game = PolymatrixGame(pm, nums_actions=[2, 2, 2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 32.9μs -> 15.5μs (113% faster)

def test_edge_all_matchups_missing():
    # 2 players, no matchups specified
    pm = {}
    game = PolymatrixGame(pm, nums_actions=[2, 2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 16.3μs -> 14.4μs (13.1% faster)

def test_edge_single_player():
    # 1 player, no matchups possible
    pm = {}
    game = PolymatrixGame(pm, nums_actions=[2])
    codeflash_output = game.range_of_payoffs(); result = codeflash_output
    # No matchups, so no matrices; should raise ValueError due to min/max of empty sequence
    with pytest.raises(ValueError):
        game.range_of_payoffs()

# ----------- LARGE SCALE TEST CASES -----------

def test_large_scale_many_players_and_actions():
    # 10 players, 5 actions each
    N = 10
    A = 5
    pm = {}
    # Fill every matchup with a matrix of random values between -100 and 100
    rng = np.random.default_rng(123)
    for i in range(N):
        for j in range(N):
            if i != j:
                pm[(i, j)] = rng.integers(-100, 101, size=(A, A)).tolist()
    game = PolymatrixGame(pm, nums_actions=[A]*N)
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 337μs -> 35.2μs (861% faster)

def test_large_scale_sparse_matchups():
    # 20 players, 3 actions each, only a few matchups specified
    N = 20
    A = 3
    pm = {}
    # Only specify (i, i+1) for i in range(N-1)
    for i in range(N-1):
        pm[(i, i+1)] = [[i, -i, 0], [i+1, -(i+1), 0], [0, 0, 0]]
    game = PolymatrixGame(pm, nums_actions=[A]*N)
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 1.39ms -> 92.0μs (1415% faster)

def test_large_scale_maximum_fill():
    # 50 players, 2 actions each, all matchups filled with same values
    N = 50
    A = 2
    pm = {}
    for i in range(N):
        for j in range(N):
            if i != j:
                pm[(i, j)] = [[1, 2], [3, 4]]
    game = PolymatrixGame(pm, nums_actions=[A]*N)
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 9.05ms -> 441μs (1950% faster)

def test_large_scale_partial_matrix_fill():
    # 100 players, 2 actions each, only diagonal matchups filled
    N = 100
    A = 2
    pm = {}
    # Only (i, (i+1)%N) filled
    for i in range(N):
        pm[(i, (i+1)%N)] = [[i, -i], [i+1, -(i+1)]]
    game = PolymatrixGame(pm, nums_actions=[A]*N)
    codeflash_output = game.range_of_payoffs(); result = codeflash_output # 36.6ms -> 1.77ms (1969% faster)

To edit these changes git checkout codeflash/optimize-PolymatrixGame.range_of_payoffs-mgh46ign and push.

Codeflash

The optimization replaces the inefficient double-pass approach in `range_of_payoffs()` with a single-pass vectorized operation using NumPy.

**Key changes:**
- **Original approach**: Two separate list comprehensions calling `min([np.min(M) for M in ...])` and `max([np.max(M) for M in ...])`, which iterate through all matrices twice and involve Python's built-in `min`/`max` functions on a list of scalar values.
- **Optimized approach**: Single concatenation of all flattened matrices using `np.concatenate([M.ravel() for M in ...])`, then applying `np.min()` and `np.max()` directly on the combined array.

**Why this is faster:**
- **Eliminates redundant iterations**: Instead of scanning all matrices twice (once for min, once for max), we flatten and concatenate once, then perform both min/max operations on the same contiguous array.
- **Vectorized operations**: NumPy's `min` and `max` functions are highly optimized C implementations that operate on contiguous memory, compared to Python's built-in functions working on lists.
- **Reduces function call overhead**: The original code calls `np.min()` once per matrix, while the optimized version calls it once total.

**Performance characteristics:**
The optimization shows dramatic speedup especially for larger games - achieving **651% to 2010% improvements** on large-scale test cases with many players/matchups, while maintaining **9-30% improvements** on smaller cases. The single-pass approach scales much better as the number of matrices increases.
@coveralls
Copy link

Coverage Status

coverage: 92.58% (+0.001%) from 92.579%
when pulling e3a5e39 on codeflash-ai:codeflash/optimize-PolymatrixGame.range_of_payoffs-mgh46ign
into 29cda3a on QuantEcon:main.

@oyamad
Copy link
Member

oyamad commented Oct 16, 2025

@misrasaurabh1 Thanks for your contribution.

For this PR, the line np.concatenate([M.ravel() for M in ...]) makes a large amount of memory allocation for large games. I am not totally sure if this is justified in terms of cost/benefit balance.

An alternative approach is to use Numba with explicit loops (even for obtaining the max and the min) over the Dict (which has to be converted to a Numba typed Dict).

@misrasaurabh1
Copy link
Contributor Author

I agree the concatenation will use more memory, even though ravel helps here reduce it. We currently don't measure memory unfortunately. Although the speedup achieved is a lot. I'll leave it to your judgement

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants