PyTorch cơ bản: tensor, autograd và vòng lặp huấn luyện 4 bước
Tensor trên CPU/GPU, chuyển ảnh OpenCV sang tensor, autograd và zero_grad, nn.Module, vòng lặp 4 bước huấn luyện CNN OK/NG, lưu nạp state_dict an toàn.
Học xong bài này, bạn sẽ
- Làm việc với tensor trên CPU, GPU
- Dùng autograd tính gradient
- Viết vòng lặp huấn luyện 4 bước
Nên học trước: Mạng nơ-ron và CNN hoạt động thế nào
Nội dung bài viết
- 1. Cài đặt và kiểm tra
- 2. Tensor: mảng nhiều chiều có dtype, shape, device
- 3. Từ NumPy và OpenCV sang tensor
- 4. CPU và GPU: viết code chạy được ở cả hai
- 5. Autograd: PyTorch tự tính gradient
- 6. Vòng lặp huấn luyện 4 bước
- 7. Định nghĩa mạng bằng nn.Module
- 8. Huấn luyện CNN nhỏ phân loại OK/NG
- 9. Lưu và nạp mô hình: state_dict
- 10. Sai lầm thường gặp
- 11. Bài tập
- Tóm tắt
Ở bài "Gradient descent từ đầu với NumPy", bạn tự đạo hàm và tự cập nhật hai tham số. Ở bài "Mạng nơ-ron và CNN hoạt động thế nào", bạn thấy một CNN nhỏ đã có hàng chục nghìn tham số: tự đạo hàm bằng tay là không thể. PyTorch giải quyết đúng việc đó. Nó là framework Deep Learning phổ biến nhất hiện nay; Ultralytics YOLO, Anomalib và phần lớn mô hình trên Hugging Face đều chạy trên PyTorch. Bài này dạy ba khái niệm nền tảng mà mọi dự án PyTorch đều dùng: tensor (mảng số chạy được trên GPU), autograd (tự tính gradient) và vòng lặp huấn luyện 4 bước, kèm cách lưu và nạp mô hình an toàn. Các bài tiếp theo đi sâu vào loss và optimizer, backpropagation, BatchNorm, kiến trúc CNN; sau đó bài "Dataset, DataLoader và augmentation" thay dữ liệu tạo sẵn trong bộ nhớ bằng thư mục ảnh thật.
permute HWC→CHW); viết code chạy được cả trên GPU lẫn CPU; dùng autograd (requires_grad, backward, zero_grad, no_grad) và hiểu vì sao gradient bị cộng dồn; định nghĩa mạng bằng nn.Module; viết vòng lặp huấn luyện 4 bước và huấn luyện một CNN nhỏ phân loại OK/NG; lưu và nạp state_dict với torch.load(weights_only=True). Bài viết chạy với PyTorch 2.14 (CPU).1. Cài đặt và kiểm tra
# Bản CPU (chạy được trên mọi máy, đủ cho toàn bộ bài này):
pip install torch torchvision
# Máy có GPU NVIDIA: vào trang pytorch.org, mục "Get Started", chọn hệ điều hành và phiên bản CUDA
# để lấy đúng lệnh pip (có tham số --index-url). Driver NVIDIA phải đủ mới cho phiên bản CUDA đó.
import torch
print("torch", torch.__version__)
print("CUDA (GPU NVIDIA) dùng được:", torch.cuda.is_available())
if torch.cuda.is_available():
print("GPU:", torch.cuda.get_device_name(0))
torch 2.14.1+cu130
CUDA (GPU NVIDIA) dùng được: False
Máy chạy bài viết không có GPU, nên dòng thứ hai là False; hậu tố +cu130 chỉ cho biết bản torch này được biên dịch kèm CUDA 13.0, không có nghĩa là máy có GPU. Trên máy có GPU NVIDIA và driver phù hợp, bạn sẽ thấy True và tên GPU. Mọi đoạn code trong bài đều tự chọn thiết bị nên chạy được ở cả hai trường hợp.
2. Tensor: mảng nhiều chiều có dtype, shape, device
Tensor giống mảng NumPy, thêm hai năng lực: chạy được trên GPU và ghi nhớ phép tính để tự đạo hàm. Ba thuộc tính phải luôn để ý: shape (kích thước các trục), dtype (kiểu số) và device (nằm ở CPU hay GPU).
import torch
a = torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) # từ list Python
print(a.shape, a.dtype, a.device)
print("zeros:", torch.zeros(2, 3).dtype, "| arange:", torch.arange(5), torch.arange(5).dtype)
torch.manual_seed(0)
b = torch.rand(3, 2) # số ngẫu nhiên đều trong [0, 1)
print("a @ b =\n", a @ b) # nhân ma trận (2x3)·(3x2) = (2x2)
print("a * 10 =", (a * 10).tolist()) # nhân từng phần tử
print("tổng theo cột (dim=0):", a.sum(dim=0), "| theo hàng (dim=1):", a.sum(dim=1))
print("broadcasting a - a.mean(dim=0):\n", a - a.mean(dim=0))
print("reshape (3, 2):", a.reshape(3, 2).tolist(), "| hàng 1:", a[1], "| cột 2:", a[:, 2])
u8 = torch.tensor([250, 10], dtype=torch.uint8)
print("uint8: 250 + 10 =", (u8 + 10).tolist(), " ← tràn số, giống NumPy")
torch.Size([2, 3]) torch.float32 cpu
zeros: torch.float32 | arange: tensor([0, 1, 2, 3, 4]) torch.int64
a @ b =
tensor([[1.5955, 2.9345],
[4.2720, 7.5375]])
a * 10 = [[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]]
tổng theo cột (dim=0): tensor([5., 7., 9.]) | theo hàng (dim=1): tensor([ 6., 15.])
broadcasting a - a.mean(dim=0):
tensor([[-1.5000, -1.5000, -1.5000],
[ 1.5000, 1.5000, 1.5000]])
reshape (3, 2): [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]] | hàng 1: tensor([4., 5., 6.]) | cột 2: tensor([3., 6.])
uint8: 250 + 10 = [4, 20] ← tràn số, giống NumPy
| dtype | Dùng cho | Ghi chú |
|---|---|---|
torch.float32 | Ảnh đầu vào, trọng số, mọi phép tính của mạng | Mặc định khi tạo từ số thực trong Python. Mạng nơ-ron gần như luôn dùng float32 (hoặc float16 khi tối ưu) |
torch.int64 (long) | Nhãn lớp cho CrossEntropyLoss, chỉ số | Mặc định khi tạo từ số nguyên |
torch.uint8 | Ảnh thô 0–255 vừa đọc lên | Phải đổi sang float trước khi đưa vào mạng; cộng trừ bị tràn số |
torch.float64 | Hiếm khi dùng trong mạng | Mảng NumPy mặc định là float64, chuyển sang tensor vẫn giữ float64: nguồn lỗi phổ biến (mục 10) |
3. Từ NumPy và OpenCV sang tensor
Ảnh bạn đọc bằng OpenCV là mảng NumPy uint8, thứ tự trục (H, W, C), kênh BGR. Lớp tích chập của PyTorch nhận (N, C, H, W): số ảnh, kênh, cao, rộng. Chuyển đổi gồm đổi kiểu, đổi thứ tự trục bằng permute và thêm trục batch bằng unsqueeze:
import cv2
import numpy as np
import torch
anh = np.zeros((48, 64, 3), np.uint8) # ảnh OpenCV: cao 48, rộng 64, 3 kênh BGR
anh[:, :, 2] = 200 # kênh đỏ (chỉ số 2 trong BGR)
print("OpenCV:", anh.shape, anh.dtype)
rgb = cv2.cvtColor(anh, cv2.COLOR_BGR2RGB)
t = torch.from_numpy(rgb) # dùng CHUNG bộ nhớ với mảng NumPy
t = t.permute(2, 0, 1) # (H, W, C) → (C, H, W)
t = t.float() / 255.0 # uint8 → float32 trong [0, 1]
x = t.unsqueeze(0) # thêm trục batch → (1, C, H, W)
print("tensor:", tuple(x.shape), x.dtype, "| kênh R trung bình:", round(x[0, 0].mean().item(), 3))
# from_numpy chia sẻ bộ nhớ, torch.tensor thì sao chép
m = np.zeros(3, np.float32)
chia_se, sao_chep = torch.from_numpy(m), torch.tensor(m)
m[0] = 7
print("from_numpy thấy thay đổi:", chia_se.tolist(), "| torch.tensor:", sao_chep.tolist())
print("NumPy float64 → tensor:", torch.from_numpy(np.zeros(2)).dtype)
# chiều ngược lại: tensor (1, C, H, W) → ảnh OpenCV để lưu hoặc hiển thị
ve = (x[0].permute(1, 2, 0) * 255).round().to(torch.uint8).numpy()
print("về lại OpenCV:", ve.shape, ve.dtype, "| khớp ảnh gốc:", np.array_equal(cv2.cvtColor(ve, cv2.COLOR_RGB2BGR), anh))
OpenCV: (48, 64, 3) uint8
tensor: (1, 3, 48, 64) torch.float32 | kênh R trung bình: 0.784
from_numpy thấy thay đổi: [7.0, 0.0, 0.0] | torch.tensor: [0.0, 0.0, 0.0]
NumPy float64 → tensor: torch.float64
về lại OpenCV: (48, 64, 3) uint8 | khớp ảnh gốc: True
Ba điểm cần nhớ: permute chỉ đổi cách đọc bộ nhớ, không sao chép (một số hàm đòi bộ nhớ liền mạch sẽ cần thêm .contiguous()); from_numpy chia sẻ bộ nhớ nên sửa mảng là sửa tensor; mảng NumPy float64 cần .float() trước khi đưa vào mạng. Bài "Dataset, DataLoader và augmentation" đóng gói các bước này thành lớp Dataset, kèm chuẩn hóa mean/std.
4. CPU và GPU: viết code chạy được ở cả hai
Quy tắc của PyTorch: mọi tensor tham gia một phép tính phải nằm trên cùng thiết bị. Mô hình và dữ liệu đều phải được chuyển bằng .to(device). Mẫu code chuẩn chọn GPU nếu có, CPU nếu không:
import torch
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("thiết bị:", device)
x = torch.randn(64, 3, 32, 32) # 64 ảnh RGB 32x32, tạo trên CPU
w = torch.randn(10, 3 * 32 * 32)
x, w = x.to(device), w.to(device) # chuyển sang thiết bị đã chọn
y = x.flatten(1) @ w.T # tính trên GPU nếu có
print("kết quả:", tuple(y.shape), "nằm trên", y.device)
ket_qua = y.cpu().numpy() # muốn dùng NumPy/OpenCV: phải về CPU trước
print("về NumPy:", ket_qua.shape, ket_qua.dtype)
thiết bị: cpu
kết quả: (64, 10) nằm trên cpu
về NumPy: (64, 10) float32
Trên máy có GPU, dòng đầu in cuda và kết quả nằm trên cuda:0. Nếu quên chuyển một tensor, PyTorch báo lỗi dạng RuntimeError: Expected all tensors to be on the same device .... Gọi .numpy() trực tiếp trên tensor GPU cũng báo lỗi; phải .cpu() trước. Trên máy Mac Apple Silicon, thiết bị tương ứng là "mps" (kiểm tra bằng torch.backends.mps.is_available()).
5. Autograd: PyTorch tự tính gradient
Đánh dấu một tensor bằng requires_grad=True, PyTorch sẽ ghi lại mọi phép tính dùng tensor đó thành một đồ thị tính toán. Gọi .backward() trên kết quả cuối (một số vô hướng, thường là loss), PyTorch đi ngược đồ thị và điền gradient vào thuộc tính .grad của từng tham số. Ví dụ nhỏ nhất:
import torch
w = torch.tensor(2.0, requires_grad=True)
x = torch.tensor(3.0)
loss = (w * x - 10) ** 2 # (6 - 10)² = 16
print("loss:", loss.item(), "| grad_fn:", loss.grad_fn.__class__.__name__)
loss.backward() # tính dloss/dw
print("w.grad:", w.grad.item(), " (tay: 2·(w·x − 10)·x = 2·(6 − 10)·3 = −24)")
loss: 16.0 | grad_fn: PowBackward0
w.grad: -24.0 (tay: 2·(w·x − 10)·x = 2·(6 − 10)·3 = −24)
Áp dụng cho chính bài hiệu chỉnh pixel–mm của bài trước: so gradient autograd với công thức ∂L/∂w = 2·mean(e·x), ∂L/∂b = 2·mean(e) mà bạn đã tự đạo hàm.
import numpy as np
import torch
rng = np.random.default_rng(0)
x_np = np.sort(rng.uniform(150, 750, 40))
y_np = 0.0482 * x_np + 0.35 + rng.normal(0, 0.03, 40)
x = torch.tensor(x_np, dtype=torch.float32)
y = torch.tensor(y_np, dtype=torch.float32)
w = torch.tensor(0.03, requires_grad=True)
b = torch.tensor(0.1, requires_grad=True)
loss = torch.mean((w * x + b - y) ** 2)
loss.backward()
e = (w * x + b - y).detach() # detach: lấy giá trị, không ghi vào đồ thị
print(f"autograd : dL/dw = {w.grad.item():.2f}, dL/db = {b.grad.item():.4f}")
print(f"công thức: dL/dw = {(2 * (e * x).mean()).item():.2f}, dL/db = {(2 * e.mean()).item():.4f}")
autograd : dL/dw = -9538.79, dL/db = -17.6992
công thức: dL/dw = -9538.79, dL/db = -17.6992
Hai kết quả trùng nhau (sai khác ở chữ số cuối là do float32 so với float64 của bài trước). Điều quan trọng: bạn chỉ viết phép tính xuôi (forward); phần đạo hàm PyTorch lo, dù mô hình có hai tham số hay hai chục triệu.
Gradient bị cộng dồn: vì sao phải zero_grad
PyTorch cộng gradient mới vào .grad chứ không ghi đè. Thiết kế này có chủ ý (cho phép gộp gradient từ nhiều batch nhỏ), nhưng nếu quên xóa, mỗi bước cập nhật sẽ dùng tổng gradient của mọi bước trước:
import torch
w = torch.tensor(2.0, requires_grad=True)
for lan in range(3):
loss = (w * 3.0 - 10) ** 2
loss.backward()
print(f"lần {lan + 1}: w.grad = {w.grad.item()}") # -24, -48, -72: cộng dồn!
w.grad.zero_() # xóa gradient (optimizer.zero_grad() làm việc này)
loss = (w * 3.0 - 10) ** 2
loss.backward()
print("sau zero_():", w.grad.item())
with torch.no_grad(): # không ghi đồ thị: dùng khi cập nhật tham số và khi suy luận
z = w * 3.0
print("trong no_grad, z.requires_grad =", z.requires_grad)
lần 1: w.grad = -24.0
lần 2: w.grad = -48.0
lần 3: w.grad = -72.0
sau zero_(): -24.0
trong no_grad, z.requires_grad = False
| Công cụ | Tác dụng | Dùng khi |
|---|---|---|
requires_grad=True | Yêu cầu tính gradient cho tensor này | Tham số tự tạo. Tham số trong nn.Module đã bật sẵn |
loss.backward() | Tính gradient của loss theo mọi tham số, cộng vào .grad | Mỗi bước huấn luyện, một lần |
optimizer.zero_grad() | Xóa .grad trước khi tính gradient mới | Trước mỗi backward() |
torch.no_grad() | Tắt ghi đồ thị: nhanh hơn, ít bộ nhớ | Đánh giá, suy luận trên trạm, tự cập nhật tham số bằng tay |
.detach(), .item() | Lấy giá trị ra khỏi đồ thị (tensor, hoặc số Python) | Ghi log loss, tính chỉ số, vẽ đồ thị |
6. Vòng lặp huấn luyện 4 bước
Viết lại gradient descent của bài trước bằng PyTorch, đầu tiên với tham số tự tạo và cập nhật bằng tay để thấy autograd thay thế đúng phần nào:
import numpy as np
import torch
rng = np.random.default_rng(0)
x_np = np.sort(rng.uniform(150, 750, 40))
y_np = 0.0482 * x_np + 0.35 + rng.normal(0, 0.03, 40)
x = torch.tensor((x_np - x_np.mean()) / x_np.std(), dtype=torch.float32) # chuẩn hóa như bài trước
y = torch.tensor(y_np, dtype=torch.float32)
w = torch.zeros((), requires_grad=True)
b = torch.zeros((), requires_grad=True)
lr = 0.1
for buoc in range(60):
du_doan = w * x + b # 1. forward
loss = torch.mean((du_doan - y) ** 2) # 2. loss
loss.backward() # 3. backward: autograd thay cho công thức tự đạo hàm
with torch.no_grad(): # 4. cập nhật (không ghi đồ thị)
w -= lr * w.grad
b -= lr * b.grad
w.grad.zero_()
b.grad.zero_()
if buoc % 20 == 0:
print(f"bước {buoc:2d}: loss {loss.item():.5f}")
print(f"w_gốc = {w.item() / x_np.std():.5f} mm/px, b_gốc = {b.item() - w.item() * x_np.mean() / x_np.std():.4f} mm")
bước 0: loss 609.69592
bước 20: loss 0.08208
bước 40: loss 0.00105
w_gốc = 0.04818 mm/px, b_gốc = 0.3686 mm
Kết quả trùng với NumPy ở bài trước (0,04818 mm/px; 0,3686 mm). Trong thực tế không ai tự tạo w, b và tự cập nhật: PyTorch có sẵn lớp (nn.Linear), hàm loss (nn.MSELoss) và bộ tối ưu (torch.optim.SGD, Adam). Vòng lặp chuẩn mà bạn sẽ viết trong mọi dự án:
import numpy as np
import torch
from torch import nn
rng = np.random.default_rng(0)
x_np = np.sort(rng.uniform(150, 750, 40))
y_np = 0.0482 * x_np + 0.35 + rng.normal(0, 0.03, 40)
X = torch.tensor((x_np - x_np.mean()) / x_np.std(), dtype=torch.float32).unsqueeze(1) # (40, 1)
Y = torch.tensor(y_np, dtype=torch.float32).unsqueeze(1) # (40, 1)
torch.manual_seed(0)
mo_hinh = nn.Linear(1, 1) # chứa w (weight) và b (bias), requires_grad sẵn
ham_loss = nn.MSELoss()
toi_uu = torch.optim.SGD(mo_hinh.parameters(), lr=0.1)
for buoc in range(60):
du_doan = mo_hinh(X) # 1. forward
loss = ham_loss(du_doan, Y) # 2. tính loss
toi_uu.zero_grad() # (xóa gradient cũ)
loss.backward() # 3. backward
toi_uu.step() # 4. cập nhật tham số
if buoc % 20 == 0:
print(f"bước {buoc:2d}: loss {loss.item():.5f}")
w, b = mo_hinh.weight.item(), mo_hinh.bias.item()
print(f"w_gốc = {w / x_np.std():.5f} mm/px, b_gốc = {b - w * x_np.mean() / x_np.std():.4f} mm")
bước 0: loss 585.31970
bước 20: loss 0.07884
bước 40: loss 0.00105
w_gốc = 0.04818 mm/px, b_gốc = 0.3686 mm
Loss ban đầu khác đoạn trước vì nn.Linear khởi tạo trọng số ngẫu nhiên thay vì bằng 0, nhưng điểm hội tụ giống hệt. Bốn bước forward → loss → backward → step (cùng zero_grad trước backward) lặp lại không đổi từ ví dụ hai tham số này đến mô hình hàng trăm triệu tham số. Chỉ có mô hình, hàm loss và cách nạp dữ liệu thay đổi.
Toàn cảnh: 7 bước của mọi dự án huấn luyện
Vòng lặp 4 bước ở trên nằm trong một khung lớn hơn gồm 7 bước (trang 63 của slide). Bảng dưới cho biết mỗi bước dùng công cụ gì và được dạy kỹ ở bài nào trong chặng này:
| Bước | Việc cần làm | PyTorch | Học kỹ ở |
|---|---|---|---|
| 1. Dataset | Đọc ảnh, nhãn, tiền xử lý | Dataset, DataLoader, transforms | Mục 8 bài này; bài Dataset, DataLoader và augmentation |
| 2. Model | Định nghĩa mạng | nn.Module, torchvision.models | Mục 7; bài Các kiến trúc CNN kinh điển |
| 3. Loss | Đo mức độ sai | nn.CrossEntropyLoss… | Bài Loss function và các optimizer |
| 4. Optimizer | Cách cập nhật trọng số | torch.optim | Bài Loss function và các optimizer |
| 5. Train | Học trên tập train | model.train(), vòng lặp 4 bước | Mục 6 và 8 |
| 6. Validate | Theo dõi sau mỗi epoch, chọn checkpoint | model.eval(), torch.no_grad() | Mục 8, 9; bài Overfitting và chia train/val/test |
| 7. Test | Đánh giá cuối cùng, một lần | như bước 6 | Dự án cuối chặng |
Ba tập dữ liệu có vai trò khác nhau: tập train để cập nhật trọng số, tập validation để chọn siêu tham số và checkpoint, tập test chỉ dùng một lần ở cuối để báo cáo khách quan. Dùng tập test để chọn mô hình là tự lừa mình.
7. Định nghĩa mạng bằng nn.Module
Mạng của bạn là một lớp kế thừa nn.Module: khai báo các lớp con trong __init__, mô tả đường đi của dữ liệu trong forward. PyTorch tự tìm mọi tham số của các lớp con, nên model.parameters() trả về đủ để đưa cho optimizer. Đây là CNN nhỏ của bài "Mạng nơ-ron và CNN hoạt động thế nào", nhận ảnh xám 32×32:
import torch
from torch import nn
class CNNNho(nn.Module):
def __init__(self, so_lop=2):
super().__init__()
self.dac_trung = nn.Sequential(
nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 1x32x32 → 8x16x16
nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # → 16x8x8
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), # → 32x8x8
nn.AdaptiveMaxPool2d(1), # → 32x1x1: "có vết ở đâu đó không"
)
self.dau_ra = nn.Linear(32, so_lop)
def forward(self, x):
return self.dau_ra(self.dac_trung(x).flatten(1)) # logit, chưa softmax
torch.manual_seed(0)
model = CNNNho()
x = torch.randn(4, 1, 32, 32)
for lop in model.dac_trung:
x = lop(x)
if not isinstance(lop, nn.ReLU):
print(f"{lop.__class__.__name__:18s} → {tuple(x.shape)}")
print("đầu ra:", tuple(model(torch.randn(4, 1, 32, 32)).shape))
for ten, p in model.named_parameters():
if ten.endswith("weight"):
print(f"{ten:20s} {tuple(p.shape)}")
print("tổng số tham số:", sum(p.numel() for p in model.parameters()))
Conv2d → (4, 8, 32, 32)
MaxPool2d → (4, 8, 16, 16)
Conv2d → (4, 16, 16, 16)
MaxPool2d → (4, 16, 8, 8)
Conv2d → (4, 32, 8, 8)
AdaptiveMaxPool2d → (4, 32, 1, 1)
đầu ra: (4, 2)
dac_trung.0.weight (8, 1, 3, 3)
dac_trung.3.weight (16, 8, 3, 3)
dac_trung.6.weight (32, 16, 3, 3)
dau_ra.weight (2, 32)
tổng số tham số: 5954
Mạng trả về logit (điểm chưa chuẩn hóa) cho 2 lớp. Không thêm softmax ở cuối: nn.CrossEntropyLoss tự làm softmax bên trong, ổn định số học hơn. Khi cần xác suất (để so với ngưỡng NG), gọi torch.softmax(logit, dim=1) lúc suy luận. Lớp AdaptiveMaxPool2d(1) lấy giá trị lớn nhất trên toàn feature map: đặc trưng "có vết xước ở bất kỳ đâu" mạnh thì logit NG cao, rất hợp với lỗi nhỏ nằm ở vị trí ngẫu nhiên.
8. Huấn luyện CNN nhỏ phân loại OK/NG
Tạo bộ ảnh xám 32×32 mô phỏng bề mặt kim loại: ảnh OK có vân và nhiễu, ảnh NG có thêm một vết xước tối ở vị trí, hướng, độ dài ngẫu nhiên. 400 ảnh để huấn luyện, 200 ảnh để kiểm tra. TensorDataset và DataLoader chia dữ liệu thành batch 32 ảnh, xáo trộn mỗi epoch (bài sau dạy kỹ phần này).
import cv2
import numpy as np
import torch
torch.set_num_threads(2)
def tao_anh(ng, rng):
van = rng.normal(0, 0.04, (32, 1)) + rng.normal(0, 0.03, (32, 32)) # vân ngang + nhiễu
anh = (0.55 + rng.normal(0, 0.03) + van).astype(np.float32) # độ sáng mỗi tấm hơi khác
if ng:
x1, y1 = (int(v) for v in rng.integers(4, 28, 2))
goc, dai = rng.uniform(0, np.pi), rng.integers(6, 14)
x2, y2 = int(x1 + dai * np.cos(goc)), int(y1 + dai * np.sin(goc))
cv2.line(anh, (x1, y1), (x2, y2), float(anh.mean() - 0.18), 1)
return np.clip(anh, 0, 1)
def tao_bo(n, rng):
nhan = rng.permutation(np.arange(n) % 2) # nửa OK (0), nửa NG (1)
anh = np.stack([tao_anh(bool(k), rng) for k in nhan])
return torch.from_numpy(anh).unsqueeze(1), torch.from_numpy(nhan).long()
rng = np.random.default_rng(0)
X_train, y_train = tao_bo(400, rng)
X_val, y_val = tao_bo(200, rng)
mean, std = X_train.mean().item(), X_train.std().item() # chỉ tính trên tập train
X_train, X_val = (X_train - mean) / std, (X_val - mean) / std # dùng cùng mean/std cho val
torch.save({"X_train": X_train, "y_train": y_train, "X_val": X_val, "y_val": y_val,
"mean": mean, "std": std}, "okng32.pt")
print("train:", tuple(X_train.shape), X_train.dtype, "| nhãn:", y_train.dtype, torch.bincount(y_train).tolist())
print("val :", tuple(X_val.shape), torch.bincount(y_val).tolist())
print(f"chuẩn hóa: mean {mean:.4f}, std {std:.4f}")
train: (400, 1, 32, 32) torch.float32 | nhãn: torch.int64 [200, 200]
val : (200, 1, 32, 32) [100, 100]
chuẩn hóa: mean 0.5498, std 0.0596
Chú ý kiểu dữ liệu: ảnh float32 shape (N, 1, 32, 32), nhãn int64 shape (N,), đúng thứ CrossEntropyLoss cần. Ảnh được chuẩn hóa bằng mean/std tính trên tập train, đúng bài học của bài gradient descent. Bước này không phải hình thức: khi viết bài, thử bỏ chuẩn hóa (ảnh quanh 0,55, độ lệch rất nhỏ), cùng mạng và cùng số epoch đứng yên ở 50%, tức đoán mò. Giờ là vòng lặp huấn luyện đầy đủ, kèm đánh giá sau mỗi epoch:
CNNNho của mục 7 và file okng32.pt vừa tạo. Chạy chúng trong cùng một notebook (Jupyter, VS Code Interactive), hoặc lưu lớp vào file mo_hinh.py và thêm from mo_hinh import CNNNho ở đầu mỗi đoạn; cách thứ hai là cách nên dùng cho dự án thật, vì code trạm cũng phải import đúng lớp này để nạp state_dict.import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
torch.manual_seed(0)
torch.set_num_threads(2)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
d = torch.load("okng32.pt", weights_only=True)
train_dl = DataLoader(TensorDataset(d["X_train"], d["y_train"]), batch_size=32, shuffle=True)
val_dl = DataLoader(TensorDataset(d["X_val"], d["y_val"]), batch_size=100, shuffle=False)
model = CNNNho().to(device) # lớp CNNNho ở mục 7
ham_loss = nn.CrossEntropyLoss()
toi_uu = torch.optim.Adam(model.parameters(), lr=3e-3)
def danh_gia(model, loader):
model.eval() # chế độ đánh giá (quan trọng với Dropout, BatchNorm)
tong_loss, dung, n = 0.0, 0, 0
with torch.no_grad(): # không cần gradient khi đánh giá
for xb, yb in loader:
xb, yb = xb.to(device), yb.to(device)
logit = model(xb)
tong_loss += ham_loss(logit, yb).item() * len(yb)
dung += (logit.argmax(dim=1) == yb).sum().item()
n += len(yb)
return tong_loss / n, dung / n
for epoch in range(8):
model.train() # chế độ huấn luyện
tong, n = 0.0, 0
for xb, yb in train_dl:
xb, yb = xb.to(device), yb.to(device)
logit = model(xb) # 1. forward
loss = ham_loss(logit, yb) # 2. loss
toi_uu.zero_grad()
loss.backward() # 3. backward
toi_uu.step() # 4. step
tong += loss.item() * len(yb) # .item(): lấy số, không giữ đồ thị
n += len(yb)
val_loss, val_acc = danh_gia(model, val_dl)
print(f"epoch {epoch + 1}: train loss {tong / n:.3f} | val loss {val_loss:.3f} | val đúng {val_acc:.1%}")
epoch 1: train loss 0.677 | val loss 0.651 | val đúng 53.0%
epoch 2: train loss 0.617 | val loss 0.572 | val đúng 94.0%
epoch 3: train loss 0.502 | val loss 0.377 | val đúng 98.5%
epoch 4: train loss 0.308 | val loss 0.184 | val đúng 97.5%
epoch 5: train loss 0.184 | val loss 0.133 | val đúng 100.0%
epoch 6: train loss 0.095 | val loss 0.066 | val đúng 98.5%
epoch 7: train loss 0.052 | val loss 0.052 | val đúng 100.0%
epoch 8: train loss 0.048 | val loss 0.088 | val đúng 96.5%
Con số cụ thể có thể lệch đôi chút giữa các máy và phiên bản thư viện, nhưng xu hướng sẽ giống: epoch đầu mô hình gần như đoán mò (53%), rồi "bắt" được đặc trưng vết xước và vượt 94% từ epoch 2. Độ chính xác val dao động vài phần trăm ở cuối (100% rồi 96,5%) vì tập val chỉ có 200 ảnh và learning rate giữ cố định; bài "Overfitting và chia train/val/test" dạy cách đọc và xử lý những dao động này. Hai chi tiết nhỏ nhưng quan trọng: model.train() / model.eval() chuyển chế độ cho các lớp như Dropout và BatchNorm (mạng này chưa có, nhưng hãy tập thói quen); loss.item() thay vì cộng thẳng tensor loss, nếu không bạn giữ lại toàn bộ đồ thị của mọi batch trong bộ nhớ.
Hai phép kiểm tra nhanh trước khi huấn luyện lâu
- Loss ban đầu ≈ ln K. Mô hình mới khởi tạo đoán gần đều 1/K cho mỗi lớp, nên cross-entropy ban đầu xấp xỉ
ln K: 0,693 với 2 lớp OK/NG, 2,303 với 10 lớp. Loss ban đầu 15 hay 0,01 gần như chắc chắn là lỗi (quên chuẩn hóa, nhãn sai, áp softmax hai lần). - Overfit một batch. Lấy đúng một batch và huấn luyện lặp trên nó: một pipeline đúng phải đưa loss về gần 0. Nếu không, lỗi nằm ở code chứ không phải ở dữ liệu hay siêu tham số.
import math
import torch
import torch.nn as nn
def kiem_tra_overfit_mot_batch(model, images, labels, so_buoc=200, lr=1e-3):
"""Huấn luyện lặp trên đúng một batch; pipeline đúng thì loss phải về gần 0."""
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
model.train()
for buoc in range(so_buoc):
loss = criterion(model(images), labels)
if buoc == 0:
so_lop = model(images).shape[1]
print(f"loss ban đầu {loss.item():.3f}, kỳ vọng ≈ ln({so_lop}) = {math.log(so_lop):.3f}")
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(f"loss sau {so_buoc} bước: {loss.item():.4f}")
torch.manual_seed(0)
mo_hinh = nn.Sequential(nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(8 * 16 * 16, 2))
anh = torch.randn(16, 1, 32, 32) # một batch 16 ảnh (ở đây là dữ liệu giả)
nhan = torch.randint(0, 2, (16,))
kiem_tra_overfit_mot_batch(mo_hinh, anh, nhan)
loss ban đầu 0.766, kỳ vọng ≈ ln(2) = 0.693
loss sau 200 bước: 0.0029
Đọc sâu hơn: PyTorch: 7 bước huấn luyện một mạng phân loại ảnh (trang 59–76 của slide: tensor, dataset có sẵn, ImageFolder, custom Dataset, script train/validate/test hoàn chỉnh trên CIFAR-10).
9. Lưu và nạp mô hình: state_dict
Cách lưu được khuyến nghị là lưu state_dict: một từ điển tên tham số → tensor. File không chứa code của lớp mạng; khi nạp, bạn tạo lại đối tượng cùng lớp rồi đổ trọng số vào.
import torch
torch.save(model.state_dict(), "cnn_nho.pt")
print("các khóa:", list(model.state_dict().keys())[:4], "...")
model2 = CNNNho() # tạo lại kiến trúc
trong_so = torch.load("cnn_nho.pt", map_location="cpu", weights_only=True)
model2.load_state_dict(trong_so)
model2.eval()
d = torch.load("okng32.pt", weights_only=True)
with torch.no_grad():
a = model.cpu().eval()(d["X_val"][:5])
b = model2(d["X_val"][:5])
print("hai mô hình cho cùng logit:", torch.equal(a, b))
p_ng = torch.softmax(b, dim=1)[:, 1] # xác suất lớp NG (cột 1)
print("P(NG) của 5 ảnh val:", [round(v, 3) for v in p_ng.tolist()], "| nhãn thật:", d["y_val"][:5].tolist())
# checkpoint đầy đủ để huấn luyện tiếp: thêm optimizer, epoch, thông tin lớp
torch.save({"model": model.state_dict(), "optimizer": toi_uu.state_dict(), "epoch": 8,
"lop": ["OK", "NG"]}, "checkpoint.pt")
ck = torch.load("checkpoint.pt", weights_only=True)
print("checkpoint:", sorted(ck.keys()), "| lớp:", ck["lop"])
các khóa: ['dac_trung.0.weight', 'dac_trung.0.bias', 'dac_trung.3.weight', 'dac_trung.3.bias'] ...
hai mô hình cho cùng logit: True
P(NG) của 5 ảnh val: [0.05, 0.999, 0.981, 0.506, 0.005] | nhãn thật: [0, 1, 1, 1, 0]
checkpoint: ['epoch', 'lop', 'model', 'optimizer'] | lớp: ['OK', 'NG']
- Ảnh val thứ tư là NG nhưng P(NG) chỉ 0,506, sát ranh giới: có thể vết xước ngắn hoặc mờ. Với ngưỡng 0,5 nó vẫn bị bắt, nhưng ngưỡng 0,6 sẽ cho lọt. Chọn ngưỡng theo escape và overkill là việc của dự án cuối chặng.
map_location="cpu": file lưu từ máy huấn luyện có GPU vẫn nạp được trên máy trạm không có GPU.weights_only=True: chỉ cho phép nạp tensor và kiểu dữ liệu cơ bản (dict, list, số, chuỗi). File.ptdùng định dạng pickle của Python, và pickle có thể chứa code tùy ý chạy ngay khi nạp;weights_only=Truechặn điều đó. Từ PyTorch 2.6 đây là giá trị mặc định, nhưng hãy viết rõ ra để code dễ đọc và an toàn với phiên bản cũ. Không bao giờ nạp file.pttừ nguồn lạ vớiweights_only=False.- Lưu luôn danh sách lớp
["OK", "NG"]và các tham số tiền xử lý cùng mô hình, để phía trạm biết cột 1 là NG. - Cách
torch.save(model)(lưu cả đối tượng) phụ thuộc vào đường dẫn module của lớp mạng và cầnweights_only=Falsekhi nạp; tránh dùng.
10. Sai lầm thường gặp
Ba lỗi kiểu dữ liệu mà người mới nào cũng gặp, kèm thông báo lỗi thật để bạn nhận ra ngay:
import numpy as np
import torch
from torch import nn
lop = nn.Linear(3, 2)
ham_loss = nn.CrossEntropyLoss()
thu = {
"mảng NumPy float64 đưa thẳng vào mạng": lambda: lop(torch.from_numpy(np.ones((4, 3)))),
"ảnh thiếu trục batch hoặc sai số kênh": lambda: nn.Conv2d(1, 8, 3)(torch.zeros(3, 32, 32)),
"nhãn 2 chiều (N, 1) thay vì (N,)": lambda: ham_loss(torch.zeros(4, 2), torch.zeros(4, 1).long()),
}
for mo_ta, ham in thu.items():
try:
ham()
print(f"{mo_ta}: không lỗi")
except RuntimeError as e:
print(f"{mo_ta}:\n {str(e).splitlines()[0]}")
mảng NumPy float64 đưa thẳng vào mạng:
mat1 and mat2 must have the same dtype, but got Double and Float
ảnh thiếu trục batch hoặc sai số kênh:
Given groups=1, weight of size [8, 1, 3, 3], expected input[1, 3, 32, 32] to have 1 channels, but got 3 channels instead
nhãn 2 chiều (N, 1) thay vì (N,):
0D or 1D target tensor expected, multi-target not supported
Lỗi thứ nhất sửa bằng .float(). Lỗi thứ hai nguy hiểm hơn: Conv2d chấp nhận đầu vào 3 chiều (C, H, W) không batch, nên ảnh RGB (3, 32, 32) đưa vào mạng 1 kênh được hiểu là ảnh có 3 kênh và báo lỗi số kênh; nếu số kênh tình cờ khớp, mạng chạy với dữ liệu sai mà không báo gì. Luôn in shape trước khi đưa vào mạng. Lỗi thứ ba: nhãn cho CrossEntropyLoss phải là int64 shape (N,). Những lỗi không báo gì, cần tự kiểm tra:
- Quên
zero_grad(): gradient cộng dồn, huấn luyện dao động hoặc phân kỳ. - Quên
model.eval()khi đánh giá hoặc xuất mô hình: Dropout vẫn tắt ngẫu nhiên neuron, BatchNorm dùng thống kê của batch hiện tại. Kết quả mỗi lần chạy một khác. - Quên
torch.no_grad()khi suy luận: chậm hơn và tốn bộ nhớ (trên GPU có thể hết bộ nhớ). - Thêm softmax trước
CrossEntropyLoss: mô hình vẫn học nhưng chậm và kém hơn, vì loss áp softmax hai lần. - Mô hình trên GPU, dữ liệu trên CPU (hoặc ngược lại): báo lỗi device; nhớ
.to(device)cho cả hai. - Cộng
loss(tensor) vào biến tổng thay vìloss.item(): bộ nhớ tăng dần mỗi batch.
11. Bài tập
Bài 1. Huấn luyện CNN nhỏ trên Fashion-MNIST (70.000 ảnh xám 28×28 của 10 loại quần áo, giày dép; 60.000 train, 10.000 test) đạt trên 85% độ chính xác trên tập kiểm tra. Dùng đúng vòng lặp 4 bước của mục 8, chỉ đổi dữ liệu và số lớp.
Xem đáp án
torchvision tải sẵn bộ dữ liệu này (khoảng 30 MB, cần Internet ở lần chạy đầu). ToImage + ToDtype(..., scale=True) đổi ảnh PIL thành tensor float trong [0, 1]; Normalize dùng mean/std xấp xỉ của tập train.
# (minh họa) cần Internet để tải Fashion-MNIST lần đầu
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets
from torchvision.transforms import v2
torch.manual_seed(0)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
tf = v2.Compose([v2.ToImage(), v2.ToDtype(torch.float32, scale=True), v2.Normalize([0.286], [0.353])])
train_ds = datasets.FashionMNIST("data", train=True, download=True, transform=tf)
test_ds = datasets.FashionMNIST("data", train=False, download=True, transform=tf)
train_dl = DataLoader(train_ds, batch_size=128, shuffle=True)
test_dl = DataLoader(test_ds, batch_size=512)
class CNNFashion(nn.Module):
def __init__(self):
super().__init__()
self.dac_trung = nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 28 → 14
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 14 → 7
)
self.dau_ra = nn.Sequential(nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Linear(128, 10))
def forward(self, x):
return self.dau_ra(self.dac_trung(x))
model = CNNFashion().to(device)
ham_loss = nn.CrossEntropyLoss()
toi_uu = torch.optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(5):
model.train()
for xb, yb in train_dl:
xb, yb = xb.to(device), yb.to(device)
loss = ham_loss(model(xb), yb)
toi_uu.zero_grad()
loss.backward()
toi_uu.step()
model.eval()
dung = 0
with torch.no_grad():
for xb, yb in test_dl:
dung += (model(xb.to(device)).argmax(1) == yb.to(device)).sum().item()
print(f"epoch {epoch + 1}: độ chính xác test {dung / len(test_ds):.2%}")
torch.save(model.state_dict(), "cnn_fashion.pt")
Mạng này thường vượt 85% ngay sau epoch đầu và đạt khoảng 90% sau 5 epoch (con số cụ thể thay đổi theo máy và seed). Trên CPU mỗi epoch mất cỡ vài chục giây đến một hai phút; trên GPU vài giây. Nếu kết quả dưới 85%, kiểm tra: có zero_grad chưa, có model.eval() khi đánh giá chưa, learning rate có quá lớn không (thử 1e-3), ảnh đã chia 255 chưa. Lưu ý: dùng tập test để báo cáo kết quả cuối, đừng dùng nó để chọn số epoch; bài "Overfitting và chia train/val/test" giải thích vì sao.
Bài 2. Không chạy code, dự đoán các giá trị in ra. Sau đó chạy để kiểm tra.
import torch
w = torch.tensor(1.0, requires_grad=True)
(w * 5).backward()
print(w.grad.item())
(w ** 2).backward()
print(w.grad.item())
with torch.no_grad():
w -= 0.1 * w.grad
print(round(w.item(), 2), w.grad.item())
Xem đáp án
5.0
7.0
0.3 7.0
Lần 1: đạo hàm của 5w là 5. Lần 2: đạo hàm của w² tại w = 1 là 2, cộng dồn vào 5 thành 7. Bước cập nhật dùng gradient 7: w = 1 − 0,1·7 = 0,3. Cập nhật tham số không xóa gradient, nên w.grad vẫn là 7; nếu bước sau quên zero_grad, gradient tiếp tục bị cộng vào con số sai này.
Bài 3. Đoạn huấn luyện sau có 4 lỗi (không phải lỗi cú pháp). Tìm và sửa.
# (minh họa) đoạn code có lỗi cố ý
for epoch in range(10):
for xb, yb in train_dl:
logit = torch.softmax(model(xb), dim=1)
loss = ham_loss(logit, yb)
loss.backward()
toi_uu.step()
dung = 0
for xb, yb in val_dl:
dung += (model(xb).argmax(1) == yb).sum().item()
print(epoch, dung / len(val_dl.dataset))
Xem đáp án
- Thiếu
toi_uu.zero_grad()trướcloss.backward(): gradient cộng dồn qua mọi batch. - Softmax trước
CrossEntropyLoss: loss đã tự làm softmax; truyền thẳng logit. - Không gọi
model.train()đầu mỗi epoch vàmodel.eval()trước khi đánh giá. - Đánh giá không có
torch.no_grad(): tốn bộ nhớ và thời gian vô ích.
Bản sửa (dùng lại mô hình và dữ liệu của mục 8):
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
torch.manual_seed(0)
d = torch.load("okng32.pt", weights_only=True)
train_dl = DataLoader(TensorDataset(d["X_train"], d["y_train"]), batch_size=32, shuffle=True)
val_dl = DataLoader(TensorDataset(d["X_val"], d["y_val"]), batch_size=100)
model = CNNNho()
ham_loss = nn.CrossEntropyLoss()
toi_uu = torch.optim.Adam(model.parameters(), lr=3e-3)
for epoch in range(3):
model.train()
for xb, yb in train_dl:
loss = ham_loss(model(xb), yb) # logit thẳng vào loss
toi_uu.zero_grad()
loss.backward()
toi_uu.step()
model.eval()
dung = 0
with torch.no_grad():
for xb, yb in val_dl:
dung += (model(xb).argmax(1) == yb).sum().item()
print(epoch, dung / len(val_dl.dataset))
0 0.53
1 0.94
2 0.985
Tóm tắt
- Tensor = mảng nhiều chiều với
shape,dtype,device. Mạng dùngfloat32; nhãn phân loại dùngint64; ảnh OpenCV(H, W, C)BGR uint8 phải đổi sang(N, C, H, W)float bằngpermute,unsqueeze. - Chọn thiết bị bằng
torch.cuda.is_available(), chuyển cả mô hình lẫn dữ liệu bằng.to(device); về CPU trước khi.numpy(). - Autograd:
requires_gradghi đồ thị,backward()điền.grad, gradient cộng dồn nên phảizero_grad();torch.no_grad()khi đánh giá, suy luận. - Mạng là lớp kế thừa
nn.Module(__init__+forward), trả về logit;CrossEntropyLosstự làm softmax. - Vòng lặp 4 bước: forward → loss → backward (sau
zero_grad) → step;model.train()khi huấn luyện,model.eval()khi đánh giá. - Lưu
state_dict, nạp bằngtorch.load(..., map_location="cpu", weights_only=True); lưu kèm danh sách lớp và tham số tiền xử lý.
Bài tiếp theo: Loss function và các optimizer, hiểu kỹ hàm loss và các optimizer bạn vừa dùng. Sau các bài về backpropagation, BatchNorm, kiến trúc CNN và khởi tạo trọng số, bài Dataset, DataLoader và augmentation đưa dữ liệu từ thư mục ảnh thật vào thay cho tensor tạo sẵn.
Bài tập thực hành
- Huấn luyện CNN nhỏ trên Fashion-MNIST đạt trên 85% độ chính xác tập kiểm tra.
Làm xong bài tập mới chuyển sang bài tiếp theo: tự viết code là cách duy nhất để nhớ lâu.


