OSCR

Expectation-driven gain modulation of preparatory spatial attention.

Code ↔ Paper

11 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 11 matches
  1. [1] § Methods › Inverted encoding model ↔ open_dvm/analysis/CTF.py, lines 1555 to 1661 · score 0.91 · predicted channel responses, electrode space, weight matrix, training stage, IEM procedure, spatial channel · Report a mismatch
  2. [2] § Results › IEM results ↔ open_dvm/analysis/CTF.py, lines 76 to 249 · score 0.88 · broadband EEG activity, neural populations, inverted encoding model, reconstruct spatially selective, spatial channels tuned, neural activity · Report a mismatch
  3. [3] § Methods › EEG preprocessing ↔ open_dvm/analysis/EEG.py, lines 3189 to 3316 · score 0.88 · contaminated epochs, artifact period, score thresholds, band pass, 110 Hz, 140 Hz · Report a mismatch
  4. [4] § Methods › Inverted encoding model ↔ open_dvm/analysis/CTF.py, lines 338 to 434 · score 0.87 · half sinusoid, angular locations, neural populations, response profile, spatial channel, sum · Report a mismatch
  5. [5] § Methods › Inverted encoding model ↔ open_dvm/analysis/CTF.py, lines 1555 to 1661 · score 0.78 · np.linalg.lstsq, weight matrix, channel responses C2, B2, B1, transform · Report a mismatch
  6. [6] § Methods › Multivariate decoding ↔ open_dvm/analysis/BDM.py, lines 75 to 220 · score 0.78 · fold cross validation, linear discriminant, baseline corrected, LDA, curve, multivariate · Report a mismatch
  7. [7] § Methods › Inverted encoding model ↔ open_dvm/analysis/CTF.py, lines 338 to 434 · score 0.71 · angular locations, circularly shifted, channel response, profile, predicted, Inverted · Report a mismatch
  8. [8] § Methods › EEG preprocessing ↔ open_dvm/analysis/EYE.py, lines 90 to 174 · score 0.64 · eye movements, eye tracking, synchronized, algorithm, deviation, gaze · Report a mismatch
  9. [9] § Methods › Apparatus, material, and procedure ↔ open_dvm/analysis/EYE.py, lines 90 to 174 · score 0.62 · eye tracker, visual angle, Drift correction, fixation, tracked · Report a mismatch
  10. [10] § Methods › EEG preprocessing ↔ open_dvm/analysis/preprocessing_pipeline.py, lines 1 to 47 · score 0.56 · blink components, high pass filtering, preprocessing, eye, EEG, epochs · Report a mismatch
  11. [11] § Methods › Apparatus, material, and procedure ↔ open_dvm/analysis/EEG.py, lines 1596 to 1638 · score 0.52 · horizontal EOG, VEOG, external, HEOG, Vertical, EEG · Report a mismatch

Paper

Loaded from Europe PMC by your browser; OSCR does not store it: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 2,914 lines · 117 KB · MIT · 5 matches

  1. """
  2. Channel Tuning Function (CTF) Analysis for EEG Data.
  3. This module implements spatial encoding analysis methods based on the
  4. inverted encoding model (IEM) framework for analyzing neural population
  5. responses. The CTF approach reconstructs spatial tuning functions from
  6. multivariate EEG patterns, enabling investigation of spatial working
  7. memory representations and attentional mechanisms.
  8. The implementation is modeled after Foster et al. (2015) "The topography
  9. of alpha-band activity tracks the content of spatial working memory" and
  10. extends the BDM (Brain Decoding Multivariate) framework with spatial
  11. encoding capabilities.
  12. Classes
  13. -------
  14. CTF :
  15. Main class for channel tuning function analysis, inheriting from
  16. BDM. Provides methods for spatial encoding analysis, basis function
  17. reconstruction, and topographical visualization of spatial tuning.
  18. Notes
  19. -----
  20. The CTF analysis pipeline includes:
  21. 1. Spatial basis function construction (von Mises functions)
  22. 2. Encoding model training on spatial location data
  23. 3. Channel response reconstruction and tuning curve estimation
  24. 4. Statistical analysis of spatial selectivity and precision
  25. References
  26. ----------
  27. Foster, J. J., Sutterer, D. W., Serences, J. T., Vogel, E. K., & Awh, E.
  28. (2015). The topography of alpha-band activity tracks the content of
  29. spatial working memory. Journal of Neurophysiology, 115(1), 168-177.
  30. Examples
  31. --------
  32. Basic CTF analysis:
  33. >>> from eeg_analyses.CTFtemp import CTF
  34. >>> ctf = CTF(sj=1, epochs=epochs, df=df,
  35. ... to_decode='target_position', nr_bins=8, nr_chans=8)
  36. >>> ctf.spatial_ctf(cnds={'condition': ['spatial_task']})
  37. Created by Dirk van Moorselaar on 30-03-2016.
  38. Copyright (c) 2016 DvM. All rights reserved.
  39. """
  40. import pickle
  41. import warnings
  42. import matplotlib
  43. import mne
  44. matplotlib.use("agg")
  45. from math import pi # noqa: E402
  46. from typing import Optional, Tuple, Union # noqa: E402
  47. import matplotlib.pyplot as plt # noqa: E402
  48. import numpy as np # noqa: E402
  49. import pandas as pd # noqa: E402
  50. from scipy.optimize import curve_fit # noqa: E402
  51. from scipy.signal import hilbert # noqa: E402
  52. from open_dvm.analysis.BDM import * # noqa: E402
  53. from open_dvm.support.FolderStructure import * # noqa: E402
  54. from open_dvm.support.preprocessing_utils import ( # noqa: E402
  55. get_time_slice,
  56. select_electrodes,
  57. trial_exclusion,
  58. )
  59. from open_dvm.visualization.plot import plot_ctf_timecourse # noqa: E402
  60. class CTF(BDM):
  61. """
  62. Channel Tuning Function (CTF) analysis for spatial encoding in
  63. EEG data.
  64. Implements inverted encoding models (IEM) to reconstruct spatial
  65. tuning functions from multivariate EEG patterns. This class extends
  66. the BDM framework to analyze how neural populations encode spatial
  67. information, enabling investigation of spatial working memory and
  68. attention mechanisms.
  69. The analysis follows the approach of Foster et al. (2015), using
  70. basis functions to model hypothetical spatial channels and
  71. reconstructing channel responses from observed neural activity
  72. patterns.
  73. Parameters
  74. ----------
  75. sj : int
  76. Subject identifier for analysis organization.
  77. epochs : mne.Epochs
  78. Epoched EEG data containing the neural responses to be analyzed.
  79. df : pd.DataFrame
  80. Behavioral data containing trial information, including the
  81. spatial locations to decode (specified in `to_decode` column).
  82. to_decode : str
  83. Column name in behavioral DataFrame containing spatial location
  84. information for each trial (e.g., 'target_location',
  85. 'cue_position').
  86. nr_bins : int
  87. Number of spatial location bins used in the experiment (e.g., 8
  88. for 8 possible target positions around a circle).
  89. nr_chans : int
  90. Number of hypothetical underlying spatial channels in the
  91. encoding model. Determines the resolution of spatial
  92. reconstruction.
  93. shift_bins : int, default=0
  94. Circular shift applied to the bin axis of the reconstructed
  95. channel responses, so that bin 0 of the output corresponds to a
  96. location shift_bins away from its original position. Only has
  97. an observable effect when avg_ch=False: with avg_ch=True (the
  98. default), reconstructions are already averaged across bins (an
  99. order-invariant operation) before shift_bins would matter, so
  100. it is silently inert in that case (a warning is raised if
  101. shift_bins != 0 and avg_ch=True are combined). To align a test
  102. condition's reconstructions to a subject-specific reference
  103. location (e.g. a per-subject high-probability distractor
  104. location) under avg_ch=True, use spatial_ctf's special_loc
  105. argument instead: it assigns every test trial the same
  106. reference bin, so that bin's own per-trial centering already
  107. yields a correctly-aligned reconstruction directly -- no
  108. bin-averaging (and so no dilution or shift_bins) is involved.
  109. nr_iter : int, default=10
  110. Number of iterations for forward model application and
  111. averaging.
  112. nr_folds : int, default=3
  113. Number of cross-validation folds for model training and testing.
  114. elec_oi : str or list, default='all'
  115. Electrode selection criteria:
  116. - 'all': Use all available electrodes in ch_names
  117. - 'posterior': Posterior electrodes (parietal, occipital
  118. regions)
  119. - 'frontal': Frontal electrodes (prefrontal, frontal regions)
  120. - 'central': Central electrodes (motor, somatosensory regions)
  121. - list: Specific electrode names to select
  122. downsample : int, default=128
  123. Target sampling frequency for EEG data in Hz. Data will be
  124. downsampled to this rate if necessary.
  125. sin_power : int, default=7
  126. Power parameter for von Mises basis functions, controlling the
  127. width of spatial tuning curves. Higher values create narrower
  128. tuning.
  129. delta : bool, default=False
  130. Whether to use delta functions (True) or shaped basis functions
  131. (False) for spatial channels. Delta functions assume no specific
  132. tuning shape.
  133. method : str, default='Foster'
  134. Analysis method to use. 'Foster' implements the Foster et al.
  135. (2015) approach. 'k-fold' is planned for a future release and
  136. is not yet functional -- using it will raise an error.
  137. avg_ch : bool, default=True
  138. Whether to average across reconstructed channels for summary
  139. statistics.
  140. ctf_param : str or bool, default='slopes'
  141. Parameter extraction method for reconstructed CTFs. Options:
  142. - 'slopes': Extract only slope parameters (fastest)
  143. - 'von_mises': Extract slopes + von Mises fitting parameters
  144. (amplitude, baseline, concentration, mean location)
  145. - 'gaussian': Extract slopes + Gaussian fitting parameters
  146. (amplitude, mean, sigma, with derived concentration measure)
  147. - False: No parameter extraction
  148. min_freq : int, default=4
  149. Minimum frequency for time-frequency analysis (Hz).
  150. max_freq : int, default=40
  151. Maximum frequency for time-frequency analysis (Hz).
  152. num_frex : int, default=25
  153. Number of frequency steps for time-frequency decomposition.
  154. freq_scaling : str, default='log'
  155. Frequency spacing: 'log' for logarithmic, 'linear' for linear.
  156. slide_window : int, default=0
  157. Sliding window size for temporal smoothing (samples). 0 disables
  158. smoothing.
  159. laplacian : bool, default=False
  160. Whether to apply Laplacian spatial filtering to enhance spatial
  161. specificity of EEG signals.
  162. pca_cmp : int, default=0
  163. Number of PCA components for dimensionality reduction.
  164. 0 disables PCA.
  165. filter : int, optional
  166. Lowpass filter cutoff frequency (Hz) applied to broadband EEG
  167. data. Only used when analysis is run on broadband signals
  168. (freqs='broadband' in spatial_ctf). Recommended range: 6-8 Hz
  169. for optimal CTF results.None disables filtering.
  170. report : bool, default=False
  171. Whether to generate automated analysis reports with
  172. visualizations.
  173. baseline : tuple, optional
  174. Time window for baseline correction (start, end) in seconds.
  175. None disables baseline correction.
  176. seed : int or bool, default=42213
  177. Random seed for reproducible results. False disables seeding.
  178. Attributes
  179. ----------
  180. sfreq : float
  181. Actual sampling frequency extracted from epochs.info['sfreq'].
  182. basisset : np.ndarray
  183. Computed spatial basis functions, created during analysis.
  184. Notes
  185. -----
  186. The CTF analysis pipeline involves several key steps:
  187. 1. **Basis Function Construction**: Create spatial basis functions
  188. (von Mises or delta) representing hypothetical neural channels
  189. 2. **Forward Model Training**: Learn mapping from channel responses
  190. to observed EEG patterns using training data
  191. 3. **Channel Response Reconstruction**: Apply inverted model to
  192. reconstruct channel responses from test data
  193. 4. **Spatial Tuning Analysis**: Extract tuning parameters and
  194. assess spatial selectivity
  195. The method assumes that EEG activity reflects weighted sums of
  196. underlying spatial channel responses, enabling reconstruction of
  197. population-level spatial representations.
  198. References
  199. ----------
  200. Foster, J. J., Sutterer, D. W., Serences, J. T., Vogel, E. K., &
  201. Awh, E. (2015). The topography of alpha-band activity tracks the
  202. content of spatial working memory. Journal of Neurophysiology,
  203. 115(1), 168-177.
  204. Examples
  205. --------
  206. Basic spatial CTF analysis:
  207. >>> from eeg_analyses.CTFtemp import CTF
  208. >>> ctf = CTF(sj=1, epochs=epochs, df=df,
  209. ... to_decode='target_position',
  210. ... nr_bins=8, nr_chans=8)
  211. >>> ctf.spatial_ctf(cnds={'condition': ['spatial_task']})
  212. Time-frequency CTF analysis:
  213. >>> ctf_tf = CTF(sj=1, epochs=epochs, df=df,
  214. ... to_decode='cue_location',
  215. ... nr_bins=6, nr_chans=6,
  216. ... power='band', min_freq=8, max_freq=30)
  217. >>> ctf_tf.spatial_ctf(cnds={'task': ['working_memory']})
  218. """
  219. def __init__(
  220. self,
  221. sj: int,
  222. epochs: mne.Epochs,
  223. df: pd.DataFrame,
  224. to_decode: str,
  225. nr_bins: int,
  226. nr_chans: int,
  227. shift_bins: int = 0,
  228. nr_iter: int = 10,
  229. nr_folds: int = 3,
  230. elec_oi: Union[str, list] = "all",
  231. downsample: int = 128,
  232. sin_power: int = 7,
  233. delta: bool = False,
  234. method: str = "Foster",
  235. avg_ch: bool = True,
  236. ctf_param: Union[str, bool] = "slopes",
  237. power: str = "band",
  238. min_freq: int = 4,
  239. max_freq: int = 40,
  240. num_frex: int = 25,
  241. freq_scaling: str = "log",
  242. slide_window: int = 0,
  243. laplacian: bool = False,
  244. pca_cmp: int = 0,
  245. filter: int = None,
  246. report: bool = False,
  247. baseline: Optional[tuple] = None,
  248. seed: Union[int, bool] = 42213,
  249. ):
  250. """
  251. Initialize CTF analysis object with experimental parameters.
  252. Sets up the channel tuning function analysis framework with
  253. specified spatial encoding parameters, preprocessing options,
  254. and analysis configuration.
  255. Notes
  256. -----
  257. The initialization configures all analysis parameters and
  258. validates sampling rate compatibility. The object inherits BDM
  259. functionality while adding CTF-specific spatial encoding
  260. capabilities.
  261. All parameters are documented in the class docstring above.
  262. """
  263. self.sj = sj
  264. self.df = df
  265. self.epochs = epochs
  266. self.to_decode = to_decode
  267. self.elec_oi = elec_oi
  268. self.ctf_param = ctf_param
  269. self.avg_ch = avg_ch
  270. self.cross = False
  271. self.baseline = baseline
  272. self.seed = seed
  273. self.shift_bins = shift_bins # shift of channel position
  274. # specify model parameters
  275. self.sfreq = epochs.info["sfreq"]
  276. if self.sfreq % downsample != 0:
  277. warnings.warn(
  278. f"Warning: sfreq ({self.sfreq}) is not evenly "
  279. f"divisible by downsample ({downsample}). This will result in"
  280. f" inexact downsampling. Consider using a downsample value "
  281. f"that evenly divides {self.sfreq} "
  282. )
  283. self.downsample = downsample
  284. self.method = method
  285. self.nr_bins = nr_bins
  286. self.nr_chans = nr_chans
  287. self.nr_iter = nr_iter
  288. self.nr_folds = nr_folds
  289. self.sin_power = sin_power
  290. self.delta = delta
  291. self.min_freq = min_freq
  292. self.max_freq = max_freq
  293. self.num_frex = num_frex
  294. self.freq_scaling = freq_scaling
  295. self.slide_wind = slide_window
  296. self.laplacian = laplacian
  297. self.pca_cmp = pca_cmp
  298. self.filter = filter
  299. self.report = report
  300. def calculate_basis_set(
  301. self, nr_bins: int, nr_chans: int, sin_power: int = 7, delta: bool = False
  302. ) -> np.array:
  303. """
  304. Calculate spatial basis functions for channel tuning function
  305. analysis.
  306. Creates a set of hypothetical spatial channel response functions
  307. that serve as basis functions for the inverted encoding model.
  308. Each basis function represents the response profile of a spatial
  309. channel across different angular locations.
  310. Parameters
  311. ----------
  312. nr_bins : int
  313. Number of spatial location bins in the experiment (e.g.,
  314. 8 for 8 target positions arranged in a circle).
  315. nr_chans : int
  316. Number of hypothetical spatial channels to model. Typically
  317. equal to nr_bins for full spatial coverage.
  318. sin_power : int, default=7
  319. Power parameter for sinusoidal basis functions. Higher
  320. values create narrower spatial tuning curves. Used when
  321. delta=False.
  322. delta : bool, default=False
  323. Type of basis function to use:
  324. - False: Shaped functions (half sinusoid raised to
  325. sin_power)
  326. - True: Delta functions (single bin = 1, others = 0)
  327. Returns
  328. -------
  329. np.ndarray
  330. Basis function matrix with shape (nr_chans, nr_bins).
  331. Each row represents one spatial channel's response profile
  332. across all spatial locations.
  333. Notes
  334. -----
  335. The basis functions model hypothetical neural populations with
  336. spatial selectivity. The choice between delta and shaped
  337. functions affects the assumptions about spatial tuning:
  338. - **Shaped functions**: Assume smooth, overlapping tuning curves
  339. following R = sin(θ/2)^sin_power, where θ is angular distance
  340. from preferred location.
  341. - **Delta functions**: Assume sharp, non-overlapping tuning with
  342. channels responding only to their preferred location.
  343. Each basis function is circularly shifted so that channels have
  344. different preferred locations evenly distributed across space.
  345. Examples
  346. --------
  347. Create smooth basis functions for 8 locations and 8 channels:
  348. >>> ctf = CTF(...)
  349. >>> basis = ctf.calculate_basis_set(nr_bins=8, nr_chans=8,
  350. ... sin_power=7, delta=False)
  351. >>> basis.shape # (8, 8)
  352. Create delta function basis set:
  353. >>> basis_delta = ctf.calculate_basis_set(nr_bins=6, nr_chans=6,
  354. ... delta=True)
  355. >>> np.sum(basis_delta, axis=1) # Each channel sums to 1
  356. """
  357. # specify basis set
  358. if nr_bins % 2 == 0:
  359. x = np.linspace(0, 2 * pi - 2 * pi / nr_bins, nr_bins)
  360. else:
  361. x = np.linspace(0, 2 * pi - 2 * pi / nr_bins, nr_bins)
  362. x += np.deg2rad(180 / nr_bins)
  363. # c_centers = np.linspace(0, 2*pi - 2*pi/nr_chans, nr_chans)
  364. # c_centers = np.rad2deg(c_centers)
  365. if delta:
  366. pred = np.zeros(nr_bins)
  367. pred[-1] = 1
  368. else:
  369. # hypothetical channel responses
  370. pred = np.sin(0.5 * x) ** sin_power
  371. # shift the initial basis function
  372. if nr_bins % 2 == 0:
  373. pred = np.roll(pred, nr_chans - (np.argmax(pred) + 1))
  374. else:
  375. pred = np.roll(pred, np.argmax(pred))
  376. basisset = np.zeros((nr_chans, nr_bins))
  377. for c in range(nr_chans):
  378. basisset[c, :] = np.roll(pred, c + 1)
  379. return basisset
  380. def generate_ctf_report(
  381. self, ctf: dict, ctf_param, freqs: Union[str, dict], info: mne.Info, report_name: str
  382. ):
  383. """
  384. Generate comprehensive HTML report for CTF analysis results.
  385. Creates an automated report containing visualizations of channel
  386. tuning functions, slope parameters over time, and
  387. frequency-specificanalyses. The report includes both
  388. time-domain and time-frequency representations of spatial
  389. encoding results.
  390. Parameters
  391. ----------
  392. ctf : dict
  393. Dictionary containing CTF reconstruction results for each
  394. condition. Keys are condition names, values contain channel
  395. response matrices and metadata.
  396. ctf_param : dict
  397. Dictionary containing extracted CTF parameters (e.g.,
  398. slopes, peaks) for statistical analysis. See
  399. `get_ctf_tuning_params`'s Returns section for the exact
  400. nested structure ({condition: {parameter: values}}).
  401. freqs : str or dict
  402. Frequency specification for analysis:
  403. - str: Frequency band name (e.g., 'alpha')
  404. - dict: Custom frequency definitions
  405. info : mne.Info
  406. MNE info object containing channel and sampling information
  407. for proper visualization setup.
  408. report_name : str
  409. Base name for the output HTML report file. Will be saved
  410. in the CTF report folder with .html extension.
  411. Notes
  412. -----
  413. The report automatically includes:
  414. 1. **CTF Slope Timecourses**: Parameter evolution over time
  415. 2. **Channel Response Functions**: Reconstructed spatial tuning
  416. 3. **Time-Frequency Plots**: If multiple frequencies analyzed
  417. 4. **Condition Comparisons**: Separate sections per condition
  418. Report files are saved in HTML format for easy viewing and
  419. sharing. Figures are embedded directly in the report for
  420. self-contained documentation.
  421. Examples
  422. --------
  423. Generate report after CTF analysis:
  424. >>> # Run CTF analysis
  425. >>> ctf_results, ctf_params = ctf.spatial_ctf(...)
  426. >>> # Create comprehensive report
  427. >>> ctf.generate_ctf_report(ctf_results, ctf_params,
  428. ... freqs={'alpha': [8, 12]},
  429. ... info=epochs.info,
  430. ... report_name='subject_01_spatial')
  431. """
  432. report_name = self.folder_tracker(["ctf", "report"], f"{report_name}.h5")
  433. report = mne.Report(title="Single subject ctf overview")
  434. for cnd in ctf.keys():
  435. if cnd == "info":
  436. continue
  437. # get ctf slope
  438. output = [
  439. f"{d}_slopes"
  440. for d in ["envelope", "voltage", "T", "E"]
  441. if f"{d}_slopes" in ctf_param[cnd].keys()
  442. ]
  443. if len(freqs) == 1:
  444. fig, ax = plt.subplots()
  445. plot_ctf_timecourse(
  446. ctf_param,
  447. cnds=[cnd],
  448. colors=["black"],
  449. timecourse="1d",
  450. output=output,
  451. stats=False,
  452. )
  453. report.add_figure(
  454. fig,
  455. title=f"CTF slope over time: {cnd}",
  456. caption=f"CTF slope for {cnd} condition",
  457. )
  458. plt.close()
  459. else:
  460. for out in output:
  461. fig, ax = plt.subplots()
  462. plot_ctf_timecourse(
  463. ctf_param,
  464. cnds=[cnd],
  465. colors=["black"],
  466. timecourse="2d_tfr",
  467. output=out,
  468. stats=False,
  469. )
  470. report.add_figure(
  471. fig,
  472. title=f"CTF slope over time: {cnd}, {out}",
  473. caption=f"CTF slope for {cnd} condition",
  474. )
  475. plt.close()
  476. # get ctf tuning functions
  477. for to in ["E", "T", "voltage", "envelope"]:
  478. if f"C2_{to}" in ctf[cnd].keys():
  479. fig, ax = plt.subplots()
  480. plot_ctf_timecourse(
  481. ctf,
  482. cnds=[cnd],
  483. colors=["black"],
  484. timecourse="2d_ctf",
  485. output=f"C2_{to}",
  486. stats=False,
  487. )
  488. report.add_figure(
  489. fig,
  490. title=f"Channel response {to}: {cnd}",
  491. caption="CTF tuning function over time",
  492. )
  493. plt.close()
  494. report.save(report_name.rsplit(".", 1)[0] + ".html", overwrite=True, open_browser=False)
  495. def spatial_ctf(
  496. self,
  497. pos_labels: dict = "all",
  498. cnds: dict = None,
  499. excl_factor: dict = None,
  500. window_oi: tuple = (None, None),
  501. freqs: dict = "main_param",
  502. GAT: bool = False,
  503. nr_perm: int = 0,
  504. collapse: bool = False,
  505. special_loc: Optional[Union[int, str]] = None,
  506. f_name: str = None,
  507. ):
  508. """
  509. Perform spatial channel tuning function analysis across
  510. conditions.
  511. This is the main analysis function that implements the complete
  512. CTF pipeline using inverted encoding models to reconstruct
  513. spatial channel responses from multivariate EEG patterns. The
  514. method follows Foster et al. (2015) and supports both
  515. within-condition and cross-condition training/testing paradigms
  516. with comprehensive parameter extraction capabilities.
  517. Parameters
  518. ----------
  519. pos_labels : dict or str, default='all'
  520. Spatial position labels for CTF analysis:
  521. - 'all': Use all unique values from the to_decode column
  522. - dict: {column_name: [list_of_positions]} to specify subset
  523. of positions for analysis
  524. cnds : dict, optional
  525. Condition specifications for decoding analysis. Structure
  526. determines analysis type:
  527. Within-condition decoding:
  528. ``{'condition_column': ['cond1', 'cond2']}``
  529. Cross-condition decoding (train on first, test on second):
  530. ``{'condition_column': [['train_cond'], ['test_cond']]}``
  531. Multiple training/testing conditions:
  532. ``{'condition_column': [['train1', 'train2'],
  533. ['test1', 'test2']]}``
  534. If None, decoding performed on all data.
  535. excl_factor : dict, optional
  536. Exclusion criteria for trial selection. Format:
  537. {column_name: [values_to_exclude]}. Multiple columns and
  538. values can be specified to filter out unwanted trials.
  539. window_oi : tuple, default=(None, None)
  540. Time window of interest (start_time, end_time) in seconds.
  541. Data will be cropped to this window after preprocessing.
  542. None values preserve the full epoch duration.
  543. freqs : dict or str, default='main_param'
  544. Frequency specification for time-frequency analysis:
  545. - 'main_param': Use frequency settings from class
  546. initialization
  547. - dict: {band_name: [low_freq, high_freq]} for custom
  548. frequency bands
  549. - 'broadband': Analyze unfiltered broadband signals only
  550. GAT : bool, default=False
  551. Whether to perform Generalization Across Time (GAT)
  552. analysis. When True, creates encoding matrices for all
  553. possible train/test time combinations.
  554. Warning: Computationally intensive.
  555. nr_perm : int, default=0
  556. Number of permutation iterations for building a null
  557. distribution of CTF slopes. Set to 0 to disable permutation
  558. testing and speed up analysis. For each permutation, the
  559. mapping between trials' true position bins and the basis
  560. function used to model them is shuffled once (fixed across
  561. all cross-validation iterations for that permutation, so
  562. only cross-validation noise -- not label-shuffle noise --
  563. varies within a permutation), while the real, unpermuted
  564. labeling is always preserved as permutation index 0.
  565. collapse : bool, default=False
  566. Whether to collapse across specific conditions or factors.
  567. Note: This feature is currently under development.
  568. special_loc : int or str, optional, default=None
  569. Convenience override for the position bin used to align
  570. reconstructions for the *test* condition(s) only (requires
  571. cross-condition `cnds`). Useful for e.g. neutral "impulse"/
  572. "ping" displays that carry no true stimulus position, where
  573. you instead want reconstructions centered on a hypothesized
  574. reference location (such as a high-probability distractor
  575. location). Training data is never affected.
  576. - int: use this same bin index for every test trial.
  577. - str: column name in the behavioral dataframe holding a
  578. per-trial bin index to use instead (e.g. to test
  579. inter-trial priming effects via trial history).
  580. Equivalent to manually overwriting `to_decode` for the test
  581. condition's rows before calling spatial_ctf, without
  582. mutating your own dataframe.
  583. f_name : str, optional, default=None
  584. Filename for saving results. If None (default), results are
  585. returned as dictionaries but not saved to disk. If provided,
  586. results are saved as pickle files with this name as suffix.
  587. Returns
  588. -------
  589. tuple of (ctfs, ctf_param, info)
  590. ctfs : dict
  591. Reconstructed channel tuning functions. For filtered
  592. data: contains 'C2_E' (evoked power) and 'C2_T' (total
  593. power).
  594. For broadband: contains 'C2_voltage' (raw ERP) and
  595. 'C2_envelope' (phase-locked amplitude via Hilbert
  596. transform).
  597. ctf_param : dict
  598. Extracted tuning parameters (slopes, von Mises fits,
  599. Gaussian fits).
  600. info : dict
  601. Analysis metadata and cross-validation indices.
  602. If f_name is not None, also saves:
  603. - 'ctfs_{f_name}.pickle': Reconstructed tuning functions
  604. - 'ctf_param_{f_name}.pickle': Tuning parameters
  605. - 'ctf_info_{f_name}.pickle': Analysis metadata
  606. If self.report=True and f_name is not None, also generates
  607. an HTML report with visualizations.
  608. Notes
  609. -----
  610. The spatial CTF analysis pipeline implements the following
  611. steps:
  612. 1. **Data Preparation**:
  613. - Select electrodes and apply preprocessing
  614. - Filter trials by conditions and exclusion criteria
  615. 2. **Basis Function Setup**:
  616. - Create spatial channel models using von Mises functions
  617. - Configure cross-validation folds for robust estimation
  618. 3. **Time-Frequency Analysis**:
  619. - For filtered data: Apply bandpass filtering, extract
  620. evoked power (phase-locked) and total power
  621. (non-phase-locked)
  622. - For broadband: Extract raw voltages (ERPs) and
  623. phase-locked amplitude envelope (via Hilbert transform
  624. after trial averaging). Voltage preserves polarity
  625. information; envelope is less sensitive to polarity
  626. reversals and reflects overall amplitude modulation
  627. 4. **Cross-Validation Loop**:
  628. - Partition data into training and testing sets
  629. - Train encoding models on spatial location data
  630. - Test model performance on held-out trials
  631. 5. **Channel Reconstruction**:
  632. - Use forward modeling to reconstruct channel responses
  633. - Generate tuning functions across spatial positions
  634. 6. **Parameter Extraction**:
  635. - Extract tuning curve slopes using linear regression
  636. - Optionally fit von Mises functions to estimate peak tuning
  637. - Optionally fit Gaussian functions for comparison
  638. 7. **Output Generation**:
  639. - Save reconstructed tuning functions and parameters
  640. - Generate analysis report with visualizations
  641. The analysis supports multiple frequency bands, cross-condition
  642. decoding, and comprehensive statistical validation through
  643. permutation testing.
  644. References
  645. ----------
  646. Foster, J. J., Sutterer, D. W., Serences, J. T., Vogel, E. K.,
  647. & Awh, E. (2015). The topography of alpha-band activity tracks
  648. the content of spatial working memory. Journal of
  649. Neurophysiology, 115(1), 168-177.
  650. Examples
  651. --------
  652. Basic analysis returning data without saving:
  653. >>> ctf = CTF(sj=1, to_decode='target_loc')
  654. >>> ctfs, ctf_param, info = ctf.spatial_ctf()
  655. Cross-condition analysis with file saving:
  656. >>> conditions = {'condition': [['easy'], 'hard']}
  657. >>> ctfs, ctf_param, info = ctf.spatial_ctf(
  658. ... cnds=conditions, f_name='cross_difficulty')
  659. Multi-frequency analysis with custom bands:
  660. >>> freq_bands = {'alpha': [8, 12], 'beta': [13, 30]}
  661. >>> ctfs, ctf_param, info = ctf.spatial_ctf(
  662. ... freqs=freq_bands, f_name='multi_band')
  663. """
  664. # Input validation and setup
  665. if self.shift_bins != 0 and self.avg_ch:
  666. warnings.warn(
  667. "shift_bins has no effect when avg_ch=True: reconstructed "
  668. "tuning curves are already centered on each bin's own "
  669. "position before being averaged across bins (an "
  670. "order-invariant operation), so shifting which bin occupies "
  671. "which row beforehand doesn't change the averaged result. "
  672. "shift_bins only has an observable effect with avg_ch=False."
  673. )
  674. # hypothesized set tuning functions underlying power measured
  675. # across electrodes
  676. print("Creating basis set with sin_power ", self.sin_power)
  677. self.basisset = self.calculate_basis_set(
  678. self.nr_bins, self.nr_chans, self.sin_power, self.delta
  679. )
  680. # read in data
  681. if cnds is None:
  682. cnd_head, _ = (None, ["all_data"])
  683. else:
  684. ((cnd_head, _),) = cnds.items()
  685. headers = [cnd_head]
  686. epochs = self.epochs.copy()
  687. df = self.df.copy()
  688. freqs, nr_freqs, bands = self.set_frequencies(freqs)
  689. data_type = "power" if freqs != ["broadband"] else "broadband"
  690. epochs, df = self.select_ctf_data(epochs, df, self.elec_oi, headers, excl_factor, data_type)
  691. # set params
  692. sfreq = epochs.info["sfreq"]
  693. downsample = int(sfreq // self.downsample)
  694. actual_sfreq = sfreq / downsample
  695. if abs(actual_sfreq - self.downsample) > 1:
  696. print(
  697. f"Warning: Actual sampling frequency ({actual_sfreq}) "
  698. f"is not equal to desired downsample ({self.downsample})."
  699. )
  700. nr_itr = self.nr_iter * self.nr_folds
  701. # Always create ctf_name for reporting; use f_name if provided
  702. if f_name is not None:
  703. ctf_name = f"sub_{self.sj}_{f_name}"
  704. else:
  705. ctf_name = f"sub_{self.sj}_ctf"
  706. nr_perm += 1
  707. nr_elec = len(epochs.ch_names)
  708. tois = get_time_slice(epochs.times, window_oi[0], window_oi[1])
  709. times_oi = epochs.times[tois]
  710. nr_samples = epochs.times[tois][::downsample].size
  711. ctf, info = {}, {}
  712. if self.method == "k-fold":
  713. raise NotImplementedError(
  714. "method='k-fold' is planned for a future release and is "
  715. "not yet functional. Use the default 'Foster' method."
  716. )
  717. if collapse:
  718. pass # 'collapse' is under development and currently a no-op
  719. if isinstance(cnds, dict):
  720. train_cnds, test_cnds = self.check_cnds_input(cnds)
  721. if test_cnds is not None:
  722. # check for overlap between train and test conditions
  723. overlap = set(train_cnds) & set(test_cnds)
  724. if overlap:
  725. warnings.warn(
  726. f"Found overlapping conditions between train and "
  727. f"test: {overlap}. Training will be done on a "
  728. f"subset of the data to match trial counts."
  729. )
  730. self.cross = "cross_cv"
  731. else:
  732. self.cross = True
  733. nr_itr = self.nr_iter
  734. else:
  735. self.cross = False
  736. else:
  737. train_cnds = ["all_data"]
  738. test_cnds = None
  739. if special_loc is not None:
  740. if test_cnds is None:
  741. raise ValueError(
  742. "special_loc requires cross-condition analysis "
  743. "(cnds must specify separate train/test conditions)."
  744. )
  745. test_mask = df[cnd_head].isin(test_cnds)
  746. if isinstance(special_loc, str):
  747. if special_loc not in df.columns:
  748. raise ValueError(
  749. f"special_loc column '{special_loc}' not found "
  750. "in the behavioral dataframe."
  751. )
  752. df.loc[test_mask, self.to_decode] = df.loc[test_mask, special_loc].values
  753. else:
  754. df.loc[test_mask, self.to_decode] = special_loc
  755. # based on conditions get position bins
  756. pos_bins, cnds, epochs, max_tr = self.select_ctf_labels(epochs, df, pos_labels, cnds)
  757. if GAT:
  758. print(
  759. "Creating generalization across time matrix.",
  760. "This may take some time and is generally not ",
  761. "recommended",
  762. )
  763. # Frequency loop (ensures that data is only filtered once)
  764. cnd_combos = []
  765. for fr in range(nr_freqs):
  766. print("Frequency {} out of {}".format(str(fr + 1), str(nr_freqs)))
  767. # Time-Frequency Analysis
  768. E, T = self.tfr_decomposition(epochs.copy(), freqs[fr], tois, downsample)
  769. # Loop over conditions
  770. for c, cnd in enumerate(train_cnds):
  771. for te_cnd in (test_cnds if test_cnds is not None else [None]):
  772. # set condition info
  773. cnd_inf = str(cnd)
  774. if self.cross:
  775. cnd_inf += f"_{te_cnd}"
  776. cnd_combos.append(cnd_inf)
  777. print(f"Running ctf for {cnd_inf} condition")
  778. # preallocate arrays
  779. if GAT:
  780. C2_E = np.zeros(
  781. (
  782. nr_perm,
  783. nr_freqs,
  784. nr_itr,
  785. nr_samples - self.slide_wind,
  786. nr_samples - self.slide_wind,
  787. self.nr_bins,
  788. self.nr_chans,
  789. )
  790. )
  791. W_E = np.zeros(
  792. (
  793. nr_perm,
  794. nr_freqs,
  795. nr_itr,
  796. nr_samples - self.slide_wind,
  797. nr_samples - self.slide_wind,
  798. self.nr_chans,
  799. nr_elec,
  800. )
  801. )
  802. else:
  803. C2_E = np.zeros(
  804. (
  805. nr_perm,
  806. nr_freqs,
  807. nr_itr,
  808. nr_samples - self.slide_wind,
  809. self.nr_bins,
  810. self.nr_chans,
  811. )
  812. )
  813. W_E = np.zeros(
  814. (
  815. nr_perm,
  816. nr_freqs,
  817. nr_itr,
  818. nr_samples - self.slide_wind,
  819. self.nr_chans,
  820. nr_elec,
  821. )
  822. )
  823. C2_T, W_T = C2_E.copy(), W_E.copy()
  824. # partition data into training and testing sets
  825. # is done once to ensure each frequency has the same sets
  826. if fr == 0:
  827. # update ctf dicts to keep track of output
  828. info.update({cnd_inf: {}})
  829. ctf.update({cnd_inf: {"C2_E": C2_E, "C2_T": C2_T, "W_E": W_E, "W_T": W_T}})
  830. # get condition indices
  831. cnd_idx = cnds == cnd
  832. if self.cross:
  833. test_idx = cnds == te_cnd
  834. test_bins = np.unique(pos_bins[test_idx])
  835. if self.cross == "cross_cv":
  836. trial_limit = max_tr
  837. else:
  838. trial_limit = None
  839. train_idx, test_idx = self.train_test_cross(
  840. pos_bins, cnd_idx, test_idx, self.nr_iter, trial_limit
  841. )
  842. else:
  843. train_idx, test_idx = self.train_test_split(pos_bins, cnd_idx, max_tr)
  844. test_bins = np.unique(pos_bins)
  845. info[cnd_inf]["train_idx"] = train_idx
  846. info[cnd_inf]["test_idx"] = test_idx
  847. if self.method == "Foster":
  848. C1 = (
  849. np.empty((self.nr_bins * (self.nr_folds - 1), self.nr_chans))
  850. * np.nan
  851. )
  852. else:
  853. C1 = self.basisset
  854. # permutation loop (p=0 is always the true, unpermuted
  855. # labeling; for p>0 the bin-to-basis-function mapping
  856. # is shuffled once and reused across all iterations
  857. # below, to build a null distribution of CTF slopes)
  858. for p in range(nr_perm):
  859. if p == 0:
  860. perm_map = np.arange(self.nr_bins)
  861. else:
  862. perm_map = np.random.permutation(self.nr_bins)
  863. # iteration loop
  864. for itr in range(nr_itr):
  865. train_idx = info[cnd_inf]["train_idx"][itr]
  866. # initialize evoked and total power arrays
  867. bin_te_E = np.zeros((self.nr_bins, nr_elec, nr_samples))
  868. bin_te_T = bin_te_E.copy()
  869. if self.method == "k-fold":
  870. pass # unreachable: spatial_ctf() raises before this point
  871. elif self.method == "Foster":
  872. nr_itr_tr = self.nr_bins * (self.nr_folds - 1)
  873. bin_tr_E = np.zeros((nr_itr_tr, nr_elec, nr_samples))
  874. bin_tr_T = bin_tr_E.copy()
  875. # position bin loop
  876. bin_cnt = 0
  877. for bin in range(self.nr_bins):
  878. if bin in test_bins:
  879. condition_info = info[cnd_inf]["test_idx"]
  880. iteration_data = condition_info[itr]
  881. bin_indices = iteration_data[bin]
  882. test_idx = np.squeeze(bin_indices)
  883. bin_te_T[bin] = np.mean(T[test_idx], axis=0)
  884. evoked_mean = np.mean(E[test_idx], axis=0)
  885. bin_te_E[bin] = self.extract_power(evoked_mean, freqs[fr])
  886. if self.method == "Foster":
  887. for j in range(self.nr_folds - 1):
  888. evoked = self.extract_power(
  889. np.mean(E[train_idx[bin][j]], axis=0), freqs[fr]
  890. )
  891. bin_tr_E[bin_cnt] = evoked
  892. total = np.mean(T[train_idx[bin][j]], axis=0)
  893. bin_tr_T[bin_cnt] = total
  894. C1[bin_cnt] = self.basisset[perm_map[bin]]
  895. bin_cnt += 1
  896. elif self.method == "k-fold":
  897. pass # unreachable: spatial_ctf() raises before this point
  898. (
  899. ctf[cnd_inf]["C2_E"][p, fr, itr],
  900. ctf[cnd_inf]["W_E"][p, fr, itr],
  901. ctf[cnd_inf]["C2_T"][p, fr, itr],
  902. ctf[cnd_inf]["W_T"][p, fr, itr],
  903. ) = self.forward_model_loop(
  904. bin_tr_E, bin_te_E, bin_tr_T, bin_te_T, C1, GAT
  905. )
  906. # take the average across model iterations
  907. for cnd in np.unique(cnd_combos):
  908. for key in ["C2_E", "C2_T", "W_E", "W_T"]:
  909. ctf[cnd][key] = ctf[cnd][key].mean(axis=2)
  910. if freqs == ["broadband"]:
  911. ctf[cnd]["C2_envelope"] = ctf[cnd].pop("C2_E")
  912. ctf[cnd]["W_envelope"] = ctf[cnd].pop("W_E")
  913. ctf[cnd]["C2_voltage"] = ctf[cnd].pop("C2_T")
  914. ctf[cnd]["W_voltage"] = ctf[cnd].pop("W_T")
  915. # save output
  916. times_oi = epochs.times[tois][::downsample]
  917. if self.slide_wind > 0:
  918. times_oi = times_oi[: -self.slide_wind]
  919. print("get ctf tuning params")
  920. ctf_param = self.get_ctf_tuning_params(
  921. ctf, self.ctf_param, GAT=GAT, avg_ch=self.avg_ch, test_bins=test_bins
  922. )
  923. ctf_param.update({"info": {"times": times_oi, "freqs": freqs, "bands": bands}})
  924. if f_name is not None:
  925. if self.ctf_param:
  926. with open(
  927. self.folder_tracker(["ctf", self.to_decode], fname=f"{ctf_name}_param.pickle"),
  928. "wb",
  929. ) as handle:
  930. print("saving ctf params")
  931. pickle.dump(ctf_param, handle)
  932. ctfs = {}
  933. for cnd in ctf.keys():
  934. ctfs[cnd] = {}
  935. for key in ["C2_E", "C2_T", "C2_envelope", "C2_voltage"]:
  936. if key in ctf[cnd].keys():
  937. # extract non-permuted data
  938. data = ctf[cnd][key][0]
  939. if self.avg_ch:
  940. # restrict to bins that actually received test
  941. # data before averaging -- otherwise unfitted
  942. # bins (left at their zero-initialized default)
  943. # would silently dilute the average
  944. if test_bins.size < self.nr_bins:
  945. data = data[..., test_bins.astype(int), :]
  946. # average across bins
  947. data = data.mean(axis=-2)
  948. ctfs[cnd][key] = data
  949. ctfs.update({"info": {"times": times_oi, "freqs": freqs}})
  950. # Generate report (always, regardless of whether data is saved)
  951. if self.report:
  952. self.generate_ctf_report(ctfs, ctf_param, freqs, info=epochs.info, report_name=ctf_name)
  953. # Save outputs if f_name is provided
  954. if f_name is not None:
  955. with open(
  956. self.folder_tracker(["ctf", self.to_decode], fname=f"{ctf_name}_ctf.pickle"), "wb"
  957. ) as handle:
  958. print("saving ctfs")
  959. pickle.dump(ctfs, handle)
  960. with open(
  961. self.folder_tracker(["ctf", self.to_decode], fname=f"{ctf_name}_info.pickle"), "wb"
  962. ) as handle:
  963. pickle.dump(info, handle)
  964. return ctfs, ctf_param, info
  965. def select_ctf_data(
  966. self,
  967. epochs: mne.Epochs,
  968. df: pd.DataFrame,
  969. elec_oi: Union[list, str] = "all",
  970. headers: list = [],
  971. excl_factor: dict = None,
  972. data_type: str = "broadband",
  973. ) -> Tuple[mne.Epochs, pd.DataFrame]:
  974. """
  975. Select and preprocess EEG data for CTF analysis.
  976. Filters epochs and behavioral data based on specified criteria,
  977. applies preprocessing steps (Laplacian filtering if requested),
  978. and selects electrodes of interest for spatial encoding
  979. analysis.
  980. Parameters
  981. ----------
  982. epochs : mne.Epochs
  983. Input epoched EEG data to be processed.
  984. df : pd.DataFrame
  985. Behavioral data frame containing trial information that
  986. corresponds to the epochs.
  987. elec_oi : list or str, default='all'
  988. Electrodes of interest for analysis:
  989. - 'all': Use all available electrodes
  990. - list: Specific electrode names or selection criteria
  991. headers : list, default=[]
  992. Currently has no effect -- reserved for the
  993. disabled trial pre-averaging step (see
  994. KNOWN_LIMITATIONS.md).
  995. excl_factor : dict, optional
  996. Exclusion criteria for trial selection. Format:
  997. {column_name: [values_to_exclude]}. Trials matching any
  998. specified criteria will be removed.
  999. data_type : str, default='broadband'
  1000. Type of data analysis. When 'broadband', applies lowpass
  1001. filtering if self.filter is specified. Other values skip
  1002. the filtering step.
  1003. Returns
  1004. -------
  1005. tuple[mne.Epochs, pd.DataFrame]
  1006. epochs : mne.Epochs
  1007. Processed epochs with selected electrodes and filtered
  1008. trials.
  1009. df : pd.DataFrame
  1010. Corresponding behavioral data with matching trial
  1011. indices.
  1012. Notes
  1013. -----
  1014. The processing pipeline includes:
  1015. 1. **Trial Exclusion**: Remove trials based on excl_factor
  1016. criteria
  1017. 2. **Index Reset**: Ensure proper alignment between epochs and
  1018. behavioral data
  1019. 3. **Lowpass Filtering**: Apply temporal filtering
  1020. (if data_type='broadband') to remove high-frequency noise
  1021. before spatial processing
  1022. 4. **Laplacian Filtering**: Apply current source density if
  1023. requested
  1024. 5. **Electrode Selection**: Pick specified electrodes for
  1025. analysis
  1026. Temporal filtering precedes Laplacian
  1027. transform to ensure clean spatial gradient calculations.
  1028. Laplacian filtering enhances spatial specificity by computing
  1029. current source density, which can improve CTF spatial
  1030. selectivity.
  1031. Examples
  1032. --------
  1033. Basic data selection:
  1034. >>> epochs_clean, df_clean = ctf.select_ctf_data(
  1035. ... epochs, df, elec_oi='posterior'
  1036. ... )
  1037. With trial exclusion:
  1038. >>> epochs_filt, df_filt = ctf.select_ctf_data(
  1039. ... epochs, df,
  1040. ... elec_oi=['Pz', 'POz', 'Oz'],
  1041. ... excl_factor={'accuracy': [0], 'rt': ['>2000']}
  1042. ... )
  1043. """
  1044. # if specified remove trials matching specified criteria
  1045. if excl_factor is not None:
  1046. # all callers of select_ctf_data pass their own disposable
  1047. # copy of epochs/df, so the in-place caveat doesn't apply
  1048. df, epochs, _ = trial_exclusion(df, epochs, excl_factor, warn_inplace=False)
  1049. # if not already done reset index (to properly align beh and epochs)
  1050. df.reset_index(inplace=True, drop=True)
  1051. # Apply lowpass filtering before Laplacian to remove high-freq noise
  1052. if data_type == "broadband":
  1053. if self.filter is not None:
  1054. epochs.filter(l_freq=None, h_freq=self.filter)
  1055. if self.laplacian:
  1056. epochs = mne.preprocessing.compute_current_source_density(epochs)
  1057. # Trial pre-averaging (BDM.average_trials) is deliberately not
  1058. # applied here -- see KNOWN_LIMITATIONS.md for why it's disabled
  1059. # for CTF specifically.
  1060. # limit analysis to electrodes of interest
  1061. picks = select_electrodes(epochs, elec_oi)
  1062. epochs.pick(picks)
  1063. return epochs, df
  1064. def check_cnds_input(self, cnds: dict) -> Tuple[list, Union[list, str, None]]:
  1065. """
  1066. Parse conditions dictionary for within vs cross-condition
  1067. analysis.
  1068. Parameters
  1069. ----------
  1070. cnds : dict
  1071. Conditions specification.
  1072. Format: {column_name: condition_values}
  1073. - For within-condition: condition_values is list of
  1074. conditions
  1075. - For cross-condition: condition_values is
  1076. [train_conditions, test_conditions]
  1077. Returns
  1078. -------
  1079. train_cnds : list
  1080. Conditions for model training.
  1081. test_cnds : list or None
  1082. Conditions for model testing. None indicates
  1083. within-condition analysis.
  1084. """
  1085. ((_, cnds_oi),) = cnds.items()
  1086. if isinstance(cnds_oi[0], list):
  1087. train_cnds, test_cnds = cnds_oi
  1088. if isinstance(test_cnds, str):
  1089. # be forgiving of a bare string for a single test
  1090. # condition (e.g. [['train'], 'test'])
  1091. test_cnds = [test_cnds]
  1092. else:
  1093. train_cnds, test_cnds = cnds_oi, None
  1094. return train_cnds, test_cnds
  1095. def select_ctf_labels(
  1096. self,
  1097. epochs: mne.Epochs,
  1098. df: pd.DataFrame,
  1099. pos_labels: Union[dict, str],
  1100. cnds: Optional[dict],
  1101. ) -> Tuple[np.ndarray, np.ndarray, mne.Epochs, int]:
  1102. """
  1103. Select and filter epochs and behavioral data based on position
  1104. and condition criteria.
  1105. Filters trials to include only specified spatial positions and
  1106. experimental conditions, ensuring compatibility with CTF
  1107. analysis requirements. Also enforces that position bins don't
  1108. exceed the number specified during class initialization.
  1109. Parameters
  1110. ----------
  1111. epochs : mne.Epochs
  1112. Input epochs object containing EEG data.
  1113. df : pd.DataFrame
  1114. Behavioral dataframe with trial information and labels.
  1115. pos_labels : dict or str
  1116. Position specification for CTF analysis:
  1117. - dict: {column_name: [position_values]} to select specific
  1118. positions
  1119. - 'all': Use all positions from self.to_decode column
  1120. cnds : dict or None
  1121. Condition specification:
  1122. - dict: {column_name: [condition_values]} to select specific
  1123. conditions
  1124. - None: Include all trials regardless of condition
  1125. Returns
  1126. -------
  1127. pos_bins : np.ndarray
  1128. Array of position labels for selected trials.
  1129. cnds : np.ndarray
  1130. Array of condition labels for selected trials.
  1131. epochs : mne.Epochs
  1132. Filtered epochs object containing only selected trials.
  1133. max_tr : int
  1134. Maximum number of trials per position bin that can be used
  1135. for balanced cross-validation across conditions.
  1136. """
  1137. # extract conditions of interest
  1138. if cnds is None:
  1139. cnds = np.array(["all_data"] * df.shape[0])
  1140. cnd_idx = np.arange(df.shape[0])
  1141. else:
  1142. ((cnd_header, cnds_oi),) = cnds.items()
  1143. cnd_idx = np.where(df[cnd_header].isin(np.hstack(cnds_oi)))[0]
  1144. cnds = df[cnd_header]
  1145. # extract position labels of interest
  1146. if pos_labels != "all":
  1147. ((pos_header, pos_labels),) = pos_labels.items()
  1148. pos_idx = np.where(df[pos_header].isin(pos_labels))[0]
  1149. else:
  1150. pos_header = self.to_decode
  1151. pos_idx = np.arange(df.shape[0])
  1152. # get data of interest
  1153. idx = np.intersect1d(cnd_idx, pos_idx)
  1154. pos_bins = df[pos_header].values[idx]
  1155. cnds = cnds[idx]
  1156. epochs = epochs[idx]
  1157. pos_bins, cnds, epochs = self.select_bins_oi(pos_bins, cnds, epochs)
  1158. max_tr = self.set_max_trial(cnds, pos_bins, self.method)
  1159. return pos_bins, cnds, epochs, max_tr
  1160. def select_bins_oi(
  1161. self, pos_bins: np.ndarray, cnds: np.ndarray, epochs: mne.Epochs
  1162. ) -> Tuple[np.ndarray, np.ndarray, mne.Epochs]:
  1163. """
  1164. Filter out position bins that exceed the valid range for
  1165. CTF analysis.
  1166. Ensures that all position labels are within the valid range
  1167. [0, nr_bins) as specified during class initialization,
  1168. removing any invalid trials.
  1169. Parameters
  1170. ----------
  1171. pos_bins : np.ndarray
  1172. Array of position labels for trials.
  1173. cnds : np.ndarray
  1174. Array of condition labels for trials.
  1175. epochs : mne.Epochs
  1176. EEG epochs object containing trial data.
  1177. Returns
  1178. -------
  1179. pos_bins : np.ndarray
  1180. Filtered position labels with only valid bins.
  1181. cnds : np.ndarray
  1182. Filtered condition labels for corresponding trials.
  1183. epochs : mne.Epochs
  1184. Filtered epochs containing only trials with valid position
  1185. bins.
  1186. """
  1187. mask = np.logical_and(pos_bins >= 0, pos_bins < self.nr_bins)
  1188. pos_bins = pos_bins[mask]
  1189. cnds = cnds[mask]
  1190. epochs = epochs[np.where(mask)[0]]
  1191. return pos_bins, cnds, epochs
  1192. def set_max_trial(self, cnds: np.ndarray, pos_bins: np.ndarray, method: str) -> int:
  1193. """
  1194. Determine maximum trials per bin for balanced cross-validation.
  1195. Calculates the maximum number of trials that can be used per
  1196. position bin to ensure balanced data across conditions and
  1197. cross-validation folds.
  1198. Parameters
  1199. ----------
  1200. cnds : np.ndarray
  1201. Array of condition labels for trials.
  1202. pos_bins : np.ndarray
  1203. Array of position labels for trials.
  1204. method : str
  1205. Cross-validation method to use:
  1206. - 'Foster': Standard k-fold approach using self.nr_folds
  1207. - 'k-fold': planned for a future release, not yet
  1208. functional -- raises NotImplementedError
  1209. Returns
  1210. -------
  1211. int
  1212. Maximum number of trials per position bin that ensures
  1213. balanced data across conditions and cross-validation folds.
  1214. """
  1215. nr_cnds = np.unique(cnds).size
  1216. if nr_cnds == 1:
  1217. _, bin_count = np.unique(pos_bins, return_counts=True)
  1218. else:
  1219. bin_count = np.zeros((nr_cnds, self.nr_bins))
  1220. for c, cnd in enumerate(np.unique(cnds)):
  1221. temp_bin = pos_bins[cnds == cnd]
  1222. for b in range(self.nr_bins):
  1223. bin_count[c, b] = sum(temp_bin == b)
  1224. # select cell with lowest number of observations
  1225. min_count = np.min(bin_count)
  1226. if method == "Foster":
  1227. nr_per_bin = int(np.floor(min_count / self.nr_folds))
  1228. elif method == "k-fold":
  1229. raise NotImplementedError(
  1230. "method='k-fold' is planned for a future release and is "
  1231. "not yet functional. Use the default 'Foster' method."
  1232. )
  1233. return nr_per_bin
  1234. def extract_power(
  1235. self, x: np.ndarray, band: Union[str, list, tuple, None] = None
  1236. ) -> np.ndarray:
  1237. """
  1238. Extract power or envelope from signal data.
  1239. Computes power by squaring the complex magnitude of the analytic
  1240. signal for filtered data, computes envelope via Hilbert
  1241. transform for broadband data, or returns the original signal
  1242. unchanged.
  1243. Parameters
  1244. ----------
  1245. x : np.ndarray
  1246. Complex analytic signal from Hilbert transform
  1247. (filtered data) or raw voltage data (broadband).
  1248. band : str, list, tuple, or None, default=None
  1249. Frequency band specification:
  1250. - list/tuple: Apply power extraction (|x|²) for filtered
  1251. data
  1252. - 'broadband': Compute envelope |Hilbert(x)| after trial
  1253. averaging for phase-locked amplitude
  1254. - str or None: Return original signal without transformation
  1255. Returns
  1256. -------
  1257. np.ndarray
  1258. Power signal (|x|²) if band is list/tuple,
  1259. envelope if band is 'broadband',
  1260. otherwise original signal unchanged.
  1261. """
  1262. if isinstance(band, (list, tuple)):
  1263. power = abs(x) ** 2
  1264. elif band == "broadband":
  1265. # Compute envelope of averaged signal (after averaging)
  1266. power = np.abs(hilbert(x, axis=-1))
  1267. else:
  1268. power = x
  1269. return power
  1270. def tfr_decomposition(
  1271. self, epochs: mne.Epochs, band: Union[tuple, str], tois: slice, downsample: int
  1272. ) -> Tuple[np.ndarray, np.ndarray]:
  1273. """
  1274. Perform time-frequency decomposition for CTF analysis.
  1275. Isolates frequency-specific activity using Butterworth filtering
  1276. followed by Hilbert transform, or processes broadband signals.
  1277. Extracts both evoked and total power representations for
  1278. subsequent encoding model analysis.
  1279. Parameters
  1280. ----------
  1281. epochs : mne.Epochs
  1282. Epochs object containing EEG data for time-frequency
  1283. analysis.
  1284. band : tuple or str
  1285. Frequency specification:
  1286. - tuple: (low_freq, high_freq) for bandpass filtering
  1287. - 'broadband': Use unfiltered broadband signal
  1288. tois : slice
  1289. Time window of interest slice object for data extraction.
  1290. downsample : int
  1291. Downsampling factor applied after filtering to reduce data
  1292. size.
  1293. Returns
  1294. -------
  1295. E : np.ndarray
  1296. For filtered data: Complex analytic signal from Hilbert
  1297. transform, shape (trials, channels, time). Used for evoked
  1298. power calculation: activity phase-locked to stimulus onset
  1299. (computed by averaging complex signal across trials,
  1300. then squaring magnitude).
  1301. For broadband: Raw baseline-corrected voltages,
  1302. shape (trials, channels, time). Envelope will be computed
  1303. AFTER trial averaging via extract_power() for cleaner
  1304. phase-locked amplitude estimation.
  1305. T : np.ndarray
  1306. For filtered data: Total power signal, shape (trials, channels,
  1307. time). Represents ongoing activity irrespective of phase
  1308. relationship to stimulus onset (computed by squaring
  1309. magnitude of complex signal, then averaging across trials).
  1310. For broadband: Raw baseline-corrected voltages,
  1311. shape (trials, channels, time). Preserves phase-locked
  1312. activity and polarity information for ERP analysis.
  1313. Notes
  1314. -----
  1315. The decomposition pipeline includes:
  1316. **For filtered data (band = tuple):**
  1317. 1. **Frequency Filtering**: 5th-order Butterworth bandpass
  1318. filter
  1319. 2. **Hilbert Transform**: Extract complex analytic signal for
  1320. phase information
  1321. 3. **Power Extraction**: Compute |signal|² for total power
  1322. 4. **Time Windowing**: Extract specified time window of interest
  1323. 5. **Downsampling**: Reduce temporal resolution to target
  1324. sampling rate
  1325. **For broadband analysis (band = 'broadband'):**
  1326. 1. **Baseline Correction**: Apply baseline normalization
  1327. 2. **Data Storage**:
  1328. - Both T and E contain raw voltages initially
  1329. - Envelope for E is computed AFTER trial averaging
  1330. (in extract_power) to capture phase-locked amplitude
  1331. 3. **Time Windowing**: Extract specified time window of interest
  1332. 4. **Downsampling**: Reduce temporal resolution to target
  1333. sampling rate
  1334. """
  1335. # initiate arrays for evoked and total power
  1336. _, nr_chan, nr_time = epochs._data.shape
  1337. # extract power using hilbert or wavelet convolution
  1338. if isinstance(band, (list, tuple)):
  1339. epochs.filter(
  1340. band[0], band[1], method="iir", iir_params=dict(ftype="butterworth", order=5)
  1341. )
  1342. epochs.apply_hilbert()
  1343. E = epochs._data
  1344. T = self.extract_power(E, band)
  1345. elif band == "broadband":
  1346. epochs.apply_baseline(baseline=self.baseline)
  1347. E = epochs._data
  1348. T = epochs._data
  1349. # trim filtered data (after filtering to avoid artifacts)
  1350. E = E[:, :, tois]
  1351. T = T[:, :, tois]
  1352. # downsample (naive strided slicing, no anti-aliasing filter --
  1353. # see KNOWN_LIMITATIONS.md)
  1354. E = E[:, :, ::downsample]
  1355. T = T[:, :, ::downsample]
  1356. return E, T
  1357. def forward_model(
  1358. self, train_X: np.array, test_X: np.array, C1: np.array
  1359. ) -> Tuple[np.array, np.array]:
  1360. """
  1361. Apply inverted encoding model (IEM) to reconstruct spatial
  1362. channels.
  1363. Implements a two-stage process to learn and apply spatial
  1364. encoding models: first training weights to map from hypothetical
  1365. channel responses to observed electrode patterns, then inverting
  1366. the model to reconstruct channel responses from test data.
  1367. Parameters
  1368. ----------
  1369. train_X : np.ndarray
  1370. Training EEG data with shape (epochs, electrodes, samples).
  1371. If 3D, samples are averaged to create (epochs, electrodes).
  1372. test_X : np.ndarray
  1373. Test EEG data with same structure as train_X.
  1374. C1 : np.ndarray
  1375. Basis function matrix with shape (channels, spatial_bins).
  1376. Represents hypothetical channel responses for training.
  1377. Returns
  1378. -------
  1379. tuple[np.ndarray, np.ndarray]
  1380. C2s : np.ndarray
  1381. Reconstructed channel responses aligned to common
  1382. spatial reference frame, shape (epochs, channels).
  1383. W : np.ndarray
  1384. Learned weight matrix mapping channels to electrodes,
  1385. shape (channels, electrodes).
  1386. Notes
  1387. -----
  1388. The IEM procedure follows two stages:
  1389. **Training Stage:**
  1390. Estimates weights W using: B1 = W @ C1
  1391. - B1: Training electrode data (electrodes x epochs)
  1392. - C1: Predicted channel responses (channels x spatial_bins)
  1393. - W: Weight matrix (channels x electrodes)
  1394. **Testing Stage:**
  1395. Reconstructs channel responses: C2 = (W^T)^-1 @ B2
  1396. - B2: Test electrode data
  1397. - C2: Reconstructed channel responses
  1398. **Spatial Alignment:**
  1399. Channel responses are circularly shifted to align with a common
  1400. spatial reference frame, enabling averaging across trials with
  1401. different target locations.
  1402. Optional PCA dimensionality reduction is applied if specified
  1403. during initialization to reduce electrode space complexity.
  1404. Examples
  1405. --------
  1406. Apply forward model to single time point:
  1407. >>> # Prepare data and basis functions
  1408. >>> C1 = ctf.calculate_basis_set(8, 8) # 8 channels, 8 positions
  1409. >>>
  1410. >>> # Apply encoding model
  1411. >>> C2_reconstructed, weights = ctf.forward_model(
  1412. ... train_data, test_data, C1)
  1413. """
  1414. # check input shapes and adjust data shapes if needed
  1415. if train_X.ndim == 3:
  1416. train_X = train_X.mean(axis=-1)
  1417. test_X = test_X.mean(axis=-1)
  1418. # apply forward model
  1419. B1 = train_X
  1420. B2 = test_X
  1421. if self.pca_cmp:
  1422. pca = PCA(n_components=self.pca_cmp, svd_solver="full").fit(B1)
  1423. B1 = pca.transform(B1)
  1424. B2 = pca.transform(B2)
  1425. # estimate weight matrix W (nr_chans x nr_electrodes)
  1426. W, resid_w, rank_w, s_w = np.linalg.lstsq(C1, B1, rcond=-1)
  1427. # estimate channel response C2 (nr_chans x nr test blocks)
  1428. C2, resid_c, rank_c, s_c = np.linalg.lstsq(W.T, B2.T, rcond=-1)
  1429. # TRANSPOSE C2 so that we average across channels
  1430. # rather than across position bins
  1431. C2 = C2.T
  1432. C2s = np.zeros(C2.shape)
  1433. # shift tunings to common center
  1434. nr_2_shift = int(np.ceil(C2.shape[1] / 2.0))
  1435. for i in range(C2.shape[0]):
  1436. shift = i - nr_2_shift
  1437. if self.nr_bins % 2 == 0:
  1438. C2s[i, :] = np.roll(C2[i, :], -shift)
  1439. else:
  1440. C2s[i, :] = np.roll(C2[i, :], -shift - 1)
  1441. # shift the predicted channel responses
  1442. # to a common spatial reference frame
  1443. C2s = np.roll(C2s, shift=self.shift_bins, axis=0)
  1444. W = np.roll(W, shift=self.shift_bins, axis=0)
  1445. return C2s, W
  1446. def forward_model_loop(
  1447. self,
  1448. E_train: np.ndarray,
  1449. E_test: np.ndarray,
  1450. T_train: np.ndarray,
  1451. T_test: np.ndarray,
  1452. C1: np.ndarray,
  1453. GAT: bool,
  1454. ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
  1455. """
  1456. Apply inverted encoding model across all time points.
  1457. Performs forward model analysis for each time sample, with
  1458. option for Generalization Across Time (GAT) analysis that tests
  1459. all train/test time combinations or standard diagonal analysis.
  1460. Parameters
  1461. ----------
  1462. E_train : np.ndarray
  1463. Training data with evoked power, shape (trials, channels,
  1464. time).
  1465. E_test : np.ndarray
  1466. Test data with evoked power, shape (trials, channels, time).
  1467. T_train : np.ndarray
  1468. Training data with total power, shape (trials, channels,
  1469. time).
  1470. T_test : np.ndarray
  1471. Test data with total power, shape (trials, channels, time).
  1472. C1 : np.ndarray
  1473. Spatial basis function matrix, shape (channels,
  1474. spatial_bins).
  1475. GAT : bool
  1476. Whether to perform Generalization Across Time analysis:
  1477. - True: Test all train/test time combinations (full matrix)
  1478. - False: Test only matching time points (diagonal only)
  1479. Returns
  1480. -------
  1481. C2_E : np.ndarray
  1482. Reconstructed channel responses from evoked power analysis.
  1483. Shape depends on GAT: (time, bins, channels) or
  1484. (time, time, bins, channels).
  1485. W_E : np.ndarray
  1486. Weight matrices from evoked power analysis.
  1487. Shape depends on GAT: (time, bins, electrodes) or
  1488. (time, time, bins, electrodes).
  1489. C2_T : np.ndarray
  1490. Reconstructed channel responses from total power analysis.
  1491. Same shape structure as C2_E.
  1492. W_T : np.ndarray
  1493. Weight matrices from total power analysis.
  1494. Same shape structure as W_E.
  1495. Notes
  1496. -----
  1497. This function applies the forward model (see `forward_model`
  1498. method) to every time point in the analysis window. For GAT
  1499. analysis, it creates a full time x time matrix showing how well
  1500. models trained at each timepoint generalize to every other time
  1501. point.
  1502. """
  1503. # set necessary parameters
  1504. slide = 1 + self.slide_wind
  1505. nr_bins = self.nr_bins
  1506. _, nr_elec, nr_samples_tr = E_train.shape
  1507. nr_samples_tr -= self.slide_wind
  1508. if GAT:
  1509. nr_samples_te = E_test.shape[-1] - self.slide_wind
  1510. else:
  1511. nr_samples_te = 1
  1512. # initialize output arrays
  1513. C2_E = np.zeros((nr_samples_tr, nr_samples_te, nr_bins, self.nr_chans))
  1514. W_E = np.zeros((nr_samples_tr, nr_samples_te, nr_bins, nr_elec))
  1515. C2_T, W_T = C2_E.copy(), W_E.copy()
  1516. for tr_t in range(nr_samples_tr):
  1517. for te_t in range(nr_samples_te):
  1518. if not GAT:
  1519. te_t = tr_t
  1520. # evoked power model fit
  1521. c2_e, w_e = self.forward_model(
  1522. E_train[..., tr_t : tr_t + slide], E_test[..., te_t : te_t + slide], C1
  1523. )
  1524. # total power model fit
  1525. c2_t, w_t = self.forward_model(
  1526. T_train[..., tr_t : tr_t + slide], T_test[..., te_t : te_t + slide], C1
  1527. )
  1528. if not GAT:
  1529. te_t = 0
  1530. C2_E[tr_t, te_t] = c2_e
  1531. W_E[tr_t, te_t, :, : w_e.shape[1]] = w_e
  1532. C2_T[tr_t, te_t] = c2_t
  1533. W_T[tr_t, te_t, :, : w_t.shape[1]] = w_t
  1534. C2_E = np.squeeze(C2_E)
  1535. W_E = np.squeeze(W_E)
  1536. C2_T = np.squeeze(C2_T)
  1537. W_T = np.squeeze(W_T)
  1538. return C2_E, W_E, C2_T, W_T
  1539. def train_test_cross(
  1540. self,
  1541. pos_bins: np.ndarray,
  1542. train_idx: np.ndarray,
  1543. test_idx: np.ndarray,
  1544. nr_iter: int,
  1545. trial_limit: Optional[int] = None,
  1546. ) -> Tuple[np.ndarray, np.ndarray]:
  1547. """
  1548. Create balanced train/test indices for cross-condition analysis.
  1549. Generates balanced trial indices for training and testing across
  1550. different experimental conditions, ensuring equal representation
  1551. of position bins in both training and test sets.
  1552. Parameters
  1553. ----------
  1554. pos_bins : np.ndarray
  1555. Array of position bin labels for all trials.
  1556. train_idx : np.ndarray
  1557. Boolean or integer indices specifying trials available for
  1558. training.
  1559. test_idx : np.ndarray
  1560. Boolean or integer indices specifying trials available for
  1561. testing.
  1562. nr_iter : int
  1563. Number of cross-validation iterations to generate.
  1564. trial_limit : int, optional
  1565. Maximum number of trials per bin to use. Applied when
  1566. train/test conditions overlap to prevent data leakage.
  1567. Returns
  1568. -------
  1569. train_idx : np.ndarray
  1570. Balanced training indices,
  1571. shape (iterations, bins, folds, trials_per_bin).
  1572. test_idx : np.ndarray
  1573. Balanced test indices,
  1574. shape (iterations, bins, 1, trials_per_bin).
  1575. Notes
  1576. -----
  1577. This function ensures statistical validity by:
  1578. 1. **Balanced Sampling**: Equal trial counts across position
  1579. bins
  1580. 2. **Cross-Validation**: Splits training data into multiple
  1581. folds
  1582. 3. **Data Leakage Prevention**: Removes overlapping trials when
  1583. trial_limit specified
  1584. 4. **Random Sampling**: Uses class seed for reproducible trial
  1585. selection
  1586. """
  1587. if self.seed:
  1588. np.random.seed(self.seed) # set seed
  1589. # select training data (ensure that number of bins is balanced)
  1590. train_bins = pos_bins[train_idx]
  1591. train_idx = np.arange(pos_bins.size)[train_idx]
  1592. bins, counts = np.unique(train_bins, return_counts=True)
  1593. min_obs = min(counts)
  1594. # Apply trial limit if specified (for overlapping train/test cnds)
  1595. if trial_limit is not None:
  1596. min_obs = min(min_obs, trial_limit)
  1597. train_idx = [np.random.choice(train_idx[train_bins == b], min_obs, False) for b in bins]
  1598. # randomly split training blocks in half for each iteration
  1599. train_idx = np.stack(train_idx)[:, None, :]
  1600. split_arrays = []
  1601. if train_idx.shape[-1] % 2 == 0:
  1602. to_split = train_idx.shape[-1]
  1603. else:
  1604. to_split = train_idx.shape[-1] - 1
  1605. split = to_split // 2
  1606. for i in range(nr_iter):
  1607. split_idx = np.random.permutation(train_idx.shape[-1])[: split * 2]
  1608. splitted = np.split(train_idx[..., split_idx], [split], axis=-1)
  1609. split_arrays.append(np.concatenate(splitted, axis=1))
  1610. train_idx = np.stack(split_arrays)
  1611. # now create test set per iteration
  1612. test_bins = pos_bins[test_idx]
  1613. idx = np.arange(pos_bins.size)[test_idx]
  1614. test_idx = []
  1615. for itr in range(nr_iter):
  1616. if trial_limit is not None:
  1617. # get selected train indices and remove from test set
  1618. used_train_indices = train_idx[itr].flatten()
  1619. available_idx = np.setdiff1d(idx, used_train_indices)
  1620. available_test_bins = pos_bins[available_idx]
  1621. else:
  1622. available_idx = idx
  1623. available_test_bins = test_bins
  1624. bins, counts = np.unique(available_test_bins, return_counts=True)
  1625. min_obs_test = min(counts)
  1626. # handle case where test bins do not match all train bins
  1627. train_bins_unique = np.unique(train_bins)
  1628. if bins.size == train_bins_unique.size:
  1629. # all bins present - simple case
  1630. selected_test_idx = [
  1631. np.random.choice(available_idx[available_test_bins == b], min_obs_test, False)
  1632. for b in bins
  1633. ]
  1634. selected_test_idx = np.array(selected_test_idx)
  1635. else:
  1636. # some bins missing - need to pad with zeros
  1637. selected_test_idx = np.zeros((train_bins_unique.size, min_obs_test), dtype=int)
  1638. bin_cnt = 0
  1639. for bin_idx, bin_val in enumerate(train_bins_unique):
  1640. if bin_val in bins:
  1641. mask = available_test_bins == bin_val
  1642. selected_test_idx[bin_idx] = np.random.choice(
  1643. available_idx[mask], min_obs_test, False
  1644. )
  1645. bin_cnt += 1
  1646. test_idx.append(np.array(selected_test_idx)[:, None, :])
  1647. test_idx = np.stack(test_idx)
  1648. return train_idx, test_idx
  1649. def train_test_split(
  1650. self, pos_bins: np.ndarray, cnd_idx: np.ndarray, max_tr: int
  1651. ) -> Tuple[np.ndarray, np.ndarray]:
  1652. """
  1653. Create random block assignment for within-condition
  1654. cross-validation.
  1655. Generates randomized train/test splits within the same
  1656. experimental condition using k-fold cross-validation approach
  1657. with balanced position bins.
  1658. Parameters
  1659. ----------
  1660. pos_bins : np.ndarray
  1661. Array of position bin labels for all trials.
  1662. cnd_idx : np.ndarray
  1663. Boolean or integer indices specifying trials from the target
  1664. condition.
  1665. max_tr : int
  1666. Maximum number of trials per position bin to use in
  1667. analysis.
  1668. Returns
  1669. -------
  1670. train_idx : np.ndarray
  1671. Training indices with
  1672. shape (iterations x folds, bins, folds-1, trials_per_block).
  1673. Contains trial indices for training across all
  1674. cross-validation folds.
  1675. test_idx : np.ndarray
  1676. Test indices with
  1677. shape (iterations x folds, bins, trials_per_block).
  1678. Contains trial indices for testing in each cross-validation
  1679. fold.
  1680. Notes
  1681. -----
  1682. This function implements k-fold cross-validation within a single
  1683. condition:
  1684. 1. **Random Block Assignment**: Trials randomly assigned to k
  1685. folds
  1686. 2. **Balanced Bins**: Equal trials per position bin across folds
  1687. 3. **Cross-Validation**: Each fold serves as test set once
  1688. 4. **Multiple Iterations**: Process repeated for robust
  1689. estimates
  1690. The block assignment ensures that each position bin has equal
  1691. representation in training and test sets across all
  1692. cross-validation iterations.
  1693. """
  1694. if self.seed:
  1695. np.random.seed(self.seed) # set seed
  1696. # get trial count and condition indices
  1697. cnd_bins = pos_bins[cnd_idx]
  1698. nr_tr = cnd_bins.size
  1699. trial_idx = np.arange(pos_bins.size)[cnd_idx]
  1700. # initiate array
  1701. bl_assign = np.zeros((self.nr_iter, nr_tr))
  1702. # loop over iterations
  1703. for i in range(self.nr_iter):
  1704. # initiate new array for each block assignment
  1705. blocks = np.full(nr_tr, np.nan)
  1706. shuf_blocks = np.full(nr_tr, np.nan)
  1707. idx_shuf = np.random.permutation(nr_tr)
  1708. shuf_bin = cnd_bins[idx_shuf]
  1709. # take the 1st max_tr x nr_blocks trials for each position bin
  1710. for bin in range(self.nr_bins):
  1711. idx = np.where(shuf_bin == bin)[0]
  1712. idx = idx[: max_tr * self.nr_folds]
  1713. x = np.tile(np.arange(self.nr_folds), (max_tr, 1))
  1714. shuf_blocks.flat[idx] = x
  1715. # unshuffle block assignment and save to CTF
  1716. blocks[idx_shuf] = shuf_blocks
  1717. bl_assign[i] = blocks
  1718. tr_per_block = int(sum(blocks == 0) / self.nr_bins)
  1719. # after block assignment split into train and test set
  1720. train_idx = np.zeros(
  1721. (self.nr_iter * self.nr_folds, self.nr_bins, self.nr_folds - 1, tr_per_block), dtype=int
  1722. )
  1723. test_idx = np.zeros((self.nr_iter * self.nr_folds, self.nr_bins, tr_per_block), dtype=int)
  1724. idx = 0
  1725. for i in range(self.nr_iter):
  1726. for bl in range(self.nr_folds):
  1727. for bin in range(self.nr_bins):
  1728. test_mask = (bl_assign[i] == bl) * (cnd_bins == bin)
  1729. test_idx[idx, bin] = trial_idx[test_mask]
  1730. train_mask = (
  1731. (~np.isnan(bl_assign[i])) * (bl_assign[i] != bl) * (cnd_bins == bin)
  1732. )
  1733. # split all train data into seperate train blocks
  1734. train = np.array_split(trial_idx[train_mask], self.nr_folds - 1)
  1735. for j in range(self.nr_folds - 1):
  1736. train_idx[idx, bin, j] = train[j]
  1737. idx += 1
  1738. return train_idx, test_idx
  1739. def set_frequencies(self, freqs: Union[str, dict]) -> Tuple[list, int]:
  1740. """
  1741. Configure frequency bands for CTF analysis.
  1742. Processes frequency specification to create standardized
  1743. frequency list for time-frequency decomposition or broadband
  1744. analysis.
  1745. Parameters
  1746. ----------
  1747. freqs : str or dict
  1748. Frequency specification for analysis:
  1749. - 'main_param': Use class initialization parameters
  1750. (min_freq, max_freq, num_frex, freq_scaling) to create
  1751. frequency bands
  1752. - 'broadband': Use unfiltered broadband signal
  1753. - dict: Custom frequency bands as
  1754. {band_name: [low_freq, high_freq]}
  1755. Returns
  1756. -------
  1757. frex : list
  1758. List of frequency specifications:
  1759. - For frequency bands: list of sorted (low_freq, high_freq)
  1760. tuples
  1761. - For broadband: list containing single 'broadband' string
  1762. nr_frex : int
  1763. Number of frequency bands to process.
  1764. bands : list or None
  1765. List of sorted band names if custom dict provided,
  1766. otherwise None.
  1767. Raises
  1768. ------
  1769. ValueError
  1770. If freqs is a string but not one of the valid options
  1771. ('main_param' or 'broadband').
  1772. """
  1773. bands = None
  1774. if freqs == "main_param":
  1775. if self.freq_scaling == "log":
  1776. frex = np.logspace(np.log10(self.min_freq), np.log10(self.max_freq), self.num_frex)
  1777. elif self.freq_scaling == "linear":
  1778. frex = np.linspace(self.min_freq, self.max_freq, self.num_frex)
  1779. frex = [(frex[i], frex[i + 1]) for i in range(self.num_frex - 1)]
  1780. elif isinstance(freqs, dict):
  1781. frex = [
  1782. (freqs[band][0], freqs[band][1])
  1783. for band in sorted(freqs.keys(), key=lambda k: freqs[k][0])
  1784. ]
  1785. bands = sorted(freqs.keys(), key=lambda k: freqs[k][0])
  1786. elif freqs == "broadband":
  1787. frex = [freqs]
  1788. elif isinstance(freqs, str):
  1789. # Invalid string option
  1790. raise ValueError(
  1791. f"Invalid frequency specification '{freqs}'. "
  1792. "String must be either 'main_param' or 'broadband'. "
  1793. "For custom bands, pass a dictionary."
  1794. )
  1795. else:
  1796. raise ValueError(
  1797. f"Invalid frequency specification type: {type(freqs)}. "
  1798. "Expected str ('main_param', 'broadband') or dict."
  1799. )
  1800. nr_frex = len(frex)
  1801. return frex, nr_frex, bands
  1802. def localizer_spatial_ctf(
  1803. self,
  1804. pos_labels_tr: dict = "all",
  1805. pos_labels_te: dict = "all",
  1806. freqs: dict = "main_param",
  1807. te_cnds: dict = None,
  1808. te_header: str = None,
  1809. window_oi_tr: tuple = None,
  1810. window_oi_te: tuple = None,
  1811. excl_factor_tr: dict = None,
  1812. excl_factor_te: dict = None,
  1813. downsample: int = 1,
  1814. nr_perm: int = 0,
  1815. GAT: bool = False,
  1816. f_name: str = None,
  1817. ):
  1818. raise NotImplementedError(
  1819. "localizer_spatial_ctf is experimental and not yet validated "
  1820. "for release -- not covered by the accompanying paper or "
  1821. "tutorials, and CTF.__init__ does not currently support the "
  1822. "list-based epochs/df it requires. Planned for a future "
  1823. "release. Use spatial_ctf() for the validated pipeline."
  1824. )
  1825. if self.method == "k-fold":
  1826. raise NotImplementedError(
  1827. "method='k-fold' is planned for a future release and is "
  1828. "not yet functional. Use the default 'Foster' method."
  1829. )
  1830. if nr_perm > 0:
  1831. raise NotImplementedError(
  1832. "localizer_spatial_ctf does not implement permutation "
  1833. "testing yet (nr_perm > 0 would silently produce "
  1834. "zero-filled, misleading 'null' output). Use nr_perm=0, "
  1835. "or use spatial_ctf, which does support permutation "
  1836. "testing."
  1837. )
  1838. # set train and test data
  1839. epochs_tr, df_tr = self.select_ctf_data(
  1840. self.epochs[0].copy(), self.df[0].copy(), self.elec_oi, excl_factor_tr
  1841. )
  1842. epochs_te, df_te = self.select_ctf_data(
  1843. self.epochs[1].copy(), self.df[1].copy(), self.elec_oi, excl_factor_te
  1844. )
  1845. if window_oi_tr is None:
  1846. window_oi_tr = (epochs_tr.tmin, epochs_tr.tmax)
  1847. if window_oi_te is None:
  1848. window_oi_te = window_oi_tr
  1849. nr_itr = 1
  1850. # Always create ctf_name for reporting; use f_name if provided
  1851. if f_name is not None:
  1852. ctf_name = f"sub_{self.sj}_{f_name}"
  1853. else:
  1854. ctf_name = f"sub_{self.sj}_loc_ctf"
  1855. nr_perm += 1
  1856. nr_elec = len(epochs_tr.ch_names)
  1857. tois_tr = get_time_slice(epochs_tr.times, window_oi_tr[0], window_oi_tr[1])
  1858. tois_te = get_time_slice(epochs_te.times, window_oi_te[0], window_oi_te[1])
  1859. avg_tr = True if not GAT else False
  1860. GAT = True
  1861. ctf, info = {}, {}
  1862. freqs, nr_freqs, _ = self.set_frequencies(freqs)
  1863. if isinstance(te_cnds, dict):
  1864. ((_, test_cnds),) = te_cnds.items()
  1865. else:
  1866. test_cnds = ["all_data"]
  1867. # set train and test data
  1868. pos_bins_tr, _, epochs_tr, _ = self.select_ctf_labels(epochs_tr, df_tr, pos_labels_tr, None)
  1869. pos_bins_te, cnds, epochs_te, _ = self.select_ctf_labels(
  1870. epochs_te, df_te, pos_labels_te, te_cnds
  1871. )
  1872. # Frequency loop (ensures that data is only filtered once)
  1873. for fr in range(nr_freqs):
  1874. print("Frequency {} out of {}".format(str(fr + 1), str(nr_freqs)))
  1875. # Time-Frequency Analysis
  1876. E_tr, T_tr = self.tfr_decomposition(epochs_tr.copy(), freqs[fr], tois_tr, downsample)
  1877. nr_samp_tr = 1 if avg_tr else E_tr.shape[-1]
  1878. E_te, T_te = self.tfr_decomposition(epochs_te.copy(), freqs[fr], tois_te, downsample)
  1879. nr_samp_te = E_te.shape[-1]
  1880. # Loop over conditions
  1881. for c, cnd in enumerate(test_cnds):
  1882. print(f"Running localizer ctf for condition: {cnd} ")
  1883. # preallocate arrays
  1884. C2_E = np.zeros(
  1885. (nr_perm, nr_freqs, nr_samp_tr, nr_samp_te, self.nr_bins, self.nr_chans)
  1886. )
  1887. W_E = np.zeros((nr_perm, nr_freqs, nr_samp_tr, nr_samp_te, self.nr_chans, nr_elec))
  1888. C2_T, W_T = C2_E.copy(), W_E.copy()
  1889. # partition data into training and testing sets
  1890. # is done once to ensure each frequency has the same sets
  1891. if fr == 0:
  1892. # update ctf dicts to keep track of output
  1893. info.update({cnd: {}})
  1894. ctf.update({cnd: {"C2_E": C2_E, "C2_T": C2_T, "W_E": W_E, "W_T": W_T}})
  1895. # get train and test indices
  1896. idx = np.arange(pos_bins_tr.size)
  1897. train_idx, _ = self.train_test_cross(pos_bins_tr, idx, None, nr_itr)
  1898. idx = cnd == cnds
  1899. _, test_idx = self.train_test_cross(pos_bins_te, idx, idx, nr_itr)
  1900. test_bins = np.unique(pos_bins_te[test_idx])
  1901. info[cnd]["train_idx"] = train_idx
  1902. info[cnd]["test_idx"] = test_idx
  1903. if self.method == "Foster":
  1904. C1 = np.empty((self.nr_bins * self.nr_folds, self.nr_chans)) * np.nan
  1905. else:
  1906. C1 = self.basisset
  1907. # p is always 0: nr_perm > 0 is rejected above, and
  1908. # nr_itr is fixed to 1 for this function (see top)
  1909. p = 0
  1910. train_idx = info[cnd]["train_idx"][0]
  1911. # initialize evoked and total power arrays
  1912. bin_te_E = np.zeros((self.nr_bins, nr_elec, nr_samp_te))
  1913. bin_te_T = bin_te_E.copy()
  1914. if self.method == "k-fold":
  1915. pass # unreachable: localizer_spatial_ctf() raises before this point
  1916. elif self.method == "Foster":
  1917. nr_itr_tr = self.nr_bins * (self.nr_folds)
  1918. bin_tr_E = np.zeros((nr_itr_tr, nr_elec, nr_samp_tr))
  1919. bin_tr_T = bin_tr_E.copy()
  1920. # position bin loop
  1921. bin_cnt = 0
  1922. for bin in range(self.nr_bins):
  1923. if bin in test_bins:
  1924. bin_idx = np.where(bin == test_bins)[0]
  1925. test_idx = np.squeeze(info[cnd]["test_idx"][0][bin_idx])
  1926. bin_te_E[bin] = abs(np.mean(E_te[test_idx], axis=0)) ** 2
  1927. bin_te_T[bin] = np.mean(T_te[test_idx], axis=0)
  1928. if self.method == "Foster":
  1929. for j in range(self.nr_folds):
  1930. evoked = abs(np.mean(E_tr[train_idx[bin][j]], axis=0)) ** 2
  1931. total = np.mean(T_tr[train_idx[bin][j]], axis=0)
  1932. if avg_tr:
  1933. evoked = evoked.mean(axis=-1)[:, np.newaxis]
  1934. total = total.mean(axis=-1)[:, np.newaxis]
  1935. bin_tr_E[bin_cnt] = evoked
  1936. bin_tr_T[bin_cnt] = total
  1937. C1[bin_cnt] = self.basisset[bin]
  1938. bin_cnt += 1
  1939. elif self.method == "k-fold":
  1940. pass # unreachable: localizer_spatial_ctf() raises before this point
  1941. (
  1942. ctf[cnd]["C2_E"][p, fr],
  1943. ctf[cnd]["W_E"][p, fr],
  1944. ctf[cnd]["C2_T"][p, fr],
  1945. ctf[cnd]["W_T"][p, fr],
  1946. ) = self.forward_model_loop(bin_tr_E, bin_te_E, bin_tr_T, bin_te_T, C1, GAT)
  1947. # take the average across model iterations
  1948. for cnd in test_cnds:
  1949. if freqs == ["broadband"]:
  1950. # Rename to clarify what each represents in broadband
  1951. ctf[cnd]["C2_envelope"] = ctf[cnd].pop("C2_E")
  1952. ctf[cnd]["W_envelope"] = ctf[cnd].pop("W_E")
  1953. ctf[cnd]["C2_voltage"] = ctf[cnd].pop("C2_T")
  1954. ctf[cnd]["W_voltage"] = ctf[cnd].pop("W_T") # save output
  1955. # Always compute ctf_param if needed (independent of saving)
  1956. if self.ctf_param:
  1957. print("get ctf tuning params")
  1958. ctf_param = self.get_ctf_tuning_params(ctf, self.ctf_param, GAT=GAT, avg_ch=self.avg_ch)
  1959. else:
  1960. ctf_param = None
  1961. # Save outputs if f_name is provided
  1962. if f_name is not None:
  1963. with open(
  1964. self.folder_tracker(["ctf", self.to_decode], fname=f"{ctf_name}_ctf.pickle"), "wb"
  1965. ) as handle:
  1966. print("saving ctfs")
  1967. pickle.dump(ctf, handle)
  1968. with open(
  1969. self.folder_tracker(["ctf", self.to_decode], fname=f"{ctf_name}_info.pickle"), "wb"
  1970. ) as handle:
  1971. pickle.dump(info, handle)
  1972. if ctf_param is not None:
  1973. with open(
  1974. self.folder_tracker(["ctf", self.to_decode], fname=f"{ctf_name}_param.pickle"),
  1975. "wb",
  1976. ) as handle:
  1977. print("saving ctf params")
  1978. pickle.dump(ctf_param, handle)
  1979. return ctf, ctf_param, info
  1980. def extract_slopes(self, X: np.array) -> float:
  1981. """
  1982. Calculate slope parameter from channel tuning function.
  1983. Extracts slope by collapsing symmetric points in the tuning
  1984. curve and fitting a linear trend from the periphery to the peak.
  1985. This provides a summary statistic of spatial selectivity
  1986. strength.
  1987. Parameters
  1988. ----------
  1989. X : np.ndarray
  1990. Reconstructed channel tuning function with shape
  1991. (nr_chans,). Represents channel responses across spatial
  1992. locations.
  1993. Returns
  1994. -------
  1995. float
  1996. Slope coefficient indicating tuning curve steepness. Higher
  1997. values indicate stronger spatial selectivity.
  1998. Notes
  1999. -----
  2000. The slope extraction process:
  2001. 1. **Symmetric Collapse**: Average responses at symmetric
  2002. spatial positions (e.g., position 1 with position 7 in
  2003. 8-position task)
  2004. 2. **Linear Fit**: Fit first-order polynomial to collapsed data
  2005. from tails to peak of tuning curve
  2006. 3. **Slope Extraction**: Return linear coefficient as
  2007. selectivity measure
  2008. This approach reduces noise by leveraging symmetry and provides
  2009. a single parameter characterizing spatial tuning strength.
  2010. Examples
  2011. --------
  2012. Extract slope from reconstructed CTF:
  2013. >>> # Calculate selectivity slope
  2014. >>> slope = ctf.extract_slopes(channel_response)
  2015. >>> print(f"Spatial selectivity: {slope:.3f}")
  2016. """
  2017. if self.nr_chans % 2 == 0:
  2018. steps = int(self.nr_chans / 2 + 1)
  2019. else:
  2020. steps = int(np.ceil(self.nr_chans / 2.0))
  2021. # collapse acrosssymmetric slope positions,
  2022. # eg (0, (45,315), (90,270), (135,225), 180)
  2023. if self.nr_chans % 2 == 0:
  2024. x = np.array([X[0]] + [np.mean((X[i], X[-i])) for i in range(1, steps)])
  2025. else:
  2026. x = np.array([np.mean((X[i], X[i + (-1 - 2 * i)])) for i in range(steps)])
  2027. slope = np.polyfit(range(1, len(x) + 1), x, 1)[0]
  2028. return slope
  2029. def extract_ctf_params(
  2030. self,
  2031. ctfs: np.array,
  2032. params: dict,
  2033. signal: str,
  2034. perm_idx: int,
  2035. ch_idx: int = None,
  2036. fitting_method: str = None,
  2037. ) -> dict:
  2038. """
  2039. Extract tuning parameters from CTF reconstructions.
  2040. Helper function that processes reconstructed channel tuning
  2041. functions to extract slope and optional cosine fitting
  2042. parameters.
  2043. Parameters
  2044. ----------
  2045. ctfs : np.ndarray
  2046. Reconstructed channel tuning functions with shape:
  2047. - 3D: (n_freqs, n_samples, n_spatial_bins) for standard
  2048. analysis
  2049. - 4D: (n_freqs, n_train_samples, n_test_samples,
  2050. n_spatial_bins) for Generalization Across Time (GAT)
  2051. analysis
  2052. params : dict
  2053. Pre-allocated parameter arrays to store results. Keys follow
  2054. pattern '{signal}_paramname' (e.g., 'E_slopes', 'T_amps').
  2055. signal : str
  2056. Signal type identifier: 'E', 'T' for filtered data;
  2057. 'envelope', 'voltage' for broadband data.
  2058. perm_idx : int
  2059. Permutation iteration index for parameter storage.
  2060. ch_idx : int, optional
  2061. Spatial channel index when processing individual channels
  2062. (avg_ch=False). If None, processes averaged channels.
  2063. fitting_method : str, optional
  2064. Method for curve fitting. Options:
  2065. - 'von_mises': Von Mises (circular cosine) fitting (default)
  2066. - 'gaussian': Gaussian bell curve fitting
  2067. Default is 'von_mises'.
  2068. Returns
  2069. -------
  2070. dict
  2071. Updated params dictionary with extracted tuning parameters.
  2072. Notes
  2073. -----
  2074. Extracts slope parameters via `extract_slopes()` and optional
  2075. curve fitting parameters (von Mises or Gaussian) when amplitude
  2076. keys are present in params dict.
  2077. """
  2078. # Determine if input is GAT (4D) or standard (3D)
  2079. if ctfs.ndim == 3:
  2080. nr_freqs, nr_samples_tr, nr_chan = ctfs.shape
  2081. nr_samples_te = 1
  2082. # insert new dimension so that indexing does not crash
  2083. ctfs = ctfs[..., np.newaxis, :]
  2084. else:
  2085. nr_freqs, nr_samples_tr, nr_samples_te, nr_chan = ctfs.shape
  2086. # Extract CTF parameters for each frequency and time point
  2087. for freq_idx in range(nr_freqs):
  2088. for train_sample in range(nr_samples_tr):
  2089. for test_sample in range(nr_samples_te):
  2090. # Extract current CTF for this time/frequency combination
  2091. current_ctf = ctfs[freq_idx, train_sample, test_sample]
  2092. # Calculate slope parameter
  2093. slope = self.extract_slopes(current_ctf)
  2094. if ch_idx is None:
  2095. params[f"{signal}_slopes"][
  2096. perm_idx, freq_idx, train_sample, test_sample
  2097. ] = slope
  2098. else:
  2099. params[f"{signal}_slopes"][
  2100. perm_idx, freq_idx, train_sample, test_sample, ch_idx
  2101. ] = slope
  2102. # Calculate curve fitting parameters
  2103. # if requested AND fitting method provided
  2104. if any([k for k in params.keys() if "amps" in k]) and fitting_method:
  2105. if fitting_method == "von_mises":
  2106. amplitude, baseline, conc, mean_loc, _ = self.fit_cos_to_ctf(
  2107. current_ctf
  2108. )
  2109. elif fitting_method == "gaussian":
  2110. gaussian_params = self.fit_gaussian(current_ctf)
  2111. amplitude, mean_loc, sigma = gaussian_params
  2112. # Gaussian doesn't have baseline offset
  2113. baseline = 0
  2114. # Convert sigma to concentration-like measure
  2115. conc = 1.0 / sigma
  2116. else:
  2117. raise ValueError(f"Unknown fitting method: " f"{fitting_method}")
  2118. if ch_idx is None:
  2119. params[f"{signal}_amps"][
  2120. perm_idx, freq_idx, train_sample, test_sample
  2121. ] = amplitude
  2122. params[f"{signal}_base"][
  2123. perm_idx, freq_idx, train_sample, test_sample
  2124. ] = baseline
  2125. params[f"{signal}_conc"][
  2126. perm_idx, freq_idx, train_sample, test_sample
  2127. ] = conc
  2128. params[f"{signal}_means"][
  2129. perm_idx, freq_idx, train_sample, test_sample
  2130. ] = mean_loc
  2131. else:
  2132. params[f"{signal}_amps"][
  2133. perm_idx, freq_idx, train_sample, test_sample, ch_idx
  2134. ] = amplitude
  2135. params[f"{signal}_base"][
  2136. perm_idx, freq_idx, train_sample, test_sample, ch_idx
  2137. ] = baseline
  2138. params[f"{signal}_conc"][
  2139. perm_idx, freq_idx, train_sample, test_sample, ch_idx
  2140. ] = conc
  2141. params[f"{signal}_means"][
  2142. perm_idx, freq_idx, train_sample, test_sample, ch_idx
  2143. ] = mean_loc
  2144. return params
  2145. def summarize_ctfs(
  2146. self,
  2147. ctfs: dict,
  2148. params: dict,
  2149. nr_samples: Union[int, tuple],
  2150. nr_freqs: int,
  2151. test_bins: np.array,
  2152. nr_perm: int = 1,
  2153. avg_ch: bool = True,
  2154. fitting_method: str = None,
  2155. ) -> dict:
  2156. """
  2157. Extract tuning parameters from CTF reconstructions across
  2158. conditions and permutations.
  2159. Orchestrates the extraction of tuning parameters (slopes,
  2160. amplitudes, etc.) from channel tuning functions for multiple
  2161. signals, permutations, and spatial channels.Handles both
  2162. standard CTF analysis and Generalization Across Time (GAT)
  2163. analysis.
  2164. Parameters
  2165. ----------
  2166. ctfs : dict
  2167. Condition-specific CTF reconstructions with keys like
  2168. 'C2_T', 'C2_E' containing arrays of shape (n_perm, n_freqs,
  2169. n_samples, n_channels) for standard analysis
  2170. or (n_perm, n_freqs, n_train_samples, n_test_samples,
  2171. n_channels) for GAT.
  2172. params : dict
  2173. Pre-initialized output dictionary with arrays for storing
  2174. tuning parameters. Keys follow pattern
  2175. '{signal}_{parameter}' (e.g., 'T_slopes', 'E_amps').
  2176. nr_samples : Union[int, tuple]
  2177. Number of time samples. Int for standard analysis,
  2178. tuple (n_train, n_test) for GAT analysis.
  2179. nr_freqs : int
  2180. Number of independent frequencies in the CTF analysis.
  2181. test_bins : np.array
  2182. Spatial bin indices actually used for CTF fitting. Allows
  2183. analysis of subset of spatial locations.
  2184. nr_perm : int, optional
  2185. Number of permutations. Default is 1 (no permutation).
  2186. avg_ch : bool, optional
  2187. Whether to average CTFs across channels (True) or analyze
  2188. each channel individually (False). Default is True.
  2189. fitting_method : str, optional
  2190. Method for curve fitting. Options:
  2191. - 'von_mises': Von Mises (circular cosine) fitting (default)
  2192. - 'gaussian': Gaussian bell curve fitting
  2193. Default is 'von_mises'.
  2194. Returns
  2195. -------
  2196. dict
  2197. Updated params dictionary with extracted tuning parameters
  2198. filled in.
  2199. """
  2200. # check whether ctfs contains matrix of train and test samples
  2201. GAT = True if isinstance(nr_samples, tuple) else False
  2202. # infer datatypes to extract
  2203. signals = np.unique([key.split("_")[0] for key in params.keys()])
  2204. for signal in signals:
  2205. # get params for each permutation
  2206. for perm_idx in range(nr_perm):
  2207. signal_ctfs = ctfs[f"C2_{signal}"][perm_idx]
  2208. if avg_ch:
  2209. # check whether ctfs contains unfitted bins
  2210. if test_bins.size < self.nr_bins:
  2211. signal_ctfs = signal_ctfs[:, :, test_bins.astype(int)]
  2212. signal_ctfs = signal_ctfs.mean(axis=-2)
  2213. params = self.extract_ctf_params(
  2214. signal_ctfs, params, signal, perm_idx, fitting_method=fitting_method
  2215. )
  2216. else:
  2217. nr_chans = signal_ctfs.shape[-2]
  2218. for channel_idx in range(nr_chans):
  2219. if GAT:
  2220. ch_ctfs = signal_ctfs[:, :, :, channel_idx]
  2221. else:
  2222. ch_ctfs = signal_ctfs[:, :, channel_idx]
  2223. if np.all(ch_ctfs == 0):
  2224. continue
  2225. params = self.extract_ctf_params(
  2226. ch_ctfs, params, signal, perm_idx, channel_idx, fitting_method
  2227. )
  2228. return params
  2229. def get_ctf_tuning_params(
  2230. self,
  2231. ctfs: dict,
  2232. ctf_param: str = "slopes",
  2233. GAT: bool = False,
  2234. avg_ch: bool = True,
  2235. test_bins: np.array = None,
  2236. ) -> dict:
  2237. """
  2238. Extract tuning parameters from channel tuning functions across
  2239. experimental conditions.
  2240. Main interface for quantifying CTF reconstructions by extracting
  2241. tuning parameters such as slopes and curve fitting parameters
  2242. (amplitude, baseline, concentration, mean location).
  2243. Orchestrates the analysis pipeline through summarize_ctfs() and
  2244. extract_ctf_params() helper functions. Supports both von Mises
  2245. and Gaussian curve fitting. Automatically detects data type
  2246. (power vs broadband) from available CTF keys.
  2247. Parameters
  2248. ----------
  2249. ctfs : dict
  2250. CTF reconstructions per condition as returned by
  2251. spatial_ctf() and localizer_spatial_ctf(). Keys are
  2252. condition names, values contain 'C2_T', 'C2_E'
  2253. (for filtered power data) or 'C2_envelope'/'C2_voltage'
  2254. (for broadband data).
  2255. Data type is automatically inferred from available keys.
  2256. ctf_param : str, optional
  2257. Parameter extraction method. Options:
  2258. - 'slopes': Extract only slope parameters (fastest)
  2259. - 'von_mises': Extract slopes + von Mises fitting parameters
  2260. (amplitude, baseline, concentration, mean location)
  2261. - 'gaussian': Extract slopes + Gaussian fitting parameters
  2262. (amplitude, mean, sigma, with derived measures)
  2263. Default is 'slopes'.
  2264. GAT : bool, optional
  2265. Whether analysis uses Generalization Across Time with
  2266. independent training and testing timepoints. When True,
  2267. expects 4D arrays(n_freqs, n_train_samples, n_test_samples,
  2268. n_channels). Default is False.
  2269. avg_ch : bool, optional
  2270. Whether to average CTFs across spatial channels (True) or
  2271. analyze each channel individually (False). Default is True.
  2272. test_bins : np.array, optional
  2273. Spatial bin indices to include in analysis. If None, uses
  2274. all available spatial bins. Default is None.
  2275. Returns
  2276. -------
  2277. dict
  2278. Nested dictionary with structure
  2279. {condition: {parameter: values}}. Parameter keys follow
  2280. pattern '{signal}_{metric}' where:
  2281. - signal: 'T'/'E' (filtered power), 'envelope'/'voltage'
  2282. (broadband)
  2283. - metric: 'slopes', 'amps', 'base', 'conc', 'means'
  2284. Values are arrays with dimensions squeezed to remove
  2285. singleton axes (except for frequency ).
  2286. """
  2287. # determine the output parameters
  2288. if test_bins is None:
  2289. test_bins = np.arange(self.nr_bins)
  2290. # infer data type from available keys
  2291. sample_ctf = ctfs[list(ctfs.keys())[0]]
  2292. if "C2_envelope" in sample_ctf:
  2293. signals = ["envelope", "voltage"]
  2294. data_key = "C2_envelope"
  2295. else:
  2296. signals = ["T", "E"]
  2297. data_key = "C2_E"
  2298. output_params = []
  2299. output_params += [f"{signal}_slopes" for signal in signals]
  2300. # Determine fitting method and whether to extract curve fitting params
  2301. if ctf_param in ["von_mises", "gaussian"]:
  2302. fitting_method = ctf_param
  2303. tuning_params = ["amps", "base", "conc", "means"]
  2304. output_params += [f"{signal}_{param}" for param in tuning_params for signal in signals]
  2305. else:
  2306. fitting_method = None # Only slopes, no curve fitting
  2307. # initiate output dict
  2308. ctf_param = {}
  2309. data = sample_ctf[data_key]
  2310. if GAT:
  2311. nr_perm, nr_freqs, nr_train_samples, nr_test_samples = list(data.shape)[:4]
  2312. output = np.zeros((nr_perm, nr_freqs, nr_train_samples, nr_test_samples))
  2313. nr_samples = (nr_train_samples, nr_test_samples)
  2314. else:
  2315. nr_perm, nr_freqs, nr_samples = list(data.shape)[:3]
  2316. output = np.zeros((nr_perm, nr_freqs, nr_samples, 1))
  2317. if not avg_ch:
  2318. output = output[..., np.newaxis] * np.zeros(self.nr_chans)
  2319. # loop over all conditions
  2320. for cnd in ctfs.keys():
  2321. ctf_param.update({cnd: {}})
  2322. # initiate output data
  2323. for param in output_params:
  2324. ctf_param[cnd].update({param: output.copy()})
  2325. # get parameters
  2326. ctf_param[cnd] = self.summarize_ctfs(
  2327. ctfs[cnd],
  2328. ctf_param[cnd],
  2329. nr_samples,
  2330. nr_freqs,
  2331. test_bins,
  2332. nr_perm,
  2333. avg_ch,
  2334. fitting_method,
  2335. )
  2336. # split off the true (index 0) result from any permutation
  2337. # draws, then squeeze safely: frequency is axis 0 for the
  2338. # true value but axis 1 for perm draws (which keep their
  2339. # own leading perm-count axis), so protect a different
  2340. # number of leading axes in each case
  2341. def _squeeze_keep_leading(x, n_protected):
  2342. squeeze_axes = tuple(ax for ax in range(n_protected, x.ndim) if x.shape[ax] == 1)
  2343. return np.squeeze(x, axis=squeeze_axes) if squeeze_axes else x
  2344. true_param = {
  2345. k: _squeeze_keep_leading(v[0], n_protected=1) for k, v in ctf_param[cnd].items()
  2346. }
  2347. if nr_perm > 1:
  2348. perm_param = {
  2349. f"{k}_perm": _squeeze_keep_leading(v[1:], n_protected=2)
  2350. for k, v in ctf_param[cnd].items()
  2351. }
  2352. true_param.update(perm_param)
  2353. ctf_param[cnd] = true_param
  2354. return ctf_param
  2355. def fit_cos_to_ctf(
  2356. self, ctf_data: np.array, conc_step: float = 0.1, estimate_center: bool = False
  2357. ):
  2358. """
  2359. Fit a von Mises (circular cosine) function to channel tuning
  2360. function data.
  2361. Estimates parameters of a von Mises distribution by fitting a
  2362. cosine-shaped tuning curve to CTF reconstruction data. Uses grid
  2363. search over concentration parameters combined with least squares
  2364. estimation of amplitude and baseline.Supports both fixed-center
  2365. spatial working memory) and free-center (spatial attention)
  2366. approaches.
  2367. Parameters
  2368. ----------
  2369. ctf_data : np.array
  2370. 1D array of CTF reconstruction values across spatial bins.
  2371. Represents channel response as a function of spatial
  2372. location.
  2373. conc_step : float, optional
  2374. Step size for concentration parameter grid search. Smaller
  2375. values provide finer resolution but slower fitting.
  2376. Default is 0.1.
  2377. estimate_center : bool, optional
  2378. Whether to estimate the mean location (True) or fix it at
  2379. center (False). Default is False.
  2380. Returns
  2381. -------
  2382. tuple
  2383. Five-element tuple containing:
  2384. - amplitude (float): Peak response amplitude of the tuning
  2385. curve
  2386. - baseline (float): Baseline offset level
  2387. - concentration (float): Concentration parameter (tuning
  2388. width)
  2389. - mean_location (float): Mean location of tuning curve (in
  2390. radians)
  2391. - rmse (float): Root mean squared error of the fit
  2392. """
  2393. num_bins = ctf_data.size
  2394. # possible concentration parameters to consider
  2395. # step size of 0.1 is usually good, but smaller makes for better fits
  2396. concentration_range = np.arange(3, 40 + conc_step, conc_step)
  2397. # allocate storage arrays for fitting results
  2398. sse, baseline_estimates, amplitude_estimates = np.zeros((3, concentration_range.size))
  2399. # define spatial coordinates for von Mises function
  2400. spatial_coords = np.linspace(0, np.pi - np.pi / num_bins, num_bins)
  2401. if estimate_center:
  2402. # estimate optimal mean location by testing multiple positions
  2403. mean_locations = spatial_coords
  2404. else:
  2405. # use center as fixed mean location
  2406. mean_locations = [spatial_coords[int(num_bins / 2)]]
  2407. # storage for best parameters across all mean locations
  2408. best_overall_sse = np.inf
  2409. best_params = None
  2410. # loop over mean locations (single center point or all positions)
  2411. for mean_location in mean_locations:
  2412. # loop over all concentration parameters
  2413. # estimate best amplitude and baseline offset
  2414. # and find combination that minimizes sum of squared errors
  2415. initial_amplitude, initial_baseline = 1, 0
  2416. for conc_idx in range(concentration_range.size):
  2417. # create the von Mises function with current concentration
  2418. current_concentration = concentration_range[conc_idx]
  2419. predicted_response = (
  2420. initial_amplitude
  2421. * np.exp(current_concentration * (np.cos(mean_location - spatial_coords) - 1))
  2422. + initial_baseline
  2423. )
  2424. # build design matrix and use GLM to estimate ampl and baseline
  2425. design_matrix = np.zeros((num_bins, 2))
  2426. design_matrix[:, 0] = predicted_response
  2427. design_matrix[:, 1] = np.ones(num_bins)
  2428. # solve for optimal amplitude and baseline
  2429. betas, _, _, _ = np.linalg.lstsq(design_matrix, ctf_data, rcond=None)
  2430. amplitude_estimates[conc_idx], baseline_estimates[conc_idx] = betas
  2431. # calculate fitted response and sum of squared errors
  2432. fitted_response = predicted_response * betas[0] + betas[1]
  2433. sse[conc_idx] = sum((fitted_response - ctf_data) ** 2)
  2434. # find best parameters for this mean location
  2435. best_conc_idx = np.argmin(sse)
  2436. current_sse = sse[best_conc_idx]
  2437. # update overall best if this mean location is better
  2438. if current_sse < best_overall_sse:
  2439. best_overall_sse = current_sse
  2440. best_params = {
  2441. "amplitude": amplitude_estimates[best_conc_idx],
  2442. "baseline": baseline_estimates[best_conc_idx],
  2443. "concentration": concentration_range[best_conc_idx],
  2444. "mean_location": mean_location,
  2445. }
  2446. # extract best parameters
  2447. best_amplitude = best_params["amplitude"]
  2448. best_baseline = best_params["baseline"]
  2449. best_concentration = best_params["concentration"]
  2450. best_mean_location = best_params["mean_location"]
  2451. # calculate final fitted response and root mean squared error
  2452. final_prediction = (
  2453. best_amplitude
  2454. * np.exp(best_concentration * (np.cos(best_mean_location - spatial_coords) - 1))
  2455. + best_baseline
  2456. )
  2457. rmse = np.sqrt(sum((final_prediction - ctf_data) ** 2) / final_prediction.size)
  2458. return (best_amplitude, best_baseline, best_concentration, best_mean_location, rmse)
  2459. def gaussian(self, x, amp, mu, sig):
  2460. """
  2461. Standard Gaussian function for curve fitting.
  2462. Computes Gaussian probability density values for given
  2463. parameters, commonly used for fitting bell-shaped tuning curves
  2464. in CTF analysis.
  2465. Parameters
  2466. ----------
  2467. x : np.ndarray
  2468. Input coordinates for Gaussian evaluation.
  2469. amp : float
  2470. Gaussian amplitude (peak height).
  2471. mu : float
  2472. Gaussian mean (center location).
  2473. sig : float
  2474. Gaussian standard deviation (width parameter).
  2475. Returns
  2476. -------
  2477. np.ndarray
  2478. Gaussian function values at input coordinates.
  2479. Notes
  2480. -----
  2481. Uses the standard Gaussian formula:
  2482. y = amp * exp(-(x - mu)² / (2 * sig²))
  2483. """
  2484. y = amp * np.exp(-((x - mu) ** 2) / (2 * sig**2))
  2485. return y
  2486. def fit_gaussian(self, y):
  2487. """
  2488. Fit a Gaussian function to input data using least squares.
  2489. Estimates optimal Gaussian parameters (amplitude, mean, width)
  2490. that best fit the provided data, commonly used for
  2491. characterizing spatial tuning curve shapes in CTF analysis.
  2492. Parameters
  2493. ----------
  2494. y : np.ndarray
  2495. Data points to fit with Gaussian function. Should represent
  2496. a bell-shaped distribution (e.g., CTF tuning curve).
  2497. Returns
  2498. -------
  2499. np.ndarray
  2500. Array containing fitted parameters [amplitude, mean, sigma]
  2501. where:
  2502. - amplitude: Peak height of fitted Gaussian
  2503. - mean: Center location of fitted Gaussian
  2504. - sigma: Width (standard deviation) of fitted Gaussian
  2505. Notes
  2506. -----
  2507. Initial parameter estimates are computed from data statistics:
  2508. - Amplitude: Maximum value in y
  2509. - Mean: Weighted center of mass
  2510. - Sigma: Estimated from data spread
  2511. Uses scipy.optimize.curve_fit for parameter optimization.
  2512. Examples
  2513. --------
  2514. Fit Gaussian to CTF tuning curve:
  2515. >>> # Reconstruct channel responses
  2516. >>> channel_response = ctf_reconstructed[0, :] # Single trial
  2517. >>>
  2518. >>> # Fit Gaussian to characterize tuning
  2519. >>> amp, mu, sigma = ctf.fit_gaussian(channel_response)
  2520. >>> print(f"Tuning width: {sigma:.2f} spatial bins")
  2521. """
  2522. x = np.arange(y.size)
  2523. mu = sum(x * y) / sum(y)
  2524. sig = np.sqrt(sum(y * (x - mu) ** 2) / sum(y))
  2525. popt, pcov = curve_fit(self.gaussian, x, y, p0=[max(y), mu, sig])
  2526. return popt

CTF.py at commit 8bc49bf, under MIT · at the source

Overview

  1. Faculty of Social and Behavioral Sciences, Experimental Psychology, Helmholtz Institute, Utrecht University Utrecht Netherlands
Institutions: Utrecht University (Netherlands)
Journal: eLife, volume 15, article RP110315
Dates: published online 6 November 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.7554/elife.110315 · PMID 42992537 · PMCID PMC13743075 · OpenAlex W7131828039
Open access: gold, a free copy (OpenAlex)
Status: code verified (how the code is found and verified)
Categories: EEG (modality), human (organism), cognitive (subfield)
Methods: Spectral & time-frequency, Connectivity, Statistics, Smoothing, state filtering, decompositions, Machine learning, Preprocessing, Evoked potentials, fMRI & imaging, Physiology & signal measures
Keywords: attention, visual search, EEG, Human
MeSH: Attention*, Space Perception*, Visual Perception*, Adult, Cues, Electroencephalography, Female, Humans, Male, Photic Stimulation, Young Adult (* major topic)
Journal subjects: Neuroscience
Topic: Visual perception and processing mechanisms (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Citations: not cited yet (Europe PMC); 38 references in the paper

Abstract

The visual system can flexibly adjust attentional deployment to match task demands, but whether observers can proactively modulate the spatial scope of attention based on expectations about upcoming search difficulty remains unclear. According to the zoom lens model, attention can narrow or broaden its spatial extent, with narrower focus enhancing processing efficiency, a mechanism that would benefit target discrimination in crowded displays. We tested whether observers adjust attentional scope when expecting sparse versus dense search arrays by combining spatial cueing with block-wise manipulations of display density expectations. Participants performed a visual search task in which endogenous cues predicted target location, while blocks predominantly contained either sparse (1 target, 3 distractors) or dense (1 target, 7 distractors) displays. We applied inverted encoding models to broadband EEG data to reconstruct spatial channel tuning functions, enabling precise characterization of both the locus and breadth of attentional deployment. Behaviorally, expecting difficult searches selectively improved accuracy at cued locations without costs elsewhere. Consistent with this selective benefit, neural measurements revealed that expectancy enhanced the amplitude of spatially selective responses at the attended location but did not alter tuning width. These findings demonstrate that expectations about search difficulty modulate attention through gain-based signal enhancement rather than adjustments to spatial scope, revealing that preparatory attentional control operates via amplitude modulation within a stable spatial focus. This mechanism complements reactive attentional adjustments and provides an efficient means for the visual system to optimize processing under predictable task demands.

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 11 matches between paragraphs and lines of code.

OSF 46a3x

License: none: the authors keep all their rights
State: the link answers, verified on 9 November 2026
Evidence: files inventoried
Size: 73 files, 0 scripts
Software Heritage: not checked
Found in: the references
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 9 November 2026: the link answers (HTTP 200)
  • 9 November 2026: the link answers (HTTP 200)
At the source:

dvanmoorselaar/DvM

License: MIT
State: the link answers, verified on 9 November 2026
Evidence: files inventoried
Commit: 8bc49bfb7f4d09137780c96c6e289a8a5ea734e2, 25 August 2026
Languages: Python (54), Jupyter (19)
Size: 97 files, 73 scripts
Software Heritage: not archived
Found in: “Data availability”
Holds: README, license file, CITATION.cff, environment (pyproject.toml, docs/requirements-docs.txt), tests, continuous integration, documentation, 19 notebooks
Tools: NumPy (55 files), pandas (38 files), MNE-Python (29 files), Matplotlib (27 files), SciPy (11 files), scikit-learn (3 files), seaborn (2 files), autoreject (1 file), statsmodels (1 file)
Availability: 1 check, the latest on 9 November 2026: the link answers
  • 9 November 2026: the link answers
75 files

dvanmoorselaar/open_dvm

License: MIT
State: the link answers, verified on 9 November 2026
Evidence: files inventoried
Commit: 8bc49bfb7f4d09137780c96c6e289a8a5ea734e2, 25 August 2026
Languages: Python (54), Jupyter (19)
Size: 97 files, 73 scripts
Software Heritage: archived
Found in: the references
Holds: README, license file, CITATION.cff, environment (pyproject.toml, docs/requirements-docs.txt), tests, continuous integration, documentation, 19 notebooks
Tools: NumPy (55 files), pandas (38 files), MNE-Python (29 files), Matplotlib (27 files), SciPy (11 files), scikit-learn (3 files), seaborn (2 files), autoreject (1 file), statsmodels (1 file)
Availability: 1 check, the latest on 9 November 2026: the link answers
  • 9 November 2026: the link answers
75 files

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:

  • 3 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 146 scripts, each with its path and the digest of its content;
  • 11 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

All behavioral, and electrophysiological will be deposited at https://osf.io/46a3x upon publication and are publicly available (https://doi.org/10.17605/OSF.IO/46A3X). The code for the analyses presented in this paper is openly accessible at the same OSF page and at https://github.com/dvanmoorselaar/DvM copy archived at van Moorselaar, 2026a.

The following dataset was generated: van Moorselaar, 2026b.

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 1, 9 November 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 2 authors, 4 keywords, 11 MeSH terms, 1 funder, 36 references.

Cite

This paper

van Moorselaar, D., & van der Stigchel, S. (2026). Expectation-driven gain modulation of preparatory spatial attention. eLife, 15, RP110315. https://doi.org/10.7554/elife.110315

BibTeX

@article{vanmoorselaar2026expectation,
author = {van Moorselaar, Dirk and van der Stigchel, Stefan},
title = {{Expectation-driven gain modulation of preparatory spatial attention}},
journal = {eLife},
year = {2026},
month = nov,
volume = {15},
pages = {RP110315},
publisher = {eLife Sciences Publications, Ltd},
issn = {2050-084X},
doi = {10.7554/elife.110315},
url = {https://doi.org/10.7554/elife.110315},
pmid = {42992537},
pmcid = {PMC13743075}
}

RIS

TY - JOUR
AU - van Moorselaar, Dirk
AU - van der Stigchel, Stefan
TI - Expectation-driven gain modulation of preparatory spatial attention
T2 - eLife
J2 - eLife
PY - 2026
DA - 2026/11/06
VL - 15
SP - RP110315
SN - 2050-084X
PB - eLife Sciences Publications, Ltd
DO - 10.7554/elife.110315
UR - https://doi.org/10.7554/elife.110315
LA - en
ER -

CSL-JSON

{
"id": "10.7554/elife.110315",
"type": "article-journal",
"title": "Expectation-driven gain modulation of preparatory spatial attention",
"container-title": "eLife",
"author": [
{
"family": "van Moorselaar",
"given": "Dirk"
},
{
"family": "van der Stigchel",
"given": "Stefan"
}
],
"container-title-short": "eLife",
"volume": "15",
"page": "RP110315",
"DOI": "10.7554/elife.110315",
"PMID": "42992537",
"PMCID": "PMC13743075",
"ISSN": "2050-084X",
"publisher": "eLife Sciences Publications, Ltd",
"URL": "https://doi.org/10.7554/elife.110315",
"language": "en",
"issued": {
"date-parts": [
[
2026,
11,
6
]
]
}
}

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.1038/s41467-023-40405-8 [code]
Pinging the brain to reveal the hidden attentional priority map using encephalography.
Journal: Nature communications
In common: MNE-Python, statsmodels, seaborn, 4 other tools, cognitive, 10 references, author Dirk van Moorselaar
[2] doi:10.7554/elife.98304
Neural mechanisms of learned suppression uncovered by probing the hidden attentional priority map.
Journal: eLife
In common: EEG, cognitive, 12 references, author Dirk van Moorselaar
[3] doi:10.1371/journal.pbio.3003779 [code]
Past and present goals are represented concurrently during visual search.
Journal: PLoS biology
In common: EEG, cognitive, 10 references
[4] doi:10.7554/elife.101733 [code]
Proactive distractor suppression in early visual cortex.
Journal: eLife
In common: 5 references, author Dirk van Moorselaar
[5] doi:10.1162/imag.a.1131 [code]
A common framework for semantic memory and semantic composition.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: autoreject, MNE-Python, statsmodels, 6 other tools, cognitive, 3 references
[6] doi:10.1093/cercor/bhac062 [code]
Control over sampling boosts numerical evidence processing in human decisions from experience.
Journal: Cerebral cortex (New York, N.Y. : 1991)
In common: autoreject, MNE-Python, statsmodels, 6 other tools, EEG, cognitive, 2 references
[7] doi:10.1038/s41586-025-08888-1 [code]
Adversarial testing of global neuronal workspace and integrated information theories of consciousness.
Journal: Nature
In common: autoreject, MNE-Python, statsmodels, 6 other tools, EEG, cognitive, 2 references
[8] doi:10.7554/elife.99478 [code]
A direct neural signature of serial dependence in working memory.
Journal: eLife
In common: autoreject, MNE-Python, scikit-learn, 4 other tools, cognitive, 3 references
[9] doi:10.1038/s42003-025-08464-3 [code]
How EEG preprocessing shapes decoding performance.
Journal: Communications biology
In common: autoreject, MNE-Python, statsmodels, 6 other tools, EEG, 2 references
[10] doi:10.1038/s41467-024-48576-8 [code]
Transcranial focused ultrasound to V5 enhances human visual motion brain-computer interface by modulating feature-based attention.
Journal: Nature communications
In common: autoreject, MNE-Python, statsmodels, 5 other tools, EEG, cognitive, 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.

Add the badge to the code's README

OSCR: the badge of this paper

If you are an author or a maintainer of this paper's code, you can add this badge. It says what OSCR knows of the paper (its code, its validated tracing map) and links the code to this page. Paste one of these snippets under the README's title. Keep the two marks around it, so that the badge can be replaced in place and is never added twice. OSCR changes nothing in your repository.

Markdown
<!-- oscr-badge:start -->
[![OSCR](https://openscicode.org/badge/doi/10.7554/elife.110315.svg)](https://openscicode.org/badge/doi/10.7554/elife.110315)
<!-- oscr-badge:end -->
reStructuredText
.. oscr-badge:start

.. image:: https://openscicode.org/badge/doi/10.7554/elife.110315.svg
   :target: https://openscicode.org/badge/doi/10.7554/elife.110315
   :alt: OSCR

.. oscr-badge:end
HTML
<!-- oscr-badge:start -->
<a href="https://openscicode.org/badge/doi/10.7554/elife.110315"><img src="https://openscicode.org/badge/doi/10.7554/elife.110315.svg" alt="OSCR"></a>
<!-- oscr-badge:end -->

On GitHub, open the README, choose Edit (the pencil), paste the snippet under the title, then "Commit changes".

If you choose to, you can have the badge kept up to date for you, with oscr badge --write from the command line or with the GitHub Action in your repository's workflows. Every other form and option (a card, AsciiDoc, LaTeX, shields.io) is on the badge page.

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: code errors and mismatches between the code and the paper about this paper and its code, from readers and its authors, beside the paper and its tracing map.

Report a mismatch between the code and the paper · Report a code error (signed in)

Reproductions: reports from readers who ran the authors' code and did not get the paper's result, what they ran, with which environment, commit and data.

Report a reproduction failure (signed in)

Versions of its code: the releases of its code tied to a version of this paper (the preprint, the accepted manuscript, the version of record…), each with the tracing map versioned with it.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.