Tối ưu hóa Pandas: Cách giảm 95% thời gian chạy và những sai lầm phổ biến

26 tháng 4, 2026·15 phút đọc

Mã Pandas thường chạy đúng nhưng lại rất chậm do các nút thắt hiệu suất ẩn. Bài viết này sẽ hướng dẫn cách nhận diện các vấn đề về hiệu suất, tránh các thao tác tốn kém theo từng hàng và biết khi nào thì Pandas không còn là lựa chọn phù hợp.

Tối ưu hóa Pandas: Cách giảm 95% thời gian chạy và những sai lầm phổ biến

Tôi đã dành thời gian để tìm hiểu Pandas một thời gian nay. Không có gì quá cao siêu cả, chỉ là làm sạch dữ liệu cơ bản, phân tích dữ liệu khám phá (EDA) và một vài hàm cần thiết. Tôi cũng đã tìm hiểu về việc nối chuỗi phương thức (method chaining) để code gọn gàng hơn, cũng như những thao tác âm thầm phá vỡ quy trình làm việc của Pandas.

Tuy nhiên, tôi chưa bao giờ thực sự nghĩ đến thời gian chạy (runtime). Thực lòng, nếu mã của tôi chạy mà không gặp lỗi và cho ra kết quả cần thiết, tôi đã hài lòng. Ngay cả khi tất cả các ô trong notebook của tôi mất vài phút để hoàn thành, tôi cũng không bận tâm. Không lỗi có nghĩa là không có vấn đề, đúng không?

Sau đó, tôi gặp khái niệm vector hóa (vectorization). Và mọi thứ bừng sáng.

Tôi đi sâu vào "cái hang thỏ" như mọi khi. Càng đọc, tôi càng nhận ra rằng "không có lỗi" và "mã hiệu quả" là hai khái niệm hoàn toàn khác nhau. Mã Pandas của bạn có thể hoàn toàn chính xác nhưng vẫn hoạt động cực kỳ tệ khi mở rộng quy mô.

Vì vậy, bài viết này là tài liệu ghi chép lại những gì tôi tìm thấy. Những sai lầm làm chậm mã Pandas, tại sao chúng xảy ra, cách khắc phục và khi nào chính Pandas trở thành nút thắt cổ chai. Nếu bạn từng chạy một notebook và mặc định chấp nhận thời gian chờ đợi là bình thường, bài viết này dành cho bạn.

Tại sao "Mã chạy được" chưa đủ tốt

Có một lý do khiến tôi mất thời gian để suy nghĩ về vấn đề này. Pandas được thiết kế để rất "dễ tha thứ". Bạn có thể viết mã theo hàng chục cách khác nhau và hầu hết chúng đều chạy được. Bạn nhận được kết quả, dataframe của bạn trông ổn, và bạn chuyển sang việc khác.

Nhưng sự linh hoạt đó đi kèm với cái giá ẩn.

Khác với SQL hay các hệ thống dữ liệu cấp độ sản xuất, Pandas không ép buộc bạn phải nghĩ về hiệu quả. Nó không cảnh báo bạn khi bạn đang làm điều gì đó tốn kém. Nó chỉ đơn giản là... làm. Chậm đôi khi, nhưng vẫn làm.

Hãy nghĩ theo cách này. SQL có bộ tối ưu hóa truy vấn. Nó nhìn vào những gì bạn yêu cầu và tìm ra cách hiệu quả nhất để lấy nó. Pandas không có thứ đó. Nó tin tưởng bạn viết mã hiệu quả. Và nếu bạn không biết mã hiệu quả trông như thế nào, bạn sẽ không bao giờ biết mình đang bỏ lỡ điều gì.

Kết quả là rất nhiều mã Pandas ngoài kia, một cách lịch sự, là kém hiệu quả. Nó hoạt động tốt trên các bộ dữ liệu nhỏ. Nó hoạt động trên các bộ dữ liệu vừa với một chút kiên nhẫn. Nhưng ngay khi bạn ném dữ liệu thực tế vào đó, thứ gì đó có vài trăm nghìn hàng hoặc nhiều hơn, những vết nứt bắt đầu xuất hiện. Thứ từng mất vài giây giờ mất vài phút. Thứ từng mất vài phút trở nên không thể sử dụng.

Và điều bực mình nhất là không có gì trông có vẻ sai. Không lỗi. Không cảnh báo. Chỉ là một notebook chậm và một con trỏ đang xoay.

Đó là cái bẫy. Pandas tối ưu hóa cho sự tiện lợi, không phải tốc độ. Và sự tiện lợi thì tuyệt vời, cho đến khi nó không còn nữa.

Vì vậy, sự thay đổi đầu tiên là về tư duy: mã chạy được và mã hiệu quả không phải là một. Khi bạn hiểu được điều đó, mọi thứ khác sẽ theo sau.

Đo lường hiệu năng: Đừng đoán, hãy đo

Đây là điều tôi nhận thấy khi đi sâu vào chủ đề này. Đa số mọi người, khi cảm thấy mã của mình chậm, sẽ làm một trong hai việc. Hoặc họ viết lại toàn bộ từ đầu hy vọng điều gì đó được cải thiện, hoặc họ chấp nhận và chờ đợi.

Không cái nào trong số đó là động thái đúng.

Động thái đúng là đo lường trước. Bạn không thể tối ưu hóa những gì bạn chưa xác định được. Và thường thì, phần mã bạn nghĩ là chậm thực ra không phải là vấn đề.

Pandas cung cấp một vài công cụ đơn giản để bắt đầu.

%timeit — Biết chính xác mọi thứ mất bao lâu

%timeit là một lệnh ma thuật của Jupyter chạy một dòng mã nhiều lần và cho bạn thời gian thực thi trung bình. Đó là cách đơn giản nhất để so sánh hai cách tiếp cận và biết cụ thể cách nào nhanh hơn.

import pandas as pd
import numpy as np

df = pd.DataFrame({
    'sales': np.random.randint(100, 10000, size=100_000),
    'discount': np.random.uniform(0.0, 0.5, size=100_000)
})

# Cách tiếp cận A
%timeit df.apply(lambda row: row['sales'] * row['discount'], axis=1)

# Cách tiếp cận B
%timeit df['sales'] * df['discount']

Trên một bộ dữ liệu 100.000 hàng, sự khác biệt không hề nhỏ:

  • Cách A: 1.91 s ± 228 ms
  • Cách B: 316 μs ± 14 μs

Cùng một kết quả. Chi phí hoàn toàn khác. Đó là loại điều mà bạn sẽ không bao giờ nhận thấy nếu chỉ chạy ô một lần và chuyển tiếp.

df.info() và df.memory_usage() — Biết bạn đang mang gì

Tốc độ không chỉ là tính toán. Bộ nhớ cũng đóng một vai trò lớn. Một dataframe bị phình lên với các kiểu dữ liệu sai sẽ làm chậm mọi thứ trước khi bạn viết bất kỳ phép chuyển đổi nào.

df.info()

Để kiểm tra việc sử dụng bộ nhớ:

df.memory_usage(deep=True)

Ở đây, chúng ta có thể thấy rằng cột discount đang chiếm gấp đôi không gian. Điều này là do discount được lưu trữ dưới dạng kiểu số "nặng" (float64) trong khi sales được lưu trong kiểu "nhẹ" hơn (int32).

Điều này trở nên đặc biệt quan trọng khi bạn làm việc với các cột chuỗi hoặc kiểu object đang âm thầm "ăn" bộ nhớ.

Sai lầm #1: Các thao tác theo từng hàng (Kẻ giết người thầm lặng)

Nếu có một điều tôi thấy lặp đi lặp lại khi nghiên cứu chủ đề này, đó là: mọi người lặp qua các dataframe của Pandas từng hàng một. Và tôi hiểu điều đó. Nó cảm thấy tự nhiên. Bạn nghĩ về dữ liệu của mình từng hàng một, vì vậy bạn viết mã xử lý nó từng hàng một.

Vấn đề là, đó không phải cách Pandas suy nghĩ.

Pandas thực sự hoạt động như thế nào

Pandas được xây dựng trên nền NumPy, nơi lưu trữ dữ liệu trong các khối bộ nhớ liền kề, cột theo cột. Điều này có nghĩa là Pandas được tối ưu hóa mạnh mẽ để hoạt động trên toàn bộ cột cùng một lúc. Khi bạn làm điều đó, nó chạy các thao tác vector hóa cấp thấp, nhanh chóng dưới bề mặt.

Khi bạn lặp qua các hàng thay vào đó, về cơ bản bạn đang bỏ qua tất cả những điều đó. Bạn đang rơi xuống thuần Python, từng hàng một, với tất cả các chi phí đi kèm. Trên bộ dữ liệu nhỏ bạn sẽ không bao giờ nhận thấy. Trên bộ dữ liệu lớn, bạn sẽ phải chờ rất lâu.

Có hai mô hình xuất hiện liên tục.

.iterrows()

# Tính giá đã giảm từng hàng một
discounted_prices = []
for index, row in df.iterrows():
    discounted_prices.append(row['sales'] * (1 - row['discount']))
df['discounted_price'] = discounted_prices

Cách này hoạt động. Nó sẽ cho bạn câu trả lời đúng. Nhưng trên một dataframe với 100.000 hàng, nó cực kỳ chậm.

.apply(axis=1)

Cái này lén lút hơn vì nó trông giống "kiểu Pandas" hơn. Nhưng việc áp dụng một hàm qua axis=1 có nghĩa là áp dụng nó từng hàng một, về cơ bản là cùng một vấn đề.

Giải pháp: Các thao tác vector hóa

Đây là cùng một phép tính, được thực hiện theo cách Pandas thực sự muốn bạn làm:

df['discounted_price'] = df['sales'] * (1 - df['discount'])

Hãy đo thời gian:

%timeit df['sales'] * (1 - df['discount'])

Kết quả: 688 μs ± 236 μs.

Chỉ có vậy. Một dòng. Không vòng lặp. Không lambda. Và nó nhanh hơn khoảng 14.800 lần so với .iterrows() và 2.180 lần so với .apply(axis=1).

Điều xảy ra ở đây là Pandas chuyển toàn bộ cột cho NumPy, nơi thực thi thao tác ở cấp C trên toàn bộ mảng cùng một lúc. Không có chi phí Python. Không có lặp lại từng hàng. Chỉ là tính toán cấp thấp nhanh chóng.

Sai lầm #2: Sao chép không cần thiết và sự phình ra bộ nhớ

Các thao tác theo từng hàng nhận được nhiều sự chú ý khi mọi người nói về hiệu suất Pandas. Bộ nhớ thì ít được chú ý hơn. Điều đáng tiếc, vì theo kinh nghiệm của tôi, bộ nhớ phình lên cũng chịu trách nhiệm cho các notebook chậm giống như tính toán kém.

Điều là thế này. Các thao tác Pandas không luôn luôn sửa đổi dataframe của bạn tại chỗ (in-place). Nhiều thao tác âm thầm tạo ra một bản sao hoàn toàn mới của dữ liệu của bạn phía sau hậu trường. Làm điều đó đủ nhiều lần, và bạn không chỉ giữ một dataframe trong bộ nhớ. Bạn đang giữ vài cái, cùng một lúc, mà không hề hay biết.

Chi phí ẩn của các thao tác nối chuỗi

Các thao tác nối chuỗi thường là thủ phạm. Chúng trông sạch sẽ và dễ đọc, nhưng mỗi bước có thể tạo ra một bản sao trung gian nằm trong bộ nhớ cho đến khi bộ thu gom rác (garbage collection) dọn dẹp nó.

# Mỗi bước ở đây tiềm năng tạo ra một bản sao mới
df2 = df[df['sales'] > 1000]
df3 = df2.dropna()
df4 = df3.reset_index(drop=True)
df5 = df4[['sales', 'discount']]

Đến khi bạn đến df5, bạn tiềm năng có năm phiên bản dữ liệu của mình đang lơ lửng trong bộ nhớ cùng lúc. Trên bộ dữ liệu nhỏ điều này vô hình. Trên bộ dữ liệu lớn, đây là cách bạn hết RAM.

Các cột tạm thời tồn tại dai dẳng

Một mô hình khác âm thầm ăn bộ nhớ là tạo ra các cột bạn chỉ cần tạm thời.

df['gross_revenue'] = df['sales'] * df['quantity']
df['tax'] = df['gross_revenue'] * 0.075
df['net_revenue'] = df['gross_revenue'] - df['tax']
# Nhưng bạn thực sự chỉ cần net_revenue

gross_revenuetax giờ là các cột vĩnh viễn trong dataframe của bạn, chiếm bộ nhớ cho phần còn lại của notebook mặc dù chúng chỉ là các bước đệm.

Giải pháp rất đơn giản. Hoặc tính toán trực tiếp:

df['net_revenue'] = (df['sales'] * df['quantity']) * (1 - 0.075)

Hoặc xóa chúng ngay khi bạn xong:

df.drop(columns=['gross_revenue', 'tax'], inplace=True)

Kiểu dữ liệu sai âm thầm tốn kém

Điều này ngạc nhiên tôi khi tôi gặp nó. Theo mặc định, Pandas khá hào phóng với lượng bộ nhớ nó gán cho mỗi cột. Cột số nguyên nhận int64. Cột số thực nhận float64. Cột chuỗi trở thành kiểu object, một trong những kiểu đói bộ nhớ nhất trong Pandas.

Hãy xem điều đó thực sự trông như thế nào:

df = pd.DataFrame({
    'order_id': np.random.randint(1000, 9999, size=100_000),
    'sales': np.random.randint(100, 10000, size=100_000),
    'discount': np.random.uniform(0.0, 0.5, size=100_000),
    'region': np.random.choice(['north', 'south', 'east', 'west'], size=100_000)
})

df.memory_usage(deep=True)

Cột region, chỉ có bốn giá trị có thể, đang tiêu thụ 5.3MB dưới dạng kiểu object. Chuyển đổi nó sang category và xem điều gì xảy ra:

df['region'] = df['region'].astype('category')
df.memory_usage(deep=True)

Từ 5.3MB xuống khoảng 100KB. Cho một cột. Logic tương tự cũng áp dụng cho các cột số nguyên nơi bạn không cần toàn bộ phạm vi int64. Nếu giá trị của bạn vừa vặn trong int32 hoặc thậm chí int16, việc giảm kiểu (downcasting) tiết kiệm bộ nhớ thực sự.

Sai lầm #3: Lạm dụng Pandas cho mọi thứ

Cái này hơi khác so với hai cái trước. Nó không phải về một hàm cụ thể hay một thói quen xấu. Nó là về việc biết giới hạn của công cụ bạn.

Pandas thực sự tuyệt vời. Đối với hầu hết các nhiệm vụ dữ liệu, đặc biệt là ở quy mô hầu hết mọi người đang làm việc, nó nhiều hơn là đủ. Nhưng có một phiên bản sử dụng Pandas mà tôi cứ thấy được mô tả khi nghiên cứu cái này: mọi người với tới Pandas theo mặc định, cho mọi thứ, bất kể nó có phải là sự phù hợp đúng không.

Và ở một quy mô nhất định, điều đó trở thành vấn đề.

Pandas bắt đầu gặp khó khăn ở đâu

Pandas tải toàn bộ bộ dữ liệu của bạn vào bộ nhớ. Điều đó ổn khi dữ liệu của bạn là vài trăm nghìn hàng. Nó bắt đầu khó chịu ở vài triệu. Và hơn thế nữa, bạn đang đấu tranh với công cụ.

Kịch bản khác là các phép chuyển đổi lồng nhau phức tạp nơi bạn xếp chồng nhiều thao tác, tạo ra các kết quả trung gian, và nói chung yêu cầu Pandas làm rất nhiều việc nặng nề theo trình tự. Mỗi bước thêm chi phí. Các chi phí xếp chồng lên nhau.

Khi nào nên cân nhắc các công cụ khác

Đôi khi câu trả lời trung thực là Pandas không phải là công cụ đúng cho việc đó. Đây không phải là chỉ trích, chỉ là phạm vi. Một vài cái đáng biết đến:

  • Polars: Thư viện dataframe hiện đại được xây dựng bằng Rust, thiết kế cho tốc độ. Nó sử dụng đánh giá lười (lazy evaluation), có nghĩa là nó tối ưu hóa toàn bộ truy vấn của bạn trước khi thực thi.
  • Dask: Mở rộng Pandas để làm việc song song qua nhiều lõi hoặc thậm chí nhiều máy.
  • DuckDB: Cho phép bạn chạy các truy vấn SQL trực tiếp trên dataframes hoặc tệp CSV với hiệu suất đáng ngạc nhiên.

Điểm không phải là bỏ Pandas. Đối với hầu hết công việc dữ liệu hàng ngày, đó là lựa chọn đúng. Điểm là nhận ra khi bạn đã chạm trần của nó, và biết rằng có những lựa chọn tốt ở phía bên kia.

Tái cấu trúc thực tế: Từ 61 giây xuống 0.33 giây

Đây là nơi mọi thứ chúng tôi bao phủ ngừng mang tính lý thuyết. Tôi đã lấy bộ dữ liệu thương mại điện tử 1 triệu hàng và viết loại mã Pandas cảm thấy hoàn toàn bình thường. Loại thứ bạn sẽ viết vào một buổi chiều thứ Ba mà không cần suy nghĩ twice.

Sau đó tôi đo thời gian nó.

Phiên bản chậm

import time
df = pd.read_csv('large_sales_data.csv')
start = time.time()

# Tính doanh thu theo từng hàng
df['gross_revenue'] = df.apply(
    lambda row: row['sales'] * row['quantity'], axis=1
)
df['tax'] = df.apply(
    lambda row: row['gross_revenue'] * 0.075, axis=1
)
df['net_revenue'] = df.apply(
    lambda row: row['gross_revenue'] - row['tax'], axis=1
)

# Gắn cờ theo từng hàng
df['order_flag'] = df.apply(
    lambda row: 'high' if row['net_revenue'] > 50000 else 'low', axis=1
)

# Tổng hợp cuối cùng
result = df.groupby('region')['net_revenue'].sum()
end = time.time()
print(f"Total runtime: {end - start:.2f} seconds")

Kết quả: 61.78 giây.

Hơn một phút. Cho một pipeline bốn bước. Và không có gì trông có vẻ sai.

Phiên bản nhanh

Đặt tất cả lại với nhau và pipeline trông như thế này:

import time
df = pd.read_csv('large_sales_data.csv')
start = time.time()

# Sửa 1: Đúng kiểu dữ liệu ngay từ đầu
df['region'] = df['region'].astype('category')
df['category'] = df['category'].astype('category')
df['status'] = df['status'].astype('category')

# Sửa 2: Tính toán doanh thu vector hóa, không có cột tạm thời
df['net_revenue'] = df['sales'] * df['quantity'] * (1 - 0.075)

# Sửa 3: Gắn cờ vector hóa với np.where
df['order_flag'] = np.where(df['net_revenue'] > 50000, 'high', 'low')

# Tổng hợp cuối cùng
result = df.groupby('region')['net_revenue'].sum()
end = time.time()
print(f"Total runtime: {end - start:.2f} seconds")

Kết quả: 0.33 giây.

Từ 61.78 giây xuống 0.33 giây. Một sự giảm 99.5% thời gian chạy. Nhanh hơn khoảng 187 lần.

Đó không phải là mẹo. Đó chỉ là cách Pandas được dự định sử dụng.

Trước khi bạn chạy Notebook tiếp theo

Mọi thứ chúng tôi bao gồm đều gói gọn trong vài thói quen cốt lõi. Không phải quy tắc. Không phải mẹo. Chỉ là một cách suy nghĩ khác về mã của bạn trước khi bạn viết nó.

  • Suy nghĩ theo cột, không phải theo hàng. Nếu bạn đang lặp qua một dataframe từng hàng một, hãy dừng lại và hỏi xem cùng một thứ có thể được biểu diễn dưới dạng thao tác cột không. Chín lần trên mười, câu trả lời là có.
  • Đo lường trước khi tối ưu hóa. Đừng đoán sự chậm chạp đến từ đâu. Sử dụng %timeitdf.memory_usage() để để các con số nói cho bạn biết cần sửa gì.
  • Theo dõi bộ nhớ, không chỉ tốc độ. Kiểu dữ liệu sai, các bản sao không cần thiết và các cột tạm thời tất cả cộng lại. Một dataframe nhẹ hơn là một dataframe nhanh hơn.
  • Biết khi nào chuyển công cụ. Pandas là lựa chọn đúng hầu hết thời gian. Nhưng ở một quy mô nhất định, tối ưu hóa đúng là nhận ra bạn đã vượt quá nó.
Bài viết được tổng hợp và biên soạn bằng AI từ các nguồn tin tức công nghệ. Nội dung mang tính tham khảo. Xem bài gốc ↗