<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>FMRI | Shaoyang Cui</title><link>https://spidermonk7.github.io/tags/fmri/</link><atom:link href="https://spidermonk7.github.io/tags/fmri/index.xml" rel="self" type="application/rss+xml"/><description>FMRI</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Thu, 14 May 2026 00:00:00 +0000</lastBuildDate><image><url>https://spidermonk7.github.io/media/icon_hu7729264130191091259.png</url><title>FMRI</title><link>https://spidermonk7.github.io/tags/fmri/</link></image><item><title>Grid Cell Demo 03: From Grid Cells to fMRI Hexadirectional Signal</title><link>https://spidermonk7.github.io/grid-cell-notes/demo3/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://spidermonk7.github.io/grid-cell-notes/demo3/</guid><description>&lt;p>This demo now focuses only on voxel-level fMRI outputs and decoding.&lt;/p>
&lt;p>Core intuition:&lt;/p>
&lt;ol>
&lt;li>Within one voxel, many cells with different spatial phases can reduce position-locked map contrast after averaging.&lt;/li>
&lt;li>But if local cells share a similar grid orientation $\phi$, the six-fold directional term can survive at population level.&lt;/li>
&lt;li>After HRF convolution, we can still decode hexadirectional structure from BOLD.&lt;/li>
&lt;/ol>
&lt;p>Decoding protocol in this page:&lt;/p>
&lt;ol>
&lt;li>Use first half of time points as training data.&lt;/li>
&lt;li>Fit $\cos(6\theta)$ and $\sin(6\theta)$ GLM terms to estimate population $\hat\phi$.&lt;/li>
&lt;li>Use second half as test data.&lt;/li>
&lt;li>Build test regressor $\cos(6(\theta-\hat\phi))$ and estimate test $\beta_{\text{hex}}$.&lt;/li>
&lt;li>Compare recovered $\hat\phi$ against ground-truth $\phi$ (modulo 60 degrees).&lt;/li>
&lt;li>Visualize aligned vs misaligned bins and directional preference on a 360-degree polar plot.&lt;/li>
&lt;/ol>
&lt;p>You can customize:&lt;/p>
&lt;ul>
&lt;li>voxel cell count&lt;/li>
&lt;li>initial phase distribution&lt;/li>
&lt;li>HRF kernel&lt;/li>
&lt;li>signal/noise settings&lt;/li>
&lt;li>trajectory length&lt;/li>
&lt;/ul>
&lt;p>and inspect how recovery quality changes.&lt;/p>
&lt;div class="gridcell-demo">
&lt;div class="gridcell-demo-intro">
&lt;p class="gridcell-demo-stage-note">
Voxel-level fMRI simulation + decoding. We generate synthetic Right EC BOLD with a forward HRF, then decode hexadirectional structure with a GLM that uses a potentially different decode HRF on regressors.
&lt;/p>
&lt;/div>
&lt;div class="gridcell-demo-controls">
&lt;label>
&lt;span>cells per voxel&lt;/span>
&lt;input type="range" min="10" max="700" value="220" step="10" data-role="n-cells">
&lt;output data-role="n-cells-value">220&lt;/output>
&lt;/label>
&lt;label>
&lt;span>phase distribution&lt;/span>
&lt;select data-role="phase-dist">
&lt;option value="uniform" selected>uniform&lt;/option>
&lt;option value="clustered">clustered&lt;/option>
&lt;option value="bimodal">bimodal&lt;/option>
&lt;/select>
&lt;/label>
&lt;label>
&lt;span>grid orientation phi (ground truth)&lt;/span>
&lt;input type="range" min="0" max="59" value="18" step="1" data-role="phi-deg">
&lt;output data-role="phi-deg-value">18 deg&lt;/output>
&lt;/label>
&lt;label>
&lt;span>noise level&lt;/span>
&lt;input type="range" min="0" max="1.0" value="0.18" step="0.01" data-role="noise-amp">
&lt;output data-role="noise-amp-value">0.18&lt;/output>
&lt;/label>
&lt;label>
&lt;span>trajectory samples&lt;/span>
&lt;input type="range" min="300" max="2000" value="800" step="20" data-role="n-time">
&lt;output data-role="n-time-value">800&lt;/output>
&lt;/label>
&lt;label>
&lt;span>runs (CV)&lt;/span>
&lt;input type="range" min="2" max="8" value="4" step="1" data-role="n-runs">
&lt;output data-role="n-runs-value">4&lt;/output>
&lt;/label>
&lt;label>
&lt;span>forward HRF (generation)&lt;/span>
&lt;textarea rows="3" data-role="hrf-forward-text">0,0,0.02,0.08,0.22,0.38,0.52,0.59,0.57,0.49,0.38,0.27,0.18,0.11,0.06,0.03,0.01,0,-0.01,-0.02,-0.02,-0.01,0&lt;/textarea>
&lt;/label>
&lt;label>
&lt;span>decode HRF (GLM design)&lt;/span>
&lt;textarea rows="3" data-role="hrf-decode-text">0,0,0.01,0.05,0.15,0.29,0.43,0.53,0.55,0.50,0.40,0.30,0.21,0.14,0.09,0.05,0.02,0.01,0,-0.01,-0.01,0,0&lt;/textarea>
&lt;/label>
&lt;div class="gridcell-demo-actions">
&lt;button type="button" data-role="reset-default">Reset defaults&lt;/button>
&lt;button type="button" data-role="rerun">Rerun simulation&lt;/button>
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-stage gridcell-demo-stage-split">
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Forward HRF Kernel&lt;/h4>
&lt;canvas width="520" height="180" data-role="hrf-forward-plot">&lt;/canvas>
&lt;/div>
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Decode HRF Kernel&lt;/h4>
&lt;canvas width="520" height="180" data-role="hrf-decode-plot">&lt;/canvas>
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-metrics">
&lt;div class="gridcell-demo-metric">&lt;span>phi true / phi estimated&lt;/span>&lt;strong data-role="phi-pair">0.0 deg / 0.0 deg&lt;/strong>&lt;/div>
&lt;div class="gridcell-demo-metric">&lt;span>phi error (mod 60)&lt;/span>&lt;strong data-role="phi-error">0.00 deg&lt;/strong>&lt;/div>
&lt;div class="gridcell-demo-metric">&lt;span>beta_hex train&lt;/span>&lt;strong data-role="beta-train">0.000&lt;/strong>&lt;/div>
&lt;div class="gridcell-demo-metric">&lt;span>beta_hex test&lt;/span>&lt;strong data-role="beta-test">0.000&lt;/strong>&lt;/div>
&lt;div class="gridcell-demo-metric">&lt;span>aligned - misaligned (test)&lt;/span>&lt;strong data-role="align-gap">0.000&lt;/strong>&lt;/div>
&lt;div class="gridcell-demo-metric">&lt;span>cv setup&lt;/span>&lt;strong data-role="cv-info">leave-one-run-out&lt;/strong>&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-stage gridcell-demo-stage-split">
&lt;div class="gridcell-demo-visual-block">
&lt;h4>BOLD and Decoded Predictor&lt;/h4>
&lt;canvas width="520" height="260" data-role="time-plot">&lt;/canvas>
&lt;/div>
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Aligned vs Misaligned Bins (Test)&lt;/h4>
&lt;canvas width="520" height="260" data-role="bin-plot">&lt;/canvas>
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-stage gridcell-demo-stage-split">
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Cell Orientation: Ground Truth vs Decoded&lt;/h4>
&lt;canvas width="420" height="420" data-role="ori-compare-plot">&lt;/canvas>
&lt;/div>
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Folded 60 deg Profile&lt;/h4>
&lt;canvas width="420" height="420" data-role="folded-plot">&lt;/canvas>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;script>
(() => {
const root = document.currentScript.previousElementSibling;
if (!root || !root.classList.contains('gridcell-demo')) return;
const controls = {
nCells: root.querySelector('[data-role="n-cells"]'),
phaseDist: root.querySelector('[data-role="phase-dist"]'),
phiDeg: root.querySelector('[data-role="phi-deg"]'),
noiseAmp: root.querySelector('[data-role="noise-amp"]'),
nTime: root.querySelector('[data-role="n-time"]'),
nRuns: root.querySelector('[data-role="n-runs"]'),
hrfForwardText: root.querySelector('[data-role="hrf-forward-text"]'),
hrfDecodeText: root.querySelector('[data-role="hrf-decode-text"]')
};
const outputs = {
nCells: root.querySelector('[data-role="n-cells-value"]'),
phiDeg: root.querySelector('[data-role="phi-deg-value"]'),
noiseAmp: root.querySelector('[data-role="noise-amp-value"]'),
nTime: root.querySelector('[data-role="n-time-value"]'),
nRuns: root.querySelector('[data-role="n-runs-value"]')
};
const metrics = {
phiPair: root.querySelector('[data-role="phi-pair"]'),
phiError: root.querySelector('[data-role="phi-error"]'),
betaTrain: root.querySelector('[data-role="beta-train"]'),
betaTest: root.querySelector('[data-role="beta-test"]'),
alignGap: root.querySelector('[data-role="align-gap"]'),
cvInfo: root.querySelector('[data-role="cv-info"]')
};
const ctxTime = root.querySelector('[data-role="time-plot"]').getContext('2d');
const ctxBin = root.querySelector('[data-role="bin-plot"]').getContext('2d');
const ctxOriCompare = root.querySelector('[data-role="ori-compare-plot"]').getContext('2d');
const ctxFolded = root.querySelector('[data-role="folded-plot"]').getContext('2d');
const ctxHrfForward = root.querySelector('[data-role="hrf-forward-plot"]').getContext('2d');
const ctxHrfDecode = root.querySelector('[data-role="hrf-decode-plot"]').getContext('2d');
const defaults = {
nCells: 220,
phaseDist: 'uniform',
phiDeg: 18,
noiseAmp: 0.18,
nTime: 800,
nRuns: 4,
hrfForwardText: '0,0,0.02,0.08,0.22,0.38,0.52,0.59,0.57,0.49,0.38,0.27,0.18,0.11,0.06,0.03,0.01,0,-0.01,-0.02,-0.02,-0.01,0',
hrfDecodeText: '0,0,0.01,0.05,0.15,0.29,0.43,0.53,0.55,0.50,0.40,0.30,0.21,0.14,0.09,0.05,0.02,0.01,0,-0.01,-0.01,0,0'
};
const FIXED_OMEGA0 = 1.6;
const FIXED_BETA_VEL = 22.0;
function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }
function mean(arr) { return arr.reduce((a, b) => a + b, 0) / Math.max(arr.length, 1); }
function randn() {
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function deg(rad) { return rad * 180 / Math.PI; }
function rad(degVal) { return degVal * Math.PI / 180; }
function mod2pi(x) {
let y = x % (2 * Math.PI);
if (y &lt; 0) y += 2 * Math.PI;
return y;
}
function mod60ErrorDeg(a, b) {
let d = Math.abs(a - b) % 60;
return Math.min(d, 60 - d);
}
function parseKernel(text, fallbackText) {
const raw = text.split(',').map((x) => Number(x.trim())).filter((x) => Number.isFinite(x));
const vec = raw.length >= 3 ? raw : fallbackText.split(',').map(Number);
const norm = vec.reduce((s, v) => s + Math.abs(v), 0) || 1;
return vec.map((v) => v / norm);
}
function syncLabels() {
outputs.nCells.textContent = controls.nCells.value;
outputs.phiDeg.textContent = `${controls.phiDeg.value} deg`;
outputs.noiseAmp.textContent = Number(controls.noiseAmp.value).toFixed(2);
outputs.nTime.textContent = controls.nTime.value;
outputs.nRuns.textContent = controls.nRuns.value;
}
function drawKernel(ctx, kernel, color) {
const w = ctx.canvas.width, h = ctx.canvas.height;
const pad = 16;
ctx.clearRect(0, 0, w, h);
const minV = Math.min(...kernel), maxV = Math.max(...kernel);
const den = Math.max(maxV - minV, 1e-6);
ctx.strokeStyle = 'rgba(19,21,24,0.22)';
ctx.strokeRect(pad, pad, w - 2 * pad, h - 2 * pad);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i &lt; kernel.length; i += 1) {
const x = pad + (i / Math.max(kernel.length - 1, 1)) * (w - 2 * pad);
const y = h - pad - ((kernel[i] - minV) / den) * (h - 2 * pad);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
function samplePhase(dist) {
if (dist === 'clustered') {
return [clamp(0.5 + 0.09 * randn(), 0, 1), clamp(0.5 + 0.09 * randn(), 0, 1), clamp(0.5 + 0.09 * randn(), 0, 1)];
}
if (dist === 'bimodal') {
const c = Math.random() &lt; 0.5 ? 0.25 : 0.75;
return [clamp(c + 0.07 * randn(), 0, 1), clamp(c + 0.07 * randn(), 0, 1), clamp(c + 0.07 * randn(), 0, 1)];
}
return [Math.random(), Math.random(), Math.random()];
}
function gridFieldValue(x, y, phase) {
const angles = [0, Math.PI / 3, 2 * Math.PI / 3];
const k = 8.6;
let acc = 0;
for (let i = 0; i &lt; 3; i += 1) {
const proj = x * Math.cos(angles[i]) + y * Math.sin(angles[i]);
acc += Math.cos(k * proj + 2 * Math.PI * phase[i]);
}
return Math.max(0, (acc + 1.6) / 4.6);
}
function convolve(signal, kernel) {
const out = new Array(signal.length).fill(0);
for (let t = 0; t &lt; signal.length; t += 1) {
let v = 0;
for (let k = 0; k &lt; kernel.length; k += 1) {
const idx = t - k;
if (idx &lt; 0) break;
v += signal[idx] * kernel[k];
}
out[t] = v;
}
return out;
}
function solveLinearSystem(A, b) {
const n = A.length;
const M = A.map((row, i) => row.concat([b[i]]));
for (let c = 0; c &lt; n; c += 1) {
let piv = c;
for (let r = c + 1; r &lt; n; r += 1) if (Math.abs(M[r][c]) > Math.abs(M[piv][c])) piv = r;
if (Math.abs(M[piv][c]) &lt; 1e-10) continue;
if (piv !== c) {
const tmp = M[c];
M[c] = M[piv];
M[piv] = tmp;
}
const div = M[c][c];
for (let j = c; j &lt;= n; j += 1) M[c][j] /= div;
for (let r = 0; r &lt; n; r += 1) {
if (r === c) continue;
const f = M[r][c];
for (let j = c; j &lt;= n; j += 1) M[r][j] -= f * M[c][j];
}
}
return M.map((row) => row[n]);
}
function fitGLM(y, regs) {
const n = y.length;
const p = regs.length;
const dim = p + 1;
const XtX = Array.from({ length: dim }, () => new Array(dim).fill(0));
const Xty = new Array(dim).fill(0);
for (let i = 0; i &lt; n; i += 1) {
const row = [1];
for (let j = 0; j &lt; p; j += 1) row.push(regs[j][i]);
for (let a = 0; a &lt; dim; a += 1) {
Xty[a] += row[a] * y[i];
for (let b = 0; b &lt; dim; b += 1) XtX[a][b] += row[a] * row[b];
}
}
for (let d = 0; d &lt; dim; d += 1) XtX[d][d] += 1e-6;
const beta = solveLinearSystem(XtX, Xty);
return beta;
}
function drawTimePlot(y1, y2) {
const ctx = ctxTime;
const w = ctx.canvas.width, h = ctx.canvas.height, pad = 22;
ctx.clearRect(0, 0, w, h);
const all = y1.concat(y2);
const minV = Math.min(...all), maxV = Math.max(...all);
const den = Math.max(maxV - minV, 1e-6);
function plot(arr, color) {
ctx.strokeStyle = color;
ctx.lineWidth = 1.7;
ctx.beginPath();
for (let i = 0; i &lt; arr.length; i += 1) {
const x = pad + (i / Math.max(arr.length - 1, 1)) * (w - 2 * pad);
const y = h - pad - ((arr[i] - minV) / den) * (h - 2 * pad);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
ctx.strokeStyle = 'rgba(19,21,24,0.24)';
ctx.strokeRect(pad, pad, w - 2 * pad, h - 2 * pad);
plot(y1, '#2b4d83');
plot(y2, '#b26a2b');
}
function drawBinPlot(bins, alignedMask) {
const ctx = ctxBin;
const w = ctx.canvas.width, h = ctx.canvas.height, pad = 30;
ctx.clearRect(0, 0, w, h);
const minV = Math.min(...bins), maxV = Math.max(...bins);
const den = Math.max(maxV - minV, 1e-6);
const bw = (w - 2 * pad) / bins.length * 0.82;
bins.forEach((v, i) => {
const x = pad + i * (w - 2 * pad) / bins.length;
const y0 = h - pad;
const y = y0 - ((v - minV) / den) * (h - 2 * pad);
ctx.fillStyle = alignedMask[i] ? 'rgba(27,110,72,0.78)' : 'rgba(164,62,62,0.72)';
ctx.fillRect(x, y, bw, y0 - y);
});
ctx.strokeStyle = 'rgba(19,21,24,0.35)';
ctx.strokeRect(pad, pad, w - 2 * pad, h - 2 * pad);
}
function drawOrientationCompare(ctx, trueDeg, estDegList) {
const w = ctx.canvas.width, h = ctx.canvas.height;
const padL = 56, padR = 22, padT = 26, padB = 42;
const innerW = w - padL - padR;
const innerH = h - padT - padB;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(19,21,24,0.28)';
ctx.strokeRect(padL, padT, innerW, innerH);
[0, 15, 30, 45, 60].forEach((degTick) => {
const y = padT + innerH - (degTick / 60) * innerH;
ctx.strokeStyle = 'rgba(19,21,24,0.14)';
ctx.beginPath();
ctx.moveTo(padL, y);
ctx.lineTo(padL + innerW, y);
ctx.stroke();
ctx.fillStyle = 'rgba(19,21,24,0.72)';
ctx.font = '12px Instrument Sans, Segoe UI, sans-serif';
ctx.fillText(`${degTick}°`, 16, y + 4);
});
const yTrue = padT + innerH - (trueDeg / 60) * innerH;
ctx.strokeStyle = 'rgba(27,110,72,0.95)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(padL, yTrue);
ctx.lineTo(padL + innerW, yTrue);
ctx.stroke();
const stride = Math.max(1, Math.floor(estDegList.length / 180));
ctx.fillStyle = 'rgba(176,106,43,0.72)';
for (let i = 0; i &lt; estDegList.length; i += stride) {
const x = padL + (i / Math.max(estDegList.length - 1, 1)) * innerW;
const y = padT + innerH - (estDegList[i] / 60) * innerH;
ctx.beginPath();
ctx.arc(x, y, 2, 0, 2 * Math.PI);
ctx.fill();
}
}
function drawPolar(ctx, values, colorStroke) {
const w = ctx.canvas.width, h = ctx.canvas.height;
const cx = w / 2, cy = h / 2;
const rMax = Math.min(w, h) * 0.37;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(19,21,24,0.22)';
for (let i = 1; i &lt;= 4; i += 1) {
ctx.beginPath(); ctx.arc(cx, cy, rMax * i / 4, 0, 2 * Math.PI); ctx.stroke();
}
for (let d = 0; d &lt; 360; d += 30) {
const a = rad(d - 90);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + rMax * Math.cos(a), cy + rMax * Math.sin(a));
ctx.stroke();
}
const minV = Math.min(...values), maxV = Math.max(...values);
const den = Math.max(maxV - minV, 1e-6);
ctx.beginPath();
values.forEach((v, i) => {
const a = rad(i * (360 / values.length) - 90);
const rr = rMax * (0.18 + 0.82 * (v - minV) / den);
const x = cx + rr * Math.cos(a);
const y = cy + rr * Math.sin(a);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.closePath();
ctx.fillStyle = 'rgba(176,106,43,0.20)';
ctx.strokeStyle = colorStroke;
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
function run() {
syncLabels();
const nCells = Number(controls.nCells.value);
const phaseDist = controls.phaseDist.value;
const phiTrue = rad(Number(controls.phiDeg.value));
const phiTrueDeg = Number(controls.phiDeg.value);
const noiseAmp = Number(controls.noiseAmp.value);
const nTime = Number(controls.nTime.value);
const nRuns = Number(controls.nRuns.value);
const hrfForward = parseKernel(controls.hrfForwardText.value, defaults.hrfForwardText);
const hrfDecode = parseKernel(controls.hrfDecodeText.value, defaults.hrfDecodeText);
drawKernel(ctxHrfForward, hrfForward, '#2b4d83');
drawKernel(ctxHrfDecode, hrfDecode, '#b26a2b');
const phases = new Array(nCells).fill(0).map(() => samplePhase(phaseDist));
const x = new Array(nTime).fill(0);
const y = new Array(nTime).fill(0);
const theta = new Array(nTime).fill(0);
for (let t = 1; t &lt; nTime; t += 1) {
const dir = Math.random() * 2 * Math.PI;
const step = 0.055 + 0.02 * randn();
x[t] = clamp(x[t - 1] + step * Math.cos(dir), -1.35, 1.35);
y[t] = clamp(y[t - 1] + step * Math.sin(dir), -1.35, 1.35);
theta[t] = Math.atan2(y[t] - y[t - 1], x[t] - x[t - 1]);
}
const cos6Raw = new Array(nTime).fill(0);
const sin6Raw = new Array(nTime).fill(0);
const neural = new Array(nTime).fill(0);
const cellSignals = Array.from({ length: nCells }, () => new Array(nTime).fill(0));
const dirs = [phiTrue, phiTrue + Math.PI / 3, phiTrue + (2 * Math.PI) / 3];
const cellPhaseState = phases.map((p) => [2 * Math.PI * p[0], 2 * Math.PI * p[1], 2 * Math.PI * p[2]]);
for (let t = 0; t &lt; nTime; t += 1) {
cos6Raw[t] = Math.cos(6 * theta[t]);
sin6Raw[t] = Math.sin(6 * theta[t]);
}
for (let t = 1; t &lt; nTime; t += 1) {
const dx = x[t] - x[t - 1];
const dy = y[t] - y[t - 1];
let pop = 0;
for (let c = 0; c &lt; nCells; c += 1) {
for (let m = 0; m &lt; 3; m += 1) {
const ux = Math.cos(dirs[m]);
const uy = Math.sin(dirs[m]);
const proj = dx * ux + dy * uy;
cellPhaseState[c][m] += FIXED_OMEGA0 + FIXED_BETA_VEL * proj;
}
const raw = Math.cos(cellPhaseState[c][0]) + Math.cos(cellPhaseState[c][1]) + Math.cos(cellPhaseState[c][2]);
const firing = Math.max(0, (raw + 1.2) / 3.2);
cellSignals[c][t] = firing;
pop += firing;
}
neural[t] = pop / nCells + noiseAmp * randn();
}
const bold = convolve(neural, hrfForward);
const cos6Conv = convolve(cos6Raw, hrfDecode);
const sin6Conv = convolve(sin6Raw, hrfDecode);
const drift = new Array(nTime).fill(0).map((_, i) => i / Math.max(nTime - 1, 1));
const runLen = Math.floor(nTime / nRuns);
const usableLen = runLen * nRuns;
if (usableLen &lt; nRuns * 40) return;
let sumSin = 0;
let sumCos = 0;
const trainBetas = [];
const testBetas = [];
const testIdxAll = [];
for (let r = 0; r &lt; nRuns; r += 1) {
const s = r * runLen;
const e = s + runLen;
const trainIdx = [];
const testIdx = [];
for (let i = 0; i &lt; usableLen; i += 1) {
if (i >= s &amp;&amp; i &lt; e) testIdx.push(i); else trainIdx.push(i);
}
testIdxAll.push(...testIdx);
const yTrain = trainIdx.map((i) => bold[i]);
const cTrain = trainIdx.map((i) => cos6Conv[i]);
const sTrain = trainIdx.map((i) => sin6Conv[i]);
const bTrain = fitGLM(yTrain, [cTrain, sTrain]);
const bc = bTrain[1];
const bs = bTrain[2];
const phiFold = mod2pi(Math.atan2(bs, bc) / 6);
const betaTrainFold = Math.sqrt(bc * bc + bs * bs);
trainBetas.push(betaTrainFold);
sumSin += Math.sin(6 * phiFold);
sumCos += Math.cos(6 * phiFold);
}
const phiHat = mod2pi(Math.atan2(sumSin, sumCos) / 6);
const phiHatDeg = deg(phiHat);
for (let r = 0; r &lt; nRuns; r += 1) {
const s = r * runLen;
const e = s + runLen;
const testIdx = [];
for (let i = s; i &lt; e; i += 1) testIdx.push(i);
const yTest = testIdx.map((i) => bold[i]);
const hexRaw = testIdx.map((i) => Math.cos(6 * (theta[i] - phiHat)));
const hexConv = convolve(hexRaw, hrfDecode).slice(0, hexRaw.length);
const bTest = fitGLM(yTest, [hexConv]);
testBetas.push(bTest[1]);
}
const betaTrain = mean(trainBetas);
const betaTest = mean(testBetas);
const fullHexRaw = theta.map((th) => Math.cos(6 * (th - phiHat)));
const fullHexConv = convolve(fullHexRaw, hrfDecode);
const fullBeta = fitGLM(bold, [fullHexConv]);
const pred = fullHexConv.map((v) => fullBeta[0] + fullBeta[1] * v);
drawTimePlot(bold, pred);
const nBins = 12;
const binSum = new Array(nBins).fill(0);
const binCnt = new Array(nBins).fill(0);
const alignedMask = new Array(nBins).fill(false).map((_, i) => (i % 2 === 0));
for (let k = 0; k &lt; testIdxAll.length; k += 1) {
const i = testIdxAll[k];
const th = mod2pi(theta[i] - phiHat);
const b = Math.min(nBins - 1, Math.floor(th / (2 * Math.PI / nBins)));
binSum[b] += bold[i];
binCnt[b] += 1;
}
const bins = binSum.map((v, i) => v / Math.max(1, binCnt[i]));
drawBinPlot(bins, alignedMask);
const aligned = bins.filter((_, i) => alignedMask[i]);
const misaligned = bins.filter((_, i) => !alignedMask[i]);
const alignGap = mean(aligned) - mean(misaligned);
const estPhiDegList = [];
const cosReg = cos6Conv.slice(0, usableLen);
const sinReg = sin6Conv.slice(0, usableLen);
for (let c = 0; c &lt; nCells; c += 1) {
const yCellNeural = cellSignals[c].slice(0, usableLen);
const yCellBold = convolve(yCellNeural, hrfForward);
const bCell = fitGLM(yCellBold, [cosReg, sinReg]);
const phiCell = mod2pi(Math.atan2(bCell[2], bCell[1]) / 6);
let phiCellDeg = deg(phiCell) % 60;
if (phiCellDeg &lt; 0) phiCellDeg += 60;
estPhiDegList.push(phiCellDeg);
}
drawOrientationCompare(ctxOriCompare, phiTrueDeg, estPhiDegList);
const nFold = 30;
const fSum = new Array(nFold).fill(0);
const fCnt = new Array(nFold).fill(0);
for (let k = 0; k &lt; testIdxAll.length; k += 1) {
const i = testIdxAll[k];
const folded = mod2pi(theta[i] - phiHat) % (Math.PI / 3);
const b = Math.min(nFold - 1, Math.floor(folded / ((Math.PI / 3) / nFold)));
fSum[b] += bold[i];
fCnt[b] += 1;
}
drawPolar(ctxFolded, fSum.map((v, i) => v / Math.max(1, fCnt[i])), 'rgba(176,106,43,0.95)');
metrics.phiPair.textContent = `${phiTrueDeg.toFixed(1)} deg / ${phiHatDeg.toFixed(1)} deg`;
metrics.phiError.textContent = `${mod60ErrorDeg(phiHatDeg, phiTrueDeg).toFixed(2)} deg`;
metrics.betaTrain.textContent = betaTrain.toFixed(3);
metrics.betaTest.textContent = betaTest.toFixed(3);
metrics.alignGap.textContent = alignGap.toFixed(3);
metrics.cvInfo.textContent = `${nRuns} runs, leave-one-run-out`;
}
function bind() {
Object.values(controls).forEach((el) => {
const eventName = (el.tagName === 'SELECT' || el.tagName === 'TEXTAREA') ? 'change' : 'input';
el.addEventListener(eventName, run);
});
root.querySelector('[data-role="rerun"]').addEventListener('click', run);
root.querySelector('[data-role="reset-default"]').addEventListener('click', () => {
controls.nCells.value = defaults.nCells;
controls.phaseDist.value = defaults.phaseDist;
controls.phiDeg.value = defaults.phiDeg;
controls.noiseAmp.value = defaults.noiseAmp;
controls.nTime.value = defaults.nTime;
controls.nRuns.value = defaults.nRuns;
controls.hrfForwardText.value = defaults.hrfForwardText;
controls.hrfDecodeText.value = defaults.hrfDecodeText;
syncLabels();
run();
});
}
syncLabels();
bind();
run();
})();
&lt;/script></description></item><item><title>Grid Cell Notes 04: From fMRI to Grid-Cell-Like Coding</title><link>https://spidermonk7.github.io/grid-cell-notes/grid-cell-notes-fmri-gridlike-coding/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://spidermonk7.github.io/grid-cell-notes/grid-cell-notes-fmri-gridlike-coding/</guid><description>&lt;p>This note is about a practical question I kept encountering:&lt;/p>
&lt;p>How do we move from noisy fMRI in the right entorhinal cortex (Right EC) to a defensible claim of &lt;strong>grid-cell-like coding&lt;/strong>?&lt;/p>
&lt;p>The short answer is: we do not look for literal hexagonal &amp;ldquo;spots&amp;rdquo; in BOLD images. We test whether Right EC activity is modulated by movement direction with a &lt;strong>60-degree periodicity&lt;/strong>.&lt;/p>
&lt;p>If movement direction aligns with a latent grid orientation, BOLD is higher; if it is offset by 30 degrees, BOLD is lower. This is the classic &lt;strong>hexadirectional modulation&lt;/strong> logic.&lt;/p>
&lt;h2 id="1-start-from-trajectory-not-static-location">1) Start from trajectory, not static location&lt;/h2>
&lt;p>Suppose the participant moves in a virtual environment and we track position:&lt;/p>
$$ (x(t), y(t)) $$&lt;p>Instantaneous movement direction is&lt;/p>
$$ \theta(t)=\mathrm{atan2}(y(t+\Delta t)-y(t), x(t+\Delta t)-x(t)) $$&lt;p>In practice, low-speed or stationary periods are excluded. The directional model is about motion direction during navigation, not occupancy.&lt;/p>
&lt;h2 id="2-estimate-grid-orientation-in-right-ec">2) Estimate grid orientation in Right EC&lt;/h2>
&lt;p>The standard modeling idea is that many grid cells in a local EC population can share a common orientation (while differing in spatial phase). At voxel level, phase-specific structure can average out, but orientation-locked directional modulation can remain.&lt;/p>
&lt;p>For each time point, construct:&lt;/p>
$$ \cos(6\theta(t)),\ \sin(6\theta(t)) $$&lt;p>The factor 6 encodes six-fold rotational symmetry (60-degree periodicity).&lt;/p>
&lt;p>Fit a GLM on training data (for example, N-1 runs):&lt;/p>
$$ \mathrm{BOLD}_{EC}(t)=\beta_{\cos}\cos(6\theta(t))+\beta_{\sin}\sin(6\theta(t))+\mathrm{nuisance\ regressors} $$&lt;p>Then recover the putative grid orientation:&lt;/p>
$$ \phi=\frac{1}{6}\mathrm{atan2}(\beta_{\sin},\beta_{\cos}) $$&lt;p>Because of 60-degree periodicity, $\phi$ is defined modulo 60 degrees.&lt;/p>
&lt;h2 id="3-test-in-independent-data-critical">3) Test in independent data (critical)&lt;/h2>
&lt;p>The key methodological constraint is avoiding circularity:
do not estimate $\phi$ and test $\phi$ on the same data.&lt;/p>
&lt;p>Use cross-validation (for example leave-one-run-out): estimate $\phi$ on training runs, test on held-out run, rotate folds, then average.&lt;/p>
&lt;p>In test data, build:&lt;/p>
$$ \cos(6(\theta(t)-\phi)) $$&lt;p>Then fit:&lt;/p>
$$ \mathrm{BOLD}_{RightEC}(t)=\beta_{\mathrm{hex}}\cos(6(\theta(t)-\phi))+\mathrm{nuisance\ regressors} $$&lt;p>If $\beta_{\mathrm{hex}} > 0$, Right EC is stronger when $\theta$ aligns with
$\phi + k\cdot60^\circ$ than when it is shifted toward $\phi + 30^\circ + k\cdot60^\circ$.&lt;/p>
&lt;p>This is the aligned &amp;gt; misaligned signature.&lt;/p>
&lt;h2 id="4-intuitive-visualization">4) Intuitive visualization&lt;/h2>
&lt;p>A useful check is directional binning:&lt;/p>
&lt;ul>
&lt;li>aligned bins: $\phi \pm 15^\circ$ and every 60-degree repetition&lt;/li>
&lt;li>misaligned bins: $\phi+30^\circ \pm 15^\circ$ and every 60-degree repetition&lt;/li>
&lt;/ul>
&lt;p>Plot Right EC beta by bins. A consistent aligned &amp;gt; misaligned gap supports the model.&lt;/p>
&lt;h2 id="5-group-level-significance">5) Group-level significance&lt;/h2>
&lt;p>A common inference flow:&lt;/p>
&lt;ol>
&lt;li>Estimate one $\beta_{\mathrm{hex}}$ per subject.&lt;/li>
&lt;li>Run one-sample t-test or permutation test at group level.&lt;/li>
&lt;li>Restrict inference to Right EC ROI with proper correction (for example small-volume correction / FWE).&lt;/li>
&lt;li>Verify effect is significantly above zero.&lt;/li>
&lt;/ol>
&lt;p>More conservative pipelines also do voxel-wise inference in EC with permutation + TFCE + FWE correction.&lt;/p>
&lt;h2 id="6-necessary-control-analyses">6) Necessary control analyses&lt;/h2>
&lt;p>A 6-fold effect alone is not enough. I would treat these controls as mandatory:&lt;/p>
&lt;ol>
&lt;li>Symmetry controls: test 4-, 5-, 7-, 8-fold models. The effect should be specific to 6-fold.&lt;/li>
&lt;li>Behavioral/perceptual confounds: control speed, head direction, turning angle, visual flow, button/motor effects, and head motion artifacts.&lt;/li>
&lt;li>Direction sampling balance: ensure movement directions are not heavily skewed by task geometry.&lt;/li>
&lt;li>Stability/coherence checks:
&lt;ul>
&lt;li>similar $\phi$ across runs&lt;/li>
&lt;li>non-random orientation structure within Right EC (for example Rayleigh-type checks).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;h2 id="7-what-conclusion-is-valid">7) What conclusion is valid&lt;/h2>
&lt;p>A careful claim is:&lt;/p>
&lt;blockquote>
&lt;p>Right EC BOLD shows significant six-fold directional modulation relative to an estimated grid orientation, replicated out-of-sample and stronger than non-6-fold control symmetries. This supports a grid-cell-like population code in Right EC.&lt;/p>
&lt;/blockquote>
&lt;p>What I should avoid saying:&lt;/p>
&lt;blockquote>
&lt;p>fMRI proves single-neuron grid cells in Right EC.&lt;/p>
&lt;/blockquote>
&lt;p>fMRI supports &lt;strong>population-level grid-like representation&lt;/strong>, not direct single-cell firing-field proof.&lt;/p>
&lt;h2 id="one-line-takeaway">One-line takeaway&lt;/h2>
&lt;p>Right EC grid-like signal in fMRI means:
estimate hidden orientation $\phi$ on independent data, then show BOLD is higher for $\theta=\phi+k\cdot60^\circ$ than for $\theta=\phi+30^\circ+k\cdot60^\circ$, with 6-fold specificity and confounds controlled.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Doeller CF, Barry C, Burgess N. &lt;a href="https://pubmed.ncbi.nlm.nih.gov/20090680/" target="_blank" rel="noopener">Evidence for grid cells in a human memory network&lt;/a>. &lt;em>Nature&lt;/em>. 2010.&lt;/li>
&lt;li>Doeller Lab copy of the paper PDF: &lt;a href="https://doellerlab.com/wp-content/uploads/2015/08/doeller-nature-2010.pdf" target="_blank" rel="noopener">Evidence for grid cells in a human memory network&lt;/a>.&lt;/li>
&lt;li>Nature Communications (2024): &lt;a href="https://www.nature.com/articles/s41467-024-45127-z" target="_blank" rel="noopener">Grid-like entorhinal representation of an abstract value space during prospective decision making&lt;/a>.&lt;/li>
&lt;li>Null/constraint evidence in passive navigation context: &lt;a href="https://pub.dzne.de/record/285920/export/hx?ln=en" target="_blank" rel="noopener">Failure to detect entorhinal grid-like signals in a passive virtual navigation paradigm&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>