מה זה Byte Pair Encoder
אנחנו יודעים שמודל שפה גדול הוא בסך הכל מכונה שיודעת לנחש בצורה מאוד טובה מה ה"מילה" הבאה בטקסט. אבל בהקשר של מודל השפה מה זה בכלל אומר "מילה"? ואיך המודל יודע איזה מילים יש בעולם? ואיך הוא לא מתבלבל כשאני כותב רק חלק ממילה או מילה שלא קיימת? ומה הם אותם טוקנים שעבורם אנחנו משלמים בעבודה עם מודלי השפה?
היחידה הבסיסית איתה מודל שפה גדול עובד נקראת טוקן ובפוסט זה אסביר מה זה Byte Pair Encoder וגם נכתוב אחד ב Ruby כדי להבין איך LLM רואה את העולם.
הדבר הראשון שאתם צריכים לדעת כשחושבים על איך LLM רואה את העולם הוא ש LLM הוא מכונה סטטיסטית ולכן בשביל "לנחש" מה המילה הבאה הוא צריך קודם להפוך את המילים למספרים רציפים. התהליך שבו אנחנו הופכים מילים למספרים נקרא Tokenization וכל מספר שמתקבל נקרא טוקן.
אז איך הופכים משפט לאוסף של מספרים? רעיון ראשון שלא עובד כל כך טוב הוא לרוץ על המילים ולתת לכל מילה מספר אקראי. נשמור את המספרים האלה ובפעם הבאה שניתקל במילה נשתמש באותו מספר. המילון שמחזיק את המיפוי מכל מילה למספר שמתאים לה נקרא אוצר המילים. הרעיון הזה לא עובד כל כך טוב כי הוא לא מאפשר למודל לראות חלקי מילים או מילים חדשות, ולא מאפשר למודל לראות את הקשר בין מילים עם אותיות דומות.
רעיון שני שלא עובד כל כך טוב הוא האפשרות ההפוכה - נחליף כל אות במספר (למשל ערך ה ASCII שלה) ונאמן את המודל לנחש את האות הבאה.
כשאנחנו בונים אוצר מילים אנחנו רוצים לשלב בין שני הרעיונות - מצד אחד אנחנו רוצים שכל מילה תקבל מספר באוצר המילים כדי שהמודל יוכל "לראות" את אותה המילה כל פעם שהיא מופיעה בטקסט. מצד שני אנחנו רוצים לאפשר למודל לייצג גם מילים שלא ראינו מראש באוצר המילים. הדרך לשילוב נקראת Byte Pair Encoding וזה נראה כך:
נותנים לכל אות מספר, למשל ערך ה ASCII שלה.
לוקחים קטע טקסט שיש בו המון מילים. ממנו נבנה את אוצר המילים. מחליטים גם על גודל אוצר המילים הרצוי.
מחפשים בקטע את זוג האותיות שמופיע הכי הרבה פעמים. נותנים לו מספר ומוסיפים אותו בתור "מילה" לאוצר המילים. אחרי זה מחליפים כל מופע של אותו זוג בטוקן החדש שיצרנו.
חוזרים על התהליך עד שאוצר המילים מגיע לגודל הרצוי.
שימו לב שהמילים שאנחנו בונים בצורה הזאת יכולות להיות באורך 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)
רוצים בשביל המשחק לממש בשפה שאתם אוהבים? אל תתביישו ושתפו בתגובות את המימוש שכתבתם אני אשמח לראות ובטוח שתלמדו מהתהליך.