Files
experiments-database/docs/superpowers/specs/2026-07-20-matlab-tdcs-analysis-design.md
T
Experiments DB Dev 2e6464cd14 docs(spec): MATLAB port of the tDCS GLM analysis
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 07:14:59 -04:00

144 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MATLAB port of the tDCS GLM analysis — Design
**Date:** 2026-07-20
**Status:** Approved (design)
**Location:** `analysis/matlab/`
**Ports:** the Python analysis in `analysis/` (`tdcs_glm.py`, `METHODS.md`) to MATLAB.
## Goal
Reproduce the tDCS reaching analysis in MATLAB: consume raw exports from the app,
derive the day-window × merge-scenario variations, run each scenario
independently via `switch/case`, and print the raw GLM output plus a
plain-language interpretation. Include a `matlab.unittest` test suite.
## Environment
- MATLAB **R2025b** + **Statistics and Machine Learning Toolbox** installed at
`~/matlab` (via `mpm`). Requires the user's license to be **activated** before
anything runs (`~/matlab/bin/activate_matlab.sh`, or a license file in
`~/matlab/licenses/`).
- Headless execution: `~/matlab/bin/matlab -batch "<cmd>"`. Tests run via
`matlab -batch "run_tests"`. **The MATLAB code and tests cannot be executed in
the dev environment until the license is active** — they are written first and
run once licensing is in place.
## Modeling engine
MATLAB has no GEE; the port uses **`fitglme`** (generalized linear mixed model)
with a **per-subject random intercept** as the single primary estimator — this
folds the Python GEE + random-intercept models into one native tool. The Python
mixed model already agreed with its GEE, so results should track. Reference group
is **Electrode-Box-B2**; `day_c` is the mean-centered training day and
`day_c2 = day_c^2` captures the plateau.
Models per scenario (on the scenario's data table):
- **Count:** `success ~ group + day_c + day_c2 + (1|subject)`, `Distribution='Poisson'` → IRRs vs B2.
- **Rate:** `success ~ group + day_c + day_c2 + (1|subject)`, `Distribution='Binomial'`, `BinomialSize=total` → odds ratios. Zero-attempt sessions (`total==0`, undefined rate) are dropped for this model only; the count model keeps them (they are genuine 0-success observations).
- **Learning rate:** `success ~ group*day_c + day_c2 + (1|subject)`, Poisson → group×day slope differences + a joint `coefTest`.
- **Per-animal:** collapse each animal to one number (mean count; pooled rate = Σsuccess/Σtotal), then `ttest2` (Welch) and `ranksum` (Mann-Whitney, one-sided) — the honest small-n check.
- **`unmerged_*` scenarios only:** classify Box-A and Right-Electrode against **both** anchors via `coefTest` contrasts → A2-like / B2-like / ambiguous.
## Data flow
```
raw/raw_success.csv ┐
raw/raw_total.csv ┴─ tdcs_load_data → long table {subject,group,day,success,total}
→ tdcs_scenario_data(scenario)
→ filtered+merged table (saved: derived/<scenario>.csv)
→ fitglme models → console + results/<scenario>.txt
```
### Raw files (produced now, in the website's export format)
Two app-style matrices, identical to the app's **Export Data** output with
X = "# Days Reach", Group by = "Group":
- `raw/raw_success.csv` — Data = Success
- `raw/raw_total.csv` — Data = Total attempts
Format (both):
```
Data,<metric>
<blank>
Group,<group per subject column>
# Days Reach,<subject headers>
<day>,<value per subject>
...
```
These are generated from the running app (via the same export logic) and are
byte-for-byte what the UI would export; the user can regenerate them from the UI.
### `tdcs_load_data.m`
Parses the matrix format (skips the `Data,` line and blank line, reads the
`Group` row, the `# Days Reach` + subject-header row, then the day rows),
reshapes both matrices to long, and joins on (subject, day) to produce one table
with `subject, group, day, success, total`. Drops blank cells; applies day ≥ 0.
Returns the full long table (all 5 groups, Box-A included).
## Scenarios (`switch/case`, 6 self-contained cases)
`tdcs_glm(scenario)` dispatches on the scenario name; each case runs end-to-end
and can be run alone. Cases:
| case | merge | window |
|---|---|---|
| `unmerged_full` | 5 groups (classify Box-A & Right) | days 026 |
| `unmerged_d0_10` | 5 groups | days 010 |
| `mergeA2_full` | Box-A→A2, Right→B2 | 026 |
| `mergeA2_d0_10` | Box-A→A2, Right→B2 | 010 |
| `mergeB2_full` | Box-A→B2, Right→B2 | 026 |
| `mergeB2_d0_10` | Box-A→B2, Right→B2 | 010 |
Merge maps: `mergeA2 = {Electrode-Box-A→Electrode-Box-A2, Right-Electrode→Electrode-Box-B2}`;
`mergeB2 = {Electrode-Box-A→Electrode-Box-B2, Right-Electrode→Electrode-Box-B2}`.
Each case saves its derived scenario table to `derived/<scenario>.csv`.
`run_all.m` loops all six; `run_tests.m` runs the test suite.
## Printed output per scenario
To console and `results/<scenario>.txt`:
1. **Descriptives** — per group: n_subjects, n_sessions, mean success count, pooled rate.
2. **Raw GLM** — the literal `fitglme` fixed-effects table (Estimate, SE, tStat, pValue, CI) for count and rate models, plus the learning-rate joint test statistic/p.
3. **Interpretation** — plain language mirroring the Python verdicts: H2 (B2 vs A2) as IRR/OR + one-sided p + SUPPORTED/not; per-animal p-values; classification (unmerged only); and the standing caveats (n=34/group, single-subject fragility, count vs rate).
## Files
```
analysis/matlab/
raw/raw_success.csv, raw/raw_total.csv # generated inputs (website format)
tdcs_load_data.m # parse + reshape + join → long table
tdcs_scenario_data.m # apply merge map + day window; save derived
tdcs_models.m # fit GLMMs, per-animal tests, classification, contrasts
tdcs_report.m # format descriptives + raw GLM + interpretation
tdcs_glm.m # switch/case entry over the 6 scenarios
run_all.m, run_tests.m
derived/ results/ # outputs (created at runtime)
tests/
tLoadData.m # join correctness (verified cells), day filter, zero-attempt drop
tScenarioData.m # per-scenario groups/subject counts, window caps
tModels.m # golden-value/direction checks vs the Python numbers (loose tolerance)
```
## Testing
`matlab.unittest`, run headless via `matlab -batch "run_tests"`:
- **Data:** `tdcs_load_data` join correct — verified cells (e.g. Vu-vuong day0 success=18, total=61; Banh-mi-1 day0 success=13) and per-group subject counts; day≥0 and zero-attempt handling.
- **Scenario derivation:** each scenario yields the right groups/subjects (`mergeB2` → B2=5 subjects, A2=3; `mergeA2` → 4/4/4; `d0_10` caps at day 10) and saves `derived/<scenario>.csv`.
- **Model sanity / golden values:** GLM results land near the Python numbers within a loose tolerance and match direction — e.g. `mergeB2_full` rate H2 odds ≈ 1.41.6 with B2>A2; anchor H2 supported on rate; `unmerged` classification labels. GLMM ≠ GEE exactly, so magnitude tolerances are wide but sign/ordering checks are strict.
Executed once the license is active; written but unrun until then (flagged in the report).
## Out of scope (YAGNI)
- Re-implementing GEE / cluster-robust sandwich SEs (using native `fitglme` instead).
- The learning-curve plots (the Python `.png`s remain; not re-created in MATLAB unless asked).
- Changing the Python analysis (it stays as the reference).
- Bayesian mixed model (the Python MAP sensitivity has no direct `fitglme` analog and is dropped; the GLMM *is* the random-intercept model).