א.21 — NumPy — מערכים וחישובים

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

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

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

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

פתיחת המחברת ב־Colab · השיעור וההרצאה באתר הקורס

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

מחברות האוניברסיטה הפתוחה — 14 חבילות

ייבוא הספרייה

כדי להשתמש בפונקציות של NumPy, מייבאים את הספרייה. הכתיבה as np נותנת לה את השם הקצר np, שבו נשתמש בכל הדוגמאות. הוראת הייבוא מכינה את הספרייה לשימוש; יצירת המערכים תיעשה בהמשך.

import numpy as np

יצירת מערך

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

יצירת מערך מרשימה

הפונקציה np.array מקבלת את הרשימה ומחזירה מערך עם הערכים שלה, באותו סדר. בדוגמה ניצור מערך של שלושה ציונים ונשמור אותו במשתנה scores.

scores = np.array([10, 20, 30])
print(scores)
print(type(scores))

פלט

[10 20 30]
<class 'numpy.ndarray'>

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

יצירת מערך מטאפל

אפשר ליצור מערך גם מטאפל (tuple). מעבירים ל־np.array את הטאפל בתוך הסוגריים של הקריאה לפונקציה. הסוגריים הפנימיים בדוגמה מגדירים את הטאפל.

values_tuple = (10, 20, 30)
scores_from_tuple = np.array(values_tuple)
print(scores_from_tuple)

פלט

[10 20 30]

גם כאן התוצאה היא מערך NumPy. יצירת מערך מרצף בתיעוד NumPy.

יצירת מערך של אפסים

לא תמיד יש לנו מראש את הערכים. לעיתים קרובות אנחנו יודעים רק כמה מקומות דרושים — למשל לוח משחק ריק או מונה לכל תלמיד — ורוצים למלא אותם בהמשך. כאשר יודעים כמה מקומות דרושים ורוצים שכולם יתחילו באפס, משתמשים ב־np.zeros. המספר 4 בדוגמה מבקש ארבעה איברים. הפרמטר dtype=int מבקש לשמור אותם כמספרים שלמים; בהמשך הפרק נרחיב על טיפוס הנתונים.

zeros = np.zeros(4, dtype=int)
print(zeros)

פלט

[0 0 0 0]

יצירת מערך של אחדים

באופן דומה, np.ones יוצרת מערך שכל איבריו מתחילים בערך 1. בדוגמה ניצור ארבעה אחדים.

ones = np.ones(4, dtype=int)
print(ones)

פלט

[1 1 1 1]

ללא dtype=int, הפונקציות zeros ו־ones יוצרות כברירת מחדל מספרים מסוג float, למשל 0. או 1.. מערך של אחדים בתיעוד NumPy.

יצירת מערך עם ערך קבוע

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

filled = np.full(4, 5, dtype=int)
print(filled)

פלט

[5 5 5 5]

המילה „קבוע” מתארת את ערך המילוי המשותף בזמן היצירה. אפשר לשנות את איברי המערך לאחר מכן.

ממדים וצורה

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

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

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

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

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

הצורה של המערך מתארת את הגודל לאורך כל ציר, לפי הסדר. לדוגמה, (2, 3) פירושה שתי שורות ושלוש עמודות. הצורה נמסרת כטאפל: מספר האיברים בטאפל הוא מספר הממדים, והערכים בו הם הגדלים שלהם.

ניצור שלושה מערכים כדי לראות זאת בקוד. ליצירת מטריצה נעביר רשימה של שורות, כשכל שורה היא רשימה באותו אורך. ליצירת מערך תלת־ממדי נוסיף רמה חיצונית של רשימות — רשימת הטבלאות. המאפיין ndim מחזיר את מספר הממדים, והמאפיין shape מחזיר את הצורה.

vector = np.array([10, 20, 30])
matrix = np.array([
    [1, 2, 3],
    [4, 5, 6]
])
layers = np.array([
    [[1, 2, 3], [4, 5, 6]],
    [[7, 8, 9], [10, 11, 12]]
])

print(vector.ndim, vector.shape)
print(matrix.ndim, matrix.shape)
print(layers.ndim, layers.shape)

פלט

1 (3,)
2 (2, 3)
3 (2, 2, 3)
מערך מספר ממדים צורה משמעות הצורה מספר איברים כולל
vector 1 (3,) רצף של שלושה איברים 3
matrix 2 (2, 3) שתי שורות, שלוש עמודות 2 * 3 = 6
layers 3 (2, 2, 3) שתי שכבות, בכל אחת שתי שורות ושלוש עמודות 2 * 2 * 3 = 12

הפסיק ב־(3,) מציין טאפל בעל איבר אחד; הוא אינו מציין ממד נוסף. במערך תלת־ממדי אין חובה שכל גדלי הממדים יהיו שווים.

אפשר לפרק את הצורה של המטריצה לשני משתנים ולקבל את מספר השורות והעמודות:

rows, columns = matrix.shape
print(rows, columns)

פלט

2 3

כעת אפשר גם ליצור טבלת אפסים או טבלה בעלת ערך אחיד. במקום מספר יחיד מעבירים לפונקציה את הצורה (2, 3):

board = np.zeros((2, 3), dtype=int)
filled_table = np.full((2, 3), 5)
print(board)
print(filled_table)

פלט

[[0 0 0]
 [0 0 0]]
[[5 5 5]
 [5 5 5]]

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

scalar = np.array(7)
print(scalar.ndim, scalar.shape)

פלט

0 ()

הטאפל הריק () מציין שאין למערך צירים. זו אינה צורה של רצף בן איבר אחד: np.array([7]) הוא מערך חד־ממדי שצורתו (1,).

גישה לאיבר במערך

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

גישה במערך חד־ממדי

ברצף הבא יש שלושה איברים, ולכן האינדקסים שלהם הם 0, 1 ו־2. אינדקס שלילי סופר מהסוף: -1 מציין את האיבר האחרון, ו־-2 את הלפני־אחרון.

scores = np.array([10, 20, 30])
print(scores[0])
print(scores[2])
print(scores[-1])

פלט

10
30
30

הגישה scores[2] מחזירה את האיבר השלישי, שערכו 30. המספר שבסוגריים הוא מיקום ולא הערך שמחפשים. גישה ל־scores[3] תגרום לשגיאת IndexError, משום שאין כאן איבר רביעי.

גישה במערך דו־ממדי

בטבלה מציינים תחילה אינדקס שורה ואחריו אינדקס עמודה: matrix[row, column]. במערך הבא השורה באינדקס 0 מכילה את 1, 2, 3, והשורה באינדקס 1 מכילה את 4, 5, 6. בכל שורה העמודות ממוספרות 0, 1, 2.

matrix = np.array([
    [1, 2, 3],
    [4, 5, 6]
])
print(matrix[0, 1])
print(matrix[1, 2])
print(matrix[-1, -1])

פלט

2
6
6

בביטוי matrix[1, 2] בוחרים את השורה השנייה, ואז את העמודה השלישית בתוכה, ומקבלים 6. בביטוי matrix[-1, -1] בוחרים את השורה האחרונה ואת העמודה האחרונה.

גישה במערך תלת־ממדי

באוסף טבלאות מציינים שלושה אינדקסים לפי הסדר: layers[layer, row, column]. הראשון בוחר שכבה, השני שורה בתוך השכבה, והשלישי עמודה בתוך השורה. בדוגמה השכבה באינדקס 0 מכילה את המספרים 1 עד 6, והשכבה באינדקס 1 מכילה את 7 עד 12.

layers = np.array([
    [[1, 2, 3], [4, 5, 6]],
    [[7, 8, 9], [10, 11, 12]]
])
print(layers[0, 1, 0])
print(layers[1, 0, 2])
print(layers[-1, -1, -1])

פלט

4
9
12

כדי להבין את layers[1, 0, 2], נעקוב אחרי הבחירה: שכבה 1 היא הטבלה השנייה; שורה 0 בתוכה היא [7, 8, 9]; עמודה 2 היא האיבר השלישי בשורה, כלומר 9.

בכל ציר בודקים את האינדקס מול גודלו של אותו ציר. כאן הצורה היא (2, 2, 3), ולכן האינדקסים הלא־שליליים לשכבה ולשורה הם 0 או 1, ולעמודה הם 0, 1 או 2.

חיתוך מערך

לפעמים נרצה כמה איברים יחד: למשל, שלושה ציונים רצופים או כמה עמודות מתוך טבלה. חיתוך (slicing) בוחר חלק מהמערך באמצעות טווח מיקומים. הוא אינו מוחק את יתר האיברים מהמערך המקורי.

בחירת טווח במערך חד־ממדי

התחביר הבסיסי הוא values[start:stop]: מתחילים באינדקס start ומתקדמים עד לפני אינדקס stop. אינדקס ההתחלה נכלל; אינדקס הסיום אינו נכלל.

בדוגמה נבחר את האיברים באינדקסים 1, 2 ו־3 מתוך רצף של שישה מספרים:

values = np.array([10, 20, 30, 40, 50, 60])
part = values[1:4]
print(part)

פלט

[20 30 40]

הערך 20 נמצא באינדקס 1, ולכן הוא הראשון שנבחר. הערך 50 נמצא באינדקס 4, ולכן הוא כבר מחוץ לטווח. המספרים שבחיתוך מציינים מיקומים, לא את ערכי האיברים.

השמטת ההתחלה או הסיום

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

print(values[:3])
print(values[3:])
print(values[:])

פלט

[10 20 30]
[40 50 60]
[10 20 30 40 50 60]

בחיתוך values[:3] נבחרים אינדקסים 0, 1, 2. בחיתוך values[3:] מתחילים באינדקס 3, ולכן הערך הראשון הוא 40.

קביעת צעד

הצעד קובע בכמה מקומות מתקדמים בכל פעם, וכך מאפשר לדלג בין איברים. כותבים אותו בחלק השלישי: values[start:stop:step]. צעד 1 הוא ברירת המחדל; צעד 2 בוחר איבר, מדלג על הבא, ושוב בוחר איבר.

ב־values[::2] מתחילים באינדקס 0 ובוחרים את אינדקסים 0, 2, 4. ב־values[1:6:2] מתחילים באינדקס 1 ובוחרים את 1, 3, 5:

print(values[::2])
print(values[1:6:2])

פלט

[10 30 50]
[20 40 60]

צעד שלילי מתקדם לאחור. הביטוי values[::-1] משמיט את שני הגבולות ומשתמש בצעד -1, ולכן בוחר את כל האיברים מהאחרון לראשון:

print(values[::-1])

פלט

[60 50 40 30 20 10]

חיתוך במערך דו־ממדי

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

נשתמש בטבלה בעלת שלוש שורות וארבע עמודות. תחילה נבחר שורה שלמה, ואחר כך עמודה שלמה:

table = np.array([
    [1, 2, 3, 4],
    [5, 6, 7, 8],
    [9, 10, 11, 12]
])
print(table[1, :])
print(table[:, 2])

פלט

[5 6 7 8]
[ 3  7 11]

ב־table[1, :] האינדקס 1 בוחר את השורה השנייה, והנקודתיים בוחרות את כל עמודותיה. ב־table[:, 2] הנקודתיים בוחרות את כל השורות, והאינדקס 2 בוחר את העמודה השלישית. בשני המקרים מתקבל רצף חד־ממדי.

כעת נבחר מלבן מתוך הטבלה: שתי השורות הראשונות, ובכל אחת העמודות באינדקסים 1 ו־2. לשם כך נשתמש בחיתוך בכל אחד משני הצירים:

print(table[:2, 1:3])

פלט

[[2 3]
 [6 7]]

בחירת השורות :2 כוללת את שורות 0 ו־1. בחירת העמודות 1:3 כוללת את עמודות 1 ו־2. התוצאה היא טבלה בצורה (2, 2).

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

print(table[:, ::2])

פלט

[[ 1  3]
 [ 5  7]
 [ 9 11]]

ההבדל בין אינדקס יחיד לחיתוך של איבר אחד

אינדקס יחיד מסיר מהתוצאה את הציר שבו בוצעה הבחירה. חיתוך משאיר את הציר, גם אם נבחר בו רק מקום אחד. לכן table[1, :] הוא רצף של ארבעה איברים, ואילו table[1:2, :] הוא טבלה עם שורה אחת וארבע עמודות:

print(table[1, :].shape)
print(table[1:2, :].shape)

פלט

(4,)
(1, 4)

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

חיתוך במערך תלת־ממדי

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

layers = np.array([
    [[1, 2, 3], [4, 5, 6]],
    [[7, 8, 9], [10, 11, 12]]
])
print(layers.shape)

פלט

(2, 2, 3)

נבחר תחילה את השכבה השנייה כולה. האינדקס 1 בוחר שכבה אחת, ושתי הנקודתיים בוחרות את כל השורות ואת כל העמודות שלה:

print(layers[1, :, :])

פלט

[[ 7  8  9]
 [10 11 12]]

התוצאה היא מטריצה: הבחירה בשכבה יחידה באמצעות אינדקס הסירה את ציר השכבות. כעת נבחר מכל שכבה רק את השורה הראשונה ואת שתי העמודות האחרונות:

part = layers[:, :1, 1:3]
print(part)
print(part.shape)

פלט

[[[2 3]]

 [[8 9]]]
(2, 1, 2)

נפרק את הבחירה: : שומרת את שתי השכבות; :1 בוחרת רק את השורה באינדקס 0; 1:3 בוחרת את העמודות באינדקסים 1 ו־2. השתמשנו בחיתוך גם לבחירת שורה אחת, ולכן נשמרו שלושת הממדים.

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

print(layers[:, :, -1])

פלט

[[ 3  6]
 [ 9 12]]

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

מספר ממדים במקור דוגמת חיתוך מה נבחר?
אחד values[1:4] טווח מיקומים ברצף
שניים table[:2, 1:3] טווח שורות וטווח עמודות
שלושה layers[:, :1, 1:3] שכבות, טווח שורות וטווח עמודות

הכלל אחיד: פסיק מפריד בין צירים; נקודתיים מגדירות טווח בתוך ציר. חיתוך רב־ממדי בתיעוד NumPy.

חיתוך והעתקה

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

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

values = np.array([10, 20, 30])
part = values[:2]
saved = values[:2].copy()
part[0] = 99
print(values)
print(saved)

פלט

[99 20 30]
[10 20]

השינוי ב־part משנה גם את values; saved נשארת נפרדת. זה שונה מחיתוך של list, שיוצר רשימה חיצונית חדשה. תצוגות והעתקות בתיעוד NumPy.

שינוי צורה — reshape

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

שינוי צורה מארגן את אותם איברים ברצף או בטבלה בעלי ממדים אחרים. הפעולה reshape מחזירה מערך בצורה המבוקשת. מספר האיברים הכולל חייב להישמר. ערך -1 במקום גודל של ממד אחד מאפשר ל־NumPy לחשב אותו.

values = np.array([1, 2, 3, 4, 5, 6])
matrix = values.reshape(2, 3)
print(matrix)
print(values.reshape(3, -1))

פלט

[[1 2 3]
 [4 5 6]]
[[1 2]
 [3 4]
 [5 6]]

בדוגמה שישה מספרים סודרו פעם אחת בשתי שורות של שלושה, ופעם אחת בשלוש שורות של שניים. בקריאה reshape(3, -1) ביקשנו שלוש שורות, ו־NumPy חישבה בעצמה שבכל שורה יהיו שני איברים, כי 6 / 3 = 2.

reshape אינה מבטיחה העתקה נפרדת של הנתונים. כשנדרש עותק עצמאי של מערך מספרי אפשר להשתמש ב־.copy(), כפי שהודגם בסעיף החיתוך.

שחלוף — transpose

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

במטריצה, שחלוף מחליף בין שורות לעמודות. matrix.T היא דרך קצרה לכתוב matrix.transpose().

matrix = np.array([[1, 2, 3],
                   [4, 5, 6]])
print(matrix.T)
print(matrix.T.shape)

פלט

[[1 4]
 [2 5]
 [3 6]]
(3, 2)

המטריצה המקורית הייתה בצורה (2, 3), ואחרי השחלוף היא בצורה (3, 2): השורה הראשונה 1, 2, 3 הפכה לעמודה הראשונה. שחלוף של מערך חד־ממדי אינו הופך אותו לעמודה. ליצירת עמודה משתמשים ב־vector.reshape(-1, 1).

טיפוס הנתונים — dtype

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

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

whole = np.array([3, 4, 4.5], dtype=int)
real = np.array([3, 4, 4.5], dtype=float)
print(whole)
print(real)

פלט

[3 4 4]
[3.  4.  4.5]

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

סריקה של מערך

לפעמים בכל זאת נרצה לעבור על איברי המערך אחד־אחד, למשל כדי להדפיס אותם או לבדוק תנאי מיוחד על כל אחד. אפשר לעשות זאת בלולאת for, כפי שעושים עם רשימה, אך במטריצה צריך לזכור שיש שני צירים.

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

matrix = np.array([[1, 2], [3, 4]])
for row in matrix:
    for value in row:
        print(value)

פלט

1
2
3
4

סריקה באמצעות אינדקסים

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

תחילה נקבל מ־matrix.shape את מספר השורות והעמודות. הלולאה החיצונית תעבור על אינדקסי השורות, והפנימית על אינדקסי העמודות. הפונקציה range מתחילה כאן ב־0 ועוצרת לפני המספר שנמסר לה: למשל, range(2) נותנת את האינדקסים 0 ו־1.

בכל צעד ניגש לאיבר באמצעות matrix[row, column]. נדפיס את אינדקס השורה, את אינדקס העמודה ואת הערך שבמיקום זה:

matrix = np.array([[1, 2], [3, 4]])
rows, columns = matrix.shape
for row in range(rows):
    for column in range(columns):
        print(row, column, matrix[row, column])

פלט

0 0 1
0 1 2
1 0 3
1 1 4

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

בדוגמה הקודמת המשתנה row החזיק שורה שלמה; כאן הוא מחזיק את אינדקס השורה. לכן נדרשת גישה דרך הסוגריים המרובעים כדי לקבל את ערך האיבר.

מהמבנה לחישובים

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

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

זהו בדיוק סוג החישוב שבבסיס למידת המכונה. בחלק ג נראה שרשת נוירונים מקבלת מערך קלט, מכפילה אותו במטריצת משקלים, מפעילה על התוצאה פונקציה מתמטית ומחשבת ממוצע של טעויות: כל אחת מהפעולות האלה מופיעה בהמשך. מי שמבין כאן כיצד עובדים broadcasting, axis וכפל מטריצות, יקרא בקלות את קוד האימון בהמשך. כל דוגמה מכאן ואילך יוצרת את הנתונים שלה או ממשיכה את הסעיף הקודם, לאחר הייבוא import numpy as np שבתחילת המחברת.

פעולות מתמטיות על מערכים

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

פעולה בין מערך למספר

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

values = np.array([2, 4, 6])
print(values + 10)
print(values - 1)
print(values * 3)
print(values / 2)
print(values ** 2)

פלט

[12 14 16]
[1 3 5]
[ 6 12 18]
[1. 2. 3.]
[ 4 16 36]

למשל, values * 3 מחשבת את 2 * 3, את 4 * 3 ואת 6 * 3. פעולת החילוק / מחזירה כאן מספרים מסוג float, גם כשהתוצאות שלמות מבחינה מתמטית.

הביטויים האלה מחזירים תוצאות בלי לשנות את values. כדי להשתמש בתוצאה בהמשך שומרים אותה במשתנה:

adjusted = values + 10
print(values)
print(adjusted)

פלט

[2 4 6]
[12 14 16]

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

print([2, 4, 6] * 2)
print(np.array([2, 4, 6]) * 2)

פלט

[2, 4, 6, 2, 4, 6]
[ 4  8 12]

פעולה בין שני מערכים באותה צורה

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

a = np.array([2, 4, 6])
b = np.array([1, 2, 3])
print(a + b)
print(a - b)
print(a * b)
print(a / b)

פלט

[3 6 9]
[1 2 3]
[ 2  8 18]
[2. 2. 2.]

אותו עיקרון חל על מטריצות: כל תא פועל מול התא באותה שורה ובאותה עמודה. בדוגמה הבאה הכפל בתא הראשון הוא 1 * 10, ובתא האחרון 4 * 40:

a = np.array([[1, 2], [3, 4]])
b = np.array([[10, 20], [30, 40]])
print(a + b)
print(a * b)

פלט

[[11 22]
 [33 44]]
[[ 10  40]
 [ 90 160]]

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

חישוב ערכי פונקציה עבור מספרים רבים

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

פונקציה מתמטית קובעת כיצד לחשב פלט מתוך קלט. לדוגמה, הפונקציה f(x) = x² + 2x + 1 נותנת עבור x=0 את הערך 1, ועבור x=1 את הערך 4. כאשר יש לנו כמה ערכי קלט, נרצה לקבל ערך פלט מתאים לכל אחד מהם, באותו סדר.

אם נכתוב את הפונקציה באמצעות פעולות ש־NumPy מבצעת איבר מול איבר, נוכל להעביר לה מערך שלם:

def f(x):
    return x ** 2 + 2 * x + 1

x_values = np.array([-2, -1, 0, 1, 2])
y_values = f(x_values)
print(x_values)
print(y_values)

פלט

[-2 -1  0  1  2]
[1 0 1 4 9]

בתוך הפונקציה, x מחזיק את כל המערך. הביטוי x ** 2 מחשב ריבוע לכל איבר, 2 * x מכפיל כל איבר בשניים, והחיבור משלב את התוצאות המתאימות. למשל, עבור הקלט האחרון, 2, מחושב 4 + 4 + 1, ולכן הפלט האחרון הוא 9. התוצאה היא מערך באותה צורה כמו מערך הקלט.

קבלת רשימת Python והחזרת רשימת תוצאות

אפשר לכתוב פונקציה שמקבלת ממש רשימת Python, ממירה אותה למערך לצורך החישוב, ומחזירה שוב רשימה. נשתמש ב־np.array להמרה בכניסה וב־.tolist() להמרה בחזרה:

def function_values(numbers):
    x = np.array(numbers)
    y = f(x)
    return y.tolist()

numbers = [-2, -1, 0, 1, 2]
results = function_values(numbers)
print(results)
print(type(results))

פלט

[1, 0, 1, 4, 9]
<class 'list'>

הפונקציה function_values משתמשת ב־f שהגדרנו קודם. כל איבר ברשימת התוצאות מתאים לאיבר באותו מיקום ברשימת הקלט. המרת מערך לרשימה בתיעוד NumPy.

לא כל פונקציה שנכתבה עבור מספר יחיד יכולה לקבל מערך ללא שינוי. לדוגמה, if x > 0 אינו בוחר בנפרד עבור כל איבר במערך. לצורך בחירה כזאת נלמד בהמשך להשתמש ב־np.where. הפונקציה f מתאימה למערך משום שכל הפעולות שבה מוגדרות איבר מול איבר.

פונקציות מתמטיות מוכנות

NumPy כוללת גם פונקציות שפועלות ישירות על כל איברי המערך. למשל, np.sqrt מחשבת שורש ריבועי. נעביר לה ארבעה מספרים לא־שליליים ונקבל ארבעה שורשים:

numbers = np.array([0, 1, 4, 9])
print(np.sqrt(numbers))

פלט

[0. 1. 2. 3.]

כל תוצאה נשארת במיקום של הקלט המתאים: השורש של 4 הוא 2, ולכן האיבר השלישי בתוצאה הוא 2.. שורש ריבועי בתיעוד NumPy.

יצירת סדרת ערכי קלט — linspace

עד עכשיו כתבנו בעצמנו את המספרים שיועברו לפונקציה. כשנרצה לחשב פונקציה בנקודות רבות לאורך תחום, נוח ליצור את ערכי הקלט באופן אוטומטי. הפונקציה np.linspace(start, stop, num) יוצרת מערך של מספרים במרווחים שווים: start הוא הערך הראשון, stop הוא האחרון, ו־num הוא מספר הערכים המבוקש.

בדוגמה ניצור חמש נקודות בין 0 ל־10, כולל שני הקצוות:

x = np.linspace(0, 10, num=5)
print(x)

פלט

[ 0.   2.5  5.   7.5 10. ]

חמש נקודות יוצרות ארבעה מרווחים, ולכן המרחק בין ערכים סמוכים הוא (10 - 0) / 4 = 2.5. הפרמטר num קובע את מספר הערכים, לא את גודל הצעד. בלי הגדרת טיפוס אחרת מתקבלים כאן מספרים מסוג float.

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

x = np.linspace(0, 10, num=5, endpoint=False)
print(x)

פלט

[0. 2. 4. 6. 8.]

כאן התקבלו חמש נקודות במרווח 2, ו־10 אינו ביניהן. יצירת סדרה באמצעות linspace בתיעוד NumPy.

העברת הסדרה לפונקציה

התוצאה של linspace היא מערך NumPy רגיל, ולכן אפשר להעביר אותה ישירות לפונקציה מתמטית של הספרייה. למשל, נחשב את השורש הריבועי בכל אחת מחמש נקודות בין 0 ל־16:

x = np.linspace(0, 16, num=5)
y = np.sqrt(x)
print(x)
print(np.round(y, 4))

פלט

[ 0.  4.  8. 12. 16.]
[0.     2.     2.8284 3.4641 4.    ]

השורה y = np.sqrt(x) מחשבת בבת אחת שורש לכל ערך ב־x. מתקבלים חמישה ערכי פלט: y[0] הוא השורש של x[0], ו־y[1] הוא השורש של x[1], וכן הלאה. אין צורך להפעיל את הפונקציה בנפרד עבור כל מספר.

כדי שהפלט יהיה קריא השתמשנו ב־np.round(y, 4), שמחזירה את הערכים מעוגלים לארבע ספרות אחרי הנקודה. העיגול נעשה כאן לצורך ההדפסה; המערך y נשאר עם הערכים שחושבו. עיגול בתיעוד NumPy.

אפשר להעביר את הסדרה גם לפונקציה f שהגדרנו קודם:

x = np.linspace(-2, 2, num=5)
y = f(x)
print(x)
print(y)

פלט

[-2. -1.  0.  1.  2.]
[1. 0. 1. 4. 9.]

גם כאן נשמרת ההתאמה בין כל קלט לפלט שלו. בפרק הגרפים נוכל להשתמש במערכים כאלה כערכי הציר האופקי והאנכי.

פונקציות מתמטיות נוספות — sin, cos, exp ו־log

נשתמש כעת באותה דרך חישוב עבור פונקציות נוספות: נעביר מערך ערכי קלט ונקבל תוצאה לכל איבר. הפונקציות האלה אינן רק דוגמאות: סינוס ישמש אותנו בחלק ג כדוגמה לפונקציה לא לינארית שרשת נוירונים לומדת לקרב, ואילו exp ו־log עומדות בבסיס פונקציות מרכזיות בלמידת מכונה, כמו פונקציית האקטיבציה Sigmoid ופונקציית ההפסד Cross Entropy שנפגוש שם.

סינוס וקוסינוס על מערך זוויות

הפונקציות np.sin ו־np.cos מחשבות סינוס וקוסינוס לכל זווית במערך. הקלט שלהן נמדד ברדיאנים, ולא במעלות. ברדיאנים סיבוב שלם הוא , וחצי סיבוב הוא π, המקביל ל־180 מעלות. הקבוע np.pi מספק את ערכו של π, בקירוב 3.14159.

ניצור באמצעות linspace חמש זוויות בין 0 ל־π: הן מקבילות ל־0, 45, 90, 135 ו־180 מעלות. נעביר את אותו מערך לשתי הפונקציות:

angles = np.linspace(0, np.pi, num=5)
sin_values = np.sin(angles)
cos_values = np.cos(angles)

print(np.round(angles, 4).tolist())
print(np.round(sin_values, 4).tolist())
print(np.round(cos_values, 4).tolist())

פלט

[0.0, 0.7854, 1.5708, 2.3562, 3.1416]
[0.0, 0.7071, 1.0, 0.7071, 0.0]
[1.0, 0.7071, 0.0, -0.7071, -1.0]

השורה הראשונה מציגה את הזוויות ברדיאנים, השנייה את ערכי הסינוס והשלישית את ערכי הקוסינוס. למשל, האיבר השלישי ב־angles הוא π/2, כלומר 90 מעלות; מולו מתקבלים סינוס 1 וקוסינוס 0 בקירוב. השתמשנו ב־.tolist() רק כדי להציג את הערכים המעוגלים כרשימות; החישובים עצמם החזירו מערכים.

חישובי המחשב משתמשים בקירובים עשרוניים. לכן ערך שאמור להיות אפס, כמו סינוס של π, עשוי להיות מספר זעיר הקרוב לאפס. העיגול מסביר מדוע הוא מוצג כאן כ־0.0. סינוס בתיעוד NumPy, קוסינוס בתיעוד NumPy.

אם הזוויות נתונות במעלות, אפשר להמיר אותן לרדיאנים באמצעות הכפלה ב־np.pi / 180 לפני הקריאה לפונקציה:

degrees = np.array([0, 90, 180])
radians = degrees * np.pi / 180
print(np.round(np.sin(radians), 4))

פלט

[0. 1. 0.]

הפונקציה המעריכית — exp

הפונקציה np.exp(x) מחשבת את e בחזקת כל ערך ב־x. המספר e הוא קבוע מתמטי שערכו בקירוב 2.71828. לדוגמה, עבור 0 מתקבל e⁰ = 1, ועבור 1 מתקבל e¹ = e. נחשב שלושה ערכים שנוצרו באמצעות linspace:

x = np.linspace(0, 2, num=3)
exponentials = np.exp(x)
print(x)
print(np.round(exponentials, 4))

פלט

[0. 1. 2.]
[1.     2.7183 7.3891]

הפלט כולל את e⁰, את ואת , באותו סדר כמו מערך הקלט. הפונקציה המעריכית בתיעוד NumPy.

לוגריתם טבעי — log

הלוגריתם הטבעי עונה על השאלה: באיזו חזקה צריך להעלות את e כדי לקבל את המספר הנתון? לכן הוא הפעולה ההפוכה ל־exp: הלוגריתם של 1 הוא 0, ושל e הוא 1. הפונקציה np.log מחשבת לוגריתם טבעי לכל איבר; בחישוב ממשי נותנים לה ערכים חיוביים.

נעביר ל־log את תוצאות החישוב הקודם:

recovered = np.log(exponentials)
print(np.round(recovered, 4))

פלט

[0. 1. 2.]

קיבלנו בחזרה, עד כדי דיוק החישוב, את הערכים המקוריים שיצרנו עם linspace. השם log ב־NumPy מציין לוגריתם בבסיס e. לוגריתם טבעי בתיעוד NumPy.

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

התאמת צורות בפעולות — broadcasting

עד כאן ביצענו פעולות בין מערך למספר יחיד, או בין שני מערכים באותה צורה בדיוק. אבל מה קורה בין מטריצה לרצף? נחשוב על טבלת מחירים שבה כל שורה היא חנות וכל עמודה היא מוצר, וברצוננו להוסיף לכל מוצר מס אחר. יש לנו טבלה ורצף של תוספות, אחת לכל עמודה, והיינו רוצים שהתוספת „תתפשט” על כל השורות מעצמה. NumPy יודעת לעשות זאת, ולמנגנון הזה קוראים broadcasting. הוא יופיע שוב ושוב בקוד של למידת מכונה, למשל כשמוסיפים את ההטיה (bias) של כל נוירון לכל שורה של תוצאות.

Broadcasting מאפשר לבצע פעולות בין מערכים בצורות שונות, כאשר הצורות מתאימות. למשל, אפשר להוסיף לטבלת מחירים תוספת אחרת לכל עמודה. NumPy משתמשת באותם ערכי תוספת שוב בכל שורה; אין צורך להכין ידנית טבלה של תוספות.

מטריצה ומערך חד־ממדי

למטריצה הבאה צורה (2, 3), ולמערך התוספות צורה (3,). שלוש התוספות מותאמות לשלוש העמודות וחוזרות בכל שורה:

matrix = np.array([
    [1, 2, 3],
    [4, 5, 6]
])
column_additions = np.array([10, 20, 30])
print(matrix + column_additions)

פלט

[[11 22 33]
 [14 25 36]]

בעמודה הראשונה נוספו 10, בשנייה 20 ובשלישית 30, וזאת בשתי השורות: NumPy השתמשה באותו רצף תוספות פעמיים, בלי שנצטרך לשכפל אותו. אפשר גם להכפיל כך: מקדם אחר לכל עמודה.

factors = np.array([1, 10, 100])
print(matrix * factors)

פלט

[[  1  20 300]
 [  4  50 600]]

תוספת אחרת לכל שורה

מה אם התוספת צריכה להיות שונה לכל שורה ולא לכל עמודה? רצף בצורה (2,) לא יתאים, כי NumPy מנסה להצמיד אותו לעמודות. הפתרון הוא לסדר את התוספות כעמודה: מערך של שתי שורות ועמודה אחת. כדי להוסיף 10 לכל השורה הראשונה ו־20 לכל השנייה, ניצור עמודת תוספות בצורה (2, 1). נשתמש ב־reshape שכבר למדנו:

row_additions = np.array([10, 20])
row_additions = row_additions.reshape(2, 1)
print(row_additions)
print(matrix + row_additions)

פלט

[[10]
 [20]]
[[11 12 13]
 [24 25 26]]

כללי התאמה

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

צורת המטריצה צורת התוספת תוצאה
(2, 3) (3,) תוספת לכל עמודה
(2, 3) (2, 1) תוספת לכל שורה
(2, 3) (2,) שגיאה: 3 ו־2 אינם מתאימים

לכן חיבור ישיר של matrix עם np.array([10, 20]) יגרום ל־ValueError. מספר השורות לבדו אינו קובע התאמה. Broadcasting אינו מוסיף איברים למערכי המקור; הוא מתאים את השימוש בנתונים במהלך החישוב. כללי broadcasting בתיעוד NumPy.

חישובי סיכום — sum, min, max ו־mean

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

פונקציה מה היא מחשבת?
np.sum סכום האיברים
np.min הערך הקטן ביותר
np.max הערך הגדול ביותר
np.mean ממוצע חשבוני — הסכום חלקי מספר האיברים

חישוב על מערך חד־ממדי

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

values = np.array([10, 20, 30, 40])
print(np.sum(values))
print(np.min(values))
print(np.max(values))
print(np.mean(values))

פלט

100
10
40
25.0

הממוצע הוא 100 / 4, כלומר 25. אפשר לכתוב את אותן פעולות גם כמתודות של המערך: values.sum(), values.min(), values.max() ו־values.mean(). בדוגמאות הבאות נשתמש בכתיבה עם np.

חישוב על מטריצה שלמה

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

scores = np.array([
    [10, 20, 30],
    [40, 50, 60]
])
print(np.sum(scores))
print(np.min(scores))
print(np.max(scores))
print(np.mean(scores))

פלט

210
10
60
35.0

כאן np.mean(scores) נותנת את ממוצע כל הציונים, ולא ממוצע נפרד לכל תלמיד. כדי לבחור אילו איברים יסוכמו יחד, מציינים ציר באמצעות הפרמטר axis.

בחירת ציר — axis

השאלה „ממוצע של מה?” היא שאלה על כיוון: ממוצע של כל תלמיד עובר לרוחב השורה, וממוצע של כל מבחן יורד לאורך העמודה. הפרמטר axis הוא הדרך לומר ל־NumPy באיזה כיוון לסכם. זהו אחד המושגים שהכי קל להתבלבל בהם, ולכן נעבור עליו לאט.

בטבלה הציר הראשון, axis=0, הוא ציר השורות; הציר השני, axis=1, הוא ציר העמודות. בחישוב סיכום, הציר שנבחר הוא הציר שלאורכו מאחדים את הערכים.

כאשר בוחרים axis=0, עוברים בין השורות ומאחדים את הערכים שבאותה עמודה. לכן מתקבלת תוצאה אחת לכל עמודה. בסכום הטבלה שלנו: 10 + 40, אחריו 20 + 50, ולבסוף 30 + 60.

כאשר בוחרים axis=1, עוברים בין העמודות ומאחדים את הערכים שבאותה שורה. לכן מתקבלת תוצאה אחת לכל שורה: 10 + 20 + 30, ואחריו 40 + 50 + 60.

print(np.sum(scores, axis=0))
print(np.sum(scores, axis=1))

פלט

[50 70 90]
[ 60 150]
בחירה מה מאחדים? מה מתקבל עבור צורה (2, 3)?
ללא axis כל האיברים מספר יחיד
axis=0 שורות זו עם זו שלוש תוצאות, אחת לכל עמודה
axis=1 עמודות זו עם זו שתי תוצאות, אחת לכל שורה

כדאי להיזהר מהקיצור „ציר השורות”: סיכום לאורך ציר השורות נותן תוצאה לכל עמודה. סכימה וצירים בתיעוד NumPy.

ממוצע לכל תלמיד או לכל מבחן

כל תלמיד נמצא בשורה, ולכן axis=1 מאחד את ציוניו בשלושת המבחנים. כל מבחן נמצא בעמודה, ולכן axis=0 מאחד את ציוני שני התלמידים באותו מבחן:

print(np.mean(scores, axis=1))
print(np.mean(scores, axis=0))

פלט

[20. 50.]
[25. 35. 45.]

למשל, ממוצע התלמיד הראשון הוא (10 + 20 + 30) / 3 = 20. ממוצע המבחן הראשון הוא (10 + 40) / 2 = 25. ממוצע בתיעוד NumPy.

מינימום ומקסימום לפי שורה או עמודה

גם min ו־max מקבלות axis. נחשב תחילה את הציון הקטן והגדול ביותר של כל תלמיד, ולאחר מכן את הקטן והגדול ביותר בכל מבחן:

print(np.min(scores, axis=1))
print(np.max(scores, axis=1))
print(np.min(scores, axis=0))
print(np.max(scores, axis=0))

פלט

[10 40]
[30 60]
[10 20 30]
[40 50 60]

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

שילוב ממוצע ו־broadcasting

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

test_means = np.mean(scores, axis=0)
centered = scores - test_means
print(test_means)
print(centered)

פלט

[25. 35. 45.]
[[-15. -15. -15.]
 [ 15.  15.  15.]]

ל־test_means צורה (3,), ולכן כל ממוצע מותאם לעמודה שלו. תוצאה שלילית פירושה שהציון נמוך מממוצע המבחן; תוצאה חיובית פירושה שהוא גבוה ממנו.

אם רוצים לחסר מכל תלמיד את הממוצע האישי שלו, נדרשת עמודת ממוצעים בצורה (2, 1). הפרמטר keepdims=True שומר את הציר שסוכם בגודל 1, וכך מכין את התוצאה לחיסור:

student_means = np.mean(
    scores, axis=1, keepdims=True
)
print(student_means)
print(scores - student_means)

פלט

[[20.]
 [50.]]
[[-10.   0.  10.]
 [-10.   0.  10.]]

ללא keepdims=True היינו מקבלים צורה (2,), שאינה מתאימה לחיסור ממטריצה בצורה (2, 3). כאן רואים כיצד בחירת הציר וצורת התוצאה משפיעות על הפעולה הבאה.

תנאים ובחירת ערכים — where

ראינו קודם ש־if x > 0 אינו עובד על מערך שלם. אבל שאלות כמו „אילו תלמידים עברו?” או „כמה ציונים גבוהים מ־75?” הן שאלות טבעיות על נתונים, ואנחנו רוצים לענות עליהן בלי לולאה. NumPy פותרת זאת בשני שלבים: תחילה בודקים את התנאי על כל האיברים בבת אחת, ואז משתמשים בתוצאה כדי לבחור ערכים.

אפשר לבדוק תנאי על כל איבר במערך. השוואה כמו scores >= 60 מחזירה מערך של ערכי אמת: True במקום שבו התנאי מתקיים ו־False במקום שבו הוא אינו מתקיים. מערך כזה מכונה מסכה בוליאנית.

scores = np.array([45, 60, 75, 90])
passed = scores >= 60
print(passed)
print(scores[passed])

פלט

[False  True  True  True]
[60 75 90]

בביטוי scores[passed] נשמרים רק הציונים שעבורם המסכה מכילה True. זו בחירה לפי תנאי, ולא חיתוך לפי טווח מיקומים. בחירה לפי תנאי במדריך NumPy.

בחירה בין שתי אפשרויות לכל איבר

הפונקציה np.where(condition, x, y) בוחרת לכל מיקום ערך מתוך x אם התנאי מתקיים, ומתוך y אחרת. למשל, נסמן ציון עובר ב־1 וציון שאינו עובר ב־0:

result = np.where(scores >= 60, 1, 0)
print(result)

פלט

[0 1 1 1]

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

updated = np.where(scores < 60, scores + 5, scores)
print(updated)
print(scores)

פלט

[50 60 75 90]
[45 60 75 90]

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

מציאת מיקומים שעומדים בתנאי

לפעמים דרושים לנו המיקומים שבהם התנאי מתקיים, ולא הערכים עצמם. כאשר מעבירים ל־np.where רק תנאי, היא מחזירה טאפל של מערכי אינדקסים, אחד לכל ציר. במערך חד־ממדי יש מערך אינדקסים אחד, ונוכל לקבל אותו באמצעות [0]:

positions = np.where(scores >= 75)[0]
print(positions)

פלט

[2 3]

אלו מיקומי הציונים 75 ו־90, ולא הציונים עצמם. בחירת ערכים ומיקומים בתיעוד where.

הגבלת ערכים לטווח — clip

כאשר רוצים שכל הערכים יהיו בין גבול תחתון לעליון, אפשר להשתמש ב־np.clip. ערך שמתחת לגבול התחתון מוחלף בגבול התחתון, ערך שמעל לעליון מוחלף בעליון, וערך בתוך הטווח נשאר כפי שהוא. למשל, לאחר תיקון ציונים נרצה לשמור על הטווח 0 עד 100:

raw_scores = np.array([-5, 40, 105])
print(np.clip(raw_scores, 0, 100))

פלט

[  0  40 100]

כאן -5 הוחלף ב־0, ו־105 הוחלף ב־100. הגבלת ערכים בתיעוד NumPy.

כפל מטריצות

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

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

a = np.array([[1, 2], [3, 4]])
b = np.array([[10, 20], [30, 40]])
print(a * b)
print(a @ b)

פלט

[[ 10  40]
 [ 90 160]]
[[ 70 100]
 [150 220]]

הפלט הראשון הוא הכפל איבר מול איבר שכבר מכירים. הפלט השני שונה לגמרי. לדוגמה, האיבר הראשון בתוצאת כפל המטריצות הוא 1 * 10 + 2 * 30, כלומר 70: השורה הראשונה של a מול העמודה הראשונה של b. באופן כללי, כפל מטריצה בצורה (m, n) במטריצה בצורה (n, k) מחזיר מטריצה בצורה (m, k). במחברת הקורס שתי המטריצות הן בצורה (2, 3), ולכן משחלפים את השנייה לצורה (3, 2) לפני הכפל. כפל מטריצות בתיעוד NumPy.