小坷笔记:强化学习零碎知识点笔记

强化学习:折扣回报与贝尔曼方程核心概念

1. 折扣回报 (Discounted Return) 的理论公式:
衡量一个状态的好坏,不仅看当下,还要看未来。

$$
G_t = R_{t+1} + \gamma R_{t+2} + \gamma^2 R_{t+3} + \dots = \sum_{k=0}^{\infty} \gamma^k R_{t+k+1}
$$

  • $\gamma$ (Gamma):折扣率 (0 到 1 之间)。决定了智能体是“目光短浅”($\gamma$ 接近 0)还是“高瞻远瞩”($\gamma$ 接近 1)。

2. 解决“未知未来”的两大落地方法:

  • 蒙特卡洛方法 (Monte Carlo):必须等一个回合 (Episode) 彻底结束,拿到所有真实的 $R$ 序列后,再反向计算 $G_t$ 进行更新。属于“事后结算”。
  • 时序差分法 (Temporal Difference, TD):不需要等回合结束,走一步算一步。核心在于利用贝尔曼方程
    $$V(s_t) = \mathbb{E}[R_{t+1} + \gamma V(s_{t+1})]$$
    利用【当下真实的即时奖励 $R_{t+1}$】加上【下一个状态的预估价值 $\gamma V(s_{t+1})$】,来更新【当前状态的价值 $V(s_t)$】。

核心机制:经验回放 (Experience Replay)

1. 经验元组 (Transition Tuple)
智能体与环境交互的基本记录格式:$[s, a, r, s’]$

  • $s$: 当前状态 (State)
  • $a$: 动作 (Action)
  • $r$: 奖励 (Reward)
  • $s’$: 下一状态 (Next State)
    (注:有时候还会加上一个 $done$ 标志,记录游戏是否结束,变成 $[s, a, r, s’, done]$)

2. 机制流程

  • 存储 (Store):智能体在探索环境时,将每一步的 $[s, a, r, s’]$ 存入一个固定容量的队列 (Memory/Buffer) 中。如果存满了,新的经验会挤掉最老的经验。
  • 采样 (Sample):在训练更新网络时,从 Memory 中随机抽取 (Random Sample) 一批大小为 batch_size 的经验进行学习。

3. 为什么必须随机抽样?

  • 打破时间相关性 (Break Correlation):连续的样本之间高度相似,会导致神经网络训练产生震荡甚至崩溃。随机抽样能让送入网络的数据分布更加均匀。
  • 提高数据利用率:过去犯下的惨痛错误(稀有经验),可以在未来被多次随机抽中并反复复习,而不是经历一次就被遗忘了。

机器学习核心概念:偏差、方差与拟合状态

在机器学习中,模型的总误差可以分解为:$Error = Bias^2 + Variance + Noise$

1. 欠拟合 (Underfitting) $\rightarrow$ 高偏差 (High Bias)

  • 表现:模型在“训练集”和“测试集”上的表现都很差,准确率都不高。
  • 原因:模型过于简单(如用直线拟合曲线),表达能力不足,无法捕捉数据中的真实规律。
  • 形象比喻:瞄准镜歪了。预测结果集体偏离真实值。

2. 过拟合 (Overfitting) $\rightarrow$ 高方差 (High Variance)

  • 表现:模型在“训练集”上表现极好(甚至 100% 准确),但在“测试集”上表现极其糟糕。
  • 原因:模型过于复杂,把训练数据里的噪声和偶然特征也当成规律死记硬背了下来。泛化能力极差。
  • 形象比喻:手抖得厉害。稍微改变一下训练数据,训练出的模型就会剧烈变动,极不稳定。

3. 终极目标:偏差-方差权衡 (Bias-Variance Tradeoff)
我们想要的是一个既能看懂规律(低偏差),又不过度敏感(低方差)的模型,也就是打靶时既对准靶心,手又稳(子弹密集击中靶心)。

Advantage Actor-Critic(A2C)

首先定义几个核心变量。$s$ 代表机器人当前的状态(例如各关节的位姿、速度、绳索张力),$a$ 代表输出的动作(例如电机的控制电压或目标力矩),$r$ 是环境给出的即时奖励,$\gamma$ 是用于折算未来收益的折扣因子(通常在 0.9 到 0.99 之间,决定了机器人有多看重长远收益)。

Critic 网络(价值网络)

Critic 网络的内部参数记作 $\phi$。它的任务是预测在状态 $s$ 下,未来能获得的总回报,即价值函数 $V_\phi(s)$。当机器人执行动作 $a$ 后,环境反馈了即时奖励 $r$,并进入下一个状态 $s’$。此时我们可以计算出一个“更真实的期望值”,即目标值(TD Target):
$$y = r + \gamma V_\phi(s’)$$
Critic 网络需要让自己的预测 $V_\phi(s)$ 尽可能逼近这个目标值 $y$。因此,Critic 的损失函数(Loss)通常采用均方误差:
$$L_C(\phi) = \frac{1}{2} (V_\phi(s) - y)^2$$
有了损失函数后,Critic 通过求导计算梯度,并以学习率 $\alpha_C$ 更新自身参数 $\phi$,使得下一次预测更准:
$$\phi \leftarrow \phi - \alpha_C \nabla_\phi L_C(\phi)$$

Actor 网络(策略网络)

Actor 网络的内部参数记作 $\theta$。它的输出是一个概率分布 $\pi_\theta(a|s)$,表示在状态 $s$ 下选择动作 $a$ 的概率。Actor 更新的基础是优势函数(Advantage Function),记作 $A(s, a)$。它衡量的是“当前动作实际带来的收益”与“Critic 预期的平均收益”之间的差值。公式直接利用了前面计算的 TD 误差:
$$A(s, a) = r + \gamma V_\phi(s’) - V_\phi(s)$$
如果 $A(s, a) > 0$,说明动作 $a$ 表现优于预期;反之则差于预期。Actor 的损失函数旨在最大化好动作的概率,由于通常使用梯度下降优化器,所以加上负号将其转化为最小化问题:
$$L_A(\theta) = - \log \pi_\theta(a|s) A(s, a)$$
Actor 根据这个损失函数计算梯度,并以学习率 $\alpha_A$ 更新自身参数 $\theta$:
$$\theta \leftarrow \theta - \alpha_A \nabla_\theta L_A(\theta)$$

完整训练闭环

下面是仿真环境中代码实际运行的流水线:
第一步(采集数据):Actor 接收机器人状态 $s$,根据概率 $\pi_\theta(a|s)$ 采样得到动作 $a$。第二步(环境交互):仿真环境执行动作 $a$,解算绳驱系统的动力学,返回即时奖励 $r$ 和新的状态 $s’$。第三步(计算优势值):Critic 根据 $r$ 和新老状态,计算出目标值 $y = r + \gamma V_\phi(s’)$,并得出优势值 $A(s, a) = y - V_\phi(s)$。第四步(更新 Critic):Critic 计算自身误差 $L_C(\phi)$,执行梯度下降更新参数 $\phi$,提升估值精度。第五步(更新 Actor):Actor 利用计算好的优势值 $A(s, a)$,计算策略误差 $L_A(\theta)$,执行梯度下降更新参数 $\theta$,优化控制策略。

Actor-Critic 与 A2C 算法区别

1. 基础版:Actor-Critic (AC)

  • 评分标准:使用绝对价值(如 $Q$ 值)来更新策略。
  • 缺点:无法区分是“动作选得好”还是“当前状态本身就好”。导致策略梯度更新时方差极大 (High Variance),训练非常不稳定。

2. 进阶版:Advantage Actor-Critic (A2C)

  • 评分标准:引入优势函数 $A(s,a)$。
  • 核心公式:$A(s,a) = Q(s,a) - V(s)$
    • $A > 0$:该动作比平均水平好,增加该动作的概率。
    • $A < 0$:该动作比平均水平差,降低该动作的概率。
  • 优点:相当于引入了一个“基线 (Baseline)”。剥离了状态本身带来的好坏,只评估动作的相对好坏。大幅降低了方差 (Reduced Variance),让训练变得极其稳定和高效。

标准优势函数 vs GAE (广义优势估计)

在 Actor-Critic 架构中,优势函数决定了给 Actor 动作打分时的“视野长度”和“准确度”。

  1. 标准优势函数

    这是最基础的打分方式,也是经典 A2C 的默认配置。公式:
    $$A_t = r_t + \gamma V(s_{t+1}) - V(s_t)$$

    通俗理解:绝对的“短视主义”。它只相信刚刚走完的这 1 步拿到手里的真实奖励 $r_t$,至于未来还能拿多少分,它全盘信任裁判(Critic)的盲猜 $V(s_{t+1})$。

    核心缺陷(高偏差):如果裁判没训练好(是个傻子),瞎给 $V(s_{t+1})$ 估分,这个优势值就会全盘大错,导致 Actor 跟着学废。

  2. 蒙特卡洛优势

    这是标准优势函数的另一个极端。公式:
    $$A_t = \sum_{k=0}^{\infty} \gamma^k r_{t+k} - V(s_t)$$
    通俗理解:绝对的“长期主义”。它完全不信任裁判的预测,狗直接跑到死,然后把这一辈子拿到的所有真实奖励加起来算总账。

    核心缺陷(高方差):数值波动极大。比如打一局完整的星际争霸,最后赢了,你很难算清楚到底是因为第 1 分钟造了农民,还是第 20 分钟放了技能。这种长线的大乱炖奖励很难指导每一步的精确更新。

  3. GAE (Generalized Advantage Estimation)

    这是现代强化学习的端水大师,完美融合了上面两者的优点。公式:
    $$A^{GAE}t = \sum{l=0}^{\infty} (\gamma \lambda)^l \delta_{t+l}$$
    (注:这里的 $\delta$ 就是上面第1点里的标准 1-步优势)

    通俗理解:它既不只看 1 步,也不死等最后的结果。它向未来透支多步的真实奖励,但通过衰减因子 $\lambda$ 给越远的未来打越高的折扣。

    参数 $\lambda$ 的魔法:当 $\lambda = 0$ 时,公式瞬间退化成“标准 1-步优势”。当 $\lambda = 1$ 时,公式瞬间退化成“蒙特卡洛优势”。

    黄金设定:在工程中通常设为 $\lambda = 0.95$。它巧妙地利用了一部分真实的未来录像(纠正傻子裁判),又利用了裁判的预测截断了过长的真实录像(压制方差波动)。

np.array_equal

比较numpy数组尽量不用==,要用np.array_equal。
两个 numpy 数组用==时,不会像普通变量 / 列表那样返回「单个 True/False」,而是对两个数组中对应位置的元素逐一比较 ,返回一个和原数组形状完全相同的布尔数组。

1
2
3
4
5
6
7
8
9
import numpy as np
# 任务1的位置:[5,6],任务2的位置:[5,7],任务3的位置:[5,6]
loc1 = np.array([5,6])
loc2 = np.array([5,7])
loc3 = np.array([5,6])

# 数组用==,逐元素比较,返回布尔数组
print(loc1 == loc2) # 输出 [ True False] → x相等,y不相等
print(loc1 == loc3) # 输出 [ True True] → x、y都相等

np.array_equal(a, b)是专门判断两个数组是否完全相等的函数,核心特性:

  1. 先判断两个数组的形状是否一致(比如都是 2 维、都是 N×2),形状不同直接返回 False;
  2. 形状一致则逐元素比较,所有元素都相等才返回单个 True,否则返回单个 False;
  3. 返回值是单个布尔值,可以直接用在if判断里,完美解决数组比较的问题。
1
2
np.array_equal(loc1, loc2)  # False(元素不全等)
np.array_equal(loc1, loc3) # True(所有元素相等,形状也一致)

@dataclass与@abstractmethod

@dataclass 装饰器

来源from dataclasses import dataclass (Python 3.7+)
定义:一个用于简化类定义的装饰器,专门用于创建主要存储数据的类(Data Class)。
核心功能:自动生成 __init____repr____eq__ 等样板代码,让代码极度简洁。

写法对比

  • 传统写法(手动挡)
    1
    2
    3
    4
    5
    6
    class Point:
    def __init__(self, x, y):
    self.x = x
    self.y = y
    def __repr__(self):
    return f"Point(x={self.x}, y={self.y})"
  • Dataclass 写法(自动挡)
    1
    2
    3
    4
    @dataclass
    class Point:
    x: int
    y: int

两个高频装饰器:@abstractmethod vs @dataclass

它们虽然都带 @ 符号,但分工完全相反,一个是配置函数,一个配置类:

  • @abstractmethod

    • 用法:放在“空壳”类里面的函数头上。
    • 作用:只提要求,不干活。它强制规定:“谁继承我所在的类,谁就必须自己把这个函数的具体代码写出来,否则直接报错不准运行!”
  • @dataclass

    • 用法:放在专门用来装载配置参数的类头上(如 xxxCfg)。
    • 作用:帮你干活。只要加了它,你只需要像写变量一样把参数列出来,Python 就会在后台自动帮你写好繁琐的 __init__ 初始化函数,极大地保持了代码的整洁。

注:我个人的理解@abstractmethod这个装饰器感觉完全没必要啊,加了跟没加一样。

field(default_factory=…)

在 Python 类中,绝对不能直接使用可变对象(如 list, dict, set)作为类变量的默认值。
错误写法:items: list = []
后果:所有该类的实例会共享同一个列表内存地址。修改 A 对象的列表,B 对象的列表也会随之改变。

为了解决共享问题,我们需要告诉 Python:不要使用现成的对象,而是每次实例化时,调用一个“工厂函数”现场创建一个新对象。

示例:

1
taskTypes: List[str] = field(default_factory=lambda: ["search", "fire", "facility"])

ROS 2 与 Gazebo 桥接器 (ros_gz_bridge) 语法

在 Launch 文件中配置 parameter_bridge 时,字符串格式非常严谨,核心公式为:
话题名称@ROS数据类型<方向号>Gazebo数据类型

1. 符号含义:

  • @:分隔符,分隔话题名称和数据类型。
  • ]:单向通信,数据从 ROS 2 流向 Gazebo(例如:下发控制指令、推力)。
  • [:单向通信,数据从 Gazebo 流向 ROS 2(例如:获取传感器数据、Odometry 里程计位置)。
  • @== (有些版本支持双向):双向通信。

2. 核心代码结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from launch_ros.actions import Node

# 1. 定义桥接规则列表
bridge_params = [
'/topic_name@ros_type]gz_type', # ROS -> Gazebo
]

# 2. 创建 Node 节点
bridge = Node(
package='ros_gz_bridge',
executable='parameter_bridge',
arguments=bridge_params,
output='screen'
)

为什么 VS Code 找不到代码引用/不高亮?

问题现象

在 VS Code 中选中一个函数(比如 step),但在其他类中调用它的地方(比如 self.bandit.step())却没有高亮显示。右键点击“查找所有引用”也毫无反应,让人误以为这个函数没被用过。

根本原因:Python 的“动态类型”特性

Python 是一门非常自由的语言,声明变量时不需要指定类型。
当你写下 def __init__(self, bandit): 时,VS Code 的代码分析器(Pylance/IntelliSense)并不知道传入的 bandit 到底是个什么对象(是数字?是字符串?还是老虎机?)。因为不知道身份,VS Code 为了避免报错,干脆就不进行跨文件/跨类的高亮关联。

终极解决办法:类型提示 (Type Hint)

在定义参数时,顺手给它“贴个标签”,明确告诉 VS Code 它的真实身份。

  • 修改前(VS Code 无法识别):
    1
    def __init__(self, bandit):
  • 修改后(VS Code 瞬间变聪明):
    1
    def __init__(self, bandit: BernoulliBandit): 
    (加上 : BernoulliBandit 后,VS Code 瞬间就能把两个类关联起来,代码高亮、Ctrl + 点击 跳转、自动补全全部复活!)

备用方案:暴力搜索法

如果是在阅读别人写的老代码(没有类型提示),千万别依赖高亮来判断函数有没有被调用。请直接使用:

  1. 单文件搜索Ctrl + F
  2. 全局搜索(最管用)Ctrl + Shift + F,在整个工程文件夹里直接搜索函数名。

rsl_rl

1. 简介

rsl_rl 是一个基于 PyTorch 实现的强化学习库,由 ETH Zurich 的 RSL 实验室开发。它专门为同步的大规模并行采样而设计,通常作为 Isaac GymIsaac Lab 的后端算法库。

2. 核心特点

  • 极致的训练速度:通过 GPU 端的向量化环境采样,可以在几十分钟内完成传统 RL 需要几天才能完成的训练量。
  • PPO 算法优化:内置了高度优化的 PPO (Proximal Policy Optimization) 算法,非常适合处理高维连续动作空间(如足式机器人的关节电机控制)。
  • 轻量化架构:相比于 Stable Baselines3 (SB3) 或 Ray Rllib,它的代码结构非常精简,开发者可以轻松修改网络结构或 Loss 函数。
  • 足式机器人适配:内置了处理刚体动力学任务中常见的观察值、特权信息(Privileged Information)和周期性奖励函数的逻辑。

3. 算法原理:PPO

rsl_rl 主要实现的是 Actor-Critic 架构的 PPO 算法。其目标函数公式如下:

$$J^{CLIP}(\theta) = \hat{\mathbb{E}}_t \left[ \min(r_t(\theta) \hat{A}_t, \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon) \hat{A}_t) \right]$$

其中:

  • $r_t(\theta)$ 是新旧策略的概率比。
  • $\hat{A}_t$ 是优势函数(Advantage Function)。
  • $\epsilon$ 是裁剪超参数,防止策略更新步长过大。

4. 关键文件结构

在使用 rsl_rl 时,你通常会接触到以下核心类:

  • OnPolicyRunner: 负责管理整个训练循环(存储、评估、存盘)。
  • PPO: 算法核心,处理梯度更新和 Loss 计算。
  • ActorCritic: 定义神经网络架构(通常包括 Actor 网络和 Critic 网络)。
  • RolloutStorage: 在 GPU 上直接存储经验轨迹的数据结构。

5. 典型工作流

  1. 环境定义:在 Isaac Gym/Lab 中定义机器人的 URDF、传感器和奖励函数。
  2. 配置参数:通过 Python 的 config 类定义超参数(如 learning_rate, num_steps_per_env, entropy_coef)。
  3. 启动训练
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
        from rsl_rl.runners import OnPolicyRunner
    # 初始化环境
    env = VecEnv(...)
    # 初始化训练器
    runner = OnPolicyRunner(env, train_cfg, log_dir)
    # 开始学习
    runner.learn(num_learning_iterations=1000, init_with_graceful_stop=True)

    # 变量占位符与拆包赋值
    ## 1. 语法现象:`_, var = function()`
    在 Python 中,当一个函数返回多个值(以元组形式)时,如果我们只需要其中的某一个或某几个,可以使用 `_` 作为占位符。

    ### 示例代码
    ```python
    def get_user_info():
    # 返回 (姓名, 年龄, 职业)
    return "wkh", 23, "算法工程师"

    # 只需要姓名,忽略其他信息
    name, _, _ = get_user_info()

    # 或者使用 * 忽略剩余所有
    name, *_ = get_user_info()

跑RL训练流程

不要在带界面的情况下跑训练(极慢),标准操作分为三步:

Step 1: 纯后台训练 (Train)

通过 –headless 剥离渲染,将 100% 的算力交给张量计算和物理引擎。

1
2
3
4
5
6
# 激活环境并挂载 C++ 底层库隔离区或者其他配置
conda activate LeggedGym
export LD_LIBRARY_PATH=/home/wkh/lessons/legged_gym/isaac_libs:$LD_LIBRARY_PATH

# 无头模式启动训练
python legged_gym/scripts/train.py --task=a1 --num_envs=64 --headless

Step 2: 数据监控 (Monitor)

代码会自动在 logs 目录下按时间戳存档。使用 TensorBoard 查看“大脑”发育情况。

1
2
# 建议使用绝对路径启动,防止找不到数据
tensorboard --logdir=/home/wkh/lessons/legged_gym/logs

核心指标: Train/mean_reward(平均奖励,需稳步上升)和 Episode/length(存活时间,需逐渐变长)。注意: 数据非实时写入,需让程序运行几分钟后刷新网页。

Step 3: 前台可视化验收 (Play)

加载最新训练好的模型权重(只做前向推理,算力压力极小),弹窗查看实际表现。

1
2
# 运行前确保处于 Xorg 桌面环境
python legged_gym/scripts/play.py --task=a1

pip install -e . (可编辑安装)

一句话总结:在当前目录下,以“开发者可编辑模式”将项目安装到当前的 Conda 环境中。它的本质是建立一个指向源代码的快捷方式,而不是复制文件。

-e (全称 –editable):代表“可编辑模式” (Editable mode)。

. :代表“当前终端所在的目录”。(前提:该目录下必须存在 setup.py 或 pyproject.toml 等项目构建文件)。

  • 普通安装(没有 -e): 比如你 pip install numpy,系统会把 numpy 的代码复制一份,扔进你的 Conda 环境的一个深层文件夹(site-packages)里。

  • 可编辑安装(加了 -e): 系统不会复制代码,它只是在你的 Conda 环境里建立了一个快捷方式,指向你当前下载的这个 rsl_rl 文件夹。

为什么要用 -e:因为如果原作者代码有 Bug,或者你想修改底层算法,由于系统是指向这个文件夹的,你只要在这个文件夹里修改了代码保存,你的环境里会立刻生效,不需要重新 pip install

rsl_rl 为例:

  1. 你使用 git clone 下载了 rsl_rl 的源代码文件夹到你的电脑上。

  2. 你在终端 cd rsl_rl 进入该文件夹。

  3. 执行 pip install -e .

结果: 此时在当前环境内, Python 已经认识了 rsl_rl 这个包。无论你的终端以后切换到电脑的哪个目录下运行代码,只要遇到 import rsl_rl,系统都会通过快捷方式,飞回你最初 clone 下来的那个源码文件夹去读取逻辑。

注意:绝对不能移动源代码文件夹! 如果你把下载的 rsl_rl 文件夹剪切到了另一个硬盘或目录,快捷方式就会断裂。当你再次运行代码时,会直接报错 ModuleNotFoundError: No module named 'rsl_rl'

import相关问题

为啥只import但不调用

第一类:常规库(如 numpy, os), 如果代码中没有显式调用,那么确实是没用的,删掉不影响程序运行。

第二类:底层框架(如 isaacgym), 则绝不能删。在 Python 中,import 语句不仅仅是声明,它会立刻触发底层的初始化逻辑。import isaacgym 会在后台瞬间唤醒 C++ 编写的 PhysX 物理引擎,并向系统申请最底层的显卡权限。

import 的执行机制

import 是一次“强行执行”, import xxx时 ,Python 会在后台做一件事:

  • 找到目标文件夹的 __init__.py(或对应脚本),从头到尾作为真实代码执行一遍
  • 遇到函数/类:在内存中画好图纸(定义)。
  • 遇到直接写在外面的代码(如 registry.register(...)):当场直接运行

所以其实import本身也可以当函数用,毕竟只要import了就自动触发init里的函数。有点类似于游戏里入场就触发一次的特性。

import的顺序问题

物理引擎(Isaac Gym)和神经网络(PyTorch)都需要高强度使用 GPU。

如果先导入 torch: PyTorch 机制极其霸道,它会瞬间接管并锁死显卡的 CUDA 内存池。随后当 Isaac Gym 试图建立物理引擎时,会发现底层通道被强占,导致内存指针错乱,直接引发“段错误”或“CUBLAS 未初始化”报错。

如果先导入 isaacgym: Isaac Gym 会在显卡中安顿好渲染和计算通道,并将其设置为“允许共享”的开放状态。随后导入 PyTorch 时,它会检测到该状态并选择和平接入,实现两者完美共存。

工程路径管理与模块导入

在构建机器人算法工程时,最好别使用写死的“硬编码”路径(如 C:/xxx),而是用动态路径解析,以确保代码的跨平台(Windows/Ubuntu)和可移植性。

一、 动态路径解析 (os.path )

在项目的根目录初始化文件(通常是 __init__.py)中,通常使用以下固定范式来获取项目的绝对根目录:

1
2
3
4
5
6
7
import os

# 1. 锁定根目录
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))

# 2. 安全拼接子目录
ENVS_DIR = os.path.join(ROOT_DIR, 'legged_gym', 'envs')

二、 模块导入方式

掌握了根目录坐标后,项目内的模块调用分为两种流派:

  1. 绝对导入
  • 语法:from legged_gym.utils import task_registry

  • 逻辑:以整个项目的根目录为起点,像使用 GPS 一样层层往下寻找目标文件。

  • 优点:路径极其清晰,不易产生歧义

  • 缺点:如果最外层的包名(legged_gym)发生更改,所有文件内的绝对导入语句都需要跟着修改。

  1. 相对导入 (Relative Import)
  • 语法:from .helpers import get_args

  • 逻辑:以“当前文件”所在的位置为起点,去寻找隔壁邻居。

    • . 代表当前目录。

    • .. 代表上一级目录。

  • 优点:利于模块内部的代码重构。只要子文件之间的相对位置不发生改变,外层文件夹随便改名也不会影响内部的相对导入。

  • 限制:只能在被当作“包 (Package)”的环境中使用,不能在直接运行的主程序入口文件(如执行 python main.py 的那个文件)中使用相对导入。

单例模式与全局状态共享

经常会在文件末尾看到直接将类实例化的代码,例如:
task_registry = TaskRegistry()

随后在其他所有文件中,导入的都是这个小写的 task_registry 对象,而不是大写的 TaskRegistry 类。这是 Python 中实现**单例模式(Singleton Pattern)**的经典操作。

一、 核心区别:导入“类” vs 导入“对象”

1. 导入“类” (Class)

  • 写法from legged_gym.utils import TaskRegistry
  • 底层逻辑:每次想用它时,都需要手动加括号实例化:my_registry = TaskRegistry()
  • 致命缺点:这相当于每次都用了一个全新的对象。每一个新管家的内部数据(如字典、列表)都是彻底清空的。无法实现跨文件的数据传递。

2. 导入“对象” (Instance)

  • 写法from legged_gym.utils import task_registry
  • 底层逻辑:因为原文件末尾已经执行了实例化,Python 在每一次导入该模块时,使用的都是这个唯一的“对象”。
  • 优势:后续无论有多少个不同的脚本去 import task_registry,拿到的都是内存中同一个对象

二、 应用场景:跨文件状态共享

legged_gym 框架的运作流程为例,这种模式是维持系统运转的生命线:

  1. 写入数据阶段(入职登记)
    当系统启动加载 envs/__init__.py 时,各种机器狗(A1, Cassie 等)会调用 task_registry.register(...),把自己的图纸数据写入到管家的内部字典中。

  2. 读取数据阶段(下发任务)
    当运行 train.py 开始训练时,主程序会调用 task_registry.make_env(...),要求管家根据名字去字典里找对应的图纸。

Python 环境与包管理

pip安装与conda安装

安装一个包一般两种方式:pip安装和conda安装

pip是 Python 环境中默认自带的包管理工具,它直接与 PyPI(Python Package Index)仓库连接。主要用于安装和管理 Python 编写的软件包。但是对于包含 C/C++ 扩展的包,pip 有时需要本地系统预装编译器和底层库才能成功安装。

conda 是一个开源的软件包管理系统和环境管理系统。能够创建完全独立的虚拟环境,每个环境可以拥有不同的 Python 版本。conda 安装的是预编译好的二进制文件,所以它不仅能管理 Python 包,还能管理非 Python 依赖(如 CUDA 驱动、C++ 编译器、MKL 库等)。拥有强大的依赖解析引擎,能自动检查各个包之间的版本兼容性。

在实际开发(如安装 rsl_rl 或 legged_gym)时,通常遵循以下原则:优先使用 conda 安装底层重型库(如 pytorch、cuda、cudatoolkit),因为 conda 能处理复杂的系统级依赖。使用 pip 安装那些 conda 仓库里没有的、或者开发者提供的源码包(使用 pip install -e .)。

Anaconda与Miniconda

Anaconda:全家桶方案。包含 Python 解释器,Conda(环境管理器,包管理器),一个base环境,还包含了数据科学、机器学习相关的几百个常用工具包(NumPy, Pandas, Matplotlib 等)。缺点是非常臃肿,安装包好几个 GB,会占用大量电脑空间。

Miniconda:毛坯房方案。它是 Anaconda 的精简版。它只包含了最核心的Python解释器 和 Conda。它的安装包通常只有几十MB。你需要什么额外的库,自己按需安装即可。

一般情况下:除了新手,直接用Miniconda。

“空壳”类与 pass:架构的“接口契约”

在底层源码(如 vec_env.py)中,经常看到一个继承ABC的空壳类,没法被实例化,类里面全是没有具体代码、只有 pass 的函数。空壳类(抽象基类)本身不能被“实例化”。也就是说,你不能直接用它在内存里造出一个对象。但是,会有其他的类去继承这个空壳类,并且把空壳里那些只有名字没有内容的函数(也就是那些写着 pass 的地方)真正填满具体的代码。这些继承后的子类,就可以被“实例化”了,造出来的真实对象就能被其他的代码去“调用”里面的函数。

  • 核心本质:这叫“接口契约 (Interface Contract)”,用来强制规范所有接入系统的外部环境。
  • 存在的意义
    1. 定规矩:它宣告了主程序(如 rsl_rl 大脑)只会通过固定的几个按钮(如 step(), reset())来控制环境。
    2. 防崩溃:强制要求任何继承它的子类(比如具体的机器狗环境)必须实现这些函数。如果不写或者名字拼错,Python 在代码启动的瞬间就会报错拦截,而不是等跑了几天才崩溃。

->typing

在强化学习中追踪几十维的巨型 Tensor 极易出错,类型提示(Type Hint)是保命的关键。

  • -> (返回值注解)

    • 作用:直接标明函数运行结束后,会吐出一个什么类型的数据(如 def get_name() -> str:)。
  • 为什么原生自带了 tuple/list,还要导入 typing.Tuple/List

    • 原生类型的局限(只能看外表):写 -> tuple 只能告诉编辑器“我返回了一个元组”,但编辑器不知道里面装的是数字还是张量。
    • typing 的核心价值(透视内部结构):在 Python 3.8 及以前,必须使用 typing.Tuple 才能精确定义内部结构,如 Tuple[torch.Tensor, int]。它能激活编辑器的“上帝视角”,一旦传入错误类型的参数,代码未运行就会亮起红线警告,极大降低 Debug 成本。
    • (注:从 Python 3.9 开始,原生 tuple/list 已支持直接透视内部,如 tuple[torch.Tensor, int],大型框架中为了兼容老版本往往仍保留 typing 的写法,但是不需要再from typing import Tuple了。)

“声明”、“传递类”和“实例化”的区别

必须严格区分“声明”、“传递类”和“实例化”这三个处于不同生命周期的操作。

1. 声明/类型提示 (Type Hint) —— 【贴招聘启事】

  • 代码形态task_class: VecEnv (注意是冒号 :
  • 本质:没有消耗内存造出任何东西。它仅仅是在“定规矩”,告诉程序员和编辑器:“未来要塞进这个变量的东西,必须是 VecEnv 的子类”。
  • 比喻:在房间门上贴个纸条:“本房间只准放哺乳动物”。至于这个哺乳动物是猫还是狗,不知道。

2. 赋值类本体 (Assigning Class) —— 【传递图纸】

  • 代码形态task_class = LeggedRobot (注意后面没有括号),(LeggedRobot 继承了 VecEnv )。
  • 本质:把“造狗的蓝图”存进了变量里,供后续随时使用。此时内存里依然没有活生生的狗,只有一张图纸。
  • 比喻:把“狗的基因图谱”放进了房间。

3. 实例化对象 (Instantiation) —— 【真正造物】

  • 代码形态env = task_class()env = LeggedRobot() (注意有括号 ()
  • 本质:真正消耗内存,根据图纸(类)在系统中生成了一个活生生、可交互的对象(Object)。
  • 比喻:真正得到了一只狗。

eval() 函数

eval() 的全称是 evaluate(求值/评估)。它的作用是:把一段“普通文本字符串”当成“真正的 Python 代码”来执行。例如如果你写 eval(“10 * 10”),Python 不会把它当成纯文字,而是会直接算出结果并返回 100。

在大型框架的配置文件里,通常只能写文本(例如 “ActorCritic”)。通过调用 eval(“ActorCritic”),Python 会在代码库里找出那个真正叫 ActorCritic 的类本体。这使得我们可以只通过修改配置文件里的单词,就能动态切换不同的神经网络或算法。

那么问题来了:我为啥不直接输入参数,而是先字符串然后转化为参数?

我为啥不:

1
2
3
4
#方案A
from rsl_rl.modules import ActorCritic # 先导入真实的类
class A1RoughCfgPPO:
policy_class = ActorCritic # 直接把类对象赋给变量

而是:

1
2
3
#方案B
class A1RoughCfgPPO:
policy_class_name = "ActorCritic" # 只写一个纯文本字符串

因为:

  1. 在一个大型工程中,配置文件(Config)应该是最底层的“纯净水”,任何人都能随时喝一口(读取参数)。
    如果你用了方案 A,配置文件就必须 import 神经网络模块。但是,神经网络在构建的时候,往往又需要反过来读取配置文件里的参数(比如要知道输入层的维度)。这样就会造成互相导入(A 导 B,B 导 A),Python 会直接报错崩溃。
    用了方案 B,配置文件就彻底变成了一张“纯文本清单”。它不需要引入任何外部的复杂代码,谁想看这张清单都不会引发代码打架。

  2. 在实际的科研和落地中,我们经常需要把配置文件保存成纯文本文件,比如 JSON 或 YAML 格式,甚至是作为终端命令行里的一个指令传递(比如 python train.py –policy=”ActorCritic”)。
    类对象是存在于内存里的活物,你绝对不可能把 ActorCritic 这个实体类保存进一个文本格式的 .json 文件里。
    字符串是可以自由流通的。用字符串 “ActorCritic”,这套配置就能在文本文件、命令行、网络传输之间随意穿梭。

Namespace 对象

在 Python 的 argparse 模块中,Namespace 对象是一个非常简单的容器。它的唯一使命就是存储从命令行解析出来的参数,并让你能以最自然的方式调用它们。

当你使用 argparse 解析命令行输入时,它不会返回一个复杂的列表或繁琐的字典,而是返回一个 Namespace 对象。这个对象本质上是一个“只包含属性的简单对象”。

如果你在终端输入 –task a1 –num_envs 4096,解析后得到的 args 对象在内存里看起来就像这样:

  • args.task 的值为 “a1”

  • args.num_envs 的值为 4096

鸭子类型 (Duck Typing)

“鸭子类型”是 Python 等动态语言的核心设计哲学。它的理念是:如果一只鸟走起来像鸭子,游泳起来像鸭子,叫起来也像鸭子,那么它就是鸭子。

对比理解:

  • 传统强类型语言 (如 C++/Java) = 查户口本。你要想当一个 VecEnv,你必须在代码里显式声明继承它(写上 class BaseTask(VecEnv))。系统只认血缘关系。

  • Python (鸭子类型) = 查工作能力。系统不在乎你继承了谁。只要你的类里面包含了 step()、reset() 函数和 num_envs 变量(具备了鸭子的能力),系统就会完美地把你当成 VecEnv 来用。