🎢

jsPsychでつくる系列再構成テスト

2021/12/20に公開

はじめに

普段は jsPsych ユーザーが増えることを願って,jsPsych やプログラミングの初心者を対象としたチュートリアル記事を作成しています。それはそれでとても大事だと思っていますが,ある程度書けるようになった人に向けて,今後のヒントになるかもしれないことを紹介するのもいいんじゃないかと思いました。

そこで,今回は jsPsych を用いて系列再構成テストを作る方法を紹介します。記事の内容が初心者向けでないどころか題材もマニアックなので,読者が自身の実験作成にすぐに活かせるものではないと思いますが,JavaScript をガシガシ書くことで色々できるとか,そういう書き方もできるんだとかなどを感じてもらえれば何よりです。

なお,本記事のデモは jsPsychv7.1.2で作成しました(コピペ用にデモコードを一番最後に用意しています)。

本記事は psyJS Advent Calendar 2021 20 日目の記事になります。

系列再構成

系列再構成テストは系列位置効果を検討するテストの一種です。このテストでは符号化時に提示された単語はすでに画面に表示されており,参加者はそれらの単語の提示順を思い出します。系列位置効果用のテストとしては自由再生テストが想像されやすい気がしますが,オンライン実験目的で自由再生は扱いにくいです。筆記回答は回収できないし,タイピングはそのスキルの個人差が大きそうだからです。

一方,系列再構成は順番を答えさせるので,「提示順にクリックする」という回答方法が可能です。反応の収集も容易ですし,反応自体の個人差もあまりなさそうなので,自由再生と比べるとオンライン実験に向いていそうです。

前置きが長くなりましたが,以下が今回のデモになります。ぜひ一度試してみてください。1 試行の流れは次のとおりです。

  • 6 つの単語が一つずつ 1 秒間提示されます。
  • 系列提示終了後,全 6 単語と 6 つのテキストボックスが画面に表示されます。提示された順番に単語をクリックしてテキストボックスを埋めてください。なお,やり直しはできません。
  • テキストボックスを埋めると「Continue」というボタンが表示されます。それを押すと次の試行に進みます。

デモは全部で 3 試行で,どの試行でも同じ単語リストが使用されています。

そもそも実装自体で工夫をこらしたつもりですが,実験遂行に特に関わる工夫点は,すべての位置の単語を回答しないと次に進めないようにした点です。また,選択してテキストボックスに入力された内容を編集できないようにしています。

実装

jsPsychSurveyHtmlFormv6.3までならjspsych-survey-html-form)を用いて系列再構成テスト用の試行変数を作成しています。このプラグインの挙動は公式のものを確認してみてください。リンク先のページの下部にあります。どういうふうに反応が保存されるかについては本記事のデモを最後までやってもらっても確認できます。

本記事のデモでは以下の 5 行でメインの試行変数を定義しています。preambleで全単語の表示部分を,htmlでテキストボックスの部分を担っています。ここではそれぞれの挙動を制御するための自作関数を指定しています。このプラグインに限らず,試行変数の大抵の項目には関数を指定できることはぜひ覚えておいてください。

const trialReconstruction = {
  type: jsPsychSurveyHtmlForm,
  preamble: makePreamble, // 関数:単語の表示
  html: makeInputBox, // 関数:テキストボックス
};

それぞれの関数について説明します。先にhtml項目で指定されている関数から説明します。

html

html項目では,記銘語の数(今回は 6 つ)だけテキストボックスが表示されるようにしています。テキストボックスは<input>タグで生成できます。つまり,今回の課題ではhtml項目には<input>タグを 6 つ含んだ文字列が指定されていればよいということになります。makeInputBox関数はまさにそういった文字列を返しています。

ただし,クリック回答をうまく動作させるために,以下のことを追加で行っています。

  • クリックされた単語が適切な位置のボックスに入力できるように,id(と念の為name)属性を指定しています。このidはクリック時の動作を制御する関数がテキストボックスを参照するために利用されます。
  • 参加者がボックス内の内容を編集できないように,readonly属性を付与しています。

なお,makeInputBox関数の定義{}外で事前に用意されている変数として,wordListがあることに注意してください。wordListは以下のような単語の配列です。

const wordList = ['りんご', 'ごりら', 'らっぱ', 'ぱんだ', 'だんご', 'ごぼう'];
function makeInputBox() {
  // 記銘単語の数(wordList.length)だけ<input>を生成する
  // ただし,それぞれのタグにはユニークなidが振られるようにする
  // 今回は 1 ~ 6 の数字が振られている
  let inputBoxList = [];
  for (let i = 0; i < wordList.length; i++) {
    let inputBox = `<input id="${i + 1}" name="${i + 1}" type="text" size="5" value="${i + 1}" readonly/>`;
    inputBoxList.push(inputBox);
  }
  // html項目に指定できるのは文字列なので,
  // 配列に格納された<input>タグを結合して一つの文字列にする。
  // このとき,各タグの間に &emsp; を入れて,ボックス間の距離を調整している
  const alinedInputBox = inputBoxList.join('&emsp;');
  return `<p>${alinedInputBox}</p>`;
}

preamble

preamble では次の 3 つのことを実現しています。

  • 6 つの単語を円環状に提示する
  • ある単語はクリックされるとテキストボックスに入力される
  • クリックされた単語は表示されなくなる

円環状の提示は,単語の位置を調整する問題なので,6 つの単語それぞれに<div></div> タグをつけてそのスタイルの position を適切に指定すればよいです。

2 つ目と 3 つ目のクリック時の動作は,<span></span>タグのonclick属性に指定すればよいです。今回は動作を事前に関数としてまとめておいたものを指定しています。

これらをまとめると,各単語について

<div style="position: 位置指定;"><span onclick="関数">りんご</span></div>

という html タグを作ることが目標になります。

他にも単語の提示位置のランダマイズという要素も加えて,makePreamble()関数は以下のように定義されています。関数定義の{}の外で事前に用意されている変数・関数が 3 つあることに注意してください。

  • wordList: 記銘単語の配列
  • wordPosList: 各提示位置の配列
  • fillVal: クリック時の動作を制御している関数
function makePreamble() {
  // 単語の配列をシャッフルする(提示位置のランダマイズのため)
  const shuffled = jsPsych.randomization.shuffle(wordList);
  // 単語と提示位置を一つずつ取り出して html タグを付与する。結果の文字列を配列に格納する。
  let htmlWordList = [];
  for (let i = 0; i < shuffled.length; i++) {
    let htmlWord = `<div ${wordPosList[i]}><span onclick="fillVal(this)">${shuffled[i]}</span></div>`;
    htmlWordList.push(htmlWord);
  }
  // preambleに指定できるのは文字列なので,
  // 配列に格納されているタグ付きの単語を結合して一つの文字列にする。
  return `<div>${htmlWordList.join('')}</div>`;
}

wordListは先述のとおりです。wordPosListは以下のような内容です。

let wordPosList = [
  'style="position: absolute; left: 40vw; top: 35vh";', // mid-left
  'style="position: absolute; left: 60vw; top: 35vh";', // mid-right
  // ... 省略
];

fillVal()は以下のような関数です。{}外でcurPosという変数を用意しています。curPosは,クリックされた単語がどの位置のテキストボックスに入力されるのかを指定するために使用されています。

function fillVal(clickElement) {
  // クリックされた要素のvisibilityを hiddenに変更する
  // -> クリックされた単語は非表示になる
  clickElement.style.visibility = 'hidden';
  // curPosと一致するidのテキストボックスに,クリックされた単語を入力する
  document.getElementById(`${curPos}`).value = clickElement.innerHTML;
  // 次のクリックのためにcurPosを1増やしておく
  if (curPos < wordList.length) {
    curPos++;
  } else {
    // curPos === wordList.length つまり,すべての単語がクリックされたら,
    curPos = 1; // reset
    // "Continue"ボタンを表示する。
    document.getElementById('jspsych-survey-html-form-next').style.visibility = 'visible';
  }
}

なお,input の値を入力するのと同様に,continueボタンの表示の切り替えもjspsych-survey-html-form-nextというidを介して行っています。このidはプラグイン内で指定されていて,デモコード内には見当たりません。もう一つ重要なこととして,系列再構成が始まった時点ではcontinueボタンが表示されないように,課題自体の html ファイルのヘッダーでvisibility: hidden;というスタイルを指定しています(詳しくは最後のデモコードを参照してください)。

おわりに

今回は系列再構成テストの作成方法を紹介しました。ニッチな課題なので,これを実際の実験で使うことはあまりないかもしれません。しかしながら,試行変数の項目に関数を指定したり,その関数の中で html を編集するということは他の実験課題でも応用できると思います。そういう工夫をすることで自由に実験課題を組めるようになるはずです。

今回の記事が,jsPsych に慣れてきた人の今後の発展のヒントになれば大変幸いです。

不定期にはなりますが,今後も引き続き心理学実験・研究法に関する Tips を共有していきます。いいね・サポートをいただけると大変励みになりますので,ぜひそちらもよろしくお願いします。

系列再構成のデモコード(コピペ用)

系列再構成テスト時の単語の表示位置が,記事中のサンプルのものと異なっていますが,ブラウザで使用する場合は以下のコード内の指定位置で良いと思います。

serial-reconstruction.html
<!DOCTYPE html>
<html>
  <head>
    <title>Serial Reconstruction</title>
    <meta charset="UTF-8" />
    <script src="https://unpkg.com/jspsych@7.1.2"></script>
    <script src="https://unpkg.com/@jspsych/plugin-html-keyboard-response@1.1.0"></script>
    <script src="https://unpkg.com/@jspsych/plugin-survey-html-form@1.0.0"></script>
    <link href="https://unpkg.com/jspsych@7.1.2/css/jspsych.css" rel="stylesheet" type="text/css" />
    <style>
      #jspsych-survey-html-form {
        position: relative;
        top: 20vh;
        left: 0vw;
      }

      #jspsych-survey-html-form-next {
        visibility: hidden;
      }
    </style>
  </head>
  <body></body>
  <script>
    const jsPsych = initJsPsych({
      on_finish: function () {
        jsPsych.data.displayData();
      },
    });
    const wordList = ['りんご', 'ごりら', 'らっぱ', 'ぱんだ', 'だんご', 'ごぼう'];

    // Instruction -------------------------------------------------------------
    const trialInst = {
      type: jsPsychHtmlKeyboardResponse,
      stimulus: 'スペースキーを押すと課題が始まります',
      choices: [' '],
    };

    // Encoding phase ----------------------------------------------------------
    // timeline_variablesの作成
    let encodingVars = [];
    for (let i = 0; i < wordList.length; i++) {
      encodingVars.push({ word: wordList[i] });
    }

    const trialEncoding = {
      type: jsPsychHtmlKeyboardResponse,
      stimulus: jsPsych.timelineVariable('word'),
      trial_duration: 1000,
      choices: 'NO_KEYS',
    };

    const blockEncoding = {
      timeline: [trialEncoding],
      timeline_variables: encodingVars,
      sample: {
        type: 'fixed-repetitions',
        size: 1,
      },
    };

    // Test phase --------------------------------------------------------------
    let wordPosList = [
      // 'style="position: absolute; left: 50vw; top: 50vh";', // center
      'style="position: absolute; left: 40vw; top: 50vh";', // mid-left
      'style="position: absolute; left: 60vw; top: 50vh";', // mid-right
      'style="position: absolute; left: 45vw; top: 41.34vh";', // top-left
      'style="position: absolute; left: 55vw; top: 41.34vh";', // top-right
      'style="position: absolute; left: 45vw; top: 58.66vh";', // bottom-left
      'style="position: absolute; left: 55vw; top: 58.66vh";', // bottom-right
    ];

    let curPos = 1;

    function fillVal(clickElement) {
      clickElement.style.visibility = 'hidden';
      document.getElementById(`${curPos}`).value = clickElement.innerHTML;
      if (curPos < wordList.length) {
        curPos++;
      } else {
        curPos = 1; // reset
        document.getElementById('jspsych-survey-html-form-next').style.visibility = 'visible';
      }
    }

    function makePreamble() {
      const shuffled = jsPsych.randomization.shuffle(wordList);
        let htmlWordList = [];
        for (let i = 0; i < shuffled.length; i++) {
          let htmlWord = `<div ${wordPosList[i]}><span onClick="fillVal(this)">${shuffled[i]}</span></div>`;
          htmlWordList.push(htmlWord);
        }
        return `<div>${htmlWordList.join('')}</div>`;
    }

    function makeInputBox() {
      let inputBoxList = [];
        for (let i = 0; i < wordList.length; i++) {
          let inputBox = `<input id="${i + 1}" name="${i + 1}" type="text" size="5" value="${i + 1}" readonly/>`;
          inputBoxList.push(inputBox);
        }
        const alinedInputBox = inputBoxList.join('&emsp;');
        return `<p>${alinedInputBox}</p>`;
    }

    const trialReconstruction = {
      type: jsPsychSurveyHtmlForm,
      preamble: makePreamble,
      html: makeInputBox,
    };

    const task = {
      timeline: [blockEncoding, trialReconstruction],
      repetitions: 3,
    };

    jsPsych.run([trialInst, task]);
  </script>
</html>

Discussion