Aluode/PerceptionLabPortable
0
1# Authors: The MNE-Python contributors.2# License: BSD-3-Clause3# Copyright the MNE-Python contributors.4 5import numbers6 7import numpy as np8from scipy.stats import pearsonr9from sklearn.base import (10 BaseEstimator,11 MetaEstimatorMixin,12 clone,13 is_regressor,14)15from sklearn.exceptions import NotFittedError16from sklearn.metrics import r2_score17 18from ..utils import _validate_type, fill_doc, pinv19from ._fixes import _check_n_features_3d, validate_data20from .base import _check_estimator, get_coef21from .time_delaying_ridge import TimeDelayingRidge22 23 24@fill_doc25class ReceptiveField(MetaEstimatorMixin, BaseEstimator):26 """Fit a receptive field model.27 28 This allows you to fit an encoding model (stimulus to brain) or a decoding29 model (brain to stimulus) using time-lagged input features (for example, a30 spectro- or spatio-temporal receptive field, or STRF)31 :footcite:`TheunissenEtAl2001,WillmoreSmyth2003,CrosseEtAl2016,HoldgrafEtAl2016`.32 33 Parameters34 ----------35 tmin : float36 The starting lag, in seconds (or samples if ``sfreq`` == 1).37 tmax : float38 The ending lag, in seconds (or samples if ``sfreq`` == 1).39 Must be >= tmin.40 sfreq : float41 The sampling frequency used to convert times into samples.42 feature_names : array, shape (n_features,) | None43 Names for input features to the model. If None, feature names will44 be auto-generated from the shape of input data after running `fit`.45 estimator : instance of sklearn.base.BaseEstimator | float | None46 The model used in fitting inputs and outputs. This can be any47 scikit-learn-style model that contains a fit and predict method. If a48 float is passed, it will be interpreted as the ``alpha`` parameter49 to be passed to a Ridge regression model. If `None`, then a Ridge50 regression model with an alpha of 0 will be used.51 fit_intercept : bool | None52 If True (default), the sample mean is removed before fitting.53 If ``estimator`` is a :class:`sklearn.base.BaseEstimator`,54 this must be None or match ``estimator.fit_intercept``.55 scoring : ['r2', 'corrcoef']56 Defines how predictions will be scored. Currently must be one of57 'r2' (coefficient of determination) or 'corrcoef' (the correlation58 coefficient).59 patterns : bool60 If True, inverse coefficients will be computed upon fitting using the61 covariance matrix of the inputs, and the cross-covariance of the62 inputs/outputs, according to :footcite:`HaufeEtAl2014`. Defaults to63 False.64 n_jobs : int | str65 Number of jobs to run in parallel. Can be 'cuda' if CuPy66 is installed properly and ``estimator is None``.67 68 .. versionadded:: 0.1869 edge_correction : bool70 If True (default), correct the autocorrelation coefficients for71 non-zero delays for the fact that fewer samples are available.72 Disabling this speeds up performance at the cost of accuracy73 depending on the relationship between epoch length and model74 duration. Only used if ``estimator`` is float or None.75 76 .. versionadded:: 0.1877 78 Attributes79 ----------80 coef_ : array, shape ([n_outputs, ]n_features, n_delays)81 The coefficients from the model fit, reshaped for easy visualization.82 During :meth:`mne.decoding.ReceptiveField.fit`, if ``y`` has one83 dimension (time), the ``n_outputs`` dimension here is omitted.84 patterns_ : array, shape ([n_outputs, ]n_features, n_delays)85 If fit, the inverted coefficients from the model.86 delays_ : array, shape (n_delays,), dtype int87 The delays used to fit the model, in indices. To return the delays88 in seconds, use ``self.delays_ / self.sfreq``89 valid_samples_ : slice90 The rows to keep during model fitting after removing rows with91 missing values due to time delaying. This can be used to get an92 output equivalent to using :func:`numpy.convolve` or93 :func:`numpy.correlate` with ``mode='valid'``.94 95 See Also96 --------97 mne.decoding.TimeDelayingRidge98 99 Notes100 -----101 For a causal system, the encoding model will have significant102 non-zero values only at positive lags. In other words, lags point103 backward in time relative to the input, so positive lags correspond104 to previous input time samples, while negative lags correspond to105 future input time samples.106 107 References108 ----------109 .. footbibliography::110 """ # noqa E501111 112 def __init__(113 self,114 tmin,115 tmax,116 sfreq,117 feature_names=None,118 estimator=None,119 fit_intercept=None,120 scoring="r2",121 patterns=False,122 n_jobs=None,123 edge_correction=True,124 ):125 self.tmin = tmin126 self.tmax = tmax127 self.sfreq = sfreq128 self.feature_names = feature_names129 self.estimator = estimator130 self.fit_intercept = fit_intercept131 self.scoring = scoring132 self.patterns = patterns133 self.n_jobs = n_jobs134 self.edge_correction = edge_correction135 136 def __repr__(self): # noqa: D105137 s = f"tmin, tmax : ({self.tmin:.3f}, {self.tmax:.3f}), "138 estimator = self.estimator139 if not isinstance(estimator, str):140 estimator = type(self.estimator)141 s += f"estimator : {estimator}, "142 if hasattr(self, "coef_"):143 if self.feature_names is not None:144 feats = self.feature_names145 if len(feats) == 1:146 s += f"feature: {feats[0]}, "147 else:148 s += f"features : [{feats[0]}, ..., {feats[-1]}], "149 s += "fit: True"150 else:151 s += "fit: False"152 if hasattr(self, "scores_"):153 s += f"scored ({self.scoring})"154 return f"<ReceptiveField | {s}>"155 156 def __sklearn_tags__(self):157 """..."""158 from sklearn.utils import RegressorTags159 160 tags = super().__sklearn_tags__()161 tags.estimator_type = "regressor"162 tags.regressor_tags = RegressorTags()163 tags.input_tags.three_d_array = True164 tags.target_tags.one_d_labels = True165 tags.target_tags.multi_output = True166 tags.target_tags.required = True167 return tags168 169 def _delay_and_reshape(self, X, y=None):170 """Delay and reshape the variables."""171 if not isinstance(self.estimator_, TimeDelayingRidge):172 # X is now shape (n_times, n_epochs, n_feats, n_delays)173 X = _delay_time_series(174 X,175 self.tmin,176 self.tmax,177 self.sfreq_,178 fill_mean=self.fit_intercept_,179 )180 X = _reshape_for_est(X)181 # Concat times + epochs182 if y is not None:183 y = y.reshape(-1, y.shape[-1], order="F")184 return X, y185 186 def _check_data(self, X, y=None, reset=False):187 if reset:188 X, y = validate_data(189 self,190 X=X,191 y=y,192 reset=reset,193 validate_separately=( # to take care of 3D y194 dict(allow_nd=True, ensure_2d=False),195 dict(allow_nd=True, ensure_2d=False),196 ),197 )198 else:199 X = validate_data(self, X=X, allow_nd=True, ensure_2d=False, reset=reset)200 _check_n_features_3d(self, X, reset)201 return X, y202 203 def _validate_params(self, X):204 if self.scoring not in _SCORERS.keys():205 raise ValueError(206 f"scoring must be one of {sorted(_SCORERS.keys())}, got {self.scoring}"207 )208 self.sfreq_ = float(self.sfreq)209 if self.tmin > self.tmax:210 raise ValueError(f"tmin ({self.tmin}) must be at most tmax ({self.tmax})")211 212 def fit(self, X, y):213 """Fit a receptive field model.214 215 Parameters216 ----------217 X : array, shape (n_times[, n_epochs], n_features)218 The input features for the model.219 y : array, shape (n_times[, n_epochs][, n_outputs])220 The output features for the model.221 222 Returns223 -------224 self : instance225 The instance so you can chain operations.226 """227 X, y = self._check_data(X, y, reset=True)228 self._validate_params(X)229 X, y, _, self._y_dim = self._check_dimensions(X, y)230 231 # Initialize delays232 self.delays_ = _times_to_delays(self.tmin, self.tmax, self.sfreq_)233 234 # Define the slice that we should use in the middle235 self.valid_samples_ = _delays_to_slice(self.delays_)236 237 if self.estimator is None or isinstance(self.estimator, numbers.Real):238 alpha = self.estimator if self.estimator is not None else 0.0239 if self.fit_intercept is None:240 self.fit_intercept_ = True241 else:242 self.fit_intercept_ = self.fit_intercept243 estimator = TimeDelayingRidge(244 self.tmin,245 self.tmax,246 self.sfreq_,247 alpha=alpha,248 fit_intercept=self.fit_intercept_,249 n_jobs=self.n_jobs,250 edge_correction=self.edge_correction,251 )252 elif is_regressor(self.estimator):253 estimator = clone(self.estimator)254 if (255 self.fit_intercept is not None256 and estimator.fit_intercept != self.fit_intercept257 ):258 raise ValueError(259 f"Estimator fit_intercept ({estimator.fit_intercept}) != "260 f"initialization fit_intercept ({self.fit_intercept}), initialize "261 "ReceptiveField with the same fit_intercept value or use "262 "fit_intercept=None"263 )264 self.fit_intercept_ = estimator.fit_intercept265 else:266 raise ValueError(267 "`estimator` must be a float or an instance of `BaseEstimator`, got "268 f"type {self.estimator}."269 )270 self.estimator_ = estimator271 del estimator272 _check_estimator(self.estimator_)273 274 # Create input features275 n_times, n_epochs, n_feats = X.shape276 n_outputs = y.shape[-1]277 n_delays = len(self.delays_)278 279 # Update feature names if we have none280 if (self.feature_names is not None) and (len(self.feature_names) != n_feats):281 raise ValueError(282 f"n_features in X does not match feature names ({n_feats} != "283 f"{len(self.feature_names)})"284 )285 286 # Create input features287 X, y = self._delay_and_reshape(X, y)288 289 self.estimator_.fit(X, y)290 coef = get_coef(self.estimator_, "coef_") # (n_targets, n_features)291 shape = [n_feats, n_delays]292 if self._y_dim > 1:293 shape.insert(0, -1)294 self.coef_ = coef.reshape(shape)295 296 # Inverse-transform model weights297 if self.patterns:298 n_total_samples = n_times * n_epochs299 if n_total_samples < 2:300 raise ValueError(301 "Cannot compute patterns with only one sample; "302 f"got n_samples = {n_total_samples}."303 )304 if isinstance(self.estimator_, TimeDelayingRidge):305 cov_ = self.estimator_.cov_ / float(n_times * n_epochs - 1)306 y = y.reshape(-1, y.shape[-1], order="F")307 else:308 X = X - X.mean(0, keepdims=True)309 cov_ = np.cov(X.T)310 del X311 312 # Inverse output covariance313 if y.ndim == 2 and y.shape[1] != 1:314 y = y - y.mean(0, keepdims=True)315 inv_Y = pinv(np.cov(y.T))316 else:317 inv_Y = 1.0 / float(n_times * n_epochs - 1)318 del y319 320 # Inverse coef according to Haufe's method321 # patterns has shape (n_feats * n_delays, n_outputs)322 coef = np.reshape(self.coef_, (n_feats * n_delays, n_outputs))323 patterns = cov_.dot(coef.dot(inv_Y))324 self.patterns_ = patterns.reshape(shape)325 326 return self327 328 def predict(self, X):329 """Generate predictions with a receptive field.330 331 Parameters332 ----------333 X : array, shape (n_times[, n_epochs], n_channels)334 The input features for the model.335 336 Returns337 -------338 y_pred : array, shape (n_times[, n_epochs][, n_outputs])339 The output predictions. "Note that valid samples (those340 unaffected by edge artifacts during the time delaying step) can341 be obtained using ``y_pred[rf.valid_samples_]``.342 """343 if not hasattr(self, "delays_"):344 raise NotFittedError("Estimator has not been fit yet.")345 346 X, _ = self._check_data(X)347 X, _, X_dim = self._check_dimensions(X, None, predict=True)[:3]348 349 del _350 # convert to sklearn and back351 pred_shape = X.shape[:-1]352 if self._y_dim > 1:353 pred_shape = pred_shape + (self.coef_.shape[0],)354 X, _ = self._delay_and_reshape(X)355 y_pred = self.estimator_.predict(X)356 y_pred = y_pred.reshape(pred_shape, order="F")357 shape = list(y_pred.shape)358 if X_dim <= 2:359 shape.pop(1) # epochs360 extra = 0361 else:362 extra = 1363 shape = shape[: self._y_dim + extra]364 y_pred.shape = shape365 return y_pred366 367 def score(self, X, y):368 """Score predictions generated with a receptive field.369 370 This calls ``self.predict``, then masks the output of this371 and ``y` with ``self.valid_samples_``. Finally, it passes372 this to a :mod:`sklearn.metrics` scorer.373 374 Parameters375 ----------376 X : array, shape (n_times[, n_epochs], n_channels)377 The input features for the model.378 y : array, shape (n_times[, n_epochs][, n_outputs])379 Used for scikit-learn compatibility.380 381 Returns382 -------383 scores : list of float, shape (n_outputs,)384 The scores estimated by the model for each output (e.g. mean385 R2 of ``predict(X)``).386 """387 # Create our scoring object388 scorer_ = _SCORERS[self.scoring]389 390 # Generate predictions, then reshape so we can mask time391 X, y = self._check_dimensions(X, y, predict=True)[:2]392 n_times, n_epochs, n_outputs = y.shape393 y_pred = self.predict(X)394 y_pred = y_pred[self.valid_samples_]395 y = y[self.valid_samples_]396 397 # Re-vectorize and call scorer398 y = y.reshape([-1, n_outputs], order="F")399 y_pred = y_pred.reshape([-1, n_outputs], order="F")400 assert y.shape == y_pred.shape401 scores = scorer_(y, y_pred, multioutput="raw_values")402 return scores403 404 def _check_dimensions(self, X, y, predict=False):405 _validate_type(X, "array-like", "X")406 _validate_type(y, ("array-like", None), "y")407 X_dim = X.ndim408 y_dim = y.ndim if y is not None else 0409 if X_dim == 2:410 # Ensure we have a 3D input by adding singleton epochs dimension411 X = X[:, np.newaxis, :]412 if y is not None:413 if y_dim == 1:414 y = y[:, np.newaxis, np.newaxis] # epochs, outputs415 elif y_dim == 2:416 y = y[:, np.newaxis, :] # epochs417 else:418 raise ValueError(419 "y must be shape (n_times[, n_epochs][,n_outputs], got "420 f"{y.shape}"421 )422 elif X.ndim == 3:423 if y is not None:424 if y.ndim == 2:425 y = y[:, :, np.newaxis] # Add an outputs dim426 elif y.ndim != 3:427 raise ValueError(428 "If X has 3 dimensions, y must have 2 or 3 dimensions"429 )430 else:431 raise ValueError(432 "X must be shape (n_times[, n_epochs], n_features), "433 f"got {X.shape}. Reshape your data to 2D or 3D "434 "(e.g., array.reshape(-1, 1) for a single feature, "435 "or array.reshape(1, -1) for a single sample)."436 )437 if y is not None:438 if X.shape[0] != y.shape[0]:439 raise ValueError(440 f"X and y do not have the same n_times\n{X.shape[0]} != "441 f"{y.shape[0]}"442 )443 if X.shape[1] != y.shape[1]:444 raise ValueError(445 f"X and y do not have the same n_epochs\n{X.shape[1]} != "446 f"{y.shape[1]}"447 )448 if predict and y.shape[-1] not in (len(self.estimator_.coef_), 1):449 raise ValueError(450 "Number of outputs does not match estimator coefficients dimensions"451 )452 return X, y, X_dim, y_dim453 454 455def _delay_time_series(X, tmin, tmax, sfreq, fill_mean=False):456 """Return a time-lagged input time series.457 458 Parameters459 ----------460 X : array, shape (n_times[, n_epochs], n_features)461 The time series to delay. Must be 2D or 3D.462 tmin : int | float463 The starting lag.464 tmax : int | float465 The ending lag.466 Must be >= tmin.467 sfreq : int | float468 The sampling frequency of the series. Defaults to 1.0.469 fill_mean : bool470 If True, the fill value will be the mean along the time dimension471 of the feature, and each cropped and delayed segment of data472 will be shifted to have the same mean value (ensuring that mean473 subtraction works properly). If False, the fill value will be zero.474 475 Returns476 -------477 delayed : array, shape(n_times[, n_epochs][, n_features], n_delays)478 The delayed data. It has the same shape as X, with an extra dimension479 appended to the end.480 481 Examples482 --------483 >>> tmin, tmax = -0.1, 0.2484 >>> sfreq = 10.485 >>> x = np.arange(1, 6)486 >>> x_del = _delay_time_series(x, tmin, tmax, sfreq)487 >>> print(x_del) # doctest:+SKIP488 [[2. 1. 0. 0.]489 [3. 2. 1. 0.]490 [4. 3. 2. 1.]491 [5. 4. 3. 2.]492 [0. 5. 4. 3.]]493 """494 _check_delayer_params(tmin, tmax, sfreq)495 delays = _times_to_delays(tmin, tmax, sfreq)496 # Iterate through indices and append497 delayed = np.zeros(X.shape + (len(delays),))498 if fill_mean:499 mean_value = X.mean(axis=0)500 if X.ndim == 3:501 mean_value = np.mean(mean_value, axis=0)502 delayed[:] = mean_value[:, np.newaxis]503 for ii, ix_delay in enumerate(delays):504 # Create zeros to populate w/ delays505 if ix_delay < 0:506 out = delayed[:ix_delay, ..., ii]507 use_X = X[-ix_delay:]508 elif ix_delay > 0:509 out = delayed[ix_delay:, ..., ii]510 use_X = X[:-ix_delay]511 else: # == 0512 out = delayed[..., ii]513 use_X = X514 out[:] = use_X515 if fill_mean:516 out[:] += mean_value - use_X.mean(axis=0)517 return delayed518 519 520def _times_to_delays(tmin, tmax, sfreq):521 """Convert a tmin/tmax in seconds to delays."""522 # Convert seconds to samples523 delays = np.arange(int(np.round(tmin * sfreq)), int(np.round(tmax * sfreq) + 1))524 return delays525 526 527def _delays_to_slice(delays):528 """Find the slice to be taken in order to remove missing values."""529 # Negative values == cut off rows at the end530 min_delay = None if delays[-1] <= 0 else delays[-1]531 # Positive values == cut off rows at the end532 max_delay = None if delays[0] >= 0 else delays[0]533 return slice(min_delay, max_delay)534 535 536def _check_delayer_params(tmin, tmax, sfreq):537 """Check delayer input parameters. For future custom delay support."""538 _validate_type(sfreq, "numeric", "`sfreq`")539 540 for tlim in (tmin, tmax):541 _validate_type(tlim, "numeric", "tmin/tmax")542 if not tmin <= tmax:543 raise ValueError("tmin must be <= tmax")544 545 546def _reshape_for_est(X_del):547 """Convert X_del to a sklearn-compatible shape."""548 n_times, n_epochs, n_feats, n_delays = X_del.shape549 X_del = X_del.reshape(n_times, n_epochs, -1) # concatenate feats550 X_del = X_del.reshape(n_times * n_epochs, -1, order="F")551 return X_del552 553 554# Create a correlation scikit-learn-style scorer555def _corr_score(y_true, y, multioutput=None):556 assert multioutput == "raw_values"557 for this_y in (y_true, y):558 if this_y.ndim != 2:559 raise ValueError(560 f"inputs must be shape (samples, outputs), got {this_y.shape}"561 )562 return np.array([pearsonr(y_true[:, ii], y[:, ii])[0] for ii in range(y.shape[-1])])563 564 565def _r2_score(y_true, y, multioutput=None):566 return r2_score(y_true, y, multioutput=multioutput)567 568 569_SCORERS = {"r2": _r2_score, "corrcoef": _corr_score}570 