26  التكرار (Iteration)

26.1 مقدمة

في هذا الفصل، ستتعلم أدوات التكرار (Iteration)، وهي تنفيذ نفس الإجراء بشكل متكرر على كائنات مختلفة. غالباً ما يبدو التكرار في لغة R مختلفاً تماماً عن لغات البرمجة الأخرى لأن الكثير منه يكون ضمنياً ونحصل عليه تلقائياً. على سبيل المثال، إذا أردت مضاعفة المتجه العددي x في R، يمكنك فقط كتابة 2 * x. بينما في معظم اللغات الأخرى، ستحتاج إلى مضاعفة كل عنصر من عناصر x صراحةً باستخدام نوع ما من حلقات التكرار (for loops).

لقد قدم لك هذا الكتاب بالفعل مجموعة صغيرة ولكنها قوية من الأدوات التي تؤدي نفس الإجراء لـ “أشياء” متعددة:

حان الوقت الآن لتعلم بعض الأدوات الأكثر عمومية، والتي تُسمى غالباً أدوات البرمجة الوظيفية (Functional Programming) لأنها مبنية حول دالّات تأخذ دالّات أخرى كمدخلات. قد يتجه تعلم البرمجة الوظيفية بسهولة نحو التجريد، ولكن في هذا الفصل سنحافظ على الطرح التطبيقي الملموس من خلال التركيز على ثلاث مهام شائعة: تعديل أعمدة متعددة، قراءة ملفات متعددة، وحفظ كائنات متعددة.

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

سنركز في هذا الفصل على الأدوات التي توفرها حزمتا dplyr و purrr، وكلاهما من الأعضاء الأساسيين في بيئة tidyverse. لقد رأيت dplyr من قبل، ولكن purrr تُعد حزمة جديدة. سنستخدم فقط دالّتين من حزمة purrr في هذا الفصل، ولكنها حزمة رائعة للاستكشاف كلما حسّنت مهاراتك البرمجية.

26.2 تعديل أعمدة متعددة

تخيل أن لديك هذا الجدول البسيط (tibble) وتريد حساب عدد الملاحظات وإيجاد الوسيط (median) لكل عمود.

set.seed(1014)
df <- tibble(
  a = rnorm(10),
  b = rnorm(10),
  c = rnorm(10),
  d = rnorm(10)
)

يمكنك القيام بذلك عبر النسخ واللصق:

df |> summarize(
  n = n(),
  a = median(a),
  b = median(b),
  c = median(c),
  d = median(d),
)
#> # A tibble: 1 × 5
#>       n      a      b       c     d
#>   <int>  <dbl>  <dbl>   <dbl> <dbl>
#> 1    10 -0.246 -0.287 -0.0567 0.144

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

df |> summarize(
  n = n(),
  across(a:d, median),
)
#> # A tibble: 1 × 5
#>       n      a      b       c     d
#>   <int>  <dbl>  <dbl>   <dbl> <dbl>
#> 1    10 -0.246 -0.287 -0.0567 0.144

تحتوي across() على ثلاث وسائط (arguments) مهمة بشكل خاص، والتي سنناقشها بالتفصيل في الأقسام التالية. ستستخدم أول وسيطين في كل مرة تستخدم فيها across(): الوسيط الأول، .cols، يحدد الأعمدة التي تريد التكرار عليها، والوسيط الثاني، .fns، يحدد ما يجب فعله بكل عمود. يمكنك استخدام الوسيط .names عندما تحتاج إلى تحكم إضافي في أسماء أعمدة المخرجات، وهو أمر مهم بشكل خاص عند استخدام across() مع mutate(). سنناقش أيضاً متغيرين مهمين، if_any() و if_all()، اللذين يعملان مع filter().

26.2.1 تحديد الأعمدة باستخدام .cols

الوسيط الأول لـ across()، وهو .cols، يحدد الأعمدة المراد تحويلها. يستخدم هذا نفس المواصفات مثل select()، قسم 3.3.2 ، لذلك يمكنك استخدام دالّات مثل starts_with() و ends_with() لتحديد الأعمدة بناءً على أسمائها.

هناك تقنيتان إضافيتان للتحديد مفيدتان بشكل خاص لـ across(): وهما everything() و where(). تعتبر everything() مباشرة: حيث تحدد كل عمود (ليس عمود تجميع):

set.seed(1014)
df <- tibble(
  grp = sample(2, 10, replace = TRUE),
  a = rnorm(10),
  b = rnorm(10),
  c = rnorm(10),
  d = rnorm(10)
)

df |> 
  group_by(grp) |> 
  summarize(across(everything(), median))
#> # A tibble: 2 × 5
#>     grp      a      b       c       d
#>   <int>  <dbl>  <dbl>   <dbl>   <dbl>
#> 1     1 -0.244 -0.522 -0.0974 -0.251 
#> 2     2 -0.247  0.468  0.112   0.0700

لاحظ أن أعمدة التجميع (grp هنا) لا يتم تضمينها في across()، لأنه يتم الحفاظ عليها تلقائياً بواسطة summarize().

تسمح لك where() بتحديد الأعمدة بناءً على نوعها:

  • where(is.numeric) تحدد جميع الأعمدة العددية.
  • where(is.character) تحدد جميع الأعمدة النصية.
  • where(is.Date) تحدد جميع أعمدة التاريخ.
  • where(is.POSIXct) تحدد جميع أعمدة التاريخ والوقت.
  • where(is.logical) تحدد جميع الأعمدة المنطقية.

تماماً مثل المحددات الأخرى، يمكنك دمج هذه الوسائط مع الجبر البولياني. على سبيل المثال، !where(is.numeric) تحدد جميع الأعمدة غير العددية، و starts_with("a") & where(is.logical) تحدد جميع الأعمدة المنطقية التي تبدأ أسمائها بـ “a”.

26.2.2 استدعاء دالّة واحدة

يُحدد المعامل (Argument) الثاني للـدالة across() كيفية تحويل كل عمود. وفي الحالات البسيطة، كما هو موضح أعلاه، سيكون هذا المعامل عبارة عن دالة واحدة موجودة بالفعل. وهذه ميزة خاصة ومميزة جداً في لغة R: حيث نقوم بتمرير دالة واحدة (مثل median، و mean، و str_flatten…) إلى دالة أخرى (across). وتُعد هذه إحدى الميزات التي تجعل R لغة برمجة وظيفية (Functional programming language).

من المهم ملاحظة أننا نمرر هذه الدالّة إلى across() حتى تتمكن across() من استدعائها؛ نحن لا نستدعيها بأنفسنا. هذا يعني أن اسم الدالّة لا ينبغي أبداً أن يتبعه قوسان (). إذا نسيت، فستحصل على خطأ:

df |> 
  group_by(grp) |> 
  summarize(across(everything(), median()))
#> Error in `summarize()`:
#> ℹ In argument: `across(everything(), median())`.
#> Caused by error in `median.default()`:
#> ! argument "x" is missing, with no default

ينشأ هذا الخطأ لأنك تستدعي الدالّة بدون مدخلات، مثل:

median()
#> Error in `median.default()`:
#> ! argument "x" is missing, with no default

26.2.3 استدعاء دالّات متعددة

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

set.seed(1014)
rnorm_na <- function(n, n_na, mean = 0, sd = 1) {
  sample(c(rnorm(n - n_na, mean = mean, sd = sd), rep(NA, n_na)))
}

df_miss <- tibble(
  a = rnorm_na(5, 1),
  b = rnorm_na(5, 1),
  c = rnorm_na(5, 2),
  d = rnorm(5)
)
df_miss |> 
  summarize(
    across(a:d, median),
    n = n()
  )
#> # A tibble: 1 × 5
#>       a     b     c     d     n
#>   <dbl> <dbl> <dbl> <dbl> <int>
#> 1    NA    NA    NA 0.413     5

سيكون من الجيد لو تمكنا من تمرير na.rm = TRUE إلى median() لإزالة هذه القيم المفقودة. للقيام بذلك، بدلاً من استدعاء median() مباشرةً، نحتاج إلى إنشاء دالّة جديدة تستدعي median() مع الوسائط المطلوبة:

df_miss |> 
  summarize(
    across(a:d, function(x) median(x, na.rm = TRUE)),
    n = n()
  )
#> # A tibble: 1 × 5
#>        a      b      c     d     n
#>    <dbl>  <dbl>  <dbl> <dbl> <int>
#> 1 -0.703 -0.265 -0.522 0.413     5

هذا الأسلوب مُسهَب نوعاً ما، لذا تُوفر لغة R اختصاراً مفيداً: فبالنسبة لهذا النوع من الدوال المؤقتة أو المجهولة (Anonymous)1، يمكنك استبدال الكلمة المفتاحية function بالرمز \2:

df_miss |> 
  summarize(
    across(a:d, \(x) median(x, na.rm = TRUE)),
    n = n()
  )

في كلتا الحالتين، تتوسع across() فعلياً إلى الكود التالي:

df_miss |> 
  summarize(
    a = median(a, na.rm = TRUE),
    b = median(b, na.rm = TRUE),
    c = median(c, na.rm = TRUE),
    d = median(d, na.rm = TRUE),
    n = n()
  )

عندما نزيل القيم المفقودة من median()، سيكون من الجيد معرفة عدد القيم التي تم إزالتها بالضبط. يمكننا معرفة ذلك من خلال تقديم دالّتين إلى across(): إحداهما لحساب الوسيط والأخرى لحساب عدد القيم المفقودة. يمكنك تقديم دالّات متعددة باستخدام قائمة مسماة (named list) في الوسيط .fns:

df_miss |> 
  summarize(
    across(a:d, list(
      median = \(x) median(x, na.rm = TRUE),
      n_miss = \(x) sum(is.na(x))
    )),
    n = n()
  )
#> # A tibble: 1 × 9
#>   a_median a_n_miss b_median b_n_miss c_median c_n_miss d_median d_n_miss
#>      <dbl>    <int>    <dbl>    <int>    <dbl>    <int>    <dbl>    <int>
#> 1   -0.703        1   -0.265        1   -0.522        2    0.413        0
#> # ℹ 1 more variable: n <int>

إذا نظرت عن كثب، فقد تستنتج أن الأعمدة تُسمى باستخدام مواصفة glue (قسم 14.3.2) مثل {.col}_{.fn}، حيث تمثل .col اسم العمود الأصلي، وتُعبر .fn عن اسم الدالة. وهذا ليس مجرد مصادفة! فكما ستتعلم في القسم التالي، يمكنك استخدام المعامل .names لتقديم مواصفة glue الخاصة بك.

26.2.4 أسماء الأعمدة

تُسمى مخرجات الدالة ()across وفقاً للمواصفة المحددة في المعامل .names. ويمكننا تحديد مواصفتنا الخاصة إذا أردنا أن يظهر اسم الدالة أولاً3:

df_miss |> 
  summarize(
    across(
      a:d,
      list(
        median = \(x) median(x, na.rm = TRUE),
        n_miss = \(x) sum(is.na(x))
      ),
      .names = "{.fn}_{.col}"
    ),
    n = n(),
  )
#> # A tibble: 1 × 9
#>   median_a n_miss_a median_b n_miss_b median_c n_miss_c median_d n_miss_d
#>      <dbl>    <int>    <dbl>    <int>    <dbl>    <int>    <dbl>    <int>
#> 1   -0.703        1   -0.265        1   -0.522        2    0.413        0
#> # ℹ 1 more variable: n <int>

يكون الوسيط .names مهماً بشكل خاص عند استخدام across() مع mutate(). افتراضياً، يتم إعطاء مخرجات across() نفس أسماء المدخلات. هذا يعني أن across() داخل mutate() ستستبدل الأعمدة الموجودة. على سبيل المثال، نستخدم هنا coalesce() لاستبدال قيم NA بـ 0:

df_miss |> 
  mutate(
    across(a:d, \(x) coalesce(x, 0))
  )
#> # A tibble: 5 × 4
#>          a      b      c        d
#>      <dbl>  <dbl>  <dbl>    <dbl>
#> 1 -0.00557 -0.283 -1.86  -0.783  
#> 2  0.255   -0.247 -0.522 -0.00289
#> 3 -1.40    -0.554  0.512  0.413  
#> 4 -2.44    -0.244  0      0.724  
#> 5  0        0      0      2.35

إذا كنت ترغب بدلاً من ذلك في إنشاء أعمدة جديدة، يمكنك استخدام الوسيط .names لإعطاء المخرجات أسماء جديدة:

df_miss |> 
  mutate(
    across(a:d, \(x) coalesce(x, 0), .names = "{.col}_na_zero")
  )
#> # A tibble: 5 × 8
#>          a      b      c        d a_na_zero b_na_zero c_na_zero d_na_zero
#>      <dbl>  <dbl>  <dbl>    <dbl>     <dbl>     <dbl>     <dbl>     <dbl>
#> 1 -0.00557 -0.283 -1.86  -0.783    -0.00557    -0.283    -1.86   -0.783  
#> 2  0.255   -0.247 -0.522 -0.00289   0.255      -0.247    -0.522  -0.00289
#> 3 -1.40    -0.554  0.512  0.413    -1.40       -0.554     0.512   0.413  
#> 4 -2.44    -0.244 NA      0.724    -2.44       -0.244     0       0.724  
#> 5 NA       NA     NA      2.35      0           0         0       2.35

26.2.5 التصفية (Filtering)

تعد across() خياراً رائعاً مع summarize() و mutate()، ولكن استخدامها أكثر صعوبة مع filter()، لأنك عادةً ما تجمع بين شروط متعددة إما بـ | أو &. من الواضح أن across() يمكن أن تساعد في إنشاء أعمدة منطقية متعددة، ولكن ماذا بعد ذلك؟ لذلك توفر dplyr متغيرين من across() يسمى الأول if_any() والثاني if_all():

# نفس df_miss |> filter(is.na(a) | is.na(b) | is.na(c) | is.na(d))
df_miss |> filter(if_any(a:d, is.na))
#> # A tibble: 2 × 4
#>       a      b     c     d
#>   <dbl>  <dbl> <dbl> <dbl>
#> 1 -2.44 -0.244    NA 0.724
#> 2 NA    NA        NA 2.35

# نفس df_miss |> filter(is.na(a) & is.na(b) & is.na(c) & is.na(d))
df_miss |> filter(if_all(a:d, is.na))
#> # A tibble: 0 × 4
#> # ℹ 4 variables: a <dbl>, b <dbl>, c <dbl>, d <dbl>

26.2.6 across() داخل الدالّات

تعتبر across() مفيدة بشكل خاص عند البرمجة بها لأنها تسمح لك بالعمل على أعمدة متعددة. على سبيل المثال، يستخدم جاكوب سكوت Jacob Scott هذه الدالّة المساعدة الصغيرة التي تغلف مجموعة من دالّات lubridate لتوسيع جميع أعمدة التاريخ إلى أعمدة للسنة، والشهر، واليوم:

expand_dates <- function(df) {
  df |> 
    mutate(
      across(where(is.Date), list(year = year, month = month, day = mday))
    )
}

df_date <- tibble(
  name = c("Amy", "Bob"),
  date = ymd(c("2009-08-03", "2010-01-16"))
)

df_date |> 
  expand_dates()
#> # A tibble: 2 × 5
#>   name  date       date_year date_month date_day
#>   <chr> <date>         <dbl>      <dbl>    <int>
#> 1 Amy   2009-08-03      2009          8        3
#> 2 Bob   2010-01-16      2010          1       16

تسهل ()across أيضاً مدي المخرجات بأعمدة متعددة في معامل واحد لأن المعامل الأول يستخدم التحديد الأنيق (tidy-selection)؛ كل ما عليك تذكره هو احتواء (Embrace) هذا المعامل بالأقواس المزدوجة، كما ناقشنا في قسم 25.3.2. على سبيل المثال، ستلخص هذه الدالة المتوسطات للأنواع الرقمية افتراضياً. ولكن من خلال توفير المعامل الثاني، يمكنك اختيار تلخيص أعمدة محددة فقط:

summarize_means <- function(df, summary_vars = where(is.numeric)) {
  df |> 
    summarize(
      across({{ summary_vars }}, \(x) mean(x, na.rm = TRUE)),
      n = n(),
      .groups = "drop"
    )
}
diamonds |> 
  group_by(cut) |> 
  summarize_means()
#> # A tibble: 5 × 9
#>   cut       carat depth table price     x     y     z     n
#>   <ord>     <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <int>
#> 1 Fair      1.05   64.0  59.1 4359.  6.25  6.18  3.98  1610
#> 2 Good      0.849  62.4  58.7 3929.  5.84  5.85  3.64  4906
#> 3 Very Good 0.806  61.8  58.0 3982.  5.74  5.77  3.56 12082
#> 4 Premium   0.892  61.3  58.7 4584.  5.97  5.94  3.65 13791
#> 5 Ideal     0.703  61.7  56.0 3458.  5.51  5.52  3.40 21551

diamonds |> 
  group_by(cut) |> 
  summarize_means(c(carat, x:z))
#> # A tibble: 5 × 6
#>   cut       carat     x     y     z     n
#>   <ord>     <dbl> <dbl> <dbl> <dbl> <int>
#> 1 Fair      1.05   6.25  6.18  3.98  1610
#> 2 Good      0.849  5.84  5.85  3.64  4906
#> 3 Very Good 0.806  5.74  5.77  3.56 12082
#> 4 Premium   0.892  5.97  5.94  3.65 13791
#> 5 Ideal     0.703  5.51  5.52  3.40 21551

26.2.7 المقارنة مع pivot_longer()

قبل أن نواصل، يجدر بنا الإشارة إلى وجود رابط مثير للاهتمام بين الدالتين across() و pivot_longer() (قسم 5.3). في كثير من الحالات، يمكنك إجراء نفس الحسابات عن طريق إعادة تدوير البيانات (pivoting) أولاً، ثم تنفيذ العمليات حسب المجموعة بدلاً من إجرائها حسب العمود. على سبيل المثال، خذ هذا الملخص متعدد الدوال:

df |> 
  summarize(across(a:d, list(median = median, mean = mean)))
#> # A tibble: 1 × 8
#>   a_median  a_mean b_median  b_mean c_median  c_mean d_median d_mean
#>      <dbl>   <dbl>    <dbl>   <dbl>    <dbl>   <dbl>    <dbl>  <dbl>
#> 1   -0.246 -0.0426    0.155 -0.0656   0.0480 -0.0297   -0.193 -0.200

يمكننا حساب نفس القيم عن طريق التدوير إلى الشكل الطولي (pivot longer) ثم التلخيص:

long <- df |> 
  pivot_longer(a:d) |> 
  group_by(name) |> 
  summarize(
    median = median(value),
    mean = mean(value)
  )
long
#> # A tibble: 4 × 3
#>   name   median    mean
#>   <chr>   <dbl>   <dbl>
#> 1 a     -0.246  -0.0426
#> 2 b      0.155  -0.0656
#> 3 c      0.0480 -0.0297
#> 4 d     -0.193  -0.200

وإذا أردت نفس الهيكل مثل across() يمكنك التدوير مرة أخرى:

long |> 
  pivot_wider(
    names_from = name,
    values_from = c(median, mean),
    names_vary = "slowest",
    names_glue = "{name}_{.value}"
  )
#> # A tibble: 1 × 8
#>   a_median  a_mean b_median  b_mean c_median  c_mean d_median d_mean
#>      <dbl>   <dbl>    <dbl>   <dbl>    <dbl>   <dbl>    <dbl>  <dbl>
#> 1   -0.246 -0.0426    0.155 -0.0656   0.0480 -0.0297   -0.193 -0.200

هذه تقنية مفيدة يجب معرفتها لأنك قد تواجه أحياناً مشكلة لا يمكن حلها حالياً بـ across(): عندما يكون لديك مجموعات من الأعمدة تريد الحساب عليها في وقت واحد. على سبيل المثال، تخيل أن إطار البيانات لدينا يحتوي على قيم وأوزان ونريد حساب متوسط موزون:

set.seed(1014)
df_paired <- tibble(
  a_val = rnorm(10),
  a_wts = runif(10),
  b_val = rnorm(10),
  b_wts = runif(10),
  c_val = rnorm(10),
  c_wts = runif(10),
  d_val = rnorm(10),
  d_wts = runif(10)
)

لا توجد طريقة حالياً للقيام بذلك باستخدام الدالة across()4، ولكن الأمر أسهل نسبياً باستخدام الدالة pivot_longer():

df_long <- df_paired |> 
  pivot_longer(
    everything(), 
    names_to = c("group", ".value"), 
    names_sep = "_"
  )
df_long
#> # A tibble: 40 × 3
#>   group    val   wts
#>   <chr>  <dbl> <dbl>
#> 1 a     -1.40  0.290
#> 2 b     -1.86  0.461
#> 3 c      0.935 0.528
#> 4 d      2.76  0.709
#> 5 a      0.255 0.678
#> 6 b     -0.522 0.315
#> # ℹ 34 more rows

df_long |> 
  group_by(group) |> 
  summarize(mean = weighted.mean(val, wts))
#> # A tibble: 4 × 2
#>   group    mean
#>   <chr>   <dbl>
#> 1 a     -0.207 
#> 2 b     -0.237 
#> 3 c      0.0208
#> 4 d      0.0655

إذا لزم الأمر، يمكنك إعادة تدوير هذا الشكل مجدداً pivot_wider() إلى النموذج الأصلي.

26.2.8 تمارين

  1. مارس مهاراتك في استخدام across() من خلال:

    1. حساب عدد القيم الفريدة في كل عمود من palmerpenguins::penguins.

    2. حساب المتوسط الحسابي لكل عمود في mtcars.

    3. تجميع diamonds حسب cut و clarity و color ثم حساب عدد الملاحظات ومتوسط كل عمود عددي.

  2. ماذا يحدث إذا استخدمت قائمة من الدالّات في across() ولكنك لم تقم بتسميتها؟ كيف ستتم تسمية المخرجات؟

  3. اضبط الدالة expand_dates() لتقوم بحذف أعمدة التواريخ تلقائياً بعد توسيعها. هل تحتاج إلى استخدام التضمين/التطويق (embrace) مع أيٍّ من الوسائط؟

  4. اشرح ما تفعله كل خطوة في هذه الدالّة. ما هي الميزة الخاصة لـ where() التي نستفيد منها؟

    show_missing <- function(df, group_vars, summary_vars = everything()) {
      df |> 
        group_by(pick({{ group_vars }})) |> 
        summarize(
          across({{ summary_vars }}, \(x) sum(is.na(x))),
          .groups = "drop"
        ) |>
        select(where(\(x) any(x > 0)))
    }
    nycflights13::flights |> show_missing(c(year, month, day))

26.3 قراءة ملفات متعددة

في القسم السابق، تعلمت كيفية استخدام الدالة dplyr::across() لتكرار عملية التحويل على أعمدة متعددة. وفي هذا القسم، ستتعلم كيفية استخدام الدالة purrr::map() لتنفيذ إجراءٍ ما على كل ملف في مجلدٍ معين. دعنا نبدأ بتمهيد مشوّق: تخيل أن لديك مجلداً مليئاً بجدوال بيانات Excel5 تريد قراءتها. يمكنك القيام بذلك عبر النسخ واللصق:

data2019 <- readxl::read_excel("data/y2019.xlsx")
data2020 <- readxl::read_excel("data/y2020.xlsx")
data2021 <- readxl::read_excel("data/y2021.xlsx")
data2022 <- readxl::read_excel("data/y2022.xlsx")

ثم استخدام dplyr::bind_rows() لدمجها جميعاً معاً:

data <- bind_rows(data2019, data2020, data2021, data2022)

يمكنك أن تتخيل أن هذا سيصبح مملاً بسرعة، خاصة إذا كان لديك مئات الملفات، وليس أربعة فقط. توضح لك الأقسام التالية كيفية أتمتة هذا النوع من المهام. هناك ثلاث خطوات أساسية: استخدم list.files() لإدراج جميع الملفات في الدليل، ثم استخدم purrr::map() لقراءة كل منها في قائمة، ثم استخدم purrr::list_rbind() لدمجها في إطار بيانات واحد. سنناقش بعد ذلك كيفية التعامل مع الحالات ذات التباين المتزايد، حيث لا يمكنك القيام بنفس الشيء بالضبط لكل ملف.

26.3.1 إدراج الملفات في مجلد

كما يوحي الاسم، تقوم list.files() بإدراج الملفات الموجودة في المجلد. ستستخدم تقريباً ثلاث وسائط دائماً:

  • الوسيط الأول، path، هو المجلد الذي سيتم البحث فيه.

  • pattern عبارة عن تعبير نمطي (regular expression) يُستخدم لتصفية أسماء الملفات. النمط الأكثر شيوعاً هو شيء مثل [.]xlsx$ أو [.]csv$ للعثور على جميع الملفات ذات الامتداد المحدد.

  • تحدد full.names ما إذا كان ينبغي تضمين اسم المجلد في المخرجات أم لا. ترغب دائماً تقريباً في جعل قيمتها TRUE.

لتوضيح مثالنا الدافع بشكل ملموس، يحتوي هذا الكتاب على مجلد يضم 12 جدول بيانات Excel تحتوي على بيانات من حزمة gapminder. يوجد هذا المجلد على الرابط: https://github.com/hadley/r4ds/tree/main/data/gapminder. يحتوي كل ملف على بيانات سنة واحدة لـ 142 دولة. يمكننا إدراجها جميعاً باستخدام استدعاء مناسب لـ list.files():

paths <- list.files("data/gapminder", pattern = "[.]xlsx$", full.names = TRUE)
paths
#>  [1] "data/gapminder/1952.xlsx" "data/gapminder/1957.xlsx"
#>  [3] "data/gapminder/1962.xlsx" "data/gapminder/1967.xlsx"
#>  [5] "data/gapminder/1972.xlsx" "data/gapminder/1977.xlsx"
#>  [7] "data/gapminder/1982.xlsx" "data/gapminder/1987.xlsx"
#>  [9] "data/gapminder/1992.xlsx" "data/gapminder/1997.xlsx"
#> [11] "data/gapminder/2002.xlsx" "data/gapminder/2007.xlsx"

26.3.2 القوائم (Lists)

الآن بعد أن أصبح لدينا هذه المسارات الـ 12، يمكننا استدعاء read_excel() 12 مرة للحصول على 12 إطار بيانات:

gapminder_1952 <- readxl::read_excel("data/gapminder/1952.xlsx")
gapminder_1957 <- readxl::read_excel("data/gapminder/1957.xlsx")
gapminder_1962 <- readxl::read_excel("data/gapminder/1962.xlsx")
 ...,
gapminder_2007 <- readxl::read_excel("data/gapminder/2007.xlsx")

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

files <- list(
  readxl::read_excel("data/gapminder/1952.xlsx"),
  readxl::read_excel("data/gapminder/1957.xlsx"),
  readxl::read_excel("data/gapminder/1962.xlsx"),
  ...,
  readxl::read_excel("data/gapminder/2007.xlsx")
)

الآن بعد أن أصبح لديك إطارات البيانات هذه في قائمة، كيف تستخرج واحداً منها؟ يمكنك استخدام files[[i]] لاستخراج العنصر رقم \(i\) (ith):

files[[3]]
#> # A tibble: 142 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         32.0 10267083      853.
#> 2 Albania     Europe       64.8  1728137     2313.
#> 3 Algeria     Africa       48.3 11000948     2551.
#> 4 Angola      Africa       34    4826015     4269.
#> 5 Argentina   Americas     65.1 21283783     7133.
#> 6 Australia   Oceania      70.9 10794968    12217.
#> # ℹ 136 more rows

سنعود لمناقشة [[ بمزيد من التفصيل في قسم 27.3.

26.3.3 purrr::map() و list_rbind()

الكود المخصص لجمع إطارات البيانات هذه في قائمة “يدوياً” هو تقريباً مجهد في كتابته بنفس قدر كود قراءة الملفات واحداً تلو الآخر. لحسن الحظ، يمكننا استخدام purrr::map() للاستفادة بشكل أفضل من متجه المسارات paths الخاص بنا. تشبه map() الدالّة across()، ولكن بدلاً من القيام بشيء ما لكل عمود في إطار البيانات، فإنها تفعل شيئاً لكل عنصر في المتجه. تُعد map(x, f) اختصاراً لـ:

list(
  f(x[[1]]),
  f(x[[2]]),
  ...,
  f(x[[n]])
)

لذا يمكننا استخدام map() للحصول على قائمة تحتوي على 12 إطار بيانات:

files <- map(paths, readxl::read_excel)
length(files)
#> [1] 12

files[[1]]
#> # A tibble: 142 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.
#> 2 Albania     Europe       55.2  1282697     1601.
#> 3 Algeria     Africa       43.1  9279525     2449.
#> 4 Angola      Africa       30.0  4232095     3521.
#> 5 Argentina   Americas     62.5 17876956     5911.
#> 6 Australia   Oceania      69.1  8691212    10040.
#> # ℹ 136 more rows

(هيكل البيانات هذا هو هيكل آخر لا يعرض نفسه بشكل مدمج للغاية مع str()، لذا قد ترغب في تحميله في RStudio وفحصه باستخدام View()).

الآن يمكننا استخدام purrr::list_rbind() لدمج قائمة إطارات البيانات تلك في إطار بيانات واحد:

list_rbind(files)
#> # A tibble: 1,704 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.
#> 2 Albania     Europe       55.2  1282697     1601.
#> 3 Algeria     Africa       43.1  9279525     2449.
#> 4 Angola      Africa       30.0  4232095     3521.
#> 5 Argentina   Americas     62.5 17876956     5911.
#> 6 Australia   Oceania      69.1  8691212    10040.
#> # ℹ 1,698 more rows

أو يمكننا القيام بكِلتا الخطوتين في وقت واحد باستخدام الأنبوب (pipeline):

paths |> 
  map(readxl::read_excel) |> 
  list_rbind()

ماذا لو أردنا تمرير وسائط إضافية إلى read_excel()؟ نستخدم نفس التقنية التي استخدمناها مع across(). على سبيل المثال، غالباً ما يكون من المفيد إلقاء نظرة خاطفة على الصفوف القليلة الأولى من البيانات باستخدام n_max = 1:

paths |> 
  map(\(path) readxl::read_excel(path, n_max = 1)) |> 
  list_rbind()
#> # A tibble: 12 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.
#> 2 Afghanistan Asia         30.3  9240934      821.
#> 3 Afghanistan Asia         32.0 10267083      853.
#> 4 Afghanistan Asia         34.0 11537966      836.
#> 5 Afghanistan Asia         36.1 13079460      740.
#> 6 Afghanistan Asia         38.4 14880372      786.
#> # ℹ 6 more rows

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

26.3.4 البيانات الموجودة في المسار

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

أولاً، نقوم بتسمية متجه المسارات. أسهل طريقة للقيام بذلك هي استخدام الدالّة set_names()، والتي يمكنها أخذ دالّة كمدخل. نستخدم هنا basename() لاستخراج اسم الملف فقط من المسار الكامل:

paths |> set_names(basename)
#>                  1952.xlsx                  1957.xlsx 
#> "data/gapminder/1952.xlsx" "data/gapminder/1957.xlsx" 
#>                  1962.xlsx                  1967.xlsx 
#> "data/gapminder/1962.xlsx" "data/gapminder/1967.xlsx" 
#>                  1972.xlsx                  1977.xlsx 
#> "data/gapminder/1972.xlsx" "data/gapminder/1977.xlsx" 
#>                  1982.xlsx                  1987.xlsx 
#> "data/gapminder/1982.xlsx" "data/gapminder/1987.xlsx" 
#>                  1992.xlsx                  1997.xlsx 
#> "data/gapminder/1992.xlsx" "data/gapminder/1997.xlsx" 
#>                  2002.xlsx                  2007.xlsx 
#> "data/gapminder/2002.xlsx" "data/gapminder/2007.xlsx"

يتم نقل هذه الأسماء تلقائياً بواسطة جميع دالّات map، لذا فإن قائمة إطارات البيانات ستكون لها نفس الأسماء:

files <- paths |> 
  set_names(basename) |> 
  map(readxl::read_excel)

هذا يجعل هذا الاستدعاء لـ map() اختصاراً لـ:

files <- list(
  "1952.xlsx" = readxl::read_excel("data/gapminder/1952.xlsx"),
  "1957.xlsx" = readxl::read_excel("data/gapminder/1957.xlsx"),
  "1962.xlsx" = readxl::read_excel("data/gapminder/1962.xlsx"),
  ...,
  "2007.xlsx" = readxl::read_excel("data/gapminder/2007.xlsx")
)

يمكنك أيضاً استخدام [[ لاستخراج العناصر حسب الاسم:

files[["1962.xlsx"]]
#> # A tibble: 142 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         32.0 10267083      853.
#> 2 Albania     Europe       64.8  1728137     2313.
#> 3 Algeria     Africa       48.3 11000948     2551.
#> 4 Angola      Africa       34    4826015     4269.
#> 5 Argentina   Americas     65.1 21283783     7133.
#> 6 Australia   Oceania      70.9 10794968    12217.
#> # ℹ 136 more rows

ثم نستخدم الوسيط names_to مع list_rbind() لإخباره بحفظ الأسماء في عمود جديد يسمى year ثم نستخدم readr::parse_number() لاستخراج الرقم من السلسلة النصية.

paths |> 
  set_names(basename) |> 
  map(readxl::read_excel) |> 
  list_rbind(names_to = "year") |> 
  mutate(year = parse_number(year))
#> # A tibble: 1,704 × 6
#>    year country     continent lifeExp      pop gdpPercap
#>   <dbl> <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1  1952 Afghanistan Asia         28.8  8425333      779.
#> 2  1952 Albania     Europe       55.2  1282697     1601.
#> 3  1952 Algeria     Africa       43.1  9279525     2449.
#> 4  1952 Angola      Africa       30.0  4232095     3521.
#> 5  1952 Argentina   Americas     62.5 17876956     5911.
#> 6  1952 Australia   Oceania      69.1  8691212    10040.
#> # ℹ 1,698 more rows

في الحالات الأكثر تعقيداً، قد تكون هناك متغيرات أخرى مخزنة في اسم المجلد، أو ربما يحتوي اسم الملف على أجزاء متعددة من البيانات. في هذه الحالة، استخدم set_names() (بدون أي وسائط) لتسجيل المسار الكامل، ثم استخدم tidyr::separate_wider_delim() وأخواتها لتحويلها إلى أعمدة مفيدة.

paths |> 
  set_names() |> 
  map(readxl::read_excel) |> 
  list_rbind(names_to = "year") |> 
  separate_wider_delim(year, delim = "/", names = c(NA, "dir", "file")) |> 
  separate_wider_delim(file, delim = ".", names = c("file", "ext"))
#> # A tibble: 1,704 × 8
#>   dir       file  ext   country     continent lifeExp      pop gdpPercap
#>   <chr>     <chr> <chr> <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 gapminder 1952  xlsx  Afghanistan Asia         28.8  8425333      779.
#> 2 gapminder 1952  xlsx  Albania     Europe       55.2  1282697     1601.
#> 3 gapminder 1952  xlsx  Algeria     Africa       43.1  9279525     2449.
#> 4 gapminder 1952  xlsx  Angola      Africa       30.0  4232095     3521.
#> 5 gapminder 1952  xlsx  Argentina   Americas     62.5 17876956     5911.
#> 6 gapminder 1952  xlsx  Australia   Oceania      69.1  8691212    10040.
#> # ℹ 1,698 more rows

26.3.5 احفظ عملك

الآن بعد أن قمت بكل هذا العمل الشاق للوصول إلى إطار بيانات مرتب (tidy)، فقد حان الوقت لحفظ عملك:

gapminder <- paths |> 
  set_names(basename) |> 
  map(readxl::read_excel) |> 
  list_rbind(names_to = "year") |> 
  mutate(year = parse_number(year))

write_csv(gapminder, "gapminder.csv")

الان، عندما تعود لهذه المشكلة في المستقبل، يمكنك قراءتها في ملف csv واحد. بالنسبة لمجموعات البيانات الكبيرة والأكثر غنى، قد يكون استخدام صيغة parquet خياراً أفضل من ملفات .csv، كما هو موضح في قسم 22.4.

إذا كنت تعمل في مشروع، فنحن نقترح تسمية الملف الذي يقوم بهذا النوع من تجهيز البيانات باسم مثل 0-cleanup.R. يشير الرمز 0 في اسم الملف إلى أنه ينبغي تشغيل هذا الملف قبل أي شيء آخر.

إذا كانت ملفات البيانات المدخلة تتغير مع مرور الوقت، فقد تفكر في تعلم أداة مثل targets لإعداد كود تنظيف البيانات ليعيد التشغيل تلقائياً كلما تم تعديل أحد الملفات المدخلة.

26.3.6 تكرارات بسيطة متعددة

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

على سبيل المثال، تخيل أنك تريد قراءة مجموعة من الملفات، وتصفية القيم المفقودة، وتدويرها (pivot)، ثم دمجها. إحدى الطرق لمعالجة المشكلة هي كتابة دالّة تأخذ ملفاً وتنفذ كل تلك الخطوات، ثم تستدعي map() مرة واحدة:

process_file <- function(path) {
  df <- read_csv(path)
  
  df |> 
    filter(!is.na(id)) |> 
    mutate(id = tolower(id)) |> 
    pivot_longer(jan:dec, names_to = "month")
}

paths |> 
  map(process_file) |> 
  list_rbind()

بدلاً من ذلك، يمكنك تنفيذ كل خطوة من خطوات process_file() على كل ملف:

paths |> 
  map(read_csv) |> 
  map(\(df) df |> filter(!is.na(id))) |> 
  map(\(df) df |> mutate(id = tolower(id))) |> 
  map(\(df) df |> pivot_longer(jan:dec, names_to = "month")) |> 
  list_rbind()

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

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

paths |> 
  map(read_csv) |> 
  list_rbind() |> 
  filter(!is.na(id)) |> 
  mutate(id = tolower(id)) |> 
  pivot_longer(jan:dec, names_to = "month")

26.3.7 البيانات غير المتجانسة (Heterogeneous data)

لسوء الحظ، لا يكون من الممكن أحياناً الانتقال من map() مباشرة إلى list_rbind() لأن إطارات البيانات تكون غير متجانسة للغاية بحيث تفشل list_rbind() أو تنتج إطار بيانات ليس مفيداً جداً. في هذه الحالة، لا يزال من المفيد البدء بتحميل جميع الملفات:

files <- paths |> 
  map(readxl::read_excel) 

عندئذٍ تكون الاستراتيجية المفيدة جداً هي التقاط بنية إطارات البيانات (Data frames) حتى تتمكن من استكشافها باستخدام مهاراتك في علم البيانات. وإحدى الطرق للقيام بذلك هي استخدام الدالة المفيدة df_types()6 التي تُرجع جدول بيانات من نوع tibble يحتوي على صف واحد لكل عمود:

df_types <- function(df) {
  tibble(
    col_name = names(df), 
    col_type = map_chr(df, vctrs::vec_ptype_full),
    n_miss = map_int(df, \(x) sum(is.na(x)))
  )
}

df_types(gapminder)
#> # A tibble: 6 × 3
#>   col_name  col_type  n_miss
#>   <chr>     <chr>      <int>
#> 1 year      double         0
#> 2 country   character      0
#> 3 continent character      0
#> 4 lifeExp   double         0
#> 5 pop       double         0
#> 6 gdpPercap double         0

يمكنك بعد ذلك تطبيق هذه الدالّة على جميع الملفات، وربما إجراء بعض التدوير (pivoting) لتسهيل رؤية مواضع الاختلافات. على سبيل المثال، هذا يسهل التحقق من أن جداول بيانات gapminder التي نعمل عليها كلها متجانسة تماماً:

files |> 
  map(df_types) |> 
  list_rbind(names_to = "file_name") |> 
  select(-n_miss) |> 
  pivot_wider(names_from = col_name, values_from = col_type)
#> # A tibble: 12 × 6
#>   file_name country   continent lifeExp pop    gdpPercap
#>   <chr>     <chr>     <chr>     <chr>   <chr>  <chr>    
#> 1 1952.xlsx character character double  double double   
#> 2 1957.xlsx character character double  double double   
#> 3 1962.xlsx character character double  double double   
#> 4 1967.xlsx character character double  double double   
#> 5 1972.xlsx character character double  double double   
#> 6 1977.xlsx character character double  double double   
#> # ℹ 6 more rows

إذا كانت الملفات تحتوي على تنسيقات غير متجانسة، فقد تحتاج إلى إجراء المزيد من المعالجة قبل أن تتمكن من دمجها بنجاح. قد ترغب في القراءة عن map_if() و map_at()؛ حيث تسمح لك map_if() بتعديل عناصر القائمة اختيارياً بناءً على قيمها، بينما تسمح لك map_at() بتعديل العناصر اختيارياً بناءً على أسمائها.

26.3.8 التعامل مع الأخطاء والإخفاقات

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

لحسن الحظ، تأتي purrr مع أداة مساعدة لمعالجة هذه المشكلة: possibly(). possibly() هي ما يُعرف بـ “مشغل الدالّة (function operator)”: فهي تأخذ دالّة وترجع دالّة وسلوكها معدل. على وجه الخصوص، تقوم possibly() بتغيير الدالّة من إصدار خطأ إلى إرجاع قيمة تحددها أنت:

files <- paths |> 
  map(possibly(\(path) readxl::read_excel(path), NULL))

data <- files |> list_rbind()

يعمل هذا بشكل جيد هنا لأن list_rbind()، مثل العديد من دالّات tidyverse، تتجاهل قيم NULL تلقائياً.

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

failed <- map_vec(files, is.null)
paths[failed]
#> character(0)

ثم استدعِ دالّة الاستيراد مرة أخرى لكل فشل واعرف ما الذي حدث بشكل خاطئ.

26.4 حفظ المخرجات المتعددة

في القسم السابق، تعلمت عن map()، وهي مفيدة لقراءة ملفات متعددة في كائن واحد. في هذا القسم، سنستكشف الآن عكس هذه المشكلة تقريباً: كيف يمكنك أخذ كائن واحد أو أكثر في R وحفظه في ملف واحد أو أكثر؟ سنستكشف هذا التحدي باستخدام ثلاثة أمثلة:

  • حفظ إطارات بيانات متعددة في قاعدة بيانات واحدة.
  • حفظ إطارات بيانات متعددة في ملفات .csv متعددة.
  • حفظ رسومات بيانية متعددة في ملفات .png متعددة.

26.4.1 الكتابة إلى قاعدة بيانات

في بعض الأحيان عند العمل مع ملفات متعددة في وقت واحد، لا يكون من الممكن استيعاب جميع بياناتك في الذاكرة مرة واحدة، ولا يمكنك إجراء map(files, read_csv). أحد المناهج للتعامل مع هذه المشكلة هو تحميل بياناتك في قاعدة بيانات حتى تتمكن من الوصول إلى الأجزاء التي تحتاجها فقط باستخدام dbplyr.

إذا كنت محظوظاً، فإن حزمة قاعدة البيانات التي تستخدمها ستوفر دالّة مفيدة تأخذ متجهاً من المسارات وتحملها جميعاً في قاعدة البيانات. هذه هي الحالة مع الدالّة duckdb_read_csv() الخاصة بـ duckdb:

con <- DBI::dbConnect(duckdb::duckdb())
duckdb::duckdb_read_csv(con, "gapminder", paths)

هذا سيعمل بشكل جيد هنا، لكن ليس لدينا ملفات csv، بل لدينا جداول بيانات excel. لذا سنضطر إلى القيام بذلك “يدوياً”. سيساعدك تعلم القيام بذلك يدوياً أيضاً عندما يكون لديك مجموعة من ملفات csv وقاعدة البيانات التي تعمل معها لا تحتوي على دالّة واحدة تحملها جميعاً.

نحتاج إلى البدء بإنشاء جدول سنملؤه بالبيانات. أسهل طريقة القيام بذلك هي إنشاء قالب (template)، وهو إطار بيانات وهمي يحتوي على جميع الأعمدة التي نريدها، ولكن فقط مع عينة من البيانات. بالنسبة لبيانات gapminder، يمكننا إنشاء هذا القالب عن طريق قراءة ملف واحد وإضافة السنة إليه:

template <- readxl::read_excel(paths[[1]])
template$year <- 1952
template
#> # A tibble: 142 × 6
#>   country     continent lifeExp      pop gdpPercap  year
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl> <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.  1952
#> 2 Albania     Europe       55.2  1282697     1601.  1952
#> 3 Algeria     Africa       43.1  9279525     2449.  1952
#> 4 Angola      Africa       30.0  4232095     3521.  1952
#> 5 Argentina   Americas     62.5 17876956     5911.  1952
#> 6 Australia   Oceania      69.1  8691212    10040.  1952
#> # ℹ 136 more rows

الآن يمكننا الاتصال ببيئة قاعدة البيانات، واستخدام DBI::dbCreateTable() لتحويل القالب الخاص بنا إلى جدول قاعدة بيانات:

con <- DBI::dbConnect(duckdb::duckdb())
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/Rtmp0oafXl/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
DBI::dbCreateTable(con, "gapminder", template)

لا تستخدم dbCreateTable() البيانات الموجودة في template، بل تستخدم فقط أسماء المتغيرات وأنواعها. لذا إذا تفحصنا جدول gapminder الآن، ستلاحظ أنه فارغ ولكنه يحتوي على المتغيرات التي نحتاجها بالأنواع التي نتوقعها:

con |> tbl("gapminder")
#> # A query:  ?? x 6
#> # Database: DuckDB 1.5.5 [unknown@Linux 6.17.0-1022-azure:R 4.6.1/:memory:]
#> # ℹ 6 variables: country <chr>, continent <chr>, lifeExp <dbl>, pop <dbl>,
#> #   gdpPercap <dbl>, year <dbl>

بعد ذلك، نحتاج إلى دالّة تأخذ مسار ملف واحد، وتقرأه في R، وتضيف النتيجة إلى جدول gapminder. يمكننا القيام بذلك عن طريق الجمع بين read_excel() و DBI::dbAppendTable():

append_file <- function(path) {
  df <- readxl::read_excel(path)
  df$year <- parse_number(basename(path))
  
  DBI::dbAppendTable(con, "gapminder", df)
}

الآن نحتاج إلى استدعاء append_file() مرة واحدة لكل عنصر من مسارات paths. هذا ممكن بالتأكيد باستخدام map():

paths |> map(append_file)

ولكننا لا نهتم بمخرجات append_file()، لذا بدلاً من map() من الأفضل قليلاً استخدام walk(). تفعل walk() نفس شيء map() بالضبط ولكنها تتخلص من المخرجات:

paths |> walk(append_file)

الآن يمكننا معرفة ما إذا كان لدينا جميع البيانات في جدولنا:

con |> 
  tbl("gapminder") |> 
  count(year)
#> # A query:  ?? x 2
#> # Database: DuckDB 1.5.5 [unknown@Linux 6.17.0-1022-azure:R 4.6.1/:memory:]
#>    year     n
#>   <dbl> <dbl>
#> 1  1967   142
#> 2  1977   142
#> 3  1987   142
#> 4  1952   142
#> 5  1957   142
#> 6  1962   142
#> # ℹ more rows

26.4.2 كتابة ملفات csv

ينطبق نفس المبدأ الأساسي إذا أردنا كتابة ملفات csv متعددة، واحد لكل مجموعة. دعنا نتخيل أننا نريد أخذ بيانات ggplot2::diamonds وحفظ ملف csv واحد لكل درجة نقاء clarity. أولاً نحتاج إلى إنشاء مجموعات البيانات الفردية تلك. هناك العديد من الطرق التي يمكنك من خلالها القيام بذلك، ولكن هناك طريقة واحدة نفضلها بشكل خاص: group_nest().

by_clarity <- diamonds |> 
  group_nest(clarity)

by_clarity
#> # A tibble: 8 × 2
#>   clarity               data
#>   <ord>   <list<tibble[,9]>>
#> 1 I1               [741 × 9]
#> 2 SI2            [9,194 × 9]
#> 3 SI1           [13,065 × 9]
#> 4 VS2           [12,258 × 9]
#> 5 VS1            [8,171 × 9]
#> 6 VVS2           [5,066 × 9]
#> # ℹ 2 more rows

هذا يعطينا جدولاً جديداً يتكون من ثمانية صفوف وعمودين. clarity هو متغير التجميع الخاص بنا و data هو عمود قائمة (list-column) يحتوي على جدول واحد لكل قيمة فريدة من clarity:

by_clarity$data[[1]]
#> # A tibble: 741 × 9
#>   carat cut       color depth table price     x     y     z
#>   <dbl> <ord>     <ord> <dbl> <dbl> <int> <dbl> <dbl> <dbl>
#> 1  0.32 Premium   E      60.9    58   345  4.38  4.42  2.68
#> 2  1.17 Very Good J      60.2    61  2774  6.83  6.9   4.13
#> 3  1.01 Premium   F      61.8    60  2781  6.39  6.36  3.94
#> 4  1.01 Fair      E      64.5    58  2788  6.29  6.21  4.03
#> 5  0.96 Ideal     F      60.7    55  2801  6.37  6.41  3.88
#> 6  1.04 Premium   G      62.2    58  2801  6.46  6.41  4   
#> # ℹ 735 more rows

أثناء وجودنا هنا، لننشئ عموداً يعطي اسم ملف المخرجات، باستخدام mutate() و str_glue():

by_clarity <- by_clarity |> 
  mutate(path = str_glue("diamonds-{clarity}.csv"))

by_clarity
#> # A tibble: 8 × 3
#>   clarity               data path             
#>   <ord>   <list<tibble[,9]>> <glue>           
#> 1 I1               [741 × 9] diamonds-I1.csv  
#> 2 SI2            [9,194 × 9] diamonds-SI2.csv 
#> 3 SI1           [13,065 × 9] diamonds-SI1.csv 
#> 4 VS2           [12,258 × 9] diamonds-VS2.csv 
#> 5 VS1            [8,171 × 9] diamonds-VS1.csv 
#> 6 VVS2           [5,066 × 9] diamonds-VVS2.csv
#> # ℹ 2 more rows

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

write_csv(by_clarity$data[[1]], by_clarity$path[[1]])
write_csv(by_clarity$data[[2]], by_clarity$path[[2]])
write_csv(by_clarity$data[[3]], by_clarity$path[[3]])
...
write_csv(by_clarity$data[[8]], by_clarity$path[[8]])

هذا مختلف قليلاً عن استخداماتنا السابقة لـ map() لأن هناك وسيطين يتغيران، وليس وسيطاً واحداً فقط. هذا يعني أننا بحاجة إلى دالّة جديدة: map2()، والتي تغير كلاً من الوسيطين الأول والثاني. ولأننا مرة أخرى لا نهتم بالمخرجات، فنحن نريد walk2() بدلاً من map2(). هذا يعطينا:

walk2(by_clarity$data, by_clarity$path, write_csv)

26.4.3 حفظ الرسوم البيانية

يمكننا اتخاذ نفس النهج الأساسي لإنشاء العديد من الرسوم البيانية. لنقم أولاً بإنشاء دالّة ترسم الرسم البياني الذي نريده:

carat_histogram <- function(df) {
  ggplot(df, aes(x = carat)) + geom_histogram(binwidth = 0.1)  
}

carat_histogram(by_clarity$data[[1]])

رسم بياني شريطي (Histogram) لوزن الألماس بالقيراط من مجموعة البيانات by_clarity، تراوح من  0 إلى 5 قيراط. التوزيع أحادي المنوال وملتوٍ نحو اليمين مع وجود قمة  حوالى 1 قيراط.

الان يمكننا استخدام الدالة map() لإنشاء قائمة تحتوي على العديد من الرسوم البيانية7 ومسارات ملفاتها النهائية:

by_clarity <- by_clarity |> 
  mutate(
    plot = map(data, carat_histogram),
    path = str_glue("clarity-{clarity}.png")
  )

ثم نستخدم walk2() مع ggsave() لحفظ كل رسم بياني:

walk2(
  by_clarity$path,
  by_clarity$plot,
  \(path, plot) ggsave(path, plot, width = 6, height = 6)
)

هذا اختصار لـ:

ggsave(by_clarity$path[[1]], by_clarity$plot[[1]], width = 6, height = 6)
ggsave(by_clarity$path[[2]], by_clarity$plot[[2]], width = 6, height = 6)
ggsave(by_clarity$path[[3]], by_clarity$plot[[3]], width = 6, height = 6)
...
ggsave(by_clarity$path[[8]], by_clarity$plot[[8]], width = 6, height = 6)

26.5 ملخص

في هذا الفصل، رأيت كيفية استخدام التكرار الصريح لحل ثلاث مشكلات تظهر كثيراً عند ممارسة علم البيانات: التعامل مع أعمدة متعددة، قراءة ملفات متعددة، وحفظ مخرجات متعددة. ولكن بشكل عام، يُعد التكرار قوة فائقة: إذا كنت تعرف تقنية التكرار المناسبة، يمكنك بسهولة الانتقال من حل مشكلة واحدة إلى حل جميع المشكلات. بمجرد إتقان التقنيات الواردة في هذا الفصل، نوصي بشدة بزيادة معرفتك من خلال قراءة فصل الدالّات الوظيفية (Functionals chapter) في كتاب Advanced R والاطلاع على موقع purrr.

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


  1. سُمّيت مجهولة لأننا لم نمنحها اسماً صريحاً مطلقاً باستخدام معامل التعيين <-. وهناك مصطلح آخر يستخدمه المبرمجون لوصف هذا النوع وهو “دالة لامبدا” (Lambda function).↩︎

  2. في الأكواد القديمة قد تلاحظ صياغة تبدو مثل ~ .x + 1. هذه طريقة أخرى لكتابة الدوال المجهولة ولكنها تعمل فقط داخل دوال حزمة tidyverse، وتستخدم دائماً اسم المتغير .x. نحن نوصي الآن باستخدام الصياغة الأساسية للغة (Base syntax): \(x) x + 1.↩︎

  3. لا يمكنك حالياً تغيير ترتيب الأعمدة مباشرة، ولكن يمكنك إعادة ترتيبها بعد ذلك باستخدام الدالة relocate() أو ما يشابهها.↩︎

  4. قد تتوفر طريقة لذلك يوماً ما، لكننا لا نرى كيفية القيام بذلك في الوقت الحالي.↩︎

  5. إذا كان لديك بدلاً من ذلك مجلد يحتوي على ملفات csv بنفس التنسيق، فيمكنك استخدام التقنية الموضحة في قسم 7.4.↩︎

  6. لن نقم بشرح كيفية عملها، ولكن إذا نظرت إلى وثائق الدوال المستخدمة (Documentation)، فستتمكن من استنتاج طريقة عملها بنفسك.↩︎

  7. يمكنك طباعة by_clarity$plot للحصول على رسوم متحركة بسيطة (Animation) — حيث ستحصل على رسم بياني واحد لكل عنصر في plots. ملاحظة: هذا لم يحدث معي.↩︎