File size: 13,476 Bytes
880dff9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
"""训练数据集:预计算 latent + sidecar 相机偏移 + 文本表查表。

一个样本就是一个 clip(81 帧 ⇒ 21 个 latent cell):

    input_latents  bf16 [48, 21, 30, 52]   VAE latent(data/latent/pool/<clip_id>.pt 里的 "latent")
    context        bf16 [21, 512, 4096]    逐 cell 的 UMT5 文本嵌入(查表,不挂 T5)
    context_ids    int64 [21]              文本串的全局 id(相同串 ⇒ 相同 id),供 arope_forward 去重
    offset_px      float32 [21, 2]         sidecar 的逐 cell 相机偏移 (dx, dy),cell 0 = 0
    offset_tok     float32 [21, 2]         = offset_px / 32(token 单位;baseline 臂的主信号,与 ARoPE 同源)
    delta_tok      float32 [21, 2]         逐 cell 增量 offset_tok[k] − offset_tok[k−1],cell 0 = 0(速度)
    action_idx     int64 [21]              9 类离散动作标签(prompts.ACTIONS 顺序),已按 MIRROR_ACTION 翻回真实屏幕方向
    clip_id        str

后四项就是 baseline/SPEC.md 的 `action_inputs`(collate 后带 batch 维),train.py 原样打包交给动作臂;
arope / plain 臂只用 offset_px。action_idx 来自 .pt 的 `actions`(数据集按镜像的 M 派生的标签,
"moving right" 的 clip 玩家实际向左走),所以要经 MIRROR_ACTION 翻回真实方向,与 offset_px 的符号一致
(sidecar 已用负 gain 修正);推理侧 infer.py 用 velocity_to_label(mirror=False) 得到同一约定的标签。

为什么逐 cell 给 21 份 context 而不是一份
----------------------------------------
转场语料里 trigger 从句按 cell 变化(前几个 cell 是 A 场景,烧起来之后是
"A burning away into B",烧完是 B),模型要学的是"文本一变就烧",所以每帧 token 只能
cross-attend 自己 cell 的文本。普通 clip 的 21 条剥完动作从句后通常完全相同,
context_ids 让 arope_forward 只对唯一串算一次 text_embedding。

text_mode
---------
* "scene"(ARoPE 臂):prompt 先过 prompts.strip_action 剥掉动作从句,
  再查 text_table_actionrope.pt(361 键)。场景 dropout ⇒ 21 个 cell 全换成 NULL_PROMPT。
* "scene_action"(plain 对照臂):原 prompt 直接查 text_table_eybx.pt(1,253 键),
  动作从句留在文本里。场景 dropout ⇒ 换成 .pt 自带的 prompts_bossdrop(只剩动作从句,
  这是数据集为该臂准备的 dropout 串;NULL_PROMPT 不在 eybx 表里)。

valid 剔除
----------
sidecar 里 valid=False(日志缺口 / 采样异常)或 sidecar 根本没有该 clip 的,
在 __init__ 时剔除并打印数量。invalid clip 的 offset_px 是实际算出来的值(不是 0),
所以不能靠"偏移为零"兜底,必须在这里剔。

转场 clip 的来源泄漏
--------------------
转场 clip(burn / hardcut)是拿两个普通 clip 拼的:帧 < n_pre 来自 src_a,帧 ≥ n_pre 逐帧
就是 src_b。划分只按 heldout 标志处理转场,没有按 src 归属划,结果 train_eybx 里有 257 条
转场的 src_a / src_b 在 val_eybx(其中 123 条 src_b ⇒ val clip 的帧 n_pre..80 原样在训练集里)。
exclude_src_in=<val 目录> 时按 data/meta/transition.jsonl 的 src_a / src_b 把这些剔掉。

rt_ratio 闸(可选)
------------------
min_rt_ratio > 0 时按 meta 的 rt_ratio(世界实时率)剔除普通 clip。SPEC 0.15 s 的 valid 规则
碰巧兼做了 rt 过滤(invalid 集合里 31% 是 rt<0.8 的降速 clip,valid 集合里 rt<0.8 为 0),
放宽 --max_sample_dt 时必须补上这道闸,否则「世界本来就慢」的 clip 会进来。转场 clip 的
meta 没有 rt_ratio,不受此闸影响。
"""
from __future__ import annotations

import functools
import glob
import json
import os
import random

import torch
from torch.utils.data import Dataset

from actionrope.prompts import MIRROR_ACTION, NULL_PROMPT, mirror_action, strip_action
from baseline import make_action_inputs

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_SIDECAR = os.path.join(ROOT, "data/latent/arope_sidecar.pt")
DEFAULT_META_DIR = os.path.join(ROOT, "data/meta")
TRANSITION_KINDS = ("burn", "hardcut")
TEXT_TABLES = {
    "scene": os.path.join(ROOT, "data/latent/text_table_actionrope.pt"),
    "scene_action": os.path.join(ROOT, "data/latent/text_table_eybx_mirror.pt"),
    # 旧约定:不翻动作词,直接用数据集的镜像标签(mirror_labels=False 时用)
    "scene_action_raw": os.path.join(ROOT, "data/latent/text_table_eybx.pt"),
}

# SPEC 的形状契约,__getitem__ 里逐项核对:latent 编码脚本若哪天换了分辨率,这里第一时间报错
LATENT_SHAPE = (48, 21, 30, 52)
CELLS = 21
CONTEXT_SHAPE = (512, 4096)


def load_sidecar(sidecar) -> dict:
    """sidecar 可以是路径或已加载的 dict(多个 Dataset 共用一份时不必重复读 32 MB)。"""
    if isinstance(sidecar, dict):
        return sidecar
    return torch.load(sidecar, map_location="cpu", weights_only=False)


def load_text_table(text_table) -> dict[str, torch.Tensor]:
    if isinstance(text_table, dict):
        return text_table
    return torch.load(text_table, map_location="cpu", weights_only=False)


@functools.lru_cache(maxsize=4)
def load_meta_index(meta_dir: str) -> dict[str, dict]:
    """data/meta/*.jsonl 里训练侧用得着的几个字段:src_a / src_b(转场)、rt_ratio(普通)。
    65 MB 的 jsonl 解析约 2 s,train / val 两个 Dataset 共用一份,所以带 lru_cache。"""
    out = {}
    for f in sorted(glob.glob(os.path.join(meta_dir, "*.jsonl"))):
        with open(f, encoding="utf-8") as fh:
            for line in fh:
                r = json.loads(line)
                out[r["clip_id"]] = {k: r[k] for k in ("src_a", "src_b", "rt_ratio", "kind") if k in r}
    return out


def clip_ids_in(split_dir: str) -> set[str]:
    return {os.path.splitext(f)[0] for f in os.listdir(split_dir) if f.endswith(".pt")}


class AropeLatentDataset(Dataset):
    def __init__(
        self,
        split_dir: str,
        sidecar=DEFAULT_SIDECAR,
        text_table=None,
        text_mode: str = "scene",
        scene_dropout: float = 0.1,
        limit: int | None = None,
        seed: int = 0,
        verbose: bool = True,
        exclude_src_in: str | None = None,
        min_rt_ratio: float = 0.0,
        meta_dir: str = DEFAULT_META_DIR,
        mirror_labels: bool = True,
    ):
        if text_mode not in ("scene", "scene_action"):
            raise ValueError(f"text_mode 只能是 scene / scene_action,得到 {text_mode!r}")
        # 数据集的动作词是镜像的(prompts.mirror_action 的注释):plain 臂默认翻回真实屏幕方向,
        # 用补齐了镜像串的 text_table_eybx_mirror.pt;mirror_labels=False 走旧约定(原表、原串)
        self.mirror_labels = bool(mirror_labels) and text_mode == "scene_action"
        self.split_dir = split_dir
        self.text_mode = text_mode
        self.scene_dropout = float(scene_dropout)
        self.min_rt_ratio = float(min_rt_ratio)
        # 受保护的 clip:转场 clip 的 src_a / src_b 落在这里 ⇒ 剔除(val 的帧不能原样进训练集)
        protected = clip_ids_in(exclude_src_in) if exclude_src_in else set()
        meta = load_meta_index(meta_dir) if (protected or self.min_rt_ratio > 0) else {}

        sc = load_sidecar(sidecar)
        self.sidecar_meta = sc["meta"]
        clips = sc["clips"]
        table_key = "scene_action_raw" if (text_mode == "scene_action" and not self.mirror_labels) else text_mode
        self.text_table = load_text_table(TEXT_TABLES[table_key] if text_table is None else text_table)
        # 文本串的全局 id:按键排序后的下标,跨样本、跨 batch 都稳定,相同串 ⇒ 相同 id
        self.key_to_id = {k: i for i, k in enumerate(sorted(self.text_table))}

        files = sorted(glob.glob(os.path.join(split_dir, "*.pt")))
        if not files:
            raise FileNotFoundError(f"{split_dir} 下没有 .pt")
        n_missing = n_invalid = n_leak = n_low_rt = 0
        self.items: list[tuple[str, str]] = []  # (clip_id, path)
        for p in files:
            cid = os.path.splitext(os.path.basename(p))[0]
            rec = clips.get(cid)
            if rec is None:
                n_missing += 1
                continue
            if not bool(rec["valid"]):
                n_invalid += 1
                continue
            m = meta.get(cid, {})
            if rec["kind"] in TRANSITION_KINDS:
                if protected and (m.get("src_a") in protected or m.get("src_b") in protected):
                    n_leak += 1
                    continue
            elif self.min_rt_ratio > 0 and float(m.get("rt_ratio", 1.0)) < self.min_rt_ratio:
                n_low_rt += 1
                continue
            self.items.append((cid, p))
        self.n_total, self.n_missing, self.n_invalid = len(files), n_missing, n_invalid
        self.n_leak, self.n_low_rt = n_leak, n_low_rt

        if limit is not None and limit < len(self.items):
            # smoke 用:按 seed 打乱后取前 limit 个,避免只取到排序靠前的同一 session
            rng = random.Random(seed)
            rng.shuffle(self.items)
            self.items = sorted(self.items[:limit])
        # 只保留用得着的 sidecar 条目,DataLoader worker fork 时少复制一点
        self.offsets = {cid: clips[cid]["offset_px"].to(torch.float32).clone() for cid, _ in self.items}
        # offset_tok / delta_tok 只依赖 offset_px,在这里一次算好(与 infer.py 共用 baseline.make_action_inputs 的定义)
        self.action_inputs = {cid: make_action_inputs(off) for cid, off in self.offsets.items()}
        self.kinds = {cid: clips[cid]["kind"] for cid, _ in self.items}

        if verbose:
            print(
                f"[AropeLatentDataset] {split_dir}: {self.n_total} 个 .pt,"
                f"sidecar 缺失 {n_missing},valid=False 剔除 {n_invalid},"
                + (f"src 在 {os.path.basename(exclude_src_in.rstrip('/'))} 的转场剔除 {n_leak}," if exclude_src_in else "")
                + (f"rt_ratio<{self.min_rt_ratio} 剔除 {n_low_rt}," if self.min_rt_ratio > 0 else "")
                + f"可用 {len(self.items)}"
                + (f"(limit={limit})" if limit is not None else "")
                + f";text_mode={text_mode},文本表 {len(self.text_table)} 键,scene_dropout={self.scene_dropout}"
                + (f",动作词镜像翻回真实方向={self.mirror_labels}" if text_mode == "scene_action" else "")
            )

    def __len__(self):
        return len(self.items)

    def clip_ids(self) -> list[str]:
        return [cid for cid, _ in self.items]

    # ------------------------------------------------------------------
    def _prompt_keys(self, d: dict) -> list[str]:
        """21 个 cell 的文本表键。场景 dropout 在这里决定(每个样本独立抽签)。"""
        drop = self.scene_dropout > 0 and random.random() < self.scene_dropout
        if self.text_mode == "scene":
            if drop:
                return [NULL_PROMPT] * CELLS
            return [strip_action(p) for p in d["prompts"]]
        prompts = d["prompts_bossdrop"] if drop else d["prompts"]
        if self.mirror_labels:
            return [mirror_action(p) for p in prompts]
        return list(prompts)

    def __getitem__(self, idx: int) -> dict:
        cid, path = self.items[idx]
        d = torch.load(path, map_location="cpu", weights_only=False)
        latent = d["latent"]
        if tuple(latent.shape) != LATENT_SHAPE:
            raise ValueError(f"{cid}: latent 形状 {tuple(latent.shape)} ≠ {LATENT_SHAPE}")
        keys = self._prompt_keys(d)
        if len(keys) != CELLS:
            raise ValueError(f"{cid}: prompts 有 {len(keys)} 条,应为 {CELLS}")
        try:
            context = torch.stack([self.text_table[k] for k in keys], 0)
        except KeyError as e:
            raise KeyError(f"{cid}: 文本表(text_mode={self.text_mode})里没有 {e.args[0]!r}") from None
        context_ids = torch.tensor([self.key_to_id[k] for k in keys], dtype=torch.int64)
        actions = d.get("actions")
        if actions is None or len(actions) != CELLS:
            raise ValueError(f"{cid}: .pt 里没有 {CELLS} 条 actions 标签(得到 {None if actions is None else len(actions)})")
        ai = self.action_inputs[cid]
        return {
            "input_latents": latent.to(torch.bfloat16).contiguous(),
            "context": context.to(torch.bfloat16),
            "context_ids": context_ids,
            "offset_px": ai["offset_px"],
            "offset_tok": ai["offset_tok"],
            "delta_tok": ai["delta_tok"],
            # 数据集标签是镜像的,翻回真实屏幕方向(与 offset_px 的符号一致)
            "action_idx": torch.tensor([MIRROR_ACTION[int(a)] for a in actions], dtype=torch.int64),
            "clip_id": cid,
            "kind": self.kinds[cid],
        }


def arope_collate(batch: list[dict]) -> dict:
    """张量按第 0 维堆叠,字符串收成 list。batch_size=1 时也统一成带 batch 维的形状。"""
    out = {}
    for k in batch[0]:
        v = batch[0][k]
        if isinstance(v, torch.Tensor):
            out[k] = torch.stack([b[k] for b in batch], 0)
        else:
            out[k] = [b[k] for b in batch]
    return out