Học AI & Lập trình

Hàm mất mát cho phân loại: cross-entropy, class weight, focal loss

Hiểu softmax và cross-entropy bằng con số cụ thể, vì sao dữ liệu ít ảnh NG làm mô hình lười, và cách xử lý bằng trọng số lớp hoặc focal loss trong PyTorch.

Hàm mất mát cho phân loại: cross-entropy, class weight, focal loss
Nội dung bài viết
  1. 1. Softmax: biến điểm số thành xác suất
  2. 2. Cross-entropy: phạt theo xác suất của nhãn đúng
  3. 3. Vấn đề dữ liệu mất cân bằng
  4. 4. Cách 1: trọng số lớp (class weight)
  5. 5. Cách 2: focal loss
  6. Chọn loss nào?

Mạng nơ-ron học bằng cách giảm dần một con số gọi là loss (hàm mất mát). Chọn sai loss, mô hình vẫn "học" rất chăm chỉ nhưng học sai điều bạn cần. Trong bài toán kiểm tra lỗi, sai lầm kinh điển là: dữ liệu có 95% ảnh OK, mô hình học được cách luôn trả lời OK, loss rất thấp, còn ảnh NG thì lọt hết. Bài này giải thích softmax, cross-entropy, và hai cách xử lý dữ liệu mất cân bằng: trọng số lớp và focal loss.

Nên đọc trước: PyTorch cơ bản: tensor, autograd và gradient descent từ đầu với NumPy.

1. Softmax: biến điểm số thành xác suất

Lớp cuối của mạng phân loại xuất ra các con số thô gọi là logits, mỗi lớp một số, có thể âm hoặc dương tùy ý. Softmax biến chúng thành xác suất: lấy exp từng số rồi chia cho tổng.

import numpy as np


def softmax(z):
    z = z - z.max()                  # trừ max để tránh tràn số exp
    e = np.exp(z)
    return e / e.sum()


logits = np.array([2.0, 0.5, -1.0])  # đầu ra thô của mạng: OK, trầy xước, móp
p = softmax(logits)
print("xác suất:", p.round(3), "tổng =", p.sum().round(3))

for nhan in range(3):
    print(f"nhãn đúng = {nhan}: loss = {-np.log(p[nhan]):.3f}")
xác suất: [0.786 0.175 0.039] tổng = 1.0
nhãn đúng = 0: loss = 0.241
nhãn đúng = 1: loss = 1.741
nhãn đúng = 2: loss = 3.241

Dòng z - z.max() không làm thay đổi kết quả (softmax không đổi khi cộng cùng một hằng số vào mọi logit) nhưng tránh được exp(1000) tràn số.

2. Cross-entropy: phạt theo xác suất của nhãn đúng

Cross-entropy cho một mẫu rất đơn giản: loss = −log(xác suất mô hình gán cho nhãn đúng). Ở ví dụ trên, nếu ảnh thật sự là OK (nhãn 0), mô hình gán 78,6% nên loss nhỏ (0,241). Nếu ảnh thật sự bị móp (nhãn 2), mô hình chỉ gán 3,9% nên bị phạt nặng (3,241).

Trong PyTorch, nn.CrossEntropyLoss nhận thẳng logits, tự tính softmax và log bên trong:

import torch
import torch.nn as nn
import torch.nn.functional as F

logits_t = torch.tensor([[2.0, 0.5, -1.0]])
nhan_t = torch.tensor([1])                       # nhãn đúng: trầy xước
print(f"nn.CrossEntropyLoss: {nn.CrossEntropyLoss()(logits_t, nhan_t).item():.3f}")
nn.CrossEntropyLoss: 1.741

Khớp với kết quả tự tính 1,741.

Lỗi rất hay gặp: softmax hai lầnĐừng thêm nn.Softmax ở cuối mô hình rồi đưa vào nn.CrossEntropyLoss. Hàm loss đã tự làm softmax; áp thêm một lần nữa khiến gradient yếu đi và mô hình học rất chậm. Chỉ dùng torch.softmax khi suy luận, để đọc xác suất.

Vì sao dùng log?

Hãy xem loss tăng thế nào khi mô hình càng sai:

for p_dung in [0.9, 0.6, 0.3, 0.05]:
    print(f"p_dung = {p_dung:4}: loss = {-np.log(p_dung):.3f}")
p_dung =  0.9: loss = 0.105
p_dung =  0.6: loss = 0.511
p_dung =  0.3: loss = 1.204
p_dung = 0.05: loss = 2.996

Đoán đúng với 90% chắc chắn gần như không bị phạt; tự tin sai (chỉ 5% cho nhãn đúng) bị phạt gấp gần 30 lần. Loss tiến tới vô cùng khi xác suất nhãn đúng tiến về 0, nên mô hình bị "ép" không được tự tin sai.

3. Vấn đề dữ liệu mất cân bằng

Dữ liệu nhà máy thường có rất ít ảnh NG. Hãy xem một "mô hình lười" luôn tự tin trả lời OK trên 950 ảnh OK và 50 ảnh NG:

# 1000 mẫu: 950 OK (nhãn 0), 50 NG (nhãn 1)
nhan = torch.cat([torch.zeros(950, dtype=torch.long), torch.ones(50, dtype=torch.long)])
# Mô hình "lười": luôn tự tin rằng mọi thứ đều OK
logits_luoi = torch.tensor([[3.0, -3.0]]).repeat(1000, 1)

ce = nn.CrossEntropyLoss()
print(f"loss trung bình: {ce(logits_luoi, nhan).item():.3f}")
print(f"loss trên mẫu OK: {ce(logits_luoi[:950], nhan[:950]).item():.3f}")
print(f"loss trên mẫu NG: {ce(logits_luoi[950:], nhan[950:]).item():.3f}")
loss trung bình: 0.302
loss trên mẫu OK: 0.002
loss trên mẫu NG: 6.002

Mỗi ảnh NG bị phạt rất nặng (6,0), nhưng vì chỉ chiếm 5% nên loss trung bình vẫn thấp (0,30). Mô hình thật trong quá trình huấn luyện dễ mắc kẹt ở trạng thái này: accuracy 95%, loss nhỏ, nhưng không phát hiện được lỗi nào.

4. Cách 1: trọng số lớp (class weight)

Ý tưởng: mỗi mẫu NG được tính nặng hơn. Công thức phổ biến (giống class_weight="balanced" trong scikit-learn): trọng số = tổng số mẫu / (số lớp × số mẫu của lớp).

so_mau = torch.tensor([950.0, 50.0])
trong_so = so_mau.sum() / (2 * so_mau)           # cùng công thức "balanced" của scikit-learn
print("trọng số lớp:", trong_so)

ce_w = nn.CrossEntropyLoss(weight=trong_so)
print(f"loss có trọng số: {ce_w(logits_luoi, nhan).item():.3f}")
trọng số lớp: tensor([ 0.5263, 10.0000])
loss có trọng số: 3.002

Lúc này 50 ảnh NG có tổng trọng số (50 × 10 = 500) bằng 950 ảnh OK (950 × 0,526 ≈ 500). Loss của mô hình lười tăng từ 0,30 lên 3,0, đủ để buộc mô hình phải học nhận ra NG. Lưu ý: khi có weight, PyTorch chia cho tổng trọng số chứ không chia cho số mẫu.

Một cách tương đương là lấy mẫu lại (oversampling): dùng torch.utils.data.WeightedRandomSampler để ảnh NG xuất hiện thường xuyên hơn trong mỗi epoch. Hai cách cho hiệu quả tương tự; đừng dùng cả hai cùng lúc với trọng số đầy đủ, vì sẽ bù quá tay.

5. Cách 2: focal loss

Focal loss được giới thiệu trong bài báo RetinaNet (Lin và cộng sự, 2017) cho object detection, nơi số vùng nền áp đảo số vùng có vật. Ý tưởng: nhân cross-entropy với hệ số (1 − p_t)^γ, trong đó p_t là xác suất của nhãn đúng.

  • Mẫu dễ (mô hình đã chắc, p_t gần 1): hệ số gần 0, gần như không đóng góp vào loss.
  • Mẫu khó (mô hình phân vân hoặc sai): hệ số lớn, giữ lại phần lớn loss.

Với γ = 0, focal loss chính là cross-entropy. PyTorch không có sẵn focal loss nhiều lớp trong torch.nn, nhưng tự viết rất ngắn:

def focal_loss(logits, nhan, gamma=2.0, alpha=None):
    """Focal loss nhiều lớp: (1 - p_t)^gamma * cross-entropy."""
    log_p = F.log_softmax(logits, dim=1)
    log_pt = log_p.gather(1, nhan.unsqueeze(1)).squeeze(1)   # log xác suất của nhãn đúng
    pt = log_pt.exp()
    loss = -((1 - pt) ** gamma) * log_pt
    if alpha is not None:                                   # trọng số theo lớp (tùy chọn)
        loss = alpha[nhan] * loss
    return loss.mean()


de = torch.tensor([[4.0, -4.0]])     # mẫu dễ: OK, mô hình rất chắc (p_t ≈ 0.9997)
kho = torch.tensor([[0.2, 0.0]])     # mẫu khó: OK nhưng mô hình phân vân (p_t ≈ 0.55)
nhan_ok = torch.tensor([0])
for ten, lg in [("dễ", de), ("khó", kho)]:
    ce_v = F.cross_entropy(lg, nhan_ok).item()
    fl_v = focal_loss(lg, nhan_ok).item()
    print(f"mẫu {ten:3}: CE = {ce_v:.2e}  focal = {fl_v:.2e}  giảm {ce_v / fl_v:.0f} lần")
mẫu dễ : CE = 3.35e-04  focal = 3.77e-11  giảm 8892837 lần
mẫu khó: CE = 5.98e-01  focal = 1.21e-01  giảm 5 lần

Mẫu dễ bị giảm hàng triệu lần, mẫu khó chỉ giảm 5 lần. Hàng nghìn ảnh OK "dễ" không còn lấn át gradient; mô hình dồn sức vào các ảnh khó, thường chính là ảnh NG và ảnh OK trông giống NG.

Với bài toán nhị phân dùng sigmoid (một đầu ra), torchvision có sẵn torchvision.ops.sigmoid_focal_loss với mặc định alpha=0.25, gamma=2, đúng cấu hình dùng trong RetinaNet.

Chọn loss nào?

Tình huốngGợi ý
Dữ liệu tương đối cân bằngnn.CrossEntropyLoss thông thường
Mất cân bằng vừa phải (ví dụ 1:5 đến 1:20)Cross-entropy + weight, hoặc WeightedRandomSampler
Mất cân bằng nặng, nhiều mẫu dễ (detection, segmentation vết lỗi nhỏ)Focal loss, bắt đầu với γ = 2
Gần như không có ảnh NGChuyển sang anomaly detection, chỉ học từ ảnh OK
Loss không thay thế được ngưỡng quyết địnhDù dùng loss nào, bước cuối vẫn là chọn ngưỡng xác suất NG phù hợp với chi phí lọt lỗi và chi phí loại nhầm của nhà máy. Xem tiêu chí OK/NG, escape và overkill.

Luôn theo dõi recall và precision của lớp NG trên tập validation, đừng chỉ nhìn loss và accuracy. Loss giảm đều không có nghĩa là mô hình đã bắt được lỗi.

Nguồn tham khảo: Lin và cộng sự, Focal Loss for Dense Object Detection (arXiv:1708.02002), PyTorch: CrossEntropyLoss, torchvision.ops.sigmoid_focal_loss.

Ban biên tập HọcFree

Chúng tôi viết những bài hướng dẫn dễ hiểu, thực tế để ai cũng có thể tự học lập trình và ngoại ngữ, hoàn toàn miễn phí.