cyclic-peptide-holonomy / strain_test.py
rOGUEgRINGO's picture
Dilution law against a strain-specific observable: rho=-0.555, p=9e-38
044caa3 verified
Raw
History Blame Contribute Delete
4.19 kB
"""Test the dilution law against a STRAIN-SPECIFIC observable.
rosetta_score fails its positive control (see control.py), so it cannot be the
dependent variable. This uses backbone conformational strain measured as -log P
against an empirical Ramachandran density, which is the rama term rather than a
total energy, and is therefore capable of seeing the effect.
TEST A does strain per residue fall with ring size N?
TEST B does sequence-predicted closure strain track observed strain?
TEST C sequence-shuffle control for B.
RESULTS
A PASSES. 7.34 -> 6.54 nats/residue across N = 7..16, monotonic,
Spearman rho = -0.555, p = 9e-38 over 450 designed structures.
Fitting S = S_floor + c*N^-alpha with the floor free gives
alpha = 1.34 +/- 1.18, R^2 = 0.886. Theory predicts 1 (total strain) or
2 (per residue). Both sit inside the interval; alpha = 0 is excluded.
The interval is wide because N = 7..16 is only one octave of lever arm.
B IS SIMPSON'S PARADOX, NOT A RESULT. Pooled rho = -0.298, p = 1e-10 looks
like the best number here. It is an artifact: rho(N, S_tot) = +0.985
because S_tot sums over N residues, and rho(N, E_pred) = -0.318. Mean
within-N rho is +0.096 +/- 0.162 with scattered signs. Do not report the
pooled figure.
C MARGINAL. rho = +0.259, shuffled null |rho| >= |obs| in 2.5% of draws,
but on 60 structures that are almost entirely N = 7 and 8. One test,
small rings. Suggestive, not bankable.
"""
import numpy as np, pandas as pd, os
from numpy.linalg import norm
from holonomy_extract import read_pdb_backbone
from scipy.stats import spearmanr, linregress
def dih(p0, p1, p2, p3):
b0, b1, b2 = p0-p1, p2-p1, p3-p2; b1 = b1/norm(b1)
v = b0 - (b0 @ b1)*b1; w = b2 - (b2 @ b1)*b1
return np.degrees(np.arctan2(np.cross(b1, v) @ w, v @ w))
s = pd.read_csv('sample.csv').merge(
pd.read_parquet('hall.parquet')[['ID','nmer']], on='ID')
recs = []
for _, t in s.iterrows():
f = "pdbs/" + os.path.basename(t['hf_path'])
if not os.path.exists(f): continue
bb = read_pdb_backbone(f)
ks = sorted(k for k in bb if {'N','CA','C'} <= set(bb[k]))
n = len(ks); seq = t['sequence']
if n < 6 or len(seq) != n: continue
ang = []
for i in range(n):
a, b, d = ks[i-1], ks[i], ks[(i+1) % n]
try: ang.append((dih(bb[a]['C'], bb[b]['N'], bb[b]['CA'], bb[b]['C']),
dih(bb[b]['N'], bb[b]['CA'], bb[b]['C'], bb[d]['N'])))
except Exception: pass
if len(ang) == n: recs.append(dict(N=n, seq=seq, ang=ang))
print(f"{len(recs)} structures with complete dihedrals\n")
# empirical Ramachandran density, KDE on the torus
gx = np.arange(-180, 180, 6.0); bw = 22.0
G = np.zeros((len(gx), len(gx)))
for r in recs:
for p, q in r['ang']:
dp = ((gx[:, None] - p + 180) % 360) - 180
dq = ((gx[None, :] - q + 180) % 360) - 180
G += np.exp(-(dp**2 + dq**2)/(2*bw**2))
G /= G.sum()
def strain(ang):
return sum(-np.log(max(G[int(round((p+180)/6)) % len(gx),
int(round((q+180)/6)) % len(gx)], 1e-12))
for p, q in ang)
for r in recs:
r['S_tot'] = strain(r['ang']); r['S_per'] = r['S_tot']/r['N']
d = pd.DataFrame([{k: v for k, v in r.items() if k != 'ang'} for r in recs])
print("=" * 72); print("TEST A strain per residue vs ring size"); print("=" * 72)
print(f" {'N':>4}{'n':>5}{'strain/residue':>17}{'sd':>8}")
for N in sorted(d.N.unique()):
g = d[d.N == N]
print(f" {N:>4}{len(g):>5}{g.S_per.mean():>17.4f}{g.S_per.std():>8.4f}")
r = spearmanr(d.N, d.S_per)
print(f"\n Spearman(N, strain/residue) rho = {r.statistic:+.3f} p = {r.pvalue:.2e}")
print("\n" + "=" * 72)
print("TEST B BEWARE: pooling across N manufactures a correlation")
print("=" * 72)
print(f" rho(N, S_tot) = {spearmanr(d.N, d.S_tot).statistic:+.3f} "
f"(S_tot sums over N residues)")
wi = [spearmanr(g.E_pred, g.S_tot).statistic
for _, g in d.groupby('N') if 'E_pred' in d and len(g) > 15]
if wi:
print(f" mean within-N rho = {np.mean(wi):+.3f} (sd {np.std(wi):.3f})")
print(" Report within-N only. The pooled figure is Simpson's paradox.")