#
WordPiece tokenization

WordPiece เป็นอัลกอริทึมสำหรับ tokenization ที่สร้างโดย Google เพื่อ pretrain โมเดล BERT หลังจากนั้นมันได้ถูกนำมาใช้กับโมเดลประเภท Transformer หลายตัวที่เป็นประเภทเดียวกับ BERT เช่น DistilBERT, MobileBERT, Funnel Transformers, และ MPNET

WordPiece มีความคล้ายกับ BPE ในวิธีการเทรน แต่วิธีการแยกคำนั้นแตกต่างกัน

💡 บทนี้จะพูดถึง WordPiece อย่างละเอียด เราจะเจาะลึกถึงไปถึงการ implement อัลกอริทึมนี้ คุณสามารถข้ามไปตอนท้ายได้ ถ้าคุณสนใจเพียงแค่ภาพรวมคร่าวๆเท่านั้น

##
Training algorithm

⚠️ เนื่องจาก Google ไม่เปิดเผยโค้ดสำหรับการเทรน WordPiece ดังนั้นโค้ดที่เราจะสอนคุณต่อจากนี้ มาจากการพยายามทำตามข้อมูลที่บอกไว้ใน paper แปลว่าโค้ดอาจจะไม่แม่นยำ 100%

เช่นเดียวกับ BPE อัลกอริทึม WordPiece เริ่มจาก vocabulary ขนาดเล็ก ที่ประกอบไปด้วย token พิเศษที่โมเดลใช้ และตัวอักษรตั้งต้น เพื่อที่โมเดลจะได้รู้ว่าคำไหนเป็นคำย่อย มันจะเขียน prefix เช่น `##` (ใช้ใน BERT) ไว้ข้างหน้าของแต่ละคำย่อย ในขั้นตอนแรก แต่ละคำจะถูกแบ่งออกเป็นตัวอักษร โดยตัวอักษรที่ไม่ใช่ตัวแรกจะมี prefix นี้

ตัวอย่างเช่น คำว่า `"word"` จะถูกแบ่งดังนี้ :

    w ##o ##r ##d

ดังนั้น vocabulary ตั้งต้น จะประกอบไปด้วยทุกๆตัวอักษรที่อยู่เริ่มต้นของแต่ละคำ และตัวอักษรอื่นๆที่อยู่ข้างในคำนั้น ซึ่งนำหน้าด้วย prefix พิเศษ

เช่นเดียวกันกับ BPE เป้าหมายในการเทรน WordPiece คือเรียนกฎเพื่อการ merge แต่ความแตกต่างคือหลักการในการเลือกคู่ token ที่จะนำมา merge แทนที่จะเลือกคู่ที่พบบ่อยที่สุด WordPiece จะคำนวณ score ให้แต่ละคู่ โดยใช้สูตรต่อไปนี้ s c o r e=(f r e q_o f_p a i r)/(f r e q_o f_f i r s t_e l e m e n t×f r e q_o f_s e c o n d_e l e m e n t)\mathrm{score} = (\mathrm{freq\_of\_pair}) / (\mathrm{freq\_of\_first\_element} \times \mathrm{freq\_of\_second\_element})score=(freq_of_pair)/(freq_of_first_element×freq_of_second_element)

การที่เราหารความถี่ของคู่ token ด้วยผลคูณของความถี่ของแต่ละ token ในคู่ จะทำให้อัลกอริทึมให้คะแนนคู่ ที่แต่ละ token มีความถี่ไม่สูง ตัวอย่างเช่น เราไม่จำเป็นจำต้อง merge `("un", "##able")` ถึงแม้ว่าคู่นี้จะมีจำนวนมากที่สุดใน vocabulary เพราะว่าทั้ง `"un"` และ `"##able"` ต่างพบได้กับคำอื่นๆด้วย และแต่ละตัวก็มีจำนวนค่อนข้างสูง ตรงกันข้ามกับ คู่เช่น `("hu", "##gging")` ซึ่งอาจจะถูกรวมเร็วกว่า (ในกรณีที่ “hugging” มีจำนวนสูงใน vocabulary ) เพราะว่า ทั้ง`"hu"` และ `“##gging” ต่างก็พบได้ไม่บ่อย

เราจะใช้ตัวอย่าง เดียวกันกับที่เราใช้ใน BPE :

    ("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)

หลังจากการแยกแต่ละคำ เราจะได้ :

    ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##g" "##s", 5)

ดังนั้น vocabulary ตั้งต้น คือ `["b", "h", "p", "##g", "##n", "##s", "##u"]` (เราขอละไม่พูดถึง token พิเศษในที่นี้) คู่ที่พบบ่อยที่สุดคือ `("##u", "##g")` ซึ่งพบ 20 ครั้ง แต่ว่าถ้านับจำนวนของแต่ละ token `"##u"` จะมีจำนวนค่อนข้างสูง ทำให้ score ของคู่นี้ไม่ได้สูงที่สุด (1 / 36) ทุกๆคู่ที่ประกอบด้วย `"##u"` จะได้ score เดียวกันซึ่งคือ (1 / 36) ดังนั้น score ที่สูงที่สุดจึงมาจากคู่ `("##g", "##s")` เพราะว่ามันไม่มี `"##u"` ซึ่งมี score เป็น 1 / 20 และกฎแรกที่เราได้ก็คือ `("##g", "##s") -> ("##gs")`

โปรดสังเกตว่า เวลาที่เรา merge เราจะลบ ตัว `##` ออกระหว่าง token สองตัวที่เราต้องการจะ merge แปลว่าเราจะได้ `"##gs"` และเราจะเพิ่ม token นี้เข้าไปใน vocabulary จากนั้นเราก็จะใช้กฎนี้กับทุกๆคำใน corpus ด้วย :

    Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs"]
    Corpus: ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##gs", 5)

ตอนนี้ `"##u"` มีอยู่ในทุกๆคู่ แปลว่า ทุกคู่จะมี score เท่ากัน

ในกรณีนี้ เราจะเลือกกฎใดกฎหนึ่งเพื่อ merge ต่อไป เราจะเลือก `("h", "##u") -> "hu"` และได้ผลลัพธ์ต่อไปนี้ :

    Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu"]
    Corpus: ("hu" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("hu" "##gs", 5)

ตอนนี้คู่ที่มี score สูงที่สุดคือ `("hu", "##g")` และ `("hu", "##gs")` ซึ่งทั้งสองมี score เท่ากับ 1/15 (ส่วนตัวอื่นๆที่เหลือ มี score เท่ากับ 1/21) เราจะเลือกคู่แรกใน list มาใช้ เพื่อ merge :

    Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu", "hug"]
    Corpus: ("hug", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("hu" "##gs", 5)

เราจะทำแบบนี้จนกว่าจะได้ vocabulary ที่มีขนาดใหญ่มากพอ

✏️ ตาคุณบ้างแล้ว! กฎ merge ต่อไปคืออะไร

##
Tokenization algorithm

การ tokenization ใน WordPiece แตกต่างจาก BPE ตรงที่ WordPiece จะบันทึกเฉพาะ vocabulary สุดท้ายเท่านั้น และไม่ได้บันทึก กฎ merge หากเราจะ tokenize คำใดคำหนึ่ง WordPiece จะหาคำย่อยที่ยาวที่สุด ที่พบใน vocabulary จากนั้นจะแยกคำออกตามนั้น ตัวอย่างเช่น ถ้าเราใช้ vocabulary ที่เทรนแล้วจากตัวอย่างด้านบน และต้องการ tokenize คำว่า `"hugs"` คำย่อยที่ยาวที่สุดก็คือ `"hug"` ดังนั้น เราจะแบ่งมันออกเป็น `["hug", "##s"]` จากนั้นเราก็จะดูที่ `"##s"` เราพบว่าเป็น token ที่อยู่ใน vocabulary ดังนั้น เราจึงได้ `["hug", "##s"]` ถ้าเราใช้ BPE เราจะใช้กฎที่เทรนมาตามลำดับ ซึ่งมันจะ tokenize ตัวอย่างของเราออกเป็น `["hu", "##gs"]`

มาดูอีกตัวอย่างกัน เช่นคำว่า `"bugs"` เราจะเริ่มอ่านจากข้างหน้าของคำไปข้างหลัง คุณจะเห็นว่า `"b"` เป็นคำย่อยที่ยาวที่สุดที่พบใน vocabulary ดังนั้น เราจะแยกคำตรงนี้ และเราจะได้ `["b", "##ugs"]` จากนั้นเราจะดูคำว่า `"##ugs"` สำหรับคำนี้เราพบว่า `"##u"` คือคำย่อยที่ยาวที่สุดที่พบใน vocabulary ดังนั้น เราจึงจะแยกคำตรงนี้ และได้ผลลัพธ์เป็น `["b", "##u, "##gs"]` สุดท้ายเราจะดูที่คำว่า `"##gs"` ซึ่งเราพบว่า มันอยู่ใน vocabulary แล้ว ดังนั้นเราไม่ต้องแบ่งมันอีก

ในกรณีที่เราไม่สามารถหาคำย่อยที่อยู่ใน vocabulary ได้เลย คำหลักที่เรากำลังอ่านนั้นจะถูกแปลงเป็นคำ unknown ตัวอย่างเช่นคำว่า `"mug"` จะถูก tokenize ให้เป็น `["[UNK]"]` เช่นเดียวกันกับคำว่า `"bum"` ถึงแม้ว่าเราจะเจอ `"b"` และ `"##u"` ใน vocabulary แต่ว่า `"##m"` ไม่ได้อยู่ใน vocabulary เราจะแปลงทั้งคำเป็น `["[UNK]"]` และจะไม่แยกมันเป็น `["b", "##u", "[UNK]"]` นี่เป็นสิ่งหนึ่งที่แตกต่างจาก BPE โดย BPE จะดูที่แต่ละตัวอักษร และถ้าตัวไหนไม่พบใน vocabulary ก็จะถูกคัดว่าเป็น unknown

✏️ ถึงตาคุณแล้ว! คำว่า `"pugs"` จะถูก tokenize อย่างไร?

##
Implementing WordPiece

มาดูกันว่า เราจะ implement อัลกอริทึม WordPiece ได้อย่างไร เช่นเดียวกับตอนที่เราสอนเรื่อง BPE สิ่งที่เราจะสอนต่อไปนี้เป็นเพียงตัวอย่าง เพื่อให้คุณเข้าใจการทำงานของอัลกอริทึม โค้ดที่ได้อาจจะไม่สามารถใช้ได้กับ corpus ใหญ่ๆ เราจะใช้ corpus ตัวอย่างเดียวกับที่ใช้ในบท BPE :

    corpus = [
        "This is the Hugging Face course.",
        "This chapter is about tokenization.",
        "This section shows several tokenizer algorithms.",
        "Hopefully, you will be able to understand how they are trained and generate tokens.",
    ]

ก่อนอื่นคุณจะต้อง pre-tokenize corpus เพื่อแยกข้อความเป็นคำๆ เนื่องจากเราจะจำลองการทำงานของ WordPiece tokenizer (เช่น BERT) เราจะใช้ `bert-base-cased` tokenizer ในการ pre-tokenize

    from transformers import AutoTokenizer

    tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")

จากนั้นคำนวณความถี่ของแต่ละคำใน corpus :

    from collections import defaultdict

    word_freqs = defaultdict(int)
    for text in corpus:
        words_with_offsets = tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str(text)
        new_words = [word for word, offset in words_with_offsets]
        for word in new_words:
            word_freqs[word] += 1

    word_freqs

    defaultdict(
        int, {'This': 3, 'is': 2, 'the': 1, 'Hugging': 1, 'Face': 1, 'Course': 1, '.': 4, 'chapter': 1, 'about': 1,
        'tokenization': 1, 'section': 1, 'shows': 1, 'several': 1, 'tokenizer': 1, 'algorithms': 1, 'Hopefully': 1,
        ',': 1, 'you': 1, 'will': 1, 'be': 1, 'able': 1, 'to': 1, 'understand': 1, 'how': 1, 'they': 1, 'are': 1,
        'trained': 1, 'and': 1, 'generate': 1, 'tokens': 1})

เราจะมาสร้างเซ็ตของ alphabet กัน ซึ่งคือเซ็ตที่ประกอบไปด้วยตัวอักษรแรกของแต่ละคำ และอักษรอื่นๆที่ไม่ใช่ตัวแรกจะมีการใส่ `##` ไว้ข้างหน้า :

    alphabet = []
    for word in word_freqs.keys():
        if word[0] not in alphabet:
            alphabet.append(word[0])
        for letter in word[1:]:
            if f"##{letter}" not in alphabet:
                alphabet.append(f"##{letter}")

    alphabet.sort()
    alphabet

    print(alphabet)

    ['##a', '##b', '##c', '##d', '##e', '##f', '##g', '##h', '##i', '##k', '##l', '##m', '##n', '##o', '##p', '##r', '##s',
     '##t', '##u', '##v', '##w', '##y', '##z', ',', '.', 'C', 'F', 'H', 'T', 'a', 'b', 'c', 'g', 'h', 'i', 's', 't', 'u',
     'w', 'y']

เราจะเพิ่ม token พิเศษ เข้าไปด้านหน้าของ list นี้ด้วย สำหรับ BERT คือ `["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"]` :

    vocab = ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"] + alphabet.copy()

จากนั้น เราจะทำการแยกแต่ละคำกัน โดยแยกตัวอักษรแรกออกมา และตัวที่เหลือจะเพิ่ม `##` ไว้ข้างหน้า :

    splits = {
        word: [c if i == 0 else f"##{c}" for i, c in enumerate(word)]
        for word in word_freqs.keys()
    }

ตอนนี้เราก็พร้อมที่จะเทรนแล้ว เราจะมาเขียนฟังก์ชันเพื่อคำนวณ score ให้แต่ละคู่ tokenกัน :

    def compute_pair_scores(splits):
        letter_freqs = defaultdict(int)
        pair_freqs = defaultdict(int)
        for word, freq in word_freqs.items():
            split = splits[word]
            if len(split) == 1:
                letter_freqs[split[0]] += freq
                continue
            for i in range(len(split) - 1):
                pair = (split[i], split[i + 1])
                letter_freqs[split[i]] += freq
                pair_freqs[pair] += freq
            letter_freqs[split[-1]] += freq

        scores = {
            pair: freq / (letter_freqs[pair[0]] * letter_freqs[pair[1]])
            for pair, freq in pair_freqs.items()
        }
        return scores

มาดูกันว่าผลลัพธ์ที่ได้หลังจากการรันครั้งแรกเป็นอย่างไร :

    pair_scores = compute_pair_scores(splits)
    for i, key in enumerate(pair_scores.keys()):
        print(f"{key}: {pair_scores[key]}")
        if i >= 5:
            break

    ('T', '##h'): 0.125
    ('##h', '##i'): 0.03409090909090909
    ('##i', '##s'): 0.02727272727272727
    ('i', '##s'): 0.1
    ('t', '##h'): 0.03571428571428571
    ('##h', '##e'): 0.011904761904761904

จากนั้น เราจะหาคู่ที่มี score สูงที่สุด โดยใช้ loop ง่ายๆ ดังนี้ :

    best_pair = ""
    max_score = None
    for pair, score in pair_scores.items():
        if max_score is None or max_score < score:
            best_pair = pair
            max_score = score

    print(best_pair, max_score)

    ('a', '##b') 0.2

กฎที่ได้จากการเทรนครั้งแรกคือ `('a', '##b') -> 'ab'` ดังนั้นเราจะเพิ่ม `'ab'` เข้าไปใน vocabulary :

    vocab.append("ab")

ก่อนที่จะทำต่อ เราจะต้องเพิ่มตัวที่ถูก merge เข้าไปใน dictionary `splits` ก่อน โดยเราจะเขียนฟังก์ชันเพื่อการคำนวณนี้ :

    def merge_pair(a, b, splits):
        for word in word_freqs:
            split = splits[word]
            if len(split) == 1:
                continue
            i = 0
            while i < len(split) - 1:
                if split[i] == a and split[i + 1] == b:
                    merge = a + b[2:] if b.startswith("##") else a + b
                    split = split[:i] + [merge] + split[i + 2 :]
                else:
                    i += 1
            splits[word] = split
        return splits

นี่คือผลลัพธ์ของการ merge ครั้งแรก :

    splits = merge_pair("a", "##b", splits)
    splits["about"]

    ['ab', '##o', '##u', '##t']

ตอนนี้เราก็มีทุกฟังก์ชันที่จำเป็นสำหรับการเทรนแล้ว เราจะเทรนจนกว่า tokenizer ได้เรียนเกี่ยวกับทุกๆ merge ที่เราต้องการ เราจะตั้งค่าขนาด vocabulary เป็น 70 สำหรับตัวอย่างนี้ :

    vocab_size = 70
    while len(vocab) < vocab_size:
        scores = compute_pair_scores(splits)
        best_pair, max_score = "", None
        for pair, score in scores.items():
            if max_score is None or max_score < score:
                best_pair = pair
                max_score = score
        splits = merge_pair(*best_pair, splits)
        new_token = (
            best_pair[0] + best_pair[1][2:]
            if best_pair[1].startswith("##")
            else best_pair[0] + best_pair[1]
        )
        vocab.append(new_token)

มาดูผลลัพธ์ของ vocabulary กัน :

    print(vocab)

    ['[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]', '##a', '##b', '##c', '##d', '##e', '##f', '##g', '##h', '##i', '##k',
     '##l', '##m', '##n', '##o', '##p', '##r', '##s', '##t', '##u', '##v', '##w', '##y', '##z', ',', '.', 'C', 'F', 'H',
     'T', 'a', 'b', 'c', 'g', 'h', 'i', 's', 't', 'u', 'w', 'y', '##fu', 'Fa', 'Fac', '##ct', '##ful', '##full', '##fully',
     'Th', 'ch', '##hm', 'cha', 'chap', 'chapt', '##thm', 'Hu', 'Hug', 'Hugg', 'sh', 'th', 'is', '##thms', '##za', '##zat',
     '##ut']

ถ้าเทียบกับ BPE คุณจะเห็นว่า tokenizer ตัวนี้สามารถเรียนเกี่ยวกับคำย่อยได้เร็วกว่านิดหน่อย

💡 ถ้าคุณใช้ `train_new_from_iterator()` กับ corpus ตัวอย่างนี้ คุณอาจจะไม่ได้ vocabulary เดียวกัน นั่นก็เพราะ 🤗 Tokenizers library ไม่ได้ใช้ WordPiece ในการเทรน แต่เราใช้ BPE

เมื่อคุณต้องการ tokenize ข้อความใหม่ คุณจะต้องทำการ pre-tokenize ข้อความแล้วจากนั้นจึง tokenize แต่ละคำ ตามหลักการของอัลกอริทึมนี้ เราจะมองหาคำย่อยที่ยาวที่สุด โดยอ่านจากข้างหน้าคำไปข้างหลัง จากนั้นเราจะแยกคำหลักออกตรงคำย่อยนี้ จากนั้นทำขั้นตอนนี้ซ้ำกับส่วนต่อๆไปของคำนั้น แล้วทำเช่นเดียวกันกับคำต่อไป

    def encode_word(word):
        tokens = []
        while len(word) > 0:
            i = len(word)
            while i > 0 and word[:i] not in vocab:
                i -= 1
            if i == 0:
                return ["[UNK]"]
            tokens.append(word[:i])
            word = word[i:]
            if len(word) > 0:
                word = f"##{word}"
        return tokens

มาทดลอง tokenize คำที่มีใน vocabulary และอีกคำที่ไม่ได้อยู่ใน vocabulary กัน :

    print(encode_word("Hugging"))
    print(encode_word("HOgging"))

    ['Hugg', '##i', '##n', '##g']
    ['[UNK]']

ตอนนี้เราจะต้องเขียนฟังก์ชันเพื่อ tokenize ข้อความกัน :

    def tokenize(text):
        pre_tokenize_result = tokenizer._tokenizer.pre_tokenizer.pre_tokenize_str(text)
        pre_tokenized_text = [word for word, offset in pre_tokenize_result]
        encoded_words = [encode_word(word) for word in pre_tokenized_text]
        return sum(encoded_words, [])

ทดลองฟังก์ชันของเรากับประโยคตัวอย่าง :

    tokenize("This is the Hugging Face course!")

    ['Th', '##i', '##s', 'is', 'th', '##e', 'Hugg', '##i', '##n', '##g', 'Fac', '##e', 'c', '##o', '##u', '##r', '##s',
     '##e', '[UNK]']

นี่คือทั้งหมดเกี่ยวกับ WordPiece ในบทถัดไปเราจะมาเรียนเกี่ยวกับ Unigram กัน