מה זה Byte Pair Encoder

30/08/2025

אנחנו יודעים שמודל שפה גדול הוא בסך הכל מכונה שיודעת לנחש בצורה מאוד טובה מה ה"מילה" הבאה בטקסט. אבל בהקשר של מודל השפה מה זה בכלל אומר "מילה"? ואיך המודל יודע איזה מילים יש בעולם? ואיך הוא לא מתבלבל כשאני כותב רק חלק ממילה או מילה שלא קיימת? ומה הם אותם טוקנים שעבורם אנחנו משלמים בעבודה עם מודלי השפה?

היחידה הבסיסית איתה מודל שפה גדול עובד נקראת טוקן ובפוסט זה אסביר מה זה Byte Pair Encoder וגם נכתוב אחד ב Ruby כדי להבין איך LLM רואה את העולם.

הדבר הראשון שאתם צריכים לדעת כשחושבים על איך LLM רואה את העולם הוא ש LLM הוא מכונה סטטיסטית ולכן בשביל "לנחש" מה המילה הבאה הוא צריך קודם להפוך את המילים למספרים רציפים. התהליך שבו אנחנו הופכים מילים למספרים נקרא Tokenization וכל מספר שמתקבל נקרא טוקן.

אז איך הופכים משפט לאוסף של מספרים? רעיון ראשון שלא עובד כל כך טוב הוא לרוץ על המילים ולתת לכל מילה מספר אקראי. נשמור את המספרים האלה ובפעם הבאה שניתקל במילה נשתמש באותו מספר. המילון שמחזיק את המיפוי מכל מילה למספר שמתאים לה נקרא אוצר המילים. הרעיון הזה לא עובד כל כך טוב כי הוא לא מאפשר למודל לראות חלקי מילים או מילים חדשות, ולא מאפשר למודל לראות את הקשר בין מילים עם אותיות דומות.

רעיון שני שלא עובד כל כך טוב הוא האפשרות ההפוכה - נחליף כל אות במספר (למשל ערך ה ASCII שלה) ונאמן את המודל לנחש את האות הבאה.

כשאנחנו בונים אוצר מילים אנחנו רוצים לשלב בין שני הרעיונות - מצד אחד אנחנו רוצים שכל מילה תקבל מספר באוצר המילים כדי שהמודל יוכל "לראות" את אותה המילה כל פעם שהיא מופיעה בטקסט. מצד שני אנחנו רוצים לאפשר למודל לייצג גם מילים שלא ראינו מראש באוצר המילים. הדרך לשילוב נקראת Byte Pair Encoding וזה נראה כך:

  1. נותנים לכל אות מספר, למשל ערך ה ASCII שלה.

  2. לוקחים קטע טקסט שיש בו המון מילים. ממנו נבנה את אוצר המילים. מחליטים גם על גודל אוצר המילים הרצוי.

  3. מחפשים בקטע את זוג האותיות שמופיע הכי הרבה פעמים. נותנים לו מספר ומוסיפים אותו בתור "מילה" לאוצר המילים. אחרי זה מחליפים כל מופע של אותו זוג בטוקן החדש שיצרנו.

  4. חוזרים על התהליך עד שאוצר המילים מגיע לגודל הרצוי.

שימו לב שהמילים שאנחנו בונים בצורה הזאת יכולות להיות באורך 1 (אם אות מסוימת לא מופיעה אף פעם בתור זוג שחוזר על עצמו), באורך 2 וגם יותר ארוכות. ניקח לדוגמה את הטקסט הבא לצורך בניית אוצר מילים:

aa aa bb aabb

אז באיטרציה הראשונה אנחנו מחליפים כל אות במספר. בשביל הדוגמה אני מחליף את a ב-1, את b ב-2 ואת רווח ב-0:

[1, 1, 0, 1, 1, 0, 2, 2, 0, 1, 1, 2, 2]

עכשיו אני מזהה את הרצף שמופיע הכי הרבה פעמים זה יהיה 1,1 שמופיע 3 פעמים ולכן אני יוצר מספר חדש, המספר 4 שמחליף את 1,1:

[4, 0, 4, 0, 2, 2, 0, 4, 2, 2]

בסיבוב הבא יש לי כמה אפשרויות אני יכול לבחור את 4,0 שמופיע פעמיים, את 0,4 שמופיע פעמיים או את 2,2 שמופיע פעמיים. בואו ניקח את 4,0 וניתן לו את הערך 5:

[5, 5, 2, 2, 0, 4, 2, 2]

עכשיו אוצר המילים שלנו כבר מורכב מ:

" " - 0
a - 1
b - 2
aa - 4
"aa " - 5

שימו לב שהמספר 5 הוא טוקן שמייצג טקסט באורך 3 תווים. הזוג הבא הכי נפוץ הוא 2,2 שמופיע פעמיים אז נקרא לו 6 ונקבל:

[5, 5, 6, 0, 4, 6]

ומפה כבר אי אפשר להמשיך כי כל הזוגות מופיעים רק פעם אחת. קיבלנו אוצר מילים בו נוכל להשתמש כדי לקודד כל טקסט חדש. כל מילה תהפוך למספר אחד או יותר ואפשר לקודד גם מילים שלא הופיעו בתור "מילים" באוצר המילים המקורי כלומר גם מילים חדשות.

בעולם האמיתי כמובן שהטקסט ממנו אנחנו בונים את אוצר המילים צריך להיות הרבה יותר ארוך ולהיות כמה שיותר דומה מבחינת תדירות הופעת המילים לטקסט האמיתי שהמודל יצטרך לנחש. היינו רוצים שיהיו כמה שיותר מילים באוצר המילים ושיהיו כמה שפחות מילים שהמודל לא מכיר. אתם יכולים לראות דוגמה להמחשה בקישור:

https://www.bpe-visualizer.com

יש הרבה ספריות שעושות BPE ובקונטקסט של עבודה בטוח כדאי לקחת ספריה קיימת לכזה קידוד. אני מימשתי אחד בשביל המשחק ברובי בביצועים די גרועים וזה הקוד:

module Tokenizer
  def self.encode(text, vocab)
    remaining = text
    encoded = []
    until remaining.empty?
      token, remaining = vocab.encode_next_token(remaining)
      encoded << token
    end
    encoded
  end


  def self.decode(encoded, vocab)
    inv = vocab.inverted
    encoded.map { |token| inv[token] }.join
  end

  class BytePairHash
    attr_accessor :vocab, :maxlen

    def initialize
      @maxlen = 1
      @vocab = (0..255).map(&:chr).each_with_index.to_a.to_h
    end

    def size
      vocab.size
    end

    def inverted
      vocab.invert
    end

    def encode_next_token(text)
      maxlen
        .downto(0)
        .find { |l| vocab.key?(text[...l]) }
        .then { |l| text[...l] }
        .then { |token| [vocab[token], text[token.length..]] }
    end

    def add_pair(pair)
      reverse_lookup = @vocab.invert
      new_token = reverse_lookup[pair[0]] + reverse_lookup[pair[1]]

      @maxlen = [@maxlen, new_token.length].max
      @vocab[new_token] = @vocab.values.max + 1
      [pair, @vocab[new_token]]
    end
  end

  class BPEHashBuilder
    attr_reader :bpe_hash

    def initialize(bpe_hash)
      @bpe_hash = bpe_hash
    end

    def extend_vocabulary(tokens)
      pair = tokens
             .each_cons(2)
             .tally
             .max_by { |(_, c)| c }
             .then { |(v, c)| c > 1 ? v : nil }

      bpe_hash.add_pair(pair) unless pair.nil?
    end
  end
end

def replace_all(tokens, new_pair, new_token)
  loop do
    index = tokens.each_cons(2).find_index(new_pair)
    break unless index

    tokens[index..index+1] = new_token
  end
end

vocab = Tokenizer::BytePairHash.new
builder = Tokenizer::BPEHashBuilder.new(vocab)
text = File.read('the-verdict.txt')
tokens = Tokenizer.encode(text, vocab)

loop do
  new_pair, new_token = builder.extend_vocabulary(tokens)
  break if new_pair.nil?

  replace_all(tokens, new_pair, new_token)
end

encoded = Tokenizer.encode('hello world', vocab)

pp encoded
pp Tokenizer.decode(encoded, vocab)

רוצים בשביל המשחק לממש בשפה שאתם אוהבים? אל תתביישו ושתפו בתגובות את המימוש שכתבתם אני אשמח לראות ובטוח שתלמדו מהתהליך.