Skip to content

Commit 8a5619f

Browse files
authored
Merge pull request #3222 from Syzygy2048/feature/texttiling-vocabulary-introduction
Implement vocabulary introduction for texttiling
2 parents c6574d7 + 5828da8 commit 8a5619f

3 files changed

Lines changed: 286 additions & 6 deletions

File tree

AUTHORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@
311311
- Jose Cols <https://github.com/josecols>
312312
- Christopher Smith <https://github.com/smithct2>
313313
- Ryan Mannion <https://github.com/ryanamannion>
314+
- Peter Pollak <https://github.com/Syzygy2048/>
314315

315316
## Others whose work we've taken and included in NLTK, but who didn't directly contribute it:
316317

nltk/test/unit/test_texttiling.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""
2+
Unit tests for nltk.tokenize.texttiling.
3+
4+
Marti A. Hearst (1997) "TextTiling: Segmenting Text into Multi-Paragraph
5+
Subtopic Passages", Computational Linguistics, 23(1), pp. 33-64.
6+
https://aclanthology.org/J97-1003.pdf
7+
"""
8+
9+
from nltk.tokenize.texttiling import (
10+
BLOCK_COMPARISON,
11+
VOCABULARY_INTRODUCTION,
12+
TextTilingTokenizer,
13+
)
14+
15+
# A text with clear topic shifts for testing.
16+
# Topic 1: animals in nature
17+
# Topic 2: stock market / finance
18+
# Topic 3: marine biology
19+
# Topic 4: weather / agriculture
20+
MULTI_TOPIC_TEXT = (
21+
"""
22+
The quick brown fox jumped over the lazy dog. The dog was sleeping
23+
in the sun. It was a beautiful day outside in the forest. The fox
24+
continued on its way through the dense woodland. Birds were singing
25+
in the trees and rabbits scurried through the underbrush. The animals
26+
were enjoying the warm afternoon sunshine.
27+
28+
Meanwhile, in the city, the stock market was experiencing significant
29+
changes. Traders were anxious about the new economic policies. The
30+
financial sector was particularly affected by the recent changes in
31+
interest rates. Banks reported lower quarterly earnings. Investors
32+
were pulling their money out of risky assets. The central bank was
33+
considering raising rates again.
34+
35+
In other news, scientists discovered a new species of deep-sea fish.
36+
The fish was found at a depth of three thousand meters below the
37+
surface. Marine biologists were excited about the discovery. The
38+
specimen was brought to the laboratory for further study. Researchers
39+
believe the species has been living in the deep ocean for millions
40+
of years.
41+
42+
The weather forecast predicted rain for the weekend across the region.
43+
Farmers were hoping for the precipitation to help their growing crops.
44+
The drought had lasted several months with no relief in sight. Water
45+
reservoirs were at critically low levels. Irrigation systems were
46+
being strained beyond their capacity.
47+
"""
48+
* 3
49+
) # repeat to get enough text for the algorithm
50+
51+
52+
class TestTextTilingBlockComparison:
53+
"""Tests for the block comparison method (Hearst 1997, Section 3.2)."""
54+
55+
def test_returns_segments(self):
56+
"""Block comparison should return a list of text segments."""
57+
tt = TextTilingTokenizer(w=10, k=5, similarity_method=BLOCK_COMPARISON)
58+
segments = tt.tokenize(MULTI_TOPIC_TEXT)
59+
assert isinstance(segments, list)
60+
assert len(segments) >= 1
61+
62+
def test_segments_cover_full_text(self):
63+
"""Concatenated segments should reconstruct the original text."""
64+
tt = TextTilingTokenizer(w=10, k=5, similarity_method=BLOCK_COMPARISON)
65+
segments = tt.tokenize(MULTI_TOPIC_TEXT)
66+
assert "".join(segments) == MULTI_TOPIC_TEXT
67+
68+
def test_demo_mode_returns_scores(self):
69+
"""In demo mode, should return (gap_scores, smooth, depth, boundaries)."""
70+
tt = TextTilingTokenizer(
71+
w=10, k=5, similarity_method=BLOCK_COMPARISON, demo_mode=True
72+
)
73+
result = tt.tokenize(MULTI_TOPIC_TEXT)
74+
assert len(result) == 4
75+
gap_scores, smooth_scores, depth_scores, boundaries = result
76+
assert len(gap_scores) > 0
77+
assert len(smooth_scores) > 0
78+
assert len(depth_scores) > 0
79+
assert len(boundaries) > 0
80+
81+
def test_gap_scores_between_zero_and_one(self):
82+
"""Block comparison gap scores should be in [0, 1] (cosine similarity)."""
83+
tt = TextTilingTokenizer(
84+
w=10, k=5, similarity_method=BLOCK_COMPARISON, demo_mode=True
85+
)
86+
gap_scores, _, _, _ = tt.tokenize(MULTI_TOPIC_TEXT)
87+
for score in gap_scores:
88+
assert 0.0 <= score <= 1.0, f"Score {score} out of [0, 1] range"
89+
90+
def test_homogeneous_text_few_boundaries(self):
91+
"""Repeating the same paragraph should produce few boundaries."""
92+
paragraph = "The cat sat on the mat. The dog chased the ball. "
93+
# Need paragraph breaks (double newlines) for the algorithm
94+
repeated = ("\n\n".join([paragraph] * 10)) + "\n\n"
95+
tt = TextTilingTokenizer(w=10, k=5, similarity_method=BLOCK_COMPARISON)
96+
segments = tt.tokenize(repeated)
97+
# Homogeneous text should have few segments
98+
assert len(segments) <= 5
99+
100+
101+
class TestTextTilingVocabIntroduction:
102+
"""Tests for the vocabulary introduction method (Hearst 1997, Section 3.2)."""
103+
104+
def test_returns_segments(self):
105+
"""Vocabulary introduction should return a list of text segments."""
106+
tt = TextTilingTokenizer(w=10, k=5, similarity_method=VOCABULARY_INTRODUCTION)
107+
segments = tt.tokenize(MULTI_TOPIC_TEXT)
108+
assert isinstance(segments, list)
109+
assert len(segments) >= 1
110+
111+
def test_segments_cover_full_text(self):
112+
"""Concatenated segments should reconstruct the original text."""
113+
tt = TextTilingTokenizer(w=10, k=5, similarity_method=VOCABULARY_INTRODUCTION)
114+
segments = tt.tokenize(MULTI_TOPIC_TEXT)
115+
assert "".join(segments) == MULTI_TOPIC_TEXT
116+
117+
def test_demo_mode_returns_scores(self):
118+
"""In demo mode, should return (gap_scores, smooth, depth, boundaries)."""
119+
tt = TextTilingTokenizer(
120+
w=10, k=5, similarity_method=VOCABULARY_INTRODUCTION, demo_mode=True
121+
)
122+
result = tt.tokenize(MULTI_TOPIC_TEXT)
123+
assert len(result) == 4
124+
gap_scores, smooth_scores, depth_scores, boundaries = result
125+
assert len(gap_scores) > 0
126+
assert len(smooth_scores) > 0
127+
assert len(depth_scores) > 0
128+
assert len(boundaries) > 0
129+
130+
def test_gap_scores_non_negative(self):
131+
"""Vocabulary introduction scores should be non-negative."""
132+
tt = TextTilingTokenizer(
133+
w=10, k=5, similarity_method=VOCABULARY_INTRODUCTION, demo_mode=True
134+
)
135+
gap_scores, _, _, _ = tt.tokenize(MULTI_TOPIC_TEXT)
136+
for score in gap_scores:
137+
assert score >= 0.0, f"Score {score} is negative"
138+
139+
def test_homogeneous_text_low_scores(self):
140+
"""Repeating the same paragraph should produce low vocab intro scores."""
141+
paragraph = "The cat sat on the mat. The dog chased the ball. "
142+
repeated = ("\n\n".join([paragraph] * 10)) + "\n\n"
143+
tt = TextTilingTokenizer(
144+
w=10, k=5, similarity_method=VOCABULARY_INTRODUCTION, demo_mode=True
145+
)
146+
gap_scores, _, _, _ = tt.tokenize(repeated)
147+
# After the first few windows, no new vocabulary is introduced
148+
# so later scores should be near zero
149+
later_scores = gap_scores[len(gap_scores) // 2 :]
150+
avg_later = sum(later_scores) / len(later_scores) if later_scores else 0
151+
assert (
152+
avg_later < 0.15
153+
), f"Average later score {avg_later} too high for repeated text"
154+
155+
156+
class TestTextTilingCommon:
157+
"""Tests common to both methods."""
158+
159+
def test_both_methods_find_boundaries(self):
160+
"""Both methods should find at least one boundary in multi-topic text."""
161+
for method in [BLOCK_COMPARISON, VOCABULARY_INTRODUCTION]:
162+
tt = TextTilingTokenizer(w=10, k=5, similarity_method=method)
163+
segments = tt.tokenize(MULTI_TOPIC_TEXT)
164+
assert (
165+
len(segments) > 1
166+
), f"Method {method} found no boundaries in multi-topic text"
167+
168+
def test_invalid_similarity_method(self):
169+
"""Invalid similarity method should raise ValueError."""
170+
tt = TextTilingTokenizer(similarity_method="invalid")
171+
try:
172+
tt.tokenize(MULTI_TOPIC_TEXT)
173+
assert False, "Expected ValueError"
174+
except ValueError:
175+
pass
176+
177+
def test_invalid_smoothing_method(self):
178+
"""Invalid smoothing method should raise ValueError."""
179+
tt = TextTilingTokenizer(smoothing_method="invalid")
180+
try:
181+
tt.tokenize(MULTI_TOPIC_TEXT)
182+
assert False, "Expected ValueError"
183+
except ValueError:
184+
pass
185+
186+
def test_boundary_count_matches_segments(self):
187+
"""Number of boundaries should be number of segments minus 1."""
188+
for method in [BLOCK_COMPARISON, VOCABULARY_INTRODUCTION]:
189+
tt = TextTilingTokenizer(w=10, k=5, similarity_method=method)
190+
segments = tt.tokenize(MULTI_TOPIC_TEXT)
191+
tt_demo = TextTilingTokenizer(
192+
w=10, k=5, similarity_method=method, demo_mode=True
193+
)
194+
_, _, _, boundaries = tt_demo.tokenize(MULTI_TOPIC_TEXT)
195+
num_boundaries = sum(boundaries)
196+
# segments = boundaries + 1 (approximately, due to normalization)
197+
assert num_boundaries >= len(segments) - 2

nltk/tokenize/texttiling.py

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616

1717
from nltk.tokenize.api import TokenizerI
1818

19-
BLOCK_COMPARISON, VOCABULARY_INTRODUCTION = 0, 1
19+
BLOCK_COMPARISON = "block_comparison"
20+
VOCABULARY_INTRODUCTION = "vocabulary_introduction"
2021
LC, HC = 0, 1
2122
DEFAULT_SMOOTHING = [0]
2223

@@ -118,7 +119,7 @@ def tokenize(self, text):
118119
if self.similarity_method == BLOCK_COMPARISON:
119120
gap_scores = self._block_comparison(tokseqs, token_table)
120121
elif self.similarity_method == VOCABULARY_INTRODUCTION:
121-
raise NotImplementedError("Vocabulary introduction not implemented")
122+
gap_scores = self._vocabulary_introduction(tokseqs)
122123
else:
123124
raise ValueError(
124125
f"Similarity method {self.similarity_method} not recognized"
@@ -157,6 +158,83 @@ def tokenize(self, text):
157158
return gap_scores, smooth_scores, depth_scores, segment_boundaries
158159
return segmented_text
159160

161+
def _vocabulary_introduction(self, tokseqs):
162+
"""Compute gap scores using the Vocabulary Introduction method.
163+
164+
From Marti A. Hearst (1997) "TextTiling: Segmenting Text into
165+
Multi-Paragraph Subtopic Passages", Computational Linguistics,
166+
23(1), pp. 33-64. https://aclanthology.org/J97-1003.pdf
167+
168+
Section 3.2:
169+
170+
The idea behind this approach is that the introduction of a new
171+
topic in a text is signaled by the distribution of new vocabulary
172+
items. For each pseudosentence gap, we count the number of new
173+
word types appearing on each side of the gap that have not been
174+
seen in earlier pseudosentences on that side.
175+
176+
Schematically (adapted from Fig 3, Hearst 1997)::
177+
178+
pseudosentences: [ s1 ] [ s2 ] [ s3 ] [ s4 ] [ s5 ] ...
179+
^
180+
gap at i=2
181+
|
182+
left side of gap: s1, s2 | right side: s3, s4, s5, ...
183+
(scan left->right) | (scan right->left)
184+
|
185+
new_L(i) = words in s_i | new_R(i) = words in s_{i+1}
186+
not seen in s1..s_{i-1} | not seen in s_{i+2}..s_N
187+
188+
The score for each gap is::
189+
190+
score(i) = ( new_L(i) + new_R(i) ) / (2 * w)
191+
192+
where ``w`` is the pseudosentence size, so ``2 * w`` normalizes
193+
by the maximum possible number of new word types across both
194+
sides of the gap.
195+
196+
:param tokseqs: list of TokenSequence objects
197+
:return: list of gap scores (length = len(tokseqs) - 1)
198+
"""
199+
n = len(tokseqs)
200+
if n < 2:
201+
return []
202+
203+
# Extract the word type sets for each pseudosentence
204+
tokseq_sets = [{token for token, _ in seq.wrdindex_list} for seq in tokseqs]
205+
206+
# Normalization factor (Section 3.2)
207+
norm = self.w * 2
208+
209+
# Scan left-to-right: for each position i, count how many words
210+
# in tokseq_sets[i] are new (not seen in any s_0..s_{i-1}).
211+
new_left = []
212+
seen_left = set()
213+
for i in range(n):
214+
new_count = len(tokseq_sets[i] - seen_left)
215+
new_left.append(new_count)
216+
seen_left |= tokseq_sets[i]
217+
218+
# Scan right-to-left: for each position i, count how many words
219+
# in tokseq_sets[i] are new (not seen in any s_{i+1}..s_{N-1}).
220+
new_right = [0] * n
221+
seen_right = set()
222+
for i in range(n - 1, -1, -1):
223+
new_count = len(tokseq_sets[i] - seen_right)
224+
new_right[i] = new_count
225+
seen_right |= tokseq_sets[i]
226+
227+
# Score each gap between pseudosentence i and i+1.
228+
# The left contribution comes from the pseudosentence just
229+
# before the gap (new_left[i]), and the right contribution
230+
# from the pseudosentence just after (new_right[i+1]).
231+
gap_scores = []
232+
for i in range(n - 1):
233+
score = (new_left[i] + new_right[i + 1]) / norm
234+
gap_scores.append(score)
235+
236+
return gap_scores
237+
160238
def _block_comparison(self, tokseqs, token_table):
161239
"""Implements the block comparison method"""
162240

@@ -429,10 +507,12 @@ def smooth(x, window_len=11, window="flat"):
429507
"""
430508

431509
if x.ndim != 1:
432-
raise ValueError("smooth only accepts 1 dimension arrays.")
510+
raise ValueError(f"smooth only accepts 1 dimension arrays, was {x.ndim}.")
433511

434512
if x.size < window_len:
435-
raise ValueError("Input vector needs to be bigger than window size.")
513+
raise ValueError(
514+
f"Input vector ({len(x)}) needs to be bigger than window size ({window_len})."
515+
)
436516

437517
if window_len < 3:
438518
return x
@@ -455,15 +535,17 @@ def smooth(x, window_len=11, window="flat"):
455535
return y[window_len - 1 : -window_len + 1]
456536

457537

458-
def demo(text=None):
538+
def demo(text=None, similarity_method=BLOCK_COMPARISON):
459539
from matplotlib import pylab
460540

461541
from nltk.corpus import brown
462542

463-
tt = TextTilingTokenizer(demo_mode=True)
543+
pylab.figure()
544+
tt = TextTilingTokenizer(demo_mode=True, similarity_method=similarity_method)
464545
if text is None:
465546
text = brown.raw()[:10000]
466547
s, ss, d, b = tt.tokenize(text)
548+
pylab.title(f"TextTiling: {similarity_method}")
467549
pylab.xlabel("Sentence Gap index")
468550
pylab.ylabel("Gap Scores")
469551
pylab.plot(range(len(s)), s, label="Gap Scores")

0 commit comments

Comments
 (0)