Chương 3·Bài 3.1

Tensor và autograd

Tensor chứa dữ liệu theo các trục, và mỗi trục mang một ý nghĩa: trục mẫu, trục đặc trưng, và các trục khác. Autograd ghi lại cách loss phụ thuộc vào tham số, để tính đạo hàm cho ta. Trước khi dựng mạng nhiều lớp, các ví dụ nhỏ dưới đây kiểm từng mắt xích: shape, đạo hàm, và một bước cập nhật.

Ví dụ 1: Hai mẫu, ba đặc trưng và một lỗi broadcasting

Mỗi hàng là một mẫu có ba đặc trưng, đã đưa về thang nhỏ. Ta nhân với một ma trận để tạo ra hai đặc trưng mới. Sau phép nhân, số mẫu phải giữ nguyên, và bias phải được cộng vào đúng từng hàng.

Python
import torch

torch.set_num_threads(1)
X = torch.tensor([[1., 2., 3.], [4., 5., 6.]])  # (mẫu, đặc trưng)
W = torch.tensor([[1., 0.], [0., 2.], [1., -1.]])
b = torch.tensor([0.5, -0.5])
Y = X @ W + b
assert Y.shape == (2, 2)
print("shape X, W, Y:", tuple(X.shape), tuple(W.shape), tuple(Y.shape))
print("đặc trưng mới:", Y.tolist())
print("tổng theo mẫu:", X.sum(dim=1).tolist())

Kiểm hàng đầu bằng tay: đặc trưng mới thứ nhất là 1+3+0.5=4.51+3+0.5=4.5, đặc trưng thứ hai là 2⋅2−3−0.5=0.52\cdot2-3-0.5=0.5. Phép nhân ma trận đã gộp trục 3 đặc trưng cũ thành 2 đặc trưng mới, còn trục mẫu vẫn giữ đủ hai hàng. Dòng cuối cộng theo dim=1, tức gộp trục đặc trưng, nên mỗi mẫu còn lại một con số.

Phép toán chạy được về mặt shape chưa chắc đã đúng về ý nghĩa. Đoạn sau cố tình để dự đoán có shape (2, 1), còn target có shape (2,).

Python
prediction = torch.tensor([[1.], [3.]])
target = torch.tensor([1., 2.])
wrong_residual = prediction - target
print("hiệu bị broadcast:", wrong_residual.tolist(), tuple(wrong_residual.shape))

target_column = target.unsqueeze(1)
assert prediction.shape == target_column.shape
print("hiệu từng mẫu:", (prediction - target_column).tolist())
print("MSE sai / đúng:", wrong_residual.square().mean().item(),
      (prediction - target_column).square().mean().item())

MSE đúng là (02+12)/2=0.5(0^2+1^2)/2=0.5. Bản sai tạo ra bảng (2, 2), so mỗi dự đoán với cả hai target, nên con số 1.5 đo một thứ hoàn toàn khác. PyTorch không báo lỗi. Cách sửa là unsqueeze(1), thêm cho target một trục đầu ra để nó khớp shape với dự đoán. Chỉ dùng reshape hay transpose khi đã rõ mỗi trục nghĩa là gì.

Ví dụ 2: Backward tính gì, và trọng số đổi lúc nào?

Lấy w=2w=2, đặt u=3wu=3w và J=(u−1)2J=(u-1)^2. Tính tay trước: u=6u=6, J=25J=25, và dJ/dw=2(6−1)⋅3=30dJ/dw=2(6-1)\cdot3=30. Với learning rate 0.01, một bước cập nhật đưa ww về 2−0.3=1.72-0.3=1.7. Giờ để PyTorch làm lại và so.

Python
import torch

torch.set_num_threads(1)
w = torch.tensor(2., requires_grad=True)
u = 3 * w
loss = (u - 1).square()
loss.backward()
print("u, loss, gradient:", u.item(), loss.item(), w.grad.item())
print("w sau backward:", w.item())
assert w.item() == 2. and w.grad.item() == 30.

# Bước cập nhật là một việc riêng, không được ghi vào graph của lần forward vừa rồi.
with torch.no_grad():
    w -= 0.01 * w.grad
print("w sau cập nhật:", round(w.item(), 4))
print("loss mới:", round((3 * w - 1).square().item(), 4))

Sau backward, w vẫn bằng 2. Backward chỉ tính gradient và ghi vào w.grad, chứ không đụng tới w. Trọng số chỉ đổi khi ta tự cập nhật. Sau bước ấy, loss giảm từ 25 xuống 16.81, đúng như tính tay.

Có một điều dễ gây bất ngờ: gradient mới được cộng dồn vào .grad, chứ không ghi đè. Đoạn sau chạy forward và backward hai lần mà không cập nhật tham số, để tách riêng chuyện cộng dồn.

Python
v = torch.tensor(2., requires_grad=True)
gradients = []
for _ in range(2):
    (3 * v - 1).square().backward()  # Graph mới, nhưng .grad cũ vẫn còn nguyên.
    gradients.append(v.grad.item())
v.grad = None
(3 * v - 1).square().backward()
gradients.append(v.grad.item())
print("gradient qua ba lượt:", gradients)
assert gradients == [30., 60., 30.]

Lần thứ hai, .grad là 60, tức 30 của lần trước cộng thêm 30 của lần này. Xóa .grad đi rồi chạy lại thì mới về 30. Trong vòng lặp train, ta gọi optimizer.zero_grad(set_to_none=True) trước mỗi lần forward để làm đúng việc xóa này. Quên dòng ấy, mỗi bước sẽ đi theo tổng gradient của mọi bước trước.

Ví dụ 3: Cắt graph giữ nguyên giá trị nhưng đổi đạo hàm

Có hai cách tính v3v^3, và cả hai đều cho 8 khi v=2v=2. Ở cách thứ hai, nhánh v2v^2 bị detach, tức bị cắt khỏi graph. Giá trị 4 trên nhánh ấy được giữ nguyên, nhưng backward coi nó là hằng số.

Python
import torch

torch.set_num_threads(1)
v = torch.tensor(2., requires_grad=True)
full = v.square() * v
full.backward()
full_gradient = v.grad.item()

v.grad = None
cut = v.square().detach() * v
cut.backward()
print("giá trị full / cut:", full.item(), cut.item())
print("gradient full / cut:", full_gradient, v.grad.item())
assert full_gradient == 12. and v.grad.item() == 4.

source = torch.tensor([2., 3.], requires_grad=True)
alias = source.detach()
snapshot = source.detach().clone()  # Bản sao riêng, không đổi khi source bị sửa.
with torch.no_grad():
    source[0] = 9.
print("source, detach, snapshot:", source.tolist(), alias.tolist(), snapshot.tolist())

Bản đầy đủ có đạo hàm 3v2=123v^2=12. Bản bị cắt chỉ còn coi 4⋅v4\cdot v là hàm của vv, nên đạo hàm là 4. Hai bản cho cùng giá trị nhưng là hai graph khác nhau.

Dòng cuối cho thấy một điều nữa. detach không sao chép dữ liệu. Nó vẫn dùng chung bộ nhớ với tensor gốc, nên khi gốc đổi thành 9, nó cũng thấy 9. Muốn giữ một bản không đổi, ví dụ để lưu checkpoint, phải thêm .clone(). Còn chỉ để ghi lại giá trị loss hay metric thì detach là đủ.

Mục lục