Skip to content

Commit 4b423e9

Browse files
authored
1.1.0: Coupling metrics, GLM group stats, result-table export, and correctness fixes (#9)
* Fix NIRX channel flags and TableOpt build Update `importNIRX` to mark valid channels (`isCh`) and dark channels (`isDark`) explicitly, so OD→Hb channel selection behaves correctly for NIRX data. Add `pf2_base.buildOptodeTable` and use it during NIRX import to construct canonical `TableOpt` from in-memory probe geometry, allowing downstream device resolution (MNI positions, SD distances, short-separation info) without requiring a `.cfg` file. * Expand coupling, GLM stats, and export tooling Adds new phase-synchrony coupling methods (PLV, imaginary coherence, wPLI) plus surrogate testing, and tightens hyperscanning inference by fixing permutation denominators, positive-metric null handling, and dyad time/fs alignment. Improves GLM reliability with new groupStats behavior, nuisance-aware condition detection, channel-label alignment, FIR/HRF and AR-order enhancements, and safer rank-deficient handling. Also introduces benchmark-oriented table exporters (blockAvgToTable, glmToTable), probe channel-label utilities, BIDS-aware directory import metadata mapping, and stronger export UX in headless vs interactive flows. Processing/pathlength updates add PPF/PVC support, OD-space short-channel regression, and expanded diagnostics, with new 3D head rendering assets and broad unit-test/documentation updates. * Fix NIRX channels and optode table ordering Updated NIRX import to mark dark/no-wavelength columns as non-channels so they are excluded from OD→Hb, SCI, and DOT paths. Updated `pf2_base.buildOptodeTable` to return a canonical OptodeNum-sorted table and vectorized source/detector lookup, keeping geometry/channel alignment consistent with `loadDeviceCfg`. Added `BuildOptodeTableTest` unit coverage for schema, sorting/alignment, unmatched channels, optional geometry columns, short-separation behavior, and no-channel errors, and documented these fixes in the changelog. * Harden GLM/coupling stats and export edge cases Improves robustness across GLM, coupling, export, and visualization paths: betaTable now returns a typed empty table when all regressors are excluded, nuisance regressor detection avoids false substring matches, and fitGLM now marks non-estimable coefficients/contrasts as NaN in rank-deficient designs even when dof is positive. Coupling updates remove Signal Toolbox dependency in PLV phase extraction, validate wPLI NFFT/one-sided sizing, reject degenerate surrogate-test inputs with explicit invalid results, and extend hyperscanning group null handling to additional non-negative metrics (e.g., granger, mutualinfo, transferentropy, hbica). Also fixes TSV writing for glmToTable/blockAvgToTable, uses attached device resolution in showHead3D, validates finite PVC in bvoxy, and warns/ignores PVC when OD->Hb runs in DPF mode 'None', with expanded unit coverage for all regressions. * Harden GLM/coupling edge cases; bump v1.1.0 Improves robustness across statistical and export paths: GLM estimability now uses a scale-invariant null-space tolerance, non-estimable contrasts also return NaN standard errors, surrogate coupling tests require jointly finite overlap, nuisance regressor matching is boundary-anchored, and blockAvgToTable now validates/normalizes Channels (including column vectors) consistently across input modes. Unit tests were expanded for these regressions, plot-style tests were isolated from ambient theme state, and release metadata/docs were updated for v1.1.0 (pf2version, README, changelog).
1 parent 68576ce commit 4b423e9

79 files changed

Lines changed: 9179 additions & 555 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎+exploreFNIRS/+core/GLMExperiment.m‎

Lines changed: 610 additions & 9 deletions
Large diffs are not rendered by default.

‎+exploreFNIRS/+coupling/granger.m‎

Lines changed: 209 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,64 @@
11
function result = granger(x, y, fs, varargin)
2-
% GRANGER Bivariate Granger causality between two time series
2+
% GRANGER Bivariate Granger causality between two fNIRS time series
33
%
4-
% Tests whether past values of x improve the prediction of y beyond
5-
% what past values of y alone provide. Uses autoregressive modeling
6-
% with an F-test on residual variance reduction.
4+
% Tests whether past values of x improve the prediction of y beyond what
5+
% past values of y alone provide. Uses OLS lag-regression with an F-test on
6+
% residual variance reduction (Granger 1969, Geweke 1982).
7+
%
8+
% IMPORTANT CAVEAT: Granger causality on fNIRS signals does NOT establish
9+
% neural causality. Direction inferred from the F-statistic can be manufactured
10+
% by (1) differences in hemodynamic response latency between brain regions
11+
% (the region with the faster HRF appears to "cause" the slower one even with
12+
% simultaneous neural activity), (2) a common systemic or physiological driver
13+
% (cardiac pulsation, Mayer waves, respiration, global signal) that reaches
14+
% different channels with different temporal profiles, and (3) spatially
15+
% structured shared noise (motion artifacts, superficial physiology). Interpret
16+
% results as directed temporal predictability within the measured haemodynamic
17+
% signal, not as directional neural influence.
18+
%
19+
% AR model order selection: by default ModelOrder=5 (fixed). Set
20+
% ModelOrder='auto' to select the order by BIC over 1..round(fs/2), matching
21+
% the approach in fitGLM. BIC is preferred over AIC at fNIRS run lengths to
22+
% avoid overfitting (Schwarz 1978). In sliding-window mode with 'auto', the
23+
% order is selected on the full signal first and then applied uniformly to
24+
% all windows so that windowed F-statistics are comparable. The selected order
25+
% is stored in result.modelOrder.
26+
%
27+
% References:
28+
% Granger, C. W. J. (1969). Investigating causal relations by econometric
29+
% models and cross-spectral methods. Econometrica, 37(3), 424-438.
30+
%
31+
% Geweke, J. (1982). Measurement of linear dependence and feedback between
32+
% multiple time series. Journal of the American Statistical Association,
33+
% 77(378), 304-313.
34+
%
35+
% Schwarz, G. (1978). Estimating the Dimension of a Model. The Annals of
36+
% Statistics, 6(2), 461-464. DOI: 10.1214/aos/1176344136
37+
%
38+
% Akaike, H. (1974). A new look at the statistical model identification.
39+
% IEEE Transactions on Automatic Control, 19(6), 716-723.
40+
% DOI: 10.1109/tac.1974.1100705
741
%
842
% Syntax:
943
% result = exploreFNIRS.coupling.granger(x, y, fs)
1044
% result = exploreFNIRS.coupling.granger(x, y, fs, 'ModelOrder', 10)
45+
% result = exploreFNIRS.coupling.granger(x, y, fs, 'ModelOrder', 'auto')
1146
% result = exploreFNIRS.coupling.granger(x, y, fs, 'WindowSize', 30)
1247
%
1348
% Inputs:
1449
% x - [T x 1] time series (candidate cause)
1550
% y - [T x 1] time series (candidate effect)
16-
% fs - Sampling frequency (Hz)
51+
% fs - Sampling frequency in Hz [scalar]
1752
%
1853
% Name-Value Parameters:
19-
% ModelOrder - Number of lags for AR model (default: 5)
20-
% WindowSize - Sliding window duration in seconds (default: 0, full signal)
21-
% WindowStep - Step size in seconds (default: WindowSize/2, 50% overlap)
54+
% 'ModelOrder' - Number of lags for the AR model (default: 5)
55+
% May also be the string 'auto', in which case BIC is
56+
% minimised over candidate orders 1..round(fs/2). In
57+
% sliding-window mode, order selection is done on the full
58+
% signal and the selected order is applied to all windows.
59+
% 'WindowSize' - Sliding window duration in seconds (default: 0, full signal)
60+
% Set to 0 to use the full signal.
61+
% 'WindowStep' - Step size in seconds (default: WindowSize/2, 50% overlap)
2262
%
2363
% Outputs:
2464
% result - Struct with fields:
@@ -27,28 +67,51 @@
2767
% .direction - 'x->y'
2868
% .method - 'granger'
2969
% .windowed - true if sliding window was used
70+
% .modelOrder - AR model order used (scalar; always present)
3071
% .windowTimes - [W x 1] center times (windowed only)
3172
%
3273
% Algorithm:
33-
% Restricted model: y(t) = sum_k a_k * y(t-k) + e_r
74+
% Restricted model: y(t) = sum_k a_k * y(t-k) + e_r (p params)
3475
% Unrestricted model: y(t) = sum_k a_k * y(t-k) + sum_k b_k * x(t-k) + e_u
35-
% F = ((RSS_r - RSS_u) / p) / (RSS_u / (T - 2p - 1))
76+
% (2p params)
77+
% Neither model fits an intercept, so the F-test denominator dof is
78+
% nObs - 2p (nObs minus the unrestricted model's parameter count only --
79+
% NOT nObs - 2p - 1, which would double-subtract a constant term that is
80+
% never estimated):
81+
% F = ((RSS_r - RSS_u) / p) / (RSS_u / (nObs - 2p)), nObs = T - p
3682
%
37-
% References:
38-
% Granger, C. W. J. (1969). Investigating causal relations by econometric
39-
% models and cross-spectral methods. Econometrica, 37(3), 424-438.
83+
% OLS lag regression is used (backslash solve). BIC for order selection:
84+
% BIC(p) = T_eff*log(sigma2_u) + 2*p*log(T_eff)
85+
% where sigma2_u = RSS_u / T_eff from the unrestricted model, and the penalty
86+
% counts the 2*p parameters of that model (p lags each of y and x).
4087
%
41-
% Geweke, J. (1982). Measurement of linear dependence and feedback between
42-
% multiple time series. Journal of the American Statistical Association,
43-
% 77(378), 304-313.
88+
% Example:
89+
% data = pf2.import.sampleData();
90+
% proc = processFNIRS2(data);
91+
% result = exploreFNIRS.coupling.granger(proc.HbO(:,1), proc.HbO(:,2), proc.fs);
92+
% fprintf('F = %.3f, p = %.4f\n', result.value, result.pvalue);
93+
%
94+
% % Automatic order selection
95+
% result = exploreFNIRS.coupling.granger(proc.HbO(:,1), proc.HbO(:,2), proc.fs, ...
96+
% 'ModelOrder', 'auto');
97+
% fprintf('Selected order: %d\n', result.modelOrder);
98+
%
99+
% Notes:
100+
% - Direction of apparent causality on fNIRS is NOT neural causality; see
101+
% the caveat in the extended description above.
102+
% - When T_valid <= 3*maxOrder (T is too short relative to the candidate
103+
% range), 'auto' order selection is skipped and order 1 is used with a
104+
% warning.
44105
%
45-
% See also: exploreFNIRS.coupling.transferEntropy, exploreFNIRS.coupling.pearson
106+
% See also: exploreFNIRS.coupling.wcoherence, exploreFNIRS.coupling.pearson,
107+
% exploreFNIRS.coupling.transferEntropy
46108

47109
p = inputParser;
48110
addRequired(p, 'x', @(v) isnumeric(v) && isvector(v));
49111
addRequired(p, 'y', @(v) isnumeric(v) && isvector(v));
50112
addRequired(p, 'fs', @(v) isnumeric(v) && isscalar(v) && v > 0);
51-
addParameter(p, 'ModelOrder', 5, @(v) isnumeric(v) && isscalar(v) && v >= 1);
113+
addParameter(p, 'ModelOrder', 5, @(v) (isnumeric(v) && isscalar(v) && v >= 1) || ...
114+
(ischar(v) && strcmpi(v, 'auto')));
52115
addParameter(p, 'WindowSize', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0);
53116
addParameter(p, 'WindowStep', 0, @(v) isnumeric(v) && isscalar(v) && v >= 0);
54117
parse(p, x, y, fs, varargin{:});
@@ -60,19 +123,42 @@
60123
error('exploreFNIRS:coupling:granger', 'x and y must have equal length');
61124
end
62125

126+
% Resolve 'auto' model order on the full signal (before windowing)
127+
autoOrder = ischar(opts.ModelOrder) && strcmpi(opts.ModelOrder, 'auto');
128+
if autoOrder
129+
maxCand = min(round(fs / 2), floor(length(x) / 4));
130+
maxCand = max(maxCand, 1);
131+
132+
% Guard: need at least 3*maxCand valid samples to run order selection
133+
validMask = ~isnan(x) & ~isnan(y);
134+
[~, segLen] = longestRun(validMask);
135+
if segLen <= 3 * maxCand
136+
warning('exploreFNIRS:coupling:granger:autoOrderShortSignal', ...
137+
['Signal is too short (valid=%d samples) for BIC order ' ...
138+
'selection up to order %d (need > %d). Using order 1.'], ...
139+
segLen, maxCand, 3 * maxCand);
140+
modelOrder = 1;
141+
else
142+
modelOrder = selectOrderBIC(x, y, maxCand);
143+
end
144+
else
145+
modelOrder = opts.ModelOrder;
146+
end
147+
63148
winSamples = round(opts.WindowSize * fs);
64149

65150
if winSamples <= 0 || winSamples >= length(x)
66151
% Full-signal mode
67-
[fStat, pval] = computeGranger(x, y, opts.ModelOrder);
152+
[fStat, pval] = computeGranger(x, y, modelOrder);
68153

69154
result.value = fStat;
70155
result.pvalue = pval;
71156
result.direction = 'x->y';
72157
result.method = 'granger';
73158
result.windowed = false;
159+
result.modelOrder = modelOrder;
74160
else
75-
% Sliding window mode
161+
% Sliding window mode: order is fixed (selected on full signal above)
76162
stepSamples = round(opts.WindowStep * fs);
77163
if stepSamples <= 0
78164
stepSamples = max(1, round(winSamples / 2));
@@ -90,7 +176,7 @@
90176
idx = starts(w):(starts(w) + winSamples - 1);
91177
xw = x(idx);
92178
yw = y(idx);
93-
[fVals(w), pVals(w)] = computeGranger(xw, yw, opts.ModelOrder);
179+
[fVals(w), pVals(w)] = computeGranger(xw, yw, modelOrder);
94180
winTimes(w) = (starts(w) + winSamples/2 - 1) / fs;
95181
end
96182

@@ -99,13 +185,24 @@
99185
result.direction = 'x->y';
100186
result.method = 'granger';
101187
result.windowed = true;
188+
result.modelOrder = modelOrder;
102189
result.windowTimes = winTimes;
103190
end
104191
end
105192

193+
%%_Subfunctions_________________________________________________________
106194

107195
function [fStat, pval] = computeGranger(x, y, order)
108-
% Compute Granger F-statistic for x -> y
196+
% COMPUTEGRANGER Compute Granger F-statistic for x -> y
197+
%
198+
% Inputs:
199+
% x - [T x 1] candidate cause
200+
% y - [T x 1] candidate effect
201+
% order - AR model order [scalar]
202+
%
203+
% Outputs:
204+
% fStat - F-statistic [scalar]
205+
% pval - p-value [scalar]
109206

110207
T = length(y);
111208
if T <= 2 * order + 1
@@ -150,7 +247,7 @@
150247
Xu(:, order + k) = x((order + 1 - k):(T - k));
151248
end
152249

153-
% Solve via backslash
250+
% Solve via backslash (OLS)
154251
betaR = Xr \ Y;
155252
betaU = Xu \ Y;
156253

@@ -160,9 +257,12 @@
160257
rssR = sum(residR .^ 2);
161258
rssU = sum(residU .^ 2);
162259

163-
% F-statistic
260+
% F-statistic. dfDen = nObs - (# unrestricted-model parameters). Neither
261+
% the restricted model (order params: y-lags) nor the unrestricted model
262+
% (2*order params: y-lags + x-lags) fits an intercept, so no additional
263+
% "-1" is subtracted for a constant term that was never estimated.
164264
dfNum = order;
165-
dfDen = nObs - 2 * order - 1;
265+
dfDen = nObs - 2 * order;
166266

167267
if dfDen <= 0 || rssU <= 0
168268
fStat = NaN;
@@ -178,8 +278,91 @@
178278
end
179279

180280

281+
function order = selectOrderBIC(x, y, maxOrder)
282+
% SELECTORDERBIC Select Granger model order by BIC on OLS residuals
283+
%
284+
% For each candidate order p = 1..maxOrder, fits the unrestricted OLS model
285+
% (y lags + x lags) and computes:
286+
% BIC(p) = T_eff * log(RSS_u / T_eff) + 2*p * log(T_eff)
287+
% (the 2*p penalty counts the unrestricted model's 2*p parameters). The order
288+
% minimising BIC is returned.
289+
%
290+
% BIC is derived from OLS residual variance (not Yule-Walker), matching the
291+
% OLS backslash inference used in computeGranger. T_eff = nObs - 2*p.
292+
%
293+
% Inputs:
294+
% x - [T x 1] candidate cause
295+
% y - [T x 1] candidate effect
296+
% maxOrder - Maximum candidate order [scalar]
297+
%
298+
% Outputs:
299+
% order - Selected AR order [scalar]
300+
301+
% Use longest valid segment for order selection
302+
valid = ~isnan(x) & ~isnan(y);
303+
[segStart, segLen] = longestRun(valid);
304+
if segLen == 0
305+
order = 1;
306+
return;
307+
end
308+
x = x(segStart:segStart + segLen - 1);
309+
y = y(segStart:segStart + segLen - 1);
310+
T = length(y);
311+
312+
bicVals = nan(maxOrder, 1);
313+
314+
for q = 1:maxOrder
315+
nObs = T - q;
316+
dfDen = nObs - 2 * q - 1;
317+
if dfDen <= 0
318+
break;
319+
end
320+
321+
Y = y((q + 1):T);
322+
323+
% Unrestricted model lag matrix
324+
Xu = zeros(nObs, 2 * q);
325+
for k = 1:q
326+
Xu(:, k) = y((q + 1 - k):(T - k));
327+
Xu(:, q + k) = x((q + 1 - k):(T - k));
328+
end
329+
330+
betaU = Xu \ Y;
331+
residU = Y - Xu * betaU;
332+
rssU = sum(residU .^ 2);
333+
334+
if rssU <= 0 || ~isfinite(rssU)
335+
continue;
336+
end
337+
338+
sigma2 = rssU / nObs;
339+
% The unrestricted model has 2*q parameters (q lags each of y and x), so
340+
% the BIC complexity penalty must count 2*q, not q; using q biased
341+
% selection toward larger lag orders.
342+
bicVals(q) = nObs * log(sigma2) + 2 * q * log(nObs);
343+
end
344+
345+
if all(isnan(bicVals))
346+
warning('exploreFNIRS:coupling:granger:autoOrderFallback', ...
347+
'BIC order selection produced all NaN; falling back to order 1.');
348+
order = 1;
349+
return;
350+
end
351+
352+
[~, order] = min(bicVals);
353+
end
354+
355+
181356
function [start, len] = longestRun(mask)
182-
% Find the start index and length of the longest contiguous run of true values
357+
% LONGESTRUN Find the start index and length of the longest true run
358+
%
359+
% Inputs:
360+
% mask - Logical vector [T x 1]
361+
%
362+
% Outputs:
363+
% start - Start index of longest run [scalar]
364+
% len - Length of longest run [scalar]
365+
183366
d = diff([0; mask(:); 0]);
184367
starts = find(d == 1);
185368
ends = find(d == -1) - 1;

0 commit comments

Comments
 (0)