📉 Giảm variance với baseline và advantage

By Everwise Intern

Đồ án đang ở đâu

Bài trước bạn đã dựng khung đồ án và viết vòng lặp REINFORCE đầu tiên: chạy một episode, tính return, nhân return với log xác suất của hành động đã chọn, rồi cập nhật trọng số policy. Vòng lặp đó chạy được, nhưng nếu bạn đã thử, bạn sẽ thấy đường cong phần thưởng nhảy loạn và kết quả phụ thuộc rất mạnh vào seed.

Chặng này của đồ án xử lý đúng chỗ đau đó bằng hai thay đổi nhỏ trong code: baseline (đường cơ sở) và advantage (lợi thế). Mục tiêu không phải đổi thuật toán, mà là làm cho cùng một thuật toán học êm hơn, ít phụ thuộc may mắn hơn.

Vì sao đường học rung lắc

Ước lượng gradient mà bạn đang dùng có dạng:

$$ \nabla_\theta J(\theta) \approx \frac{1}{N} \sum_{i=1}^{N} \sum_{t=0}^{T} \nabla_\theta \log \pi_\theta(a_t \mid s_t), G_t $$

Return $G_t$ là tổng phần thưởng cộng dồn của cả episode, nên nó biến động rất mạnh giữa các lần chạy. Hệ quả: cùng một hành động tốt, có episode gradient đẩy rất mạnh, có episode đẩy rất nhẹ, thậm chí đẩy ngược. Policy dao động thay vì tiến đều.

Cách sửa: trừ đi một đại lượng $b(s_t)$ khỏi $G_t$. Điều đáng ngạc nhiên là việc này không làm lệch hướng học. Với mọi hàm $b$ không phụ thuộc vào hành động $a$:

$$ \mathbb{E}{a \sim \pi\theta(\cdot \mid s)}\left[\nabla_\theta \log \pi_\theta(a \mid s), b(s)\right] = b(s) \sum_a \nabla_\theta \pi_\theta(a \mid s) = b(s), \nabla_\theta 1 = 0 $$

Vì tổng xác suất trên mọi hành động luôn bằng 1, đạo hàm của nó bằng 0. Kết luận: baseline không tạo bias (độ lệch), chỉ giảm variance (phương sai). Đây là lý do baseline là kỹ thuật giảm variance an toàn về mặt toán.

Ba lựa chọn baseline cho đồ án:

  • Hằng số theo batch: lấy trung bình return trong batch hiện tại. Rẻ, dễ, hợp với đồ án nhỏ.
  • Trung bình chạy (running average): cập nhật sau mỗi episode bằng return của các episode trước. Không phụ thuộc hành động hiện tại.
  • Value function $V(s)$: một mạng phụ học dự đoán return từ trạng thái. Giảm variance mạnh nhất, nhưng thêm một mạng phải huấn luyện.
flowchart TD
    A["🎮<br/>Chạy episode"] --> B["📈<br/>Return G_t"]
    B --> C["📏<br/>Baseline b(s_t)"]
    C --> D["⚖️<br/>Advantage A_t"]
    D --> E["🔧<br/>Chuẩn hóa batch"]
    E --> F["🔄<br/>Cập nhật policy"]

Advantage: tín hiệu có nghĩa hơn return thô

Advantage được định nghĩa đơn giản là phần return vượt mức tham chiếu:

$$ A_t = G_t - b(s_t) $$

Dấu của $A_t$ trả lời câu hỏi "hành động này tốt hơn mức trung bình bao nhiêu". $A_t > 0$ thì tăng xác suất hành động, $A_t < 0$ thì giảm. Nhờ vậy, ngay cả khi mọi return đều dương, hành động tệ nhất trong batch vẫn bị giảm xác suất, điều mà REINFORCE gốc không làm được.

Một bước nhỏ nữa rất nên làm: chuẩn hóa (normalize) advantage trong batch, tức trừ trung bình rồi chia độ lệch chuẩn. Lý do thực dụng: mỗi batch có thang phần thưởng khác nhau, chuẩn hóa giữ cho độ lớn gradient ổn định và learning rate giữ nguyên ý nghĩa.

Thử tính tay trước khi sửa code

Cho ba episode trong một batch với return $[2, 4, 9]$ và baseline là trung bình batch. Tính advantage thô, rồi advantage sau chuẩn hóa.

Xem lời giải

Baseline $b = (2 + 4 + 9) / 3 = 5$. Advantage thô: $[-3, -1, 4]$, trung bình đúng bằng 0.

Độ lệch chuẩn: $\sqrt{(9 + 1 + 16)/3} = \sqrt{26/3} \approx 2{,}94$.

Sau chuẩn hóa: $[-1{,}02;\ -0{,}34;\ 1{,}36]$. Chú ý thứ tự tốt xấu không đổi, chỉ có thang đo thay đổi.

Sửa code đồ án trong ba bước

Tách phần tính toán ra thành hàm riêng để dễ kiểm tra:

import numpy as np

def compute_returns(rewards, gamma=0.99):
    """Return-to-go G_t cho từng bước trong episode."""
    G, out = 0.0, []
    for r in reversed(rewards):
        G = r + gamma * G
        out.append(G)
    return out[::-1]

def compute_advantages(returns, values=None, eps=1e-8):
    returns = np.asarray(returns, dtype=np.float32)
    if values is None:
        baseline = returns.mean()          # baseline hằng số theo batch
    else:
        baseline = np.asarray(values)      # baseline theo trạng thái V(s_t)
    adv = returns - baseline
    return (adv - adv.mean()) / (adv.std() + eps)

# trong vòng lặp cập nhật: advantage chỉ là trọng số, không lan gradient
adv = compute_advantages(returns)
loss = -(log_probs * torch.tensor(adv, dtype=torch.float32)).sum()

Hai chi tiết dễ sai: log_probs phải là log xác suất của đúng hành động đã chọn, và advantage phải được tách khỏi đồ thị tính toán (detach) vì nó là hằng số nhân vào, không phải biến cần đạo hàm.

Lộ trình 40 phút

  1. Viết compute_returns và compute_advantages, kiểm tra bằng ví dụ tính tay ở trên (5 phút).
  2. Chạy lại vòng lặp với baseline là trung bình batch, lưu return từng episode (10 phút).
  3. Chạy thêm biến thể dùng trung bình chạy hoặc value function nếu kịp (15 phút).
  4. Chạy 3 seed cho mỗi biến thể (gốc, có baseline), ghi lại return trung bình và độ lệch chuẩn (10 phút).

Tiêu chí nghiệm thu

Tiêu chí Cách kiểm tra
compute_advantages chạy đúng Đầu ra cùng độ dài với returns, trung bình xấp xỉ 0, không có NaN
Loss hữu hạn In loss ở 10 vòng đầu; nếu ra nan thì xem lại phép chia cho độ lệch chuẩn và dấu log
Advantage đã tách khỏi đồ thị Kiểm tra adv.requires_grad là False (hoặc dùng torch.tensor)
So sánh công bằng Hai biến thể dùng cùng siêu tham số, cùng số episode, cùng 3 seed
Đường học ổn định hơn Đường return trung bình của bản có baseline dao động ít hơn và có xu hướng tăng rõ hơn
Có ghi chép Một bảng nhỏ: biến thể, return trung bình, độ lệch chuẩn trên 3 seed

Cạm bẫy thường gặp và cách gỡ

Bốn lỗi dưới đây chiếm phần lớn thời gian debug của đồ án này. Đọc trước rồi hẵng sửa, vì mỗi lỗi đều có triệu chứng rất dễ nhận ra.

1. Quên detach advantage. Nếu bạn lấy adv từ một tensor còn gắn với đồ thị tính toán, PyTorch sẽ lan gradient ngược qua cả baseline. Triệu chứng: loss giảm nhưng policy học lệch hẳn. Cách gỡ: bọc bằng .detach() hoặc tạo tensor mới.

2. Chia cho độ lệch chuẩn bằng 0. Với batch nhỏ hoặc khi mọi return bằng nhau, adv.std() bằng 0. Cách gỡ: luôn cộng thêm eps, ví dụ 1e-8.

3. Baseline phụ thuộc hành động. Baseline chỉ được phụ thuộc trạng thái, ví dụ $b(s_t)$. Nếu bạn lấy baseline là return của chính hành động vừa chọn, ước lượng gradient sẽ bị lệch, mất luôn tính chất không lệch (unbiased) vốn có.

4. Kết luận từ một seed. Variance chính là thứ đang được đo, nên một lần chạy không nói lên điều gì. Cách gỡ: chạy 3 seed và so cả trung bình lẫn độ lệch chuẩn.

Bài tập nhỏ: dấu hiệu nào cho thấy bạn quên detach?

Loss vẫn giảm đều nhưng return trung bình lại thấp hơn cả bản REINFORCE gốc, và nếu in adv.requires_grad ra thì thấy True. Đó là dấu hiệu gradient đang chảy qua nhánh baseline, kéo theo hướng cập nhật sai.

Tự kiểm tra: baseline, advantage và các con số cần nhớ

Baseline chỉ phụ thuộc trạng thái nên không làm thay đổi _____ của ước lượng gradient, nó chỉ làm giảm _____. Advantage được định nghĩa là A(s, a) = Q(s, a) trừ _____. Trong REINFORCE có baseline, trọng số nhân với log xác suất là _____, viết là G_t − b(s_t). Khi chuẩn hóa advantage theo batch, ta cộng thêm _____ vào mẫu số để tránh chia cho 0. Trước khi nhân với log xác suất, advantage phải được _____ khỏi đồ thị tính toán.

Front: Vì sao REINFORCE gốc có variance cao? Back: Vì trọng số là return thô G_t, biến thiên rất lớn giữa các episode.

Front: Baseline b(s) trong công thức cập nhật là gì? Back: Một giá trị ước lượng không phụ thuộc hành động, thường là V(s) hoặc trung bình return của batch.

Front: Vì sao thêm baseline không làm ước lượng gradient bị lệch? Back: Vì kỳ vọng của log-gradient nhân với một đại lượng chỉ phụ thuộc trạng thái bằng 0.

Front: Advantage A(s, a) được định nghĩa thế nào? Back: A = Q(s, a) − V(s), tức phần return tốt hơn mức trung bình của trạng thái đó.

Front: Return-to-go G_t khác return toàn episode ở đâu? Back: G_t chỉ cộng phần thưởng từ bước t trở đi, bỏ các phần thưởng trước đó.

Front: Hai baseline đơn giản nhất để làm trong đồ án? Back: Trung bình return của batch và trung bình chạy (moving average) qua các episode.

Front: Chuẩn hóa advantage theo batch gồm mấy bước? Back: Trừ trung bình rồi chia cho độ lệch chuẩn, cộng thêm epsilon ở mẫu số.

Front: Vì sao phải detach advantage? Back: Vì nó là trọng số hằng, không phải biến cần lan gradient về phía policy.

Front: Dấu của loss khi muốn gradient ascent? Back: Loss = −(log_prob × advantage); tối thiểu hóa loss tức là tối đa hóa mục tiêu.

Front: Làm sao so sánh công bằng bản gốc và bản có baseline? Back: Cùng siêu tham số, cùng số episode, chạy nhiều seed và so return trung bình kèm độ lệch chuẩn.

Front: Baseline có làm đổi nghiệm tối ưu của policy không? Back: Không, nó chỉ giảm phương sai của ước lượng gradient.

Front: Nếu độ lệch chuẩn của advantage bằng 0 thì sao? Back: Phép chia mất ổn định, nên phải cộng epsilon vào mẫu số.

Đồ án đã có thêm gì sau bài này

Bạn vừa nâng cấp vòng lặp REINFORCE từ chỗ nhân thẳng return thô thành một bản có baseline và advantage: hàm compute_returns cho return-to-go, hàm compute_advantages cho trọng số đã chuẩn hóa, và một bảng so sánh hai biến thể trên nhiều seed. Đó chính là phần giảm variance (variance reduction) mà mọi bản REINFORCE dùng trong thực tế đều có.

Bài tiếp theo sẽ lấy đúng hai biến thể này, huấn luyện dài hơn, vẽ đường cong học (learning curve) bằng matplotlib và viết phần đánh giá kết quả: nhìn vào đâu để nói rằng policy đang học tốt lên, và con số nào đủ để bảo vệ kết luận đó.