To ensure that effort is strictly unimodal or multimodal according to the condition (gesture, vocal, multimodal), we will exclude trials that are above threshold
We will set this threshold based on data, rather than excluding anything a priori.
Code to prepare the environment
# Import packagesimport osimport globimport pandas as pdimport numpy as npimport matplotlib.pyplot as pltimport seaborn as snsfrom pathlib import Pathcurfolder = os.getcwd()# Here we store our merged final timeseries datamergedfolder = os.path.join(curfolder, "..", "03_TS_processing", "TS_merged")filestotrack = glob.glob(os.path.join(mergedfolder, "merged_*.csv"))filestotrack = [x for x in filestotrack if"merged_0"notin x] # Get rid of those from dyad 0 (pilot data)filestotrack = [x for x in filestotrack if os.path.basename(x).split("_")[2] !="1"] # Get rid of those that have in the basename third element 1 (part 1, different experiment)print(f"Number of files in total: {len(filestotrack)}")# Here we store the final datacleaningfolder = os.path.join(curfolder, "Cleaning")
Number of files in total: 5238
Collect features for establishing threshold
Useful functions
def rms(x):"""Calculate the root mean square of input array x. Args: x: Input array-like object Returns: float: RMS value of x, or np.nan if x is empty """ x = np.asarray(x)return np.sqrt(np.mean(x **2))def safe_percentile(x, q):"""Calculate the q-th percentile of input array x, returning np.nan if x is empty. Args: x: Input array-like object q: Percentile to compute (0-100) Returns: float: q-th percentile of x, or np.nan if x is empty """ x = np.asarray(x)return np.percentile(x, q) iflen(x) else np.nandef parse_condition_from_fileinfo(fileinfo):""" Extract experimental condition from FileInfo string by substring matching (order-independent). Returns one of: 'gesture', 'vocal', 'multimodal', or 'unknown' """ s = fileinfo.lower() gesture_tokens = {"gebaar", "gebaren", "gesture"} vocal_tokens = {"vocaal", "vocal", "geluiden"} multimodal_tokens = {"combinatie", "multimodal", "multi"}ifany(tok in s for tok in gesture_tokens):return"gesture"elifany(tok in s for tok in vocal_tokens):return"vocal"elifany(tok in s for tok in multimodal_tokens):return"multimodal"else:print(f"Warning: could not parse condition from FileInfo: {fileinfo}")return"unknown"def integral_abs(x):"""Calculate the integral of absolute values of input array x. Args: x: Input array-like object Returns: float: Integral of absolute values of x, or np.nan if x is empty """ x = np.asarray(x)return np.sum(np.abs(x)) iflen(x) else np.nandef extract_trial_features( csv_path, fileinfo_col="FileInfo", trialid_col="TrialID", signals=None):""" Extract trial-level features from a single CSV. signals: dict key -> column name in CSV value -> short prefix used in feature names """if signals isNone: signals = {"envelope_norm": "env","RWrist_speed": "rwrist","LWrist_speed": "lwrist","arm_accKin_sum": "arm_acc","arm_power": "arm_power","f0": "f0", } usecols =list(signals.keys()) + [fileinfo_col, trialid_col] df = pd.read_csv(csv_path, usecols=usecols) trial_id = df[trialid_col].iloc[0] fileinfo = df[fileinfo_col].iloc[0] condition = parse_condition_from_fileinfo(fileinfo) features = {"trial_id": trial_id,"condition": condition, }for col, prefix in signals.items(): x = df[col].dropna().valuesiflen(x) ==0: features.update({f"{prefix}_mean": np.nan,f"{prefix}_rms": np.nan,f"{prefix}_p95": np.nan,f"{prefix}_integral": np.nan, })continue features.update({f"{prefix}_mean": float(np.mean(x)),f"{prefix}_rms": float(rms(x)),f"{prefix}_p95": float(np.percentile(x, 95)),f"{prefix}_integral": float(integral_abs(x)), })return features
Now we collect features for each trial, on the basis of which we later establish thresholds for condition adherence
ifnot os.path.exists(os.path.join(cleaningfolder, "condition_adherence_features.csv")):# prepare list to collect features all_features = []# loop over all files and collect featuresforfilein filestotrack:print(f"Processing file: {file}")# collect features features = extract_trial_features(file) all_features.append(features)# save the df features_df = pd.DataFrame(all_features) features_df.to_csv(os.path.join(cleaningfolder, "condition_adherence_features.csv"), index=False)else:# load the df features_df = pd.read_csv(os.path.join(cleaningfolder, "condition_adherence_features.csv"))
Here we can see percentile thresholds (dashed lines) for envelope, each modality separately
For each participant, we rank-normalize their vocal energy (env_rms) and arm movement (arm_acc_rms) into within-participant percentiles. Then we flag trials as violations — cases where the effort signal doesn’t match what the condition demands:
vocal trials: sound is nearly absent (<10th pct) or movement is surprisingly high (>80th pct)
gesture trials: movement is nearly absent (<10th pct) or sound is surprisingly high (>80th pct)
multimodal trials: either modality is nearly absent (<10th pct)
# create colum participant_id where you take first and last elemets of trial_idfeatures_df["participant_id"] = features_df["trial_id"].apply(lambda x: x.split("_")[0] +"_"+ x.split("_")[-1])features_df["wrist_rms_mean"] = ( features_df["rwrist_rms"] + features_df["lwrist_rms"]) /2