項目反応理論#

モデル#

i番目の被験者のj番目の項目の値yijが二値{0,1}であるとする(例えば正解・不正解だったり、アンケートの「あてはまる」「あてはまらない」という2件法など)。

yijの背後には潜在的な能力の連続量θiRが存在し、θiが閾値bjを超えていたら1、超えていなければ0が観測されるとする。つまりyijが以下のように決まるとする。

yij={0 if θi<bj1 if θibj

正規累積モデル#

1パラメータ正規累積モデル#

しかし、実際には被験者iの体調や運(たまたま正解できた)などにより、常にこのようにきれいに正解・不正解が決まるわけではないと考えられる。こうした誤差を表すパラメータεijN(0,σε2)も追加して

yij={0 if (θiεij)<bj1 if (θiεij)bj

とする。誤差が確率変数のため、yijのとる値も確率変数として考えることができるようになる。bjを移項すると

yij={0 if (θiεijbj)<01 if (θiεijbj)0

となる。θiεijbjN(θibj,σε2)である。 εijを移項すれば

yij={0 if (θibj)<εij1 if (θibj)εij

でもあるので「yij=1となるのは誤差εijθibj以下のとき」とわかる。

仮にεijが標準正規分布(σε2=1の正規分布)に従うならば、特性値θiの人が項目jに当てはまると回答する確率は

P(yij=1)=P(εijθibj)=(θibj)12πexp(z22)dz

となる(最後のは、εijが従う標準正規分布のうち から θibj までの範囲の面積がP(εijθibj)ということ)。

Hide code cell source
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm
epsilon = np.linspace(-4, 4, 1000)
y = norm.pdf(epsilon)
fig, ax = plt.subplots(figsize=[4,2])
ax.plot(epsilon, y, 'k-')

c = 1

ax.fill_between(epsilon, y, where=(epsilon < c), color='blue', alpha=0.3, label=r'$P(y_{ij} = 1)$')
ax.fill_between(epsilon, y, where=(epsilon >= c), color='red', alpha=0.3, label=r'$P(y_{ij} = 0)$')
ax.axvline(x=c, color='grey', linestyle='--', label=r'$\theta_i - b_j$')
ax.set(
    title=r'$\varepsilon_{ij} \sim N(0, 1)$',
    xlabel=r"$\varepsilon_{ij}$",
    ylabel="density"
)
ax.legend()
ax.grid(True)
fig.show()
../../_images/5712ae734db6153a9ef016638910c03d75dc897c651cfabd2859064dce5946d8.png

2パラメータ正規累積モデル#

σε2が項目ごとに異なる場合を考える。σε2=1/ajとすると、誤差の確率分布は

εijN(0,1aj)

となる。両辺をaj倍すると、ajεijN(0,1)と表すことができ、引き続き標準正規分布を使うことができる。そのためモデルはaiが追加され

P(yij=1)=P(ajεijθibj)=aj(θibj)12πexp(z22)dz

となる。

パラメータの意味

bjが大きくなるとθibjの値は小さくなり、P(yij=1)の面積が小さくなる。yij=1が正解を表しているとするなら、正答率が低くなる方向に作用する。そのためbj項目困難度(item difficulty) と呼ばれる。

またajは値が大きくなるとεijの分散を下げて分布がより尖っていく。また横軸にθibj、縦軸にP(yij=1)のグラフを書くとき、この曲線の傾きを急にして、θiが低い人と高い人の間でP(yij=1)の変化を大きくする。そのためaj項目識別力(item discrimination) と呼ばれる。

Hide code cell source
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm
x = np.linspace(-4, 4, 1000)

fig, axes = plt.subplots(figsize=[8, 2], ncols=2)


a = 1
for beta in [-1, 0, 1]:
    axes[0].plot(x, norm.cdf(x, loc=beta, scale=1/a), label=r"$b$ = " + f"{beta}")
axes[0].set(xlabel=r"$\theta_i$", ylabel=r"$P(y_{ij} = 1)$", xticklabels=[], yticklabels=[])
axes[0].legend()
axes[0].grid(True)

beta = 0
for a in [0.5, 1, 2]:
    axes[1].plot(x, norm.cdf(x, loc=beta, scale=1/a), label=r"$a$ = " + f"{a}")
axes[1].set(xlabel=r"$\theta_i$", ylabel=r"$P(y_{ij} = 1)$", xticklabels=[], yticklabels=[])
axes[1].legend()
axes[1].grid(True)

fig.show()
../../_images/4f65c10845499fb139dd77b2cfd323f871e6bf58b933a5933fe5bcbcdd7ec42f.png

なお横軸にθi、縦軸にP(yij=1)をとったグラフは 項目特性曲線 (item characteristic curve: ICC) と呼ばれる。

ロジスティックモデル#

正規累積モデルはプロビット回帰と同様のことをするので、コンピュータで積分計算をするのがやや難しいという問題がある。そこでロジスティック分布に置き換えたものが使われる。

ロジスティック分布の確率密度関数と累積分布関数は

f(x)=exp(x)[1+exp(x)]2,F(x)=11+exp(x)

となる。とくにxを約1.7倍したロジスティック分布は累積分布関数が正規分布と非常に近くなることが知られている。

Hide code cell source
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm, logistic
x = np.linspace(-10, 10, 1000)

fig, axes = plt.subplots(figsize=[12, 3], dpi=90, ncols=2)
ax = axes[0]
ax.plot(x, norm.pdf(x), 'k-', label='Normal Distribution')
ax.plot(x, logistic.pdf(x),  'orange', alpha=0.9, label='Logistic Distribution')

ax.set_title('Normal Distribution vs Logistic Distribution')
ax.set_xlabel('x')
ax.set_ylabel('Probability Density')
ax.legend()
ax.grid(True)

ax = axes[1]
ax.plot(x, norm.cdf(x), 'k-', label=r'CDF of $Normal(x)$')
ax.plot(x, logistic.cdf(x), 'orange', alpha=0.9, label=r'CDF of $Logistic(x)$')
ax.plot(x, logistic.cdf(x * 1.704), 'red', alpha=0.9, label=r'CDF of $Logistic(1.7 x)$')
ax.set_title('Normal Distribution vs Logistic Distribution (CDF)')
ax.set_xlabel('x')
ax.set_ylabel('Probability')
ax.legend()
ax.grid(True)
plt.show()
../../_images/a8b9eefa5477841feff8c8ca72c4e81d329ffb9c9400cf4020a36c296e53fbec.png

2PLモデル#

正規分布の代わりにロジスティック分布を使った 2パラメータロジスティック(2PL)モデル は以下のように表される。

2PLモデル

P(yij=1)=11+exp(1.7aj(θibj))
  • aj:項目識別力

  • bj:項目困難度

なお1.7は正規累積モデルに近づけるための定数なので、正規累積モデルと比較する必要がなければ不要。

3PLモデル#

例えば4択問題では、正解がわからなくて適当に選んだとしても1/4は当たることになる。こうした影響を「当て推量」パラメータcjとして取り入れたモデル。

3PLモデル

P(yij=1)=cj+1cj1exp(aj(θibj))
  • aj:項目識別力

  • bj:項目困難度

  • cj:当て推量

cjは項目特性曲線の下限となる。θiがどんなに低い人でも必ずcj以上のP(yij=1)になるということ。

4PLモデル#

項目特性曲線の上限を表すパラメータdjを追加したもの。θiがどんなに高い人でも100%の正答率にはできない高難度な状況(運ゲー)を想定したモデル。

4PLモデル

P(yij=1)=cj+djcj1exp(aj(θibj))
  • aj:項目識別力

  • bj:項目困難度

  • cj:当て推量。項目特性曲線の下限

  • dj:項目特性曲線の上限

5PLモデル#

「非対称性」のパラメータejを追加したもの。4PLまでは項目特性曲線の動き方が0.5を中心に対称になっている。5PLでは「最初はθiがあがるほど急激にP(yij=1)が上がるが、徐々に上がりにくくなる」などの状況を表すことができる。

5PLモデル

P(yij=1)=cj+djcj[1exp(aj(θibj))]ej
  • aj:項目識別力

  • bj:項目困難度

  • cj:当て推量。項目特性曲線の下限

  • dj:項目特性曲線の上限

  • ej:非対称性

因子分析と2P正規累積モデルは等価

因子分析と2パラメータ正規累積モデルは数学的に等価であると知られている。標準化していない(切片が0でない)1因子モデルは、

yij=τj+ajfiεij(εijN(0,σε))

となる(IRTの説明に合わせて誤差の符号をマイナスにしている)

カテゴリカル因子分析では離散的な観測変数yijはその背後にある連続量によって決まる、という考え方をするため、IRTの冒頭の説明と同じ。

標準正規分布に従う誤差εijτj+ajfiより小さいときにyij=1となるため、その確率P(yij=1)

P(yij=1)=P(εijτj+ajfi)=(τj+ajfi)12πexp(z22)dz

となる。2つのモデルのパラメータを(fi,aj,τj)=(θi,aj,ajbj)と対応させると

τj+ajfi=aj(fi+τjaj)=aj(θibi)

となる。

多値型モデル#

項目の反応yijが多値になった場合のモデルも存在する。

段階反応モデル#

段階反応モデル(graded response model: GRM) は複数の二値IRTモデルを組み合わせて多値反応を表現する。

回答者iの項目jに対する回答yij=k(k=1,2,,K)について、「k以上のカテゴリを選ぶ確率」を考えると、これはまだ「k未満 or k以上」の二値なので2PLなどで表せる。例えば以下のようになる。

P(yijk)=11+exp(aj(θibjk))

なお、困難度は項目jのカテゴリkごとに用意されるためbjkに変更している。

このモデルを組み合わせると、「ちょうどk番目のカテゴリを選ぶ確率」は

P(yij=k)=P(yijk)P(yijk+1)

と表すことができる。ただし端のカテゴリはP(yij1)=1,P(yijK+1)=0とする。また確率100%の困難度は低くて当然なのでbj1=とする。

段階反応モデル

P(yij=k)=11+exp(aj(θibjk))11+exp(aj(θibjk+1))

名義反応モデル#

名義反応モデル(nominal response model: NRM) も段階反応モデルと同様に多値の回答にIRTを適用したモデル。softmax関数のような形で多値化する。

P(yij=k)=exp(ajkθi+γjk)κ=1Kexp(ajκθi+γjκ)

連続反応モデル#

連続反応モデル(Continuous Response Model, CRM)は連続的な反応(例:時間、強度、割合)を扱うことができる。

CRMは段階反応モデル(GRM)をさらに多段階に拡張していって連続値を扱う。

受験者iが項目jにおいてxj点以上をとる確率は、次のようにロジスティック関数で表すことができる

pi(xj)=11+exp(aj(θibxj))

これを使うと、受験者iが項目jにおいてxj点をとる確率 pi(xj) を次のように定義できる

pi(xj)=limΔxj0pi(xj)pi(xj+Δxj)Δxj

bxjは項目jにおいてxj以上の点を取る困難度で、ロジスティック関数の逆関数を用いて以下のように定義される

bxj=βj+1αjlogxjKjxj

ここでαj,βjはロジスティック関数の逆関数における識別力と困難度を表現するパラメータ、Kjxjのとる最大値(xj[0,Kj]

適合度の評価#

局所独立性の確認#

  • χ2統計量

個人適合度#

θiが高い人なのに困難度が低い項目で間違えるのはおかしい」といった考え方から、特性θiと困難度bjの関係性を見る。

  • zhlz)統計量 :ある回答者の反応パターンyi=(yi1,yi2,,yiJ) と「考えられる全反応パターン」での尤度を比較する

IRTの前提条件・仮定#

局所独立性(local independence)の仮定#

θiで条件づけたとき、項目jと項目ljl) への回答は完全に独立であるという仮定。最尤推定の計算の簡易化のために必要になる。

仮定を満たすか確認する方法:

  • 例えばデータからθiが特定の値の人を集めて(θiで条件づけたデータを用意して)、項目j,lの回答のクロス表を作ったとき、関連性がない場合は局所独立性が満たされていることがわかる。

一次元性#

もともとのIRTは単一のテストに対する単一の能力を測定することが主たる目的のため、推定する因子数は1である(因子数を拡張したモデルも存在するが、基本は1)。そのため、すべての項目が同じ能力・特性のみを反映していることが必要となる。

仮定を満たすか確認する方法:

  • 例えば、1因子の確認的因子分析を行い、適合度指標(RMSEAやCFA、AGFIなど)が許容範囲内かどうかで確認する。

  • DIMTEST(Stout et al., 1996)や DETECT(Zhang & Stout, 1999)といった手法もある

PythonでのIRTの実装#

一般的なモデル → pyirtなどパッケージがある

複雑な、独自のモデル → PyStanやPyMCでベイズ推定するのが良さそう

2PL#

データの生成#

Hide code cell source
# ダミーデータの生成
# 出所: https://qiita.com/takuyakubo/items/43d56725952e67032b49
import numpy as np
from functools import partial

# 3 parameter logistic model の定義
def L3P(a, b, c, x):
    return c + (1 - c) / (1 + np.exp(-  a * (x - b)))

# model parameterの定義
# aは正の実数, bは実数, cは0より大きく1未満であれば良い
a_min = 0.3
a_max = 1

b_min = -2
b_max = 2

c_min = 0
c_max = .4

# 問題数と回答者数
num_items = 10
num_users = 1000

# 問題parameterの生成
item_params = np.array(
    [np.random.uniform(a_min, a_max, num_items),
     np.random.uniform(b_min, b_max, num_items),
     np.random.uniform(c_min, c_max, num_items)]
).T

# 受験者parameterの生成
user_params = np.random.normal(size=num_users)

# 項目反応行列の作成、 要素は1(正答)か0(誤答)
# i行j列は問iに受験者jがどう反応したか
ir_matrix_ij = np.vectorize(int)(
    np.array([partial(L3P, *ip)(user_params) > np.random.uniform(0, 1, num_users) for ip in item_params])
)

import pandas as pd

df = pd.DataFrame(ir_matrix_ij.T,
                  index=[f"user_{i+1}" for i in range(num_users)],
                  columns=[f"question_{j+1}" for j in range(num_items)])

df.head()
question_1 question_2 question_3 question_4 question_5 question_6 question_7 question_8 question_9 question_10
user_1 0 1 0 1 1 1 0 0 1 1
user_2 1 0 1 0 1 0 1 1 1 1
user_3 0 0 0 0 0 0 1 0 1 0
user_4 1 1 0 0 1 0 0 0 1 1
user_5 1 0 1 0 1 0 0 0 1 1
# 縦持ちへ変換
df_long = pd.melt(
    df.reset_index(),
    id_vars="index",
    var_name="item",
    value_name="response",
).rename(columns={"index": "user"})
df_long.head()
user item response
0 user_1 question_1 0
1 user_2 question_1 1
2 user_3 question_1 0
3 user_4 question_1 1
4 user_5 question_1 1

モデルの定義#

# indexと値の取得
user_idx, users = pd.factorize(df_long["user"])
item_idx, items = pd.factorize(df_long["item"])
responses = df_long["response"].to_numpy()

import pymc as pm
model = pm.Model(coords={"user": df.index, "item": df.columns})
with model:
    # 観測値の配列
    response_obs = pm.ConstantData("responses", responses)

    # 2PLM
    theta = pm.Normal("theta", mu=0.0, sigma=1.0, dims="user")
    a = pm.HalfNormal("a", sigma=1.0, dims="item")
    beta = pm.Normal("beta", mu=0.0, sigma=1.0, dims="item")
    logit_p = pm.Deterministic("logit_p", a[item_idx] * (theta[user_idx] - beta[item_idx]))

    # ベルヌーイ分布(pではなくlogit_pの引数に渡すことでシグモイド関数の計算をpymc側にまかせている)
    obs = pm.Bernoulli("obs", logit_p=logit_p, observed=response_obs)

g = pm.model_to_graphviz(model)
g
/usr/local/lib/python3.10/site-packages/pymc/data.py:235: FutureWarning: ConstantData is deprecated. All Data variables are now mutable. Use Data instead.
  warnings.warn(
../../_images/3b62609ef72341dabf3e0c4e9d707638110538d74d1c3b2c97bc136e836196be.svg

推定#

%%time
with model:
    idata = pm.sample(random_seed=0)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [theta, a, beta]

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
File <timed exec>:2

File /usr/local/lib/python3.10/site-packages/pymc/sampling/mcmc.py:964, in sample(draws, tune, chains, cores, random_seed, progressbar, progressbar_theme, step, var_names, nuts_sampler, initvals, init, jitter_max_retries, n_init, trace, discard_tuned_samples, compute_convergence_checks, keep_warning_stat, return_inferencedata, idata_kwargs, nuts_sampler_kwargs, callback, mp_ctx, blas_cores, model, compile_kwargs, **kwargs)
    960 t_sampling = time.time() - t_start
    962 # Packaging, validating and returning the result was extracted
    963 # into a function to make it easier to test and refactor.
--> 964 return _sample_return(
    965     run=run,
    966     traces=trace if isinstance(trace, ZarrTrace) else traces,
    967     tune=tune,
    968     t_sampling=t_sampling,
    969     discard_tuned_samples=discard_tuned_samples,
    970     compute_convergence_checks=compute_convergence_checks,
    971     return_inferencedata=return_inferencedata,
    972     keep_warning_stat=keep_warning_stat,
    973     idata_kwargs=idata_kwargs or {},
    974     model=model,
    975 )

File /usr/local/lib/python3.10/site-packages/pymc/sampling/mcmc.py:1049, in _sample_return(run, traces, tune, t_sampling, discard_tuned_samples, compute_convergence_checks, return_inferencedata, keep_warning_stat, idata_kwargs, model)
   1047 # Pick and slice chains to keep the maximum number of samples
   1048 if discard_tuned_samples:
-> 1049     traces, length = _choose_chains(traces, tune)
   1050 else:
   1051     traces, length = _choose_chains(traces, 0)

File /usr/local/lib/python3.10/site-packages/pymc/backends/base.py:624, in _choose_chains(traces, tune)
    622 lengths = [max(0, len(trace) - tune) for trace in traces]
    623 if not sum(lengths):
--> 624     raise ValueError("Not enough samples to build a trace.")
    626 idxs = np.argsort(lengths)
    627 l_sort = np.array(lengths)[idxs]

ValueError: Not enough samples to build a trace.
idata
arviz.InferenceData
    • <xarray.Dataset> Size: 353MB
      Dimensions:        (chain: 4, draw: 1000, user: 1000, item: 10,
                          logit_p_dim_0: 10000)
      Coordinates:
        * chain          (chain) int64 32B 0 1 2 3
        * draw           (draw) int64 8kB 0 1 2 3 4 5 6 ... 994 995 996 997 998 999
        * user           (user) <U9 36kB 'user_1' 'user_2' ... 'user_999' 'user_1000'
        * item           (item) <U11 440B 'question_1' 'question_2' ... 'question_10'
        * logit_p_dim_0  (logit_p_dim_0) int64 80kB 0 1 2 3 4 ... 9996 9997 9998 9999
      Data variables:
          theta          (chain, draw, user) float64 32MB 0.009313 0.2246 ... -0.2381
          beta           (chain, draw, item) float64 320kB -1.785 -0.8052 ... -1.193
          a              (chain, draw, item) float64 320kB 0.6816 0.1785 ... 0.2549
          logit_p        (chain, draw, logit_p_dim_0) float64 320MB 1.223 ... 0.2435
      Attributes:
          created_at:                 2025-03-03T08:40:11.496294+00:00
          arviz_version:              0.20.0
          inference_library:          pymc
          inference_library_version:  5.17.0
          sampling_time:              56.205814838409424
          tuning_steps:               1000

    • <xarray.Dataset> Size: 496kB
      Dimensions:                (chain: 4, draw: 1000)
      Coordinates:
        * chain                  (chain) int64 32B 0 1 2 3
        * draw                   (draw) int64 8kB 0 1 2 3 4 5 ... 995 996 997 998 999
      Data variables: (12/17)
          step_size_bar          (chain, draw) float64 32kB 0.2212 0.2212 ... 0.2179
          diverging              (chain, draw) bool 4kB False False ... False False
          energy                 (chain, draw) float64 32kB 8.004e+03 ... 8.084e+03
          perf_counter_start     (chain, draw) float64 32kB 1.098e+06 ... 1.098e+06
          index_in_trajectory    (chain, draw) int64 32kB 8 5 -4 12 -10 ... -5 13 9 -7
          max_energy_error       (chain, draw) float64 32kB 2.905 0.5263 ... 0.432
          ...                     ...
          acceptance_rate        (chain, draw) float64 32kB 0.3001 0.8956 ... 0.8464
          perf_counter_diff      (chain, draw) float64 32kB 0.01731 ... 0.01731
          n_steps                (chain, draw) float64 32kB 15.0 15.0 ... 15.0 15.0
          energy_error           (chain, draw) float64 32kB 0.2613 -0.1657 ... 0.1819
          process_time_diff      (chain, draw) float64 32kB 0.01732 ... 0.01731
          step_size              (chain, draw) float64 32kB 0.1867 0.1867 ... 0.2182
      Attributes:
          created_at:                 2025-03-03T08:40:11.515943+00:00
          arviz_version:              0.20.0
          inference_library:          pymc
          inference_library_version:  5.17.0
          sampling_time:              56.205814838409424
          tuning_steps:               1000

    • <xarray.Dataset> Size: 160kB
      Dimensions:    (obs_dim_0: 10000)
      Coordinates:
        * obs_dim_0  (obs_dim_0) int64 80kB 0 1 2 3 4 5 ... 9995 9996 9997 9998 9999
      Data variables:
          obs        (obs_dim_0) int64 80kB 1 1 0 0 1 1 1 1 0 1 ... 1 1 0 1 0 1 0 1 0
      Attributes:
          created_at:                 2025-03-03T08:40:11.525280+00:00
          arviz_version:              0.20.0
          inference_library:          pymc
          inference_library_version:  5.17.0

    • <xarray.Dataset> Size: 120kB
      Dimensions:          (responses_dim_0: 10000)
      Coordinates:
        * responses_dim_0  (responses_dim_0) int64 80kB 0 1 2 3 ... 9997 9998 9999
      Data variables:
          responses        (responses_dim_0) int32 40kB 1 1 0 0 1 1 1 ... 1 0 1 0 1 0
      Attributes:
          created_at:                 2025-03-03T08:40:11.527285+00:00
          arviz_version:              0.20.0
          inference_library:          pymc
          inference_library_version:  5.17.0

一部の項目のaj,bj

Hide code cell source
import arviz as az

query = {"item": ["question_1", "question_2"]}

az.plot_trace(idata, coords=query, var_names=["a", "beta"], figsize=[4, 4])
plt.tight_layout()
plt.show()
../../_images/847d3dc8c7accadbbb682ec47ac405b7c071f600e5278f78abf25459e4a09afc.png

一部の回答者のθi

Hide code cell source
import arviz as az

query = {"user": ["user_1", "user_2"]}

az.plot_trace(idata, coords=query, var_names=["theta"], figsize=[4, 2])
plt.tight_layout()
plt.show()
../../_images/5c21829b51ba0494adc1c777272e996fc9c6c1370c41132a44abb67a4b52a643.png

参考#