Plugins for jspsych@6.1
This commit is contained in:
397
jspsych-nafc-adaptive.js
Executable file
397
jspsych-nafc-adaptive.js
Executable file
@@ -0,0 +1,397 @@
|
||||
/*------------------------------------------------------------------------------
|
||||
* n-AFC Adaptive Task generic for jsPsych
|
||||
*------------------------------------------------------------------------------
|
||||
* This template requires the jspsych-audio-sequence-button-response plugin.
|
||||
*------------------------------------------------------------------------------
|
||||
* Author: Etienne Gaudrain <etienne.gaudrain@cnrs.fr>
|
||||
* 2020-05-19: Fixed some typos
|
||||
* 2020-12-08: Fixed threshold calculation
|
||||
*----------------------------------------------------------------------------*/
|
||||
|
||||
/* To use, you need to include the following in your webpage:
|
||||
<script src="/js/vendor/jspsych.js"></script>
|
||||
<script src="/js/vendor/jspsych-plugins/jspsych-instructions.js"></script>
|
||||
<script src="/js/vendor/jspsych-plugins/jspsych-waitfor-function.js"></script>
|
||||
<script src="/js/vendor/jspsych-plugins/jspsych-audio-sequence-button-response.js"></script>
|
||||
*/
|
||||
|
||||
/* Here's an example for next_trial:
|
||||
function prepare_trial(last_trial, step, options, condition, done){
|
||||
var diff = last_trial.trial_definition.f0 + step;
|
||||
var new_trial = {
|
||||
stimuli: null,
|
||||
i_correct: getRandomIntInclusive(0,3),
|
||||
trial_definition: {f0: diff+"st"}, // The parameters that define the trial
|
||||
step: step, // Just in case we modified the provided step
|
||||
difference: diff
|
||||
};
|
||||
async_generate_stimuli(function(){
|
||||
new_trial.stimuli = ['blah', 'blah', 'blih'];
|
||||
done(new_trial);
|
||||
});
|
||||
}
|
||||
|
||||
function send_data_securely_to_server(data){
|
||||
console.log(data.last().values().json());
|
||||
}
|
||||
*/
|
||||
|
||||
/* Here's an example for options:
|
||||
var options = {
|
||||
initial_step_size: 2,
|
||||
starting_difference: 12,
|
||||
step_size_modifier: 1/Math.sqrt(2),
|
||||
down_up: [2, 1], // 2-down, 1-up => 70.7%
|
||||
terminate_on_nturns: 8,
|
||||
terminate_on_ntrials: 150,
|
||||
terminate_on_max_difference: 25,
|
||||
threshold_on_last_nturns: 6,
|
||||
change_step_size_on_difference: 2,
|
||||
change_step_size_on_ntrials: 15,
|
||||
prompt: "Which of the three is different from the two others?",
|
||||
isi: 250,
|
||||
intervals: ['1', '2', '3'],
|
||||
prepare_trial: prepare_trial,
|
||||
after_the_run: send_data_securely_to_server, // function(options, condition, data, success_cb())
|
||||
start_button: 'Beginnen',
|
||||
opening_message: "<h1>Test</h1><p>You will hear three sounds. Choose the one that is different from the two others.</p>",
|
||||
closing_message: "<h1>Thank you!</h1><p>This is the end of this block. Thank you for your help!</p>"
|
||||
};
|
||||
*/
|
||||
|
||||
// This relies on some tools and polyfills that are in /js/tools.js
|
||||
|
||||
function nAFC_adapt(opts, condition) {
|
||||
// This generates trials for one "condition", that is a whole run that leads
|
||||
// to a threshold.
|
||||
//
|
||||
// `options` defines the parameters of the AFC
|
||||
// `condition` defines the condition along which the parameters are being adapted.
|
||||
|
||||
var options = jsPsych.utils.deepCopy(opts);
|
||||
|
||||
// Mandatory arguments in options
|
||||
var mand_opts = ['initial_step_size', 'starting_difference', 'down_up', 'terminate_on_nturns',
|
||||
'terminate_on_ntrials', 'terminate_on_max_difference', 'threshold_on_last_nturns', 'step_size_modifier', 'change_step_size_on_difference',
|
||||
'change_step_size_on_ntrials', 'intervals', 'prepare_trial'
|
||||
];
|
||||
for(var k of mand_opts) {
|
||||
if(!(k in options)) {
|
||||
throw "nAFC_adapt: " + k + " is a mandatory option to provide.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if(typeof options.opening_message === 'undefined')
|
||||
options.opening_message = null;
|
||||
if(typeof options.closing_message === 'undefined')
|
||||
options.closing_message = null;
|
||||
if(typeof options.prompt === 'undefined')
|
||||
options.prompt = null;
|
||||
if(typeof options.isi === 'undefined')
|
||||
options.isi = 0;
|
||||
if(typeof options.after_the_run === 'undefined')
|
||||
options.after_the_run = function(dat, done){ done(); };
|
||||
|
||||
options.current_step = 0;
|
||||
options.current_step_size = options.initial_step_size;
|
||||
options.current_difference = options.starting_difference;
|
||||
options.change_step_size_on_ntrials_counter = 0;
|
||||
|
||||
function is_correct(t) {
|
||||
return parseInt(t.button_pressed) == t.i_correct;
|
||||
}
|
||||
|
||||
function whereto_next(last_trials) {
|
||||
// This returns the new step based on the last n trials.
|
||||
|
||||
if(typeof whereto_next.v === 'undefined')
|
||||
whereto_next.v = [];
|
||||
|
||||
whereto_next.v.push(last_trials.slice(-1)[0].correct);
|
||||
|
||||
// We go neither up nor down
|
||||
var step = 0;
|
||||
|
||||
console.log("---------------------- WHERE TO NEXT?");
|
||||
console.log(whereto_next.v);
|
||||
|
||||
options.change_step_size_on_ntrials_counter++;
|
||||
|
||||
// Are we going down?
|
||||
//if(typeof last_trials[last_trials.length - options.down_up[0]] !== 'undefined') {
|
||||
if(whereto_next.v.length >= options.down_up[0] && whereto_next.v.slice(-options.down_up[0]).every(Boolean)) {
|
||||
// We're goin' down!
|
||||
if(options.current_difference - options.current_step_size <= 0)
|
||||
options.current_step_size = options.current_difference / 2;
|
||||
else if(options.current_difference <= options.current_step_size * options.change_step_size_on_difference ||
|
||||
options.change_step_size_on_ntrials_counter == options.change_step_size_on_ntrials) {
|
||||
options.current_step_size = options.current_step_size * options.step_size_modifier;
|
||||
options.change_step_size_on_ntrials_counter = 0;
|
||||
}
|
||||
|
||||
step = -options.current_step_size;
|
||||
|
||||
console.log("WE'RE GOIN' DOWN!");
|
||||
|
||||
whereto_next.v = [];
|
||||
}
|
||||
|
||||
// Are we going up?
|
||||
//if(typeof last_trials[last_trials.length - options.down_up[1]] !== 'undefined') {
|
||||
else if(whereto_next.v.length >= options.down_up[1] && whereto_next.v.slice(-options.down_up[1]).every(function(t) { return !t; })) {
|
||||
// We're goin' up!
|
||||
if(options.current_difference <= options.current_step_size * options.change_step_size_on_difference ||
|
||||
options.change_step_size_on_ntrials_counter == options.change_step_size_on_ntrials) {
|
||||
options.current_step_size = options.current_step_size * options.step_size_modifier;
|
||||
options.change_step_size_on_ntrials_counter = 0;
|
||||
}
|
||||
|
||||
step = options.current_step_size;
|
||||
|
||||
console.log("WE'RE GOIN' UP!");
|
||||
|
||||
whereto_next.v = [];
|
||||
}
|
||||
|
||||
else {
|
||||
console.log("WE'RE GOIN' NOWHERE!");
|
||||
}
|
||||
|
||||
if(options.change_step_size_on_ntrials_counter == options.change_step_size_on_ntrials) {
|
||||
options.change_step_size_on_ntrials_counter = 0;
|
||||
}
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
function do_we_keep_going(data) {
|
||||
// Data is a jsPsych DataCollection that contains all the audio-sequence-button-response trials so far
|
||||
// returns true to continue, and false to stop
|
||||
// When we stop we insert a new row in the data collection containing the threshold or the reason why we stopped
|
||||
|
||||
var steps = data.select('step').values;
|
||||
steps.push(options.current_step);
|
||||
var differences = data.select('difference').values;
|
||||
// The current difference hasn't been computed yet, this happens prepare_trial(), so we add the likely next value
|
||||
differences.push(options.current_difference + options.current_step);
|
||||
|
||||
if(data.count() >= options.terminate_on_ntrials) {
|
||||
|
||||
jsPsych.setProgressBar(1);
|
||||
var corrects = data.select('correct').values;
|
||||
jsPsych.data.get().push({
|
||||
type: 'threshold',
|
||||
threshold: NaN,
|
||||
geom_threshold: NaN,
|
||||
reason: 'ntrials',
|
||||
steps: steps,
|
||||
differences: differences,
|
||||
condition: condition,
|
||||
corrects: corrects,
|
||||
internal_node_id: data.last().select('internal_node_id').values[0]
|
||||
});
|
||||
return false;
|
||||
|
||||
} else if(differences.slice(-1)[0]>options.terminate_on_max_difference) {
|
||||
|
||||
jsPsych.setProgressBar(1);
|
||||
var corrects = data.select('correct').values;
|
||||
jsPsych.data.get().push({
|
||||
type: 'threshold',
|
||||
threshold: NaN,
|
||||
geom_threshold: NaN,
|
||||
reason: 'max_difference',
|
||||
steps: steps,
|
||||
differences: differences,
|
||||
condition: condition,
|
||||
corrects: corrects,
|
||||
internal_node_id: data.last().select('internal_node_id').values[0]
|
||||
});
|
||||
return false;
|
||||
|
||||
} else {
|
||||
|
||||
var snnsd = steps.non_zero().map(Math.sign).diff().non_zero();
|
||||
nturns = snnsd.length;
|
||||
|
||||
console.log("---------------------- DO WE KEEP GOIN'?");
|
||||
console.log("Corrects: ["+data.select('correct').values+"]");
|
||||
console.log("steps: ["+steps+"]");
|
||||
console.log("steps!=0: ["+steps.non_zero()+"]");
|
||||
console.log("sign(steps!=0): ["+steps.non_zero().map(Math.sign)+"]");
|
||||
console.log("diff(sign(steps!=0)): ["+steps.non_zero().map(Math.sign).diff()+"]");
|
||||
console.log("snnsd: ["+snnsd+"]");
|
||||
console.log("nturns:"+nturns);
|
||||
|
||||
jsPsych.setProgressBar(nturns/options.terminate_on_nturns);
|
||||
|
||||
if(nturns >= options.terminate_on_nturns) {
|
||||
// That's a nice way of ending... Let's calculate the threshold
|
||||
|
||||
var i_nz = steps.findIndices(function(x) { return x != 0; });
|
||||
// EG: 2020-12-07, correcting stupid mistake
|
||||
//var i_tp = i_nz.filter(function(x, i) { return snnsd[i] != 0; });
|
||||
var i_d = steps.non_zero().map(Math.sign).diff().findIndices(function(x) { return x != 0; });
|
||||
var i_tp = i_nz.select(i_d);
|
||||
// -- end edit 2020-12-07
|
||||
i_tp.push(differences.length - 1);
|
||||
i_tp = i_tp.slice(-options.threshold_on_last_nturns);
|
||||
|
||||
var corrects = data.select('correct').values;
|
||||
|
||||
thr = differences.select(i_tp).mean();
|
||||
geom_thr = Math.exp(differences.select(i_tp).map(Math.log).mean());
|
||||
|
||||
jsPsych.data.get().push({
|
||||
type: 'threshold',
|
||||
threshold: thr,
|
||||
geom_threshold: geom_thr,
|
||||
reason: 'nturns',
|
||||
steps: steps,
|
||||
differences: differences,
|
||||
condition: condition,
|
||||
corrects: corrects,
|
||||
internal_node_id: data.last().select('internal_node_id').values[0]
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Make an adaptive run based on a condition
|
||||
|
||||
var run_timeline = [];
|
||||
|
||||
if(options.opening_message != null) {
|
||||
run_timeline.push({
|
||||
type: 'instructions',
|
||||
pages: [
|
||||
options.opening_message
|
||||
],
|
||||
show_clickable_nav: true,
|
||||
button_label_next: options.start_button,
|
||||
allow_backward: false,
|
||||
on_start: function(){
|
||||
jsPsych.setProgressBar(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//var ExpState = { N: 0 };
|
||||
|
||||
var t = {
|
||||
timeline: [{
|
||||
type: 'waitfor-function',
|
||||
func: function(done) {
|
||||
//var files = ['/audio/Beer.wav', '/audio/Beer.wav', '/audio/Beer.wav'];
|
||||
//ExpState.files = files;
|
||||
|
||||
var last_trial = jsPsych.data.get().filter({trial_type: 'audio-sequence-button-response'}).last().values()[0];
|
||||
|
||||
// prepare_trial is a user provided function that expects the following arguments:
|
||||
// last_trial: the last trial
|
||||
// step: the new step that needs applying
|
||||
// options: all the options
|
||||
// condition: a definition of the condition we're in
|
||||
// success_callback: the function that will be called upon success, which takes the new trial definition
|
||||
// new_trial has the following keys:
|
||||
// stimuli: the list of sound files to load
|
||||
// i_correct: the index of the correct response
|
||||
// trial_definition: the parameters that define the trial
|
||||
// step: the step used to create the new trial
|
||||
// difference: the difference used to create the new trial
|
||||
|
||||
options.prepare_trial(
|
||||
last_trial, options.current_step, options, condition,
|
||||
function(new_trial) {
|
||||
options.current_difference = new_trial.difference;
|
||||
jsPsych.pluginAPI.preloadAudioFiles(new_trial.stimuli, function() {
|
||||
done(new_trial);
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
async: true,
|
||||
min_duration: 1000
|
||||
},
|
||||
{
|
||||
type: 'audio-sequence-button-response',
|
||||
stimuli: function() {
|
||||
return jsPsych.data.getLastTrialData().values()[0].value.stimuli;
|
||||
},
|
||||
data: function() {
|
||||
return jsPsych.data.getLastTrialData().values()[0].value;
|
||||
},
|
||||
trial_ends_after_audio: false,
|
||||
i_correct: function() {
|
||||
return jsPsych.data.getLastTrialData().values()[0].value.i_correct;
|
||||
},
|
||||
visual_feedback: options.visual_feedback,
|
||||
button_html: "<button class='jspsych-btn afc-btn'>%choice%</button>",
|
||||
choices: options.intervals,
|
||||
prompt: options.prompt,
|
||||
prompt_position: 'top',
|
||||
isi: options.isi,
|
||||
on_finish: function(data) {
|
||||
data.correct = is_correct(data);
|
||||
console.log("Correct? "+data.correct);
|
||||
}
|
||||
}
|
||||
],
|
||||
loop_function: function(data) {
|
||||
// If only we had access to the current nodeID, things would be a bit simpler... but we'll hack something
|
||||
|
||||
var last_trial_node_id = jsPsych.data.getLastTrialData().select('internal_node_id').values[0];
|
||||
var the_node_id_we_want = last_trial_node_id.split('-').slice(0,-2).join("-");
|
||||
|
||||
options.current_step = whereto_next(jsPsych.data.get().filter({trial_type: 'audio-sequence-button-response'}).last(Math.max(options.down_up[0], options.down_up[1]) + 1).values());
|
||||
|
||||
return do_we_keep_going(jsPsych.data.getDataByTimelineNode(the_node_id_we_want).filter({ trial_type: 'audio-sequence-button-response' }));
|
||||
},
|
||||
};
|
||||
|
||||
run_timeline.push(t);
|
||||
|
||||
// To save the data... would be better suited for on_finish, but we need async
|
||||
run_timeline.push({
|
||||
type: 'waitfor-function',
|
||||
func: function(done) {
|
||||
var last_trial_node_id = jsPsych.data.getLastTrialData().select('internal_node_id').values[0];
|
||||
var the_node_id_we_want = last_trial_node_id.split('-').slice(0,-2).join("-");
|
||||
|
||||
options.after_the_run(options, condition, jsPsych.data.getDataByTimelineNode(the_node_id_we_want), done);
|
||||
},
|
||||
async: true,
|
||||
min_duration: 0
|
||||
});
|
||||
|
||||
if(options.closing_message != null) {
|
||||
run_timeline.push({
|
||||
type: 'instructions',
|
||||
pages: [
|
||||
options.closing_message
|
||||
],
|
||||
show_clickable_nav: true,
|
||||
button_label_next: 'OK',
|
||||
allow_backward: false
|
||||
});
|
||||
}
|
||||
/*
|
||||
run_timeline.push({
|
||||
type: 'instructions',
|
||||
pages: [
|
||||
"<h1>Thank you!</h1>" +
|
||||
"<p>This is the end of that block.</p>"
|
||||
],
|
||||
show_clickable_nav: true,
|
||||
button_label_next: 'Next'
|
||||
});
|
||||
*/
|
||||
|
||||
return {timeline: run_timeline};
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user