الفصل 09

إضافة الأعمدة — استخراج الأرقام غير الموجودة في الملف

إنشاء أعمدة جديدة من الأعمدة الحالية: العمليات الحسابية على الأعمدة بالكامل ودور الفهرس في مطابقة القيم، ودوال assign و np.where و np.select و map و pd.cut و .str و .dt، واستخدام transform للمقارنة مع المجموعة، و apply كملاذ أخير.

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

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

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

فتحت الملف orders.csv. يحتوي الملف على عمود quantity (الكمية) و price (السعر)، وعمود branch (أي الفروع الثلاثة) و date (التاريخ). لا يوجد أي شيء مما طلبته موجوداً بشكل مباشر داخل الملف. بل يجب استخراج وبناء كل معلومة من هذه المعلومات من واقع الأعمدة الموجودة بالفعل.

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

python
import pandas as pd

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

revenues = []
for i in range(len(orders)):
    revenues.append(orders.loc[i, "quantity"] * orders.loc[i, "price"])
orders["revenue"] = revenues

print(orders[["product", "quantity", "price", "revenue"]])
text
product  quantity  price  revenue
0       pen        12   15.0    180.0
1  notebook         5   60.0    300.0
2       bag         2  850.0   1700.0
3    bottle         7  120.0    840.0
4    eraser        30    8.0    240.0
5       bag         1  850.0    850.0
6       pen        20   15.0    300.0
7    bottle         4  120.0    480.0

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

السبب الأول أنه بطيء: ينفّذ بايثون جسم حلقة التكرار مرة واحدة لكل صف، وبالتالي فإن ملفاً يضم مليون صف يعني استدعاء مفسر بايثون مليون مرة. والسبب الثاني أنه طويل ومفرط في التعقيد: أربعة أسطر لمجرد عملية ضرب واحدة، وقائمة الطلبات تضم خمسة أعمدة أخرى. والسبب الثالث أنه هشّ وعرضة للانهيار بطريقة يصعب كشفها بالعين المجردة؛ حيث تعد الحلقة التكرارية المواضع 0, 1, 2, … ثم تبحث عنها كـ تسميات فهرس (labels) باستخدام .loc. وهذا لا ينجح إلا إذا تطابق رقم الموضع مع تسمية الفهرس مصادفةً. ولكن لو قمت بتصفية الجدول أولاً — كأن تختار طلبات الفرع الشمالي فقط كما فعلنا في الفصل الخامس — فلن يتطابقا بعد ذلك أبداً:

python
north = orders[orders["branch"] == "north"]
print(north.index.tolist())

revenues = []
for i in range(len(north)):
    revenues.append(north.loc[i, "quantity"] * north.loc[i, "price"])
text
[0, 2, 4, 7]
KeyError: 1

تحتفظ صفوف north بتسميات فهرسها الأصلية 0, 2, 4, 7. وتبحث حلقة التكرار عن التسمية 1، وهي طلب يخص الفرع الجنوبي وغير موجود في north، مما يؤدي إلى انهيار البرنامج بالكامل. والنسخ الأسوأ من هذا الخطأ لا تنهار على الإطلاق — بل تقرأ بهدوء صفاً خاطئاً وتستمر في الحساب.

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

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

  • إنشاء عمود جديد بإجراء العمليات الحسابية على أعمدة كاملة، وشرح سبب عدم الحاجة إلى أي حلقة تكرار
  • توقع كيفية قيام بانداس بمحاذاة القيم بحسب الفهرس عند إسناد كائن Series، وتجنب قيم NaN الناتجة عن عدم التطابق
  • إضافة أعمدة دون تعديل الجدول الأصلي باستخدام دالة assign()
  • الاختيار بين قيمتين أو أكثر لكل صف باستخدام np.where و np.select
  • مطابقة واستبدال القيم باستخدام .map()، وتقسيم الأرقام إلى فئات باستخدام pd.cut، واستخراج أجزاء من النصوص والتواريخ باستخدام .str و .dt
  • مقارنة كل صف بمجموعته الخاصة باستخدام groupby(...).transform(...)
  • معرفة الحالات التي يُبرر فيها استخدام apply(axis=1)، ولماذا تعتبر دوماً الملاذ الأخير
  • شرح ما تعنيه ميزة النسخ عند الكتابة (Copy-on-Write) في بانداس 3 عند إضافة أعمدة إلى جدول مصفّى

المتطلبات المسبقة: التجميع والإحصاء (grouping and aggregation).


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

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

تستخدم بعض الأمثلة أيضاً مكتبة NumPy، والتي تم تثبيتها تلقائياً مع بانداس — يكفيك كتابة import numpy as np.


قبل كتابة الكود: خطة العمل

كل عمود جديد هو بمثابة برنامج صغير. خمس دقائق من التخطيط المسبق توفر عليك ساعات تقضيها لاحقاً في التساؤل عن مصدر ظهور قيم NaN.

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

  • revenue — كم من المال جلبه هذا الطلب. يُحسب من quantity × price.
  • size — "big" إذا كان الإيراد 500 على الأقل، وإلا فهو "small". يُحسب من revenue.
  • manager — من يدير الفرع الذي ورد منه الطلب. يُحسب من branch عبر جدول مطابقة.
  • weekday — في أي يوم من أيام الأسبوع تم تسجيل الطلب. يُحسب من date.
  • branch_share — حصة إيراد هذا الطلب من إجمالي مبيعات فرعه. يُحسب من revenue و branch.

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

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 ونوع price هو float64، لذا فإن ضربهما سينفذ عملية حسابية حقيقية. أما date فهو str — أي نص يشبه التاريخ فقط. والنصوص لا تحتوي على أيام أسبوع، لذا ستحتاج إلى تحويله قبل أن تتمكن من استخدام .dt عليه. تدوين هذه الملاحظة الآن يمنع حدوث خطأ AttributeError لاحقاً.

3. ارسم شكلاً تقريبياً للمخرجات، واحسب صفاً واحداً يدوياً. قبل تشغيل أي كود، حدد ما يجب أن يصبح عليه صفان من الصفوف — أحدهما عادي والآخر يمثل حالة خاصة:

text
product  quantity  price  revenue  size
pen            12   15.0    180.0  small
bag             2  850.0   1700.0  big

12 × 15.0 = 180.0، و 180 أقل من 500، إذن فالقيمة هي small. يمثل الصف المحسوب يدوياً اختباراً عملياً: بعد تشغيل الكود، إذا لم يُظهر الصف 0 القيمة 180.0 و small، فإن الكود خاطئ مهما بدا شكله منطقياً.

4. اختر الأداة التي تناسب طبيعة القاعدة الحسابية. يقع كل عمود جديد تقريباً ضمن إحدى الحالات التالية:

  1. عمليات حسابية على أعمدة أو مع رقم ثابت → استخدام + - * / على الأعمدة بالكامل. مثال: quantity * price.
  2. الاختيار بين قيمتين بناءً على شرط → استخدام np.where. مثال: "big" أو "small".
  3. الاختيار من بين عدة قيم بناءً على شروط متتالية → استخدام np.select. مثال: "bulk" أو "normal" أو "few".
  4. البحث عن قيمة في جدول ثابت أو قاموس → استخدام .map(dict). مثال: من branch إلى manager.
  5. تحديد النطاق الذي يقع فيه الرقم → استخدام pd.cut. مثال: تصنيف quantity إلى فئات.
  6. استخراج جزء من نص → دوال الملحق .str. مثال: الأحرف الأولى من الفئة.
  7. استخراج جزء من تاريخ → استخدام pd.to_datetime أولاً، ثم الملحق .dt. مثال: اسم يوم الأسبوع.
  8. مقارنة الصف بمجموعته الخاصة → استخدام groupby(...).transform(...). مثال: حصة الطلب من إجمالي الفرع.
  9. ما عدا ذلك → استخدام apply(axis=1). مثال: قاعدة شديدة التعقيد والشذوذ لا تناسب بقية الأدوات.

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

5. حدد ما ستتحقق منه بعد الانتهاء. ثلاث اختبارات ثابتة في كل مرة: لم يتغير عدد الصفوف؛ والعمود الجديد له نوع البيانات المتوقع؛ ونتيجة isna().sum() عليه تساوي صفراً (أو تساوي بدقة عدد القيم المفقودة المتوقعة التي تعرف سببها). بالإضافة إلى مطابقة الصف المحسوب يدوياً.


العمليات الحسابية على الأعمدة بالكامل

إليك كيفية حساب عمود الإيراد (revenue) بأسلوب بانداس النموذجي:

python
import pandas as pd

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

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

print(orders[["product", "quantity", "price", "revenue"]])
text
product  quantity  price  revenue
0       pen        12   15.0    180.0
1  notebook         5   60.0    300.0
2       bag         2  850.0   1700.0
3    bottle         7  120.0    840.0
4    eraser        30    8.0    240.0
5       bag         1  850.0    850.0
6       pen        20   15.0    300.0
7    bottle         4  120.0    480.0

يُظهر الصف 0 القيمة 180.0 — وهي نفس القيمة المحسوبة يدوياً. لا حلقات تكرار، ولا عدادات لمواقع الفهرس.

لماذا ينجح هذا؟ التعبير orders["quantity"] هو عبارة عن Series من ثمانية أرقام؛ وكذلك orders["price"]. عندما تضع علامة الضرب * بين كائني Series، يقرن بانداس قيمهما بحسب تسمية الفهرس (index label) — التسمية 0 مع التسمية 0، والتسمية 1 مع التسمية 1 — ويضرب كل زوج معاً. حلقة التكرار تحدث بالفعل، ولكن داخل كود بانداس السريع المترجم بلغة C وليس في بايثون. ولهذا السبب تتميز هذه الطريقة بالسرعة الفائقة، ولن تقرأ صفاً خاطئاً أبداً لعدم وجود عداد يدوي يمكن أن يختل توازنه.

ثم يقوم التعبير orders["revenue"] = ... بأحد أمرين بناءً على اسم العمود:

  • إذا كان revenue غير موجود، فإنه يضيف عموداً جديداً في أقصى اليمين؛
  • إذا كان موجوداً بالفعل، فإنه يستبدله بهدوء ودون أي تحذير.

يتبع نوع الناتج قواعد الحساب العادية: ضرب int64 × float64 يعطي float64، ولهذا السبب يظهر في revenue الرقم 180.0 بدلاً من 180.

يمكن أيضاً دمج العمود مع رقم ثابت واحد. يتم استخدام هذا الرقم لكل صف على حدة — ويسمي بانداس ذلك البث (broadcasting):

python
orders["price_with_vat"] = (orders["price"] * 1.15).round(2)

print(orders[["product", "price", "price_with_vat"]].head(4))
text
product  price  price_with_vat
0       pen   15.0           17.25
1  notebook   60.0           69.00
2       bag  850.0          977.50
3    bottle  120.0          138.00

الأقواس مهمة هنا: تطبق الدالة .round(2) على ناتج عملية الضرب بالكامل، وهو كائن Series بدوره. كل خطوة هنا تأخذ عموداً وتعيد عموداً، لذا يمكنك مواصلة ربط العمليات ببعضها.


الفهرس هو ما يحدد أين توضع كل قيمة

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

لنفترض أنك تريد إنشاء عمود يحتوي على الكمية لطلبات الفرع الشمالي فقط:

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

orders["north_qty"] = north["quantity"]

print(orders[["branch", "quantity", "north_qty"]])
text
branch  quantity  north_qty
0  north        12       12.0
1  south         5        NaN
2  north         2        2.0
3   east         7        NaN
4  north        30       30.0
5  south         1        NaN
6   east        20        NaN
7  north         4        4.0

يحتوي north على التسميات 0, 2, 4, 7. وضع بانداس كل قيمة مقابل الصف الذي يحمل نفس التسمية، وحصلت بقية الصفوف على NaN — أي "لا توجد قيمة لهذه التسمية". ولأن NaN يعتبر عدداً عشرياً، تحول نوع العمود إلى float64 على الرغم من أن الكميات أعداد صحيحة. قد يكون هذا ما تريده تماماً، أو قد يكون بداية لرحلة بحث طويلة عن بيانات مفقودة أنشأتها بنفسك. والقاعدة الذهبية لتجنب ذلك هي: الإسناد يطابق التسميات، وليس المواضع.

هذه القاعدة نفسها تحميك من الأخطاء. فعملية الترتيب والفرز (الفصل السابع) تعيد ترتيب عناصر الـ Series ولكنها تحتفظ برابط كل قيمة مع تسمية فهرسها، ولذلك فإن إعادة إسناد Series تم فرزها يعيد كل قيمة إلى صفها الأصلي:

python
by_revenue = orders["revenue"].sort_values()
print(by_revenue.head(3))

orders["revenue_copy"] = by_revenue
print(orders[["product", "revenue", "revenue_copy"]].head(3))
print((orders["revenue_copy"] == orders["revenue"]).all())
text
0    180.0
4    240.0
6    300.0
Name: revenue, dtype: float64
    product  revenue  revenue_copy
0       pen    180.0         180.0
1  notebook    300.0         300.0
2       bag   1700.0        1700.0
True

تسير عناصر الـ Series بعد فرزها بترتيب التسميات 0، 4، 6 — القيمة الثانية 240.0 تخص الصف 4، وليس الصف 1. ومع ذلك، فإن revenue_copy مطابق تماماً لـ revenue صفاً بصف، وهو ما يؤكده السطر الأخير لجميع الصفوف الثمانية. اعتمد بانداس على التسميات وتجاهل الترتيب. ولو كان قد اعتمد على الموضع، لكان الصف 1 (الدفتر، 300.0) قد استلم القيمة 240.0 بطريق الخطأ.

أما القائمة (list) العادية في بايثون فلا تحتوي على تسميات، لذا يتم إسنادها بحسب الموضع — وحينها يجب أن يتطابق طولها تماماً مع عدد الصفوف:

python
try:
    orders["rating"] = [5, 4, 3]
except ValueError as error:
    print("ValueError:", error)
text
ValueError: Length of values (3) does not match length of index (8)

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

لم نعد بحاجة إلى الأعمدة التجريبية. تعيد الدالة drop(columns=...) الجدول بعد إزالتها:

python
orders = orders.drop(columns=["north_qty", "revenue_copy", "price_with_vat"])
print(list(orders.columns))
text
['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']

دالة assign(): إضافة أعمدة دون تعديل الجدول الأصلي

يؤدي التعبير orders["revenue"] = ... إلى تعديل جدول orders نفسه مباشرة. غالباً ما يكون هذا هو المطلوب. ولكن أحياناً ترغب في تجربة بعض الأعمدة، أو إنشاء جدول مخصص لتقرير ما، مع الحفاظ على الجدول الأصلي كما قُرئ من الملف تماماً. تعيد دالة assign() كائن DataFrame جديداً بالأعمدة المضافة وتترك الجدول الأصلي كما هو دون أي تغيير:

python
import numpy as np
import pandas as pd

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

report = orders.assign(
    revenue=orders["quantity"] * orders["price"],
    vat=lambda d: (d["revenue"] * 0.15).round(2),
)

print(report[["product", "revenue", "vat"]].head(3))
print("revenue" in orders.columns)
text
product  revenue    vat
0       pen    180.0   27.0
1  notebook    300.0   45.0
2       bag   1700.0  255.0
False

هناك ثلاث ملاحظات مهمة هنا:

أسماء الوسائط تصبح هي أسماء الأعمدة — كتابة revenue=... تنشئ عموداً باسم revenue. ولهذا السبب يجب أن تكون أسماء الأعمدة متوافقة مع قواعد تسمية متغيرات بايثون عند استخدام assign().

كُتب عمود vat باستخدام دالة lambda. وكان لا بد من ذلك: لأن vat يحتاج إلى revenue، و revenue غير موجود في orders الأصلي — بل موجود فقط في الجدول الجديد الذي يتم بناؤه الآن. تستقبل الدالة lambda d: ... هذا الجدول الجديد كمعامل باسم d، وبالتالي يكون d["revenue"] متاحاً بداخلها. تتم إضافة الأعمدة داخل assign() من الأعلى إلى الأسفل، وترى كل دالة lambda الأعمدة التي أُنشئت قبلها.

يطبع السطر الأخير False: لا يحتوي orders على عمود revenue. لم يتم تغيير أي شيء في الجدول الأصلي.


np.where: الاختيار بين قيمتين

قاعدة "إذا كان الإيراد 500 على الأقل فضع big، وإلا فضع small" هي عبارة عن شرط if/else يطبق على كل صف. كتابة if عادية تعني العودة إلى حلقة التكرار، لأن if تتطلب قيمة بوليانية مفردة True أو False، بينما مقارنة عمود كامل تعطي ثماني قيم بوليانية (وهو سبب الخطأ "truth value of a Series is ambiguous" الذي رأيناه في الفصل الخامس).

دالة where من مكتبة NumPy هي عبارة عن شرط if/else يعمل على العمود بالكامل: أينما (where) كان الشرط True، خذ القيمة الأولى؛ وفيما عدا ذلك خذ القيمة الثانية.

python
report["size"] = np.where(report["revenue"] >= 500, "big", "small")

print(report[["product", "revenue", "size"]])
text
product  revenue   size
0       pen    180.0  small
1  notebook    300.0  small
2       bag   1700.0    big
3    bottle    840.0    big
4    eraser    240.0  small
5       bag    850.0    big
6       pen    300.0  small
7    bottle    480.0  small

الصف 0 أصبح small، والصف 2 أصبح big — وكلاهما يطابق الخطة المحسوبة مسبقاً. الشرط هنا هو قناع بولياني من نفس النوع الذي بنيناه في الفصل الخامس؛ وتقوم np.where بتحويل كل قيمة True/False إلى القيمة المقابلة. انتبه للحدود الفاصلة: الشرط >= 500 يعني أن الطلب الذي قيمته 500 بالضبط سيصنف كـ big. احرص على اتخاذ هذه القرارات عن قصد وتخطيط.

أكثر من قيمتين: np.select

لتقسيم الطلبات إلى ثلاث فئات بناءً على الكمية — 20 أو أكثر تعتبر "bulk"، و 5 أو أكثر تعتبر "normal"، وأي قيمة أخرى تعتبر "few". تأخذ دالة np.select قائمة بالشروط وقائمة بالقيم المقابلة لها، بالإضافة إلى قيمة افتراضية default للصفوف التي لا ينطبق عليها أي شرط:

python
conditions = [
    report["quantity"] >= 20,
    report["quantity"] >= 5,
]
labels = ["bulk", "normal"]

report["tier"] = np.select(conditions, labels, default="few")

print(report[["product", "quantity", "tier"]])
text
product  quantity    tier
0       pen        12  normal
1  notebook         5  normal
2       bag         2     few
3    bottle         7  normal
4    eraser        30    bulk
5       bag         1     few
6       pen        20    bulk
7    bottle         4     few

أول شرط يتحقق ويصبح True هو الذي يفوز. ولهذا السبب نضع الشرط الأكثر صرامة في البداية. اعكس الترتيب وشاهد ما سيحدث لطلب الممحاة ذي الكمية 30:

python
wrong = np.select(
    [report["quantity"] >= 5, report["quantity"] >= 20],
    ["normal", "bulk"],
    default="few",
)
print(wrong)
text
['normal' 'normal' 'few' 'normal' 'normal' 'few' 'normal' 'few']

أصبح كل طلب إما normal أو few؛ ولم يحصل أي طلب على bulk، لأن 30 و 20 كلاهما يحقق أيضاً الشرط >= 5 الذي تم فحصه أولاً. لم يظهر أي خطأ، بل نشأ عمود غير صحيح — ولهذا يجب أن تتضمن الصفوف المحسوبة يدوياً في الخطة قيمة قصوى أو استثنائية.


الدالة .map(): البحث عن القيم واستبدالها

اسم المدير ليس رقماً يتم حسابه من معادلة. بل هو حقيقة معلومة لديك: تدير آشا الفرع الشمالي، وعمر الفرع الجنوبي، ولينا الفرع الشرقي. عندما تكون القاعدة "ابحث عن القيمة المقابلة"، اكتب جدول البحث كقاموس بايثون (dictionary) ومرر العمود عبره باستخدام .map():

python
manager_of = {
    "north": "Asha",
    "south": "Omar",
    "east": "Lina",
}

report["manager"] = report["branch"].map(manager_of)

print(report[["branch", "manager"]])
print(report["manager"].isna().sum())
text
branch manager
0  north    Asha
1  south    Omar
2  north    Asha
3   east    Lina
4  north    Asha
5  south    Omar
6   east    Lina
7  north    Asha
0

تم استبدال كل فرع بقيمته المقابلة في القاموس — وهو اسم المدير. السطر الثاني الذي يطبع عدد القيم المفقودة هو الاختبار الذي وضعناه في الخطة، وهو بالغ الأهمية بسبب تصرف .map() عندما لا تجد المفتاح المطلوب. تخيل لو أن القاموس كُتب قبل افتتاح الفرع الشرقي:

python
old_managers = {"north": "Asha", "south": "Omar"}

print(report["branch"].map(old_managers).isna().sum())
text
2

لم يظهر أي خطأ؛ بل حصل طلبا الفرع الشرقي ببساطة على NaN. في جدول من ثمانية صفوف ستكتشف ذلك بسهولة؛ ولكن في جدول يضم ثمانين ألف صف لن تلاحظه بالعين، وستتجاهل أي عملية groupby("manager") لاحقة تلك الطلبات بهدوء تام (قاعدة الفصل الثامن: يتم إسقاط المفاتيح NaN). احرص دائماً على كتابة isna().sum() بعد كل استخدام لـ .map().


دالة pd.cut: تحديد النطاق الذي يقع فيه الرقم

يريد المتجر تقسيم الطلبات إلى نطاقات بحسب الكمية: حتى 5 قطع تصنف كـ small، ومن 6 إلى 15 تصنف كـ medium، وأكثر من 15 تصنف كـ large. يمكنك كتابة ذلك باستخدام np.select، ولكن لمسألة "في أي نطاق يقع الرقم" أداة مخصصة بالغة الكفاءة. تأخذ دالة pd.cut حدود النطاقات (bins) وتسمية لكل نطاق:

python
report["band"] = pd.cut(
    report["quantity"],
    bins=[0, 5, 15, np.inf],
    labels=["small", "medium", "large"],
)

print(report[["product", "quantity", "band"]])
text
product  quantity    band
0       pen        12  medium
1  notebook         5   small
2       bag         2   small
3    bottle         7  medium
4    eraser        30   large
5       bag         1   small
6       pen        20   large
7    bottle         4   small

أربعة حدود تصنع ثلاثة نطاقات: (0, 5] و (5, 15] و (15, ∞]. القوس الدائري يعني "غير مشمول"، والقوس المربع يعني "مشمول" — أي أن كل نطاق يشمل حده الأيمن. ولهذا السبب فإن طلب الدفتر ذي الكمية 5 بالضبط يصنف كـ small وليس medium. القيمة np.inf تعني ما لا نهاية، وهي حد علوي لا يمكن لأي كمية تجاوزه.

الناتج له نوع بيانات خاص لم نره من قبل:

python
print(report["band"].dtype)
print(report["band"].cat.categories.tolist())
text
category
['small', 'medium', 'large']

يتذكر العمود الفئوي category أن small < medium < large. وبالتالي فإن ترتيب الجدول بحسب عمود band يعتمد هذا الترتيب المنطقي بدلاً من الترتيب الأبجدي — وهو ما تحتاجه التقارير عادة.


النصوص والتواريخ: الملحقان .str و .dt

أجزاء النصوص

استخدمنا في الفصل الخامس الملحق .str.contains للتصفية. والملحق .str نفسه يستخدم أيضاً في إنشاء الأعمدة: فكل دالة نصوص تعرفها في بايثون، يمكن تطبيقها على جميع قيم العمود دفعة واحدة. لنفترض أن كل طلب يحتاج إلى كود تعريفي مثل STA-1001 — يضم أول ثلاثة أحرف من الفئة بأحرف كبيرة، متبوعة بشرطة، ثم رقم الطلب:

python
report["code"] = (
    report["category"].str[:3].str.upper()
    + "-"
    + report["order_id"].astype(str)
)

print(report[["category", "order_id", "code"]].head(3))
text
category  order_id      code
0   stationery      1001  STA-1001
1   stationery      1002  STA-1002
2  accessories      1003  ACC-1003

تقوم .str[:3] باقتطاع أول 3 أحرف من كل قيمة، وتقوم .str.upper() بتحويل كل قيمة إلى أحرف كبيرة، وتدمج علامة + بين الأعمدة النصية صفاً بصف. ولأن order_id رقم ولا يمكن دمج الأرقام مع النصوص، فإن .astype(str) تحوله إلى نص أولاً.

أجزاء التواريخ

أوضحت خطتنا مسبقاً أن نوع date هو str. قم بتحويله باستخدام pd.to_datetime، وسينفتح لك الملحق .dt بكل خصائصه:

python
report["date"] = pd.to_datetime(report["date"])
print(report["date"].dtype)

report["weekday"] = report["date"].dt.day_name()

print(report[["date", "weekday"]].head(4))
text
datetime64[us]
        date   weekday
0 2024-03-01    Friday
1 2024-03-01    Friday
2 2024-03-02  Saturday
3 2024-03-02  Saturday

أصبح العمود الآن من نوع datetime64 — تاريخ حقيقي، وليس نصاً. (الرمز [us] يعبر عن الدقة التي اختارها بانداس 3، ويمكنك تجاهله). يمنحك التاريخ الحقيقي عبر .dt دوالاً مثل .dt.day_name() و .dt.month و .dt.year و .dt.day وغيرها. وقد تم إجراء التحويل مرة واحدة مع إسناد النتيجة إلى date، بحيث تتعامل جميع الخطوات اللاحقة مع تواريخ حقيقية بدلاً من السلاسل النصية.


دالة transform: مقارنة كل صف بمجموعته

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

python
print(report.groupby("branch")["revenue"].sum())
text
branch
east     1140.0
north    2600.0
south    1150.0
Name: revenue, dtype: float64

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

python
branch_total = report.groupby("branch")["revenue"].transform("sum")
print(branch_total)
text
0    2600.0
1    1150.0
2    2600.0
3    1140.0
4    2600.0
5    1150.0
6    1140.0
7    2600.0
Name: revenue, dtype: float64

نفس التجميع، ونفس الدالة "sum"، ولكن النتيجة تتكون من ثماني قيم مفهرسة بنفس تسميات الجدول الأصلي — حيث يحمل كل صف في الفرع الشمالي القيمة 2600، وكل صف في الفرع الجنوبي القيمة 1150. والآن تصبح العملية الحسابية عادية ومباشرة:

python
report["branch_share"] = (report["revenue"] / branch_total).round(3)

print(report[["branch", "product", "revenue", "branch_share"]])
text
branch   product  revenue  branch_share
0  north       pen    180.0         0.069
1  south  notebook    300.0         0.261
2  north       bag   1700.0         0.654
3   east    bottle    840.0         0.737
4  north    eraser    240.0         0.092
5  south       bag    850.0         0.739
6   east       pen    300.0         0.263
7  north    bottle    480.0         0.185

تحقق منها: طلب الحقائب في الفرع الشمالي هو 1700 / 2600 = 0.654. ويجب أن يكون مجموع الحصص داخل كل فرع مساوياً للرقم 1:

python
print(report.groupby("branch")["branch_share"].sum())
text
branch
east     1.0
north    1.0
south    1.0
Name: branch_share, dtype: float64

القاعدة التي يجب حفظها: دوال agg/sum تقلص المجموعة إلى صف واحد؛ بينما تعيد transform قيمة واحدة لكل صف من صفوف الجدول الأصلي. استخدم الأولى لجداول التلخيص، والثانية لإنشاء الأعمدة الجديدة.


apply(axis=1): الملاذ الأخير

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

python
def delivery_fee(row):
    if row["branch"] == "north":
        return 0 if row["revenue"] >= 500 else 60
    return 0 if row["revenue"] > 1000 else 120


report["fee"] = report.apply(delivery_fee, axis=1)

print(report[["branch", "revenue", "fee"]])
text
branch  revenue  fee
0  north    180.0   60
1  south    300.0  120
2  north   1700.0    0
3   east    840.0  120
4  north    240.0   60
5  south    850.0  120
6   east    300.0  120
7  north    480.0   60

المعامل axis=1 يعني "مرر للدالة صفاً واحداً في كل مرة". وداخل الدالة، يمثل المتغير row كائناً من نوع Series مفهرساً بأسماء الأعمدة، لذا تعمل العبارة row["branch"] بنجاح.

هذا الكود سهل القراءة، وتلك هي ميزته الوحيدة. ولكن عيبه أنه يعيدنا مباشرة إلى حلقة التكرار البطيئة التي بدأنا بها الفصل: حيث ينشئ بانداس كائن Series لكل صف ويستدعي دالتك في بايثون مرة لكل صف. في ثمانية صفوف لن تشعر بشيء؛ ولكن في مليون صف، سيكون هذا هو الفارق بين طرفة عين واستراحة لتناول القهوة.

لذلك، قبل أن تلجأ إلى apply، حاول صياغة القاعدة باستخدام أدوات الأعمدة المتجهية. هذه القاعدة في الحقيقة هي "ثلاث حالات يتم فحصها بالترتيب" — أي أنها مناسبة تماماً لـ np.select:

python
in_north = report["branch"] == "north"

fee_fast = np.select(
    [in_north & (report["revenue"] >= 500), in_north, report["revenue"] > 1000],
    [0, 60, 0],
    default=120,
)

print((fee_fast == report["fee"]).all())
text
True

نفس النتيجة تماماً، دون استدعاء دوال بايثون لكل صف. احتفظ بـ apply(axis=1) للحالات التي يستحيل فيها كتابة الكود بهذه الطريقة — مثل استدعاء مكتبة خارجية، أو تحليل نصوص معقدة متعددة المراحل — واعتبر الحاجة إليها إشارة لإعادة التفكير مرة أخرى.


إضافة عمود إلى جدول مصفّى: ميزة Copy-on-Write

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

python
import pandas as pd

orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]

north = orders[orders["branch"] == "north"]
north["revenue_k"] = north["revenue"] / 1000

print(north[["product", "revenue", "revenue_k"]])
print(list(orders.columns))
text
product  revenue  revenue_k
0     pen    180.0       0.18
2     bag   1700.0       1.70
4  eraser    240.0       0.24
7  bottle    480.0       0.48
['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']

لم يظهر أي تحذير، ولم يُضَف عمود revenue_k إلى جدول orders. يستخدم بانداس 3 آلية النسخ عند الكتابة (Copy-on-Write): حيث يتصرف كل ناتج تصفية أو اختيار كجدول مستقل بذاته. وتعديل north لن يؤثر إطلاقاً على orders.

إذا قرأت أكواداً قديمة أو إجابات سابقة على الإنترنت، فسترى هذا النمط متبوعاً بتحذير شهير اسمه SettingWithCopyWarning ("A value is trying to be set on a copy of a slice from a DataFrame")، مع نصيحة بكتابة orders[...].copy(). كان ذلك التحذير يظهر لأن الإصدارات القديمة من بانداس كانت تشارك البيانات في الذاكرة بين الجدولين أحياناً، ولم يكن أحد يستطيع التنبؤ بذلك بسهولة. أزال بانداس 3 كل هذا اللبس، واختفى معه التحذير أيضاً. كتابة .copy() الآن لم تعد ضرورية، وإن كانت لا تضر.

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

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

print(orders["revenue"].tolist())
text
[180.0, 300.0, 1700.0, 840.0, 240.0, 850.0, 300.0, 480.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

الزوج الأول من الأقواس ينشئ جدولاً جديداً، والزوج الثاني يعدل القيم داخل ذلك الجدول الجديد، ثم يتم التخلص من الجدول الجديد وإهماله. ينبهك بانداس بخطأ ChainedAssignmentError، وتظهر القائمة أن قيم revenue لم تتغير — إذ تظل طلبات الفرع الشمالي كما هي: 180.0 و 1700.0 و 240.0 و 480.0. (تذهب التحذيرات إلى قناة الأخطاء، لذا قد يظهر التحذير في طرفيتك فوق القائمة بدلاً من أسفلها). ولتعديل الجدول الأصلي، قم بالعملية في خطوة واحدة باستخدام .loc[rows, column]:

python
orders.loc[orders["branch"] == "north", "note"] = "main branch"

print(orders[["branch", "note"]])
text
branch         note
0  north  main branch
1  south          NaN
2  north  main branch
3   east          NaN
4  north  main branch
5  south          NaN
6   east          NaN
7  north  main branch

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


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

يجيب البرنامج enrich.py عن رسالة مالكة المتجر بالكامل متبعاً خطتنا المحددة: قراءة البيانات، الفحص، إضافة الأعمدة، ثم التحقق مجدداً.

python
import numpy as np
import pandas as pd

MANAGER_OF = {
    "north": "Asha",
    "south": "Omar",
    "east": "Lina",
}

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

# 1. Conversions first, so later steps get the right types.
orders["date"] = pd.to_datetime(orders["date"])

# 2. New columns, one rule each.
orders["revenue"] = orders["quantity"] * orders["price"]
orders["size"] = np.where(orders["revenue"] >= 500, "big", "small")
orders["manager"] = orders["branch"].map(MANAGER_OF)
orders["weekday"] = orders["date"].dt.day_name()
branch_total = orders.groupby("branch")["revenue"].transform("sum")
orders["branch_share"] = (orders["revenue"] / branch_total).round(3)

# 3. Checks: same rows, nothing missing, the hand-worked row is right.
new_columns = ["revenue", "size", "manager", "weekday", "branch_share"]
assert len(orders) == rows_before
assert orders[new_columns].isna().sum().sum() == 0
assert orders.loc[0, "revenue"] == 180.0

print(orders[["order_id", "branch", "manager", "weekday", "revenue", "size", "branch_share"]])
print()
print(orders[new_columns].dtypes)
text
order_id branch manager   weekday  revenue   size  branch_share
0      1001  north    Asha    Friday    180.0  small         0.069
1      1002  south    Omar    Friday    300.0  small         0.261
2      1003  north    Asha  Saturday   1700.0    big         0.654
3      1004   east    Lina  Saturday    840.0    big         0.737
4      1005  north    Asha    Sunday    240.0  small         0.092
5      1006  south    Omar    Sunday    850.0    big         0.739
6      1007   east    Lina    Monday    300.0  small         0.263
7      1008  north    Asha    Monday    480.0  small         0.185

revenue         float64
size                str
manager             str
weekday             str
branch_share    float64
dtype: object

أسباب كتابة الكود بهذه الطريقة:

  • التحويل يسبق كل شيء. تم تحويل date إلى تاريخ حقيقي قبل أن يعتمد عليه أي كود، وبذلك نضمن عدم فشل الملحق .dt لاحقاً ولا يحتاج أي شخص لاحق لتذكر التحويل.
  • قاعدة واحدة في كل سطر. كل سطر يجيب عن سطر في الخطة، لذا إذا ظهر رقم غير متوقع فإنك تعرف بدقة السطر البرمجي الوحيد المسؤول عن ذلك.
  • جدول المطابقة معرّف كثابت (constant) في الأعلى. عندما يفتح المتجر فرعاً غربياً، سيكون التعديل في سطر واحد في مكان واضح، وسيتوقف البرنامج فوراً عبر فحص isna إذا نسي أحدهم إضافته.
  • الفحوصات تعتمد على assert بدلاً من الطباعة العادية. إذا تغير عدد الصفوف، أو نسي القاموس أحد الفروع، أو تغيرت قيمة الصف 0 عن 180، يتوقف البرنامج على الفور بدلاً من طباعة تقرير خاطئ يبدو سليماً ظاهرياً.
  • طباعة dtypes في النهاية لأن نوع البيانات جزء لا يتجزأ من صحة النتيجة: يجب أن يكون revenue و branch_share من نوع فلوت، والأعمدة النصية من نوع str.

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

خطأ KeyError: 'Price' خطأ إملائي في اسم العمود في الطرف الأيمن، أو عدم مطابقة حالة الأحرف الكبيرة والصغيرة. اطبع list(orders.columns) وانسخ الاسم منها بدقة. وانتبه للطرف الأيسر أيضاً: كتابة orders["revenu"] = ... لا تُصدر أي خطأ — بل تنشئ عموداً جديداً خاطئ الإملاء بجوار العمود الأصلي. إذا قمت بـ "تحديث" عمود ولم يتغير، فابحث عن عمود شبيه به يحتوي على خطأ إملائي.

خطأ ValueError: Length of values (3) does not match length of index (8) قمت بإسناد قائمة (أو مصفوفة) لا يطابق طولها عدد صفوف الجدول. القوائم العادية لا تحتوي على تسميات للفهرس، لذا لا يستطيع بانداس محاذاتها ويرفض العملية تماماً. قم بإنشاء القيم من واقع أعمدة الجدول نفسه، أو تأكد مسبقاً من أن len(values) == len(df).

ظهور قيم NaN في بعض صفوف العمود الجديد، وتحول نوعه إلى float64 قمت بإسناد كائن Series تتطابق تسميات فهرسه جزئياً فقط مع تسميات الجدول — وغالباً ما يكون مبنياً من جدول مصفّى. الإسناد يطابق التسميات، وليس المواضع. قم ببناء العمود الجديد من الجدول الكامل (باستخدام np.where أو .loc[mask, "col"] = ...) بدلاً من جدول جزئي.

ظهور قيم مكررة في revenue مثل 15.015.015.0…، أو خطأ TypeError: can't multiply sequence by non-int of type 'float' أحد الأعمدة نصي وليس رقمياً. ضرب عدد صحيح int في نص يؤدي إلى تكرار النص — وفق قاعدة بايثون المعروفة 3 * "ab" == "ababab" — ويطبق بانداس هذه القاعدة صفاً بصف دون إصدار أي اعتراض:

python
import pandas as pd
from io import StringIO

RAW = """product,quantity,price
pen,12,15.0
bag,2,"1,200"
"""
bad = pd.read_csv(StringIO(RAW))

print(bad.dtypes)
print(bad["quantity"] * bad["price"])
text
product       str
quantity    int64
price         str
dtype: object
0    15.015.015.015.015.015.015.015.015.015.015.015.0
1                                          1,2001,200
dtype: str

فاصلة الآلاف في "1,200" جعلت عمود price بأكمله من نوع str. ولهذا تنص خطتنا دوماً على فحص dtypes قبل إجراء العمليات الحسابية. عالج المشكلة عند قراءة الملف بتمرير thousands=","، أو حوّل العمود عبر pd.to_numeric:

python
good = pd.read_csv(StringIO(RAW), thousands=",")

print(good.dtypes["price"])
print(good["quantity"] * good["price"])
text
float64
0     180.0
1    2400.0
dtype: float64

خطأ AttributeError: Can only use .dt accessor with datetimelike values لا يزال العمود نصياً. حوله أولاً باستخدام df["date"] = pd.to_datetime(df["date"])، ثم استخدم الملحق .dt بعد ذلك.

خطأ KeyError: 'quantity' عند استخدام apply نسيت إضافة المعامل axis=1. بدونه، تمرر دالة apply أعمدة كاملة إلى دالتك، وبالتالي تبحث العبارة row["quantity"] عن صف يحمل التسمية quantity داخل العمود وتفشل. أضف axis=1 — ثم اسأل نفسك عما إذا كنت بحاجة إلى apply من الأساس.

خطأ IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer استدعيت .astype(int) على عمود يحتوي على قيم NaN. لا تستطيع الأعداد الصحيحة العادية في NumPy تخزين القيم المفقودة. قم بملء القيم المفقودة أولاً (الفصل السادس)، أو استخدم نوع الأعداد الصحيحة القابلة للقيم الفارغة في بانداس عبر .astype("Int64") (بحرف I كبير).

خطأ ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. كتبت df[mask]["col"] = value. في ظل ميزة Copy-on-Write، تنشئ الخطوة الأولى جدولاً مستقلاً، وبالتالي لا يتغير الجدول الأصلي إطلاقاً. اكتبها في خطوة واحدة: df.loc[mask, "col"] = value.

ظهور قيم NaN في عمود أنشئ بواسطة .map() حيث كنت تتوقع قيمة فعلية هناك قيمة في العمود غير موجودة كمفتاح في القاموس — فرع جديد، أو اختلاف في الإملاء، أو مسافة زائدة في النهاية. اكتشفها عبر df.loc[df["new"].isna(), "key_column"].unique()، ثم صحح القاموس أو البيانات.