Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:46:15 +08:00

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))