Up

上下法(階段法)データのベイズ分析

PyStanPythonStan)のインストールについての説明のウェブサイトを用意した。

サンプルプログラムとデータファイルは、圧縮ファイルfiles2Cat.zipにまとめた。Jupyter Notebook用のものも圧縮ファイルfiles2Cat_jn.zipにまとめた。

それぞれダウンロード・解凍すれば、自由に利用することができる。

PythonPyStanの使い方については、拙著<岡本安晴「いまさら聞けないPythonでデータ分析」、丸善出版>で説明している。

PythonPyStanも無料のソフトである。

心理物理学(精神物理学、psychophysics)において主観的等価点を求める方法として上下法(Up-down method)あるいは階段法(Staircase method)と呼ばれている方法がある。これは、提示された比較刺激が標準刺激より「強い」と判断されたときは、次の試行ではより弱い比較刺激を提示し、「弱い」と判断されたときは次の試行ではより強い比較刺激を提示するものである。すなわち、第試行における比較刺激とそれに対する判断をで表し、刺激の変化量をで表すとき、

とおくと、

のとき,

のとき,

,

である。

上下法では、提示刺激値が直前の判断に依存して決まるため、被験者が提示刺激値を予測する可能性が高い。これを避けるため、2系列用意して2つの系列の試行をランダムに混ぜて実施する(二重上下法;大山、2005)。上式において、2つの系列を系列1と系列2として、系列でのものであることを右上に添え字として明記すると、以下のようになる。

とおくと、

のとき,

のとき,

,

データ分析においては、系列1のデータと系列2のデータをまとめてとおき、刺激に対する反応の確率を次式で与える。

ここで、は累積標準正規分布関数である。このとき、尤度関数を次式で与える。

である。この尤度関数は、データが上下法以外の方法、例えば恒常法、で集められた場合にも適用できることに注意。データは、「強い」判断および「弱い」判断が確実なそれぞれ刺激値が十分に強いものと弱いものを含むと、の事後分布が拡散することを防ぐことができる。

この尤度関数に対して、事前分布として一様分布を設定したときの事後分布を求めるStanスクリプトをリスト1に示す(岡本、早期公開2019.6.21)。の値は、−と+を1と2で表している。

 

リスト1 上下法データのベイズ分析用Stanスクリプト

data {

    int N;              //      The number of trials

    int Res[N];         //      Responses:  1 denotes -, 2 denotes +

    real X[N];          //      Stimuli

    real min_sgm;       //      The lower boundary of sgm

    real max_sgm;       //      The upper boundary of sgm

}

 

parameters {

    real<lower = 0.0> sgm;

    real pse;

}

 

transformed parameters {

    vector[2] p[N];             //      Probabilities for X[N]

    for (i in 1:N){

        p[i][1] = 1 - Phi((X[i] - pse) / sgm);

        p[i][2] = Phi((X[i] - pse) / sgm);

    }

}

 

model {

    sgm ~ uniform(min_sgm, max_sgm);

    for (i in 1:N)

        Res[i] ~ categorical(p[i]);

}

 

リスト1のStanスクリプトによる分析プログラム例を付記にリスト2として示す。プログラムファイルとサンプルデータファイルは圧縮ファイルfiles2Cat.zipとしてまとめた。ダウンロードして自由に利用されたい。Jupyter Notebook用も圧縮ファイルfiles2Cat_jn.zipとして用意している。

リスト2のスクリプトを実行すると、入力データファイル名の設定が求められる(図1)。

図1

 

データファイルは、CSV形式のファイルとして用意するが(Excelで保存時に、ファイル拡張子として.csvを指定すればよい)、形式は図2のようである。

図2

 

第1行目に、変数名を適当に設定する。2行目からデータを設定するが、1列目はデータの識別用である。図2では通し番号が設定されているが、任意の文字列でよい。2列目に提示刺激値を設定し、3列目に反応を設定する。反応は、「弱い」判断のときは1、「強い」判断のときは2を設定する。データの行の順序は任意である。これは、判断の確率が刺激値のみによって与えられるので、例えば、図2の2行目にある0番目のデータと101行目の99番目のデータを入れ替えても尤度関数値は変わらないからである。

データの設定が終われば、ファイル拡張子として.csvを選んで保存すればよい。CSV形式のファイルについては、拙著<岡本安晴「いまさら聞けないPythonでデータ分析」丸善出版pp. 33-35, 101-102>で説明している。パラメータの事後分布が発散しないように、提示刺激には「強い判断」の確率が1に近い十分強い刺激と「弱い」判断の確率が0に近い十分弱い刺激が含まれるようにする。2系列用意する上下法において、「強い」判断が容易な強い刺激値で始まる系列と「弱い」判断が容易な弱い刺激値で始まる系列の2系列を用意すればよい。

入力データファイル名を設定すると、次に出力ファイル名の設定が求められる(図3)。適当なテキストファイル名を設定する。

図3

 

入力ファイル名と出力ファイル名の設定が終わると、データ読み込まれ、Stanによる事後分布からのサンプリングが始まる。リスト2のプログラムでは、以下のように初期化の関数f_initとデータの辞書stan_dataが設定されて、リスト1のStanスクリプトPM2Cat.stanによるサンプリングが行われる。PythonにおけるStanPyStan)の使用については、拙著<岡本安晴「いまさら聞けないPythonでデータ分析」丸善出版pp. 178-208>で説明している。

 

min_sgm = 0.0                       #   lower boundary for sigma

max_sgm = np.amax(X) - np.amin(X)   #   upper boundary for sigma

 

C1C2 = np.percentile(X, [20, 80])

 

def f_init():

    """

                Initial values for pse(mu) and sigma

    """

    return dict(pse = (C1C2[0] + C1C2[1]) / 2, sgm = C1C2[1] - C1C2[0])

 

stan_data = {'N': N, 'X':X, 'Res': Res, 'min_sgm': min_sgm, 'max_sgm': max_sgm}

#

#       Sampling by Stan

#

sm = pystan.StanModel(file = 'PM2Cat.stan')

fit = sm.sampling(data = stan_data, pars = ['sgm', 'pse'],

                    init = f_init, n_jobs = 1)

 

サンプリングが終了すると、パラメータmu()の事後分布のグラフがMAP推定値、95 CIとともに表示される(図4)。グラフは、保存ボタンをクリックすると保存できる。

図4

 

フォーム右上のX印をクリックしてフォームを閉じると、次に、sigma)の事後分布のグラフが、MAP推定値と95 CIとともに表示される(図5)。

図5

 

図5のフォームを閉じると、次にPFとデータ値のグラフがMAP推定値に基づくPSEJNDの推定値とともに表示される(図6)。

図6

 

データ値は、次式

により、0あるいは1に変換した後、上下に少しランダムな変動を加えて、完全に重なって表示されることを防いでいる。

図6のフォームを閉じると、図7のフォームが表示される。

図7

 

データ(判断R=2の比率)をKDEで滑らかにした曲線(緑の破線)とパラメータPSEJNDの推定値に基づくPF曲線(青の曲線)が描かれている。

図7のフォームを閉じると、図8のようにw_kの現在値の表示と、次のw_k値の設定が求められる。

図8

 

このw_k値は、KDEの窓の大きさで、値が大きいと大まかな滑らかな曲線が得られるが、小さいと凹凸の激しい曲線が得られる。w_kの値として0以下の値(図8の場合は0)を設定すると、プログラムは終了する。

図9

 

プログラムの終了後、表示されている出力ファイルを開くと以下のようになっている。

Data file...Data2Cat.csv

 

Number of data = 100

    0 250.00    2

    1 230.00    2

    2 215.00    2

    3 200.00    2

    4 185.00    1

    5 200.00    1

   ・

   ・

   ・

   95 200.00    1

   96 215.00    2

   97 200.00    1

   98 215.00    1

   99 230.00    2

 

Summary...

Inference for Stan model: anon_model_c563ee5b69f9c9fbc61d408eed6fd018.

4 chains, each with iter=2000; warmup=1000; thin=1;

post-warmup draws per chain=1000, total post-warmup draws=4000.

 

       mean se_mean     sd   2.5%    25%    50%    75%  97.5%  n_eff   Rhat

sgm   18.57    0.12    4.9  12.14  15.29  17.61  20.74  30.68   1704    1.0

pse  203.05    0.06   2.71 197.63 201.33 203.03 204.77 208.27   2001    1.0

lp__ -48.87    0.03   1.14 -52.17 -49.26 -48.54  -48.1  -47.8   1135    1.0

 

Samples were drawn using NUTS at Wed Jul 10 13:35:13 2019.

For each parameter, n_eff is a crude measure of effective sample size,

and Rhat is the potential scale reduction factor on split chains (at

convergence, Rhat=1).

 

mu(MAP) = 203.094    sigma(MAP) = 15.718

 

PSE = 203.094,    95% CI = [197.63, 208.27]

JND = 10.60,    95% CI = [8.19, 20.67]

 

 

読み込まれたデータの出力後、ベイズ分析の統計量が出力されている。データが正しく読み込まれたかどうかの確認と、ベイズ分析の統計量を出力ファイルでも読むことができる。

 

引用文献

岡本安晴「いまさら聞けないPythonでデータ分析――多変量解析、ベイズ統計分析(PyStan、PyMC――」丸善出

岡本安晴(早期公開2019.6.21).多件法による上下法.基礎心理学研究、38巻、1号.

大山正(2005).第7章 精神物理学的測定法. 大山正・岩脇三良・宮埜嘉夫(共著).心理学研究法――データ収集・分析から論文作成まで――.サイエンス社.

 

 

 

付記 Pythonスクリプト

 

上下法データの分析を行うPythonスクリプトをリスト2およびリスト3として用意した。メインスクリプトがリスト2であり、リスト3のモジュールがimportされている。これらのファイルは、圧縮ファイルfiles2Cat.zipとしてまとめられているので、ダウンロードすれば解凍して自由に利用できる。Jupyter Notebook用も圧縮ファイルfiles2Cat_jn.zipとしてまとめた。

 

リスト2 リスト1のStanスクリプトによる上下法データのベイズ分析Pythonスクリプト.

import numpy as np

import matplotlib.pyplot as plt

import pystan

import pickle

import seaborn as sb

import csv

import scipy.optimize as sciopt

from my_module import *

 

fin_nm = input('Data file name (*.csv) = ')

fout_nm = input('\nOutput file name (*.txt) = ')

fout = open(fout_nm, 'w')

fout.write('Data file...{}\n'.format(fin_nm))

#

#       Read data from CSV file

#

with open(fin_nm, 'r') as fin:

    data = [v for v in csv.reader(fin)]

N = len(data) - 1

ID = []

X = np.empty(N, dtype = float)

Res = np.empty(N, dtype = int)

for t in range(N):

    ID.append(data[t + 1][0])

    X[t] = float(data[t + 1][1])

    Res[t] = int(data[t + 1][2])

 

print('Number of data = ', N)

fout.write('\nNumber of data = {}\n'.format(N))

for i in range(N):

    print('{0}  {1}  {2}'.format(ID[i], X[i], Res[i]))

 

for i, s, r in zip(ID, X, Res):

    fout.write('{0:>5s}{1:>7.2f}{2:>5d}\n'.format(i, s, r))

#

#       Boundaries and initial values

#

min_sgm = 0.0                       #   lower boundary for sigma

max_sgm = np.amax(X) - np.amin(X)   #   upper boundary for sigma

 

C1C2 = np.percentile(X, [20, 80])

 

def f_init():

    """

                Initial values for pse(mu) and sigma

    """

    return dict(pse = (C1C2[0] + C1C2[1]) / 2, sgm = C1C2[1] - C1C2[0])

 

stan_data = {'N': N, 'X':X, 'Res': Res, 'min_sgm': min_sgm, 'max_sgm': max_sgm}

#

#       Sampling by Stan

#

sm = pystan.StanModel(file = 'PM2Cat.stan')

 

fit = sm.sampling(data = stan_data, pars = ['sgm', 'pse'],

                    init = f_init, n_jobs = 1)

 

print(fit)

fout.write('\nSummary...\n{}\n'.format(fit))

 

SmplSgm = fit['sgm']        #   Samples from the posterior distribution for sigma

SmplPSE = fit['pse']        #   Samples from the posterior distribution for pse(mu)

#

#       Percentile points for posterior distributions

#

SgmL025 = np.percentile(SmplSgm, 2.5)

SgmMed = np.percentile(SmplSgm, 50)

SgmU975 = np.percentile(SmplSgm, 97.5)

PSEL025 = np.percentile(SmplPSE, 2.5)

PSEMed = np.percentile(SmplPSE, 50)

PSEU975 = np.percentile(SmplPSE, 97.5)

#

#       Calculate MAP estimates for mu(pse) and sigma

#

muMAP, sgmMAP = calcMAP(X, Res, PSEMed, SgmMed)     #   calcMAP from my_module.py

 

print('mu = ', muMAP, '    sgm = ', sgmMAP)

fout.write('\nmu(MAP) = {0:<.3f}    sigma(MAP) = {1:<.3f}\n'.format(muMAP, sgmMAP))

#

#       KDE plot for the samples from posterior distribution for PSE(mu)

#

sb.kdeplot(SmplPSE)

plt.plot([PSEL025, PSEU975], [0.0, 0.0], color = 'b', linewidth = 5, label = '95% CI')

plt.plot(muMAP, 0.0, 'go', markersize = 10, label = 'MAP')

plt.yticks([])

plt.xlabel('$\mu$', fontsize = 14)

plt.title('Posterior Distribution for $\mu$\n' +

          ('MAP = {0:<.2f}, 95% CI = [{1:<.2f}, {2:<.2f}]'.format(muMAP, PSEL025, PSEU975)),

          fontsize = 16)

plt.legend()

plt.tight_layout(h_pad = 5)

plt.show()

#

#       KDE plot for the samples from posterior distribution for sigma

#

sb.kdeplot(SmplSgm)

plt.plot([SgmL025, SgmU975], [0.0, 0.0], color = 'b', linewidth = 5, label = '95% CI')

plt.plot(sgmMAP, 0.0, 'go', markersize = 10, label = 'MAP')

plt.yticks([])

plt.xlabel('$\sigma$', fontsize = 14)

plt.title('Posterior Distribution for $\sigma$\n' +

          ('MAP = {0:<.2f}, 95% CI = [{1:<.2f}, {2:<.2f}]'.format(sgmMAP, SgmL025, SgmU975)),

          fontsize = 16)

plt.legend()

plt.tight_layout(h_pad = 5)

plt.show()

#

#       Prob(z < s75) = 0.75 for the standard normal distribution

#

s75 = bisect(f_phi, 0.75, 0.5, 1.0)             #   f_phi from my_module.py

print('s75 = ', s75, '     p = ', f_phi(s75))

JND = sgmMAP * s75

PSE = muMAP

fout.write('\nPSE = {0:<.3f},    95% CI = [{1:<.2f}, {2:<.2f}]'.format(

            PSE, PSEL025, PSEU975))

fout.write('\nJND = {0:<.2f},    95% CI = [{1:<.2f}, {2:<.2f}]'.format(

            JND, s75 * SgmL025, s75 * SgmU975))

#

#       Data and the psychometric function

#

pf = PF(muMAP, sgmMAP)          #   Class PF from my_module.py

minX = X[np.argmin(X)]

maxX = X[np.argmax(X)]

xvalues = []

yvalues = []

for i in range(1001):

    t = minX + i * (maxX - minX) / 1000.0

    xvalues.append(t)

    yvalues.append(pf.v(t))     #   Values of the psychometric function

plt.title('Psychometric Function\n' +

          ('PSE = {0:<.2f},  JND = {1:<.2f}'.format(PSE, JND)),

          fontsize = 16)

plt.plot(xvalues, yvalues, linewidth = 3, label = 'PF')

plt.yticks([0.0, 0.5, 0.75, 1.0])

#

#       Resposes are scaled to values from 0 to 1, and added small fluctuations

#

Res_rn = (Res - 1) + np.random.uniform(-0.05, 0.05, len(Res))  

plt.scatter(X, Res_rn, alpha = 0.5, color = 'g', label = 'Data')

plt.plot([minX, maxX], [0.0, 0.0], color = 'k')

plt.plot([minX, maxX], [1.0, 1.0], color = 'k')

plt.plot([minX, PSE, PSE], [0.5, 0.5, 0.0], color = 'k')

plt.plot([minX, PSE + JND, PSE + JND], [0.75, 0.75, 0.0], color = 'k')

plt.legend(loc = 7)

plt.xlabel('Stimulus value', fontsize = 12)

plt.ylabel('Probability', fontsize = 12)

plt.show()

 

#

#       Observed responses smoothed by Kernel density estimation

#

w_1 = np.empty(len(Res))        #   Weights for Response >= 2

for t in range(len(Res)):

    w_1[t] = 0 if Res[t] < 2 else 1

 

X_range = np.arange(minX, maxX, (maxX - minX) / 100)

w_k = JND / 2

 

while True:

    pm_1 = []           #   KDE values for Response >= 2

    for v in X_range:

        pm_1.append(k_ave(v, X, w_1, w_k))

       

    plt.title('Observed and Estimated PFs\n' +

              ('PSE = {0:<.2f},  JND = {1:<.2f}'.format(PSE, JND)),

              fontsize = 16)

    plt.plot(xvalues, yvalues, color = 'b', linewidth = 3, label = 'PF')

    plt.yticks([0.0, 0.5, 1.0])

    plt.plot(X_range, pm_1, 'g--', linewidth = 2, label = 'Res(>1)')

    plt.xlabel('Stimulus value', fontsize = 12)

    plt.legend(loc = 'upper left')

    plt.show()

    print('\nThe current value of w_k = {}'.format(w_k))

    w_k = float(input('The next value of w_k = '))

    if w_k <= 0.0:

        break

 

fout.close()

 

print('\nOutput file {} was saved.'.format(fout_nm))

 

 

リスト2で用いられているモジュールファイルmy_module.pyをリスト3に示す。これらは、圧縮ファイルfiles2Cat.zipにまとめられている。Jupyter Notebook用は圧縮ファイルfiles2Cat_jn.zipとして用意した。

 

リスト3 モジュールファイルmy_module.py

import numpy as np

import scipy

 

f_phi = scipy.stats.norm.cdf    #   Cumulative standard normal distribution function

 

def my_log(x):

    """

            To avoid the error message with 0 values for log function

    """

    if x < 1.0e-323:

        return -744.0

    else:

        return np.log(x)

 

class LLog:

    """

            Negative log likelihood

    """

    def __init__(self, X, Res):

        self.N = len(X)

        self.X = X

        self.Res = Res

 

    def v(self, z):

        mu = z[0]

        sgm = z[1] ** 2         #       Squared to avoid negative values

        v = 0

        for t in range(self.N):

            if self.Res[t] == 2:

                v += -my_log(f_phi((self.X[t] - mu) / sgm))

            else:

                v += -my_log(1.0 - f_phi((self.X[t] - mu) / sgm))

        return v

 

def calcMAP(X, Res, PSEMed, SgmMed):

    """

            Calculation of MAP estimates

    """

 

    llog = LLog(X, Res)         #   Negative log likelihood

 

    z = np.empty(2)

    z[0] = PSEMed               # Initial value for pse(mu)

    z[1] = SgmMed ** 0.5        # Initila value for square root of sigma

 

 

    import scipy.optimize

 

    res = scipy.optimize.minimize(llog.v, z, method = 'Nelder-Mead',

                        options = {'xatol': 1.0e-7, 'fatol': 1.0e-12})

 

    muMAP = res.x[0]

    sgmMAP = res.x[1] ** 2

 

    return muMAP, sgmMAP

 

 

class PF:

    """

            Psychometric function with the parameters mu and sigma

    """

    def __init__(self, mu, sgm):

        self.mu = mu

        self.sgm = sgm

 

    def v(self, x):

        return f_phi((x - self.mu) / self.sgm)

 

 

def bisect( f, c, Lb, Ub ):

    """

            Return the value root so that f(root) = c

    """

    s = 1.0 if f(Ub) > f(Lb) else -1.0      #   s = 1 when f(x) is an increasing functin

    while ((abs(Ub - Lb) > (1.0e-15) * (abs(Ub) + abs(Lb)))

        and

          (abs(Ub) + abs(Lb) > 1.0e-14)):

              m = 0.5 * (Ub + Lb)

              if (s * (f(m) - c)) > 0.0:

                  Ub = m

              else:

                  Lb = m

 

    return 0.5 * (Ub + Lb)

 

 

 

def my_kernel(x, y, w_k):

    return np.exp(-((x - y) / w_k) ** 2)

 

 

def k_ave(v, X, W, w_k):

    n = len(X)

    num = 0.0

    den = 0.0

    for i in range(n):

        k_v = my_kernel(v, X[i], w_k)

        num += W[i] * k_v

        den += k_v

    return num / den

 

 

 

Up