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

الوحدة 7 — شبكات Q العميقة وإعادة تشغيل التجربة

CartPole: حالة مكوّنة من أربعة أرقام حقيقيّة (موضع العربة، سرعتها، زاوية العصا، سرعتها الزاويّة). الفضاء مستمرّ، لا يمكن تخصيص خانة جدول لكلّ حالة. حلّ ذلك ورقة DeepMind الشهيرة 2015 بشبكة عصبيّة تتنبّأ Q(s,a)Q(s, a): Deep Q-Network أو DQN. الفكرة بسيطة، لكن جعلها تعمل تطلّب حيلتين معماريّتين نُدرسهما هنا.

من الجدول إلى الشبكة

نستبدل جدول Q[s,a]Q[s, a] بشبكة Qθ(s)Q_\theta(s) تُخرج متّجهًا من A|A| قيم، واحدة لكلّ فعل:

import torch
import torch.nn as nn

class ReseauQ(nn.Module):
def __init__(self, dim_etat, dim_action):
super().__init__()
self.pipeline = nn.Sequential(
nn.Linear(dim_etat, 128),
nn.ReLU(),
nn.Linear(128, 128),
nn.ReLU(),
nn.Linear(128, dim_action),
)
def forward(self, s):
return self.pipeline(s) # (batch, dim_action)

هدف التدريب من بلمان مباشرة، خسارة تربيعيّة على خطأ TD:

L(θ)=E[(r+γmaxaQθ(s,a)Qθ(s,a))2]\mathcal{L}(\theta) = \mathbb{E}\left[ \left( r + \gamma \max_{a'} Q_{\theta}(s', a') - Q_{\theta}(s, a) \right)^2 \right]

يبدو الأمر مطابقًا للتبويب. لكن، على عكس التبويب، هذه الخوارزميّة لا تتقارب إذا نُفّذت كما هي. سببان مترابطان.

المشكلة الأولى: بيانات مترابطة

عيّنات التدريب المتتاليّة (st,at,rt,st+1)(s_t, a_t, r_t, s_{t+1}) متشابهة جدًّا (الحالة st+1s_{t+1} قريبة من sts_t). خوارزميّات الاستمثال المستخدمة في التعلّم العميق (SGD، Adam) تفترض عيّنات مستقلّة؛ وترابط بياناتنا يجعل التحديثات تتخذ اتّجاهًا واحدًا لفترة طويلة ثمّ ينقلب فجأة، فيتذبذب النموذج.

الحلّ: تخزين الرجعة (Experience Replay). ندع الوكيل يتفاعل، ونحفظ كلّ رباعيّة (s,a,r,s,done)(s, a, r, s', \text{done}) في مخزن دائري ضخم (مثلًا 100 ألف عيّنة). عند التدريب نسحب دفعة عشوائيّة من هذا المخزن.

from collections import deque
import random

memoire = deque(maxlen=100_000)

# داخل الحلقة
memoire.append((s, a, r, s_prime, terminated))
if len(memoire) >= batch_size:
dfe = random.sample(memoire, batch_size)

فوائد التخزين ثلاث: كسر الترابط الزماني، إعادة استخدام كلّ تجربة عدّة مرّات (كفاءة العيّنة)، وتنعيم توزيع البيانات الذي يراه المحلّل.

المشكلة الثانية: أهداف متحرّكة

المشكلة الثانية أخفى. عند حساب الخسارة:

cible=r+γmaxaQθ(s,a)\text{cible} = r + \gamma \max_{a'} Q_{\theta}(s', a')

نستعمل نفس θ\theta لحساب الهدف وحساب التوقّع. كلّ تحديث لـθ\theta يُغيّر الهدف نفسه الذي نُحاول ملاحقته. النتيجة: تدريب غير مستقرّ، خسارة تتذبذب، وقيمة تنفجر أو تنهار.

الحلّ: الشبكة الهدف. نحتفظ بنسختين من الشبكة، θ\theta (المدرَّبة) وθ\theta^{-} (المجمّدة). نحسب الهدف بـθ\theta^{-}:

cible=r+γmaxaQθ(s,a)\text{cible} = r + \gamma \max_{a'} Q_{\theta^{-}}(s', a')

كلّ CC خطوة (مثلًا 1000)، ننسخ θθ\theta^{-} \leftarrow \theta. بين هذه النسخات، الهدف ثابت، فيصبح التدريب استمثالًا عاديًّا مع هدف قار.

reseau = ReseauQ(4, 2)                          # CartPole: 4 مُدخلات، فعلان
cible = ReseauQ(4, 2)
cible.load_state_dict(reseau.state_dict())
cible.eval()

# كلّ 1000 خطوة
if pas % 1000 == 0:
cible.load_state_dict(reseau.state_dict())

تحديث ناعم بديل: بدل النسخ الكامل، حرّك θτθ+(1τ)θ\theta^{-} \leftarrow \tau \theta + (1 - \tau) \theta^{-} مع τ=0,005\tau = 0{,}005 في كلّ خطوة. أكثر سلاسة، أسهل ضبطًا، وهو ما تعتمده أغلب التنفيذات الحديثة.

حلقة DQN كاملة

gamma = 0.99
optim = torch.optim.Adam(reseau.parameters(), lr=1e-3)

for pas in range(200_000):
# فعل بإبسيلون-جشعة
if random.random() < epsilon:
a = env.action_space.sample()
else:
with torch.no_grad():
a = int(reseau(torch.tensor(s, dtype=torch.float32)).argmax())
s_prime, r, term, trunc, _ = env.step(a)
memoire.append((s, a, r, s_prime, term))
s = s_prime if not (term or trunc) else env.reset()[0]

if len(memoire) >= 64:
lot = random.sample(memoire, 64)
S = torch.tensor([x[0] for x in lot], dtype=torch.float32)
A = torch.tensor([x[1] for x in lot])
R = torch.tensor([x[2] for x in lot], dtype=torch.float32)
S2 = torch.tensor([x[3] for x in lot], dtype=torch.float32)
D = torch.tensor([x[4] for x in lot], dtype=torch.float32)

with torch.no_grad():
cible_q = R + gamma * (1 - D) * cible(S2).max(dim=1).values
pred_q = reseau(S).gather(1, A.unsqueeze(1)).squeeze()
perte = nn.functional.smooth_l1_loss(pred_q, cible_q)

optim.zero_grad()
perte.backward()
torch.nn.utils.clip_grad_norm_(reseau.parameters(), 10.0)
optim.step()

if pas % 1000 == 0:
cible.load_state_dict(reseau.state_dict())

بعد نحو 100 ألف خطوة على CartPole يحقّق الوكيل 195/200\approx 195/200 خطوة قبل السقوط، وهو حدّ الحلّ المُعلن. smooth_l1_loss (Huber) بدل MSE يحدّ من تأثير القيم الشاذّة في الهدف عند بداية التدريب.

Double DQN

مشكلة أخيرة: عامل max\max في الهدف يُبالغ منهجيًّا في تقدير QQ (بسبب الضجيج على التقدير). Double DQN يفصل بين اختيار الفعل وتقدير قيمته:

cible=r+γQθ(s,argmaxaQθ(s,a))\text{cible} = r + \gamma Q_{\theta^{-}}\left(s', \arg\max_{a'} Q_{\theta}(s', a')\right)

نستعمل θ\theta لاختيار الفعل الأفضل، وθ\theta^{-} لتقدير قيمته. تعديل سطر واحد يُصحّح تحيّزًا حقيقيًّا، ويحسّن الأداء دون كلفة إضافية.

علامات فشل DQN
  • خسارة تنفجر: قصّوا التدرّجات (clip_grad_norm_ عند 10) واستعملوا Huber بدل MSE.
  • QQ تنفجر: راجعوا الشبكة الهدف — نسيان تفعيلها هو السبب الأشيع.
  • الوكيل لا يتعلّم: تفقّدوا تناقص إبسيلون؛ تناقص سريع جدًّا يقتل الاستكشاف قبل ملء المخزن.
  • تباين هائل بين البذور: طبيعي في DQN، يجب دومًا الإبلاغ عن متوسّط ثلاث بذور على الأقلّ.

الخلاصة

  • DQN يستبدل جدول QQ بشبكة عصبيّة، فيسمح بفضاءات حالة مستمرّة أو كبيرة جدًّا.
  • تخزين الرجعة يكسر ترابط البيانات الزماني ويسمح بإعادة استخدام العيّنات؛ وحده يجعل الاستمثال يعمل.
  • الشبكة الهدف تُثبّت هدف الخسارة، فيصبح التدريب استمثالًا مع أهداف قارّة بين النسخات.
  • Double DQN يُقلّص التحيّز التصاعدي لـmax\max؛ تعديل يستحقّ التبنّي.
  • CartPole يُحلّ بنحو 100 ألف خطوة إذا احترمت الحيل الأربع؛ وإسقاط أيّ منها يعطّل التعلّم كلّه.

الوحدة التالية: نُغيّر المنظور تمامًا. بدل تعلّم قيمة ثمّ استخلاص السياسة، نتعلّم السياسة مباشرة بتدرّج على أوزان شبكة سياسة.