תחי ישראל - אין לנו ארץ אחרת

תחי ישראל -אין לנו ארץ אחרת

Keras Tuner - לבחירת ההיפר-פרמטרים למודל למידת מכונה

מחבר:
בתאריך:

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

comparing human devised model and keras tuner recommended model

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

ההנחיות כוללות:

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

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

להורדת מחברת עם הקוד המלא של המדריך בפורמט Jupyter notebook

 

הספריות

import numpy as np
import pandas as pd

TensorFlow היא ספרייה שמפתחת Google ללמידת מכונה:

import tensorflow as tf

הגירסה חשובה:

tf.__version__
2.4.1

רק עם גרסה 2.4 הצלחתי להתקין את Keras Tuner.

import kerastuner as kt

אנחנו זקוקים לספריות נוספות אותם נייבא במהלך המדריך.

 

מסד הנתונים

מסד הנתונים במדריך הוא churn dataset המכיל מידע על 7,043 לקוחות שחלק קטן מהם נטש את חברת Telco הדמיונית. המטרה שלנו היא לפתח מודל שיחזה איזה לקוחות יעזבו את החברה.

את מסד הנתונים פתחו ב-IBM והם היו מספיק נחמדים כדי להעלות אותו ל-GitHub.

https://github.com/IBM/telco-customer-churn-on-icp4d

את מסד הנתונים הורדתי לסביבת colab שמאפשרת להריץ קוד פייתון על מחברת בסגנון Jupyter notebook, בתוך השרתים של גוגל בחינם.

# Load the dataset
df = pd.read_csv('Telco_customer_churn.csv')

נקבל תחושה על מסד הנתונים:

# Explore
df.head()

explore Telco customer churn database

כמה עמודות ושורות?

df.shape
(7043, 21)

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

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

מה בנוגע לעמודה שהמודל צריך לחזות?

# What are the labels for the 'Churn' column?
df.Churn.unique()
array(['No', 'Yes'], dtype=object)
  • עמודה קטגורית עם שני ערכים: Yes - נטש - או No.

כיצד הנתונים מתפלגים?

# How many samples do we have of each type?
df.Churn.value_counts()
No     5174
Yes    1869
Name: Churn, dtype: int64
  • רוב הלקוחות לא עזבו ועל כן סט הנתונים אינו מאוזן. בבעיה זו נטפל בהמשך.

 

הכנת הנתונים ללמידת מכונה

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

בקצרה:

# since all the values are unique we can dismiss the column as mere noise
del df['customerID']

# 'TotalCharges' needs to be a float. How come it is an object?
# We need to replace the spaces with zeros prior to converting the type to float
df.TotalCharges.replace(' ', np.NaN, inplace=True)
df.TotalCharges.fillna(0, inplace=True)
df.TotalCharges = df.TotalCharges.astype(float)

# next we're going to one-hot encode the dataset and split it into train and split datasets
# first copy the original dataframe so we'll have a fallback to return to. just in case
df_clean = df

# which columns are of type object and thus need to be one-hot encoded
df_clean.columns.to_series().groupby(df.dtypes).groups

categorical_columns = ['gender', 'Partner', 'Dependents', 'PhoneService', 'MultipleLines', 'InternetService', 'OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies', 'Contract', 'PaperlessBilling', 'PaymentMethod', 'Churn']
for cname in categorical_columns:
    dummies = pd.get_dummies(df_clean[cname], prefix=cname)
    df_clean = pd.concat([df_clean, dummies], axis=1)
    df_clean.drop([cname], axis=1, inplace=True)
  • ניקיתי את הנתונים.
  • העתקתי את הנתונים לתוך מסד נתונים ששמו df_clean שאיתו נעבוד בהמשך.
  • הפכתי את הקטגוריות למספרים בשיטת one-hot encoding.

בגלל קידוד one-hot encoding מסד הנתונים מכיל עכשיו שתי עמודות Churn: Churn_Yes ו- Churn_No הם יהיו המשתנים התלויים שלנו. אותם ננסה לחזות.

נפריד את הנתונים התלויים מהבלתי תלויים תוך התחשבות בחוסר האיזון של העמודה Churn:

# 1. separate into dependent and independent dataframes
y_cols = df_clean.columns[-2:]
X_cols = df_clean.columns[:-2]


# make the actual separation
y = df_clean[y_cols]
X = df_clean[X_cols]


# 2. split into train and test datasets
# stratify to keep the ratio of y
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y,
   train_size=.7,
   random_state=42,
   stratify=y)
  1. סט הנתונים הבלתי תלויים מוכל במשתנה X. ממנו ננסה לחזות את y.
  2. הפונקציה train_test_split שימשה להפרדה לסט ניסוי ומבחן. הפרמטר stratify שומר על היחס בין הקטגוריות ב-y.

נוודא שהיחס אכן נשמר:

# explore to see if the ratio is kept
# original dataset
sum(y.Churn_Yes)/len(y.Churn_Yes) # 0.2653698707936959
 
# the ratio following the split
sum(y_test.Churn_Yes)/len(y_test.Churn_Yes) # 0.26549929010885

 

המודל הראשוני

את המודל אני בונה על סמך הידע שלי בתחום של פיתוח מודלים של למידה עמוקה באמצעות TensorFlow.

נייבא את התלויות של TensorFlow:

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Activation
from tensorflow.keras.optimizers import Adam

נבנה את המודל:

# build the model
model = Sequential()
 
# the first layer receives 45 input features and outputs 64 to the next layer
# the activation function 'relu' is the standard in the literature
model.add(Dense(64, input_shape=(45,), activation='relu'))
 
# the hidden layer has less units because that's a common practice
# to architect the hidden layers as a funnel
# it is also common that the number of neurons in a hidden has a power of 2
model.add(Dense(16, activation='relu'))
 
# the third layer outputs 2 classes as the number of categories
# because it is categorical we use the softmax activation function
model.add(Dense(2, activation='softmax'))
  • המודל מקבל 45 קלטים inputs כמספר העמודות הבלתי תלויות.
  • שכבת הקלט, כמו יתר השכבות, היא מסוג Dense, והיא כוללת 64 יחידות עיבוד.
  • השכבה השנייה היא חבויה hidden layer והיא כוללת פחות יחידות עיבוד 16.
  • שכבת הפלט מכילה פונקצית אקטיבציה מסוג softmax כי אנחנו מנסים לחזות מידע קטגורי.
  • בשתי השכבות האחרות פונקציית האקטיבציה היא מסוג relu כי ידוע שהיא נותנת תוצאות טובות במיוחד.

נציג את מבנה המודל:

model.summary()
Model: "sequential"
______________________________________
Layer (type)     Output Shape  Param #   
======================================
dense (Dense)    (None, 64)       2944      
______________________________________
dense_1 (Dense)  (None, 16)       1040      
______________________________________
dense_2 (Dense)  (None, 2)          34        
======================================
Total params: 4,018
Trainable params: 4,018
Non-trainable params: 0

נקמפל:

# The categorical_crossentropy loss function is the one we
# use when working with categorical labels
# the adam optimizer and accuracy as a metrics are standard
model.compile(optimizer='adam',
             loss='categorical_crossentropy',
             metrics=['accuracy'])
  • מטרת תהליך הלמידה היא לשפר את המודל. עבור בעיות סיווג מרבים להשתמש בפונקציית loss מסוג categorical_crossentropy המודדת את התקדמות המודל באמצעות מדידת רמת הדיוק accuracy.
  • על האופטימיזציה אחראי אלגוריתם מסוג Adam הידוע ביעילותו הרבה.

נאמן את המודל במשך 5 מחזורים כי בניסויים מקדימים ראיתי שהפונקציה מתכנסת לפיתרון אחרי 5 סיבובים:

# Train the model
model.fit(X_train, y_train,
          validation_data=(X_test,y_test),
          batch_size=32,
          epochs=5)

train the human devised model to predict the churn rate

המודל סיים לרוץ.

מה מידת הדיוק?

# find the accuracy
loss, acc = model.evaluate(X_test, y_test)
67/67 [====] - 0s 981us/step - loss: 0.6821 - accuracy: 0.7937
  • מידת הדיוק של המודל היא 79.37%

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

מכיוון שזה לא העיקר של המדריך אסתפק במדד accuracy שנותן תוצאה די גבוהה הקרובה ל-80%.

אבל כשמשתמשים במדד דיוק (accuracy) חשוב להשוות לדיוק null (null accuracy) שהוא שיעור ההצלחה במקרה שהמודל חוזה תמיד את הקטגוריה הנפוצה ביותר.

כשמשתמשים במדד דיוק (accuracy) חשוב להשוות לדיוק null (null accuracy) שהוא שיעור ההצלחה במקרה שהמודל חוזה תמיד את הקטגוריה הנפוצה ביותר.

נחשב את ה-null accuracy:

# the most frequent class is Churn_No
# calculate the ratio by summing the number 
# of samples and dividing by the overall length
# 1 - sum(y_test.Churn_Yes)/len(y_test.Churn_Yes)
sum(y_test.Churn_No)/len(y_test.Churn_No)
0.73450070989115
  • הקטגוריה הנפוצה יותר היא לקוחות שנשארו בחברה ולכן השיעור שלהם בקבוצת המבחן הוא ה-null accuracy.
  • ערך null accuracy 73% אומר לנו שמודל פשוט שכל מה שהוא יודע לעשות הוא לחזות עבור 100% מהדוגמאות שהם לא יעזבו את החברה היה צודק ב-73% מהמקרים. בכלל לא רחוק מ-79.3% מה שגורם לי למחשבות שניות בנוגע ליעילות המודל.

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

 

מציאת מודל למידת המכונה הטוב ביותר באמצעות Keras Tuner

Keras Tuner היא ספרייה מבוססת אלגוריתם שפתחו בגוגל במטרה למצוא את ההיפר-פרמטרים הטובים ביותר שיש להשתמש בהם במודל TensorFlow. האלגוריתם סורק את מרחב האפשרויות שמעבירים אליו. דוגמת: מספר שכבות, מספר יחידות בשכבה, קצב למידה עד שהוא מוצא את שילוב ההיפר-פרמטרים שמגיע לתוצאות הטובות ביותר.

נתקין את הספרייה:

!pip install -U keras-tuner

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

# build the model
def build_model(hp):
    model = Sequential()
    # the model layers
    meturn model
  • בתוך הפונקציה Keras Tuner בונה את המודלים.
  • הפונקציה מקבלת משתנה hp היפר-פרמטרים ומחזירה מודלים של TensorFlow.

נוסיף את שכבת הקלט:

# build the model
def build_model(hp):
   model = Sequential()
 
   # input layers with variable number of units
   model.add(Dense(units=hp.Int('units',
                                min_value=8,
                                max_value=640,
                                step=8),
                                input_shape=(45,),
                                activation='relu'))
  • מספר יחידות הקלט חייב להיות 45. כמספר העמודות הבלתי תלויות במסד הנתונים.
  • בתוך הפונקציה hp.Int() אנחנו מגדירים את הטווח של מספר הנוירונים בשכבה. המספר יכול להיות בין 8 - 640 בקפיצות של 8.

נוסיף את השכבות החבויות:

# build the model
def build_model(hp):
   model = Sequential()
 
   # input layers with variable number of units
   model.add(Dense(units=hp.Int('units',
                                min_value=8,
                                max_value=640,
                                step=8),
                                input_shape=(45,),
                                activation='relu'))
 
   # variable number of hidden layers
   # with a variable number of units in each
   for i in range(hp.Int('num_layers', 1, 4)):
       model.add(Dense(units=hp.Int('units_' + str(i),
                                           min_value=8,
                                           max_value=640,
                                           step=8),
                                           activation='relu'))
  • הגדרנו מספר משתנה של שכבות ויחידות בכל שכבה מהם האלגוריתם צריך לבחור .
  • את מספר השכבות נגדיר בתוך לולאה פייתונית.
  • את מספר היחידות בכל שכבה אנו מגדירים שוב באמצעות הפונקציה hp.Int()

נגדיר את שכבת הפלט:

# build the model
def build_model(hp):
   model = Sequential()
 
   # input layers with variable number of units
   model.add(Dense(units=hp.Int('units',
                                min_value=8,
                                max_value=640,
                                step=8),
                                input_shape=(45,),
                                activation='relu'))
 
   # variable number of hidden layers
   # with a variable number of units in each
   for i in range(hp.Int('num_layers', 1, 4)):
       model.add(Dense(units=hp.Int('units_' + str(i),
                                           min_value=8,
                                           max_value=640,
                                           step=8),
                                           activation='relu'))
 
   # output layer
   model.add(Dense(2, activation='softmax'))
  • שכבת הפלט היא ללא שינוי וכוללת 2 יחידות כמספר הקטגוריות ופונקצית אקטיבציה softmax. .

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

# build the model
def build_model(hp):
   model = Sequential()
 
   # input layers with variable number of units
   model.add(Dense(units=hp.Int('units',
                                min_value=8,
                                max_value=640,
                                step=8),
                                input_shape=(45,),
                                activation='relu'))
 
   # variable number of hidden layers
   # with a variable number of units in each
   for i in range(hp.Int('num_layers', 1, 4)):
       model.add(Dense(units=hp.Int('units_' + str(i),
                                           min_value=8,
                                           max_value=640,
                                           step=8),
                                           activation='relu'))
  
   # output layer
   model.add(Dense(2, activation='softmax'))
 
   # a range of learning rates to choose from
   hp_learning_rate = hp.Choice('learning_rate', values = [1e-2, 1e-3, 1e-4])
  
   model.compile(optimizer = keras.optimizers.Adam(learning_rate = hp_learning_rate),
                 loss='categorical_crossentropy',
                 metrics = ['accuracy'])
    return model
  • העברנו לפונקציה hp.Choice() טווח של ערכים אפשריים לבחור מתוכם את קצב הלמידה.

ניצור תיקייה שבתוכה ה-tuner ישמור את המודלים השונים שהוא בודק בתהליך.

!rm -rf /content/logs

הפונקציה RandomSearch() היא זו שמייצרת את המודלים ובוחרת את המוצלחים:

tuner = RandomSearch(
   build_model,
   objective='val_accuracy',
   max_trials=5,
   executions_per_trial=3,
   directory='/content/logs',
   project_name='optimize_churn')

הפרמטרים כוללים:

  • את שם הפונקציה שבונה את המודלים ללא פרמטרים. רק השם.
  • הפרמטר objective מגדיר את מטרת המודל. במקרה זה המטרה היא להגיע ל-accuracy הגבוה ביותר עבור קבוצת המבחן.
  • מספר הניסיונות max_trials הוא 5 הכוללים 3 ניסיונות לבניית מודלים בכל ניסיון executions_per_trial.
  • באמצעות הפרמטרים directory ו-project_name נגדיר את התיקייה שבתוכה ישמרו תוצאות הניסוי.

את מקום הפונקציה fit() שבה אנו משתמשים בדרך כלל להרצת מודלים של TensorFlow תתפוס הפונקציה tuner.search() שמקבלת את אותם הפרמטרים:

#model.fit(X_train, y_train, validation_data=(X_test,y_test), batch_size=32, epochs=10)
 
tuner.search(x = X_train,
             y = y_train,
             epochs = 5,
             batch_size = 32,
             validation_data = (X_test, y_test))

התוצאה:

Trial 5 Complete [00h 00m 12s]
val_accuracy: 0.7830888231595358

Best val_accuracy So Far: 0.7830888231595358
Total elapsed time: 00h 01m 32s
INFO:tensorflow:Oracle triggered exit

מהם המודלים הטובים ביותר שמצא האלגוריתם?

tuner.results_summary()
Results summary
Results in /content/logs/optimize_churn
Showing 10 best trials
Objective(name='val_accuracy', direction='max')
Trial summary
Hyperparameters:
units: 488
num_layers: 3
units_0: 168
learning_rate: 0.0001
units_1: 256
units_2: 40
Score: 0.7830888231595358
Trial summary
Hyperparameters:
units: 608
num_layers: 3
units_0: 624
learning_rate: 0.01
units_1: 576
units_2: 152
Score: 0.7805647651354471
Trial summary
Hyperparameters:
units: 528
num_layers: 1
units_0: 440
learning_rate: 0.001
units_1: 432
units_2: 528
Score: 0.7794604897499084
Trial summary
Hyperparameters:
units: 448
num_layers: 2
units_0: 152
learning_rate: 0.01
units_1: 8
Score: 0.7345007061958313
Trial summary
Hyperparameters:
units: 568
num_layers: 3
units_0: 488
learning_rate: 0.01
units_1: 592
units_2: 8
Score: 0.7345007061958313
  • המודל הטוב ביותר הגיע ל-accuracy של 0.78
  • המודל החמישי בטיבו הגיע לציון 0.73

נבחן את המודל הנבחר.

נמצה את ההיפר-פרמטרים עבור המודל הנבחר:

# Get the optimal hyperparameters
best_hps = tuner.get_best_hyperparameters(num_trials = 1)[0]

מההיפר-פרמטרים נבנה את המודל הנבחר:

# Build the model with the optimal hyperparameters and train it on the data
model = tuner.hypermodel.build(best_hps)
  • המודל הוא כרגיל כשמשתמשים ב- TensorFlow.

מהי ארכיטקטורת המודל?

print(model.summary())
Model: "sequential"
_________________________________________
Layer (type)       Output Shape   Param #   
=========================================
dense (Dense)      (None, 488)      22448     
_________________________________________
dense_1 (Dense)    (None, 168)      82152     
_________________________________________
dense_2 (Dense)    (None, 256)      43264     
_________________________________________
dense_3 (Dense)    (None, 40)       10280     
_________________________________________
dense_4 (Dense)    (None, 2)           82        
=========================================
Total params: 158,226
Trainable params: 158,226
Non-trainable params: 0
_________________________
  • 3 שכבות חבויות.
  • 488 יחידות בשכבת הקלט
  • מספר היחידות מצטמצם בין השכבות החבויות מ-168 ל-40.

נריץ את המודל:

model.fit(X_train, y_train,
         batch_size = 32,
         epochs = 5,
         validation_data = (X_test, y_test))

train the model that keras tuner found

נעריך את ביצועי המודל:

loss, acc = model.evaluate(X_test, y_test)
67/67 [====] - 0s 1ms/step - loss: 1.4820 - accuracy: 0.7795
  • המודל אותו בחר האלגוריתם הגיע לרמת דיוק של 78%.

 

סיכום ודיון

במדריך השתמשתי באלגוריתם Keras Tuner כדי לבחור את המודל הטוב ביותר, והשוותי את התוצאות למודל שאני פתחתי על סמך הידע שרכשתי בתחום. להפתעתי, Keras Tuner לא התעלה על התוצאות שהמודל שלי השיג על פי מדד דיוק (78% לעומת 80%). יתרה מכך, המודל שהצעתי הוא קטן ורזה משמעותית מבחינת מספר השכבות ומספר היחידות בכל שכבה. הבדל שמהווה יתרון משמעותי בכל הנוגע לסיכוי להתאמת יתר over fitting ואם המודל היה גדול יותר החסכון הוא גם בחשמל ובשעות מחשב.

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

בכל מקרה, טוב שיהיה כלי כמו ה- Keras Tuner בארגז הכלים אבל גם לא צריך לזנוח את דרך הלמידה שעמדה לאבותינו ולנו.

 

אולי גם זה יעניין אותך?

לכל המדריכים בנושא של למידת מכונה

 

אהבתם? לא אהבתם? דרגו!

0 הצבעות, ממוצע 0 מתוך 5 כוכבים

 

 

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

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

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

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

השימוש שלך באתר מהווה ראייה להסכמתך עם הכללים והתקנות שנוסחו בהסכם תנאי השימוש.

הוסף תגובה חדשה

 

 

ענה על השאלה הפשוטה הבאה כתנאי להוספת תגובה:

דג למים הוא כמו ציפור ל...?