عندما تتعامل مع ملايين الصفوف في pandas، تصبح الأخطاء الصغيرة كوارث حقيقية: تسرب ذاكرة، بطء في المعالجة، نتائج خاطئة. إليك أكثر الأخطاء شيوعاً وكيفية تجنبها من تجارب حقيقية في شركات مثل أوبر وكريم.
في أحد مشاريع تحليل البيانات الضخمة لشركة توصيل طلبات، قضيت ثلاث ساعات كاملة في تتبع سبب بطء معالجة ملف CSV حجمه 2 جيجابايت. المشكلة؟ سطر واحد فقط في الكود: df['new_col'] = df['old_col'].apply(lambda x: x * 2). كان هذا السطر ينشئ سلسلة جديدة كاملة في الذاكرة لكل صف، بدلاً من استخدام العمليات المتجهة في pandas. النتيجة؟ استهلاك ذاكرة وصل إلى 16 جيجابايت قبل أن ينهار السيرفر. هذه ليست مجرد مشكلة أداء، بل هي خطأ في فهم كيفية عمل pandas تحت الغطاء.
pandas ليس مجرد مكتبة لمعالجة البيانات، بل هو محرك متكامل يعتمد على NumPy وCython تحت الغطاء. عندما تخطئ في استخدامه، لا تخسر فقط الوقت، بل تخسر أيضاً الثقة في النتائج التي تنتجها. في هذا المقال، سأفكك الأخطاء الأكثر شيوعاً التي أراها يومياً في أكواد المطورين، سواء كانوا جونيورز أو حتى سينيورز في شركات كبيرة. لن نتحدث عن الأخطاء النحوية البسيطة، بل عن الأخطاء التي تحدث خلف الكواليس وتؤثر على الأداء، الذاكرة، ودقة النتائج.
أكبر خطأ أراه في أكواد pandas هو إنشاء نسخ غير مقصودة للبيانات دون أن يدرك المطور ذلك. عندما تقوم بعملية بسيطة مثل df['new_col'] = df['old_col'] * 2، قد تعتقد أنك تضيف عموداً جديداً فقط. لكن خلف الكواليس، pandas يقوم بإنشاء DataFrame جديد بالكامل في الذاكرة إذا كان هناك أي تعديل على البيانات الأصلية. هذا يعني أنك قد ينتهي بك الأمر بثلاث نسخ من نفس البيانات في الذاكرة دون أن تدري.
المشكلة تزداد سوءاً عندما تستخدم دوال مثل apply أو map التي تعمل على مستوى الصفوف الفردية. هذه الدوال ليست متجهة بطبيعتها، مما يعني أنها تعالج كل صف على حدة، وتنشئ كائنات مؤقتة في الذاكرة لكل صف. في مشروع سابق، كان لدينا DataFrame بحجم 5 ملايين صف، واستخدام apply على عمود نصي أدى إلى استهلاك ذاكرة تجاوز 20 جيجابايت قبل أن ينهار البرنامج. الحل؟ استخدام العمليات المتجهة كلما أمكن، أو في أسوأ الأحوال، استخدام list comprehensions التي تكون أسرع من apply في معظم الحالات.
# ❌ خطأ شائع: استخدام apply لإنشاء عمود جديد
# هذا ينشئ نسخة مؤقتة لكل صف، مما يؤدي إلى تسرب ذاكرة
import pandas as pd
df = pd.DataFrame({'values': range(1000000)})
df['doubled'] = df['values'].apply(lambda x: x * 2) # بطيء ويستهلك ذاكرة
# ✅ الحل: استخدام العمليات المتجهة
# هذا أسرع بـ 100 مرة ولا ينشئ نسخاً مؤقتة
import numpy as np
df['doubled'] = df['values'] * 2 # عملية متجهة
# مثال آخر مع النصوص
# ❌ خطأ: استخدام apply على عمود نصي
words = ['hello', 'world', 'pandas'] * 100000
df = pd.DataFrame({'word': words})
df['upper'] = df['word'].apply(lambda x: x.upper()) # بطيء جداً
# ✅ الحل: استخدام str.upper مباشرة
# pandas لديه دوال متجهة للنصوص أيضاً
df['upper'] = df['word'].str.upper() # أسرع بكثيرالحقيقة هي أن معظم المطورين لا يدركون أن pandas يقوم بإنشاء نسخ من البيانات في الخلفية. حتى عمليات بسيطة مثل التصفية df[df['col'] > 5] تنشئ DataFrame جديداً بالكامل. إذا كنت تعمل مع بيانات كبيرة، فهذا يعني أنك تضاعف استهلاك الذاكرة دون داعٍ. الحل الأمثل هو استخدام طرق مثل copy=False عند الضرورة، أو العمل على نفس DataFrame بدلاً من إنشاء نسخ جديدة باستمرار.
الـ Index في pandas هو سلاح ذو حدين. من ناحية، يوفر مرونة كبيرة في الوصول إلى البيانات والتعامل معها. من ناحية أخرى، يمكن أن يصبح كابوساً حقيقياً إذا لم تفهم كيفية عمله. المشكلة الأكبر هي عندما تفترض أن الـ Index هو مجرد رقم تسلسلي، بينما في الواقع قد يكون أي شيء: نص، تاريخ، أو حتى كائن معقد.
في أحد المشاريع، كان لدينا DataFrame يحتوي على تواريخ كـ Index. عند محاولة دمج هذا الـ DataFrame مع آخر، كانت النتيجة فوضى كاملة لأن التواريخ لم تكن متطابقة تماماً (بسبب فروق في التوقيت أو تنسيق التاريخ). بدلاً من الدمج السلس الذي توقعناه، حصلنا على NaN في كل مكان. السبب؟ pandas يستخدم الـ Index كمفتاح رئيسي في عمليات الدمج، وإذا لم يكن متطابقاً تماماً، سينشئ صفوفاً جديدة بدلاً من الدمج الصحيح.
# ❌ خطأ شائع: تجاهل نوع الـ Index
# إذا كان الـ Index تواريخ، يجب أن تكون متطابقة تماماً
import pandas as pd
df1 = pd.DataFrame({'value': [1, 2, 3]}, index=pd.to_datetime(['2023-01-01', '2023-01-02', '2023-01-03']))
df2 = pd.DataFrame({'value': [4, 5, 6]}, index=pd.to_datetime(['2023-01-01 00:00:00', '2023-01-02', '2023-01-03']))
# هذا الدمج سينتج NaN لأن التواريخ ليست متطابقة تماماً
merged = pd.merge(df1, df2, left_index=True, right_index=True)
print(merged) # ستجد NaN في الصف الأول
# ✅ الحل: توحيد تنسيق التواريخ قبل الدمج
# استخدم نفس تنسيق التاريخ في كلا الـ DataFrames
df2.index = pd.to_datetime(df2.index).normalize() # إزالة الوقت من التاريخ
merged = pd.merge(df1, df2, left_index=True, right_index=True)
print(merged) # الآن الدمج صحيحمشكلة أخرى شائعة هي إعادة تعيين الـ Index باستخدام reset_index دون فهم العواقب. هذه الدالة تحول الـ Index إلى عمود عادي، مما قد يؤدي إلى مشاكل إذا كنت تعتمد على الـ Index في عمليات لاحقة. في شركة كريم، واجهنا مشكلة حيث كان الـ Index يحتوي على معرفات فريدة للمستخدمين، وعند إعادة تعيينه، فقدنا القدرة على تتبع المستخدمين بشكل صحيح في عمليات الدمج اللاحقة. الحل؟ استخدام parameter مثل drop=True إذا كنت تريد التخلص من الـ Index دون تحويله إلى عمود.
الكثير من المطورين يحبون كتابة كود pandas بطريقة الـ chaining لأنها تجعل الكود يبدو أنيقاً وسهل القراءة. لكن المشكلة هي أن كل عملية في السلسلة تنشئ DataFrame مؤقت جديد في الذاكرة. إذا كانت سلسلتك تحتوي على 10 عمليات، فهذا يعني أنك تنشئ 10 نسخ مؤقتة من البيانات قبل الوصول إلى النتيجة النهائية. في مشروع لتحليل بيانات المستخدمين في أوبر، كان لدينا سلسلة من 15 عملية على DataFrame بحجم 10 ملايين صف، واستغرق الكود أكثر من 20 دقيقة لينفذ، بينما كان يمكن تنفيذه في أقل من دقيقة باستخدام نهج مختلف.
الحل ليس التخلي عن الـ chaining بالكامل، بل استخدامه بحكمة. على سبيل المثال، يمكنك تجميع العمليات المتشابهة معاً لتقليل عدد النسخ المؤقتة. بدلاً من كتابة df[df['col'] > 5].groupby('category').sum().reset_index()، يمكنك استخدام query التي تكون أسرع في بعض الحالات: df.query('col > 5').groupby('category').sum().reset_index(). أيضاً، استخدم دوال مثل assign بدلاً من إنشاء أعمدة جديدة بشكل منفصل، لأنها مصممة للعمل مع الـ chaining دون إنشاء نسخ إضافية.
# ❌ خطأ: سلسلة طويلة من العمليات تنشئ نسخاً مؤقتة
# هذا الكود ينشئ 4 نسخ مؤقتة على الأقل
result = (
df[df['age'] > 30]
.groupby('city')
.agg({'salary': 'mean'})
.sort_values('salary', ascending=False)
.reset_index()
)
# ✅ الحل: استخدام query وassign لتقليل النسخ المؤقتة
# هذا الكود أسرع وأكثر كفاءة في الذاكرة
result = (
df.query('age > 30')
.groupby('city')
.agg(avg_salary=('salary', 'mean'))
.sort_values('avg_salary', ascending=False)
.reset_index()
)
# مثال آخر: استخدام assign بدلاً من إنشاء أعمدة جديدة
# ❌ خطأ: إنشاء عمود جديد في كل سطر
filtered = df[df['age'] > 30]
filtered['age_group'] = filtered['age'].apply(lambda x: '30-40' if x < 40 else '40+')
result = filtered.groupby('age_group').mean()
# ✅ الحل: استخدام assign للحفاظ على الـ chaining
result = (
df[df['age'] > 30]
.assign(age_group=lambda x: np.where(x['age'] < 40, '30-40', '40+'))
.groupby('age_group')
.mean()
)في تجربتي، الـ chaining مفيد للكود القصير والمباشر، لكنه يصبح كارثة عندما تتعامل مع بيانات كبيرة. إذا كنت تعمل على DataFrame بحجم يتجاوز المليون صف، فكر مرتين قبل استخدام سلسلة طويلة من العمليات. بدلاً من ذلك، قسم الكود إلى خطوات منطقية واستخدم متغيرات مؤقتة لتخزين النتائج الوسيطة. هذا ليس فقط أسرع، بل يجعل الكود أسهل في الـ debugging أيضاً.
واحدة من أكثر المشاكل إحباطاً في pandas هي تسرب الذاكرة. قد تظن أن الكود يعمل بشكل طبيعي، لكن خلف الكواليس، pandas يحتفظ بمراجع للبيانات المؤقتة ولا يطلقها حتى بعد انتهاء العملية. هذا يؤدي إلى زيادة مستمرة في استهلاك الذاكرة حتى ينهار البرنامج. في أحد المشاريع، كان لدينا سكربت لمعالجة ملفات CSV يومية، وكان يستهلك ذاكرة تزيد عن 30 جيجابايت بعد ساعات قليلة من التشغيل، رغم أن حجم الملفات الأصلية لم يتجاوز 5 جيجابايت.
المشكلة الرئيسية تأتي من استخدام دوال مثل apply مع كائنات خارجية، أو الاحتفاظ بمراجع للـ DataFrames المؤقتة. على سبيل المثال، إذا كنت تستخدم global variable داخل دالة apply، فإن pandas يحتفظ بمرجع لهذه المتغير حتى بعد انتهاء الدالة، مما يمنع جمع القمامة من تحرير الذاكرة. أيضاً، استخدام دوال مثل groupby بدون تخزين النتيجة في متغير يمكن أن يؤدي إلى تسرب ذاكرة لأن pandas قد يحتفظ بمراجع للبيانات المؤقتة داخلياً.
# ❌ خطأ: تسرب ذاكرة بسبب استخدام متغير خارجي في apply
import pandas as pd
import numpy as np
global_var = np.random.rand(1000000) # متغير كبير في الذاكرة
def process_row(row):
# استخدام global_var داخل apply يؤدي إلى تسرب ذاكرة
return row['value'] * global_var[row['id'] % len(global_var)]
df = pd.DataFrame({'value': range(1000000), 'id': range(1000000)})
df['result'] = df.apply(process_row, axis=1) # تسرب ذاكرة هنا
# ✅ الحل: تجنب استخدام متغيرات خارجية في apply
# بدلاً من ذلك، قم بتمرير البيانات اللازمة كوسائط
# أو استخدم دوال متجهة بدلاً من apply
def safe_process(row, multiplier):
return row['value'] * multiplier[row['id'] % len(multiplier)]
multiplier = np.random.rand(1000000) # متغير محلي
# استخدم list comprehension بدلاً من apply إذا أمكن
# أو قم بتمرير multiplier كوسيط للدالة
# df['result'] = df.apply(safe_process, args=(multiplier,), axis=1)حل آخر لتجنب تسرب الذاكرة هو استخدام del لحذف المتغيرات الكبيرة بعد الانتهاء منها، واستدعاء gc.collect() لإجبار جمع القمامة على العمل. أيضاً، إذا كنت تعمل مع بيانات كبيرة، استخدم chunksize عند قراءة الملفات الكبيرة، أو استخدم مكتبات مثل Dask التي تدير الذاكرة بشكل أفضل من pandas التقليدي. في شركة كريم، استخدمنا Dask لمعالجة ملفات بحجم 100 جيجابايت، وكانت النتيجة مذهلة: استهلاك ذاكرة ثابت عند 8 جيجابايت فقط بدلاً من 50 جيجابايت مع pandas التقليدي.
الـ dtype في pandas هو أحد أكثر الأشياء التي يتم تجاهلها، لكنه يمكن أن يكون السبب الرئيسي في بطء الأداء أو استهلاك الذاكرة الزائد. عندما تقرأ ملف CSV باستخدام read_csv، pandas يحاول تخمين نوع البيانات لكل عمود. لكن هذا التخمين ليس دائماً دقيقاً، وقد ينتهي بك الأمر بعمود نصي بدلاً من رقم، أو العكس. في مشروع لتحليل بيانات المبيعات، كان لدينا عمود للأرقام يحتوي على قيم مثل '1,000' بدلاً من 1000، مما أدى إلى تحويل العمود إلى نص بدلاً من رقم. النتيجة؟ عمليات الجمع والطرح كانت تعطي نتائج خاطئة تماماً.
المشكلة الأكبر هي أن pandas يحتفظ بنوع البيانات حتى بعد تعديلها. على سبيل المثال، إذا كان لديك عمود نصي يحتوي على أرقام، وقمت بتحويله إلى أرقام باستخدام astype(int)، فإن pandas سيحتفظ بالنوع الجديد، لكن قد تبقى بعض القيم كـ NaN إذا لم تكن قابلة للتحويل. هذا يؤدي إلى مشاكل في العمليات الحسابية لاحقاً. الحل هو استخدام دوال مثل pd.to_numeric مع parameter مثل errors='coerce' التي تحول القيم غير القابلة للتحويل إلى NaN بدلاً من رفع خطأ.
# ❌ خطأ: قراءة ملف CSV بدون تحديد أنواع البيانات
# pandas قد يخمن النوع الخطأ، مما يؤدي إلى مشاكل لاحقاً
df = pd.read_csv('sales_data.csv')
# إذا كان العمود يحتوي على قيم مثل '1,000'، سيصبح نصاً
print(df['price'].dtype) # قد يكون object بدلاً من float
# ✅ الحل: تحديد أنواع البيانات عند القراءة
# استخدم dtype لتحديد النوع الصحيح لكل عمود
# أو استخدم converters لتحويل القيم أثناء القراءة
df = pd.read_csv('sales_data.csv', dtype={'price': float})
# مثال آخر: تحويل عمود نصي إلى أرقام
# ❌ خطأ: استخدام astype بدون التعامل مع القيم غير القابلة للتحويل
df['price'] = df['price'].astype(float) # سيرفع خطأ إذا كانت هناك قيم نصية
# ✅ الحل: استخدام pd.to_numeric مع errors='coerce'
df['price'] = pd.to_numeric(df['price'], errors='coerce') # يحول القيم غير القابلة إلى NaN
# مثال متقدم: استخدام category لتقليل استهلاك الذاكرة
# إذا كان لديك عمود يحتوي على قيم متكررة (مثل الجنس أو الدولة)
df['gender'] = df['gender'].astype('category') # يقلل الذاكرة بشكل كبيرفي تجربتي، تحديد أنواع البيانات بشكل صريح عند قراءة الملفات يمكن أن يقلل استهلاك الذاكرة بنسبة تصل إلى 70%. أيضاً، استخدام category بدلاً من object للأعمدة التي تحتوي على قيم متكررة يمكن أن يقلل الذاكرة بشكل كبير. في شركة أوبر، استخدمنا هذا الأسلوب لتقليل حجم البيانات من 10 جيجابايت إلى 3 جيجابايت فقط، مما جعل المعالجة أسرع بكثير.
بعد عشر سنوات من العمل مع pandas في مشاريع مختلفة، هذه هي النصائح الذهبية التي أتمنى لو عرفتها في بداية مسيرتي: أولاً، دائماً استخدم العمليات المتجهة بدلاً من apply أو loops. ثانياً، افهم أن كل عملية في pandas قد تنشئ نسخة جديدة من البيانات، لذا قلل من عدد العمليات المؤقتة. ثالثاً، اهتم بالـ dtype وIndex منذ البداية، لأن تغييرهما لاحقاً سيكون مكلفاً. رابعاً، استخدم chunksize أو مكتبات مثل Dask عندما تتعامل مع بيانات كبيرة. وأخيراً، لا تنسَ أن pandas ليس مجرد أداة لمعالجة البيانات، بل هو محرك معقد له سلوكياته الخاصة، وفهم هذه السلوكيات هو ما يميز المطور الجيد عن المبتدئ.
إذا كان لديك نصيحة واحدة فقط لتأخذها من هذا المقال، فهي هذه: لا تفترض أن الكود الذي يعمل على عينة صغيرة من البيانات سيعمل بنفس الكفاءة على البيانات الكبيرة. دائماً اختبر الكود على بيانات بحجم مماثل للبيانات الحقيقية، واستخدم أدوات مثل memory_profiler وcProfile لتحليل الأداء واستهلاك الذاكرة. ، ستتجنب الكوارث التي قد تكلفك أياماً من الـ Debugging.