NLP
من النص الخام إلى النماذج اللغوية الكبيرة.
معالجة اللغة الطبيعية (NLP)
لا يعرف النموذج أن يتعامل إلا مع الأرقام. أما النص فهو سلسلة من الأحرف يتوقف معناها على الترتيب والسياق والضمني، وأحيانًا على ما لم يُكتب أصلًا. وبين هذين العالمين لا بد من بناء جسر، وطريقة بناء هذا الجسر تحدد تقريبًا كل ما سيُحسنه النظام وكل ما سيخفق فيه.
وهذا ما يفسر سلوكيات محيّرة. فنموذج لغوي قادر على كتابة مقال متوازن قد يخطئ في عدّ حروف كلمة. وقد يؤكد بثقة تامة مرجعًا بيبليوغرافيًا لم يوجد قط. تبدو هذه الإخفاقات عشوائية، لكنها ليست كذلك. فكل واحد منها يُستنتج من خيار تصميمي محدد يقع في موضع ما على الجسر بين الأحرف والأرقام.
يعبر هذا الدرس الجسر بالترتيب: تقطيع النص، ثم تحويل القطع إلى متجهات، ثم جعل كل قطعة تراعي القطع الأخرى. وهذه النقطة الثالثة هي الانتباه (attention)، الفكرة التي قلبت المجال في عام 2017، وعليها يقوم كل ما يُسمى اليوم «نموذجًا لغويًا كبيرًا». وسنبرمجها في خمسة أسطر من NumPy.
وسننهي بآلية الهلوسة. لا لنأسف عليها، بل لنبيّن أنها نتيجة مباشرة ومتوقعة للهدف الذي نمنحه لهذه النماذج.
تقطيع النص: الترميز إلى وحدات (Tokenization)
القرار الأول: ما هي الوحدة الأولية؟ الجواب البديهي، وهو الكلمة المفصولة بمسافات، تتكشف حدوده سريعًا.
texte = "Le chat dort. Les chats dorment, le chaton dort aussi !"
mots = texte.lower().replace(".", " ").replace(",", " ").replace("!", " ").split()
print(mots)
# ['le', 'chat', 'dort', 'les', 'chats', 'dorment', 'le', 'chaton', 'dort', 'aussi']
print(len(mots), len(set(mots))) # 10 8
بالنسبة إلى الآلة، فإن chat وchats وchaton ثلاثة رموز لا علاقة بينها إطلاقًا، فهي غريبة بعضها عن بعض بقدر غربة chat عن aussi. والأمر نفسه ينطبق على dort وdorment. وسيتعين تعلم كل صيغة مصرَّفة على حدة، مما يضاعف عدد الأمثلة اللازمة.
أما المشكلة الثانية فهي المفردات الخارجة عن القائمة. فالكلمة الغائبة عن مفردات التدريب، كاسم علم أو لفظ مستحدث أو خطأ مطبعي، لا تملك أي تمثيل، فتُستبدل برمز عام للمجهول.
لهذا تقطّع النماذج الحالية النص إلى وحدات جزئية من الكلمات (sous-mots). وأشهر خوارزمية في ذلك هي Byte Pair Encoding، وهي تنطلق من الأحرف وتدمج تكراريًا أكثر الأزواج تواترًا في المدونة. والنتيجة حل وسط: تبقى الكلمات الشائعة قطعة واحدة، وتتفكك الكلمات النادرة إلى أجزاء معروفة.
Découpage en mots : ["anticonstitutionnellement"] → inconnu si absent
Découpage en sous-mots : ["anti", "constitution", "nelle", "ment"] → tout est connu
وهذا الخيار له نتيجة مباشرة كثيرًا ما تُساء فهمها: النموذج اللغوي لا يرى الحروف. إنه يرى معرّفات لأجزاء. وسؤاله عن عدد مرات ظهور الحرف «r» في كلمة يعني أن نطلب منه معلومة ليست أمام عينيه، وهذا يفسر الهشاشة الموثقة جيدًا لهذه النماذج في مهام عدّ الحروف والجناس التبادلي (anagrammes) والقوافي.
وفي المقاربات الكلاسيكية، يُمثَّل كل مستند بعد ذلك بمتجه من أعداد التكرار:
from sklearn.feature_extraction.text import CountVectorizer
corpus = [
"le chat dort sur le canape",
"le chien dort sur le tapis",
"le chat mange une souris",
"le chien mange une croquette",
"la souris mange du fromage",
]
v = CountVectorizer()
M = v.fit_transform(corpus)
print(M.shape) # (5, 14)
print(v.get_feature_names_out())
# ['canape' 'chat' 'chien' 'croquette' 'dort' 'du' 'fromage' 'la' 'le'
# 'mange' 'souris' 'sur' 'tapis' 'une']
خمس جمل قصيرة جدًا تنتج 14 عمودًا. وفي مدونة حقيقية يضم هذا المتجه عشرات الآلاف من الأبعاد، وتكون قيمة معظمها صفرًا في أي مستند معين. والأهم أن هذا التمثيل يتجاهل الترتيب تمامًا: فالجملتان «le chat mange la souris» و«la souris mange le chat» متطابقتان فيه.
من الكلمات إلى المتجهات: التضمينات المعجمية (Word embeddings)
الفكرة التي تفتح الطريق لغوية قبل أن تكون رياضية: الكلمات التي تظهر في السياقات نفسها تتقارب معانيها. هذه هي الفرضية التوزيعية، ويمكن التحقق منها بالحساب.
import numpy as np
vocab = v.get_feature_names_out().tolist()
# Matrice de co-occurrence : combien de fois deux mots apparaissent
# dans la même phrase.
C = np.zeros((len(vocab), len(vocab)))
for phrase in corpus:
presents = [vocab.index(m) for m in phrase.split() if m in vocab]
for i in presents:
for j in presents:
if i != j:
C[i, j] += 1
def cosinus(u, w):
return float(u @ w / (np.linalg.norm(u) * np.linalg.norm(w)))
i_chat = vocab.index("chat")
i_chien = vocab.index("chien")
i_fromage = vocab.index("fromage")
print(round(cosinus(C[i_chat], C[i_chien]), 3)) # 0.867
print(round(cosinus(C[i_chat], C[i_fromage]), 3)) # 0.258
في خمس جمل فقط، تبلغ درجة التشابه بين chat وchien 0.867، مقابل 0.258 بين chat وfromage. لم يتلقَّ الحساب أي معلومة عن الحيوانات، وإنما لاحظ ببساطة أن chat وchien تحيط بهما الكلمات نفسها (le وdort وsur وmange)، بينما يرتاد fromage جوارًا مختلفًا.
وتضفي التضمينات المعجمية (word embeddings) طابعًا صناعيًا على هذه الفكرة. فبدل المتجهات المتناثرة بحجم المفردات، يُتعلَّم لكل كلمة متجه كثيف من بضع مئات من الأبعاد، يُحسَّن للتنبؤ بسياقها. وهذا هو مبدأ word2vec (Mikolov وآخرون، 2013) وGloVe (Pennington وآخرون، 2014).
أما مقياس التشابه فيبقى هو نفسه:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
T = TfidfVectorizer().fit_transform(corpus)
print(cosine_similarity(T[0:1], T).round(2))
# [[1. 0.61 0.38 0.18 0. ]]
الجملة 0 («le chat dort sur le canapé») قريبة من الجملة 1 («le chien dort sur le tapis») بدرجة 0.61، وغريبة تمامًا عن الجملة 4 («la souris mange du fromage») بدرجة 0.00. ويصحح ترجيح TF-IDF بالمناسبة عيبًا في العدّ الخام: فهو يخفض وزن الكلمات الموجودة في كل مكان، مثل le، التي لا تقدم أي معلومة تمييزية.
وتبقى هناك حدّ مهم: فالتضمين الكلاسيكي يمنح متجهًا واحدًا لكل كلمة. فكلمة «avocat» لها التمثيل نفسه في «un avocat au barreau» (محامٍ) وفي «un avocat bien mûr» (ثمرة أفوكادو). وحل ذلك يقتضي جعل التمثيل معتمدًا على السياق، وهذا بالضبط ما تقدمه الآلية التالية.
الانتباه: عدم النظر إلى كل الكلمات بالقدر نفسه
يجيب الانتباه عن سؤال بسيط: لتمثيل كلمة معينة، أي الكلمات الأخرى في الجملة ينبغي أخذها في الاعتبار، وبأي نسبة؟
يتم الحساب بثلاثة كائنات لكل كلمة: استعلام (query)، وهو ما تبحث عنه هذه الكلمة، ومفتاح (key)، وهو ما تعرضه كل كلمة، وقيمة (value)، وهي المعلومة المطلوب استرجاعها. تُقارَن الاستعلام بكل المفاتيح، وتُحوَّل الدرجات إلى نسب مجموعها 1، ثم يُحسب متوسط مرجَّح للقيم.
import numpy as np
np.set_printoptions(suppress=True)
mots = ["chat", "souris", "dort"]
# Clés et valeurs illustratives, choisies pour rendre le calcul lisible.
K = np.array([[4.0, 0.0, 0.0, 0.0], # chat
[3.0, 2.0, 0.0, 0.0], # souris
[0.0, 0.0, 4.0, 0.0]]) # dort
V = np.array([[10.0], [6.0], [1.0]])
Q = np.array([[4.0, 0.0, 0.0, 0.0]]) # la requête cherche un sujet animé
d = K.shape[1]
scores = Q @ K.T / np.sqrt(d) # division : évite des scores trop étalés
poids = np.exp(scores - scores.max())
poids = poids / poids.sum() # softmax
print(scores.round(2)) # [[8. 6. 0.]]
print(poids.round(3)) # [[0.881 0.119 0. ]]
print((poids @ V).round(2)) # [[9.52]]
القراءة مباشرة: 88.1% من الانتباه يذهب إلى chat، و11.9% إلى souris، وما يكاد يكون لا شيء إلى dort. والتمثيل الناتج، 9.52، يقارب تمثيل chat (10.0)، مع انجذاب طفيف نحو تمثيل souris (6.0).
ثلاث خصائص تفسر نجاح هذه الآلية:
- الأوزان تُحسب ولا تُثبَّت. ستحصل الكلمة نفسها على توزيع مختلف في جملة أخرى. فيغدو التمثيل سياقيًا، وهذا يحل مشكلة «avocat».
- المسافة لا تكلّف شيئًا. يمكن لكلمة في الموضع 1 أن تتلقى كل انتباه كلمة في الموضع 400 بالسهولة نفسها التي تتلقاها من جارتها المباشرة. أما البنى التكرارية التي سبقتها، والتي كانت تعالج النص كلمة بعد كلمة، فكانت تفقد تدريجيًا المعلومات البعيدة.
- كل شيء قابل للتوازي. تُحسب درجات جميع الكلمات في جداء مصفوفي واحد، وبالتالي على بطاقة الرسوميات. وهذه الخاصية، بقدر جودة النتائج، هي التي سمحت بالانتقال إلى النطاقات الكبيرة.
المحوّلات (Transformers) والنماذج اللغوية الكبيرة
تسمى البنية التي تكدّس طبقات الانتباه المحوّل (transformeur)، وقد قُدّمت في عام 2017 في مقال «Attention Is All You Need» لـVaswani وآخرين. ولأن الانتباه وحده يتجاهل المواضع، يُضاف إليه ترميز للموضع، إلى جانب طبقات كثيفة كلاسيكية وعمليات تطبيع.
والنموذج اللغوي الكبير هو محوّل مدرَّب على مهمة بسيطة بصورة مذهلة: بالنظر إلى الوحدات السابقة، تنبّأ بالتالية. لا حاجة إلى أي توسيم بشري، فالنص هو وسمه الخاص، مما يتيح التدريب على مدونات ضخمة.
بعض أوامر الحجم الموثقة، المفيدة لتحديد المقاييس:
| النموذج | السنة | عدد المعاملات |
|---|---|---|
| BERT-base | 2018 | نحو 110 ملايين |
| BERT-large | 2018 | نحو 340 مليونًا |
| GPT-3 | 2020 | 175 مليارًا |
تأتي هذه الأرقام من المقالات المنشورة لكل نموذج. أما النماذج التجارية الحديثة فلم تعد تنشر هذه المعلومة بانتظام: وعند غياب المصدر، الأفضل ألا نذكر شيئًا.
والأمر اللافت، الذي لم يكن متوقعًا، أن هذا الهدف الضيق، أي التنبؤ بالكلمة التالية، ينتج قدرات لم تُبرمج: الترجمة والتلخيص وإعادة الصياغة واتباع التعليمات. غير أنه ينبغي التزام الدقة في ما هو ثابت. فالثابت هو الأداء المقيس على مهام التقييم. أما ما يزال محل جدل فهو التفسير: هل تنتمي هذه القدرات إلى شكل من التعميم، أم إلى استيفاء داخلي دقيق جدًا انطلاقًا من مدونة هائلة؟ سؤال مفتوح، وكل تأكيد قاطع في هذا الاتجاه أو ذاك يتجاوز ما تتيح البيانات إثباته.
لماذا يهلوس النموذج اللغوي
يمكن إثبات الآلية على نموذج صغير جدًا. فنموذج الثنائيات (bigrammes) لا يحتفظ إلا بشيء واحد: لكل كلمة، قائمة الكلمات التي تلتها في المدونة. ثم يولّد النص بالسحب العشوائي من هذه القائمة.
import random
from collections import defaultdict
texte = ("le chat dort sur le canape le chien dort sur le tapis "
"le chat mange une souris le chien mange une croquette")
mots = texte.split()
suivants = defaultdict(list)
for a, b in zip(mots, mots[1:]):
suivants[a].append(b)
random.seed(3)
mot = "le"
phrase = [mot]
for _ in range(11):
if mot not in suivants:
break
mot = random.choice(suivants[mot])
phrase.append(mot)
print(" ".join(phrase))
# le canape le chien mange une souris le chien dort sur le
النتيجة تكاد تكون فرنسية. فالتتابعات المحلية لا غبار عليها: «le chien mange une souris» جملة سليمة التكوين تمامًا. وهي أيضًا خاطئة قياسًا إلى المدونة، حيث يأكل الكلب قطعة من طعام الكلاب (croquette) ويكون القط هو الذي يأكل الفأر. لم يكذب النموذج ولم يخطئ بمعنى علة برمجية: لقد أنتج تتمة معقولة إحصائيًا، وهذا بالضبط، وهذا فقط، ما طُلب منه.
هذه هي الهلوسة، على مقياس عشرين كلمة من المفردات. ويفعل نموذج من عدة مليارات من المعاملات الشيء نفسه بسلاسة تفوقها بما لا يقارن، مما يجعل اكتشاف الخطأ أصعب بكثير. فاختلاف الحجم يغيّر جودة السطح، لا طبيعة الهدف.
وتنتج عن ذلك أربع نتائج عملية، يمكن التحقق منها جميعًا:
- السلاسة ليست دليلًا على الموثوقية. يُصاغ المرجع المختلق بالثقة نفسها التي يُصاغ بها المرجع الصحيح، لأن العمليتين تنتجهما الآلية ذاتها.
- لا يوجد مصدر. لا ينسخ النموذج من قاعدة معارف يمكن الرجوع إليها، بل ينتج سلسلة من الوحدات. وسؤاله «من أين أتت هذه المعلومة؟» يحصل على إجابة مولَّدة، لا على أثر.
- نافذة السياق محدودة. فبعد طول معين، تخرج الوحدات القديمة من السياق المأخوذ في الاعتبار.
- المعالجات تخفف ولا تزيل. إن تقديم وثائق ضمن السياق واشتراط اقتباسات يمكن التحقق منها، وهو ما يسمى التوليد المعزَّز بالاسترجاع (RAG)، يقلل الظاهرة كثيرًا بربط الإجابة بنصوص حقيقية. وإلى اليوم، لا توجد تقنية منشورة تزيلها بالكامل.
ومن هنا يأتي الموقف الهندسي السليم: التعامل مع مخرجات النموذج اللغوي بوصفها اقتراحًا يجب التحقق منه، لا نتيجة نهائية. وفي كل موضع تكون فيه الدقة التزامًا تعاقديًا، يجب أن يكون التحقق خارج النموذج.
ما ينبغي تذكره
- الترميز إلى وحدات هو أول خيار تصميمي وله آثار دائمة. فالتقطيع إلى وحدات جزئية يحل مشكلة الكلمات المجهولة، لكنه يجعل النموذج لا يرى الحروف أبدًا، ومن هنا هشاشته في عدّ الحروف.
- يمكن التحقق من الفرضية التوزيعية بالحساب: ففي خمس جمل، يبلغ التشابه بين
chatوchien0.867 مقابل 0.258 بينchatوfromage، دون تقديم أي معرفة بعلم الحيوان. - يحسب الانتباه لكل كلمة نسبة الانتباه الواجب منحها للكلمات الأخرى. فهو يجعل التمثيل معتمدًا على السياق، ويلغي كلفة المسافة، ويُحسب على التوازي.
- النموذج اللغوي الكبير هو محوّل مدرَّب على التنبؤ بالوحدة التالية. وهذا الهدف الوحيد ينتج قدرات غير مبرمجة؛ أما تفسيرها النظري فما يزال محل جدل.
- الهلوسة ليست عطلًا بل نتيجة للهدف: فالنموذج يحسّن المعقولية لا الحقيقة. ونموذج ثنائيات من عشرين كلمة يعيد إنتاجها أصلًا. وكل مخرجات مخصصة لاستخدام جاد يجب التحقق منها خارج النموذج.
التعليقات
التعليقات مدعومة عبر GitHub Discussions
سجّل الدخول باستخدام GitHub للمشاركة في النقاش