NumPy: nền tảng tính toán số học của Python

NumPy là nền móng của gần như toàn bộ hệ sinh thái khoa học dữ liệu trong Python. Pandas, scikit-learn, PyTorch — tất cả đều xây trên NumPy. Hiểu NumPy giúp bạn viết code nhanh hơn và hiểu vì sao những thư viện kia hoạt động như vậy.

Vì sao không dùng list Python?

List Python lưu con trỏ tới các đối tượng nằm rải rác trong bộ nhớ. Mảng NumPy lưu dữ liệu thô liên tiếp nhau, cùng một kiểu. Khác biệt này tạo ra chênh lệch tốc độ rất lớn:

import numpy as np

# Cộng hai dãy một triệu phần tử
a = list(range(1_000_000))
b = list(range(1_000_000))
c = [x + y for x, y in zip(a, b)]      # chậm

a = np.arange(1_000_000)
b = np.arange(1_000_000)
c = a + b                               # nhanh hơn nhiều lần

Phép cộng ở dòng cuối được thực hiện bằng mã máy đã biên dịch sẵn, không phải qua vòng lặp của trình thông dịch Python.

Tạo mảng

np.array([1, 2, 3])
np.zeros((3, 4))              # ma trận 3x4 toàn số 0
np.ones((2, 3))
np.full((2, 2), 7)
np.arange(0, 10, 2)           # [0 2 4 6 8]
np.linspace(0, 1, 5)          # [0. 0.25 0.5 0.75 1.]
np.eye(3)                     # ma trận đơn vị 3x3

rng = np.random.default_rng(42)
rng.random((2, 3))            # số ngẫu nhiên trong [0, 1)
rng.normal(0, 1, 1000)        # phân phối chuẩn

Dùng default_rng thay cho np.random.seed kiểu cũ. Cách mới cho phép tạo nhiều bộ sinh số độc lập, tránh việc các phần khác nhau của chương trình can thiệp lẫn nhau.

Thuộc tính của mảng

a = np.array([[1, 2, 3], [4, 5, 6]])

print(a.shape)     # (2, 3) — 2 hàng, 3 cột
print(a.ndim)      # 2      — số chiều
print(a.size)      # 6      — tổng số phần tử
print(a.dtype)     # int64  — kiểu dữ liệu

Kiểu dữ liệu quyết định dung lượng bộ nhớ. Khi làm việc với mảng lớn, chọn kiểu nhỏ hơn có thể tiết kiệm đáng kể:

a = np.arange(1_000_000, dtype=np.int32)   # một nửa dung lượng so với int64

Truy cập và cắt lát

a = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]])

a[0, 2]         # 3     — hàng 0, cột 2
a[1]            # cả hàng 1
a[:, 1]         # cả cột 1
a[0:2, 1:3]     # vùng con
a[::2]          # các hàng chẵn

Một điểm quan trọng: cắt lát mảng NumPy trả về khung nhìn, không phải bản sao. Sửa khung nhìn là sửa mảng gốc:

b = a[0:2]
b[0, 0] = 999
print(a[0, 0])      # 999 — mảng gốc đã đổi

c = a[0:2].copy()   # muốn bản sao độc lập thì phải nói rõ

Lọc theo điều kiện

a = np.array([3, 8, 1, 9, 4, 7])

a[a > 5]                    # [8 9 7]
a[(a > 2) & (a < 8)]        # [3 4 7]
np.where(a > 5, a, 0)       # [0 8 0 9 0 7]
np.clip(a, 2, 7)            # giới hạn giá trị trong [2, 7]

Broadcasting

Đây là cơ chế cho phép NumPy thực hiện phép toán giữa các mảng có hình dạng khác nhau:

a = np.array([[1, 2, 3], [4, 5, 6]])    # (2, 3)
b = np.array([10, 20, 30])              # (3,)

print(a + b)
# [[11 22 33]
#  [14 25 36]]

NumPy tự “kéo giãn” mảng b cho khớp với a mà không thực sự sao chép dữ liệu trong bộ nhớ. Nhờ vậy bạn chuẩn hóa dữ liệu chỉ bằng một dòng:

du_lieu = rng.normal(100, 15, (1000, 5))
chuan_hoa = (du_lieu - du_lieu.mean(axis=0)) / du_lieu.std(axis=0)

Tham số axis

Đây là chỗ gây nhầm lẫn nhiều nhất. Với mảng hai chiều, axis=0 là theo chiều dọc (gộp các hàng lại), axis=1 là theo chiều ngang (gộp các cột lại):

a = np.array([[1, 2, 3], [4, 5, 6]])

a.sum()             # 21 — tổng tất cả
a.sum(axis=0)       # [5 7 9] — tổng theo từng cột
a.sum(axis=1)       # [6 15]  — tổng theo từng hàng

Cách nhớ: axis chỉ trục bị loại bỏ sau phép tính. Mảng (2, 3) sau sum(axis=0) còn lại hình dạng (3,).

Đại số tuyến tính

A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])

A * B            # nhân từng phần tử tương ứng
A @ B            # nhân ma trận đúng nghĩa

np.linalg.inv(A)      # ma trận nghịch đảo
np.linalg.det(A)      # định thức
np.linalg.solve(A, np.array([1, 2]))   # giải hệ phương trình

Phân biệt rõ *@ — nhầm hai toán tử này là nguồn gốc của nhiều lỗi khó phát hiện, vì cả hai đều chạy được mà không báo lỗi.

Mẹo tăng tốc

Nguyên tắc quan trọng nhất: tránh vòng lặp Python trên mảng NumPy. Nếu bạn đang viết for để duyệt từng phần tử, gần như chắc chắn có một hàm NumPy làm việc đó nhanh hơn nhiều lần.

# Chậm
ket_qua = np.zeros(len(a))
for i in range(len(a)):
    ket_qua[i] = a[i] ** 2 + 1

# Nhanh
ket_qua = a ** 2 + 1

Ngoài ra, cấp phát bộ nhớ sẵn thay vì nối mảng liên tục. Mỗi lần np.append là một lần cấp phát và sao chép toàn bộ mảng — dùng trong vòng lặp sẽ khiến chương trình chậm theo cấp số nhân.