تحسين استهلاك الذاكرة في Pandas عند تحليل ملفات CSV ضخمة
مقدمة
يمثل تحسين استهلاك الذاكرة في Pandas عند تحليل ملفات CSV ضخمة تحدياً أساسياً في مشاريع علم البيانات، ولا سيما عندما تتجاوز الملفات سعة الذاكرة العشوائية المتاحة على الحاسوب أو الخادم. فقد يؤدي استخدام pd.read_csv() بالإعدادات الافتراضية إلى استهلاك عدة غيغابايتات من الذاكرة لملف حجمه على القرص أقل بكثير، لأن البيانات النصية تتحول إلى كائنات داخلية، ولأن Pandas قد تستنتج أنواع بيانات أوسع من اللازم.
لا يعني الملف الكبير بالضرورة التخلي عن Pandas أو الانتقال مباشرة إلى أدوات موزعة ومعقدة. ففي عدد كبير من الحالات، يمكن خفض الاستهلاك بدرجة ملموسة عبر ثلاث ممارسات رئيسية: تحديد أنواع الأعمدة صراحة، وتحميل الأعمدة المطلوبة فقط، وقراءة الملف على دفعات صغيرة. وتزداد فعالية هذه الممارسات عند دمجها مع معالجة القيم الفئوية، وتجنب النسخ غير الضرورية، وقياس الذاكرة بصورة منهجية.
يشرح هذا المقال كيفية تشخيص المشكلة، واختيار الأنواع المناسبة، وبناء خط معالجة عملي لملفات CSV كبيرة دون تحميلها كاملة في الذاكرة. كما يوضح الحالات التي تكون فيها القراءة المجزأة أفضل من إنشاء إطار بيانات كامل، والأخطاء التي قد تبطل فائدة التحسينات رغم تطبيقها ظاهرياً.
فهم سبب تضخم الذاكرة وقياس نقطة البداية
حجم ملف CSV على القرص ليس مؤشراً مباشراً على كمية الذاكرة المطلوبة لتحليله. فصيغة CSV تخزن القيم كنصوص مفصولة بفواصل، بينما يحتاج Pandas إلى تحويلها عند التحميل إلى تمثيلات داخلية مثل الأعداد الصحيحة أو العشرية أو السلاسل النصية. وقد يحتوي الملف المضغوط على بيانات تتوسع كثيراً بعد فك الضغط والتحويل، خصوصاً إذا اشتمل على أعمدة نصية طويلة أو تواريخ أو قيم مفقودة.
تظهر مشكلة شائعة عندما يستنتج Pandas نوع العمود على أنه object. هذا النوع مرن، لكنه مكلف في الذاكرة لأنه يخزن مراجع إلى كائنات Python بدلاً من مصفوفة متجانسة ومضغوطة. كما أن الأعمدة العددية قد تُحمّل افتراضياً بالنوع int64 أو float64، رغم أن مدى القيم الفعلي قد يناسب نوعاً أصغر مثل int16 أو float32.
قبل التحسين، ينبغي قياس الوضع الحالي بدلاً من التخمين. يوفر Pandas طريقتين مهمتين: info(memory_usage='deep') لإظهار تقدير أعمق لاستهلاك الأعمدة النصية، وmemory_usage(deep=True) للحصول على تفصيل لكل عمود. ويجب استخدام الخيار deep=True لأن الحساب الافتراضي قد لا يعكس تكلفة الكائنات النصية بدقة.
import pandas as pd
df = pd.read_csv("transactions.csv")
print(df.info(memory_usage="deep"))
memory_by_column = (
df.memory_usage(deep=True)
.sort_values(ascending=False)
.div(1024 ** 2)
)
print(memory_by_column)
لا يهدف هذا القياس إلى إنتاج رقم واحد فقط، بل إلى تحديد الأعمدة الأكثر كلفة. في العادة، تكون أعمدة الوصف الحر، والعناوين، والمعرفات النصية، والحالات المتكررة مثل اسم المدينة أو حالة الطلب، هي المصدر الأكبر للاستهلاك. وعند اكتشاف هذه الأعمدة، يمكن اختيار استراتيجية مناسبة لكل منها بدلاً من تطبيق تحويل واحد على جميع البيانات.
تحديد أنواع البيانات بدقة عند القراءة
يعد تمرير قاموس dtype إلى read_csv من أكثر الوسائل فاعلية في خفض الذاكرة. والفكرة هي تحديد أصغر نوع آمن لكل عمود وفق نطاق قيمه ومعناه. على سبيل المثال، إذا كانت كمية المنتج تتراوح بين صفر و500، فليس هناك داعٍ لتخزينها في int64؛ إذ يكفي int16. وإذا كانت درجة التقييم بين صفر وخمس، فيكفي int8 أو UInt8 عندما تكون القيم المفقودة ممكنة.
ينبغي الانتباه إلى أن الأنواع الصحيحة التقليدية مثل int16 لا تمثل القيم المفقودة NaN. لذلك يقدم Pandas أنواعاً قابلة للإهمال مثل Int16 وInt32 وUInt8. وهي مناسبة للأعمدة الصحيحة التي تحتوي على قيم مفقودة، مع وجود تكلفة إضافية بسيطة مقارنة بالأنواع الرقمية الخام.
import pandas as pd
dtypes = {
"transaction_id": "uint32",
"customer_id": "uint32",
"quantity": "uint16",
"discount_percent": "float32",
"is_returned": "boolean",
"store_id": "uint16",
"status": "category"
}
df = pd.read_csv(
"transactions.csv",
dtype=dtypes
)
print(df.info(memory_usage="deep"))
بالنسبة للأعداد العشرية، قد يكون float32 مناسباً للقياسات والتنبؤات والنسب التي لا تتطلب دقة مضاعفة. لكنه ليس الخيار الأفضل تلقائياً للبيانات المالية؛ إذ قد تظهر أخطاء تقريب عند جمع القيم العشرية. في البيانات المالية الدقيقة، من الأفضل غالباً تخزين المبالغ كوحدات صحيحة صغيرة، مثل الهللات أو السنتات، باستخدام int32 أو int64، ثم تحويلها إلى صيغة عرض عند الحاجة.
لا ينبغي اختيار الأنواع اعتماداً على الحدس فقط. يمكن قراءة عينة أولية، ثم فحص الحدود الفعلية للأعمدة العددية وعدد القيم الفريدة في الأعمدة النصية. هذه المرحلة الاستكشافية تمنع اختيار نوع أصغر من اللازم، وهو خطأ قد يسبب تجاوزاً في القيم أو فشل التحميل.
sample = pd.read_csv("transactions.csv", nrows=100_000)
print(sample["quantity"].min(), sample["quantity"].max())
print(sample["customer_id"].min(), sample["customer_id"].max())
print(sample["status"].nunique(dropna=False))
وينبغي كذلك التعامل بحذر مع الأعمدة التي تبدو رقمية لكنها معرفات، مثل أرقام الهواتف والرموز البريدية وأرقام الفواتير التي قد تبدأ بأصفار. تحميل هذه الأعمدة كأعداد قد يؤدي إلى فقدان الأصفار أو إلى تغيير معناها. في هذه الحالة، يكون النوع النصي أو string هو الاختيار الصحيح حتى لو زاد الاستهلاك نسبياً.
تحميل الأعمدة الضرورية والتحكم في التحويل أثناء القراءة
من الأخطاء المتكررة تحميل جميع أعمدة الملف ثم حذف غير المطلوب منها. عند تنفيذ هذا الأسلوب، تكون الذاكرة قد استهلكت بالفعل في قراءة البيانات وتحليلها وتحويلها. البديل الصحيح هو استخدام الوسيط usecols لتحميل الأعمدة اللازمة للتحليل فقط منذ البداية.
columns_needed = [
"transaction_date",
"store_id",
"product_id",
"quantity",
"amount",
"status"
]
df = pd.read_csv(
"transactions.csv",
usecols=columns_needed,
dtype={
"store_id": "uint16",
"product_id": "uint32",
"quantity": "uint16",
"amount": "float32",
"status": "category"
}
)
إذا كان الملف يحتوي على خمسين عموداً بينما يتطلب التقرير ستة أعمدة فقط، فقد يكون usecols أكثر تأثيراً من معظم التحويلات اللاحقة. فهو يخفض الذاكرة اللازمة، ووقت تحليل CSV، وحجم البيانات التي ستنتقل بين مراحل المعالجة. ويمكن تمرير دالة إلى usecols عندما تكون أسماء الأعمدة كثيرة أو تتبع نمطاً معيناً.
كذلك ينبغي عدم تحويل التواريخ إلى كائنات تاريخية إلا عند الحاجة الفعلية. فتمرير parse_dates مفيد عندما ستجري عمليات زمنية مثل التجميع الشهري أو حساب الفروق بين التواريخ، لكنه يضيف كلفة أثناء التحميل. وإذا كان العمود التاريخي غير مستخدم في التحليل الحالي، فلا داعي إلى تحميله أو تحويله.
df = pd.read_csv(
"transactions.csv",
usecols=["transaction_date", "store_id", "amount"],
dtype={"store_id": "uint16", "amount": "float32"},
parse_dates=["transaction_date"]
)
ومن المفيد أيضاً تعريف القيم التي تمثل الغياب في المصدر بدقة عبر na_values، مثل "N/A" أو "-". يساعد ذلك على منع الخلط بين النصوص والقيم العددية، والذي قد يدفع Pandas إلى تحويل عمود رقمي كامل إلى object. أما تعطيل كشف القيم المفقودة عبر keep_default_na=False فيجب أن يتم فقط بعد فهم دلالات الملف، لأنه قد يحول كلمة مثل NA إلى نص عادي بدلاً من قيمة مفقودة.
اختيار التمثيل المناسب للنصوص والقيم الفئوية
الأعمدة النصية ليست متشابهة من حيث الكلفة. فعمود يحتوي على ملايين الأوصاف الفريدة يختلف عن عمود «حالة الطلب» الذي لا يضم سوى قيم مثل: جديد، مكتمل، ملغى، ومسترد. في الحالة الثانية، يعد النوع category خياراً ممتازاً، إذ يخزن قائمة مختصرة بالقيم الفريدة ومصفوفة من الرموز العددية بدلاً من تكرار النص نفسه في كل صف.
df["status"] = df["status"].astype("category")
df["city"] = df["city"].astype("category")
print(df[["status", "city"]].memory_usage(deep=True))
تكون الفئات فعالة عندما يكون عدد القيم المختلفة صغيراً مقارنة بعدد الصفوف. أما إذا كان لكل صف تقريباً نص فريد، كما في معرف جلسة طويل أو تعليق مستخدم، فقد لا تحقق الفئات وفراً حقيقياً، وقد تزيد التعقيد في بعض العمليات. لهذا يجب مقارنة عدد القيم الفريدة بعدد الصفوف قبل التحويل.
يستحسن في الإصدارات الحديثة من Pandas استخدام string بدلاً من object للبيانات النصية عند الحاجة إلى عمليات نصية منظمة. ويمكن أيضاً استخدام string[pyarrow] إذا كانت بيئة العمل تدعم PyArrow، إذ يوفر في حالات كثيرة تمثيلاً أكثر كفاءة للسلاسل النصية. غير أن اختيار هذا النوع يجب اختباره على الملف الفعلي، لأن الأداء واستهلاك الذاكرة يتأثران بنسخة Pandas وPyArrow وطبيعة النصوص.
df = pd.read_csv(
"transactions.csv",
dtype={
"transaction_code": "string",
"status": "category"
}
)
بعد التحويلات، ينبغي تجنب إنشاء نسخ كاملة من إطار البيانات بلا حاجة. فالتعبير df = df.copy() أو السلاسل الطويلة من عمليات التصفية والتحويل قد تنتج نسخاً مؤقتة كبيرة. الأفضل هو اختيار الأعمدة مبكراً، وإسناد النتائج الضرورية فقط، وحذف الكائنات الوسيطة عند الانتهاء منها باستخدام del عند العمل ضمن عملية طويلة.
القراءة على دفعات ومعالجة البيانات دون تحميلها كاملة
عندما يكون الملف أكبر من الذاكرة المتاحة، لا تكفي تحسينات الأنواع وحدها. هنا تصبح القراءة المجزأة عبر chunksize هي الاستراتيجية الأساسية. بدلاً من أن يعيد read_csv إطار بيانات واحداً، يعيد قارئاً يولد إطاراً صغيراً في كل مرة. ويمكن معالجة كل دفعة، واستخراج الإحصاءات أو التجميعات المطلوبة، ثم التخلص منها قبل قراءة التالية.
المبدأ المهم هو أن تكون العملية قابلة للتجميع. فحساب إجمالي المبيعات حسب المتجر، أو عدد السجلات حسب الحالة، أو المتوسط عبر الجمع والعد، يمكن تنفيذه على دفعات. أما جمع كل الدفعات في قائمة ثم دمجها في النهاية، فيلغي غالباً ميزة القراءة المجزأة لأنه يعيد تكوين البيانات كاملة في الذاكرة.
import pandas as pd
result = None
reader = pd.read_csv(
"transactions.csv",
usecols=["store_id", "amount", "status"],
dtype={
"store_id": "uint16",
"amount": "float32",
"status": "category"
},
chunksize=200_000
)
for chunk in reader:
partial = chunk.groupby("store_id", observed=True)["amount"].sum()
if result is None:
result = partial
else:
result = result.add(partial, fill_value=0)
result = result.sort_values(ascending=False)
print(result.head(10))
يعتمد الحجم الأمثل للدفعة على سعة الذاكرة، وعدد الأعمدة، وطبيعة العمليات المنفذة. يمكن البدء بـ100 ألف أو 200 ألف صف، ثم المراقبة والتعديل. الدفعات الأصغر تخفض الذروة في الذاكرة لكنها تزيد عدد مرات التكرار وكلفة الإدخال والإخراج، بينما الدفعات الأكبر تسرع التنفيذ عادةً إلى أن تقترب من حدود الذاكرة.
وفي التحليلات التي تتطلب تصفية، ينبغي تطبيق شرط التصفية داخل الحلقة قبل أي عمليات ثقيلة. مثال ذلك استبعاد المعاملات الملغاة أو تحديد فترة زمنية معينة، ثم تنفيذ التجميع على الصفوف المتبقية فقط. بهذه الطريقة لا تنتقل الصفوف غير المفيدة إلى مراحل معالجة لاحقة.
بناء خط معالجة موثوق والتحقق من النتائج
التحسين الناجح ليس مجرد خفض عدد الميغابايتات، بل المحافظة على صحة النتائج وسهولة صيانة الشفرة. لذلك يفضل بناء خط معالجة واضح يبدأ بعينة للاستكشاف، ثم تعريف مخطط بيانات ثابت، ثم تنفيذ التحميل الكامل أو المجزأ، وأخيراً التحقق من المخرجات. ويجب حفظ قاموس الأنواع والأعمدة المطلوبة ضمن الشفرة أو ملف إعدادات، بدلاً من ترك Pandas يستنتج الأنواع بصورة مختلفة كل مرة.
من المفيد قياس الذاكرة قبل التحسين وبعده، وتسجيل زمن القراءة وزمن المعالجة. كما ينبغي فحص عدد الصفوف، وعدد القيم المفقودة، ومجاميع الأعمدة المهمة للتأكد من أن تقليل الدقة أو تغيير الأنواع لم يغير النتيجة. وإذا حُولت مبالغ مالية إلى أعداد صحيحة بوحدات أصغر، فيجب توثيق ذلك بوضوح حتى لا يسيء مستخدم آخر تفسير القيم.
before_mb = 850.0
after_mb = df.memory_usage(deep=True).sum() / 1024 ** 2
print(f"استهلاك الذاكرة بعد التحسين: {after_mb:.2f} ميغابايت")
print(f"نسبة التخفيض التقريبية: {(1 - after_mb / before_mb) * 100:.1f}%")
assert df["quantity"].ge(0).all()
assert df["amount"].notna().any()
إذا كان المطلوب إجراء عمليات معقدة تحتاج إلى ترتيب عالمي أو ربط عدة جداول ضخمة أو الاحتفاظ بكل الصفوف في الذاكرة، فقد لا تكون القراءة المجزأة في Pandas كافية وحدها. عندئذ يمكن التفكير في تحويل CSV إلى Parquet، أو استخدام محرك تحليلي مثل DuckDB، أو أدوات معالجة موزعة. لكن حتى في هذه الحالات، تبقى معرفة أنواع البيانات، وانتقاء الأعمدة، وتقليل النسخ مبادئ أساسية لا غنى عنها.
خاتمة
يمكن التعامل بكفاءة مع ملفات CSV الضخمة في Pandas دون استنزاف الذاكرة إذا بدأ التحليل بتصميم واعٍ لعملية الإدخال. تحديد dtype المناسب يمنع الهدر الناتج عن الأنواع الواسعة، واستخدام usecols يمنع تحميل البيانات غير اللازمة، والقراءة عبر chunksize تتيح إجراء التحليلات التجميعية على ملفات أكبر من الذاكرة المتاحة.
تكتمل هذه الاستراتيجية بتحويل الأعمدة النصية منخفضة التنوع إلى فئات، واختيار تمثيل آمن للبيانات المالية والمعرفات، وقياس الاستهلاك الحقيقي باستخدام deep=True. والقاعدة العملية الأهم هي ألا تُحمّل أو تُخزّن أو تُنسخ بيانات لا يحتاجها السؤال التحليلي. بهذه المنهجية يتحول Pandas من أداة قد تتعثر أمام الملفات الكبيرة إلى بيئة عملية وفعالة لتحليلها.
تعليقات
إرسال تعليق