الفصل 05

تصفية الصفوف — طرح السؤال نفسه على كل صف

الاحتفاظ بالصفوف التي تطابق الشرط فقط: الأقنعة المنطقية (Boolean masks)، والربط باستخدام & و | و ~، ودوال isin و between وطرق .str، وتصفية الصفوف عبر .loc — وتعديل تلك الصفوف بأمان دون الوقوع في خطأ الإسناد المتسلسل.

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

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

أرسل مدير المتجر رسالة يطلب فيها: "ما هي الطلبات الواردة من فرع الشمال (north branch) التي تجاوزت ثلاث قطع؟ أريد الاطلاع عليها."

إذا كان لديك ثمانية طلبات فقط كما في الفصل السابق، لكان بإمكانك الإجابة بمجرد النظر: مرر إصبعك على عمود branch، وتوقف عند كل north، ثم القِ نظرة سريعة على عمود quantity. ثلاثة صفوف فقط. انتهى الأمر.

لكن تخيل الآن الملف الحقيقي في بيئة العمل — اثنا عشر ألف طلب على مدار عام كامل. هنا يعجز النظر البشري، وأول فكرة تخطر ببال معظم مبرمجي بايثون هي استخدام حلقة تكرار (loop):

python
import pandas as pd

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

keep = []
for i in range(len(orders)):
    row = orders.iloc[i]
    if row["branch"] == "north" and row["quantity"] > 3:
        keep.append(int(row["order_id"]))

print(keep)
text
[1001, 1005, 1008]

النتيجة صحيحة تماماً. لكن انظر إلى التكلفة البرمجية الباهظة: عداد، وقائمة فارغة، وشرط if، وعملية append، وتفكيك الجدول صفاً تلو الآخر. والأسوأ من ذلك أن النتيجة لم تعد جدولاً — بل أصبحت مجرد قائمة بأرقام الطلبات. فإذا أردت أن تعرض للمدير أسماء المنتجات وأسعارها أيضاً، فستضطر لكتابة حلقة تكرار أخرى. كما أن هذا الكود شديد البطء عند تطبيقه على اثني عشر ألف صف، لأن كل استدعاء orders.iloc[i] ينشئ كائن Series جديداً بالكامل لمجرد قراءة قيمتين منه.

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

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

  • تحويل أي سؤال إلى قناع منطقي (boolean mask) — عمود من قيم True/False، بمعدل قيمة لكل صف — واستخدامه للاحتفاظ بالصفوف، أو لعدّها وحساب نسبتها عبر mask.sum() و mask.mean()
  • دمج الشروط باستخدام المعاملات & و | و ~، وتفسير سبب كون الأقواس إلزامية تماماً
  • استخدام دوال isin و between وطرق .str للتعامل مع القوائم والنطاقات والأنماط النصية
  • تصفية الصفوف واختيار الأعمدة في خطوة واحدة باستخدام .loc[mask, columns]
  • تعديل القيم في الصفوف المصفاة بأمان عبر .loc[mask, column] = value، والتعرف على خطأ ChainedAssignmentError الذي ينتج عن الطريقة غير الآمنة في بانداس 3

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


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

يستخدم هذا الفصل ملف 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

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

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

1. قسّم السؤال إلى شروط مستقلة، شرط واحد لكل عمود. حلل عبارة المدير إلى أجزائها:

  • "الطلبات الواردة من فرع الشمال" ← العمود branch، المقارنة يساوي، القيمة "north"
  • "تجاوزت ثلاث قطع" ← العمود quantity، المقارنة أكبر من، القيمة 3
  • "أريد الاطلاع عليها" ← لا يوجد اختبار؛ احتفظ بالصف بأكمله

يفرض عليك هذا التقسيم اتخاذ قرارين حاسمين: أولاً، عبارة "تجاوزت ثلاثة" تعني > 3 وليست >= 3 — أي أن الطلب الذي يحتوي على ثلاث قطع تحديداً لا يُحسب. ثانياً، الشرطان مرتبطان بحرف العطف و (and): يجب أن يجتاز الطلب كلا الاختبارين معاً. هذان القراران هما جوهر التصفية بأكمله؛ وما تبقى هو مجرد كتابة الكود.

2. افحص المدخلات — الأبعاد (shape)، والأنواع (dtypes)، والقيم الفعلية التي ستقارن بها.

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، لذا فإن المقارنة > 3 ستقارن أرقاماً حسابية. لو كان قد قُرئ كـ str — بسبب كتابة أحدهم "3 pcs" في إحدى الخلايا — لفشلت المقارنة بخطأ TypeError، ومن الضروري معرفة ذلك الآن قبل البدء بالعمل. أما العمود branch فهو str، وستتم مقارنته كنص، ومقارنة النصوص دقيقة وحرفية: فالقيم "north" و "North" و "north " تُعتبر ثلاث قيم مختلفة تماماً. لذا، اطلع على القيم الموجودة فعلياً:

python
print(orders["branch"].unique())
text
<StringArray>
['north', 'south', 'east']
Length: 3, dtype: str

ثلاثة فروع، مكتوبة بصيغة موحدة دون مسافات زائدة. الآن تضمن أن القيمة "north" في الكود ستطابق البيانات بشكل مؤكد.

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

4. اختر الأداة المناسبة وفقاً لطبيعة الشرط.

  • مقارنة واحدة ← قناع منطقي: df["col"] > value
  • شروط متعددة ← أقنعة مدمجة بواسطة & (and)، و | (or)، و ~ (not)
  • "واحدة من هذه القيم" ← df["col"].isin([...])
  • "بين قيمتين" ← df["col"].between(low, high)
  • نمط داخل نص ← df["col"].str.contains(...)، و .str.startswith(...)
  • تصفية صفوف و اختيار أعمدة محددة ← df.loc[mask, ["col1", "col2"]]
  • تعديل القيم في تلك الصفوف ← df.loc[mask, "col"] = new_value

سؤال المدير عبارة عن مقارنتين مرتبطتين بـ and، إذن نحتاج: قناعين مدمجين بواسطة &.

5. حدد كيف ستتحقق من صحة الإجابة. بعد التصفية، هناك ثلاثة اختبارات سريعة تكشف معظم الأخطاء: تطابق عدد الصفوف مع تقديرك الأولي؛ وتحقق الشرط في كل صف متبقٍ ((result["quantity"] > 3).all() يجب أن تُرجع True)؛ وعدم اختفاء أي صف متوقع (مثل وجود الطلب 1008 في النتيجة).

والآن إلى الكود — خطوة تلو الأخرى.


المقارنة تُنتج قناعاً منطقياً

عندما تقارن عموداً بقيمة معينة، لا تمنحك بانداس قيمة مفردة True أو False؛ بل تقارن كل صف على حدة وتمنحك عموداً كاملاً من الإجابات:

python
big = orders["quantity"] > 5

print(big)
text
0     True
1    False
2    False
3     True
4     True
5    False
6     True
7    False
Name: quantity, dtype: bool

هذا كائن Series من النوع bool — ويُسمى قناعاً منطقياً (boolean mask). يمتلك نفس الفهرس لجدول orders (0 إلى 7)، ويحتوي على إجابة لكل صف: الصف 0 فيه 12 قطعة، إذن True؛ والصف 1 فيه 5 قطع، والعدد 5 ليس أكبر من 5، إذن False.

لماذا تعمل بانداس بهذه الطريقة بدلاً من إجبارك على كتابة حلقة تكرار؟ لأن العمود يُخزن في الذاكرة ككتلة متصلة من الأرقام من نوع بيانات واحد، ومقارنة كتلة كاملة بالقيمة 5 هي عملية واحدة تُنفذ عبر كود C مجمع وفائق السرعة (vectorized operation). حلقتك السابقة طلبت من بايثون فحص كل صف على حدة ثماني مرات — أو اثني عشر ألف مرة. أما القناع فيسأل السؤال مرة واحدة للجميع.

تعمل جميع معاملات المقارنة بالطريقة نفسها: ==، !=، <، <=، >، >=. كما تعمل على الأعمدة النصية أيضاً — التعبير orders["branch"] == "north" يُنتج قناعاً يحتوي على True في الصفوف 0 و 2 و 4 و 7.

حتى الآن، لم تتم تصفية أي شيء. فالقناع يمثل الإجابات فقط. والخطوة التالية هي استخدامها.

القناع يحتفظ بالصفوف

ضع القناع المنطقي داخل أقواس مربعة، وسيقوم بانداس بالاحتفاظ بالصفوف التي يقابلها True في القناع:

python
print(orders[big])
text
order_id        date branch product     category  quantity  price
0      1001  2024-03-01  north     pen   stationery        12   15.0
3      1004  2024-03-02   east  bottle  accessories         7  120.0
4      1005  2024-03-03  north  eraser   stationery        30    8.0
6      1007  2024-03-04   east     pen   stationery        20   15.0

أربعة صفوف من أصل ثمانية. انظر إلى الفهرس على اليسار: 0, 3, 4, 6. تحتفظ الصفوف المتبقية بتسمياتها (labels) الأصلية. فالتصفية لا تعيد ترقيم الصفوف إطلاقاً — فالصف 3 يظل الصف 3، وهو نفس الطلب 1004 الذي كان في الجدول الأصلي. وهذه الميزة بالغة الأهمية وسنعود إليها لاحقاً.

كيف تطابق بانداس القناع مع صفوف الجدول؟ ليس حسب الموضع — بل عبر تسمية الفهرس (index label). التسمية 0 في القناع تقرر مصير الصف 0، والتسمية 3 تقرر مصير الصف 3. هنا الفهرسان متطابقان تماماً لأن القناع بُني من جدول orders نفسه، لذا لا يظهر أي اختلاف. ويظهر ذلك فقط إذا استخدمت قناعاً مأخوذاً من جدول مختلف — وهو ما سنناقشه في قسم "عندما تفشل الشيفرة".

غالباً ما ستشاهد القناع مكتوباً مباشرة داخل الأقواس دون حفظه في متغير:

python
print(orders[orders["branch"] == "north"])
text
order_id        date branch product     category  quantity  price
0      1001  2024-03-01  north     pen   stationery        12   15.0
2      1003  2024-03-02  north     bag  accessories         2  850.0
4      1005  2024-03-03  north  eraser   stationery        30    8.0
7      1008  2024-03-04  north  bottle  accessories         4  120.0

اقرأ التعبير من الداخل إلى الخارج: الجزء الداخلي `orders["branch"] == "north"` يبني القناع، بينما الجزء الخارجي `orders[ … ]` يحتفظ بالصفوف التي قيمتها True. وتسمية القناع في متغير منفصل (big = …) تكون مفيدة عندما يكون الشرط طويلاً أو عند استخدامه عدة مرات؛ أما للشروط البسيطة فالكتابة المباشرة ممتازة.

العد بواسطة القناع دون تصفية

في كثير من الأحيان، لا يحتاج المدير إلى رؤية الصفوف نفسها، بل يريد فقط معرفة العدد: كم عدد الطلبات التي تجاوزت خمس قطع؟ لا تحتاج إلى تصفية الجدول لمعرفة ذلك. في الحسابات البرمجية، تُعامل القيمة True كـ 1، والقيمة False كـ 0، لذا:

python
print(big.sum())
print(big.mean())
text
4
0.5

تقوم sum() بجمع الآحاد: 4 طلبات. وتقوم mean() بالقسمة على إجمالي عدد الصفوف: 4 من 8 تعطي 0.5، أي أن نصف الطلبات تجاوزت خمس قطع. لمعرفة "كم العدد"، استخدم .sum() على القناع؛ ولمعرفة "ما النسبة"، استخدم .mean() على القناع. كلاهما سطر واحد ولا ينشئ أي منهما جدولاً جديداً في الذاكرة.

وعندما تريد حساب مجموع داخل الصفوف المصفاة — مثل إجمالي عدد القطع في تلك الطلبات الكبيرة — قم بالتصفية أولاً، ثم حدد العمود، ثم احسب المجموع:

python
print(orders[big]["quantity"].sum())
text
69

12 + 7 + 30 + 20 = 69. قراءة عمود من جدول مصفى بهذه الطريقة أمر آمن تماماً. أما الكتابة والتعديل بهذه الطريقة فمحفوف بالمخاطر — وهو ما سنشرحه في القسم الأخير من هذا الفصل.


شروط متعددة: & و | و ~

يتضمن سؤال المدير شرطين. تدمج بانداس الأقنعة المنطقية باستخدام ثلاثة معاملات:

  • المعامل & يعني و (and) — يُحتفظ بالصف عندما يكون كلا القناعين True
  • المعامل | يعني أو (or) — يُحتفظ بالصف عندما يكون قناع واحد على الأقل True
  • المعامل ~ يعني ليس (not) — يعكس كل True إلى False والعكس صحيح

إليك حل سؤال المدير أخيراً:

python
north_big = orders[(orders["branch"] == "north") & (orders["quantity"] > 3)]

print(north_big)
text
order_id        date branch product     category  quantity  price
0      1001  2024-03-01  north     pen   stationery        12   15.0
4      1005  2024-03-03  north  eraser   stationery        30    8.0
7      1008  2024-03-04  north  bottle  accessories         4  120.0

ثلاثة صفوف: 1001 و 1005 و 1008 — تماماً كما توقعنا في خطوة التخطيط. والآن لنجرِ اختبارات التحقق من الخطوة 5:

python
print(len(north_big))
print((north_big["quantity"] > 3).all())
print((north_big["branch"] == "north").all())
text
3
True
True

تطابق العدد، وتحقق كلا الشرطين في كل صف متبقٍ. الدالة .all() تسأل: "هل كل قيمة في هذا القناع هي True؟" — وهي أسرع طريقة للتأكد من أن الفلتر أدى المطلوب بدقة.

مثال على سؤال يستخدم أو (or): طلبات فرع الشرق، أو أي طلب لأي منتج بسعر 850 أو أكثر.

python
print(orders[(orders["branch"] == "east") | (orders["price"] >= 850)])
text
order_id        date branch product     category  quantity  price
2      1003  2024-03-02  north     bag  accessories         2  850.0
3      1004  2024-03-02   east  bottle  accessories         7  120.0
5      1006  2024-03-03  south     bag  accessories         1  850.0
6      1007  2024-03-04   east     pen   stationery        20   15.0

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

مثال على سؤال يستخدم النفي (not): كل المنتجات ما عدا الأدوات المكتبية (stationery).

python
print(orders[~(orders["category"] == "stationery")])
text
order_id        date branch product     category  quantity  price
2      1003  2024-03-02  north     bag  accessories         2  850.0
3      1004  2024-03-02   east  bottle  accessories         7  120.0
5      1006  2024-03-03  south     bag  accessories         1  850.0
7      1008  2024-03-04  north  bottle  accessories         4  120.0

يقوم ~ بتحويل كل True إلى False والعكس. للمقارنة الفردية البسيطة، يمكنك كتابة != بدلاً من ذلك، وهو أبسط؛ وتتجلى قوة ~ الحقيقية عندما تنفي شرطاً مركباً أو دالة isin (كما سنرى قريباً).

لماذا تعتبر الأقواس إلزامية

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

python
print(orders[orders["quantity"] > 3 & orders["price"] < 100])
text
TypeError: Cannot perform 'rand_' with a dtyped [float64] array and scalar of type [bool]

ما تراه أعلاه هو السطر الأخير من تتبع الخطأ (traceback)، وهو السطر الذي يحدد اسم الخطأ. والسبب في هذا الخطأ هو أسبقية المعاملات (operator precedence) — أي الترتيب الذي ينفذ به بايثون العمليات. في بايثون، يمتلك المعامل & أسبقية أعلى من معاملات المقارنة > و <. وقد وُضع هذا الترتيب قبل ظهور بانداس بوقت طويل، لأن & صُمم أصلاً للعمليات الثنائية على الأعداد الصحيحة (bitwise and)، والتي يجب أن تسبق المقارنات. لذلك يقرأ بايثون السطر على هذا النحو:

text
orders["quantity"] > (3 & orders["price"]) < 100

يحاول بايثون حساب 3 & orders["price"] أولاً — وهي عملية bitwise and بين الرقم 3 وعمود من الأعداد العشرية — وهذا هو سبب الخطأ. وتذكر الرسالة rand_ (وهي عملية and للطرف الأيمن) ومصفوفة، ولكن السبب الحقيقي دائماً هو: نسيان الأقواس.

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

python
print(len(orders[(orders["quantity"] > 3) & orders["price"] < 100]))
print(len(orders[(orders["quantity"] > 3) & (orders["price"] < 100)]))
text
8
4

يقرأ بايثون السطر الأول هكذا: ((quantity > 3) & price) < 100. يقوم المعامل & بدمج القناع مع الأسعار، وتصادف أن جميع النتائج أقل من 100، فيحتفظ هذا "الفلتر" بجميع الصفوف الثمانية! بينما الفلتر الصحيح يحتفظ بأربعة صفوف فقط. الأعمدة نفسها، والأرقام نفسها، وفارق زوج واحد فقط من الأقواس — والنسخة الخاطئة تعمل دون أي تنبيه وتُخرج بيانات مغلوطة. احرص على إحاطة كل شرط بأقواسه الخاصة في كل مرة. وحينها لن تقع في فخ الخطأ البرمجي ولا النتيجة المضللة.

لماذا لا نستخدم كلمة and؟

من الطبيعي أن يفكر المبرمج في كتابة الكلمة الإنجليزية المعتادة:

python
print(orders[(orders["branch"] == "north") and (orders["quantity"] > 3)])
text
ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().

تتطلب كلمات بايثون المحجوزة and و or و not قيمة منطقية واحدة True أو False على كل جانب — لأنها صُممت لجمل if الشرطية. لاستخدام and، يسأل بايثون القناع الأول: "هل قيمتك صحيحة؟"، ولا يمكن لعمود يضم ثماني إجابات مختلفة أن يرد بإجابة واحدة. هل هو صحيح إذا كانت أي قيمة فيه True؟ أم إذا كانت جميعها كذلك؟ يرفض بانداس التخمين، وتسرد رسالة الخطأ الطرق التي يمكنك الاختيار من بينها (a.any() أو a.all()). لكنك لم تكن تطلب إجابة مفردة؛ بل أردت عملية "and" صفاً بصف. وتلك هي وظيفة &. القاعدة الذهبية: داخل عمليات التصفية، تتحول and/or/not دائماً إلى &/|/~.


واحدة من عدة قيم: isin

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

python
print(orders[orders["branch"].isin(["east", "south"])])
text
order_id        date branch   product     category  quantity  price
1      1002  2024-03-01  south  notebook   stationery         5   60.0
3      1004  2024-03-02   east    bottle  accessories         7  120.0
5      1006  2024-03-03  south       bag  accessories         1  850.0
6      1007  2024-03-04   east       pen   stationery        20   15.0

وهنا تبرز الفائدة الكبيرة للمعامل ~ — لتحديد جميع الفروع ما عدا الشمال والشرق، نضع ~ مباشرة أمام isin:

python
print(orders[~orders["branch"].isin(["north", "east"])]["order_id"].tolist())
text
[1002, 1006]

طلبان، كلاهما من فرع الجنوب. لاحظ أن التعبير ~orders["branch"].isin([...]) لا يحتاج إلى أقواس إضافية: لأن ~ ينطبق مباشرة على ناتج استدعاء الدالة، ولا يوجد & أو | ليتنازع معها في الأسبقية. ولكن بمجرد دمجه مع شرط آخر، تعود الأقواس للظهور: (~orders["branch"].isin([...])) & (orders["quantity"] > 3).

نطاق من القيم: between

الطلبات التي يتراوح سعرها بين 15 و 120. يتطلب هذا مقارنتين: >= 15 و <= 120. تتيح لك دالة between التعبير عن ذلك بكلمة واحدة:

python
print(orders[orders["price"].between(15, 120)])
text
order_id        date branch   product     category  quantity  price
0      1001  2024-03-01  north       pen   stationery        12   15.0
1      1002  2024-03-01  south  notebook   stationery         5   60.0
3      1004  2024-03-02   east    bottle  accessories         7  120.0
6      1007  2024-03-04   east       pen   stationery        20   15.0
7      1008  2024-03-04  north    bottle  accessories         4  120.0

تشمل الدالة between كلا الطرفين افتراضياً — فالأقلام بسعر 15.0 والزجاجات بسعر 120.0 مشمولة في النتيجة. وهذا يطابق الفهم العام لعبارة "من 15 إلى 120"، ولكن يجب التأكد من نص المسألة. إذا كان المطلوب حصراً ما يقع بينهما بدقة واستبعاد الأطراف، فحدد ذلك صراحة:

python
print(orders["price"].between(15, 120, inclusive="neither").sum())
text
1

الدفتر فقط بسعر 60.0 يقع بين القيمتين بدقة. وتقبل المعلمة inclusive أيضاً الخيارات "left" و "right" للنطاقات شبه المفتوحة — وهو الخيار الشائع للشرائح المالية مثل 0–100 و 100–200 لتجنب وقوع القيمة الحدية في شريحتين معاً.

الأنماط النصية: دوال .str

تتطلب المقارنة العادية للنصوص تطابقاً تاماً. يوفر الموصّل .str لعمود النصوص دوال السلاسل النصية القياسية في بايثون، ويطبقها على كل صف، وتعيد كل منها قناعاً منطقياً.

المنتجات التي يبدأ اسمها بحرف "b":

python
print(orders[orders["product"].str.startswith("b")])
text
order_id        date branch product     category  quantity  price
2      1003  2024-03-02  north     bag  accessories         2  850.0
3      1004  2024-03-02   east  bottle  accessories         7  120.0
5      1006  2024-03-03  south     bag  accessories         1  850.0
7      1008  2024-03-04  north  bottle  accessories         4  120.0

المنتجات التي تحتوي على حرف "o"، سواء كان كبيراً أو صغيراً:

python
print(orders[orders["product"].str.contains("O", case=False)])
text
order_id        date branch   product     category  quantity  price
1      1002  2024-03-01  south  notebook   stationery         5   60.0
3      1004  2024-03-02   east    bottle  accessories         7  120.0
7      1008  2024-03-04  north    bottle  accessories         4  120.0

لا يوجد حرف O كبير في البيانات، ومع ذلك تم العثور على الدفتر والزجاجتين لأن case=False تتجاهل حالة الأحرف. وبدونها، لم تكن .str.contains("O") لتجد أي نتيجة.

أما المقارنة التامة (==) فلا تحتوي على مثل هذا الخيار. وهنا يقع الخطأ الأكثر شيوعاً الذي يجعل المبرمج يتساءل "لماذا لا يُرجع الفلتر أي صفوف؟":

python
print(orders[orders["branch"] == "North"])
text
Empty DataFrame
Columns: [order_id, date, branch, product, category, quantity, price]
Index: []

لا يوجد خطأ برمجي، ولا توجد صفوف أيضاً. الجدول سليم وبانداس سليم — المشكلة ببساطة أنه لا يوجد فرع كُتب بحرف كبير "North". لهذا السبب قمنا في الخطوة 2 بطباعة unique(): يجب دائماً نسخ القيم التي تقارن بها من محتوى العمود الفعلي وليس كتابتها من الذاكرة.

التواريخ النصية تُقارن بشكل صحيح — إذا كانت بتنسيق ISO

قُرئ عمود date كنص str. فهل يمكنك تصفيته باستخدام >=؟ الطلبات من 3 مارس فصاعداً:

python
print(orders["date"] >= "2024-03-03")
text
0    False
1    False
2    False
3    False
4     True
5     True
6     True
7     True
Name: date, dtype: bool

هذا يعمل بالفعل، ولكن من المهم فهم السبب. هذه نصوص، لذا فإن >= تقارنها كنصوص أبجدية، حرفاً بحرف، تماماً كما تُرتّب الكلمات في القاموس. وقد أعطى ذلك نتيجة صحيحة فقط لأن التواريخ كُتبت بتنسيق السنة-الشهر-اليوم مع أصفار ملء الخانات (2024-03-03)، مما جعل الترتيب الأبجدي يطابق الترتيب الزمني صدفة. لو كُتب العمود بصيغة 3/3/2024، لأعطت المقارنة نتائج خاطئة — لأن "10/1/2024" ستأتي قبل "9/1/2024" نظراً لأن الحرف "1" يسبق الحرف "9". ولأي عمليات تتجاوز المقارنات البسيطة، يجب تحويل التواريخ إلى تواريخ فعلية عبر pd.to_datetime، وهو ما سنتناوله في فصل إضافة الأعمدة.


الصفوف والأعمدة معاً: .loc[mask, columns]

احتفظت جميع الفلاتر السابقة بكل الأعمدة. لكن المدير طلب طلبات فرع الشمال، والتقرير لا يحتاج سوى المنتج والكمية. لقد تعرفت على .loc[rows, columns] في الفصل السابق باستخدام التسميات. يقبل جزء الصفوف أيضاً قناعاً منطقياً (mask):

python
print(orders.loc[orders["branch"] == "north", ["product", "quantity"]])
text
product  quantity
0     pen        12
2     bag         2
4  eraser        30
7  bottle         4

خطوة واحدة، وكائن واحد، ويستطيع أي شخص يقرأ كودك أن يرى السؤال كاملاً — أي الصفوف، وأي الأعمدة — في سطر واحد. وإذا مررت اسم عمود مفرد بدلاً من قائمة .loc[mask, "quantity"]، فستحصل على كائن Series جاهز للعمليات الحسابية:

python
print(orders.loc[orders["branch"] == "north", "quantity"].sum())
text
48

12 + 2 + 30 + 4 = 48 قطعة بيعت في فرع الشمال.

query(): الفلتر نفسه على هيئة جملة مقروءة

هناك صيغة ثانية لكتابة الفلاتر وهي دالة query()، والتي تستقبل الشرط كنص:

python
print(orders.query("branch == 'north' and quantity > 3"))
text
order_id        date branch product     category  quantity  price
0      1001  2024-03-01  north     pen   stationery        12   15.0
4      1005  2024-03-03  north  eraser   stationery        30    8.0
7      1008  2024-03-04  north  bottle  accessories         4  120.0

الصفوف الثلاثة نفسها تماماً. داخل النص، يمكنك كتابة أسماء الأعمدة مباشرة، ويُسمح باستخدام and و or و not، لأن query تحلل النص برمجياً بنفسها ولا تسلمه لمعاملات بايثون. وإذا أردت استخدام متغير بايثون، فضع قبله الرمز @:

python
min_qty = 5
print(orders.query("quantity >= @min_qty")["order_id"].tolist())
text
[1001, 1002, 1004, 1005, 1007]

تتميز query بوضوح قراءتها للشروط الطويلة. ولكن لها عيوبها: فالأخطاء الإملائية داخل النص لا تُكتشف إلا عند تشغيل السطر، ومحرر الكود لا يقدم إكمالاً تلقائياً داخله، وأسماء الأعمدة التي تحتوي على مسافات تتطلب استخدام علامات الاقتباس المائلة (backticks). تعتمد هذه الدورة على الأقنعة المنطقية كأداة أساسية لأن كل شيء آخر في بانداس — مثل .loc والإسناد والحسابات — مبني عليها. ولكن يجب أن تعرف query لتتمكن من قراءة أكواد الآخرين.


الجدول المصفى يحتفظ بتسمياته القديمة

بالعودة إلى الفهرس: احفظ طلبات فرع الشمال وافحص فهرسها:

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

print(north.index.tolist())
text
[0, 2, 4, 7]

التسميات هي 0, 2, 4, 7. فماذا يحدث عند كتابة north.loc[1]؟ لا توجد تسمية 1 في هذا الجدول — فالطلب 1002 كان من فرع الجنوب واستُبعد بالتصفية:

python
print(north.loc[1])
text
KeyError: 1

تبحث .loc عن تسمية (label)، والتسمية 1 غير موجودة هنا. وإذا كنت تقصد "الطلب الثاني في فرع الشمال"، فهذا موضع ترتيبي (position)، والمواضع يتم الوصول إليها عبر .iloc:

python
print(north.iloc[1])
text
order_id           1003
date         2024-03-02
branch            north
product             bag
category    accessories
quantity              2
price             850.0
Name: 2, dtype: object

الصف الثاني حسب الموضع هو طلب الحقيبة — وقيمة Name الخاصة به هي 2، وهي تسميته الأصلية في الجدول الأول. هذا هو التمييز الدقيق بين loc و iloc من الفصل السابق، وتظهر أهميته بوضوح بعد التصفية لأن التسميات والمواضع تتوقف عن التطابق.

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

python
print(north.reset_index(drop=True))
text
order_id        date branch product     category  quantity  price
0      1001  2024-03-01  north     pen   stationery        12   15.0
1      1003  2024-03-02  north     bag  accessories         2  850.0
2      1005  2024-03-03  north  eraser   stationery        30    8.0
3      1008  2024-03-04  north  bottle  accessories         4  120.0

يتخلص drop=True من التسميات القديمة. ودونها، ستُحفظ التسميات كعمود جديد باسم index، وهو ما لا تريده عادة في التقارير.

النتيجة الفارغة ليست خطأً برمجياً

python
none = orders[orders["branch"] == "west"]

print(none)
print(none.shape)
print(none.empty)
text
Empty DataFrame
Columns: [order_id, date, branch, product, category, quantity, price]
Index: []
(0, 7)
True

لا يوجد فرع باسم الغرب، لذا لا توجد صفوف — ولا يوجد أي خطأ برمجي. هذا سلوك صحيح تماماً (فالصفر إجابة مقبولة)، ولكنه يعني أن الفلتر الذي يحتوي على خطأ إملائي سيفشل بصمت. فالمجموع على جدول فارغ يكون 0 والمتوسط يكون nan، وقد تنتقل هذه القيم لمسافات طويلة داخل التقرير قبل أن يلاحظها أحد. عندما تعتمد خطوة هامة على نتيجة التصفية، تحقق من result.empty أو عدد الصفوف، وأوضح ذلك صراحة في المخرجات: كتابة "No orders from the west branch." أفضل بكثير من طباعة جدول فارغ.


تعديل الصفوف المصفاة: .loc[mask, column] = value

في بعض الأحيان، نقوم بالتصفية بهدف التعديل. كانت أسعار الأقلام المباعة في فرع الشرق خاطئة؛ وكان ينبغي أن تكون 13.5 بدلاً من 15.0. الكود المغري هنا هو تصفية الجدول ثم اختيار العمود — أي زوجان من الأقواس المربعة، تماماً كما فعلنا عند قراءة القيم:

python
import pandas as pd

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

orders[orders["branch"] == "east"]["price"] = 13.5

print(orders["price"].tolist())
text
[15.0, 60.0, 850.0, 120.0, 8.0, 850.0, 15.0, 120.0]
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment.
Such chained assignment never works to update the original DataFrame or Series, because the intermediate object on which we are setting values always behaves as a copy (due to Copy-on-Write).

Try using '.loc[row_indexer, col_indexer] = value' instead, to perform the assignment in a single step.

See the documentation for a more detailed explanation: https://pandas.pydata.org/pandas-docs/stable/user_guide/copy_on_write.html#chained-assignment

انظر إلى الأسعار: لم يتغير أي شيء. وقد أصدر بانداس تحذيراً مفصلاً. (يظهر هذا التحذير في الطرفية عبر stderr قبل القائمة، ولكننا نعرضه هنا بعد المخرجات للتوضيح). يُعرف هذا بـ الإسناد المتسلسل (chained assignment) — خطوتان متتاليتان للفهرسة: orders[mask] ثم ["price"]، مع وجود علامة التعيين في نهاية السلسلة. الخطوة الأولى orders[mask] تنشئ جدولاً جديداً مؤقتاً يحتوي على صفوف فرع الشرق. والخطوة الثانية تعدل الأسعار في ذلك الجدول المؤقت الجديد. ثم يختفي هذا الجدول المؤقت من الذاكرة لأنه لم يُحفظ في أي متغير. ولم يتأثر جدول orders الأصلي على الإطلاق.

في إصدار بانداس 3، أصبح هذا السلوك مضموناً وثابتاً تحت آلية تُعرف بـ النسخ عند الكتابة (Copy-on-Write): أي جدول تحصل عليه عبر الفهرسة يتصرف كنسخة مستقلة تماماً، وبالتالي فإن التعديل عليه لا يمكن أن يصل إلى الجدول الأصلي. ويُعد ChainedAssignmentError تحذيراً وليس استثناءً يوقف البرنامج — أي أن البرنامج يواصل العمل — وهذا هو مكمن خطورته: حيث ينتهي السكريبت "بنجاح" مع بقاء الأسعار القديمة دون تعديل!

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

الحل هو اتباع نصيحة التحذير نفسه: خطوة فهرسة واحدة فقط عبر .loc[rows, column] مع وضع التعيين عليها مباشرة:

python
orders.loc[orders["branch"] == "east", "price"] = 13.5

print(orders["price"].tolist())
text
[15.0, 60.0, 850.0, 13.5, 8.0, 850.0, 13.5, 120.0]

مهلاً — لقد غيّر ذلك سعر كلا طلبي فرع الشرق، القلم والزجاجة! ولم تكن الزجاجة مسعرة بالخطأ. لقد نفذ الكود ما طُلب منه بدقة؛ ولكن السؤال المكتوب في الكود كان غير دقيق. وهنا تتجلى أهمية الخطوة 1 من خطتنا: "الأقلام المباعة في فرع الشرق" تتكون من شرطين، بينما كتبنا شرطاً واحداً فقط. لنبدأ من جديد بنسخة نظيفة ونكتب كلا الشرطين:

python
import pandas as pd

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

fix = (orders["branch"] == "east") & (orders["product"] == "pen")
print(fix.sum())

orders.loc[fix, "price"] = 13.5
print(orders.loc[orders["branch"] == "east", ["product", "price"]])
text
1
  product  price
3  bottle  120.0
6     pen   13.5

نرى هنا عادتين برمجيتين ممتازتين: إعطاء القناع اسماً واضحاً (fix) لأنه يُستخدم في عملية هامة؛ وقبل التعديل، التحقق عبر fix.sum() من عدد الصفوف التي ستتغير — صف واحد فقط، وهو ما توقعناه تماماً. احرص دائماً على عد الصفوف قبل الكتابة عليها.

للقراءة، استخدام df[mask]["col"] سليم تماماً. أما للكتابة، فاستخدم دائماً df.loc[mask, "col"] = value. الصيغة المتسلسلة لا تُطلق استثناءً يوقف البرنامج، ولكن التعديل يختفي في صمت.

حفظ الجدول المصفى للعمل عليه منفصلاً أمر ممتاز

ماذا عن هذا الكود؟

python
import pandas as pd

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

east = orders[orders["branch"] == "east"]
east["price"] = 0.0

print(east["price"].tolist())
print(orders["price"].tolist())
text
[0.0, 0.0]
[15.0, 60.0, 850.0, 120.0, 8.0, 850.0, 15.0, 120.0]

لا يظهر أي تحذير، وهذا سليم تماماً. لأنك هنا أردت عمداً إنشاء جدول مستقل: أطلقت عليه اسم east، وتعديله يغير جدول east فقط. ويظل جدول orders دون أي مساس. تجعل آلية النسخ عند الكتابة (Copy-on-Write) هذا السلوك متوقعاً وموثوقاً — فالجدول الذي تحصل عليه عبر التصفية يكون دائماً مستقلاً عن الأصل. وتحدث المشكلة فقط في الصيغة المتسلسلة حيث يكون الجدول المؤقت مجهول الاسم ويختفي التعديل معه.


مثال شامل متكامل

طلب المدير تقريراً ملخصاً. report.py:

python
import pandas as pd

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

# Check what arrived before asking it anything.
print("Rows, columns:", orders.shape)
print("Branches:", list(orders["branch"].unique()))
print()

# 1. The original question: north-branch orders of more than three items.
north_big = orders.loc[
    (orders["branch"] == "north") & (orders["quantity"] > 3),
    ["order_id", "product", "quantity"],
]
print("North orders over 3 items:")
print(north_big.reset_index(drop=True))
print()

# 2. Counting with masks: no new table needed.
accessories = orders["category"] == "accessories"
print("Accessory orders   :", accessories.sum())
print("Share of all orders:", accessories.mean())
print("Accessory items    :", orders.loc[accessories, "quantity"].sum())
print()

# 3. Outside north, cheap items (under 100).
cheap_outside = orders[
    (orders["branch"] != "north") & (orders["price"] < 100)
]
print("Cheap orders outside north:", cheap_outside["order_id"].tolist())

# 4. A branch we do not have: say so instead of printing an empty table.
west = orders[orders["branch"] == "west"]
if west.empty:
    print("No orders from the west branch.")
text
Rows, columns: (8, 7)
Branches: ['north', 'south', 'east']

North orders over 3 items:
   order_id product  quantity
0      1001     pen        12
1      1005  eraser        30
2      1008  bottle         4

Accessory orders   : 4
Share of all orders: 0.5
Accessory items    : 14

Cheap orders outside north: [1002, 1007]
No orders from the west branch.

لماذا كُتب الكود بهذه الطريقة:

  • الفحص أولاً ثم التصفية. طُبعت الأبعاد shape وقائمة الفروع قبل تطبيق أي فلتر. فلو كان الملف قد وصل بكلمة North تبدأ بحرف كبير، لظهر ذلك في سطر الفروع قبل أن يطبع التقرير جدولاً فارغاً.
  • استخدام .loc[mask, columns] للسؤال الأساسي. تم تحديد الصفوف والأعمدة المطلوبة في مكان واحد، واستُخدمت reset_index(drop=True) لإعادة ترقيم النتيجة لأن هذا هو التقرير النهائي.
  • قناع مسمى، استُخدم ثلاث مرات. تم حساب القناع accessories مرة واحدة ثم استُخدم للعد (sum)، وحساب النسبة (mean)، واختيار عمود للحساب. تسمية القناع تجنبك تكرار كتابة الشرط ثلاث مرات وخطر الخطأ فيه.
  • إحاطة كل شرط بأقواس مستقلة، حتى في الشروط القصيرة، لتجنب أخطاء الأسبقية عند إضافة شروط لاحقة.
  • التعامل مع الحالة الفارغة بوضوح. عدم وجود صفوف إجابة مقبولة، وأوضحها التقرير بنص صريح.

عندما تفشل الشيفرة

ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all(). استخدمت and أو or أو not بين قناعين — أو استخدمت if على قناع كامل. تتطلب كلمات بايثون قيمة واحدة True/False، بينما يحتوي القناع على قيمة لكل صف. داخل الفلتر، اكتب & و | و ~. وداخل جملة if، وضح ما تقصده بدقة: استخدم if mask.any(): لتعني "هل يوجد صف واحد على الأقل صحيح؟"، أو if mask.all(): لتعني "هل كل الصفوف صحيحة؟".

TypeError: Cannot perform 'rand_' with a dtyped [float64] array and scalar of type [bool] (أو 'ror_' أو [int64]) نسيت وضع الأقواس حول شرط مجاور لـ & أو |. قام بايثون بتطبيق & قبل >، فحاول تنفيذ عملية bitwise and بين رقم وعمود. ضع كل شرط في أقواسه الخاصة: (a > 3) & (b < 100).

الفلتر يُرجع جدولاً فارغاً دون أي رسالة خطأ القيمة التي تبحث عنها غير موجودة في العمود بالصيغة المكتوبة تماماً. اطبع df["col"].unique() — أو الأفضل من ذلك list(df["col"].unique()) التي تعرض القيم داخل علامات اقتباس، فتظهر أي مسافة زائدة مثل 'north '. نظف البيانات عبر df["col"].str.strip()، أو استخدم المطابقة المرنة عبر .str.contains(..., case=False).

ظهور KeyError: 1 (أو أي رقم آخر) فوراً بعد التصفية استخدمت .loc[n] على الجدول المصفى مفترضاً أن تسمياته أصبحت 0, 1, 2…. تذكر أن الجدول المصفى يحتفظ بتسمياته الأصلية. استخدم .iloc[n] للوصول إلى "الصف ذي الترتيب n"، أو نفذ reset_index(drop=True) أولاً إذا لم تعد بحاجة للتسميات القديمة.

ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. كتبت df[mask]["col"] = value. هذا الكود لا يغير df أبداً في بانداس 3 — يخبرك التحذير بأن هذا السطر لم يفعل أي شيء. اكتب بدلاً منه: df.loc[mask, "col"] = value.

UserWarning: Boolean Series key will be reindexed to match DataFrame index. تم إنشاء القناع من جدول مختلف عن الجدول الذي تقوم بتصفيته — مثل استخدام قناع مأخوذ من جدول orders الكامل لتصفية جدول north المصفى مسبقاً. يقوم بانداس بمطابقة الصفوف حسب التسميات ويستبعد التسميات التي لا يجدها بصمت:

python
import pandas as pd

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

north = orders[orders["branch"] == "north"]
big = orders["quantity"] > 5

print(north[big])
text
order_id        date branch product    category  quantity  price
0      1001  2024-03-01  north     pen  stationery        12   15.0
4      1005  2024-03-03  north  eraser  stationery        30    8.0
UserWarning: Boolean Series key will be reindexed to match DataFrame index.

(يظهر هذا التحذير في الطرفية فوق الجدول). تصادف هنا أن الإجابة صحيحة، ولكن فقط بفضل تطابق التسميات. ابنِ القناع دائماً من الجدول نفسه الذي تصفيه: north[north["quantity"] > 5].