feat(matlab): add Satterthwaite DF + honest random-slope test to LME reports

Every fitlme-based report (lme_*, paper_*, phase_*, and the variations'
analyze.m) now shows, per effect: residual-DF p, Satterthwaite-DF p, and --
for the interaction -- an HONEST test from a per-animal random-SLOPE model
(day|rat), whose Satterthwaite DF collapses toward the animal count.

New: tdcs_random_slope_interaction.m (shared helper). Wired into tdcs_lme,
tdcs_paper_lme, tdcs_phase_lme, variation_analyze; SUMMARY.csv gains
interaction_p_satt / interaction_p_rs. Regenerated all results/, variations/,
matched-effort outputs.

Key point this surfaces: Satterthwaite ~= residual on the random-INTERCEPT
model (the slope's error is at session level), so it does NOT fix
pseudoreplication; the random-slope model does. Effect: full-range mergeA2
interaction 0.009 -> 0.75 (collapses); unmerge_d0_5 0.015 -> 0.13 (n.s.);
the pooled-control early windows survive honestly (naive_a2_d0_5 0.001 ->
0.028; naive_boxa_d0_5 0.003 -> 0.036). Suite 42/42.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-07-22 15:45:19 -04:00
parent 5fcd97f81a
commit 8a18c894dd
77 changed files with 2066 additions and 345 deletions
+30 -8
View File
@@ -44,6 +44,7 @@ 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;
@@ -51,24 +52,30 @@ 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, ci, 'stim');
L.day = localTerm(C, An, ci, 'day');
L.interaction = localTerm(C, An, ci, 'day:stim'); % MATLAB canonicalizes stim:day -> day:stim
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, ci, name)
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, :));
'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)
@@ -101,12 +108,13 @@ else
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 %-13s %-13s %s\n', 'effect', 't (df)', 'F (df1)', 'p')];
s = [s sprintf('%s\n', repmat('-', 1, 66))];
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';
@@ -133,7 +141,21 @@ fprintf(fid, '%s', s);
end
function r = localRow(name, e)
r = sprintf('%-26s t(%d)=%6.2f F(%d)=%8.3f p=%.4g\n', name, e.df, e.t, e.df1, e.F, e.p);
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)