From b0aa0810d729ff1772f8a90ab9872414c62fe9d5 Mon Sep 17 00:00:00 2001 From: i-vikaas Date: Tue, 22 Jul 2025 08:21:04 +0000 Subject: [PATCH 1/4] Added n400 experiment and the text for the experiment --- eegnb/experiments/__init__.py | 2 +- eegnb/experiments/semantic_n400/n400.py | 139 +++++++++++++++++++++ eegnb/stimuli/__init__.py | 1 + eegnb/stimuli/text/congruent/C_text1.txt | 1 + eegnb/stimuli/text/incongruent/IC_text.txt | 1 + 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 eegnb/experiments/semantic_n400/n400.py create mode 100644 eegnb/stimuli/text/congruent/C_text1.txt create mode 100644 eegnb/stimuli/text/incongruent/IC_text.txt diff --git a/eegnb/experiments/__init__.py b/eegnb/experiments/__init__.py index 9306e07b..49d0af05 100644 --- a/eegnb/experiments/__init__.py +++ b/eegnb/experiments/__init__.py @@ -1,7 +1,7 @@ from .visual_n170.n170 import VisualN170 from .visual_p300.p300 import VisualP300 from .visual_ssvep.ssvep import VisualSSVEP - +from .semantic_n400.n400 import TextN400 # PTB does not yet support macOS Apple Silicon, # this experiment needs to run as i386 if on macOS. import sys diff --git a/eegnb/experiments/semantic_n400/n400.py b/eegnb/experiments/semantic_n400/n400.py new file mode 100644 index 00000000..0e2f0653 --- /dev/null +++ b/eegnb/experiments/semantic_n400/n400.py @@ -0,0 +1,139 @@ +""" eeg-notebooks/eegnb/experiments/semantic_n400/n400.py """ + +import os +from time import time +from glob import glob +from random import choice, shuffle +from psychopy import visual, core, event + +from eegnb.devices.eeg import EEG +from eegnb.stimuli import N400_TEXT +from eegnb.experiments import Experiment +from typing import Optional + +import pandas as pd +import numpy as np + + +class TextN400(Experiment.BaseExperiment): + + def __init__(self, duration=120, eeg: Optional[EEG]=None, save_fn=None, + n_trials=200, iti=0.5, soa=0.5, jitter=0.2): + + # Set experiment name + exp_name = "Text N400" # Updated experiment name + # Call the super class constructor to initialize the experiment variables + super(TextN400, self).__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter) + + # Define specific marker names for N400 experiment + # These correspond to the values you'll push to the EEG stream + self.markernames = { + 'congruent': 1, + 'incongruent': 2 + } + + def load_stimulus(self): + """ + Loads the text stimuli (congruent and incongruent sentences/words) + from the specified folders. + """ + # Define base path for text stimuli + text_stim_path = N400_TEXT # This points to eegnb/stimuli/text + + # Load congruent words/sentences from text files + congruent_files = glob(os.path.join(text_stim_path, "congruent", "*.txt")) + self.congruent_stims = [] # Renamed from words to stims for generality + for f in congruent_files: + with open(f, 'r') as file: + # Assuming each line in the .txt file is one stimulus (word or sentence) + self.congruent_stims.extend([line.strip() for line in file if line.strip()]) + + # Load incongruent words/sentences from text files + incongruent_files = glob(os.path.join(text_stim_path, "incongruent", "*.txt")) + self.incongruent_stims = [] # Renamed from words to stims + for f in incongruent_files: + with open(f, 'r') as file: + self.incongruent_stims.extend([line.strip() for line in file if line.strip()]) + + # Create a list of trial types and stimuli + num_congruent = len(self.congruent_stims) # Use actual number of loaded stimuli + num_incongruent = len(self.incongruent_stims) + + # Determine how many trials of each type based on n_trials parameter, keeping proportions + total_stims_available = num_congruent + num_incongruent + if total_stims_available == 0: + raise ValueError("No stimulus text files found in the congruent or incongruent folders!") + + # Calculate ideal distribution for n_trials, ensuring not to exceed available stimuli + ratio_congruent = num_congruent / total_stims_available + ratio_incongruent = num_incongruent / total_stims_available + + trials_to_use_congruent = min(num_congruent, int(self.n_trials * ratio_congruent)) + trials_to_use_incongruent = min(num_incongruent, int(self.n_trials * ratio_incongruent)) + + # Adjust total trials if we can't meet n_trials with available unique stimuli + self.n_trials = trials_to_use_congruent + trials_to_use_incongruent + + # Select stimuli for trials (can use random.sample if no replacement needed, or np.random.choice with replacement) + selected_congruent = np.random.choice(self.congruent_stims, size=trials_to_use_congruent, replace=True).tolist() + selected_incongruent = np.random.choice(self.incongruent_stims, size=trials_to_use_incongruent, replace=True).tolist() + + trial_data = [] + for stim_text in selected_congruent: + trial_data.append({'stim_text': stim_text, 'type': 'congruent'}) + for stim_text in selected_incongruent: + trial_data.append({'stim_text': stim_text, 'type': 'incongruent'}) + + # Shuffle the trials to randomize presentation order + shuffle(trial_data) + + # Create a pandas DataFrame for easier trial management + self.trials = pd.DataFrame(trial_data) + self.trials['iti'] = self.iti + np.random.rand(len(self.trials)) * self.jitter + self.trials['soa'] = self.soa + + # Setup visual stimulus for text presentation + # Use visual.TextStim for words/sentences + self.text_stim = visual.TextStim(win=self.window, text="", color='white', height=0.1) # Initialize with empty text + self.fixation = visual.GratingStim(win=self.window, size=0.2, pos=[0, 0], sf=0, rgb=[1, 0, 0]) + self.fixation.setAutoDraw(True) + + # Initial flip to show fixation before first trial + self.window.flip() + + return None + + def present_stimulus(self, idx: int): + """ + Presents the current word/sentence stimulus and sends a marker to the EEG. + """ + # Get trial details from the trials DataFrame + current_trial = self.trials.iloc[idx] + word_to_display = current_trial['stim_text'] + stim_type = current_trial['type'] + marker_value = self.markernames[stim_type] + + # Update text stimulus and draw it + self.text_stim.text = word_to_display + self.text_stim.draw() + self.fixation.draw() # Ensure fixation is drawn + + # Flip the window to display the word + self.window.flip() + + # Push the sample to the EEG at the exact moment the word appears + if self.eeg: + timestamp = time() + # Marker value should be an int or a list of ints depending on backend + marker = [int(marker_value)] if self.eeg.backend == "muselsl" else int(marker_value) # Ensure marker is int + self.eeg.push_sample(marker=marker, timestamp=timestamp) + + # Wait for the Stimulus Onset Asynchrony (SOA) duration + core.wait(self.soa) + + # Clear the screen after presentation + self.text_stim.text = '' # Clear the text + self.window.flip() + + # Wait for the Inter Trial Interval (ITI) duration before the next trial + core.wait(current_trial['iti']) \ No newline at end of file diff --git a/eegnb/stimuli/__init__.py b/eegnb/stimuli/__init__.py index 551ad0a6..c1c36864 100644 --- a/eegnb/stimuli/__init__.py +++ b/eegnb/stimuli/__init__.py @@ -2,3 +2,4 @@ FACE_HOUSE = path.join(path.dirname(__file__), "visual", "face_house") CAT_DOG = path.join(path.dirname(__file__), "visual", "cats_dogs") +N400_TEXT = path.join(path.dirname(path.abspath(__file__)), "text") diff --git a/eegnb/stimuli/text/congruent/C_text1.txt b/eegnb/stimuli/text/congruent/C_text1.txt new file mode 100644 index 00000000..4587ffe6 --- /dev/null +++ b/eegnb/stimuli/text/congruent/C_text1.txt @@ -0,0 +1 @@ +The cake is in the oven to bake. diff --git a/eegnb/stimuli/text/incongruent/IC_text.txt b/eegnb/stimuli/text/incongruent/IC_text.txt new file mode 100644 index 00000000..41f37b67 --- /dev/null +++ b/eegnb/stimuli/text/incongruent/IC_text.txt @@ -0,0 +1 @@ +He puts gas in his chest. From 3ed0e240ad8062fcf38a479774fabf6194dffd9a Mon Sep 17 00:00:00 2001 From: i-vikaas Date: Tue, 22 Jul 2025 08:45:56 +0000 Subject: [PATCH 2/4] Added more data --- eegnb/stimuli/text/congruent/C_text2.txt | 1 + eegnb/stimuli/text/congruent/C_text3.txt | 1 + eegnb/stimuli/text/congruent/C_text4.txt | 1 + eegnb/stimuli/text/congruent/C_text5.txt | 1 + eegnb/stimuli/text/incongruent/{IC_text.txt => IC_text1.txt} | 0 eegnb/stimuli/text/incongruent/IC_text2.txt | 1 + eegnb/stimuli/text/incongruent/IC_text3.txt | 1 + eegnb/stimuli/text/incongruent/IC_text4.txt | 1 + eegnb/stimuli/text/incongruent/IC_text5.txt | 1 + 9 files changed, 8 insertions(+) create mode 100644 eegnb/stimuli/text/congruent/C_text2.txt create mode 100644 eegnb/stimuli/text/congruent/C_text3.txt create mode 100644 eegnb/stimuli/text/congruent/C_text4.txt create mode 100644 eegnb/stimuli/text/congruent/C_text5.txt rename eegnb/stimuli/text/incongruent/{IC_text.txt => IC_text1.txt} (100%) create mode 100644 eegnb/stimuli/text/incongruent/IC_text2.txt create mode 100644 eegnb/stimuli/text/incongruent/IC_text3.txt create mode 100644 eegnb/stimuli/text/incongruent/IC_text4.txt create mode 100644 eegnb/stimuli/text/incongruent/IC_text5.txt diff --git a/eegnb/stimuli/text/congruent/C_text2.txt b/eegnb/stimuli/text/congruent/C_text2.txt new file mode 100644 index 00000000..ae4bb971 --- /dev/null +++ b/eegnb/stimuli/text/congruent/C_text2.txt @@ -0,0 +1 @@ +In the winter the pond will freeze. \ No newline at end of file diff --git a/eegnb/stimuli/text/congruent/C_text3.txt b/eegnb/stimuli/text/congruent/C_text3.txt new file mode 100644 index 00000000..ef6db151 --- /dev/null +++ b/eegnb/stimuli/text/congruent/C_text3.txt @@ -0,0 +1 @@ +The soccer team scored a goal. \ No newline at end of file diff --git a/eegnb/stimuli/text/congruent/C_text4.txt b/eegnb/stimuli/text/congruent/C_text4.txt new file mode 100644 index 00000000..4bddd571 --- /dev/null +++ b/eegnb/stimuli/text/congruent/C_text4.txt @@ -0,0 +1 @@ +My mom puts money in the bank. \ No newline at end of file diff --git a/eegnb/stimuli/text/congruent/C_text5.txt b/eegnb/stimuli/text/congruent/C_text5.txt new file mode 100644 index 00000000..a134a583 --- /dev/null +++ b/eegnb/stimuli/text/congruent/C_text5.txt @@ -0,0 +1 @@ +His mom makes her own pie crust. \ No newline at end of file diff --git a/eegnb/stimuli/text/incongruent/IC_text.txt b/eegnb/stimuli/text/incongruent/IC_text1.txt similarity index 100% rename from eegnb/stimuli/text/incongruent/IC_text.txt rename to eegnb/stimuli/text/incongruent/IC_text1.txt diff --git a/eegnb/stimuli/text/incongruent/IC_text2.txt b/eegnb/stimuli/text/incongruent/IC_text2.txt new file mode 100644 index 00000000..961c2c56 --- /dev/null +++ b/eegnb/stimuli/text/incongruent/IC_text2.txt @@ -0,0 +1 @@ +He put on his shoes to go apple. \ No newline at end of file diff --git a/eegnb/stimuli/text/incongruent/IC_text3.txt b/eegnb/stimuli/text/incongruent/IC_text3.txt new file mode 100644 index 00000000..a6e046fc --- /dev/null +++ b/eegnb/stimuli/text/incongruent/IC_text3.txt @@ -0,0 +1 @@ +Babies sleep in shot. \ No newline at end of file diff --git a/eegnb/stimuli/text/incongruent/IC_text4.txt b/eegnb/stimuli/text/incongruent/IC_text4.txt new file mode 100644 index 00000000..e3ece84f --- /dev/null +++ b/eegnb/stimuli/text/incongruent/IC_text4.txt @@ -0,0 +1 @@ +The angry boys got in a mouse. \ No newline at end of file diff --git a/eegnb/stimuli/text/incongruent/IC_text5.txt b/eegnb/stimuli/text/incongruent/IC_text5.txt new file mode 100644 index 00000000..160ccfe5 --- /dev/null +++ b/eegnb/stimuli/text/incongruent/IC_text5.txt @@ -0,0 +1 @@ +The cake is in the oven to milk. \ No newline at end of file From dde7be631bed0b22ae6620f186bc31446f6c56ea Mon Sep 17 00:00:00 2001 From: i-vikaas Date: Tue, 22 Jul 2025 08:57:36 +0000 Subject: [PATCH 3/4] Added LICENSE.txt --- eegnb/stimuli/text/LICENSE.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 eegnb/stimuli/text/LICENSE.txt diff --git a/eegnb/stimuli/text/LICENSE.txt b/eegnb/stimuli/text/LICENSE.txt new file mode 100644 index 00000000..48c92339 --- /dev/null +++ b/eegnb/stimuli/text/LICENSE.txt @@ -0,0 +1,3 @@ +All the text data were taken from N400Stimset_stimuli_parameters.tsv from the paper given below +Evoking the N400 event-related potential (ERP) component using a publicly available novel set of sentences with semantically incongruent or congruent eggplants +they are Public Domain (CC0). \ No newline at end of file From 8398f1715e24317b6dac352d0a8ac54e653c2bdc Mon Sep 17 00:00:00 2001 From: i-vikaas Date: Tue, 22 Jul 2025 09:04:08 +0000 Subject: [PATCH 4/4] updated n400.py --- eegnb/experiments/semantic_n400/n400.py | 70 ++++++++++++------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/eegnb/experiments/semantic_n400/n400.py b/eegnb/experiments/semantic_n400/n400.py index 0e2f0653..5776925e 100644 --- a/eegnb/experiments/semantic_n400/n400.py +++ b/eegnb/experiments/semantic_n400/n400.py @@ -20,13 +20,13 @@ class TextN400(Experiment.BaseExperiment): def __init__(self, duration=120, eeg: Optional[EEG]=None, save_fn=None, n_trials=200, iti=0.5, soa=0.5, jitter=0.2): - # Set experiment name - exp_name = "Text N400" # Updated experiment name - # Call the super class constructor to initialize the experiment variables + + exp_name = "Text N400" + super(TextN400, self).__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter) - # Define specific marker names for N400 experiment - # These correspond to the values you'll push to the EEG stream + + self.markernames = { 'congruent': 1, 'incongruent': 2 @@ -37,44 +37,44 @@ def load_stimulus(self): Loads the text stimuli (congruent and incongruent sentences/words) from the specified folders. """ - # Define base path for text stimuli - text_stim_path = N400_TEXT # This points to eegnb/stimuli/text + + text_stim_path = N400_TEXT - # Load congruent words/sentences from text files + congruent_files = glob(os.path.join(text_stim_path, "congruent", "*.txt")) - self.congruent_stims = [] # Renamed from words to stims for generality + self.congruent_stims = [] for f in congruent_files: with open(f, 'r') as file: - # Assuming each line in the .txt file is one stimulus (word or sentence) + self.congruent_stims.extend([line.strip() for line in file if line.strip()]) - # Load incongruent words/sentences from text files + incongruent_files = glob(os.path.join(text_stim_path, "incongruent", "*.txt")) - self.incongruent_stims = [] # Renamed from words to stims + self.incongruent_stims = [] for f in incongruent_files: with open(f, 'r') as file: self.incongruent_stims.extend([line.strip() for line in file if line.strip()]) - # Create a list of trial types and stimuli - num_congruent = len(self.congruent_stims) # Use actual number of loaded stimuli + + num_congruent = len(self.congruent_stims) num_incongruent = len(self.incongruent_stims) - # Determine how many trials of each type based on n_trials parameter, keeping proportions + total_stims_available = num_congruent + num_incongruent if total_stims_available == 0: raise ValueError("No stimulus text files found in the congruent or incongruent folders!") - # Calculate ideal distribution for n_trials, ensuring not to exceed available stimuli + ratio_congruent = num_congruent / total_stims_available ratio_incongruent = num_incongruent / total_stims_available trials_to_use_congruent = min(num_congruent, int(self.n_trials * ratio_congruent)) trials_to_use_incongruent = min(num_incongruent, int(self.n_trials * ratio_incongruent)) - # Adjust total trials if we can't meet n_trials with available unique stimuli + self.n_trials = trials_to_use_congruent + trials_to_use_incongruent - # Select stimuli for trials (can use random.sample if no replacement needed, or np.random.choice with replacement) + selected_congruent = np.random.choice(self.congruent_stims, size=trials_to_use_congruent, replace=True).tolist() selected_incongruent = np.random.choice(self.incongruent_stims, size=trials_to_use_incongruent, replace=True).tolist() @@ -84,21 +84,21 @@ def load_stimulus(self): for stim_text in selected_incongruent: trial_data.append({'stim_text': stim_text, 'type': 'incongruent'}) - # Shuffle the trials to randomize presentation order + shuffle(trial_data) - # Create a pandas DataFrame for easier trial management + self.trials = pd.DataFrame(trial_data) self.trials['iti'] = self.iti + np.random.rand(len(self.trials)) * self.jitter self.trials['soa'] = self.soa - # Setup visual stimulus for text presentation - # Use visual.TextStim for words/sentences - self.text_stim = visual.TextStim(win=self.window, text="", color='white', height=0.1) # Initialize with empty text + + + self.text_stim = visual.TextStim(win=self.window, text="", color='white', height=0.1) self.fixation = visual.GratingStim(win=self.window, size=0.2, pos=[0, 0], sf=0, rgb=[1, 0, 0]) self.fixation.setAutoDraw(True) - # Initial flip to show fixation before first trial + self.window.flip() return None @@ -107,33 +107,33 @@ def present_stimulus(self, idx: int): """ Presents the current word/sentence stimulus and sends a marker to the EEG. """ - # Get trial details from the trials DataFrame + current_trial = self.trials.iloc[idx] word_to_display = current_trial['stim_text'] stim_type = current_trial['type'] marker_value = self.markernames[stim_type] - # Update text stimulus and draw it + self.text_stim.text = word_to_display self.text_stim.draw() - self.fixation.draw() # Ensure fixation is drawn + self.fixation.draw() - # Flip the window to display the word + self.window.flip() - # Push the sample to the EEG at the exact moment the word appears + if self.eeg: timestamp = time() - # Marker value should be an int or a list of ints depending on backend - marker = [int(marker_value)] if self.eeg.backend == "muselsl" else int(marker_value) # Ensure marker is int + + marker = [int(marker_value)] if self.eeg.backend == "muselsl" else int(marker_value) self.eeg.push_sample(marker=marker, timestamp=timestamp) - # Wait for the Stimulus Onset Asynchrony (SOA) duration + core.wait(self.soa) - # Clear the screen after presentation - self.text_stim.text = '' # Clear the text + + self.text_stim.text = '' self.window.flip() - # Wait for the Inter Trial Interval (ITI) duration before the next trial + core.wait(current_trial['iti']) \ No newline at end of file