Cases and Case Attributes for Qualitative Coding
Group interview excerpts by participant, attach attributes such as site or age group, and count codes by case or by attribute in Potato.
A case in Potato groups the items that belong to one unit of analysis, such as an interview participant or the documents from one organization. Potato builds cases at server start from a field in your data, stores attributes such as site or age group on each case, and lets an admin count codes by case or by case attribute. QDA Mode turns cases on by default, and you can turn cases on in any other study with one config block. Cases arrived in Potato 2.6.0, in the same release as QDA Mode.
The unit of analysis is the entity a study draws conclusions about. In an interview study the unit of analysis is usually the participant, while the unit Potato codes is the excerpt. Each excerpt is one item in the data file. A case connects the two levels. With cases, a code applied to one excerpt counts toward the participant who said the excerpt, and a participant's site or age group applies to every excerpt from that interview.
Researchers coming from NVivo or ATLAS.ti know the idea under other names. NVivo's help says that case classifications "describe the type of case" and that each classification has the attributes you choose to record. Age and location are among the help page's examples. The ATLAS.ti manual describes document groups as "quasi dichotomous variables" (a group such as gender::female). ATLAS.ti compares codes across documents and document groups in the Code-Document Table. A Potato case is closest to an NVivo case. Potato has no classification types, and every case in a project draws on one flat set of attributes. The code crosstab described below plays the part of a code-by-group table. Each cell of the crosstab counts excerpts.
Case detection from item fields
Potato reads cases from the items themselves. Each item names its case in a field, and items that share a value in that field form one case. Each line of the file data/excerpts.jsonl below is one JSON object, and the file contains six excerpts from three interviews:
{"id": "p01-01", "text": "I stopped going because the bus only runs twice a day.", "participant_id": "P01", "site": "rural", "age_group": "18-34"}
{"id": "p01-02", "text": "The nurse remembered my name, and that mattered more than the wait.", "participant_id": "P01"}
{"id": "p02-01", "text": "My copay went up and I started skipping refills.", "participant_id": "P02", "site": "urban", "age_group": "55+"}
{"id": "p02-02", "text": "I never know which clinic is open on Saturdays.", "participant_id": "P02"}
{"id": "p03-01", "text": "The bus takes an hour each way, so I go only when it is urgent.", "participant_id": "P03", "site": "rural", "age_group": "35-54"}
{"id": "p03-02", "text": "The doctor explained everything twice without rushing me.", "participant_id": "P03"}At server start, Potato reads the field named by cases.key on every item and files the item under the case with that name. Potato creates the case the first time the name appears. Without cases.key, Potato checks case_id, then participant_id, then respondent_id, and uses the first of those fields with a non-empty value. Potato makes that check item by item. A file with case_id on some rows and participant_id on others therefore yields cases named from both fields. Setting cases.key prevents the mix. For the file above, the server log reports Cases auto-detect: 3 case(s), 6 instance(s) assigned.
Potato reads only top-level fields, never a field nested under another key. An item with no value in the case field belongs to no case, and an item belongs to at most one case. Potato runs detection again on every start and only adds or updates records. When you restart after adding interviews, Potato places the new excerpts in the matching cases and keeps the earlier cases. Potato never deletes a case, and a participant you drop from the data still appears in the case list.
Potato stores cases, case attributes, and case membership in <task_dir>/project.sqlite. The same project database also contains the codebook and memos. Cases are filed under the study's annotation_task_name. If you rename the task, Potato builds a fresh set of cases at the next start and leaves the old set in the file.
Configuration
The config below turns on QDA Mode with one span scheme for the codes, groups excerpts by participant_id, and copies site and age_group onto each case:
annotation_task_name: Clinic Access Interviews
task_dir: .
output_annotation_dir: annotation_output/
data_files:
- data/excerpts.jsonl
item_properties:
id_key: id
text_key: text
user_config:
allow_all_users: true
qda_mode:
enabled: true
cases:
key: participant_id
attributes: [site, age_group]
annotation_schemes:
- annotation_type: span
name: codes
description: Highlight a passage and apply a code
codebook: true
labels: [transport, cost, scheduling, provider trust]QDA Mode turns cases on, and the cases block here only names the key and the attributes. For a study outside QDA Mode, add enabled: true to the block. Every option Potato reads is listed below:
| Option | Default | Description |
|---|---|---|
cases.enabled | unset (follows qda_mode.enabled) | true turns cases on in any study. false turns cases off even under QDA Mode. |
cases.key | unset (checks case_id, participant_id, then respondent_id) | The item field whose value names the case. |
cases.auto_detect | true | Builds cases from the data at server start. With false, Potato builds no new cases, and assignments from earlier starts stay unchanged. |
cases.attributes | [] | Item fields copied onto each case as case attributes. |
Potato checks the cases block against a closed list of these four keys. On a config with a misspelled key such as atributes, python -m potato.validate_cli config.yaml reports the key and suggests attributes.
Case attributes
A case attribute records something about the participant rather than about one excerpt, such as the study site or an age group. At startup, Potato copies each field listed in cases.attributes from every item onto the item's case. A participant-level field therefore needs to appear on only one excerpt per participant. In the data above, site and age_group appear only on the first excerpt of each interview. Data exported from a spreadsheet with the field on every row works the same way.
When excerpts of one case disagree, the excerpt read last in data-file order sets the case value. A field set to null is skipped, but an empty string counts as a value and replaces an earlier value. Leave the field out of a row rather than writing "". Potato stores every attribute value as text, and 34 and "34" end up as the same value.
Although the annotation page does not show case attributes, any logged-in annotator can read every case and attribute through /api/cases. A treatment condition stored as a case attribute is one request away from a coder who is meant to be blind to the condition. In that design, keep the condition out of the data file Potato loads and join the condition to the export afterward.
Counting codes by case attribute
For each code and each value of one attribute, the code crosstab counts the excerpts that received the code. The Admin Dashboard page explains where the admin key comes from. An admin requests the table from /admin/api/code_crosstab and sends the admin key in the X-API-Key header:
curl -H "X-API-Key: $KEY" "http://localhost:8000/admin/api/code_crosstab?attribute=site"After two coders worked on the example data, the response reads:
{
"attribute_key": "site",
"codes": ["codes::cost", "codes::provider trust", "codes::transport"],
"values": ["rural", "urban"],
"cells": [
{"code": "codes::cost", "value": "urban", "count": 1},
{"code": "codes::provider trust", "value": "rural", "count": 2},
{"code": "codes::transport", "value": "rural", "count": 1}
],
"n_instances": 6,
"schema_filter": null
}Potato fills the cells by five rules:
- Excerpts as the unit. Each cell counts excerpts. A code counts once per excerpt when both coders applied the code to the excerpt or when one coder highlighted the code twice in the excerpt. In the example, both coders coded
transporton excerptp01-01, and thetransport/ruralcell reads 1. - Case fallback. Potato takes the attribute from the excerpt first and falls back to the excerpt's case when the excerpt has no value. Excerpt
p01-02has nositefield, and Potato counts the excerpt underruralfrom case P01. - What counts as a code. A code is named
scheme::label. Selected labels and highlighted spans count. A slider's number and free text do not count. - The denominator.
n_instancesis the number of excerpts with a value for the attribute, coded or not. Excerpts with no value from either the excerpt or the case are left out of the table. - One scheme. With
&schema=codesadded to the request, the table counts only the codes of one annotation scheme.
To count codes by case, pass the case key itself as the attribute (e.g., attribute=participant_id). The table then has one column per participant. Because the case-key field is on every excerpt of a case, the by-case table needs no fallback.
The crosstab pools all coders and has no filter by coder or by case. One request covers one attribute, and the response is JSON only. The admin dashboard in Potato 2.13.1 has no crosstab view. For counts by coder, use the coder column of the quotation report described in the exporting section below.
Reading cases from the API
Two read-only endpoints return cases to any logged-in session without the admin key:
| Method | Path | Returns |
|---|---|---|
| GET | /api/cases | Every case in the study, sorted by name, with its attributes |
| GET | /api/cases/instance/<id> | The case for one item, with its attributes, or {"case": null} |
The endpoints answer 401 to a request without a session and 503 when cases are off. A case record has a generated id, the name taken from the data, the attributes, created_by (cases-autodetect for detected cases), and Unix timestamps in created_at and updated_at.
Exporting cases
No Potato exporter writes cases in 2.13.1. The REFI-QDA .qdpx export contains sources, codes, codings, and coders, and leaves cases and case attributes out. A project opened from that file in NVivo or ATLAS.ti arrives without cases or classifications. Potato prints a warning about the omission only when the config sets cases.enabled: true explicitly. A study that relies on QDA Mode's default gets no warning.
The case data sits in project.sqlite. Two queries with the sqlite3 command-line tool write case membership and case attributes to CSV:
sqlite3 -header -csv project.sqlite "SELECT d.instance_id, c.name AS case_name FROM case_documents d JOIN cases c ON c.id = d.case_id ORDER BY c.name;" > cases.csv
sqlite3 -header -csv project.sqlite "SELECT c.name AS case_name, a.key AS attribute, a.value FROM cases c JOIN case_attributes a ON a.case_id = c.id ORDER BY c.name, a.key;" > case_attributes.csvThe file cases.csv has one row per excerpt. The instance_id column of cases.csv matches the instance_id column of the quotation report. Write the quotation report with this command:
python -m potato.export -c config.yaml --format quotation_report -o export/The command writes one row per coded passage to export/quotations.csv. The columns name the code, the quoted text, the character offsets, the item, and the coder. Join the quotation report to cases.csv on instance_id to give every coded quotation its participant, then join on case_name to add the attributes.
Limits
- No manual assignment. Cases come only from detection at startup. No page or API call moves an item to another case or edits an attribute. To change a case assignment or an attribute, edit the data file and restart Potato.
- One level. A case cannot contain other cases, and an item belongs to one case. A focus-group excerpt can belong to the group or to one speaker, never to both.
- Codes attach to excerpts. QDA Mode has no way to code a case as a whole.
- Agreement per item. Intercoder agreement is computed per item and is not grouped by case.
The upstream cases example
The upstream example runs from a Potato checkout. Each of the example's six excerpts has a participant_id and a condition, and the config copies condition onto each case:
python potato/flask_server.py start examples/advanced/cases-example/config.yaml -p 8000Log in, code a few excerpts, and request /admin/api/code_crosstab?attribute=condition with the admin key to see codes by condition.
Further Reading
- QDA Mode, the switch that turns on cases together with the codebook and memos
- Codebook, for the shared set of codes the crosstab counts
- Coding Interview Transcripts, from recordings to coded excerpts with one case per participant
- Admin Dashboard, for the admin key and the other admin endpoints
- Export Formats, for the quotation report and the
.qdpxexport
For implementation details, see the source documentation.