value-based-qlearning / code /train_qlearning.py
lin267's picture
value-based 实践:Q-Learning 智能体(FrozenLake + Taxi) + 回放视频 + 评估分数
c3d6fc9 verified
Raw History Blame Contribute Delete
28.1 kB
# -*- coding: utf-8 -*-
"""
第3周 · 实战3(value-based / Q-Learning)—— 训练脚本
=====================================================
对应 PPT《第3周-深度强化学习原理实践》第 27–40 页:
· 第 29 页:Q 表 / Q 函数 / 最优策略 pi*(s) = argmax_a Q*(s,a)
· 第 31–32 页:FrozenLake-v1(4x4 不滑版)观测/动作/奖励
· 第 33 页:Q 表初始化 + 贪心策略
· 第 34 页:epsilon-greedy 行为策略 + 超参数
· 第 35 页:Q-Learning 训练循环(Bellman 更新)
· 第 36–37 页:训练、Q 表可视化、评估
· 第 38–40 页:Taxi-v3
本脚本从零手写 Q-Learning(不依赖 stable-baselines3),一次跑通三个环境:
frozenlake4x4 FrozenLake-v1 4x4,is_slippery=False(PPT 第一部分,确定性)
frozenlake8x8 FrozenLake-v1 8x8,is_slippery=True (PPT 第 31 页提到的进阶版本,随机性)
taxi Taxi-v3(PPT 第二部分,500 个状态 × 6 个动作)
输出(全部落在本目录下):
logs/<run>/train_console.log 完整控制台日志(含环境自检/超参/每 N 回合进度/每次评估)
logs/<run>/progress.csv 结构化进度数据(画曲线 / 写报告用)
logs/<run>/train_curve.png 训练曲线(回报、epsilon、成功率、评估)
models/qtable_<run>.pkl 训练出的模型文件(Q 表 + 全套配置,可被 evaluate_and_record.py 直接加载)
用法(本机已装好 DRL 环境,无需再装包):
D:\\study\\robot\\pyenv\\DRL\\python.exe train_qlearning.py --env frozenlake4x4
D:\\study\\robot\\pyenv\\DRL\\python.exe train_qlearning.py --env frozenlake8x8
D:\\study\\robot\\pyenv\\DRL\\python.exe train_qlearning.py --env taxi
只改一个变量做对照实验(例如演示"epsilon 衰减太慢"):
python train_qlearning.py --env frozenlake4x4 --decay-rate 0.0001 --run-name fl4x4_slow_decay
"""
import argparse
import csv
import os
import pickle
import random
import sys
import time
from datetime import datetime
import gymnasium as gym
import matplotlib
matplotlib.use("Agg") # 不弹窗,直接存 png
import matplotlib.pyplot as plt
import numpy as np
# ======================================================================
# 全局路径
# ======================================================================
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_ROOT = os.path.join(BASE_DIR, "logs")
MODEL_ROOT = os.path.join(BASE_DIR, "models")
# 中文字体:Windows 自带的微软雅黑 / 黑体
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
# ======================================================================
# 第 38–39 页:Taxi-v3 官方评估种子(PPT 原文:请勿修改 EVAL_SEED)
# 作用:让所有同学都在相同的出租车起始位置上评估,班级分数才可比。
# ======================================================================
EVAL_SEED_TAXI = [
16, 54, 165, 177, 191, 191, 120, 80, 149, 178, 48, 38, 6, 125, 174, 73, 50,
172, 100, 148, 146, 6, 25, 40, 68, 148, 49, 167, 9, 97, 164, 176, 61, 7, 54,
55, 161, 131, 184, 51, 170, 12, 120, 113, 95, 126, 51, 98, 36, 135, 54, 82,
45, 95, 89, 59, 95, 124, 9, 113, 58, 85, 51, 134, 121, 169, 105, 21, 30, 11,
50, 65, 12, 43, 82, 145, 152, 97, 106, 55, 31, 85, 38, 112, 102, 168, 123, 97,
21, 83, 158, 26, 80, 63, 5, 81, 32, 11, 28, 148,
]
# ======================================================================
# 环境 + 超参数配置(严格对应 PPT 第 34 页与第 39 页)
# ======================================================================
EXPERIMENTS = {
# ---- 第一部分:冰冻湖 4x4,不滑(确定性) -------------------------
"frozenlake4x4": dict(
title="FrozenLake-v1(4x4,is_slippery=False)",
env_id="FrozenLake-v1",
env_kwargs=dict(map_name="4x4", is_slippery=False),
n_training_episodes=10000, # 第 34 页:总训练回合数
learning_rate=0.7, # 第 34 页:学习率
gamma=0.95, # 第 34 页:折扣率
max_steps=99, # 第 34 页:每回合最大步数
max_epsilon=1.0, # 第 34 页:开始时探索概率
min_epsilon=0.05, # 第 34 页:最小探索概率
decay_rate=0.0005, # 第 34 页:探索概率指数衰减率
n_eval_episodes=100, # 第 34 页:评估回合数
eval_seed=None, # 第 34 页 eval_seed = []
eval_every=1000,
log_every=500,
),
# ---- 第 31 页提到的另一种模式:4x4 + 滑冰(is_slippery=True) ------
# 与第一部分只差一个变量 is_slippery,但冰面会以 1/3 概率把智能体推偏,
# 所以同样的超参数明显更难学:这里把探索期拉长(min_epsilon 0.1 / decay 0.0003)
# 并多训一倍回合,成功率才能从 ~20% 提到 ~75%。
"frozenlake4x4_slippery": dict(
title="FrozenLake-v1(4x4,is_slippery=True)",
env_id="FrozenLake-v1",
env_kwargs=dict(map_name="4x4", is_slippery=True),
n_training_episodes=20000,
learning_rate=0.7,
gamma=0.95,
max_steps=99,
max_epsilon=1.0,
min_epsilon=0.1,
decay_rate=0.0003,
n_eval_episodes=100,
eval_seed=list(range(100)),
eval_every=2000,
log_every=1000,
),
# ---- 第 31 页提到的进阶版:冰冻湖 8x8(地图变大) -----------------
# 8x8 最优路径约 14 步,比 4x4 长一倍多;Q 表从 16x4 变成 64x4。
# 注意:8x8 的稀疏奖励让"随机探索"很难碰到终点,如果沿用 4x4 的快速 epsilon 衰减,
# Q 表会一直是全 0(这一点在实验报告里作为对照实验记录)。
# 所以这里把探索期拉长(min_epsilon 0.3 / decay 0.00003),让智能体先"逛遍"地图。
"frozenlake8x8": dict(
title="FrozenLake-v1(8x8,is_slippery=False)",
env_id="FrozenLake-v1",
env_kwargs=dict(map_name="8x8", is_slippery=False),
n_training_episodes=60000,
learning_rate=0.8,
gamma=0.95,
max_steps=200,
max_epsilon=1.0,
min_epsilon=0.3,
decay_rate=0.00003,
n_eval_episodes=100,
eval_seed=list(range(100)),
eval_every=6000,
log_every=3000,
),
# ---- 第二部分:Taxi-v3 -------------------------------------------
"taxi": dict(
title="Taxi-v3",
env_id="Taxi-v3",
env_kwargs=dict(),
n_training_episodes=25000, # 第 39 页:总训练回合数
learning_rate=0.7,
gamma=0.95,
max_steps=99,
max_epsilon=1.0,
min_epsilon=0.05,
decay_rate=0.005, # 第 39 页:0.005(比冰冻湖衰减快,因为回合更长)
n_eval_episodes=100,
eval_seed=EVAL_SEED_TAXI, # 第 39 页:官方评估种子
eval_every=2500,
log_every=1000,
),
}
# ======================================================================
# 工具:同时写控制台 + 日志文件(这就是"有日志输出的训练代码")
# ======================================================================
class Tee:
"""把 print 的内容同时写进 stdout 和日志文件。"""
def __init__(self, log_path):
self.terminal = sys.stdout
self.log_file = open(log_path, "w", encoding="utf-8")
def write(self, message):
self.terminal.write(message)
self.log_file.write(message)
self.log_file.flush()
def flush(self):
self.terminal.flush()
self.log_file.flush()
def timestamp():
return datetime.now().strftime("%H:%M:%S")
# ======================================================================
# 第 33 页:创建并初始化 Q 表
# Q 表大小 = (状态数, 动作数),初始全 0("在开始时 Q 表是无用的")
# ======================================================================
def initialize_q_table(state_space, action_space):
return np.zeros((state_space, action_space))
# ======================================================================
# 第 33 页:贪婪策略(更新策略 / 最终策略)
# pi*(s) = argmax_a Q(s,a)
# ======================================================================
def greedy_policy(q_table, state):
return int(np.argmax(q_table[state]))
# ======================================================================
# 第 34 页:epsilon-greedy 策略(行为策略)
# 以 epsilon 的概率探索(随机动作),否则利用(贪心动作)
# —— Q-Learning 是离线策略(off-policy):行动用 epsilon-greedy,
# 更新时却用 max_a Q(s', a),两套策略。
# ======================================================================
def epsilon_greedy_policy(q_table, state, epsilon, action_space):
if random.random() > epsilon:
return greedy_policy(q_table, state) # 利用
return int(action_space.sample()) # 探索
# ======================================================================
# 第 35 页:训练循环
# Q(s,a) <- Q(s,a) + lr * [ r + gamma * max_a' Q(s',a') - Q(s,a) ]
# ======================================================================
def train(
env,
q_table,
n_training_episodes,
max_steps,
learning_rate,
gamma,
max_epsilon,
min_epsilon,
decay_rate,
train_seed=0,
log_every=500,
eval_every=None,
eval_kwargs=None,
):
"""
返回:q_table、每回合数据、评估历史
"""
episode_rewards = []
episode_steps = []
episode_success = [] # 是否到达目标(FrozenLake 的 reward==1 / Taxi 的 reward==20)
epsilons = []
eval_history = [] # [(episode, mean, std, success_rate), ...]
started = time.time()
eval_kwargs = eval_kwargs or {}
for episode in range(n_training_episodes):
# ---- epsilon 指数衰减:越往后越少探索 ----
epsilon = min_epsilon + (max_epsilon - min_epsilon) * np.exp(-decay_rate * episode)
epsilons.append(epsilon)
# ---- 重置环境 ----
# 每回合都用固定种子 reset(seed=train_seed+episode):
# 同一个 --seed 重跑,起点序列、滑冰随机性和探索随机性完全一致 -> 分数可复现。
# 注意:gymnasium 的 env.reset(seed=...) 只给 env 播种,不给 action_space 播种,
# 而 epsilon-greedy 的"探索"要用 env.action_space.sample(),
# 所以这里再单独给 action_space 播种一次(这一行是可复现的关键)。
if train_seed is not None:
state, _ = env.reset(seed=train_seed + episode)
env.action_space.seed(train_seed + episode)
else:
state, _ = env.reset()
total_reward = 0.0
reached_goal = False
for step in range(max_steps):
# 1) 用 epsilon-greedy 选择动作 At
action = epsilon_greedy_policy(q_table, state, epsilon, env.action_space)
# 2) 执行动作,观察 Rt+1 和 St+1
new_state, reward, terminated, truncated, _ = env.step(action)
done = terminated or truncated
# 3) Bellman 更新(Q-Learning 的核心这一行)
# 注意:真实终止状态没有未来价值,next_q 取 0;
# PPT 截图里没做这个掩码,这里修正后结果更稳(FrozenLake 两者等价)。
next_q = 0.0 if terminated else float(np.max(q_table[new_state]))
q_table[state][action] += learning_rate * (
reward + gamma * next_q - q_table[state][action]
)
total_reward += reward
state = new_state
if done:
reached_goal = reward > 0
break
episode_rewards.append(total_reward)
episode_steps.append(step + 1)
episode_success.append(1 if reached_goal else 0)
# ---- 每 log_every 回合打印一行进度(可直接粘进实验报告)----
if (episode + 1) % log_every == 0 or episode == 0:
window = episode_rewards[-min(100, len(episode_rewards)):]
window_success = episode_success[-min(100, len(episode_success)):]
window_steps = episode_steps[-min(100, len(episode_steps)):]
elapsed_min = (time.time() - started) / 60.0
print(
f"[train] ep={episode + 1:>6}/{n_training_episodes} | "
f"eps={epsilon:.3f} | "
f"最近100回合平均回报={np.mean(window):6.2f} | "
f"成功率={np.mean(window_success) * 100:5.1f}% | "
f"平均步数={np.mean(window_steps):5.1f} | "
f"Q非零项={np.count_nonzero(q_table):5d} | "
f"用时={elapsed_min:5.2f}min"
)
# ---- 周期评估(用贪婪策略跑独立回合)----
if eval_every and (episode + 1) % eval_every == 0:
mean_r, std_r, succ = evaluate_agent(
env, q_table, max_steps, eval_kwargs.get("n_eval", 20),
eval_kwargs.get("eval_seed"),
)
eval_history.append((episode + 1, mean_r, std_r, succ))
print(
f"[eval ] ep={episode + 1:>6} | 贪婪策略 {eval_kwargs.get('n_eval', 20)} 局: "
f"回报={mean_r:6.2f} ± {std_r:5.2f} | 成功率={succ * 100:5.1f}%"
)
return q_table, episode_rewards, episode_steps, episode_success, epsilons, eval_history
# ======================================================================
# 第 36 页:评估智能体(贪婪策略,返回平均回报 ± 标准差)
# ======================================================================
def evaluate_agent(env, q_table, max_steps, n_episodes, seeds=None):
rewards = []
successes = []
lengths = []
for i in range(n_episodes):
if seeds is not None and i < len(seeds):
state, _ = env.reset(seed=int(seeds[i]))
else:
state, _ = env.reset()
total_reward = 0.0
for step in range(max_steps):
action = greedy_policy(q_table, state)
state, reward, terminated, truncated, _ = env.step(action)
total_reward += reward
if terminated or truncated:
break
rewards.append(total_reward)
lengths.append(step + 1)
successes.append(1 if total_reward > 0 else 0)
return float(np.mean(rewards)), float(np.std(rewards)), float(np.mean(successes))
# ======================================================================
# 画训练曲线
# ======================================================================
def moving_average(values, window=100):
values = np.asarray(values, dtype=float)
if len(values) < window:
return values
kernel = np.ones(window) / window
return np.convolve(values, kernel, mode="valid")
def plot_curve(run_dir, title, episode_rewards, epsilons, episode_success,
eval_history, n_training_episodes):
fig, axes = plt.subplots(2, 1, figsize=(12, 8), sharex=False)
# 上图:每回合回报 + 100 回合滑动平均 + 周期评估
ax = axes[0]
ax.plot(np.arange(1, len(episode_rewards) + 1), episode_rewards,
color="#93c5fd", linewidth=0.5, alpha=0.6, label="每回合回报")
ma = moving_average(episode_rewards, 100)
ax.plot(np.arange(100, 100 + len(ma)), ma,
color="#1d4ed8", linewidth=2, label="100 回合滑动平均")
if eval_history:
xs = [h[0] for h in eval_history]
ys = [h[1] for h in eval_history]
errs = [h[2] for h in eval_history]
ax.errorbar(xs, ys, yerr=errs, fmt="o--", color="#dc2626", markersize=4,
linewidth=1.2, capsize=3, label="周期评估(贪婪策略)")
ax.set_xlabel("训练回合 episode")
ax.set_ylabel("回报 reward")
ax.set_title(f"{title} · 训练曲线(共 {n_training_episodes} 回合)")
# 早期有几局极端负分(例如 Taxi 乱按上下客被 -10 罚到 -400),会把曲线压扁,
# 这里按 0.5%~99.5% 分位数裁一下 y 轴,极端点仍在图外但趋势看得清。
rewards_arr = np.asarray(episode_rewards, dtype=float)
low = min(float(np.percentile(rewards_arr, 0.5)), 0.0) - 2
high = max(float(np.percentile(rewards_arr, 99.5)), 1.0) + 1
if high - low > 5:
ax.set_ylim(low, high)
ax.legend(loc="lower right", fontsize=9)
ax.grid(alpha=0.3)
# 下图:epsilon 衰减 + 探索/利用的阶段
ax2 = axes[1]
ax2.plot(np.arange(1, len(epsilons) + 1), epsilons, color="#f59e0b", linewidth=2,
label="epsilon(探索概率)")
ax2.fill_between(np.arange(1, len(epsilons) + 1), 0, epsilons, color="#f59e0b", alpha=0.15)
ax2.set_xlabel("训练回合 episode")
ax2.set_ylabel("epsilon")
ax2.set_title("epsilon 指数衰减:从\"大胆探索\"到\"相信 Q 表\"")
ax2.grid(alpha=0.3)
ax2.legend(loc="upper right", fontsize=9)
fig.tight_layout()
path = os.path.join(run_dir, "train_curve.png")
fig.savefig(path, dpi=130)
plt.close(fig)
return path
def write_progress_csv(run_dir, rewards, steps, success, epsilons, eval_history):
path = os.path.join(run_dir, "progress.csv")
with open(path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["episode", "epsilon", "episode_reward", "episode_steps", "success"])
for i, (r, s, ok, eps) in enumerate(zip(rewards, steps, success, epsilons), start=1):
writer.writerow([i, f"{eps:.6f}", f"{r:.6f}", s, ok])
writer.writerow([])
writer.writerow(["eval_episode", "eval_mean_reward", "eval_std_reward", "eval_success_rate"])
for ep, mean_r, std_r, succ in eval_history:
writer.writerow([ep, f"{mean_r:.6f}", f"{std_r:.6f}", f"{succ:.4f}"])
return path
# ======================================================================
# 主流程
# ======================================================================
def main():
parser = argparse.ArgumentParser(description="Q-Learning 训练(FrozenLake / Taxi)")
parser.add_argument("--env", choices=list(EXPERIMENTS.keys()), default="frozenlake4x4",
help="用哪个环境训练")
parser.add_argument("--episodes", type=int, default=None, help="覆盖总训练回合数")
parser.add_argument("--learning-rate", type=float, default=None, help="覆盖学习率 lr")
parser.add_argument("--gamma", type=float, default=None, help="覆盖折扣率 gamma")
parser.add_argument("--decay-rate", type=float, default=None, help="覆盖 epsilon 衰减率")
parser.add_argument("--min-epsilon", type=float, default=None, help="覆盖最小探索概率")
parser.add_argument("--max-steps", type=int, default=None, help="覆盖每回合最大步数")
parser.add_argument("--eval-episodes", type=int, default=None, help="覆盖最终评估回合数")
parser.add_argument("--eval-every", type=int, default=None, help="每多少回合评估一次")
parser.add_argument("--log-every", type=int, default=None, help="每多少回合打印一行日志")
parser.add_argument("--seed", type=int, default=0, help="训练随机种子(默认 0)")
parser.add_argument("--run-name", type=str, default=None, help="日志/模型文件名前缀")
parser.add_argument("--slippery", action="store_true",
help="把 FrozenLake 的冰面切换成滑冰版(is_slippery=True,随机转移)")
args = parser.parse_args()
cfg = dict(EXPERIMENTS[args.env])
if args.slippery:
cfg["env_kwargs"] = dict(cfg["env_kwargs"], is_slippery=True)
cfg["title"] += " · 滑冰版"
# 滑冰版起点随机性大,固定 100 个起点种子才能公平比较
cfg["eval_seed"] = list(range(100))
# 命令行覆盖(方便做"一次只改一个变量"的对照实验)
if args.episodes is not None:
cfg["n_training_episodes"] = args.episodes
if args.learning_rate is not None:
cfg["learning_rate"] = args.learning_rate
if args.gamma is not None:
cfg["gamma"] = args.gamma
if args.decay_rate is not None:
cfg["decay_rate"] = args.decay_rate
if args.min_epsilon is not None:
cfg["min_epsilon"] = args.min_epsilon
if args.max_steps is not None:
cfg["max_steps"] = args.max_steps
if args.eval_episodes is not None:
cfg["n_eval_episodes"] = args.eval_episodes
if args.eval_every is not None:
cfg["eval_every"] = args.eval_every
if args.log_every is not None:
cfg["log_every"] = args.log_every
cfg["train_seed"] = args.seed
run_name = args.run_name or args.env
run_dir = os.path.join(LOG_ROOT, run_name)
os.makedirs(run_dir, exist_ok=True)
os.makedirs(MODEL_ROOT, exist_ok=True)
# 每次运行先设置随机种子:Python random 管探索、np 管 Q 表初始化
random.seed(args.seed)
np.random.seed(args.seed)
tee = Tee(os.path.join(run_dir, "train_console.log"))
sys.stdout = tee
try:
print("=" * 78)
print(f"Q-Learning 训练 · {cfg['title']}")
print(f"运行名称: {run_name} 开始时间: {datetime.now():%Y-%m-%d %H:%M:%S}")
print("=" * 78)
# ---------------- 第 31–32 页:创建并理解环境 ----------------
env = gym.make(cfg["env_id"], render_mode="rgb_array", **cfg["env_kwargs"])
state_space = env.observation_space.n
action_space = env.action_space.n
sample_state, _ = env.reset(seed=args.seed)
print("\n[环境自检]")
print(f" env_id = {cfg['env_id']}")
print(f" env_kwargs = {cfg['env_kwargs']}")
print(f" 观测空间 = {env.observation_space} -> {state_space} 个状态")
print(f" 动作空间 = {env.action_space} -> {action_space} 个动作")
if cfg["env_id"] == "FrozenLake-v1":
ncol = env.unwrapped.ncol
print(f" 一次 reset 的观测 = {sample_state}"
f"(整数 = 状态编号,{env.unwrapped.nrow}x{ncol} 地图按 row*{ncol}+col 编码)")
else:
print(f" 一次 reset 的观测 = {sample_state}(整数 = 状态编号,共 500 个)")
print(f" 每回合最大步数 = {cfg['max_steps']}")
# ---------------- 第 33 页:Q 表 ----------------
q_table = initialize_q_table(state_space, action_space)
print("\n[Q 表初始化]")
print(f" Q 表形状 = {q_table.shape}(= (状态数, 动作数)),初始值全为 0")
print(f" 需要学习的参数个数 = {state_space * action_space}")
# ---------------- 第 34 页:超参数 ----------------
print("\n[超参数]")
print(f" n_training_episodes = {cfg['n_training_episodes']}")
print(f" learning_rate = {cfg['learning_rate']}")
print(f" gamma = {cfg['gamma']}")
print(f" max_steps = {cfg['max_steps']}")
print(f" max_epsilon = {cfg['max_epsilon']}")
print(f" min_epsilon = {cfg['min_epsilon']}")
print(f" decay_rate = {cfg['decay_rate']}")
print(f" n_eval_episodes = {cfg['n_eval_episodes']}")
print(f" eval_seed = {'官方 100 个种子' if cfg['eval_seed'] else '[](不指定)'}")
print(f" train_seed = {args.seed}")
# ---------------- 第 35 页:训练 ----------------
print(f"\n[开始训练] {timestamp()} 每 {cfg['log_every']} 回合打印一行,"
f"每 {cfg['eval_every']} 回合评估一次")
print(f" 可复现性:每回合 env.reset(seed={args.seed}+episode),同 --seed 重跑分数一致")
started = time.time()
(q_table, rewards, steps, success, epsilons, eval_history) = train(
env=env,
q_table=q_table,
n_training_episodes=cfg["n_training_episodes"],
max_steps=cfg["max_steps"],
learning_rate=cfg["learning_rate"],
gamma=cfg["gamma"],
max_epsilon=cfg["max_epsilon"],
min_epsilon=cfg["min_epsilon"],
decay_rate=cfg["decay_rate"],
train_seed=args.seed,
log_every=cfg["log_every"],
eval_every=cfg["eval_every"],
eval_kwargs=dict(
n_eval=20,
eval_seed=cfg["eval_seed"],
),
)
elapsed = (time.time() - started) / 60.0
print(f"\n[训练完成] {timestamp()} 总用时 = {elapsed:.2f} 分钟")
# ---------------- 第 36 页:最终评估 ----------------
print(f"\n[最终评估] 用贪婪策略跑 {cfg['n_eval_episodes']} 局(独立于训练)")
mean_r, std_r, succ = evaluate_agent(
env, q_table, cfg["max_steps"], cfg["n_eval_episodes"], cfg["eval_seed"]
)
print(f" Mean_reward = {mean_r:.2f} +/- {std_r:.2f}")
print(f" 成功率 = {succ * 100:.0f}%")
if eval_history:
best = max(eval_history, key=lambda h: h[1])
print(f" 训练途中最好的周期评估:{best[1]:.2f} ± {best[2]:.2f} @ 第 {best[0]} 回合")
print(f" Q 表非零项 = {np.count_nonzero(q_table)} / {q_table.size}")
print(f" Q 表最大价值 = {q_table.max():.4f}")
# ---------------- 第 36–37 页:Q 表可视化 + 训练曲线 ----------------
curve_path = plot_curve(run_dir, cfg["title"], rewards, epsilons, success,
eval_history, cfg["n_training_episodes"])
csv_path = write_progress_csv(run_dir, rewards, steps, success, epsilons, eval_history)
print(f"\n[输出] 训练曲线 = {os.path.relpath(curve_path, BASE_DIR)}")
print(f"[输出] 进度 CSV = {os.path.relpath(csv_path, BASE_DIR)}")
# ---------------- 第 37 页:保存模型(= "训练出的模型文件") ----------------
model = {
"run_name": run_name,
"title": cfg["title"],
"env_id": cfg["env_id"],
"env_kwargs": cfg["env_kwargs"],
"max_steps": cfg["max_steps"],
"n_training_episodes": cfg["n_training_episodes"],
"n_eval_episodes": cfg["n_eval_episodes"],
"eval_seed": cfg["eval_seed"],
"learning_rate": cfg["learning_rate"],
"gamma": cfg["gamma"],
"max_epsilon": cfg["max_epsilon"],
"min_epsilon": cfg["min_epsilon"],
"decay_rate": cfg["decay_rate"],
"train_seed": args.seed,
"qtable": q_table,
"metrics": {
"mean_reward": mean_r,
"std_reward": std_r,
"success_rate": succ,
"training_minutes": elapsed,
},
"trained_at": datetime.now().isoformat(timespec="seconds"),
}
model_path = os.path.join(MODEL_ROOT, f"qtable_{run_name}.pkl")
with open(model_path, "wb") as f:
pickle.dump(model, f)
print(f"[输出] 模型文件 = {os.path.relpath(model_path, BASE_DIR)} "
f"({os.path.getsize(model_path) / 1024:.1f} KB)")
print("\n" + "=" * 78)
print(f"完成。下一步:python evaluate_and_record.py --model models/qtable_{run_name}.pkl")
print("=" * 78)
env.close()
finally:
sys.stdout = tee.terminal
tee.log_file.close()
if __name__ == "__main__":
main()