codexmarkov-classes-absorption-closed-cycle.py
markov-classes-absorption-closed-cycle.py
"""Draw closed classes versus an absorbing state in a constructed chain.
Run from _media: MPLCONFIGDIR=/tmp/codex-wiki-matplotlib python ../markov-classes-absorption-closed-cycle.py
Tested with Python 3.14.4, matplotlib 3.10.7, numpy 2.3.5.
"""
from pathlib import Path
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, FancyArrowPatch, FancyBboxPatch
from matplotlib.path import Path as MPath
def main():
plt.rcParams.update({'font.family': 'DejaVu Sans', 'font.size': 12})
fig, ax = plt.subplots(figsize=(7.6, 4.6), dpi=120, facecolor='white')
ax.set_facecolor('white')
ax.set(xlim=(-1.25, 5.6), ylim=(-2.25, 2.2), aspect='equal')
ax.axis('off')
ink, blue, orange, green = '#273746', '#246b9b', '#b46418', '#267050'
pos = {'s': (0, 0), 'c1': (2.6, .95), 'c2': (4.65, .95), 'a': (3.65, -1.15)}
for x, y, w, h, color in [(-.7, -.6, 1.4, 1.95, orange), (1.95, .1, 3.4, 1.65, blue), (2.75, -1.9, 2.15, 1.45, green)]:
ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle='round,pad=0.03,rounding_size=0.15', lw=1.4, linestyle=(0, (4, 3)), facecolor='white', edgecolor=color, zorder=0))
for name, xy in pos.items():
color = orange if name == 's' else green if name == 'a' else blue
ax.add_patch(Circle(xy, .28, facecolor='#f8f4ee' if name == 's' else '#e8f4eb' if name == 'a' else '#e8f1fa', edgecolor=color, lw=2, zorder=3))
if name == 'a':
ax.add_patch(Circle(xy, .22, fill=False, edgecolor=green, lw=1, zorder=4))
text = {'s': '$s$', 'c1': '$c_1$', 'c2': '$c_2$', 'a': '$a$'}[name]
ax.text(*xy, text, ha='center', va='center', fontsize=15, color=ink, zorder=5)
def edge(start, end, label, xy, rad=0):
ax.add_patch(FancyArrowPatch(pos[start], pos[end], arrowstyle='-|>', mutation_scale=15, shrinkA=22, shrinkB=22, connectionstyle=f'arc3,rad={rad}', lw=1.7, color=ink))
ax.text(*xy, label, ha='center', va='center', color=ink, bbox={'facecolor':'white', 'edgecolor':'none', 'pad':1.5})
edge('s', 'c1', '1/4', (1.13, .65))
edge('s', 'a', '1/4', (1.3, -.70))
edge('c1', 'c2', '1', (3.65, 1.45), -.3)
edge('c2', 'c1', '1', (3.65, .46), -.3)
def loop(name, label, direction):
x, y = pos[name]
d = direction
vertices = [(x-.16, y+.23*d), (x-.8, y+1.02*d), (x+.8, y+1.02*d), (x+.16, y+.23*d)]
path = MPath(vertices, [MPath.MOVETO, MPath.CURVE4, MPath.CURVE4, MPath.CURVE4])
ax.add_patch(FancyArrowPatch(path=path, arrowstyle='-|>', mutation_scale=13, lw=1.7, color=ink))
ax.text(x, y+.85*d, label, ha='center', va='center', color=ink, bbox={'facecolor':'white','edgecolor':'none','pad':1})
loop('s', '1/2', 1)
# Side loop keeps the absorbing state and its class label apart.
x, y = pos['a']
path = MPath([(x+.23,y+.16),(x+1.2,y+.7),(x+1.2,y-.7),(x+.23,y-.16)], [MPath.MOVETO,MPath.CURVE4,MPath.CURVE4,MPath.CURVE4])
ax.add_patch(FancyArrowPatch(path=path, arrowstyle='-|>', mutation_scale=13, lw=1.7, color=ink))
ax.text(x+1.0,y,'1', ha='center', va='center', color=ink, bbox={'facecolor':'white','edgecolor':'none','pad':1})
ax.text(0, -.46, 'open', color=orange, ha='center', fontsize=11)
ax.text(3.65, .22, 'closed: alternates forever', color=blue, ha='center', fontsize=10)
ax.text(3.65, -1.72, 'closed: absorbing', color=green, ha='center', fontsize=10)
fig.text(.5, .96, 'A closed class need not be an absorbing state', ha='center', va='top', fontsize=15, weight='bold', color=ink)
fig.text(.5, .89, 'Constructed toy chain • dashed outlines are communicating classes', ha='center', fontsize=10, color=ink)
fig.text(.5, .09, r'From $s$: P(enter the pair) = P(hit $a$) = 1/2', ha='center', fontsize=12, color=ink)
fig.text(.5, .035, r'Mean time to either closed class: 2 steps', ha='center', fontsize=11, color=ink)
fig.subplots_adjust(left=.035, right=.965, top=.84, bottom=.15)
fig.savefig(Path(__file__).with_suffix('.png').name, facecolor='white', transparent=False)
plt.close(fig)
if __name__ == '__main__':
main()