The run-and-read guides for every decoding job, in six self-contained parts.
| Part | What it covers | Was |
|---|---|---|
| Decoding job | The ordinary time-resolved decoding job: choosing the electrodes, what is decoded per condition set, every output, and how to read the true-vs-shuffle and block-comparison figures | decoding.md |
| A4 cross-decoding | Train on congruency, test on switch type (and the reverse): the anova / csv / power_traces / none electrode definitions, every knob, the outputs, the task-transfer positive controls, and the control battery with its 2026-10-01 results (§13.8) |
a4_cross_decoding.md |
| N3b block transfer | Train in one kind of block, test in another: the design choices, what was built, and how to run it | n3b_block_transfer.md |
| Cross-decoding controls | What to run, in what order, when a transfer comes back uninformative, and what each outcome lets you say | cross_decoding_controls.md |
| RT matching | The standalone util that subsamples trials to equal RT distributions, its random-subset control, and how to plug it into any analysis | new |
| Overall-activity control | Is a decode or transfer carried by a uniform rise in activity or by the pattern across electrodes: remove_mean / mean_only, how to read them, and their limits |
new |
A4 and N3b are two modes of the same job
(dcc_scripts/decoding/stability_flexibility_cross_decoding_dcc.py). The
ordinary job shares the decoder with them, but not the statistics.
Each part keeps its own section numbers. A bare § inside a part refers to that part's own sections.
Why the jobs are built the way they are: analysis_guide.md
§7 (the decoder), §17 (A4) and §21 (the circularity controls). Where the results
sit in the paper: analysis_plans.md.
Decoding — the time-resolved decoding job: how to run it and how to read it
What this document is. A standalone walkthrough of the ordinary decoding job
(submit_specific_conditions_decoding_dcc.sh → run_decoding_dcc.py →
decoding_dcc.py): what it decodes, the ways to choose the electrodes it decodes
from (no table, a saved A1 table, an ANOVA on held-out trials, and the other
launchers), every parameter that changes the answer, the exact commands, every
file it writes, and how to read them.
It is the run-and-read companion to analysis_guide.md §7
(the module layout and the Decoder class) and §21 (the circularity controls, in
depth). For training on one contrast and testing on another, see
A4 cross-decoding; that job shares the decoder but
not the statistics described here.
If you read only one section, read §3 Choosing the electrodes: the shipped submit script decodes from a saved ANOVA table by default, and turning that off needs a file edit.
cd dcc_scripts/decoding
export EPOCHS_ROOT_FILE=Stimulus_-1.0to1.5sec_decFactor_8_outliers_10_drop_and_nan_thresh_perc_5.0_70.0-150.0_Hz_padLength_1.5s_filterbank_hilbert_stat_func_ttest_zmax_20
# 1. decode in each population of a saved A1 table (the shipped default:
# both / congruency_only / switch_type_only x congruency / switch type = 6 jobs)
bash submit_specific_conditions_decoding_dcc.sh
# 2. one population, other condition sets
ANOVA_LABEL_EFFECT=both \
CONDITIONS="stimulus_lwpc_block_balanced_conditions stimulus_lwps_block_balanced_conditions" \
bash submit_specific_conditions_decoding_dcc.sh
# 3. no table: every task-significant lPFC electrode.
# First set ANOVA_LABELS_CSVS=("") in submit_specific_conditions_decoding_dcc.sh (§3.1)
CONDITIONS="stimulus_lwpc_block_balanced_conditions" bash submit_specific_conditions_decoding_dcc.sh
# 4. electrodes defined by the windowed ANOVA on 30% of trials, decoded on the other 70%
bash submit_decoding_with_anova_electrode_selection_dcc.shThe results land in dcc_scripts/decoding/figs/<EPOCHS_ROOT_FILE>/... (§6). The
figures to look at are true_v_shfle_<comparison> (is the contrast decodable?)
and, for condition sets that have one, the context comparison (is it more
decodable in one block type than the other?).
| Role | File |
|---|---|
| Job submitter (one job per condition set × table × population) | dcc_scripts/decoding/submit_specific_conditions_decoding_dcc.sh |
| Other launchers, same job | submit_decoding_with_anova_electrode_selection_dcc.sh, submit_decoding_with_electrode_definition_split_dcc.sh, submit_decoding_with_coupling_electrode_sets_dcc.sh, submit_loo_decoding_dcc.sh |
| Cluster wrapper (5 cores, 225 GB, 48 h) | dcc_scripts/decoding/sbatch_decoding_dcc.sh |
| The knobs: Python constants, plus a few environment variables | dcc_scripts/decoding/run_decoding_dcc.py |
| The job: load → electrodes → bootstraps → stats → figures → pickle | dcc_scripts/decoding/decoding_dcc.py (main, run_decoding_for_one_electrode_set) |
| What gets decoded for each condition set | src/analysis/config/condition_registry.py (comparisons, pooled_shuffle, context_comparison) |
| One bootstrap: pseudo-trials → decode → accuracies | src/analysis/decoding/process_bootstrap.py |
| Sliding-window decode per ROI | src/analysis/decoding/roi_confusion.py (get_confusion_matrices_for_rois_time_window_decoding_jim) |
| Pooling across bootstraps, the percentile cluster test | src/analysis/decoding/accuracy_stats.py (compute_pooled_bootstrap_statistics) |
| The two-condition comparison (e.g. LWPC) | src/analysis/decoding/context_comparison.py (run_context_comparison_analysis) |
| Saved-table selection | src/analysis/utils/anova_label_selection.py |
| Re-plotting from the saved pickle | src/analysis/decoding/plots/replot.py (replot_master_results, replot_all) |
submit_specific_conditions_decoding_dcc.sh loops tables x populations x CONDITIONS
└ sbatch_decoding_dcc.sh
└ run_decoding_dcc.py constants + env vars -> args, SAVE_DIR
└ decoding_dcc.main(args)
├ create_subjects_mne_objects_dict HG epochs per subject x condition (correct trials)
├ electrodes: ROI x (sig | all), filtered against what survived epoching
├ optional: saved-table filter (csv), held-out split, ANOVA sets, coupling sets
└ run_decoding_for_one_electrode_set (once, or once per electrode set)
├ joblib over BOOTSTRAPS: process_bootstrap
│ ├ pseudo-trials: per electrode, drop NaN trials, subsample to the ROI's
│ │ minimum trial count per condition
│ ├ time-averaged confusion matrix per comparison
│ ├ sliding-window decode per comparison: true + shuffle
│ └ pooled shuffle null (registry 'pooled_shuffle')
├ time-averaged confusion matrices, summed over bootstraps
├ compute_pooled_bootstrap_statistics: true vs shuffle, percentile clusters
├ true_v_shfle_<comparison> figures, CM-trace figures
├ context comparison (registry 'context_comparison'): two traces, paired
│ cluster tests both ways, difference figure
└ <timestamp>_MASTER_RESULTS_<params>_<condition>.pkl
CONDITION_NAME (one per job; the submit script's CONDITIONS list) names an
entry of condition_registry.py. That entry says which conditions to load and
what to decode:
comparisons— one decode each. A comparison is a list of classes, each a list of condition-name substrings; e.g.i_vs_c_at_inc25is[['Stimulus_i_MC_MR', 'Stimulus_i_MC_MS'], ['Stimulus_c_MC_MR', 'Stimulus_c_MC_MS']].pooled_shuffle— an extra shuffle-only decode that pools the classes over the block levels, used as the null in the context figure.context_comparison— which two comparisons to set against each other (e.g. congruency in 25%- vs 75%-incongruent blocks), with colours and labels.
The condition sets you will use most:
CONDITION_NAME |
Comparisons | Context comparison |
|---|---|---|
stimulus_congruency_conditions |
congruency (c vs i, all trials) |
– |
stimulus_switch_type_conditions |
switchType (r vs s, all trials) |
– |
stimulus_lwpc_block_balanced_conditions |
i_vs_c_at_inc25, i_vs_c_at_inc75 |
LWPC_block_balanced |
stimulus_lwps_block_balanced_conditions |
s_vs_r_at_sw25, s_vs_r_at_sw75 |
LWPS_block_balanced |
stimulus_congruency_by_switch_prop_block_balanced_conditions |
i_vs_c_at_sw25, i_vs_c_at_sw75 |
congruency_by_switch_prop_block_balanced |
stimulus_switch_type_by_inc_prop_block_balanced_conditions |
s_vs_r_at_inc25, s_vs_r_at_inc75 |
switch_type_by_inc_prop_block_balanced |
stimulus_lwpc_conditions / stimulus_lwps_conditions |
six pairs of c25/c75/i25/i75 (or s/r) cells |
LWPC / LWPS |
stimulus_task_by_congruency_conditions / _by_switch_type_ |
task within congruent / incongruent (repeat / switch) trials, and the reverse | task_by_congruency / task_by_switch_type |
stimulus_block_pairwise_conditions / stimulus_block_multiclass_conditions |
block A–D pairwise / 4-class | – |
Use the _block_balanced sets for the within-block contrasts. Each of their
classes is the union of two physical block types, and each block type is
subsampled to the smaller one before they are pooled (balance_strata=True), so a
tonic block difference cannot ride into the contrast. The 4-cell sets
(stimulus_lwpc_conditions etc.) pool raw BIDS events, so the two classes come
from a 3:1 vs 1:3 mix of block types.
Gotcha:
stimulus_experiment_conditionsandstimulus_main_effect_conditionshave nocomparisons. They exist for A4 and the ANOVAs. Submitted here, the job loads everything and decodes nothing.
Each of BOOTSTRAPS (5) samples, in parallel:
- Pseudo-trials. For every electrode, trials with NaNs are dropped; the
minimum remaining trial count per condition across the ROI's electrodes is
found; and every electrode is independently subsampled to that count. Trial k
of electrode A and trial k of electrode B are therefore different physical
trials, even in the same patient: the decoder sees no trial-level covariance
between electrodes (
analysis_guide.md§7). - Balance. For each comparison, sub-conditions within a class are subsampled
to equal size (
balance_strata), then the classes are subsampled to equal size (BALANCE_METHOD='subsample'). Chance is 1 / number of classes: 0.5 for every set here except the 4-class block decode. - Decode. PCA keeping
EXPLAINED_VARIANCE(0.90) → the classifier (MODEL_CHOICE, LDA), in windows ofWINDOW_SIZE(64) samples everySTEP_SIZE(16).N_SPLITS(5)-fold CV,N_REPEATS(5) times; the confusion matrices of the folds in a repeat are summed, giving one accuracy trace per repeat. - Shuffle. The same, with the training labels permuted and the model refit,
N_SHUFFLE_PERMS(50) times: 50 null traces. - Pooled shuffle. For condition sets with
pooled_shuffle, a further 50 null traces on the classes pooled over block levels. - Time-averaged confusion matrix for each comparison (no windows).
With UNIT_OF_ANALYSIS='repeat', the pooled samples per comparison × ROI are
BOOTSTRAPS × N_REPEATS = 25 true traces and BOOTSTRAPS × N_SHUFFLE_PERMS = 250
shuffle traces.
- True vs shuffle (
compute_pooled_bootstrap_statistics): a window is a candidate when the mean true accuracy exceeds thePERCENTILE(95th) percentile of the 250 shuffle values at that window. Runs of consecutive candidate windows form clusters, and a cluster survives if it is longer than theCLUSTER_PERCENTILE(95th) percentile of the longest run found when one shuffle trace is held out and tested against the rest,N_CLUSTER_PERMS(100) times. The surviving windows are the> shufflebar on every figure. - Condition A vs condition B (
run_context_comparison_analysis): the 25 true traces of each comparison go into two one-tailedtime_perm_clustertests (A > B, then B > A), withSTAT_FUNC='ttest',PERMUTATION_TYPE='independent',P_THRESH_FOR_TIME_PERM_CLUSTER_STATS=0.025,P_CLUSTER=0.025andN_CLUSTER_PERMS(100) permutations.
What the samples are. The 25 samples are CV repeats and bootstraps of the same trials, not subjects, so every p-value here is a within-dataset reliability check.
submit_loo_decoding_dcc.sh(§5.5) is the check that no single subject carries a result.
By default a decoder sample is one (pseudo-)trial and its features are every
channel × time point in the window (FEATURE_MODE=channels_x_time). With
FEATURE_MODE=channels, each trial at one time point is a sample, with the
channels as the features:
- Inside each window,
(trials, channels, w)becomes(trials × w, channels), and each training label is repeatedwtimes. - The folds are still cut by trial, before that expansion, so all of a trial's time points sit on one side of the split. If the folds were cut over trial × time samples, neighbouring (autocorrelated) time points of one trial would fall in both train and test, and accuracy would be inflated.
- A test trial is scored by averaging its
wtime points' decision scores (a soft vote) and taking the best class. The confusion matrix therefore still counts trials, so the statistics in §2.3 and the figures are unchanged. - The shuffle permutes labels per trial, before the expansion.
- The pseudo-trial caveat is the same as in §2.2. Electrodes from different patients come from different trials, but they are time-locked, so a (pseudo-trial, time point) vector is a pseudo-population state at that time.
The decoder then asks a different question: is there a spatial pattern
that separates the classes throughout the window? Differences in a response's
time course within the window no longer count. PCA (EXPLAINED_VARIANCE) runs
in channel space, with at most n_channels components.
FEATURE_MODE=channels CONDITIONS="stimulus_congruency_conditions" \
bash submit_specific_conditions_decoding_dcc.shOr set FEATURE_MODE near the top of
submit_specific_conditions_decoding_dcc.sh; the environment value overrides
it. File and job names get _feat-channels. Needs a classifier with
decision_function (LDA, linear SVC).
FEATURE_MODE reaches the sliding-window decode and its pooled shuffle, so the
accuracy traces, their cluster test and the LWPC/LWPS context comparisons all
use channel features. The time-averaged confusion matrices still flatten
channels × time over the epoch, though their file names also get
_feat-channels. Jobs that build their own decoder (A4 cross-decoding, the
TFR-cluster job, §3.6) ignore it. Because PCA and LDA are linear, averaging a
trial's decision scores equals scoring its window-mean channel vector; the
mode differs from window-mean decoding in training, which fits single time
points. The code walk-through and the statistics are in
new_analyses_walkthrough.md §1.
State-space trajectories in channel space. With FEATURE_MODE=channels and
RUN_VISUALIZATION_DEBUG = True (a constant at line 138 of run_decoding_dcc.py,
with no environment variable: edit it in your clone and push), the visualisation
step also writes channel_pca_3d_trajectory_<roi> and
..._condition_mean_removed (plots/trajectories.py,
channel_pca_trajectories):
- Each condition's mean is averaged in the decoding windows, giving one channel vector per condition and window.
- PCA is fit on those vectors, so each PC is a pattern across channels.
- Each condition's path is its window vectors projected onto the top PCs.
The older pca_3d_trajectory instead fits PCA on channels × time features,
where each PC is a whole spatiotemporal pattern. That is why its first PCs
explain little of the variance. It also takes its time axis from
FIRST_TIME_POINT (−1.5), which is 0.5 s off for the −1.0 s epochs; the
channel-space plot reads the times from the data. In high gamma, PC1 of the
plain version is mostly the response all conditions share. The
condition_mean_removed version subtracts that shared response first, so its
PCs describe how the conditions differ.
The trajectories are descriptive: the PCs and the projections come from the same trials, with no cross-validation, and nothing in them is tested. With two conditions the mean-removed paths are mirror images through the origin (the two states are ±half their difference). The held-out version is §4 of the subspace trajectory plan, which is not built.
Two levels decide what is decoded:
- The ROIs —
ROIS_DICTinrun_decoding_dcc.py(lPFC only as shipped). Override from the environment withROIS=lpfc,occ(names fromsrc/analysis/config/rois.py; a misspelling raises). Every ROI is decoded separately, in the same job. - The electrodes inside each ROI — one of the routes below.
| Route | Electrodes decoded | How to select it | Output root |
|---|---|---|---|
| sig / all (no table) | the ROI's task-significant electrodes, or all of them | ANOVA_LABELS_CSVS=("") in the submit script; ELECTRODES in run_decoding_dcc.py |
figs/<EPOCHS>/ |
| csv (shipped default) | every ROI electrode in one population of a saved A1 table | ANOVA_LABELS_CSV, ANOVA_LABEL_EFFECT |
figs/<EPOCHS>/anova_label_selections/<table>__effect-<pop>__…/<CONDITION>/ |
| ANOVA sets on held-out trials | the power-traces windowed-ANOVA sets (lwpc, lwps, …) from 30% of trials, decoded on the other 70% | submit_decoding_with_anova_electrode_selection_dcc.sh |
figs/<EPOCHS>/elecset_<set>/ |
| responsiveness on held-out trials | electrodes that respond on half the trials, decoded on the other half | submit_decoding_with_electrode_definition_split_dcc.sh |
figs/<EPOCHS>/, _defsplit in the names |
| coupling sets | electrodes in a significant gamma-envelope correlation pair, against n-matched non-coupled draws | submit_decoding_with_coupling_electrode_sets_dcc.sh |
figs/<EPOCHS>/elecset_coupled/, elecset_uncoupled_drawNN/ |
| window-mean groups (§3.6) | sig, nonsig and all electrodes, each at a matched count, decoded from 0–1.5 s means, compared by membership permutation | submit_window_mean_groups_dcc.sh |
figs/<EPOCHS>/window_mean_groups/ |
| top-fraction sets on held-out trials (§3.7) | each score's top 20 % (LWPC, LWPS, congruency, switch type) chosen on 30 % of trials, decoded on the other 70 % | score_electrode_selection_dcc.py, then submit_decoding_with_score_electrode_sets_dcc.sh |
figs/<EPOCHS>/score_selections/<selection>/<CONDITION>/elecset_<set>/ |
The job refuses ANOVA sets combined with either the responsiveness split or
coupling sets. A saved-table filter is applied before any of the held-out routes,
so an ANOVA_LABELS_CSV left in the environment narrows them too (§3.1).
This is "decode from the region", with no selection on the decoded effect.
-
ELECTRODESis a constant inrun_decoding_dcc.py('all'as shipped; there is no environment variable).sigkeeps the electrodes whose high gamma beats their pre-stimulus baseline, read fromsig_chans_<subject>_<EPOCHS_ROOT_FILE>.json;allkeeps every ROI electrode. That significance test says nothing about congruency or switching, so decoding those fromsigelectrodes is not circular. -
To run it, the submit script's table list must be empty. Uncomment the line after the array in
submit_specific_conditions_decoding_dcc.sh:ANOVA_LABELS_CSVS=("")Then
bash submit_specific_conditions_decoding_dcc.shsubmits one job per condition set.
Gotcha: the environment cannot switch the table off.
ANOVA_LABELS_CSV=(empty) is ignored and the listed table is used. And do not keep anANOVA_LABELS_CSVexported in your shell: every launcher passes--export=ALL, so the other launchers (§5) would silently filter to it too.
- Filenames carry
sig_elecsorall_elecs.
ANOVA_LABELS_CSV=$REPO/dcc_scripts/stats/results/$EPOCHS_ROOT_FILE/anova_conjunction_window_0.0to1.5s_sig_lpfc_condition_none \
ANOVA_LABEL_EFFECT=congruency_only \
bash submit_specific_conditions_decoding_dcc.sh-
What it does: loads every electrode of the ROI (
ELECTRODESis ignored), then keeps the(subject, electrode)pairs the table puts in the chosen population. If nothing is left, the job stops with an error rather than decode an empty set. -
ANOVA_LABELS_CSVis the table (anova_labels.csv) or its folder, and replaces the script'sANOVA_LABELS_CSVSlist. The list holds one condition-mode table as shipped; add lines to it to loop over several. -
Populations (
ANOVA_LABEL_EFFECT, one per job): the table'sSandFflags define them.Name Electrodes Mode congruency/lwpcS = 1 (includes both)condition / proportion switch_type/lwpsF = 1 (includes both)condition / proportion congruency_only/lwpc_onlyS = 1, F = 0 condition / proportion switch_type_only/lwps_onlyF = 1, S = 0 condition / proportion bothS = 1 and F = 1 either unionS = 1 or F = 1 either As shipped, the script submits
both,congruency_onlyandswitch_type_onlyfor each table and condition set. -
ANOVA_LABEL_CORRECTION(flagsdefault,none,fdr_bh) andANOVA_LABEL_ALPHA(0.05):flagsuses the table's own 0/1 flags;nonere-thresholds its raw p at alpha;fdr_bhits q. -
Output:
figs/<EPOCHS>/anova_label_selections/<table folder>__effect-<population>__correction-<c>__alpha-<a>__roi-all__<hash>/<CONDITION_NAME>/. The table's own folder name (window, electrodes, mode, correction) is kept in the path.
Gotcha: population names are not checked against the table's mode here. Both A1 modes store their two effects in the same
S/Fcolumns, socongruency_onlyon a proportion-mode table silently selects the LWPC-only electrodes, under a folder namedeffect-congruency_only. (The A4 job refuses this; this job does not.) Match the names to the_condition_/_proportion_in the table's folder.
Gotcha:
ANOVA_LABEL_EFFECTS(plural) in the environment is ignored. The script assigns that array itself. UseANOVA_LABEL_EFFECT(one name) from the environment, or edit the array. Emptying the array (ANOVA_LABEL_EFFECTS=()) makes the script submit every population of the table's mode (read off its folder name).
Gotcha: selection and decoding share trials. The table was fit on the same trials this job decodes. Decoding congruency from
congruency_onlyelectrodes is inflated by that selection and is descriptive only; decoding switch type from them is not selected on. For numbers with no overlap, use §3.3.
bash submit_decoding_with_anova_electrode_selection_dcc.sh
FRAC_SELECT=0.3 N_PERM=500 SETS=lwpc_only,lwps_only,overlap \
bash submit_decoding_with_anova_electrode_selection_dcc.shOne job per decoded condition set (CONDITIONS, default stimulus_lwpc_conditions
and stimulus_lwps_conditions). Each job splits every subject's trials on
metadata.trial_count, runs the power-traces windowed ANOVA with permutation
cluster correction on the FRAC_SELECT (30%) side for each SEL_LABELS set,
builds lwpc, lwps, lwpc_only, lwps_only, overlap, union, and decodes
each requested set on the other 70%, into elecset_<set>/. N_PERM (per
electrode) is the cost driver. The selection report and the ANOVA runs go to
figs/<EPOCHS>/electrode_selection/.
Full description, output tree and caveats: analysis_guide.md §21.3.
bash submit_decoding_with_electrode_definition_split_dcc.sh
FRAC_DEF=0.6 SEED=1 STRATA=congruency,task_sequence,block_type \
bash submit_decoding_with_electrode_definition_split_dcc.shSplits each subject's trials FRAC_DEF (0.5) / rest, re-selects responsive
electrodes (window vs baseline t-test, BH across channels at ALPHA) on the first
part, and decodes the rest. The candidates are the ELECTRODES set, so with the
shipped sig the upstream significance list (computed on all trials) still
applies first.
Gotcha: pass
STRATA=congruency,task_sequence,block_type. The defaultcongruency,switchType,blockTypenames two columns the metadata does not have, so the split is stratified on congruency alone (analysis_guide.md§21.2).
submit_decoding_with_coupling_electrode_sets_dcc.sh decodes the electrodes that
take part in a significant gamma-envelope correlation pair (the PAC path's
high_corr_*.csv), against N_DRAWS n-matched draws of non-coupled electrodes,
and compares the coupled trace with the distribution over draws. Its header
explains the knobs; run report_coupling_counts.py first, and
run_coupling_comparison_dcc.py after a run split over several jobs.
bash submit_window_mean_groups_dcc.sh # congruency and switch type
ROIS=lpfc,dlpfc WINDOW_MEAN_N_PERM=2000 bash submit_window_mean_groups_dcc.sh
WINDOW_MEAN_MATCH_ELECTRODES=false bash submit_window_mean_groups_dcc.sh # no size matchingWithout ROIS the job uses run_decoding_dcc.py's ROIS_DICT, which ships
as dlpfc. ROIS, EPOCHS_ROOT_FILE and the WINDOW_MEAN_* parameters can
also be set near the top of submit_window_mean_groups_dcc.sh; the
environment overrides them. The code walk-through and the statistics are in
new_analyses_walkthrough.md §2.
What it asks. Do the task-significant electrodes (sig), the rest
(nonsig) and all of them (all) decode the contrast equally well, with no
time features?
How it decodes:
- Each electrode's HG is averaged over
WINDOW_MEAN_TMIN–TMAX(0–1.5 s), so a trial is one vector of electrode means. - Accuracy grows with the number of electrodes, so every group is decoded at
the same size,
n_match. By default that's the smallest group's size; setWINDOW_MEAN_N_MATCHto change it. - Each group's
n_matchelectrodes are drawn at random, afresh in each ofWINDOW_MEAN_BOOTSTRAPS(20) pseudo-trial draws. Every group's draw comes from the same pseudo-trials. WINDOW_MEAN_MATCH_ELECTRODES=falseturns the matching off: each group is decoded on all its electrodes (leaveWINDOW_MEAN_N_MATCHempty). The bars then favour the bigger groups, but the test below still deals pseudo-groups of the real sizes, so it compares each group with random groups of its own size. Results go to a tag ending in_unmatched.
How groups are compared. By membership permutation, not by an ANOVA over bootstraps:
- The bootstraps and CV repeats re-use the same trials. A test across them gets more significant the more of them you run.
- Instead, the two groups' electrodes are pooled and dealt back into groups of
the original sizes, keeping any overlap, and the matched decode is repeated
WINDOW_MEAN_N_PERM(1000) times. - The p is two-sided, with a floor of 1/(n_perm+1). Unmatched, the null is centred on the size difference, so p doubles the smaller tail (floor 2/(n_perm+1)). Holm-adjusted p is reported across the three pairs.
- The null is "which group an electrode belongs to does not matter".
- For
sigvsall, each permutation draws a pseudo-sigfromall.
Output. figs/<EPOCHS>/window_mean_groups/<condition>_<window>_<n>bts_<n>perm_seed<seed>[_unmatched]/<comparison>/<roi>/:
accuracies.csv(group × bootstrap × repeat, true and shuffled-label);summary.csv;tests.csv(difference, p, Holm p, the null's 95 % range);params.json;group_bars.png/.pdf: bars are means, whiskers are 2.5–97.5 % over bootstraps × repeats, dots are bootstrap means, white dashes are chance, and brackets give the Holm p.
Reading it.
- To use non-sig or all electrodes for the anatomy, the claim is "they decode at least as well as sig". A non-significant p is not evidence of equivalence. Report the difference beside the null's range, and look at the bars' overlap.
- Cost: about 0.16 s per decode, so 1000 permutations × 20 bootstraps × 3 pairs is about an hour per contrast and ROI on 5 cores.
- The job refuses to run with any other electrode route (§3.2–3.5, §3.7).
Results (2026-10-15 run): lPFC and DLPFC, congruency and switch type, in
new_analyses_walkthrough.md §2.6. In short:
sig decodes better than nonsig at matched count in all four cases (by
0.17–0.24, p = 0.001), so the equivalence reading does not hold.
Step 1: select (minutes). Split each subject's correct trials 30 / 70
(stratified, keyed on trial_count). Score every lPFC electrode on the 30 %
with the A1 window-mean contrasts (naive_sensitivities,
contrast_mode='proportion', main_effects=True): LWPC, LWPS, congruency and
switch type. Keep each score's top 20 % per ROI:
cd dcc_scripts/decoding
mkdir -p out # the batch script logs to out/
sbatch sbatch_score_electrode_selection_dcc.sh --rois lpfc # top 20 %, positive
sbatch sbatch_score_electrode_selection_dcc.sh --rois lpfc --sign negative # most negative 20 %
sbatch sbatch_score_electrode_selection_dcc.sh --rois lpfc --sign absolute # largest |score|Select in every ROI the later jobs use. The decoding and power jobs refuse a
ROI the file did not select in. run_decoding_dcc.py and
run_power_traces_dcc.py both ship with ROIS_DICT = {'dlpfc': ...}
(2026-10-08). Decoding takes ROIS=lpfc from the environment; power traces
has no override, so either select with --rois lpfc,dlpfc or edit its
ROIS_DICT. Each ROI is ranked on its own.
--sign positive keeps the largest scores. Those are the behavioural
direction for LWPC and LWPS (the low-proportion block shows the larger effect),
and incongruent > congruent or switch > repeat for the main effects. Other
options: --frac-select, --top-frac, --electrodes sig, --tmin/--tmax,
--seed.
Each score is ranked on its own (the top 20 % of LWPC, the top 20 % of LWPS, and so on). The scores are raw per-electrode Cohen's d on the selection trials, pooled over participants; unlike the A3 scores, responsiveness and participant means are not removed.
The job writes figs/<EPOCHS>/score_selections/<params>/:
score_selection.json: the sets, the trial split and the parameters;selection_scores.csv: every candidate's scores and set membership.
The sets are lwpc, lwps, congruency, switch_type, the <score>_only
sets of each pair, adaptation_overlap and main_effects_overlap.
Step 2: decode the other 70 %.
SEL=$PWD/figs/$EPOCHS_ROOT_FILE/score_selections/<params>/score_selection.json
ROIS=lpfc ELECTRODE_SETS_JSON=$SEL bash submit_decoding_with_score_electrode_sets_dcc.shThis runs one job per condition set (default: congruency, switch type, and the
block-balanced LWPC and LWPS sets), with the battery run once per set into
elecset_<name>/. With all ten sets that is 4 jobs × 10 battery runs. Limit the
sets with ELECTRODE_SETS=lwpc_only,lwps_only.
Step 3: power traces on the same 70 %. One set per call; each call submits the four block-balanced condition sets fixed in the submitter, so the loop below is 16 jobs:
cd ../power
for SET in lwpc lwps congruency switch_type; do
ELECTRODE_SETS_JSON=$SEL ELECTRODE_SET=$SET ELECTRODES=all \
bash submit_specific_conditions_power_traces_dcc.sh
doneGive ELECTRODE_SETS_JSON as an absolute path ($SEL above): this submitter
passes it on as is, and the job reads a relative path from dcc_scripts/power/.
ELECTRODES=all only sets the all_elecs label in the file names (the
submitter's default is sig); the set decides the electrodes.
The output goes to .../anova_within_roi/score_selections/<params>/<set>/<condition>/.
What it shows. The question is whether, for example, the top LWPC
electrodes carry LWPC but not LWPS, in both decoding and power. Each set is 20 %
of the ROI, so the sets match in size. Because selection and measurement use
disjoint trials, a set's effect on its own contrast is not inflated by the
selection. Electrodes in both top sets go to *_overlap, not *_only.
"Carries LWPC but not LWPS" is a comparison between two effects. One being
significant and the other not is not a test of their difference, and the
pipeline has no test across sets or contrasts yet; report the effects and their
intervals side by side. The score formulas, the set algebra and the reading are
in new_analyses_walkthrough.md §3.
Results (2026-10-15 run): the three step-1 selections (positive,
negative, absolute; --rois lpfc,dlpfc) are described in
new_analyses_walkthrough.md §3.5. Steps 2
and 3 had not finished.
Fixed alongside (2026-10-13):
assign_trial_partitionsseeded each subject withhash(subject). Python salts string hashes per process, so the sameSEEDgave a different 30/70 split in every job, including the §3.3 ANOVA route. It now useszlib.crc32. ANOVA-route runs made before this fix cannot be reproduced exactly.
| Variable | Default | Notes |
|---|---|---|
CONDITION_NAME |
– (required) | Set per job by the launchers from CONDITIONS. |
CONDITIONS |
stimulus_congruency_conditions stimulus_switch_type_conditions |
Launcher only; space-separated, one job each. |
EPOCHS_ROOT_FILE |
the _ttest_zmax_20 file |
Also picks the sig_chans file. |
ROIS |
unset (lpfc) |
e.g. lpfc,occ. |
ANOVA_LABELS_CSV, ANOVA_LABEL_EFFECT, ANOVA_LABEL_CORRECTION, ANOVA_LABEL_ALPHA, ANOVA_LABEL_ROI |
the script's table; flags; 0.05 |
§3.2. |
LEAVE_OUT |
unset | Drop one subject (§5.5). |
N_JOBS |
SLURM_CPUS_PER_TASK (5) |
Bootstraps run in parallel. |
ELECTRODE_DEFINITION_SPLIT*, ANOVA_ELECTRODE_SELECTION, ELECTRODE_SELECTION_*, COUPLING_* |
off | Set by the launchers of §3.3–3.5. |
| Constant | Value | What it does |
|---|---|---|
SUBJECTS |
24 subjects | |
ACC_TRIALS_ONLY |
True |
Correct trials only. |
ROIS_DICT |
lpfc |
Overridden by ROIS. |
ELECTRODES |
'all' |
'sig' or 'all' (§3.1). Ignored on the csv route. |
MODEL_CHOICE |
'LDA' |
'SVC' for a linear SVM. |
EXPLAINED_VARIANCE |
0.90 |
PCA variance kept. |
N_SPLITS / N_REPEATS |
5 / 5 |
CV folds; repeats per bootstrap. |
BOOTSTRAPS |
5 |
Independent pseudo-trial draws. |
BALANCE_METHOD |
'subsample' |
Equal classes by subsampling. |
WINDOW_SIZE / STEP_SIZE |
64 / 16 |
Samples at 256 Hz: 250 ms windows every 62.5 ms. |
N_SHUFFLE_PERMS |
50 |
Shuffle refits per bootstrap. |
UNIT_OF_ANALYSIS |
'repeat' |
What one sample is: 'repeat', 'fold' (folds kept separate) or 'bootstrap' (one mean per bootstrap). Changes the sample count and so every error band and test. |
PERCENTILE / CLUSTER_PERCENTILE / N_CLUSTER_PERMS |
95 / 95 / 100 |
The true-vs-shuffle test (§2.3). N_CLUSTER_PERMS is also the permutation count of the context test. |
STAT_FUNC_CHOICE, P_THRESH_FOR_TIME_PERM_CLUSTER_STATS, P_CLUSTER, PERMUTATION_TYPE |
'ttest', 0.025, 0.025, 'independent' |
The context-comparison test. |
RANDOM_STATE |
42 |
Bootstrap b uses 42 + b. |
RUN_VISUALIZATION_DEBUG |
False |
Plot trials and the decision boundary on the first two PCs. |
The commented "testing params" block near the bottom (one subject, 2 splits, 1 repeat, 2 bootstraps) is the smoke test: uncomment it and run one condition before a full submission.
FIRST_TIME_POINT in the file is not used by the decoder: process_bootstrap.py
passes −1.0 s, the start of the Stimulus_-1.0to1.5sec epochs. A response-locked
or differently cropped epochs file needs that line changed, or the figures' time
axis is shifted.
REPO=/hpc/home/$USER/coganlab/$USER/GlobalLocal
cd $REPO/dcc_scripts/decoding
export EPOCHS_ROOT_FILE=Stimulus_-1.0to1.5sec_decFactor_8_outliers_10_drop_and_nan_thresh_perc_5.0_70.0-150.0_Hz_padLength_1.5s_filterbank_hilbert_stat_func_ttest_zmax_20
COND_CSV=$REPO/dcc_scripts/stats/results/$EPOCHS_ROOT_FILE/anova_conjunction_window_0.0to1.5s_sig_lpfc_condition_none
PROP_CSV=$REPO/dcc_scripts/stats/results/$EPOCHS_ROOT_FILE/anova_conjunction_window_0.0to1.5s_sig_lpfc_proportion_noneThe table must come from the same epochs file as the decode, which the paths
above guarantee. A missing table is made by
dcc_scripts/stats/submit_stability_flexibility_anova_conjunction_dcc.sh
(analysis_guide.md §17.5 step 1).
| Question | Command |
|---|---|
| Main effects in each main-effect population | ANOVA_LABELS_CSV=$COND_CSV bash submit_specific_conditions_decoding_dcc.sh (the default: 3 populations × 2 sets) |
| LWPC/LWPS in the main-effect populations | ANOVA_LABELS_CSV=$COND_CSV CONDITIONS="stimulus_lwpc_block_balanced_conditions stimulus_lwps_block_balanced_conditions" bash submit_... |
| LWPC/LWPS in the LWPC-only / LWPS-only electrodes | run twice with ANOVA_LABELS_CSV=$PROP_CSV and ANOVA_LABEL_EFFECT=lwpc_only, then lwps_only |
| Everything in the region, no selection | ANOVA_LABELS_CSVS=("") in the script (§3.1), then CONDITIONS="..." bash submit_... |
| Two regions | add ROIS=lpfc,occ |
| Selection on held-out trials | bash submit_decoding_with_anova_electrode_selection_dcc.sh (§3.3) |
- The script echoes
condition=… anova_labels=… effect=…per job. That is what will run. - Logs:
out/slurm_<jobid>_dec_a<table>e<pop>_<condition>.out. The top prints subjects, conditions, ROIs, the selection settings and every decoding parameter; the[anova-labels] decoding with N electrodesline gives the set size.
Per comparison × ROI × bootstrap: N_REPEATS × N_SPLITS true fits and
N_SHUFFLE_PERMS × N_SPLITS shuffle fits at every window, so the shuffle is ten
times the true decode. A pooled_shuffle adds another 50 × 5 per ROI. The wrapper
asks for 48 h and 225 GB; the memory is for loading every subject's epochs. The
selection launchers add the ANOVA permutations (§3.3) on top.
submit_loo_decoding_dcc.sh submits one job per subject in its
LEAVE_OUT_SUBJECTS list and condition set, each with that subject dropped
(_loo-<subject> in the filenames). A result that disappears when one subject is
left out rests on that subject. Edit the two arrays at the top first; the list
must name subjects that are in SUBJECTS (as shipped it includes D0130, which
is not, so that job decodes everyone).
| Route | Save directory |
|---|---|
| no table | dcc_scripts/decoding/figs/<EPOCHS_ROOT_FILE>/ (all condition sets share it; the comparison names keep them apart) |
| csv | …/figs/<EPOCHS_ROOT_FILE>/anova_label_selections/<table>__effect-<pop>__correction-<c>__alpha-<a>__roi-all__<hash>/<CONDITION_NAME>/ |
| ANOVA or coupling sets | …/figs/<EPOCHS_ROOT_FILE>/elecset_<set>/ per set |
.png, .pdf, .pkl and friends are git-ignored: the results live on the
cluster.
Inside the save directory, with <params> =
job<SLURM id>_<n>_subs_[<electrodes>_]<clf>_<B>bts_<S>splts_<R>rps_<unit>_unit_ev_<ev>:
| File | Contents | Read it for |
|---|---|---|
<comparison>/<roi>/<timestamp>_true_v_shfle_<comparison>_<roi>_<params>.{png,pdf,eps} |
Mean true accuracy ± 1 SD over the 25 samples, the shuffle mean ± 1 SD, and a > shuffle bar over the surviving clusters |
Is the contrast decodable, and when |
<context>/<roi>/<timestamp>_<context>_comparison_<roi>_<params>.* |
The two comparisons of the context (e.g. 25% vs 75% incongruent) and the pooled shuffle; a solid and a dashed bar where one beats the other; under them, each trace's own > chance bar |
Does decodability differ between the two blocks |
<context>/<roi>/<timestamp>_<context>_ACC_DIFF_plot_<roi>_<params>.* |
Sample-wise difference (first − second) ± 1 SD, with the same two bars | The size and timing of that difference |
<comparison>/<roi>/<timestamp>_<condition>_<roi>_<comparison>_SUMMED_<B>boots_…_time_averaged_confusion_matrix.png |
Row-normalised confusion matrix, whole epoch, counts summed over bootstraps | Whether one class dominates the errors |
<comparison>/<roi>/…DEBUG_CM_Traces_<comparison>… |
Each confusion-matrix cell over time (correct in green, errors in red) | Debugging: a class the decoder always predicts |
<timestamp>_MASTER_RESULTS_<params>_<condition>.pkl |
Everything needed to re-plot or re-test (§6.3) | Numbers and replots |
confusion_matrices/ |
Created by every bootstrap; normally empty | – |
Every title names the decoded analysis and the electrode set, and every filename carries the job ID, so figures from different runs in the same folder stay attributable.
import pickle
with open(path, 'rb') as f:
r = pickle.load(f)
r['metadata']['time_window_centers'] # seconds, one per window
r['metadata']['n_electrodes'], r['metadata']['electrodes'] # {roi: {subject: [electrodes]}}
r['metadata']['args'] # every setting of the run
s = r['stats']['i_vs_c_at_inc25']['lpfc']
s['repeat_true_accs'] # (25, n_windows)
s['repeat_shuffle_accs'] # (250, n_windows)
s['significant_clusters'] # bool per window: the > shuffle bar
r['stats']['pooled_shuffles']['lpfc']['lwpc_block_balanced'] # the context null
r['comparison_clusters']['lpfc']['lwpc_block_balanced']['1_over_2']['clusters'] # 25% > 75%
r['comparison_clusters']['lpfc']['lwpc_block_balanced']['2_over_1']['clusters'] # 75% > 25%The keys under stats are the registry's comparison names; the context keys are
the registry's condition_name, lower-cased. replot_master_results(path, save_dir)
in src/analysis/decoding/plots/replot.py redraws every figure a pickle
supports, and replot_all does a whole tree; replot_all_decoding_figures.ipynb
in dcc_scripts/decoding/ drives them.
Read in this order.
Step 1 — the log. Check the dropped-electrode summary, the electrode count
([anova-labels] decoding with N electrodes, or DECODING ON ELECTRODE SET … (N electrodes, M subjects)), and that every bootstrap finished (No data generated for bootstrap means one did not).
Step 2 — is it decodable? (true_v_shfle)
| What you see | Reading |
|---|---|
> shuffle bar after stimulus onset, true mean clearly above the shuffle band |
Decodable, in those windows. Report onset and extent, not a single window's accuracy. |
| no bar | Not decodable at this threshold. Not evidence of absence: 25 samples and a percentile test are not a power analysis. |
| bar in windows centred before −0.125 s | Nothing about the current stimulus exists there. For congruency or switch type, it is an artifact meter: block-level baseline differences or fold leakage (Cross-decoding controls §6). Task can legitimately appear early on repeat trials. |
| the shuffle band sits far from 0.5 | Class imbalance or a pipeline problem; check the time-averaged confusion matrix. |
A window centred at t covers t ± 125 ms (64 samples), so a window centred at 0.05 s already contains post-stimulus data.
Step 3 — do the two blocks differ? (the context figure) Only for condition
sets with a context_comparison.
- A solid bar in the first trace's colour marks windows where the first comparison (e.g. 25% incongruent) decodes better than the second; a dashed bar in the second's colour, the reverse. The lower bars are each trace against chance.
- Direction for the adaptation effects. LWPC predicts a smaller congruency
effect in 75%-incongruent blocks, so congruency should decode better in
25%-incongruent blocks: the
25% I > 75% Ibar. The same holds for switch type and the25% S > 75% Sbar. This is the decoding counterpart of the univariate direction test (n2_direction_tests.md). - Compare, do not count. "Decodable in 25% blocks, not in 75% blocks" is not a difference; only the between-trace bar is. Read the difference figure for its size.
- Which condition set. Use the
_block_balancedsets (§2.1). A difference on the 4-cell sets can come from their block mix.
Step 4 — compare electrode sets correctly. Accuracy grows with the number of electrodes, and the sets differ in size. Compare sets on their own two traces (the within-set context comparison, which set size does not bias), not one set's accuracy against another's. For selected sets, remember which decodes are on the selecting effect (§3.2).
Step 5 — report n_electrodes and n_subjects together. Both are in the
pickle's metadata and the figure titles. The p-values are over resamples of the
same trials (§2.3); the leave-one-subject-out runs (§5.5) are the protection.
ANOVA_LABELS_CSVScannot be cleared from the environment (§3.1), and the shipped default is a saved table, so the no-table run needs a file edit.ANOVA_LABEL_EFFECTSfrom the environment is ignored (§3.2).- No mode check on population names (§3.2), unlike A4.
- The def-split launcher's default
STRATAnames columns that do not exist (§3.4). FIRST_TIME_POINTinrun_decoding_dcc.pyis unused; the decoder assumes epochs start at −1.0 s (§4.2).- The pseudo-trials carry no between-electrode covariance (§2.2), which
bounds what these accuracies can say about population codes
(
analysis_plans.md› Simplification plan §1.1–1.2). - The no-table runs of every condition set share one folder, kept apart only by comparison names and job IDs.
[ ] export EPOCHS_ROOT_FILE; the table (if any) comes from the same file
[ ] choose the route: no table (edit ANOVA_LABELS_CSVS), csv, or a held-out launcher
[ ] population names match the table's mode (_condition_ vs _proportion_)
[ ] CONDITIONS are registry keys with comparisons (block-balanced sets for block contrasts)
[ ] smoke test: the testing-params block, one condition
[ ] check the echoed submission lines
[ ] log: electrode count, subjects, every bootstrap finished
[ ] true_v_shfle: > shuffle bar after onset, none before -0.125 s
[ ] context figure: read the between-trace bars and the difference plot
[ ] compare sets on within-set contrasts, not raw accuracy
[ ] n_electrodes and n_subjects reported together; LOO for anything headline
pip install -e . pytest, then python -m pytest -o addopts="" tests/analysis/decoding -q.
| Test file | Pins |
|---|---|
test_decoding.py |
balancing, windowing, the bootstrap and pooling, main end to end on mocked data, sig vs all electrodes |
test_decoding_figures.py |
significance strip layout, legends and titles from the registry, replotting from the pickle |
test_trial_splitting.py |
the responsiveness split: disjointness, stratification, the selector |
test_anova_electrode_selection.py, ..._integration.py |
the ANOVA-set split across condition sets, set algebra, a planted interaction on synthetic data |
test_coupling_electrode_selection.py |
coupled sets and matched draws |
tests/analysis/utils/test_anova_label_selection.py |
population names, corrections, the output-folder slug |
analysis_guide.md§7 — the decoding modules and theDecoderclass; §21 — the held-out selection splits in depth; §17.5 — the runbook for the main-effect populations- A4 cross-decoding — train on one contrast, test on another, in the same populations
- N3b block transfer — train in one block, test in the other
- Cross-decoding controls §6 — what a pre-stimulus cluster means
n2_direction_tests.md— the univariate direction of the adaptation effectsanalysis_plans.md› Simplification plan §1 — what pseudo-trial decoding can and cannot show
A4 — cross-decoding congruency ↔ switch type: how to run it and how to read it
What this document is. A standalone walkthrough of the A4 cross-decoding job
(submit_stability_flexibility_cross_decoding_dcc.sh): the question it asks, the
four ways it can define electrode groups (anova, csv, power_traces, none),
every parameter that changes the answer, the exact commands, every file it writes,
and how to read them. It also covers the task-transfer positive controls, which
run through the same job (§9), and the confound controls built into it: RT
matching (§6.4), the overall-activity control (§6.5) and the response-locked
condition set (§5). §13 is the runbook for all of them together: what to run
in what order, how to compare the runs, and what each outcome lets you say.
It is the run-and-read companion to three other documents, and does not repeat them:
analysis_guide.md§17 — why A4 is built the way it is (derived class definitions, why condition sets must cross, the payoff 2×2).- Cross-decoding controls — what to do when a transfer comes back uninformative.
- N3b block transfer — the block-transfer analysis, which is a third mode of this same job.
For the ordinary (non-transfer) decoding job, see Decoding job.
If you read only one section, read §3 The defaults you actually get: the submit script and the Python runner disagree on half the knobs, and the submit script's defaults changed on 2026-09-28. If you have a transfer and want to know whether it is reportable, read §13 The control battery. The results of the battery so far are in §13.8.
cd dcc_scripts/decoding
export EPOCHS_ROOT_FILE=Stimulus_-1.0to1.5sec_decFactor_8_outliers_10_drop_and_nan_thresh_perc_5.0_70.0-150.0_Hz_padLength_1.5s_filterbank_hilbert_stat_func_ttest_zmax_20
# no electrode groups: decode every task-significant lPFC electrode as one set
ELECTRODE_DEFINITION=none bash submit_stability_flexibility_cross_decoding_dcc.sh
# groups from a saved A1 table. ELECTRODE_DEFINITION=csv is REQUIRED (§4.2)
ELECTRODE_DEFINITION=csv ANOVA_LABELS_CSV=<A1 result folder> \
bash submit_stability_flexibility_cross_decoding_dcc.sh
# groups from an ANOVA fit inside the job (the default route)
bash submit_stability_flexibility_cross_decoding_dcc.sh
# the same, with the ANOVA fit on 30% of trials and everything decoded on the other 70%
# (writes to its own ..._split0.3s0 folder)
ELECTRODE_SELECTION_SPLIT=true bash submit_stability_flexibility_cross_decoding_dcc.sh
# the confound controls (§13 has the full battery and how to read it)
ELECTRODE_DEFINITION=none RT_MATCH=rt bash submit_stability_flexibility_cross_decoding_dcc.sh # RT-matched trials
ELECTRODE_DEFINITION=none RT_MATCH=random bash submit_stability_flexibility_cross_decoding_dcc.sh # its trial-count control
ELECTRODE_DEFINITION=none ACTIVITY_CONTROL=remove_mean bash submit_stability_flexibility_cross_decoding_dcc.sh # pattern only
ELECTRODE_DEFINITION=none ACTIVITY_CONTROL=mean_only bash submit_stability_flexibility_cross_decoding_dcc.sh # overall activity onlyThen open summary.txt in the save directory printed near the top of
out/slurm_<jobid>_<jobname>.out. For each electrode group, the result is three
numbers per transfer direction: how many windows beat the shuffle null, how many
fall below the within-contrast ceiling, and what share of the ceiling it keeps
(§8). Whether a transfer survives the controls is read by comparing runs (§13.3).
| Role | File |
|---|---|
| Job submitter (one job per table × population × condition set) | dcc_scripts/decoding/submit_stability_flexibility_cross_decoding_dcc.sh |
| Cluster wrapper (8 cores, 128 GB, 16 h) | dcc_scripts/decoding/sbatch_stability_flexibility_cross_decoding_dcc.sh |
The knobs: environment variables → args, and the save directory |
dcc_scripts/decoding/run_stability_flexibility_cross_decoding_dcc.py |
| The job: electrode groups → ROI array → designs → outputs | dcc_scripts/decoding/stability_flexibility_cross_decoding_dcc.py (main) |
| Contrasts, condition checks, the two label vectors, the decoder | src/analysis/decoding/cross_decoding.py |
The cross-validated decoder (labels_test, stratify_labels, frac_train, temporal_generalization) |
src/analysis/decoding/decoder.py (Decoder.cv_cm_jim_window_shuffle) |
| Accuracy and the cluster test against shuffle | src/analysis/decoding/accuracy_stats.py |
anova route: one window-mean ANOVA per electrode |
src/analysis/stats/stability_flexibility_segregation.py (per_electrode_anova_labels) |
power_traces route: finished windowed-ANOVA runs |
src/analysis/stats/power_traces_conjunction.py (electrode_labels) |
csv route: a saved A1 anova_labels.csv |
src/analysis/utils/anova_label_selection.py |
RT_MATCH: RT-matched (or count-matched random) decode trials |
src/analysis/utils/rt_matching.py, called by _apply_rt_match in the job |
ACTIVITY_CONTROL: remove each subject's mean across electrodes, or keep only it |
src/analysis/decoding/activity_control.py, called by _activity_controlled / _decoded_group in the job |
The held-out selection split (ELECTRODE_SELECTION_SPLIT) |
src/analysis/decoding/anova_electrode_selection.py (assign_trial_partitions, apply_trial_partition) |
| Same job, other analyses | submit_block_transfer_dcc.sh (ANALYSIS=block_transfer, N3b), submit_task_transfer_dcc.sh (ANALYSIS=task_transfer, §9) |
submit_stability_flexibility_cross_decoding_dcc.sh loops tables x populations x condition sets
└ sbatch_stability_flexibility_cross_decoding_dcc.sh
└ run_stability_flexibility_cross_decoding_dcc.py env vars -> args; validates; builds SAVE_DIR
└ stability_flexibility_cross_decoding_dcc.main(args)
├ (i) electrode definition -> a labels table (S, F, CPC, SPS, CPS, SPC)
│ anova load epochs -> assemble_long_df -> per_electrode_anova_labels
│ csv load_anova_labels + load_anova_label_electrodes(effect)
│ power_traces power_traces_conjunction.electrode_labels(runs)
│ none no table
├ groups: both / S_only / F_only (disjoint), plus the reference group 'all'
├ (ii) _build_roi_arrays: the ROI pseudopopulation, channels named '<subject>-<electrode>'
│ load epochs -> selection split (decode side) [ELECTRODE_SELECTION_SPLIT]
│ -> RT matching of the decode trials [RT_MATCH; writes rt_match_*.csv]
│ -> pseudopopulation
├ factors_are_crossed check; class definitions read from the conditions' declared levels
├ per decode: restrict to the group's electrodes -> activity control [ACTIVITY_CONTROL]
├ A4(0) within-block decodes [16-cell condition set only]
├ A4(0b) within-block 2x2 per definition group, circular cells skipped [16-cell + groups]
├ A4(a) per group: stab_to_stab, flex_to_flex, stab_to_flex, flex_to_stab
├ A4(c) temporal generalization on TEMPGEN_GROUPS
└ cross_decoding.json, accuracy_traces.npz, tempgen_*.npy, anova_labels.csv,
rt_match_*.csv, figures, summary.txt
Every decode in the job goes through the same four steps:
cd.run_cross_decoding(arrays, roi, train_strings, test_strings)
├ build_cross_decoding_arrays concatenate conditions; labels_train, labels_test, strata
│ (strata = the source condition, so folds stay balanced on
│ the labelling that is SCORED, not only the trained one)
├ make_decoder PCA (EXPLAINED_VARIANCE) -> LDA with equal class priors
├ cv_cm_jim_window_shuffle x2 true labels, then train labels permuted and refit (the null)
└ _summarise confusion matrices -> accuracy per window x repeat
-> time_perm_cluster, true vs shuffle, one-tailed, N_PERM
The A1/A2 counts show whether the same electrodes carry both effects. They cannot say whether those electrodes carry one code or two codes that happen to share electrodes. A4 trains a classifier on one contrast and scores it on the other, on the same trials:
- stability = congruency: incongruent (class 0) vs congruent (class 1);
- flexibility = switch type: switch (class 0) vs repeat (class 1).
stab_to_flex trains on congruency and scores the predictions against switch
type. If the axis that separates incongruent from congruent also separates switch
from repeat, the predictions track switch type and accuracy beats the shuffle null.
The transfer is signed. Class 0 is paired with class 0: the classifier's "incongruent" is scored as "switch". A shared axis on which incongruent and switch trials both sit on the same side comes out above chance. A shared axis with the opposite pairing (incongruent with repeat) comes out reliably below chance. That is still a shared axis, not a null; see Cross-decoding controls §5 before reporting it.
A transfer means nothing without its ceiling: the within-contrast decode of
the labelling it is scored on, on the same trials with the same folds.
stab_to_flex is read against flex_to_flex, and flex_to_stab against
stab_to_stab. The job runs all four in every group and compares them for you.
| Design | What is decoded | Runs when |
|---|---|---|
| A4(0) within-block | congruency inside 25%- and 75%-incongruent blocks; switch type inside 25%- and 75%-switch blocks, on all loaded electrodes | the condition set declares both proportions (stimulus_experiment_conditions) |
| A4(0b) within-block per group | the same 2×2 plus the two cross cells, on each definition group (CPC/SPS/CPS/SPC, or congruency/switch_type for main-effect labels), skipping the cells that group was selected on | as A4(0), and the route defines groups |
| A4(a) label transfer | the two transfers and the two ceilings, on each group | always |
| A4(c) temporal generalization | train at one window, test at every window: congruency within, switch type within, congruency → switch type | TEMPGEN_GROUPS is non-empty |
A4(a) is pooled over the block proportions: its classes are every incongruent cell against every congruent cell, whichever condition set you use. Only A4(0)/A4(0b) split by block.
run_stability_flexibility_cross_decoding_dcc.py has its own defaults, used when
you run it directly (the synthetic dry runs). The submit script sets different
ones and passes them all through sbatch --export, so a submitted job uses the
right-hand column:
| Knob | Runner default (python run_...) |
Submit-script default (bash submit_...) |
|---|---|---|
CONDITIONS |
stimulus_experiment_conditions (16 cells) |
stimulus_main_effect_conditions (4 cells) |
ELECTRODE_DEFINITION |
anova |
anova |
CONTRAST_MODE |
proportion, or read off the CSV folder |
condition, or read off the CSV folder |
FDR_CORRECTION |
fdr_bh |
none (raw p), or flags on the csv route |
WINDOW_TMIN / WINDOW_TMAX |
0.0 / 0.5 s |
0.0 / 1.5 s |
WINDOW_SIZE / STEP_SIZE |
20 / 10 samples |
64 / 16 samples (250 / 62.5 ms) |
ANOVA_LABEL_EFFECT |
both |
union |
EPOCHS_ROOT_FILE |
required for real data | the ..._filterbank_hilbert_stat_func_ttest_zmax_20 file |
ROI, ELECTRODES, ALPHA |
lpfc, sig, 0.05 |
same |
N_SPLITS, N_REPEATS, N_PERM, EXPLAINED_VARIANCE |
5, 10, 500, 0.8 |
same |
ELECTRODE_SELECTION_SPLIT |
false |
same |
RT_MATCH (+ RT_MATCH_BINS, _BALANCE) |
none (10, equal) |
same |
ACTIVITY_CONTROL |
none |
same |
So bash submit_stability_flexibility_cross_decoding_dcc.sh with nothing set is:
main-effect groups (congruency and switch-type main effects, raw p < 0.05,
window-mean HG over 0–1.5 s), fit in the job on the task-significant lPFC
electrodes, with the transfer pooled over both proportions. The within-block
designs A4(0)/A4(0b) do not run, because the 4-cell condition set has no block
factor. No selection split, no RT matching and no activity control run unless you
ask for them; each one, when set, adds a tag to the output folder (§13.1).
Stale defaults elsewhere.
analysis_guide.md§17.4 still describes the older submit defaults (csv route, 16-cell set, 0–0.5 s, 20/10 samples), and the §17.5 runbook's step 3 command (ANOVA_LABELS_CSV=$COND_CSV bash submit_...) no longer reads the table, because the default route is nowanova(§4.2). This document describes the scripts as they are.
Three choices decide which electrodes are decoded, and they are easy to conflate:
ROI— which region (a key ofsrc/analysis/config/rois.py:lpfc,acc,dlpfc,parietal,occ,v1, …).ELECTRODES— which of that region's electrodes are loaded.sigkeeps the electrodes whose high gamma beats their pre-stimulus baseline (read fromsig_chans_<subject>_<EPOCHS_ROOT_FILE>.json, so the epochs file also picks the significance file);allkeeps every electrode in the ROI. The csv route ignores this and always loads every ROI electrode.ELECTRODE_DEFINITION— how the loaded electrodes are split into groups.
Every route except none produces a labels table with a binary S and F flag
per electrode. The groups are the three disjoint cells of that table, plus the
reference group:
| Group | Electrodes | Named, CONTRAST_MODE=proportion |
Named, CONTRAST_MODE=condition |
|---|---|---|---|
| S and F | carry both effects | both |
both |
| S only | carry the stability effect only | S_only |
congruency_only |
| F only | carry the flexibility effect only | F_only |
switch_type_only |
| reference | every loaded electrode, selected by nothing A4 decodes | all (REFERENCE_GROUP) |
all |
proportion flags are the LWPC (congruency × incongruent proportion) and LWPS
(switch type × switch proportion) interactions; condition flags are the
congruency and switch-type main effects. Groups with fewer than MIN_GROUP_SIZE
(5) electrodes are skipped, with a line in the log. If a group happens to equal
the whole array (e.g. both in the synthetic data), the reference group is not
added a second time.
bash submit_stability_flexibility_cross_decoding_dcc.sh # main effects
CONTRAST_MODE=proportion bash submit_stability_flexibility_cross_decoding_dcc.sh # LWPC / LWPS
WINDOW_TMIN=0.2 WINDOW_TMAX=0.7 FDR_CORRECTION=fdr_bh bash submit_stability_flexibility_cross_decoding_dcc.sh- What it fits: one ANOVA per electrode on the window-mean high gamma over
[WINDOW_TMIN, WINDOW_TMAX], on theELECTRODESset ofROI(per_electrode_anova_labels). An electrode is flagged when its p (or BH q, across electrodes) is belowALPHA. The flag ignores direction. - Knobs that matter:
CONTRAST_MODE,WINDOW_TMIN/WINDOW_TMAX,FDR_CORRECTION(noneorfdr_bh;flagsis refused because there is no saved table),ALPHA,ELECTRODES,ANOVA_MODEL. ANOVA_MODEL(condition mode only).oneway(default) tests congruency and switch type in two separate ANOVAs, each pooled over the other factor.twowayfits one Type IIIcongruency * switchTypeANOVA per electrode: each main effect is the equal-weight average of its two simple effects, so a switch effect cannot leak into the congruency test when the cells are unequal, and the other factor is taken out of the error term. The interaction is written toanova_labels.csvasCXSand counted against the three groups on theinteraction_electrodesline ofsummary.txt; it does not change the groups. The folder gains_twowayafter the correction.ELECTRODE_SELECTION_SPLIT=truefits the ANOVA onELECTRODE_SELECTION_FRAC(0.3) of each subject's trials, stratified on congruency, task sequence and block, and decodes only the other 70%. The split is keyed on each trial'smetadata.trial_count, so a physical trial cannot be in the selection half under one condition and the decode half under another. With the split, A4(0b) keeps the cells that would otherwise be circular. This is the "clean-ceiling" version of the csv run (see §8 step 2).- Writes
anova_labels.csv(the table it fit) into the save directory. - Save directory:
results/<EPOCHS_ROOT_FILE>/cross_decoding_<roi>_window_<tmin>to<tmax>s_<electrodes>_anova_<mode>_<correction>/<CONDITIONS>/, e.g.cross_decoding_lpfc_window_0.0to1.5s_sig_anova_condition_none/stimulus_main_effect_conditions/.
The split names its folder. A split run writes to
..._anova_<mode>_<correction>_split<frac>s<seed>/(e.g._split0.3s0), so it no longer overwrites the unsplit run of the same settings, andsummary.txtrecords it on itselectrode_selection_splitline. (Before 2026-09-30 neither was true; a split run from then shares the unsplit folder, and only the slurm log's[trial-split]lines tell them apart.)SAVE_DIR=...still overrides the whole path.
ELECTRODE_DEFINITION=csv \
ANOVA_LABELS_CSV=$REPO/dcc_scripts/stats/results/$EPOCHS_ROOT_FILE/anova_conjunction_window_0.0to1.5s_sig_lpfc_condition_none \
bash submit_stability_flexibility_cross_decoding_dcc.shELECTRODE_DEFINITION=csvis required. With any other route the submit script throws the table list away (it would only name folders after tables the job never reads), soANOVA_LABELS_CSV=... bash submit_...on its own silently runs the in-jobanovaroute instead. The firstecholine of the submission saysanova_labels=nonewhen this happens.ANOVA_LABELS_CSVis the A1anova_labels.csvor its result folder. Without it, the job uses theANOVA_LABELS_CSVSarray in the submit script (one condition-mode table as shipped), one job per entry.ANOVA_LABEL_EFFECTS(space-separated, defaultunion) says which population of the table each job starts from; one job per name. The job restricts the table to that population, then decodes the disjoint groups left in it:union— every electrode with either effect, soboth, the two*_onlygroups andallin one job. This is the normal choice.both,congruency_only, … — only that population; the other groups come out empty and are skipped.allis still decoded.- Names must belong to the table's mode:
lwpc,lwps,lwpc_only,lwps_onlyfor a proportion table;congruency,switch_type,congruency_only,switch_type_onlyfor a condition table. The submit script skips the other mode's names with a message, and the runner refuses them.
CONTRAST_MODEis read off the table's folder name (..._<roi>_<mode>_<correction>). The submit script passes the folder's mode whateverCONTRAST_MODEsays; the runner, run directly, refuses a contradiction.ANOVA_LABEL_CORRECTION(flagsdefault,none,fdr_bh) andANOVA_LABEL_ALPHAbecome the job'sFDR_CORRECTION/ALPHA.flagskeeps the table's own 0/1 flags (whatever correction built it);noneandfdr_bhre-threshold its saved p or q columns atANOVA_LABEL_ALPHA.ELECTRODESis ignored. The ROI array holds every ROI electrode, so theallgroup is every lPFC electrode, not only the task-significant ones, even though the table itself was fit on thesigelectrodes and the folder name still sayssig. Controls for a csv run (the task transfer, §9) should therefore useELECTRODES=all.- Unused but still in the folder name:
WINDOW_TMIN/WINDOW_TMAXandELECTRODES.ELECTRODE_SELECTION_SPLITis refused: a saved table has no record of which trials fit it. - Save directory:
The folder repeats the table's own name, so the A1 window and correction are recoverable from the path.
results/<EPOCHS_ROOT_FILE>/cross_decoding_<roi>_window_<tmin>to<tmax>s_<electrodes>_csv_<mode>_<correction>/ <CONDITIONS>/anova_label_selections/ <table folder>__effect-<population>__correction-<c>__alpha-<a>__roi-all__<hash>/
Gotcha: the table was fit on the trials A4 decodes. Each group's within decode of the effect that selected it (
stab_to_staboncongruency_only,flex_to_flexonswitch_type_only, both onboth) is inflated by selection. Theallgroup's ceilings are not. For numbers with no trial overlap anywhere, run theanovaroute withELECTRODE_SELECTION_SPLIT=true(§4.1).
ELECTRODE_DEFINITION=none bash submit_stability_flexibility_cross_decoding_dcc.sh # task-significant lPFC
ELECTRODE_DEFINITION=none ELECTRODES=all bash submit_stability_flexibility_cross_decoding_dcc.sh # every lPFC electrode
ELECTRODE_DEFINITION=none ROI=occ bash submit_stability_flexibility_cross_decoding_dcc.sh # another region- What it does: no ANOVA, no table. The only group is the reference group,
i.e. every electrode
ELECTRODESloads. WithELECTRODES=sigthat is every task-significant electrode of the ROI. This is the plain question "does this region's code transfer?", with no selection on either effect. - Temporal generalization runs on that group by default (
TEMPGEN_GROUPSdefaults toREFERENCE_GROUPhere, instead ofboth). - What does not run: A4(0b) (no definition groups). A4(0) still runs if you
pass the 16-cell set (
CONDITIONS=stimulus_experiment_conditions). - Unused and left out of the folder name:
WINDOW_TMIN/WINDOW_TMAX,CONTRAST_MODE,FDR_CORRECTION,ALPHA, and every table setting. REFERENCE_GROUPmust be non-empty; the runner refuses''.- No
anova_labels.csvis written. - Save directory:
results/<EPOCHS_ROOT_FILE>/cross_decoding_<roi>_<electrodes>_none/<CONDITIONS>/, e.g.cross_decoding_lpfc_sig_none/stimulus_main_effect_conditions/.
ELECTRODE_DEFINITION=power_traces CONTRAST_MODE=proportion \
POWER_TRACES_RUN_DIR=/path/to/power_traces/run \
bash submit_stability_flexibility_cross_decoding_dcc.sh- What it reads: the within-electrode windowed ANOVA runs from the power-traces pipeline and their permutation cluster correction. An electrode is flagged when a cluster for the LWPC (LWPS) interaction survives anywhere in time. More sensitive to brief interactions than the window mean, and the groups become exactly the electrodes the power-trace figures call significant.
- Run directories:
POWER_TRACES_RUN_DIR(one run whose ANOVA carried all four interactions, e.g. astimulus_experiment_conditionsrun), or one per interaction withPOWER_TRACES_CPC,_SPS,_CPS,_SPC. POWER_TRACES_CORRECTION:fdr_bh(default; BH across electrodes),cluster(raw cluster p, the older lab convention) ornone(any surviving cluster).POWER_TRACES_ROIrestricts the table to one ROI.- Needs no epochs for the definition step (the decode still loads them).
Gotcha: pass
CONTRAST_MODE=proportion. This route always reads the interactions, but the submit script defaultsCONTRAST_MODEtocondition. Left at that, the groups are misnamedcongruency_only/switch_type_only, and the A4(0b) circularity guard treats them as main-effect groups: it skips every decode of each group's contrast and drops the CPS/SPC groups.
CONDITIONS names a dict in src/analysis/config/experiment_conditions.py. A4
needs every condition to declare congruency and switchType, and needs the
two to cross (all four combinations present). Two sets qualify:
CONDITIONS |
Cells | Designs that run | Why pick it |
|---|---|---|---|
stimulus_main_effect_conditions (submit default) |
4: Stimulus_{i,c}{r,s}, both proportions pooled |
A4(a), A4(c) | about 4× the trials per cell, so fewer incomplete rows; folds stratified on congruency × switch type |
stimulus_experiment_conditions (runner default) |
16: the full 2×2×2×2 | all four | the only set with block factors; folds stratified on all four factors |
response_main_effect_conditions |
4: Response_{i,c}{r,s}, both proportions pooled |
A4(a), A4(c) | the response-locked twin of the submit default; needs a Response_... epochs file |
response_main_effect_conditions and response_experiment_conditions are the
response-locked 4- and 16-cell sets. Pair them with a response-locked
EPOCHS_ROOT_FILE (make_epoched_data.py writes a Response_... file next to
each Stimulus_... one, with the same settings) and set FIRST_TIME_POINT to
that file's first sample (−1.0 for a −1.0 to 1.5 s epoch). With
ELECTRODES=sig the electrodes come from the response file's own significance
list, so they are not exactly the stimulus run's; the log prints the count.
Single-factor sets (stimulus_congruency_conditions, …) are
refused: they are separate epoch sets over the same trials, so the transfer would
be scored on trials it trained on. Confounded sets (stimulus_iS_cR_err_conditions
and siblings) are refused: congruency and switch type split their trials
identically, so a "transfer" would be the within decode. CONDITIONS="a b"
submits one job per set.
All are environment variables; nothing needs a file edited. Defaults below are the submit script's (§3).
| Variable | Default | Notes |
|---|---|---|
EPOCHS_ROOT_FILE |
the _ttest_zmax_20 file |
Also picks the sig_chans file. Export it once so every script agrees. |
DATA_SOURCE |
real |
synthetic builds a pseudopopulation with a planted answer (§7.1). |
SYNTHETIC_CODE |
shared |
shared must transfer; orthogonal must not. |
CONDITIONS |
stimulus_main_effect_conditions |
§5. Space-separated for several jobs. |
ROI |
lpfc |
A key of config/rois.py. |
ELECTRODES |
sig |
sig or all. Ignored on the csv route. |
REFERENCE_GROUP |
all |
Name of the unselected group; '' drops it (not allowed with none). |
MIN_GROUP_SIZE |
5 |
Groups with fewer electrodes are skipped. |
| Variable | Default | Used by | Notes |
|---|---|---|---|
ELECTRODE_DEFINITION |
anova |
– | anova, csv, power_traces, none (§4). |
CONTRAST_MODE |
condition |
anova, power_traces | Read off the folder on csv. Set proportion for power_traces. |
WINDOW_TMIN / WINDOW_TMAX |
0.0 / 1.5 |
anova | The ANOVA window, seconds from stimulus onset. Not the decoding window. |
FDR_CORRECTION |
none (flags on csv) |
anova, csv | none = raw p; fdr_bh = BH across electrodes. |
ANOVA_MODEL |
oneway |
anova + condition | twoway = one congruency x switch type ANOVA, plus the interaction count (§4.1). |
ALPHA |
0.05 |
anova, csv, power_traces | |
ELECTRODE_SELECTION_SPLIT |
false |
anova | Fit on 30%, decode 70% (§4.1). |
ELECTRODE_SELECTION_FRAC / _SEED |
0.3 / 0 |
anova + split | |
ANOVA_LABELS_CSV |
the script's list | csv | One table or its folder. |
ANOVA_LABEL_EFFECTS |
union |
csv | Space-separated populations; one job each. ANOVA_LABEL_EFFECT (one name) also works. |
ANOVA_LABEL_CORRECTION / _ALPHA |
flags / 0.05 |
csv | Become FDR_CORRECTION / ALPHA. |
ANOVA_LABEL_ROI |
unset | csv | Only if the table has a roi column. |
POWER_TRACES_RUN_DIR or POWER_TRACES_CPC/_SPS/_CPS/_SPC |
unset | power_traces | |
POWER_TRACES_CORRECTION / POWER_TRACES_ROI |
fdr_bh / unset |
power_traces |
| Variable | Default | Notes |
|---|---|---|
WINDOW_SIZE / STEP_SIZE |
64 / 16 |
Samples at 256 Hz: 250 ms windows every 62.5 ms, 37 windows over −1.0 to 1.5 s. |
N_SPLITS |
5 |
Folds; or random resamples per repeat when FRAC_TRAIN is set. |
N_REPEATS |
10 |
Repeats of the fold split. The samples of the cluster test and the main runtime lever. Temporal generalization uses half (at least 2). |
FRAC_TRAIN |
unset | Unset = StratifiedKFold, (N_SPLITS−1)/N_SPLITS train. Set (e.g. 0.5) for StratifiedShuffleSplit at that fraction. Also a probe for fold leakage (Cross-decoding controls §6). |
EXPLAINED_VARIANCE |
0.8 |
PCA variance kept, refit per fold. |
N_PERM |
500 |
Permutations for each cluster test. |
TEMPGEN_GROUPS |
both (all under none) |
Comma-separated. '' skips A4(c). both,all adds the unselected matrix. Each matrix costs n_windows² predictions. |
TRAIN_LABEL / TEST_LABEL |
unset | One decode instead of the battery: stability/congruency, flexibility/switchType. Goes to a train_<x>_test_<y>/ subfolder and has no ceiling comparison. |
SAMPLING_RATE / FIRST_TIME_POINT |
256 / -1.0 |
Only label the figure time axes. Change FIRST_TIME_POINT for an epoch that does not start at −1.0 s. |
SEED |
0 |
Seeds the folds, the ROI-array padding and the cluster tests. |
SAVE_DIR |
derived | Overrides the whole output path. |
Incongruent and switch trials are slower (in combinedData.csv, correct trials:
+153 ms and +197 ms per subject on average). In stimulus-locked data, activity
that tracks time-to-response then differs between the levels of both factors, and
a transfer can come from that shared latency rather than a shared code.
RT_MATCH subsamples the decode trials, per subject, so every combination of the
decoded factors has the same RT distribution
(RT matching has the method).
| Variable | Default | Notes |
|---|---|---|
RT_MATCH |
none |
rt: the RT-matched subset. random: its control, the same number of trials per subject and cell drawn without regard to RT. Compare rt with random, not with none: matching keeps about half the trials, and random pays the same cost. |
RT_MATCH_BINS |
10 |
Quantile RT bins per subject. 10 leaves ~+2 / +1 ms (i − c / s − r, n.s.) on the behavioural data; 5 leaves i − c at +8 ms (p = 0.03). |
RT_MATCH_BALANCE |
equal |
equal: the same count per cell and bin. proportional: keep the cells' size ratios. |
RT_MATCH_WITHIN |
unset | Extra strata, comma-separated, e.g. incongruent_proportion,switch_proportion to match within each block type as well. |
RT_MATCH_GROUPS |
the decoded factors | Override which factors are matched (A4: congruency, switchType; N3b / task transfer: the design's contrast and transfer factor). |
RT_MATCH_SEED |
SEED |
Seeds the draw. Use the same seed for the rt and random runs. |
- Matching runs on the decode trials only, after
ELECTRODE_SELECTION_SPLIT; theanovaroute's electrode definition still uses its own (unmatched) trials. - The default matches the four congruency × switch-type cells pooled over blocks,
which is what A4(a) decodes. The within-block designs A4(0)/A4(0b) (16-cell set)
compare classes inside one block, so for them add
RT_MATCH_WITHIN=incongruent_proportion,switch_proportion. - Each mode gets its own folder:
..._rtmatch10/,..._rtrandom10/(plus_proportional,_within-...,_groups-...,_seed<n>when those are non-default). - It writes
rt_match_<factors>_balance.csv(per subject × cell: counts and RTs before/after),_contrasts.csv(per-subject RT difference per factor) and_summary.csv(across subjects: mean and one-sample t, before/after), and adds anRT matching of the decode trialsblock tosummary.txt. Theafterrow of_summary.csvis the "residual RT difference" to report. - A subject left with no trials stops the job: its channels are in every
pseudo-trial, so it cannot drop out. Lower
RT_MATCH_BINSif that happens. - After matching, each electrode's NaN trials are dropped and every electrode is
subsampled to the fewest clean trials per condition (the ordinary decoder's
builder). How many each condition keeps is the
Decoded pseudo-trialsblock ofsummary.txt(pseudo_trialsincross_decoding.json; older runs have it only as the log linecondition '<name>': subsampling to N trials, see Changes to A4). It is set by the subject with the fewest matched trials, not by the "kept … of … trials" total. The subset is random, so the matched RT distributions carry over. - Synthetic data have no RTs, so
RT_MATCHis refused there. - It works for
ANALYSIS=block_transferandtask_transfertoo (same knobs).
ELECTRODE_DEFINITION=none RT_MATCH=rt bash submit_stability_flexibility_cross_decoding_dcc.sh
ELECTRODE_DEFINITION=none RT_MATCH=random bash submit_stability_flexibility_cross_decoding_dcc.sh| Variable | Default | Notes |
|---|---|---|
ACTIVITY_CONTROL |
none |
remove_mean: subtract each subject's mean across the decoded electrodes, per pseudo-trial and time point, so only the pattern across electrodes is decoded. mean_only: decode each subject's mean alone, the uniform part only. |
- Applied to every decode in the job (A4(0), A4(0b), A4(a), A4(c); N3b and task transfer too), per electrode group after restriction, so a subject's mean is over the electrodes that group decodes.
- Each mode gets its own folder (
..._remove_mean/,..._mean_only/); the tags stack with the split and RT-matching ones. summary.txtgets anactivity_controlline and, per group, what it did (subjects, features, and the single-electrode subjectsremove_meanempties).- How to read it, and what it cannot rule out: Overall-activity control.
ELECTRODE_DEFINITION=none ACTIVITY_CONTROL=remove_mean bash submit_stability_flexibility_cross_decoding_dcc.sh
ELECTRODE_DEFINITION=none ACTIVITY_CONTROL=mean_only bash submit_stability_flexibility_cross_decoding_dcc.shThe synthetic pseudopopulation has 40 channels, 16 conditions × 40 trials and 32
time samples, with congruency and switch type planted on either the same axis
(shared) or orthogonal axes (orthogonal). Channels 0–19 are labelled S_only,
20–39 F_only, and all 40 both.
cd dcc_scripts/decoding
export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1
DATA_SOURCE=synthetic SYNTHETIC_CODE=shared WINDOW_SIZE=16 STEP_SIZE=8 \
python run_stability_flexibility_cross_decoding_dcc.py
DATA_SOURCE=synthetic SYNTHETIC_CODE=orthogonal WINDOW_SIZE=16 STEP_SIZE=8 \
python run_stability_flexibility_cross_decoding_dcc.py
DATA_SOURCE=synthetic ELECTRODE_DEFINITION=none WINDOW_SIZE=16 STEP_SIZE=8 \
python run_stability_flexibility_cross_decoding_dcc.py- The window must fit in 32 samples, hence
WINDOW_SIZE=16(3 windows atSTEP_SIZE=8). - Single-threaded BLAS matters off the cluster: the LDA fits are small, and with
the default threading three runs on a 4-core machine were still in the first
design after 13 minutes; with the
exportline they finished in minutes. - Output goes to
results/synthetic_<code>/..., never to a real run's folder. - Planted answer: under
sharedthe transfers beat shuffle and keep most of their ceiling; underorthogonalboth ceilings beat shuffle and the transfers sit at shuffle. If that does not happen, a real-data null means nothing. - The synthetic data have no pre-stimulus period, but the time axis still starts
at
FIRST_TIME_POINT(−1.0), so ignore the axis labels on these figures. bash submit_...withDATA_SOURCE=synthetic WINDOW_SIZE=16 STEP_SIZE=16runs the same thing as a cluster job.
§8.1 shows what summary.txt looks like for these runs.
Set the shell up once (the same as the analysis_guide.md
§17.5 runbook):
REPO=/hpc/home/$USER/coganlab/$USER/GlobalLocal
cd $REPO/dcc_scripts/decoding
export EPOCHS_ROOT_FILE=Stimulus_-1.0to1.5sec_decFactor_8_outliers_10_drop_and_nan_thresh_perc_5.0_70.0-150.0_Hz_padLength_1.5s_filterbank_hilbert_stat_func_ttest_zmax_20
COND_CSV=$REPO/dcc_scripts/stats/results/$EPOCHS_ROOT_FILE/anova_conjunction_window_0.0to1.5s_sig_lpfc_condition_noneThen, depending on the question:
| Question | Command |
|---|---|
| Does lPFC's code transfer at all? | ELECTRODE_DEFINITION=none bash submit_stability_flexibility_cross_decoding_dcc.sh |
| … on every lPFC electrode? | ELECTRODE_DEFINITION=none ELECTRODES=all bash submit_... |
| Does it transfer in each main-effect population (saved table)? | ELECTRODE_DEFINITION=csv ANOVA_LABELS_CSV=$COND_CSV bash submit_... |
| The same, with no selection/decode trial overlap | ELECTRODE_SELECTION_SPLIT=true bash submit_... (own ..._split0.3s0 folder) |
| In the LWPC/LWPS populations | CONTRAST_MODE=proportion bash submit_... (in-job) or ELECTRODE_DEFINITION=csv ANOVA_LABELS_CSV=<a ..._proportion_... table> bash submit_... |
| With the within-block designs too | add CONDITIONS=stimulus_experiment_conditions |
| With the unselected temporal-generalization matrix | add TEMPGEN_GROUPS=both,all |
| The task-transfer positive controls | bash submit_task_transfer_dcc.sh (§9) |
| Is the transfer a response-time difference? | ELECTRODE_DEFINITION=none RT_MATCH=rt bash submit_... and ... RT_MATCH=random ... (§6.4) |
| … seen from the response? | ELECTRODE_DEFINITION=none CONDITIONS=response_main_effect_conditions EPOCHS_ROOT_FILE=Response_... bash submit_... (§5) |
| Is it a uniform rise in activity? | ELECTRODE_DEFINITION=none ACTIVITY_CONTROL=remove_mean bash submit_... and ... ACTIVITY_CONTROL=mean_only ... (§6.5) |
Run the none job first. It is the cheapest (one group) and is the reference
every grouped run is compared with. §13.2 has the confound controls as one
ordered script, with the flags that keep them cheap.
- Check the submission lines the script echoes:
condition=,anova_labels=,effect=,definition=,contrast=,correction=. They are what the job will run.definition=anovawhen you meant to pass a table means you left outELECTRODE_DEFINITION=csv. TEMPGEN_GROUPSandSAVE_DIRare carried by--export=ALLfrom your environment, not from the explicit export list (commas would cutTEMPGEN_GROUPS=both,allat the first comma). Setting them on the command line as above works.- Logs go to
out/slurm_<jobid>_<jobname>.outin the directory you submitted from. The top of the file prints every setting and the save directory.
Each group runs 4 decodes (true + shuffle each) of N_REPEATS × N_SPLITS fits
per window, plus 6 cluster tests; temporal generalization adds 3 matrices per
group in TEMPGEN_GROUPS at n_windows² predictions each. A csv union job
decodes up to 4 groups and is the heaviest. The wrapper asks for 16 h; if a job
runs out, resubmit with SBATCH_TIMELIMIT=36:00:00 bash submit_..., or halve the
work with N_REPEATS=5.
Everything goes to the save directory of §4.
| File | Contents | Read it for |
|---|---|---|
summary.txt |
Settings, then every design's numbers and the reading guide | Start here |
cross_decoding.json |
The same numbers per design and group: per-window significant_windows (cluster-corrected), cluster_p (despite the name, the uncorrected per-window permutation p; its floor is 1/(N_PERM+1), and an isolated window with a small p is not significant), n_below_ceiling, retained, and activity_control (what the control did to that group); also rt_match (the matching summary) and pseudo_trials (the decoded pseudo-trials per condition and per class, cd.pseudo_trial_counts). Arrays longer than 64 values are dropped |
Tables and scripts; §13.3 compares runs from it |
accuracy_traces.npz |
Label-transfer accuracy per window × repeat. Keys labeltransfer_<group>_<direction>_true / _shuffle |
Re-plotting, your own statistics |
tempgen_<name>.npy |
Temporal-generalization matrix, train window × test window, e.g. tempgen_stability_flexibility_cross_both.npy |
A4(c) |
anova_labels.csv |
The per-electrode definition table the groups came from (anova, csv, power_traces) |
Which electrodes are in which group |
rt_match_<factors>_{balance,contrasts,summary}.csv |
With RT_MATCH set: per subject × cell counts and RTs, per-subject RT differences, and their across-subject test, before/after (§6.4). In a random run they show the RT costs left in place |
The residual RT difference to report (§13.4) |
cross_decoding_summary.png |
Overview: A4(0) bar charts (top left, empty without block factors), the stab_to_flex trace per group (top right), up to three temporal-generalization matrices (bottom) |
A first look |
<direction>_<group>__cross_decoding.{png,pdf,eps} |
One figure per group × direction: true accuracy against shuffle, ±1 SD over repeats, bars where the cluster test is significant | The figures to show |
Window times are window centres. With 64-sample windows, a window centred at t covers t ± 125 ms, so the first window with no pre-stimulus sample is centred at +0.125 s, and the last window entirely before the stimulus is centred at −0.125 s.
The layout, with … for numbers (written by write_summary):
========================================================================
STABILITY vs FLEXIBILITY — A4 CROSS-DECODING
========================================================================
data_source: real
electrode_definition: csv
reference_group: all
electrode_group_sizes: {'both': …, 'congruency_only': …, 'switch_type_only': …, 'all': …}
electrode_selection_split: off (electrodes selected on the same trials that are decoded)
rt_match: off | RT-matched; groups=…, within=['subject'], bins=10, … | count-matched random control …
activity_control: none | remove_mean (…) | mean_only (…)
window: [0.0, 1.5]s
… (every other setting of the run)
------------------------------------------------------------------------
RT matching of the decode trials: <- RT_MATCH runs only
[rt-match] RT-matched, groups=['congruency', 'task_sequence'] n_bins=10 …: kept … of … trials (…%)
[rt-match] congruency (i - c): mean per-subject RT difference +… ms before -> +… ms after (t=…, p=…)
[rt-match] task_sequence (s - r): …
------------------------------------------------------------------------
Decoded pseudo-trials. Every electrode is subsampled to the fewest clean
trials of any electrode in a condition, so the subject with the fewest
trials sets the count; every electrode group decodes these same rows:
Stimulus_ir=… Stimulus_is=… Stimulus_cr=… Stimulus_cs=…
congruency: c=… i=…
switchType: r=… s=…
total=…; the label transfer trains on ~… of them per fold
------------------------------------------------------------------------
A4(0) within-block decoding baseline (Fig 9): <- 16-cell runs only
congruency (LWPC) | block 25% incongruent: mean acc=… peak=… sig windows=k/n
congruency (LWPC) | block 75% incongruent: mean acc=… peak=… sig windows=k/n
Δ(block) on mean accuracy = …
switchType (LWPS) | block 25% switch: …
------------------------------------------------------------------------
A4(0b) per-group within-block 2x2 (…diagonal cell is omitted by design):
[congruency] n_electrodes=… ignored cell=('congruency', 'any block')
switchType by switch_proportion [25%]: mean acc=… sig=k/nw
------------------------------------------------------------------------
A4(a) label transfer by group. …
[both] activity control: remove_mean over … subjects (…) <- ACTIVITY_CONTROL runs only
[both] stab_to_stab: mean acc=… peak=… (shuffle …) sig windows=k/n
[both] flex_to_flex: …
[both] stab_to_flex: mean acc=… peak=… (shuffle …) sig windows=k/n
vs flex_to_flex, its ceiling: below it in m windows; keeps X% of it above chance
[both] flex_to_stab: …
vs stab_to_stab, its ceiling: …
[congruency_only] … [switch_type_only] … [all] …
------------------------------------------------------------------------
A4(c) temporal generalization (Fig 10):
stability (within) [both]: mean diagonal=… mean off-diagonal=… (… code)
flexibility (within) [both]: …
stability->flexibility (cross) [both]: …
========================================================================
Reading: …
Step 1 — the log. Before any number, check in the slurm .out:
ROI 'lpfc' pseudopopulation: N channels (sig electrodes)and the dropped-electrode summary above it;decoded electrode groups: both=… congruency_only=… switch_type_only=… all=…, and anyhas N electrodes in ROI (< 5); skippingline;conditions: K decodable cells(4 or 16) and theblock levels:line, which says whether A4(0)/A4(0b) ran.
Step 2 — the ceilings. For each group, stab_to_stab and flex_to_flex must
have significant windows. A transfer is scored against the ceiling of the
labelling it predicts, and if that ceiling never beats shuffle the transfer is
uninterpretable: summary.txt then prints keeps n/a (its ceiling never beats shuffle). On the csv and unsplit anova routes, a group's ceiling on its own
selecting effect is inflated by selection (§4.2); read the all group's
ceilings, or the split run's, for honest ones.
Step 3 — each transfer against its ceiling. Three numbers per direction:
sig windows=k/n— windows where the transfer beats its refit shuffle null, cluster-corrected over time;below it in m windows— windows where the ceiling beats the transfer (the same cluster test, ceiling vs transfer);keeps X% of it above chance— over the windows where the ceiling beats shuffle, (transfer − 0.5) / (ceiling − 0.5). 100% is full transfer, 0% none.
| Ceiling | Transfer | Reading |
|---|---|---|
| never beats shuffle | anything | Uninterpretable. There is no code to transfer. |
| beats shuffle | beats shuffle, 0 windows below ceiling, keeps ≈ 100% | One shared axis. |
| beats shuffle | beats shuffle, some windows below ceiling, keeps 20–80% | Partial overlap. Report the share; do not round it to yes or no. |
| beats shuffle | 0 sig windows, keeps ≈ 0% | Separable codes — both contrasts are decodable but along different axes. |
| beats shuffle | reliably below shuffle, keeps < 0% | Anti-aligned axis (incongruent with repeat). Check the class ordering first (Cross-decoding controls §5). |
| – | transfer above its own ceiling, or significant well before stimulus onset | Artifact (F3, Cross-decoding controls §6). |
Step 4 — both directions. stab_to_flex and flex_to_stab should agree. If
only one transfers, the axis learned from the weaker contrast is noisier, so it
generalizes worse; that is a difference in code strength, not in code geometry.
Step 5 — groups against all. The prediction for a shared code is that
both transfers and the *_only groups do not. Compare every group with all,
which no effect selected. Groups differ in size and accuracy grows with
electrodes, so compare the keeps X% shares (each relative to its own
ceiling), not raw accuracies.
Step 6 — the pre-stimulus windows. Congruency and switch type cannot be
decoded before the stimulus. Significant windows centred before −0.125 s are an
artifact meter, not a result (analysis_guide.md §17 records
them in the earlier real runs). FRAC_TRAIN=0.5 is the quick probe: a cluster
that shrinks with the training set is fold leakage.
Step 7 — temporal generalization. Open the matrices, not just the summary
line. Train time is on the y-axis, test time on the x-axis. A bright diagonal
only is a code that changes over time; a bright square is a stable one. The
cross matrix shows whether congruency trained at one time predicts switch type
at another. The summary's sustained/stable vs diagonal/phasic label is a
threshold (mean off-diagonal > 0.55) over the whole matrix, baseline included, and
the matrices have no shuffle null or statistic, so treat them as descriptive.
Step 8 — the controls. A single run cannot rule out that the transfer is a
response-time difference or a uniform rise in activity; that takes comparing runs
(§13.3) and the reading rules of §13.4–13.5. In each control run, first check the
rt_match / activity_control lines say what you intended, and for RT_MATCH=rt
that the residual RT difference in the RT matching block is near 0.
Step 9 — within-block (16-cell runs only). A4(0) lists each block's mean
accuracy and Δ(block) = high − low. A negative Δ for congruency means congruency
is less decodable in 75%-incongruent blocks, the direction LWPC predicts. Δ is a
difference of means over all windows, baseline included, with no test of its
own, so read it with the traces. A4(0b) lists the per-group cells, with the
circular one named on each group's ignored cell= line.
- The p-values are optimistic. The samples of every cluster test are CV
repeats of the same trials, not subjects, and the pseudopopulation concatenates
electrodes from different patients whose trials were never recorded together.
Treat
n_sig_windowsas a within-dataset reliability check. mean accandpeakin A4(a) are averaged over every window, including the second before the stimulus. They are diluted and are not the post-stimulus accuracy. Read the traces, or averageaccuracy_traces.npzover the windows you care about.- A transfer at chance is only a result next to a ceiling that is not. Cross-decoding controls §2 is the rule, and the task-transfer T1/T3 controls (§9) show the pipeline can carry a code from one trial population to another.
The same job, run with ANALYSIS=task_transfer by its own launcher. It asks
whether the pipeline can transfer a code at all, using factors that should
transfer. It uses the N3b machinery: each contrast is trained in one level of
another factor and tested in the other level, uncentered and centered, on every
electrode of the ROI, with no groups and no ANOVA.
| Design | Decoded | Train → test | Condition set |
|---|---|---|---|
| T1 | task (global vs local) | congruent → incongruent trials (and back) | stimulus_task_by_congruency_conditions |
| T2 | task | repeat → switch trials | stimulus_task_by_switch_type_conditions |
| T3 | congruency | global-task → local-task trials | stimulus_task_by_congruency_conditions |
| T4 | switch type | global-task → local-task trials | stimulus_task_by_switch_type_conditions |
cd dcc_scripts/decoding
bash submit_task_transfer_dcc.sh # lpfc, task-significant electrodes
ELECTRODES=all bash submit_task_transfer_dcc.sh # every lpfc electrode: pairs with a csv A4 run
DATA_SOURCE=synthetic SYNTHETIC_CODE=congruency_specific N_REPEATS=5 WINDOW_SIZE=16 STEP_SIZE=8 \
bash submit_task_transfer_dcc.sh # planted: T1 fails, T2 transfers- Knobs:
EPOCHS_ROOT_FILE,ROI,ELECTRODES,DATA_SOURCE,SYNTHETIC_CODE(shared,congruency_specific,carryover),WINDOW_SIZE,STEP_SIZE,N_SPLITS,N_REPEATS(balanced resamples),N_PERM,SEED. Run it on the sameROI,ELECTRODESand epochs file as the A4 run it controls. - Output:
results/<EPOCHS_ROOT_FILE>/task_transfer_<roi>_<electrodes>_w<W>s<S>/pooled_design_conditions/withsummary.txt,task_transfer.json,task_transfer_traces.npzand one figure per design × centering × direction. The layout and the 2×2 table insummary.txtare the N3b ones (N3b block transfer Part 3), with the levels printed as congruent/incongruent, repeat/switch, global/local. - Reading, in order:
- T1 is the clean control: task learned on congruent trials should keep most of its within accuracy on incongruent trials. If it does, a null A4 transfer is not a pipeline failure.
- The
EFFECT SIZEline prints within-level task accuracy (T1) next to within-level congruency accuracy (T3). The task cue is drawn with the stimulus, so task is large and partly visual; the further apart the two are, the less T1 says about a congruency-sized code. - T3 is the control at congruency's own effect size. Put its
keeps X%beside A4'sstab_to_flex: congruency transferring across task while failing to transfer to switch type is the "separable codes" result. - T2/T4 carry a confound: on a switch trial the previous task was the
other one, so leftover previous-task activity flips between the two levels. A
drop there is expected even with one task code. A
PRE-STIMULUSline on a task design is expected for the same reason; on T3/T4 it is anARTIFACT FLAG.
The rationale for these controls is Cross-decoding controls §3.5; the block-transfer counterpart (X1–X3, X2b) is N3b block transfer.
ANOVA_LABELS_CSVis silently ignored unlessELECTRODE_DEFINITION=csv(§4.2). The §17.5 runbook command inanalysis_guide.mdpredates the default change and now runs theanovaroute.Fixed 2026-09-30 (§4.1); earlier split runs still share the unsplit folder.ELECTRODE_SELECTION_SPLITis not in the folder name orsummary.txt- On the csv route,
ELECTRODESand the window are ignored but still name the folder (§4.2). power_tracesneedsCONTRAST_MODE=proportionby hand (§4.4).- A4(a)
mean accincludes the baseline (§8.3). There is no post-stimulus summary for label transfer, unlike N3b's table. - Temporal generalization has no null (§8.2 step 7).
- Pre-stimulus significance in selected groups. The earlier real A4 runs
showed cross-decode clusters before stimulus onset
(
analysis_guide.md§17, caveat). In the 2026-09-29 runs they appear in the main-effect-selectedbothgroup (anova and csv routes) and in the csv run's 398-electrodeall, but not in the 171 task-significant electrodes of thenonerun. The likeliest route is block structure (pooled cells are confounded with block type, and the one-way main-effect ANOVA has no block term); Cross-decoding controls §6 has the diagnosis. Any transfer needs its pre-stimulus windows reported next to it. - Resamples are not subjects (§8.3); there is no leave-one-subject-out for A4.
- The transfer sits where RT differs. In the 2026-09-29
nonerun (171 task-significant lPFC electrodes) both transfers are significant only from the window centred at +0.62 s (median correct RT ~1.17 s), and incongruent and switch trials are both slower. Report the RT-matched run (§6.4) against itsrandomcontrol, and the response-locked run (§5), next to it (§13). - No formal test between runs. Whether a control "keeps a similar share" is judged against the spread across seeds (§13.4), not tested; there is no permutation test of one run's transfer against another's.
ACTIVITY_CONTROLandRT_MATCHare wired into this job only. The functions work on any epochs structure or ROI arrays, but the ordinary decoding and power-trace jobs do not call them yet (RT matching, Overall-activity control).
Running (§13.2 has the commands)
[ ] export EPOCHS_ROOT_FILE once; set every other knob per command, not with export
[ ] synthetic dry runs: shared transfers, orthogonal does not
[ ] ELECTRODE_DEFINITION=none (the ungrouped reference run)
[ ] RT_MATCH=rt and RT_MATCH=random, same SEED (RT control and its trial-count control)
[ ] CONDITIONS=response_main_effect_conditions + a Response_ epochs file (response-locked)
[ ] ACTIVITY_CONTROL=remove_mean and =mean_only (pattern vs overall activity)
[ ] RT_MATCH=rt/random + ACTIVITY_CONTROL=remove_mean (both controls at once)
[ ] bash submit_task_transfer_dcc.sh (positive controls, same electrodes)
[ ] SEED=1, SEED=2 with their own SAVE_DIR (noise floor)
[ ] groups only if claimed: ELECTRODE_SELECTION_SPLIT=true (clean ceilings; own folder)
(or ELECTRODE_DEFINITION=csv ANOVA_LABELS_CSV=... for the saved table; check the echo lines)
Reading (§8.2, §13.3-13.5)
[ ] log: group sizes, skipped groups, cells, block levels, rt-match / activity-control lines
[ ] every ceiling (stab_to_stab, flex_to_flex) beats shuffle <- else stop
[ ] no significant windows centred before -0.125 s <- else stop
[ ] per group and direction: sig windows, windows below ceiling, share kept
[ ] both directions agree (or the asymmetry is reported)
[ ] RT-matched: residual RT ~0; rt vs random share kept, against the seed spread
[ ] remove_mean / mean_only share kept vs baseline
[ ] T1 transfers; T3 share set beside A4's share
[ ] groups compared by share kept, against 'all', split run only
[ ] fill in the report block (§13.7)
Run outside the cluster with pip install -e . pytest then
python -m pytest -o addopts="" tests/analysis/decoding -q.
| Test file | Pins |
|---|---|
test_cross_decoding.py |
the two label vectors, stratification on the condition cell, padding handling, equal priors, shared-transfers/orthogonal-does-not, frac_train, temporal generalization |
test_cross_decoding_condition_scheme.py |
class definitions read from declared levels, crossed vs confounded sets, 4- vs 16-cell sets, the none route decoding only the loaded electrodes |
test_cross_decoding_electrode_groups.py |
channel keys, disjoint groups, the reference group, the csv union and raw-correction behaviour |
test_cross_decoding_circularity.py |
which within-block cell each group double-dips on |
test_cross_decoding_runner.py |
contrast mode read from the folder, mode/effect mismatches refused, csv ignored off its route, folder names (incl. the split and RT-matching tags) |
test_cross_decoding_rt_match.py |
RT matching in the job: runs after the split, writes its report, keeps the same counts for rt and random, refuses a subject left empty and synthetic data, lands in summary.txt |
../utils/test_rt_matching.py |
the RT-matching util itself: planted RT costs removed, equal/proportional balance, unusable rows, reproducibility, the random control, the epochs adapter |
test_activity_control.py |
the two transforms (per-subject, NaN-safe, single-electrode subjects), and the planted answers: a shared gain dies under remove_mean and survives mean_only; a shared pattern does the reverse |
test_cross_decoding_activity_control.py |
the job feeds every design the transformed arrays, per group, in A4 and the transfer analyses, and reports it in summary.txt |
test_task_transfer.py |
T1–T4 on real condition sets, planted synthetic answers, end to end |
A transfer above chance says that congruency and switch type share something. Before it can be reported as a shared code, the obvious cheaper explanations have to be ruled out. Each has a control built into this job. This section is the one place that says what to run, in what order, what to compare with what, and what each outcome lets you say. The single controls are documented in §6.4 (RT matching), §6.5 (overall activity), §5 (response-locked) and §9 (task transfer); the methods are in RT matching and Overall-activity control.
Run everything on the ungrouped none route first: it is cheap, and no
selection on either effect can inflate it. Do the groups (both vs *_only)
only if they go in the paper (§13.6).
| Run | The alternative it tests | Knob | Read it against | Folder (under results/<EPOCHS_ROOT_FILE>/) |
|---|---|---|---|---|
| Baseline | – (this is the result) | ELECTRODE_DEFINITION=none |
its own ceilings | cross_decoding_lpfc_sig_none/ |
| RT-matched | Shared latency. Incongruent and switch trials are both ~200 ms slower, so late stimulus-locked windows can separate them by time-to-response alone. | RT_MATCH=rt |
the random run, not the baseline |
…_none_rtmatch10/ |
| RT control | Matching keeps about half the trials; a weaker transfer could just be less data. | RT_MATCH=random |
the rt run |
…_none_rtrandom10/ |
| Response-locked | The same question from the other side: align every trial to its response. | CONDITIONS=response_main_effect_conditions + a Response_… epochs file |
where in the epoch the transfer sits | cross_decoding_lpfc_sig_none/, under the Response file's folder instead |
| Pattern only | Shared gain. Hard trials raise HG on most electrodes at once; a decoder can learn "activity is up". | ACTIVITY_CONTROL=remove_mean |
the baseline, by share kept | …_none_remove_mean/ |
| Mean only | The uniform part alone. | ACTIVITY_CONTROL=mean_only |
the baseline, by share kept | …_none_mean_only/ |
| Both controls | Latency and gain together (after the two above are read). | RT_MATCH=rt ACTIVITY_CONTROL=remove_mean (and random) |
each other | …_none_rtmatch10_remove_mean/ |
| Positive controls | A broken pipeline. If a control kills the transfer, that only means something if the pipeline can carry a code across trial populations at all. | bash submit_task_transfer_dcc.sh |
T1/T3 share kept beside A4's | task_transfer_lpfc_sig_w64s16/ |
| Seeds | Differences between runs are only the random trial pairing of the pseudopopulation. | SEED=1, SEED=2 (+ SAVE_DIR) |
the spread of share kept | …_none_seed<n>/ (set by you) |
Each A4 folder has a <CONDITIONS>/ subfolder (stimulus_main_effect_conditions/,
or response_main_effect_conditions/ for the response-locked run); the task
transfer's is pooled_design_conditions/. SEED is not in the folder name, so
seed runs need their own SAVE_DIR (§13.2). Every other row gets its own folder
automatically.
REPO=/hpc/home/$USER/coganlab/$USER/GlobalLocal
cd $REPO && git pull # RT matching / ACTIVITY_CONTROL are from 2026-09-30
cd dcc_scripts/decoding
export EPOCHS_ROOT_FILE=Stimulus_-1.0to1.5sec_decFactor_8_outliers_10_drop_and_nan_thresh_perc_5.0_70.0-150.0_Hz_padLength_1.5s_filterbank_hilbert_stat_func_ttest_zmax_20
RESP_FILE=Response_-1.0to1.5sec_decFactor_8_outliers_10_drop_and_nan_thresh_perc_5.0_70.0-150.0_Hz_padLength_1.5s_filterbank_hilbert_stat_func_ttest_zmax_20
A4=submit_stability_flexibility_cross_decoding_dcc.sh
# 1. baseline (the 2026-09-29 run; rerun it if the decoding code changed since)
ELECTRODE_DEFINITION=none bash $A4
# 2. RT: the matched run and its trial-count control, same SEED
ELECTRODE_DEFINITION=none RT_MATCH=rt TEMPGEN_GROUPS= bash $A4
ELECTRODE_DEFINITION=none RT_MATCH=random TEMPGEN_GROUPS= bash $A4
# 3. response-locked (check that $RESP_FILE exists first)
ELECTRODE_DEFINITION=none CONDITIONS=response_main_effect_conditions \
EPOCHS_ROOT_FILE=$RESP_FILE bash $A4
# 4. overall activity
ELECTRODE_DEFINITION=none ACTIVITY_CONTROL=remove_mean TEMPGEN_GROUPS= bash $A4
ELECTRODE_DEFINITION=none ACTIVITY_CONTROL=mean_only TEMPGEN_GROUPS= bash $A4
# 5. both controls together (worth it once 2 and 4 are read)
ELECTRODE_DEFINITION=none RT_MATCH=rt ACTIVITY_CONTROL=remove_mean TEMPGEN_GROUPS= bash $A4
ELECTRODE_DEFINITION=none RT_MATCH=random ACTIVITY_CONTROL=remove_mean TEMPGEN_GROUPS= bash $A4
# 6. positive controls, same electrodes and epochs file as the A4 runs
bash submit_task_transfer_dcc.sh
# 7. seeds: SEED is not in the folder name, so give each its own SAVE_DIR
for s in 1 2; do
ELECTRODE_DEFINITION=none SEED=$s TEMPGEN_GROUPS= \
SAVE_DIR=$PWD/results/$EPOCHS_ROOT_FILE/cross_decoding_lpfc_sig_none_seed$s/stimulus_main_effect_conditions \
bash $A4
done- Set control knobs per command, never with
export. Both launchers submit with--export=ALL, so an exportedRT_MATCHorACTIVITY_CONTROLwould ride along into every later job in that shell, including the task-transfer controls.EPOCHS_ROOT_FILEis the one variable meant to be exported. TEMPGEN_GROUPS=(empty) skips temporal generalization, which on thenoneroute otherwise runs onalland costs 3 × 37² decodes. The controls do not need it; the baseline and response-locked runs keep it.- Check the echo lines before walking away:
definition=none, andrt_match=/activity_control=as intended. - Cost: one group, so each job is the cheapest A4 run (§7.4). RT-matched runs decode about half the trials and are faster still.
- RT matching is refused on synthetic data and stops the job if a subject would
lose every trial (lower
RT_MATCH_BINS, §6.4).
summary.txt in each folder has the numbers for that run. To see the runs side by
side, point this at the save directories (it reads cross_decoding.json and, if
present, accuracy_traces.npz):
import json, os
import numpy as np
RUNS = { # label -> save directory (the folder holding cross_decoding.json)
'baseline': 'results/<EPOCHS_ROOT_FILE>/cross_decoding_lpfc_sig_none/stimulus_main_effect_conditions',
'rt': 'results/<EPOCHS_ROOT_FILE>/cross_decoding_lpfc_sig_none_rtmatch10/stimulus_main_effect_conditions',
'random': 'results/<EPOCHS_ROOT_FILE>/cross_decoding_lpfc_sig_none_rtrandom10/stimulus_main_effect_conditions',
'remove_mean': 'results/<EPOCHS_ROOT_FILE>/cross_decoding_lpfc_sig_none_remove_mean/stimulus_main_effect_conditions',
'mean_only': 'results/<EPOCHS_ROOT_FILE>/cross_decoding_lpfc_sig_none_mean_only/stimulus_main_effect_conditions',
}
WINDOW, STEP, SRATE, FIRST = 64, 16, 256, -1.0 # WINDOW_SIZE, STEP_SIZE, SAMPLING_RATE, FIRST_TIME_POINT
print(f"{'run':10} {'group':17} {'direction':13} {'n':>4} {'trials':>6} {'sig':>3} {'from':>6} "
f"{'pre':>3} {'peak':>5} {'post':>5} {'kept':>5} {'below':>5}")
for label, folder in RUNS.items():
saved = json.load(open(os.path.join(folder, 'cross_decoding.json')))
runs = saved['label_transfer']
counts = saved.get('pseudo_trials') # absent in older runs
trials = sum(counts['per_condition'].values()) if counts else ''
npz = os.path.join(folder, 'accuracy_traces.npz')
traces = np.load(npz) if os.path.exists(npz) else None
for group, directions in runs.items():
for direction, r in directions.items():
sig = np.asarray(r['significant_windows'], bool)
t = FIRST + (np.arange(sig.size) * STEP + WINDOW / 2) / SRATE # window centres
post = ''
if traces is not None: # mean over windows fully after 0 s
acc = traces[f'labeltransfer_{group}_{direction}_true'].mean(axis=1)
post = f"{acc[t >= WINDOW / 2 / SRATE].mean():.3f}"
kept = r.get('retained')
print(f"{label:10} {group:17} {direction:13} {r['n_channels']:4d} {trials:>6} {sig.sum():3d} "
f"{(f'{t[sig][0]:+.2f}' if sig.any() else '-'):>6} "
f"{int(sig[t <= -WINDOW / 2 / SRATE].sum()):3d} {r['peak_accuracy']:5.3f} "
f"{post:>5} {('' if kept is None else f'{kept:.0%}'):>5} "
f"{r.get('n_below_ceiling', ''):>5}")| Column | Meaning |
|---|---|
n |
electrodes decoded (for mean_only, the features are one per subject; the summary says how many) |
trials |
pseudo-trials decoded, summed over the conditions (blank for runs from before the count was recorded, Changes to A4; read their slurm log) |
sig, from |
significant windows (cluster-corrected against the refit shuffle) and the centre of the first one |
pre |
significant windows centred at or before −0.125 s, i.e. entirely before the stimulus: the artifact meter. On the response-locked run these are pre-response windows and are not artifacts. |
peak |
best window's accuracy |
post |
mean accuracy over the windows entirely after 0 s. Use this, not mean_accuracy (which averages in the baseline second). |
kept |
share of the ceiling's above-chance accuracy the transfer keeps (transfers only) |
below |
windows where the ceiling beats the transfer |
The response-locked run has its own time axis (0 = response); keep it in a
separate RUNS dict or read its column from as time-to-response.
First, the prerequisites, in every run: both ceilings (stab_to_stab,
flex_to_flex) have significant windows, and pre is 0. A run that fails either
cannot say anything about transfer (§8.2 steps 2 and 6).
RT matching. Check the matching worked, then compare rt with random.
rt_match_congruency_task_sequence_summary.csvin thertfolder: themean_aftercolumn should be near 0 withp_after> .05 for both factors (the behavioural data give +2 ms and +1 ms at 10 bins). If not, rerun with more bins (RT_MATCH_BINS=15). Therandomfolder's CSV should still show the full RT costs; that is what makes it the control.- Compare the two runs' transfers:
rt vs random |
Reading |
|---|---|
similar share kept and similar sig |
Not a shared latency. The transfer survives when the compared trials are equally fast. |
rt clearly lower, still above chance |
Partly latency. Report the share that survives. |
rt at chance, random transfers |
The late transfer was the RT difference. The codes are separable once RT is equated. |
random loses the transfer too |
Inconclusive: halving the trials cost the power. Try RT_MATCH_BINS=5 (keeps more, matches less tightly) or more electrodes. |
"Similar" has no formal test: judge it against the seed spread (§13.1, the
Seeds row). A difference between rt and random smaller than the difference
between two seeds of the same run is not a difference.
Response-locked. Descriptive; read it together with the RT result.
| Where the response-locked transfer sits | Reading |
|---|---|
| only early in the epoch (far before the response), fading towards it | the latency signature: at a fixed time before the response, slow trials are further past their stimulus |
| around the response itself, with both ceilings there | something shared in the response period that is not a timing shift, e.g. effort or response caution |
| nowhere | the shared component is locked to the stimulus, not to the response |
The electrodes come from the response file's own significance list, so they are not exactly the baseline's 171 (the log prints the count). Windows after the response include feedback and the next trial's preparation.
Overall activity. Compare each run's kept with the baseline's, per
direction (the ceilings change too, so do not compare raw accuracy):
remove_mean |
mean_only |
Reading |
|---|---|---|
| keeps a similar share | little or no transfer | A shared pattern, not a uniform rise in activity |
| transfer gone, ceilings still above chance | transfers | A shared gain: both effects raise overall HG; their specific patterns differ |
| reduced but present | transfers | Both. Report the share under each. |
| ceilings gone too | – | the contrasts themselves are mostly overall activity; this control cannot speak to the transfer |
remove_mean removes only a shift shared by all of a subject's decoded
electrodes; a rise on a subset of them still reads as "pattern"
(Overall-activity control).
Positive controls. T1 (task learned on congruent trials, tested on incongruent ones) should keep most of its ceiling; if it does, a transfer that a control kills was killed by the control, not by the pipeline. Put T3's share (congruency across task, at congruency's own effect size) next to A4's (§9).
Seeds. The spread of kept across SEED=0,1,2 is the noise floor for every
comparison above. The grouped runs of 2026-09-29 suggest it is not small: both
switch → congruency kept 55% in the anova run and 34% in the csv run, with the
same group sizes and probably the same electrodes (the csv route loads all 398
electrodes, which changes how trials are paired into pseudo-trials).
RT (rt vs random) |
Activity (remove_mean) |
What the transfer supports |
|---|---|---|
| survives | survives | Congruency and switch type share part of their lPFC code, not explained by response time or by a uniform rise in activity. The strongest claim. Confirm with run 5 (both controls at once). |
| survives | gone, mean_only transfers |
They share a uniform activity increase (a common difficulty/effort signal) that is not a timing artifact; their specific patterns are separable. |
| gone | – | The late transfer is the RT difference; once trials are equally fast the codes are separable. |
| partial | partial | Report the share kept under each control; do not round it to yes or no. |
Whatever the row, also report: the early separability (in the 2026-10-01 baseline, congruency is decodable from the window centred at +0.12 s and switch type from +0.31 s, but nothing transfers until the window covering 0.5–0.75 s; the 2026-09-29 run had the onset order the other way round, so do not read anything into which code appears first), the direction asymmetry (congruency → switch keeps more than switch → congruency), and the pre-stimulus windows.
Only if the paper makes a claim about the groups. Two extra requirements on top of §13.4:
- Held-out selection. On the csv and unsplit
anovaroutes the groups were selected on the trials they are decoded on, which inflates each group's own ceilings and can inflate the transfer too (electrodes picked because both effects push HG the same way favour a shared axis). RunELECTRODE_SELECTION_SPLIT=true bash $A4(folder…_anova_condition_none_split0.3s0/; addRT_MATCH/ACTIVITY_CONTROLas above). Split and RT matching together leave about a third of the trials. - The pre-stimulus meter. In the 2026-09-29 runs,
bothand the csv run's 398-electrodeallhad significant pre-stimulus windows; the 171-electrodeallhad none. Cross-decoding controls §6 has the diagnosis. A group withpre> 0 cannot be reported.
Compare groups by kept, never by raw accuracy, and against the seed spread.
The block to fill in per transfer direction (numbers so far: the 2026-10-01 runs, 171 task-significant lPFC electrodes, pooled 2×2, 250 ms windows; window times are centres; §13.8 has every run):
electrodes lpfc, task-significant (ELECTRODES=sig), 171 electrodes / 21 subjects
condition set stimulus_main_effect_conditions (congruency x switch type, blocks pooled), 9181 trials
decoding PCA(80%) -> LDA (equal priors), 5-fold x 10 repeats; 250 ms windows, 62.5 ms steps;
cluster-corrected against a refit label-shuffle null (500 permutations)
ceilings stab_to_stab: sig from +0.12 s, peak 0.757 @ 0.88 s
flex_to_flex: sig from +0.31 s, peak 0.764 @ 1.19 s
transfer stab_to_flex: sig from +0.62 s, peak 0.670 @ 1.00 s, keeps 47%, below ceiling 19 windows
flex_to_stab: sig from +0.62 s, peak 0.608 @ 1.06 s, keeps 26%, below ceiling 28 windows
pre-stimulus none
pseudo-trials per cell: __ (baseline), __ (rt and random), from each run's slurm log
(`subsampling to N trials`); estimated from combinedData.csv at ~45 and ~20
RT matching residual RT i-c +2 ms (p .45), s-r +2 ms (p .39); 4408 of 9181 trials kept (48%)
stab_to_flex keeps 43% (rt) vs 70% (random); flex_to_stab 12% (rt) vs 39% (random)
rt transfer only at +0.62..+1.00 s; both half-trial runs have pre-stimulus windows
response-locked not run
activity control keeps 81% / 77% (remove_mean); mean_only not run
both controls uninterpretable: congruency ceiling 0 windows (rt + remove_mean), 5 (random + remove_mean)
positive controls not run
seeds not run (baseline vs random, which differ only in trial count, differ by 23 / 13 points)
region control occipital, 54 electrodes: keeps 54% / 23%; its ceilings have pre-stimulus windows
Done (2026-10-01): the baseline, RT matching with its random control,
remove_mean, both controls together, the occipital baseline with its RT pair,
and the held-out-selection group runs with their RT and activity variants.
§13.8 has the results.
To run: mean_only, the response-locked run, the positive controls, seeds
(baseline, rt and random at least), and lPFC subsampled to occipital's
electrode count (§13.8.4).
The 2026-09-29 numbers that used to fill this block (ceilings peaking at 0.62 and 0.66, transfers keeping 64% and 39%) came from before the NaN-trial change (Changes to A4) and are not comparable with these.
Two things the controls do not fix, to state in the methods: the cluster tests' samples are CV repeats of one pseudopopulation, not subjects (§8.3), and there is no leave-one-subject-out for A4 (§10).
Every run here postdates the NaN-trial change (Changes to A4)
except the unsplit anova group run
(cross_decoding_lpfc_window_0.0to1.5s_sig_anova_condition_none/). That one has
no rt_match or activity_control line in its summary.txt, and its all
group reproduces the 2026-09-29 baseline. Leave it out, or rerun it.
DLPFC (2026-10-07). cross_decoding_dlpfc_sig_none/ (121 task-significant
electrodes, no controls): ceilings 0.755 / 0.735, transfer from +0.69 s keeping
39 % and 17 % of the ceilings, close to lPFC. Unlike lPFC, congruency is
decodable before the stimulus (−1.0 to −0.5 s, cluster p = 0.002), probably
from block proportion leaking into the pooled main-effect set. Details and the
control runs still needed: dlpfc_results.md §4, §8.4.
Short version. Congruency and switch type are each strongly decodable from the 171 task-significant lPFC electrodes, and they share a minority of their code. The congruency decoder recovers about half of switch type's decodable information and the switch-type decoder about a quarter of congruency's. The shared part appears only from ~0.5 s and is not a uniform rise in activity. Under RT matching the shared part shrinks and is left only at 0.5–1.1 s. That is before most responses (median correct RT 1.17 s), but these half-trial runs sit at the noise floor. Occipital cortex shows similar shares at full trials, so nothing yet makes the shared part specific to lPFC. The main-effect groups do not behave as a shared code predicts, and should not be reported.
sig = significant windows of 37. clusters = their window centres in s.
pre = windows centred at or before −0.125 s. kept and below as in §13.3.
Mean accuracy is left out because it averages in the baseline second (§8.3).
| Run (trials) | Decode | sig | clusters | pre | peak | kept | below |
|---|---|---|---|---|---|---|---|
| baseline (9181) | stab_to_stab | 21 | +0.12..+1.38 | 0 | 0.757 | ||
| flex_to_flex | 18 | +0.31..+1.38 | 0 | 0.764 | |||
| stab_to_flex | 13 | +0.62..+1.38 | 0 | 0.670 | 47% | 19 | |
| flex_to_stab | 13 | +0.62..+1.38 | 0 | 0.608 | 26% | 28 | |
| remove_mean (9181) | stab_to_stab | 15 | +0.50..+1.38 | 0 | 0.629 | ||
| flex_to_flex | 17 | +0.38..+1.38 | 0 | 0.646 | |||
| stab_to_flex | 15 | +0.50..+1.38 | 0 | 0.636 | 81% | 4 | |
| flex_to_stab | 14 | +0.56..+1.38 | 0 | 0.598 | 77% | 6 | |
| rt (4408) | stab_to_stab | 20 | −0.75..−0.44, +0.12..+0.94 | 6 | 0.691 | ||
| flex_to_flex | 11 | +0.38..+1.00 | 0 | 0.725 | |||
| stab_to_flex | 7 | +0.62..+1.00 | 0 | 0.623 | 43% | 6 | |
| flex_to_stab | 5 | +0.69..+0.94 | 0 | 0.641 | 12% | 14 | |
| random (4408) | stab_to_stab | 18 | +0.31..+1.38 | 0 | 0.761 | ||
| flex_to_flex | 12 | +0.69..+1.38 | 0 | 0.718 | |||
| stab_to_flex | 17 | −0.56..−0.25, +0.75..+1.38 | 6 | 0.721 | 70% | 14 | |
| flex_to_stab | 13 | −0.56..−0.38, +0.81..+1.31 | 4 | 0.692 | 39% | 11 | |
| rt + remove_mean (4408) | stab_to_stab | 0 | – | 0 | 0.653 | ||
| flex_to_flex | 6 | +0.38..+0.69 | 0 | 0.712 | |||
| stab_to_flex | 7 | +0.62..+1.00 | 0 | 0.599 | 18% | 6 | |
| flex_to_stab | 4 | +0.69..+0.88 | 0 | 0.611 | n/a | 8 | |
| random + remove_mean (4408) | stab_to_stab | 5 | +0.81..+1.06 | 0 | 0.611 | ||
| flex_to_flex | 4 | +0.69..+0.88 | 0 | 0.597 | |||
| stab_to_flex | 7 | +0.94..+1.31 | 0 | 0.592 | 23% | 0 | |
| flex_to_stab | 12 | −0.56..−0.38, +0.94..+1.38 | 4 | 0.641 | 105% | 5 |
RT matching worked: the per-subject RT costs went from +160 ms (i − c) and +195 ms (s − r) to +2 ms and +2 ms (p = .45 and .39, n = 24). The random control kept them (+156 ms and +208 ms).
| Run (trials) | Decode | sig | clusters | pre | peak | kept | below |
|---|---|---|---|---|---|---|---|
| baseline (9181) | stab_to_stab | 26 | −0.19..+1.38 | 2 | 0.592 | ||
| flex_to_flex | 23 | −0.88..−0.56, −0.12..+0.12, +0.69..+1.38 | 7 | 0.630 | |||
| stab_to_flex | 11 | +0.31..+0.50, +1.00..+1.38 | 0 | 0.593 | 54% | 15 | |
| flex_to_stab | 12 | −0.12..+0.19, +1.06..+1.38 | 1 | 0.550 | 23% | 32 | |
| rt (4408) | stab_to_stab | 14 | −0.38..+0.19, +0.31..+0.50 | 5 | 0.593 | ||
| flex_to_flex | 0 | – | 0 | 0.581 | |||
| stab_to_flex | 9 | −0.38..−0.19, +0.94..+1.19 | 4 | 0.574 | n/a | 0 | |
| flex_to_stab | 14 | −0.06..+0.25, +0.38..+0.56, +0.94..+1.12 | 0 | 0.571 | 74% | 0 | |
| random (4408) | stab_to_stab | 16 | +0.12..+1.06 | 0 | 0.652 | ||
| flex_to_flex | 15 | −0.75..−0.50, +0.38..+0.62, +1.12..+1.38 | 5 | 0.633 | |||
| stab_to_flex | 8 | +0.44..+0.88 | 0 | 0.575 | 12% | 7 | |
| flex_to_stab | 6 | +0.88..+1.19 | 0 | 0.591 | 40% | 24 |
Baseline: partial, asymmetric, late. Both ceilings are significant with no
pre-stimulus windows. Both transfers are above chance from the window centred at
+0.62 s (covering 0.5–0.75 s) to the end of the epoch, and both sit below their
ceilings over most of it (19 and 28 windows). Per §8.2 step 3 this is partial
overlap, not one shared axis. For ~0.3–0.5 s the two codes are both present but
do not transfer. Do not read the asymmetry (47% against 26%) as geometry (§8.2
step 4). The two ceilings peak at the same accuracy. Part of the gap is the
metric: kept averages over the ceiling's significant windows, and the
congruency ceiling starts earlier (+0.12 against +0.31 s). So flex_to_stab's
share includes more early windows, where nothing transfers in either direction.
Overall activity: not a uniform gain. With each subject's mean removed, the
ceilings fall (peaks 0.76 → 0.63–0.65) but the transfers barely move (13 → 15
and 13 → 14 windows; peaks 0.67 → 0.64 and 0.61 → 0.60). The share kept rises to
~80%. So the transfer is not "both effects raise HG on every electrode". The
part that remove_mean took out was mostly specific to each contrast. Two limits
apply. mean_only has not been run, so the second column of the §13.4 table is
empty. And since the NaN-trial change, a subject's electrodes in one pseudo-trial
come from different trials, so the removed mean is a condition-level shift, not
shared trial-to-trial fluctuation.
RT: smaller and earlier, but at the noise floor. Against its own random
control, RT matching cuts the transfer from 17 to 7 windows (70% → 43% kept) and
from 13 to 5 windows (39% → 12%). What survives is confined to the windows
centred at +0.62..+1.00 s, in both directions. The part after ~1.0 s goes. With
correct-trial RTs at a median of 1.17 s (35% of trials answered by 1.0 s,
combinedData.csv), the late part that RT matching removes overlaps the
response on most trials. The part that survives sits mostly before it. On the
§13.4 table this is "rt clearly lower, still above chance": partly
latency. Three things keep it from being more than that:
- The half-trial runs produce artifacts the size of the effect. The
rtrun's congruency ceiling has a 6-window pre-stimulus cluster. Bothrandomtransfers have one too (6 and 4 windows). The survivingrttransfers are 7 and 5 windows. The full-trial baseline has none. Neither run passes the §13.4 prerequisite (pre= 0) cleanly. keptmoves ~20 points between runs that should agree. The baseline andrandomdiffer only in trial count, yet keep 47% against 70% and 26% against 39%. Thert−randomgaps (27 points each) are about that size. Without seeds there is no way to say which gap is real (§13.4, "Seeds").rtflex_to_stab's 12% includes artifact windows.keptaverages over the ceiling's significant windows, and 6 of this ceiling's 20 are pre-stimulus. Recompute it over post-stimulus windows fromaccuracy_traces.npzbefore quoting it.
Both controls together: uninterpretable. Under rt + remove_mean the
congruency ceiling has no significant window, so flex_to_stab cannot be read.
stab_to_flex (7 windows, again at +0.62..+1.00 s, 18%) is measured against a
6-window ceiling. The random + remove_mean control loses its ceilings too (5
and 4 windows). That is the §13.4 row "random loses the transfer too": halving
the trials and removing the mean together cost the power. The power that is left
is set by one subject. The decoder gets as many pseudo-trials per cell as the
subject with the fewest trials. Re-running the matching on combinedData.csv
puts that at ~20 per cell in the half-trial runs (D0116, whose large RT costs
leave it 20 of ~83 trials per cell; the next lowest is 31), against ~45 at full
trials. That is about 32 training pseudo-trials per class per fold. The job's
own numbers are in each run's slurm log, and in summary.txt once rerun
(Changes to A4). Several within-contrast
decodes in these runs also average well below 0.5 over the epoch (mean acc
0.47–0.48 in summary.txt), the sign of a single small pseudopopulation's
accuracy wandering for long stretches. Read nothing from them except "no
power".
Region: the shares are not specific to lPFC. At full trials, occipital cortex keeps 54% and 23% of its ceilings, against lPFC's 47% and 26%. Two differences favour lPFC, though neither is a test:
- Timing. At full trials, occipital's transfers sit at +1.00 s and later,
plus early windows (+0.31..+0.50 s for congruency → switch, −0.12..+0.19 s for
the reverse), with none centred between +0.56 and +0.94 s. The late window is
exactly the part of the lPFC transfer that RT matching removes. (Occipital's
half-trial
randomrun does transfer congruency → switch at +0.44..+0.88 s, keeping 12%.) - Data quality. Occipital's own ceilings have pre-stimulus windows (2 and 7). Under RT matching it loses the switch-type ceiling entirely, and its RT pair is mostly artifact windows.
With 54 electrodes against 171, occipital also has less power to show a 0.5–1.0 s transfer. Comparing them needs lPFC subsampled to 54 electrodes.
Groups: drop them (§13.6). With held-out selection (_split0.3s0, no
controls) every group transfers, and congruency_only keeps more of its
ceiling than both (108% against 65% for congruency → switch). That is the
opposite of the shared-code prediction. Groups with pre > 0 cannot be
reported (§13.6), and that rules out the 70% decode half's unselected all
(ceilings 7 and 5 pre-stimulus windows), both (switch ceiling 6) and
switch_type_only (transfers 4 and 5). The groups are also unstable. The ANOVA
refit on each run's selection trials gives both 9, 8, 5 and 7 electrodes in the
four split runs. With RT matching the groups' ceilings are weak or carry
pre-stimulus windows (both congruency ceiling: 0 windows in both RT-matched
runs).
Supported now, at full trials:
Congruency and switch type are each decodable from the same task-significant lPFC electrodes. A decoder trained on one predicts the other above chance only from ~0.5 s after the stimulus, and recovers about half (congruency → switch) or a quarter (switch → congruency) of the other's decodable information. The shared part is not a uniform increase in activity across a participant's electrodes.
That is overlapping population, largely distinct codes, about the base effects (not LWPC/LWPS). Not supported yet:
- that the shared part survives RT matching;
- that it is specific to lPFC;
- anything about the main-effect groups.
The runs that would settle these, cheapest first:
- Seeds.
SEED=1,2(more if affordable) for the baseline,rtandrandom, with their ownSAVE_DIR(§13.2 step 7). Thert−randomgap has to exceed the seed spread. Also recomputekeptover post-stimulus windows only, and the mean accuracy over 0.5–1.0 s, fromaccuracy_traces.npz. - lPFC at occipital's size. Decode 54 randomly drawn lPFC electrodes
(several draws), and compare
keptand the 0.5–1.0 s windows with occipital's. This needs a channel-subsampling option the job does not have yet. - Positive controls.
bash submit_task_transfer_dcc.sh: T1 has to keep most of its ceiling for a transfer that a control removes to mean anything. mean_onlyand the response-locked run (§13.2 steps 3–4).- For a main-text claim only: an estimate across subjects, which A4 does not have (§10).
Where this sits in the paper:
analysis_plans.md › Closing figure plan,
"Congruency ↔ switch cross-decoding with task positive controls". Manuscript
methods and a results paragraph:
methods.md › A4.
analysis_guide.md§17 — A4's design and the reasons behind it; §17.5 — the runbook for the main-effect populations and task controls- Cross-decoding controls — diagnosing a transfer that did not work, and the report block to print with every transfer
- RT matching and Overall-activity control — the methods behind §6.4, §6.5 and §13
- N3b block transfer — block transfer, the third mode of this job
- Decoding job — the ordinary decoding job, run in the same populations
analysis_plans.md› Closing figure plan — where the cross-decoding results sit in the paperanalysis_plans.md› Concurrent-regulation plan §4 — the plan this implements
N3b: block-transfer cross-decoding
Status: implemented and validated on synthetic data; not yet run on real data.
Spec: analysis_plans.md › Concurrent-regulation plan §4. Controls and decision rules: Cross-decoding controls.
Run it: cd dcc_scripts/decoding && bash submit_block_transfer_dcc.sh (Part 3).
N3b trains a classifier in one kind of block and tests it in another:
| Design | Decoded contrast | Train → test | Role |
|---|---|---|---|
| X1 | congruency | 25%-incongruent blocks → 75%-incongruent blocks | primary (LWPC) |
| X2 | switch type | 25%-switch blocks → 75%-switch blocks | primary (LWPS) |
| X3 | congruency | 25%-switch blocks → 75%-switch blocks | positive control for X1 |
| X2b | switch type | 25%-incongruent blocks → 75%-incongruent blocks | reciprocal control for X2 |
Every design runs in both directions.
The existing decoder (Decoder.cv_cm_jim_window_shuffle) always cuts its train and test sets out of one pool of trials with random folds. It has no way to say "train on these trials, test on those". N3b needs exactly that.
Goals:
- the smallest change that reuses the existing pipeline;
- run on all significant electrodes of an ROI (LPFC by default), with no electrode groups;
- explain the design choices well enough that the code can be maintained by someone who didn't write it.
This doc has three parts:
- Part 1: the concepts, as answers to the design questions;
- Part 2: what was built;
- Part 3: how to run it and read the output.
The same change also fixed the A4 padding bug (see 1.1 and "Changes to A4" at the end), and it removed dead code.
From combinedData.csv: each subject has 4 physical blocks of 112 trials, one block per type.
| Block | Incongruent | Switch | Accurate incongruent / congruent trials per subject (mean) |
|---|---|---|---|
| A | 75% | 25% | 71 / 26 |
| B | 75% | 75% | 66 / 24 |
| C | 25% | 25% | 22 / 79 |
| D | 25% | 75% | 20 / 73 |
Inside a block, the classes run about 3:1, and the majority class flips between the 25% and 75% levels. Most of the design choices below follow from that.
- Stratifying a split: dealing the trials into folds so that every fold is a small copy of the whole set. If 25% of the trials are incongruent, every fold is about 25% incongruent. It never adds or removes a trial. It only stops a random split from, say, putting most of the rare trials in one fold.
stratify_labels(incv_cm_jim_window_shuffle) is the label the folds are kept proportional on. The default is the training labels. A4 passesstrata, which is the index of the condition each trial came from (0–15 for the 16 cells). So every fold has the same mix of all 16 cells, and therefore of congruency, switch type and both proportions at once. That matters in label transfer, which scores on switch type: folds balanced only on congruency could come out lopsided on switch type.- Balancing is a different thing. It changes the data: you subsample so that groups have equal counts.
- A4 subsamples training data instead of using mixup. Pure-padding rows are removed up front; within each fold, incomplete training pseudo-trials are removed and the remaining classes are subsampled to equal sizes.
- Partial test rows are still filled with independent noise, as in the normal decoder. This preserves test observations without synthesizing training signal.
- N3b needs both: balancing, then stratified folds.
- Balancing is needed because of the 3:1 ratio inside a block. LDA's default priors are the training class frequencies. For a weak effect, a 3:1 prior pushes nearly every prediction to the majority class. In the simple 1-D case with d′ = 0.5, balanced accuracy is 0.51 instead of 0.60, even though the signal is there. In X1 the majority also flips between training and testing.
- Use a design-specific pooled 2×2 condition set and balance its four contrast × transfer-level cells. X1 uses
stimulus_lwpc_conditions, X2 usesstimulus_lwps_conditions, X3 usesstimulus_congruency_by_switch_proportion_conditions, and X2b usesstimulus_switch_type_by_incongruent_proportion_conditions. The irrelevant block factor is pooled rather than split into 16 cells, retaining more trials. - This is the higher-trial-count version of N3b. The four-group version keeps about 42 trials per class per level per subject rather than about 40 when balancing all eight full-factor cells.
- X1 and X3 use their respective pooled condition definitions. They cover the same physical trial population while grouping it by different transfer factors.
- Tradeoff for X3: pooling incongruent proportion means congruency can correlate with the physical A/C or B/D block mix. Treat X3 as a positive control with that caveat; the requested gain in retained trials comes from not balancing the nuisance factor's eight full-factor cells.
- Do not additionally balance on switch type in X1 (or on congruency in X2). It is 25/75 inside a block by design, so doing so would cut the data in half. Folds are stratified on the four pooled condition cells.
- What gets dropped is only the extra majority trials. Each repeat draws a new random balanced subsample, so over 10 repeats almost every trial gets used.
- The real limit is the minority class (about 20 incongruent trials per subject per 25%-incongruent block), and no method removes it.
- The per-channel minimum in
subsample_to_min_trials_per_condition(the "~22/class" inanalysis_plans.md› Simplification plan §1.2) belongs to the ordinary decoding job. A4 and N3b never call it.
- "Train on all the 25% trials, test on all the 75% trials" is valid for the transfer number on its own. The two sets share no trials, so there is no double-dipping.
- Folds are needed for three other reasons:
- The ceiling: a transfer accuracy only means something next to the within-block accuracy (Cross-decoding controls §2), and the within-block accuracy has to be cross-validated.
- Matching: if the transfer classifier trains on 100% of the 25% trials but the ceiling classifier trains on 80%, the two numbers aren't comparable.
- Repeats: you need a spread of values for the shuffle null and error bars.
- The design: cut folds only inside the training level. Each fold's classifier is scored twice: on its held-out 25% trials (the ceiling), and on all the 75% trials (the transfer). Same folds and same seed, so the ceiling and the transfer come from literally the same classifier.
- Which ceiling to compare against: compare transfer 25→75 with within-75, because both are scored on the same test trials.
- The congruency code can simply be weaker in 75%-incongruent blocks (that is the LWPC effect).
- In that case 25→75 drops while the axis is unchanged.
- A real change of axis makes both directions fall short of their test block's ceiling.
- A drop in only one direction means the training block's code is weaker, not that the axis changed.
- The job reports the full 2×2 table (train level × test level).
- Centering subtracts one vector per block level. It is the same vector for every trial in that level, congruent and incongruent alike. It moves the whole cloud, and cannot rotate the direction that separates C from I inside the block.
same axis, whole block shifted different axis
25%: C● ●I 25%: C● ●I
75%: C● ●I 75%: ●I
●C
centering lines them up -> transfers centering can't fix it -> still fails
- If C25/I25 and C75/I75 separate along different directions (the reconfiguration hypothesis), that survives centering and X1 still fails.
- What centering removes is the tonic shift of the whole block. Examples: all high-gamma higher in 75% blocks, or the pooled-baseline artifact (
analysis_plans.md› Simplification plan §1.4). A shift like that can make transfer fail even when the axis is identical, which is why an uncentered null can't be read on its own. - The tonic effect itself is not lost. It is simply a different claim, and X5 or the univariate block effect measures it.
- Within-level accuracies are unchanged by centering. The same vector is subtracted from both the training and the test trials. That makes them a built-in check.
- Pitfall: balance first, then center.
- The raw mean of a 3:1 block sits a quarter of the C–I distance away from the class midpoint, on the majority side.
- The majority flips between levels, so centering on raw means shifts the two levels half the C–I difference apart, exactly along the decoding axis.
- That fakes "X1 and X2 fail, X3 transfers", which is the pattern the analysis is looking for.
Read these centered and uncentered, in both directions:
| within (test level) | transfer, uncentered | transfer, centered | meaning |
|---|---|---|---|
| at chance | – | – | Can't interpret: there was nothing to transfer |
| above | ≈ within | ≈ within | Same code |
| above | < within | ≈ within | Same axis; the blocks differ by a tonic shift |
| above | < within in both directions | < within in both directions | Block context reorganizes the code. Only counts if X3, on the same trials, transfers |
Any pre-stimulus windows that come out significant are an artifact flag (Cross-decoding controls §6).
- The N3b job (
dcc_scripts/decoding/submit_block_transfer_dcc.sh) defaults toROI=lpfc ELECTRODES=sig. It runs no CSV, power-trace or ANOVA step and forms no electrode groups.ELECTRODES=allkeeps every electrode in the ROI. - What "sig" means: the electrode's high-gamma during the stimulus beats its pre-stimulus baseline (a per-electrode cluster test done at epoching). It's read from
sig_chans_<subject>_<EPOCHS_ROOT_FILE>.json. It says nothing about congruency or switching, so there is no double-dipping with N3b. EPOCHS_ROOT_FILEdecides which significance file is used. The A4 submit default has no_filterbank_hilbert; the ANOVA-label CSV folders were computed with it. Set it on purpose.- Gotcha in the existing A4 job: with its default
ELECTRODE_DEFINITION=csv, it loads every electrode in the ROI, significant or not (_build_roi_arraysinstability_flexibility_cross_decoding_dcc.py), even though output folders saysig.
- Follow one call path, not files.
submit_*.sh→sbatch_*.sh→run_*_dcc.py(turns environment variables intoargs) →*_dcc.py main(args)(loads data → ROI arrays → loops over designs)- →
cross_decoding.py(arrays → label vectors) →decoder.py(folds → fit → confusion matrices) - →
accuracy_stats.py(confusion matrices → accuracy → cluster test vs shuffle) → saving and plots
- Find the one step that differs. For N3b, that is which trials train and which test. Everything else is reused.
- Add the new behavior as an optional argument that is off by default. Every existing call stays identical, and the existing tests prove it.
- Test on synthetic data with a planted answer before touching real data. Plant the confounds you're worried about too (3:1 classes, block offsets).
Everything below the job function is the ordinary cross-decoding pipeline; the new pieces are marked new.
submit_block_transfer_dcc.sh new: ANALYSIS=block_transfer, ROI, ELECTRODES
└ sbatch_stability_flexibility_cross_decoding_dcc.sh
└ run_stability_flexibility_cross_decoding_dcc.py environment variables -> args
└ stability_flexibility_cross_decoding_dcc.main(args)
└ run_block_transfer_job(args) new: loads the ROI, loops all four designs x centering
├ _build_roi_arrays the ROI pseudopopulation (sig or all electrodes)
├ block_transfer.run_block_transfer new: balance -> center -> the 2x2
│ ├ cross_decoding.build_cross_decoding_arrays remove pure-padding rows
│ ├ cross_decoding.make_decoder PCA -> LDA with equal priors
│ └ Decoder.cv_cm_jim_window_shuffle(test_only=...) folds -> confusion matrices
├ _summarise accuracy + cluster test vs the shuffle null
└ summary.txt, block_transfer.json, block_transfer_traces.npz, figures
src/analysis/decoding/decoder.py:test_only. A new optional argument ofcv_cm_jim_window_shuffle: a True/False flag per trial. Flagged trials are never trained on. The folds are cut from the unflagged trials only, and every fold's classifier is scored on all the flagged trials. Withtest_only=None(the default) the function behaves exactly as before. The whole change is the few lines that picktrain_idxandtest_idxin the fold loop.src/analysis/decoding/cross_decoding.py:build_cross_decoding_arraysdrops pure-padding rows through_drop_padding_rows. Fold preparation subsamples incomplete training rows and balances the surviving classes; test gaps retain the independent-noise fill.make_decoderbuilds the Decoder with equal LDA priors (see 1.2). It passes them asclf_params, which also stopsieegprinting "No initial parameters" on every fit.synthetic_roi_labeled_arraysgainsblock_code,block_offsetanddesign_proportions, so tests can plant a block-specific code, a tonic block shift and 3:1 cells. Its default output is byte-identical to before.
src/analysis/decoding/block_transfer.py(new, about 150 lines, reads top to bottom):prepare: trials, labels, each trial's block level and balance group.balanced_subsample: equal trials from every group.center_levels: subtract each level's mean trial.run_block_transfer: the resample loop that produces the 2x2.
dcc_scripts/decoding/stability_flexibility_cross_decoding_dcc.py:run_block_transfer_joband its three small helpers (_summarise_block_transfer,_plot_block_transfer,_write_block_transfer_summary).main()hands off to it whenargs.analysis == 'block_transfer', so none of the A4 electrode-group code runs.dcc_scripts/decoding/run_stability_flexibility_cross_decoding_dcc.py:- reads
ANALYSIS(defaulta4, so existing submissions are unchanged); - gives block-transfer runs their own results folder;
- always puts synthetic runs under
results/synthetic_<code>/, so a dry run can no longer overwrite a real run's folder.
- reads
dcc_scripts/decoding/submit_block_transfer_dcc.sh(new): one job, readable on one screen.- Removed dead code:
decoder.py: a commented-out older copy ofcv_cm_jim_window_shuffle,fit_predict,cv_cm_return_scores,calculate_scores, and unused imports. Its comments no longer claim a StandardScaler; theieegpipeline is PCA → LDA.cross_decoding.py:CONTRASTS,resolve_contrastand their helpers. They were used only by one test, and they coded incongruent as 1 while the rest of the module codes it as 0.
tests/analysis/decoding/test_block_transfer.py:
- The decoder:
test_onlytrials are never trained on and are always the whole test set, and a transfer trains on exactly the folds of the matching within-level decode. - Balancing and centering (no
ieegneeded): the four design-specific balance groups, equal counts after balancing, and the balance-then-center order. The class midpoint lands at 0; centering the raw 3:1 trials would put it a quarter of the class difference off. - Planted answers on synthetic data:
- a block-invariant code transfers as well as it decodes;
- a block-specific code fails X1 in both directions but still passes X3;
- a tonic block offset breaks only uncentered transfer;
- within-level accuracies don't move with centering.
- End to end:
main()withanalysis='block_transfer'on synthetic data writes all its outputs.
tests/analysis/decoding/test_cross_decoding.py adds tests for the padding fix and for equal priors on a 3:1 class split.
To run them outside the cluster: pip install -e . pytest, then python -m pytest -o addopts="" tests/analysis/decoding -q.
- Synthetic dry run (runs anywhere):
ANALYSIS=block_transfer DATA_SOURCE=synthetic SYNTHETIC_CODE=block_specific N_REPEATS=10 WINDOW_SIZE=16 STEP_SIZE=8 python dcc_scripts/decoding/run_stability_flexibility_cross_decoding_dcc.py.- It takes about 20 minutes on a 4-core machine.
N_REPEATS=2finishes in a couple of minutes, but then nothing can reach significance, and the summary says so. - The planted answer is "X1 fails, X3 transfers". The default
SYNTHETIC_CODEplants a block-invariant code, so everything should transfer. - The results go under
results/synthetic_<code>/.
- It takes about 20 minutes on a 4-core machine.
- Real data on the DCC:
cd dcc_scripts/decoding bash submit_block_transfer_dcc.sh # lpfc, sig electrodes ROI=acc ELECTRODES=all bash submit_block_transfer_dcc.sh # another region, every electrode EPOCHS_ROOT_FILE=<root with the sig_chans you mean> bash submit_block_transfer_dcc.sh - Cost: 4 designs × 2 centerings × 4 cells × (true + shuffle) ×
N_REPEATSresamples ×N_SPLITSfolds × windows. Check the first real run's runtime before scaling up. - Check the log first. For each design it prints the real trials available per contrast × transfer-level cell, and how many of each are kept per resample. That is the go/no-go of the concurrent-regulation plan §4.4. If the kept number is in the low teens, expect a null and say so up front.
Written to results/<EPOCHS_ROOT_FILE>/block_transfer_<ROI>_<ELECTRODES>_w<W>s<S>/pooled_design_conditions/:
| File | Contents |
|---|---|
summary.txt |
Read this first. For every design and centering: the 2×2 table, the ceiling test, the go/no-go line, any artifact flag, and the reading guide |
block_transfer.json |
The same numbers per cell, plus the run's settings and the group sizes |
block_transfer_traces.npz |
Accuracy traces, windows × resamples. Keys look like X1_centered_25to75_true / ..._shuffle |
<design>_<centering>_<train>to<test>_<roi>_block_transfer.{pdf,png,eps} |
The transfer into a level, drawn against that level's own within-level accuracy (its ceiling) and the shuffle null. Bars mark windows where the transfer beats shuffle |
Each design gets a block like this one. It comes from the synthetic dry run above with SYNTHETIC_CODE=block_specific, where the planted answer is that congruency uses a different axis in each incongruent-proportion level:
X1_uncentered: congruency, trained in one incongruent_proportion level and tested in the other
balanced to 80 trials per contrast × transfer-level cell (available: {'c|inc25|sw25': 120, ...})
post-stimulus mean accuracy (significant windows vs shuffle, post/pre):
train | test 25% 75%
25% 0.870 (3/0) 0.513 (2/0)
75% 0.510 (0/0) 0.879 (3/0)
25% -> 75% vs within 75%: below that ceiling in 3 windows
75% -> 25% vs within 25%: below that ceiling in 3 windows
CEILING: both within-level decodes beat shuffle -> interpretable
In that run:
-
X1 transfer sits at chance in both directions, uncentered and centered (0.51–0.53 against ceilings of 0.87–0.88). That is the "reorganizes the code" row of the table in 1.6.
-
X2 and X3 transfer at their within-level accuracy (about 0.88 and 0.75).
-
The 25% → 75% cell is "significant" in 2 windows at 0.513. Resamples share trials, so tiny departures from the shuffle null can pass the cluster test. Judge a transfer against its ceiling, not against shuffle alone.
-
The table: rows are the level trained on, columns the level tested on. The diagonal is the within-level ceiling; off the diagonal is transfer. Each cell shows the mean accuracy after stimulus onset, then (significant post / pre windows against the shuffle null).
-
The "vs within" lines compare each transfer with the ceiling of the level it is tested on (see 1.4).
-
The CEILING line is the go/no-go (the first row of the table in 1.6).
-
An ARTIFACT FLAG line appears if any cell is significant before stimulus onset.
-
Read the pairs together: a design's uncentered and centered blocks, then X1 against X3.
- A4(0b) cross cells (congruency by switch proportion, switch type by incongruent proportion) have the same class-mix confound as unbalanced X3 (see 1.2). This predates the padding fix. For within-block numbers, use N3b's balanced within-level cells.
- Resamples aren't independent subjects, so cluster p-values are optimistic. X1-vs-X3 on the same trials is the load-bearing contrast.
- Follow-ups if transfer sits at chance: the PCA basis is fit on the training level (Cross-decoding controls §4.3). X4 and X5 aren't built: X4 needs a letter-identity condition set.
- Stale material:
src/analysis/decoding/cross_decoding_tutorial.ipynbanddocs/skeletons/a4_cross_decoding.pydescribe functions that no longer exist. - The same job runs the task-transfer controls:
ANALYSIS=task_transfer(submit_task_transfer_dcc.sh) swaps the block for a trial-level factor — task across congruency and switch type, congruency and switch type across task (Cross-decoding controls §3.5).
The padding fix (1.1) changes every A4 design, so A4 numbers from before this change aren't comparable with new runs.
build_cross_decoding_arraysdrops all-NaN padding rows. In each fold, cross-decoding subsamples incomplete training rows and equalizes the surviving class counts instead of applying mixup. Incomplete test rows remain and are filled with independent noise.run_cross_decodingnow uses equal LDA priors (make_decoder). Without the padding, the within-block decodes (A4(0)) have their real 3:1 class ratio, and training-frequency priors would lean toward the majority class.- NaN trials are dropped when the pseudopopulation is built (
_build_roi_arrays). The job now uses the ordinary decoder's builder: each electrode's NaN (outlier) trials are dropped, then every electrode is subsampled to the fewest clean trials of any electrode in that condition, so every pseudo-trial is complete. The only NaN left are whole padding rows where conditions differ in trial count, andbuild_cross_decoding_arraysdrops those. Before, subjects were NaN-padded and outlier trials stayed NaN. A training pseudo-trial was only usable if every subject's electrodes were clean in it, which with ~24 subjects left a handful of rows per condition. After RT matching that fell to one per class, and LDA failed ("The number of samples must be more than the number of classes"). A4 numbers from before this change aren't comparable with new runs. - The decoded pseudo-trials are recorded (
cd.pseudo_trial_counts).summary.txtgets aDecoded pseudo-trialsblock (per condition, per class, the total and the training rows per fold) andcross_decoding.jsonapseudo_trialsentry. Before this, the count was only in the slurm log (condition '<name>': subsampling to N trials). The decoding itself is unchanged.
Cross-decoding controls — diagnosing a transfer that didn't work
Companion to
analysis_plans.md › Concurrent-regulation plan
§4 and to analysis_guide.md §17. This document is the
troubleshooting protocol: what to run, in what order, when a cross-decode comes
back uninformative — and what each outcome licenses you to say.
It covers both shapes of cross-decode in this project:
- label transfer — two labellings of the same trials (A4: train congruency,
score switchType).
build_cross_decoding_arrays+labels_test=. - block transfer — one labelling, two disjoint trial populations (N3b X1,
X2, X2b, and X3). The implemented
test_onlypath cuts folds only from the training level and scores every fold on the other level; the diagnostics below apply to it identically, plus §6.
"It didn't work" is three different problems with three different fixes. Look at the accuracy trace against the refit shuffle null before doing anything else.
| Signature | What it looks like | Section |
|---|---|---|
| F1 — at chance | transfer ≈ shuffle null, everywhere | §4 |
| F2 — below chance | transfer reliably under the null | §5 |
| F3 — significant where it cannot be | above-chance cluster in the pre-stimulus window, or transfer > within-condition accuracy | §6 |
F3 is the one the existing A4 runs actually show (analysis_guide §17's standing caveat: the two cross panels carry clusters extending into and before the baseline, for current-trial congruency, which is diagnostically impossible). F1 is the one X1/X2 are most likely to produce. Do not debug them the same way.
A transfer accuracy is meaningless without the within-condition accuracy on the same trials, matched for n. This is the decoding version of the noise ceiling.
Always report the pair:
within-condition (train and test in the same block / same labelling)
transfer (train in one, test in the other)
both against their own refit shuffle nulls, both with n per class printed
Decision rule:
| within-condition | transfer | Reading |
|---|---|---|
| at chance | at chance | Uninformative. There was no signal to transfer. Not a result. Fix the signal or report that the analysis is not runnable. |
| well above chance | at chance | Interpretable null — the code does not generalize. This is the X1 result that means "block context reconfigures the congruency code." |
| well above chance | above chance but lower | Partial generalization; quantify the drop, do not binarize it. |
| at chance | above chance | Impossible. Go to §6 — something is leaking. |
Concretely for the primary designs: if within-block congruency decoding in the 25%-incongruent blocks sits at 0.57 against a 0.50 null, a null 25 → 75 transfer tells you nothing, and no positive control elsewhere in the brain repairs it. The control you need is one that runs in the same ROI, at the same trial count, in the same effect-size regime — that is X3 (§3.4).
Cheapest first. Each rules out a different failure and each is worth running before concluding anything about a real null.
cross_decoding.synthetic_roi_labeled_arrays(code="shared" | "orthogonal") plants
a known answer, and two tests already assert it:
test_shared_code_transfers_and_orthogonal_code_does_not— a planted shared axis transfers; a planted orthogonal axis does not, even though both are individually decodable (the orthogonal world is in fact the easier within-contrast decode, which is the point).test_shuffle_null_is_at_chance_for_a_real_cross_decode— the null is centred.
This validates the code path, not your data. Passing it means the transfer machinery works; it says nothing about whether the lPFC signal is strong enough. Extend it for block transfer: plant a block-invariant code (must transfer) and a block-specific code (must not).
Run the same condition, trained on a random half and tested on the other half,
routed through run_cross_decoding / the block-transfer splitter rather than
through ordinary CV. Transfer accuracy must match ordinary cross-validated
accuracy on those trials.
This is the sharpest cheap control, because it isolates the plumbing from the science: same trials, same signal, same classifier, only the code path differs. If a split-half transfer through the new splitter underperforms ordinary CV on the same data, the splitter (or the subsampling, or the stratification) is broken — stop and fix it before interpreting X1.
Train big-letter decoding on task = global, test on task = local. The
physical stimulus is identical and only attention differs, so visual cortex
should carry the big letter either way.
Caveat to write down and respect: on congruent trials the big and small letters are confounded, so this is a control for the code path on real data, not a claim about global-specific coding. Restrict to incongruent trials if you want it clean, and report the trial counts.
Decode congruency within one switch-proportion level and test in the other, holding incongruent proportion fixed (design X3).
This is the control that makes a null X1 publishable, because it holds everything constant except which block factor is being crossed: same ROI, same electrodes, same trial-count regime, same effect-size regime, same number of block transitions. The result you want:
congruency transfers across switch proportion but not across incongruent proportion.
That contrast is the finding. Its absence — congruency failing to transfer across both — means the failure is generic (SNR, block nonstationarity, or the pipeline), not specific to LWPC.
The positive controls for the congruency ↔ switch label transfer
(analysis_plans.md › Closing figure plan, "Congruency ↔ switch cross-decoding with task
positive controls"). They reuse the block-transfer 2×2 with a trial-level factor
in place of the block (ANALYSIS=task_transfer, submit_task_transfer_dcc.sh;
run recipe in analysis_guide.md §17.5):
| Design | Decoded | Train → test | What a transfer shows |
|---|---|---|---|
| T1 | task | congruent → incongruent | the pipeline carries a code across trial populations — the clean control |
| T2 | task | repeat → switch | same, but confounded (below) |
| T3 | congruency | global task → local task | the A4 contrast transfers at its own effect size |
| T4 | switch type | global task → local task | same for switch type, with T2's confound |
Each design balances its four class × level cells and runs uncentered and
centered, exactly like N3b, so the reading rules of §2 and
N3b block transfer §1.6 apply. summary.txt adds, per
transfer, the share of the ceiling's above-chance accuracy it keeps (over the
windows where the ceiling beats shuffle), and one line setting within-level task
accuracy (T1) against within-level congruency accuracy (T3).
What each can and cannot license:
- T1 is a code-path control, not an effect-size control. The frame colour
that cues the task is drawn with the stimulus (
src/task/mainTask.m:163), so a stimulus-locked task decoder partly decodes colour: large and partly visual. A T1 transfer rules out a broken pipeline; it does not show a congruency-sized code would survive. The plan's fix — subsample electrodes or trials until within-level task accuracy matches congruency's — is not built; the effect-size line says how far apart they are. - T3 is the matched control. It is a congruency decoder, so it lives in the effect-size regime of the A4 transfer. Its task shift (the frame colour again) is a tonic offset between the two levels, which centering removes.
- T2 and T4 carry a real confound. On a switch trial the previous task was the
other one. Leftover previous-task activity then agrees with the current task
on repeat trials and opposes it on switch trials (T2), and flips its relation to
switch type between the tasks (T4). Centering cannot remove it: it is a class ×
level interaction, not a level offset. A T2/T4 drop is expected even with one
code;
SYNTHETIC_CODE=carryoverplants it. - Pre-stimulus task windows are not automatically artifacts. The previous
task predicts the current one on repeat trials, so some task information can
exist before the cue.
summary.txtflags them separately from congruency / switch-type pre-stimulus windows, which remain artifacts (§6).
The rungs above guard a null. A transfer that comes back above chance needs the opposite kind of control: a cheaper reason both labellings could be decoded along one axis. The first is response time. Incongruent and switch trials are both slower, so in stimulus-locked windows near the response anything that tracks time-to-response separates both contrasts the same way.
RT_MATCH=rt keeps, per subject, a subset in which the congruency × switch-type
cells have the same RT distribution; RT_MATCH=random keeps the same counts
without regard to RT. The transfer survives RT if the rt run keeps about the
share the random run keeps. Method: RT matching; run and read:
A4 §13. The response-locked
run (response_main_effect_conditions) looks at the same question from the
response side.
If hard trials raise HG on most electrodes at once, a decoder trained on one
contrast learns "activity is up" and scores the other contrast above chance: a
shared gain rather than a shared pattern. ACTIVITY_CONTROL=remove_mean removes
each subject's mean across its decoded electrodes (only the pattern is left);
ACTIVITY_CONTROL=mean_only keeps only that mean. A shared pattern survives the
first and fails the second; a shared gain does the reverse. Method:
Overall-activity control.
Work through these in order; each is cheap and each rules out a distinct cause.
Congruency × switchType × inc-proportion × switch-proportion cells lose trials
fast, and subsample_to_min_trials_per_condition takes the minimum across
channels in the ROI, so a single bad electrode caps the whole cell. The
[NaN filter] log lines reporting large "% dropped" are padding removal, not
artifact rejection — do not read them as data loss.
Print, per design: n per class in the train population, n per class in the test
population, and the four joint-cell counts.
tests/analysis/decoding/test_cross_decoding.py::test_all_four_joint_cells_are_populated_and_balanced
is the shape of the assertion.
If the counts are in the low teens per class, expect a null and say so up front. This is not something a better classifier fixes.
Training in one block and testing in another means any tonic block-level HG
difference shifts the test cloud along a direction the classifier did not intend
to use. The baseline carries exactly that confound by construction: a random
0.5 s pre-stimulus baseline z-scored with statistics pooled across all trials, in
a design where incongruentProportion is the block
(analysis_plans.md › Simplification plan §1.4).
Check: center features within block — per channel, per block, subtract that block's mean over trials — and re-run. Report both versions.
- Transfer recovers after centering → the null was a DC shift, not code reconfiguration. The centered version is the one that answers the question.
- Transfer still null after centering → the geometric claim survives its most likely artifact.
Corollary worth stating in Methods: centering deliberately discards the tonic block effect, which may itself be the proactive-control signal. That is undecidable in a blocked design, which is why both versions are reported.
explained_variance=0.8 is unsupervised and refit on the training data every
fold. Nothing guarantees the retained components span the discriminant direction
for the test labelling — so a shared code can exist and still fail to transfer
because the axis it lives on was discarded as low-variance.
Three re-runs, any of which diagnoses it:
- PCA off entirely (feasible only with few electrodes / a short window),
- a fixed, generous
n_components, - PCA fit on the pooled data (unsupervised, so no label leakage) rather than per fold.
If transfer appears under any of these, the null was a basis artifact. Report the version with the pre-specified basis and note the sensitivity.
Cross-decoding deliberately handles missing train and test values differently.
With its default oversample=False, sample_fold removes incomplete training
pseudo-trials and deterministically subsamples the surviving classes to the same
count; it does not use mixup2. Partial test rows are retained and their NaNs
are filled with i.i.d. Gaussian noise, deliberately non-informative so test
imputation cannot leak class information. A transfer whose test population draws
more heavily on sparsely-covered subjects can nevertheless be depressed because
more of its test features are noise.
The A4 / transfer job itself no longer feeds incomplete pseudo-trials to the decoder: it drops each electrode's NaN trials when it builds the pseudopopulation, as the ordinary decoder does (see Changes to A4). The fold-level handling above applies to callers that pass padded arrays.
Check: per-subject channel coverage in the train population vs the test population, and the fraction of test features that were NaN-filled. If the test side is markedly sparser, restrict both sides to the subjects/channels present in both and re-run.
Two decoders whose accuracies are compared must match on trial count, class balance, CV folds, feature set, window, and step size. An LWPC decoder with more trials than the LWPS decoder will look better for that reason alone. Subsample to the common minimum and average over subsamples, or do not compare them.
Below-chance transfer is almost always a class-ordering flip between the training labelling and the scoring labelling — the classifier is right, the labels are backwards.
Check first: cats_train vs cats_test from build_cross_decoding_arrays
(or the block-transfer equivalent). Both are {tuple(group): class_idx}; confirm
the same substantive class maps to the same index on both sides. In the block
transfer, confirm the contrast's pos/neg levels are resolved the same way in
both block populations.
Related traps in this codebase, both already guarded but worth re-checking when the numbers look strange:
- Confounded labellings. If the two contrasts split the surviving trials
identically, the "transfer" is the within-contrast decode reported as perfect
generalization — a high number, not an error.
build_cross_decoding_arraysraises on this (_same_partition), andcd.factors_are_crossedis the check to run when filtering conditions by hand. - Sign instability across folds. LDA's class order is not guaranteed stable when a fold is missing a class. Pin it explicitly; this matters most for the Haufe patterns (plan §8.2 step 4), but it also produces noisy-looking accuracy when folds disagree.
A genuinely below-chance transfer, after ordering is verified, is an anti-code (the two conditions use opposed axes). That is a real and reportable result — but verify the ordering twice before claiming it.
The diagnostic case: a congruency decode with an above-chance cluster before the stimulus. Current-trial congruency cannot be known pre-stimulus, so any such cluster is a confound readout. Use the pre-stimulus window as an artifact meter: whatever drives it back to chance is the right fix.
Suspects, in the order worth testing:
- Fold structure ignores time.
StratifiedKFold(shuffle=True)draws random folds with no regard for trial order or run boundaries, so slow drift correlated with a temporally clustered label leaks across folds. Fix: time-/run-aware folds — leave-one-run-out orGroupKFoldon run/block id. This is the same recommendation as simplification plan §2.8's leave-one-block-out. - Block-level baseline leakage. The pooled-statistics z-score puts tonic
block differences into the pre-stimulus window by construction, and
incongruentProportionis the block. The switchType panel (which varies within block) shows no pre-stimulus cluster while the proportion panel shows one spanning the whole baseline — that asymmetry is the signature. Fix: per-trial baseline (simplification plan §2.4) and/or within-block centering (§4.2 above). - Tiny min-balanced samples on the rare cell, which make accuracy estimates unstable enough to produce spurious clusters.
- Sequence carryover. Legitimate for switch type (the previous trial defines it); a confound for congruency.
Quick probe: sweep frac_train. If the pre-stimulus cluster shrinks as the
training set shrinks, it is fold leakage rather than signal.
Where it shows up now (A4, 2026-09-29). Pre-stimulus windows appear in the
electrodes selected for the congruency and switch-type main effects (both, on
both the in-job anova and the csv route) and in the csv run's all (every
lPFC electrode, 398, including non-responsive ones), but not in the 171
task-significant electrodes decoded without selection (none). That pattern
points at suspect 2 through the selection:
- In the pooled 2×2 (
stimulus_main_effect_conditions), 75% of incongruent trials come from 75%-incongruent blocks and 75% of switch trials from 75%-switch blocks, so any tonic block difference separates the classes before the stimulus. - The main-effect selection is a one-way ANOVA on congruency (or switch type)
alone (
per_electrode_anova_labels), with no block term, over the window mean. An electrode with a tonic block offset shows a "congruency effect", so the selection can enrich for exactly these electrodes. - Non-responsive electrodes (in the 398) carry slow drift and tonic offsets with no stimulus response to dilute them.
Checks, cheapest first: decode block type with the ordinary decoding job on the
same electrodes (CONDITIONS=stimulus_block_multiclass_conditions, blocks A–D as
four classes, or stimulus_block_pairwise_conditions; with no table, as in
Decoding job §3.1) and look at its pre-stimulus windows. If block
type is decodable before the stimulus, the leak is confirmed. Then the
selection split (ELECTRODE_SELECTION_SPLIT=true), and, if the groups are to be
reported, tonic block centering (§4.2) — which is not built for A4 and which
removes any genuine proactive (block-level) signal along with the artifact.
2026-10-01: smaller pseudopopulations show them too. After the NaN-trial change, the unselected 171 electrodes have no pre-stimulus window at full trials. They do have them once the decoded trials shrink:
- the 70% decode half of the split runs (
allceilings: 7 and 5 windows); - the RT-matched run (congruency ceiling: 6);
- its random control (both transfers: 6 and 4).
No selection touches these electrodes. A real block leak would also be at least as visible with all the trials as with half. That points at suspect 3: a single small pseudopopulation's noise, which a cluster test over CV repeats of that one pseudopopulation does not see (A4 §8.3). The practical rule: in a run on half the trials, a 4–7-window cluster is within what noise produces. Compare such runs across seeds, not by their window counts (A4 §13.8).
Also treat transfer > within-condition accuracy as an F3: a transferred axis cannot beat an axis trained on the labelling it is scored against. That combination means the two labellings are not actually crossed, or the test population is contaminated with training trials.
Make it a fixed table in the output directory, not something reconstructed later.
design X1: congruency, 25%inc -> 75%inc
electrode set lpfc, anatomical, n = ___ channels / ___ subjects
n per class (train) ___ / ___
n per class (test) ___ / ___
joint cell counts ___ ___ ___ ___
feature centering within-block: yes / no
PCA explained_variance = 0.8, refit per fold
fold structure PredefinedSplit on block; ___ subsamples
within-condition acc ___ (null ___, p ___) <- the ceiling
transfer acc ___ (null ___, p ___)
pre-stimulus cluster none / [t0, t1] <- artifact meter
reverse direction 75%inc -> 25%inc: ___
positive control X3 congruency across switch proportion: ___
positive control T1 / T3 task across congruency: ___ ; congruency across task: ___
RT matching (positives) residual RT ___ ms; transfer ___ (rt) vs ___ (random)
activity (positives) transfer ___ (remove_mean) vs ___ (mean_only) vs ___ (none)
response-locked transfer windows ___ relative to the response
seeds share kept ___ +/- ___
The last four lines apply to a transfer that came back above chance; A4 §13.7 has the same block filled in for the label transfer.
The two lines that carry all the interpretive weight are within-condition acc and pre-stimulus cluster. A reader who sees the first can tell whether a null means anything; a reader who sees the second can tell whether a positive means anything.
transfer at chance?
├── within-condition also at chance ........... not runnable — report counts, stop (§2)
└── within-condition above chance
├── block transfer? → center within block and re-run ......... (§4.2)
│ └── still null → check PCA basis (§4.3), NaN asymmetry (§4.4)
├── counts in the low teens? → underpowered, say so .......... (§4.1)
└── all checks pass + X3 transfers → INTERPRETABLE NULL:
the code is reconfigured by block context
transfer below chance? .......................... check class ordering first (§5)
transfer above chance?
├── pre-stimulus cluster present → artifact; fix folds/baseline .. (§6)
├── transfer > within-condition → labellings not crossed ......... (§6)
└── clean → rule out the cheaper shared axes (A4 §13)
├── RT_MATCH=rt keeps ≈ the share RT_MATCH=random keeps? ..... (§3.6)
│ └── no → the transfer is (partly) a response-time difference
├── ACTIVITY_CONTROL=remove_mean keeps the share? ............ (§3.7)
│ └── no, and mean_only transfers → a shared gain, not a pattern
└── both yes → report with its ceiling, reverse direction, and the controls
For the primary question, the reportable pattern is:
| Design | Expected if stability and flexibility are concurrently but separably regulated |
|---|---|
| within-block congruency decode | above chance in both incongruent-proportion blocks |
| X1 congruency 25% ↔ 75% inc | fails to transfer (block context reconfigures the congruency code) |
| X3 congruency 25% ↔ 75% switch | transfers (a block factor that does not reconfigure it) |
| within-block switchType decode | above chance in both switch-proportion blocks |
| X2 switchType 25% ↔ 75% switch | fails to transfer |
| X5 inc-proportion axis ↔ switch-proportion axis | at chance, with both within-axis decodes significant → concurrent but separable regulation |
X1-fails-while-X3-transfers is the load-bearing contrast. Either one alone is not a result.
A standalone util: subsample trials so the groups being compared have the same RT distribution, in any analysis.
Code: src/analysis/utils/rt_matching.py. Tests:
tests/analysis/utils/test_rt_matching.py. Wired into A4 / N3b / task transfer
through RT_MATCH (A4 §6.4); any other analysis can call it
in one line.
Incongruent and switch trials are slower than congruent and repeat trials. On the
behavioural data (combinedData.csv, correct trials, 26 subjects) the mean
per-subject costs are +153 ms (i − c) and +197 ms (s − r). In stimulus-locked
data, anything that tracks time-to-response then differs between the levels of
both factors: response preparation arrives later on the slow trials, and activity
that lasts until the response lasts longer. A decoder or a power contrast can
read that latency as a condition effect, and a cross-decode can read it as a
shared code. Matching asks whether the effect survives when the compared trials
were, on average, equally fast. Reviewers commonly ask for it on any
conflict/switch-cost effect.
Separately for each subject (and any extra within strata):
- Pool the subject's RTs over every group being matched and cut them into
n_binsquantile bins (equal trial counts per bin; invariant to RT vs log RT). - Count each group's trials per bin, choose how many to keep per group and bin,
and draw that many at random without replacement.
balance='equal'(default): the minimum across groups, so every group ends up the same size with the same bin profile. Decoders balance classes anyway.balance='proportional': every group gets the same bin profile but keeps its size relative to the others (rounding spread across bins, so it stays unbiased). Use it when unequal group sizes should survive, e.g. a power contrast inside a 75/25 block.
Rows with no RT, or a missing value in a matched factor, are dropped. A stratum with fewer than two groups is dropped. Each stratum's draw depends only on the seed and that stratum, so adding a subject never changes another's trials.
The control. Matching keeps about half the trials, so a weaker effect after
matching could just be the smaller sample. count_matched_random (or
mode='random') keeps exactly as many trials per subject and group as the
RT-matched set, drawn with no regard to RT. Compare the RT-matched result with the
random one: the difference between them is the RT-driven part.
On the behavioural data (the four congruency × switch-type cells matched to
each other, balance='equal'):
n_bins |
kept | i − c after | s − r after |
|---|---|---|---|
| 3 | 56% | +14 ms (p = .06) | +22 ms (p = .001) |
| 5 | 54% | +8 ms (p = .03) | +4 ms (p = .16) |
| 10 (default) | 48% | +2 ms (p = .26) | +1 ms (p = .59) |
10, mode='random' |
48% | +142 ms (p < .001) | +208 ms (p < .001) |
Kept per subject × cell at the default: median 47 trials, minimum 20. The epoched
data can hold fewer trials than the behaviour file, so read the job's own
rt_match_*_balance.csv.
Match every factor whose levels the analysis compares, together, so none of them keeps an RT difference:
| Analysis | groups |
Notes |
|---|---|---|
| A4 congruency ↔ switch-type transfer | ('congruency', 'switchType') |
the job's default |
| Congruency decoded within each incongruent-proportion block (LWPC) | ('congruency', 'incongruentProportion') |
matches i vs c and the two blocks to one another |
| Same, but leave block RT differences alone | ('congruency',), within=('incongruentProportion',) |
only i vs c inside each block |
| Switch type within switch-proportion blocks (LWPS) | ('switchType', 'switchProportion') |
|
| A power trace of i vs c | ('congruency',), balance='proportional' |
keeps more trials when the cells differ in size |
Factor names can use any project spelling (switchType, switch_type,
task_sequence; incongruentProportion, incongruent_proportion); metadata
columns can be named directly too (e.g. prev_congruency).
Every loader returns the {subject: {condition: {key: Epochs}}} structure, and
the epochs' metadata already carries reaction_time and trial_count (parsed
from the event names by make_metadata_from_event_names). So right after
loading:
from src.analysis.utils.rt_matching import (
rt_match_subjects_mne_objects, save_rt_match_report)
subjects_mne_objects = create_subjects_mne_objects_dict(...) # as now
subjects_mne_objects, rt_report = rt_match_subjects_mne_objects(
subjects_mne_objects, groups=('congruency', 'incongruentProportion'),
mode='rt') # 'random' for the control run
save_rt_match_report(rt_report, save_dir) # rt_match_{balance,contrasts,summary}.csvIn the ordinary decoding job that is after create_subjects_mne_objects_dict in
dcc_scripts/decoding/decoding_dcc.py (~line 388); in the power traces, after
the same call in dcc_scripts/power/power_traces_dcc.py (~line 124). Neither is
wired yet. Give the matched run its own save directory.
The loader also stores a trial-averaged <key>_avg and <key>_std_err Evoked
next to every Epochs, and the power-trace plots read the _avg ones
(src/analysis/power/evoked_builders.py). The adapter rebuilds both from the
kept trials (refresh_evoked, the loader's own formulas), so a matched power
trace plots the matched average, not the full one.
For anything that is not an epochs structure (a behavioural table, a long per-trial DataFrame), call the core directly:
from src.analysis.utils.rt_matching import rt_match, count_matched_random
keep = rt_match(trials, ['congruency', 'task_sequence'], within=['subject'])
control = count_matched_random(trials, keep, ['congruency', 'task_sequence'])- The method: "Within each participant, trials were subsampled so that [the four congruency × switch-type cells] had matched RT distributions (10 quantile bins of the participant's pooled RTs; equal trials per cell per bin). A control analysis used an equal number of randomly chosen trials per cell."
- The residual: the
afterrow ofrt_match_*_summary.csv, e.g. "residual RT difference i − c = +2 ms, t(25) = 1.2, p = .26". - The result against the random control, not against the full data.
- It equates mean timing, not trial-by-trial processing: within a bin a slow group can still sit slightly later (the residual line says how much).
- It does not remove effects that scale with difficulty rather than with time (e.g. more activity on hard trials at every RT). If an effect survives matching, "shared difficulty signal" is still open; RT matching only rules out "shared latency".
- Error trials and trials with no response have no usable RT and are never kept.
Is a decode, or a transfer, carried by a uniform rise in activity on hard trials, or by the pattern across electrodes?
Code: src/analysis/decoding/activity_control.py. Tests:
tests/analysis/decoding/test_activity_control.py,
test_cross_decoding_activity_control.py. Wired into A4 / N3b / task transfer
through ACTIVITY_CONTROL (A4 §6.5).
Incongruent and switch trials are both harder. If being on a hard trial simply raises high gamma on most electrodes, a decoder trained on congruency learns "activity is up", and that axis separates switch from repeat too. The transfer is then real but says "both effects raise overall activity", not "the two effects share a representational code". A4's transfer cannot tell these apart on its own.
Both are per subject: a pseudo-trial row holds a different physical trial from
each subject, so a mean across all of a row's channels would mix trials. The
subject comes from the channel name (<subject>-<electrode>).
remove_mean— for each subject, subtract the mean across that subject's decoded electrodes, per pseudo-trial and time point. Anything that moves all of a subject's electrodes together is gone; the pattern across electrodes is left.mean_only— replace each subject's electrodes by their mean: one feature per subject, the uniform part alone.
NaNs stay where they are (a subject absent from a row stays absent). A subject
with a single decoded electrode has nothing left after remove_mean; the summary
lists them.
Compare the three runs (none, remove_mean, mean_only) by each transfer's
share of its own ceiling (keeps X%), since the ceilings change with the
transform too:
remove_mean |
mean_only |
Reading |
|---|---|---|
| transfer survives, similar share | transfers little or not at all | A shared pattern. Not explained by a uniform rise in activity. |
| transfer gone, ceilings still above chance | transfers | A shared gain. The two effects share "more activity on hard trials", while their specific patterns differ. |
| transfer reduced but present | transfers | Both. Report the share under each. |
| ceilings gone too | — | The contrasts are themselves mostly overall activity; remove_mean cannot speak to the transfer. |
Planted answers (the test): two subjects × 8 electrodes; congruency and switch type each have their own zero-mean pattern plus a shared component. Congruency's ceiling / congruency→switch transfer:
| Shared component | full | remove_mean |
mean_only |
|---|---|---|---|
| uniform gain | 0.81 / 0.66 | 0.60 / 0.52 | 0.74 / 0.76 |
| zero-mean pattern | 0.77 / 0.68 | 0.78 / 0.65 | 0.48 / 0.51 |
Keep the planted effects weak when extending these tests: with large effects LDA treats the other factor's effect as within-class variance and projects the shared direction out, and nothing transfers in either world.
remove_meanremoves only a shift shared by all of a subject's decoded electrodes. A rise on a subset of them (say, the most task-responsive third) survives and reads as "pattern". Restricting to more homogeneous groups, or addingmean_onlyon subgroups, narrows this but does not close it.- It is additive. The HG is z-scored per channel against baseline, so a proportional gain mostly looks additive, but not exactly.
- It says nothing about RT: combine with
RT_MATCH=rt(and itsrandomcontrol) for the strongest version. The folder tags stack (..._rtmatch10_remove_mean/).
from src.analysis.decoding.activity_control import apply_activity_control
arrays, channel_names, info = apply_activity_control(
roi_labeled_arrays, roi, channel_names, 'remove_mean') # or 'mean_only'roi_labeled_arrays is the {roi: {condition: (trials, channels, time)}} dict
the decoders use; channel_names its channel labels in order (for a LabeledArray,
roi_labeled_arrays[roi].labels[2]). It returns plain arrays. The ordinary
decoding job reads only the condition keys and arrays
(gather_class_data_by_stratum), so they should drop in, but it is neither wired
nor tested there. Apply it after restricting to the electrodes you decode, so each
subject's mean is over those electrodes.