Rozdział 9 Porównanie zmiennych zależnych


9.1 Porównanie średnich

Test t-Studenta. Metoda do porównania dwóch zmiennych zależnych sprowadza się do przeprowadzenia testu t-Studenta dla jednej zmiennej tzn. różnic między obserwacjami.

import numpy as np
import scipy.stats as stats
import pingouin as pg
  
x = stats.norm.rvs(0, 1, size=20, random_state=2305)
y = stats.norm.rvs(1.5, 3, size=20, random_state=4101)
  
print(pg.ttest(x,y,paired=True))
##             T  dof       tail     p-val           CI95%  cohen-d   BF10  power
## T-test -2.688   19  two-sided  0.014562  [-3.73, -0.46]    1.006  3.752  0.989

Dokładny test znaków. Ta procedura sprowadza się do określenia liczby znaków dla różnic między obserwacjami. Inaczej mówiąc po pominięciu różnic równych zero zliczamy dodatnie (statystyka dokładnego testu \(T_+\)) i ujemne różnice. Na podstawie testu dwumianowego scipy.stats.binom_test o argunentach: \(x=T_+\),\(n=T_++T_-\) oraz \(p=0,5\) możemy określić dokładną p-wartość. Weryfikowana hipoteza zerowa ma postać: \[\begin{equation} H_0:\;p=0,5\quad\mbox{vs}\quad H_1:\;p\neq 0,5 \tag{9.1} \end{equation}\] gdzie: \(p\) to prawdopodobieństwo tego, że \(P(x>y)=0,5\).

import numpy as np
import scipy.stats as stats

x = stats.norm.rvs(0, 1, size=20, random_state=2305)
y = stats.norm.rvs(1.5, 3, size=20, random_state=4101)
z = np.greater(x-y,[0]).astype(int)

print("S:",sum(z),", p-value:",stats.binom_test(sum(z), len(z)))
## S: 6 , p-value: 0.11531829833984371

RM-Anova / Greenhouse-Geisser. Test Mauchly który został zaimplementowany do funkcji pingouin.sphericity określa czy warunek sferyczności jest spełniony. W hipotezie zerowej zakładamy, że wariancje dla różnic pomiędzy parami powtarzanych pomiarów są takie same. \[\begin{equation} H_0:\;\sigma^2_{d1}=\sigma^2_{d2}=\ldots=\sigma^2_{di}\quad\mbox{vs}\quad H_1:\mbox{nie wszystkie wariancje są równe} \tag{9.2} \end{equation}\] gdzie: \(\sigma^2_{di}\) to wariancja dla \(i\)-tej różnicy zmiennych.

Jeśli analizowane zmienne nie spełniają tego założenia, to należy dostosować wyniki RM-ANOVA za pomocą jednej z korekt: Greenhouse-Geisser [1958] lub Huynh and Feldt [1976]. Funkcja pingouin.rm_anova ma opcję correction dzięki której można wykonać test z korektą lub bez. Generalnie współczynnik korekcyjny HF jest używany częściej, ponieważ współczynnik GG jest zbyt konserwatywny tzn. nie zawsze udaje się wykryć prawdziwą różnicę między grupami. Dzięki funkcji pingouin.epsilon można otrzymać współczynniki \(\epsilon-\)epsilon. Określają one odstępstwo od symetrii złożonej dla każdej z dwóch procedur: GG i HF. Im mniejsza wartość \(\epsilon\) tym większe jest odstępstwo od warunku sferyczności. \[\begin{equation} \epsilon_{HF} = \frac{n(k-1)\epsilon_{GG}-2}{(k-1)(n-1-(k-1)\epsilon_{GG})} \tag{9.3} \end{equation}\] Wartości p-value są obliczane na podstawie rozkładu F po skorygowaniu stopni swobody: \[\begin{equation} df_1=(k-1)\cdot \epsilon_{HF} \quad\mbox{oraz}\quad df_2=(k-1)\cdot (n-1)\cdot \epsilon_{HF} \tag{9.4} \end{equation}\]

import numpy as np
import scipy.stats as stats
import pandas as pd
import pingouin as pg

y = np.concatenate((stats.norm.rvs(0, 1, size=20, random_state=2305),
                    stats.norm.rvs(1.5, 3, size=20, random_state=4101),
                    stats.norm.rvs(1.5, 2, size=20, random_state=4026)))
Dpaired = pd.DataFrame({'y': y,
                        'g': np.repeat(np.linspace(1,3,3), [20,20,20], axis=0),
                        'b': np.tile(np.linspace(1,20,20), 3)})

print(pg.rm_anova(dv='y', within='g', subject='b', data=Dpaired,\
                  correction=True).drop(['sphericity','np2'], axis=1),'\n')

dat = Dpaired.pivot(index='b', columns='g', values='y')
hf = pg.epsilon(dat, correction='hf')
df1 = dat.shape[1]-1
df2 = dat.shape[0]-1
p = 1-stats.f.cdf(5.094, hf*df1, hf*df1*df2)

print(pd.DataFrame({'HF':[hf],'df1':[df1],'df2':[df2],'p-HF-corr':[p]}))
##   Source  ddof1  ddof2      F     p-unc  p-GG-corr   eps  W-spher   p-spher
## 0      g      2     38  5.094  0.010968   0.018047  0.79    0.734  0.061652 
## 
##          HF  df1  df2  p-HF-corr
## 0  0.849268    2   19   0.015664

W większości przypadków lepiej zastosować wielowymiarową analize wariancji tj. MANOVA (O’brien i Kaiser 1985) lub liniowe modele mieszane (Zieliński 2010) ponieważ są one odporne na złamanie założenia kulistości. Ta procedura jest dostępna dzięki funkcji pingouin.mixed_anova.

9.2 Porównanie rang

Test Wilcoxona / metoda Pratta. Procedura rangowanych znaków dla dwóch zmiennych zależnych polega na obliczeniu \(d_i\) czyli różnic między obserwacjami a następnie porangowaniu ich wartości bezwzględnych tzn. \(\mbox{rank}|d_i|\). W metodzie Pratta sumujemy tylko te rangi dla których różnica dwóch zmiennych \(d_i\) była mniejsza od zera tzn. \(V=\sum_{d_i<0}\mbox{rank}|d_i|\). Według metody Wilcoxona zanim porangujemy wartości bezwzględnych różnic musimy usunąć różnice równe zero. Następnie obliczamy sumę rang według wzoru \(V=\sum_{d_i>0}\mbox{rank}|d_i|\). Do funkcji scipy.stats.wilcoxon zostały zaimplementowane obie metody.

Statystyka testowa dla metody Wilcoxona z poprawką na ciągłość: \[\begin{equation} Z=\frac{V-\frac{1}{4}\left[n(n+1)\right]-0,5}{\sqrt{\frac{1}{24}\left[n(n+1)(2n+1)\right]-\frac{1}{48}\sum_{i=1}^{c}(t_i^3-t_i)}} \tag{9.5} \end{equation}\] gdzie: \(n\) to liczba różnic czyli par zmiennych, \(V\) to suma rang dla różnic dodatnich, \(0,5\) to poprawka na ciągłość, \(c\) to liczba grup pomiarów wiązanych, \(t_i\) to liczba pomiarów wiązanych w \(i\)-tej grupie pomiarów wiązanych.

import scipy.stats as stats

x = stats.norm.rvs(0, 1, size=20, random_state=2305)
y = stats.norm.rvs(1.5, 3, size=20, random_state=4101)

print(stats.wilcoxon(x, y, zero_method='wilcox', correction=True))
## WilcoxonResult(statistic=43.0, pvalue=0.021678215270968325)

Statystyka testowa dla metody Pratta: \[\begin{equation} Z=\frac{V-\frac{1}{4}\left[n(n+1)-t_0(t_0+1)\right]}{\sqrt{\frac{1}{24}\left[n(n+1)(2n+1)-t_0(t_0+1)(2t_0+1)\right]-\frac{1}{48}\sum_{i=1}^{c}(t_i^3-t_i)}} \tag{9.6} \end{equation}\] gdzie: \(n\) to liczba różnic czyli par zmiennych, \(V\) to suma rang dla różnic ujemnych, \(t_0\) to liczba zerowych różnic, \(c\) to liczba grup pomiarów wiązanych, \(t_i\) to liczba pomiarów wiązanych w \(i\)-tej grupie pomiarów wiązanych.

import scipy.stats as stats

x = stats.norm.rvs(0, 1, size=20, random_state=2305)
y = stats.norm.rvs(1.5, 3, size=20, random_state=4101)

print(stats.wilcoxon(x, y, zero_method='pratt'))
## WilcoxonResult(statistic=43.0, pvalue=0.020633435105949553)

Test Friedmana. Rozszerzeniem testu znaków na kilka zmiennych sparowanych jest test Friedmana który został zaimplementowany do funkcji scipy.stats.friedmanchisquare oraz pingouin.friedman.

Statystyka testowa:

\[\begin{equation} \chi^2=\left(1-\frac{\sum_{i=1}^{c}(t^3_i-t_i)}{nk(k^2-1)}\right)^{-1}\left[\frac{12}{nk(k+1)}\sum_{j=1}^{k}R^2_j-3n(k+1)\right] \tag{9.7} \end{equation}\] gdzie: \(k\) to liczebność grup, \(n_j\) to liczebność obserwacji w \(i\)-tej grupie, \(c\) to liczba grup pomiarów wiązanych, \(t_i\) to liczba pomiarów wiązanych w \(i\)-tej grupie pomiarów wiązanych.

import numpy as np
import scipy.stats as stats
import pandas as pd
import pingouin as pg
  
y = np.concatenate((stats.norm.rvs(0, 1, size=20, random_state=2305),
                    stats.norm.rvs(1.5, 3, size=20, random_state=4101),
                    stats.norm.rvs(1.5, 2, size=20, random_state=4026)))
Dpaired = pd.DataFrame({'y': y,
                        'g': np.repeat(np.linspace(1,3,3), [20,20,20], axis=0),
                        'b': np.tile(np.linspace(1,20,20), 3)})
                              
print(pg.friedman(dv='y', within='g', subject='b', data=Dpaired))
##          Source  ddof1    Q     p-unc
## Friedman      g      2  3.9  0.142274

Test Imana-Davenporta. Modyfikacją testu Friedmana jest metoda Imana-Davenporta która sprowadza się do przekształcenia statystyki \(\chi^2\) według wzoru: \[\begin{equation} F=\frac{(n_j-1)\chi^2}{n_j(k-1)-\chi^2} \tag{9.8} \end{equation}\] gdzie: \(k\) to liczebność grup, \(n_j\) to liczebność obserwacji w \(j\)-tej grupie, \(df_1=k-1\) oraz \(df_2=(k-1)(n_j-1)\).

import numpy as np
import scipy.stats as stats
import pandas as pd
import pingouin as pg
  
y = np.concatenate((stats.norm.rvs(0, 1, size=20, random_state=2305),
                    stats.norm.rvs(1.5, 3, size=20, random_state=4101),
                    stats.norm.rvs(1.5, 2, size=20, random_state=4026)))
Dpaired = pd.DataFrame({'y': y,
                        'g': np.repeat(np.linspace(1,3,3), [20,20,20], axis=0),
                        'b': np.tile(np.linspace(1,20,20), 3)})

F = pg.friedman(dv='y', within='g', subject='b', data=Dpaired)['Q'][0]
n = 20
k = 3
df1 = k-1
df2 = (k-1)*(n-1)
F = ((n-1)*F)/(n*(k-1)-F)
p = 1-stats.f.cdf(F,df1,df2)
print(pd.DataFrame({'F':[F],'df1':[df1],'df2':[df2],'p':[p]}))
##           F  df1  df2         p
## 0  2.052632    2   38  0.142396

Test wyrównanych rang Friedmana. W tej procedurze (ang. Friedman Aligned Ranks) obliczenia wykonujemy na przekształconych danych tj. \(x_{ij}-\bar{x}_i\). Otrzymane w ten sposób wartości trzeba porangować bez podziału na grupy i obliczyć sumy kwadratów rang dla \(k\) grup (kolumn) \(\sum_{j=1}^{k}\hat{R^2_j}\) oraz dla \(n\) obserwacji (wierszy) \(\sum_{i=1}^{n}\hat{R^2_i}\) aby wyznaczyć statystykę testu.

Statystyka testu: \[\begin{equation} T=\frac{(k-1)[\sum_{j=1}^{k}\hat{R^2_j}-(kn^2/4)(kn+1)^2]}{\left([kn(kn+1)(2kn+1)]/6\right)-(1/k)\sum_{i=1}^{n}\hat{R^2_i}} \tag{9.9} \end{equation}\]

import numpy as np
import scipy.stats as stats
import pandas as pd
    
df = pd.DataFrame()
df['a1'] = stats.norm.rvs(0, 1, size=20, random_state=2305)
df['a2'] = stats.norm.rvs(1.5, 3, size=20, random_state=4101)
df['a3'] = stats.norm.rvs(1.5, 2, size=20, random_state=4026)

mu = df.mean(axis=1)
w = [ df[i]-mu for i in df.columns ]
r = stats.rankdata(w)
rdf = pd.DataFrame({'a1':r[:20],'a2':r[20:40],'a3':r[40:60]})
Sk = sum(rdf.sum(axis=0)**2)
Sn = sum(rdf.sum(axis=1)**2)
n = df.shape[0]
k = df.shape[1]
T = ((k-1)*(Sk-((k*n**2)/4)*(k*n+1)**2))/(((k*n*(k*n+1)*(2*k*n+1))/6)-(1/k)*Sn)
p = 1-stats.chi2.cdf(T,df=k-1)
print(pd.DataFrame({'T':[T],'df':[k-1],'p':[p]}))
##           T  df         p
## 0  7.448925   2  0.024126

Test Quade. Dobrą alternatywą dla testu Friedmana może być również metoda Quade dostępna w funkcji stac.nonparametric_tests.quade_test. Jest to rozszerzenie testu rangowanych znaków Wilcoxona na więcej niż dwie sparaowane zmienne.

Statystyka testu: \[\begin{equation} F_Q=\frac{(n-1)SS_{tre}}{SS_{tot}-SS_{tre}} \tag{9.10} \end{equation}\] gdzie: \(n\) to liczba bloków, \(k\) to liczba grup, \(R_{ij}\) to rangi obliczone oddzielnie dla każdego bloku, \(Q_i\) to rangi obliczone dla różnic \(x_{max}-x_{min}\) obliczonych dla każdego bloku, \(S_{ij}\) to macierz o postaci \(S_{ij}=Q_i\left[R_{ij}-(k+1)/2\right]\), \(SS_{tot}=\sum_{i=1}^{n}\sum_{j=1}^{k}S_{ij}^2\) to suma wszystkich elementów macierzy \(S_{ij}\) które zostały podniesione do kwadratu, \(SS_{tre}=\frac{1}{n}\sum_{j=1}^{k}S_i^2\) to suma elementów macierzy \(S_{ij}\) dla każdej grupy i podniesionych do kwadratu a następnie te wartości są sumowane i podzielone przez liczbę bloków. Stopnie swobody są obliczane na podstawie wzorów \(df_1=k-1\) oraz \(df_2=(n-1)(k-1)\).

import numpy as np
import scipy.stats as stats
import pandas as pd

df = pd.DataFrame()
df['a1'] = stats.norm.rvs(0, 1, size=20, random_state=2305)
df['a2'] = stats.norm.rvs(1.5, 3, size=20, random_state=4101)
df['a3'] = stats.norm.rvs(1.5, 2, size=20, random_state=4026)

def quade_test(x):
    df = x
    n = df.shape[0]
    k = df.shape[1]
    rdat = df.rank(axis=1)
    minmax = df.apply(lambda x: max(x)-min(x),axis=1)
    Q = pd.DataFrame(stats.rankdata(minmax), columns=['a'])
    m = rdat-(k+1)/2
    S = m.values * Q.values
    SStot = sum(sum(S**2))
    SStre = sum(sum(S)**2)/n
    F = (n-1)*(SStre)/(SStot-SStre)
    df1 = k-1
    df2 = (n-1)*(k-1)
    p = 1-stats.f.cdf(F,df1,df2)
    DF = pd.DataFrame({'F':[F],'df1':[df1],'df2':[df2],'SStot':[SStot],'SStre':[SStre],'p':[p]})
    return DF
    
print(quade_test(df))
##           F  df1  df2   SStot   SStre         p
## 0  4.066348    2   38  5740.0  1011.9  0.025103

Dalsza analiza. W pakiecie scikit-posthocs jest dostępnych wiele testów post hoc dla nieparametrycznej analizy wariancji z powtarzanymi pomiarami. Popularnym wyborem do porównań wielokrotnych po odrzuceniu hipotezy zerowej w teście Friedmana jest przeprowadzenie serii testów znaków lub test Nemenyi. W przypadku wyrównanych rang Friedmana błąd standardowy badanych różnic rang \(\hat{R}_i-\hat{R}_j\) jest dany wzorem: \[\begin{equation} SE=\sqrt{\frac{k(kn+1)}{6}} \tag{9.11} \end{equation}\] Poniżej przykład skryptu dla tego rozwiązania w języku Python:

import numpy as np
import scipy.stats as stats
import pandas as pd
from pingouin import multicomp
import itertools

df = pd.DataFrame()
df['a1'] = stats.norm.rvs(0, 1, size=20, random_state=2305)
df['a2'] = stats.norm.rvs(1.5, 3, size=20, random_state=4101)
df['a3'] = stats.norm.rvs(1.5, 2, size=20, random_state=4026)

def post_hoc_far(x):
    df = x
    n = df.shape[0]
    k = df.shape[1]
    mu = df.mean(axis=1)
    w = [ df[i]-mu for i in df.columns ]
    r = stats.rankdata(w)
    rdat = pd.DataFrame(r.reshape((k,n)).T,columns=df.columns)
    SS = rdat.mean(axis=0)
    SE = np.sqrt((k * (n * k + 1))/6)
    stat = SS/SE
    res = list(itertools.combinations(stat, 2))
    sol = [np.abs(np.diff(res[i])) for i in range(len(res))]
    es = list(itertools.combinations(list(df.columns), 2))
    f = pd.DataFrame({'stat':np.ravel(sol).tolist()},index=es)
    f['p-val'] = [ 2*(1-stats.norm.cdf(i)) for i in f['stat']]
    f['p-val_Holm'] = multicomp(f['p-val'].tolist(), method='holm')[1].tolist()
    f['sign'] = multicomp(f['p-val'].tolist(), method='holm')[0].tolist()
    return f
    
print(post_hoc_far(df))
##               stat     p-val  p-val_Holm   sign
## (a1, a2)  3.250233  0.001153    0.003459   True
## (a1, a3)  1.231286  0.218216    0.218216  False
## (a2, a3)  2.018947  0.043493    0.086985  False

Z kolei rozwiązaniem dedykowanym dla testu Quade jest seria testów rangowanych znaków Wilcoxona zaimplementowanych do funkcji scikit_posthocs.posthoc_wilcoxon oraz metoda dostępna dzięki funkcji scikit_posthocs.posthoc_quade która działa z wykorzystaniem rozkładu t-Studenta lub normalnego. W tej metodzie badamy różnice wyznaczone w oparciu o sumy obliczone dla każdej grupy z wykorzystaniem macierzy \(S_{ij}\) natomiast błąd standardowy można określić za pomocą wzoru: \[\begin{equation} SE=\sqrt{\frac{2n(SS_{tot}-SS_{tre})}{(n-1)(k-1)}} \tag{9.12} \end{equation}\]

import numpy as np
import scipy.stats as stats
import pandas as pd
from pingouin import multicomp
import itertools

df = pd.DataFrame()
df['a1'] = stats.norm.rvs(0, 1, size=20, random_state=2305)
df['a2'] = stats.norm.rvs(1.5, 3, size=20, random_state=4101)
df['a3'] = stats.norm.rvs(1.5, 2, size=20, random_state=4026)

def post_hoc_quade_test_1(x):
    df = x
    n = df.shape[0]
    k = df.shape[1]
    rdat = df.rank(axis=1)
    minmax = df.apply(lambda x: max(x)-min(x),axis=1)
    Q = pd.DataFrame(stats.rankdata(minmax), columns=['a'])
    m = rdat-(k+1)/2
    S = m.values * Q.values
    SStot = sum(sum(S**2))
    SStre = sum(sum(S)**2)/n
    SS = S.sum(axis=0)
    df2 = (n-1)*(k-1)
    SE = np.sqrt((2*n*(SStot-SStre))/df2)
    stat = SS/SE
    res = list(itertools.combinations(stat, 2))
    sol = [np.abs(np.diff(res[i])) for i in range(len(res))]
    es = list(itertools.combinations(list(df.columns), 2))
    f = pd.DataFrame({'stat':np.ravel(sol).tolist()},index=es)
    f['p-val'] = [ 2*(1-stats.t.cdf(i,df=df2)) for i in f['stat']]
    f['p-val_Holm'] = multicomp(f['p-val'].tolist(), method='holm')[1].tolist()
    f['sign'] = multicomp(f['p-val'].tolist(), method='holm')[0].tolist()
    return f
    
print(post_hoc_quade_test_1(df))
##               stat     p-val  p-val_Holm   sign
## (a1, a2)  2.849145  0.007041    0.021122   True
## (a1, a3)  1.318261  0.195307    0.268163  False
## (a2, a3)  1.530884  0.134081    0.268163  False

Alternatywą może być badanie różnic w oparciu o sumy elementów macierzy \(R_{ij}Q_i\) obliczone dla każdej grupy tzn. \(W_j=\frac{\sum_{i=1}^{n}R_{ij}Q_i}{n(n+1)/2}\) a błąd standardowy jest dany wzorem: \[\begin{equation} SE=\sqrt{\frac{k(k+1)(2n+1)(k-1)}{18n(n+1)}} \tag{9.13} \end{equation}\]

import numpy as np
import scipy.stats as stats
import pandas as pd
from pingouin import multicomp
import itertools

df = pd.DataFrame()
df['a1'] = stats.norm.rvs(0, 1, size=20, random_state=2305)
df['a2'] = stats.norm.rvs(1.5, 3, size=20, random_state=4101)
df['a3'] = stats.norm.rvs(1.5, 2, size=20, random_state=4026)
  
def post_hoc_quade_test_2(x):
    df = x; n = df.shape[0]; k = df.shape[1]
    rdat = df.rank(axis=1)
    minmax = df.apply(lambda x: max(x)-min(x),axis=1)
    Q = pd.DataFrame(stats.rankdata(minmax), columns=['a'])
    W = rdat.values * Q.values
    SS = W.sum(axis=0)/(n*(n+1)/2)
    SE = np.sqrt((k*(k+1)*(2*n+1)*(k-1))/(18*n*(n+1)))
    stat = SS/SE
    res = list(itertools.combinations(stat, 2))
    sol = [np.abs(np.diff(res[i])) for i in range(len(res))]
    es = list(itertools.combinations(list(df.columns), 2))
    f = pd.DataFrame({'stat':np.ravel(sol).tolist()},index=es)
    f['p-val'] = [ 2*(1-stats.norm.cdf(i)) for i in f['stat']]
    f['p-val_Holm'] = multicomp(f['p-val'].tolist(), method='holm')[1].tolist()
    f['sign'] = multicomp(f['p-val'].tolist(), method='holm')[0].tolist()
    return f
    
print(post_hoc_quade_test_2(df))
##               stat     p-val  p-val_Holm   sign
## (a1, a2)  2.653017  0.007978    0.023933   True
## (a1, a3)  1.227516  0.219629    0.308024  False
## (a2, a3)  1.425502  0.154012    0.308024  False

Bibliografia

O’brien, R. G., i M. Kent Kaiser. 1985. „MANOVA method for analyzing repeated measures designs: an extensive primer.” Psychological bulletin 97 2: 316–33. https://www.semanticscholar.org/paper/MANOVA-method-for-analyzing-repeated-measures-an-O'brien-Kaiser/589e9049758cd50b8fde4ceb8842e8b3f3778c6e?tab=abstract.

Zieliński, P. 2010. „Multilevel analysis for repeated measures - hierarchical linear model as an alternative to the analysis of variance”. Psychologia Społeczna 5 (14, 2-3). Wydawnictwo Naukowe SCHOLAR: 234–59. http://spbulletin.com/articles/zielinski-p-2010-multilevel-analysis-for-repeated-measures-hierarchical-linear-model-as-an-alternative-to-the-analysis-of-variance/.