انتقل إلى المحتوى الرئيسي

الوحدة 6 — المرمّز: BERT ونماذج الفهم

يجتمع في هذه الوحدة كلّ ما بنيناه في الوحدات السابقة، ونركّبه في كتلة مرمِّز كاملة. ثمّ نُخرج من الشيفرة إلى الواقع لنرى كيف بنى BERT، منذ 2018، فئة كاملة من النماذج تُسمّى نماذج الفهم — نماذج ممتازة في تصنيف النصّ، واستخراج المعلومات، والإجابة على الأسئلة، دون أن تولّد كلمة واحدة.

كتلة المرمّز الكاملة

كتلة المرمّز في المشروع الموجّه ليست أكثر من تكديس NN من EncoderLayer التي بنيناها في الوحدة 5، مع طبقة تضمين وترميز موضع في المُدخَل:

import torch
import torch.nn as nn

# نفترض EncoderLayer وSinusoidalPositionalEncoding من الوحدات السابقة

class TransformerEncoder(nn.Module):
def __init__(self, vocab_size: int, d_model: int = 128, num_heads: int = 4,
num_layers: int = 4, d_ff: int = 512, max_len: int = 512,
dropout: float = 0.1):
super().__init__()
self.embed = nn.Embedding(vocab_size, d_model)
self.pos = SinusoidalPositionalEncoding(d_model, max_len)
self.layers = nn.ModuleList([
EncoderLayer(d_model, num_heads, d_ff, dropout)
for _ in range(num_layers)
])
self.norm = nn.LayerNorm(d_model) # ضروري في pre-norm
self.dropout = nn.Dropout(dropout)
self.d_model = d_model

def forward(self, tokens: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
# tokens: (B, n)
x = self.embed(tokens) * (self.d_model ** 0.5) # مقياس Vaswani
x = self.pos(x)
x = self.dropout(x)
for layer in self.layers:
x = layer(x, mask=mask)
return self.norm(x) # (B, n, d_model)


if __name__ == "__main__":
encoder = TransformerEncoder(vocab_size=1000)
tokens = torch.randint(0, 1000, (2, 20))
out = encoder(tokens)
print(out.shape) # torch.Size([2, 20, 128])

اِنتبه إلى ثلاث نقاط. الأولى، ضربنا التضمين في dmodel\sqrt{d_{\text{model}}} كما فعل Vaswani، وإلّا يخنق ترميز الموضع (المصغّر مقاسه) التضمين. الثانية، LayerNorm نهائية بعد كلّ الطبقات، وهي ضرورية في pre-norm. الثالثة، ليست هناك طبقة إخراج بعد: كتلة المرمّز تُنتج تمثيلات جتونات ثريّة (B,n,dmodel)(B, n, d_{\text{model}})، وما بعدها يعتمد على المهمّة.

عمّاذا يبحث BERT بالضبط

Bidirectional Encoder Representations from Transformers، كما يفكّه الاسم، شيء واحد: مرمّز يقرأ التسلسل من الطرفَين معًا. فما جدّته الرئيسي على GPT-1 السابق له بأشهر ليس المعمارية (كلاهما Transformer)، بل مهمّة التدريب المسبق.

كان GPT-1 يتدرّب على مهمّة توقّع الجتون التالي: من أوّل جتون إلى الجتون قبل الأخير، أخبِرني الجتون التالي. هذا يُلزم النموذج بأن يُغطّي المستقبل بقناع (وهو ما سنراه في الوحدة 7)، أي لا يستطيع كلّ جتون النظر إلّا إلى ما سبقه.

BERT اقترح مهمّة مختلفة: Masked Language Model أو MLM. نأخذ جملة، ونُخفي 15% من جتوناتها عشوائيًّا بالجتون الخاصّ [MASK]، ونُطلب من النموذج التوقّع بأيّ جتون كانت. هذه المهمّة لا تفرض قناعًا: كلّ جتون يستطيع النظر إلى كلّ الجتونات الأخرى، بما فيها التي تليه. من هنا اسم «ثنائي الاتّجاه».

النتيجة تمثيلات جتونات تُدمج في كلّ منها معلومات من السياق كاملًا، مما يجعلها ممتازة لمهمّات الفهم.

المهمّة الثانوية الثانية في التدريب المسبق كانت Next Sentence Prediction: هل الجملة الثانية تتبع الأولى نصّيًّا؟ أوراق لاحقة أظهرت أنّ هذه المهمّة لا تُقدّم شيئًا يُذكر، فأُسقطت في RoBERTa.

جتون CLS ورأس التصنيف

يُضاف في مقدّمة كلّ تسلسل جتون خاصّ اسمه [CLS]. لا يحمل معنى لغويًّا في المُدخَل، لكنّه بعد مرور عدّة طبقات انتباه يتشرّب معلومات كلّ الجتونات الأخرى. تمثيله في مخرج المرمّز يُستعمل كـمتّجه يمثّل الجملة كلّها.

بناء عليه، رأس التصنيف بسيط:

class BertClassifier(nn.Module):
def __init__(self, encoder: TransformerEncoder, num_classes: int):
super().__init__()
self.encoder = encoder
self.head = nn.Linear(encoder.d_model, num_classes)

def forward(self, tokens):
h = self.encoder(tokens) # (B, n, d)
cls = h[:, 0, :] # التمثيل عند الموقع 0
return self.head(cls) # (B, num_classes)

بالنسبة إلى استخراج المعلومات (Named Entity Recognition مثلًا)، لا نُفلتِر إلى موقع CLS: بل نستعمل كلّ التمثيلات (B,n,d)(B, n, d) ونُطبّق رأسًا على كلّ موقع بمعزل. الفارق كلّه في كيفيّة استغلال المخرَجات، لا في المعمارية.

قبل أن تُدرّب BERT من الصفر

تدريب BERT-base من الصفر يتطلّب مئات ساعات GPU وعشرات جيغابايت من النصّ. لا يُبدأ من الصفر في الحالات العمليّة. الافتراض المسؤول أن تنطلق من نموذج مُدرَّب مسبقًا (bert-base، أو distilbert، أو araBERT، أو camemBERT حسب لغتك)، وتُطبِّق عليه الأفياد على مهمّتك. هذا هو موضوع كامل مذكرة الأفياد الرسميّة في مكتبة transformers، ويسمّى fine-tuning.

عائلة BERT: متى نختار من ومن

عائلة نماذج المرمّز نمت كثيرًا. اختصار المتحرّر:

النموذجالميزة الأساسيةمتى نختاره
bert-base-uncasedالمرجع الإنجليزي، 110 مليون معاملخطّ أساس على نصّ إنجليزي عامّ
bert-large340 مليون معاملتحسين بضع نقاط، لكنّه بطيء ثلاث مرّات
RoBERTaتدريب أطول ومصفوفة تدريب أفضلبديل أفضل من bert-base في الأغلب
DistilBERTتقطيرات على 60% معاملات وسرعة x2مناسب للاستدلال في الوقت الحقيقي
CamemBERTمُدرَّب على فرنسي مصفوفًاخطّ أساس على فرنسي
AraBERTمُدرَّب على عربي مصفوفًاخطّ أساس على عربي، خصوصًا الفصحى
MARBERTمُدرَّب على العربي بمنصّات التواصلنصّ عربي دارج ومختلط
DeBERTa v3انتباه مفصول عن الموضع، ونتائج أعلىحالات أدنى قليلة الموارد لكنّها تسعى للأداء

القاعدة العملية على مشاريع الإنتاج: ابدأ بDistilBERT لتَقيس البقيّة سرعة وأدنى تكلفة. ثمّ اصعد إلى النسخة المفتوحة المقاس الكامل إن كانت الجودة غير كافية.

الأفياد على مكتبة Transformers

قِدْم كتلة المرمّز يتضاءل حين ننتقل إلى الإنتاج. مكتبة Hugging Face transformers تُقدّم واجهة موحّدة لكلّ عائلة BERT وأمثالها:

from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch

model_name = "aubmindlab/bert-base-arabertv02"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=3)

texts = ["الفيلم كان رائعًا حقًّا", "ضاع الوقت والمال"]
enc = tokenizer(texts, padding=True, truncation=True, return_tensors="pt")

with torch.no_grad():
logits = model(**enc).logits

preds = logits.argmax(dim=-1)
print(preds) # (2,)

AutoTokenizer يُعيد جتونات بأرقام مفهرَسة على معجم النموذج نفسه. AutoModelForSequenceClassification يُضيف تلقائيًّا رأس تصنيف بعدد الأصناف الذي تطلبه. أثناء الأفياد، لا يُدرَّب الرأس وحده: بل يُدرَّب النموذج بأكمله بمعدّل تعلّم صغير جدًّا (بين 10510^{-5} و5×1055 \times 10^{-5}) على بيانات المهمّة.

انتبه إلى جتونات padding

بمجموع اثنَين من الجمل واحدة قصيرة والأخرى طويلة، padding=True يُكمل القصيرة بجتونات [PAD]. لا يجب أن يحصل الانتباه على أوزان لهذه الجتونات. المكتبة تُعالج هذا تلقائيًّا عبر attention_mask، لكن حين تكتب رأسًا مخصّصًا يستقبل hh كاملة، لا تنسَ أن تُطبّق القناع قبل أيّ تجميع.

في الخلاصة

  • كتلة المرمّز تجمع تضمين الجتون، ترميز الموضع، وعدّة طبقات EncoderLayer\text{EncoderLayer}؛ تُنتج تمثيلات جتونات (B,n,dmodel)(B, n, d_{\text{model}}) ثنائيّة الاتّجاه.
  • BERT يتدرّب بمهمّة قناع الجتون التي تُلزم النموذج بالتوقّع من السياق الثنائي؛ لهذا يقرأ من الطرفَين معًا.
  • جتون CLS في المقدّمة يتشرّب معلومات الجملة كاملةً؛ رأس التصنيف يعمل عليه، ورأس الاستخراج يعمل على كلّ الجتونات.
  • عمليًّا يبدأ من نموذج مُدرَّب مسبقًا ثمّ يُفاد على المهمّة؛ الاختيار بين bert وdistilbert وaraBERT وcamemBERT يعتمد على اللغة والسرعة وحجم البيانات.

الوحدة التالية: نبني فاكّ الترميز، ونرى كيف يجعل قناع سببي واحد من الكتلة نفسها آلة توليد، وهو أساس عائلة GPT.