thai-nlp-toolkit / tokenizer /thai_tokenizer.py
puttimej's picture
Upload tokenizer/thai_tokenizer.py with huggingface_hub
276f94b verified
Raw
History Blame Contribute Delete
10.3 kB
import os
import json
from typing import List, Dict, Optional
import sentencepiece as spm
import torch
from .preprocess import preprocess_thai # จาก Phase 1
# Special token IDs β€” ΰΈ•ΰΈ£ΰΈ‡ΰΈΰΈ±ΰΈšΰΈ—ΰΈ΅ΰΉˆΰΈ•ΰΈ±ΰΉ‰ΰΈ‡ΰΈ„ΰΉˆΰΈ²ΰΈ•ΰΈ­ΰΈ™ SentencePiece training
PAD_ID = 0
UNK_ID = 1
BOS_ID = 2
EOS_ID = 3
CLS_ID = 4 # user_defined_symbols ΰΈ₯ΰΈ³ΰΈ”ΰΈ±ΰΈšΰΈ—ΰΈ΅ΰΉˆ 1
SEP_ID = 5 # user_defined_symbols ΰΈ₯ΰΈ³ΰΈ”ΰΈ±ΰΈšΰΈ—ΰΈ΅ΰΉˆ 2
MASK_ID = 6 # user_defined_symbols ΰΈ₯ΰΈ³ΰΈ”ΰΈ±ΰΈšΰΈ—ΰΈ΅ΰΉˆ 3
class ThaiTokenizer:
"""
Wraps SentencePiece BPE ΰΈͺΰΈ³ΰΈ«ΰΈ£ΰΈ±ΰΈšΰΈ ΰΈ²ΰΈ©ΰΈ²ΰΉ„ΰΈ—ΰΈ’
- preprocess text ΰΈΰΉˆΰΈ­ΰΈ™ encode ทุกครั้ง
- ΰΈ£ΰΈ­ΰΈ‡ΰΈ£ΰΈ±ΰΈš special tokens: [PAD], [UNK], [BOS], [EOS], [CLS], [SEP], [MASK]
- batch_encode ΰΈ„ΰΈ·ΰΈ™ torch.Tensor ΰΈžΰΈ£ΰΉ‰ΰΈ­ΰΈ‘ΰΉƒΰΈŠΰΉ‰ΰΉƒΰΈ™ DataLoader
"""
def __init__(self, model_path: str):
"""
Parameters
----------
model_path : str
path ΰΈ–ΰΈΆΰΈ‡ .model file จาก SentencePiece training
ΰΉ€ΰΈŠΰΉˆΰΈ™ "tokenizer/thai_bpe.model"
"""
if not os.path.exists(model_path):
raise FileNotFoundError(
f"ΰΉ„ΰΈ‘ΰΉˆΰΈžΰΈš model file: {model_path}\n"
f"ΰΈ£ΰΈ±ΰΈ™ train_tokenizer.py ΰΈΰΉˆΰΈ­ΰΈ™ΰΉ€ΰΈžΰΈ·ΰΉˆΰΈ­ΰΈͺΰΈ£ΰΉ‰ΰΈ²ΰΈ‡ model"
)
self.model_path = model_path
self.sp = spm.SentencePieceProcessor()
self.sp.load(model_path)
# validate vocab size
self.vocab_size = self.sp.get_piece_size()
# special token ids (ΰΈ”ΰΈΆΰΈ‡ΰΈˆΰΈ²ΰΈ model ΰΈˆΰΈ£ΰΈ΄ΰΈ‡ ΰΉ„ΰΈ‘ΰΉˆ hardcode)
self.pad_id = self.sp.piece_to_id("[PAD]")
self.unk_id = self.sp.piece_to_id("[UNK]")
self.bos_id = self.sp.piece_to_id("[BOS]")
self.eos_id = self.sp.piece_to_id("[EOS]")
self.cls_id = self.sp.piece_to_id("[CLS]")
self.sep_id = self.sp.piece_to_id("[SEP]")
self.mask_id = self.sp.piece_to_id("[MASK]")
# ──────────────────────────────────────────────
# Core encode / decode
# ──────────────────────────────────────────────
def encode(self, text: str, add_special_tokens: bool = True) -> List[int]:
text = preprocess_thai(text)
if not text:
return [self.cls_id, self.sep_id] if add_special_tokens else []
ids = self.sp.encode(text, out_type=int)
if add_special_tokens:
ids = [self.cls_id] + ids + [self.sep_id] # ← ΰΉ€ΰΈ›ΰΈ₯ΰΈ΅ΰΉˆΰΈ’ΰΈ™ΰΈ•ΰΈ£ΰΈ‡ΰΈ™ΰΈ΅ΰΉ‰
return ids
def encode_qa(
self,
question: str,
context: str,
max_length: int = 512,
) -> dict:
"""
QA ต้องการ: [CLS] question [SEP] context [SEP]
แΰΈ₯ΰΈ°ΰΈ•ΰΉ‰ΰΈ­ΰΈ‡ΰΈ£ΰΈΉΰΉ‰ΰΈ§ΰΉˆΰΈ² context ΰΉ€ΰΈ£ΰΈ΄ΰΉˆΰΈ‘ΰΈ—ΰΈ΅ΰΉˆ position ΰΉ„ΰΈ«ΰΈ™
"""
q_ids = self.sp.encode(preprocess_thai(question), out_type=int)
c_ids = self.sp.encode(preprocess_thai(context), out_type=int)
# [CLS] q_ids [SEP] c_ids [SEP]
ids = [self.cls_id] + q_ids + [self.sep_id] + c_ids + [self.sep_id]
# context ΰΉ€ΰΈ£ΰΈ΄ΰΉˆΰΈ‘ΰΈ—ΰΈ΅ΰΉˆ position ΰΈ™ΰΈ΅ΰΉ‰ (QAHead ΰΉƒΰΈŠΰΉ‰ mask ΰΈ„ΰΈ³ΰΈ™ΰΈ§ΰΈ“ span)
context_start = 1 + len(q_ids) + 1 # ΰΈ«ΰΈ₯ΰΈ±ΰΈ‡ [CLS] + question + [SEP]
# truncate ถ้าฒาวเกิน
if len(ids) > max_length:
# ΰΈ•ΰΈ±ΰΈ” context ΰΈΰΉˆΰΈ­ΰΈ™ ΰΉ„ΰΈ‘ΰΉˆΰΈ•ΰΈ±ΰΈ” question
allowed_ctx = max_length - context_start - 1 # -1 ΰΈͺำหรับ [SEP] ΰΈͺΰΈΈΰΈ”ΰΈ—ΰΉ‰ΰΈ²ΰΈ’
c_ids = c_ids[:allowed_ctx]
ids = [self.cls_id] + q_ids + [self.sep_id] + c_ids + [self.sep_id]
attention_mask = [1] * len(ids)
return {
"input_ids": ids,
"attention_mask": attention_mask,
"context_start": context_start, # QAHead ΰΉƒΰΈŠΰΉ‰ΰΈ›ΰΉ‰ΰΈ­ΰΈ‡ΰΈΰΈ±ΰΈ™ predict span ΰΉƒΰΈ™ question
}
def decode(
self,
ids: List[int],
skip_special_tokens: bool = True,
) -> str:
"""
แปΰΈ₯ΰΈ‡ list of token IDs β†’ text
Parameters
----------
skip_special_tokens : bool
ΰΈ–ΰΉ‰ΰΈ² True ΰΈˆΰΈ°ΰΈΰΈ£ΰΈ­ΰΈ‡ PAD, BOS, EOS, CLS, SEP, MASK ΰΈ­ΰΈ­ΰΈΰΈΰΉˆΰΈ­ΰΈ™ decode
"""
special = {
self.pad_id, self.bos_id, self.eos_id,
self.cls_id, self.sep_id, self.mask_id,
}
if skip_special_tokens:
ids = [i for i in ids if i not in special]
return self.sp.decode(ids)
# ──────────────────────────────────────────────
# Batch encode β€” ΰΉƒΰΈŠΰΉ‰ΰΉƒΰΈ™ DataLoader
# ──────────────────────────────────────────────
def batch_encode(
self,
texts: List[str],
max_length: int = 512,
padding: bool = True,
add_special_tokens: bool = True,
return_tensors: bool = True,
) -> Dict:
"""
Encode ΰΈ«ΰΈ₯ΰΈ²ΰΈ’ texts ΰΈžΰΈ£ΰΉ‰ΰΈ­ΰΈ‘ΰΈΰΈ±ΰΈ™ ΰΈžΰΈ£ΰΉ‰ΰΈ­ΰΈ‘ padding แΰΈ₯ΰΈ° truncation
Returns
-------
dict with keys:
input_ids : (B, T) β€” token IDs
attention_mask : (B, T) β€” 1=real token, 0=padding
"""
all_ids = []
for text in texts:
ids = self.encode(text, add_special_tokens=add_special_tokens)
# Truncate: ถ้าฒาวเกิน max_length ΰΉƒΰΈ«ΰΉ‰ΰΈ•ΰΈ±ΰΈ”ΰΈ—ΰΉ‰ΰΈ²ΰΈ’
# ΰΉΰΈ•ΰΉˆΰΈ£ΰΈ±ΰΈΰΈ©ΰΈ² EOS ΰΉ„ΰΈ§ΰΉ‰ΰΉ€ΰΈͺΰΈ‘ΰΈ­
if len(ids) > max_length:
if add_special_tokens:
ids = ids[:max_length - 1] + [self.eos_id]
else:
ids = ids[:max_length]
all_ids.append(ids)
# Padding: pad ΰΉƒΰΈ«ΰΉ‰ΰΈ’ΰΈ²ΰΈ§ΰΉ€ΰΈ—ΰΉˆΰΈ²ΰΈΰΈ±ΰΈš sequence ΰΈ—ΰΈ΅ΰΉˆΰΈ’ΰΈ²ΰΈ§ΰΈ—ΰΈ΅ΰΉˆΰΈͺΰΈΈΰΈ”ΰΉƒΰΈ™ batch
if padding:
max_len = max(len(ids) for ids in all_ids)
padded_ids = []
attention_masks = []
for ids in all_ids:
pad_len = max_len - len(ids)
padded_ids.append(ids + [self.pad_id] * pad_len)
# attention_mask: 1 ΰΈͺำหรับ real token, 0 ΰΈͺำหรับ padding
attention_masks.append([1] * len(ids) + [0] * pad_len)
else:
padded_ids = all_ids
attention_masks = [[1] * len(ids) for ids in all_ids]
if return_tensors:
return {
"input_ids": torch.tensor(padded_ids, dtype=torch.long),
"attention_mask": torch.tensor(attention_masks, dtype=torch.long),
}
return {
"input_ids": padded_ids,
"attention_mask": attention_masks,
}
# ──────────────────────────────────────────────
# Save / Load
# ──────────────────────────────────────────────
def save(self, output_dir: str) -> None:
"""
ΰΈšΰΈ±ΰΈ™ΰΈ—ΰΈΆΰΈ tokenizer ΰΈ₯ΰΈ‡ directory
ΰΈͺΰΈ£ΰΉ‰ΰΈ²ΰΈ‡ 2 ΰΉ„ΰΈŸΰΈ₯์:
- thai_bpe.model (SentencePiece binary)
- tokenizer_config.json (metadata)
"""
os.makedirs(output_dir, exist_ok=True)
# copy model file
import shutil
dst = os.path.join(output_dir, "thai_bpe.model")
if os.path.abspath(self.model_path) != os.path.abspath(dst):
shutil.copy2(self.model_path, dst)
# ΰΈšΰΈ±ΰΈ™ΰΈ—ΰΈΆΰΈ metadata
config = {
"vocab_size": self.vocab_size,
"model_file": "thai_bpe.model",
"special_tokens": {
"pad_id": self.pad_id,
"unk_id": self.unk_id,
"bos_id": self.bos_id,
"eos_id": self.eos_id,
"cls_id": self.cls_id,
"sep_id": self.sep_id,
"mask_id": self.mask_id,
}
}
with open(os.path.join(output_dir, "tokenizer_config.json"), "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
print(f"tokenizer saved β†’ {output_dir}")
@classmethod
def from_pretrained(cls, directory: str) -> "ThaiTokenizer":
"""
ΰΉ‚ΰΈ«ΰΈ₯ΰΈ” tokenizer จาก directory ΰΈ—ΰΈ΅ΰΉˆ save() ΰΉ„ΰΈ§ΰΉ‰
Usage:
tok = ThaiTokenizer.from_pretrained("outputs/tokenizer")
"""
config_path = os.path.join(directory, "tokenizer_config.json")
if not os.path.exists(config_path):
raise FileNotFoundError(f"ΰΉ„ΰΈ‘ΰΉˆΰΈžΰΈš tokenizer_config.json ΰΉƒΰΈ™ {directory}")
with open(config_path) as f:
config = json.load(f)
model_path = os.path.join(directory, config["model_file"])
return cls(model_path=model_path)
# ──────────────────────────────────────────────
# Utilities
# ──────────────────────────────────────────────
def tokenize(self, text: str) -> List[str]:
"""แΰΈͺΰΈ”ΰΈ‡ subword pieces ΰΉ€ΰΈ›ΰΉ‡ΰΈ™ string β€” ΰΉƒΰΈŠΰΉ‰ΰΈͺำหรับ debug"""
text = preprocess_thai(text)
return self.sp.encode(text, out_type=str)
def vocab_size_actual(self) -> int:
return self.sp.get_piece_size()
def __len__(self) -> int:
return self.vocab_size
def __repr__(self) -> str:
return (
f"ThaiTokenizer("
f"vocab_size={self.vocab_size}, "
f"model='{os.path.basename(self.model_path)}')"
)