Skip to content
This repository was archived by the owner on Apr 2, 2020. It is now read-only.

Commit 839f8c9

Browse files
jdkentGilles86bthirionkchawla-pi
committed
First level residuals (#410)
* Allow for storing residuals in First Level Models * import missing modules and update calls to functions * fix flake8 errors * remove @set_attr_on_read * modify tests to reflect updated code base * fix typo and simplifiy loop * respond to review comments: - add docstrings - re-add @setattr_on_read - change rsq to r_square * change rsq to r_square * change rsq to r_square in tests * fix function calls * Example of how to use for . * Made ValueError for storing model attributes more verbose. * Also include R-squared * fix heading underlines in example * fix grammar Co-Authored-By: bthirion <[email protected]> * fix code formatting and do not standardize * change parameter timeseries to result_as_time_series * attempt to address @bthirion comments * split imports statements * always return list get_voxelwise_model_attribute_ * change docstrings for output to always be a list * modify tests to treat output as list * make _get_voxelwise_model_attribute private and improve documentation * fix formatting of function call * add empty line back in * revert regression.py to master * make result_as_time_series mandatory * add newlines to docs * add newline to end of file * fix missing newline * add James Kent to .mailmap * add entry for the new attributes to FirstLevelModel Co-authored-by: Gilles de Hollander <[email protected]> Co-authored-by: bthirion <[email protected]> Co-authored-by: Kshitij Chawla <[email protected]>
1 parent a10c5fe commit 839f8c9

File tree

7 files changed

+362
-5
lines changed

7 files changed

+362
-5
lines changed

.mailmap

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ Fabian Pedregosa <[email protected]>
2121
Franz Liem <[email protected]>
2222
Gael Varoquaux <[email protected]>
2323
Greg Kiar <[email protected]>
24+
James D. Kent <[email protected]>
25+
26+
James D. Kent <[email protected]> Fred Mertz <[email protected]>
2427
Jan Margeta <[email protected]>
2528
Jaques Grobler <[email protected]>
2629
Jason Gors <[email protected]>

doc/whats_new.rst

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
New
1010
---
1111

12+
* :func:`nistats.first_level_model.FirstLevelModel` now has the attributes: ``residuals``, ``predicted``, and ``r_square``
13+
which returns a Niimg-like object in the same shape as the input Niimg-like object.
14+
Additionally, there is an example showcasing the use of the attributes.
1215
* Use :func:`nistats.reporting.make_glm_report` to easily generate HTML reports from fitted first and second level models and contrasts.
1316
* New dataset fetcher, :func:`nistats.datasets.fetch_language_localizer_demo_dataset` , BIDS 1.2 compatible.
1417
* New example showcasing the use of a GLM to get beta maps for decoding experiments (aka beta-regression).
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""
2+
Predicted time series and residuals
3+
===================================
4+
5+
Here we fit a First Level GLM with the `minimize_memory`-argument set to `False`.
6+
By doing so, the `FirstLevelModel`-object stores the residuals, which we can then inspect.
7+
Also, the predicted time series can be extracted, which is useful to assess the quality of the model fit.
8+
"""
9+
10+
11+
#########################################################################
12+
# Import modules
13+
# --------------
14+
from nistats.datasets import fetch_spm_auditory
15+
from nilearn import image
16+
from nilearn import masking
17+
import pandas as pd
18+
19+
20+
# load fMRI data
21+
subject_data = fetch_spm_auditory()
22+
fmri_img = image.concat_imgs(subject_data.func)
23+
24+
# Make an average
25+
mean_img = image.mean_img(fmri_img)
26+
mask = masking.compute_epi_mask(mean_img)
27+
28+
# Clean and smooth data
29+
fmri_img = image.clean_img(fmri_img, standardize=False)
30+
fmri_img = image.smooth_img(fmri_img, 5.)
31+
32+
# load events
33+
events = pd.read_table(subject_data['events'])
34+
35+
36+
#########################################################################
37+
# Fit model
38+
# ---------
39+
# Note that `minimize_memory` is set to `False` so that `FirstLevelModel`
40+
# stores the residuals.
41+
# `signal_scaling` is set to False, so we keep the same scaling as the
42+
# original data in `fmri_img`.
43+
from nistats.first_level_model import FirstLevelModel
44+
45+
fmri_glm = FirstLevelModel(t_r=7,
46+
drift_model='cosine',
47+
signal_scaling=False,
48+
mask_img=mask,
49+
minimize_memory=False)
50+
51+
fmri_glm = fmri_glm.fit(fmri_img, events)
52+
53+
54+
#########################################################################
55+
# Calculate and plot contrast
56+
# ---------------------------
57+
from nilearn import plotting
58+
59+
z_map = fmri_glm.compute_contrast('active - rest')
60+
61+
plotting.plot_stat_map(z_map, bg_img=mean_img, threshold=3.1)
62+
63+
#########################################################################
64+
# Extract the largest clusters
65+
# ----------------------------
66+
from nistats.reporting import get_clusters_table
67+
from nilearn import input_data
68+
69+
table = get_clusters_table(z_map, stat_threshold=3.1,
70+
cluster_threshold=20).set_index('Cluster ID', drop=True)
71+
table.head()
72+
73+
# get the 6 largest clusters' max x, y, and z coordinates
74+
coords = table.loc[range(1, 7), ['X', 'Y', 'Z']].values
75+
76+
# extract time series from each coordinate
77+
masker = input_data.NiftiSpheresMasker(coords)
78+
real_timeseries = masker.fit_transform(fmri_img)
79+
predicted_timeseries = masker.fit_transform(fmri_glm.predicted[0])
80+
81+
82+
#########################################################################
83+
# Plot predicted and actual time series for 6 most significant clusters
84+
# ---------------------------------------------------------------------
85+
import matplotlib.pyplot as plt
86+
87+
# colors for each of the clusters
88+
colors = ['blue', 'navy', 'purple', 'magenta', 'olive', 'teal']
89+
# plot the time series and corresponding locations
90+
fig1, axs1 = plt.subplots(2, 6)
91+
for i in range(0, 6):
92+
# plotting time series
93+
axs1[0, i].set_title('Cluster peak {}\n'.format(coords[i]))
94+
axs1[0, i].plot(real_timeseries[:, i], c=colors[i], lw=2)
95+
axs1[0, i].plot(predicted_timeseries[:, i], c='r', ls='--', lw=2)
96+
axs1[0, i].set_xlabel('Time')
97+
axs1[0, i].set_ylabel('Signal intensity', labelpad=0)
98+
# plotting image below the time series
99+
roi_img = plotting.plot_stat_map(
100+
z_map, cut_coords=[coords[i][2]], threshold=3.1, figure=fig1,
101+
axes=axs1[1, i], display_mode='z', colorbar=False, bg_img=mean_img)
102+
roi_img.add_markers([coords[i]], colors[i], 300)
103+
fig1.set_size_inches(24, 14)
104+
105+
106+
#########################################################################
107+
# Get residuals
108+
# -------------
109+
resid = masker.fit_transform(fmri_glm.residuals[0])
110+
111+
112+
#########################################################################
113+
# Plot distribution of residuals
114+
# ------------------------------
115+
# Note that residuals are not really distributed normally.
116+
fig2, axs2 = plt.subplots(2, 3)
117+
axs2 = axs2.flatten()
118+
for i in range(0, 6):
119+
axs2[i].set_title('Cluster peak {}\n'.format(coords[i]))
120+
axs2[i].hist(resid[:, i], color=colors[i])
121+
print('Mean residuals: {}'.format(resid[:, i].mean()))
122+
123+
fig2.set_size_inches(12, 7)
124+
fig2.tight_layout()
125+
126+
127+
#########################################################################
128+
# Plot R-squared
129+
# --------------
130+
# Because we stored the residuals, we can plot the R-squared: the proportion
131+
# of explained variance of the GLM as a whole. Note that the R-squared is markedly
132+
# lower deep down the brain, where there is more physiological noise and we
133+
# are further away from the receive coils. However, R-Squared should be interpreted
134+
# with a grain of salt. The R-squared value will necessarily increase with
135+
# the addition of more factors (such as rest, active, drift, motion) into the GLM.
136+
# Additionally, we are looking at the overall fit of the model, so we are
137+
# unable to say whether a voxel/region has a large R-squared value because
138+
# the voxel/region is responsive to the experiment (such as active or rest)
139+
# or because the voxel/region fits the noise factors (such as drift or motion)
140+
# that could be present in the GLM. To isolate the influence of the experiment,
141+
# we can use an F-test as shown in the next section.
142+
plotting.plot_stat_map(fmri_glm.r_square[0],
143+
bg_img=mean_img, threshold=.1, display_mode='z', cut_coords=7)
144+
145+
146+
#########################################################################
147+
# Calculate and Plot F-test
148+
# -------------------------
149+
# The F-test tells you how well the GLM fits effects of interest such as
150+
# the active and rest conditions together. This is different from R-squared,
151+
# which tells you how well the overall GLM fits the data, including active, rest
152+
# and all the other columns in the design matrix such as drift and motion.
153+
import numpy as np
154+
155+
design_matrix = fmri_glm.design_matrices_[0]
156+
157+
# contrast with a one for "active" and zero everywhere else
158+
active = np.array([1 if c == 'active' else 0 for c in design_matrix.columns])
159+
160+
# contrast with a one for "rest" and zero everywhere else
161+
rest = np.array([1 if c == 'rest' else 0 for c in design_matrix.columns])
162+
163+
effects_of_interest = np.vstack((active, rest))
164+
# f-test for rest and activity
165+
z_map_ftest = fmri_glm.compute_contrast(
166+
effects_of_interest,
167+
stat_type='F',
168+
output_type='z_score')
169+
170+
plotting.plot_stat_map(z_map_ftest,
171+
bg_img=mean_img, threshold=3.1, display_mode='z', cut_coords=7)

nistats/first_level_model.py

+97-4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import numpy as np
1919
import pandas as pd
2020
from nibabel import Nifti1Image
21+
from nibabel.onetime import setattr_on_read
2122

2223
from sklearn.base import (BaseEstimator,
2324
clone,
@@ -36,6 +37,7 @@
3637
from .regression import (ARModel,
3738
OLSModel,
3839
SimpleRegressionResults,
40+
RegressionResults
3941
)
4042
from .utils import (_basestring,
4143
_check_run_tables,
@@ -117,14 +119,14 @@ def run_glm(Y, X, noise_model='ar1', bins=100, n_jobs=1, verbose=0):
117119
acceptable_noise_models = ['ar1', 'ols']
118120
if noise_model not in acceptable_noise_models:
119121
raise ValueError(
120-
"Acceptable noise models are {0}. You provided 'noise_model={1}'".\
121-
format(acceptable_noise_models, noise_model))
122+
"Acceptable noise models are {0}. You provided 'noise_model={1}'".
123+
format(acceptable_noise_models, noise_model))
122124

123125
if Y.shape[0] != X.shape[0]:
124126
raise ValueError(
125127
'The number of rows of Y should match the number of rows of X.'
126-
' You provided X with shape {0} and Y with shape {1}'.\
127-
format(X.shape, Y.shape))
128+
' You provided X with shape {0} and Y with shape {1}'.
129+
format(X.shape, Y.shape))
128130

129131
# Create the model
130132
ols_result = OLSModel(X).fit(Y)
@@ -309,6 +311,7 @@ def __init__(self, t_r=None, slice_time_ref=0., hrf_model='glover',
309311
else:
310312
raise ValueError('signal_scaling must be "False", "0", "1"'
311313
' or "(0, 1)"')
314+
312315
self.noise_model = noise_model
313316
self.verbose = verbose
314317
self.n_jobs = n_jobs
@@ -583,6 +586,96 @@ def compute_contrast(self, contrast_def, stat_type=None,
583586

584587
return outputs if output_type == 'all' else output
585588

589+
def _get_voxelwise_model_attribute(self, attribute, result_as_time_series):
590+
"""Transform RegressionResults instances within a dictionary
591+
(whose keys represent the autoregressive coefficient under the 'ar1'
592+
noise model or only 0.0 under 'ols' noise_model and values are the
593+
RegressionResults instances) into input nifti space.
594+
595+
Parameters
596+
----------
597+
attribute : str
598+
an attribute of a RegressionResults instance.
599+
possible values include: resid, norm_resid, predicted,
600+
SSE, r_square, MSE.
601+
result_as_time_series : bool
602+
whether the RegressionResult attribute has a value
603+
per timepoint of the input nifti image.
604+
605+
Returns
606+
-------
607+
output : list
608+
a list of Nifti1Image(s)
609+
"""
610+
# check if valid attribute is being accessed.
611+
all_attributes = dict(vars(RegressionResults)).keys()
612+
possible_attributes = [prop for prop in all_attributes if '__' not in prop]
613+
if attribute not in possible_attributes:
614+
msg = "attribute must be one of: {attr}".format(attr=possible_attributes)
615+
raise ValueError(msg)
616+
617+
if self.minimize_memory:
618+
raise ValueError('To access voxelwise attributes like R-squared, residuals, '
619+
'and predictions, the `FirstLevelModel`-object needs to store '
620+
'there attributes. To do so, set `minimize_memory` to `False` '
621+
'when initializing the `FirstLevelModel`-object.')
622+
623+
if self.labels_ is None or self.results_ is None:
624+
raise ValueError('The model has not been fit yet')
625+
626+
output = []
627+
628+
for design_matrix, labels, results in zip(self.design_matrices_, self.labels_, self.results_):
629+
630+
if result_as_time_series:
631+
voxelwise_attribute = np.zeros((design_matrix.shape[0], len(labels)))
632+
else:
633+
voxelwise_attribute = np.zeros((1, len(labels)))
634+
635+
for label_ in results:
636+
label_mask = labels == label_
637+
voxelwise_attribute[:, label_mask] = getattr(results[label_], attribute)
638+
639+
output.append(self.masker_.inverse_transform(voxelwise_attribute))
640+
641+
return output
642+
643+
@setattr_on_read
644+
def residuals(self):
645+
"""Transform voxelwise residuals to the same shape
646+
as the input Nifti1Image(s)
647+
648+
Returns
649+
-------
650+
output : list
651+
a list of Nifti1Image(s)
652+
"""
653+
return self._get_voxelwise_model_attribute('resid', result_as_time_series=True)
654+
655+
@setattr_on_read
656+
def predicted(self):
657+
"""Transform voxelwise predicted values to the same shape
658+
as the input Nifti1Image(s)
659+
660+
Returns
661+
-------
662+
output : list
663+
a list of Nifti1Image(s)
664+
"""
665+
return self._get_voxelwise_model_attribute('predicted', result_as_time_series=True)
666+
667+
@setattr_on_read
668+
def r_square(self):
669+
"""Transform voxelwise r-squared values to the same shape
670+
as the input Nifti1Image(s)
671+
672+
Returns
673+
-------
674+
output : list
675+
a list of Nifti1Image(s)
676+
"""
677+
return self._get_voxelwise_model_attribute('r_square', result_as_time_series=False)
678+
586679

587680
@replace_parameters({'mask': 'mask_img'}, end_version='next')
588681
def first_level_models_from_bids(

nistats/regression.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ def __init__(self, theta, Y, model, wY, wresid, cov=None, dispersion=1.,
276276
dispersion, nuisance)
277277
self.wY = wY
278278
self.wresid = wresid
279+
self.wdesign = model.wdesign
279280

280281
@setattr_on_read
281282
def resid(self):
@@ -310,7 +311,7 @@ def predicted(self):
310311
"""
311312
beta = self.theta
312313
# the LikelihoodModelResults has parameters named 'theta'
313-
X = self.model.design
314+
X = self.wdesign
314315
return np.dot(X, beta)
315316

316317
@setattr_on_read
@@ -319,6 +320,13 @@ def SSE(self):
319320
"""
320321
return (self.wresid ** 2).sum(0)
321322

323+
@setattr_on_read
324+
def r_square(self):
325+
"""Proportion of explained variance.
326+
If not from an OLS model this is "pseudo"-R2.
327+
"""
328+
return np.var(self.predicted, 0) / np.var(self.wY, 0)
329+
322330
@setattr_on_read
323331
def MSE(self):
324332
""" Mean square (error) """

0 commit comments

Comments
 (0)