Sentence Meaning and Natural Language Inference › Worked Example: A Small NLI Battery

Michael BrenndoerferMay 9, 202515 min read
Language AI HandbookAll chapters
Part 8 of 67Semantics and Information Extraction
Chapter 51Sentence Meaning and Natural Language Inference › Worked Example: A Small NLI Battery
Reading section 9 of 12 · Worked Example: A Small NLI Battery · 15 minSection 9 of 12 · 15 min

Section 9 of 12 · 15 min read

Sentence Meaning and Natural Language Inference › Worked Example: A Small NLI BatterySection view · Sections in this chapter9 / 12 ↓
Reading tools Definitions, links & notes

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

Notes & comments

We will now build a small NLI battery and run an explicit lexical baseline. This deterministic example uses Python, NumPy, pandas, and Matplotlib from the locked book environment; it downloads no models or data. The baseline intentionally ignores word order and lexical hierarchies so that the battery exposes its limitations.

The battery is intentionally small. It is a teaching tool, not a benchmark. The goal is to inspect controlled failures and practice computing evaluation metrics. The manually chosen score weights are not learned or tuned on a held-out split. Softmax normalizes those scores to sum to one; it does not calibrate them as empirical confidence estimates.

Setting Up

The hidden setup at the start of the chapter seeds the book's plotting utilities once. Each figure applies the shared book style when it renders.

Next, import the small numerical and plotting dependencies. The tokenization below lowercases text and extracts ASCII letter/digit runs; it is deliberately limited to these English teaching examples.

PythonIn [5]
CollapseExpand
import re

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

A Transparent Lexical Baseline

The baseline uses hypothesis-word overlap, a mismatch in negation words, and a small list of contrasting terms. These are fallible cues: opposite words need not denote incompatible events, and a negation word need not have the same scope in both sentences. The coefficients below are fixed illustrative choices, so the scores describe this heuristic rather than a trained NLI system.

PythonIn [6]
CollapseExpand
LABELS = ["contradiction", "entailment", "neutral"]
NEGATIONS = {
    "not",
    "no",
    "never",
    "none",
    "nobody",
    "nothing",
    "neither",
    "nor",
    "without",
}
ANTONYM_PAIRS = [
    ("open", "closed"),
    ("hot", "cold"),
    ("up", "down"),
    ("before", "after"),
    ("true", "false"),
    ("always", "never"),
    ("more", "less"),
    ("increase", "decrease"),
    ("buy", "sell"),
    ("win", "lose"),
]
STOPWORDS = {
    "a",
    "an",
    "the",
    "is",
    "are",
    "was",
    "were",
    "be",
    "been",
    "being",
    "of",
    "to",
    "in",
    "on",
    "at",
    "for",
    "with",
    "and",
    "or",
    "that",
    "this",
    "it",
    "as",
    "by",
    "from",
}


def simple_tokens(text):
    return re.findall(r"[a-z0-9]+", text.lower())
PythonIn [7]
CollapseExpand
class LexicalNLIBaseline:
    """Fixed teaching heuristic, without training or model downloads."""

    def predict(self, pairs):
        rows = []
        for premise, hypothesis in pairs:
            pt = set(simple_tokens(premise))
            ht = set(simple_tokens(hypothesis))
            content = ht - STOPWORDS
            overlap = len(content & pt) / max(1, len(content))
            neg_p = any(token in NEGATIONS for token in pt)
            neg_h = any(token in NEGATIONS for token in ht)
            antonym_hit = any(
                (a in pt and b in ht) or (b in pt and a in ht)
                for a, b in ANTONYM_PAIRS
            )

            ent_logit = 0.2 + 1.6 * overlap
            con_logit = 0.1
            if neg_p != neg_h:
                con_logit += 1.1
            if antonym_hit:
                con_logit += 1.3
            neu_logit = 0.7 + 1.0 * (1.0 - overlap)

            logits = np.array([con_logit, ent_logit, neu_logit])
            probs = np.exp(logits - logits.max())
            rows.append(probs / probs.sum())
        return np.vstack(rows) if rows else np.empty((0, len(LABELS)))

Instantiating the Baseline

The output columns always use LABELS in the declared order: contradiction, entailment, neutral. There is no external model configuration or environment-dependent fallback.

PythonIn [8]
CollapseExpand
nli_model = LexicalNLIBaseline()
model_labels = LABELS

Prediction Functions

The batch function checks that every premise has a hypothesis. It returns a normalized heuristic score for each label and selects the largest score. Exact ties follow the declared label order because the first maximum is selected. An empty batch returns an empty list.

PythonIn [9]
CollapseExpand
def predict_nli_batch(premises, hypotheses):
    if len(premises) != len(hypotheses):
        raise ValueError("Each premise needs exactly one hypothesis.")
    pairs = list(zip(premises, hypotheses))
    raw = nli_model.predict(pairs)
    results = []
    for row in raw:
        probs = {label: float(row[i]) for i, label in enumerate(LABELS)}
        prediction = max(LABELS, key=lambda label: probs[label])
        results.append({"probs": probs, "prediction": prediction})
    return results


def predict_nli(premise, hypothesis):
    return predict_nli_batch([premise], [hypothesis])[0]

Running the Battery

The battery covers simple entailment, contradiction, neutral pairs, lexical ambiguity, event structure, and downward monotonicity. Gold labels use the intended readings stated here. Hold referents and relevant times fixed when descriptions are intended to corefer. In the bank example, the premise uses the financial sense and the hypothesis the geographical sense, so they concern different entities. The train-arrival pair refers to one arrival, and the meeting-time pair to one scheduled start; different events would change the judgment. The child-and-park example uses an outdoor park reading, and the museum pair uses one recurring weekly schedule with a binary daily open/closed status. The toy battery has no training/test split because no parameters are fitted.

PythonIn [10]
CollapseExpand
examples = [
    {
        "id": 1,
        "premise": "A young child is riding a bicycle in the park.",
        "hypothesis": "A child is outdoors.",
        "gold": "entailment",
    },
    {
        "id": 2,
        "premise": "The museum is open every day.",
        "hypothesis": "The museum is closed on Mondays.",
        "gold": "contradiction",
    },
    {
        "id": 3,
        "premise": "A woman is playing the violin.",
        "hypothesis": "A woman is performing in an orchestra.",
        "gold": "neutral",
    },
    {
        "id": 4,
        "premise": "All mammals are warm-blooded, and dolphins are mammals.",
        "hypothesis": "Dolphins are warm-blooded.",
        "gold": "entailment",
    },
    {
        "id": 5,
        "premise": "The train arrived before noon.",
        "hypothesis": "The train arrived after noon.",
        "gold": "contradiction",
    },
    {
        "id": 6,
        "premise": "The bank raised interest rates.",
        "hypothesis": "The river bank is steep.",
        "gold": "neutral",
    },
    {
        "id": 7,
        "premise": "No dogs are allowed in the store.",
        "hypothesis": "No puppies are allowed in the store.",
        "gold": "entailment",
    },
    {
        "id": 8,
        "premise": "The door is open.",
        "hypothesis": "The door is not open.",
        "gold": "contradiction",
    },
    {
        "id": 9,
        "premise": "The lawyer questioned the witness.",
        "hypothesis": "The witness questioned the lawyer.",
        "gold": "neutral",
    },
    {
        "id": 10,
        "premise": "Alice sold Bob a car.",
        "hypothesis": "Bob bought a car from Alice.",
        "gold": "entailment",
    },
    {
        "id": 11,
        "premise": "The keys are in the drawer.",
        "hypothesis": "The keys are in the kitchen.",
        "gold": "neutral",
    },
    {
        "id": 12,
        "premise": "The meeting starts at 3 p.m.",
        "hypothesis": "The meeting starts at 4 p.m.",
        "gold": "contradiction",
    },
]
examples_df = pd.DataFrame(examples)
PythonIn [11]
CollapseExpand
batch_predictions = predict_nli_batch(
    examples_df["premise"].tolist(),
    examples_df["hypothesis"].tolist(),
)

results_df = examples_df.copy()
results_df["prediction"] = [item["prediction"] for item in batch_predictions]
for label in LABELS:
    results_df[f"p_{label}"] = [
        item["probs"][label] for item in batch_predictions
    ]
results_df["correct"] = results_df["gold"] == results_df["prediction"]
ConsoleOut [12]
CollapseExpand
 id          gold    prediction  p_contradiction  p_entailment  p_neutral  correct
  1    entailment       neutral            0.155         0.381      0.465    False
  2 contradiction contradiction            0.403         0.207      0.390     True
  3       neutral       neutral            0.155         0.293      0.552     True
  4    entailment    entailment            0.121         0.660      0.220     True
  5 contradiction contradiction            0.379         0.379      0.242     True
  6       neutral       neutral            0.155         0.293      0.552     True
  7    entailment    entailment            0.143         0.524      0.334     True
  8 contradiction    entailment            0.343         0.367      0.290    False
  9       neutral    entailment            0.121         0.660      0.220    False
 10    entailment    entailment            0.143         0.524      0.334     True
 11       neutral       neutral            0.155         0.381      0.465     True
 12 contradiction    entailment            0.139         0.552      0.309    False

Accuracy on this toy battery: 0.667

The table shows the baseline's normalized scores and agreement with the hand labels. A large score does not establish correct inference or explain a decision. Inspecting errors can suggest patterns for controlled follow-up tests, but one output cannot establish a systematic failure or show that a correct answer was accidental. Example 5 has equal contradiction and entailment scores; its correct label comes from the declared tie-breaking order rather than a separation between those two scores.

The next chart uses the same twelve predictions, shows the normalized score assigned to the chosen label, and marks agreement with the hand label. A larger score does not establish better calibration or semantic understanding.

VisualizationOut [14]
CollapseExpand
Chosen-label scores for twelve examples, colored by hand-label agreement.
Normalized heuristic score assigned to the chosen label for each hand-labeled example. Color separates correct from incorrect choices. These scores come from fixed rules and do not measure calibrated confidence.

Directional Entailment

The first probe isolates lexical specificity: a poodle barking entails a dog barking, while the reverse is neutral. The second probe assumes the same restaurant, time, and a binary open/closed status. Under that explicit convention, "open" and "not closed" are equivalent. The lexical baseline is not expected to implement either semantic rule correctly.

PythonIn [15]
CollapseExpand
directional_pairs = [
    ("A poodle barked.", "A dog barked."),
    ("A dog barked.", "A poodle barked."),
    ("The restaurant is open.", "The restaurant is not closed."),
    ("The restaurant is not closed.", "The restaurant is open."),
]

directional_preds = predict_nli_batch(
    [p for p, h in directional_pairs],
    [h for p, h in directional_pairs],
)

directional_df = pd.DataFrame(
    directional_pairs, columns=["premise", "hypothesis"]
)
directional_df["prediction"] = [
    item["prediction"] for item in directional_preds
]
directional_df["p_entailment"] = [
    item["probs"]["entailment"] for item in directional_preds
]
directional_df["p_neutral"] = [
    item["probs"]["neutral"] for item in directional_preds
]
directional_df["p_contradiction"] = [
    item["probs"]["contradiction"] for item in directional_preds
]
ConsoleOut [16]
CollapseExpand
                      premise                    hypothesis    prediction  p_entailment  p_neutral  p_contradiction
             A poodle barked.                 A dog barked.       neutral         0.381      0.465            0.155
                A dog barked.              A poodle barked.       neutral         0.381      0.465            0.155
      The restaurant is open. The restaurant is not closed. contradiction         0.114      0.216            0.670
The restaurant is not closed.       The restaurant is open. contradiction         0.149      0.182            0.669

These probes change the premise-hypothesis direction; they do not swap grammatical roles inside a sentence. Compare the output with the stated semantic targets. Equal heuristic scores can expose a missing distinction, while differing scores alone do not establish correct inference. The chart shows the baseline's entailment score for each ordered pair.

VisualizationOut [18]
CollapseExpand
Normalized entailment scores for four ordered NLI probes.
Normalized entailment scores for four ordered probes. The first two bars compare poodle and dog statements in both directions. The last two compare open and not closed under a binary-status convention. Differences in these heuristic scores alone do not show correct semantic inference.

Presupposition Probes

Presuppositions often project under negation in the readings considered here. A model that only learns surface entailment may treat the negated premise differently from the positive one, even though the presupposition survives in both.

PythonIn [19]
CollapseExpand
presupposition_pairs = [
    ("The king of France is bald.", "There is a king of France."),
    ("The king of France is not bald.", "There is a king of France."),
    ("John stopped smoking.", "John used to smoke."),
    ("John did not stop smoking.", "John used to smoke."),
]

presupposition_preds = predict_nli_batch(
    [p for p, h in presupposition_pairs],
    [h for p, h in presupposition_pairs],
)

presupposition_df = pd.DataFrame(
    presupposition_pairs, columns=["premise", "hypothesis"]
)
presupposition_df["prediction"] = [
    item["prediction"] for item in presupposition_preds
]
presupposition_df["p_entailment"] = [
    item["probs"]["entailment"] for item in presupposition_preds
]
presupposition_df["p_neutral"] = [
    item["probs"]["neutral"] for item in presupposition_preds
]
presupposition_df["p_contradiction"] = [
    item["probs"]["contradiction"] for item in presupposition_preds
]
ConsoleOut [20]
CollapseExpand
                        premise                 hypothesis prediction  p_entailment  p_neutral  p_contradiction
    The king of France is bald. There is a king of France. entailment         0.475      0.376            0.148
The king of France is not bald. There is a king of France. entailment         0.367      0.290            0.343
          John stopped smoking.        John used to smoke.    neutral         0.293      0.552            0.155
     John did not stop smoking.        John used to smoke.    neutral         0.223      0.421            0.356

The three-way label set does not separately identify presupposition. If both positive and negated premises receive entailment labels, the outputs alone do not establish which cues or meaning representations produced them. Additional controlled probes are needed to investigate the mechanism.

Stress Tests

The stress battery targets lexical overlap, negation, and downward monotonicity. The last two examples are a minimal pair: "No dogs are allowed" entails "No puppies are allowed," but "No puppies are allowed" does not entail "No dogs are allowed."

PythonIn [21]
CollapseExpand
stress_examples = [
    {
        "premise": "The lawyer questioned the witness.",
        "hypothesis": "The witness questioned the lawyer.",
        "gold": "neutral",
    },
    {
        "premise": "The lawyer questioned the witness.",
        "hypothesis": "The lawyer questioned someone.",
        "gold": "entailment",
    },
    {
        "premise": "The door is open.",
        "hypothesis": "The door is closed.",
        "gold": "contradiction",
    },
    {
        "premise": "The door is not open.",
        "hypothesis": "The door is closed.",
        "gold": "entailment",
    },
    {
        "premise": "No dogs are allowed in the store.",
        "hypothesis": "No puppies are allowed in the store.",
        "gold": "entailment",
    },
    {
        "premise": "No puppies are allowed in the store.",
        "hypothesis": "No dogs are allowed in the store.",
        "gold": "neutral",
    },
]

stress_df = pd.DataFrame(stress_examples)
stress_preds = predict_nli_batch(
    stress_df["premise"].tolist(),
    stress_df["hypothesis"].tolist(),
)
stress_df["prediction"] = [item["prediction"] for item in stress_preds]
stress_df["correct"] = stress_df["gold"] == stress_df["prediction"]
ConsoleOut [22]
CollapseExpand
                             premise                           hypothesis          gold    prediction  correct
  The lawyer questioned the witness.   The witness questioned the lawyer.       neutral    entailment    False
  The lawyer questioned the witness.       The lawyer questioned someone.    entailment    entailment     True
                   The door is open.                  The door is closed. contradiction contradiction     True
               The door is not open.                  The door is closed.    entailment contradiction    False
   No dogs are allowed in the store. No puppies are allowed in the store.    entailment    entailment     True
No puppies are allowed in the store.    No dogs are allowed in the store.       neutral    entailment    False

Stress-test accuracy: 0.500

The lawyer-witness example is the classic lexical overlap trap. The premise and hypothesis share almost all content words, but the grammatical roles are reversed. A model that attends to argument order should predict neutral. A model that counts overlapping words may predict entailment. The minimal pair about dogs and puppies tests the other end of the same idea: downward monotonicity means the inference runs from the broader term to the narrower one, not the other way around.

Metrics and Figures

We can compute a confusion matrix and macro-F1 on the toy battery. With only twelve hand-chosen examples, these numbers describe this battery; they are not a representative estimate of population performance. The point is to practice reporting per-class behavior, not to rank models.

PythonIn [23]
CollapseExpand
confusion = np.zeros((len(LABELS), len(LABELS)), dtype=int)
for gold, pred in zip(results_df["gold"], results_df["prediction"]):
    confusion[LABELS.index(gold), LABELS.index(pred)] += 1


def macro_f1(y_true, y_pred, labels):
    scores = []
    for label in labels:
        tp = sum(t == label and p == label for t, p in zip(y_true, y_pred))
        fp = sum(t != label and p == label for t, p in zip(y_true, y_pred))
        fn = sum(t == label and p != label for t, p in zip(y_true, y_pred))
        precision = tp / (tp + fp) if tp + fp else 0.0
        recall = tp / (tp + fn) if tp + fn else 0.0
        f1 = (
            2 * precision * recall / (precision + recall)
            if precision + recall
            else 0.0
        )
        scores.append(f1)
    return float(np.mean(scores))


accuracy = float((results_df["gold"] == results_df["prediction"]).mean())
f1 = macro_f1(
    results_df["gold"].tolist(), results_df["prediction"].tolist(), LABELS
)
ConsoleOut [24]
CollapseExpand
Accuracy: 0.667
Macro F1: 0.672

Confusion matrix (rows = gold, columns = predicted):
               Contradiction  Entailment  Neutral
Contradiction              2           2        0
Entailment                 0           3        1
Neutral                    0           1        3

The confusion matrix shows the observed label errors. Two of four gold contradictions and one of four gold neutrals are predicted as entailment. These counts describe this heuristic on the battery; they do not establish a general error distribution for NLI models. Macro-F1 gives each class equal weight.

The next figure compares the three normalized heuristic scores for four selected examples. It shows which labels the fixed rules favor, without treating score magnitude as calibrated confidence.

PythonIn [25]
CollapseExpand
plot_ids = [1, 2, 4, 7]
plot_df = results_df[results_df["id"].isin(plot_ids)].copy().sort_values("id")
VisualizationOut [26]
CollapseExpand
Three normalized heuristic scores for each of four selected NLI pairs.
Normalized heuristic scores for four premise-hypothesis pairs in contradiction, entailment, and neutral order. The distributions show which labels the fixed rules favor and how strongly they separate them; they are not calibrated confidence estimates.

The second figure shows the confusion matrix. It is a compact way to see which labels the model confuses.

VisualizationOut [27]
CollapseExpand
Three-by-three confusion matrix for gold and predicted NLI labels on a small battery.
Confusion matrix on the hand-labeled NLI battery. Rows are gold labels and columns are predicted labels; diagonal cells show correct predictions, and off-diagonal cells show which label pairs the model most often confuses.

With twelve examples, the confusion matrix is sparse. That is normal for a teaching battery. For an application evaluation, choose a sampling design and sample size that fit the target population and required uncertainty, then report category-level results with an appropriate uncertainty analysis.

Comments

Add to the conversation

A question, a correction, or an example from your own work.

No comments yet. Be the first to share your thoughts!

Newsletter

Stay up to date

Get new articles, book updates, and notes on AI and technology.

Unsubscribe at any time. Privacy policy