mne-tools / mne-tools/mne-python

ConfoundRegressor with GeneralizingEstimator

Open
#8,566 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
3.5k
Forks
1.6k
Avg merge
1d 6h
Merged PRs (30d)
100

Description

Hello there!

I would like to check whether, once the influence of some confound variables is ruled out, my decoding still works (EEG data).
To do so, I thought to try the cross-validated "confound regression" described in this paper.

I've made a simple and naive adaptation of the code provided with this paper to use it inside MNE sliders:

# Giulia Gennari November 2020
"""
Adaptation of the confounds module retrievable at https://github.com/lukassnoek/MVCA/blob/master/analyses/confounds.py
which contains code to handle/account for confounds in pattern analyses.
The main change relative to the original code consists in providing separate train
and test confound vectors at inizialization while avoiding to specify X. This should enable the
employment of ConfoundRegressor inside MNE sliders 
""" 

import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin


class ConfoundRegressorG(BaseEstimator, TransformerMixin):
    """ Fits a confound onto each feature in X and returns their residuals."""

    def __init__(self, confound_train, confound_test, skip_cross_val=False, stack_intercept=True):
        """ Regresses out a variable (confound) from each feature in X.
        Parameters
        ----------
        confound_train, confound_test: numpy arrays
            Array of length (n_samples, n_confounds) to regress out of each
            feature. May have multiple columns for multiple confounds.
        skip_cross_val : bool
            If True do not transform the test set 
        stack_intercept : bool
            Whether to stack an intercept to the confound (default is True in original module)

        Attributes
        ----------
        weights_ : numpy array
            Array with weights for the confound(s).
        """

        self.confound_train = confound_train
        self.confound_test = confound_test
        self.skip_cross_val = skip_cross_val
        self.stack_intercept = stack_intercept
        self.weights_ = None

    def fit(self, X, y=None):
        """ Fits the confound-regressor to X.
        Parameters
        ----------
        X : numpy array
            An array of shape (n_samples, n_features)
        y : None
            Included for compatibility; does nothing.
        """

        if self.stack_intercept:
            icept = np.ones(self.confound_train.shape[0])
            self.confound_train = np.c_[icept, self.confound_train]
            
            icept_bis = np.ones(self.confound_test.shape[0])
            self.confound_test = np.c_[icept_bis, self.confound_test]

        confound_fit = self.confound_train

        # Vectorized implementation estimating weights for all features
        self.weights_ = np.linalg.lstsq(confound_fit, X, rcond=None)[0]
        return self

    def transform(self, X):
        """ Regresses out confound from X.
        Parameters
        ----------
        X : numpy array
            An array of shape (n_samples, n_features)
        Returns
        -------
        X_new : ndarray
            ndarray with confound-regressed features
        """
        
        # no transformation when skip_cross_val is True and tranform is called upon 
        # test data (otherwise select the proper confounds and proceed)
        
        if self.confound_test.shape[-1] != self.confound_train.shape[-1]:
            icept_bis = np.ones(self.confound_test.shape[0])
            self.confound_test = np.c_[icept_bis, self.confound_test]
        
        if self.skip_cross_val and len(X)== len(self.confound_test):
            X_corr = X
        else:
            if len(X)== len(self.confound_train):
                confound_transform = self.confound_train
            if len(X)== len(self.confound_test):
                confound_transform = self.confound_test

            X_corr = X - confound_transform.dot(self.weights_)
            
        return X_corr

This works perfectly with SlidingEstimator. However, with GeneralizingEstimatos I found a behavior that needs to be explained to me (please!). Namely: when I call .score everything seems to work, BUT when I call either .predict/ .predict_proba or .decision_function it doesn't. Turns out that X_test.shape in the latter cases becomes (n_epochs*n_time_points, n_features) at some point.
My one-million question at the moment: how come no error comes up with .score ??
I thought that e.g. .predict_proba is called internally with .score and thus I would expect an error in this case as well.

By saving the lines above as confound_regressor_customized.py you can reproduce everything with the following:

import os
import numpy as np
import pandas as pd
import mne
from sklearn.model_selection import train_test_split
from confound_regressor_customized import ConfoundRegressorG
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from mne.decoding import GeneralizingEstimator

kiloword_data_folder = mne.datasets.kiloword.data_path()
kiloword_data_file = os.path.join(kiloword_data_folder,'kword_metadata-epo.fif')
epochs = mne.read_epochs(kiloword_data_file)
epochs.crop(0.,.076)

train_indx, test_indx = train_test_split(range(len(epochs)))
epochs_train, epochs_test = epochs[train_indx], epochs[test_indx]

y_train = epochs_train.metadata['NumberOfLetters'].to_numpy(dtype=int)
y_test = epochs_test.metadata['NumberOfLetters'].to_numpy(dtype=int)

c_train = epochs_train.metadata[['ConsonantVowelProportion', 'VisualComplexity']].to_numpy()
c_test = epochs_test.metadata[['ConsonantVowelProportion', 'VisualComplexity']].to_numpy()

cfr = ConfoundRegressorG(confound_train=c_train, confound_test=c_test)
pipeline = make_pipeline(cfr, StandardScaler(), LogisticRegression(max_iter=1000))
pip_gen = GeneralizingEstimator(pipeline, scoring='roc_auc_ovr_weighted')

pip_gen.fit(epochs_train.get_data(), y=y_train)
scores_gen =  pip_gen.score(epochs_test.get_data(), y=y_test)

# the error arrives here! (same for .predict or .decision_function) 
probas_gen =  pip_gen.predict_proba(epochs_test.get_data())

Thank you very much for looking at this issue!!!!

Giulia

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Reproduce the behavior with confound_regressor_customized.py and the provided GeneralizingEstimator pipeline. Read the GeneralizingEstimator score, predict_proba, predict, and decision_function entry points, then trace the input shapes passed to the pipeline. Done means explaining why score differs from the other methods and documenting or correcting the inconsistent behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, scikit-learn
Domain
machine-learning
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
32/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.