مقدمة: لماذا كودك في pandas بطيء؟
لنكن صريحين — أغلبنا (أنا شخصياً من ضمنهم) بدأ رحلته مع pandas باستخدام حلقات for ومرّر دوال عبر apply() على كل صف. وهذا طبيعي جداً، لأن هذه هي الطريقة التي نفكر بها كبشر: "لكل صف، نفّذ كذا". المشكلة؟ هذا الأسلوب قد يجعل كودك أبطأ بـ 700 ضعف مقارنة بالبديل الصحيح. نعم، 700 ضعف — ليس رقماً مبالغاً فيه.
في هذا الدليل، سنقارن بين الطرق الأربع الأساسية لمعالجة البيانات في pandas — من الأبطأ إلى الأسرع — مع أمثلة عملية وقياسات أداء حقيقية. وسترى بنفسك كيف يمكنك تحويل عملية تستغرق 45 دقيقة إلى ثوانٍ معدودة.
تسلسل الأداء: من الأبطأ إلى الأسرع
قبل ما ندخل في التفاصيل، إليك الترتيب العام لطرق معالجة البيانات في pandas من حيث السرعة:
- حلقات
forمعiterrows()— الأبطأ على الإطلاق itertuples()— أسرع من iterrows لكنه لا يزال بطيئاًapply()— حل وسط مقبول أحياناً- العمليات المتجهة (Vectorization) — الأسرع بفارق كبير جداً
يلّا نفهم كل طريقة بالتفصيل مع أكواد عملية.
1. iterrows() — لماذا يجب أن تتوقف عن استخدامه
دالة iterrows() تمر على كل صف في DataFrame وتحوّله إلى كائن Series. وهنا المشكلة الكبيرة: إنشاء كائن Series لكل صف منفرد عملية مكلفة جداً من حيث الأداء.
import pandas as pd
import numpy as np
import time
# إنشاء DataFrame تجريبي بمليون صف
df = pd.DataFrame({
"price": np.random.uniform(10, 1000, size=1_000_000),
"quantity": np.random.randint(1, 100, size=1_000_000),
"discount": np.random.uniform(0, 0.3, size=1_000_000)
})
# الطريقة الأبطأ: iterrows
start = time.time()
totals = []
for idx, row in df.iterrows():
total = row["price"] * row["quantity"] * (1 - row["discount"])
totals.append(total)
df["total_iterrows"] = totals
print(f"iterrows: {time.time() - start:.2f} ثانية")
على مليون صف، هذا الكود يستغرق عادةً 30-50 ثانية. صراحةً، لحساب بسيط كهذا الرقم صادم.
لماذا iterrows بطيء إلى هذا الحد؟
- كل صف يُحوَّل إلى كائن
pd.Series— وهذا التحويل وحده مكلف - أنواع البيانات قد تتغير أثناء التحويل (الأعداد الصحيحة مثلاً قد تصبح عشرية بدون سبب واضح)
- لا يمكن لـ Python الاستفادة من التحسينات المنخفضة المستوى في C/C++
- الوصول للذاكرة يتم بشكل متكرر وغير فعال
2. itertuples() — بديل أفضل، لكن ليس بكثير
إذا كنت مُصرّاً على التكرار صفاً بصف (وأحياناً يكون الأمر ضرورياً)، فاستخدم itertuples() بدلاً من iterrows(). الفرق الجوهري أن itertuples() تُعيد كل صف كـ namedtuple بدلاً من Series، مما يوفر حِملاً كبيراً:
# أسرع من iterrows بحوالي 10x
start = time.time()
totals = []
for row in df.itertuples():
total = row.price * row.quantity * (1 - row.discount)
totals.append(total)
df["total_itertuples"] = totals
print(f"itertuples: {time.time() - start:.2f} ثانية")
النتيجة: حوالي 3-5 ثوانٍ — تحسّن بـ 10 أضعاف تقريباً.
لكن هل هذا كافي؟ لا. لسه في الطريق الكثير.
3. apply() — الحل الوسط المخادع
دالة apply() هي الخيار اللي يلجأ إليه معظم مطوري Python عندما يريدون "تطبيق دالة على كل صف". وهي بالفعل أقرب شكلياً من الحلقات العادية — لكنها في جوهرها لا تزال حلقة تكرارية مُقنّعة:
# apply — أسرع من iterrows لكنه ليس الحل الأمثل
start = time.time()
df["total_apply"] = df.apply(
lambda row: row["price"] * row["quantity"] * (1 - row["discount"]),
axis=1
)
print(f"apply (axis=1): {time.time() - start:.2f} ثانية")
النتيجة: حوالي 8-15 ثانية على مليون صف.
وهنا المفاجأة: apply() مع axis=1 قد يكون في الواقع أبطأ من itertuples()! السبب أنه يُنشئ كائن Series لكل صف أيضاً. من تجربتي، كثير من المطورين يفترضون أن apply أسرع دائماً، لكن القياسات تقول غير ذلك.
حيلة raw=True لتسريع apply
إذا كنت مضطراً لاستخدام apply()، أضف raw=True لتخطي إنشاء كائنات Series والعمل مع مصفوفات NumPy مباشرةً:
# apply مع raw=True — أسرع بـ 4x تقريباً
start = time.time()
df["total_apply_raw"] = df.apply(
lambda row: row[0] * row[1] * (1 - row[2]),
axis=1,
raw=True
)
print(f"apply (raw=True): {time.time() - start:.2f} ثانية")
مع raw=True، تنخفض المدة إلى 2-4 ثوانٍ تقريباً. لكن لاحظ أنك تفقد الوصول بأسماء الأعمدة وتضطر لاستخدام الفهارس الرقمية بدلاً منها — وهذا يجعل الكود أقل وضوحاً.
4. العمليات المتجهة — هنا يبدأ السحر الحقيقي
العمليات المتجهة (Vectorized Operations) لا تمر على الصفوف واحداً تلو الآخر. بدلاً من ذلك، تُطبّق العملية على العمود بأكمله دفعة واحدة باستخدام كود C/C++ مُحسّن تحت الغطاء:
# العمليات المتجهة — الأسرع بفارق كبير
start = time.time()
df["total_vectorized"] = df["price"] * df["quantity"] * (1 - df["discount"])
print(f"Vectorized: {time.time() - start:.4f} ثانية")
النتيجة: 0.005-0.01 ثانية فقط!
أقل من جزء من مئة من الثانية لمعالجة مليون صف. هذا أسرع بـ 700 ضعف من iterrows() وأسرع بـ 100 ضعف من apply(). أول مرة شفت الفرق بنفسي، صراحةً ما صدقت الأرقام وأعدت التجربة ثلاث مرات.
لماذا العمليات المتجهة بهذه السرعة؟
- لا حلقات Python: العمليات تتم بالكامل في كود C/C++ مُترجم عبر NumPy
- معالجة الدُفعات: المعالج يعالج عدة قيم في وقت واحد باستخدام تعليمات SIMD
- وصول متسلسل للذاكرة: البيانات مُخزّنة بشكل متجاور، مما يُحسّن أداء الذاكرة المؤقتة (cache)
- لا إنشاء كائنات: لا يتم إنشاء كائنات Series أو tuple لكل صف
جدول المقارنة الشاملة
إليك ملخص الأداء على DataFrame بمليون صف (العملية: حساب الإجمالي من ثلاثة أعمدة):
| الطريقة | الزمن التقريبي | مُعامل السرعة | متى تستخدمها |
|---|---|---|---|
iterrows() | 40 ثانية | 1x (مرجع) | لا تستخدمه أبداً |
itertuples() | 4 ثوانٍ | ~10x | حالات نادرة تحتاج تكراراً حقيقياً |
apply(axis=1) | 12 ثانية | ~3x | منطق معقد لا يمكن تحويله لمتجهات |
apply(raw=True) | 3 ثوانٍ | ~13x | عندما تحتاج apply لكن تريد سرعة أكبر |
| عمليات متجهة | 0.008 ثانية | ~5000x | دائماً — الخيار الافتراضي |
أنماط عملية: كيف تحوّل apply إلى عمليات متجهة
هذا هو السؤال الأهم عملياً: كيف آخذ الكود اللي يستخدم apply() وأحوّله إلى عمليات متجهة؟ إليك أشهر الأنماط التي ستواجهها:
النمط 1: الشروط (if/else)
# ❌ بطيء — استخدام apply مع شرط
df["category"] = df.apply(
lambda row: "expensive" if row["price"] > 500 else "affordable",
axis=1
)
# ✅ سريع — استخدام np.where
df["category"] = np.where(df["price"] > 500, "expensive", "affordable")
الفرق في السرعة هنا ملحوظ جداً، خصوصاً مع بيانات أكثر من 100 ألف صف.
النمط 2: شروط متعددة
# ❌ بطيء
def classify(row):
if row["price"] > 800:
return "premium"
elif row["price"] > 400:
return "mid-range"
else:
return "budget"
df["tier"] = df.apply(classify, axis=1)
# ✅ سريع — استخدام np.select
conditions = [
df["price"] > 800,
df["price"] > 400
]
choices = ["premium", "mid-range"]
df["tier"] = np.select(conditions, choices, default="budget")
النمط 3: عمليات النصوص
# ❌ بطيء
df["name_upper"] = df["name"].apply(lambda x: x.upper())
# ✅ سريع — استخدام .str accessor
df["name_upper"] = df["name"].str.upper()
هذا النمط بسيط لكن كثير من المبتدئين يقعون فيه. دوال .str المدمجة في pandas مُحسّنة داخلياً وأسرع بمراحل.
النمط 4: الحسابات بين أعمدة متعددة
# ❌ بطيء
df["bmi"] = df.apply(
lambda row: row["weight"] / (row["height"] / 100) ** 2,
axis=1
)
# ✅ سريع — عمليات متجهة مباشرة
df["bmi"] = df["weight"] / (df["height"] / 100) ** 2
النمط 5: التعامل مع القيم المفقودة
# ❌ بطيء
df["filled"] = df["value"].apply(lambda x: x if pd.notna(x) else 0)
# ✅ سريع
df["filled"] = df["value"].fillna(0)
عندما لا تستطيع استخدام العمليات المتجهة
بالطبع مش كل شيء يمكن تحويله بسهولة. في بعض الحالات يكون المنطق معقداً بدرجة تجعل التحويل صعباً أو غير عملي. في هذه المواقف، لديك خيارات أخرى:
الخيار 1: Numba مع engine="numba"
بدءاً من pandas 2.2، يمكنك استخدام engine="numba" مع apply() لترجمة الدالة إلى كود آلة مُحسّن عبر مُترجم LLVM:
import numba
@numba.jit(nopython=True)
def complex_calculation(price, quantity, discount):
# منطق معقد لا يمكن تحويله لمتجهات بسهولة
if price > 500 and quantity > 50:
base = price * quantity * 0.9
elif discount > 0.2:
base = price * quantity * (1 - discount * 1.5)
else:
base = price * quantity * (1 - discount)
return base
# استخدام Numba مع apply
df["total"] = df.apply(
lambda row: complex_calculation(row[0], row[1], row[2]),
axis=1,
raw=True,
engine="numba"
)
الاستدعاء الأول بيكون بطيء بسبب مرحلة الترجمة (compilation)، لكن الاستدعاءات اللاحقة ستكون أسرع بـ 50-200 ضعف مقارنة بـ apply العادي. يستحق الانتظار.
الخيار 2: مكتبة Swifter للتحسين التلقائي
مكتبة swifter تختار تلقائياً أفضل طريقة لتنفيذ الدالة — سواء عبر التحويل المتجه أو المعالجة المتوازية عبر Dask:
import swifter
# swifter يختار تلقائياً الطريقة الأسرع
df["total"] = df.swifter.apply(
lambda row: row["price"] * row["quantity"] * (1 - row["discount"]),
axis=1
)
# التثبيت
pip install swifter
أجمل شيء في Swifter أنه يعمل بشفافية — لا تحتاج لتعديل منطق الدالة. فقط أضف .swifter قبل .apply() وهو يتكفل بالباقي.
تحسينات pandas 3.0 للأداء
مع إصدار pandas 3.0 (يناير 2026)، جاءت تحسينات أداء مهمة تستحق الانتباه:
محرك PyArrow للنصوص
أصبح نوع البيانات str المدعوم بـ PyArrow هو الافتراضي. عمليات النصوص مثل .str.contains() و .str.lower() أصبحت أسرع بـ 5-10 أضعاف مع استهلاك ذاكرة أقل بنسبة 50%.
import pandas as pd
# في pandas 3.0 — النصوص تستخدم PyArrow تلقائياً
df = pd.DataFrame({"name": ["أحمد", "سارة", "محمد"] * 333_333})
# هذه العملية أسرع بـ 5-10x مقارنة بالإصدارات السابقة
result = df["name"].str.contains("أح")
Copy-on-Write يقلل النسخ غير الضروري
نظام CoW الذي أصبح افتراضياً في pandas 3.0 يعني أن العمليات مثل التقطيع والفهرسة لم تعد تنسخ البيانات إلا عند الضرورة. النتيجة؟ استهلاك ذاكرة أقل وعمليات متسلسلة أسرع.
نقل البيانات بدون نسخ مع Arrow PyCapsule
إذا كنت تعمل مع مكتبات أخرى مثل Polars أو DuckDB، يمكنك الآن نقل البيانات بينها بدون نسخ عبر بروتوكول Arrow PyCapsule. هذا يجعل أنابيب البيانات متعددة المكتبات أسرع بكثير (وهذه ميزة كنت أتمنى وجودها من زمان).
نصائح إضافية لتسريع pandas
- حسّن أنواع البيانات أولاً: استخدم
int32بدلint64، وfloat32بدلfloat64، وcategoryللأعمدة ذات القيم المتكررة. هذا وحده قد يقلل استهلاك الذاكرة بـ 50-80%. - استخدم
engine="pyarrow"عند القراءة: عند قراءة ملفات CSV كبيرة، أضفengine="pyarrow"لتسريع القراءة بـ 2-3 أضعاف. - حوّل إلى Parquet: إذا كنت تقرأ نفس الملف مراراً، حوّله لصيغة Parquet مرة واحدة. القراءات اللاحقة ستكون أسرع بـ 5-10 أضعاف — فرق تحس فيه فعلياً.
- استخدم
eval()للتعبيرات المعقدة: دالةdf.eval()تسمح بكتابة تعبيرات تُنفَّذ بكفاءة عالية باستخدام محرك numexpr.
# استخدام eval للعمليات المعقدة
df.eval("total = price * quantity * (1 - discount)", inplace=True)
# أو باستخدام query للتصفية
expensive = df.query("price > 500 and quantity > 10")
الأسئلة الشائعة
هل يجب أن أتجنب apply() تماماً؟
ليس بالضرورة. apply() مفيدة عندما يكون المنطق معقداً جداً للتحويل إلى عمليات متجهة. لكن القاعدة الذهبية: ابدأ دائماً بمحاولة كتابة عمليات متجهة، ولا تلجأ لـ apply() إلا كخيار أخير. وعندما تستخدمها، لا تنسَ raw=True.
ما الفرق بين apply على العمود وعلى الصف؟
عندما تستخدم apply() مع axis=0 (الافتراضي)، تُطبَّق الدالة على كل عمود — وهذا سريع نسبياً لأن الأعمدة في الأصل مصفوفات NumPy. المشكلة مع axis=1 لأنها تمر على كل صف وتنشئ كائن Series لكل واحد.
هل np.vectorize أسرع من apply؟
دالة np.vectorize() ليست عمليات متجهة حقيقية رغم اسمها المُضلِّل — إنها في الأساس حلقة تكرارية مُغلّفة بشكل أنيق. أداؤها أفضل قليلاً من apply() في بعض الحالات، لكنها لا تقارن بالعمليات المتجهة الحقيقية.
كيف أعرف أن كودي يمكن تحويله إلى عمليات متجهة؟
اسأل نفسك سؤال بسيط: هل يمكن التعبير عن العملية كمعادلة رياضية أو منطقية تُطبَّق على أعمدة كاملة؟ إذا كانت الإجابة نعم، فغالباً يمكنك استخدام العمليات المتجهة. العمليات الحسابية، المقارنات، np.where()، np.select()، ودوال .str كلها متجهة ومُحسّنة.
هل مكتبة Polars أسرع من pandas حتى مع العمليات المتجهة؟
في كثير من الحالات، نعم. Polars مبنية من الصفر بلغة Rust وتدعم المعالجة المتوازية والتقييم الكسول (lazy evaluation). لكن pandas 3.0 مع PyArrow قلّص الفجوة بشكل ملحوظ. وبصراحة، معظم فرق عمل البيانات لا تزال تعتمد على pandas بسبب نضج نظامها البيئي وتكاملها الواسع مع بقية مكتبات Python — وهذا عامل لا يُستهان به في المشاريع الحقيقية.