はじめに
えいたんごクイズという無料の英単語学習サービスを開発しています。
このサービスでは、 項目反応理論(項目応答理論、IRT: Item Response Theory) を使って、学習者の語彙力を推定し、最適な難易度の問題を出題しています。
この記事では、IRTの基本的な考え方から、実際のサービスに実装する際に直面した課題と解決策について書きます。
項目反応理論(項目応答理論 / IRT)とは
IRTは、テストの各問題(項目)に対する回答パターンから、受験者の能力を統計的に推定する理論です。TOEFL iBT、TOEIC、GMATなど、世界中の大規模テストで採用されています。
従来のテストでは「100点満点中何点」という素点で評価しますが、IRTでは能力パラメータ θ(シータ) という尺度で評価します。
従来方式との違い
| 素点ベース | IRTベース | |
|---|---|---|
| 評価対象 | 正解数 | 回答パターン |
| 問題の難易度 | 考慮しない | 各問題の難易度を考慮 |
| テスト間の比較 | 同一問題でないと不可 | 異なる問題セットでも比較可能 |
| 出題方式 | 全員同じ | 受験者に合わせて変えられる |
2パラメータ・ロジスティックモデル(2PLモデル)
えいたんごクイズでは、IRTの2PLモデルを採用しています。
正答確率の計算
ある学習者(能力 θ)が、ある問題(難易度 b、識別力 a)に正答する確率は、以下の式で求まります。
P(\theta) = \frac{1}{1 + \exp(-a(\theta - b))}
- θ(シータ): 学習者の能力。値が大きいほど語彙力が高い
- b(難易度): 問題の難しさ。θ = b のとき正答確率が50%になる
- a(識別力): その問題が能力の高低をどれだけ識別できるか
// app/Services/IrtEngine.php
public function probability(float $theta, float $a, float $b): float
{
return 1.0 / (1.0 + exp(-$a * ($theta - $b)));
}
シグモイド関数なので、θ が b より大きければ正答確率は50%を超え、小さければ50%を下回ります。
なぜ2PLモデルか
IRTには1PL(識別力が全問共通)、2PL、3PL(当て推量を考慮)などのバリエーションがあります。
- 1PL: シンプルだが、問題ごとの「識別力の差」を表現できない
- 3PL: 当て推量パラメータの推定にはデータ数が必要(最低数百件/問題)
4択問題なので当て推量は25%ありますが、3PLの推定には大量のデータが必要です。2PLは識別力の違いを表現でき、かつ妥当なデータ量で推定可能なバランスの良い選択です。
問題の初期難易度 — 単語の出現頻度からの推定
IRTを動かすには、各問題の難易度パラメータ b の初期値が必要です。しかし、サービス開始時点では回答データがゼロなので、キャリブレーションで推定することができません。
英語コーパスの出現頻度を使う
えいたんごクイズでは、初期の難易度パラメータを英語の単語出現頻度から設定しました。wordfreqライブラリ(Webテキストから構築された頻度コーパス)を使い、頻出語ほど難易度を低く、低頻度語ほど難易度を高く設定しています。
# tools/step1_extract_words.py
THETA_MIN = -3.5 # 最も簡単
THETA_MAX = 3.5 # 最も難しい
def calculate_difficulty(rank: int, total: int) -> float:
"""頻度順位から難易度パラメータを線形マッピング"""
t = rank / max(total - 1, 1)
return round(THETA_MIN + t * (THETA_MAX - THETA_MIN), 2)
頻度順位1位(最頻出)→ b = -3.5、最下位(最低頻度)→ b = 3.5 という線形マッピングです。直感的には「よく使われる単語は簡単、めったに見ない単語は難しい」という仮定であり、母語話者にとってはそれなりに妥当です。
日本語話者にとっての欠陥
しかし、この方法には大きな欠陥がありました。英語コーパスの出現頻度は、日本語話者にとっての難易度と一致しないのです。
たとえば、chocolate、camera、taxi、guitar といった単語は、英語コーパスではそこまで高頻度ではありませんが、日本語にカタカナ語として定着しているため、日本語話者にとっては「知っていて当然」の簡単な単語です。
逆に、get、make、take などの基本動詞は英語コーパスでは超高頻度ですが、多義語であるため、出題される意味によっては日本語話者にとって難しいこともあります。
この問題は、回答データが蓄積されてキャリブレーションが進むことで徐々に解消されていきます。実際に日本語話者が答えたデータから難易度を推定し直すので、「日本語話者にとっての本当の難易度」に収束していく仕組みです。ただし、収束には問題ごとに数十件以上の回答データが必要であり、低頻度の問題ほど時間がかかります。
初期値の設定方法はIRT実装における実用上の課題です。理想的には対象言語話者の予備テストデータから設定すべきですが、サービス立ち上げ時にはそれがないため、コーパス頻度による近似からスタートし、運用データで補正していくアプローチをとりました。
学習者の能力推定 — EAP推定
学習者が問題に回答するたびに、能力 θ を更新する必要があります。えいたんごクイズではEAP推定(Expected A Posteriori) を使っています。
EAPの考え方
ベイズの定理を使って「この人の能力はこのくらいだろう」という事後分布を求め、その期待値を能力推定値とします。
\hat{\theta}_{EAP} = \frac{\sum_{k} \theta_k \cdot L(\theta_k) \cdot \pi(\theta_k)}{\sum_{k} L(\theta_k) \cdot \pi(\theta_k)}
- $\pi(\theta_k)$: 事前分布(直前の推定値を中心とした正規分布)
- $L(\theta_k)$: 尤度(回答データが得られる確率)
実装では、θの候補点を61個並べた数値積分(求積法)で計算しています。
// app/Services/IrtEngine.php(簡略化)
public function updateTheta(
float $priorTheta,
float $priorSE,
array $responses // [{b, a, correct}, ...]
): array {
$quadPoints = 61;
$halfSpan = max(4.0 * $priorSE, 3.0);
$lo = $priorTheta - $halfSpan;
$hi = $priorTheta + $halfSpan;
$step = ($hi - $lo) / ($quadPoints - 1);
$posteriorSum = 0.0;
$weightedThetaSum = 0.0;
$weightedThetaSqSum = 0.0;
for ($i = 0; $i < $quadPoints; $i++) {
$theta = $lo + $i * $step;
// 事前分布: 正規分布
$prior = exp(-0.5 * (($theta - $priorTheta) / $priorSE) ** 2);
// 尤度: 各回答の正答/誤答確率の積
$likelihood = 1.0;
foreach ($responses as $r) {
$p = $this->probability($theta, $r['a'], $r['b']);
$likelihood *= $r['correct'] ? $p : (1.0 - $p);
}
$posterior = $prior * $likelihood;
$posteriorSum += $posterior;
$weightedThetaSum += $theta * $posterior;
$weightedThetaSqSum += $theta * $theta * $posterior;
}
$eapTheta = $weightedThetaSum / $posteriorSum;
$variance = ($weightedThetaSqSum / $posteriorSum) - ($eapTheta ** 2);
$se = sqrt(max($variance, 0.4 * 0.4)); // SEの下限を設定
return ['theta' => $eapTheta, 'se' => $se];
}
ダンピング — 急激な変動を抑える
EAPの出力をそのまま使うと、1問ごとにθが大きく振れることがあります。特に序盤は不安定です。そこでダンピング(減衰) を適用しています。
// 新しいθ = 古いθ + damping × (IRT推定値 - 古いθ)
$newTheta = $oldTheta + $damping * ($irtTheta - $oldTheta);
ダンピング係数は通常0.18。IRT推定値との差分の18%だけ動かします。ローパスフィルタのように、急激な変動をなめらかにします。
診断モードと学習モードで変動率を変える
ダンピング係数は、モードの目的に応じて大きく変えています。
学習モード(通常のクイズ)ではダンピング 0.18 です。学習モードの目的は「繰り返し学習して語彙を定着させること」なので、1問ごとにθが大きく動く必要はありません。むしろ安定していたほうが、適切な難易度の問題が出続けます。セッションを重ねて少しずつθが動けば十分です。
語彙力診断モードではダンピングを大幅に上げています。診断の目的は「20問で能力を正確に絞り込むこと」なので、1問ごとにθを大きく動かして、素早く収束させる必要があります。
| モード | ダンピング | 初期θ | 目的 |
|---|---|---|---|
| 学習モード | 0.18 | ユーザの前回θ | 安定した出題 |
| 診断:かんたん | 0.6 | -4.0 | 初級帯の測定 |
| 診断:ふつう | 0.9 | -2.0 | 中級帯の測定 |
| 診断:激ムズ | 1.4 | 0.0 | 上級帯の測定 |
「激ムズ」でダンピングが1.0を超えているのは、高い能力帯では正答率の差が小さく、θが動きにくいためです。オーバーシュートさせて収束を早めています。
このように、同じIRTエンジンでも「学習に適した安定性」と「診断に適した収束速度」をダンピング1つで切り替えています。
問題の難易度推定 — キャリブレーション
IRTでは問題の難易度パラメータも推定する必要があります。ここが「学習者の能力推定」と対になるもうひとつの軸です。
学習者と問題、2つの推定
IRTには推定すべきものが2つあります。
| 学習者側 | 問題側 | |
|---|---|---|
| 推定するもの | 能力 θ | 難易度 b、識別力 a |
| 推定タイミング | リアルタイム(回答ごと) | バッチ(定期的に一括) |
| 手法 | EAP推定 | 最尤推定 + ベイズ縮小 |
| 必要データ量 | 1問から推定開始 | 最低5件(b)、30件(a) |
学習者のθはセッション中にリアルタイムで更新しますが、問題パラメータの推定は蓄積された回答データを使ってバッチ処理で行います。
最尤推定(Newton-Raphson法)
問題の難易度 b の推定には、ニュートン・ラフソン法で対数尤度を最大化します。
// app/Services/ItemCalibrator.php(簡略化)
private function estimateDifficulty(array $responses, float $a, float $priorB): float
{
$b = $priorB;
for ($iter = 0; $iter < 50; $iter++) {
$gradient = 0.0;
$hessian = 0.0;
foreach ($responses as $r) {
$p = 1.0 / (1.0 + exp(-$a * ($r['theta'] - $b)));
$w = $r['weight'];
$gradient += $w * (-$a) * ($r['correct'] - $p);
$hessian += $w * (-$a * $a) * $p * (1 - $p);
}
if (abs($hessian) < 1e-10) break;
$delta = $gradient / $hessian;
$b -= $delta;
if (abs($delta) < 1e-4) break; // 収束
}
return $b;
}
同じ問題を複数回解いた場合の制御
実際のサービスでは、同じ学習者が同じ問題を何度も解くことがあります。これがキャリブレーションで厄介な問題を引き起こします。
問題: ある学習者がある単語を5回連続で正解した場合、素朴に集計すると「正答率100%」になり、難易度が過度に下がります。しかし実際は、その学習者が「その単語をすでに覚えた」だけであり、問題が簡単だとは限りません。
解決策: 同一単語の連続正答/連続誤答にストリーク減衰を適用しています。
// 同一単語の連続正答/誤答に対する重み付け
// streak 1回目: 重み 1.0(通常通り)
// streak 2回目: 重み 0.5
// streak 3回目: 重み 0.25
// streak n回目: 重み 1 / 2^(n-1)
$damping = 1.0 / pow(2, $streak - 1);
2回目以降の同一単語回答は、指数的に重みが減衰します。学習によって正答できるようになっただけのデータが、難易度推定を歪めるのを防ぎます。
ベイズ縮小 — データが少ない問題の安定化
データが少ない問題の推定値は不安定です。極端な例では、5人中5人が正解しただけで「超簡単な問題」と判定されてしまいます。
これを防ぐために、ベイズ縮小(Bayesian Shrinkage) を使っています。事前分布(初期設定値)とデータからの推定値を、データ量に応じて混合します。
// n: 回答数、n0: 事前分布の強さ(= 90)
$shrinkWeight = $n0 / ($n0 + $n);
$calibratedB = $shrinkWeight * $priorB + (1 - $shrinkWeight) * $estimatedB;
| 回答数 | 事前分布の比重 | データの比重 |
|---|---|---|
| 10件 | 90% | 10% |
| 100件 | 47% | 53% |
| 1,000件 | 8% | 92% |
データが少ないうちは初期設定に近い値を使い、データが増えるにつれて推定値を信頼するようになります。
出題アルゴリズム — 適応型テスト
能力推定と難易度推定が揃ったら、次は「どの問題を出すか」です。
フィッシャー情報量
IRTでは、フィッシャー情報量が最大になる問題が、能力推定を最も効率的に改善します。
I(\theta) = a^2 \cdot P(\theta) \cdot (1 - P(\theta))
これは $P(\theta) = 0.5$(θ = b)のとき最大になります。つまり「正解と不正解が五分五分の問題」が情報量的には最適です。
でも50%は辛い
理論的には50%が最適ですが、学習サービスでは半分間違えるのはストレスが高すぎます。そこで目標正答率を70% に設定しています。
// config/irt.php
'target_correct_rate' => 0.7,
正答確率が70%付近の問題を優先的に出題するスコアリングを行い、さらに:
- 簡単な問題へのペナルティを緩和: 難しすぎる問題より簡単な問題のほうがまし
- ジッター(ランダムノイズ): θに標準偏差0.5のノイズを加え、出題に幅を持たせる
- 未出題ボーナス: 回答データが少ない問題に最大2倍のボーナスを付与
// app/Services/ItemSelector.php(概要)
// 1. フィッシャー情報量(に近い値)を計算
$diffMatch = $p * (1 - $p);
// 2. 目標正答率との距離でボーナス
$pBonus = 1 - abs($p - $targetP);
// 3. 難しすぎる問題は急激にスコアを下げる
$score = ($p >= $targetP)
? $diffMatch * $pBonus // 簡単側: 緩やかな減衰
: $diffMatch * pow($pBonus, 3); // 難しい側: 急な減衰
// 4. ランダムジッター
$score *= 0.5 + (mt_rand() / mt_getrandmax()) * 1.0;
// 5. 既出問題はスコアを0.3倍
if (in_array($wordId, $seenWordIds)) {
$score *= 0.3;
}
上位10件から重み付きランダムで1問を選択します。理論的に最適な1問ではなく、あえてランダム性を入れることで、出題パターンの固定化を防いでいます。
語彙力診断の3モード設計
英語の語彙力は英検5級レベル(数百語)から1級レベル(1万語超)まで非常に幅が広く、全範囲を20問で正確にカバーするのは困難です。
そこで、かんたん・ふつう・激ムズの3モードに分け、各モードで初期θとダンピングを変えることで、20問・約2分での診断を実現しています。
各モードは測定対象の能力帯を絞り込んでいるため、少ない問題数でも十分な精度が得られます。
まとめ
IRTの実装で特に重要だったポイントをまとめます。
- 学習者の能力推定はEAP + ダンピング: リアルタイムで安定した推定を実現
- 問題の難易度推定は最尤推定 + ベイズ縮小: データが少なくても暴走しない
- 同一問題の繰り返しにはストリーク減衰: 学習効果と難易度推定を分離
- 出題は情報量最大化 + ランダム性: 理論的な最適性と実用的な多様性のバランス
IRTの理論自体は1960年代から研究されていますが、実際のサービスに組み込む際には「データが少ない」「同じ問題を何度も解く」「学習者が離脱しないUIにする」といった現実的な課題への対処が必要でした。
従来、IRTの実装は統計学やテスト理論の専門知識が前提で、個人開発者にはハードルが高いものでした。しかし2026年現在、高度なLLMやコーディングエージェントを活用すれば、数式の意味や実装の方向性さえ指し示せば、動くコードに落とし込むことができます。えいたんごクイズの実装も、その恩恵を大いに受けています。IRTに興味があるけれど実装は難しそうだと感じていた方は、ぜひ挑戦してみてください。
試してみる
えいたんごクイズでは、この記事で解説した仕組みをそのまま使っています。
- 2分で語彙力診断する — IRTによる適応型テスト
- 英単語クイズに挑戦する — 学習モード
- 語彙力診断の裏側 — IRTで実力を正確に測る仕組み — 非エンジニア向けのやさしい解説
開発背景や、AI時代に英単語クイズを作る意味については、Noteにも書きました。
皆さんの回答データは問題の難易度推定にも活用されています。使えば使うほどシステムの精度が向上し、すべての英語学習者のためになります。ぜひ試してみてください。