1 引言
分享一下如何使用MATLAB中的心理学工具箱(Psychtoolbox,PTB)编写一个完整的心理学实验。一般而言,任何行为实验都可以按照本文的框架来编写。
今天要编写的例子是一个很简单的按键判断的实验,只有一个block,block里有五个trial。每个trial的流程图如下。首先呈现一个500~1200ms的注视点,然后随机出现左箭头或右箭头,被试需要根据出现的刺激按方向键反应,如果500ms之内按键则记录反应时、正确率等信息,并且箭头消失,否则箭头会呈现500ms的时间后自动消失,最后是300ms的空屏。
2 实验程序的编写
2.1 创建文件夹、构建程序的基本框架
首先新建一个文件夹,将来的实验程序文件都放在这个文件夹中,这样子管理起来比较方便。我的叫“My_Psych_exp”,注意MATLAB读取文件时有一些命名规范,一般采用英文命名即可,单词与单词之间用首字母大写或下划线来区分。
在MATLAB左边的面板点击进入刚刚创建的文件夹,新建一个叫“exp_example”的文件夹,在该文件夹中通过快捷键Ctrl+N
新建一个脚本。
开头输入如下三行代码。clear
是清除所有变量,clc
是清除命令行窗口的代码,sca
相当于Screen('CloseAll')
,即关闭PTB里所有打开的窗口。
clear;
clc;
sca;
接下来输入如下代码。
try
HideCursor;
catch
ShowCursor;
sca;
psychrethrow(psychlasterror);
end
编写实验程序时推荐采用这种“try……catch……end”的框架,实验的主要代码就放在try和catch中间,这样万一出现错误,就会执行catch之后的语句。
我们一般做行为实验时都是通过按键反应,鼠标指针是隐藏起来的,因此可以在一开始使用HideCursor
隐藏鼠标指针,同时在catch部分写上ShowCursor
,这样程序出错时鼠标指针就会显示出来。而sca
会关闭窗口,psychrethrow(psychlasterror)
则会在命令行窗口告诉我们出错的代码的位置。
2.2 创建数据表格
现在,让我们来看看整个实验程序最重要的部分:数据。
在MATLAB中,我们的数据都储存在变量中,虽然可以直接将这些变量保存为“mat”格式的文件,并在MATLAB中处理这些数据,但最好还是将实验数据统一整理并导出为外部文件,以便将来进行查阅和统计分析。
在这个例子中,我们想要收集的信息有:被试的基本信息(包括被试编号、性别、年龄和利手)、注视点呈现时间、箭头刺激的朝向、反应按键、反应时、正确率。并且这个实验总共有5个trial。因此预期的数据矩阵的格式如下(为了方便理解,添加了表头)。
在实验过程中,我们需要时不时地从这么一个矩阵中读写信息,例如将被试的反应按键记录到矩阵中,然后在计算正确率的时候再将这一信息读取出来使用。为此,我们先创建一个名为“data”的cell矩阵,矩阵的大小就按照我们预期的要求。之所以使用cell,是因为我们的数据信息同时包含了数值和字符串。现在,让我们将下面这段代码添加至“try……”的后面。
% Create a cell array to save data
data = cell(5,9);
2.3 收集被试的基本信息
在开始实验前,我们还需要收集被试的基本信息。
使用过E-Prime的同学应该知道,E-Prime运行实验程序时,会出现一些收集被试的基本信息的对话框。在MATLAB中,我们同样可以采用对话框的方式来收集被试的信息。
% Obtain some information of experiment
prompt = {'Subject Number','Gender[1 = m, 2 = f]','Age','Handeness[1 = left, 2 = right]'};
title = 'Exp infor'; % The title of the dialog box
definput = {'','','',''}; % Default input value(s)
% Using inputdlg() to obtain information and save it to cell array
data(1:5, 1:4) = repmat(inputdlg(prompt,title,[1, 50],definput)', 5, 1);
这里用到了MATLAB自带的inputdlg
函数,其作用是打开一个收集用户输入的对话框,并将输入的信息赋给一个变量(在这里就是赋给我们刚刚创建的cell矩阵啦)。至于inputdlg
中的参数,prompt
是对需要输入的信息的提示,title
就是对话框标题,“[1, 50]”是输入框的维度,表示对话框的高度为一个字符,长度为50个字符。如果需要增加、减少想要收集的信息,则在prompt
这个变量里添加/删除一下就可以了。
接着,我们将收集到的信息,通过'
(英文的单引号,效果是将行变列、将列变行)进行转置,并通过repmat
函数复制5次后,写入我们刚刚创建的数据矩阵中。先让我们看一下最终的效果。
其实这些信息只需要一行就够了,我这么做只是为了美观一点……
2.4 打开窗口
继续在try部分添加下面这些代码。代码的作用是打开一个窗口(这个窗口便是我们呈现实验刺激的地方),同时我们还设置了一些所需的参数。
HideCursor;
% Open a black backgound window
[w, wrect] = Screen('OpenWindow', 0, [0, 0, 0]);
% Define the center coordinates
[x_center, y_center] = RectCenter(wrect);
% Measure the vertical refresh rate of the monitor
ifi = Screen('GetFlipInterval', w);
% Text font and text color
Screen('TextFont', w, 'Simhei');
Screen('TextSize', w, 65);
接下来解释一下各行代码。
Screen('OpenWindow')
的作用是打开一个窗口,我们将这个窗口命名为“w”,“wrect”返回的值是窗口的分辨率,0是显示器的编号,第一个显示器的编号是0,后面依次是1、2、3……,当存在多个显示器时,可以通过代码自动获取各个显示器的编号,这里不再赘述。[0, 0, 0] 是窗口的颜色,这里以RGB的方式编写,三个0就是黑色,三个255就是白色,很好记。
RectCenter(wrect)
是获取显示器屏幕的中心点的位置,之后我们可以用x_center和y_center这两个值作为坐标来呈现注视点。
Screen('GetFlipInterval')
的目的是获取当前显示器每刷新一帧所需的秒数,即每次“Flip”所需的时间,后面讲到timing的时候会介绍这一数值的作用。
最后两行是设置窗口中呈现的文本的字体、字号,“w”就是我们定义的窗口的名字啦,要想在PTB中正常显示中文,就需要选择一个支持中文的字体,这里选择的是黑体“SimHei”,字号是65。
2.5 设置按键
接下来设置一下我们需要用到的按键,也就是键盘上的左、右方向键,然后我们再设置一个退出键——“Esc”,中途需要退出时便可以使用这个按键。
% Define some keyboard keys
KbName('UnifyKeyNames');
left_key = KbName('LeftArrow');
right_key = KbName('RightArrow');
esc_key = KbName('escape');
2.6 准备实验材料
接下来,我们要准备两个内容:其一是所有trial的注视点的呈现时间(在500ms至1200ms之内随机决定 ),其二是所有trial的箭头刺激的朝向(朝左或朝右)。
% Prepare materials
for i = 1:5
data{i, 5} = unifrnd(0.5,1.2); % Fixation time
if unidrnd(2) == 1 % Arrow orientation
data{i, 6} = '←';
else
data{i, 6} = '→';
end
end
这里用了一个循环语句,从而依次生成5个trial的注视点呈现时间和箭头朝向,并将这些信息写入我们的数据矩阵中。首先通过unifrnd(0.5,1.2)
随机生成 [0.5,1.2] 之中的值,作为注视点的呈现时间。其次,通过unifrnd(2)
生成一个随机数,这个随机数为1或2,我们通过逻辑语句对这个数值进行判断,当生成的随机数是1时,这次trial的箭头刺激朝左,否则朝右。
不过,做正式实验的时候,正确的做法应该是将刺激序列保存为一个外部的表格文件,每次运行脚本时,就调用一次表格文件,同时按需要将其顺序打乱从而以随机序列的方式呈现刺激。本文之所以将所有代码都放置在一个脚本中,只是为了方便大家理解。
2.7 呈现指导语
然后便是准备指导语了,虽然我们可以用DrawText
或DrawFormattedText
直接在屏幕上“画”出指导语,但是为了美观,还是推荐通过读取事先制作好的图片来呈现指导语。
我们可以在PowerPoint中编写指导语界面,保存为图片并放入文件夹中,最后用MakeTexture
函数,将指导语和结束语图片赋给exp_instruction
和exp_end
两个变量,这样就准备完成了。需要的时候,用DrawTexture
“画”出来即可。
现在我们先呈现指导语,然后通过KbStrokeWait
等待按键,以便被试在阅读完指导语后按任意键继续。
% Load pictures
exp_instruction = Screen('MakeTexture', w, imread('pic\exp_instruction.tif'));
exp_end = Screen('MakeTexture', w, imread('pic\exp_end.tif'));
% Display instruction
Screen('DrawTexture', w, exp_instruction, []);
Screen('Flip', w);
KbStrokeWait;
需要注意的是,这里我们将指导语和结束语的图片放在了实验程序文件夹中的一个叫“pic”的子文件夹,之后输出的实验数据文件我们会放到另一个叫“data”的子文件夹,这样做是为了方便整理,以免各种实验材料、数据、程序都放在同一个文件夹中而导致过于混乱。
指导语和结束语的图片如下,你可以将其另存为tif格式的图片,然后放置在“pic”文件夹中。
2.8 构建trial的框架
接下来便是编写一个trial之中的内容,即呈现注视点呀、收集反应信息呀什么的,我们采用“for……end”的格式。即该实验有五个trial,循环运行五个trial之后就结束这一部分。trial = 1:5
中的trial
是计数用的变量,写成i = 1:5
或任何其它名称都可以(当然不能是MATLAB中的保留字),为了方便理解,这里写为trial
。
% Loop for the total number of trials
for trial = 1:5
end
2.9 呈现注视点
我们可以采用DrawDots
绘制注视点,其中的参数是指,在“w”窗口的中心画出一个点,大小是10,颜色是白色。
% Fixation: 500~1200ms
for i = 1:round(cell2mat(data(trial, 5)) / ifi)
Screen('DrawDots', w, [x_center; y_center], 10, [255,255,255], [], 1);
Screen('Flip', w);
end
至于注视点的时间,需要先从我们的数据矩阵中调用刚刚随机生成的值(即cell2mat(data(trial, 5))
),这个值是秒数,我们将这个值除以ifi
,并通过round
取整,便得到该时间段在这台显示器上对应的帧数。例如,我的显示器的ifi
是0.0167,一秒对应的帧数就是1/0.0167≈60,也就是说我的显示器的FPS(frames per second)是60帧/秒,也就是60Hz的刷新率。
所以,我们只需根据秒数算出帧数x,然后反复绘制x次刺激并通过Screen('Flip')
刷新屏幕(绘制的刺激是在缓冲区,需要刷新屏幕使其呈现出来),便可以在指定的时间内精确地呈现刺激。
2.10 呈现刺激
注视点消失后便是箭头刺激的呈现,很简单,和呈现注视点是类似的套路。DrawFormattedText
的第二个参数是我们想要呈现的文本,这里同样从数据矩阵中调用已经随机生成好的信息。
% Stimulus: 500ms
DrawFormattedText(w, data{trial, 6}, 'center', 'center', [255,255,255]);
Screen('Flip', w);
2.11 收集反应信息
现在我们需要收集被试的反应啦。首先试着分析一下如何达到这个目的。我们希望被试在箭头刺激呈现的500ms之内反应,所以可以通过一个while循环来实现,只要刺激呈现的时间距离当前时间的差值在500ms以内,则执行一些语句,以便记录被试的按键、反应时、正确率等信息,并提前结束刺激的呈现,如果超出500ms,则直接结束刺激的呈现,然后将按键、反应时和正确率记录为空值。
现在,我们按照上述的构思,一步步写出代码。
首先,我们通过GetSecs
获取一个时间戳,赋给t0
,作为刺激呈现的起始时间点,之后我们可以随时获取新的时间戳,并减去t0
,得到的便是自刺激呈现之后经过的时间。
让我们来定义一个While循环,使脚本在呈现刺激后的500ms内,反复运行while循环内的语句。
% Record responses data
t0 = GetSecs;
while GetSecs - t0 < 0.5
end
接着在循环内添加以下代码,其作用是检查被试按下的按键。
[keyisdown, secs, keycode] = KbCheck;
然后通过if语句判断,如果按的是Esc键,则直接结束脚本。
if keycode(esc_key)
sca;
return
如果是按了其它的按键,则将按键的名称和反应时间(GetSecs - t0
)记录到数据矩阵中。
elseif keyisdown
data{trial, 7} = KbName(keycode); % resp
data{trial, 8} = GetSecs - t0; % rt
接着便是判断此次反应的正确率了,如果按键为左方向键且刺激为左箭头,或者按键为右方向键且刺激为右箭头,则该试次的正确率记为1,反之记为0,最后通过break
来结束循环。
% acc
if keycode(left_key) && data{trial, 6} == '←'
data{trial, 9} = 1; % acc=1
elseif keycode(right_key) && data{trial, 6} == '→'
data{trial, 9} = 1; % acc=1
else
data{trial, 9} = 0; % acc=0
end
break % break the loop
还有一种情况是没有按键反应,此时我们将反应按键、反应时和正确率都记为NA(当然,不同的实验设计,记录的方式是不同的,这里只是举个例子)。
else
data{trial, 7} = 'NA'; % resp
data{trial, 8} = 'NA'; % rt
data{trial, 9} = 'NA'; % acc
end
end
该部分完整的代码如下:
% Record responses data
t0 = GetSecs;
while GetSecs - t0 < 0.5
[keyisdown, secs, keycode] = KbCheck;
if keycode(esc_key)
sca;
return
elseif keyisdown
data{trial, 7} = KbName(keycode); % resp
data{trial, 8} = GetSecs - t0; % rt
% acc
if keycode(left_key) && data{trial, 6} == '←'
data{trial, 9} = 1; % acc=1
elseif keycode(right_key) && data{trial, 6} == '→'
data{trial, 9} = 1; % acc=1
else
data{trial, 9} = 0; % acc=0
end
break % break the loop
else
data{trial, 7} = 'NA'; % resp
data{trial, 8} = 'NA'; % rt
data{trial, 9} = 'NA'; % acc
end
end
2.12 呈现空屏
之后便是300ms的空屏,刷新一下屏幕然后等待0.3秒即可。
% Interval: 300ms
Screen('Flip', w);
WaitSecs(0.3);
2.13 呈现结束语
至此,实验主体部分的内容都完成了,接下来便是呈现结束语并退出实验程序。这里我们设置的是在呈现结束语的1秒后自动退出。
% Display instruction2
for i = 1:round(1 / ifi)
Screen('DrawTexture', w, exp_end, []);
Screen('Flip', w);
end
sca;
2.14 保存数据文件
但退出窗口之后,我们还有一个很重要的任务,也就是将收集到的数据保存起来。之所以在呈现结束语、退出窗口后才保存数据,是因为将数据写入表格是需要一定的时间的,如果在呈现结束语之前写入数据,会导致trial和结束语之间有一个短暂的“卡顿”,就让人感觉程序运行起来不够“丝滑”了。
代码如下。
% Put all data to a table object
header = {'SubjectNumber', 'Gender', 'Age', 'Handedness',...
'DotsTime', 'Arrow', 'Resp', 'RT', 'ACC'};
data_table = cell2table(data, 'VariableNames', header);
% Create a csv file to save data
exp_data = strcat('data\', 'exp_example_', char(data{1,1}), '_', date, '.csv');
writetable(data_table, exp_data);
现在说明一下这几行代码的含义。
首先我们将cell格式的数据矩阵转换为table。之所以转换为table的形式,是因为我们希望得到的数据文件是带有表头的,header
这个变量的内容就是我们的表头。然后,我们通过writetable
将这个table写入csv格式的表格文件。csv是一个开放性很好的数据文件格式,包括Excel、SPSS、R和Mplus在内的各种软件都支持对csv的读写。然后,我们通过strcat
函数将各种字符和字符变量串起来,作为数据文件的文件名,在这里,exp_data
这个变量包含了需要保存的数据文件的目录(保存在“data”这个子文件夹里)、文件名和文件格式。其中文件名由实验的名称(“exp_example”)、被试的编号和实验日期组成(date
函数的功能是获取当前日期,当然我们还可以通过datestr
函数获取更多样化的日期和时间格式,作为文件名中的实验日期)。
2.15 收尾
完事之后,在命令行窗口输出一些信息,这样我们就知道程序是完全正常地运行结束了。
disp('Succeed!');
对了,如果你无法运行这个脚本并且报错信息指向Screen('OpenWindow')
这行代码,此时可能的原因有很多,其中一个主要的原因是电脑性能可能不够了……
解决的方法则是在脚本的开头添加下面这行代码。
Screen('Preference', 'SkipSyncTests', 2);
这行代码还有一个作用,就是跳过每次运行脚本时会出现的冗长的初始化界面。当然,为了使程序的timing更精确,正式实验时最好还是去掉这一行代码。
3 实验程序的运行效果
好啦,现在试着运行一下看看效果。点击运行,同时系统提示我们选择文件的保存位置和文件名,我就命名为“exp_demo”啦。
运行结束后,命令行窗口显示了“Succeed!”,说明是顺利运行了!
现在打开数据文件,看看数据的保存情况。
可以发现,注视点的呈现时间确实如我们期望的一样,处于500~1200ms的范围之内(为了确保符合预期效果可以多跑几次看看)。刚刚运行时,第一、第二个trial我按了正确的按键,第三个按了未定义的按键(上方向键),第四个按了相反的按键,第五个trial没有按键反应,这些信息都在表格中记录下来了。在这个程序里,对于“按了错误的按键”与“未按任何按键”,记录的信息是一样的,如果需要区分的话,可以继续在程序里进行修改完善。
最后来看一眼该实验程序所有的文件,m文件就是我们的实验程序,“pic”文件夹里放的是指导语的图片,“data”文件夹里的是刚刚运行后收集到的数据,这些文件都保存在“exp_example”这个文件夹之下。
实验程序的全部代码如下。加上注释和换行也只有136行~
% exp_example_procedure.m
%
% A very simple behavioral experiment demo of PTB-3,
% shows you how to create visual stimulus and record responses data.
%
% Wei Zi-Qian, 2020, August
clear;
clc;
sca;
Screen('Preference', 'SkipSyncTests', 2);
try
% Create a cell array to save data
data = cell(5,9);
% Obtain some information of experiment
prompt = {'Subject Number','Gender[1 = m, 2 = f]','Age','Handeness[1 = left, 2 = right]'};
title = 'Exp infor'; % The title of the dialog box
definput = {'','','',''}; % Default input value(s)
% Using inputdlg() to obtain information and save it to cell array
data(1:5, 1:4) = repmat(inputdlg(prompt,title,[1, 50],definput)', 5, 1);
HideCursor;
% Open a black backgound window
[w, wrect] = Screen('OpenWindow', 0, [0, 0, 0]);
% Define the center coordinates
[x_center, y_center] = RectCenter(wrect);
% Measure the vertical refresh rate of the monitor
ifi = Screen('GetFlipInterval', w);
% Text font and text color
Screen('TextFont', w, 'Simhei');
Screen('TextSize', w, 65);
% Define some keyboard keys
KbName('UnifyKeyNames');
left_key = KbName('LeftArrow');
right_key = KbName('RightArrow');
esc_key = KbName('escape');
% Prepare materials
for i = 1:5
data{i, 5} = unifrnd(0.5,1.2); % Fixation time
if unidrnd(2) == 1 % Arrow orientation
data{i, 6} = '←';
else
data{i, 6} = '→';
end
end
% Load pictures
exp_instruction = Screen('MakeTexture', w, imread('pic\exp_instruction.tif'));
exp_end = Screen('MakeTexture', w, imread('pic\exp_end.tif'));
% Display instruction
Screen('DrawTexture', w, exp_instruction, []);
Screen('Flip', w);
KbStrokeWait;
% Loop for the total number of trials
for trial = 1:5
% Fixation: 500~1200ms
for i = 1:round(cell2mat(data(trial, 5)) / ifi)
Screen('DrawDots', w, [x_center; y_center], 10, [255,255,255], [], 1);
Screen('Flip', w);
end
% Stimulus: 500ms
DrawFormattedText(w, data{trial, 6}, 'center', 'center', [255,255,255]);
Screen('Flip', w);
% Record responses data
t0 = GetSecs;
while GetSecs - t0 < 0.5
[keyisdown, secs, keycode] = KbCheck;
if keycode(esc_key)
sca;
return
elseif keyisdown
data{trial, 7} = KbName(keycode); % resp
data{trial, 8} = GetSecs - t0; % rt
% acc
if keycode(left_key) && data{trial, 6} == '←'
data{trial, 9} = 1; % acc=1
elseif keycode(right_key) && data{trial, 6} == '→'
data{trial, 9} = 1; % acc=1
else
data{trial, 9} = 0; % acc=0
end
break % break the loop
else
data{trial, 7} = 'NA'; % resp
data{trial, 8} = 'NA'; % rt
data{trial, 9} = 'NA'; % acc
end
end
% Interval: 300ms
Screen('Flip', w);
WaitSecs(0.3);
end
% Display instruction2
for i = 1:round(1 / ifi)
Screen('DrawTexture', w, exp_end, []);
Screen('Flip', w);
end
sca;
% Put all data to a table object
header = {'SubjectNumber', 'Gender', 'Age', 'Handedness',...
'DotsTime', 'Arrow', 'Resp', 'RT', 'ACC'};
data_table = cell2table(data, 'VariableNames', header);
% Create a csv file to save data
exp_data = strcat('data\', 'exp_example_', char(data{1,1}), '_', date, '.csv');
writetable(data_table, exp_data);
disp('Succeed!');
catch
ShowCursor;
sca;
psychrethrow(psychlasterror);
end
4 扩展
4.1 操作多个图片的方法
举个例子,假如我们有20张刺激图片,名叫1.png、2.png、3.png……20.png,这些图片放在实验程序所在文件夹中的名为pic的子文件夹。那么,我们可以通过以下代码,将这些图片依次加载至MATLAB,并统一放置在名为img的数组中。
% Prepare image
img = zeros(20, 1);
for i = 1:20
pic = strcat('pic\',string(i),'.png');
img(i) = Screen('MakeTexture', w, imread(pic));
end
上述这段代码位于打开窗口之后,循环呈现指导语之前。更多的图片也是类似的操作,修改一下参数即可。
接着,如果我们是想随机呈现图片的话,可以用以下代码打乱img的顺序。
img=img(randperm(length(img))); % random order
某些情况下,我们还可以反复使用这一行代码来打乱img的顺序。例如实验设计有n个block,每个block的刺激内容都是相同的,只是顺序不同,此时便可以在每个block的起始位置添加该代码,便能轻松实现随机呈现的效果。
之后便是在每个trial中呈现图片刺激了,非常简单,只需要将trial的数值作为索引来调用img中的图片。注意我这里将图片调整为255*255像素的大小,你可以根据自己的实验设计来修改这一参数。
% Stimulus: 500ms
Screen('DrawTexture', w, img(trial), [0 0 255 255]);
Screen('Flip', w);
最后,也是最重要的一点,在保存每个trial的反应信息的时候,千万别忘了记录这个trial中呈现的img的类型。
在这个例子中,图片有两种类型,如下。
condition1 = [11,12,13,14,15,16,17,18,19,20];
condition2 = [21,22,23,24,25,26,27,28,29,30];
其中的11、12、13等等,是img中的值,代表每个图片,至于为什么会是这些值,我就不太清楚了,将图片载入MATLAB后就是这样的,我只是根据载入后的结果写了这些代码……
于是每个trial的结尾便可以通过以下代码判断img的类型。
% Save img type
if ismember(img(trial), condition1)
imgType = 'condition1';
elseif ismember(img(trial), condition2)
imgType = 'condition2';
end
这里的imgType只是举例,你也可以直接将判断的结果放入到保存所有数据的matrix中。
4.2 略
等待施工……
5 结语
本文的目的是用尽量简单的方法在PTB中编写一个心理学实验程序。不过鉴于作者也只是刚刚开始自学PTB,水平有限,某些部分可能会有更便捷的方法。以及,文中难免会有一些错漏,请大家多多指正。
最后再说一下,在学习PTB的过程中,对于任何不清楚的部分,请善用MATLAB中的帮助系统。对于MATLAB自带的函数,可以通过help
函数查看其功能(例如help writetable
),对于PTB中的函数,例如DrawText
,可以通过Screen DrawText?
这样的形式查看其功能。此外,“Psychtoolbox”文件夹中的有一个名为“PsychDemos”的文件夹,其包含着许多展示PTB功能的demo。官网里也有不少学习PTB的资料,大家可以自行学习,例如,“Tutorial”这个链接里就有很多介绍PTB功能的范例(其中第一、第二部分是入门PTB必看的)。
----------2020.10.23更新----------
修改了一处冗余的代码:将61行的char(data(trial, 6))
改成了data{trial, 6}
----------2020.12.30更新----------
更改了timing的方式:将“以秒为单位呈现刺激”改为了“以帧为单位呈现刺激”
更改了文章中的几处表述,对“收集反应信息”一节的内容做了更详细的说明
----------2021.01.12更新----------
修改了一处错误
----------2021.01.31更新----------
添加了扩展内容4.1