Skip to content

Classification and change labels

Attach a label to a whole sample, one visit, one acquisition, one region, one object or one slice — and, for a label that describes a difference, to the interval between two visits.

Choose a scope

A classification says what its label is about. Getting the scope wrong is the common mistake, because every scope reads the same at the call site.

Scope The label is about Example
sample the subject RECIST response across the study
timepoint one visit image quality at follow-up
grid one acquisition — the annotation's grid= this series is motion-corrupted
roi one region, by region id this region is suspicious
instance one object this lesion is calcified
slice one plane this slice is non-diagnostic
w.add_classification("quality", {"non_diagnostic": 1.0},
                     scope="timepoint", timepoints=["tp1"], scope_ids=[1])

A scope needs a scope_ids to point at. timepoints records which visits the annotation concerns; scope_ids is what keys each assertion to one of them, by timepoint index. Give only timepoints and the assertion comes back with scope_id=None, so assertions() and by_scope_id() cannot tell you which visit it was about — which is the entire point of a timepoint scope.

The same applies to roi, instance and slice: the scope names the kind of thing, scope_ids names the thing — a region id, an instance_id, a slice index.

Change labels span an interval

A label that describes a difference is a classification naming both visits:

w.add_classification("response", {"progressive_disease": 1.0},
                     scope="sample", timepoints=["tp0", "tp1"],
                     schemes=["RECIST 1.1"])
c = s.annotations["response"]
c.timepoints        # ("tp0", "tp1") — a statement about the interval
c.labels

Because both visits are in one file, a change label has a referent. Split across two files it would be a claim about a filename.

timepoints on a change label is not "which visits this applies to" but "which interval this describes". A label naming ["tp0", "tp1"] is a statement about what happened between them, so it is neither tp0's label nor tp1's, and reading it as either is how a progression label gets attributed to the baseline scan that predates it.

Pass them in acquisition order. The writer stores the sequence you give it verbatim — it does not sort or normalise — and TimepointPairSampler matches a change label to a visit pair by comparing the tuples exactly. Pass ["tp1", "tp0"] and the label is stored reversed, the sampler's forward pair finds nothing, and paired training silently sees no label at all:

w.add_classification("response", {...}, timepoints=["tp1", "tp0"])   # reversed
# sampler pair ("tp0", "tp1") -> label None.  No error, no warning.

Order the pair the way the sample declares its timepoints — s.timepoints.ids is that order.

Read them back

c = s.annotations["response"]
c.scope             # "sample"
c.timepoints        # ("tp0", "tp1")
c.labels            # {"progressive_disease": 1.0}
c.schemes           # ("RECIST 1.1",)
c.is_change_label   # True — sample scope, more than one timepoint

labels maps a class key to a confidence, so a one-hot label and a soft label have the same shape and nothing has to know which it is holding. A value of 0.0 is an explicit negative — assessed and not present — which is different from a class nobody assessed (spec §9).

Where one annotation holds several assertions per class — one per visit, one per lesion — read them per scope unit instead:

q = s.annotations["quality"]
q.by_scope_id()     # {1: (Assertion(...),)} — keyed by timepoint index here
q.assertions()      # every assertion, with its class, value and scope_id