scripts/experiments/jnd/jnd.js
import { balance_subconditions } from '/scripts/experiment-properties/balancing/balancing_controller.js'
import { initialize_random_order } from '/scripts/experiment-properties/balancing/generators/random_generator.js'
import { get_data } from '/scripts/experiment-properties/data/data_controller.js'
import { generateDistribution } from '/scripts/experiment-properties/distribution/gaussian_distribution_generator.js'
import { randomize_position } from '/scripts/helpers/experiment_helpers.js'
export default class JND {
/**
* Initializes a JND experiment object.
*
* @param {assoc array} Parameters passed from routing.
*/
constructor(params) {
let trial_structure = params["trial_structure"];
let condition_name = params["condition"];
let graph_type = params["graph_type"];
let balancing_type = params["balancing"];
let conversion_factor = params["conversion_factor"];
this.condition_name = condition_name;
this.condition_group = this.condition_name.split('_')[0]; // Mostly to handle "distractor" conditions.
// TODO: Should have a better flag for it.
this.subject_id = params["subject_id"];
this.subject_initials = params["subject_initials"];
// ========================================
// PARAMETER CHECKING
// **NOTE: EXPERIMENTS variable comes from /public/config/experiments-config.js
if (!EXPERIMENTS["jnd"]["trial_structure"].includes(trial_structure)) {
throw Error(trial_structure + " is not supported.");}
else {
this.trial_structure = trial_structure;
}
if (!EXPERIMENTS["jnd"]["graph_type"].includes(graph_type)){
throw Error(graph_type + " is not supported.")}
else {
this.graph_type = graph_type;
};
if (!EXPERIMENTS["jnd"]["balancing_type"].includes(balancing_type)) {
throw Error(balancing_type + " is not supported.") }
else {
this.balancing_type = balancing_type;
}
// ========================================
// EXPERIMENT CONSTANTS
this.PIXELS_PER_CM = conversion_factor;
this.MIN_CORRELATION = 0.0;
this.MAX_CORRELATION = 1.0;
this.MIN_TRIALS = 24;
this.MAX_TRIALS = 52;
this.WINDOW_SIZE = 24;
this.WINDOW_INTERVAL = 3;
this.CONVERGENCE_THRESHOLD = 0.75;
this.INCORRECT_MULTIPLIER = 3;
// ========================================
// PRACTICE EXPERIMENT VARIABLES
this.practice_conditions_constants;
this.current_practice_condition_index;
// ========================================
// TEST EXPERIMENT VARIABLES
this.first_trial_of_sub_condition = true;
this.sub_condition_order;
this.sub_conditions_constants;
this.current_sub_condition_index;
this.adjusted_quantity_matrix = {}; // The matrix is in this format:
// { sub_condition_index : [adjusted_quantity1, adjusted_quantity2 ... ] }
// ========================================
// CURRENT TRIAL DATA
// Plotting-related vars
this.left_coordinates = "";
this.right_coordinates = "";
this.distractor_coordinates = "";
// JsPsych trial_data for the current trial
this.trial_data = "";
// ========================================
// PREPARE EXPERIMENT
// Extract raw constants
this.raw_constants = get_data(this);
// Prepare experiment + practice data
this.prepare_experiment();
this.prepare_practice();
// ========================================
// Adjusted Statistic Values
// ** Previously were set inside the get_next_adjusted_statistic method.
// ** Replacing constants into variables to be set inside the method.
let correct_statistic_increment;
let incorrect_statistic_increment;
}
/**
* Orders the input data according to balancing type and
* initializes the JND object's variables.
*
* @param balancing_type {string} Type of balancing. Currently only latin_square
* is supported.
* dataset {[{assoc array}, {assoc array}, ... ]} The data to be ordered.
*/
prepare_experiment() {
let dataset = this.raw_constants;
this.sub_condition_order = balance_subconditions(this.balancing_type, this.constructor.name.toLowerCase(), dataset.length);
var ordered_dataset = [];
// Order the data set according to the latin square
// Initialize adjusted_quantity_matrix size
for (let i=0; i < this.sub_condition_order.length; i++){
ordered_dataset[i] = dataset[this.sub_condition_order[i]];
this.adjusted_quantity_matrix[i] = [];
}
// Set experiment trials
this.sub_conditions_constants = ordered_dataset;
this.current_sub_condition_index = 0;
}
/**
* Orders the input dataset by randomizing it, and initializes the practice variables.
*
* @param dataset {[{assoc array}, {assoc array}, ... ]} The data to be ordered.
*/
prepare_practice() {
let dataset = this.raw_constants;
this.sub_condition_order = initialize_random_order(dataset.length);
let practice_dataset = [];
// Order the data set according to the latin square
// Initialize adjusted_quantity_matrix size
for (let i=0; i < this.sub_condition_order.length; i++){
practice_dataset[i] = dataset[this.sub_condition_order[i]];
}
// Set practice trials
this.practice_conditions_constants = practice_dataset;
this.current_practice_condition_index = 0;
}
/**
* Generates a JND trial object for use in the JsPsych timeline.
*
* @param type {string} "test" or "practice"
* @return trial {object}
*/
generate_trial(block_type) {
if ((block_type !== "test") && (block_type !== "practice")) {throw Error(block_type + " is not supported.")};
// Initialize a variable for this so it is usable inside on_start
var jnd_exp = this;
var address = location.protocol + "//" + location.hostname + ":" + location.port + "/jnd_trial";
var trial = {
type:'external-html-keyboard-response',
url: address,
choices:['z', 'm', 'q'], //q is exit button (for debugging)
execute_script: true,
response_ends_trial: true,
post_trial_gap: 300,
on_start: function(trial){ // NOTE: on_start takes in trial var
// Set the constants to be used:
if (block_type == "test"){
var index = jnd_exp.current_sub_condition_index;
var constants = jnd_exp.sub_conditions_constants[index];
}
else {
var index = jnd_exp.current_practice_condition_index;
var constants = jnd_exp.practice_conditions_constants[index];
}
// Calculate adjusted value
var adjusted_value = jnd_exp.calculate_adjusted_value(constants);
// Handling saving this trial's data:
jnd_exp.handle_data_saving(trial, block_type, constants, index, adjusted_value);
// Generate distributions
var base_coordinates = generateDistribution(constants.base_correlation,
constants.error,
constants.num_points,
constants.num_SD,
constants.mean,
constants.SD);
var adjusted_coordinates = jnd_exp.generate_adjusted_distribution(constants, adjusted_value);
// trial block for symmetric jnd
if (jnd_exp.condition_group === "symmetric"){
var adjusted_value_high = constants.adjusted_correlation_high;
var adjusted_value_low = constants.adjusted_correlation_low;
jnd_exp.handle_data_saving(trial, block_type, constants, index, adjusted_value_high);
jnd_exp.handle_data_saving(trial, block_type, constants, index, adjusted_value_low);
var low_coordinates = generateDistribution(constants.base_correlation,
constants.error,
constants.num_points,
constants.num_SD,
constants.mean,
constants.SD,
constants.adjusted_correlation_low);
var high_coordinates = generateDistribution(constants.base_correlation,
constants.error,
constants.num_points,
constants.num_SD,
constants.mean,
constants.SD,
constants.adjusted_correlation_high);
}
if (jnd_exp.condition_group === "distractor"){
var left_dist_coordinates = generateDistribution(constants.dist_base,
constants.dist_error,
constants.dist_num_points,
constants.num_SD,
constants.mean,
constants.SD);
var right_dist_coordinates = generateDistribution(constants.dist_base,
constants.dist_error,
constants.dist_num_points,
constants.num_SD,
constants.mean,
constants.SD);
jnd_exp.distractor_coordinates = [left_dist_coordinates, right_dist_coordinates];
}
// Randomize position of the base and adjusted graphs
var result = randomize_position(trial,
base_coordinates,
adjusted_coordinates,
constants,
adjusted_value);
// // For testing purposes, can force R graph to have greater correlation
// var result = force_greater_right_position(trial,
// base_coordinates,
// adjusted_coordinates,
// constants.base_correlation,
// adjusted_correlation);
// Set up D3 variables for plotting
jnd_exp.coordinates = [result.left, result.right];
jnd_exp.trial_data = trial.data;
if (constants.task) {
console.log("[TASK TYPE]: " + constants.task);
}
console.log("[RIGHT] Correlation: " + trial.data.right_correlation);
console.log("[RIGHT] Num points: " + trial.data.right_num_points);
console.log("[LEFT] Correlation: " + trial.data.left_correlation);
console.log("[LEFT] Num points: " + trial.data.left_num_points);
},
on_finish: function(data){ // NOTE: on_finish takes in data var
// Set the constants to be used:
let index;
let constants;
if (block_type == "test"){
index = jnd_exp.current_sub_condition_index;
constants = jnd_exp.sub_conditions_constants[index];
}
else {
index = jnd_exp.current_practice_condition_index;
constants = jnd_exp.practice_conditions_constants[index];
}
jnd_exp.check_response(data, constants);
console.log("RESPONSE: " + data.correct);
}
};
return trial;
}
/**
* Generates the adjusted distribution depending on the type of task.
* If no task is specified, defaults to using correlation.
*
* @return {object} Adjusted coordinates
*/
generate_adjusted_distribution(constants, adjusted_value) {
let adjusted_coordinates;
if (constants.task) {
switch (constants.task) {
case "numerosity":
// Use base correlation but w/ adjusted num of points
adjusted_coordinates = generateDistribution(constants.base_correlation,
constants.error,
adjusted_value,
constants.num_SD,
constants.mean,
constants.SD);
break;
case "correlation":
adjusted_coordinates = generateDistribution(adjusted_value,
constants.error,
constants.num_points,
constants.num_SD,
constants.mean,
constants.SD);
break;
default:
throw Error("Generating adjusted distribution has not been handled for task: " + constants.task);
}
} else {
adjusted_coordinates = generateDistribution(adjusted_value,
constants.error,
constants.num_points,
constants.num_SD,
constants.mean,
constants.SD);
}
return adjusted_coordinates;
}
/**
* Handles saving the relevant data on a given trial.
*
* For reference, these are the helper variables created to assist in trial logic (i.e not present in excel)
* trial_variables =
* {type: 'jnd',
* run_type: '',
* left_correlation: '',
* right_correlation: '',
* };
*
* These are variables created WITHIN the trial logic that were not present in excel (but need to be
* outputted to results).
* export_variables =
* {sub_condition: '', // Chronological ordering of sub_condition [1, 2, 3 ... ]
* balanced_sub_condition: '', // Index of sub_condition according to balancing order
* jnd: '',
* base_correlation: '',
* adjusted_value: '',
* correct: '',
* };
*
* @param trial {object}
* block_type {string} "test" or "practice"
* constants {assoc array}
* index {integer}
* adjusted_value {double}
*/
handle_data_saving(trial, block_type, constants, index, adjusted_value) {
// Add all constants from excel
trial.data = constants;
// Adding constants that required computation (not from excel)
trial.data.type = "jnd";
trial.data.adjusted_value = adjusted_value;
if (constants.task) {
switch (constants.task) {
case "numerosity":
trial.data.adjusted_value_type = "number of points";
trial.data.jnd = Math.abs(adjusted_value - constants.num_points);
break;
case "correlation":
trial.data.adjusted_value_type = "correlation";
trial.data.jnd = Math.abs(adjusted_value - constants.base_correlation);
break;
default:
throw Error("Calculations for jnd has not been handled for task: " + constants.task);
}
} else {
trial.data.jnd = Math.abs(adjusted_value - constants.base_correlation);
}
trial.data.sub_condition = index;
trial.data.balanced_sub_condition = this.sub_condition_order[index];
// Block specific saves
if (block_type == "test"){
this.adjusted_quantity_matrix[index].push(adjusted_value);
trial.data.run_type = "test";
}
else{
trial.data.run_type = "practice";
}
}
/**
* Determines whether the current sub condition can end or not.
*
* @return {boolean} True if sub condition should end.
*/
end_sub_condition() {
if (this.adjusted_quantity_matrix[this.current_sub_condition_index].length == this.MAX_TRIALS ||
this.is_converged_in_window()){
return true;
}
else {
return false;
}
}
/**
* Checks whether we have just started a subcondition.
*
* @return {boolean} True if sub condition has just started.
*/
is_start_of_subcondition() {
if (this.adjusted_quantity_matrix[this.current_sub_condition_index].length === 0) {
return true;
}
return false;
}
/**
* Determines whether current subcondition has converged or not.
*
* @return {boolean} True if converged.
*/
is_converged_in_window() {
var converged = false;
var num_completed_trials = this.adjusted_quantity_matrix[this.current_sub_condition_index].length;
// Check if we have completed the minimum number of trials
// and if the number of completed trials is greater than the window size
if (num_completed_trials >= this.MIN_TRIALS && num_completed_trials >= this.WINDOW_SIZE) {
// 2D Matrix of windows of adjusted quantities
var adjusted_quantity_windows = [];
// The index of the last trial
var last_trial = num_completed_trials - 1;
// Compute the interval size and remainder
// The remainder is computed in case the window size isn't divisible by the # intervals
var interval_size = this.WINDOW_SIZE / this.WINDOW_INTERVAL;
var interval_remainder = this.WINDOW_SIZE % this.WINDOW_INTERVAL;
// This is the first trial in the window
// For example:
// numCompletedTrials = 5
// windowSize = 3
// [ 0 1 2 3 4 5 6 7 8 9 ]
// windowStart would be at index: 5 - 3 = 2
var window_start = num_completed_trials - this.WINDOW_SIZE;
console.log("num completed: " + num_completed_trials);
console.log("window start: " + window_start);
// Iterate over all of the trials from the start of the window to the last trial
// and organize them into the 2D adjustedQuantityWindows matrix
while (window_start < last_trial) {
// While we have extra elements that don't fit into an interval
// add one extra to each window interval
var current_interval_size = interval_remainder > 0 ? interval_size + 1 : interval_size;
if (interval_remainder > 0) {
interval_remainder--;
}
// Collect the adjusted quantity values from the trials into the double[]
var adjusted_quantities = [];
for (let i = 0; i < current_interval_size; ++i) {
var adjusted_quantity = this.adjusted_quantity_matrix[this.current_sub_condition_index][i + window_start];
adjusted_quantities.push(adjusted_quantity);
}
// Set the window start to the next interval
window_start += current_interval_size;
adjusted_quantity_windows.push(adjusted_quantities);
}
console.log(adjusted_quantity_windows);
var variance = [];
var mean = [];
for (let i = 0; i < adjusted_quantity_windows.length; i++){
variance.push(math.var(adjusted_quantity_windows[i]));
mean.push(math.mean(adjusted_quantity_windows[i]));
}
var mean_of_variances = math.mean(variance);
var variance_of_means = math.var(mean);
var F = variance_of_means/mean_of_variances;
console.log("F: " + F);
// Convergence if the F value is < 1 - convergenceThreshold
// if the F is greater than 0.25, then converge
converged = F < (1 - this.CONVERGENCE_THRESHOLD);
}
if (converged) {console.log("CONVERGED!!!!")};
return converged;
}
/**
* Calculates the adjusted value depending on whether this is the
* first trial of the sub condition or not.
*
* @param constants {assoc array}
* @return adjusted_value {double}
*/
calculate_adjusted_value(constants) {
// For the first trial, we need to initialize the adjusted correlation:
if (this.first_trial_of_sub_condition){
var adjusted_value = this.initialize_adjusted_statistic(constants);
// Set flag to false
this.first_trial_of_sub_condition = false;
}
else{
var last_JND_trial = jsPsych.data.get().filter({type: "jnd"}).last(1).values()[0];
var adjusted_value = this.get_next_adjusted_statistic(last_JND_trial, constants);
}
return adjusted_value;
}
/**
* Initializes the adjusted value for the first time.
*
* @param {object} constants
* @return {double} adjusted_value
*/
initialize_adjusted_statistic(constants) {
let adjusted_value;
if (constants.reference_start) {
adjusted_value = constants.reference_start;
}
else {
if (constants.converge_from_above){
adjusted_value = Math.min(this.MAX_CORRELATION, constants.base_correlation + constants.initial_difference);
}
else {
adjusted_value = Math.max(this.MIN_CORRELATION, constants.base_correlation - constants.initial_difference);
};
}
return adjusted_value;
}
/**
* Calculates the next adjusted value.
*
* @param {object} last_JND_trial
*. {object} constants
* @return {double} next_adjusted_statistic
*/
get_next_adjusted_statistic(last_JND_trial, constants){
let next_adjusted_statistic;
if (constants.task){
switch (constants.task) {
case "numerosity":
if (last_JND_trial.correct) {
correct_numerosity_statistic_increment = 1;
next_adjusted_statistic = last_JND_trial.adjusted_value + correct_numerosity_statistic_increment;
} else {
let incorrect_numerosity_statistic_increment = 3;
next_adjusted_statistic = last_JND_trial.adjusted_value - incorrect_numerosity_statistic_increment;
}
break;
case "correlation":
// For num_corr conditions, guard to force adjusted stat to be use this calculation instead of the
// traditional min/max AKA what is in get_next_adjusted_correlation
if (this.condition_name.split("_").includes("num") && this.condition_name.split("_").includes("corr")) {
if (last_JND_trial.correct) {
correct_statistic_increment = 0.01;
next_adjusted_statistic = last_JND_trial.adjusted_value + correct_statistic_increment;
} else {
incorrect_statistic_increment = 0.03;
next_adjusted_statistic = last_JND_trial.adjusted_value - incorrect_statistic_increment;
}
}
// Checks if the condition under correlation is symmetric
else if (this.condition.name.includes("symmetric")){
if (last_JND_trial.correct){
correct_statistic_increment = 0.1;
next_adjusted_statistic = last_JND_trial.adjusted_value + correct_statistic_increment;
} else {
incorrect_statistic_increment = 0.05;
next_adjusted_statistic = last_JND_trial.adjusted_value - incorrect_statistic_increment;
}
}
else {
next_adjusted_statistic = this.get_next_adjusted_correlation(last_JND_trial, constants);
}
break;
default:
throw Error("Calculations for getting next adjusted statistic has not been handled for task: " + constants.task);
break;
}
}
else {
let initial_difference = constants.base_correlation;
next_adjusted_statistic = this.get_next_adjusted_correlation(last_JND_trial, constants);
}
return next_adjusted_statistic;
}
/**
* Calculates the next adjusted correlation (the traditional way).
*
* @param {object} last_JND_trial
*. {object} constants
* @return {double} next_adjusted_statistic
*/
get_next_adjusted_correlation(last_JND_trial, constants){
let next_adjusted_statistic;
let initial_difference = constants.base_correlation;
if (constants.converge_from_above) {
if (last_JND_trial.correct) {
next_adjusted_statistic = Math.max(initial_difference, last_JND_trial.adjusted_value - constants.max_step_size);
} else {
next_adjusted_statistic = Math.min(this.MAX_CORRELATION, last_JND_trial.adjusted_value + constants.max_step_size
* this.INCORRECT_MULTIPLIER);
}
} else {
if (last_JND_trial.correct) {
next_adjusted_statistic = Math.min(initial_difference, last_JND_trial.adjusted_value + constants.max_step_size);
} else {
next_adjusted_statistic = Math.max(this.MIN_CORRELATION, last_JND_trial.adjusted_value - constants.max_step_size
* this.INCORRECT_MULTIPLIER);
}
}
return next_adjusted_statistic;
}
/**
* Given a JND trial data, determines whether response is
* correct or not.
*
* @param {JsPsych.data} data
* @param. {object} constants
* @return {boolean}
*/
check_response(data, constants) {
// For debugging purposes:
if (data.key_press == jsPsych.pluginAPI.convertKeyCharacterToKeyCode('q')){
data.correct = -1;
return -1;
}
let right_greater_clause;
let left_greater_clause;
if (!constants.task || constants.task === "correlation") {
right_greater_clause = data.right_correlation > data.left_correlation;
left_greater_clause = data.left_correlation > data.right_correlation;
} else if (constants.task === "numerosity") {
right_greater_clause = data.right_num_points > data.left_num_points;
left_greater_clause = data.left_num_points > data.right_num_points;
} else {
throw Error("Check response function has not been handled for task: " + constants.task);
}
if (right_greater_clause && (data.key_press == jsPsych.pluginAPI.convertKeyCharacterToKeyCode('m')) ||
left_greater_clause && (data.key_press == jsPsych.pluginAPI.convertKeyCharacterToKeyCode('z'))){
data.correct = true;
return true;
}
// Assuming that if base_correlation = adjusted_correlation, at this point
// any user choice is wrong.
else {
data.correct = false;
return false;
}
}
/**
* When called, will save individual trial data into a CSV.
*/
export_trial_data() {
var trial_data = jsPsych.data.get().filter({type: 'jnd', run_type: 'test'})
.filterCustom(function(x){ //Don't include the exit trials
return x.correct != -1;
})
// JND's trial variables
.ignore('type')
.ignore('run_type')
.ignore('left_correlation')
.ignore('right_correlation')
// These are variables forced on by jsPsych
.ignore('stimulus')
.ignore('key_press')
.ignore('choices')
.ignore('trial_type')
.ignore('trial_index')
.ignore('time_elapsed')
.ignore('internal_node_id');
// TODO: js converting key_string to use double quotes, needs to be single to pass into ignore() fxn
//
// for (var key in jnd_exp.trial_variables){
// var key_string = '${key}';
// trial_data.ignore(key);
// }
var string = "S" + this.subject_id + "_" + this.condition_name + "_jnd_trial_results.csv";
trial_data.localSave('csv', string);
}
/**
* When called, will save aggregated trial data into a CSV.
*/
export_summary_data() {
var csv = 'SUBJECT_ID,SUBJECT_INITIALS,PLOT,BASE,ABOVE,NUM_POINTS,JND,TRIALS\n';
var data = [];
// Organize each row of the csv
for (let i = 0; i<this.sub_conditions_constants.length; i++){
var row = [this.subject_id, this.subject_initials, this.condition_name];
var constants = this.sub_conditions_constants[i];
var condition_data = jsPsych.data.get().filter({type: 'jnd', run_type: 'test', balanced_sub_condition: this.sub_condition_order[i]})
.filterCustom(function(x){ //Don't include the exit trials
return x.correct != -1;
})
row.push(constants.base_correlation);
row.push(constants.converge_from_above);
row.push(constants.num_points);
row.push(condition_data.select('jnd').mean());
row.push(condition_data.count());
data.push(row);
}
// Append each row
data.forEach(function(row){
csv += row.join(',');
csv += "\n";
});
var hiddenElement = document.createElement('a');
hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(csv);
hiddenElement.target = '_blank';
hiddenElement.download = "S" + this.subject_id + "_" + this.condition_name + "_jnd_summary_results.csv";
hiddenElement.click();
}
}