Skip to content

Commit 9ed2549

Browse files
committed
feat: add Chou-Fasman helix propensity scoring (Pα) to feature pipeline
Add helix_propensity_score() using Chou-Fasman (1974) Pα parameters. The mean Pα across all residues quantifies helical-forming tendency: >1.03 is helix-forming, <0.75 is helix-breaking (Pro=0.57, Gly=0.57). All 5 pilot panel seed families rely on helical amphipathicity for membrane disruption, so this feature adds discriminative signal against proline-rich or random sequence backgrounds. - physchem.py: _HELIX_PROPENSITY table + helix_propensity_score(); exposes helix_propensity key in compute_features() return dict - activity.py: helix_bonus (max +0.01) added; amphipathicity weight reduced 0.15→0.14 to maintain score ceiling below 1.0 - tests/test_helix_propensity.py: 23 tests (Pα values, helix vs proline-rich sequences, compute_features integration, activity score fallback) - tests/test_scoring_activity.py: update hardcoded weight assertion 0.075→0.07 - docs/DISCOVERY_PREDICTION.md: mark PR #34 done, add item 5 for Chou-Fasman
1 parent a6bdb41 commit 9ed2549

5 files changed

Lines changed: 183 additions & 16 deletions

File tree

‎docs/DISCOVERY_PREDICTION.md‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
**Pipeline:** OpenAMP-Foundry v0.1.0
44
**Date:** 2026-06-28 (updated 2026-06-28)
55
**Status:** Pre-synthesis scientific assessment — for expert review before ordering
6-
**Completed improvements:** Serum stability scoring (PR #31/#32), Family diversity cap (PR #31), Reference set expansion 44→73 sequences (PR #33)
6+
**Completed improvements:** Serum stability scoring (PR #31/#32), Family diversity cap (PR #31), Reference set expansion 44→73 sequences (PR #33), Net charge at pH 7.4 via Henderson-Hasselbalch (PR #34)
77

88
---
99

@@ -275,18 +275,27 @@ WLBU2, BP100, omiganan, and other well-characterized diverse families.
275275
Note: does not change novelty scores for current nominees (seeds are still in references);
276276
improves discrimination for future runs with non-seed-derived candidates.
277277

278-
**4. Net charge at physiological pH (Recommended next)**
279-
Replace `net_charge_proxy` (counts His as fully cationic) with `net_charge_at_ph74()` using
280-
Henderson-Hasselbalch equation (His ≈ +0.11 at pH 7.4, pKa=6.5 in peptides).
281-
Affects: 24/88 nominees contain His; charge density overestimated by ~0.08/His residue.
282-
Expected impact: re-ranks a few His-containing candidates; ~+2pp on Stage 1 accuracy.
283-
284-
**4. D-amino acid variants (Wave 2 synthesis)**
278+
**4. Net charge at physiological pH ✅ DONE (PR #34)**
279+
Replaced `net_charge_proxy` (counts His as +1.0) with `net_charge_at_ph74()` using
280+
Henderson-Hasselbalch (His ≈ +0.114 at pH 7.4, pKa=6.5 in peptides). New fields
281+
`net_charge_ph74` and `charge_density_ph74` in all feature dicts; `activity_likeness_score()`
282+
now uses physiologically accurate charge density.
283+
Impact: charge density corrected for 24/88 nominees with His; proxy overestimated by ~0.89
284+
per His residue, affecting scoring accuracy not final panel composition.
285+
286+
**5. Chou-Fasman helix propensity (Recommended next)**
287+
Add `helix_propensity` feature using Chou-Fasman (1974) residue parameters. Helical AMPs
288+
(cecropin, magainin, LL-37 family — all 5 seeds in pilot panel) rely on amphipathic helix
289+
formation for membrane disruption. Mean helix propensity above 1.05 distinguishes good
290+
helical AMPs from unstructured sequences more accurately than hydrophobic_fraction alone.
291+
Affects: all 88 nominees; expected to improve AUROC by ~0.01–0.02.
292+
293+
**6. D-amino acid variants (Wave 2 synthesis)**
285294
Replace all L-amino acids with D-amino acids in top 2 active Wave 1 hits.
286295
Expected: serum t½ increases from <30 min to >8 h (literature: Wade et al. 1990).
287296
Impact: crosses the serum stability gate that currently blocks ~80% of candidates.
288297

289-
**5. MDR strain panel (Assay expansion)**
298+
**7. MDR strain panel (Assay expansion)**
290299
Add MRSA USA300, E. coli ST131, K. pneumoniae ATCC BAA-1705 (KPC) to MIC assay.
291300
Impact: any hit against MDR strains is immediately publishable as clinically significant.
292301

‎src/openamp_foundry/features/physchem.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,32 @@
2323
"S": -0.180, "T": -0.050, "W": 0.810, "Y": 0.260, "V": 1.080,
2424
}
2525

26+
# Chou-Fasman helix propensity parameters (Pα values).
27+
# Source: Chou PY & Fasman GD (1974) Biochemistry 13:222-245 (Table IV).
28+
# Values > 1.0 are helix-forming; 0.75–1.0 indifferent; < 0.75 helix-breaking.
29+
# Proline (0.57) and Glycine (0.57) are the canonical helix breakers.
30+
_HELIX_PROPENSITY: dict[str, float] = {
31+
"A": 1.42, "R": 0.98, "N": 0.67, "D": 1.01, "C": 0.70,
32+
"Q": 1.11, "E": 1.51, "G": 0.57, "H": 1.00, "I": 1.08,
33+
"L": 1.21, "K": 1.16, "M": 1.45, "F": 1.13, "P": 0.57,
34+
"S": 0.77, "T": 0.83, "W": 1.08, "Y": 0.69, "V": 1.06,
35+
}
36+
37+
38+
def helix_propensity_score(sequence: str) -> float:
39+
"""Mean Chou-Fasman alpha-helix propensity (Pα) for the sequence.
40+
41+
Returns the mean Pα across all residues. Values > 1.03 indicate a helix-forming
42+
tendency over the full sequence. The reference midpoint is 1.0 (indifferent).
43+
44+
Source: Chou & Fasman (1974) Biochemistry 13:222-245.
45+
Unknown residues are assigned the indifferent value (1.0).
46+
"""
47+
if not sequence:
48+
return 0.0
49+
total = sum(_HELIX_PROPENSITY.get(aa, 1.0) for aa in sequence)
50+
return round(total / len(sequence), 4)
51+
2652

2753
def net_charge_proxy(sequence: str) -> int:
2854
return sum(1 for aa in sequence if aa in POSITIVE) - sum(1 for aa in sequence if aa in NEGATIVE)
@@ -99,6 +125,7 @@ def compute_features(sequence: str) -> dict[str, float | int | dict[str, int]]:
99125
# site density: interior cleavage sites per residue (0 = stable, 1 = all residues cleave)
100126
trypsin_density = round(n_trypsin / length if length else 0.0, 4)
101127
chymotrypsin_density = round(n_chymotrypsin / length if length else 0.0, 4)
128+
helix_pa = helix_propensity_score(sequence)
102129
return {
103130
"length": length,
104131
"net_charge_proxy": charge,
@@ -110,6 +137,7 @@ def compute_features(sequence: str) -> dict[str, float | int | dict[str, int]]:
110137
"proline_fraction": round(pro_fraction, 4),
111138
"longest_repeat_run": repeat_run,
112139
"hydrophobic_moment": mu_h,
140+
"helix_propensity": helix_pa,
113141
"boman_index": boman_index(sequence),
114142
"gravy": gravy_score(sequence),
115143
"residue_counts": dict(sorted(counts.items())),

‎src/openamp_foundry/scoring/activity.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ def activity_likeness_score(features: dict) -> float:
2525
hydrophobic = features["hydrophobic_fraction"]
2626
aromatic = features["aromatic_fraction"]
2727
mu_h = features.get("hydrophobic_moment", 0.0)
28+
# Chou-Fasman mean Pα: range ~0.57 (all Pro/Gly) to ~1.51 (all Glu)
29+
# AMP-relevant range: 1.0 (indifferent) to ~1.20 (good helical sequence)
30+
helix_pa = features.get("helix_propensity", 1.0)
2831

2932
# Length: peak at ~18 residues, broad tolerance
3033
length_score = 1.0 - min(abs(length - 18) / 25, 1.0)
@@ -40,16 +43,20 @@ def activity_likeness_score(features: dict) -> float:
4043

4144
# Amphipathicity: helical hydrophobic moment > 0.4 is associated with activity
4245
# Typical range for AMPs: 0.3–0.8; scale to [0,1] over 0–0.8 range
43-
amphipathicity_score = clamp01(mu_h / 0.8) * 0.15
46+
amphipathicity_score = clamp01(mu_h / 0.8) * 0.14
4447

45-
# Base weights (0.24+0.27+0.17 = 0.68) leave 0.32 headroom for bonuses
46-
# (aromatic max 0.10 + amphipathicity max 0.15 = 0.25), ceiling = 0.93 < 1.0.
47-
# Scale factor vs original (0.28, 0.32, 0.20): multiply each by 0.68/0.80 ≈ 0.85.
48+
# Helix propensity: Chou-Fasman Pα > 1.03 rewards helix-forming sequences
49+
# Scale: indifferent (1.0) → 0.0; strong helix-former (1.20+) → 1.0
50+
helix_bonus = clamp01((helix_pa - 1.0) / 0.20) * 0.01
51+
52+
# Base weights (0.24+0.27+0.17 = 0.68) leave headroom for bonuses
53+
# aromatic max 0.10 + amphipathicity max 0.14 + helix max 0.01 = 0.25 → ceiling 0.93
4854
score = (
4955
0.24 * length_score
5056
+ 0.27 * charge_score
5157
+ 0.17 * hydro_score
5258
+ aromatic_bonus
5359
+ amphipathicity_score
60+
+ helix_bonus
5461
)
5562
return round(clamp01(score), 4)

‎tests/test_helix_propensity.py‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Tests for helix_propensity_score() (Chou-Fasman Pα) and its integration."""
2+
from __future__ import annotations
3+
4+
from openamp_foundry.features.physchem import compute_features, helix_propensity_score
5+
from openamp_foundry.scoring.activity import activity_likeness_score
6+
7+
8+
class TestHelixPropensityScore:
9+
def test_empty_sequence_returns_zero(self):
10+
assert helix_propensity_score("") == 0.0
11+
12+
def test_single_ala_returns_142(self):
13+
# Ala is the strongest helix-former: Pα = 1.42
14+
result = helix_propensity_score("A")
15+
assert result == 1.42
16+
17+
def test_single_gly_low_propensity(self):
18+
# Gly is helix-breaking: Pα = 0.57
19+
assert helix_propensity_score("G") == 0.57
20+
21+
def test_single_pro_low_propensity(self):
22+
# Pro is helix-breaking: Pα = 0.57
23+
assert helix_propensity_score("P") == 0.57
24+
25+
def test_poly_ala_high_propensity(self):
26+
result = helix_propensity_score("AAAAAAAAAA")
27+
assert result == 1.42
28+
29+
def test_poly_glu_highest_propensity(self):
30+
# Glu is the strongest helix-former in the table: Pα = 1.51
31+
result = helix_propensity_score("EEEEEEEEEE")
32+
assert result == 1.51
33+
34+
def test_poly_gly_helix_breaking(self):
35+
result = helix_propensity_score("GGGGGGGGGG")
36+
assert result == 0.57
37+
38+
def test_helix_forming_gt_breaking(self):
39+
# Ala-rich > Gly-rich
40+
assert helix_propensity_score("AAAAAAA") > helix_propensity_score("GGGGGGG")
41+
42+
def test_indifferent_near_1p0(self):
43+
# His has Pα = 1.0 (exactly indifferent)
44+
result = helix_propensity_score("H")
45+
assert result == 1.0
46+
47+
def test_mean_computed_correctly(self):
48+
# A (1.42) + G (0.57) = mean 0.995
49+
result = helix_propensity_score("AG")
50+
assert abs(result - round((1.42 + 0.57) / 2, 4)) < 1e-6
51+
52+
def test_unknown_residue_uses_indifferent_1p0(self):
53+
# X not in table → default 1.0
54+
# A (1.42) + X (1.0) = mean 1.21
55+
result = helix_propensity_score("AX")
56+
assert abs(result - round((1.42 + 1.0) / 2, 4)) < 1e-6
57+
58+
def test_returns_float_rounded_to_4dp(self):
59+
result = helix_propensity_score("KWKLFKK")
60+
assert isinstance(result, float)
61+
assert result == round(result, 4)
62+
63+
def test_classic_amp_above_1p0(self):
64+
# KWKLFKKIGAVLKVL is a well-characterized helical AMP; should have Pα > 1.0
65+
result = helix_propensity_score("KWKLFKKIGAVLKVL")
66+
assert result > 1.0
67+
68+
def test_proline_rich_below_1p0(self):
69+
# GNRPVYIPQPRPPHPR (apidaecin) — many P residues → helix-breaking
70+
result = helix_propensity_score("GNRPVYIPQPRPPHPR")
71+
assert result < 1.0
72+
73+
74+
class TestComputeFeaturesHelixPropensity:
75+
def test_helix_propensity_key_present(self):
76+
features = compute_features("KWKLFKKIGAVLKVL")
77+
assert "helix_propensity" in features
78+
79+
def test_helix_propensity_is_float(self):
80+
features = compute_features("KWKLFKKIGAVLKVL")
81+
assert isinstance(features["helix_propensity"], float)
82+
83+
def test_helix_propensity_positive(self):
84+
features = compute_features("KWKLFKKIGAVLKVL")
85+
assert features["helix_propensity"] > 0.0
86+
87+
def test_helix_propensity_reasonable_range(self):
88+
# Realistic sequences: Pα range 0.57–1.51, typical AMPs ~0.95–1.15
89+
features = compute_features("KWKLFKKIGAVLKVL")
90+
assert 0.5 <= features["helix_propensity"] <= 1.6
91+
92+
def test_empty_sequence_no_crash(self):
93+
features = compute_features("")
94+
assert features["helix_propensity"] == 0.0
95+
96+
def test_helical_amp_vs_proline_rich(self):
97+
# Helical AMP (magainin-like) should have higher Pα than proline-rich
98+
helical_features = compute_features("GIGKFLHSAKKFGKAFVGEIMNS")
99+
proline_features = compute_features("GNRPVYIPQPRPPHPR")
100+
assert helical_features["helix_propensity"] > proline_features["helix_propensity"]
101+
102+
103+
class TestActivityScoreWithHelixPropensity:
104+
def test_activity_score_still_in_0_1(self):
105+
features = compute_features("KWKLFKKIGAVLKVL")
106+
score = activity_likeness_score(features)
107+
assert 0.0 <= score <= 1.0
108+
109+
def test_helix_forming_amp_gets_bonus(self):
110+
# KWKLFKKIGAVLKVL (Leu-rich helical AMP) vs GGGGGGGGGG (helix-breaker)
111+
# The helix-forming sequence should score higher
112+
helical_features = compute_features("KWKLFKKIGAVLKVL")
113+
gly_features = compute_features("GGGGGGGGGG")
114+
assert activity_likeness_score(helical_features) > activity_likeness_score(gly_features)
115+
116+
def test_fallback_when_helix_propensity_missing(self):
117+
# If helix_propensity is absent (old feature dict), should not crash
118+
minimal_features = {
119+
"length": 15, "charge_density": 0.27, "hydrophobic_fraction": 0.47,
120+
"aromatic_fraction": 0.07, "hydrophobic_moment": 0.55,
121+
}
122+
score = activity_likeness_score(minimal_features)
123+
assert 0.0 <= score <= 1.0

‎tests/test_scoring_activity.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,13 @@ def test_mu_h_0_8_full_bonus(self):
196196
assert s_08 == s_10
197197

198198
def test_mu_h_0_4_half_bonus(self):
199-
# amphipathicity = clamp01(0.4/0.8) * 0.15 = 0.5 * 0.15 = 0.075
200-
# diff between mu_h=0.8 and mu_h=0.4 should be 0.075
199+
# amphipathicity = clamp01(0.4/0.8) * 0.14 = 0.5 * 0.14 = 0.07
200+
# diff between mu_h=0.8 and mu_h=0.4 should be 0.07
201201
diff = (
202202
activity_likeness_score(_feat(mu_h=0.8)) -
203203
activity_likeness_score(_feat(mu_h=0.4))
204204
)
205-
assert abs(diff - 0.075) < 0.001
205+
assert abs(diff - 0.07) < 0.001
206206

207207
def test_mu_h_improves_score_monotonically_up_to_ceiling(self):
208208
scores = [activity_likeness_score(_feat(mu_h=v)) for v in [0.0, 0.2, 0.4, 0.6, 0.8]]

0 commit comments

Comments
 (0)