Annotate across levels
Some decisions are not about a note but about a patient: is this patient eligible for the study, given all their notes? Annotators then need to read every note of a patient before answering. In this tutorial, you add a patients table above the notes, and a view where selecting a patient shows their notes, and selecting a note its entities.
Before you start
Follow Design your view first: this tutorial adds patients to its oncology.pret file.
Add the patients
Build one row per patient from the notes, with the number of notes and an eligible column to fill:
from pret import DataFrame, load_store
store = load_store("~/metanno-home/annotations/oncology.pret")
notes = store["notes"].materialize().to_pandas()
patients = notes.groupby("patient_id", as_index=False).agg(note_count=("note_id", "count"))
store["patients"] = DataFrame(patients.assign(eligible=False))
store["patients"].materialize().to_pandas()
| patient_id | note_count | eligible | |
|---|---|---|---|
| 0 | 10 | 2 | False |
| 1 | 12 | 2 | False |
The file now holds three tables, linked by two columns: patient_id between patients and notes, note_id between notes and entities.
Write the view
%%writefile ~/metanno-home/recipes/patient_review.py
from typing import Annotated, TypedDict
from pret import DataFrame
from pret_joy import Stack
from pret_simple_dock import Layout, Panel
from metanno import DataRelation, DataWidgetFactory, RecipeTable
LABELS = {
"tumeur": {"name": "Tumeur", "color": "rgb(255,200,206)", "shortcut": "t"},
"tnm": {"name": "TNM", "color": "rgb(210,236,247)", "shortcut": "n"},
}
class Tables(TypedDict):
patients: Annotated[DataFrame, RecipeTable("One row per patient", keywords=["patient", "person"])]
notes: Annotated[DataFrame, RecipeTable("Notes of the patients", keywords=["note", "doc"])]
entities: Annotated[DataFrame, RecipeTable("Entities found in the notes", keywords=["entit"])]
def main(store: Tables):
"""
Decide on each patient while reading their notes and entities
"""
# One relation per link between two tables. The notes table shows the
# notes of every listed patient (working_set), the text the entities
# of the selected note (selection, the default)
factory = DataWidgetFactory(
store,
relations=[
DataRelation("patients", "notes", on="patient_id", load="working_set"),
DataRelation("notes", "entities", on="note_id"),
],
)
# The patient level decision
patients = factory.create_table_widget(
store_key="patients",
primary_key="patient_id",
fields=[
{"key": "patient_id", "kind": "text"},
{"key": "note_count", "kind": "text"},
{"key": "eligible", "kind": "boolean", "editable": True},
],
style={"height": "100%"},
)
# Notes of the listed patients, starting with those of the selected
# patient only, All above the table shows them all
notes = factory.create_table_widget(
store_key="notes",
primary_key="note_id",
fields=[
{"key": "note_id", "kind": "text"},
{"key": "patient_id", "kind": "text"},
{"key": "department", "kind": "text"},
],
follow_parent_selection=True,
parent_row_name="patient",
style={"height": "100%"},
)
# Text of the selected note with its entities
text, toolbar = factory.create_text_widget(
store_text_key="notes",
store_spans_key="entities",
text_key="text",
text_primary_key="note_id",
spans_primary_key="entity_id",
fields=[{"key": "label", "kind": "select", "editable": True, "options": list(LABELS)}],
labels=LABELS,
)
# Patients above their notes on the left, the text on the right
return Layout(
Panel(patients, key="Patients"),
Panel(notes, key="Notes"),
Panel(Stack(toolbar, text, spacing=1), key="Text"),
default_config={
"kind": "row",
"children": [
{"kind": "column", "size": 35, "children": [{"tabs": ["Patients"]}, {"tabs": ["Notes"]}]},
{"tabs": ["Text"], "size": 65},
],
},
)
As in Design your view, Tables names the tables main reads, so patient_review also opens from Recipes in the sidebar, on the tables of your sources.
A DataRelation links a parent table to a child table through a column they share, and decides which child rows the child widgets show:
load | The child table shows | Use it when |
|---|---|---|
selection, the default | the rows of the selected parent row only | you work on one parent at a time, such as the entities of a note |
working_set | the rows of every parent row listed, after its filters | you also compare parents, such as the notes of all eligible patients |
With working_set, follow_parent_selection=True starts the child table on the selected parent row only, with and above it to switch to the whole working set and back. A table of the entities would also offer once a note is selected, and show the entities of the selected patient until then.
Open it
from metanno import view_opener
view_opener(store, view="patient_review")
Then go through the patients:
- select patient 12: notes 4 and 5 are listed, select note 4 to read it
- click to see the notes of every patient, then type
oncologieunderdepartmentto keep the oncology notes only - tick
eligiblefor the patients you would include
Small data as nested lists
For a few hundred records built in Python, a Store can also hold nested lists of dictionaries, without DataFrames, such as {"patients": [{"patient_id": 10, "notes": [...]}]}. Widgets then point to the nested level with a dotted path, store_key="patients.notes", and need no DataRelation. Prefer DataFrames for data coming from a database: they are what produces, and they stay fast on large tables.
Next
The factory widgets cover tables, texts and forms. Custom widgets shows how to put your own components next to them.