最早听说线上可以做实验的时候,我也不觉得奇怪,毕竟虽然数据质量并不一定好,但开发这玩意还是相当有前景的。不过当时我对 JavaScript 还不甚了解,尽管很清楚肯花时间的话总是能搞明白的,但是毕竟优先级比较低,平时也没有特别的动力去折腾。至于 PsychoPy,我也早有耳闻,在实验课上就用传统的过程式写法写过一个注意瞬脱的实验程序,知道用起来不错,仅此而已。

直到后来做项目有需求了,才知道有 PsychoPy,PsychoPy Builder 和 PsychoJS 的一系列生态也经历了怀疑、理解、成为、超越的过程。应该说他虽然问题多多,但确实给实验者提供了莫大的方便:代码能力一般的可以局限在框架提供的东西内,也可以抄别人的作业;代码能力不错的可以用它组织庞大的程序,实验的设计会更直观一些。这里列举一些我折腾过的功能或者踩过的坑,提供解决问题的思路。注意一切以官方文档为准

名称指代

我们首先要解决名称指代的问题。PsychoPy Builder,如其名,实际上主要是提供 PsychoPy 程序编写的可视化框架,实验者在 Builder 中插入的组件会按照相应的参数设置编译为对应的 Python 脚本。但同时,Builder 也提供了组件编译为 HTML 和 PsychoJS 实现的实验程序这一选项。PsychoPy 和 PsychoJS 虽然在 API 上高度相似,但毕竟是在不同的平台上运行的,所以在线上实验的上下文中我们应该用 PsychoJS 指代实验程序的框架。

本地调试

我更新过不同的 Builder 版本,但都没有办法成功用它调试实验,好在这是最容易解决的问题。我们只要提供一个简易的、可以返回正确 MIME 类型的 HTTP 服务器,在 /lib/ 目录下放好对应版本的 PsychoJS 即可。以 Node.js 为例,在实验目录下添加脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const http = require("http");
const Path = require("path");
const fs = require("fs");
const sys = require("util");

const args = sys.parseArgs({
strict: false,
options: {
port: { type: 'string', default: '8080' },
verbose: { type: 'boolean', default: false },
}
})

var index = __dirname;
var server = http.createServer(function (req, res) {
if (args.values.verbose) {
console.log(req.method, req.headers["user-agent"]);
console.log('Request: %s', req.url.split('?'))
}
var url = req.url.split('?')[0];
const fileName = Path.resolve(index, "." + (
url.endsWith('/')? url+'/index.html' : url
));
const extName = Path.extname(fileName).slice(1);

if (fs.existsSync(fileName)) {
var mineTypeMap = {
html: 'text/html;charset=utf-8',
htm: 'text/html;charset=utf-8',
xml: "text/xml;charset=utf-8",
js: "text/javascript;charset=utf-8",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
css: "text/css;charset=utf-8",
txt: "text/plain;charset=utf-8",
ico: "image/x-icon",
}
if (mineTypeMap[extName]) {
res.setHeader('Content-Type', mineTypeMap[extName]);
}
var stream = fs.createReadStream(fileName);
stream.pipe(res);
} else {
res.end('404')
}
})
server.listen(8080);

这里也可以替换为其他端口。调试时,通过终端运行:

1
node debug.js

然后在浏览器打开 http://localhost:8080/index.html 即可。

如果导出 HTML 时没有自动下载 PsychoJS 脚本,也可以自己用 Node.js 编译。用 git 克隆仓库源码:

1
2
git clone https://github.com/psychopy/psychojs.git
git checkout 2023.2.3 # 可以替换其他版本号

这样仓库就会进入指定版本的状态。然后运行 npm run build,检查输出目录。

如果想让实验程序的定制化程度更高,可以用这种方式修改 PsychoJS 的源码然后自己编译。

翻译问题

明明是 PsychoPy 先来的,为什么会变成这样? PsychoPy Builder 支持一个看似强大的功能:将用户自定义组件的 Python 代码翻译成 JS 代码,而这种需求还是相当常见的,因为 PsychoPy 的用户不见得懂 JS,而很多涉及实验条件的功能都只能靠自定义代码来实现。不过目前为止的实现看起来都非常粗糙,旧版的已知问题可以参考这份文档。在我个人体验中,最常见的还是变量声明问题:在特定位置使用变量时,Builder 无法识别并添加对应的 JS 变量声明,导致运行时报错。这种问题比较好排查,因为报错信息很直接,不过只是比较烦人。变量、常量声明及其作用域见 MDN 文档

实际上大部分和 JS 有关的问题也可以查阅 MDN,可谓应有尽有。

另一个比较显然不过也需要注意的事情是:Python 的第三方模块自然没有办法翻译因为这里是 JS,该滚的是你们吧! 不过也没有必要吊死在一颗树上,我们 JS 人有 JS 人自己的第三方库,如果有需要的话,直接修改 index.html 引入就好,比如这样:

1
2
3
4
5
6
7
8
<!-- external libraries -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/jquery-ui.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/preloadjs.min.js"></script>
<script src=" https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js "></script>
<script src="./jstat.min.js"></script>
<script src="./md5.js"></script>
<script src="./cos-js-sdk-v5.min.js"></script>

这里的前三行实际上是自动生成的文件里就有的,作用就是引入 PsychoJS 需要用到的库。可以把需要的库下载到本地(就像第 5、6、7 行),确保引入路径正确,也可以像 PsychoJS 一样从 CDN 获取(就像第 4 行)。至于速度是否有差异,没有比较的条件,很难衡量。当然,需要承认的是,JS 似乎没有 numpy 之类用起来比较顺手的科学计算库,大概是因为基本没有这种需要。为了生成服从正态分布的随机数,我用的是 jStat,感觉还行,至少不用自己抄一遍算法了。不过,因为 Builder 每次导出的时候都会重新生成网页并覆盖原文件,所以可以考虑用一个脚本自动从模板复制,因为正常来说 index.html 本身的内容是不会随实验内容而改变的。当然,也可以用动态引入的方式,但是那样似乎还要更麻烦一些。

个人建议:如果确定需要做线上实验,那么无论是否还要在线下做,那么有条件的情况下,最好都用纯 JS 编写程序,而不是指望不靠谱的翻译或者维护两份功能一致语言不同的代码。毕竟,就算用 JS 写好了程序,也不见得非要在线上平台完成才算尽了它的使命。

添加问卷

添加问卷也是实验的常见需求。我们当然可以选择用外部问卷系统,但那样就很难做一些实验、问卷深度交互的操作,比如根据问卷做分流,或者用问卷的形式考察被试对指导语的理解直至通过为止。遗憾的是,截至本文撰写时(2023年11月26日),最新版 PsychoJS 的问卷接口仍然是残废状态。所幸有热心研究者做了一个效果还不错的线上转换版本,用法示例可以参考作者的代码仓库,简单来说逻辑是这样的:

  • 根据表格内容,导出一个自带样式的 HTML;
  • 在实验程序中用动态添加 iframe 的形式内联问卷 HTML(也即网页中的网页),然后通过 jquey 的接口以提交表单的形式处理问卷信息;
  • 在提交表单的回调函数submit中用psychoJS.experiment.addData(key, value)或者expInfo[key] = value的方式记录问卷信息,其中前者是只添加到当前试次,后者会出现在实验数据文件的每一行中,相当于全局信息。
  • 根据问卷结果,通过改变continueRoutine的值解除阻塞,结束问卷。

添加全局信息会大大增加数据文件的体积,如非必要不建议这么干。

自定义字体

线上实验会在千家万户的浏览器上面运行,你也不知道自己精心调试的实验程序在对面的屏幕上会是什么效果。一种提高一致性的方法是自己选定一种字体。不过问题在于:不同操作系统的自带字体极少有重叠的部分,而重叠的部分一般效果也不太令人满意。由此,就只剩下自己分发字体这种选择了。在作出这个决定后,我居然又一次踩到了 PsychoJS 的两个大坑。

首先,西文的字体文件一般不会太大,但是和图片之类的资源比也不小,为了在实验开始前就加载完成,我自然想到把它加入到 Builder 预加载资源列表里的办法。结果它辜负了我。在连连报错想不明白原因之后,我终于下定决心开了单步执行调试,然后翻了一下 PsychoJS 的源码,发现这一块处理是有问题的,也给官方提了 issue,可惜暂时还没人理我。如果要预加载字体,千万不要用 Builder 提供的办法,下面是解决方案:

为了和其他字体区分,不妨取一个不大可能重名的 CustomFont 作为 font-family 的名字。添加一个代码组件,在实验开始前插入一个异步函数:

1
2
3
4
5
6
7
8
9
10
11
12
// name -> font-family
// url -> path of font resource
// weight -> font-weight
async function loadFontFace(name, url, weight) {
const font = new FontFace(
name,
`url(${url}) format('truetype')`,
{ weight: weight }
);
await font.load();
document.fonts.add(font);
}

熟悉字体属性的朋友应该能看出 weight 就是字重,truetype 就是指 .ttf 类型的 TrueType 字体。如果你用的是 OpenType 字体,作相应替换即可。这里用到的FontFace构造器可能并未得到所有浏览器的支持,具体可以检查 MDN 文档,这里就不作介绍了。

在定义了这个异步函数之后,我们需要用阻塞的方式等他把字体加载完毕,否则很有可能字体还没加载好就开始实验了(如果你不了解具体原因,可以自行检索异步机制)。那自然的想法就是加几行 await loadFontFace(...) 就完事了,结果!一加进去,Builder 的导出就全乱套了。没办法,只能避开这个关键字,用更笨的办法。为预加载字体添加一个 Routine,然后定义一个控制阻塞状态的变量var continuePreloading,在 Routine 开始前加入代码:

1
2
3
4
5
6
7
Promise.allSettled([ // 注意把字体路径改成你自己的
loadFontFace('CustomFont', 'OpenSans-Regular.ttf', 'normal'),
loadFontFace('CustomFont', 'OpenSans-Bold.ttf', 'bold')
]).then(function (value) {
document.body.classList.add('fonts-loaded');
continuePreloading = false;
});

然后在每一帧添加代码将 continuePreloading 赋值给 continueRoutine 就搞定了。正确加载字体之后,在对应文本组件里设置的 font 就能生效。

目前 PsychoJS 的 TextStim 默认使用字体的 Light 字重。如果你想要改成正常字重,可以在上面的代码里添加一行将 weight=‘300’ 对应的字体覆盖掉。

接管实验部署与数据上传!

这算是本文的重量级部分。目前实验程序的线上部署,据我所知,一般是通过脑岛(国内)或者 Pavlovia(国外)完成,脑岛虽然数据质量本身不大靠谱,不过提供了其他分组之类的实验功能,这里姑且不论。Pavlovia 就相当离谱了:收着 0.4 镑一份数据的服务费,结果连最基本的数据上传可靠性都保证不了,而且数据还会全部用 commit 的形式塞进 gitlab 仓库,看着实在恶心。

那怎么办呢?如果要收国外数据,真的只能捏着鼻子用了吗?当然不是。这是因为:

基于 PsychoJS 的线上实验,本质上只需要一个静态网页。

这句话的意思是,在点击实验链接,开始正式实验之前,浏览器需要从服务器请求对应的文件。一旦资源加载完毕,就算拔掉网线也不影响实验本身。在调试模式下,试验结束后数据会直接存到本地,否则也会有联网的需要,将数据文件上传到服务器。这两部分都是可以通过云计算服务商的对象存储用极低成本完成的。至于什么是对象存储,这里不展开介绍,大致理解为支持编程访问、有很多附加功能的高级网盘就可以了。我们以腾讯云 COS 为例实现部署和上传,你也可以替换成你喜欢的任意一种产品,只要它能为文件返回正确的 MIME 类型,保证网页能正常加载就行。

  • 前期准备:在 COS 中开启一个存储桶,设置成私有读写即可。节点位置离目标用户当然是越近越好。
  • 将所有实验文件(html、js、/lib/ 中的 PsychoJS 库等等)上传到你想要的目录下,设置成公共读的权限。
  • 对于需要频繁更新的文件,将缓存控制的对象信息 Cache-Control 设置为 no-cache, no-store, must-revalidate 来防止客户端缓存,无法反映更新。在自己调试的时候可以直接使用强制刷新,但是尝试教会被试强制刷新恐怕就不太靠谱。

这样你的实验就部署好了!真的就是这么简单。当然,这样实验数据会在结束后以调试模式保存到本地,我们通常来说当然不希望还要被试自己手动上传,而是可以通过程序解决。这个需求也只是多写一些代码的事情,具体细节请参考 COS 文档或者你选用的产品文档。这里介绍思路,给一些代码示例。

  • 在访问控制中开设子账户,记下对应的 API Access Key 和 Secret(类似于用户名密码)。
  • 为对应账号和存储桶设置(尽量)充分且必要的权限。
  • 如果上传的存储桶和实验部署的存储桶不同,要注意跨域请求设置,否则浏览器干脆连请求都会拦下来。
  • 在实验开始前创建 COS 对象。
  • 实验结束后,整理实验数据,保存成 csv 的字符串形式。
  • 调用 API 上传。

使用这些接口的时候,记得要在网页里引入对应的 js 文件。在实验开始前创建 COS 对象,填入相应信息:

1
2
3
4
5
6
const Bucket = 'your-bucket';
const Region = 'your-region';
const CustomUploader = new COS({
SecretId: 'yourid',
SecretKey: 'yourkey'
});

这种写法直接在代码中暴露了 API Key,所以一定要确保遵照最小权限原则!

然后在实验结束后整理数据。

1
2
3
4
5
6
7
if (psychoJS.experiment.isEntryEmpty()) {
psychoJS.experiment.nextEntry(); // 结束当前试次
}
let slices = psychoJS._experiment._trialsData.slice();
const worksheet = XLSX.utils.json_to_sheet(slices);
const sheetContent = XLSX.utils.sheet_to_csv(worksheet);
const sheetName = `${expInfo['participant']}_data.csv`;

注意这里需要引入XLSX这个外部库,引入方式不再赘述。我是怎么发现这种做法的呢?实际上就是靠翻 PsychoJS 的源码,顺着实验结束的部分一步一步找到的。这里懒得整理 log 信息了,有兴趣的读者可以自己到 ExperimentHandler.save 的源码里抄作业。注意这里的slicesObject构成的Array,这意味你可以在这里就筛选掉不需要的行和列。

比如,目前的 Builder 里没有办法通过图形界面阻止它记录 ButtomStim 的 On-Off 信息,在不需要的时候这些数据就会导致文件体积膨胀,浪费时间和空间,所以在json_to_sheet之前可以这样删掉:

1
2
3
4
slices.forEach(x => {
delete x['button.timesOn'];
delete x['button.timesOff'];
});

记得把button替换成你的组件名。

最后就是上传了。为了保险起见,我考虑的方案是这样的:

  • 先上传一次,运气好的话就结束了。
  • 万一被试拔网线了或者网络真的在波动,那就重试三次。
  • 再不行就算了,先存一份本地,再给一个链接让被试手动上传。

实现也很简单,可以通过一个递归来完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const maxRetry = 3;
const manualUploadingLink = 'your-link';
function uploadExperimentData(retry_counter) {
CustomUploader.uploadFile({
Bucket: Bucket,
Region: Region,
Key: `data/${sheetName}`,
Body: new File([sheetContent], sheetName, { type: 'text/csv'} )
}, function(err, data) {
if (err && retry_counter) {
upload_text.setText('Uploading failed, now retrying...');
console.log(`Uploading failed with remaining ${retry_counter} attempts`);
uploadExperimentData(retry_counter - 1);
} else if (err) {
upload_text.setText(`Uploading failed within ${maxRetry} attempts.`);
util.offerDataForDownload(`${expInfo['participant']}_data.csv`, sheetContent, 'text/csv');
throw Error(`Please complete the online form ${manualUploadingLink} to manually upload your data, and DO NOT MODIFY THE DATA FILE. Feel free to contact us if needed.`);
} else {
continueUploadRoutine = false;
}
});
}
uploadExperimentData(maxRetry);

这里的 upload_text 和报错信息都是提供给被试的,util.offerDataForDownload 是 psychoJS 提供的函数。实际上在最坏的情况下我还会计算数据文件的散列值避免被试篡改,不过也许只是我杞人忧天。如果你也有这种担忧,那么引入一个 md5 的外部库就好了。

实验的时候把 index.html 的链接提供给被试就好,去他的 Pavlovia。

结语

实际上,要在线上实验里随心所欲地设计还是比较困难的,对于纯心理学背景的人来说尤是如此。不过好在 JS 也是高级语言,文档发达,外部库也不少,在某些场合下写起来其实比 Python 还要自然一点点。只要你想,愿意在文档和源码里来回翻找,一般来说总是有办法解决的,唯一的顾虑也许就是时间成本能不能得到相应的回报。对于爱折腾的人来说,这个过程未尝不是一种乐事。

不过也许读者也注意到了,如果要写足够复杂的功能,Builder 提供的帮助就非常有限了,那为什么不自己从头搓一个呢?应该说,这样又有点极端了。虽然 Code Component 在这里重要得多,但是毕竟最醇真的实验是用不到这些的,自动根据条件表格做随机化之类的操作都可以通过点点点完成;即便需要更复杂的功能,Builder 也可以帮助我们用比较直观的方式组织实验思路,节约不少时间。至于代码的可维护性,就需要花费更多功夫来保证了。这确实是目前的一大痛点。目前来说,我认为 PsychoJS 及其生态还是值得使用的。

最后贴上本文标题的 meme 来源。