الفصل 10

دمج كائنات DataFrame — الرصف باستخدام concat والمطابقة باستخدام merge

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

50 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 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 — طلبات شهر مارس، وهو الملف نفسه المستخدم في الفصول السابقة:

text
order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0

orders_april.csv — طلبات شهر أبريل، بالأعمدة نفسها. لاحظ وجود الدباسة (stapler): بدأ المتجر في بيعها خلال شهر أبريل.

text
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.csv — صف واحد لكل منتج، يحتفظ به فريق المشتريات. هناك أمران "ناقصان" هنا عمداً، كما يحدث في بيئات العمل الحقيقية: لم تُضَف الدباسة الجديدة بعد إلى السجل، وهناك قلم تحديد (marker) لم يبعه المتجر قط.

text
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

branches.csv — المسؤول عن إدارة كل فرع من فروع المتجر الثلاثة. سُمي العمود branch_name بدلاً من branch، لأن شخصاً آخر هو من أنشأ هذا الملف.

text
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) كل جدول لتتوقع أبعاد النتيجة بدقة.

python
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))
text
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) نفسه في كلا الطرفين. فالمفتاح الذي يُمثَّل كرقم في جدول وكنص في جدول آخر يستحيل أن يتطابق:

python
print(march["product"].dtype, products["product"].dtype)
text
str str

كلاهما من النوع str. إذن يمكن مقارنتهما.

4. تحقق من أن المفتاح فريد في جدول البحث (Lookup)

هذا هو الفحص الأكثر أهمية في الفصل بأكمله. يحتاج كل طلب بيع إلى تكلفة واحدة فقط. فإذا تكرر pen مرتين في products.csv، سيتطابق كل طلب لقلم مرتين وسيظهر مرتين في النتيجة النهائية.

python
print(products["product"].is_unique)
print(products["product"].duplicated().sum())
text
True
0

القيمة is_unique هي True ولا توجد أي قيم مكررة (0): صف واحد لكل منتج. يمكن لكل طلب أن يتطابق مع صف منتج واحد على الأكثر.

5. تحقق من المفاتيح التي لن تجد تطابقاً

قبل الدمج، اسأل نفسك: هل توجد طلبات لا وجود لمنتجاتها في products.csv، وهل توجد منتجات لم يطلبها أحد؟ الدالة isin (من فصل التصفية) تجيب عن هذين السؤالين معاً:

python
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())
text
order_id  product
10      1011  stapler
['marker']

هناك طلب واحد — الدباسة، 1011 — ليس له تكلفة مسجلة. وهناك منتج واحد، قلم التحديد (marker)، لم يُبَع قط. الآن صرت تعرف قبل الدمج ما الذي سيتعين على عملية الدمج التعامل معه.

6. توقع النتيجة، ثم اتخذ القرار

دوّن توقعك في صورة أرقام واضحة:

  • يحتوي جدول orders على 8 + 4 = 12 صفاً.
  • يحتوي جدول products على صف واحد لكل منتج، لذا لن تتضاعف الطلبات بسبب المطابقة.
  • إذا احتفظنا بجميع الطلبات، فسيحتوي الجدول المدمج على 12 صفاً، وسيكون أحدها (الدباسة) بدون تكلفة.
  • أما إذا احتفظنا بالطلبات المتطابقة فقط، فسيحتوي على 11 صفاً.

أيهما تختار؟ في تقرير الأرباح، يؤدي حذف طلب الدباسة بصمت إلى التقليل من إجمالي المبيعات الحقيقية. الأفضل هو الإبقاء عليه، ورؤية أن تكلفته مفقودة، ثم اتخاذ القرار المناسب — وهو الخيار الذي يحدده المعامل how=. وبعد إتمام الدمج، أول شيء تطبعه هو الأبعاد shape، وتقارنها بهذا التوقع.

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


رصف الصفوف رأسياً باستخدام pd.concat

تستقبل الدالة pd.concat قائمة من الجداول وترصفها فوق بعضها رأسياً:

python
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"]])
text
(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 تظهر مرتين. وهذه ليست مجرد مشكلة شكلية:

python
print(both.loc[0, ["order_id", "date"]])
text
order_id        date
0      1001  2024-03-01
0      1009  2024-04-01

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

python
print(both.index.is_unique)
text
False

ignore_index=True — ترقيم الصفوف من جديد

عندما يكون الفهرس القديم مجرد عداد للصفوف (أي RangeIndex كما في جميع ملفات read_csv السابقة)، فإنه لا يحمل أي دلالة خاصة. تخلص منه ودع concat ترقم النتيجة من الصفر بتسلسل جديد:

python
orders = pd.concat([march, april], ignore_index=True)
print(orders.index.is_unique)
print(orders[["order_id", "date", "product"]].tail(5))
text
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") حيث تكون معرفات الطلبات فريدة بالفعل).

تتبع مصدر كل صف

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

python
march["month"] = "March"
april["month"] = "April"
orders = pd.concat([march, april], ignore_index=True)

print(orders["month"].value_counts())
text
month
March    8
April    4
Name: count, dtype: int64

يعمل هذا العد كوسيلة تحقق مزدوجة: 8 + 4 = 12، لم يُفقد أي صف.

عندما لا تتطابق الأعمدة

تطابق دالة concat الأعمدة بحسب أسمائها. وإذا وجد عمود في بعض الجداول دون غيرها، فإنه سيظل يظهر في النتيجة النهائية — وستأخذ صفوف الجداول التي تفتقر إليه القيمة NaN في ذلك العمود. لنفترض أن هناك طلباً استثنائياً أُدخل يدوياً، يحتوي على عمود الخصم discount ويفتقر إلى date و product و category و price:

python
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())
text
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 جدولين ومفتاحاً مشتركاً. ولكل صف في الجدول الأيسر، تبحث عن الصفوف المقابلة له في الجدول الأيمن التي تحمل قيمة المفتاح نفسها، وتدمج أعمدتها معاً:

python
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"]])
text
(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)، مع وضع الطلبات في الطرف الأيسر:

python
merged = pd.merge(orders, products, on="product", how="left")
print(merged.shape)
print(merged[["order_id", "product", "cost", "supplier"]])
text
(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) فهو الصورة المعكوسة — يتم الإبقاء على كل منتج، ويظهر قلم التحديد بدون أي طلب بيع:

python
right = pd.merge(orders, products, on="product", how="right")
print(right.shape)
print(right[["order_id", "product", "cost"]].tail(3))
text
(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):

python
audit = pd.merge(orders, products, on="product", how="outer", indicator=True)
print(audit[["order_id", "product", "cost", "_merge"]])
print(audit["_merge"].value_counts())
text
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 هو بيانات عادية يمكنك التصفية بناءً عليها. وحصر المشكلات لا يتطلب سوى سطر واحد:

python
problems = audit[audit["_merge"] != "both"]
print(problems[["order_id", "product", "_merge"]])
text
order_id  product      _merge
6        NaN   marker  right_only
12    1011.0  stapler   left_only

قم بإجراء هذا النوع من التدقيق دائماً كلما دمجت بيانات لم تنشئها بنفسك. وعندما تكون قائمة المشكلات فارغة، يكون لديك دليل قاطع على توافق الملفين تماماً.


الخطر الصامت: المفاتيح المكررة (Duplicate Keys)

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

python
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"]])
text
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 قلماً إضافياً لم يُبَع منها قلم واحد في الواقع.

python
print(march["quantity"].sum(), exploded["quantity"].sum())
text
81 113

81 في مقابل 113. يُسمى هذا تضاعف الصفوف غير المقصود (row explosion)، وهو أخطر أخطاء الدمج تكلفة لأن النتيجة تبدو طبيعية تماماً للعين المجردة. والقاعدة المنطقية خلف ذلك: يطابق الدمج كل صف أيسر متطابق مع كل صف أيمن متطابق. فإذا كانت العلاقة واحد-لواحد (one-to-one) أو متعدد-لواحد (many-to-one) يظل عدد الصفوف ثابتاً؛ أما وجود مفتاح مكرر في كلا الطرفين فيضاعف عدد الصفوف بالضرب.

عملية الدمج التي تزيد الصفوف لا تخبرك بذلك أبداً. والعَرَض الوحيد هو مجموع كلي أكبر من اللازم، والمجاميع الكبيرة نادراً ما تثير الشك بمجرد النظر. قارن len() قبل كل دمج وبعده — فهذا هو الفحص الحاسم الذي يكشف هذه المشكلة فوراً.

validate= — دع بانداس يتحقق نيابةً عنك

يمكنك تحديد طبيعة العلاقة التي تتوقعها في الكود، وسيرفض بانداس إتمام الدمج إذا خالفت البيانات تلك العلاقة. تتشارك طلبات عديدة في المنتج نفسه، لذا يجب أن يكون هذا الدمج متعدد-لواحد (many-to-one):

python
try:
    pd.merge(march, products_dup, on="product", how="left", validate="many_to_one")
except Exception as error:
    print(type(error).__name__ + ":", error)
text
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= لا يكلف شيئاً ويحول النتيجة الخاطئة الصامتة إلى خطأ واضح وصريح، لذا اجعله جزءاً من كل عملية دمج مع جداول البحث.

معالجة المشكلة: تحديد أي نسخة مكررة هي الصحيحة

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

python
print(products_dup[products_dup["product"].duplicated(keep=False)])
text
product  cost supplier
0     pen   9.0    Alpha
6     pen  10.0    Gamma

يقوم الخيار duplicated(keep=False) بتحديد كل النسخ المكررة، وليس النسخة الثانية فقط. والآن حان وقت اتخاذ قرار يخص طبيعة العمل. فإذا كان الصف الأحدث يحل محل القديم، فاحتفظ بالنسخة الأخيرة:

python
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"]])
text
(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=:

python
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))
text
['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'month', 'branch_name', 'manager']

يتم الإبقاء على كلا عمودي المفتاح، لأن بانداس لا يمكنه التخمين بأنك تعتبرهما عموداً واحداً. يحتوي العمودان branch و branch_name الآن على القيم نفسها بالضبط، لذا احذف أحدهما:

python
with_manager = with_manager.drop(columns="branch_name")
print(with_manager[["order_id", "branch", "manager"]].head(4))
text
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:

python
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"]])
text
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=:

python
compared = pd.merge(march, list_prices, on="product", suffixes=("_sold", "_list"))
print(compared[["order_id", "price_sold", "price_list"]])
text
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) الجدول الأيمن كمفتاح لها — وتكون مفيدة جداً عندما يكون جدول البحث مفهرساً بالفعل بواسطة المفتاح المطلوب:

python
by_product = products.set_index("product")

joined = orders.join(by_product, on="product")
print(joined[["order_id", "product", "cost", "supplier"]].head(3))
text
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:

python
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))
text
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 — فسنستخدمه أيضاً في قسم "دورك الآن". (إذا كان لا يزال لديك سجل السائقين بالاسم نفسه من فصل البيانات المفقودة، فاستبدله بهذا الملف، فهذا ملف مختلف تماماً.)

text
order_id,status
#1001,delivered
#1002,delivered
#1003,returned
#1005,delivered
#1006,pending
python
import 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)
text
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 فهو مخصص لحالة مختلفة تماماً؛ تجاهله). والحل هو جعل المفاتيح من النوع نفسه وبالنص نفسه — احذف الرمز #، ثم حول نوع البيانات:

python
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"]])
text
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= غير موجود في أحد الجدولين:

python
branches = pd.read_csv("branches.csv")

try:
    pd.merge(march, branches, on="branch")
except KeyError as error:
    print("KeyError:", error)
text
KeyError: 'branch'

يسمي ملف branches هذا العمود باسم branch_name. اطبع list(df.columns) لكلا الجدولين؛ واستخدم left_on="branch", right_on="branch_name"، أو أعد تسمية أحد الجانبين. المسافة الزائدة في نهاية اسم العمود (مثل "branch ") تسبب الخطأ نفسه ولا تظهر بوضوح إلا عند عرض القائمة كأعمدة نصية.

اكتمل الدمج بنجاح، لكن جميع الأعمدة الجديدة قيمها NaN لا يوجد أي خطأ برمجي، ولكن كل صف فشل في العثور على شريك له. تبدو المفاتيح متطابقة لكنها ليست كذلك: مسافة في النهاية مثل "north "، أو حرف كبير مثل "South"، أو المعرف نفسه مخزن كنص في ملف ومسبوق ببادئة في ملف آخر. افحص التطابق باستخدام isin قبل الدمج:

python
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")
text
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

python
try:
    pd.concat(march, deliveries)
except TypeError as error:
    print("TypeError:", error)
text
TypeError: concat() takes 1 positional argument but 2 were given

تستقبل الدالة concat وسيطاً واحداً فقط، وهو عبارة عن قائمة من الجداول. اكتب pd.concat([march, april]) — فالأقواس المعقوفة [] هي النقطة الأساسية هنا.

تكرار الفهرس بعد concat، ودالة loc تعيد صفين احتفظ كل جدول مدخل بفهرسه الأصلي 0, 1, 2, …. أضف المعامل ignore_index=True.