Skip to content
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

[ENH] Add threshold argument to make_adaptive_mask #635

Merged
merged 12 commits into from
Jan 27, 2021
42 changes: 30 additions & 12 deletions tedana/combine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"""
import logging
import numpy as np
from tedana.utils import unmask
from tedana.due import due, Doi

LGR = logging.getLogger(__name__)
Expand Down Expand Up @@ -137,7 +136,9 @@ def make_optcom(data, tes, adaptive_mask, t2s=None, combmode='t2s', verbose=True
tes : (E,) :obj:`numpy.ndarray`
Array of TEs, in seconds.
adaptive_mask : (S,) :obj:`numpy.ndarray`
Adaptive mask of the data indicating the number of echos with signal at each voxel
Adaptive mask of the data indicating the number of echos with signal at each voxel.
This mask may be thresholded, typically with values less than 3 set to 0 depending
tsalo marked this conversation as resolved.
Show resolved Hide resolved
on the thresholding method.
t2s : (S [x T]) :obj:`numpy.ndarray` or None, optional
Estimated T2* values. Only required if combmode = 't2s'.
Default is None.
Expand Down Expand Up @@ -181,6 +182,11 @@ def make_optcom(data, tes, adaptive_mask, t2s=None, combmode='t2s', verbose=True
Magnetic Resonance in Medicine: An Official Journal of the
International Society for Magnetic Resonance in Medicine,
55(6), 1227-1235.

See Also
--------
:func:`tedana.utils.make_adaptive_mask` : The function used to create the ``adaptive_mask``
parameter.
"""
if data.ndim != 3:
raise ValueError('Input data must be 3D (S x E x T)')
Expand Down Expand Up @@ -217,23 +223,35 @@ def make_optcom(data, tes, adaptive_mask, t2s=None, combmode='t2s', verbose=True
'estimates')
LGR.info(msg)

mask = adaptive_mask >= 3
data = data[mask, :, :] # mask out unstable voxels/samples
echos_to_run = np.unique(adaptive_mask)
# When there is one good echo, use two
if 1 in echos_to_run:
echos_to_run = np.sort(np.unique(np.append(echos_to_run, 2)))
echos_to_run = echos_to_run[echos_to_run >= 2]

tes = np.array(tes)[np.newaxis, ...] # (1 x E) array_like
combined = np.zeros((data.shape[0], data.shape[2]))
report = True
for echo in np.unique(adaptive_mask[mask]):
echo_idx = adaptive_mask[mask] == echo
for i_echo, echo_num in enumerate(echos_to_run):
if echo_num == 2:
# Use the first two echoes for cases where there are
# either one or two good echoes
voxel_idx = np.where(np.logical_and(adaptive_mask > 0, adaptive_mask <= echo_num))[0]
else:
voxel_idx = np.where(adaptive_mask == echo_num)[0]

if combmode == 'paid':
combined[echo_idx, :] = _combine_paid(data[echo_idx, :echo, :],
tes[:echo], report=report)
combined[voxel_idx, :] = _combine_paid(data[voxel_idx, :echo_num, :],
tes[:, :echo_num])
else:
t2s_ = t2s[mask, ..., np.newaxis] # mask out empty voxels/samples
t2s_ = t2s[..., np.newaxis] # add singleton

combined[echo_idx, :] = _combine_t2s(
data[echo_idx, :echo, :], tes[:, :echo], t2s_[echo_idx, ...], report=report)
combined[voxel_idx, :] = _combine_t2s(
data[voxel_idx, :echo_num, :],
tes[:, :echo_num],
t2s_[voxel_idx, ...],
report=report
)
report = False

combined = unmask(combined, mask)
return combined
32 changes: 28 additions & 4 deletions tedana/decay.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ def fit_monoexponential(data_cat, echo_times, adaptive_mask, report=True):
Echo times in milliseconds.
adaptive_mask : (S,) :obj:`numpy.ndarray`
Array where each value indicates the number of echoes with good signal
for that voxel.
for that voxel. This mask may be thresholded, typically with values
tsalo marked this conversation as resolved.
Show resolved Hide resolved
less than 3 set to 0 depending on the thresholding method.
report : bool, optional
Whether to log a description of this step or not. Default is True.

Expand All @@ -85,6 +86,11 @@ def fit_monoexponential(data_cat, echo_times, adaptive_mask, report=True):
Notes
-----
This method is slower, but more accurate, than the log-linear approach.

See Also
--------
:func:`tedana.utils.make_adaptive_mask` : The function used to create the ``adaptive_mask``
parameter.
"""
if report:
RepLGR.info("A monoexponential model was fit to the data at each voxel "
Expand All @@ -104,6 +110,7 @@ def fit_monoexponential(data_cat, echo_times, adaptive_mask, report=True):
data_cat, echo_times, adaptive_mask, report=False)

echos_to_run = np.unique(adaptive_mask)
# When there is one good echo, use two
if 1 in echos_to_run:
echos_to_run = np.sort(np.unique(np.append(echos_to_run, 2)))
echos_to_run = echos_to_run[echos_to_run >= 2]
Expand All @@ -114,6 +121,8 @@ def fit_monoexponential(data_cat, echo_times, adaptive_mask, report=True):

for i_echo, echo_num in enumerate(echos_to_run):
if echo_num == 2:
# Use the first two echoes for cases where there are
# either one or two good echoes
voxel_idx = np.where(adaptive_mask <= echo_num)[0]
else:
voxel_idx = np.where(adaptive_mask == echo_num)[0]
Expand Down Expand Up @@ -210,6 +219,7 @@ def fit_loglinear(data_cat, echo_times, adaptive_mask, report=True):
n_samp, n_echos, n_vols = data_cat.shape

echos_to_run = np.unique(adaptive_mask)
# When there is one good echo, use two
if 1 in echos_to_run:
echos_to_run = np.sort(np.unique(np.append(echos_to_run, 2)))
echos_to_run = echos_to_run[echos_to_run >= 2]
Expand All @@ -220,7 +230,9 @@ def fit_loglinear(data_cat, echo_times, adaptive_mask, report=True):

for i_echo, echo_num in enumerate(echos_to_run):
if echo_num == 2:
voxel_idx = np.where(adaptive_mask <= echo_num)[0]
# Use the first two echoes for cases where there are
# either one or two good echoes
voxel_idx = np.where(np.logical_and(adaptive_mask > 0, adaptive_mask <= echo_num))[0]
else:
voxel_idx = np.where(adaptive_mask == echo_num)[0]

Expand Down Expand Up @@ -274,7 +286,8 @@ def fit_decay(data, tes, mask, adaptive_mask, fittype, report=True):
time AND echoes) non-zero
adaptive_mask : (S,) array_like
Valued array indicating number of echos that have sufficient signal in
given sample
given sample. This mask may be thresholded, typically with values
less than 3 set to 0 depending on the thresholding method.
fittype : {loglin, curvefit}
The type of model fit to use
report : bool, optional
Expand Down Expand Up @@ -304,6 +317,11 @@ def fit_decay(data, tes, mask, adaptive_mask, fittype, report=True):
Additionally, very small :math:`T_2^*` values above zero are replaced with a floor
value to prevent zero-division errors later on in the workflow.
It also replaces NaN values in the :math:`S_0` map with 0.

See Also
--------
:func:`tedana.utils.make_adaptive_mask` : The function used to create the ``adaptive_mask``
parameter.
"""
if data.shape[1] != len(tes):
raise ValueError('Second dimension of data ({0}) does not match number '
Expand Down Expand Up @@ -364,7 +382,8 @@ def fit_decay_ts(data, tes, mask, adaptive_mask, fittype):
time AND echoes) non-zero
adaptive_mask : (S,) array_like
Valued array indicating number of echos that have sufficient signal in
given sample
given sample. This mask may be thresholded, typically with values
less than 3 set to 0 depending on the thresholding method.
fittype : :obj: `str`
The type of model fit to use

Expand All @@ -384,6 +403,11 @@ def fit_decay_ts(data, tes, mask, adaptive_mask, fittype):
Full S0 timeseries. For voxels affected by dropout, with good signal
from only one echo, the full timeseries uses the single echo's value
at that voxel/volume.

See Also
--------
:func:`tedana.utils.make_adaptive_mask` : The function used to create the ``adaptive_mask``
parameter.
"""
n_samples, _, n_vols = data.shape
tes = np.array(tes)
Expand Down
9 changes: 8 additions & 1 deletion tedana/decomposition/pca.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ def tedpca(data_cat, data_oc, combmode, mask, adaptive_mask, t2sG,
mask : (S,) array_like
Boolean mask array
adaptive_mask : (S,) array_like
Adaptive mask of the data indicating the number of echos with signal at each voxel
Adaptive mask of the data indicating the number of echos with signal at each voxel.
This mask may be thresholded, typically with values less than 3 set to
0 depending on the thresholding method.
t2sG : (S,) array_like
Map of voxel-wise T2* estimates.
ref_img : :obj:`str` or img_like
Expand Down Expand Up @@ -147,6 +149,11 @@ def tedpca(data_cat, data_oc, combmode, mask, adaptive_mask, t2sG,
pca_mixing.tsv PCA mixing matrix.
pca_components.nii.gz Component weight maps.
====================== =================================================

See Also
--------
:func:`tedana.utils.make_adaptive_mask` : The function used to create the ``adaptive_mask``
parameter.
"""
if algorithm == 'kundu':
alg_str = ("followed by the Kundu component selection decision "
Expand Down
10 changes: 8 additions & 2 deletions tedana/metrics/kundu_fit.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ def dependence_metrics(catd, tsoc, mmix, adaptive_mask, tes, ref_img,
is components and `T` is the same as in `catd`
adaptive_mask : (S) array_like
Adaptive mask, where each voxel's value is the number of echoes with
"good signal".
"good signal". This mask may be thresholded, typically with values
less than 3 set to 0 depending on the thresholding method.
tes : list
List of echo times associated with `catd`, in milliseconds
ref_img : str or img_like
Expand Down Expand Up @@ -69,9 +70,14 @@ def dependence_metrics(catd, tsoc, mmix, adaptive_mask, tes, ref_img,
betas : :obj:`numpy.ndarray`
mmix_corrected : :obj:`numpy.ndarray`
Mixing matrix after sign correction and resorting (if reindex is True).

See Also
--------
:func:`tedana.utils.make_adaptive_mask` : The function used to create the ``adaptive_mask``
parameter.
"""
# Use adaptive_mask as mask
mask = adaptive_mask >= 3
mask = adaptive_mask > 0

if not (catd.shape[0] == adaptive_mask.shape[0] == tsoc.shape[0]):
raise ValueError('First dimensions (number of samples) of catd ({0}), '
Expand Down
8 changes: 4 additions & 4 deletions tedana/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,15 @@ def test_load_image():
def test_make_adaptive_mask():
# load data make masks
data = io.load_data(fnames, n_echos=len(tes))[0]
mask, masksum = utils.make_adaptive_mask(data, getsum=True)
mask, masksum = utils.make_adaptive_mask(data, getsum=True, threshold=1)

# getsum doesn't change mask values
assert np.allclose(mask, utils.make_adaptive_mask(data))
# shapes are all the same
assert mask.shape == masksum.shape == (64350,)
assert np.allclose(mask, (masksum >= 3).astype(bool))
assert np.allclose(mask, (masksum >= 1).astype(bool))
# mask has correct # of entries
assert mask.sum() == 41749
assert mask.sum() == 50786
# masksum has correct values
vals, counts = np.unique(masksum, return_counts=True)
assert np.allclose(vals, np.array([0, 1, 2, 3]))
Expand All @@ -98,7 +98,7 @@ def test_make_adaptive_mask():
# TODO: Add mask file with no bad voxels to test against
mask, masksum = utils.make_adaptive_mask(data, mask=pjoin(datadir,
'mask.nii.gz'),
getsum=True)
getsum=True, threshold=3)
assert np.allclose(mask, (masksum >= 3).astype(bool))


Expand Down
16 changes: 10 additions & 6 deletions tedana/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def load_image(data):
return fdata


def make_adaptive_mask(data, mask=None, getsum=False):
def make_adaptive_mask(data, mask=None, getsum=False, threshold=1):
"""
Makes map of `data` specifying longest echo a voxel can be sampled with

Expand All @@ -55,6 +55,9 @@ def make_adaptive_mask(data, mask=None, getsum=False):
to generate mask from data with good signal across echoes
getsum : :obj:`bool`, optional
Return `masksum` in addition to `mask`. Default: False
threshold : :obj:`int`, optional
Minimum echo count to retain in the mask. Default is 1, which is
equivalent not thresholding.

Returns
-------
Expand Down Expand Up @@ -90,19 +93,20 @@ def make_adaptive_mask(data, mask=None, getsum=False):
masksum = (np.abs(echo_means) > lthrs).sum(axis=-1)

if mask is None:
# make it a boolean mask to (where we have at least 1 echo with good signal)
mask = (masksum >= 3).astype(bool)
# make it a boolean mask to (where we have at least `threshold` echoes with good signal)
mask = (masksum >= threshold).astype(bool)
masksum[masksum < threshold] = 0
else:
# if the user has supplied a binary mask
mask = load_image(mask).astype(bool)
masksum = masksum * mask
mask = (masksum >= 3).astype(bool)
# reduce mask based on masksum
# TODO: Use visual report to make checking the reduced mask easier
if np.any(masksum[mask] < 3):
n_bad_voxels = np.sum(masksum[mask] < 3)
if np.any(masksum[mask] < threshold):
n_bad_voxels = np.sum(masksum[mask] < threshold)
LGR.warning('{0} voxels in user-defined mask do not have good '
'signal. Removing voxels from mask.'.format(n_bad_voxels))
masksum[masksum < threshold] = 0
Copy link
Contributor

Choose a reason for hiding this comment

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

I am not completely familiar with where the adaptive mask is used, but could adding the following line

masksum[maskum == 1] = 2

simplify the downstream steps or would it break other parts of the code?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it depends on where we want to bury the "treat one good echo as two good echoes" trick. The way it is now, we can dig into where the adaptive mask indicates only one good echo, and it's on the other functions to document that those voxels will be treated as if they have two good echoes (depending on the threshold). On the other hand, if we add that, then the other functions can be simplified a bit, but we won't be able to investigate the adaptive mask as well.

Copy link
Contributor

Choose a reason for hiding this comment

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

That makes sense. Thanks.

mask = masksum.astype(bool)

if getsum:
Expand Down
2 changes: 1 addition & 1 deletion tedana/workflows/t2smap.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def t2smap_workflow(data, tes, out_dir='.', mask=None,
LGR.info('Computing adaptive mask')
else:
LGR.info('Using user-defined mask')
mask, masksum = utils.make_adaptive_mask(catd, mask=mask, getsum=True)
mask, masksum = utils.make_adaptive_mask(catd, mask=mask, getsum=True, threshold=1)

LGR.info('Computing adaptive T2* map')
if fitmode == 'all':
Expand Down
3 changes: 2 additions & 1 deletion tedana/workflows/tedana.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,8 @@ def tedana_workflow(data, tes, out_dir='.', mask=None,
RepLGR.info("An initial mask was generated from the first echo using "
"nilearn's compute_epi_mask function.")

mask, masksum = utils.make_adaptive_mask(catd, mask=mask, getsum=True)
# Create an adaptive mask with at least 3 good echoes.
mask, masksum = utils.make_adaptive_mask(catd, mask=mask, getsum=True, threshold=3)
LGR.debug('Retaining {}/{} samples'.format(mask.sum(), n_samp))
io.filewrite(masksum, op.join(out_dir, 'adaptive_mask.nii'), ref_img)

Expand Down