본문 바로가기
오블완 챌린지

KLUE/RoBERTa 토크나이저 토큰 대체하기!

by carrotomato 2024. 11. 26.
728x90

BERT 계열 모델을 사용하다 보면, 모델의 도메인에 대한 이해를 높인다던가 어떤 중요한 단어를 토큰화하지 않고 하나의 토큰으로 가져가게 하고 싶은 경우가 있지요.
예를 들어 곤충 언어모델을 만들고 싶은데 토크나이저에 '나비' 토큰을 추가하지 않고,
'나', '##비'로 토큰화 되어버리면 정말 슬플거에요.
사실 subword tokenizer의 장점이 Out of Vocab에 강하다는 건데
그래도 '나비' 로 바로 토큰화해서 모델이 받는거랑 '나' '##비' 혹은 '나' '비' 로 받는 거랑은
모델이 이해하는데 차이가 조금은 있을 수 있겠죠?

이럴 때 tokenizer.add_tokens 로 토큰을 추가하고, resize_token_embeddings를 적용하는 방법이 있긴한데,
이렇게 하면 임베딩 사이즈가 달라지게 돼서 호환이 안되는 경우가 종종 있어요.
이럴 때 모델의 토크나이저를 까보면 [unused0] 처럼 비워둔 토큰을 발견할 수 있어요.
이 빈자리에 우리의 domain word를 추가하면 임베딩 사이즈의 변경 없이 토큰을 대체할 수 있다는 말씀!

그럼 코드를 한 번 봐봅시다.
일단은 라이브러리를 로드해와야겠죠.

import json
from tqdm import tqdm
from collections import OrderedDict


이번 코드에서는 klue/roberta-base 토큰나이저의 tokenizer.json 파일을 사용해봤어요.

with open("tokenizer.json", 'r', encoding='utf-8') as f:
    tokenizer_data = json.load(f) # Klue/RoBERTa-base tokenizer


이렇게 열고 한번 확인해 보면..

{
  "version": "1.0",
  "truncation": null,
  "padding": null,
  "added_tokens": [
    {
      "id": 0,
      "special": true,
      "content": "[CLS]",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false
    },
    {
      "id": 1,
      "special": true,
      "content": "[PAD]",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false
    },
    {
      "id": 2,
      "special": true,
      "content": "[SEP]",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false
    },
    {
      "id": 3,
      "special": true,
      "content": "[UNK]",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false
    },
    {
      "id": 4,
      "special": true,
      "content": "[MASK]",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false
    }
  ],
  "normalizer": {
    "type": "BertNormalizer",
    "clean_text": true,
    "handle_chinese_chars": true,
    "strip_accents": null,
    "lowercase": false
  },
  "pre_tokenizer": { "type": "BertPreTokenizer" },
  "post_processor": {
    "type": "TemplateProcessing",
    "single": [
      { "SpecialToken": { "id": "[CLS]", "type_id": 0 } },
      { "Sequence": { "id": "A", "type_id": 0 } },
      { "SpecialToken": { "id": "[SEP]", "type_id": 0 } }
    ],
    "pair": [
      { "SpecialToken": { "id": "[CLS]", "type_id": 0 } },
      { "Sequence": { "id": "A", "type_id": 0 } },
      { "SpecialToken": { "id": "[SEP]", "type_id": 0 } },
      { "Sequence": { "id": "B", "type_id": 0 } },
      { "SpecialToken": { "id": "[SEP]", "type_id": 0 } }
    ],
    "special_tokens": {
      "[CLS]": { "id": "[CLS]", "ids": [0], "tokens": ["[CLS]"] },
      "[SEP]": { "id": "[SEP]", "ids": [2], "tokens": ["[SEP]"] }
    }
  },
  "decoder": { "type": "WordPiece", "prefix": "##", "cleanup": true },
  "model": {
    "type": "WordPiece",
    "unk_token": "[UNK]",
    "continuing_subword_prefix": "##",
    "max_input_chars_per_word": 100,
    "vocab": {     ####### 요기 vocab!
      "[CLS]": 0,
      "[PAD]": 1,
	...,
    "[unused498]": 31998,
    "[unused499]": 31999
    }
  }
}

 

요런식으로 토큰나이저가 구성이 되어 있어요. 그런데 우리가 찾는 unused 토큰은 'vocab' 안에 들어있단 말이죠?

그럼 이렇게 변수 설정을 해봅시다.

vocab = tokenizer_data['model']['vocab']


이번엔 우리가 추가하고 싶은 단어를 설정해봐요. 보통 corpus에서 뽑는데 여기서는 그냥 리스트로 써볼게요.

nouns = ['당근', '토마토', '딩가딩가', '꾸루루']

 

그리고 나서 토큰에 'unused'가 있는 친구들만 모아줘요. 그리고 출력해보면 주석 처리해 놓은 부분처럼 unused0 번부터 499번까지 500개의 unused 토큰이 있네요.
OrderedDict를 사용한 이유는 원래 dictionary는 순서가 없는 자료형이에요. 파이썬 3.6부터는 순서가 적용된다고 하는데 혹시 모르잖아요~ 3.5를 쓸 일이 있을지 (사실 transformers 제대로 쓰려면 파이썬 3.10은 써야해요 ㅎ)

unused_tokens = OrderedDict(
    sorted(
        {key: value for key, value in vocab.items() if 'unused' in key}.items(),
        key = lambda item: item[1]  
    )
)
added_tokens = OrderedDict()
print(unused_tokens)

# OrderedDict([('[unused0]', 31500), ('[unused1]', 31501), ...
# ('[unused497]', 31997), ('[unused498]', 31998), ('[unused499]', 31999)])


근데 이미 토크나이저에 있는 단어를 unused로 굳이 대체할 필요는 없겠죠?

for noun in nouns:
    if noun in vocab:
        print(f"'{noun}'은(는) 토크나이저에 있다리~")
        continue
    if not unused_tokens:
        print("unused token이 더 이상 없다리~")
        break
    unused_token, index = unused_tokens.popitem(last=False)
    del vocab[unused_token]
    vocab[noun] = index
    added_tokens[noun] = index

# '당근'은(는) 토크나이저에 있다리~
# '토마토'은(는) 토크나이저에 있다리~


당근과 토마토는 이미 있는 토큰이었군요..
눈치 빠르신 분들은 위에 added_tokens 라는 딕셔너리를 하나 더 만들어 둔 걸 보셨을 거에요.
이건 그냥 추가된 토큰 확인용이에요 ㅎ

print(added_tokens)

# OrderedDict([('딩가딩가', 31500), ('꾸루루', 31501)])


그래서 추가된 토큰을 보면~ '딩가딩가' 와 '꾸루루' 토큰이 31500번 인덱스와 31501번 인덱스에 추가되었군요!

그럼 이제 기존 tokenizer_data의 'vocab' 부분을 우리가 워드를 대체한 vocab으로 변경해주고, 저장하면 끝!

tokenizer_data['model'][vocab] = vocab

with open('tokenzier.json', 'w', encoding='utf-8') as f:
    json.dump(tokenizer_data, f, ensure_ascii=False, indent=4)

 

복잡한 듯 하면서 간단하죠?
사실 토크나이저를 변경하는데에는 큰 시간을 쏟지 않는 게 좋긴해요.
어차피 wordpiece tokenizer 쓰면 진짜 어지간하면 unknown 토큰을 볼 일은 없을 거에요.
그래도 도메인 학습시킬 때 빠지면 아쉬운 토큰 추가라고나 할까나..
다시 IT 블로거가 되어가는 느낌이에요 ㅎ
누군가는 찾을 수도 있으니깐!! 그럼 20000!

728x90