前两篇指南,我们快速过了一遍 jsPsych 中程序编写的基本概念和逻辑,但并没有从一个“实际的实验”角度去看如何编写程序。这篇教程会从实战角度出发,从头梳理编写 jsPsych 程序的基本流程。
还是那句话,本系列教程并非面向心理学初学者的教程,而是面向已经有了心理学基础(特别是心理学实验编写基础)的同学,快速了解并上手 jsPsych 框架而用的教程。
由于在本文中不会涉及到相关的改动,因此我们仅需要注意版本号的区别,不需对代码方法有过多的调整,后文不再赘述。
0. 我们要写一个什么实验?
在最开始,当然是先说一下我们准备写一个什么实验。我们用作举例的实验是一个词图匹配任务:
呈现一张图片,给出三个选项,被试选择其一,用以测量被试的学习效果。
1. 选择合适的插件
如之前的教程所言,jsPsych 本身是一个很大的框架,我们需要针对不同的试次类型选择不同的插件加入其中。我们可以在这个列表(https://www.jspsych.org/v8/plugins/list-of-plugins/)里面看到各种各样的官方插件。
你可以看到每一种插件的具体用例和使用方法,并引入到你自己的实验中。
既然要选择,我们就得先梳理一下我们的实验需要什么。
这是一个很简单的实验,我们只需要呈现刺激的图片,让被试按按钮选择就行了。在这种情况下,我们只需要用到这一款插件:
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
(单词对应的图片名)。
当然,我并不是一个有大把大把时间手输八百个单词的人,因此我写了一个脚本来自动拼接这些数据。这是后话,你可以用你熟悉的方法来完成这一点。
定义好变量列表后,接下来我们就可以修改我们的时间线了:
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
的阶段进行。
我们先来理理大概需要做些什么:
- 对比正确选项和被试的选择;
- 把对比的结果放进去。
我们知道,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 快速上手指南系列就到这里了,希望它对你有所帮助!