feat(matlab): add linear days x tDCS mixed-model scenarios (lme_*)

Adds 6 switch cases replicating the paper's linear mixed model
success ~ day * tDCS + (1|subject) on the Box-B2(tDCS=1) vs Box-A2(tDCS=0)
arm, reporting the days x tDCS interaction, days, and tDCS effects as t/F/p.
mergeA2_full reproduces the paper's interaction (F=7.09 vs 7.12, p=0.009 vs
0.008); interpretation is sign-aware and honestly notes our data diverges from
the paper (Box-B2 ahead on day 1, gap narrows -- not 'equal on day 1,
accumulates'). Adds tLme tests. Suite 31/31.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-07-20 09:43:08 -04:00
parent b1057f6105
commit 08c14c476c
5 changed files with 217 additions and 3 deletions
+6 -2
View File
@@ -1,9 +1,13 @@
%RUN_ALL Run all 6 tDCS GLM scenarios and write results/<scenario>.txt for each. %RUN_ALL Run all tDCS scenarios and write results/<scenario>.txt for each.
% Usage: matlab -batch "run_all" % Usage: matlab -batch "run_all"
% Covers the 6 GLMM scenarios and their 6 linear "days x tDCS" (lme_*) variants.
scenarios = {'unmerged_full', 'unmerged_d0_10', ... scenarios = {'unmerged_full', 'unmerged_d0_10', ...
'mergeA2_full', 'mergeA2_d0_10', ... 'mergeA2_full', 'mergeA2_d0_10', ...
'mergeB2_full', 'mergeB2_d0_10'}; 'mergeB2_full', 'mergeB2_d0_10', ...
'lme_unmerged_full', 'lme_unmerged_d0_10', ...
'lme_mergeA2_full', 'lme_mergeA2_d0_10', ...
'lme_mergeB2_full', 'lme_mergeB2_d0_10'};
for i = 1:numel(scenarios) for i = 1:numel(scenarios)
fprintf('\n\n### Running scenario: %s ###\n', scenarios{i}); fprintf('\n\n### Running scenario: %s ###\n', scenarios{i});
+31 -1
View File
@@ -9,6 +9,10 @@ function tdcs_glm(scenario)
% unmerged_full, unmerged_d0_10, % unmerged_full, unmerged_d0_10,
% mergeA2_full, mergeA2_d0_10, % mergeA2_full, mergeA2_d0_10,
% mergeB2_full, mergeB2_d0_10 % mergeB2_full, mergeB2_d0_10
% or a linear "days x tDCS" mixed model (Box-B2 vs Box-A2, per the paper):
% lme_unmerged_full, lme_unmerged_d0_10,
% lme_mergeA2_full, lme_mergeA2_d0_10,
% lme_mergeB2_full, lme_mergeB2_d0_10
% %
% Each case below is a self-contained, independently invocable call of % Each case below is a self-contained, independently invocable call of
% the same three-function pipeline; they are enumerated explicitly % the same three-function pipeline; they are enumerated explicitly
@@ -54,11 +58,37 @@ switch scenario
R = tdcs_models(S, meta, cfg); R = tdcs_models(S, meta, cfg);
tdcs_report(S, R, meta, cfg, 'mergeB2_d0_10'); tdcs_report(S, R, meta, cfg, 'mergeB2_d0_10');
% --- Linear "days x tDCS" mixed model (Box-B2 vs Box-A2), per the paper ---
case 'lme_unmerged_full'
L = tdcs_lme(tdcs_scenario_data('unmerged_full'), cfg);
tdcs_lme_report(L, 'lme_unmerged_full', cfg);
case 'lme_unmerged_d0_10'
L = tdcs_lme(tdcs_scenario_data('unmerged_d0_10'), cfg);
tdcs_lme_report(L, 'lme_unmerged_d0_10', cfg);
case 'lme_mergeA2_full'
L = tdcs_lme(tdcs_scenario_data('mergeA2_full'), cfg);
tdcs_lme_report(L, 'lme_mergeA2_full', cfg);
case 'lme_mergeA2_d0_10'
L = tdcs_lme(tdcs_scenario_data('mergeA2_d0_10'), cfg);
tdcs_lme_report(L, 'lme_mergeA2_d0_10', cfg);
case 'lme_mergeB2_full'
L = tdcs_lme(tdcs_scenario_data('mergeB2_full'), cfg);
tdcs_lme_report(L, 'lme_mergeB2_full', cfg);
case 'lme_mergeB2_d0_10'
L = tdcs_lme(tdcs_scenario_data('mergeB2_d0_10'), cfg);
tdcs_lme_report(L, 'lme_mergeB2_d0_10', cfg);
otherwise otherwise
error('tdcs_glm:badScenario', ... error('tdcs_glm:badScenario', ...
['Unrecognized scenario "%s". Valid scenarios are: ' ... ['Unrecognized scenario "%s". Valid scenarios are: ' ...
'unmerged_full, unmerged_d0_10, mergeA2_full, mergeA2_d0_10, ' ... 'unmerged_full, unmerged_d0_10, mergeA2_full, mergeA2_d0_10, ' ...
'mergeB2_full, mergeB2_d0_10.'], scenario); 'mergeB2_full, mergeB2_d0_10, and their lme_* variants ' ...
'(lme_unmerged_full, ..., lme_mergeB2_d0_10).'], scenario);
end end
end end
+50
View File
@@ -0,0 +1,50 @@
function L = tdcs_lme(S, cfg)
%TDCS_LME Linear mixed-effects "days x tDCS" model on the Box-B2 vs Box-A2 arm.
% L = TDCS_LME(S, CFG) subsets one scenario table S to the two anchor groups,
% codes a binary tDCS factor (Box-B2 = 1, Box-A2 = 0), and fits the LINEAR
% mixed model
%
% success ~ day * tDCS + (1|subject)
%
% replicating the published formulation (a linear mixed-effects model for the
% number of successful reaches with a days x tDCS interaction, a main effect
% of days, and a main effect of tDCS). `day` is used raw (not centered), so
% the tDCS main effect is the group difference on the first day ("Day 1").
%
% L fields:
% .lme the LinearMixedModel object
% .nSubjects number of subjects (Box-A2 + Box-B2)
% .nObs number of sessions
% .interaction / .day / .tDCS effect structs, each with .estimate .se
% .t .df .p (from Coefficients) and .F .df1 .df2 .Fp (from
% ANOVA). For these single-df terms F == t^2 and Fp == p.
%
% NOTE: this is a LINEAR mixed model (Gaussian on the raw count), matching
% the paper's method, unlike the Poisson/Binomial GLMMs in tdcs_models.
T = S(ismember(S.group, {cfg.anchorLow, cfg.anchorHigh}), :);
T.group = removecats(T.group);
T.tDCS = double(T.group == cfg.anchorHigh); % Box-B2 = 1, Box-A2 = 0
lme = fitlme(T, 'success ~ day*tDCS + (1|subject)');
C = lme.Coefficients;
A = anova(lme);
L.lme = lme;
L.nSubjects = numel(unique(T.subject));
L.nObs = height(T);
L.interaction = localTerm(C, A, 'day:tDCS');
L.day = localTerm(C, A, 'day');
L.tDCS = localTerm(C, A, 'tDCS');
end
function e = localTerm(C, A, name)
%LOCALTERM Pull one term's coefficient (t/df/p) and ANOVA (F/df/p) stats.
ci = strcmp(C.Name, name);
ai = strcmp(A.Term, name);
e = struct( ...
'estimate', C.Estimate(ci), 'se', C.SE(ci), ...
't', C.tStat(ci), 'df', C.DF(ci), 'p', C.pValue(ci), ...
'F', A.FStat(ai), 'df1', A.DF1(ai), 'df2', A.DF2(ai), 'Fp', A.pValue(ai));
end
+67
View File
@@ -0,0 +1,67 @@
function tdcs_lme_report(L, scenario, cfg)
%TDCS_LME_REPORT Print + save the days x tDCS linear-mixed-model report.
% TDCS_LME_REPORT(L, SCENARIO, CFG) formats the result of TDCS_LME to the
% console and to analysis/matlab/results/<SCENARIO>.txt, in the style of the
% published result (interaction, days, tDCS effects) plus interpretation and
% the paper's reference numbers.
bar = repmat('=', 1, 78);
s = sprintf('%s\n', bar);
s = [s sprintf('LINEAR MIXED MODEL (days x tDCS) -- scenario: %s\n', scenario)];
s = [s sprintf('%s\n', bar)];
s = [s sprintf('model: success ~ day * tDCS + (1|subject) [tDCS: %s = 1 vs %s = 0]\n', ...
cfg.anchorHigh, cfg.anchorLow)];
s = [s sprintf('N = %d subjects, %d sessions (day used raw: tDCS main = first-day difference)\n\n', ...
L.nSubjects, L.nObs)];
s = [s sprintf('%-27s %-14s %-13s %s\n', 'effect', 't (df)', 'F (df1)', 'p')];
s = [s sprintf('%s\n', repmat('-', 1, 70))];
s = [s localRow('days x tDCS (interaction)', L.interaction)];
s = [s localRow('days (learning)', L.day)];
s = [s localRow('tDCS (main, at day 0)', L.tDCS)];
s = [s sprintf('\nINTERPRETATION\n')];
if L.interaction.p >= 0.05
interTxt = 'slopes are parallel (no differential change over training)';
elseif L.interaction.estimate > 0
interTxt = 'the tDCS (Box-B2) group improves FASTER -- benefit ACCUMULATES over training';
else
interTxt = 'the tDCS (Box-B2) group improves SLOWER -- groups CONVERGE (Box-B2 is ahead early, the gap narrows)';
end
s = [s sprintf(' - days x tDCS interaction: %s (p=%.4f, slope diff=%.2f) -> %s.\n', ...
localSig(L.interaction.p), L.interaction.p, L.interaction.estimate, interTxt)];
s = [s sprintf(' - days (learning): %s (p=%.2g) -> performance improves with training.\n', ...
localSig(L.day.p), L.day.p)];
s = [s sprintf(' - tDCS main effect at day 0: %s (p=%.4f) -> the groups %s on the first day.\n', ...
localSig(L.tDCS.p), L.tDCS.p, ...
localPick(L.tDCS.p < 0.05, 'already DIFFER', 'are comparable'))];
s = [s sprintf('\nPaper reference (N=24): interaction t(227)=2.68, F(1)=7.12, p=0.008;\n')];
s = [s sprintf(' days t(227)=9.64, F(1)=267.64, p=1.2e-18; tDCS t(227)=0.23, F(1)=0.053, p=0.81.\n')];
s = [s sprintf(' (Our N and exact statistics differ; this replicates the MODEL FORM on our data.)\n')];
fprintf('%s', s);
thisDir = fileparts(mfilename('fullpath'));
resDir = fullfile(thisDir, 'results');
if ~exist(resDir, 'dir'); mkdir(resDir); end
fid = fopen(fullfile(resDir, [scenario '.txt']), 'w');
if fid < 0
error('tdcs_lme_report:fopen', 'Cannot open results file for "%s".', scenario);
end
cleanup = onCleanup(@() fclose(fid)); %#ok<NASGU>
fprintf(fid, '%s', s);
end
function r = localRow(name, e)
r = sprintf('%-27s t(%d)=%6.2f F(%d)=%8.3f p=%.4g\n', name, e.df, e.t, e.df1, e.F, e.p);
end
function t = localSig(p)
if p < 0.05; t = 'SIGNIFICANT'; else; t = 'n.s.'; end
end
function t = localPick(b, yes, no)
if b; t = yes; else; t = no; end
end
+63
View File
@@ -0,0 +1,63 @@
classdef tLme < matlab.unittest.TestCase
%TLME Tests for the linear "days x tDCS" mixed model (tdcs_lme) and its
% switch/case entry. Golden values computed from this dataset; the
% mergeA2_full interaction closely reproduces the paper's F=7.12/p=0.008.
methods (Test)
function testSubjectCountsAndDayEffect(testCase)
% N (Box-A2 + Box-B2 subjects) per scenario, and the always-present
% strong learning (day) effect.
cfg = tdcs_config();
expectedN = struct('unmerged_full', 6, 'mergeA2_full', 8, 'mergeB2_full', 8);
for sc = fieldnames(expectedN)'
L = tdcs_lme(tdcs_scenario_data(sc{1}), cfg);
testCase.verifyEqual(L.nSubjects, expectedN.(sc{1}), ...
sprintf('%s: wrong subject count', sc{1}));
testCase.verifyEqual(L.day.df1, 1); % F(1) term
testCase.verifyLessThan(L.day.p, 1e-6, ...
sprintf('%s: day (learning) effect should be highly significant', sc{1}));
end
end
function testMergeA2ReplicatesPaperInteraction(testCase)
% mergeA2_full reproduces the paper's days x tDCS interaction
% (paper: F(1)=7.12, p=0.008; |t|=2.68).
cfg = tdcs_config();
L = tdcs_lme(tdcs_scenario_data('mergeA2_full'), cfg);
testCase.verifyEqual(L.interaction.df1, 1);
testCase.verifyEqual(L.interaction.F, 7.09, 'AbsTol', 0.1);
testCase.verifyEqual(L.interaction.p, 0.009, 'AbsTol', 0.002);
testCase.verifyEqual(abs(L.interaction.t), 2.66, 'AbsTol', 0.05);
% single-df term: F == t^2
testCase.verifyEqual(L.interaction.F, L.interaction.t^2, 'RelTol', 1e-6);
end
function testEffectStructShape(testCase)
cfg = tdcs_config();
L = tdcs_lme(tdcs_scenario_data('unmerged_full'), cfg);
for f = {'interaction', 'day', 'tDCS'}
e = L.(f{1});
for g = {'estimate', 'se', 't', 'df', 'p', 'F', 'df1', 'df2', 'Fp'}
testCase.verifyTrue(isfield(e, g{1}), ...
sprintf('%s missing field %s', f{1}, g{1}));
end
end
end
function testSwitchRunsAndWritesResults(testCase)
% The lme_* switch cases run end-to-end and write results/<name>.txt.
here = fileparts(fileparts(mfilename('fullpath'))); % analysis/matlab
outFile = fullfile(here, 'results', 'lme_mergeA2_full.txt');
if exist(outFile, 'file'); delete(outFile); end
evalc("tdcs_glm('lme_mergeA2_full')");
testCase.verifyTrue(exist(outFile, 'file') == 2, ...
'lme_mergeA2_full.txt was not written');
end
function testBadScenarioErrors(testCase)
testCase.verifyError(@() tdcs_glm('lme_nonsense'), 'tdcs_glm:badScenario');
end
end
end