To blue or not to blue?

To continue the analysis, I would love to go into detail on the math[1] but it would be boring.

We are now computing the rational choice, i.e., the choice that saves the most people in expectation, depending on how we think others will vote. This is represented by a probability p of voting blue. We saw previously that if we assume p = 0.5, then blue is always the rational choice. Now we want to know the result for different values of p.

The result is that for any p < 0.5, with a small number of participants, blue is (sometimes when p is high enough) the rational choice. However, at some point, with enough participants, the chances of you being the deciding vote (i.e., basically being able to save them) become too low, and red becomes the rational choice. When p \geq 0.5, blue is always the rational choice.

I made a graph, although it was better in my head. (I cut off less than 10% and more than 90% because you can probably guess the color in those areas).

I wanted to upload the file but it does not allow .py so I’ll just add it here.

the (python) code
#!/usr/bin/env python3

# mostly AI generated
# you'll need to "pip install numpy matplotlib scipy"
"""
Generate a cool 2D heatmap showing where X*p_exact > p_less for 2X coin tosses.

- X-axis: number of participants
- Y-axis: p (probability of blue)
- Color:
    - Blue if X * P(exactly X blue | p) > P(less than X blue | p)
    - Red  if X * P(exactly X blue | p) < P(less than X blue | p)
"""

import math
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
from scipy.stats import binom
from typing import Tuple, Optional


def calculate_probabilities(p: float, X: int) -> Tuple[float, float]:
    """
    Compute P(exactly X blue) and P(less than X blue) for 2X tosses.

    Args:
        p: Probability of blue (0 <= p <= 1)
        X: Non-negative integer; total tosses = 2X

    Returns:
        (p_exact, p_less): Probabilities of exactly X blue and less than X blue
    """
    if not (0.0 <= p <= 1.0):
        raise ValueError("p must be between 0 and 1 inclusive.")
    if X < 0:
        raise ValueError("X must be a non-negative integer.")
    
    n = 2 * X
    # P(exactly X blue)
    p_exact = binom.pmf(X, n, p)
    # P(less than X blue)
    if X > 0:
        p_less = binom.cdf(X - 1, n, p)
    else:
        # X == 0: zero tosses, zero blue; less than 0 impossible
        p_less = 0.0
    return p_exact, p_less

def is_blue_rational(p: float, X: int) -> bool:
    """
    Return True if X * P(exactly X blue | p) >= P(less than X blue | p)
    (this corresponds to BLUE having higher expected value).
    """
    p_exact, p_less = calculate_probabilities(p, X)
    left = X * p_exact
    right = p_less
    return left >= right

def find_x_threshold(p: float, max_search: int = 10**6, verbose: bool = False) -> Optional[int]:
    """
    Find the smallest integer X >= 0 such that:
        X * P(exactly X blue in 2X tosses | p) < P(less than X blue in 2X tosses | p)
        i.e. such that red becomes better (assuming probability of blue is less than 0.5)

    Args:
        p: Probability of blue (0 <= p <= 1)
        max_search: Maximum X to search (to avoid infinite loops; increase if needed)
        verbose: If True, prints progress for large searches

    Returns:
        The smallest X satisfying the inequality, or None if no such X exists
        (notably when p == 0.5, the inequality is never satisfied).
    """
    if not (0.0 <= p <= 1.0):
        raise ValueError("p must be between 0 and 1 inclusive.")
    
    # Special case: p == 0.5 -> inequality never holds
    if p >= 0.5:
        return None
    
    # Search upward from X = 0
    for X in range(0, max_search + 1):
        p_exact, p_less = calculate_probabilities(p, X)
        left = X * p_exact
        right = p_less
        if verbose and X % 10_000 == 0 and X > 0:
            print(f"Searching X = {X:,} ... left={left:.6e}, right={right:.6e}")
        if left < right:
            return X
    
    # If not found within max_search
    return None


def make_plot():
    # --- Configuration: choose ranges to make a nice plot ---
    # X ranges (half the tosses). Keep reasonable so it’s fast but informative.
    X_min = 1
    X_max = 1500        # increase to 80–120 for a sharper boundary (slower)
    X_step = 10       # grid resolution in X

    # p ranges
    p_min = 0.1
    p_max = 0.9
    p_step = 0.001     # grid resolution in p (0.005 is smoother but slower)

    # Build grids
    X_vals = np.arange(X_min, X_max + 1, X_step)
    p_vals = np.arange(p_min, p_max + 0.0000001, p_step)
    
    # Create a 2D array: rows -> p, columns -> X
    heatmap = np.zeros((len(p_vals), len(X_vals)), dtype=np.uint8)
    
    # Fill the grid: 1 = BLUE (inequality holds: X*p_exact > p_less), 0 = RED otherwise
    for i, p in enumerate(p_vals):
        for j, X in enumerate(X_vals):
            if is_blue_rational(p, X):
                heatmap[i, j] = 1  # blue
            else:
                heatmap[i, j] = 0  # red
    
    # --- Plotting ---
    # Define a clean blue/red colormap
    cmap = ListedColormap(["#d73027", "#4575b4"])  # red, blue
    # (index 0 -> red, index 1 -> blue)

    fig, ax = plt.subplots(figsize=(10, 6), dpi=120)

    # Show heatmap: note: imshow expects (rows, cols) = (y, x)
    im = ax.imshow(
        heatmap,
        cmap=cmap,
        origin="lower",
        aspect="auto",
        extent=[X_min - 0.5 * X_step, X_max + 0.5 * X_step, p_min, p_max],
        interpolation="nearest"
    )

    # Axis labels and title
    ax.set_xlabel("number of participants", fontsize=12)
    ax.set_ylabel("probability of blue vote", fontsize=12)

    # Ticks
    # Choose reasonable tick spacing for X
    x_tick_step = max(1, (X_max - X_min) // 20)
    x_ticks = np.arange(X_min, X_max + 1, x_tick_step)
    ax.set_xticks(x_ticks)
    # Replace labels with 2X+1
    ax.set_xticklabels([str(2 * x + 1) for x in x_ticks])

    # p ticks
    p_tick_step = 0.05
    ax.set_yticks(np.arange(p_min, p_max + 1e-12, p_tick_step))

    # Add a simple legend (colorbar-like legend)
    # Create proxy artists
    from matplotlib.patches import Patch
    legend_elements = [
        Patch(facecolor="#4575b4", edgecolor="black", label="Blue wins"),
        Patch(facecolor="#d73027", edgecolor="black", label="Red wins"),
    ]
    ax.legend(handles=legend_elements, loc="upper right", frameon=True)

    # Optional: add a faint grid to help read values
    ax.grid(True, which="both", linestyle=":", linewidth=0.5, alpha=0.4)

    plt.tight_layout()
    # Save or show
    # plt.savefig("inequality_heatmap.png", bbox_inches="tight")
    plt.show()

def main():
    make_plot()

if __name__ == "__main__":
    main()

  1. this is sarcastic ↩︎