الفصل 08

التجميع والإحصاء — إجابة واحدة لكل مجموعة

تحويل أسئلة من قبيل "لكل فرع" و"لكل فئة" و"لكل يوم" إلى سطر واحد باستخدام groupby: المفتاح، وعمود القيمة، ودالة التجميع، وحساب قيم متعددة عبر named agg، والفرق بين size و count، ومفتاحان للتجميع، وفخ متوسط المتوسطات.

45 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

أرسل إليك صاحب المتجر رسالة مقتضبة من سطر واحد: "كم وحدة بعناها في كل فرع من فروعنا هذا الأسبوع؟"

لديك ملف orders.csv — يحتوي على ثمانية طلبات، كل طلب في صف مستقل. وبما تعلمته في الفصول الأربعة السابقة، يمكنك الإجابة عن سؤاله بالفعل: قم بفلترة كل فرع، واجمع قيم عمود الكمية، ثم كرر ذلك:

python
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)
text
north 48
south 6
east 27

الأرقام صحيحة تماماً. لكن هذه الطريقة تعاني من ثلاث مشكلات جوهرية، وتزداد كل واحدة منها سوءاً كلما نما حجم البيانات.

أولاً، كان عليك معرفة أسماء الفروع مسبقاً. لقد قمت بكتابة القائمة بيدك. عندما يتضمن ملف الأسبوع القادم طلباً من فرع جديد وليكن west، فلن يراه هذا التكرار (loop) على الإطلاق — ولن يتوقف الكود أو يظهر أي خطأ، بل سيتجاهل فرع west ببساطة، ولن يعود مجموع الفروع مساوياً لإجمالي المتجر.

ثانياً، كل سؤال جديد يتطلب كتابة حلقة تكرار جديدة. فالسؤال عن "الإيراد لكل فئة" يعني كتابة حلقة ثانية على قائمة ثانية مكتوبة يدوياً. وسؤال مثل "عدد الوحدات لكل فرع ولكل فئة" يعني كتابة حلقة متداخلة داخل حلقة أخرى.

ثالثاً، الإجابة الناتجة ليست جدولاً. إنها مجرد أسطر نصية مطبوعة على الشاشة. لا يمكنك إعادة ترتيبها، أو فلترتها، أو وضعها بجانب إجابة أخرى دون إعادة بنائها من الصفر.

ما تريده حقاً هو أن تقول في سطر واحد: قسّم الصفوف حسب الفرع، واجمع الكميات داخل كل جزء، ثم أعد لي تلك الأجزاء مجمعة في جدول واحد. هذا هو جوهر الدالة groupby، وهي الطريقة التي يُكتب بها كل ملخص إحصائي تقريباً في مكتبة بانداس — سواء كان تقريراً، أو رقماً في لوحة تحكم، أو الإجابة الأولى عن سؤال "من أين تأتي أرباحنا؟".

بنهاية هذا الفصل ستكون قادراً على

  • تحويل سؤال مثل "عدد الوحدات لكل فرع" إلى أجزائه الثلاثة — المفتاح، وعمود القيمة، ودالة التجميع — والتحقق من المفتاح قبل التجميع
  • كتابة df.groupby(key)[column].sum() وقراءة كائن Series الناتج، مع استخدام المفتاح كفهرس له
  • حساب عدة ملخصات إحصائية دفعة واحدة باستخدام .agg() والتجميع المسمى (named aggregation)
  • التمييز بدقة بين size() و count()، وتحديد كيفية التعامل مع المفاتيح والقيم المفقودة
  • التجميع باستخدام عمودين، وتسوية النتيجة أو إعادة تشكيلها، وتجنب خطأ "متوسط المتوسطات" الشائع

المتطلبات المسبقة: ترتيب الصفوف.


الملف الذي نستخدمه

نفس ملف orders.csv المستخدم في الفصول السابقة — ثمانية طلبات من متجر صغير. إذا لم يكن متوفراً لديك، فأنشئه في مجلد مشروعك بهذه الأسطر تحديداً:

text
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. فحص المدخلات قبل التجميع

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

python
import pandas as pd

orders = pd.read_csv("orders.csv")

print(orders.shape)
print(orders.dtypes)
text
(8, 7)
order_id      int64
date            str
branch          str
product         str
category        str
quantity      int64
price       float64
dtype: object

أمران مهمان هنا: عمود القيمة وهو quantity نوعه int64 — أي رقمي، لذا ستقوم دالة sum بعملية الجمع الحسابي بدلاً من دمج النصوص. وعمود المفتاح وهو branch نوعه str، وهو مناسب تماماً ليكون مفتاحاً للتجميع.

ثم افحص القيم الفعلية داخل عمود المفتاح. هذا هو الفحص الذي يتجاهله الكثيرون، وهو الأكثر أهمية على الإطلاق:

python
print(orders["branch"].value_counts())
print(orders[["branch", "quantity"]].isna().sum())
text
branch
north    4
south    2
east     2
Name: count, dtype: int64
branch      0
quantity    0
dtype: int64

هناك ثلاثة فروع مميزة، ولا توجد قيم مفقودة في أي من العمودين. تقوم groupby بإنشاء مجموعة واحدة لكل قيمة مميزة حرفاً بحرف — لذا ستعامل "north" و "north " كمجموعتين مختلفتين تماماً. ودالة value_counts() هي المكان الذي تكتشف فيه مثل هذه الأخطاء قبل أن تتحول إلى تقارير خاطئة.

إليك ما يحدث عند تخطي هذا الفحص؛ ثلاثة صفوف تشير جميعها إلى فرع north — أحدها كُتب بشكل صحيح، والآخر يحمل مسافة زائدة في نهايته، والثالث يبدأ بحرف كبير:

python
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())
text
branch
North     30
north     12
north      2
Name: quantity, dtype: int64
branch
north    44
Name: quantity, dtype: int64

لم يظهر أي خطأ برمي، وظهرت ثلاثة "فروع" بدلاً من فرع واحد! أرقام كل مجموعة صحيحة وفقاً لما قُدم لبانداس، لكن التقرير الإداري خاطئ تماماً. إن تنظيف المفتاح — بحذف المسافات وتحويل الحروف إلى صغيرة — قبل التجميع يجعلها مجموعة واحدة بإجمالي حقيقي قدره 44.

3. تخيل شكل المخرجات قبل توليدها

حدد شكل الإجابة المتوقعة مسبقاً، حتى تلاحظ الخلل فور حدوثه:

text
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 ثلاث خطوات متتالية، ومن المفيد معرفة أسمائها لأن كل مسألة تجميع هي عبارة عن هذه الخطوات الثلاث:

  1. التقسيم (Split): تقسيم الصفوف إلى أجزاء، جزء لكل قيمة مفتاح مميزة.
  2. التطبيق (Apply): تطبيق العملية الحسابية على كل جزء على حدة.
  3. الدمج (Combine): دمج النتائج في كائن جديد مفهرس بقيم المفتاح.

يمكنك معاينة خطوة التقسيم بمفردها؛ إذ يمكن التكرار على كائن groupby للحصول على كل مفتاح مع الجزء الخاص به من الجدول:

python
for branch, part in orders.groupby("branch"):
    print(branch, part.shape, list(part["order_id"]))
text
east (2, 7) [1004, 1007]
north (4, 7) [1001, 1003, 1005, 1008]
south (2, 7) [1002, 1006]

كل جزء هو DataFrame عادي بجميع الأعمدة السبعة — يحتوي جزء north على أربعة صفوف، بينما يحتوي الآخران على صفين. لم يضطر أحد لكتابة أسماء الفروع يدوياً؛ بل عثرت بانداس عليها تلقائياً. ولو جاء طلب جديد من فرع west الأسبوع المقبل، فسيظهر ببساطة كجزء رابع.

نادراً ما ستحتاج لكتابة مثل هذا التكرار في الكود الفعلي، ولكننا نعرضه هنا لتثبيت الفكرة: كل ما يليه هو "تطبيق عملية على كل جزء، ثم لصق النتائج معاً".

رقم واحد لكل مجموعة

سؤال "الوحدات لكل فرع" بأجزائه الثلاثة — المفتاح branch، والقيمة quantity، والتجميع sum — يُكتب بهذا الترتيب تماماً:

python
units = orders.groupby("branch")["quantity"].sum()
print(units)
text
branch
east     27
north    48
south     6
Name: quantity, dtype: int64

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

والآن قارن النتيجة بما رسمته مسبقاً. ثلاثة صفوف — نعم. الترتيب الهجائي — نعم. وبرهان التحقق:

python
print(units.sum(), orders["quantity"].sum())
text
81 81

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

النتيجة هي كائن Series، والمفتاح هو فهرسها

python
print(type(units))
print(units.index)
print(units["north"])
text
<class 'pandas.Series'>
Index(['east', 'north', 'south'], dtype='str', name='branch')
48

وضعت خطوة الدمج الفروع في الفهرس (index) وليس في عمود عادي. ولهذا يظهر branch في المخرجات على سطر مستقل فوق القيم — إنه اسم الفهرس. وهذا يعني أيضاً أنه يمكنك البحث عن قيمة باستخدام اسم الفرع، مثل units["north"]، تماماً كما فعلت مع .loc في الفصل الرابع.

ولأنه كائن Series عادي، فإن كل ما تعلمته في الفصول السابقة ينطبق عليه. وترتيبه يجيب عن سؤال "أي الفروع باع أكثر":

python
print(units.sort_values(ascending=False))
print(units.idxmax())
text
branch
north    48
east     27
south     6
Name: quantity, dtype: int64
north

تُعيد الدالة idxmax() تسمية الفهرس الخاصة بأكبر قيمة — أي اسم الفرع، وليس الرقم. وعند السؤال عن "من هو الأول"، فهذا هو ما تريده عادة.

لماذا تحدد العمود أولاً

قد يبدو من المغري تخطي تحديد العمود ["quantity"] وترك بانداس تجمع كل شيء:

python
print(orders.groupby("branch").sum())
text
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 فهي أكثر صرامة، وترفض التنفيذ بدلاً من إنتاج نتائج مشوهة:

python
print(orders.groupby("branch").mean())
text
TypeError: dtype 'str' does not support operation 'mean'

يوضح هذا الخطأ نفس المشكلة تماماً. اقرأه هكذا: لقد طلبت مني حساب متوسط عمود نصي. والحل ليس تمرير numeric_only=True — لأن ذلك سيظل يحسب متوسط order_id و price دون داعٍ — بل بتحديد العمود المطلوب صراحة.

عدة أرقام دفعة واحدة: .agg()

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

python
print(orders.groupby("branch")["quantity"].agg(["sum", "mean", "count"]))
text
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 من خطتنا عملياً: إذا لم يكن عمود القيمة موجوداً، فأنشئه قبل التجميع.

python
orders["revenue"] = orders["quantity"] * orders["price"]

summary = orders.groupby("branch").agg(
    orders=("order_id", "count"),
    units=("quantity", "sum"),
    revenue=("revenue", "sum"),
)
print(summary)
text
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، لذا تُرتّب تماماً كأي جدول:

python
print(summary.sort_values("revenue", ascending=False))
text
orders  units  revenue
branch                        
north        4     48   2600.0
south        2      6   1150.0
east         2     27   1140.0

العد: الدالة size() في مقابل count()

يبدو سؤال "كم طلباً لكل فرع" سؤالاً بسيطاً. لكن لدى بانداس إجابتان مختلفتان عنه، وتختلفان تحديداً عند وجود بيانات مفقودة:

  • تعد size() الصفوف في كل مجموعة، أياً كانت محتوياتها.
  • تعد count() القيم غير المفقودة في عمود معين داخل كل مجموعة.

في الملف المكتمل النظيف تتطابق النتيجتان. ولرؤية الاختلاف بينهما، إليك جدولاً صغيراً أُنشئ يدوياً بجانب orders، يحتوي على فرع مفقود وقيمة كمية مفقودة:

python
gaps = pd.DataFrame(
    {
        "order_id": [1001, 1002, 1003, 1004, 1005],
        "branch": ["north", "south", None, "east", "north"],
        "quantity": [12, 5, 2, None, 30],
    }
)
print(gaps)
text
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 بسبب القيمة المفقودة — وقد أوضح الفصل السادس سبب ذلك.)

python
print(gaps.groupby("branch").size())
print(gaps.groupby("branch")["quantity"].count())
text
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، الذي لا يحمل اسم فرع، غير موجود في أي منهما! دخلت خمسة طلبات، وخرجت أربعة:

python
print(gaps.groupby("branch").size().sum(), len(gaps))
text
4 5

تقوم groupby افتراضياً بحذف الصفوف التي تفتقر لمفتاح التجميع. فلا توجد مجموعة اسمها "مجهول"، فتختفي تلك الصفوف من النتائج تماماً. وفحص الإجمالي الذي شرحناه سابقاً يكشف هذا الخلل فوراً. وإذا كانت تلك الصفوف مهمة — والطلب الذي بلا فرع لا يزال طلباً حقيقياً — فاطلب تضمينها بـ dropna=False:

python
print(gaps.groupby("branch", dropna=False)["quantity"].sum())
text
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 — أي "لا تحسب المجموع إلا إذا توفرت قيمة حقيقية واحدة على الأقل":

python
print(gaps.groupby("branch", dropna=False)["quantity"].sum(min_count=1))
text
branch
east      NaN
north    42.0
south     5.0
NaN       2.0
Name: quantity, dtype: float64

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

مفتاحان: مجموعات داخل مجموعات

سؤال "الإيراد لكل فرع ولكل فئة" يحتوي على مفتاحين. مرر قائمة بهما:

python
by_both = orders.groupby(["branch", "category"])["revenue"].sum()
print(by_both)
text
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".

يمكنك اختيار عناصر منه بالطريقة المعتادة:

python
print(by_both.loc["north"])
print(by_both.loc[("north", "stationery")])
text
category
accessories    2180.0
stationery      420.0
Name: revenue, dtype: float64
420.0

التسمية الفردية تعطيك فرع north بالكامل؛ وتمرير tuple من التسميتين يعطيك رقماً محدداً بدقة.

استعادة الأعمدة العادية

يتميز MultiIndex بالإيجاز، ولكن في العديد من الخطوات التالية — مثل الحفظ في ملف CSV، أو الفلترة بقناع، أو دمج الجدول مع جدول آخر لاحقاً — تكون الأعمدة العادية أسهل بكثير في الاستخدام. هناك طريقتان لتحقيق ذلك، وتنتجان نفس الجدول تماماً:

python
flat = orders.groupby(["branch", "category"], as_index=False)["revenue"].sum()
print(flat)
print(flat.equals(by_both.reset_index()))
text
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) أكثر راحة من القائمة الطويلة — الفروع رأسياً والفئات أفقياً:

python
print(by_both.unstack())
text
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:

python
print(orders.groupby("branch")["price"].mean())
text
branch
east      67.50
north    248.25
south    455.00
Name: price, dtype: float64

هذا هو متوسط الأسعار المكتوبة على أسطر الطلبات، وكل سطر يحسب مرة واحدة بالتساوي — فالطلب الفردي للحقيبة في فرع south (وحدة واحدة بسعر 850) يزن نفس وزن الدفاتر (5 وحدات بسعر 60). لكن العملاء لم يشتروا وحدة واحدة من كل سطر؛ بل اشتروا كميات مختلفة من الوحدات. ومتوسط السعر لكل وحدة مباعة هو إجمالي الإيراد مقسوماً على إجمالي الوحدات، ويجب حسابه من المجاميع:

python
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)
text
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 عن سؤال صاحب المتجر بطريقة شاملة وموثوقة — لكل فرع: عدد الطلبات، والوحدات، والإيراد، ونسبة الإيراد الإجمالي، والمنتج الأكثر مبيعاً بالوحدات — مع إثبات صحة الإجماليات ذاتياً.

python
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))
text
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() لحساب الإجمالي.