codex/past-exam-of-the-clinical-social-cognitive-neuroscience-course-of-the-university-of-oxford/2022-heartbeat-counting.py

past-exam-of-the-clinical-social-cognitive-neuroscience-course-of-the-university-of-oxford/2022-heartbeat-counting.py
"""Original counterexample to interpreting a heartbeat-counting score as detection.

Python 3.14.4 / NumPy 2.3.5 / Matplotlib 3.10.7, existing pyproject dependencies.
All values are calculated examples, not measurements from people.
Writes its same-basename white opaque PNG into the current working directory.
"""
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch

def score(recorded,reported):
 recorded=np.asarray(recorded,dtype=float)
 assert np.all(recorded>0)
 return 1-np.abs(recorded-reported)/recorded
assert score(60,60)==1
assert np.isclose(score(90,60),2/3)
assert score(30,60)==0
assert score(20,60)==-1 # No unasserted clipping to [0,1].
actual=np.arange(30,121);reported=60;scores=score(actual,reported)
plt.rcParams.update({'font.family':'DejaVu Sans','font.size':10})
fig,(ax,b)=plt.subplots(1,2,figsize=(9.2,4.3),dpi=100,facecolor='white',gridspec_kw={'width_ratios':[1.1,1]})
fig.subplots_adjust(left=.075,right=.98,bottom=.22,top=.80,wspace=.28)
ax.plot(actual,scores,lw=2,color='#176b9e');ax.scatter([30,60,90],[0,1,2/3],color='#b95816',s=28,zorder=4)
ax.set(xlim=(28,122),ylim=(-.045,1.12),xlabel='Actual beats in one minute, N',ylabel='Score  A = 1 − |N − 60| / N')
ax.grid(alpha=.18);ax.annotate('Perfect score\nwithout detecting beats',xy=(60,1),xytext=(73,.96),fontsize=9,arrowprops={'arrowstyle':'->','color':'#42627a'})
ax.set_title('Fixed estimate: always report 60',fontsize=11)
b.axis('off');b.set(xlim=(0,1),ylim=(0,1))
def box(x,y,w,h,text):
 b.add_patch(FancyBboxPatch((x,y),w,h,boxstyle='round,pad=.015',facecolor='#edf3f8',edgecolor='#42627a'))
 b.text(x+w/2,y+h/2,text,ha='center',va='center',fontsize=9,linespacing=1.4)
box(.02,.70,.96,.23,'Actual count N = 60\nReported count C = 60')
box(.01,.22,.45,.24,'Detect all beats\n→ report 60')
box(.54,.22,.45,.24,'Estimate 60 bpm\n→ report 60')
for end in [(.23,.48),(.77,.48)]:b.add_patch(FancyArrowPatch((.5,.68),end,arrowstyle='-|>',mutation_scale=12,color='#42627a'))
b.text(.5,.08,'Same observations and score;\ndifferent latent reporting strategies',ha='center',va='center',fontsize=10)
fig.suptitle('A perfect counting score does not identify heartbeat detection',fontsize=13,y=.97)
fig.text(.5,.09,'Calculated counterexample, not participant data. The fixed-rate estimator receives no heartbeat signal.',ha='center',fontsize=9)
fig.text(.5,.035,'Unclipped score shown for N = 30…120; sufficiently large overcounts can produce negative values.',ha='center',fontsize=9)
fig.savefig(Path.cwd()/(Path(__file__).stem+'.png'),facecolor='white',transparent=False,dpi=100,metadata={'Software':'Matplotlib','Description':'Original calculated heartbeat-counting identifiability counterexample'})
plt.close(fig)