يُقال إنه لا يمكنك أن تكره شخصًا حقًا إلا إذا أحببته أولًا. لا أعلم إن كان هذا صحيحًا كمبدأ عام، ولكنه بالتأكيد يصف علاقتي بـ NumPy.
بالمناسبة، NumPy هو برنامج يُجري عمليات حسابية على المصفوفات باستخدام بايثون. يحظى بشعبية هائلة، وكان له تأثير كبير على جميع مكتبات التعلم الآلي الشائعة مثل PyTorch. تشترك هذه المكتبات في معظم المشكلات التي أناقشها أدناه، لكنني سأختار NumPy لمزيد من الدقة.
يُسهّل NumPy الأمور البسيطة. لنفترض أن A مصفوفة ٥×٥، وx متجه بطول ٥، وتريد إيجاد المتجه y بحيث يكون Ay=x. في NumPy، يكون ذلك:
y = np.linalg.solve(A, x)
ما أجملها من أناقة! ما أجمله من وضوح!
لنفترض أن الوضع أكثر تعقيدًا. لنفترض أن A عبارة عن كومة من 100 مصفوفة بطول 5×5، مُعطاة كمصفوفة 100×5×5. ولنفترض أن x عبارة عن كومة من 100 متجه بطول 5، مُعطاة كمصفوفة 100×5. ولنفترض أنك تريد حل Aᵢyᵢ=xᵢ عند 1≤i≤100.
إذا كان بإمكانك استخدام الحلقات، فسيكون هذا الأمر سهلاً:
y = np.empty_like(x)
for i in range(100):
y[i,:] = np.linalg.solve(A[i,:,:], x[i,:])
لكن لا يمكنك استخدام الحلقات. إلى حد ما، هذا يُمثل قيدًا على بطء الحلقات في بايثون. لكن في الوقت الحاضر، كل شيء يعتمد على وحدة معالجة الرسومات (GPU)، وإذا كانت لديك مصفوفات كبيرة، فربما لا ترغب في استخدام الحلقات في أي لغة. لتشغيل كل هذه الترانزستورات، تحتاج إلى استدعاء دوال وحدة معالجة الرسومات (GPU) الخاصة التي تُقسّم المصفوفات إلى أجزاء صغيرة كثيرة وتُعالجها بالتوازي.
الخبر السار هو أن NumPy يعرف هذه الروتينات الخاصة (على الأقل إذا كنت تستخدم JAX أو CuPy)، وإذا قمت باستدعاء np.linalg.solve بشكل صحيح، فسوف يستخدمها.
الخبر السيئ هو أن لا أحد يعرف كيفية القيام بذلك.
لا تصدقني؟ حسنًا، أيهما الصحيح؟
y = linalg.solve(A,x)
y = linalg.solve(A,x,axis=0)
y = linalg.solve(A,x,axes=[[1,2],1])
y = linalg.solve(A.T, x.T)
y = linalg.solve(A.T, x).T
y = linalg.solve(A, x[None,:,:])
y = linalg.solve(A,x[:,:,None])
y = linalg.solve(A,x[:,:,None])[:,:,0]
y = linalg.solve(A[:,:,:,None],x[:,None,None,:])
y = linalg.solve(A.transpose([1,2,0]),x[:,:,None]).T
لا أحد يعلم. دعوني أريكم شيئًا آخر. إليكم الوثائق:

اقرأ هذا. تأمل فيه. الآن، لاحظ: ما زلت لا تعرف كيفية حل Aᵢyᵢ=xᵢ لجميع i دفعةً واحدة. هل هذا ممكن أصلًا؟ هل كذبتُ عندما قلتُ ذلك؟
بقدر ما أستطيع أن أقول، فإن ما يفعله الناس في الواقع هو تجربة الاختلافات العشوائية حتى يبدو أن أحدها يعمل.
لماذا NumPy سيئ؟
يتمحور NumPy حول تطبيق العمليات على المصفوفات. عندما تحتوي المصفوفات على بُعدين أو أقل، يكون كل شيء على ما يرام. ولكن إذا كنت تقوم بشيء، حتى لو كان معقدًا بعض الشيء، فستجد نفسك حتمًا أمام عملية تريد تطبيقها على بعض أبعاد المصفوفة A، وأبعاد أخرى من المصفوفة B، وأبعاد أخرى من المصفوفة C. ولا يمتلك NumPy نظريةً لكيفية التعبير عن ذلك.
دعني أوضح لك ما أقصده. افترض:
Aهي مصفوفةK×L×MBعبارة عن مصفوفةL×NCعبارة عن مصفوفةK×M
ولنفترض أنه لكل k وn، ترغب في حساب المتوسط على بُعدي L وM. أي أنك تريد
Dkn = 1/(LM) × ∑lm Aklm Bln Ckm.
للقيام بذلك، لديك خياران. الأول هو استخدام حيل محاذاة الأبعاد الغريبة:
D = np.mean(
np.mean(
A[:,:,:,None] *
B[None,:,None,:] *
C[:,None,:,None],
axis=1),
axis=1)
يا للهول، لماذا لا يوجد أي شيء في كل مكان؟ حسنًا، عند فهرسة مصفوفة في NumPy، يمكنك كتابة None لإضافة بُعد جديد. A يساوي K×L×M، لكن A[:,:,:,None] يساوي K×L×M×1. وبالمثل، B[None,:,None,:] يساوي 1×L×1×N وC[:,None,:,None] يساوي K×1×M×1. عند ضرب هذه القيم معًا، يُرسل NumPy جميع الأبعاد ذات الحجم 1 للحصول على مصفوفة K×L×M×N. بعد ذلك، تُحسب متوسط استدعاءات np.mean للبُعدين L وM.
أعتقد أن هذا سيء. أستخدم NumPy منذ سنوات، وما زلت أجد صعوبة في كتابة شيفرة برمجية كهذه دون الوقوع في أخطاء.
كما أن قراءتها شبه مستحيلة. ولإثبات ذلك، رميتُ عملة معدنية وأدخلتُ خطأً أعلاه إذا وفقط إذا كانت العملة على الوجه الآخر. هل هناك خطأ؟ هل أنت متأكد؟ لا أحد يعلم.
خيارك الثاني هو أن تحاول جاهدًا أن تكون ذكيًا. الحياة قصيرة وثمينة، ولكن إذا قضيت وقتًا طويلًا في قراءة وثائق NumPy، فقد تدرك في النهاية وجود دالة تُسمى np.tensordot، وأنه من الممكن جعلها تؤدي معظم العمل:
D = (1/L) * np.mean(
np.tensordot(A, B, axes=[1,0]) *
C[:,:,None],
axis=1)
هذا صحيح. (أعدك.) ولكن لماذا يعمل؟ ما الذي يفعله np.tensordot تحديدًا؟ لو رأيتَ هذا الكود في سياق آخر، هل كان لديك أدنى فكرة عمّا يحدث؟
هذه هي الطريقة التي سأفعل بها ذلك، إذا تمكنت من استخدام الحلقات:
D = np.zeros((K,N))
for k in range(K):
for n in range(N):
a = A[k,:,:]
b = B[:,n]
c = C[k,:]
assert a.shape == (L,M)
assert b.shape == (L,)
assert c.shape == (M,)
D[k,n] = np.mean(a * b[:,None] * c[None,:])
قد يجد من كتبوا كثيرًا في NumPy هذا الأمر مُرهقًا. أظن أن هذا يُشبه متلازمة ستوكهولم. لكن بالتأكيد يُمكننا الاتفاق على أنه واضح.
في الواقع، غالبًا ما تكون الأمور أسوأ. لنفترض أن A كان شكلها M×K×L بدلًا من K×L×M. مع الحلقات، لا مشكلة. لكن NumPy يتطلب منك كتابة خوارزميات معقدة مثل A.transpose([1,2,0]). أم يجب أن تكون A.transpose([2,0,1])؟ ما الأشكال الناتجة؟ لا أحد يعلم.
كانت الحلقات أفضل.
حسنًا لقد كذبت
هناك خيار ثالث:
D = 1/(L*M) * np.einsum('klm,ln,km->kn', A, B, C)
إذا لم ترَ مُجمّع أينشتاين من قبل، فقد يبدو الأمر مُرعبًا. لكن تذكّر، هدفنا هو إيجاد
Dkn = 1/(LM) × ∑lm Aklm Bln Ckm.
تُعطي السلسلة النصية في الكود أعلاه تسمياتٍ للمؤشرات في كلٍّ من المُدخلات الثلاثة (klm، ln، km) والمؤشرات المُستهدفة للمُخرج (->kn). بعد ذلك، تُضاعف دالة np.einsum العناصر المُقابلة للمُدخلات وتُجمع جميع المؤشرات غير الموجودة في المُخرج.
شخصيًا، أعتقد أن دالة np.einsum من الأجزاء النادرة الجيدة في NumPy. السلاسل النصية مُملة بعض الشيء، لكنها تستحق العناء، لأن الدالة بشكل عام سهلة الفهم (نوعًا ما)، وواضحة تمامًا، وعامة وفعّالة.
لكن، كيف يُحقق np.einsum كل هذا؟ إنه يستخدم الفهارس. أو بتعبير أدق، يُقدم لغةً صغيرةً خاصةً بالمجال تعتمد على الفهارس. لا يعاني من عيوب تصميم NumPy لأنه يرفض الالتزام بقواعده المعتادة.
لكن دالة np.einsum لا تؤدي سوى مهام قليلة. (بينما دالة Einops تؤدي مهامًا إضافية). ماذا لو أردت تطبيق دالة أخرى على أبعاد مختلفة لبعض المصفوفات؟ لا توجد دالة np.linalg.einsolve. وإذا أنشأتَ دالة خاصة بك، فلن تجد لها نسخة “أينشتاين” منها.
أعتقد أن جودة np.einsum تُظهر أن NumPy ذهب إلى مكان ما.
أين أخطأ NumPy؟
هذا ما أريده من لغة المصفوفات. لستُ مُهتمًا بالقواعد النحوية، ولكن سيكون من الجيد لو:
- عندما تريد أن تفعل شيئًا ما، فمن “الواضح” كيفية القيام بذلك.
- عندما تقرأ بعض التعليمات البرمجية، فمن “الواضح” ما تفعله.
ألن يكون ذلك رائعًا؟ أعتقد أن NumPy لم تحقق هذا بسبب “خطيئتها الأصلية”: فقد ألغت مفهوم الفهارس (indices) واستبدلته بآلية البث (broadcasting). لكنّ البث لا يمكنه أن يحلّ محل الفهارس.
أنا لا أحب بث NumPy
جوهر NumPy هو البث. خذ هذا الكود:
A = np.array([[1,2],[3,4],[5,6]])
B = np.array([10,20])
C = A * B
print(C)
الناتج
[[ 10 40]
[ 30 80]
[ 50 120]]
هنا، A عبارة عن مصفوفة بحجم 3×2، وB مصفوفة طولها 2. عند ضربهما معًا، يتم بث (broadcast) المصفوفة B لتأخذ شكل A، أي أن العمود الأول من A يُضرب في B[0] = 10، والعمود الثاني يُضرب في B[1] = 20.
في الحالات البسيطة، يبدو هذا جيدًا. لكنني لا أحبه. أحد الأسباب هو أنه، كما رأينا سابقًا، غالبًا ما يتعين عليك القيام بأشياء فظيعة في الأبعاد لجعلها متناسقة.
سبب آخر هو أنها غير واضحة أو مقروءة. أحيانًا تتضاعف قيمة AB عنصرًا تلو الآخر، وأحيانًا أخرى تُجري عمليات أكثر تعقيدًا. لذلك، في كل مرة ترى فيها AB، عليك تحديد أي حالة في قواعد البث يتم تفعيلها.
لكن المشكلة الحقيقية في البث تكمن في كيفية تأثيره على كل شيء آخر. سأشرح ذلك لاحقًا.
أنا لا أحب فهرسة NumPy
إليكم لغزًا. خذوا هذا الكود:
A = np.ones((10,20,30,40))
i = np.array([1,2,3])
j = np.array([[0],[1]])
B = A[:,i,j,:]
ما هو شكل B؟
اتضح أن الناتج هو 10×2×3×40. وذلك لأن الفهرسين i وj تم بثّهما (broadcast) إلى شكل 2×3، ثم يحدث شيء ما… همهمة همهمة همهمة 😊 حاول أن تقنع نفسك بأن هذا منطقي.
هل انتهيت؟ حسنًا، جرّب هذه الآن:
C = A[:,:,i,j]
D = A[:,i,:,j]
E = A[:,1:4,j,:]
ما هي الأشكال التي لديهم؟
- المصفوفة C هي بحجم 10×20×2×3. ويبدو هذا منطقيًا، نظرًا لما حدث مع المصفوفة B أعلاه
- وماذا عن D؟ حجمها هو 2×3×10×30. والآن، لسببٍ ما، يبدو أن 2 و3 تأتيان في البداية؟
- وماذا عن E؟ حسنًا، إن “الشرائح” (slices) في بايثون تستبعد نقطة النهاية،
- لذا فإن
1:4 تعادل [1, 2, 3]، وهي تعادل المتغير i، وبالتالي من المفترض أن تكون E مثل B. هاهاها، أمزح فقط! 😄 في الواقع، E هي بحجم 10×3×2×1×40.
نعم، هذا ما يحدث. جرّبه إن لم تُصدّقني! أفهم سبب قيام NumPy بهذا، فقد استوعبتُ هذا المستند المكون من 5000 كلمة والذي يشرح كيفية عمل فهرسة NumPy. لكنني أريد استعادة ذلك الوقت.
للتسلية، حاولتُ سؤال مجموعة من نماذج الذكاء الاصطناعي لمعرفة أشكال تلك المصفوفات. وهذه هي النتائج:
| AI | B | C | D | E |
|---|---|---|---|---|
| GPT 4.1 | ✔️ | ✔️ | X | ✔️ |
| Grok 3 | ✔️ | ✔️ | X | X |
| Claude 3 Opus | X | X | X | X |
| Llama 4 Maverick | ✔️ | ✔️ | X | X |
| o3 | ✔️ | ✔️ | X | ✔️ |
| Claude 3.7 | ✔️ | ✔️ | X | X |
| Gemini 2.5 Pro | ✔️ | ✔️ | ✔️ | ✔️ |
| DeepSeek R1 | ✔️ | ✔️ | ✔️ | ✔️ |
(استخدمت سلسلة أفكار DeepSeek كلمة “انتظر” 76 مرة. وقد نجحت في كل شيء في المرة الأولى، ولكن عندما حاولت مرة أخرى، نجحت بطريقة ما في الحصول على الكلمات B وC وD بشكل خاطئ، لكنها نجحت في الحصول على الكلمات E بشكل صحيح.)
هذا جنون. استخدام الميزات الأساسية لا يتطلب حل ألغاز منطقية معقدة.
قد تفكر، “حسنًا، سأقتصر على الفهرسة بطرق بسيطة”. يبدو هذا جيدًا، إلا أنك قد تحتاج أحيانًا إلى فهرسة متقدمة. وحتى لو كنت تقوم بشيء بسيط، فلا يزال عليك توخي الحذر لتجنب الأخطاء غير المتوقعة.
هذا يجعل كل شيء غير مقروء. حتى لو كنت تقرأ شيفرةً تستخدم الفهرسة بطريقة بسيطة، كيف تعرف أنها بسيطة؟ إذا رأيت A[B,C]، فقد يعني ذلك أي شيء تقريبًا. لفهمه، عليك تذكر أشكال A وB وC والعمل على جميع الحالات. وبالطبع، غالبًا ما تُنتج A وB وC شيفرةً أخرى، وهو ما يجب عليك أيضًا التفكير فيه…
أنا لا أحب دوال NumPy
لماذا انتهى الأمر بـ NumPy إلى دالة np.linalg.solve(A,B) المُربكة للغاية؟ أعتقد أنهم جعلوها تعمل أولًا عندما تكون A مصفوفة ثنائية الأبعاد وb مصفوفة أحادية أو ثنائية الأبعاد، تمامًا مثل الصيغة الرياضية لـ A⁻¹b أو A⁻¹B.
حتى الآن، الأمور على ما يرام. ولكن ربما جاء أحدهم بمصفوفة ثلاثية الأبعاد. إذا كان بإمكانك استخدام الحلقات، فسيكون الحل هو “استخدام الدالة القديمة مع الحلقات”. لكن لا يمكنك استخدام الحلقات. لذا، كانت هناك ثلاثة خيارات أساسية:
- يمكن إضافة بعض حجج المحاور الإضافية، ليتمكن المستخدم من تحديد الأبعاد التي سيتم العمل عليها. ربما يمكنك كتابة solve(A,B,axes=[[1,2],1]).
- يمكن إنشاء دوال مختلفة بأسماء مختلفة لحالات مختلفة. ربما تقوم solve_matrix_vector بمهمة، بينما تقوم solve_tensor_matrix بمهمة أخرى.
- يمكنهم إضافة اتفاقية: سيحاول خيار عشوائي لكيفية الحل داخليًا محاذاة الأبعاد. عندها، تقع على عاتق المستخدم مسؤولية فهم هذه الاتفاقيات والالتزام بها.
جميع هذه الخيارات سيئة، لأن أياً منها لا يتعامل مع وجود عدد تركيبي من الحالات المختلفة. اختارت NumPy: جميعها. بعض الدوال تحتوي على وسيطات محاور. بعضها له إصدارات مختلفة بأسماء مختلفة. بعضها له اصطلاحات. بعضها له اصطلاحات ووسيطات محاور. وبعضها لا يوفر أي إصدار متجهي على الإطلاق.
لكن أكبر عيب في NumPy هو هذا: لنفترض أنك أنشأت دالة تحل مشكلة ما باستخدام مصفوفات ذات شكل معين. الآن، كيف يمكنك تطبيقها على أبعاد معينة لمصفوفات أكبر؟ الإجابة هي: إعادة كتابة دالتك من الصفر بطريقة أكثر تعقيدًا. المبدأ الأساسي للبرمجة هو التجريد – حل المشكلات البسيطة ثم استخدام الحلول كوحدات بناء لمشكلات أكثر تعقيدًا. لا يتيح لك NumPy القيام بذلك.
يرجى الانتباه
مثال أخير لأوضح لكم ما أتحدث عنه. كلما اشتكيتُ من NumPy، يرغب الناس دائمًا في رؤية مثال يتضمن خاصية الاهتمام الذاتي، وهي الحيلة الأساسية وراء نماذج اللغات الحديثة. حسنًا. إليك تطبيق، أقترح بتواضع أنه أفضل من جميع الإصدارات الـ 227 التي وجدتها عند البحث عن “numpy” ذات الاهتمام الذاتي:
# self attention by your friend dynomight
input_dim = 4
seq_len = 4
d_k = 5
d_v = input_dim
X = np.random.randn(seq_len, input_dim)
W_q = np.random.randn(input_dim, d_k)
W_k = np.random.randn(input_dim, d_k)
W_v = np.random.randn(input_dim, d_v)
def softmax(x, axis):
e_x = np.exp(x - np.max(x, axis=axis, keepdims=True))
return e_x / np.sum(e_x, axis=axis, keepdims=True)
def attention(X, W_q, W_k, W_v):
d_k = W_k.shape[1]
Q = X @ W_q
K = X @ W_k
V = X @ W_v
scores = Q @ K.T / np.sqrt(d_k)
attention_weights = softmax(scores, axis=-1)
return attention_weights @ V
لا بأس. بعض تفاصيل المحور غامضة بعض الشيء، لكن لا بأس.
لكن ما تحتاجه نماذج اللغة حقًا هو التركيز متعدد الرؤوس، حيث يتم التركيز عدة مرات بالتوازي ثم دمج النتائج. كيف نفعل ذلك؟
أولاً، لنتخيل أننا نعيش في عالمٍ سليمٍ يُسمح لنا فيه باستخدام التجريد. حينها يُمكننا ببساطة استدعاء الدالة السابقة في حلقةٍ برمجية:
# multi-head self attention by your friend dynomight
# if only we could use loops
n_head = 2
X = np.random.randn(seq_len, input_dim)
W_q = np.random.randn(n_head, input_dim, d_k)
W_k = np.random.randn(n_head, input_dim, d_k)
W_v = np.random.randn(n_head, input_dim, d_v)
W_o = np.random.randn(n_head, d_v, input_dim // n_head)
def multi_head_attention(X, W_q, W_k, W_v, W_o):
projected = []
for n in range(n_head):
output = attention(X,
W_q[n,:,:],
W_k[n,:,:],
W_v[n,:,:])
my_proj = output @ W_o[n,:,:]
projected.append(my_proj)
projected = np.array(projected)
output = []
for i in range(seq_len):
my_output = np.ravel(projected[:,i,:])
output.append(my_output)
return np.array(output)
يبدو غبيًا، أليس كذلك؟ نعم، شكرًا لك! الذكاء سيء.
لكننا لا نعيش في عالمٍ سليم. لذا، عليك القيام بما يلي:
# multi-head self attention by your friend dynomight
# all vectorized and bewildering
def multi_head_attention(X, W_q, W_k, W_v, W_o):
d_k = W_k.shape[-1]
Q = np.einsum('si,hij->hsj', X, W_q)
K = np.einsum('si,hik->hsk', X, W_k)
V = np.einsum('si,hiv->hsv', X, W_v)
scores = Q @ K.transpose(0, 2, 1) / np.sqrt(d_k)
weights = softmax(scores, axis=-1)
output = weights @ V
projected = np.einsum('hsv,hvd->hsd', output, W_o)
return projected.transpose(1, 0, 2).reshape(
seq_len, input_dim)
فماذا إذن؟
للتوضيح، كل ما أقصده هو أن NumPy هي “أسوأ لغة برمجة مصفوفات بين جميع لغات البرمجة الأخرى”. ما فائدة الشكوى إن لم يكن لديّ اقتراح أفضل؟
حسنًا، في الواقع، لديّ اقتراح أفضل. لقد صممتُ نموذجًا أوليًا لـ NumPy “أفضل”، أعتقد أنه يحتفظ بكامل قوته مع إزالة جميع الجوانب غير الواضحة. ظننتُ أن هذا سيكون مجرد مقدمة تحفيزية قصيرة، ولكن بعد أن بدأتُ الكتابة، سيطر عليّ شعورٌ بالذنب، وها نحن ذا بعد 3000 كلمة.
من الحكمة أيضًا الحفاظ على مسافة بين الجدل الحادّ ومقترحات لغة المصفوفة البنّاءة. لذا سأتناول موضوعي الجديد في المرة القادمة.
اكتشاف المزيد من بايثون العربي
اشترك للحصول على أحدث التدوينات المرسلة إلى بريدك الإلكتروني.