Ở các bài trước ta tự viết gradient descent bằng NumPy: đạo hàm tính tay, cập nhật tay. Cách đó dạy được bản chất, nhưng sẽ không sống nổi khi mô hình có hàng nghìn tham số và nhiều lớp. PyTorch giải quyết đúng chỗ đó bằng ba thứ: tensor (mảng nhiều chiều chạy được trên CPU và GPU), autograd (tự động tính đạo hàm) và torch.nn (các khối xây mô hình). Bài này đi từ tensor PyTorch đến autograd, rồi vòng lặp huấn luyện hoàn chỉnh với nn.Module, DataLoader và Adam, mỗi bước đều có output thật.

Bài nối tiếp Xây hệ gợi ý sản phẩm bằng Python: content-based và collaborative filtering, nơi ta tự cài ALS bằng NumPy; Hồi quy tuyến tính và gradient descent: tự cài đặt bằng NumPy là nền trực tiếp của phần vòng lặp; còn tensor chính là mảng NumPy được nâng cấp, xem lại NumPy cho machine learning: vector hoá và broadcasting. Môi trường: máy Mac chip Apple Silicon, 16 GB RAM, Python 3 và bản PyTorch ổn định mới nhất lúc chạy; mọi API dùng trong bài đã có từ PyTorch 2.0 (ra mắt tháng 3/2023) hoặc sớm hơn. Dữ liệu hoàn toàn giả lập.

Tensor trong PyTorch: giống NumPy nhưng có thêm gì

Theo hướng dẫn Tensors của PyTorch, tensor là cấu trúc dữ liệu cốt lõi, giống mảng NumPy nhưng chạy được trên GPU và các bộ tăng tốc khác. Ba thuộc tính cần nhớ là shape, dtype và device; tensor mới mặc định nằm trên CPU.

a = torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
b = torch.tensor([10.0, 20.0, 30.0])
print(a + b)       # broadcasting như NumPy
print(a @ a.T)     # nhân ma trận
MPS: True

[1. Tensor]
a.shape = (2, 3) | dtype = torch.float32 | device = cpu
int tensor dtype: torch.int64
broadcasting a + b =
 tensor([[11., 22., 33.],
        [14., 25., 36.]])
a @ a.T =
 tensor([[14., 32.],
        [32., 77.]])

Hai điểm khác NumPy đáng chú ý khi bắt đầu dùng PyTorch. Một, số thực mặc định là float32 (NumPy là float64) vì mạng nơ-ron không cần độ chính xác kép và float32 nhanh, tiết kiệm bộ nhớ hơn. Hai, dtype khắt khe: nhân tensor float với tensor số nguyên báo lỗi thay vì tự ép kiểu, ta sẽ thấy ở phần lỗi thường gặp.

Cầu nối giữa PyTorch và NumPy cũng đáng hiểu. Tài liệu nói tensor trên CPU và mảng NumPy có thể dùng chung bộ nhớ. Ta kiểm tra bằng cách tạo hai tensor từ cùng một mảng rồi sửa mảng gốc:

n = np.array([1.0, 2.0, 3.0])
t = torch.from_numpy(n)       # dùng chung bộ nhớ với n
t2 = torch.tensor(n)          # torch.tensor luôn sao chép
n[0] = 99.0
sau khi sửa n[0] = 99: from_numpy t = [99.0, 2.0, 3.0] | torch.tensor t2 = [1.0, 2.0, 3.0]

from_numpy thấy thay đổi vì không sao chép; torch.tensor sao chép nên không bị ảnh hưởng. Lỗi quen thuộc là sửa dữ liệu NumPy gốc mà quên rằng tensor đang trỏ cùng vùng nhớ. Tài liệu cũng khuyên tránh các phép in-place (tên có hậu tố gạch dưới như add_) vì chúng tiết kiệm bộ nhớ nhưng có thể gây rắc rối khi tính đạo hàm.

Autograd trong PyTorch: tự tính đạo hàm

Trong PyTorch, theo hướng dẫn Automatic Differentiation with torch.autograd, các tensor cần đạo hàm (như tham số mô hình) được đánh dấu requires_grad=True. Mỗi phép tính trên chúng được ghi lại thành một nút Function trong đồ thị tính toán; thuộc tính grad_fn của mỗi tensor trỏ về hàm lùi tạo ra nó. Gọi loss.backward() chạy ngược đồ thị và điền kết quả vào .grad của các tensor lá.

x = torch.tensor(3.0, requires_grad=True)
y = x ** 2 + 2 * x + 1        # y = (x+1)^2, dy/dx = 2x + 2
y.backward()
print(x.grad)                 # 8.0
[2. Autograd]
y = 16.0 | y.grad_fn = <AddBackward0 object at 0x10e8c7e80>
dy/dx tại x=3: 8.0 (giải tích: 2*3+2 = 8)

Kết quả 8,0 khớp công thức giải tích 2x + 2 tại x = 3. Điều quan trọng là tài liệu nói đồ thị này là DAG được dựng lại từ đầu sau mỗi lần gọi backward(); nhờ vậy mô hình có thể dùng if, vòng lặp và đổi hình dạng giữa các lần chạy. Đó là lý do PyTorch được gọi là "define-by-run": ta viết Python bình thường, đồ thị hình thành khi chạy.

Gradient cộng dồn trong PyTorch: lý do phải gọi zero_grad

Cùng tài liệu nhắc: các lần backward() liên tiếp cộng vào .grad chứ không ghi đè. Ta kiểm chứng bằng cách chạy backward hai lần trên cùng biến:

backward lần 2 mà không zero: x.grad = 16.0 (cộng dồn: 8 + 8)
sau zero_(): 0.0

Đạo hàm đúng vẫn là 8 nhưng .grad thành 16. Trong vòng lặp huấn luyện, nếu quên xoá, gradient của các batch cộng chồng lên nhau và bước cập nhật sai hướng, sai độ lớn. Ta đo hậu quả trên một hồi quy tuyến tính nhỏ (cùng dữ liệu, cùng learning rate 0,1, 30 bước SGD):

[4. Quên zero_grad]
có zero_grad  : loss sau 30 bước = 0.0116
thiếu zero_grad: loss sau 30 bước = 8.388

Cùng mô hình, cùng dữ liệu: loss 0,0116 so với 8,388, chỉ vì thiếu một dòng. Đây cũng là lỗi âm thầm, vì chương trình không báo gì, loss chỉ trông "lạ". Có hai cách ngăn gradient bị theo dõi khi không cần: torch.no_grad() tắt ghi đồ thị trong cả khối, còn detach() trả về tensor tách khỏi đồ thị.

trong no_grad: z.requires_grad = False
detach(): requires_grad = False

Kiểm tra autograd bằng đạo hàm số

Khi tự viết phép tính mới, cách kiểm tra đáng tin nhất là so gradient của autograd với sai phân trung tâm (f(w+e) - f(w-e)) / 2e. Ta thử với một hàm mất mát bình phương trên tích vô hướng:

w = torch.tensor([1.0, -2.0], requires_grad=True)
xx = torch.tensor([3.0, 4.0])
loss = ((w * xx).sum() - 5.0) ** 2
loss.backward()
grad bằng autograd : [-60.0, -80.0]
grad số (sai phân)  : [-60.005, -79.994]

Hai kết quả khớp tới sai số của sai phân hữu hạn, đúng như kỳ vọng: loss = (3w0 + 4w1 - 5)^2 có đạo hàm 2(3w0 + 4w1 - 5) nhân 3 và 4, tức -60 và -80 tại điểm này. PyTorch cung cấp sẵn torch.autograd.gradcheck làm đúng việc này cho hàm tự định nghĩa.

Vòng lặp huấn luyện viết tay với PyTorch

Ghép lại các mảnh của PyTorch: forward để có dự đoán, tính loss, backward() để có gradient, cập nhật tham số, xoá gradient. Ta làm lại hồi quy tuyến tính ba đặc trưng, nhưng gradient đến từ autograd thay vì công thức viết tay, và cập nhật nằm trong torch.no_grad() để việc trừ gradient không bị ghi vào đồ thị.

# X, y: 200 mẫu, 3 đặc trưng, sinh trong pytorch_basics.py (y = X @ [2, -1, 0.5] + 4 + nhiễu)
w = torch.zeros(3, requires_grad=True); b = torch.zeros(1, requires_grad=True)
lr = 0.1
for epoch in range(101):
    pred = X @ w + b
    loss = ((pred - y) ** 2).mean()
    loss.backward()
    with torch.no_grad():
        w -= lr * w.grad
        b -= lr * b.grad
    w.grad.zero_(); b.grad.zero_()
[3. Hồi quy tuyến tính, cập nhật thủ công]
epoch   0  loss = 21.42914
epoch  25  loss = 0.01250
epoch  50  loss = 0.01138
epoch  75  loss = 0.01138
epoch 100  loss = 0.01138
w học được: [1.994, -1.003, 0.495] | b: 4.0 | thật: [2.0, -1.0, 0.5], 4.0
nghiệm bình phương tối thiểu: [1.994, -1.003, 0.495, 4.0]

Dữ liệu sinh với trọng số thật (2, -1, 0,5) và hệ số chặn 4 cộng nhiễu 0,1. Mô hình học được (1,994, -1,003, 0,495; 4,0), trùng đến ba chữ số với nghiệm bình phương tối thiểu tính trực tiếp bằng torch.linalg.lstsq. Loss dừng ở 0,01138, xấp xỉ phương sai nhiễu 0,1 bình phương, tức là mức thấp nhất có thể. Kết quả cùng loại với bài gradient descent bằng NumPy, nhưng lần này ta không viết đạo hàm nào; PyTorch lo việc đó.

nn.Module, DataLoader và optimizer của PyTorch

Viết tay từng tham số chỉ ổn với mô hình nhỏ. Theo hướng dẫn Optimizing Model Parameters, mỗi lượt huấn luyện qua một batch gồm: forward (pred = model(X), loss = loss_fn(pred, y)), loss.backward(), optimizer.step(), rồi optimizer.zero_grad(). Tài liệu cũng nêu ba siêu tham số cơ bản: số epoch, batch size và learning rate; với model.train() và model.eval() đặt chế độ cho các lớp như dropout và batch normalization, còn torch.no_grad() bọc phần đánh giá để bỏ tính gradient.

Ta áp dụng cho bài toán phân loại nhị phân trên 4.000 khách hàng giả lập với 6 đặc trưng, nhãn được sinh từ một hàm phi tuyến (có số hạng tích hai đặc trưng). Chia 3.000 train và 1.000 validation. Mạng gồm một lớp ẩn 16 nơ-ron với ReLU:

# tl: DataLoader trên 3.000 mẫu train, tạo trong pytorch_basics.py
class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.body = nn.Sequential(nn.Linear(6, 16), nn.ReLU(), nn.Linear(16, 1))
    def forward(self, x):
        return self.body(x).squeeze(1)

model = Net()
loss_fn = nn.BCEWithLogitsLoss()
opt = torch.optim.Adam(model.parameters(), lr=1e-2)

for epoch in range(1, 21):
    model.train()
    for xb, yb in tl:                 # tl = DataLoader(batch_size=64, shuffle=True)
        opt.zero_grad()
        loss = loss_fn(model(xb), yb)
        loss.backward()
        opt.step()

Hai chi tiết thiết kế của PyTorch. Thứ nhất, lớp cuối không có sigmoid vì BCEWithLogitsLoss gộp sigmoid với cross-entropy theo cách ổn định số học hơn; dự đoán lớp dương khi logit lớn hơn 0. Thứ hai, DataLoader lo việc chia batch và xáo trộn, để vòng lặp chỉ còn bốn câu lệnh cốt lõi (zero_grad, tính loss từ forward, backward, step).

[5. Phân loại với nn.Module, DataLoader, Adam]
tỉ lệ nhãn dương: 0.563
số tham số: 129
baseline đoán lớp đa số trên validation: acc = 0.558
epoch  1 | val loss 0.4398 | val acc 0.794
epoch  5 | val loss 0.3975 | val acc 0.823
epoch 10 | val loss 0.4008 | val acc 0.814
epoch 20 | val loss 0.3989 | val acc 0.819
thời gian huấn luyện 20 epoch trên CPU: 0.31s

Mạng PyTorch 129 tham số đạt accuracy 0,819 trên validation so với baseline lớp đa số 0,558, mất khoảng 0,31 giây cho 20 epoch trên CPU. Quan sát quan trọng hơn con số: validation loss giảm từ 0,4398 xuống 0,3975 ở epoch 5 rồi đứng quanh 0,40; mô hình đã học xong phần tín hiệu mà kiến trúc nhỏ này bắt được, và huấn luyện thêm không giúp gì. Cách theo dõi validation loss như vậy sẽ là nền cho bài về ổn định huấn luyện ở phần sau của series.

Cuối cùng là lưu và nạp. Cách được khuyến nghị là lưu state_dict (từ điển tham số) chứ không lưu cả đối tượng mô hình, và nạp bằng torch.load(..., weights_only=True) để không thực thi mã tuỳ ý trong file pickle:

lưu và nạp state_dict, dự đoán khớp: True

PyTorch trên CPU hay MPS: khi nào GPU đáng dùng

Tensor PyTorch có thể chuyển sang thiết bị khác bằng .to(device); trên máy Mac dùng chip Apple Silicon, backend GPU tên là mps. Ta đo thời gian nhân hai ma trận vuông, trung bình 20 lần, sau khi khởi động 3 lần và đồng bộ trước khi bấm giờ (GPU chạy bất đồng bộ nên không đồng bộ sẽ đo sai):

[6. CPU và MPS (Mac chip Apple Silicon)]
matmul   64x64    CPU    0.002 ms | MPS    0.083 ms
matmul 1024x1024  CPU    1.523 ms | MPS    1.404 ms
matmul 4096x4096  CPU  124.344 ms | MPS   46.415 ms

Trong ba lần chạy liên tiếp (ghi đủ trong output.txt), ma trận 4096x4096 trên CPU mất 109 đến 124 ms, MPS 43 đến 46 ms; ma trận 1024x1024 chênh vừa phải. Với ma trận nhỏ 64x64, MPS lại chậm hơn CPU nhiều lần vì chi phí khởi chạy và giao tiếp lấn át phép tính. Bài học: GPU chỉ đáng dùng khi mỗi lần gọi có đủ việc. Mạng 129 tham số ở trên chạy bằng PyTorch trên CPU là hợp lý; chuyển lên MPS hay GPU NVIDIA chỉ có ý nghĩa khi mô hình và batch đủ lớn. Đây là số đo trên một máy, không phải kết luận chung cho mọi phần cứng, và đừng quên rằng việc sao chép tensor lớn giữa các thiết bị cũng tốn thời gian.

Khi nào không nên dùng PyTorch

PyTorch rất mạnh nhưng không phải lúc nào cũng là công cụ đúng, và bạn nên cân nhắc trước khi kéo nó vào dự án:

  • Dữ liệu dạng bảng, ít mẫu: như ở bài gradient boosting với XGBoost và LightGBM, cây tăng cường thường thắng mạng nơ-ron và đơn giản hơn nhiều. Mạng ở trên chỉ nên xem là ví dụ giáo khoa.
  • Mô hình tuyến tính, cây, phân cụm cổ điển: scikit-learn đã có sẵn, có Pipeline và cross-validation, như trong bài Machine learning với scikit-learn: Pipeline, cross-validation và tuning.
  • Cần giải thích được từng quyết định: mạng nơ-ron khó giải thích hơn; phải kèm công cụ như SHAP.
  • Môi trường chạy hạn chế: PyTorch là thư viện nặng (thư mục môi trường ảo cài torch và numpy chiếm 786 MB theo du -sh, ghi trong output.txt) khó đưa vào container nhỏ; khi phục vụ mô hình thường export sang định dạng gọn hơn.

Lỗi thường gặp với PyTorch

  • Quên zero_grad(): gradient cộng dồn, loss 8,388 thay vì 0,0116 như đã đo ở trên.
  • Lệch dtype: nhân float với số nguyên cho lỗi rõ ràng. Output thật: dot : expected both vectors to have same dtype, but found Float and Long. Nhãn phân loại cho BCEWithLogitsLoss phải là float, còn nhãn của CrossEntropyLoss là số nguyên (long).
  • Sửa in-place tham số lá: w.add_(1.0) trên tensor cần gradient báo a leaf Variable that requires grad is being used in an in-place operation. Muốn cập nhật tham số thì làm trong torch.no_grad(), như trong vòng lặp viết tay.
  • Quên model.eval() khi đánh giá: dropout và batch norm vẫn ở chế độ huấn luyện, kết quả đánh giá nhiễu và không tái lập.
  • Quên no_grad() khi suy luận: tốn bộ nhớ vì đồ thị vẫn được ghi.
  • Không đặt seed: kết quả đổi theo lần chạy; dùng torch.manual_seed. Ngay cả khi đặt seed, kết quả PyTorch có thể khác nhau giữa CPU và GPU.
  • Đo thời gian GPU không đồng bộ: không gọi torch.mps.synchronize() (hoặc tương đương trên CUDA) trước khi bấm giờ sẽ đo sai.

Checklist PyTorch cơ bản

  • Tensor PyTorch mặc định float32; kiểm tra shape, dtype, device khi gặp lỗi.
  • from_numpy dùng chung bộ nhớ, torch.tensor sao chép.
  • requires_grad=True cho tham số; backward() điền .grad; gradient cộng dồn.
  • Vòng lặp chuẩn: zero_grad(), forward, loss, backward(), step().
  • Đánh giá mô hình PyTorch dưới model.eval() và torch.no_grad(); theo dõi validation loss, không chỉ train loss.
  • Lưu state_dict, nạp với weights_only=True.
  • Kiểm chứng mô hình PyTorch bằng baseline và đạo hàm số; chỉ dùng GPU khi mỗi lần gọi đủ lớn.

Mã nguồn đầy đủ trong code/s7-20 (pytorch_basics.py, output.txt, requirements.txt, không ghim phiên bản), chạy bằng python pytorch_basics.py. Mọi số liệu về PyTorch trong bài là output thật từ máy Mac chip Apple Silicon, 16 GB RAM; thời gian đo thay đổi nhẹ giữa các lần chạy, các giá trị loss và accuracy cố định theo hạt giống; dữ liệu giả lập.

Bài tiếp theo

Mạng nơ-ron từ con số 0: forward, backpropagation và hàm kích hoạt. Autograd làm mọi thứ trong một dòng backward(); bài kế tiếp mở hộp đen đó bằng cách tự cài đặt mạng nơ-ron và backpropagation bằng NumPy, rồi đối chiếu với PyTorch.