def _demean_df(df, y='cv_loss', extra_cols=[]):
    """demean a column of the dataframe

    Calculate the mean of `y` across the values in the 'subject' and
    'loss_func' columns, then demean `y` and return df with several new
    columns:
    - `demeaned_{y}`: each y with `{y}_mean` subtracted off
    - `{y}_mean`: the average of y per subject per loss_func
    - `{y}_mean_overall`: the average of `{y}_mean` per loss_func
    - `remeaned_{y}`: the `demeaned_{y}` with `{y}_mean_overall` added
      back to it

    If you use this with the defaults, the overall goal of this is to
    enable us to look at how the cv_loss varies across models, because
    the biggest effect is the difference in cv_loss across
    subjects. Demeaning the cv_loss on a subject-by-subject basis
    enables us to put all the subjects together so we can look for
    patterns across models. For example, we can then compute error bars
    that only capture the variation across models, but not across
    subjects. Both remeaned or demeaned will capture this, the question
    is what values to have on the y-axis. If you use demeaned, you'll
    have negative loss, which might be confusing. If you use remeaned,
    the y-axis values will be the average across subjects, which might
    be easier to interpret.

    Parameters
    ----------
    df : pd.DataFrame
        dataframe to demean
    y : str, optional
        the column to demean
    extra_cols : list, optionla
        list of columns to de/remean using the mean from `y`. for
        example, you might want to de/remean the noise_ceiling using the
        mean from the cross-validation loss

    Returns
    -------
    df : pd.DataFrame
        dataframe with new, demeaned column

    """
    gb_cols = ['subject', 'loss_func']
    df = df.set_index(gb_cols)
    y_mean = df.groupby(gb_cols)[y].mean()
    df[f'{y}_mean'] = y_mean
    # here we take the average over the averages. we do this so that we weight
    # all of the groups the same. For example, if gb_cols=['subject'] and one
    # subject had twice as many rows (because it had two sessions in df, for
    # example), then this ensures that subject isn't twice as important when
    # computing the mean (which would be the case if we used
    # df[f'{y}_mean'].mean() instead). We do, however, want to do this
    # separately for each loss function, since they'll probably have different
    # means
    df = df.reset_index()
    df = df.set_index('loss_func')
    df[f'{y}_mean_overall'] = y_mean.reset_index().groupby('loss_func')[y].mean()
    df[f'demeaned_{y}'] = df[y] - df[f'{y}_mean']
    df[f'remeaned_{y}'] = df[f'demeaned_{y}'] + df[f'{y}_mean_overall']
    for col in extra_cols:
        df[f'demeaned_{col}'] = df[col] - df[f'{y}_mean']
        df[f'remeaned_{col}'] = df[f'demeaned_{col}'] + df[f'{y}_mean_overall']
    return df.reset_index()