def plot_paired(data=None, dv=None, within=None, subject=None, order=None, boxplot=True, boxplot_in_front=False, orient='v', figsize=(4, 4), dpi=100, ax=None, colors=['green', 'grey', 'indianred'], pointplot_kwargs={'scale': .6, 'marker': '.'}, boxplot_kwargs={'color': 'lightslategrey', 'width': .2}): """ Paired plot. Parameters ---------- data : :py:class:`pandas.DataFrame` Long-format dataFrame. dv : string Name of column containing the dependent variable. within : string Name of column containing the within-subject factor. Note that ``within`` must have exactly two within-subject levels (= two unique values). subject : string Name of column containing the subject identifier. order : list of str List of values in ``within`` that define the order of elements on the x-axis of the plot. If None, uses alphabetical order. boxplot : boolean If True, add a boxplot to the paired lines using the :py:func:`seaborn.boxplot` function. boxplot_in_front : boolean If True, the boxplot is plotted on the foreground (i.e. above the individual lines) and with a slight transparency. This makes the overall plot more readable when plotting a large numbers of subjects. .. versionadded:: 0.3.8 orient : string Plot the boxplots vertically and the subjects on the x-axis if ``orient='v'`` (default). Set to ``orient='h'`` to rotate the plot by by 90 degrees. .. versionadded:: 0.3.9 figsize : tuple Figsize in inches dpi : int Resolution of the figure in dots per inches. ax : matplotlib axes Axis on which to draw the plot. colors : list of str Line colors names. Default is green when value increases from A to B, indianred when value decreases from A to B and grey when the value is the same in both measurements. pointplot_kwargs : dict Dictionnary of optional arguments that are passed to the :py:func:`seaborn.pointplot` function. boxplot_kwargs : dict Dictionnary of optional arguments that are passed to the :py:func:`seaborn.boxplot` function. Returns ------- ax : Matplotlib Axes instance Returns the Axes object with the plot for further tweaking. Notes ----- Data must be a long-format pandas DataFrame. Examples -------- Default paired plot: .. plot:: >>> import pingouin as pg >>> df = pg.read_dataset('mixed_anova').query("Time != 'January'") >>> df = df.query("Group == 'Meditation' and Subject > 40") >>> ax = pg.plot_paired(data=df, dv='Scores', within='Time', ... subject='Subject', dpi=150) Paired plot on an existing axis (no boxplot and uniform color): .. plot:: >>> import pingouin as pg >>> import matplotlib.pyplot as plt >>> df = pg.read_dataset('mixed_anova').query("Time != 'January'") >>> df = df.query("Group == 'Meditation' and Subject > 40") >>> fig, ax1 = plt.subplots(1, 1, figsize=(5, 4)) >>> pg.plot_paired(data=df, dv='Scores', within='Time', ... subject='Subject', ax=ax1, boxplot=False, ... colors=['grey', 'grey', 'grey']) # doctest: +SKIP Horizontal paired plot with three unique within-levels: .. plot:: >>> import pingouin as pg >>> import matplotlib.pyplot as plt >>> df = pg.read_dataset('mixed_anova').query("Group == 'Meditation'") >>> # df = df.query("Group == 'Meditation' and Subject > 40") >>> pg.plot_paired(data=df, dv='Scores', within='Time', subject='Subject', orient='h') # doctest: +SKIP With the boxplot on the foreground: .. plot:: >>> import pingouin as pg >>> df = pg.read_dataset('mixed_anova').query("Time != 'January'") >>> df = df.query("Group == 'Control'") >>> ax = pg.plot_paired(data=df, dv='Scores', within='Time', ... subject='Subject', boxplot_in_front=True) """ from pingouin.utils import _check_dataframe, remove_rm_na # Update default kwargs with specified inputs _pointplot_kwargs = {'scale': .6, 'marker': '.'} _pointplot_kwargs.update(pointplot_kwargs) _boxplot_kwargs = {'color': 'lightslategrey', 'width': .2} _boxplot_kwargs.update(boxplot_kwargs) # Extract pointplot alpha, if set pp_alpha = _pointplot_kwargs.pop('alpha', 1.) # Calculate size of the plot elements by scale as in Seaborn pointplot scale = _pointplot_kwargs.pop('scale') lw = plt.rcParams["lines.linewidth"] * 1.8 * scale # get the linewidth mew = lw * .75 # get the markeredgewidth markersize = np.pi * np.square(lw) * 2 # get the markersize # Set boxplot in front of Line2D plot (zorder=2 for both) and add alpha if boxplot_in_front: _boxplot_kwargs.update({ 'boxprops': {'zorder': 2}, 'whiskerprops': {'zorder': 2}, 'zorder': 2, }) # Validate args _check_dataframe(data=data, dv=dv, within=within, subject=subject, effects='within') # Remove NaN values data = remove_rm_na(dv=dv, within=within, subject=subject, data=data) # Extract within-subject level (alphabetical order) x_cat = np.unique(data[within]) if order is None: order = x_cat else: assert len(order) == len(x_cat), ( 'Order must have the same number of elements as the number ' 'of levels in `within`.' ) # Substitue within by integer order of the ordered columns to allow for # changing the order of numeric withins. data['wthn'] = data[within].replace( {_ordr: i for i, _ordr in enumerate(order)} ) order_num = range(len(order)) # Make numeric order # Start the plot if ax is None: fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi) # Set x and y depending on orientation using the num. replacement within _x = 'wthn' if orient == 'v' else dv _y = dv if orient == 'v' else 'wthn' for cat in range(len(x_cat) - 1): _order = (order_num[cat], order_num[cat + 1]) # Extract data of the current subject-combination data_now = data.loc[data['wthn'].isin(_order), [dv, 'wthn', subject]] # Select colors for all lines between the current subjects y1 = data_now.loc[data_now['wthn'] == _order[0], dv].to_numpy() y2 = data_now.loc[data_now['wthn'] == _order[1], dv].to_numpy() # Line and scatter colors depending on subject dv trend _colors = np.where( y1 < y2, colors[0], np.where(y1 > y2, colors[2], colors[1]) ) # Line and scatter colors as hue-indexed dictionary _colors = { subj: clr for subj, clr in zip(data_now[subject].unique(), _colors) } # Plot individual lines using Seaborn sns.lineplot(data=data_now, x=_x, y=_y, hue=subject, palette=_colors, ls='-', lw=lw, legend=False, ax=ax) # Plot individual markers using Seaborn sns.scatterplot(data=data_now, x=_x, y=_y, hue=subject, palette=_colors, edgecolor='face', lw=mew, sizes=[markersize] * data_now.shape[0], legend=False, ax=ax, **_pointplot_kwargs) # Set zorder and alpha of pointplot markers and lines _ = plt.setp(ax.collections, alpha=pp_alpha, zorder=2) # Set marker alpha _ = plt.setp(ax.lines, alpha=pp_alpha, zorder=2) # Set line alpha if boxplot: # Set boxplot x and y depending on orientation _xbp = within if orient == 'v' else dv _ybp = dv if orient == 'v' else within sns.boxplot(data=data, x=_xbp, y=_ybp, order=order, ax=ax, orient=orient, **_boxplot_kwargs) # Set alpha to patch of boxplot but not to whiskers for patch in ax.artists: r, g, b, a = patch.get_facecolor() patch.set_facecolor((r, g, b, .75)) else: # If no boxplot, axis needs manual styling as in Seaborn pointplot if orient == 'v': xlabel, ylabel = within, dv ax.set_xticks(np.arange(len(x_cat))) ax.set_xticklabels(order) ax.xaxis.grid(False) ax.set_xlim(-.5, len(x_cat) - .5, auto=None) else: xlabel, ylabel = dv, within ax.set_yticks(np.arange(len(x_cat))) ax.set_yticklabels(order) ax.yaxis.grid(False) ax.set_ylim(-.5, len(x_cat) - .5, auto=None) ax.invert_yaxis() ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) # Despine and trim sns.despine(trim=True, ax=ax) return ax
def plot_paired(data=None, dv=None, within=None, subject=None, order=None, boxplot=True, boxplot_in_front=False, figsize=(4, 4), dpi=100, ax=None, colors=['green', 'grey', 'indianred'], pointplot_kwargs={'scale': .6, 'markers': '.'}, boxplot_kwargs={'color': 'lightslategrey', 'width': .2}): """ Paired plot. Parameters ---------- data : :py:class:`pandas.DataFrame` Long-format dataFrame. dv : string Name of column containing the dependent variable. within : string Name of column containing the within-subject factor. Note that ``within`` must have exactly two within-subject levels (= two unique values). subject : string Name of column containing the subject identifier. order : list of str List of values in ``within`` that define the order of elements on the x-axis of the plot. If None, uses alphabetical order. boxplot : boolean If True, add a boxplot to the paired lines using the :py:func:`seaborn.boxplot` function. boxplot_in_front : boolean If True, the boxplot is plotted on the foreground (i.e. above the individual lines) and with a slight transparency. This makes the overall plot more readable when plotting a large numbers of subjects. .. versionadded:: 0.3.8 figsize : tuple Figsize in inches dpi : int Resolution of the figure in dots per inches. ax : matplotlib axes Axis on which to draw the plot. colors : list of str Line colors names. Default is green when value increases from A to B, indianred when value decreases from A to B and grey when the value is the same in both measurements. pointplot_kwargs : dict Dictionnary of optional arguments that are passed to the :py:func:`seaborn.pointplot` function. boxplot_kwargs : dict Dictionnary of optional arguments that are passed to the :py:func:`seaborn.boxplot` function. Returns ------- ax : Matplotlib Axes instance Returns the Axes object with the plot for further tweaking. Notes ----- Data must be a long-format pandas DataFrame. Examples -------- Default paired plot: .. plot:: >>> import pingouin as pg >>> df = pg.read_dataset('mixed_anova').query("Time != 'January'") >>> df = df.query("Group == 'Meditation' and Subject > 40") >>> ax = pg.plot_paired(data=df, dv='Scores', within='Time', ... subject='Subject', dpi=150) Paired plot on an existing axis (no boxplot and uniform color): .. plot:: >>> import pingouin as pg >>> import matplotlib.pyplot as plt >>> df = pg.read_dataset('mixed_anova').query("Time != 'January'") >>> df = df.query("Group == 'Meditation' and Subject > 40") >>> fig, ax1 = plt.subplots(1, 1, figsize=(5, 4)) >>> pg.plot_paired(data=df, dv='Scores', within='Time', ... subject='Subject', ax=ax1, boxplot=False, ... colors=['grey', 'grey', 'grey']) # doctest: +SKIP With the boxplot on the foreground: .. plot:: >>> import pingouin as pg >>> df = pg.read_dataset('mixed_anova').query("Time != 'January'") >>> df = df.query("Group == 'Control'") >>> ax = pg.plot_paired(data=df, dv='Scores', within='Time', ... subject='Subject', boxplot_in_front=True) """ from pingouin.utils import _check_dataframe, remove_rm_na # Update default kwargs with specified inputs _pointplot_kwargs = {'scale': .6, 'markers': '.'} _pointplot_kwargs.update(pointplot_kwargs) _boxplot_kwargs = {'color': 'lightslategrey', 'width': .2} _boxplot_kwargs.update(boxplot_kwargs) # Set boxplot in front of Line2D plot (zorder=2 for both) and add alpha if boxplot_in_front: _boxplot_kwargs.update({ 'boxprops': {'zorder': 2}, 'whiskerprops': {'zorder': 2}, 'zorder': 2, }) # Validate args _check_dataframe(data=data, dv=dv, within=within, subject=subject, effects='within') # Remove NaN values data = remove_rm_na(dv=dv, within=within, subject=subject, data=data) # Extract subjects subj = data[subject].unique() # Extract within-subject level (alphabetical order) x_cat = np.unique(data[within]) assert len(x_cat) == 2, 'Within must have exactly two unique levels.' if order is None: order = x_cat else: assert len(order) == 2, 'Order must have exactly two elements.' # Start the plot if ax is None: fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi) for idx, s in enumerate(subj): tmp = data.loc[data[subject] == s, [dv, within, subject]] x_val = tmp[tmp[within] == order[0]][dv].to_numpy()[0] y_val = tmp[tmp[within] == order[1]][dv].to_numpy()[0] if x_val < y_val: color = colors[0] elif x_val > y_val: color = colors[2] elif x_val == y_val: color = colors[1] # Plot individual lines using Seaborn sns.pointplot(data=tmp, x=within, y=dv, order=order, color=color, ax=ax, **_pointplot_kwargs) if boxplot: sns.boxplot(data=data, x=within, y=dv, order=order, ax=ax, **_boxplot_kwargs) # Set alpha to patch of boxplot but not to whiskers for patch in ax.artists: r, g, b, a = patch.get_facecolor() patch.set_facecolor((r, g, b, .75)) # Despine and trim sns.despine(trim=True, ax=ax) return ax
def test_remove_rm_na(self): """Test function remove_rm_na.""" # With one within factor df = pd.DataFrame({'Time': ['A', 'A', 'B', 'B'], 'Values': [1.52, np.nan, 8.2, 3.4], 'Ss': [0, 1, 0, 1]}) df = remove_rm_na(dv='Values', within='Time', subject='Ss', data=df) assert df['Ss'].nunique() == 1 # With multiple factor df = read_dataset('rm_missing') stats = remove_rm_na(data=df, dv='BOLD', within=['Session', 'Time'], subject='Subj') assert stats['BOLD'].isnull().sum() == 0 assert stats['Memory'].isnull().sum() == 5 # Multiple factors stats = remove_rm_na(data=df, within=['Time', 'Session'], subject='Subj') assert stats['BOLD'].isnull().sum() == 0 assert stats['Memory'].isnull().sum() == 0 # Aggregation remove_rm_na(data=df, dv='BOLD', within='Session', subject='Subj') remove_rm_na(data=df, within='Session', subject='Subj', aggregate='sum') remove_rm_na(data=df, within='Session', subject='Subj', aggregate='first') df.loc['Subj', 1] = np.nan with pytest.raises(ValueError): remove_rm_na(data=df, within='Session', subject='Subj')
def plot_paired(data=None, dv=None, within=None, subject=None, order=None, boxplot=True, figsize=(4, 4), dpi=100, ax=None, colors=['green', 'grey', 'indianred'], pointplot_kwargs={ 'scale': .6, 'markers': '.' }, boxplot_kwargs={ 'color': 'lightslategrey', 'width': .2 }): """ Paired plot. Parameters ---------- data : pandas DataFrame Long-format dataFrame. dv : string Name of column containing the dependant variable. within : string Name of column containing the within-subject factor. Note that ``within`` must have exactly two within-subject levels (= two unique values). subject : string Name of column containing the subject identifier. order : list of str List of values in ``within`` that define the order of elements on the x-axis of the plot. If None, uses alphabetical order. boxplot : boolean If True, add a boxplot to the paired lines using the :py:func:`seaborn.boxplot` function. figsize : tuple Figsize in inches dpi : int Resolution of the figure in dots per inches. ax : matplotlib axes Axis on which to draw the plot. colors : list of str Line colors names. Default is green when value increases from A to B, indianred when value decreases from A to B and grey when the value is the same in both measurements. pointplot_kwargs : dict Dictionnary of optional arguments that are passed to the :py:func:`seaborn.pointplot` function. boxplot_kwargs : dict Dictionnary of optional arguments that are passed to the :py:func:`seaborn.boxplot` function. Returns ------- ax : Matplotlib Axes instance Returns the Axes object with the plot for further tweaking. Notes ----- Data must be a long-format pandas DataFrame. Examples -------- Default paired plot: .. plot:: >>> from pingouin import read_dataset >>> df = read_dataset('mixed_anova') >>> df = df.query("Group == 'Meditation' and Subject > 40") >>> df = df.query("Time == 'August' or Time == 'June'") >>> import pingouin as pg >>> ax = pg.plot_paired(data=df, dv='Scores', within='Time', ... subject='Subject', dpi=150) Paired plot on an existing axis (no boxplot and uniform color): .. plot:: >>> from pingouin import read_dataset >>> df = read_dataset('mixed_anova').query("Time != 'January'") >>> import pingouin as pg >>> import matplotlib.pyplot as plt >>> fig, ax1 = plt.subplots(1, 1, figsize=(5, 4)) >>> pg.plot_paired(data=df[df['Group'] == 'Meditation'], ... dv='Scores', within='Time', subject='Subject', ... ax=ax1, boxplot=False, ... colors=['grey', 'grey', 'grey']) # doctest: +SKIP """ from pingouin.utils import _check_dataframe, remove_rm_na # Validate args _check_dataframe(data=data, dv=dv, within=within, subject=subject, effects='within') # Remove NaN values data = remove_rm_na(dv=dv, within=within, subject=subject, data=data) # Extract subjects subj = data[subject].unique() # Extract within-subject level (alphabetical order) x_cat = np.unique(data[within]) assert len(x_cat) == 2, 'Within must have exactly two unique levels.' if order is None: order = x_cat else: assert len(order) == 2, 'Order must have exactly two elements.' # Start the plot if ax is None: fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi) for idx, s in enumerate(subj): tmp = data.loc[data[subject] == s, [dv, within, subject]] x_val = tmp[tmp[within] == order[0]][dv].to_numpy()[0] y_val = tmp[tmp[within] == order[1]][dv].to_numpy()[0] if x_val < y_val: color = colors[0] elif x_val > y_val: color = colors[2] elif x_val == y_val: color = colors[1] # Plot individual lines using Seaborn sns.pointplot(data=tmp, x=within, y=dv, order=order, color=color, ax=ax, **pointplot_kwargs) if boxplot: sns.boxplot(data=data, x=within, y=dv, order=order, ax=ax, **boxplot_kwargs) # Despine and trim sns.despine(trim=True, ax=ax) return ax
def pairwise_ttests(dv=None, between=None, within=None, subject=None, data=None, parametric=True, alpha=.05, tail='two-sided', padjust='none', effsize='hedges', return_desc=False, export_filename=None): '''Pairwise T-tests. Parameters ---------- dv : string Name of column containing the dependant variable. between : string or list with 2 elements Name of column(s) containing the between factor(s). within : string or list with 2 elements Name of column(s) containing the within factor(s). subject : string Name of column containing the subject identifier. Compulsory for contrast including a within-subject factor. data : pandas DataFrame DataFrame. Note that this function can also directly be used as a Pandas method, in which case this argument is no longer needed. parametric : boolean If True (default), use the parametric :py:func:`ttest` function. If False, use :py:func:`pingouin.wilcoxon` or :py:func:`pingouin.mwu` for paired or unpaired samples, respectively. alpha : float Significance level tail : string Indicates whether to return the 'two-sided' or 'one-sided' p-values padjust : string Method used for testing and adjustment of pvalues. Available methods are :: 'none' : no correction 'bonferroni' : one-step Bonferroni correction 'holm' : step-down method using Bonferroni adjustments 'fdr_bh' : Benjamini/Hochberg FDR correction 'fdr_by' : Benjamini/Yekutieli FDR correction effsize : string or None Effect size type. Available methods are :: 'none' : no effect size 'cohen' : Unbiased Cohen d 'hedges' : Hedges g 'glass': Glass delta 'eta-square' : Eta-square 'odds-ratio' : Odds ratio 'AUC' : Area Under the Curve return_desc : boolean If True, append group means and std to the output dataframe export_filename : string Filename (without extension) for the output file. If None, do not export the table. By default, the file will be created in the current python console directory. To change that, specify the filename with full path. Returns ------- stats : DataFrame Stats summary :: 'A' : Name of first measurement 'B' : Name of second measurement 'Paired' : indicates whether the two measurements are paired or not 'Parametric' : indicates if (non)-parametric tests were used 'Tail' : indicate whether the p-values are one-sided or two-sided 'T' : T-values (only if parametric=True) 'U' : Mann-Whitney U value (only if parametric=False and unpaired data) 'W' : Wilcoxon W value (only if parametric=False and paired data) 'dof' : degrees of freedom (only if parametric=True) 'p-unc' : Uncorrected p-values 'p-corr' : Corrected p-values 'p-adjust' : p-values correction method 'BF10' : Bayes Factor 'hedges' : Hedges effect size 'CLES' : Common language effect size Notes ----- Data are expected to be in long-format. If your data is in wide-format, you can use the :py:func:`pandas.melt` function to convert from wide to long format. If ``between`` or ``within`` is a list (e.g. ['col1', 'col2']), the function returns 1) the pairwise T-tests between each values of the first column, 2) the pairwise T-tests between each values of the second column and 3) the interaction between col1 and col2. The interaction is dependent of the order of the list, so ['col1', 'col2'] will not yield the same results as ['col2', 'col1']. In other words, if ``between`` is a list with two elements, the output model is between1 + between2 + between1 * between2. Similarly, if `within`` is a list with two elements, the output model is within1 + within2 + within1 * within2. If both ``between`` and ``within`` are specified, the function return within + between + within * between. Missing values in repeated measurements are automatically removed using the :py:func:`pingouin.remove_rm_na` function. However, you should be very careful since it can result in undesired values removal (especially for the interaction effect). We strongly recommend that you preprocess your data and remove the missing values before using this function. This function has been tested against the `pairwise.t.test` R function. See Also -------- ttest : T-test. wilcoxon : Non-parametric test for paired samples. mwu : Non-parametric test for independent samples. Examples -------- 1. One between-factor >>> from pingouin import pairwise_ttests, read_dataset >>> df = read_dataset('mixed_anova.csv') >>> post_hocs = pairwise_ttests(dv='Scores', between='Group', data=df) 2. One within-factor >>> post_hocs = pairwise_ttests(dv='Scores', within='Time', ... subject='Subject', data=df) >>> print(post_hocs) # doctest: +SKIP 3. Non-parametric pairwise paired test (wilcoxon) >>> pairwise_ttests(dv='Scores', within='Time', subject='Subject', ... data=df, parametric=False) # doctest: +SKIP 4. Within + Between + Within * Between with corrected p-values >>> posthocs = pairwise_ttests(dv='Scores', within='Time', ... subject='Subject', between='Group', ... padjust='bonf', data=df) 5. Between1 + Between2 + Between1 * Between2 >>> posthocs = pairwise_ttests(dv='Scores', between=['Group', 'Time'], ... data=df) ''' from pingouin.parametric import ttest from pingouin.nonparametric import wilcoxon, mwu # Safety checks _check_dataframe(dv=dv, between=between, within=within, subject=subject, effects='all', data=data) if tail not in ['one-sided', 'two-sided']: raise ValueError('Tail not recognized') if not isinstance(alpha, float): raise ValueError('Alpha must be float') # Check if we have multiple between or within factors multiple_between = False multiple_within = False contrast = None if isinstance(between, list): if len(between) > 1: multiple_between = True contrast = 'multiple_between' assert all([b in data.keys() for b in between]) else: between = between[0] if isinstance(within, list): if len(within) > 1: multiple_within = True contrast = 'multiple_within' assert all([w in data.keys() for w in within]) else: within = within[0] if all([multiple_within, multiple_between]): raise ValueError("Multiple between and within factors are", "currently not supported. Please select only one.") # Check the other cases if isinstance(between, str) and within is None: contrast = 'simple_between' assert between in data.keys() if isinstance(within, str) and between is None: contrast = 'simple_within' assert within in data.keys() if isinstance(between, str) and isinstance(within, str): contrast = 'within_between' assert all([between in data.keys(), within in data.keys()]) # Initialize empty variables stats = pd.DataFrame([]) ddic = {} if contrast in ['simple_within', 'simple_between']: # OPTION A: SIMPLE MAIN EFFECTS, WITHIN OR BETWEEN paired = True if contrast == 'simple_within' else False col = within if contrast == 'simple_within' else between # Remove NAN in repeated measurements if contrast == 'simple_within' and data[dv].isnull().values.any(): data = remove_rm_na(dv=dv, within=within, subject=subject, data=data) # Extract effects labels = data[col].unique().tolist() for l in labels: ddic[l] = data.loc[data[col] == l, dv].values # Number and labels of possible comparisons if len(labels) >= 2: combs = list(combinations(labels, 2)) else: raise ValueError('Columns must have at least two unique values.') # Initialize vectors for comb in combs: col1, col2 = comb x = ddic.get(col1) y = ddic.get(col2) if parametric: df_ttest = ttest(x, y, paired=paired, tail=tail) # Compute exact CLES df_ttest['CLES'] = compute_effsize(x, y, paired=paired, eftype='CLES') else: if paired: df_ttest = wilcoxon(x, y, tail=tail) else: df_ttest = mwu(x, y, tail=tail) # Compute Hedges / Cohen ef = compute_effsize(x=x, y=y, eftype=effsize, paired=paired) stats = _append_stats_dataframe(stats, x, y, col1, col2, alpha, paired, tail, df_ttest, ef, effsize) stats['Contrast'] = col # Multiple comparisons padjust = None if stats['p-unc'].size <= 1 else padjust if padjust is not None: if padjust.lower() != 'none': _, stats['p-corr'] = multicomp(stats['p-unc'].values, alpha=alpha, method=padjust) stats['p-adjust'] = padjust else: stats['p-corr'] = None stats['p-adjust'] = None else: # B1: BETWEEN1 + BETWEEN2 + BETWEEN1 * BETWEEN2 # B2: WITHIN1 + WITHIN2 + WITHIN1 * WITHIN2 # B3: WITHIN + BETWEEN + WITHIN * BETWEEN if contrast == 'multiple_between': # B1 factors = between fbt = factors fwt = [None, None] # eft = ['between', 'between'] paired = False elif contrast == 'multiple_within': # B2 factors = within fbt = [None, None] fwt = factors # eft = ['within', 'within'] paired = True else: # B3 factors = [within, between] fbt = [None, between] fwt = [within, None] # eft = ['within', 'between'] paired = False for i, f in enumerate(factors): stats = stats.append(pairwise_ttests(dv=dv, between=fbt[i], within=fwt[i], subject=subject, data=data, parametric=parametric, alpha=alpha, tail=tail, padjust=padjust, effsize=effsize, return_desc=return_desc), ignore_index=True, sort=False) # Rename effect size to generic name stats.rename(columns={effsize: 'efsize'}, inplace=True) # Then compute the interaction between the factors labels_fac1 = data[factors[0]].unique().tolist() labels_fac2 = data[factors[1]].unique().tolist() comb_fac1 = list(combinations(labels_fac1, 2)) comb_fac2 = list(combinations(labels_fac2, 2)) lc_fac1 = len(comb_fac1) lc_fac2 = len(comb_fac2) for lw in labels_fac1: for l in labels_fac2: tmp = data.loc[data[factors[0]] == lw] ddic[lw, l] = tmp.loc[tmp[factors[1]] == l, dv].values # Pairwise comparisons combs = list(product(labels_fac1, comb_fac2)) for comb in combs: fac1, (col1, col2) = comb x = ddic.get((fac1, col1)) y = ddic.get((fac1, col2)) if parametric: df_ttest = ttest(x, y, paired=paired, tail=tail) # Compute exact CLES df_ttest['CLES'] = compute_effsize(x, y, paired=paired, eftype='CLES') else: if paired: df_ttest = wilcoxon(x, y, tail=tail) else: df_ttest = mwu(x, y, tail=tail) ef = compute_effsize(x=x, y=y, eftype=effsize, paired=paired) stats = _append_stats_dataframe(stats, x, y, col1, col2, alpha, paired, tail, df_ttest, ef, effsize, fac1) # Update the Contrast columns txt_inter = factors[0] + ' * ' + factors[1] idxitr = np.arange(lc_fac1 + lc_fac2, stats.shape[0]).tolist() stats.loc[idxitr, 'Contrast'] = txt_inter # Multi-comparison columns if padjust is not None and padjust.lower() != 'none': _, pcor = multicomp(stats.loc[idxitr, 'p-unc'].values, alpha=alpha, method=padjust) stats.loc[idxitr, 'p-corr'] = pcor stats.loc[idxitr, 'p-adjust'] = padjust # --------------------------------------------------------------------- stats['Paired'] = stats['Paired'].astype(bool) stats['Parametric'] = parametric # Round effect size and CLES stats[['efsize', 'CLES']] = stats[['efsize', 'CLES']].round(3) # Reorganize column order col_order = [ 'Contrast', 'Time', 'A', 'B', 'mean(A)', 'std(A)', 'mean(B)', 'std(B)', 'Paired', 'Parametric', 'T', 'U', 'W', 'dof', 'tail', 'p-unc', 'p-corr', 'p-adjust', 'BF10', 'CLES', 'efsize' ] if return_desc is False: stats.drop(columns=['mean(A)', 'mean(B)', 'std(A)', 'std(B)'], inplace=True) stats = stats.reindex(columns=col_order) stats.dropna(how='all', axis=1, inplace=True) # Rename effect size column stats.rename(columns={'efsize': effsize}, inplace=True) # Rename Time columns if contrast in ['multiple_within', 'multiple_between', 'within_between']: stats['Time'].fillna('-', inplace=True) stats.rename(columns={'Time': factors[0]}, inplace=True) if export_filename is not None: _export_table(stats, export_filename) return stats
def pairwise_ttests(data=None, dv=None, between=None, within=None, subject=None, parametric=True, alpha=.05, tail='two-sided', padjust='none', effsize='hedges', nan_policy='listwise', return_desc=False, interaction=True, export_filename=None): '''Pairwise T-tests. Parameters ---------- data : pandas DataFrame DataFrame. Note that this function can also directly be used as a Pandas method, in which case this argument is no longer needed. dv : string Name of column containing the dependant variable. between : string or list with 2 elements Name of column(s) containing the between factor(s). within : string or list with 2 elements Name of column(s) containing the within factor(s). subject : string Name of column containing the subject identifier. Compulsory for contrast including a within-subject factor. parametric : boolean If True (default), use the parametric :py:func:`ttest` function. If False, use :py:func:`pingouin.wilcoxon` or :py:func:`pingouin.mwu` for paired or unpaired samples, respectively. alpha : float Significance level tail : string Specify whether the alternative hypothesis is `'two-sided'` or `'one-sided'`. Can also be `'greater'` or `'less'` to specify the direction of the test. `'greater'` tests the alternative that ``x`` has a larger mean than ``y``. If tail is `'one-sided'`, Pingouin will automatically infer the one-sided alternative hypothesis of the test based on the test statistic. padjust : string Method used for testing and adjustment of pvalues. Available methods are :: 'none' : no correction 'bonf' : one-step Bonferroni correction 'sidak' : one-step Sidak correction 'holm' : step-down method using Bonferroni adjustments 'fdr_bh' : Benjamini/Hochberg FDR correction 'fdr_by' : Benjamini/Yekutieli FDR correction effsize : string or None Effect size type. Available methods are :: 'none' : no effect size 'cohen' : Unbiased Cohen d 'hedges' : Hedges g 'glass': Glass delta 'r' : Pearson correlation coefficient 'eta-square' : Eta-square 'odds-ratio' : Odds ratio 'AUC' : Area Under the Curve 'CLES' : Common Language Effect Size nan_policy : string Can be `'listwise'` for listwise deletion of missing values in repeated measures design (= complete-case analysis) or `'pairwise'` for the more liberal pairwise deletion (= available-case analysis). .. versionadded:: 0.2.9 return_desc : boolean If True, append group means and std to the output dataframe interaction : boolean If there are multiple factors and ``interaction`` is True (default), Pingouin will also calculate T-tests for the interaction term (see Notes). .. versionadded:: 0.2.9 export_filename : string Filename (without extension) for the output file. If None, do not export the table. By default, the file will be created in the current python console directory. To change that, specify the filename with full path. Returns ------- stats : DataFrame Stats summary :: 'A' : Name of first measurement 'B' : Name of second measurement 'Paired' : indicates whether the two measurements are paired or not 'Parametric' : indicates if (non)-parametric tests were used 'Tail' : indicate whether the p-values are one-sided or two-sided 'T' : T statistic (only if parametric=True) 'U-val' : Mann-Whitney U stat (if parametric=False and unpaired data) 'W-val' : Wilcoxon W stat (if parametric=False and paired data) 'dof' : degrees of freedom (only if parametric=True) 'p-unc' : Uncorrected p-values 'p-corr' : Corrected p-values 'p-adjust' : p-values correction method 'BF10' : Bayes Factor 'hedges' : effect size (or any effect size defined in ``effsize``) See also -------- ttest, mwu, wilcoxon, compute_effsize, multicomp Notes ----- Data are expected to be in long-format. If your data is in wide-format, you can use the :py:func:`pandas.melt` function to convert from wide to long format. If ``between`` or ``within`` is a list (e.g. ['col1', 'col2']), the function returns 1) the pairwise T-tests between each values of the first column, 2) the pairwise T-tests between each values of the second column and 3) the interaction between col1 and col2. The interaction is dependent of the order of the list, so ['col1', 'col2'] will not yield the same results as ['col2', 'col1'], and will only be calculated if ``interaction=True``. In other words, if ``between`` is a list with two elements, the output model is between1 + between2 + between1 * between2. Similarly, if `within`` is a list with two elements, the output model is within1 + within2 + within1 * within2. If both ``between`` and ``within`` are specified, the function return within + between + within * between. Missing values in repeated measurements are automatically removed using a listwise (default) or pairwise deletion strategy. However, you should be very careful since it can result in undesired values removal (especially for the interaction effect). We strongly recommend that you preprocess your data and remove the missing values before using this function. This function has been tested against the `pairwise.t.test` R function. Examples -------- 1. One between-factor >>> from pingouin import pairwise_ttests, read_dataset >>> df = read_dataset('mixed_anova.csv') >>> post_hocs = pairwise_ttests(dv='Scores', between='Group', data=df) 2. One within-factor >>> post_hocs = pairwise_ttests(dv='Scores', within='Time', ... subject='Subject', data=df) >>> print(post_hocs) # doctest: +SKIP 3. Non-parametric pairwise paired test (wilcoxon) >>> pairwise_ttests(dv='Scores', within='Time', subject='Subject', ... data=df, parametric=False) # doctest: +SKIP 4. Within + Between + Within * Between with corrected p-values >>> posthocs = pairwise_ttests(dv='Scores', within='Time', ... subject='Subject', between='Group', ... padjust='bonf', data=df) 5. Between1 + Between2 + Between1 * Between2 >>> posthocs = pairwise_ttests(dv='Scores', between=['Group', 'Time'], ... data=df) 6. Between1 + Between2, no interaction >>> posthocs = df.pairwise_ttests(dv='Scores', between=['Group', 'Time'], ... interaction=False) ''' from .parametric import ttest from .nonparametric import wilcoxon, mwu # Safety checks _check_dataframe(dv=dv, between=between, within=within, subject=subject, effects='all', data=data) assert tail in ['one-sided', 'two-sided', 'greater', 'less'] assert isinstance(alpha, float), 'alpha must be float.' assert nan_policy in ['listwise', 'pairwise'] # Check if we have multiple between or within factors multiple_between = False multiple_within = False contrast = None if isinstance(between, list): if len(between) > 1: multiple_between = True contrast = 'multiple_between' assert all([b in data.keys() for b in between]) else: between = between[0] if isinstance(within, list): if len(within) > 1: multiple_within = True contrast = 'multiple_within' assert all([w in data.keys() for w in within]) else: within = within[0] if all([multiple_within, multiple_between]): raise ValueError("Multiple between and within factors are", "currently not supported. Please select only one.") # Check the other cases if isinstance(between, str) and within is None: contrast = 'simple_between' assert between in data.keys() if isinstance(within, str) and between is None: contrast = 'simple_within' assert within in data.keys() if isinstance(between, str) and isinstance(within, str): contrast = 'within_between' assert all([between in data.keys(), within in data.keys()]) # Reorganize column order col_order = [ 'Contrast', 'Time', 'A', 'B', 'mean(A)', 'std(A)', 'mean(B)', 'std(B)', 'Paired', 'Parametric', 'T', 'U-val', 'W-val', 'dof', 'Tail', 'p-unc', 'p-corr', 'p-adjust', 'BF10', effsize ] if contrast in ['simple_within', 'simple_between']: # OPTION A: SIMPLE MAIN EFFECTS, WITHIN OR BETWEEN paired = True if contrast == 'simple_within' else False col = within if contrast == 'simple_within' else between # Remove NAN in repeated measurements if contrast == 'simple_within' and data[dv].isnull().values.any(): # Only if nan_policy == 'listwise'. For pairwise deletion, # missing values will be removed directly in the lower-level # functions (e.g. pg.ttest) if nan_policy == 'listwise': data = remove_rm_na(dv=dv, within=within, subject=subject, data=data) else: # The `remove_rm_na` also aggregate other repeated measures # factor using the mean. Here, we ensure this behavior too. data = data.groupby([subject, within])[dv].mean().reset_index() # Now we check that subjects are present in all conditions # For example, if we have four subjects and 3 conditions, # and if subject 2 have missing data at the third condition, # we still need a row with missing values for this subject. if data.groupby(within)[subject].count().nunique() != 1: raise ValueError("Repeated measures dataframe is not balanced." " `Subjects` must have the same number of " "elements in all conditions, " "even when missing values are present.") # Extract effects grp_col = data.groupby(col, sort=False)[dv] labels = grp_col.groups.keys() # Number and labels of possible comparisons if len(labels) >= 2: combs = list(combinations(labels, 2)) combs = np.array(combs) A = combs[:, 0] B = combs[:, 1] else: raise ValueError('Columns must have at least two unique values.') # Initialize dataframe stats = pd.DataFrame(dtype=np.float64, index=range(len(combs)), columns=col_order) # Force dtype conversion cols_str = ['Contrast', 'Time', 'A', 'B', 'Tail', 'p-adjust', 'BF10'] cols_bool = ['Parametric', 'Paired'] stats[cols_str] = stats[cols_str].astype(object) stats[cols_bool] = stats[cols_bool].astype(bool) # Fill str columns stats.loc[:, 'A'] = A stats.loc[:, 'B'] = B stats.loc[:, 'Contrast'] = col stats.loc[:, 'Tail'] = tail stats.loc[:, 'Paired'] = paired for i in range(stats.shape[0]): col1, col2 = stats.at[i, 'A'], stats.at[i, 'B'] x = grp_col.get_group(col1).to_numpy(dtype=np.float64) y = grp_col.get_group(col2).to_numpy(dtype=np.float64) if parametric: stat_name = 'T' df_ttest = ttest(x, y, paired=paired, tail=tail) stats.at[i, 'BF10'] = df_ttest.at['T-test', 'BF10'] stats.at[i, 'dof'] = df_ttest.at['T-test', 'dof'] else: if paired: stat_name = 'W-val' df_ttest = wilcoxon(x, y, tail=tail) else: stat_name = 'U-val' df_ttest = mwu(x, y, tail=tail) # Compute Hedges / Cohen ef = np.round( compute_effsize(x=x, y=y, eftype=effsize, paired=paired), 3) if return_desc: stats.at[i, 'mean(A)'] = np.round(np.nanmean(x), 3) stats.at[i, 'mean(B)'] = np.round(np.nanmean(y), 3) stats.at[i, 'std(A)'] = np.round(np.nanstd(x), 3) stats.at[i, 'std(B)'] = np.round(np.nanstd(y), 3) stats.at[i, stat_name] = df_ttest[stat_name].iat[0] stats.at[i, 'p-unc'] = df_ttest['p-val'].iat[0] stats.at[i, effsize] = ef # Multiple comparisons padjust = None if stats['p-unc'].size <= 1 else padjust if padjust is not None: if padjust.lower() != 'none': _, stats['p-corr'] = multicomp(stats['p-unc'].values, alpha=alpha, method=padjust) stats['p-adjust'] = padjust else: stats['p-corr'] = None stats['p-adjust'] = None else: # B1: BETWEEN1 + BETWEEN2 + BETWEEN1 * BETWEEN2 # B2: WITHIN1 + WITHIN2 + WITHIN1 * WITHIN2 # B3: WITHIN + BETWEEN + WITHIN * BETWEEN if contrast == 'multiple_between': # B1 factors = between fbt = factors fwt = [None, None] # eft = ['between', 'between'] paired = False elif contrast == 'multiple_within': # B2 factors = within fbt = [None, None] fwt = factors # eft = ['within', 'within'] paired = True else: # B3 factors = [within, between] fbt = [None, between] fwt = [within, None] # eft = ['within', 'between'] paired = False stats = pd.DataFrame() for i, f in enumerate(factors): stats = stats.append(pairwise_ttests(dv=dv, between=fbt[i], within=fwt[i], subject=subject, data=data, parametric=parametric, alpha=alpha, tail=tail, padjust=padjust, effsize=effsize, return_desc=return_desc), ignore_index=True, sort=False) # Then compute the interaction between the factors if interaction: nrows = stats.shape[0] grp_fac1 = data.groupby(factors[0], sort=False)[dv] grp_fac2 = data.groupby(factors[1], sort=False)[dv] grp_both = data.groupby(factors, sort=False)[dv] labels_fac1 = grp_fac1.groups.keys() labels_fac2 = grp_fac2.groups.keys() # comb_fac1 = list(combinations(labels_fac1, 2)) comb_fac2 = list(combinations(labels_fac2, 2)) # Pairwise comparisons combs_list = list(product(labels_fac1, comb_fac2)) ncombs = len(combs_list) # np.array(combs_list) does not work because of tuples # we therefore need to flatten the tupple combs = np.zeros(shape=(ncombs, 3), dtype=object) for i in range(ncombs): combs[i] = _flatten_list(combs_list[i], include_tuple=True) # Append empty rows idxiter = np.arange(nrows, nrows + ncombs) stats = stats.append(pd.DataFrame(columns=stats.columns, index=idxiter), ignore_index=True) # Update other columns stats.loc[idxiter, 'Contrast'] = factors[0] + ' * ' + factors[1] stats.loc[idxiter, 'Time'] = combs[:, 0] stats.loc[idxiter, 'Paired'] = paired stats.loc[idxiter, 'Tail'] = tail stats.loc[idxiter, 'A'] = combs[:, 1] stats.loc[idxiter, 'B'] = combs[:, 2] for i, comb in enumerate(combs): ic = nrows + i # Take into account previous rows fac1, col1, col2 = comb x = grp_both.get_group((fac1, col1)).to_numpy(dtype=np.float64) y = grp_both.get_group((fac1, col2)).to_numpy(dtype=np.float64) ef = np.round( compute_effsize(x=x, y=y, eftype=effsize, paired=paired), 3) if parametric: stat_name = 'T' df_ttest = ttest(x, y, paired=paired, tail=tail) stats.at[ic, 'BF10'] = df_ttest.at['T-test', 'BF10'] stats.at[ic, 'dof'] = df_ttest.at['T-test', 'dof'] else: if paired: stat_name = 'W-val' df_ttest = wilcoxon(x, y, tail=tail) else: stat_name = 'U-val' df_ttest = mwu(x, y, tail=tail) # Append to stats if return_desc: stats.at[ic, 'mean(A)'] = np.round(np.nanmean(x), 3) stats.at[ic, 'mean(B)'] = np.round(np.nanmean(y), 3) stats.at[ic, 'std(A)'] = np.round(np.nanstd(x), 3) stats.at[ic, 'std(B)'] = np.round(np.nanstd(y), 3) stats.at[ic, stat_name] = df_ttest[stat_name].iat[0] stats.at[ic, 'p-unc'] = df_ttest['p-val'].iat[0] stats.at[ic, effsize] = ef # Multi-comparison columns if padjust is not None and padjust.lower() != 'none': _, pcor = multicomp(stats.loc[idxiter, 'p-unc'].values, alpha=alpha, method=padjust) stats.loc[idxiter, 'p-corr'] = pcor stats.loc[idxiter, 'p-adjust'] = padjust # --------------------------------------------------------------------- # Append parametric columns stats.loc[:, 'Parametric'] = parametric # Reorder and drop empty columns stats = stats[np.array(col_order)[np.isin(col_order, stats.columns)]] stats = stats.dropna(how='all', axis=1) # Rename Time columns if (contrast in ['multiple_within', 'multiple_between', 'within_between'] and interaction): stats['Time'].fillna('-', inplace=True) stats.rename(columns={'Time': factors[0]}, inplace=True) if export_filename is not None: _export_table(stats, export_filename) return stats
def pairwise_ttests(data=None, dv=None, between=None, within=None, subject=None, parametric=True, marginal=True, alpha=.05, tail='two-sided', padjust='none', effsize='hedges', correction='auto', nan_policy='listwise', return_desc=False, interaction=True): """Pairwise T-tests. Parameters ---------- data : :py:class:`pandas.DataFrame` DataFrame. Note that this function can also directly be used as a Pandas method, in which case this argument is no longer needed. dv : string Name of column containing the dependant variable. between : string or list with 2 elements Name of column(s) containing the between-subject factor(s). .. warning:: Note that Pingouin gives slightly different T and p-values compared to JASP posthoc tests for 2-way factorial design, because Pingouin does not pool the standard error for each factor, but rather calculate each pairwise T-test completely independent of others. within : string or list with 2 elements Name of column(s) containing the within-subject factor(s), i.e. the repeated measurements. subject : string Name of column containing the subject identifier. This is compulsory when ``within`` is specified. parametric : boolean If True (default), use the parametric :py:func:`ttest` function. If False, use :py:func:`pingouin.wilcoxon` or :py:func:`pingouin.mwu` for paired or unpaired samples, respectively. marginal : boolean If True, average over repeated measures factor when working with mixed or two-way repeated measures design. For instance, in mixed design, the between-subject pairwise T-test(s) will be calculated after averaging across all levels of the within-subject repeated measures factor (the so-called *"marginal means"*). Similarly, in two-way repeated measures factor, the pairwise T-test(s) will be calculated after averaging across all levels of the other repeated measures factor. Setting ``marginal=True`` is recommended when doing posthoc testing with multiple factors in order to avoid violating the assumption of independence and conflating the degrees of freedom by the number of repeated measurements. This is the default behavior of JASP. .. warning:: The default behavior of Pingouin <0.3.2 was ``marginal = False``, which may have led to incorrect p-values for mixed or two-way repeated measures design. Make sure to always use the latest version of Pingouin. .. versionadded:: 0.3.2 alpha : float Significance level tail : string Specify whether the alternative hypothesis is `'two-sided'` or `'one-sided'`. Can also be `'greater'` or `'less'` to specify the direction of the test. `'greater'` tests the alternative that ``x`` has a larger mean than ``y``. If tail is `'one-sided'`, Pingouin will automatically infer the one-sided alternative hypothesis of the test based on the test statistic. padjust : string Method used for testing and adjustment of pvalues. * ``'none'``: no correction * ``'bonf'``: one-step Bonferroni correction * ``'sidak'``: one-step Sidak correction * ``'holm'``: step-down method using Bonferroni adjustments * ``'fdr_bh'``: Benjamini/Hochberg FDR correction * ``'fdr_by'``: Benjamini/Yekutieli FDR correction effsize : string or None Effect size type. Available methods are: * ``'none'``: no effect size * ``'cohen'``: Unbiased Cohen d * ``'hedges'``: Hedges g * ``'glass'``: Glass delta * ``'r'``: Pearson correlation coefficient * ``'eta-square'``: Eta-square * ``'odds-ratio'``: Odds ratio * ``'AUC'``: Area Under the Curve * ``'CLES'``: Common Language Effect Size correction : string or boolean For unpaired two sample T-tests, specify whether or not to correct for unequal variances using Welch separate variances T-test. If `'auto'`, it will automatically uses Welch T-test when the sample sizes are unequal, as recommended by Zimmerman 2004. .. versionadded:: 0.3.2 nan_policy : string Can be `'listwise'` for listwise deletion of missing values in repeated measures design (= complete-case analysis) or `'pairwise'` for the more liberal pairwise deletion (= available-case analysis). .. versionadded:: 0.2.9 return_desc : boolean If True, append group means and std to the output dataframe interaction : boolean If there are multiple factors and ``interaction`` is True (default), Pingouin will also calculate T-tests for the interaction term (see Notes). .. versionadded:: 0.2.9 Returns ------- stats : :py:class:`pandas.DataFrame` * ``'A'``: Name of first measurement * ``'B'``: Name of second measurement * ``'Paired'``: indicates whether the two measurements are paired or not * ``'Parametric'``: indicates if (non)-parametric tests were used * ``'Tail'``: indicate whether the p-values are one-sided or two-sided * ``'T'``: T statistic (only if parametric=True) * ``'U-val'``: Mann-Whitney U stat (if parametric=False and unpaired data) * ``'W-val'``: Wilcoxon W stat (if parametric=False and paired data) * ``'dof'``: degrees of freedom (only if parametric=True) * ``'p-unc'``: Uncorrected p-values * ``'p-corr'``: Corrected p-values * ``'p-adjust'``: p-values correction method * ``'BF10'``: Bayes Factor * ``'hedges'``: effect size (or any effect size defined in ``effsize``) See also -------- ttest, mwu, wilcoxon, compute_effsize, multicomp Notes ----- Data are expected to be in long-format. If your data is in wide-format, you can use the :py:func:`pandas.melt` function to convert from wide to long format. If ``between`` or ``within`` is a list (e.g. ['col1', 'col2']), the function returns 1) the pairwise T-tests between each values of the first column, 2) the pairwise T-tests between each values of the second column and 3) the interaction between col1 and col2. The interaction is dependent of the order of the list, so ['col1', 'col2'] will not yield the same results as ['col2', 'col1'], and will only be calculated if ``interaction=True``. In other words, if ``between`` is a list with two elements, the output model is between1 + between2 + between1 * between2. Similarly, if ``within`` is a list with two elements, the output model is within1 + within2 + within1 * within2. If both ``between`` and ``within`` are specified, the output model is within + between + within * between (= mixed design). Missing values in repeated measurements are automatically removed using a listwise (default) or pairwise deletion strategy. However, you should be very careful since it can result in undesired values removal (especially for the interaction effect). We strongly recommend that you preprocess your data and remove the missing values before using this function. This function has been tested against the `pairwise.t.test <https://www.rdocumentation.org/packages/stats/versions/3.6.2/topics/pairwise.t.test>`_ R function. .. warning:: Versions of Pingouin below 0.3.2 gave incorrect results for mixed and two-way repeated measures design (see above warning for the ``marginal`` argument). .. warning:: Pingouin gives slightly different results than the JASP's posthoc module when working with multiple factors (e.g. mixed, factorial or 2-way repeated measures design). This is mostly caused by the fact that Pingouin does not pool the standard error for between-subject and interaction contrasts. You should always double check your results with JASP or another statistical software. Examples -------- For more examples, please refer to the `Jupyter notebooks <https://github.com/raphaelvallat/pingouin/blob/master/notebooks/01_ANOVA.ipynb>`_ 1. One between-subject factor >>> from pingouin import pairwise_ttests, read_dataset >>> df = read_dataset('mixed_anova.csv') >>> pairwise_ttests(dv='Scores', between='Group', data=df) # doctest: +SKIP 2. One within-subject factor >>> post_hocs = pairwise_ttests(dv='Scores', within='Time', ... subject='Subject', data=df) >>> print(post_hocs) # doctest: +SKIP 3. Non-parametric pairwise paired test (wilcoxon) >>> pairwise_ttests(dv='Scores', within='Time', subject='Subject', ... data=df, parametric=False) # doctest: +SKIP 4. Mixed design (within and between) with bonferroni-corrected p-values >>> posthocs = pairwise_ttests(dv='Scores', within='Time', ... subject='Subject', between='Group', ... padjust='bonf', data=df) 5. Two between-subject factors. The order of the list matters! >>> posthocs = pairwise_ttests(dv='Scores', between=['Group', 'Time'], ... data=df) 6. Same but without the interaction >>> posthocs = df.pairwise_ttests(dv='Scores', between=['Group', 'Time'], ... interaction=False) """ from .parametric import ttest from .nonparametric import wilcoxon, mwu # Safety checks _check_dataframe(dv=dv, between=between, within=within, subject=subject, effects='all', data=data) assert tail in ['one-sided', 'two-sided', 'greater', 'less'] assert isinstance(alpha, float), 'alpha must be float.' assert nan_policy in ['listwise', 'pairwise'] # Check if we have multiple between or within factors multiple_between = False multiple_within = False contrast = None if isinstance(between, list): if len(between) > 1: multiple_between = True contrast = 'multiple_between' assert all([b in data.keys() for b in between]) else: between = between[0] if isinstance(within, list): if len(within) > 1: multiple_within = True contrast = 'multiple_within' assert all([w in data.keys() for w in within]) else: within = within[0] if all([multiple_within, multiple_between]): raise ValueError("Multiple between and within factors are", "currently not supported. Please select only one.") # Check the other cases if isinstance(between, str) and within is None: contrast = 'simple_between' assert between in data.keys() if isinstance(within, str) and between is None: contrast = 'simple_within' assert within in data.keys() if isinstance(between, str) and isinstance(within, str): contrast = 'within_between' assert all([between in data.keys(), within in data.keys()]) # Reorganize column order col_order = ['Contrast', 'Time', 'A', 'B', 'mean(A)', 'std(A)', 'mean(B)', 'std(B)', 'Paired', 'Parametric', 'T', 'U-val', 'W-val', 'dof', 'Tail', 'p-unc', 'p-corr', 'p-adjust', 'BF10', effsize] if contrast in ['simple_within', 'simple_between']: # OPTION A: SIMPLE MAIN EFFECTS, WITHIN OR BETWEEN paired = True if contrast == 'simple_within' else False col = within if contrast == 'simple_within' else between # Remove NAN in repeated measurements if contrast == 'simple_within' and data[dv].isnull().to_numpy().any(): # Only if nan_policy == 'listwise'. For pairwise deletion, # missing values will be removed directly in the lower-level # functions (e.g. pg.ttest) if nan_policy == 'listwise': data = remove_rm_na(dv=dv, within=within, subject=subject, data=data) else: # The `remove_rm_na` also aggregate other repeated measures # factor using the mean. Here, we ensure this behavior too. data = data.groupby([subject, within])[dv].mean().reset_index() # Now we check that subjects are present in all conditions # For example, if we have four subjects and 3 conditions, # and if subject 2 have missing data at the third condition, # we still need a row with missing values for this subject. if data.groupby(within)[subject].count().nunique() != 1: raise ValueError("Repeated measures dataframe is not balanced." " `Subjects` must have the same number of " "elements in all conditions, " "even when missing values are present.") # Extract effects grp_col = data.groupby(col, sort=False)[dv] labels = grp_col.groups.keys() # Number and labels of possible comparisons if len(labels) >= 2: combs = list(combinations(labels, 2)) combs = np.array(combs) A = combs[:, 0] B = combs[:, 1] else: raise ValueError('Columns must have at least two unique values.') # Initialize dataframe stats = pd.DataFrame(dtype=np.float64, index=range(len(combs)), columns=col_order) # Force dtype conversion cols_str = ['Contrast', 'Time', 'A', 'B', 'Tail', 'p-adjust', 'BF10'] cols_bool = ['Parametric', 'Paired'] stats[cols_str] = stats[cols_str].astype(object) stats[cols_bool] = stats[cols_bool].astype(bool) # Fill str columns stats.loc[:, 'A'] = A stats.loc[:, 'B'] = B stats.loc[:, 'Contrast'] = col stats.loc[:, 'Tail'] = tail stats.loc[:, 'Paired'] = paired for i in range(stats.shape[0]): col1, col2 = stats.at[i, 'A'], stats.at[i, 'B'] x = grp_col.get_group(col1).to_numpy(dtype=np.float64) y = grp_col.get_group(col2).to_numpy(dtype=np.float64) if parametric: stat_name = 'T' df_ttest = ttest(x, y, paired=paired, tail=tail, correction=correction) stats.at[i, 'BF10'] = df_ttest.at['T-test', 'BF10'] stats.at[i, 'dof'] = df_ttest.at['T-test', 'dof'] else: if paired: stat_name = 'W-val' df_ttest = wilcoxon(x, y, tail=tail) else: stat_name = 'U-val' df_ttest = mwu(x, y, tail=tail) # Compute Hedges / Cohen ef = compute_effsize(x=x, y=y, eftype=effsize, paired=paired) if return_desc: stats.at[i, 'mean(A)'] = np.nanmean(x) stats.at[i, 'mean(B)'] = np.nanmean(y) stats.at[i, 'std(A)'] = np.nanstd(x, ddof=1) stats.at[i, 'std(B)'] = np.nanstd(y, ddof=1) stats.at[i, stat_name] = df_ttest[stat_name].iat[0] stats.at[i, 'p-unc'] = df_ttest['p-val'].iat[0] stats.at[i, effsize] = ef # Multiple comparisons padjust = None if stats['p-unc'].size <= 1 else padjust if padjust is not None: if padjust.lower() != 'none': _, stats['p-corr'] = multicomp(stats['p-unc'].to_numpy(), alpha=alpha, method=padjust) stats['p-adjust'] = padjust else: stats['p-corr'] = None stats['p-adjust'] = None else: # Multiple factors if contrast == 'multiple_between': # B1: BETWEEN1 + BETWEEN2 + BETWEEN1 * BETWEEN2 factors = between fbt = factors fwt = [None, None] paired = False # the interaction is not paired agg = [False, False] # TODO: add a pool SD option, as in JASP and JAMOVI? elif contrast == 'multiple_within': # B2: WITHIN1 + WITHIN2 + WITHIN1 * WITHIN2 factors = within fbt = [None, None] fwt = factors paired = True agg = [True, True] # Calculate marginal means for both factors else: # B3: WITHIN + BETWEEN + WITHIN * BETWEEN factors = [within, between] fbt = [None, between] fwt = [within, None] paired = False agg = [False, True] stats = pd.DataFrame() for i, f in enumerate(factors): # Introduced in Pingouin v0.3.2 if all([agg[i], marginal]): tmp = data.groupby([subject, f], as_index=False, sort=False).mean() else: tmp = data stats = stats.append(pairwise_ttests(dv=dv, between=fbt[i], within=fwt[i], subject=subject, data=tmp, parametric=parametric, marginal=marginal, alpha=alpha, tail=tail, padjust=padjust, effsize=effsize, correction=correction, nan_policy=nan_policy, return_desc=return_desc), ignore_index=True, sort=False) # Then compute the interaction between the factors if interaction: nrows = stats.shape[0] grp_fac1 = data.groupby(factors[0], sort=False)[dv] grp_fac2 = data.groupby(factors[1], sort=False)[dv] grp_both = data.groupby(factors, sort=False)[dv] labels_fac1 = grp_fac1.groups.keys() labels_fac2 = grp_fac2.groups.keys() # comb_fac1 = list(combinations(labels_fac1, 2)) comb_fac2 = list(combinations(labels_fac2, 2)) # Pairwise comparisons combs_list = list(product(labels_fac1, comb_fac2)) ncombs = len(combs_list) # np.array(combs_list) does not work because of tuples # we therefore need to flatten the tupple combs = np.zeros(shape=(ncombs, 3), dtype=object) for i in range(ncombs): combs[i] = _flatten_list(combs_list[i], include_tuple=True) # Append empty rows idxiter = np.arange(nrows, nrows + ncombs) stats = stats.append(pd.DataFrame(columns=stats.columns, index=idxiter), ignore_index=True) # Update other columns stats.loc[idxiter, 'Contrast'] = factors[0] + ' * ' + factors[1] stats.loc[idxiter, 'Time'] = combs[:, 0] stats.loc[idxiter, 'Paired'] = paired stats.loc[idxiter, 'Tail'] = tail stats.loc[idxiter, 'A'] = combs[:, 1] stats.loc[idxiter, 'B'] = combs[:, 2] for i, comb in enumerate(combs): ic = nrows + i # Take into account previous rows fac1, col1, col2 = comb x = grp_both.get_group((fac1, col1)).to_numpy(dtype=np.float64) y = grp_both.get_group((fac1, col2)).to_numpy(dtype=np.float64) ef = compute_effsize(x=x, y=y, eftype=effsize, paired=paired) if parametric: stat_name = 'T' df_ttest = ttest(x, y, paired=paired, tail=tail, correction=correction) stats.at[ic, 'BF10'] = df_ttest.at['T-test', 'BF10'] stats.at[ic, 'dof'] = df_ttest.at['T-test', 'dof'] else: if paired: stat_name = 'W-val' df_ttest = wilcoxon(x, y, tail=tail) else: stat_name = 'U-val' df_ttest = mwu(x, y, tail=tail) # Append to stats if return_desc: stats.at[ic, 'mean(A)'] = np.nanmean(x) stats.at[ic, 'mean(B)'] = np.nanmean(y) stats.at[ic, 'std(A)'] = np.nanstd(x, ddof=1) stats.at[ic, 'std(B)'] = np.nanstd(y, ddof=1) stats.at[ic, stat_name] = df_ttest[stat_name].iat[0] stats.at[ic, 'p-unc'] = df_ttest['p-val'].iat[0] stats.at[ic, effsize] = ef # Multi-comparison columns if padjust is not None and padjust.lower() != 'none': _, pcor = multicomp(stats.loc[idxiter, 'p-unc'].to_numpy(), alpha=alpha, method=padjust) stats.loc[idxiter, 'p-corr'] = pcor stats.loc[idxiter, 'p-adjust'] = padjust # --------------------------------------------------------------------- # Append parametric columns stats.loc[:, 'Parametric'] = parametric # Reorder and drop empty columns stats = stats[np.array(col_order)[np.isin(col_order, stats.columns)]] stats = stats.dropna(how='all', axis=1) # Rename Time columns if (contrast in ['multiple_within', 'multiple_between', 'within_between'] and interaction): stats['Time'].fillna('-', inplace=True) stats.rename(columns={'Time': factors[0]}, inplace=True) return stats