Results Export & Notebook
Every table behind the Results page as CSV or Excel — and a notebook inside the app to run your own Python against them: custom charts, custom validation, any reshaping you need.
Your results, your analysis
However many charts a tool draws, it will not draw the one your reviewer asks for. The Results page answers what did this study measure, and Results Validation answers does the difference survive a test. This page is about the third question, the one no fixed feature can answer in advance: the analysis we did not build.
There are two answers to it, and you can use either or both. Take the whole result set out as tables and work on it in whatever you already use. Or stay in the app and write the analysis against the results directly, with everything already loaded.
Export and notebook
| What it is | Use it when | |
|---|---|---|
| Export results | Nine tables — experiments, runs, every stored score, metric summaries, every configuration value — as CSV files, a zip, or one Excel workbook. | You want the numbers in R, SPSS, a spreadsheet, a paper, or a colleague’s inbox. |
| Notebook | A third tab on the Results page. Cells of Python, run one at a time, output underneath. | You want a chart or a test the app does not offer, without leaving it or re-exporting after every change. |
What each plan gets
Export is free on every plan, including Free. These are your measurements; a tool that recorded them and then charged you to read them elsewhere would not deserve them.
Running a notebook inside the app is part of Researcher and above. It is the same boundary the ML Suite’s Custom Code block already uses: a Free plan gets the whole catalogue, and what is paid for is the escape hatch out of it.
| Action | Free | Researcher and up |
|---|---|---|
| Export every table (CSV / zip / Excel) | Yes | Yes |
| Starter notebook shipped with the export | Yes | Yes |
| Write, edit and save notebooks in the app | Yes | Yes |
| Open the four templates | Yes | Yes |
Export a notebook as .ipynb | Yes | Yes |
Import an .ipynb | Yes | Yes |
| Run cells inside the app | — | Yes |
The escape hatch stays open
.ipynb and run it in your own Jupyter or in Colab — the templates and the exported CSVs are built for exactly that. What Researcher buys is not the capability, it is not having to leave.Exporting Results
Where to start it
Two places, one dialog — they cannot drift apart:
Results page → Export results…
Import / Export page → Take Out → Export Results
.bciproj they cannot open.The dialog lists all nine tables with a checkbox each, the three formats, and a switch for the starter notebook. The export itself runs on a background thread — a study with thousands of scores spends real time flattening every configuration into the parameters table, and the window stays usable while it does.
The nine tables
| Table | One row per | Holds |
|---|---|---|
study | key / value | Study name and id, app version, export time, how many experiments, runs and scores, and which metrics were recorded. |
experiments | experiment | Identity, status, version, which experiment it was branched from, datasets, subjects, sessions, modalities, run count — and one column per metric that experiment actually summarised. |
runs | training run | Run label and number, timestamp, cohort mode, workflow, the pipelines it scored, how many units and scores, duration, seed, and each run’s own run-level metrics. |
observations | stored score | The tidy long table. One row per number the study measured, labelled with every axis it belongs to. This is the table statistics are run on. |
metrics | experiment × metric | Best, mean, SD, n, min, max, and which direction is better. |
parameters | configuration value | Every setting the study holds, flattened — preprocessing, filtering, activation maps, GLM, ML training, analysis, report, plus each run’s own hyperparameters. |
models | trained model | What was produced, where it points, its size and content hash. |
reproducibility | experiment | Config and dataset hashes, seeds, and the software versions the run was produced under. |
versions | version-history entry | The study’s own version log. |
Nothing is invented
How they join
Every row of every experiment-level table carries experiment_id as its first column. Open the folder in anything — pandas, R, Excel — and the tables rejoin without the app:
import pandas as pd
obs = pd.read_csv("observations.csv")
params = pd.read_csv("parameters.csv")
runs = pd.read_csv("runs.csv")
# every score, next to the settings that produced it
settings = params[params.scope == "experiment"].pivot_table(
index="experiment_id", columns="parameter", values="value", aggfunc="first")
obs.merge(settings, on="experiment_id", how="left")The observations table is the one most analyses start from:
experiment_id experiment run pipeline subject session metric value exp-1 Motor Imagery Run 1 EEGNet sub-01 Session 1 accuracy 0.7318 exp-1 Motor Imagery Run 1 CSP+LDA sub-01 Session 1 accuracy 0.6741 exp-1 Motor Imagery Run 1 EEGNet sub-01 Session 2 accuracy 0.7204 ⋮
These are the same rows the Results Validation tab tests, produced by the same reader. An export whose numbers disagree with the screen they came from would be worse than no export, and the only way to guarantee they agree is for there to be one source.
The parameters table
The table that makes the rest of the export usable as evidence. A set of scores whose settings are written down nowhere cannot be reproduced, and “we used the defaults” is not a record of what the defaults were.
experiment_id experiment scope run section parameter value type exp-1 Motor Imagery experiment preprocessing bandpass.low 8.0 float exp-1 Motor Imagery experiment preprocessing bandpass.high 30.0 float exp-1 Motor Imagery experiment preprocessing channels C3; Cz; C4 list exp-1 Motor Imagery experiment ml_training folds 5 int exp-1 Motor Imagery run Run 1 hyperparameters lr 0.001 float exp-1 Motor Imagery run Run 1 hyperparameters epochs 50 int
Nested settings are flattened to a dotted path (bandpass.low). A list of plain values stays one parameter — C3; Cz; C4 — because splitting it into three rows turns one setting into three that no longer read as one.
Formats
| Format | What you get | Notes |
|---|---|---|
| CSV folder | One <table>.csv per table, plus a README.txt that explains each file. | The default. Written with a UTF-8 byte-order mark so Excel on Windows reads non-ASCII subject ids correctly instead of mangling them. |
| CSV zip | The same files in one archive, with the starter notebook and README inside. | For emailing or attaching to a submission. |
| Excel workbook | One .xlsx, one sheet per table, header row frozen. | Needs the optional openpyxl package. Without it the option is disabled and says so; every table is still available as CSV. |
The starter notebook
Every export can write an analysis.ipynb beside the tables. It is not a format description — it is a file that already runs. Open it in Jupyter or drop the folder into Colab and it loads the CSVs, summarises each metric by pipeline, draws a bar chart with error bars, runs a paired t-test across subjects, and pivots the parameters table.
jupyter lab analysis.ipynb
It uses nothing but pandas, matplotlib and scipy, so it opens anywhere. It is the answer to “can I do my own analysis on this?” that does not require reading a schema first.
The Notebook
A third tab on the Results page, beside Results and Results Validation. Notebooks on the left, cells in the middle. It is a notebook in the ordinary sense — cells of Python, run one at a time, output underneath, and state carried from one cell to the next.
What makes it worth having inside the app rather than beside it is two things.
What is already loaded
The study is not something you load; it is already there. Every name below exists in every cell from the first line you type:
| Name | What it is |
|---|---|
results | The tidy table — one row per stored score, with every axis it belongs to. The same rows Results Validation tests. |
df | The same table under the name most people type first. It is the same object as results, not a copy, so the two can never drift apart. |
experiments_df, runs_df, metrics_df, parameters_df, models_df | The other export tables, as DataFrames. |
sheets | All nine, keyed by name. |
experiments | The raw experiment records, for anything the tables flattened away. |
np, pd, plt | numpy, pandas, matplotlib. |
sns, scipy, stats, sklearn, torch | seaborn, scipy, scipy.stats, scikit-learn and PyTorch — imported the first time a cell touches them, so a notebook that never uses torch never pays for it. |
validation | The app’s own statistics layer — the exact module the Results Validation tab runs on. |
describe() | Mean / SD / n per metric and pipeline, the usual first look. |
save_csv(), save_figure(), workdir() | Write a table or a figure into this notebook’s folder, and say where that is. |
Nothing is loaded twice
np, pd and plt in a cell are the same module objects the app already has in memory — the notebook shares them rather than importing its own copies. Starting a notebook adds a few megabytes, and that is your study’s own tables, not libraries.That validation module is the second reason this belongs inside the app. A “custom” analysis written against a re-implementation of the study’s statistics can quietly disagree with the tab beside it. Written against the same module, it cannot.
The Data button
You do not have to remember any of the table above. Data on the toolbar opens a reference onto your own study and answers the three questions that otherwise cost the first ten minutes:
| Question | What the dialog shows |
|---|---|
| What names can I type? | Every name a cell starts with, grouped — the study’s tables, the statistics module, the libraries, the helpers — with a line each. Plus all nine tables under sheets[…]. |
| What is in this table? | Every column: its name, its type, a real value out of your study, and what the column actually means. |
| How big is it? | Rows and columns, for this study, now. |
Example values are read from your data rather than invented — “what does this actually contain” is the question being asked, and a made-up "sub-01" answers a different one. Copy name puts the selected name on the clipboard ready to paste into a cell (a table comes across as sheets["parameters"], the form that works).
It works before you have run anything
Running cells
| Control | Does |
|---|---|
Run on a cell, or Ctrl+Enter | Runs that one cell. |
| Run all | Runs every code cell top to bottom, and stops at the first failure. |
| Interrupt | Stops the running cell. A Python loop stops promptly; a single long C call — a big torch matmul — finishes first, the same as Jupyter. |
| Restart | Forgets every variable and starts fresh. Outputs are cleared with it. |
| Clear outputs | Empties every cell’s output, keeping the code and the variables. |
| Add cell | A code cell or a text cell. Text cells are Markdown — double-click to edit, Done to render. |
| Data | What is in your data — every name, every column, and what each one means. See The Data button. |
Save, or Ctrl+S | Writes the notebook into the study. |
Run all stops where it failed
Cells run on a background thread, never the window’s. The app stays responsive while a cell works, and Interrupt is always reachable.
Outputs
Each output lands under its cell in the form that suits it:
| The cell produced | You get |
|---|---|
print(...) | Text, in a monospace block. Standard error is shown in red. |
| A DataFrame or Series on the last line | A real table. Large frames show the first 200 rows and say how many there were. |
| Any other value on the last line | Its repr, the same as a notebook anywhere. |
| A matplotlib figure | A chart on the app’s own canvas — see below. |
| An exception | A traceback trimmed to your cell, with the kernel’s own frames removed. The session survives it; the next cell still has your variables. |
Figures and the right-click menu
A chart you write is a chart the app drew. Figures come back as live matplotlib objects and go onto the same canvas every other chart in BCILattice uses, which means yours gets the same right-click menu without asking for it:
- Save image — PNG at 300 or 600 dpi, SVG, or PDF
- Copy to clipboard
- Zoom and pan, in data space, with reset
- Transpose, grid, legend and X-label rotation
That is why the notebook runs in the app rather than in a separate process: a picture of a chart cannot be re-exported at 600 dpi or saved as vector, and a figure is the thing most likely to end up in a paper.
You can produce several
plt.show() — it is accepted and ignored.Reading and writing files
Each notebook has a folder of its own. workdir() returns the path, save_csv(frame, "name.csv") and save_figure(fig, "name.png", dpi=300) write into it and print where they landed, and a relative open("out.csv", "w") resolves there too.
save_csv(results, "scores.csv") save_figure(fig, "accuracy_by_pipeline.png", dpi=300) print(workdir())
Reading is unrestricted — point pandas at any file on your machine. Writing outside the notebook’s folder is refused, with a message that says where the folder is. It is a guardrail against a stray path, not a security boundary, and it is documented as one in the code.
Saving, and what is stored
Notebooks are saved inside the study, not as loose files, so they travel with it — export the study, share it with your team, sync it, and the notebooks go along.
Code is stored; outputs are not
.ipynb can include outputs — a file you hand to someone should show what it showed.Templates
New notebook offers a blank one and four templates. Each is a whole small notebook that runs unchanged against any study — none of them hard-codes a metric or a pipeline name, because a template that raises on its first cell teaches you the feature is broken.
| Template | Covers |
|---|---|
| Explore the results | What each column of results means, what the study actually measured, and the first cuts of it. |
| Custom graph | A bar chart with error bars, the same data as per-subject lines, and a seaborn box-and-strip plot — all on the app canvas, then saved at print resolution. |
| Custom result validation | The app’s own validate() driven from a cell, a paired comparison written by hand with an effect size and CI, and a pass-threshold criterion no omnibus test answers. |
| Reshape and re-export | Long to wide, joining scores to the settings that produced them, a publication-ready mean ± SD table, and writing it all back out as CSV. |
Worked Examples
results and df are the same table — use whichever name you prefer; these examples use results.
A chart the app does not draw
Average within an observation unit before plotting — fourteen subjects × three pipelines is 42 numbers but not 42 independent ones — and show the spread, because a bar with no error bar is a claim with no evidence.
metric = results["metric"].value_counts().index[0]
subset = results[results["metric"] == metric]
per_subject = subset.groupby(["pipeline", "subject"])["value"].mean().reset_index()
summary = per_subject.groupby("pipeline")["value"].agg(["mean", "std", "count"])
fig, ax = plt.subplots(figsize=(7, 4.2))
ax.bar(summary.index, summary["mean"], yerr=summary["std"].fillna(0),
capsize=5, color="#14b8a6", edgecolor="none")
ax.set_ylabel(metric)
ax.set_title(f"{metric} by pipeline (mean ± SD over {summary['count'].iloc[0]} subjects)")
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
figA validation of your own
The Validation tab’s full analysis, driven from a cell — design selection, assumption checks, omnibus test, cross-check, corrected pairwise family and an APA line:
observations = validation.collect_observations(experiments)
metric = validation.available_metrics(observations)[0]
result = validation.validate(
observations,
metric=metric,
group_factors=["pipeline"], # what is being compared
unit_factors=["subject"], # what an independent observation is
design="auto", # paired when the design supports it
alpha=0.05,
)
print(validation.report_text(result))Or the test written out by hand, when you want one the tab does not offer:
from math import sqrt
wide = (results[results["metric"] == metric]
.groupby(["subject", "pipeline"])["value"].mean()
.unstack("pipeline").dropna())
a, b = wide.columns[:2]
diff = wide[a] - wide[b]
n = len(diff)
t, p = stats.ttest_rel(wide[a], wide[b])
half = stats.t.ppf(0.975, n - 1) * diff.std(ddof=1) / sqrt(n)
print(f"{a} vs {b}")
print(f" mean difference {diff.mean():+.4f} 95% CI [{diff.mean() - half:+.4f}, {diff.mean() + half:+.4f}]")
print(f" t({n - 1}) = {t:.2f}, p = {validation.format_p(p)}, n = {n}")Reshape and re-export
# one row per subject, one column per pipeline
wide = (results[results["metric"] == metric]
.groupby(["subject", "pipeline"])["value"].mean()
.unstack("pipeline").round(4))
# a publication table
summary = (results[results["metric"] == metric]
.groupby(["pipeline", "subject"])["value"].mean().reset_index()
.groupby("pipeline")["value"].agg(["mean", "std", "count"]))
table = summary.apply(
lambda r: f"{r['mean']:.3f} ± {r['std']:.3f} (n={int(r['count'])})", axis=1)
save_csv(wide.reset_index(), f"{metric}_wide.csv")Jupyter, Colab and .ipynb
The notebook format here is the Jupyter format — .ipynb v4, nothing invented. A notebook written in BCILattice opens in Jupyter, Colab, VS Code, nbviewer and GitHub; a notebook written in any of those opens here.
| Direction | How | What happens |
|---|---|---|
| Out | Notebook file → Export as .ipynb… | You are asked whether to include the outputs shown on screen. Include them to hand someone a notebook that already shows its results; leave them out for a clean file that runs from scratch. |
| In | Notebook file → Import a .ipynb… | The cells come in. Stored outputs are dropped — they were produced by a run this app never saw, against data it cannot check, and showing them under the cells would present someone else’s results as your study’s. |
Every results export also ships an analysis.ipynb for the same reason: nothing about this feature should make your work harder to take somewhere else.
Safety and Limits
A notebook runs real Python with the same access as the rest of the app. Four things follow from that, and each is handled rather than assumed away:
| Concern | What the app does |
|---|---|
| Nothing runs on its own | Not on page load, not when a study opens, not on a tab switch, and not because a notebook arrived inside an imported study. A cell runs when you press Run and at no other time. |
| A notebook from someone else | Notebooks record the machine that wrote them. One that arrived with an imported study shows a banner saying so, and the first Run asks you to confirm you have read the code. |
| A cell that will not stop | Cells never run on the window’s thread, so the app stays usable. Interrupt raises inside the running cell. |
| An accidental write | Writes are confined to the notebook’s own folder. Reads are not restricted. |
Read imported notebooks before running them
Other limits worth knowing:
- One cell runs at a time; Run all queues the rest.
- A cell’s printed output is capped at 200,000 characters, and a displayed table at the first 200 rows and 60 columns — a runaway loop is a normal mistake, and turning it into hundreds of megabytes of window is not a normal consequence.
- Restarting or switching study discards every variable. Notebook code is never lost by either.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| The Run buttons are disabled and a banner mentions Researcher | Running in the app is a Researcher feature. Everything else on the tab still works — write the notebook, export it as .ipynb, and run it in your own Jupyter. |
results is empty | The study has no stored scores yet. Train a run, or check the Results tab — if it shows “no results yet”, there is nothing for the notebook to read either. |
| The Excel option is greyed out | The optional openpyxl package is not installed. Use the CSV folder or zip — the same tables, same rows. |
PermissionError writing a file | The path is outside the notebook’s folder. Use save_csv / save_figure, or a path under workdir(). |
ImportError for seaborn or torch | Those are imported on first use. The message names the package and the pip install line for it. |
| A cell hangs | Press Interrupt. If the cell is inside one long native call it finishes that first; Restart after it returns. |
| My chart looks blank | A figure with no axes drawn on it is treated as a step rather than a result and is not shown. Plot something onto it. |
| Outputs vanished after reopening the study | Expected — stored notebooks keep code, not outputs. Press Run all. |
| Excel shows mangled subject ids | Should not happen: CSVs are written with a UTF-8 byte-order mark for exactly this. If it does, open the file with UTF-8 encoding explicitly. |
Related