diff --git a/src/wrappers/OsipiBase.py b/src/wrappers/OsipiBase.py index 70dcb0f1..e78b27b1 100644 --- a/src/wrappers/OsipiBase.py +++ b/src/wrappers/OsipiBase.py @@ -29,10 +29,14 @@ class OsipiBase: Parameter bounds for constrained optimization. Should be a dict with keys like "S0", "f", "Dp", "D" and values as [lower, upper] lists or arrays. E.g. {"S0" : [0.7, 1.3], "f" : [0, 1], "Dp" : [0.005, 0.2], "D" : [0, 0.005]}. - initial_guess : dict, optional - Initial parameter estimates for the IVIM fit. Should be a dict with keys - like "S0", "f", "Dp", "D" and float values. - E.g. {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001}. + initial_guess : dict or str, optional + Initial parameter estimates for the IVIM fit. Can be: + - A dict with keys like "S0", "f", "Dp", "D" and float values. + E.g. {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001}. + - A string naming a body part (e.g., "brain", "liver", "kidney"). + The string is looked up in the body-part defaults table and + replaced with the corresponding dict. If bounds are not provided, + body-part-specific bounds are also applied. algorithm : str, optional Name of an algorithm module in ``src/standardized`` to load dynamically. If supplied, the instance is immediately converted to that algorithm’s @@ -43,6 +47,14 @@ class OsipiBase: "Dp":[0.005, 0.2], "D":[0, 0.005]}. To prevent this, set this bool to False. Default initial guess {"S0" : 1, "f": 0.1, "Dp": 0.01, "D": 0.001}. + body_part : str, optional + Name of the anatomical region being scanned (e.g., "brain", "liver", + "kidney", "prostate", "pancreas", "head_and_neck", "breast", + "placenta"). When provided, body-part-specific initial guesses, + bounds, and thresholds are used as defaults instead of the generic + ones. User-provided bounds/initial_guess always take priority. + See :mod:`src.wrappers.ivim_body_part_defaults` for available + body parts and their literature-sourced parameter values. **kwargs Additional keyword arguments forwarded to the selected algorithm’s initializer if ``algorithm`` is provided. @@ -102,7 +114,14 @@ class OsipiBase: f_map = results["f"] """ - def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=None, algorithm=None, force_default_settings=True, **kwargs): + def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=None, algorithm=None, force_default_settings=True, body_part=None, **kwargs): + from src.wrappers.ivim_body_part_defaults import get_body_part_defaults + + # If initial_guess is a string, treat it as a body part name + if isinstance(initial_guess, str): + body_part = initial_guess + initial_guess = None + # Define the attributes as numpy arrays only if they are not None self.bvalues = np.asarray(bvalues) if bvalues is not None else None self.thresholds = np.asarray(thresholds) if thresholds is not None else None @@ -113,20 +132,34 @@ def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=Non self.deep_learning = False self.supervised = False self.stochastic = False + self.body_part = body_part # Store for reference if force_default_settings: - if self.bounds is None: - print('warning, no bounds were defined, so default bounds are used of [0, 0, 0.005, 0.7],[0.005, 1.0, 0.2, 1.3]') - self.bounds = {"S0" : [0.7, 1.3], "f" : [0, 1.0], "Dp" : [0.005, 0.2], "D" : [0, 0.005]} # These are defined as [lower, upper] - self.forced_default_bounds = True - - if self.initial_guess is None: - print('warning, no initial guesses were defined, so default initial guesses are used of [0.001, 0.001, 0.01, 1]') - self.initial_guess = {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001} - self.forced_default_initial_guess = True - - if self.thresholds is None: - self.thresholds = np.array([200]) + if body_part is not None: + # Use body-part-specific defaults from the literature-sourced lookup table + bp_defaults = get_body_part_defaults(body_part) + if self.bounds is None: + self.bounds = bp_defaults["bounds"] + self.forced_default_bounds = True + if self.initial_guess is None: + self.initial_guess = bp_defaults["initial_guess"] + self.forced_default_initial_guess = True + if self.thresholds is None: + self.thresholds = np.array(bp_defaults["thresholds"]) + else: + # Generic defaults (original behavior) + if self.bounds is None: + print('warning, no bounds were defined, so default bounds are used of [0, 0, 0.005, 0.7],[0.005, 1.0, 0.2, 1.3]') + self.bounds = {"S0" : [0.7, 1.3], "f" : [0, 1.0], "Dp" : [0.005, 0.2], "D" : [0, 0.005]} # These are defined as [lower, upper] + self.forced_default_bounds = True + + if self.initial_guess is None: + print('warning, no initial guesses were defined, so default initial guesses are used of [0.001, 0.001, 0.01, 1]') + self.initial_guess = {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001} + self.forced_default_initial_guess = True + + if self.thresholds is None: + self.thresholds = np.array([200]) self.osipi_bounds = self.bounds # Variable that stores the original bounds before they are passed to the algorithm self.osipi_initial_guess = self.initial_guess # Variable that stores the original initial guesses before they are passed to the algorithm diff --git a/src/wrappers/ivim_body_part_defaults.py b/src/wrappers/ivim_body_part_defaults.py new file mode 100644 index 00000000..ad1c2779 --- /dev/null +++ b/src/wrappers/ivim_body_part_defaults.py @@ -0,0 +1,147 @@ +""" +Body-part specific IVIM parameter defaults. + +Literature-based initial guesses, bounds, and thresholds for +different anatomical regions. Used by OsipiBase when the user +specifies a body_part parameter. + +References: + Brain: Federau 2017 (DOI: 10.1002/nbm.3780) + Liver: Dyvorne 2013 (DOI: 10.1016/j.ejrad.2013.03.003), + Guiu 2012 (DOI: 10.1002/jmri.23762) + Kidney: Li 2017 (DOI: 10.1002/jmri.25571), + Ljimani 2020 (DOI: 10.1007/s10334-019-00802-0) + Prostate: Kuru 2014 (DOI: 10.1007/s00330-014-3165-y) + Pancreas: Barbieri 2020 (DOI: 10.1002/mrm.27910) + Head/Neck: Sumi 2012 (DOI: 10.1259/dmfr/15696758) + Breast: Lee 2018 (DOI: 10.1097/RCT.0000000000000661) + Placenta: Zhu 2023 (DOI: 10.1002/jmri.28858) +""" + +IVIM_BODY_PART_DEFAULTS = { + "brain": { + "initial_guess": {"S0": 1.0, "f": 0.05, "Dp": 0.01, "D": 0.0008}, + "bounds": { + "S0": [0.7, 1.3], + "f": [0.0, 0.15], + "Dp": [0.005, 0.05], + "D": [0.0003, 0.002], + }, + "thresholds": [200], + }, + "liver": { + "initial_guess": {"S0": 1.0, "f": 0.12, "Dp": 0.06, "D": 0.001}, + "bounds": { + "S0": [0.7, 1.3], + "f": [0.0, 0.40], + "Dp": [0.01, 0.15], + "D": [0.0003, 0.003], + }, + "thresholds": [200], + }, + "kidney": { + "initial_guess": {"S0": 1.0, "f": 0.20, "Dp": 0.03, "D": 0.0019}, + "bounds": { + "S0": [0.7, 1.3], + "f": [0.0, 0.50], + "Dp": [0.01, 0.08], + "D": [0.0005, 0.004], + }, + "thresholds": [200], + }, + "prostate": { + "initial_guess": {"S0": 1.0, "f": 0.08, "Dp": 0.025, "D": 0.0015}, + "bounds": { + "S0": [0.7, 1.3], + "f": [0.0, 0.25], + "Dp": [0.005, 0.06], + "D": [0.0003, 0.003], + }, + "thresholds": [200], + }, + "pancreas": { + "initial_guess": {"S0": 1.0, "f": 0.18, "Dp": 0.02, "D": 0.0012}, + "bounds": { + "S0": [0.7, 1.3], + "f": [0.0, 0.50], + "Dp": [0.005, 0.06], + "D": [0.0003, 0.003], + }, + "thresholds": [200], + }, + "head_and_neck": { + "initial_guess": {"S0": 1.0, "f": 0.15, "Dp": 0.025, "D": 0.001}, + "bounds": { + "S0": [0.7, 1.3], + "f": [0.0, 0.40], + "Dp": [0.005, 0.08], + "D": [0.0003, 0.003], + }, + "thresholds": [200], + }, + "breast": { + "initial_guess": {"S0": 1.0, "f": 0.10, "Dp": 0.02, "D": 0.0014}, + "bounds": { + "S0": [0.7, 1.3], + "f": [0.0, 0.30], + "Dp": [0.005, 0.06], + "D": [0.0004, 0.003], + }, + "thresholds": [200], + }, + "placenta": { + "initial_guess": {"S0": 1.0, "f": 0.28, "Dp": 0.04, "D": 0.0017}, + "bounds": { + "S0": [0.7, 1.3], + "f": [0.05, 0.60], + "Dp": [0.01, 0.1], + "D": [0.0005, 0.004], + }, + "thresholds": [200], + }, +} + +# Keep the current universal defaults as "generic" +IVIM_BODY_PART_DEFAULTS["generic"] = { + "initial_guess": {"S0": 1.0, "f": 0.1, "Dp": 0.01, "D": 0.001}, + "bounds": { + "S0": [0.7, 1.3], + "f": [0, 1.0], + "Dp": [0.005, 0.2], + "D": [0, 0.005], + }, + "thresholds": [200], +} + + +def get_body_part_defaults(body_part): + """Get IVIM default parameters for a given body part. + + Args: + body_part (str): Name of the body part (e.g., "brain", "liver", "kidney"). + Case-insensitive. Spaces and hyphens are normalized to + underscores (e.g., "head and neck" -> "head_and_neck"). + + Returns: + dict: Dictionary with keys "initial_guess", "bounds", and "thresholds". + + Raises: + ValueError: If the body part is not in the lookup table. + """ + key = body_part.lower().replace(" ", "_").replace("-", "_") + if key not in IVIM_BODY_PART_DEFAULTS: + available = ", ".join(sorted(IVIM_BODY_PART_DEFAULTS.keys())) + raise ValueError( + f"Unknown body part '{body_part}'. " + f"Available body parts: {available}" + ) + return IVIM_BODY_PART_DEFAULTS[key] + + +def get_available_body_parts(): + """Return a sorted list of all available body part names. + + Returns: + list: Sorted list of body part name strings. + """ + return sorted(IVIM_BODY_PART_DEFAULTS.keys()) diff --git a/tests/IVIMmodels/unit_tests/test_body_part_defaults.py b/tests/IVIMmodels/unit_tests/test_body_part_defaults.py new file mode 100644 index 00000000..f7976d19 --- /dev/null +++ b/tests/IVIMmodels/unit_tests/test_body_part_defaults.py @@ -0,0 +1,210 @@ +""" +Unit tests for body-part aware IVIM initial guesses (Feature #87). + +Tests the lookup table in ivim_body_part_defaults.py and its integration +with OsipiBase.__init__(). +""" + +import os +import sys +import pytest +import numpy as np + +# Ensure project root is on the path +root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) +if root not in sys.path: + sys.path.insert(0, root) + +from src.wrappers.ivim_body_part_defaults import ( + IVIM_BODY_PART_DEFAULTS, + get_body_part_defaults, + get_available_body_parts, +) +from src.wrappers.OsipiBase import OsipiBase + + +# --------------------------------------------------------------------------- +# Tests for the standalone lookup table module +# --------------------------------------------------------------------------- + + +class TestBodyPartDefaults: + """Tests for get_body_part_defaults() and the lookup table.""" + + def test_brain_initial_guess(self): + """Brain defaults should return literature-sourced values.""" + bp = get_body_part_defaults("brain") + assert bp["initial_guess"]["f"] == 0.05 + assert bp["initial_guess"]["D"] == 0.0008 + assert bp["initial_guess"]["Dp"] == 0.01 + + def test_liver_initial_guess(self): + """Liver defaults should return literature-sourced values.""" + bp = get_body_part_defaults("liver") + assert bp["initial_guess"]["f"] == 0.12 + assert bp["initial_guess"]["D"] == 0.001 + assert bp["initial_guess"]["Dp"] == 0.06 + + def test_kidney_initial_guess(self): + """Kidney defaults should return literature-sourced values.""" + bp = get_body_part_defaults("kidney") + assert bp["initial_guess"]["f"] == 0.20 + assert bp["initial_guess"]["D"] == 0.0019 + assert bp["initial_guess"]["Dp"] == 0.03 + + def test_liver_bounds_differ_from_generic(self): + """Liver bounds should be tighter than generic bounds.""" + liver = get_body_part_defaults("liver") + generic = get_body_part_defaults("generic") + # Liver D upper bound should be tighter than generic + assert liver["bounds"]["D"][1] < generic["bounds"]["D"][1] + # Liver Dp lower bound should be higher than generic + assert liver["bounds"]["Dp"][0] >= generic["bounds"]["Dp"][0] + + def test_unknown_body_part_raises_valueerror(self): + """Unknown body part should raise ValueError with available list.""" + with pytest.raises(ValueError, match="Unknown body part"): + get_body_part_defaults("elbow") + + def test_case_insensitivity(self): + """Body part lookup should be case-insensitive.""" + lower = get_body_part_defaults("brain") + upper = get_body_part_defaults("Brain") + mixed = get_body_part_defaults("BRAIN") + assert lower == upper == mixed + + def test_spaces_and_hyphens_normalized(self): + """Spaces and hyphens should be normalized to underscores.""" + bp1 = get_body_part_defaults("head_and_neck") + bp2 = get_body_part_defaults("head and neck") + bp3 = get_body_part_defaults("head-and-neck") + assert bp1 == bp2 == bp3 + + def test_all_body_parts_have_required_keys(self): + """Every body part entry should have initial_guess, bounds, and thresholds.""" + for name, defaults in IVIM_BODY_PART_DEFAULTS.items(): + assert "initial_guess" in defaults, f"{name} missing initial_guess" + assert "bounds" in defaults, f"{name} missing bounds" + assert "thresholds" in defaults, f"{name} missing thresholds" + + def test_all_body_parts_have_valid_parameter_keys(self): + """Every initial_guess and bounds should have S0, f, Dp, D.""" + required_params = {"S0", "f", "Dp", "D"} + for name, defaults in IVIM_BODY_PART_DEFAULTS.items(): + ig_keys = set(defaults["initial_guess"].keys()) + assert ig_keys == required_params, f"{name} initial_guess keys: {ig_keys}" + bounds_keys = set(defaults["bounds"].keys()) + assert bounds_keys == required_params, f"{name} bounds keys: {bounds_keys}" + + def test_all_initial_guesses_within_bounds(self): + """Every initial guess value should fall within its corresponding bounds.""" + for name, defaults in IVIM_BODY_PART_DEFAULTS.items(): + ig = defaults["initial_guess"] + bounds = defaults["bounds"] + for param in ["S0", "f", "Dp", "D"]: + lo, hi = bounds[param] + val = ig[param] + assert lo <= val <= hi, ( + f"{name}: {param}={val} outside bounds [{lo}, {hi}]" + ) + + def test_get_available_body_parts(self): + """get_available_body_parts() should return a sorted list.""" + available = get_available_body_parts() + assert isinstance(available, list) + assert available == sorted(available) + assert "brain" in available + assert "liver" in available + assert "generic" in available + + def test_generic_matches_original_defaults(self): + """The 'generic' entry should match the original OsipiBase defaults.""" + generic = get_body_part_defaults("generic") + assert generic["initial_guess"]["S0"] == 1.0 + assert generic["initial_guess"]["f"] == 0.1 + assert generic["initial_guess"]["Dp"] == 0.01 + assert generic["initial_guess"]["D"] == 0.001 + assert generic["bounds"]["D"] == [0, 0.005] + assert generic["bounds"]["f"] == [0, 1.0] + + +# --------------------------------------------------------------------------- +# Tests for OsipiBase integration +# --------------------------------------------------------------------------- + + +class TestOsipiBaseBodyPart: + """Tests for body_part integration in OsipiBase.__init__().""" + + def test_body_part_sets_initial_guess(self): + """body_part='brain' should set brain-specific initial guess.""" + fit = OsipiBase(bvalues=[0, 50, 200, 800], body_part="brain") + assert fit.initial_guess["f"] == 0.05 + assert fit.initial_guess["D"] == 0.0008 + + def test_body_part_sets_bounds(self): + """body_part='liver' should set liver-specific bounds.""" + fit = OsipiBase(bvalues=[0, 50, 200, 800], body_part="liver") + assert fit.bounds["Dp"] == [0.01, 0.15] + assert fit.bounds["D"] == [0.0003, 0.003] + + def test_body_part_none_uses_generic(self): + """body_part=None (default) should use original generic defaults.""" + fit = OsipiBase(bvalues=[0, 50, 200, 800]) + assert fit.initial_guess["f"] == 0.1 + assert fit.initial_guess["Dp"] == 0.01 + assert fit.initial_guess["D"] == 0.001 + + def test_user_initial_guess_overrides_body_part(self): + """Explicit initial_guess dict should take priority over body_part.""" + custom = {"S0": 1.0, "f": 0.99, "Dp": 0.05, "D": 0.002} + fit = OsipiBase( + bvalues=[0, 50, 200, 800], + body_part="brain", + initial_guess=custom, + ) + assert fit.initial_guess["f"] == 0.99 # User value, not brain default + # But bounds should still be brain-specific + assert fit.bounds["D"] == [0.0003, 0.002] + + def test_user_bounds_overrides_body_part(self): + """Explicit bounds dict should take priority over body_part.""" + custom_bounds = {"S0": [0.5, 1.5], "f": [0, 0.5], "Dp": [0, 0.1], "D": [0, 0.01]} + fit = OsipiBase( + bvalues=[0, 50, 200, 800], + body_part="liver", + bounds=custom_bounds, + ) + assert fit.bounds["D"] == [0, 0.01] # User value, not liver default + # But initial_guess should still be liver-specific + assert fit.initial_guess["f"] == 0.12 + + def test_initial_guess_as_string(self): + """Passing initial_guess='liver' should work like body_part='liver'.""" + fit = OsipiBase(bvalues=[0, 50, 200, 800], initial_guess="liver") + assert fit.initial_guess["f"] == 0.12 + assert fit.initial_guess["Dp"] == 0.06 + assert fit.bounds["D"] == [0.0003, 0.003] + + def test_body_part_stored_as_attribute(self): + """The body_part should be stored on the instance for reference.""" + fit = OsipiBase(bvalues=[0, 50, 200, 800], body_part="kidney") + assert fit.body_part == "kidney" + + def test_body_part_none_attribute(self): + """Default body_part should be None.""" + fit = OsipiBase(bvalues=[0, 50, 200, 800]) + assert fit.body_part is None + + def test_unknown_body_part_raises(self): + """Unknown body part in OsipiBase should raise ValueError.""" + with pytest.raises(ValueError, match="Unknown body part"): + OsipiBase(bvalues=[0, 50, 200, 800], body_part="elbow") + + def test_all_body_parts_create_valid_instance(self): + """Every body part should create a valid OsipiBase instance.""" + for bp_name in get_available_body_parts(): + fit = OsipiBase(bvalues=[0, 50, 200, 800], body_part=bp_name) + assert fit.initial_guess is not None + assert fit.bounds is not None + assert fit.thresholds is not None