OSCR

Sensory-guided human-machine joint learning accelerates the acquisition of motor imagery brain computer interface control.

Code ↔ Paper

3 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 3 matches · 1 of them tie a paragraph to a whole file, not to given lines: a weak match, whose lines are not tinted
  1. [1] § Methods › Joint learning framework and sample reweighting algorithm ↔ onlineFeedback.py, the whole file · a weak match · score 0.63 · 4–40 Hz, bandpass filtered, downsampled, online, window, EEG
  2. [2] § Methods › Joint learning framework and sample reweighting algorithm ↔ updateModel.py, lines 701–735 · score 0.55 · linear discriminant, LDA, CSP, score, probability, filters
  3. [3] § Methods › Joint learning framework and sample reweighting algorithm ↔ updateModel.py, lines 995–1089 · score 0.50 · bandpass filtered, 4–40 Hz, window, training

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 1,138 lines · 35 KB · MIT · 2 matches

  1. """
  2. Update Weighted EEGNet from public-release MATLAB runData files.
  3. Expected MATLAB structure:
  4. runData.trialSignal : samples x channels x trials
  5. runData.trialTargetClass : samples x trials, or one label per trial
  6. This script replaces the old updateWeightedEEGNet.py workflow that loaded .dat files directly.
  7. It does not use BCI2kReader and does not read raw .dat files.
  8. The user edits the parameters in the __main__ section and runs this script directly.
  9. """
  10. import re
  11. from pathlib import Path
  12. from typing import Any, Dict, List, Optional, Tuple
  13. import numpy as np
  14. import scipy.io as sio
  15. import scipy.signal as signal
  16. import torch
  17. from scipy.stats import rankdata
  18. from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
  19. from sklearn.metrics import accuracy_score
  20. from torch import nn
  21. from torch.utils.data import Dataset, DataLoader
  22. from torch.utils.data.dataloader import default_collate
  23. from resources.CSP import CSP
  24. from resources.eegnet import EEGNetv4
  25. # -------------------------
  26. # Dataset wrappers
  27. # -------------------------
  28. class CustomDataset(Dataset):
  29. def __init__(self, data: np.ndarray, labels: np.ndarray):
  30. self.data = data
  31. self.labels = labels
  32. def __len__(self) -> int:
  33. return len(self.data)
  34. def __getitem__(self, index: int):
  35. return self.data[index], self.labels[index]
  36. class WeightedDataset(Dataset):
  37. def __init__(self, data: np.ndarray, labels: np.ndarray, sample_weights: np.ndarray):
  38. self.data = data
  39. self.labels = labels
  40. self.sample_weights = sample_weights
  41. def __len__(self) -> int:
  42. return len(self.data)
  43. def __getitem__(self, index: int):
  44. return self.data[index], self.labels[index], self.sample_weights[index]
  45. # -------------------------
  46. # MATLAB loading utilities
  47. # -------------------------
  48. def _mat_struct_to_dict(obj: Any) -> Any:
  49. """
  50. Recursively convert scipy.io MATLAB mat_struct objects to Python dictionaries.
  51. Important:
  52. This function preserves numpy structured arrays because hdf5storage can load
  53. MATLAB structs as numpy arrays with dtype.names.
  54. """
  55. if isinstance(obj, np.ndarray) and obj.dtype.names is not None:
  56. return obj
  57. if isinstance(obj, np.void) and obj.dtype.names is not None:
  58. return obj
  59. if hasattr(obj, "_fieldnames"):
  60. out = {}
  61. for name in obj._fieldnames:
  62. out[name] = _mat_struct_to_dict(getattr(obj, name))
  63. return out
  64. if isinstance(obj, np.ndarray) and obj.dtype == object:
  65. if obj.size == 1:
  66. return _mat_struct_to_dict(obj.item())
  67. return np.array([_mat_struct_to_dict(x) for x in obj.flat], dtype=object).reshape(obj.shape)
  68. return obj
  69. def load_mat_file(path: str) -> Dict[str, Any]:
  70. """
  71. Load MATLAB .mat file.
  72. Tries:
  73. 1. scipy.io.loadmat
  74. 2. mat73
  75. 3. hdf5storage
  76. Public release files saved as MATLAB -v7.3 may require mat73 or hdf5storage.
  77. """
  78. path = str(path)
  79. try:
  80. mat = sio.loadmat(path, squeeze_me=True, struct_as_record=False)
  81. return {k: _mat_struct_to_dict(v) for k, v in mat.items() if not k.startswith("__")}
  82. except NotImplementedError:
  83. pass
  84. except ValueError as exc:
  85. if "Unknown mat file type" not in str(exc) and "Please use HDF reader" not in str(exc):
  86. raise
  87. try:
  88. import mat73 # type: ignore
  89. return mat73.loadmat(path)
  90. except Exception:
  91. pass
  92. try:
  93. import hdf5storage # type: ignore
  94. return hdf5storage.loadmat(path)
  95. except Exception as exc:
  96. raise RuntimeError(
  97. "Could not load this .mat file. If it was saved with MATLAB -v7.3, "
  98. "install one of: pip install mat73, or pip install hdf5storage.\n"
  99. f"File: {path}\nOriginal error: {exc}"
  100. )
  101. def squeeze_item(x: Any) -> Any:
  102. """
  103. Squeeze simple arrays, but preserve structured arrays.
  104. Do not call .item() on structured arrays, otherwise dtype field names may be lost.
  105. """
  106. if isinstance(x, np.ndarray):
  107. if x.dtype.names is not None:
  108. return np.squeeze(x)
  109. x = np.squeeze(x)
  110. if x.shape == ():
  111. try:
  112. return x.item()
  113. except Exception:
  114. return x
  115. return x
  116. def list_mat_fields(obj: Any) -> List[str]:
  117. """
  118. List fields from MATLAB struct loaded as dict, mat_struct,
  119. numpy structured array, numpy void, or nested object array.
  120. """
  121. obj = squeeze_item(obj)
  122. if isinstance(obj, dict):
  123. return list(obj.keys())
  124. if hasattr(obj, "__dict__"):
  125. return [k for k in obj.__dict__.keys() if not k.startswith("_")]
  126. if isinstance(obj, np.void) and obj.dtype.names is not None:
  127. return list(obj.dtype.names)
  128. if isinstance(obj, np.ndarray):
  129. obj = np.squeeze(obj)
  130. if obj.dtype.names is not None:
  131. return list(obj.dtype.names)
  132. if obj.size == 1:
  133. try:
  134. return list_mat_fields(obj.item())
  135. except Exception:
  136. return []
  137. return []
  138. def get_mat_field(obj: Any, field_name: str) -> Any:
  139. """
  140. Robust field getter for MATLAB structs loaded as:
  141. - dict
  142. - scipy mat_struct
  143. - numpy structured array
  144. - numpy void
  145. - numpy object array containing one struct
  146. """
  147. obj = squeeze_item(obj)
  148. if isinstance(obj, dict):
  149. if field_name in obj:
  150. return squeeze_item(obj[field_name])
  151. raise KeyError(f"Field '{field_name}' not found. Available fields: {list(obj.keys())}")
  152. if hasattr(obj, field_name):
  153. return squeeze_item(getattr(obj, field_name))
  154. if hasattr(obj, "__dict__") and field_name in obj.__dict__:
  155. return squeeze_item(obj.__dict__[field_name])
  156. if isinstance(obj, np.void) and obj.dtype.names is not None:
  157. if field_name in obj.dtype.names:
  158. return squeeze_item(obj[field_name])
  159. raise KeyError(f"Field '{field_name}' not found. Available fields: {obj.dtype.names}")
  160. if isinstance(obj, np.ndarray):
  161. obj = np.squeeze(obj)
  162. if obj.dtype.names is not None:
  163. if field_name in obj.dtype.names:
  164. return squeeze_item(obj[field_name])
  165. raise KeyError(f"Field '{field_name}' not found. Available fields: {obj.dtype.names}")
  166. if obj.size == 1:
  167. return get_mat_field(obj.item(), field_name)
  168. raise KeyError(
  169. f"Field '{field_name}' not found. "
  170. f"Object type: {type(obj)}. "
  171. f"Available fields: {list_mat_fields(obj)}"
  172. )
  173. def has_mat_field(obj: Any, field_name: str) -> bool:
  174. try:
  175. get_mat_field(obj, field_name)
  176. return True
  177. except Exception:
  178. return False
  179. def get_nested(d: Dict[str, Any], keys: List[str], default: Any = None) -> Any:
  180. cur = d
  181. for key in keys:
  182. if isinstance(cur, dict) and key in cur:
  183. cur = cur[key]
  184. else:
  185. return default
  186. return cur
  187. def to_string_list(x: Any) -> List[str]:
  188. """
  189. Convert MATLAB string / cellstr / char-array-ish objects to list[str].
  190. If selected_channels is not parsed cleanly, training is not affected.
  191. """
  192. if x is None:
  193. return []
  194. try:
  195. x = squeeze_item(x)
  196. if isinstance(x, str):
  197. return [x]
  198. if isinstance(x, bytes):
  199. return [x.decode()]
  200. arr = np.asarray(x)
  201. if arr.dtype.kind in {"U", "S"}:
  202. return [str(v) for v in arr.ravel().tolist()]
  203. if arr.dtype == object:
  204. out = []
  205. for v in arr.ravel():
  206. if isinstance(v, bytes):
  207. out.append(v.decode())
  208. elif isinstance(v, str):
  209. out.append(v)
  210. elif isinstance(v, np.ndarray):
  211. out.append("".join(v.astype(str).ravel().tolist()))
  212. else:
  213. out.append(str(v))
  214. return out
  215. return [str(v) for v in arr.ravel().tolist()]
  216. except Exception:
  217. return []
  218. # -------------------------
  219. # Public runData extraction
  220. # -------------------------
  221. def _as_numeric_array(x: Any) -> np.ndarray:
  222. arr = np.asarray(x)
  223. if arr.dtype == object:
  224. raise ValueError("Expected numeric array but got object array/cell array.")
  225. return arr.astype(np.float64)
  226. def _cell_to_trials(cell_obj: Any) -> np.ndarray:
  227. """
  228. Convert MATLAB cell array of trial matrices into trials x channels x samples.
  229. This is not recommended for the update pipeline unless all trials have the same length.
  230. Public EEGNet-style update files should usually be numeric arrays, not cells.
  231. """
  232. arr = np.asarray(cell_obj, dtype=object)
  233. trials = []
  234. for item in arr.ravel():
  235. trial = np.asarray(item, dtype=np.float64)
  236. if trial.ndim != 2:
  237. trial = np.squeeze(trial)
  238. if trial.ndim != 2:
  239. raise ValueError("Each trialSignal cell must be a 2D matrix: samples x channels or channels x samples.")
  240. # Public data stores cells as trialLength x channels.
  241. if trial.shape[0] >= trial.shape[1]:
  242. trial = trial.T # channels x samples
  243. trials.append(trial)
  244. lengths = {t.shape[1] for t in trials}
  245. channels = {t.shape[0] for t in trials}
  246. if len(lengths) != 1 or len(channels) != 1:
  247. raise ValueError(
  248. "trialSignal is a variable-length cell array. Weighted update needs fixed-length EEGNet-style trials. "
  249. "Use fixed-length runData files or add a padding/cropping rule."
  250. )
  251. return np.stack(trials, axis=0) # trials x channels x samples
  252. def trial_signal_to_numpy(trial_signal: Any) -> np.ndarray:
  253. """
  254. Convert runData.trialSignal to trials x channels x samples.
  255. Expected fixed-length public format:
  256. samples x channels x trials
  257. """
  258. arr = np.asarray(trial_signal)
  259. if arr.dtype == object:
  260. return _cell_to_trials(arr)
  261. arr = arr.astype(np.float64)
  262. arr = np.squeeze(arr)
  263. if arr.ndim != 3:
  264. raise ValueError(f"trialSignal must be 3D numeric array or cell array. Got shape {arr.shape}.")
  265. # Most public EEGNet-style data:
  266. # samples x channels x trials, e.g. 5000 x 62 x 30.
  267. # Convert to trials x channels x samples.
  268. if arr.shape[0] > arr.shape[1] and arr.shape[2] < arr.shape[0]:
  269. return np.transpose(arr, (2, 1, 0))
  270. # If already trials x channels x samples.
  271. if arr.shape[2] > arr.shape[1] and arr.shape[0] < arr.shape[2]:
  272. return arr
  273. # Conservative fallback: assume samples x channels x trials.
  274. return np.transpose(arr, (2, 1, 0))
  275. def _mode_or_mean_label(v: np.ndarray) -> int:
  276. v = v[np.isfinite(v)]
  277. if v.size == 0:
  278. raise ValueError("Empty target label vector.")
  279. rounded = np.rint(v).astype(int)
  280. vals, counts = np.unique(rounded, return_counts=True)
  281. return int(vals[np.argmax(counts)])
  282. def target_class_to_labels(trial_target_class: Any, n_trials: int) -> np.ndarray:
  283. """
  284. Convert runData.trialTargetClass into one label per trial.
  285. Common fixed-length format:
  286. samples x trials
  287. """
  288. arr = np.asarray(trial_target_class)
  289. if arr.dtype == object:
  290. labels = []
  291. for item in arr.ravel():
  292. v = np.asarray(item, dtype=np.float64).ravel()
  293. labels.append(_mode_or_mean_label(v))
  294. return np.asarray(labels, dtype=int)
  295. arr = np.squeeze(arr.astype(np.float64))
  296. if arr.ndim == 1:
  297. if arr.size == n_trials:
  298. return np.rint(arr).astype(int)
  299. raise ValueError(f"1D trialTargetClass length {arr.size} does not match n_trials {n_trials}.")
  300. if arr.ndim != 2:
  301. raise ValueError(f"trialTargetClass must be 1D/2D/cell. Got shape {arr.shape}.")
  302. # samples x trials
  303. if arr.shape[1] == n_trials:
  304. return np.asarray([_mode_or_mean_label(arr[:, i]) for i in range(n_trials)], dtype=int)
  305. # trials x samples
  306. if arr.shape[0] == n_trials:
  307. return np.asarray([_mode_or_mean_label(arr[i, :]) for i in range(n_trials)], dtype=int)
  308. raise ValueError(f"Cannot align trialTargetClass shape {arr.shape} with n_trials {n_trials}.")
  309. def extract_structured_runData_field(run_data: np.ndarray, field_name: str) -> Any:
  310. """
  311. Extract a field from structured ndarray runData.
  312. Example current format:
  313. runData shape: (1,)
  314. runData dtype.names includes trialSignal
  315. runData["trialSignal"][0] has shape 5000 x 62 x trials
  316. """
  317. if not isinstance(run_data, np.ndarray) or run_data.dtype.names is None:
  318. raise TypeError("run_data is not a numpy structured array.")
  319. if field_name not in run_data.dtype.names:
  320. raise KeyError(f"runData.{field_name} not found. Available fields: {run_data.dtype.names}")
  321. out = run_data[field_name]
  322. if out.shape[0] == 1:
  323. out = out[0]
  324. return out
  325. def parse_meta_from_structured_runData(run_data: np.ndarray) -> Tuple[float, List[str]]:
  326. """
  327. Extract sampling_rate_hz and selected_channels from structured runData.meta if possible.
  328. If parsing fails, return fs=1000 and empty channel list.
  329. """
  330. fs = 1000.0
  331. selected_channels: List[str] = []
  332. if not isinstance(run_data, np.ndarray) or run_data.dtype.names is None:
  333. return fs, selected_channels
  334. if "meta" not in run_data.dtype.names:
  335. return fs, selected_channels
  336. try:
  337. meta = run_data["meta"][0]
  338. if isinstance(meta, np.ndarray) and meta.dtype.names is not None:
  339. if "sampling_rate_hz" in meta.dtype.names:
  340. fs_raw = meta["sampling_rate_hz"]
  341. if isinstance(fs_raw, np.ndarray):
  342. fs_raw = np.squeeze(fs_raw)
  343. if fs_raw.shape == ():
  344. fs = float(fs_raw.item())
  345. else:
  346. fs = float(np.asarray(fs_raw).ravel()[0])
  347. else:
  348. fs = float(fs_raw)
  349. if "selected_channels" in meta.dtype.names:
  350. try:
  351. selected_channels = to_string_list(meta["selected_channels"])
  352. except Exception:
  353. selected_channels = []
  354. elif isinstance(meta, np.void) and meta.dtype.names is not None:
  355. if "sampling_rate_hz" in meta.dtype.names:
  356. fs = float(np.asarray(meta["sampling_rate_hz"]).squeeze())
  357. if "selected_channels" in meta.dtype.names:
  358. try:
  359. selected_channels = to_string_list(meta["selected_channels"])
  360. except Exception:
  361. selected_channels = []
  362. except Exception as exc:
  363. print(f"Warning: could not parse meta cleanly: {exc}")
  364. fs = 1000.0
  365. selected_channels = []
  366. if not np.isfinite(fs) or fs <= 0:
  367. fs = 1000.0
  368. return fs, selected_channels
  369. def load_runData_trials(mat_path: str) -> Tuple[np.ndarray, np.ndarray, List[str], float, Dict[int, int]]:
  370. """
  371. Load already segmented runData file.
  372. Returns:
  373. sig: trials x channels x samples
  374. labels_zero_based: trials
  375. selected_channels: list[str]
  376. sampling_rate_hz: float
  377. label_mapping: dict from original label -> zero-based label
  378. """
  379. mat = load_mat_file(mat_path)
  380. if "runData" not in mat:
  381. raise KeyError(f"File does not contain runData: {mat_path}. Available keys: {list(mat.keys())}")
  382. run_data = mat["runData"]
  383. print("Top-level mat keys:", list(mat.keys()))
  384. print("runData type:", type(run_data))
  385. # if isinstance(run_data, np.ndarray):
  386. # print("runData shape:", run_data.shape)
  387. # print("runData dtype:", run_data.dtype)
  388. # print("runData dtype.names:", run_data.dtype.names)
  389. # print("runData ndim:", run_data.ndim)
  390. # Case 1: hdf5storage loaded MATLAB struct as structured ndarray.
  391. if isinstance(run_data, np.ndarray) and run_data.dtype.names is not None:
  392. trial_signal = extract_structured_runData_field(run_data, "trialSignal")
  393. trial_target_class = extract_structured_runData_field(run_data, "trialTargetClass")
  394. sampling_rate_hz, selected_channels = parse_meta_from_structured_runData(run_data)
  395. # Case 2: runData loaded as dict / mat_struct.
  396. else:
  397. if not isinstance(run_data, dict):
  398. run_data = _mat_struct_to_dict(run_data)
  399. if isinstance(run_data, dict):
  400. if "trialSignal" not in run_data:
  401. raise KeyError(f"runData.trialSignal not found. Available fields: {list(run_data.keys())}")
  402. if "trialTargetClass" not in run_data:
  403. raise KeyError(
  404. f"runData.trialTargetClass not found. Available fields: {list(run_data.keys())}. "
  405. "This update script is for EEGNet-style runData files."
  406. )
  407. trial_signal = run_data["trialSignal"]
  408. trial_target_class = run_data["trialTargetClass"]
  409. meta = run_data.get("meta", {}) if isinstance(run_data.get("meta", {}), dict) else {}
  410. selected_channels = to_string_list(meta.get("selected_channels", []))
  411. sampling_rate_hz = float(np.asarray(meta.get("sampling_rate_hz", 1000)).squeeze())
  412. else:
  413. trial_signal = get_mat_field(run_data, "trialSignal")
  414. trial_target_class = get_mat_field(run_data, "trialTargetClass")
  415. selected_channels = []
  416. sampling_rate_hz = 1000.0
  417. if has_mat_field(run_data, "meta"):
  418. meta = get_mat_field(run_data, "meta")
  419. if has_mat_field(meta, "sampling_rate_hz"):
  420. sampling_rate_hz = float(np.asarray(get_mat_field(meta, "sampling_rate_hz")).squeeze())
  421. if has_mat_field(meta, "selected_channels"):
  422. selected_channels = to_string_list(get_mat_field(meta, "selected_channels"))
  423. if not np.isfinite(sampling_rate_hz) or sampling_rate_hz <= 0:
  424. sampling_rate_hz = 1000.0
  425. sig = trial_signal_to_numpy(trial_signal)
  426. labels = target_class_to_labels(trial_target_class, sig.shape[0])
  427. # Convert arbitrary integer labels to contiguous 0..C-1 for PyTorch NLLLoss.
  428. unique_labels = sorted([int(x) for x in np.unique(labels)])
  429. label_mapping = {old: new for new, old in enumerate(unique_labels)}
  430. labels_zero_based = np.asarray([label_mapping[int(x)] for x in labels], dtype=np.int64)
  431. return sig, labels_zero_based, selected_channels, sampling_rate_hz, label_mapping
  432. # -------------------------
  433. # Signal preprocessing
  434. # -------------------------
  435. def bandpass_filter_trials(sig: np.ndarray, fs: float, low: float = 4.0, high: float = 40.0) -> np.ndarray:
  436. """Butterworth bandpass on trials x channels x samples."""
  437. nyq = fs / 2.0
  438. if high >= nyq:
  439. high = nyq - 1.0
  440. if low <= 0 or high <= low:
  441. raise ValueError(f"Invalid bandpass range: low={low}, high={high}, fs={fs}")
  442. b, a = signal.butter(4, [low, high], btype="bandpass", fs=fs)
  443. return signal.filtfilt(b, a, sig, axis=-1)
  444. def slice_trials(
  445. sig: np.ndarray,
  446. labels: np.ndarray,
  447. fs: float,
  448. window_sec: float = 1.0,
  449. step_sec: float = 0.04,
  450. start_sec: float = 0.5,
  451. end_sec: float = 4.5,
  452. ) -> Tuple[np.ndarray, np.ndarray]:
  453. """
  454. Slice trials into sliding 1-second windows from 0.5 to 4.5 s with 40 ms step.
  455. Input:
  456. sig: trials x channels x samples
  457. Output:
  458. windows x channels x window_samples
  459. """
  460. n_trials, n_ch, n_samples = sig.shape
  461. start = int(round(start_sec * fs))
  462. stop = int(round(end_sec * fs))
  463. win = int(round(window_sec * fs))
  464. step = int(round(step_sec * fs))
  465. if stop > n_samples:
  466. stop = n_samples
  467. if start < 0 or start + win > stop:
  468. raise ValueError(
  469. f"Invalid slicing window. n_samples={n_samples}, fs={fs}, "
  470. f"start={start}, stop={stop}, win={win}."
  471. )
  472. n_slices = int(np.floor((stop - start - win) / step) + 1)
  473. out = np.zeros((n_trials * n_slices, n_ch, win), dtype=np.float64)
  474. out_labels = np.zeros(n_trials * n_slices, dtype=np.int64)
  475. k = 0
  476. for i in range(n_trials):
  477. for j in range(n_slices):
  478. s = start + j * step
  479. out[k] = sig[i, :, s:s + win]
  480. out_labels[k] = labels[i]
  481. k += 1
  482. out = out - np.mean(out, axis=2, keepdims=True)
  483. return out, out_labels
  484. def resample_trials(sig: np.ndarray, fs_in: float, fs_out: float = 100.0) -> Tuple[np.ndarray, float]:
  485. if abs(fs_in - fs_out) < 1e-6:
  486. return sig, fs_in
  487. from fractions import Fraction
  488. frac = Fraction(fs_out / fs_in).limit_denominator(1000)
  489. resampled = signal.resample_poly(sig, up=frac.numerator, down=frac.denominator, axis=-1)
  490. return resampled, fs_out
  491. # -------------------------
  492. # Weighting utilities
  493. # -------------------------
  494. def cal_init_weight(train_data: np.ndarray, train_label: np.ndarray) -> np.ndarray:
  495. """CSP + LDA initial sample weights for binary labels."""
  496. train_label = np.ravel(train_label.astype(int))
  497. csp = CSP(m_filters=4)
  498. _, eig_vec = csp.fit(train_data, train_label)
  499. features = np.zeros((train_data.shape[0], 2 * csp.m_filters))
  500. for i in range(train_data.shape[0]):
  501. features[i, :] = csp.transform(train_data[i, :, :], eig_vec)
  502. lda = LinearDiscriminantAnalysis()
  503. lda.fit(features, train_label)
  504. pred_prob = lda.predict_proba(features)
  505. pred_label = lda.predict(features)
  506. acc = accuracy_score(pred_label, train_label)
  507. print(f"CSP TrainAcc: {acc:.4f}")
  508. weights = np.zeros(train_data.shape[0], dtype=np.float64)
  509. for i in range(train_data.shape[0]):
  510. if pred_label[i] == train_label[i]:
  511. weights[i] = np.max(pred_prob[i, :])
  512. else:
  513. weights[i] = np.min(pred_prob[i, :])
  514. return weights
  515. def cal_init_weight_2d(train_data: np.ndarray, train_label: np.ndarray) -> np.ndarray:
  516. """One-vs-rest CSP + LDA initial weights for multiclass labels."""
  517. train_label = np.ravel(train_label.astype(int))
  518. weights = np.zeros(train_data.shape[0], dtype=np.float64)
  519. classes = np.unique(train_label)
  520. for cls in classes:
  521. tmp_label = np.ones_like(train_label)
  522. cls_idx = np.where(train_label == cls)[0]
  523. tmp_label[cls_idx] = 0
  524. csp = CSP(m_filters=4)
  525. _, eig_vec = csp.fit(train_data, tmp_label)
  526. features = np.zeros((train_data.shape[0], 2 * csp.m_filters))
  527. for i in range(train_data.shape[0]):
  528. features[i, :] = csp.transform(train_data[i, :, :], eig_vec)
  529. lda = LinearDiscriminantAnalysis()
  530. lda.fit(features, tmp_label)
  531. pred_prob = lda.predict_proba(features)
  532. pred_tmp = lda.predict(features)
  533. acc = accuracy_score(pred_tmp, tmp_label)
  534. print(f"CSP one-vs-rest class {cls} TrainAcc: {acc:.4f}")
  535. for idx in cls_idx:
  536. if pred_tmp[idx] == tmp_label[idx]:
  537. weights[idx] = np.max(pred_prob[idx, :])
  538. else:
  539. weights[idx] = np.min(pred_prob[idx, :])
  540. return weights
  541. def select_init_data(data: np.ndarray, labels: np.ndarray, weights: np.ndarray, init_size: float):
  542. n = max(1, int(np.floor(init_size * labels.shape[0])))
  543. chosen = np.argsort(weights)[-n:][::-1]
  544. chosen_weight = np.zeros_like(weights)
  545. chosen_weight[chosen] = weights[chosen]
  546. return data[chosen], labels[chosen], weights[chosen], chosen_weight
  547. def select_init_data_2d(data: np.ndarray, labels: np.ndarray, weights: np.ndarray, init_size: float):
  548. chosen_weight = np.zeros_like(weights)
  549. classes = np.unique(labels)
  550. n_per_class = max(1, int(np.floor(init_size * (labels.shape[0] / len(classes)))))
  551. for cls in classes:
  552. cls_idx = np.where(labels == cls)[0]
  553. local = np.argsort(weights[cls_idx])[-n_per_class:][::-1]
  554. chosen_weight[cls_idx[local]] = weights[cls_idx[local]]
  555. mask = chosen_weight > 0
  556. return data[mask], labels[mask], weights[mask], chosen_weight
  557. def get_data_weight_round(
  558. model,
  559. loss_fn,
  560. dataset,
  561. train_data,
  562. train_label,
  563. round_size,
  564. device,
  565. ):
  566. all_loss = []
  567. all_weight = np.zeros(train_label.shape[0], dtype=np.float64)
  568. model.eval()
  569. loader = DataLoader(dataset, batch_size=len(dataset), shuffle=False)
  570. with torch.no_grad():
  571. for x, y in loader:
  572. x = x.to(device=device, dtype=torch.float32)
  573. y = torch.squeeze(y).to(device=device).long()
  574. pred = model(x)
  575. sample_loss = loss_fn(pred, y)
  576. all_loss.extend(sample_loss.detach().cpu().numpy().tolist())
  577. norm_loss = rankdata(all_loss, nan_policy="omit") / len(all_loss)
  578. for i, norm_val in enumerate(norm_loss):
  579. if norm_val <= round_size:
  580. if round_size >= 1:
  581. epsilon = 1e-2
  582. all_weight[i] = np.log(norm_val + epsilon) / np.log(epsilon)
  583. else:
  584. numerator = norm_val + 1 - round_size
  585. denominator = np.log(1 - round_size)
  586. if numerator > 0 and denominator != 0:
  587. all_weight[i] = np.log(numerator) / denominator
  588. else:
  589. all_weight[i] = 0.0
  590. all_weight = np.nan_to_num(all_weight, nan=0.0, posinf=0.0, neginf=0.0)
  591. all_weight[all_weight < 0] = 0.0
  592. wmax = np.nanmax(all_weight)
  593. if not np.isfinite(wmax) or wmax == 0:
  594. print("Warning: round weights are all zero; using constant 0.1 weights.")
  595. all_weight[:] = 0.1
  596. else:
  597. all_weight /= wmax
  598. mask = all_weight > 0
  599. return train_data[mask], train_label[mask], all_weight[mask], all_weight
  600. # -------------------------
  601. # Training
  602. # -------------------------
  603. def train_weighted(
  604. model,
  605. loss_fn,
  606. max_epoch,
  607. train_dataset,
  608. device,
  609. lr=0.005,
  610. wd=1e-4,
  611. batch_size=128,
  612. ):
  613. model.to(device)
  614. model = model.to(torch.float32)
  615. optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=wd)
  616. scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, max(max_epoch - 1, 1))
  617. last_loss = torch.tensor(float("nan"), device=device)
  618. for epoch in range(max_epoch):
  619. model.train()
  620. loader = DataLoader(
  621. train_dataset,
  622. batch_size=batch_size,
  623. shuffle=True,
  624. drop_last=False,
  625. collate_fn=lambda x: [y.to(device) for y in default_collate(x)],
  626. )
  627. for data, label, sample_weight in loader:
  628. optimizer.zero_grad()
  629. data = data.to(torch.float32)
  630. label = torch.squeeze(label).long()
  631. sample_weight = sample_weight.to(torch.float32)
  632. pred = model(data)
  633. loss_per_sample = loss_fn(pred, label)
  634. denom = torch.sum(sample_weight).clamp_min(1e-8)
  635. loss = torch.sum(loss_per_sample * sample_weight) / denom
  636. loss.backward()
  637. optimizer.step()
  638. last_loss = loss.detach()
  639. scheduler.step()
  640. print(f"Epoch {epoch + 1:03d}/{max_epoch}, Train Loss: {last_loss.item():.6f}")
  641. return model
  642. def fit_weighted_eegnet(
  643. train_data: np.ndarray,
  644. train_label: np.ndarray,
  645. previous_model_path: Optional[str] = None,
  646. init_size: float = 0.2,
  647. rounds: int = 8,
  648. max_epoch_per_round: int = 30,
  649. device: Optional[str] = None,
  650. ):
  651. train_label = np.asarray(train_label, dtype=np.int64).ravel()
  652. classes = np.unique(train_label)
  653. if classes.min() != 0 or classes.max() != len(classes) - 1:
  654. raise ValueError(f"Labels must be contiguous 0..C-1. Got {classes}.")
  655. if device is None:
  656. device = "cuda" if torch.cuda.is_available() else "cpu"
  657. device_obj = torch.device(device)
  658. print(f"Using device: {device_obj}")
  659. print(f"Training data shape: {train_data.shape}")
  660. print(f"Training labels: {classes}")
  661. all_weight_log = []
  662. if len(classes) <= 2:
  663. init_weight = cal_init_weight(train_data, train_label)
  664. round_data, round_label, round_weight, all_weight = select_init_data(
  665. train_data,
  666. train_label,
  667. init_weight,
  668. init_size,
  669. )
  670. else:
  671. init_weight = cal_init_weight_2d(train_data, train_label)
  672. round_data, round_label, round_weight, all_weight = select_init_data_2d(
  673. train_data,
  674. train_label,
  675. init_weight,
  676. init_size,
  677. )
  678. all_dataset = CustomDataset(train_data, train_label)
  679. model = EEGNetv4(
  680. train_data.shape[1],
  681. len(classes),
  682. n_times=train_data.shape[2],
  683. add_log_softmax=True,
  684. kernel_length=min(50, train_data.shape[2]),
  685. )
  686. if previous_model_path:
  687. print(f"Loading previous model: {previous_model_path}")
  688. state = torch.load(previous_model_path, map_location=device_obj)
  689. model.load_state_dict(state)
  690. loss_fn = nn.NLLLoss(reduction="none")
  691. for round_idx in range(rounds):
  692. if round_idx == 0:
  693. round_size = init_size
  694. print(f"Init with {init_size}")
  695. else:
  696. round_size = (1 - init_size) / (rounds - 1) * round_idx + init_size
  697. if round_size > 1 - 1e-2:
  698. round_size = 1.0
  699. print(f"Round {round_idx + 1}/{rounds}, round size: {round_size:.4f}")
  700. round_data, round_label, round_weight, all_weight = get_data_weight_round(
  701. model,
  702. loss_fn,
  703. all_dataset,
  704. train_data,
  705. train_label,
  706. round_size,
  707. device_obj,
  708. )
  709. all_weight_log.append(all_weight.copy())
  710. train_dataset = WeightedDataset(
  711. round_data,
  712. round_label,
  713. round_weight.astype(np.float32),
  714. )
  715. model = train_weighted(
  716. model,
  717. loss_fn,
  718. max_epoch_per_round,
  719. train_dataset,
  720. device_obj,
  721. )
  722. return model, all_weight_log
  723. # -------------------------
  724. # Main
  725. # -------------------------
  726. def derive_run_name(mat_path: str) -> str:
  727. base = Path(mat_path).stem
  728. # S001_sess01_run02 -> sess01_run02
  729. m = re.match(r"S\d{3}_(.+)$", base)
  730. return m.group(1) if m else base
  731. def main(
  732. mat_path,
  733. save_dir=r".\Parameter",
  734. previous_model_path="",
  735. output_prefix="",
  736. apply_bandpass=True,
  737. target_fs=100.0,
  738. init_size=0.2,
  739. rounds=8,
  740. epochs=30,
  741. device=None,
  742. ):
  743. save_dir = Path(save_dir)
  744. save_dir.mkdir(parents=True, exist_ok=True)
  745. print(f"MAT file: {mat_path}")
  746. print(f"Previous model: {previous_model_path if previous_model_path else '[none]'}")
  747. print(f"Save directory: {save_dir}")
  748. sig, label, channel_names, fs, label_mapping = load_runData_trials(mat_path)
  749. print(f"Loaded trialSignal: {sig.shape} [trials x channels x samples]")
  750. print(f"Original sampling rate: {fs}")
  751. print(f"Label mapping original->zero-based: {label_mapping}")
  752. if channel_names:
  753. print(f"Channels: {len(channel_names)}")
  754. if apply_bandpass:
  755. print("Applying 4-40 Hz bandpass filter.")
  756. sig = bandpass_filter_trials(sig, fs=fs, low=4.0, high=40.0)
  757. else:
  758. print("Bandpass filtering disabled.")
  759. sig, label = slice_trials(
  760. sig,
  761. label,
  762. fs=fs,
  763. window_sec=1.0,
  764. step_sec=0.04,
  765. start_sec=0.5,
  766. end_sec=4.5,
  767. )
  768. print(f"After sliding-window slicing: {sig.shape}")
  769. sig, fs_out = resample_trials(sig, fs_in=fs, fs_out=target_fs)
  770. print(f"After resampling to {fs_out:g} Hz: {sig.shape}")
  771. model, all_weight_log = fit_weighted_eegnet(
  772. train_data=sig,
  773. train_label=label,
  774. previous_model_path=previous_model_path if previous_model_path else None,
  775. init_size=init_size,
  776. rounds=rounds,
  777. max_epoch_per_round=epochs,
  778. device=device,
  779. )
  780. prefix = output_prefix if output_prefix else derive_run_name(mat_path)
  781. model_path = save_dir / f"{prefix}Model.pth"
  782. weight_path = save_dir / f"{prefix}WeightLog.mat"
  783. mapping_path = save_dir / f"{prefix}LabelMapping.mat"
  784. torch.save(model.state_dict(), model_path)
  785. sio.savemat(
  786. weight_path,
  787. {
  788. "AllWeightLog": np.asarray(all_weight_log, dtype=object)
  789. }
  790. )
  791. sio.savemat(
  792. mapping_path,
  793. {
  794. "original_labels": np.array(list(label_mapping.keys())),
  795. "zero_based_labels": np.array(list(label_mapping.values())),
  796. }
  797. )
  798. print(f"Saved model: {model_path}")
  799. print(f"Saved weight log: {weight_path}")
  800. print(f"Saved label mapping: {mapping_path}")
  801. return {
  802. "model_path": str(model_path),
  803. "weight_path": str(weight_path),
  804. "mapping_path": str(mapping_path),
  805. "label_mapping": label_mapping,
  806. "fs_out": fs_out,
  807. "data_shape": sig.shape,
  808. }
  809. if __name__ == "__main__":
  810. # ============================================================
  811. # User parameters: edit these directly
  812. # ============================================================
  813. mat_path = r"D:\Tactile\PublicRelease_Final\JointLearning\S001\S001_sess04_run03.mat"
  814. save_dir = r".\Parameter"
  815. # First run: use ""
  816. # Later run: use previous model, for example:
  817. # previous_model_path = r".\Parameter\sess01_run01Model.pth"
  818. previous_model_path = ""
  819. # If empty, output prefix is derived from mat filename.
  820. output_prefix = ""
  821. apply_bandpass = True
  822. target_fs = 100.0
  823. init_size = 0.2
  824. rounds = 9
  825. epochs = 30
  826. # None = cuda if available, otherwise cpu.
  827. # Or set "cuda" / "cpu".
  828. device = None
  829. # ============================================================
  830. # Run update
  831. # ============================================================
  832. result = main(
  833. mat_path=mat_path,
  834. save_dir=save_dir,
  835. previous_model_path=previous_model_path,
  836. output_prefix=output_prefix,
  837. apply_bandpass=apply_bandpass,
  838. target_fs=target_fs,
  839. init_size=init_size,
  840. rounds=rounds,
  841. epochs=epochs,
  842. device=device,
  843. )
  844. print("Finished.")
  845. print(result)

updateModel.py at commit 57710f7, under MIT · at the source

Overview

Authors: Hanwen Wang1, Yisha Zhang1, Maxim Karrenbach2, Yidan Ding1, Bin He1,2,3
  1. Department of Biomedical Engineering, Carnegie Mellon University,Pittsburgh, PA USA
  2. Department of Electrical and Computer Engineering, Carnegie Mellon University,Pittsburgh, PA USA
  3. Neuroscience Institute, Carnegie Mellon University,Pittsburgh, PA USA
Institutions: Carnegie Mellon University (United States)
Journal: Nature communications, volume 17, issue 1, article 6177
Dates: received 20 October 2025; accepted 23 June 2026; published online 15 July 2026
Type: Research article · Language: English
License: CC BY-NC-ND
Identifiers: DOI 10.1038/s41467-026-75435-5 · PMID 42457692 · PMCID PMC13373212 · OpenAlex W7168376136
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: EEG (modality), human (organism)
Methods: Spectral & time-frequency, Preprocessing, Connectivity, Statistics, Smoothing, state filtering, decompositions, Machine learning
Keywords: Brain-machine interface, Biomedical engineering, Touch receptors, Information technology, Electroencephalography - EEG
MeSH: Brain-Computer Interfaces*, Imagination*, Machine Learning*, Adaptive Algorithms, Adult, Algorithms, Electroencephalography, Female, Humans, Learning, Male, Neuronal Plasticity, Reinforcement Machine Learning, Young Adult (* major topic)
Topic: EEG and Brain-Computer Interfaces (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Citations: cited by 1 paper (Europe PMC); 76 references in the paper

Abstract

The abstract is not reproduced here: the paper's license (CC BY-NC-ND) does not allow it. Read it in the paper, at the publisher or on Europe PMC.

Repositories

Its files are read in the Code ↔ Paper reader above, with 3 matches between paragraphs and lines of code.

bfinl/SensoryGuidedJointLearning

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 57710f76a5ddd6f9a3aad28566ca55776a424400, 2 June 2026
Languages: Python (6)
Size: 8 files, 6 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: PyTorch (5 files), NumPy (4 files), SciPy (3 files), MNE-Python (1 file), scikit-learn (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
8 files

Zenodo 20480141

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: “Code availability”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: PyTorch (5 files), NumPy (4 files), SciPy (3 files), MNE-Python (1 file), scikit-learn (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
8 files
At the source:

Code availability statement

The paper has a code availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

Read it in the paper: doi.org/10.1038/s41467-026-75435-5.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

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

The paper has a data availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

  • no repository, dataset or request procedure was recognized in it

Read it in the paper: doi.org/10.1038/s41467-026-75435-5.

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, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 5 authors, 5 keywords, 14 MeSH terms, 2 funders, 74 references.

Cite

This paper

Wang, H., Zhang, Y., Karrenbach, M., Ding, Y., & He, B. (2026). Sensory-guided human-machine joint learning accelerates the acquisition of motor imagery brain computer interface control. Nature communications, 17(1), 6177. https://doi.org/10.1038/s41467-026-75435-5

BibTeX

@article{wang2026sensory,
author = {Wang, Hanwen and Zhang, Yisha and Karrenbach, Maxim and Ding, Yidan and He, Bin},
title = {{Sensory-guided human-machine joint learning accelerates the acquisition of motor imagery brain computer interface control}},
journal = {Nature communications},
year = {2026},
month = jul,
volume = {17},
number = {1},
pages = {6177},
publisher = {Nature Publishing Group},
issn = {2041-1723},
doi = {10.1038/s41467-026-75435-5},
url = {https://doi.org/10.1038/s41467-026-75435-5},
pmid = {42457692},
pmcid = {PMC13373212}
}

RIS

TY - JOUR
AU - Wang, Hanwen
AU - Zhang, Yisha
AU - Karrenbach, Maxim
AU - Ding, Yidan
AU - He, Bin
TI - Sensory-guided human-machine joint learning accelerates the acquisition of motor imagery brain computer interface control
T2 - Nature communications
J2 - Nat Commun
PY - 2026
DA - 2026/07/15
VL - 17
IS - 1
SP - 6177
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/s41467-026-75435-5
UR - https://doi.org/10.1038/s41467-026-75435-5
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41467-026-75435-5",
"type": "article-journal",
"title": "Sensory-guided human-machine joint learning accelerates the acquisition of motor imagery brain computer interface control",
"container-title": "Nature communications",
"author": [
{
"family": "Wang",
"given": "Hanwen"
},
{
"family": "Zhang",
"given": "Yisha"
},
{
"family": "Karrenbach",
"given": "Maxim"
},
{
"family": "Ding",
"given": "Yidan"
},
{
"family": "He",
"given": "Bin"
}
],
"container-title-short": "Nat Commun",
"volume": "17",
"issue": "1",
"page": "6177",
"DOI": "10.1038/s41467-026-75435-5",
"PMID": "42457692",
"PMCID": "PMC13373212",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41467-026-75435-5",
"language": "en",
"issued": {
"date-parts": [
[
2026,
7,
15
]
]
}
}

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-026-69853-8 [code]
Transcranial focused ultrasound induces source localizable cortical activation in resting state humans when applied concurrently with transcranial electric stimulation.
Journal: Nature communications
In common: MNE-Python, SciPy, NumPy, EEG, 2 authors
[2] doi:10.1186/s12984-026-02041-3 [code]
Mental tasks induce common modulations of oscillations in cortex and spinal cord.
Journal: Journal of neuroengineering and rehabilitation
In common: EEG, 6 references
[3] doi:10.1371/journal.pcbi.1014112 [code]
Opposing cortical forces: Alpha slowing and sensorimotor mu acceleration during motor-related BCI training.
Journal: PLoS computational biology
In common: EEG, 6 references
[4] doi:10.1073/pnas.2510015122 [code]
Mapping epileptogenic brain using a unified spatial–temporal–spectral source imaging framework
Journal: n/a
In common: EEG, 2 references, author Bin He
[5] doi:10.1038/s44385-026-00098-2 [code]
Cross-region neural signal reconstruction to lift electrode placement constraints in SSVEP brain-computer interfaces.
Journal: npj biomedical innovations
In common: EEG, 5 references
[6] doi:10.3390/s26134045
Brain Signal for Secure EEG Biometric Authentication: A Comprehensive Survey.
Journal: Sensors (Basel, Switzerland)
In common: EEG, 6 references
[7] doi:10.1162/imag.a.1259 [code]
Neuronal avalanches as a predictive biomarker for guiding tailored BCI training programs.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: MNE-Python, scikit-learn, SciPy, 1 other tool, EEG, 2 references
[8] doi:10.1007/s10916-026-02374-5 [code]
Attention-Enhanced U-Net for Sensor-Efficient High-Density EEG Reconstruction in Wearable Brain Monitoring Systems.
Journal: Journal of medical systems
In common: MNE-Python, PyTorch, scikit-learn, 2 other tools, EEG, 2 references
[9] doi:10.1038/s41593-026-02258-4 [code]
Laminar organization of cellular microcircuits modulating human interictal epileptiform discharges.
Journal: Nature neuroscience
In common: MNE-Python, scikit-learn, SciPy, 1 other tool, EEG, 2 references
[10] doi:10.1186/s13634-026-01330-2 [code]
Leednet: a lightweight network for event detection in EEG signals.
Journal: Journal on advances in signal processing
In common: MNE-Python, PyTorch, scikit-learn, 2 other tools, EEG, 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.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

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