Personalized adaptive virtual reality experience driven by electroencephalography-based pain recognition.
The 12 matches
- [1] § Materials and methods › Data analysis ↔ EEG_XGBoost_Pain_Classification.ipynb, lines 521–541 · score 0.75 · max_depth, learning_rate, n_estimators, metrics, Hyperparameter, classification
- [2] § Materials and methods › Sample technique and feature extraction ↔ EEG_LightGBM_Pain_Classification.ipynb, lines 260–327 · score 0.63 · wavelet transform, Daubechies, coefficient, db4, wavedec, energy
- [3] § Materials and methods › Sample technique and feature extraction ↔ EEG_XGBoost_Pain_Classification.ipynb, lines 258–325 · score 0.63 · wavelet transform, Daubechies, coefficient, db4, wavedec, energy
- [4] § Materials and methods › Sample technique and feature extraction ↔ EEG_LightGBM_Pain_Classification.ipynb, lines 163–182 · score 0.62 · find bad eog, ICA component, eye, scores, channels, EEG
- [5] § Materials and methods › Sample technique and feature extraction ↔ EEG_XGBoost_Pain_Classification.ipynb, lines 161–180 · score 0.62 · find bad eog, ICA component, eye, scores, channels, EEG
- [6] § Materials and methods › Data analysis ↔ EEG_XGBoost_Pain_Classification.ipynb, lines 521–541 · score 0.61 · unique pain, XGBoost, softmax, metrics, objective, overfitting
- [7] § Materials and methods › Model validity and reliability ↔ EEG_LightGBM_Pain_Classification.ipynb, lines 508–522 · score 0.61 · StandardScaler, XGBoost, partition, stratified, split, classes
- [8] § Materials and methods › Model validity and reliability ↔ EEG_XGBoost_Pain_Classification.ipynb, lines 505–519 · score 0.61 · StandardScaler, XGBoost, partition, stratified, split, classes
- [9] § Materials and methods › Data analysis ↔ EEG_LightGBM_Pain_Classification.ipynb, lines 524–540 · score 0.57 · learning_rate, n_estimators, Hyperparameter, classification, model
- [10] § Materials and methods › Population and dataset ↔ EEG_LightGBM_Pain_Classification.ipynb, lines 45–60 · score 0.53 · raw EEG, BrainVision, vhdr, Pain
- [11] § Materials and methods › Population and dataset ↔ EEG_XGBoost_Pain_Classification.ipynb, lines 43–58 · score 0.53 · raw EEG, BrainVision, vhdr, Pain
- [12] § Materials and methods › Setting and system architecture ↔ EEG_LightGBM_Pain_Classification.ipynb, lines 1–32 · score 0.51 · PyWavelets, libraries, gradient, LightGBM, MNE, boosting
Paper
Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC
The paper is loaded when this pane is shown.
The authors' code
Jupyter notebook · 605 lines · 23 KB · no license · 6 matches
- # %% [markdown]
- # # EEG Pain-Level Classification with XGBoost
- #
- # A full pipeline for classifying subjective pain levels from EEG data using Discrete Wavelet Transform (DWT) feature extraction and an XGBoost classifier.
- #
- # **Pipeline overview:**
- # 1. [Environment Setup](#1-environment-setup)
- # 2. [EEG Data Loading & Inspection](#2-eeg-data-loading--inspection)
- # 3. [Preprocessing & Artifact Removal (ICA)](#3-preprocessing--artifact-removal-ica)
- # 4. [Epoching & Visualisation](#4-epoching--visualisation)
- # 5. [DWT Feature Extraction](#5-dwt-feature-extraction)
- # 6. [Merging Per-Participant CSV Files](#6-merging-per-participant-csv-files)
- # 7. [Dataset Preparation](#7-dataset-preparation)
- # 8. [XGBoost Model Training & Evaluation](#8-xgboost-model-training--evaluation)
- # 9. [Model Saving](#9-model-saving)
- # %% [markdown]
- # ## 1. Environment Setup
- # %%
- # Install required packages
- # MNE: EEG/MEG analysis library
- # autoreject: automatic rejection threshold estimation for EEG epochs
- # PyWavelets: Discrete Wavelet Transform implementation
- %pip install mne autoreject PyWavelets
- # %%
- # Mount Google Drive to access EEG data files and save outputs
- from google.colab import drive
- drive.mount('/content/drive')
- # %%
- import os
- import numpy as np
- import pandas as pd
- import matplotlib.pyplot as plt
- import seaborn as sns
- import mne
- # Suppress verbose MNE logs — set to 'warning' or 'info' for more detail
- mne.set_log_level('error')
- # %% [markdown]
- # ## 2. EEG Data Loading & Inspection
- #
- # Load a single BrainVision `.vhdr` file and inspect its annotations.
- # Each annotation encodes a pain stimulus level (e.g. `Comment/10` = pain level 10).
- # %%
- # ── Configuration ──────────────────────────────────────────────────────────
- # Update p_id to match the participant you want to process.
- p_id = 'vp51'
- data_dir = '/content/drive/My Drive/EEG_Data/'
- raw_file = os.path.join(data_dir, f'Exp_Mediation_Paradigm1_Perception_{p_id}.vhdr')
- # Load raw EEG recording; preload=True reads all data into memory up front
- raw = mne.io.read_raw_brainvision(raw_file, preload=True, verbose='error')
- print(raw.info)
- # %%
- # Inspect annotations to understand which pain levels are present in this file.
- # Run this before defining event_mapping — the available Comment/* codes vary
- # between participants.
- mne.events_from_annotations(raw)
- # %%
- events, event_dict = mne.events_from_annotations(raw)
- # Map annotation strings to integer event IDs.
- # Adjust this dictionary based on the output of the cell above.
- event_mapping = {
- 'Comment/10': 10001,
- 'Comment/20': 10002,
- 'Comment/30': 10003,
- 'Comment/40': 10004,
- 'Comment/50': 10006,
- 'Comment/60': 10007,
- }
- # Plot the event timeline to verify the mapping visually
- fig, ax = plt.subplots(figsize=(15, 5))
- mne.viz.plot_events(events, raw.info['sfreq'], event_id=event_mapping, axes=ax)
- plt.title(f'Event Timeline — Participant {p_id}')
- plt.show()
- # %% [markdown]
- # ## 3. Preprocessing & Artifact Removal (ICA)
- #
- # Steps performed here:
- # 1. Assign correct channel types (EOG, ECG, misc) so MNE can apply them correctly.
- # 2. Set the standard 10-05 electrode montage.
- # 3. Band-pass filter the raw signal (0.1 – 30 Hz for general analysis; 1 – 30 Hz for ICA).
- # 4. Create fixed-length epochs, automatically estimate an amplitude rejection threshold,
- # then fit ICA and exclude eye-movement (EOG) components.
- # %%
- # ── Channel type assignment ─────────────────────────────────────────────────
- # MNE needs to know which channels are EEG vs. EOG vs. ECG so that it can
- # apply artifact detection and montage correctly.
- channel_types = {
- 'LE': 'eog', # Left eye electrode
- 'RE': 'eog', # Right eye electrode
- 'ECG': 'ecg', # Cardiac channel
- 'Ne': 'misc', # Non-EEG / miscellaneous
- 'Ma': 'misc',
- 'Ext': 'misc',
- }
- raw.set_channel_types(channel_types)
- # Apply the standard 10-05 electrode position system
- montage = mne.channels.make_standard_montage('standard_1005')
- raw.set_montage(montage)
- # %%
- # ── Band-pass filtering (for downstream epoching & ERPs) ───────────────────
- # 0.1 Hz high-pass removes slow drifts; 30 Hz low-pass removes high-frequency
- # noise and line interference above the gamma band of interest.
- low_cut = 0.1
- hi_cut = 30.0
- raw_filt = raw.copy().filter(low_cut, hi_cut)
- # Visualise the power spectral density to confirm the filter worked
- raw_filt.plot_psd(fmax=40, show=False)
- plt.show()
- # %%
- # ── ICA preparation ─────────────────────────────────────────────────────────
- # ICA works best when low-frequency power is removed more aggressively (≥1 Hz).
- # We create a separate filtered copy solely for ICA fitting.
- ica_low_cut = 1.0
- raw_ica = raw.copy().filter(ica_low_cut, hi_cut)
- # Segment the ICA copy into 1-second fixed-length epochs.
- # These are used only for fitting ICA — not for downstream analysis.
- tstep = 1.0
- events_ica = mne.make_fixed_length_events(raw_ica, duration=tstep)
- epochs_ica = mne.Epochs(
- raw_ica, events_ica,
- tmin=0.0, tmax=tstep,
- baseline=None, preload=True
- )
- # %%
- from autoreject import get_rejection_threshold
- # Automatically compute a peak-to-peak amplitude rejection threshold.
- # Epochs exceeding this threshold are excluded from ICA fitting,
- # preventing large artefacts from corrupting the decomposition.
- reject = get_rejection_threshold(epochs_ica)
- print('Rejection threshold:', reject)
- # %%
- # ── Fit ICA ─────────────────────────────────────────────────────────────────
- random_state = 42 # Fix seed for reproducibility
- ica_n_components = 0.99 # Retain components explaining 99 % of variance
- ica = mne.preprocessing.ICA(n_components=ica_n_components, random_state=random_state)
- ica.fit(epochs_ica, reject=reject, tstep=tstep)
- # %%
- # ── Identify and exclude EOG artefact components ───────────────────────────
- # Uses frontal channels (Fp1, F8) as EOG proxies via z-score correlation.
- # Components with |z| > 1.96 (≈ 95 % CI) are flagged as eye-movement artefacts.
- ica_z_thresh = 1.96
- eog_indices, eog_scores = ica.find_bads_eog(
- raw_ica,
- ch_name=['Fp1', 'F8'],
- threshold=ica_z_thresh
- )
- ica.exclude = eog_indices
- print(f'Excluded ICA components (EOG): {eog_indices}')
- # Visualise the z-scores — flagged components shown in red
- ica.plot_scores(eog_scores)
- plt.show()
- # Inspect the spatial topographies of all ICA components
- ica.plot_components()
- plt.show()
- # %% [markdown]
- # ## 4. Epoching & Visualisation
- #
- # Create stimulus-locked epochs around each pain-level event, apply ICA artefact
- # correction, re-reference to mastoid electrodes, and visualise the resulting ERPs.
- # %%
- # ── Epoch the filtered (non-ICA) data around stimulus events ───────────────
- tmin = -0.200 # 200 ms pre-stimulus baseline
- tmax = 1.000 # 1 000 ms post-stimulus
- baseline = (None, 0) # Baseline-correct using the pre-stimulus window
- epochs = mne.Epochs(
- raw_filt,
- events, event_mapping,
- tmin, tmax,
- baseline=baseline,
- preload=True
- )
- print(epochs)
- # %%
- # Quick sanity check: visualise the grand-average ERP before artefact removal
- epochs.average().plot(spatial_colors=True, show=False)
- plt.title('Grand-average ERP — before ICA correction')
- plt.show()
- # Scalp topography at 100 ms intervals across the epoch window
- times = np.arange(0, tmax, 0.1)
- epochs.average().plot_topomap(times=times, average=0.050)
- plt.show()
- # %%
- # ── Apply ICA to the stimulus-locked epochs ─────────────────────────────────
- # Removes the previously identified EOG components from the data.
- epochs_postica = ica.apply(epochs.copy())
- # Visualise grand-average ERP after artefact correction
- epochs_postica.average().plot(spatial_colors=True, show=False)
- plt.title('Grand-average ERP — after ICA correction')
- plt.show()
- times = np.arange(0, tmax, 0.1)
- epochs_postica.average().plot_topomap(times=times, average=0.050)
- plt.show()
- # %%
- # ── Re-reference to linked mastoids ─────────────────────────────────────────
- # TP9 and TP10 correspond to the left and right mastoid electrodes.
- # Mastoid referencing is standard in ERP and pain research.
- epochs_mastoidref = epochs_postica.set_eeg_reference(ref_channels=['TP9', 'TP10'])
- times = np.arange(0, tmax, 0.1)
- epochs_mastoidref.average().plot_topomap(times=times, average=0.050)
- plt.title('Grand-average topography — mastoid reference')
- plt.show()
- # %%
- # ── Per-condition ERP plots ──────────────────────────────────────────────────
- # Create one Evoked object per pain-level condition for comparison
- conditions = [
- 'Comment/10', 'Comment/20', 'Comment/30',
- 'Comment/40', 'Comment/50', 'Comment/60',
- ]
- evokeds = {c: epochs_mastoidref[c].average() for c in conditions}
- # Assign condition name as comment so it appears in plot titles
- for condition, evoked in evokeds.items():
- evoked.comment = condition
- # Joint plot shows butterfly + topomaps at key latencies
- key_times = [0.150, 0.250, 0.400, 0.600, 0.800]
- for condition, evoked in evokeds.items():
- evoked.plot_joint(times=key_times, title=condition)
- plt.show()
- # %% [markdown]
- # ## 5. DWT Feature Extraction
- #
- # For each epoch and each EEG channel, apply a 5-level Daubechies-4 Discrete
- # Wavelet Transform and extract 8 statistical features from the resulting
- # approximation (cA) and detail (cD) coefficients:
- #
- # | Feature | Description |
- # |---|---|
- # | `cD_Energy` | Mean energy across detail sub-bands |
- # | `cA_Energy` | Energy of the approximation coefficients |
- # | `D_Entropy` | Mean wavelet entropy across detail sub-bands |
- # | `A_Entropy` | Wavelet entropy of the approximation coefficients |
- # | `D_mean` | Mean amplitude across detail sub-bands |
- # | `A_mean` | Mean amplitude of the approximation coefficients |
- # | `D_std` | Mean std deviation across detail sub-bands |
- # | `A_std` | Std deviation of the approximation coefficients |
- #
- # This yields **8 features × N channels** per epoch, saved to a per-participant CSV.
- # %%
- from pywt import wavedec
- # EEG channels used for feature extraction.
- # These 8 channels span frontal, central, parietal, and occipital regions.
- CHANNELS = ['Fz', 'C3', 'Cz', 'C4', 'Pz', 'PO7', 'Oz', 'PO8']
- def wavelet_avg_features(data, type_wav='db4'):
- """
- Compute 8 DWT-based features from a single EEG channel signal.
- Parameters
- ----------
- data : array-like, shape (n_samples,)
- Single-channel EEG time series.
- type_wav : str
- Wavelet family to use. Default is 'db4' (Daubechies-4).
- Returns
- -------
- list of float
- [cD_Energy, cA_Energy, D_Entropy, A_Entropy,
- D_mean, A_mean, D_std, A_std]
- """
- # Decompose signal into 5 detail sub-bands + 1 approximation sub-band
- coeffs = wavedec(data, type_wav, level=5)
- # coeffs[0] → approximation (cA5)
- # coeffs[1..5] → detail sub-bands (cD5 … cD1, fine → coarse)
- # Energy: sum of squared coefficients (Parseval's theorem analog)
- cD_Energy = np.mean([np.sum(np.square(coeffs[i])) for i in range(1, 6)])
- cA_Energy = np.sum(np.square(coeffs[0]))
- # Wavelet entropy: captures signal complexity / information content
- D_Entropy = np.mean([
- np.sum(np.square(coeffs[i]) * np.log(np.square(coeffs[i]) + 1e-10))
- for i in range(1, 6)
- ])
- A_Entropy = np.sum(np.square(coeffs[0]) * np.log(np.square(coeffs[0]) + 1e-10))
- # Statistical moments
- D_mean = np.mean([np.mean(coeffs[i]) for i in range(1, 6)])
- A_mean = np.mean(coeffs[0])
- D_std = np.mean([np.std(coeffs[i]) for i in range(1, 6)])
- A_std = np.std(coeffs[0])
- return [cD_Energy, cA_Energy, D_Entropy, A_Entropy, D_mean, A_mean, D_std, A_std]
- # %%
- # ── Step 1: Extract pain-level labels from annotations ──────────────────────
- # Each 'Comment/XX' annotation encodes a pain stimulus intensity (10–60).
- # We divide by 10 so labels become integers 1–6.
- print('Extracting labels from annotations...')
- labels = []
- for ann in raw.annotations:
- if 'Comment/' in ann['description']:
- try:
- raw_level = ann['description'].split('/')[-1]
- # Remove any non-digit characters (e.g. 'ß') before converting
- pain_level = int(raw_level.replace('ß', '')) // 10
- labels.append(pain_level)
- except ValueError:
- print(f'Warning: skipping invalid annotation — {ann["description"]}')
- print(f'Extracted {len(labels)} labels.')
- # %%
- # ── Step 2: Extract wavelet features for every epoch ───────────────────────
- features = []
- for epoch in epochs.iter_evoked():
- epoch_features = []
- for ch_idx in range(len(CHANNELS)):
- # wavelet_avg_features returns 8 values per channel
- ch_features = wavelet_avg_features(epoch.data[ch_idx, :])
- epoch_features.extend(ch_features)
- features.append(epoch_features)
- # ── Step 3: Align features and labels (lengths may differ slightly) ─────────
- min_length = min(len(features), len(labels))
- features = features[:min_length]
- labels = labels[:min_length]
- print(f'Aligned features and labels: {min_length} rows.')
- # %%
- # ── Step 4: Save features + labels to a per-participant CSV ─────────────────
- output_dir = '/content/drive/My Drive/discrete_wavelet/8channels/'
- # Build column names: one set of 8 feature names per channel
- columns = []
- feature_names = ['cD_Energy', 'cA_Energy', 'D_Entropy', 'A_Entropy',
- 'D_mean', 'A_mean', 'D_std', 'A_std']
- for ch in CHANNELS:
- columns.extend([f'{ch}_{feat}' for feat in feature_names])
- df_participant = pd.DataFrame(features, columns=columns)
- df_participant['Label'] = labels
- csv_path = os.path.join(output_dir, f'wavelet_{p_id}.csv')
- df_participant.to_csv(csv_path, index=False)
- print(f'Saved {df_participant.shape[0]} epochs × {df_participant.shape[1]} columns → {csv_path}')
- # %% [markdown]
- # ## 6. Merging Per-Participant CSV Files
- #
- # After running the extraction above for every participant, merge all individual
- # CSVs into a single dataset file ready for model training.
- # %%
- # ── Merge all per-participant CSVs ──────────────────────────────────────────
- folder_path = '/content/drive/My Drive/discrete_wavelet/8channels'
- output_file = '/content/drive/My Drive/discrete_wavelet/merged/merged_data_8channels.csv'
- csv_files = [f for f in os.listdir(folder_path) if f.endswith('.csv')]
- print(f'Found {len(csv_files)} participant file(s): {csv_files}')
- df_list = [pd.read_csv(os.path.join(folder_path, f)) for f in csv_files]
- merged_df = pd.concat(df_list, ignore_index=True)
- merged_df.to_csv(output_file, index=False)
- print(f'Merged dataset saved → {output_file}')
- print(f'Shape: {merged_df.shape}')
- # %% [markdown]
- # ## 7. Dataset Preparation
- #
- # Load the merged dataset, verify integrity, and apply optional moving-average
- # smoothing within each pain-level condition before model training.
- # %%
- # ── Load merged dataset ─────────────────────────────────────────────────────
- csv_path = '/content/drive/My Drive/discrete_wavelet/merged/merged_data_8channels.csv'
- df = pd.read_csv(csv_path)
- print(df.head())
- print(f'\nDataset shape: {df.shape}')
- print(f'Label distribution:\n{df["Label"].value_counts().sort_index()}')
- # Check for missing values — none expected, but good practice
- missing_X = df.drop(columns=['Label']).isna().sum().sum()
- missing_Y = df['Label'].isna().sum()
- print(f'\nMissing values — Features: {missing_X} | Labels: {missing_Y}')
- # %%
- # ── Feature correlation heatmap ─────────────────────────────────────────────
- # Helps identify highly correlated features that may be redundant.
- # Large figures are needed due to the number of feature columns.
- plt.figure(figsize=(50, 50))
- cor_matrix = df.corr()
- sns.heatmap(cor_matrix, annot=True, cmap='coolwarm', fmt='.2f')
- plt.title('Feature Correlation Heatmap')
- plt.tight_layout()
- plt.show()
- # %%
- # ── Optional: moving-average smoothing per pain-level class ─────────────────
- # Smoothing reduces sample-to-sample noise within each condition, which can
- # improve the signal-to-noise ratio seen by the classifier.
- def moving_average(x, w=10):
- """Apply a simple uniform moving average with window size w."""
- return np.convolve(x, np.ones(w), 'valid') / w
- df_true = df.copy()
- df_true['predefinedlabel'] = df_true['Label']
- feature_columns = CHANNELS # The 8 channels defined in Section 5
- feature_suffixes = ['cD_Energy', 'cA_Energy', 'D_Entropy', 'A_Entropy',
- 'D_mean', 'A_mean', 'D_std', 'A_std']
- df_ma_rows = []
- for k in df_true['predefinedlabel'].unique():
- df_k = df_true[df_true['predefinedlabel'] == k]
- features_smoothed = {}
- for col in feature_columns:
- for suffix in feature_suffixes:
- feat_col = f'{col}_{suffix}'
- features_smoothed[feat_col] = moving_average(df_k[feat_col].values)
- n = len(next(iter(features_smoothed.values())))
- label_arr = np.full(n, k)
- timepoint_arr = np.arange(n)
- row_df = pd.DataFrame(features_smoothed)
- row_df['timepoint'] = timepoint_arr
- row_df['Label'] = label_arr
- df_ma_rows.append(row_df)
- df_ma = pd.concat(df_ma_rows, ignore_index=True)
- print(f'Smoothed dataset shape: {df_ma.shape}')
- print(df_ma.head())
- # %% [markdown]
- # ## 8. XGBoost Model Training & Evaluation
- #
- # Train an XGBoost multi-class classifier on the smoothed feature dataset.
- # Two complementary evaluations are used:
- # - **Hold-out test set** (80/20 split): gives a single accuracy estimate.
- # - **Stratified 5-fold cross-validation**: gives a more robust, variance-aware estimate.
- # %%
- import xgboost as xgb
- from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
- from sklearn.metrics import (accuracy_score, f1_score,
- classification_report, confusion_matrix)
- from sklearn.preprocessing import StandardScaler
- from xgboost import plot_importance
- # %%
- # ── Prepare features and labels ─────────────────────────────────────────────
- X = df_ma.drop(columns=['Label'])
- Y = df_ma['Label']
- # Ensure all feature columns are numeric
- X = X.select_dtypes(include=['number']).astype('float32')
- print(f'Feature matrix: {X.shape}')
- print(f'Label distribution:\n{Y.value_counts().sort_index()}')
- # %%
- # ── Train / test split ──────────────────────────────────────────────────────
- # stratify=Y ensures each pain-level class is proportionally represented
- # in both the training and test partitions.
- X_train, X_test, y_train, y_test = train_test_split(
- X, Y, test_size=0.2, random_state=42, stratify=Y
- )
- # Feature scaling — XGBoost is tree-based and does not strictly require
- # scaling, but it can help convergence when combined with regularisation.
- scaler = StandardScaler()
- X_train = scaler.fit_transform(X_train)
- X_test = scaler.transform(X_test)
- print(f'Train size: {X_train.shape[0]} | Test size: {X_test.shape[0]}')
- # %%
- # ── Define and train the XGBoost classifier ─────────────────────────────────
- # Key hyperparameters:
- # objective : 'multi:softmax' for discrete class outputs
- # num_class : number of unique pain-level labels (set to max label + 1)
- # learning_rate : step size shrinkage to prevent overfitting
- # max_depth : maximum tree depth (controls model complexity)
- # n_estimators : number of boosting rounds
- model = xgb.XGBClassifier(
- objective='multi:softmax',
- num_class=int(Y.max()) + 1, # Automatically inferred from data
- learning_rate=0.1,
- max_depth=5,
- n_estimators=200,
- random_state=42,
- use_label_encoder=False,
- eval_metric='mlogloss',
- )
- model.fit(X_train, y_train)
- print('Training complete.')
- # %%
- # ── Evaluate on the hold-out test set ───────────────────────────────────────
- y_pred = model.predict(X_test)
- accuracy_test = accuracy_score(y_test, y_pred)
- f1_test = f1_score(y_test, y_pred, average='weighted')
- print(f'Test Accuracy : {accuracy_test * 100:.2f}%')
- print(f'Test F1 Score : {f1_test:.4f}')
- print()
- print('Classification Report:')
- print(classification_report(y_test, y_pred))
- # %%
- # ── Confusion matrix ────────────────────────────────────────────────────────
- conf_matrix = confusion_matrix(y_test, y_pred)
- class_labels = sorted(Y.unique())
- plt.figure(figsize=(8, 6))
- sns.heatmap(
- conf_matrix, annot=True, fmt='d', cmap='Blues',
- xticklabels=class_labels, yticklabels=class_labels
- )
- plt.xlabel('Predicted Label')
- plt.ylabel('True Label')
- plt.title('Confusion Matrix — Hold-out Test Set')
- plt.tight_layout()
- plt.show()
- # %%
- # ── Stratified 5-fold cross-validation ─────────────────────────────────────
- # Cross-validation gives a more reliable accuracy estimate by training and
- # evaluating on all folds of the data, reducing dependence on a single split.
- skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
- cv_results = cross_val_score(model, X, Y, cv=skf, scoring='accuracy')
- print(f'Cross-validation Accuracy: {cv_results.mean() * 100:.2f}% '
- f'(± {cv_results.std() * 100:.2f}%)')
- print(f'Per-fold scores: {[f"{s*100:.1f}%" for s in cv_results]}')
- # %%
- # ── Feature importance ──────────────────────────────────────────────────────
- # 'weight' counts the number of times a feature is used to split a node.
- # High-importance features are the most discriminative for pain classification.
- plt.figure(figsize=(12, 6))
- plot_importance(model, importance_type='weight', max_num_features=20)
- plt.title('Top-20 Feature Importances (split count)')
- plt.tight_layout()
- plt.show()
- # %% [markdown]
- # ## 9. Model Saving
- #
- # Export the trained XGBoost model to Google Drive so it can be loaded later
- # for inference or fine-tuning without retraining.
- # %%
- # ── Save model weights ──────────────────────────────────────────────────────
- # The .json format is portable across XGBoost versions and languages.
- # Use .txt if you need compatibility with older XGBoost (< 1.6) versions.
- model_path = '/content/drive/My Drive/xgboost_model_8channels.json'
- model.get_booster().save_model(model_path)
- print(f'Model saved → {model_path}')
EEG_XGBoost_Pain_Classification.ipynb at commit 7f55aff, no license · at the source
Overview
- Department of Electrical and Computing Engineering, Rochester Institute of Technology, Dubai, United Arab Emirates
- College of Medicine, Mohammed Bin Rashid University of Medicine and Health Sciences, Dubai, United Arab Emirates
Abstract
Background: Non-pharmacological pain management represents an urgent clinical need. Emerging technologies such as virtual reality (VR) and electroencephalography (EEG)-based artificial intelligence (AI) offer promising avenues for objective pain assessment and adaptive therapeutic intervention.
Purpose: This study aims to develop and validate a real-time, closed-loop EEG-driven VR therapy system that classifies pain levels from brain signals and delivers personalized, avatar-guided therapeutic responses.
Methods: An open-source EEG dataset (51 participants; perception condition; laser-induced pain stimuli rated 0–100) was preprocessed using bandpass filtering, Independent Component Analysis (ICA), and AutoReject. Wavelet-based features (Daubechies-4, 5 levels) were extracted from 1-second epochs and used to train two gradient-boosting classifiers: XGBoost and LightGBM. Predicted pain levels were transmitted via HTTP POST requests to Unreal Engine 5.3.2, where a MetaHuman avatar delivered adaptive therapeutic responses.
Results: LightGBM achieved 97.89% classification accuracy (cross-validation: 95.78% ± 0.82%) and XGBoost achieved 97.25% (cross-validation: 96.09% ± 0.70%) across 11 pain classes (0–10), outperforming all comparable studies in the literature. Real-time avatar responses were demonstrated across three pain categories: Slight (1–3), Moderate (4–6), and Severe (7–10).
Conclusion: The study successfully demonstrates the technical feasibility of a closed-loop EEG-VR pain management system using lightweight machine learning models. The system achieves state-of-the-art pain classification accuracy with fine-grained 11-class granularity.
Implications: This system offers a scalable, drug-free alternative for pain management applicable in clinical and rehabilitation settings. The modular design facilitates future extensions, including emotional state tracking, haptic feedback, and reinforcement learning-based personalization.
Reproduced under the paper's license (CC BY), from the paper cited above.
Repositories
Its files are read in the Code ↔ Paper reader above, with 12 matches between paragraphs and lines of code.
Zenodo 20846022
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
- 27 September 2026: the link answers (HTTP 200)
3 files
- EEG_LightGBM_Pain_Classi
fication.ipynb , Jupyter, 617 lines - EEG_XGBoost_Pain_Classif
ication.ipynb , Jupyter, 605 lines - README.md, Text, 106 lines
sabrinamkb/eeg-vr-pain-adaptation
7f55aff51f03e1ff56906ba3624f2ad6f868c1ae, 25 June 2026Availability: 1 check, the latest on 27 September 2026: the link answers
- 27 September 2026: the link answers
3 files
- EEG_LightGBM_Pain_Classi
fication.ipynb , Jupyter, 617 lines, 6 matches - EEG_XGBoost_Pain_Classif
ication.ipynb , Jupyter, 605 lines, 6 matches - README.md, Text, 106 lines
The paper's code and data availability statement is in the Data section.
Tracing map
Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.
What the map holds:
- 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
- 4 scripts, each with its path and the digest of its content;
- 12 matches between paragraphs of the paper and lines of the code (method lexical-v1);
- neither the text of the paper nor the code itself.
Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.
Data
No dataset and no data link were found in the paper.
Data Availability
The author-generated code and software pipelines underlying the findings of this study have been made publicly available without restriction. The permanent, reproducible archive can be accessed via Zenodo under DOI: 10.5281/
Reproduced under the paper's license (CC BY), from the paper cited above.
Versions
The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.
Version 2, 28 September 2026
- Funding: added Mohammed Bin Rashid University of Medicine and Health Sciences
Version 1, 27 September 2026: the first record
Recorded: type, language, journal, volume, issue, pages, dates, 7 authors, 13 MeSH terms, 25 references.
Cite
This paper
Al Bukhari, S., Zahran, A., Anvaj, A., Hamdan, M., Atif, A., Mounsef, J., & Hadjiat, Y. (2026). Personalized adaptive virtual reality experience driven by electroencephalography-b
BibTeX
@article{albukhari2026pe
author = {Al Bukhari, Sabrina and Zahran, Ahmad and Anvaj, Anzif and Hamdan, Muhammed and Atif, Ahmed and Mounsef, Jinane and Hadjiat, Yacine},
title = {{Personalized adaptive virtual reality experience driven by electroencephalography-b
journal = {PloS one},
year = {2026},
month = aug,
volume = {21},
number = {8},
pages = {e0354510},
publisher = {PLOS},
issn = {1932-6203},
doi = {10.1371/
url = {https://
pmid = {42636251},
pmcid = {PMC13502692}
}
RIS
TY - JOUR
AU - Al Bukhari, Sabrina
AU - Zahran, Ahmad
AU - Anvaj, Anzif
AU - Hamdan, Muhammed
AU - Atif, Ahmed
AU - Mounsef, Jinane
AU - Hadjiat, Yacine
TI - Personalized adaptive virtual reality experience driven by electroencephalography-b
T2 - PloS one
J2 - PLoS One
PY - 2026
DA - 2026/
VL - 21
IS - 8
SP - e0354510
SN - 1932-6203
PB - PLOS
DO - 10.1371/
UR - https://
LA - en
ER -
CSL-JSON
{
"id": "10.1371/
"type": "article-journal",
"title": "Personalized adaptive virtual reality experience driven by electroencephalography-b
"container-title": "PloS one",
"author": [
{
"family": "Al Bukhari",
"given": "Sabrina"
},
{
"family": "Zahran",
"given": "Ahmad"
},
{
"family": "Anvaj",
"given": "Anzif"
},
{
"family": "Hamdan",
"given": "Muhammed"
},
{
"family": "Atif",
"given": "Ahmed"
},
{
"family": "Mounsef",
"given": "Jinane"
},
{
"family": "Hadjiat",
"given": "Yacine"
}
],
"container-title-short":
"volume": "21",
"issue": "8",
"page": "e0354510",
"DOI": "10.1371/
"PMID": "42636251",
"PMCID": "PMC13502692",
"ISSN": "1932-6203",
"publisher": "PLOS",
"URL": "https://
"language": "en",
"issued": {
"date-parts": [
[
2026,
8,
24
]
]
}
}
The tracing map gets a citation of its own once an author has validated it and it has a DOI.
Similar papers
The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.
- [1] doi:10.1371/journal.pcbi.1014302 [code]
- Trial-level sequence modeling reveals hidden dynamics of dual-task interference.Journal: PLoS computational biologyIn common: autoreject, MNE-Python, seaborn, 4 other tools, EEG, 2 references
- [2] doi:10.1162/imag.a.1321 [code]
- Phase similarity between similar objects indicates representational merging across retrieval training but not sleep.Journal: Imaging neuroscience (Cambridge, Mass.)In common: autoreject, MNE-Python, seaborn, 4 other tools, EEG, 2 references
- [3] doi:10.1162/imag.a.1269 [code]
- From early to contemporary normative modeling: Mapping individual differences in neurophysiological signals.Journal: Imaging neuroscience (Cambridge, Mass.)In common: autoreject, MNE-Python, seaborn, 4 other tools, EEG, 2 references
- [4] doi:10.3389/fpsyg.2026.1774068 [code]
- Analysis of cognitive mechanisms in phoneme perception and pronunciation errors among Korean language learners.Journal: Frontiers in psychologyIn common: LightGBM, XGBoost, MNE-Python, 5 other tools, EEG
- [5] doi:10.7554/elife.108023 [code]
- Challenges in replay detection by TDLM in post-encoding resting state.Journal: eLifeIn common: autoreject, MNE-Python, seaborn, 4 other tools, 2 references
- [6] doi:10.1002/hbm.70368 [code]
- The Mismatch Negativity Compared: EEG, SQUID‐MEG, and Novel 4 Helium‐OPMsJournal: n/aIn common: autoreject, MNE-Python, seaborn, 3 other tools, EEG, 2 references
- [7] doi:10.1186/s11689-026-09706-z [code]
- Neural oscillatory dynamics reveal altered top-down and integrative mechanisms during face processing in autistic children and unaffected siblings of autistic children.Journal: Journal of neurodevelopmental disordersIn common: autoreject, MNE-Python, seaborn, 3 other tools, EEG, 2 references
- [8] doi:10.1038/s41597-026-07350-9 [code]
- An open multi-center MEG-EEG dataset for studying conscious visual perception.Journal: Scientific dataIn common: autoreject, MNE-Python, seaborn, 4 other tools, EEG, 1 reference
- [9] doi:10.3389/fnhum.2026.1869918 [code]
- Single-subject auditory ERP-BCI performance enhancement in ALS via an AI coding assistant prompt.Journal: Frontiers in human neuroscienceIn common: LightGBM, XGBoost, MNE-Python, 4 other tools, EEG
- [10] doi:10.1162/imag.a.1040 [code]
- Novel 4 He-OPMs support waveform-specific beta burst analysis comparable to SQUID-MEGJournal: n/aIn common: autoreject, MNE-Python, scikit-learn, 3 other tools, 2 references
Contribute
The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.
Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.
Claim this paper
Correct its record
Say what each link of this record is, remove the ones that are not the paper's, add the ones that are missing. The correction becomes a new version of the record, in its Versions section.
Validate its tracing map
You validate the map as this page shows it: 2 repositories of the authors' code, each at its verified commit and with its license, 4 scripts, and 12 matches between paragraphs and code (see the Code and Map sections). It then receives a DOI on Zenodo, with you (your ORCID iD) and OSCR as its creators; the code itself is not deposited.
The map's fingerprint: sha256:67f1af6ffb616a88…
Add the badge to its README
The badge links the code to this page. Copy one of these into the README of the paper's code: only you decide where it goes, and nothing is changed for you.
Markdown
[, paste the snippet at the top, then “Commit changes…” and, to review it first, “Create a new branch and start a pull request”. You open the pull request; OSCR asks for no permission.
Request its removal
To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).
Discussion, reproductions, activity
Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.
Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.
Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.
