Skip to content

fix(transliterate): stop runaway repetition in thai2rom romanize - #1500

Open
kamthorn wants to merge 2 commits into
PyThaiNLP:mainfrom
kamthorn:fix/thai2rom-repetition-loop
Open

kamthorn wants to merge 2 commits into
PyThaiNLP:mainfrom
kamthorn:fix/thai2rom-repetition-loop

Conversation

@kamthorn

Copy link
Copy Markdown

Summary

Fixes #1403.

romanize(..., engine="thai2rom") and romanize(..., engine="thai2rom_onnx")
could return a ~100-character string of repeated characters instead of a
valid romanization, e.g.:

romanize("กรุงเทพฯ", engine="thai2rom")
# -> 'krungtheppaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'

Same for ฯลฯ and long Pali/Sanskrit-derived compounds such as
ราษฎรบำรุง and สตรีเศรษฐบุตรบำเพ็ญ (see the issue for full analysis).

Root cause: both engines decode greedily (argmax/topk(1)) with no
repetition penalty or n-gram blocking. When the attention mechanism gets
trapped on certain syllable transitions or out-of-vocabulary sequences, it
keeps re-attending to the same input position and never emits <end>, so
decoding runs to the hard _maxlength = 100 cap and returns the truncated,
meaningless repeat.

Fix: add pythainlp/transliterate/_repetition.py with
find_trailing_repeat_period(), a small pure-Python helper (no torch/onnx
dependency) that detects a short cycle (1-12 target characters) repeating 3+
times in a row at the end of the tokens generated so far. Both
Seq2Seq.forward() (thai2rom.py) and Seq2Seq_ONNX.run()
(thai2rom_onnx.py) now check for this after each inference step and, if
found, stop decoding and truncate the output to keep only the cycle's first
occurrence rather than running to max_len.

  • กรุงเทพฯ -> krungtheppa
  • ฯลฯ -> paila
  • ราษฎรบำรุง -> ratsadotb
  • สตรีเศรษฐบุตรบำเพ็ญ -> satrisetthabutba

Normal words are unaffected (verified แมว -> maeo, สวัสดี -> sawatdi,
etc. are unchanged).

pythainlp/transliterate/thaig2p.py has a near-identical decode loop and
likely the same failure mode, but it's out of scope for this PR/issue and
tracked separately.

Test plan

  • Added unit tests for find_trailing_repeat_period() (branch coverage:
    no cycle, single-char cycle, multi-char cycle, period > default
    max_period, min_repeats threshold) in tests/core/test_transliterate.py
  • Added regression tests using the issue's reproduction strings, for both
    thai2rom and thai2rom_onnx, in tests/noauto_torch/testn_transliterate_torch.py
    and tests/noauto_onnx/testn_transliterate_onnx.py
  • ruff check passes on all changed files
  • build_tools/analysis/type-analyzer.py reports 0 functions with
    incomplete type hints repo-wide (unchanged from before this PR)
  • Full tests.core suite (209 tests) passes
  • Manually verified before/after output for all reproduction cases from
    the issue, on both the torch and ONNX engines
  • Updated CHANGELOG.md under [Unreleased] / ### Fixed

🤖 Generated with Claude Code

romanize(..., engine="thai2rom" or "thai2rom_onnx") could return a
~100-character string of repeated characters instead of a valid
romanization, e.g. romanize("กรุงเทพฯ", engine="thai2rom") produced
"krungtheppaaaa...aaaa" (100 chars). Same for "ฯลฯ" and long
Pali/Sanskrit-derived compounds such as "ราษฎรบำรุง" and
"สตรีเศรษฐบุตรบำเพ็ญ".

Both engines decode greedily (argmax/topk(1)) with no repetition
penalty or n-gram blocking. When the attention mechanism gets
trapped on certain syllable transitions or out-of-vocabulary
sequences, it keeps re-attending to the same input position and
never emits <end>, so decoding runs to the hard _maxlength=100 cap
and returns the truncated, meaningless repeat.

Add pythainlp/transliterate/_repetition.py with
find_trailing_repeat_period(), which detects a short cycle (1-12
target characters) repeating 3+ times in a row at the end of the
tokens generated so far. Both Seq2Seq.forward() (thai2rom.py) and
Seq2Seq_ONNX.run() (thai2rom_onnx.py) now check for this after each
inference step and, if found, stop decoding and truncate the output
to keep only the cycle's first occurrence rather than running to
max_len.

Adds regression tests using the reproduction cases above to
tests/noauto_torch/testn_transliterate_torch.py and
tests/noauto_onnx/testn_transliterate_onnx.py, plus unit tests for
find_trailing_repeat_period() in tests/core/test_transliterate.py.

Closes PyThaiNLP#1403
Comment thread tests/core/test_transliterate.py Outdated
def test_single_char_cycle(self):
# e.g. the "aaaa..." tail seen for "กรุงเทพฯ"
self.assertEqual(find_trailing_repeat_period([9, 1, 1, 1]), 1)
self.assertEqual(find_trailing_repeat_period([1, 1]), None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.assertEqual(find_trailing_repeat_period([1, 1]), None)
self.assertIsNone(find_trailing_repeat_period([1, 1]))

bact requested this change on PR PyThaiNLP#1500: prefer assertIsNone() over
assertEqual(x, None) for the unittest idiom.
@sonarqubecloud

Copy link
Copy Markdown

kamthorn added a commit to kamthorn/pythainlp that referenced this pull request Sep 17, 2026
…on-loop

Pull in the assertIsNone fix requested by bact on PR PyThaiNLP#1500 so PR PyThaiNLP#1501
(stacked on top) picks it up too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Romanize company name, found repeated strings

2 participants