قواعد بايثون شائعة في علم البيانات (متقدمة)

في الأيام القليلة الماضية، كنت أقرأ كتاب Data Science from Scratch (رابط PDF)، وهو كتاب تمهيدي ممتاز وسهل الفهم في علم البيانات. يخصص أحد فصوله لشرح قواعد بايثون الأساسية والقواعد المتقدمة الشائعة في علم البيانات، وقد وجدت الشرح ممتازًا وموجزًا وواضحًا. لذلك، قمت بترجمته ووضعه هنا كمرجع. قواعد بايثون الشائعة في علم البيانات (أساسية) قواعد بايثون الشائعة في علم البيانات (متقدمة)

يركز هذا الفصل على تقديم قواعد ووظائف بايثون المتقدمة المفيدة جدًا في معالجة البيانات (بناءً على Python 2.7).

الفرز Sorting

إذا أردت فرز قائمة في بايثون، يمكنك استخدام الدالة 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) # الناتج [-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]

وبالمثل، يمكنك تحويل القوائم إلى قواميس (dictionaries) أو مجموعات (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) ستنتج قائمة تحتوي على مليون عنصر. إذا كنت تعالج البيانات عنصرًا واحدًا تلو الآخر، فقد يستغرق الأمر وقتًا طويلاً جدًا (أو قد ينفد الذاكرة). وفي الواقع، قد تحتاج فقط إلى أول بضعة عناصر، مما يجعل العمليات الأخرى زائدة عن الحاجة.

تسمح لك المولدات (generators) بالتكرار فقط على البيانات التي تحتاجها. يمكنك إنشاء مولد باستخدام دالة وتعبير yield:

def lazy_range(n):
    """نسخة كسولة من range"""
    i = 0
    while i < n:
        yield i
        i += 1

ملاحظة المترجم: المولد هو أيضًا نوع خاص من المكررات (iterators)، وyield هو المفتاح لتحقيق التكرار في المولدات. يعمل كنقطة إيقاف واستئناف لتنفيذ المولد، حيث يمكن تعيين قيمة لتعبير yield، ويمكن أيضًا إرجاع قيمة تعبير yield. أي دالة تحتوي على عبارة yield تسمى مولدًا. عند الخروج من المولد، يقوم المولد بحفظ حالة التنفيذ الحالية واستعادتها عند التنفيذ التالي للحصول على القيمة التكرارية التالية. استخدام القوائم للتكرار سيستهلك مساحة ذاكرة كبيرة، بينما استخدام المولدات يستهلك مساحة ذاكرة واحدة تقريبًا، مما يوفر الذاكرة.

ستستهلك هذه الحلقة قيمة واحدة من yield في كل مرة حتى يتم استهلاكها بالكامل:

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

(في الواقع، بايثون تأتي مع دالة مدمجة تحقق تأثير _lazy_range_ المذكور أعلاه، وتسمى xrange، وفي بايثون 3 تسمى lazy.) هذا يعني أنه يمكنك إنشاء سلسلة لا نهائية:

def natural_numbers():
    """تُرجع 1, 2, 3, ..."""
    n = 1
    while True:
        yield n
        n += 1

ومع ذلك، لا يُنصح باستخدام عبارات حلقة لا تحتوي على منطق للخروج.

نصيحة

أحد عيوب التكرار باستخدام المولدات هو أنه يمكن تكرار العناصر من البداية إلى النهاية مرة واحدة فقط. إذا أردت التكرار عدة مرات، فعليك إنشاء مولدات جديدة في كل مرة أو استخدام القوائم.

الطريقة الثانية لإنشاء مولد: استخدام تعبيرات الفهم داخل الأقواس:

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)           # تعيين البذرة إلى 10
print random.random()     # 0.57140259469
random.seed(10)           # إعادة تعيين البذرة إلى 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]

يمكنك الحصول على عدة عينات عشوائية (مع السماح بالتكرار) من خلال استدعاء الدالة عدة مرات:

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

التعبيرات المنتظمة Regular Expressions

تُستخدم التعبيرات المنتظمة للبحث في النصوص، وهي معقدة بعض الشيء لكنها مفيدة جدًا، ولذلك توجد كتب كثيرة مخصصة لشرحها. سنقوم بشرحها بالتفصيل عندما نصادفها، وفيما يلي بعض الأمثلة على استخدام التعبيرات المنتظمة في بايثون:

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

كما هو الحال في العديد من اللغات، تسمح لك بايثون بتعريف الفئات (classes) التي تُغلف البيانات والدوال (functions) التي تتعامل معها. سنستخدمها أحيانًا لجعل شيفرتنا أكثر وضوحًا وتبسيطًا. ربما يكون أسهل طريقة لشرحها هي بناء مثال يحتوي على الكثير من التعليقات. لنفترض عدم وجود مجموعات بايثون المدمجة، فقد نرغب في إنشاء فئتنا الخاصة Set. فما هي الوظائف التي يجب أن تتمتع بها هذه الفئة؟ على سبيل المثال، إذا أعطينا Set، نحتاج إلى القدرة على إضافة عناصر إليها، وحذف عناصر منها، والتحقق مما إذا كانت تحتوي على قيمة معينة. لذلك، سنقوم بإنشاء جميع هذه الوظائف كدوال أعضاء لهذه الفئة. وبهذه الطريقة، يمكننا الوصول إلى هذه الدوال الأعضاء باستخدام نقطة بعد كائن Set:

# حسب العرف، نُسمي الفئات بـ _PascalCase_
class Set:
    # هذه هي دوال الأعضاء (Member Functions)
    # كل دالة عضوية تحتوي على معامل "self" في البداية (عرف آخر)
    # "self" يشير إلى كائن Set المحدد الذي يتم استخدامه

    def __init__(self, values=None):
        """هذه دالة الإنشاء (Constructor)
        تُستدعى هذه الدالة في كل مرة تُنشئ فيها كائن 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
        يمكنك الحصول عليه بكتابة اسم الكائن في نافذة أوامر بايثون أو باستخدام دالة 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

وبالمثل، تنفذ المرشحات (filters) وظيفة 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 لإنشاء أزواج tuples (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

إذا كانت أطوال القوائم المتعددة غير متساوية، ستتوقف عملية الضغط عند نهاية أقصر قائمة. يمكنك أيضًا استخدام خدعة “فك الضغط” الغريبة لفك ضغط القوائم:

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) هو زوج (tuple) يحتوي على المعاملات غير المسماة، بينما kwargs (اختصار لـ keyword arguments) هو قاموس (dictionary) يحتوي على المعاملات المسماة.

يمكن استخدامها أيضًا في حالات تمرير المعاملات كقائمة (أو زوج) أو مصفوفة: n:

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

مرحبًا بك في عالم علم البيانات!

دينغ! تهانينا، لقد فتحت بابًا جديدًا إلى عالم آخر! الآن يمكنك الاستمتاع والمرح!

قراءات ذات صلة:

قواعد بايثون الشائعة في علم البيانات (أساسية)