From c61b7bc8c23167b50c5e3c164c5be63c1bdb8cf5 Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Wed, 10 Jun 2026 10:11:54 +0200 Subject: [PATCH] Add iLIS interface score (sqrt(LIS * cLIS), AFM-LIS) iLIS (Kim et al., AFM-LIS; github.com/flyark/AFM-LIS) is the geometric mean of LIS and its contact-restricted variant cLIS: cLIS = the LIS PAE transform (12-PAE)/12 averaged only over residue pairs that are also in direct contact (representative atom CB-else-CA within contact_thresh; default 8 A == AFM-LIS Cb-Cb 8 A) iLIS = sqrt(LIS * cLIS) The geometric mean zeroes the score unless an interface has both broad PAE confidence (LIS) and confident direct contacts (cLIS). cLIS reuses the existing Interface contact set, so no new geometry is introduced; both chain directions are averaged, mirroring lis(). Adds Interface.clis()/ilis(), emits interface_cLIS / interface_iLIS in the per-run CSV next to interface_LIS, and adds a deterministic unit test. Validated against the official AFM-LIS lis.py on benchmark dimers (LIS/cLIS/iLIS agree to 4 decimal places). Co-Authored-By: Claude Opus 4.8 --- src/alphajudge/interface.py | 39 ++++++++++++++++++++++ src/alphajudge/runner.py | 2 ++ test/test_parsers_and_runner.py | 58 +++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/src/alphajudge/interface.py b/src/alphajudge/interface.py index 036aa16e..485bcfda 100644 --- a/src/alphajudge/interface.py +++ b/src/alphajudge/interface.py @@ -174,6 +174,45 @@ def _lis_dir(idx_src: np.ndarray, idx_dst: np.ndarray) -> float: b = _lis_dir(self._idx2, self._idx1) return float(0.5 * (a + b)) + def clis(self) -> float: + """ + Contact-restricted LIS (cLIS): the LIS PAE transform averaged only over + residue pairs that are also in direct physical contact (representative + atom, CB-else-CA, within contact_thresh; default Cβ-Cβ <= 8 A). Both + chain directions are scored separately and averaged, mirroring lis(). + Returns 0.0 when no contacts or no valid PAE pairs. + """ + if not self._pairs: + return 0.0 + ab: list[float] = [] + ba: list[float] = [] + for r1, r2 in self._pairs: + i = self._rim.get((r1.get_parent().id, r1.id)) + j = self._rim.get((r2.get_parent().id, r2.id)) + if i is None or j is None: + continue + p_ab = float(self._pae[i, j]) + p_ba = float(self._pae[j, i]) + if p_ab < 12.0: + ab.append((12.0 - p_ab) / 12.0) + if p_ba < 12.0: + ba.append((12.0 - p_ba) / 12.0) + a = float(np.mean(ab)) if ab else 0.0 + b = float(np.mean(ba)) if ba else 0.0 + return float(0.5 * (a + b)) + + def ilis(self) -> float: + """ + Integrated LIS (iLIS = sqrt(LIS * cLIS); Kim et al., AFM-LIS). The + geometric mean forces the score to 0 unless the interface has both broad + PAE confidence (LIS) and confident direct contacts (cLIS). + """ + lis = self.lis() + clis = self.clis() + if lis <= 0.0 or clis <= 0.0: + return 0.0 + return float(math.sqrt(lis * clis)) + @property def polar(self) -> float: return self._frac(POLAR_RES) diff --git a/src/alphajudge/runner.py b/src/alphajudge/runner.py index dae92731..9b744414 100644 --- a/src/alphajudge/runner.py +++ b/src/alphajudge/runner.py @@ -117,6 +117,8 @@ def process( "interface_pDockQ2": pd2, "interface_ipSAE": iface.ipsae(), "interface_LIS": iface.lis(), + "interface_cLIS": iface.clis(), + "interface_iLIS": iface.ilis(), } # Add expensive metrics only if not skipped diff --git a/test/test_parsers_and_runner.py b/test/test_parsers_and_runner.py index 0b10fa56..b4e15bea 100644 --- a/test/test_parsers_and_runner.py +++ b/test/test_parsers_and_runner.py @@ -41,6 +41,8 @@ "interface_pDockQ2", "interface_ipSAE", "interface_LIS", + "interface_cLIS", + "interface_iLIS", "interface_hb", "interface_sb", "interface_ss", @@ -332,6 +334,62 @@ def test_af2_runner_outputs_have_expected_scores(tmp_path: Path, af2_dir_src: Pa assert nearly_equal(got_iptm_ptm, float(exp_ptm)), f"AF2 monomer iptm_ptm should be NaN or ptm for {m}" +class _FakeResidue: + """Minimal hashable Bio.PDB-like residue with .id and .get_parent().id.""" + + def __init__(self, chain_id: str, res_id: Any): + self.id = res_id + self._parent = type("_Chain", (), {"id": chain_id})() + + def get_parent(self): + return self._parent + + +def _make_residue(chain_id: str, res_id: Any) -> _FakeResidue: + return _FakeResidue(chain_id, res_id) + + +def test_clis_ilis_math_is_deterministic(): + """ + cLIS restricts the LIS PAE transform to contacting residue pairs and iLIS is + the geometric mean sqrt(LIS * cLIS) (AFM-LIS, Kim et al.). Build a tiny 2+2 + complex with a known PAE matrix so the arithmetic is checkable by hand. + """ + from alphajudge.interface import Interface + + a1, a2 = _make_residue("A", (" ", 1, " ")), _make_residue("A", (" ", 2, " ")) + b1, b2 = _make_residue("B", (" ", 1, " ")), _make_residue("B", (" ", 2, " ")) + + # PAE indices: A1->0, A2->1, B1->2, B2->3. Intra-chain PAE is irrelevant. + pae = np.array( + [ + [0.0, 0.0, 0.0, 6.0], + [0.0, 0.0, 24.0, 24.0], + [0.0, 24.0, 0.0, 0.0], + [6.0, 24.0, 0.0, 0.0], + ] + ) + + iface = object.__new__(Interface) # bypass __init__; set only what we exercise + iface._pae = pae + iface._idx1 = np.array([0, 1]) + iface._idx2 = np.array([2, 3]) + iface._rim = {("A", a1.id): 0, ("A", a2.id): 1, ("B", b1.id): 2, ("B", b2.id): 3} + iface._pairs = {(a1, b1)} # only A1-B1 is in physical contact + + # LIS: A->B valid entries (12-pae)/12 = [1.0, 0.5]; B->A = [1.0, 0.5]; mean 0.75. + assert nearly_equal(iface.lis(), 0.75) + # cLIS: only the A1-B1 contact, pae 0 both directions -> 1.0. + assert nearly_equal(iface.clis(), 1.0) + # iLIS = sqrt(0.75 * 1.0). + assert nearly_equal(iface.ilis(), math.sqrt(0.75)) + + # No contacts -> cLIS 0 -> iLIS 0 regardless of LIS. + iface._pairs = set() + assert iface.clis() == 0.0 + assert iface.ilis() == 0.0 + + # ------------------------- # AF3 runner: best/all + score checks # -------------------------