PyTorch là thư viện dùng để xây và huấn luyện mạng neural trong khóa học. Dữ liệu trong PyTorch là tensor: mảng nhiều chiều giống mảng NumPy, nhưng chạy được trên GPU. Điểm mới quan trọng hơn là autograd: PyTorch ghi lại các phép tính đã làm để tự tính đạo hàm của loss theo từng trọng số, thay cho việc tính tay như ở bài 0.6. Bài này tính một gradient bằng tay trước, rồi kiểm rằng autograd cho cùng kết quả.
Từ mảng C++ tới tensor
Tensor là một khối số có shape, kiểu dữ liệu (dtype) và nơi lưu (device). Một số có shape (), một vector ba số có shape (3,), bảng hai hàng ba cột có shape (2, 3). Phép tính nào cũng đòi ba thứ này khớp nhau, giống kích thước mảng và kiểu số trong một lời giải CP. Tensor không tự biết chiều nào là học sinh, token hay đặc trưng; người viết code phải tự nhớ.
Trọng số và đặc trưng thường dùng float32; mã lớp hoặc token ID dùng int64, còn gọi là torch.long. Tensor số nguyên không thể đặt requires_grad=True, vì đạo hàm chỉ có nghĩa trên số thực. torch.tensor suy dtype từ dữ liệu, nên chỉ một dấu chấm thập phân cũng đổi kiểu của cả tensor. Ví dụ dưới dùng số nhỏ để tính tay được: một bảng hai mẫu, chiếu sang hai đặc trưng mới.
import torch
torch.set_num_threads(1)
torch.manual_seed(7)
X = torch.tensor([[1., 2., 3.], [4., 5., 6.]])
W = torch.tensor([[1., 0.], [0., 1.], [1., 1.]])
b = torch.tensor([0.5, -0.5])
Y = X @ W + b
print(tuple(X.shape), X.dtype, X.device)
print(Y.tolist())
print(torch.tensor([1, 2, 3]).dtype, torch.tensor([1., 2., 3.]).dtype)
(2, 3) torch.float32 cpu
[[4.5, 4.5], [10.5, 10.5]]
torch.int64 torch.float32
Dòng đầu cho cả shape, dtype và device. Dòng thứ hai gồm hai hàng vì ta vẫn có hai mẫu. Với hàng đầu, đầu ra thứ nhất là ; đầu ra thứ hai là . Bias (2,) được cộng vào từng hàng. @ gộp chiều đặc trưng, còn X * X chỉ bình phương từng ô. Dòng cuối cho thấy dtype đi theo dữ liệu: [1, 2, 3] thành int64, [1., 2., 3.] thành float32.
| Đại lượng | Ý nghĩa | Shape |
|---|---|---|
X |
mẫu, mỗi mẫu đặc trưng | (B, d) |
W |
đầu vào thành đầu ra | (d, h) |
b |
Bias cho từng đầu ra | (h,) |
X @ W + b |
Đặc trưng mới của từng mẫu | (B, h) |
Hỏi nhanh
X có shape (8, 3), W có shape (3, 5). X @ W có shape gì?
Nhân ma trận gộp mất chiều 3 ở giữa, giữ số hàng 8 của X và số cột 5 của W. Chiều batch không biến mất. (3, 3) là lấy nhầm hai chiều bị gộp. (5, 8) là shape của W.T @ X.T. (8, 3, 5) là kết quả của X[:, :, None] * W khi chưa cộng theo chiều 3.
Shape đúng phải đi cùng ý nghĩa đúng
Khi xử lý chuỗi, tensor thường là (B, L, d): batch, độ dài, đặc trưng. reshape chia lại cách nhìn một dãy phần tử; nó không đổi thứ tự logic của dãy. transpose hoặc permute mới hoán đổi các trục. Đây giống khác biệt giữa đánh lại chỉ số của mảng một chiều và chuyển vị thật một bảng hai chiều.
A = torch.arange(6).reshape(2, 3)
print(A.tolist())
print(A.reshape(3, 2).tolist())
print(A.transpose(0, 1).tolist())
print(A.unsqueeze(0).shape, A.sum(dim=1).shape)
[[0, 1, 2], [3, 4, 5]]
[[0, 1], [2, 3], [4, 5]]
[[0, 3], [1, 4], [2, 5]]
torch.Size([1, 2, 3]) torch.Size([2])
So hai bảng (3, 2): reshape gom hai phần tử liên tiếp thành một hàng; transpose lấy các cột cũ làm hàng mới. Shape bằng nhau không bảo đảm dữ liệu mang cùng nghĩa.
unsqueeze(0) thêm chiều batch kích thước 1; sum(dim=1) gộp ba cột của mỗi hàng. squeeze(0) chỉ xóa chiều 0 nếu nó bằng 1. Tránh dùng squeeze() không chỉ định chiều khi batch có thể bằng 1: chiều batch có thể mất ngoài ý muốn.
Vài quy ước gặp liên tục:
- Một batch ảnh màu có shape
(N, C, H, W): 32 ảnh RGB là(32, 3, 64, 64). Thư viện ảnh thường trả(H, W, 3), nên phảipermute(2, 0, 1)trước khi thêm chiều batch. - Gộp theo chiều nào thì chiều đó biến mất: logits
(32, 10)lấy nhãn bằngargmax(dim=1), còndim=0cho 10 số, mỗi số là ảnh có điểm cao nhất của một lớp. torch.catnối theo một chiều có sẵn: hai tensor(3, 4)thành(6, 4).torch.stacktạo chiều mới: thành(2, 3, 4).
Hỏi nhanh
Bảng A có shape (2, 3). Muốn đổi thành (3, 2) sao cho các cột cũ thành hàng mới, ta dùng gì?
reshape và view chỉ chia lại dãy phần tử theo đúng thứ tự cũ, nên với bảng arange(6), hàng đầu vẫn là [0, 1]. transpose đổi chỗ hai trục, nên hàng đầu là [0, 3], đúng là cột cũ. unsqueeze(0) chỉ thêm một chiều dài 1, ra (1, 2, 3).
Broadcasting cũng cần kiểm. Phép cộng (B, h) với (h,) có ý nghĩa bias; phép trừ dự đoán (B, 1) với nhãn (B,) lại tạo (B, B). Máy không hiểu ta định so từng mẫu. Đặt assert prediction.shape == target.shape trước loss hồi quy là một kiểm tra rẻ nhưng hữu ích.
Hỏi nhanh
Dự đoán pred = [[1.], [2.]], shape (2, 1), khớp đúng nhãn target = [1., 2.], shape (2,). Vậy mà ((pred - target) ** 2).mean() lại in ra 0.5. Vì sao?
Hiệu có shape (2, 2), bằng [[0, -1], [1, 0]]: mỗi dự đoán bị trừ cho mọi nhãn. Trung bình bình phương là , trong khi MSE đúng phải bằng 0. Số 1 và 2 được biểu diễn chính xác trong float32, và đổi kiểu hay cách chia cũng không tạo ra chiều thứ hai. Sửa bằng pred.squeeze(-1) kèm một dòng assert. Gặp hai shape này, F.mse_loss chỉ cảnh báo rồi vẫn trả về 0.5.
Autograd lưu đường đi của phép tính
Trong bài linear regression, ta tự viết gradient. Với mạng nhiều lớp, biểu thức dài hơn nhưng mỗi phép cục bộ vẫn nhỏ: nhân, cộng, bình phương, ReLU. Autograd ghép đạo hàm cục bộ bằng quy tắc hàm hợp. Nó tính đạo hàm của phép tính đã thực hiện, không tìm công thức tối ưu và không tự sửa trọng số. So với sai phân hữu hạn ở bài 2.1, autograd cho đạo hàm chính xác và chỉ cần một lượt ngược cho mọi tham số, thay vì mỗi tham số một lượt chạy.
Hình dung phép tính như một DAG trong CP. Forward tính các đỉnh theo thứ tự topo, lưu giá trị cần thiết. Backward đi ngược DAG, truyền độ nhạy của loss về các đầu vào. Nếu một biến góp mặt ở nhiều nhánh, các đóng góp gradient được cộng, giống cộng số đường đi từ các nhánh tới một đỉnh.
PyTorch dựng DAG ngay trong lúc forward chạy và dựng lại ở mỗi lần forward (đồ thị động). Vì vậy if hay vòng lặp Python trong forward không cản autograd: backward đi theo đúng nhánh đã thực sự chạy.
Mỗi phép lưu đúng thứ nó cần cho backward. Sigmoid lưu chính đầu ra vì đạo hàm là , nên sửa tại chỗ như y.add_(1) sau forward sẽ làm backward báo lỗi.
Thử trên một phép tính nhỏ. Đặt , , . Forward cho , . Nếu tăng một lượng rất nhỏ, tăng ba lần lượng đó. Còn nhạy với một lượng . Vì vậy
Gradient dương nói rằng tăng một chút sẽ làm loss tăng tại điểm đang xét. Muốn đi xuống, gradient descent dùng . Với , một bước cho ; đó là phép cập nhật riêng, không thuộc backward().
Với nhiều biến, gradient gom các đạo hàm riêng: tại có gradient .
w = torch.tensor(2., requires_grad=True)
u = 3 * w
J = (u - 1) ** 2
J.backward()
print(round(J.item(), 2), round(w.grad.item(), 2))
with torch.no_grad():
w -= 0.01 * w.grad
print(round(w.item(), 2))
25.0 30.0
1.7
Hai số ở dòng đầu khớp và đã tính tay; dòng sau kiểm phép cập nhật . Vài điều đọc từ đoạn code:
requires_grad=Trueyêu cầu PyTorch ghi lại các phép tính dùng tớiw.wlà tensor lá do ta tạo trực tiếp; gradient được cộng vàow.grad. Tensor trung gian nhưukhông giữ.grad, nhưng đạo hàm vẫn đi qua nó bình thường.Jlà một số (scalar) nênbackward()biết bắt đầu từ độ nhạy 1. Với đầu ra là vector, phải gộp thành một số trước, ví dụ lấy tổng hoặc trung bình.
2 câu hỏi nhanh dành cho tài khoản Pro
Mở khóa bằng tài khoản ProGradient được cộng dồn
PyTorch cộng gradient mới vào .grad. Cách này cho phép tích lũy nhiều microbatch, nhưng trong training loop bình thường ta phải xóa trước mỗi bước. Sau backward(), PyTorch giải phóng các giá trị trung gian của graph, nên mỗi lượt dưới đây có forward riêng:
v = torch.tensor(2., requires_grad=True)
((3 * v - 1) ** 2).backward()
print(v.grad.item())
((3 * v - 1) ** 2).backward()
print(v.grad.item())
v.grad = None
((3 * v - 1) ** 2).backward()
print(v.grad.item())
30.0
60.0
30.0
Dãy cho thấy gradient cộng dồn và phép xóa đã hoạt động. Trong training loop, việc xóa do optimizer.zero_grad(set_to_none=True) đảm nhận. Gọi backward() hai lần trên cùng một graph sẽ báo lỗi; đừng sửa bằng retain_graph=True: giữ graph tốn bộ nhớ, không xóa gradient cũ, và graph cũ vẫn mô tả trọng số cũ. Mỗi bước huấn luyện cần forward mới rồi backward mới.
Câu hỏi nhanh dành cho tài khoản Pro
Mở khóa bằng tài khoản ProDetach, no_grad và bản sao
Khi lưu dự đoán để tính metric, ta không muốn giữ cả đường tính toán. Có ba công cụ:
detach()tạo tensor không nối gradient về nguồn. Nó vẫn dùng chung vùng nhớ với tensor gốc.detach().clone()tạo bản sao độc lập, dùng khi muốn lưu lại giá trị lúc đó.with torch.no_grad():tạm ngừng ghi graph cho các phép trong khối, dùng khi suy luận hoặc cập nhật trọng số bằng tay.
no_grad() không tự chuyển model sang chế độ đánh giá eval() (bài 3.4 và 3.6); ngược lại, eval() cũng không tắt gradient. Lúc đánh giá nên dùng cả hai.
Ta kiểm ý nghĩa “cắt đường gradient” bằng biểu thức . Nếu giữ cả hai nhánh, đạo hàm tại là . Nếu tính rồi detach, hệ số 4 ấy được coi như hằng số:
v.grad = None
square = v ** 2
frozen_square = square.detach()
(frozen_square * v).backward()
print(v.grad.item(), frozen_square.requires_grad)
snapshot = square.detach().clone()
print(snapshot.item())
4.0 False
4.0
Gradient 4 là đúng với graph đã bị cắt, dù giá trị forward vẫn bằng 8. Vì vậy detach không phải cách sửa lỗi backward: nó đổi bài toán, và tham số nằm trước điểm cắt sẽ không học được. Chuyển tensor cần gradient sang NumPy cũng nên viết tensor.detach().cpu().numpy() khi chỉ phục vụ phân tích. Với một số đơn lẻ như loss, loss.item() trả về số Python; ghi log bằng số này không giữ graph.
Cập nhật tham số bằng tay cũng đặt trong no_grad(): with torch.no_grad(): w -= lr * w.grad; viết thẳng thì PyTorch báo lỗi sửa tại chỗ một tensor lá cần gradient. Ngược lại, chạy forward trong no_grad() rồi gọi loss.backward() sẽ báo loss không có grad_fn. Detach còn được dùng có chủ đích: mang trạng thái ẩn của RNN sang đoạn văn bản sau bằng hidden.detach() (bài 3.8).
Câu hỏi nhanh dành cho tài khoản Pro
Mở khóa bằng tài khoản ProThiết bị và nơi lưu dữ liệu
GPU thực hiện nhiều phép số song song, nhưng mọi tensor trong một phép tính phải cùng device:
- Với tensor,
.to(device)trả về tensor mới trên device đã chọn, nên phải gán lại:X = X.to(device). - Với model,
model.to(device)chuyển tham số của model tại chỗ. - Tensor tạo mới trong
forward, nhưtorch.ones(...), mặc định nằm trên CPU; viếtdevice=x.devicehoặctorch.ones_likeđể nó theo dữ liệu, đừng ghi cứng'cuda'.
Đoạn sau chọn CUDA khi có, nếu không thì dùng CPU; các dòng còn lại không phụ thuộc máy:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
X_device = X.to(device)
linear = torch.nn.Linear(3, 2).to(device)
with torch.no_grad():
result = linear(X_device)
print(str(device), tuple(result.shape))
print(result.detach().cpu().numpy().shape)
cpu (2, 2)
(2, 2)
Máy chạy bài không có CUDA nên dòng đầu in cpu; trên máy có CUDA, cùng đoạn code chọn cuda mà không phải sửa dòng nào. Dòng hai là đường thường dùng khi lấy kết quả ra phân tích: ngắt graph, chép về CPU, rồi mới sang NumPy.
GPU cũng không tự nhanh hơn: với dữ liệu rất nhỏ, thời gian chép dữ liệu và khởi động có thể lớn hơn phần tính. Bài 3.7 có cách đo thời gian mỗi bước trên máy CUDA.
Câu hỏi nhanh dành cho tài khoản Pro
Mở khóa bằng tài khoản ProKhi đi thi
- In shape ở ranh giới dataset, model và loss; đặt
assert pred.shape == target.shape. - Nhãn lớp cho cross-entropy là
long; nhãn hồi quy và nhãn BCE làfloat. - Đặt
deviceở một chỗ và chuyển bằng.to(device), đừng gõ cứng.cuda().
Tóm tắt
- Tensor là mảng kèm shape, dtype và device.
reshapechia lại dãy phần tử,transposeđổi trục; broadcasting có thể lặng lẽ tạo shape sai. - Autograd ghi DAG của forward, rồi cộng gradient theo các đường ở backward.
backward()chưa cập nhật trọng số;.gradcộng dồn nên phải xóa trước mỗi bước.detach()cắt graph,clone()tạo bản sao,.item()lấy ra số Python,no_grad()ngừng ghi graph cho một khối.
Kiểm tra cuối bài
Câu 1
Batch embedding E có shape (4, 6, 8): 4 câu, mỗi câu 6 token, mỗi token 8 đặc trưng. Muốn mỗi câu có một vector bằng trung bình các token, ta gọi E.mean(dim=?), và nhận được shape gì?
Chiều token là chiều 1. Lấy trung bình theo chiều ấy thì còn (4, 8), mỗi câu một vector 8 số. dim=0 trộn 4 câu khác nhau. dim=2 trộn các đặc trưng của từng token. (4, 1, 8) chỉ xuất hiện khi thêm keepdim=True.
Câu 2
a = torch.tensor([1, 2, 3]) và b = torch.Tensor([1, 2, 3]). Hai tensor này có dtype gì?
torch.tensor, viết thường, suy dtype từ dữ liệu: toàn số nguyên thì thành int64. torch.Tensor, viết hoa, là hàm tạo của lớp, luôn dùng dtype mặc định float32. Vì vậy gọi a.mean() hay đặt requires_grad=True cho a đều báo lỗi. Khi cần số thực, hãy viết [1., 2., 3.] hoặc thêm dtype=torch.float32.
Câu 3
x = torch.tensor([1., -2., 3.], requires_grad=True), loss = (x ** 2).mean(), rồi loss.backward(). x.grad bằng bao nhiêu, làm tròn hai chữ số?
, nên . [2, -4, 6] là gradient của tổng bình phương: đổi từ tổng sang trung bình thì gradient bị chia cho số phần tử. [0.33, 1.33, 3.00] là , nhầm giá trị với đạo hàm. 4.67 là chính loss, còn .grad thì luôn cùng shape với x.
7 câu hỏi nhanh dành cho tài khoản Pro
Mở khóa bằng tài khoản ProĐọc thêm
- PyTorch - Automatic Differentiation: đọc về graph động, gradient cộng dồn và cách tắt theo dõi.
- D2L - Forward/Backward Propagation: xem đồ thị tính toán như cầu nối từ đạo hàm một biến sang mạng.
- PyTorch 2.9 - Tensor: tra shape, dtype, device và các thao tác tensor trước khi ghép model lớn.