function L = tdcs_paper_lme(mergeKey, cfg, windowKey) %TDCS_PAPER_LME Replicate the paper's linear mixed model, verbatim formula: % behavior ~ stim + day + stim:day + (1|rat) % fit with fitlme on the Box-B2 (stim = 1, tDCS) vs Box-A2 (stim = 0, control) % subset of the MERGEKEY grouping ('unmerged'|'mergeA2'|'mergeB2'|'mergeNaive'). % `behavior` = successful reaches (COUNT), `day` = training day (raw; day 0 = % the paper's "Day 1"), `rat` = subject. Reports the three effects (stim x day % interaction, day, stim) as t(df) / F(1) / p with the interaction 95% CI, % alongside the paper's reference values, and writes a results/ txt file. % % L = TDCS_PAPER_LME(MERGEKEY, CFG) uses the full training range (default) and % writes results/paper_.txt. % % L = TDCS_PAPER_LME(MERGEKEY, CFG, WINDOWKEY) restricts to a day window -- % WINDOWKEY is 'full' (day <= 26), 'd0_10' (day <= 10), or 'd0_13' (day <= 13, % the last day Box-A2 has data). The windowed fits give the two anchor groups % EQUAL day coverage, so the stim:day interaction is not confounded by the % full-range coverage imbalance; windowed runs write % results/paper__.txt. % % (This is the same model as tdcs_lme -- success ~ day*tDCS + (1|subject) -- % written with the paper's exact term order and variable names.) % % L fields: .model .nRats .nObs .windowKey and .stim/.day/.interaction effect % structs (.estimate .se .t .df .p from Coefficients; .F .df1 .df2 .Fp from % ANOVA; .ci = coefficient 95% CI). if nargin < 3 || isempty(windowKey) windowKey = 'full'; end if ~ismember(windowKey, {'full', 'd0_10', 'd0_13'}) error('tdcs_paper_lme:badWindow', ... 'windowKey must be ''full'', ''d0_10'', or ''d0_13'' (got "%s").', windowKey); end S = tdcs_scenario_data([mergeKey '_' windowKey]); A = S(ismember(S.group, {cfg.anchorLow, cfg.anchorHigh}), :); tbl = table(); tbl.behavior = A.success; tbl.day = A.day; tbl.stim = double(A.group == cfg.anchorHigh); % Box-B2 = 1, Box-A2 = 0 tbl.rat = A.subject; model = fitlme(tbl, 'behavior ~ stim + day + stim:day + (1|rat)'); C = model.Coefficients; An = anova(model); ci = coefCI(model); Ans = anova(model, 'DFMethod', 'satterthwaite'); % Satterthwaite denominator DF L.model = model; L.windowKey = windowKey; L.nRats = numel(unique(tbl.rat)); L.nObs = height(tbl); L.maxDayCtrl = max(tbl.day(tbl.stim == 0)); L.maxDayStim = max(tbl.day(tbl.stim == 1)); L.stim = localTerm(C, An, Ans, ci, 'stim'); L.day = localTerm(C, An, Ans, ci, 'day'); L.interaction = localTerm(C, An, Ans, ci, 'day:stim'); % MATLAB canonicalizes stim:day -> day:stim % Honest test: refit with a per-rat random SLOPE so the interaction DF % collapses toward the animal count (see tdcs_random_slope_interaction). L.interRS = tdcs_random_slope_interaction(tbl, ... 'behavior ~ stim + day + stim:day + (day|rat)', 'day:stim'); localReport(L, mergeKey, cfg); end function e = localTerm(C, An, Ans, ci, name) i = strcmp(C.Name, name); if ~any(i) error('tdcs_paper_lme:missingTerm', 'No "%s" coefficient (have: %s).', ... name, strjoin(C.Name, ', ')); end ai = strcmp(An.Term, name); si = strcmp(Ans.Term, name); e = struct('estimate', C.Estimate(i), 'se', C.SE(i), 't', C.tStat(i), ... 'df', C.DF(i), 'p', C.pValue(i), 'F', An.FStat(ai), 'df1', An.DF1(ai), ... 'df2', An.DF2(ai), 'Fp', An.pValue(ai), 'ci', ci(i, :), ... 'dfSatt', Ans.DF2(si), 'pSatt', Ans.pValue(si)); end function localReport(L, mergeKey, cfg) window = L.windowKey; if strcmp(window, 'full') scenarioName = ['paper_' mergeKey]; windowLabel = 'full range'; else scenarioName = ['paper_' mergeKey '_' window]; windowLabel = strrep(window, 'd0_10', 'days 0-10'); windowLabel = strrep(windowLabel, 'd0_13', 'days 0-13'); end bar = repmat('=', 1, 78); s = sprintf('%s\n', bar); s = [s sprintf('PAPER LME REPLICATION -- %s (%s)\n', mergeKey, windowLabel)]; s = [s sprintf('%s\n', bar)]; s = [s sprintf('model: behavior ~ stim + day + stim:day + (1|rat) [stim: %s=1 vs %s=0]\n', ... cfg.anchorHigh, cfg.anchorLow)]; s = [s sprintf('behavior = successful reaches (COUNT per session)\n')]; s = [s sprintf('N = %d rats, %d sessions (day raw; day 0 = paper "Day 1")\n', L.nRats, L.nObs)]; s = [s sprintf('day coverage: stim(B2) 0..%d, control(A2) 0..%d\n', L.maxDayStim, L.maxDayCtrl)]; if abs(L.maxDayStim - L.maxDayCtrl) > 2 s = [s sprintf(['** WARNING: unequal day coverage -- the full-range stim:day interaction\n' ... ' extrapolates the control group''s line and is CONFOUNDED here (the paper''s\n' ... ' groups had equal coverage). See the _d0_13 fair-window and phased analyses. **\n'])]; else s = [s sprintf(['(Equal day coverage -- the stim:day interaction over this window is NOT\n' ... ' confounded by the full-range coverage imbalance.)\n'])]; end s = [s sprintf('\n')]; s = [s tdcs_model_summary(L.model, 'fitlme: behavior ~ stim + day + stim:day + (1|rat)') sprintf('\n')]; s = [s sprintf('\n%-26s %-18s %-12s %s\n', 'effect', 't(df) / F(df1)', 'p (resid)', 'Satterthwaite: p (df)')]; s = [s sprintf('%s\n', repmat('-', 1, 76))]; s = [s localRow('stim x day (interaction)', L.interaction)]; s = [s localRow('day (learning)', L.day)]; s = [s localRow('stim (main, Day 1)', L.stim)]; s = [s sprintf('interaction 95%% CI: [%+.2f, %+.2f]\n', L.interaction.ci(1), L.interaction.ci(2))]; s = [s localHonest(L.interRS)]; s = [s sprintf('\nINTERPRETATION\n')]; if L.interaction.p >= 0.05 interTxt = 'slopes parallel -- no differential learning rate over this window'; elseif L.interaction.estimate > 0 interTxt = 'tDCS (Box-B2) improves FASTER -- benefit accumulates over training'; else interTxt = 'tDCS (Box-B2) improves SLOWER -- groups converge'; end s = [s sprintf(' - stim x day interaction: %s (p=%.4f, slope diff=%+.2f) -> %s.\n', ... localSigTxt(L.interaction.p), L.interaction.p, L.interaction.estimate, interTxt)]; s = [s sprintf(' - stim main effect on Day 1 (our day 0): %s (p=%.4f) -> groups %s on Day 1.\n', ... localSigTxt(L.stim.p), L.stim.p, localPick(L.stim.p < 0.05, 'already DIFFER', 'are comparable'))]; s = [s sprintf(['\nPaper (N=24): interaction t(227)=2.68, F(1)=7.12, p=0.008; ' ... 'day t(227)=9.64,\n F(1)=267.64, p=1.2e-18; stim t(227)=0.23, F(1)=0.053, p=0.81.\n'])]; fprintf('%s', s); thisDir = fileparts(mfilename('fullpath')); resDir = fullfile(thisDir, 'results'); if ~exist(resDir, 'dir'); mkdir(resDir); end fid = fopen(fullfile(resDir, [scenarioName '.txt']), 'w'); if fid < 0; error('tdcs_paper_lme:fopen', 'Cannot open results file.'); end cleanup = onCleanup(@() fclose(fid)); %#ok fprintf(fid, '%s', s); end function r = localRow(name, e) r = sprintf('%-26s t(%d)=%6.2f F(%d)=%7.3f p=%.4g p=%.4g (df=%.0f)\n', ... name, e.df, e.t, e.df1, e.F, e.p, e.pSatt, e.dfSatt); end function s = localHonest(rs) if rs.ok s = sprintf(['\nHONEST LME -- per-rat random slope (day|rat): ' ... 'interaction F(%d,%.1f)=%.2f, p=%.4g\n'], rs.df1, rs.df2, rs.F, rs.p); else s = sprintf(['\nHONEST LME -- per-rat random slope (day|rat): ' ... 'model did not converge for this window.\n']); end s = [s sprintf([' Satterthwaite DF on the random-INTERCEPT model above stays ~= residual (the\n' ... ' slope''s error is at session level), so it does NOT fix pseudoreplication. A per-animal\n' ... ' random slope collapses the interaction DF toward the animal count -- the honest test.\n'])]; end function t = localSigTxt(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