Skip to content

Commit 95fa200

Browse files
authored
Merge pull request #1319 from PyThaiNLP/copilot/investigate-tokenization-issue
fix(newmm): resolve exponential BFS path explosion in ambiguous tokenization
2 parents f0d2f71 + 98511de commit 95fa200

4 files changed

Lines changed: 50 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,11 @@ The minimum requirement is now Python 3.9.
8080
- Kho Khon alphabet issue in `tltk` transliteration (#1187)
8181
- Suppress Gensim duplicate-word warnings when loading word2vec
8282
binary files (#1316)
83-
- `db.json` is no longer created on import; it is created lazily only
83+
- `db.json` is no longer created on import; created lazily only
8484
when a corpus is first downloaded (#1317)
85+
- Fix exponential-time explosion in "newmm" tokenization
86+
engine when tokenizing text with many ambiguous
87+
breaking points (#1319)
8588

8689
## [5.2.0] - 2025-12-20
8790

pythainlp/tokenize/newmm.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,17 @@
6363
def _bfs_paths_graph(
6464
graph: defaultdict, start: int, goal: int
6565
) -> Generator[list[int], None, None]:
66+
# visited set prevents re-exploring nodes already reached via a shorter
67+
# path, converting worst-case BFS from exponential to O(V + E).
68+
visited: set[int] = {start}
6669
queue = [(start, [start])]
6770
while queue:
6871
(vertex, path) = queue.pop(0)
6972
for pos in graph[vertex]:
7073
if pos == goal:
7174
yield path + [pos]
72-
else:
75+
elif pos not in visited:
76+
visited.add(pos)
7377
queue.append((pos, path + [pos]))
7478

7579

@@ -89,7 +93,7 @@ def _onecut(text: str, custom_dict: Trie) -> Generator[str, None, None]:
8993
end_pos = 0
9094
while pos_list[0] < len_text:
9195
begin_pos = heappop(pos_list)
92-
for word in custom_dict.prefixes(text[begin_pos:]):
96+
for word in custom_dict.prefixes(text, begin_pos):
9397
end_pos_candidate = begin_pos + len(word)
9498
if end_pos_candidate in valid_poss:
9599
graph[begin_pos].append(end_pos_candidate)
@@ -107,20 +111,20 @@ def _onecut(text: str, custom_dict: Trie) -> Generator[str, None, None]:
107111
_bfs_paths_graph(graph, end_pos, pos_list[0])
108112
)
109113
graph_size = 0
114+
graph.clear()
110115
for pos in end_pos_candidates[1:]:
111116
yield text[end_pos:pos]
112117
end_pos = pos
113118
elif len_pos_list == 0: # no candidate, deal with non-dictionary word
114-
m = _PAT_NONTHAI.match(text[begin_pos:])
119+
m = _PAT_NONTHAI.match(text, begin_pos)
115120
if m: # non-Thai token, skip to the end
116-
end_pos = begin_pos + m.end()
121+
end_pos = m.end()
117122
else: # Thai token, find minimum skip
118123
for pos in range(begin_pos + 1, len_text):
119124
if pos in valid_poss:
120-
prefix = text[pos:]
121125
words = [
122126
word
123-
for word in custom_dict.prefixes(prefix)
127+
for word in custom_dict.prefixes(text, pos)
124128
if (
125129
(pos + len(word) in valid_poss)
126130
and not _PAT_THAI_TWOCHARS.match(word)
@@ -131,14 +135,14 @@ def _onecut(text: str, custom_dict: Trie) -> Generator[str, None, None]:
131135
break
132136

133137
# is a non-Thai token
134-
if _PAT_NONTHAI.match(prefix):
138+
if _PAT_NONTHAI.match(text, pos):
135139
end_pos = pos
136140
break
137141
else:
138142
end_pos = len_text
139143

140-
graph[begin_pos].append(end_pos)
141-
graph_size = graph_size + 1
144+
graph_size = 0
145+
graph.clear()
142146
yield text[begin_pos:end_pos]
143147
heappush(pos_list, end_pos)
144148

@@ -155,13 +159,17 @@ def segment(
155159
156160
A custom dictionary can be supplied.
157161
162+
For very long texts (hundreds of kilobytes or more), consider using
163+
``safe_mode=True`` to enable chunk-based processing and reduce memory use.
164+
158165
:param text: text to be tokenized
159166
:type text: str
160167
:param custom_dict: tokenization dictionary,\
161168
defaults to word_dict_trie()
162169
:type custom_dict: Trie, optional
163-
:param safe_mode: reduce chance for long processing time for long text\
164-
with many ambiguous breaking points, defaults to False
170+
:param safe_mode: use chunk-based processing to reduce memory use and
171+
processing time for long text with many ambiguous breaking points,
172+
defaults to False
165173
:type safe_mode: bool, optional
166174
:return: list of tokens
167175
:rtype: list[str]

pythainlp/util/trie.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,22 +105,26 @@ def remove(self, word: str) -> None:
105105
break
106106
del parent.children[ch] # remove from parent dict
107107

108-
def prefixes(self, text: str) -> list[str]:
108+
def prefixes(self, text: str, start: int = 0) -> list[str]:
109109
"""List all possible words from first sequence of characters in a word.
110110
111-
:param str text: a word
112-
:return: a list of possible words
113-
:rtype: List[str]
111+
:param str text: text to search for prefixes
112+
:param int start: starting position in text, defaults to 0
113+
:return: a list of possible words starting at ``start``
114+
:rtype: list[str]
114115
"""
115116
res = []
116117
cur = self.root
117-
for i, ch in enumerate(text):
118-
node = cur.children.get(ch)
118+
i = start
119+
n = len(text)
120+
while i < n:
121+
node = cur.children.get(text[i])
119122
if not node:
120123
break
121124
if node.end:
122-
res.append(text[: i + 1])
125+
res.append(text[start : i + 1])
123126
cur = node
127+
i += 1
124128
return res
125129

126130
def __contains__(self, key: str) -> bool:

tests/core/test_tokenize.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# SPDX-FileType: SOURCE
33
# SPDX-License-Identifier: Apache-2.0
44

5+
import time
56
import unittest
67

78
from pythainlp.tokenize import (
@@ -583,6 +584,21 @@ def test_newmm_dangertext(self):
583584
word_tokenize(DANGER_TEXT_3, engine="newmm-safe"), list
584585
)
585586

587+
def test_newmm_ambiguous_performance(self):
588+
# Regression test for issue #893: newmm BFS path explosion.
589+
# Repeated ambiguous words (e.g. "ด้านหน้า" which can split as
590+
# "ด้าน"+"หน้า" or stay whole) used to cause exponential BFS blowup.
591+
# This test verifies that tokenizing 1,000 repetitions completes
592+
# quickly (well under 1 second).
593+
text = "ด้านหน้า" * 1000
594+
t = time.perf_counter()
595+
result = word_tokenize(text, engine="newmm")
596+
elapsed = time.perf_counter() - t
597+
self.assertIsInstance(result, list)
598+
self.assertGreater(len(result), 0)
599+
# Should complete in well under 1 second after the BFS fix.
600+
self.assertLess(elapsed, 5.0)
601+
586602
def test_tcc(self):
587603
assert_segment_handles_none_and_empty(self, tcc.segment)
588604
self.assertEqual(

0 commit comments

Comments
 (0)