ג.17 רשת במחלקה — סיווג עשר ספרות
עד כה המודל החזיר ערך מספרי או תשובה בינארית. בזיהוי הספרה 7 שאלנו "האם זו 7?", ובפרק הקודם "האם זו שמלה?" ו"האם זו ספרה?". אבל השאלה הטבעית על תמונה של ספרה אינה שאלת כן/לא אלא "איזו ספרה זו?" — ויש לה עשר תשובות אפשריות. כך גם בעולם האמיתי: אפליקציה שמזהה פרחים צריכה לבחור מבין מאות מינים, ומערכת שממיינת דוא"ל בוחרת תיקייה מבין כמה. זהו סיווג רב־קטגוריות — Multi-class Classification: לכל דוגמה יש תשובה נכונה אחת, הנבחרת מתוך כמה אפשרויות.
הרעיון הכללי של הפתרון פשוט: במקום נוירון פלט אחד, ניתן לרשת נוירון פלט לכל קטגוריה. כל נוירון מחזיר "ציון" שמבטא עד כמה הקלט מתאים לקטגוריה שלו, והתשובה של הרשת היא הקטגוריה בעלת הציון הגבוה ביותר. כדי לאמן רשת כזו צריך שני כלים חדשים: דרך להפוך את הציונים להסתברויות — Softmax — ופונקציית הפסד שמשווה את ההסתברויות לתשובה הנכונה — Cross Entropy. שאר התהליך, מהכנת הנתונים ועד לולאת האימון, נשאר כפי שהכרנו.
בפרק זה נכיר תחילה את שני הכלים האלה, ואז נבנה רשת שמסווגת תמונת MNIST לאחת מעשר הספרות 0–9. בדרך נלמד גם דרך שנייה להגדיר רשת ב־PyTorch: באמצעות מחלקה במקום nn.Sequential. הדרך הזו גמישה יותר, והיא הדרך המקובלת ברשתות מורכבות.
השיעור וההרצאות באתר של גלעד מרקמן
חומרי הליווי: 9. רשת נורונים באמצעות מחלקה (עותק מקומי) · מחברת התרגול (עותק מקומי)
Softmax
בסיווג לכמה קבוצות, לכל קבוצה יש נוירון פלט. נרצה שהערך המתאים לקבוצה הנכונה יהיה גבוה מהערכים האחרים. אבל הציונים שהשכבה הלינארית האחרונה מחזירה הם מספרים כלשהם — חיוביים או שליליים, גדולים או קטנים — וקשה לפרש אותם ולהשוות ביניהם. בסיווג בינארי פתרנו זאת בעזרת Sigmoid, שהפכה מספר אחד להסתברות בין 0 ל־1. Softmax היא ההכללה של Sigmoid לכמה פלטים: היא הופכת את ציוני הפלט להסתברויות שסכומן 1, כך שכל ערך מבטא את "הביטחון" של הרשת בקטגוריה המתאימה, וסדר הציונים נשמר — הציון הגבוה ביותר הופך להסתברות הגבוהה ביותר.
החישוב לכל ציון הוא exp(z_i) / Σ exp(z_j): מעלים כל ציון בחזקת e, וכך כל הערכים הופכים לחיוביים, ומחלקים בסכום כדי שהתוצאות יסתכמו ל־1. מכיוון שהפונקציה המעריכית גדלה מהר, פערים קטנים בין הציונים הופכים לפערים גדולים בהסתברויות. לדוגמה, הציונים [5.0, 2.5, 0.5] נותנים בקירוב, לאחר עיגול לספרה אחת, [0.9, 0.1, 0.0].
שתי דוגמאות נוספות, בערכים מעוגלים:
| ציוני הפלט | הסתברויות אחרי Softmax |
|---|---|
[1.3, 5.1, 2.2, 0.7, 1.1] |
[0.02, 0.90, 0.05, 0.01, 0.02] |
[5, 6, 11, 7] |
[0.00, 0.01, 0.97, 0.02] |
בשתי הדוגמאות הקטגוריה בעלת הציון הגבוה ביותר מקבלת כמעט את כל ההסתברות, והאחרות נשארות עם מעט. כדי לדעת מה הרשת "בחרה" מספיק לאתר את המקום של הערך הגבוה ביותר.
One-hot encoding
אחרי שיש בידינו הסתברויות, צריך לתאר גם את התשובה הנכונה באותה צורה, כדי שנוכל להשוות ביניהן. התווית של תמונה היא מספר אחד — למשל "הספרה 3" — ואילו הרשת מחזירה וקטור של עשר הסתברויות. בייצוג one-hot יש מקום לכל קבוצה: במקום של הקבוצה הנכונה מופיע 1, ובשאר המקומות 0 — כלומר, התשובה הנכונה מיוצגת כהסתברות 1 לקטגוריה הנכונה ו־0 לכל השאר. לדוגמה, בקידוד צבעים:
| צבע | Red | Yellow | Green |
|---|---|---|---|
| Red | 1 | 0 | 0 |
| Red | 1 | 0 | 0 |
| Yellow | 0 | 1 | 0 |
| Green | 0 | 0 | 1 |
| Yellow | 0 | 1 | 0 |
Cross Entropy
כעת יש לנו שני וקטורים באותו אורך — ההסתברויות שהרשת חישבה והתשובה הנכונה בייצוג one-hot — ונותר להגדיר כיצד מודדים את המרחק ביניהם. Cross Entropy היא פונקציית הפסד המשווה את תחזית הרשת לתשובה הרצויה: היא מסתכלת על ההסתברות שהרשת נתנה לקטגוריה הנכונה, ומחזירה הפסד קטן כשהסתברות זו קרובה ל־1 והפסד גדול — שגדל במהירות — כשהיא קרובה ל־0. זו ההכללה של BCE, שהכרנו בסיווג בינארי, למספר קטגוריות. לדוגמה, אפשר להשוות את ההסתברויות [0.775, 0.116, 0.039, 0.070] לתשובה [1, 0, 0, 0]: הרשת נתנה לקטגוריה הנכונה 0.775, וההפסד יקטן ככל שערך זה יתקרב ל־1. ההפסד מודד את השגיאה; הוא אינו הופך את התחזית לווקטור one-hot.
הכנת נתוני MNIST
נחזור למאגר MNIST שהכרנו בפרק ג.13, והפעם נשמור את תוויות הספרות עצמן, ולא נהפוך אותן לתשובת כן/לא. הכנת הנתונים זהה לזו שראינו, ולכן נעבור עליה בקצרה. נתקדם דרך הכנת הנתונים, הגדרת הרשת וההפסד, אימון ובדיקת התוצאות.
תחילה נייבא את הספריות. חדש כאן הוא torch.nn.functional, המיובא בקיצור F: הוא מכיל את פונקציות האקטיבציה כפונקציות רגילות (למשל F.relu) במקום כשכבות, ונשתמש בו בהגדרת המחלקה:
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import matplotlib.pyplot as plt
import torchvision
import torchvision.transforms as transforms
נבחר את התקן החישוב:
if torch.cuda.is_available():
device = torch.device('cuda')
else:
device = torch.device('cpu')
נטען את קבוצות האימון והבדיקה וניצור אצוות של 50 תמונות, בדיוק כמו בפרק ג.13:
batch_size = 50
train_dataset = torchvision.datasets.MNIST(root='./data',
train=True,
transform=transforms.ToTensor(),
download=True)
test_dataset = torchvision.datasets.MNIST(root='./data',
train=False,
transform=transforms.ToTensor())
# Data loader
train_loader = torch.utils.data.DataLoader(dataset=train_dataset,
batch_size=batch_size,
shuffle=True)
test_loader = torch.utils.data.DataLoader(dataset=test_dataset,
batch_size=batch_size,
shuffle=False)
נציג שש תמונות מאצוות הבדיקה הראשונה ואת התוויות שלהן. שימו לב שהתוויות הן הספרות עצמן, 0–9, וכך הן יישארו לאורך כל הפרק:
examples = iter(test_loader)
example_data, example_targets = next(examples)
for i in range(6):
plt.subplot(2,3,i+1)
plt.imshow(example_data[i][0], cmap='gray')
print(example_targets[i].item(),end = " ")
plt.show()
פלט
7 2 1 0 4 1
פרמטרים ומחלקת הרשת
כל תמונה מכילה 28×28 פיקסלים, כלומר 784 ערכי קלט. החידוש הוא הפרמטר num_classes: מספר הקטגוריות, שיהיה גם מספר נוירוני הפלט. נגדיר עשר קטגוריות, חמישה אפוקים וקצב למידה 0.01:
input_size = 784 # 28x28
hidden_size = 200
num_classes = 10
epochs = 5
learning_rate = 0.01
# losses = torch.zeros(epochs*len(train_loader)//10)
עד כה הגדרנו רשתות באמצעות nn.Sequential, שמעבירה את הנתונים משכבה לשכבה בסדר קבוע. זה נוח, אך מגביל: אי אפשר, למשל, לפצל את הנתונים לשני מסלולים או להפעיל תנאי בדרך. הדרך הכללית ב־PyTorch היא לכתוב מחלקה משלנו. המחלקה ANN_Model יורשת מ־nn.Module, מחלקת הבסיס של כל המודלים ב־PyTorch, וכך היא מקבלת ממנה את כל היכולות שהכרנו — parameters(), .to(device) והקריאה למודל כפונקציה. שתי שיטות דרושות: ב־__init__ מאתחלים את מחלקת הבסיס (super().__init__()) ומגדירים את השכבות כשדות של האובייקט; ב־forward מגדירים את מעבר הנתונים ביניהן — כאן אנו כותבים בעצמנו, שורה אחרי שורה, את מה ש־nn.Sequential עשתה בשבילנו. לרשת שתי שכבות נסתרות, בגודל 200 ו־100, ושכבת פלט בת עשרה נוירונים — נוירון לכל ספרה. הגרסה השקולה ב־nn.Sequential מופיעה בהערה לצורך השוואה.
# Model = nn.Sequential(
# nn.Linear(input_size,hidden_size,device=device),
# nn.ReLU(),
# nn.Linear(hidden_size, num_classes, device=device),
# )
class ANN_Model(nn.Module):
def __init__(self):
super().__init__()
self.linear1 = nn.Linear(input_size,hidden_size)
self.linear2 = nn.Linear(hidden_size,100)
self.linear3 = nn.Linear(100, num_classes)
def forward(self, x):
x = self.linear1(x)
x = F.relu(x) # torch.nn.functional as F
x = self.linear2(x)
x = F.relu(x)
x = self.linear3(x)
return x
Model = ANN_Model().to(device)
הקריאה Model(images) מפעילה את מעבר הנתונים שהוגדר ב־forward; איננו קוראים ל־forward ישירות. אחרי שתי השכבות הראשונות מופעלת ReLU; השכבה האחרונה מחזירה עשרה ציונים גולמיים לכל תמונה, ללא Softmax. הסיבה לכך מוסברת בסעיף הבא. שימו לב גם ש־.to(device) מועבר הפעם על המודל כולו, בשורה האחרונה, ולא על כל שכבה בנפרד.
פונקציית הפסד ואופטימייזר
כאן נפגשים שני הכלים שהכרנו בתחילת הפרק. ב־PyTorch, nn.CrossEntropyLoss כוללת את חישוב LogSoftmax ו־NLLLoss — כלומר היא מפעילה בעצמה את Softmax על הציונים ואז מחשבת את Cross Entropy — ולכן אין להוסיף Softmax בסוף הרשת; אם נוסיף, Softmax יופעל פעמיים והאימון ייפגע. נוחות נוספת: אין צורך להמיר את התוויות ל־one-hot בעצמנו. נעביר אליה את ציוני הרשת ואת מספרי הספרות כפי שהם, והיא תדע לפרש את המספר 3 כ"הקטגוריה השלישית". נשתמש ב־Adam לעדכון המשקלים.
Loss = nn.CrossEntropyLoss () # applies nn.LogSoftmax + nn.NLLLoss, No softmax in last layer
# init optimizer
# optim = torch.optim.SGD(Model.parameters(), lr=learning_rate,momentum=0.9)
optim = torch.optim.Adam(Model.parameters(), lr=learning_rate)
אימון הרשת
לולאת האימון כמעט זהה לזו של הסיווג הבינארי. בכל אצווה נפרוש את התמונות לשורות של 784 ערכים ונעביר אותן ואת התוויות להתקן. ההבדל היחיד הוא בטיפול בתוויות: בפרקים הקודמים המרנו אותן ל־0/1 וסידרנו בעמודה, ואילו כאן הן נשארות וקטור של 50 ספרות, כפי ש־CrossEntropyLoss מצפה. לאחר החישוב קדימה נחשב הפסד ונגזרות, נעדכן את המשקלים ונאפס את הגרדיאנטים.
n_total_steps = len(train_loader)
for epoch in range(epochs):
for i, (images, lables) in enumerate(train_loader):
# origin shape: [50, 1, 28, 28]
# resized: [50, 784]
images = images.reshape(-1, 28*28).to(device)
lables = lables.to(device) #no reshape for CrossEntropyLoss
# forward
Y_predict = Model(images)
# backward
loss = Loss(Y_predict, lables)
loss.backward()
# update wights
optim.step()
if i % 100 == 0:
print(f"epoch= {epoch} i= {i+epoch * n_total_steps} loss={loss.item():.4f} ")
# zero grads
optim.zero_grad()
תחילת הפלט וסופו, בדילוג על שורות הביניים:
פלט
epoch= 0 i= 0 loss=2.2988
...
epoch= 4 i= 5900 loss=0.0169
ההפסד המודפס שייך לאצווה הנוכחית, ולא לממוצע של האפוק. ההפסד ההתחלתי, 2.2988, הוא בערך מה שמצפים מרשת שעדיין אינה יודעת דבר ומחלקת את ההסתברות באופן שווה בין עשר ספרות: Cross Entropy במקרה כזה היא ln 10, כ־2.3. בסוף האפוק החמישי ההפסד הוא 0.0169.
פירוש פלט הרשת
בסיווג בינארי עיגלנו את פלט הרשת כדי לקבל תשובה; כאן הפלט הוא עשרה מספרים לכל תמונה, ונצטרך דרך אחרת לחלץ ממנו תשובה. נבחן את Y_predict שנשאר מאצוות האימון האחרונה. לכל תמונה עשרה ציונים; torch.max(Y_predict, 1) מחזירה שני טנסורים: את הציון המרבי בכל שורה (הפרמטר 1 מציין חיפוש לאורך השורה, כלומר בין עשרת הציונים של אותה תמונה) ואת מיקומו, שהוא הספרה החזויה. אין צורך להפעיל Softmax לשם כך: Softmax שומרת על סדר הציונים, ולכן המקום של הציון הגבוה ביותר הוא גם המקום של ההסתברות הגבוהה ביותר. נדפיס את הציונים, את המקסימום, את המיקומים ואת התוויות האמיתיות לצורך השוואה:
print(f"epoch= {epoch} i= {i+epoch * n_total_steps} loss={loss.item():.4f}")
print(Y_predict)
v, i = torch.max(Y_predict,1)
print (v)
print ('max index: \n', i)
print('Labels: \n', lables)
בדיקת הדיוק
כעת נעבור על קבוצת הבדיקה, נבחר לכל תמונה את הספרה בעלת הציון המרבי באותה דרך, ונמנה כמה תחזיות תואמות לתוויות. את ערך המקסימום עצמו אין לנו צורך לשמור, ולכן הוא נקלט במשתנה __:
with torch.no_grad():
n_correct = 0
n_samples = 0
for images, lables in test_loader:
images = images.reshape(-1, 28*28).to(device)
lables = lables.to(device)
Y_predict = Model(images)
__,y_predict_arg = torch.max(Y_predict,1)
n_samples += lables.size(0)
n_correct += (y_predict_arg == lables).sum().item()
acc = 100 * n_correct / n_samples
print(f'Accuracy of the network on the {n_samples} test images: {acc} %')
תוצאת ההרצה השמורה:
פלט
Accuracy of the network on the 10000 test images: 96.15 %
הרשת זיהתה נכון כ־96 מתוך כל 100 תמונות שלא ראתה מעולם באימון. זו תוצאה טובה לרשת פשוטה, אך היא גם מראה שנותרו טעויות; בפרק על רשתות קונבולוציה נראה מבנה רשת שמתאים יותר לתמונות ומשפר את הדיוק.
הצגת התחזיות
נציג תמונות לצד התחזיות כדי לראות כיצד החלטות הרשת מתאימות לתוכן התמונה. ניצור איטרטור חדש של קבוצת הבדיקה:
examples = iter(test_loader)
ניקח אצווה, נחשב את הספרה החזויה לכל תמונה ונדפיס אותה לצד התווית:
example_data, example_targets = next(examples)
example_data = example_data.to(device)
example_targets = example_targets.to(device)
example_predict_value, example_predict_arg = torch.max(Model(example_data.reshape(-1, 28*28)),1)
# print(example_predict_arg)
for i in range(len(example_data)):
print ("Predict: ",example_predict_arg[i].item(), "target Label: ",example_targets[i].item() )
נציג 25 תמונות מהאצווה, עם הספרה החזויה בכותרת כל תמונה:
plt.figure(figsize=(8, 6))
for i in range(25):
subplot = plt.subplot(5,5,i+1)
plt.imshow(example_data[i][0].to('cpu'), cmap='gray')
subplot.set_title (example_predict_arg[i].item())
print(f'target: {example_targets[i].item()} predicted: {example_predict_arg[i].item()}',end = " ")
plt.tight_layout()
plt.show()