تطبّق هذه الصفحة كل مفاهيم الموجة الرابعة في مشروع متكامل: تحميل بيانات صور، بناء CNN صغير، تدريبه، تقييمه، حفظ أوزانه، ثم إعادة تحميله لاستخدامه لاحقًا.
المشكلة
تصنيف صور أرقام مكتوبة بخطّ اليد (MNIST) إلى 10 أصناف (0-9). رغم بساطتها، هذه مشكلة كلاسيكية تتحقّق منها كل مفاهيم الموجة:
- CNN (درس 66): استخراج ميزات مكانية من الصور.
- Loss + Optimizer (درس 73):
CrossEntropyLoss+ Adam. - Training Loop (درس 72): forward/backward/step.
- Save/Load (درس 75): حفظ checkpoint للاستخدام اللاحق.
المتطلبات المسبقة
import torch # 2.0+
import torchvision # 0.15+
import torch.nn as nn
from torch.utils.data import DataLoader
تثبيت PyTorch و torchvision محليًا:
pip install torch torchvision
إذا كنت على macOS/Windows بدون NVIDIA GPU، استخدم CPU-only build (أصغر وأسرع في التحميل).
مجموعة البيانات — MNIST
- 60,000 صورة تدريب + 10,000 صورة اختبار.
- كل صورة: 28×28 بكسل رمادي (قناة واحدة).
- 10 أصناف: الأرقام 0-9.
- مصدرها:
torchvision.datasets.MNIST.
import torchvision.transforms as transforms
transform = transforms.Compose([
transforms.ToTensor(), # (H, W) uint8 → (1, H, W) float32
transforms.Normalize((0.1307,), (0.3081,)), # متوسّط وانحراف MNIST
])
ملاحظة: قِيَم التطبيع
0.1307و0.3081هي متوسّط وانحراف MNIST الكلاسيكي. استخدمها كما هي لتسريع التقارب.
التحميل
import torchvision
train_ds = torchvision.datasets.MNIST(
root="./data", train=True, download=True, transform=transform
)
test_ds = torchvision.datasets.MNIST(
root="./data", train=False, download=True, transform=transform
)
print("Train size:", len(train_ds)) # 60,000
print("Test size:", len(test_ds)) # 10,000
print("Sample shape:", train_ds[0][0].shape) # torch.Size([1, 28, 28])
print("Label:", train_ds[0][1]) # 5
تنبيه أوّلي: عند أوّل تشغيل،
download=Trueيُنزّل ~50MB إلى./data/MNIST/. المجلد مُتجاهَل في.gitignoreبنمط ضيّق (/data/MNIST/) فلا حاجة لإضافة/data/كاملًا. إذا لم يكن الإنترنت متاحًا، القسم بديل بيانات تركيبيّة يشرح خيارًا بدون تنزيل.
DataLoader
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
test_loader = DataLoader(test_ds, batch_size=64)
# عيّنة من دفعة
xb, yb = next(iter(train_loader))
print("batch x:", xb.shape) # torch.Size([64, 1, 28, 28])
print("batch y:", yb.shape) # torch.Size([64])
المعمارية — CNN صغيرة
طبقًا لـ درس CNN، نضع طبقتَين تلافيفيّتين مع pooling، ثم مصنّف fully-connected:
class SmallCNN(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 16, kernel_size=3, padding=1), nn.ReLU(),
nn.MaxPool2d(2), # 28 → 14
nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(),
nn.MaxPool2d(2), # 14 → 7
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(32 * 7 * 7, 128), nn.ReLU(),
nn.Linear(128, 10),
)
def forward(self, x):
return self.classifier(self.features(x))
حساب الأشكال (تحقّق برمجي):
import torch
model = SmallCNN()
x = torch.zeros(1, 1, 28, 28)
# اتّباع الأشكال عبر الطبقات
for layer in model.features:
x = layer(x)
print(f"{layer.__class__.__name__:10s} → {tuple(x.shape)}")
# ثم classifier
print("\nClassifier input (after Flatten):", x.flatten().shape)
y = model(torch.zeros(1, 1, 28, 28))
print("Output logits:", y.shape) # (1, 10)
المتوقّع:
Conv2d → (1, 16, 28, 28)
ReLU → (1, 16, 28, 28)
MaxPool2d → (1, 16, 14, 14)
Conv2d → (1, 32, 14, 14)
ReLU → (1, 32, 14, 14)
MaxPool2d → (1, 32, 7, 7)
Classifier input (after Flatten): torch.Size([1568])
Output logits: torch.Size([1, 10])
عدد المعاملات:
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total parameters: {total:,}")
print(f"Trainable: {trainable:,}")
Total parameters: 222,058
Trainable: 222,058
نموذج صغير عمدًا (~222K parameter) ليجري بسرعة على CPU.
التدريب
طبقًا لـ Training Loop:
import torch.optim as optim
from torch.utils.data import DataLoader, SubsetRandomSampler
import numpy as np
torch.manual_seed(42)
np.random.seed(42)
# Subset لتسريع التدريب محليًا (2000 عيّنة فقط)
train_idx = list(range(2000))
val_idx = list(range(2000, 3000))
test_idx = list(range(500))
train_loader = DataLoader(train_ds, batch_size=64, sampler=SubsetRandomSampler(train_idx))
val_loader = DataLoader(train_ds, batch_size=64, sampler=SubsetRandomSampler(val_idx))
test_loader = DataLoader(test_ds, batch_size=64, sampler=SubsetRandomSampler(test_idx))
model = SmallCNN()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
best_val_acc = 0.0
for epoch in range(5):
# === TRAIN ===
model.train()
train_loss = 0.0
n_train = 0
for xb, yb in train_loader:
optimizer.zero_grad()
logits = model(xb)
loss = criterion(logits, yb)
loss.backward()
optimizer.step()
train_loss += loss.item() * len(xb)
n_train += len(xb)
# === VALIDATE ===
model.eval()
val_correct, val_loss, n_val = 0, 0.0, 0
with torch.no_grad():
for xb, yb in val_loader:
logits = model(xb)
val_loss += criterion(logits, yb).item() * len(xb)
val_correct += (logits.argmax(1) == yb).sum().item()
n_val += len(xb)
train_acc = evaluate_acc(model, train_loader) # helper
val_acc = val_correct / n_val
print(
f"epoch {epoch}: "
f"train_loss={train_loss/n_train:.4f} "
f"train_acc={train_acc:.4f} "
f"val_loss={val_loss/n_val:.4f} "
f"val_acc={val_acc:.4f}"
)
# === حفظ أفضل نموذج ===
if val_acc > best_val_acc:
best_val_acc = val_acc
torch.save(model.state_dict(), "best_image_classifier.pt")
print(f" ↳ saved best (val_acc={val_acc:.4f})")
مع evaluate_acc:
def evaluate_acc(model, loader):
model.eval()
correct, total = 0, 0
with torch.no_grad():
for xb, yb in loader:
correct += (model(xb).argmax(1) == yb).sum().item()
total += len(xb)
return correct / total
نتائج متوقّعة (2000 عيّنة تدريب، 1000 validation، 5 epochs):
epoch 0: train_loss=0.5873 train_acc=0.8510 val_loss=0.2657 val_acc=0.9230
↳ saved best (val_acc=0.9230)
epoch 1: train_loss=0.1689 train_acc=0.9510 val_loss=0.1677 val_acc=0.9530
↳ saved best (val_acc=0.9530)
epoch 2: train_loss=0.1105 train_acc=0.9705 val_loss=0.1278 val_acc=0.9690
↳ saved best (val_acc=0.9690)
epoch 3: train_loss=0.0758 train_acc=0.9810 val_loss=0.1133 val_acc=0.9720
↳ saved best (val_acc=0.9720)
epoch 4: train_loss=0.0561 train_acc=0.9870 val_loss=0.1095 val_acc=0.9700
قد تختلف النتائج قليلًا بسبب العشوائية، لكن ينبغي أن تصل
val_accفوق 95% بسهولة.
التقييم النهائي على Test Set
# تحميل أفضل نموذج
model = SmallCNN()
model.load_state_dict(torch.load("best_image_classifier.pt", weights_only=True))
model.eval()
test_correct, test_loss, n_test = 0, 0.0, 0
all_preds, all_labels = [], []
with torch.no_grad():
for xb, yb in test_loader:
logits = model(xb)
test_loss += criterion(logits, yb).item() * len(xb)
preds = logits.argmax(1)
all_preds.extend(preds.tolist())
all_labels.extend(yb.tolist())
test_correct += (preds == yb).sum().item()
n_test += len(xb)
test_acc = test_correct / n_test
print(f"Test accuracy: {test_acc:.4f}")
print(f"Test loss: {test_loss/n_test:.4f}")
متوقّع: test_acc ≈ 0.96+ على الـ subset.
حفظ وإعادة التحميل — التحقّق العملي
طبقًا لـ درس Save/Load:
# === حفظ (نهاية التدريب) ===
torch.save(model.state_dict(), "best_image_classifier.pt")
print("Saved.")
# === في جلسة جديدة (افتراض إعادة تشغيل) ===
model_loaded = SmallCNN() # أوزان عشوائية
model_loaded.load_state_dict(
torch.load("best_image_classifier.pt", weights_only=True)
)
model_loaded.eval()
# === تحقّق: نفس التنبّؤات ===
xb_test, yb_test = next(iter(test_loader))
with torch.no_grad():
pred_orig = model(xb_test).argmax(1)
pred_loaded = model_loaded(xb_test).argmax(1)
print("Same predictions:", torch.equal(pred_orig, pred_loaded))
print("First 10 true:", yb_test[:10].tolist())
print("First 10 pred:", pred_loaded[:10].tolist())
النتيجة: Same predictions: True — تأكيد أنّ save/load لم يفقد شيئًا.
تنبّؤ على صورة جديدة
def predict_image(model, image_tensor_1x28x28):
"""image_tensor_1x28x28: torch.Tensor shape (1, 28, 28) normalized"""
model.eval()
with torch.no_grad():
logits = model(image_tensor_1x28x28.unsqueeze(0)) # add batch dim
probs = torch.softmax(logits, dim=-1)
return probs.argmax(1).item(), probs.max().item()
idx = 42
img, label = test_ds[idx]
pred_class, confidence = predict_image(model_loaded, img)
print(f"True: {label}, Pred: {pred_class} ({confidence*100:.1f}% confidence)")
بديل بيانات تركيبيّة
إذا لم يكن الإنترنت متاحًا، أنشئ بيانات وهمية صغيرة داخل الكود:
from torch.utils.data import Dataset
class SyntheticShapes(Dataset):
"""3 أصناف: 0=نقطة، 1=خطّ أفقي، 2=خطّ عمودي — 28×28 رمادي."""
def __init__(self, n_samples=300, size=28):
self.X = torch.zeros(n_samples, 1, size, size)
self.y = torch.zeros(n_samples, dtype=torch.long)
torch.manual_seed(0)
for i in range(n_samples):
cls = i % 3
self.y[i] = cls
if cls == 0: # نقطة
cx, cy = torch.randint(5, size-5, (2,))
self.X[i, 0, cy, cx] = 1.0
elif cls == 1: # خطّ أفقي
row = torch.randint(5, size-5, (1,))
self.X[i, 0, row, 5:size-5] = 1.0
else: # خطّ عمودي
col = torch.randint(5, size-5, (1,))
self.X[i, 0, 5:size-5, col] = 1.0
def __len__(self):
return len(self.X)
def __getitem__(self, idx):
return self.X[idx], self.y[idx]
# مثال
synth = SyntheticShapes()
img, label = synth[0]
print("synthetic image shape:", img.shape, "label:", label)
ثم استبدل train_ds بـ synth وأكمل نفس خطوات التدريب. هذه البيانات أبسط بكثير من MNIST — نتائج مرتفعة (98%+) متوقّعة.
تجارب يمكنك تجربتها
- عدد الفلاتر: غيّر
Conv2d(1, 16, ...)إلىConv2d(1, 32, ...)ولاحظ الفرق في الدقّة ووقت التدريب. - Learning rate: جرّب
1e-4،1e-3،1e-2ولاحظ التقارب. - عدد الـ epochs: 5 vs 10 vs 20. هل يستمر التحسّن؟
- Dropout: أضف
nn.Dropout(0.5)قبل الطبقة الأخيرة:nn.Linear(32 * 7 * 7, 128), nn.ReLU(), nn.Dropout(0.5), - Batch size: 32، 64، 128 — وتأثيره على الاستقرار.
- Data Augmentation (متقدّم):
transform = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ]) - استخدم FashionMNIST بدل MNIST — أصعب قليلًا وأكثر تنوّعًا:
train_ds = torchvision.datasets.FashionMNIST(root="./data", train=True, download=True, transform=transform)
ملاحظات مهمّة
- MNIST data ليست في الـ repo — تُنزّل إلى
./data/MNIST/عند أوّل تشغيل، والمجلد مُتجاهَل في.gitignoreبنمط ضيّق. - النتيجة المتوقّعة على 2000 عيّنة: ~96% val_acc. على كامل 60,000 عيّنة: ~99%+.
- حجم النموذج صغير (~222K params) — عملي جدًا للتشغيل على CPU.
set_grad_enabled(False)ضمني داخلwith torch.no_grad():— ضرورى لتقييم سليم.
أخطاء شائعة
- نسيان
model.eval()قبل inference: Dropout يبقى مفعّلًا → نتائج غير مستقرّة. torch.load(...)بدونweights_only=True: تحذير متوقّع؛ مرّرweights_only=Trueعند حفظ/تحميل أوزان فقط.- عدم تطابق device:
model.to(device)وxb.to(device)معًا. هنا CPU فقط فلا مشكلة. - عدم تطابق الـ class index مع label في MNIST: صنف
5يعني الرقم 5، لا ترتيبه 5. اطبعdataset.classesأوdataset[i][1]للتأكّد. - تحميل ملف من مصدر غير موثوق:
torch.loadيستخدم pickle. تحقّق من المصدر أو استخدمweights_only=True.
الخطوات التالية
- CNN — الشبكات العصبية الالتفافية — البنية نظريًا.
- Optimizers و Regularization — ضبط التدريب.
- حفظ وتحميل النماذج — كل أنماط save/load.
- Loss و Optimizer في PyTorch — اختيار loss الصحيح.
- تمثيل الصور — إذا أردت فهم كيف تصير الصورة tensor قبل دخول الشبكة.
- خط أنابيب عملي لتصنيف الصور — augmentation، ImageFolder، confusion matrix.
- Transfer Learning في Computer Vision — استخدام backbone مُدرَّب مسبقًا.
🔍 إذا أردت مشروعًا أكبر: استبدل MNIST بـ CIFAR-10 (صور ملوّنة 32×32، 10 أصناف) — ولكنّه يحتاج معمارية أكبر لتجاوز 70% دقّة.