jspsych / jsPsych

Create behavioral experiments in a browser using JavaScript
http://www.jspsych.org
MIT License
1.04k stars 679 forks source link

How to set timeline_variables to be a function that filters out stimuli based on participant's previous answers #3423

Open AnafNeves opened 3 weeks ago

AnafNeves commented 3 weeks ago

I am working with @DominiqueMakowski to try to set up an experiment in which we need to filter an array of stimuli based on the demographic answers given by the participant at the beginning of the experiment.

However, setting timeline variable to be a function does not seem to work:

timeline.push({
        timeline_variables: function() {return new_stimuli_list}, // new_stimuli_list being created on the on_finish of a previous phase
        timeline: [
            fixactioncross,
            showimage,
        ],
    }
)

We tried as an alternative to put the whole timeline object into a function with the hope that that function would only be called after the demographic phase:

function expe_trials() {
    return {
        timeline_variables: function() {return new_stimuli_list}, // new_stimuli_list being created on the on_finish of a previous phase
        timeline: [
            fixactioncross,
            showimage,
        ],
    }
}
timeline.push(expe_trials())

But that didn't do the trick either, as the function is called immediately on script initialization.

What is the best way to dynamically filter the timeline__variable? Thanks

Shaobin-Jiang commented 3 weeks ago

Take a look at the documentation. https://www.jspsych.org/latest/overview/timeline/#sampling-methods

DominiqueMakowski commented 2 weeks ago

Thanks @Shaobin-Jiang , but this solution unfortunately does not seem to be fit to our purpose.

Basically, the core of the issue is that the timeline variable can only be created after a couple of routines (after demographic questions, as it depends on their answers).

Setting a custom sample function that would fail as it seems to be called at initializion, before we create new_stimul_list.

Tagging also @Max-Lovell for help

DominiqueMakowski commented 2 weeks ago

PS: what we tried with the sample feature was the following:

var fiction_phase1a = {
    timeline_variables: stimuli_original,
    timeline: [
        fixationcross,
        showimage,
        ratings,
    ],
    sample: {
        type: 'custom',
        fn: function (t) {
           // returns a vector of integers corresponding of the position of each element of stimuli_new of stimuli_original
            var idx = stimuli_new.map(subsetItem => {
                return stimuli_original.findIndex(fullItem => {
                    return fullItem.stimulus === subsetItem.stimulus
                })
            })
            return idx
        }
    }
}

But it didn't work, saying that stimuli_new doesn't existed (it doesn't exist on experiment initialization, it gets created later)

Shaobin-Jiang commented 2 weeks ago

I do not think so, as I added a trial object before the trial with a sample function and the sample function does not get called upon initialization.

let jsPsych = initJsPsych();

let test = {
    type: jsPsychHtmlKeyboardResponse,
    stimulus: 'hello world',
};

let fiction_phase1a = {
    timeline_variables: [{stimulus: 123}],
    timeline: [
        {
            type: jsPsychHtmlKeyboardResponse,
            stimulus: jsPsych.timelineVariable('stimulus'),
        },
    ],
    sample: {
        type: 'custom',
        fn: function (t) {
            // returns a vector of integers corresponding of the position of each element of stimuli_new of stimuli_original
            var idx = stimuli_new.map((subsetItem) => {
                return stimuli_original.findIndex((fullItem) => {
                    return fullItem.stimulus === subsetItem.stimulus;
                });
            });
            return idx;
        },
    },
};

jsPsych.run([test, fiction_phase1a]);
Max-Lovell commented 2 weeks ago

If you're using survey.js, are you aware: https://www.jspsych.org/v8/overview/building-surveys/#:~:text=content%20and%20style-,survey%20plugin,Not%20well%2Dsuited%20for%20use%20with%20timeline%20variables,-Large%20set%20of

states:

Which might be part of the problem? You could use another survey provider, or even write your own in HTML.

Either way, can you write a minimal reproduciable example - something I can copy paste and run the whole thing myself which highlights the exact issue?

AnafNeves commented 2 weeks ago

@Shaobin-Jiang Here is a reproducible example:

<!DOCTYPE html>
<html>

<head>
    <!-- Title shown in tab -->
    <title>Experiment</title>

    <!-- Load JsPsych -->
    <script src="https://unpkg.com/jspsych@7.3.1"></script>
    <link href="https://unpkg.com/jspsych@7.3.1/css/jspsych.css" rel="stylesheet" type="text/css" />
    <script src="https://unpkg.com/@jspsych/plugin-html-keyboard-response@1.1.3"></script>
</head>

<body></body>

<script>
    let jsPsych = initJsPsych({
        on_finish: function () {
            jsPsych.data.displayData("json") // Display data in browser
        }
    })

    var timeline = []

    var stimuli_all = [{ stimulus: "ONE" }, { stimulus: "TWO" }, { stimulus: "THREE" }]

    var instructions = {
        type: jsPsychHtmlKeyboardResponse,
        stimulus: "BLABLA INSTRUCTIONS",
        // CREATE NEW STIMULUS LIST AFTER THIS STAGE
        on_finish: function () {
            stimuli_new = [{ stimulus: "TWO" }, { stimulus: "ONE" }]
        }
    }
    timeline.push(instructions)

    // TRIALS
    let trial = {
        type: jsPsychHtmlKeyboardResponse,
        stimulus: jsPsych.timelineVariable('stimulus'),
    }

    let timelime_trials = {
        timeline_variables: stimuli_all,
        timeline: [trial],
        sample: {
            type: 'custom',
            fn: function (t) {
                // returns a vector of integers corresponding of the position of each element of stimuli_new of stimuli_original
                var idx = stimuli_new.map((subsetItem) => {
                    return stimuli_all.findIndex((fullItem) => {
                        return fullItem.stimulus === subsetItem.stimulus
                    })
                })
                return idx
            },
        },
    }
    timeline.push(timelime_trials)
    jsPsych.run(timeline)
</script>

</html>

Essentially, stimuli_new gets defined during the experiment (and does not exist at initialization), and later we use it in the sample function to get the corresponding indices from the stimuli_all list. However, the experiment does not load and throws that stimuli_new is not defined, as if it called the sample function on initialization

AnafNeves commented 2 weeks ago

@Max-Lovell here the issue is not with the survey plugin but more general I think :)

Max-Lovell commented 2 weeks ago

Ah okay, well this is a little hacky but works. relies on the fact that:

https://www.jspsych.org/v7/overview/timeline/#conditional-timelines

The conditional function is evaluated whenever jsPsych is about to run the first trial on the timeline.

perhaps something you could do with looping timlines too but I couldn't figure it out. Can you work with this?

One thing to note is that the for loop MUST use let i=0 and not var i=0. If you use var, i will retain it's final value regardless of when stimuli_all[i] is evaluated, but with let it remains at the right value within the loop iteration.

<!DOCTYPE html>
<html>

<head>
    <!-- Title shown in tab -->
    <title>Experiment</title>

    <!-- Load JsPsych -->
    <script src="https://unpkg.com/jspsych@7.3.1"></script>
    <link href="https://unpkg.com/jspsych@7.3.1/css/jspsych.css" rel="stylesheet" type="text/css" />
    <script src="https://unpkg.com/@jspsych/plugin-html-keyboard-response@1.1.3"></script>
</head>

<body></body>

<script>
    var jsPsych = initJsPsych({
        on_finish: function () {
            jsPsych.data.displayData("json") // Display data in browser
        }
    })

    var timeline = []

    var stimuli_all = ["ONE", "TWO", "THREE"]
    var stimuli_new;

    var instructions = {
        type: jsPsychHtmlKeyboardResponse,
        stimulus: "BLABLA INSTRUCTIONS",
        // CREATE NEW STIMULUS LIST AFTER THIS STAGE
        on_finish: function () {
            stimuli_new = ["ONE", "TWO"]
        }
    }
    timeline.push(instructions)

    // TRIALS
    for(let i=0; i<stimuli_all.length; i++){ // KEEP THIS AS LET AND NOT VAR
        var trial = {
            type: jsPsychHtmlKeyboardResponse,
            stimulus: stimuli_all[i]
        }

        var newNode = {
            timeline: [trial],
            conditional_function: function(){ // Evaluated only at the start of trials
                if(stimuli_new.includes(stimuli_all[i])){ // check stimuli new includes the current stimulus
                        return true; // continue
                    } else {
                        return false;
                    }
                }
        }

        timeline.push(newNode)
    }

    jsPsych.run(timeline)

</script>

</html>
DominiqueMakowski commented 2 weeks ago

Cheers, we'll try that out! Also tagging @jodeleeuw in case there's a better solution

Max-Lovell commented 1 week ago

Having seen the original code, a minimum reproduciable example and a better solution is below - instead of creating a separate timeline for every trial, do that for every stimulus set, and use the conditional function on that timeline. Either that, or use https://www.jspsych.org/v7/reference/jspsych/#jspsychaddnodetoendoftimeline and to it in the on_finish function maybe.

(note NSFW, as this is stim from the original study):

<!DOCTYPE html>
<html>

<head>
    <!-- Load JsPsych -->
    <script src="https://unpkg.com/jspsych@7.3.1"></script>
    <link href="https://unpkg.com/jspsych@7.3.1/css/jspsych.css" rel="stylesheet" type="text/css" />
    <!-- Load plugins -->
    <script src="https://unpkg.com/@jspsych/plugin-survey@1.0.1"></script>
    <script src="https://unpkg.com/@jspsych/plugin-html-keyboard-response@1.1.3"></script>
    <link rel="stylesheet" href="https://unpkg.com/@jspsych/plugin-survey@1.0.1/css/survey.css" />
</head>

<body></body>

<script>
    // https://github.com/RealityBending/FictionEro/tree/main/study2/experiment
    // Initialize experiment =================================================
    var stimuli_list =  [
        {
        "stimulus": "Opposite-sex_couple_001_v.jpg",
        "Category": "Opposite-sex Couple",
        "Orientation": "v"
        },
        {
        "stimulus": "Opposite-sex_couple_002_h.jpg", 
        "Category": "Opposite-sex Couple",
        "Orientation": "h"
        },
        {
        "stimulus": "Male_couple_002_h.jpg",
        "Category": "Male Couple",
        "Orientation": "h"
        },
        {
        "stimulus": "Male_couple_003_v.jpg",
        "Category": "Male Couple",
        "Orientation": "v"
        },
        {
        "stimulus": "Female_couple_002_v.jpg",
        "Category": "Female Couple",
        "Orientation": "v"
        },
        {
        "stimulus": "Female_couple_003_h.jpg",
        "Category": "Female Couple",
        "Orientation": "h"
        },
        {
        "stimulus": "Male_001_v.jpg",
        "Category": "Male",
        "Orientation": "v"
        },
        {
        "stimulus": "Male_002_v.jpg",
        "Category": "Male",
        "Orientation": "v"
        },
        {
        "stimulus": "Female_001_h.jpg",
        "Category": "Female",
        "Orientation": "h"
        },
        {
        "stimulus": "Female_002_h.jpg",
        "Category": "Female",
        "Orientation": "h"
        }
    ]

    const categorizedStimuli = stimuli_list.reduce((acc, item) => {
        // If the category doesn't exist in our accumulator, create an array for it
        if (!acc[item.Category]) {
            acc[item.Category] = [];
        }
        // Push the current item to its category array
        acc[item.Category].push(item);
        return acc;
    }, {});

    var jsPsych = initJsPsych({
        on_finish: function () {
            jsPsych.data.displayData("json") // Display data in browser
        }
    })

    var relevantPhotoCategories; // store selection here to filter which timeline to show

    var demographics_questions = {
        type: jsPsychSurvey,
        survey_json: {
            pages: [
                {
                    elements: [
                        {
                            title: "What is your gender?",
                            name: "Gender",
                            type: "radiogroup",
                            choices: ["Male", "Female", "Other"],
                        },
                    ],
                },
                {
                    elements: [
                        {
                            title: "What sexual orientation do you identify with?",
                            name: "SexualOrientation",
                            type: "radiogroup",
                            choices: ["Heterosexual", "Homosexual", "Bisexual"],
                            showOtherItem: true,
                            otherText: "Other",
                            otherPlaceholder: "Please specify",
                            isRequired: true,
                        },
                    ],
                },
            ],
        },
        data: {
            screen: "demographic_questions",
        },
        on_finish: getRelevantPhotoCategory
    }

    function getRelevantPhotoCategory(data){
        // Get the response data
        const response = data.response;

        // Extract gender and orientation
        const gender = response.Gender;
        const orientation = response.SexualOrientation;

        // Create combined string
        if(orientation === 'Bisexual'){
            relevantPhotoCategories = ["Opposite-sex Couple", "Male Couple", "Female Couple", "Female", "Male"]
        } else if(orientation === "Homosexual") {
            relevantPhotoCategories = [gender, gender + " Couple"]
        } else {
            const oppositeSex = gender === "Male" ? "Female" : "Male"
            relevantPhotoCategories = ["Opposite-sex Couple", oppositeSex, oppositeSex + " Couple" ]
        }
        data.relevantPhotoCategories = relevantPhotoCategories
        // To filer stim: stimuli_list.filter(item => data.relevantPhotoCategories.includes(item.Category));
    }

    // init
    var timeline = []
    timeline.push(demographics_questions)

    // First define the image trial
    var fiction_showimage1 = {
        type: jsPsychHtmlKeyboardResponse,
        stimulus: function() {
            // Create an img element with the stimulus filename
            return `<img src="https://raw.githubusercontent.com/RealityBending/FictionEro/main/study2/experiment/stimuli/${jsPsych.timelineVariable('stimulus')}" style="max-width: 100%;">`;
        },
        choices: ["s"],
    }
    var categories = ["Opposite-sex Couple", "Male Couple", "Female Couple", "Female", "Male"];
    // Loop through categories and create timeline segments
    for(let i=0; i < categories.length; i++) {
        let fiction_phase1a = {
            timeline: [fiction_showimage1], // Put the trial object in an array
            timeline_variables: categorizedStimuli[categories[i]],
            conditional_function: function() {
                if(relevantPhotoCategories.includes(categories[i])) {
                    console.log("Showing category:", categories[i]);
                    return true;
                } else {
                    console.log("Skipping category:", categories[i]);
                    return false;
                }
            }
        }
        timeline.push(fiction_phase1a)
    }

    jsPsych.run(timeline)
</script>

</html>