【R Shiny】学生の操作履歴を記録する方法|選択肢の変更・ボタン操作をCSV保存

Shiny

R Shinyでは、学生の回答や得点だけでなく、アプリ内で行われた操作を記録することもできます。

例えば、選択式問題に回答する際、学生が最初から正解を選んだのか、何度か選択肢を変更してから回答したのかを区別できます。

前回の記事「【R Shiny】小テストの回答・得点・解答時間をCSV保存する方法」では、問題ごとの回答、正誤、得点、開始時刻、回答時刻、解答時間を記録するアプリを作成しました。

今回は、さらに学生の操作履歴(イベントログ)を記録する方法を紹介します。

具体的には、次の情報を記録します。

  • 回答を開始した時刻
  • 選択肢を選んだ時刻
  • 選択肢を変更した時刻
  • 「回答する」ボタンを押した時刻
  • 「次の問題へ」ボタンを押した時刻
  • 各操作が問題開始から何秒後に行われたか

記録した操作履歴は、CSVファイルとしてダウンロードできるようにします。

1. 操作ログとは?

操作ログとは、アプリ内で発生した操作を時系列で記録したデータです。

例えば、ある学生が次のように回答したとします。

時刻操作内容
10:00:00.000問題開始問題1
10:00:02.150選択変化しない
10:00:04.320選択変更2倍になる
10:00:05.100回答確定2倍になる
10:00:07.500次へ問題2

この場合、最終的な回答は「2倍になる」です。

しかし、操作履歴を見ると、最初は「変化しない」を選択し、その後「2倍になる」に変更したことが分かります。

通常の回答履歴では、最終的にどの選択肢を選んだかしか分かりません。

一方、操作ログを記録すれば、最終回答に至るまでの操作の順番や経過時間も確認できます。

ただし、操作履歴だけで学生の思考過程を直接知ることはできません。選択肢の変更には、知識の再検討だけでなく、誤操作なども含まれる可能性があります。

2. 今回作成するアプリの仕組み

今回も薬物動態に関する3問の選択式問題を使用します。

アプリの流れは次のとおりです。

アプリを起動
      ↓
「回答を開始する」をクリック
      ↓
問題1を表示
      ↓
選択肢を選ぶ
      ↓
必要に応じて選択肢を変更
      ↓
「回答する」をクリック
      ↓
正誤と解説を表示
      ↓
「次の問題へ」をクリック
      ↓
問題2・問題3も同様に回答
      ↓
得点と操作履歴を表示
      ↓
CSVファイルをダウンロード

選択肢を変更するたびに、操作の種類、選択した内容、操作時刻を記録します。

今回のCSV保存方式は、前回と同じく学生自身がファイルをダウンロードする方法です。

3. 必要な環境

使用するものは、R、RStudio、Shinyパッケージです。

追加のRパッケージは必要ありません。

Shinyがインストールされていない場合は、次のコードを実行してください。

install.packages("shiny")

デスクトップなどに、次のフォルダを作成します。

Shiny_Operation_Log
└── app.R

RStudioで新しいR Scriptを作成し、以下のコードを app.R として保存してください。

4. 完成コード:選択肢の変更とボタン操作を記録する

# ============================================================
# R Shiny 操作ログ記録アプリ
#
# 記録する内容
# ・問題開始
# ・選択肢の選択と変更
# ・回答確定
# ・次の問題への移動
# ・操作時刻と経過時間
#
# 操作ログと回答履歴をCSVでダウンロード
# ============================================================

library(shiny)


# ============================================================
# 1. 問題データ
# ============================================================

questions <- c(
  "CLが一定でVdが2倍になると、消失半減期は?",
  "Vdが一定でCLが2倍になると、消失半減期は?",
  "消失半減期を決定する組み合わせは?"
)

choices_list <- list(
  c("半分になる", "変化しない", "2倍になる", "4倍になる"),
  c("半分になる", "変化しない", "2倍になる", "4倍になる"),
  c("CLとVd", "投与量と投与速度",
    "吸収速度定数と投与量", "最高血中濃度と投与間隔")
)

correct_answers <- c(
  "2倍になる",
  "半分になる",
  "CLとVd"
)

explanations <- c(
  "t1/2 = 0.693 × Vd / CL です。Vdが2倍なら半減期も2倍になります。",
  "半減期はCLに反比例します。CLが2倍なら半減期は半分になります。",
  "線形の1-コンパートメントモデルでは、半減期はCLとVdで決まります。"
)

n_questions <- length(questions)


# ============================================================
# 2. 時刻を記録する関数
# ============================================================

# UNIX時刻を整数ミリ秒で取得する
now_ms <- function() {
  round(as.numeric(Sys.time()) * 1000)
}

# 整数ミリ秒を日時文字列に変換する
format_ms <- function(ms) {

  whole_seconds <- ms %/% 1000
  milliseconds <- ms %% 1000

  time_text <- format(
    as.POSIXct(
      whole_seconds,
      origin = "1970-01-01",
      tz = "UTC"
    ),
    "%Y-%m-%d %H:%M:%S",
    tz = Sys.timezone()
  )

  sprintf(
    "%s.%03d",
    time_text,
    as.integer(milliseconds)
  )
}


# ============================================================
# 3. UTF-8 BOM付きCSVを書き出す関数
# ============================================================

save_utf8_csv <- function(data, file) {

  # 一時ファイルを作成
  temp <- tempfile(fileext = ".csv")

  on.exit(unlink(temp), add = TRUE)

  write.csv(
    data,
    file = temp,
    row.names = FALSE,
    fileEncoding = "UTF-8",
    na = ""
  )

  # CSVをバイナリ形式で読み込む
  raw_data <- readBin(
    temp,
    what = "raw",
    n = file.info(temp)$size
  )

  # UTF-8 BOMを追加
  bom <- as.raw(c(0xEF, 0xBB, 0xBF))

  writeBin(
    c(bom, raw_data),
    file
  )
}


# ============================================================
# 4. UI
# ============================================================

ui <- fluidPage(

  titlePanel("薬物動態 小テスト:操作ログの記録"),

  p("選択肢の変更やボタン操作を時刻付きで記録します。"),

  p("※時刻はサーバー側で記録します。"),

  hr(),

  uiOutput("quiz_area"),

  hr(),

  uiOutput("download_area")

)


# ============================================================
# 5. Server
# ============================================================

server <- function(input, output, session) {

  # ----------------------------------------------------------
  # 5-1. アプリの状態
  # ----------------------------------------------------------

  phase <- reactiveVal("ready")

  current_question <- reactiveVal(1L)

  question_start_ms <- reactiveVal(NULL)

  last_result <- reactiveVal(NULL)


  # ----------------------------------------------------------
  # 5-2. 操作ログ用の表
  # ----------------------------------------------------------

  operation_log <- reactiveVal(

    data.frame(
      question_number = integer(0),
      event = character(0),
      detail = character(0),
      event_time = character(0),
      elapsed_seconds = numeric(0),
      stringsAsFactors = FALSE
    )

  )


  # ----------------------------------------------------------
  # 5-3. 回答履歴用の表
  # ----------------------------------------------------------

  answer_log <- reactiveVal(

    data.frame(
      question_number = integer(0),
      answer = character(0),
      correct_answer = character(0),
      is_correct = logical(0),
      score = integer(0),
      elapsed_seconds = numeric(0),
      stringsAsFactors = FALSE
    )

  )


  # ----------------------------------------------------------
  # 5-4. 操作ログを追加する関数
  # ----------------------------------------------------------

  add_log <- function(event, detail = "", time_ms = now_ms()) {

    i <- current_question()

    start <- question_start_ms()

    elapsed <- if (is.null(start)) {
      NA_real_
    } else {
      (time_ms - start) / 1000
    }

    new_row <- data.frame(
      question_number = i,
      event = event,
      detail = detail,
      event_time = format_ms(time_ms),
      elapsed_seconds = elapsed,
      stringsAsFactors = FALSE
    )

    operation_log(
      rbind(operation_log(), new_row)
    )

  }


  # ----------------------------------------------------------
  # 5-5. 画面表示
  # ----------------------------------------------------------

  output$quiz_area <- renderUI({

    state <- phase()
    i <- current_question()

    # 開始画面
    if (state == "ready") {

      return(
        tagList(
          h3("理解度チェック"),
          p("準備ができたら開始してください。"),
          actionButton(
            "start_quiz",
            "回答を開始する"
          )
        )
      )

    }

    # 問題画面
    if (state == "question") {

      return(
        tagList(

          h3(paste0("問題 ", i, " / ", n_questions)),

          p(questions[i]),

          radioButtons(
            inputId = paste0("answer_", i),
            label = "正しいものを選択してください",
            choices = choices_list[[i]],
            selected = character(0)
          ),

          actionButton(
            "submit",
            "回答する"
          )

        )
      )

    }

    # 正誤・解説画面
    if (state == "feedback") {

      result <- last_result()

      return(
        tagList(

          h3(
            if (result$is_correct) "正解!" else "不正解"
          ),

          p(paste0("あなたの回答:", result$answer)),

          p(paste0("正解:", correct_answers[i])),

          p(explanations[i]),

          p(
            sprintf(
              "解答時間:約%.3f秒",
              result$elapsed_seconds
            )
          ),

          actionButton(
            "next_question",
            if (i < n_questions) {
              "次の問題へ"
            } else {
              "結果を見る"
            }
          )

        )
      )

    }

    # 終了画面
    if (state == "finished") {

      data <- answer_log()

      return(
        tagList(

          h3("終了しました"),

          h3(
            paste0(
              "得点:",
              sum(data$score),
              " / ",
              n_questions
            )
          ),

          h4("回答履歴"),

          tableOutput("answer_table"),

          h4("操作履歴"),

          tableOutput("operation_table")

        )
      )

    }

  })


  # ----------------------------------------------------------
  # 5-6. 回答開始
  # ----------------------------------------------------------

  observeEvent(input$start_quiz, {

    if (phase() != "ready") return()

    current_question(1L)

    question_start_ms(now_ms())

    add_log("問題開始", "問題1")

    phase("question")

  })


  # ----------------------------------------------------------
  # 5-7. 選択肢の操作を記録
  # ----------------------------------------------------------

  # 問題ごとに異なるinputIdを監視する
  for (j in seq_len(n_questions)) {

    local({

      question_id <- j

      observeEvent(
        input[[paste0("answer_", question_id)]],
        {

          # 表示中の問題だけを記録する
          if (phase() != "question") return()

          if (current_question() != question_id) return()

          selected_answer <- input[[
            paste0("answer_", question_id)
          ]]

          if (is.null(selected_answer)) return()

          if (length(selected_answer) == 0) return()

          add_log(
            event = "選択肢を選択",
            detail = selected_answer
          )

        },
        ignoreNULL = TRUE
      )

    })

  }


  # ----------------------------------------------------------
  # 5-8. 回答確定
  # ----------------------------------------------------------

  observeEvent(input$submit, {

    if (phase() != "question") return()

    i <- current_question()

    answer <- input[[paste0("answer_", i)]]

    if (is.null(answer) || length(answer) == 0) {

      showNotification(
        "選択肢を選んでください。",
        type = "warning"
      )

      return()
    }

    # 回答時刻
    end_ms <- now_ms()

    # 解答時間
    elapsed <- (
      end_ms - question_start_ms()
    ) / 1000

    # 正誤
    correct <- identical(
      answer,
      correct_answers[i]
    )

    score <- if (correct) 1L else 0L

    # 操作ログ
    add_log(
      event = "回答確定",
      detail = answer,
      time_ms = end_ms
    )

    # 回答履歴
    new_answer <- data.frame(
      question_number = i,
      answer = answer,
      correct_answer = correct_answers[i],
      is_correct = correct,
      score = score,
      elapsed_seconds = elapsed,
      stringsAsFactors = FALSE
    )

    answer_log(
      rbind(answer_log(), new_answer)
    )

    last_result(
      list(
        answer = answer,
        is_correct = correct,
        elapsed_seconds = elapsed
      )
    )

    phase("feedback")

  })


  # ----------------------------------------------------------
  # 5-9. 次の問題へ
  # ----------------------------------------------------------

  observeEvent(input$next_question, {

    if (phase() != "feedback") return()

    i <- current_question()

    if (i >= n_questions) {

      add_log("テスト終了", "")

      phase("finished")

      return()
    }

    # 次の問題の開始時刻
    next_ms <- now_ms()

    current_question(i + 1L)

    question_start_ms(next_ms)

    add_log(
      "問題開始",
      paste0("問題", i + 1L),
      time_ms = next_ms
    )

    phase("question")

  })


  # ----------------------------------------------------------
  # 5-10. 回答履歴の表示
  # ----------------------------------------------------------

  output$answer_table <- renderTable({

    data <- answer_log()

    data.frame(
      "問題番号" = data$question_number,
      "回答" = data$answer,
      "正解" = data$correct_answer,
      "正誤" = ifelse(data$is_correct, "正解", "不正解"),
      "得点" = data$score,
      "解答時間(秒)" = sprintf(
        "%.3f",
        data$elapsed_seconds
      ),
      check.names = FALSE
    )

  }, rownames = FALSE)


  # ----------------------------------------------------------
  # 5-11. 操作履歴の表示
  # ----------------------------------------------------------

  output$operation_table <- renderTable({

    data <- operation_log()

    data.frame(
      "問題番号" = data$question_number,
      "操作" = data$event,
      "内容" = data$detail,
      "操作時刻" = data$event_time,
      "開始からの経過秒数" = ifelse(
        is.na(data$elapsed_seconds),
        "",
        sprintf("%.3f", data$elapsed_seconds)
      ),
      check.names = FALSE
    )

  }, rownames = FALSE)


  # ----------------------------------------------------------
  # 5-12. CSVダウンロードボタン
  # ----------------------------------------------------------

  output$download_area <- renderUI({

    if (phase() != "finished") return(NULL)

    tagList(

      h4("CSVファイルの保存"),

      downloadButton(
        "download_operations",
        "操作履歴をCSVで保存"
      ),

      downloadButton(
        "download_answers",
        "回答履歴をCSVで保存"
      )

    )

  })


  # ----------------------------------------------------------
  # 5-13. 操作ログCSV
  # ----------------------------------------------------------

  output$download_operations <- downloadHandler(

    filename = function() {
      paste0(
        "operation_log_",
        format(Sys.time(), "%Y%m%d_%H%M%S"),
        ".csv"
      )
    },

    content = function(file) {

      data <- operation_log()

      csv_data <- data.frame(
        "問題番号" = data$question_number,
        "操作" = data$event,
        "内容" = data$detail,
        "操作時刻" = data$event_time,
        "開始からの経過秒数" = sprintf(
          "%.3f",
          data$elapsed_seconds
        ),
        check.names = FALSE
      )

      save_utf8_csv(csv_data, file)

    },

    contentType = "text/csv"

  )


  # ----------------------------------------------------------
  # 5-14. 回答履歴CSV
  # ----------------------------------------------------------

  output$download_answers <- downloadHandler(

    filename = function() {
      paste0(
        "answer_log_",
        format(Sys.time(), "%Y%m%d_%H%M%S"),
        ".csv"
      )
    },

    content = function(file) {

      data <- answer_log()

      csv_data <- data.frame(
        "問題番号" = data$question_number,
        "回答" = data$answer,
        "正解" = data$correct_answer,
        "正誤" = ifelse(
          data$is_correct,
          "正解",
          "不正解"
        ),
        "得点" = data$score,
        "解答時間(秒)" = sprintf(
          "%.3f",
          data$elapsed_seconds
        ),
        check.names = FALSE
      )

      save_utf8_csv(csv_data, file)

    },

    contentType = "text/csv"

  )

}


# ============================================================
# 6. アプリ起動
# ============================================================

shinyApp(ui = ui, server = server)

5. アプリを実行する

RStudioで app.R を開き、「Run App」をクリックします。

「回答を開始する」ボタンを押すと、問題1が表示されます。

ここでは、最初に「変化しない」を選択し、その後「2倍になる」に変更してから「回答する」を押してみてください。

正誤と解説が表示されたら、「次の問題へ」を押します。

問題2と問題3にも回答すると、最終結果が表示されます。

最後に「操作履歴をCSVで保存」を押してください。

保存したCSVファイルには、問題開始、選択肢の選択、回答確定などのイベントが時系列で記録されます。

6. observeEvent()で選択肢の変更を記録する

今回のアプリで重要なのが、observeEvent() です。

observeEvent() は、指定した入力値が変化したときに処理を実行するための関数です。

例えば、次のコードでは、問題1の選択肢が変更されたときに処理が実行されます。

observeEvent(input$answer_1, {

  print(input$answer_1)

})

radioButtons() で選択した値は、input$answer_1 に格納されます。

選択肢を変更すると、この値も変化します。

今回の完成コードでは、3問それぞれに対して監視処理を作成しています。

for (j in seq_len(n_questions)) {

  local({

    question_id <- j

    observeEvent(
      input[[paste0("answer_", question_id)]],
      {

        if (phase() != "question") return()

        if (current_question() != question_id) return()

        selected_answer <- input[[
          paste0("answer_", question_id)
        ]]

        if (is.null(selected_answer)) return()

        if (length(selected_answer) == 0) return()

        add_log(
          event = "選択肢を選択",
          detail = selected_answer
        )

      },
      ignoreNULL = TRUE
    )

  })

}

seq_len(n_questions) は、1から問題数までの整数を生成します。

paste0() は、文字列を結合する関数です。

例えば、問題番号が2なら、監視する入力IDは answer_2 になります。

local() は、ループ内で使用する変数を問題ごとに保持するために使用しています。

また、現在表示されている問題番号を確認することで、別の問題の入力値を誤って記録しないようにしています。

7. 操作ログを追加する関数

今回のコードでは、操作を記録するために add_log() という関数を作成しました。

add_log <- function(event, detail = "", time_ms = now_ms()) {

  i <- current_question()

  start <- question_start_ms()

  elapsed <- if (is.null(start)) {
    NA_real_
  } else {
    (time_ms - start) / 1000
  }

  new_row <- data.frame(
    question_number = i,
    event = event,
    detail = detail,
    event_time = format_ms(time_ms),
    elapsed_seconds = elapsed,
    stringsAsFactors = FALSE
  )

  operation_log(
    rbind(operation_log(), new_row)
  )

}

この関数は、操作が発生するたびに1行のデータを作成します。

event には操作の種類を、detail には選択肢などの詳細を記録します。

time_ms は、操作時刻を整数ミリ秒で表した値です。

さらに、問題開始時刻との差を計算し、操作までの経過時間を秒単位で記録しています。

例えば、問題開始から2.150秒後に選択肢を選んだ場合、elapsed_seconds には 2.150 が記録されます。

8. 回答履歴と操作履歴の違い

今回のアプリでは、2種類のCSVファイルを保存できます。

回答履歴

回答履歴には、問題ごとの最終的な回答結果を記録します。

問題番号回答正誤得点解答時間(秒)
12倍になる正解15.100
2半分になる正解13.200
3投与量と投与速度不正解04.500

操作履歴

操作履歴には、回答途中の操作も記録します。

問題番号操作内容開始からの経過秒数
1問題開始問題10.000
1選択肢を選択変化しない2.150
1選択肢を選択2倍になる4.320
1回答確定2倍になる5.100

※これらは説明用のデータです。

回答履歴からは、最終的な正誤や得点を確認できます。

操作履歴からは、どの順番で選択肢を選んだかを確認できます。

両方を組み合わせることで、回答結果だけでは分からない操作の特徴を調べられます。

9. CSVファイルをRで分析する

保存した操作履歴は、RStudioで読み込むことができます。

# 操作履歴CSVを読み込む
log_data <- read.csv(
  file.choose(),
  fileEncoding = "UTF-8-BOM",
  check.names = FALSE,
  stringsAsFactors = FALSE
)

# データを表示
View(log_data)

# 操作の種類ごとの件数
table(log_data[["操作"]])

# 問題ごとの選択操作回数
selection_data <- subset(
  log_data,
  操作 == "選択肢を選択"
)

table(selection_data[["問題番号"]])

table() は、値ごとの出現回数を集計する関数です。

subset() は、指定した条件に一致するデータを抽出する関数です。

このコードでは、問題ごとに選択操作が何回記録されたかを確認できます。

ただし、最初の選択も1回として数えるため、選択肢の変更回数を求める場合は、選択操作回数から1を引く必要があります。

例えば、問題1で3回の選択操作が記録されていれば、最初の選択後に2回変更したことになります。

10. 操作ログの測定上の注意点

今回のアプリでは、R Shinyのサーバー側で操作時刻を記録しています。

したがって、CSVに保存される時刻は、ブラウザで操作した瞬間そのものではなく、操作に対応する入力値の変化をサーバー側で処理した時刻です。

通信遅延やShinyの入力処理によって、実際の操作時刻と記録時刻には差が生じる可能性があります。

また、ブラウザ上で短時間に複数の操作を行った場合、Shinyの入力更新処理によって、一部の中間状態が個別のイベントとして記録されない可能性もあります。

そのため、今回の方法は、学習アプリ内での操作傾向を把握する用途に適していますが、すべてのマウスクリックを漏れなく記録する仕組みではありません。

より詳細な操作ログを取得する場合は、JavaScriptでブラウザ側のイベントを記録する方法もあります。

11. 複数の学生に利用してもらう場合

今回のアプリでは、操作ログをShinyのセッションごとにメモリ上で保持しています。

学生がそれぞれ独立したセッションでアクセスした場合、通常は回答履歴や操作履歴も独立して管理されます。

ただし、今回のコードには、学生の氏名や学籍番号を登録する機能はありません。

また、ログは自動的に教員のパソコンへ送信されるわけではありません。

学生がCSVファイルをダウンロードし、別途提出する必要があります。

複数の学生のログを自動的に収集するには、サーバー側での保存処理が必要です。

その場合、同時アクセスへの対応や、個人情報の取り扱いにも注意する必要があります。

12. まとめ

今回は、R Shinyを使って学生の操作履歴を記録する方法を紹介しました。

選択式問題の最終回答だけでなく、選択肢の変更やボタン操作を記録することで、学生がどのようにアプリを操作したかを確認できます。

今回使用した主な関数は次のとおりです。

関数役割
observeEvent()入力値の変化を検出する
reactiveVal()操作履歴を保持する
data.frame()操作履歴を表形式にする
rbind()操作履歴を1行ずつ追加する
Sys.time()現在時刻を取得する
paste0()文字列を結合する
table()操作回数を集計する
subset()条件に合うデータを抽出する
downloadHandler()CSVファイルを生成する

操作履歴は、正答率や得点とは異なる視点から学習状況を調べるためのデータになります。

ただし、操作の順番や回数だけで理解度を判断することはできません。問題の難易度や操作環境なども考慮する必要があります。

また、今回のアプリでは、学生がCSVファイルをダウンロードする方式を採用しています。

複数の学生のデータを教員側でまとめて収集する場合には、サーバー側で回答履歴や操作履歴を保存する仕組みが必要になります。

コメント

タイトルとURLをコピーしました