chakokuのブログ(rev4)

テック・コミック・DTM・・・ごくまれにチャリ

LLM from scratch; tokenizerを作成

背景:LLMを理解すべくLLM from scratch本でプログラムを書く
進捗:「2.2 テキストをトークン化する」(P21)
課題:文書をLLMで扱うには、単語をトークン(ID)に置き換える必要がある。このためトークナイザが必要
取り組み:自作トークナイザを作成
結論:自作トークナイザはできた

詳細:
本では正規表現でsplitさせる方法で文書から単語を切り出して、辞書に登録していく方法が採用されている。多分一番短いプログラムだと思う。他人の真似をするのが嫌なへそ曲がりなので、一文字ずつ順番に文字を読み込みながら単語に分割するアルゴリズムで組んでみる。はっきりって、こっちは効率が悪い。しかもソースが長い。さらに、単語の切り出し完了はスペーサの出現としているのでスペーサが来ないと辞書に単語が登録されない(バグ臭がする。例えば単語だけからなるテキストは単語と見なされない)。何もいいことがない。(自分で書いたのでカスタマイズは自由にできるが)

#!/usr/bin/python3

#
#  tokenizer(v0.01 2025/8/12)
#
#  input: text
#  output: converted text into id list with
#          word index , spacer index
#          in json format
#

# bug:
#
#  if text consists only word and not spacer then can't read word
#  e.g if input_text = 'abc'  then can not read abc
#

# algo memo
#  implemented handling of character "'"
#
#  "abc's"  ->  ' is member of word 
#  " 'ab"   ->  ' is not member of word and ' is spacer
#  "bc' "   ->  ' is not member of word and ' is spacer
#


import json

INPUT_TEXT = './data/the-verdict.txt'
OUTPUT = 'tokenized.json'

spacer_dic = {}
spacer_max_idx = 0
word_dic = {}
word_max_idx = 0

target_text = []

def is_letter(ch):
    return (ch >= 'A' and ch <= 'Z') or \
        (ch >= 'a' and ch <= 'z') or \
        (ch >= '0' and ch <= '9')

word = ''
fetch = None
with open(INPUT_TEXT, 'r') as f:
    for paragraph in f:
        iter_paragraph = iter(paragraph)
        for _ in range(len(paragraph)):
            if fetch is None:
                ch = next(iter_paragraph)
            else:
                ch = fetch
                fetch = None

            is_word = None
            print('[', ch ,']',end='')
            #
            # check single quote is spacer or between letter
            # 
            if ch == "'":   
                fetch = next(iter_paragraph)
                if word != '' and is_letter(fetch):
                    is_word = True
                else:
                    is_word = False
            else:
                if is_letter(ch):
                    is_word = True
                else:
                    is_word = False

            if is_word:
                word += ch
            else:
                spacer = ch
                print(f'\nword:{word}')
                print(f'spacer{spacer}')
                # regist and convert word
                if word != '':
                     if word not in word_dic:
                        word_dic[word] = word_max_idx
                        word_max_idx += 1
                    print(word_dic[word])
                    target_text.append(f'word_{word_dic[word]}')
                    word = ''
                # regist and convert spacer
                if spacer not in spacer_dic:
                    spacer_dic[spacer] = spacer_max_idx
                    spacer_max_idx += 1
                print(spacer_dic[spacer])
                target_text.append(f'sp_{spacer_dic[spacer]}')

with open(OUTPUT, 'w') as f:
    tokened = {'word_tbl' : word_dic, 'spacer_tbl' : spacer_dic , 'text' : target_text}
    json.dump(tokened, f)

Gitにpushした。URLは以下
build-LLM-from-scratch/src/tokenizer.py at main · foobarbazfred/build-LLM-from-scratch · GitHub
■追記
本来なら、上記をエンコーダとして、文書をトークナイズして、生成されたトークンの並びからデコーダで元の文書に戻してdiffして差がないことを検証すべきだが、、、自分のバグが怖くてそこまでやっていない。なんとなく動いてそうだ・・というレベル
■追記
encoder/decoderに仕上げる必要あり
■追記
decoderを作ろうとして気づいたのだが、元のコードでは、単語・トークンのテーブルの形式が、単語ー>token_idの形式になっていて、これだとトークン化されたテキストを元に戻せない。tokenizerの出力としては、単語・トークンテーブルのは、token_idー>単語 の形式で出力する必要あり。
■追記
ソースを修正して、token_idー>単語の形式でも出力するようにした。そして、decoder.pyを作った。ソースは以下

#!/usr/bin/python3
#
# decoder.py
# v0.01 (2025/8/13)
#
import json

INPUT_DATA = 'tokenized.json'
 
with open(INPUT_DATA, 'r') as f:
   tokened = json.load(f)

#print(tokened)

for id in tokened['text']:
    if 'word_' in id:
        id = id.split('_')[1]
        if id in tokened['id2word']['word_tbl']:
              print(tokened['id2word']['word_tbl'][id], end='')
        else:
            print("error unk id",id)
    elif 'sp_' in id:
        id = id.split('_')[1]
        if id in tokened['id2word']['spacer_tbl']:
            print(tokened['id2word']['spacer_tbl'][id], end='')
        else:
            print("error unk id",id)
    else:
        print('unk internal error')

以下にpush
https://github.com/foobarbazfred/build-LLM-from-scratch/blob/main/src/decoder.py

オリジナル文章と、encoder->decoderした結果の文章を比較
オリジナル

I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no great surprise to me to hear that, in the height of his glory, he had dropped his painting, married a rich widow, and established himself in a villa on the Riviera. (Though I rather thought it would have been Rome or Florence.)

encoder/decoderした結果

$ ./decoder.py
I HAD always thought Jack Gisburn rather a cheap genius--though a good fellow enough--so it was no great surprise to me to hear that, in the height of his glory, he had dropped his painting, married a rich widow, and established himself in a villa on the Riviera. (Though I rather thought it would have been Rome or Florence.)

ぱっと見た感じは正しく戻っているが、改行とかちょっと自信がない