cai/benchmarks/cyberPII-bench/annotation_metrics.py

529 lines
20 KiB
Python

"""
Annotation Evaluation Script
This script processes entity-tagged text data from multiple annotators and computes performance metrics
(precision, recall, F1, F2) by comparing them to ground truth annotations. It supports multiple annotation
formats (BIO, span labels) and handles reports generation including mistakes analysis, per-entity type
breakdowns, and overall statistics.
Main Features:
- Entity extraction and normalization
- Label generation (BIO, span)
- Per-annotator comparison and evaluation
- Metrics computation (precision, recall, F1, F2)
- Report generation in structured text format
The input csv file should have the following columns:
- id: the id of the row
- target_text: the text to be annotated
- target_text_{annotator}_sanitized: the text annotated by the annotator
Arguments:
--input_csv_path (str): Path to the CSV file containing annotations. The file should include columns identifying the text, entities, and annotator.
--annotator (str): Name of the annotator whose annotations are to be evaluated.
--skip_entities (List[str], optional): List of entity types to skip during evaluation (e.g., EMAIL_ADDRESS, PHONE_NUMBER).
Example usage:
python evaluate_annotations.py --input_csv_path path/to/file.csv --annotator alias0 --skip_entities EMAIL_ADDRESS PHONE_NUMBER
"""
import pandas as pd
import re
import os
from typing import Dict, List, Set, Tuple
from collections import defaultdict
from datetime import datetime
import argparse
# Define valid entity types
VALID_ENTITIES = {
"PERSON",
"PHONE_NUMBER",
"LOCATION",
"CREDIT_CARD",
"CRYPTO",
"IBAN_CODE",
"IP_ADDRESS",
"EMAIL_ADDRESS",
"URL",
"DATE_TIME",
"NIF",
"MEDICAL_LICENSE",
"US_SSN",
"US_BANK_NUMBER",
"US_DRIVER_LICENSE",
"US_ITIN",
"US_PASSPORT",
"ORGANIZATION",
"ADDRESS",
"NRP",
"DNI",
"NIE",
"IBAN",
"EUROPEAN_BANK_ACCOUNT",
}
# ============ DATA NORMALIZATION FUNCTIONS ============
def find_entities_with_positions(
text: str, skip_entities: Set[str] = set()
) -> List[Tuple[str, int, int, str]]:
"""
Find entities marked with brackets and their positions in the text.
Returns: List of (entity_type, start_pos, end_pos, full_tag)
"""
if not isinstance(text, str) or pd.isna(text):
return []
entities = []
valid_entities = VALID_ENTITIES - skip_entities
pattern = r"\[({})\]".format("|".join(valid_entities))
for match in re.finditer(pattern, text):
entity_type = match.group(1)
if entity_type not in skip_entities:
start = match.start()
end = match.end()
full_tag = match.group(0)
entities.append((entity_type, start, end, full_tag))
return sorted(entities, key=lambda x: x[1])
def generate_span_labels(text: str, entities: List[Tuple[str, int, int, str]]) -> str:
"""
Generate span labels in format: start:end:entity_type|start:end:entity_type
"""
if not isinstance(text, str) or pd.isna(text) or not entities:
return ""
spans = []
for entity_type, start, end, _ in entities:
spans.append(f"{start}:{end}:{entity_type}")
return "|".join(spans)
def generate_bio_labels(text: str, entities: List[Tuple[str, int, int, str]]) -> str:
"""
Generate BIO labels for each character in the text
"""
if not isinstance(text, str) or pd.isna(text):
return ""
# Initialize all positions as O (Outside)
bio_labels = ["O"] * len(text)
# Mark entity positions
for entity_type, start, end, _ in entities:
# Mark B (Beginning)
if start < len(bio_labels):
bio_labels[start] = f"B-{entity_type}"
# Mark I (Inside) for the rest of the entity
for i in range(start + 1, end):
if i < len(bio_labels):
bio_labels[i] = f"I-{entity_type}"
return "".join(bio_labels)
def normalize_annotations(
df: pd.DataFrame, annotator_config: Dict[str, Dict[str, str]], skip_entities: Set[str] = set()
) -> pd.DataFrame:
"""
Normalize annotations for ground truth and all annotators.
"""
# First normalize ground truth
ground_truth_entities = df["target_text"].apply(
lambda x: find_entities_with_positions(x, skip_entities)
)
df["span_labels"] = df.apply(
lambda row: generate_span_labels(row["target_text"], ground_truth_entities[row.name]),
axis=1,
)
df["mbert_bio_labels"] = df.apply(
lambda row: generate_bio_labels(row["target_text"], ground_truth_entities[row.name]), axis=1
)
# Then normalize each annotator's data
for annotator, config in annotator_config.items():
target_col = config["target_text"]
if target_col not in df.columns:
print(f"Warning: Column {target_col} not found for annotator {annotator}")
continue
# Fill NaN values with empty string to avoid errors
df[target_col] = df[target_col].fillna("")
# Generate entities and labels
annotator_entities = df[target_col].apply(
lambda x: find_entities_with_positions(x, skip_entities)
)
df[f"span_labels_{annotator}"] = df.apply(
lambda row: generate_span_labels(row[target_col], annotator_entities[row.name]), axis=1
)
df[f"mbert_bio_labels_{annotator}"] = df.apply(
lambda row: generate_bio_labels(row[target_col], annotator_entities[row.name]), axis=1
)
return df
# ============ METRICS CALCULATION FUNCTIONS ============
def calculate_metrics(
df: pd.DataFrame, annotator_config: Dict[str, Dict[str, str]], skip_entities: Set[str] = set()
) -> Dict:
"""
Calculate metrics comparing ground truth with annotators
"""
stats = {
"total_rows": len(df),
"entity_counts": defaultdict(lambda: defaultdict(int)),
"metrics_per_annotator": defaultdict(dict),
"metrics_per_entity_type": defaultdict(lambda: defaultdict(dict)),
"mistakes": defaultdict(list),
}
# First calculate ground truth entities once for all annotators
all_true_entities = []
for idx, row in df.iterrows():
ground_truth = find_entities_with_positions(row["target_text"], skip_entities)
# Store entities with row index for exact matching
for entity in ground_truth:
all_true_entities.append((idx, entity[0], entity[1], entity[2]))
stats["entity_counts"]["ground_truth"][entity[0]] += 1
true_set = set(all_true_entities)
total_ground_truth = len(true_set)
# Process each annotator
for annotator, config in annotator_config.items():
target_col = config["target_text"]
if target_col not in df.columns:
print(f"Warning: Column {target_col} not found in the dataset")
continue
# Collect predicted entities
all_pred_entities = []
# Process each row
for idx, row in df.iterrows():
pred_entities = find_entities_with_positions(row[target_col], skip_entities)
# Store entities with row index for exact matching
for entity in pred_entities:
all_pred_entities.append((idx, entity[0], entity[1], entity[2]))
stats["entity_counts"][annotator][entity[0]] += 1
# Record mistakes
ground_truth = [e for e in all_true_entities if e[0] == idx]
gt_set = {(e[1], e[2], e[3]) for e in ground_truth}
pred_set = {(e[0], e[1], e[2]) for e in pred_entities}
if gt_set != pred_set:
false_positives = list(pred_set - gt_set)
false_negatives = list(gt_set - pred_set)
if false_positives or false_negatives:
stats["mistakes"][annotator].append(
{
"id": row.get("id", idx),
"text": row["target_text"],
"annotated_text": row[target_col],
"ground_truth": list(gt_set),
"prediction": list(pred_set),
"false_positives": false_positives,
"false_negatives": false_negatives,
}
)
# Calculate overall metrics
pred_set = set(all_pred_entities)
tp = len(true_set & pred_set)
fp = len(pred_set - true_set)
fn = len(true_set - pred_set)
precision = tp / len(pred_set) if pred_set else 0
recall = tp / len(true_set) if true_set else 0
f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0
f2 = 5 * (precision * recall) / (4 * precision + recall) if (precision + recall) > 0 else 0
stats["metrics_per_annotator"][annotator] = {
"true_positives": tp,
"false_positives": fp,
"false_negatives": fn,
"precision": precision,
"recall": recall,
"f1_score": f1,
"f2_score": f2,
"total_entities": total_ground_truth, # Use the same ground truth count for all annotators
}
# Calculate per-entity type metrics
for entity_type in VALID_ENTITIES - skip_entities: # Only evaluate non-skipped entities
true_type = {e for e in true_set if e[1] == entity_type}
pred_type = {e for e in pred_set if e[1] == entity_type}
if not true_type and not pred_type:
continue
tp_type = len(true_type & pred_type)
fp_type = len(pred_type - true_type)
fn_type = len(true_type - pred_type)
precision_type = tp_type / len(pred_type) if pred_type else 0
recall_type = tp_type / len(true_type) if true_type else 0
f1_type = (
2 * (precision_type * recall_type) / (precision_type + recall_type)
if (precision_type + recall_type) > 0
else 0
)
f2_type = (
5 * (precision_type * recall_type) / (4 * precision_type + recall_type)
if (precision_type + recall_type) > 0
else 0
)
if tp_type > 0 or fp_type > 0 or fn_type > 0:
stats["metrics_per_entity_type"][annotator][entity_type] = {
"true_positives": tp_type,
"false_positives": fp_type,
"false_negatives": fn_type,
"precision": precision_type,
"recall": recall_type,
"f1_score": f1_type,
"f2_score": f2_type,
"total_entities": len(true_type),
}
return stats
# ============ REPORT GENERATION FUNCTIONS ============
def generate_overall_report(
stats: Dict,
output_dir: str,
input_file: str,
annotator_config: Dict[str, Dict[str, str]],
skip_entities: Set[str] = set(),
):
"""Generate overall statistics report"""
with open(os.path.join(output_dir, "overall_report.txt"), "w") as f:
f.write("=== Overall Annotation Statistics ===\n\n")
# Add input file information
f.write(f"Input File: {input_file}\n")
# Add information about skipped entities
if skip_entities:
f.write(f"\nExcluded Entity Types: {', '.join(sorted(skip_entities))}\n")
# Add annotator configuration information
f.write("\nAnnotator Configurations:\n")
for annotator, config in annotator_config.items():
f.write(f"\n{annotator}:\n")
for key, value in config.items():
f.write(f" {key}: {value}\n")
f.write("\n" + "=" * 50 + "\n\n")
f.write(f"Total rows analyzed: {stats['total_rows']}\n\n")
f.write("Ground Truth Entity Counts:\n")
for entity_type, count in sorted(stats["entity_counts"]["ground_truth"].items()):
f.write(f"[{entity_type}]: {count}\n")
f.write("\nAnnotator Entity Counts:\n")
for annotator in stats["entity_counts"]:
if annotator != "ground_truth":
f.write(f"\n{annotator}:\n")
for entity_type, count in sorted(stats["entity_counts"][annotator].items()):
f.write(f"[{entity_type}]: {count}\n")
def generate_entity_report(
stats: Dict, output_dir: str, annotator_names: List[str], skip_entities: Set[str] = set()
):
"""Generate per-entity type performance report"""
with open(os.path.join(output_dir, "entity_performance.txt"), "w") as f:
f.write("=== Entity Type Performance by Annotator ===\n\n")
# Add information about skipped entities
if skip_entities:
f.write(f"Note: The following entity types were excluded from evaluation:\n")
f.write(f"{', '.join(sorted(skip_entities))}\n\n")
f.write("=" * 50 + "\n\n")
for annotator in annotator_names:
if annotator in stats["metrics_per_entity_type"]:
f.write(f"\n{annotator.upper()}:\n")
for entity_type in sorted(VALID_ENTITIES - skip_entities):
if entity_type in stats["metrics_per_entity_type"][annotator]:
metrics = stats["metrics_per_entity_type"][annotator][entity_type]
f.write(f"\n {entity_type}:\n")
f.write(f" Precision: {metrics['precision']:.4f}\n")
f.write(f" Recall: {metrics['recall']:.4f}\n")
f.write(f" F1 Score: {metrics['f1_score']:.4f}\n")
f.write(f" F2 Score: {metrics['f2_score']:.4f}\n")
f.write(f" True Positives: {metrics['true_positives']}\n")
f.write(f" False Positives: {metrics['false_positives']}\n")
f.write(f" False Negatives: {metrics['false_negatives']}\n")
def generate_mistakes_report(
stats: Dict, output_dir: str, annotator_names: List[str], skip_entities: Set[str] = set()
):
"""Generate detailed mistakes report"""
with open(os.path.join(output_dir, "mistakes.txt"), "w") as f:
f.write("=== Detailed Mistakes Analysis ===\n\n")
# Add information about skipped entities
if skip_entities:
f.write(f"Note: The following entity types were excluded from evaluation:\n")
f.write(f"{', '.join(sorted(skip_entities))}\n\n")
f.write("=" * 50 + "\n\n")
for annotator in annotator_names:
if annotator in stats["mistakes"] and stats["mistakes"][annotator]:
f.write(
f"\n{annotator.upper()} Mistakes ({len(stats['mistakes'][annotator])} total):\n"
)
for mistake in stats["mistakes"][annotator]:
f.write(f"\nExample {mistake['id']}:\n")
f.write(f"Original text: {mistake['text']}\n")
f.write(f"Annotated text: {mistake['annotated_text']}\n")
if mistake["false_negatives"]:
f.write("\nMissed entities (should have been anonymized):\n")
for entity_type, start, end in mistake["false_negatives"]:
f.write(f"- {entity_type} at position {start}-{end}\n")
if mistake["false_positives"]:
f.write("\nIncorrect anonymizations:\n")
for entity_type, start, end in mistake["false_positives"]:
f.write(f"- {entity_type} at position {start}-{end}\n")
f.write("-" * 80 + "\n")
else:
f.write(f"\n{annotator.upper()}: No mistakes found\n")
def generate_metrics_report(
stats: Dict, output_dir: str, annotator_names: List[str], skip_entities: Set[str] = set()
):
"""Generate overall metrics report"""
with open(os.path.join(output_dir, "metrics.txt"), "w") as f:
f.write("=== Overall Metrics by Annotator ===\n\n")
# Add information about skipped entities
if skip_entities:
f.write(f"Note: The following entity types were excluded from evaluation:\n")
f.write(f"{', '.join(sorted(skip_entities))}\n\n")
f.write("=" * 50 + "\n\n")
for annotator in annotator_names:
if annotator in stats["metrics_per_annotator"]:
metrics = stats["metrics_per_annotator"][annotator]
f.write(f"\n{annotator.upper()}:\n")
f.write(f" Total Entities in Ground Truth: {metrics['total_entities']}\n")
f.write(f" True Positives: {metrics['true_positives']}\n")
f.write(f" False Positives: {metrics['false_positives']}\n")
f.write(f" False Negatives: {metrics['false_negatives']}\n")
f.write(f" Precision: {metrics['precision']:.4f}\n")
f.write(f" Recall: {metrics['recall']:.4f}\n")
f.write(f" F1 Score: {metrics['f1_score']:.4f}\n")
f.write(f" F2 Score: {metrics['f2_score']:.4f}\n")
def get_output_dir(base_dir: str) -> str:
"""Create and return the output directory name with date and sequence number in the same directory as the input file"""
# Get the directory of the input file
base_name = f"output_metrics_{datetime.now().strftime('%Y%m%d')}"
counter = 1
while True:
dir_name = os.path.join(base_dir, f"{base_name}_{counter}")
if not os.path.exists(dir_name):
os.makedirs(dir_name)
return dir_name
counter += 1
# ============ MAIN EXECUTION ============
def main():
parser = argparse.ArgumentParser(description="Annotator Evaluation Script")
parser.add_argument("--input_csv_path", type=str, required=True, help="Path to input CSV file")
parser.add_argument(
"--annotator",
type=str,
required=True,
help="Annotator used to generate the input CSV file, options: alias0, privateAI",
)
parser.add_argument(
"--skip_entities",
type=str,
nargs="+",
default=[],
help="List of entity types to skip in evaluation",
)
args = parser.parse_args()
# Convert skip_entities to a set for faster lookups
skip_entities = set(args.skip_entities)
# Validate skip_entities
invalid_entities = skip_entities - VALID_ENTITIES
if invalid_entities:
raise ValueError(
f"Invalid entities to skip: {invalid_entities}. Valid entities are: {VALID_ENTITIES}"
)
df = pd.read_csv(args.input_csv_path, sep=";")
ANNOTATOR_CONFIG = {
args.annotator: {
"target_text": f"target_text_{args.annotator}_sanitized",
"span_labels": f"span_labels_{args.annotator}_sanitized",
"mbert_bio_labels": f"mbert_bio_labels_{args.annotator}_sanitized",
}
}
print("Normalizing annotations...")
df = normalize_annotations(df, ANNOTATOR_CONFIG, skip_entities)
print("Calculating metrics...")
stats = calculate_metrics(df, ANNOTATOR_CONFIG, skip_entities)
# Determine output directory
base_dir = os.path.dirname(os.path.abspath(args.input_csv_path))
dir_annotator = os.path.join(base_dir, args.annotator)
print(dir_annotator)
output_dir = get_output_dir(dir_annotator)
print(output_dir)
print("Generating reports...")
generate_overall_report(stats, output_dir, args.input_csv_path, ANNOTATOR_CONFIG, skip_entities)
generate_entity_report(stats, output_dir, list(ANNOTATOR_CONFIG.keys()), skip_entities)
generate_mistakes_report(stats, output_dir, list(ANNOTATOR_CONFIG.keys()), skip_entities)
generate_metrics_report(stats, output_dir, list(ANNOTATOR_CONFIG.keys()), skip_entities)
print(f"\nAnalysis complete. Reports have been generated in {output_dir}/")
if skip_entities:
print(
f"Note: The following entities were excluded from evaluation: {', '.join(sorted(skip_entities))}"
)
if __name__ == "__main__":
main()