18  القيم المفقودة

18.1 مقدمة

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

سنبدأ بمناقشة بعض الأدوات العامة للعمل مع القيم المفقودة المسجلة كـ NAs. ثم سنستكشف فكرة القيم المفقودة ضمنياً (implicitly missing values)، وهي القيم الغائبة ببساطة عن بياناتك، ونعرض بعض الأدوات التي يمكنك استخدامها لجعلها صريحة. سننهي بمناقشة ذات صلة حول المجموعات الفارغة، الناتجة عن مستويات العوامل (factor levels) التي لا تظهر في البيانات.

18.1.1 المتطلبات المسبقة

تأتي الدوال الخاصة بالتعامل مع البيانات المفقودة غالباً من حزمتي dplyr و tidyr، وهما من الأعضاء الأساسيين في مجموعة tidyverse.

18.2 القيم المفقودة الصريحة

للبدء، دعنا نستكشف بضع أدوات مفيدة لإنشاء أو التخلص من القيم المفقودة الصريحة، أي الخلايا التي ترى فيها NA بشكل واضح.

18.2.1 الملاحظة الأخيرة المنقولة للأمام (LOCF)

الاستخدام الشائع للقيم المفقودة هو كوسيلة لتسهيل إدخال البيانات. عند إدخال البيانات يدوياً، تشير القيم المفقودة أحياناً إلى أن القيمة في الصف السابق قد تكررت (أو نُقلت للأمام):

treatment <- tribble(
  ~person,            ~treatment, ~response,
  "Derrick Whitmore", 1,          7,
  NA,                 2,          10,
  NA,                 3,          NA,
  "Katherine Burke",  1,          4
)

يمكنك ملء هذه القيم المفقودة باستخدام tidyr::fill(). وهي تعمل مثل select()، حيث تأخذ مجموعة من الأعمدة:

treatment |> 
  fill(everything())
#> # A tibble: 4 × 3
#>   person           treatment response
#>   <chr>                <dbl>    <dbl>
#> 1 Derrick Whitmore         1        7
#> 2 Derrick Whitmore         2       10
#> 3 Derrick Whitmore         3       10
#> 4 Katherine Burke          1        4

يسمى هذا الإجراء أحياناً “الملاحظة الأخيرة المنقولة للأمام” (last observation carried forward)، أو اختصاراً locf. يمكنك استخدام المعامل .direction لملء القيم المفقودة التي تم إنشاؤها بطرق أكثر تنوعاً.

18.2.2 القيم الثابتة

في بعض الأحيان تمثل القيم المفقودة قيمة ثابتة ومعروفة، وتكون في الغالب 0. يمكنك استخدام dplyr::coalesce() لاستبدالها:

x <- c(1, 4, 5, 7, NA)
coalesce(x, 0)
#> [1] 1 4 5 7 0

في أحيان أخرى ستواجه المشكلة المعاكسة حيث تمثل قيمة ملموسة معينة في الواقع قيمة مفقودة. ينشأ هذا عادةً في البيانات التي يتم إنشاؤها بواسطة البرامج القديمة التي لا تحتوي على طريقة صحيحة لتمثيل القيم المفقودة، لذا يجب بدلاً من ذلك استخدام قيمة خاصة مثل 99 أو -999.

إذا كان ذلك ممكناً، تعامل مع هذا عند قراءة البيانات، على سبيل المثال، باستخدام المعامل na في دالة readr::read_csv()، مثل read_csv(path, na = "99"). إذا اكتشفت المشكلة لاحقاً، أو لم توفر مصدر البيانات طريقة للتعامل معها عند القراءة، يمكنك استخدام dplyr::na_if():

x <- c(1, 4, 5, 7, -99)
na_if(x, -99)
#> [1]  1  4  5  7 NA

18.2.3 NaN

قبل أن نواصل، هناك نوع خاص واحد من القيم المفقودة ستصادفه من وقت لآخر: وهو NaN (ويُنطق “نان”)، اختصاراً لـ not a number (ليس رقماً). ليس من المهم جداً معرفة تفاصيله لأنه يتصرف عموماً تماماً مثل NA:

x <- c(NA, NaN)
x * 10
#> [1]  NA NaN
x == 1
#> [1] NA NA
is.na(x)
#> [1] TRUE TRUE

في الحالات النادرة التي تحتاج فيها إلى التمييز بين NA و NaN، يمكنك استخدام is.nan(x).

ستصادف عموماً NaN عندما تقوم بعملية حسابية لها نتيجة غير محددة:

0 / 0 
#> [1] NaN
0 * Inf
#> [1] NaN
Inf - Inf
#> [1] NaN
sqrt(-1)
#> Warning in sqrt(-1): NaNs produced
#> [1] NaN

18.3 القيم المفقودة الضمنية

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

stocks <- tibble(
  year  = c(2020, 2020, 2020, 2020, 2021, 2021, 2021),
  qtr   = c(   1,    2,    3,    4,    2,    3,    4),
  price = c(1.88, 0.59, 0.35,   NA, 0.92, 0.17, 2.66)
)

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

  • السعر price في الربع الرابع من عام 2020 مفقود صراحةً، لأن قيمته هي NA.

  • السعر price للربع الأول من عام 2021 مفقود ضمناً، لأنه ببساطة لا يظهر في مجموعة البيانات.

إحدى الطرق للتفكير في هذا الفرق هي مع هذا التشبيه الحكيم الفلسفي:

القيمة المفقودة الصريحة هي وجودٌ للغياب.

القيمة المفقودة الضمنية هي غيابٌ للوجود.

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

18.3.1 التدوير (Pivoting)

لقد رأيت بالفعل أداة واحدة يمكنها جعل القيم المفقودة الضمنية صريحة والعكس صحيح: التدوير (pivoting). جعل البيانات أوسع (wider) يمكن أن يجعل القيم المفقودة الضمنية صريحة لأن كل تجميعة بين الصفوف والأعمدة الجديدة يجب أن تحتوي على قيمة ما. على سبيل المثال، إذا قمنا بتدوير stocks لوضع الربع السنوي quarter في الأعمدة، ستصبح كلا القيمتين المفقودتين صريحتين:

stocks |> 
  pivot_wider(
    names_from = qtr, 
    values_from = price
  )
#> # A tibble: 2 × 5
#>    year   `1`   `2`   `3`   `4`
#>   <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1  2020  1.88  0.59  0.35 NA   
#> 2  2021 NA     0.92  0.17  2.66

افتراضياً، يحافظ جعل البيانات أطول (longer) على القيم المفقودة الصريحة، ولكن إذا كانت قيم مفقودة هيكلياً وتوجد فقط لأن البيانات ليست مرتبة (tidy)، يمكنك حذفها (جعلها ضمنية) عن طريق ضبط values_drop_na = TRUE. انظر الأمثلة في قسم 5.2 لمزيد من التفاصيل.

18.3.2 الإكمال (Complete)

تسمح لك دالة tidyr::complete() بإنشاء قيم مفقودة صريحة من خلال تقديم مجموعة من المتغيرات التي تحدد التجميعة بين الصفوف التي ينبغي أن تكون موجودة. على سبيل المثال، نعلم أن جميع التجميعات بين year و qtr يجب أن تكون موجودة في بيانات stocks:

stocks |> 
  complete(year, qtr)
#> # A tibble: 8 × 3
#>    year   qtr price
#>   <dbl> <dbl> <dbl>
#> 1  2020     1  1.88
#> 2  2020     2  0.59
#> 3  2020     3  0.35
#> 4  2020     4 NA   
#> 5  2021     1 NA   
#> 6  2021     2  0.92
#> # ℹ 2 more rows

عادةً، ستستدعي complete() بأسماء المتغيرات الموجودة، لملء التجميعات المفقودة. ومع ذلك، في بعض الأحيان تكون المتغيرات الفردية نفسها غير كاملة، لذا يمكنك بدلاً من ذلك تقديم البيانات الخاصة بك. على سبيل المثال، قد تعرف أن مجموعة بيانات stocks من المفترض أن تمتد من 2019 إلى 2021، لذا يمكنك تزويد تلك القيم صراحةً لـ year:

stocks |> 
  complete(year = 2019:2021, qtr)
#> # A tibble: 12 × 3
#>    year   qtr price
#>   <dbl> <dbl> <dbl>
#> 1  2019     1 NA   
#> 2  2019     2 NA   
#> 3  2019     3 NA   
#> 4  2019     4 NA   
#> 5  2020     1  1.88
#> 6  2020     2  0.59
#> # ℹ 6 more rows

إذا كان نطاق المتغير صحيحاً، ولكن ليست جميع القيم موجودة، يمكنك استخدام full_seq(x, 1) لإنشاء جميع القيم من min(x) إلى max(x) بفاصل 1.

في بعض الحالات، لا يمكن إنشاء المجموعة الكاملة من الملاحظات بتركيبة بسيطة من المتغيرات. في هذه الحالة، يمكنك القيام يدوياً بما تفعله complete() من أجلك: إنشاء إطار بيانات يحتوي على جميع الصفوف التي يجب أن تكون موجودة (باستخدام أي تركيبات من التقنيات التي تحتاجها)، ثم دمجها مع مجموعة البيانات الأصلية باستخدام dplyr::full_join().

18.3.3 عمليات الدمج (Joins)

يقودنا هذا إلى طريقة مهمة أخرى للكشف عن الملاحظات المفقودة ضمناً: عمليات الدمج (joins). ستتعلم المزيد عن عمليات الدمج في الفصل 19، لكننا أردنا ذكرها لك هنا سريعاً لأنك غالباً ما تعرف أن القيم مفقودة من مجموعة بيانات واحدة فقط عند مقارنتها بمجموعة أخرى.

تعتبر دالة dplyr::anti_join(x, y) أداة مفيدة بشكل خاص هنا لأنها تحدد فقط الصفوف في x التي ليس لها تطابق في y. على سبيل المثال، يمكننا استخدام عمليتي anti_join() للكشف عن أننا نقتقد معلومات لـ 4 مطارات و 722 طائرة مذكورة في جدول flights:

library(nycflights13)

flights |> 
  distinct(faa = dest) |> 
  anti_join(airports)
#> Joining with `by = join_by(faa)`
#> # A tibble: 4 × 1
#>   faa  
#>   <chr>
#> 1 BQN  
#> 2 SJU  
#> 3 STT  
#> 4 PSE

flights |> 
  distinct(tailnum) |> 
  anti_join(planes)
#> Joining with `by = join_by(tailnum)`
#> # A tibble: 722 × 1
#>   tailnum
#>   <chr>  
#> 1 N3ALAA 
#> 2 N3DUAA 
#> 3 N542MQ 
#> 4 N730MQ 
#> 5 N9EAMQ 
#> 6 N532UA 
#> # ℹ 716 more rows

18.3.4 تمارين

  1. هل يمكنك العثور على أي علاقة بين شركة الطيران (carrier) والصفوف التي يبدو أنها مفقودة من جدول planes؟

18.4 العوامل والمجموعات الفارغة

النوع الأخير من حالات الفقدان هو المجموعة الفارغة (empty group)، وهي مجموعة لا تحتوي على أي ملاحظات، والتي يمكن أن تنشأ عند العمل مع العوامل (factors). على سبيل المثال، تخيل أن لدينا مجموعة بيانات تحتوي على بعض المعلومات الصحية عن الأشخاص:

health <- tibble(
  name   = c("Ikaia", "Oletta", "Leriah", "Dashay", "Tresaun"),
  smoker = factor(c("no", "no", "no", "no", "no"), levels = c("yes", "no")),
  age    = c(34, 88, 75, 47, 56),
)

ونريد حساب عدد المدخنين باستخدام dplyr::count():

health |> count(smoker)
#> # A tibble: 1 × 2
#>   smoker     n
#>   <fct>  <int>
#> 1 no         5

تحتوي مجموعة البيانات هذه على غير المدخنين فقط، لكننا نعلم أن المدخنين موجودون؛ وبالتالي فإن مجموعة المدخنين فارغة. يمكننا التوجيه لـ count() للإبقاء على جميع المجموعات، حتى تلك التي لم نراها في البيانات، باستخدام .drop = FALSE:

health |> count(smoker, .drop = FALSE)
#> # A tibble: 2 × 2
#>   smoker     n
#>   <fct>  <int>
#> 1 yes        0
#> 2 no         5

ينطبق نفس المبدأ على المحاور المنفصلة في ggplot2، والتي ستسقط أيضاً المستويات التي لا تحتوي على أي قيم. يمكنك إجبارها على العرض عن طريق تقديم drop = FALSE إلى المحور المنفصل المناسب:

ggplot(health, aes(x = smoker)) +
  geom_bar() +
  scale_x_discrete()

ggplot(health, aes(x = smoker)) +
  geom_bar() +
  scale_x_discrete(drop = FALSE)

مخطط شريطي بقيمة واحدة على المحور الأفقي، "no".

نفس المخطط الشريطي السابق، ولكن الآن مع قيمتين على المحور الأفقي، "yes" و "no". لا يوجد شريط للفئة "yes".

تظهر نفس المشكلة بشكل عام مع dplyr::group_by(). ومرة أخرى يمكنك استخدام .drop = FALSE للحفاظ على جميع مستويات العوامل:

health |> 
  group_by(smoker, .drop = FALSE) |> 
  summarize(
    n = n(),
    mean_age = mean(age),
    min_age = min(age),
    max_age = max(age),
    sd_age = sd(age)
  )
#> # A tibble: 2 × 6
#>   smoker     n mean_age min_age max_age sd_age
#>   <fct>  <int>    <dbl>   <dbl>   <dbl>  <dbl>
#> 1 yes        0      NaN     Inf    -Inf   NA  
#> 2 no         5       60      34      88   21.6

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

# متجه يحتوي على قيمتين مفقودتين
x1 <- c(NA, NA)
length(x1)
#> [1] 2

# متجه لا يحتوي على شيء
x2 <- numeric()
length(x2)
#> [1] 0

تعمل جميع دلالات التلخيص (summary functions) مع المتجهات ذات الطول الصفري (zero-length vectors)، ولكنها قد تُرجع نتائج قد تبدو مفاجئة للوهلة الأولى. نرى هنا أن mean(age) تُرجع NaN لأن mean(age) تساوي sum(age)/length(age)، والتي تؤول هنا إلى 0/0. أما الدالتان max() و min() فتُرجعان -Inf و Inf للمتجهات الفارغة، بحيث إذا قمت بدمج النتائج مع متجه غير فارغ من البيانات الجديدة وأعادت الحساب، فستحصل على القيمة الدنيا أو القصوى للبيانات الجديدة1.

في بعض الأحيان يكون النهج الأبسط هو إجراء التلخيص ثم جعل القيم المفقودة الضمنية صريحة باستخدام complete().

health |> 
  group_by(smoker) |> 
  summarize(
    n = n(),
    mean_age = mean(age),
    min_age = min(age),
    max_age = max(age),
    sd_age = sd(age)
  ) |> 
  complete(smoker)
#> # A tibble: 2 × 6
#>   smoker     n mean_age min_age max_age sd_age
#>   <fct>  <int>    <dbl>   <dbl>   <dbl>  <dbl>
#> 1 yes       NA       NA      NA      NA   NA  
#> 2 no         5       60      34      88   21.6

العيب الرئيسي لهذا النهج هو أنك تحصل على NA للعدد (count)، على الرغم من أنك تعلم أنه يجب أن يكون صفراً.

18.5 ملخص

القيم المفقودة غريبة! أحياناً يتم تسجيلها كـ NA صريحة ولكن في أوقات أخرى لا تلاحظها إلا من خلال غيابها. لقد قدم لك هذا الفصل بعض الأدوات للعمل مع القيم المفقودة الصريحة، وأدوات للكشف عن القيم المفقودة الضمنية، وناقش بعض الطرق التي يمكن أن تتحول بها الضمنية إلى صريحة والعكس صحيح.

في الفصل التالي، نتناول الفصل الأخير في هذا الجزء من الكتاب: عمليات الدمج (joins). هذا يتضمن تغييراً طفيفاً مقارنة بالفصول السابقة لأننا سنناقش الأدوات التي تعمل مع إطارات البيانات ككل، وليس مع المكونات والبيانات الداخلية التي تضعها داخل إطار البيانات.


  1. بعبارة أخرى، min(c(x, y)) تساوي دائماً min(min(x), min(y)).↩︎