Splitting an analysis into stages#
A long analysis is easier to work with as several notebooks — segment, then track, then
measure — than as one. acia.analysis.StageContext is what holds such a chain
together: every stage of one imaged population shares an output folder, writes its
results there as files, and reads what the previous stage left.
from acia.analysis import StageContext
ctx = StageContext.for_image(image_id, output_folder)
print(ctx) # population pos001_roi002 -> .../output
That one line answers the three questions every stage has: where results go, which population this is, and what has already run here.
Writing a stage#
Name the stage when you build the context, then record as you go:
ctx = StageContext.for_image(image_id, output_folder, stage='Segment')
source = open_sequence(image_id)
ctx.log_calibration(source)
ctx.log_params(backend='omnipose', diameter_px=12)
props = extract_properties(source)
write_units_csv(ctx.keyed(props), ctx.output_path('cell_properties.csv'))
np.savez(ctx.output_path('segmentation.npz'), **segmentation)
ctx.log_metrics(n_cells=len(props))
ctx.log_figure(fig, 'size_distribution', caption='cell area distribution')
ctx.finish()
Everything logged is written to stage_manifest.json the moment it happens, not at
the end. That matters more than it sounds: parameters and figures are the parts of a run
that re-running cannot recover, and a notebook that dies half-way used to record nothing
at all — while scale(exist_skip=True) then skips that stage on every later run. Until
finish(), the entry is marked "running", so an
interrupted stage is never mistaken for a finished one.
output_path() joins a name onto the output folder and
creates its parent; input_path() does the same for a
file this stage reads, failing with a useful message instead of a confusing one further
down:
segmentation = load_segmentation(ctx.input_path('segmentation.npz'))
The pair is about saying what you meant to read and write. Direction itself is still
observed rather than taken from these calls — see below — but a name declared and never
written shows up in the manifest as io.missing, which is usually a typo.
keyed() stamps the population’s identity columns onto a
table so many populations concatenate cleanly later.
Under scale(), the parameters papermill injected are recorded on
their own — you do not have to repeat your parameter cell into log_params.
Seeing what is there#
Put the context at the end of a cell and it renders the folder: which stages ran and how they ended, this stage’s parameters, metrics and figures, and every file with the stage that produced it.
ctx
Calibration#
log_calibration() records the pixel size and frame
interval a stage actually resolved. The movie stays the single authority — this does not
become a second place to read calibration from — but it means “what interval did this run
use?” has an answer, and a later stage that resolves a different value warns instead
of quietly producing results that cannot be compared with the earlier ones.
Re-running a stage#
Re-running is normal and allowed — a stage is re-run because its settings changed, and the folder should describe the run that produced the files now in it. Two things happen:
the previous entry is replaced, not merged. Its metrics, figures and recorded outputs are dropped when the new run starts, so a re-run that dies half-way can never read as one complete run built from two;
you get a warning naming what the re-run invalidates:
stage 'Segment' already ran in this folder (finished 2026-08-25T08:00:24+00:00); this
run replaces its entry. Stages that read its results are now out of date until they are
re-run too: Track.
That last clause is the part you cannot see from the notebook you are in.
check_stale() reports it too, but only on Track’s next run —
which is too late to be useful when the decision to re-run is being made here.
A previous run that never finished says so instead, and claims no staleness: nothing downstream can have consumed it.
record()#
ctx.record('Segment', n_cells=…) still works and still writes exactly what it always
did; it is the explicit finalize, equivalent to logging those keyword arguments and
calling finish(). A context built without a stage= name behaves exactly as before:
nothing is written until record().
A later stage does not need to be told the source again — the folder already records it:
ctx = StageContext.for_image(output_folder=output_folder) # image_id recovered
What gets recorded, without being asked#
While a stage runs, the context notes which files it actually read and wrote, and
records them with a cheap (size, mtime) fingerprint, alongside when it ran, how long it
took and which version of the notebook produced it:
"Track": {
"artifacts": [],
"acia_version": "0.3.2",
"started_at": "2026-08-17T09:10:41+00:00",
"finished_at": "2026-08-17T09:12:03+00:00",
"duration_s": 82.4,
"code": {"notebook": "02_Track.ipynb", "mtime": "2026-08-16T17:02:55+00:00",
"sha256": "9f2c…"},
"env": {"python": "3.10.14", "platform": "Linux-5.15…", "host": "gpu-node-3"},
"io": {
"schema": "acia.stage_io/v1",
"inputs": [
{"path": "../pos001_roi001.tiff", "size": 4120233, "mtime": 1786951087},
{"path": "segmentation.npz", "size": 88401, "mtime": 1786956912,
"produced_by": "Segment"}
],
"outputs": [{"path": "tracking/", "size": 1044, "mtime": 1786957923}]
}
}
Nothing in the notebook asks for this. Two things follow from it:
The dependency graph is derived, not declared. Track read the file Segment wrote,
so there is an edge between them — nobody had to write that down, and it cannot go out of
date:
from acia.analysis import stage_graph
stage_graph(output_folder)
# [('Segment', 'segmentation.npz', 'Track')]
A result whose input moved says so. Re-segment with a different filter and the tracking output stays on disk looking perfectly current. The next context built in that folder warns:
UserWarning: Track may be stale -- its input 'segmentation.npz' changed after it ran
(recorded 2026-08-17T09:12:03+00:00, file modified 2026-08-17T10:41:55+00:00).
It only ever warns; you decide whether that matters. To actually redo the stage, remove what it produced:
ctx.clear('Track') # deletes exactly Track's recorded outputs
This is also the fix for a stage that failed half-way: scale(exist_skip=True) keys on
the copied notebook existing, so a half-finished stage is skipped on every later run until
its traces are gone.
Searching many runs#
After a batch, stage_table() turns a whole folder of results into one
table — one row per stage run, with every setting a stage recorded as a column:
from acia.analysis import stage_table
runs = stage_table('automated_executions_stages')
runs[runs.stale] # what needs redoing
runs[runs.status != 'ok'][['population_id', 'stage', 'error_type', 'error_message']]
runs[runs.stage == 'Segment'].pixel_size.value_counts() # settings drift in the batch
runs.groupby(['stage', 'code_sha256']).size() # did it all run the same code?
runs.pivot_table(index='population_id', columns='stage',
values='status', aggfunc='first') # coverage matrix
Every parameter and metric the stages logged is a column of its own name, so the headline
numbers of a whole fan-out — n_detections_after, n_tracklets, growth_rate_CC — are
answerable here without opening a single result CSV.
status reports how each stage ended, and error_type/error_message say why one did
not: across a batch that is what separates a single ROI running out of GPU memory from a
chain that is broken for every population. Counting the stages that left an entry cannot
tell those apart — a stage that ran and failed still left one.
The code_sha256 column is worth knowing about: it is a digest of the notebook that ran,
so when two populations disagree you can tell whether they were produced by the same
analysis instead of guessing.
Naming stages#
Any string works as a stage name. Prefer one without a number — Segment rather than
01_Segment — and keep the numbering on the notebook filename, where it orders the
chain. A number in the stage name becomes a problem the day a stage is renumbered or one
is inserted before it: the manifest gets a new key and the previous records are orphaned,
even though it is the same stage. The ordering is already visible in code.notebook and
in the derived graph.
Advanced#
Everything below is optional. A stage chain works without any of it.
Restricting what is captured#
Capture is on by default and covers everything the stage does. track_io=False turns it
off, and track() turns it back on for a region — useful
in a notebook with exploratory cells whose reads should not count as dependencies:
ctx = StageContext.for_image(image_id, output_folder, track_io=False)
... # scratch work, not recorded
with ctx.track():
overlay = segment(source) # the real analysis
Regions are re-enterable, because a with block cannot span notebook cells: open one per
cell and everything accumulates into the next record().
Reads are only noticed under the working directory, the output folder and the source.
Anything else — a model checkpoint under /models, say — needs track_roots:
ctx = StageContext.for_image(image_id, output_folder, track_roots=['/models'])
Reading an upstream stage’s settings#
pixel_size = ctx.stage('Segment')['pixel_size'] # None if it never ran here
Exporting lineage#
acia.analysis.lineage.to_openlineage() writes the recorded runs as
OpenLineage events, for handing a lineage graph to tooling
outside the project. Needs the optional extra, and writes to a file rather than a server:
$ pip install "acia[lineage]"
from acia.analysis.lineage import to_openlineage
to_openlineage('automated_executions_stages', 'lineage.jsonl')
What capture cannot see#
Worth knowing before trusting a record completely:
Files read through a library’s C/C++ layer (
cv2.imread) raise no Python event. Writes are still caught, because the output folder is compared before and after; reads are not, so route them throughctx.require().The same applies to remote sources (fsspec, SMB, OMERO), which never touch a local file. The source itself is always recorded regardless.
Fingerprints are
(size, mtime), not content hashes — a move/change detector, not verification. Hashing a gigabyte image stack per stage would cost more than the analysis; the notebook, being kilobytes, is hashed.If capture fails for any reason, the
ioblock is simply absent and the manifest is exactly what it would have been without this feature. Provenance never breaks a run.