Spaces:
Paused
Paused
| """ | |
| Inter-Annotator Agreement Calculation Module | |
| This module provides functionality for calculating inter-annotator agreement metrics, | |
| including Krippendorff's alpha, Cohen's kappa (pairwise), and Fleiss' kappa | |
| (N raters), from annotation data. It supports both rating agreement (interval | |
| metric) and skip agreement (nominal metric) calculations. | |
| The module processes annotation files in JSON format and outputs agreement statistics | |
| along with a CSV file containing the processed annotation data. | |
| """ | |
| import argparse | |
| from itertools import combinations | |
| import simpledorff | |
| from simpledorff.metrics import * | |
| import ujson | |
| import pandas as pd | |
| from collections import defaultdict | |
| import numpy as np | |
| def get_nans(shape): | |
| """ | |
| Create a numpy array filled with NaN values. | |
| Args: | |
| shape: The shape of the array to create | |
| Returns: | |
| numpy.ndarray: Array filled with NaN values | |
| """ | |
| ar = np.empty(shape) | |
| ar[:] = np.NaN | |
| return ar | |
| def cohen_kappa_pairwise(reliability_df): | |
| """ | |
| Compute Cohen's kappa for every pair of annotators and return aggregate stats. | |
| Cohen's kappa is defined for exactly two raters. With N>2 raters we compute | |
| kappa for each pair on the items they both rated, then return the mean and the | |
| per-pair breakdown. Pairs that share fewer than 2 items are skipped. | |
| Args: | |
| reliability_df: long-format DataFrame with columns | |
| unit (item id), annotator (user), annotation (label value). | |
| Returns: | |
| dict with keys: mean_kappa (float | None), pairs (list of | |
| {annotator_a, annotator_b, kappa, n_items}), n_pairs_evaluated, | |
| n_pairs_skipped. | |
| """ | |
| from sklearn.metrics import cohen_kappa_score | |
| annotators = sorted(reliability_df["annotator"].unique()) | |
| pairs = [] | |
| skipped = 0 | |
| for a, b in combinations(annotators, 2): | |
| a_rows = reliability_df[reliability_df["annotator"] == a].set_index("unit")["annotation"] | |
| b_rows = reliability_df[reliability_df["annotator"] == b].set_index("unit")["annotation"] | |
| shared = a_rows.index.intersection(b_rows.index) | |
| if len(shared) < 2: | |
| skipped += 1 | |
| continue | |
| y_a = a_rows.loc[shared].astype(str).tolist() | |
| y_b = b_rows.loc[shared].astype(str).tolist() | |
| try: | |
| kappa = float(cohen_kappa_score(y_a, y_b)) | |
| except Exception: | |
| skipped += 1 | |
| continue | |
| pairs.append({ | |
| "annotator_a": a, | |
| "annotator_b": b, | |
| "kappa": round(kappa, 4), | |
| "n_items": int(len(shared)), | |
| }) | |
| mean_kappa = (sum(p["kappa"] for p in pairs) / len(pairs)) if pairs else None | |
| return { | |
| "mean_kappa": round(mean_kappa, 4) if mean_kappa is not None else None, | |
| "pairs": pairs, | |
| "n_pairs_evaluated": len(pairs), | |
| "n_pairs_skipped": skipped, | |
| } | |
| def fleiss_kappa(reliability_df): | |
| """ | |
| Compute Fleiss' kappa for N raters over a categorical label set. | |
| Fleiss' kappa assumes the same number of ratings per item but tolerates | |
| different rater identities per item. Items with fewer than 2 ratings are | |
| dropped; the remaining items are padded by repeating their available | |
| ratings up to the per-item rater count (`n_raters = max ratings per item`). | |
| When per-item rater counts vary widely the metric is approximate; we report | |
| `n_raters` and `n_items_evaluated` so the caller can judge. | |
| Args: | |
| reliability_df: long-format DataFrame with columns | |
| unit (item id), annotator (user), annotation (label value). | |
| Returns: | |
| dict with keys: kappa (float | None), n_items_evaluated (int), | |
| n_raters (int), n_categories (int), interpretation (str). | |
| """ | |
| if reliability_df.empty: | |
| return {"kappa": None, "n_items_evaluated": 0, "n_raters": 0, | |
| "n_categories": 0, "interpretation": "No data"} | |
| df = reliability_df.copy() | |
| df["annotation"] = df["annotation"].astype(str) | |
| counts_by_item = df.groupby(["unit", "annotation"]).size().unstack(fill_value=0) | |
| items_with_ratings = counts_by_item.sum(axis=1) | |
| counts_by_item = counts_by_item.loc[items_with_ratings >= 2] | |
| if counts_by_item.empty: | |
| return {"kappa": None, "n_items_evaluated": 0, "n_raters": 0, | |
| "n_categories": int(df["annotation"].nunique()), | |
| "interpretation": "No items with >=2 raters"} | |
| n_raters = int(counts_by_item.sum(axis=1).max()) | |
| n_items = int(counts_by_item.shape[0]) | |
| n_categories = int(counts_by_item.shape[1]) | |
| matrix = counts_by_item.to_numpy(dtype=float) | |
| row_sums = matrix.sum(axis=1, keepdims=True) | |
| row_sums[row_sums == 0] = 1.0 | |
| matrix = matrix * (n_raters / row_sums) | |
| p_j = matrix.sum(axis=0) / (n_items * n_raters) | |
| if n_raters < 2: | |
| return {"kappa": None, "n_items_evaluated": n_items, "n_raters": n_raters, | |
| "n_categories": n_categories, | |
| "interpretation": "Need >=2 raters per item"} | |
| p_i = (np.sum(matrix ** 2, axis=1) - n_raters) / (n_raters * (n_raters - 1)) | |
| p_bar = float(p_i.mean()) | |
| p_e = float(np.sum(p_j ** 2)) | |
| if p_e >= 1.0: | |
| kappa = 1.0 if p_bar >= 1.0 else 0.0 | |
| else: | |
| kappa = (p_bar - p_e) / (1 - p_e) | |
| return { | |
| "kappa": round(float(kappa), 4), | |
| "n_items_evaluated": n_items, | |
| "n_raters": n_raters, | |
| "n_categories": n_categories, | |
| "interpretation": interpret_kappa(kappa), | |
| } | |
| def interpret_kappa(kappa): | |
| """Landis & Koch (1977) interpretation bands for kappa-family metrics.""" | |
| if kappa is None: | |
| return "No agreement computable" | |
| if kappa < 0: | |
| return "Worse than chance" | |
| if kappa < 0.21: | |
| return "Slight" | |
| if kappa < 0.41: | |
| return "Fair" | |
| if kappa < 0.61: | |
| return "Moderate" | |
| if kappa < 0.81: | |
| return "Substantial" | |
| return "Almost perfect" | |
| def flatten(annotations): | |
| """ | |
| Flatten annotation data structure for processing. | |
| Converts a list of annotation dictionaries into a format where each | |
| annotation is a dictionary mapping user IDs to their labels. | |
| Args: | |
| annotations: List of annotation dictionaries | |
| Returns: | |
| list: Flattened annotation data structure | |
| Example: | |
| Input: [{"user": "user1", "label": "positive"}, {"user": "user2", "label": "negative"}] | |
| Output: [{"user1": "positive", "user2": "negative"}] | |
| """ | |
| return [{a["user"]: a["label"] for a in ann} for ann in annotations] | |
| def main(args): | |
| """ | |
| Main function for calculating inter-annotator agreement. | |
| This function processes annotation data from a JSON file, calculates | |
| Krippendorff's alpha for both rating agreement and skip agreement, | |
| and outputs the results along with a CSV file of the processed data. | |
| Args: | |
| args: Command line arguments containing file paths | |
| Side Effects: | |
| - Reads annotation data from input file | |
| - Prints agreement statistics to console | |
| - Writes processed data to output CSV file | |
| The function processes the first 385 annotations by default and handles | |
| missing annotations and skipped items appropriately. | |
| """ | |
| # Load annotation data from JSON file | |
| with open(args.file, "r") as f: | |
| annotations = [ujson.loads(line)["annotations"] for line in f] | |
| # Extract unique user IDs from all annotations | |
| users = set([a["user"] for ann in annotations for a in ann]) | |
| annotations = flatten(annotations) | |
| # Limit to first 385 annotations (configurable limit) | |
| annotations = annotations[:385] | |
| # Create data matrix for agreement calculation | |
| # Each row represents a user, each column represents an annotation | |
| # -1 values indicate skipped annotations, NaN indicates missing annotations | |
| data = [ | |
| [np.nan if user not in a or int(a[user]) == -1 else int(a[user]) for a in annotations] | |
| for user in users | |
| ] | |
| # Create skip data matrix (boolean indicating if annotation was skipped) | |
| skip_data = [ | |
| [np.nan if user not in a else int(a[user]) < 0 for a in annotations] for user in users | |
| ] | |
| # Calculate statistics for each user | |
| labeled = ~np.isnan(data) | |
| skipped = [ | |
| [False if user not in a else int(a[user]) < 0 for a in annotations] for user in users | |
| ] | |
| # Print summary statistics | |
| print("calculating over:") | |
| for user, skip in zip(labeled, skipped): | |
| print("labeled:", sum(user)) | |
| print("skipped:", sum(skip)) | |
| # Count instances where all users provided annotations | |
| print(np.all(labeled, axis=0).sum()) | |
| # Calculate and print Krippendorff's alpha for rating agreement | |
| # Uses interval metric for continuous rating scales | |
| print("rating agreement:") | |
| print(simpledorff.calculate_krippendorffs_alpha(pd.DataFrame(data),metric_fn=interval_metric)) | |
| # Calculate and print Krippendorff's alpha for skip agreement | |
| # Uses nominal metric for binary skip/no-skip decisions | |
| print("skip agreement:") | |
| print(simpledorff.calculate_krippendorffs_alpha(pd.DataFrame(data),metric_fn=nominal_metric)) | |
| # Write processed data to CSV file | |
| with open(args.outfile, "w") as f: | |
| for row in zip(*data): | |
| f.write(",".join([str(a) for a in row]) + "\n") | |
| if __name__ == "__main__": | |
| # Set up command line argument parsing | |
| parser = argparse.ArgumentParser( | |
| description="Calculate Krippendorf's alpha from given JSON file of annotations" | |
| ) | |
| parser.add_argument("file", help="path to JSON file") | |
| parser.add_argument("outfile", help="write path to CSV") | |
| main(parser.parse_args()) | |