0e9263b958
Add tdcs_model_summary as the single place that renders a fitted model verbatim (disp(model) with the Command-Window <strong> markup stripped), and route every report through it so all scenarios emit the complete model-fitting output: - tdcs_report: adds the learning-rate GLMM's full summary (count/rate already had theirs); localCleanDisp now delegates to the shared helper. - tdcs_lme_report / tdcs_paper_lme: embed the full fitlme summary before the curated effect table. - tdcs_phase_lme: appends each phase's full fitlme summary. Tests: tReport asserts 3 native summaries in a GLMM report; tLme asserts the lme_*/paper_*/phase_* reports embed 1/1/3 summaries with markup stripped. Suite 39/39. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
182 lines
8.1 KiB
Matlab
182 lines
8.1 KiB
Matlab
function tdcs_report(S, R, meta, cfg, scenario)
|
|
%TDCS_REPORT Print + persist the tDCS GLM report for one scenario.
|
|
% TDCS_REPORT(S, R, META, CFG, SCENARIO) prints a three-part report to
|
|
% the console (DESCRIPTIVES, RAW GLM, INTERPRETATION) and writes the
|
|
% same text to analysis/matlab/results/<SCENARIO>.txt (the results/
|
|
% folder is created if it does not already exist).
|
|
%
|
|
% S scenario table, from TDCS_SCENARIO_DATA.
|
|
% R model results struct, from TDCS_MODELS.
|
|
% META scenario metadata struct, from TDCS_SCENARIO_DATA
|
|
% (.mergeKey, .maxDay, .isUnmerged).
|
|
% CFG config struct, from TDCS_CONFIG.
|
|
% SCENARIO scenario name (char/string), used only for the report
|
|
% header and the output filename.
|
|
%
|
|
% The INTERPRETATION section mirrors verdicts()/classify() in
|
|
% analysis/tdcs_glm.py and the "Classification logic (unknowns)" /
|
|
% "Caveats" sections of analysis/METHODS.md: the H2 anchor-check
|
|
% verdict (IRR/OR + one-sided p + SUPPORTED/not supported), the
|
|
% per-animal Welch/Mann-Whitney p-values, the unknown-condition
|
|
% classification (unmerged scenarios only), and the standing caveats
|
|
% (tiny groups, single-subject classification, count-vs-rate).
|
|
%
|
|
% See analysis/tdcs_glm.py, analysis/METHODS.md, and
|
|
% docs/superpowers/plans/2026-07-20-matlab-tdcs-analysis.md (Task 4).
|
|
|
|
scenario = char(scenario);
|
|
txt = '';
|
|
|
|
txt = [txt localHeader(sprintf('tDCS GLM report -- scenario: %s', scenario))];
|
|
txt = [txt sprintf('merge key: %s day window: 0..%d observations: %d\n', ...
|
|
meta.mergeKey, meta.maxDay, height(S))];
|
|
|
|
txt = [txt localDescriptives(S)];
|
|
txt = [txt localRawGLM(R)];
|
|
txt = [txt localInterpretation(R, meta, cfg)];
|
|
|
|
fprintf('%s', txt);
|
|
|
|
thisDir = fileparts(mfilename('fullpath'));
|
|
resultsDir = fullfile(thisDir, 'results');
|
|
if ~exist(resultsDir, 'dir')
|
|
mkdir(resultsDir);
|
|
end
|
|
outFile = fullfile(resultsDir, [scenario '.txt']);
|
|
fid = fopen(outFile, 'w');
|
|
if fid == -1
|
|
error('tdcs_report:cannotWrite', 'Could not open "%s" for writing.', outFile);
|
|
end
|
|
cleanupObj = onCleanup(@() fclose(fid)); %#ok<NASGU>
|
|
fprintf(fid, '%s', txt);
|
|
|
|
end
|
|
|
|
% ------------------------------------------------------------------ header
|
|
function s = localHeader(title)
|
|
bar = repmat('=', 1, 78);
|
|
s = sprintf('%s\n%s\n%s\n', bar, title, bar);
|
|
end
|
|
|
|
% -------------------------------------------------------------- descriptives
|
|
function s = localDescriptives(S)
|
|
s = localHeader('DESCRIPTIVES');
|
|
h = sprintf('%-20s %8s %10s %13s %10s %8s', ...
|
|
'group', 'n_subj', 'n_sessions', 'mean_success', 'mean_rate', 'max_day');
|
|
s = [s h sprintf('\n') repmat('-', 1, length(h)) sprintf('\n')];
|
|
|
|
grps = categories(S.group);
|
|
for i = 1:numel(grps)
|
|
rows = S.group == grps{i};
|
|
nSubj = numel(unique(S.subject(rows)));
|
|
nSessions = sum(rows);
|
|
meanSuccess = mean(S.success(rows));
|
|
meanRate = sum(S.success(rows)) / sum(S.total(rows));
|
|
maxDay = max(S.day(rows));
|
|
s = [s sprintf('%-20s %8d %10d %13.1f %10.3f %8d\n', ...
|
|
grps{i}, nSubj, nSessions, meanSuccess, meanRate, maxDay)]; %#ok<AGROW>
|
|
end
|
|
s = [s sprintf('\n')];
|
|
end
|
|
|
|
% ---------------------------------------------------------------- raw GLM
|
|
function s = localRawGLM(R)
|
|
s = localHeader('(A) LEVEL / COUNT -- Poisson GLMM (subject random intercept)');
|
|
s = [s localCleanDisp(R.countGLME) sprintf('\n')];
|
|
|
|
s = [s localHeader('(B) LEVEL / RATE -- Binomial GLMM (subject random intercept)')];
|
|
s = [s localCleanDisp(R.rateGLME) sprintf('\n')];
|
|
|
|
s = [s localHeader('(C) LEARNING RATE -- Poisson GLMM (group x day interaction)')];
|
|
s = [s localCleanDisp(R.learnGLME) sprintf('\n')];
|
|
s = [s sprintf('Per-animal OLS slope test (Box-B2 vs Box-A2), cluster-honest:\n')];
|
|
if R.learn.slopeMWUp < 0.05
|
|
verdict = 'DIFFERENT learning rates';
|
|
else
|
|
verdict = 'parallel learning (no slope difference detected)';
|
|
end
|
|
s = [s sprintf(['Per-subject OLS slope of success vs day: Box-B2 mean=%.2f, Box-A2 mean=%.2f\n' ...
|
|
' Welch two-sided p=%.3f, Mann-Whitney p=%.3f (nB=%d, nA=%d) -> %s\n'], ...
|
|
R.learn.meanSlopeB, R.learn.meanSlopeA, R.learn.slopeWelchP, R.learn.slopeMWUp, ...
|
|
R.learn.nB, R.learn.nA, verdict)];
|
|
s = [s sprintf(['(Reference only: the GLMM group x day_c joint F-test gives p=%.4f, but with just\n' ...
|
|
' observation-level DF (df2=%.0f) it is ANTICONSERVATIVE for this few-subject design and is\n' ...
|
|
' NOT the basis for the conclusion above.)\n\n'], R.learn.glmeJointP, R.learn.glmeJointDF2)];
|
|
end
|
|
|
|
function s = localCleanDisp(model)
|
|
%LOCALCLEANDISP Full native model summary (disp(MODEL)) as plain text.
|
|
% Thin wrapper over the shared TDCS_MODEL_SUMMARY so the count, rate, and
|
|
% learning GLMMs here render the same verbatim summary used by the LME
|
|
% reports.
|
|
s = tdcs_model_summary(model);
|
|
end
|
|
|
|
% ------------------------------------------------------------ interpretation
|
|
function s = localInterpretation(R, meta, cfg)
|
|
s = localHeader('INTERPRETATION');
|
|
s = [s sprintf(['Note: these are subject-level GLMMs (Laplace-approximated ' ...
|
|
'fitglme), not the\nPython reference''s population-average GEE -- ' ...
|
|
'directions/magnitudes should agree,\nexact ratios and p-values will ' ...
|
|
'differ.\n\n'])];
|
|
|
|
s = [s sprintf('Anchor check -- H2: Box-B2 BETTER than Box-A2 (the two anchors must differ)\n')];
|
|
s = [s localH2Line('count/level', R.h2.countIRR, R.h2.countOneSidedP)];
|
|
s = [s localH2Line('rate/level ', R.h2.rateOR, R.h2.rateOneSidedP)];
|
|
|
|
s = [s sprintf('\nPer-animal (Box-B2 n=%d vs Box-A2 n=%d), pure stats (no GLME):\n', ...
|
|
R.perAnimal.nB, R.perAnimal.nA)];
|
|
s = [s sprintf(' count (per-subject mean success): Welch two-sided p=%.4f, Mann-Whitney one-sided (B2>A2) p=%.4f\n', ...
|
|
R.perAnimal.countWelchP, R.perAnimal.countMWUp)];
|
|
s = [s sprintf(' rate (per-subject pooled success/total): Welch two-sided p=%.4f, Mann-Whitney one-sided (B2>A2) p=%.4f\n', ...
|
|
R.perAnimal.rateWelchP, R.perAnimal.rateMWUp)];
|
|
|
|
if meta.isUnmerged
|
|
s = [s sprintf('\nClassification -- is each UNKNOWN condition A2-like or B2-like?\n')];
|
|
s = [s sprintf('(ratio >1 = above that anchor; p = differs from that anchor)\n')];
|
|
for i = 1:numel(cfg.unknowns)
|
|
unknown = cfg.unknowns{i};
|
|
fieldName = matlab.lang.makeValidName(unknown);
|
|
entry = R.classify.(fieldName);
|
|
s = [s sprintf('\n %s:\n', unknown)]; %#ok<AGROW>
|
|
s = [s localClassifyLine('count/level', entry.count)]; %#ok<AGROW>
|
|
s = [s localClassifyLine('rate/level ', entry.rate)]; %#ok<AGROW>
|
|
end
|
|
s = [s sprintf(['\n NOTE: each unknown has ONLY 1 subject. ''Matches'' means ' ...
|
|
'''not statistically\n distinguishable'', weak evidence at n=1, not proof ' ...
|
|
'of equivalence.\n'])];
|
|
else
|
|
s = [s sprintf(['\n(No unknown groups -- they were merged into the anchors; ' ...
|
|
'only the H2 anchor\ncontrast applies.)\n'])];
|
|
end
|
|
|
|
s = [s sprintf('\n') localHeader('CAVEATS')];
|
|
s = [s sprintf([' - Tiny groups: each arm has only n=3-4 subjects (Naive n=4; ' ...
|
|
'anchor arms n=3-5\n depending on merge), and -- in unmerged scenarios -- ' ...
|
|
'each unknown condition\n (Electrode-Box-A, Right-Electrode) has only ' ...
|
|
'n=1 subject. Treat every group\n comparison here as preliminary.\n'])];
|
|
s = [s sprintf([' - Single-subject classification: for a 1-subject unknown, ' ...
|
|
'''matches anchor X''\n means ''not statistically distinguishable from X'', ' ...
|
|
'NOT proof of equivalence;\n inference with a single subject in a group ' ...
|
|
'is fragile.\n'])];
|
|
s = [s sprintf([' - Count vs rate: ''success'' alone is a raw count; the rate ' ...
|
|
'model\n (success/attempts) is the fairer accuracy comparison when attempt ' ...
|
|
'counts differ\n between groups.\n'])];
|
|
end
|
|
|
|
function s = localH2Line(label, ratio, p)
|
|
tag = 'not supported';
|
|
if ratio > 1 && p < 0.05
|
|
tag = 'SUPPORTED';
|
|
end
|
|
s = sprintf(' [%s] Box-B2 = %.2fx Box-A2 (one-sided p=%.4f) -> %s\n', ...
|
|
label, ratio, p, tag);
|
|
end
|
|
|
|
function s = localClassifyLine(label, verdict)
|
|
s = sprintf([' [%s] vs Box-A2: %.2fx p=%.3f vs Box-B2: %.2fx p=%.3f\n' ...
|
|
' -> %s\n'], ...
|
|
label, verdict.vsA2.ratio, verdict.vsA2.p, ...
|
|
verdict.vsB2.ratio, verdict.vsB2.p, verdict.label);
|
|
end
|