-
Notifications
You must be signed in to change notification settings - Fork 45
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
1
commit 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.
+239
−6
Open
Changes from all commits
Commits
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,139 @@ | ||
| """ | ||
| Weighted Least Squares (WLS) 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 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 linear regression | ||
|
|
||
| Weighting follows Veraart et al. (2013): weights = signal^2 to account | ||
| for heteroscedasticity introduced by the log-transform. | ||
|
|
||
| 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 | ||
| """ | ||
|
|
||
| import numpy as np | ||
| import warnings | ||
|
|
||
|
|
||
| def _weighted_linreg(x, y, weights): | ||
| """Fast weighted linear regression: y = a + b*x. | ||
|
|
||
| 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 wls_ivim_fit(bvalues, signal, cutoff=200): | ||
| """ | ||
| Weighted Least Squares IVIM fit (segmented approach). | ||
|
|
||
| Step 1: Fit D from high b-values using WLS on log-signal. | ||
| Weights = S(b)^2 (Veraart et al. 2013). | ||
| Step 2: Fit D* from residuals at low b-values using WLS. | ||
|
|
||
| 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². | ||
|
|
||
| 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). | ||
| """ | ||
| 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 | ||
| # Weighted linear fit: weights = S(b)^2 (Veraart correction) | ||
|
|
||
| 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) | ||
|
|
||
| # Veraart weights: w = S^2 (corrects for noise amplification in log-domain) | ||
| weights_high = s_high ** 2 | ||
|
|
||
| # WLS: ln(S) = intercept + slope * (-b) ⟹ slope = D | ||
| intercept, D = _weighted_linreg(-b_high, log_s, weights_high) | ||
|
|
||
| # 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) | ||
|
|
||
| weights_low = r_low ** 2 | ||
|
|
||
| if len(b_low) >= 2: | ||
| _, Dp = _weighted_linreg(-b_low, log_r, weights_low) | ||
| 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,87 @@ | ||
| 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): | ||
| """ | ||
| Weighted Least Squares IVIM fitting using statsmodels Robust Linear Model. | ||
|
|
||
| Segmented approach: | ||
| 1. Estimate D from high b-values using robust linear regression on log-signal | ||
| 2. Estimate D* from residuals at low b-values using robust 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 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): | ||
| """ | ||
| Initialize the WLS 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. | ||
| """ | ||
| super(DT_IIITN_WLS, self).__init__( | ||
| bvalues=bvalues, bounds=bounds, | ||
| initial_guess=initial_guess, thresholds=thresholds | ||
| ) | ||
|
|
||
| def ivim_fit(self, signals, bvalues, **kwargs): | ||
| """Perform the IVIM fit using WLS. | ||
|
|
||
| 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) | ||
|
|
||
| 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
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?