relations = ["≡", "[", "]", "^", "|", "u", "#"]
join_table = {
("≡", "≡"): {"≡"},
("≡", "["): {"["},
("≡", "]"): {"]"},
("≡", "^"): {"^"},
("≡", "|"): {"|"},
("≡", "u"): {"u"},
("≡", "#"): {"#"},
("[", "≡"): {"["},
("[", "["): {"["},
("[", "]"): {"#", "|", "≡", "[", "]"},
("[", "^"): {"|"},
("[", "|"): {"|"},
("[", "u"): {"#", "^", "u", "|", "["},
("[", "#"): {"#", "|", "["},
("]", "≡"): {"]"},
("]", "["): {"#", "u", "≡", "[", "]"},
("]", "]"): {"]"},
("]", "^"): {"u"},
("]", "|"): {"#", "^", "u", "|", "]"},
("]", "u"): {"u"},
("]", "#"): {"#", "u", "]"},
("^", "≡"): {"^"},
("^", "["): {"u"},
("^", "]"): {"|"},
("^", "^"): {"≡"},
("^", "|"): {"]"},
("^", "u"): {"["},
("^", "#"): {"#"},
("|", "≡"): {"|"},
("|", "["): {"[", "^", "|", "u", "#"},
("|", "]"): {"|"},
("|", "^"): {"["},
("|", "|"): {"#", "|", "≡", "[", "]"},
("|", "u"): {"["},
("|", "#"): {"#", "|", "["},
("u", "≡"): {"u"},
("u", "["): {"u"},
("u", "]"): {"#", "^", "u", "|", "]"},
("u", "^"): {"]"},
("u", "|"): {"]"},
("u", "u"): {"#", "u", "≡", "[", "]"},
("u", "#"): {"#", "u", "]"},
("#", "≡"): {"#"},
("#", "["): {"#", "u", "["},
("#", "]"): {"]", "|", "#"},
("#", "^"): {"#"},
("#", "|"): {"#", "|", "]"},
("#", "u"): {"#", "u", "["},
("#", "#"): set(relations),
}
print("\t".join([""] + relations))
for r1 in relations:
row = "\t".join(
"".join([r3 for r3 in relations if r3 in join_table[r1, r2]])
for r2 in relations
)
print(f"{r1}\t{row}\n")Assignments 3 and 4
Like Assignments 1 and 2, Assignments 3 and 4 are bundled together. You only need to do Tasks 1 and 2 for Assignment 3 and Task 3 for Assignment 4.
These assignments focus on implementing a natural language inference system. In natural language inference, we receive a premise sentence and a hypothesis sentence and we must say whether we can infer the hypothesis from the premise. For instance, if (1) were our premise and (2) were our hypothesis, our system should respond yes.
- Every firm polled saw costs grow more than expected, even after adjusting for inflation.
- Every big company in the poll reported cost increases.
In MacCartney & Manning 2009 (henceforth, M&M), you read about one sort of system for doing this: a natural logic system. This system works by (i) obtaining an edit path from the premise and the hypothesis; (ii) mapping that edit path into an inference path; (iii) computing the join of the inferences in this path to obtain a relation between the premise and the hypothesis; and (iv) checking whether there is a forward entailment relation between the premise and the hypothesis.
The definition of the relations is given in Table 2 of the paper.
| Symbol | Names | Example | Set theoretic definition |
|---|---|---|---|
| \(x \equiv y\) | equivalence | couch \(\equiv\) sofa | \(x = y\) |
| \(x \sqsubset y\) | forward entailment | crow \(\sqsubset\) bird | \(x \subset y\) |
| \(x \sqsupset y\) | reverse entailment | European \(\sqsupset\) French | \(x \supset y\) |
| \(x \land y\) | negation | human \(\land\) nonhuman | \(x \cap y = \emptyset\) & \(x \cup y = U\) |
| \(x \mid y\) | alternation | cat \(\mid\) dog | \(x \cap y = \emptyset\) & \(x \cup y \neq U\) |
| \(x \smile y\) | cover | animal \(\smile\) nonhuman | \(x \cap y \neq \emptyset\) & \(x \cup y = U\) |
| \(x\;\#\;y\) | independence | animal \(\;\#\;\) nonhuman | otherwise |
The table of joins is given below.
In Tasks 1 and 2, you will be developing the core of this system, using the minimum edit distance-based edit paths we developed in class and assuming default inference relations associated with each atomic edit operation (as discussed in Section 4 of M&M). In Task 3, you will enrich this system with lexical relation information from WordNet and test it on the classic FraCaS dataset.
Task 1
Lines: 4
Define the __add__ magic method for the Inference class below. This method should use join_table (defined above) to produce a set of Inferences by joining two inferences—e.g. animal \(\sqsupset\) dog \(\bowtie\) dog \(\sqsupset\) greyhound = {animal \(\sqsupset\) greyhound}. __add__ must return a set because, as M&M discussed, the result of joining two relations can result in indeterminacy. (In their implementation, M&M actually treat all such indetrminate joins as #. We will not do that here, since it is useful to see why they do that.)
Importantly, note that __add__ should not be symmetric for the same reason joins are not: animal \(\sqsupset\) dog \(\bowtie\) dog \(\sqsubset\) mammal = {animal \(\equiv\) mammal, animal \(\sqsupset\) mammal, animal \(\sqsubset\) mammal, animal \(\smile\) mammal, animal \(\#\) mammal}, but dog \(\sqsubset\) mammal \(\bowtie\) animal \(\sqsupset\) dog isn’t even a licit join.
class Inference:
"""An inference from one linguistic expression to another.
Parameters
----------
premise : list[str]
The premise in the relation.
hypothesis : list[str]
The hypothesis in the relation.
relation : str
The relation.
"""
def __init__(
self, premise: list[str], hypothesis: list[str], relation: str
) -> None:
if relation not in relations:
raise ValueError(f"relation must be in {relations}")
self.premise = premise
self.hypothesis = hypothesis
self.relation = relation
def __repr__(self) -> str:
return (
" ".join(self.premise)
+ " "
+ self.relation
+ " "
+ " ".join(self.hypothesis)
)
def __hash__(self) -> int:
return hash((tuple(self.premise), tuple(self.hypothesis), self.relation))
def __add__(self, other: Inference) -> set[Inference]:
"""Compose this inference with another using the join table.
Parameters
----------
other : Inference
The inference to compose with.
Returns
-------
set[Inference]
The set of inferences resulting from composition.
"""
raise NotImplementedError
def __eq__(self, other: object) -> bool:
if not isinstance(other, Inference):
return NotImplemented
return (
(self.premise == other.premise)
& (self.hypothesis == other.hypothesis)
& (self.relation == other.relation)
)Test your implementation of Inference.__add__ using the Editor subclasses below.
from abc import ABC, abstractmethod
class Editor(ABC):
"""Abstract base class for text edit operations."""
_relation: str
@abstractmethod
def __call__(self, input: list[str], idx: int) -> Inference:
"""Apply this edit operation at the given index."""
raise NotImplementedError
@property
def relation(self) -> str:
"""The inference relation produced by this editor."""
return self._relation
class Substitution(Editor):
"""A substitution editor.
Parameters
----------
input : str
The string in the input to replace.
output : str
The string to replace the input string with.
relation : str
The inference relation that results.
"""
default_relation = None
def __init__(self, input: str, output: str, relation: str) -> None:
self._input = input
self._output = output
self._relation = relation
@property
def input(self) -> str:
return self._input
@property
def output(self) -> str:
return self._output
def __repr__(self) -> str:
return (
f'<SUB "{self._output}" for "{self._input}" resulting in {self._relation}>'
)
def __call__(self, input: list[str], idx: int) -> Inference:
"""Substitute input for output at the given location.
Parameters
----------
input : list[str]
The input text as a list of tokens.
idx : int
The position at which to substitute.
Returns
-------
Inference
The resulting inference after substitution.
"""
if input[idx] != self._input:
raise ValueError(
f'SUB "{self._input}" -> "{self._output}" at {idx} '
f"cannot be applied to {input}"
)
output = input[:idx] + [self._output] + input[(idx + 1) :]
return Inference(input, output, self._relation)
class Deletion(Editor):
"""A deletion editor.
Parameters
----------
input : str
The string in the input to delete.
relation : str
The inference relation that results.
"""
def __init__(self, input: str, relation: str = "[") -> None:
self._input = input
self._relation = relation
@property
def input(self) -> str:
return self._input
def __repr__(self) -> str:
return f'<DEL "{self._input}" resulting in {self._relation}>'
def __call__(self, input: list[str], idx: int) -> Inference:
"""Delete the element at the given location.
Parameters
----------
input : list[str]
The input text as a list of tokens.
idx : int
The position at which to delete.
Returns
-------
Inference
The resulting inference after deletion.
"""
if input[idx] != self._input:
raise ValueError(
f'DEL "{self._input}" at {idx} cannot be applied to {input}'
)
output = input[:idx] + input[(idx + 1) :]
return Inference(input, output, self._relation)
class Insertion(Editor):
"""An insertion editor.
Parameters
----------
output : str
The string to insert into the output.
relation : str
The inference relation that results.
"""
def __init__(self, output: str, relation: str = "]") -> None:
self._output = output
self._relation = relation
@property
def output(self) -> str:
return self._output
def __repr__(self) -> str:
return f'<INS "{self._output}" resulting in {self._relation}>'
def __call__(self, input: list[str], idx: int) -> Inference:
"""Insert an element at the given location.
Parameters
----------
input : list[str]
The input text as a list of tokens.
idx : int
The position at which to insert.
Returns
-------
Inference
The resulting inference after insertion.
"""
output = input[:idx] + [self._output] + input[idx:]
return Inference(input, output, self._relation)These subclasses are initialized with input and/or output strings and a relation. For instance, “brindle” and “fawn” are two different colorings of greyhounds—no greyhound is both brindle and fawn—and so they are in the | relation. Each is at least a subsective modifier (all brindle greyhounds are greyhounds), so if we delete one, we obtain a \(\sqsubset\) relation, and if we insert one, we get a \(\sqsupset\) relation (the default relations for deletion and insertion, as discussed in M&M).
substitute_fawn_for_brindle = Substitution("brindle", "fawn", "|")
delete_brindle = Deletion("brindle")
insert_brindle = Insertion("brindle")
substitute_fawn_for_brindle, delete_brindle, insert_brindleNote that not all insertions or deletions of adjectives will be associated with \(\sqsubset\) or \(\sqsupset\): privative adjectives like “fake” will introduce a \(|\): fake greyhounds are not greyhounds (fake greyhound \(|\) greyhound) and greyhounds are not fake greyhounds (greyhound \(|\) fake greyhound).
delete_fake = Deletion("fake", relation="|")
insert_fake = Insertion("fake", relation="|")Indeed, most substitutions involving “fake” will also yield a \(|\) relation.
substitute_fake_for_virtuosic = Substitution("virtuosic", "fake", "|")
substitute_virtuosic_for_fake = Substitution("fake", "virtuosic", "|")But insertion and deletion edits involving “virtuosic” should act like “brindle”.
delete_virtuosic = Deletion("virtuosic")
insert_virtuosic = Insertion("virtuosic")Use the following four sentences to write your tests. These tests should involve applying an edit \(e_1\) to sentence \(s_i\) to yield sentence \(e_1(s_i)\), then applying an edit \(e_2\) to \(e_1(s_i)\) to yield sentence. You should then combine the inferences associated with \(e_1\) and \(e_2\) using your Inference.__add__ and check that it is correct. Make sure to test at least one case where the result should be a non-singleton set of inferences.
test_sentence1 = [
"a",
"virtuosic",
"synthesist",
"loves",
"a",
"happy",
"brindle",
"greyhound",
]
test_sentence2 = ["a", "synthesist", "loves", "a", "happy", "greyhound"]
test_sentence3 = [
"a",
"fake",
"synthesist",
"loves",
"a",
"happy",
"brindle",
"greyhound",
]
test_sentence4 = ["a", "synthesist", "loves", "a", "happy", "brindle", "greyhound"]# write tests hereEditor Libraries
We’ll need a way to store collections of editors and, crucially, make new default ones when needed. The EditorLibrary class provides a convenient way to store and retrieve editors (like substitutions, deletions, and insertions). When we try to get an editor that isn’t in the library, it automatically creates a default one - substitutions get a # relation (since we don’t have a default for them), while deletions and insertions get the default behavior defined by MacCartney & Manning.
from collections.abc import Mapping
from typing import Literal
type EditorType = Literal["substitute", "delete", "insert"]
type EditorInputOutput = str | tuple[str, str]
type EditorLibraryMapping = Mapping[
EditorType,
Mapping[EditorInputOutput, Editor],
]
class EditorLibrary:
"""A mutable library of editors indexed by edit type and parameters."""
def __init__(self, library: EditorLibraryMapping | None = None) -> None:
self._library: dict[
EditorType,
dict[EditorInputOutput, Editor],
] = {"substitute": {}, "delete": {}, "insert": {}}
if library is not None:
for editor_type, editors in library.items():
self._library[editor_type].update(editors)
def __getitem__(self, key: tuple[EditorType, EditorInputOutput]) -> Editor:
editor_type, edit = key
if edit not in self._library[editor_type]:
self._add_default_editor(editor_type, edit)
return self._library[editor_type][edit]
def __contains__(self, key: tuple[EditorType, EditorInputOutput]) -> bool:
editor_type, edit = key
return edit in self._library[editor_type]
def add_editor(self, editor: Editor) -> None:
if isinstance(editor, Substitution):
self._library["substitute"][(editor.input, editor.output)] = editor
elif isinstance(editor, Insertion):
self._library["insert"][editor.output] = editor
elif isinstance(editor, Deletion):
self._library["delete"][editor.input] = editor
def _add_default_editor(
self,
editor_type: EditorType,
edit: EditorInputOutput,
) -> None:
if editor_type == "substitute":
if not isinstance(edit, tuple):
raise TypeError("a substitution requires an (input, output) pair")
self.add_editor(Substitution(edit[0], edit[1], relation="#"))
elif editor_type == "insert":
if not isinstance(edit, str):
raise TypeError("an insertion requires one output string")
self.add_editor(Insertion(edit))
elif editor_type == "delete":
if not isinstance(edit, str):
raise TypeError("a deletion requires one input string")
self.add_editor(Deletion(edit))Task 2
Lines: 20
We don’t want to have to hand-compute the edits that are required to convert one sentence into another. Instead, we will use a modified form of the StringEdit class we developed in class. What we need in particular are the edit paths that that class produces.
First, we’ll define a class for representing and manipulating edit paths. One important thing we want this class to do is to convert the edit path into a list of editors, for which we need to have a way to look up the editor for a given edit type and parameters given an EditorLibrary.
type EditLocation = int
type EditorParameters = tuple[EditorInputOutput, EditLocation]
type Edit = tuple[EditorType, EditorParameters]
class EditPath:
"""Represent and apply a sequence of text edits."""
def __init__(self, edits: list[Edit]) -> None:
self.edits_unshifted = edits
self.edits = self._shift_indices(edits)
def __repr__(self) -> str:
return str(self.edits)
def __call__(self, input_text: list[str]) -> list[str]:
"""Apply the edit path to the input text."""
current_text = input_text.copy()
for edit_type, (edit, idx) in self.edits:
if edit_type == "substitute":
if not isinstance(edit, tuple):
raise TypeError("a substitution requires an (input, output) pair")
old_word, new_word = edit
if old_word != current_text[idx]:
raise ValueError(
f"Substitution {old_word} -> {new_word} at {idx} "
f"cannot be applied to {current_text}"
)
current_text[idx] = new_word
elif edit_type == "delete":
if not isinstance(edit, str):
raise TypeError("a deletion requires one input string")
current_text.pop(idx)
elif edit_type == "insert":
if not isinstance(edit, str):
raise TypeError("an insertion requires one output string")
current_text.insert(idx, edit)
return current_text
def _shift_indices(self, edit_path: list[Edit]) -> list[Edit]:
"""Adjust indices for preceding insertions and deletions."""
edit_path_shifted: list[Edit] = []
shifts: list[tuple[int, int]] = [(0, -1)]
for i, (edit_type, (edit, idx)) in enumerate(edit_path):
original_idx = idx
for shift_idx, shift in shifts:
idx = idx + shift if idx >= shift_idx else idx
if edit_type == "substitute":
if not isinstance(edit, tuple):
raise TypeError("a substitution requires an (input, output) pair")
if edit[0] == edit[1]:
continue
shifts.append((idx, 0))
elif edit_type == "delete":
if not isinstance(edit, str):
raise TypeError("a deletion requires one input string")
shifts.append((idx, -1))
elif edit_type == "insert":
if not isinstance(edit, str):
raise TypeError("an insertion requires one output string")
shifts.append((idx, 1))
if i + 1 < len(edit_path):
next_edit_type, (_, next_idx) = edit_path[i + 1]
followed_by_substitution = (
next_edit_type == "substitute" and next_idx == original_idx
)
else:
followed_by_substitution = False
if not followed_by_substitution:
idx += 1
edit_path_shifted.append((edit_type, (edit, idx)))
return edit_path_shifted
def to_editors(self, library: EditorLibrary) -> list[tuple[EditLocation, Editor]]:
"""Convert the edit path to indexed editors from a library."""
return [
(edit_idx, library[edit_type, edit])
for edit_type, (edit, edit_idx) in self.edits
]Next, we’ll define a class for computing edit distances, alignments, and edit paths between strings we used in class, with some slight updates for the current assignment.
import numpy as np
from typing import overload
type Alignment = list[tuple[int, int]]
type Alignments = list[Alignment]
class StringEdit:
"""Class for computing edit distances, alignments, and edit paths between strings.
This class implements the Wagner-Fisher algorithm for computing minimum edit
distance between sequences, along with the corresponding alignments and edit paths.
Parameters
----------
insertion_cost : float, default=1.0
Cost of inserting a character.
deletion_cost : float, default=1.0
Cost of deleting a character.
substitution_cost : float or None, default=None
Cost of substituting a character. If None, defaults to insertion_cost + deletion_cost.
"""
def __init__(
self,
insertion_cost: float = 1.0,
deletion_cost: float = 1.0,
substitution_cost: float | None = None,
) -> None:
self._insertion_cost = insertion_cost
self._deletion_cost = deletion_cost
if substitution_cost is None:
self._substitution_cost = insertion_cost + deletion_cost
else:
self._substitution_cost = substitution_cost
@overload
def __call__(
self,
source: list[str],
target: list[str],
only_distance: Literal[True],
) -> float: ...
@overload
def __call__(
self,
source: list[str],
target: list[str],
only_distance: Literal[False] = False,
) -> tuple[float, Alignments, list[EditPath]]: ...
def __call__(
self,
source: list[str],
target: list[str],
only_distance: bool = False,
) -> float | tuple[float, Alignments, list[EditPath]]:
"""Compute edit distance and, by default, all minimum edit paths."""
return self._wagner_fisher(source, target, only_distance)
def _wagner_fisher(
self, source: list[str], target: list[str], only_distance: bool
) -> float | tuple[float, Alignments, list[EditPath]]:
"""Compute minimum edit distance, alignment, and edit sequence using Wagner-Fisher algorithm.
Parameters
----------
source : list[str]
Source sequence.
target : list[str]
Target sequence.
only_distance : bool
If True, return only the edit distance.
Returns
-------
float | tuple[float, Alignment, list[EditPath]]
If only_distance is True, returns just the edit distance.
Otherwise returns a tuple of (distance, alignment, edit_paths).
"""
n, m = len(source), len(target)
source, target = self._add_sentinel(source, target)
# initialize matrices for dynamic programming
distance = np.zeros([n + 1, m + 1], dtype=float)
pointers = np.zeros([n + 1, m + 1], dtype=list)
edits = np.zeros([n + 1, m + 1], dtype=list)
pointers[0, 0] = []
edits[0, 0] = []
# initialize first column (deletions)
for i in range(1, n + 1):
distance[i, 0] = distance[i - 1, 0] + self._deletion_cost
pointers[i, 0] = [(i - 1, 0)]
edits[i, 0] = [("delete", (source[i], i))]
# initialize first row (insertions)
for j in range(1, m + 1):
distance[0, j] = distance[0, j - 1] + self._insertion_cost
pointers[0, j] = [(0, j - 1)]
edits[0, j] = [("insert", (target[j], j))]
# fill in the rest of the matrices
for i in range(1, n + 1):
for j in range(1, m + 1):
if source[i] == target[j]:
substitution_cost = 0.0
else:
substitution_cost = self._substitution_cost
costs = np.array(
[
distance[i - 1, j] + self._deletion_cost,
distance[i - 1, j - 1] + substitution_cost,
distance[i, j - 1] + self._insertion_cost,
]
)
distance[i, j] = costs.min()
best_edits = np.where(costs == distance[i, j])[0]
indices = [(i - 1, j), (i - 1, j - 1), (i, j - 1)]
pointers[i, j] = [indices[k] for k in best_edits]
edit_types: list[Edit] = [
("delete", (source[i], i)),
("substitute", ((source[i], target[j]), i)),
("insert", (target[j], i)),
]
edits[i, j] = [edit_types[k] for k in best_edits]
if only_distance:
return float(distance[n, m])
pointer_backtrace, edit_backtrace = self._construct_backtrace(
pointers, edits, n, m
)
edit_paths = [EditPath(bt) for bt in edit_backtrace]
return distance[n, m], pointer_backtrace, edit_paths
def _construct_backtrace(
self, pointers: np.ndarray, edits: np.ndarray, n: int, m: int
) -> tuple[Alignments, list[list[Edit]]]:
"""Construct all possible backtraces through the dynamic programming matrix.
Parameters
----------
pointers : np.ndarray
Matrix of pointers to previous cells.
edits : np.ndarray
Matrix of edit operations.
n : int
Length of source sequence.
m : int
Length of target sequence.
Returns
-------
tuple[list[list[tuple[int, int]]], list[list[tuple[str, tuple[str, int]]]]]
Returns (pointer_backtraces, edit_backtraces).
"""
stack: list[tuple[Alignment, list[Edit]]] = [([(n, m)], [])]
complete_pointer_backtraces: Alignments = []
complete_edit_backtraces: list[list[Edit]] = []
while stack:
current_pointer_path, current_edit_path = stack.pop()
current_pos = current_pointer_path[-1]
if current_pos == (0, 0):
complete_pointer_backtraces.append(current_pointer_path[::-1])
complete_edit_backtraces.append(current_edit_path[::-1])
continue
for next_pos, edit in zip(pointers[current_pos], edits[current_pos]):
new_pointer_path = current_pointer_path + [next_pos]
new_edit_path = current_edit_path + [edit]
stack.append((new_pointer_path, new_edit_path))
return complete_pointer_backtraces, complete_edit_backtraces
def _add_sentinel(
self,
source: list[str],
target: list[str],
) -> tuple[list[str], list[str]]:
"""Add a sentinel token to each token sequence."""
return ["#"] + source, ["#"] + targetIn the original implementation, the edit path indexed into the source string. This made sense at the time because we wanted to know which words, relative to their original position in the string, are operated on by an edit. It’s problematic for current purposes, because once we compute insertions and deletions, the position of later insertions or deletions change. The implementation below now corrects for this, but just make sure you’re taking into account that the order of edits matters for this reason.
editdist = StringEdit(1, 1, 1)
dist, align, edits = editdist(test_sentence1, test_sentence2)
edit_path_is_correct = all(e(test_sentence1) == test_sentence2 for e in edits)
print("Source: ", test_sentence1)
print("Target: ", test_sentence2)
print("Pointer path:", align)
print("Edit path:", edits)
print("Edited:", [e(test_sentence1) for e in edits])
print("Edit path is correct:", edit_path_is_correct)dist, align, edits = editdist(test_sentence2, test_sentence1)
edit_path_is_correct = all(e(test_sentence2) == test_sentence1 for e in edits)
print("Source: ", test_sentence2)
print("Target: ", test_sentence1)
print("Pointer path:", align)
print("Edit path:", edits)
print("Edited:", [e(test_sentence2) for e in edits])
print("Edit path is correct:", edit_path_is_correct)test_sentence1_prime = ["some"] + test_sentence1[1:]
dist, align, edits = editdist(test_sentence2, test_sentence1_prime)
edit_path_is_correct = all(e(test_sentence2) == test_sentence1_prime for e in edits)
print("Source: ", test_sentence2)
print("Target: ", test_sentence1_prime)
print("Pointer path:", align)
print("Edit path:", edits)
print("Edit path is correct:", edit_path_is_correct)dist, align, edits = editdist(test_sentence1_prime, test_sentence2)
edit_path_is_correct = all(e(test_sentence1_prime) == test_sentence2 for e in edits)
print("Source: ", test_sentence1_prime)
print("Target: ", test_sentence2)
print("Pointer path:", align)
print("Edit path:", edits)
print("Edit path is correct:", edit_path_is_correct)test_sentence1_prime2 = test_sentence1[:4] + ["some"] + test_sentence1[5:]
dist, align, edits = editdist(test_sentence2, test_sentence1_prime2)
edit_path_is_correct = all(e(test_sentence2) == test_sentence1_prime2 for e in edits)
print("Source: ", test_sentence2)
print("Target: ", test_sentence1_prime2)
print("Pointer path:", align)
print("Edit path:", edits)
print("Edit path is correct:", edit_path_is_correct)dist, align, edits = editdist(test_sentence1_prime2, test_sentence2)
edit_path_is_correct = all(e(test_sentence1_prime2) == test_sentence2 for e in edits)
print("Source: ", test_sentence1_prime2)
print("Target: ", test_sentence2)
print("Pointer path:", align)
print("Edit path:", edits)
print(
"Edit path is correct:",
all(e(test_sentence1_prime2) == test_sentence2 for e in edits),
)test_sentence1_prime3 = ["some"] + test_sentence1[1:4] + ["some"] + test_sentence1[5:]
dist, align, edits = editdist(test_sentence1_prime3, test_sentence2)
edit_path_is_correct = all(e(test_sentence1_prime3) == test_sentence2 for e in edits)
print("Source: ", test_sentence1_prime3)
print("Target: ", test_sentence2)
print("Pointer path:", align)
print("Edit path:", edits)
print("Edit path is correct:", edit_path_is_correct)dist, align, edits = editdist(test_sentence2, test_sentence1_prime3)
edit_path_is_correct = all(e(test_sentence2) == test_sentence1_prime3 for e in edits)
print("Source: ", test_sentence2)
print("Target: ", test_sentence1_prime3)
print("Pointer path:", align)
print("Edit path:", edits)
print("Edit path is correct:", edit_path_is_correct)Implement the __call__ method for the NaturalLogic class. This should take a premise sentence and a hypothesis sentence, and it should produce the paths of inferences (computed from the paths of edits) that take you from premise to hypothesis.
Each path should be a list of inferences that result from cumulatively composing the inferences associated with each edit in the path. It should not be a path of local inferences. That is, it should not be a list of inferences that result from each edit in an edit path, but rather a list of inferences that result from composing those edits using Inference.__add__.
You will not be using EditPath.__call__ in any way. That method is implemented to demonstrate how we should apply edit paths to strings. You should instead be using EditPath.to_editors to get the list of editors that result from the edit path, and then you should use those editors to compute the local inferences (again, the inferences that result from applying each editor in sequence).
from typing import ClassVar
type InferencePath = tuple[Inference, ...]
class NaturalLogic:
"""Class for performing natural logic inference between sentences.
This class implements natural logic inference by finding edit paths between sentences
and composing the inferences associated with each edit. It uses an editor library
that maps edit operations (substitutions, deletions, insertions) to Editor instances
that specify the inference relations for those edits.
Parameters
----------
editor_library : EditorLibrary, optional
Library mapping edit types and parameters to Editor instances.
Defaults to an empty EditorLibrary.
Attributes
----------
EDIT : StringEdit
StringEdit instance used for computing edit distances and paths between strings,
with default costs of 1 for substitution, deletion, and insertion.
"""
EDIT: ClassVar[StringEdit] = StringEdit(1, 1, 1)
def __init__(self, editor_library: EditorLibrary | None = None) -> None:
self._editor_library = (
EditorLibrary() if editor_library is None else editor_library
)
def __getitem__(self, key: tuple[EditorType, EditorInputOutput]) -> Editor:
"""Look up an editor in the library.
Parameters
----------
key : tuple[EditorType, EditorInputOutput]
A tuple of the editor type and the edit parameters.
Returns
-------
Editor
The editor for the given type and parameters.
"""
return self._editor_library[key]
@property
def editor_library(self) -> EditorLibrary:
return self._editor_library
def __call__(self, premise: list[str], hypothesis: list[str]) -> set[InferencePath]:
"""Perform natural logic inference between a premise and hypothesis sentence.
This method computes the possible edit paths between the premise and
hypothesis, and then composes the inferences associated with each edit
to yield all possible inference paths implied by the edit paths.
Parameters
----------
premise : list[str]
The premise sentence to infer from.
hypothesis : list[str]
The hypothesis sentence to infer to.
Returns
-------
set[InferencePath]
A set of inference paths representing the paths of cumulatively
composed inferences from the premise to the hypothesis implied by
the edit path.
"""
raise NotImplementedErrorImplement tests using the four test sentences above. (Ignore my modified versions of these sentences.) For now, you can just assume that the editor library contains the editors defined for Task 1. (We don’t need to explicitly specify any insertions that result in \(\sqsupset\) or deletions that result in \(\sqsubset\), since those are added by default by NaturalLogic.add_editor.) In Task 3, we will expand the library using WordNet.
library: dict[EditorType, dict[EditorInputOutput, Editor]] = {
"substitute": {
("virtuosic", "fake"): substitute_fake_for_virtuosic,
("fake", "virtuosic"): substitute_virtuosic_for_fake,
("brindle", "fawn"): substitute_fawn_for_brindle,
},
"delete": {"fake": delete_fake},
"insert": {"fake": insert_fake},
}# write tests hereEvaluating against FraCaS
For the remainder of the assignment (Task 3), we will evaluate our NaturalLogic implementation using the FraCaS textual inference test suite. FraCaS is shipped as XML.
from pathlib import Path
fracas_path = Path("fracas.xml")
if not fracas_path.is_file():
raise FileNotFoundError(
"fracas.xml is bundled with the assignment; run this notebook from "
"the assignments directory"
)
print(f"FraCaS data: {fracas_path.resolve()}")I’ve included a simple corpus reader below.
# Beautiful Soup and lxml are installed by the course requirements.from collections.abc import Iterator
from pathlib import Path
from typing import Self
from bs4 import BeautifulSoup, Tag
class Fracas:
"""Read the FraCaS textual-inference problem set."""
def __init__(self, root: str | Path = "fracas.xml") -> None:
markup = Path(root).read_text(encoding="utf-8")
self._data = BeautifulSoup(markup, "xml")
self._problem_generator: Iterator[FracasProblem] = (
self._construct_problem_generator()
)
def __iter__(self) -> Self:
return self
def __next__(self) -> FracasProblem:
return next(self._problem_generator)
def __repr__(self) -> str:
return str(self._data.comment or "FraCaS corpus")
def _construct_problem_generator(self) -> Iterator[FracasProblem]:
yield from self.problems
@property
def problems(self) -> list[FracasProblem]:
return [
FracasProblem(problem)
for problem in self._data.find_all("problem")
if isinstance(problem, Tag)
]
class FracasProblem:
"""Represent one FraCaS problem."""
def __init__(self, problem: Tag) -> None:
problem_id = problem.get("id")
answer = problem.get("fracas_answer")
if not isinstance(problem_id, str) or not isinstance(answer, str):
raise ValueError("FraCaS problem is missing an id or answer")
premise = problem.find("p")
question = problem.find("q")
hypothesis = problem.find("h")
if (
not isinstance(premise, Tag)
or not isinstance(question, Tag)
or not isinstance(hypothesis, Tag)
):
raise ValueError(f"FraCaS problem {problem_id} is incomplete")
self.id = problem_id
self.answer = answer
self.premise = premise.get_text(strip=True)
self.question = question.get_text(strip=True)
self.hypothesis = hypothesis.get_text(strip=True)
def __repr__(self) -> str:
return (
f"id: {self.id}"
f"\n\npremise: {self.premise}"
f"\nquestion: {self.question}"
f"\nhypothesis: {self.hypothesis}"
f"\n\nanswer: {self.answer}"
)fracas = Fracas()
fracasThe sentences are raw strings, so we first need to tokenize them. The TreebankWordTokenizer is lightweight and deterministic: it does not download a neural model or introduce a dependency on PyTorch. For the WordNet evaluation, we will then reduce each token to a lemma using WordNet’s morphy function. This simplification does not perform full contextual part-of-speech tagging, but it is enough to expose the main lexical-coverage problem in this assignment.
from nltk.tokenize import TreebankWordTokenizer
tokenizer = TreebankWordTokenizer()
def tokenize(sentence: str) -> list[str]:
"""Tokenize and lowercase one sentence."""
return [token.lower() for token in tokenizer.tokenize(sentence)]
tokenize("Every virtuosic synthesist loves some greyhounds.")To use this dataset to test your NaturalLogic implementation, you will need to convert the inference produced by __call__ into a “yes”, “no”, or “don’t know” answer. (Don’t worry about any items not labeled with one of these three. This will require you to define a mapping from inference types to answers. You should then compute the accuracy, precision, recall, and F1 of your system.
Each of these metrics can be defined in terms of…
- The true positive count for class \(c\): \[\mathrm{tp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c) = |\{i\;:\;y^\mathrm{test}_i = \hat{y}^\mathrm{test}_i = c\}|\]
- The true negative count for class \(c\): \[\mathrm{tn}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c) = |\{i\;:\;y^\mathrm{test}_i \neq c \land \hat{y}^\mathrm{test}_i \neq c\}|\]
- The false positive count for class \(c\): \[\mathrm{fp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c) = |\{i\;:\;y^\mathrm{test}_i \neq c \land \hat{y}^\mathrm{test}_i = c\}|\]
- The false negative count for class \(c\): \[\mathrm{fn}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c) = |\{i\;:\;y^\mathrm{test}_i = c \land \hat{y}^\mathrm{test}_i \neq c\}|\]
…where the class is “yes”, “no”, or “unknown”; \(y^\mathrm{test}_i\) is the true label for item \(i\) (found in FraCaS) and \(\hat{y}^\mathrm{test}_i\) is your system’s prediction for the class of item \(i\). (Ignore cases where the class is not one of these three.)
Accuracy
For what proportion of the test data \(\{(x^\mathrm{test}_{1}, y^\mathrm{test}_1), ..., (x^\mathrm{test}_N, y^\mathrm{test}_N)\}\) does the model’s predicted class \(f(x^\mathrm{test}_i) = \hat{y}^\mathrm{test}_i\) for an item match the ground truth class for that item?
\[\mathrm{accuracy}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}\right) = \frac{\sum_{c \in \mathcal{Y}}\mathrm{tp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c)}{N}\]
sklearn.metrics technically provides an accuracy_score function, but generally it’s just as straightforward to compute it yourself.
# Scikit-learn is installed by the course requirements.Precision
For a particular class \(c\), what proportion of the test items that the model said have that class actually have that class?
\[\mathrm{precision}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c\right) = \frac{\mathrm{tp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c)}{\mathrm{tp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c) + \mathrm{fp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c)}\]
For giving an aggregate precision across classes, it’s common to distinguish micro-average precision and macro-average precision.
\[\mathrm{microprecision}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}\right) = \frac{\sum_{c \in \mathcal{Y}} \mathrm{tp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c)}{\sum_{c \in \mathcal{Y}} \mathrm{tp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c) + \mathrm{fp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c)}\]
\[\mathrm{macroprecision}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}\right) = \frac{1}{|\mathcal{Y}|}\sum_{c \in \mathcal{Y}} \mathrm{precision}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c\right)\]
Recall
For a particular class \(c\), what proportion of the test items that have that class did the model correctly predict to have that class?
\[\mathrm{recall}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c\right) = \frac{\mathrm{tp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c)}{\mathrm{tp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c) + \mathrm{fn}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c)}\]
Similar definitions for micro- and macro-average recall can be given.
F1
For a class \(c\), what is the harmonic mean of precision and recall?
\[F_1\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c\right) = \frac{2}{\frac{1}{\mathrm{precision}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c\right)} + \frac{1}{\mathrm{recall}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c\right)}} = 2\frac{\mathrm{precision}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c\right)\;\cdot\;\mathrm{recall}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c\right)}{\mathrm{precision}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c\right) + \mathrm{recall}\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c\right)}\]
To define micro- and macro-average \(F_1\) it can be useful to reexpress it.
\[F_1\left(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c\right) = \frac{2\mathrm{tp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c)}{2\mathrm{tp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c) + \mathrm{fp}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c) + \mathrm{fn}(\hat{\mathbf{y}}^\mathrm{test}_i, \mathbf{y}^\mathrm{test}, c)}\]
Definitions similar to those for precision can be given for micro- and macro-average \(F_1\).
Task 3
Define an instance method NaturalLogic.load_wordnet that constructs an editor library from WordNet hypernymy, hyponymy, and antonymy relations.
import nltk
from nltk.corpus import wordnet
nltk.download("wordnet", quiet=True, raise_on_error=True)
def lemmatize_sentence(sentence: str) -> list[str]:
"""Tokenize a sentence and choose an available WordNet lemma."""
lemmas: list[str] = []
for token in tokenize(sentence):
candidates = (
wordnet.morphy(token, part_of_speech)
for part_of_speech in ("n", "v", "a", "r")
)
lemmas.append(next((lemma for lemma in candidates if lemma), token))
return lemmas
lemmatize_sentence("Every virtuosic synthesist loves some greyhounds.")class WordNetNaturalLogic(NaturalLogic):
def load_wordnet(self) -> None:
raise NotImplementedError
@classmethod
def from_wordnet(cls) -> NaturalLogic:
natlog = cls()
natlog.load_wordnet()
return natlogTest your new library by writing examples that require knowledge of hypernymy, hyponymy, and antonymy to correctly handle.
# write tests hereEvaluate your new library on FraCaS by computing precision, recall, and F1 for the items that are either labeled “yes”, “no”, or “don’t know”. Remember that this is going to require you to define a way of mapping inference types to answers.
# write evaluation hereThese numbers will be bad. The point is to see that handling even the apparently simple cases in FraCaS is very difficult, even with a fairly extensive edit library.
Find at least three examples you get wrong. For each example, identify where in the edit sequence the problem occurs and explain how this issue might be fixed on the basis of what you read in MacCartney and Manning. (Hint: look at how they model quantifiers and negation.) If the MacCartney and Manning approach is not fully sufficient to fix the error, identify what you would need to do to extend it to handle the problem. (Hint: this will often involve changes to your editor library. What would those changes need to look like?)
# write explanation here