Tối ưu hóa Logistics trong môi trường biến động với MARL: Xây dựng tác nhân bất biến về quy mô

Công nghệ05 tháng 5, 2026·16 phút đọc

Đây là phần thứ hai trong loạt bài về tối ưu hóa lịch trình logistics sử dụng Học tăng cường đa tác nhân (MARL). Bài viết tập trung vào việc đạt được khả năng tổng quát hóa mô hình thông qua ba khái niệm nền tảng: kiến trúc lai, quan sát bất biến về quy mô và khả năng thích ứng của MARL. Mục tiêu là tạo ra các tác nhân có thể hoạt động hiệu quả ngay cả khi điều kiện vận hành thay đổi liên tục.

Tối ưu hóa Logistics trong môi trường biến động với MARL: Xây dựng tác nhân bất biến về quy mô

Đây là phần thứ hai của loạt bài về tối ưu hóa lịch trình trong logistics với Học tăng cường đa tác nhân (MARL). Trong phần này, tôi sẽ tập trung sâu hơn vào cách thức đạt được khả năng tổng quát hóa (generalization) cho mô hình. Nếu bạn muốn nắm rõ bối cảnh kiến trúc và kinh doanh, tôi khuyên bạn nên đọc Phần 1 trước.

Mục tiêu của chúng ta là để mô hình có thể tổng quát hóa các quy trình vận chuyển tầm trung (mid-mile) và hoạt động ổn định ngay cả trong những điều kiện thay đổi. Tôi đã hiện thực hóa tầm nhìn này thông qua ba khái niệm nền tảng:

  • Kiến trúc lai (Hybrid architecture) trừu tượng hóa sự phức tạp vật lý.
  • Quan sát bất biến về quy mô (Scale-invariant observations) tạo ra đầu vào mô hình phổ quát.
  • MARL giúp các tác nhân trở nên thích nghi.

Cảnh báo nhỏ: Hai khái niệm đầu tiên cho phép chúng ta chuyển đổi tác nhân dễ dàng giữa các nhiệm vụ, trong khi khái niệm thứ ba giúp tác nhân thích nghi trong phạm vi một nhiệm vụ và vượt xa hơn thế. Hãy cùng đi sâu vào từng khái niệm.

Kiến trúc lai (Hybrid Architecture)

Làm thế nào để xây dựng một hệ thống có khả năng đưa ra các giải pháp mạnh mẽ, ngay cả khi được chuyển sang một bối cảnh hoàn toàn mới? Bạn chỉ cần khiến nó giải quyết không phải một trường hợp cụ thể, mà là một vấn đề ở mức độ trừu tượng hóa cao hơn.

Nhưng làm thế nào để hiện thực hóa điều này? Hãy chia vấn đề thành các lớp và giải quyết nó bằng sự kết hợp: RL (Học tăng cường) chỉ huy chiến lược cấp cao, và LP (Lập trình tuyến tính) xử lý việc thực thi cấp thấp. Nhờ đó, chúng ta cho phép RL tổng hợp kiến thức lĩnh vực rộng lớn hơn, trong khi LP giải quyết các trường hợp đóng gói cụ thể, riêng lẻ.

action = [num_vehicles_1, .. , num_vehicles_n]

(Xem Phần 1 để biết thêm chi tiết về phương pháp tiếp cận lai và các phiên bản hành động)

Nhờ sự "phân chia nhiệm vụ" này, thành phần RL được giải phóng khỏi những chi tiết kỹ thuật vụn vặt về việc kiện hàng nào đi đâu, hoặc cách chúng được đóng gói. Giống như một người quản lý tách biệt khỏi các chi tiết thực thi.

Cuối cùng, tác nhân RL tác động đến môi trường một cách gián tiếp — các hành động vĩ mô của nó được xử lý thông qua bộ giải LP, sau đó làm mới trạng thái của môi trường.

Dưới đây là cách chúng ta xử lý hành động của tác nhân RL và chuyển nó vào bộ giải LP:

def decide_send_LP(self, action: np.array):
    # Phân tích mảng hành động của tác nhân RL thành từ điển các đích đến hoạt động
    neighb_action = {v_id: num_v for v_id, num_v in enumerate(action) if num_v > 0}
    if not neighb_action:
        return 0, 0 # Không có xe được điều phối

    # Lấy tồn kho kho hàng cho các kiện hàng thực sự có thể đi đến các đích đến đã chọn
    available_parcels = self.get_available_parcels(destinations=neighb_action.keys())
    if available_parcels.empty:
        return 0, 0 # Không có gói hàng để gửi

    # LP quyết định các kiện hàng nào sẽ được đưa vào xe để tối đa hóa thể tích/lợi nhuận
    av_vehicles = self.get_available_vehicles()
    parcels_result, edges_result = send_veh(neighb_action, available_parcels, av_vehicles)

    # Cập nhật trạng thái môi trường dựa trên việc thực thi vật lý của LP
    self.process_sent(parcels_result)

    # Trả về chi phí cho môi trường (để tính toán phần thưởng)
    shipment_cost = sum(edges_result.c_cost * edges_result.v_varr_value)
    num_vehicles_sent = edges_result.v_varr_value.sum()
    return shipment_cost, num_vehicles_sent

Điều gì đang xảy ra ở đây? Đầu tiên, chúng ta phải dịch các hành động của tác nhân thành một định dạng dễ hiểu, đảm bảo rằng tác nhân thực sự yêu cầu ít nhất một lần điều phối. Sau đó, chúng ta kiểm tra xem có kiện hàng nào trong kho có thể gửi đi không.

Tiếp theo, chúng ta chạy lập trình tuyến tính, sẽ đóng gói các gói hàng có sẵn vào các phương tiện có sẵn, chọn không chỉ loại hình vận tải mà còn cả phương tiện cụ thể, cũng như nơi kiện hàng này sẽ đến.

Và cuối cùng, chúng ta cập nhật trạng thái của môi trường dựa trên việc thực thi của LP, tính toán chi phí vận chuyển và trả về để tính toán phần thưởng.

Như vậy, chúng ta đã có tính khả chuyển — miễn là cấu trúc của nhiệm vụ giống nhau, hệ thống có thể thích ứng với bất kỳ vấn đề nào trong cùng một lớp.

Quan sát bất biến về quy mô (Scale-Invariant Observations)

Giả sử chúng ta đã có kiến trúc lai. Nhưng làm thế nào để nó tồn tại trong các bối cảnh khác nhau nếu không gian quan sát và hành động của các tác nhân RL về mặt kỹ thuật được cố định ngay từ đầu?

Tôi đã đạt được điều đó bằng cách chuyển đổi các quan sát — tôi chuẩn hóa không gian quan sát để làm cho nó bất biến về quy mô. Thay vì theo dõi số lượng thô (ví dụ: "bao nhiêu gói hàng đã được gửi"), chúng tôi theo dõi tỷ lệ (ví dụ: "bao nhiêu phần trăm tổng lượng tồn đọng đã được gửi").

Đây là một thủ thuật kỹ thuật cụ thể mang lại cho bạn khả năng chuyển đổi tác nhân "miễn phí" từ nhiệm vụ này sang nhiệm vụ khác bằng cách cho phép tác nhân hoạt động ở mức độ trừu tượng cao hơn, nơi các con số tuyệt đối là không liên quan.

Hãy thảo luận một số ví dụ.

Quan sát (Observations)

Local Inventory perc_piles_wh — Số lượng gói hàng tại mỗi kho.

def upd_perc_piles_wh(env):
    piles_wh = env.metrics['piles_wh']
    return np.array([piles_wh / env.num_piles])

Ở đây, để làm cho quan sát bất biến về quy mô, tôi chia số lượng tồn kho hiện tại của kho piles_wh cho tổng số lượng gói hàng tuyệt đối sẽ đi qua mô phỏng env.num_piles. Bằng cách đó, tác nhân học cách ưu tiên dựa trên tỷ lệ khối lượng công việc hàng ngày mà nó hiện đang nắm giữ.

Local Inventory by Directions — Cho biết chính xác khối lượng hiện tại cần đi đâu. Đây là nền tảng của quyết định định tuyến.

def upd_warehouse_loading_level_by_directions(env):
    # Lấy tồn kho vật lý hiện tại tại nút cụ thể này
    parcels = env.get_current_warehouse_parcels()
    if parcels.empty:
        return np.zeros(env.num_vertices)

    # Chuẩn bị mảng đích đến
    destinations = parcels['destination'].values.astype(int)

    # Lấy số lượng cho các đích đến
    counts = np.bincount(destinations, minlength=env.num_vertices)
    return counts / len(parcels)

Đầu tiên, chúng ta lấy lượng hàng hiện có tại kho cụ thể này và xác minh rằng nó không trống. Tiếp theo, chúng ta trích xuất cột 'destination' dưới dạng mảng số nguyên, đại diện cho các ID kho đích. Cuối cùng, np.bincount tính toán sự phân phối của các gói hàng trên tất cả các đích đến. Bằng cách chia các số lượng này cho tổng số gói hàng hiện tại tại kho địa phương này, chúng ta chuyển đổi thể tích tuyệt đối thành một tỷ lệ. Kết quả là một vector số thực bất biến về quy mô, trong đó mỗi chỉ mục đại diện cho tỷ lệ phần trăm chính xác của hàng tồn kho địa phương hướng tới đỉnh cụ thể đó.

Closest Deadline by Direction (deadlines_min_dist) — Phân phối của các hạn chót gần nhất cho hàng tồn kho hiện tại.

def upd_deadlines_min_dist(env):
    parcels = env.get_current_warehouse_parcels()
    deadlines = np.ones(env.num_vertices) # 1.0 có nghĩa là không khẩn cấp hoặc không có kiện hàng
    if not parcels.empty:
        # Nhóm theo đích đến và tìm thời gian còn lại thực tế tối thiểu
        min_times = parcels.groupby('destination')['time_left'].min() / env.max_time_left
        # Gán các giá trị tối thiểu đã tính cho các chỉ mục đích đến tương ứng của chúng
        deadlines[min_times.index.astype(int)] = min_times.values
    return np.clip(deadlines, env.config.OBS_BOX_LOW, env.config.OBS_BOX_HIGH)

Ở đây, chúng ta lại kéo tồn kho địa phương hiện tại. Chúng ta khởi tạo một vector hạn chót có kích thước bằng đồ thị và điền nó bằng các số 1 (trong đó 1.0 có nghĩa là không khẩn cấp, và các giá trị tiến tới 0.0 cho biết một hạn chót đã đến).

Tiếp theo, chúng ta nhóm các kiện hàng theo đích đến của chúng và tìm time_left tối thiểu cho mỗi tuyến đường. Và chúng ta chia số này cho thời gian tối đa có thể còn lại để chuyển đổi thời gian tuyệt đối thành tỷ lệ tương đối (cách tiếp cận tương tự ở đây).

Vì vector kết quả chỉ chứa dữ liệu cho các đích đến hoạt động, nó là thưa thớt và không được căn chỉnh với không gian hành động của chúng ta. Chúng ta ánh xạ các hạn chót khẩn cấp này vào các ID vị trí tô pô chính xác của chúng bằng cách sử dụng các đích đến làm chỉ mục số nguyên.

Là một bước hoàn thiện, chúng ta cắt mảng để nghiêm ngặt nằm giữa 0 và 1. Đây là biện pháp an toàn quan trọng, vì các gói hàng quá hạn sẽ tạo ra các giá trị thời gian âm, điều này sẽ phá vỡ các giới hạn quan sát của mạng nơ-ron.

Do đó, thông thường, một nhiệm vụ mới ngụ ý một không gian quan sát hoàn toàn mới. Tuy nhiên, trong phương pháp lai của tôi, điều này không đúng: các tác nhân có thể được chuyển từ kho này sang kho khác theo thiết kế, bất kể số lượng gói hàng, phương tiện hoặc nút lân cận.

Zero-Padding hoặc Maximum Node Padding

Trong phiên bản hiện tại, ngoại lệ duy nhất là tổng số kho trong mạng (thứ tự của đồ thị). Điều này phải được biết trước, vì việc chuyển đổi chỉ có thể thực hiện được đối với một đồ thị có cùng kích thước tối đa.

Chúng tôi xử lý giới hạn này bằng cách sử dụng zero-padding tiêu chuẩn. Chúng tôi xác định kích thước đồ thị tối đa (ví dụ: 100 đỉnh), và đối với bất kỳ đồ thị nhỏ hơn nào, chúng tôi che các nút không tồn tại bằng các giá trị 0. Nếu kích thước đồ thị tối đa của bạn là 100 đỉnh, bạn chỉ cần triển khai tác nhân trên các đỉnh hoạt động hiện có và che phần còn lại bằng các số 0. Logic tương tự cũng áp dụng cho việc quan sát các nút lân cận: kích thước vector luôn bằng thứ tự của đồ thị logistics, nhưng chỉ các nút lân cận có sẵn (có thể quan sát) mới có giá trị khác 0.

MARL (Học tăng cường đa tác nhân)

Giải pháp tốt trong bối cảnh thay đổi

Bây giờ hãy giải quyết một vấn đề khác: thực tế là biến động.

Một cơn bão tuyết đột ngột ập đến, thuế 3PL tăng gấp ba, hoặc có sự gia tăng đơn hàng khổng lồ ngay trước ngày lễ. Một công ty cần có khả năng thích nghi về mặt vận hành để tồn tại qua điều này. Lưu ý rằng các quy tắc vật lý của trò chơi (kích thước xe, bản đồ) vẫn giữ nguyên, nhưng bối cảnh lại thay đổi hoàn toàn.

Các heuristic tĩnh (ví dụ: quy tắc mã hóa cứng để "điều phối ở 85% công suất") sẽ ngay lập tức bắt đầu tạo ra những khoản lỗ khổng lồ trong các kịch bản này. Một lợi thế lớn của phương pháp MARL là nó tổng quát hóa tình huống dựa trên các quan sát. Nó dịch chuyển ngưỡng ra quyết định của mình một cách linh hoạt "trên không trung" để phản ứng với các quan sát đang thay đổi này.

Một lợi ích tuyệt vời khác của MARL là vấn đề được chia thành các phần nhỏ hơn, được giải quyết độc lập bởi các tác nhân. Kiến trúc đa tác nhân ngăn cản chúng ta bị buộc phải giải quyết toàn bộ vấn đề mạng lưới với một "tác nhân siêu lớn" (mega-agent). Tuy nhiên, tôi sẽ đề cập chi tiết hơn về điều đó trong bài viết tiếp theo của mình về giảm chiều dữ liệu.

Triển khai MARL

Một vài lời về cách chúng tôi cụ thể triển khai khía cạnh đa tác nhân. Tôi đã đối mặt với hai thách thức riêng biệt:

  1. Vì hành động của các tác nhân phụ thuộc lẫn nhau, chúng có thể dễ dàng thích nghi với các hành vi không tối ưu của nhau. Do đó, ở các giai đoạn đầu của quá trình huấn luyện, MARL truyền thống có thể rất không ổn định.
  2. Tôi muốn giữ lại stack OpenAI Gym + Stable-baselines, vốn không hỗ trợ rõ ràng việc huấn luyện MARL gốc.

Đồng thời, việc quay lại giải pháp tác nhân đơn là không thể do số lượng kho khổng lồ, và cách tiếp cận "một tác nhân siêu lớn" đã bị loại bỏ ở giai đoạn kiến trúc (chi tiết trong Kiến trúc Phần 1).

Kết quả là, tôi đã thiết kế quy trình huấn luyện sau:

  • Thay vì huấn luyện tất cả các tác nhân cùng lúc, chúng tôi chỉ huấn luyện một tác nhân — tác nhân "hiện tại" cho mỗi tập (episode).
  • Trong khi tác nhân "hiện tại" huấn luyện, các tác nhân khác hoạt động hoàn toàn ở chế độ suy luận đóng băng (frozen inference mode).
  • Một "bước" (step) môi trường toàn cầu bao gồm việc thực hiện tuần tự của tất cả các tác nhân: tác nhân "huấn luyện" thực hiện hành động của nó, tiếp theo là các tác nhân "suy luận".

Dưới đây là cách nó trông trong mã:

# Khởi tạo môi trường và tải trọng số tốt nhất hiện tại cho tất cả các tác nhân
env.env_method('prepare_env', best_agent_paths)

for i in range(NUM_MARL_LOOPS):
    for training_ag_id in agents.keys():
        # Chuyển góc nhìn của môi trường sang tác nhân hoạt động hiện tại
        env.env_method('set_cur_training_agent', training_ag_id)

        # Lấy mô hình chính sách của tác nhân hoạt động
        agent_obj = agents.get(training_ag_id)

        # Chỉ huấn luyện tác nhân NÀY
        # (Điều này sẽ gọi env.step() dưới mui xe
        # và sẽ chạy các tác nhân khác ở chế độ suy luận đóng băng)
        agent_obj = agent_obj.learn(
            TS_PER_AGENT,
            reset_num_timesteps=False,
            tb_log_name=f"Agent_{training_ag_id}",
            callback=callbacks,
        )

        # Lưu trọng số đã cập nhật và đẩy chúng vào bộ nhớ đệm mô hình trực tiếp
        agent_obj.save(last_agent_paths[training_ag_id])
        agents[training_ag_id] = agent_obj

Đầu tiên, prepare_env() được thực thi, thiết lập các giá trị mặc định và đường dẫn để lưu các tác nhân. Sau đó, chúng tôi khởi chạy vòng lặp chính, quy định số lần huấn luyện NUM_MARL_LOOPS trên toàn bộ mạng lưới.

Bên trong đó, chúng tôi xử lý việc huấn luyện một tác nhân "hiện tại" duy nhất. agents là một từ điển: khóa là ID, giá trị là các đối tượng mô hình. Phương thức set_cur_training_agent() chuyển đổi góc nhìn của môi trường. Sau đó, chúng ta lấy mô hình của tác nhân hiện tại và kích hoạt .learn(). Sau đó, khá đơn giản: chúng tôi lưu mô hình và cập nhật từ điển tác nhân.

Bây giờ, hãy xem ngắn gọn về cách bước này thực sự thực thi bên trong môi trường:

def step(self, action) -> tuple[dict, float, bool, dict]:
    # Tác nhân Huấn luyện thực hiện hành động của mình
    reward = self.process_packages(action)
    self.process_inflow() # Địa phương hóa tại nút của tác nhân hoạt động
    self.update_state_and_metrics(reward)
    self.save_current_act_agent()

    # Vòng lặp Suy luận: Các tác nhân khác lần lượt thực hiện lượt của họ
    for ag_id in self.inference_agents.keys():
        if ag_id == self.cur_training_agent:
            continue # Bỏ qua tác nhân huấn luyện (nó đã hành động)

        # Chuyển đổi bối cảnh môi trường sang tác nhân suy luận hiện tại
        self.current_origin = ag_id
        self.load_act_agent()

        # Tải mô hình và nhận dự đoán được che (masked prediction)
        agent_obj = self.inference_agents.get(ag_id)
        action_mask = self.valid_action_mask()
        ag_action, _ = agent_obj.predict(self.state, action_masks=action_mask)

        # Thực hiện hành động của tác nhân suy luận
        sub_reward = self.process_packages(ag_action)
        self.update_state_and_metrics(sub_reward)
        self.save_current_act_agent()

    # Khôi phục trạng thái môi trường về góc nhìn của Tác nhân Huấn luyện
    self.current_origin = self.cur_training_agent
    self.load_act_agent()

    # Kiểm tra điều kiện kết thúc
    done = self.check_if_done()
    self.step_n += 1
    return self.state, reward, done, self.info

Đầu tiên, chúng ta thực hiện hành động cho tác nhân huấn luyện "hiện tại". Chúng ta bắt đầu bằng cách xử lý các kiện hàng hiện có trong hệ thống thông qua self.process_packages(action), nơi hành động của tác nhân được áp dụng cho logic môi trường. Nói cách khác, nếu tác nhân quyết định điều phối một số xe tải đến một số kho, bộ giải LP thực thi điều đó ở đây.

Sau đó, chúng ta nhận các gói hàng mới đến trong self.process_inflow(), cập nhật trạng thái và số liệu trong self.update_state_and_metrics(), và lưu ngữ cảnh tác nhân trong save_current_act_agent().

Bây giờ phần thú vị bắt đầu. Vì tác nhân huấn luyện hiện tại đã thực hiện hành động của mình, chúng ta cần suy luận các hành động cho phần còn lại của mạng lưới. Vì vậy, chúng ta bắt đầu một vòng lặp for trên các tác nhân có sẵn của mình, bỏ qua tác nhân đang huấn luyện. Bên trong vòng lặp này, chúng ta chuyển đổi ngữ cảnh tác nhân "hiện tại", tải mô hình của nó và tạo ra một suy luận bằng cách cung cấp trạng thái hiện tại và mặt nạ hành động vào agent_obj.predict().

Từ đó, quy trình giống hệt với tác nhân huấn luyện: chúng ta xử lý hành động được tạo ra (lần này là bởi một tác nhân suy luận) và cập nhật môi trường. Cuối cùng, ở cuối vòng lặp, chúng ta chuyển đổi ngữ cảnh trở lại tác nhân huấn luyện hiện tại và chuyển kết quả cuối cùng trở lại vòng lặp.

Trong các tập tiếp theo

Vì vậy, bây giờ chúng ta có một vòng lặp huấn luyện hoàn toàn chức năng. Mã chạy được và môi trường MARL sẽ khởi tạo, nhưng làm thế nào chúng ta có thể đảm bảo quy trình huấn luyện này thực sự:

  • Kết thúc trong một khoảng thời gian hợp lý?
  • Khiến các mô hình hội tụ?
  • Sản xuất các chiến lược định tuyến "đủ tốt"?

Đó là những gì tôi sẽ phân tích trong các bài viết tiếp theo. Hãy theo dõi!

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗