Jiahong 的个人博客

凡事预则立,不预则废


  • Home

  • Tags

  • Archives

  • Navigation

  • Search

Python——Typer工具的使用

  • 参考链接:
    • Typer, build great CLIs. Easy to code. Based on Python type hints.
      • 本文大部分内容基于这篇说明文档

Typer 整体介绍

  • Typer 是一款基于 Python 类型注解(Type Hints)的 CLI 开发库,被称为CLI 界的 FastAPI ,是 FastAPI 的同作者开发的“轻兄弟”库
  • 理解:Typer(是 CLI 开发库)的本质是将 Python 函数变成可以在 Terminal 直接调用的命令形式
  • Typer 基于 Click 构建,兼具代码简洁、自动生成帮助文档、终端自动补全、支持复杂子命令等特性,开发者仅需少量代码就能构建专业、易用的命令行工具,同时对新手极其友好
    • 注:Click 是 Python 生态中一款经典、成熟的命令行界面(CLI)开发框架,也是 Typer 的底层核心依赖——Typer 本质上是对 Click 的高级封装,基于 Click 实现了所有 CLI 核心能力,同时通过 Python 类型注解简化了 Click 的使用流程

前置补充:Click 介绍

  • Click 由 Pallets 团队开发(同 Flask 开发团队),是 Python 中最主流的 CLI 开发库之一,解决了 Python 内置 argparse 模块代码繁琐、体验不佳、扩展能力弱的问题,核心优势是:
    • 基于装饰器实现简洁的命令 / 参数定义
    • 原生支持子命令、选项 / 参数解析、终端补全
    • 兼容所有主流终端,支持跨平台(Windows/Linux/macOS)
    • 提供丰富的扩展能力(如进度条、密码输入、颜色输出)

Typer 核心优势

  • 基于 Python 原生类型注解,无需学习新语法,少量代码即可实现 CLI 功能
  • 自动生成 --help 帮助文档、终端自动补全(支持 Bash/Zsh/Fish/PowerShell),bool 类型参数自动生成 --xxx/--no-xxx 双选项
  • 可灵活扩展,从简单单命令到多层嵌套子命令,可随项目复杂度无缝升级
  • 无缝兼容 Python 代码,无需修改现有 Python 脚本,直接通过 typer 命令将普通函数转为 CLI 工具
  • 内置美观的错误提示(基于 Rich)、进度条、彩色输出,提升用户使用体验

Typer 安装

  • Typer 支持 Python 3.6+,推荐在虚拟环境中安装,执行以下命令即可:

    1
    pip install typer
  • 安装完成后会自动附带三个核心依赖:

    • Click :Python 经典 CLI 框架,Typer 的底层基础
    • Rich :实现美观的格式化输出、彩色错误提示
    • shellingham :自动检测当前终端类型,支持自动补全安装

Typer 使用简单示例

  • Typer 的使用分为无侵入式运行普通脚本 和显式使用 Typer 开发 两种方式,先从最简单的无侵入式开始
  • 示例来自:

无侵入式:普通脚本直接转 CLI

  • 无需在代码中引入 Typer,直接将普通带类型注解的 Python 函数转为 CLI 工具

  • Step 1,创建 main.py,编写普通函数:

    1
    2
    3
    # 仅需普通Python代码+类型注解,无Typer相关代码
    def main(name: str):
    print(f"Hello {name}!")
  • Step 2,通过 typer 命令运行:

    1
    2
    3
    4
    # 查看帮助
    typer main.py run --help
    # 传入参数运行
    typer main.py run 张三
  • Step 3,效果:自动识别 name 为必填字符串参数,缺失时会抛出美观的错误提示,无需手动处理参数解析

显式使用(入侵式):引入 Typer 开发

  • 在代码中引入 Typer,可直接通过 python 命令运行,更适合正式开发

  • 修改 main.py,仅需2行新增代码(导入+运行):

    1
    2
    3
    4
    5
    6
    7
    import typer  # 新增:导入Typer

    def main(name: str):
    print(f"Hello {name}!")

    if __name__ == "__main__":
    typer.run(main) # 新增:运行Typer应用
  • 直接通过 Python 运行,体验与原生 CLI 工具一致:

    1
    2
    3
    4
    # 查看帮助
    python main.py --help
    # 传入参数运行
    python main.py 张三

Typer 进阶用法:多子命令开发

  • 当 CLI 工具需要多个功能时,Typer 支持通过 @app.command() 装饰器创建子命令 ,类似 Git 的 git add/git commit 模式,结构清晰

基础多子命令代码示例

  • 创建包含 hello(问候)和 goodbye(告别)两个子命令的工具,goodbye 新增布尔可选参数 formal(正式模式):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    import typer

    # 1. 创建Typer应用实例,作为CLI入口
    app = typer.Typer()

    # 2. 用@app.command()装饰函数,转为子命令
    @app.command()
    def hello(name: str):
    """子命令:普通问候"""
    print(f"Hello {name}!")

    @app.command()
    def goodbye(name: str, formal: bool = False):
    """子命令:告别,--formal 开启正式模式"""
    if formal:
    print(f"Goodbye Ms. {name}. Have a good day!")
    else:
    print(f"Bye {name}!")

    # 3. 运行应用
    if __name__ == "__main__":
    app()

基础多子命令运行与使用示例

  • 查看全局帮助(显示所有子命令):

    1
    python main.py --help
    • 会自动列出 hello、goodbye 两个子命令,以及 --install-completion(安装终端补全)等全局选项
  • 查看子命令帮助:

    1
    2
    # 查看goodbye子命令的帮助,会显示--formal/--no-formal选项
    python main.py goodbye --help
  • 执行子命令:

    1
    2
    3
    4
    # 普通告别
    python main.py goodbye 张三
    # 正式模式告别(bool参数自动生成--formal选项)
    python main.py goodbye --formal 张三

基础多子命令示例关键特性说明

  • 布尔参数自动优化 :定义 formal: bool = False 后,Typer 会自动生成 --formal(开启)和 --no-formal(关闭)两个选项,无需手动配置
  • 帮助文档自动生成 :函数的文档字符串("""注释""")会自动作为子命令的帮助说明,--help 中直接显示
  • 命令名省略规则 :仅单个命令时,可直接 python main.py 参数;多个子命令时,必须显式指定子命令名(如 python main.py hello 张三)

Typer 核心语法:参数定义

  • Typer 完全基于Python 原生类型注解 定义 CLI 参数,无需学习额外的装饰器或语法,支持所有常见类型

基础类型参数

  • 直接通过类型注解定义,Typer 自动解析为 CLI 位置参数/选项:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @app.command()
    def demo(
    # 必填字符串参数(位置参数)
    name: str,
    # 可选整数参数,默认值10(选项参数)
    age: int = 10,
    # 布尔参数,默认False(自动生成--is-adult/--no-is-adult)
    is_adult: bool = False
    ):
    # 推荐使用 `typer.echo()` 替代 `print()`,支持彩色输出、跨终端兼容
    typer.echo(f"姓名:{name},年龄:{age},是否成年:{is_adult}")

常用参数类型

  • 除基础类型外,Typer 还支持以下常用类型,直接注解即可:
    • 列表 :List[str],接收多个参数
    • 路径 :Path,自动校验文件/目录是否存在
    • 枚举 :Enum,实现参数可选值限制
    • 文件 :typer.File(),直接读取文件对象
  • 示例(枚举+列表):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    from typing import List
    from enum import Enum
    import typer

    # 枚举:限制gender的可选值
    class Gender(str, Enum):
    MALE = "male"
    FEMALE = "female"

    @app.command()
    def user(
    name: str,
    gender: Gender, # 仅能传入male/female
    hobbies: List[str] = None # 接收多个爱好,如--hobbies 篮球 读书
    ):
    typer.echo(f"姓名:{name},性别:{gender},爱好:{hobbies}")

Typer 高级功能:子命令组与全局配置

  • 当 CLI 工具功能复杂时,可创建子命令组(如按模块拆分:db backup/db restore),并通过 @app.callback() 实现全局配置(如环境、全局参数)

Typer 子命令组示例(数据库工具)

  • 示例代码

    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
    import typer

    # 全局应用入口
    app = typer.Typer(help="多功能CLI工具")

    # 创建子命令组:db(数据库相关操作)
    db_app = typer.Typer(help="数据库操作子命令组")
    # 将子命令组添加到全局应用,命令名为db
    app.add_typer(db_app, name="db")

    # db子命令组下的子命令:backup(备份)
    @db_app.command()
    def backup(path: str = "./backup.db"):
    typer.echo(f"正在备份数据库到:{path}")

    # db子命令组下的子命令:restore(恢复)
    @db_app.command()
    def restore(path: str = "./backup.db"):
    typer.echo(f"正在从{path}恢复数据库")

    # 全局子命令:无归属,直接在根目录
    @app.command()
    def version():
    typer.echo("CLI工具版本:v1.0.0")

    if __name__ == "__main__":
    app()
  • 运行:

    1
    2
    3
    4
    5
    6
    # 查看数据库子命令组帮助
    python main.py db --help
    # 执行数据库备份
    python main.py db backup --path D:/mydb.db
    # 执行全局版本命令
    python main.py version

Typer 全局配置(@app.callback())

  • 通过 @app.callback() 定义全局参数(如运行环境 env),所有子命令均可共享:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import typer
    from typer import Context

    app = typer.Typer(help="带全局配置的CLI工具")

    # 全局回调:定义全局参数,所有子命令生效,所有子命令执行前,都会先执行这个函数
    @app.callback()
    def global_config(ctx: Context, env: str = typer.Option("dev", help="运行环境:dev/prod/test")):
    # 将全局参数存入ctx,供子命令获取
    ctx.ensure_object(dict)
    ctx.obj["env"] = env # 注:所有子命令的 ctx 对象都可以传入 env 参数,并可通过 ctx.obj["env"] 获取到 env

    @app.command()
    def run(ctx: Context):
    # 获取全局配置的env
    env = ctx.obj["env"]
    typer.echo(f"在{env}环境中运行程序...")

    if __name__ == "__main__":
    app()
  • 运行:

    1
    2
    3
    4
    # 用默认dev环境运行
    python main.py run
    # 指定prod环境运行
    python main.py run --env prod

Typer 实用功能:终端补全与进度条

安装终端自动补全

  • Typer 支持一键安装终端自动补全,输入以下命令后按提示操作即可:

    1
    python main.py --install-completion
    • 支持 Bash、Zsh、Fish、PowerShell 等主流终端,安装后输入命令时按 Tab 即可自动补全子命令和参数

内置进度条

  • 处理耗时操作(如下载、批量处理)时,Typer 内置进度条功能,无需额外安装库,一行代码实现:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import typer
    import time

    app = typer.Typer()

    @app.command()
    def download(total: int = 10):
    """模拟下载,显示进度条"""
    # 用 typer.progressbar 创建进度条
    with typer.progressbar(range(total), label="下载中") as progress:
    for i in progress:
    time.sleep(0.5) # 模拟耗时操作
    typer.echo("下载完成!")

    if __name__ == "__main__":
    app()

Python——Ray-远程函数与本地函数的区别


整体说明

  • 远程函数与本地函数的区别主要在 序列化机制 和 执行位置 两个维度
  • 序列化本质差异:
    • 本地函数是“传引用”,依赖执行环境已有定义;
    • Ray 远程函数是“传定义+环境”,集群自动同步,支持跨节点;
  • 执行位置差异:
    • 本地函数固定在调用方进程,无分布式能力;
    • Ray 远程函数由集群调度,可分布式并发执行;
  • 使用场景:
    • 本地函数:适用于单进程/单节点的简单逻辑,无需分布式;
    • Ray 远程函数:适用于分布式计算、并发任务、跨节点执行,是 Ray 分布式能力的核心
  • 核心差异总览
    对比维度 本地函数(未用 @ray.remote 装饰) Ray 远程函数(用 @ray.remote 装饰)
    序列化方式 依赖 Python 原生 pickle,仅序列化「函数引用」 Ray 自定义序列化(结合 pickle+集群元数据),序列化「函数元信息+代码定义」
    序列化限制 无法跨节点传递(远程节点无函数定义,引用失效) 可跨节点传递(集群自动同步函数定义到执行节点)
    执行位置 固定在「调用方所在的本地进程/线程」 分布式调度到「集群任意节点的 Worker 进程」(可指定资源)
    执行特性 同步执行,阻塞调用方;无并发调度能力 异步执行,返回 ObjectRef;支持集群级并发/分布式调度
    依赖传递 需手动确保执行环境有函数依赖(如导入、变量) Ray 自动打包函数依赖(如嵌套函数、闭包变量)并分发

序列化机制:“仅传引用” vs “传定义+元信息”

  • 序列化的核心目的是:让函数能在「非定义环境」中被正确执行
  • 两者的序列化逻辑完全不同:

本地函数:仅序列化“函数引用”,无实际代码

  • Python 原生 pickle 序列化本地函数时,不会打包函数的代码本身 ,只会记录函数的「模块路径+函数名」(比如 __main__.add)
  • 这种“引用式序列化”仅在「同一进程/同一节点且函数已定义」的场景下有效,跨节点会直接失效
  • 错误示例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    import ray

    ray.init(ignore_reinit_error=True)

    # 本地函数
    def add_remote(a, b):
    return a + b

    # 直接传递远程函数的引用(Ray 自动处理序列化)
    @ray.remote
    def execute_remote_func(func, x, y):
    return func(x,y) # 远程工作进程无法识别调用方的 local func,错误

    # 跨节点调度执行(单节点可以成功,但集群有多个节点会失败)
    result_ref = execute_remote_func.remote(add_remote, 2, 3)
    print(ray.get(result_ref)) # 单节点输出:5(成功执行);多节点执行错误

    ray.shutdown()

Ray 远程函数:序列化“函数元信息+代码定义”

  • Ray 对远程函数的序列化做了增强 :
    • 1)序列化时,不仅记录函数引用,还会打包函数的代码定义、依赖模块、闭包变量(若有);
    • 2)远程节点接收后,会自动还原函数的执行环境(无需手动导入);
    • 3)底层用 Ray 自定义的序列化器(兼容 pickle,但更适合分布式场景)
  • 正确示例:远程函数跨节点调用成功
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    import ray

    ray.init(ignore_reinit_error=True)

    # Ray 远程函数(已注册,自动序列化代码)
    @ray.remote
    def add_remote(a, b):
    return a + b

    # 直接传递远程函数的引用(Ray 自动处理序列化)
    @ray.remote
    def execute_remote_func(func, x, y):
    return ray.get(func.remote(x, y)) # 远程节点能识别并执行

    # 跨节点调度执行(即使集群有多个节点也能成功)
    result_ref = execute_remote_func.remote(add_remote, 2, 3)
    print(ray.get(result_ref)) # 输出:5(成功执行)

    ray.shutdown()

补充:Ray 还支持 嵌套远程函数 闭包变量传递

  • 比如在远程函数中引用本地变量,Ray 会自动序列化传递:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import ray

    ray.init(ignore_reinit_error=True)

    @ray.remote
    def outer_remote(x):
    # 闭包变量 x 会被 Ray 自动序列化到远程节点
    @ray.remote
    def inner_remote(y):
    return x + y
    return inner_remote.remote(10)

    print(ray.get(ray.get(outer_remote.remote(5)))) # 输出:15

    ray.shutdown()

执行位置:“本地固定” vs “集群分布式调度”

  • 执行位置的差异是两者最直观的区别,直接决定了是否能利用集群资源:

本地函数:执行在「调用方所在进程」

  • 本地函数的执行位置完全固定:
    • 无论在哪里调用(即使在远程函数内部调用本地函数),函数都会在「发起调用的进程」中执行【存疑】
      • 问题:会出错吧,理论上远程函数内部无法调用本地函数?
    • 若在远程函数中调用本地函数,本质是在「远程节点的 Worker 进程」中执行,但该进程没有本地函数的定义(除非手动同步代码),所以必然失败;
    • 无并发能力:多个调用会串行执行在同一个进程/线程(或 Python 多进程的子进程,但需手动管理)

远程函数:执行在「集群 Worker 进程」

  • Ray 远程函数的执行位置由 Ray 集群的调度器统一管理:

    • 1)调用 func.remote() 时,会向 Ray 调度器提交一个任务
    • 2)调度器根据集群节点的资源(CPU、GPU、内存)情况,将任务分配到任意可用节点的 Worker 进程
    • 3)执行完成后,结果会存储在 Ray 的对象存储中,通过 ray.get() 可获取
    • 4)支持并发:多个 remote() 调用会被调度到不同 Worker 进程/节点,并行执行
  • 示例:远程函数分布式执行(多节点/多进程并发)

    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
    import ray
    import os
    import time

    # os.environ["RAY_DEDUP_LOGS"] = "0" # 本意是让每个进程结果都完整输出,但这行代码仅当前进程生效,需要启动前环境变量才可以
    ray.init(ignore_reinit_error=True)

    # Ray 远程函数:打印执行节点的进程 ID 和节点名
    @ray.remote
    def add_remote(a, b):
    node_name = ray.util.get_node_ip_address() # 获取执行节点 IP
    pid = os.getpid() # 获取执行进程 ID
    print(f"在节点 {node_name} 的进程 {pid} 执行 add({a}, {b})")
    time.sleep(1) # 模拟耗时操作
    return a + b

    # 提交 5 个并发任务(会被调度到不同 Worker 进程)
    start = time.time()
    result_refs = [add_remote.remote(i, i*2) for i in range(5)]
    results = ray.get(result_refs) # 等待所有任务完成
    end = time.time()

    print("结果:", results) # 输出:[0, 3, 6, 9, 12]
    print(f"总耗时: {end - start:.2f}s") # 约 1s(并发执行,而非 5s 串行)

    ray.shutdown()
  • 执行上述脚本:

    1
    2
    export RAY_DEDUP_LOGS=0
    python demo.py
    • 注意:仅在代码里面添加 os.environ["RAY_DEDUP_LOGS"] = "0" 是不够的,因为:
      • Ray 的日志去重功能是在 Worker 进程启动时就决定的,而 Worker 是由 Ray 的主进程(Driver)启动的
      • 上面的代码在 ray.init() 之后才启动 Worker,那么环境变量必须在 Driver 启动 Worker 之前就传递过去,否则 Worker 进程会继承默认的去重配置
      • 所以最安全的打印所有日志的方式就是再启动脚本前配置环境变量
    • 另一种实现方式是在远程函数中返回 PID,然后由 Driver 打印
  • 输出示例:

    1
    2
    3
    4
    5
    6
    7
    8
    2025-11-04 11:42:43,175 INFO worker.py:1918 -- Started a local Ray instance. View the dashboard at 127.0.0.1:8265 
    (add_remote pid=14393) 在节点 127.0.0.1 的进程 14393 执行 add(2, 4)
    (add_remote pid=14399) 在节点 127.0.0.1 的进程 14399 执行 add(4, 8)
    (add_remote pid=14398) 在节点 127.0.0.1 的进程 14398 执行 add(1, 2)
    (add_remote pid=14400) 在节点 127.0.0.1 的进程 14400 执行 add(3, 6)
    (add_remote pid=14396) 在节点 127.0.0.1 的进程 14396 执行 add(0, 0)
    结果: [0, 3, 6, 9, 12]
    总耗时: 1.62s

Math——f-divergence


f-divergence定义

  • \( f \)-散度(\( f \)-divergence)是概率论和信息论中的一种概念,用于衡量两个概率分布之间的差异
  • 形式上,对于两个概率分布 \( P \) 和 \( Q \),定义在一个共同的样本空间上, \( f \)-散度可以被定义为:
    $$ D_f(P|Q) = \int_{\Omega} q(x) f\left(\frac{p(x)}{q(x)}\right) dx $$
    • \( \Omega \) 是样本空间
    • \( p(x) \) 和 \( q(x) \) 分别是 \( P \) 和 \( Q \) 的概率密度函数
    • \( f \) 是一个凸函数,满足 \( f(1) = 0 \),这是为了保证当 \( P = Q \) 时 \( D_f(P|Q) = 0 \)
  • \( f \)-散度的一个重要性质是它是非负的,即 \( D_f(P|Q) \geq 0 \),并且只有当 \( P = Q \) 时等号成立。这意味着 \( f \)-散度可以作为两个概率分布之间距离的一种度量,尽管它不满足距离的所有公理(比如对称性)

常见的f-divergence例子

  • Kullback-Leibler散度 (KL散度),其中 \( f(u) = u \log u \)
    • 注意带入以后可以消去分母得到KL散度的最终公式
  • Hellinger距离,这里 \( f(u) = (\sqrt{u} - 1)^2 \)
  • 总变差距离,此时 \( f(u) = |u - 1| \)
  • χ²散度,使用 \( f(u) = \frac{(u - 1)^2}{u} \)

附录:KL散度的非负性证明

  • 核心,利用Jensen不等式证明 Kullback-Leibler(KL)散度是非负的
  • 对于两个概率分布 \( P \) 和 \( Q \) 在同一空间 \( \mathcal{X} \) 上,KL 散度定义为:
    $$
    D_{\text{KL} }(P \parallel Q) = \sum_{x \in \mathcal{X} } P(x) \log \frac{P(x)}{Q(x)}
    $$
    • 注意 KL 散度的积分权重和分子是相同的(这是由其含义和非负性决定的,详情见附录),若对换分子分母,得到的是 KL 的负数值
  • 对于连续变量,定义为:
    $$
    D_{\text{KL} }(P \parallel Q) = \int_{-\infty}^{\infty} p(x) \log \frac{p(x)}{q(x)} , dx
    $$
    • 其中 \( p(x) \) 和 \( q(x) \) 分别是 \( P \) 和 \( Q \) 的概率密度函数
  • 进一步地,KL散度可以表示为:
    $$
    D_{\text{KL} }(P \parallel Q) = \mathbb{E}_{P} \left[ \log \frac{P(x)}{Q(x)} \right]
    $$
    • 即 \( \log \frac{P(x)}{Q(x)} \) 在分布 \( P \) 下的期望

应用Jensen不等式求负KL散度

  • 由于 \( \log(x) \) 是一个凹函数(伞状),根据Jensen不等式,对于凹函数有:
    $$
    \mathbb{E}[\log X] \leq \log \mathbb{E}[X]
    $$
    • 令 \( X = \frac{Q(x)}{P(x)} \),则:
      $$
      \mathbb{E}_{P} \left[ \log \frac{Q(x)}{P(x)} \right] \leq \log \left( \mathbb{E}_{P} \left[ \frac{Q(x)}{P(x)} \right] \right)
      $$
  • 计算期望:
    $$
    \mathbb{E}_{P} \left[ \frac{Q(x)}{P(x)} \right] = \sum_{x} P(x) \cdot \frac{Q(x)}{P(x)} = \sum_{x} Q(x) = 1
    $$
  • 因此:
    $$
    \mathbb{E}_{P} \left[ \log \frac{Q(x)}{P(x)} \right] \leq \log(1) = 0
    $$

推导KL散度的非负性

  • 注意到:
    $$
    \mathbb{E}_{P} \left[ \log \frac{Q(x)}{P(x)} \right] = -D_{\text{KL} }(P \parallel Q)
    $$
  • 因此:
    $$
    -D_{\text{KL} }(P \parallel Q) \leq 0 \implies D_{\text{KL} }(P \parallel Q) \geq 0
    $$
  • 当且仅当 \( P(x) = Q(x) \) 对所有 \( x \) 成立时,\( \frac{P(x)}{Q(x)} = 1 \),此时:
    $$
    D_{\text{KL} }(P \parallel Q) = \sum_{x} P(x) \log 1 = 0
    $$
  • KL散度始终满足:
    $$
    D_{\text{KL} }(P \parallel Q) \geq 0
    $$
  • 且 \( D_{\text{KL} }(P \parallel Q) = 0 \) 当且仅当 \( P = Q \)

附录:KL 散度=交叉熵与熵的差 推导

  • KL 散度本质上是交叉熵与熵的差,反映了用错误模型编码时的“额外信息量”

熵 \(H(P)\) 和 交叉熵 \(H(P, Q)\) 的定义

  • 对于离散分布 \(P(x)\),熵定义为:
    $$
    H(P) = -\sum_x P(x) \log P(x)
    $$
    • 它表示在分布 \(P\) 下,平均需要多少信息量(比特或 nats)来编码事件
  • 交叉熵定义为:
    $$
    H(P, Q) = -\sum_x P(x) \log Q(x)
    $$
    • 它表示在真实分布是 \(P\) 时,如果用分布 \(Q\) 来编码,平均需要的信息量

KL 散度=两者的差

  • 交叉熵与熵的差:
    $$
    \begin{align}
    H(P, Q) - H(P) &= \left[ -\sum_x P(x) \log Q(x) \right] - \left[ -\sum_x P(x) \log P(x) \right] \\
    &= -\sum_x P(x) \log Q(x) + \sum_x P(x) \log P(x) \\
    &= \sum_x P(x) \left[ \log P(x) - \log Q(x) \right] \\
    &= \sum_x P(x) \log \frac{P(x)}{Q(x)} \\
    &= D_{\mathrm{KL} }(P | Q)
    \end{align}
    $$

理解

  • 熵 \(H(P)\) :理想编码长度
  • 交叉熵 \(H(P, Q)\) :用错误分布 \(Q\) 编码的平均长度
  • KL 散度 :额外的编码长度,也就是交叉熵比真实熵多出来的部分

附录:卡方散度 和 KL 散度对比

卡方散度定义

  • 设 \(P,Q\) 为两个概率分布,且 \(Q\) 绝对连续于 \(P\)(\(P(x)=0\Rightarrow Q(x)=0\)), 皮尔逊卡方散度(简称卡方散度) 定义为:
    $$
    \chi^2(P|Q) = \int \frac{(P(x)-Q(x))^2}{Q(x)} dx
    $$
  • 离散形式:
    $$
    \chi^2(P|Q) = \sum_i \frac{(P_i-Q_i)^2}{Q_i}
    $$

回顾 KL 散度定义

  • 连续形式
    $$
    D_{\mathrm{KL} }(P|Q) = \int P(x)\log\frac{P(x)}{Q(x)} dx
    $$
  • 离散形式:
    $$
    D_{\mathrm{KL} }(P|Q) = \sum_i P_i\log\frac{P_i}{Q_i}
    $$

卡方散度与 KL 散度的关系

  • 关系1:泰勒展开关系
    • 当 \(P\) 接近 \(Q\) 时,对 \(\log\frac{P}{Q}\) 在 \(P=Q\) 处展开:
      $$
      D_{\mathrm{KL} }(P|Q) = \frac{1}{2}\chi^2(P|Q) + o\bigl(|P-Q|^2\bigr)
      $$
      • 即:KL 散度在局部等价于卡方散度的 1/2
  • 关系2:不等式关系
    • 由 Jensen 不等式可证:
      $$
      D_{\mathrm{KL} }(P|Q) \le \chi^2(P|Q)
      $$

两者特点对比

  • 对比详情:
    特性 KL 散度 \(D_{\mathrm{KL} }(P|Q)\) 卡方散度 \(\chi^2(P|Q)\)
    形式 含对数,信息论度量 二次型,统计检验度量
    对称性 非对称:\(D_{\mathrm{KL} }(P|Q)\neq D_{\mathrm{KL} }(Q|P)\) 非对称:\(\chi^2(P|Q)\neq \chi^2(Q|P)\)
    非负性 满足 \(D_{\mathrm{KL} }\ge 0\) 满足 \(\chi^2\ge 0\)
    对小 \(Q_i\) 敏感,但存在对数约束,爆炸缓慢 及其敏感,但无对数,更容易爆炸
    权重 按 \(P_i\) 加权 按 \(1/Q_i\) 加权
    来源 信息论、编码、熵 皮尔逊卡方检验、拟合优度
    优化 常用于变分推断、生成模型 常用于密度比、分布检验
  • 重点:卡方散度比 KL 散度更不稳定(在 \(Q(x)\) 极小时,卡方散度很容易出现爆炸)
    • 卡方散度是被 \(\frac{1}{Q}\) 修饰的,当 \(Q(x)\) 减小时,是线性增长
    • KL 散度是被 \(\log \frac{1}{Q}\) 修饰的,当 \(Q(x)\) 减小时,对数增长就慢很多

Math——线性规划求解方法和理解

本文包含对线性规划的直观理解,不严谨,后续有新的问题/理解持续更新

  • 参考链接:
    • 运筹学中应该如何理解互补松弛性。这条性质又该如何运用?
    • 第4章 对偶理论和敏感度分析
    • 线性规划对偶问题的定义,有什么直觉上的解释吗?:原始问题到对偶问题最好的一种很简洁的解释
    • 互联网广告算法漫谈——浅谈广告中的出价技术。注意:该参考链接中没有把预算约束相关的互补松弛定理写出来,且2.3中存在一些较为明显的小bug,但整体求解思路和结论没问题

原始问题

  • 问题描述:
    • 假设你是一个木匠有200单位的木头和90单位的时间
    • 木匠可以制作桌子或者椅子
      • 桌子成本为5单位木头+2单位时间,售价10元
      • 椅子成本为2单位木头+1单位时间,售价3元
  • 目标:在已有资源情况下,最大化收入,应该生产多少桌子和椅子?
  • 问题形式化描述:
    • 假设应该生产 \(x_1\) 把桌子和 \(x_1\) 把椅子
      $$
      \begin{align}
      \max \ \ 10x_1 &+ 3x_2 \\
      5x_1 + 2x_2 &<= 200 \\
      3x_1 + \ \ x_2 &<= 90 \\
      x_1,x_2 &>= 0 \\
      \end{align}
      $$
  • 作图法可求得最优解为 \(x_1^* = 30, x_2^*=0\),此时最大收益为300
    • 在二维坐标轴上先画出可行域,然后按照目标直线斜率找到最优点

对偶问题

  • 对偶问题描述:
    • 上述原始问题可以换一个视角看
    • 假设现在你是一个原材料收购商(想要以最低价格收购木匠的原材料)
    • 目标:对单位木头和单位时间进行出价,以最低的价格买完木匠的资源(假设木匠愿意卖出的前提是收购上出价的最小值不小于木匠原始问题中收益的最大值)
      • 实际上最好是刚好等于木匠原始问题的最大收益
  • 对偶问题形式化描述
    $$
    \begin{align}
    \min \ \ 200p_1 &+ 90p_2 \quad – 总付款 \\
    5p_1 + 3p_2 &>= 10 \quad – 一张桌子的资源售价不低于一张桌子的收益 \\
    2p_1 + \ \ p_2 &>= 3 \quad – 一张椅子的资源售价不低于一张椅子的收益 \\
    p_1,p_2 &>= 0 \quad – 售价不为负数 \\
    \end{align}
    $$
  • 其中 \(p_1, p_2\) 分别称为单位木头和单位时间的影子价格
  • 作图法可求得最优解为 \(p_1^* = 0, p_2^* = 3.3\),此时最小支付金额为300

互补松弛定理的理解

从原始问题的约束视角出发

等价于从对偶问题的解出发

  • 对偶问题中,最优解是 \(p_1^* = 0, p_2^* = 3.3\)
    • \(p_1^* = 0\) 意味着我们的木材过量了,其实不需要这么多木材,原始问题中,最优解对应的木材约束是松的( \(5x_1^* + 2x_2^*=150 < 200\) )
    • \(p_2^* = 3.3\) 说明时间资源非常紧俏,原始问题中,最优解对应的时间约束是紧的( \(3x_1^* + \ \ x_2^* = 90\) )
  • 对应互补松弛的含义:
    • 如果在最优条件下一个约束不等式是松的(木材),那么这个约束对应的影子价格为0
    • 反过来说,如果某个约束对应的影子价格严格大于0,那么这个约束不等式一定是紧的
    • 总的来说,原始问题的约束和对偶问题变量(影子价格)总有一个要为0

从对偶问题的约束视角出发

等价于从原始问题的解出发

  • 原始问题中,最优解是 \(x_1^* = 30, x_2^*=0\)
    • \(x_1^* = 30\) 意味着桌子非常合算,应该多生产桌子,对偶问题中,桌子约束是紧的( \(5p_1^* + 3p_2^* = 10\) )
    • \(x_2^*=0\) 以为这椅子不合算,不应该生产椅子,对偶问题中,椅子的约束是松的( \(2p_1^* + \ \ p_2^* = 3.3 > 3\) )
  • 补充互补松弛的含义:
    • 如果在对偶最优条件下一个约束不等式是松的(椅子),那么这个约束对应的原始问题变量最优解( \(x_2^*\) )为0
    • 反过来说,如果某个原始问题变量(桌子)对应的解( \(x_1^*\) )严格大于0,那么对偶问题中这个约束不等式一定是紧的
    • 总的来说,对偶问题的约束和对应原始问题变量总有一个要为0

互补松弛定理的公式化

$$
(5p_1^* + 3p_2^* - 10)x_1^* = 0 \\
(2p_1^* + p_2^* - 3)x_2^* = 0 \\
(5x_1^* + 2x_2^* - 200)p_1^* = 0 \\
(3x_1^* + x_2^* - 90)p_2^* = 0 \\
$$


附录:USCB推导

  • 《A Unified Solution to Constrained Bidding in Online Display Advertising》——论文阅读
    • 这篇文章的约束很多,每个商家都有自己的约束
    • 推导时用到的对偶变换和互补松弛定理均可由论文推导得出【有时间再详细推导】

附录:BCB推导(单约束)

  • 《Budget Constrained Bidding by Model-free Reinforcement Learning in Display Advertising》——论文原文
    • 这篇文章中的问题定义比较简单,整体只有一个预算约束
    • 上述结果详细的推导可以参考:
      • 智能出价——BCB求解
      • 互联网广告算法漫谈——浅谈广告中的出价技术。注意:该参考链接中没有把预算约束相关的互补松弛定理写出来,且2.3中存在一些较为明显的小bug,但整体求解思路和结论没问题
    • 推导结果 \(bid = \frac{v_i}{\lambda}\) 与常用的方法(RL-MPCA)结果不一致,但可以证明本质是等价的

附录:CPC约束推导(单约束)

  • 问题描述:单位置、二价拍卖,且CPM计费场景,CPC约束下最大化商家点击量
  • 推导过程可参考论文Bid Optimization by Multivariable Control in Display Advertising
  • 基本推导思路:先通过拉格朗日乘子法得到最优解的形式(这里先忽略边际条件 \(0\le x_i \le 1\) ),再将原始问题转换成对偶问题,进一步分情况讨论得到最终解
  • 问题定义
    $$
    \begin{align}
    &\max \sum_i x_i \cdot ctr_i \\
    \text{s.t.} &\quad \frac{\sum_i x_i \cdot wp_i}{\sum_i x_i \cdot ctr_i} \le cpc \\
    &\quad 0 \le x_i \le 1, \forall i
    \end{align}
    $$
  • 第一步:推导最优出价形式:
    • 写出拉格朗日函数并求导:
      $$\mathcal{L}(x, \lambda, \mu) = - \sum_i x_i \cdot ctr_i + \lambda \left(\sum_i x_i \cdot wp_i - \sum_i x_i \cdot ctr_i \cdot cpc\right) + \sum_i \mu_i (x_i - 1)$$
    • 对任意的 \(x_i\) 求导有:
      $$ \frac{\partial \mathcal{L}(x, \lambda, \mu)}{\partial x_i} = - \sum_i ctr_i + \lambda \sum_i wp_i - \lambda \sum_i ctr_i \cdot cpc + \sum_i \mu_i $$
    • 令上述导数为0有(\(\mu_i\) 来自边界条件 \(0\le x_i \le 1\),为了得到最优解形式,接下来先忽略边界条件,最后会证明在满足边界条件下,该形式也是最优的):
      $$
      \begin{align}
      wp_i &= \frac{ctr_i + \lambda \cdot cpc \cdot ctr_i}{\lambda} \\
      &= \frac{1 + \lambda \cdot cpc}{\lambda} \cdot ctr_i
      \end{align}
      $$
      • 所以我们令出价等于下面的形式:
        $$bid_i = \frac{1 + \lambda \cdot cpc}{\lambda} \cdot ctr_i$$
  • 第二步:验证最优出价形式:
    • 原始问题对应的对偶问题为:
      $$
      \begin{align}
      &\mathop{\min}_{\lambda, r_i} \sum_i r_i \\
      \text{s.t.} &\quad \lambda(wp_i - cpc\cdot ctr_i) + r_i \ge ctr_i \quad \text(1)\\
      &\quad \lambda \ge 0 \\
      &\quad r_i \ge 0, \forall i
      \end{align}
      $$
    • 互补松弛条件:
      $$
      \begin{align}
      x_i(\lambda(wp_i - cpc\cdot ctr_i) + r_i - ctr_i) = 0 \quad &\text{(2)} \\
      r_i(x_i - 1) = 0, \forall i \quad &\text{(3)}
      \end{align}
      $$
    • 将最优出价公式 \(bid_i = \frac{ctr_i + \lambda \cdot cpc \cdot ctr_i}{\lambda}\) 带入公式(2)可得:
      $$ x_i(\lambda(wp_i - bid_i) + r_i) = 0$$
      • 当 \(x_i \gt 0\) 时,有 \(wp_i - bid_i = -\frac{r_i}{\lambda} \lt 0\),进一步推得 \(bid_i \ge wp_i\)
      • 当 \(x_i = 0\) 时,由公式(3)有 \(r_i = 0\);将最优出价公式 \(wp_i = \frac{ctr_i + \lambda \cdot cpc \cdot ctr_i}{\lambda}\) 带入公式(1)可得 \(\lambda(wp_i - bid_i) + r_i \ge 0\),进一步推得 \(wp_i - bid_i \ge 0\),即\(bid_i \le wp_i\)
    • 证毕
  • 如何理解最优出价形式?
    $$
    \begin{align}
    bid_i = \frac{1 + \lambda \cdot cpc}{\lambda} \cdot ctr_i = \color{red}{(\frac{1}{\lambda \cdot cpc} + 1)} \cdot cpc \cdot ctr_i
    \end{align}
    $$
    • 二价计费场景中 ,计费比未知,所以引入了一个大于 1 的出价系数:
      $$ k = \color{red}{\frac{1}{\lambda \cdot cpc} + 1} $$
      • 用来提升出价以做到目标CPC达成(可以证明,在整个周期内流量足够多的情况下,如果实际CPC小于目标CPC,则此时一定不是点击最大化的出价策略)
      • 实际使用中,由于 \(1\) 是全局固定值,商家的 \(cpc\) 是商家粒度的固定值,\(\lambda\) 是商家粒度的变量,可以合并成一个变量即可(\(\lambda\) 和 \(k\) 是一一对应的),最终可以忽略 \(k\) 值的具体形式,只需要直接调节 \(k\) 即可,此时最优公式为:
        $$ bid_i = \color{red}{k} \cdot cpc \cdot ctr_i $$
    • 如果竞争环境非常激烈,计费比趋近于1(同时考虑预估值准确),此时每次出价都按照 \(\color{red}{bid_i = cpc \cdot ctr_i} \),可保证投放周期内实际CPC的期望刚好等于目标CPC,\(\color{red}{k=1}\) 就是最优的出价策略
    • 调控系数的其他功能 :从推导来看,系数 \(k\) 可以用于补足二价计费的Gap;在实际应用中,这个 k 值还可以解决 CTR 均值预估值不准确的问题,比如CTR预估过高 ,\(k\) 会小于1 ,从而保证不超成本
      • 可以注意到:在这个假设下有矛盾点,\(k < 1\) 时对应的 \(\lambda < 0\),并不满足对拉格朗日乘子的要求,但不用担心,这里实际上 \(k = k_1 \cdot k_2\),其中,由 \(\lambda\) 导出的 \(k_1\) 依然是大于1的,用来调平CTR预估值 \(k_2\) 是小于1的,实际上,\(\lambda \geq 0\) 始终成立

附录:oCPC场景约束推导(单约束)

  • 问题描述1:单位置、二价拍卖,且CPC计费场景,CPS约束下最大化商家订单量
  • 实际上,本问题中与上文单位置拍卖的CPM计费场景,CPC约束下最大化商家点击量非常相似,仅需把对应的参数替换一下即可(\(ctr_i \rightarrow cvr_i\),\(cpc \rightarrow cps\)),于是有最优出价形式是:
    $$
    \begin{align}
    wp_i = \frac{1 + \lambda \cdot cps}{\lambda} \cdot cvr_i = \color{red}{(\frac{1}{\lambda \cdot cps} + 1)} \cdot cps \cdot cvr_i
    \end{align}
    $$
    • 出价系数:
      $$ k = \color{red}{\frac{1}{\lambda \cdot cps} + 1} $$
    • 注:实际使用中,同上描述,最终可以忽略 \(k\) 值的具体形式,只需要直接调节 \(k\) 即可,此时最优公式为:
      $$ bid_i = \color{red}{k} \cdot cps \cdot cvr_i $$
  • 问题描述2:单位置、二价拍卖,且CPC计费场景,ROI约束下最大化商家Revenue
  • 此时可以进一步表达为如下形式(\(cvr_i \rightarrow cvr_i\cdot rev_i\),\(cps \rightarrow rate = \frac{1}{ROI}\), ):
    $$
    \begin{align}
    wp_i &= \frac{1 + \lambda \cdot 1/ROI}{\lambda} \cdot cvr_i \cdot rev_i \\
    &= \frac{ROI + \lambda}{\lambda \cdot ROI} \cdot rev_i \cdot cvr_i \\
    &= \frac{ROI + \lambda}{\lambda} \cdot \frac{rev_i \cdot cvr_i}{ROI} \\
    &= \color{red}{(\frac{ROI}{\lambda} + 1)} \cdot \frac{rev_i \cdot cvr_i}{ROI} \\
    \end{align}
    $$
    • 出价系数:
      $$ k = \color{red}{\frac{ROI}{\lambda} + 1}$$
    • 注:实际使用中,同上描述,最终可以忽略 \(k\) 值的具体形式,只需要直接调节 \(k\) 即可,此时最优公式为:
      $$ bid_i = \color{red}{k} \cdot \frac{rev_i \cdot cvr_i}{ROI} $$

附录:紧约束和松约束

  • 紧约束(Tight Constraint)和松约束(Slack Constraint)是描述约束条件对可行解集影响的两个概念
  • 紧约束指的是那些在其边界上限制了最优解的约束条件。换句话说,如果改变某个约束条件会直接影响到最优解的位置或值,那么这个约束条件就是紧的。例如,在线性规划问题中,如果一个不等式约束以“=”的形式满足于最优解处,那么这个约束就是紧约束。紧约束对于确定最优解至关重要,因为它们直接定义了最优解所在的位置
  • 松约束则指的是那些在最优解处并没有起到实际限制作用的约束条件。也就是说,即使这些约束不存在,也不会改变问题的最优解。这类约束条件提供了额外的空间,但在这个空间内的点并不会比边界上的点更优。因此,松约束的存在不会影响最终的优化结果,但在某些情况下,它们可能为寻找最优解提供便利或增加灵活性。

Math——运筹优化开源求解器-GLPK的使用

本文介绍各种运筹优化开源求解器-GLPK的使用

  • GLPK是一款完全开源免费的运筹优化求解器,可以任意商用

Ubuntu安装GLPK

  • 据说Ubuntu安装较为方便,所以建议首选Ubuntu

  • 在网站下载文件:https://ftp.gnu.org/gnu/glpk/

    • 可以下载任意版本,建议选最新
  • 安装命令

    1
    2
    3
    4
    tar -xzvf glpk-xxx.tar.gz
    ./configure
    make
    sudo make install
  • 安装后直接执行可能出现错误

    1
    error while loading shared libraries: libglpk.so.36:...
  • 解决方案(原始解决方案地址):

    1
    https://github.com/rstudio/renv/issues/1881

Ubuntu下GLPK的使用

  • 下列式子参考了:线性规划工具 GLPK 的安装及基本使用

  • 创建问题描述文件glpkDemo.mod

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /* Variables */
    var x1 >= 0;
    var x2 >= 0;
    var x3 >= 0;

    /* Object function */
    maximize z: 3*x1 + x2 +2*x3;

    /* Constrains */
    s.t. con1: x1 + x2 + 3*x3 <= 30;
    s.t. con2: 2*x1 +2*x2 + 5*x3 <= 24;
    s.t. con3: 4*x1 + x2 + 2*x3 <= 36;

    end;
  • 执行命令解决问题

    1
    glpsol -m glpkDemo.mod -o ./output/glpkDemo.sol
  • 输出文件

    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
    Problem:    glpkDemo
    Rows: 4
    Columns: 3
    Non-zeros: 12
    Status: OPTIMAL
    Objective: z = 28 (MAXimum)

    No. Row name St Activity Lower bound Upper bound Marginal
    ------ ------------ -- ------------- ------------- ------------- -------------
    1 z B 28
    2 a B 12 30
    3 b NU 24 24 0.166667
    4 c NU 36 36 0.666667

    No. Column name St Activity Lower bound Upper bound Marginal
    ------ ------------ -- ------------- ------------- ------------- -------------
    1 x1 B 8 0
    2 x2 B 4 0
    3 x3 NL 0 0 -0.166667

    Karush-Kuhn-Tucker optimality conditions:

    KKT.PE: max.abs.err = 0.00e+00 on row 0
    max.rel.err = 0.00e+00 on row 0
    High quality

    KKT.PB: max.abs.err = 0.00e+00 on row 0
    max.rel.err = 0.00e+00 on row 0
    High quality

    KKT.DE: max.abs.err = 2.22e-16 on column 1
    max.rel.err = 3.17e-17 on column 1
    High quality

    KKT.DB: max.abs.err = 0.00e+00 on row 0
    max.rel.err = 0.00e+00 on row 0
    High quality

    End of output
  • Activity这一列就是想要的解

  • 其他输出项如何理解?

自动化生成问题

  • 使用shell或者Python自动生成.mod文件,然后自然解析.sol文件,实现自动化测试参数

RL——IQL

  • 参考链接
    • 原始论文:ICLR 2022 Poster, Offline reinforcement learning with implicit q-learning
    • 相关论文:(AWR)ADVANTAGE-WEIGHTED REGRESSION: SIMPLE AND SCALABLE OFF-POLICY REINFORCEMENT LEARNING

IQL 的基本思想

  • 常规的方法会直接约束策略或者正则来减少OOD问题,IQL则通过SARSA style的方法仅在见过的state-action上进行学习,不直接面对OOD问题
  • 策略学习使用了AWR(Advantage Weighted Regression)方法

多步动态规划和 Single-step 方法

多步动态规划(Multi-step DP)

  • 多步动态规划方法(multi-step dynamic programming methods,简写作Multi-step DP)
  • 已有Offline RL方法的很大一部分是基于约束或正则化的近似动态规划(例如,Q-learning 或 actor-critic 方法),constraint或Regularization用于限制与行为策略的偏差。 我们将这些方法称为多步动态规划(Multi-step DP)算法,因为它们对多次迭代执行真正的动态规划,因此如果提供高覆盖率数据,原则上可以恢复最优策略。通常情况下Multi-step DP问题也可以分为:
    • 显式密度模型(explicit density model):BRAC,BCQ,BEAR等
    • 隐式差异约束(implicit divergence constraints):AWAC,CRR,AWR等
  • 如何理解显示密度模型和隐式约束模型的定义?
    • 显式密度模型:直接建模State-Action的价值分布,从而得到最优策略
    • 隐式差异约束:不直接建模State-Action的价值分布,更多是模仿优质策略行为的思想
  • 问题:显示密度模型中的“密度”是什么意思?
    • 这里的密度是指概率密度,显示密度模型即会直接定义并学习概率密度函数的模型

Single-step 方法

  • Single-step 方法(Single-step Methods)是指一类方法,这类方法仅依赖于单步策略迭代的方法,即对行为策略的价值函数或Q函数进行拟合,然后提取相应的贪心策略,或者完全避免价值函数并利用行为克隆目标
  • 这类方法避免了访问看不见的状态动作对,因为它们要么根本不使用价值函数,要么学习行为策略的价值函数
  • IQL 就是一种 Single-step 方法
  • 传统的模仿学习也属于 Single-step 方法

多步动态规划和 Single-step 方法的比较

  • from https://zhuanlan.zhihu.com/p/497358947

IQL 之前的方案

一般的 Offline RL 学习方法

  • 思路:按照贝尔曼最优方程迭代
  • 损失函数:
    $$
    L_{TD}(\theta) = \mathbb{E}_{(s,a,s’) \sim D} \left[ (r(s, a) + \gamma \max_{a’} Q_{\theta’}(s’, a’) - Q_\theta(s, a))^2 \right]
    $$
  • 分析:
    • 直接使用上述损失函数存在值高估问题
    • 大多数最近的离线RL方法修改了上述值函数损失(或直接约束argmax这个策略本身选择动作的方位),以正则化值函数,使其生成的策略接近数据,缓解值高估问题

能避免 OOD 的学习方法

  • 思路:按照SARSA-style的方法迭代,即贝尔曼期望方程( \(a’\sim \pi_\beta\) )
  • 损失函数:SARSA-style的损失函数如下
    $$
    L(\theta) = \mathbb{E}_{(s,a,s’,a’) \sim D} \left[ (r(s, a) + \gamma Q_{\theta’}(s’, a’) - Q_\theta(s, a))^2 \right]
    $$
    • 按照上面的损失函数学习,学到的 \(Q_\theta(s,a)\) 本质是行为策略对应的Q值,也就是说,当样本无限时,Q值收敛到
      $$
      Q_\theta^*(s, a) \approx r(s, a) + \gamma \mathbb{E}_{s’ \sim p(\cdot|s,a), a’ \sim \pi_\beta(\cdot|s’)} \left[ Q_{\theta’}(s’, a’) \right]
      $$
  • 分析:
    • 本质上是在估计数据集上的状态和动作分布下,Q值的期望
    • 显然上面学到的只是行为策略对应的Q值,不是我们想要的最优Q值(行为策略不一定是最优策略)
    • 上面的方法更像是在对行为策略进行模仿

Offline RL 的最优 Q 值目标

  • 思路:避免OOD且能学到“最优策略”的迭代形式,限制了argmax动作不访问OOD的状态动作对
  • 损失函数:
    $$
    L(\theta) = \mathbb{E}_{(s,a,s’) \sim D} \left[ (r(s, a) + \gamma \max_{a’ \in A, \pi_\beta(a’|s’) > 0} Q_{\theta’}(s’, a’) - Q_\theta(s, a))^2 \right]
    $$
  • 分析:
    • 既保证使用的最大Q值对饮动作不超过数据集(避免了OOD),又可以在支持集上最大化当前策略
    • 上面的定义实际上也可能访问到支持集以外的动作,后续需要使用期望回归来改进为SARSA-style的形式
  • 注意:IQL 并不直接学习上述目标( \(\pi_\beta(a’|s’) > 0\) 导致无法学习),只是隐式的学习上述目标 ,具体方法是引入期望回归(Expectile Regression)
    • BCQ等方法已经学习过上述目标的改进版本
    • 上述目标无法直接学习,因为判断 \(\pi_\beta(a’|s’) > 0\) 需要维护一个表格,统计所有数据,状态动作空间很大时无法实现,除非像BCQ一样,用一个网络去学习概率

IQL 的解决方案

期望回归与分位数回归

  • 期望回归(Expectile Regression) ,是估计随机变量的各种统计量的方法,定义如下:

    • 某个随机变量 \(X\) 的 \(\tau \in (0, 1)\) 期望值定义为以下非对称最小二乘问题的解:
      $$
      \mathop{\arg\min}_{m_\tau} \mathbb{E}_{x \sim X} \left[ L_\tau^2(x - m_\tau) \right], \quad \text{ Where } \quad L_\tau^2(u) = |\tau - 1(u < 0)| u^2.
      $$
    • \(L_\tau^2(u)\) 也常常写作 \(L_\tau^e(u)\)
    • 给定 \(\tau\), \(m_\tau\) 就是在拟合随机变量的某个 \(\tau\) 期望点,不同的 \(\tau\) 下 \(m_\tau\) 也会不同,学到的,比如 \(\tau=0.5\) 时就是对应期望
    • 分析:
      • 当 \(\tau > 0.5\) 时,这种非对称损失函数会降低小于 \(m_\tau\) 的 \(x\) 值的权重,而增加大于 \(m_\tau\) 的 \(x\) 值的权重
      • 当 \(\tau = 0.5\) 时,损失函数退化成对称的,等价于均方误差MSE(这里把 \(u\) 看做是误差项)
        $$ L^{\tau=0.5}_{2}(u) = |0.5 - \Bbb{1}(u<0)|u^2 = \frac{1}{2}u^2 $$
  • 条件随机变量的期望回归

    • 对于给定的条件随机变量 \(y = f(x)\),假定 \((x,y)\) 成对出现在数据集 \(\mathcal{D}\) 中,则可以定义:
      $$\mathop{\arg\min}_{m_\tau(x)} \mathbb{E}_{(x,y) \sim \mathcal{D}} \left[ L_\tau^2(y - m_\tau(x)) \right]$$
    • 给定 \(\tau\), \(m_\tau(x)\) 是一个关于 \(x\) 的函数,不同的 \(\tau\) 得到的拟合函数不同,相同的 \(\tau\),给定不同的 \(x\) 会得到不同的 \(m_\tau(x)\), \(m_\tau(x)\) 本质是在拟合 \(y\),下图中最右侧的图展示了条件随机变量的期望回归
  • 分位数回归(Quantile Regression)定义如下:
    $$
    \mathop{\arg\min}_{m_\tau} \mathbb{E}_{x \sim X} \left[ L_\tau^1(x - m_\tau) \right], \quad \text{ Where } \quad L_\tau^1(u) = (\tau - 1(u < 0)) u.
    $$

    • \(L_\tau^1(u)\) 也常常写作 \(L_\tau^q(u)\)
    • \((\tau - 1(u < 0)) u\) 不使用绝对值的原因是此时无论 \(u\) 取值正负 \(L_\tau^1(u) \ge 0\) 都成立,相当于已经给整体加了绝对值了,最终目标是类似MAE的形式
  • 分位数回归和期望回归的对比

    • 常规的MSE叫做mean,等价于求均值,等价于 \(\tau = 0.5\) 的期望回归(expectile regression)
    • 常规的MAE叫做median,等价于求中位数,等价于 \(\tau = 0.5\) 的分位数回归(quantile regression)
  • 更多比较

    • 修正:左边第二行需要使用绝对值 \(\mathcal{R}_\tau^e(u) = u^2|\tau - \mathbf{1}(u < 0)|\)
  • 问题:为什么使用期望回归而不是分位数回归?

    • 审稿人也有这个疑问,作者的回答是实验得到的,没有正面给出回答?, \(\tau=0.9\) 时效果最好

基于期望回归的 Q 值学习

  • 借助期望回归来学习Q值:
    $$
    L(\theta) = \mathbb{E}_{(s,a,s’,a’) \sim D} \left[ L_\tau^2(r(s, a) + \gamma Q_{\theta’}(s’, a’) - Q_\theta(s, a)) \right]
    $$
  • 其中 \(\mathcal{D} \sim \pi_\beta\),选择合适的 \(\tau\) 后,可以学到一个大于 \(Q^{\pi_\beta}(s,a)\) (行为策略对应的Q值)的 \(Q(s,a)\)
  • 理解:给定 \((s,a)\) 的情况下,存在许多不同的 \((s’,a’)\) 样本,当 \(\tau > 0.5\) 时,相当于是通过这种非对称损失函数降低小于 \(Q_\theta(s, a)\) 的动作状态对 \((s’, a’)\) 所对应的目标值 \(r(s, a) + \gamma Q_{\theta’}(s’, a’)\) 的权重,增加大于 \(Q_\theta(s, a)\) 的动作状态对 \((s’, a’)\) 所对应的目标值 \(r(s, a) + \gamma Q_{\theta’}(s’, a’)\) 的权重,从而学到较大的 \((s’,a’)\) 对应的目标值,极端情况下,学到的是最大值 \(r(s, a) + \gamma \max_{(s,a,s’,a’) \sim \mathcal{D}} Q_{\theta’}(s’, a’)\)
  • 上面的损失函数还存在一些不足,由于环境可能是动态变化的,状态 \(s’\) 是按照概率 \(p(s’|s,a)\) 出现,所以以上损失函数还使得Q学到了环境转换的信息。具体来说,学到的Q值高不一定是选到了优秀动作的反应,还可能是因为运气好碰上了转移到一个较好的状态 \(s’\) 上
    • 补充说明1:即使是随机环境,在状态 \(s\) 下,选择 \(a\) 后有一定概率得到较优秀的 \(s’\),能说明在状态 \(s\) 下,选择 \(a\) 是较为优秀的吗?回答是不一定!因为在这种随机环境的情况下,最优贝尔曼方程里面,我们也需要对 \(s’\) 计算期望 \(\mathbb{E}_{s’\sim p(s’|s,a)}\) 而不是取最大 \(\max_{s’}\),这是我们的目标是找一个策略,使得按照这个策略交互得到的期望收益最大,而线上推断时,我们不能保证一定能走到最大的 \(s’\),除非是确定性环境,即 \((s,a)\) 确定后, \(s’\) 也是确定的
    • 补充问题1:如果是确定性的环境,是否可以直接使用上述损失函数?

IQL 的 Q 值学习

  • 由于基于期望回归的Q值学习引入了状态转移随机偏差,存在问题,所以需要进行改进:
  • 第一步:使用期望回归去从已知的 \(Q_{\hat{\theta}}(s,a)\) 中学习 \(V(s)\)
    $$ L_V(\psi) = \mathbb{E}_{(s,a) \sim D} \left[ L_\tau^2(Q_{\theta’}(s, a) - V_\psi(s)) \right] $$
    • 这里可以看出 \(V(s)\) 学到的是 \(\max_a Q_{\hat{\theta}}(s,a)\) 的思想,即对应V值的贝尔曼最优方程
  • 第二步:使用最优的 \(V\) 去学习 \(Q\)
    $$L_Q(\theta) = \mathbb{E}_{(s,a,s’) \sim D} \left[ (r(s, a) + \gamma V_\psi(s’) - Q_\theta(s, a))^2 \right] $$
    • 由于 \(V\) 在上一步已经通过期望回归学到了最优形式,这一步不需要继续使用期望回归了
  • 至此,我们已经实现了通过SARSA-style的形式,隐式的学到了近似最优Q值
  • 关于参数 \(\tau\) 的一些分析以及以上贝尔曼方程收敛性见附录

IQL 的策略学习

  • 虽然我们已经得到了近似最优Q值,但为了避免使用样本外的动作,这里做策略学习时,我们不能直接遍历所有动作
  • AWR提供了一种方法从近似最优Q值里面提取策略(因为策略学习并不影响Q值,所以更像是从近似最优Q值中提取策略):
    $$
    L_\pi(\phi) = \mathbb{E}_{(s,a) \sim D} \left[ \exp(\beta (Q_{\theta’}(s, a) - V_\psi(s))) \log \pi_\phi(a|s) \right]
    $$
    • 其中 \(\beta \ge 0\) 是温度系数。对于较小的超参数值,该目标类似于行为克隆(近似所有样本权重相等的策略梯度,原始策略梯度中,样本权重是温度系数为1的Q值),而对于较大的值,它试图恢复Q函数的最大值(Q值越大,对应的样本权重越大)。正如AWR等先前工作所示,此目标学习一个在分布约束下的最大化Q值的策略
  • 注意,策略学习时Q值收敛以后进行的(Q和V是交替更新),Q值学习和策略学习是串行的,且Q值学习彻底完成以后才进行策略学习,并不是交替进行
  • 思考:使用期望回归学到的 \(V\) 值是 \(V^{\pi^*} = \max_a Q_{\hat{\theta}}(s,a)\),为什么可以用最优的 \(V\) 值来更新策略 \(Q_{\theta’}(s, a) - V_\psi(s)\) ?
    • 这种做法是可以的,Q值和V值符合优势函数的定义,因为传统优势函数的定义也是 \(A^\pi(s,a) = Q^\pi(s,a) - V^\pi(s)\),其中 \(V^\pi(s) = \mathbb{E}_{a \sim \pi(\cdot|s)}[Q^\pi(s,a)]\),看似与 IQL 中学到的 \(V\) 值不同,但此时将当前Q值和V值对应策略 \(\pi(a|s)\) 理解为选择Q值最大的动作或近似动作,实际上 \(Q\) 值和 \(V\) 值都满足传统的优势函数了
    • 理解 :(即使不满足原始优势函数)虽然此时的 \(V\) 值是 \(\max_a Q_{\hat{\theta}}(s,a)\),但是 \(Q_{\theta’}(s, a) - V_\psi(s)\) 依然可以对动作的好坏进行区分。实际上,只要可以保证动作越好,优势函数越大即可,即使所有动作都是负的或者都是正的也没问题,因为策略的实现是一个softmax,大家都降低的时候,降的少的动作上对一个的概率自然会提升。实践也告诉我们,\(V\) 值是否是当前状态下动作的期望结果并不重要
    • 特别说明 :AWR 中使用的 \(V\) 值是从历史样本的累计奖励上学习的,相当于是历史样本上的期望,也就是行为策略 \(\mu\)(多轮迭代下可能是混合策略)对应的 \(V^\mu\) 值,AWR 的整个推导中奖励 \(\mathcal{R}^\mu_{\mathbf{s},\mathbf{a}}\) 和 \(V^\mu\) 值都是使用行为策略 \(\mu\) 来表示的,奖励使用的是蒙特卡洛估计 \(\mathcal{R}^D_{\mathbf{s},\mathbf{a}} = \sum_{t=0}^T\gamma^t r_t\)

IQL 训练流程

  • 伪代码如下(说明:伪代码中最后一行策略更新公式有问题,应该是加号,或者把损失函数添上负号,因为这里是想要最大化目标, 作者开源代码中是正确的github.com/ikostrikov/implicit_q_learning,论文中写错了):

附录:为什么 AWR 和策略梯度法损失函数不同?

  • 副标题:不同AC框架算法策略更新公式对比分析,为什么相同的目标推导出来完全不同的更新公式?
  • 问题补充:
    • 普通AC(策略梯度法)更新公式是:
      $$\mathop{\arg\max}_{\theta} \mathbb{E}_{(s,a) \sim \pi_{\theta_k}}\Big[(Q^{\pi_{\theta_k}}(s,a)-V^{\pi_{\theta_k}}(s))\log\pi_\theta(a|s)\Big]$$
    • PPO更新公式:
      $$\mathop{\arg\max}_{\theta} \mathbb{E}_{(s,a) \sim \pi_{\theta_k}}\Big[\frac{\pi_\theta(a|s)}{\pi_{\theta_k}(a|s)} A^{\pi_{\theta_k}}(s,a) - \beta D_{KL}(\pi_{\theta_{k}}(\cdot|s), \pi_\theta(\cdot|s))\Big]$$
    • DDPG更新公式
      $$\mathop{\arg\max}_{\theta} \mathbb{E}_{s_t \sim \rho^\beta(s)} [Q_w(s_t,\mu_\theta(s_t))] $$
    • SAC更新公式
      $$\mathop{\arg\max}_{\theta}\mathbb{E}_{s_t \sim \mathcal{D}, \epsilon_t \sim \mathcal{N}}[\log \pi_\theta(f_\theta(\epsilon_t;s_t)\vert s_t) - Q_\theta(s_t, f_\theta(\epsilon_t; s_t))]$$
    • AWR更新公式:
      $$\mathop{\arg\max}_{\theta} \mathbb{E}_{(s,a) \sim \pi_\beta}\Big[exp\Big(\frac{1}{\beta}(R_{s,a}^{\mathcal{D}}-V^{\mathcal{D}}(s))\Big)\log\pi_\theta(a|s)\Big]$$
      • 其中 \(R_{s,a}^{\mathcal{D}} = \sum_{t=0}^\infty \gamma^t r_t\),不是网络,是真实的轨迹收益
    • IQL更新公式:
      $$\mathop{\arg\max}_{\theta} \mathbb{E}_{(s,a) \sim \pi_\beta}\Big[exp\Big(\beta (Q_{\theta’}(s, a) - V_\psi(s))\Big)\log\pi_\theta(a|s)\Big]$$
    • AWAC更新公式:
      $$\mathop{\arg\max}_{\theta} \mathbb{E}_{(s,a) \sim \pi_\beta}\Big[exp(\frac{1}{\lambda} A^{\pi_{\theta_k}}(s,a))\log\pi_\theta(a|s)\Big]$$
  • 基本推导思路总结:
    • 策略梯度法 :推导是直接从最初目标出发,视图求最初目标相对策略的梯度
    • PPO :更新公式是从策略提升的视角出发得到梯度提升的目标,通过限制策略变化幅度和重要性采样分别将未知策略的状态和动作采样的问题切换到已知策略
    • DDPG :直接以最大化Q值为目标来更新,可直接传导策略梯度
    • SAC :的目标中增加了熵,可以看成是DDPG的增加熵的版本
    • AWR、IQL和AWAC :更新公式都是相同的形式,是从策略提升的视角出发得到梯度提升的目标,并对该目标进行推导,得到最终的最优策略形式,再带入最优策略形式,从而得到更新公式
  • 也就是说,AWR、IQL和AWAC这三个方法的目标是为了策略提升量最大化 ,而策略梯度法的目标是为了原始目标最大化(梯度提升法)

附录:为什么 IQL 效果比 AWR 好?

  • IQL和 AWR 的 Q 值是不同策略的优势函数,IQL 的优势函数是在 \(\tau\) 分位点期望动作策略分布上的 Q 和 V,即 \(A^{\pi^*}(s,a) = Q^{\pi^*}(s,a) - V^{\pi^*}(s)\),而AWR的优势函数是真实的轨迹回报和V值 \(A^{\pi_k}(s,a) = R_{s,a}^{\mathcal{D}} - V^{\pi_k}(s)\)
  • IQL 不是迭代训练,是先学好 Q 值(不依赖策略),再利用学好的 Q 值一次性提取策略
  • 标准的 AWR 是 off-policy 的,是一种迭代训练的流程,V 值学习依赖策略与环境交互的轨迹数据,策略学习也依赖上一步的V值,V值,策略,轨迹三者是不断优化的
  • 如果把 AWR 直接用到 Offline R L场景下,则不再与环境交互,AWR 退化到学习一次V值,接着一次性学习策略;
    • Offline RL 下学到的 V 值是行为策略对应的 V 值,不是最优的 V 值,但这本身应该没有问题
    • 基于统计的 \(R_{s,a}^{\mathcal{D}}\) 方差可能很大
  • 使用公式 \(L_\pi(\phi) = \mathbb{E}_{(s,a) \sim D} \left[ \exp(\beta (Q_{\theta’}(s, a) - V_\psi(s))) \log \pi_\phi(a|s) \right]\) 来迭代策略时,Q 值和 V 值应该使用什么样的才是最优的?
    • 这个公式是从最大化策略提升项得到的,在推导策略提升时,这里使用的A值(对应到Q值和V值)是上一步策略对应的值 \(A^\mu(s,a)\),即旧策略 \(\mu\) 对应 Q 值和 V 值,而我们的目标是在 \(\mu\) 的基础上有所提升,得到优秀的新策略 \(\pi\),所以 Q 值和 V 值最好是优秀的策略对应的Q值和V值,否则可能我们的策略 \(\pi\) 在不好的策略上提升,结果也可能不是很优秀
  • 补充问题:可以随便使用一个策略来评估优势函数吗?
    • 回答是不可以,因为不同策略下,A 值选择不同动作以后的值是不同的,显然学到的策略也不同,从推导看,必须使用上一步的才可以

附录:贝尔曼方程收敛性及 \(\tau\) 的分析

  • 关于参数 \(\tau\) 的一些分析,原始论文中关于 \(\tau\) 的分析如下:

  • 当 \(\tau = 0.5\),相当于是SARSA算法;当 \(\tau \rightarrow 1\),相当于是Q-Learning算法

  • 对于任意的 \(\tau\),Q值和V值迭代都会收敛,且Q值和V值会收敛到 \(Q_{\tau}(s,a)\) 和 \(V_{\tau}(s)\),Lamma1中最后两行就是两者的贝尔曼方程,其中 \(\mathbb{E}_{a \sim \mu(\cdot|s)}^\tau\) 表示 \(\mu(\cdot|s)\) 分布下的 \(\tau\) 期望分位值(或 \(\tau\) 阶期望分位数)。注意,我们在说分位数时,还需要说明是那个随机变量或者哪个分布的分位数,否则没有意义

  • 为什么说Q值和V值迭代都会收敛到 \(Q_{\tau}(s,a)\) 和 \(V_{\tau}(s)\) 呢?

    • 理解:这里的 \(\tau\) 期望分位动作可以视作是一个策略,每次选择动作时,不选择最优动作,也不选择随机动作,而是选择 \(\tau\) 期望分位点动作,这样,可以得到跟论文中一样的结论:当 \(\tau = 0.5\),相当于是SARSA算法;当 \(\tau \rightarrow 1\),相当于是Q-Learning算法
    • 证明:定义一个策略如下:
      $$\pi_\tau(s) = \mathop{\text{arg_expectile}^\tau}_a(Q(s,a))$$
      该策略表示在状态 \(s\) 下,该策略会选择使得Q值等于 \(Q(s,a)\) 关于动作 \(a\) 的 \(\tau\) 期望分位点的动作,则期望分位动作策略对应的贝尔曼方程跟普通策略下的贝尔曼方程没有区别
    • 更详细的来说:
      • Q值:假定已经有了 \(V_\tau(s’)\),此时Q值的更新是学习当前状态 \(s\) 下,按照当前状态对应的 \(\tau\) 期望分位动作,以及后续策略也采用 \(\tau\) 期望分位动作得到的价值 \(V_\tau(s’)\) 来进行拟合的目标值(注意,这里跟其他贝尔曼方程一样,一旦动作决定了, \(r(s,a)\) 就确定了,我们所说的期望分位动作就是对动作 \(a\) 的分布而言的, \(Q(s,a)\) 的拟合只考虑 \((s,a)\) 状态动作对即可,不需要考虑期望分位动作);
      • V值:假定已经有了 \(Q_{\tau}(s,a)\),V值可以从 \(Q_{\tau}(s,a)\) 中学到 \(V_\tau(s’)\),这里需要使用 \(Q_{\tau}(s,a)\) 而不是 \(Q_{\pi_\beta(s,a)}\) 的原因是,V的本质是 \(Q(s,a)\) 关于动作 \(a\) 期望,但直接求期望只到了当前状态 \(s\) 这一层,如果使用 \(Q_{\pi_\beta(s,a)}\) 来学习那么学到的不是 \(V_\tau(s’)\) ( \(V_\tau(s’)\) 是指后续的动作也是 \(\tau\) 期望分位动作来定义的,正如Q值和V值的常规贝尔曼方程一样)

Implicit 名字的来源

  • Implicit 含义是“隐式的”,与隐式约束的隐式不等价,在IQL中表示通过期望回归隐式的学到了最优价值函数 \(V^*(s) = \max Q(s,a)\)

IQL 可能存在的问题

  • IQL 没有没有像 CQL 一样对非行为策略的 Q 值进行打压(甚至学习过程中全程未学习未知状态动作对的 Q 值),也没有像 BCQ 一样对动作选择进行限制,理论上可能会因为对 OOD 状态动作 Q 值高估而出现问题
  • IQL 源码实现时的解法:采用 Twin Q 来缓解高估问题(理解:对于数据集中存在的,两个 Q 网络都能估准;对于数据集中不存在的,可能都估不准,但是我们取最小的那个,可以缓解对未知状态动作对 Q 值的高估问题)

Python——Ray-分布式架构简单了解


整体介绍

  • Ray 是一个用于分布式计算的开源框架,专为构建和运行分布式应用程序而设计
  • Ray 提供了简洁的 API,让开发者能够轻松地将单机程序扩展到分布式集群上,同时保持代码的可读性和可维护性
  • Ray 最初由 UC Berkeley 的 RISELab 开发,现在由 Anyscale 公司维护,广泛应用于机器学习、强化学习、并行计算等领域
  • Ray 既可以在本地实现并行计算,又可以非常容易的扩展到集群模式,实现分布式计算
  • Ray 与深度学习框架如 TensorFlow、PyTorch 和 MXNet 等互相兼容

Ray 的核心架构

  • Ray的系统架构采用了混合任务调度的思路,遵循典型的 Master-Slave 设计,但与传统分布式系统有所不同

Ray 中的关键组件总结

  • Ray在集群部署模式下启动了以下关键组件:
    • GlobalScheduler(全局调度器) :运行在Master节点上,负责接收本地调度器提交的任务,并将任务分发给合适的本地任务调度器执行
    • RedisServer :Master节点上启动的Redis服务器,用于保存分布式任务的状态信息(ControlState),包括对象机器的映射、任务描述、任务 debug 信息等
    • LocalScheduler(本地调度器) :每个 Slave 节点上启动的本地调度器,用于提交任务到全局调度器,以及分配任务给当前机器的 Worker 进程
    • Worker进程 :每个 Slave 节点上可以启动多个 Worker 进程执行分布式任务,并将计算结果存储到 ObjectStore
    • ObjectStore(对象存储) :每个 Slave 节点上的存储系统,用于存储只读数据对象,Worker 可以通过共享内存的方式访问这些对象数据,有效减少内存拷贝和对象序列化成本。ObjectStore 底层由 Apache Arrow 实现
    • Plasma :每个 Slave 节点上的ObjectStore管理器,当 Worker 访问本地 ObjectStore 上不存在的远程数据对象时,Plasma 会主动拉取其它 Slave 上的对象数据到当前机器

执行模型

  • Ray的执行模型基于动态任务图 ,这与 TensorFlow 中的静态计算图有本质区别:
    • TensorFlow的计算图用于表征神经网络,在单个应用中执行很多次
    • Ray的任务图用于表征整个应用,并仅执行一次
    • 任务图对于前台是未知的,随着应用的运行而动态地构建
    • 一个任务的执行可能创建更多的任务,形成动态依赖关系

代码示例

并行计算示例(无状态)

  • 基于 Ray 的并行计算代码 Demo:
    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
    import ray
    import time
    import numpy as np

    # 初始化 Ray,默认在本地启动
    ray.init()

    # 使用 @ray.remote 装饰器将函数转换为分布式任务
    @ray.remote
    def compute_square(x):
    # 模拟耗时计算
    time.sleep(1)
    return x * x

    # 生成一些数据
    data = np.arange(10)

    # 并行执行任务
    start_time = time.time()
    # 创建任务对象引用
    square_refs = [compute_square.remote(i) for i in data]
    # 等待所有任务完成并获取结果
    results = ray.get(square_refs)
    end_time = time.time()

    print(f"串行计算结果: {[i*i for i in data]}")
    print(f"Ray 并行计算结果: {results}")
    print(f"Ray 并行计算耗时: {end_time - start_time:.4f} 秒")

    # 关闭 Ray
    ray.shutdown()

    # 串行计算结果: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
    # Ray 并行计算结果: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
    # Ray 并行计算耗时: 1.4069 秒

串行计算示例(有状态)

  • 基于 Ray 的串行计算代码 Demo:
    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
    import ray
    import time

    # 初始化 Ray
    ray.init()

    # 使用 @ray.remote 装饰器定义 Actor 类
    @ray.remote
    class Counter:
    def __init__(self):
    self.count = 0

    def increment(self):
    time.sleep(1) # 模拟耗时操作
    self.count += 1
    return self.count

    def get_count(self):
    return self.count

    # 创建 Actor 实例
    counter = Counter.remote()

    # 并行调用 Actor 方法
    start_time = time.time()
    # 提交多个增量任务
    increment_refs = [counter.increment.remote() for _ in range(10)]
    # 获取所有增量任务的结果
    results = ray.get(increment_refs)
    # 获取最终计数
    final_count = ray.get(counter.get_count.remote())
    end_time = time.time()

    print(f"每次增量结果: {results}")
    print(f"最终计数: {final_count}")
    print(f"执行耗时: {end_time - start_time:.4f} 秒")

    # 关闭 Ray
    ray.shutdown()

    # 每次增量结果: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    # 最终计数: 10
    # 执行耗时: 10.1293 秒

分布式调度示例(集群模式)

  • 以上代码经过非常简单的修改即可进入集群模式

  • Ray 集群部署包括三个步骤(下面以 6379 端口为例展示流程)

  • 第一步:启动主节点 ,运行 ray start --head 从主节点启动集群

    1
    ray start --head --port=6379 --redis-password='your_secure_password_123'
    • 注:可通过 --redis-password 设置密码(可选),防止未授权节点加入,也可以不使用该参数
  • 第二步:启动工作节点 ,运行 ray start --address=<主节点IP> 加入集群

    1
    ray start --address='<head-node-ip>:6379' --redis-password='your_secure_password_123'
    • 执行上述命令后工作节点就会:
      • 自动连接到主节点
      • 等待接收任务
      • 执行主节点分配的计算任务
      • 将结果返回给主节点
  • 第三步:在主节点上运行的代码中连接集群

    1
    ray.init(address='auto', _redis_password='your_secure_password_123')
    • 注:以上代码仅在主节点上运行,工作节点不需要显示运行任何代码,仅需要启动并加入集群即可
  • 关闭 Ray 服务:

    1
    ray stop
  • 特别说明:集群模式与普通单机并行模式的区别很小,仅需要增加修改以上代码即可(其他代码都不需要修改)

  • Ray 在分布式下默认有许多默认功能:

    • 自动负载均衡:Ray 会自动将任务分配到空闲节点
    • 容错能力:如果某个工作节点失败,Ray 会重新调度任务
  • 集群模式工作流程总结:

    • 主节点通过 Redis 将任务(remote 函数或者类对象)放入队列
    • 空闲的工作节点从队列中获取任务
    • 工作节点执行任务
    • 将运算结果通过 共享内存/Object Store 返回给主节点

附录:工作节点启动高级配置

  • 可以通过参数调整工作节点行为:
    1
    2
    3
    4
    5
    ray start --address='<head-node-ip>:6379' \
    --redis-password='your_secure_password_123' \
    --num-cpus=8 \ # 限制使用8个CPU核心
    --num-gpus=1 \ # 声明有1个GPU可用
    --object-store-memory=100000000 \ # 设置对象存储大小

附录:Ray 集群状态监控

  • Ray 提供了 Web UI 用于监控集群状态
  • 在主节点启动时已经启用了 Dashboard(默认端口8265)
  • 在浏览器访问:http://<主节点IP>:8265
  • 在 Dashboard 中可以看到:
    • 集群节点列表和资源使用情况
    • 当前运行的任务
    • 历史任务统计
    • 每个节点的CPU/内存使用情况

DL——模型训练预热


整体说明

  • 预热(Warm-up)是一种训练技巧:
    • 在模型训练初期采用一些策略,逐步调整超参数(如学习率、 Batch Size 大小等)或模型状态 ,使得训练过程更加稳定、高效的初始化阶段
    • 通过合理预热,可以显著提升训练稳定性、收敛速度和最终性能
  • 预热的核心目的是避免训练初期因参数随机初始化或学习率过高导致的梯度不稳定、收敛困难等问题
  • 常见的预热技术主要包含两类:
    • 学习率预热(Learning Rate Warm-up) :训练初期从极小的学习率(如0)逐步线性或非线性增加到预设值
    • 优化器预热 :
      • Adam 预热阶段可用小学习率,比如正常值的 \(1/10\)(Adam 优化器的自适应动量在初期可能不准确);
      • Adam 在预热阶段启用偏差修正 ,避免初期估计偏差过大
  • 其他预热技术还包括:Batch Size 预热(Batch Size 从小到大),模型参数预热(逐步解冻模型层) 和 混合精度预热等(初期禁用混合精度)
  • 最常见的预热技术是学习率预热,其中 Transformer 常使用 学习率线性预热(比如 BERT 训练中常用 10,000 步线性预热)
  • 术语:warm-up ratio
    • 如 warm-up ratio 等于 0.03,表示 warm-up 阶段(学习率上升阶段)步数占总训练阶段步数的 3%

学习率预热的相关策略

  • 学习率预热(Learning Rate Warm-up)是训练初期逐步增加学习率的策略,旨在稳定训练并提升最终性能。以下是常见的具体方法及其细节:

线性预热(Linear Warm-up)

  • 在预热步数 \(N\) 内,学习率从 \(0\)(或极小值 \(\epsilon\))线性增长到初始学习率 \(lr_{\text{base} }\)
    $$
    lr_t = \epsilon + \left(\frac{t}{N}\right) \cdot (lr_{\text{base} } - \epsilon)
    $$
    • 其中 \(t\) 是当前步数,\(t \leq N\)
  • 最常用的方式之一

余弦预热(Cosine Warm-up)

  • 结合余弦函数曲线调整学习率,初期缓慢增长,后期平滑过渡到目标值
    $$
    lr_t = \frac{1}{2} \left(1 + \cos\left(\pi \cdot \left(1 - \frac{t}{N}\right)\right)\right) \cdot lr_{\text{base} }
    $$
  • 注:也可与余弦退火结合,预热后直接进入衰减阶段
  • 更平滑的过渡,减少初期学习率突变
  • 一些大模型中会使用到

指数预热(Exponential Warm-up)

  • 学习率从 \(\epsilon\) 开始指数增长到 \(lr_{\text{base} }\)
    $$
    lr_t = \epsilon \cdot \left(\frac{lr_{\text{base} } }{\epsilon}\right)^{\frac{t}{N} }
    $$
  • 较少使用,因可能过早进入高学习率阶段

阶梯预热(Step Warm-up)

  • 将预热阶段分为多个离散区间,逐步跳跃式增加学习率

附录:torch 自带预热和学习率调度代码示例

  • 一个完整的PyTorch示例:先进行学习率预热,再正常训练模型

  • 以简单的图像分类任务(CIFAR-10)为基础,结合线性预热和余弦退火调度器

  • 代码示例:

    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
    import torch
    import torch.nn as nn
    import torch.optim as optim
    from torch.optim.lr_scheduler import LambdaLR, CosineAnnealingLR
    from torchvision import datasets, transforms
    from torch.utils.data import DataLoader
    import matplotlib.pyplot as plt

    class SimpleCNN(nn.Module):
    def__init__(self):
    super().__init__()
    self.conv1 = nn.Conv2d(3, 16, 3, padding=1)
    self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
    self.fc = nn.Linear(32 * 8 * 8, 10) # CIFAR-10输入为32x32,经过两次池化后为8x8
    self.pool = nn.MaxPool2d(2, 2)
    self.relu = nn.ReLU()

    def forward(self, x):
    x = self.pool(self.relu(self.conv1(x)))
    x = self.pool(self.relu(self.conv2(x)))
    x = x.view(-1, 32 * 8 * 8)
    x = self.fc(x)
    return x

    transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
    ])
    train_set = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
    train_loader = DataLoader(train_set, batch_size=128, shuffle=True)

    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model = SimpleCNN().to(device)
    ## 注:学习率包含在优化器 optimizer 中,使用不同的学习率调度器来执行 step,就可以实现不同的学习率调度
    optimizer = optim.AdamW(model.parameters(), lr=0.001) # 初始学习率设为0.001(预热目标值)

    warmup_steps = 500 # 预热步数
    total_steps = 5000 # 总训练步数

    # 线性预热函数
    def warmup_lambda(current_step):
    if current_step < warmup_steps:
    return float(current_step) / float(max(1, warmup_steps))
    else:
    return 1.0 # 预热结束后保持学习率

    # 预热阶段调度器
    warmup_scheduler = LambdaLR(optimizer, lr_lambda=warmup_lambda) # 基于优化器初始化调度器

    # 预热后的余弦退火调度器(从预热结束开始)
    cosine_scheduler = CosineAnnealingLR(
    optimizer, # 与预热阶段调度器初始化相同的优化器
    T_max=total_steps - warmup_steps, # 余弦周期长度
    eta_min=1e-6 # 最小学习率
    )

    criterion = nn.CrossEntropyLoss()
    lr_history = []
    for step in range(total_steps):
    inputs = torch.randn(128, 3, 32, 32).to(device)
    labels = torch.randint(0, 10, (128,)).to(device)

    optimizer.zero_grad()
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    loss.backward()
    optimizer.step()

    # 更新学习率
    if step < warmup_steps:
    warmup_scheduler.step() # 预热阶段,step 函数会按照 warmup_scheduler 的定义来修改学习率
    else:
    cosine_scheduler.step() # 预热后余弦退火,step 函数会按照 cosine_scheduler 的定义来修改学习率

    # 记录学习率,可打印出来观测
    lr_history.append(optimizer.param_groups[0]['lr'])

    if step % 200 == 0:
    print(f"Step {step}: LR = {optimizer.param_groups[0]['lr']:.6f}, Loss = {loss.item():.4f}")
  • 预热阶段(前500步):学习率从 0 线性增长到初始值 0.001
    $$ lr = \text{base_lr} \times \frac{\text{current_step} }{\text{warmup_steps} } $$

  • 正常训练阶段(500步后):切换为余弦退火调度器(CosineAnnealingLR),学习率从 0.001 逐渐衰减到 1e-6

    • 注: 余弦退火的周期长度 \( T_{\text{max} } \) 设为总步数减去预热步数
  • 总体来说,学习率曲线是先线性上升,后余弦式下降(平滑振荡衰减)的过程


附录:transformers 库的模型训练预热调度示例

  • transformers 库中使用模型训练预热代码(按照初始学习率 1e-4, epochs= )

    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
    import matplotlib.pyplot as plt
    import transformers
    import torch

    initial_lr = 1.0e-4 # 初始学习率
    warmup_ratio = 0.1 # 预热比例

    num_training_steps = 1000 # 总训练 step 数
    num_warmup_steps = int(num_training_steps * warmup_ratio) # 计算 warmup 的 step 数

    optimizer = torch.optim.AdamW([torch.tensor(0.0)], lr=initial_lr) # [torch.tensor(0.0)] 是虚拟的模型参数,可随意设置

    # 使用 transformers 库创建余弦退火学习率调度器
    lr_scheduler = transformers.get_cosine_schedule_with_warmup(
    optimizer=optimizer,
    num_warmup_steps=num_warmup_steps, # warmup step 数
    num_training_steps=num_training_steps, # 训练总 step 数
    # num_cycles=0.5, # 对应 cosine 曲线的周期,默认值是0.5,也就是半周期(递减)
    # last_epoch=-1, # 用于从 checkpoint 启动时恢复训练,设置为 ckpt 对应 step-1 即可
    # 比如从第 500 步的 ckpt启动,设置为499,从第0步启动,设置为-1(默认值)
    )

    learning_rates = []
    for _ in range(num_training_steps):
    learning_rates.append(optimizer.param_groups[0]["lr"])
    lr_scheduler.step() # 更新 optimizer.param_groups[0]["lr"]

    # 设置中文字体
    plt.rcParams["font.family"] = ["SimHei", "WenQuanYi Micro Hei", "Heiti TC"]

    # 以下为可视化代码
    plt.figure(figsize=(10, 6))
    plt.plot(learning_rates)
    plt.title('学习率变化曲线')
    plt.xlabel('训练步骤')
    plt.ylabel('学习率')
    plt.grid(True)
    plt.axvline(x=num_warmup_steps, color='r', linestyle='--', label='预热结束')
    plt.legend()

    plt.annotate(f'初始学习率: {initial_lr}', xy=(num_warmup_steps, initial_lr),
    xytext=(num_warmup_steps + 50, initial_lr * 1.5),
    arrowprops=dict(facecolor='black', shrink=0.05))
    plt.annotate(f'预热起点: 0', xy=(0, 0),
    xytext=(50, initial_lr * 0.2),
    arrowprops=dict(facecolor='black', shrink=0.05))
    plt.annotate(f'最终学习率: {learning_rates[-1]:.8f}', xy=(num_training_steps-1, learning_rates[-1]),
    xytext=(num_training_steps-200, learning_rates[-1] * 10),
    arrowprops=dict(facecolor='black', shrink=0.05))
    plt.tight_layout()
    plt.savefig('warmup_learning_rate_curve_cycles_0.5.png', dpi=300)
    # plt.show()
  • 可视化结果(半周期余弦 num_cycles=0.5 的结果):

    • warmup 阶段,学习率从 0 开始逐步提升到最大值
    • 正式训练阶段,学习率按照余弦调度器波动
  • 如果设置为 num_cycles=1,则会在指定训练步数内完成两个周期的学习率变化:

  • 如果设置为 num_cycles=1.5,则会在指定训练步数内完成两个周期的学习率变化:

  • 如果设置为 num_cycles=2,则会在指定训练步数内完成两个周期的学习率变化:


附录:预热有什么用?

  • 解决梯度不稳定问题 :模型初始阶段参数随机初始化,直接使用高学习率可能导致梯度爆炸或震荡
  • 解决学习率敏感性问题 :过大的初始学习率可能使模型跳过最优解附近区域;过小则导致收敛缓慢
  • 保证优化器适应性 :如 Adam 等自适应优化器在初期需要积累梯度统计量(如动量、方差),预热阶段可为优化器提供更稳定的初始估计

附录:一般预热多少步更合适?

  • 预热步数通常取决于模型规模和数据集大小:
    • 小规模数据:数百到几千步
    • 大规模训练(如LLM):数万步甚至更长(例如 GPT-3 的数千批次预热)
  • 另一种设置方式是:通常为总训练步数的 5-10%(例如 BERT 的 10k 步预热,总步数 100k)

DL——深度学习并行技术总结


整体说明

  • 并行化技术一般在训练大型深度学习模型时使用
  • 并行化技术氛围三种:
    • 数据并行 (Data Parallelism)
    • 模型并行 (Model Parallelism)
    • 流水线并行 (Pipeline Parallelism),有的地方也翻译为管道并行

各种并行方法之间的关系总结

  • 整体可分为 模型并行 (Model Parallelism) 和 数据并行
    • 数据并行 :每个 GPU 都拥有一个完整的模型副本,但处理不同的数据批次
    • 模型并行 :每个 GPU 只负责模型的一部分,所有 GPU 共同处理一个完整的数据批次,包括 张量并行 和 流水线并行 两种具体实现
  • 模型并行的进一步介绍:当模型太大无法放入单个 GPU 时,就需要使用模型并行
    • 将模型的不同部分分配给不同的 GPU
    • 优点是可以训练显存无法容纳的巨大模型
    • 缺点是实现相对复杂,且由于不同 GPU 间的通信和等待,可能会导致 GPU 利用率不高
  • 实际应用中,为了充分利用资源并训练超大模型,通常会结合多种并行化技术,形成 混合并行 策略,例如同时使用数据并行、流水线并行和张量并行

数据并行 (Data Parallelism)

  • 最常见、也最容易理解的并行化方法
  • 数据并行的工作方式 :
    • 训练数据集被分成多个子集(例如,一个 128 张图片的批次被分成 4 个 32 张图片的子批次)
    • 每个 GPU 拥有一个完整的模型,并独立处理一个子批次的数据
  • 数据并行的训练过程 :
    • 1)每个 GPU 计算其子批次的前向传播和反向传播,得到各自的梯度
    • 2)通过 All-Reduce 这样的通信操作,将所有 GPU 的梯度进行汇总和平均
    • 3)每个 GPU 用这个平均后的梯度来更新自己的模型参数,从而确保所有模型副本保持同步
  • 优点是实现简单,对模型结构无特殊要求 ,比如使用 PyTorch 的 DP 类就可以实现
  • 缺点是每个 GPU 都需要存储完整的模型 ,当模型参数量非常大时,会超出单个 GPU 的显存限制 ,此时数据并行就无法使用

流水线并行 (Pipeline Parallelism)

  • 流水线并行将模型的不同“层”(或一组层)分配给不同的 GPU,形成一个“流水线”
    • 例如,GPU 1 负责模型的第 1-4 层,GPU 2 负责第 5-8 层,以此类推
  • 数据并行的训练过程 :
    • 一个数据批次被分解成更小的“微批次”(micro-batches)
    • GPU 1 处理第一个微批次,完成后将输出传给 GPU 2
    • 当 GPU 1 开始处理第二个微批次时,GPU 2 就可以同时处理第一个微批次
  • 优点是部分解决了模型过大的问题(单层过大仍然无法解决)
  • 缺点是存在 “流水线气泡”(pipeline bubble) 问题,即流水线开始和结束时,部分 GPU 会处于空闲等待状态,导致 GPU 利用率并非 100%
    • 注:通过流水线的方式(即错位并行处理不同微批次的方式),可以一定程度上重叠不同 GPU 的计算,提高整体效率

张量并行 (Tensor Parallelism)

  • 张量并行 不按层切分模型,而是将模型中某个操作内部的 张量(例如一个大型矩阵)切分到不同的 GPU 上
  • 举例来说:一个 \(A \times B\) 的矩阵乘法,可以把矩阵 B 按列切分,每个 GPU 分别计算,最后再通过通信操作将结果合并
  • 优点是:
    • 可以进一步解决单个层或单个操作的参数过大的问题,从根本上解决了模型过大的问题
    • 因为所有 GPU 都在同一时间处理同一个微批次,所以不会有流水线气泡问题 ,GPU 利用率通常更高
  • 缺点是
    • 对模型结构有要求,通常只能在某些特定操作(如矩阵乘法、线性层)中应用
    • 需要频繁的 GPU 间通信来同步切分后的张量,这要求非常高速的 GPU 互联(例如 NVLink)

NLP——LLM模型存储形式


整体说明

  • 当前主流的大模型存储格式可以按 “训练框架原生格式 -> 通用交换格式 -> 高效推理格式” 这条演进路线来理解
  • TLDR:“训练阶段用 .pth/.ckpt/.bin,跨框架交换用 ONNX,线上部署优先 Safetensors,如果对体积和 CPU 推理速度极端敏感就转 GGUF”

模型格式归纳

  • 训练框架原生格式
    • .pth / .pt:PyTorch 的 pickle 序列化结果,既可以是 state_dict,也可以是完整模型(含结构+权重)通用、易用,但体积大、加载慢,且存在反序列化安全风险
    • .ckpt:PyTorch Lightning 在 .pth 基础上扩展出的 Checkpoint 格式,额外保存优化器状态、epoch、超参等,用于断点续训
    • .bin:TensorFlow 早期常用的纯权重二进制文件,没有统一元数据,需配合 config.json 使用;在 Hugging Face 生态中仍大量出现
  • 通用交换格式
    • ONNX(.onnx):微软+Facebook 推出的开放标准,旨在跨框架(PyTorch/TF/ Paddle 等)部署;支持图优化、量化,但大模型时文件体积依旧可观
    • HDF5 / .h5:Keras/TensorFlow 传统格式,层次化存储网络结构和权重;对超大规模模型支持有限,已逐渐被 TF Checkpoint 或 SavedModel 取代
  • 高效推理格式
    • Safetensors(.safetensors):Hugging Face 推出的安全张量格式,只存权重、无代码、支持 zero-copy 与懒加载,加载速度 >pickle,且杜绝反序列化漏洞,已成为 HF Hub 的默认推荐
    • GGUF(GPT-Generated Unified Format):由 llama.cpp 作者 Georgi Gerganov 设计,用于取代旧版 GGML二进制紧凑、自带量化方案(Q2_K/Q4_0 等)、内存映射快速加载、元数据自包含,无需额外文件即可部署;Gemma、Qwen、Llama-3 等均官方提供 GGUF 版本
    • GGML(已弃用):早期 llama.cpp 使用的二进制格式,无版本控制、扩展困难,已全部迁移到 GGUF
  • 训练/数据级格式(辅助)
    • TFRecord / RecordIO:TensorFlow 训练数据管道常用,顺序、可压缩、高吞吐
    • Parquet / Arrow / LMDB:离线特征或中间结果列式存储,便于大规模并行读取

大模型常用框架相关的格式整体说明

  • 本文描述大模型的存储的形式和 转到 Hugging Face 的方式
  • Megatron / DeepSpeed / FSDP 都把 “一张完整的权重图” 切成很多片,文件名、目录结构、张量 key 名均与 HF 不一致;
  • 想进 HF 生态,必须 “合并分片 + 重命名 key + 生成 config.json”;
  • 合并脚本一般都已有各框架的官方提供,一般按照官方提供脚本转换即可

Hugging Face 原生格式

  • Hugging Face 上的开源模型通常以 “模型仓库(model repository)” 的形式托管,下载到本地后是一个目录,里面包含若干标准文件
  • 使用 Hugging Face 的接口加载模型时,该接口会大致进行以下流程:
    • 加载配置:读取 config.json 文件,用于构建模型的基本结构
    • 加载权重:读取模型权重文件(如 model.safetensors)中的参数值会被加载到定义好的模型结构中
    • 分词器初始化(处理输入):分词器文件(如 tokenizer.json, vocab.json)负责将原始文本转换为模型能够理解的 token ID 序列
    • 其他步骤:如果是文本生成任务,generation_config.json 会提供默认的生成参数
  • 推理最少三件套 :config.json + 权重文件 + 分词器文件
  • 微调再补 :tokenizer_config.json、special_tokens_map.json、generation_config.json 及优化器 checkpoint
  • 大模型 :使用 .safetensors 分片和 *.index.json 索引,断点续传更方便

必存在文件(推理/微调都少不了)

  • config.json
    • 主要包含模型超参与架构描述:隐藏层大小(hidden_size)、注意力头数(num_attention_heads)、层数(num_hidden_layers)、激活函数(hidden_act)等
    • 不同模型的内容不完全相同(是各家模型厂商自己自定义的),这个文件是会被当做超参数传递到模型的初始化文件中的
    • 还包含模型的 参数类型 (比如 "torch_dtype": "bfloat16") 作为加载时的统一转换类型
      • 注:同一个模型的不同参数可以存储为不同类型,这里的 "torch_dtype" 字段仅仅指定加载时的参数
  • 权重主文件(可以是 .bin, .h5, safetensors 等类型的文件,也可能是 gguf 等量化格式的文件)
    • pytorch_model.bin(PyTorch)
      • 分片(shard)文件 比如pytorch_model-00001-of-00008.bin 到 pytorch_model-00008-of-00008.bin,会伴随一个 pytorch_model.bin.index.json 索引文件来记录这些分片信息
    • tf_model.h5(TensorFlow)
    • flax_model.msgpack(Flax/JAX,不常见)
    • model.safetensors(新版统一二进制格式,零拷贝、更安全)
      • Hugging Face 推荐的安全格式 ,不包含可执行代码 ,避免了传统 PyTorch 格式因使用 pickle 序列化而可能存在的安全风险(如恶意代码执行)
      • 通常加载更快且更节省内存
      • 分片(shard)文件 比如model-00001-of-00008.safetensors 到 model-00008-of-00008.safetensors,会伴随一个 model.safetensors.index.json 索引文件来记录这些分片信息
    • gguf(量化后的 GGUF 格式)
      • CPU 或个人设备上进行本地推理 ,首选能提供更好的体验
      • 一般会按照不同的量化格式提供多个 gguf 文件,如 q4_k_m.gguf 等来说明量化形式
    • 注:加载过程中,根据不同的模型权重类型,Hugging Face 框架会使用不同的加载函数加载
  • tokenizer.json
    • 分词器核心配置:预处理器状态、编解码规则、特殊 token 映射
    • 词表内容一般也会在这格文件中,以 vocab 为 Key 存在,所以 tokenizer.json 一般会有几十 MB 大小
  • 补充:model.safetensors.index.json 索引文件示例,包含模型的每一层权重到权重分片的映射
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    "metadata": {
    "total_size": 144575840256
    },
    "weight_map": {
    "lm_head.weight": "model-00082-of-00082.safetensors",
    "transformer.h.0.attn.c_attn.bias": "model-00002-of-00082.safetensors",
    "transformer.h.0.attn.c_attn.weight": "model-00002-of-00082.safetensors",
    "transformer.h.0.attn.c_proj.weight": "model-00002-of-00082.safetensors",
    "..."
    }
    }

常见文件(大部分仓库可见)

  • 分词器相关文件:
    • tokenizer.json:分词器的完整定义,包括编码规则和词汇表映射(前面已经介绍过)
    • tokenizer_config.json:分词器的附加配置,如特殊标记(如[CLS]、[SEP]、[PAD])、填充方式、截断策略等
      • 部分模型会将聊天模版也放到这个文件中的 chat_template 字段(Qwen,Deepseek 等),部分模型则将聊天模板放到外面的 chat_template.jinja 文件(这样虽然不便于管理,但可读性会更高)
    • vocab.txt, vocab.json:模型的词汇表,存储 token 到 ID 的映射关
      • 注:目前许多模型已经不需要这个文件,因为该文件会以 "vocab" 字段的形式放到 tokenizer.json 中
    • merges.txt:适用于 BPE 等分词算法,定义了 token 的合并规则
      • 注:目前许多模型已经不需要这个文件,因为该文件会以 "merges" 字段的形式放到 tokenizer.json 中
    • special_tokens_map.json:统一声明 [PAD]、[CLS]、[SEP]、<|im_start|> 等特殊 token 的 ID 与字符串映射
      • 注:目前许多模型已经不需要这个文件,因为该文件会以 "additional_special_tokens" 字段的形式放到 tokenizer_config.json
    • added_tokens.json :用户或微调阶段追加的新 token
      • 注:目前许多模型已经不需要这个文件,因为该文件会以 "added_tokens" 字段的形式放到 tokenizer.json 中
  • generation_config.json : 文本生成默认策略:max_new_tokens、do_sample、temperature、top_p 等
  • .gitattributes : 用于配合 Git-LFS 把大文件托管到 LFS
    • gitattributes 是 Git 中用于定义特定文件(或文件类型)在 Git 操作中的处理规则的配置文件
    • 核心作用是 “为不同文件定制 Git 行为” ,告诉 Git:对于不同类型的文件,应该如何执行换行符转换、合并策略、文件属性标记、diff 对比方式等操作,从而在团队协作或跨平台开发中保持文件处理的一致性

附录:关于 generation_config.json 文件的使用

  • 在使用 Hugging Face 的 transformers 库加载模型时,会自动读取模型文件路径下的 generation_config.json 文件(如果存在的话)

  • generation_config.json 是用于存储模型生成相关配置的文件,包含了如最大生成长度(max_length)、采样温度(temperature)、top-k 采样等与文本生成任务相关的参数

  • 当使用 from_pretrained() 方法加载模型时,库会自动检查并加载该文件中的配置,这些配置会被存储在模型的 generation_config 属性中。例如:

    1
    2
    3
    4
    5
    6
    7
    from transformers import AutoModelForCausalLM, AutoTokenizer

    model = AutoModelForCausalLM.from_pretrained("model_path")
    tokenizer = AutoTokenizer.from_pretrained("model_path")

    # 查看加载的生成配置
    print(model.generation_config)
  • 如果模型路径中存在 generation_config.json,上述代码会自动加载其中的配置;如果该文件不存在,transformers 会使用默认的生成配置

  • 也可以通过 GenerationConfig 类手动加载或修改这些配置,并在生成文本时传入:

    1
    2
    3
    4
    5
    6
    7
    8
    from transformers import GenerationConfig

    # 手动加载生成配置
    gen_config = GenerationConfig.from_pretrained("model_path")
    # 修改配置
    gen_config.max_length = 100
    # 生成文本时使用
    outputs = model.generate(**inputs, generation_config=gen_config)

其他可选/场景文件

  • training_args.bin : 由 transformers.Trainer 自动保存,包含学习率、warmup step、batch_size 等训练超参
  • optimizer.bin / scheduler.bin : 断点续训时保存的优化器状态和 LR scheduler 状态
  • quantization/ 目录 : 低比特量化权重,如 F8_E4M3、INT4、GGML 等
  • README.md / LICENSE / *.md : 模型卡片、许可证、使用示例、局限性与伦理声明
  • preprocessor_config.json : 多模态模型(如 LLaVA、BLIP-2)中,图像预处理超参
  • adapter_config.json / adapter_model.bin : PEFT/LoRA 微调产生的轻量 adapter,仅含可训练增量参数
  • tokenizer.model 文件是 SentencePiece 分词器的核心文件,通常以二进制格式存储,包含分词规则、词汇表和预处理逻

关于 .bin 格式 和 .safetensors 格式的说明

  • .bin 是 Hugging Face 最早、最通用的格式(PyTorch 的格式),任何支持 from_pretrained() 的库都能直接加载

  • 如果同时包含一个同名的 .safetensors 和 .bin,HF 会优先用 .safetensors(更快、更安全)

  • 将 .bin 格式升级为 .safetensors 格式的接口如下:

    1
    2
    import safetensors
    safetensors.torch.save_file(state_dict, "model.safetensors")
  • 特别说明:.bin 和 .safetensors 中会包含权重文件的参数类型(fp16, fp32, bf16 等)

    • 而且,.bin 和 .safetensors 文件会为每个不同的参数张量存储各自的参数,所以理论上这些参数类型可以不用
    • 在加载模型时,会先按照权重文件中的真实类型读取,并转换成 config.json 中指定的文件格式(比如 "torch_dtype": "bfloat16" 指定 bf16 格式),存放到内存中

Megatron-LM 框架文件格式

  • 分片数量与分片参数(TP=N1、PP=N2、DP=N3)有关,下面是磁盘目录示例:

    1
    2
    3
    4
    5
    6
    7
    8
    iter_0001000/
    ├── model_optim_rng.pt # 传统同步格式(老版本)
    ├── __0_0.distcp # 新异步格式(v0.7+),每个文件只含本 rank 的分片
    ├── ...
    ├── __1_0.distcp
    ├── common.pt # 公共张量(embedding、lm_head 等)
    ├── metadata.json # 并行拓扑
    └── latest_checkpointed_iteration.txt
  • 注:部分 Megatron-LM 存储形式中, iter_0001000 下存储的是多个类似 mp_rank_xx_xx_cp_xx_dp_xx 目录的结构,每个结构存储部分模型参数

    • 这是 Megatron-LM 原生 checkpoint 的分布式存储结构

    • 命名规则:

      1
      mp_rank_{tensor_parallel_rank}_{checkpoint_partition}_{data_parallel_rank}
    • 含义:

      • mp_rank_xx_xx :Tensor Model Parallel rank(张量并行+流水线并行的分片编号)
      • cp_xx :Context parallel rank(上下文并行分片编号)
      • dp_xx :Data parallel rank(数据并行的副本编号)
    • 每个目录里可能包含:

      • distrib_optim.pt
        • 分布式优化器(比如 ZeRO)的状态分片,包含梯度累积缓冲、参数分片等,用于 resume 训练使用,若确定不再需要继续训练,则可以删除该文件
      • model_optim_rng.pt
        • 保存随机数生成器状态(Python random、NumPy RNG、PyTorch CPU/CUDA RNG、Megatron并行RNG),用于恢复训练时保证随机性一致
        • 注:部分架构中,模型权重也存储在这个文件里面
  • Megatron 格式与 HF 不兼容;需要合并+重命名,下面是官方给出的转换脚本(同步格式)

    1
    2
    3
    4
    5
    6
    python tools/checkpoint_converter.py \
    --model-type GPT \
    --load-dir iter_0001000 \
    --save-dir hf_format \
    --target-tensor-parallel-size 1 \
    --target-pipeline-parallel-size 1
  • HF 的一般格式现在是类似下面的形式

    1
    2
    3
    4
    5
    6
    7
    hf_format/
    ├── model_00001-of-00010.safetensors # 文件权重
    ├── model_xxx...
    ├── model.safetensors.index.json # 分片成多个文件时用于索引
    ├── tokenizer.json
    ├── tokenizer_config.json
    └── config.json # 由脚本自动生成

DeepSpeed 框架文件格式

  • ZeRO-3 会对参数进行分片,分片数量参数有关,磁盘目录(16 GPU)

    1
    2
    3
    4
    5
    6
    7
    global_step1000/
    ├── bf16_zero_pp_rank_00_mp_rank_00_optim_states.pt # 优化器状态
    ├── bf16_zero_pp_rank_01_mp_rank_00_optim_states.pt
    ├── ...
    ├── zero_pp_rank_00_mp_rank_00_model_states.pt # 权重分片
    ├── zero_pp_rank_01_mp_rank_00_model_states.pt
    └── ...
  • DeepSpeed 与 HF 不兼容;需要合并(DeepSpeed 自带工具)

    1
    python zero_to_fp32.py global_step1000 ds_model.pth
  • 进一步精简权重文件(仅保留权重)并转 HF 的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import torch
    from transformers import AutoConfig, AutoModelForCausalLM

    state_dict = torch.load('ds_model.pth', map_location='cpu')
    torch.save(state_dict, 'pytorch_model.bin') # 仅权重

    config = AutoConfig.from_pretrained('meta-llama/Llama-2-7b-hf')
    model = AutoModelForCausalLM.from_config(config)
    model.load_state_dict(state_dict)
    model.save_pretrained('hf_from_ds')

PyTorch FSDP 框架文件格式

  • 磁盘目录(8 GPU)

    1
    2
    3
    4
    5
    checkpoint-1000/
    ├── __0_0.distcp # 每个 rank 的分片
    ├── ...
    ├── __7_0.distcp # 每个 rank 的分片
    └── .metadata # FSDP 元数据
  • PyTorch FSDP 与 HF 不兼容;需要合并,官方合并脚本(PyTorch 大于 2.2)

    1
    2
    3
    python -m torch.distributed.checkpoint.format_utils dcp_to_torch_save \
    checkpoint-1000 \
    fsdp_model.pth
  • 再转成 HF Safetensors(更快、安全)

    1
    2
    3
    4
    5
    from safetensors.torch import save_file
    import torch

    state_dict = torch.load('fsdp_model.pth')
    save_file(state_dict, 'model.safetensors')

附录:在不加载模型的情况下查看 safetensors 文件参数类型

  • 使用 transformers 库加载模型后查看参数,参数可能会被自动转换(依据不同模型实现有所不同,部分模型参数加载后是 float32)
    • 注意:即使 config.json 中显示是 "torch_dtype": "bfloat16",在 from_pretrain 函数不显示指定参数类型的情况下,也会出现自动转换为 float32 的情况
  • 显示指定参数类型加载后,输出与指定类型一致,但是看不到原始的参数类型了
  • 下面介绍两种方法,可以直接查看某个 safetensors 文件的参数类型

方式一:命令行查看

  • 使用 hexdump 命令可以抽取部分文件查看其 dtype 信息

    1
    hexdump -C -n 4096 model_00001-of-00010.safetensors | grep -A 20 '"dtype"'
  • 这条命令的作用是查看 safetensors 模型文件的十六进制内容,并筛选出包含 “dtype” 的行及其后 20 行,以便分析模型数据类型相关信息。下面是详细解释:

    • hexdump:用于以十六进制和 ASCII 形式显示文件内容的工具
    • -C:以规范的十六进制+ASCII 格式显示,左侧为十六进制值,右侧为对应的可打印字符
    • -n 4096:仅显示文件的前 4096 个字节(4KB)
    • grep -A 20:在文本中搜索匹配模式,除了显示匹配的行外,还显示该行之后的 20 行内容(A 即 After 的缩写)
  • 执行命令后会看到类似的输出:

    1
    ..."dtype":"BF16"...

方式二:python 查看

  • 安装 safetensors 包

  • 执行下面的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    from safetensors.torch import load_file

    # 加载 .safetensors 文件
    weight_file = "~/model/Qwen2.5-7B-Instruct/model-00001-of-00004.safetensors"

    state_dict = load_file(weight_file, device="cpu")

    # 查看存储类型
    for name, param in list(state_dict.items())[:5]:
    print(f"参数 {name} 在硬盘上的存储类型: {param.dtype}")
  • 输出如下:

    1
    2
    3
    4
    5
    参数 model.embed_tokens.weight 在硬盘上的存储类型: torch.bfloat16
    参数 model.layers.0.input_layernorm.weight 在硬盘上的存储类型: torch.bfloat16
    参数 model.layers.0.mlp.down_proj.weight 在硬盘上的存储类型: torch.bfloat16
    参数 model.layers.0.mlp.gate_proj.weight 在硬盘上的存储类型: torch.bfloat16
    参数 model.layers.0.mlp.up_proj.weight 在硬盘上的存储类型: torch.bfloat16
  • bfloat16 与 Qwen2.5-7B-Instruct 的 config.json 类型能对齐


附录:ckpt 中添加自定义模型类

  • 在模型 ckpt 目录(hf 文件目录)下,可以存放 *.py 文件,用于定义自定义的模型结构
  • 这些 *.py 文件会被 transformers 库加载,故而可以在 config.json 中指定使用
  • 注意:megatron 训练一般不会使用 config.json 中指定的类,而是根据各种超参加载的
  • transformers 库 AutoModelForCausalLM.from_pretrained -> AutoConfig.from_pretrained 加载模型的方式有两种:
    • 第一种:config.json 包含 model_type 参数的
      • 此时要求模型类提前备注册过
    • 第二种:config.json 不包含 model_type 参数的
      • 此时可以按照自定义的类进行初始化(定义在 *.py 中,放到 ckpt 路径下即可)
      • 执行 AutoModelForCausalLM.from_pretrained 函数时添加 trust_remote_code=True 参数,否则无法加载模型文件
1…192021…63
Joe Zhou

Joe Zhou

Stay Hungry. Stay Foolish.

628 posts
53 tags
GitHub E-Mail
© 2026 Joe Zhou
Powered by Hexo
|
Theme — NexT.Gemini v5.1.4