دمج كائنات DataFrame — الرصف باستخدام concat والمطابقة باستخدام merge
جمع الجداول معاً: رصف بيانات الأشهر المتشابهة رأسياً باستخدام concat، وإضافة معلومات من جدول آخر باستخدام merge — اختيار نوع الدمج المناسب، ومعرفة الصفوف غير المتطابقة، واكتشاف المفاتيح المكررة التي تضاعف الصفوف بصمت.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
بيانات المتجر لا تسكن أبداً في ملف واحد. لم تكن كذلك ولن تكون.
طلبات شهر مارس موجودة في orders.csv، وهو الجدول الذي استخدمته منذ الفصل الرابع. ووصلت طلبات شهر أبريل هذا الأسبوع في ملف تصدير منفصل، orders_april.csv. أما ما يتكلفه المتجر في كل منتج (cost) — والمورّد (supplier) الذي يبيعه له — فيحتفظ به فريق المشتريات في ملف خاص به، products.csv. لم يضع أحد عمود التكلفة في ملف الطلبات، لأن التكلفة حقيقة تخص المنتج ذاته وليست حقيقة تخص طلب البيع.
والآن يطرح مالك المتجر سؤالاً واحداً: كم بلغ الربح (profit) الذي حققه كل مورّد على مدار شهري مارس وأبريل معاً؟
لا يمكن لملف واحد بمفرده الإجابة عن هذا السؤال. فالربح يتطلب معرفة الكمية وسعر البيع (الموجودين في ملف الطلبات)، والتكلفة (الموجودة في ملف المنتجات)، والمورّد (الموجود أيضاً في ملف المنتجات). وفوق ذلك، فإن الطلبات موزعة عبر شهرين منفصلين.
في جداول البيانات المعتادة (Spreadsheets)، كنت ستنسخ صفوف شهر أبريل وتلصقها أسفل صفوف شهر مارس، ثم تكتب دالة VLOOKUP في عمود جديد لجلب تكلفة كل صف، وتسحب المعادلة للأسفل، متمنياً ألا يحدث خطأ. أما في بانداس، فيُنجز هذان العملان في سطرين فقط:
- الرصف الرأسي (stack) للجداول التي تحتوي على النوع نفسه من الصفوف — كطلبات مارس وأبريل — في جدول واحد أطول:
pd.concat - المطابقة والربط (match) لكل صف من جدول بالصف الصحيح من جدول آخر عبر عمود مشترك — كربط كل طلب بمنتجه المقابل — وجلب أعمدة الجدول الآخر:
pd.merge
السطران البرمجيان قصيران ومباشران. لكن ما يجعل هذا الفصل بالغ الأهمية هو ما يمكن أن يسير بشكل خاطئ دون أن يظهر أي خطأ برمجي: إذ يمكن لعملية الدمج (merge) أن تحذف بصمت طلبات لا يوجد منتجها في جدول البحث، أو أن تكرر بصمت الطلبات إذا ورد منتج ما مرتين في جدول البحث. وفي كلتا الحالتين، ستحصل على مجموع يبدو ظاهرياً معقولاً ومقنعاً للغاية، لكنه خاطئ تماماً. ولهذا فإن معظم هذا الفصل يدور حول معرفة عدد الصفوف الدقيق الذي ينبغي أن تحصل عليه قبل الدمج وبعده — والتحقق منه خطوة بخطوة.
بنهاية هذا الفصل ستكون قادراً على
- التمييز بدقة بين العمليتين: رصف الصفوف رأسياً (
concat) والمطابقة بناءً على مفتاح مشترك (merge) - رصف الجداول باستخدام
pd.concat، وتصحيح الفهرس المكرر عبرignore_index=True، وفهم ما يحدث عند اختلاف الأعمدة - ربط جدولين معاً عبر مفتاح باستخدام
pd.merge، واختيار نوع الربط عمداً:how="inner"أو"left"أو"right"أو"outer" - تدقيق ومراجعة عملية الدمج عبر
indicator=Trueوحمايتها بواسطةvalidate="many_to_one" - رصد ظاهرة تضاعف الصفوف غير المقصود (row explosion) الناتجة عن المفاتيح المكررة ومعالجتها
- الدمج بناءً على مفاتيح مختلفة الأسماء عبر
left_on/right_on، والتعامل مع تعارض أسماء الأعمدة عبرsuffixes - تشخيص أخطاء الدمج الشائعة — مثل عدم تطابق أنواع بيانات المفتاح، وغياب أعمدة المفاتيح — واكتشاف الأخطاء الصامتة التي لا تصدر أي تنبيه
المتطلبات المسبقة: إضافة الأعمدة.
أنشئ الملفات أولاً
يستخدم هذا الفصل أربعة ملفات بيانات صغيرة. ضعها بجوار ملف البرنامج الخاص بك.
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.0orders_april.csv — طلبات شهر أبريل، بالأعمدة نفسها. لاحظ وجود الدباسة (stapler): بدأ المتجر في بيعها خلال شهر أبريل.
order_id,date,branch,product,category,quantity,price
1009,2024-04-01,north,pen,stationery,15,15.0
1010,2024-04-01,east,notebook,stationery,3,60.0
1011,2024-04-02,south,stapler,stationery,6,95.0
1012,2024-04-02,north,bag,accessories,1,850.0products.csv — صف واحد لكل منتج، يحتفظ به فريق المشتريات. هناك أمران "ناقصان" هنا عمداً، كما يحدث في بيئات العمل الحقيقية: لم تُضَف الدباسة الجديدة بعد إلى السجل، وهناك قلم تحديد (marker) لم يبعه المتجر قط.
product,cost,supplier
pen,9.0,Alpha
notebook,40.0,Alpha
bag,600.0,Bravo
bottle,80.0,Bravo
eraser,5.0,Alpha
marker,25.0,Alphabranches.csv — المسؤول عن إدارة كل فرع من فروع المتجر الثلاثة. سُمي العمود branch_name بدلاً من branch، لأن شخصاً آخر هو من أنشأ هذا الملف.
branch_name,manager
north,Mira
south,Omar
east,Lenaقبل كتابة الكود
إن خطوة دمج الجداول هي أكثر مرحلة يؤدي فيها سطر كود كُتب على عجل إلى نتيجة خاطئة تبدو مقنعة وموثوقة تماماً. لذلك، يأتي التخطيط أولاً، وهو عبارة عن أسئلة تجيب عنها بمجرد النظر إلى البيانات.
1. صِغ السؤال في جملة واحدة
"الربح لكل مورّد عن شهري مارس وأبريل معاً." هذه الجملة تحدد لك مسبقاً شكل الجدول النهائي: صف واحد لكل مورّد، ورقم واحد في كل صف. لدينا موردان فقط، Alpha و Bravo، لذا ينبغي أن تحتوي النتيجة على صفين تقريباً. فإذا انتهى بك الأمر بصف واحد أو ثلاثة صفوف، فهذا يعني أن خطأ ما قد حدث أثناء المعالجة.
2. حدد العملية التي يحتاجها كل زوج من الجداول
توجد طريقتان رئيسيتان فقط للجمع بين الجداول، ومعيار التمييز بينهما بسيط للغاية: هل يحتوي الجدولان على النوع نفسه من الصفوف، أم يحتويان على حقائق وتفاصيل مختلفة عن الشيء نفسه؟
- الأعمدة نفسها، صفوف مختلفة — طلبات مارس + طلبات أبريل. استخدم
pd.concat([a, b]). تنمو النتيجة رأسياً للأسفل: صفوف أكثر، مع بقاء الأعمدة نفسها. - عمود مفتاح مشترك، حقائق مختلفة — الطلبات + المنتجات، مرتبطتان عبر عمود
product. استخدمpd.merge(a, b, on="product"). تنمو النتيجة أفقياً إلى الجانب: الصفوف نفسها، مع زيادة عدد الأعمدة.
طلبات مارس وأبريل كلاهما "طلبات بيع" — الأعمدة نفسها مع زيادة الصفوف — لذا يتم رصفهما رأسياً (stack). أما الطلبات والمنتجات فهما كيانان مختلفان يربطهما اسم المنتج، لذا يتم دمجهما ومطابقتهما (match).
3. عاين كل جدول قبل دمجه
تعلمت في الفصل الثالث عادة فحص الملف بمجرد قراءته. وعند التعامل مع عدة ملفات تصبح هذه العادة ضرورة لا غنى عنها: فأنت بحاجة إلى معرفة أبعاد (shape) كل جدول لتتوقع أبعاد النتيجة بدقة.
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
products = pd.read_csv("products.csv")
for name, df in [("march", march), ("april", april), ("products", products)]:
print(name, df.shape, list(df.columns))march (8, 7) ['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price']
april (4, 7) ['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price']
products (6, 3) ['product', 'cost', 'supplier']يمتلك جدولا مارس وأبريل الأعمدة السبعة نفسها بالضبط وبالترتيب ذاته — وهو أمر مثالي للرصف الرأسي. ويشترك جدول products معهما في اسم عمود واحد فقط: product. هذا العمود هو المفتاح (key)، وهو العمود الذي ستتم المطابقة بناءً عليه.
تحقق من أن المفتاح يمتلك نوع البيانات (dtype) نفسه في كلا الطرفين. فالمفتاح الذي يُمثَّل كرقم في جدول وكنص في جدول آخر يستحيل أن يتطابق:
print(march["product"].dtype, products["product"].dtype)str strكلاهما من النوع str. إذن يمكن مقارنتهما.
4. تحقق من أن المفتاح فريد في جدول البحث (Lookup)
هذا هو الفحص الأكثر أهمية في الفصل بأكمله. يحتاج كل طلب بيع إلى تكلفة واحدة فقط. فإذا تكرر pen مرتين في products.csv، سيتطابق كل طلب لقلم مرتين وسيظهر مرتين في النتيجة النهائية.
print(products["product"].is_unique)
print(products["product"].duplicated().sum())True
0القيمة is_unique هي True ولا توجد أي قيم مكررة (0): صف واحد لكل منتج. يمكن لكل طلب أن يتطابق مع صف منتج واحد على الأكثر.
5. تحقق من المفاتيح التي لن تجد تطابقاً
قبل الدمج، اسأل نفسك: هل توجد طلبات لا وجود لمنتجاتها في products.csv، وهل توجد منتجات لم يطلبها أحد؟ الدالة isin (من فصل التصفية) تجيب عن هذين السؤالين معاً:
orders = pd.concat([march, april], ignore_index=True)
no_cost = ~orders["product"].isin(products["product"])
print(orders.loc[no_cost, ["order_id", "product"]])
never_sold = ~products["product"].isin(orders["product"])
print(products.loc[never_sold, "product"].tolist())order_id product
10 1011 stapler
['marker']هناك طلب واحد — الدباسة، 1011 — ليس له تكلفة مسجلة. وهناك منتج واحد، قلم التحديد (marker)، لم يُبَع قط. الآن صرت تعرف قبل الدمج ما الذي سيتعين على عملية الدمج التعامل معه.
6. توقع النتيجة، ثم اتخذ القرار
دوّن توقعك في صورة أرقام واضحة:
- يحتوي جدول
ordersعلى 8 + 4 = 12 صفاً. - يحتوي جدول
productsعلى صف واحد لكل منتج، لذا لن تتضاعف الطلبات بسبب المطابقة. - إذا احتفظنا بجميع الطلبات، فسيحتوي الجدول المدمج على 12 صفاً، وسيكون أحدها (الدباسة) بدون تكلفة.
- أما إذا احتفظنا بالطلبات المتطابقة فقط، فسيحتوي على 11 صفاً.
أيهما تختار؟ في تقرير الأرباح، يؤدي حذف طلب الدباسة بصمت إلى التقليل من إجمالي المبيعات الحقيقية. الأفضل هو الإبقاء عليه، ورؤية أن تكلفته مفقودة، ثم اتخاذ القرار المناسب — وهو الخيار الذي يحدده المعامل how=. وبعد إتمام الدمج، أول شيء تطبعه هو الأبعاد shape، وتقارنها بهذا التوقع.
هذه هي الخطة الكاملة: صياغة السؤال بجملة، وتحديد العملية المناسبة لكل زوج، ومعاينة كل جدول، والتحقق من فرادة المفتاح، وتحديد المفاتيح غير المتطابقة، وحساب عدد الصفوف المتوقع مسبقاً. وما تبقى من الفصل هو الأدوات العملية لتنفيذ هذه الخطة.
رصف الصفوف رأسياً باستخدام pd.concat
تستقبل الدالة pd.concat قائمة من الجداول وترصفها فوق بعضها رأسياً:
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
both = pd.concat([march, april])
print(both.shape)
print(both[["order_id", "date", "product", "quantity"]])(12, 7)
order_id date product quantity
0 1001 2024-03-01 pen 12
1 1002 2024-03-01 notebook 5
2 1003 2024-03-02 bag 2
3 1004 2024-03-02 bottle 7
4 1005 2024-03-03 eraser 30
5 1006 2024-03-03 bag 1
6 1007 2024-03-04 pen 20
7 1008 2024-03-04 bottle 4
0 1009 2024-04-01 pen 15
1 1010 2024-04-01 notebook 3
2 1011 2024-04-02 stapler 6
3 1012 2024-04-02 bag 1اثنا عشر صفاً بالأعمدة السبعة نفسها. قامت بانداس بمحاذاة الأعمدة حسب الاسم، وليس حسب موضعها في الملف — فلو كانت أعمدة ملف أبريل مرتبة بترتيب مختلف، لاستقرت أيضاً تحت العناوين الصحيحة.
ولكن انظر إلى الحافة اليسرى حيث يوجد الفهرس (index). ستجد الفهرس يبدأ من 0 إلى 7، ثم يبدأ مجدداً من 0. احتفظت concat بالفهرس الأصلي لكل جدول، لذا أصبحت التسميات من 0 إلى 3 تظهر مرتين. وهذه ليست مجرد مشكلة شكلية:
print(both.loc[0, ["order_id", "date"]])order_id date
0 1001 2024-03-01
0 1009 2024-04-01طلبت الصف ذي التسمية 0 فحصلت على صفين بدلاً من صف واحد. وأي كود يفترض أن التسمية الواحدة تشير إلى صف واحد فقط — مثل التحديد بـ loc، أو دمج لاحق بناءً على الفهرس، أو تعديل خلية معينة — سينفذ الآن سلوكاً غير متوقع. يمكنك التأكد من ذلك برمجياً:
print(both.index.is_unique)Falseignore_index=True — ترقيم الصفوف من جديد
عندما يكون الفهرس القديم مجرد عداد للصفوف (أي RangeIndex كما في جميع ملفات read_csv السابقة)، فإنه لا يحمل أي دلالة خاصة. تخلص منه ودع concat ترقم النتيجة من الصفر بتسلسل جديد:
orders = pd.concat([march, april], ignore_index=True)
print(orders.index.is_unique)
print(orders[["order_id", "date", "product"]].tail(5))True
order_id date product
7 1008 2024-03-04 bottle
8 1009 2024-04-01 pen
9 1010 2024-04-01 notebook
10 1011 2024-04-02 stapler
11 1012 2024-04-02 bagأصبحت التسميات الآن متسلسلة من 0 إلى 11، وكل منها فريد تماماً. اجعل ignore_index=True خيارك الافتراضي دائماً عند رصف الملفات رأسياً؛ ولا تستغنِ عنه إلا إذا كان للفهرس معنى وظيفي مقصود (كما بعد استخدام set_index("order_id") حيث تكون معرفات الطلبات فريدة بالفعل).
تتبع مصدر كل صف
بعد الرصف الرأسي، لا يوجد في الجدول ما يوضح الشهر الذي ينتمي إليه كل صف. في هذا المثال، توضح التواريخ ذلك، ولكن في مواقف كثيرة لا تتوفر مثل هذه المعلومة. أضف عموداً قبل الرصف — بالتقنية التي تعلمتها في الفصل السابق — وستنتقل هذه المعلومة تلقائياً مع الصفوف:
march["month"] = "March"
april["month"] = "April"
orders = pd.concat([march, april], ignore_index=True)
print(orders["month"].value_counts())month
March 8
April 4
Name: count, dtype: int64يعمل هذا العد كوسيلة تحقق مزدوجة: 8 + 4 = 12، لم يُفقد أي صف.
عندما لا تتطابق الأعمدة
تطابق دالة concat الأعمدة بحسب أسمائها. وإذا وجد عمود في بعض الجداول دون غيرها، فإنه سيظل يظهر في النتيجة النهائية — وستأخذ صفوف الجداول التي تفتقر إليه القيمة NaN في ذلك العمود. لنفترض أن هناك طلباً استثنائياً أُدخل يدوياً، يحتوي على عمود الخصم discount ويفتقر إلى date و product و category و price:
manual = pd.DataFrame({
"order_id": [2001],
"branch": ["north"],
"quantity": [3],
"discount": [0.1],
})
mixed = pd.concat([march.head(2), manual], ignore_index=True)
print(mixed[["order_id", "branch", "price", "discount"]])
print(mixed.isna().sum())order_id branch price discount
0 1001 north 15.0 NaN
1 1002 south 60.0 NaN
2 2001 north NaN 0.1
order_id 0
date 1
branch 0
product 1
category 1
quantity 0
price 1
month 1
discount 2
dtype: int64لا يظهر أي خطأ. تحتوي النتيجة على اتحاد (union) جميع الأعمدة، وتُملأ الفجوات بالقيمة NaN. ولهذا السبب يستحق الرصف الرأسي تطبيق فحص isna().sum() الذي تعلمته في فصل البيانات المفقودة: فاختلاف حرف واحد في كتابة اسم العمود في أحد الملفات (مثل Quantity بدلاً من quantity) لن يوقف البرنامج بخطأ، بل سينتج عمودين نصف فارغين. فإذا زاد عدد الأعمدة بعد استخدام concat عن عدد أعمدة أي جدول مدخل، فهذا يعني أن بعض الأعمدة لم تتطابق أسماؤها.
axis=1 — ولماذا لا ترغب فيه عادةً
التعليمة pd.concat([a, b], axis=1) تضع الجداول جنباً إلى جنب بدلاً من رصفها رأسياً، وتطابق الصفوف بناءً على تسميات الفهرس (index labels). قد يبدو هذا الخيار مغرياً لتنفيذ فكرة "أضف أعمدة المنتج إلى الطلبات"، لكنه لا ينظر إطلاقاً إلى اسم المنتج — بل يضع الصف 0 من الجدول الأول بجوار الصف 0 من الجدول الثاني أياً كانت محتوياتهما. ولإجراء مطابقة بناءً على عمود محدد، استخدم دائماً merge.
مطابقة الصفوف وربطها باستخدام pd.merge
تستقبل الدالة merge جدولين ومفتاحاً مشتركاً. ولكل صف في الجدول الأيسر، تبحث عن الصفوف المقابلة له في الجدول الأيمن التي تحمل قيمة المفتاح نفسها، وتدمج أعمدتها معاً:
products = pd.read_csv("products.csv")
merged = pd.merge(orders, products, on="product")
print(merged.shape)
print(merged[["order_id", "product", "quantity", "price", "cost", "supplier"]])(11, 10)
order_id product quantity price cost supplier
0 1001 pen 12 15.0 9.0 Alpha
1 1002 notebook 5 60.0 40.0 Alpha
2 1003 bag 2 850.0 600.0 Bravo
3 1004 bottle 7 120.0 80.0 Bravo
4 1005 eraser 30 8.0 5.0 Alpha
5 1006 bag 1 850.0 600.0 Bravo
6 1007 pen 20 15.0 9.0 Alpha
7 1008 bottle 4 120.0 80.0 Bravo
8 1009 pen 15 15.0 9.0 Alpha
9 1010 notebook 3 60.0 40.0 Alpha
10 1012 bag 1 850.0 600.0 Bravoيمتلك كل طلب الآن تكلفته ومورّده إلى جانبه مباشرة، دون الحاجة إلى كتابة حلقة تكرارية (loop) ودون أي تأثر بترتيب الصفوف في أي من الملفين. ويظهر عمود المفتاح product مرة واحدة فقط لأنه متطابق في الجانبين.
والآن، قارن الأبعاد بالخطة الموضوعة مسبقاً. لقد توقعنا 12 صفاً إذا تم الإبقاء على كل شيء — ولكن لدينا هنا 11 صفاً فقط. لقد اختفى طلب الدباسة، 1011. لا تحذير، ولا خطأ.
هذا ليس خللاً برمجياً. بل هو السلوك الافتراضي لعملية الدمج، how="inner"، التي تؤدي وظيفتها كما حُددت لها تماماً: الإبقاء فقط على المفاتيح الموجودة في كلا الجدولين معاً. الدباسة غير موجودة في products، لذا لم ينجُ طلبها؛ وقلم التحديد غير موجود في orders، لذا لم يظهر هو الآخر. ولو لم تكن قد توقعت 12 صفاً مسبقاً، لما انتبهت أبداً إلى اختفاء عملية البيع هذه.
الأنواع الأربعة للدمج (merge)
يحدد المعامل how= ما يحدث للصفوف التي لا يجد مفتاحها شريكاً مقابلاً في الطرف الآخر. تخيل مجموعتي المفاتيح:
how="inner"(الافتراضي) يحتفظ فقط بالمفاتيح الموجودة في كلا الجدولين. وتُحذف الصفوف غير المتطابقة من كلا الطرفين. استخدمه عندما تريد السجلات المكتملة فقط.how="left"يحتفظ بجميع صفوف الجدول الأيسر. وتأخذ صفوف الطرف الأيسر التي ليس لها شريك القيمةNaNفي أعمدة الجدول الأيمن؛ بينما تُحذف صفوف الطرف الأيمن غير المتطابقة. استخدمه لإثراء جدول رئيسي بمعلومات إضافية — وهو الخيار الأكثر شيوعاً واستخداماً.how="right"هو الصورة المعكوسة تماماً: يتم الإبقاء على جميع صفوف الجدول الأيمن، وتُحذف صفوف الجدول الأيسر غير المتطابقة.how="outer"يحتفظ بجميع المفاتيح من كلا الطرفين، ويملأ الفراغات بالقيمةNaN. استخدمه لمقارنة وتدقيق قائمتين معاً.
السؤال الجوهري الذي يجب أن تطرحه على نفسك هو: ما الجدول الذي لا يجوز لصفوفه أن تختفي أبداً؟ في حالتنا، هو جدول الطلبات. فكل طلب يمثل عملية بيع حقيقية؛ بينما ملف المنتجات مجرد جدول بحث ومرجع. إذن الدمج الصحيح هو الدمج الأيسر (left merge)، مع وضع الطلبات في الطرف الأيسر:
merged = pd.merge(orders, products, on="product", how="left")
print(merged.shape)
print(merged[["order_id", "product", "cost", "supplier"]])(12, 10)
order_id product cost supplier
0 1001 pen 9.0 Alpha
1 1002 notebook 40.0 Alpha
2 1003 bag 600.0 Bravo
3 1004 bottle 80.0 Bravo
4 1005 eraser 5.0 Alpha
5 1006 bag 600.0 Bravo
6 1007 pen 9.0 Alpha
7 1008 bottle 80.0 Bravo
8 1009 pen 9.0 Alpha
9 1010 notebook 40.0 Alpha
10 1011 stapler NaN NaN
11 1012 bag 600.0 Bravoاثنا عشر صفاً كما توقعنا تماماً. لا يزال طلب الدباسة موجوداً، مع القيمة NaN للتكلفة والمورّد — وهو أمر صريح ودقيق: فالتكلفة غير معروفة، وأنت ترى ذلك بوضوح. إن وجود قيمة مفقودة واضحة أمام عينيك أفضل بمراحل من اختفاء صف كامل دون أن تدري. ومن هنا، يمكنك استخدام أدوات فصل البيانات المفقودة: كحساب عدد الفراغات عبر isna().sum()، أو تعبئة التكلفة إذا زوّدك بها فريق المشتريات، أو عزل هذا الطلب في تقرير مستقل.
أما الدمج الأيمن (right merge) فهو الصورة المعكوسة — يتم الإبقاء على كل منتج، ويظهر قلم التحديد بدون أي طلب بيع:
right = pd.merge(orders, products, on="product", how="right")
print(right.shape)
print(right[["order_id", "product", "cost"]].tail(3))(12, 10)
order_id product cost
9 1008.0 bottle 80.0
10 1005.0 eraser 5.0
11 NaN marker 25.0لاحظ كيف تحولت قيم order_id من أرقام صحيحة إلى 1008.0 و 1005.0 وما إلى ذلك. نظراً لأن صف قلم التحديد ليس له معرف طلب، أصبح العمود يحتوي الآن على NaN — وكما رأيت في فصل البيانات المفقودة، فإن عمود الأعداد الصحيحة الذي يستقبل قيمة NaN يتحول تلقائياً إلى float64. فإذا لاحظت بعد أي عملية دمج أن المعرفات كُتبت فجأة ملحقة بـ .0، فالسبب هو أن بعض الصفوف لم تجد شريكاً مطابقاً.
indicator=True — معرفة مصدر كل صف بدقة
يحتفظ الدمج الخارجي (outer merge) بكل شيء من كلا الطرفين. وعند إضافة الخيار indicator=True، يضيف بانداس عموداً باسم _merge يوضح لكل صف ما إذا كان مفتاحه قد وجد في كلا الجدولين (both)، أو في الجدول الأيسر فقط (left_only)، أو في الجدول الأيمن فقط (right_only):
audit = pd.merge(orders, products, on="product", how="outer", indicator=True)
print(audit[["order_id", "product", "cost", "_merge"]])
print(audit["_merge"].value_counts())order_id product cost _merge
0 1003.0 bag 600.0 both
1 1006.0 bag 600.0 both
2 1012.0 bag 600.0 both
3 1004.0 bottle 80.0 both
4 1008.0 bottle 80.0 both
5 1005.0 eraser 5.0 both
6 NaN marker 25.0 right_only
7 1002.0 notebook 40.0 both
8 1010.0 notebook 40.0 both
9 1001.0 pen 9.0 both
10 1007.0 pen 9.0 both
11 1009.0 pen 9.0 both
12 1011.0 stapler NaN left_only
_merge
both 11
left_only 1
right_only 1
Name: count, dtype: int64هذا هو تقرير التدقيق الفعلي لعملية الدمج. أحد عشر صفاً تطابقت؛ وطلب واحد ليس له صف منتج مقترن به (left_only: الدباسة)؛ ومنتج واحد لم يطلبه أحد (right_only: قلم التحديد). إنها المعلومات نفسها الناتجة عن الخطوة 5 في خطتنا، ولكن أُنتجت مباشرة عبر عملية الدمج ذاتها.
كما قام الدمج الخارجي أيضاً بترتيب الصفوف أبجدياً بحسب المفتاح — bag، ثم bottle، ثم eraser، وهكذا — بدلاً من الحفاظ على الترتيب الأصلي للطلبات. الدمج الداخلي والأيسر يحافظان على ترتيب الجدول الأيسر؛ أما الدمج الخارجي فلا يضمن ذلك. وإذا كان الترتيب مهماً بالنسبة لك، فرتب البيانات لاحقاً كما في الفصل السابع.
عمود _merge هو بيانات عادية يمكنك التصفية بناءً عليها. وحصر المشكلات لا يتطلب سوى سطر واحد:
problems = audit[audit["_merge"] != "both"]
print(problems[["order_id", "product", "_merge"]])order_id product _merge
6 NaN marker right_only
12 1011.0 stapler left_onlyقم بإجراء هذا النوع من التدقيق دائماً كلما دمجت بيانات لم تنشئها بنفسك. وعندما تكون قائمة المشكلات فارغة، يكون لديك دليل قاطع على توافق الملفين تماماً.
الخطر الصامت: المفاتيح المكررة (Duplicate Keys)
حتى الآن، كان لجدول products صف واحد فقط لكل منتج. لنفترض الآن أن فريق المشتريات أضاف مورداً ثانياً للأقلام بسعر أعلى قليلاً وألحق صفاً في نهاية الملف، بحيث أصبح pen مكرراً مرتين:
products_dup = pd.concat(
[products, pd.DataFrame({"product": ["pen"], "cost": [10.0], "supplier": ["Gamma"]})],
ignore_index=True,
)
print(products_dup["product"].is_unique)
exploded = pd.merge(march, products_dup, on="product", how="left")
print(march.shape, "->", exploded.shape)
print(exploded[["order_id", "product", "quantity", "cost", "supplier"]])False
(8, 8) -> (10, 10)
order_id product quantity cost supplier
0 1001 pen 12 9.0 Alpha
1 1001 pen 12 10.0 Gamma
2 1002 notebook 5 40.0 Alpha
3 1003 bag 2 600.0 Bravo
4 1004 bottle 7 80.0 Bravo
5 1005 eraser 30 5.0 Alpha
6 1006 bag 1 600.0 Bravo
7 1007 pen 20 9.0 Alpha
8 1007 pen 20 10.0 Gamma
9 1008 bottle 4 80.0 Bravoكان في شهر مارس 8 طلبات؛ لكن عملية الدمج أعادت 10 صفوف. ظهر الطلبان 1001 و 1007 — وهما طلبا الأقلام — مرتين لكل منهما، مرة لكل صف من صفي القلم في جدول المنتجات. لم يحدث أي خطأ في الكود. لكن إذا قمت بجمع الكمية الآن، فستُحسب الأقلام مرتين: 32 قلماً إضافياً لم يُبَع منها قلم واحد في الواقع.
print(march["quantity"].sum(), exploded["quantity"].sum())81 11381 في مقابل 113. يُسمى هذا تضاعف الصفوف غير المقصود (row explosion)، وهو أخطر أخطاء الدمج تكلفة لأن النتيجة تبدو طبيعية تماماً للعين المجردة. والقاعدة المنطقية خلف ذلك: يطابق الدمج كل صف أيسر متطابق مع كل صف أيمن متطابق. فإذا كانت العلاقة واحد-لواحد (one-to-one) أو متعدد-لواحد (many-to-one) يظل عدد الصفوف ثابتاً؛ أما وجود مفتاح مكرر في كلا الطرفين فيضاعف عدد الصفوف بالضرب.
عملية الدمج التي تزيد الصفوف لا تخبرك بذلك أبداً. والعَرَض الوحيد هو مجموع كلي أكبر من اللازم، والمجاميع الكبيرة نادراً ما تثير الشك بمجرد النظر. قارن len() قبل كل دمج وبعده — فهذا هو الفحص الحاسم الذي يكشف هذه المشكلة فوراً.validate= — دع بانداس يتحقق نيابةً عنك
يمكنك تحديد طبيعة العلاقة التي تتوقعها في الكود، وسيرفض بانداس إتمام الدمج إذا خالفت البيانات تلك العلاقة. تتشارك طلبات عديدة في المنتج نفسه، لذا يجب أن يكون هذا الدمج متعدد-لواحد (many-to-one):
try:
pd.merge(march, products_dup, on="product", how="left", validate="many_to_one")
except Exception as error:
print(type(error).__name__ + ":", error)MergeError: Merge keys are not unique in right dataset; not a many-to-one merge
Duplicates in right:
product
pen ...حصلنا على الخطأ MergeError، بل ويسرد الخطأ أيضاً المفتاح المتسبب في المشكلة. القيم الأخرى المتاحة هي "one_to_one" و "one_to_many" و "many_to_many". استخدام validate= لا يكلف شيئاً ويحول النتيجة الخاطئة الصامتة إلى خطأ واضح وصريح، لذا اجعله جزءاً من كل عملية دمج مع جداول البحث.
معالجة المشكلة: تحديد أي نسخة مكررة هي الصحيحة
يخبرك الخطأ البرمجي بوجود خطأ؛ لكن تحديد ما هو الصواب مهمتك أنت. عاين الصفوف المكررة أولاً:
print(products_dup[products_dup["product"].duplicated(keep=False)])product cost supplier
0 pen 9.0 Alpha
6 pen 10.0 Gammaيقوم الخيار duplicated(keep=False) بتحديد كل النسخ المكررة، وليس النسخة الثانية فقط. والآن حان وقت اتخاذ قرار يخص طبيعة العمل. فإذا كان الصف الأحدث يحل محل القديم، فاحتفظ بالنسخة الأخيرة:
products_clean = products_dup.drop_duplicates(subset="product", keep="last")
fixed = pd.merge(march, products_clean, on="product", how="left", validate="many_to_one")
print(fixed.shape)
print(fixed.loc[fixed["product"] == "pen", ["order_id", "cost", "supplier"]])(8, 10)
order_id cost supplier
0 1001 10.0 Gamma
6 1007 10.0 Gammaعدنا مجدداً إلى 8 صفوف، صف واحد لكل طلب بيع. أما إذا كان كلا الموردين يبيعان الأقلام بالفعل وكان الطلب يحدد أيهما، فإن عمود supplier ينبغي أن يكون موجوداً في جدول الطلبات أيضاً، ويصبح مفتاح الدمج عمودين معاً: on=["product", "supplier"]. الكود البرمجي لا يمكنه اتخاذ هذا القرار بمفرده؛ بل التخطيط وفهم مجال العمل هما من يقرران ذلك.
عندما تختلف أسماء أعمدة المفاتيح
يسمي ملف branches.csv عموده branch_name، بينما تسميه الطلبات branch. يحتاج المعامل on= إلى الاسم نفسه في كلا الجدولين، لذا حدد اسم كل طرف بشكل مستقل عبر left_on= و right_on=:
branches = pd.read_csv("branches.csv")
with_manager = pd.merge(orders, branches, left_on="branch", right_on="branch_name", how="left")
print(list(with_manager.columns))['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'month', 'branch_name', 'manager']يتم الإبقاء على كلا عمودي المفتاح، لأن بانداس لا يمكنه التخمين بأنك تعتبرهما عموداً واحداً. يحتوي العمودان branch و branch_name الآن على القيم نفسها بالضبط، لذا احذف أحدهما:
with_manager = with_manager.drop(columns="branch_name")
print(with_manager[["order_id", "branch", "manager"]].head(4))order_id branch manager
0 1001 north Mira
1 1002 south Omar
2 1003 north Mira
3 1004 east Lenaالبديل هو إعادة تسمية العمود قبل الدمج — branches.rename(columns={"branch_name": "branch"}) — ثم استخدام on="branch" العادية مباشرة. كلا الأسلوبين سليم؛ وإعادة التسمية المسبقة تحافظ على نظافة النتيجة دون الحاجة لخطوة حذف لاحقة.
عندما يحتوي كلا الجدولين على عمود بالاسم نفسه
إذا وُجد عمود غير المفتاح في كلا الجدولين بالاسم نفسه، فلن يتمكن بانداس من وضع عمودين باسم price في جدول واحد، ولذا يعيد تسميتهما تلقائياً بإضافة اللاحقتين _x (للجدول الأيسر) و _y (للجدول الأيمن). تخيل جدول أسعار معلنة يحتوي أيضاً على عمود باسم price:
list_prices = pd.DataFrame({"product": ["pen", "bag"], "price": [14.0, 800.0]})
compared = pd.merge(march, list_prices, on="product")
print(compared[["order_id", "product", "price_x", "price_y"]])order_id product price_x price_y
0 1001 pen 15.0 14.0
1 1003 bag 850.0 800.0
2 1006 bag 850.0 800.0
3 1007 pen 15.0 14.0تؤدي التسميتان price_x و price_y الغرض مؤقتاً، ولكن بعد بضعة أسابيع لن يتذكر أحد أيهما سعر البيع الفعلي وأيهما السعر المعلن. اختر لهما أسماء واضحة بنفسك باستخدام suffixes=:
compared = pd.merge(march, list_prices, on="product", suffixes=("_sold", "_list"))
print(compared[["order_id", "price_sold", "price_list"]])order_id price_sold price_list
0 1001 15.0 14.0
1 1003 850.0 800.0
2 1006 850.0 800.0
3 1007 15.0 14.0الآن تعبر الأعمدة عن معناها بدقة، ويصبح حساب الخصم الممنوح في كل طلب مجرد price_list - price_sold. (أي كود يطلب لاحقاً compared["price"] سيواجه الخطأ KeyError، لأنه بعد الدمج لم يعد هناك عمود يحمل هذا الاسم تحديداً — وهو سبب إضافي لاختيار الأسماء بوعي.)
join — الدمج عبر الفهرس بإيجاز
تمتلك كائنات DataFrame أيضاً الدالة .join(). وهي عبارة عن عملية دمج تستخدم فهرس (index) الجدول الأيمن كمفتاح لها — وتكون مفيدة جداً عندما يكون جدول البحث مفهرساً بالفعل بواسطة المفتاح المطلوب:
by_product = products.set_index("product")
joined = orders.join(by_product, on="product")
print(joined[["order_id", "product", "cost", "supplier"]].head(3))order_id product cost supplier
0 1001 pen 9.0 Alpha
1 1002 notebook 40.0 Alpha
2 1003 bag 600.0 Bravoتعتمد join افتراضياً على الدمج الأيسر how="left". وتؤدي الوظيفة نفسها التي تؤديها pd.merge(..., how="left")؛ إلا أن merge هي الأداة الأكثر شمولاً وعمومية، وهي المعتمدة في بقية هذا المسار التعليمي.
مثال تطبيقي متكامل
بالعودة إلى سؤال المالك الأصلي: ما هو الربح المحقق لكل مورّد على مدار شهري مارس وأبريل، مع احتساب كل طلب بيع بدقة؟
ملف supplier_profit.py:
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
products = pd.read_csv("products.csv")
# 1. Stack the two months, remembering where each row came from.
march["month"] = "March"
april["month"] = "April"
orders = pd.concat([march, april], ignore_index=True)
print("orders:", orders.shape)
# 2. The lookup key must be unique, or the merge will duplicate orders.
assert products["product"].is_unique
# 3. Attach cost and supplier to every order. Keep every order.
sales = pd.merge(
orders, products, on="product", how="left",
validate="many_to_one", indicator=True,
)
print("after merge:", sales.shape)
assert len(sales) == len(orders)
# 4. Report the orders that found no product row, then set them aside.
unmatched = sales[sales["_merge"] == "left_only"]
print("no cost on file:", unmatched["order_id"].tolist(), unmatched["product"].tolist())
sales = sales[sales["_merge"] == "both"].drop(columns="_merge")
# 5. Profit per row, then per supplier and month.
sales["profit"] = sales["quantity"] * (sales["price"] - sales["cost"])
report = (
sales.groupby(["supplier", "month"], as_index=False)["profit"]
.sum()
.sort_values(["supplier", "profit"], ascending=[True, False])
.reset_index(drop=True)
)
print()
print(report)
print()
print(sales.groupby("supplier")["profit"].sum().sort_values(ascending=False))orders: (12, 8)
after merge: (12, 11)
no cost on file: [1011] ['stapler']
supplier month profit
0 Alpha March 382.0
1 Alpha April 150.0
2 Bravo March 1190.0
3 Bravo April 250.0
supplier
Bravo 1440.0
Alpha 532.0
Name: profit, dtype: float64لماذا كُتب الكود بهذا الأسلوب:
- تُطبع الأبعاد (
shape) بعد كل خطوة دمج مباشرة. تؤكد النتيجةorders: (12, 8)نجاح الرصف الرأسي (8 + 4 صفوف، 7 أعمدة +month). وتؤكد النتيجةafter merge: (12, 11)عدم فقدان أو تكرار أي صف — مع إضافة 3 أعمدة جديدة:costوsupplierو_merge. فحص الأبعاد هو أسرع وأبسط برهان على أن كل خطوة قد حققت ما نصت عليه الخطة. - صيغت الافتراضات في صورة كود برمجي تنفيذي. التعليمتان
assert products["product"].is_uniqueوvalidate="many_to_one"تحميان معاً من خطر تضاعف الصفوف؛ وتضمنassert len(sales) == len(orders)صحة التوقع العددي من الخطة. وإذا أرسل شخص ما ملف منتجات الشهر القادم محتوياً على تكرار، سيتوقف البرنامج فوراً بدلاً من إخراج أرباح وهمية متضخمة. - احتفظ الخيار
how="left"بطلب الدباسة، وحدده الخيارindicator=Trueبالاسم. لم يتجاهل البرنامج الطلب بهدوء؛ بل طبع[1011] ['stapler']ليعلم المالك بوضوح أن إجمالي شهر أبريل ينقصه طلب بيع واحد حتى يحدد فريق المشتريات تكلفته. هذا السطر جزء لا يتجزأ من الإجابة وليس مجرد مخرجات تصحيح. - أُضيف عمود
monthقبل تطبيقconcat. لولا ذلك، لتطلب تقسيم التقرير شهرياً استخراج التواريخ وتحويلها — وهو أمر ممكن، لكنه مجهود إضافي للوصول إلى النتيجة نفسها. - حُسب الربح بعد الدمج مباشرة، لأنه يحتاج إلى أعمدة من كلا الملفين معاً. هذا هو التسلسل الطبيعي للعمليات: الدمج، ثم إضافة الأعمدة (الفصل التاسع)، ثم التجميع (الفصل الثامن)، ثم الترتيب (الفصل السابع).
النتيجة: حقق المورّد Bravo ربحاً قدره 1,440 وحقق Alpha ربحاً قدره 532، مع وجود طلب بيع واحد في أبريل لا يزال في انتظار تسجيل تكلفته. يبيع Bravo قطعاً أقل، ولكن الحقائب والزجاجات تحقق هامش ربح أعلى بكثير للقطعة الواحدة مقارنة بالأقلام والمحايات.
عندما تحدث الأخطاء وكيفية حلها
خطأ ValueError: You are trying to merge on int64 and str columns for key 'order_id'. If you wish to proceed you should use pd.concat
نوع بيانات المفتاح مختلف في كلا الطرفين. إليك ملف عمليات التوصيل الذي تم تصدير معرفات الطلبات فيه مسبوقة بالرمز #. احفظه باسم deliveries.csv — فسنستخدمه أيضاً في قسم "دورك الآن". (إذا كان لا يزال لديك سجل السائقين بالاسم نفسه من فصل البيانات المفقودة، فاستبدله بهذا الملف، فهذا ملف مختلف تماماً.)
order_id,status
#1001,delivered
#1002,delivered
#1003,returned
#1005,delivered
#1006,pendingimport pandas as pd
march = pd.read_csv("orders.csv")
deliveries = pd.read_csv("deliveries.csv")
print(march["order_id"].dtype, deliveries["order_id"].dtype)
try:
pd.merge(march, deliveries, on="order_id", how="left")
except ValueError as error:
print("ValueError:", error)int64 str
ValueError: You are trying to merge on int64 and str columns for key 'order_id'. If you wish to proceed you should use pd.concatالقيمة 1001 والقيمة "#1001" قيمتان مختلفتان تماماً، ويرفض بانداس التخمين بينهما. (أما التلميح الخاص بـ pd.concat فهو مخصص لحالة مختلفة تماماً؛ تجاهله). والحل هو جعل المفاتيح من النوع نفسه وبالنص نفسه — احذف الرمز #، ثم حول نوع البيانات:
deliveries["order_id"] = deliveries["order_id"].str.removeprefix("#").astype("int64")
status = pd.merge(march, deliveries, on="order_id", how="left")
print(status[["order_id", "product", "status"]])order_id product status
0 1001 pen delivered
1 1002 notebook delivered
2 1003 bag returned
3 1004 bottle NaN
4 1005 eraser delivered
5 1006 bag pending
6 1007 pen NaN
7 1008 bottle NaNتأخذ الطلبات التي ليس لها سجل توصيل القيمة NaN في عمود status — حيث يحافظ الدمج الأيسر عليها، وهو ما تريده تماماً عندما يكون السؤال المطروح: "ما الطلبات التي لم يتم توصيلها بعد؟"
خطأ MergeError: Merge keys are not unique in right dataset; not a many-to-one merge استخدمت المعامل validate="many_to_one" ولكن الجدول الأيمن يحتوي على مفتاح مكرر. تسرد الرسالة المفاتيح المكررة بالتفصيل. لا تحذف validate لإخفاء الخطأ — بل افحص الصفوف المكررة عبر duplicated(keep=False) وحدد أيها هو الصحيح، تماماً كما فعلنا في قسم تضاعف الصفوف.
خطأ KeyError: 'branch' العمود المحدد في on= غير موجود في أحد الجدولين:
branches = pd.read_csv("branches.csv")
try:
pd.merge(march, branches, on="branch")
except KeyError as error:
print("KeyError:", error)KeyError: 'branch'يسمي ملف branches هذا العمود باسم branch_name. اطبع list(df.columns) لكلا الجدولين؛ واستخدم left_on="branch", right_on="branch_name"، أو أعد تسمية أحد الجانبين. المسافة الزائدة في نهاية اسم العمود (مثل "branch ") تسبب الخطأ نفسه ولا تظهر بوضوح إلا عند عرض القائمة كأعمدة نصية.
اكتمل الدمج بنجاح، لكن جميع الأعمدة الجديدة قيمها NaN لا يوجد أي خطأ برمجي، ولكن كل صف فشل في العثور على شريك له. تبدو المفاتيح متطابقة لكنها ليست كذلك: مسافة في النهاية مثل "north "، أو حرف كبير مثل "South"، أو المعرف نفسه مخزن كنص في ملف ومسبوق ببادئة في ملف آخر. افحص التطابق باستخدام isin قبل الدمج:
branches_messy = pd.DataFrame({"branch_name": ["north ", "South", "east"], "manager": ["Mira", "Omar", "Lena"]})
print(march["branch"].isin(branches_messy["branch_name"]).sum(), "of", len(march), "orders match")
branches_messy["branch_name"] = branches_messy["branch_name"].str.strip().str.lower()
print(march["branch"].isin(branches_messy["branch_name"]).sum(), "of", len(march), "orders match")2 of 8 orders match
8 of 8 orders matchتطابق طلبان من أصل ثمانية قبل التنظيف، وتطابقت الطلبات الثمانية بالكامل بعده. نظف المفتاح بالطريقة نفسها في كلا الطرفين دائماً — عبر str.strip() وتوحيد حالة الأحرف — قبل الدمج.
زيادة عدد الصفوف بشكل غير متوقع بعد الدمج يحتوي الجدول الأيمن على مفتاح مكرر؛ راجع قسم "الخطر الصامت" أعلاه. قارن len() قبل كل دمج وبعده، واستخدم المعامل validate=.
اختفاء بعض الصفوف بعد الدمج استخدمت الدمج الافتراضي how="inner"، ولم يكن لبعض المفاتيح شريك مقابل. استخدم how="left" مع وضع الجدول الأساسي في الطرف الأيسر، واستعن بـ indicator=True لمعرفة الصفوف التي لم تتطابق.
خطأ TypeError: concat() takes 1 positional argument but 2 were given
try:
pd.concat(march, deliveries)
except TypeError as error:
print("TypeError:", error)TypeError: concat() takes 1 positional argument but 2 were givenتستقبل الدالة concat وسيطاً واحداً فقط، وهو عبارة عن قائمة من الجداول. اكتب pd.concat([march, april]) — فالأقواس المعقوفة [] هي النقطة الأساسية هنا.
تكرار الفهرس بعد concat، ودالة loc تعيد صفين احتفظ كل جدول مدخل بفهرسه الأصلي 0, 1, 2, …. أضف المعامل ignore_index=True.
Step 4 of 6 — Predict
Check your understanding
يحتوي شهر مارس على 8 طلبات وأبريل على 4. ما الذي سيُطبع؟
from io import StringIO
import pandas as pd
# Stand in for orders.csv, orders_april.csv and products.csv,
# so this snippet runs on its own.
MARCH = """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
"""
APRIL = """order_id,date,branch,product,category,quantity,price
1009,2024-04-01,north,pen,stationery,15,15.0
1010,2024-04-01,east,notebook,stationery,3,60.0
1011,2024-04-02,south,stapler,stationery,6,95.0
1012,2024-04-02,north,bag,accessories,1,850.0
"""
PRODUCTS = """product,cost,supplier
pen,9.0,Alpha
notebook,40.0,Alpha
bag,600.0,Bravo
bottle,80.0,Bravo
eraser,5.0,Alpha
marker,25.0,Alpha
"""
march = pd.read_csv(StringIO(MARCH))
april = pd.read_csv(StringIO(APRIL))
products = pd.read_csv(StringIO(PRODUCTS))
both = pd.concat([march, april])
print(both.shape, both.index[-1])- A(12, 7) 11
- B(12, 7) 3
- C(12, 14) 3
- D(8, 7) 7
أضاف شخص ما صفاً ثانياً للمنتج pen في قائمة المنتجات. ما الذي سيُطبع؟
from io import StringIO
import pandas as pd
# Stand in for orders.csv, orders_april.csv and products.csv,
# so this snippet runs on its own.
MARCH = """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
"""
APRIL = """order_id,date,branch,product,category,quantity,price
1009,2024-04-01,north,pen,stationery,15,15.0
1010,2024-04-01,east,notebook,stationery,3,60.0
1011,2024-04-02,south,stapler,stationery,6,95.0
1012,2024-04-02,north,bag,accessories,1,850.0
"""
PRODUCTS = """product,cost,supplier
pen,9.0,Alpha
notebook,40.0,Alpha
bag,600.0,Bravo
bottle,80.0,Bravo
eraser,5.0,Alpha
marker,25.0,Alpha
"""
march = pd.read_csv(StringIO(MARCH))
april = pd.read_csv(StringIO(APRIL))
products = pd.read_csv(StringIO(PRODUCTS))
products = pd.concat([products, pd.DataFrame({"product": ["pen"], "cost": [10.0], "supplier": ["Charlie"]})])
report = pd.merge(march, products, on="product", how="left")
print(len(march), len(report))- A8 8
- B8 6
- C8 10 — تطابق كل طلب لـ pen مع كلا صفي pen وتضاعف، دون أي خطأ
- Dخطأ `MergeError` بشأن المفاتيح المكررة
يحتاج كل طلب في شهر مارس إلى معرفة تكلفة منتجه، ولا يجب أن يُفقد أي طلب حتى لو كان المنتج غير موجود في القائمة. أي خيار لـ how= ستستخدم؟
- A`how="inner"`
- B`how="right"`
- C`how="outer"`
- D`how="left"`، مع وضع جدول الطلبات كجدول أيسر
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.
دورك الآن
أرسل فريق التوصيل ملف deliveries.csv (الموضح في قسم "عندما تحدث الأخطاء") — ويحتوي على صف واحد لكل طلب لديهم سجل بشأنه، مع كتابة معرفات الطلبات بالصيغة #1001. ويطلب المالك الآن تقرير توصيل شاملاً لشهري مارس وأبريل معاً.
اكتب ملف delivery_report.py بحيث:
- يتم الاحتفاظ بكل طلب من كلا الشهرين — صف واحد بالضبط لكل طلب بيع، مع التحقق من عدد الصفوف باستخدام
assert، وحماية الدمج باستخدامvalidate=وتدقيقه باستخدامindicator=True. - تتطابق المفاتيح فعلياً وبدقة — تنظيف مفتاح التوصيل ليصبح من نوع
order_idنفسه، وإيقاف البرنامج فوراً إذا ظهر أي طلب مرتين في ملف التوصيل. - غياب السجل يعني "لا يوجد سجل" صراحة — تأخذ الطلبات التي ليس لها صف توصيل الحالة
"no record"، بدلاً من ترك قيمةNaNصامتة. - تُطبع المخرجات بهذا الترتيب الدقيق: أبعاد الرصف الرأسي، وأبعاد الدمج، وتكرارات عمود
_merge، وعدد الطلبات لكل حالة، والطلبات التي لا تملك سجلاً (order_idوbranchوproduct، مرتبة حسبorder_id)، وأخيراً عدد الطلبات المسلمة لكل فرع.
عندما يكون الحل صحيحاً، ستكون المخرجات مطابقة تماماً لما يلي:
orders: (12, 7)
after merge: (12, 9)
_merge
left_only 7
both 5
right_only 0
Name: count, dtype: int64
status
no record 7
delivered 3
returned 1
pending 1
Name: count, dtype: int64
order_id branch product
3 1004 east bottle
6 1007 east pen
7 1008 north bottle
8 1009 north pen
9 1010 east notebook
10 1011 south stapler
11 1012 north bag
branch
north 2
south 1
dtype: int64قبل كتابة أي سطر من الكود، فسر كل رقم في هذه المخرجات بالاعتماد على فحص الملفات وحدها: لماذا 12 صفاً بعد الرصف، ولماذا بقيت 12 بعد الدمج، ولماذا 7 طلبات بدون سجل. إذا لم تستطع تفسير ذلك، فهذا يعني أن خطتك لم تكتمل بعد.
ثم تعمد كسر الكود مرتين:
- احذف
.str.removeprefix("#"). ما الخطأ الذي يظهر، وفي أي سطر يحدث؟ - أضف صفاً ثانياً لـ
#1003في ملفdeliveries.csv(مثل#1003,delivered). ماذا سيفعل المعاملvalidate=الآن؟ وماذا كانت النتيجة لتكون بدونه؟
الحل
الخطة. السؤال المطلوب: "لكل طلب في شهري مارس وأبريل، ما هي حالة توصيله؟" — إذن تحتوي النتيجة على صف واحد لكل طلب، أي 12 صفاً في المجمل. يمتلك شهرا مارس وأبريل الأعمدة نفسها: نقوم برصفهما باستخدام concat مع ignore_index=True فينتج (8 + 4 = 12). ملف التوصيل مفهرس بمعرف الطلب، ولكنه مخزن كنص مسبوق بـ #، لذا يحتاج المفتاح إلى تنظيف قبل مطابقته مع المعرف العددي order_id. ويجب أن يظهر كل طلب مرة واحدة كحد أقصى، لذا فالعلاقة هي واحد-لواحد (one-to-one) (لكل طلب صف توصيل واحد على الأكثر، ولكل صف توصيل طلب واحد). ويجب ألا يُفقد أي طلب، لذا تأتي الطلبات في الطرف الأيسر ويكون نوع الدمج how="left". توجد خمسة صفوف توصيل وجميعها تخص شهر مارس، لذا ينبغي أن يتبقى 12 − 5 = 7 طلبات بدون أي سجل توصيل.
ملف delivery_report.py:
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
deliveries = pd.read_csv("deliveries.csv")
# 1. Stack the months.
orders = pd.concat([march, april], ignore_index=True)
print("orders:", orders.shape)
# 2. Clean the key, then check it.
deliveries["order_id"] = deliveries["order_id"].str.removeprefix("#").astype("int64")
assert deliveries["order_id"].is_unique, "an order appears twice in deliveries.csv"
# 3. Keep every order.
report = pd.merge(
orders, deliveries, on="order_id", how="left",
validate="one_to_one", indicator=True,
)
print("after merge:", report.shape)
assert len(report) == len(orders)
print(report["_merge"].value_counts())
# 4. Missing status means the delivery team has no record.
report["status"] = report["status"].fillna("no record")
# 5. Orders per status.
print()
print(report["status"].value_counts())
# 6. Orders with no record.
missing = report.loc[report["status"] == "no record", ["order_id", "branch", "product"]]
print()
print(missing.sort_values("order_id"))
# 7. Delivered orders per branch.
delivered = report[report["status"] == "delivered"]
print()
print(delivered.groupby("branch").size())orders: (12, 7)
after merge: (12, 9)
_merge
left_only 7
both 5
right_only 0
Name: count, dtype: int64
status
no record 7
delivered 3
returned 1
pending 1
Name: count, dtype: int64
order_id branch product
3 1004 east bottle
6 1007 east pen
7 1008 north bottle
8 1009 north pen
9 1010 east notebook
10 1011 south stapler
11 1012 north bag
branch
north 2
south 1
dtype: int64تحقق كل توقع بدقة: 12 صفاً بعد الرصف، و 12 بعد الدمج، و 7 بدون سجل توصيل. وتؤكد تكرارات _merge ذلك بشكل مستقل — 5 في both، و 7 في left_only، و 0 في right_only (الدمج الأيسر لا يُنتج right_only أبداً).
ملاحظات حول القرارات المتخذة:
- استخدام
validate="one_to_one"بدلاً من"many_to_one". يمتلك جدول الطلبات أيضاً معرفات طلبات فريدة، لذا فكلا الطرفين فريد. وهذا الفحص الأكثر صرامة يكتشف أيضاً أي تكرار لمعرف طلب في جدول الطلبات — كأن يكرر ملف شهر أبريل طلباً من شهر مارس بالخطأ. - وضع
assertللتحقق منis_uniqueقبل الدمج، مع رسالة توضيحية، حتى يوضح سبب الفشل بكلمات مفهومة ومباشرة بدلاً من مصطلحات الدمج الغامضة. - تطبيق
fillna("no record")بعد الدمج دائماً، وليس قبله أبداً: فالحالات المفقودة لا تظهر إلا بعد الإبقاء على الطلبات التي لا تملك صف توصيل بواسطة الدمج الأيسر. - الفرع الشرقي (east) ليس لديه أي طلبات مسلمة، لذا لا يظهر في الجدول الأخير إطلاقاً. التجميع يعيد فقط المجموعات الموجودة في البيانات — وإذا كان المالك بحاجة لرؤية 0 للفرع الشرقي، فهذا يتطلب دمج قائمة الفروع مع هذه النتيجة عبر
how="left"واستخدامfillna(0).
تجربتا التعطيل العمدي. بدون removeprefix، يفشل سطر astype("int64") أولاً بالخطأ ValueError: invalid literal for int() with base 10: '#1001'، لأن النص "#1001" لا يمكن تحويله لرقم — ويشير الخطأ إلى سطر التنظيف وهو المكان الذي يجب تصحيحه فعلياً. وإذا حذفت astype أيضاً، فستطلق دالة الدمج نفسها الخطأ ValueError: You are trying to merge on int64 and str columns for key 'order_id'. وبإضافة صف ثانٍ لـ #1003، توقف تعليمة assert البرنامج بالخطأ AssertionError: an order appears twice in deliveries.csv؛ وإذا حذفت الـ assert، ستطلق validate="one_to_one" الخطأ MergeError: Merge keys are not unique in right dataset; not a one-to-one merge. ودون أي من هذين الحصنين، كان التقرير سيعرض 13 صفاً لـ 12 طلباً ويحسب الطلب 1003 مرتين — مرة بحالة returned ومرة بحالة delivered — وكان كل إجمالي تحته سيصبح خاطئاً بصمت.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz