共通テストの選択肢問題のTeXによる自動採点

この記事はTeX & LaTeX Advent Calendar 2023の22日目の記事です。21日目はujimushi at SradJPさんでした。23日目はwtsnjpさんです。

目的

学習塾や学校などの教育機関において,共通テスト等における選択肢問題を採点する場面は多く見られます。記述問題ではなく,選択肢問題であるという特性を考えるとこれを目視で手採点し,合計点を足し合わせるというのは非効率的であり,また正確性を考慮しても自動化することが好ましいです。採点時間も大幅に短縮されるため,生徒側にとってもすぐに返却されるというメリットがあり,まさに,今年のテーマである「(La)TeXで幸せになる方法」であるといえます。マークシートの読み取りやPDFの結合などTeX 以外の操作も含めた全体のワークフローをパッケージ化(SwiftにてmacOSで動くアプリケーションソフトを実装予定)し,配布することで,TeX に明るくない講師を含めてすべての人が恩恵を受けることが出来ます。

(更新:2024/1/31に実装完了)

作りたいもの

実際に生じた例として,1988年度の大学入試センター試験(試行)の英語の試験を実施し,自動採点により以下のような返却答案を配布することを考えます。

ワークフロー


  1. 個別化されたマークシート答案の作成
  2. マークシートの読み取り
  3. TeXによる自動採点
  4. 採点データの集計
  5. マークシートの答案原本とmergeして印刷

全体のワークフローは以上のようですが,すべてを解説するとあまりにも長くなりすぎるので,本記事ではTeXによる自動採点の部分に主に焦点を当てて解説することとします。

個別化されたマークシート答案の作成

「型」となるマークシートに受験番号と氏名だけ異なる解答用紙を作成するには,doraTeXさんの以下の記事が参考になります。

doratex.hatenablog.jp

doratex.hatenablog.jp

マークシートの読み取り

マークシートの読み取りには社内版マークシートリーダー(非公開)を用いています。

TeXによる自動採点

共通テストの選択肢の出題方式として,次の3通りがあります。

  1. 単答問題:最もシンプルな問題形式。1つの問題につき1つの選択肢を選ぶ。
  2. 完答問題:複数の問題(本試験ではすべて2つ)をすべて正解したときにのみ配点が与えられる。
  3. 順不同問題:連続する複数の選択肢と解答の選択肢群が集合としてどの程度一致しているかにより配点が与えられる。
    例えば,設問番号53~56の正解選択肢が4, 5, 9, 0であるとき,0, 5, 9, 4や9, 0, 5, 4と答えたものには満点を与え,9, 0, 1, 2のように答えた場合には2問分の配点を与える。また,4, 4, 4, 4のように答えた場合は当然1問分の配点しか与えない。

模範解答の登録

まずは模範解答.texに模範解答を登録します。

%\模範解答登録{問題番号}{正解の選択肢}{配点}のように登録

\模範解答登録{1}{2}{6}%
\模範解答登録{2}{1}{2}%
...
\模範解答登録{55}{9}{5}%
\模範解答登録{56}{0}{5}%

%上で登録したもののうち,完答問題に該当するものを
%\完答問題登録{問題番号1, 問題番号2, 問題番号3, ...}のように完答問題のセットごとに登録します。
\完答問題登録{28,29}
...
\完答問題登録{36,37}

%同様に,順不同問題に該当するものを
%\順不同問題登録{問題番号1, 問題番号2, 問題番号3, ...}のように順不同問題のセットごとに登録します。
\順不同問題登録{53,54,55,56}

また,準備として\@namedefの完全展開版である,\e@namedefを以下のように定義します。

\def\e@namedef#1{\expandafter\edef\csname#1\endcsname}

%latex.ltx内の\@namedefの定義内の\defを\edefに置き換えたもの
%\def\@namedef#1{\expandafter\def\csname #1\endcsname
%\模範解答登録の定義
\def\模範解答登録#1#2#3{%
\e@namedef{#1解答}{#2}%
\e@namedef{#1配点}{#3}%
\e@namedef{#1順不同解答}{#2}%これは順不同問題にしか使わないが,面倒なのですべての問題において定義しておく
}

次に,\完答問題登録の定義を確認します。完答問題の集合(以下,「完答集合」)に含まれるそれぞれの問題から完答集合への逆引きを内部で作ります。

例えば \完答問題登録{28,29} を実行すると,その中で \e@namedef{完答集合28}{28,29} および \e@namedef{完答集合29}{28,29} が実行されます。

%%%完答問題
\newcounter{完答問題に必要な得点数}%
\def\完答問題登録#1{%
\setcounter{完答問題に必要な得点数}{0}%
%配列のそれぞれの要素に対してループ
\expandafter\@for\expandafter\@完答問題\expandafter:\expandafter=#1\do{%%
\e@namedef{完答集合\@完答問題}{#1}%
\stepcounter{完答問題に必要な得点数}%
\ifnum\arabic{完答問題に必要な得点数}=\@ne
\else
\e@namedef{\@完答問題 配点}{0}%完答問題のセットのうち最初の問題以外の配点を0に上書き
\fi
}%%loop
\e@namedef{#1の完答問題数}{\arabic{完答問題に必要な得点数}}%
}

次に,順不同問題の定義に移ります。ここでは,順不同問題の集合に属する問題から順不同問題の正解の選択肢群への写像を作成します。 例えば,

\順不同問題登録{53,54,55,56}\e@namedef{順不同集合53}{53,54,55,56}
\e@namedef{順不同集合54}{53,54,55,56}
\e@namedef{順不同集合55}{53,54,55,56}
\e@namedef{順不同集合56}{53,54,55,56}
\e@namedef{正解集合53,54,55,56}{4,5,9,0}
\e@namedef{問56から正解集合への写像}{\正解集合53,54,55,56}

のように展開されます。ただし,\問○○から正解集合への写像はその集合内で最も問題番号が大きい問題のときのみ生成されるようにします。これは後で採点をする際に,最後の問題が来るまで採点を保留するためのものです。

%%%順不同問題
\newcounter{順不同集合内の問題数}%
\def\順不同問題登録#1{%
\@tempcnta =\z@
\setcounter{順不同集合内の問題数}{0}%

%配列のそれぞれの要素に対してループ
\@for\@順不同問題:=#1\do{%%
\e@namedef{順不同集合\@順不同問題}{#1}%
\stepcounter{順不同集合内の問題数}%
%%%正解集合との逆引きを作成
\@ifundefined{正解集合#1}{%未定義の場合
\e@namedef{正解集合#1}{\@nameuse{\@順不同問題 解答}}%
}{%定義済の場合
\e@namedef{正解集合#1}{\@nameuse{正解集合#1},\@nameuse{\@順不同問題 解答}}
}
\e@namedef{\@順不同問題 の問題数}{\arabic{順不同集合内の問題数}}%
}%%ここまでループ
\@for\@順不同問題:=#1\do{%%
\advance\@tempcnta by\@ne
\ifnum\value{順不同集合内の問題数}=\@tempcnta%集合内で最後の問題のときのみ生成
\e@namedef{\@@順不同問題 から正解集合の写像}{\csname 正解集合#1\endcsname}
\fi
}
}

採点の部分

次に,採点の部分の実装の説明をします。 まず,マークシートを読み取った結果を登録します。

\def\読み取り結果#1#2{%
\setcounter{問題番号}{0}%
\setcounter{出席番号}{#1}%
\stepcounter{生徒数}%
%それぞれの生徒に対し,記入解答を登録する
\@for\@生徒解答:=#2\do
\stepcounter{問題番号}%
\e@namedef{\arabic{出席番号}番の人の解答記入数値\arabic{問題番号}}{%
\ifx\@生徒解答\@empty -\else\@生徒解答\fi}%無記入の場合には-に置き換える
}%%ここまでループ
\setcounter{問題数}{\arabic{問題番号}}%
}

単答問題

これは単純です。記入した値と解答の数字を確認し,この2つの値が等しい場合にのみ配点を与えます。なお,正答率を計算するため,正誤のフラグも用意しています。

\ifnum\@nameuse{\arabic{出席番号}番の人の解答記入数値\arabic{k}}=\@nameuse{\arabic{k}解答}\relax%%正解のとき
\e@namedef{\arabic{出席番号}番の問\arabic{k}の正誤}{1}%正誤フラグ
\e@namedef{\arabic{出席番号}番の問\arabic{k}の配点}{\@nameuse{\arabic{k}配点}}%配点を与える
\advance \@tempcnta by \@nameuse{\arabic{k}配点}%合計点を計算
\else%不正解
\e@namedef{\arabic{出席番号}番の問\arabic{k}の正誤}{0}%正誤フラグに0を代入
\e@namedef{\arabic{出席番号}番の問\arabic{k}の配点}{0}%配点を与えない
\fi

完答問題

先ほどの単答問題よりは複雑ですが,まだ簡単です。kを現在採点する問題の番号を保持するindexとして,\@ifundefined{完答集合\arabic{k}}{完答問題以外の処理}{完答問題の処理}により,これが定義されているときは完答問題の処理を行うようにします。

アイディアとしては,模範解答.texで用意した\完答集合に対してループを回し,この集合の要素の数(つまりその数だけ正解して初めて配点が与えられるような問題数)と正解した問題数が一致したときにのみ配点を与えます。

\@tempcnta = \z@%完答問題の問題数のカウンタ
\@tempcntb = \z@%完答問題の正解数のカウンタ
\expandafter\@for\expandafter\@完答問題\expandafter:\expandafter=\csname 完答集合\arabic{k}\endcsname\do{%ただしkは現在の問題番号を保持するindex
%記入が-のとき
\expandafter\expandafter\expandafter\ifx\csname\arabic{出席番号}番の人の解答記入数値\@完答問題\endcsname-\relax
\e@namedef{\arabic{出席番号}番の問\arabic{k}の正誤}{0}%正誤フラグに0を代入
\e@namedef{\arabic{出席番号}番の問\arabic{k}の配点}{0}%配点を与えない
\else%%%記入が-ではないとき
\advance \@tempcnta by \@ne
\ifnum\csname\arabic{出席番号}番の人の解答記入数値\@完答問題\endcsname=\@nameuse{\@完答問題 解答}\relax%%正解のとき
\e@namedef{\arabic{出席番号}番の問\@完答問題 の正誤}{1}%正誤フラグ
\advance \@tempcntab by \@ne
\else%%%不正解のとき
\e@namedef{\arabic{出席番号}番の問\@完答問題 の正誤}{0}%正誤フラグ
\fi
\fi%%-かどうかの場合分け終了
}%%完答問題内のループ終了
\ifnum\@tempcnta=\@tempcntb\relax
\e@namedef{\arabic{出席番号}番の問\arabic{k}の配点}{\@nameuse{\arabic{k}配点}}%すべての問題に正解したときのみ配点を与える
\fi

順不同問題

続いて,最も難関な順不同問題の処理を行います。方針として,順不同問題のそれぞれについてループを回し,解答と比較します。よって,計算量のオーダーとしては,n2のオーダーになります。このとき,正解した際に解答を☃に置き換えることがポイントです。こうすることで,同じ解答に対して重複して加点されるのを防ぎます。

\@ifundefined{\arabic{k}から正解集合の写像}{\relax}{%順不同問題の最後の問題のときのみ処理
\@tempcntb = \z@%%順不同集合内の問題数
\expandafter\@for\expandafter\@順不同\expandafter:\expandafter=\csname 順不同集合\arabic{k}\endcsname\do{%ループ1開始
\expandafter\expandafter\expandafter\ifx\csname\arabic{出席番号}番の人の解答記入数値\@順不同\endcsname-\relax%はじめに記入が-の場合の処理
\e@namedef{\arabic{出席番号}番の問\@順不同 の正誤}{0}%正誤フラグに0を代入
\e@namedef{\arabic{出席番号}番の問\@順不同 の配点}{0}%配点を与えない
\else%%%記入が-ではないときの処理
\expandafter\@for\expandafter\@順不同解答\expandafter:\expandafter=\csname 順不同集合\arabic{k}\endcsname\do{%ループ2開始
\expandafter\expandafter\expandafter\ifx\csname\arabic{出席番号}番の問\@順不同 の正誤\endcsname1\relax \else %すでに正解の場合はなにもしない
\expandafter\expandafter\expandafter\ifx\expandafter\csname\expandafter\arabic\expandafter{\expandafter\expandafter\expandafter\expandafter\expandafter}\expandafter\expandafter\expandafter\expandafter\expandafter\expandafter\expandafter\expandafter\expandafter\expandafter\expandafter\@順不同\expandafter\endcsname\csname\@順不同解答 順不同解答\endcsname\relax%%正解のとき
\e@namedef{\@順不同解答 順不同解答}{}%正解した場合は解答を☃に置き換え,もうヒットしないようにする(4,5,9,0が正解のとき,4,4,4,4という解答に満点を与えるのを防ぐため)
%%可読性向上のため,\expandafter\patchcmd\expandafter{\csname 問\arabic{k}解答\endcsname}{\@正解集合}{☃}{}{}としてもよい(ただし,順不同解答集合が「相異なる1桁の数値のみの羅列」であることが保証されているものとします)
\advance \@tempcnta by \@nameuse{\@順不同 配点}%合計点を計算
\e@namedef{\arabic{出席番号}番の問\@順不同 の正誤}{1}%正誤フラグ
\advance \@tempcntb by \@ne
\e@namedef{\arabic{出席番号}番の問\@順不同 番の正解数}{\the\@tempcntb}
\else%%不正解のとき
\e@namedef{\arabic{出席番号}番の問\@順不同 の正誤}{0}%正誤フラグ
\fi
\fi%%すでに正解の場合はなにもしない
}%ループ2終了
\fi%記入が-かどうかの場合分け終了
}%ループ1終了
%%答えをリセット
\expandafter\@for\expandafter\@@順不同解答\expandafter:\expandafter=\csname 順不同集合\arabic{k}\endcsname\do{%
\e@namedef{\@@順不同解答 順不同解答}{\@nameuse{\@@順不同解答 解答}}%一応答えを保存しておくため,解答とは別に順不同用の解答を用意し,こちらを☃に変えている
}%
}%

採点データの集計

\データ集計にて平均点・標準偏差・順位・偏差値をはじめ,設問別得点率のレーダーチャート・得点分布・成績優秀者などの採点データを集計。具体的な実装はここでは省略しますが,レーダーチャートの作り方として参考にdoraTeXさんの記事を貼っておきます。

doratex.hatenablog.jp

本体となるTeXファイル

\begin{document}
\def\何位まで掲載するか{6}%成績優秀者を何位まで掲載するか

%第1引数は出席番号,第2引数はマークシートリーダの読み取り結果のカンマ区切りの配列
\読み取り結果{1}{2,1,3,4,3,4,3,4,2,1,3,4,2,2,3,3,4,3,1,4,4,4,1,3,2,1,4,5,2,1,3,5,3,1,2,1,5,1,2,4,1,4,4,2,2,2,1,3,4,1,1,3,3,5,7,9}

%採点の実行
\正誤判定{1}
...
\正誤判定{40}

%平均・偏差値などの採点データの集計
\データ集計

%それぞれの生徒の個人成績表を出力
\個人成績表{1} %出席番号1の生徒の個人成績表を出力
...
\個人成績表{40}%
\end{document}