🐥

項目反応理論で使われる2パラメータプロビットモデルのためのギブスサンプラーの実装例

2024/02/03に公開

Albert, J. H. (1992). Bayesian estimation of normal ogive item response curves using Gibbs sampling. Journal of educational statistics, 17(3), 251-269. https://www.jstor.org/stable/1165149 を参考にしていますがパラメータの置き方だけちょっといじっています.

モデルと分析対象

正解, 不正解の2値に明確に分類できるような試験問題を考えます.

被験者 ij 問目への回答の正解, 不正解を変数 y_{i,j} で表します(1が正解, 0が不正解).

この記事で扱う2パラメータ正規累積モデル(2PL normal ogive model)では, 被験者 i (i=1,\ldots,n) の j (j=1,\ldots,m) 問目に正解する確率を,

p_{i,j}=\Phi(\alpha_j(\theta_i-\gamma_j);0,1)

とモデル化します. \Phi(x;\mu,\sigma^2) は平均 \mu, 分散 \sigma^2 の正規分布の分布関数を表す記号で, \Phi(x;0,1) は標準正規分布の累積分布関数です.

\theta_i は大きいほどy_{i,j}=1が出やすくなるので能力パラメータと呼ばれます.

\gamma_i は大きいほどy_{i,j}=10が出やすくなるので問題の難しさを表すと解釈されます.

\alpha_j は識別パラメータとよばれます.

潜在変数を用いた解釈

テストの背後には被験者iが問題jごとに発揮するパフォーマンスみたいな潜在変数 Z_{i,j} があり, それが平均 \theta_i-\gamma_j の正規分布に従うとします(これはモデル内の話であって人間の能力が現実に正規分布していると信じるわけではないです).

パフォーマンスが閾値0を超えた場合に正解にたどり着き, 0以下のとき誤答すると考えると,

正解の確率は,

\Phi(0;\theta_i-\gamma_j,1/\alpha_j)

不正解の確率は,

\{1-\Phi(0;\theta_i-\gamma_j,1/\alpha_j)\}

となり, 正規累積モデルの尤度と一致します.

識別パラメータ \alpha_j は分散を決めるパラメータでが大きいほどパフォーマンスを精度よく反映する, つまり\theta_iが小さいのに偶然正解しちゃうことや\theta_iが大きいのに運悪く誤答することがすくない問題ということで, 問題を評価するのに使います.

ギブスサンプリング

ここから \theta_i の事前分布に, 平均 0, 分散 1 の正規分布, \gamma_j の事前分布にも平均 0, 分散 1 の正規分布, \alpha_j の事前分布にパラメータ 1, 1 のガンマ分布を仮定してパラメータを推定します.

事後分布の実現にギブスサンプリングを使います. ギブスサンプリングそのものについては別の文献を参照してください(例えば須山『ベイズ推論による機械学習入門』).

ここではギブスサンプリングのための full-conditional distribution だけ書きます.

潜在変数 Z_{i,j}=z_{i,j}\alpha_j, \gamma_j が得られたときの \theta_i の事後分布は次に比例します. パラメータ \theta_i に依存しない因子は無視するのがコツです.

P(\theta_i|\theta_i 以外) \propto \exp\left(-\frac{\theta_i^2 -2\theta_i\{\sum_{j=1}^{m}\alpha_j(\gamma_j+z_{i,j})\}/(\sum_{j=1}^m\alpha_j+1)}{2/(\sum_{j=1}^m\alpha_j+1)}\right)

これは正規分布の密度関数です.

\alpha_j, \theta_i が得られたときの \gamma_j の事後分布は次に比例します.

P(\gamma_j|\gamma_j 以外) \propto \exp\left(-\frac{(\gamma_j^2-2\gamma_j \alpha_j \sum_i^n(\theta_i-z_{i,j})/(\alpha_j (n+1) )}{2/(\alpha_j (n+1))}\right) ]

これは正規分布の密度関数です.

\theta_i, \gamma_j が得られたときの \alpha_j の事後分布は以下に比例します.

P(\alpha_j|\alpha_j以外) \propto \alpha_j ^{-n/2+1}\exp\left(-\alpha_j \frac{\sum_{i=1}^n( z_{i,j}-(\theta_i-\gamma_j))^2+2}{2}\right)

これはガンマ分布の密度関数です.

シミュレーション

モデルと同じ設定でデータを作ってちゃんと推定できるか確かめてみます.

100人に100問, 50人に100問, 100人に50問, 50人に50問と4通りの設定を試しました.

点が事後分布の平均, 縦の棒は95%信頼(信用)区間です.

ぴったり一致したときは図の対角線上に点が並びます.

コード

#include <Rcpp.h>
using namespace Rcpp;
static const double t4 = 0.45;
/* Exponential rejection sampling (a,inf) */
double ers_a_inf(const double & a) {
  double ainv = 1.0 / a;
  double x;
  double rho;
  do {
    x = R::rexp(ainv) + a; /* rexp works with 1/lambda */
rho = exp(-0.5 * pow((x - a), 2));
  } while (R::runif(0, 1) > rho);
  return x;
}

/* Normal rejection sampling (a,inf) */
double nrs_a_inf(const double & a) {
  double x = -DBL_MAX;
  while (x < a) {
    x = R::rnorm(0, 1);
  }
  return x;
}

double rhalf_norm(const double & mean, const double & sd) {
  const double alpha = (- mean) / sd;
  if (alpha < t4) {
    return mean + sd * nrs_a_inf(alpha);
  } else {
    return mean + sd * ers_a_inf(alpha);
  }
}

/*
double rnorm_u (double eta, double sigma){
  return R::qnorm(R::runif(R::pnorm(0,eta,sigma,true,false),1),eta,sigma,true,false);
}

double rnorm_l (double eta, double sigma){
  return R::qnorm(R::runif(0,R::pnorm(0,eta,sigma,true,false)),eta,sigma,true,false);
}
*/

// [[Rcpp::export]]
List Gibbs2PLNO(int iter, IntegerMatrix y){
  int n;
  int m;
  n = y.nrow();
  m = y.ncol();
  NumericMatrix theta(iter,n);
  NumericMatrix gamma(iter,m);
  NumericMatrix alpha(iter,m);
  alpha(0,_) = rgamma(m,1,1);
  NumericMatrix Z(n,m);
  for(int k=1; k<iter; k++){
    for(int i=0; i<n; i++){
      for(int j=0; j<m; j++){
        if(y(i,j)==1){
          Z(i,j) = rhalf_norm((theta(k-1,i)-gamma(k-1,j)),1/sqrt(alpha(k-1,j)));
        }else{
          Z(i,j) = -rhalf_norm(-(theta(k-1,i)-gamma(k-1,j)),1/sqrt(alpha(k-1,j)));
        }
      }
    }
    for(int j=0; j<m; j++){
      alpha(k,j) = R::rgamma(n/2.0+1,2.0/(sum(pow(Z(_,j)-(theta(k-1,_)-gamma(k-1,j)),2))+2));
      gamma(k,j) = R::rnorm(sum(alpha(k,j)*(theta(k-1,_)-Z(_,j)))/(n*alpha(k,j)+1),1/sqrt(n*alpha(k,j)+1));
    }
    for(int i=0; i<n; i++){
      theta(k,i) = R::rnorm(sum(alpha(k,_)*(Z(i,_)+gamma(k,_)))/(sum(alpha(k,_))+1),1/sqrt(sum(alpha(k,_))+1));
    }
  }
  return List::create(Named("ability") = theta, _["item"]=gamma, _["alpha"]=alpha);
}

library(Rcpp)
library(parallel)

sourceCpp("albert1992.cpp")

Rhat <- function(samples_l){
  samples <- simplify2array(samples_l)
  term2 <-apply(apply(samples, 2:3, mean),1,var)
  varW <-apply(apply(samples, 2:3, var),1,mean)
  n.r <-nrow(samples)
  var1 <-((n.r-1)/n.r)*varW+term2
  sqrt(var1/varW)
}

quantile_mat <- function(x, probs=c(0.025, 0.975)){
  t(apply(x[-burnin,,], 2, quantile, probs=probs))
}

compscattter <- function(true, estimates, intervals){
  ran1 <-range(true, estimates, intervals)
  plot(true, estimates, xlim=ran1, ylim=ran1, pch=16)
  segments(true, intervals[,1], true, intervals[,2])
  abline(0,1,lty=2, col="steelblue")
}

randbmat <- function(n,m){
  theta <- rnorm(n,0,1)
  gamma <- rnorm(m,0,1)
  alpha <- rgamma(m,1,1)
  y <- matrix(NA,n,m)
  for(i in 1:n){
    for(j in 1:m){
      y[i,j] <- rbinom(1,1,pnorm(alpha[j]*(theta[i]-gamma[j])))
    }
  }
  return(list(Y=y, theta=theta, gamma=gamma, alpha=alpha))
}

###
cond <- matrix(c(50, 50,
                 50, 100,
                 100, 50,
                 100, 100), byrow=TRUE, nrow = 4)

for(i in 1:4){
  dat1 <- randbmat(cond[i,1], cond[i,2])
  burnin <- 1:1000
  fitlist <-mclapply(1:4,function(i){
    Gibbs2PLNO(4000,dat1$Y)
  },mc.cores = detectCores())
  
  th <- simplify2array(lapply(fitlist, function(x)x$ability))
  ga <- simplify2array(lapply(fitlist, function(x)x$item))
  al <- simplify2array(lapply(fitlist, function(x)x$alpha))
  
  #matplot(th[,2,], type = "l", lty=1, col=hcl.colors(4, alpha = 0.7))
  
  print(all(Rhat(th[-burnin,,])<1.1))
  print(all(Rhat(ga[-burnin,,])<1.1))
  print(all(Rhat(al[-burnin,,])<1.1))
  
  hattheta <-apply(th[-burnin,,],2,mean)
  hatgamma <-apply(ga[-burnin,,],2,mean)
  hatalpha <-apply(al[-burnin,,],2,mean)
  
  cobntext <- paste0("(n=", cond[i,1], ", m=", cond[i, 2], ")")
  
  png(paste0("abil_",i,".png"))
  compscattter(dat1$theta,
               apply(th[-burnin,,],2,mean),
               quantile_mat(th[-burnin,,]))
  title(paste("ability", cobntext))
  dev.off()
  
  png(paste0("diff_",i,".png"))
  compscattter(dat1$ga,
               apply(ga[-burnin,,],2,mean),
               quantile_mat(ga[-burnin,,]))
  title(paste("difficulty",cobntext))
  dev.off()
  
  png(paste0("disc_",i,".png"))
  compscattter(dat1$alpha,
               apply(al[-burnin,,],2,mean),
               quantile_mat(al[-burnin,,]))
  title(paste("discrimination", cobntext))
  dev.off()
}

補足

Albert (1992) では

p_{i,j}=\Phi(\alpha_j\theta_i-\gamma_j;0,1)

としていますがギブスサンプリングに使う条件付き分布の導出がちょっとだけ面倒になるので変えました.

Discussion