ג.3 טנסורים ב־PyTorch
בפרק המבוא ראינו שרשת נוירונים היא בסופו של דבר אוסף גדול של מספרים: הקלטים X, המשקלים W והפלטים Y. תמונה של חתול היא טבלה של ערכי פיקסלים; המשקלים של שכבה ברשת הם טבלה של מספרים, אחד לכל חיבור; ואוסף של אלף תמונות אימון הוא כבר "טבלה של טבלאות". כל החישוב שהרשת מבצעת — הכפלת קלטים במשקלים, חיבור, הפעלת אקטיבציה — הוא פעולות חשבון על המערכים האלה, ובאימון גם המשקלים עצמם מתעדכנים שוב ושוב.
לכן כלי הבסיס שלנו צריך להיות מבנה נתונים שמחזיק מערכים רב־ממדיים של מספרים ומבצע עליהם חישובים במהירות. בחלק א הכרנו מערכים כאלה ב־NumPy. ב־PyTorch המבנה המקביל נקרא טנסור, והוא דומה מאוד למערך NumPy אך מוסיף שתי יכולות שחסרות לו ושחיוניות לאימון רשתות: אפשרות להריץ את החישוב על GPU, ומעקב אוטומטי אחרי החישוב לצורך חישוב נגזרות. את המשמעות המלאה של הנגזרות נבין בפרקים הבאים; כאן נלמד קודם כל להחזיק את המספרים ולעבוד איתם.
ספריית PyTorch מבוססת על טנסורים — Tensors, מבני נתונים המיועדים ללמידת מכונה. טנסור הוא מערך בעל מספר ממדים, הכולל יכולות נוספות המיוחדות ללמידת מכונה:
- חישובים מתמטיים מהירים על מערכים בעלי מספר ממדים.
- שימוש ב־GPU לביצוע חישובים במקביל ולקיצור זמני החישוב.
- חישוב נגזרות — Gradients — באופן אוטומטי באמצעות
autograd. - שימוש בטנסורים לבניית רשתות נוירונים עמוקות באמצעות
torch.nn.
בפרק זה נלמד את יסודות השימוש בטנסורים: כיצד יוצרים אותם, מהם המאפיינים שלהם, איך ניגשים לאיברים ומבצעים חישובים, ואיך משנים את צורתם. רבות מהפעולות יזכירו את NumPy, וזה לא מקרה; מי שמכיר את NumPy ירגיש כאן בבית.
השיעור וההרצאות באתר של גלעד מרקמן
חומרי הליווי: 2. PyTorch Tensor · השיעור באתר הקורס · מחברת הטנסורים ב־Colab
נייבא את PyTorch ואת NumPy. את NumPy נצטרך כדי להראות את המעבר בין שני סוגי המערכים:
import torch
import numpy as np
יצירת טנסורים
הצעד הראשון הוא להכניס מספרים לטנסור. בעבודה אמיתית הנתונים מגיעים בדרך כלל מבחוץ, למשל מקובץ או מתמונה, ואילו המשקלים של הרשת מתחילים בערכים אקראיים. לכן נלמד שתי דרכים: ניצור טנסורים מתוך נתונים קיימים או באמצעות ערכים שנקבע מראש. מרשימה: נגדיר רשימה של שתי שורות ושתי עמודות ונעביר אותה ל־torch.Tensor.
data = [[1,2], [3,4]]
x = torch.Tensor(data)
print (x)
פלט
tensor([[1., 2.],
[3., 4.]])
האות T ב־Tensor גדולה. בדרך יצירה זו מתקבלים כאן מספרים בנקודה צפה, ולכן מופיעה נקודה אחרי כל מספר. מספרים בנקודה צפה הם ברירת המחדל הטבעית בלמידת מכונה, מפני שמשקלים ותוצאות ביניים כמעט אף פעם אינם שלמים.
ממערך NumPy: ניצור מערך מאותם נתונים ונמיר אותו לטנסור באמצעות torch.from_numpy.
np_array = np.array(data)
x_t = torch.from_numpy(np_array)
print(x_t)
פלט
tensor([[1, 2],
[3, 4]])
כאן הנתונים נשארים מספרים שלמים. בהמשך הפרק נראה גם את הקשר בין הזיכרון של מערך NumPy לזה של הטנסור.
יצירה עם ערכים: לעיתים קרובות נרצה טנסור בגודל נתון בלי להקליד את כל האיברים, למשל טנסור משקלים אקראיים או טנסור אפסים שיאסוף תוצאות. המשתנה shape קובע את הצורה — שתי שורות ושלוש עמודות — וכל אחת מהפונקציות הבאות ממלאת צורה כזו בדרך אחרת.
randיוצרת ערכים אקראיים בין 0 ל־1, ללא 1.randintיוצרת שלמים אקראיים; בדוגמה מ־0 עד 8.onesו־zerosממלאות את הטנסור באחדות ובאפסים.fullממלאת אותו בערך שנבחר.arange(10)יוצרת את הרצף מ־0 עד 9.
shape = (2,3)
rand_tensor = torch.rand(shape)
rand_int_tensor = torch.randint(0,9,shape)
ones_tensor = torch.ones(shape)
zeros_tensor = torch.zeros(shape)
full_tensor = torch.full((2, 3), 0.5)
range_tensor = torch.arange(10)
print(f"Random Tensor: \n {rand_tensor} \n")
print(f"Random int Tensor: \n {rand_int_tensor} \n")
print(f"Ones Tensor: \n {ones_tensor} \n")
print(f"Zeros Tensor: \n {zeros_tensor} \n")
print(f"full_tensor: \n {full_tensor} \n")
print(f"Range tensor: \n {range_tensor} \n")
הערכים האקראיים בפלט הם דוגמה; הם עשויים להשתנות בכל הרצה.
פלט
Random Tensor:
tensor([[0.1126, 0.8180, 0.6814],
[0.3566, 0.8625, 0.2720]])
Random int Tensor:
tensor([[1, 2, 5],
[2, 0, 2]])
Ones Tensor:
tensor([[1., 1., 1.],
[1., 1., 1.]])
Zeros Tensor:
tensor([[0., 0., 0.],
[0., 0., 0.]])
full_tensor:
tensor([[0.5000, 0.5000, 0.5000],
[0.5000, 0.5000, 0.5000]])
Range tensor:
tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
טנסור ללא אתחול: כשמתכוונים למלא את כל האיברים מיד לאחר היצירה, אין טעם לבזבז זמן על מילוי באפסים. empty מקצה מקום לטנסור, אך אינה קובעת את ערכי האיברים. בדוגמה הטנסור תלת־ממדי: שני "לוחות", בכל אחד שתי שורות ושלוש עמודות.
empty_tensor = torch.empty(2,2,3)
print(f"empty_tensor: \n {empty_tensor} \n")
זהו פלט אפשרי בלבד. גם אם מופיעים אפסים, אין להסתמך עליהם: הערכים בזיכרון לא אותחלו.
פלט
empty_tensor:
tensor([[[0., 0., 0.],
[0., 0., 0.]],
[[0., 0., 0.],
[0., 0., 0.]]])
המאפיינים של טנסור
כשמקבלים טנסור שלא אנחנו יצרנו, למשל פלט של שכבה ברשת, השאלות הראשונות הן: מה הצורה שלו, מאיזה סוג המספרים, והיכן הוא נמצא. שלושה מאפיינים מתארים את הטנסור:
- shape — המבנה שלו: מספר האיברים בכל ממד.
- dtype — סוג המספרים שהוא מכיל.
- device — ההתקן שבזיכרון שלו הנתונים נמצאים: CPU או GPU. בפרק זה נעבוד על ה־CPU; בעבודה עם GPU נדון בסוף הפרק.
שגיאות רבות בעבודה עם רשתות נובעות מאי־התאמה באחד משלושת המאפיינים האלה, למשל חיבור של טנסורים בצורות שונות או של טנסור על CPU עם טנסור על GPU, ולכן כדאי להתרגל לבדוק אותם.
tensor = torch.rand(3,4)
print(tensor)
print(tensor.shape)
print(tensor.dtype)
print(tensor.device)
ערכי הטנסור אקראיים; הצורה, סוג הנתונים וההתקן המוצגים כאן קבועים. בפלט נראה ש־torch.rand יוצרת כברירת מחדל מספרים מסוג float32 על ה־CPU.
פלט
tensor([[0.5128, 0.5309, 0.0430, 0.2308],
[0.4617, 0.0351, 0.7457, 0.3395],
[0.4427, 0.3195, 0.4810, 0.2737]])
torch.Size([3, 4])
torch.float32
cpu
קביעת מאפייני טנסור
עד כה PyTorch בחרה עבורנו את סוג המספרים. בעת היצירה אפשר לבחור את סוג הנתונים באמצעות הפרמטר dtype. float32 ו־float16 הם מספרים בנקודה צפה ברוחב 32 ו־16 סיביות; ככל שהרוחב קטן יותר, המספר תופס פחות זיכרון והחישוב מהיר יותר, אך הדיוק נמוך יותר. באותו אופן אפשר לבחור בעת היצירה גם את ההתקן, באמצעות הפרמטר device. כברירת מחדל הטנסור נוצר על ה־CPU, וכך נעבוד לאורך הפרק; בסוף הפרק נלמד כיצד יוצרים טנסורים על GPU.
ניצור טנסור מסוג float32 וטנסור מסוג float16:
tensor_float32 = torch.tensor(data, dtype=torch.float32)
tensor_float16 = torch.rand(size=(3, 3), dtype=torch.float16)
print("tensor_float32:")
print(tensor_float32.dtype, tensor_float32.device)
print("tensor_float16:")
print(tensor_float16.dtype, tensor_float16.device)
פלט
tensor_float32:
torch.float32 cpu
tensor_float16:
torch.float16 cpu
שני הטנסורים נוצרו על ה־CPU, כל אחד בסוג המספרים שביקשנו.
אינדקסים
לעיתים קרובות נצטרך לעבוד עם חלק מהטנסור: לשלוף דוגמה אחת מתוך אוסף הנתונים, לבחור עמודה אחת של תכונה, או לקבוע ערכים באזור מסוים. הגישה לאיברים עובדת כמו ברשימות וב־NumPy. אפשר לגשת לאיבר בטנסור באמצעות האינדקס שלו. במטריצה האינדקס הראשון בוחר שורה והשני בוחר עמודה; הספירה מתחילה ב־0. הסימן : במקום אינדקס בוחר את כל האיברים באותו ממד: tensor[:, 1] היא העמודה השנייה בכל השורות.
ניצור טנסור של אחדות בגודל 4×4. נשנה את האיבר בשורה הרביעית ובעמודה הרביעית ל־5, נאפס את העמודה השנייה, ואז נקבע את כל השורה השלישית ל־0.1.
tensor = torch.ones(4, 4)
tensor[3,3] = 5
tensor[:,1] = 0 # zero second column
tensor[2] = 0.1
print (tensor)
פלט
tensor([[1.0000, 0.0000, 1.0000, 1.0000],
[1.0000, 0.0000, 1.0000, 1.0000],
[0.1000, 0.1000, 0.1000, 0.1000],
[1.0000, 0.0000, 1.0000, 5.0000]])
ההשמה לשורה השלישית משנה גם את האפס שהיה בה בעקבות ההשמה לעמודה. כאשר נותנים אינדקס אחד בלבד למטריצה, כמו tensor[2], מקבלים שורה שלמה.
סריקת טנסור בלולאה
מכיוון ש־tensor[i] מחזיר את השורה ה־i, טנסור מתנהג כמו רשימה של שורות, ואפשר לעבור עליו בלולאת for כמו על כל אוסף ב־Python. בכל סיבוב של הלולאה החיצונית מתקבלת שורה אחת, ולולאה פנימית עוברת על האיברים שבה. נחשב כך את סכום כל האיברים בטנסור:
total = 0
for row in tensor:
for value in row:
total += value
print(total)
פלט
tensor(13.4000)
כל value הוא טנסור בעל איבר אחד, ולכן גם הסכום המתקבל הוא טנסור.
הלולאה עובדת, אך היא איטית. כל סיבוב מתבצע ב־Python, איבר אחר איבר, ובטנסור של מיליוני איברים זה זמן רב. הפעולות המובנות של PyTorch כתובות בשפת C ומבצעות את החישוב על כל הטנסור בבת אחת, ולכן הן מהירות פי מאות ואלפים, ועל GPU אף יותר. את אותו סכום מחשבת הפקודה sum בשורה אחת וללא לולאה:
print(tensor.sum())
פלט
tensor(13.4000)
הכלל: לפני שכותבים לולאה על טנסור, תמיד מחפשים פקודה של PyTorch שמבצעת את אותה פעולה ללא לולאה. כמעט תמיד יש כזו, ואת החשובות שבהן נכיר בהמשך הפרק.
חיתוך
חיתוך — Slicing בוחר קטע רציף מהטנסור באמצעות טווח start:stop, כמו ברשימות של Python: האיבר במקום start נכלל, האיבר במקום stop אינו נכלל. אפשר לתת טווח בכל ממד בנפרד.
נמשיך לעבוד עם tensor, המטריצה בגודל 4×4 שיצרנו בסעיף האינדקסים: 4 שורות ו־4 עמודות. נדפיס אותה שוב, כדי שהמטריצה השלמה תהיה מול העיניים כשנחתוך ממנה חלקים:
print(tensor)
print(tensor.shape)
פלט
tensor([[1.0000, 0.0000, 1.0000, 1.0000],
[1.0000, 0.0000, 1.0000, 1.0000],
[0.1000, 0.1000, 0.1000, 0.1000],
[1.0000, 0.0000, 1.0000, 5.0000]])
torch.Size([4, 4])
חיתוך של מטריצה מחזיר מטריצה קטנה יותר, תת־מטריצה. טווח בממד הראשון בוחר שורות, טווח בממד השני בוחר עמודות, וכשנותנים טווח בשני הממדים מקבלים את המלבן שבמפגש שלהם. נדפיס שלושה חיתוכים, ואחרי כל אחד את הצורה שלו:
print(f"rows 0-1: \n {tensor[0:2]}")
print(tensor[0:2].shape)
print(f"columns 1-2: \n {tensor[:, 1:3]}")
print(tensor[:, 1:3].shape)
print(f"rows 1-2, columns 1-2: \n {tensor[1:3, 1:3]}")
print(tensor[1:3, 1:3].shape)
פלט
rows 0-1:
tensor([[1., 0., 1., 1.],
[1., 0., 1., 1.]])
torch.Size([2, 4])
columns 1-2:
tensor([[0.0000, 1.0000],
[0.0000, 1.0000],
[0.1000, 0.1000],
[0.0000, 1.0000]])
torch.Size([4, 2])
rows 1-2, columns 1-2:
tensor([[0.0000, 1.0000],
[0.1000, 0.1000]])
torch.Size([2, 2])
tensor[0:2]נותן טווח לממד השורות בלבד: שורות 0 ו־1. לממד העמודות לא ניתן דבר, ולכן מתקבלות כל העמודות. התוצאה היא מטריצה של 2 שורות ו־4 עמודות, שתי השורות העליונות של המטריצה המקורית.tensor[:, 1:3]בוחר בעזרת:את כל השורות, ומכל שורה את העמודות 1 ו־2. התוצאה היא מטריצה של 4 שורות ו־2 עמודות, רצועה אנכית מתוך המטריצה.tensor[1:3, 1:3]נותן טווח בשני הממדים: שורות 1 ו־2 ועמודות 1 ו־2. התוצאה היא הריבוע הפנימי בגודל 2×2 שבמפגש השורות והעמודות האלה.
שימו לב שבכל שלושת המקרים התוצאה נשארה דו־ממדית. טווח, גם אם הוא בוחר שורה אחת בלבד, שומר על הממד; רק אינדקס בודד, כמו tensor[1] בסעיף הקודם, מוריד ממד.
נוסף על טווחים, יש עוד כמה דרכים לבחור שורות ועמודות. ... משלים את הממדים הקודמים. האינדקס -2 בוחר את העמודה הלפני־אחרונה. רשימת אינדקסים כמו [0, 1, 3] מאפשרת לבחור כמה שורות או עמודות.
print(f"second row: {tensor[1]}")
print(f"Third column: {tensor[:, 2]}")
print(f"Second-last column: {tensor[..., -2]}") # use ... when there are more then two dimentions
print(f"1st, 2nd & 4th row: \n {tensor[[0, 1, 3]]}")
print(f"1st, 2nd & 4th column: \n {tensor[:,[0, 1, 3]]}")
פלט
second row: tensor([1., 0., 1., 1.])
Third column: tensor([1.0000, 1.0000, 0.1000, 1.0000])
Second-last column: tensor([1.0000, 1.0000, 0.1000, 1.0000])
1st, 2nd & 4th row:
tensor([[1., 0., 1., 1.],
[1., 0., 1., 1.],
[1., 0., 1., 5.]])
1st, 2nd & 4th column:
tensor([[1.0000, 0.0000, 1.0000],
[1.0000, 0.0000, 1.0000],
[0.1000, 0.1000, 0.1000],
[1.0000, 0.0000, 5.0000]])
חיתוך של אצווה: שלושה ממדים ויותר
הדוגמאות עד כאן היו על מטריצה, אך בעבודה אמיתית כמעט אף פעם לא מזינים לרשת דוגמה אחת. מזינים אצווה — Batch: קבוצה של דוגמאות שהרשת מעבדת יחד. לכן הטנסורים שנפגוש הם לפחות תלת־ממדיים, והממד הראשון הוא תמיד ממד האצווה: batch[i] היא הדוגמה ה־i. חיתוך של אצווה הוא פעולה יום־יומית, למשל כדי לשלוף דוגמה אחת ולהסתכל עליה, לבחור ערוץ צבע אחד מכל התמונות, או לגזור חלק מכל תמונה.
נבנה אצווה קטנה: שתי "תמונות" אפורות, כל אחת בגודל 3 שורות על 4 עמודות. ברשימה שממנה נוצר הטנסור קל לראות את המבנה: רשימה של שתי מטריצות.
batch = torch.tensor([
[[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]],
[[12, 13, 14, 15],
[16, 17, 18, 19],
[20, 21, 22, 23]],
])
print(batch.shape)
פלט
torch.Size([2, 3, 4])
הצורה [2, 3, 4] נקראת כך: 2 דוגמאות באצווה, לכל דוגמה 3 שורות ו־4 עמודות. בחיתוך נותנים אינדקס או טווח לכל ממד לפי הסדר הזה: הראשון בוחר דוגמאות, השני שורות, השלישי עמודות. הסימן : בממד הראשון פירושו "כל הדוגמאות באצווה".
print(f"first sample: \n {batch[0]}")
print(f"first row of every sample: \n {batch[:, 0]}")
print(f"last column of every sample: \n {batch[..., -1]}")
print(f"rows 1-2, columns 0-1 of every sample: \n {batch[:, 1:3, 0:2]}")
print(batch[:, 1:3, 0:2].shape)
פלט
first sample:
tensor([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
first row of every sample:
tensor([[ 0, 1, 2, 3],
[12, 13, 14, 15]])
last column of every sample:
tensor([[ 3, 7, 11],
[15, 19, 23]])
rows 1-2, columns 0-1 of every sample:
tensor([[[ 4, 5],
[ 8, 9]],
[[16, 17],
[20, 21]]])
torch.Size([2, 2, 2])
batch[0]נותן אינדקס לממד הראשון בלבד ומחזיר את הדוגמה הראשונה, מטריצה של 3×4. כל אינדקס בודד מוריד ממד אחד.batch[:, 0]בוחר את כל הדוגמאות, ומכל אחת את השורה הראשונה. התוצאה היא מטריצה של 2×4: שורה לכל דוגמה.batch[..., -1]בוחר את העמודה האחרונה....משלים את כל הממדים שלפני האחרון, ולכן זה קיצור ל־batch[:, :, -1]. הקיצור שימושי במיוחד כשאיננו יודעים מראש כמה ממדים יש לטנסור.batch[:, 1:3, 0:2]גוזר מכל דוגמה ריבוע של 2×2. טווח, בניגוד לאינדקס בודד, שומר על הממד, ולכן התוצאה נשארת תלת־ממדית:[2, 2, 2].
תמונות צבעוניות מוסיפות ממד לערוצי הצבע, והאצווה היא בת ארבעה ממדים: [batch, channels, height, width]. הכללים אינם משתנים. אם images היא אצווה כזו, images[0] היא התמונה הראשונה על כל ערוציה, images[:, 0] הוא הערוץ הראשון (אדום) של כל התמונות, ו־images[:, :, 0:14, :] הוא החצי העליון של כל התמונות אם גובהן 28 פיקסלים. בכל פעם שמתקבל טנסור לא מוכר, כדאי להדפיס את shape שלו לפני החיתוך ואחריו, ולוודא שהממדים שנשארו הם אלה שהתכוונו אליהם.
פעולות מתמטיות
כאן נמצא הכוח האמיתי של טנסורים. במקום לכתוב לולאה שעוברת על כל איבר, כותבים פעולה אחת על הטנסור כולו, ו־PyTorch מבצעת אותה על כל האיברים, במקביל אם הטנסור על GPU. כך נראה בהמשך חישוב של שכבה שלמה ברשת בשורה אחת. אפשר לבצע פעולות מתמטיות על טנסורים שלמים. בדוגמאות הבאות כל פעולה מתבצעת בין איברים במקומות המתאימים.
ניצור טנסור של אחדות וטנסור של שתיים, שניהם בגודל 3×3, ונחבר אותם:
x = torch.ones(3,3)
y = torch.full((3,3), 2)
print (x,"\n", y)
z1 = x + y
print (z1)
פלט
tensor([[1., 1., 1.],
[1., 1., 1.],
[1., 1., 1.]])
tensor([[2, 2, 2],
[2, 2, 2],
[2, 2, 2]])
tensor([[3., 3., 3.],
[3., 3., 3.],
[3., 3., 3.]])
נכפול את האיברים המתאימים:
z2 = x * y
print (z2)
פלט
tensor([[2., 2., 2.],
[2., 2., 2.],
[2., 2., 2.]])
אפשר לשלב גם פעולה בין טנסור למספר בודד: המספר מופעל על כל איבר בנפרד. נעלה כל איבר ב־y בריבוע ונוסיף לו 5:
z3 = y**2 + 5
print(z3, "\n")
פלט
tensor([[9, 9, 9],
[9, 9, 9],
[9, 9, 9]])
פעולות המחזירות ערך בודד
באימון נרצה לעיתים קרובות לכווץ טנסור שלם למספר אחד, למשל לחשב את ממוצע הטעות על כל הדוגמאות כדי להדפיס אותו או לצייר גרף. פעולות כמו סכום וממוצע מחזירות טנסור בעל איבר יחיד. כדי לקבל ממנו מספר פייתון רגיל משתמשים ב־item().
data = torch.tensor([[1,2,3],[4,5,6]], dtype=torch.float32)
sum = data.sum()
mean = data.mean()
print("sum: ", sum)
print("mean: ", mean.item())
n = mean.item()
פלט
sum: tensor(21.)
mean: 3.5
הסכום מוצג כטנסור, ואילו הממוצע מוצג כמספר לאחר item(). קריאה ל־item() על טנסור בעל כמה איברים תגרום לשגיאה.
Transpose — שחלוף
בפרק המבוא כתבנו את הסכום המשוקלל של נוירון כ־WᵀX. האות T הקטנה מסמנת שחלוף — Transpose, פעולה נפוצה מאוד בחישובי רשתות, ולכן כדאי להכיר אותה כבר עכשיו. שחלוף הופך את שורות המטריצה לעמודות: שורה 0 הופכת לעמודה 0, ושורה 1 לעמודה 1. זו החלפה בין שורות לעמודות, ולא סיבוב של המטריצה.
x = torch.tensor([[1,2,3],[4,5,6]])
print (x)
x_trans = x.T
print (x.T)
print(x)
פלט
tensor([[1, 2, 3],
[4, 5, 6]])
tensor([[1, 4],
[2, 5],
[3, 6]])
tensor([[1, 2, 3],
[4, 5, 6]])
x.T מחזירה את המטריצה המשוחלפת. כפי שרואים בהדפסה האחרונה, הצורה של x עצמו נשארה שתי שורות ושלוש עמודות.
פעולות in-place
עד כה כל פעולה יצרה טנסור חדש והשאירה את המקורי ללא שינוי. לפעמים נרצה דווקא לשנות את הטנסור הקיים, למשל כדי לעדכן את המשקלים במקום בזמן האימון בלי להקצות זיכרון חדש בכל צעד. פעולות המשנות את הטנסור המקורי נקראות in-place. בדרך כלל מסמנים אותן בקו תחתון בסוף שם הפעולה.
תחילה נוסיף 5 באמצעות ביטוי והשמה:
x = torch.tensor([[1,2,3],[4,5,6]])
print (x)
x = x + 5
print (x)
פלט
tensor([[1, 2, 3],
[4, 5, 6]])
tensor([[ 6, 7, 8],
[ 9, 10, 11]])
הביטוי x + 5 יוצר תוצאה חדשה, וההשמה קושרת אליה את השם x. זו אינה פעולת in-place.
כעת נוסיף עוד 5 באמצעות add_, ולאחר מכן נשחלף את הטנסור עצמו באמצעות t_:
x.add_(5)
x.t_()
print (x)
פלט
tensor([[11, 14],
[12, 15],
[13, 16]])
שתי הפעולות משנות את x עצמו. הערכים בפלט כוללים את שתי ההוספות של 5: אחת בכל קטע קוד.
Reshape — שינוי צורה
הרשת מצפה לקלט בצורה מסוימת, והנתונים לא תמיד מגיעים בצורה הזו. תמונה, למשל, מגיעה כטבלה של שורות ועמודות של פיקסלים, אך לעיתים נצטרך "לשטח" אותה לשורה אחת ארוכה של מספרים לפני שנזין אותה לרשת. שינוי צורה מארגן את אותם נתונים בממדים אחרים. הפעולה reshape עושה זאת תוך שמירה על מספר האיברים. אם מציינים ממד אחד כ־-1, גודלו מחושב אוטומטית מתוך מספר האיברים והממדים האחרים.
a = torch.arange(12)
a1 = a.reshape(-1,1) # (12,1)
a2 = a.reshape(4,3)
a3 = torch.reshape(a1, (2,2,3))
a4 = a.reshape(2, 2, -1)
print("a", a, a.shape)
print ("a1", a1, a1.shape)
print ("a2", a2, a2.shape)
print ("a3", a3, a3.shape)
print ("a4", a4, a4.shape)
פלט
a tensor([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) torch.Size([12])
a1 tensor([[ 0],
[ 1],
[ 2],
[ 3],
[ 4],
[ 5],
[ 6],
[ 7],
[ 8],
[ 9],
[10],
[11]]) torch.Size([12, 1])
a2 tensor([[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8],
[ 9, 10, 11]]) torch.Size([4, 3])
a3 tensor([[[ 0, 1, 2],
[ 3, 4, 5]],
[[ 6, 7, 8],
[ 9, 10, 11]]]) torch.Size([2, 2, 3])
a4 tensor([[[ 0, 1, 2],
[ 3, 4, 5]],
[[ 6, 7, 8],
[ 9, 10, 11]]]) torch.Size([2, 2, 3])
בכל התוצאות נשארו אותם 12 איברים. ב־a1 הם מסודרים בעמודה, ב־a2 בארבע שורות ושלוש עמודות, וב־a3 וב־a4 בשלושה ממדים שגודלם 2×2×3.
View
בקוד של PyTorch נפגוש לעיתים קרובות גם את view, שנראית דומה ל־reshape, ולכן כדאי להבין את ההבדל. גם view מאפשרת להציג נתונים בצורה אחרת. בשינוי צורה היא חולקת את אותו זיכרון עם הטנסור המקורי, ודורשת שסידור הנתונים בזיכרון יתאים לצורה המבוקשת. reshape יכולה להעתיק את הנתונים כאשר נדרש.
v = torch.arange(12)
v1 = v.view(-1,1)
v2 = v.view(4,3)
v3 = v.view(torch.float32)
v4 = v.view(-1, 1, 3)
print ("v", v.shape, v.dtype)
print ("v1", v1.shape)
print ("v2", v2.shape)
print ("v3", v3.shape, v3.dtype)
print ("v4", v4.shape)
פלט
v torch.Size([12]) torch.int64
v1 torch.Size([12, 1])
v2 torch.Size([4, 3])
v3 torch.Size([24]) torch.float32
v4 torch.Size([4, 1, 3])
v1, v2 ו־v4 משנות את הצורה. לעומתן, v.view(torch.float32) מפרשת את אותם בתים כ־float32; זו אינה המרה מספרית של הערכים. לכן מתקבלים 24 איברים של 32 סיביות במקום 12 איברים של 64 סיביות.
Permute — שינוי סדר הממדים
לפעמים הנתונים מסודרים בממדים הנכונים אך בסדר לא נכון. תמונה צבעונית, למשל, יכולה להישמר כשורות, עמודות וערוצי צבע, בעוד שהרשת מצפה לקבל תחילה את ערוצי הצבע. במקרה כזה לא רוצים לחלק את האיברים מחדש אלא רק להחליף את סדר הממדים. הפעולה torch.permute מחליפה את סדר הממדים בטנסור. בסדר (2, 0, 1), הממד שהיה שלישי עובר למקום הראשון, הראשון לשני והשני לשלישי.
c = torch.randn(1,3,2)
c1 = torch.permute(c, (2, 0, 1)) # סדר המימדים
print(c.size())
print(c1.size())
print(c)
print(c1)
הערכים האקראיים הם דוגמה; שינוי הצורה הוא מ־1×3×2 ל־2×1×3.
פלט
torch.Size([1, 3, 2])
torch.Size([2, 1, 3])
tensor([[[-0.2273, -2.1568],
[ 0.7375, 1.3695],
[-0.3904, -1.8550]]])
tensor([[[-0.2273, 0.7375, -0.3904]],
[[-2.1568, 1.3695, -1.8550]]])
בניגוד לחלוקה מחדש של האיברים באמצעות reshape, כאן משנים את סדר הממדים.
Squeeze ו־Unsqueeze — הסרה והוספה של ממד באורך 1
בסעיף החיתוך ראינו שהרשת מקבלת תמיד אצווה, כלומר טנסור שהממד הראשון שלו הוא ממד האצווה. אבל מה עושים כשיש בידינו דוגמה אחת בלבד, למשל תמונה אחת שרוצים לבדוק? הרשת עדיין מצפה לאצווה, ולכן צריך להוסיף לתמונה ממד אצווה באורך 1: מתמונה בצורה [3, 4] לאצווה בצורה [1, 3, 4]. הפעולה unsqueeze(dim) מוסיפה ממד באורך 1 במקום dim, והפעולה ההפוכה squeeze(dim) מסירה ממד באורך 1. שתיהן אינן משנות את הנתונים, רק את הצורה.
img = torch.rand(3, 4)
batch = img.unsqueeze(0)
print(img.shape, batch.shape)
back = batch.squeeze(0)
print(back.shape)
פלט
torch.Size([3, 4]) torch.Size([1, 3, 4])
torch.Size([3, 4])
כדי לראות מה קורה לאיברים עצמם, ניקח טנסור קטן עם ערכים ידועים:
t = torch.tensor([[1, 2, 3]])
print(t.shape)
print(t.squeeze(0), t.squeeze(0).shape)
print(t.unsqueeze(2), t.unsqueeze(2).shape)
פלט
torch.Size([1, 3])
tensor([1, 2, 3]) torch.Size([3])
tensor([[[1],
[2],
[3]]]) torch.Size([1, 3, 1])
t היא מטריצה עם שורה אחת, בצורה [1, 3]. squeeze(0) מסיר את ממד השורות שאורכו 1 ומשאיר וקטור של שלושה איברים. unsqueeze(2) מוסיף ממד חדש בסוף, וכל איבר הופך לרשימה בת איבר אחד.
שימוש נפוץ נוסף הוא בפלט של הרשת. רשת שמחזירה מספר אחד לכל דוגמה מוציאה טנסור בצורה [batch, 1], ואילו טנסור התשובות הוא לרוב בצורה [batch]. כדי להשוות ביניהם מסירים את הממד המיותר: output.squeeze(1).
זהירות: squeeze() ללא ציון ממד מסיר את כל הממדים שאורכם 1, ובאצווה של דוגמה אחת ייעלם גם ממד האצווה. לכן עדיף תמיד לציין את הממד שרוצים להסיר:
output = torch.zeros(1, 1) # batch of one sample, one number per sample
print(output.squeeze().shape) # all size-1 dims removed
print(output.squeeze(1).shape) # only the last dim removed
פלט
torch.Size([])
torch.Size([1])
בשורה הראשונה נשאר טנסור ללא ממדים כלל, מספר בודד; ממד האצווה נעלם. בשורה השנייה נשאר ממד האצווה באורך 1, כפי שרצינו.
Cat ו־Stack — שרשור וערימה של טנסורים
כשנתונים מגיעים בחלקים, למשל דוגמאות משני קבצים שונים, נרצה לאחד אותם לטנסור אחד. הפעולה cat מחברת טנסורים לאורך ממד שנבחר. כך אפשר להוסיף שורות או עמודות; הגדלים בשאר הממדים צריכים להתאים.
x1 = torch.ones(1,4)
x2 = torch.full((1,4) , 2)
print (x1, x2)
x3 = torch.cat((x1,x2),0)
x4 = torch.cat((x1,x2),1)
print (x3)
print (x4)
פלט
tensor([[1., 1., 1., 1.]]) tensor([[2, 2, 2, 2]])
tensor([[1., 1., 1., 1.],
[2., 2., 2., 2.]])
tensor([[1., 1., 1., 1., 2., 2., 2., 2.]])
בחיבור לאורך ממד 0 מתקבלות שתי שורות וארבע עמודות. בחיבור לאורך ממד 1 מתקבלת שורה אחת ובה שמונה עמודות.
vstack ו־hstack
לשני המקרים הנפוצים יש קיצורים בשמות מוכרים מ־NumPy: vstack (vertical) מחבר לאורך ממד 0, כלומר מוסיף שורות, ו־hstack (horizontal) מחבר לאורך ממד 1, כלומר מוסיף עמודות. עם x1 ו־x2 מהדוגמה הקודמת הן נותנות בדיוק את x3 ואת x4:
x5 = torch.vstack((x1, x2))
x6 = torch.hstack((x1, x2))
print(x5, x5.shape)
print(x6, x6.shape)
פלט
tensor([[1., 1., 1., 1.],
[2., 2., 2., 2.]]) torch.Size([2, 4])
tensor([[1., 1., 1., 1., 2., 2., 2., 2.]]) torch.Size([1, 8])
ההבדל בין vstack ל־cat מתגלה בווקטורים. cat של שני וקטורים מחבר אותם לווקטור ארוך אחד, ואילו vstack מתייחס לכל וקטור כשורה ובונה מהם מטריצה:
v1 = torch.tensor([1, 2, 3])
v2 = torch.tensor([4, 5, 6])
print(torch.cat((v1, v2)))
print(torch.vstack((v1, v2)))
print(torch.hstack((v1, v2)))
פלט
tensor([1, 2, 3, 4, 5, 6])
tensor([[1, 2, 3],
[4, 5, 6]])
tensor([1, 2, 3, 4, 5, 6])
Stack — ערימה לממד חדש
cat מחבר טנסורים לאורך ממד קיים, ולכן מספר הממדים אינו משתנה. הפעולה stack עושה משהו אחר: היא מערימה טנסורים בעלי צורה זהה זה על זה ויוצרת ממד חדש. זו הדרך המקובלת לבנות אצווה מדוגמאות בודדות: מרשימה של תמונות, כל אחת בצורה [3, 4], מתקבלת אצווה בצורה [2, 3, 4]. השוו ל־cat, שהיה מדביק את שתי התמונות לתמונה אחת ארוכה:
img1 = torch.rand(3, 4)
img2 = torch.rand(3, 4)
batch = torch.stack((img1, img2))
print(batch.shape)
print(torch.cat((img1, img2)).shape)
פלט
torch.Size([2, 3, 4])
torch.Size([6, 4])
הפרמטר dim קובע היכן ייווצר הממד החדש. עם הווקטורים v1 ו־v2, ערימה ב־dim=0 מסדרת אותם כשורות, וערימה ב־dim=1 מסדרת אותם כעמודות:
s0 = torch.stack((v1, v2), dim=0)
s1 = torch.stack((v1, v2), dim=1)
print(s0, s0.shape)
print(s1, s1.shape)
פלט
tensor([[1, 2, 3],
[4, 5, 6]]) torch.Size([2, 3])
tensor([[1, 4],
[2, 5],
[3, 6]]) torch.Size([3, 2])
לסיכום: cat, vstack ו־hstack מאריכים ממד קיים; stack מוסיף ממד; unsqueeze מוסיף ממד באורך 1 לטנסור בודד. stack של רשימת דוגמאות שקול ל־unsqueeze(0) לכל דוגמה ואחר כך cat לאורך ממד 0.
המרה בין טנסור למערך NumPy
ספריות רבות שהכרנו, כמו PyPlot לציור גרפים, עובדות עם מערכי NumPy ולא עם טנסורים. לכן נצטרך לא פעם להמיר טנסור למערך NumPy ולהפך, ובתחילת הפרק הבטחנו להסביר מה קורה אז לזיכרון. טנסור CPU מתאים ומערך NumPy יכולים לשתף את אותם נתונים בזיכרון. בדוגמה הבאה ההמרה אינה מעתיקה את הנתונים, ולכן שינוי באחד מהם נראה גם באחר.
ניצור טנסור של חמישה ערכים:
t = torch.full ((5,), 2.25)
print(t)
פלט
tensor([2.2500, 2.2500, 2.2500, 2.2500, 2.2500])
נמיר אותו למערך NumPy, נכפיל את הטנסור המקורי ב־5 במקום ונדפיס את המערך:
n = t.numpy()
t.mul_(5)
print(n)
פלט
[11.25 11.25 11.25 11.25 11.25]
גם ערכי n השתנו, משום שהוא חולק זיכרון עם t. אפשר ליצור ממנו שוב טנסור:
t1 = torch.from_numpy(n)
print(t1)
פלט
tensor([11.2500, 11.2500, 11.2500, 11.2500, 11.2500])
שיתוף הזיכרון חוסך זמן והעתקות, אך דורש זהירות: שינוי במקום באחד מהם משנה גם את השני, לעיתים בלי שהתכוונו לכך.
טנסורים ו־GPU
בפתיחת הפרק אמרנו שאחד היתרונות של טנסור על פני מערך NumPy הוא היכולת להריץ את החישוב על GPU. עד כאן כל הטנסורים שלנו נוצרו על ה־CPU, וזה מספיק לכל הדוגמאות בפרק. באימון של רשת אמיתית, על אלפי תמונות ומיליוני משקלים, ה־GPU מקצר את זמן החישוב פי עשרות, ולכן נסיים את הפרק בכלים הדרושים לעבודה איתו.
CUDA היא הטכנולוגיה של NVIDIA להרצת חישובים כלליים על המעבד הגרפי, וזהו השם שבו PyTorch מתייחסת ל־GPU. במאפיין device של טנסור, ובפרמטר device בעת היצירה, "cpu" מציין את המעבד המרכזי ו־"cuda" מציין GPU של NVIDIA.
המשתנה device
לא בכל מחשב יש GPU, ולכן לא כותבים device="cuda" ישירות בקוד. המקובל ב־PyTorch הוא להגדיר בתחילת התוכנית משתנה בשם device: בודקים מה יש במחשב, שומרים במשתנה את הערך המתאים, ומכאן ואילך יוצרים את הטנסורים עם device=device. הבדיקה torch.cuda.is_available() מחזירה True כאשר אפשר להשתמש ב־CUDA. כך אותו קוד רץ ללא שינוי גם במחשב עם GPU וגם במחשב בלעדיו.
device = "cuda" if torch.cuda.is_available() else "cpu"
print("device:", device)
tensor_float32 = torch.tensor(
[[1, 2], [3, 4]], dtype=torch.float32, device=device
)
print(tensor_float32.dtype, tensor_float32.device)
השורה הראשונה מכניסה למשתנה device את המחרוזת "cuda" אם יש GPU זמין, ואחרת את המחרוזת "cpu". במחשב עם GPU יופיע ההתקן cuda:0; הספרה 0 מציינת את ה־GPU הראשון. במחשב ללא GPU יופיע cpu, והקוד ירוץ באותה צורה. את המשתנה device נגדיר פעם אחת בתחילת התוכנית ונשתמש בו בכל מקום שבו צריך לציין התקן.
העברת טנסור בין התקנים
ל־GPU יש זיכרון משלו, נפרד מזיכרון המחשב, וכדי שה־GPU יבצע חישוב על טנסור, הטנסור חייב להימצא בזיכרון הזה. בפרק הקודם למדנו לבחור GPU ב־Colab; כעת נראה כיצד מעבירים אליו נתונים. אפשר להעביר טנסור מהזיכרון המשמש את ה־CPU לזיכרון ה־GPU ולהפך. משתמשים בפעולה to ושומרים את הטנסור שהיא מחזירה.
tensor_cpu = torch.rand(3, 3)
tensor_device = tensor_cpu.to(device)
print("tensor_device:", tensor_device.device)
tensor_back = tensor_device.to("cpu")
print("tensor_back:", tensor_back.device)
tensor_device מכיל עותק על ההתקן שנשמר במשתנה device, כלומר על ה־GPU אם הוא זמין, ו־tensor_back מכיל עותק שהועבר חזרה ל־CPU. שימו לב ש־to אינה משנה את הטנסור המקורי אלא מחזירה טנסור חדש; לכן חייבים לשמור את התוצאה במשתנה. חישוב יכול להתבצע רק בין טנסורים שנמצאים על אותו התקן.
העברת רשת ונתונים ל־GPU
כדי לבצע חישוב של רשת נוירונים על GPU, צריך להעביר לזיכרון שלו גם את רשת הנוירונים, ובכלל זה המשקלים (W), וגם את הנתונים — הטנסורים שעליהם הרשת תבצע את החישוב. באימון מעבירים גם את טנסור התשובות הרצויות, כך שהרשת, הקלט והתשובות נמצאים על אותו התקן.
ההעברה נעשית באמצעות to(device), עם המשתנה device שהגדרנו קודם. הקטע הבא מניח שכבר נוצרו רשת בשם model, טנסור קלט בשם inputs וטנסור תשובות בשם targets; את יצירת הרשת נלמד בהמשך:
model = model.to(device)
inputs = inputs.to(device)
targets = targets.to(device)
model.to(device) מעבירה את הרשת להתקן שנבחר; inputs.to(device) ו־targets.to(device) מעבירות אליו את טנסורי הקלט והתשובות, וההשמות שומרות את הטנסורים המוחזרים. בחירת GPU בתפריט של Colab לבדה אינה מעבירה אליו את הרשת או את הטנסורים.
יש לנו עתה כלי להחזיק את הנתונים ואת המשקלים ולבצע עליהם חישובים. אך כדי לאמן רשת לא די בחישוב הפלט; צריך גם לדעת כיצד לשנות את המשקלים כדי להקטין את הטעות, ולשם כך נחוצות נגזרות. בפרק הבא נכיר את autograd ונלמד כיצד מחשבים נגזרות באמצעות טנסורים.