-
Notifications
You must be signed in to change notification settings - Fork 46
feature: Add Weighted Least Squares (WLS) IVIM fitting algorithm — DT_IIITN (Feature #110) #148
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
Open
Devguru-codes
wants to merge
2
commits into
OSIPI:main
Choose a base branch
from
Devguru-codes:feature-wls-ivim-fitting
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+277
−6
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| # WLS IVIM fitting by Devguru Tiwari, IIIT Nagpur |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| """ | ||
| Weighted Least Squares (WLS) / Robust Linear Model (RLM) IVIM fitting. | ||
|
|
||
| Author: Devguru Tiwari, IIIT Nagpur | ||
| Date: 2026-03-01 | ||
|
|
||
| Implements a segmented approach for IVIM parameter estimation: | ||
| 1. Estimate D from high b-values using weighted/robust linear regression on log-signal | ||
| 2. Estimate f from the intercept of the Step 1 fit | ||
| 3. Estimate D* from residuals at low b-values using weighted/robust linear regression | ||
|
|
||
| Two regression methods are available: | ||
| - WLS: Weighted Linear Least Squares with Veraart weights (w = S^2) | ||
| - RLM: Robust Linear Model using Huber's T norm (statsmodels) | ||
|
|
||
| Reference: | ||
| Veraart, J. et al. (2013). "Weighted linear least squares estimation of | ||
| diffusion MRI parameters: strengths, limitations, and pitfalls." | ||
| NeuroImage, 81, 335-346. | ||
| DOI: 10.1016/j.neuroimage.2013.05.028 | ||
|
|
||
| Requirements: | ||
| numpy | ||
| statsmodels (only for method="RLM") | ||
| """ | ||
|
|
||
| import numpy as np | ||
| import warnings | ||
|
|
||
|
|
||
| def _weighted_linreg(x, y, weights): | ||
| """Fast weighted linear regression: y = a + b*x. | ||
|
|
||
| Uses Veraart et al. (2013) approach with weights = S^2. | ||
|
|
||
| Args: | ||
| x: 1D array, independent variable. | ||
| y: 1D array, dependent variable. | ||
| weights: 1D array, weights for each observation. | ||
|
|
||
| Returns: | ||
| (intercept, slope) tuple. | ||
| """ | ||
| W = np.diag(weights) | ||
| X = np.column_stack([np.ones_like(x), x]) | ||
| # Weighted normal equations: (X^T W X) beta = X^T W y | ||
| XtW = X.T @ W | ||
| beta = np.linalg.solve(XtW @ X, XtW @ y) | ||
| return beta[0], beta[1] # intercept, slope | ||
|
|
||
|
|
||
| def _rlm_linreg(x, y): | ||
| """Robust linear regression using statsmodels RLM with Huber's T norm. | ||
|
|
||
| RLM down-weights outlier observations via iteratively reweighted least | ||
| squares (IRLS), making the fit resistant to corrupted/noisy voxels. | ||
|
|
||
| Args: | ||
| x: 1D array, independent variable. | ||
| y: 1D array, dependent variable. | ||
|
|
||
| Returns: | ||
| (intercept, slope) tuple. | ||
| """ | ||
| import statsmodels.api as sm | ||
| X = sm.add_constant(x) | ||
| model = sm.RLM(y, X, M=sm.robust.norms.HuberT()) | ||
| result = model.fit() | ||
| return result.params[0], result.params[1] # intercept, slope | ||
|
|
||
|
|
||
| def wls_ivim_fit(bvalues, signal, cutoff=200, method="WLS"): | ||
| """ | ||
| IVIM fit using WLS or RLM (segmented approach). | ||
|
|
||
| Step 1: Fit D from high b-values on log-signal. | ||
| Step 2: Fit D* from residuals at low b-values. | ||
|
|
||
| Args: | ||
| bvalues (array-like): 1D array of b-values (s/mm²). | ||
| signal (array-like): 1D array of signal intensities (will be normalized). | ||
| cutoff (float): b-value threshold separating D from D* fitting. | ||
| Default: 200 s/mm². | ||
| method (str): Regression method to use. | ||
| - "WLS": Weighted Least Squares with Veraart S² weights (default). | ||
| - "RLM": Robust Linear Model with Huber's T norm (statsmodels). | ||
|
|
||
| Returns: | ||
| tuple: (D, f, Dp) where | ||
| D (float): True diffusion coefficient (mm²/s). | ||
| f (float): Perfusion fraction (0-1). | ||
| Dp (float): Pseudo-diffusion coefficient (mm²/s). | ||
| """ | ||
| method = method.upper() | ||
| if method not in ("WLS", "RLM"): | ||
| raise ValueError(f"Unknown method '{method}'. Use 'WLS' or 'RLM'.") | ||
|
|
||
| bvalues = np.array(bvalues, dtype=float) | ||
| signal = np.array(signal, dtype=float) | ||
|
|
||
| # Normalize signal to S(b=0) | ||
| s0_vals = signal[bvalues == 0] | ||
| if len(s0_vals) == 0 or np.mean(s0_vals) <= 0: | ||
| return 0.0, 0.0, 0.0 | ||
| s0 = np.mean(s0_vals) | ||
| signal = signal / s0 | ||
|
|
||
| try: | ||
| # ── Step 1: Estimate D from high b-values ───────────────────── | ||
| # At high b, perfusion component ≈ 0, so: | ||
| # S(b) ≈ (1 - f) * exp(-b * D) | ||
| # ln(S(b)) = ln(1 - f) - b * D | ||
| high_mask = bvalues >= cutoff | ||
| b_high = bvalues[high_mask] | ||
| s_high = signal[high_mask] | ||
|
|
||
| # Guard against zero/negative signal values | ||
| s_high = np.maximum(s_high, 1e-8) | ||
| log_s = np.log(s_high) | ||
|
|
||
| if method == "WLS": | ||
| # Veraart weights: w = S^2 (corrects for noise in log-domain) | ||
| weights_high = s_high ** 2 | ||
| intercept, D = _weighted_linreg(-b_high, log_s, weights_high) | ||
| else: | ||
| # RLM: robust regression, no explicit weights needed | ||
| intercept, D = _rlm_linreg(-b_high, log_s) | ||
|
|
||
| # Extract f from intercept: intercept = ln(1 - f) | ||
| f = 1.0 - np.exp(intercept) | ||
|
|
||
| # Clamp to physically meaningful ranges | ||
| D = np.clip(D, 0, 0.005) | ||
| f = np.clip(f, 0, 1) | ||
|
|
||
| # ── Step 2: Estimate D* from low b-value residuals ──────────── | ||
| # Subtract the diffusion component: | ||
| # residual(b) = S(b) - (1 - f) * exp(-b * D) | ||
| # ≈ f * exp(-b * D*) | ||
| # ln(residual) = ln(f) - b * D* | ||
| residual = signal - (1 - f) * np.exp(-bvalues * D) | ||
|
|
||
| low_mask = (bvalues < cutoff) & (bvalues > 0) | ||
| b_low = bvalues[low_mask] | ||
| r_low = residual[low_mask] | ||
|
|
||
| # Guard against zero/negative residuals | ||
| r_low = np.maximum(r_low, 1e-8) | ||
| log_r = np.log(r_low) | ||
|
|
||
| if len(b_low) >= 2: | ||
| if method == "WLS": | ||
| weights_low = r_low ** 2 | ||
| _, Dp = _weighted_linreg(-b_low, log_r, weights_low) | ||
| else: | ||
| _, Dp = _rlm_linreg(-b_low, log_r) | ||
| Dp = np.clip(Dp, 0.005, 0.2) | ||
| else: | ||
| Dp = 0.01 # fallback | ||
|
|
||
| # Ensure D* > D (by convention) | ||
| if Dp < D: | ||
| D, Dp = Dp, D | ||
| f = 1 - f | ||
|
|
||
| return D, f, Dp | ||
|
|
||
| except Exception: | ||
| # If fit fails, return zeros (consistent with other algorithms) | ||
| return 0.0, 0.0, 0.0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| from src.wrappers.OsipiBase import OsipiBase | ||
| from src.original.DT_IIITN.wls_ivim_fitting import wls_ivim_fit | ||
| import numpy as np | ||
|
|
||
|
|
||
| class DT_IIITN_WLS(OsipiBase): | ||
| """ | ||
| Segmented IVIM fitting with selectable regression method. | ||
|
|
||
| Two methods are available: | ||
| - WLS: Weighted Least Squares with Veraart S² weights (default) | ||
| - RLM: Robust Linear Model with Huber's T norm (statsmodels) | ||
|
|
||
| Segmented approach: | ||
| 1. Estimate D from high b-values using linear regression on log-signal | ||
| 2. Estimate D* from residuals at low b-values using linear regression | ||
|
|
||
| Author: Devguru Tiwari, IIIT Nagpur | ||
|
|
||
| Reference: | ||
| Veraart, J. et al. (2013). "Weighted linear least squares estimation of | ||
| diffusion MRI parameters: strengths, limitations, and pitfalls." | ||
| NeuroImage, 81, 335-346. | ||
| DOI: 10.1016/j.neuroimage.2013.05.028 | ||
| """ | ||
|
|
||
| # Algorithm identification | ||
| id_author = "Devguru Tiwari, IIIT Nagpur" | ||
| id_algorithm_type = "Weighted least squares / robust linear model segmented fit" | ||
| id_return_parameters = "f, D*, D" | ||
| id_units = "seconds per milli metre squared or milliseconds per micro metre squared" | ||
| id_ref = "https://doi.org/10.1016/j.neuroimage.2013.05.028" | ||
|
|
||
| # Algorithm requirements | ||
| required_bvalues = 4 | ||
| required_thresholds = [0, 0] | ||
| required_bounds = False | ||
| required_bounds_optional = True | ||
| required_initial_guess = False | ||
| required_initial_guess_optional = True | ||
|
|
||
| # Supported inputs | ||
| supported_bounds = False | ||
| supported_initial_guess = False | ||
| supported_thresholds = True | ||
| supported_dimensions = 1 | ||
| supported_priors = False | ||
|
|
||
| def __init__(self, bvalues=None, thresholds=None, | ||
| bounds=None, initial_guess=None, method="WLS"): | ||
| """ | ||
| Initialize the IVIM fitting algorithm. | ||
|
|
||
| Args: | ||
| bvalues (array-like, optional): b-values for the fitted signals. | ||
| thresholds (array-like, optional): Threshold b-value for segmented | ||
| fitting. The first value is used as the cutoff between high | ||
| and low b-values. Default: 200 s/mm². | ||
| bounds (dict, optional): Not used by this algorithm. | ||
| initial_guess (dict, optional): Not used by this algorithm. | ||
| method (str): Regression method — "WLS" (default) or "RLM". | ||
| """ | ||
| super(DT_IIITN_WLS, self).__init__( | ||
| bvalues=bvalues, bounds=bounds, | ||
| initial_guess=initial_guess, thresholds=thresholds | ||
| ) | ||
| self.method = method.upper() | ||
|
|
||
| def ivim_fit(self, signals, bvalues, **kwargs): | ||
| """Perform the IVIM fit using the selected method (WLS or RLM). | ||
|
|
||
| Args: | ||
| signals (array-like): Signal intensities at each b-value. | ||
| bvalues (array-like, optional): b-values for the signals. | ||
|
|
||
| Returns: | ||
| dict: Dictionary with keys "D", "f", "Dp". | ||
| """ | ||
| bvalues = np.array(bvalues) | ||
|
|
||
| # Use threshold as cutoff if available | ||
| cutoff = 200 | ||
| if self.thresholds is not None and len(self.thresholds) > 0: | ||
| cutoff = self.thresholds[0] | ||
|
|
||
| D, f, Dp = wls_ivim_fit(bvalues, signals, cutoff=cutoff, | ||
| method=self.method) | ||
|
|
||
| results = {} | ||
| results["D"] = D | ||
| results["f"] = f | ||
| results["Dp"] = Dp | ||
|
|
||
| return results | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
@IvanARashid , I'm a bit confused what the difference is between
requiered bounds
requiered bounds optional
supported bounds
Does it make sense for the second to be true and the others false?
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 followed the src\standardized\ETP_SRI_LinearFitting.py file pattern which also has required_bounds_optional = True with supported_bounds = False. From what i understood, required_bounds_optional = True means means the wrapper's init file will accept bounds without raising an error, while supported_bounds = False means the underlying algorithm doesn't actually use them. If any suggestion come up, I will update this. Thank you.
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.
Yeah, not the wisest naming convention on my part. required_bounds can be False while required_bounds_optional can be true for algorithms that e.g. don't require bounds but can still take them as inputs. But I think both should be false for ETP_SRI_LinearFitting since it doesn't look like any bounds are being passed to the algorithm. It's something that has been overlooked in our previous passes.
So required_bounds_optional just means if bounds are optional and can still be passed.
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.
Keep in mind that LinearFitting was one of the very first algorithms to be committed to the repo and standardized, so there can be variables there that are deprecated :)