תחביר Python נפוץ במדעי הנתונים (מתקדם)

בימים אלו אני קורא את הספר Data Science from Scrach (כתובת PDF), שהוא ספר מבוא מצוין וקל להבנה למדעי הנתונים. אחד הפרקים בספר מציג את התחביר הבסיסי של Python, יחד עם תחביר מתקדם הנפוץ במדעי הנתונים. מצאתי את ההסברים מצוינים, תמציתיים ובהירים, ולכן החלטתי לתרגם אותם ולרכז אותם כאן לעיון עתידי. תחביר Python נפוץ במדעי הנתונים (בסיסי) תחביר Python נפוץ במדעי הנתונים (מתקדם)

פרק זה מתמקד בהצגת פונקציות ותחביר מתקדם של Python, שימושיים במיוחד לעיבוד נתונים (מבוסס Python 2.7).

מיון Sorting

אם אתם רוצים למיין רשימה ב-Python, אפשר להשתמש במתודת sort של הרשימה. אם אינכם רוצים לשנות את הרשימה המקורית, השתמשו בפונקציה sorted שתחזיר רשימה חדשה וממוינת:

x = [4,1,2,3]
y = sorted(x)       # y = [1,2,3,4], x לא משתנה
x.sort()            # כעת x = [1,2,3,4]
# כברירת מחדל, sort וגם sorted ממיינות את הרשימה בסדר עולה (מהקטן לגדול).

כדי למיין בסדר יורד (מהגדול לקטן), יש לציין את הפרמטר reverse = True.

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

# ממיין לפי ערך מוחלט, בסדר יורד
x = sorted([-4,1,-2,3], key=abs, reverse=True) # is [-4,3,-2,1]
# ממיין לפי מספר הפעמים שהמילה מופיעה, בסדר יורד
wc = sorted(word_counts.items(),
key=lambda (word, count): count,
reverse=True)

הבנות רשימה List Comprehensions

לעתים קרובות אנו נתקלים בצורך לחלץ איברים ספציפיים מרשימה כדי ליצור רשימה חדשה, לשנות את ערכם של איברים קיימים, או שניהם יחד. הדרך המקובלת וה’פיית’ונית’ לעשות זאת היא באמצעות הבנות רשימה (List Comprehensions):

even_numbers = [x for x in range(5) if x % 2 == 0]  # [0, 2, 4]
squares = [x * x for x in range(5)]                 # [0, 1, 4, 9, 16]
even_squares = [x * x for x in even_numbers]        # [0, 4, 16]

באופן דומה, אפשר להפוך רשימות למילונים או לקבוצות (sets):

square_dict = { x : x * x for x in range(5) }       # { 0:0, 1:1, 2:4, 3:9, 4:16 }
square_set = { x * x for x in [1, -1] }             # { 1 }

אם אין לכם צורך להשתמש בערך של איברי הרשימה, תוכלו להשתמש בקו תחתון (_) כמשתנה:

zeroes = [0 for _ in even_numbers] # באורך זהה לרשימה even_numbers

הבנות רשימה תומכות בלולאות for מרובות:

pairs = [(x, y)
    for x in range(10)
    for y in range(10)]    # סך הכל 100 זוגות: (0,0) (0,1) ... (9,8), (9,9)

לולאת ה-for הפנימית יכולה להשתמש בתוצאות של לולאת ה-for החיצונית:

increasing_pairs = [(x, y)                      # מכיל רק זוגות שבהם x < y
                    for x in range(10)          # range(lo, hi) שווה ל-
                    for y in range(x + 1, 10)]  # [lo, lo + 1, ..., hi - 1]

נשתמש בהבנות רשימה לעתים קרובות בעתיד.

גנרטורים ואיטרטורים Generators and Iterators

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

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

def lazy_range(n):
    """גרסה 'עצלה' של range"""
    i = 0
    while i < n:
        yield i
        i += 1

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

הלולאה הבאה תצרוך ערך אחד מתוך ה-yield בכל פעם, עד שכל הערכים יצרכו:

for i in lazy_range(10):
    do_something_with(i)

(למעשה, Python מגיעה עם פונקציה מובנית שמשיגה את אותו אפקט כמו lazy_range לעיל, הנקראת xrange ב-Python 2, וב-Python 3 היא range שהיא עצמה גנרטור/איטרטור). משמעות הדבר היא שתוכלו ליצור סדרה אינסופית:

def natural_numbers():
    """מחזיר 1, 2, 3, ..."""
    n = 1
    while True:
        yield n
        n += 1

עם זאת, לא מומלץ להשתמש בהצהרות כאלו ללא לוגיקה של יציאה מהלולאה.

טיפ

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

הדרך השנייה ליצור גנרטור היא באמצעות ביטוי הבנה בתוך סוגריים עגולים (generator expression):

lazy_evens_below_20 = (i for i in lazy_range(20) if i % 2 == 0)

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

אקראיות Randomness

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

import random
four_uniform_randoms = [random.random() for _ in range(4)]
# [0.8444218515250481,        # random.random() מייצר מספר אקראי
# 0.7579544029403025,         # המספר האקראי מנורמל, וטווחו בין 0 ל-1
# 0.420571580830845,          # זוהי הפונקציה הנפוצה ביותר ליצירת מספרים אקראיים
# 0.25891675029296335]

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

random.seed(10)           # קובע את ה-seed ל-10
print random.random()     # 0.57140259469
random.seed(10)           # מאפס את ה-seed ל-10
print random.random()     # 0.57140259469 שוב

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

random.randrange(10)      # בוחר מספר אקראי מתוך range(10) = [0, 1, ..., 9]
random.randrange(3, 6)    # בוחר מספר אקראי מתוך range(3, 6) = [3, 4, 5]

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

up_to_ten = range(10)
random.shuffle(up_to_ten)
print up_to_ten
# [2, 5, 1, 9, 7, 3, 8, 6, 4, 0] (התוצאה שלכם צפויה להיות שונה)

אם רוצים לבחור איבר אקראי אחד מרשימה, אפשר להשתמש במתודת random.choice:

my_best_friend = random.choice(["Alice", "Bob", "Charlie"]) # אני קיבלתי "Bob"

אם רוצים ליצור רצף אקראי אך מבלי לשנות את הרשימה המקורית, אפשר להשתמש במתודת random.sample:

lottery_numbers = range(60)
winning_numbers = random.sample(lottery_numbers, 6) # [16, 36, 10, 6, 25, 9]

ניתן לבחור מספר דגימות אקראיות (עם חזרות) על ידי קריאה חוזרת ל-random.choice:

four_with_replacement = [random.choice(range(10))
                         for _ in range(4)]
# [9, 4, 4, 2]

ביטויים רגולריים Regular Expressions

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

import re
print all([                                 # כל הביטויים הבאים יחזירו True, מכיוון ש-
    not re.match("a", "cat"),               # * 'cat' לא מתחיל ב-'a'
    re.search("a", "cat"),                  # * 'cat' מכיל את האות 'a'
    not re.search("c", "dog"),              # * 'dog' לא מכיל את האות 'c'
    3 == len(re.split("[ab]", "carbs")),    # * מחלק את המילה לשלושה חלקים ['c','r','s'] לפי 'a' או 'b'
    "R-D-" == re.sub("[0-9]", "-", "R2D2")  # * מחליף ספרות במקף
    ])                                      # פלט: True

תכנות מונחה עצמים Object-Oriented Programming

בדומה לשפות רבות אחרות, Python מאפשרת להגדיר מחלקות (classes) שעוטפות נתונים, ופונקציות שמפעילות אותם. לעיתים אנו משתמשים בהן כדי להפוך את הקוד שלנו לברור ותמציתי יותר. הדרך הפשוטה ביותר להסביר זאת היא באמצעות בניית דוגמה מלווה בהערות רבות. נניח שאין ב-Python מבנה נתונים מובנה של Set (קבוצה), וברצוננו ליצור מחלקת Set משלנו. אילו יכולות צריכות להיות למחלקה זו? למשל, בהינתן Set, אנו צריכים להיות מסוגלים להוסיף אליה פריטים, להסיר ממנה פריטים, ולבדוק אם היא מכילה ערך מסוים. לכן, ניצור את כל היכולות הללו כפונקציות חברות (member functions) של המחלקה. באופן זה, נוכל לגשת לפונקציות חברות אלו באמצעות נקודה לאחר אובייקט ה-Set:

# כפי שמקובל, אנו נותנים למחלקות שמות בפורמט _PascalCase_
class Set:
    # אלו הן פונקציות חברות
    # לכל פונקציית חבר יש פרמטר "self" ראשון (עוד מוסכמה)
    # "self" מתייחס לאובייקט ה-Set הספציפי שבו נעשה שימוש

    def __init__(self, values=None):
        """זוהי פונקציית הבנאי.
        היא נקראת בכל פעם שיוצרים Set חדש.
        אפשר לקרוא לה כך:
        s1 = Set() # קבוצה ריקה
        s2 = Set([1,2,2,3]) # מאתחל קבוצה עם ערכים נתונים"""
        self.dict = {} # לכל מופע של Set יש תכונת dict משלו
        # אנו משתמשים בתכונה זו כדי לעקוב אחר כל חבר בקבוצה
        if values is not None:
            for value in values:
            self.add(value)

    def __repr__(self):
        """זהו הייצוג המחרוזתי של אובייקט Set.
        ניתן לקבל אותו על ידי הקלדת שם האובייקט בחלון הפקודה של Python או שימוש במתודת str()."""
        return "Set: " + str(self.dict.keys())

    # נציין חברות בקבוצה על ידי הפיכת הערך למפתח ב-self.dict והצבת True כערך שלו.
    def add(self, value):
        self.dict[value] = True

    # אם הפרמטר הוא מפתח במילון, הערך נמצא בקבוצה (Set).
    def contains(self, value):
        return value in self.dict

    def remove(self, value):
        del self.dict[value]

ואז נוכל להשתמש ב-Set כך:

s = Set([1,2,3])
s.add(4)
print s.contains(4)     # True
s.remove(3)
print s.contains(3)     # False

כלים פונקציונליים Functional Tools

פונקציות חלקיות partial

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

def exp(base, power):
    return base ** power

נרצה להשתמש בה כדי ליצור פונקציה חדשה שמקבלת משתנה אחד, ומחזירה את תוצאת הפונקציה exp(2, power) – כלומר, חזקה עם בסיס 2.

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

def two_to_the(power):
  return exp(2, power)

הדרך החכמה יותר היא להשתמש במתודת functools.partial:

from functools import partial
two_to_the = partial(exp, 2)      # כעת לפונקציה יש משתנה אחד בלבד
print two_to_the(3)               # 8

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

square_of = partial(exp, power=2)
print square_of(3)                # 9

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

מיפוי map

לפעמים נשתמש בפונקציות כמו map, reduce ו-filter כתחליף לפונקציונליות של הבנות רשימה:

def double(x):
    return 2 * x

xs = [1, 2, 3, 4]
twice_xs = [double(x) for x in xs]      # [2, 4, 6, 8]
twice_xs = map(double, xs)              # זהה
list_doubler = partial(map, double)     # הפונקציה מכפילה רשימה
twice_xs = list_doubler(xs)             # גם [2, 4, 6, 8]

מתודת map יכולה לשמש גם למיפוי פונקציות מרובות פרמטרים לרשימות מרובות:

def multiply(x, y): return x * y

products = map(multiply, [1, 2], [4, 5])  # [1 * 4, 2 * 5] = [4, 10]

סינון filter

באופן דומה, filter מיישמת את הפונקציונליות של ה-if בהבנות רשימה:

def is_even(x):
    """מחזיר True אם x זוגי, False אם x אי-זוגי"""
    return x % 2 == 0

x_evens = [x for x in xs if is_even(x)]   # [2, 4]
x_evens = filter(is_even, xs)             # זהה
list_evener = partial(filter, is_even)    # פונקציה שמסננת רשימה
x_evens = list_evener(xs)                 # גם [2, 4]

צמצום reduce

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

x_product = reduce(multiply, xs)          # = 1 * 2 * 3 * 4 = 24
list_product = partial(reduce, multiply)  # פונקציה שמצמצמת רשימה
x_product = list_product(xs)              # גם 24

מנייה enumerate

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

# פחות 'פיית'ונית' (פחות תמציתי ואלגנטי)
for i in range(len(documents)):
    document = documents[i]
    do_something(i, document)

# גם פחות 'פיית'ונית' (פחות תמציתי ואלגנטי)
i = 0
for document in documents:
    do_something(i, document)
    i += 1

הדרך התמציתית ביותר היא להשתמש במתודת enumerate שמייצרת טאפלים מהצורה (index, element):

for i, document in enumerate(documents):
    do_something(i, document)

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

for i in range(len(documents)): do_something(i)   # לא תמציתי
for i, _ in enumerate(documents): do_something(i) # תמציתי

נשתמש בשיטה זו לעיתים קרובות בהמשך.

דחיסה ופריקת ארגומנטים zip and Argument Unpacking

דחיסה zip

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

list1 = ['a', 'b', 'c']
list2 = [1, 2, 3]
zip(list1, list2)       # מחזיר [('a', 1), ('b', 2), ('c', 3)]

פריקת ארגומנטים Argument Unpacking

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

pairs = [('a', 1), ('b', 2), ('c', 3)]
letters, numbers = zip(*pairs)

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

zip(('a', 1), ('b', 2), ('c', 3))  # מחזיר [('a','b','c'), ('1','2','3')]

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

def add(a, b): return a + b

add(1, 2)           # מחזיר 3
add([1, 2])         # שגיאה
add(*[1, 2])        # מחזיר 3

למרות שזה לא תמיד מעשי, זוהי טכניקה נחמדה שיכולה להפוך את הקוד לקומפקטי יותר.

העברת מספר בלתי מוגבל של ארגומנטים args and kwargs

נניח שאנו רוצים ליצור פונקציה מסדר גבוה (higher-order function) שמקבלת פונקציה קיימת, ומחזירה פונקציה חדשה שהיא הפונקציה המקורית כפול 2:

def doubler(f):
    def g(x):
      return 2 * f(x)
    return g

דוגמת הפעלה:

def f1(x):
    return x + 1

g = doubler(f1)
print g(3)        # 8 (== ( 3 + 1) * 2)
print g(-1)       # 0 (== (-1 + 1) * 2)

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

def f2(x, y):
    return x + y

g = doubler(f2)
print g(1, 2) # שגיאה TypeError: g() takes exactly 1 argument (2 given)

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

def magic(*args, **kwargs):
    print "unnamed args:", args
    print "keyword args:", kwargs
magic(1, 2, key="word", key2="word2")
# פלט:
# unnamed args: (1, 2)
# keyword args: {'key2': 'word2', 'key': 'word'}

כאשר אנו מגדירים פונקציה באופן כזה, args (קיצור של arguments) הוא טאפל המכיל את הפרמטרים ללא שם, ואילו kwargs (קיצור של keyword arguments) הוא מילון המכיל את הפרמטרים בעלי השם.

ניתן להשתמש בהם גם כאשר הפרמטרים המועברים הם רשימה (או טאפל) או מילון:

def other_way_magic(x, y, z):
    return x + y + z

x_y_list = [1, 2]
z_dict = { "z" : 3 }
print other_way_magic(*x_y_list, **z_dict)    # 6

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

def doubler_correct(f):
    """עובד ביעילות לכל f"""
    def g(*args, **kwargs):
        """מעביר את כל הארגומנטים ל-f כראוי, ללא קשר למספרם"""
        return 2 * f(*args, **kwargs)
    return g

g = doubler_correct(f2)
print g(1, 2) # 6

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

דינג! מזל טוב! פתחתם דלת לעולם חדש! עכשיו אפשר להתחיל ליהנות ולשחק עם הנתונים בכיף~

קריאה נוספת:

תחביר Python נפוץ במדעי הנתונים (בסיסי)