Measuring Inter-Annotator Agreement with Cohen’s Kappa

Cohen’s kappa scores how much two annotators agree on class labels beyond what chance would produce given their own labelling habits. For matched features it takes their two class vectors and returns a number from below zero (worse than chance) to one (perfect). It exists because raw percentage agreement is inflated by class imbalance: on a batch where 92% of features are buildings, two annotators who call everything a building agree 92% of the time and demonstrate no skill at all. Kappa returns 0.0 for that pair, which is the honest answer. This guide computes it over geospatial features, reads the confusion matrix that says why it is low, and covers the two situations where kappa itself misleads.

Why the Chance Correction Matters Here

Geospatial label distributions are extreme. A building-footprint project is mostly buildings; a land-cover project over farmland is mostly one crop class; an infrastructure project may have one class at 95% and four at one percent each. Under those distributions, raw agreement carries almost no information — it is a restatement of the class prior.

Kappa’s correction is simple. Expected agreement is what you would get if each annotator independently drew labels from their own observed distribution. Observed agreement is what actually happened. Kappa is the fraction of the available improvement that was achieved:

code
kappa = (p_observed − p_expected) / (1 − p_expected)

When p_expected is 0.85 because one class dominates, an observed 0.92 yields kappa 0.47 — mediocre. When p_expected is 0.30 on a balanced taxonomy, an observed 0.92 yields kappa 0.89 — excellent. Same raw number, opposite verdicts, and the second is the one that reflects skill.

0.92 observed agreement, two different verdicts Under a balanced taxonomy, chance agreement is about 0.30, so an observed 0.92 uses most of the available headroom and kappa is 0.89. Under a taxonomy where one class holds 92 percent of features, chance agreement is already 0.85, leaving almost no headroom, and the same observed 0.92 yields kappa 0.47. balanced taxonomy 5 classes, none over 30% chance 0.30 skill — the headroom actually used observed 0.92 · expected 0.30 → kappa 0.89 one class dominates top class 92% chance 0.85 — nearly all of it observed 0.92 · expected 0.85 → kappa 0.47 the gap between the two bars is what kappa measures, and raw agreement ignores

Step-by-Step Implementation

Step 1 — Install and Build the Paired Vectors

bash
pip install scikit-learn==1.5.1 geopandas==0.14.4 pandas==2.2.2 numpy==1.26.4

Kappa needs two aligned label vectors, one per annotator, over the same matched features. The matching itself — deciding which of A’s polygons corresponds to which of B’s — is the step described in annotation quality metrics and agreement; this guide starts from its output.

python
import pandas as pd
import geopandas as gpd

def paired_labels(a: gpd.GeoDataFrame, b: gpd.GeoDataFrame, pairs: pd.DataFrame,
                  class_field: str = "class_name") -> tuple[list[str], list[str]]:
    """Two aligned class vectors over the matched pairs, in a stable order."""
    pairs = pairs.sort_values("ia").reset_index(drop=True)
    ya = [a.loc[int(r.ia), class_field] for r in pairs.itertuples()]
    yb = [b.iloc[int(r.ib)][class_field] for r in pairs.itertuples()]
    return ya, yb

Sorting the pairs is not cosmetic. Kappa itself is order-independent, but the confusion matrix you will read next is easier to diff between batches when the row order is stable.

Step 2 — Fix the Label Set From the Taxonomy

Passing the labels explicitly, from the taxonomy rather than from the batch, keeps matrices comparable when a rare class happens to be absent.

python
import json
from sklearn.metrics import cohen_kappa_score, confusion_matrix

def load_taxonomy_labels(path: str) -> list[str]:
    with open(path, encoding="utf-8") as fh:
        return sorted(json.load(fh)["classes"])

def agreement(ya: list[str], yb: list[str], labels: list[str]) -> dict:
    """Kappa, raw agreement and the confusion matrix over a fixed label set."""
    n = len(ya)
    raw = sum(x == y for x, y in zip(ya, yb)) / n if n else float("nan")
    return {
        "n_pairs": n,
        "kappa": float(cohen_kappa_score(ya, yb, labels=labels)) if n else float("nan"),
        "raw_agreement": float(raw),
        "labels": labels,
        "matrix": confusion_matrix(ya, yb, labels=labels).tolist(),
    }

Reporting n_pairs alongside the score is what stops a kappa computed on eleven features being read with the same confidence as one computed on nine hundred.

Step 3 — Read the Confusion Matrix, Not Just the Number

A low kappa is a symptom. The matrix says which disagreement produced it, and the shape of the answer determines who fixes it.

Where the off-diagonal mass sits decides who fixes it On the left almost all disagreement is in a single pair of cells, orchard against cropland, which means the taxonomy has not separated those two on this imagery. On the right the same total disagreement is spread thinly across many pairs, which is a care-and-training pattern rather than a definitions problem. concentrated — fix the taxonomy bldgcrop orchwatr bldgcrop orchwatr 412288 96140 6158 119 of 127 disagreements are orchard ↔ cropland no annotator training fixes a missing definition spread — train the annotators bldgcrop orchwatr bldgcrop orchwatr 398274 88132 127 disagreements across all twelve pairs no definition is missing — attention is both matrices report the same kappa; only the shape says what to do about it
python
def dominant_confusion(result: dict, top_k: int = 3) -> list[tuple[str, str, int]]:
    """The largest off-diagonal cells: which class pairs the disagreement lives in."""
    labels, m = result["labels"], result["matrix"]
    cells = [(labels[i], labels[j], m[i][j])
             for i in range(len(labels)) for j in range(len(labels)) if i != j and m[i][j]]
    return sorted(cells, key=lambda c: c[2], reverse=True)[:top_k]

If one pair holds most of the mass — orchard against cropland, say — the taxonomy has not distinguished them on this imagery, and no amount of annotator training will help. If the off-diagonal mass is spread evenly, the disagreement is about care rather than definitions, and it is a training conversation.

Step 4 — Go Pairwise Across Annotators

With three or more people, the mean of pairwise kappas answers a question Fleiss’ kappa cannot: is one person the outlier?

python
from itertools import combinations

def pairwise_matrix(labels_by_annotator: dict[str, list[str]], labels: list[str]) -> pd.DataFrame:
    """Kappa for every pair of annotators over their common features."""
    names = sorted(labels_by_annotator)
    out = pd.DataFrame(index=names, columns=names, dtype=float)
    for x, y in combinations(names, 2):
        k = cohen_kappa_score(labels_by_annotator[x], labels_by_annotator[y], labels=labels)
        out.loc[x, y] = out.loc[y, x] = round(float(k), 3)
    return out

An annotator whose row is uniformly low disagrees with everyone, which is one conversation. A matrix where every cell is low is a taxonomy problem affecting the whole team, which is a different one.

One outlier, or everybody In the left matrix three annotators agree well with each other and all disagree with the fourth, whose row is uniformly low: that is one person's calibration. In the right matrix every pair sits between 0.38 and 0.44, so nobody is the outlier and the classes themselves are not separable on this imagery. one annotator is the outlier A B C D A B C D 0.860.840.41 0.860.880.39 0.840.880.37 0.410.390.37 D's whole row is low — a calibration conversation with one person nobody is the outlier A B C D A B C D 0.420.400.38 0.420.440.41 0.400.440.39 0.380.410.39 every pair is low — the classes are not separable on this imagery a single mean kappa reports 0.62 on the left and 0.41 on the right, and hides which of the two you are looking at

Parameters and Thresholds Reference

Quantity Value Meaning
Kappa floor, trainable class 0.60 Below this, the class is a taxonomy question
Kappa target, crisp class ≥ 0.80 Buildings, roads, solar arrays on decent imagery
Minimum pairs for a per-class score ~50 Below that the estimate swings on a handful of features
Self-agreement ceiling measure once No pair of people beats one person against themselves
Reporting cadence per batch, per class A project-level mean hides the class that is failing

Common Errors and Fixes

ValueError: Number of classes ... does not match Root cause: labels was inferred from one vector rather than passed explicitly. Fix: always pass the full taxonomy list, as in Step 2.

Kappa is nan Root cause: every feature in the batch has the same class in both vectors, so the expected-agreement denominator is zero. Fix: report raw_agreement and n_pairs instead, and note that kappa is undefined here — it is not zero, and reporting it as zero is a mistake that reads as a broken team.

Kappa is negative Root cause: the two annotators systematically disagree — often because one is using an old taxonomy version where two class names swapped meaning. Fix: check the taxonomy version each annotation was made under before concluding anything about people, using the versioned taxonomy described in defining ROI label taxonomies.

Agreement collapses after adding a class Root cause: a new class carved out of an existing one, so features that were unambiguous now have two plausible labels. Fix: expected. Re-adjudicate a sample under the new taxonomy and treat the pre-change kappa as belonging to a different measurement.

Frequently Asked Questions

# Does kappa work for multi-label annotation?

Not directly — it assumes exactly one class per feature. Where a feature can carry several labels, compute a per-label binary kappa (present versus absent) and report the vector. Averaging those into one number reintroduces exactly the imbalance problem kappa exists to remove.

# How large should the overlap set be for a stable kappa?

Enough that each class you care about has roughly fifty matched pairs. For a taxonomy with a long tail that means stratifying the overlap set so rare classes appear in it, rather than sampling tiles uniformly and hoping.

# Should model predictions be scored with kappa against human labels?

You can, and it is a reasonable monitoring signal, but keep the series separate from human-versus-human agreement. Mixing them makes the number move when the model is retrained, so it stops measuring the annotation team — which is what it was for.

# What about weighted kappa?

Weighted kappa penalises some confusions more than others, which fits ordered classes — low, medium, high density — where confusing adjacent levels is milder than confusing extremes. For unordered land-cover classes there is no natural weighting and the unweighted form is the honest choice.

This measurement is one part of the Annotation Quality Metrics & Inter-Annotator Agreement topic within Geospatial Annotation Fundamentals & Architecture.