feat(fusion): add clinical-test signal normalisers (MMSE/MoCA/UPDRS/gait/age)
Browse files- src/fusion/clinical.py +38 -0
- tests/fusion/test_clinical.py +54 -0
src/fusion/clinical.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Map raw clinical-test scores to a unitless signal in [-1, 1].
|
| 2 |
+
|
| 3 |
+
+1 means the test strongly supports the disease being present.
|
| 4 |
+
-1 means it strongly supports the disease being absent.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _linear_map(value: float, low: float, high: float, *, invert: bool) -> float:
|
| 10 |
+
"""Map `value` from [low, high] to [-1, 1]. If invert, flip sign."""
|
| 11 |
+
if high == low:
|
| 12 |
+
return 0.0
|
| 13 |
+
clipped = max(low, min(high, value))
|
| 14 |
+
norm = (clipped - low) / (high - low)
|
| 15 |
+
signal = 2.0 * norm - 1.0
|
| 16 |
+
return -signal if invert else signal
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def mmse_to_signal(score: float) -> float:
|
| 20 |
+
"""MMSE: 30 = healthy (-1), 18 = severe (+1), 24 = mild cutoff (0)."""
|
| 21 |
+
return _linear_map(score, low=18.0, high=30.0, invert=True)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def moca_to_signal(score: float) -> float:
|
| 25 |
+
"""MoCA: same scale as MMSE."""
|
| 26 |
+
return _linear_map(score, low=18.0, high=30.0, invert=True)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def updrs_to_signal(score: float) -> float:
|
| 30 |
+
return _linear_map(score, low=0.0, high=199.0, invert=False)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def gait_to_signal(speed_m_s: float) -> float:
|
| 34 |
+
return _linear_map(speed_m_s, low=0.0, high=1.4, invert=True)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def age_to_signal(years: float) -> float:
|
| 38 |
+
return _linear_map(years, low=30.0, high=90.0, invert=False)
|
tests/fusion/test_clinical.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for src.fusion.clinical — per-test signal normalisers.
|
| 2 |
+
|
| 3 |
+
Convention: signal in [-1, 1] where +1 = strong evidence the disease IS
|
| 4 |
+
present and -1 = strong evidence it is NOT present.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from src.fusion import clinical
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestMMSE:
|
| 14 |
+
def test_perfect_score_signals_no_alzheimers(self) -> None:
|
| 15 |
+
assert clinical.mmse_to_signal(30.0) == pytest.approx(-1.0)
|
| 16 |
+
|
| 17 |
+
def test_severely_impaired_signals_alzheimers(self) -> None:
|
| 18 |
+
assert clinical.mmse_to_signal(0.0) == pytest.approx(1.0)
|
| 19 |
+
|
| 20 |
+
def test_borderline_24_is_near_neutral_slightly_positive(self) -> None:
|
| 21 |
+
sig = clinical.mmse_to_signal(24.0)
|
| 22 |
+
assert -0.1 < sig < 0.5
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TestMoCA:
|
| 26 |
+
def test_perfect_signals_negative(self) -> None:
|
| 27 |
+
assert clinical.moca_to_signal(30.0) == pytest.approx(-1.0)
|
| 28 |
+
|
| 29 |
+
def test_zero_signals_positive(self) -> None:
|
| 30 |
+
assert clinical.moca_to_signal(0.0) == pytest.approx(1.0)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TestUPDRS:
|
| 34 |
+
def test_zero_signals_no_parkinsons(self) -> None:
|
| 35 |
+
assert clinical.updrs_to_signal(0.0) == pytest.approx(-1.0)
|
| 36 |
+
|
| 37 |
+
def test_max_signals_parkinsons(self) -> None:
|
| 38 |
+
assert clinical.updrs_to_signal(199.0) == pytest.approx(1.0, abs=1e-3)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TestGait:
|
| 42 |
+
def test_fast_walker_signals_negative(self) -> None:
|
| 43 |
+
assert clinical.gait_to_signal(1.4) < -0.4
|
| 44 |
+
|
| 45 |
+
def test_slow_walker_signals_positive(self) -> None:
|
| 46 |
+
assert clinical.gait_to_signal(0.3) > 0.4
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class TestAge:
|
| 50 |
+
def test_young_signals_negative(self) -> None:
|
| 51 |
+
assert clinical.age_to_signal(30.0) < -0.4
|
| 52 |
+
|
| 53 |
+
def test_elderly_signals_positive(self) -> None:
|
| 54 |
+
assert clinical.age_to_signal(85.0) > 0.4
|