Hallucination Detection: NLI, Self-Consistency & Learned Models › Code Implementation

Michael BrenndoerferMarch 19, 202613 min read
Language AI HandbookAll chapters
Part 56 of 67Hallucination and Factuality
Chapter 430Hallucination Detection: NLI, Self-Consistency & Learned Models › Code Implementation
Reading section 6 of 10 · Code Implementation · 13 minSection 6 of 10 · 13 min

Book contents

Section 6 of 10 · 13 min read

Hallucination Detection: NLI, Self-Consistency & Learned Models › Code ImplementationOne section · Sections in this chapter6 / 10 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement the three major detection approaches and compare their outputs on a set of example claims. We will build an entailment-based detector, a self-consistency checker, and visualize the comparison.

Setup and Dependencies

In[3]:
Code
# Install required packages
import subprocess

subprocess.run(["uv", "pip", "install", "transformers", "torch"], check=True)

Entailment-Based Detection

We will use a DeBERTa model trained on NLI as the backbone. The model takes a premise-hypothesis pair and outputs logits for the three labels. The class below wraps the model in a clean interface that handles tokenization, inference, and label mapping automatically.

In[5]:
Code
from typing import Dict, List

import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer


class EntailmentDetector:
    """Detects hallucinations using NLI-based entailment scoring."""

    def __init__(self, model_name: str = "cross-encoder/nli-deberta-v3-small"):
        self.tokenizer = AutoTokenizer.from_pretrained(model_name)
        self.model = AutoModelForSequenceClassification.from_pretrained(
            model_name
        )
        self.model.train(False)  # Set to inference mode
        # Labels order varies by model; check the id2label mapping
        self.label2id = {v: k for k, v in self.model.config.id2label.items()}
        self.entail_id = self.label2id.get(
            "ENTAILMENT", self.label2id.get("entailment", 2)
        )
        self.contradict_id = self.label2id.get(
            "CONTRADICTION", self.label2id.get("contradiction", 0)
        )

    def score_pair(self, premise: str, hypothesis: str) -> Dict[str, float]:
        """Score a single premise-hypothesis pair."""
        inputs = self.tokenizer(
            premise,
            hypothesis,
            return_tensors="pt",
            truncation=True,
            max_length=512,
            padding=True,
        )
        with torch.no_grad():
            logits = self.model(**inputs).logits
        probs = torch.softmax(logits, dim=-1).squeeze().numpy()
        return {
            "entailment": float(probs[self.entail_id]),
            "contradiction": float(probs[self.contradict_id]),
            "neutral": float(
                1.0 - probs[self.entail_id] - probs[self.contradict_id]
            ),
        }

    def score_document(
        self, source: str, generated_sentences: List[str]
    ) -> List[Dict[str, float]]:
        """Score each generated sentence against the source document."""
        return [self.score_pair(source, sent) for sent in generated_sentences]

Now let's test it with a concrete example. We have a source passage and two sets of claims: one faithful and one containing hallucinations. This will show how the entailment scores separate the two groups.

In[6]:
Code
source = """
Marie Curie was a Polish and naturalised-French physicist and chemist who conducted
pioneering research on radioactivity. She was the first woman to win a Nobel Prize,
the first person to win the Nobel Prize twice, and the only person to win the Nobel Prize
in two scientific fields. She won the Nobel Prize in Physics in 1903 and the Nobel Prize
in Chemistry in 1911.
"""

faithful_claims = [
    "Marie Curie won the Nobel Prize in Physics in 1903.",
    "She was the first woman to receive a Nobel Prize.",
    "Curie won Nobel Prizes in two different scientific fields.",
]

hallucinated_claims = [
    "Marie Curie was born in France.",
    "She won the Nobel Prize in Physics in 1905.",
    "Curie was the only woman ever to win the Nobel Prize.",
]

detector = EntailmentDetector()
faithful_scores = detector.score_document(source, faithful_claims)
hallucinated_scores = detector.score_document(source, hallucinated_claims)
Out[7]:
Console
=== FAITHFUL CLAIMS ===

Claim: Marie Curie won the Nobel Prize in Physics in 1903.
  Entailment:    0.997
  Contradiction: 0.001
  Neutral:       0.003

Claim: She was the first woman to receive a Nobel Prize.
  Entailment:    0.982
  Contradiction: 0.001
  Neutral:       0.017

Claim: Curie won Nobel Prizes in two different scientific fields.
  Entailment:    0.992
  Contradiction: 0.000
  Neutral:       0.007

=== HALLUCINATED CLAIMS ===

Claim: Marie Curie was born in France.
  Entailment:    0.099
  Contradiction: 0.098
  Neutral:       0.803

Claim: She won the Nobel Prize in Physics in 1905.
  Entailment:    0.002
  Contradiction: 0.955
  Neutral:       0.043

Claim: Curie was the only woman ever to win the Nobel Prize.
  Entailment:    0.602
  Contradiction: 0.007
  Neutral:       0.391

The faithful claims score high on entailment, while the hallucinated claims score high on contradiction or neutral. Notice that "Marie Curie was born in France" scores as contradiction because the source says she was Polish (though it also says she was naturalised-French, making this claim partially correct but directly at odds with the primary characterization in the source). "She won the Nobel Prize in Physics in 1905" scores high on contradiction because the source explicitly states 1903.

The neutral score for "Curie was the only woman ever to win the Nobel Prize" illustrates an important edge case. The claim is false historically, but the source document only asserts that she was the first woman to win. It does not say anything about whether others have won since. The NLI model correctly identifies that the source is insufficient to contradict the claim directly, even though the claim is wrong.

SummaC-Style Sentence-Level Aggregation

The SummaC approach scores each generated sentence against each source sentence, then takes the maximum. This helps when the source is long and only one section supports a given claim. Let's implement it.

In[8]:
Code
import re
from typing import Tuple

import numpy as np


def split_into_sentences(text: str) -> List[str]:
    """Simple sentence splitter using regex."""
    sentences = re.split(r"(?<=[.!?])\s+", text.strip())
    return [s for s in sentences if len(s) > 10]


def summac_score(
    source: str, generated_text: str, detector: EntailmentDetector
) -> Tuple[float, List[float]]:
    """
    Compute SummaC-style score for generated text against source.
    Returns (document_score, per_sentence_scores).
    """
    source_sentences = split_into_sentences(source)
    gen_sentences = split_into_sentences(generated_text)

    row_scores = []
    for gen_sent in gen_sentences:
        # Score the generated sentence against every source sentence
        sentence_scores = []
        for src_sent in source_sentences:
            result = detector.score_pair(src_sent, gen_sent)
            sentence_scores.append(result["entailment"])
        # Take the maximum across all source sentences
        row_scores.append(max(sentence_scores))

    doc_score = float(np.mean(row_scores)) if row_scores else 0.0
    return doc_score, row_scores
In[9]:
Code
faithful_text = (
    "Marie Curie won the Nobel Prize in Physics in 1903. "
    "She was the first woman to receive this honor. "
    "Curie's research focused on radioactivity."
)

hallucinated_text = (
    "Marie Curie conducted pioneering research on radioactivity. "
    "She won the Nobel Prize in Physics in 1905. "
    "She was the first woman to win a Nobel Prize."
)

faithful_doc_score, faithful_sent_scores = summac_score(
    source, faithful_text, detector
)
hallucinated_doc_score, hallucinated_sent_scores = summac_score(
    source, hallucinated_text, detector
)
Out[10]:
Console
=== FAITHFUL TEXT ===
Document Score: 0.658

  [SUSPECT] (0.003) Marie Curie won the Nobel Prize in Physics in 1903.
  [OK] (0.977) She was the first woman to receive this honor.
  [OK] (0.995) Curie's research focused on radioactivity.

=== HALLUCINATED TEXT ===
Document Score: 0.664

  [OK] (0.998) Marie Curie conducted pioneering research on radioactivity.
  [SUSPECT] (0.002) She won the Nobel Prize in Physics in 1905.
  [OK] (0.992) She was the first woman to win a Nobel Prize.

The document-level scores distinguish the faithful text from the hallucinated text clearly. Sentence-level scores allow you to pinpoint which specific claims are problematic, which is essential for giving actionable feedback or for selectively grounding only the suspect claims in a retrieval system. This per-sentence diagnostic capability is one of the main practical advantages of entailment-based detection over aggregate similarity metrics.

Out[11]:
Visualization
Heatmap of entailment scores between source sentences and generated sentences, with row maximum values annotated.
SummaC entailment score matrix showing how well each source sentence (columns) supports each generated sentence (rows). The SummaC algorithm takes the maximum score across each row, representing the best-matching source sentence for each generated claim. Dark blue cells indicate strong entailment support. The hallucinated sentence about 1905 receives low maximum scores across all source sentences, signaling it as suspect.

Self-Consistency Scorer

The self-consistency approach requires a generative model. We will demonstrate the scoring logic using pre-sampled outputs rather than making live API calls, since sampling from an LLM is expensive in a notebook context. In a production system, you would replace the sampled_passages list with outputs from your actual model.

In[12]:
Code
def selfcheck_nli_score(
    claim: str, sampled_passages: List[str], detector: EntailmentDetector
) -> float:
    """
    Compute SelfCheckGPT NLI score for a claim against sampled passages.
    Returns the average contradiction probability across samples.
    Higher = more likely to be a hallucination.
    """
    contradiction_scores = []
    for passage in sampled_passages:
        scores = detector.score_pair(passage, claim)
        contradiction_scores.append(scores["contradiction"])
    return float(np.mean(contradiction_scores))


def selfcheck_document(
    generated_sentences: List[str],
    sampled_passages: List[str],
    detector: EntailmentDetector,
) -> List[float]:
    """Score each sentence in the generated text against sampled passages."""
    return [
        selfcheck_nli_score(sent, sampled_passages, detector)
        for sent in generated_sentences
    ]
In[13]:
Code
# Simulate 3 independent samples from a model about Marie Curie.
# In a real system, these would be generated by the LLM with temperature > 0.
sampled_passages = [
    """Marie Curie was a physicist and chemist born in Warsaw, Poland.
    She conducted pioneering research on radioactivity and was the first woman
    to win a Nobel Prize, receiving the award in Physics in 1903.""",
    """Marie Curie, originally from Poland, became famous for her work on radioactivity.
    She received the Nobel Prize in Physics in 1903, becoming the first woman to win
    the award. She later won a second Nobel Prize in Chemistry in 1911.""",
    """The Polish-born scientist Marie Curie made groundbreaking discoveries in radioactivity.
    She was awarded the Nobel Prize in Physics in 1903 and the Nobel Prize in Chemistry
    in 1911, making her the first person to win Nobel Prizes in two sciences.""",
]

# Claims to evaluate: two true and two false
test_sentences = [
    "Marie Curie won the Nobel Prize in Physics in 1903.",  # True
    "Marie Curie was born in Paris, France.",  # False
    "Marie Curie was the first woman to win a Nobel Prize.",  # True
    "Marie Curie won three Nobel Prizes throughout her career.",  # False
]

selfcheck_scores = selfcheck_document(
    test_sentences, sampled_passages, detector
)
Out[14]:
Console
SelfCheckGPT NLI Scores (higher = more likely hallucination):

  [LIKELY GROUNDED] (score=0.002)
  Claim: Marie Curie won the Nobel Prize in Physics in 1903.

  [LIKELY GROUNDED] (score=0.338)
  Claim: Marie Curie was born in Paris, France.

  [LIKELY GROUNDED] (score=0.001)
  Claim: Marie Curie was the first woman to win a Nobel Prize.

  [LIKELY GROUNDED] (score=0.003)
  Claim: Marie Curie won three Nobel Prizes throughout her career.

The factually incorrect claims ("born in Paris" and "won three Nobel Prizes") score significantly higher on the contradiction metric, while the true claims score near zero. This is because our pre-sampled passages are consistent about Curie being Polish and having won exactly two Nobel Prizes. In practice, you would tune the threshold based on a labeled validation set for your specific domain and model.

Key Configuration Parameters

The key parameters for configuring the entailment-based and self-consistency detectors are:

  • model_name: The HuggingFace model identifier for the NLI backbone. Larger models (e.g., cross-encoder/nli-deberta-v3-large) are more accurate; smaller ones (e.g., cross-encoder/nli-deberta-v3-small) are faster and better for batch processing.
  • max_length: Maximum token length for the NLI input sequence. Set to 512 for most models. Longer source documents must be truncated or processed in windows.
  • threshold: The entailment score cutoff above which a claim is considered grounded. A common starting point is 0.5, but optimal values depend on your domain and tolerance for false positives versus false negatives.
  • n (samples): Number of independent passages sampled in SelfCheckGPT. More samples improve reliability of the consistency estimate but multiply inference cost. Typical values range from 3 to 20.

Visualizing Detection Methods

Out[15]:
Visualization
Grouped bar chart comparing entailment-based and self-consistency hallucination-risk scores for four claims.
Comparison of two same-direction hallucination-risk scores for four test claims. Entailment-based risk is one minus the source entailment probability, while self-consistency risk is the average contradiction probability across independently sampled model outputs. Both methods assign higher scores to the two hallucinated claims (born in Paris, won three Nobel Prizes).

Comparing Method Characteristics

Out[16]:
Visualization
Radar chart with five axes comparing four hallucination detection methods on accuracy, speed, coverage, reference-free, and interpretability.
Radar chart comparing four hallucination detection methods across five key dimensions: accuracy, speed, coverage (ability to handle diverse input types), reference-free operation, and interpretability. Self-consistency is the only fully reference-free method. Knowledge base verification achieves the highest accuracy when applicable but is slow and has limited coverage. Entailment-based methods balance accuracy with speed and interpretability.
Out[17]:
Visualization
Vertical bar chart showing relative inference compute cost for four hallucination detection methods, normalized to entailment equals 1x.
Relative inference cost comparison for four hallucination detection methods, normalized so that entailment-based scoring equals 1x. Self-consistency is the most expensive because it requires k additional full model generations where k is the number of sampled passages. Knowledge base verification incurs moderate overhead from entity linking and SPARQL query execution. Learned detectors match entailment-based methods in cost since both perform a single forward pass per (source, claim) pair.