ג.19 Pima — רשת לנתונים בטבלה

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

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

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

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

חומרי הליווי: מצגת רשת נוירונים באמצעות מחלקה (עותק מקומי) · מחברת Pima (עותק מקומי)

יבוא הספריות

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

import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.datasets import fetch_openml
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import pandas as pd

טעינת הנתונים והיכרות עם הטבלה

לפני שבונים רשת כדאי להכיר את הנתונים: כמה שורות יש, מה סוג הערכים בכל עמודה ובאיזה טווח הם נעים. נטען את המאגר ונציג את הטבלה, את פרטי העמודות ואת הסטטיסטיקה שלהן. בטבלה 768 שורות: שמונה תכונות ועמודת היעד class, שערכיה הם tested_positive או tested_negative. הפקודה df.info() מפרטת את סוג הנתונים של כל עמודה ומאשרת שאין ערכים חסרים, ו־df.describe() מציגה לכל עמודה מספרית את הממוצע, סטיית התקן, המינימום והמקסימום.

# Load the Pima Indians Diabetes dataset
data = fetch_openml(name="diabetes", version=1, as_frame=True)
df = data.frame
print(df)
df.info()
df.describe()

פלט

preg  plas  pres  skin  insu  mass   pedi  age            class
0       6   148    72    35     0  33.6  0.627   50  tested_positive
1       1    85    66    29     0  26.6  0.351   31  tested_negative
2       8   183    64     0     0  23.3  0.672   32  tested_positive
3       1    89    66    23    94  28.1  0.167   21  tested_negative
4       0   137    40    35   168  43.1  2.288   33  tested_positive
..    ...   ...   ...   ...   ...   ...    ...  ...              ...
763    10   101    76    48   180  32.9  0.171   63  tested_negative
764     2   122    70    27     0  36.8  0.340   27  tested_negative
765     5   121    72    23   112  26.2  0.245   30  tested_negative
766     1   126    60     0     0  30.1  0.349   47  tested_positive
767     1    93    70    31     0  30.4  0.315   23  tested_negative

[768 rows x 9 columns]
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 768 entries, 0 to 767
Data columns (total 9 columns):
 #   Column  Non-Null Count  Dtype   
---  ------  --------------  -----   
 0   preg    768 non-null    int64   
 1   plas    768 non-null    int64   
 2   pres    768 non-null    int64   
 3   skin    768 non-null    int64   
 4   insu    768 non-null    int64   
 5   mass    768 non-null    float64 
 6   pedi    768 non-null    float64 
 7   age     768 non-null    int64   
 8   class   768 non-null    category
dtypes: category(1), float64(2), int64(6)
memory usage: 49.0 KB

פלט

preg        plas        pres        skin        insu        mass  \
count  768.000000  768.000000  768.000000  768.000000  768.000000  768.000000   
mean     3.845052  120.894531   69.105469   20.536458   79.799479   31.992578   
std      3.369578   31.972618   19.355807   15.952218  115.244002    7.884160   
min      0.000000    0.000000    0.000000    0.000000    0.000000    0.000000   
25%      1.000000   99.000000   62.000000    0.000000    0.000000   27.300000   
50%      3.000000  117.000000   72.000000   23.000000   30.500000   32.000000   
75%      6.000000  140.250000   80.000000   32.000000  127.250000   36.600000   
max     17.000000  199.000000  122.000000   99.000000  846.000000   67.100000   

             pedi         age  
count  768.000000  768.000000  
mean     0.471876   33.240885  
std      0.331329   11.760232  
min      0.078000   21.000000  
25%      0.243750   24.000000  
50%      0.372500   29.000000  
75%      0.626250   41.000000  
max      2.420000   81.000000

מהסטטיסטיקה אפשר ללמוד שני דברים חשובים. ראשית, לכל עמודה סולם משלו: מספר ההריונות (preg) נע בין 0 ל־17, ואילו האינסולין (insu) מגיע עד 846. שנית, בכמה עמודות המינימום הוא 0, ערך שאינו סביר ללחץ דם או למסת גוף; אלה כנראה מדידות חסרות שנרשמו כאפס. לא נטפל בכך בפרק זה, אך כדאי לזכור שנתונים אמיתיים אינם תמיד נקיים.

נציג את חמש השורות הראשונות:

df.head()

פלט

preg  plas  pres  skin  insu  mass   pedi  age            class
0     6   148    72    35     0  33.6  0.627   50  tested_positive
1     1    85    66    29     0  26.6  0.351   31  tested_negative
2     8   183    64     0     0  23.3  0.672   32  tested_positive
3     1    89    66    23    94  28.1  0.167   21  tested_negative
4     0   137    40    35   168  43.1  2.288   33  tested_positive

הפרדת התכונות והיעד

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

# Separate features and target
X = df.iloc[:, :-1].values  # Features
y = df.iloc[:, -1].apply(lambda x: 1 if x == 'tested_positive' else 0).values  # Target (binary encoding)

תקנון הנתונים

ראינו זה עתה שהעמודות נמדדות בסולמות שונים מאוד. אם נזין אותן לרשת כמות שהן, העמודה בעלת הערכים הגדולים תשלוט בחישוב, והאימון יתקשה להתכנס — זו הסיבה שלמדנו לנרמל נתונים בפרק ג.9. תקנון — Standardization הוא הנרמול שהכרנו שם בשם Z-normalization: מחסרים מכל תכונה את הממוצע שלה ומחלקים בסטיית התקן שלה, כך שכל עמודה מקבלת ממוצע 0 וסטיית תקן 1. נבצע אותו באמצעות StandardScaler, שמחשב את הממוצע וסטיית התקן של כל עמודה (fit) ומיישם את ההמרה (transform) בפעולה אחת:

# Normalize the features
scaler = StandardScaler()
X = scaler.fit_transform(X)

פיצול לאימון ולבדיקה

כדי לדעת אם הרשת באמת למדה משהו כללי, ולא רק שיננה את הדוגמאות שראתה, נשמור חלק מהנתונים בצד ולא נראה אותו לרשת במהלך האימון. נפצל את הנתונים ל־80% אימון ו־20% בדיקה; הפרמטר random_state=42 קובע את הערבוב, כך שנקבל את אותו פיצול בכל הרצה:

# Train-test split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

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

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

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

# Convert to PyTorch tensors
X_train_tensor = torch.tensor(X_train, dtype=torch.float32)
y_train_tensor = torch.tensor(y_train, dtype=torch.float32).unsqueeze(1)
X_test_tensor = torch.tensor(X_test, dtype=torch.float32)
y_test_tensor = torch.tensor(y_test, dtype=torch.float32).unsqueeze(1)

הגדרת הרשת הראשונה

כעת נבנה את הרשת עצמה, כמחלקה שיורשת מ־nn.Module כפי שלמדנו. לרשת שמונה קלטים — אחד לכל תכונה בטבלה — שכבה נסתרת של 128 נוירונים עם ReLU, ופלט יחיד עם Sigmoid שמחזיר מספר בין 0 ל־1: ההסתברות שהמטופלת חולה. שם המחלקה הוא SingleNeuronModel, אך המבנה שלה כולל שכבה נסתרת:

# Define the model
class SingleNeuronModel(nn.Module):
    def __init__(self, input_dim):
        super().__init__()
        self.fc1 = nn.Linear(input_dim, 128)  # Hidden layer with 128 neurons
        self.sigmoid = nn.Sigmoid()
        self.fc2 = nn.Linear(128, 1)
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.relu(self.fc1(x))
        x = self.sigmoid(self.fc2(x))

        return x

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

# Initialize the model
input_dim = X_train.shape[1]  # Number of features
model = SingleNeuronModel(input_dim)

# Loss function and optimizer
criterion = nn.BCELoss()  # Binary Cross-Entropy Loss
optimizer = optim.Adam(model.parameters(), lr=0.01)

אימון הרשת הראשונה

לולאת האימון היא זו שהכרנו. נאמן במשך 10,000 תקופות; קבוצת האימון קטנה — 80% מ־768 שורות — ולכן אין צורך באצוות, ובכל תקופה כל נתוני האימון עוברים ברשת יחד. נאפס את הגרדיאנטים, נחשב את הפלט ואת ההפסד, נחשב נגזרות ב־backward ונעדכן את המשקלים ב־step. כל 100 תקופות נדפיס את ההפסד כדי לעקוב אחר ההתקדמות:

# Training loop
epochs = 10000
for epoch in range(epochs):
    model.train()
    optimizer.zero_grad()
    outputs = model(X_train_tensor)
    loss = criterion(outputs, y_train_tensor)
    loss.backward()
    optimizer.step()

    if (epoch + 1) % 100 == 0:
        print(f'Epoch [{epoch + 1}/{epochs}], Loss: {loss.item():.4f}')

תחילת הפלט וסופו, בדילוג על שורות הביניים:

פלט

Epoch [100/10000], Loss: 0.2964
Epoch [200/10000], Loss: 0.1197
...
Epoch [10000/10000], Loss: 0.0000

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

בדיקת הרשת הראשונה

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

# Evaluate the model
model.eval()
with torch.no_grad():
    y_pred = model(X_test_tensor)
    y_pred_classes = (y_pred > 0.5).float()
    accuracy = (y_pred_classes.eq(y_test_tensor).sum() / y_test_tensor.shape[0]).item()
    print(f'Accuracy: {accuracy:.4f}')

פלט

Accuracy: 0.6688

התוצאה השמורה היא שיעור הצלחה של כ־66.88%. הפער בין הפסד אפס באימון לבין דיוק של כשני שלישים בבדיקה הוא סימן מובהק להתאמת יתר — Overfitting: הרשת, עם 128 נוירונים נסתרים ו־10,000 תקופות, שיננה את דוגמאות האימון במקום ללמוד מהן כלל שמתאים גם לנתונים חדשים. רשת כזו מצליחה על מה שראתה ונכשלת על מה שלא ראתה — וזה ההפך ממטרת הלמידה, שהיא הכללה.

רשת עם שתי שכבות נסתרות

האם אפשר להשיג הכללה טובה יותר? נבחן כעת גרסה נוספת של הרשת, עם שתי שכבות נסתרות אך צרות יותר. הקוד הבא חוזר על כל השלבים מתחילת הפרק בתוכנית אחת שלמה, עם שלושה שינויים. נטען שוב את הנתונים ונשתמש הפעם ב־MinMaxScaler, נרמול המינימום־מקסימום מפרק ג.9, שמביא כל תכונה לתחום 0–1. הרשת הפעם היא 8→32→16→1, עם ReLU בשתי השכבות הנסתרות ו־Sigmoid בפלט — פחות נוירונים בסך הכול מאשר 128 בשכבה אחת. קצב הלמידה קטן פי מאה, 0.0001, כך שהרשת מתקדמת בצעדים זהירים יותר; משך האימון נשאר 10,000 תקופות:

import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.datasets import fetch_openml
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import MinMaxScaler
import pandas as pd

# Load the Pima Indians Diabetes dataset
data = fetch_openml(name="diabetes", version=1, as_frame=True)
df = data.frame

# Separate features and target
X = df.iloc[:, :-1].values  # Features
y = df.iloc[:, -1].apply(lambda x: 1 if x == 'tested_positive' else 0).values  # Target (binary encoding)

# Normalize the features
scaler = MinMaxScaler()
X = scaler.fit_transform(X)

# Train-test split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Convert to PyTorch tensors
X_train_tensor = torch.tensor(X_train, dtype=torch.float32)
y_train_tensor = torch.tensor(y_train, dtype=torch.float32).unsqueeze(1)
X_test_tensor = torch.tensor(X_test, dtype=torch.float32)
y_test_tensor = torch.tensor(y_test, dtype=torch.float32).unsqueeze(1)

# Define the model with additional hidden layers
class MultiLayerModel(nn.Module):
    def __init__(self, input_dim):
        super(MultiLayerModel, self).__init__()
        self.fc1 = nn.Linear(input_dim, 32)  # First hidden layer with 32 neurons
        self.fc2 = nn.Linear(32, 16)        # Second hidden layer with 16 neurons
        self.fc3 = nn.Linear(16, 1)         # Output layer
        self.relu = nn.ReLU()
        self.sigmoid = nn.Sigmoid()

    def forward(self, x):
        x = self.relu(self.fc1(x))
        x = self.relu(self.fc2(x))
        x = self.sigmoid(self.fc3(x))
        return x

# Initialize the model
input_dim = X_train.shape[1]  # Number of features
model = MultiLayerModel(input_dim)

# Loss function and optimizer
criterion = nn.BCELoss()  # Binary Cross-Entropy Loss
optimizer = optim.Adam(model.parameters(), lr=0.0001)  # Adam optimizer for better performance

# Training loop
epochs = 10000
for epoch in range(epochs):
    model.train()
    optimizer.zero_grad()
    outputs = model(X_train_tensor)
    loss = criterion(outputs, y_train_tensor)
    loss.backward()
    optimizer.step()

    if (epoch + 1) % 100 == 0:
        print(f'Epoch [{epoch + 1}/{epochs}], Loss: {loss.item():.4f}')

# Evaluate the model
model.eval()
with torch.no_grad():
    y_pred = model(X_test_tensor)
    y_pred_classes = (y_pred > 0.5).float()
    accuracy = (y_pred_classes.eq(y_test_tensor).sum() / y_test_tensor.shape[0]).item()
    print(f'Accuracy: {accuracy:.4f}')

תחילת הפלט וסופו, בדילוג על שורות הביניים:

פלט

Epoch [100/10000], Loss: 0.6881
Epoch [200/10000], Loss: 0.6791
...
Epoch [10000/10000], Loss: 0.3703
Accuracy: 0.7597

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