R Shinyでは、学生が選択式問題に回答し、その場で正誤判定や得点を確認できる学習アプリを作成できます。
前回の記事「【R Shiny】理解度チェック・小テストの作り方|選択式問題と自動採点」では、1問版と3問版の理解度チェックを作成しました。
しかし、前回のアプリでは、学生の回答や採点結果は画面に表示されるだけで、ファイルには保存されませんでした。
今回は、学生の回答、正誤、得点、回答時刻、解答時間をCSVファイルに記録する方法を紹介します。
具体的には、次の機能を実装します。
- 選択式問題を1問ずつ表示する
- 回答ボタンを押すと正誤と解説を表示する
- 各問題の解答時間を秒単位で測定する
- 回答時刻を記録する
- 3問の回答結果と合計得点を表示する
- 学習履歴をCSVファイルとしてダウンロードする
今回も題材は薬物動態です。
なお、今回の方法は、学生が自分のパソコンにCSVファイルを保存する方式です。学生の操作なしに教員側へ回答を自動収集する仕組みではありません。
1. なぜ回答履歴を保存するのか?
理解度チェックでは、正解したかどうかをその場で確認するだけでも学習に役立ちます。
しかし、回答履歴を保存できれば、後から学習結果を確認できます。
例えば、次のような情報を記録できます。
| 項目 | 記録内容 |
|---|---|
| 問題番号 | 1、2、3 |
| 問題文 | 出題した問題 |
| 学生の回答 | 選択した選択肢 |
| 正解 | あらかじめ設定した正解 |
| 正誤 | TRUEまたはFALSE |
| 得点 | 正解なら1点、不正解なら0点 |
| 回答時刻 | 回答ボタンを押した時刻 |
| 解答時間 | 出題開始から回答までの秒数 |
Googleフォームでも回答内容や得点を記録できます。フォームに解答した最終時刻を記録することはできますが、各問題の解答時間を取得することはできません。
一方、R Shinyでは、問題の表示開始時刻と回答時刻を取得する処理を組み込むことで、解答時間も測定できます。
ただし、今回のコードで測定するのは、サーバー側で出題を開始した時刻から、回答操作をサーバーが受け付けた時刻までの経過時間です。
ブラウザに問題が実際に表示された瞬間からの厳密な時間ではありません。通信や画面描画による遅延が含まれる可能性があります。
通常の教材で解答時間の目安を記録する用途には利用できますが、ミリ秒単位の反応時間実験などには、そのまま使用しないでください。
2. 今回作成するアプリの仕組み
今回は、前回の3問を1問ずつ出題する方式に変更します。
アプリの流れは次のとおりです。
アプリを起動
↓
問題1を表示
↓
学生が回答
↓
回答時刻と解答時間を記録
↓
正誤と解説を表示
↓
「次の問題へ」をクリック
↓
問題2・問題3も同様に回答
↓
合計得点と回答履歴を表示
↓
CSVファイルをダウンロード
1問ずつ表示する理由は、問題ごとの解答時間を測定しやすくするためです。
3問を同時に表示すると、学生がどの問題をいつ読み始めたかを把握することが難しくなります。
一方、1問ずつ出題すれば、各問題の開始時刻と回答時刻を区別できます。
3. 必要な環境とフォルダの作成
今回使用するものは次の3つです。
- R
- RStudio
- Shinyパッケージ
追加のRパッケージは必要ありません。
Shinyをまだインストールしていない場合は、RStudioのConsoleで次のコードを実行してください。
# Shinyパッケージをインストールする
# インストール済みの場合は不要
install.packages("shiny")
次に、デスクトップなどに次のフォルダを作成します。
Shiny_Quiz_CSV
└── app.R
RStudioで新しいR Scriptを作成し、次の完成コードをコピーしてください。
ファイル名は app.R として保存します。
4. 完成コード:解答時間の測定とCSV保存
今回のコードは少し長くなりますが、初めて使用する関数や処理にはコメントを付けています。
まずはコード全体をコピーして実行し、動作を確認してください。その後、各部分の役割を説明します。
# ============================================================
# R Shiny 理解度チェック
# 回答履歴・得点・解答時間の記録とCSVダウンロード
# ============================================================
# Shinyパッケージを読み込む
library(shiny)
# ============================================================
# 1. 問題データを作成する
# ============================================================
# 問題文を保存する
questions <- c(
"CLが一定のままVdが2倍になると、消失半減期はどうなりますか?",
"Vdが一定のままCLが2倍になると、消失半減期はどうなりますか?",
"線形の1-コンパートメントモデルで、消失半減期を決定する組み合わせはどれですか?"
)
# 各問題の選択肢を保存する
# list()を使うと、問題ごとに選択肢をまとめられる
choices_list <- list(
# 問題1の選択肢
c(
"半分になる",
"変化しない",
"2倍になる",
"4倍になる"
),
# 問題2の選択肢
c(
"半分になる",
"変化しない",
"2倍になる",
"4倍になる"
),
# 問題3の選択肢
c(
"CLとVd",
"投与量と投与速度",
"吸収速度定数と投与量",
"最高血中濃度と投与間隔"
)
)
# 各問題の正解
correct_answers <- c(
"2倍になる",
"半分になる",
"CLとVd"
)
# 各問題の解説
explanations <- c(
paste0(
"消失半減期は t1/2 = 0.693 × Vd / CL ",
"で表されます。CLが一定なら、",
"Vdが2倍になると半減期も2倍になります。"
),
paste0(
"消失半減期はCLに反比例します。",
"Vdが一定なら、CLが2倍になると",
"半減期は半分になります。"
),
paste0(
"線形の1-コンパートメントモデルでは、",
"消失半減期はCLとVdによって決まります。"
)
)
# 問題数を取得する
n_questions <- length(questions)
# ============================================================
# 2. 画面を作成する
# ============================================================
ui <- fluidPage(
# アプリのタイトル
titlePanel("薬物動態 理解度チェック:学習履歴の記録"),
# アプリの説明
p(
"問題は1問ずつ表示されます。",
"回答すると正誤と解説が表示され、",
"解答時間も記録されます。"
),
# 今回の測定方法に関する注意
p(
"※解答時間はサーバー側で測定する概算値です。"
),
hr(),
# 問題・解説・結果を表示する場所
uiOutput("quiz_area")
)
# ============================================================
# 3. サーバー側の処理
# ============================================================
server <- function(input, output, session) {
# ----------------------------------------------------------
# 3-1. 現在の状態を保存する
# ----------------------------------------------------------
# 現在の問題番号
# 最初は問題1
current_question <- reactiveVal(1L)
# 現在の画面状態
# question:問題を表示
# feedback:正誤と解説を表示
# finished:全問題終了
phase <- reactiveVal("question")
# 問題の開始時刻
# アプリのセッション開始時刻を初期値とする
start_time <- reactiveVal(Sys.time())
# 直前の問題の判定結果
last_result <- reactiveVal(NULL)
# ----------------------------------------------------------
# 3-2. 回答履歴を保存するための表を作成
# ----------------------------------------------------------
# 最初は0行のデータフレーム
# データフレームは表形式のデータを扱うためのもの
history <- reactiveVal(
data.frame(
question_number = integer(0),
question = character(0),
answer = character(0),
correct_answer = character(0),
is_correct = logical(0),
score = integer(0),
start_time = character(0),
answered_at = character(0),
elapsed_seconds = numeric(0),
stringsAsFactors = FALSE
)
)
# ----------------------------------------------------------
# 3-3. 問題・解説・結果の画面を作成する
# ----------------------------------------------------------
output$quiz_area <- renderUI({
# 現在の画面状態を取得
state <- phase()
# 現在の問題番号を取得
i <- current_question()
# --------------------------------------------------------
# 問題を表示する場合
# --------------------------------------------------------
if (state == "question") {
return(
tagList(
# 問題番号
h3(
paste0(
"問題 ",
i,
" / ",
n_questions
)
),
# 問題文
p(questions[i]),
# 選択式の回答欄
radioButtons(
# 問題ごとに異なるIDを使う
inputId = paste0("answer_", i),
label = "正しいものを1つ選んでください",
choices = choices_list[[i]],
# 初期状態では選択しない
selected = character(0)
),
# 回答ボタン
actionButton(
inputId = "submit",
label = "回答する"
)
)
)
}
# --------------------------------------------------------
# 正誤と解説を表示する場合
# --------------------------------------------------------
if (state == "feedback") {
# 直前の採点結果を取得
result <- last_result()
return(
tagList(
h3(
paste0(
"問題 ",
i,
" の結果"
)
),
# 正解・不正解を表示
h4(
if (result$is_correct) {
"正解!"
} else {
"不正解"
}
),
# 学生の回答
p(
paste0(
"あなたの回答:",
result$answer
)
),
# 正解
p(
paste0(
"正解:",
correct_answers[i]
)
),
# 解説
p(explanations[i]),
# 解答時間
p(
paste0(
"解答時間:約",
sprintf("%.1f", result$elapsed_seconds),
"秒"
)
),
# 次の問題へ進むボタン
actionButton(
inputId = "next_question",
label = if (i < n_questions) {
"次の問題へ"
} else {
"結果を見る"
}
)
)
)
}
# --------------------------------------------------------
# 全問題が終了した場合
# --------------------------------------------------------
if (state == "finished") {
# 保存した回答履歴を取得
data <- history()
# 得点の合計
total_score <- sum(data$score)
return(
tagList(
h3("すべての問題が終了しました"),
# 合計得点
h3(
paste0(
"得点:",
total_score,
" / ",
n_questions,
"点"
)
),
# 回答履歴の見出し
h4("回答履歴"),
# 回答履歴を表で表示する
tableOutput("history_table"),
hr(),
# CSVダウンロードボタン
downloadButton(
outputId = "download_csv",
label = "回答履歴をCSVで保存"
),
# 保存に関する注意
p(
"※ボタンを押すとCSVファイルがダウンロードされます。",
"自動保存ではありません。"
)
)
)
}
})
# ----------------------------------------------------------
# 3-4. 回答ボタンを押したときの処理
# ----------------------------------------------------------
observeEvent(input$submit, {
# 問題画面以外では処理しない
if (phase() != "question") {
return()
}
# 現在の問題番号
i <- current_question()
# 現在の問題の回答欄のID
answer_id <- paste0("answer_", i)
# 回答を取得する
answer <- input[[answer_id]]
# 未回答ならメッセージを表示して終了
if (is.null(answer) || length(answer) == 0) {
showNotification(
"選択肢を選んでください。",
type = "warning"
)
return()
}
# --------------------------------------------------------
# 回答時刻と解答時間を計算
# --------------------------------------------------------
# 回答を受け付けた時刻
answered_time <- Sys.time()
# 出題開始から回答までの経過秒数
elapsed <- as.numeric(
difftime(
answered_time,
start_time(),
units = "secs"
)
)
# 小数第1位まで丸める
elapsed <- round(elapsed, 1)
# --------------------------------------------------------
# 正誤を判定する
# --------------------------------------------------------
is_correct <- identical(
answer,
correct_answers[i]
)
# 正解なら1点、不正解なら0点
point <- if (is_correct) 1L else 0L
# --------------------------------------------------------
# 回答履歴に1行追加する
# --------------------------------------------------------
# 今回の回答を1行の表にする
new_row <- data.frame(
question_number = i,
question = questions[i],
answer = answer,
correct_answer = correct_answers[i],
is_correct = is_correct,
score = point,
start_time = format(
start_time(),
"%Y-%m-%d %H:%M:%S"
),
answered_at = format(
answered_time,
"%Y-%m-%d %H:%M:%S"
),
elapsed_seconds = elapsed,
stringsAsFactors = FALSE
)
# これまでの履歴に今回の回答を追加
history(
rbind(
history(),
new_row
)
)
# --------------------------------------------------------
# 正誤と解答時間を保持する
# --------------------------------------------------------
last_result(
list(
answer = answer,
is_correct = is_correct,
elapsed_seconds = elapsed
)
)
# 解説画面に切り替える
phase("feedback")
})
# ----------------------------------------------------------
# 3-5. 次の問題へ進む処理
# ----------------------------------------------------------
observeEvent(input$next_question, {
# 解説画面以外では処理しない
if (phase() != "feedback") {
return()
}
# 現在の問題番号
i <- current_question()
# 最後の問題まで終了した場合
if (i >= n_questions) {
# 結果画面に切り替える
phase("finished")
return()
}
# 次の問題番号に進める
current_question(i + 1L)
# 次の問題の開始時刻を記録
start_time(Sys.time())
# 問題画面に切り替える
phase("question")
})
# ----------------------------------------------------------
# 3-6. 回答履歴を表で表示する
# ----------------------------------------------------------
output$history_table <- renderTable({
# 回答履歴を取得
data <- history()
# 表示する列を選ぶ
data.frame(
問題番号 = data$question_number,
回答 = data$answer,
正誤 = ifelse(
data$is_correct,
"正解",
"不正解"
),
得点 = data$score,
解答時間_秒 = data$elapsed_seconds,
check.names = FALSE
)
})
# ----------------------------------------------------------
# 3-7. CSVファイルをダウンロードする
# ----------------------------------------------------------
output$download_csv <- downloadHandler(
# ダウンロード時のファイル名
filename = function() {
paste0(
"shiny_quiz_",
format(Sys.time(), "%Y%m%d_%H%M%S"),
".csv"
)
},
# CSVファイルの内容を作成する
content = function(file) {
# 回答履歴を取得する
data <- history()
# CSV形式で書き出す
write.csv(
data,
file = file,
row.names = FALSE,
fileEncoding = "UTF-8"
)
},
# ファイルの種類
contentType = "text/csv"
)
}
# ============================================================
# 4. アプリを起動する
# ============================================================
shinyApp(
ui = ui,
server = server
)
5. アプリを実行する
コードを app.R として保存したら、RStudioでファイルを開き、「Run App」をクリックします。
問題1が表示されたら、次の順番で操作してください。
- 選択肢を選び、「回答する」をクリックする
- 正誤、解説、解答時間が表示されることを確認する
- 「次の問題へ」をクリックする
- 問題2と問題3にも回答する
- 最後に「結果を見る」をクリックする
- 合計得点と回答履歴を確認する
- 「回答履歴をCSVで保存」をクリックする
ブラウザのダウンロード先にCSVファイルが保存されれば成功です。
※このコードはShinyの標準的な関数に基づいて作成していますが、記事作成時点では実際のRStudioでの動作確認を行っていません。公開前に上記の操作を確認してください。
6. 解答時間はどのように測定しているのか?
今回のコードで重要なのは、Sys.time() と difftime() です。
Sys.time()とは?
Sys.time() は、現在の日付と時刻を取得するRの関数です。
例えば、RStudioのConsoleで次のコードを実行してください。
# 現在の日時を表示する
Sys.time()
実行した時点の日時が表示されます。
今回のアプリでは、問題の開始時刻を次のように保持しています。
# 問題の開始時刻
start_time <- reactiveVal(Sys.time())
また、回答ボタンが押されたときには、次のコードで回答時刻を取得します。
# 回答時刻を取得する
answered_time <- Sys.time()
difftime()とは?
difftime() は、2つの日時の差を計算する関数です。
例えば、次のように使用します。
# 開始時刻
start <- Sys.time()
# 3秒待つ
Sys.sleep(3)
# 終了時刻
end <- Sys.time()
# 経過時間を秒単位で計算する
difftime(
end,
start,
units = "secs"
)
およそ3秒という結果が得られます。
今回のアプリでは、次のように計算しています。
elapsed <- as.numeric(
difftime(
answered_time,
start_time(),
units = "secs"
)
)
as.numeric() は、計算結果を数値として取り出すための関数です。
また、round(elapsed, 1) とすることで、小数第1位までの秒数に丸めています。
ただし、この測定には、通信や画面描画の遅延が含まれる場合があります。
また、最初の問題ではアプリのセッション開始時刻を、2問目以降では次の問題へ進む処理を行った時刻を開始時刻としています。いずれも、ブラウザに問題が完全に表示された瞬間を厳密に取得しているわけではありません。
7. 回答履歴を保存するreactiveVal()とdata.frame()
前回の記事では、reactiveVal() を使って正誤判定の結果を保持しました。
今回は、複数の回答を表形式で保持するために使用します。
history <- reactiveVal(
data.frame(
question_number = integer(0),
question = character(0),
answer = character(0),
correct_answer = character(0),
is_correct = logical(0),
score = integer(0),
start_time = character(0),
answered_at = character(0),
elapsed_seconds = numeric(0),
stringsAsFactors = FALSE
)
)
data.frame() は、Rで表形式のデータを扱うための基本的な仕組みです。
ここでは、最初に0行の表を作成しています。
integer(0) は整数型、character(0) は文字列型、logical(0) はTRUE/FALSE型、numeric(0) は数値型の空のデータを表します。
学生が回答するたびに、次のコードで履歴を追加します。
history(
rbind(
history(),
new_row
)
)
rbind() は、複数の表を行方向に結合する関数です。
つまり、問題1に回答すると1行、問題2に回答すると2行、問題3に回答すると3行の履歴が保存されます。
ただし、ここでの「保存」は、Shinyアプリの実行中にメモリ上で保持することを意味します。
この時点では、パソコン上のCSVファイルには保存されていません。
8. CSVファイルを保存するdownloadButton()とdownloadHandler()
今回新しく使用するのが、downloadButton() と downloadHandler() です。
この2つを組み合わせることで、Shinyアプリ内のデータをファイルとしてダウンロードできます。
downloadButton()とは?
downloadButton() は、ダウンロード用のボタンを作成する関数です。
今回のコードでは次のように使用しています。
downloadButton(
outputId = "download_csv",
label = "回答履歴をCSVで保存"
)
outputId は、ダウンロード処理を識別するための名前です。
label は、ボタンに表示する文字列です。
downloadHandler()とは?
downloadHandler() は、ダウンロードするファイル名や内容を指定するための関数です。
output$download_csv <- downloadHandler(
filename = function() {
"quiz_result.csv"
},
content = function(file) {
write.csv(
history(),
file = file,
row.names = FALSE
)
}
)
filename では、ダウンロードするファイル名を指定します。
content では、ダウンロードするファイルの内容を作成します。
今回使用している write.csv() は、RのデータフレームをCSV形式で書き出す関数です。
row.names = FALSE は、Rの行番号をCSVファイルに追加しないための設定です。
完成コードでは、ファイル名に日時を含めることで、複数回実行した場合でもファイルを区別しやすくしています。
これらの関数はShinyの公式ドキュメントでも紹介されています。
参考:Shiny公式ドキュメント:File Downloads
9. 保存したCSVファイルを確認する
ダウンロードしたCSVファイルには、問題ごとの回答履歴が保存されています。
例えば、次のような内容になります。
| question_number | answer | is_correct | score | elapsed_seconds |
|---|---|---|---|---|
| 1 | 2倍になる | TRUE | 1 | 8.4 |
| 2 | 変化しない | FALSE | 0 | 15.2 |
| 3 | CLとVd | TRUE | 1 | 6.8 |
※この表は説明用の架空データで、実際のCSVには問題文、正解、回答時刻などの列も含まれます。
CSVファイルは、ExcelやRで開くことができます。
ただし、日本語を含むUTF-8形式のCSVファイルは、Windows版Excelで直接ダブルクリックして開くと文字化けする場合があります。
その場合は、Excelの「データ」タブから「テキストまたはCSVから」を選び、文字コードをUTF-8として読み込んでください。
RでCSVファイルを読み込む
RStudioでは、次のように読み込めます。
# CSVファイルを読み込む
# ファイル選択画面が表示される
data <- read.csv(
file.choose(),
fileEncoding = "UTF-8"
)
# 内容を確認する
View(data)
# 合計得点を計算する
sum(data$score)
# 平均解答時間を計算する
mean(data$elapsed_seconds)
file.choose() は、読み込むファイルを選択するための関数です。
View() は、データを表形式で確認するための関数です。
sum() は合計、mean() は平均を計算します。
このように、Shinyで記録したデータをRで再解析できます。
10. 複数の学生に利用してもらう場合の注意点
今回のアプリでは、回答履歴を server 関数の内部で作成しています。
そのため、通常のShinyのセッション管理では、学生ごとに独立した回答履歴が保持されます。
ただし、アプリを終了したり、セッションが切断されたりすると、メモリ上の回答履歴は失われます。
また、今回のアプリには、学生の氏名や学籍番号を入力する機能はありません。
したがって、ダウンロードしたCSVファイルだけでは、誰の回答かを特定できません。
CSVダウンロード方式のメリット
今回の方法には、次の利点があります。
- サーバー側のファイル保存処理が不要
- 学生ごとの回答を独立して扱いやすい
- CSVファイルをExcelやRで解析できる
- 比較的簡単なコードで実装できる
一方、欠点もあります。
- 学生がダウンロードボタンを押す必要がある
- ダウンロードを忘れると記録を回収できない
- 教員が回答を集めるには、別途提出方法が必要
- 途中でアプリを閉じると、未保存の履歴が失われる
このため、授業で全学生の回答を確実に回収したい場合には、別の方法が必要です。
教員側に自動保存することも可能?
R Shinyでは、回答のたびにサーバー側へ記録を保存する仕組みも実装できます。
例えば、CSVファイルやデータベースに回答履歴を書き込む方法があります。
ただし、複数の学生が同時に回答する場合、全員が同じCSVファイルへ書き込む設計では、書き込みの競合やデータ破損が発生する可能性があります。
そのため、授業で本格的に利用する場合には、同時アクセスに対応したデータベースや、適切な保存処理を検討する必要があります。
また、学籍番号などの個人情報を扱う場合は、大学の情報管理規程に従い、保存先やアクセス権限を適切に設定する必要があります。
11. 解答時間の記録で何が分かるのか?
今回の方法では、正誤だけでなく、各問題に回答するまでの経過時間も記録できます。
例えば、ある問題で正答率が低く、解答時間も長い場合、その問題が学生にとって難しかった可能性があります。
また、正答率は高いものの解答時間が長い場合、知識の理解や判断に時間がかかっている可能性も考えられます。
ただし、解答時間が長いことが、必ずしも理解不足を意味するわけではありません。
学生が途中で考えるのを中断したり、別の作業を行ったりしている場合もあります。
さらに、解答時間と学習効果の関係を検討する場合には、問題の難易度や学生間の違いなども考慮する必要があります。
解答時間は、正答率を補完する指標の1つとして利用するのが適切でしょう。
12. 今回のアプリの限界と今後の発展
今回のアプリでは、回答内容、正誤、得点、回答時刻、解答時間をCSVファイルとして保存できるようにしました。
しかし、次の機能は実装していません。
- 学籍番号などによる学生の識別
- 教員側への回答の自動収集
- 回答途中の履歴の永続保存
- 選択肢を変更した履歴の記録
- ブラウザ上での厳密な表示時刻の測定
- 複数学生の回答を統合する機能
特に、今回のアプリで記録するのは、最終的に回答ボタンを押した時点の回答です。
学生が途中で何度選択肢を変更したか、どの順番で選択したかは記録していません。
こうした操作履歴も記録できれば、学生が正解に至るまでの過程をより詳しく分析できる可能性があります。
ただし、そのためには追加のイベント記録処理が必要です。
13. まとめ
今回は、R Shinyを使った理解度チェックに、回答履歴と解答時間の記録機能を追加しました。
今回学習した主な関数は、次のとおりです。
| 関数 | 役割 |
|---|---|
Sys.time() | 現在時刻を取得する |
difftime() | 2つの時刻の差を計算する |
data.frame() | 表形式のデータを作成する |
rbind() | データを行方向に追加する |
reactiveVal() | アプリ内で値を保持する |
renderTable() | 表を画面に表示する |
downloadButton() | ダウンロードボタンを作成する |
downloadHandler() | ダウンロードするファイルを生成する |
write.csv() | CSV形式でデータを書き出す |
Googleフォームでも、選択式問題の作成や自動採点、回答の保存は可能です。
一方、R Shinyでは、今回のように解答時間の測定を組み込んだり、今後さらに操作履歴を記録したりするなど、学習アプリを柔軟に拡張できます。
ただし、今回の方式では、学生自身がCSVファイルをダウンロードする必要があります。
授業での利用を考える場合には、回答の自動収集や、複数学生による同時アクセスへの対応なども重要になります。
R Shinyを利用することで、単に問題を出題するだけでなく、学習過程を記録・分析する教材へと発展させることができます。

コメント