Skip to content

Commit 812cdd3

Browse files
committed
feat: safety pH74 charge consistency + stronger selectivity penalty (PR #53)
- safety_score(): use charge_density_ph74 (physiologically accurate pH 7.4) preferentially over the proxy, matching activity_likeness_score() behaviour. Prevents His-rich sequences from being incorrectly flagged for charge risk at the physiological pH where His is uncharged. Backward compat: falls back to charge_density when charge_density_ph74 is absent. - _pilot_priority(): add cytotox_penalty term for HIGH_CYTOTOX_RISK candidates (selectivity_proxy < 0.5). Candidates in this tier now receive both reduced bonus AND an additional linear penalty, totalling up to −0.05 extra demerit vs the old soft-bonus formulation. Max total selectivity swing: ±0.05 → 0.10. SEED-004 temporin (proxy=0.30): net selectivity −0.005 (was +0.015 before). Literature: Dathe & Wieprecht (1999) BBA 1462:71-87; Carotenuto et al. (2008). - 4 new tests: charge_density_ph74 preference in safety, cytotox_penalty boundary, max value, and intermediate value - 3 updated tests: max_delta 0.05→0.10, SEED-004 demerit 0.035→0.055 - Makefile: bump test count to 1172
1 parent 9db4d7e commit 812cdd3

5 files changed

Lines changed: 88 additions & 18 deletions

File tree

‎Makefile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ lint:
2020
$(RUFF) check src tests scripts
2121

2222
ci: lint test
23-
@echo "CI passed: lint + 1168-test suite green"
23+
@echo "CI passed: lint + 1172-test suite green"
2424

2525
bench-leakage:
2626
PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli bench leakage \

‎src/openamp_foundry/scoring/safety.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ def safety_score(features: dict) -> float:
1717
typical AMP-like candidates (μH ≈ 0.35–0.50).
1818
"""
1919
length = features["length"]
20-
charge_density = abs(features["charge_density"])
20+
# Prefer pH 7.4 charge (same as activity scorer); fall back to proxy for older callers.
21+
charge_density = abs(features.get("charge_density_ph74", features["charge_density"]))
2122
hydrophobic = features["hydrophobic_fraction"]
2223
cys = features["cysteine_fraction"]
2324
repeat_run = features["longest_repeat_run"]

‎src/openamp_foundry/selection/pilot.py‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,42 @@ def _pilot_priority(scores: dict) -> float:
1818
1919
Formula:
2020
ensemble − 0.30×disagreement + 0.05×serum_stability + 0.05×novelty
21-
+ 0.05×selectivity_proxy
21+
+ 0.05×selectivity_proxy − 0.05×cytotox_penalty
22+
23+
The cytotox_penalty is non-zero only for candidates in the HIGH_CYTOTOX_RISK tier
24+
(selectivity_proxy < 0.5). It applies an additional −0.05 × (0.5 − proxy) / 0.5
25+
penalty on top of the reduced bonus, totalling a max additional demerit of −0.05 vs
26+
the soft-bonus formulation.
2227
2328
Weights are intentionally equal for the three bonus terms (0.05 each) so no single
2429
physicochemical axis dominates over the ensemble activity prediction. The ensemble
2530
score remains the dominant term.
2631
2732
selectivity_proxy [0,1]: likelihood of selective bacterial killing without mammalian
28-
cytotoxicity (Dathe & Wieprecht 1999 BBA). Weighted the same as stability and novelty
29-
so low-selectivity candidates (e.g. temporin-like, high GRAVY) are gently demoted
30-
relative to equivalently-scored but more selective peptides. The maximum range of the
31-
selectivity term is 0.05, which breaks ties within families without overriding ensemble.
32-
33-
A candidate with novelty=0.467 (SEED-005 max) gains at most +0.023.
34-
A candidate with selectivity_proxy=0.30 (SEED-004 temporin) gets +0.015 instead of
35-
+0.05 maximum — a relative demerrit of 0.035 vs a fully selective peptide.
33+
cytotoxicity (Dathe & Wieprecht 1999 BBA). Low-selectivity candidates (proxy < 0.5,
34+
e.g. temporin-like temporins with charge=+1.0, GRAVY > +1.0) are doubly penalised:
35+
they receive less bonus (0.05 × 0.30 = +0.015 for SEED-004) AND a cytotox_penalty
36+
(−0.05 × 0.40 = −0.02 for proxy=0.30), for a net swing of −0.055 vs fully selective
37+
candidates. This better reflects that Stage 2 failure (hemolysis/cytotoxicity) is the
38+
largest single contributor to missed "breaking news" candidates in the current panel.
39+
Literature: Dathe & Wieprecht (1999) BBA 1462:71-87; Carotenuto et al. (2008) J Med Chem.
40+
41+
Example impact:
42+
proxy=1.00: +0.05 bonus, 0.00 penalty → net +0.050
43+
proxy=0.50: +0.025 bonus, 0.00 penalty → net +0.025
44+
proxy=0.30: +0.015 bonus, −0.020 penalty → net −0.005 (HIGH_CYTOTOX_RISK)
45+
proxy=0.00: +0.000 bonus, −0.050 penalty → net −0.050 (most toxic)
3646
"""
3747
ensemble = scores.get("ensemble", 0.0)
3848
disagreement = scores.get("disagreement", 0.5)
3949
stability = scores.get("serum_stability", 0.5)
4050
novelty = scores.get("novelty", 0.0)
4151
selectivity = scores.get("selectivity_proxy", 1.0)
52+
# Additional penalty for HIGH_CYTOTOX_RISK tier (proxy < 0.5)
53+
cytotox_penalty = 0.0 if selectivity >= 0.5 else 0.05 * (0.5 - selectivity) / 0.5
4254
return round(
4355
ensemble - 0.3 * disagreement + 0.05 * stability + 0.05 * novelty
44-
+ 0.05 * selectivity,
56+
+ 0.05 * selectivity - cytotox_penalty,
4557
6,
4658
)
4759

‎tests/test_pilot_panel.py‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,13 @@ def test_selectivity_proxy_higher_is_better(self):
123123
)
124124
assert p_selective > p_cytotoxic
125125

126-
def test_selectivity_proxy_max_delta_is_0_05(self):
127-
# Full selectivity (1.0) adds 0.05; zero selectivity adds 0.00
128-
# Maximum difference between any two candidates from this term alone = 0.05
126+
def test_selectivity_proxy_max_delta_is_0_10(self):
127+
# proxy=1.0: +0.05 bonus, 0.00 penalty → net +0.05
128+
# proxy=0.0: +0.00 bonus, -0.05 penalty → net -0.05
129+
# Maximum difference between any two candidates from selectivity alone = 0.10
129130
p_max = _pilot_priority({"ensemble": 0.0, "disagreement": 0.0, "selectivity_proxy": 1.0})
130131
p_min = _pilot_priority({"ensemble": 0.0, "disagreement": 0.0, "selectivity_proxy": 0.0})
131-
assert p_max - p_min == pytest.approx(0.05, abs=1e-6)
132+
assert p_max - p_min == pytest.approx(0.10, abs=1e-6)
132133

133134
def test_selectivity_proxy_absent_defaults_to_1(self):
134135
# When selectivity_proxy is not in scores dict, assume fully selective (default=1.0)
@@ -141,11 +142,41 @@ def test_selectivity_proxy_absent_defaults_to_1(self):
141142

142143
def test_seed004_temporin_demoted_vs_selective_equal_ensemble(self):
143144
# SEED-004 (temporin, proxy=0.30) should rank below a fully selective candidate
144-
# with equal ensemble/disagreement/stability/novelty
145+
# with equal ensemble/disagreement/stability/novelty.
146+
# New formula: proxy=0.30 gets +0.015 bonus − 0.020 cytotox_penalty = −0.005 net
147+
# proxy=1.00 gets +0.050 bonus − 0.000 cytotox_penalty = +0.050 net
148+
# Expected delta: 0.050 − (−0.005) = 0.055
145149
temporin = _pilot_priority({"ensemble": 0.72, "disagreement": 0.10, "selectivity_proxy": 0.30})
146150
selective = _pilot_priority({"ensemble": 0.72, "disagreement": 0.10, "selectivity_proxy": 1.0})
147151
assert selective > temporin
148-
assert selective - temporin == pytest.approx(0.05 * (1.0 - 0.30), abs=1e-6)
152+
assert selective - temporin == pytest.approx(0.055, abs=1e-6)
153+
154+
def test_cytotox_penalty_not_applied_at_boundary(self):
155+
# proxy=0.50 is exactly at the boundary — no cytotox_penalty applied
156+
# Use serum_stability=0.0 to isolate the selectivity term (avoid 0.5 default)
157+
p_boundary = _pilot_priority(
158+
{"ensemble": 0.0, "disagreement": 0.0, "serum_stability": 0.0, "selectivity_proxy": 0.5}
159+
)
160+
p_above = _pilot_priority(
161+
{"ensemble": 0.0, "disagreement": 0.0, "serum_stability": 0.0, "selectivity_proxy": 0.51}
162+
)
163+
# At boundary: bonus = 0.05 * 0.5 = 0.025, penalty = 0
164+
assert p_boundary == pytest.approx(0.025, abs=1e-6)
165+
assert p_above > p_boundary # higher proxy → higher priority (no penalty)
166+
167+
def test_cytotox_penalty_max_value(self):
168+
# proxy=0.0: bonus=0, penalty=0.05*(0.5/0.5)=0.05 → net selectivity term = −0.05
169+
p_zero = _pilot_priority(
170+
{"ensemble": 0.0, "disagreement": 0.0, "serum_stability": 0.0, "selectivity_proxy": 0.0}
171+
)
172+
assert p_zero == pytest.approx(-0.05, abs=1e-6)
173+
174+
def test_cytotox_penalty_at_proxy_025(self):
175+
# proxy=0.25: bonus=0.05*0.25=0.0125, penalty=0.05*(0.5-0.25)/0.5=0.025 → net=−0.0125
176+
p = _pilot_priority(
177+
{"ensemble": 0.0, "disagreement": 0.0, "serum_stability": 0.0, "selectivity_proxy": 0.25}
178+
)
179+
assert p == pytest.approx(-0.0125, abs=1e-6)
149180

150181

151182
class TestSelectPilotPanel:

‎tests/test_safety_moment.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,32 @@ def test_seed005_variants_flagged(self):
8484
f"(μH={feat['hydrophobic_moment']:.4f} > 0.55 threshold)"
8585
)
8686

87+
def test_charge_density_ph74_used_when_available(self):
88+
# safety_score should use charge_density_ph74 (physiologically accurate) over proxy.
89+
# Construct a feature dict where the two values differ: charge_density=0.70 (proxy)
90+
# vs charge_density_ph74=0.30 (pH-adjusted — His residues are uncharged at pH 7.4).
91+
# Using the proxy (0.70 > 0.55) would trigger a charge risk penalty; pH74 (0.30) would not.
92+
feat_with_ph74 = {
93+
"length": 15, "charge_density": 0.70, "charge_density_ph74": 0.30,
94+
"hydrophobic_fraction": 0.40, "cysteine_fraction": 0.0,
95+
"longest_repeat_run": 1, "hydrophobic_moment": 0.0,
96+
}
97+
feat_without_ph74 = {
98+
"length": 15, "charge_density": 0.70,
99+
"hydrophobic_fraction": 0.40, "cysteine_fraction": 0.0,
100+
"longest_repeat_run": 1, "hydrophobic_moment": 0.0,
101+
}
102+
score_with = safety_score(feat_with_ph74)
103+
score_without = safety_score(feat_without_ph74)
104+
# When pH74 is present, the 0.70 proxy should be ignored → no charge risk → higher score
105+
assert score_with > score_without, (
106+
"When charge_density_ph74=0.30 is present, the high proxy (0.70) should be ignored"
107+
)
108+
# Without pH74 key, falls back to the proxy (0.70 > 0.55 → charge risk penalty)
109+
assert score_without < 1.0, "Fallback to charge_density=0.70 should trigger risk penalty"
110+
# With pH74 key (0.30 < 0.55), no charge risk penalty
111+
assert score_with == 1.0, "charge_density_ph74=0.30 should produce no charge risk"
112+
87113
def test_seed003_variants_lower_risk(self):
88114
# SEED-003 tryptophan-rich variants: shorter, highly charged, moderate μH
89115
# Should have higher safety than SEED-005 amphipathic variants

0 commit comments

Comments
 (0)