-
Notifications
You must be signed in to change notification settings - Fork 45
feat: Add body-part aware initial guesses for IVIM fitting (Feature #87) #149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here, I'd like to see behaviour doing: If body_part is not None: initialize with body_part-specific values, but then override any non-None values from user input. So if I use body_part = liver, threshold = 500, it takes the initial guess and bounds from the liver, but overrides the threshold by 500.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok. This adds flexibility and user control as well. I update logic so that when |
||
| # 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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], | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In lieu of the IVIM review paper, a cut-off of 300 is adviced for brain
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. all other organs have 200 adviced
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I made the threshold of 200 based on general practice like this paper Federau 2017, DOI: 10.1002/nbm.3780), which uses b-value cutoffs around 200 s/mm² in their segmented fittings. |
||
| }, | ||
| "liver": { | ||
| "initial_guess": {"S0": 1.0, "f": 0.12, "Dp": 0.06, "D": 0.001}, | ||
| "bounds": { | ||
| "S0": [0.7, 1.3], | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Generally, the S0 bounds are defined by the SNR (i.e. more noisy data will mean S0 varies more). I would be in favour to broaden these bounds (i.e. 0.5-2).
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (for every organ)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok. I made the it tighter as a conservative starting point for all organs. I will update it and broaden it for better accomodation. |
||
| "f": [0.0, 0.40], | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For all organs --> I have asked the IVIM experts to review these bounds :). I personally think they are somewhat tight, but lets see what the verdict is. They should be simple to adapt once we have made up our minds on what they should be
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I really appreciate it. Expert's opinion will help here. I will wait to touch this part and update it after expert opinion.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also for expert's reference, all of the bounds that i chose is based of papers and their list is given in this PR. |
||
| "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}" | ||
|
Comment on lines
+134
to
+136
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be nice to also provide the user with a list of the available/implemented options here
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this could be achieved easily by using the function below
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have implemented this in lines 134 to 136 of ivim_body_part_defaults.py file. it has this function available = sorted(IVIM_BODY_PART_DEFAULTS.keys())
raise ValueError(
f"Unknown body part '{body_part}'. "
f"Available body parts: {available}"
)So the user sees: |
||
| ) | ||
| 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()) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this import should be at the top of the file
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok I wlll do it.