Methods
We divide automatic methods for automatic error detection into two categories which we dub flagger and scorer. Flagging means that methods gives a dichotomous, binary judgement whether the label for an instance is correct or erroneous. Scoring methods on the other hand give a percentage estimate on how likely it is that an annotation is erroneous. These correspond to unranked and ranked evaluation from information retrieval and similarly require different evaluation metrics.
Flagger:
Abbreviation |
Method |
Text |
Token |
Span |
Proposed by |
---|---|---|---|---|---|
CL |
Confident Learning |
✓ |
✓ |
✓ |
Northcutt (2021) |
CS |
Curriculum Spotter |
✓ |
Amiri (2018) |
||
DE |
Diverse Ensemble |
✓ |
✓ |
✓ |
Loftsson (2009) |
IRT |
Item Response Theory |
✓ |
✓ |
✓ |
Rodriguez (2021) |
LA |
Label Aggregation |
✓ |
✓ |
✓ |
Amiri (2018) |
LS |
Leitner Spotter |
✓ |
Amiri (2018) |
||
PE |
Projection Ensemble |
✓ |
✓ |
✓ |
Reiss (2020) |
RE |
Retag |
✓ |
✓ |
✓ |
van Halteren (2000) |
VN |
Variation n-Grams |
✓ |
✓ |
Dickinson (2003) |
Scorer:
Abbreviation |
Method |
Text |
Token |
Span |
Proposed by |
---|---|---|---|---|---|
BC |
Borda Count |
✓ |
✓ |
✓ |
Larson (2020) |
CU |
Classification Uncertainty |
✓ |
✓ |
✓ |
Hendrycks (2017) |
DM |
Data Map Confidence |
✓ |
✓ |
✓ |
Swayamdipta (2020) |
DU |
Dropout Uncertainty |
✓ |
✓ |
✓ |
Amiri (2018) |
KNN |
k-Nearest Neighbor Entropy |
✓ |
✓ |
✓ |
Grivas (2020) |
LE |
Label Entropy |
✓ |
✓ |
Hollenstein (2016) |
|
MD |
Mean Distance |
✓ |
✓ |
✓ |
Larson (2019) |
PM |
Prediction Margin |
✓ |
✓ |
✓ |
Dligach (2011) |
WD |
Weighted Discrepancy |
✓ |
✓ |
Hollenstein (2016) |
We further divide methods by their means of annotation error detection and describe how to use each.
Tasks and data
We focus on methods for text classification as well as token and span labeling, but our implementations should be easily adaptable to other tasks. We provide example datasets that can be used to test methods and understand the data formats used.
[ ]:
# Some imports
import awkward as ak
[ ]:
from nessie.dataloader import load_example_text_classification_data, load_example_token_labeling_data
text_data = load_example_text_classification_data().subset(100)
token_data = load_example_token_labeling_data().subset(100)
token_data_flat = token_data.flatten() # Most methods need flat and not nested inputs, therefore, we flatten results here
[ ]:
# Span
from nessie.dataloader import load_example_span_classification_data
from seqeval.metrics.sequence_labeling import get_entities
span_data = load_example_span_classification_data().subset(100)
span_data_flat = span_data.flatten() # Most methods need flat and not nested inputs, therefore, we flatten results here
span_noisy_entities = [e[0] for e in get_entities(span_data.noisy_labels.tolist())]
Different tasks have a different form of their inputs and output dimensions. For instance, token and span labeling typically are given as ragged/nested arrays, that is arrays of a varying second dimension (because the number of tokens is different per sentence). These are flattened when passed to most general methods and unflattened to their original shape. For span labeling, we also work on span-level and not on (BIO) tag level, therefore, spans need to be extracted and outputs from models need
to be aggregated from token to spans. We also need to align predictions of models with the given span labels, as they can differ due to different boundary predictions. nessie
provides helper functions for these operations.
Variation based
Methods based on the variation principle leverage the observation that similar surface forms are often only annotated with one or a few distinct labels. If an instance is annotated with a different, rarer label, then it is more often than not an annotation error or an inconsistency. Variation based methods are relatively easy to implement and can be used in settings for which it is difficult to train a machine learning model, being it because of low-resource scenarios or a task that is difficult to train models on. Their main disadvantage though is that they need overlapping surface forms to perform well, which is not the case in settings like text classification or datasets with diverse instances.
Variation n-grams
For each instance, n-gram contexts of different sizes are collected and compared to others. If the label for an instance disagrees with labels from other instances in the same context, then it is considered an error.
[ ]:
# Token classification
from nessie.detectors import VariationNGrams
detector = VariationNGrams()
flags = detector.score(sentences=token_data.sentences, tags=token_data.noisy_labels)
[ ]:
# Span classification
from nessie.detectors import VariationNGramsSpan
detector = VariationNGramsSpan()
flagged_spans = detector.score(sentences=span_data.sentences, tags=span_data.noisy_labels)
Model Based
Machine learning models trained on the to-be-corrected dataset can be used to find annotation errors. Models in this context are usually trained via cross-validation and the respective holdout set is used to detect errors. After all folds have been used as holdout, the complete dataset is analyzed. The training itself is not part of the method and is not altered by it, in contrast to other methods like the ones based on training dynamics. Several ways have been devised for model-based annotation error detection, which are described in the following. As the name implies, m-based annotation detection methods need trained models to obtain predictions or probabilities. We already implemented the most common models for you to be ready to use. We provide the following models:
Text classification:
Class name |
Description |
---|---|
FastTextTextClassifier |
Fasttext |
FlairTextClassifier |
Flair |
LgbmTextClassifier |
LightGBM with handcrafted features |
LgbmTextClassifier |
LightGBM with S-BERT features |
MaxEntTextClassifier |
Logistic Regression with handcrafted features |
MaxEntTextClassifier |
Logistic with S-BERT features |
TransformerTextClassifier |
Transformers |
Sequence Classification:
Class name |
Description |
---|---|
FlairSequenceTagger |
Flair |
CrfSequenceTagger |
CRF with handcrafted features |
MaxEntSequenceTagger |
Maxent sequence tagger |
TransformerSequenceTagger |
Transformer |
You can add your own models by implementing the respective abstract class for TextClassifier or SequenceTagger. Models are typicall trained via cross-validation, for which we provide a helper class.
[ ]:
from nessie.helper import CrossValidationHelper
from nessie.models.text import DummyTextClassifier
from nessie.models.tagging import DummySequenceTagger
num_splits = 3 # Usually 10 is a good number, we use 3 for simplicity
# Text
cv = CrossValidationHelper(n_splits=num_splits)
tc_result = cv.run(text_data.texts, text_data.noisy_labels, DummyTextClassifier())
# Token
cv = CrossValidationHelper(n_splits=num_splits)
tl_result = cv.run_for_ragged(token_data.sentences, token_data.noisy_labels, DummySequenceTagger())
tl_result_flat = tl_result.flatten() # Most methods need flat and not nested inputs, therefore, we flatten results here
# Span
from nessie.task_support.span_labeling import align_span_labeling_result
cv = CrossValidationHelper(n_splits=num_splits)
sl_result = cv.run_for_ragged(span_data.sentences, span_data.noisy_labels, DummySequenceTagger())
# We extract spans from BIO tags, align them with model predictions and aggregate token level probabilities to span level
sl_result = align_span_labeling_result(span_data.noisy_labels, sl_result)
Retag
A simple way to use a trained model for annotation error detection is to use model predictions directly; when disagreeing with the given labels to correct, instances are flagged as annotation errors.
[ ]:
from nessie.detectors import Retag
detector = Retag()
# Text
flags = detector.score(text_data.noisy_labels, tc_result.predictions)
# Token
flags_flat = detector.score(token_data_flat.noisy_labels, tl_result_flat.predictions)
flags = ak.unflatten(flags_flat, token_data.sizes)
# Span
flags = detector.score(sl_result.labels, sl_result.predictions)
Classification Uncertainty
Probabilistic classification models assign probabilities which are typically higher for instances that are correctly labeled compared to erroneous ones. Therefore, the class probabilities of the noisy labels can be used to score these for being an annotation error.
[ ]:
from nessie.detectors import ClassificationUncertainty
detector = ClassificationUncertainty()
# Text
scores_cu_text = detector.score(labels=text_data.noisy_labels, probabilities=tc_result.probabilities, le=tc_result.le)
# Token
scores_cu_token_flat = detector.score(labels=token_data_flat.noisy_labels, probabilities=tl_result_flat.probabilities, le=tl_result_flat.le)
scores_cu_token = ak.unflatten(scores_cu_token_flat, token_data.sizes)
# Span
scores_cu_span = detector.score(labels=sl_result.labels, probabilities=sl_result.probabilities, le=sl_result.le)
Prediction Margin
Inspired by active learning, Prediction Margin uses the probabilities of the two highest scoring labels for an instance. The resulting score is simply their difference. The intuition behind this is that samples with smaller margin are more likely to be an annotation error, since the smaller the decision margin is the more unsure the model was.
[ ]:
from nessie.detectors import PredictionMargin
detector = PredictionMargin()
# Text
scores_pm_text = detector.score(labels=text_data.noisy_labels, probabilities=tc_result.probabilities, le=tc_result.le)
# Token
scores_pm_token_flat = detector.score(labels=token_data_flat.noisy_labels, probabilities=tl_result_flat.probabilities, le=tl_result_flat.le)
scores_pm_token = ak.unflatten(scores_pm_token_flat, token_data.sizes)
# Span
scores_pm_span = detector.score(labels=sl_result.labels, probabilities=sl_result.probabilities, le=sl_result.le)
Confident Learning
This method estimates the joint distribution of noisy and true labels. A threshold is then learnt (the average self-confidence) and instances whose computed probability of having the correct label is below the respective threshold are flagged as erroneous.
[ ]:
from nessie.detectors import ConfidentLearning
detector = ConfidentLearning()
# Text
flags = detector.score(labels=text_data.noisy_labels, probabilities=tc_result.probabilities, le=tc_result.le)
# Token
flags_flat = detector.score(labels=token_data_flat.noisy_labels, probabilities=tl_result_flat.probabilities, le=tl_result_flat.le)
flags = ak.unflatten(flags_flat, token_data.sizes)
# Span
flags = detector.score(labels=sl_result.labels, probabilities=sl_result.probabilities, le=sl_result.le)
Dropout Uncertainty
This method uses Monte Carlo dropout, that is, dropout during inference over several runs with different seeds to estimate the uncertainty of an underlying model. There are different acquisition methods to compute uncertainty from the stochastic passes, we use entropy over runs.
[ ]:
from nessie.detectors import DropoutUncertainty
detector = DropoutUncertainty()
# Text
scores_du_text = detector.score(tc_result.repeated_probabilities)
# Token
scores_du_token_flat = detector.score(token_data_flat.repeated_probabilities)
scores_du_token = ak.unflatten(scores_du_token_flat, token_data.sizes)
# Span
scores_du_span = detector.score(sl_result.repeated_probabilities)
Label Aggregation
Given repeated predictions obtained via Monte Carlo Dropout, one can use aggregation techniques from crowdsourcing like Dawid-Skene or MACE to adjudicate the resulting repeated predictions.
[ ]:
from nessie.detectors import LabelAggregation
detector = LabelAggregation()
# Text
flags = detector.score(labels=text_data.noisy_labels, repeated_probabilities=tc_result.repeated_probabilities, le=tc_result.le)
# Token
flags_flat = detector.score(labels=token_data_flat.noisy_labels, repeated_probabilities=tl_result_flat.repeated_probabilities, le=tl_result_flat.le)
flags = ak.unflatten(flags_flat, token_data.sizes)
# Span
flags = detector.score(labels=sl_result.labels, repeated_probabilities=sl_result.repeated_probabilities, le=sl_result.le)
Training Dynamics
Methods based on training dynamics use information derived from how a model behaves during training and how predictions change over the course of its training. The assumption behind both of these methods is that instances that are perceived harder or misqualified more frequently are more often annotation errors than easier ones.
Curriculum Spotter and Leitner Spotter require that the instances can be scheduled independently. This is for instance not the case for sequence labeling, as the model trains on complete sentences and not individual tokens or span. Even if they have different difficulties, they would end up in the same batch nonetheless.
The implementation requires access to information during training, which is solved via callbacks. As only transformers have this avaiable, we only implmenet training dynamic methods for transformers.
Curriculum Spotter
This trains a model via curriculum learning, where the network trains on easier instances during earlier epochs and is then gradually introduced to harder instances. Instances then are ranked by how hard they were perceived during training.
[ ]:
from nessie.detectors import CurriculumSpotter
detector = CurriculumSpotter(max_epochs=2)
scores_cs_text = detector.score(texts=text_data.texts, labels=text_data.noisy_labels)
Leitner Spotter
This method adapts the idea of the Zettelkasten to model training. There, difficult instances are presented more often during training than easier ones.
[ ]:
from nessie.detectors import LeitnerSpotter
detector = LeitnerSpotter(max_epochs=2)
scores_ls_text = detector.score(texts=text_data.texts, labels=text_data.noisy_labels)
Data Map Confidence
This method uses the class probability for each instance’s gold label across epochs as a measure of confidence. It has been shown that low confidence correlates well with an item having a wrong label.
[ ]:
# Text
from nessie.detectors import DataMapConfidence
from nessie.models.text import TransformerTextClassifier
detector = DataMapConfidence(TransformerTextClassifier(max_epochs=2))
scores_dm_text = detector.score(text_data.texts, text_data.noisy_labels)
[ ]:
from nessie.detectors import DataMapConfidence
from nessie.models.tagging import TransformerSequenceTagger
# Token
detector = DataMapConfidence(TransformerSequenceTagger(max_epochs=2), needs_flattening=True)
scores_dm_token = detector.score(token_data.sentences, token_data.noisy_labels)
scores_dm_token_flat = ak.flatten(scores_dm_token).to_numpy()
[ ]:
from nessie.detectors import DataMapConfidence
from nessie.models.tagging import TransformerSequenceTagger
from nessie.task_support.span_labeling import aggregate_scores_to_spans
# Span
detector = DataMapConfidence(TransformerSequenceTagger(max_epochs=2), needs_flattening=True)
scores_dm_span = detector.score(span_data.sentences, span_data.noisy_labels)
scores_dm_span = aggregate_scores_to_spans(span_data.noisy_labels, scores_dm_span)
scores_dm_span = ak.flatten(scores_dm_span).to_numpy()
Vector Space Proximity
Approaches of this kind leverage dense embeddings of tokens, spans, and texts into a vector space and use their distribution therein. The distance of an instance to semantically similar instances is expected to be smaller than the distance to semantically different ones. Embeddings are typically obtained by using BERT-type models for tokens and spans or S-BERT for sentences.
[ ]:
# Prepare the embeddings
from nessie.models.featurizer import CachedSentenceTransformer, FlairTokenEmbeddingsWrapper
from flair.embeddings import TransformerWordEmbeddings
# Text
sentence_embedder = CachedSentenceTransformer()
sentence_embeddings = sentence_embedder.embed(text_data.texts)
# Token
token_embedder = FlairTokenEmbeddingsWrapper(TransformerWordEmbeddings())
token_embeddings = token_embedder.embed(token_data.sentences, flat=True)
# Span
from nessie.task_support.span_labeling import embed_spans
span_embeddings = embed_spans(span_data.sentences, span_data.noisy_labels, token_embedder)
Mean Distance
This method computes the centroid of each class by averaging vector embeddings of the respective instances. Items are then scored by the distance from their embedding vector to their centroid. The underlying assumption is that semantically similar items should have the same label and be close together (and thereby to the mean embedding) in the vector space.
[ ]:
from nessie.detectors import MeanDistance
detector = MeanDistance()
# Text
scores_md_text = detector.score(labels=text_data.noisy_labels, embedded_instances=sentence_embeddings)
# Token
scores_md_token_flat = detector.score(labels=token_data_flat.noisy_labels, embedded_instances=token_embeddings)
scores_md_token = ak.unflatten(scores_md_token_flat, token_data.sizes)
# Span
scores_md_span = detector.score(labels=span_noisy_entities, embedded_instances=ak.flatten(span_embeddings).to_numpy())
k-Nearest-Neighbor Entropy
For this method, all instances are first embedded into a vector space. Then, for every instance to check, its k nearest neighbors based on Euclidean distance in the vector space are retrieved. Their distances to the item’s embedding vector are then used to compute a distribution over labels via applying softmax. An instance’s score is then the entropy of its distance distribution; if it is large, it indicates uncertainty, hinting at being mislabeled.
[ ]:
from nessie.detectors import KnnEntropy
detector = KnnEntropy()
# Text
scores_knn_text = detector.score(labels=text_data.noisy_labels, embedded_instances=sentence_embeddings)
# Token
scores_knn_token_flat = detector.score(labels=token_data_flat.noisy_labels, embedded_instances=token_embeddings)
scores_knn_token = ak.unflatten(scores_knn_token_flat, token_data.sizes)
# Span
scores_knn_span = detector.score(labels=span_noisy_entities, embedded_instances=ak.flatten(span_embeddings).to_numpy())
Ensembling
Ensembling methods combine the scores or predictions of several individual flagger or scorer to obtain better performance than the sum of their parts.
[ ]:
from nessie.helper import CrossValidationHelper
from nessie.models.text import DummyTextClassifier
from nessie.models.tagging import DummySequenceTagger
num_splits = 3 # Usually 10 is a good number, we use 3 for simplicity
cv = CrossValidationHelper(n_splits=num_splits)
[ ]:
# Text
# Replace these with non-dummy models
models = [DummyTextClassifier(), DummyTextClassifier(), DummyTextClassifier()]
collected_tc_predictions = []
for model in models:
result = cv.run(text_data.texts, text_data.noisy_labels, model)
collected_tc_predictions.append(result.predictions)
[ ]:
# Token
# Replace these with non-dummy models
models = [DummySequenceTagger(), DummySequenceTagger(), DummySequenceTagger()]
collected_tl_predictions = []
for model in models:
result = cv.run_for_ragged(token_data.sentences, token_data.noisy_labels, model)
collected_tl_predictions.append(result.flatten().predictions)
[ ]:
# Span
# Replace these with non-dummy models
models = [DummySequenceTagger(), DummySequenceTagger(), DummySequenceTagger()]
collected_sl_predictions = []
for model in models:
result = cv.run_for_ragged(span_data.sentences, span_data.noisy_labels, model)
result_aligned = align_span_labeling_result(span_data.noisy_labels, result)
collected_sl_predictions.append(result_aligned.predictions)
Diverse Ensemble
Instead of using a single prediction like Retag does, here, the predictions of several models are aggregated. If most of them disagree on the label for an instance, then it is likely to be an annotation error.
[ ]:
from nessie.detectors import MajorityVotingEnsemble
detector = MajorityVotingEnsemble()
# Text
flags = detector.score(text_data.noisy_labels, collected_tc_predictions)
# Token
flags = detector.score(token_data_flat.noisy_labels, collected_tl_predictions)
# Span
flags = detector.score(span_noisy_entities, collected_sl_predictions)
Item Response Theory
Item Response Theory is a mathematical framework to model relationships between measured responses of test subjects (e.g. answers to questions in an exam) for an underlying, latent trait (e.g. the overall grasp on the subject that is tested). It can also be used to estimate the discriminative power of an item, i.e. how well the response to a question can be used to distinguish between subjects of different ability. In the context of AED, test subjects are trained models, the observations are the predictions on the dataset and the latent trait is task performance.
[ ]:
from nessie.detectors import ItemResponseTheoryFlagger
detector = ItemResponseTheoryFlagger(num_iters=5) # Use 10,000 in real code
# Text
flags = detector.score(text_data.noisy_labels, collected_tc_predictions)
# Token
flags = detector.score(token_data_flat.noisy_labels, collected_tl_predictions)
# Span
flags = detector.score(span_noisy_entities, collected_sl_predictions)
Projection Ensemble
This method trains an ensemble of logistic regression models on different Gaussian projections of BERT embeddings. If most of them disagree on the label for an instance, then it is likely to be an annotation error.
[ ]:
from nessie.detectors import MaxEntProjectionEnsemble
detector = MaxEntProjectionEnsemble(n_components=[32, 64], seeds=[42], max_iter=100) # Use the defaults in real code
# TODO: Write me
Borda Count
Similarly to combining several flagger into an ensemble, rankings obtained from different scorer can be combined as well. Here we leverage Borda counts, a voting scheme that assigns points based on their ranking. For each scorer, given scores for N instances, the instance that is ranked the highest is given N points, the second-highest N-1 and so on. The points assigned by different scorers are then summed up for each instance and form the aggregated ranking. From out experiments, it is best to use only a few and well performing scorer when aggregating them way.
[ ]:
import numpy as np
scores_text = np.vstack([
scores_cu_text, scores_pm_text, # scores_du_text,
scores_ls_text, scores_cs_text, scores_dm_text, scores_md_text, scores_knn_text
])
scores_token = np.vstack([
scores_cu_token_flat, scores_pm_token_flat, # scores_du_token_flat,
scores_dm_token_flat, scores_md_token_flat, scores_knn_token_flat,
])
scores_span = np.vstack([
scores_cu_span, scores_pm_span, # scores_du_span,
scores_dm_span, scores_md_span,scores_knn_span,
])
[ ]:
from nessie.detectors import BordaCount
detector = BordaCount()
# Text
scores_bc_text = detector.score(scores_text)
# Token
scores_bc_token_flat = detector.score(scores_token)
# Span
scores_bc_span = detector.score(scores_span)