Chuyển đến nội dung chính

Tối ưu Chính sách Lân cận với PyTorch và Gymnasium

Tìm hiểu các nguyên lý nền tảng của Proximal Policy Optimization, bao gồm triển khai bằng PyTorch với Gymnasium!
Đã cập nhật 5 thg 10, 2026  · 15 phút đọc

Khám phá cùng AI

ChatGPTClaudePerplexity

Proximal Policy Optimization (PPO) là một trong những thuật toán được ưa chuộng để giải quyết các bài toán Học Tăng cường (RL). Thuật toán này được phát triển vào năm 2017 bởi John Schuman, đồng sáng lập OpenAI. 

PPO đã được sử dụng rộng rãi tại OpenAI để huấn luyện các mô hình mô phỏng hành vi giống con người. Thuật toán này cải thiện so với các phương pháp trước đó như Trust Region Policy Optimization (TRPO) và trở nên phổ biến vì tính vững và hiệu quả. 

Trong hướng dẫn này, chúng ta sẽ đi sâu vào PPO. Chúng ta sẽ trình bày lý thuyết và minh họa cách triển khai bằng PyTorch.

Tìm hiểu Proximal Policy Optimization (PPO)

Các thuật toán học có giám sát truyền thống cập nhật tham số theo hướng của gradient dốc nhất. Nếu bản cập nhật này quá mức, nó sẽ được điều chỉnh trong các ví dụ huấn luyện tiếp theo vốn độc lập với nhau.

Tuy nhiên, các ví dụ huấn luyện trong học tăng cường bao gồm hành động và phần thưởng tích lũy (returns) của tác tử. Do đó, các ví dụ huấn luyện có tương quan với nhau. Tác tử khám phá môi trường để tìm chính sách tối ưu. Vì vậy, việc thay đổi lớn đối với gradient có thể khiến chính sách mắc kẹt trong vùng kém với phần thưởng dưới tối ưu. Do tác tử cần khám phá môi trường, các thay đổi lớn của chính sách làm quá trình huấn luyện trở nên bất ổn. 

Các phương pháp dựa trên miền tin cậy nhằm tránh vấn đề này bằng cách đảm bảo các cập nhật chính sách nằm trong một miền tin cậy. Đây là vùng bị ràng buộc nhân tạo trong không gian chính sách, trong đó các cập nhật được cho phép. Chính sách cập nhật chỉ có thể nằm trong miền tin cậy của chính sách cũ. Đảm bảo cập nhật chính sách theo từng bước nhỏ giúp ngăn chặn bất ổn.

Cập nhật chính sách miền tin cậy (TRPO)

Thuật toán Trust Region Policy Updates (TRPO) được đề xuất năm 2015 bởi John Schulman (người cũng đề xuất PPO năm 2017). Để đo sự khác biệt giữa chính sách cũ và chính sách cập nhật, TRPO sử dụng độ lệch Kullback–Leibler (KL). KL được dùng để đo sự khác biệt giữa hai phân phối xác suất. TRPO đã chứng minh hiệu quả trong việc triển khai miền tin cậy. 

Vấn đề với TRPO là độ phức tạp tính toán liên quan đến KL. Khi áp dụng KL cần khai triển bậc hai bằng các phương pháp số như khai triển Taylor, vốn tốn kém tính toán. PPO được đề xuất như một thay thế đơn giản và hiệu quả hơn cho TRPO. PPO kẹp (clip) tỉ số giữa các chính sách để xấp xỉ miền tin cậy mà không cần đến các phép tính phức tạp liên quan đến KL.

Đó là lý do PPO trở nên được ưa chuộng hơn TRPO trong việc giải các bài toán RL. Nhờ phương pháp ước lượng miền tin cậy hiệu quả hơn, PPO cân bằng tốt giữa hiệu năng và ổn định. 

Xấp xỉ chính sách lân cận (PPO)

PPO thường được xem là một phân lớp của các phương pháp actor-critic, vốn cập nhật gradient chính sách dựa trên hàm giá trị. Các phương pháp Advantage Actor-Critic (A2C) sử dụng một tham số gọi là lợi thế (advantage). Tham số này đo sự khác biệt giữa phần thưởng dự kiến do critic dự đoán và phần thưởng thực tế thu được khi thực thi chính sách. 

Để hiểu PPO, bạn cần biết các thành phần của nó:

  1. Actor thực thi chính sách. Actor được triển khai dưới dạng mạng nơ-ron. Với trạng thái làm đầu vào, nó xuất ra hành động cần thực hiện. 
  2. Critic là một mạng nơ-ron khác. Nó nhận trạng thái làm đầu vào và xuất ra giá trị kỳ vọng của trạng thái đó. Do đó, critic biểu diễn hàm giá trị trạng thái. 
  3. Các phương pháp dựa trên gradient chính sách có thể dùng các hàm mục tiêu khác nhau. Cụ thể, PPO sử dụng hàm lợi thế. Hàm lợi thế đo mức độ phần thưởng tích lũy (dựa trên chính sách do actor thực thi) vượt quá phần thưởng nền tảng kỳ vọng (do critic dự đoán). Mục tiêu của PPO là tăng xác suất chọn những hành động có lợi thế cao. Mục tiêu tối ưu hóa của PPO sử dụng các hàm mất mát dựa trên hàm lợi thế này. 
  4. Hàm mục tiêu kẹp (clipped objective) là đổi mới chính của PPO. Nó ngăn các cập nhật chính sách lớn trong một vòng lặp huấn luyện. Nó giới hạn mức độ cập nhật chính sách trong mỗi lần lặp. Để đo các cập nhật chính sách gia tăng, các phương pháp dựa trên chính sách dùng tỉ số xác suất của chính sách mới so với chính sách cũ. 
  5. Mất mát thay thế (surrogate loss) là hàm mục tiêu trong PPO và tính đến các đổi mới nêu trên. Nó được tính như sau:
    1. Tính tỉ số thực tế (như đã giải thích) và nhân với lợi thế. 
    2. Kẹp tỉ số vào trong một khoảng mong muốn. Nhân tỉ số đã kẹp với lợi thế. 
    3. Lấy giá trị nhỏ hơn trong hai đại lượng trên. 
  6. Trong thực tế, một hạng tử entropy cũng được cộng vào mất mát thay thế. Đây được gọi là phần thưởng entropy (entropy bonus). Nó dựa trên phân phối toán học của xác suất hành động. Ý tưởng đằng sau entropy bonus là đưa thêm một chút ngẫu nhiên một cách có kiểm soát. Làm vậy khuyến khích quá trình tối ưu hóa khám phá không gian hành động. Entropy bonus cao thúc đẩy khám phá hơn là khai thác. 

Hiểu cơ chế kẹp

Giả sử dưới chính sách cũ πold, xác suất thực hiện hành động a ở trạng thái s là πold(a|s). Dưới chính sách mới, xác suất thực hiện cùng hành động a từ cùng trạng thái s được cập nhật thành πnew(a|s). Tỉ số của các xác suất này, như một hàm theo tham số chính sách θ, là r(θ). Khi chính sách mới làm hành động đó có xác suất cao hơn (ở cùng trạng thái), tỉ số lớn hơn 1 và ngược lại. 

Cơ chế kẹp hạn chế tỉ số xác suất này sao cho xác suất hành động mới phải nằm trong một tỷ lệ phần trăm nhất định so với xác suất hành động cũ. Ví dụ, r(θ) có thể bị ràng buộc trong khoảng từ 0.8 đến 1.2. Điều này ngăn các bước nhảy lớn, qua đó đảm bảo quá trình huấn luyện ổn định.

Trong phần còn lại của bài viết, bạn sẽ học cách lắp ráp các thành phần để triển khai PPO đơn giản bằng PyTorch.

Trước khi triển khai PPO, chúng ta cần cài đặt các thư viện phần mềm tiên quyết và chọn một môi trường phù hợp để áp dụng chính sách. 

Cài đặt PyTorch và các thư viện cần thiết 

Chúng ta cần cài đặt các phần mềm sau: 

  • PyTorch và các thư viện khác như numpy (cho các hàm toán học và thống kê) và matplotlib (để vẽ biểu đồ).
  • Gói phần mềm mã nguồn mở Gym từ OpenAI, một thư viện Python mô phỏng các môi trường và trò chơi khác nhau có thể được giải bằng học tăng cường. Bạn có thể dùng API Gym để cho thuật toán tương tác với môi trường. Vì chức năng của gym đôi khi thay đổi qua các lần nâng cấp, trong ví dụ này, chúng ta cố định phiên bản ở 0.25.2.

Để cài đặt trên máy chủ hoặc máy cục bộ, chạy: 

$ pip install torch numpy matplotlib gym==0.25.2

Để cài đặt bằng Notebook như Google Colab hoặc DataLab, dùng: 

!pip install torch numpy matplotlib gym==0.25.2

Tạo các môi trường CartPole

Dùng OpenAI Gym để tạo hai thể hiện (một cho huấn luyện và một cho kiểm thử) của môi trường CartPole:

env_train = gym.make('CartPole-v1')
env_test = gym.make('CartPole-v1')

Bây giờ, hãy triển khai PPO bằng PyTorch.

Định nghĩa mạng chính sách

Như đã giải thích, PPO được triển khai theo mô hình actor-critic. Actor triển khai chính sách và critic dự đoán giá trị ước tính. Cả actor và critic đều nhận cùng đầu vào—trạng thái ở mỗi bước thời gian. Vì vậy, actor và critic có thể chia sẻ một mạng nơ-ron chung, được gọi là kiến trúc xương sống (backbone). Actor và critic có thể mở rộng backbone với các tầng bổ sung. 

Định nghĩa mạng xương sống

Các bước sau mô tả mạng xương sống:

  • Triển khai mạng với 3 tầng - đầu vào, ẩn, và đầu ra. 
  • Sau tầng đầu vào và tầng ẩn, chúng ta dùng hàm kích hoạt. Trong hướng dẫn này, chọn ReLU vì hiệu quả tính toán. 
  • Chúng ta cũng áp dụng dropout sau tầng đầu vào và tầng ẩn để có mạng vững hơn. Dropout sẽ ngẫu nhiên đưa một số nơ-ron về 0. Điều này giảm phụ thuộc vào các nơ-ron cụ thể và ngăn quá khớp, giúp mạng vững hơn. 

Đoạn mã dưới đây triển khai backbone:

class BackboneNetwork(nn.Module):
    def __init__(self, in_features, hidden_dimensions, out_features, dropout):
        super().__init__()
        self.layer1 = nn.Linear(in_features, hidden_dimensions)
        self.layer2 = nn.Linear(hidden_dimensions, hidden_dimensions)
        self.layer3 = nn.Linear(hidden_dimensions, out_features)
        self.dropout = nn.Dropout(dropout)
    def forward(self, x):
        x = self.layer1(x)
        x = f.relu(x)
        x = self.dropout(x)
        x = self.layer2(x)
        x = f.relu(x)
        x = self.dropout(x)
        x = self.layer3(x)
        return x

Định nghĩa mạng actor-critic

Giờ chúng ta có thể dùng mạng này để định nghĩa lớp actor-critic, ActorCritic. Actor mô hình hóa chính sách và dự đoán hành động. Critic mô hình hóa hàm giá trị và dự đoán giá trị. Cả hai đều nhận trạng thái làm đầu vào. 

class ActorCritic(nn.Module):
    def __init__(self, actor, critic):
        super().__init__()
        self.actor = actor
        self.critic = critic
    def forward(self, state):
        action_pred = self.actor(state)
        value_pred = self.critic(state)
        return action_pred, value_pred

Khởi tạo các mạng actor và critic

Chúng ta sẽ dùng các mạng đã định nghĩa để tạo actor và critic. Sau đó, tạo một agent bao gồm actor và critic. 

Trước khi tạo agent, khởi tạo các tham số của mạng:

  • Kích thước tầng ẩn, H, là tham số cấu hình. Kích thước và số lượng tầng ẩn phụ thuộc độ phức tạp của bài toán. Chúng ta sẽ dùng một tầng ẩn với kích thước 64 X 64. 
  • Đặc trưng đầu vào, N, trong đó N là kích thước mảng trạng thái. Tầng đầu vào có kích thước N X H. Trong môi trường CartPole, trạng thái là mảng 4 phần tử. Vậy N là 4. 
  • Đặc trưng đầu ra của mạng actor, O, trong đó O là số hành động trong môi trường. Tầng đầu ra của actor có kích thước H x O. Môi trường CartPole có 2 hành động. 
  • Đặc trưng đầu ra của mạng critic. Vì critic dự đoán một giá trị kỳ vọng (với một trạng thái đầu vào), số đặc trưng đầu ra là 1. 
  • Tỷ lệ dropout.

Mã sau cho thấy cách khai báo mạng actor và critic dựa trên backbone:

def create_agent(hidden_dimensions, dropout):
    INPUT_FEATURES = env_train.observation_space.shape[0]
    HIDDEN_DIMENSIONS = hidden_dimensions
    ACTOR_OUTPUT_FEATURES = env_train.action_space.n
    CRITIC_OUTPUT_FEATURES = 1
    DROPOUT = dropout
    actor = BackboneNetwork(
            INPUT_FEATURES, HIDDEN_DIMENSIONS, ACTOR_OUTPUT_FEATURES, DROPOUT)
    critic = BackboneNetwork(
            INPUT_FEATURES, HIDDEN_DIMENSIONS, CRITIC_OUTPUT_FEATURES, DROPOUT)
    agent = ActorCritic(actor, critic)
    return agent

Tính toán returns

Môi trường cho phần thưởng khi chuyển từ mỗi bước sang bước kế tiếp, tùy theo hành động của tác tử. Phần thưởng, R, được biểu diễn là:

Return được định nghĩa là giá trị tích lũy của phần thưởng kỳ vọng trong tương lai. Phần thưởng ở các bước thời gian xa trong tương lai có giá trị thấp hơn phần thưởng tức thời. Vì vậy, return thường được tính là return chiết khấu, G, định nghĩa như sau:

Trong hướng dẫn này (và nhiều tài liệu khác), return đề cập đến return chiết khấu. 

Để tính return:

  • Bắt đầu với các phần thưởng kỳ vọng từ mọi trạng thái tương lai.
  • Nhân mỗi phần thưởng tương lai với lũy thừa của hệ số chiết khấu . Ví dụ, phần thưởng kỳ vọng sau 2 bước (từ hiện tại) nhân với 2. 
  • Cộng tất cả các phần thưởng tương lai đã chiết khấu để tính return. 
  • Chuẩn hóa giá trị return. 

Hàm calculate_returns() thực hiện các phép tính này, như dưới đây:

def calculate_returns(rewards, discount_factor):
    returns = []
    cumulative_reward = 0
    for r in reversed(rewards):
        cumulative_reward = r + cumulative_reward * discount_factor
        returns.insert(0, cumulative_reward)
    returns = torch.tensor(returns)
    # normalize the return
    returns = (returns - returns.mean()) / returns.std()
    return returns

Triển khai hàm lợi thế (advantage)

Lợi thế được tính là chênh lệch giữa giá trị do critic dự đoán và return kỳ vọng từ các hành động do actor chọn theo chính sách. Với một hành động cho trước, lợi thế biểu thị lợi ích của việc chọn hành động cụ thể đó so với một hành động (trung bình) bất kỳ. 

Trong bài báo PPO gốc (phương trình 10), lợi thế khi nhìn tới bước thời gian T được biểu diễn là:

Khi lập trình thuật toán, ràng buộc nhìn tới một số bước thời gian cố định được áp đặt thông qua kích thước batch. Do đó, phương trình trên có thể giản lược thành chênh lệch giữa giá trị và return kỳ vọng. Return kỳ vọng được lượng hóa trong hàm giá trị trạng thái–hành động, Q. 

Vì vậy, công thức giản lược dưới đây biểu diễn lợi thế của việc chọn: 

  • một hành động cụ thể 
  • trong một trạng thái nhất định 
  • theo một chính sách cụ thể 
  • tại một bước thời gian cụ thể 

Được biểu diễn là: 

OpenAI cũng dùng công thức này để triển khai RL. Hàm calculate_advantages() dưới đây tính lợi thế:

def calculate_advantages(returns, values):
    advantages = returns - values
    # Normalize the advantage
    advantages = (advantages - advantages.mean()) / advantages.std()
    return advantages

Mất mát thay thế và cơ chế kẹp

Mất mát chính sách sẽ là mất mát gradient chính sách tiêu chuẩn nếu không có các kỹ thuật đặc biệt như PPO. Mất mát gradient chính sách tiêu chuẩn được tính là tích của:

  • Xác suất hành động theo chính sách 
  • Hàm lợi thế, được tính là chênh lệch giữa:
    • Return của chính sách 
    • Giá trị kỳ vọng

Mất mát gradient chính sách tiêu chuẩn không thể điều chỉnh cho các thay đổi đột ngột của chính sách. Mất mát thay thế sửa đổi mất mát tiêu chuẩn để hạn chế mức độ thay đổi chính sách trong mỗi lần lặp. Nó là giá trị nhỏ hơn của hai đại lượng:

  • Tích của:
    • Tỉ số chính sách. Tỉ số này biểu diễn sự khác biệt giữa xác suất hành động cũ và mới.  
    • Hàm lợi thế 
  • Tích của:
    • Giá trị kẹp của tỉ số chính sách. Tỉ số này được kẹp sao cho chính sách cập nhật nằm trong một tỷ lệ phần trăm nhất định so với chính sách cũ. 
    • Hàm lợi thế 

Trong quá trình tối ưu hóa, mất mát thay thế được dùng như đại diện cho mất mát thực tế. 

Cơ chế kẹp

Tỉ số chính sách, R, là sự khác biệt giữa chính sách mới và cũ, được cho bởi tỉ số xác suất log của chính sách dưới tham số mới và cũ:

Tỉ số chính sách đã kẹp, R', bị ràng buộc sao cho: 

Với lợi thế At như trên, và tỉ số chính sách như trên, mất mát thay thế được tính là:

Đoạn mã dưới đây cho thấy cách triển khai cơ chế kẹp và mất mát thay thế. 

def calculate_surrogate_loss(
        actions_log_probability_old,
        actions_log_probability_new,
        epsilon,
        advantages):
    advantages = advantages.detach()
    policy_ratio = (
            actions_log_probability_new - actions_log_probability_old
            ).exp()
    surrogate_loss_1 = policy_ratio * advantages
    surrogate_loss_2 = torch.clamp(
            policy_ratio, min=1.0-epsilon, max=1.0+epsilon
            ) * advantages
    surrogate_loss = torch.min(surrogate_loss_1, surrogate_loss_2)
    return surrogate_loss

Giờ hãy huấn luyện tác tử.

Tính toán mất mát chính sách và giá trị 

Chúng ta đã sẵn sàng để tính mất mát chính sách và giá trị: 

  • Mất mát chính sách là tổng của mất mát thay thế và entropy bonus. 
  • Mất mát giá trị dựa trên chênh lệch giữa giá trị do critic dự đoán và returns (phần thưởng tích lũy) do chính sách tạo ra. Tính mất mát giá trị sử dụng hàm Smooth L1 Loss. Điều này giúp làm mượt hàm mất mát và ít nhạy với ngoại lệ hơn. 

Cả hai mất mát ở trên đều là tensor. Gradient descent dựa trên giá trị vô hướng. Để có một giá trị vô hướng đại diện cho mất mát, dùng hàm .sum() để cộng các phần tử tensor. Hàm dưới đây minh họa cách làm: 

def calculate_losses(
        surrogate_loss, entropy, entropy_coefficient, returns, value_pred):
    entropy_bonus = entropy_coefficient * entropy
    policy_loss = -(surrogate_loss + entropy_bonus).sum()
    value_loss = f.smooth_l1_loss(returns, value_pred).sum()
    return policy_loss, value_loss

Định nghĩa vòng lặp huấn luyện

Trước khi bắt đầu quá trình huấn luyện, tạo một tập các bộ đệm (buffer) là mảng rỗng. Thuật toán huấn luyện sẽ dùng các bộ đệm này để lưu thông tin về hành động của tác tử, trạng thái của môi trường và phần thưởng ở mỗi bước thời gian. Hàm dưới đây khởi tạo các bộ đệm:

def init_training():
    states = []
    actions = []
    actions_log_probability = []
    values = []
    rewards = []
    done = False
    episode_reward = 0
    return states, actions, actions_log_probability, values, rewards, done, episode_reward

Mỗi lần lặp huấn luyện chạy tác tử với các tham số chính sách của vòng lặp đó. Tác tử tương tác với môi trường theo các bước thời gian trong một vòng lặp cho đến khi đạt điều kiện kết thúc. 

Sau mỗi bước, hành động, phần thưởng và giá trị của tác tử được thêm vào các bộ đệm tương ứng. Khi tập kết thúc, hàm trả về tập bộ đệm đã cập nhật, tóm tắt kết quả của tập. 

Trước khi chạy vòng lặp huấn luyện:

  • Đặt mô hình sang chế độ huấn luyện bằng agent.train(). 
  • Đặt lại môi trường về trạng thái ngẫu nhiên bằng env.reset(). Đây là trạng thái bắt đầu cho vòng lặp huấn luyện này. 

Các bước sau giải thích những gì xảy ra ở mỗi bước thời gian trong vòng lặp huấn luyện: 

  • Truyền trạng thái vào tác tử. 
  • Tác tử trả về:
    • Hành động dự đoán với trạng thái đã cho, dựa trên chính sách (actor). Truyền tensor hành động dự đoán qua hàm softmax để thu bộ xác suất hành động.  
    • Giá trị dự đoán của trạng thái, dựa trên critic.
  • Tác tử chọn hành động cần thực hiện:
    • Dùng các xác suất hành động để ước lượng phân phối xác suất. 
    • Chọn ngẫu nhiên một hành động bằng cách lấy mẫu từ phân phối này. Hàm dist.sample() thực hiện việc này. 
  • Dùng hàm env.step() để truyền hành động này vào môi trường nhằm mô phỏng phản hồi của môi trường cho bước thời gian này. Dựa trên hành động của tác tử, môi trường sinh ra:
    • Trạng thái mới
    • Phần thưởng
    • Giá trị boolean done (chỉ báo môi trường đã đạt trạng thái kết thúc hay chưa)
  • Thêm vào các bộ đệm tương ứng các giá trị hành động của tác tử, phần thưởng, giá trị dự đoán và trạng thái mới. 

Tập huấn luyện kết thúc khi hàm env.step() trả về true cho giá trị boolean done. 

Sau khi tập kết thúc, dùng các giá trị tích lũy ở mỗi bước để tính returns tích lũy của tập bằng cách cộng phần thưởng ở mỗi bước. Chúng ta dùng hàm calculate_returns() đã mô tả trước đó. Đầu vào của hàm này là hệ số chiết khấu và bộ đệm chứa phần thưởng của mỗi bước. Chúng ta dùng các returns này và các giá trị tích lũy ở mỗi bước để tính lợi thế bằng hàm calculate_advantages(). 

Hàm Python sau cho thấy cách triển khai các bước này:

def forward_pass(env, agent, optimizer, discount_factor):
    states, actions, actions_log_probability, values, rewards, done, episode_reward = init_training()
    state = env.reset()
    agent.train()
    while not done:
        state = torch.FloatTensor(state).unsqueeze(0)
        states.append(state)
        action_pred, value_pred = agent(state)
        action_prob = f.softmax(action_pred, dim=-1)
        dist = distributions.Categorical(action_prob)
        action = dist.sample()
        log_prob_action = dist.log_prob(action)
        state, reward, done, _ = env.step(action.item())
        actions.append(action)
        actions_log_probability.append(log_prob_action)
        values.append(value_pred)
        rewards.append(reward)
        episode_reward += reward
    states = torch.cat(states)
    actions = torch.cat(actions)
    actions_log_probability = torch.cat(actions_log_probability)
    values = torch.cat(values).squeeze(-1)
    returns = calculate_returns(rewards, discount_factor)
    advantages = calculate_advantages(returns, values)
    return episode_reward, states, actions, actions_log_probability, advantages, returns

Cập nhật tham số mô hình

Mỗi lần lặp huấn luyện chạy mô hình qua một tập hoàn chỉnh gồm nhiều bước thời gian (tới khi đạt điều kiện kết thúc). Ở mỗi bước, chúng ta lưu tham số chính sách, hành động của tác tử, returns và advantages. Sau mỗi lần lặp, chúng ta cập nhật mô hình dựa trên hiệu năng của chính sách qua tất cả các bước trong lần lặp đó. 

Số bước thời gian tối đa trong môi trường CartPole là 500. Trong các môi trường phức tạp hơn, có nhiều bước thời gian hơn, thậm chí hàng triệu. Khi đó, tập dữ liệu kết quả huấn luyện phải được chia thành các batch. Số bước thời gian trong mỗi batch gọi là kích thước batch tối ưu hóa. 

Vì vậy, các bước cập nhật tham số mô hình là:

  • Chia tập dữ liệu kết quả huấn luyện thành các batch. 
  • Với mỗi batch:
    • Lấy hành động của tác tử và giá trị dự đoán cho mỗi trạng thái.
    • Dùng các hành động dự đoán này để ước lượng phân phối xác suất hành động mới. 
    • Dùng phân phối này để tính entropy. 
    • Dùng phân phối này để lấy log xác suất của các hành động trong tập kết quả huấn luyện. Đây là tập log xác suất mới của các hành động đó. Tập log xác suất cũ của cùng các hành động đã được tính trong vòng lặp huấn luyện ở phần trước. 
    • Tính mất mát thay thế bằng phân phối xác suất cũ và mới của các hành động. 
    • Tính mất mát chính sách và mất mát giá trị bằng mất mát thay thế, entropy và lợi thế. 
    • Chạy .backward() riêng cho mất mát chính sách và mất mát giá trị. Điều này cập nhật gradient trên các hàm mất mát. 
    • Chạy .step() trên optimizer để cập nhật tham số chính sách. Ở đây, chúng ta dùng optimizer Adam để cân bằng tốc độ và độ vững. 
    • Cộng dồn mất mát chính sách và mất mát giá trị. 
  • Lặp lại lan truyền ngược (các thao tác trên) trên mỗi batch vài lần, tùy theo tham số PPO_STEPS. Lặp lại lan truyền ngược trên mỗi batch tiết kiệm tính toán vì nó làm tăng hiệu quả kích thước tập huấn luyện mà không cần thêm các lần lan truyền xuôi. Số bước môi trường trong mỗi lần thay phiên giữa lấy mẫu và tối ưu hóa được gọi là kích thước batch vòng lặp. 
  • Trả về mất mát chính sách và mất mát giá trị trung bình.

Đoạn mã dưới đây triển khai các bước trên:

def update_policy(
        agent,
        states,
        actions,
        actions_log_probability_old,
        advantages,
        returns,
        optimizer,
        ppo_steps,
        epsilon,
        entropy_coefficient):
    BATCH_SIZE = 128
    total_policy_loss = 0
    total_value_loss = 0
    actions_log_probability_old = actions_log_probability_old.detach()
    actions = actions.detach()
    training_results_dataset = TensorDataset(
            states,
            actions,
            actions_log_probability_old,
            advantages,
            returns)
    batch_dataset = DataLoader(
            training_results_dataset,
            batch_size=BATCH_SIZE,
            shuffle=False)
    for _ in range(ppo_steps):
        for batch_idx, (states, actions, actions_log_probability_old, advantages, returns) in enumerate(batch_dataset):
            # get new log prob of actions for all input states
            action_pred, value_pred = agent(states)
            value_pred = value_pred.squeeze(-1)
            action_prob = f.softmax(action_pred, dim=-1)
            probability_distribution_new = distributions.Categorical(
                    action_prob)
            entropy = probability_distribution_new.entropy()
            # estimate new log probabilities using old actions
            actions_log_probability_new = probability_distribution_new.log_prob(actions)
            surrogate_loss = calculate_surrogate_loss(
                    actions_log_probability_old,
                    actions_log_probability_new,
                    epsilon,
                    advantages)
            policy_loss, value_loss = calculate_losses(
                    surrogate_loss,
                    entropy,
                    entropy_coefficient,
                    returns,
                    value_pred)
            optimizer.zero_grad()
            policy_loss.backward()
            value_loss.backward()
            optimizer.step()
            total_policy_loss += policy_loss.item()
            total_value_loss += value_loss.item()
    return total_policy_loss / ppo_steps, total_value_loss / ppo_steps

Giờ hãy chạy tác tử PPO.

Đánh giá hiệu năng 

Để đánh giá hiệu năng của tác tử, tạo một môi trường mới và tính phần thưởng tích lũy khi chạy tác tử trong môi trường này. Bạn cần đặt tác tử sang chế độ đánh giá bằng hàm .eval(). Các bước tương tự như vòng lặp huấn luyện. Đoạn mã dưới đây triển khai hàm đánh giá: 

def evaluate(env, agent):
    agent.eval()
    rewards = []
    done = False
    episode_reward = 0
    state = env.reset()
    while not done:
        state = torch.FloatTensor(state).unsqueeze(0)
        with torch.no_grad():
            action_pred, _ = agent(state)
            action_prob = f.softmax(action_pred, dim=-1)
        action = torch.argmax(action_prob, dim=-1)
        state, reward, done, _ = env.step(action.item())
        episode_reward += reward
    return episode_reward

Trực quan hóa kết quả huấn luyện

Chúng ta sẽ dùng thư viện Matplotlib để trực quan hóa tiến trình huấn luyện. Hàm dưới đây cho thấy cách vẽ phần thưởng từ cả vòng lặp huấn luyện và kiểm thử:

def plot_train_rewards(train_rewards, reward_threshold):
    plt.figure(figsize=(12, 8))
    plt.plot(train_rewards, label='Training Reward')
    plt.xlabel('Episode', fontsize=20)
    plt.ylabel('Training Reward', fontsize=20)
    plt.hlines(reward_threshold, 0, len(train_rewards), color='y')
    plt.legend(loc='lower right')
    plt.grid()
    plt.show()
def plot_test_rewards(test_rewards, reward_threshold):
    plt.figure(figsize=(12, 8))
    plt.plot(test_rewards, label='Testing Reward')
    plt.xlabel('Episode', fontsize=20)
    plt.ylabel('Testing Reward', fontsize=20)
    plt.hlines(reward_threshold, 0, len(test_rewards), color='y')
    plt.legend(loc='lower right')
    plt.grid()
    plt.show()

Trong các biểu đồ ví dụ dưới đây, chúng tôi hiển thị phần thưởng huấn luyện và kiểm thử, thu được khi áp dụng chính sách trong môi trường huấn luyện và kiểm thử tương ứng. Lưu ý rằng hình dạng các đồ thị sẽ khác nhau mỗi lần chạy mã, do tính ngẫu nhiên vốn có của quá trình huấn luyện. 

Plot of the training rewards in each episode

Phần thưởng huấn luyện (thu được khi áp dụng chính sách trong môi trường huấn luyện). Ảnh: Tác giả.

Phần thưởng kiểm thử (thu được khi áp dụng chính sách trong môi trường kiểm thử). Ảnh: Tác giả. 

Trong các đồ thị đầu ra ở trên, hãy quan sát tiến trình huấn luyện:

  • Phần thưởng bắt đầu từ giá trị thấp. Khi huấn luyện tiếp diễn, phần thưởng tăng lên. 
  • Phần thưởng dao động ngẫu nhiên trong quá trình tăng. Điều này là do tác tử đang khám phá không gian chính sách. 
  • Huấn luyện kết thúc và phần thưởng kiểm thử đã ổn định quanh ngưỡng (475) trong nhiều lần lặp. 
  • Phần thưởng bị giới hạn ở 500. Đây là các ràng buộc do môi trường (Gym CartPole v1) đặt ra. 

Tương tự, bạn có thể vẽ mất mát giá trị và chính sách qua các lần lặp:

def plot_losses(policy_losses, value_losses):
    plt.figure(figsize=(12, 8))
    plt.plot(value_losses, label='Value Losses')
    plt.plot(policy_losses, label='Policy Losses')
    plt.xlabel('Episode', fontsize=20)
    plt.ylabel('Loss', fontsize=20)
    plt.legend(loc='lower right')
    plt.grid()
    plt.show()

Biểu đồ ví dụ dưới đây hiển thị các mất mát được theo dõi qua các tập huấn luyện:

Mất mát giá trị và chính sách trong quá trình huấn luyện. Ảnh: Tác giả

Quan sát biểu đồ và lưu ý:

  • Các mất mát có vẻ phân bố ngẫu nhiên và không theo mẫu hình nào. 
  • Điều này là điển hình của huấn luyện RL, nơi mục tiêu không phải tối thiểu hóa mất mát mà là tối đa hóa phần thưởng. 

Chạy thuật toán PPO

Bạn đã có tất cả các thành phần để huấn luyện tác tử bằng PPO. Để ghép lại, bạn cần:

  • Khai báo siêu tham số như hệ số chiết khấu, kích thước batch, tốc độ học, v.v. 
  • Khởi tạo các bộ đệm là mảng rỗng để lưu phần thưởng và mất mát từ mỗi lần lặp. 
  • Tạo một thể hiện tác tử bằng hàm create_agent().
  • Chạy lặp đi lặp lại các lần lan truyền xuôi và ngược bằng các hàm forward_pass() và update_policy(). 
  • Kiểm thử hiệu năng chính sách bằng hàm evaluate(). 
  • Thêm mất mát chính sách, mất mát giá trị và phần thưởng từ các hàm huấn luyện và đánh giá vào các bộ đệm tương ứng. 
  • Tính trung bình phần thưởng và mất mát trong vài bước cuối. Ví dụ dưới đây lấy trung bình trên 40 bước cuối. 
  • In kết quả đánh giá sau mỗi vài bước. Ví dụ dưới đây in mỗi 10 bước. 
  • Kết thúc quá trình khi phần thưởng trung bình vượt một ngưỡng nhất định. 

Mã dưới đây cho thấy cách khai báo một hàm làm điều này trong Python:

def run_ppo():
    MAX_EPISODES = 500
    DISCOUNT_FACTOR = 0.99
    REWARD_THRESHOLD = 475
    PRINT_INTERVAL = 10
    PPO_STEPS = 8
    N_TRIALS = 100
    EPSILON = 0.2
    ENTROPY_COEFFICIENT = 0.01
    HIDDEN_DIMENSIONS = 64
    DROPOUT = 0.2
    LEARNING_RATE = 0.001
    train_rewards = []
    test_rewards = []
    policy_losses = []
    value_losses = []
    agent = create_agent(HIDDEN_DIMENSIONS, DROPOUT)
    optimizer = optim.Adam(agent.parameters(), lr=LEARNING_RATE)
    for episode in range(1, MAX_EPISODES+1):
        train_reward, states, actions, actions_log_probability, advantages, returns = forward_pass(
                env_train,
                agent,
                optimizer,
                DISCOUNT_FACTOR)
        policy_loss, value_loss = update_policy(
                agent,
                states,
                actions,
                actions_log_probability,
                advantages,
                returns,
                optimizer,
                PPO_STEPS,
                EPSILON,
                ENTROPY_COEFFICIENT)
        test_reward = evaluate(env_test, agent)
        policy_losses.append(policy_loss)
        value_losses.append(value_loss)
        train_rewards.append(train_reward)
        test_rewards.append(test_reward)
        mean_train_rewards = np.mean(train_rewards[-N_TRIALS:])
        mean_test_rewards = np.mean(test_rewards[-N_TRIALS:])
        mean_abs_policy_loss = np.mean(np.abs(policy_losses[-N_TRIALS:]))
        mean_abs_value_loss = np.mean(np.abs(value_losses[-N_TRIALS:]))
        if episode % PRINT_INTERVAL == 0:
            print(f'Episode: {episode:3} | \
                  Mean Train Rewards: {mean_train_rewards:3.1f} \
                  | Mean Test Rewards: {mean_test_rewards:3.1f} \
                  | Mean Abs Policy Loss: {mean_abs_policy_loss:2.2f} \
                  | Mean Abs Value Loss: {mean_abs_value_loss:2.2f}')
        if mean_test_rewards >= REWARD_THRESHOLD:
            print(f'Reached reward threshold in {episode} episodes')
            break
    plot_train_rewards(train_rewards, REWARD_THRESHOLD)
    plot_test_rewards(test_rewards, REWARD_THRESHOLD)
    plot_losses(policy_losses, value_losses)

Chạy chương trình: 

run_ppo()

Đầu ra sẽ tương tự ví dụ dưới đây: 

Episode:  10 | Mean Train Rewards: 22.3 | Mean Test Rewards: 30.4 | Mean Abs Policy Loss: 0.37 | Mean Abs Value Loss: 0.39 
Episode:  20 | Mean Train Rewards: 38.6 | Mean Test Rewards: 69.8 | Mean Abs Policy Loss: 0.46 | Mean Abs Value Loss: 0.37 
.
.
.
Episode: 100 | Mean Train Rewards: 289.5 | Mean Test Rewards: 427.3 | Mean Abs Policy Loss: 1.73 | Mean Abs Value Loss: 0.21 
Episode: 110 | Mean Train Rewards: 357.7 | Mean Test Rewards: 461.4 | Mean Abs Policy Loss: 1.86 | Mean Abs Value Loss: 0.22 
Reached reward threshold in 116 episodes

Bạn có thể xem và chạy chương trình hoạt động trong notebook DataLab này!

Trong machine learning, siêu tham số kiểm soát quá trình huấn luyện. Dưới đây, tôi giải thích một số siêu tham số quan trọng dùng trong PPO: 

  • Tốc độ học (Learning rate): Tốc độ học quyết định mức độ tham số chính sách có thể thay đổi trong mỗi lần lặp. Trong gradient descent ngẫu nhiên, lượng cập nhật tham số chính sách ở mỗi lần lặp được quyết định bởi tích của tốc độ học và gradient. 
  • Tham số kẹp (Clipping parameter): Còn được gọi là epsilon, ε. Nó quyết định phạm vi kẹp của tỉ số chính sách. Tỉ số giữa chính sách mới và cũ được phép thay đổi trong khoảng [1-ε, 1+ε]. Khi vượt ngoài khoảng này, nó sẽ bị kẹp lại để nằm trong khoảng. 
  • Kích thước batch: Đây là số bước xét cho mỗi cập nhật gradient. Trong PPO, kích thước batch là số bước thời gian cần để áp dụng chính sách và tính mất mát thay thế nhằm cập nhật tham số chính sách. Trong bài, chúng ta dùng batch size 64. 
  • Số bước lặp (Iteration steps): Đây là số lần mỗi batch được tái sử dụng để chạy lan truyền ngược. Trong mã, tham số này là PPO_STEPS. Trong môi trường phức tạp, chạy nhiều lần lan truyền xuôi rất tốn kém tính toán. Một cách hiệu quả hơn là tái sử dụng mỗi batch vài lần. Thường khuyến nghị dùng giá trị từ 5 đến 10. 
  • Hệ số chiết khấu (Discount factor): Còn được gọi là gamma, γ. Nó thể hiện mức độ phần thưởng tức thời có giá trị hơn phần thưởng tương lai. Điều này tương tự khái niệm lãi suất trong giá trị thời gian của tiền. Khi gần 0, nghĩa là phần thưởng tương lai kém giá trị và tác tử nên ưu tiên phần thưởng tức thời. Khi gần 1, nghĩa là phần thưởng tương lai quan trọng. 
  • Hệ số entropy: Hệ số entropy quyết định entropy bonus, được tính là tích của hệ số entropy và entropy của phân phối. Vai trò của entropy bonus là đưa thêm tính ngẫu nhiên vào chính sách. Điều này khuyến khích tác tử khám phá không gian chính sách. Tuy nhiên, khi tính ngẫu nhiên quá cao, huấn luyện sẽ không hội tụ tới chính sách tối ưu. 
  • Tiêu chí thành công cho huấn luyện: Bạn cần đặt tiêu chí để quyết định khi nào huấn luyện thành công. Cách phổ biến là đặt điều kiện phần thưởng trung bình trong N tập (episode) gần nhất vượt một ngưỡng. Trong mã ví dụ trên, điều này được biểu diễn bởi biến N_TRIALS. Khi đặt giá trị này cao hơn, huấn luyện mất nhiều thời gian hơn vì chính sách phải đạt phần thưởng ngưỡng trong nhiều tập hơn. Nó cũng giúp chính sách vững hơn nhưng tốn kém tính toán hơn. Lưu ý PPO là chính sách ngẫu nhiên, sẽ có những tập tác tử không vượt ngưỡng. Vì vậy nếu N_TRIALS quá cao, huấn luyện của bạn có thể không kết thúc.

Chiến lược tối ưu hiệu năng PPO

Tối ưu hiệu năng huấn luyện PPO đòi hỏi thử nghiệm và thay đổi các giá trị siêu tham số. Tuy nhiên, có một số hướng dẫn chung: 

  • Hệ số chiết khấu: Khi phần thưởng dài hạn quan trọng, như trong môi trường CartPole, nơi cột phải ổn định theo thời gian, hãy bắt đầu với gamma ở mức vừa phải, như 0.99. 
  • Entropy bonus: Trong môi trường phức tạp, tác tử phải khám phá không gian hành động để tìm chính sách tối ưu. Entropy bonus thúc đẩy khám phá. Entropy bonus được cộng vào mất mát thay thế. Hãy kiểm tra độ lớn của mất mát thay thế và entropy của phân phối trước khi quyết định hệ số entropy. Trong bài, chúng ta dùng hệ số entropy 0.01.
  • Tham số kẹp: Tham số kẹp quyết định mức độ khác biệt mà chính sách cập nhật có thể có so với chính sách hiện tại. Giá trị kẹp lớn khuyến khích khám phá môi trường tốt hơn, nhưng có nguy cơ làm bất ổn huấn luyện. Bạn cần một tham số kẹp cho phép khám phá dần dần đồng thời ngăn các cập nhật gây bất ổn. Trong bài, chúng ta dùng tham số kẹp 0.2.
  • Tốc độ học: Khi tốc độ học quá cao, chính sách được cập nhật bằng những bước lớn và quá trình huấn luyện có thể trở nên bất ổn. Khi quá thấp, huấn luyện mất quá nhiều thời gian.  Hướng dẫn này dùng tốc độ học 0.001, phù hợp với môi trường. Trong nhiều trường hợp, khuyến nghị dùng tốc độ học 1e-5. 

Thách thức và thực tiễn tốt trong PPO

Sau khi giải thích các khái niệm và chi tiết triển khai PPO, hãy bàn về các thách thức và thực tiễn tốt. 

Các thách thức thường gặp khi huấn luyện PPO

Dù PPO được dùng rộng rãi, bạn cần nhận diện các thách thức tiềm ẩn để giải quyết thành công các bài toán thực tế bằng kỹ thuật này. Một số thách thức như:

  • Hội tụ chậm: Trong môi trường phức tạp, PPO có thể kém hiệu quả về mẫu và cần nhiều tương tác với môi trường để hội tụ tới chính sách tối ưu. Điều này làm huấn luyện chậm và tốn kém. 
  • Nhạy cảm với siêu tham số: PPO dựa trên việc khám phá hiệu quả không gian chính sách. Độ ổn định của quá trình huấn luyện và tốc độ hội tụ nhạy với các giá trị siêu tham số. Các giá trị tối ưu thường chỉ xác định được qua thử nghiệm. 
  • Quá khớp:  Các môi trường RL thường được khởi tạo với tham số ngẫu nhiên. Huấn luyện PPO dựa trên việc tìm chính sách tối ưu theo môi trường của tác tử. Đôi khi quá trình huấn luyện hội tụ tới bộ tham số tối ưu cho một môi trường cụ thể nhưng không cho các môi trường ngẫu nhiên khác. Thường xử lý bằng cách có nhiều lần lặp, mỗi lần với môi trường huấn luyện được ngẫu nhiên hóa khác nhau. 
  • Môi trường động: Các môi trường RL đơn giản như CartPole là tĩnh - luật lệ không đổi theo thời gian. Nhiều môi trường khác, như robot học đi trên bề mặt chuyển động không ổn định, là động - luật lệ của môi trường thay đổi theo thời gian. Để hoạt động tốt trong các môi trường như vậy, PPO thường cần tinh chỉnh bổ sung. 
  • Khám phá vs khai thác: Cơ chế kẹp của PPO đảm bảo các cập nhật chính sách nằm trong miền tin cậy. Tuy nhiên, nó cũng hạn chế tác tử khám phá không gian hành động. Điều này có thể dẫn đến hội tụ tại cực trị cục bộ, đặc biệt trong môi trường phức tạp. Ngược lại, cho phép tác tử khám phá quá nhiều có thể ngăn nó hội tụ tới bất kỳ chính sách tối ưu nào. 

Thực tiễn tốt khi huấn luyện mô hình PPO

Để đạt kết quả tốt với PPO, tôi khuyến nghị một số thực tiễn tốt như:

  • Chuẩn hóa đặc trưng đầu vào: Chuẩn hóa giá trị returns và advantages làm giảm độ biến động dữ liệu và dẫn đến cập nhật gradient ổn định. Chuẩn hóa đưa tất cả giá trị về một khoảng số học nhất quán. Nó giúp giảm ảnh hưởng của ngoại lệ và giá trị cực đoan, vốn có thể làm méo cập nhật gradient và làm chậm hội tụ. 
  • Dùng kích thước batch đủ lớn: Batch nhỏ cho phép cập nhật nhanh hơn và huấn luyện nhanh nhưng có thể dẫn tới hội tụ tại cực trị cục bộ và bất ổn trong huấn luyện. Batch lớn giúp tác tử học chính sách vững, dẫn đến quá trình huấn luyện ổn định. Tuy nhiên, batch quá lớn cũng không tối ưu. Ngoài việc tăng chi phí tính toán, chúng làm cập nhật chính sách kém nhạy với hàm giá trị vì cập nhật gradient dựa trên trung bình ước lượng trên các batch lớn. Hơn nữa, có thể dẫn tới quá khớp với batch cụ thể đó. 
  • Số bước lặp: Thông thường nên tái sử dụng mỗi batch trong 5–10 lần lặp. Điều này làm quá trình huấn luyện hiệu quả hơn. Tái sử dụng cùng một batch quá nhiều lần dẫn tới quá khớp. Mã gọi siêu tham số này là PPO_STEPS. 
  • Đánh giá định kỳ: Để phát hiện quá khớp, cần theo dõi định kỳ hiệu quả của chính sách. Nếu chính sách kém hiệu quả trong một số kịch bản, có thể cần huấn luyện thêm hoặc tinh chỉnh. 
  • Tinh chỉnh siêu tham số: Như đã giải thích, huấn luyện PPO nhạy với giá trị siêu tham số. Hãy thử nghiệm các giá trị khác nhau để tìm tập giá trị phù hợp cho bài toán của bạn. 
  • Mạng xương sống dùng chung: Như minh họa trong bài, dùng chung backbone ngăn chặn mất cân bằng giữa mạng actor và critic. Chia sẻ backbone giúp trích xuất đặc trưng chung và hiểu biết chung về môi trường, khiến việc học hiệu quả và ổn định hơn. Nó cũng giúp giảm độ phức tạp về không gian và thời gian tính toán của thuật toán. 
  • Số lượng và kích thước tầng ẩn: Tăng số tầng ẩn và kích thước cho môi trường phức tạp hơn. Bài toán đơn giản như CartPole có thể giải với một tầng ẩn. Tầng ẩn trong bài có 64 chiều. Làm mạng lớn hơn nhiều so với cần thiết sẽ lãng phí tính toán và có thể làm mạng bất ổn. 
  • Dừng sớm: Dừng huấn luyện khi đạt chỉ số đánh giá giúp tránh huấn luyện quá mức và lãng phí tài nguyên. Một chỉ số phổ biến là khi tác tử vượt ngưỡng phần thưởng trong N sự kiện gần đây. 

Kết luận

Trong bài viết này, chúng ta đã thảo luận về PPO như một cách giải các bài toán RL. Sau đó, chúng ta chi tiết các bước triển khai PPO bằng PyTorch. Cuối cùng, chúng ta đưa ra một số mẹo hiệu năng và thực tiễn tốt cho PPO. 

Cách học tốt nhất là tự bạn triển khai mã. Bạn cũng có thể sửa mã để hoạt động với các môi trường điều khiển cổ điển khác trong Gym. Để học cách triển khai các tác tử RL bằng Python và Gymnasium của OpenAI, hãy theo khóa học Reinforcement Learning with Gymnasium in Python!


Arun Nanda's photo
Author
Arun Nanda
LinkedIn

Arun là cựu nhà sáng lập startup, yêu thích xây dựng những điều mới mẻ. Hiện tại, anh đang khám phá các nền tảng kỹ thuật và toán học của Trí tuệ Nhân tạo. Anh thích chia sẻ những gì mình học được, vì thế anh viết về chúng.

Bên cạnh DataCamp, bạn có thể đọc các bài viết của anh trên Medium, Airbyte và Vultr.

Chủ đề
Machine Learning
Python

Tìm hiểu thêm về machine learning với các khóa học này!

Khóa học

Machine Learning với mô hình dựa trên cây trong Python

5 giờ
119.8K
Trong khóa học này, bạn sẽ học cách sử dụng các mô hình dựa trên cây và các tập hợp cho hồi quy và phân loại bằng scikit-learn.
Xem chi tiếtRight Arrow
Bắt Đầu Khóa Học
Xem thêmRight Arrow
Liên quan

blog

Claude Opus 4.6: Tính năng, Điểm chuẩn, Bài kiểm tra thực hành và hơn thế nữa

Mô hình mới nhất của Anthropic dẫn đầu ở mã hóa tác tử và lập luận phức tạp. Thêm vào đó, nó có cửa sổ ngữ cảnh 1M.
Matt Crabtree's photo

Matt Crabtree

10 phút

Xem ThêmXem Thêm