PsychoPy与JavaScript与线上实验
最早听说线上可以做实验的时候,我也不觉得奇怪,毕竟虽然数据质量并不一定好,但开发这玩意还是相当有前景的。不过当时我对 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 | const http = require("http"); |
这里也可以替换为其他端口。调试时,通过终端运行:
1 | node debug.js |
然后在浏览器打开 http://localhost:8080/index.html
即可。
如果导出 HTML 时没有自动下载 PsychoJS 脚本,也可以自己用 Node.js 编译。用 git
克隆仓库源码:
1 | git clone https://github.com/psychopy/psychojs.git |
这样仓库就会进入指定版本的状态。然后运行 npm run build
,检查输出目录。
如果想让实验程序的定制化程度更高,可以用这种方式修改 PsychoJS 的源码然后自己编译。
翻译问题
明明是 PsychoPy 先来的,为什么会变成这样? PsychoPy Builder 支持一个看似强大的功能:将用户自定义组件的 Python 代码翻译成 JS 代码,而这种需求还是相当常见的,因为 PsychoPy 的用户不见得懂 JS,而很多涉及实验条件的功能都只能靠自定义代码来实现。不过目前为止的实现看起来都非常粗糙,旧版的已知问题可以参考这份文档。在我个人体验中,最常见的还是变量声明问题:在特定位置使用变量时,Builder 无法识别并添加对应的 JS 变量声明,导致运行时报错。这种问题比较好排查,因为报错信息很直接,不过只是比较烦人。变量、常量声明及其作用域见 MDN 文档。
实际上大部分和 JS 有关的问题也可以查阅 MDN,可谓应有尽有。
另一个比较显然不过也需要注意的事情是:Python 的第三方模块自然没有办法翻译,因为这里是 JS,该滚的是你们吧! 不过也没有必要吊死在一颗树上,我们 JS 人有 JS 人自己的第三方库,如果有需要的话,直接修改 index.html
引入就好,比如这样:
1 | <!-- external libraries --> |
这里的前三行实际上是自动生成的文件里就有的,作用就是引入 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 | // name -> font-family |
熟悉字体属性的朋友应该能看出 weight 就是字重,truetype 就是指 .ttf 类型的 TrueType 字体。如果你用的是 OpenType 字体,作相应替换即可。这里用到的FontFace
构造器可能并未得到所有浏览器的支持,具体可以检查 MDN 文档,这里就不作介绍了。
在定义了这个异步函数之后,我们需要用阻塞的方式等他把字体加载完毕,否则很有可能字体还没加载好就开始实验了(如果你不了解具体原因,可以自行检索异步机制)。那自然的想法就是加几行 await loadFontFace(...)
就完事了,结果!一加进去,Builder 的导出就全乱套了。没办法,只能避开这个关键字,用更笨的办法。为预加载字体添加一个 Routine,然后定义一个控制阻塞状态的变量var continuePreloading
,在 Routine 开始前加入代码:
1 | Promise.allSettled([ // 注意把字体路径改成你自己的 |
然后在每一帧添加代码将 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 | const Bucket = 'your-bucket'; |
这种写法直接在代码中暴露了 API Key,所以一定要确保遵照最小权限原则!
然后在实验结束后整理数据。
1 | if (psychoJS.experiment.isEntryEmpty()) { |
注意这里需要引入XLSX
这个外部库,引入方式不再赘述。我是怎么发现这种做法的呢?实际上就是靠翻 PsychoJS 的源码,顺着实验结束的部分一步一步找到的。这里懒得整理 log 信息了,有兴趣的读者可以自己到 ExperimentHandler.save
的源码里抄作业。注意这里的slices
是Object
构成的Array
,这意味你可以在这里就筛选掉不需要的行和列。
比如,目前的 Builder 里没有办法通过图形界面阻止它记录 ButtomStim 的 On-Off 信息,在不需要的时候这些数据就会导致文件体积膨胀,浪费时间和空间,所以在json_to_sheet
之前可以这样删掉:
1 | slices.forEach(x => { |
记得把button
替换成你的组件名。
最后就是上传了。为了保险起见,我考虑的方案是这样的:
- 先上传一次,运气好的话就结束了。
- 万一被试拔网线了或者网络真的在波动,那就重试三次。
- 再不行就算了,先存一份本地,再给一个链接让被试手动上传。
实现也很简单,可以通过一个递归来完成:
1 | const maxRetry = 3; |
这里的 upload_text
和报错信息都是提供给被试的,util.offerDataForDownload
是 psychoJS 提供的函数。实际上在最坏的情况下我还会计算数据文件的散列值避免被试篡改,不过也许只是我杞人忧天。如果你也有这种担忧,那么引入一个 md5 的外部库就好了。
实验的时候把 index.html
的链接提供给被试就好,去他的 Pavlovia。
结语
实际上,要在线上实验里随心所欲地设计还是比较困难的,对于纯心理学背景的人来说尤是如此。不过好在 JS 也是高级语言,文档发达,外部库也不少,在某些场合下写起来其实比 Python 还要自然一点点。只要你想,愿意在文档和源码里来回翻找,一般来说总是有办法解决的,唯一的顾虑也许就是时间成本能不能得到相应的回报。对于爱折腾的人来说,这个过程未尝不是一种乐事。
不过也许读者也注意到了,如果要写足够复杂的功能,Builder 提供的帮助就非常有限了,那为什么不自己从头搓一个呢?应该说,这样又有点极端了。虽然 Code Component 在这里重要得多,但是毕竟最醇真的实验是用不到这些的,自动根据条件表格做随机化之类的操作都可以通过点点点完成;即便需要更复杂的功能,Builder 也可以帮助我们用比较直观的方式组织实验思路,节约不少时间。至于代码的可维护性,就需要花费更多功夫来保证了。这确实是目前的一大痛点。目前来说,我认为 PsychoJS 及其生态还是值得使用的。
最后贴上本文标题的 meme 来源。