الوحدة 4 · التحليل: التجميع والدمج وإعادة التشكيل
الترتيب والتقسيم إلى فئات
16 دقراءة 3 دتمرين Python
الدرس في لمحة
رتّب العملاء بحسب الإنفاق، وقِس مدى تركّز الإيراد، وحوّل رقمًا متصلًا إلى شرائح باستخدام cut (حدود ثابتة) و qcut (مجموعات متساوية الحجم).
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
“مَن أفضل عملائنا؟” سؤال يطرحه كل فريق مبيعات أو تسويق، والإجابة الصادقة لها جزءان: قائمة مرتّبة، وكم يهم أعلى تلك القائمة. في Cartwheel الإجابة لافتة، وهي تشكّل طريقة قراءتك لكل رقم إيراد، ومنها قفزة الربع الرابع.
صف لكل عميل
ابدأ بجدول على مستوى العميل: صف لكل عميل، مع الطلبات والإنفاق، مبني بالتجميع (groupby) من الدرس 4.1.
import pandas as pd
items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
cust = orders.groupby("customer_id").agg(orders=("order_id", "count"), spend=("revenue", "sum"))
print(len(cust), "customers with a non-cancelled order")
print(cust["spend"].describe().round(0))503 عملاء. بلغ الوسيط 551 دولارًا، والمتوسط 888، والأعلى 5,370. حين يقع المتوسط فوق الوسيط بكثير، فهناك قيم كبيرة قليلة تسحبه إلى الأعلى؛ ويعود الدرس 5.1 إلى ما يعنيه ذلك عند الإبلاغ عن المتوسطات.
الترتيب
import pandas as pd
items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
cust = orders.groupby("customer_id").agg(orders=("order_id", "count"), spend=("revenue", "sum"))
cust["rank"] = cust["spend"].rank(ascending=False, method="min")
cust["percentile"] = cust["spend"].rank(pct=True)
print(cust.sort_values("rank").head(5).round(2))
top20 = cust.nlargest(int(len(cust) * 0.2), "spend")
print(len(top20), "customers =", round(top20["spend"].sum() / cust["spend"].sum(), 3), "of revenue")تعطي rank كل صف مرتبته، و1 لأعلى إنفاق بسبب ascending=False. والتعادل هو حيث تختلف الطرق: "min" تعطي العملاء المتعادلين المرتبة نفسها وتقفز فوق الأرقام التالية، مثل جدول الدوري، و"dense" لا تقفز أبدًا، و"first" تكسر التعادل بحسب ترتيب الصفوف، والقيمة الافتراضية "average" تعطيهم متوسط المراتب. وتحوّل pct=True المراتب إلى مئينات بين 0 و1، تصلح للمقارنة بين مجموعات مختلفة الأحجام.
السطر الأخير هو رقم التركّز: أعلى 100 عميل، أي خُمس القاعدة، يحقّقون 55.5% من الإيراد. هذا أقل تطرّفًا من قاعدة 80/20 المعروفة في الكتب، لكنه ما زال يعني أن خسارة حفنة من العملاء الكبار ستؤلم أكثر من خسارة مئة عميل صغير.
الترتيب داخل المجموعات
مديرو الدول يريدون أفضل عملائهم هم، لا قائمة عالمية تزاحم فيها الأسواق الكبرى الجميع. تعمل rank على عمود مجمَّع أيضًا، فتبدأ من 1 من جديد داخل كل مجموعة:
import pandas as pd
items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
customers = pd.read_csv("customers.csv")
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
cust = orders.groupby("customer_id", as_index=False).agg(spend=("revenue", "sum"))
cust = cust.merge(customers[["customer_id", "country"]], on="customer_id", validate="one_to_one")
cust["rank_in_country"] = cust.groupby("country")["spend"].rank(ascending=False, method="first")
print(cust[cust["rank_in_country"] == 1].sort_values("spend", ascending=False).round(0))التصفية على rank_in_country == 1 تحتفظ بأعلى منفق في كل دولة، ثمانية صفوف إجمالًا؛ والاحتفاظ بالمراتب 3 أو أقل سيعطي قائمة أعلى ثلاثة لكل دولة. تضمن method="first" صفًا واحدًا بالضبط لكل مرتبة حتى حين يتعادل عميلان، وهذا ما تريده لقائمة ثابتة الطول. ونمط “رتّب داخل المجموعة ثم صفِّ” هذا يجيب عن عائلة كاملة من الأسئلة: المنتج الأكثر مبيعًا في كل فئة، وأول طلب لكل عميل، وآخر تذكرة لكل طلب.
cut: فئات بحدود تختارها أنت
يريد التسويق نطاقات إنفاق يستطيع تسميتها في حملة. أنت تختار الحدود:
import numpy as np
import pandas as pd
items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
cust = orders.groupby("customer_id").agg(orders=("order_id", "count"), spend=("revenue", "sum"))
cust["band"] = pd.cut(cust["spend"], bins=[0, 250, 500, 1000, 2000, np.inf],
labels=["<250", "250-500", "500-1k", "1k-2k", "2k+"])
print(cust.groupby("band", observed=True)["spend"].agg(["count", "sum"]).round(0))كل زوج من الحدود يعرّف فئة، مغلقة من اليمين افتراضيًا: (250, 500] تشمل 500 ولا تشمل 250. والعملاء الـ 57 في النطاق 2k+، أي 11% من القاعدة، جلبوا 173,407 دولارات، أكثر من أي نطاق آخر. تعيد cut عمودًا فئويًا (categorical) تحافظ فئاته على ترتيبك، فيسرد الجدول النطاقات من الأصغر إلى الأكبر لا أبجديًا.
qcut: مجموعات متساوية الحجم
حين تريد مجموعات متساوية الحجم بدلًا من حدود ثابتة، تقطع qcut عند المئينات (quantiles):
import pandas as pd
items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
cust = orders.groupby("customer_id").agg(orders=("order_id", "count"), spend=("revenue", "sum"))
cust["quartile"] = pd.qcut(cust["spend"], q=4, labels=["Q1 low", "Q2", "Q3", "Q4 high"])
share = cust.groupby("quartile", observed=True)["spend"].sum() / cust["spend"].sum()
print(share.round(3))
print(pd.qcut(cust["orders"], q=4, duplicates="drop").value_counts().sort_index())الرُّبيع الأعلى من العملاء يجلب 63.2% من الإيراد، والرُّبيع الأدنى 2.8%. ويبيّن السطر الأخير حدود qcut: ثلث العملاء قدّموا طلبًا واحدًا بالضبط، فيكون أدنى حدّين للرُّبيعات كلاهما 1، وتُطلق qcut العادية الخطأ ValueError: Bin edges must be unique. تدمج duplicates="drop" الحدود المكرّرة، فتبقى مجموعات أقل وغير متساوية. وللأعداد التي فيها تعادلات كثيرة، تكون cut مع حدود تختارها بيدك أوضح عادةً.
في التمرين تقسّم العملاء إلى شرائح بنفسك. وبهذا تكتمل عُدّة التحليل؛ ويوجّهها القسم 5 نحو أسئلة حقيقية، بدءًا بما يستطيع المتوسط أن يخبرك به وما لا يستطيع.
أهم النقاط
rank(ascending=False, method="min")ترقّم الصفوف بحسب القيمة؛ وmethodتحدّد طريقة ترقيم المتعادلين، وpct=Trueتعطي ترتيبًا مئينيًا.- تضع
pd.cutالقيم في فئات بحدود تختارها أنت؛ استخدمnp.infحدًّا أخيرًا كي لا تقع أي قيمة خارجها. - تصنع
pd.qcutفئات فيها أعداد متساوية (تقريبًا) من الصفوف، مثل الرُّبيعات (quartiles)؛ وحين تتعادل قيم كثيرة، مرّرduplicates="drop". - كلتاهما تعيد عمودًا فئويًا (categorical)، فجمّع بحسبه مع
observed=Trueورتّب بحسب ترتيب الفئات لا أبجديًا. - رقم التركّز، مثل “أعلى 20% من العملاء يجلبون 55% من الإيراد”، كثيرًا ما يكون أنفع رقم مفرد تستطيع تقديمه للعمل.