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

الأسبوع 5: تجميع أداة سطر الأوامر، ضبط درجة الحرارة، والشعور بجدار السرعة

⏱️ هذا هو الأسبوع الذي يتوقف فيه قيد "بايثون خالصة، بلا numpy" عن كونه إزعاجًا ويصبح هو الهدف.

🎯 أهداف التعلّم

بنهاية هذا الأسبوع ستكون قادرًا على:

  • تجميع كل قطعة من الأسابيع 1-4 في مولّد نصوص واحد كامل يعمل عبر سطر الأوامر.
  • إضافة معامل درجة الحرارة (temperature) يتحكم في مدى عشوائية أو قابلية توقّع النص المُولَّد.
  • توقيت خط أنابيبك ببايثون الخالصة على مجموعة نصوص أكبر وشرح، من قياس مباشر، لماذا يحفّز هذا القسم الثاني.
  • التفكير بشكل غير رسمي في كيفية تضخّم وقت تشغيل خط أنابيبك مع حجم المدخلات.

الدرس

تجميع خط الأنابيب

تتركّب كل دالة من الأسابيع 1-4 في خط أنابيب واحد — تحميل، تجزئة، عدّ، تحويل لاحتمالات، توليد:

import csv
import random

def load_corpus(path):
with open(path, newline="") as f:
return [row["sentence"] for row in csv.DictReader(f)]

def tokenize(sentence):
return sentence.lower().split()

def bigrams(tokens):
return [(tokens[i], tokens[i + 1]) for i in range(len(tokens) - 1)]

def build_bigram_probabilities(sentences):
counts_table = {}
for sentence in sentences:
for first, second in bigrams(tokenize(sentence)):
counts_table.setdefault(first, {})
counts_table[first][second] = counts_table[first].get(second, 0) + 1
probs_table = {}
for word, next_counts in counts_table.items():
total = sum(next_counts.values())
probs_table[word] = {w: c / total for w, c in next_counts.items()}
return probs_table

def generate_text(probs_table, start_word, max_words=10):
words, current = [start_word], start_word
for _ in range(max_words - 1):
if current not in probs_table:
break
next_words = list(probs_table[current].keys())
weights = list(probs_table[current].values())
current = random.choices(next_words, weights=weights, k=1)[0]
words.append(current)
return " ".join(words)

if __name__ == "__main__":
corpus = load_corpus("slm-corpus.csv")
probs_table = build_bigram_probabilities(corpus)
print(generate_text(probs_table, "the", max_words=8))

dict.setdefault(key, {}) في build_bigram_probabilities اختصار صغير لنمط if key not in table: table[key] = {} من الأسبوع 3 — تفعل نفس الشيء في سطر واحد. if __name__ == "__main__": هي الطريقة القياسية لتحديد "شغّل هذا عندما يُنفَّذ الملف مباشرة" — سترى هذا العُرف باستمرار بمجرد تثبيت بايثون محليًا في المشروع الختامي.

درجة الحرارة (Temperature): ضبط العشوائية

درجة الحرارة هي مقبض واحد يمدّ أو يُسطّح توزيعًا احتماليًا قبل المعاينة منه، دون تغيير النتائج الممكنة — فقط مدى قرب سلوك المعاينة من المنتظم (المُسطَّح) أو من الجشع (المُذرَّى):

PT(w)=P(w)1/TwP(w)1/TP_T(w) = \frac{P(w)^{1/T}}{\sum_{w'} P(w')^{1/T}}
  • T=1T = 1: بلا تغيير — معاينة عادية من التوزيع الأصلي.
  • T<1T < 1 (مثلًا 0.50.5): يُحدّد التوزيع — الكلمات عالية الاحتمال تصبح أكثر ترجيحًا، دافعةً التوليد نحو السلوك الجشع من الأسبوع الماضي.
  • T>1T > 1 (مثلًا 2.02.0): يُسطّح التوزيع — الكلمات منخفضة الاحتمال تصبح أكثر ترجيحًا نسبيًا، مُنتجة نصًا أكثر مفاجأة وفوضوية.

لماذا الرفع للأس، بدلًا من مجرد تحجيم الاحتمالات مباشرة؟ لأن رفع كل P(w)P(w) لقوة 1T\frac{1}{T} يؤثر على الاحتمالات الكبيرة والصغيرة بشكل غير متساوٍ بالضبط في الاتجاه الذي تريده: لـ T<1T < 1 (بحيث 1T>1\frac{1}{T} > 1)، احتمال مثل 0.50.5 مرفوع لقوة أكبر من 1 يتقلّص، لكن احتمالًا أصغر مثل 0.050.05 يتقلّص بنسبة أكبر بكثير — موسّعًا الفجوة بين الكلمات المرجّحة وغير المرجّحة. خطوة إعادة التطبيع (القسمة على المجموع الجديد) هي ما يُحوّل هذه المجموعة المُعاد تشكيلها من الأرقام مرة أخرى إلى توزيع احتمالي صحيح يجمع إلى 1، نفس خاصية "المجموع يساوي 1" من الأسبوع 2.

def apply_temperature(probs, temperature):
adjusted = {w: p ** (1 / temperature) for w, p in probs.items()}
total = sum(adjusted.values())
return {w: v / total for w, v in adjusted.items()}

def sample_next(word, probs_table, temperature=1.0):
if word not in probs_table:
return None
probs = probs_table[word]
if temperature != 1.0:
probs = apply_temperature(probs, temperature)
return random.choices(list(probs.keys()), weights=list(probs.values()), k=1)[0]

هذا نفس مفهوم "درجة الحرارة" بالضبط الذي سترى إشارات إليه في واجهات برمجة النماذج اللغوية الكبيرة الحقيقية — أنت تُنفّذ نسخة مبسّطة من مقبض تحكم حقيقي ومستخدَم على نطاق واسع.

توقيته: الشعور بجدار السرعة

إليك التمرين الذي كان هذا المسار بأكمله يبني نحوه. وقّت خط أنابيبك على مجموعة نصوص أكبر — لنقل، نسخة من مجموعة النصوص مُكرَّرة 200 مرة لمحاكاة بيانات أكثر (corpus * 200، باستخدام ضرب القوائم) — باستخدام وحدة time المدمجة في بايثون:

import time

big_corpus = corpus * 200 # محاكاة مجموعة بيانات أكبر بكثير
start = time.time()
big_probs_table = build_bigram_probabilities(big_corpus)
elapsed = time.time() - start
print(f"Built bigram table for {len(big_corpus)} sentences in {elapsed:.3f} seconds")

شغّل هذا في الملعب البرمجي ولاحظ رقمك الفعلي. على الأرجح لا يزال سريعًا بهذا الحجم — لكن راقب ما يحدث بينما تضاعف حجم مجموعة النصوص مجددًا (* 1000، * 5000): يقوم أسلوب الحلقة المتداخلة، قاموس-من-قواميس من الأسبوع 3 بعمل حقيقي لـكل ظهور كلمة منفرد، بلا اختصارات. لا توجد طريقة "لتوجيه" حلقة for في بايثون الخالصة بالطريقة التي تستطيعها المكتبات الرقمية المتخصصة.

ما يُظهره التوقيت فعليًا

كل جملة إضافية في مجموعة النصوص تضيف كمية عمل ثابتة تقريبًا: جزّئها، كوّن ثنائيات غرامها، حدّث حفنة من مُدخلات القاموس. مضاعفة عدد الجمل تُضاعف تقريبًا عدد ظهورات الكلمات المُعالَجة، مما يُضاعف تقريبًا وقت التشغيل — علاقة خطية بين حجم المدخل والوقت، بشكل غير رسمي O(n)O(n) حيث nn هو إجمالي عدد الكلمات. هذا في الحقيقة سلوك تضخّم جيد، وليس سيئًا — المشكلة الحقيقية ليست أن هذه الخوارزمية تتضخّم بشكل سيء نظريًا، بل أن كل خطوة منفردة (بحث في قاموس، استدعاء دالة، تكرار حلقة) تكلّف أكثر في بايثون الخالصة المُفسَّرة مما تكلّفه الخطوة المكافئة في الكود المُصرَّف والموجَّه أسفل numpy/pandas. أرقام توضيحية (ستختلف أرقامك حسب الجهاز، لكن الشكل يجب أن يبدو مشابهًا):

تكرارات مجموعة النصوصتقريبًا عدد الجملتقريبًا الوقت
×120بضع ميلي ثوانٍ
×2004,000لا يزال أقل بكثير من ثانية
×5,000100,000ملحوظ بوضوح الآن

تلك الفجوة — بين ما تستطيع بايثون الخالصة فعله بارتياح وما تحتاجه مسائل النصوص/البيانات ذات الحجم الحقيقي — هي بالضبط ما وُجد القسم الثاني ليسدّه. لا تجعل pandas وnumpy الكود أقصر فقط؛ بل تجعلان عمليات كهذه تعمل أسرع بمراتب من الحجم، بدفع التكرار الفعلي أسفل إلى كود مُحسَّن ومُصرَّف بدلًا من حلقة مُفسِّر بايثون نفسه.

⚠️ أخطاء شائعة

  • توقيت شيء يتضمن تكاليف إعداد لمرة واحدة. إن تضمّن كود التوقيت لديك عن طريق الخطأ وقت قراءة ملف load_corpus جنبًا إلى جنب مع الحساب الفعلي لـbuild_bigram_probabilities، فأنت تقيس شيئين مختلفين دفعة واحدة — وقّت فقط الخطوة المحددة التي تهمك.
  • مقارنة توقيتات عبر أحمال جهاز مختلفة جدًا. تشغيل برامج ثقيلة أخرى في نفس الوقت يمكن أن يُشوّه قياس التوقيت؛ إن بدت نتيجة مفاجئة، أعد تشغيلها بضع مرات.
  • افتراض أن التضخم الخطي يعني "لا مشكلة عند أي حجم". O(n)O(n) جيد، لكن عاملًا ثابتًا كبيرًا بما يكفي لكل عملية لا يزال يتراكم — نقطة هذا الأسبوع بأكملها هي أن العامل الثابت لكل عملية هو ما تخسره بايثون الخالصة مقارنة بالكود المُوجَّه، حتى بنفس التضخم على المستوى الكبير.

🧩 تحديات

وسّع كتلة if __name__ == "__main__": بحيث تطلب من المستخدم (عبر input()) كلمة بداية وعدد كلمات أقصى، بدلًا من تثبيتهما في الكود.

ولّد نصًا عند temperature=0.5 وtemperature=2.0 بنفس كلمة البداية، عدة مرات لكل منهما. صف الفرق النوعي الذي تلاحظه.

وقّت build_bigram_probabilities عند أحجام مجموعة نصوص متزايدة (corpus * 1، * 50، * 200، * 1000) واطبع جدولًا صغيرًا للحجم مقابل الوقت المنقضي. هل يبدو النمو خطيًا، أم أسوأ من خطي؟

لماذا تعالج sample_next حالة temperature != 1.0 بشكل خاص بدلًا من استدعاء apply_temperature دائمًا؟ ماذا تحسب apply_temperature فعليًا عندما تكون temperature == 1.0؟

باستخدام توقيتات التحدي 3، احسب نسبة الوقت عند 1000x إلى الوقت عند 50x. قارن تلك النسبة بنسبة أحجام مجموعة النصوص (1000/50 = 20). هل تتطابق نسبة الوقت تقريبًا، مؤكدة التضخم الخطي؟

اكتب دالة generate_batch(probs_table, start_word, temperatures, max_words=10) تُولّد جملة واحدة لكل قيمة درجة حرارة في قائمة مُعطاة (مثل [0.5, 1.0, 1.5, 2.0]) وتُعيدها كلها معًا، بحيث يمكنك مقارنة أثر درجة الحرارة جنبًا إلى جنب في استدعاء واحد.

🤔 أسئلة سقراطية

  • قِستَ للتو شخصيًا أداء بايثون الخالصة على مهمة معالجة نصوص. هل تعتقد أن التباطؤ الذي رأيته يتعلق أساسًا بحلقة for في بايثون، أم عمليات القاموس، أم شيء آخر؟ ماذا ستحتاج لاختباره لعزل السبب؟
  • تُعيد درجة الحرارة تشكيل توزيع احتمالي لكن لا تُغيّر أبدًا أي النتائج ممكنة (كلمة باحتمال 0 تبقى باحتمال 0 بغض النظر عن درجة الحرارة). لماذا هذه خاصية مهمة يجب أن يمتلكها "مقبض العشوائية"؟
  • يبدأ القسم الثاني بعرض يُظهر نفس نوع مهمة عدّ الكلمات تعمل أسرع بشكل كبير بـnumpy/pandas من نسخة بايثون الخالصة التي بنيتها للتو. بناءً على ما تعرفه الآن عن كيفية عمل build_bigram_probabilities فعليًا (حلقات متداخلة، بحث في قواميس)، ماذا تتوقع أن تحتاج نسخة موجَّهة لفعله بشكل مختلف لتكون أسرع؟
  • كان تضخّم هذا الأسبوع خطيًا (O(n)O(n)) في إجمالي عدد الكلمات — ليس أسوأ سلوك تضخّم ممكن. هل يمكنك التفكير في خطوة في خط الأنابيب هذا بأكمله على مدى 5 أسابيع كان يمكن أن تكون أسوأ بكثير (مثلًا تربيعية) لو كُتبت بطريقة أخرى أكثر سذاجة؟

✅ اختبار الأسبوع

✅ اختبار الأسبوع

1. درجة حرارة أقل من 1.0 (مثلًا 0.5) تجعل النص المُولَّد:
2. ماذا تفعل dict.setdefault(key, {}) إن كان المفتاح موجودًا بالفعل؟
3. الهدف من تمرين التوقيت هذا الأسبوع على مجموعة نصوص أكبر هو إظهار:
4. تُستخدم if __name__ == "__main__": لـ:
5. مضاعفة حجم مجموعة النصوص تقريبًا ورؤية وقت البناء يتضاعف تقريبًا هو مثال على:

🎁 إضافي: تغليف النموذج كـ class

يُتاح بعد اجتيازك اختبار هذا الأسبوع.


🎉 لقد أنهيت بايثون 101. بنيت نموذجًا لغويًا عاملًا (وإن كان بسيطًا) باستخدام مكتبة بايثون القياسية فقط — وقِستَ شخصيًا أين يبدأ بالإجهاد. هذا بالضبط الباب الذي يفتحه القسم الثاني، Pandas وتحليل البيانات، تاليًا.