מבוא
בפוסט הקודם בבלוג התחלתי לעבור ברמה הטכנית על מה ש-LLM ספציפי שמבוסס על טרנספורמר עושה. בפוסט ההוא עוד לא הגענו אל ה-LLM עצמו; רק דיברנו על מה עושה הטוקנייזר, שהוא מרכיב נוסף וחשוב בפני עצמו שלוקח את הטקסט שעליו מריצים את ה-LLM ומפרק אותו ליחידות הבסיס שעליהן LLM-ים עובדים: טוקנים. כזכור, מה שקורה מכאן והלאה הוא זה:
-
המרה של כל טוקן לוקטור שמייצג אותו.
-
הפעלה של שלב שנקרא Attention שבו כל וקטור אוסף מידע מוקטורים שבאו לפניו בסדרה.
-
הפעלה של שלב שנקרא MLP שבו מתוך כל וקטור מחושב וקטור חדש שאמור לתאר את "מה שצריך לבוא אחריו".
-
תרגום של כל וקטור אל דירוג של כל הטוקנים שנקרא logits.
חוץ משלב 2, כל שלב עובד על טוקן בודד בכל פעם. כלומר, אם יש לי טקסט שכולל 1,537 טוקנים, המספר 1,537 הזה לא הולך להיות בעל משמעות באף אחד מהשלבים חוץ מ-2 - בכל השלבים האחרים אנחנו מתמקדים ברמת הטוקן הבודד והוקטור שמתקבל ממנו. בפוסט הזה אני אנסה לראות מה בדיוק קורה בשלבים הללו.
בואו נתחיל.
השלב הראשון
בואו ניזכר מה אני בעצם עושה. קודם כל אני טוען מודל ספציפי שנקרא SmolLM2-1.7B-Instruct:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "HuggingFaceTB/SmolLM2-1.7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype="auto",
)
עכשיו אני מריץ אותו על טקסט ספציפי שלקחתי מ"אלגוריתמיקה" של דוד הראל:
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "A zupchok is a flying, novel-writing whale. It has been carefully cultivated in a laboratory over several generations to ensure that its fins evolve into wing-like things that enable it to fly. It has also been gradually taught to read and write. It has a thorough knowledge of modern literature, and has the ability to write publishable mystery stories. Do you think zupchoks exist? If not, explain why."}
]
inputs = tokenizer.apply_chat_template(
messages,
add_generation_prompt=True,
return_tensors="pt",
return_dict=True,
)
with torch.inference_mode():
output = model.generate(
**inputs,
max_new_tokens=500,
do_sample=False,
)
print(tokenizer.decode(output[0, inputs["input_ids"].shape[1]:], skip_special_tokens=True))
אני מקבל תשובה די טובה, ואני מקבל אותה באופן דטרמיניסטי (במקרה הזה, כי יש שם do_sample=False ברשימת הפרמטרים, ולכן אין שום אקראיות שמעורבת בתהליך היצירה של התשובה). אני אוהב סיטואציות כאלו שבהן ברור מה נכנס ומה יוצא, וכל מה שנשאר לעשות הוא לעקוב אחרי מה שקורה בתוך הקופסא השחורה לכל אורכה.
השלב הראשון הוא הטוקנייזר, שבהפעלה של apply_chat_template עושה כמה דברים שונים יחד - לוקח את הטקסט ומוסיף לו סימנים שהמודל הספציפי יודע לזהות בתור תגים של "הנה מה שאתה אמור להיות, הנה השאלה ששואלים אותך, עכשיו תתחיל לענות" (ה"עכשיו תתחיל לעשות" הוא מה שהפרמטר add_generation_prompt מוסיף).
מה שטיפה פחות ברור הוא ה-return_tensors שיש שם. אם לא הייתי מעביר אותו, הייתי מקבל מילון של פייתון עם שני ערכים: אחד נקרא attention_mask והוא לא רלוונטי לנו הפעם (הוא רלוונטי רק אם מעבירים כמה טקסטים ביחד). השני נקרא input_ids והוא כולל רשימה של פייתון שמתחילה ככה:
\(1,9690,198,2683,359,253,5356,11173,30,2,198,1,4093,\ldots\)
המספרים הללו הם הטוקנים, בייצוג הקומפקטי שבו במקום לכתוב טוקן אנחנו פשוט כותבים את האינדקס שלו במילון. איזה מילון? כזכור, עם הקבצים של ה-LLM מגיע אחד, tokenzier.json שמכיל הרבה שדות מידע וביניהם vocab שהוא רשימת המילים במילון והאינדקס שלהם. למשל, איבר 1 במילון הוא <|im_start|> שהוא הטוקן המיוחד של התחלה של טקסט צ'אט; מייד אחריו מגיע תיאור של מי כותב את הטקסט צ'אט הזה ואכן אם נלך אל \(9690\) במילון נראה שזה הטוקן system. הטוקן \(2683\) שמופיע קצת אחר כך הוא You(ההתחלה של You are a helpful assistant) וכן הלאה - הבנו את הקטע.
עכשיו, מה ה-return_tensors=''pt'' שמופיע שם עושה? אם נאפשר אותו, במקום לקבל בתוך input_ids רשימה פייתונית, נקבל משהו שנראה בערך ככה:
tensor([[ 1, 9690, 198, ..., 520, 9531, 198]])
מה שאנחנו רואים פה הוא אובייקט שנקרא tensor של ספרייה שנקראת pyTorch. בשורה התחתונה - זו אותה רשימה כמו קודם של מספרים, היא פשוט בפורמט שאיתו pyTorch עובדת. אז אני צריך הסבר קצר - מה זה טנזורים ומה זה pyTorch.
ראשית, טנזורים. טנזור הוא אובייקט מתמטי עם שלל משמעויות שקשורות זו לזו; הנה פוסט שלי שבו אני מדבר על אחת מהן. ההקשר שלנו הוא מאוד קונקרטי: אצלנו טנזור הוא הכללה של מטריצה. אם למטריצה יש שני ממדים, לטנזור יש מספר כלשהו של ממדים. על טנזור עם מימד אחד אפשר לחשוב בתור וקטור; חוץ מזה שיש לו מימד אחד הוא גם מאופיין על ידי האורך שלו, כלומר מספר האיברים בו. במטריצה יש שני ממדים, ולכל אחד מהם יש אורך נפרד - מטריצה של 5 שורות ו-3 עמודות היא משהו שונה ממטריצה של 3 שורות ו-5 עמודות. על טנזור עם שלושה מימדים אפשר לחשוב בתור "רשימה של מטריצות", נאמר, רשימה של 7 מטריצות שכל אחת מהן היא בעלת 5 שורות ו-3 עמודות. בכל המקרים הללו אפשר לחשוב על טנזור בתור משהו שיש לו shape, שהוא האורך של כל מימד; על רשימה מאורך 1337 אפשר לחשוב כאילו יש לה shape שהוא \(\left(1337\right)\) ; על מטריצה של 3 שורות ו-5 עמודות אפשר לחשוב כאילו ה-shape שלה הוא \(\left(5,3\right)\) ועל טנזור של 7 מטריצות של 5 על זה אפשר לחשוב כאילו ה-shape שלו הוא \(\left(7,5,3\right)\) . או אולי זה דווקא \(\left(5,3,7\right)\) ? זה משנה, בעצם? הרי הרעיון הוא שזה משהו שיש לו "כניסות" שכל אחת מהן היא מספר ממשי, ולכל כניסה יש אינדקסים. אז אם אנחנו במשהו עם צורה \(\left(5,3,7\right)\) האינדקס הראשון רץ בין 0 ל-4 והשני בין 0 ל-2 והשלישי בין 0 ל-6 (תזכרו שבמחשבים לרוב מתחילים אינדקסים מאפס כי ככה השתרש) ואם הצורה הייתה \(\left(7,5,3\right)\) היינו רק צריכים לזכור שהפעם זה האינדקס הראשון שנע בין 0 ל-6. באופן כללי, טנזורים הם דבר גמיש; אם יש לנו טנזור עם צורה \(\left(5,3,7\right)\) אפשר לבקש מהספריה שעובדת איתו לחשוב עליו בתור טנזור עם צורה \(\left(3,7,5\right)\) וזה יעבוד. וגם, נאמר, על טנזור \(\left(2,2,2,2\right)\) אפשר לחשוב בתור טנזור \(\left(4,4\right)\) או \(\left(16\right)\), וכן הלאה. הכל בהתאם למה שמתאים כרגע. רשימה של 7 מטריצות \(5\) על 3 יכולה להיות גם רשימה של 3 מטריצות \(7\) על \(5\) .
בשורה התחתונה, המידע שיש בטנזור הוא, אה, שורה של מספרים. השאלה איזו אינדקסציה תפנה לאיזה מקום בשורה - זה חשבון שנוח להשאיר לספריה שעובדת עם הטנזורים לבצע, ולסמוך עליה שהיא יודעת לבצע אותו מהר. כך למשל אם יש לנו מטריצה \(A\) ואנחנו מבצעים לה את פעולת ה-transpose, כלומר מחליפים אותה ב-\(A^{T}\), אז הספריה שמנהלת את הסיפור לא חייבת לשנות שום דבר במטריצה עצמה; רק לזכור את השינוי באינדקסציה שזה מוביל לו - ומכאן, הקוד שלנו מהיר יותר, ומהירות היא שם המשחק פה. כאן נכנסת לתמונה הספרייה PyTorch. בגדול, זו ספריית פייתון שמיועדת לשלל ענייני למידת מכונה ובפרט מתאימה כדי להריץ טרנספורמרים (אבל ממש לא רק). למרות שהיא כתובה בפייתון, שהיא שפת תכנות יחסית פשוטה לעבודה איתה אבל לא יעילה במיוחד, החלקים ה"כבדים" שדורשים חישובים של טנזורים כתובים בשפות אחרות ומאופטמזים בצורה כזו שאפשר להריץ אותם על כרטיסי GPU - כרטיסים שבמקור יועדו לחישובים שדרושים לגרפיקה ממוחשבת, אבל התברר שהם מתאימים מאוד גם ללמידת מכונה. למרות ש-PyTorch היא לא הספריה היחידה בסביבה, היא כנראה הפופולרית ביותר נכון לזמן כתיבת השורות הללו.
בפרט, המודל שאני עובד איתו בנוי כך שאפשר יהיה להטעין אותו בקלות בעזרת PyTorch (זה לא שאי אפשר בעזרת ספריות אחרות; בהמשך נראה בדיוק איך נראה כל המידע של המודל ולכן אפשר, בתיאוריה, לשלוף אותו החוצה ולהשתמש עליו בספריה אחרת. זה פשוט לא יהיה מיידי). כשכתבתי את השורה
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype="auto",
)
מה שה-AutoModel הזה עשה היה לבנות אובייקט שיודע לעבוד מול PyTorch וכולל את המידע על המודל. אחר כך, כשיצרתי את רשימת הטוקנים, העברתי return_tensors="pt"כדי לבקש שהפלט יהיה בפורמט שמתאים ל-PyTorch (שהוא, כפי שראינו, טנזור שאפשר לחשוב עליו בתור רשימה של רשימות). אחר כך, כשקראתי ל-model.generate מה שרץ בפועל הוא פונקציה של הספריה transformers שעובדת בסיוע PyTorch. אז אם אני רוצה להבין מה קורה בפועל "שם בפנים" אני צריך לעקוב אחרי מה שהפונקציה הזו עושה.
אני כמובן אדלג על רוב מה שהולך שם - מה שמעניין אותי הוא זרימת המידע הבסיסי שדיברתי עליה. התחלנו עם רשימת הטוקנים; היא תגיע ללא שינוי אל השלב האחרון ב-generate, שבוא היא נשלחת לפונקציה אחרת (במקרה הנוכחי, אחת שנקראת _sample) עם עוד אקסטרה מידע רלוונטי. שם זה נשלח לפונקציה אחרת, _prefill. זה דווקא מעניין, בואו נדבר על זה:
כזכור, מה שטרנספורמר עושה הוא לייצר טוקן חדש בהינתן סדרת הטוקנים הקודמים. טוקן בודד זה לא כזה להיט, אז כשמריצים טרנספורמר חוזרים על התהליך שוב, ושוב, ושוב. בכל פעם לוקחים את הטוקן האחרון שנוצר, מוסיפים אותו לטקסט, ומתחילים את תהליך היצירה מחדש. כפי שנראה בהמשך, לא באמת צריך לעשות הכל מחדש: מריצים את תהליך היצירה אך ורק על הטוקן האחרון שנוצר, ועבור האחרים, שרלוונטיים רק בשלבי ה-attention, המידע הרלוונטי כבר נוצר בעבר ושמור במשהו שנקרא ה-KV cache ולכן לא צריך לבצע עליהם חישוב שוב פעם. אבל בשביל זה היה צריך שנבצע עליהם חישוב בעבר - כלומר, היה שלב שבו כן עברנו על כל הטוקנים שקיבלנו בקלט ולא רק על האחרון מביניהם. השלב הזה הוא מה שנקרא ה-prefill.
בסופו של דבר, אנחנו מגיעים לפונקציה שנקראת forward במחלקה LlamaModel(המודל שלנו שייך למשפחה של מודלים מסוג LlamaModel ברמת המבנה הבסיסי שלו, לכן זה מה שמופעל פה). כאן סוף סוף מגיע האקשן והטוקנים שלנו, שעד כה נשמרו יפה בתוך המשתנה input_ids בתור הטנזור שראינו קודם, מתחילים להשתנות:
if inputs_embeds is None:
inputs_embeds: torch.Tensor = self.embed_tokens(input_ids)
על embed_tokens כדאי לחשוב בתור מטריצה שבה יש שורה לכל טוקן, ומספר עמודות שמתאים לגודל של ה-hidden state של הטרנספורמר. מה זה hidden state? כזכור, אמרנו שהשלב הראשון בפעולה שלנו יהיה "המרה של כל טוקן לוקטור שמייצג אותו". הוקטור הזה הוא ה-hidden state המדובר. של הוקטור הן מספרים ממשיים; הגודל של הוקטור הזה הוא מספר העמודות המדובר. עבור המודל שלנו, הגודל של ה-hidden state הוא 2048; והמילון הוא מגודל 49152, ולכן ה-embed_tokens היא מטריצה מסדר \(49152\times2048\) . כל אחת מהכניסות במטריצה הזו הוא פרמטר של המודל, כלומר אחד מאותם ערכים שנקבעים במהלך אימון המודל; המודל נקרא SmolLM2-1.7B-Instruct כשה-1.7B שכתוב שם הוא מספר הפרמטרים, 1.7 מיליארד. אם נחשב את \(2048\) כפול \(49152\) נקבל \(100,663,296\) ; כלומר, בערך מאה מיליון פרמטרים מתוך ה-1.7 מיליארד הולכים על השלב הראשוני הזה. ואנחנו נמשיך את הספירה ככל שנתקדם.
בפועל embed_tokens הוא לא מטריצה אלא אובייקט פייתוני שיודע לקבל טנזור של טוקנים ומה שהוא עושה הוא להחליף כל טוקן בוקטור שהוא כל השורה שמתאימה לטוקן הזה. למשל, הטוקן הראשון ברשימה, שבאופן די נוח הוא 1, הוחלף בוקטור שמתחיל ככה:
\(\left[0.0117,0.0051,-0.022,\ldots\right]\)
אבל למה? למה דווקא המספרים הללו? עזבו, אין לי תשובה וגם לא תהיה, אני פשוט שואל עכשיו את השאלה כדי להבהיר שאני הולך להתעלם ממנה בעתיד. יותר מעניין אותי לדעת מאיפה המספרים הללו מגיעים, פיזית. איפה הקובץ שמכיל אותם. זה דווקא קל: יחד עם כל שאר הקבצים של המודל, הקובץ של tokenizer.json וכל אלו, יושב גם קובץ קטן בשם model.safetensors שעבור המודל הזה הוא בערך 3.4 ג'יגהבייט (לא כזה גדול! בדיסקי DVD שאיתנו כבר משנות התשעים היה אפשר לאחסן 4.7 ג'יגהבייט!). קובץ safetensors הוא אחת מהדרכים המקובלות לשמור את הפרמטרים של מודל: אפשר לחשוב עליו בתור אוסף של טנזורים ששמורים בצורה קומפקטית, עם איזה פתיח ראשוני שמסביר מה השמות של כל הטנזורים והגדלים שלהם. הטנזור הספציפי שרלוונטי לנו כרגע הוא model.embed_tokens.weight שהוא טנזור עם shape של \(\left(49\,152,2\,048\right)\) ; זה למעשה הטנזור הגדול ביותר ברשימה - כל היתר הם פשוטים יותר, אבל הם שייכים לשלב שחוזר על עצמו 24 פעמים עם וריאציות, בזמן ש-embed_tokens רלוונטי רק פעם אחת.
אם אני רוצה להסתכל על המטריצות באופן ישיר, אפשר לעשות את זה בקלות עם האובייקט של המודל:
print(model.model.embed_tokens.weight)
כשאני עושה את זה אני רואה טנזור דו ממדי (כלומר, מטריצה...) שהשורה השנייה שלו מתחילה ב-\(\left[0.0117,0.0051,-0.022,\ldots\right]\) . למה השנייה? כי זוכרים, במדעי המחשב האינדקסים מתחילים מ-0 כך שטוקן מס' 1 הוא למעשה השני (הטוקן הראשון הוא <|endoftext|>).
זה אומר שאם אני רוצה מסיבה לא הגיונית כלשהי לעשות את כל העבודה בעצמי, בלי אופטימיזציות והתחכמויות אלא פשוט לעבוד ישירות עם המטריצות, אני יכול להחליף את כל הדיון שהיה עד כה בקוד הזה:
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "A zupchok is a flying, novel-writing whale. It has been carefully cultivated in a laboratory over several generations to ensure that its fins evolve into wing-like things that enable it to fly. It has also been gradually taught to read and write. It has a thorough knowledge of modern literature, and has the ability to write publishable mystery stories. Do you think zupchoks exist? If not, explain why."}
]
inputs = tokenizer.apply_chat_template(
messages,
add_generation_prompt=True,
return_dict = False,
)
hidden_states = [model.model.embed_tokens.weight[token_id] for token_id in inputs]
פרט לשורה האחרונה אין פה ממש משהו חדש - אני מייצר את רשימת הטוקנים, רק שעכשיו אני מייצר אותה פשוט בתור רשימה פייתונית של מספרים. השלב האחרון הוא זה שבו אני ניגש למטריצה ושולף את שורה מס' token_id בה לכל אחד מהטוקנים - מה שאני מקבל הוא רשימה של וקטורים שכל אחד מהם הוא באורך 2048. השלב הראשון הושלם.
בואו נקפוץ לרגע אל השלב האחרון.
השלב האחרון
בשלב הראשון של ה-LLM לקחנו טוקן והמרנו אותו ב-hidden state, וקטור של מספרים ממשיים (במקרה שלנו, באורך 2048). בשלב האחרון אנחנו עושים את ההפך: ה-hidden state שלנו עבר שלל תהפוכות במהלך החישוב שה-LLM ביצע, ועכשיו במקום לתאר את הטוקן המקורי, הוא מתאר את "מה שסביר שיהיה הבא בתור אחרי הטוקן המקורי, בהתחשב גם בכל הטוקנים שבאו לפניו". השלב האחרון לוקח את ה-hidden state הזה וממיר אותו חזרה - לא לטוקן בודד, אלא למעין "דירוג" של כל הטוקנים, וקטור של מספרים שנקראים logits(קיצור של logistic units; השם הזה הוא לא המצאה חדשה של LLM-ים אבל לא ניכנס עכשיו להיסטוריה שלו). לכל טוקן יש logit משלו, כך שיש לנו במקרה שלנו וקטור של \(49152\) מספרים ממשיים שאיכשהו הופק מהוקטור מאורך \(2048\) שהיה לנו לפני רגע. איך עושים את זה?
מתמטית, אנחנו מחפשים פונקציה \(f:\mathbb{R}^{2048}\to\mathbb{R}^{49152}\) . בגלל סדרי הגודל של הוקטורים המעורבים, די מתבקש לשאול אילו פונקציות פשוטות אבל מועילות אנחנו מכירים שעושות את זה, והתשובה היא כמובן פונקציות לינאריות. האלגברה הלינארית מוכיחה ושוב ושוב את היכולת של הפונקציות שלה להיות שימושיות ומעניינות למרות הפשטות היחסית שלהן. כל פונקציה לינארית מתוארת על ידי מטריצה, במקרה הזה זו תהיה מטריצה \(A\in\mathbb{R}^{2048\times49152}\), שהכניסות שלה הן פרמטרים של המודל ולכן יכולות להיקבע על ידי אימון. אם \(h\) מסמן את ה-hidden state ואילו \(l\) את ה-logits, אז מה שהשלב הזה עושה בפועל הוא את החישוב \(l=A\cdot h\) .
השלב הזה נקרא LM head(LM מגיע מ-Language Modeling; תחשבו על זה בתור ה"ראש" של החישוב שאחראי להמרה מהשפה הפנימית של ה-LLM, כלומר ה-hidden state, חזרה אל שפת בני האנוש, כלומר טוקנים). את המטריצה הרלוונטית נהוג לסמן ב-\(W_{LM}\) .
עכשיו, שימו לב שאת השלב הראשון, של המרת הטוקנים לוקטורים, אפשר גם לתאר עם מטריצה: \(E\in\mathbb{R}^{49152\times2048}\), כך שהרעיון הוא שטוקן מיוצג על ידי וקטור שיש לו 1 במקום של המספר הסדרתי של הטוקן ו-0 בכל היתר, כך שהמכפלה \(E\cdot v=h\) "שולפת" בדיוק את השורה המתאימה מ-\(H\) . כפי שראינו, \(E\) היא גדולה למדי (במקרה שלנו, בערך מאה אלף פרמטרים) ו-\(W_{LM}\) אמורה להיות גדולה באותה מידה; אבל במודל הספיצפי שלנו מה שעושים בפועל הוא לקבוע \(W_{LM}=E^{T}\) וזהו. כלומר, השלב האחרון והשלב הראשון משתמשים באותה מטריצה.
זה לא משהו שחייב לקרות; זה גם לא משהו שבהכרח כדאי שיקרה. ניסיתי לקרוא הסברים על מתי משתלם ולא משתלם לעשות את זה ולא הגעתי להסבר חד משמעי שמשכנע אותי. יתרון ברור אחד של הגישה הזו הוא שצריך פחות פרמטרים ולאו דווקא מאבדים הרבה; קונספטואלית, המורכבות של \(W_{LM}\) מגיעה מהצורך לתווך בין עולם הוקטורים ובין עולם הטוקנים, והמטריצה \(E\) כבר עושה את התיווך הזה מספיק טוב אז אין הכרח לייצר עוד מטריצה. אבל זה נפנוף ידיים; מומחי LLM יוכלו לתת הסברים טובים ממני על מתי כדאי ולא כדאי לעשות את זה.
זה כמובן מעורר אצלי תהיה - מה בעצם קורה אם לוקחים טוקן, מאמבדים אותו ומייד "מפענחים" אותו? למרבה המזל זה משהו שקל לבצע ישירות עם משחקים עם המטריצות. זה נראה פשוט ככה:
hidden_state = model.model.embed_tokens.weight[token_id]
logits = model.lm_head.weight @ hidden_state
הסימן \(@\) כאן מציין כפל מטריצות, כלומר אנחנו מבצעים \(W_{LM}\cdot h\) ; אין שום דבר נסתר כאן. אחר כך צריך עוד קצת קוד כדי למיין את הטוקנים לפי הערך של ה-logit שלהם ולמצוא את אלו בעלי הערך הגבוה ביותר, אבל זה למשל מה שקורה עבור cat:
[('cat', 9.5),
(' cat', 7.09375),
('Cat', 6.90625),
(' Cat', 6.3125),
('cats', 5.65625),
('CAT', 5.625),
(' cats', 4.6875),
(' CAT', 4.40625),
('Cats', 4.21875),
(' Cats', 3.859375),
(' feline', 3.75),
(' catalytic', 3.09375),
(' kitten', 3.03125),
(' catal', 2.8125),
(' catar', 2.734375),
(' catalysts', 2.703125),
(' kit', 2.5625),
(' kittens', 2.53125),
(' catalyst', 2.421875),
(' catfish', 2.375)]
כלומר - הרבה טוקנים של cat לסוגיו השונים, אבל גם feline שהוא מילה שונה לגמרי אבל גם חתולית ביותר, וגם דברים כמו kitten. במילים אחרות, אל תצפו מה-LM head פשוט להחזיר טוקן אחד קונקרטי אפילו אם המצב שלנו הגיע מטוקן כזה; צפו ממנו לתת דירוג גבוה לטוקנים בעלי קשר כלשהו אליו.
זה... יפה מאוד בפני עצמו. עוד לא הגענו לאקשן האמיתי של LLM-ים, כלומר לשלבים שחוזרים עליהם שוב ושוב באמצע, אבל כבר עכשיו יש לנו תחושה של הידע שמקודד באותם מאה אלף פרמטרים שהשתמשנו בהם - לא סתם קידוד עיוור של כל טוקן בתור וקטור אקראי, אלא כזה שבו טוקנים שקשורים אליו הם איכשהו קרובים אליו בתור וקטורים, אפילו אם בתור מחרוזות לא היה שום קשר ברור שכזה. זו המחשה של היתרון שבעבודה עם וקטורים של מספרים ממשיים ולא עם מחרוזות.
אנחנו גם רואים שלעשות את החישובים בעצמנו, ברמת הלקחת מטריצות ולכפול אותן - זה די פשוט! זה לא יהיה יעיל, אבל בתור משחק, זה לא היה משהו שקשה לעשות. האם נוכל לחקות ככה את כל ה-LLM? בשביל זה נצטרך בפוסט הבא להיכנס סוף סוף לעובי הקורה של מה שקורה בפנים.