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

الوحدة 10 — مشروع: التنبّؤ بسلسلة قياسات

تسع وحدات نظرية، وأداة عملانية أخيرة. تجمع هذه الوحدة كلّ ما سبق في مشروع من أوّله إلى آخره على الفيل الأحمر: التنبّؤ بالأربع والعشرين ساعة القادمة من استهلاك مبنى المكاتب، انطلاقًا من أسبوع من البيانات. كلّ قرار في الكود يُحيل إلى الوحدة التي تُبرِّره.

الإطار

بيانات سنتين، خطوة ساعة. المتغيّر المستهدف kwh (الاستهلاك)، متغيّر مساعد temp (الحرارة الخارجية)، ومتغيّرات مُشتقّة يوم الأسبوع وساعة اليوم بترميز جيبي/جيب تمامي.

import numpy as np
import pandas as pd

df = pd.read_csv("conso.csv", parse_dates=["ts"]).set_index("ts").sort_index()

# متغيرات دورية (الوحدة 6 من دورة هندسة المتغيرات).
heure = df.index.hour
jour = df.index.dayofweek
df["h_sin"] = np.sin(2 * np.pi * heure / 24)
df["h_cos"] = np.cos(2 * np.pi * heure / 24)
df["j_sin"] = np.sin(2 * np.pi * jour / 7)
df["j_cos"] = np.cos(2 * np.pi * jour / 7)

variables = ["kwh", "temp", "h_sin", "h_cos", "j_sin", "j_cos"]

المرجعية الساذجة، أوّل ما يُنشَر

قبل أي نموذج تكراري، مرجعية ساذجة. الفعلية على السلاسل الزمنية هي persistence بأمس: نتنبّأ باستهلاك اليوم القادم بأنّه سيكون مطابقًا لاستهلاك اليوم الماضي في نفس الساعات.

def persistance_hier(serie_kwh):
"""يعيد تنبؤ 24 ساعة قادمة كنسخة من آخر 24 ساعة."""
return serie_kwh[-24:]

# على مجموعة الاختبار: كل نافذة نتنبأ بها بآخر 24 ساعة قبل الأفق.
predictions_naives = []
for i in range(0, len(te) - 168 - 24 + 1, 24):
entree = te["kwh"].iloc[i:i + 168].values
prediction = persistance_hier(entree)
predictions_naives.append(prediction)

mae_naif = np.mean(np.abs(np.array(predictions_naives) -
y_te_reel))
print(f"MAE مرجعية persistance: {mae_naif:.2f} kWh")
# MAE مرجعية persistance: 6.83 kWh

هذا الرقم هو الرقم الذي يجب أن نتغلّب عليه. أيّ نموذج تكراري لا يتقدّم على 6.83 kWh هو جهد مبذول عبثًا. ينبغي أن يستهلّ به كلّ مشروع سلسلة زمنية، دائمًا.

التقسيم والتسوية بلا تسرّب

from sklearn.preprocessing import StandardScaler

n = len(df)
tr = df.iloc[: int(n * 0.7)]
val = df.iloc[int(n * 0.7): int(n * 0.85)]
te = df.iloc[int(n * 0.85):]

echelle = StandardScaler().fit(tr[variables])
tr_norm = echelle.transform(tr[variables])
val_norm = echelle.transform(val[variables])
te_norm = echelle.transform(te[variables])

كما شرحت الوحدة 9: fit على التدريب فقط، لا نقاش.

معمارية النموذج

نبدأ بـGRU طبقتين، حجم مخفية 64، بلا ثنائية اتّجاه (الوحدة 7 تُحرِّم ذلك على التنبّؤ). طبقة إخراج تُنتج 24 قيمة معًا.

import torch
import torch.nn as nn

class PredicteurConso(nn.Module):
def __init__(self, n_vars=6, hidden=64, horizon=24):
super().__init__()
self.rnn = nn.GRU(n_vars, hidden, num_layers=2,
dropout=0.2, batch_first=True)
self.tete = nn.Sequential(
nn.Linear(hidden, 128),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(128, horizon),
)

def forward(self, x): # x: (N, 168, 6)
_, h_T = self.rnn(x) # h_T: (2, N, 64)
return self.tete(h_T[-1]) # نأخذ الطبقة الأخيرة → (N, 24)

h_T[-1] هو مُخرَج آخر طبقة GRU، وهو ما يجب دفعه إلى طبقة الإخراج. h_T[0] هو مُخرَج الطبقة الأولى، وسنكسب من إهماله ألّا نُلوّث التنبّؤ بتمثيل غير كافٍ تجريدًا.

حلقة التدريب

critere = nn.HuberLoss(delta=1.0)                                # (*) أدنى حساسية للقيم الشاذة
optimiseur = torch.optim.AdamW(modele.parameters(), lr=3e-4, weight_decay=1e-4)
planificateur = torch.optim.lr_scheduler.CosineAnnealingLR(optimiseur, T_max=50)

meilleure_val, patience, attente = float("inf"), 8, 0

for epoque in range(50):
modele.train()
perte_tr = 0.0
for X, y in charg_tr:
X, y = X.to(appareil), y.to(appareil)
optimiseur.zero_grad()
perte = critere(modele(X), y)
perte.backward()
# الوحدة 4: قص التدرج على كل شبكة تكرارية.
nn.utils.clip_grad_norm_(modele.parameters(), max_norm=1.0)
optimiseur.step()
perte_tr += perte.item() * X.size(0)

modele.eval()
perte_val = 0.0
with torch.no_grad():
for X, y in charg_val:
X, y = X.to(appareil), y.to(appareil)
perte_val += critere(modele(X), y).item() * X.size(0)

perte_tr /= len(charg_tr.dataset)
perte_val /= len(charg_val.dataset)
planificateur.step()

if perte_val < meilleure_val:
meilleure_val, attente = perte_val, 0
torch.save(modele.state_dict(), "meilleur_gru.pt")
else:
attente += 1
if attente >= patience:
print(f"توقف مبكر عند الحقبة {epoque}")
break

print(f"epoque {epoque:2d} | train {perte_tr:.4f} | val {perte_val:.4f}")

خمس نقاط انتباه في هذه الحلقة، كلّها من وحدات سابقة. Huber بدل MSE يحمي من ذُرَى الاستهلاك القصوى (تشغيل مفاجئ لجهاز ثقيل). clip_grad_norm_ من الوحدة 4 وقائي. weight_decay بسيط لأنّ الشبكة صغيرة. CosineAnnealingLR من الدورة 7 يُساعد التقارب. التوقّف المبكّر يمنع الإفراط في التعلّم.

LSTM أمام GRU

نُعيد كامل الأنبوب مع nn.LSTM بدل nn.GRU، بنفس أبعاد الطبقات. النتائج على مجموعة الاختبار:

النموذجMAE (kWh)RMSE (kWh)زمن حقبة
Persistence بأمس6.839.20
متوسّط الأسبوع5.948.12
GRU (18 ألف وسيط)3.424.8522 ث
LSTM (23 ألف وسيط)3.384.7827 ث

الفارق بين GRU وLSTM هامشيّ (0.04 kWh)، وGRU أسرع بـ20%. القرار الإنتاجي واضح: GRU.

فترات ثقة بالكوانتيلات

التنبّؤ بنقطة واحدة قصور. المستخدم يحتاج معرفة مدى ثقة النموذج: هل الاستهلاك المتوقّع 45 kWh يعني بين 42 و48، أم بين 30 و60؟ الحلّ الأنيق: تدريب النموذج على التنبّؤ بعدّة كوانتيلات في وقت واحد.

نستبدل خسارة Huber بخسارة الكوانتيل الصنوبرية (pinball loss). لكلّ كوانتيل τ(0,1)\tau \in (0, 1):

τ(y,y^)=max(τ(yy^),  (τ1)(yy^))\ell_\tau(y, \hat{y}) = \max\left(\tau (y - \hat{y}),\; (\tau - 1)(y - \hat{y})\right)

نُخرج ثلاث توقّعات (τ=0.1\tau = 0.1، τ=0.5\tau = 0.5، τ=0.9\tau = 0.9) فتُشكِّل الأولى والثالثة فترة ثقة 80% حول الثانية.

class PredicteurCoantile(nn.Module):
def __init__(self, n_vars=6, hidden=64, horizon=24, kwantiles=3):
super().__init__()
self.rnn = nn.GRU(n_vars, hidden, num_layers=2, dropout=0.2, batch_first=True)
self.tete = nn.Linear(hidden, horizon * kwantiles)
self.horizon, self.kw = horizon, kwantiles

def forward(self, x):
_, h_T = self.rnn(x)
return self.tete(h_T[-1]).view(-1, self.horizon, self.kw)

def perte_pinball(pred, cible, tau):
"""pred: (N, H). cible: (N, H). tau: float."""
err = cible - pred
return torch.mean(torch.max(tau * err, (tau - 1) * err))

def perte_multi_kw(pred, cible, taus=(0.1, 0.5, 0.9)):
"""pred: (N, H, K). cible: (N, H)."""
return sum(perte_pinball(pred[:, :, i], cible, tau)
for i, tau in enumerate(taus)) / len(taus)

النموذج بعد التدريب يُنتج ثلاث توقّعات لكلّ ساعة. الفرق بين τ=0.9\tau=0.9 وτ=0.1\tau=0.1 يُعطي عرض فترة ثقة 80%. على الاختبار، 90% من القيم الحقيقية تقع داخل هذه الفترة، وهو ما يجعل التنبّؤ قابلًا للتشغيل في قرار تشغيلي: هل نُفعِّل مولّدًا احتياطيًا؟ هل نغيّر جدول التشغيل؟

أخطاء تجنّبها

خلاصة تجربة، ست أخطاء يقع فيها كلّ من يبدأ مشروع تنبّؤ:

  • ترك train_test_split عشوائيًا بدلًا من زمني: الوحدة 1، تسرّب صامت
  • معالجة قبلية على السلسلة كاملة قبل التقسيم: الوحدة 9، تسرّب من المستقبل
  • قفز إلى LSTM قبل تجريب مرجعية ساذجة: هذه الوحدة، إهدار وقت
  • استعمال شبكة ثنائية الاتّجاه للتنبّؤ: الوحدة 7، تسرّب معماريّ مطلق
  • نسيان nn.utils.clip_grad_norm_: الوحدة 4، انفجار عشوائي في حقبة ما
  • تقديم تنبّؤ بنقطة واحدة بلا فترة ثقة: عدم قابليّة للتشغيل، مهما بلغت الدقّة
قبل الانتقال إلى دورة المحوّلات

كلّ ما بنيناه في هذه الدورة صالح كأساس جيّد لأنبوب تنبّؤ إنتاجي. لكنّ الحدود موجودة. على السلاسل الطويلة جدًّا (آلاف الخطوات) والسلاسل متعدّدة المتغيّرات المترابطة، تتفوّق المعماريّات القائمة على الانتباه — Informer وTemporal Fusion Transformer وTemporalCNN — على LSTM بهامش ملموس. حين تُصبح مشكلتك مقصورة على السلاسل ذات التبعيات المعقدة أو الأفق البعيد، فالانتقال إلى تلك المعماريّات هو الخطوة التالية الطبيعية. لكنّ فهم الشبكات التكرارية يبقى شرطًا مسبقًا لفهم لماذا فتحت المحوّلات فصلًا جديدًا.

في الخلاصة

  • تبدأ كلّ مشروع سلسلة زمنية بـمرجعية ساذجة (persistence، متوسّط)؛ أيّ نموذج لا يتقدّم عليها هو جهد مضاعَف بلا مبرّر.
  • التقسيم زمنيّ، التسوية على التدريب وحده، pack عند الحاجة: قواعد الوحدة 9 معًا هي ما يفصل نموذجًا يعمل عن نموذج يظنّ نفسه يعمل.
  • GRU خيار افتراضي على السلاسل الزمنية متوسّطة الأفق؛ اللجوء إلى LSTM حين يكون الفارق ملموسًا.
  • فترات ثقة بالكوانتيلات تحوّل تنبّؤًا نظريًا إلى قرار تشغيلي، وهي رخيصة الحساب مقابل مكسبها الوظيفي.

الخطوة التالية: المراجعة والاختبار.