22  Arrow

22.1 مقدمة

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

سنربط ملفات parquet بـ Apache Arrow، وهي مجموعة أدوات متطورة متعددة اللغات صُممت للتحليل والنقل الفعال لمجموعات البيانات الكبيرة. سنستخدم Apache Arrow عبر حزمة arrow, والتي توفر محركاً خلفياً (backend) لـ dplyr يتيح لك تحليل مجموعات بيانات أكبر من سعة الذاكرة العشوائية (larger-than-memory) باستخدام بناء جمل dplyr الفعال الشائع لديك. كفائدة إضافية، تمتاز arrow بسرعتها الفائقة: ستشاهد بعض الأمثلة على ذلك لاحقاً في هذا الفصل.

تتمتع كل من arrow و dbplyr بمحركات خلفية لـ dplyr، لذا قد تتساءل متى تستخدم كلاً منهما. في كثير من الحالات، يكون الخيار محدوماً بالنسبة لك، حيث تكون البيانات موجودة بالفعل في قاعدة بيانات أو في ملفات parquet، وستحتاج إلى العمل معها كما هي. ولكن إذا كنت تبدأ ببياناتك الخاصة (ربما ملفات CSV)، فيمكنك إما تحميلها في قاعدة بيانات أو تحويلها إلى parquet. بشكل عام، من الصعب معرفة ما سينجح بشكل أفضل مسبقاً، لذا نوصيك في المراحل الأولى من التحليل بتجربة كليتهما واختيار الخيار الأفضل بالنسبة لك.

(جزيل الشكر لـ دانييل نافارو التي ساهمت في النسخة الأولية من هذا الفصل.)

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

في هذا الفصل، سنستمر في استخدام tidyverse، وبخاصة dplyr، ولكننا سنربطها بحزمة arrow المصممة خصيصاً للعمل مع البيانات الكبيرة.

لاحقاً في هذا الفصل، سنستعرض بعض الروابط بين arrow و duckdb، لذا سنحتاج أيضاً إلى حزمتي dbplyr و duckdb.

library(dbplyr, warn.conflicts = FALSE)
library(duckdb)
#> Loading required package: DBI

22.2 الحصول على البيانات

نبدأ بالحصول على مجموعة بيانات تستحق استخدام هذه الأدوات: مجموعة بيانات استعارات الكتب والمواد من مكتبات سياتل العامة، والمتاحة عبر الإنترنت على الرابط data.seattle.gov/Community/Checkouts-by-Title/tmmm-ytt6. تحتوي مجموعة البيانات هذه على 41,389,465 صفاً تخبرك بعدد المرات التي تم فيها استعارة كل كتاب في كل شهر من أبريل 2005 إلى أكتوبر 2022.

سيجلب لك الكود التالي نسخة مخبأة (cached copy) من البيانات. تتكون البيانات من ملف CSV بحجم 9 جيجابايت، لذا سيستغرق تنزيله بعض الوقت. نوصي بشدة باستخدام الدالة curl::multi_download() للحصول على الملفات الكبيرة جداً لأنها مُصممة خصيصاً لهذا الغرض: فهي تمنحك شريط تقدم ويمكنها استئناف التنزيل إذا انقطع.

dir.create("data", showWarnings = FALSE)

curl::multi_download(
  "[https://r4ds.s3.us-west-2.amazonaws.com/seattle-library-checkouts.csv](https://r4ds.s3.us-west-2.amazonaws.com/seattle-library-checkouts.csv)",
  "data/seattle-library-checkouts.csv",
  resume = TRUE
)

22.3 فتح مجموعة البيانات

دعنا نبدأ بأخذ نظرة على البيانات. بحجم 9 جيجابايت، يُعد هذا الملف كبيراً بدرجة كافية تجعلنا نفضل عدم تحميله بالكامل في الذاكرة. القاعدة الذهبية العملية هي أنك تحتاج عادةً إلى ذاكرة عشوائية تعادل ضعف حجم البيانات على الأقل، والعديد من أجهزة الكمبيوتر المحمولة تتوقف عند 16 جيجابايت. هذا يعني أننا نريد تجنب read_csv() والاستعاضة عنها باستخدام arrow::open_dataset():

seattle_csv <- open_dataset(
  sources = "data/seattle-library-checkouts.csv", 
  col_types = schema(ISBN = string()),
  format = "csv"
)

ماذا يحدث عند تشغيل هذا الكود؟ ستقوم open_dataset() بفحص بضعة آلاف من الصفوف لمعرفة هيكل مجموعة البيانات. يحتوي عمود ISBN على قيم فارغة في أول 80,000 صف، لذا يتوجب علينا تحديد نوع العمود لمساعدة arrow في استنتاج هيكل البيانات. بمجرد فحص البيانات بواسطة open_dataset()، تسجل ما وجدته وتتوقف؛ ولن تقرأ المزيد من الصفوف إلا عندما تطلب ذلك خصيصاً. هذه البيانات الوصفية (metadata) هي ما نراه إذا قمنا بطباعة seattle_csv:

seattle_csv
#> FileSystemDataset with 1 csv file
#> 12 columns
#> UsageClass: string
#> CheckoutType: string
#> MaterialType: string
#> CheckoutYear: int64
#> CheckoutMonth: int64
#> Checkouts: int64
#> Title: string
#> ISBN: string
#> Creator: string
#> Subjects: string
#> Publisher: string
#> PublicationYear: string

يخبرك السطر الأول في المخرجات أن seattle_csv مخزنة محلياً على القرص الصلب كملف CSV واحد؛ ولن تُحمل في الذاكرة إلا عند الحاجة. بقية المخرجات تخبرك بنوع العمود الذي نسبته arrow لكل عمود.

يمكننا رؤية ما يوجد بداخلها بالفعل عبر glimpse(). يكشف هذا عن وجود ما يقرب من 41 مليون صف و 12 عموداً، ويعرض لنا بضع قيم.

seattle_csv |> glimpse()
#> FileSystemDataset with 1 csv file
#> 41,389,465 rows x 12 columns
#> $ UsageClass      <string> "Physical", "Physical", "Digital", "Physical", "Ph…
#> $ CheckoutType    <string> "Horizon", "Horizon", "OverDrive", "Horizon", "Hor…
#> $ MaterialType    <string> "BOOK", "BOOK", "EBOOK", "BOOK", "SOUNDDISC", "BOO…
#> $ CheckoutYear     <int64> 2016, 2016, 2016, 2016, 2016, 2016, 2016, 2016, 20…
#> $ CheckoutMonth    <int64> 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,…
#> $ Checkouts        <int64> 1, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 2, 3, 2, 1, 3, 2,…
#> $ Title           <string> "Super rich : a guide to having it all / Russell S…
#> $ ISBN            <string> "", "", "", "", "", "", "", "", "", "", "", "", ""…
#> $ Creator         <string> "Simmons, Russell", "Barclay, James, 1965-", "Tim …
#> $ Subjects        <string> "Self realization, Conduct of life, Attitude Psych…
#> $ Publisher       <string> "Gotham Books,", "Pyr,", "Random House, Inc.", "Di…
#> $ PublicationYear <string> "c2011.", "2010.", "2015", "2005.", "c2004.", "c20…

يمكننا البدء في استخدام مجموعة البيانات هذه مع أفعال dplyr، باستخدام collect() لإجبار arrow على إجراء الحسابات وإعادة بعض البيانات. على سبيل المثال، يخبرنا هذا الكود بإجمالي عدد الاستعارات لكل عام:

seattle_csv |> 
  group_by(CheckoutYear) |> 
  summarise(Checkouts = sum(Checkouts)) |> 
  arrange(CheckoutYear) |> 
  collect()
#> # A tibble: 18 × 2
#>   CheckoutYear Checkouts
#>          <int>     <int>
#> 1         2005   3798685
#> 2         2006   6599318
#> 3         2007   7126627
#> 4         2008   8438486
#> 5         2009   9135167
#> 6         2010   8608966
#> # ℹ 12 more rows

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

22.4 صيغة Parquet

لجعل هذه البيانات أسهل في التعامل معها، دعنا ننتقل إلى صيغة ملفات Parquet وتقسيمها إلى ملفات متعددة. ستعرفك الأقسام التالية أولاً على صيغة parquet والتقسيم (partitioning)، ثم تطبيق ما تعلمناه على بيانات مكتبة سياتل.

22.4.1 مزايا صيغة Parquet

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

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

  • ملفات Parquet تحتوي على نظام أنواع غني. كما تحدثنا في قسم 7.3، لا يقدم ملف CSV أي معلومات حول أنواع الأعمدة. على سبيل المثال، يجب على قارئ CSV التخمين ما إذا كان ينبغي تحليل "08-10-2022" كنص أو كتاريخ. في المقابل، تُخزن ملفات parquet البيانات بطريقة تسجل النوع جنباً إلى جنب مع البيانات.

  • ملفات Parquet موجهة نحو الأعمدة (column-oriented). هذا يعني أنها منظمة عموداً بعمود، تماماً مثل إطار البيانات في R. يقود هذا عادةً إلى أداء أفضل لمهام تحليل البيانات مقارنة بملفات CSV الموجهة صفاً بصف.

  • ملفات Parquet مقسمة إلى أجزاء (chunked)، مما يجعل من الممكن العمل على أجزاء مختلفة من الملف في نفس الوقت، وإذا كنت محظوظاً، تجاوز بعض الأجزاء بالكامل.

هناك عيب رئيسي واحد لملفات parquet: لم تعد “قابلة للقراءة بواسطة البشر”، أي إذا نظرت إلى ملف parquet باستخدام readr::read_file()، فسترى مجرد كتابة غريبة غير مفهومة.

22.4.2 التقسيم (Partitioning)

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

لا توجد قواعد صارمة حول كيفية تقسيم مجموعة البيانات الخاصة بك: ستعتمد النتائج على بياناتك، وأنماط الوصول إليها، والأنظمة التي تقرأ البيانات. من المرجح أن تحتاج إلى إجراء بعض التجارب قبل العثور على التقسيم المثالي لحالتك. كدليل تقريبي، تقترح arrow تجنب الملفات الأصغر من 20 ميجابايت والأكبر من 2 جيجابايت، وتجنب التقسيمات التي تنتج أكثر من 10,000 ملف. يجب عليك أيضاً محاولة التقسيم حسب المتغيرات التي تقوم بالتصفية بناءً عليها؛ وكما ستشاهد قريباً، يتيح ذلك لـ arrow تجاوز الكثير من العمل عن طريق قراءة الملفات ذات الصلة فقط.

22.4.3 إعادة كتابة بيانات مكتبة سياتل

دعنا نطبق هذه الأفكار على بيانات مكتبة سياتل لنرى كيف تتجلى في الممارسة العملية. سنقوم بالتقسيم حسب CheckoutYear، نظرًا لأنه من المرجح أن ترغب بعض التحليلات فقط في النظر إلى البيانات الحديثة، والتقسيم حسب السنة ينتج 18 جزءاً بحجم معقول.

لإعادة كتابة البيانات، نحدد التقسيم باستخدام dplyr::group_by() ثم نحفظ التقسيمات في دليل باستخدام arrow::write_dataset(). تمتلك write_dataset() معاملين مهمين: الدليل الذي سننشئ الملفات فيه والصيغة التي سنستخدمها.

pq_path <- "data/seattle-library-checkouts"
seattle_csv |> 
  group_by(CheckoutYear) |> 
  write_dataset(path = pq_path, format = "parquet")

يستغرق هذا حوالي دقيقة واحدة للتشغيل؛ وكما ستشاهد قريباً، فهذا استثمار أولي يؤدي ثماراً جعل العمليات المستقبلية أسرع بكثير.

دعنا نلقي نظرة على ما أنتجناه للتو:

tibble(
  files = list.files(pq_path, recursive = TRUE),
  size_MB = file.size(file.path(pq_path, files)) / 1024^2
)
#> # A tibble: 18 × 2
#>   files                            size_MB
#>   <chr>                              <dbl>
#> 1 CheckoutYear=2005/part-0.parquet    109.
#> 2 CheckoutYear=2006/part-0.parquet    164.
#> 3 CheckoutYear=2007/part-0.parquet    177.
#> 4 CheckoutYear=2008/part-0.parquet    194.
#> 5 CheckoutYear=2009/part-0.parquet    214.
#> 6 CheckoutYear=2010/part-0.parquet    222.
#> # ℹ 12 more rows

تمت إعادة كتابة ملف CSV الوحيد بحجم 9 جيجابايت إلى 18 ملف parquet. تستخدم أسماء الملفات عرفاً “واصفاً لذاته” مُستخدماً من قبل مشروع Apache Hive. تسمي التقسيمات بنمط Hive المجلدات باتباع عرف “مفتاح=قيمة” (key=value)، لذا وكما قد تخمن، فإن المجلد CheckoutYear=2005 يحتوي على جميع البيانات التي تكون فيها CheckoutYear هي 2005. يتراوح حجم كل ملف بين 100 و 300 ميجابايت والحجم الإجمالي الآن حوالي 4 جيجابايت، وهو أكثر قليلاً من نصف حجم ملف CSV الأصلي. هذا أمر متوقع لأن صيغة parquet هي صيغة أكثر كفاءة بكثير.

22.5 استخدام dplyr مع arrow

الآن وقد أنشأنا ملفات parquet هذه، سنحتاج إلى قراءتها مرة أخرى. نستخدم open_dataset() مرة أخرى، ولكن هذه المرة نمرر لها دليلاً وليس ملفاً مفصلاً:

seattle_pq <- open_dataset(pq_path)

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

query <- seattle_pq |> 
  filter(CheckoutYear >= 2018, MaterialType == "BOOK") |> 
  group_by(CheckoutYear, CheckoutMonth) |> 
  summarize(TotalCheckouts = sum(Checkouts)) |> 
  arrange(CheckoutYear, CheckoutMonth)

كتابة كود dplyr لبيانات arrow تتشابه مفاهيمياً مع dbplyr، الفصل 21: أنت تكتب كود dplyr، ويتم تحويله تلقائياً إلى استعلام تفهمه مكتبة C++ الخاصة بـ Apache Arrow، ثم يُنفذ عند استدعاء collect(). إذا قمنا بطباعة كائن الاستعلام query فيمكننا رؤية القليل من المعلومات حول ما نتوقع أن تعيده Arrow عند التنفيذ:

query
#> FileSystemDataset (query)
#> CheckoutYear: int32
#> CheckoutMonth: int64
#> TotalCheckouts: int64
#> 
#> * Grouped by CheckoutYear
#> * Sorted by CheckoutYear [asc], CheckoutMonth [asc]
#> See $.data for the source Arrow object

ويمكننا الحصول على النتائج عن طريق استدعاء collect():

query |> collect()
#> # A tibble: 58 × 3
#> # Groups:   CheckoutYear [5]
#>   CheckoutYear CheckoutMonth TotalCheckouts
#>          <int>         <int>          <int>
#> 1         2018             1         355101
#> 2         2018             2         309813
#> 3         2018             3         344487
#> 4         2018             4         330988
#> 5         2018             5         318049
#> 6         2018             6         341825
#> # ℹ 52 more rows

مثل dbplyr، تفهم arrow بعض تعبيرات R فقط، لذا قد لا تتمكن من كتابة نفس الكود تماماً الذي تكتبه عادةً. ومع ذلك، فإن قائمة العمليات والدوال المدعومة واسعة للغاية وتستمر في النمو؛ يمكنك العثور على قائمة كاملة بالدوال المدعومة حالياً عبر ?acero.

22.5.1 الأداء

دعنا نلقي نظرة سريعة على تأثير الأداء الناتج عن الانتقال من CSV إلى parquet. أولاً، دعنا نقيس الوقت الذي يستغرقه حساب عدد الكتب المستعارة في كل شهر من عام 2021، عندما تُخزن البيانات كملف csv كبير واحد:

seattle_csv |> 
  filter(CheckoutYear == 2021, MaterialType == "BOOK") |> 
  group_by(CheckoutMonth) |> 
  summarize(TotalCheckouts = sum(Checkouts)) |> 
  arrange(desc(CheckoutMonth)) |> 
  collect() |> 
  system.time()
#>    user  system elapsed 
#>   47.72   21.43  153.46

الآن دعنا نستخدم النسخة الجديدة من مجموعة البيانات التي تمت فيها تقسيم بيانات استعار مكتبة سياتل إلى 18 ملف parquet أصغر:

seattle_pq |> 
  filter(CheckoutYear == 2021, MaterialType == "BOOK") |> 
  group_by(CheckoutMonth) |> 
  summarize(TotalCheckouts = sum(Checkouts)) |> 
  arrange(desc(CheckoutMonth)) |> 
  collect() |> 
  system.time()
#>    user  system elapsed 
#>    0.80    0.35    3.47

يرجع التسريع الهائل في الأداء بنحو 100 ضعف إلى عاملين: التقسيم متعدد الملفات، وصيغة الملفات الفردية:

  • التقسيم يحسن الأداء لأن هذا الاستعلام يستخدم CheckoutYear == 2021 لتصفية البيانات، و arrow ذكية بما يكفي للإدراك بأنها تحتاج فقط إلى قراءة ملف 1 فقط من أصل 18 ملف parquet.
  • صيغة Parquet تحسن الأداء عن طريق تخزين البيانات في صيغة ثنائية يمكن قراءتها بشكل أباشر في الذاكرة. التنسيق الموجه نحو العمود والبيانات الوصفية الغنية تعني أن arrow تحتاج فقط إلى قراءة الأعمدة الأربعة المستخدمة بالفعل في الاستعلام (CheckoutYear و MaterialType و CheckoutMonth و Checkouts).

هذا الاختلاف الهائل في الأداء هو السبب في أن تحويل ملفات CSV الكبيرة إلى parquet أمر مجدٍ للغاية!

22.5.2 استخدام duckdb مع arrow

هناك ميزة أخيرة لـ parquet و arrow — من السهل جداً تحويل مجموعة بيانات arrow إلى قاعدة بيانات DuckDB (الفصل 21) عن طريق استدعاء arrow::to_duckdb():

seattle_pq |> 
  to_duckdb() |> 
  filter(CheckoutYear >= 2018, MaterialType == "BOOK") |> 
  group_by(CheckoutYear) |> 
  summarize(TotalCheckouts = sum(Checkouts)) |> 
  arrange(desc(CheckoutYear)) |> 
  collect()
#> Warning: Missing values are always removed in SQL aggregation functions.
#> Use `na.rm = TRUE` to silence this warning
#> This warning is displayed once every 8 hours.
#> # A tibble: 5 × 2
#>   CheckoutYear TotalCheckouts
#>          <int>          <dbl>
#> 1         2022        2431502
#> 2         2021        2266438
#> 3         2020        1241999
#> 4         2019        3931688
#> 5         2018        3987569

الشيء الرائع في to_duckdb() هو أن النقل لا يتطلب أي نسخ للذاكرة، ويتماشى مع أهداف منظومة arrow: تمكين الانتقال السلس من بيئة حوسبية إلى أخرى.

22.5.3 تمارين

  1. اكتشف الكتاب الأكثر شعبية في كل عام.
  2. أي مؤلف لديه أكبر عدد من الكتب في نظام مكتبة سياتل؟
  3. كيف تغيرت استعارات الكتب الورقية مقابل الكتب الإلكترونية (ebooks) على مدار السنوات العشر الماضية؟

22.6 ملخص

في هذا الفصل، تذوقت حزمة arrow، والتي توفر محركاً خلفياً لـ dplyr للعمل مع مجموعات البيانات الكبيرة على القرص الصلب. يمكنها العمل مع ملفات CSV، ولكنها أسرع بكثير إذا قمت بتحويل بياناتك إلى parquet. Parquet هي صيغة بيانات ثنائية صُممت خصيصاً لتحليل البيانات على أجهزة الكمبيوتر الحديثة. عدد أقل بكثير من الأدوات يمكنه العمل مع ملفات parquet مقارنة بـ CSV، لكن بنتيها المقسمة والمضغوطة والموجهة نحو العمود تجعل تحليل البيانات أكثر كفاءة بكثير.

في الفصل التالي، ستتعلم عن أول مصدر بيانات غير مستطيل (non-rectangular data)، والذي ستتعامل معه باستخدام الأدوات التي توفرها حزمة tidyr. سنركز على البيانات التي تأتي من ملفات JSON، ولكن المبادئ العامة تنطبق على البيانات الهيكلية الشبيهة بالشجرة بغض النظر عن مصدرها.