codex/probability-generating-function-coefficient-convolution.py

probability-generating-function-coefficient-convolution.py
"""Exact coefficient accounting; Python 3.14, matplotlib 3.10.7, numpy 2.3.5.

Run from _media; writes the matching PNG to the current directory.
"""
from fractions import Fraction
from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle


def main():
    px = [Fraction(7, 10), Fraction(3, 10)]
    py = [Fraction(1, 4), Fraction(1, 2), Fraction(1, 4)]
    cells = [[a * b for b in py] for a in px]
    totals = [sum((cells[x][y] for x in range(2) for y in range(3)
                   if x + y == n), Fraction()) for n in range(4)]
    assert totals == [Fraction(7, 40), Fraction(17, 40),
                      Fraction(13, 40), Fraction(3, 40)]
    assert sum(totals) == 1
    colors = ['#466b9c', '#168477', '#c1841a', '#99538a']
    plt.rcParams.update({'font.size': 12, 'font.family': 'DejaVu Sans'})
    fig = plt.figure(figsize=(10, 5.5), dpi=120, facecolor='white')
    fig.suptitle('Adding counts multiplies their probability generating functions',
                 fontsize=17, y=.96)
    fig.text(.5, .875, r'$G_{X+Y}(s)=(0.7+0.3s)(1+2s+s^2)/4$',
             ha='center', fontsize=17)
    ax = fig.add_axes([.06, .22, .49, .53])
    ax.set_xlim(-.75, 3.1)
    ax.set_ylim(-.1, 2.6)
    ax.axis('off')
    fig.text(.30, .79, 'Each cell: joint mass × monomial', ha='center', fontsize=13)
    for y in range(3):
        ax.text(y + .5, 2.18, f'Y = {y}\nP = {float(py[y]):g}',
                ha='center', va='bottom', fontsize=11)
    for x in range(2):
        ax.text(-.12, 1.5 - x, f'X = {x}\nP = {float(px[x]):g}',
                ha='right', va='center', fontsize=11)
        for y in range(3):
            n = x + y
            ax.add_patch(Rectangle((y, 1-x), .97, .94,
                                  facecolor=colors[n], alpha=.16,
                                  edgecolor=colors[n], linewidth=2))
            power = '' if n == 0 else ('s' if n == 1 else f's^{n}')
            ax.text(y+.485, 1.47-x, rf'${float(cells[x][y]):.3f}\,{power}$',
                    ha='center', va='center', fontsize=15, color=colors[n])
    ax2 = fig.add_axes([.66, .22, .30, .53])
    bottoms = [0.0] * 4
    for x in range(2):
        for y in range(3):
            n = x+y
            mass = float(cells[x][y])
            ax2.bar(n, mass, bottom=bottoms[n], color=colors[n],
                    edgecolor='white', linewidth=1.5,
                    alpha=.62 if x == 0 else 1)
            ax2.text(n, bottoms[n]+mass/2, f'{mass:.3f}', ha='center',
                     va='center', color='white' if x else '#162a38', fontsize=11)
            bottoms[n] += mass
    for n, total in enumerate(totals):
        ax2.text(n, float(total)+.012, f'{float(total):.3f}', ha='center',
                 fontsize=12, weight='bold', color=colors[n])
    ax2.set(xticks=range(4), xlabel='Total n = X + Y', ylabel='Probability mass',
            ylim=(0, .49), title='Group equal powers')
    ax2.spines[['top', 'right']].set_visible(False)
    ax2.grid(axis='y', alpha=.15)
    ax2.set_axisbelow(True)
    fig.text(.5, .09, 'Exact chosen model probabilities • all six pairs accounted for • total mass = 1',
             ha='center', fontsize=12)
    fig.savefig(Path(__file__).stem + '.png', facecolor='white')
    plt.close(fig)


if __name__ == '__main__':
    main()