Q-Learning: Học Bằng Thử Và Sai

Reinforcement Learning · Kỳ 1

Nếu bạn từng nghe chuyện một chương trình máy tính tự học chơi cờ vây giỏi hơn nhà vô địch thế giới, hay một cánh tay robot tự học cách cầm nắm đồ vật mà chẳng ai lập trình sẵn từng động tác, thì phần lớn thời gian, thứ đứng sau những câu chuyện đó là Reinforcement Learning — học tăng cường. Nghe hoành tráng thật, nhưng gốc rễ của cả hệ thuật toán đồ sộ ấy lại bắt đầu từ một ý tưởng rất nhỏ: học bằng thử và sai, không hơn không kém.

Để thấy ý tưởng đó trần trụi nhất, không cần tới AlphaGo hay cánh tay robot nào cả — chỉ cần một cái lưới ô vuông trống trơn, và một con robot tên là Pixel.

Pixel nhỏ, hình dáng như một con chuột, bị thả xuống giữa lưới ô vuông đó mà không có gì trong tay: không bản đồ, không ai chỉ đường, cũng chẳng ai nói cho nó biết đích đến nằm ở đâu, hay "đi đúng" nghĩa là gì.

Nhưng nó có một thứ, dù chỉ một: sau mỗi hành động, thế giới lặng lẽ trả lại một con số. Số dương giống như một cái gật đầu. Số âm giống một cái lắc đầu. Còn phần lớn thời gian, chỉ có im lặng.

Bài toán của Pixel

Với chừng đó thông tin, nhiệm vụ của Pixel là tối đa hoá tổng những con số ấy, cộng dồn theo thời gian. Không ai dạy nó luật chơi — nó phải tự suy ra, bằng cách chơi.

Đó chính là bài toán Reinforcement Learning đặt ra. Và Q-Learning là một trong những câu trả lời lâu đời và cứng cựa nhất cho bài toán đó.

Nghe hơi giống cách người ta huấn luyện một con chó: không giải thích khái niệm "ngồi xuống" là gì, chỉ thưởng snack mỗi khi con chó tình cờ làm đúng. Nhưng có một khác biệt: chẳng có bàn tay nào đứng ngoài chỉnh hành vi của Pixel cả. Nó phải tự mò, tự thử, tự sai, và — quan trọng nhất — tự ghi nhớ.

Chính chữ "tự ghi nhớ" đó là chỗ Q-Learning bước vào.

Model-free · Value-based · Off-policy

Ba tính chất của Q-Learning

Q-Learning có ba tính chất định nghĩa nên nó:

  • Model-free — Pixel không có bản đồ nội bộ, không đoán trước hậu quả. Nó hành động, thấy kết quả thật, rồi học.
  • Value-based — thay vì học thẳng một chính sách hành động, Pixel học giá trị (Q-value) của từng cặp trạng thái và hành động, rồi suy ra lựa chọn tốt nhất từ đó.
  • Off-policy — tính chất tinh tế nhất trong ba. Mỗi lần cập nhật, giá trị mới = phần thưởng vừa nhận + giá trị của hành động tốt nhất có thể ở trạng thái kế tiếp, chứ không phải hành động Pixel thực sự sẽ đi.

Ở đây, Q-Learning có một người họ hàng gần: SARSA.

off-policy

Q-Learning

Cập nhật = phần thưởng + giá trị hành động tốt nhất có thể ở trạng thái kế tiếp — bất kể Pixel có thực sự chọn nó hay không.

on-policy

SARSA

Cập nhật = phần thưởng + giá trị hành động Pixel thực sự sẽ thực hiện ở trạng thái kế tiếp — kể cả khi đó là một quyết định dại dột.

Khác biệt nhỏ này quyết định tính cách của agent khi đứng trước nguy hiểm. Ví dụ cụ thể: giả sử đường ngắn nhất tới đích buộc Pixel phải đi sát một cái bẫy. Q-Learning, vì luôn giả định "từ giờ mình sẽ luôn chọn đúng", không sợ đi sát mép — nó nghĩ mình sẽ không bao giờ trượt chân. SARSA thì biết rõ hơn về chính mình: nó tính luôn cả khả năng, giữa lúc học, thỉnh thoảng nó vẫn chọn một hành động ngẫu nhiên để khám phá — nên nó thận trọng hơn, chấp nhận đi vòng xa bẫy hơn một chút, dù tốn thêm vài bước.

Bạn sẽ thấy đúng hiệu ứng này ở demo mê cung phía sau: đường Q-Learning vẽ ra đôi khi đi sát bẫy tới mức rợn người.

Giờ thì Pixel vẫn đang đứng ở góc lưới ô vuông, chưa biết gì cả.

Hãy xem nó bắt đầu như thế nào.

Trạng thái, hành động, phần thưởng

Pixel đứng ở ô góc trái. Việc đầu tiên nó nhận ra: nó đang ở một chỗ cụ thể, trong một tình huống cụ thể — gọi là trạng thái (state). Mỗi ô trên lưới là một trạng thái khác nhau.

Từ trạng thái đó, nó có thể làm vài việc: đi lên, đi xuống, đi trái, đi phải. Mỗi lựa chọn như vậy là một hành động (action).

Nó chọn đại một hướng — đi lên. Ô mới. Trạng thái mới. Và ngay sau đó, thế giới trả lời bằng một con số: phần thưởng (reward), đúng như đã nói ở phần trước.

Ba thứ đó — trạng thái, hành động, phần thưởng — là toàn bộ những gì Pixel có để làm việc. Câu hỏi còn lại: làm sao từ đó suy ra hành động nào tốt?

Q-Learning gán cho mỗi cặp (trạng thái, hành động) một con số gọi là Q-value — ước tính "nếu đứng ở đây và làm việc này, về sau mình sẽ thu được bao nhiêu điểm". Toàn bộ những con số đó gộp lại thành một bảng, gọi là Q-table.

Ban đầu, Q-table trống trơn. Mỗi bước đi, Pixel cập nhật lại đúng một ô trong bảng đó, dựa trên phần thưởng vừa nhận và ước tính ở bước kế tiếp. Vòng lặp cứ thế lặp lại: hành động → phản hồi → cập nhật → hành động tiếp theo.

Trước khi viết công thức, một cái tên đáng biết: đây là kiểu học Temporal Difference (TD) — "học theo chênh lệch thời gian". Ý tưởng: so sánh ước tính ở bước này với một ước tính mới hơn, đáng tin hơn một chút (vì đã cộng thêm phần thưởng thật vừa nhận), rồi thu hẹp khoảng cách giữa hai ước tính đó. Không cần đợi hết cả episode mới học — học ngay sau mỗi bước.

Công thức cập nhật, viết ra, trông như vầy:

Q(s, a) ← Q(s, a) + α [ r + γ · maxa′ Q(s′, a′) − Q(s, a) ]
  • Q(s, a) — ước tính hiện tại cho cặp trạng thái–hành động đó. Vì sao có nó: đây là thứ duy nhất Pixel thực sự "nhớ" giữa các bước — không có nó thì chẳng có gì để sửa.
  • α (learning rate) — tin bao nhiêu vào thông tin vừa nhận. Vì sao có nó: nếu tin 100% vào mỗi lần thử, một lần xui rủi sẽ xoá sạch mọi thứ đã học được trước đó. α giúp việc học tích luỹ dần qua nhiều lần thử, thay vì đảo lộn theo từng kết quả đơn lẻ.
  • r — phần thưởng vừa nhận được. Vì sao có nó: đây là con số thật duy nhất trong cả công thức — mọi thành phần khác đều chỉ là ước tính.
  • γ (discount factor) — coi trọng tương lai bao nhiêu so với hiện tại. Vì sao có nó: không có nó, một phần thưởng ở 100 bước sau có giá trị ngang một phần thưởng ngay bây giờ — vừa vô lý, vừa có thể khiến giá trị cộng dồn tới vô hạn.
  • max Q(s′, a′) — giá trị tốt nhất có thể ở trạng thái kế tiếp. Vì sao có nó: đây chính là phần "off-policy" nói ở đầu bài — Pixel học từ hành động tốt nhất có thể, không phải hành động nó sắp thực sự làm.

Phần trong ngoặc vuông — đúng cái "chênh lệch thời gian" nhắc ở trên — có một cái tên riêng: TD error. Cả công thức chỉ đang làm một việc: thu hẹp khoảng cách đó một chút, không thu hẹp hết.

Nghe thì trừu tượng. Nhưng nếu bỏ hết mọi lựa chọn hành động, chỉ giữ lại đúng một phép cập nhật duy nhất, chuyện gì sẽ xảy ra?

Thử xem.

Demos

Q-Learning, nhìn thấy tận mắt

Đọc công thức là một chuyện. Thấy nó chạy thật, từng bước một, là chuyện khác hẳn.

Hai demo dưới đây cùng một mục đích: giúp bạn nhìn thấy Q-Learning học, không chỉ đọc về nó.

  • Demo 1 cắt bỏ hết mọi thứ gây rối — không có lựa chọn, không có nhiều hướng đi — chỉ giữ lại đúng phép cập nhật, để bạn thấy giá trị lan dần từ đích ngược về điểm xuất phát.
  • Demo 2 trả lại cho Pixel quyền lựa chọn: nhiều hành động, một mê cung nhỏ, và phải tự tìm đường.

Demo 1 · Một hành động duy nhất

Năm ô xếp thành một hàng. Pixel luôn xuất phát ở ô 0, và chỉ có đúng một việc để làm: đi sang phải. Không có lựa chọn nào khác. Ô cuối (ô 4) là đích, thưởng +10; mọi bước khác không thưởng gì cả. Bấm "Chạy 1 episode" từng lần, quan sát ô nào sáng lên trước — và ô nào phải đợi.

Learning rate α 0.50
Discount γ 0.90
Số episode đã chạy: 0
ô 0 · xuất phát0.00
ô 10.00
ô 20.00
ô 30.00
ô 4 · đích+10

Ở episode đầu, chỉ ô 3 — ô ngay sát đích — nhận được gì đó. Phải tới episode sau, ô 2 mới "biết" rằng đi tiếp sẽ dẫn tới một ô có giá trị. Cứ thế, giá trị lan ngược từng bước một, về phía ô 0.

Giờ trả lại cho Pixel những gì nó vừa mất: nhiều ô để đi, nhiều hướng để chọn.

Phép cập nhật vẫn y hệt — chỉ có một chỗ thay đổi: giờ nó phải quyết định sẽ làm gì tiếp theo, trước khi biết chuyện gì xảy ra.

Khai thác hay khám phá?

Pixel giờ đã có chút hiểu biết: vài ô nó tin là ổn, một hai ô nó nghi là bẫy. Nhưng "tin" và "nghi" không phải "chắc chắn."

Nếu Pixel luôn chọn hành động nó tin là tốt nhất — gọi là khai thác (exploitation) — nó có thể mắc kẹt mãi với con đường tạm ổn tìm ra đầu tiên, trong khi một con đường ngắn hơn chưa từng được thử. Nếu nó luôn chọn hành động ngẫu nhiên — khám phá (exploration) — nó chẳng bao giờ dùng được những gì đã học.

Cách giải quyết dùng trong demo và code ở bài này gọi là ε-greedy:

  • với xác suất ε, chọn một hành động ngẫu nhiên (khám phá)
  • với xác suất 1 − ε, chọn hành động Pixel tin là tốt nhất, tức argmax Q(s, a) (khai thác)

ε càng lớn, Pixel càng hay thử cái mới. ε càng nhỏ, Pixel càng bám sát những gì đã biết. Ở demo dưới, kéo thanh trượt "Khám phá ε" để thấy tận mắt hai thái cực đó.

Demos

Demo 2 · Giờ thì Pixel phải chọn

Một lưới 5×5. Pixel xuất phát ở góc trên-trái. Đích ở góc dưới-phải, thưởng +10. Có hai ô bẫy, chạm vào là mất −10 và kết thúc. Mỗi bước đi bình thường tốn −1 — đi càng dài, điểm càng thấp. Bấm "Train nhanh" để Pixel tự chạy hàng trăm lượt thử–sai, rồi bấm "Xem Pixel đi" để xem nó áp dụng những gì học được.

Learning rate α 0.30
Discount γ 0.90
Khám phá ε 0.20
Số ván đã train: 0
Ván xem gần nhất: —
Q cao (đường tốt) Q thấp / gần bẫy Đích Bẫy

Mũi tên trong mỗi ô là hành động Pixel tin là tốt nhất ở đó — chính là argmax Q(s, a). Càng train nhiều, mũi tên càng ổn định thành một con đường rõ ràng vòng qua hai cái bẫy.

Ở trên là Q-Learning chạy ngay trong trình duyệt, viết bằng JavaScript, cho dễ nhìn.

Nhưng nếu bạn muốn tự tay chạy lại, sửa phần thưởng, đổi kích thước lưới — thì mã Python bên dưới là bản để bạn nghịch.

Cài đặt bằng Python

Đúng bài toán ở demo trên — lưới 5×5, một đích, hai bẫy — viết lại bằng Python và NumPy:

import numpy as np
import random

N = 5
START = (0, 0)
GOAL = (4, 4)
TRAPS = {(1, 3), (3, 1)}
ACTIONS = [(-1, 0), (1, 0), (0, -1), (0, 1)]  # lên, xuống, trái, phải

alpha = 0.3
gamma = 0.9
epsilon = 0.2
episodes = 200
max_steps = 100

Q = np.zeros((N, N, len(ACTIONS)))


def step(state, action):
    r, c = state
    dr, dc = ACTIONS[action]
    nr, nc = r + dr, c + dc
    if not (0 <= nr < N and 0 <= nc < N):
        nr, nc = r, c
    if (nr, nc) == GOAL:
        return (nr, nc), 10, True
    if (nr, nc) in TRAPS:
        return (nr, nc), -10, True
    return (nr, nc), -1, False


def choose_action(state, eps):
    if random.random() < eps:
        return random.randrange(len(ACTIONS))
    r, c = state
    return int(np.argmax(Q[r, c]))


for ep in range(episodes):
    state = START
    for t in range(max_steps):
        action = choose_action(state, epsilon)
        next_state, reward, done = step(state, action)
        r, c = state
        nr, nc = next_state
        best_next = 0 if done else np.max(Q[nr, nc])
        Q[r, c, action] += alpha * (reward + gamma * best_next - Q[r, c, action])
        state = next_state
        if done:
            break

Toàn bộ thuật toán nằm trong đúng một dòng: Q[r, c, action] += alpha * (reward + gamma * best_next - Q[r, c, action]) — công thức ở phần trước, viết lại bằng code.

Một chi tiết dễ bị bỏ sót: khi done là True (Pixel vừa tới đích hoặc sa bẫy), không có "bước kế tiếp" nào để tính giá trị tốt nhất — nên best_next phải là 0, không phải Q[nr, nc]. Bỏ chi tiết if done này thường không gây lỗi rõ ràng ngay (vì ô đích và ô bẫy không bao giờ được chọn làm điểm xuất phát của một bước đi, nên giá trị của chúng vốn dĩ vẫn luôn là 0) — nhưng viết tường minh ra vẫn tốt hơn: dễ đọc hơn, và không còn âm thầm dựa vào một sự trùng hợp.

Sau khi train xong, xem Pixel đi đường nào bằng chính sách đã học (không còn khám phá ngẫu nhiên nữa):

def run_greedy():
    state = START
    path = [state]
    for _ in range(40):
        r, c = state
        action = int(np.argmax(Q[r, c]))
        state, reward, done = step(state, action)
        path.append(state)
        if done:
            break
    return path

print(run_greedy())

Muốn nghịch thêm, vài chỗ đáng sửa trước tiên:

  • TRAPS — đổi vị trí hoặc thêm bẫy, xem đường đi học được thay đổi ra sao.
  • epsilon — để cao suốt quá trình train (không giảm dần) là một sự đơn giản hoá. Thử giảm dần epsilon theo từng episode, Pixel sẽ khai thác nhiều hơn về cuối.
  • N — tăng kích thước lưới lên 10×10 hoặc hơn, rồi xem episodes cần bao nhiêu mới đủ để hội tụ.

Chỉnh cái cuối cùng đó đủ lâu, bạn sẽ đụng đúng vấn đề mà phần tiếp theo nói tới.

Giới hạn

Khi bảng không còn chứa nổi

Tăng N lên 10, Q-table có 400 ô. Lên 50, có 10.000 ô. Với môi trường thực — ảnh chụp màn hình game, cảm biến robot, hàng trăm biến số liên tục — số trạng thái không còn đếm được nữa.

Đây là giới hạn của Q-Learning dạng bảng: nó cần một ô riêng cho từng cặp (trạng thái, hành động), và phải ghé thăm từng ô đó ít nhất một lần mới học được gì. Bảng càng lớn, càng lâu học, càng tốn bộ nhớ — tới một lúc không còn khả thi.

Một thắc mắc hay gặp: nếu Q(s, a) phụ thuộc vào Q(s′, a′) ở bước kế tiếp, thì cái bảng đó phải "biết trước" gì để dựng lên được? Câu trả lời: hình dạng của bảng — bao nhiêu hàng, bao nhiêu cột — được cố định ngay từ đầu, trước khi Pixel học bất cứ điều gì: một hàng cho mỗi trạng thái có thể có, một cột cho mỗi hành động có thể có, giá trị khởi tạo bằng 0. Việc học chỉ thay đổi các con số bên trong từng ô, không thay đổi hình dạng bảng. Nói cách khác: bảng không lớn dần khi Pixel học — nó đã phải đủ lớn để chứa mọi trạng thái có thể xảy ra, kể cả những trạng thái Pixel chưa từng ghé qua. Đó chính xác là lý do số lượng trạng thái là vấn đề chí mạng: nó quyết định kích thước bảng phải dựng lên ngay từ đầu, không phải tốc độ học.

Thử tưởng tượng đem cách này áp vào một bàn cờ vua. Mỗi thế cờ hợp lệ là một trạng thái. Số thế cờ hợp lệ trên bàn cờ vua ước tính vào khoảng 1047 — một con số không cách nào liệt kê hết, chứ đừng nói dựng thành hàng trong một cái bảng. Đây cũng chính là lý do chương trình chơi cờ vây nhắc ở đầu bài viết này không hề dùng Q-table: với cờ vây, con số đó còn lớn hơn nhiều.

Cách sửa phổ biến nhất: thay cái bảng bằng một mạng neural. Thay vì tra một ô có sẵn, mạng đó ước lượng Q-value trực tiếp từ trạng thái đầu vào — kể cả những trạng thái nó chưa từng thấy. Gọi là Deep Q-Network (DQN).

Nguyên tắc lõi — thử, sai, cập nhật theo đúng công thức ở phần trước — không đổi. Chỉ có nơi lưu trữ hiểu biết là đổi: từ một cái bảng, thành trọng số của một mạng neural.

Kết

Pixel chưa bao giờ hiểu cái lưới ô vuông đó. Nó không có khái niệm gì về hình học, về khoảng cách, về việc góc nào gần đích hơn góc nào. Suốt từ đầu tới cuối, nó chỉ làm đúng một việc, lặp đi lặp lại: so sánh cái vừa xảy ra với cái nó từng nghĩ, rồi sửa sai một chút.

Không có gì thông minh trong chuyện đó. Chỉ có sự lặp lại, và một cuốn sổ không bao giờ ngừng cập nhật.

Đó cũng chính là bí mật đứng sau chương trình chơi cờ vây, đứng sau cánh tay robot nhắc tới ở đầu bài viết này. Vẫn là phép cập nhật đó — chỉ được lặp lại nhiều triệu lần hơn, trên những cuốn sổ lớn hơn rất nhiều lần.

Đọc thêm