22 Arrow
22.1 Giới thiệu
Tệp CSV được thiết kế để con người dễ đọc. Chúng là một định dạng trao đổi tốt vì rất đơn giản và mọi công cụ đều có thể đọc được. Nhưng tệp CSV không thực sự hiệu quả: bạn phải thực hiện khá nhiều thao tác để đọc dữ liệu vào R. Trong chương này, bạn sẽ tìm hiểu về một giải pháp thay thế mạnh mẽ: định dạng parquet, một định dạng dựa trên tiêu chuẩn mở được sử dụng rộng rãi bởi các hệ thống dữ liệu lớn (big data).
Chúng ta sẽ kết hợp tệp parquet với Apache Arrow, một bộ công cụ đa ngôn ngữ được thiết kế để phân tích và truyền tải tập dữ liệu (dataset) lớn một cách hiệu quả. Chúng ta sẽ sử dụng Apache Arrow thông qua package arrow, package này cung cấp một backend cho dplyr cho phép bạn phân tích các tập dữ liệu lớn hơn bộ nhớ bằng cú pháp dplyr quen thuộc. Một lợi ích bổ sung là arrow cực kỳ nhanh: bạn sẽ thấy một số ví dụ ở phần sau của chương.
Cả arrow và dbplyr đều cung cấp backend cho dplyr, nên bạn có thể thắc mắc khi nào nên dùng cái nào. Trong nhiều trường hợp, lựa chọn đã được quyết định sẵn cho bạn, vì dữ liệu đã nằm trong database hoặc trong tệp parquet, và bạn sẽ muốn làm việc với nó nguyên trạng. Nhưng nếu bạn bắt đầu với dữ liệu của riêng mình (có thể là tệp CSV), bạn có thể nạp nó vào database hoặc chuyển đổi sang parquet. Nhìn chung, rất khó biết cách nào sẽ hoạt động tốt nhất, vì vậy trong giai đoạn đầu phân tích, chúng tôi khuyến khích bạn thử cả hai và chọn cái phù hợp nhất.
(Xin cảm ơn Danielle Navarro, người đã đóng góp phiên bản ban đầu của chương này.)
22.1.1 Điều kiện tiên quyết
Trong chương này, chúng ta sẽ tiếp tục sử dụng tidyverse, đặc biệt là dplyr, nhưng sẽ kết hợp với package arrow được thiết kế riêng để làm việc với dữ liệu lớn.
Ở phần sau của chương, chúng ta cũng sẽ thấy một số kết nối giữa arrow và duckdb, nên chúng ta cũng cần dbplyr và duckdb.
22.2 Lấy dữ liệu
Chúng ta bắt đầu bằng việc lấy một tập dữ liệu xứng tầm với những công cụ này: tập dữ liệu về lượt mượn sách từ các thư viện công cộng Seattle, có sẵn trực tuyến tại data.seattle.gov/Community/Checkouts-by-Title/tmmm-ytt6. Tập dữ liệu này chứa 41.389.465 row cho biết mỗi cuốn sách được mượn bao nhiêu lần mỗi tháng từ tháng 4 năm 2005 đến tháng 10 năm 2022.
Đoạn mã sau sẽ giúp bạn lấy bản sao đã lưu trong bộ nhớ đệm (cache) của dữ liệu. Dữ liệu là một tệp CSV 9GB, nên sẽ mất một lúc để tải xuống. Tôi rất khuyến khích sử dụng curl::multi_download() để tải các tệp rất lớn vì nó được xây dựng chính xác cho mục đích này: nó cho bạn thanh tiến trình và có thể tiếp tục tải xuống nếu bị gián đoạn.
dir.create("data", showWarnings = FALSE)
curl::multi_download(
"https://r4ds.s3.us-west-2.amazonaws.com/seattle-library-checkouts.csv",
"data/seattle-library-checkouts.csv",
resume = TRUE
)
#> # A tibble: 1 × 10
#> success status_code resumefrom url destfile error
#> <lgl> <int> <dbl> <chr> <chr> <chr>
#> 1 TRUE 200 0 https://r4ds.s3.us-we… data/seattle-l… <NA>
#> # ℹ 4 more variables: type <chr>, modified <dttm>, time <dbl>,
#> # headers <list>22.3 Mở một tập dữ liệu
Hãy bắt đầu bằng cách xem qua dữ liệu. Với kích thước 9 GB, tệp này đủ lớn để chúng ta có lẽ không muốn nạp toàn bộ vào bộ nhớ. Một quy tắc kinh nghiệm tốt là bạn thường cần ít nhất gấp đôi dung lượng bộ nhớ so với kích thước dữ liệu, và nhiều máy tính xách tay chỉ có tối đa 16 GB. Điều này có nghĩa là chúng ta muốn tránh read_csv() và thay vào đó sử dụng arrow::open_dataset():
seattle_csv <- open_dataset(
sources = "data/seattle-library-checkouts.csv",
col_types = schema(ISBN = string()),
format = "csv"
)Điều gì xảy ra khi đoạn mã này được chạy? open_dataset() sẽ quét vài nghìn row để xác định cấu trúc của tập dữ liệu. Cột ISBN chứa giá trị trống trong 80.000 row đầu tiên, nên chúng ta phải chỉ định kiểu column để giúp arrow xác định cấu trúc dữ liệu. Sau khi dữ liệu đã được quét bởi open_dataset(), nó ghi lại những gì đã tìm thấy và dừng lại; nó chỉ đọc thêm các row khi bạn yêu cầu cụ thể. Siêu dữ liệu (metadata) này là những gì chúng ta thấy khi in seattle_csv:
seattle_csv
#> FileSystemDataset with 1 csv file
#> UsageClass: string
#> CheckoutType: string
#> MaterialType: string
#> CheckoutYear: int64
#> CheckoutMonth: int64
#> Checkouts: int64
#> Title: string
#> ISBN: string
#> Creator: string
#> Subjects: string
#> Publisher: string
#> PublicationYear: stringDòng đầu tiên trong kết quả cho bạn biết rằng seattle_csv được lưu trữ cục bộ trên đĩa dưới dạng một tệp CSV duy nhất; nó sẽ chỉ được nạp vào bộ nhớ khi cần. Phần còn lại của kết quả cho bạn biết kiểu column mà arrow đã suy luận cho mỗi column.
Chúng ta có thể xem thực sự có gì bên trong bằng glimpse(). Điều này cho thấy có ~41 triệu row và 12 column, và hiển thị một vài giá trị.
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…Chúng ta có thể bắt đầu sử dụng tập dữ liệu này với các function (function) dplyr, dùng collect() để buộc arrow thực hiện tính toán và trả về dữ liệu. Ví dụ, đoạn mã này cho chúng ta biết tổng số lượt mượn theo từng năm:
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 rowsNhờ arrow, đoạn mã này sẽ hoạt động bất kể tập dữ liệu bên dưới lớn đến đâu. Nhưng hiện tại nó khá chậm: trên máy tính của Hadley, mất ~10 giây để chạy. Điều đó không quá tệ xét theo lượng dữ liệu chúng ta có, nhưng chúng ta có thể làm nhanh hơn nhiều bằng cách chuyển sang định dạng tốt hơn.
22.4 Định dạng parquet
Để làm cho dữ liệu này dễ làm việc hơn, hãy chuyển sang định dạng tệp parquet và chia nhỏ nó thành nhiều tệp. Các phần tiếp theo sẽ giới thiệu cho bạn về parquet và phân vùng (partitioning), sau đó áp dụng những gì chúng ta đã học vào dữ liệu thư viện Seattle.
22.4.1 Ưu điểm của parquet
Giống như CSV, parquet được dùng cho dữ liệu hình chữ nhật, nhưng thay vì là định dạng văn bản mà bạn có thể đọc bằng bất kỳ trình soạn thảo nào, nó là một định dạng nhị phân (binary format) tùy chỉnh được thiết kế riêng cho nhu cầu của dữ liệu lớn. Điều này có nghĩa là:
Tệp parquet thường nhỏ hơn tệp CSV tương đương. Parquet dựa vào các phương pháp mã hóa hiệu quả để giữ kích thước tệp nhỏ, và hỗ trợ nén tệp. Điều này giúp tệp parquet nhanh vì có ít dữ liệu hơn cần di chuyển từ đĩa vào bộ nhớ.
Tệp parquet có hệ thống kiểu dữ liệu phong phú. Như chúng ta đã nói ở Phần 7.3, tệp CSV không cung cấp bất kỳ thông tin nào về kiểu column. Ví dụ, một trình đọc CSV phải đoán xem
"08-10-2022"nên được phân tích cú pháp thành string hay ngày tháng. Ngược lại, tệp parquet lưu trữ dữ liệu theo cách ghi lại kiểu cùng với dữ liệu.Tệp parquet được “tổ chức theo cột” (column-oriented). Điều này có nghĩa là chúng được sắp xếp theo từng column, tương tự như data frame trong R. Điều này thường dẫn đến hiệu suất tốt hơn cho các tác vụ phân tích dữ liệu so với tệp CSV, vốn được tổ chức theo row.
Tệp parquet được “chia thành khối” (chunked), giúp có thể làm việc trên các phần khác nhau của tệp cùng lúc, và nếu may mắn, có thể bỏ qua hoàn toàn một số khối.
Có một nhược điểm chính của tệp parquet: chúng không còn “đọc được bằng mắt người”, tức là nếu bạn xem một tệp parquet bằng readr::read_file(), bạn sẽ chỉ thấy một mớ ký tự vô nghĩa.
22.4.2 Phân vùng
Khi tập dữ liệu ngày càng lớn, việc lưu trữ tất cả dữ liệu trong một tệp duy nhất trở nên ngày càng khó khăn và thường hữu ích khi chia tập dữ liệu lớn thành nhiều tệp. Khi việc cấu trúc này được thực hiện một cách thông minh, chiến lược này có thể dẫn đến cải thiện hiệu suất đáng kể vì nhiều phân tích chỉ cần một tập con các tệp.
Không có quy tắc cứng nhắc nào về cách phân vùng tập dữ liệu của bạn: kết quả sẽ phụ thuộc vào dữ liệu, cách truy cập, và các hệ thống đọc dữ liệu. Bạn có thể sẽ cần thử nghiệm một chút trước khi tìm ra cách phân vùng lý tưởng cho tình huống của mình. Như một hướng dẫn sơ bộ, arrow khuyến nghị bạn tránh các tệp nhỏ hơn 20MB và lớn hơn 2GB, và tránh phân vùng tạo ra hơn 10.000 tệp. Bạn cũng nên cố gắng phân vùng theo các biến mà bạn dùng để lọc; như bạn sẽ thấy ngay sau đây, điều đó cho phép arrow bỏ qua rất nhiều công việc bằng cách chỉ đọc các tệp liên quan.
22.4.3 Viết lại dữ liệu thư viện Seattle
Hãy áp dụng những ý tưởng này vào dữ liệu thư viện Seattle để xem chúng hoạt động như thế nào trong thực tế. Chúng ta sẽ phân vùng theo CheckoutYear, vì có khả năng một số phân tích chỉ muốn xem dữ liệu gần đây và phân vùng theo năm cho ra 18 khối có kích thước hợp lý.
Để viết lại dữ liệu, chúng ta định nghĩa phân vùng bằng dplyr::group_by() và sau đó lưu các phân vùng vào một thư mục với arrow::write_dataset(). write_dataset() có hai argument quan trọng: thư mục nơi chúng ta sẽ tạo các tệp và định dạng chúng ta sẽ sử dụng.
pq_path <- "data/seattle-library-checkouts"seattle_csv |>
group_by(CheckoutYear) |>
write_dataset(path = pq_path, format = "parquet")Việc này mất khoảng một phút để chạy; như chúng ta sẽ thấy ngay sau đây, đây là khoản đầu tư ban đầu sẽ được đền đáp bằng việc các thao tác sau này nhanh hơn rất nhiều.
Hãy xem những gì chúng ta vừa tạo ra:
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 178.
#> 4 CheckoutYear=2008/part-0.parquet 195.
#> 5 CheckoutYear=2009/part-0.parquet 214.
#> 6 CheckoutYear=2010/part-0.parquet 222.
#> # ℹ 12 more rowsTệp CSV 9GB duy nhất của chúng ta đã được viết lại thành 18 tệp parquet. Tên tệp sử dụng quy ước “tự mô tả” được dùng bởi dự án Apache Hive. Phân vùng kiểu Hive đặt tên thư mục theo quy ước “key=value”, nên như bạn có thể đoán, thư mục CheckoutYear=2005 chứa tất cả dữ liệu có CheckoutYear là 2005. Mỗi tệp có kích thước từ 100 đến 300 MB và tổng kích thước bây giờ khoảng 4 GB, hơn một nửa kích thước tệp CSV gốc. Điều này đúng như kỳ vọng vì parquet là định dạng hiệu quả hơn nhiều.
22.5 Sử dụng dplyr với arrow
Bây giờ chúng ta đã tạo các tệp parquet này, chúng ta sẽ cần đọc lại chúng. Chúng ta sử dụng open_dataset() một lần nữa, nhưng lần này chúng ta truyền vào một thư mục:
seattle_pq <- open_dataset(pq_path)Bây giờ chúng ta có thể viết pipeline dplyr. Ví dụ, chúng ta có thể đếm tổng số sách được mượn mỗi tháng trong năm năm gần nhất:
Viết mã dplyr cho dữ liệu arrow về mặt khái niệm tương tự như dbplyr, Chương 21: bạn viết mã dplyr, mã này tự động được chuyển đổi thành truy vấn (query) mà thư viện C++ của Apache Arrow hiểu được, và truy vấn đó được thực thi khi bạn gọi collect(). Nếu chúng ta in đối tượng query, chúng ta có thể thấy một chút thông tin về những gì Arrow dự kiến trả về khi thực thi:
query
#> FileSystemDataset (query)
#> CheckoutYear: int32
#> CheckoutMonth: int64
#> TotalCheckouts: int64
#>
#> * Grouped by CheckoutYear
#> * Sorted by CheckoutYear [asc], CheckoutMonth [asc]
#> See $.data for the source Arrow objectVà chúng ta có thể lấy kết quả bằng cách gọi 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 rowsGiống như dbplyr, arrow chỉ hiểu một số biểu thức R, nên bạn có thể không viết được chính xác mã như thường lệ. Tuy nhiên, list các phép toán và function được hỗ trợ khá rộng và tiếp tục được mở rộng; tìm list đầy đủ các function hiện được hỗ trợ trong ?acero.
22.5.1 Hiệu suất
Hãy xem nhanh tác động hiệu suất khi chuyển từ CSV sang parquet. Đầu tiên, hãy đo thời gian tính số sách được mượn mỗi tháng của năm 2021, khi dữ liệu được lưu dưới dạng một tệp csv lớn:
Bây giờ hãy dùng phiên bản mới của tập dữ liệu trong đó dữ liệu mượn sách thư viện Seattle đã được phân vùng thành 18 tệp parquet nhỏ hơn:
Tốc độ nhanh hơn ~100 lần là nhờ hai yếu tố: phân vùng đa tệp và định dạng của từng tệp riêng lẻ:
- Phân vùng cải thiện hiệu suất vì truy vấn này sử dụng
CheckoutYear == 2021để lọc dữ liệu, và arrow đủ thông minh để nhận ra rằng nó chỉ cần đọc 1 trong 18 tệp parquet. - Định dạng parquet cải thiện hiệu suất bằng cách lưu trữ dữ liệu ở định dạng nhị phân có thể đọc trực tiếp hơn vào bộ nhớ. Định dạng theo column và siêu dữ liệu phong phú nghĩa là arrow chỉ cần đọc bốn column thực sự được sử dụng trong truy vấn (
CheckoutYear,MaterialType,CheckoutMonth, vàCheckouts).
Sự khác biệt hiệu suất to lớn này là lý do tại sao việc chuyển đổi CSV lớn sang parquet rất đáng giá!
22.5.2 Sử dụng duckdb với arrow
Có một ưu điểm cuối cùng của parquet và arrow — rất dễ biến một tập dữ liệu arrow thành database DuckDB (Chương 21) bằng cách gọi 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Điều tuyệt vời về to_duckdb() là việc chuyển đổi không liên quan đến bất kỳ sao chép bộ nhớ nào, và thể hiện mục tiêu của hệ sinh thái arrow: cho phép chuyển đổi liền mạch từ environment tính toán này sang environment tính toán khác.
22.5.3 Bài tập
- Tìm cuốn sách phổ biến nhất mỗi năm.
- Tác giả nào có nhiều sách nhất trong hệ thống thư viện Seattle?
- Lượt mượn sách giấy so với sách điện tử (ebook) đã thay đổi như thế nào trong 10 năm qua?
22.6 Tóm tắt
Trong chương này, bạn đã được giới thiệu sơ lược về package arrow, package cung cấp backend cho dplyr để làm việc với các tập dữ liệu lớn trên đĩa. Nó có thể làm việc với tệp CSV, và nhanh hơn rất nhiều nếu bạn chuyển đổi dữ liệu sang parquet. Parquet là định dạng dữ liệu nhị phân được thiết kế riêng cho phân tích dữ liệu trên máy tính hiện đại. Ít công cụ hơn có thể làm việc với tệp parquet so với CSV, nhưng cấu trúc phân vùng, nén và theo column của nó giúp việc phân tích hiệu quả hơn nhiều.
Tiếp theo bạn sẽ tìm hiểu về nguồn dữ liệu phi hình chữ nhật đầu tiên, mà bạn sẽ xử lý bằng các công cụ do package tidyr cung cấp. Chúng ta sẽ tập trung vào dữ liệu từ tệp JSON, nhưng các nguyên tắc chung áp dụng cho dữ liệu dạng cây bất kể nguồn gốc của nó.