fix(matlab): report learning rate via per-animal slope test, not anticonservative GLMM F-test

fitglme coefTest only offers observation-level DF (df2~=178), which overstates
the group x day interaction significance for this few-subject design (p~=0 in
every scenario). Replace the reported learning-rate result with a cluster-honest
per-animal slope test (per-subject OLS slope of success vs day, Welch + MWU,
B2 vs A2) -- now correctly parallel in all 6 scenarios, matching the Python GEE.
The GLMM F-test is kept as a flagged reference only. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-07-20 08:40:47 -04:00
parent 13414d31fc
commit b1057f6105
3 changed files with 70 additions and 9 deletions
+41 -4
View File
@@ -12,9 +12,18 @@ function R = tdcs_models(S, meta, cfg)
% R.learnGLME GeneralizedLinearMixedModel, Poisson learning-rate
% model with a group x day_c interaction:
% success ~ group*day_c + day_c2 + (1|subject)
% R.learn struct with the joint Wald test (via COEFTEST) that
% all group x day_c interaction terms are zero:
% .jointP, .jointF, .jointDF1, .jointDF2.
% R.learn learning-rate comparison, Box-B2 vs Box-A2. The reported
% result is a PER-ANIMAL slope test (cluster-honest, like
% R.perAnimal): each subject's OLS slope of success on day,
% then Welch t / two-sided Mann-Whitney across groups:
% .slopeWelchP, .slopeMWUp, .nB, .nA, .meanSlopeB,
% .meanSlopeA. The GLMM group x day_c joint F-test is also
% kept for reference (.glmeJointP, .glmeJointF, .glmeJointDF1,
% .glmeJointDF2) but is ANTICONSERVATIVE here: fitglme's
% coefTest offers only observation-level DF, which overstates
% significance for this few-subject design (it reads p~=0
% even when the per-animal slopes are clearly parallel), so
% it is NOT the basis for the conclusion.
% R.perAnimal per-subject Box-B2 vs Box-A2 comparison (pure stats,
% no GLME involved): .countWelchP, .countMWUp,
% .rateWelchP, .rateMWUp, .nB, .nA. "count" = per-subject
@@ -51,10 +60,10 @@ R.rateGLME = fitglme(Sr, countFormula, 'Distribution', 'Binomial', ...
learnFormula = 'success ~ group*day_c + day_c2 + (1|subject)';
R.learnGLME = fitglme(S, learnFormula, 'Distribution', 'Poisson');
R.learn = localJointInteractionTest(R.learnGLME);
R.perAnimal = localPerAnimal(S, cfg);
R.h2 = localH2(R.countGLME, R.rateGLME, cfg);
R.learn = localLearn(R.learnGLME, S, cfg);
if meta.isUnmerged
R.classify = localClassifyAll(R.countGLME, R.rateGLME, cfg);
@@ -200,6 +209,34 @@ end
end
% -------------------------------------------------------------- learning
function out = localLearn(learnGLME, S, cfg)
%LOCALLEARN Learning-rate result: per-animal slope test (primary, cluster-
% honest) plus the GLMM interaction joint F-test (reference, anticonservative).
bSlopes = localSubjectSlopes(S(S.group == cfg.anchorHigh, :));
aSlopes = localSubjectSlopes(S(S.group == cfg.anchorLow, :));
[~, slopeWelchP] = ttest2(bSlopes, aSlopes, 'Vartype', 'unequal');
slopeMWUp = ranksum(bSlopes, aSlopes); % two-sided: slope-diff direction unknown
j = localJointInteractionTest(learnGLME);
out = struct( ...
'slopeWelchP', slopeWelchP, 'slopeMWUp', slopeMWUp, ...
'nB', numel(bSlopes), 'nA', numel(aSlopes), ...
'meanSlopeB', mean(bSlopes), 'meanSlopeA', mean(aSlopes), ...
'glmeJointP', j.jointP, 'glmeJointF', j.jointF, ...
'glmeJointDF1', j.jointDF1, 'glmeJointDF2', j.jointDF2);
end
function slopes = localSubjectSlopes(T)
%LOCALSUBJECTSLOPES Per-subject OLS slope of success on day.
subj = unique(T.subject);
slopes = zeros(numel(subj), 1);
for i = 1:numel(subj)
rows = T.subject == subj(i);
coeffs = polyfit(T.day(rows), T.success(rows), 1);
slopes(i) = coeffs(1);
end
end
function out = localJointInteractionTest(learnGLME)
%LOCALJOINTINTERACTIONTEST Joint Wald test that all group x day_c
% interaction fixed effects are zero (do groups learn at different