Getting the Annotations Out¶
The export formats, what a CSV holds, and where consent answers go. It ships with the potato-skill skill:
/plugin marketplace add davidjurgens/potato-skill
/plugin install potato-skill@potato
"Where is my CSV" is the first question after the annotators finish, and the answer surprises people: there isn't one until you make it. Decide the export before handover and say so, because the researcher who finds out at the end is the one who wanted COCO and got a directory of per-annotator JSON.
Storage and export¶
Annotations live at annotation_output/<user>/user_state.json, one file per
annotator, and that is the only storage format. output_annotation_format looks
like it changes that, and never did; it is deprecated now, and the loader reads
it as export_annotation_format. Everything else is an export: a derived copy,
produced on a timer or on demand, that you can regenerate and can safely
delete.
Three ways to produce one:
export_annotation_format: [csv, jsonl] # on a timer, while the study runs
auto_export_interval: 60 # seconds; writes to annotation_output/exports/<fmt>/
# on demand, against a running server
curl -H "X-API-Key: $(cat admin_api_key.txt)" -H "Content-Type: application/json" \
-X POST -d '{"format":"coco"}' localhost:8000/admin/api/export
potato deploy pull config.yaml --dest ./collected # from a hosted task
The admin route writes to the server's disk and hands back the paths — it is
not a download. On a host you cannot reach a shell on, /admin/api/data/archive
streams the whole task directory as a gzipped tar instead. deploying.md has
that path.
One scheme keeps its answers somewhere else. multi_document_event writes
the events themselves to annotation_output/event_registry.json — one registry
for the whole study, not one file per annotator:
{"events": {"evt_f6ec517d3154": {
"title": "Payments certificate outage",
"slot_values": {"service": "Payments API", "cause": "expired certificate"},
"member_doc_ids": ["r01"],
"evidence": [{"slot_name": "scope", "doc_id": "r01", "span_start": 0,
"span_end": 30, "quoted_text": "Payments API returned 503s for",
"created_by": "s4@x.com"}],
"created_by": "s4@x.com"}}}
Two consequences to decide about before you recruit. The per-annotator side is
missing entirely: on 2.8.2-11 the input holding which events a document belongs
to carries no annotation-input class, so user_state.json stays {} for that
scheme and the export has nothing in it. The registry file is the whole dataset.
The registry is also shared. Annotator 2 opens a document and sees annotator 1's
events already attached to it, member_doc_ids records no attribution, and
there is no second opinion to compute agreement from. Treat this as one
collaborative pass rather than a replicated one, and back the file up the way
you would back up user_state.json.
A bad format name is silent until it matters. export_annotation_format is
not checked at load, so [csvv] validates clean under --strict, boots clean,
and produces one runtime warning after the first save — by which point nobody is
reading the log. Check the name against the list below.
Which format¶
32 formats are registered. GET /admin/api/export/formats lists them with
descriptions against the running server; potato preview does not.
| They want | Format |
|---|---|
| A table, one row per annotator-item | csv, tsv, jsonl |
| The same at corpus scale, for pandas or DuckDB | parquet |
| To train an NER model | conll_2003, conll_u |
| To train a detector or segmenter | coco, yolo, pascal_voc, mask_png |
| To go back to the tool they came from | cvat, labelme, darwin, cityscapes, kitti |
| Tracking ground truth | mot, davis |
| Phonetics or linguistic tiers | textgrid, eaf |
| A ConvoKit corpus with the labels attached | convokit |
| Their qualitative codebook and quotes | codebook, quotation_report |
| To open it in NVivo, MAXQDA or ATLAS.ti | qdpx |
| Agent or coding-agent evaluation data | agent_eval, coding_eval |
| Preference pairs and SFT targets from corrections | trajectory_correction |
| Per-frame embodied episode labels | episode_jsonl |
| How the text was typed, or how the boxes were drawn | keystrokes, annotation_telemetry |
| The single resolved label per item, after adjudication | adjudication |
| The labeling engine's labels, certificate and model-call log | labeling_engine (see labeling-engine.md) |
| It published | huggingface (see publish in modes-and-subsystems.md) |
An item nobody annotated is not in the export. I ran a three-image study, annotated two and advanced past the third, and every one of the eleven vision formats wrote two images. The third appears in none of them, not even as an entry with an empty annotation list. That matters for detector training, where images with no objects are the negatives, and it matters for anyone reconciling counts: a study of N items exports the number of items somebody actually marked, not N. Every format says so in its warnings and names the items, so read them — this is the one case where the count you get and the count you expected differ for a reason that is nobody's mistake.
Take masks from an export, not from user_state.json. The stored form is
an internal one: the mask lives in a _data label as a JSON string whose rle
is row-major with size as [height, width]. COCO's RLE is column-major, so
reading the raw file with pycocotools conventions gives a transposed mask that
looks plausible and is wrong — a stroke of mine came back at [289,0,321,399]
instead of [390,180,473,200]. The coco exporter does the conversion, so
annToMask on its output is correct.
A composite scheme's answer arrives as a JSON string, one level in. Types
that record a structure rather than a label store the whole answer under a
single (schema, schema) label, so the jsonl export nests it as a string.
Measured on code_review and text_edit; the geometry schemes do the same
thing through a _data label, as the mask note below describes:
{"instance_id": "t1", "user_id": "r@example.com",
"labels": {"review": {"review": "{\"verdict\": \"request_changes\", \"comments\": [...]}"}}}
A consumer has to json.loads the inner value before it is data. Worth knowing
before you write the analysis script, and worth checking on your own schemes:
which of them land as a label and which land as a blob is decided by the type,
not by anything in your config.
The vision and linguistics formats are the reason to ask early: a researcher who
says "we'll train a detector on this" wants coco or yolo, and a task designed
without that in mind can produce geometry that does not survive the conversion.
The CSV columns¶
Columns are derived from what was stored rather than from what the config declares. One row per annotator per item, with a column per scheme-and-label:
instance_id,user_id,sentiment.Negative,sentiment.Positive,severity.Moderate,issues.Price
r03,alice@x.com,Negative,,Moderate,Price
r02,alice@x.com,,Positive,,
That is one column per label an annotator actually chose, not one column per
scheme. A three-label radio becomes three sparse columns and there is no
sentiment column anywhere in the file, so the first thing anyone does with a
Potato CSV is coalesce them. Warn whoever receives it: a researcher who opens
it expecting one column per question reads the file as broken. Driven on
six schemes and three annotators, 23 rows came out as 17 columns.
Three consequences:
- A scheme nobody answered has no column, so an empty export is evidence about the annotations rather than about the exporter.
- A label nobody chose has no column either, so the column set is not stable across two runs of the same study.
- After a scheme is renamed mid-study, the columns carry the old name, and
keep carrying it. See
after-annotators-start.md.
Spans arrive as one <scheme>._spans column holding a JSON array of
{schema, name, title, start, end, id, target_field, text} objects — the
offsets and the words they cover, sliced server-side, so the two cannot
disagree:
[{"schema": "evidence", "name": "Evidence", "start": 23, "end": 39,
"target_field": "body", "text": "price I expected"}]
The item is not in the export. csv, tsv and jsonl carry instance_id,
user_id and the answers, and nothing else: not the annotated text, not the
image URL, not the fields you never displayed. Verified on a study whose items
carried body, photo, sku and batch. None of the four appears in any of
the three. Whoever gets the CSV has labels keyed by instance_id and has to
join back to the data file to find out what was labelled, so hand over the data
file with it. parquet is the exception.
The parquet export¶
parquet writes several files rather than one, and reshapes the
annotations:
csv / tsv / jsonl |
parquet |
|
|---|---|---|
| Files | annotations.<ext> |
annotations.parquet, items.parquet, spans.parquet |
| The items | absent | items.parquet, every field, displayed or not |
A radio scheme |
one sparse column per label used | one column holding the chosen label |
A multiselect |
one column per label used | one list column |
| Spans | JSON blob in <scheme>._spans |
spans.parquet, one row per span |
Both families add a phase_responses file when export_include_phase_data is
on, so a parquet handover is four files against csv's two.
So the "keep a condition label out of the annotator's view without losing it"
trick works, but only through parquet or by joining the data file back on
instance_id.
Open the file before you hand it over. A composite widget is one scheme holding several answers, and the column it produces depends on how the widget stored them. Both families agree, and the shape is worth knowing before the analyst asks:
| Scheme | Stored as | Columns |
|---|---|---|
multirate, constant_sum, soft_label |
one entry per row or option | reasons.Population, reasons.Intervention, … |
hierarchical_multiselect |
one entry, comma-joined | topics.selected_labels holding Annotation,People,Experts |
ranking |
one entry, comma-joined in rank order | priority.rank_order holding Cost,Agreement,Accuracy |
image_annotation and the other geometry types |
one entry, a JSON blob | uibox._data |
The joined ones need splitting before anything can be counted. The hierarchical
list also carries every ancestor of each leaf an annotator ticked, so a count of
Annotation includes everyone who chose something inside it.
import pandas as pd
print(pd.read_parquet("annotations.parquet").head())
head() on the file you are about to send is the check. It costs a minute and
it is the last point at which a column carrying the wrong thing is still cheap
to fix.
Consent and survey answers¶
export_include_phase_data: false # the default
export_include_annotation_changes: false
Phase responses — consent, pre-study, post-study surveys — are excluded from exports unless you turn them on, and they are usually where the demographics and the free-text sit. That default is doing real work. Turning it on is a decision to move identifiable answers into a file people will pass around, so make it deliberately and say so in the handover.
They arrive as a second file, not as extra columns. Turning the key on adds
phase_responses.csv (or .jsonl) beside annotations.csv, long-format, one
row per answer:
user_id,phase,page,sequence,schema,label_name,value
alice@x.com,consent,consent,0,consent_agree,I agree,I agree
alice@x.com,poststudy,poststudy,0,task_clarity,4,4
alice@x.com,poststudy,poststudy,1,comments,text_box,alice poststudy comment
"Here is the CSV" is two files once this is on, and the second one is the one carrying the demographics. Name that file explicitly when you hand the study over; a filename nobody was told about is one that gets left behind.
The admin API's response tells you which way it went: num_phase_responses and
num_phase_responses_excluded are both in stats, and one of them is always
zero.
export_include_annotation_changes adds the revision trail: every answer an
annotator moved off, with timestamps. Useful for studying how people decide,
much larger, and not what anyone means by "the data".
Agreement¶
The export holds answers. Agreement is computed on request, from the admin API:
curl -H "X-API-Key: $(cat admin_api_key.txt)" localhost:8000/admin/iaa
Per scheme it reports the kind it inferred and the metrics that follow from
it. The kind is inferred from the schema type, so a rating stored as a radio
is scored as unordered — assignment-and-agreement.md has the table, and it is
worth checking before quoting a number at anyone.