Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/original/DT_IIITN/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# WLS IVIM fitting by Devguru Tiwari, IIIT Nagpur
139 changes: 139 additions & 0 deletions src/original/DT_IIITN/wls_ivim_fitting.py
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
87 changes: 87 additions & 0 deletions src/standardized/DT_IIITN_WLS.py
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
Copy link
Copy Markdown
Collaborator

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?

Copy link
Copy Markdown
Contributor Author

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.

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Contributor

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 :)

Copy link
Copy Markdown
Contributor Author

@Devguru-codes Devguru-codes Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @IvanARashid sir for helping here. My algorithm here doesnt use bounds and initial guesses, I will set required_bounds_optional = False and required_initial_guess_optional = False as well. Thank you.

Copy link
Copy Markdown
Contributor Author

@Devguru-codes Devguru-codes Mar 13, 2026

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 :)

Do we need to update it or should we let it be. If you think I should update it, please tell. I will do it in a new PR. Thank you.

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
18 changes: 12 additions & 6 deletions tests/IVIMmodels/unit_tests/algorithms.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,18 @@
"OJ_GU_seg",
"OJ_GU_segMATLAB",
"OJ_GU_bayesMATLAB",
"TF_reference_IVIMfit"
"TF_reference_IVIMfit",
"DT_IIITN_WLS"
],
"TCML_TechnionIIT_lsqBOBYQA": {
"xfail_names": {"pericardium": false, "bounds": false}
"xfail_names": {
"pericardium": false,
"bounds": false
}
},
"IVIM_NEToptim": {
"IVIM_NEToptim": {
"deep_learning": true,
"n":3000000
"n": 3000000
},
"Super_IVIM_DC": {
"deep_learning": true
Expand All @@ -51,7 +55,9 @@
},
"ETP_SRI_LinearFitting": {
"options": {
"thresholds": [500]
"thresholds": [
500
]
},
"tolerances": {
"rtol": {
Expand Down Expand Up @@ -81,4 +87,4 @@
},
"test_bounds": false
}
}
}