名義反応モデル#
順序のないカテゴリカル変数を名義尺度(nominal scale)という。
名義尺度の反応を扱えるのが 名義反応モデル(nominal response model: NRM) softmax関数の形で多値化する。
名義反応モデル(nominal response model: NRM)
個人 \(i\)、項目 \(j\)、カテゴリ \(k \in \{1, \dots, K_j\}\) に対して、反応確率が次のように定義されるモデル
名義カテゴリモデル(nominal categories model) という呼ばれ方もする
同値変換#
NRMのモデル
は
\(a_{jk}' = -a_{jk}\)
\(\theta_i' = -\theta_i\)
と置き換えても
となるため、推定に使うライブラリの違いなどで推定結果が真逆になることがある
多次元バージョン#
Thissen et al. (2010) の General-Purpose Multidimensional Nominal Model (GPMNM) は名義反応モデルで多次元の特性パラメータ\(\boldsymbol{\theta}\)と識別力\(\mathbf a_{jk}\)を扱えるよう一般化したモデル
General-Purpose Multidimensional Nominal Model (GPMNM)
\(\boldsymbol{\theta}_i \in \mathbb{R}^D\):個人 \(i\) の 多次元潜在特性(能力ベクトル)
\(\mathbf a_{jk} \in \mathbb{R}^D\):項目 \(j\) のカテゴリ \(k\) に固有な識別力ベクトル
\(b_{jk} \in \mathbb{R}\):項目 \(j\) のカテゴリ \(k\) に固有な困難度
\(K_j\):項目 \(j\) のカテゴリ数
実装例#
# データを生成
import numpy as np
import pandas as pd
def simulate_nrm(N=1000, J=10, K=4, seed=42):
rng = np.random.default_rng(seed)
theta = rng.normal(0, 1, size=N)
# カテゴリ0を参照カテゴリ (a=0, b=0) とする
a_free = np.sort(rng.normal(0, 1, size=(J, K - 1)), axis=1)
b_free = rng.normal(0, 1, size=(J, K - 1))
a_full = np.zeros((J, K))
a_full[:, 1:] = a_free
b_full = np.zeros((J, K))
b_full[:, 1:] = b_free
U = np.zeros((N, J), dtype=int)
for i in range(N):
for j in range(J):
logits = a_full[j] * theta[i] + b_full[j]
logits -= logits.max()
P = np.exp(logits)
P /= P.sum()
U[i, j] = rng.choice(K, p=P)
return U, theta, a_free, b_free
num_users = 1000
num_items = 20
U, true_theta, true_a, true_b = simulate_nrm(N=num_users, J=num_items)
df = pd.DataFrame(
U,
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 | question_11 | question_12 | question_13 | question_14 | question_15 | question_16 | question_17 | question_18 | question_19 | question_20 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| user_1 | 0 | 0 | 3 | 2 | 0 | 3 | 3 | 0 | 0 | 3 | 3 | 3 | 3 | 0 | 1 | 1 | 1 | 3 | 3 | 2 |
| user_2 | 2 | 1 | 0 | 1 | 1 | 1 | 0 | 0 | 1 | 2 | 1 | 3 | 1 | 1 | 1 | 1 | 2 | 3 | 2 | 0 |
| user_3 | 1 | 2 | 2 | 3 | 0 | 3 | 3 | 2 | 0 | 0 | 3 | 3 | 1 | 2 | 2 | 3 | 3 | 3 | 3 | 2 |
| user_4 | 2 | 1 | 3 | 0 | 0 | 3 | 3 | 2 | 0 | 3 | 3 | 3 | 3 | 0 | 0 | 1 | 1 | 3 | 3 | 0 |
| user_5 | 1 | 1 | 0 | 1 | 1 | 1 | 1 | 0 | 1 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 2 |
# 縦持ちへ変換
df_long = pd.melt(
df.reset_index(),
id_vars="index",
var_name="item",
value_name="response",
).rename(columns={"index": "user"}).astype({"user": "category", "item": "category"})
df_long.head()
| user | item | response | |
|---|---|---|---|
| 0 | user_1 | question_1 | 0 |
| 1 | user_2 | question_1 | 2 |
| 2 | user_3 | question_1 | 1 |
| 3 | user_4 | question_1 | 2 |
| 4 | user_5 | question_1 | 1 |
import pymc as pm
import pytensor.tensor as pt
user_idx = df_long["user"].cat.codes.to_numpy()
users = df_long["user"].cat.categories.to_numpy()
item_idx = df_long["item"].cat.codes.to_numpy()
items = df_long["item"].cat.categories.to_numpy()
responses = df_long["response"].to_numpy().astype("int64")
K = int(responses.max() + 1)
n_free = K - 1
n_items = len(items)
coords = {
"user": users,
"item": items,
"category_free": np.arange(n_free),
"obs_id": np.arange(len(df_long)),
}
with pm.Model(coords=coords) as model:
user_idx_ = pm.Data("user_idx", user_idx, dims="obs_id")
item_idx_ = pm.Data("item_idx", item_idx, dims="obs_id")
theta = pm.Normal("theta", 0.0, 1.0, dims="user")
a_free = pm.Normal("a_free", 0.0, 1.0, dims=("item", "category_free"))
b_free = pm.Normal("b_free", 0.0, 2.0, dims=("item", "category_free"))
# 参照カテゴリ(0列目)にゼロを付与
a_full = pt.concatenate([pt.zeros((n_items, 1)), a_free], axis=1)
b_full = pt.concatenate([pt.zeros((n_items, 1)), b_free], axis=1)
# logits[n, k] = a_{j,k} * theta_i + b_{j,k}
logits = a_full[item_idx_] * theta[user_idx_][:, None] + b_full[item_idx_]
p = pm.math.softmax(logits, axis=1)
pm.Categorical("obs", p=p, observed=responses, dims="obs_id")
推定#
%%time
with model:
idata = pm.sample(random_seed=0, draws=1000)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [theta, a_free, b_free]
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
File <timed exec>:2
File ~/work/notes/notes/.venv/lib/python3.10/site-packages/pymc/sampling/mcmc.py:957, 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)
953 t_sampling = time.time() - t_start
955 # Packaging, validating and returning the result was extracted
956 # into a function to make it easier to test and refactor.
--> 957 return _sample_return(
958 run=run,
959 traces=trace if isinstance(trace, ZarrTrace) else traces,
960 tune=tune,
961 t_sampling=t_sampling,
962 discard_tuned_samples=discard_tuned_samples,
963 compute_convergence_checks=compute_convergence_checks,
964 return_inferencedata=return_inferencedata,
965 keep_warning_stat=keep_warning_stat,
966 idata_kwargs=idata_kwargs or {},
967 model=model,
968 )
File ~/work/notes/notes/.venv/lib/python3.10/site-packages/pymc/sampling/mcmc.py:1042, in _sample_return(run, traces, tune, t_sampling, discard_tuned_samples, compute_convergence_checks, return_inferencedata, keep_warning_stat, idata_kwargs, model)
1040 # Pick and slice chains to keep the maximum number of samples
1041 if discard_tuned_samples:
-> 1042 traces, length = _choose_chains(traces, tune)
1043 else:
1044 traces, length = _choose_chains(traces, 0)
File ~/work/notes/notes/.venv/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.
EAP推定量#
post_mean = idata.posterior.mean(dim=["chain", "draw"])
import matplotlib.pyplot as plt
fig, axes = plt.subplots(figsize=[12, 4], ncols=3)
ax = axes[0]
ax.scatter(true_theta, post_mean["theta"])
ax.plot(true_theta, true_theta, color="gray")
_ = ax.set(xlabel="true_theta", ylabel="theta_hat")
ax = axes[1]
ax.scatter(true_a.flatten(), post_mean["a_free"].to_numpy().flatten())
ax.plot(true_a.flatten(), true_a.flatten(), color="gray")
_ = ax.set(xlabel="true_a", ylabel="a_hat")
ax = axes[2]
ax.scatter(true_b.flatten(), post_mean["b_free"].to_numpy().flatten())
ax.plot(true_b.flatten(), true_b.flatten(), color="gray")
_ = ax.set(xlabel="true_b", ylabel="b_hat")
参考文献#
Lipovetsky, S. (2021). Handbook of Item Response Theory, Volume 1, Models. Technometrics, 63(3), 428-431.