File size: 28,072 Bytes
c3d6fc9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
# -*- 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()