Lisping in Python

This is more or less what some students will write to evaluate whether the AI player they just wrote is any good:

def win_rate(player, opponents=["random_player",
                               "minimax_player(1)",
                               "minimax_player(2)"], N=10):
    """Return the win rate for the given player against a specifiable
    roster of opponents, averaged over N trials."""
    answer = {}
    for opponent in opponents:
        score = []
        for _ in range(N//2):
            g = game(player, eval(opponent))
            score += [player1_wins(g)]
        for _ in range(N//2):
            g = game(eval(opponent), player)
            score += [player2_wins(g)]
        answer[opponent] = np.mean(score)
    return answer

How would you review this piece of code ?

As far a code quality goes, this is not bad. The names are all well chosen and meaningful, the docstring is accurate, the implementation is straightforward, and avoid a tricky pitfall: the "first player bias", in which the first player has a statistically significant advantage.

One could nitpick the reliance on eval(), which is a bit hacky, but within the wider context makes perfect sense11: trust me. , and the reliance on playerX_wins returning a bool which in numerical context map to 1 and 0, allowing np.mean to compute the win rate. This is implicit but not terribly unexpected.

The worst I can see is that when N is odd and >=3, the number of trials is actually N-1. This does not matter for a big enough N.

Would you rewrite this function ? If so, how ?

Ever since I've learned Lisp, I tend to write in Python as I would in Lisp, making use of list and dictionary comprehension:

def win_rate(player, opponents=["random_player",
                               "minimax_player(1)",
                               "minimax_player(2)"], N=10):
    """Return the win rate for the given player against a specifiable
    roster of opponents, averaged over N trials."""
    return {opponent:
            np.mean(
                [player1_wins(game(player,         eval(opponent))) for _ in range(N//2)] +
                [player2_wins(game(eval(opponent), player))         for _ in range(N//2)])
            for opponent in opponents}

I find this formulation better than the first:

Yet, my students struggle to understand the latter form more than they struggle understanding the first one.

Maybe it is because they lack fundamental mathematical training. The lispish form is closer to the what would be the mathematical formalization of the win_rate function: "Let win_rate be a function that returns a mapping from each opponent to the win rate of the given player against said opponent".

Maybe it is because Lisp is a write-once language, and the elegance of the second implementation is only visible to the author.

In any case, Python is flexible enough to accomodate both styles, and that makes it a nice teaching language despite its horribly irregular syntax.

1. Changelog

<2024-12-25 Wed> Initial version