إضافة الأعمدة — استخراج الأرقام غير الموجودة في الملف
إنشاء أعمدة جديدة من الأعمدة الحالية: العمليات الحسابية على الأعمدة بالكامل ودور الفهرس في مطابقة القيم، ودوال assign و np.where و np.select و map و pd.cut و .str و .dt، واستخدام transform للمقارنة مع المجموعة، و apply كملاذ أخير.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
أرسلت مالكة المتجر رسالة تقول فيها: "لكل طلب، أريد أن أرى كم حقق من المال، وهل كان طلباً كبيراً، ومن هو المدير المسؤول عنه، وفي أي يوم من أيام الأسبوع تم تقديم هذا الطلب. ولكل طلب أيضاً، ما هي حصته من إجمالي مبيعات فرعه؟"
فتحت الملف orders.csv. يحتوي الملف على عمود quantity (الكمية) و price (السعر)، وعمود branch (أي الفروع الثلاثة) و date (التاريخ). لا يوجد أي شيء مما طلبته موجوداً بشكل مباشر داخل الملف. بل يجب استخراج وبناء كل معلومة من هذه المعلومات من واقع الأعمدة الموجودة بالفعل.
إذا كنت قادماً من بايثون التقليدي، فستعرف يداك تلقائياً ما ستكتبه — حلقة تكرار تمر على الصفوف صفاً صفاً:
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"]])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. وهذا لا ينجح إلا إذا تطابق رقم الموضع مع تسمية الفهرس مصادفةً. ولكن لو قمت بتصفية الجدول أولاً — كأن تختار طلبات الفرع الشمالي فقط كما فعلنا في الفصل الخامس — فلن يتطابقا بعد ذلك أبداً:
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"])[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، وهو نفس الملف المستخدم منذ الفصل الرابع. إذا لم يكن متوفراً لديك، فأنشئه داخل مجلد مشروعك بهذه الأسطر تحديداً:
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. عاين المدخلات قبل تعديلها. تعتمد الأدوات المتاحة لك على أنواع بيانات الأعمدة التي تبدأ منها:
import pandas as pd
orders = pd.read_csv("orders.csv")
print(orders.shape)
print(orders.dtypes)(8, 7)
order_id int64
date str
branch str
product str
category str
quantity int64
price float64
dtype: objectثمانية صفوف؛ ويجب أن يظل هذا العدد ثمانية عند الانتهاء. نوع quantity هو int64 ونوع price هو float64، لذا فإن ضربهما سينفذ عملية حسابية حقيقية. أما date فهو str — أي نص يشبه التاريخ فقط. والنصوص لا تحتوي على أيام أسبوع، لذا ستحتاج إلى تحويله قبل أن تتمكن من استخدام .dt عليه. تدوين هذه الملاحظة الآن يمنع حدوث خطأ AttributeError لاحقاً.
3. ارسم شكلاً تقريبياً للمخرجات، واحسب صفاً واحداً يدوياً. قبل تشغيل أي كود، حدد ما يجب أن يصبح عليه صفان من الصفوف — أحدهما عادي والآخر يمثل حالة خاصة:
product quantity price revenue size
pen 12 15.0 180.0 small
bag 2 850.0 1700.0 big12 × 15.0 = 180.0، و 180 أقل من 500، إذن فالقيمة هي small. يمثل الصف المحسوب يدوياً اختباراً عملياً: بعد تشغيل الكود، إذا لم يُظهر الصف 0 القيمة 180.0 و small، فإن الكود خاطئ مهما بدا شكله منطقياً.
4. اختر الأداة التي تناسب طبيعة القاعدة الحسابية. يقع كل عمود جديد تقريباً ضمن إحدى الحالات التالية:
- عمليات حسابية على أعمدة أو مع رقم ثابت → استخدام
+ - * /على الأعمدة بالكامل. مثال:quantity * price. - الاختيار بين قيمتين بناءً على شرط → استخدام
np.where. مثال:"big"أو"small". - الاختيار من بين عدة قيم بناءً على شروط متتالية → استخدام
np.select. مثال:"bulk"أو"normal"أو"few". - البحث عن قيمة في جدول ثابت أو قاموس → استخدام
.map(dict). مثال: من branch إلى manager. - تحديد النطاق الذي يقع فيه الرقم → استخدام
pd.cut. مثال: تصنيف quantity إلى فئات. - استخراج جزء من نص → دوال الملحق
.str. مثال: الأحرف الأولى من الفئة. - استخراج جزء من تاريخ → استخدام
pd.to_datetimeأولاً، ثم الملحق.dt. مثال: اسم يوم الأسبوع. - مقارنة الصف بمجموعته الخاصة → استخدام
groupby(...).transform(...). مثال: حصة الطلب من إجمالي الفرع. - ما عدا ذلك → استخدام
apply(axis=1). مثال: قاعدة شديدة التعقيد والشذوذ لا تناسب بقية الأدوات.
اقرأ القائمة من الأعلى وتوقف عند أول خيار يناسب حالتك. هذا الترتيب مقصود تماماً: فالأدوات الثماني الأولى تعمل على الأعمدة ككل دفعة واحدة وبسرعة فائقة؛ بينما تعود الأداة الأخيرة إلى استدعاء دالة بايثون لكل صف على حدة.
5. حدد ما ستتحقق منه بعد الانتهاء. ثلاث اختبارات ثابتة في كل مرة: لم يتغير عدد الصفوف؛ والعمود الجديد له نوع البيانات المتوقع؛ ونتيجة isna().sum() عليه تساوي صفراً (أو تساوي بدقة عدد القيم المفقودة المتوقعة التي تعرف سببها). بالإضافة إلى مطابقة الصف المحسوب يدوياً.
العمليات الحسابية على الأعمدة بالكامل
إليك كيفية حساب عمود الإيراد (revenue) بأسلوب بانداس النموذجي:
import pandas as pd
orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]
print(orders[["product", "quantity", "price", "revenue"]])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):
orders["price_with_vat"] = (orders["price"] * 1.15).round(2)
print(orders[["product", "price", "price_with_vat"]].head(4))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 إلى عمود يعتمد أيضاً على مطابقة التسميات. في معظم الأوقات لن تلاحظ ذلك لأن القيم تأتي من نفس الجدول والتسميات تتطابق تماماً. لكنك ستلاحظ ذلك في اليوم الذي تختلف فيه التسميات.
لنفترض أنك تريد إنشاء عمود يحتوي على الكمية لطلبات الفرع الشمالي فقط:
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders[["branch", "quantity", "north_qty"]])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 تم فرزها يعيد كل قيمة إلى صفها الأصلي:
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())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) العادية في بايثون فلا تحتوي على تسميات، لذا يتم إسنادها بحسب الموضع — وحينها يجب أن يتطابق طولها تماماً مع عدد الصفوف:
try:
orders["rating"] = [5, 4, 3]
except ValueError as error:
print("ValueError:", error)ValueError: Length of values (3) does not match length of index (8)تم رفض إسناد ثلاث قيم لثمانية صفوف بدلاً من التخمين، وهذا هو السلوك الأكثر أماناً على الإطلاق.
لم نعد بحاجة إلى الأعمدة التجريبية. تعيد الدالة drop(columns=...) الجدول بعد إزالتها:
orders = orders.drop(columns=["north_qty", "revenue_copy", "price_with_vat"])
print(list(orders.columns))['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']دالة assign(): إضافة أعمدة دون تعديل الجدول الأصلي
يؤدي التعبير orders["revenue"] = ... إلى تعديل جدول orders نفسه مباشرة. غالباً ما يكون هذا هو المطلوب. ولكن أحياناً ترغب في تجربة بعض الأعمدة، أو إنشاء جدول مخصص لتقرير ما، مع الحفاظ على الجدول الأصلي كما قُرئ من الملف تماماً. تعيد دالة assign() كائن DataFrame جديداً بالأعمدة المضافة وتترك الجدول الأصلي كما هو دون أي تغيير:
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)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، خذ القيمة الأولى؛ وفيما عدا ذلك خذ القيمة الثانية.
report["size"] = np.where(report["revenue"] >= 500, "big", "small")
print(report[["product", "revenue", "size"]])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 للصفوف التي لا ينطبق عليها أي شرط:
conditions = [
report["quantity"] >= 20,
report["quantity"] >= 5,
]
labels = ["bulk", "normal"]
report["tier"] = np.select(conditions, labels, default="few")
print(report[["product", "quantity", "tier"]])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:
wrong = np.select(
[report["quantity"] >= 5, report["quantity"] >= 20],
["normal", "bulk"],
default="few",
)
print(wrong)['normal' 'normal' 'few' 'normal' 'normal' 'few' 'normal' 'few']أصبح كل طلب إما normal أو few؛ ولم يحصل أي طلب على bulk، لأن 30 و 20 كلاهما يحقق أيضاً الشرط >= 5 الذي تم فحصه أولاً. لم يظهر أي خطأ، بل نشأ عمود غير صحيح — ولهذا يجب أن تتضمن الصفوف المحسوبة يدوياً في الخطة قيمة قصوى أو استثنائية.
الدالة .map(): البحث عن القيم واستبدالها
اسم المدير ليس رقماً يتم حسابه من معادلة. بل هو حقيقة معلومة لديك: تدير آشا الفرع الشمالي، وعمر الفرع الجنوبي، ولينا الفرع الشرقي. عندما تكون القاعدة "ابحث عن القيمة المقابلة"، اكتب جدول البحث كقاموس بايثون (dictionary) ومرر العمود عبره باستخدام .map():
manager_of = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
report["manager"] = report["branch"].map(manager_of)
print(report[["branch", "manager"]])
print(report["manager"].isna().sum())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() عندما لا تجد المفتاح المطلوب. تخيل لو أن القاموس كُتب قبل افتتاح الفرع الشرقي:
old_managers = {"north": "Asha", "south": "Omar"}
print(report["branch"].map(old_managers).isna().sum())2لم يظهر أي خطأ؛ بل حصل طلبا الفرع الشرقي ببساطة على NaN. في جدول من ثمانية صفوف ستكتشف ذلك بسهولة؛ ولكن في جدول يضم ثمانين ألف صف لن تلاحظه بالعين، وستتجاهل أي عملية groupby("manager") لاحقة تلك الطلبات بهدوء تام (قاعدة الفصل الثامن: يتم إسقاط المفاتيح NaN). احرص دائماً على كتابة isna().sum() بعد كل استخدام لـ .map().
دالة pd.cut: تحديد النطاق الذي يقع فيه الرقم
يريد المتجر تقسيم الطلبات إلى نطاقات بحسب الكمية: حتى 5 قطع تصنف كـ small، ومن 6 إلى 15 تصنف كـ medium، وأكثر من 15 تصنف كـ large. يمكنك كتابة ذلك باستخدام np.select، ولكن لمسألة "في أي نطاق يقع الرقم" أداة مخصصة بالغة الكفاءة. تأخذ دالة pd.cut حدود النطاقات (bins) وتسمية لكل نطاق:
report["band"] = pd.cut(
report["quantity"],
bins=[0, 5, 15, np.inf],
labels=["small", "medium", "large"],
)
print(report[["product", "quantity", "band"]])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 تعني ما لا نهاية، وهي حد علوي لا يمكن لأي كمية تجاوزه.
الناتج له نوع بيانات خاص لم نره من قبل:
print(report["band"].dtype)
print(report["band"].cat.categories.tolist())category
['small', 'medium', 'large']يتذكر العمود الفئوي category أن small < medium < large. وبالتالي فإن ترتيب الجدول بحسب عمود band يعتمد هذا الترتيب المنطقي بدلاً من الترتيب الأبجدي — وهو ما تحتاجه التقارير عادة.
النصوص والتواريخ: الملحقان .str و .dt
أجزاء النصوص
استخدمنا في الفصل الخامس الملحق .str.contains للتصفية. والملحق .str نفسه يستخدم أيضاً في إنشاء الأعمدة: فكل دالة نصوص تعرفها في بايثون، يمكن تطبيقها على جميع قيم العمود دفعة واحدة. لنفترض أن كل طلب يحتاج إلى كود تعريفي مثل STA-1001 — يضم أول ثلاثة أحرف من الفئة بأحرف كبيرة، متبوعة بشرطة، ثم رقم الطلب:
report["code"] = (
report["category"].str[:3].str.upper()
+ "-"
+ report["order_id"].astype(str)
)
print(report[["category", "order_id", "code"]].head(3))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 بكل خصائصه:
report["date"] = pd.to_datetime(report["date"])
print(report["date"].dtype)
report["weekday"] = report["date"].dt.day_name()
print(report[["date", "weekday"]].head(4))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: مقارنة كل صف بمجموعته
الطلب الأخير — حصة كل طلب من إجمالي إيرادات فرعه — يتطلب وجود معلومتين في نفس الصف: إيراد هذا الطلب، وإجمالي إيرادات فرعه ككل. علّمنا الفصل الثامن كيف نستخرج الإجماليات:
print(report.groupby("branch")["revenue"].sum())branch
east 1140.0
north 2600.0
south 1150.0
Name: revenue, dtype: float64لكن هذه ثلاث قيم فقط، قيمة واحدة لكل فرع، بينما يضم الجدول ثمانية صفوف. ولا يمكنك قسمة ثمانية أرقام على ثلاثة. ما نحتاجه هو تكرار إجمالي الفرع في كل صف ينتمي إلى ذلك الفرع. وهذا هو ما تعيده دالة transform تحديداً:
branch_total = report.groupby("branch")["revenue"].transform("sum")
print(branch_total)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. والآن تصبح العملية الحسابية عادية ومباشرة:
report["branch_share"] = (report["revenue"] / branch_total).round(3)
print(report[["branch", "product", "revenue", "branch_share"]])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:
print(report.groupby("branch")["branch_share"].sum())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 على كل صف:
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"]])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:
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())Trueنفس النتيجة تماماً، دون استدعاء دوال بايثون لكل صف. احتفظ بـ apply(axis=1) للحالات التي يستحيل فيها كتابة الكود بهذه الطريقة — مثل استدعاء مكتبة خارجية، أو تحليل نصوص معقدة متعددة المراحل — واعتبر الحاجة إليها إشارة لإعادة التفكير مرة أخرى.
إضافة عمود إلى جدول مصفّى: ميزة Copy-on-Write
في كثير من الأحيان، ستقوم بتصفية البيانات أولاً ثم إضافة عمود جديد إلى الناتج. هذا الأمر في غاية البساطة والوضوح في بانداس 3، ومن المهم فهم سبب ذلك لأن الدروس القديمة على الإنترنت تدعي خلاف ذلك.
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))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 الأصلي عبر تسلسل من خطوتين. إذا كنت تريد تعديل القيم في الجدول الأصلي لصفوف معينة، فإن هذا الكود لن يغير شيئاً:
orders[orders["branch"] == "north"]["revenue"] = 0
print(orders["revenue"].tolist())[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]:
orders.loc[orders["branch"] == "north", "note"] = "main branch"
print(orders[["branch", "note"]])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 عن رسالة مالكة المتجر بالكامل متبعاً خطتنا المحددة: قراءة البيانات، الفحص، إضافة الأعمدة، ثم التحقق مجدداً.
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)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" — ويطبق بانداس هذه القاعدة صفاً بصف دون إصدار أي اعتراض:
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"])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:
good = pd.read_csv(StringIO(RAW), thousands=",")
print(good.dtypes["price"])
print(good["quantity"] * good["price"])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()، ثم صحح القاموس أو البيانات.
Step 4 of 6 — Predict
Check your understanding
تم إسناد عمود مبني من طلبات فرع north إلى الجدول بالكامل. ما الذي ستتم طباعته؟
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders["north_qty"].isna().sum(), orders["north_qty"].dtype)- A0 int64
- B4 int64
- C4 float64
- D0 float64
الهدف هو إضافة عمود revenue إلى orders. ما الذي ستتم طباعته؟
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
orders.assign(revenue=orders["quantity"] * orders["price"])
print("revenue" in orders.columns)- A`True`
- B`False` — دالة `assign` تعيد جدولاً جديداً وتترك `orders` دون أي تعديل، ولم يتم حفظ الناتج في متغير
- Cخطأ `KeyError: 'revenue'`
- Dخطأ `ChainedAssignmentError`
أنت بحاجة إلى عمود يحتوي في كل صف على حصة هذا الطلب من إجمالي كمية فرعه. أي سطر يعطي قيمة واحدة لكل صف؟
- Aorders["quantity"] / orders.groupby("branch")["quantity"].sum()
- Borders["quantity"] / orders["quantity"].sum()
- Corders.groupby("branch")["quantity"].sum() / orders["quantity"]
- Dorders["quantity"] / orders.groupby("branch")["quantity"].transform("sum")
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
دورك الآن
باستخدام نفس الملف orders.csv، اكتب برنامجاً باسم enrich_task.py يولد لمالكة المتجر تقريراً بالخصومات. ابدأ بوضع خطة عمل أولاً — دوّن قائمة الأعمدة الجديدة واحسب الصف 0 يدوياً — ثم أضف هذه الأعمدة بهذا الترتيب المحدد:
revenue=quantity×pricediscount= 10% منrevenueإذا كانتquantity10 أو أكثر، وإلا فـ 0net=revenue−discountmanager— يُستخرج من الفرع باستخدام القاموس المشروح في هذا الفصلband— يُستخرج منnet: حتى 300 يصنف كـ"low"، وأكثر من 300 حتى 800 يصنف كـ"mid"، وأكثر من 800 يصنف كـ"high"weekday— يُستخرج منdatecategory_share— حصة صافي إيراد كل طلب (net) من إجمالي صافي إيرادات فئته بالكامل، مقربة إلى منزلتين عشريتين
اطبع أبعاد الجدول (shape) قبل وبعد العملية، ثم اطبع الأعمدة order_id و product و quantity و net و band و weekday و category_share مرتبة بحسب net من الأعلى إلى الأدنى.
يكون برنامجك صحيحاً عندما:
- يطبع المخرجات المطابقة تماماً لما هو موضح أدناه.
- يخلو تماماً من أي حلقة
forأو دالةapply— حيث يُنشأ كل عمود باستخدام عمليات الأعمدة الكاملة دفعة واحدة. - إذا فُقد أي فرع من القاموس، يتوقف البرنامج على الفور بخطأ
AssertionErrorبدلاً من الاستمرار بوجود قيمNaN. - يظل عدد الصفوف 8 قبل التعديل وبعده.
المخرجات المتوقعة:
before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17قبل التشغيل، توقع: أي طلب سيحصل على أعلى net، وفي أي فئة (band) سيقع طلب الممحاة ذو الكمية 30؟ ثم قارن توقعاتك مع المخرجات.
الحل
خطة العمل. الحساب اليدوي للصف 0: طلب الأقلام، 12 × 15.0 = 180.0؛ الكمية 12 لذا الخصم 18.0 والصافي net هو 162.0؛ والرقم 162 لا يتجاوز 300 لذا فئته low. طلب الممحاة: 30 × 8.0 = 240.0، والخصم 24.0، والصافي 216.0 — وهو أيضاً في فئة low رغم أنه أكبر طلب من حيث الكمية. أعلى صافي net متوقع هو لطلب الحقيبتين: 1700.0 دون أي خصم.
revenueوnet— حسابات متجهية على الأعمدة: ضرب وطرح مباشر.discount—np.where: الاختيار بين قيمتين لكل صف.manager—.map(dict)متبوعاً بـisna().sum(): مطابقة من قاموس مع التحقق.band—pd.cutبالحدود[0, 300, 800, inf]: نطاقات متتالية. يشمل كل نطاق حده الأيمن، لذا فإن 300 بالضبط تصنف كـlowو 800 بالضبط تصنف كـmid.weekday—pd.to_datetimeثم.dt.day_name(): استخراج اليوم يتطلب تاريخاً حقيقياً.category_share—groupby("category")["net"].transform("sum"): قيمة واحدة لكل صف، مقارنةً بمجموعته.
ملف الحل enrich_task.py:
import numpy as np
import pandas as pd
MANAGER_OF = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
orders = pd.read_csv("orders.csv")
print("before:", orders.shape)
orders["revenue"] = orders["quantity"] * orders["price"]
orders["discount"] = np.where(orders["quantity"] >= 10, orders["revenue"] * 0.10, 0.0)
orders["net"] = orders["revenue"] - orders["discount"]
orders["manager"] = orders["branch"].map(MANAGER_OF)
assert orders["manager"].isna().sum() == 0
orders["band"] = pd.cut(
orders["net"],
bins=[0, 300, 800, np.inf],
labels=["low", "mid", "high"],
)
orders["date"] = pd.to_datetime(orders["date"])
orders["weekday"] = orders["date"].dt.day_name()
category_total = orders.groupby("category")["net"].transform("sum")
orders["category_share"] = (orders["net"] / category_total).round(2)
print("after: ", orders.shape)
print()
columns = ["order_id", "product", "quantity", "net", "band", "weekday", "category_share"]
print(orders[columns].sort_values("net", ascending=False))before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17مراجعة النتائج وفق خطة العمل:
- أبعاد الجدول (Shape) انتقلت من 7 أعمدة إلى 14 عموداً مع بقاء 8 صفوف: سبعة أعمدة جديدة دون زيادة أو نقصان في الصفوف.
- الصف 0 (الطلب 1001) يمتلك قيمة
netمساوية لـ162.0وفئةlow— مطابقاً تماماً للحساب اليدوي. - أعلى قيمة
netهي للطلب 1003 (الحقائب) بقيمة1700.0كما توقعنا. وطلب الممحاة 1005 مصنف كـlowبقيمة216.0: أكبر عدد من القطع ولكنها رخيصة الثمن. - استخدمت
np.whereعموداً كقيمة في حال التحقق —orders["revenue"] * 0.10— فحصل كل صف على نسبة 10% الخاصة به، وليس على رقم موحد. يمكن لكلا فرعيnp.whereأن يكونا أعمدة أو قيماً ثابتة. - حدود النطاق واضحة تماماً. طلب الدفتر يمتلك
netبقيمة300.0بالضبط وصنف كـlowلأن(0, 300]تشمل الحد الأيمن 300. ولو كانت مالكة المتجر تقصد "300 فما فوق تعتبر mid"، لوجب تعديل الحدود — وهذا قرار منطقي وليس مجرد تفصيلة صغيرة. - مجموع الحصص داخل كل فئة يساوي 1. قسم الإكسسوارات:
0.44 + 0.22 + 0.22 + 0.12 = 1.00. وقسم القرطاسية:0.32 + 0.28 + 0.23 + 0.17 = 1.00. ولو لم يكن المجموع 1، لكان ذلك دليلاً على تجميعtransformبعمود خاطئ.
جرب الآن تعطيل الكود عمداً لاختبار صلابته: احذف "east" من القاموس وشاهد كيف توقف عبارة assert البرنامج؛ وغير bins لتبدأ من 200 بدلاً من 0 ولاحظ الصفوف التي ستحصل على NaN في عمود band ولماذا.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz