تخطَّ إلى المحتوى
7/30الفصل 7 من 30

ابنِ BPE Tokenizer: لماذا لا يستطيع نموذجك عدّ حروف R؟

درّب byte-pair encoder في 60 سطرًا وشاهده يكتشف كلمة «the» بنفسه، ثم قِس لماذا تكلّف فقرة بالإسبانية أكثر بـ 39 %.

في هذه الصفحة

اسأل نموذجًا قادرًا على اجتياز امتحان نقابة المحامين كم حرف r في strawberry، وهناك احتمال لا بأس به أن يقول: اثنان.

التفسير المعتاد هو أن نماذج اللغة «سيئة في العدّ» أو «لا تفهم حقًا». كلاهما غير قابل للدحض، وليس أيٌّ منهما السبب. السبب ميكانيكي، يحدث قبل أن يعمل النموذج، ويمكنك رؤيته في سطر واحد:

TEXT
'strawberry'  ->  3 tokens  [496, 675, 15717]  ['str', 'aw', 'berry']

النموذج لا ينظر إلى عشرة أحرف. إنه ينظر إلى ثلاثة أرقام. لكي يعدّ حروف r، سيكون عليه أن يعرف، من هوية token 496 وحدها، كم حرف r داخل سلسلة لا يستطيع رؤيتها — ثم يفعل الشيء نفسه مع 675 و15717 ويجمعها. إنه يُسأل سؤالًا عن تمثيل لا يملك الوصول إليه.

يبني هذا الفصل الشيء الذي ينتج تلك الأرقام الثلاثة. يستغرق الأمر نحو ستين سطرًا، وهو الخوارزمية نفسها التي يستخدمها كل نموذج كبير تقريبًا، وبمجرد أن تكتبها، تنهار عشرات الغرائب التي تبدو غير مترابطة لتعود إلى سبب واحد.

لماذا ليس الأحرف، ولماذا ليس الكلمات

رابط إلى القسم: لماذا ليس الأحرف، ولماذا ليس الكلمات

هناك طريقتان بديهيتان لإدخال النص إلى شبكة، وكلتاهما تفشل لأسباب تستحق الفهم، لأن الفشل هو ما يحدد شكل الحل.

الكلمات. قسّم على المسافات، وأعطِ كل كلمة رقمًا. في الإنجليزية مئات الآلاف من صيغ الكلمات، ويحتاج النموذج إلى صف embedding لكل واحدة، لذلك يصبح المعجم — وطبقة الخرج، التي يجب أن تنتج درجة لكل مدخل — هائلًا. والأسوأ هو ما يحدث أثناء الاستدلال: كلمة لم يرها النموذج في التدريب لا تملك رقمًا. هذه هي مشكلة خارج المعجم، والترقيع المعتاد هو تحويل كل ما هو مجهول إلى token واحد هو <UNK>، ما يرمي المعلومة بعيدًا. كما أن «الكلمة» ليست مفهومًا محددًا جيدًا: الصينية واليابانية لا تضعان مسافات بين الكلمات، والألمانية تركّب اسمًا فوق آخر بلا حد تقريبًا.

الأحرف. لا توجد مشكلة خارج المعجم، والمعجم لا يتجاوز نحو مئة رمز. لكن التسلسلات تصبح طويلة جدًا، وسيُظهر الفصل 9 أن تكلفة attention تنمو تربيعيًا مع طول التسلسل. مستند من 1000 كلمة يساوي نحو 5000 حرف — تسلسل أطول بأربع إلى خمس مرات مما يلزم، وبثمن تربيعي. كما أن كل حرف يحمل معنى ضئيلًا جدًا وحده، فتُستهلك الطبقات الأولى في إعادة تركيب كلمات كان يمكن للـ tokenizer أن يسلّمها كاملة.

الإجابة تقع بينهما: subwords. الكلمات الشائعة تصبح token واحدًا، والكلمات النادرة تنقسم إلى قطع، ولا شيء يكون مجهولًا أبدًا لأن القطع تنتهي في الأساس إلى بايتات فردية. الجزء المثير للاهتمام هو أن أحدًا لا يصمم التقسيم. يُدرَّب tokenizer، على النوع نفسه من البيانات التي يُدرَّب عليها النموذج، ويتعلم أي تسلسلات بايت تستحق رقمها الخاص عبر عدّ تكرار ظهورها معًا.

تعود الخوارزمية إلى عام 1994، وكانت خوارزمية ضغط. نشرها Philip Gage في C Users Journal بوصفها طريقة لتصغير الملفات عبر استبدال أكثر زوج متجاور من البايتات تكرارًا، مرارًا، ببايت لا يظهر في البيانات.1 بقيت هناك اثنين وعشرين عامًا إلى أن أعاد Sennrich وHaddow وBirch توظيفها للترجمة الآلية في 2016 لحل مشكلة خارج المعجم.2 وهي الآن الطريقة التي يقرأ بها كل نموذج لغوي كبير تقريبًا.

حلقة التدريب تتكرر في أربع خطوات:

رمّز نص التدريب بصيغة UTF-8. كل قيمة بايت من 0 إلى 255 هي token. حجم المعجم: 256.

امشِ عبر التسلسل وعدّ عدد مرات ظهور كل زوج من tokens المتجاورة.

خذ الفائز، وأنشئ له token id جديدًا، واستبدل كل ظهور له في التسلسل. يكبر المعجم بواحد؛ ويقصر التسلسل.

خزّن الزوج والـ id الذي أصبحه، بالترتيب. تلك القائمة المرتبة هي tokenizer — إنها كل ما يلزم لترميز نص جديد لاحقًا.

هذا هو المدرّب كاملًا:

bpe.pyPYTHON
def get_stats(ids):
    counts = {}
    for a, b in zip(ids, ids[1:]):
        counts[(a, b)] = counts.get((a, b), 0) + 1
    return counts


def merge(ids, pair, idx):
    out, i = [], 0
    while i < len(ids):
        if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
            out.append(idx)
            i += 2
        else:
            out.append(ids[i])
            i += 1
    return out


class BPE:
    def __init__(self):
        self.merges = {}
        self.vocab = {i: bytes([i]) for i in range(256)}

    def train(self, text, vocab_size):
        ids = list(text.encode("utf-8"))
        for i in range(vocab_size - 256):
            stats = get_stats(ids)
            if not stats:
                break
            pair = max(stats, key=stats.get)      
            idx = 256 + i                         
            ids = merge(ids, pair, idx)           
            self.merges[pair] = idx               
            self.vocab[idx] = self.vocab[pair[0]] + self.vocab[pair[1]]
        return ids

شغّله على 151,191 بايتًا من نثر إنجليزي واطبع أول اثني عشر دمجًا أثناء حدوثها. هذا هو الجزء الذي يستحق القراءة ببطء، لأن أحدًا لم يخبر الخوارزمية أي شيء عن الإنجليزية:

TEXT
merge   1: b'e' + b' '   -> b'e '     (occurred 4433 times)
merge   2: b' ' + b't'   -> b' t'     (occurred 3302 times)
merge   3: b'\xe2' + b'\x80' -> b'\xe2\x80'  (occurred 3247 times)
merge   4: b' ' + b'a'   -> b' a'     (occurred 2335 times)
merge   5: b' t' + b'h'  -> b' th'    (occurred 2253 times)
merge   6: b'i' + b'n'   -> b'in'     (occurred 2011 times)
merge   7: b't' + b' '   -> b't '     (occurred 1904 times)
merge   8: b'e' + b'r'   -> b'er'     (occurred 1813 times)
merge   9: b'd' + b' '   -> b'd '     (occurred 1703 times)
merge  10: b'o' + b'u'   -> b'ou'     (occurred 1554 times)
merge  11: b' ' + b's'   -> b' s'     (occurred 1467 times)
merge  12: b' th' + b'e '-> b' the '  (occurred 1270 times)

هناك ثلاثة أشياء في تلك القائمة تستحق الإشارة إليها.

الدمج 12 هو كلمة «the» — مع المسافة قبلها والمسافة بعدها، كوحدة واحدة، اكتُشفت في التكرار الثاني عشر من حلقة تعدّ الأزواج. لم يزوّدها أحد بقاموس. إنها هناك لأن تلك البايتات الخمسة تظهر معًا أكثر من أي خمسة بايتات أخرى في الإنجليزية.

الدمج 3 ليس نصًا أصلًا. \xe2\x80 هما أول بايتين من ترميز UTF-8 لعلامات ترقيم طباعية — الشرطة الطويلة، وعلامات الاقتباس المنحنية. الخوارزمية لا تعرف أن UTF-8 موجود، ومع ذلك أعادت اكتشاف قطعة من بنيته، لأن الترميزات متعددة البايتات هي، بحكم التصميم، تسلسلات بايت تظهر دائمًا معًا.

معظم عمليات الدمج المبكرة تتضمن مسافة، والمسافة تكون غالبًا على اليسار. هذا هو أصل أحد أكثر السلوكيات إرباكًا في الممارسة، وسنعود إليه بعد قليل.

كل دمج يجعل التسلسل أقصر والمعجم أكبر. إلى أي مدى يجب دفع ذلك قرار حقيقي، ويمكن قياسه — هنا على البايتات نفسها وعددها 151,191:

حجم المعجمtokens الناتجةالضغط (بايت لكل token)
300101,0651.50
51268,2492.22
1,02450,3693.00
2,04839,3063.85
4,09630,7574.92

عوائد متناقصة، بوضوح. المضاعفة من 512 إلى 1024 تشتري 0.78 بايت لكل token؛ والمضاعفة من 2048 إلى 4096 تشتري 1.07 — أفضل هنا فقط لأن هذا المتن صغير بما يكفي لتظل عمليات الدمج الأطول مجدية. على متن حقيقي، ينفرد المنحنى بقوة.

وتكلفة معجم أكبر ليست الذاكرة فقط. كل token يحتاج إلى صف embedding، و— بتكلفة أعلى — يجب على طبقة خرج النموذج أن تنتج درجة لكل مدخل في المعجم في كل خطوة، لذلك يتوسع ضرب المصفوفة النهائي مع حجم المعجم. تقع النماذج الحقيقية بين 32,000 و200,000: استخدم GPT-2 عددًا قدره 50,257، ويستخدم cl100k في GPT-4 عددًا قدره 100,277، ويضاعف o200k في GPT-4o ذلك تقريبًا. الاتجاه تصاعدي، والسبب في القسم التالي.

هذه هي الفقرة نفسها، مترجمة، ومقاسة باستخدام tokenizers الحقيقية التي تشحنها OpenAI:

اللغةالأحرفtokens (cl100k)tokens (o200k)tokens/charالزيادة مقارنة بالإنجليزية
الإنجليزية16431310.189
الإسبانية16943360.254+39 %
الروسية17878430.438+152 %
اليابانية7279581.097+155 %

المحتوى نفسه، والمعنى نفسه، ومع cl100k تستهلك النسخة الروسية ضعفين ونصفًا من tokens. وبما أن APIs تحاسب لكل token وأن context windows تُقاس بـ tokens، فهذه ليست طرافة لغوية — إنها بند في الميزانية، وcontext window فعلي أقصر، واستجابة أبطأ، كلها في الوقت نفسه، لكل من لا يعمل بالإنجليزية.

الآلية هي بيانات التدريب. tokenizer دُرّب غالبًا على الإنجليزية ينفق ميزانية الدمج على تسلسلات بايت إنجليزية. الإسبانية تشترك في الأبجدية اللاتينية، لذلك ما زالت تحصل على بعض الفائدة؛ الروسية لا تحصل على شيء تقريبًا، لأن الأحرف السيريلية تأخذ بايتين في UTF-8 وقليلًا من تلك الأزواج كان شائعًا بما يكفي في متن التدريب ليستحق دمجًا. اليابانية أسوأ: ثلاثة بايتات لكل حرف، و72 حرفًا تصبح 79 token — tokens أكثر من الأحرف.

يوضح عمود o200k أن هذه مشكلة قابلة للحل وأنها تُحل بالفعل. مضاعفة المعجم وإعادة موازنة بيانات التدريب تخفض الزيادة الإسبانية من +39 % إلى +16 %، والروسية من +152 % إلى +39 %. هذا هو السبب الحقيقي لاستمرار نمو المعاجم: ليس الضغط لذاته، بل لأن الجيل السابق كان يفرض بهدوء كلفة إضافية كبيرة على جزء واسع من العالم.

الترميز، ولماذا يهم ترتيب عمليات الدمج

رابط إلى القسم: الترميز، ولماذا يهم ترتيب عمليات الدمج

أنتج التدريب قائمة مرتبة من عمليات الدمج. ترميز نص جديد يعيد تشغيلها — ويجب أن يعيد تشغيلها بالترتيب نفسه، لأن الدمج 12 يجمع نتائج الدمجين 5 و1. طبّقها بترتيب مختلف وستحصل على tokenization مختلف وخاطئ لن يطابق أي شيء رآه النموذج في التدريب.

bpe.py (continued)PYTHON
    def encode(self, text):
        ids = list(text.encode("utf-8"))
        while len(ids) >= 2:
            stats = get_stats(ids)
            # the pair whose merge came FIRST during training wins   
            pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))   
            if pair not in self.merges:
                break
            ids = merge(ids, pair, self.merges[pair])
        return ids

    def decode(self, ids):
        return b"".join(self.vocab[i] for i in ids).decode("utf-8", errors="replace")

فك الترميز تافه بالمقارنة: ابحث عن بايتات كل id، ألصقها، ثم فكّها كـ UTF-8. لاحظ errors="replace": يمكن للنموذج أن يصدر تسلسل token ينتهي في منتصف حرف، وهذا ليس افتراضًا نظريًا — إنه ما يحدث عندما تُقطع استجابة بث في منتصف رمز تعبيري، ولهذا تخزن APIs البث البايتات الجزئية مؤقتًا بدل فك الترميز token بعد token.

تعمل العودة ذهابًا وإيابًا على أي شيء، وهذا هو وعد BPE على مستوى البايت:

TEXT
'strawberry'                            -> 6 tokens, decode == original: True
'Alice was beginning to get very tired' -> 14 tokens, decode == original: True
'café — naïve — 日本語'                   -> 23 tokens, decode == original: True

بمجرد أن تتضح الآلية، يتبين أن مجموعة من الشكاوى التي تبدو غير مترابطة هي الشكوى نفسها.

الحساب. لا تُقسَّم الأرقام بطريقة متسقة:

TEXT
1234     -> 2 tokens  ['123', '4']
12345    -> 2 tokens  ['123', '45']
1000000  -> 3 tokens  ['100', '000', '0']
3.14159  -> 4 tokens  ['3', '.', '141', '59']
2024     -> 2 tokens  ['202', '4']

لجمع 1234 و12345 يجب على النموذج أولًا أن يستنتج أن ['123','4'] و['123','45'] أرقام تصطف خاناتها بطريقة معينة — ويتغير الاصطفاف لكل زوج من الأرقام. خانات الرقم ليست في الأماكن نفسها من رقم إلى آخر. بعض tokenizers الأحدث تجبر الأرقام على الانقسام إلى مجموعات متسقة من ثلاثة تحديدًا لإزالة هذه العقبة، والنماذج المدرَّبة بها أفضل في الحساب بدرجة قابلة للقياس.

المسافات البادئة في Python.

TEXT
'    x = 1'      -> 5 tokens  ['   ', ' x', ' =', ' ', '1']
'        x = 1'  -> 5 tokens  ['       ', ' x', ' =', ' ', '1']
'\tx = 1'        -> 4 tokens  ['\tx', ' =', ' ', '1']

أربع مسافات وثماني مسافات هما tokens مفردة مختلفة، وtab ملتحم بالحرف الذي بعده. المسافات البادئة، وهي بنية نحوية في Python، ممثلة بشكل غير متسق — وهذا جزء كبير من سبب أن النماذج كانت تنتج Python بمسافات بادئة خاطئة قليلًا، وسبب أن tokenizers الموجهة للكود تضيف tokens صريحة لسلاسل المسافات البادئة الشائعة.

التهجئة والعكس. السبب نفسه مثل عدّ حروف r: أن تطلب من نموذج عكس strawberry يعني أن تطلب منه إعادة ترتيب أحرف داخل ثلاثة ids مبهمة. تفعل النماذج ذلك لأنها حفظت التهجئات أثناء التدريب لا لأنها تنظر إليها، ولهذا تنجح مع الكلمات الشائعة وتفشل مع النادرة.

Glitch tokens. الحالة الأكثر لفتًا هي SolidGoldMagikarp ومجموعة من السلاسل المشابهة التي جعلت GPT-2 وGPT-3 يتصرفان بغرابة — يرفضان تكرارها، وينتجان خرجًا غير مرتبط، وأحيانًا يهينان المستخدم. التفسير عادي ويتبع مباشرة من حقيقة أن tokenizer يُدرَّب منفصلًا عن النموذج: كانت تلك السلاسل شائعة في متن تدريب tokenizer (كانت أسماء مستخدمين في Reddit)، لذلك حصلت على token خاص بها، لكنها كانت نادرة أو غائبة في متن تدريب النموذج. النتيجة هي صف embedding هُيئ عشوائيًا ونادرًا ما حُدّث. لدى النموذج رمز لم يره عمليًا، وسلوكه هناك هو ما صادف أن منحته التهيئة العشوائية.

WordPiece، المستخدم في BERT، يختلف عن BPE في قاعدة الاختيار: بدل دمج الزوج الأكثر تكرارًا، يدمج الزوج الذي يزيد احتمالية بيانات التدريب أكثر — وهذا يطبّع بحسب مدى شيوع الأجزاء أصلًا، لذلك يمكن لزوج من قطعتين نادرتين أن يتغلب على زوج من قطعتين شائعتين.

Unigram، من Kudo، يعمل بالعكس: ابدأ بمعجم مرشح كبير ثم أزل تكراريًا القطع التي يؤذي حذفها احتمالية المتن أقل ما يمكن. كما يعطي احتمالًا لكل تقسيم، ما يسمح بأخذ عينات من tokenizations مختلفة للسلسلة نفسها كمنظِّم.

SentencePiece هو التنفيذ الذي تستخدمه معظم النماذج غير الإنجليزية. إسهامه هو التعامل مع الإدخال كتدفق خام بلا pre-tokenization إطلاقًا، وترميز المسافة كحرف مرئي، ما يعني أنه يعمل بالطريقة نفسها للغات التي لا تفصل الكلمات بمسافات. ويمكنه تشغيل BPE أو Unigram تحته.

tokenizer هو واجهة فاقدة بين النص والأرقام، وكل سلوك غريب في هذا الفصل هو ظهور الواجهة من خلال النموذج. يجدر توضيح أن هذه المقايضة مقصودة: BPE على مستوى البايت يعني أنه لا يوجد إدخال غير قابل للتمثيل أبدًا، وأن التسلسلات أقصر بأربع إلى خمس مرات مما ستكون عليه لو كانت أحرفًا، وأن الكلمات الشائعة تصل كاملة.

الثمن هو أن ذرات النموذج ليست ذراتنا. إنه يستدل على نص لا يستطيع تهجئته، بوحدات اختيرت بعدّ تكرارات في متن لم يره، وبكلفة لكل لغة لم يتفاوض عليها أحد.

أصبح لديك الآن تسلسل من أعداد صحيحة. هذا هو تنسيق الإدخال لكل شيء في بقية الجزء الثاني.

ما لا تملكه هو أي سبب يجعل عددًا صحيحًا يتبع آخر. يقدم الفصل التالي الهدف الذي يُدرَّب عليه كل نموذج لغة، وهو بسيط على نحو مدهش: بالنظر إلى tokens حتى الآن، تنبأ بالـ token التالي. ذلك الهدف الواحد — بلا تسميات، بلا annotation، مجرد نص ومستقبله نفسه هو الهدف — هو ما يحوّل الإنترنت كله إلى بيانات تدريب، ومنه تأتي أول تمثيلات حقيقية للنموذج.

ويتطلب أيضًا أن تكون قاعدة السلسلة في الاحتمالات من الفصل 2 صحيحة تمامًا، لأن الادعاء بأن التنبؤ بـ token واحد كل مرة هو نفسه نمذجة مستندات كاملة هو تحليل إلى عوامل، لا استعارة.

الفصل 8 هو الهدف autoregressive، وembeddings، وأول مكان يتعلم فيه النموذج شيئًا لم يضعه أحد هناك.


Kudo, T. Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates (arXiv:1804.10959) يقدم نموذج Unigram؛ وKudo وRichardson، SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing (arXiv:1808.06226) هو التنفيذ الذي تستخدمه معظم النماذج متعددة اللغات؛ وSchuster وNakajima، Japanese and Korean Voice Search (ICASSP 2012) هو أصل WordPiece. عمل Andrej Karpathy بعنوان Let's build the GPT Tokenizer ومستودع karpathy/minbpe المرافق هما السلفان المباشران للكود في هذا الفصل ويذهبان أبعد بكثير، بما في ذلك regex الخاص بـ GPT-4 والتعامل مع special-token. يغطي الفصل 6 من Hugging Face LLM Course الخوارزميات الثلاث جنبًا إلى جنب مع أمثلة محلولة.

  1. Gage, P. A New Algorithm for Data Compression. The C Users Journal 12(2), pp. 23–38 (1994). Byte-pair encoding بوصفه مخطط ضغط، قبل اثنين وعشرين عامًا من استخدامه في نماذج اللغة.

  2. Sennrich, R., Haddow, B. and Birch, A. Neural Machine Translation of Rare Words with Subword Units. arXiv:1508.07909 (2015; ACL 2016). الورقة التي أدخلت BPE إلى NLP، بدافع كلمات خارج المعجم في الترجمة.

  3. Radford, A., Wu, J., Child, R., Luan, D., Amodei, D. and Sutskever, I. Language Models are Unsupervised Multitask Learners (2019). يقدم القسم 2.2 BPE على مستوى البايت مع regex الخاص بـ pre-tokenization الذي نوقش أعلاه.

هل أنت مستعد لتترك الاختيار لـ LIA؟

ابنِ بكل نماذج الذكاء الاصطناعي في مكان واحد — ابدأ مجانًا اليوم.