Bỏ qua

Kiểm Thử và Đảm Bảo Chất Lượng

Kiểm thử (testing) là cách bạn biết mã của bạn hoạt động, không chỉ ngay bây giờ, mà sau mọi thay đổi. File này bao gồm kim tự tháp kiểm thử (test pyramid), kiểm thử đơn vị với pytest, mocking, kiểm thử mã ML đặc thù, pipeline CI/CD, linting, định dạng (formatting) và code review — những thực hành ngăn chặn lỗi trước khi chúng đến môi trường sản xuất.

  • Mã ML nổi tiếng là thiếu kiểm thử. "Nó huấn luyện được, vậy là nó hoạt động" là thái độ phổ biến. Điều này dẫn đến các lỗi thầm lặng: một bộ nạp dữ liệu xáo trộn sai, một hàm mất mát với lỗi dấu, một bước tiền xử lý bỏ mất 5% dữ liệu. Những lỗi này không làm sập chương trình của bạn. Chúng chỉ làm cho mô hình của bạn tệ hơn một cách lặng lẽ, và bạn mất hàng tuần gỡ lỗi các chỉ số mà "đáng ra phải cao hơn."

  • Kiểm thử không phải là chi phí chung. Nó là cách nhanh nhất để di chuyển nhanh mà không làm hỏng việc.

Kim Tự Tháp Kiểm Thử

  • Các kiểm thử được tổ chức theo các tầng, từ nhanh và hẹp đến chậm và rộng:

    • Kiểm thử đơn vị (Unit tests) (nền): kiểm tra các hàm và lớp riêng lẻ một cách cô lập. Nhanh (mili giây), nhiều (hàng trăm đến hàng ngàn). "Hàm normalise_image có tạo ra các giá trị trong [0, 1] không?"

    • Kiểm thử tích hợp (Integration tests) (giữa): kiểm tra các thành phần hoạt động cùng nhau. Chậm hơn (giây). "Bộ nạp dữ liệu có tạo ra các batch ở định dạng mà mô hình mong đợi không?"

    • Kiểm thử đầu cuối (End-to-end tests) (đỉnh): kiểm tra toàn bộ pipeline từ đầu vào đến đầu ra. Chậm (phút). "python train.py --config test.yaml có hoàn thành không lỗi và tạo ra một checkpoint hợp lệ không?"

  • Hình dạng kim tự tháp có nghĩa là: viết nhiều kiểm thử đơn vị, ít kiểm thử tích hợp hơn và một số ít kiểm thử đầu cuối. Kiểm thử đơn vị bắt được hầu hết các lỗi và chạy trong vài giây. Kiểm thử đầu cuối bắt các vấn đề tích hợp nhưng chậm và dễ vỡ.

Kiểm Thử Đơn Vị với pytest

  • pytest là framework kiểm thử Python tiêu chuẩn. Một bài kiểm thử là một hàm bắt đầu bằng test_ trong một file bắt đầu bằng test_:
# tests/test_utils.py

def test_normalise_image():
    import numpy as np
    image = np.array([0, 128, 255], dtype=np.uint8)
    result = normalise_image(image, mean=128, std=128)
    assert result.min() >= -1.0
    assert result.max() <= 1.0
    assert abs(result[1]) < 1e-6  # 128 normalised by mean=128 should be ~0

def test_normalise_empty():
    import numpy as np
    image = np.array([], dtype=np.uint8)
    result = normalise_image(image, mean=128, std=128)
    assert len(result) == 0
pytest tests/                     # chạy tất cả kiểm thử
pytest tests/test_utils.py        # chạy một file
pytest -v                         # đầu ra chi tiết
pytest -x                         # dừng ở lỗi đầu tiên
pytest -k "normalise"             # chạy kiểm thử khớp tên mẫu
pytest --tb=short                 # traceback ngắn hơn

Fixtures

  • Fixtures cung cấp thiết lập có thể tái sử dụng cho các kiểm thử. Thay vì lặp lại mã thiết lập trong mọi kiểm thử, hãy định nghĩa nó một lần:
import pytest

@pytest.fixture
def sample_dataset():
    """Create a small dataset for testing."""
    return {
        "inputs": torch.randn(10, 3, 32, 32),
        "labels": torch.randint(0, 10, (10,))
    }

@pytest.fixture
def trained_model():
    """Load a small pretrained model."""
    model = SmallModel()
    model.load_state_dict(torch.load("tests/fixtures/small_model.pt"))
    return model

def test_model_output_shape(trained_model, sample_dataset):
    output = trained_model(sample_dataset["inputs"])
    assert output.shape == (10, 10)  # batch_size x num_classes
  • Fixtures có thể có phạm vi (scope): scope="function" (mặc định, mỗi kiểm thử một lần), scope="module" (mỗi file một lần), scope="session" (mỗi lần chạy kiểm thử một lần). Dùng scope="session" cho thiết lập đắt đỏ như tải một mô hình.

Kiểm Thử Tham Số Hóa

  • Kiểm tra cùng một hàm với nhiều đầu vào mà không trùng lặp mã:
@pytest.mark.parametrize("input,expected", [
    ([1, 2, 3], 6),
    ([], 0),
    ([-1, 1], 0),
    ([1000000, 1000000], 2000000),
])
def test_sum(input, expected):
    assert sum(input) == expected

Mocking và Patching

  • Mocking thay thế một phụ thuộc thực sự bằng một phụ thuộc giả trong quá trình kiểm thử. Điều này cho phép bạn kiểm tra một hàm một cách cô lập, mà không cần cơ sở dữ liệu, API hay GPU.
from unittest.mock import patch, MagicMock

def test_training_logs_metrics():
    mock_logger = MagicMock()

    with patch("my_project.training.trainer.wandb") as mock_wandb:
        trainer = Trainer(logger=mock_logger)
        trainer.train_one_epoch()

        # verify that the trainer logged metrics
        mock_logger.log.assert_called()
        # verify it logged a loss value
        call_args = mock_logger.log.call_args
        assert "loss" in call_args[1]
  • Khi nào nên mock: các dịch vụ bên ngoài (API, cơ sở dữ liệu, lưu trữ đám mây), các thao tác đắt đỏ (tính toán GPU, I/O file lớn) và hành vi không tất định (bộ sinh số ngẫu nhiên, dấu thời gian).

  • Khi nào KHÔNG nên mock: mã của chính bạn. Nếu bạn mock mọi thứ, các kiểm thử của bạn xác minh rằng các mock hoạt động như mong đợi, chứ không phải mã của bạn hoạt động. Mock ở các ranh giới, kiểm tra logic của bạn trực tiếp.

Kiểm Thử Mã ML

  • Mã ML có những thách thức kiểm thử độc đáo: đầu ra có tính xác suất, việc huấn luyện chậm và "đúng" là một khái niệm mơ hồ.

Hạt Giống Tất Định

  • Đặt hạt giống ngẫu nhiên ở mọi nơi để làm cho các kiểm thử có thể tái tạo:
import random
import numpy as np
import torch

def set_seed(seed=42):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

Dung Sai Số

  • So sánh dấu phẩy động yêu cầu dung sai (chapter 13, IEEE 754):
# TỆ: so sánh chính xác thất bại do dấu phẩy động
assert model_output == 0.5

# TỐT: so sánh xấp xỉ
import numpy as np
assert np.isclose(model_output, 0.5, atol=1e-5)

# Cho tensor
assert torch.allclose(output, expected, atol=1e-4)

Những Gì Cần Kiểm Thử trong ML

  • Kiểm thử hình dạng (Shape tests): xác minh rằng đầu ra có kích thước mong đợi.
def test_model_output_shape():
    model = MyModel(d_model=256, n_classes=10)
    x = torch.randn(8, 32, 256)  # batch=8, seq=32, dim=256
    output = model(x)
    assert output.shape == (8, 10)
  • Luồng gradient: xác minh rằng gradient khác không cho các tham số có thể huấn luyện.
def test_gradients_flow():
    model = MyModel()
    x = torch.randn(4, 3, 32, 32)
    y = torch.randint(0, 10, (4,))

    output = model(x)
    loss = F.cross_entropy(output, y)
    loss.backward()

    for name, param in model.named_parameters():
        assert param.grad is not None, f"No gradient for {name}"
        assert param.grad.abs().sum() > 0, f"Zero gradient for {name}"
  • Overfit trên một batch: một mô hình phải có khả năng ghi nhớ một batch đơn. Nếu không, có điều gì đó căn bản sai.
def test_overfit_one_batch():
    model = MyModel()
    optimiser = torch.optim.Adam(model.parameters(), lr=1e-3)
    x, y = get_single_batch()

    for _ in range(100):
        loss = F.cross_entropy(model(x), y)
        loss.backward()
        optimiser.step()
        optimiser.zero_grad()

    assert loss.item() < 0.01, f"Cannot overfit one batch: loss={loss.item()}"
  • Xác thực dữ liệu: xác minh việc tải dữ liệu tạo ra đầu ra hợp lệ.
def test_dataset_basics():
    dataset = MyDataset("tests/fixtures/small_data.csv")
    assert len(dataset) > 0
    x, y = dataset[0]
    assert x.shape == (3, 224, 224)
    assert 0 <= y < 10
    assert not torch.isnan(x).any()
    assert not torch.isinf(x).any()
  • Tính tất định: cùng đầu vào + cùng seed → cùng đầu ra.
def test_determinism():
    set_seed(42)
    output1 = model(input_data)
    set_seed(42)
    output2 = model(input_data)
    assert torch.allclose(output1, output2)

Pipeline CI/CD

  • Tích hợp Liên tục (Continuous Integration - CI): tự động chạy kiểm thử trên mọi commit hoặc PR. Nếu kiểm thử thất bại, PR không thể được hợp nhất. Điều này ngăn mã hỏng đến được main.

  • GitHub Actions ví dụ (.github/workflows/ci.yml):

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install -e ".[dev]"
      - run: ruff check src/
      - run: mypy src/
      - run: pytest tests/ -v --tb=short
  • Pre-commit hooks: chạy các kiểm tra trước mỗi commit (cục bộ), bắt các vấn đề trước khi chúng đến CI:
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.3.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
pip install pre-commit
pre-commit install    # bây giờ hooks chạy trên mỗi git commit

Linting và Formatting

  • Linting bắt lỗi và vấn đề phong cách mà không cần chạy mã. Formatting thực thi phong cách nhất quán tự động.

  • Ruff: một linter và formatter Python nhanh (thay thế flake8, isort và black trong một công cụ):

ruff check src/          # lint
ruff check --fix src/    # lint và tự động sửa
ruff format src/         # format
  • mypy: trình kiểm tra kiểu tĩnh cho Python. Bắt lỗi kiểu trước runtime:
mypy src/
# src/model.py:42: error: Argument 1 to "forward" has incompatible type "int"; expected "Tensor"
  • Type hints làm cho mã tự giải thích và bắt lỗi:
def train(
    model: nn.Module,
    dataloader: DataLoader,
    optimiser: torch.optim.Optimizer,
    num_epochs: int = 10,
) -> float:
    """Train model and return final loss."""
    ...

Thực Hành Code Review Tốt

  • Đối với tác giả:

    • Tự review diff của bạn trước khi yêu cầu review. Bạn sẽ bắt được các vấn đề hiển nhiên.
    • Giữ PR nhỏ và tập trung. Một mối quan tâm mỗi PR.
    • Viết mô tả rõ ràng: cái gì, tại sao, cách kiểm tra.
    • Phản hồi mọi bình luận (dù chỉ là "done").
  • Đối với người review:

    • Hãy tử tế. Phê bình mã, không phải con người. "Chỗ này có thể rõ ràng hơn" chứ không phải "chỗ này khó hiểu."
    • Phân biệt các vấn đề chặn (blocking issues - lỗi, bảo mật) với các gợi ý (phong cách, đặt tên). Dùng nhãn: "nit:", "suggestion:", "blocking:".
    • Đặt câu hỏi thay vì ra lệnh. "Chuyện gì xảy ra nếu list này rỗng?" hữu ích hơn "xử lý trường hợp rỗng."
    • Phê duyệt kịp thời. Một PR chờ review nhiều ngày chặn tác giả và khuyến khích PR lớn, gộp batch (khó review hơn).