Ở Bài 2, chúng ta đã hiểu bản chất toán học của đại số tuyến tính và cách tối ưu hóa độ dốc. Tuy nhiên, khi huấn luyện các mô hình AI thực tế, lượng dữ liệu đầu vào không phải là một vài con số đơn lẻ mà là hàng triệu điểm ảnh, hàng tỷ từ ngữ hoặc các bảng cơ sở dữ liệu khổng lồ.

Nếu tiếp tục sử dụng các vòng lặp for hay danh sách (List) mặc định của Python để xử lý tập dữ liệu khổng lồ này, chương trình của bạn sẽ chạy chậm đến mức không thể chấp nhận được. Bài học này sẽ giúp bạn hiểu rõ nguyên nhân gốc rễ ở cấp độ phần cứng và cách khắc phục triệt để bằng cách tận dụng hai thư viện nền tảng của AI: NumPyPandas.

✅ Bạn cần gì trước khi bắt đầu
Khác với Bài 2 (chạy bằng Python thuần), bài này bắt buộc phải cài hai thư viện. Kích hoạt môi trường ảo đã tạo ở Bài 1 rồi chạy:

pip install numpy pandas

Nhắc lại một cái bẫy của Bài 1: nếu bạn mở terminal mới thì phải source venv/bin/activate lại, nếu không sẽ gặp ModuleNotFoundError: No module named 'numpy' dù rõ ràng đã cài.

Kiến thức cần có: Bài 2 — cụ thể là ma trận, phép nhân ma trận và điều kiện ghép chiều "cột trái = dòng phải". Mục 3.3 dựa trực tiếp vào đó.
🧭 Hai thư viện, hai việc khác nhau — đừng lẫn ngay từ đầu
Bài này dạy hai thư viện và người mới rất hay lẫn xem nên dùng cái nào. Phân định cho gọn:
  • NumPy lo những khối số thuần nhất — mọi ô cùng một kiểu, không tên cột. Một lô ảnh, một ma trận trọng số, một tensor. Đây là thứ mô hình thật sự tính toán trên đó.
  • Pandas lo bảng có tên cột và kiểu lẫn lộn — cột này là chuỗi, cột kia là số, cột nọ là ngày tháng, và có ô trống. Một file CSV, một bảng xuất từ cơ sở dữ liệu.
Quy trình thật gần như luôn đi theo thứ tự: Pandas dọn dẹp trước, NumPy tính toán sau. Bạn đọc file thô bằng Pandas, xử lý ô trống và lọc dòng rác, rồi chuyển phần số sang NumPy để đưa vào mô hình. Dự án ở mục 3.5 làm đúng chuỗi đó từ đầu đến cuối.

3.1 Vì sao vòng lặp For trong Python rất chậm?

Như đã nói ở Bài 1, Python là ngôn ngữ thông dịchđịnh kiểu động — chạy trực tiếp không qua bước biên dịch, và một biến có thể giữ số ở dòng này rồi giữ chuỗi ở dòng sau. Hai đặc điểm ấy tiện khi viết, nhưng phải trả giá, và mục này chỉ ra giá đó nằm ở đâu.

Khi bạn tạo một danh sách thông thường my_list = [1, 2, 3], Python không lưu ba số này cạnh nhau trong RAM. Nó tạo ra một mảng các con trỏ (pointer) — mỗi con trỏ là một địa chỉ, chỉ tới một đối tượng Python đầy đủ (gọi là PyObject) nằm rải rác khắp nơi trong bộ nhớ.

📚 Hình dung: giá sách xếp liền vs hộp phiếu mượn sách
Mảng NumPy giống một giá sách xếp liền nhau: muốn lấy 10 cuốn kế tiếp, bạn với tay một lần là gom được cả 10.

List của Python giống một hộp phiếu mượn sách: mỗi phiếu chỉ ghi tên phòng chứa cuốn sách đó. Muốn đọc 10 cuốn, bạn phải chạy tới 10 phòng khác nhau. Bản thân việc đọc sách không chậm hơn — chạy đi chạy lại mới là chỗ mất thời gian.

Và mỗi cuốn sách trong Python còn được bọc thêm nhiều lớp giấy gói: một số nguyên 1 trong Python không phải 8 byte, mà là một đối tượng đầy đủ mang theo thông tin kiểu, số đếm tham chiếu và những thứ khác — cỡ 28 byte. Còn trong NumPy, nó đúng là 8 byte, hoặc 4 byte nếu bạn chọn float32.

Mỗi khi chạy một vòng lặp for x in my_list, tại từng bước lặp, trình thông dịch Python buộc phải làm một loạt việc ngầm mà bạn không viết ra — thuật ngữ gọi là overhead (phần chi phí phụ):

  • Kiểm tra kiểu dữ liệu (Type checking): Kiểm tra xem x ở bước lặp hiện tại là số nguyên, số thực hay chuỗi để quyết định phép toán phù hợp.
  • Đóng/Mở hộp giá trị (Boxing/Unboxing): Trích xuất giá trị số thô nằm sâu bên trong cấu trúc đối tượng PyObject phức tạp.
  • Quản lý bộ nhớ: Cập nhật số lượng tham chiếu (Reference counting) để dọn rác tự động.
🧠 Bản chất phần cứng: Contiguous Memory vs Cache Locality
Thư viện NumPy lưu trữ dữ liệu dưới dạng các mảng liên tục (Contiguous Memory Layout). Mảng np.ndarray thực chất là một khối bộ nhớ RAM chứa các giá trị số thô (raw bytes) có kiểu dữ liệu đồng nhất (ví dụ: float32) nằm sát cạnh nhau.

Về mặt phần cứng, cấu trúc liên tục này cho phép tận dụng tối đa cơ chế Cache Locality (Tính cục bộ của bộ nhớ đệm). Khi CPU cần tính toán một phần tử, phần cứng sẽ tự động nạp cả khối dữ liệu liên tiếp xung quanh phần tử đó từ RAM vào bộ nhớ đệm tốc độ cao Cache L1/L2/L3 của CPU. Do đó, các phép toán tiếp theo trên mảng sẽ diễn ra ngay lập tức trên thanh ghi CPU. Trái lại, Python List chứa các con trỏ phân tán sẽ bắt CPU phải liên tục truy xuất trực tiếp vào RAM để tìm địa chỉ thực tế của đối tượng (gây ra lỗi Cache Miss liên tục), làm giảm hiệu năng đi hàng chục lần.

3.2 Nguyên lý Vectorization (Vector hóa câu lệnh)

Vectorization (Vector hóa) là kỹ thuật lập trình thay thế các vòng lặp tường minh bằng các phép toán trực tiếp trên toàn bộ mảng dữ liệu. Thay vì viết vòng lặp xử lý từng phần tử một ở tầng Python chậm chạp, chúng ta đẩy toàn bộ logic lặp xuống tầng mã máy hiệu năng cao của thư viện NumPy.

Bên cạnh đó, các CPU hiện đại ngày nay đều hỗ trợ tập lệnh song song ở cấp phần cứng gọi là SIMD (Single Instruction Multiple Data - Một chỉ thị, nhiều dữ liệu). Công nghệ này cho phép CPU thực hiện một phép toán (ví dụ như phép nhân) trên nhiều ô nhớ cùng một lúc trong một chu kỳ xung nhịp duy nhất.

Hãy cùng so sánh hiệu năng trực quan giữa vòng lặp Python thuần và phép toán vector hóa của NumPy:

vectorization_demo.py
import time
import numpy as np

size = 1_000_000
python_list = list(range(size))
numpy_array = np.arange(size)

# Way 1: a plain Python loop (a list comprehension is still a Python loop).
start = time.time()
python_result = [x * 2 for x in python_list]
print(f"Python loop:        {(time.time() - start) * 1000:7.2f} ms")

# Way 2: one vectorised NumPy operation. The loop still happens — but inside
# compiled C, over a contiguous block, using the CPU's SIMD instructions.
start = time.time()
numpy_result = numpy_array * 2
print(f"NumPy vectorised:   {(time.time() - start) * 1000:7.2f} ms")

Kết quả chạy trên máy viết bài (Apple M1 Max, 32 GB RAM, Python 3.11, NumPy 2.4):

Terminal
Python loop:          22.14 ms
NumPy vectorised:      0.39 ms

Khoảng 20 đến 56 lần nhanh hơn — tôi chạy nhiều lượt và tỉ lệ dao động trong khoảng đó, lượt đầu luôn kém nhất vì cache CPU còn nguội. Đừng bám vào một con số cụ thể: tỉ lệ phụ thuộc vào máy, vào kiểu dữ liệu và vào chính phép toán bạn làm. Điều đáng nhớ là bậc độ lớn — hàng chục lần, không phải vài phần trăm.

Cũng cần nói rõ một điều dễ hiểu sai: vòng lặp không hề biến mất. Một triệu phép nhân vẫn phải xảy ra. Chỉ là vòng lặp đó chuyển từ tầng Python xuống tầng mã máy C đã biên dịch, nơi không còn kiểm kiểu và không còn mở hộp giá trị ở mỗi bước. "Vector hóa" là chuyển vòng lặp xuống chỗ khác, không phải xóa nó đi.

⚠️ Cạm bẫy: Vòng lặp For trên mảng NumPy
Một sai lầm rất phổ biến của người mới là viết vòng lặp duyệt qua mảng NumPy: for x in my_numpy_array: .... Cách này thậm chí còn chậm hơn cả dùng List thuần của Python, vì ở mỗi bước lặp NumPy phải lấy số thô trong RAM và gói nó thành một PyObject để Python dùng được — tức là bạn vừa trả giá của cả hai bên.

Đo thật trên cùng máy nói trên, cộng dồn một triệu phần tử: vòng lặp trên ndarray mất 106 ms, còn trên list chỉ mất 50 ms — chậm hơn 2,1 lần. Nói cách khác, dùng NumPy sai cách còn tệ hơn không dùng NumPy.

Quy tắc: luôn dùng hàm có sẵn của NumPy (np.sum(), np.mean(), arr * 2) thay vì tự viết vòng lặp. Nếu bạn thấy mình đang gõ for lên một mảng NumPy, gần như chắc chắn có một cách vector hóa cho việc đó.

3.3 Phép phát thanh (Broadcasting) trong NumPy

Trong đại số tuyến tính chuẩn, bạn chỉ có thể cộng hai ma trận có cùng kích thước chính xác với nhau. Tuy nhiên, trong học máy, chúng ta thường xuyên phải thực hiện các phép toán lệch chiều, ví dụ như cộng một vector độ lệch (bias) vào toàn bộ các dòng của một ma trận dữ liệu.

NumPy hỗ trợ cơ chế cực kỳ mạnh mẽ gọi là Broadcasting. Cơ chế này tự động căn chỉnh và kéo dãn ảo kích thước của mảng nhỏ hơn để khớp với mảng lớn hơn trong lúc tính toán, mà không hề sao chép thêm dữ liệu thật trong RAM.

📢 Broadcasting là gì, nói cho gọn
Tên tiếng Việt "phát thanh" nghe lạ, nhưng đúng nghĩa: một giá trị được phát ra cho nhiều nơi cùng nghe, thay vì phải in ra nhiều bản.

Broadcasting là: khi hai mảng lệch kích thước, NumPy tự lặp lại mảng nhỏ cho đủ để khớp mảng lớn — nhưng chỉ lặp trên giấy tờ, không lặp trong bộ nhớ.

Hình dung theo lối đời thường: bạn có bảng lương 1.000 người và muốn tăng mỗi người 500 nghìn. Bạn không cần tạo một cột 1.000 ô toàn số 500 rồi mới cộng — bạn chỉ nói "cộng 500 vào tất cả". Broadcasting là đúng việc đó, mở rộng cho mảng nhiều chiều.

Ví dụ dùng được: ma trận điểm (100, 5) — 100 học sinh, 5 môn — cộng với vector (5,) là điểm cộng riêng cho từng môn. Chiều cuối khớp nhau (5 = 5), nên mỗi học sinh nhận đúng phần cộng của môn tương ứng. Đây chính là hình dạng của phép cộng bias trong mọi tầng mạng nơ-ron.

Ví dụ KHÔNG dùng được: ma trận (100, 5) cộng với vector (4,). Chiều cuối là 5 và 4, không bằng nhau và cũng chẳng cái nào bằng 1, nên NumPy không có cách nào đoán ý bạn — nó báo lỗi thay vì đoán. Điều này đáng mừng: nếu NumPy tự ý dãn bừa thì bạn sẽ nhận một kết quả sai mà không hề biết.
🧠 Cơ chế Stride Tricks — Tiết kiệm bộ nhớ tuyệt đối
Khi thực hiện Broadcasting, NumPy không hề sao chép dữ liệu của mảng nhỏ hơn để tạo ra một mảng lớn hơn thực tế trong bộ nhớ RAM (đảm bảo bộ nhớ không bị phình to vô ích).

Thay vào đó, NumPy sử dụng kỹ thuật điều chỉnh thuộc tính Strides (bước nhảy) của mảng. Bước nhảy cho biết cần dịch chuyển bao nhiêu bytes trong RAM để chuyển sang phần tử tiếp theo của chiều đó. Bằng cách gán bước nhảy của chiều cần phát thanh bằng 0, NumPy bắt con trỏ lặp đi lặp lại giá trị cũ dọc theo chiều đó khi thực hiện phép toán ma trận, giúp tính toán cực kỳ tiết kiệm bộ nhớ.

Hai mảng dữ liệu có thể thực hiện Broadcasting nếu kích thước các chiều của chúng thỏa mãn một trong hai quy tắc (xét từ phải qua trái):

  1. Kích thước của chiều đó ở hai mảng bằng nhau.
  2. Một trong hai mảng có kích thước của chiều đó bằng 1 (chiều này sẽ được dãn ảo để khớp với mảng còn lại).
  3. Chiều đó không tồn tại ở mảng ít chiều hơn. Khi hai mảng khác số chiều, NumPy tự thêm các chiều bằng 1 vào phía trước mảng ngắn hơn cho bằng số chiều, rồi mới xét hai quy tắc trên. Đây là lý do vector (3,) được hiểu thành (1, 3) chứ không phải (3, 1) — và chính chỗ này sinh ra cạm bẫy ở cuối mục.

Từ "từ phải qua trái" là phần quan trọng nhất của ba quy tắc trên. NumPy căn hai shape theo lề phải, giống như cộng hai số thập phân thì căn theo dấu phẩy chứ không căn theo chữ số đầu.

Ví dụ minh họa Broadcasting:

$$A = \begin{bmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \end{bmatrix} \quad (\text{kích thước } 2 \times 3)$$ $$B = \begin{bmatrix} 10 & 20 & 30 \end{bmatrix} \quad (\text{kích thước } 1 \times 3)$$

Khi ta cộng $A + B$, NumPy nhận thấy chiều cuối cùng bằng nhau ($3 = 3$), và chiều thứ nhất của $B$ bằng $1$. NumPy sẽ tự động "nhân bản ảo" vector $B$ dọc theo chiều dòng để tạo thành ma trận kích thước $2 \times 3$ và cộng trực tiếp:

$$A + B = \begin{bmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \end{bmatrix} + \begin{bmatrix} 10 & 20 & 30 \\ 10 & 20 & 30 \end{bmatrix} = \begin{bmatrix} 11 & 22 & 33 \\ 14 & 25 & 36 \end{bmatrix}$$
broadcasting_demo.py
import numpy as np

A = np.array([[1, 2, 3],
              [4, 5, 6]])          # shape (2, 3)

B = np.array([10, 20, 30])         # shape (3,) — read as (1, 3) when broadcasting

# Adding straight away: B is repeated down the rows, virtually.
result = A + B
print("result:\n", result)
# result:
#  [[11 22 33]
#   [14 25 36]]
⚠️ Broadcasting theo CỘT (dễ nhầm nhất): vector (N,) không tự khớp theo dòng
Ví dụ trên hoạt động vì vector B cộng theo CỘT — chiều cuối của B (3) khớp với chiều cuối của A (3). Nhưng nếu bạn muốn cộng một giá trị RIÊNG cho mỗi DÒNG (ví dụ mỗi dòng là 1 mẫu dữ liệu, mỗi mẫu có 1 hệ số điều chỉnh khác nhau), một vector phẳng (4,) sẽ KHÔNG tự khớp với ma trận (4, 3) — vì NumPy so khớp chiều CUỐI trước tiên (4 ≠ 3), không phải chiều đầu.
broadcasting_row_vs_col.py
import numpy as np

matrix = np.arange(12).reshape(4, 3)   # shape (4, 3) — 4 rows, 3 columns
row_bias = np.array([1, 2, 3, 4])      # shape (4,)  — one value per ROW

try:
    result = matrix + row_bias
except ValueError as e:
    print("error:", e)
    # error: operands could not be broadcast together with shapes (4,3) (4,)
    # NumPy aligns from the RIGHT: 3 (matrix) against 4 (row_bias) -> no match.

# The fix: add a virtual dimension, turning (4,) into (4, 1).
row_bias_col = row_bias[:, np.newaxis]   # same as row_bias.reshape(4, 1)
result = matrix + row_bias_col           # (4,3) + (4,1) -> valid, one value per row
print("added per row:\n", result)
# added per row:
#  [[ 1  2  3]
#   [ 5  6  7]
#   [ 9 10 11]
#   [13 14 15]]

Quy tắc ghi nhớ: vector phẳng (N,) mặc định broadcast theo CỘT (mỗi phần tử ứng với 1 cột, lặp lại cho mọi dòng). Muốn broadcast theo DÒNG, bạn phải chủ động chuyển nó thành vector cột (N, 1) bằng reshape hoặc np.newaxis trước.

3.4 Pandas: bảng dữ liệu có tên cột

Ba mục vừa rồi đều nói về NumPy, và NumPy có một điều kiện tiên quyết mà ta chưa nhắc tới: mọi ô trong mảng phải cùng một kiểu. Điều đó ổn với ảnh và ma trận trọng số. Nhưng dữ liệu thật hiếm khi gọn gàng như vậy — một file CSV xuất từ hệ thống thường có cột tên (chuỗi), cột ngày tháng, cột số, và rải rác vài ô trống. Đưa thẳng bảng đó vào NumPy thì mọi thứ bị ép thành chuỗi và bạn mất luôn khả năng tính toán.

Đó là chỗ Pandas vào việc.

DataFrame và Series — hai kiểu dữ liệu duy nhất bạn cần biết

DataFrame là một bảng hai chiều có tên cột, mỗi cột một kiểu dữ liệu riêng. Hình dung theo lối đời thường: nó là một sheet Excel mà bạn điều khiển bằng code. Có tiêu đề cột, có số dòng, mỗi cột đựng một loại thông tin, và ô trống là chuyện bình thường.

Series là một cột đơn lẻ lấy ra từ bảng đó. Phân biệt này quan trọng hơn vẻ ngoài của nó, vì cách bạn viết dấu ngoặc quyết định bạn nhận về cái nào — và đó là cạm bẫy đầu tiên ở phần dưới.

Hai ví dụ để thấy ranh giới giữa Pandas và NumPy.

  • Việc của Pandas: bảng 50.000 dòng đơn hàng gồm mã đơn, tên khách, ngày đặt, số tiền, và 3% số dòng thiếu ngày đặt. Bạn cần lọc theo tháng, gộp theo khách, điền chỗ thiếu. Có tên cột, kiểu lẫn lộn, có ô trống — Pandas.
  • Việc của NumPy: lô 50.000 ảnh, mỗi ảnh 224×224×3 điểm ảnh, tất cả đều là số. Không có "tên cột" nào có nghĩa ở đây, và bạn cần phép toán chạy nhanh trên 7,5 tỉ con số — NumPy. Nhét khối này vào DataFrame chỉ làm nó chậm đi và khó dùng hơn.

Tạo bảng và nhìn vào nó trước khi làm gì khác

Ta dùng một bảng điểm nhỏ làm ví dụ xuyên suốt mục này. Bảng cố tình có hai ô trống, vì dữ liệu thật luôn có ô trống và cách xử lý chúng chiếm phần lớn công việc thực tế:

pandas_basics.py
import numpy as np
import pandas as pd

df = pd.DataFrame(
    {
        "student_id": [1, 2, 3, 4, 5, 6],
        "name": ["An", "Binh", "Chi", "Dung", "Em", "Phuc"],
        "class": ["A", "A", "B", "B", "A", "B"],
        "math": [8.5, 6.0, 9.0, np.nan, 7.5, 5.5],       # np.nan = an empty cell
        "literature": [7.0, 8.5, 6.5, 7.0, np.nan, 6.0],
    }
)

print("shape:", df.shape)      # (rows, columns)
print(df.head(3))              # the first 3 rows — always look before you compute
print(df.isna().sum())         # how many empty cells in each column
Terminal
shape: (6, 5)
   student_id  name class  math  literature
0           1    An     A   8.5         7.0
1           2  Binh     A   6.0         8.5
2           3   Chi     B   9.0         6.5

student_id    0
name          0
class         0
math          1
literature    1
dtype: int64

df.isna().sum() là dòng lệnh đáng thành phản xạ đầu tiên khi mở một bộ dữ liệu mới. Nó cho biết mỗi cột thiếu bao nhiêu ô, ở đây là 1 ô ở math và 1 ô ở literature. Nếu bỏ qua bước này, những ô trống ấy sẽ âm thầm biến mọi con số trung bình về sau thành NaN.

🕳️ Vì sao cột có ô trống luôn là số thực, không bao giờ là số nguyên
Cột math chứa 8.5 nên là số thực, chuyện đó dễ hiểu. Nhưng thử tạo một cột toàn số nguyên rồi làm trống một ô: kiểu của nó cũng tự nhảy sang float64.

Lý do: NaN (Not a Number) là một giá trị đặc biệt của chuẩn số thực, số nguyên không có chỗ nào để biểu diễn nó. Nên chỉ cần một ô trống là cả cột phải thành số thực.

Hệ quả bạn sẽ gặp thật: một cột "số lượng" đọc từ CSV bỗng in ra 3.0 thay vì 3. Không phải bạn làm sai — đó là dấu hiệu cột đó có ô trống ở đâu đó. Đúng cái manh mối mà isna().sum() vừa chỉ ra.

Chọn dữ liệu: [] vs .loc vs .iloc

Đây là chỗ gây nhầm lẫn nhiều nhất cho người mới học Pandas, nên tách riêng ra. Có ba cách chọn và chúng không thay thế được nhau:

  • df["math"] — chọn một cột theo tên, trả về một Series. Còn df[["math"]] với hai lớp ngoặc trả về một DataFrame chỉ có một cột. Cùng dữ liệu, khác kiểu — và đây là nguyên nhân của rất nhiều lỗi AttributeError khó hiểu về sau.
  • df.loc[...] — chọn theo nhãn: tên cột, và điều kiện lọc. Đây là cách bạn dùng 90% thời gian.
  • df.iloc[...] — chọn theo vị trí số, hệt như cắt list Python. Chữ "i" là integer. Dùng khi bạn muốn "3 dòng đầu" bất kể chúng tên gì.
pandas_select.py
print(type(df["math"]).__name__)      # Series    — one set of brackets
print(type(df[["math"]]).__name__)    # DataFrame — two sets of brackets

# .loc takes a condition and a list of columns. Read it as a sentence:
# "rows where math is above 7, and only the name and math columns".
print(df.loc[df["math"] > 7, ["name", "math"]])

# .iloc counts positions instead, exactly like list slicing.
print(df.iloc[0:2, 1:3])              # first 2 rows, columns at positions 1 and 2
Terminal
Series
DataFrame
  name  math
0   An   8.5
2  Chi   9.0
4   Em   7.5
   name class
0    An     A
1  Binh     A

Chú ý kết quả của .loc: các số dòng còn lại là 0, 2, 4 — nhảy cách. Đó là nhãn dòng, được giữ nguyên từ bảng gốc chứ không đánh số lại từ 0. Chi tiết này gây bất ngờ liên tục: sau khi lọc, df.iloc[1]df.loc[1] trả về hai dòng khác nhau. Nhớ được sự khác biệt nhãn/vị trí ở đây là bạn đã vượt qua phần khó nhất của Pandas.

Xử lý ô trống

Có hai lựa chọn, và chọn sai thì hỏng dữ liệu:

  • dropna()bỏ hẳn dòng có ô trống. Gọn, nhưng nếu 3% dòng thiếu ô thì bạn mất 3% dữ liệu, và cái mất đi thường không mất một cách ngẫu nhiên.
  • fillna(giá_trị)điền vào chỗ trống. Với cột số, điền bằng trung bình hoặc trung vị của chính cột đó là cách phổ biến nhất.
pandas_missing.py
clean = df.copy()   # never modify the original while exploring

# Fill each numeric column with ITS OWN mean, not one mean for the whole table.
clean["math"] = clean["math"].fillna(clean["math"].mean())
clean["literature"] = clean["literature"].fillna(clean["literature"].mean())

print(clean[["name", "math", "literature"]])
Terminal
   name  math  literature
0    An   8.5         7.0
1  Binh   6.0         8.5
2   Chi   9.0         6.5
3  Dung   7.3         7.0
4    Em   7.5         7.0
5  Phuc   5.5         6.0

Dòng của Dung nhận 7.3 ở cột math: đó là trung bình của 5 giá trị có thật $(8.5 + 6.0 + 9.0 + 7.5 + 5.5) / 5 = 7.3$. Đáng chú ý là mean() tự bỏ qua ô trống khi tính — nó chia cho 5 chứ không phải 6. Đây là hành vi mặc định rất tiện nhưng cũng dễ khiến bạn không nhận ra bảng của mình đang thiếu dữ liệu, một lý do nữa để chạy isna().sum() trước.

🕳️ Cạm bẫy: gán qua hai bước thì không ghi được vào bảng gốc
Đây là cạm bẫy kinh điển nhất của Pandas. Bạn lọc ra một phần bảng rồi gán giá trị cho phần đó, mong bảng gốc thay đổi theo:

sub = df[df["math"] > 8]  rồi  sub["math"] = 10.0

Bảng df gốc không hề đổi.df[...] có thể trả về một bản sao chứ không phải một cửa sổ nhìn vào dữ liệu gốc, nên bạn vừa sửa vào bản sao rồi bỏ đi.

Điều tệ hơn là mức độ ồn ào của nó thay đổi theo phiên bản. Tôi chạy thử cùng một đoạn code trên hai bản:
  • pandas 2.2: in ra SettingWithCopyWarning kèm gợi ý dùng .loc. Ít nhất bạn được cảnh báo.
  • pandas 3.0: không cảnh báo gì cả. Lệnh gán im lặng không có tác dụng. Cơ chế Copy-on-Write mới đã bỏ luôn lời cảnh báo đó.
Cả hai bản đều để bảng gốc y nguyên — chỉ khác ở việc bạn có được nhắc hay không. Nên đừng trông vào cảnh báo, hãy viết đúng ngay từ đầu: gán một lần bằng .loc, chọn dòng và cột trong cùng một cặp ngoặc:

df.loc[df["math"] > 8, "math"] = 10.0

Quy tắc dễ nhớ: nếu bạn thấy hai cặp ngoặc vuông liên tiếp ở bên trái dấu =, gần như chắc chắn có vấn đề.

groupby — thứ khiến việc học Pandas trở nên đáng giá

Nếu chỉ có một lý do để dùng Pandas thay vì tự viết code, thì đó là groupby. Nó gộp các dòng theo giá trị của một cột rồi tính toán trên từng nhóm — việc mà viết bằng vòng lặp và dict thì mất hai chục dòng và dễ sai.

pandas_groupby.py
# Read it as: split the rows by class, then average these two columns in each group.
print(clean.groupby("class")[["math", "literature"]].mean().round(3))
Terminal
        math  literature
class
A      7.333         7.5
B      7.267         6.5

Một dòng, và bạn có ngay điểm trung bình từng môn của từng lớp. Chú ý cột class đã chuyển thành nhãn dòng của bảng kết quả — đó là hành vi mặc định của groupby, thêm as_index=False nếu bạn muốn nó vẫn là cột bình thường.

Chuyển sang NumPy — điểm nối giữa hai nửa bài học

Mô hình AI không nhận DataFrame. Bước cuối của mọi quy trình tiền xử lý luôn là chuyển bảng đã dọn sạch thành mảng số thuần — và đó chính là chỗ hai nửa của bài học này gặp nhau:

pandas_to_numpy.py
# Pick only the numeric columns — a model has no use for the name column.
features = clean[["math", "literature"]].to_numpy()

print(type(features).__name__, features.shape, features.dtype)
# ndarray (6, 2) float64

# From here on it is pure NumPy again: broadcasting works, vectorisation works.
print(features - features.mean(axis=0))   # centre each column on its own mean

.to_numpy() là câu trả lời cho câu hỏi "vậy hai thư viện này nối với nhau ở đâu". Pandas nhận dữ liệu bừa bộn của thế giới thật và trả ra một khối số sạch; NumPy nhận khối đó và tính. Chú ý axis=0 ở dòng cuối: nó nghĩa là "gộp theo chiều dòng", cho ra trung bình của từng cột. Nhớ được axis=0 là theo cột còn axis=1 là theo dòng sẽ tiết kiệm cho bạn rất nhiều lần thử sai.

3.5 Dự án thực hành bài 3: Tiền xử lý một lô ảnh bằng cả hai thư viện

Dự án của bài này ghép đúng chuỗi công việc đã nói ở đầu bài: Pandas soi và dọn, NumPy tính. Chương trình tạo ra một lô ảnh xám giả lập (mảng 3 chiều Batch × Height × Width), lập bảng thống kê từng ảnh bằng Pandas, dùng bảng đó để phát hiện và loại các ảnh lỗi, rồi chuẩn hóa những ảnh còn lại bằng NumPy — không một vòng lặp nào chạy trên điểm ảnh.

Hai điểm trong cách dựng dữ liệu giả đáng nói trước, vì chúng quyết định dự án có dạy được gì hay không. Một: mỗi ảnh có một mức sáng riêng (40, 55, 70…) chứ không phải cùng một phân phối ngẫu nhiên — nếu mọi ảnh giống nhau về mặt thống kê thì bảng Pandas ở giữa sẽ toàn số như nhau và chẳng cho biết điều gì. Hai: hai ảnh bị làm hỏng có chủ đích, một đen thui và một trắng xóa, để bước dọn dẹp có việc thật mà làm. Dữ liệu thật luôn có những khung hình như vậy — cảm biến chết hoặc cháy sáng.

Toàn bộ mã nguồn:

image_normalize.py
# image_normalize.py
# Lesson 3: Working with large data — NumPy & Pandas in depth
# Practical AI Engineer series
#
# Run it with:  python image_normalize.py
# Requires:     pip install numpy pandas
#
# A small but complete preprocessing pipeline, the shape you meet in real
# projects: Pandas inspects and cleans the batch metadata, then NumPy does the
# heavy per-pixel arithmetic. Not one `for` loop over pixels anywhere.

import numpy as np
import pandas as pd

BATCH, HEIGHT, WIDTH = 12, 28, 28


def generate_dummy_images(num_images=BATCH, height=HEIGHT, width=WIDTH):
    """Fake a batch of grayscale images that genuinely differ from each other.

    Each image gets its own brightness level and its own amount of noise, so the
    per-image statistics below actually vary. (Drawing every image from the same
    uniform 0-255 distribution would make them statistically identical, and the
    normalisation step would have nothing to show.)

    Two images are deliberately broken, to give the cleaning step real work:
    one is entirely black, one is entirely white.
    """
    rng = np.random.default_rng(42)
    images = np.empty((num_images, height, width), dtype=np.uint8)

    for i in range(num_images):  # per IMAGE, not per pixel — 12 iterations, not 9408
        brightness = 40 + i * 15  # 40, 55, 70, ... a different level each time
        noise = rng.normal(0.0, 18.0, size=(height, width))
        images[i] = np.clip(brightness + noise, 0, 255).astype(np.uint8)

    images[3] = 0  # a dead sensor: completely black
    images[8] = 255  # an overexposed frame: completely white
    return images


def describe_batch(images):
    """Build a Pandas table of per-image statistics — one row per image.

    This is what Pandas is for: a small table with named, mixed-type columns that
    you want to inspect, filter and group. The pixels themselves stay in NumPy.
    """
    flat = images.reshape(len(images), -1)  # (batch, height*width), still no loop
    df = pd.DataFrame(
        {
            "image_id": np.arange(len(images)),
            "camera": ["cam-a", "cam-b"] * (len(images) // 2),
            "mean": flat.mean(axis=1),  # axis=1 -> collapse pixels, keep images
            "std": flat.std(axis=1),
            "min": flat.min(axis=1),
            "max": flat.max(axis=1),
        }
    )
    # A flat image has zero variation in it, so std == 0 marks a broken frame.
    # Storing the verdict as a column keeps the rule in one readable place.
    df["is_flat"] = df["std"] == 0
    return df


def normalise(images):
    """Min-max scale to [0, 1] and z-score standardise, both fully vectorised."""
    if not isinstance(images, np.ndarray):
        raise TypeError("expected a NumPy ndarray")

    x = images.astype(np.float32)

    lo, hi = x.min(), x.max()
    span = hi - lo or 1.0  # guard against a batch where every pixel is identical
    minmax = (x - lo) / span

    mean, std = x.mean(), x.std()
    zscore = (x - mean) / (std or 1.0)
    return minmax, zscore, float(mean), float(std)


if __name__ == "__main__":
    raw = generate_dummy_images()
    print(f"raw batch: {raw.shape}  (batch x height x width), dtype={raw.dtype}")

    print("\n=== Pandas: per-image statistics ===")
    stats = describe_batch(raw)
    print(stats.round(2).to_string(index=False))

    print("\n=== Pandas: average brightness per camera ===")
    print(stats.groupby("camera")["mean"].mean().round(2).to_string())

    bad = stats.loc[stats["is_flat"], "image_id"].to_numpy()
    print(f"\nflat (broken) images found: {bad.tolist()}")

    keep = stats.loc[~stats["is_flat"], "image_id"].to_numpy()
    clean = raw[keep]  # NumPy fancy indexing: select rows by an array of positions
    print(f"kept {len(clean)} of {len(raw)} images -> {clean.shape}")

    print("\n=== NumPy: vectorised normalisation ===")
    minmax, zscore, mean, std = normalise(clean)
    print(f"batch mean = {mean:.4f} | batch std = {std:.4f}")
    print(f"after min-max: min = {minmax.min():.4f}, max = {minmax.max():.4f}")
    print("  (expected exactly 0 and 1)")
    print(f"after z-score: mean = {zscore.mean():.4f}, std = {zscore.std():.4f}")
    print("  (expected ~0 and ~1)")

Chạy nó ra kết quả như sau:

Terminal
raw batch: (12, 28, 28)  (batch x height x width), dtype=uint8

=== Pandas: per-image statistics ===
 image_id camera   mean   std  min  max  is_flat
        0  cam-a  39.05 17.63    0   92    False
        1  cam-b  53.98 18.26    0  112    False
        2  cam-a  68.30 18.04   14  122    False
        3  cam-b   0.00  0.00    0    0     True
        4  cam-a  99.43 17.88   50  155    False
        5  cam-b 114.46 17.77   58  177    False
        6  cam-a 129.40 17.90   74  188    False
        7  cam-b 145.08 18.69   65  202    False
        8  cam-a 255.00  0.00  255  255     True
        9  cam-b 173.74 17.43  120  234    False
       10  cam-a 189.84 17.58  126  255    False
       11  cam-b 203.57 18.80  146  255    False

=== Pandas: average brightness per camera ===
camera
cam-a    130.17
cam-b    115.14

flat (broken) images found: [3, 8]
kept 10 of 12 images -> (10, 28, 28)

=== NumPy: vectorised normalisation ===
batch mean = 121.6832 | batch std = 57.1982
after min-max: min = 0.0000, max = 1.0000
  (expected exactly 0 and 1)
after z-score: mean = -0.0000, std = 1.0000
  (expected ~0 and ~1)

Đọc kết quả này theo bốn bước, đúng thứ tự chương trình chạy. Một: cột mean tăng dần từ 39 lên 203 — đó là các mức sáng khác nhau ta cố tình tạo ra, và nó chứng minh bảng Pandas thật sự nói được điều gì đó về dữ liệu. Hai: hai ảnh số 3 và số 8 có std = 0. Độ lệch chuẩn bằng 0 nghĩa là mọi điểm ảnh y hệt nhau, tức là một khung hình phẳng không chứa thông tin gì — đó là cách nhận ra ảnh lỗi mà không cần xem ảnh. Ba: hai dòng đó bị loại, còn 10 trên 12 ảnh. Bốn: sau chuẩn hóa, min-max cho đúng 0 và 1, z-score cho trung bình 0 và độ lệch chuẩn 1 — đúng định nghĩa, nên đây vừa là kết quả vừa là phép tự kiểm tra.

Con số -0.0000 ở dòng cuối không phải lỗi: đó là số 0 âm của số thực, sinh ra khi tổng các sai số làm tròn lệch về phía âm một lượng cực nhỏ. Nó bằng 0.

Một chi tiết đáng để ý trong generate_dummy_images: có một vòng lặp for, và nó không vi phạm điều gì. Vòng lặp đó chạy 12 lần — một lần mỗi ảnh — chứ không phải 9.408 lần một lần mỗi điểm ảnh. Đây là ranh giới thực tế của quy tắc "đừng dùng vòng lặp": lặp vài chục lần ở tầng Python thì không sao, lặp hàng triệu lần mới là vấn đề. Mọi phép tính trên điểm ảnh trong file này đều được vector hóa.

Cách chạy dự án này trên máy bạn

  1. Cài thư viện nếu chưa có: pip install numpy pandas trong môi trường ảo.
  2. Tải image_normalize.py ở cuối bài, hoặc gõ lại đoạn code trên.
  3. Chạy: python3 image_normalize.py.
  4. Bạn sẽ ra đúng những con số ở trên, vì rng = np.random.default_rng(42) cố định hạt giống ngẫu nhiên. Đổi số 42 thành số khác thì dữ liệu đổi theo nhưng cấu trúc kết quả vẫn vậy — hai ảnh phẳng vẫn bị phát hiện. Thử luôn: xóa dòng images[3] = 0 rồi chạy lại, xem danh sách ảnh lỗi rút còn một phần tử.
💡 Khái niệm Chuẩn hóa Min-Max và Z-Score
Trong tiền xử lý dữ liệu cho AI:
  • Min-Max Scaling đưa mọi điểm ảnh về khoảng cố định $[0, 1]$. Kỹ thuật này rất phù hợp cho dữ liệu ảnh vì giá trị pixel có biên giới hạn rõ ràng ($0$ đến $255$).
  • Z-Score Normalization đưa dữ liệu về phân phối chuẩn có giá trị trung bình bằng $0$ và độ lệch chuẩn bằng $1$. Kỹ thuật này giúp thuật toán huấn luyện hội tụ nhanh hơn bằng cách giảm thiểu sự mất cân bằng quy mô giữa các thuộc tính đầu vào.

Tóm tắt bài học & Cầu nối kiến thức

🔑 Bài học đạt được:
  • Đạt được: biết vì sao vòng lặp Python chậm — con trỏ rải rác, kiểm kiểu ở mỗi bước — và vì sao mảng NumPy liền khối lại nhanh.
  • Đạt được: vector hóa, và hiểu rằng nó chuyển vòng lặp xuống tầng C chứ không xóa vòng lặp; kèm cạm bẫy dùng NumPy sai cách còn chậm hơn không dùng.
  • Đạt được: ba quy tắc Broadcasting và lý do vector phẳng (N,) khớp theo cột chứ không theo dòng.
  • Đạt được: DataFrame và Series, phân biệt [] / .loc / .iloc, xử lý ô trống, groupby, và cạm bẫy gán qua hai bước.
  • Đạt được: biết dùng thư viện nào cho việc gì, và điểm nối giữa chúng là .to_numpy().

Cầu nối bài tiếp theo: NumPy rất mạnh trên CPU, nhưng để huấn luyện các mạng nơ-ron sâu phức tạp trên chip đồ họa GPU, chúng ta cần chuyển giao diện sang thư viện chuyên dụng PyTorch và cơ chế tính toán đạo hàm tự động Autograd ở Bài số 4.

Tải file code thực hành minh họa bài học

File Python image_normalize.py — quy trình tiền xử lý hoàn chỉnh: Pandas lập bảng thống kê từng ảnh và loại ảnh lỗi, NumPy chuẩn hóa vector hóa phần còn lại (chạy python image_normalize.py, cần pip install numpy pandas):

Tải về image_normalize.py

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 2: Đại số Tuyến tính & Đạo hàm qua dòng lệnh Bài 4: PyTorch Cơ Bản: Tensor & Autograd chuyên sâu Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận