Published on

Day 10: PyTorch Fundamentals

Authors

Mục Tiêu

Sau bài này, bạn cần làm được các việc sau:

  • Tạo và debug Tensor bằng shape, dtype, device, requires_grad.
  • Hiểu autograd ở mức đủ để viết training loop đúng: forward -> loss -> backward -> optimizer.step.
  • Viết model bằng nn.Module, đặt computation trong forward() và dùng state_dict để lưu artifact.
  • Tạo DatasetDataLoader cho mini-batch training.
  • Quản lý CPU/GPU device một cách nhất quán cho model, input, label và checkpoint.
  • Rebuild MLP XOR của Day 9 bằng PyTorch, dùng BCEWithLogitsLoss thay vì tự viết backprop bằng NumPy.
  • Biết khi nào PyTorch phù hợp production và cần thêm điều kiện gì.

Cách Học Bài Này

  1. Đọc document.md để nắm mental model, API và trade-off.
  2. Làm lần lượt exercise.md, đặc biệt bài rebuild MLP XOR.
  3. So sánh code PyTorch với MLP NumPy ở Day 9: phần nào được framework xử lý, phần nào vẫn là trách nhiệm của engineer.
  4. Tự trả lời checklist cuối bài trước khi sang Day 11.

Bức Tranh Tổng Quan

PyTorch thay thế phần khó nhất của Day 9: bạn không còn tự tính đạo hàm và tự cập nhật từng weight bằng NumPy. Thay vào đó:

Tensor + autograd  -> tự track phép toán và tính gradient
nn.Module          -> đóng gói architecture và parameters
Dataset/DataLoader -> đóng gói data access và mini-batch
Optimizer          -> cập nhật parameters từ gradient
state_dict         -> artifact có thể lưu, load, deploy

Nhưng PyTorch không tự giải quyết mọi vấn đề production. Bạn vẫn phải kiểm soát shape contract, dtype, device, preprocessing, seed, data split, metric, checkpoint, logging, monitoring và rollback.

Best Solution Theo Context

Với bài Day 10, best solution là dùng PyTorch thuần, không dùng trainer framework. Lý do: mục tiêu là hiểu cơ chế nền tảng trước khi sang optimizer/scheduler ở Day 11 và Hugging Face ở các bài sau.

ContextLựa chọn nên dùngVì sao
Học backprop ở mức conceptNumPy như Day 9Thấy rõ phép toán và gradient
Training deep learning thậtPyTorchCó autograd, GPU, module, optimizer, ecosystem
Model nhỏ, dev localCPU fallbackĐơn giản, ít lỗi môi trường
Matrix compute lớn hoặc batch inferenceGPUTận dụng parallel compute
Binary classificationBCEWithLogitsLossỔn định số học hơn sigmoid + BCELoss
Inference/evaluationmodel.eval() + torch.inference_mode()Đúng behavior và giảm memory

Dùng Được Trong Production Không?

Có, PyTorch dùng được trong production cho cả training và inference. Tuy nhiên code demo trong bài chỉ là baseline học tập. Để dùng production, tối thiểu cần:

  • Artifact rõ ràng: state_dict, config architecture, preprocessing config, label mapping, threshold và metric.
  • Reproducibility: seed, package version, dataset snapshot, data split, training config.
  • Validation: kiểm tra shape, dtype, missing value, range của feature và device mismatch.
  • Runtime mode đúng: training dùng model.train(), evaluation/inference dùng model.eval() kèm torch.no_grad() hoặc torch.inference_mode().
  • Observability: log loss, metric, latency, error rate, prediction distribution, drift.
  • Reliability: device fallback CPU/GPU, xử lý checkpoint lỗi, rollback model version, không load checkpoint từ nguồn không tin cậy.
  • Performance test: benchmark p50/p95/p99 latency, throughput, VRAM/RAM, data loading bottleneck.

Checklist Hoàn Thành

  • Giải thích được Tensor khác NumPy array ở điểm nào.
  • Biết vì sao loss.backward() tạo gradient và vì sao gradient bị accumulate.
  • Biết vì sao cần optimizer.zero_grad(set_to_none=True) trước mỗi update.
  • Viết được nn.Module__init__forward.
  • Tạo được Dataset/DataLoader trả về batch (features, labels).
  • Chạy được MLP XOR bằng PyTorch với BCEWithLogitsLoss.
  • Save/load được state_dict.
  • Trả lời được production readiness của code mình viết.

Tài liệu

1. PyTorch Giải Quyết Gì Sau Day 9?

Ở Day 9, bạn tự viết MLP bằng NumPy:

forward -> tính loss -> tự đạo hàm -> tự update W/b

Cách đó tốt để học, nhưng không phù hợp training deep learning thật vì dễ sai gradient, khó dùng GPU, khó mở rộng architecture và khó lưu/load artifact chuẩn.

PyTorch giữ lại mental model đó nhưng thay phần thủ công bằng các primitive production hơn:

Day 9 với NumPyDay 10 với PyTorchÝ nghĩa
np.ndarraytorch.TensorDữ liệu n chiều, có thể chạy CPU/GPU
Tự viết backpropAutogradTự tính gradient từ computational graph
Tự quản lý W1, b1, W2, b2nn.Modulenn.ParameterĐóng gói weights thành model
Tự chia batchDatasetDataLoaderData pipeline chuẩn
Tự update weighttorch.optimOptimizer chuẩn như SGD, AdamW
Tự save mảng NumPystate_dictArtifact chuẩn của PyTorch

Best solution trong bài này: viết training loop PyTorch thuần. Chưa dùng abstraction cao hơn vì Senior SE cần hiểu loop nền trước khi debug model thật.

2. Tensor, dtype, shape, device

Tensor là core data structure của PyTorch. Nó giống NumPy array ở chỗ biểu diễn dữ liệu n chiều, nhưng có thêm:

  • device: tensor nằm trên CPU, CUDA GPU hoặc backend khác.
  • requires_grad: có cần autograd track phép toán không.
  • .grad: nơi gradient được accumulate sau backward().
  • Tích hợp với torch.nn, optimizer và GPU runtime.

Ví dụ inspect tensor:

import torch

x = torch.tensor(
    [[0.0, 1.0], [1.0, 0.0]],
    dtype=torch.float32,
)

print("shape:", x.shape)   # torch.Size([2, 2])
print("dtype:", x.dtype)   # torch.float32
print("device:", x.device) # cpu
print("ndim:", x.ndim)
print("numel:", x.numel())

Shape Là Contract

Với binary classifier MLP:

X      shape = (batch_size, input_dim)
logits shape = (batch_size, 1)
y      shape = (batch_size, 1)

Nếu logits(32, 1) nhưng label là (32,), một số operation có thể broadcast âm thầm hoặc loss báo lỗi khó đọc. Trong code gần production, hãy validate shape ở boundary: khi tạo dataset, trước khi tính loss, hoặc trong test.

Dtype Là Contract Tính Toán

Các dtype hay gặp:

DtypeDùng khiLưu ý
torch.float32Default cho training neural networkCân bằng tốc độ, memory, độ chính xác
torch.float64Numerical analysis cần chính xác caoChậm hơn, tốn RAM/VRAM hơn
torch.int64 / torch.longClass index cho CrossEntropyLossKhông dùng cho BCEWithLogitsLoss target
torch.boolMaskKhông dùng trực tiếp làm feature numeric
torch.float16 / bfloat16Mixed precisionCần hiểu GPU support và stability

Với BCEWithLogitsLoss, input là logits dạng float và target nên là float cùng shape, giá trị 0 hoặc 1.

Device Là Runtime Placement

Model và tensor input phải nằm cùng device:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

x = x.to(device)
model = model.to(device)

Lỗi phổ biến:

Expected all tensors to be on the same device, but found at least two devices...

Nguyên nhân thường là model đã .to("cuda") nhưng batch từ DataLoader vẫn ở CPU, hoặc checkpoint được load về CPU rồi trộn với tensor GPU.

Best practice:

  • Chọn device một lần ở đầu program.
  • Move model sang device ngay sau khi khởi tạo.
  • Move từng batch sang device trong training/evaluation loop.
  • Khi cần log hoặc convert sang NumPy, đưa tensor về CPU bằng .detach().cpu().

3. Autograd

Autograd build computational graph trong lúc bạn chạy forward pass. Khi gọi loss.backward(), PyTorch tính gradient của loss theo các leaf tensor có requires_grad=True, thường là parameters của model.

Ví dụ nhỏ:

import torch

w = torch.tensor([2.0], requires_grad=True)
x = torch.tensor([3.0])
y = w * x
loss = (y - 10.0) ** 2

loss.backward()

print(w.grad)  # dloss/dw

Điều quan trọng: gradient được accumulate. Nếu bạn không reset gradient, batch sau sẽ cộng dồn gradient với batch trước.

Training step chuẩn:

optimizer.zero_grad(set_to_none=True)
logits = model(xb)
loss = loss_fn(logits, yb)
loss.backward()
optimizer.step()

Vì sao set_to_none=True?

  • Giảm một số thao tác ghi zero vào memory.
  • Có thể tiết kiệm memory và nhanh hơn trong nhiều workload.
  • Giúp phát hiện parameter nào không nhận gradient vì .grad vẫn là None.

Trade-off: nếu code cũ giả định .grad luôn là tensor zero, cần sửa logic đó.

Khi Không Cần Gradient

Evaluation và inference không cần lưu graph:

model.eval()
with torch.inference_mode():
    logits = model(xb)
    probs = torch.sigmoid(logits)

torch.no_grad()torch.inference_mode() đều tắt gradient tracking. inference_mode() tối ưu mạnh hơn cho inference thuần, nhưng ít linh hoạt hơn nếu bạn còn cần tensor tham gia autograd sau đó. Trong bài này, evaluation/predict dùng inference_mode(). Khi viết code debug cần linh hoạt, no_grad() vẫn là lựa chọn an toàn.

model.eval() không thay thế no_grad() hoặc inference_mode(). eval() đổi behavior của layer như Dropout/BatchNorm; gradient mode kiểm soát autograd memory.

4. nn.Moduleforward()

nn.Module là base class cho model/layer. Subclass thường có:

  • __init__: khai báo layer, parameter, buffer.
  • forward: mô tả computation từ input sang output.
  • state_dict: dictionary chứa parameters và buffers.

Ví dụ:

from torch import nn


class XORMLP(nn.Module):
    def __init__(self, hidden_dim: int = 8) -> None:
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(2, hidden_dim),
            nn.Tanh(),
            nn.Linear(hidden_dim, 1),
        )

    def forward(self, x):
        if x.ndim != 2 or x.shape[1] != 2:
            raise ValueError(f"Expected input shape (batch, 2), got {tuple(x.shape)}")
        return self.net(x)

Gọi model bằng model(x), không gọi trực tiếp model.forward(x) trong training code. model(x) đi qua hook và cơ chế nội bộ của nn.Module.

model.train()model.eval()

model.train()  # training mode
model.eval()   # evaluation mode

Với MLP XOR không có Dropout/BatchNorm, output có thể giống nhau giữa train/eval. Nhưng trong model thật, quên eval() có thể làm metric và inference sai.

Production rule:

  • Training loop: gọi model.train() đầu epoch.
  • Validation/test/inference: gọi model.eval() và tắt gradient.
  • Sau khi load checkpoint để serve: gọi model.eval().

5. DatasetDataLoader

Dataset mô tả cách lấy một sample. DataLoader biến dataset thành iterable mini-batch.

from torch.utils.data import Dataset, DataLoader


class XORDataset(Dataset):
    def __init__(self):
        self.features = ...
        self.labels = ...

    def __len__(self):
        return len(self.features)

    def __getitem__(self, idx):
        return self.features[idx], self.labels[idx]


loader = DataLoader(
    XORDataset(),
    batch_size=32,
    shuffle=True,
    num_workers=0,
)

Các option quan trọng:

OptionÝ nghĩaTrade-off
batch_sizeSố sample mỗi batchLớn hơn thường tăng throughput nhưng tốn memory
shuffleXáo trộn mỗi epochNên bật cho train, tắt cho validation/test
num_workersSố process load dataTăng throughput nhưng phức tạp debug hơn
pin_memoryCopy tensor vào pinned memory cho CUDACó ích khi dùng GPU, không cần cho CPU
drop_lastBỏ batch cuối nếu thiếu sampleHữu ích cho BatchNorm/distributed training
collate_fnCách ghép sample thành batchCần cho sequence dài ngắn khác nhau

Best practice:

  • Dataset không nên tự move tensor sang GPU. Để training loop quyết định device.
  • Dataset nên trả về tensor có shape/dtype ổn định.
  • shuffle=True chỉ dùng cho training set.
  • Với dữ liệu lớn, tách preprocessing offline hoặc cache để DataLoader không thành bottleneck.

6. CPU/GPU Device Management

Pattern cơ bản:

def select_device() -> torch.device:
    if torch.cuda.is_available():
        return torch.device("cuda")
    return torch.device("cpu")


device = select_device()
model = XORMLP().to(device)

for xb, yb in loader:
    xb = xb.to(device)
    yb = yb.to(device)

GPU không tự động nhanh hơn trong mọi trường hợp. Nếu model rất nhỏ hoặc batch rất nhỏ, cost copy CPU -> GPU có thể lớn hơn lợi ích compute.

Production considerations:

  • Log device đang dùng ở startup.
  • Có fallback khi CUDA không available.
  • Tránh .to(device) từng sample; move theo batch.
  • Tránh gọi .item() quá nhiều trong GPU loop vì có thể ép đồng bộ CPU/GPU.
  • Theo dõi VRAM, GPU utilization, dataloader wait time.

7. Training Loop Tối Thiểu Nhưng Đúng

Skeleton cho binary classification:

from torch import nn

loss_fn = nn.BCEWithLogitsLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-2, weight_decay=1e-4)

model.train()
for xb, yb in train_loader:
    xb = xb.to(device)
    yb = yb.to(device)

    optimizer.zero_grad(set_to_none=True)
    logits = model(xb)
    loss = loss_fn(logits, yb)
    loss.backward()
    optimizer.step()

Tại sao không đặt Sigmoid trong model khi dùng BCEWithLogitsLoss?

  • BCEWithLogitsLoss nhận raw logits.
  • Nó kết hợp sigmoid và binary cross entropy theo cách ổn định số học hơn.
  • Khi cần probability để metric/inference, dùng torch.sigmoid(logits) sau model.

8. Save/Load Bằng state_dict

Không nên serialize cả object model nếu không cần. Cách chuẩn là lưu state_dict kèm config.

checkpoint = {
    "model_state_dict": model.state_dict(),
    "optimizer_state_dict": optimizer.state_dict(),
    "config": {
        "input_dim": 2,
        "hidden_dim": 8,
        "output_dim": 1,
    },
    "metrics": {"val_accuracy": 1.0},
}
torch.save(checkpoint, "artifacts/xor_mlp.pt")

Load:

checkpoint = torch.load("artifacts/xor_mlp.pt", map_location=device)
model = XORMLP(hidden_dim=checkpoint["config"]["hidden_dim"]).to(device)
model.load_state_dict(checkpoint["model_state_dict"])
model.eval()

Production note:

  • Chỉ load checkpoint từ nguồn tin cậy.
  • Version config, preprocessing và code model cùng artifact.
  • Với PyTorch mới, cân nhắc weights_only=True khi phù hợp để giảm rủi ro untrusted pickle.

9. NumPy Vs PyTorch

Tiêu chíNumPyPyTorch
Core datandarrayTensor
AutogradKhông có native autogradCó autograd
GPUKhông phải default workflowFirst-class CUDA support
Neural network layersTự viết hoặc dùng lib kháctorch.nn
OptimizerTự viếttorch.optim
Data pipelineTự viết batchingDataset/DataLoader
Production DLKhông phải lựa chọn chínhRất phổ biến
Học mathRất tốtTốt nhưng che bớt chi tiết gradient

Best solution:

  • Dùng NumPy để học linear algebra, loss, manual gradient.
  • Dùng PyTorch khi training neural network thật, cần GPU, checkpoint, ecosystem và khả năng mở rộng.

10. Performance Và Trade-Off

Quyết địnhLợi íchChi phí/rủi roGợi ý
CPUDễ chạy, ít phụ thuộcChậm với model lớnTốt cho dev, test, inference nhỏ
GPUThroughput cao cho matrix computeVRAM, setup, transfer costDùng khi batch/model đủ lớn
Batch lớnTận dụng vectorizationTốn memory, có thể ảnh hưởng generalizationTăng dần đến khi gần giới hạn memory
num_workers > 0Load data song songDebug khó hơn, overhead processBắt đầu 0, tăng khi data loading nghẽn
float32Default ổn địnhTốn hơn mixed precisionDùng trước khi tối ưu
Mixed precisionNhanh hơn, ít VRAM hơnCó rủi ro numerical issueĐể Day sau khi loop đã đúng
inference_mode()Ít overhead hơn inferenceÍt linh hoạt hơn no_grad()Dùng cho inference thuần

Performance rule: đo trước khi tối ưu. Với model nhỏ như XOR, GPU có thể chậm hơn CPU vì overhead dominate.

11. Lỗi Phổ Biến

  • Quên optimizer.zero_grad(...), làm gradient cộng dồn sai.
  • Dùng Sigmoid trong model rồi lại dùng BCEWithLogitsLoss.
  • Label dtype là torch.long trong bài toán BCE thay vì float.
  • Label shape (batch,) nhưng logits shape (batch, 1).
  • Model ở GPU nhưng batch ở CPU.
  • Quên model.eval() khi validation/inference.
  • Quên torch.no_grad() hoặc torch.inference_mode() khi inference, làm tốn memory.
  • Save cả object model thay vì state_dict, làm artifact phụ thuộc code path nhiều hơn.
  • Convert tensor GPU trực tiếp sang NumPy thay vì .detach().cpu().numpy().

12. Kết Luận Production

PyTorch fundamentals trong bài này dùng được làm nền production. Code demo có thể chạy local và làm template nhỏ, nhưng chưa đủ production nếu thiếu config management, test, logging, checkpoint policy, data validation, model registry, serving layer và monitoring.

Câu trả lời ngắn: dùng được trong production nếu bạn quản lý đầy đủ artifact, reproducibility, input contract, runtime mode, device fallback, performance benchmark và observability. Không nên copy nguyên training script demo vào production service mà không bổ sung các lớp kiểm soát đó.


Bài tập

Chuẩn Bị

Cài PyTorch theo môi trường của bạn. Với máy CPU/dev local, cách tối thiểu thường là:

python3 -m pip install torch numpy

Kiểm tra:

python3 - <<'PY'
import torch
print(torch.__version__)
print("cuda:", torch.cuda.is_available())
PY

Nếu dùng CUDA, hãy cài theo hướng dẫn chính thức phù hợp driver/CUDA runtime của máy. Không hard-code "cuda" trong code production; luôn có fallback.

Bài 1: Inspect Tensor

Mục tiêu: quen với shape, dtype, device, requires_grad.

import torch

x = torch.tensor([[0.0, 1.0], [1.0, 0.0]], dtype=torch.float32)
w = torch.randn((2, 1), dtype=torch.float32, requires_grad=True)

print("x:", x)
print("x.shape:", x.shape)
print("x.dtype:", x.dtype)
print("x.device:", x.device)
print("w.requires_grad:", w.requires_grad)

Việc cần làm:

  1. Đổi dtype của x sang torch.float64 và quan sát output.
  2. Nếu máy có GPU, move x sang CUDA bằng .to("cuda").
  3. Tạo lỗi device mismatch bằng cách để x ở CPU nhưng w ở CUDA, sau đó sửa lại.

Bài 2: Autograd Tối Giản

Mục tiêu: hiểu loss.backward() và gradient accumulation.

import torch

w = torch.tensor([2.0], requires_grad=True)
x = torch.tensor([3.0])
target = torch.tensor([10.0])

for step in range(3):
    y = w * x
    loss = (y - target).pow(2).mean()
    loss.backward()
    print(step, "loss=", loss.item(), "grad=", w.grad.item())

Bạn sẽ thấy gradient bị cộng dồn. Sửa loop bằng cách thêm:

w.grad = None

trước mỗi loss.backward(). Khi dùng optimizer, cách chuẩn là:

optimizer.zero_grad(set_to_none=True)

Bài 3: Rebuild MLP XOR Từ Day 9 Bằng PyTorch

Tạo file train_xor_pytorch.py nếu muốn chạy riêng. Code dưới đây cố tình có cấu trúc gần production hơn toy script: có config, seed, device fallback, train/eval mode, DataLoader, BCEWithLogitsLoss, state_dict và checkpoint.

from __future__ import annotations

from dataclasses import asdict, dataclass
from pathlib import Path
import random

import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset, random_split


@dataclass(frozen=True)
class TrainConfig:
    seed: int = 42
    repeats: int = 512
    noise_std: float = 0.04
    hidden_dim: int = 8
    batch_size: int = 32
    epochs: int = 250
    learning_rate: float = 0.03
    weight_decay: float = 1e-4
    val_ratio: float = 0.2
    checkpoint_path: str = "artifacts/xor_mlp.pt"


def set_seed(seed: int) -> None:
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)

    # Determinism giúp demo dễ lặp lại hơn, nhưng có thể giảm performance.
    torch.backends.cudnn.benchmark = False
    torch.backends.cudnn.deterministic = True


def select_device() -> torch.device:
    if torch.cuda.is_available():
        return torch.device("cuda")
    if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
        return torch.device("mps")
    return torch.device("cpu")


class XORDataset(Dataset):
    def __init__(self, repeats: int, noise_std: float, seed: int) -> None:
        base_x = torch.tensor(
            [[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]],
            dtype=torch.float32,
        )
        base_y = torch.tensor([[0.0], [1.0], [1.0], [0.0]], dtype=torch.float32)

        self.features = base_x.repeat((repeats, 1))
        self.labels = base_y.repeat((repeats, 1))

        if noise_std > 0:
            generator = torch.Generator().manual_seed(seed)
            noise = torch.randn(self.features.shape, generator=generator) * noise_std
            self.features = torch.clamp(self.features + noise, 0.0, 1.0)

        if self.features.shape[0] != self.labels.shape[0]:
            raise ValueError("features and labels must have the same number of rows")

    def __len__(self) -> int:
        return self.features.shape[0]

    def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]:
        return self.features[idx], self.labels[idx]


class XORMLP(nn.Module):
    def __init__(self, hidden_dim: int) -> None:
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(2, hidden_dim),
            nn.Tanh(),
            nn.Linear(hidden_dim, 1),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        if x.ndim != 2 or x.shape[1] != 2:
            raise ValueError(f"Expected input shape (batch, 2), got {tuple(x.shape)}")
        return self.net(x)


def binary_accuracy_from_logits(logits: torch.Tensor, targets: torch.Tensor) -> float:
    probs = torch.sigmoid(logits)
    preds = (probs >= 0.5).to(dtype=targets.dtype)
    return (preds == targets).float().mean().item()


def move_batch(
    batch: tuple[torch.Tensor, torch.Tensor],
    device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor]:
    features, labels = batch
    non_blocking = device.type == "cuda"
    return (
        features.to(device, non_blocking=non_blocking),
        labels.to(device, non_blocking=non_blocking),
    )


def train_one_epoch(
    model: nn.Module,
    loader: DataLoader,
    loss_fn: nn.Module,
    optimizer: torch.optim.Optimizer,
    device: torch.device,
) -> dict[str, float]:
    model.train()

    total_loss = 0.0
    total_accuracy = 0.0
    total_rows = 0

    for batch in loader:
        xb, yb = move_batch(batch, device)

        optimizer.zero_grad(set_to_none=True)
        logits = model(xb)
        loss = loss_fn(logits, yb)
        loss.backward()
        optimizer.step()

        batch_size = xb.shape[0]
        total_loss += loss.item() * batch_size
        total_accuracy += binary_accuracy_from_logits(logits.detach(), yb) * batch_size
        total_rows += batch_size

    return {
        "loss": total_loss / total_rows,
        "accuracy": total_accuracy / total_rows,
    }


@torch.inference_mode()
def evaluate(
    model: nn.Module,
    loader: DataLoader,
    loss_fn: nn.Module,
    device: torch.device,
) -> dict[str, float]:
    model.eval()

    total_loss = 0.0
    total_accuracy = 0.0
    total_rows = 0

    for batch in loader:
        xb, yb = move_batch(batch, device)
        logits = model(xb)
        loss = loss_fn(logits, yb)

        batch_size = xb.shape[0]
        total_loss += loss.item() * batch_size
        total_accuracy += binary_accuracy_from_logits(logits, yb) * batch_size
        total_rows += batch_size

    return {
        "loss": total_loss / total_rows,
        "accuracy": total_accuracy / total_rows,
    }


@torch.inference_mode()
def predict_clean_xor(model: nn.Module, device: torch.device) -> tuple[torch.Tensor, torch.Tensor]:
    model.eval()
    clean_x = torch.tensor(
        [[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]],
        dtype=torch.float32,
        device=device,
    )
    logits = model(clean_x)
    probs = torch.sigmoid(logits)
    preds = (probs >= 0.5).to(torch.int64)
    return probs.cpu(), preds.cpu()


def make_loaders(config: TrainConfig, device: torch.device) -> tuple[DataLoader, DataLoader]:
    dataset = XORDataset(
        repeats=config.repeats,
        noise_std=config.noise_std,
        seed=config.seed,
    )

    val_size = int(len(dataset) * config.val_ratio)
    train_size = len(dataset) - val_size

    split_generator = torch.Generator().manual_seed(config.seed)
    train_dataset, val_dataset = random_split(
        dataset,
        [train_size, val_size],
        generator=split_generator,
    )

    pin_memory = device.type == "cuda"
    loader_generator = torch.Generator().manual_seed(config.seed)

    train_loader = DataLoader(
        train_dataset,
        batch_size=config.batch_size,
        shuffle=True,
        num_workers=0,
        pin_memory=pin_memory,
        generator=loader_generator,
    )
    val_loader = DataLoader(
        val_dataset,
        batch_size=config.batch_size,
        shuffle=False,
        num_workers=0,
        pin_memory=pin_memory,
    )
    return train_loader, val_loader


def save_checkpoint(
    config: TrainConfig,
    model: nn.Module,
    optimizer: torch.optim.Optimizer,
    metrics: dict[str, float],
) -> None:
    path = Path(config.checkpoint_path)
    path.parent.mkdir(parents=True, exist_ok=True)
    torch.save(
        {
            "model_state_dict": model.state_dict(),
            "optimizer_state_dict": optimizer.state_dict(),
            "config": asdict(config),
            "metrics": metrics,
        },
        path,
    )


def load_model_for_inference(path: str, device: torch.device) -> XORMLP:
    checkpoint = torch.load(path, map_location=device, weights_only=True)
    model_config = checkpoint["config"]
    model = XORMLP(hidden_dim=model_config["hidden_dim"]).to(device)
    model.load_state_dict(checkpoint["model_state_dict"])
    model.eval()
    return model


def main() -> None:
    config = TrainConfig()
    set_seed(config.seed)
    device = select_device()
    print("device:", device)

    train_loader, val_loader = make_loaders(config, device)
    model = XORMLP(hidden_dim=config.hidden_dim).to(device)
    loss_fn = nn.BCEWithLogitsLoss()
    optimizer = torch.optim.AdamW(
        model.parameters(),
        lr=config.learning_rate,
        weight_decay=config.weight_decay,
    )

    best_val_accuracy = 0.0
    best_metrics: dict[str, float] = {}

    for epoch in range(1, config.epochs + 1):
        train_metrics = train_one_epoch(model, train_loader, loss_fn, optimizer, device)
        val_metrics = evaluate(model, val_loader, loss_fn, device)

        if val_metrics["accuracy"] >= best_val_accuracy:
            best_val_accuracy = val_metrics["accuracy"]
            best_metrics = {
                "epoch": float(epoch),
                "train_loss": train_metrics["loss"],
                "train_accuracy": train_metrics["accuracy"],
                "val_loss": val_metrics["loss"],
                "val_accuracy": val_metrics["accuracy"],
            }
            save_checkpoint(config, model, optimizer, best_metrics)

        if epoch == 1 or epoch % 25 == 0:
            print(
                f"epoch={epoch:03d} "
                f"train_loss={train_metrics['loss']:.4f} "
                f"train_acc={train_metrics['accuracy']:.3f} "
                f"val_loss={val_metrics['loss']:.4f} "
                f"val_acc={val_metrics['accuracy']:.3f}"
            )

    print("best_metrics:", best_metrics)

    loaded_model = load_model_for_inference(config.checkpoint_path, device)
    probs, preds = predict_clean_xor(loaded_model, device)

    print("Clean XOR probabilities:")
    print(torch.round(probs * 10000) / 10000)
    print("Clean XOR predictions:")
    print(preds)
    print("Expected:")
    print(torch.tensor([[0], [1], [1], [0]], dtype=torch.int64))


if __name__ == "__main__":
    main()

Kỳ vọng:

  • Loss giảm dần sau vài chục epoch.
  • Validation accuracy thường đạt gần 1.0.
  • Clean XOR predictions là [[0], [1], [1], [0]].
  • Checkpoint nằm ở artifacts/xor_mlp.pt.

Nếu model không học:

  • Kiểm tra BCEWithLogitsLoss đang nhận logits, không nhận probability đã sigmoid.
  • Kiểm tra label là float32 và shape (batch, 1).
  • Thử tăng hidden_dim lên 16.
  • Thử giảm noise_std về 0.0.
  • Kiểm tra learning rate quá lớn khiến loss dao động.

Bài 4: So Sánh Với NumPy Day 9

Điền bảng sau sau khi chạy hai phiên bản:

Câu hỏiNumPy Day 9PyTorch Day 10
Weight nằm ở đâu?
Ai tính gradient?
Code update weight nằm ở đâu?
Có GPU dễ không?
Save/load artifact thế nào?
Lỗi shape dễ phát hiện ở đâu?

Gợi ý trả lời:

  • NumPy giúp thấy rõ math nhưng bạn tự chịu trách nhiệm gradient.
  • PyTorch ngắn hơn ở phần gradient/update nhưng vẫn cần bạn kiểm soát data contract.
  • PyTorch không loại bỏ nhu cầu test, logging, validation và monitoring.

Bài 5: Thay Đổi Có Kiểm Soát

Chạy lại script với từng thay đổi, mỗi lần chỉ đổi một biến:

  1. hidden_dim: 2, 4, 8, 16.
  2. learning_rate: 0.003, 0.03, 0.3.
  3. batch_size: 4, 32, 128.
  4. noise_std: 0.0, 0.04, 0.15.
  5. Device: CPU vs CUDA nếu có GPU.

Ghi lại:

  • Loss cuối cùng.
  • Validation accuracy tốt nhất.
  • Clean XOR predictions.
  • Thời gian chạy tương đối.
  • Có lỗi memory/device/dtype/shape không.

Bài 6: Checklist Production Readiness

Trả lời trước khi coi code là gần production:

  • Input contract đã rõ shape, dtype, range chưa?
  • Train/validation/test split có deterministic và không leakage không?
  • Có seed và config lưu cùng checkpoint không?
  • Checkpoint có state_dict, config và metrics không?
  • Inference có model.eval()torch.inference_mode() không?
  • Có CPU/GPU fallback không?
  • Có logging metric theo epoch không?
  • Có benchmark latency/throughput không?
  • Có kiểm soát nguồn checkpoint khi load không?
  • Có version preprocessing và threshold không?

Kết luận: script trong bài dùng tốt cho học tập, prototype và baseline nhỏ. Để vào production, cần bổ sung test tự động, data validation, config file, experiment tracking, model registry, serving API, monitoring và rollback.