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

الوحدة 4 — التصفية القائمة على المحتوى والتشابه

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

المبدأ

نُمثّل كلّ عنصر بمتجه من الخصائص، ثمّ نُمثّل بروفيل المستخدم بمتوسّط متجهات ما أعجبه، ثمّ نُوصي بالعناصر الأقرب إلى بروفيل المستخدم.

على منصّة الدورات، كلّ دورة تملك: عنوانًا، وصفًا، وقائمة كلمات مفتاحيّة، ومستوى (مبتدئ / متوسّط / متقدّم)، ومدّة، وسعرًا. الميّزات المُنظَّمة (المستوى، المدّة) يمكن تمثيلها مباشرةً. لكنّ الوصف النصّيّ هو أغنى إشارة، وهو ما يستحقّ عناية خاصّة.

تضمين الأوصاف بالنماذج اللغويّة

كانت الطرق التقليديّة (TF-IDF، LSA) تعتمد على تكرار الكلمات. تعمل بشكل معقول لكنّها تفشل في التماس المعاني: «Docker» و«الحاويات» لا يلتقيان بمجرّد تكرار الكلمة.

نماذج التضمين الحديثة (Sentence-BERT، وMultilingual-E5، وOpenAI Embeddings) تُحوّل كلّ نصّ إلى متجه في فضاء دلاليّ. النصوص المتقاربة معنى تكون متجهاتها متقاربة، بغضّ النظر عن الكلمات المستعملة. نتناول هذا بالتفصيل في الدورة 13 (معالجة اللغة الطبيعيّة)؛ هنا نستعمل النتيجة.

from sentence_transformers import SentenceTransformer
import numpy as np
import pandas as pd

# نموذج متعدّد اللغات يتعامل مع العربيّة والفرنسيّة والإنجليزيّة
modele = SentenceTransformer("intfloat/multilingual-e5-small")

cours = pd.read_csv("cours.csv") # courseId, title, description, keywords, niveau

# نصّ موحّد لكلّ دورة
def texte_dorse(row):
return f"{row['title']}. {row['description']}. مستوى: {row['niveau']}. {row['keywords']}"

textes = cours.apply(texte_dorse, axis=1).tolist()

# تضمين كلّ دورة: مصفوفة (500, 384)
vecteurs = modele.encode(textes, normalize_embeddings=True)

print(f"شكل مصفوفة التضمينات: {vecteurs.shape}")

الخيار normalize_embeddings=True يُعيّر المتجهات إلى طول واحد، ممّا يجعل التشابه الكوسينوسي مساويًا للضرب النقطيّ. حيلة صغيرة تُسرّع كلّ ما يأتي بعد.

بروفيل المستخدم: متوسّط ما أعجبه

بعد تضمين كلّ دورة، نبني متجه بروفيل المستخدم من متوسّط متجهات الدورات التي أتمّها أو قيّمها إيجابيًّا (مثلًا 4 نجوم فأكثر):

u=1DuiDuwici\vec{u} = \frac{1}{|D_u|}\sum_{i \in D_u} w_i\,\vec{c}_i

DuD_u الدورات التي تفاعل معها إيجابيًّا، ci\vec{c}_i متجه الدورة، wiw_i وزن يُعبّر عن قوّة الإشارة (5 نجوم أثقل من 3، إتمام أثقل من تسجيل).

def profil_utilisateur(interactions, vecteurs, index_cours):
"""interactions: [(courseId, poids), ...] pour un utilisateur."""
if not interactions:
return None
vecs = np.array([vecteurs[index_cours[cid]] for cid, _ in interactions])
poids = np.array([p for _, p in interactions]).reshape(-1, 1)
profil = (vecs * poids).sum(axis=0) / poids.sum()
# renormaliser pour rester sur la sphère unité
return profil / np.linalg.norm(profil)

بروفيل المستخدم متجه في نفس الفضاء الدلاليّ للدورات. هذا الترميز الموحّد ضروريّ لحساب التشابه.

التوصية: أقرب الجيران

نُوصي المتعلّم بالدورات الأقرب إلى بروفيله، مع استبعاد ما تفاعل معه سابقًا:

def recommander(profil, vecteurs, ids_deja_vus, top_k=10):
if profil is None:
return [] # مستخدم جديد، لا بروفيل بعد -> ذاهب إلى البداية الباردة
similarites = vecteurs @ profil # لأنّ كلّ شيء معيَّر
ordre = np.argsort(-similarites)
recommandations = []
for idx in ordre:
if idx in ids_deja_vus:
continue
recommandations.append((idx, float(similarites[idx])))
if len(recommandations) >= top_k:
break
return recommandations

الضرب المصفوفيّ vecteurs @ profil يحسب كلّ التشابهات دفعةً واحدة، وهو أسرع بكثير من حلقة صريحة. على 500 دورة، يستغرق أقلّ من ميلي ثانية. لعدد أكبر بكثير (ملايين العناصر)، نحتاج إلى بحث الجيران التقريبيّ (FAISS، Annoy، HNSW) الذي نتناوله في الوحدة السادسة.

نقاط القوّة الحقيقيّة

للمقاربة القائمة على المحتوى مزايا لا تقدّمها التصفية التعاونيّة.

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

تفسّر التوصية بسهولة. يمكن أن نقول للمستخدم: «نُوصيك بهذه الدورة لأنّها شبيهة بدورة Docker التي أتممتها». هذا الشفافيّة تُحسّن الثقة، وهي مطلب في بعض القطاعات المنظّمة.

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

فقاعة الترشيح: الفخّ الأكبر

في المقابل، للمقاربة عيب بنيويّ يجب مواجهته صراحةً. إذا أعجبت المستخدم دورات Docker، فسنُوصيه بمزيد من دورات Docker، ثمّ بمزيد من الحاويات، ثمّ بمزيد من DevOps. متجه بروفيله سيغرق تدريجيًّا في زاوية واحدة من الفضاء الدلاليّ، وسيقلّ تعرّضه لأشياء مختلفة.

هذه الظاهرة تُسمّى فقاعة الترشيح (filter bubble). صاغ العبارة إيلي باريزر عام 2011 في وصف خوارزميّات المحتوى الاجتماعيّ، لكنّها تنطبق حرفيًّا على التوصية بالمحتوى. المخاطر: تضييق فرصة الاكتشاف، حصر المستخدم في تخصّص واحد، وفشل تجاريّ (لا يشتري ما لا يراه).

الحلول العمليّة:

  • التنويع المُتعمَّد: نأخذ الأعلى 20 تشابهًا، ثمّ نُطبّق MMR (Maximal Marginal Relevance) لاختيار 10 تُعظّم التشابه مع تعظيم التباعد فيما بينها.
  • الاستكشاف المحسوب: نُدرج عمدًا 2 من 10 توصيات من خارج الفقاعة (ε-greedy).
  • الهجينة مع التصفية التعاونيّة: من يشبهك ربّما اكتشف شيئًا مختلفًا؛ نعود إلى هذا في الوحدة التالية.

قيود صريحة

الطريقة تعتمد كلّيًّا على جودة وصف العناصر. دورة بوصف نصف سطر تُنتج تضمينًا فقيرًا، وتوصياتها ستكون ضعيفة. وعمق التفاصيل مطلوب. كذلك النموذج اللغويّ نفسه: تضمين إنجليزيّ قد لا يفهم فروقًا بين مستويات الحدّة بالعربيّة إذا لم يكن مُدرَّبًا على العربيّة، ولذلك نختار عادةً نماذج متعدّدة اللغات.

الخلاصة

  • التصفية بالمحتوى تُمثّل كلّ عنصر بمتجه من ميّزات (نصّ + بنيويّة)، وبروفيل المستخدم متوسّط موزون لعناصره الإيجابيّة.
  • تضمينات النصّ الحديثة (E5، Sentence-BERT) تلتقط المعنى، لا مجرّد الكلمات، وتُعالج تعدّد اللغات في نموذج واحد.
  • المزيّة الكبرى: حلّ البداية الباردة على العنصر وتفسير التوصيات. القيد الكبير: فقاعة الترشيح.
  • تُعالَج الفقاعة بتنويع مُتعمَّد (MMR، ε-greedy) وبالجمع مع التصفية التعاونيّة.

الوحدة التالية: المقاربات الهجينة، كيف نجمع التصفية التعاونيّة بالمحتوى بحيث يكمّل الاثنان بعضهما.