الفصل 06

البيانات المفقودة — العثور على الفجوات وتحديد معناها

العثور على كل فجوة في الجدول، بما في ذلك القيم المخفية كنصوص مثل - أو ؟، وفهم كيف يغير NaN عمليات الجمع والمتوسطات والأنواع، واتخاذ قرار مدروس عموداً بعمود — بحذف الفجوة أو ملئها أو الإبقاء عليها.

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

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

تم إدخال طلبات المتجر للأسبوع الأول من شهر مارس يدوياً في ثلاثة فروع، وتصديرها في ملف باسم orders_raw.csv. ويطلب مالك المتجر معرفة رقمين بحلول المساء: كم عدد الوحدات التي بيعت، وكم من المال تم تحصيله.

تقوم بقراءة الملف، وبدافع العادة من الفصول السابقة، تبدأ بحساب مجموع عمود السعر أولاً لمعرفة حجم المبيعات تقريبياً:

python
import pandas as pd

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

print(orders["price"].sum())
text
15.060.0850.08.0-15.0120.0

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

python
print(orders["quantity"].sum())
print(orders["quantity"].mean())
text
76.0
10.857142857142858

يبدو هذا المخرج سليماً للوهلة الأولى، وذلك تحديداً ما يجعله أكثر خطورة. يحتوي الملف على ثمانية طلبات، ومع ذلك فإن ناتج 76 / 8 هو 9.5، وليس 10.86. كانت إحدى خانات الكمية فارغة، فقام بانداس باستبعادها بهدوء من المجموع ومن المتوسط الحسابي معاً. ولم يخبرك أحد بذلك أيضاً.

إذن، يحتوي الملف على فجوات (gaps)، وتأتي هذه الفجوات بنوعين. بعضها مرئي: خلية فارغة يحولها بانداس إلى علامة خاصة تدل على "قيمة مفقودة" ثم يتخطاها بصمت في العمليات الحسابية. والبعض الآخر مخفي: قام شخص ما بكتابة - بدلاً من ترك الخلية فارغة، فتعامل معها بانداس كقيمة نصية عادية، وتحول عمود السعر بأكمله إلى نصوص.

كلا النوعين لا يُصدر أي رسالة خطأ. وكلاهما يغير الإجابة النهائية. يدور هذا الفصل حول كيفية العثور على كل فجوة، وفهم ما يفعله بانداس معها، ثم اتخاذ قرار مدروس — بحذفها (drop)، أو ملئها (fill)، أو الإبقاء عليها (keep) — بدلاً من ترك السلوك الافتراضي يقرر بالنيابة عنك.

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

  • شرح ماهية NaN، ولماذا تكون نتيجة NaN == NaN هي False، ولماذا يتحول عمود الأعداد الصحيحة إلى أعداد عشرية بمجرد فراغ خلية واحدة
  • عدّ القيم المفقودة في كل عمود باستخدام isna().sum() واستخراج الصفوف غير المكتملة
  • تحويل العلامات المخفية مثل - أو ? إلى قيم مفقودة حقيقية باستخدام na_values= أو pd.to_numeric(errors="coerce")
  • توقع كيفية تعامل sum و mean و count والعمليات الحسابية بين الأعمدة مع القيم المفقودة
  • إزالة القيم المفقودة باستخدام dropna()، مع تحديد subset= و how= لحذف ما تقصده تحديداً
  • استبدال القيم المفقودة باستخدام fillna() — بقيمة واحدة، أو بقيم مختلفة لكل عمود، أو بقيمة إحصائية
  • اتخاذ قرار مدروس لكل عمود على حدة بشأن الحذف أو الملء أو الإبقاء — مع بيان السبب
  • تجنب فخ inplace=True الذي لا يقوم بأي تغيير في بانداس 3

المتطلبات المسبقة: تصفية الصفوف.


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

أنشئ في مجلد مشروعك ملفاً باسم orders_raw.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,,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,,bottle,accessories,7,N/A
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,-
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0

هذه هي الطلبات الثمانية نفسها من ملف orders.csv في الفصول السابقة، مع أربعة عيوب: الطلب 1002 ليس له كمية، والطلب 1004 ليس له فرع وسعره مسجل كـ N/A، والطلب 1006 يحمل علامة - كسعر له.


قبل كتابة الكود

البيانات المفقودة هي الموضوع الوحيد الذي تصبح فيه كتابة الكود أولاً خطوة محفوفة بالمخاطر: فكلتا الدالتين dropna() و fillna() تعملان دون أي اعتراض وتغيران النتيجة في الوقت نفسه. لذا يبدأ العمل دائماً بالفحص والتقرير.

1. صِغ السؤال في جملة واحدة. "ما إجمالي الكمية المباعة، وإجمالي الإيرادات (الكمية × السعر) لهذا الأسبوع — وكم عدد الطلبات التي تعذر احتسابها؟" الجزء الأخير بالغ الأهمية. فالإجمالي الذي يستبعد طلبين بصمت ليس كالإجمالي الذي يوضح أن "6 من أصل 8 طلبات تم احتسابها".

2. عاين ما وصلك أولاً وقبل أي شيء آخر. تخبرك الدالتان shape و info() معاً بكل ما تحتاج معرفته عن كل عمود:

python
print(orders.shape)
orders.info()
text
(8, 7)
<class 'pandas.DataFrame'>
RangeIndex: 8 entries, 0 to 7
Data columns (total 7 columns):
 #   Column    Non-Null Count  Dtype
---  ------    --------------  -----
 0   order_id  8 non-null      int64
 1   date      8 non-null      str
 2   branch    7 non-null      str
 3   product   8 non-null      str
 4   category  8 non-null      str
 5   quantity  7 non-null      float64
 6   price     7 non-null      str
dtypes: float64(1), int64(1), str(5)
memory usage: 580.0 bytes

قارن هذه المخرجات بما كنت تتوقعه، عموداً بعمود:

  • branch — كان المتوقع 8 قيم غير فارغة من النوع str، لكنك حصلت على 7 فقط. هناك فرع مفقود.
  • quantity — كان المتوقع 8 قيم غير فارغة من النوع int64، لكنك حصلت على 7 من النوع float64. هناك كمية مفقودة، وهذا هو سبب تحوله إلى float64 (كما سنوضح أدناه).
  • price — كان المتوقع 8 قيم غير فارغة من النوع float64، لكنك حصلت على 7 من النوع str. هناك سعر مفقود كما تسربت نصوص إلى داخل العمود.

هناك أمران في تلك القائمة يمثلان قرائن وأدلة: ظهور عمود الأعداد الصحيحة بنوع float64 يعني بنسبة شبه مؤكدة وجود فجوة. وظهور عمود رقمي بنوع str يعني دائماً اختلاط نصوص بالأرقام — والنصوص في عمود الأرقام تكون عادة علامات خفية تدل على قيم مفقودة.

3. خطط لشكل المخرجات التي تريدها. مثل هذا الشكل تقريباً:

text
Missing values per column: branch 1, quantity 1, price 2
Orders counted for revenue: 6 of 8
Total quantity (known):     76
Total revenue (counted):    ...

لاحظ أن التقرير يعرض الفجوات إلى جانب المجاميع. يجب على قارئ التقرير أن يعلم أن الإيرادات المعروضة هي حد أدنى (lower bound).

4. حدد الأداة المناسبة لكل فجوة — قبل كتابة الكود. اسأل نفسك لكل عمود: ما معنى وجود خلية فارغة هنا، وهل يمكن الإجابة عن السؤال بدونها؟

  • branch — الفجوة تعني أن الطلب تم ولكن لم يُسجل الفرع. القرار: املأها بـ "Unknown"، لأن الطلب حقيقي ومبلغه يدخل في الحساب.
  • quantity — الفجوة تعني أننا لا نعلم عدد الوحدات المباعة. القرار: أبقِها مفقودة واستبعد الصف من حساب الإيرادات، لأن ابتكار رقم من عندك (0؟ أو المتوسط؟) سيخلق مبيعات وهمية.
  • price — الفجوة تعني أننا لا نعلم سعر البيع. القرار: أبقِها مفقودة واستبعد الصف من حساب الإيرادات للسبب نفسه — مع توضيح عدد الصفوف المستبعدة في التقرير.

5. حدد ما ستفحصه بعد كل خطوة. بعد كل خطوة تحذف قيماً أو تستبدلها، اطبخ أعداد القيم المفقودة والأبعاد مجدداً. إذا أدت دالة dropna() إلى تقليص البيانات من 8 صفوف إلى صفين، فمن الأفضل أن تكتشف ذلك فوراً، وليس بعد إصدار التقرير.

تشرح بقية أجزاء هذا الفصل كل أداة بالترتيب، وفي النهاية سنجمع هذه الخطة في برنامج واحد متكامل.


كيف تبدو القيمة "المفقودة": NaN

عندما يجد بانداس خلية فارغة، فإنه لا يخزن نصاً فارغاً ولا صفراً. بل يخزن قيمة عشرية خاصة تُعرف باسم NaN — أي "not a number" (ليس رقماً). يمكنك إنشاء واحدة بنفسك بتمرير None إلى كائن Series:

python
import pandas as pd

quantity = pd.Series([12, None, 2])
print(quantity)
text
0    12.0
1     NaN
2     2.0
dtype: float64

انظر إلى نوع البيانات dtype. لقد مررت عددين صحيحين وفجوة، وحصلت في المقابل على float64 مع القيمتين 12.0 و 2.0. هذا هو السبب الكامن وراء القرينة التي لاحظناها في info(): فقيمة NaN هي قيمة عشرية (float)، ولا يمكن لعمود الأعداد الصحيحة التقليدي احتواؤها. لذا، بمجرد أن تصبح خلية واحدة مفقودة، يقوم بانداس بترقية العمود بالكامل إلى float64 ليفسح المجال لقيمة NaN. وهذا يفسر طباعة quantity في ملفنا بالأشكال 12.0 و 2.0 وما إلى ذلك.

أما الأعمدة النصية فتتصرف بطريقة مختلفة؛ إذ يمكن لعمود str أن يحمل قيمة مفقودة دون تغيير نوعه:

python
branch = pd.Series(["north", None, "east"])
print(branch)
text
0    north
1      NaN
2     east
dtype: str

لا تزال تُطبع كـ NaN، ويظل النوع str. لذا ستقابل NaN في بانداس 3 في كل من أعمدة الأرقام والنصوص، وتستطيع الأدوات نفسها العثور عليها في الحالتين.

NaN لا تساوي أي شيء — ولا حتى نفسها

هذه الحقيقة هي الأكثر إثارة للدهشة حول القيم المفقودة، وهي تفسد الطريقة الأكثر تلقائية للبحث عنها:

python
import numpy as np
import pandas as pd

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

print(np.nan == np.nan)
print((orders["branch"] == np.nan).sum())
text
False
0

تعني NaN "قيمة ما لا نعرفها". وقيمتان غير معروفتين لا يُفترض تساويهما، لذا تُرجع المقارنة False — في كل مرة. هذا يجعل التعبير == np.nan اختباراً لا يطابق أي شيء على الإطلاق: فعلى الرغم من وجود فجوة في عمود الفرع، فإن العدّ يعطي 0. اسأل بانداس مباشرة بدلاً من ذلك.

إياك والبحث عن القيم المفقودة باستخدام معامل المقارنة ==. فالتعبير == np.nan ينفذ دون أي خطأ ولا يعثر على شيء أبداً، مما يجعل العمود المليء بالفجوات يبدو مكتملاً تماماً. استخدم الدالة isna().

البحث عن القيم المفقودة: isna()

تطرح الدالة isna() السؤال الذي يعجز عنه المعامل ==: "هل هذه الخلية مفقودة؟" عند تطبيقها على عمود، تُرجع عموداً من قيم True/False — قناعاً (mask) يشبه تماماً الأقنعة التي استخدمناها في فصل التصفية:

python
print(orders["branch"].isna())
text
0    False
1    False
2    False
3     True
4    False
5    False
6    False
7    False
Name: branch, dtype: bool

وعلى مستوى جدول DataFrame بالكامل، تقوم بالشيء نفسه لكل خلية. ونادراً ما ستحتاج لقراءة ثمانية صفوف من قيم True/False؛ بل ستحتاج عادة إلى عدّها، وبما أن True تُحسب كـ 1، فإن الدالة .sum() تفي بالغرض:

python
print(orders.isna().sum())
text
order_id    0
date        0
branch      1
product     0
category    0
quantity    1
price       1
dtype: int64

هذا هو السطر الذي ينبغي تشغيله بعد info() على أي ملف جديد. إنه يمثل نفس معلومات Non-Null Count، ولكن بصيغة معكوسة تعدّ الفجوات مباشرة.

ولكن انظر إلى price: يظهر الرقم 1. ونحن نعلم أن هناك سعرين معطوبين — N/A و -. احتفظ بهذه النقطة في ذهنك؛ سنعود إليها في القسم التالي.

ما هي الصفوف غير المكتملة؟

يخبرك العدد بالكمية؛ ولمعرفة أي الصفوف هي، استخدم القناع كعامل تصفية. يطرح التعبير isna().any(axis=1) سؤالاً لكل صف: "هل هناك أي قيمة مفقودة في هذا الصف؟":

python
incomplete = orders[orders.isna().any(axis=1)]
print(incomplete)
text
order_id        date branch   product     category  quantity price
1      1002  2024-03-01  south  notebook   stationery       NaN  60.0
3      1004  2024-03-02    NaN    bottle  accessories       7.0   NaN

المعامل axis=1 يعني "البحث أفقياً عبر الصف" بدلاً من البحث عمودياً إلى الأسفل. ولاحظ من ليس في القائمة: الطلب 1006، الذي يحمل السعر -. بالنسبة لبانداس، هذا الصف مكتمل تماماً. احتفظ بهذه الفكرة أيضاً. إن فحص الصفوف الفعلية يستحق كتابة سطر إضافي: فهكذا تلاحظ، على سبيل المثال، أن كل الكميات المفقودة تخص فرعاً واحداً بعينه، وهو سؤال يُوجه للمسؤولين في ذلك الفرع وليس لمكتبة بانداس.

كنسبة مئوية وليس مجرد عدد

مع ثمانية صفوف، تكون الأعداد كافية. ولكن مع ثمانين ألف صف، لا يعني وجود "312 قيمة مفقودة" الكثير حتى تعرف أنها تمثل 0.4%. دالة .mean() المطبقة على عمود من قيم True/False تُعطي نسبة ظهور True:

python
print(orders.isna().mean())
text
order_id    0.000
date        0.000
branch      0.125
product     0.000
category    0.000
quantity    0.125
price       0.125
dtype: float64

خلية واحدة من كل ثماني خلايا في أعمدة branch و quantity و price مفقودة — أي بنسبة 12.5% لكل منها. العمود الفارغ بنسبة 90% يتطلب قرارات تختلف تماماً عن العمود الفارغ بنسبة 0.1%، وهذه هي الطريقة التي تميز بها بينهما.


القيم المفقودة المخفية: عندما تُكتب الفجوة كنص

عدّت الدالة isna() سعراً مفقوداً واحداً، لكن هناك سعرين غير صالحين للاستخدام. السعر الثاني هو علامة - في الطلب 1006. بالنسبة لبانداس، ليست علامة - قيمة مفقودة — بل هي قيمة نصية صالحة تماماً. ووجود قيمة نصية واحدة في عمود أرقام كافٍ لجعل العمود بأكمله من النوع str، وهو ما أظهرته دالة info() تماماً.

إليك كيفية معرفة ما يحتويه العمود فعلياً:

python
print(orders["price"].unique())
text
<StringArray>
['15.0', '60.0', '850.0', nan, '8.0', '-', '120.0']
Length: 7, dtype: str

كل قيمة محاطة بعلامات اقتباس — كلها نصوص، بما فيها '15.0'. وهناك نوعان من الفجوات متجاوران: nan، التي تعرف عليها بانداس، و '-'، التي لم يتعرف عليها.

لماذا تحولت N/A إلى nan بينما لم تتحول -؟ لأن الدالة read_csv تأتي بقائمة مدمجة من النصوص التي تعاملها كقيم مفقودة افتراضياً: الخلية الفارغة، و NA، و N/A، و n/a، و NaN، و null، و None وبعض النصوص الأخرى. أما - و ? و missing و 0 و unknown فليست مدرجة في تلك القائمة. وما كتبه مدخل البيانات للتعبير عن القيمة المجهولة هو ما يحدد نوع الفجوة التي ستحصل عليها.

هناك طريقتان لتحويل هذه العلامات المخفية إلى قيم NaN حقيقية.

الحل 1: إخبار read_csv بالعلامة — na_values=

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

python
orders = pd.read_csv("orders_raw.csv", na_values=["-"])

print(orders["price"].dtype)
print(orders.isna().sum())
text
float64
order_id    0
date        0
branch      1
product     0
category    0
quantity    1
price       2
dtype: int64

قارن هذه النتيجة بالعدد السابق. بقي عمودا branch و quantity دون تغيير — لأن na_values= يضيف إلى القائمة ولا يستبدلها، مما يحافظ على التعرف على الخلايا الفارغة و N/A. ما تغير هو عمود price: أصبح الآن float64، ويُظهر وجود 2 من القيم المفقودة — حيث تحولت كل من N/A و - إلى قيم NaN حقيقية الآن. وأصبح العمود رقمياً مجدداً، مما يعني أن العمليات الحسابية عليه ستتصرف كحسابات فعلية.

لاحظ أن na_values=["-"] تنطبق على كل الأعمدة. وهذا ما نريده هنا. أما إذا كانت علامة - يمكن أن تمثل قيمة صحيحة في عمود آخر — كرمز لمنتج مثلاً — فمرر قاموساً بدلاً من ذلك، مثل na_values={"price": ["-"]}، وبذلك سيتأثر عمود price فقط.

الحل 2: التحويل لاحقاً — pd.to_numeric(errors="coerce")

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

python
raw = pd.read_csv("orders_raw.csv")

price = pd.to_numeric(raw["price"])
text
ValueError: Unable to parse string "-" at position 5

هذا الخطأ مفيد للغاية: فهو يذكر القيمة المخالفة وموقعها بدقة. وإضافة errors="coerce" تعني "أي قيمة تعجز عن تحويلها إلى رقم، اجعلها NaN":

python
price = pd.to_numeric(raw["price"], errors="coerce")
print(price)
text
0     15.0
1     60.0
2    850.0
3      NaN
4      8.0
5      NaN
6     15.0
7    120.0
Name: price, dtype: float64

لتعديل الجدول فعلياً، أعد إسناد النتيجة إلى العمود نفسه — فالدالة to_numeric تُرجع كائن Series جديداً وتترك raw دون تعديل:

python
raw["price"] = pd.to_numeric(raw["price"], errors="coerce")
print(raw["price"].dtype, raw["price"].isna().sum())
text
float64 2

أي الحلين تختار

المعامل errors="coerce" قوي وحاسم، ولكنه قد يكون عشوائياً بعض الشيء. فهو يحول أي شيء غير رقمي إلى NaN — بما في ذلك السعر الحقيقي الذي كُتب خطأً مثل 1O0 (بحرف O الإنجليزي) أو 1,200 مع فاصلة الآلاف. هذه ليست قيماً مفقودة، بل هي قيم تحتوي على أخطاء إملائية، وإجبارها على التحويل يتخلص من البيانات بصمت.

لذا اجعل عادتك دائماً: عُدّ القيم قبل التحويل، وعُدّها بعده، وفَسّر الفرق.

  • إذا كنت تعرف العلامة (- أو ? أو missing): استخدم na_values= في read_csv. إنه خيار دقيق — إذ يحول ذلك النص بعينه فقط إلى قيمة مفقودة.
  • إذا كانت هناك شوائب غير معروفة، ويجب أن يكون العمود رقمياً: استخدم pd.to_numeric(errors="coerce"). إنه يلتقط كل شيء، لذا افحص ما التقطه بعد الانتهاء.
  • إذا كانت بعض القيم عبارة عن أخطاء كتابية يمكن تصحيحها: افحص unique() أولاً، وأصلح الأخطاء، ثم أجرِ التحويل. فالإجبار على التحويل قد يدمر بيانات حقيقية.

قبل التحويل الإجباري، أظهرت دالة raw["price"].unique() نصاً واحداً غير رقمي ('-') وقيمة nan واحدة. وبعد الإجبار، أصبح هناك اثنان من NaN تحديداً. الأرقام متطابقة، لذا لم نفقد أي شيء آخر. هذا الفحص البسيط هو الفارق الحاسم بين تنظيف البيانات وإتلافها.

من هنا فصاعداً سنعتمد النسخة المقروءة باستخدام na_values=["-"].


كيف تتعامل العمليات الحسابية مع NaN

الآن بعد أن أصبحت الفجوات قيماً حقيقية من نوع NaN، تحتاج إلى معرفة كيف يتعامل بانداس معها — لأنه لن يتوقف ليسألك في كل مرة.

ملخصات الأعمدة تتجاهل NaN

python
orders = pd.read_csv("orders_raw.csv", na_values=["-"])
q = orders["quantity"]

print("len  :", len(q))
print("count:", q.count())
print("sum  :", q.sum())
print("mean :", q.mean())
print("check:", q.sum() / q.count())
text
len  : 8
count: 7
sum  : 76.0
mean : 10.857142857142858
check: 10.857142857142858

ثلاثة سلوكيات يجب تذكرها:

  • len تحسب عدد الصفوف؛ بينما تحسب count() عدد القيم الموجودة. الفرق بينهما هو عدد الفجوات. وهذا يطابق مفهوم Non-Null Count في info().
  • sum() تتجاهل NaN. تجمع الكميات السبع المعلومة فقط. هذا منطقي — ولكنه يعني أن "76" هو مجموع الكميات المعلومة، وليس مجموع كل الطلبات.
  • mean() تقسم على count() وليس على len. يُحسب المتوسط على القيم السبع المعلومة فقط. وهذا ما تريده عادة؛ فمعاملة الفجوة كصفر كانت ستخفض المتوسط دون مبرر.

إذا كنت تفضل أن يتم تنبيهك بوجود قيمة مفقودة، فإن تمرير skipna=False يجعل النتيجة NaN بمجرد وجود أي قيمة مفقودة:

python
print(q.sum(skipna=False))
text
nan

العمليات الحسابية بين الأعمدة تنشر NaN

تتجاهل الدوال التلخيصية الفجوات؛ أما العمليات الحسابية فتنشرها. أي عملية تجمع بين قيمة عادية و NaN تنتج NaN، لأن "7 مضروبة في سعر مجهول" تظل قيمة مجهولة:

python
revenue = orders["quantity"] * orders["price"]
print(revenue)
print("total:", revenue.sum())
print("rows counted:", revenue.count(), "of", len(revenue))
text
0     180.0
1       NaN
2    1700.0
3       NaN
4     240.0
5       NaN
6     300.0
7     480.0
dtype: float64
total: 2900.0
rows counted: 5 of 8

اجمع بين هذين السلوكين وسترى الفخ المذكور في بداية الفصل بوضوح. طبعت الدالة revenue.sum() القيمة 2900.0 دون أي تحذير — لكن ثلاثة من الطلبات الثمانية لم تساهم بشيء في الناتج. الطلب 1002 تنقصه كمية، والطلبان 1004 و 1006 ينقصهما سعر. لا يكون المجموع نزيهاً إلا إذا وضحت أنه يمثل "5 من أصل 8 طلبات". لهذا السبب طلبت خطتنا وضع عداد بجانب كل مجموع.


إزالة القيم المفقودة: dropna()

تحذف الدالة dropna() الصفوف التي تحتوي على NaN. وبدون أي وسائط، تقوم بحذف أي صف يحتوي على أي فجوة في أي عمود:

python
print(orders.shape)
print(orders.dropna().shape)
text
(8, 7)
(5, 7)

اختفت ثلاثة صفوف من أصل ثمانية — وتم استبعاد الطلب 1004 لأن فرعه كان مفقوداً، رغم أنه كان صفاً صالحاً تماماً لحساب إجمالي الكمية. هذه هي مشكلة استخدام dropna() بدون تحديد: إنها تجيب عن سؤال "احذف أي شيء غير مثالي"، وهو سؤال نادراً ما نسعى إليه.

subset= — الأعمدة التي يحتاجها السؤال فقط

حدد الأعمدة التي يجب توفرها بالضرورة:

python
known_qty = orders.dropna(subset=["quantity"])
print(known_qty.shape)
print(known_qty["quantity"].sum())
text
(7, 7)
76.0

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

python
countable = orders.dropna(subset=["quantity", "price"])
print(countable[["order_id", "quantity", "price"]])
text
order_id  quantity  price
0      1001      12.0   15.0
2      1003       2.0  850.0
4      1005      30.0    8.0
6      1007      20.0   15.0
7      1008       4.0  120.0

انتبه للفهرس: 0, 2, 4, 6, 7. تحتفظ dropna() بالفهارس الأصلية تماماً كما تفعل عمليات التصفية، بحيث يمكنك تتبع الصفوف في الملف الأصلي دائماً. وإذا كنت بحاجة إلى ترقيم متسلسل 0, 1, 2… مجدداً، أضف .reset_index(drop=True).

how="all" — الصفوف الفارغة تماماً فقط

غالباً ما تنتهي ملفات الجداول المصدرة بعدد قليل من الصفوف الفارغة في كل الأعمدة. يحذف الخيار how="all" تلك الصفوف بالذات ويُبقي على أي صف يحتوي ولو على قيمة واحدة:

python
print(orders.dropna(how="all").shape)
text
(8, 7)

لم يُحذف شيء هنا — فليس لدينا أي صف فارغ تماماً — وهذا تحديداً ما تحتاج للتأكد منه.

dropna() تُرجع جدولاً جديداً

كما هي الحال مع معظم دوال بانداس، لا تقوم dropna() بتعديل جدول orders الأصلي؛ بل تمنحك جدول DataFrame جديداً. إذا كتبت orders.dropna() في سطر منفصل، فلن يتغير شيء في orders. عليك إسناد النتيجة — ويفضل إسنادها لاسم جديد إذا كنت بحاجة للجدول الكامل لاحقاً، وهو ما ستحتاجه في تقرير مثل تقريرنا (لتوضيح عدد الصفوف المحذوفة).


استبدال القيم المفقودة: fillna()

في بعض الأحيان يكون هناك بديل منطقي للفجوة. تضع الدالة fillna() قيمة مكان NaN.

قيمة واحدة لعمود واحد

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

python
orders["branch"] = orders["branch"].fillna("Unknown")
print(orders["branch"].tolist())
text
['north', 'south', 'north', 'Unknown', 'north', 'south', 'east', 'north']

صيغة ذلك السطر مهمة للغاية: خذ العمود، وطبق عليه fillna، ثم أعد إسناده للعمود نفسه. تُرجع دالة fillna كائن Series جديداً؛ وبدون وجود orders["branch"] = في البداية، لن يُحفظ أي شيء.

قيم مختلفة لأعمدة مختلفة: استخدام dict

مرر قاموساً بصيغة {column: value} لملء عدة أعمدة معاً، كل منها بقيمته الخاصة. وتبقى الأعمدة غير المذكورة دون أي تعديل:

python
orders = pd.read_csv("orders_raw.csv", na_values=["-"])

filled = orders.fillna({"branch": "Unknown", "quantity": 0})
print(filled.isna().sum())
text
order_id    0
date        0
branch      0
product     0
category    0
quantity    0
price       2
dtype: int64

لا يزال عمود price يحتوي على فجوتيه، لأننا لم نذكره في القاموس. وهذه هي ميزة القاموس مقارنة بـ orders.fillna(0)، التي كانت ستملأ كل الأعمدة — بما فيها الأسعار — بالرقم 0، وسعر 0 يعني حقيبة مجانية.

ملء الفراغات يغير النتيجة

بدا ملء الكمية بالرقم 0 تصرفاً غير ضار. قارن المتوسطات الناتجة:

python
print("skip the gap :", orders["quantity"].mean())
print("fill with 0  :", orders["quantity"].fillna(0).mean())
print("fill with median:", orders["quantity"].fillna(orders["quantity"].median()).mean())
text
skip the gap : 10.857142857142858
fill with 0  : 9.5
fill with median: 10.375

ثلاثة متوسطات مختلفة لعمود واحد، والفارق الوحيد هو ما قررته لمعنى الكمية المجهولة:

  • 0 يعني "لم يبع هذا الطلب أي شيء". وهذا غير صحيح بالنسبة للطلب 1002 (طلب دفاتر بيع منه بالتأكيد شيء ما)، مما يسحب المتوسط إلى الأسفل ظلماً.
  • الوسيط (median) (وهو 7 هنا) يعني "افتراض طلب نموذجي معتاد". هذا يحافظ على المتوسط قريباً من وضعه الأصلي، لكن الإجمالي أصبح يشمل الآن 7 دفاتر لم يحسبها أحد في الواقع.
  • التجاهل يعني "لا نعرف، لذا دعونا نستبعدها". فيكون المتوسط محسوباً على الطلبات السبعة المعروفة فقط.

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

قاعدة عامة تعمل بنجاح في معظم الحالات:

  • التسميات والتصنيفات (الفرع، الفئة، الاسم): املأها بـ "Unknown" أو ما شابه ذلك. يُحفظ الصف، وتظل الفجوة مرئية كمجموعة قائمة بذاتها.
  • الأعداد التي يعني فراغها الصفر فعلياً (المرتجعات، الشكاوى): املأها بـ 0 — ولكن فقط عندما يقصد مصدر البيانات الصفر فعلاً بالخانة الفارغة.
  • القياسات أو المبالغ المالية (الكمية المباعة، السعر، الدرجات): احتفظ بـ NaN، واستبعدها من الحساب، وأوضح عدد المستبعدين في تقريرك. فالأرقام المصطنعة تصبح مجاميع مصطنعة.
  • العمود الذي يتطلبه السؤال وموجود في معظمه: طبق dropna(subset=[...]) على ذلك السؤال تحديداً. خسارة طفيفة ومقبولة في سبيل إجابة أمينة وصادقة.

استعادة الأعداد الصحيحة

بعد الملء، لا يحتوي عمود quantity على أي فجوات، ولكنه يظل من النوع float64 — فلا يعيده بانداس إلى أعداد صحيحة تلقائياً. وبمجرد التخلص من كل NaN، يعمل التحويل astype(int) بنجاح:

python
filled["quantity"] = filled["quantity"].astype(int)
print(filled["quantity"].tolist())
print(filled["quantity"].dtype)
text
[12, 0, 2, 7, 30, 1, 20, 4]
int64

جرب تشغيله قبل الملء وسيفشل، لأنه لا يوجد عدد صحيح في بايثون يعبر عن "قيمة مجهولة" — انظر قسم "عندما تفشل البرمجية" أدناه.


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

يضع الملف clean_orders.py الخطة التي وضعناها في "قبل كتابة الكود" في برنامج واحد متكامل: يقرأ الملف بالعلامة المعروفة، ويعرض كل فجوة، ويملأ الفرع، ويستبعد الصفوف التي يتعذر احتسابها، ويُخرج المجاميع بوضوح مع عدد الطلبات التي تغطيها.

python
import pandas as pd

orders = pd.read_csv("orders_raw.csv", na_values=["-"])

# 1. Look first
print("Rows, columns:", orders.shape)
missing = orders.isna().sum()
print("Missing per column:")
print(missing[missing > 0])
print()

# 2. Decide per column: branch -> label; quantity and price -> keep, exclude
orders["branch"] = orders["branch"].fillna("Unknown")

# 3. Only rows with both numbers can be counted for revenue
countable = orders.dropna(subset=["quantity", "price"])
skipped = orders[orders[["quantity", "price"]].isna().any(axis=1)]

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

print("Orders counted for revenue:", len(countable), "of", len(orders))
print("Total quantity (known):    ", int(orders["quantity"].sum()))
print("Total revenue (counted):   ", revenue.sum())
print()
print("Not counted - check these with the branch:")
print(skipped[["order_id", "branch", "quantity", "price"]])
text
Rows, columns: (8, 7)
Missing per column:
branch      1
quantity    1
price       2
dtype: int64

Orders counted for revenue: 5 of 8
Total quantity (known):     76
Total revenue (counted):    2900.0

Not counted - check these with the branch:
   order_id   branch  quantity  price
1      1002    south       NaN   60.0
3      1004  Unknown       7.0    NaN
5      1006    south       1.0    NaN

لماذا كُتب البرنامج بهذه الطريقة:

  • na_values=["-"] موجودة في السطر الأول تماماً. تم اكتشاف العلامة مسبقاً بفحص unique(). ووضعها في read_csv يجعل العمود رقمياً منذ البداية، ويرى أي شخص يقرأ الكود العلامة المستخدمة في الملف بوضوح.
  • missing[missing > 0] تطبع فقط الأعمدة التي تحتوي على فجوات. في ملف يضم أربعين عموداً، هذا هو الفارق بين تقرير سهل القراءة وجدار من الأصفار.
  • تم ملء الفرع ولم تُملأ الأرقام. هذا تطبيق مباشر لقائمة القرارات الواردة في الخطة البرمجية. قد يختلف القارئ مع القرار، لكنه يراه بوضوح ولا يُخفى عنه شيء.
  • حددت dropna(subset=...) العمودين اللذين تتطلبهما الإيرادات، لذا استُبعد الطلب 1004 لغياب سعره، وليس لغياب فرعه.
  • طُبعت الصفوف المستبعدة ولم يُكتفَ بعدّها. عبارة "3 طلبات لم تُحسب" تثير تلقائياً السؤال "أي طلبات؟" — فأجبنا عن ذلك في التقرير. هذه الطلبات الثلاثة قائمة يمكن لأي شخص التحقق منها وتصحيحها في الفرع.
  • تسميتا "Total quantity (known)" و "(counted)" توضحان طبيعة الأرقام بدقة. فكلاهما حد أدنى، والتسميات تمنع أي شخص من اعتبارهما حساباً مكتملاً.

عندما تفشل البرمجية

TypeError: Cannot perform reduction 'mean' with string dtype لقد طلبت حساب المتوسط الحسابي لعمود قرأه بانداس كنص — والسبب الشائع هو احتواء خلية على علامة مثل - أو ? أو missing. نفّذ df["col"].unique() لتحديد القيمة المخالفة، ثم أعد قراءة الملف باستخدام na_values=["-"] أو حول العمود باستخدام pd.to_numeric(df["col"], errors="coerce").

python
raw = pd.read_csv("orders_raw.csv")
print(raw["price"].mean())
text
TypeError: Cannot perform reduction 'mean' with string dtype

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

ValueError: Unable to parse string "-" at position 5 واجهت دالة pd.to_numeric نصاً لم تستطع تحويله، وأخبرتك بالقيمة وموقعها بدقة. إما أن تضيف errors="coerce" (بعد التأكد عبر unique() من أن القيم الغريبة هي علامات للقيم المفقودة وليست أخطاء كتابية)، أو تصحح الأخطاء الكتابية أولاً.

تُرجع (df["col"] == np.nan).sum() القيمة 0 رغم وجود فجوات قيمة NaN لا تساوي أي شيء على الإطلاق، حتى نفسها، لذا فإن == np.nan لا تطابق أي خلية. استخدم دائماً df["col"].isna().

IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer لقد استدعيت astype(int) على عمود لا يزال يحتوي على قيم NaN. لا يوجد في بايثون عدد صحيح يعبر عن "قيمة مجهولة". املأ الفجوات أو احذفها أولاً، ثم أجرِ التحويل:

python
orders = pd.read_csv("orders_raw.csv", na_values=["-"])
orders["quantity"] = orders["quantity"].astype(int)
text
pandas.errors.IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer.Replace or remove non-finite values or cast to an integer typethat supports these values (e.g. 'Int64')

(المسافات المفقودة في "integer.Replace" و "typethat" موجودة في رسالة بانداس الأصلية نفسها). تشير الرسالة أيضاً إلى النوع 'Int64' بحرف I كبير: وهو نوع أعداد صحيحة خاص ببانداس يمكنه احتواء القيم المفقودة. وهو مفيد بالتأكيد، إلا أن ملء الفجوات أو حذفها أولاً هو الحل الأبسط والأكثر شيوعاً.

ظهور خطأ ChainedAssignmentError — مع بقاء الفجوة دون تغيير لقد استخدمت أسلوب inplace=True الذي تجده في العديد من الشروحات القديمة:

python
orders = pd.read_csv("orders_raw.csv", na_values=["-"])
orders["quantity"].fillna(0, inplace=True)
print(orders["quantity"].isna().sum())
text
1
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment using an inplace method.
Such inplace method 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).

For example, when doing 'df[col].method(value, inplace=True)', try using 'df.method({col: value}, inplace=True)' instead, to perform the operation inplace on the original object, or try to avoid an inplace operation using 'df[col] = df[col].method(value)'.

See the documentation for a more detailed explanation: https://pandas.pydata.org/pandas-docs/stable/user_guide/copy_on_write.html

لا يزال العدد 1: لم يُملأ أي شيء. في بانداس 3، يتصرف orders["quantity"] دائماً كنسخة مستقلة بذاتها (وفق آلية النسخ عند الكتابة Copy-on-Write)، لذا فإن ملأه "في مكانه" يُعدل تلك النسخة المستقلة التي سرعان ما تُهمل وتُحذف. كانت إصدارات بانداس القديمة تسمح بذلك أحياناً، ولهذا السبب ينتشر هذا النمط بكثرة على الإنترنت. اكتب التعيين صراحة بدلاً من ذلك — فهو يعمل في كل الإصدارات ويوضح بدقة ما يتم تغييره:

python
orders["quantity"] = orders["quantity"].fillna(0)
print(orders["quantity"].isna().sum())
text
0

حذفت دالة dropna() صفوفاً أكثر بكثير مما توقعت تحذف الدالة dropna() العادية الصف عند وجود فجوة في أي عمود — بما في ذلك عمود الملاحظات notes أو الخصومات discount الفارغ في معظمه والذي قد لا تستخدمه أصلاً في تحليلك. اطبع df.isna().sum() لتحديد العمود الذي يحتوي على فجوات كثيرة، ثم استخدم dropna(subset=[...]) مع تحديد الأعمدة التي يحتاجها سؤالك فقط.

TypeError: can't multiply sequence by non-int of type 'float' حاولت ضرب عمود رقمي في عمود نصي (quantity * price بينما price لا يزال نصاً str). الحل يطابق الخطأ الأول: اجعل عمود price رقمياً أولاً.