- #!/usr/bin/env python3
- """
- Case 20 arithmetic.
-
- Source: Pain Resilience Therapy vs. Pain Neuroscience Education, Healthcare
- 2026, 14(13):1940, doi 10.3390/healthcare14131940, PMID 42450950, PMC13362344.
- n = 60 completers, 30 per arm. Counts read from their Table 4; RR/ARR/NNT
- compared against their Table 5.
-
- Nothing here comes from prose: every count is a cell of Table 4 and every
- printed value is a cell of Table 5. Run this file to regenerate all numbers.
- """
- import numpy as np
- from scipy.stats import norm, hypergeom
-
- n1 = n2 = 30 # completers per arm (PNE, PRT); Table 4 rows sum to 30 each
-
- # name: (PNE yes, PRT yes, printed RR, printed ARR, printed NNT)
- T = {
- "STAI": (8, 13, 1.62, 0.17, 6),
- "FABQ-PA": (8, 13, 1.62, 0.17, 6),
- "FABQ-W": (4, 9, 2.25, 0.17, 6),
- "PCS": (6, 17, 2.83, 0.37, 3),
- "TSK-11": (4, 16, 4.00, 0.40, 3),
- "PASS-20": (5, 9, 1.80, 0.13, 8),
- "SER": (15, 24, 1.60, 0.30, 2),
- "CPAQ": (2, 13, 6.50, 0.37, 3),
- }
-
-
- def wilson(x, n, alpha=0.05):
- z = norm.ppf(1 - alpha / 2)
- p = x / n
- d = 1 + z * z / n
- c = (p + z * z / (2 * n)) / d
- h = z * np.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
- return p, max(0.0, c - h), min(1.0, c + h)
-
-
- def newcombe_arr(x1, n1, x2, n2, alpha=0.05):
- """Newcombe hybrid score interval for p2 - p1 (method 10, 1998)."""
- p1, l1, u1 = wilson(x1, n1, alpha)
- p2, l2, u2 = wilson(x2, n2, alpha)
- d = p2 - p1
- lo = d - np.sqrt((p2 - l2) ** 2 + (u1 - p1) ** 2)
- hi = d + np.sqrt((u2 - p2) ** 2 + (p1 - l1) ** 2)
- return d, lo, hi
-
-
- def wald_arr(x1, n1, x2, n2, alpha=0.05):
- p1, p2 = x1 / n1, x2 / n2
- se = np.sqrt(p1 * (1 - p1) / n1 + p2 * (1 - p2) / n2)
- z = norm.ppf(1 - alpha / 2)
- return p2 - p1, (p2 - p1) - z * se, (p2 - p1) + z * se
-
-
- def koopman_rr(x1, n1, x2, n2, alpha=0.05):
- """Koopman (1984) score interval for RR = p2/p1. Good coverage at small counts."""
- from scipy.stats import chi2
- from scipy.optimize import brentq, minimize_scalar
- crit = chi2.ppf(1 - alpha, 1)
-
- def T(r):
- def f(p1):
- p2 = r * p1
- if p1 <= 0 or p1 >= 1 or p2 >= 1:
- return 1e9
- return ((x1 / n1 - p1) ** 2 / (p1 * (1 - p1) / n1) +
- (x2 / n2 - p2) ** 2 / (p2 * (1 - p2) / n2))
- hi = min(1.0, 1 / r - 1e-9)
- return minimize_scalar(f, bounds=(1e-9, hi), method="bounded").fun
-
- rhat = (x2 / n2) / (x1 / n1)
- return (brentq(lambda r: T(r) - crit, 1e-6, rhat),
- brentq(lambda r: T(r) - crit, rhat, 1e5))
-
-
- def reciprocal_ci(lo, hi):
- """NNT interval from the ARR interval; flags sign change (no finite bound)."""
- if hi <= 0 or lo >= 0:
- vals = sorted([1 / lo, 1 / hi]) if lo * hi > 0 else (float("-inf"), float("inf"))
- return vals
- return (float("-inf"), float("inf"))
-
-
- print(f"{'outcome':9} {'p1':>6} {'p2':>6} {'ARR':>6} {'ARR 95% CI (Newcombe)':>26} "
- f"{'half-w':>7} {'ARR/hw':>7} {'NNT':>6} {'NNT CI':>16} {'printed':>14}")
- rows = []
- for k, (x1, x2, rr_p, arr_p, nnt_p) in T.items():
- p1, p2 = x1 / n1, x2 / n2
- arr, lo, hi = newcombe_arr(x1, n1, x2, n2)
- hw = (hi - lo) / 2
- dist = arr / hw if hw > 0 else float("inf")
- nnt = 1 / arr
- nlo, nhi = reciprocal_ci(lo, hi)
- rows.append((k, arr, lo, hi, hw, dist, nnt, nlo, nhi, nnt_p))
- print(f"{k:9} {p1:6.3f} {p2:6.3f} {arr:6.3f} {'[' + format(lo, '.3f') + ', ' + format(hi, '.3f') + ']':>26} "
- f"{hw:7.3f} {dist:7.2f} {nnt:6.2f} "
- f"{('[' + format(nlo, '.2f') + ', ' + format(nhi, '.2f') + ']'):>16} "
- f"{f'RR {rr_p} ARR {arr_p} NNT {nnt_p}':>14}")
-
- print("\n-- arithmetic check of the printed table --")
- for k, (x1, x2, rr_p, arr_p, nnt_p) in T.items():
- arr = x2 / n2 - x1 / n1
- rr = (x2 / n2) / (x1 / n1)
- print(f"{k:9} printed RR {rr_p:5.2f} vs {rr:5.2f} | printed ARR {arr_p:5.2f} vs {arr:5.3f} "
- f"| 1/ARR = {1 / arr:5.2f} -> printed NNT {nnt_p}")
-
- print("\n-- the two headline numbers --")
- for k in ("CPAQ", "SER"):
- x1, x2 = T[k][0], T[k][1]
- arr, lo, hi = newcombe_arr(x1, n1, x2, n2)
- w_lo, w_hi = wald_arr(x1, n1, x2, n2)[1:]
- rlo, rhi = koopman_rr(x1, n1, x2, n2)
- print(f"{k}: RR={(x2/n2)/(x1/n1):.2f} Koopman 95% CI [{rlo:.2f}, {rhi:.2f}]")
- print(f" ARR={arr:.3f} Newcombe [{lo:.3f}, {hi:.3f}] Wald [{w_lo:.3f}, {w_hi:.3f}]")
- print(f" half-width Newcombe {(hi-lo)/2:.3f} -> ARR/half-width = {arr/((hi-lo)/2):.2f}")
- nlo, nhi = reciprocal_ci(lo, hi)
- print(f" NNT = {1/arr:.2f}, 95% CI [{nlo:.2f}, {nhi:.2f}] "
- f"(a factor of {nhi/nlo:.1f} between the edges)")
- if w_lo <= 0:
- print(" under the Wald reading the ARR interval touches 0 -> no finite NNT bound")
- else:
- print(f" under the Wald reading NNT CI [{1/w_hi:.2f}, {1/w_lo:.2f}]")
-
- print("\n-- what the rounding hides (CPAQ) --")
- x1, x2 = T["CPAQ"][0], T["CPAQ"][1]
- for d in range(-1, 3):
- xx1 = x1 + d
- arr = x2 / n2 - xx1 / n1
- print(f" if {xx1} of 30 PNE patients had improved instead of {x1}: "
- f"ARR {arr:.3f}, NNT {1/arr:6.2f}, RR {(x2/n2)/(xx1/n1):6.2f}")