項目反応理論#
モデル#
正規累積モデル#
1パラメータ正規累積モデル#
しかし、実際には被験者
とする。誤差が確率変数のため、
となる。
でもあるので「
仮に
となる(最後のは、
Show 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()

2パラメータ正規累積モデル#
となる。両辺を
となる。
パラメータの意味
また
Show 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()

なお横軸に
ロジスティックモデル#
正規累積モデルはプロビット回帰と同様のことをするので、コンピュータで積分計算をするのがやや難しいという問題がある。そこでロジスティック分布に置き換えたものが使われる。
ロジスティック分布の確率密度関数と累積分布関数は
となる。とくに
Show 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()

2PLモデル#
正規分布の代わりにロジスティック分布を使った 2パラメータロジスティック(2PL)モデル は以下のように表される。
2PLモデル
:項目識別力 :項目困難度
なお1.7は正規累積モデルに近づけるための定数なので、正規累積モデルと比較する必要がなければ不要。
3PLモデル#
例えば4択問題では、正解がわからなくて適当に選んだとしても1/4は当たることになる。こうした影響を「当て推量」パラメータ
3PLモデル
:項目識別力 :項目困難度 :当て推量
4PLモデル#
項目特性曲線の上限を表すパラメータ
4PLモデル
:項目識別力 :項目困難度 :当て推量。項目特性曲線の下限 :項目特性曲線の上限
5PLモデル#
「非対称性」のパラメータ
5PLモデル
:項目識別力 :項目困難度 :当て推量。項目特性曲線の下限 :項目特性曲線の上限 :非対称性
因子分析と2P正規累積モデルは等価
因子分析と2パラメータ正規累積モデルは数学的に等価であると知られている。標準化していない(切片が0でない)1因子モデルは、
となる(IRTの説明に合わせて誤差の符号をマイナスにしている)
カテゴリカル因子分析では離散的な観測変数
標準正規分布に従う誤差
となる。2つのモデルのパラメータを
となる。
多値型モデル#
項目の反応
段階反応モデル#
段階反応モデル(graded response model: GRM) は複数の二値IRTモデルを組み合わせて多値反応を表現する。
回答者
なお、困難度は項目
このモデルを組み合わせると、「ちょうど
と表すことができる。ただし端のカテゴリは
段階反応モデル
名義反応モデル#
名義反応モデル(nominal response model: NRM) も段階反応モデルと同様に多値の回答にIRTを適用したモデル。softmax関数のような形で多値化する。
連続反応モデル#
連続反応モデル(Continuous Response Model, CRM)は連続的な反応(例:時間、強度、割合)を扱うことができる。
CRMは段階反応モデル(GRM)をさらに多段階に拡張していって連続値を扱う。
受験者
これを使うと、受験者
ここで
適合度の評価#
局所独立性の確認#
統計量
個人適合度#
「
( )統計量 :ある回答者の反応パターン と「考えられる全反応パターン」での尤度を比較する
IRTの前提条件・仮定#
局所独立性(local independence)の仮定#
仮定を満たすか確認する方法:
例えばデータから
が特定の値の人を集めて( で条件づけたデータを用意して)、項目 の回答のクロス表を作ったとき、関連性がない場合は局所独立性が満たされていることがわかる。
一次元性#
もともとのIRTは単一のテストに対する単一の能力を測定することが主たる目的のため、推定する因子数は1である(因子数を拡張したモデルも存在するが、基本は1)。そのため、すべての項目が同じ能力・特性のみを反映していることが必要となる。
仮定を満たすか確認する方法:
例えば、1因子の確認的因子分析を行い、適合度指標(RMSEAやCFA、AGFIなど)が許容範囲内かどうかで確認する。
DIMTEST(Stout et al., 1996)や DETECT(Zhang & Stout, 1999)といった手法もある
PythonでのIRTの実装#
一般的なモデル → pyirtなどパッケージがある
複雑な、独自のモデル → PyStanやPyMCでベイズ推定するのが良さそう
2PL#
データの生成#
Show 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(
推定#
%%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
-
<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
一部の項目の
Show 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()

一部の回答者の
Show 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()
