# Install the course package
!pip install -q git+https://github.com/BaytAlhikmah/hands-on-llms-for-swes.git#subdirectory=pkg
print('✓ Setup complete!')لتشغيل النسخة العربية، نزّل الملف وافتحه في Jupyter أو ارفعه إلى Google Colab. ويمكنك أيضًا فتح الدفتر الإنجليزي الأصلي في Colab.
التعليمات هنا بالعربية. أبقينا البرامج وتعليقاتها ونصوص مخرجاتها والبيانات كما هي لتظل التجارب مطابقة للنسخة الأصلية.
الفصل 3 — من النظرية إلى التطبيق: التنبؤ ببيانات فعلية
تعلّمت في الفصل الثاني الإنتروبيا والإنتروبيا المتقاطعة، وهما أساس قياس جودة التنبؤ. لنطبّق الآن هذه المفاهيم على مسائل تنبؤ فعلية.
نبدأ بمسألة صغيرة: حجر وورقة ومقص، بمصفوفة \(3\times3\). ثم ننتقل إلى المقاطع ثنائية الحروف، بمصفوفة \(28\times28\). سنبني مصفوفات الانتقال، ونقيس إنتروبيتها، ونستثمر أنماطها، ونكتشف عائق \(V^2\) الذي يدفعنا إلى البحث عن بدائل مثل الشبكات العصبية.
اقرأ هذا الدفتر بالتوازي مع الدرس العربي.
الجزء صفر: الإعداد
التمرين 0: ثبّت حزمة الدورة باستخدام خلية الإعداد التالية.
📺 نوصي بشدة: يستلهم هذا الفصل كثيرًا من محاضرة أندريه كارباثي «مقدّمة تفصيلية لنمذجة اللغة: بناء makemore». شاهدها بالتوازي مع الدفتر، خصوصًا قسم النموذج الثنائي (أول 45 دقيقة تقريبًا). مصدر بيانات الأسماء والأسلوب التعليمي هو هذا العمل. المحاضرة بالإنجليزية.
الجزء الأول: حجر وورقة ومقص (مصفوفة انتقال 3 × 3)
يظن صديقك أنه يلعب حجرًا وورقةً ومقصًا عشوائيًا. سجّلت له 1,000 حركة. لنرَ إن كان ذلك صحيحًا، وإن كان بإمكاننا استثمار أنماط لعبه.
التمرين 1: تحميل الحركات
حمّل الحركات المسجّلة وافحص تكراراتها الإجمالية.
import csv
import math
import random
import urllib.request
import matplotlib.pyplot as plt
from alhikmah_llms import plot_matrix, TransitionLearner
# Load the recorded moves from GitHub
url = 'https://raw.githubusercontent.com/BaytAlhikmah/hands-on-llms-for-swes/main/chapters/3/rps_opponent_moves.csv'
response = urllib.request.urlopen(url)
lines = response.read().decode('utf-8').strip().split('\n')
moves: list[str] = []
reader = csv.DictReader(lines)
for row in reader:
moves.append(row['move'])
short = {'Rock': 'R', 'Paper': 'P', 'Scissors': 'S'}
moves_short = [short[m] for m in moves]
print(f'Total moves recorded: {len(moves)}')
print(f'First 20: {" ".join(moves_short[:20])}')
print()
# Overall frequencies
for m in ['Rock', 'Paper', 'Scissors']:
count = moves.count(m)
print(f' {m:<10s} {count:>4d} ({count/len(moves)*100:.1f}%)')
print()
print('Looks random...')التمرين 2: عدّ الانتقالات وحساب الإنتروبيا
التكرارات الإجمالية متقاربة؛ تظهر كل حركة في نحو 33% من الحالات. لكن ذلك لا يعني أن التسلسل عشوائي.
توقّع أولًا: إذا لعب صديقك حجرًا للتو، فهل تتساوى احتمالات أن يلعب حجرًا أو ورقة أو مقصًا بعده؟ أم أن هناك نمطًا؟
# Count transitions: how often does move Y follow move X?
rps_labels = ['Rock', 'Paper', 'Scissors']
rps_stoi = {m: i for i, m in enumerate(rps_labels)}
rps_counts: list[list[int]] = [[0] * 3 for _ in range(3)]
for i in range(len(moves) - 1):
prev = rps_stoi[moves[i]]
curr = rps_stoi[moves[i + 1]]
rps_counts[prev][curr] += 1
# Display as bar chart
for i, prev_move in enumerate(rps_labels):
row_total = sum(rps_counts[i])
print(f'After {prev_move}:')
for j, next_move in enumerate(rps_labels):
pct = rps_counts[i][j] / row_total * 100
bar = chr(9608) * int(pct / 2)
print(f' -> {next_move:<10s} {rps_counts[i][j]:>3d} ({pct:5.1f}%) {bar}')
print()
plot_matrix(rps_counts, rps_labels, rps_labels,
title='RPS Transition Counts',
xlabel='Next move', ylabel='Previous move',
figsize=(4, 4))
print('They are NOT random.')حساب إنتروبيا الصف
لنحسب إنتروبيا كل صف باستخدام صيغة الفصل الثاني، ثم نقارنها بإنتروبيا التوزيع المنتظم.
def row_entropy(probs: list[float]) -> float:
"""Calculate entropy of a probability distribution."""
return -sum(p * math.log2(p) for p in probs if p > 0)
# Normalize counts to get probabilities for each row
print("Entropy by previous move:\n")
for i, prev_move in enumerate(rps_labels):
row_total = sum(rps_counts[i])
probs = [rps_counts[i][j] / row_total for j in range(3)]
h = row_entropy(probs)
print(f" After {prev_move:<10s} {h:.3f} bits")
# Compare to uniform (truly random)
uniform_entropy = row_entropy([1/3, 1/3, 1/3])
print(f"\n Uniform (random): {uniform_entropy:.3f} bits")
print(f"\nLower entropy = more predictable = easier to exploit!")
print(f"The entropy gap represents information you can use to win.")تعمّق اختياري
لفهم الصلة بالإنتروبيا بمزيد من التفصيل، راجع الدفتر الإضافي entropy_from_counts.ipynb (بالإنجليزية).
التمرين 3: استثمار الأنماط
تكشف مصفوفة الانتقال عادات واضحة. إذا توقّعنا الحركة التالية، نلعب الحركة التي تهزمها:
- نتوقّع حجرًا ← نلعب ورقة.
- نتوقّع ورقة ← نلعب مقصًا.
- نتوقّع مقصًا ← نلعب حجرًا.
لكن أولًا، لنرَ ما يحدث إذا اخترنا حركتنا عشوائيًا.
beats = {'Rock': 'Paper', 'Paper': 'Scissors', 'Scissors': 'Rock'}
def play_rps(moves: list[str], strategy) -> tuple[int, int, int]:
"""Play RPS using a strategy function. Returns (wins, losses, draws)."""
wins = losses = draws = 0
for i in range(1, len(moves)):
their_move = moves[i]
our_move = strategy(moves, i)
if our_move == their_move:
draws += 1
elif beats[their_move] == our_move:
wins += 1
else:
losses += 1
return wins, losses, draws
def print_results(label: str, wins: int, losses: int, draws: int) -> None:
total = wins + losses + draws
print(f'{label} over {total} games:')
print(f' Wins: {wins:>4d} ({wins/total*100:.1f}%)')
print(f' Losses: {losses:>4d} ({losses/total*100:.1f}%)')
print(f' Draws: {draws:>4d} ({draws/total*100:.1f}%)')
# Random baseline
random.seed(42)
def random_strategy(moves: list[str], i: int) -> str:
return random.choice(rps_labels)
w, l, d = play_rps(moves, random_strategy)
print_results('Random guessing', w, l, d)
print()
print('No edge. We need a better strategy.')طريقة العدّ
لدينا أعداد الانتقالات بالفعل. اقسم كل صف على مجموعه للحصول على الاحتمالات، ثم توقّع الحركة التالية الأرجح والعب ما يهزمها.
نستخدم هنا سجل الحركات نفسه لاستخراج الأنماط وتقييمها. لتقدير الأداء على حركات جديدة، نحتاج إلى سجل اختبار مستقل.
# Normalize counts to probabilities
P_rps_counted: list[list[float]] = [[0.0] * 3 for _ in range(3)]
for i in range(3):
row_total = sum(rps_counts[i])
for j in range(3):
P_rps_counted[i][j] = rps_counts[i][j] / row_total
plot_matrix(P_rps_counted, rps_labels, rps_labels,
title='RPS Transition Probabilities (counted)',
xlabel='Next move', ylabel='Previous move',
cmap='Purples', figsize=(4, 4))
def counter_strategy(P: list[list[float]]) -> callable:
"""Return a strategy that predicts from P and plays the counter."""
def strategy(moves: list[str], i: int) -> str:
prev = rps_stoi[moves[i - 1]]
probs = P[prev]
predicted = rps_labels[probs.index(max(probs))]
return beats[predicted]
return strategy
w, l, d = play_rps(moves, counter_strategy(P_rps_counted))
print_results('Counting model', w, l, d)
print(f'\nWin rate jumped from ~33% to {w/(w+l+d)*100:.1f}%.')التمرين 4: تعلّم الاحتمالات تلقائيًا
تنجح طريقة العدّ في هذه المسألة الصغيرة. لكن ماذا لو بدأنا بمصفوفة احتمالات أولية وحسّنّاها تدريجيًا بالنظر إلى البيانات؟
سنستخدم TransitionLearner، وهي أداة مساعدة صغيرة:
- تبدأ باحتمالات منتظمة؛ فلا تفضّل أي نتيجة.
- تفحص الانتقالات وتقيس خطأ تنبؤاتها.
- تعدّل الأعداد لتقليل الخطأ قليلًا.
- تكرّر العملية.
تعامل معها الآن كصندوق مغلق. سنشرح آليتها لاحقًا.
rps_model = TransitionLearner(n=3)
print('Initial probabilities (uniform — the model knows nothing):')
plot_matrix(rps_model.probabilities(), rps_labels, rps_labels,
title='RPS Before Training',
xlabel='Next move', ylabel='Previous move',
cmap='Purples', figsize=(4, 4))# Prepare training data
rps_xs = [rps_stoi[moves[i]] for i in range(len(moves) - 1)]
rps_ys = [rps_stoi[moves[i + 1]] for i in range(len(moves) - 1)]
print(f'Training on {len(rps_xs)} transitions...\n')
rps_model.train(rps_xs, rps_ys, steps=200, print_every=50)
print('\nLearned probabilities:')
plot_matrix(rps_model.probabilities(), rps_labels, rps_labels,
title='RPS After Training (learned)',
xlabel='Next move', ylabel='Previous move',
cmap='Purples', figsize=(4, 4))# Play with the learned model
P_rps_learned = rps_model.probabilities()
w, l, d = play_rps(moves, counter_strategy(P_rps_learned))
print_results('Learned model', w, l, d)
# Compare the two matrices
max_diff = 0.0
for i in range(3):
for j in range(3):
d_val = abs(P_rps_counted[i][j] - P_rps_learned[i][j])
if d_val > max_diff:
max_diff = d_val
print(f'\nMax difference between counted and learned: {max_diff:.4f}')
print('Same answer, different path.')نتيجة متقاربة بطريق مختلف
ينجح العدّ لأن المسألة صغيرة: 3 حركات و9 انتقالات ممكنة. لدينا مشاهدات كثيرة نسبيًا لتقدير احتمالاتها من التكرارات.
لكن ماذا لو كبرت المفردات؟ وماذا لو لم نرَ كل انتقال ممكن؟ نحتاج حينها إلى أساليب تعمّم بين الأنماط. مجرد تدريب جدول احتمالات مستقل لا يوفّر هذه القدرة وحده؛ سنعود إلى هذا الفرق في الدرس والفصول اللاحقة.
الجزء الثاني: المقاطع ثنائية الحروف (مصفوفة انتقال 28 × 28)
الفكرة نفسها، لكن المسألة أكبر. بدل 3 حركات، لدينا 28 رمزًا: الحروف من a إلى z، ورمزا البداية والنهاية. وبدل التنبؤ بالحركة التالية في اللعبة، نتنبّأ بالحرف التالي في اسم.
تكبر المصفوفة من \(3\times3\) إلى \(28\times28\)، لكن الطريقة لا تتغيّر.
التمرين 5: تحميل بيانات الأسماء
ما المقطع الثنائي؟ هو زوج من العناصر المتتابعة، والعناصر هنا حروف ورموز حدود.
# Load names dataset
url = 'https://raw.githubusercontent.com/karpathy/makemore/master/names.txt'
response = urllib.request.urlopen(url)
names = [n.strip().lower() for n in response.read().decode('utf-8').strip().split('\n') if n.strip()]
# Train/test split (90/10)
random.seed(42)
shuffled = names.copy()
random.shuffle(shuffled)
split = int(0.9 * len(shuffled))
train_names = shuffled[:split]
test_names = shuffled[split:]
print(f'Total names: {len(names):,}')
print(f' Train: {len(train_names):,}')
print(f' Test: {len(test_names):,}')
# Build vocabulary from ALL names (so test names don't have unknown characters)
chars = sorted(set(''.join(names)))
stoi: dict[str, int] = {'<S>': 0, '<E>': 1}
for i, c in enumerate(chars):
stoi[c] = i + 2
itos: dict[int, str] = {i: c for c, i in stoi.items()}
V = len(stoi)
print(f'\nVocab size (V): {V}')
print(f'Tokens: {" ".join(itos[i] for i in range(V))}')ما المقطع الثنائي؟
المقطع الثنائي هو زوج من العناصر المتتابعة. ينتج الاسم emma الأزواج التالية:
<S> -> e
e -> m
m -> m
m -> a
a -> <E>
يشير الانتقال الأول إلى بداية الاسم بالحرف e، والأخير إلى انتهائه بعد a.
يحدّد الرمزان <S> و<E> بداية الاسم ونهايته. ومن دونهما، لا يتعلّم النموذج بهذه الطريقة الحروف التي تميل إلى بدء الأسماء أو إنهائها.
for name in ['emma', 'olivia', 'ava']:
chs = ['<S>'] + list(name) + ['<E>']
print(f"'{name}' -> {len(chs)-1} bigrams:")
for i in range(len(chs) - 1):
print(f" '{chs[i]}' -> '{chs[i+1]}'")
print()التمرين 6: بناء مصفوفة أعداد 28 × 28
كما في اللعبة، ابنِ مصفوفة بحجم \(V\times V\)، بحيث تحتوي الخانة [i][j] على عدد المرات التي جاء فيها الحرف j بعد الحرف i.
استخدم أسماء التدريب فقط لحساب هذه الأعداد، ثم حوّل الصفوف غير الصفرية إلى توزيعات احتمالية.
# Build the count matrix (from training data only)
bg_counts: list[list[int]] = [[0] * V for _ in range(V)]
for name in train_names:
chs = ['<S>'] + list(name) + ['<E>']
for i in range(len(chs) - 1):
ix1 = stoi[chs[i]]
ix2 = stoi[chs[i + 1]]
bg_counts[ix1][ix2] += 1
total_bigrams = sum(sum(row) for row in bg_counts)
nonzero = sum(1 for i in range(V) for j in range(V) if bg_counts[i][j] > 0)
print(f'Total bigram occurrences (train): {total_bigrams:,}')
print(f'Non-zero entries: {nonzero} / {V*V} ({nonzero/(V*V)*100:.1f}%)')
# What follows 'm'?
m_id = stoi['m']
m_row = [(itos[j], bg_counts[m_id][j]) for j in range(V) if bg_counts[m_id][j] > 0]
m_row.sort(key=lambda x: -x[1])
print(f'\nAfter \'m\':')
for ch, cnt in m_row[:10]:
print(f' m -> {repr(ch):<5s} {cnt:>5,d} times')# Normalize to probabilities
P_bg_counted: list[list[float]] = [[0.0] * V for _ in range(V)]
for i in range(V):
row_total = sum(bg_counts[i])
if row_total > 0:
for j in range(V):
P_bg_counted[i][j] = bg_counts[i][j] / row_total
# Show P(next | 'm') top 10
print("P(next | 'm') -- top 10:")
m_probs = [(itos[j], P_bg_counted[m_id][j]) for j in range(V) if P_bg_counted[m_id][j] > 0]
m_probs.sort(key=lambda x: -x[1])
for ch, p in m_probs[:10]:
print(f" P('{ch}' | 'm') = {p:.4f}")
print(f'\nRow sum: {sum(P_bg_counted[m_id]):.6f} (should be 1.0)')bg_labels = [itos[i] for i in range(V)]
plot_matrix(bg_counts, bg_labels, bg_labels,
title='Bigram Counts',
xlabel='Next character', ylabel='Current character')
plot_matrix(P_bg_counted, bg_labels, bg_labels,
title='Bigram Probabilities — P(next | current)',
xlabel='Next character', ylabel='Current character',
cmap='Purples')التمرين 7: توليد أسماء
لتوليد اسم:
- ابدأ بالرمز
<S>. - خذ عيّنة للرمز التالي من
P(next | current)، أي توزيع الرمز التالي عند معرفة الرمز الحالي. - كرّر حتى تحصل على
<E>؛ وتضع الدالة أيضًا حدًا أقصى للطول.
توقّع أولًا: هل ستبدو هذه الأسماء حقيقية؟
def generate_name(P: list[list[float]], stoi: dict[str, int],
itos: dict[int, str], max_len: int = 20) -> str:
"""Sample a name from a bigram probability matrix."""
current = stoi['<S>']
end = stoi['<E>']
result: list[str] = []
for _ in range(max_len):
row = P[current]
r = random.random()
cumulative = 0.0
for j in range(len(row)):
cumulative += row[j]
if cumulative >= r:
if j == end:
return ''.join(result)
result.append(itos[j])
current = j
break
return ''.join(result)
random.seed(42)
print('Generated names (counting bigram):')
for i in range(20):
name = generate_name(P_bg_counted, stoi, itos)
print(f' {i+1:>2d}. {name}')التمرين 8: تعلّم المقاطع الثنائية باستخدام TransitionLearner
كما في اللعبة، استخدم TransitionLearner لتعلّم مصفوفة الاحتمالات ذات الحجم \(28\times28\) من الصفر بخوارزمية تعلّم.
عند مقارنة المصفوفتين، لاحظ أن صف <E> لا يملك انتقالات خارجة في بيانات التدريب. يبقى صفّه صفريًا في مصفوفة العدّ، وقد يحتفظ النموذج المتعلّم بتوزيعه الابتدائي فيه. لذلك قد يتأثّر أكبر فرق عبر المصفوفة كلها بهذا الصف الذي لا نستخدمه لمتابعة التوليد. افحص أيضًا الصفوف المرصودة، مثل صف m، وتذكّر أن بعض أزواج الحروف نادر أو غير مرصود.
# Prepare training data: every bigram as (input_id, target_id)
bg_xs: list[int] = []
bg_ys: list[int] = []
for name in train_names:
chs = ['<S>'] + list(name) + ['<E>']
for i in range(len(chs) - 1):
bg_xs.append(stoi[chs[i]])
bg_ys.append(stoi[chs[i + 1]])
print(f'Training pairs: {len(bg_xs):,}')
bg_model = TransitionLearner(n=V)
bg_model.train(bg_xs, bg_ys, steps=200, print_every=50)# Compare learned vs counted
P_bg_learned = bg_model.probabilities()
max_diff = 0.0
for i in range(V):
for j in range(V):
d_val = abs(P_bg_counted[i][j] - P_bg_learned[i][j])
if d_val > max_diff:
max_diff = d_val
print(f'Max absolute difference: {max_diff:.6f}')
# Side-by-side for 'm' row
print(f"\nP(next | 'm') -- top 10:")
print(f" {'char':>6s} {'counted':>10s} {'learned':>10s} {'diff':>10s}")
print(f' {"-" * 42}')
c_row = P_bg_counted[m_id]
l_row = P_bg_learned[m_id]
indices = sorted(range(V), key=lambda j: -c_row[j])[:10]
for idx in indices:
ch = itos[idx]
pc = c_row[idx]
pl = l_row[idx]
print(f' {repr(ch):>6s} {pc:>10.4f} {pl:>10.4f} {abs(pc - pl):>10.6f}')التمرين 9: التوليد من النموذج المتعلّم
توقّع أولًا: هل ستختلف هذه الأسماء عن أسماء نموذج العدّ؟
قد تتشابه الأنماط العامة عندما تتقارب الاحتمالات، لكن اختلافات صغيرة فيها قد تغيّر الأسماء المسحوبة حتى مع استخدام البذرة العشوائية نفسها.
# Generate names from the learned model
random.seed(42)
print('Generated names (learned bigram):')
for i in range(20):
name = generate_name(P_bg_learned, stoi, itos)
print(f' {i+1:>2d}. {name}')التمرين 10: عائق V²
نموذجنا الثنائي مصفوفة بحجم \(V\times V\). عند استخدام 28 رمزًا، لا نحتاج إلا إلى 784 خانة؛ وهذا حجم صغير جدًا.
لكن ماذا لو استخدمنا الكلمات بدل الحروف؟
يعرض الجدول التالي أحجامًا مختلفة للمفردات، ويحسب عدد الخانات والذاكرة المطلوبة بافتراض 4 بايتات لكل قيمة float32 في جدول كثيف. أحجام مفردات الكلمات أمثلة توضيحية، وليست حدًا ثابتًا للغة.
فكّر أيضًا في البيانات: كم زوجًا ممكنًا لن يظهر في التدريب؟ توفير الذاكرة بالتخزين المتناثر لا يعلّم النموذج وحده كيفية التعميم على هذه الأزواج.
examples = [
('Characters (this notebook)', V),
('GPT-2 BPE tokens', 50_257),
('English words (typical)', 100_000),
('Llama 3 tokens', 128_256),
('Arabic words (news corpus)', 300_000),
]
print(f'{"Vocabulary":<30s} {"V":>10s} {"V^2":>15s} {"Memory":>10s}')
print('-' * 70)
for label, v in examples:
v_sq = v * v
mem = v_sq * 4 # float32 bytes
if mem < 1e6:
mem_str = f'{mem/1024:.0f} KB'
elif mem < 1e9:
mem_str = f'{mem/1e6:.0f} MB'
elif mem < 1e12:
mem_str = f'{mem/1e9:.0f} GB'
else:
mem_str = f'{mem/1e12:.0f} TB'
print(f'{label:<30s} {v:>10,d} {v_sq:>15,d} {mem_str:>10s}')