3454a55636
cffconvert / validate (push) Has been cancelled
ci-workflow / pre-commit (push) Has been cancelled
ci-workflow / Minimal NLTK Download Test (macos-latest) (push) Has been cancelled
ci-workflow / Minimal NLTK Download Test (ubuntu-latest) (push) Has been cancelled
ci-workflow / Minimal NLTK Download Test (windows-latest) (push) Has been cancelled
ci-workflow / Python 3.10 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.11 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.12 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.13 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.14 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.14t on macos-latest (push) Has been cancelled
ci-workflow / Python 3.10 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.11 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.12 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.13 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.14 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.14t on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.10 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.11 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.12 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.13 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.14 on windows-latest (push) Has been cancelled
492 lines
15 KiB
Python
492 lines
15 KiB
Python
"""
|
|
Regression tests for ``nltk.tag.tnt.TnT``.
|
|
"""
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from nltk.tag.tnt import (
|
|
_BOS,
|
|
_EOS,
|
|
_LOG_FLOOR_2,
|
|
TnT,
|
|
_safe_inverse,
|
|
_safe_log2,
|
|
)
|
|
|
|
_TRAIN = [
|
|
[("the", "DT"), ("cat", "NN"), ("ran", "VBD"), (".", ".")],
|
|
[("the", "DT"), ("running", "VBG"), ("dog", "NN"), ("barked", "VBD"), (".", ".")],
|
|
[("Dianne", "NNP"), ("loves", "VBZ"), ("to", "TO"), ("hug", "VB"), (".", ".")],
|
|
[("Pappy", "NNP"), ("is", "VBZ"), ("very", "RB"), ("loyal", "JJ"), (".", ".")],
|
|
]
|
|
|
|
_AMBIGUITY_TRAIN = [
|
|
[("the", "DT"), ("dogs", "NNS"), (".", ".")],
|
|
[("the", "DT"), ("dogs", "VBZ"), (".", ".")],
|
|
[("the", "DT"), ("fish", "NN"), ("swims", "VBZ"), (".", ".")],
|
|
[("the", "DT"), ("fish", "NN"), ("swims", "NNS"), (".", ".")],
|
|
[("dogs", "NNS"), ("run", "VBP"), ("fast", "RB"), (".", ".")],
|
|
[("the", "DT"), ("run", "NN"), ("ended", "VBD"), (".", ".")],
|
|
[("dogs", "NNS"), ("bark", "VBP"), ("loudly", "RB"), (".", ".")],
|
|
[("a", "DT"), ("bark", "NN"), ("echoed", "VBD"), (".", ".")],
|
|
]
|
|
|
|
_OOV_WORDS = ["xyzzy", "friendo", "diogenes", "phlogiston"]
|
|
_OOV_HEAVY_SENT = ["doodad", "watched", "friendo", "playing", "happily", "."]
|
|
_LONG_AMBIGUOUS_SENT = ["dogs", "run", "bark"] * 334 + ["."] # 1003 tokens
|
|
|
|
_MODEL_STATE_FIELDS = (
|
|
"_lambda1",
|
|
"_lambda2",
|
|
"_lambda3",
|
|
"_num_tag_tokens",
|
|
"_log2_beam_threshold",
|
|
"_tag_prior_probs",
|
|
"_theta",
|
|
"_trans_logp_unigram",
|
|
"_trans_logp_bigram",
|
|
"_trans_logp_trigram",
|
|
)
|
|
|
|
|
|
class _CountingUnk:
|
|
def __init__(self):
|
|
self.train_calls = 0
|
|
|
|
def train(self, _data):
|
|
self.train_calls += 1
|
|
|
|
def tag(self, toks):
|
|
return [(w, "NN") for w in toks]
|
|
|
|
|
|
class _AlternatingUnk:
|
|
def __init__(self):
|
|
self.flip = False
|
|
|
|
def train(self, _data):
|
|
pass
|
|
|
|
def tag(self, toks):
|
|
self.flip = not self.flip
|
|
return [(w, "NN" if self.flip else "JJ") for w in toks]
|
|
|
|
|
|
class _ConstantTagUnk:
|
|
def __init__(self, tag="NONESUCH"):
|
|
self._constant = tag
|
|
|
|
def train(self, _data):
|
|
pass
|
|
|
|
def tag(self, toks):
|
|
return [(w, self._constant) for w in toks]
|
|
|
|
|
|
class _ExtraTagsUnk:
|
|
def train(self, _data):
|
|
pass
|
|
|
|
def tag(self, toks):
|
|
return [(toks[0], "X"), ("extra", "X")]
|
|
|
|
|
|
class _EmptyOutputUnk:
|
|
def train(self, _data):
|
|
pass
|
|
|
|
def tag(self, _toks):
|
|
return []
|
|
|
|
|
|
def _trained_tagger(train_data=None, **kwargs):
|
|
if train_data is None:
|
|
train_data = _TRAIN
|
|
t = TnT(**kwargs)
|
|
t.train(train_data)
|
|
return t
|
|
|
|
|
|
def _trained_tags(train):
|
|
return {tag for sent in train for _, tag in sent}
|
|
|
|
|
|
def _words(sent):
|
|
return [word for word, _ in sent]
|
|
|
|
|
|
def _assert_tag_output(words, out):
|
|
assert len(out) == len(words)
|
|
assert all(isinstance(pair, tuple) and len(pair) == 2 for pair in out)
|
|
assert [w for w, _ in out] == words
|
|
assert all(isinstance(tag, str) for _, tag in out)
|
|
|
|
|
|
def _assert_decode_stable(tagger, words, repeats=2):
|
|
known, unknown = tagger.known, tagger.unknown
|
|
try:
|
|
first = tagger.tag(words)
|
|
for _ in range(repeats - 1):
|
|
assert tagger.tag(words) == first
|
|
return first
|
|
finally:
|
|
tagger.known, tagger.unknown = known, unknown
|
|
|
|
|
|
def _state(tagger, word, tag):
|
|
return (tag, tagger._use_capitalization and bool(word) and word[0].isupper())
|
|
|
|
|
|
def _cfd_snapshot(cfd):
|
|
return {condition: dict(dist) for condition, dist in cfd.items()}
|
|
|
|
|
|
def _suffix_snapshot(tagger):
|
|
return {
|
|
cap: _cfd_snapshot(suffix_trie)
|
|
for cap, suffix_trie in tagger._suffix_trie_by_cap.items()
|
|
}
|
|
|
|
|
|
def _model_state(tagger):
|
|
return {field: getattr(tagger, field) for field in _MODEL_STATE_FIELDS}
|
|
|
|
|
|
def _assert_model_state_equal(a, b):
|
|
assert a._beam_threshold == b._beam_threshold
|
|
assert a._use_capitalization == b._use_capitalization
|
|
assert a._unk_trained == b._unk_trained
|
|
assert dict(a._tag_unigrams) == dict(b._tag_unigrams)
|
|
assert _cfd_snapshot(a._tag_bigrams) == _cfd_snapshot(b._tag_bigrams)
|
|
assert _cfd_snapshot(a._tag_trigrams) == _cfd_snapshot(b._tag_trigrams)
|
|
assert _cfd_snapshot(a._word_tag_freqs) == _cfd_snapshot(b._word_tag_freqs)
|
|
assert _suffix_snapshot(a) == _suffix_snapshot(b)
|
|
assert _model_state(a) == _model_state(b)
|
|
|
|
|
|
def _decode_mutation_snapshot(tagger):
|
|
return (
|
|
dict(tagger._tag_unigrams),
|
|
_cfd_snapshot(tagger._tag_bigrams),
|
|
_cfd_snapshot(tagger._tag_trigrams),
|
|
_cfd_snapshot(tagger._word_tag_freqs),
|
|
_suffix_snapshot(tagger),
|
|
)
|
|
|
|
|
|
def _deleted_interpolation_logp(tagger, prev2, prev1, current):
|
|
"""Reference deleted-interpolation transition log-prob from the
|
|
trained n-gram counts (the formula the cache should match)."""
|
|
l1, l2, l3 = tagger._lambda1, tagger._lambda2, tagger._lambda3
|
|
inv_total = _safe_inverse(tagger._num_tag_tokens)
|
|
bigram_dist = tagger._tag_bigrams.get(prev1)
|
|
trigram_dist = tagger._tag_trigrams.get((prev2, prev1))
|
|
p = l1 * (tagger._tag_unigrams[current] * inv_total)
|
|
if bigram_dist is not None:
|
|
p += l2 * bigram_dist.get(current, 0) * _safe_inverse(bigram_dist.N())
|
|
if trigram_dist is not None:
|
|
p += l3 * trigram_dist.get(current, 0) * _safe_inverse(trigram_dist.N())
|
|
return _safe_log2(p)
|
|
|
|
|
|
def _cached_logp(tagger, prev2, prev1, current):
|
|
"""Tier-by-tier lookup against the precomputed transition cache."""
|
|
v = tagger._trans_logp_trigram.get((prev2, prev1), {}).get(current)
|
|
if v is not None:
|
|
return v
|
|
v = tagger._trans_logp_bigram.get(prev1, {}).get(current)
|
|
if v is not None:
|
|
return v
|
|
return tagger._trans_logp_unigram.get(current, _LOG_FLOOR_2)
|
|
|
|
|
|
def _assert_transition_match(tagger, prev2, prev1, current):
|
|
assert _cached_logp(tagger, prev2, prev1, current) == _deleted_interpolation_logp(
|
|
tagger, prev2, prev1, current
|
|
)
|
|
|
|
|
|
def _assert_observed_trigrams_match(tagger):
|
|
seen = False
|
|
for (prev2, prev1), dist in tagger._tag_trigrams.items():
|
|
for current in dist:
|
|
_assert_transition_match(tagger, prev2, prev1, current)
|
|
seen = True
|
|
assert seen, "fixture should expose observed trigrams"
|
|
|
|
|
|
def _assert_bigram_fallbacks_match(tagger, bogus_prev2):
|
|
for prev1, dist in tagger._tag_bigrams.items():
|
|
if (bogus_prev2, prev1) in tagger._tag_trigrams:
|
|
continue
|
|
for current in dist:
|
|
_assert_transition_match(tagger, bogus_prev2, prev1, current)
|
|
|
|
|
|
def _assert_unigram_fallbacks_match(tagger, bogus_prev2, bogus_prev1):
|
|
assert bogus_prev1 not in tagger._tag_bigrams.conditions()
|
|
for state in tagger._tag_unigrams:
|
|
_assert_transition_match(tagger, bogus_prev2, bogus_prev1, state)
|
|
|
|
|
|
_BOGUS_PREV2 = ("BOGUS_PREV2", False)
|
|
_BOGUS_PREV1 = ("BOGUS_PREV1", False)
|
|
_BOGUS_STATE = ("NEVER_SEEN", False)
|
|
|
|
|
|
def _assert_transition_cache_matches_formula(tagger):
|
|
_assert_observed_trigrams_match(tagger)
|
|
_assert_bigram_fallbacks_match(tagger, _BOGUS_PREV2)
|
|
_assert_unigram_fallbacks_match(tagger, _BOGUS_PREV2, _BOGUS_PREV1)
|
|
|
|
assert (
|
|
_cached_logp(tagger, _BOGUS_PREV2, _BOGUS_PREV1, _BOGUS_STATE) == _LOG_FLOOR_2
|
|
)
|
|
assert (
|
|
_deleted_interpolation_logp(tagger, _BOGUS_PREV2, _BOGUS_PREV1, _BOGUS_STATE)
|
|
== _LOG_FLOOR_2
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def tagger():
|
|
return _trained_tagger()
|
|
|
|
|
|
@pytest.fixture(params=[False, True], ids=["C_False", "C_True"])
|
|
def reordered_taggers(request):
|
|
return (
|
|
_trained_tagger(C=request.param),
|
|
_trained_tagger(list(reversed(_TRAIN)), C=request.param),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
[0, -1, 0.5, math.nan, math.inf, -math.inf, True, False, "1000", None, [1000]],
|
|
)
|
|
def test_invalid_n_raises_value_error(bad):
|
|
with pytest.raises(ValueError):
|
|
TnT(N=bad)
|
|
|
|
|
|
def test_empty_training_resets_interpolation_weights():
|
|
t = _trained_tagger([])
|
|
assert (t._lambda1, t._lambda2, t._lambda3) == (0.0, 0.0, 0.0)
|
|
|
|
|
|
def test_interpolation_weights_are_normalized(tagger):
|
|
lambdas = (tagger._lambda1, tagger._lambda2, tagger._lambda3)
|
|
assert all(x >= 0 for x in lambdas)
|
|
assert math.isclose(sum(lambdas), 1.0, abs_tol=1e-12)
|
|
|
|
|
|
def test_repeated_train_rebuilds_state_and_clears_decode_cache():
|
|
t = _trained_tagger(_AMBIGUITY_TRAIN)
|
|
t.tag(["xyzzy"])
|
|
assert t._candidate_tags_cache
|
|
|
|
t.train(_TRAIN)
|
|
|
|
assert not t._candidate_tags_cache
|
|
_assert_model_state_equal(t, _trained_tagger(_TRAIN))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("trained_flag", "expected_calls"),
|
|
[(False, 1), (True, 0)],
|
|
ids=["default", "trained_flag"],
|
|
)
|
|
def test_external_unk_train_respects_trained_flag(trained_flag, expected_calls):
|
|
cu = _CountingUnk()
|
|
t = _trained_tagger(unk=cu, Trained=trained_flag)
|
|
assert cu.train_calls == expected_calls
|
|
t.train(_TRAIN)
|
|
assert cu.train_calls == expected_calls
|
|
|
|
|
|
def test_train_records_eos_after_sentence_final_tag(tagger):
|
|
"""EOS is folded into unigram/bigram/trigram counts and attributed
|
|
to the actual final-tag history from training, not a hardcoded
|
|
predecessor."""
|
|
expected_unigram = sum(1 for sent in _TRAIN if sent)
|
|
assert tagger._tag_unigrams[_EOS] == expected_unigram
|
|
|
|
dot_state = _state(tagger, ".", ".")
|
|
|
|
expected_bigram_count = 0
|
|
expected_trigram_counts = {}
|
|
|
|
for sent in _TRAIN:
|
|
if not sent or sent[-1][0] != ".":
|
|
continue
|
|
expected_bigram_count += 1
|
|
prev_state = _state(tagger, sent[-2][0], sent[-2][1])
|
|
history = (prev_state, dot_state)
|
|
expected_trigram_counts[history] = expected_trigram_counts.get(history, 0) + 1
|
|
|
|
assert tagger._tag_bigrams[dot_state][_EOS] == expected_bigram_count
|
|
for history, count in expected_trigram_counts.items():
|
|
assert tagger._tag_trigrams[history][_EOS] == count
|
|
|
|
|
|
def test_empty_sentences_do_not_record_eos_at_bos():
|
|
t = _trained_tagger([[], [("the", "DT"), ("cat", "NN"), (".", ".")], []])
|
|
assert _EOS not in t._tag_bigrams[_BOS]
|
|
assert _EOS not in t._tag_trigrams[(_BOS, _BOS)]
|
|
|
|
|
|
def test_tagging_oov_words_does_not_mutate_trained_counts(tagger):
|
|
before = _decode_mutation_snapshot(tagger)
|
|
|
|
for word in _OOV_WORDS:
|
|
tagger.tag([word])
|
|
|
|
assert _decode_mutation_snapshot(tagger) == before
|
|
|
|
|
|
def test_untrained_tagger_tags_unknown_word_as_unk():
|
|
assert TnT().tag(["xyzzy"]) == [("xyzzy", "Unk")]
|
|
|
|
|
|
def test_external_unk_unseen_tag_uses_transition_floor():
|
|
"""An external ``unk`` may return a tag never seen during training;
|
|
the transition cache falls through to the model floor and the path
|
|
stays alive."""
|
|
t = _trained_tagger(unk=_ConstantTagUnk("NONESUCH"))
|
|
assert t.tag(["xyzzy"]) == [("xyzzy", "NONESUCH")]
|
|
|
|
|
|
def test_external_unk_is_not_cached():
|
|
"""Stateful ``unk`` taggers must be invoked on every call; otherwise
|
|
caching collapses their varying output into a single result."""
|
|
t = _trained_tagger(unk=_AlternatingUnk())
|
|
seen = [t.tag(["xyzzy"])[0][1] for _ in range(3)]
|
|
assert seen == ["NN", "JJ", "NN"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("unk_class", "expected_n"),
|
|
[(_ExtraTagsUnk, 2), (_EmptyOutputUnk, 0)],
|
|
ids=["extra_tags", "no_tags"],
|
|
)
|
|
def test_external_unk_wrong_length_output_raises_clear_error(unk_class, expected_n):
|
|
t = _trained_tagger(unk=unk_class())
|
|
with pytest.raises(ValueError, match=f"returned {expected_n} tags for 1 word"):
|
|
t.tag(["xyzzy"])
|
|
|
|
|
|
def test_transition_logp_cache_matches_deleted_interpolation_formula(tagger):
|
|
_assert_transition_cache_matches_formula(tagger)
|
|
|
|
|
|
def test_known_words_decode_to_their_only_seen_tag(tagger):
|
|
for sent in _TRAIN:
|
|
assert tagger.tag(_words(sent)) == sent
|
|
|
|
|
|
def test_threshold_pruning_keeps_viable_ambiguous_beam():
|
|
t = _trained_tagger(_AMBIGUITY_TRAIN, N=2)
|
|
words = ["the", "dogs", "."]
|
|
_assert_tag_output(words, t.tag(words))
|
|
|
|
|
|
def test_decode_tie_breaking_is_repeat_stable():
|
|
t = _trained_tagger(_AMBIGUITY_TRAIN, N=1000)
|
|
_assert_decode_stable(t, ["the", "fish", "swims", "."], repeats=10)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"train",
|
|
[pytest.param(_TRAIN, id="full"), pytest.param(_TRAIN[:1], id="one_sent")],
|
|
)
|
|
def test_suffix_model_decodes_oov_heavy_sentence_repeatably(train):
|
|
t = _trained_tagger(train)
|
|
out = _assert_decode_stable(t, _OOV_HEAVY_SENT)
|
|
|
|
_assert_tag_output(_OOV_HEAVY_SENT, out)
|
|
assert all(tag in _trained_tags(train) for _, tag in out)
|
|
|
|
|
|
def test_iterative_decode_handles_long_ambiguous_sentence():
|
|
"""Long ambiguous input must decode iteratively and repeatably. The
|
|
length is above Python's default recursion limit, so a recursive
|
|
decoder would fail here."""
|
|
t = _trained_tagger(_AMBIGUITY_TRAIN, N=1000)
|
|
out = _assert_decode_stable(t, _LONG_AMBIGUOUS_SENT)
|
|
_assert_tag_output(_LONG_AMBIGUOUS_SENT, out)
|
|
|
|
|
|
def test_candidate_cache_does_not_grow_on_repeated_sentence(tagger):
|
|
sent = _OOV_WORDS[:2] + ["the", "cat"]
|
|
tagger.tag(sent)
|
|
size_after_first = len(tagger._candidate_tags_cache)
|
|
tagger.tag(sent)
|
|
assert len(tagger._candidate_tags_cache) == size_after_first
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("words", "segment", "expected_len"),
|
|
[
|
|
([], False, 0),
|
|
([], True, 0),
|
|
(["the", "cat", ".", "the", "dog", "."], True, 6),
|
|
(["the", "cat", ".", "the", "dog"], True, 5),
|
|
],
|
|
)
|
|
def test_segmented_tagging_preserves_output_shape(tagger, words, segment, expected_len):
|
|
out = tagger.tag(words, segment=segment)
|
|
assert len(out) == expected_len
|
|
if expected_len > 0:
|
|
_assert_tag_output(words, out)
|
|
|
|
|
|
def test_segment_true_matches_segment_false_on_single_sentence(tagger):
|
|
words = ["the", "cat", "ran", "."]
|
|
assert tagger.tag(words) == tagger.tag(words, segment=True)
|
|
|
|
|
|
def test_tagdata_forwards_segment_kwarg_to_tag(tagger):
|
|
inputs = [
|
|
["the", "cat", ".", "the", "dog", "."],
|
|
["beagles", "are", "happy", "to", "rest", "."],
|
|
]
|
|
assert tagger.tagdata(inputs, segment=True) == [
|
|
tagger.tag(s, segment=True) for s in inputs
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("method", "match"),
|
|
[("tag", "list of tokens"), ("tagdata", "list of tokenized sentences")],
|
|
)
|
|
@pytest.mark.parametrize("bad", ["the cat sat", b"the cat sat"])
|
|
def test_tag_and_tagdata_reject_string_or_bytes_input(tagger, bad, method, match):
|
|
"""``str`` would iterate over characters and ``bytes`` over ints; each
|
|
method catches the common 'forgot to tokenize' mistake at its own
|
|
boundary."""
|
|
with pytest.raises(TypeError, match=match):
|
|
getattr(tagger, method)(bad)
|
|
|
|
|
|
def test_pickle_round_trip_preserves_model_state_and_tags(tagger):
|
|
import pickle
|
|
|
|
restored = pickle.loads(pickle.dumps(tagger))
|
|
_assert_model_state_equal(restored, tagger)
|
|
|
|
for sent in _TRAIN:
|
|
assert restored.tag(_words(sent)) == tagger.tag(_words(sent))
|
|
|
|
|
|
def test_training_data_reordering_preserves_model_state(reordered_taggers):
|
|
_assert_model_state_equal(*reordered_taggers)
|
|
|
|
|
|
def test_training_data_reordering_preserves_decoded_output(reordered_taggers):
|
|
a, b = reordered_taggers
|
|
for sent in _TRAIN:
|
|
assert a.tag(_words(sent)) == b.tag(_words(sent))
|