meroni-api / train.py
arrondavide's picture
Meroni API - EEG to fMRI translation pipeline
ba9143f
Raw History Blame Contribute Delete
15.5 kB
"""
Training script for EEG-to-fMRI translation model.
Uses publicly available paired EEG-fMRI datasets to train the MLP
to map EEG features → fMRI BOLD signals across 96 Harvard-Oxford regions.
Public datasets used:
1. Simultaneous EEG-fMRI dataset from OpenNeuro (ds003505)
- Resting-state EEG-fMRI from multiple subjects
- EEG: 64 channels, 5000 Hz
- fMRI: 3T, TR=2s, Harvard-Oxford parcellation
Usage:
python train.py --data_dir ./data --epochs 100 --output model_weights.pt
If no real data is available, generates structured synthetic paired data
that preserves known EEG-fMRI correlations (alpha-occipital, beta-motor, etc.)
"""
import argparse
import os
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
from model import EEGtoFMRIModel
from eeg_processor import (
extract_all_features,
get_feature_dimension,
BANDS,
BAND_NAMES,
)
class EEGfMRIDataset(Dataset):
"""Dataset of paired EEG features and fMRI BOLD values."""
def __init__(self, features: np.ndarray, bold: np.ndarray):
self.features = torch.FloatTensor(features)
self.bold = torch.FloatTensor(bold)
def __len__(self):
return len(self.features)
def __getitem__(self, idx):
return self.features[idx], self.bold[idx]
def generate_synthetic_paired_data(
n_samples: int = 5000,
n_channels: int = 8,
sfreq: float = 256.0,
n_regions: int = 96,
epoch_duration: float = 2.0,
) -> tuple[np.ndarray, np.ndarray]:
"""
Generate synthetic paired EEG-fMRI data that preserves known
neurophysiological relationships between EEG bands and brain regions.
Known correlations used:
- Alpha power (8-13 Hz) negatively correlates with visual cortex BOLD
- Beta power (13-30 Hz) correlates with motor cortex BOLD
- Theta power (4-8 Hz) correlates with hippocampal/default mode BOLD
- Gamma power (30-45 Hz) correlates with attention/executive BOLD
- Delta power (1-4 Hz) correlates with deep structures during rest
- Cross-hemisphere coherence correlates with bilateral activation
This is NOT a substitute for real data training — it's a structured
initialization that produces physiologically plausible outputs while
we collect/process real paired datasets.
"""
print(f"Generating {n_samples} synthetic paired EEG-fMRI samples...")
n_samples_per_epoch = int(sfreq * epoch_duration)
all_features = []
all_bold = []
# Define which regions respond to which EEG features
# Based on known EEG-fMRI correlations from literature
# Region indices follow Harvard-Oxford atlas ordering
# Visual regions (occipital)
visual_regions = [21, 22, 23, 31, 35, 38, 39, 46, 47, 69, 70, 71, 79, 83, 86, 87, 94, 95]
# Motor regions
motor_regions = [6, 16, 17, 25, 54, 64, 65, 73]
# Language regions (temporal)
language_regions = [4, 5, 8, 9, 10, 11, 18, 19, 20, 44, 45, 52, 53, 56, 57, 58, 59, 66, 67, 68, 92, 93]
# Default mode network
dmn_regions = [0, 24, 27, 28, 29, 30, 33, 34, 48, 72, 75, 76, 77, 78, 81, 82]
# Executive/attention
exec_regions = [1, 2, 3, 26, 32, 40, 41, 42, 49, 50, 51, 74, 80, 88, 89, 90]
for sample_idx in range(n_samples):
# Generate different "brain states" for diversity
state = np.random.choice(["rest", "visual", "motor", "cognitive", "mixed"])
# Generate EEG with state-specific spectral properties
eeg = np.zeros((n_channels, n_samples_per_epoch))
t = np.arange(n_samples_per_epoch) / sfreq
# Base noise
eeg += np.random.randn(n_channels, n_samples_per_epoch) * 0.5
# State-dependent oscillations
alpha_power = 0.3
beta_power = 0.3
theta_power = 0.3
gamma_power = 0.2
delta_power = 0.2
if state == "rest":
alpha_power = 0.8 + np.random.rand() * 0.4
theta_power = 0.5 + np.random.rand() * 0.3
delta_power = 0.4 + np.random.rand() * 0.2
elif state == "visual":
alpha_power = 0.1 + np.random.rand() * 0.2 # Alpha suppression
gamma_power = 0.6 + np.random.rand() * 0.4
elif state == "motor":
beta_power = 0.7 + np.random.rand() * 0.5
alpha_power = 0.2 + np.random.rand() * 0.2
elif state == "cognitive":
theta_power = 0.7 + np.random.rand() * 0.4
gamma_power = 0.5 + np.random.rand() * 0.3
alpha_power = 0.2 + np.random.rand() * 0.2
else: # mixed
alpha_power = np.random.rand() * 0.8
beta_power = np.random.rand() * 0.8
theta_power = np.random.rand() * 0.8
gamma_power = np.random.rand() * 0.5
# Add oscillations to EEG channels with spatial distribution
for ch in range(n_channels):
# Each channel gets slightly different weights
ch_weight = 0.7 + 0.6 * np.random.rand()
# Posterior channels (6,7) get more alpha
alpha_w = alpha_power * (1.5 if ch >= 6 else 0.7) * ch_weight
# Central channels (2,3,4,5) get more beta
beta_w = beta_power * (1.5 if 2 <= ch <= 5 else 0.7) * ch_weight
# Frontal channels (0,1) get more theta
theta_w = theta_power * (1.5 if ch <= 1 else 0.7) * ch_weight
freq_alpha = 10 + np.random.randn() * 0.5
freq_beta = 20 + np.random.randn() * 2
freq_theta = 6 + np.random.randn() * 0.5
freq_gamma = 40 + np.random.randn() * 3
freq_delta = 2 + np.random.randn() * 0.3
eeg[ch] += alpha_w * np.sin(2 * np.pi * freq_alpha * t + np.random.rand() * 2 * np.pi)
eeg[ch] += beta_w * np.sin(2 * np.pi * freq_beta * t + np.random.rand() * 2 * np.pi)
eeg[ch] += theta_w * np.sin(2 * np.pi * freq_theta * t + np.random.rand() * 2 * np.pi)
eeg[ch] += gamma_power * ch_weight * np.sin(2 * np.pi * freq_gamma * t + np.random.rand() * 2 * np.pi)
eeg[ch] += delta_power * ch_weight * np.sin(2 * np.pi * freq_delta * t + np.random.rand() * 2 * np.pi)
# Extract real features from this synthetic EEG
features = extract_all_features(eeg, sfreq, target_channels=n_channels)
# Generate corresponding BOLD values based on known correlations
bold = np.ones(n_regions) * 0.3 + np.random.randn(n_regions) * 0.05
# Visual cortex: negatively correlated with alpha, positively with gamma
for r in visual_regions:
if r < n_regions:
bold[r] = 0.3 + (1.0 - alpha_power) * 0.4 + gamma_power * 0.2 + np.random.randn() * 0.05
# Motor cortex: correlated with beta
for r in motor_regions:
if r < n_regions:
bold[r] = 0.3 + beta_power * 0.5 + np.random.randn() * 0.05
# Language regions: mix of theta and gamma
for r in language_regions:
if r < n_regions:
bold[r] = 0.3 + theta_power * 0.2 + gamma_power * 0.3 + np.random.randn() * 0.05
# Default mode: correlated with alpha and theta, anti-correlated with task
for r in dmn_regions:
if r < n_regions:
bold[r] = 0.3 + alpha_power * 0.3 + theta_power * 0.2 - gamma_power * 0.15 + np.random.randn() * 0.05
# Executive: correlated with gamma and theta, anti-correlated with alpha
for r in exec_regions:
if r < n_regions:
bold[r] = 0.3 + gamma_power * 0.35 + theta_power * 0.15 - alpha_power * 0.1 + np.random.randn() * 0.05
# Clip to [0, 1]
bold = np.clip(bold, 0.05, 0.95)
all_features.append(features)
all_bold.append(bold)
features_array = np.array(all_features)
bold_array = np.array(all_bold)
print(f"Generated features shape: {features_array.shape}")
print(f"Generated BOLD shape: {bold_array.shape}")
return features_array, bold_array
def load_real_data(data_dir: str):
"""
Load real paired EEG-fMRI data from preprocessed files.
Expected format:
data_dir/features.npy — (n_samples, 236) EEG feature vectors
data_dir/bold.npy — (n_samples, 96) fMRI BOLD values
To create these files from raw data:
1. Download dataset from OpenNeuro (e.g., ds003505)
2. Run preprocessing with MNE (EEG) and nilearn (fMRI)
3. Extract EEG features using eeg_processor.extract_all_features()
4. Extract fMRI ROI values using Harvard-Oxford atlas
5. Save as .npy files
"""
features_path = os.path.join(data_dir, "features.npy")
bold_path = os.path.join(data_dir, "bold.npy")
if os.path.exists(features_path) and os.path.exists(bold_path):
features = np.load(features_path)
bold = np.load(bold_path)
print(f"Loaded real data: {features.shape[0]} samples")
print(f" Features: {features.shape}")
print(f" BOLD: {bold.shape}")
return features, bold
return None
def train(
features: np.ndarray,
bold: np.ndarray,
epochs: int = 100,
batch_size: int = 64,
lr: float = 1e-3,
val_split: float = 0.15,
output_path: str = "model_weights.pt",
):
"""Train the EEG-to-fMRI translation model."""
# Normalize features
feature_mean = features.mean(axis=0)
feature_std = features.std(axis=0)
feature_std[feature_std < 1e-8] = 1.0
features_norm = (features - feature_mean) / feature_std
# Train/val split
n = len(features_norm)
n_val = int(n * val_split)
indices = np.random.permutation(n)
train_idx = indices[n_val:]
val_idx = indices[:n_val]
train_dataset = EEGfMRIDataset(features_norm[train_idx], bold[train_idx])
val_dataset = EEGfMRIDataset(features_norm[val_idx], bold[val_idx])
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=batch_size)
print(f"Training: {len(train_dataset)} samples")
print(f"Validation: {len(val_dataset)} samples")
print(f"Feature dim: {features.shape[1]}")
print(f"Output dim: {bold.shape[1]}")
# Model
model = EEGtoFMRIModel(input_dim=features.shape[1], output_dim=bold.shape[1])
device = torch.device("cpu")
model.to(device)
# Loss and optimizer
criterion = nn.MSELoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
best_val_loss = float("inf")
patience = 15
patience_counter = 0
for epoch in range(epochs):
# Training
model.train()
train_loss = 0
for batch_features, batch_bold in train_loader:
batch_features = batch_features.to(device)
batch_bold = batch_bold.to(device)
pred = model(batch_features)
loss = criterion(pred, batch_bold)
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
train_loss += loss.item()
train_loss /= len(train_loader)
# Validation
model.eval()
val_loss = 0
with torch.no_grad():
for batch_features, batch_bold in val_loader:
batch_features = batch_features.to(device)
batch_bold = batch_bold.to(device)
pred = model(batch_features)
loss = criterion(pred, batch_bold)
val_loss += loss.item()
val_loss /= len(val_loader)
scheduler.step()
if (epoch + 1) % 10 == 0 or epoch == 0:
print(
f"Epoch {epoch+1:3d}/{epochs} | "
f"Train Loss: {train_loss:.6f} | "
f"Val Loss: {val_loss:.6f} | "
f"LR: {scheduler.get_last_lr()[0]:.2e}"
)
# Early stopping
if val_loss < best_val_loss:
best_val_loss = val_loss
patience_counter = 0
# Save best model
torch.save(
{
"model_state_dict": model.state_dict(),
"feature_mean": feature_mean,
"feature_std": feature_std,
"val_loss": best_val_loss,
"epoch": epoch,
"input_dim": features.shape[1],
"output_dim": bold.shape[1],
},
output_path,
)
else:
patience_counter += 1
if patience_counter >= patience:
print(f"Early stopping at epoch {epoch+1}")
break
print(f"\nBest validation loss: {best_val_loss:.6f}")
print(f"Model saved to {output_path}")
# Compute per-region correlation on validation set
model.load_state_dict(
torch.load(output_path, map_location=device, weights_only=False)["model_state_dict"]
)
model.eval()
all_preds = []
all_true = []
with torch.no_grad():
for batch_features, batch_bold in val_loader:
pred = model(batch_features.to(device)).cpu().numpy()
all_preds.append(pred)
all_true.append(batch_bold.numpy())
all_preds = np.vstack(all_preds)
all_true = np.vstack(all_true)
# Per-region Pearson correlation
from scipy.stats import pearsonr
correlations = []
for r in range(bold.shape[1]):
if np.std(all_preds[:, r]) > 0 and np.std(all_true[:, r]) > 0:
corr, _ = pearsonr(all_preds[:, r], all_true[:, r])
correlations.append(corr)
else:
correlations.append(0.0)
mean_corr = np.mean(correlations)
print(f"Mean per-region correlation: {mean_corr:.4f}")
print(f"Top 5 regions by correlation:")
top_indices = np.argsort(correlations)[-5:][::-1]
for idx in top_indices:
from model import HARVARD_OXFORD_REGIONS
print(f" {HARVARD_OXFORD_REGIONS[idx]}: r={correlations[idx]:.4f}")
return model
def main():
parser = argparse.ArgumentParser(description="Train EEG-to-fMRI translation model")
parser.add_argument("--data_dir", type=str, default="./data", help="Directory with paired data")
parser.add_argument("--epochs", type=int, default=100, help="Training epochs")
parser.add_argument("--batch_size", type=int, default=64, help="Batch size")
parser.add_argument("--lr", type=float, default=1e-3, help="Learning rate")
parser.add_argument("--n_synthetic", type=int, default=5000, help="Number of synthetic samples if no real data")
parser.add_argument("--output", type=str, default="model_weights.pt", help="Output model path")
args = parser.parse_args()
# Try to load real data first
real_data = load_real_data(args.data_dir)
if real_data is not None:
features, bold = real_data
print("Training on REAL paired EEG-fMRI data")
else:
print("No real data found — generating structured synthetic data")
print("(To train on real data, place features.npy and bold.npy in data_dir)")
features, bold = generate_synthetic_paired_data(n_samples=args.n_synthetic)
train(
features,
bold,
epochs=args.epochs,
batch_size=args.batch_size,
lr=args.lr,
output_path=args.output,
)
if __name__ == "__main__":
main()