How to Handle Fitting Failures¶
Diagnose and resolve common fitting problems in perfusion analysis.
CLI Troubleshooting¶
If a CLI pipeline run produces poor results:
# Validate your config first
osipy --validate config.yaml
# Run with verbose logging to see per-step diagnostics
osipy config.yaml data.nii.gz -v
Common config issues: wrong population_aif, incorrect flip_angles or tr, missing mask. Check the verbose output for warnings about low R² or high failure rates.
Identify Fitting Failures¶
Check the quality mask and R² values:
import numpy as np
import osipy
# After fitting
result = osipy.fit_model("extended_tofts", concentration, aif, time)
# Check quality
quality_mask = result.quality_mask
r_squared = result.r_squared_map
total_voxels = quality_mask.size
valid_voxels = (quality_mask > 0).sum()
failed_voxels = total_voxels - valid_voxels
print(f"Total voxels: {total_voxels}")
print(f"Valid fits: {valid_voxels} ({100*valid_voxels/total_voxels:.1f}%)")
print(f"Failed fits: {failed_voxels} ({100*failed_voxels/total_voxels:.1f}%)")
# R² distribution
valid_r2 = r_squared[quality_mask > 0]
print(f"\nR² statistics (valid voxels):")
print(f" Mean: {valid_r2.mean():.4f}")
print(f" Min: {valid_r2.min():.4f}")
print(f" <0.5: {(valid_r2 < 0.5).sum()} voxels")
Common Causes and Solutions¶
Poor Signal Quality¶
Symptoms: Low R², many failed voxels, noisy parameter maps
Diagnose by checking the signal-to-noise ratio:
# Check signal-to-noise ratio
baseline = concentration[..., :5].mean(axis=-1)
noise = concentration[..., :5].std(axis=-1)
snr = baseline / (noise + 1e-10)
print(f"SNR range: {snr.min():.1f} - {snr.max():.1f}")
print(f"Low SNR voxels (<10): {(snr < 10).sum()}")
Apply a stricter mask to exclude low-SNR voxels:
# 1. Apply stricter mask
high_snr_mask = snr > 10
result = osipy.fit_model("extended_tofts", concentration, aif, time, mask=high_snr_mask)
# 2. Spatial smoothing (before fitting)
# Note: osipy does not currently include a spatial smoothing function.
# Consider using an external library for spatial smoothing before fitting,
# or use osipy's temporal_filter for temporal smoothing:
# from osipy.common.signal.filtering import temporal_filter
Incorrect Time Units¶
Symptoms: Ktrans clustering at bounds (0 or 5.0), all fits fail
Check the time array:
Convert to seconds if needed:
# Convert from minutes to seconds if needed
time_seconds = time * 60
# Or from milliseconds
time_seconds = time / 1000
AIF (Arterial Input Function) Timing Mismatch¶
Symptoms: Systematically low R², concentration curves don't match AIF shape
Plot the AIF alongside a tissue curve to check alignment:
import matplotlib.pyplot as plt
# Compare AIF and tissue timing
fig, ax = plt.subplots()
ax.plot(time, aif.concentration, 'r-', label='AIF')
# Sample tissue curve
sample_curve = concentration[32, 32, 16, :]
ax.plot(time, sample_curve / sample_curve.max() * aif.concentration.max(),
'b-', label='Tissue (scaled)')
ax.set_xlabel('Time (s)')
ax.legend()
plt.show()
Option 1 -- automatic delay fitting (recommended):
# Let osipy estimate per-voxel arterial delay automatically
result = osipy.fit_model(
"extended_tofts", concentration, aif, time,
fit_delay=True # Adds a "delay" parameter to the fit
)
# Check estimated delays
delay = result.parameter_maps["delay"].values
print(f"Estimated delay range: {delay[result.quality_mask > 0].min():.1f} - "
f"{delay[result.quality_mask > 0].max():.1f} s")
Option 2 -- manual AIF shift with the shift_aif utility:
from osipy.common.aif import shift_aif
import numpy as np
# Shift AIF by a known delay
shifted_conc = shift_aif(aif.concentration, time, delay=5.0, xp=np)
aif_shifted = osipy.ArterialInputFunction(time=time, concentration=shifted_conc)
result = osipy.fit_model("extended_tofts", concentration, aif_shifted, time)
Inappropriate Model¶
Symptoms: Good fits in some regions, poor in others
Compare models to check whether the chosen model is appropriate:
# Compare models
result_standard = osipy.fit_model("tofts", concentration, aif, time)
result_extended = osipy.fit_model("extended_tofts", concentration, aif, time)
# Check R² improvement
r2_diff = result_extended.r_squared_map - result_standard.r_squared_map
print(f"R² improvement with Extended Tofts: {r2_diff.mean():.4f}")
Solution: Try different models (see Compare Multiple Models)
Boundary Violations¶
Symptoms: Parameters at bounds (Ktrans=0 or max), warnings about convergence
Check whether parameters cluster at their bounds:
ktrans = result.parameter_maps['Ktrans'].values
valid = quality_mask > 0
# Check for boundary clustering
at_lower = (ktrans[valid] < 0.001).sum()
at_upper = (ktrans[valid] > 0.9).sum()
print(f"At lower bound: {at_lower}")
print(f"At upper bound: {at_upper}")
Adjust bounds if physiologically justified:
# Adjust bounds if physiologically justified
result = osipy.fit_model(
"extended_tofts", concentration, aif, time,
bounds_override={
'Ktrans': (0.0001, 2.0), # Wider range
've': (0.01, 0.99),
'vp': (0.001, 0.5)
}
)
Negative Concentrations¶
Symptoms: Fitting errors, NaN results
Check for negative values:
negative_voxels = (concentration < 0).any(axis=-1).sum()
print(f"Voxels with negative concentration: {negative_voxels}")
Clip or correct them:
# Clip negative values
concentration_clipped = np.maximum(concentration, 0)
# Or improve baseline correction
baseline = concentration[..., :n_baseline].mean(axis=-1, keepdims=True)
concentration_corrected = concentration - baseline
concentration_corrected = np.maximum(concentration_corrected, 0)
Visualize Failures¶
Plot failed vs successful fits:
import matplotlib.pyplot as plt
import numpy as np
def visualize_fitting_failures(result, concentration, time, aif, slice_idx=None):
"""Visualize fitting success and failures."""
if slice_idx is None:
slice_idx = result.quality_mask.shape[2] // 2
quality = result.quality_mask[:, :, slice_idx]
r_squared = result.r_squared_map[:, :, slice_idx]
fig, axes = plt.subplots(2, 3, figsize=(12, 8))
# Success/failure map
axes[0, 0].imshow(quality, cmap='RdYlGn')
axes[0, 0].set_title(f'Quality Mask\n(Green=valid)')
# R² map
im = axes[0, 1].imshow(r_squared, cmap='viridis', vmin=0, vmax=1)
axes[0, 1].set_title('R² Map')
plt.colorbar(im, ax=axes[0, 1])
# R² histogram
valid_r2 = result.r_squared_map[result.quality_mask > 0]
axes[0, 2].hist(valid_r2, bins=50, color='steelblue')
axes[0, 2].axvline(0.5, color='r', linestyle='--', label='Threshold')
axes[0, 2].set_xlabel('R²')
axes[0, 2].set_title('R² Distribution')
axes[0, 2].legend()
# Sample successful fit
success_idx = np.where(quality > 0)
if len(success_idx[0]) > 0:
x, y = success_idx[0][0], success_idx[1][0]
axes[1, 0].plot(time, concentration[x, y, slice_idx, :], 'ko', label='Data')
axes[1, 0].set_title(f'Successful Fit\nR²={r_squared[x,y]:.3f}')
axes[1, 0].legend()
# Sample failed fit
fail_idx = np.where(quality == 0)
if len(fail_idx[0]) > 0:
x, y = fail_idx[0][0], fail_idx[1][0]
axes[1, 1].plot(time, concentration[x, y, slice_idx, :], 'ko', label='Data')
axes[1, 1].set_title(f'Failed Fit\nR²={r_squared[x,y]:.3f}')
axes[1, 1].legend()
# Failure reasons
axes[1, 2].axis('off')
n_total = quality.size
n_valid = (quality > 0).sum()
n_low_r2 = (r_squared < 0.5).sum() - (quality == 0).sum()
text = f"""Fitting Summary:
Total voxels: {n_total}
Valid fits: {n_valid} ({100*n_valid/n_total:.1f}%)
Low R² (<0.5): {n_low_r2}
"""
axes[1, 2].text(0.1, 0.5, text, fontsize=12, family='monospace')
plt.tight_layout()
return fig
fig = visualize_fitting_failures(result, concentration, time, aif)
plt.savefig('fitting_diagnostics.png', dpi=150)
Retry Strategies¶
Progressive Simplification¶
Start with a complex model and fall back to a simpler one for failed voxels:
# Start with complex model, fall back to simpler
def fit_with_fallback(concentration, aif, time, mask):
# Try Extended Tofts first
result = osipy.fit_model("extended_tofts", concentration, aif, time, mask=mask)
# Find failed voxels
failed = (result.quality_mask == 0) & mask
if failed.sum() > 0:
print(f"Retrying {failed.sum()} voxels with Standard Tofts")
# Retry with simpler model
result_simple = osipy.fit_model("tofts", concentration, aif, time,
mask=failed)
# Merge results
# Note: Merging DCEFitResult objects requires manual attribute updates.
# This is a simplified example—in practice, you would need to update
# result.parameter_maps, result.quality_mask, and result.r_squared_map.
return result
result = fit_with_fallback(concentration, aif, time, mask)
Different Bounds Overrides¶
Try multiple bound configurations and keep the best result:
# Multiple bound configurations
def fit_with_multiple_bounds(concentration, aif, time):
best_result = None
best_r2 = -np.inf
# Try different bounds_override configurations
bounds_configs = [
# Default-like tight bounds
{'Ktrans': (0.0001, 1.0), 've': (0.01, 0.99), 'vp': (0.001, 0.1)},
# Wider bounds for high-permeability tissue
{'Ktrans': (0.0001, 2.0), 've': (0.01, 0.99), 'vp': (0.001, 0.5)},
# Narrow bounds for low-permeability tissue
{'Ktrans': (0.0001, 0.1), 've': (0.1, 0.8), 'vp': (0.001, 0.05)},
]
for bounds in bounds_configs:
result = osipy.fit_model("extended_tofts", concentration, aif, time,
bounds_override=bounds)
mean_r2 = result.r_squared_map[result.quality_mask > 0].mean()
if mean_r2 > best_r2:
best_r2 = mean_r2
best_result = result
return best_result
Prevention Tips¶
- Check data quality first: SNR, motion artifacts, baseline
- Verify timing: Time units, bolus arrival, AIF alignment
- Start simple: Fit Standard Tofts before Extended
- Use masks: Exclude non-tissue voxels
- Monitor progress: Check R² during development