Skip to content

Commit a836b09

Browse files
authored
Merge pull request #1372 from PyThaiNLP/copilot/remove-lekcut-insert-deepcut-onnx
Migrate deepcut tokenizer from TensorFlow to built-in ONNX inference
2 parents 775279a + 6751025 commit a836b09

9 files changed

Lines changed: 211 additions & 135 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ and this project adheres to
3232
- Build WSD Trie after populating dictionary (#1388).
3333
- Doctests across all modules (#1392).
3434

35+
## [Unreleased]
36+
37+
### Changed
38+
39+
- `pythainlp.tokenize.deepcut`: migrated from the TensorFlow-based `deepcut`
40+
package to a built-in ONNX inference engine, removing the TensorFlow
41+
dependency. The `deepcut.onnx` model (ported from
42+
[LEKCut](https://github.com/PyThaiNLP/LEKCut)) is now bundled with PyThaiNLP.
43+
The `segment()` API is unchanged; the `custom_dict` parameter is kept for
44+
backward compatibility but is no longer applied to the model inference.
45+
Deepcut tests moved from `tests/noauto_tensorflow/` to `tests/noauto_onnx/`.
46+
3547
## [5.3.3] - 2026-03-26
3648

3749
Security fixes and thai2rom_onnx bug fixes.

pyproject.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,6 @@ noauto-torch = [
219219

220220
# TensorFlow-based dependencies - for tests.noauto_tensorflow
221221
noauto-tensorflow = [
222-
"deepcut>=0.7.0",
223222
"numpy>=1.26.0",
224223
]
225224

@@ -248,8 +247,6 @@ full = [
248247
"attaparse==1.0.0",
249248
"bpemb>=0.3.6,<0.4",
250249
"budoux==0.7.0",
251-
"deepcut==0.7.0.0",
252-
"emoji>=0.6.0,<1",
253250
"epitran==1.26.0",
254251
"esupar>=1.3.9,<2",
255252
'fairseq>=0.10.0,<0.13;python_version<"3.11"',
@@ -455,7 +452,6 @@ module = [
455452
"attaparse.*",
456453
"bpemb.*",
457454
"budoux.*",
458-
"deepcut.*",
459455
"emoji.*",
460456
"epitran.*",
461457
"esupar.*",

pythainlp/corpus/deepcut.onnx

2.08 MB
Binary file not shown.

pythainlp/corpus/default_db.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,29 @@
11
{
2+
"deepcut_onnx": {
3+
"name": "deepcut_onnx",
4+
"latest_version": "1.0.0",
5+
"description": "DeepCut ONNX model",
6+
"long_description": "DeepCut Thai word segmentation model in ONNX format, ported from the original TensorFlow model",
7+
"url": "https://github.com/PyThaiNLP/LEKCut",
8+
"authors": [
9+
"Rakpong Kittinaradorn",
10+
"Titipat Achakulvisut",
11+
"Korakot Chaovavanich",
12+
"Kittinan Srithaworn",
13+
"Pattarawat Chormai",
14+
"Chanwit Kaewkasi",
15+
"Tulakan Ruangrong",
16+
"Krichkorn Oparad"
17+
],
18+
"license": "MIT",
19+
"versions": {
20+
"1.0.0": {
21+
"filename": "deepcut.onnx",
22+
"md5": "f4662560dd9a706bfb1d7790ad6c667f",
23+
"pythainlp_version": ">=5.4.0"
24+
}
25+
}
26+
},
227
"thainer": {
328
"name": "thainer",
429
"latest_version": "1.5.1",

pythainlp/tokenize/core.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ def word_tokenize(
166166
`budoux <https://github.com/google/budoux>`_.
167167
:Note:
168168
- The **custom_dict** parameter only works for \
169-
*deepcut*, *longest*, *newmm*, and *newmm-safe* engines.
169+
*longest*, *newmm*, and *newmm-safe* engines.
170170
- Built-in tokenizers (*longest*, *mm*, *newmm*, and *newmm-safe*) \
171171
are thread-safe.
172172
- Wrappers of external tokenizer are designed to be thread-safe \
@@ -261,11 +261,7 @@ def word_tokenize(
261261
elif engine == "deepcut": # deepcut can optionally use dictionary
262262
from pythainlp.tokenize.deepcut import segment as deepcut_segment # noqa: I001
263263

264-
if custom_dict:
265-
custom_dict = list(custom_dict) # type: ignore[assignment]
266-
segments = deepcut_segment(text, custom_dict)
267-
else:
268-
segments = deepcut_segment(text)
264+
segments = deepcut_segment(text)
269265
elif engine == "icu":
270266
from pythainlp.tokenize.pyicu import segment as pyicu_segment # noqa: I001
271267

pythainlp/tokenize/deepcut.py

Lines changed: 158 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,177 @@
11
# SPDX-FileCopyrightText: 2016-2026 PyThaiNLP Project
22
# SPDX-FileType: SOURCE
33
# SPDX-License-Identifier: Apache-2.0
4-
"""Wrapper for deepcut Thai word segmentation. deepcut is a
5-
Thai word segmentation library using 1D Convolution Neural Network.
4+
"""DeepCut Thai word segmentation using ONNX runtime.
65
7-
User need to install deepcut (and its dependency: tensorflow) by themselves.
6+
DeepCut is a Thai word segmentation library using 1D Convolution Neural
7+
Network. This module provides ONNX-based inference, removing the need for
8+
TensorFlow.
9+
10+
The ONNX model is ported from the original DeepCut TensorFlow model,
11+
available from the LEKCut project.
812
913
:See Also:
10-
* `GitHub repository <https://github.com/rkcosmos/deepcut>`_
14+
* `DeepCut GitHub <https://github.com/rkcosmos/deepcut>`_
15+
* `LEKCut GitHub <https://github.com/PyThaiNLP/LEKCut>`_
16+
17+
:References:
18+
Rakpong Kittinaradorn, Titipat Achakulvisut, Korakot Chaovavanich,
19+
Kittinan Srithaworn, Pattarawat Chormai, Chanwit Kaewkasi,
20+
Tulakan Ruangrong, Krichkorn Oparad.
21+
(2019, September 23). DeepCut: A Thai word tokenization library using
22+
Deep Neural Network. Zenodo. https://doi.org/10.5281/zenodo.3457707
1123
"""
1224

1325
from __future__ import annotations
1426

15-
from typing import Union, cast
27+
from typing import TYPE_CHECKING, Optional
28+
29+
import numpy as np
30+
from onnxruntime import InferenceSession
31+
32+
from pythainlp.corpus import get_corpus_path
33+
34+
if TYPE_CHECKING:
35+
from numpy.typing import NDArray
36+
37+
_MODEL_NAME: str = "deepcut_onnx"
38+
_N_PAD: int = 21
39+
_THRESHOLD: float = 0.5
40+
41+
# Character type mapping from the original DeepCut model
42+
_CHAR_TYPE: dict[str, str] = {
43+
"กขฃคฆงจชซญฎฏฐฑฒณดตถทธนบปพฟภมยรลวศษสฬอ": "c",
44+
"ฅฉผฟฌหฮ": "n",
45+
"ะาำิีืึุู": "v",
46+
"เแโใไ": "w",
47+
"่้๊๋": "t",
48+
"์ๆฯ.": "s",
49+
"0123456789๑๒๓๔๕๖๗๘๙": "d",
50+
'"': "q",
51+
"'": "q",
52+
"\u2018": "q",
53+
"\u2019": "q",
54+
" ": "p",
55+
"abcdefghijklmnopqrstuvwxyz": "s_e",
56+
"ABCDEFGHIJKLMNOPQRSTUVWXYZ": "b_e",
57+
}
58+
59+
_CHAR_TYPE_FLAT: dict[str, str] = {}
60+
for _ks, _ct in _CHAR_TYPE.items():
61+
for _k in _ks:
62+
_CHAR_TYPE_FLAT[_k] = _ct
63+
64+
_CHARS: list[str] = [
65+
"\n", " ", "!", '"', "#", "$", "%", "&", "'", "(", ")", "*", "+",
66+
",", "-", ".", "/", "0", "1", "2", "3", "4", "5", "6", "7", "8",
67+
"9", ":", ";", "<", "=", ">", "?", "@", "A", "B", "C", "D", "E",
68+
"F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R",
69+
"S", "T", "U", "V", "W", "X", "Y", "Z", "[", "\\", "]", "^", "_",
70+
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
71+
"n", "o", "other", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y",
72+
"z", "}", "~", "ก", "ข", "ฃ", "ค", "ฅ", "ฆ", "ง", "จ", "ฉ", "ช",
73+
"ซ", "ฌ", "ญ", "ฎ", "ฏ", "ฐ", "ฑ", "ฒ", "ณ", "ด", "ต", "ถ", "ท",
74+
"ธ", "น", "บ", "ป", "ผ", "ฝ", "พ", "ฟ", "ภ", "ม", "ย", "ร", "ฤ",
75+
"ล", "ว", "ศ", "ษ", "ส", "ห", "ฬ", "อ", "ฮ", "ฯ", "ะ", "ั", "า",
76+
"ำ", "ิ", "ี", "ึ", "ื", "ุ", "ู", "ฺ", "เ", "แ", "โ", "ใ", "ไ",
77+
"ๅ", "ๆ", "็", "่", "้", "๊", "๋", "์", "ํ", "๐", "๑", "๒", "๓",
78+
"๔", "๕", "๖", "๗", "๘", "๙", "\u2018", "\u2019", "\ufeff",
79+
]
80+
_CHARS_MAP: dict[str, int] = {v: k for k, v in enumerate(_CHARS)}
81+
82+
_CHAR_TYPES: list[str] = [
83+
"b_e", "c", "d", "n", "o", "p", "q", "s", "s_e", "t", "v", "w",
84+
]
85+
_CHAR_TYPES_MAP: dict[str, int] = {v: k for k, v in enumerate(_CHAR_TYPES)}
86+
87+
# Default index for unknown characters and types
88+
_OTHER_CHAR_INDEX: int = _CHARS_MAP.get("other", 80)
89+
_OTHER_TYPE_INDEX: int = _CHAR_TYPES_MAP.get("o", 4)
1690

17-
try:
18-
from deepcut import tokenize
19-
except ImportError as e:
20-
raise ImportError(
21-
"deepcut is not installed. Install it with: pip install deepcut"
22-
) from e
23-
from pythainlp.util import Trie
91+
_session: Optional[InferenceSession] = None
92+
93+
94+
def _get_session() -> InferenceSession:
95+
"""Return a cached ONNX inference session, loading it on first call."""
96+
global _session
97+
if _session is None:
98+
model_path = get_corpus_path(_MODEL_NAME)
99+
if not model_path:
100+
raise FileNotFoundError(
101+
f"corpus-not-found name={_MODEL_NAME!r}\n"
102+
" DeepCut ONNX model file not found in the package.\n"
103+
" Try reinstalling PyThaiNLP:\n"
104+
" pip install --force-reinstall pythainlp"
105+
)
106+
_session = InferenceSession(model_path)
107+
return _session
108+
109+
110+
def _create_feature_array(
111+
text: str, n_pad: int = _N_PAD
112+
) -> tuple["NDArray[np.float32]", "NDArray[np.float32]"]:
113+
"""Create character and type feature arrays for ONNX model input.
114+
115+
:param str text: input text
116+
:param int n_pad: window size for padding (default: 21)
117+
:return: character and type feature arrays of shape (n, n_pad)
118+
:rtype: tuple[numpy.ndarray, numpy.ndarray]
119+
"""
120+
n = len(text)
121+
n_pad_2 = (n_pad - 1) // 2
122+
text_pad = [" "] * n_pad_2 + list(text) + [" "] * n_pad_2
123+
x_char: list[list[int]] = []
124+
x_type: list[list[int]] = []
125+
for i in range(n_pad_2, n_pad_2 + n):
126+
char_list = (
127+
text_pad[i + 1 : i + n_pad_2 + 1]
128+
+ list(reversed(text_pad[i - n_pad_2 : i]))
129+
+ [text_pad[i]]
130+
)
131+
x_char.append([_CHARS_MAP.get(c, _OTHER_CHAR_INDEX) for c in char_list])
132+
x_type.append(
133+
[
134+
_CHAR_TYPES_MAP.get(_CHAR_TYPE_FLAT.get(c, "o"), _OTHER_TYPE_INDEX)
135+
for c in char_list
136+
]
137+
)
138+
return (
139+
np.array(x_char, dtype=np.float32),
140+
np.array(x_type, dtype=np.float32),
141+
)
24142

25143

26144
def segment(
27-
text: str, custom_dict: Union[Trie, list[str], str] = []
145+
text: str,
28146
) -> list[str]:
147+
"""Segment Thai text using the DeepCut ONNX model.
148+
149+
:param str text: text to segment
150+
:return: list of word tokens
151+
:rtype: list[str]
152+
153+
:Example:
154+
::
155+
156+
from pythainlp.tokenize import deepcut
157+
158+
deepcut.segment("ทดสอบตัดคำ")
159+
# output: ['ทดสอบ', 'ตัด', 'คำ']
160+
"""
29161
if not text or not isinstance(text, str):
30162
return []
31163

32-
if custom_dict:
33-
if isinstance(custom_dict, Trie):
34-
custom_dict = list(custom_dict)
35-
36-
return cast("list[str]", tokenize(text, custom_dict))
164+
session = _get_session()
165+
x_char, x_type = _create_feature_array(text)
166+
outputs = session.run(None, {"input_1": x_char, "input_2": x_type})
167+
y_predict = (outputs[0].ravel() > _THRESHOLD).astype(int)
168+
word_end = y_predict[1:].tolist() + [1]
37169

38-
return cast("list[str]", tokenize(text))
170+
tokens: list[str] = []
171+
word = ""
172+
for char, is_end in zip(text, word_end):
173+
word += char
174+
if is_end:
175+
tokens.append(word)
176+
word = ""
177+
return tokens

tests/noauto_onnx/testn_tokenize_onnx.py

Lines changed: 8 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -11,71 +11,33 @@
1111
import unittest
1212

1313
from pythainlp.tokenize import (
14-
oskut,
15-
sefr_cut,
1614
word_tokenize,
1715
)
1816

19-
from ..core.test_tokenize import TEXT_1
20-
from ..test_helpers import assert_segment_handles_none_and_empty
2117

18+
class tokenizeDeepcutTestCaseN(unittest.TestCase):
19+
"""Tests for deepcut tokenizer numeric handling (requires onnxruntime)"""
2220

23-
class DetokenizeSEFRCutTestCaseN(unittest.TestCase):
24-
"""Tests for sefr_cut tokenizer numeric handling (requires onnxruntime)"""
25-
26-
def test_numeric_data_format_sefr_cut(self):
21+
def test_numeric_data_format_deepcut(self):
2722
self.assertIn(
2823
"127.0.0.1",
29-
word_tokenize("ไอพีของคุณคือ 127.0.0.1 ครับ", engine="sefr_cut"),
24+
word_tokenize("ไอพีของคุณคือ 127.0.0.1 ครับ", engine="deepcut"),
3025
)
3126

3227
tokens = word_tokenize(
33-
"เวลา 12:12pm มีโปรโมชั่น 11.11", engine="sefr_cut"
28+
"เวลา 12:12pm มีโปรโมชั่น 11.11", engine="deepcut"
3429
)
3530
self.assertTrue(
3631
any(value in tokens for value in ["12:12pm", "12:12"]),
37-
msg=f"sefr_cut: {tokens}",
32+
msg=f"deepcut: {tokens}",
3833
)
3934
self.assertIn("11.11", tokens)
4035

4136
self.assertIn(
4237
"1,234,567.89",
43-
word_tokenize("รางวัลมูลค่า 1,234,567.89 บาท", engine="sefr_cut"),
38+
word_tokenize("รางวัลมูลค่า 1,234,567.89 บาท", engine="deepcut"),
4439
)
4540

46-
tokens = word_tokenize("อัตราส่วน 2.5:1 คือ 5:2", engine="sefr_cut")
41+
tokens = word_tokenize("อัตราส่วน 2.5:1 คือ 5:2", engine="deepcut")
4742
self.assertIn("2.5:1", tokens)
4843
self.assertIn("5:2", tokens)
49-
50-
51-
class WordTokenizeOSKutTestCaseN(unittest.TestCase):
52-
"""Tests for oskut tokenizer (requires onnxruntime)"""
53-
54-
def test_word_tokenize_oskut(self):
55-
self.assertIsNotNone(word_tokenize(TEXT_1, engine="oskut"))
56-
57-
def test_oskut(self):
58-
assert_segment_handles_none_and_empty(self, oskut.segment)
59-
self.assertIsNotNone(
60-
oskut.segment("ฉันรักภาษาไทยเพราะฉันเป็นคนไทย"),
61-
)
62-
self.assertIsNotNone(
63-
oskut.segment("ฉันรักภาษาไทยเพราะฉันเป็นคนไทย", engine="scads"),
64-
)
65-
66-
67-
class WordTokenizeSEFRCutTestCaseN(unittest.TestCase):
68-
"""Tests for sefr_cut tokenizer (requires onnxruntime)"""
69-
70-
def test_word_tokenize_sefr_cut(self):
71-
self.assertIsNotNone(word_tokenize(TEXT_1, engine="sefr_cut"))
72-
73-
def test_sefr_cut(self):
74-
assert_segment_handles_none_and_empty(self, sefr_cut.segment)
75-
self.assertIsNotNone(
76-
sefr_cut.segment("ฉันรักภาษาไทยเพราะฉันเป็นคนไทย"),
77-
)
78-
self.assertIsNotNone(
79-
sefr_cut.segment("ฉันรักภาษาไทยเพราะฉันเป็นคนไทย", engine="tnhc"),
80-
)
81-

tests/noauto_tensorflow/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
Test functions that require TensorFlow and its ecosystem dependencies:
77
- tensorflow
88
- keras
9-
- deepcut
109
1110
These tests are NOT run in automated CI workflows due to:
1211
- Very large dependencies (~1-2 GB for tensorflow)
@@ -15,14 +14,15 @@
1514
1615
These tests are kept for manual testing and may be run in separate CI
1716
workflows dedicated to TensorFlow-based features.
17+
18+
NOTE: deepcut tokenizer was migrated to ONNX; its tests are now in
19+
tests/noauto_onnx/.
1820
"""
1921

2022
from unittest import TestLoader, TestSuite
2123

2224
# Names of module to be tested
23-
test_packages: list[str] = [
24-
"tests.noauto_tensorflow.testn_tokenize_tensorflow",
25-
]
25+
test_packages: list[str] = []
2626

2727

2828
def load_tests(

0 commit comments

Comments
 (0)