Đọc file lớn bằng Python: kiểu dữ liệu và xử lý theo khối

Xem thử

Tuần này đội GDev Mart nhận file đơn hàng tháng 9: 1 triệu dòng, 6 cột, khoảng 70 MB trên đĩa. Bạn gõ pd.read_csv("orders.csv") và máy vẫn chạy ổn. Nhưng tháng sau file lớn gấp 30 lần, và đồng nghiệp chạy cùng script trên laptop 8 GB thì bị MemoryError. Điều gì khác nhau giữa "70 MB trên đĩa" và "bao nhiêu trong bộ nhớ"?

Hãy dự đoán trước khi đọc tiếp: một file CSV 70 MB, sau khi nạp mặc định vào pandas, chiếm khoảng bao nhiêu bộ nhớ: nhỏ hơn, bằng, hay lớn hơn nhiều so với 70 MB? Và bạn có thể làm gì để thu nhỏ nó mà không mất thông tin? Viết ra con số ước tính của bạn.

Sau bài này, bạn có thể

  • Ước tính bộ nhớ của một DataFrame từ số dòng và dtype của từng cột.
  • Khai báo dtype và parse_dates khi đọc CSV, giữ đúng mã cửa hàng dạng 07.
  • Viết vòng lặp chunksize với kết quả gộp từng phần, và nói được phép tính nào không gộp được theo khối.

Cần biết trước: Chương 2 (Shell, Git, Docker) và Python 3 cùng pandas ở mức cơ bản; chạy trong môi trường lab Docker Compose đã dựng ở tuần 1.

Mô hình tư duy 1: bộ nhớ = số dòng × kích thước kiểu dữ liệu của từng cột

Khi pandas đọc CSV, mỗi cột có một dtype. Nếu bạn không nói gì, nó đoán: số nguyên thành int64 (8 byte), chữ thành object (mỗi ô là một đối tượng chuỗi Python riêng, nặng hơn nhiều so với số ký tự bạn thấy). Từ pandas 3.0 cột chữ mặc định có dtype str, dùng PyArrow nếu đã cài, nên cột chữ nhỏ hơn nhiều và dtypes hiển thị str thay vì object; mẹo category vẫn có lợi nhưng mức chênh nhỏ hơn. Hãy ước tính cho bảng orders 1 triệu dòng:

CộtDtype mặc địnhƯớc tínhDtype tối ưuƯớc tính
order_idint648 MBint324 MB
customer_idint648 MBint324 MB
store_id (40 giá trị, dạng 07)int64 (mất số 0 đứng đầu)8 MBcategory1 MB
amountint648 MBint324 MB
status (5 giá trị)object56 MBcategory1 MB
created_atobject73 MBdatetime648 MB
Tổngkhoảng 161 MBkhoảng 22 MB

Giải đáp cho dự đoán đầu bài: file 70 MB trên đĩa có thể chiếm khoảng 160 MB trong bộ nhớ, hơn gấp đôi dung lượng trên đĩa vì mỗi chuỗi là một đối tượng Python (khoảng 49 byte chi phí cố định, cộng độ dài chữ, cộng 8 byte con trỏ). Bằng cách chọn dtype đúng, ta giảm khoảng 7 lần (161 MB xuống 22 MB). Con số trong bảng là ước tính theo cách tính trên; bạn nên kiểm tra thực tế bằng df.memory_usage(deep=True).sum() vì pandas có thể dùng chung các chuỗi lặp lại.

python
import pandas as pd

dtypes = {
    "order_id": "int32",
    "customer_id": "int32",
    "store_id": pd.CategoricalDtype([f"{i:02d}" for i in range(1, 41)]),
    "amount": "int32",
    "status": "category",
}
df = pd.read_csv("orders.csv", dtype=dtypes, parse_dates=["created_at"])
print(df.memory_usage(deep=True).sum() / 1_000_000, "MB")

Cái bẫy số 0 đứng đầu. Mã cửa hàng 07 trông như số nhưng là mã định danh. Hãy dự đoán: pd.read_csv("orders.csv", dtype={"store_id": "category"})["store_id"].cat.categories[:3] trả về gì? Câu trả lời là [1, 2, 3] chứ không phải ['01', '02', '03']: pandas suy luận danh mục là số nguyên và bỏ số 0 đứng đầu, nên 07 và 7 trở thành cùng một cửa hàng khi nối với nguồn khác. Vì vậy mã định danh phải được đọc là chuỗi: dùng "string" khi chỉ đếm theo khối, hoặc pd.CategoricalDtype với danh sách danh mục là chuỗi như trong đoạn mã trên khi muốn tiết kiệm bộ nhớ.

Đang tải sơ đồ…

Cách đọc hình: hai nhánh đọc cùng một file nhưng khác lời hướng dẫn. Nhánh trái để pandas đoán, nhánh phải khai báo schema rõ ràng. Khai báo schema còn mang lại một lợi ích khác: nếu một ô số nguyên chứa chữ, bạn nhận lỗi ngay lúc đọc thay vì để lỗi lặng lẽ chảy xuống kho dữ liệu. Riêng parse_dates không báo lỗi khi gặp ngày sai: cột âm thầm giữ kiểu chuỗi, nên hãy kiểm tra df["created_at"].dtype sau khi đọc.

Mô hình tư duy 2: khi dữ liệu lớn hơn bộ nhớ, xử lý theo khối

Tối ưu dtype giúp giảm khoảng 7 lần (161 MB xuống 22 MB), nhưng tới một kích thước nào đó vẫn không đủ. Với file 30 GB bạn cần cách khác: đọc từng khối (chunk), xử lý, giữ lại kết quả nhỏ, bỏ khối đó đi. Cách này quen thuộc từ bài Shell: dữ liệu chảy, không chứa.

Ví dụ đếm số đơn theo cửa hàng cho file không vừa bộ nhớ:

python
import pandas as pd

totals = pd.Series(dtype="int64")
reader = pd.read_csv("orders.csv", usecols=["store_id"], dtype={"store_id": "string"}, chunksize=500_000)
for chunk in reader:
    totals = totals.add(chunk["store_id"].value_counts(), fill_value=0)
print(totals.sort_values(ascending=False).head(5).astype(int))

Ba điểm đáng nhớ: usecols chỉ đọc cột cần, nên khối nhỏ hơn rất nhiều; chunksize quyết định số dòng mỗi khối; và kết quả trung gian (totals) chỉ có 40 dòng thay vì 20 triệu.

Đang tải sơ đồ…

Cách đọc hình: mỗi khối được đọc, tính ra một kết quả nhỏ rồi bị bỏ; bộ nhớ chỉ cần chứa một khối và kết quả gộp. Điều kiện để cách này đúng là phép tính có thể gộp từng phần: đếm, tổng, nhỏ nhất, lớn nhất đều gộp được. Một số phép như "trung vị chính xác" hay "loại trùng toàn bộ" thì không gộp tự nhiên được, và đó là lúc ta chuyển sang cơ sở dữ liệu hoặc Spark.

Đang tải Animation…

Cái bẫy loại trùng khi chia khối

Hãy dự đoán: nếu bạn gọi drop_duplicates("order_id") trong từng khối, bạn đã loại hết đơn trùng chưa? Chưa. Hai dòng trùng nằm ở hai khối khác nhau sẽ không bao giờ được so sánh với nhau. Muốn loại trùng đúng bạn cần trạng thái xuyên khối (tập các order_id đã thấy, tốn bộ nhớ) hoặc để cơ sở dữ liệu làm việc đó với khóa chính và upsert; đây chính là lý do ở tuần 6 ta thiết kế thao tác ghi idempotent.

Những hiểu lầm thường gặp

  • "pandas nhanh nên cứ nạp hết." pandas nhanh khi dữ liệu vừa bộ nhớ; vượt quá nó, bạn phải chia khối hoặc đổi công cụ.
  • "object cho tiện, kiểu nào cũng nhận." object là kiểu tốn kém nhất và khiến lỗi dữ liệu (ví dụ chữ lẫn trong cột số) bị che.
  • "Chunk nào cũng làm được mọi phép tính." Chỉ các phép gộp được từng phần mới đúng khi chia khối.

Tự kiểm tra (có đáp án)

Câu 1. Vì sao cột status chỉ có 5 giá trị nên dùng category? Đáp án: vì category lưu mỗi giá trị khác nhau một lần và mỗi ô chỉ là một mã nhỏ (1 byte với ít hơn 128 giá trị), thay vì một đối tượng chuỗi cho từng ô.

Câu 2. Đọc theo khối có tính đúng được "trung bình amount" không? Đáp án: có, bằng cách giữ tổng và số dòng của từng khối rồi chia ở cuối; không được lấy trung bình của các trung bình khối nếu các khối có số dòng khác nhau.

Câu 3. Vì sao drop_duplicates theo từng khối chưa đủ để loại trùng toàn file? Đáp án: vì các dòng trùng ở hai khối khác nhau không được so sánh với nhau; cần trạng thái xuyên khối hoặc một cơ sở dữ liệu có khóa chính.

Nếu chỉ nhớ 3 điều

  1. Bộ nhớ phụ thuộc dtype; khai báo schema (int32, category, datetime64) có thể giảm bộ nhớ khoảng 7 lần.
  2. Với dữ liệu quá lớn hãy đọc theo khối, giữ kết quả gộp nhỏ và chỉ dùng phép tính gộp được từng phần.
  3. Loại trùng cần trạng thái xuyên khối; việc đó nên giao cho cơ sở dữ liệu qua khóa chính và upsert.

Nguồn tham khảo