ג.8 רגרסיה עם הטיה ו־nn.Linear

בפרק הקודם למדנו להתאים לנתונים ישר מהצורה y = wx, וסיימנו בקבוצת נקודות שנוצרה מהנוסחה y = 2x + 5. ישר כזה אינו עובר בראשית הצירים, ולכן שום בחירה של w לבדה לא תתאר אותו: המודל שלנו "כבול" לנקודה (0, 0). זה מצב נפוץ מאוד בעולם האמיתי. מחיר של נסיעה במונית מתחיל מתעריף פתיחה גם לפני הקילומטר הראשון; חשבון חשמל כולל תשלום קבוע שאינו תלוי בצריכה; ובמדידת טמפרטורה יש קשר ישר בין צלזיוס לפרנהייט, אך 0 מעלות צלזיוס אינן 0 פרנהייט. בכל המקרים האלה הישר חוצה את ציר y בגובה שאינו אפס.

הפתרון הוא להוסיף למודל פרמטר שני, ההטיה — Bias, שמסומן b: המודל הופך ל־y = wx + b. המשקל w עדיין קובע את השיפוע, וההטיה b קובעת את הגובה שבו הישר חוצה את ציר y — כלומר את התחזית כשהקלט הוא אפס. שני הפרמטרים נלמדים יחד באותו תהליך בדיוק: לכל זוג (w, b) יש הפסד, ו־Gradient Descent מוצא את הזוג שממזער אותו, בדיוק כפי שמצאנו מינימום של פונקציה בשני משתנים בפרק ג.6.

בפרק זה נעשה את הדבר פעמיים. תחילה נבנה את המודל בעצמנו, עם שני טנסורים W ו־B, כדי לראות שאין כאן שום קסם. אחר כך נכיר את torch.nn, ספריית המודלים של PyTorch, ונראה שהיא מספקת את אותו מודל בשורה אחת — יחד עם פונקציית הפסד מוכנה. זהו הכלי שבו נבנה בהמשך גם רשתות נוירונים שלמות, ולכן חשוב לראות שהוא מסתיר בדיוק את מה שכתבנו ידנית.

נמשיך אפוא בהתאמת קו ישר, והפעם נוסיף הטיה המאפשרת לו לחתוך את ציר y גם מחוץ לראשית. נתאים את הקו ל־100 נקודות. נבנה תחילה את המודל באמצעות פרמטרים שנגדיר בעצמנו, ולאחר מכן נשתמש ב־torch.nn להגדרת אותו סוג של מודל.

השיעור וההרצאות באתר של גלעד מרקמן

חומרי הליווי: 5.2. רגרסיה לינארית (עותק מקומי) · מחברת רגרסיה עם nn.Linear (עותק מקומי)

נייבא את הספריות. שתיים מהן חדשות: torch.nn היא ספריית המודלים של PyTorch, ו־sklearn.datasets היא חלק מספריית scikit-learn, שתשמש אותנו ליצירת נתוני דוגמה:

import torch
import numpy as np
import matplotlib.pyplot as plt
import torch.nn as nn
from sklearn import datasets

סדר העבודה נשאר כפי שהיה בפרק הקודם: הכנת נתונים, הגדרת מודל, הגדרת הפסד ואופטימייזר, ולולאת אימון הכוללת חיזוי, חישוב נגזרות ועדכון פרמטרים.

הכנת הנתונים

במקום להקליד נקודות ידנית, ניעזר בפונקציה מוכנה שמייצרת נתונים המתאימים לרגרסיה: היא בוחרת ישר, מפזרת סביבו נקודות ומוסיפה רעש אקראי, בדומה למה שעשינו בפרק הקודם עם torch.rand. ניצור 100 זוגות (x, y) באמצעות make_regression. לכל דוגמה יש תכונת קלט אחת; noise=20 מוסיף רעש, ו־bias=50 מוסיף איבר קבוע לנתוני y — כלומר הישר שממנו נוצרו הנתונים אינו עובר בראשית, וזו בדיוק הסיבה שאנו זקוקים להטיה. random_state=1 קובע את המספרים האקראיים כך שהנתונים יהיו זהים בכל הרצה.

x_np, y_np = datasets.make_regression(n_samples=100, n_features=1, noise=20, random_state=1, bias=50)
print (x_np.shape , y_np.shape)
plt.scatter(x_np, y_np, color='red')
plt.xlabel("x")
plt.ylabel("Y")
plt.show()

פלט

(100, 1) (100,)
מאה נקודות שנוצרו לצורך התאמת ישר.
מאה נקודות שנוצרו לצורך התאמת ישר.

הפלט (100, 1) (100,) מספר לנו על צורת הנתונים: x הוא מערך של 100 שורות ועמודה אחת, ואילו y הוא מערך חד־ממדי של 100 ערכים. ההבדל הזה יחייב אותנו לטפל בצורה של y בסעיף הבא. מי שרוצה לראות את הערכים עצמם יכול להדפיס את שני המערכים:

print(x_np)
print(y_np)

המרה לטנסורים

הפונקציה make_regression מחזירה מערכי NumPy, ואילו האימון ב־PyTorch עובד על טנסורים. נמיר את הנתונים לטנסורים מסוג float32, הסוג שבו PyTorch עובד כברירת מחדל. מערך x כבר בנוי מ־100 שורות ועמודה אחת; מערך y הוא חד־ממדי, ולכן נעצב אותו גם כן ל־100 שורות ועמודה אחת. הפקודה reshape(-1,1) פירושה "עמודה אחת, ומספר השורות ייקבע לפי כמות הערכים".

# Prepare tensors

X = torch.from_numpy(x_np.astype(np.float32))
Y = torch.from_numpy(y_np.astype(np.float32))
# Y = Y.view(Y.shape[0],1)
Y = Y.reshape(-1,1)


print (X.shape, X.dtype)
print(Y.shape, Y.dtype)
# print (Y)

פלט

torch.Size([100, 1]) torch.float32
torch.Size([100, 1]) torch.float32

כך לכל שורת קלט מתאימה שורת תשובה אחת, ושני הטנסורים בעלי אותה צורה. ההקפדה על הצורות חשובה: כשנחסר בין התחזית ל־Y בפונקציית ההפסד, שני הטנסורים חייבים להיות באותה צורה, אחרת PyTorch עלול לחסר "כל אחד מכל אחד" ולתת תוצאה שגויה בלי הודעת שגיאה.

הגדרת הפרמטרים, המודל, ההפסד והאופטימייזר

כעת נבנה את המודל בעצמנו, בדיוק כמו בפרק הקודם, אלא שיש בו פרמטר נוסף. המודל הוא y = wx + b: המשקל w קובע את השיפוע, וההטיה b קובעת את החיתוך עם ציר y. לכן נגדיר שני פרמטרים, W ו־B, ונעביר את שניהם לאופטימייזר. שניהם מאותחלים לאפס, כלומר הישר ההתחלתי הוא ציר x עצמו, והאופטימייזר יעדכן את שניהם בכל צעד לפי הנגזרת של כל אחד.

נוסיף גם רשימה בשם losses לשמירת ערכי ההפסד, כדי שנוכל לצייר בסוף האימון גרף המראה כיצד ההפסד ירד מאיטרציה לאיטרציה:

W = torch.tensor([0.0], requires_grad=True)
B = torch.tensor([0.0], requires_grad=True)

learning_rate = 0.01
epochs = 1000
losses = []

# design model
def Model(X):
    return W * X + B

#construct loss and optimizer
def Loss (Y_predict, Y):
    return ((Y_predict-Y)**2).mean()   # (W * X + B - Y)^2

# optimizer
optimizer = torch.optim.SGD([W, B], lr=learning_rate) # W = W - grad * LR / B -= grad * LR

לולאת האימון

לולאת האימון זהה במבנה לזו שכתבנו בפרק הקודם. באימון נעדכן יחד את המשקל ואת ההטיה כדי להקטין את ההפסד: קריאה אחת ל־backward() מחשבת את הנגזרת לפי שני הפרמטרים, וקריאה אחת ל־optimizer.step() מעדכנת את שניהם. הלולאה range(epochs+1) מבצעת 1,001 איטרציות, ממספר 0 עד 1,000. בכל איטרציה נשמור את ההפסד ברשימה ונאפס את הנגזרות לאחר העדכון.

for epoch in range(epochs+1):
    # forward
    Y_predict = Model(X)

    # backward
    loss = Loss(Y_predict, Y)
    loss.backward()

    # Update parameters
    optimizer.step()  # W = W - grad * LR

    if epoch % 10 == 0:
        print(f"epoch= {epoch} loss={loss.item():.3f} ")

    losses.append(loss.item())

    # zero grads
    optimizer.zero_grad()

גרף ההפסד הוא הכלי הבסיסי ביותר לבדיקה שהאימון מתנהל כשורה: אנו מצפים לעקומה שיורדת במהירות בתחילה ואחר כך מתיישרת, כלומר ההפסד מתייצב ואין עוד מה לשפר. נציג את ההפסד לאורך האימון:

# print(losses)
plt.plot (losses)
הציר האופקי מציג את מספר האיטרציה, והאנכי את ההפסד.
הציר האופקי מציג את מספר האיטרציה, והאנכי את ההפסד.

הצגת התוצאה

נציג את ההפסד האחרון ואת הקו שהתקבל. כדי לצייר את הישר נחשב את תחזית המודל עבור כל ערכי X, בתוך torch.no_grad() כי אין צורך בנגזרות, ונמיר את התוצאה למערך NumPy באמצעות .detach().numpy():

print(f"END: loss={loss.item():.4f}")
plt.scatter(x_np, y_np, color='red')
plt.xlabel("x")
plt.ylabel("Y")

with torch.no_grad():
    predicted = Model(X).detach().numpy()
    plt.plot (x_np, predicted, color = "green")
    plt.show()

פלט

END: loss=332.5676
הקו הירוק שנלמד באמצעות W ו־B, לצד הנקודות האדומות.
הקו הירוק שנלמד באמצעות W ו־B, לצד הנקודות האדומות.

ההפסד הסופי, כ־332, נראה גדול לעומת הפרק הקודם, אך יש לזכור שהוא ממוצע של ריבועי שגיאות: הנתונים נוצרו עם רעש בסדר גודל של 20, וריבוע של 20 הוא 400. ההפסד הזה משקף אפוא את הרעש שבנתונים ולא כשל של המודל; הישר עצמו עובר יפה במרכז ענן הנקודות.

שימוש ב־torch.nn

עד עכשיו כתבנו את המודל ואת פונקציית ההפסד בעצמנו. עבור ישר עם שני פרמטרים זה קל, אך רשת נוירונים יכולה להכיל אלפי פרמטרים, ואיש אינו רוצה להגדיר כל אחד מהם ידנית. לשם כך קיימת הספרייה torch.nn, שמספקת אבני בניין מוכנות למודלים; כל אבן בניין כזו נקראת שכבה — Layer. PyTorch מספקת מודל לינארי מוכן. nn.Linear(1, 1, bias=True) מגדירה קלט אחד ופלט אחד, עם משקל והטיה, ומבצעת את החישוב wx+b — בדיוק הפונקציה Model שכתבנו למעלה. המספרים בסוגריים הם מספר ערכי הקלט ומספר ערכי הפלט; בפרקים הבאים נראה שכבות עם קלטים ופלטים רבים.

גם פונקציית ההפסד מוכנה: nn.MSELoss() מחשבת את ממוצע ריבועי השגיאות, בדיוק כמו הפונקציה Loss שלנו. השכבה יוצרת ומאתחלת את הפרמטרים בעצמה, בערכים אקראיים קטנים, ולכן איננו יכולים לכתוב [W, B] כבעבר; במקום זאת מעבירים לאופטימייזר את Model.parameters(), שמחזירה את כל הפרמטרים שהשכבה מחזיקה. שימו לב שהפעם נשתמש בקצב למידה גדול יותר, 0.1, ובפחות אפוקים — 100 במקום 1,000:

learning_rate = 0.1
epochs = 100
losses = []

# design model
Model = nn.Linear(1, 1, bias=True) # model = W * X + B

#construct loss and optimizer
Loss = nn.MSELoss() # Mean squared error

# init optimizer
optimizer = torch.optim.SGD(Model.parameters(), lr=learning_rate)

אימון המודל המוכן

לולאת האימון אינה משתנה כלל — זהו היופי של הגישה: החלפנו את המודל ואת פונקציית ההפסד ברכיבים מוכנים, אך הקריאות Model(X), Loss(...), backward(), step() ו־zero_grad() נשארות בדיוק כפי שהיו. נריץ את אותה לולאת אימון, הפעם בקצב 0.1 וב־101 איטרציות:

for epoch in range(epochs+1):
    # forward
    Y_predict = Model(X)

    # backward
    loss = Loss(Y_predict, Y)
    loss.backward()

    # Update parameters
    optimizer.step()

    if epoch % 10 == 0:
        print(f"epoch= {epoch} loss={loss.item():.3f} ")

    losses.append(loss.item())

    # zero grads
    optimizer.zero_grad()

הפלט השמור הבא מדגים את הירידה בהפסד; ערכי ההתחלה עשויים להשתנות מהרצה להרצה, משום שפרמטרי השכבה מאותחלים באקראי בכל פעם שיוצרים אותה.

פלט

epoch= 0 loss=9087.091 
epoch= 10 loss=508.141 
epoch= 20 loss=337.327 
epoch= 30 loss=332.720 
epoch= 40 loss=332.573 
epoch= 50 loss=332.568 
epoch= 60 loss=332.568 
epoch= 70 loss=332.568 
epoch= 80 loss=332.568 
epoch= 90 loss=332.568 
epoch= 100 loss=332.568

ההפסד צונח מכ־9,087 לכ־508 כבר אחרי עשרה אפוקים, ומהאפוק ה־50 הוא מתייצב על 332.568 ואינו משתנה עוד. זהו אותו הפסד סופי שקיבלנו במודל הידני — שני המודלים מצאו את אותו ישר. בזכות קצב הלמידה הגדול פי 10 נדרשו כאן רק כמה עשרות אפוקים במקום מאות. נציג את גרף ההפסד:

# print(losses)
plt.plot (losses)
ההפסד לאורך אימון המודל המבוסס על nn.Linear.
ההפסד לאורך אימון המודל המבוסס על nn.Linear.

הצגת התוצאה והפרמטרים

נציג את ההפסד האחרון ואת הישר שהתקבל:

print(f"END: loss={loss.item():.4f}")
plt.scatter(x_np, y_np, color='red')
plt.xlabel("x")
plt.ylabel("Y")

with torch.no_grad():
    predicted = Model(X).detach().numpy()
    plt.plot (x_np, predicted)
    plt.show()

פלט

END: loss=332.5675
הקו שנלמד באמצעות nn.Linear, לצד הנתונים.
הקו שנלמד באמצעות nn.Linear, לצד הנתונים.

במודל הידני יכולנו פשוט להדפיס את W ואת B. בשכבה של torch.nn הפרמטרים חיים בתוך האובייקט, ואפשר לגשת אליהם באמצעות named_parameters(), שמחזירה לכל פרמטר את שמו ואת ערכו. נקרא את שמות הפרמטרים ואת ערכיהם:

# print(W)
for name, param in Model.named_parameters():
    print(name, param)

פלט

weight Parameter containing:
tensor([[82.4845]], requires_grad=True)
bias Parameter containing:
tensor([54.0540], requires_grad=True)

השכבה קוראת למשקל weight ולהטיה bias. הישר שנלמד הוא אפוא בקירוב y = 82.5x + 54. ההטיה שנלמדה, 54, קרובה ל־50 — הערך שנתנו ל־bias בעת יצירת הנתונים — והפער נובע מהרעש. המשקל מופיע כמטריצה של 1×1 ולא כמספר בודד, כי nn.Linear בנויה מראש למספר כלשהו של קלטים ופלטים.

חיזוי

הגענו למטרה שלשמה עשינו את כל זה: לאחר האימון אפשר להשתמש במודל כדי לחזות תוצאות עבור קלטים חדשים, כאלה שלא היו בין 100 הנקודות. נעביר למודל שלוש דוגמאות חדשות: 0, 1 ו־1.5, ונציג את התחזיות. הקלט חייב להיות טנסור באותה צורה שעליה אומן המודל — עמודה אחת — ולכן כל דוגמה כתובה בסוגריים משלה:

test = torch.tensor(([0],[1], [1.5]), dtype=torch.float32);
with torch.no_grad():
    print(Model(test))

פלט

tensor([[ 54.0540],
        [136.5385],
        [177.7808]])

התחזיות מתיישבות עם הפרמטרים שקראנו: עבור קלט 0 המודל מחזיר בדיוק את ההטיה, 54.054; עבור קלט 1 הוא מחזיר משקל ועוד הטיה, כ־136.5; ועבור 1.5 — כ־177.8. זוהי ההכללה שדיברנו עליה בפרק הקודם: המודל לא ראה מעולם את הקלט 1.5, ובכל זאת נתן לו הערכה המבוססת על הקשר שלמד.