الفصل 07

ترتيب الصفوف — وتحديد كيفية التعامل مع حالات التعادل

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

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

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

أرسل صاحب المتجر رسالة قصيرة: "ما هي أكبر طلباتنا لهذا الشهر؟ أرسل لي أعلى ثلاثة طلبات."

البيانات هي ملف orders.csv المألوف — ثمانية طلبات من فروع المتجر الثلاثة: north و south و east. مع ثمانية صفوف فقط، يمكنك الإجابة بالنظر المجرد: تفقد عمود quantity، وابحث عن الرقم 30، ثم 20، ثم 12. انتهى الأمر.

والآن تخيل الملف الحقيقي: أربعة آلاف طلب. لم تعد قراءة العمود للعثور على أكبر ثلاثة أرقام طريقة مجدية، بل أصبحت مجرد أمنية مستحيلة. ورسالة صاحب المتجر التالية في طريقها إليك بالفعل: "هل يمكنك إرسال القائمة مجمعة حسب الفرع، بحيث يظهر أكبر طلب أولاً داخل كل فرع؟ واجعل أحدث الطلبات في الأعلى من فضلك."

كل واحد من هذه الطلبات هو في جوهره نفس العملية: وضع الصفوف في ترتيب مفيد، ثم القراءة من الأعلى. هذا هو الترتيب (sorting)، وهو في مكتبة بانداس دالة واحدة: sort_values().

لكن استدعاء الدالة هو الجزء السهل. الأسئلة الحقيقية تحيط بهذه الدالة، وهي السبب وراء الأخطاء الفادحة في التقارير:

  • "الأكبر" وفقاً لماذا؟ الكمية (quantity)، أم السعر (price)، أم الإيراد المالي الذي حققه الطلب؟ كل معيار يعطيك ثلاثة طلبات مختلفة تماماً.
  • عندما يتعادل طلبان، أيهما يأتي أولاً — وهل تتغير الإجابة في كل مرة تشغل فيها الكود؟
  • ماذا يحدث للطلب الذي تكون كميته فارغة؟
  • إذا تمت قراءة عمود أرقام كنص بالخطأ، فسيتم ترتيب "9" بعد "30"، دون أن يظهر لك أي تحذير ينبهك.

يعلمك هذا الفصل استخدام هذه الدالة، والأهم من ذلك، يعلمك كيفية اتخاذ تلك القرارات الحاسمة.

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

  • ترتيب الجدول وفق عمود واحد أو أعمدة متعددة باستخدام sort_values()، مع تحديد اتجاه مستقل لكل عمود، وحفظ النتيجة بدلاً من فقدانها
  • شرح سبب انتقال تسميات الفهرس (index labels) مع صفوفها، والاختيار بثقة بين الإبقاء عليها، أو استخدام sort_index()، أو reset_index(drop=True)
  • فك حالات التعادل بصورة مقصودة، وتحديد مكان ظهور القيم المفقودة باستخدام na_position
  • الإجابة عن أسئلة "أعلى N" باستخدام head() و nlargest() و nsmallest() — بما في ذلك "أعلى N ضمن مجموعة فرعية" وحالات التعادل عند حد القطع
  • ترتيب النصوص دون تأثر بحالة الأحرف (كبيرة أو صغيرة) باستخدام key=، والتعرف على الأرقام المخزنة كنصوص من طريقة ترتيبها

المتطلبات المسبقة: البيانات المفقودة والقيم الناقصة — اكتشاف الفراغات وحذفها وملؤها.


أنشئ الملف أولاً

تقرأ جميع الأمثلة في هذا الفصل ملف 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

يستخدم قسم لاحق ملفاً ثانياً باسم orders_gaps.csv. وهو نفس الجدول مع ترك كميتين فارغتين — للطلبين 1002 و 1007 — وهو نوع الفراغات الذي تعلمت اكتشافه في الفصل السادس:

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,,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,,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0

قبل أن تكتب الكود

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

1. اكتب السؤال في جملة واحدة واضحة، متضمنةً اسم العمود. "أكبر الطلبات" ليس سؤالاً يمكن لبانداس الإجابة عنه مباشرة. بينما "الطلبات الثلاثة ذات أكبر quantity" هو سؤال محدد يمكن حله برمجياً. وكذلك "الطلبات الثلاثة ذات أعلى price". فكلاهما يعطي نتائج مختلفة؛ فالحقيبة بسعر 850 هي المنتج الأغلى، لكن لم يُبع منها سوى حقيبتين فقط. حدد بدقة ما يقصده صاحب المتجر. وإذا لم تكن متأكداً، فاسأله؛ فالأمر يتطلب رسالة واحدة فقط، بينما يؤدي تقديم قائمة خاطئة إلى فقدان الثقة.

2. تفحص المدخلات قبل الترتيب. هناك ثلاثة أمور أساسية تؤثر في الترتيب: عدد الصفوف، وأنواع بيانات الأعمدة التي سترتب بناءً عليها، وما إذا كانت تلك الأعمدة تحتوي على فراغات.

python
import pandas as pd

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

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

ثمانية صفوف وسبعة أعمدة. نوع quantity هو int64 ونوع price هو float64، لذا سيتم ترتيبهما كـ أرقام. ونوع branch و product هو str — أي نصوص، وسيتم ترتيبهما أبجدياً. ونوع date هو أيضاً str. وهذا لا يمثل مشكلة هنا لسبب يستحق المعرفة: التواريخ المكتوبة بصيغة YYYY-MM-DD تترتب كنصوص بشكل صحيح لأن الجزء الأهم (السنة) يأتي أولاً. بينما التواريخ المكتوبة بصيغة مثل 4/3/2024 لن تترتب ترتيباً زمنياً صحيحاً كنصوص. وعمود quantity لا يحتوي على فراغات، لذا لا نحتاج لتحديد موضع القيم المفقودة — حالياً.

3. ارسم مسودة للمخرجات المتوقعة. لسؤال "أعلى ثلاثة طلبات حسب الكمية"، يجب أن تكون الإجابة ثلاثة صفوف من الأكبر إلى الأصغر: 30 (ممحاة، الطلب 1005)، ثم 20 (قلم، 1007)، ثم 12 (قلم، 1001). تدوين هذا التوقع قبل تشغيل أي كود يمنحك معياراً لمقارنة النتائج البرمجية به.

4. حدد تفاصيل الترتيب المطلوبة.

  • المفتاح (Key) — ما العمود أو الأعمدة التي تحدد الترتيب؟ وهذا ما يُمرر إلى المعامل by=.
  • الاتجاه (Direction) — من الأصغر إلى الأكبر أم من الأكبر إلى الأصغر لكل مفتاح؟ وهذا ما يُمرر إلى ascending=.
  • التعادل (Ties) — إذا تساوى صفان في القيمة، فأيهما يسبق الآخر؟ يحدد ذلك بمفتاح ثانٍ، أو عبر kind="stable".
  • القيم الفارغة (Blanks) — هل تظهر القيم المفقودة في البداية أم في النهاية؟ يُحدد ذلك بالمعامل na_position=.
  • العدد المطلوب (How many) — هل تريد كل الصفوف أم أعلى N فقط؟ يُحدد ذلك عبر .head(n) أو nlargest().
  • الفهرس (Index) — هل لتسميات الصفوف القديمة أي دلالة بعد الترتيب؟ احتفظ بها أو احذفها وأعد الترقيم بـ reset_index(drop=True).

5. اختر الأداة المناسبة.

  • لكل الصفوف، مرتبة وفق عمود أو أكثر: sort_values()
  • لأعلى أو أدنى N صفوف فقط وفق عمود رقمي: nlargest() / nsmallest()
  • لإعادة الصفوف إلى ترتيب الفهرس الأصلي: sort_index()
  • لأعلى N صفوف ضمن مجموعة فرعية ("في فرع north"): التصفية أولاً، ثم الترتيب، ثم الاقتطاع بـ head()

6. اعرف كيف ستتحقق من النتيجة. بعد الترتيب، يجب أن تطابق الصفوف الأولى مسودتك، ويجب أن يبقى shape كما هو دون تغيير (فالترتيب لا يضيف ولا يحذف صفوفاً أبداً)، وفي حالة المفتاح الرقمي، يجب أن تقرأ قيم العمود بتزايد أو تناقص مستمر على طول الصفحة.


sort_values() — عمود واحد

للحفاظ على عرض المخرجات مناسباً، تعرض الأمثلة خمسة من الأعمدة السبعة. المتغير cols هو مجرد قائمة بأسماء الأعمدة، تُستخدم مع اختيار الأقواس المزدوجة التي تعلمتها في الفصل الرابع.

python
cols = ["order_id", "branch", "product", "quantity", "price"]

by_qty = orders.sort_values("quantity")

print(by_qty[cols])
text
order_id branch   product  quantity  price
5      1006  south       bag         1  850.0
2      1003  north       bag         2  850.0
7      1008  north    bottle         4  120.0
1      1002  south  notebook         5   60.0
3      1004   east    bottle         7  120.0
0      1001  north       pen        12   15.0
6      1007   east       pen        20   15.0
4      1005  north    eraser        30    8.0

الوضع الافتراضي هو تصاعدي (ascending) — من الأصغر إلى الأكبر. اقرأ عمود quantity من الأعلى إلى الأسفل: 1، 2، 4، 5، 7، 12، 20، 30. هذا هو التحقق المذكور في الخطة: يجب أن تتصاعد أرقام المفتاح الرقمي باستمرار.

والآن انظر إلى الحافة اليسرى. لم يعد الفهرس متسلسلاً 0, 1, 2, …، بل أصبح: 5, 2, 7, 1, 3, 0, 6, 4.

لماذا تنتقل تسميات الفهرس مع صفوفها

لم يُعد بانداس ترقيم الصفوف، وهذا تصرف متعمد ومقصود. تسمية الفهرس (index label) هي اسم الصف وهويته، وليست موضعه. فالتسمية 5 كانت تشير إلى الطلب 1006 قبل الترتيب، وما زالت تشير إلى الطلب 1006 بعده. ولو قام الترتيب بإعادة ترقيم الصفوف تلقائياً، لأشارت كل تسمية إلى طلب مختلف تماماً، ولأصبحت أي ملاحظة دونتها عن "الصف 5" خاطئة بصمت.

لذا، بعد إجراء الترتيب، يصبح هناك طريقتان مختلفتان لقول "الصف الأول"، ولم تعودا متطابقتين:

python
print(by_qty.iloc[0]["order_id"])
print(by_qty.loc[0]["order_id"])
text
1006
1001

الخاصية iloc[0] تعني الموضع صفر — الصف الأول على الصفحة، وهو الطلب 1006. أما loc[0] فتعني الصف الذي يحمل التسمية 0 — وهو الطلب 1001، الذي أصبح الآن سادس صف على الصفحة. أوضح الفصل الرابع هذا التمييز بدقة؛ والترتيب هو العملية التي تجعل هذا الفارق جوهرياً. عندما تريد "الصف الأعلى بعد الترتيب"، استخدم iloc أو head()، فكلاهما يعد المواضع.

الأكبر أولاً: ascending=False

python
print(orders.sort_values("quantity", ascending=False)[cols])
text
order_id branch   product  quantity  price
4      1005  north    eraser        30    8.0
6      1007   east       pen        20   15.0
0      1001  north       pen        12   15.0
3      1004   east    bottle         7  120.0
1      1002  south  notebook         5   60.0
7      1008  north    bottle         4  120.0
2      1003  north       bag         2  850.0
5      1006  south       bag         1  850.0

أعلى ثلاثة طلبات هي 1005 و 1007 و 1001 — تماماً كما رسمنا في المسودة. ولإرسال هذه الطلبات الثلاثة فقط، أضف head(3):

python
top3 = orders.sort_values("quantity", ascending=False).head(3)

print(top3[cols])
text
order_id branch product  quantity  price
4      1005  north  eraser        30    8.0
6      1007   east     pen        20   15.0
0      1001  north     pen        12   15.0

قراءة هذا التسلسل البرمجي توضح المنطق مباشرة: خذ orders، وضع الكميات الأكبر أولاً، ثم احتفظ بأول ثلاثة صفوف. هذا النمط "رتِّب ثم استدعِ head" يجيب عن معظم أسئلة "أعلى N".

الترتيب الأبجدي للنصوص

يتم ترتيب عمود str وفق الترتيب الأبجدي المعتاد:

python
print(orders.sort_values("product")[cols])
text
order_id branch   product  quantity  price
2      1003  north       bag         2  850.0
5      1006  south       bag         1  850.0
3      1004   east    bottle         7  120.0
7      1008  north    bottle         4  120.0
4      1005  north    eraser        30    8.0
1      1002  south  notebook         5   60.0
0      1001  north       pen        12   15.0
6      1007   east       pen        20   15.0

تأتي كلمة bag قبل bottle لأن الحرف a يسبق o. لدينا حقيبتان وزجاجتان وقلمان — هذه حالات تعادل (ties)، وسنعود قريباً لمسألة تحديد أي العنصرين المتعادلين يسبق الآخر.


عملية الترتيب تُرجع جدولاً جديداً

هذا هو الخطأ الشائع الذي يقع فيه الجميع تقريباً في البداية، ولا ينتج عنه أي تنبيه أو خطأ:

python
orders.sort_values("quantity", ascending=False)

print(orders[cols].head(3))
text
order_id branch   product  quantity  price
0      1001  north       pen        12   15.0
1      1002  south  notebook         5   60.0
2      1003  north       bag         2  850.0

لم يتغير شيء ولم يتم الترتيب! دالة sort_values() لا تعدل جدول orders الأصلي؛ بل تُنشئ وتُرجع DataFrame جديداً بالترتيب المطلوب. السطر الأول أنشأ هذا الجدول المرتب ثم أهمله وتخلص منه لعدم تخزينه في متغير. فبقي orders كما أنتجته دالة read_csv تماماً.

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

دالة sort_values() لا تعدل الجدول الأصلي أبداً. وإذا لم تسند النتيجة لمتغير، فسيحدث الترتيب ويُهمل في الذاكرة دون أن ينبهك أي خطأ.

لذا، احرص دائماً على حفظ النتيجة، إما في اسم متغير جديد أو بإعادة إسنادها لنفس الاسم:

python
by_qty_desc = orders.sort_values("quantity", ascending=False)   # keep both

print(by_qty_desc["order_id"].head(3).tolist())
print(orders["order_id"].head(3).tolist())
text
[1005, 1007, 1001]
[1001, 1002, 1003]

تحتوي النسخة المرتبة على الطلبات الكبيرة في الأعلى؛ بينما بقي الأصل دون مساس. كتابة orders = orders.sort_values(...) مقبولة تماماً عندما تكون متأكداً أنك لم تعد بحاجة إلى الترتيب القديم.

قد تصادف inplace=True في الأكواد القديمة. حيث تقوم بتعديل الجدول في مكانه وتُرجع None، مما يتسبب في أخطاء خاصة به (انظر قسم الأخطاء الشائعة وحلولها). ومع ميزة النسخ عند الكتابة (Copy-on-Write) في بانداس 3، لم يعد خيار inplace=True يوفر أي ذاكرة أيضاً. لذا فإن إسناد النتيجة لمتغير هو الأسلوب الأوضح دائماً.


أعمدة متعددة: الأولوية والاتجاه

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

python
by_branch = orders.sort_values(["branch", "quantity"], ascending=[True, False])

print(by_branch[cols])
text
order_id branch   product  quantity  price
6      1007   east       pen        20   15.0
3      1004   east    bottle         7  120.0
4      1005  north    eraser        30    8.0
0      1001  north       pen        12   15.0
7      1008  north    bottle         4  120.0
2      1003  north       bag         2  850.0
1      1002  south  notebook         5   60.0
5      1006  south       bag         1  850.0

اقرأ الجدول بالطريقة التي بناها بها بانداس. أولاً، وُضع كل صف حسب عمود branch أبجدياً من A إلى Z: فرع east، ثم north، ثم south. أما المفتاح الثاني quantity، فيتم الرجوع إليه فقط عندما تتساوى قيم المفتاح الأول — أي داخل east، وداخل north، وداخل south. فداخل فرع north تظهر الكميات: 30، 12، 4، 2؛ من الأكبر إلى الأصغر، لأن القيمة الثانية في ascending هي False.

يأخذ المعامل ascending قيمة منطقية (True أو False) لكل مفتاح، بنفس ترتيب قائمة by. ولو مررت قيمة واحدة False، فستنطبق على كلا المفتاحين وتضع فرع south أولاً.

وعكس ترتيب الأعمدة في القائمة يغير طبيعة السؤال كلياً:

python
print(orders.sort_values(["quantity", "branch"], ascending=[False, True])[cols].head(4))
text
order_id branch product  quantity  price
4      1005  north  eraser        30    8.0
6      1007   east     pen        20   15.0
0      1001  north     pen        12   15.0
3      1004   east  bottle         7  120.0

الآن أصبحت السيادة للكمية، ولن يتم الالتفات للفرع branch إلا إذا تساوى طلبان في الكمية تماماً — وهو ما لم يحدث هنا. فأصبحت الفروع مشتتة. لذا فإن السؤال الأساسي قبل كتابة القائمة هو: "ما المعيار الأساسي لتنظيم التقرير؟" ذلك العمود هو الذي يوضع أولاً.


حالات التعادل: اجعل الترتيب قراراً مقصوداً لا صدفة

رتِّب بناءً على price، الأغلى سعراً أولاً:

python
print(orders.sort_values("price", ascending=False)[cols])
text
order_id branch   product  quantity  price
2      1003  north       bag         2  850.0
5      1006  south       bag         1  850.0
7      1008  north    bottle         4  120.0
3      1004   east    bottle         7  120.0
1      1002  south  notebook         5   60.0
0      1001  north       pen        12   15.0
6      1007   east       pen        20   15.0
4      1005  north    eraser        30    8.0

انظر إلى الزجاجتين بسعر 120. في الملف الأصلي، يسبق الطلب 1004 الطلب 1008. لكن في الجدول المرتب، جاء 1008 أولاً. لم يحدث عطل: ففي الترتيب بمفتاح واحد، لا تضمن خوارزمية الترتيب الافتراضية في بانداس (quicksort) الحفاظ على الترتيب الأصلي للصفوف المتعادلة. بقيت الحقيبتان بسعر 850 بترتيب الملف، بينما تبادلت الزجاجتان المواضع — لا توجد قاعدة يمكن التنبؤ بها هنا.

غالباً لا يهم ذلك كثيراً، حتى اللحظة التي تقرر فيها اقتطاع القائمة. فإذا كان المطلوب "أغلى ثلاثة طلبات"، فالمركز الثالث يمثل تعادلاً بين 1004 و 1008، وتحديد أيهما يدخل القائمة صار قراراً اتخذته الخوارزمية بالصدفة، وليس أنت. وإذا شغلت الكود على ملف آخر بتنسيق مختلف قليلاً، فقد تختلف النتيجة.

هناك طريقتان لاستعادة السيطرة على هذا القرار.

الخيار 1: الترتيب المستقر (stable sort). يضمن تمرير kind="stable" احتفاظ الصفوف المتعادلة بالترتيب الذي كانت عليه قبل الفرز — وهو ترتيب الملف الأصلي هنا:

python
stable = orders.sort_values("price", ascending=False, kind="stable")

print(stable["order_id"].tolist())
text
[1003, 1006, 1004, 1008, 1002, 1001, 1007, 1005]

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

python
tie_broken = orders.sort_values(["price", "quantity"], ascending=[False, False])

print(tie_broken[cols])
text
order_id branch   product  quantity  price
2      1003  north       bag         2  850.0
5      1006  south       bag         1  850.0
3      1004   east    bottle         7  120.0
7      1008  north    bottle         4  120.0
1      1002  south  notebook         5   60.0
6      1007   east       pen        20   15.0
0      1001  north       pen        12   15.0
4      1005  north    eraser        30    8.0

انظر إلى الزجاجتين مجدداً: أصبح 1004 فوق 1008 لأن الكمية 7 تتفوق على 4. وانظر للقلمين بسعر 15: سابقاً جاء 1001 أولاً؛ والآن فاز 1007 لأن 20 تتفوق على 12 — وسيفوز دائماً على أي جهاز ومهما كان ترتيب وصول الملف. يحرص مفتاح فك التعادل على تحويل الصدفة العشوائية إلى قاعدة يمكنك تفسيرها بثقة. (عند الترتيب بأكثر من عمود، يكون ترتيب بانداس مستقراً بطبيعته، لذا لا تتشتت حالات التعادل الحقيقية أبداً).

وإذا لم تكن لديك قاعدة عمل محددة، فإن عمود order_id يعد مفتاح حسم ممتازاً: فهو فريد بطبيعته، مما يضمن انتفاء أي تعادل بعده.


القيم المفقودة: na_position

اقرأ نسخة الملف التي تحتوي على كميتين فارغتين:

python
gaps = pd.read_csv("orders_gaps.csv")

print(gaps["quantity"].isna().sum())
print(gaps.sort_values("quantity", ascending=False)[cols])
text
2
   order_id branch   product  quantity  price
4      1005  north    eraser      30.0    8.0
0      1001  north       pen      12.0   15.0
3      1004   east    bottle       7.0  120.0
7      1008  north    bottle       4.0  120.0
2      1003  north       bag       2.0  850.0
5      1006  south       bag       1.0  850.0
1      1002  south  notebook       NaN   60.0
6      1007   east       pen       NaN   15.0

لاحظ أمرين هنا: أولاً، تُطبع الكميات الآن بصيغة 30.0 و 12.0 — فقد حولت الفراغات نوع العمود إلى float64 كما أوضح الفصل السادس، لأن NaN تصنف كرقم عشري. ثانياً، انتقلت صفوف NaN إلى أسفل الجدول، على الرغم من أننا طلبنا الترتيب التنازلي (الأكبر أولاً). هذا هو السلوك الافتراضي: na_position="last"، وينطبق في كلا الاتجاهين؛ فالقيمة المفقودة ليست "صغيرة" ولا "كبيرة"، بل مجهولة، لذا يضعها بانداس جانباً بدلاً من التخمين.

لاحظ أثر ذلك على أعلى ثلاثة طلبات: الطلب 1007 بيع فيه 20 قلماً بالفعل، لكن كميته فارغة في هذا الملف، فاستُبعد من أعلى ثلاثة طلبات وحل 1004 محله. لا يمكن لعملية الترتيب إصلاح البيانات المفقودة؛ بل تكشف لك فقط أين استقرت تلك الفراغات. لهذا السبب تضمنت الخطوة 2 من التخطيط إحصاء الفراغات قبل الترتيب.

عند تدقيق البيانات، غالباً ما تريد العكس تماماً — وضع المشاكل في المقدمة لتراها بوضوح:

python
print(gaps.sort_values("quantity", na_position="first")[cols].head(3))
text
order_id branch   product  quantity  price
1      1002  south  notebook       NaN   60.0
6      1007   east       pen       NaN   15.0
5      1006  south       bag       1.0  850.0

إذن na_position ليس مجرد خيار تنسيقي؛ فالخيار "last" يناسب التقارير حتى لا تحجب الفراغات القيم الحقيقية، بينما يناسب "first" عمليات الفحص والتدقيق حيث تكون الفراغات هي ما تبحث عنه تحديداً.


العودة إلى البداية: sort_index() و reset_index()

نظراً لأن كل صف يحتفظ بتسميته، فإن الترتيب الأصلي لا يضيع أبداً. والترتيب بناءً على الفهرس يعيده كما كان:

python
restored = by_qty.sort_index()

print(restored["order_id"].tolist())
text
[1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008]

تقبل الدالة sort_index() المعامل ascending=False أيضاً، وهو ما يتيح لك عرض الجدول بـ "أحدث التسميات أولاً".

في بعض الأحيان تفقد التسميات القديمة معناها. فإذا قمت بالترتيب لإنشاء قائمة متصدرين ومشاركتها، فإن ظهور التسميات 4, 6, 0 على الجانب يكون مربكاً للقارئ. يؤدي استخدام reset_index(drop=True) إلى التخلص منها وإعادة ترقيم الصفوف إلى 0, 1, 2, … وفق ترتيبها الجديد:

python
ranked = orders.sort_values("quantity", ascending=False).reset_index(drop=True)

print(ranked[cols].head(3))
print(ranked.loc[0, "order_id"])
text
order_id branch product  quantity  price
0      1005  north  eraser        30    8.0
1      1007   east     pen        20   15.0
2      1001  north     pen        12   15.0
1005

الآن يتطابق loc[0] و iloc[0] من جديد: فكلاهما يشير إلى الطلب 1005 ذي الكمية الأكبر. وبدون drop=True، سيحتفظ بانداس بالتسميات القديمة في عمود جديد باسم index — وهو أمر مفيد إذا أردت تتبع أصل الصفوف، ومربك إذا كنت لا تحتاجه.

أيهما تختار؟ احتفظ بالتسميات طالما أنك لا تزال تعمل على معالجة البيانات وتحتاج لربط الصفوف بأصلها. وأعد ضبطها عندما يكون الترتيب بحد ذاته هو النتيجة النهائية — مثل قوائم التصنيف والترتيب.


الطريق المختصر لـ "أعلى N": nlargest() و nsmallest()

طلب "أعلى ثلاثة طلبات حسب الكمية" متكرر جداً، لدرجة أن بانداس يوفر دالة مخصصة تعبر عن ذلك مباشرة:

python
print(orders.nlargest(3, "quantity")[cols])
text
order_id branch product  quantity  price
4      1005  north  eraser        30    8.0
6      1007   east     pen        20   15.0
0      1001  north     pen        12   15.0

وهي تُرجع نفس صفوف sort_values("quantity", ascending=False).head(3) تماماً. لماذا نفضلها؟ لأنها تعبر عن الهدف المباشر — "أكبر ثلاثة" — بدلاً من تفاصيل الآلية. كما أنها أسرع في الجداول الكبيرة، لأنها لا تحتاج لترتيب جميع الصفوف فقط للاحتفاظ بثلاثة منها.

ودالة nsmallest() هي المقابل المباشر لها: أرخص ثلاثة منتجات في المتجر:

python
print(orders.nsmallest(3, "price")[cols])
text
order_id branch product  quantity  price
4      1005  north  eraser        30    8.0
0      1001  north     pen        12   15.0
6      1007   east     pen        20   15.0

التعادل عند حد القطع: keep=

اطلب أغلى ثلاثة منتجات وسيحدث أمر محرج:

python
print(orders.nlargest(3, "price")[cols])
text
order_id branch product  quantity  price
2      1003  north     bag         2  850.0
5      1006  south     bag         1  850.0
3      1004   east  bottle         7  120.0

المركز الثالث بسعر 120 — لكن طلبين يحملان هذا السعر: 1004 و 1008. أعاد بانداس أحدهما فقط — وفق القيمة الافتراضية keep="first" التي تختار الأسبق ظهوراً في الجدول — واستبعد الآخر بصمت. فإذا كنت تمنح جوائز لـ "أفضل ثلاثة"، فهذا ظلم بيّن للطلب 1008. يضمن الخيار keep="all" الاحتفاظ بكل صف يتعادل عند حد الاقتطاع، حتى لو أدى ذلك لإرجاع صفوف أكثر من N:

python
print(orders.nlargest(3, "price", keep="all")[cols])
text
order_id branch product  quantity  price
2      1003  north     bag         2  850.0
5      1006  south     bag         1  850.0
3      1004   east  bottle         7  120.0
7      1008  north  bottle         4  120.0

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

تعمل دالتا nlargest و nsmallest على الأعمدة الرقمية فقط. بالنسبة للنصوص، استخدم sort_values مع head.


أعلى N في مجموعة فرعية: صفِّ، ثم رتِّب، ثم اقتطع

"ما هما أكبر طلبين في فرع north؟" يدمج هذا السؤال بين ما تعلمته في الفصل الخامس وهذا الفصل، وترتيب الخطوات حاسم للغاية:

python
north = orders[orders["branch"] == "north"]

top_north = north.sort_values("quantity", ascending=False).head(2)

print(top_north[cols])
text
order_id branch product  quantity  price
4      1005  north  eraser        30    8.0
0      1001  north     pen        12   15.0

قم بالتصفية أولاً، لأن السؤال يدور حول فرع north حصراً. فلو قمت بترتيب الجدول بأكمله وأخذت أول صفين عبر head(2) أولاً، لحصلت على 1005 و 1007 — والطلب 1007 يتبع فرع east. والتصفية بعدها كانت ستبقي صفاً واحداً فقط، فتقدم تقريراً عن "أعلى اثنين" بعنصر واحد يتيم! القاعدة الذهبية: احصر البيانات في الصفوف المطلوبة أولاً، ثم رتبها، ثم اقتطع العدد المطلوب.

ينطبق المنطق نفسه مع دالة nlargest: orders[orders["branch"] == "north"].nlargest(2, "quantity").


ترتيب النصوص دون التحسس لحالة الأحرف: key=

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

python
names = pd.DataFrame({"product": ["pen", "Notebook", "bag", "Bottle", "eraser"]})

print(names.sort_values("product"))
text
product
3    Bottle
1  Notebook
2       bag
4    eraser
0       pen

قفزت Bottle و Notebook قبل bag فقط لأنهما تبدآن بحرف كبير. لا يمكن لأي قارئ اعتبار هذا ترتيباً أبجدياً سليماً.

يمكنك تنظيف البيانات، ولكن في كثير من الأحيان لا يُحبذ ذلك — فقد تكون طريقة الكتابة هي نفسها التي أدخلها العميل. يتيح لك المعامل key= الترتيب وفق نسخة معدلة من العمود مع الاحتفاظ بالقيم الأصلية كما هي. حيث يأخذ دالة تستقبل العمود بأكمله وتُرجع النسخة التي سيتم الترتيب بناءً عليها:

python
print(names.sort_values("product", key=lambda col: col.str.lower()))
text
product
2       bag
3    Bottle
4    eraser
1  Notebook
0       pen

تُستخدم النسخة ذات الأحرف الصغيرة للمقارنة فقط ثم تُهمل. ولا يزال الجدول يعرض Bottle و Notebook تماماً كما كُتبتا. (الدالة lambda col: col.str.lower() هي دالة بسطر واحد: تأخذ العمود col وتُرجعه بحروف صغيرة).


عندما تُخزّن الأرقام كنصوص

هذا هو الفخ الذي وُضعت خطوة فحص أنواع البيانات dtypes لاكتشافه مسبقاً. افترض أن عمود quantity ورد كنصوص — بسبب وجود قيمة "N/A" نصية في الملف، أو لأن نظام التصدير يصدر كل شيء كنصوص. لننشئ عموداً مماثلاً عمداً باستخدام astype لنرى ما يحدث:

python
as_text = orders.astype({"quantity": "str"})

print(as_text["quantity"].dtype)
print(as_text.sort_values("quantity")[["order_id", "quantity"]])
text
str
   order_id quantity
5      1006        1
0      1001       12
2      1003        2
6      1007       20
4      1005       30
7      1008        4
1      1002        5
3      1004        7

النتيجة: 1, 12, 2, 20, 30, 4, 5, 7. لم يظهر أي خطأ برمجياً، لكن الترتيب غير سليم؛ حيث تُقارن النصوص حرفاً بحرف: فالنص "12" يبدأ بالحرف 1، وهو أقل من 2، لذا تأتي "12" قبل "2" — تماماً كما تأتي "ab" قبل "b" في المعجم. نفذ الترتيب ما طُلب منه بدقة، لكن ما طُلب منه كان غير صحيح.

هناك علامتان تكشفان ذلك: محاذاة أرقام quantity لليسار في الطباعة (وهي طريقة عرض النصوص)، وظهور نوع البيانات كـ str في dtype. الحل الجذري هو تحويل العمود لأرقام باستخدام pd.to_numeric (كما تعلمنا في الفصل السادس). وإذا كنت مضطراً لترك العمود كما هو، يمكنك استخدام key= للترتيب وفق القيمة الرقمية:

python
fixed = as_text.sort_values("quantity", key=pd.to_numeric)

print(fixed["quantity"].tolist())
text
['1', '2', '4', '5', '7', '12', '20', '30']

الترتيب صحيح الآن — لكن القيم لا تزال نصوصاً، لذا لن تتمكن من جمعها. الأفضل دائماً هو تحويل العمود مرة واحدة فور قراءة الملف، لتضمن دقة كل العمليات التالية من ترتيب وتصفية وحساب.


مثال تطبيقي متكامل

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

"الإيراد المالي" هو حاصل ضرب الكمية في السعر. سنتناول إضافة الأعمدة بالتفصيل لاحقاً في الدورة؛ وحالياً يكفي هذا السطر لإنشاء عمود جديد بمجرد إسناد ناتج الضرب إليه.

الملف sorted_report.py:

python
import pandas as pd

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

# Check the input before ranking anything
print("Rows, columns:", orders.shape)
print("Blank quantities:", orders["quantity"].isna().sum())
print()

orders["revenue"] = orders["quantity"] * orders["price"]
cols = ["order_id", "date", "branch", "product", "revenue"]

# 1. Top three by revenue, ties broken by the larger quantity, renumbered as a ranking
top3 = (
    orders.sort_values(["revenue", "quantity"], ascending=[False, False])
    .head(3)
    .reset_index(drop=True)
)
print("Top three orders by revenue")
print(top3[cols])
print()

# 2. Newest first; within the same day, the bigger order first
newest = orders.sort_values(["date", "revenue"], ascending=[False, False])
print("Newest first")
print(newest[cols].head(4))
print()

# 3. By branch A-Z, biggest order first inside each branch
by_branch = orders.sort_values(["branch", "revenue"], ascending=[True, False])
print("By branch")
print(by_branch[cols])
print()

print("Still eight rows:", by_branch.shape[0] == orders.shape[0])
text
Rows, columns: (8, 7)
Blank quantities: 0

Top three orders by revenue
   order_id        date branch product  revenue
0      1003  2024-03-02  north     bag   1700.0
1      1006  2024-03-03  south     bag    850.0
2      1004  2024-03-02   east  bottle    840.0

Newest first
   order_id        date branch product  revenue
7      1008  2024-03-04  north  bottle    480.0
6      1007  2024-03-04   east     pen    300.0
5      1006  2024-03-03  south     bag    850.0
4      1005  2024-03-03  north  eraser    240.0

By branch
   order_id        date branch   product  revenue
3      1004  2024-03-02   east    bottle    840.0
6      1007  2024-03-04   east       pen    300.0
2      1003  2024-03-02  north       bag   1700.0
7      1008  2024-03-04  north    bottle    480.0
4      1005  2024-03-03  north    eraser    240.0
0      1001  2024-03-01  north       pen    180.0
5      1006  2024-03-03  south       bag    850.0
1      1002  2024-03-01  south  notebook    300.0

Still eight rows: True

لماذا كُتب البرنامج بهذه الطريقة:

  • التحقق يأتي أولاً. تمت طباعة الأبعاد shape وعدد الفراغات قبل أي ترتيب؛ فالاعتماد على ملف ناقص أو به قيم مفقودة كان سيبدو واثقاً من نفسه ظاهرياً ولكنه خاطئ عملياً.
  • تحويل "الأكبر" إلى عمود رقمي صريح. الترتيب حسب الكمية quantity كان سيضع 30 ممحاة في القمة (بقيمة 240 فقط). بينما الترتيب حسب الإيراد revenue وضع حقيبتين بقيمة 1,700 في الصدارة. خطوة التخطيط الأولى — صياغة السؤال مع ذكر اسم العمود — هي ما حدد الإجابة الصحيحة لصاحب المتجر.
  • تحديد معيار حسم لكل تعادل. في تاريخ 2024-03-04 يوجد طلبان، ويحسم عمود revenue أيهما يسبق الآخر، مما يضمن ثبات التقرير عند كل تشغيل.
  • إعادة ترقيم الفهرس لقائمة التصنيف فقط. الجدول top3 قائمة نهائية، لذا أصبحت تسمياته 0 و 1 و 2. بينما احتفظ newest و by_branch بتسمياتهما الأصلية لتسهيل مراجعة الصفوف في الملف الأصلي.
  • لم يتم تعديل ترتيب الجدول orders الأصلي أبداً. كل تقرير يمثل جدولاً جديداً مبنياً على نفس المدخلات الأصلية، فلا يؤثر تقرير على آخر.
  • السطر الأخير تأكيد سريع وغير مكلف. لا يجوز للترتيب تغيير عدد الصفوف. طباعة True لا تكلف شيئاً وتكشف أي استدعاء عارض لدالة head() في غير موضعه.

الأخطاء الشائعة وحلولها

KeyError: 'Quantity'

python
orders.sort_values("Quantity")
text
KeyError: 'Quantity'

يجب أن يطابق الاسم في by= اسم العمود تماماً — بما في ذلك حالة الأحرف والمسافات. اسم العمود هو quantity بحروف صغيرة. اطبع list(orders.columns) لمعاينة الأسماء الحقيقية بما فيها علامات التنصيص والمسافات الزائدة، كما نصحنا في الفصل الثالث.

AttributeError: 'NoneType' object has no attribute 'head'

python
top = orders.sort_values("quantity", inplace=True)
top.head()
text
AttributeError: 'NoneType' object has no attribute 'head'

عند تمرير inplace=True، تقوم دالة sort_values بتعديل جدول orders مباشرة وتُرجع None. بالتالي أصبحت قيمة top هي None، والتي لا تملك دالة head(). اختر أحد الأسلوبين: إما كتابة orders.sort_values("quantity", inplace=True) في سطر مستقل، أو — وهو الأفضل — top = orders.sort_values("quantity") بدون inplace.

ValueError: Length of ascending (1) != length of by (2)

python
orders.sort_values(["branch", "quantity"], ascending=[False])
text
ValueError: Length of ascending (1) != length of by (2)

تحتاج قائمة ascending إلى عنصر مقابل لكل مفتاح في by. مفتاحان يتطلبان قيمتين منطقيتين: ascending=[True, False]. (يُسمح بتمرير قيمة منطقية واحدة مباشرة مثل False خارج قائمة، وستنطبق حينها على كل المفاتيح).

ValueError: For argument "ascending" expected type bool, received type str.

python
orders.sort_values("branch", ascending="False")
text
ValueError: For argument "ascending" expected type bool, received type str.

القيمة "False" بين علامات تنصيص هي نص وليست قيمة منطقية بولية. احذف علامات التنصيص لتصبح False.

TypeError: Column 'branch' has dtype str, cannot use method 'nlargest' with this dtype

python
orders.nlargest(3, "branch")
text
TypeError: Column 'branch' has dtype str, cannot use method 'nlargest' with this dtype

ترتب دالتا nlargest و nsmallest الأرقام فقط. وللحصول على "آخر ثلاثة فروع أبجدياً"، استخدم orders.sort_values("branch", ascending=False).head(3). وإذا ظهر لك هذا الخطأ في عمود كنت تتوقع أنه رقمي، فالمشكلة الأساسية أنه قُرئ كنص — راجع dtypes.

TypeError: '<' not supported between instances of 'str' and 'int'

python
mixed = pd.Series([12, "unknown", 5])
mixed.sort_values()
text
TypeError: '<' not supported between instances of 'str' and 'int'

يحتوي هذا العمود على أرقام حقيقية ونص معاً، لذا فإن نوعه هو object، ولا يستطيع بايثون تحديد ما إذا كانت "unknown" أكبر أم أصغر من 12. نظف العمود أولاً: يحول pd.to_numeric(mixed, errors="coerce") النص "unknown" إلى NaN، وعندها ينجح الترتيب ويحدد na_position مكان ظهور الفراغ.