Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

段階反応モデル

段階反応モデル(graded response model: GRM, Samejima, 1969 は多値の順序尺度の反応を扱えるモデル。複数の二値IRTモデルを組み合わせて多値反応を表現する。

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

P(yijk)=11+exp(aj(θibjk))P(y_{ij} \geq k) = \frac{1}{1+ \exp \big(-a_j ( \theta_i - b_{jk}) \big)}

なお、困難度は項目jjのカテゴリkkごとに用意されるためbjkb_{jk}に変更している。

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

P(yij=k)=P(yijk)P(yijk+1)P(y_{ij} = k) = P(y_{ij} \geq k) - P(y_{ij} \geq k + 1)

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

実装例(全項目のカテゴリ数が等しい場合)

PyMCパッケージを使ってベイズ推定する場合、各項目のカテゴリ数が同じ場合は順序ロジットモデルで簡単に実装できる

# データを生成
import numpy as np
import pandas as pd

def simulate_grm(N=1000, J=10, K=4, seed=42):
    rng = np.random.default_rng(seed)

    theta = rng.normal(0, 1, size=N)
    a = rng.lognormal(0, 0.3, size=J)
    b = np.sort(
        rng.normal(0, 1, size=(J, K-1)),
        axis=1
    )

    U = np.zeros((N, J), dtype=int)
    for j in range(J):
        for i in range(N):
            eta = a[j]*(theta[i] - b[j])
            P_ge = 1 / (1 + np.exp(-eta))

            P = np.empty(K)
            P[0] = 1 - P_ge[0]
            for k in range(1, K-1):
                P[k] = P_ge[k-1] - P_ge[k]
            P[K-1] = P_ge[K-2]

            U[i, j] = rng.choice(K, p=P)
    return U, theta, a, b

num_users = 1000
num_items = 20
U, true_theta, true_a, true_b = simulate_grm(N=num_users, J=num_items)
df = pd.DataFrame(U,
                  index=[f"user_{i+1}" for i in range(num_users)],
                  columns=[f"item_{j+1}" for j in range(num_items)])

df.head()
Loading...
# 縦持ちへ変換
user_categories = df.index
item_categories = df.columns

df_long = pd.melt(
    df.reset_index(),
    id_vars="index",
    var_name="item",
    value_name="response",
).rename(columns={"index": "user"})

df_long["user"] = pd.Categorical(df_long["user"], categories=user_categories, ordered=True)
df_long["item"] = pd.Categorical(df_long["item"], categories=item_categories, ordered=True)

df_long.head()
Loading...

閾値(cutpoints)の推定

pymc.OrderedLogisticlogit1(ηck)\text{logit}^{-1}(\eta - c_k) なので、IRTモデルのlogit1(a(θbk))=logit1(aθabk)\text{logit}^{-1}(a(\theta - b_k)) = \text{logit}^{-1}(a\theta - a b_k) に合わせると cutpoints = a[:, None] * b のようにするのが素直な実装。

# --- GRM の閾値:各 item ごとに (K-1) 本 ---
# ordered transform が last axis に対して単調増加を強制する想定
b = pm.Normal(
    "b",
    mu=np.linspace(-1, 1, n_thresholds),
    sigma=2.0,
    dims=("item", "threshold"),
    initval=np.tile(np.linspace(-1, 1, n_thresholds), (num_items, 1)), # 初期値を指定
    transform=pm.distributions.transforms.ordered,
)

# GRM の cutpoints: a_j * b_{jk}
# OrderedLogistic は P(Y >= k) = sigma(eta - cutpoints_k) を計算するため、
# Samejima のパラメタリゼーション P(Y >= k) = sigma(a*(theta - b)) に合わせるには
# cutpoints = a * b とする必要がある(a > 0 なので順序は保たれる)
cutpoints = a[:, None] * b

しかし、これはaとbに依存が関係が生じてサンプリングが安定しにくいかもしれない。cutpoints = pm.Normal() にして b = cutpoints / a[:, None] としたほうが良いかも

import numpy as np
import pymc as pm

# indexと値の取得
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")  # 0..K-1

# カテゴリ数
K = int(responses.max() + 1)
n_thresholds = K - 1

coords = {
    "user": users,
    "item": items,
    "threshold": np.arange(n_thresholds),
    "obs_id": np.arange(len(df_long)),
}

with pm.Model(coords=coords) as model:
    responses_ = pm.Data("responses", responses, dims="obs_id")

    theta = pm.Normal("theta", 0.0, 1.0, dims="user")
    a = pm.LogNormal("a", 0.0, 0.5, dims="item")

    # --- 閾値:各 item ごとに (K-1) 本 ---
    b = pm.Normal(
        "b",
        mu=np.linspace(-1, 1, n_thresholds),
        sigma=2.0,
        dims=("item", "threshold"),
        initval=np.tile(np.linspace(-1, 1, n_thresholds), (num_items, 1)), # 初期値を指定
        transform=pm.distributions.transforms.ordered,
    )
    # OrderedLogistic は P(Y >= k) = inv_logit(eta - cutpoints_k) を計算するため、
    # GRM の P(Y >= k) = inv_logit(a*(theta - b_k)) に合わせてcutpoints_k = a * b_k
    cutpoints = pm.Deterministic("cutpoints", a[:, None] * b)

    # 線形予測子
    eta = pm.Deterministic("eta", a[item_idx] * theta[user_idx])

    # 観測:item ごとの cutpoints を観測ごとに参照
    pm.OrderedLogistic(
        "obs",
        eta=eta,
        cutpoints=cutpoints[item_idx],
        observed=responses_,
        dims="obs_id",
    )
pm.model_to_graphviz(model)
Loading...

推定

%%time
with model:
    idata = pm.sample(random_seed=0, draws=1000, tune=2000)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [theta, a, b]
Loading...
Loading...
Sampling 4 chains for 2_000 tune and 1_000 draw iterations (8_000 + 4_000 draws total) took 114 seconds.
CPU times: user 16.5 s, sys: 5.18 s, total: 21.7 s
Wall time: 1min 59s

EAP推定量

import arviz as az
az.summary(idata, var_names=["a", "b"], round_to=2)
Loading...
post_mean = idata.posterior.mean(dim=["chain", "draw"])

# 項目パラメータのEAP推定量
params_EAP = pd.DataFrame({
    "item": coords["item"],
    "a": post_mean["a"],
})
params_EAP.head()
Loading...
Source
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, post_mean["a"])
ax.plot(true_a, true_a, color="gray")
_ = ax.set(xlabel="true_a", ylabel="a_hat")

ax = axes[2]
ax.scatter(true_b.flatten(), post_mean["b"].to_numpy().flatten())
ax.plot(true_b, true_b, color="gray")
_ = ax.set(xlabel="true_b", ylabel="b_hat")
<Figure size 1200x400 with 3 Axes>

実装例(各項目のカテゴリ数が異なる場合)

OrderedLogisticを複数個重ねるように実装する方法がある

rng = np.random.default_rng(42)

N = 100  # respondents
J = 20   # items total

# K per item
K_per_item = np.array([3] * 7 + [4] * 7 + [5] * 6)

# True parameters
theta_true = rng.normal(0, 1, N)
a_true = rng.lognormal(0, 0.5, J)
# b_true[j]: sorted array of K_j-1 thresholds
b_true = [np.sort(rng.normal(0, 1, K_per_item[j] - 1)) for j in range(J)]

# Generate responses
Y = np.zeros((N, J), dtype=int)
for j in range(J):
    K_j = K_per_item[j]
    # P*(Y >= k) for k=1,...,K_j-1 — shape (N, K_j-1)
    eta = a_true[j] * (theta_true[:, None] - b_true[j][None, :])
    cum_p = 1 / (1 + np.exp(-eta))
    probs = np.concatenate([
        1 - cum_p[:, :1],
        cum_p[:, :-1] - cum_p[:, 1:],
        cum_p[:, -1:],
    ], axis=1)  # (N, K_j)
    u = rng.uniform(size=(N, 1))
    Y[:, j] = (u > np.cumsum(probs, axis=1)).sum(axis=1)

print(f"Response matrix shape: {Y.shape}")
for k in np.unique(K_per_item):
    items_k = np.where(K_per_item == k)[0]
    counts = np.bincount(Y[:, items_k].ravel(), minlength=k)
    print(f"K={k}: {len(items_k)} items, category counts = {counts}")
Response matrix shape: (100, 20)
K=3: 7 items, category counts = [275 154 271]
K=4: 7 items, category counts = [256  85 113 246]
K=5: 6 items, category counts = [193  38  92 124 153]
df = pd.DataFrame(Y,
                  index=[f"user_{i+1}" for i in range(Y.shape[0])],
                  columns=[f"item_{j+1}" for j in range(Y.shape[1])])

df.tail()
Loading...
# 縦持ちへ変換
user_categories = df.index
item_categories = df.columns

df_long = pd.melt(
    df.reset_index(),
    id_vars="index",
    var_name="item",
    value_name="response",
).rename(columns={"index": "user"})

df_long["user"] = pd.Categorical(df_long["user"], categories=user_categories, ordered=True)
df_long["item"] = pd.Categorical(df_long["item"], categories=item_categories, ordered=True)

df_long.head()
Loading...
import numpy as np
import pymc as pm

# indexと値の取得
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")  # 0..K-1

# カテゴリ数
K = int(responses.max() + 1)
n_thresholds = K - 1

coords = {
    "user": users,
    "item": items,
    "threshold": np.arange(n_thresholds),
    "obs_id": np.arange(len(df_long)),
}

with pm.Model(coords=coords) as model:
    responses_ = pm.Data("responses", responses, dims="obs_id")

    theta = pm.Normal("theta", 0.0, 1.0, dims="user")
    a = pm.LogNormal("a", 0.0, 0.5, dims="item")

    # --- GRM の閾値:各 item ごとに (K-1) 本 ---
    # ordered transform が last axis に対して単調増加を強制する想定
    b = pm.Normal(
        "b",
        mu=np.linspace(-1, 1, n_thresholds),   # 初期の目安
        sigma=2.0,
        dims=("item", "threshold"),
        transform=pm.distributions.transforms.ordered,
    )
    # 線形予測子
    eta = a[item_idx] * theta[user_idx]

    # GRM の cutpoints: a_j * b_{jk}
    # OrderedLogistic は P(Y >= k) = sigma(eta - cutpoints_k) を計算するため、
    # Samejima のパラメタリゼーション P(Y >= k) = sigma(a*(theta - b)) に合わせるには
    # cutpoints = a * b とする必要がある(a > 0 なので順序は保たれる)
    cutpoints = a[:, None] * b

    # 観測:item ごとの cutpoints を観測ごとに参照
    pm.OrderedLogistic(
        "obs",
        eta=eta,
        cutpoints=cutpoints[item_idx],
        observed=responses_,
        dims="obs_id",
    )

モデル

荘島宏二郎 & 豊田秀樹 (2004) を参考に、複数のモデルの尤度を掛けるようにして混合形式のモデルを構築する。

ある項目jjのカテゴリ数がkk個だとし、それをGRMでモデリングした項目特性曲線を PjGRM(k)(θ)P^{GRM(k)}_j(\theta) とすると、今回のデータはカテゴリ数がk=3k=3の項目が7個、k=4k=4の項目が7個、k=5k=5の項目が6個なので

L(θ)=i=1N{j=17PjGRM(3)(θi)×j=17PjGRM(4)(θi)×j=16PjGRM(5)(θi)}L(\theta) = \prod_{i=1}^N \left\{ \prod_{j=1}^7 P^{GRM(3)}_j(\theta_i) \times \prod_{j=1}^7 P^{GRM(4)}_j(\theta_i) \times \prod_{j=1}^6 P^{GRM(5)}_j(\theta_i) \right\}

となる。同じカテゴリ数の項目ごとにグルーピングして実装すれば

with pm.Model() as model:
    for k in [3, 4, 5]:
        subset = data[K_per_item == k] # 対応する項目を取り出す
        pm.OrderedLogistic(...) # GRMを構築
        ...

のように実装できる

K_per_item = df.max() + 1
unique_K = np.sort(np.unique(K_per_item))

with pm.Model() as model:
    # theta, aはkによらず共通
    theta = pm.Normal("theta", mu=0.0, sigma=1.0, shape=N)
    a = pm.LogNormal("a", mu=0.0, sigma=0.5, shape=J)

    # カテゴリ数Kが同じ項目ごとにまとめてGRMを構築
    for k in unique_K:
        item_idx = np.where(K_per_item == k)[0]
        J_k = len(item_idx)
        Y_k = Y[:, item_idx]  # (N, J_k)
        b_k = pm.Normal(
            f"b_{k}",
            mu=0.0,
            sigma=1.5,
            shape=(J_k, k - 1),
            transform=pm.distributions.transforms.ordered,
            initval=np.tile(np.linspace(-1.0, 1.0, k - 1), (J_k, 1)),
        )

        eta_k = pm.Deterministic(f"eta_{k}",theta[:, None] * a[item_idx][None, :])   # (N, J_k)
        cutpoints_k = pm.Deterministic(f"cutpoints_{k}", a[item_idx][:, None] * b_k)  # (J_k, K-1)

        pm.OrderedLogistic(
            f"y_{k}",
            eta=eta_k,
            cutpoints=cutpoints_k,
            observed=Y_k,
        )

pm.model_to_graphviz(model)
Loading...
with model:
    idata = pm.sample(
        draws=3000,
        tune=2000,
        chains=4,
        target_accept=0.9,
        random_seed=42,
        progressbar=True,
        nuts_sampler="numpyro",
    )
WARNING:2026-04-29 20:37:55,403:jax._src.xla_bridge:794: An NVIDIA GPU may be present on this machine, but a CUDA-enabled jaxlib is not installed. Falling back to cpu.
Loading...
Loading...
Loading...
Loading...

EAP推定値

item_params = pd.DataFrame({"item": df.columns})
item_params["a"] = idata.posterior["a"].mean(dim=["chain", "draw"]).values  # (J,)

for k in K_per_item.unique():
    b_col = f"b_{k}"
    B_k = idata.posterior[b_col].mean(dim=["chain", "draw"]).values
    J_k = B_k.shape[1]
    B_df = pd.DataFrame(B_k, columns=[f"b{i+1}" for i in range(J_k)])
    for col in B_df.columns:
        item_params.loc[np.array(k == K_per_item), col] = B_df[col].values

# mirtパッケージのような表
item_params
Loading...
theta_mean = idata.posterior["theta"].mean(dim=["chain", "draw"]).values  # (N,)
thetas = pd.DataFrame({"person": np.arange(N), "theta": theta_mean})

真値と比較

Source
import matplotlib.pyplot as plt

fig, axes = plt.subplots(figsize=[10,3], ncols=3)

ax = axes[0]
ax.scatter(theta_true, theta_mean)
ax.plot(theta_true, theta_true, color="gray")
_ = ax.set(xlabel="true_theta", ylabel="theta_hat")

ax = axes[1]
ax.scatter(a_true, item_params["a"])
ax.plot(a_true, a_true, color="gray")
_ = ax.set(xlabel="true_a", ylabel="a_hat")

ax = axes[2]
b_hat_flatten = item_params.filter(regex="b\d").to_numpy().flatten()
ax.scatter(np.concatenate(b_true), b_hat_flatten[~np.isnan(b_hat_flatten)])
ax.plot(true_b, true_b, color="gray")
_ = ax.set(xlabel="true_b", ylabel="b_hat")

fig.subplots_adjust(wspace=0.3)
<Figure size 1000x300 with 3 Axes>

参考文献

References
  1. Samejima, F. (1969). Estimation of Latent Ability Using a Response Pattern of Graded Scores. Psychometrika, 34(S1), 1–97. 10.1007/bf03372160