概述

本系列为我在vla 上的同步实践笔记,之前的实践中我大多都是在调用指令、修改参数,没有去深入了解相关的底层原理,因此在本系列中我将尽可能地探究底层原理。本文将展示我通过代码小实验对flow matching的理解。


1. 实验设计

本次实验的输入为带有噪声信号的二维轨迹,仿照pi0.5的流程,通过流匹配求出每一轮的轨迹变化速度,10次积分后得到最终轨迹。


2. 代码

import json
from pathlib import Path

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import torch
from torch import nn

N_POINTS = 50
TRAIN_STEPS = 20000
BATCH_SIZE = 128
GENERATE_STEPS = 10

class FlowMLP(nn.Module):
    def __init__(self):
        super().__init__()
        # 输入:50×2个动作数值 + 时间t + 曲线幅度条件。即初始是50个2维的点坐标
        # 输出:50×2个速度数值。
        self.net = nn.Sequential(
            # 加的2为:时间t一维标量 + 曲线幅度1维标量
            nn.Linear(N_POINTS * 2 + 2, 256),
            nn.SiLU(),
            nn.Linear(256, 256),
            nn.SiLU(),
            nn.Linear(256, N_POINTS * 2)
        )

    def forward(self, x_t, t, condition):
        # xt的第0维(batch维)不变,后面的维度拉平,变成[x1, y1, x2, y2, ..., x50, y50]
        # 拼接时,也是batch维不变,后面的拼好,变成[x1, y1, x2, y2, x3, y3, ..., x50, y50, t, condition]
        inputs = torch.cat(
            [x_t.flatten(start_dim=1), t, condition], dim=1
        )
        return self.net(inputs).reshape_as(x_t)

# 下面这个函数用来生成标准答案曲线
def make_trajectory(condition):
    # 每个幅度条件对应一条完整曲线:(batch, 50, 2)。
    # 第一步,生成x坐标
    # linspace从 -1 到 1,均匀取 50 个点
    # expand是为了增加batch维,变成(B, 50)
    x = torch.linspace(
        -1, 1, N_POINTS, device=condition.device
    ).expand(condition.shape[0], -1)
    # 第二步,生成y坐标,即正弦函数,高度由condition决定
    y = condition * torch.sin(torch.pi * x)
    # 把生成的x和y一一配对,变成(B, 50, 2),即B批,50对,每对都是(x,y)
    return torch.stack([x, y], dim=-1)

def main():
    torch.manual_seed(1000)
    device = torch.device(
        "mps" if torch.backends.mps.is_available() else "cpu"
    )

    model = FlowMLP().to(device)
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

    print("设备:", device)
    print("参数量:", sum(p.numel() for p in model.parameters()))

    # 1.训练
    model.train()
    losses = []

    for step in range(1, TRAIN_STEPS + 1):
        # 幅度在0.6~1.4之间变化,作为网络的条件输入。
        condition = 0.6 + 0.8 * torch.rand(
            BATCH_SIZE, 1, device=device
        )
        # 生成标准答案。由于每一轮训练时的condition随机,故标准答案也随机,由此实现泛化性
        actions = make_trajectory(condition)
        # 噪声
        noise = torch.rand_like(actions)
        # 随机时间步
        t = 0.001 + 0.998 * torch.rand(
            BATCH_SIZE, 1, device=device
        )

        # 与π0.5采用相同的线性加噪和速度监督公式。
        # 每一step的训练中,time都不一样,因此该step输入的带噪时间步也都不一样,模型正是在多种带噪时间步的输入下一点一点学会的
        time = t[:, :, None]
        # 初始的带噪时间步
        x_t = (1 - time) * actions + time * noise
        # 参考答案
        target_velocity = noise - actions

        # 输入为这一轮训练的带噪时间步 + 这一轮对应的t参数 + 这一轮对应的condition参数
        predicted_velocity = model(x_t, t, condition)
        loss = nn.functional.mse_loss(
            predicted_velocity, target_velocity
        )

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        value = loss.item()
        losses.append(value)

        if step == 1:
            print("真实轨迹形状:", tuple(actions.shape))
            print("带噪轨迹形状:", tuple(x_t.shape))
            print("预测速度形状:", tuple(predicted_velocity.shape))

        if step == 1 or step % 500 == 0:
            print(f"step={step}, loss={value}")

    # 2. 推理
    model.eval()
    snapshots = {}

    with torch.inference_mode():
        # 生成随机的初始轨迹
        x_t = torch.randn(1, N_POINTS, 2, device=device)
        snapshots[0] = x_t[0].cpu().numpy().copy()

        # 标准参考答案
        condition = torch.tensor([[1.0]], device=device)
        reference = make_trajectory(condition)
        initial_mse = nn.functional.mse_loss(
            x_t, reference
        ).item()

        # 用于速度积分
        dt = -1.0 / GENERATE_STEPS

        for step in range(GENERATE_STEPS):
            t = torch.full(
                (1, 1), 1 + step * dt, device=device
            )
            velocity = model(x_t, t, condition)
            # 积分:当前带噪动作 + 当前这一步的速度 * 时间
            x_t = x_t + dt * velocity

            if step + 1 in (2, 5, 8, 10):
                snapshots[step + 1] = (
                    x_t[0].cpu().numpy().copy()
                )

        # 最后生成的loss
        generated_mse = nn.functional.mse_loss(
            x_t, reference
        ).item()
        # 参考轨迹
        reference_points = reference[0].cpu().numpy()

        assert torch.isfinite(x_t).all().item()

    # 3. 画图
    # 3. 一张图:训练loss + 噪声到曲线的生成过程。
    fig, axes = plt.subplots(2, 3, figsize=(15, 9))
    axes = axes.flatten()

    axes[0].plot(losses, linewidth=1)
    axes[0].set_yscale("log")
    axes[0].set_title("Training velocity MSE")
    axes[0].set_xlabel("Training step")
    axes[0].set_ylabel("Loss")
    axes[0].grid(alpha=0.25)

    limit = max(
        1.5, max(abs(points).max() for points in snapshots.values())
    ) * 1.05

    for ax, (step, points) in zip(axes[1:], snapshots.items()):
        ax.plot(
            reference_points[:, 0],
            reference_points[:, 1],
            "--",
            color="gray",
            label="Target",
        )
        ax.plot(
            points[:, 0], points[:, 1],
            "o-", markersize=3, linewidth=1, label="Generated",
        )
        ax.set_title(
            f"After {step} updates, t={1-step/GENERATE_STEPS:.1f}"
        )
        ax.set_xlim(-limit, limit)
        ax.set_ylim(-limit, limit)
        ax.set_aspect("equal")
        ax.grid(alpha=0.25)
        ax.legend(fontsize=8)

    fig.suptitle(
        "Flow Matching: 50 two-dimensional points form one trajectory"
    )
    fig.tight_layout()

    output_dir = (
        Path(__file__).resolve().parents[1]
        / "outputs/week6/c1_flow_matching"
    )
    output_dir.mkdir(parents=True, exist_ok=True)

    figure_path = output_dir / "c1_flow_matching.png"
    fig.savefig(figure_path, dpi=180)
    plt.close(fig)

    metrics = {
        "device": str(device),
        "train_steps": TRAIN_STEPS,
        "generation_steps": GENERATE_STEPS,
        "training_losses": losses,
        "initial_noise_mse": initial_mse,
        "generated_trajectory_mse": generated_mse,
    }
    (output_dir / "metrics.json").write_text(
        json.dumps(metrics, indent=2)
    )

    print("初始噪声与目标轨迹的MSE:", initial_mse)
    print("生成轨迹与目标轨迹的MSE:", generated_mse)
    print("生成过程图:", figure_path)

    assert generated_mse < initial_mse, "生成未改善,请保留输出排查"
    print("FLOW_MATCHING_DEMO_CHECK_PASS")

if __name__ == "__main__":
    main()

3. 结果

参数量: 117860
真实轨迹形状: (128, 50, 2)
带噪轨迹形状: (128, 50, 2)
预测速度形状: (128, 50, 2)
step=1, loss=0.7628627419471741
step=500, loss=0.056497376412153244
step=1000, loss=0.03080480545759201
step=1500, loss=0.017268814146518707
step=2000, loss=0.017937956377863884
step=2500, loss=0.013999688439071178
step=3000, loss=0.014280745759606361
step=3500, loss=0.0105742784217
step=4000, loss=0.01215374656021595
初始噪声与目标轨迹的MSE: 1.0767486095428467
生成轨迹与目标轨迹的MSE: 0.10508836805820465
生成过程图: /Users/dukiya/Desktop/VLA-learning/pi0.5/outputs/week6/c1_flow_matching/c1_flow_matching.png
FLOW_MATCHING_DEMO_CHECK_PASS
(vla-dl) dukiya@MacBookPro pi0.5 % clear
(vla-dl) dukiya@MacBookPro pi0.5 % python -u "/Users/dukiya/Desktop/VLA-learning/pi0.5/scripts/C1_1_flow_matching.py"
设备: mps
参数量: 117860
真实轨迹形状: (128, 50, 2)
带噪轨迹形状: (128, 50, 2)
预测速度形状: (128, 50, 2)
step=1, loss=0.7628627419471741
step=500, loss=0.056497376412153244
step=1000, loss=0.03080480545759201
step=1500, loss=0.017268814146518707
step=2000, loss=0.017937956377863884
step=2500, loss=0.013999688439071178
step=3000, loss=0.014280745759606361
step=3500, loss=0.0105742784217
step=4000, loss=0.01215374656021595
step=4500, loss=0.010596787557005882
step=5000, loss=0.011056812480092049
step=5500, loss=0.011975659988820553
step=6000, loss=0.012458705343306065
step=6500, loss=0.00996104534715414
step=7000, loss=0.010937346145510674
step=7500, loss=0.007030962500721216
step=8000, loss=0.011274922639131546
step=8500, loss=0.009572314098477364
step=9000, loss=0.010419373400509357
step=9500, loss=0.00797924492508173
step=10000, loss=0.01026662066578865
step=10500, loss=0.009255047887563705
step=11000, loss=0.007038257550448179
step=11500, loss=0.010559530928730965
step=12000, loss=0.00735721318051219
step=12500, loss=0.00927362497895956
step=13000, loss=0.009580424055457115
step=13500, loss=0.0087526710703969
step=14000, loss=0.01084986887872219
step=14500, loss=0.01018508430570364
step=15000, loss=0.00851376261562109
step=15500, loss=0.009675636887550354
step=16000, loss=0.009681525640189648
step=16500, loss=0.00786056462675333
step=17000, loss=0.010169069282710552
step=17500, loss=0.009501800872385502
step=18000, loss=0.007643285673111677
step=18500, loss=0.00810040719807148
step=19000, loss=0.007944180630147457
step=19500, loss=0.007525602355599403
step=20000, loss=0.0067156539298594
初始噪声与目标轨迹的MSE: 1.3391687870025635
生成轨迹与目标轨迹的MSE: 0.0014069663593545556
生成过程图: /Users/dukiya/Desktop/VLA-learning/pi0.5/outputs/week6/c1_flow_matching/c1_flow_matching.png
FLOW_MATCHING_DEMO_CHECK_PASS
(vla-dl) dukiya@MacBookPro pi0.5 % 

可以看到,经过10轮的积分迭代,初始的噪声被还原得逐渐接近标准轨迹。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐