Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
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?

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
}
}
}