OSCR

Symmetric sensing and symmetry-breaking processing as a minimal principle for directional inference.

Code ↔ Paper

8 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 8 matches
  1. [1] § Results › Sign recovery was robust to noise but depended on angular range and kernel scale ↔ env_symmetry_stage2_revised_additional.py, lines 735–763 · score 0.80 · edge clustering, central clustering, low band, broad band, high band, spectrum
  2. [2] § Results › A decoder trained on the full cross-correlation acquired an odd-like readout structure ↔ env_symmetry_stage2_revised_additional.py, lines 1080–1114 · score 0.70 · odd energy fraction, learned decoder weight, full cross correlation, trained, readout
  3. [3] § Results › Processing asymmetry selectively increased sign information while reducing unsigned information ↔ env_symmetry_stage2_revised_additional.py, lines 1080–1114 · score 0.65 · polarity invariant, unsigned information, unsigned spatial, corr, sweep, accuracy
  4. [4] § STAR★Methods › Method details › Tactile sensing model ↔ env_symmetry_stage1._revised.py, lines 18–32 · score 0.63 · receptive field, body surface, tactile, Gaussian, sensor
  5. [5] § Results › A decoder trained on the full cross-correlation acquired an odd-like readout structure ↔ env_symmetry_stage2_revised_additional.py, lines 864–937 · score 0.62 · scalar odd, learned decoder, balanced sign accuracy, full cross correlation, logistic, trained
  6. [6] § Results › Sign recovery was robust to noise but depended on angular range and kernel scale ↔ env_symmetry_stage2.py, lines 546–673 · score 0.59 · symmetric placement, wider ranges, sigma_k, cross correlation, max, regimes
  7. [7] § STAR★Methods › Quantification and statistical analysis › Learned full cross-correlation decoder ↔ env_symmetry_stage2_revised_additional.py, lines 1045–1072 · score 0.57 · learned decoder weight, odd components, correlation
  8. [8] § STAR★Methods › Quantification and statistical analysis › Numerical implementation ↔ env_symmetry_stage2_revised_additional.py, lines 1–57 · score 0.56 · NumPy, learned decoder, Matplotlib, seed, sweeps

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,213 lines · 44 KB · no license · 6 matches

  1. #!/usr/bin/env python3
  2. """
  3. Revised Stage 2 analysis for the iScience bilateral symmetry manuscript.
  4. Purpose
  5. -------
  6. This script replaces the single-seed Stage 2 analysis with reviewer-oriented
  7. additional analyses:
  8. 1. Multi-seed gamma sweep with mean, SD, and 95% CI.
  9. 2. Decomposition of mutual information into:
  10. - I(phi; m)
  11. - I(sign(phi); m)
  12. - I(|phi|; m)
  13. 3. Sensitivity of mutual-information estimates to histogram bin number.
  14. 4. Endpoint robustness tests for assumptions requested by reviewers:
  15. - sensor noise
  16. - kernel width
  17. - angular range
  18. - asymmetric source prior
  19. - non-Gaussian noise
  20. - different signal spectra
  21. - different source distributions
  22. - different sensor spacings
  23. 5. Learned decoder control using the full cross-correlation vector C_LR(tau),
  24. with analysis of the learned even/odd weight components.
  25. The script is standalone and uses only NumPy and Matplotlib.
  26. Outputs are written by default to:
  27. ~/Desktop/results/env_symmetry_stage2_revised/
  28. Typical commands
  29. ----------------
  30. Smoke test:
  31. python3 -u env_symmetry_stage2_revised_additional.py --mode smoke
  32. Reviewer-facing quick run:
  33. python3 -u env_symmetry_stage2_revised_additional.py --mode quick
  34. Full run:
  35. python3 -u env_symmetry_stage2_revised_additional.py --mode full --resume
  36. """
  37. from __future__ import annotations
  38. import argparse
  39. import csv
  40. import hashlib
  41. import json
  42. import math
  43. import time
  44. from dataclasses import asdict, dataclass
  45. from pathlib import Path
  46. from typing import Iterable
  47. import matplotlib.pyplot as plt
  48. import numpy as np
  49. # =============================================================================
  50. # Configuration
  51. # =============================================================================
  52. @dataclass
  53. class Config:
  54. # Signal
  55. fs: float = 2000.0
  56. T_sig: float = 2.0
  57. f_lo: float = 1.0
  58. f_hi: float = 8.0
  59. spectrum_mode: str = "baseline" # baseline, low_band, high_band, broad_band
  60. source_distribution: str = "uniform" # uniform, central_cluster, edge_cluster
  61. phi_prior: str = "symmetric" # symmetric, left_heavy, right_heavy
  62. # Noise
  63. noise_sigma: float = 0.20
  64. noise_model: str = "gaussian" # gaussian, laplace, student_t
  65. # Geometry
  66. d_base: float = 1.0
  67. c_sound: float = 1.0
  68. eps_shift: float = 0.0
  69. eps_tilt: float = 0.0
  70. eps_front_back: float = 0.0
  71. # Cross-correlation and kernels
  72. tau_max_frac: float = 1.2
  73. n_tau: int = 161
  74. sigma_k_frac: float = 0.4
  75. d0_frac: float = 0.5
  76. # Experiment
  77. n_trials: int = 600
  78. n_gamma: int = 21
  79. n_bins_mi: int = 20
  80. bin_sensitivity: tuple[int, ...] = (10, 15, 20, 30, 40)
  81. phi_max: float = math.pi / 6
  82. # Seeds
  83. seed_base: int = 20260418
  84. n_seeds: int = 50
  85. # Learned decoder
  86. decoder_trials: int = 1500
  87. decoder_seeds: int = 10
  88. decoder_train_frac: float = 0.70
  89. decoder_l2: float = 1e-2
  90. decoder_lr: float = 0.25
  91. decoder_iters: int = 500
  92. def tau_max(self) -> float:
  93. return self.tau_max_frac * (self.d_base / self.c_sound)
  94. def sigma_k(self) -> float:
  95. return self.sigma_k_frac * (self.d_base / self.c_sound)
  96. def d0(self) -> float:
  97. return self.d0_frac * (self.d_base / self.c_sound)
  98. @dataclass
  99. class ModePreset:
  100. n_trials: int
  101. n_seeds: int
  102. n_gamma: int
  103. n_tau: int
  104. fs: float
  105. T_sig: float
  106. decoder_trials: int
  107. decoder_seeds: int
  108. decoder_iters: int
  109. MODE_PRESETS = {
  110. "smoke": ModePreset(
  111. n_trials=24,
  112. n_seeds=2,
  113. n_gamma=5,
  114. n_tau=41,
  115. fs=400.0,
  116. T_sig=0.35,
  117. decoder_trials=80,
  118. decoder_seeds=2,
  119. decoder_iters=80,
  120. ),
  121. "quick": ModePreset(
  122. n_trials=180,
  123. n_seeds=8,
  124. n_gamma=11,
  125. n_tau=101,
  126. fs=1000.0,
  127. T_sig=1.0,
  128. decoder_trials=600,
  129. decoder_seeds=4,
  130. decoder_iters=250,
  131. ),
  132. "full": ModePreset(
  133. n_trials=600,
  134. n_seeds=50,
  135. n_gamma=21,
  136. n_tau=161,
  137. fs=2000.0,
  138. T_sig=2.0,
  139. decoder_trials=1500,
  140. decoder_seeds=10,
  141. decoder_iters=500,
  142. ),
  143. }
  144. # =============================================================================
  145. # Utilities
  146. # =============================================================================
  147. START_TIME = time.time()
  148. def log(msg: str) -> None:
  149. elapsed = time.time() - START_TIME
  150. print(f"[{elapsed:9.2f}s] {msg}", flush=True)
  151. def ensure_dir(path: Path) -> Path:
  152. path.mkdir(parents=True, exist_ok=True)
  153. return path
  154. def jsonable_cfg(cfg: Config) -> dict:
  155. d = asdict(cfg)
  156. d["bin_sensitivity"] = list(cfg.bin_sensitivity)
  157. return d
  158. def stable_hash(obj: dict) -> str:
  159. raw = json.dumps(obj, sort_keys=True, separators=(",", ":"))
  160. return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
  161. def seed_for(cfg: Config, seed_index: int, tag: str) -> int:
  162. h = int(hashlib.sha1(tag.encode("utf-8")).hexdigest()[:8], 16)
  163. return int((cfg.seed_base + 10007 * seed_index + h) % (2**32 - 1))
  164. def ci95(values: np.ndarray, axis: int = 0) -> np.ndarray:
  165. values = np.asarray(values, dtype=float)
  166. n = values.shape[axis]
  167. if n <= 1:
  168. return np.zeros_like(np.mean(values, axis=axis))
  169. return 1.96 * np.std(values, axis=axis, ddof=1) / math.sqrt(n)
  170. def write_csv(path: Path, header: list[str], rows: Iterable[Iterable]) -> None:
  171. with open(path, "w", newline="") as f:
  172. w = csv.writer(f)
  173. w.writerow(header)
  174. for row in rows:
  175. w.writerow(row)
  176. def save_json(path: Path, obj: dict) -> None:
  177. with open(path, "w") as f:
  178. json.dump(obj, f, indent=2)
  179. # =============================================================================
  180. # Geometry and signal generation
  181. # =============================================================================
  182. def sensor_pair(
  183. d: float,
  184. eps_shift: float = 0.0,
  185. eps_tilt: float = 0.0,
  186. eps_front_back: float = 0.0,
  187. ) -> tuple[np.ndarray, np.ndarray]:
  188. x1 = np.array([-d / 2.0, 0.0], dtype=float)
  189. x2 = np.array([+d / 2.0, 0.0], dtype=float)
  190. if eps_tilt != 0.0:
  191. c, s = np.cos(eps_tilt), np.sin(eps_tilt)
  192. R = np.array([[c, -s], [s, c]], dtype=float)
  193. x1, x2 = R @ x1, R @ x2
  194. if eps_shift != 0.0:
  195. sh = np.array([0.0, eps_shift], dtype=float)
  196. x1, x2 = x1 + sh, x2 + sh
  197. if eps_front_back != 0.0:
  198. x1 = x1 + np.array([0.0, -eps_front_back / 2.0], dtype=float)
  199. x2 = x2 + np.array([0.0, +eps_front_back / 2.0], dtype=float)
  200. return x1, x2
  201. def spectrum_edges(cfg: Config) -> tuple[float, float]:
  202. if cfg.spectrum_mode == "baseline":
  203. return cfg.f_lo, cfg.f_hi
  204. if cfg.spectrum_mode == "low_band":
  205. return 0.5, 4.0
  206. if cfg.spectrum_mode == "high_band":
  207. return 4.0, 16.0
  208. if cfg.spectrum_mode == "broad_band":
  209. return 0.5, 20.0
  210. raise ValueError(f"unknown spectrum_mode: {cfg.spectrum_mode}")
  211. def generate_source(cfg: Config, rng: np.random.Generator) -> np.ndarray:
  212. N = max(8, int(round(cfg.T_sig * cfg.fs)))
  213. white = rng.standard_normal(N)
  214. freqs = np.fft.rfftfreq(N, d=1.0 / cfg.fs)
  215. f_lo, f_hi = spectrum_edges(cfg)
  216. mask = (freqs >= f_lo) & (freqs <= f_hi)
  217. if not np.any(mask):
  218. # Fall back to all nonzero frequencies for very small smoke settings.
  219. mask = freqs > 0
  220. X = np.fft.rfft(white) * mask
  221. x = np.fft.irfft(X, n=N)
  222. std = np.std(x)
  223. return x / std if std > 1e-12 else x
  224. def fractional_delay(x: np.ndarray, d_samples: float) -> np.ndarray:
  225. N = len(x)
  226. t = np.arange(N, dtype=float) - d_samples
  227. return np.interp(t, np.arange(N, dtype=float), x, left=0.0, right=0.0)
  228. def noise_vector(cfg: Config, rng: np.random.Generator, size: int) -> np.ndarray:
  229. if cfg.noise_model == "gaussian":
  230. return rng.normal(0.0, cfg.noise_sigma, size=size)
  231. if cfg.noise_model == "laplace":
  232. # Laplace variance = 2 b^2, so b is scaled to match requested std.
  233. b = cfg.noise_sigma / math.sqrt(2.0)
  234. return rng.laplace(0.0, b, size=size)
  235. if cfg.noise_model == "student_t":
  236. # df=3 has variance 3; scale to requested std.
  237. return rng.standard_t(df=3, size=size) * (cfg.noise_sigma / math.sqrt(3.0))
  238. raise ValueError(f"unknown noise_model: {cfg.noise_model}")
  239. def sensor_signals(
  240. phi: float,
  241. x1: np.ndarray,
  242. x2: np.ndarray,
  243. src: np.ndarray,
  244. cfg: Config,
  245. rng: np.random.Generator,
  246. ) -> tuple[np.ndarray, np.ndarray]:
  247. u = np.array([np.sin(phi), np.cos(phi)], dtype=float)
  248. tau1 = (x1 @ u) / cfg.c_sound
  249. tau2 = (x2 @ u) / cfg.c_sound
  250. s1 = fractional_delay(src, tau1 * cfg.fs)
  251. s2 = fractional_delay(src, tau2 * cfg.fs)
  252. s1 = s1 + noise_vector(cfg, rng, len(s1))
  253. s2 = s2 + noise_vector(cfg, rng, len(s2))
  254. return s1, s2
  255. def cross_correlation(sL: np.ndarray, sR: np.ndarray, taus: np.ndarray, cfg: Config) -> np.ndarray:
  256. N = len(sL)
  257. c_full = np.correlate(sL, sR, mode="full") / N
  258. lags = np.arange(-(N - 1), N, dtype=float) / cfg.fs
  259. return np.interp(taus, lags, c_full, left=0.0, right=0.0)
  260. # =============================================================================
  261. # Source distributions
  262. # =============================================================================
  263. def sample_phi(cfg: Config, n: int, rng: np.random.Generator) -> np.ndarray:
  264. pm = cfg.phi_max
  265. if cfg.source_distribution == "uniform":
  266. mag = rng.uniform(0.0, pm, size=n)
  267. elif cfg.source_distribution == "central_cluster":
  268. mag = np.abs(rng.normal(loc=0.25 * pm, scale=0.12 * pm, size=n))
  269. mag = np.clip(mag, 0.0, pm)
  270. elif cfg.source_distribution == "edge_cluster":
  271. mag = np.abs(rng.normal(loc=0.75 * pm, scale=0.12 * pm, size=n))
  272. mag = np.clip(mag, 0.0, pm)
  273. else:
  274. raise ValueError(f"unknown source_distribution: {cfg.source_distribution}")
  275. if cfg.phi_prior == "symmetric":
  276. p_positive = 0.5
  277. elif cfg.phi_prior == "left_heavy":
  278. p_positive = 0.30
  279. elif cfg.phi_prior == "right_heavy":
  280. p_positive = 0.70
  281. else:
  282. raise ValueError(f"unknown phi_prior: {cfg.phi_prior}")
  283. signs = np.where(rng.random(n) < p_positive, 1.0, -1.0)
  284. # Avoid exact zero labels by imposing a small lower bound on magnitude.
  285. mag = np.maximum(mag, 1e-6)
  286. return signs * mag
  287. # =============================================================================
  288. # Kernels and readout
  289. # =============================================================================
  290. def gaussian_even(taus: np.ndarray, sigma: float) -> np.ndarray:
  291. g = np.exp(-(taus ** 2) / (2.0 * sigma ** 2))
  292. denom = np.max(np.abs(g))
  293. return g / denom if denom > 1e-12 else g
  294. def gaussian_odd(taus: np.ndarray, sigma: float) -> np.ndarray:
  295. g = (taus / sigma) * np.exp(-(taus ** 2) / (2.0 * sigma ** 2))
  296. denom = np.max(np.abs(g))
  297. return g / denom if denom > 1e-12 else g
  298. def kernel_integral(taus: np.ndarray, gamma: float, sigma: float) -> np.ndarray:
  299. return np.cos(gamma) * gaussian_even(taus, sigma) + np.sin(gamma) * gaussian_odd(taus, sigma)
  300. def kernel_two_tap(taus: np.ndarray, gamma: float, d0: float) -> np.ndarray:
  301. k = np.zeros_like(taus)
  302. i_plus = int(np.argmin(np.abs(taus - d0)))
  303. i_minus = int(np.argmin(np.abs(taus + d0)))
  304. k[i_plus] += np.cos(gamma) + np.sin(gamma)
  305. k[i_minus] += np.cos(gamma) - np.sin(gamma)
  306. return k
  307. def kernels_for_gammas(cfg: Config, kernel_type: str = "integral") -> tuple[np.ndarray, np.ndarray, np.ndarray]:
  308. taus = np.linspace(-cfg.tau_max(), cfg.tau_max(), cfg.n_tau)
  309. gammas = np.linspace(0.0, math.pi / 2.0, cfg.n_gamma)
  310. if kernel_type == "integral":
  311. K = np.vstack([kernel_integral(taus, g, cfg.sigma_k()) for g in gammas])
  312. elif kernel_type == "two_tap":
  313. K = np.vstack([kernel_two_tap(taus, g, cfg.d0()) for g in gammas])
  314. else:
  315. raise ValueError(f"unknown kernel_type: {kernel_type}")
  316. return taus, gammas, K
  317. def apply_kernels(C: np.ndarray, K: np.ndarray, taus: np.ndarray) -> np.ndarray:
  318. dtau = float(taus[1] - taus[0]) if len(taus) > 1 else 1.0
  319. # C: trials x tau, K: gamma x tau -> M: trials x gamma
  320. return C @ K.T * dtau
  321. # =============================================================================
  322. # Diagnostics
  323. # =============================================================================
  324. def discretize_equal_width(x: np.ndarray, n_bins: int) -> np.ndarray:
  325. x = np.asarray(x, dtype=float)
  326. lo = float(np.min(x))
  327. hi = float(np.max(x))
  328. if abs(hi - lo) < 1e-12:
  329. return np.zeros_like(x, dtype=int)
  330. edges = np.linspace(lo - 1e-12, hi + 1e-12, n_bins + 1)
  331. idx = np.digitize(x, edges) - 1
  332. return np.clip(idx, 0, n_bins - 1)
  333. def mutual_information_discrete(a: np.ndarray, b: np.ndarray, n_a: int | None = None, n_b: int | None = None) -> float:
  334. a = np.asarray(a, dtype=int)
  335. b = np.asarray(b, dtype=int)
  336. if len(a) != len(b):
  337. raise ValueError("a and b must have the same length")
  338. if n_a is None:
  339. n_a = int(a.max()) + 1 if len(a) else 0
  340. if n_b is None:
  341. n_b = int(b.max()) + 1 if len(b) else 0
  342. if n_a <= 0 or n_b <= 0:
  343. return 0.0
  344. H = np.zeros((n_a, n_b), dtype=float)
  345. for ai, bi in zip(a, b):
  346. if 0 <= ai < n_a and 0 <= bi < n_b:
  347. H[ai, bi] += 1.0
  348. total = H.sum()
  349. if total <= 0:
  350. return 0.0
  351. Pxy = H / total
  352. Px = Pxy.sum(axis=1, keepdims=True)
  353. Py = Pxy.sum(axis=0, keepdims=True)
  354. with np.errstate(divide="ignore", invalid="ignore"):
  355. ratio = Pxy / (Px * Py)
  356. log_term = np.where(Pxy > 0, np.log(ratio + 1e-30), 0.0)
  357. return float(np.sum(Pxy * log_term))
  358. def mi_continuous_pair(x: np.ndarray, y: np.ndarray, n_bins: int) -> float:
  359. if np.std(x) < 1e-12 or np.std(y) < 1e-12:
  360. return 0.0
  361. xb = discretize_equal_width(x, n_bins)
  362. yb = discretize_equal_width(y, n_bins)
  363. return mutual_information_discrete(xb, yb, n_bins, n_bins)
  364. def mi_discrete_continuous(label: np.ndarray, y: np.ndarray, n_label: int, n_bins_y: int) -> float:
  365. if np.std(y) < 1e-12:
  366. return 0.0
  367. yb = discretize_equal_width(y, n_bins_y)
  368. return mutual_information_discrete(label.astype(int), yb, n_label, n_bins_y)
  369. def sign_labels(phi: np.ndarray) -> np.ndarray:
  370. return (phi > 0).astype(int)
  371. def sign_accuracy(phi: np.ndarray, m: np.ndarray) -> float:
  372. if np.std(m) < 1e-12:
  373. return 0.5
  374. y = sign_labels(phi)
  375. pred_plus = (m > 0).astype(int)
  376. acc_plus = np.mean(pred_plus == y)
  377. acc_minus = np.mean((1 - pred_plus) == y)
  378. return float(max(acc_plus, acc_minus))
  379. def balanced_sign_accuracy(phi: np.ndarray, m: np.ndarray) -> float:
  380. if np.std(m) < 1e-12:
  381. return 0.5
  382. y = sign_labels(phi)
  383. pred = (m > 0).astype(int)
  384. accs = []
  385. for polarity in (pred, 1 - pred):
  386. tprs = []
  387. for cls in (0, 1):
  388. mask = y == cls
  389. if np.any(mask):
  390. tprs.append(np.mean(polarity[mask] == y[mask]))
  391. accs.append(float(np.mean(tprs)) if tprs else 0.5)
  392. return float(max(accs))
  393. def pearson_signed_and_abs(phi: np.ndarray, m: np.ndarray) -> tuple[float, float]:
  394. if np.std(phi) < 1e-12 or np.std(m) < 1e-12:
  395. return 0.0, 0.0
  396. corr = float(np.corrcoef(phi, m)[0, 1])
  397. return corr, abs(corr)
  398. def parity_odd_strength(phi: np.ndarray, m: np.ndarray, n_bins: int) -> float:
  399. """Normalized magnitude of the odd part of E[m | phi].
  400. This is not a formal performance measure; it is a diagnostic that checks
  401. whether the conditional mean has left-right antisymmetric structure.
  402. """
  403. phi = np.asarray(phi, dtype=float)
  404. m = np.asarray(m, dtype=float)
  405. pm = float(np.max(np.abs(phi)))
  406. if pm <= 0 or np.std(m) < 1e-12:
  407. return 0.0
  408. edges = np.linspace(-pm - 1e-12, pm + 1e-12, n_bins + 1)
  409. means = np.full(n_bins, np.nan)
  410. idx = np.clip(np.digitize(phi, edges) - 1, 0, n_bins - 1)
  411. for b in range(n_bins):
  412. sel = idx == b
  413. if np.any(sel):
  414. means[b] = np.mean(m[sel])
  415. # Fill empty bins by interpolation over valid bins.
  416. valid = np.isfinite(means)
  417. if valid.sum() < 2:
  418. return 0.0
  419. xs = np.arange(n_bins)
  420. means = np.interp(xs, xs[valid], means[valid])
  421. odd = np.zeros(n_bins)
  422. for b in range(n_bins):
  423. bm = n_bins - 1 - b
  424. odd[b] = 0.5 * (means[b] - means[bm])
  425. scale = np.std(means)
  426. return float(np.mean(np.abs(odd)) / scale) if scale > 1e-12 else 0.0
  427. def diagnostics_for_readout(phi: np.ndarray, m: np.ndarray, n_bins: int) -> dict[str, float]:
  428. corr, abs_corr = pearson_signed_and_abs(phi, m)
  429. labels = sign_labels(phi)
  430. return {
  431. "mi_phi_m": mi_continuous_pair(phi, m, n_bins),
  432. "mi_sign_m": mi_discrete_continuous(labels, m, 2, n_bins),
  433. "mi_absphi_m": mi_continuous_pair(np.abs(phi), m, n_bins),
  434. "sign_acc": sign_accuracy(phi, m),
  435. "balanced_sign_acc": balanced_sign_accuracy(phi, m),
  436. "corr_phi_m": corr,
  437. "abs_corr_phi_m": abs_corr,
  438. "odd_strength": parity_odd_strength(phi, m, n_bins),
  439. }
  440. # =============================================================================
  441. # Trial data generation and caching
  442. # =============================================================================
  443. def condition_key(cfg: Config, seed_index: int, tag: str) -> str:
  444. keep = {
  445. "fs": cfg.fs,
  446. "T_sig": cfg.T_sig,
  447. "f_lo": cfg.f_lo,
  448. "f_hi": cfg.f_hi,
  449. "spectrum_mode": cfg.spectrum_mode,
  450. "source_distribution": cfg.source_distribution,
  451. "phi_prior": cfg.phi_prior,
  452. "noise_sigma": cfg.noise_sigma,
  453. "noise_model": cfg.noise_model,
  454. "d_base": cfg.d_base,
  455. "c_sound": cfg.c_sound,
  456. "eps_shift": cfg.eps_shift,
  457. "eps_tilt": cfg.eps_tilt,
  458. "eps_front_back": cfg.eps_front_back,
  459. "tau_max_frac": cfg.tau_max_frac,
  460. "n_tau": cfg.n_tau,
  461. "phi_max": cfg.phi_max,
  462. "n_trials": cfg.n_trials,
  463. "seed_base": cfg.seed_base,
  464. "seed_index": seed_index,
  465. "tag": tag,
  466. }
  467. return stable_hash(keep)
  468. def generate_trial_data(
  469. cfg: Config,
  470. seed_index: int,
  471. outdir: Path,
  472. tag: str,
  473. resume: bool = True,
  474. n_trials_override: int | None = None,
  475. ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
  476. cfg_local = Config(**jsonable_cfg(cfg))
  477. if n_trials_override is not None:
  478. cfg_local.n_trials = int(n_trials_override)
  479. key = condition_key(cfg_local, seed_index, tag)
  480. cache_dir = ensure_dir(outdir / "cache")
  481. cache_path = cache_dir / f"trialdata_{tag}_{key}.npz"
  482. if resume and cache_path.exists():
  483. z = np.load(cache_path)
  484. return z["phi"], z["C"], z["taus"]
  485. rng = np.random.default_rng(seed_for(cfg_local, seed_index, tag))
  486. taus = np.linspace(-cfg_local.tau_max(), cfg_local.tau_max(), cfg_local.n_tau)
  487. x1, x2 = sensor_pair(
  488. cfg_local.d_base,
  489. eps_shift=cfg_local.eps_shift,
  490. eps_tilt=cfg_local.eps_tilt,
  491. eps_front_back=cfg_local.eps_front_back,
  492. )
  493. phi = sample_phi(cfg_local, cfg_local.n_trials, rng)
  494. C = np.zeros((cfg_local.n_trials, cfg_local.n_tau), dtype=float)
  495. for i in range(cfg_local.n_trials):
  496. src = generate_source(cfg_local, rng)
  497. sL, sR = sensor_signals(phi[i], x1, x2, src, cfg_local, rng)
  498. C[i] = cross_correlation(sL, sR, taus, cfg_local)
  499. np.savez_compressed(cache_path, phi=phi, C=C, taus=taus)
  500. return phi, C, taus
  501. # =============================================================================
  502. # Main analyses
  503. # =============================================================================
  504. def run_main_multiseed(cfg: Config, outdir: Path, resume: bool) -> dict:
  505. log("[main] multi-seed symmetric gamma sweep")
  506. rows = []
  507. per_seed = []
  508. for si in range(cfg.n_seeds):
  509. log(f"[main] seed {si + 1}/{cfg.n_seeds}")
  510. phi, C, taus = generate_trial_data(cfg, si, outdir, tag="main", resume=resume)
  511. _, gammas, K = kernels_for_gammas(cfg, kernel_type="integral")
  512. M = apply_kernels(C, K, taus)
  513. seed_rows = []
  514. for gi, gamma in enumerate(gammas):
  515. d = diagnostics_for_readout(phi, M[:, gi], cfg.n_bins_mi)
  516. row = {
  517. "seed_index": si,
  518. "gamma": float(gamma),
  519. "A_proc": float(np.sin(gamma)),
  520. **d,
  521. }
  522. rows.append(row)
  523. seed_rows.append(row)
  524. per_seed.append(seed_rows)
  525. metrics = [
  526. "mi_phi_m",
  527. "mi_sign_m",
  528. "mi_absphi_m",
  529. "sign_acc",
  530. "balanced_sign_acc",
  531. "corr_phi_m",
  532. "abs_corr_phi_m",
  533. "odd_strength",
  534. ]
  535. gammas = np.linspace(0.0, math.pi / 2.0, cfg.n_gamma)
  536. A_proc = np.sin(gammas)
  537. summary_rows = []
  538. arr_by_metric = {}
  539. for metric in metrics:
  540. arr = np.array([[per_seed[si][gi][metric] for gi in range(cfg.n_gamma)] for si in range(cfg.n_seeds)])
  541. arr_by_metric[metric] = arr
  542. for gi, gamma in enumerate(gammas):
  543. row = {"gamma": float(gamma), "A_proc": float(A_proc[gi])}
  544. for metric in metrics:
  545. vals = arr_by_metric[metric][:, gi]
  546. row[f"{metric}_mean"] = float(np.mean(vals))
  547. row[f"{metric}_sd"] = float(np.std(vals, ddof=1)) if len(vals) > 1 else 0.0
  548. row[f"{metric}_ci95"] = float(ci95(vals, axis=0))
  549. summary_rows.append(row)
  550. write_csv(
  551. outdir / "main_gamma_sweep_per_seed.csv",
  552. ["seed_index", "gamma", "A_proc"] + metrics,
  553. ([r["seed_index"], r["gamma"], r["A_proc"]] + [r[m] for m in metrics] for r in rows),
  554. )
  555. header = ["gamma", "A_proc"]
  556. for metric in metrics:
  557. header += [f"{metric}_mean", f"{metric}_sd", f"{metric}_ci95"]
  558. write_csv(
  559. outdir / "main_gamma_sweep_summary.csv",
  560. header,
  561. ([r[h] for h in header] for r in summary_rows),
  562. )
  563. plot_main_gamma(summary_rows, outdir)
  564. out = {
  565. "metrics": metrics,
  566. "summary_rows": summary_rows,
  567. }
  568. save_json(outdir / "main_gamma_sweep_summary.json", out)
  569. return out
  570. def run_bin_sensitivity(cfg: Config, outdir: Path, resume: bool) -> dict:
  571. log("[bin] MI bin sensitivity at A_proc endpoints")
  572. rows = []
  573. endpoint_gammas = {"even_Aproc0": 0.0, "odd_Aproc1": math.pi / 2.0}
  574. for n_bins in cfg.bin_sensitivity:
  575. vals_by_endpoint = {name: [] for name in endpoint_gammas}
  576. for si in range(cfg.n_seeds):
  577. phi, C, taus = generate_trial_data(cfg, si, outdir, tag="main", resume=resume)
  578. for name, gamma in endpoint_gammas.items():
  579. k = kernel_integral(taus, gamma, cfg.sigma_k())
  580. m = apply_kernels(C, k[None, :], taus)[:, 0]
  581. vals_by_endpoint[name].append(diagnostics_for_readout(phi, m, n_bins))
  582. for name in endpoint_gammas:
  583. for metric in ("mi_phi_m", "mi_sign_m", "mi_absphi_m", "balanced_sign_acc"):
  584. vals = np.array([v[metric] for v in vals_by_endpoint[name]], dtype=float)
  585. rows.append({
  586. "n_bins": int(n_bins),
  587. "endpoint": name,
  588. "metric": metric,
  589. "mean": float(np.mean(vals)),
  590. "sd": float(np.std(vals, ddof=1)) if len(vals) > 1 else 0.0,
  591. "ci95": float(ci95(vals, axis=0)),
  592. })
  593. write_csv(
  594. outdir / "mi_bin_sensitivity.csv",
  595. ["n_bins", "endpoint", "metric", "mean", "sd", "ci95"],
  596. ([r["n_bins"], r["endpoint"], r["metric"], r["mean"], r["sd"], r["ci95"]] for r in rows),
  597. )
  598. save_json(outdir / "mi_bin_sensitivity.json", {"rows": rows})
  599. plot_bin_sensitivity(rows, outdir)
  600. return {"rows": rows}
  601. def cfg_with(cfg: Config, **kwargs) -> Config:
  602. d = jsonable_cfg(cfg)
  603. d.update(kwargs)
  604. if isinstance(d.get("bin_sensitivity"), list):
  605. d["bin_sensitivity"] = tuple(d["bin_sensitivity"])
  606. return Config(**d)
  607. def robustness_conditions(cfg: Config) -> list[tuple[str, str, Config]]:
  608. conds: list[tuple[str, str, Config]] = []
  609. for val in (0.05, 0.10, 0.20, 0.40):
  610. conds.append(("noise_sigma", f"{val:.2f}", cfg_with(cfg, noise_sigma=val)))
  611. for val in (0.2, 0.4, 0.6, 0.8):
  612. conds.append(("sigma_k_frac", f"{val:.2f}", cfg_with(cfg, sigma_k_frac=val)))
  613. for val in (math.pi / 6, math.pi / 4, math.pi / 3, 0.4 * math.pi):
  614. conds.append(("phi_max", f"{val:.6f}", cfg_with(cfg, phi_max=val)))
  615. for val in ("symmetric", "left_heavy", "right_heavy"):
  616. conds.append(("phi_prior", val, cfg_with(cfg, phi_prior=val)))
  617. for val in ("gaussian", "laplace", "student_t"):
  618. conds.append(("noise_model", val, cfg_with(cfg, noise_model=val)))
  619. for val in ("baseline", "low_band", "high_band", "broad_band"):
  620. conds.append(("spectrum_mode", val, cfg_with(cfg, spectrum_mode=val)))
  621. for val in ("uniform", "central_cluster", "edge_cluster"):
  622. conds.append(("source_distribution", val, cfg_with(cfg, source_distribution=val)))
  623. for val in (0.5, 1.0, 1.5, 2.0):
  624. conds.append(("d_base", f"{val:.2f}", cfg_with(cfg, d_base=val)))
  625. # Deduplicate exact repeats while preserving order.
  626. seen = set()
  627. out = []
  628. for group, value, cc in conds:
  629. key = (group, value)
  630. if key not in seen:
  631. seen.add(key)
  632. out.append((group, value, cc))
  633. return out
  634. def run_robustness_endpoints(cfg: Config, outdir: Path, resume: bool) -> dict:
  635. log("[robustness] endpoint tests for model assumptions")
  636. endpoint_gammas = {"even_Aproc0": 0.0, "odd_Aproc1": math.pi / 2.0}
  637. rows = []
  638. conditions = robustness_conditions(cfg)
  639. for ci, (group, value, cc) in enumerate(conditions):
  640. tag = f"robust_{group}_{value}".replace(".", "p").replace("/", "_")
  641. log(f"[robustness] {ci + 1}/{len(conditions)} {group}={value}")
  642. endpoint_metrics: dict[str, list[dict[str, float]]] = {name: [] for name in endpoint_gammas}
  643. for si in range(cc.n_seeds):
  644. phi, C, taus = generate_trial_data(cc, si, outdir, tag=tag, resume=resume)
  645. for name, gamma in endpoint_gammas.items():
  646. k = kernel_integral(taus, gamma, cc.sigma_k())
  647. m = apply_kernels(C, k[None, :], taus)[:, 0]
  648. endpoint_metrics[name].append(diagnostics_for_readout(phi, m, cc.n_bins_mi))
  649. for endpoint, dicts in endpoint_metrics.items():
  650. for metric in ("mi_phi_m", "mi_sign_m", "mi_absphi_m", "balanced_sign_acc", "sign_acc", "abs_corr_phi_m", "odd_strength"):
  651. vals = np.array([d[metric] for d in dicts], dtype=float)
  652. rows.append({
  653. "condition_group": group,
  654. "condition_value": value,
  655. "endpoint": endpoint,
  656. "metric": metric,
  657. "mean": float(np.mean(vals)),
  658. "sd": float(np.std(vals, ddof=1)) if len(vals) > 1 else 0.0,
  659. "ci95": float(ci95(vals, axis=0)),
  660. })
  661. write_csv(
  662. outdir / "robustness_endpoints_summary.csv",
  663. ["condition_group", "condition_value", "endpoint", "metric", "mean", "sd", "ci95"],
  664. ([r["condition_group"], r["condition_value"], r["endpoint"], r["metric"], r["mean"], r["sd"], r["ci95"]] for r in rows),
  665. )
  666. save_json(outdir / "robustness_endpoints_summary.json", {"rows": rows})
  667. plot_robustness(rows, outdir)
  668. return {"rows": rows}
  669. # =============================================================================
  670. # Learned decoder control
  671. # =============================================================================
  672. def standardize_train_test(X_train: np.ndarray, X_test: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
  673. mu = X_train.mean(axis=0)
  674. sd = X_train.std(axis=0)
  675. sd[sd < 1e-8] = 1.0
  676. return (X_train - mu) / sd, (X_test - mu) / sd, mu, sd
  677. def sigmoid(z: np.ndarray) -> np.ndarray:
  678. return 1.0 / (1.0 + np.exp(-np.clip(z, -40.0, 40.0)))
  679. def fit_logistic_l2(X: np.ndarray, y: np.ndarray, l2: float, lr: float, n_iter: int) -> tuple[np.ndarray, float]:
  680. n, p = X.shape
  681. w = np.zeros(p, dtype=float)
  682. b = 0.0
  683. y = y.astype(float)
  684. for it in range(n_iter):
  685. pred = sigmoid(X @ w + b)
  686. err = pred - y
  687. grad_w = (X.T @ err) / n + l2 * w
  688. grad_b = float(np.mean(err))
  689. # mild learning-rate decay for stability
  690. eta = lr / math.sqrt(1.0 + 0.01 * it)
  691. w -= eta * grad_w
  692. b -= eta * grad_b
  693. return w, b
  694. def accuracy_binary(y: np.ndarray, pred: np.ndarray) -> float:
  695. return float(np.mean(y.astype(int) == pred.astype(int)))
  696. def balanced_accuracy_binary(y: np.ndarray, pred: np.ndarray) -> float:
  697. vals = []
  698. for cls in (0, 1):
  699. mask = y == cls
  700. if np.any(mask):
  701. vals.append(np.mean(pred[mask] == y[mask]))
  702. return float(np.mean(vals)) if vals else 0.5
  703. def even_odd_components(vec: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
  704. rev = vec[::-1]
  705. even = 0.5 * (vec + rev)
  706. odd = 0.5 * (vec - rev)
  707. return even, odd
  708. def odd_energy_fraction(vec: np.ndarray) -> float:
  709. even, odd = even_odd_components(vec)
  710. e_even = float(np.sum(even ** 2))
  711. e_odd = float(np.sum(odd ** 2))
  712. denom = e_even + e_odd
  713. return e_odd / denom if denom > 1e-12 else 0.0
  714. def run_learned_decoder(cfg: Config, outdir: Path, resume: bool) -> dict:
  715. log("[decoder] learned full cross-correlation classifier")
  716. rows = []
  717. weight_rows = []
  718. all_weights = []
  719. all_taus = None
  720. cfg_dec = cfg_with(cfg, n_trials=cfg.decoder_trials)
  721. for si in range(cfg.decoder_seeds):
  722. log(f"[decoder] seed {si + 1}/{cfg.decoder_seeds}")
  723. phi, C, taus = generate_trial_data(cfg_dec, si, outdir, tag="decoder", resume=resume)
  724. all_taus = taus
  725. y = sign_labels(phi)
  726. rng = np.random.default_rng(seed_for(cfg, si, "decoder_split"))
  727. idx = rng.permutation(len(y))
  728. n_train = int(round(cfg.decoder_train_frac * len(y)))
  729. train_idx = idx[:n_train]
  730. test_idx = idx[n_train:]
  731. X_train_raw, X_test_raw = C[train_idx], C[test_idx]
  732. y_train, y_test = y[train_idx], y[test_idx]
  733. X_train, X_test, _, _ = standardize_train_test(X_train_raw, X_test_raw)
  734. w, b = fit_logistic_l2(X_train, y_train, cfg.decoder_l2, cfg.decoder_lr, cfg.decoder_iters)
  735. prob_test = sigmoid(X_test @ w + b)
  736. pred_test = (prob_test >= 0.5).astype(int)
  737. acc = accuracy_binary(y_test, pred_test)
  738. bacc = balanced_accuracy_binary(y_test, pred_test)
  739. odd_frac = odd_energy_fraction(w)
  740. # Scalar endpoint controls on the same test samples.
  741. k_even = kernel_integral(taus, 0.0, cfg.sigma_k())
  742. k_odd = kernel_integral(taus, math.pi / 2.0, cfg.sigma_k())
  743. m_even = apply_kernels(C[test_idx], k_even[None, :], taus)[:, 0]
  744. m_odd = apply_kernels(C[test_idx], k_odd[None, :], taus)[:, 0]
  745. scalar_even_bacc = balanced_sign_accuracy(phi[test_idx], m_even)
  746. scalar_odd_bacc = balanced_sign_accuracy(phi[test_idx], m_odd)
  747. scalar_even_misign = mi_discrete_continuous(sign_labels(phi[test_idx]), m_even, 2, cfg.n_bins_mi)
  748. scalar_odd_misign = mi_discrete_continuous(sign_labels(phi[test_idx]), m_odd, 2, cfg.n_bins_mi)
  749. rows.append({
  750. "seed_index": si,
  751. "full_decoder_acc": acc,
  752. "full_decoder_balanced_acc": bacc,
  753. "full_decoder_odd_energy_fraction": odd_frac,
  754. "scalar_even_balanced_acc": scalar_even_bacc,
  755. "scalar_odd_balanced_acc": scalar_odd_bacc,
  756. "scalar_even_mi_sign_m": scalar_even_misign,
  757. "scalar_odd_mi_sign_m": scalar_odd_misign,
  758. })
  759. all_weights.append(w)
  760. for t, wi in zip(taus, w):
  761. weight_rows.append([si, float(t), float(wi)])
  762. metrics = [k for k in rows[0].keys() if k != "seed_index"] if rows else []
  763. summary = {}
  764. for m in metrics:
  765. vals = np.array([r[m] for r in rows], dtype=float)
  766. summary[m] = {
  767. "mean": float(np.mean(vals)),
  768. "sd": float(np.std(vals, ddof=1)) if len(vals) > 1 else 0.0,
  769. "ci95": float(ci95(vals, axis=0)),
  770. }
  771. write_csv(outdir / "learned_decoder_per_seed.csv", ["seed_index"] + metrics, ([r["seed_index"]] + [r[m] for m in metrics] for r in rows))
  772. write_csv(outdir / "learned_decoder_weights.csv", ["seed_index", "tau", "weight"], weight_rows)
  773. write_csv(
  774. outdir / "learned_decoder_summary.csv",
  775. ["metric", "mean", "sd", "ci95"],
  776. ([m, summary[m]["mean"], summary[m]["sd"], summary[m]["ci95"]] for m in metrics),
  777. )
  778. if all_weights and all_taus is not None:
  779. W = np.vstack(all_weights)
  780. plot_decoder_weights(all_taus, W, outdir)
  781. save_json(outdir / "learned_decoder_summary.json", {"per_seed": rows, "summary": summary})
  782. return {"per_seed": rows, "summary": summary}
  783. # =============================================================================
  784. # Plotting
  785. # =============================================================================
  786. def plot_with_ci(ax, x: np.ndarray, mean: np.ndarray, ci: np.ndarray, label: str | None = None) -> None:
  787. ax.plot(x, mean, marker="o", markersize=3, label=label)
  788. ax.fill_between(x, mean - ci, mean + ci, alpha=0.2)
  789. def plot_main_gamma(summary_rows: list[dict], outdir: Path) -> Path:
  790. x = np.array([r["A_proc"] for r in summary_rows], dtype=float)
  791. fig, axes = plt.subplots(2, 3, figsize=(15, 8))
  792. panels = [
  793. ("mi_phi_m", "I(phi; m) [nats]"),
  794. ("mi_sign_m", "I(sign(phi); m) [nats]"),
  795. ("mi_absphi_m", "I(|phi|; m) [nats]"),
  796. ("balanced_sign_acc", "balanced sign accuracy"),
  797. ("abs_corr_phi_m", "|corr(phi, m)|"),
  798. ("odd_strength", "odd structure of E[m | phi]"),
  799. ]
  800. for ax, (metric, ylabel) in zip(axes.ravel(), panels):
  801. mean = np.array([r[f"{metric}_mean"] for r in summary_rows], dtype=float)
  802. ci = np.array([r[f"{metric}_ci95"] for r in summary_rows], dtype=float)
  803. plot_with_ci(ax, x, mean, ci)
  804. if metric == "balanced_sign_acc":
  805. ax.axhline(0.5, linestyle="--", linewidth=1.0)
  806. ax.set_ylim(0.3, 1.05)
  807. ax.set_xlabel("A_proc = sin(gamma)")
  808. ax.set_ylabel(ylabel)
  809. ax.grid(alpha=0.3)
  810. fig.suptitle("Revised main sweep: sign information separated from unsigned information")
  811. fig.tight_layout()
  812. path = outdir / "figure_revised_main_gamma_sweep.png"
  813. fig.savefig(path, dpi=180)
  814. plt.close(fig)
  815. return path
  816. def plot_bin_sensitivity(rows: list[dict], outdir: Path) -> Path:
  817. metrics = ["mi_phi_m", "mi_sign_m", "mi_absphi_m"]
  818. endpoints = ["even_Aproc0", "odd_Aproc1"]
  819. fig, axes = plt.subplots(1, 3, figsize=(15, 4))
  820. for ax, metric in zip(axes, metrics):
  821. for endpoint in endpoints:
  822. rs = [r for r in rows if r["metric"] == metric and r["endpoint"] == endpoint]
  823. rs = sorted(rs, key=lambda z: z["n_bins"])
  824. x = np.array([r["n_bins"] for r in rs], dtype=float)
  825. y = np.array([r["mean"] for r in rs], dtype=float)
  826. ci = np.array([r["ci95"] for r in rs], dtype=float)
  827. plot_with_ci(ax, x, y, ci, label=endpoint)
  828. ax.set_xlabel("histogram bins")
  829. ax.set_ylabel(metric)
  830. ax.grid(alpha=0.3)
  831. ax.legend(fontsize=8)
  832. fig.suptitle("Mutual-information bin sensitivity")
  833. fig.tight_layout()
  834. path = outdir / "figure_mi_bin_sensitivity.png"
  835. fig.savefig(path, dpi=180)
  836. plt.close(fig)
  837. return path
  838. def plot_robustness(rows: list[dict], outdir: Path) -> Path:
  839. # Compact plot: balanced sign accuracy for even and odd endpoints.
  840. groups = []
  841. for r in rows:
  842. g = r["condition_group"]
  843. if g not in groups:
  844. groups.append(g)
  845. for group in groups:
  846. rs = [r for r in rows if r["condition_group"] == group and r["metric"] == "balanced_sign_acc"]
  847. values = []
  848. labels = []
  849. for val in [] if not rs else sorted(set(r["condition_value"] for r in rs), key=str):
  850. labels.append(str(val))
  851. e = next(r for r in rs if r["condition_value"] == val and r["endpoint"] == "even_Aproc0")
  852. o = next(r for r in rs if r["condition_value"] == val and r["endpoint"] == "odd_Aproc1")
  853. values.append((e["mean"], e["ci95"], o["mean"], o["ci95"]))
  854. if not values:
  855. continue
  856. x = np.arange(len(values), dtype=float)
  857. even_mean = np.array([v[0] for v in values])
  858. even_ci = np.array([v[1] for v in values])
  859. odd_mean = np.array([v[2] for v in values])
  860. odd_ci = np.array([v[3] for v in values])
  861. fig, ax = plt.subplots(figsize=(max(7, 0.75 * len(values)), 4.5))
  862. width = 0.35
  863. ax.bar(x - width / 2, even_mean, width, yerr=even_ci, capsize=3, label="even A_proc=0")
  864. ax.bar(x + width / 2, odd_mean, width, yerr=odd_ci, capsize=3, label="odd A_proc=1")
  865. ax.axhline(0.5, linestyle="--", linewidth=1.0)
  866. ax.set_xticks(x)
  867. ax.set_xticklabels(labels, rotation=30, ha="right")
  868. ax.set_ylabel("balanced sign accuracy")
  869. ax.set_title(f"Robustness endpoint test: {group}")
  870. ax.set_ylim(0.3, 1.05)
  871. ax.grid(axis="y", alpha=0.3)
  872. ax.legend(fontsize=8)
  873. fig.tight_layout()
  874. path = outdir / f"figure_robustness_{group}.png"
  875. fig.savefig(path, dpi=180)
  876. plt.close(fig)
  877. return outdir / "figure_robustness_*.png"
  878. def plot_decoder_weights(taus: np.ndarray, W: np.ndarray, outdir: Path) -> Path:
  879. w_mean = W.mean(axis=0)
  880. w_ci = ci95(W, axis=0)
  881. even, odd = even_odd_components(w_mean)
  882. fig, axes = plt.subplots(1, 2, figsize=(12, 4))
  883. plot_with_ci(axes[0], taus, w_mean, w_ci, label="learned weight")
  884. axes[0].axhline(0.0, linewidth=1.0)
  885. axes[0].axvline(0.0, linewidth=1.0)
  886. axes[0].set_xlabel("tau")
  887. axes[0].set_ylabel("decoder weight")
  888. axes[0].set_title("Learned full-correlation decoder weight")
  889. axes[0].grid(alpha=0.3)
  890. axes[0].legend(fontsize=8)
  891. axes[1].plot(taus, even, marker="o", markersize=2, label="even component")
  892. axes[1].plot(taus, odd, marker="o", markersize=2, label="odd component")
  893. axes[1].axhline(0.0, linewidth=1.0)
  894. axes[1].axvline(0.0, linewidth=1.0)
  895. axes[1].set_xlabel("tau")
  896. axes[1].set_ylabel("component weight")
  897. axes[1].set_title("Even/odd decomposition of learned weight")
  898. axes[1].grid(alpha=0.3)
  899. axes[1].legend(fontsize=8)
  900. fig.tight_layout()
  901. path = outdir / "figure_learned_decoder_weights.png"
  902. fig.savefig(path, dpi=180)
  903. plt.close(fig)
  904. return path
  905. # =============================================================================
  906. # Output report
  907. # =============================================================================
  908. def make_readme(outdir: Path, cfg: Config, requested_analyses: list[str]) -> None:
  909. text = f"""# Revised Stage 2 outputs
  910. Generated by: env_symmetry_stage2_revised_additional.py
  911. ## Configuration
  912. ```json
  913. {json.dumps(jsonable_cfg(cfg), indent=2)}
  914. ```
  915. ## Analyses requested in this run
  916. {chr(10).join('- ' + a for a in requested_analyses)}
  917. ## Key files
  918. - `main_gamma_sweep_summary.csv`: multi-seed mean, SD, and 95% CI across A_proc.
  919. - `main_gamma_sweep_per_seed.csv`: seed-level values for all metrics.
  920. - `figure_revised_main_gamma_sweep.png`: revised main result figure separating sign and unsigned information.
  921. - `mi_bin_sensitivity.csv`: mutual-information bin sensitivity.
  922. - `robustness_endpoints_summary.csv`: endpoint robustness analyses for reviewer-requested assumptions.
  923. - `learned_decoder_summary.csv`: full cross-correlation learned decoder control.
  924. - `learned_decoder_weights.csv`: learned decoder weights across tau.
  925. - `figure_learned_decoder_weights.png`: even/odd decomposition of learned decoder weights.
  926. ## Interpretation notes for the manuscript
  927. - `mi_sign_m` is the direct information-theoretic measure for sign recovery.
  928. - `mi_absphi_m` separates unsigned spatial information from directional sign.
  929. - `balanced_sign_acc` should be used when priors are asymmetric because raw accuracy can be biased by class imbalance.
  930. - `corr_phi_m` is reported with its sign, but `abs_corr_phi_m` is the polarity-invariant diagnostic.
  931. - The learned-decoder odd-energy fraction tests whether a decoder trained on the full C_LR(tau) vector develops an odd-like readout structure.
  932. """
  933. (outdir / "README_FIRST_REVISED_STAGE2.md").write_text(text, encoding="utf-8")
  934. # =============================================================================
  935. # CLI
  936. # =============================================================================
  937. def parse_args() -> argparse.Namespace:
  938. parser = argparse.ArgumentParser(description="Revised Stage 2 reviewer analyses")
  939. parser.add_argument("--mode", choices=["smoke", "quick", "full"], default="quick")
  940. parser.add_argument("--outdir", type=str, default=str(Path.home() / "Desktop" / "results" / "env_symmetry_stage2_revised"))
  941. parser.add_argument("--resume", action="store_true", help="Reuse cached trial data when available")
  942. parser.add_argument("--seed-base", type=int, default=20260418)
  943. parser.add_argument("--n-trials", type=int, default=None)
  944. parser.add_argument("--n-seeds", type=int, default=None)
  945. parser.add_argument("--n-gamma", type=int, default=None)
  946. parser.add_argument("--n-tau", type=int, default=None)
  947. parser.add_argument("--fs", type=float, default=None)
  948. parser.add_argument("--T-sig", type=float, default=None)
  949. parser.add_argument("--noise", type=float, default=0.20)
  950. parser.add_argument("--phi-max", type=float, default=math.pi / 6)
  951. parser.add_argument("--sigma-k-frac", type=float, default=0.4)
  952. parser.add_argument("--main", action="store_true", help="Run main multi-seed gamma sweep")
  953. parser.add_argument("--bin-sensitivity", action="store_true", help="Run MI bin sensitivity")
  954. parser.add_argument("--robustness", action="store_true", help="Run endpoint robustness tests")
  955. parser.add_argument("--decoder", action="store_true", help="Run learned decoder control")
  956. parser.add_argument("--all", action="store_true", help="Run all analyses")
  957. return parser.parse_args()
  958. def build_config(args: argparse.Namespace) -> Config:
  959. preset = MODE_PRESETS[args.mode]
  960. cfg = Config(
  961. fs=args.fs if args.fs is not None else preset.fs,
  962. T_sig=args.T_sig if args.T_sig is not None else preset.T_sig,
  963. noise_sigma=args.noise,
  964. phi_max=args.phi_max,
  965. sigma_k_frac=args.sigma_k_frac,
  966. n_trials=args.n_trials if args.n_trials is not None else preset.n_trials,
  967. n_seeds=args.n_seeds if args.n_seeds is not None else preset.n_seeds,
  968. n_gamma=args.n_gamma if args.n_gamma is not None else preset.n_gamma,
  969. n_tau=args.n_tau if args.n_tau is not None else preset.n_tau,
  970. seed_base=args.seed_base,
  971. decoder_trials=preset.decoder_trials,
  972. decoder_seeds=preset.decoder_seeds,
  973. decoder_iters=preset.decoder_iters,
  974. )
  975. return cfg
  976. def main() -> None:
  977. args = parse_args()
  978. cfg = build_config(args)
  979. outdir = ensure_dir(Path(args.outdir).expanduser())
  980. if not (args.main or args.bin_sensitivity or args.robustness or args.decoder or args.all):
  981. # Default for each mode: all analyses. This avoids an accidental run that produces nothing.
  982. args.all = True
  983. requested = []
  984. log("=" * 78)
  985. log("Revised Stage 2 analyses for bilateral symmetry manuscript")
  986. log("=" * 78)
  987. log(f"mode : {args.mode}")
  988. log(f"outdir : {outdir}")
  989. log(f"resume : {args.resume}")
  990. log(f"n_trials : {cfg.n_trials}")
  991. log(f"n_seeds : {cfg.n_seeds}")
  992. log(f"n_gamma : {cfg.n_gamma}")
  993. log(f"n_tau : {cfg.n_tau}")
  994. log(f"fs/T_sig : {cfg.fs} / {cfg.T_sig}")
  995. save_json(outdir / "config_revised_stage2.json", jsonable_cfg(cfg))
  996. if args.all or args.main:
  997. requested.append("main multi-seed gamma sweep")
  998. run_main_multiseed(cfg, outdir, resume=args.resume)
  999. if args.all or args.bin_sensitivity:
  1000. requested.append("mutual-information bin sensitivity")
  1001. run_bin_sensitivity(cfg, outdir, resume=args.resume)
  1002. if args.all or args.robustness:
  1003. requested.append("endpoint robustness tests")
  1004. run_robustness_endpoints(cfg, outdir, resume=args.resume)
  1005. if args.all or args.decoder:
  1006. requested.append("learned full cross-correlation decoder")
  1007. run_learned_decoder(cfg, outdir, resume=args.resume)
  1008. make_readme(outdir, cfg, requested)
  1009. log("=" * 78)
  1010. log(f"Done. First file to open: {outdir / 'README_FIRST_REVISED_STAGE2.md'}")
  1011. log("=" * 78)
  1012. if __name__ == "__main__":
  1013. main()

env_symmetry_stage2_revised_additional.py at commit f735644, no license · at the source

Overview

Authors: Nobuchika Yamaki1,2, Tenna Churiki1
ORCID iDs: Nobuchika Yamaki
  1. TNQ Tech, Co., 131 Continental Drive, Suite 305, Newark, DE 19713, USA
  2. King’s College London, London, UK
Institutions: King's College London (United Kingdom)
Journal: iScience, volume 29, issue 9, article 117184
Dates: received 19 April 2026; accepted 24 July 2026; published online 25 August 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1016/j.isci.2026.117184 · PMID 42699093 · PMCID PMC13542609 · OpenAlex W7171621635
Open access: gold, a free copy (OpenAlex)
Status: code verified
Methods: Spectral & time-frequency, Machine learning, Connectivity
Keywords: bilateral symmetry, lateralization, directional inference, symmetry breaking, sensory processing
Topic: Hemispheric Asymmetry in Neuroscience (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Citations: not cited yet (Europe PMC); 16 references in the paper

Abstract

Bilateral animals often combine symmetric body plans with lateralized neural processing, but the computational relation between these features remains unclear. We used a minimal two-sensor framework to separate sensing from readout. Across auditory, binocular, and tactile models, symmetric sensor placement produced Z2-equivariant Fisher-information profiles, indicating matched local estimation precision for opposite sides of the body midline. Because Fisher information does not directly establish sign recovery, we separately measured total information, sign information, unsigned spatial information, and balanced sign accuracy. In an auditory cross-correlation model, an even scalar readout preserved unsigned source eccentricity but remained nearly sign-blind. Increasing the odd readout component selectively increased sign information, balanced accuracy, correlation magnitude, and odd conditional-mean structure, while reducing unsigned information. A decoder trained on the full cross-correlation also acquired an almost purely odd weight profile. These results show that symmetric sensing balances input precision, whereas antisymmetric readout recovers directional sign.

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

Zenodo 19648083

License: CC-BY-4.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: “Data and code availability”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Matplotlib (2 files), NumPy (2 files)
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
3 files

nobuchika-yamaki/symmetric-sensing-and-symmetry-breaking-

License: none: the authors keep all their rights
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: f7356443c682b0635f79c52623f743d5e4b8693e, 22 June 2026
Languages: Python (4)
Size: 5 files, 4 scripts
Software Heritage: not archived
Found in: the Zenodo archive record
Holds: README
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Matplotlib (4 files), NumPy (4 files)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
5 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:

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

All simulation code, analysis scripts, and numerical data generated in this study are publicly available on Zenodo at https://doi.org/10.5281/zenodo.19648083.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 2, 28 September 2026

  • Authors: added Nobuchika Yamaki (0009-0003-4719-8819); removed Nobuchika Yamaki

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 2 authors, 5 keywords, 16 references.

Cite

This paper

Yamaki, N., & Churiki, T. (2026). Symmetric sensing and symmetry-breaking processing as a minimal principle for directional inference. iScience, 29(9), 117184. https://doi.org/10.1016/j.isci.2026.117184

BibTeX

@article{yamaki2026symmetric,
author = {Yamaki, Nobuchika and Churiki, Tenna},
title = {{Symmetric sensing and symmetry-breaking processing as a minimal principle for directional inference}},
journal = {iScience},
year = {2026},
month = aug,
volume = {29},
number = {9},
pages = {117184},
publisher = {Elsevier},
issn = {2589-0042},
doi = {10.1016/j.isci.2026.117184},
url = {https://doi.org/10.1016/j.isci.2026.117184},
pmid = {42699093},
pmcid = {PMC13542609}
}

RIS

TY - JOUR
AU - Yamaki, Nobuchika
AU - Churiki, Tenna
TI - Symmetric sensing and symmetry-breaking processing as a minimal principle for directional inference
T2 - iScience
J2 - iScience
PY - 2026
DA - 2026/08/25
VL - 29
IS - 9
SP - 117184
SN - 2589-0042
PB - Elsevier
DO - 10.1016/j.isci.2026.117184
UR - https://doi.org/10.1016/j.isci.2026.117184
LA - en
ER -

CSL-JSON

{
"id": "10.1016/j.isci.2026.117184",
"type": "article-journal",
"title": "Symmetric sensing and symmetry-breaking processing as a minimal principle for directional inference",
"container-title": "iScience",
"author": [
{
"family": "Yamaki",
"given": "Nobuchika"
},
{
"family": "Churiki",
"given": "Tenna"
}
],
"container-title-short": "iScience",
"volume": "29",
"issue": "9",
"page": "117184",
"DOI": "10.1016/j.isci.2026.117184",
"PMID": "42699093",
"PMCID": "PMC13542609",
"ISSN": "2589-0042",
"publisher": "Elsevier",
"URL": "https://doi.org/10.1016/j.isci.2026.117184",
"language": "en",
"issued": {
"date-parts": [
[
2026,
8,
25
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1371/journal.pbio.3003693 [code]
Thalamic reticular neurons provide cell type-specific modulation of sound processing in the auditory thalamus.
Journal: PLoS biology
In common: Matplotlib, NumPy, 1 reference
[2] doi:10.1038/s41598-026-54678-8 [code]
Neural correlates of appetitive extinction learning: an fMRI study with actively participating pigeons.
Journal: Scientific reports
In common: Matplotlib, 1 reference
[3] doi:10.3390/biomimetics11070505 [code]
Binocular Perception Instance Authentication Learning for Few-Shot Visual Recognition.
Journal: Biomimetics (Basel, Switzerland)
In common: NumPy, 1 reference
[4] doi:10.3390/bioengineering13070793
Vibrotactile Stimulation Encoded by Beta and Gamma Bands Varies with Locations on the Upper Limbs.
Journal: Bioengineering (Basel, Switzerland)
In common: 1 reference
[5] doi:10.3389/fnsys.2026.1786396 [code]
Circuit dynamics of binocular conflict in mouse primary visual cortex.
Journal: Frontiers in systems neuroscience
In common: 1 reference
[6] doi:10.1111/jne.70257 [code]
A symmetric systemic challenge elicits a right-biased response mediated by vasopressin signaling.
Journal: Journal of neuroendocrinology
In common: 1 reference
[7] doi:10.1093/cercor/bhag067 [code]
Language laterality and cognitive skills: does anatomy matter?
Journal: Cerebral cortex (New York, N.Y. : 1991)
In common: 1 reference
[8] doi:10.1371/journal.pcbi.1014575 [code]
Remembering the "when": Hebbian memory models for the time of past events.
Journal: PLoS computational biology
In common: 1 reference
[9] doi:10.1016/j.isci.2026.116484
Molecular taxonomy and spatial organization define neuronal subtypes in the mouse inferior colliculus.
Journal: iScience
In common: 1 reference
[10] doi:10.1038/s42003-026-10321-w
Non-calyceal inputs gate the timing of calyx of Held evoked MNTB output.
Journal: Communications biology
In common: 1 reference

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.