التجميع والإحصاء — إجابة واحدة لكل مجموعة
تحويل أسئلة من قبيل "لكل فرع" و"لكل فئة" و"لكل يوم" إلى سطر واحد باستخدام groupby: المفتاح، وعمود القيمة، ودالة التجميع، وحساب قيم متعددة عبر named agg، والفرق بين size و count، ومفتاحان للتجميع، وفخ متوسط المتوسطات.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
أرسل إليك صاحب المتجر رسالة مقتضبة من سطر واحد: "كم وحدة بعناها في كل فرع من فروعنا هذا الأسبوع؟"
لديك ملف orders.csv — يحتوي على ثمانية طلبات، كل طلب في صف مستقل. وبما تعلمته في الفصول الأربعة السابقة، يمكنك الإجابة عن سؤاله بالفعل: قم بفلترة كل فرع، واجمع قيم عمود الكمية، ثم كرر ذلك:
import pandas as pd
orders = pd.read_csv("orders.csv")
for branch in ["north", "south", "east"]:
units = orders[orders["branch"] == branch]["quantity"].sum()
print(branch, units)north 48
south 6
east 27الأرقام صحيحة تماماً. لكن هذه الطريقة تعاني من ثلاث مشكلات جوهرية، وتزداد كل واحدة منها سوءاً كلما نما حجم البيانات.
أولاً، كان عليك معرفة أسماء الفروع مسبقاً. لقد قمت بكتابة القائمة بيدك. عندما يتضمن ملف الأسبوع القادم طلباً من فرع جديد وليكن west، فلن يراه هذا التكرار (loop) على الإطلاق — ولن يتوقف الكود أو يظهر أي خطأ، بل سيتجاهل فرع west ببساطة، ولن يعود مجموع الفروع مساوياً لإجمالي المتجر.
ثانياً، كل سؤال جديد يتطلب كتابة حلقة تكرار جديدة. فالسؤال عن "الإيراد لكل فئة" يعني كتابة حلقة ثانية على قائمة ثانية مكتوبة يدوياً. وسؤال مثل "عدد الوحدات لكل فرع ولكل فئة" يعني كتابة حلقة متداخلة داخل حلقة أخرى.
ثالثاً، الإجابة الناتجة ليست جدولاً. إنها مجرد أسطر نصية مطبوعة على الشاشة. لا يمكنك إعادة ترتيبها، أو فلترتها، أو وضعها بجانب إجابة أخرى دون إعادة بنائها من الصفر.
ما تريده حقاً هو أن تقول في سطر واحد: قسّم الصفوف حسب الفرع، واجمع الكميات داخل كل جزء، ثم أعد لي تلك الأجزاء مجمعة في جدول واحد. هذا هو جوهر الدالة groupby، وهي الطريقة التي يُكتب بها كل ملخص إحصائي تقريباً في مكتبة بانداس — سواء كان تقريراً، أو رقماً في لوحة تحكم، أو الإجابة الأولى عن سؤال "من أين تأتي أرباحنا؟".
بنهاية هذا الفصل ستكون قادراً على
- تحويل سؤال مثل "عدد الوحدات لكل فرع" إلى أجزائه الثلاثة — المفتاح، وعمود القيمة، ودالة التجميع — والتحقق من المفتاح قبل التجميع
- كتابة
df.groupby(key)[column].sum()وقراءة كائن Series الناتج، مع استخدام المفتاح كفهرس له - حساب عدة ملخصات إحصائية دفعة واحدة باستخدام
.agg()والتجميع المسمى (named aggregation) - التمييز بدقة بين
size()وcount()، وتحديد كيفية التعامل مع المفاتيح والقيم المفقودة - التجميع باستخدام عمودين، وتسوية النتيجة أو إعادة تشكيلها، وتجنب خطأ "متوسط المتوسطات" الشائع
المتطلبات المسبقة: ترتيب الصفوف.
الملف الذي نستخدمه
نفس ملف orders.csv المستخدم في الفصول السابقة — ثمانية طلبات من متجر صغير. إذا لم يكن متوفراً لديك، فأنشئه في مجلد مشروعك بهذه الأسطر تحديداً:
order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0يمثل كل صف طلباً واحداً: من أي فرع جاء، والمنتج، وفئته، وكمية الوحدات، وسعر الوحدة الواحدة.
قبل كتابة الكود
كل سؤال تجميعي له نفس الهيكل الأساسي. وقبل أن تكتب كلمة groupby، قم بتفكيك السؤال إلى عناصره الأولية.
1. صياغة السؤال في ثلاثة أجزاء
يحتوي السؤال التجميعي دائماً على ثلاثة عناصر، وتوضح كلمات السؤال دور كل عنصر بدقة:
- المفتاح (Key) — العمود الذي تحدد قيمه المجموعات. وتأتي تسميته عادة بعد كلمات مثل لكل أو حسب (per, by, for each). في سؤال "الوحدات لكل فرع"، المفتاح هو
branch. - القيمة (Value) — العمود المراد تلخيصه وحسابه: الشيء الذي نقيسه. وهو هنا
quantity. - التجميع (Aggregation) — العملية الرياضية التي تحول القيم المتعددة إلى قيمة واحدة: المجموع، المتوسط، العدد، الحد الأقصى. وهو هنا
sum.
إليك أمثلة أخرى لترسيخ هذا النمط الذهني:
- "الإيراد لكل فئة" — المفتاح
category، والقيمةrevenue، ودالة التجميعsum - "متوسط حجم الطلب في كل فرع" — المفتاح
branch، والقيمةquantity، ودالة التجميعmean - "كم طلباً في كل يوم" — المفتاح
date، ولا يوجد عمود قيمة (لأنك تعد الصفوف)، ودالة التجميعsize - "أكبر طلب فردي لكل منتج" — المفتاح
product، والقيمةquantity، ودالة التجميعmax
إذا لم تتمكن من تحديد هذه الأجزاء الثلاثة بوضوح، فالسؤال ليس جاهزاً للبرمجة بعد. فسؤال مثل "أي الفروع هو الأفضل؟" لا يحتوي على قيمة ولا على دالة تجميع — هل الأفضل بالوحدات؟ أم بالإيرادات؟ أم بعدد الطلبات؟ اسأل قبل أن تبدأ الحساب، فقد تكون الإجابة فرعاً مختلفاً لكل معيار منها.
2. فحص المدخلات قبل التجميع
تخفي عملية التجميع الصفوف الفردية. وبمجرد تحويل الطلبات الثمانية إلى ثلاثة مجاميع، يصبح الصف المعطوب أو غير المتوقع غير مرئي داخل المجموع الإجمالي. لذلك يجب إجراء الفحوصات قبل التجميع:
import pandas as pd
orders = pd.read_csv("orders.csv")
print(orders.shape)
print(orders.dtypes)(8, 7)
order_id int64
date str
branch str
product str
category str
quantity int64
price float64
dtype: objectأمران مهمان هنا: عمود القيمة وهو quantity نوعه int64 — أي رقمي، لذا ستقوم دالة sum بعملية الجمع الحسابي بدلاً من دمج النصوص. وعمود المفتاح وهو branch نوعه str، وهو مناسب تماماً ليكون مفتاحاً للتجميع.
ثم افحص القيم الفعلية داخل عمود المفتاح. هذا هو الفحص الذي يتجاهله الكثيرون، وهو الأكثر أهمية على الإطلاق:
print(orders["branch"].value_counts())
print(orders[["branch", "quantity"]].isna().sum())branch
north 4
south 2
east 2
Name: count, dtype: int64
branch 0
quantity 0
dtype: int64هناك ثلاثة فروع مميزة، ولا توجد قيم مفقودة في أي من العمودين. تقوم groupby بإنشاء مجموعة واحدة لكل قيمة مميزة حرفاً بحرف — لذا ستعامل "north" و "north " كمجموعتين مختلفتين تماماً. ودالة value_counts() هي المكان الذي تكتشف فيه مثل هذه الأخطاء قبل أن تتحول إلى تقارير خاطئة.
إليك ما يحدث عند تخطي هذا الفحص؛ ثلاثة صفوف تشير جميعها إلى فرع north — أحدها كُتب بشكل صحيح، والآخر يحمل مسافة زائدة في نهايته، والثالث يبدأ بحرف كبير:
messy = pd.DataFrame({"branch": ["north", "north ", "North"], "quantity": [12, 2, 30]})
print(messy.groupby("branch")["quantity"].sum())
messy["branch"] = messy["branch"].str.strip().str.lower()
print(messy.groupby("branch")["quantity"].sum())branch
North 30
north 12
north 2
Name: quantity, dtype: int64
branch
north 44
Name: quantity, dtype: int64لم يظهر أي خطأ برمي، وظهرت ثلاثة "فروع" بدلاً من فرع واحد! أرقام كل مجموعة صحيحة وفقاً لما قُدم لبانداس، لكن التقرير الإداري خاطئ تماماً. إن تنظيف المفتاح — بحذف المسافات وتحويل الحروف إلى صغيرة — قبل التجميع يجعلها مجموعة واحدة بإجمالي حقيقي قدره 44.
3. تخيل شكل المخرجات قبل توليدها
حدد شكل الإجابة المتوقعة مسبقاً، حتى تلاحظ الخلل فور حدوثه:
branch units
east ?
north ?
south ?من خلال الفحوصات السابقة، بت تعرف ثلاث حقائق مؤكدة عنها: سيكون فيها 3 صفوف بالضبط (صف لكل فرع مستقل)، وستظهر الفروع مرتبة هجائياً (لأن groupby ترتب المفاتيح افتراضياً)، ويجب أن يكون مجموع الأرقام الثلاثة مساوياً لإجمالي العمود الأصلي بأكمله. هذه الحقيقة الأخيرة هي برهانك لاحقاً.
4. اختيار الأداة المناسبة
- رقم واحد لكل مجموعة من عمود واحد:
df.groupby(key)[col].sum()(أوmeanأوmax...) - عدة أرقام من عمود واحد:
df.groupby(key)[col].agg(["sum", "mean"]) - عدة أرقام من عدة أعمدة بأسماء مخصصة:
df.groupby(key).agg(name=(col, "sum"), ...) - عدد الصفوف في كل مجموعة:
df.groupby(key).size() - عمود غير موجود بعد (مثل الإيراد): أنشئه أولاً ثم قم بالتجميع
والآن إلى الكود.
التقسيم، التطبيق، والدمج (Split, apply, combine)
تنفذ groupby ثلاث خطوات متتالية، ومن المفيد معرفة أسمائها لأن كل مسألة تجميع هي عبارة عن هذه الخطوات الثلاث:
- التقسيم (Split): تقسيم الصفوف إلى أجزاء، جزء لكل قيمة مفتاح مميزة.
- التطبيق (Apply): تطبيق العملية الحسابية على كل جزء على حدة.
- الدمج (Combine): دمج النتائج في كائن جديد مفهرس بقيم المفتاح.
يمكنك معاينة خطوة التقسيم بمفردها؛ إذ يمكن التكرار على كائن groupby للحصول على كل مفتاح مع الجزء الخاص به من الجدول:
for branch, part in orders.groupby("branch"):
print(branch, part.shape, list(part["order_id"]))east (2, 7) [1004, 1007]
north (4, 7) [1001, 1003, 1005, 1008]
south (2, 7) [1002, 1006]كل جزء هو DataFrame عادي بجميع الأعمدة السبعة — يحتوي جزء north على أربعة صفوف، بينما يحتوي الآخران على صفين. لم يضطر أحد لكتابة أسماء الفروع يدوياً؛ بل عثرت بانداس عليها تلقائياً. ولو جاء طلب جديد من فرع west الأسبوع المقبل، فسيظهر ببساطة كجزء رابع.
نادراً ما ستحتاج لكتابة مثل هذا التكرار في الكود الفعلي، ولكننا نعرضه هنا لتثبيت الفكرة: كل ما يليه هو "تطبيق عملية على كل جزء، ثم لصق النتائج معاً".
رقم واحد لكل مجموعة
سؤال "الوحدات لكل فرع" بأجزائه الثلاثة — المفتاح branch، والقيمة quantity، والتجميع sum — يُكتب بهذا الترتيب تماماً:
units = orders.groupby("branch")["quantity"].sum()
print(units)branch
east 27
north 48
south 6
Name: quantity, dtype: int64اقرأ السطر من اليسار إلى اليمين: جمّع حسب الفرع، وخذ الكمية، واجمعها. نفس الأرقام التي أعطتها حلقة التكرار سابقاً، بسطر واحد ودون كتابة أسماء الفروع يدوياً.
والآن قارن النتيجة بما رسمته مسبقاً. ثلاثة صفوف — نعم. الترتيب الهجائي — نعم. وبرهان التحقق:
print(units.sum(), orders["quantity"].sum())81 81مجموع المجموعات يساوي إجمالي الجدول بالكامل، إذن لم يُفقد أي صف ولم يُحسب أي صف مرتين. هذا السطر الواحد من الفحص جدير بأن تجعله عادة دائمة: متى ما قمت بالتجميع، قارن إجمالي النتيجة بإجمالي المدخلات.
النتيجة هي كائن Series، والمفتاح هو فهرسها
print(type(units))
print(units.index)
print(units["north"])<class 'pandas.Series'>
Index(['east', 'north', 'south'], dtype='str', name='branch')
48وضعت خطوة الدمج الفروع في الفهرس (index) وليس في عمود عادي. ولهذا يظهر branch في المخرجات على سطر مستقل فوق القيم — إنه اسم الفهرس. وهذا يعني أيضاً أنه يمكنك البحث عن قيمة باستخدام اسم الفرع، مثل units["north"]، تماماً كما فعلت مع .loc في الفصل الرابع.
ولأنه كائن Series عادي، فإن كل ما تعلمته في الفصول السابقة ينطبق عليه. وترتيبه يجيب عن سؤال "أي الفروع باع أكثر":
print(units.sort_values(ascending=False))
print(units.idxmax())branch
north 48
east 27
south 6
Name: quantity, dtype: int64
northتُعيد الدالة idxmax() تسمية الفهرس الخاصة بأكبر قيمة — أي اسم الفرع، وليس الرقم. وعند السؤال عن "من هو الأول"، فهذا هو ما تريده عادة.
لماذا تحدد العمود أولاً
قد يبدو من المغري تخطي تحديد العمود ["quantity"] وترك بانداس تجمع كل شيء:
print(orders.groupby("branch").sum())order_id date ... quantity price
branch ...
east 2011 2024-03-022024-03-04 ... 27 135.0
north 4017 2024-03-012024-03-022024-03-032024-03-04 ... 48 993.0
south 2008 2024-03-012024-03-03 ... 6 910.0
[3 rows x 6 columns]لا يظهر أي خطأ — ومعظم الأرقام في الناتج بلا أي معنى. فقد جُمعت أرقام order_id كما لو كانت مبالغ مالية! ولُصقت نصوص date ببعضها البعض، لأن "جمع" النصوص يعني دمجها نصياً. كما جُمعت الأسعار في price أيضاً: فالرقم 993.0 لفرع north هو مجموع أسعار أربعة منتجات مختلفة لا رابط بينها.
التعبير orders.groupby("branch").sum() لا يرفع أي خطأ برمجي. لكنه يجمع أرقام المعرفات، ويلصق التواريخ ببعضها، ويجمع أسعار الوحدات، ثم يقدم النتيجة في مظهر تقرير خادع.وحده عمود quantity ذو مغزى، وكان بإمكانك طلبه وحده منذ البداية. ولهذا تنص القاعدة على: اختر عمود القيمة قبل تطبيق دالة التجميع؛ فدالة sum تنفذ عملية الجمع بحسب ما يعنيه لكل نوع بيانات، وهو في معظم الأعمدة أمر لم يطلبه أحد.
أما دالة mean فهي أكثر صرامة، وترفض التنفيذ بدلاً من إنتاج نتائج مشوهة:
print(orders.groupby("branch").mean())TypeError: dtype 'str' does not support operation 'mean'يوضح هذا الخطأ نفس المشكلة تماماً. اقرأه هكذا: لقد طلبت مني حساب متوسط عمود نصي. والحل ليس تمرير numeric_only=True — لأن ذلك سيظل يحسب متوسط order_id و price دون داعٍ — بل بتحديد العمود المطلوب صراحة.
عدة أرقام دفعة واحدة: .agg()
نادراً ما يكون ملخص إحصائي واحد كافياً. فسؤال مثل "كم باع كل فرع، وما متوسط كل طلب، وعبر كم طلباً تم ذلك؟" يمثل ثلاث عمليات تجميع لنفس العمود. مرر قائمة بأسمائها إلى .agg():
print(orders.groupby("branch")["quantity"].agg(["sum", "mean", "count"]))sum mean count
branch
east 27 13.5 2
north 48 12.0 4
south 6 3.0 2أصبحت النتيجة الآن DataFrame — عمود لكل عملية تجميع، وصف لكل فرع. وهو يروي تفاصيل كان المجموع وحده يخفيها: فرع east باع أكثر بقليل من نصف كمية north، لكن متوسط طلبه أكبر. يحصل فرع north على عدد طلبات أكثر؛ بينما يحصل east على طلبات أقل لكنها أكبر حجماً.
الأسماء في القائمة هي نصوص: "sum" و "mean" و "count" و "min" و "max" و "median" و "nunique" (عدد القيم الفريدة) و "first" و "last". وهي نفس الدوال التي استخدمتها على عمود واحد، مطبقة هنا على كل جزء.
التجميع المسمى (Named aggregation): أعمدتك بتسمياتك الخاصة
في التقارير الواقعية، نحتاج لتلخيص أعمدة مختلفة بطرق مختلفة، مع الرغبة في تسمية الأعمدة بأسماء واضحة. ينجز التجميع المسمى الأمرين معاً في استدعاء واحد. يكون كل وسيط اسمياً (keyword) اسماً لعمود المخرجات، وتكون قيمته زوجاً: (عمود المدخلات, دالة التجميع).
الإيراد (revenue) هو المؤشر الطبيعي الذي يطلبه الجميع، وهو ليس عموداً في الملف بعد — بل هو حاصل ضرب الكمية في السعر لكل صف. سنفرد لإنشاء الأعمدة فصلاً كاملاً تالياً؛ ويكفينا الآن سطر واحد يوضح الخطوة 4 من خطتنا عملياً: إذا لم يكن عمود القيمة موجوداً، فأنشئه قبل التجميع.
orders["revenue"] = orders["quantity"] * orders["price"]
summary = orders.groupby("branch").agg(
orders=("order_id", "count"),
units=("quantity", "sum"),
revenue=("revenue", "sum"),
)
print(summary)orders units revenue
branch
east 2 27 1140.0
north 4 48 2600.0
south 2 6 1150.0هذا تقرير احترافي يمكنك مشاركته مباشرة. يُقرأ كل سطر في دالة agg كجملة مفيدة: عمود orders هو count لـ order_id، وعمود units هو sum لـ quantity، وعمود revenue هو sum لـ revenue. أسماء الأعمدة في الناتج من اختيارك أنت وليست موروثة كأسماء عامة مثل sum و count، فلا حاجة لإعادة تسميتها لاحقاً.
وهذا التقرير يغير الإجابة السابقة؛ فبحسب عدد الوحدات كان east ثانياً بوضوح، لكن بحسب الإيراد يتقدم south قليلاً — فحقيبة واحدة بسعر 850 تتفوق على عشرين قلماً. ولهذا أصرت الخطوة 1 على تسمية القيمة بدقة: فـ "أفضل فرع" بالكمية و"أفضل فرع" بالإيراد سؤالان مختلفان تماماً.
والنتيجة هي DataFrame، لذا تُرتّب تماماً كأي جدول:
print(summary.sort_values("revenue", ascending=False))orders units revenue
branch
north 4 48 2600.0
south 2 6 1150.0
east 2 27 1140.0العد: الدالة size() في مقابل count()
يبدو سؤال "كم طلباً لكل فرع" سؤالاً بسيطاً. لكن لدى بانداس إجابتان مختلفتان عنه، وتختلفان تحديداً عند وجود بيانات مفقودة:
- تعد
size()الصفوف في كل مجموعة، أياً كانت محتوياتها. - تعد
count()القيم غير المفقودة في عمود معين داخل كل مجموعة.
في الملف المكتمل النظيف تتطابق النتيجتان. ولرؤية الاختلاف بينهما، إليك جدولاً صغيراً أُنشئ يدوياً بجانب orders، يحتوي على فرع مفقود وقيمة كمية مفقودة:
gaps = pd.DataFrame(
{
"order_id": [1001, 1002, 1003, 1004, 1005],
"branch": ["north", "south", None, "east", "north"],
"quantity": [12, 5, 2, None, 30],
}
)
print(gaps)order_id branch quantity
0 1001 north 12.0
1 1002 south 5.0
2 1003 NaN 2.0
3 1004 east NaN
4 1005 north 30.0(تحول عمود quantity إلى أرقام عشرية float بسبب القيمة المفقودة — وقد أوضح الفصل السادس سبب ذلك.)
print(gaps.groupby("branch").size())
print(gaps.groupby("branch")["quantity"].count())branch
east 1
north 2
south 1
dtype: int64
branch
east 0
north 2
south 1
Name: quantity, dtype: int64لدى فرع east طلب واحد (size = 1) ولكن صفر من الكميات المعروفة (count = 0). كلاهما صحيح، ويجيبان عن سؤالين مختلفين. استخدم size() لـ "كم طلباً مسجلاً لدينا"، واستخدم count() لـ "كم طلباً يتوفر فيه رقم كمية حقيقي".
أمران تفعلهما البيانات المفقودة بصمت
انظر مرة أخرى إلى تلك المخرجات. الطلب 1003، الذي لا يحمل اسم فرع، غير موجود في أي منهما! دخلت خمسة طلبات، وخرجت أربعة:
print(gaps.groupby("branch").size().sum(), len(gaps))4 5تقوم groupby افتراضياً بحذف الصفوف التي تفتقر لمفتاح التجميع. فلا توجد مجموعة اسمها "مجهول"، فتختفي تلك الصفوف من النتائج تماماً. وفحص الإجمالي الذي شرحناه سابقاً يكشف هذا الخلل فوراً. وإذا كانت تلك الصفوف مهمة — والطلب الذي بلا فرع لا يزال طلباً حقيقياً — فاطلب تضمينها بـ dropna=False:
print(gaps.groupby("branch", dropna=False)["quantity"].sum())branch
east 0.0
north 42.0
south 5.0
NaN 2.0
Name: quantity, dtype: float64تظهر الآن المجموعات الأربع جميعاً، وتحمل المجموعة NaN الوحدتين اللتين لم يُعرف فرعهما.
الأمر الثاني الصامت يظهر في نفس المخرجات: يعرض فرع east القيمة 0.0. فرع east لم يبع صفراً؛ بل إن مبيعاته مجهولة. تعامل sum حالة "لا يوجد ما أجمعه" كصفر. وإذا كان يجب أن تظل القيمة المجهولة مجهولة، فحدد ذلك باستخدام min_count=1 — أي "لا تحسب المجموع إلا إذا توفرت قيمة حقيقية واحدة على الأقل":
print(gaps.groupby("branch", dropna=False)["quantity"].sum(min_count=1))branch
east NaN
north 42.0
south 5.0
NaN 2.0
Name: quantity, dtype: float64تعتمد الإجابة الصحيحة على متطلبات عملك، وتلك هي الفكرة الأساسية: اتخذ القرار في مرحلة التخطيط عن قصد، لا بالصدفة. والحل الأفضل للمشكلتين هو تنظيف البيانات المفقودة أولاً (الفصل السادس) قبل التجميع.
مفتاحان: مجموعات داخل مجموعات
سؤال "الإيراد لكل فرع ولكل فئة" يحتوي على مفتاحين. مرر قائمة بهما:
by_both = orders.groupby(["branch", "category"])["revenue"].sum()
print(by_both)branch category
east accessories 840.0
stationery 300.0
north accessories 2180.0
stationery 420.0
south accessories 850.0
stationery 300.0
Name: revenue, dtype: float64صف واحد لكل توافق موجود فعلياً في البيانات — ستة توافقات هنا. يحتوي الفهرس الآن على مستويين، branch و category؛ وتطلق بانداس على هذا اسم MultiIndex، وتطبع المستوى الخارجي مرة واحدة لكل قسم لتسهيل القراءة. والفراغ تحت east يعني "ما زلنا في فرع east".
يمكنك اختيار عناصر منه بالطريقة المعتادة:
print(by_both.loc["north"])
print(by_both.loc[("north", "stationery")])category
accessories 2180.0
stationery 420.0
Name: revenue, dtype: float64
420.0التسمية الفردية تعطيك فرع north بالكامل؛ وتمرير tuple من التسميتين يعطيك رقماً محدداً بدقة.
استعادة الأعمدة العادية
يتميز MultiIndex بالإيجاز، ولكن في العديد من الخطوات التالية — مثل الحفظ في ملف CSV، أو الفلترة بقناع، أو دمج الجدول مع جدول آخر لاحقاً — تكون الأعمدة العادية أسهل بكثير في الاستخدام. هناك طريقتان لتحقيق ذلك، وتنتجان نفس الجدول تماماً:
flat = orders.groupby(["branch", "category"], as_index=False)["revenue"].sum()
print(flat)
print(flat.equals(by_both.reset_index()))branch category revenue
0 east accessories 840.0
1 east stationery 300.0
2 north accessories 2180.0
3 north stationery 420.0
4 south accessories 850.0
5 south stationery 300.0
Trueيخبر as_index=False الدالة groupby بعدم نقل المفاتيح إلى الفهرس منذ البداية؛ بينما تعيدها reset_index() إلى أعمدة عادية بعد انتهاء التجميع. استخدم الأسلوب الأكثر وضوحاً في سياق كودك.
إعادة التشكيل للقراءة البشرية: unstack()
بالنسبة للقارئ البشري، تعتبر الشبكة (grid) أكثر راحة من القائمة الطويلة — الفروع رأسياً والفئات أفقياً:
print(by_both.unstack())category accessories stationery
branch
east 840.0 300.0
north 2180.0 420.0
south 850.0 300.0تأخذ unstack() مستوى الفهرس الداخلي وهو category، وتحول قيمه إلى أعمدة. الأرقام هي نفس الأرقام الستة السابقة تماماً. وإذا لم يبع فرع معين أي منتج من فئة ما، فستكون خانته NaN؛ واستخدام unstack(fill_value=0) يضع 0 بدلاً منها.
فخ متوسط المتوسطات (average of averages)
سؤال مغرٍ: كم يبلغ متوسط تكلفة الوحدة في كل فرع؟ الإجابة السريعة الساذجة هي حساب متوسط عمود السعر price:
print(orders.groupby("branch")["price"].mean())branch
east 67.50
north 248.25
south 455.00
Name: price, dtype: float64هذا هو متوسط الأسعار المكتوبة على أسطر الطلبات، وكل سطر يحسب مرة واحدة بالتساوي — فالطلب الفردي للحقيبة في فرع south (وحدة واحدة بسعر 850) يزن نفس وزن الدفاتر (5 وحدات بسعر 60). لكن العملاء لم يشتروا وحدة واحدة من كل سطر؛ بل اشتروا كميات مختلفة من الوحدات. ومتوسط السعر لكل وحدة مباعة هو إجمالي الإيراد مقسوماً على إجمالي الوحدات، ويجب حسابه من المجاميع:
per_unit = orders.groupby("branch").agg(
units=("quantity", "sum"),
revenue=("revenue", "sum"),
)
per_unit["avg_unit_price"] = (per_unit["revenue"] / per_unit["units"]).round(2)
print(per_unit)units revenue avg_unit_price
branch
east 27 1140.0 42.22
north 48 2600.0 54.17
south 6 1150.0 191.67يهبط الرقم الخاص بفرع south من 455 إلى حوالي 192. ليس أي من الرقمين "خاطئاً" حسابياً؛ لكن أحدهما فقط يجيب عن السؤال الفعلي. القاعدة الذهبية: عندما يكون المطلوب نسبة (ratio)، قم بتجميع البسط والمقام كل على حدة، ثم أجرِ عملية القسمة. فحساب متوسط شيء هو في الأصل متوسط أو سعر للوحدة يعطي كل صف نفس الوزن، سواء كانت كميته وحدة واحدة أو ثلاثين وحدة.
مثال تطبيقي متكامل
يجيب البرنامج branch_report.py عن سؤال صاحب المتجر بطريقة شاملة وموثوقة — لكل فرع: عدد الطلبات، والوحدات، والإيراد، ونسبة الإيراد الإجمالي، والمنتج الأكثر مبيعاً بالوحدات — مع إثبات صحة الإجماليات ذاتياً.
import pandas as pd
orders = pd.read_csv("orders.csv")
# 1. Check the input before grouping hides anything.
print("Rows, columns:", orders.shape)
print("Branches:", sorted(orders["branch"].unique()))
print("Missing in key/value:", int(orders[["branch", "quantity", "price"]].isna().sum().sum()))
print()
# 2. Create the value column that does not exist yet.
orders["revenue"] = orders["quantity"] * orders["price"]
# 3. One grouped call, one row per branch.
report = orders.groupby("branch").agg(
orders=("order_id", "count"),
units=("quantity", "sum"),
revenue=("revenue", "sum"),
)
report["share_pct"] = (report["revenue"] / report["revenue"].sum() * 100).round(1)
# 4. Best product per branch: group by both keys, then keep each branch's top row.
by_product = orders.groupby(["branch", "product"], as_index=False)["quantity"].sum()
top = by_product.sort_values("quantity", ascending=False).drop_duplicates("branch")
report["top_product"] = top.set_index("branch")["product"]
report = report.sort_values("revenue", ascending=False)
print(report)
print()
# 5. Prove nothing was lost or double-counted.
print("Revenue check:", report["revenue"].sum(), "==", orders["revenue"].sum())
print("Orders check :", report["orders"].sum(), "==", len(orders))Rows, columns: (8, 7)
Branches: ['east', 'north', 'south']
Missing in key/value: 0
orders units revenue share_pct top_product
branch
north 4 48 2600.0 53.2 eraser
south 2 6 1150.0 23.5 notebook
east 2 27 1140.0 23.3 pen
Revenue check: 4890.0 == 4890.0
Orders check : 8 == 8لماذا كُتب الكود بهذا النمط المنهجي:
- تأتي الفحوصات أولاً وتُطبع على الشاشة. ثلاثة فروع مميزة وصفر من القيم المفقودة هي الشروط التي تجعل التقرير جديراً بالثقة. إذا تضمن ملف الأسبوع المقبل أربعة فروع أو قيمتين مفقودتين، فستلاحظ ذلك في الأعلى قبل قراءة أي إجمالي.
- إنشاء
revenueقبل التجميع. إن جمعquantity * priceلكل صف ثم تجميعها يعطي الإيراد الحقيقي. أما ضرب مجموع الكميات في متوسط السعر لاحقاً فكان سيوقعنا في فخ متوسط المتوسطات مجدداً. - استدعاء
aggواحد بأعمدة مسماة. يتم التصريح عن كل عمود ناتج مرة واحدة بالاسم الذي سيراه القارئ، وإضافة مقياس رابع لا تتطلب سوى سطر واحد إضافي. - حساب
share_pctعلى الناتج لا على المدخلات. بعد التجميع، يصبحreportجدول DataFrame عادياً مفهرساً بالفرع، لذا تنطبق عليه العمليات الحسابية للأعمدة العادية، ومجموع نسبه المئوية يساوي 100.0 بدقة. - تحديد المنتج الأفضل باستخدام التجميع بمفتاحين ثم الترتيب. تعطي
groupby(["branch", "product"])كميات كل زوج فرع-منتج؛ وترتيبها تنازلياً حسبquantityمع الإبقاء على الصف الأول لكل فرع (drop_duplicates("branch")) يفرز المنتج الأكثر مبيعاً في كل فرع. وإسنادtop.set_index("branch")["product"]يطابق المنتجات حسب تسمية الفرع وليس حسب الموضع — فالفهرس هو من يتولى المطابقة. - السطران الأخيران هما برهان التحقق. إجمالي الإيرادات وعدد الطلبات للمجموعات يطابقان المدخلات الأصلية بدقة. لو سقط فرع أو وُجد فرع فارغ، لاختلفت الأرقام وعرفت ذلك قبل إرسال التقرير.
عندما تقع الأخطاء وكيفية معالجتها
KeyError: 'Branch' كُتب اسم عمود المفتاح بحالة أحرف مختلفة عما في الملف — هنا بحرف B كبير. أسماء الأعمدة حساسة لحالة الأحرف، وتتحقق منها groupby على الفور. اطبع list(orders.columns) وانسخ الاسم كما هو تماماً؛ وتكشف علامات التنصيص في القائمة أيضاً المسافات الزائدة في الأطراف مثل 'branch '.
KeyError: 'Column not found: Quantity' نفس الخطأ السابق ولكن بعد خطوة: كان المفتاح سليماً ولكن عمود القيمة في groupby("branch")["Quantity"] غير موجود. توضح الرسالة "Column not found" بدلاً من مجرد ذكر الاسم، مما يوضح أن الخطأ وقع في عملية الاختيار التي تلت groupby.
KeyError: "Label(s) ['qty'] do not exist" في التجميع المسمى، يجب أن يكون العنصر الأول من كل زوج عموداً حقيقياً موجوداً في الجدول: يفشل units=("qty", "sum") لعدم وجود عمود باسم qty. يمكن لاسم المخرجات على اليسار (units) أن يكون أي شيء؛ أما اسم المدخلات على اليمين فيجب أن يكون موجوداً بالفعل.
ValueError: Cannot subset columns with a tuple with more than one element. Use a list instead. كتبت orders.groupby("branch")["quantity", "price"]. كتابة اسمين داخل زوج واحد من الأقواس المربعة تنشئ tuple. لتحديد عدة أعمدة تحتاج إلى قائمة — أي أقواس مزدوجة، تماماً كما في الفصل الرابع: orders.groupby("branch")[["quantity", "price"]].sum().
TypeError: dtype 'str' does not support operation 'mean' وصلت دالة التجميع إلى عمود نصي — إما لعدم تحديد عمود (groupby("branch").mean())، أو لأن العمود الذي حددته تم تحميله كنص str. في الحالة الثانية، ستظهر دالة info() النوع str حيث توقعت رقماً؛ واستخدام pd.to_numeric(..., errors="coerce") من الفصل السادس هو الحل.
ظهور KeyError: 'quantity' عند قراءة النتيجة بعد تنفيذ units = orders.groupby("branch")["quantity"].sum()، تصبح الفروع هي فهرس units، وكلمة quantity هي مجرد اسم للسلسلة. فكتابة units["quantity"] تبحث عن فرع يسمى quantity. استخدم units["north"] للقراءة، أو units.sum() لحساب الإجمالي.
Step 4 of 6 — Predict
Check your understanding
ما الذي ستتم طباعته؟
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
print(orders.groupby("branch")["quantity"].sum())- Abranch north 48 south 6 east 27 Name: quantity, dtype: int64
- Bbranch east 27 north 48 south 6 Name: quantity, dtype: int64
- C branch quantity 0 east 27 1 north 48 2 south 6
- D81
الهدف هو حساب إجمالي الكمية وإجمالي السعر لكل فرع. ماذا سيحدث؟
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
by_branch = orders.groupby("branch")["quantity", "price"].sum()- Aيعمل بنجاح ويعيد جدولاً يحتوي على عمودين
- B`KeyError: ('quantity', 'price')`
- C`ValueError: Cannot subset columns with a tuple with more than one element. Use a list instead.`
- Dيعيد عمود quantity فقط
ما الفرق بين size() و count() بعد تطبيق groupby؟
- Aلا يوجد فرق؛ إنهما اسمان لشيء واحد
- Bتحسب `size()` عدد الصفوف في كل مجموعة بما في ذلك القيم المفقودة؛ بينما تحسب `count()` القيم غير المفقودة فقط
- Cتحسب `count()` الصفوف؛ بينما تحسب `size()` القيم الفريدة
- Dتقيس `size()` الذاكرة المستخدمة بواسطة كل مجموعة
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
دورك الآن
يطلب صاحب المتجر الآن نفس النوع من التقارير وفقاً لـ الفئة (category) واليوم (date). باستخدام ملف orders.csv، اكتب السكربت category_report.py الذي ينجز هذه المهام الأربع تحديداً:
- قراءة الملف وطباعة
shape، وقائمة الفئات المميزة مرتبة هجائياً، وعدد القيم المفقودة في أعمدةcategoryوquantityوpriceمعاً. - إنشاء عمود
revenue، ثم بناء جدول يحتوي على صف واحد لكل فئة ويشتمل على هذه الأعمدة، مرتبة تنازلياً حسب الإيراد:orders(عدد الطلبات)،units(إجمالي الكمية)،revenue(إجمالي الإيراد)،avg_order_units(متوسط الكمية لكل طلب)، وavg_unit_price(الإيراد مقسوماً على الوحدات، مقرباً لمنزلتين عشريتين). - طباعة الإيراد لكل يوم، ثم اليوم الوحيد صاحب أعلى إيراد.
- طباعة سطر للتحقق والبرهان: إيراد جدول الفئات، وإيراد جدول الأيام، وإيراد الملف بأكمله.
قبل كتابة الكود، خطط على ورقة: بالنسبة للشرطين 2 و 3، حدد المفتاح والقيمة ودالة التجميع لكل عمود، واكتب عدد الصفوف المتوقعة في كل نتيجة.
عند اكتمال الكود بالشكل الصحيح، سيطبع البرنامج هذا الناتج تحديداً:
Rows, columns: (8, 7)
Categories: ['accessories', 'stationery']
Missing: 0
orders units revenue avg_order_units avg_unit_price
category
accessories 4 14 3870.0 3.50 276.43
stationery 4 67 1020.0 16.75 15.22
date
2024-03-01 480.0
2024-03-02 2540.0
2024-03-03 1090.0
2024-03-04 780.0
Name: revenue, dtype: float64
Best day: 2024-03-02 with 2540.0
Proof: 4890.0 4890.0 4890.0بعد ذلك، تعمد إفساد الكود لتلاحظ ما يحدث في كل حالة:
- غيّر فئة الطلب 1001 إلى
"Stationery"(بحرف S كبير). كم صفاً أصبح في جدولك الآن، وهل ما زال برهان الإجمالي ينجح؟ - استبدل
"sum"بـ"mean"لعمودunits. أي الأرقام يتغير، وعن أي سؤال أصبح يجيب؟ - احسب
avg_unit_priceكمتوسط لعمودpriceبدلاً من القسمة. أي الفئات تغيرت قيمتها بشكل أكبر ولماذا؟
الحل
التخطيط أولاً.
- الشرط 2: المفتاح
category. عمودorders= عد لـorder_id؛ وعمودunits= مجموع لـquantity؛ وعمودrevenue= مجموع لـrevenue؛ وعمودavg_order_units= متوسط لـquantity؛ وعمودavg_unit_price= ليس تجميعاً لعمود واحد — بل نسبة، لذا يُحسب لاحقاً منrevenueوunits. الملف يحتوي على فئتين، إذن الجدول سيتكون من صفين (2 rows). - الشرط 3: المفتاح
date، والقيمةrevenue، والتجميعsum. هناك أربعة تواريخ مميزة، إذن 4 صفوف؛ وأفضل يوم هو تسمية الفهرس المقابلة للقيمة العظمى باستخدامidxmax(). - البرهان: يجب أن يتطابق إجمالي إيرادات الجدولين مع إجمالي الملف البالغ 4890.0.
السكربت category_report.py:
import pandas as pd
orders = pd.read_csv("orders.csv")
print("Rows, columns:", orders.shape)
print("Categories:", sorted(orders["category"].unique()))
print("Missing:", int(orders[["category", "quantity", "price"]].isna().sum().sum()))
print()
orders["revenue"] = orders["quantity"] * orders["price"]
table = orders.groupby("category").agg(
orders=("order_id", "count"),
units=("quantity", "sum"),
revenue=("revenue", "sum"),
avg_order_units=("quantity", "mean"),
)
table["avg_unit_price"] = (table["revenue"] / table["units"]).round(2)
table = table.sort_values("revenue", ascending=False)
print(table)
print()
daily = orders.groupby("date")["revenue"].sum()
print(daily)
print("Best day:", daily.idxmax(), "with", daily.max())
print()
print("Proof:", table["revenue"].sum(), daily.sum(), orders["revenue"].sum())Rows, columns: (8, 7)
Categories: ['accessories', 'stationery']
Missing: 0
orders units revenue avg_order_units avg_unit_price
category
accessories 4 14 3870.0 3.50 276.43
stationery 4 67 1020.0 16.75 15.22
date
2024-03-01 480.0
2024-03-02 2540.0
2024-03-03 1090.0
2024-03-04 780.0
Name: revenue, dtype: float64
Best day: 2024-03-02 with 2540.0
Proof: 4890.0 4890.0 4890.0صفان وأربعة صفوف كما خططنا تماماً، وإجماليات الإيرادات الثلاثة متطابقة. لاحظ ما يظهره الجدول ولا يستطيع عمود بمفرده إظهاره: تبيع القرطاسية قرابة خمسة أضعاف عدد الوحدات، لكن الإكسسوارات تحقق ما يقارب أربعة أضعاف الإيرادات المالية.
ما تكشفه التجارب الثلاث للأخطاء المتعمدة:
- وجود
"Stationery"بحرف كبير يجعل الجدول يحتوي على 3 صفوف — حيث تصبحStationeryمجموعة مستقلة بطلب واحد. يظل البرهان ناجحاً لأن الصف لم يُفقد بل وُضع في مجموعة خاطئة؛ ولهذا طُبع سطر الفئات المميزة في الأعلى: سيُظهر الآن ثلاث فئات فتكتشف الخطأ قبل قراءة أي رقم. - استخدام
"mean"يجعلunitsمساوية لـ 3.5 و 16.75 — وهي نفس قيمةavg_order_units. فيصبح الجواب عن "ما حجم الطلب النموذجي" بدلاً من "كم بعنا إجمالاً". ويتغيرavg_unit_priceأيضاً لأنه يقسم الإيراد على المعيار الخاطئ. - حساب متوسط
priceيعطي الإكسسوارات(850 + 120 + 850 + 120) / 4 = 485.0، وهو أعلى بكثير من القيمة الحقيقية 276.43، لأن طلبي الحقائب (وحدة ووحدتان) عوملا بنفس وزن طلبي الزجاجات (سبع وأربع وحدات). ويرتفع سعر القرطاسية من 15.22 إلى 24.5 — وكان الفارق أقل حدة فقط لأن أسعارها متقاربة ومنخفضة. فحساب متوسط سعر الوحدة عبر أسطر الطلبات هو الفخ بعينه، وقسمة المجاميع هي الحل الصحيح.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz