前两篇指南,我们快速过了一遍 jsPsych 中程序编写的基本概念和逻辑,但并没有从一个“实际的实验”角度去看如何编写程序。这篇教程会从实战角度出发,从头梳理编写 jsPsych 程序的基本流程。

还是那句话,本系列教程并非面向心理学初学者的教程,而是面向已经有了心理学基础(特别是心理学实验编写基础)的同学,快速了解并上手 jsPsych 框架而用的教程。

此外,需要提醒大家。由于上两篇教程距今跨度较大,jsPsych 框架现已更新至 8.x 版本。本文将以 8.0 版本的代码为准。有关 7.x 到 8.x 的改变(特别是代码方法上的变化),推荐各位读者移步阅读 jsPsych 8.0 发布后的升级指南 了解。

由于在本文中不会涉及到相关的改动,因此我们仅需要注意版本号的区别,不需对代码方法有过多的调整,后文不再赘述。

0. 我们要写一个什么实验?

在最开始,当然是先说一下我们准备写一个什么实验。我们用作举例的实验是一个词图匹配任务:

词图匹配任务

呈现一张图片,给出三个选项,被试选择其一,用以测量被试的学习效果。

1. 选择合适的插件

如之前的教程所言,jsPsych 本身是一个很大的框架,我们需要针对不同的试次类型选择不同的插件加入其中。我们可以在这个列表(https://www.jspsych.org/v8/plugins/list-of-plugins/)里面看到各种各样的官方插件。

jsPsych 官方插件列表

你可以看到每一种插件的具体用例和使用方法,并引入到你自己的实验中。

既然要选择,我们就得先梳理一下我们的实验需要什么。

这是一个很简单的实验,我们只需要呈现刺激的图片,让被试按按钮选择就行了。在这种情况下,我们只需要用到这一款插件:

image-button-response 的简单介绍

建议大家多了解 jsPsych 的整个插件库,了解它支持什么,可以做什么,方便在编写实验时选择并编写。

2. 从核心试次开始

2.1 搭建骨架

在编写 jsPsych 程序时,我推荐先从实验最核心的功能开始编写,一步一步完善实验程序。

首先,准备我们的实验材料。在本篇教程中,我们有一个 ./images/ 的文件夹,里面放有我们所有的待测图片(你可以在里面随便放一些图片做测试用):

实验材料示例

接下来,新建我们的 jsPsych 项目文件。从 index.html 开始:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Word-Image-Recognize</title>
    <script src="https://unpkg.com/jspsych@8.0.0"></script> <!--注意版本号!-->
    <script src="https://unpkg.com/@jspsych/plugin-image-button-response@2.0.0"></script> <!--注意版本号!-->
    <link href="https://unpkg.com/jspsych@8.0.0/css/jspsych.css" rel="stylesheet" type="text/css" />
</head>
<body>
    <script src="exp.js"></script>
</body>
</html>

然后新建我们的 exp.js 文件。我们先构建一个骨架:

let jsPsych = initJsPsych();

const main_timeline = {
    timeline: [
        {
            type: jsPsychImageButtonResponse,
            stimulus: "./images/backbend.jpg",
            post_trial_gap: 500,
            choices: ["backbend", "bath towel", "burpees"],
            prompt: `Choose the right word that matches the image.`
        }
    ]
};

jsPsych.run([
    main_timeline
]);

它具有最基本的功能:呈现了一张图片(目前是固定的),然后呈现了三个选项(目前是固定的)。接下来就需要我们搞定时间线变量了。

2.2 编辑时间线变量

上一篇教程中,我们提到,时间线变量用来简化试次的编写,将试次的数据部分抽离出来(一定程度上类似 PsychoPy 的数据文件)。我们其实会希望时间线变量中的数据多一些,便于后续进行一些操作。

这里有两个比较重要的时间线变量:(1)刺激的图片;(2)刺激的答案。

2.2.1 刺激图片

在这里,我们的时间线变量肯定要包含 stimulus 中的图片路径。但是很遗憾,因为 JavaScript 是运行在客户端(而不是服务端)的程序,它是没有权限读取系统的文件结构的(毕竟你也不想让远程的网页知道你的电脑上有什么文件吧!)。因此我们不能动态地定义这个列表,需要自己手动定义每一个刺激。

我采用的格式是这样的:

const variable_list = [
  {
    "id": 1,
    "word": "arcade game",
    "name": "arcade game.jpg",
  },
  {
    "id": 2,
    "word": "badminton racket",
    "name": "badminton racket.jpg",
  },
  {
    "id": 3,
    "word": "baseball glove",
    "name": "baseball glove.jpg",
  }, //...
]

我定义了一个常量 variable_list,用作我们的时间线变量。其中包含三个键:id(方便做索引,在实验中没什么具体的用途),word(具体的单词),name(单词对应的图片名)。

当然,我并不是一个有大把大把时间手输八百个单词的人,因此我写了一个脚本来自动拼接这些数据。这是后话,你可以用你熟悉的方法来完成这一点。

如果你使用了 Node.js 等服务端框架的话,那么动态读取服务端的文件是可能的,但既然你已经用上 Node.js 了,相关的解决方案就不在本文的覆盖范围内了,因此这里就不做赘述了。

定义好变量列表后,接下来我们就可以修改我们的时间线了:

let jsPsych = initJsPsych();

const main_timeline = {
    timeline_variables: variable_list,
    randomize_order: true,
    timeline: [
        {
            type: jsPsychImageButtonResponse,  
            stimulus: () => `./images/${jsPsych.evaluateTimelineVariable('name')}`, 
            post_trial_gap: 500,
            choices: ["backbend", "bath towel", "burpees"],
            prompt: `Choose the right word that matches the image.`
        }
    ]
};

jsPsych.run([
    main_timeline
]);

注意我们的 stimulus,因为我们导入的时间线变量中,文件名的部分里不包括前导的路径,因此需要在这里动态地拼接一下。

至于这个函数,它是 JavaScript 中的箭头函数,一种简化的函数形式。它和下面的写法是完全等价的:

// stimulus: () => `./images/${jsPsych.evaluateTimelineVariable('name')}`,
stimulus: function() {
    html = `./images/${jsPsych.evaluateTimelineVariable('name')}`
    return html
},

下面这个写法更有利于初学者理解,上面的写法更加简洁。如果你不熟悉 JavaScript 的话,我推荐你用下面的写法。

为了在函数中引用时间线变量,我们需要使用 jsPsych 提供的函数:jsPsych.evaluateTimeLineVariable()。所以上面的函数实际上就做了一件事:把当前试次的 name 变量拼接到 ./images/ 后面。

2.2.2 选项

选项就有一些棘手了。我们需要三个选项,其中一个是正确的,另外两个是错误的。正确的选项可以直接从时间线变量中读出来,但错误的选项怎么生成呢?我这里采取了一个讨巧的方法:从整个变量列表中随机选择两个选项,加上正确选项一起,组成三个选项。

因此,我们的 choices 也需要动态地变化。以下是代码。

choices: function () {
  // 获取目标单词
  const target_word = jsPsych.evaluateTimelineVariable('word');

  // 过滤出不包含目标单词的对象
  const filteredList = variable_list.filter(
    item => item.word !== target_word
  );

  // 随机选择两个不同的对象
  const selectedItems = [];
  while (selectedItems.length < 2) {
    const randomIndex = Math.floor(Math.random() * filteredList.length);
    const randomItem = filteredList[randomIndex];
    if (!selectedItems.includes(randomItem)) {
      selectedItems.push(randomItem);
    }
  }

  // 创建答案列表
  const ans_list = [target_word, selectedItems[0].word, selectedItems[1].word];

  // 打乱答案列表的顺序
  for (let i = ans_list.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [ans_list[i], ans_list[j]] = [ans_list[j], ans_list[i]];
  }

  return ans_list;
}

首先我们把时间线变量中的 word 提出来,它是我们会用到的正确答案(第 3 行)。

然后,我们使用 .filter 方法,将目标单词从列表中删去,以防我们从列表里抽出重复的正确答案(第 6-8 行)。

再然后,我们从列表中随机挑选两个对象,它们就是本次试次中的错误选项了(第 11-18 行)。

选出错误选项后,我们需要创建一个答案列表,并且打乱答案的顺序(毕竟我们不想让正确答案永远放在第一个)。我们在第 21 行创建这个列表,在第 24-25 行对答案列表进行随机化。

最后,我们返回打乱后的答案列表,这就是我们的 choices 了。

到这一步,完整的代码如下:

let jsPsych = initJsPsych();

const variable_list = [ /*试次数据列表略去*/ ]; 

const main_timeline = {
    timeline_variables: variable_list,
    randomize_order: true,
    timeline: [
        {
            type: jsPsychImageButtonResponse,
            // stimulus: () => `./images/${jsPsych.evaluateTimelineVariable('name')}`,
            stimulus: function () {
                html = `./images/${jsPsych.evaluateTimelineVariable('name')}`
                return html
            },
            post_trial_gap: 500,
            choices: function () {
                // 获取目标单词
                const target_word = jsPsych.evaluateTimelineVariable('word');

                // 过滤出不包含目标单词的对象
                const filteredList = variable_list.filter(
                    item => item.word !== target_word
                );

                // 随机选择两个不同的对象
                const selectedItems = [];
                while (selectedItems.length < 2) {
                    const randomIndex = Math.floor(Math.random() * filteredList.length);
                    const randomItem = filteredList[randomIndex];
                    if (!selectedItems.includes(randomItem)) {
                        selectedItems.push(randomItem);
                    }
                }

                // 创建答案列表
                const ans_list = [target_word, selectedItems[0].word, selectedItems[1].word];

                // 打乱答案列表的顺序
                for (let i = ans_list.length - 1; i > 0; i--) {
                    const j = Math.floor(Math.random() * (i + 1));
                    [ans_list[i], ans_list[j]] = [ans_list[j], ans_list[i]];
                }

                return ans_list;
            },
            prompt: `Choose the right word that matches the image.`
        }
    ]
};

jsPsych.run([main_timeline]);

你看,我们的程序一步一步复杂了起来。

3 记录数据

接下来,我们要记录(1)被试的选择(2)被试的选择是否正确。数据的记录过程应该发生在这个试次完成之后,也就是在 on_finish 的阶段进行。

我们先来理理大概需要做些什么:

  1. 对比正确选项和被试的选择;
  2. 把对比的结果放进去。

我们知道,jsPsych 会默认保存一些数据,但这些数据并不够。我们可以看一看它都保存了什么。在实验中打开 F12 调试工具,在控制台中使用这条命令:jsPsych.data.get()

在控制台中查看数据

就实验而言,这里记录了反应时、刺激、选项(的序号)。为了对比选项,我们需要先知道选项是什么。所以我们先把当前试次的选项保存下来:

const main_timeline = {
    timeline_variables: variable_list,
    randomize_order: true,
    timeline: [
        {
            type: jsPsychImageButtonResponse,
            stimulus: () => `./images/${jsPsych.evaluateTimelineVariable('name')}`,
            save_trial_parameters: {
                choices: true,
              },
            post_trial_gap: 500,
            // 余下部分省略
}

我们新增一个参数: save_trial_parameters,在这里指定 choices,这样 jsPsych 就会保存当前试次的选择。

接下来,我们开始编写 on_finish 函数:

on_finish: function (data) {
    data.stimuli_word = jsPsych.evaluateTimelineVariable('word');
    if (data.choices[data.response] == data.stimuli_word) {
         data.correct = true;
    }
    else {
         data.correct = false
    }
}

on_finish 有一个特性:它可以把当前试次的数据传入这个函数,因此我们首先传入 data 对象。

在函数主体里,我们先把正确的选项从时间线变量中提取出来(再次使用 jsPsych.evaluateTimelineVariable(),随后和当前试次的数据进行比较。

注意我们在这里如何提取当前试次的数据的:data.choices[data.response]。我们已经知道了,data 是当前试次的数据,因此 data.choices 就是当前试次的所有选项了(我们刚刚保存下来的)。而 data.response 是 jsPsych 记录的被试选项(序号)。因此它们两个组合在一起,就能够读取到被试当前选择了什么。

4 下载数据

数据是保存在浏览器的内存空间中的,jsPsych 框架不会主动下载数据,如果网页刷新,或者浏览器关闭了,数据就会丢失。要拿到数据,我们就需要写一段对应的代码。

我们已经知道了,jsPsych.data.get() 可以看到当前的数据。而要下载数据,可以使用这一条命令:jsPsych.data.localSave('csv', 'data.csv')。它会唤起一次下载,保存一份 csv 格式的数据。

如果我们想让数据自动保存呢?首先我们要理解,浏览器是不能够全自动下载文件的——安全起见。因此我们只能做到自动唤起一次下载。

在初始化 jsPsych 时,我们可以指定一些行为,让 jsPsych 在实验完成时进行,比如:

let jsPsych = initJsPsych({
  on_finish: function () {
     jsPsych.data.get().localSave('csv', 'data.csv')
  },
});

这样,整个实验完成时,数据下载就会自动唤起了。

5 总结

到这里,我们的实验编写就基本完成了,让我们来看看完整的 exp.js 代码:

let jsPsych = initJsPsych({
    on_finish: function () {
        jsPsych.data.get().localSave('csv', 'data.csv')
    },
});

const variable_list = [ /*试次数据列表略去*/ ]; 

const main_timeline = {
    timeline_variables: variable_list,
    randomize_order: true,
    timeline: [
        {
            type: jsPsychImageButtonResponse,
            stimulus: () => `./images/${jsPsych.evaluateTimelineVariable('name')}`,
            save_trial_parameters: {
                choices: true,
            },
            post_trial_gap: 500,
            choices: function () {
                // 获取目标单词
                const target_word = jsPsych.evaluateTimelineVariable('word');

                // 过滤出不包含目标单词的对象
                const filteredList = variable_list.filter(
                    item => item.word !== target_word
                );

                // 随机选择两个不同的对象
                const selectedItems = [];
                while (selectedItems.length < 2) {
                    const randomIndex = Math.floor(Math.random() * filteredList.length);
                    const randomItem = filteredList[randomIndex];
                    if (!selectedItems.includes(randomItem)) {
                        selectedItems.push(randomItem);
                    }
                }

                // 创建答案列表
                const ans_list = [target_word, selectedItems[0].word, selectedItems[1].word];

                // 打乱答案列表的顺序
                for (let i = ans_list.length - 1; i > 0; i--) {
                    const j = Math.floor(Math.random() * (i + 1));
                    [ans_list[i], ans_list[j]] = [ans_list[j], ans_list[i]];
                }

                return ans_list;
            },
            prompt: `Choose the right word that matches the image.`,
            on_finish: function (data) {
                data.stimuli_word = jsPsych.evaluateTimelineVariable('word');
                if (data.choices[data.response] == data.stimuli_word) {
                    data.correct = true;
                    }
                else {
                    data.correct = false
                }
            }
        }
    ]
};

jsPsych.run([main_timeline]);

我们从实验程序本身的需求出发,一步一步完成了这段代码。

整个教程遵循一条从始至终的主线:在实际的实验程序编写中,首先思考清楚自己要做什么实验,然后从简单到复杂,一步步构建整个实验程序。这是实验编写最基本的思路。

此外,我在写教程时很顺利地掏出了一堆参数、一堆代码作演示。但实际的开发当然没有这么顺利。我怎么知道有哪些我可以用的参数?我怎么编写这一段的逻辑?这个过程当然需要时间、需要思考、需要大量的积累。因此,在我们开始写实验程序前,我非常建议先把 jsPsych 的文档通读一遍,思考它的功能有哪些和我们的需求契合,再开始编写我们的程序。

当然,我只是展现了一种可能性,这种可能性并不是唯一解。它能够发挥出怎样的潜力、能够有哪些不同的思路,这些就是大家可以做到的部分了。


总而言之,jsPsych 快速上手指南系列就到这里了,希望它对你有所帮助!

最后修改:2024 年 11 月 16 日
虽然点赞什么的确实没什么意义但是也可以点一个再走呗?(