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õ * và @ — 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.