Add inference code and MineExplorer patches
Browse files- README.md +65 -0
- code/bench/diag_perception.py +176 -0
- code/bench/diag_perception_direction.py +196 -0
- code/bench/diag_perception_presence.py +235 -0
- code/bench/splits_find25.json +30 -0
- code/train/bt_train.py +274 -0
- code/train/ctrl_policy.py +389 -0
- code/train/grpo_locations.json +1 -0
- code/train/hid_policy.py +101 -0
- code/train/hid_server.py +151 -0
- patches/MineExplorer.patch +437 -0
- patches/sandbox_mc_server.patch +52 -0
README.md
CHANGED
|
@@ -102,3 +102,68 @@ Perception prompts (thinking off, temperature 0, 320x180 frame):
|
|
| 102 |
## Prompt (B)
|
| 103 |
|
| 104 |
`Task: find {target}. Reply with the behavior code for the next second.` The curiosity variant appends ` Seen directions (0, 45, ..., 315 deg right of view): {8 bits}. Visited cells 4 blocks along them: {8 bits}.`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
## Prompt (B)
|
| 103 |
|
| 104 |
`Task: find {target}. Reply with the behavior code for the next second.` The curiosity variant appends ` Seen directions (0, 45, ..., 315 deg right of view): {8 bits}. Visited cells 4 blocks along them: {8 bits}.`
|
| 105 |
+
|
| 106 |
+
## 推理代码 / Inference code
|
| 107 |
+
|
| 108 |
+
`code/` 是评测时实际用的脚本,原样上传,保留原来的相对目录(脚本之间按相对路径 import)。`patches/` 是对 MineExplorer 的两个补丁。
|
| 109 |
+
|
| 110 |
+
- `code/train/ctrl_policy.py`:A 和 B 的策略。`Policy` 是 A(好奇心版按 `w0` 的行数自动加记忆输入;`box_joint_lora` 也用它),`BTPolicy` 是 B,`make_policy` 按 `kind` 选择。
|
| 111 |
+
- `code/train/hid_policy.py`、`code/train/hid_server.py`:`hidden_frozen`、`hidden_joint_lora` 的策略和隐状态特征服务(HTTP,`--adapter` 加载 LoRA)。`hid_server.py` 的模型加载函数来自 `code/train/bt_train.py`。
|
| 112 |
+
- `code/bench/diag_perception*.py`:感知问题原文、框的解析和视场常量,`ctrl_policy.py` 从这里 import;`code/train/grpo_locations.json` 在 import 时读取。
|
| 113 |
+
- `code/bench/splits_find25.json`:find25 的 25 个任务编号。
|
| 114 |
+
- `patches/MineExplorer.patch`:MineExplorer(commit `cca4c5e`)的 `eval_benchmark.py`,加了 `MC_POLICY` 入口(用上面的策略代替 LLM agent)、`--tasks`、多个 sandbox 轮流分配(`MC_SANDBOX_URLS`);`env/minerl_sandbox.py` 的 reset 超时可配(`MC_RESET_TIMEOUT`)。
|
| 115 |
+
- `patches/sandbox_mc_server.patch`:sandbox 镜像里的 `minestudio/mc_server.py`,step 返回 info(策略要读 `player_pos`),每次 reset 重新设 world seed。
|
| 116 |
+
|
| 117 |
+
`code/` holds the scripts the evaluations ran, uploaded unchanged in their original relative layout (they import each other by relative path); `patches/` holds two patches to MineExplorer.
|
| 118 |
+
|
| 119 |
+
- `code/train/ctrl_policy.py`: the policies of A and B. `Policy` is A (the curiosity variant turns on the memory inputs from the row count of `w0`; `box_joint_lora` uses it too), `BTPolicy` is B, and `make_policy` picks one by `kind`.
|
| 120 |
+
- `code/train/hid_policy.py`, `code/train/hid_server.py`: the policy of `hidden_frozen` / `hidden_joint_lora` and the HTTP hidden-state server (`--adapter` loads a LoRA). `hid_server.py` takes its model loader from `code/train/bt_train.py`.
|
| 121 |
+
- `code/bench/diag_perception*.py`: the perception prompts, the box parser and the field-of-view constants, imported by `ctrl_policy.py`; `code/train/grpo_locations.json` is read at import time.
|
| 122 |
+
- `code/bench/splits_find25.json`: the 25 task ids of find25.
|
| 123 |
+
- `patches/MineExplorer.patch`: MineExplorer (commit `cca4c5e`) `eval_benchmark.py` gains the `MC_POLICY` hook (runs the policies above instead of the LLM agent), `--tasks` and round-robin over several sandboxes (`MC_SANDBOX_URLS`); `env/minerl_sandbox.py` gets a configurable reset timeout (`MC_RESET_TIMEOUT`).
|
| 124 |
+
- `patches/sandbox_mc_server.patch`: `minestudio/mc_server.py` in the sandbox image returns the step info (the policies read `player_pos`) and re-applies the world seed on every reset.
|
| 125 |
+
|
| 126 |
+
Setup:
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
cd code
|
| 130 |
+
git clone https://github.com/meituan-longcat/MineExplorer.git
|
| 131 |
+
git -C MineExplorer checkout cca4c5e && git -C MineExplorer apply ../../patches/MineExplorer.patch
|
| 132 |
+
mkdir -p models && ln -s ../../Qwen3.5-9B models/Qwen3.5-9B
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
Start the sandbox from MineExplorer's Docker image (`davidzhth/mineexplorer:0.0.1`, see its README) with `patches/sandbox_mc_server.patch` applied to `/home/sandbox/mc/lumine-client/minestudio/mc_server.py`. Versions used: vLLM 0.27.1; torch 2.13, transformers 5.15, peft 0.21 for `hid_server.py`; Python 3.11 with MineExplorer's requirements plus numpy, openai, requests for the eval.
|
| 136 |
+
|
| 137 |
+
Perception server:
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
# A: base Qwen3.5-9B
|
| 141 |
+
vllm serve models/Qwen3.5-9B --served-model-name perception --dtype bfloat16 --max-model-len 16384 \
|
| 142 |
+
--limit-mm-per-prompt '{"image": 24}' --port 9060
|
| 143 |
+
# B or box_joint_lora: the LoRA under the name the policy asks for (box: ../ablations/box_joint_lora/grpo_r25)
|
| 144 |
+
vllm serve models/Qwen3.5-9B --served-model-name perception-base --dtype bfloat16 --max-model-len 16384 \
|
| 145 |
+
--limit-mm-per-prompt '{"image": 24}' --port 9060 --enable-lora --max-lora-rank 16 \
|
| 146 |
+
--lora-modules perception=../B_behavior_token/grpo_r25
|
| 147 |
+
# hidden_frozen (no adapter) and hidden_joint_lora
|
| 148 |
+
CUDA_VISIBLE_DEVICES=0 python train/hid_server.py --port 9210 --adapter ../ablations/hidden_joint_lora/grpo_r25
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
Eval (find25, 1000 steps; our tables pool seeds 0, 10000, 20000; keep `--num-workers` at 2 or more, since 1 takes a serial path that ignores `MC_SANDBOX_URLS`):
|
| 152 |
+
|
| 153 |
+
```bash
|
| 154 |
+
cd MineExplorer
|
| 155 |
+
MC_SANDBOX_URLS=http://localhost:8031,http://localhost:8032 MC_RESET_TIMEOUT=1200 \
|
| 156 |
+
AGENT_API_KEY=unused AGENT_API_BASE=unused MC_POLICY='<json below>' \
|
| 157 |
+
python eval_benchmark.py --model ctrl --benchmark-dir benchmark --output-dir out_s0 \
|
| 158 |
+
--tasks $(python -c "import json; print(','.join(json.load(open('../bench/splits_find25.json'))['eval']))") \
|
| 159 |
+
--num-workers 13 --max-steps 1000
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
| Model | Server | `MC_POLICY` |
|
| 163 |
+
|---|---|---|
|
| 164 |
+
| A (any `A_cerebellum/*/ctrl.npz`) | vLLM, base | `{"npz": "/abs/ctrl.npz", "perception": "vlm", "url": "http://localhost:9060/v1", "model": "perception", "seed": 0}` |
|
| 165 |
+
| `box_joint_lora` | vLLM + its LoRA | as A, with its `ctrl.npz` |
|
| 166 |
+
| B | vLLM + B's LoRA | `{"kind": "bt", "table": "/abs/table.npz", "url": "http://localhost:9060/v1", "model": "perception", "seed": 0}`, plus `"memory": true` for `grpo_r25_curiosity` |
|
| 167 |
+
| `hidden_frozen`, `hidden_joint_lora` | `hid_server.py` (adapter only for `hidden_joint_lora`) | `{"kind": "hid", "npz": "/abs/ctrl.npz", "url": "http://localhost:9210", "seed": 0}` |
|
| 168 |
+
|
| 169 |
+
Each episode writes `steps.jsonl` with the policy's per-step log (inputs, sampled bins, perception replies).
|
code/bench/diag_perception.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Diagnosis B (2026-10-13): can the base Qwen3.5-9B (no LoRA) recognize the target in our 320x180 frames?
|
| 2 |
+
|
| 3 |
+
Frames: GRPO v2 rollouts, rounds 1-5 (round 6 was cut off mid-rollout when training was paused), frames/NNNN.png =
|
| 4 |
+
the exact PNG bytes the policy saw. frames/i.png shows the pose after step i (steps.jsonl `pos`); frame 0 the
|
| 5 |
+
teleport location (train/grpo_locations.json) with yaw 0, pitch 0, which is also the origin of the spawn-relative
|
| 6 |
+
milestone targets. Angles follow benchmark_gen/utils.py (target yaw = atan2(-dx, dz)); a camera yaw delta adds to
|
| 7 |
+
pos.yaw (checked on round 1: 6346 of 6346 turns), so a target at a larger yaw is to the right. Pitch > 0 looks down.
|
| 8 |
+
- positives: target 3-12 blocks away, <= 20 deg left/right of the view center, <= 30 deg above/below it (the
|
| 9 |
+
vertical half field of view is 35: Minecraft fov 70, options.txt fov:0.0);
|
| 10 |
+
- behind negatives: same distance band, >= 120 deg off center;
|
| 11 |
+
- wrong-name negatives: the positive frames, asked about a target name of another scene that is neither a target of
|
| 12 |
+
this scene nor a block its build commands place.
|
| 13 |
+
At most 2 frames per episode and target, >= 20 frames apart, then round-robin over scenes.
|
| 14 |
+
Question "Is there a <name> in this image? Answer yes or no.", thinking off, temperature 0.
|
| 15 |
+
Writes items.jsonl, summary.md and contact_sheet.png (48 positives, red circle = projected target point).
|
| 16 |
+
Usage: diag_perception.py --url http://localhost:9041/v1 --model NAME [--n 200] [--out DIR]
|
| 17 |
+
"""
|
| 18 |
+
import argparse
|
| 19 |
+
import base64
|
| 20 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 21 |
+
import json
|
| 22 |
+
import math
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
import random
|
| 25 |
+
|
| 26 |
+
from openai import OpenAI
|
| 27 |
+
from PIL import Image, ImageDraw
|
| 28 |
+
|
| 29 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 30 |
+
GRPO = ROOT / "runs/grpo_v2_20261013"
|
| 31 |
+
ROUNDS = [f"round{r:03d}" for r in range(1, 6)]
|
| 32 |
+
BENCH = ROOT / "MineExplorer/benchmark"
|
| 33 |
+
LOCATIONS = json.loads((ROOT / "train/grpo_locations.json").read_text())
|
| 34 |
+
EYE = 1.62 # eye height above player_pos (the feet)
|
| 35 |
+
W, H, HALF_VFOV = 320, 180, 35.0
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def episodes():
|
| 39 |
+
"""Finished rollout episodes: (episode dir, scene, steps, poses, targets); poses[i] is the pose of frames/i.png,
|
| 40 |
+
targets are (milestone id, task, absolute target)."""
|
| 41 |
+
for result in sorted(p for r in ROUNDS for p in (GRPO / r).glob("*/result.json")):
|
| 42 |
+
ep = result.parent
|
| 43 |
+
scene = ep.name.split("_")[0]
|
| 44 |
+
x, y, z = LOCATIONS[scene]
|
| 45 |
+
steps = [json.loads(line) for line in (ep / "steps.jsonl").read_text().splitlines()]
|
| 46 |
+
poses = [{"x": x, "y": y, "z": z, "yaw": 0.0, "pitch": 0.0}] + [s["pos"] for s in steps]
|
| 47 |
+
meta = json.loads((BENCH / scene / "multi-agent/metadata.json").read_text())
|
| 48 |
+
targets = [(m["milestone_id"], m["task"], [t + o for t, o in zip(m["rules"][0]["params"]["target"], (x, y, z))])
|
| 49 |
+
for m in meta["milestones"]["milestones"]]
|
| 50 |
+
yield ep, scene, steps, poses, targets
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def view(pose, target):
|
| 54 |
+
"""(distance, horizontal, vertical): distance from the feet as the milestone check; horizontal = target yaw minus
|
| 55 |
+
view yaw in [-180, 180), > 0 = right; vertical = target elevation from the eye above the view center."""
|
| 56 |
+
dx, dy, dz = target[0] - pose["x"], target[1] - pose["y"] - EYE, target[2] - pose["z"]
|
| 57 |
+
h = (math.degrees(math.atan2(-dx, dz)) - pose["yaw"] + 180) % 360 - 180
|
| 58 |
+
v = math.degrees(math.atan2(dy, math.hypot(dx, dz))) + pose["pitch"]
|
| 59 |
+
return math.dist((pose["x"], pose["y"], pose["z"]), target), h, v
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def spread(candidates, n, rng):
|
| 63 |
+
"""At most 2 per (episode, target), >= 20 frames apart, then round-robin over scenes up to n."""
|
| 64 |
+
rng.shuffle(candidates)
|
| 65 |
+
groups = {}
|
| 66 |
+
for c in candidates:
|
| 67 |
+
g = groups.setdefault((c["ep"], c["mid"]), [])
|
| 68 |
+
if len(g) < 2 and all(abs(c["frame"] - o["frame"]) >= 20 for o in g):
|
| 69 |
+
g.append(c)
|
| 70 |
+
by_scene = {}
|
| 71 |
+
for c in (c for g in groups.values() for c in g):
|
| 72 |
+
by_scene.setdefault(c["scene"], []).append(c)
|
| 73 |
+
queues = [by_scene[s] for s in sorted(by_scene)]
|
| 74 |
+
out = []
|
| 75 |
+
while len(out) < n and any(queues):
|
| 76 |
+
out += [q.pop() for q in queues if q][:n - len(out)]
|
| 77 |
+
return out
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def select(n):
|
| 81 |
+
"""(positives, behind negatives, wrong-name negatives), n of each where available."""
|
| 82 |
+
rng = random.Random(0)
|
| 83 |
+
pos, behind, names, placed = [], [], {}, {}
|
| 84 |
+
for ep, scene, _, poses, targets in episodes():
|
| 85 |
+
placed[scene] = " ".join(json.loads((BENCH / scene / "multi-agent/metadata.json").read_text())["commands"])
|
| 86 |
+
for mid, task, target in targets:
|
| 87 |
+
if not task.startswith("find "): # a handful of "interact with X" / "open door" goals
|
| 88 |
+
continue
|
| 89 |
+
name = task[5:].removeprefix("a ").replace("_", " ")
|
| 90 |
+
names.setdefault(scene, set()).add(name)
|
| 91 |
+
for i, pose in enumerate(poses):
|
| 92 |
+
dist, h, v = view(pose, target)
|
| 93 |
+
item = {"ep": str(ep.relative_to(ROOT)), "scene": scene, "mid": mid, "frame": i, "name": name,
|
| 94 |
+
"asked": name, "dist": round(dist, 1), "h": round(h, 1), "v": round(v, 1)}
|
| 95 |
+
if 3 <= dist <= 12 and abs(h) <= 20 and abs(v) <= 30:
|
| 96 |
+
pos.append(item)
|
| 97 |
+
elif 3 <= dist <= 12 and abs(h) >= 120:
|
| 98 |
+
behind.append(item)
|
| 99 |
+
pos, behind = spread(pos, n, rng), spread(behind, n, rng)
|
| 100 |
+
every = sorted(set().union(*names.values()))
|
| 101 |
+
wrong = [{**p, "asked": rng.choice([x for x in every if x not in names[p["scene"]]
|
| 102 |
+
and x.replace(" ", "_") not in placed[p["scene"]]])} for p in pos]
|
| 103 |
+
return pos, behind, wrong
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def main():
|
| 107 |
+
ap = argparse.ArgumentParser()
|
| 108 |
+
ap.add_argument("--url", required=True)
|
| 109 |
+
ap.add_argument("--model", required=True)
|
| 110 |
+
ap.add_argument("--n", type=int, default=200)
|
| 111 |
+
ap.add_argument("--out", type=Path, default=ROOT / "runs/diag_20261013/perception")
|
| 112 |
+
args = ap.parse_args()
|
| 113 |
+
args.out.mkdir(parents=True, exist_ok=True)
|
| 114 |
+
pos, behind, wrong = select(args.n)
|
| 115 |
+
print(f"positives {len(pos)} from {len({p['scene'] for p in pos})} scenes, behind {len(behind)} from "
|
| 116 |
+
f"{len({p['scene'] for p in behind})} scenes, wrong-name {len(wrong)}", flush=True)
|
| 117 |
+
|
| 118 |
+
client = OpenAI(api_key="EMPTY", base_url=args.url)
|
| 119 |
+
|
| 120 |
+
def ask(kind_item):
|
| 121 |
+
kind, item = kind_item
|
| 122 |
+
png = (ROOT / item["ep"] / "frames" / f"{item['frame']:04d}.png").read_bytes()
|
| 123 |
+
r = client.chat.completions.create(
|
| 124 |
+
model=args.model, temperature=0, max_tokens=5,
|
| 125 |
+
messages=[{"role": "user", "content": [
|
| 126 |
+
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(png).decode()}},
|
| 127 |
+
{"type": "text", "text": f"Is there a {item['asked']} in this image? Answer yes or no."}]}],
|
| 128 |
+
extra_body={"chat_template_kwargs": {"enable_thinking": False}})
|
| 129 |
+
answer = r.choices[0].message.content
|
| 130 |
+
return {"kind": kind, **item, "answer": answer, "yes": answer.strip().lower().startswith("yes")}
|
| 131 |
+
|
| 132 |
+
with ThreadPoolExecutor(16) as pool:
|
| 133 |
+
items = list(pool.map(ask, [("pos", p) for p in pos] + [("behind", b) for b in behind]
|
| 134 |
+
+ [("wrong", w) for w in wrong]))
|
| 135 |
+
with (args.out / "items.jsonl").open("w") as f:
|
| 136 |
+
f.writelines(json.dumps(item, ensure_ascii=False) + "\n" for item in items)
|
| 137 |
+
|
| 138 |
+
rate = lambda xs: f"{100 * sum(x['yes'] for x in xs) / len(xs):.1f}%" if xs else "-"
|
| 139 |
+
by_kind = {k: [x for x in items if x["kind"] == k] for k in ("pos", "behind", "wrong")}
|
| 140 |
+
lines = ["| 类型 | 数量 | 答 yes 的比例 |", "|---|---:|---:|",
|
| 141 |
+
f"| 正例(目标在视野中央附近) | {len(by_kind['pos'])} | {rate(by_kind['pos'])} |",
|
| 142 |
+
f"| 背后负例(目标在身后) | {len(by_kind['behind'])} | {rate(by_kind['behind'])} |",
|
| 143 |
+
f"| 错名负例(同一批正例帧,问别的场景的目标名) | {len(by_kind['wrong'])} | {rate(by_kind['wrong'])} |"]
|
| 144 |
+
per = sorted({x["asked"] for x in items}, key=lambda n: -sum(x["asked"] == n for x in by_kind["pos"]))[:20]
|
| 145 |
+
lines += ["", "逐目标(正例最多的 20 个;错名一列是把这个名字问在不含它的帧上):", "",
|
| 146 |
+
"| 目标 | 正例 n | 正例 yes | 背后 n | 背后 yes | 错名 n | 错名 yes |", "|---|---:|---:|---:|---:|---:|---:|"]
|
| 147 |
+
for n in per:
|
| 148 |
+
cells = [[x for x in by_kind[k] if x["asked"] == n] for k in ("pos", "behind", "wrong")]
|
| 149 |
+
lines.append(f"| {n} | " + " | ".join(f"{len(c)} | {rate(c)}" for c in cells) + " |")
|
| 150 |
+
(args.out / "summary.md").write_text(
|
| 151 |
+
"帧来自 GRPO v2 第 1 到 5 轮 rollout(策略实际看到的 320×180 PNG)。正例:目标距离 3 到 12 格,偏离视野中心左右 ≤20°、"
|
| 152 |
+
"上下 ≤30°(竖直半视场 35°);背后负例:同距离段,偏离 ≥120°;错名负例:同一批正例帧,问另一个场景的目标名"
|
| 153 |
+
"(本场景的目标和建造命令里都没有这种方块,但个别名字如南瓜、甘蔗可能在自然地形里出现)。每个回合每个目标最多 2 帧、"
|
| 154 |
+
f"相隔 ≥20 帧,按场景轮流抽:正例来自 {len({p['scene'] for p in pos})} 个场景,背后负例来自 "
|
| 155 |
+
f"{len({p['scene'] for p in behind})} 个场景。只按几何选帧,不判断遮挡;"
|
| 156 |
+
"接触表 contact_sheet.png 是前 48 个正例(红圈为目标点按视角投影的位置,标注为目标名、回答、距离、回合/帧号),"
|
| 157 |
+
"全部逐条结果见 items.jsonl。\n\n" + "\n".join(lines) + "\n")
|
| 158 |
+
|
| 159 |
+
sheet = Image.new("RGB", (6 * W, 8 * (H + 14)), "white")
|
| 160 |
+
for k, x in enumerate(by_kind["pos"][:48]):
|
| 161 |
+
img = Image.open(ROOT / x["ep"] / "frames" / f"{x['frame']:04d}.png").convert("RGB")
|
| 162 |
+
draw = ImageDraw.Draw(img)
|
| 163 |
+
# approximate pinhole projection from the two angles (exact at pitch 0); 16:9, vertical fov 70
|
| 164 |
+
u = W / 2 + W / 2 * math.tan(math.radians(x["h"])) / (math.tan(math.radians(HALF_VFOV)) * W / H)
|
| 165 |
+
v = H / 2 - H / 2 * math.tan(math.radians(x["v"])) / math.tan(math.radians(HALF_VFOV))
|
| 166 |
+
draw.ellipse((u - 6, v - 6, u + 6, v + 6), outline="red", width=2)
|
| 167 |
+
cell = (k % 6 * W, k // 6 * (H + 14))
|
| 168 |
+
sheet.paste(img, (cell[0], cell[1] + 14))
|
| 169 |
+
ImageDraw.Draw(sheet).text((cell[0] + 2, cell[1] + 1), f"{x['name']} | {x['answer'].strip()[:5]} | {x['dist']:.0f}m | "
|
| 170 |
+
f"{Path(x['ep']).name}/{x['frame']}", fill="black")
|
| 171 |
+
sheet.save(args.out / "contact_sheet.png")
|
| 172 |
+
print((args.out / "summary.md").read_text(), flush=True)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
main()
|
code/bench/diag_perception_direction.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Diagnosis B2 (2026-10-13): can the base Qwen3.5-9B (no LoRA) tell a controller WHERE the named target is in our
|
| 2 |
+
320x180 frames, not only whether it is there?
|
| 3 |
+
|
| 4 |
+
Frames, poses, angles and spreading come from bench/diag_perception.py (B), with wider bands:
|
| 5 |
+
- positives: target 3-16 blocks away, |h| <= 45, |v| <= 30, 100 from each h bin [-45,-15), [-15,15], (15,45];
|
| 6 |
+
- behind negatives: same distance band, |h| >= 120, 100;
|
| 7 |
+
- wrong-name negatives: every third positive frame, asked about a target name of another scene that is neither a target
|
| 8 |
+
of this scene nor a block its build commands place (B's rule).
|
| 9 |
+
Two questions per frame, thinking off, temperature 0:
|
| 10 |
+
(a) grounding in Qwen's native JSON format. Qwen3.5 answers [{"bbox_2d": [x1, y1, x2, y2], "label": ...}] in relative
|
| 11 |
+
0-1000 coordinates (checked on six clearly visible frames of B: values up to 959 and 825 on a 320x180 frame that vLLM
|
| 12 |
+
resizes to 352x192; scaled by W/1000 and H/1000 the boxes sit on the objects). The first box counts; its center gives
|
| 13 |
+
a horizontal angle with B's pinhole model (exact at pitch 0);
|
| 14 |
+
(b) one word: left, center, right or none (truth for positives: center = |h| <= 15).
|
| 15 |
+
Writes items.jsonl, summary.md and contact_sheet.png (48 positives, 16 per h bin: green = boxes, the first one thick,
|
| 16 |
+
red = projected target point as in B).
|
| 17 |
+
Usage: diag_perception_direction.py --url http://localhost:9045/v1 --model NAME [--out DIR]
|
| 18 |
+
"""
|
| 19 |
+
import argparse
|
| 20 |
+
import base64
|
| 21 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 22 |
+
import json
|
| 23 |
+
import math
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
import random
|
| 26 |
+
import re
|
| 27 |
+
import statistics
|
| 28 |
+
|
| 29 |
+
from openai import OpenAI
|
| 30 |
+
from PIL import Image, ImageDraw
|
| 31 |
+
|
| 32 |
+
from diag_perception import BENCH, H, HALF_VFOV, ROOT, W, episodes, spread, view
|
| 33 |
+
|
| 34 |
+
TAN_HALF_HFOV = math.tan(math.radians(HALF_VFOV)) * W / H # horizontal half fov ~51.2 deg
|
| 35 |
+
SIDES = ("left", "center", "right")
|
| 36 |
+
GROUND = ("Locate the {0} in this image and report its bbox coordinates in JSON format. "
|
| 37 |
+
"If there is no {0} in the image, output an empty list [].")
|
| 38 |
+
WORD = ("Where is the {0} in this image? Answer with one word: left, center, or right. "
|
| 39 |
+
"Answer none if there is no {0} in the image.")
|
| 40 |
+
BOX = re.compile(r'"bbox_2d"\s*:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]')
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def side(h):
|
| 44 |
+
return "left" if h < -15 else "center" if h <= 15 else "right"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def select():
|
| 48 |
+
"""(positives, behind negatives, wrong-name negatives)."""
|
| 49 |
+
rng = random.Random(0)
|
| 50 |
+
by_side, behind, names, placed = {s: [] for s in SIDES}, [], {}, {}
|
| 51 |
+
for ep, scene, _, poses, targets in episodes():
|
| 52 |
+
placed[scene] = " ".join(json.loads((BENCH / scene / "multi-agent/metadata.json").read_text())["commands"])
|
| 53 |
+
for mid, task, target in targets:
|
| 54 |
+
if not task.startswith("find "):
|
| 55 |
+
continue
|
| 56 |
+
name = task[5:].removeprefix("a ").replace("_", " ")
|
| 57 |
+
names.setdefault(scene, set()).add(name)
|
| 58 |
+
for i, pose in enumerate(poses):
|
| 59 |
+
dist, h, v = (round(a, 1) for a in view(pose, target))
|
| 60 |
+
item = {"ep": str(ep.relative_to(ROOT)), "scene": scene, "mid": mid, "frame": i, "name": name,
|
| 61 |
+
"asked": name, "dist": dist, "h": h, "v": v, "pitch": round(pose["pitch"], 1)}
|
| 62 |
+
if 3 <= dist <= 16 and abs(h) <= 45 and abs(v) <= 30:
|
| 63 |
+
by_side[side(h)].append(item)
|
| 64 |
+
elif 3 <= dist <= 16 and abs(h) >= 120:
|
| 65 |
+
behind.append(item)
|
| 66 |
+
pos = [x for s in SIDES for x in spread(by_side[s], 100, rng)]
|
| 67 |
+
behind = spread(behind, 100, rng)
|
| 68 |
+
every = sorted(set().union(*names.values()))
|
| 69 |
+
wrong = [{**p, "asked": rng.choice([x for x in every if x not in names[p["scene"]]
|
| 70 |
+
and x.replace(" ", "_") not in placed[p["scene"]]])} for p in pos[::3]]
|
| 71 |
+
return pos, behind, wrong
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def spearman(a, b):
|
| 75 |
+
rank = lambda xs: [sorted(xs).index(x) + (sorted(xs).count(x) - 1) / 2 for x in xs]
|
| 76 |
+
return statistics.correlation(rank(a), rank(b))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def main():
|
| 80 |
+
ap = argparse.ArgumentParser()
|
| 81 |
+
ap.add_argument("--url", required=True)
|
| 82 |
+
ap.add_argument("--model", required=True)
|
| 83 |
+
ap.add_argument("--out", type=Path, default=ROOT / "runs/diag_20261013/perception_direction")
|
| 84 |
+
args = ap.parse_args()
|
| 85 |
+
args.out.mkdir(parents=True, exist_ok=True)
|
| 86 |
+
pos, behind, wrong = select()
|
| 87 |
+
counts = ", ".join(f"{s} {sum(side(p['h']) == s for p in pos)}" for s in SIDES)
|
| 88 |
+
print(f"positives {len(pos)} ({counts}) from "
|
| 89 |
+
f"{len({p['scene'] for p in pos})} scenes, behind {len(behind)} from {len({b['scene'] for b in behind})} "
|
| 90 |
+
f"scenes, wrong-name {len(wrong)}", flush=True)
|
| 91 |
+
|
| 92 |
+
client = OpenAI(api_key="EMPTY", base_url=args.url)
|
| 93 |
+
|
| 94 |
+
def ask(kind_item):
|
| 95 |
+
kind, item = kind_item
|
| 96 |
+
png = (ROOT / item["ep"] / "frames" / f"{item['frame']:04d}.png").read_bytes()
|
| 97 |
+
image = {"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(png).decode()}}
|
| 98 |
+
|
| 99 |
+
def chat(prompt, max_tokens):
|
| 100 |
+
r = client.chat.completions.create(
|
| 101 |
+
model=args.model, temperature=0, max_tokens=max_tokens,
|
| 102 |
+
messages=[{"role": "user", "content": [image, {"type": "text", "text": prompt.format(item["asked"])}]}],
|
| 103 |
+
extra_body={"chat_template_kwargs": {"enable_thinking": False}})
|
| 104 |
+
return r.choices[0].message.content
|
| 105 |
+
|
| 106 |
+
ground, word = chat(GROUND, 256), chat(WORD, 5)
|
| 107 |
+
boxes = [[float(c) for c in b] for b in BOX.findall(ground)]
|
| 108 |
+
out = {"kind": kind, **item, "ground": ground, "boxes": boxes, "word_raw": word,
|
| 109 |
+
"word": next((w for w in SIDES + ("none",) if word.strip().lower().startswith(w)), "other")}
|
| 110 |
+
if boxes:
|
| 111 |
+
x1, _, x2, _ = boxes[0]
|
| 112 |
+
out["h_box"] = round(math.degrees(math.atan(((x1 + x2) / 1000 - 1) * TAN_HALF_HFOV)), 1)
|
| 113 |
+
return out
|
| 114 |
+
|
| 115 |
+
with ThreadPoolExecutor(32) as pool:
|
| 116 |
+
items = list(pool.map(ask, [("pos", p) for p in pos] + [("behind", b) for b in behind]
|
| 117 |
+
+ [("wrong", w) for w in wrong]))
|
| 118 |
+
with (args.out / "items.jsonl").open("w") as f:
|
| 119 |
+
f.writelines(json.dumps(item, ensure_ascii=False) + "\n" for item in items)
|
| 120 |
+
|
| 121 |
+
by_kind = {k: [x for x in items if x["kind"] == k] for k in ("pos", "behind", "wrong")}
|
| 122 |
+
pct = lambda k, n: f"{100 * k / n:.1f}%" if n else "-"
|
| 123 |
+
med = lambda xs: f"{statistics.median(xs):+.1f}" if xs else "-"
|
| 124 |
+
|
| 125 |
+
def row(label, xs):
|
| 126 |
+
det = [x for x in xs if x["boxes"]]
|
| 127 |
+
err = [x["h_box"] - x["h"] for x in det]
|
| 128 |
+
said = [x for x in xs if x["word"] in SIDES]
|
| 129 |
+
return (f"| {label} | {len(xs)} | {pct(len(det), len(xs))} | {med([abs(e) for e in err]).lstrip('+')} | "
|
| 130 |
+
f"{med(err)} | {pct(sum(abs(e) <= 10 for e in err), len(det))} | "
|
| 131 |
+
f"{pct(sum(abs(e) <= 15 for e in err), len(det))} | "
|
| 132 |
+
f"{pct(sum(side(x['h_box']) == side(x['h']) for x in det), len(xs))} | {pct(len(said), len(xs))} | "
|
| 133 |
+
f"{pct(sum(x['word'] == side(x['h']) for x in said), len(xs))} | "
|
| 134 |
+
f"{pct(sum(x['word'] == side(x['h']) for x in said), len(said))} |")
|
| 135 |
+
|
| 136 |
+
p = by_kind["pos"]
|
| 137 |
+
lines = ["| 正例分组 | n | (a) 给框率 | (a) 角度误差中位数(度) | (a) 有符号误差中位数 | (a) 误差 ≤10° | (a) 误差 ≤15° | "
|
| 138 |
+
"(a) 框推出的方向对(占全部) | (b) 非 none 率 | (b) 方向对(占全部) | (b) 方向对(占作答) |",
|
| 139 |
+
"|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|", row("全部", p)]
|
| 140 |
+
lines += [row(f"距离 {lo}-{hi} 格", [x for x in p if lo <= x["dist"] < hi or (hi == 16 and x["dist"] == 16)])
|
| 141 |
+
for lo, hi in ((3, 6), (6, 10), (10, 16))]
|
| 142 |
+
lines += [row(f"h {label}", [x for x in p if side(x["h"]) == s])
|
| 143 |
+
for s, label in zip(SIDES, ("左 [-45,-15)", "中 [-15,15]", "右 (15,45]"))]
|
| 144 |
+
lines += ["", "(a) 的误差只算给了框的正例;“占全部”把没给框或答 none 算作错。", "",
|
| 145 |
+
"(b) 的混淆表(行为真值,列为回答):", "", "| 真值 | left | center | right | none | 其他 |",
|
| 146 |
+
"|---|---:|---:|---:|---:|---:|"]
|
| 147 |
+
lines += [f"| {s} | " + " | ".join(str(sum(x["word"] == w for x in p if side(x["h"]) == s))
|
| 148 |
+
for w in SIDES + ("none", "other")) + " |" for s in SIDES]
|
| 149 |
+
lines += ["", "负例(误报率):", "", "| 负例类型 | n | (a) 给框率 | (b) 非 none 率 |", "|---|---:|---:|---:|"]
|
| 150 |
+
lines += [f"| {label} | {len(by_kind[k])} | {pct(sum(bool(x['boxes']) for x in by_kind[k]), len(by_kind[k]))} | "
|
| 151 |
+
f"{pct(sum(x['word'] in SIDES for x in by_kind[k]), len(by_kind[k]))} |"
|
| 152 |
+
for k, label in (("behind", "背后(目标在身后)"), ("wrong", "错名(正例帧,问别的场景的目标名)"))]
|
| 153 |
+
det = [x for x in p if x["boxes"]]
|
| 154 |
+
height = [x["boxes"][0][3] - x["boxes"][0][1] for x in det]
|
| 155 |
+
area = [(x["boxes"][0][2] - x["boxes"][0][0]) * (x["boxes"][0][3] - x["boxes"][0][1]) for x in det]
|
| 156 |
+
dist = [x["dist"] for x in det]
|
| 157 |
+
lines += ["", f"距离线索(给了框的 {len(det)} 个正例,第一个框):框高与几何距离的 Spearman ρ = "
|
| 158 |
+
f"{spearman(height, dist):+.2f},框面积与几何距离 ρ = {spearman(area, dist):+.2f}。"]
|
| 159 |
+
(args.out / "summary.md").write_text(
|
| 160 |
+
"帧和几何同 B(bench/diag_perception.py):GRPO v2 第 1 到 5 轮 rollout 中策略实际看到的 320×180 PNG。"
|
| 161 |
+
"正例:目标距离 3 到 16 格,水平角 |h| ≤ 45°、竖直角 |v| ≤ 30°,按 h 分三段 [-45,-15)、[-15,15]、(15,45] 各抽 100;"
|
| 162 |
+
"背后负例:同距离段,|h| ≥ 120°,100 个;错名负例:每三个正例取一帧,问另一个场景的目标名(B 的规则),"
|
| 163 |
+
f"{len(by_kind['wrong'])} 个。每个回合每个目标在每段最多 2 帧、相隔 ≥20 帧,按场景轮流抽:正例来自 "
|
| 164 |
+
f"{len({x['scene'] for x in p})} 个场景、{len({x['ep'] for x in p})} 个回合,背后负例来自 "
|
| 165 |
+
f"{len({x['scene'] for x in by_kind['behind']})} 个场景。只按几何选帧,不判断遮挡。\n\n"
|
| 166 |
+
"坐标系核对:Qwen3.5 的原生定位输出是 JSON 列表 [{\"bbox_2d\": [x1, y1, x2, y2], \"label\": ...}],坐标是相对 0 到 1000,"
|
| 167 |
+
"不是像素:在 B 的 6 张目标清楚可见的帧上,y 最大到 825、x 最大到 959(原图 320×180,vLLM 送进模型时缩放为 352×192,"
|
| 168 |
+
"两种像素坐标都不可能出现这些值),按 x·W/1000、y·H/1000 换算后框落在物体上(品红/紫色玻璃、箱子、淡蓝混凝土准确,"
|
| 169 |
+
"矢车菊和盆栽竹子这种很小的物体偏约 15 像素)。下面取第一个框的中心,用针孔模型(水平半视场 "
|
| 170 |
+
f"{math.degrees(math.atan(TAN_HALF_HFOV)):.1f}°,俯仰为 0 时精确)换算成水平角,和几何 h 比较。\n\n"
|
| 171 |
+
f"问题(思考关闭,温度 0):(a) \"{GROUND.format('<name>')}\";(b) \"{WORD.format('<name>')}\","
|
| 172 |
+
"真值 center 为 |h| ≤ 15°。\n\n" + "\n".join(lines) + "\n\n"
|
| 173 |
+
"接触表 contact_sheet.png:每段前 16 个正例(左、中、右依次排列),绿框为 (a) 的框(第一个加粗),红圈为目标点按视角投影的位置"
|
| 174 |
+
"(同 B),标注为序号、目标名、距离、h、(b) 的回答;全部逐条结果见 items.jsonl。\n")
|
| 175 |
+
|
| 176 |
+
sheet = Image.new("RGB", (6 * W, 8 * (H + 14)), "white")
|
| 177 |
+
shown = [x for s in SIDES for x in [x for x in p if side(x["h"]) == s][:16]]
|
| 178 |
+
for k, x in enumerate(shown):
|
| 179 |
+
img = Image.open(ROOT / x["ep"] / "frames" / f"{x['frame']:04d}.png").convert("RGB")
|
| 180 |
+
draw = ImageDraw.Draw(img)
|
| 181 |
+
u = W / 2 + W / 2 * math.tan(math.radians(x["h"])) / TAN_HALF_HFOV
|
| 182 |
+
v = H / 2 - H / 2 * math.tan(math.radians(x["v"])) / math.tan(math.radians(HALF_VFOV))
|
| 183 |
+
draw.ellipse((u - 6, v - 6, u + 6, v + 6), outline="red", width=2)
|
| 184 |
+
for j, (x1, y1, x2, y2) in enumerate(x["boxes"]):
|
| 185 |
+
draw.rectangle((x1 * W / 1000, y1 * H / 1000, x2 * W / 1000, y2 * H / 1000), outline=(0, 255, 0),
|
| 186 |
+
width=2 if j == 0 else 1)
|
| 187 |
+
cell = (k % 6 * W, k // 6 * (H + 14))
|
| 188 |
+
sheet.paste(img, (cell[0], cell[1] + 14))
|
| 189 |
+
ImageDraw.Draw(sheet).text((cell[0] + 2, cell[1] + 1), f"{k} {x['name']} | {x['dist']:.0f}m | h{x['h']:+.0f} | "
|
| 190 |
+
f"{x['word_raw'].strip()[:7]}", fill="black")
|
| 191 |
+
sheet.save(args.out / "contact_sheet.png")
|
| 192 |
+
print((args.out / "summary.md").read_text(), flush=True)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
if __name__ == "__main__":
|
| 196 |
+
main()
|
code/bench/diag_perception_presence.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verification step 1b (2026-10-13): presence scores of the base Qwen3.5-9B (no LoRA) from token logprobs, plus the
|
| 2 |
+
box temporal check of PLAN_DECOUPLED_20261013.md 2.4 point 3. Thinking off, temperature 0, top 20 logprobs.
|
| 3 |
+
|
| 4 |
+
1b, on step 1's 500 items (runs/diag_20261013/perception_direction/items.jsonl: 300 geometric positives, 100 behind,
|
| 5 |
+
100 wrong-name), two scores:
|
| 6 |
+
- s_yes: B's question, max_tokens 1; P(yes) / (P(yes) + P(no)), case and leading-space variants summed;
|
| 7 |
+
- s_box: step 1's grounding question with max_tokens 48 (the controller's call). Answers either open a code fence
|
| 8 |
+
("```", "json", "\\n", "[", "\\n", "\\t", '{"', ...) or refuse ("There are no objects belonging to ..."); after the fence
|
| 9 |
+
the list token is "[" with P("[]") ~ 0 (stored per item as p_list_empty), so empty vs non-empty is decided at the
|
| 10 |
+
first token: s_box = P(first token "```").
|
| 11 |
+
AUC: 300 positives vs 200 negatives, and step 1's hand-labelled contact-sheet positives (37 visible vs 11 not).
|
| 12 |
+
Temporal check on GRPO v2 rounds 1-5: disjoint windows of 5 consecutive frames turning >= 5 deg at each step.
|
| 13 |
+
real = a target in the geometric field of view (|h| <= 51.2, |v| <= 35) 3-16 blocks away in all 5 frames;
|
| 14 |
+
wrong = the same windows asked about a wrong name (step 1's rule); behind = a target 3-16 blocks away with |h| >= 120
|
| 15 |
+
in all 5 frames (spread as step 1, up to 100). Per step, the first box centre's angle change is compared with
|
| 16 |
+
-delta yaw, the change a fixed world point shows.
|
| 17 |
+
Writes items.jsonl, windows.jsonl, summary.md.
|
| 18 |
+
Usage: diag_perception_presence.py --url http://localhost:9041/v1 --model NAME [--out DIR]
|
| 19 |
+
"""
|
| 20 |
+
import argparse
|
| 21 |
+
import base64
|
| 22 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 23 |
+
import json
|
| 24 |
+
import math
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
import random
|
| 27 |
+
import statistics
|
| 28 |
+
|
| 29 |
+
from openai import OpenAI
|
| 30 |
+
|
| 31 |
+
from diag_perception import BENCH, HALF_VFOV, ROOT, episodes, spread, view
|
| 32 |
+
from diag_perception_direction import BOX, GROUND, SIDES, TAN_HALF_HFOV, side
|
| 33 |
+
|
| 34 |
+
STEP1 = ROOT / "runs/diag_20261013/perception_direction/items.jsonl"
|
| 35 |
+
HIDDEN = {11, 17, 19, 20, 27, 29, 33, 36, 43, 45, 47} # contact-sheet positives judged not visible (step 1 summary.md)
|
| 36 |
+
HALF_HFOV = math.degrees(math.atan(TAN_HALF_HFOV))
|
| 37 |
+
YESNO = "Is there a {0} in this image? Answer yes or no."
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def windows(rng):
|
| 41 |
+
"""(real, wrong, behind) windows: start frame, the 4 yaw changes, geometric (dist, h, v) per frame."""
|
| 42 |
+
real, behind, names, placed = [], [], {}, {}
|
| 43 |
+
for ep, scene, _, poses, targets in episodes():
|
| 44 |
+
placed[scene] = " ".join(json.loads((BENCH / scene / "multi-agent/metadata.json").read_text())["commands"])
|
| 45 |
+
turn = [(b["yaw"] - a["yaw"] + 180) % 360 - 180 for a, b in zip(poses, poses[1:])]
|
| 46 |
+
for mid, task, target in targets:
|
| 47 |
+
if not task.startswith("find "):
|
| 48 |
+
continue
|
| 49 |
+
name = task[5:].removeprefix("a ").replace("_", " ")
|
| 50 |
+
names.setdefault(scene, set()).add(name)
|
| 51 |
+
free = {"real": 0, "behind": 0}
|
| 52 |
+
for s in range(len(poses) - 4):
|
| 53 |
+
if not all(abs(d) >= 5 for d in turn[s:s + 4]):
|
| 54 |
+
continue
|
| 55 |
+
geo = [[round(a, 1) for a in view(poses[s + j], target)] for j in range(5)]
|
| 56 |
+
kind = ("real" if all(3 <= d <= 16 and abs(h) <= HALF_HFOV and abs(v) <= HALF_VFOV for d, h, v in geo)
|
| 57 |
+
else "behind" if all(3 <= d <= 16 and abs(h) >= 120 for d, h, v in geo) else None)
|
| 58 |
+
if kind and s >= free[kind]:
|
| 59 |
+
free[kind] = s + 5
|
| 60 |
+
(real if kind == "real" else behind).append(
|
| 61 |
+
{"kind": kind, "ep": str(ep.relative_to(ROOT)), "scene": scene, "mid": mid, "frame": s,
|
| 62 |
+
"name": name, "asked": name, "turn": [round(d, 2) for d in turn[s:s + 4]], "geo": geo})
|
| 63 |
+
every = sorted(set().union(*names.values()))
|
| 64 |
+
wrong = [{**w, "kind": "wrong", "asked": rng.choice([x for x in every if x not in names[w["scene"]]
|
| 65 |
+
and x.replace(" ", "_") not in placed[w["scene"]]])}
|
| 66 |
+
for w in real]
|
| 67 |
+
return real, wrong, spread(behind, 100, rng)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def auc(pos, neg):
|
| 71 |
+
return sum((a > b) + 0.5 * (a == b) for a in pos for b in neg) / (len(pos) * len(neg))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def main():
|
| 75 |
+
ap = argparse.ArgumentParser()
|
| 76 |
+
ap.add_argument("--url", required=True)
|
| 77 |
+
ap.add_argument("--model", required=True)
|
| 78 |
+
ap.add_argument("--out", type=Path, default=ROOT / "runs/diag_20261013/perception_presence")
|
| 79 |
+
args = ap.parse_args()
|
| 80 |
+
args.out.mkdir(parents=True, exist_ok=True)
|
| 81 |
+
step1 = [json.loads(line) for line in STEP1.read_text().splitlines()]
|
| 82 |
+
real, wrong, behind = windows(random.Random(0))
|
| 83 |
+
print(f"windows: real {len(real)} ({len({w['scene'] for w in real})} scenes, {len({w['ep'] for w in real})} "
|
| 84 |
+
f"episodes), wrong {len(wrong)}, behind {len(behind)} ({len({w['scene'] for w in behind})} scenes)", flush=True)
|
| 85 |
+
|
| 86 |
+
client = OpenAI(api_key="EMPTY", base_url=args.url)
|
| 87 |
+
|
| 88 |
+
def chat(key, prompt, max_tokens):
|
| 89 |
+
ep, frame, asked = key
|
| 90 |
+
png = (ROOT / ep / "frames" / f"{frame:04d}.png").read_bytes()
|
| 91 |
+
r = client.chat.completions.create(
|
| 92 |
+
model=args.model, temperature=0, max_tokens=max_tokens, logprobs=True, top_logprobs=20,
|
| 93 |
+
messages=[{"role": "user", "content": [
|
| 94 |
+
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(png).decode()}},
|
| 95 |
+
{"type": "text", "text": prompt.format(asked)}]}],
|
| 96 |
+
extra_body={"chat_template_kwargs": {"enable_thinking": False}})
|
| 97 |
+
return r.choices[0].message.content, r.choices[0].logprobs.content
|
| 98 |
+
|
| 99 |
+
def ground(key):
|
| 100 |
+
text, lp = chat(key, GROUND, 48)
|
| 101 |
+
boxes = [[float(c) for c in b] for b in BOX.findall(text)]
|
| 102 |
+
out = {"ground": text, "boxes": boxes,
|
| 103 |
+
"s_box": sum(math.exp(t.logprob) for t in lp[0].top_logprobs if t.token.strip() == "```"),
|
| 104 |
+
"p_list_empty": sum(math.exp(t.logprob) for t in lp[3].top_logprobs if t.token == "[]")
|
| 105 |
+
if text.startswith("```json\n[") else None}
|
| 106 |
+
if boxes:
|
| 107 |
+
x1, _, x2, _ = boxes[0]
|
| 108 |
+
out["h_box"] = round(math.degrees(math.atan(((x1 + x2) / 1000 - 1) * TAN_HALF_HFOV)), 1)
|
| 109 |
+
return out
|
| 110 |
+
|
| 111 |
+
def yes(key):
|
| 112 |
+
text, lp = chat(key, YESNO, 1)
|
| 113 |
+
p = {w: sum(math.exp(t.logprob) for t in lp[0].top_logprobs if t.token.strip().lower() == w)
|
| 114 |
+
for w in ("yes", "no")}
|
| 115 |
+
return {"answer": text, "s_yes": p["yes"] / (p["yes"] + p["no"])}
|
| 116 |
+
|
| 117 |
+
keys1 = [(x["ep"], x["frame"], x["asked"]) for x in step1]
|
| 118 |
+
keys = list(dict.fromkeys(keys1 + [(w["ep"], w["frame"] + j, w["asked"]) for w in real + wrong + behind
|
| 119 |
+
for j in range(5)]))
|
| 120 |
+
with ThreadPoolExecutor(32) as pool:
|
| 121 |
+
g = dict(zip(keys, pool.map(ground, keys)))
|
| 122 |
+
y = dict(zip(keys1, pool.map(yes, keys1)))
|
| 123 |
+
items = [{**{k: x[k] for k in ("kind", "ep", "scene", "mid", "frame", "name", "asked", "dist", "h", "v", "pitch")},
|
| 124 |
+
**y[key], **g[key]} for x, key in zip(step1, keys1)]
|
| 125 |
+
wins = [{**w, "ground": [g[(w["ep"], w["frame"] + j, w["asked"])] for j in range(5)]}
|
| 126 |
+
for w in real + wrong + behind]
|
| 127 |
+
real, wrong, behind = ([w for w in wins if w["kind"] == k] for k in ("real", "wrong", "behind"))
|
| 128 |
+
with (args.out / "items.jsonl").open("w") as f:
|
| 129 |
+
f.writelines(json.dumps(x, ensure_ascii=False) + "\n" for x in items)
|
| 130 |
+
with (args.out / "windows.jsonl").open("w") as f:
|
| 131 |
+
f.writelines(json.dumps(w, ensure_ascii=False) + "\n" for w in wins)
|
| 132 |
+
|
| 133 |
+
by = {k: [x for x in items if x["kind"] == k] for k in ("pos", "behind", "wrong")}
|
| 134 |
+
shown = [x for s in SIDES for x in [x for x in by["pos"] if side(x["h"]) == s][:16]]
|
| 135 |
+
vis = [x for k, x in enumerate(shown) if k not in HIDDEN]
|
| 136 |
+
hid = [x for k, x in enumerate(shown) if k in HIDDEN]
|
| 137 |
+
neg = by["behind"] + by["wrong"]
|
| 138 |
+
aucs = ["| 比较 | n | s_yes AUC | s_box AUC |", "|---|---|---:|---:|"]
|
| 139 |
+
aucs += [f"| {label} | {len(a)} 对 {len(b)} | {auc([x['s_yes'] for x in a], [x['s_yes'] for x in b]):.3f} | "
|
| 140 |
+
f"{auc([x['s_box'] for x in a], [x['s_box'] for x in b]):.3f} |"
|
| 141 |
+
for label, a, b in (("正例对负例(背后 + 错名)", by["pos"], neg), ("正例对背后", by["pos"], by["behind"]),
|
| 142 |
+
("正例对错名", by["pos"], by["wrong"]), ("人工:可见对看不见", vis, hid),
|
| 143 |
+
("人工可见对负例", vis, neg))]
|
| 144 |
+
med = lambda xs: f"{statistics.median(xs):.3f}"
|
| 145 |
+
scores = ["| 组 | n | s_yes 中位数 | 答 yes | s_box 中位数 | 给框(48 token) |", "|---|---:|---:|---:|---:|---:|"]
|
| 146 |
+
scores += [f"| {label} | {len(xs)} | {med([x['s_yes'] for x in xs])} | "
|
| 147 |
+
f"{sum(x['answer'].strip().lower().startswith('yes') for x in xs)} | {med([x['s_box'] for x in xs])} | "
|
| 148 |
+
f"{sum(bool(x['boxes']) for x in xs)} |"
|
| 149 |
+
for label, xs in (("正例", by["pos"]), ("背后", by["behind"]), ("错名", by["wrong"]),
|
| 150 |
+
("人工可见", vis), ("人工看不见", hid))]
|
| 151 |
+
fence = [x["p_list_empty"] for x in items if x["p_list_empty"] is not None]
|
| 152 |
+
starts = sorted({x["ground"][:12] for x in items if x["p_list_empty"] is None})
|
| 153 |
+
scores += ["", f"代码块开头的回答 {len(fence)} 条,列表 token 上 P(\"[]\") 最大 {max(fence):.1e};"
|
| 154 |
+
f"其余 {len(items) - len(fence)} 条开头为 {starts}。"]
|
| 155 |
+
|
| 156 |
+
def row(label, seqs):
|
| 157 |
+
"""seqs: (4 yaw changes, 5 box centre angles, None = no box)."""
|
| 158 |
+
had = [(-d, a, b) for turns, hs in seqs for d, a, b in zip(turns, hs, hs[1:]) if a is not None]
|
| 159 |
+
both = [(x, b - a) for x, a, b in had if b is not None]
|
| 160 |
+
slope, icpt = statistics.linear_regression(*zip(*both))
|
| 161 |
+
rms = math.sqrt(statistics.fmean((y - slope * x - icpt) ** 2 for x, y in both))
|
| 162 |
+
frames = [h for _, hs in seqs for h in hs]
|
| 163 |
+
big = [y for x, y in both if abs(x) > 10]
|
| 164 |
+
pct = lambda k, n: f"{100 * k / n:.0f}%"
|
| 165 |
+
return (f"| {label} | {len(seqs)} | {pct(sum(h is not None for h in frames), len(frames))} | "
|
| 166 |
+
f"{pct(sum(b is None for _, _, b in had), len(had))} | {len(both)} | "
|
| 167 |
+
f"{statistics.median(abs(x) for x, _ in both):.1f} | {slope:.2f} | {icpt:+.1f} | {rms:.1f} | "
|
| 168 |
+
f"{pct(sum(abs(y - x) <= 5 for x, y in both), len(both))} | {pct(sum(abs(y) <= 5 for y in big), len(big))} | "
|
| 169 |
+
f"{pct(sum(min(abs(y - x), abs(y)) > 10 for x, y in both), len(both))} | "
|
| 170 |
+
f"{statistics.median(abs(y) for _, y in both):.1f} |")
|
| 171 |
+
|
| 172 |
+
boxes = lambda ws: [(w["turn"], [f.get("h_box") for f in w["ground"]]) for w in ws]
|
| 173 |
+
rng = random.Random(0)
|
| 174 |
+
pool = [f["h_box"] for w in wrong + behind for f in w["ground"] if "h_box" in f]
|
| 175 |
+
errs = [x["h_box"] - x["h"] for x in by["pos"] if "h_box" in x]
|
| 176 |
+
|
| 177 |
+
def fake_sim(redraw, world):
|
| 178 |
+
"""Fake box angles over the fake windows' turns (20 repeats): first from the observed fake boxes; each step
|
| 179 |
+
redrawn with probability redraw, else kept (world = 1: shifted by -delta yaw like a fixed world point)."""
|
| 180 |
+
seqs = []
|
| 181 |
+
for w in (wrong + behind) * 20:
|
| 182 |
+
hs = [rng.choice(pool)]
|
| 183 |
+
for d in w["turn"]:
|
| 184 |
+
hs.append(rng.choice(pool) if rng.random() < redraw else hs[-1] - world * d)
|
| 185 |
+
seqs.append((w["turn"], hs))
|
| 186 |
+
return seqs
|
| 187 |
+
|
| 188 |
+
def real_sim(redraw):
|
| 189 |
+
"""True target angle + an error from the 300 positives (plan 2.4 point 1), redrawn with probability redraw."""
|
| 190 |
+
seqs = []
|
| 191 |
+
for w in real * 20:
|
| 192 |
+
e, hs = rng.choice(errs), []
|
| 193 |
+
for _, h, _ in w["geo"]:
|
| 194 |
+
e = rng.choice(errs) if hs and rng.random() < redraw else e
|
| 195 |
+
hs.append(h + e)
|
| 196 |
+
seqs.append((w["turn"], hs))
|
| 197 |
+
return seqs
|
| 198 |
+
|
| 199 |
+
temporal = ["| 窗口 | 窗口数 | 帧给框 | 消失(上一帧有框,这一帧没有) | 相邻两帧都有框的步 | \\|Δyaw\\| 中位数 | 斜率 | 截距 | "
|
| 200 |
+
"残差 RMS | 随世界点(\\|Δh_box + Δyaw\\| ≤ 5°) | 画面不动(\\|Δh_box\\| ≤ 5°,\\|Δyaw\\| > 10° 的步) | "
|
| 201 |
+
"跳(两者都偏 >10°) | \\|Δh_box\\| 中位数 |",
|
| 202 |
+
"|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
|
| 203 |
+
row("真目标:几何真值 h(参照)", [(w["turn"], [g[1] for g in w["geo"]]) for w in real]),
|
| 204 |
+
row("真目标:框", boxes(real)), row("假框:错名(同一批窗口)", boxes(wrong)),
|
| 205 |
+
row("假框:背后", boxes(behind)), row("假框合计", boxes(wrong + behind)),
|
| 206 |
+
row("模拟假框 (1) 每步独立", fake_sim(1, 0)), row("模拟假框 (2) 固定世界点", fake_sim(0, 1)),
|
| 207 |
+
row("模拟假框 (3) 画面里不动", fake_sim(0, 0)), row("模拟假框 (3),每步以 0.5 概率重抽", fake_sim(0.5, 0)),
|
| 208 |
+
row("模拟真框:每步独立误差(2.4 第 1 点原样)", real_sim(1)), row("模拟真框:误差保持,每步以 0.5 概率重抽", real_sim(0.5))]
|
| 209 |
+
(args.out / "summary.md").write_text(
|
| 210 |
+
"基座 Qwen3.5-9B(无 LoRA),思考关闭,温度 0,取前 20 个 token 的 logprob。\n\n"
|
| 211 |
+
"## 1b 存在性分数\n\n"
|
| 212 |
+
"同第 1 步的 500 条(runs/diag_20261013/perception_direction/items.jsonl:几何正例 300,背后负例 100,错名负例 100)。"
|
| 213 |
+
f"s_yes:B 的问题 \"{YESNO.format('<name>')}\",max_tokens 1,P(yes)/(P(yes)+P(no)),大小写和前导空格变体合计。"
|
| 214 |
+
"s_box:第 1 步的定位问题,max_tokens 48(2.2 里控制器的调用)。回答只有两种开头:代码块(```、json、换行、[、换行、制表符、{\"…)"
|
| 215 |
+
"或拒答(There are no objects belonging to …);进了代码块后列表 token 是 [,P(\"[]\") 约为 0,"
|
| 216 |
+
"所以空和非空在第一个 token 上决定:s_box = P(第一个 token 是 ```)。人工标注取第 1 步 summary.md 接触表核对:"
|
| 217 |
+
"48 个正例里序号 11、17、19、20、27、29、33、36、43、45、47 看不见,其余 37 个可见。\n\n"
|
| 218 |
+
+ "\n".join(aucs) + "\n\n" + "\n".join(scores) + "\n\n"
|
| 219 |
+
"## 框的时间行为\n\n"
|
| 220 |
+
"GRPO v2 第 1 到 5 轮 rollout,5 帧连续窗口,4 步每步 |Δyaw| ≥ 5°,同一回合同一目标的窗口不重叠。"
|
| 221 |
+
f"真目标:目标在全部 5 帧都在几何视场内(|h| ≤ {HALF_HFOV:.1f}°,|v| ≤ 35°)、距离 3 到 16 格,全部取用 {len(real)} 个"
|
| 222 |
+
f"({len({w['scene'] for w in real})} 个场景、{len({w['ep'] for w in real})} 个回合,这批 rollout 里满足条件的只有这么多);"
|
| 223 |
+
"错名:同一批窗口问别的场景的目标名(第 1 步的规则);背后:目标在全部 5 帧都在 3 到 16 格、|h| ≥ 120°,按第 1 步的 spread 取"
|
| 224 |
+
f" {len(behind)} 个({len({w['scene'] for w in behind})} 个场景)。定位问题同 1b(max_tokens 48),取第一个框中心换算水平角 h_box。"
|
| 225 |
+
"每步把 Δh_box 对 −Δyaw(固定世界点在画面里的水平角变化)做最小二乘:固定世界点斜率 1,画面里不动斜率 0。"
|
| 226 |
+
"参照行用目标点的几何 h 代替框,差别来自走路的视差。跳:框中心变化离固定世界点的预测和画面不动都超过 10°。"
|
| 227 |
+
"模拟行在观测窗口的转头序列上各重复 20 次(seed 0):假框的位置从观测到的假框 h_box 里抽,真框 = 目标几何 h + 从 1b 的 300 个正例里抽的"
|
| 228 |
+
"框误差;“重抽”指这一步换一个新抽样,否则沿用上一步(假框 (2) 再按 −Δyaw 平移)。\n\n"
|
| 229 |
+
+ "\n".join(temporal) + "\n\n"
|
| 230 |
+
"逐条结果:items.jsonl(1b 的 500 条),windows.jsonl(每个窗口 5 帧的回答、框、h_box、s_box)。\n")
|
| 231 |
+
print((args.out / "summary.md").read_text(), flush=True)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
if __name__ == "__main__":
|
| 235 |
+
main()
|
code/bench/splits_find25.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"eval": [
|
| 3 |
+
"0046",
|
| 4 |
+
"0048",
|
| 5 |
+
"0050",
|
| 6 |
+
"0062",
|
| 7 |
+
"0066",
|
| 8 |
+
"0084",
|
| 9 |
+
"0116",
|
| 10 |
+
"0147",
|
| 11 |
+
"0148",
|
| 12 |
+
"0158",
|
| 13 |
+
"0165",
|
| 14 |
+
"0188",
|
| 15 |
+
"0198",
|
| 16 |
+
"0208",
|
| 17 |
+
"0233",
|
| 18 |
+
"0237",
|
| 19 |
+
"0243",
|
| 20 |
+
"0261",
|
| 21 |
+
"0310",
|
| 22 |
+
"0313",
|
| 23 |
+
"0329",
|
| 24 |
+
"0348",
|
| 25 |
+
"0356",
|
| 26 |
+
"0377",
|
| 27 |
+
"0386"
|
| 28 |
+
],
|
| 29 |
+
"note": "First 25 tasks of splits_find.json eval in its fixed sorted order (hop mix 10/7/5/3 vs 20/15/10/5 in the 50). Fixed before any 25-task result; chosen 2026-10-11 so the running 20-frame eval (which dispatches in this order) can stop at 25."
|
| 30 |
+
}
|
code/train/bt_train.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LoRA training of the BT policy (EXPERIMENT_LOG.md 2026-10-17): Qwen3.5-9B reads one frame and BT_PROMPT with the
|
| 2 |
+
target name and writes the behavior code (one digit token per FSQ dimension). Runs with torchrun in .train-venv.
|
| 3 |
+
|
| 4 |
+
Rows (jsonl): image (PNG path), name, code (digits); ppo rows also adv and vllm_logp, and memory (the two 0/1 strings
|
| 5 |
+
of the decision's prompt) when the rollouts ran with memory. A row is the chat template of [image, bt_prompt(name,
|
| 6 |
+
memory)] with the generation prompt (thinking off), as vLLM renders BTPolicy's request, followed by the code and
|
| 7 |
+
<|im_end|>; batches are right-padded and the scored tokens gathered per row.
|
| 8 |
+
- sft: cross entropy on the code tokens and <|im_end|>, new LoRA (r 16, alpha 32, train_iql.py's modules), AdamW,
|
| 9 |
+
10-update warmup then cosine; per-digit and whole-code (all digits argmax) accuracy on --valid after each epoch.
|
| 10 |
+
Saves the adapter to --out (vLLM-loadable).
|
| 11 |
+
- ppo: continues --adapter (and its optimizer.pt if it has one, i.e. a PPO round's), loads --ref (the SFT adapter) as
|
| 12 |
+
a second adapter; old and ref log-probs of the code tokens computed once with no_grad, then --epochs of PPO:
|
| 13 |
+
loss = -min(ratio A, clip(ratio, 0.8, 1.2) A) + beta * sum over the code tokens of k3 = exp(ref - lp) - (ref - lp) - 1,
|
| 14 |
+
ratio = exp(sum lp - sum old). Saves the adapter, optimizer.pt and stats.json (pg_loss, kl, clip_frac of the last
|
| 15 |
+
epoch; logp_gap = mean of old - vllm_logp, logp_gap_abs) to --out.
|
| 16 |
+
Usage: CUDA_VISIBLE_DEVICES=6,7 .train-venv/bin/python -m torch.distributed.run --nproc_per_node=2 train/bt_train.py
|
| 17 |
+
sft --data TRAIN.jsonl --valid VALID.jsonl --out DIR [--epochs 1 --lr 1e-4 --max-updates N]
|
| 18 |
+
... train/bt_train.py ppo --data ROUND/train.jsonl --adapter ADAPTER --ref SFT --out DIR
|
| 19 |
+
"""
|
| 20 |
+
import argparse
|
| 21 |
+
import json
|
| 22 |
+
import math
|
| 23 |
+
import os
|
| 24 |
+
import random
|
| 25 |
+
import time
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import torch
|
| 29 |
+
import torch.distributed as dist
|
| 30 |
+
from peft import LoraConfig, PeftModel, get_peft_model
|
| 31 |
+
from PIL import Image
|
| 32 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 33 |
+
from torch.utils.data import DataLoader, DistributedSampler
|
| 34 |
+
from transformers import AutoProcessor, Qwen3_5ForConditionalGeneration
|
| 35 |
+
|
| 36 |
+
from ctrl_policy import bt_prompt
|
| 37 |
+
|
| 38 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 39 |
+
MODULES = (r"model.language_model.*\.(q_proj|k_proj|v_proj|o_proj|in_proj_qkv|in_proj_z|in_proj_b|in_proj_a|out_proj"
|
| 40 |
+
r"|gate_proj|up_proj|down_proj)$") # train_iql.py
|
| 41 |
+
END = "<|im_end|>"
|
| 42 |
+
CLIP, BETA = 0.2, 0.02
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def log(message):
|
| 46 |
+
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}", flush=True)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class Collate:
|
| 50 |
+
def __init__(self, processor):
|
| 51 |
+
self.processor = processor
|
| 52 |
+
|
| 53 |
+
def __call__(self, rows):
|
| 54 |
+
p = self.processor
|
| 55 |
+
texts = [p.apply_chat_template([{"role": "user", "content": [{"type": "image"}, {"type": "text",
|
| 56 |
+
"text": bt_prompt(r["name"], r.get("memory"))}]}], tokenize=False,
|
| 57 |
+
add_generation_prompt=True, enable_thinking=False) + r["code"] + END
|
| 58 |
+
for r in rows]
|
| 59 |
+
inputs = p(text=texts, images=[Image.open(r["image"]).convert("RGB") for r in rows], padding=True,
|
| 60 |
+
padding_side="right", return_tensors="pt")
|
| 61 |
+
n = len(rows[0]["code"])
|
| 62 |
+
ends = inputs["attention_mask"].sum(1) # sequence lengths: the last token, <|im_end|>, is at end - 1
|
| 63 |
+
targets = torch.stack([inputs["input_ids"][i, e - n - 1:e] for i, e in enumerate(ends)]) # code + im_end
|
| 64 |
+
extra = {k: torch.tensor([r[k] for r in rows], dtype=torch.float32) for k in ("adv", "vllm_logp", "index")
|
| 65 |
+
if k in rows[0]}
|
| 66 |
+
return {"inputs": dict(inputs), "ends": ends, "targets": targets, **extra}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def token_logprobs(model, batch, device):
|
| 70 |
+
"""(B, n + 1) log-probs of the code tokens and <|im_end|>, and the logits' argmax at those positions."""
|
| 71 |
+
inputs = {k: v.to(device) for k, v in batch["inputs"].items()}
|
| 72 |
+
ends, k = batch["ends"], batch["targets"].shape[1]
|
| 73 |
+
lo, hi = int(ends.min()) - k - 1, int(ends.max()) - 1 # logits at position j predict token j + 1
|
| 74 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 75 |
+
logits = model(**inputs, logits_to_keep=torch.arange(lo, hi, device=device), use_cache=False).logits
|
| 76 |
+
rows = torch.stack([logits[i, e - k - 1 - lo:e - 1 - lo] for i, e in enumerate(ends.tolist())]).float()
|
| 77 |
+
lp = rows.log_softmax(-1)
|
| 78 |
+
targets = batch["targets"].to(device)
|
| 79 |
+
return lp.gather(-1, targets[..., None])[..., 0], rows.argmax(-1) == targets
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def load_model(local):
|
| 83 |
+
model = Qwen3_5ForConditionalGeneration.from_pretrained(
|
| 84 |
+
ROOT / "models/Qwen3.5-9B", dtype=torch.bfloat16, device_map={"": local}, attn_implementation="sdpa",
|
| 85 |
+
local_files_only=True, use_kernels=True)
|
| 86 |
+
model.requires_grad_(False)
|
| 87 |
+
return model
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def train_setup(model):
|
| 91 |
+
model.enable_input_require_grads()
|
| 92 |
+
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
|
| 93 |
+
model.config.use_cache = False
|
| 94 |
+
model.config.text_config.use_cache = False
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def loader(rows, collate, batch, rank, world, seed, shuffle=True):
|
| 98 |
+
sampler = DistributedSampler(rows, num_replicas=world, rank=rank, seed=seed, shuffle=shuffle)
|
| 99 |
+
return sampler, DataLoader(rows, batch_size=batch, sampler=sampler, collate_fn=collate, num_workers=4,
|
| 100 |
+
persistent_workers=True, prefetch_factor=2)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@torch.no_grad()
|
| 104 |
+
def accuracy(model, rows, collate, batch, rank, world, device):
|
| 105 |
+
"""Per-digit and whole-code accuracy (teacher-forced argmax) of the code tokens on rows."""
|
| 106 |
+
model.eval()
|
| 107 |
+
totals = torch.zeros(3, device=device)
|
| 108 |
+
for b in DataLoader(rows[rank::world], batch_size=batch, collate_fn=collate, num_workers=4):
|
| 109 |
+
_, right = token_logprobs(model, b, device)
|
| 110 |
+
right = right[:, :-1]
|
| 111 |
+
totals += torch.tensor([right.sum(), right.all(1).sum(), len(right)], device=device, dtype=torch.float32)
|
| 112 |
+
dist.all_reduce(totals)
|
| 113 |
+
model.train()
|
| 114 |
+
return {"digit_acc": round((totals[0] / totals[2] / right.shape[1]).item(), 4),
|
| 115 |
+
"code_acc": round((totals[1] / totals[2]).item(), 4), "rows": int(totals[2])}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def sft(args, rank, world, local, device):
|
| 119 |
+
rows = [json.loads(l) for l in args.data.read_text().splitlines()]
|
| 120 |
+
valid = [json.loads(l) for l in args.valid.read_text().splitlines()]
|
| 121 |
+
processor = AutoProcessor.from_pretrained(ROOT / "models/Qwen3.5-9B", local_files_only=True)
|
| 122 |
+
collate = Collate(processor)
|
| 123 |
+
torch.manual_seed(args.seed)
|
| 124 |
+
model = get_peft_model(load_model(local), LoraConfig(r=16, lora_alpha=32, lora_dropout=0.0,
|
| 125 |
+
task_type="CAUSAL_LM", target_modules=MODULES))
|
| 126 |
+
train_setup(model)
|
| 127 |
+
ddp = DistributedDataParallel(model, device_ids=[local])
|
| 128 |
+
sampler, data = loader(rows, collate, args.batch, rank, world, args.seed)
|
| 129 |
+
opt = torch.optim.AdamW((p for p in model.parameters() if p.requires_grad), lr=args.lr)
|
| 130 |
+
total = min(args.max_updates or 10**9, len(data) * args.epochs)
|
| 131 |
+
if rank == 0:
|
| 132 |
+
log(f"SFT rows {len(rows)} valid {len(valid)}, {world} GPUs x batch {args.batch}, updates {total}")
|
| 133 |
+
log("VALID before " + json.dumps(accuracy(model, valid, collate, args.batch, rank, world, device)))
|
| 134 |
+
else:
|
| 135 |
+
accuracy(model, valid, collate, args.batch, rank, world, device)
|
| 136 |
+
update, start = 0, time.monotonic()
|
| 137 |
+
for epoch in range(args.epochs):
|
| 138 |
+
sampler.set_epoch(epoch)
|
| 139 |
+
for b in data:
|
| 140 |
+
lp, _ = token_logprobs(ddp, b, device)
|
| 141 |
+
loss = -lp.mean()
|
| 142 |
+
opt.zero_grad(set_to_none=True)
|
| 143 |
+
loss.backward()
|
| 144 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 145 |
+
for g in opt.param_groups:
|
| 146 |
+
g["lr"] = args.lr * min(1, (update + 1) / 10) * 0.5 * (1 + math.cos(math.pi * update / total))
|
| 147 |
+
opt.step()
|
| 148 |
+
update += 1
|
| 149 |
+
if rank == 0 and (update <= 5 or update % 50 == 0):
|
| 150 |
+
s = time.monotonic() - start
|
| 151 |
+
log(f"SFT update {update}/{total} loss {loss.item():.4f} {s / update:.2f} s/update "
|
| 152 |
+
f"eta {(total - update) * s / update / 60:.0f} min peak {torch.cuda.max_memory_allocated() / 2**30:.1f} GiB")
|
| 153 |
+
if update >= total:
|
| 154 |
+
break
|
| 155 |
+
acc = accuracy(model, valid, collate, args.batch, rank, world, device)
|
| 156 |
+
if rank == 0:
|
| 157 |
+
log(f"VALID epoch {epoch + 1} " + json.dumps(acc))
|
| 158 |
+
model.save_pretrained(args.out)
|
| 159 |
+
(args.out / "valid.json").write_text(json.dumps({"epoch": epoch + 1, "updates": update, **acc}))
|
| 160 |
+
if update >= total:
|
| 161 |
+
break
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def ppo(args, rank, world, local, device):
|
| 165 |
+
rows = [{**json.loads(l), "index": i} for i, l in enumerate(args.data.read_text().splitlines())]
|
| 166 |
+
processor = AutoProcessor.from_pretrained(ROOT / "models/Qwen3.5-9B", local_files_only=True)
|
| 167 |
+
collate = Collate(processor)
|
| 168 |
+
torch.manual_seed(args.seed)
|
| 169 |
+
model = PeftModel.from_pretrained(load_model(local), args.adapter, is_trainable=True)
|
| 170 |
+
model.load_adapter(args.ref, adapter_name="ref")
|
| 171 |
+
n = len(rows[0]["code"])
|
| 172 |
+
lp_of = {}
|
| 173 |
+
for name in ("ref", "default"): # once per round, no_grad: the reference and the old policy
|
| 174 |
+
model.set_adapter(name)
|
| 175 |
+
model.eval()
|
| 176 |
+
mine = {}
|
| 177 |
+
with torch.no_grad():
|
| 178 |
+
for b in DataLoader(rows[rank::world], batch_size=2 * args.batch, collate_fn=collate, num_workers=4):
|
| 179 |
+
lp, _ = token_logprobs(model, b, device)
|
| 180 |
+
mine.update(zip(b["index"].long().tolist(), lp[:, :n].cpu()))
|
| 181 |
+
gathered = [None] * world
|
| 182 |
+
dist.all_gather_object(gathered, mine)
|
| 183 |
+
lp_of[name] = torch.stack([{k: v for g in gathered for k, v in g.items()}[i] for i in range(len(rows))])
|
| 184 |
+
model.set_adapter("default")
|
| 185 |
+
for name, p in model.named_parameters():
|
| 186 |
+
p.requires_grad = "lora_" in name and ".default." in name
|
| 187 |
+
model.train()
|
| 188 |
+
train_setup(model)
|
| 189 |
+
ddp = DistributedDataParallel(model, device_ids=[local])
|
| 190 |
+
params = [p for p in model.parameters() if p.requires_grad]
|
| 191 |
+
opt = torch.optim.AdamW(params, lr=args.lr)
|
| 192 |
+
if (args.adapter / "optimizer.pt").exists():
|
| 193 |
+
opt.load_state_dict(torch.load(args.adapter / "optimizer.pt", map_location=device))
|
| 194 |
+
for g in opt.param_groups:
|
| 195 |
+
g["lr"] = args.lr
|
| 196 |
+
sampler, data = loader(rows, collate, args.batch, rank, world, args.seed)
|
| 197 |
+
old_all, ref_all = lp_of["default"].to(device), lp_of["ref"].to(device)
|
| 198 |
+
vllm = torch.tensor([r["vllm_logp"] for r in rows], device=device)
|
| 199 |
+
gap = old_all.sum(1) - vllm
|
| 200 |
+
start = time.monotonic()
|
| 201 |
+
for epoch in range(args.epochs):
|
| 202 |
+
sampler.set_epoch(epoch)
|
| 203 |
+
stats = []
|
| 204 |
+
for step, b in enumerate(data):
|
| 205 |
+
idx = b["index"].long().to(device)
|
| 206 |
+
lp, _ = token_logprobs(ddp, b, device)
|
| 207 |
+
lp = lp[:, :n]
|
| 208 |
+
adv = b["adv"].to(device)
|
| 209 |
+
ratio = (lp.sum(1) - old_all[idx].sum(1)).exp()
|
| 210 |
+
pg = -torch.min(ratio * adv, ratio.clamp(1 - CLIP, 1 + CLIP) * adv)
|
| 211 |
+
d = ref_all[idx] - lp
|
| 212 |
+
kl = (d.exp() - d - 1).sum(1)
|
| 213 |
+
loss = (pg + BETA * kl).mean() / args.accumulation
|
| 214 |
+
sync = (step + 1) % args.accumulation == 0 or step + 1 == len(data)
|
| 215 |
+
if sync:
|
| 216 |
+
loss.backward()
|
| 217 |
+
torch.nn.utils.clip_grad_norm_(params, 1.0)
|
| 218 |
+
opt.step()
|
| 219 |
+
opt.zero_grad(set_to_none=True)
|
| 220 |
+
else:
|
| 221 |
+
with ddp.no_sync():
|
| 222 |
+
loss.backward()
|
| 223 |
+
stats.append([pg.mean().item(), kl.mean().item(), ((ratio - 1).abs() > CLIP).float().mean().item()])
|
| 224 |
+
if rank == 0:
|
| 225 |
+
log(f"PPO epoch {epoch + 1} pg {sum(s[0] for s in stats) / len(stats):+.4f} "
|
| 226 |
+
f"kl {sum(s[1] for s in stats) / len(stats):.4f} {time.monotonic() - start:.0f} s")
|
| 227 |
+
totals = torch.tensor(stats, device=device).mean(0)
|
| 228 |
+
dist.all_reduce(totals)
|
| 229 |
+
totals /= world
|
| 230 |
+
if rank == 0:
|
| 231 |
+
args.out.mkdir(parents=True, exist_ok=True)
|
| 232 |
+
model.save_pretrained(args.out, selected_adapters=["default"])
|
| 233 |
+
torch.save(opt.state_dict(), args.out / "optimizer.pt")
|
| 234 |
+
result = {"pg_loss": round(totals[0].item(), 4), "kl": round(totals[1].item(), 4),
|
| 235 |
+
"clip_frac": round(totals[2].item(), 4), "logp_gap": round(gap.mean().item(), 4),
|
| 236 |
+
"logp_gap_abs": round(gap.abs().mean().item(), 4), "rows": len(rows),
|
| 237 |
+
"train_s": round(time.monotonic() - start)}
|
| 238 |
+
(args.out / "stats.json").write_text(json.dumps(result))
|
| 239 |
+
log("PPO_DONE " + json.dumps(result))
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def main():
|
| 243 |
+
ap = argparse.ArgumentParser()
|
| 244 |
+
ap.add_argument("mode", choices=["sft", "ppo"])
|
| 245 |
+
ap.add_argument("--data", type=Path, required=True)
|
| 246 |
+
ap.add_argument("--valid", type=Path)
|
| 247 |
+
ap.add_argument("--adapter", type=Path)
|
| 248 |
+
ap.add_argument("--ref", type=Path)
|
| 249 |
+
ap.add_argument("--out", type=Path, required=True)
|
| 250 |
+
ap.add_argument("--epochs", type=int)
|
| 251 |
+
ap.add_argument("--lr", type=float)
|
| 252 |
+
ap.add_argument("--batch", type=int, default=16, help="rows per GPU per forward")
|
| 253 |
+
ap.add_argument("--accumulation", type=int, default=2, help="ppo: forwards per update (64 rows on 2 GPUs)")
|
| 254 |
+
ap.add_argument("--max-updates", type=int)
|
| 255 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 256 |
+
args = ap.parse_args()
|
| 257 |
+
rank, world, local = int(os.environ["RANK"]), int(os.environ["WORLD_SIZE"]), int(os.environ["LOCAL_RANK"])
|
| 258 |
+
torch.set_num_threads(4)
|
| 259 |
+
random.seed(args.seed)
|
| 260 |
+
torch.cuda.set_device(local)
|
| 261 |
+
device = torch.device("cuda", local)
|
| 262 |
+
dist.init_process_group("nccl", device_id=device)
|
| 263 |
+
if args.mode == "sft":
|
| 264 |
+
args.epochs, args.lr = args.epochs or 1, args.lr or 1e-4
|
| 265 |
+
sft(args, rank, world, local, device)
|
| 266 |
+
else:
|
| 267 |
+
args.epochs, args.lr = args.epochs or 2, args.lr or 3e-6
|
| 268 |
+
ppo(args, rank, world, local, device)
|
| 269 |
+
dist.barrier()
|
| 270 |
+
dist.destroy_process_group()
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
if __name__ == "__main__":
|
| 274 |
+
main()
|
code/train/ctrl_policy.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Controller policy of the decoupled plan (PLAN_DECOUPLED_20261013.md sections 3 and 5): a frozen perception gives
|
| 2 |
+
(p, h, v) of the current target, a small MLP on the last 20 steps picks the MineRL action. Runs in the eval venv
|
| 3 |
+
(numpy, openai; no torch). MineExplorer/eval_benchmark.py (MC_POLICY) and the GRPO rollouts call Policy.act once per
|
| 4 |
+
step; train/ctrl.py (torch) trains the MLP and exports it as npz: w0, b0, w1, b1, w2, b2 with weights as (in, out).
|
| 5 |
+
|
| 6 |
+
Actions, three heads sampled independently:
|
| 7 |
+
- movement, 15 combinations (MOVES); sprint only with forward, else MineExplorer's validation swaps in a no-op. A VPT
|
| 8 |
+
key bitmask maps to its class by cancelling opposite keys (as the game does), then dropping sneak, sprint and jump
|
| 9 |
+
in turn until the combination is one of MOVES;
|
| 10 |
+
- camera yaw, 17 bins (YAWS: 0, +-0.5 ... +-64 deg, > 0 turns right); camera pitch, 13 bins (PITCHES: 0, +-0.5 ...
|
| 11 |
+
+-16 deg, > 0 looks down). A recorded delta maps to the nearest bin on a log scale, 0 only for an exact 0.
|
| 12 |
+
attack, use, inventory, hotbar, ESC and the remaining keys are always 0.
|
| 13 |
+
Input: 5 numbers per step, the last 20 steps oldest first (100): p, h/HALF_HFOV, v/HALF_VFOV of the current target
|
| 14 |
+
(bench/diag_perception.py view: h > 0 right of the view center, v > 0 above it) and the policy's own previous camera
|
| 15 |
+
action yaw/64, pitch/16. Steps before the episode start are 0; when the target changes, the target part of the
|
| 16 |
+
earlier steps is set to 0. A controller whose w0 has 116 rows also gets 16 memory inputs after the window (memory():
|
| 17 |
+
for the 8 directions yaw + 45 i, i = 0..7, whether the direction's world-yaw sector was a view sector and whether the
|
| 18 |
+
4x4 cell 4 blocks along it was stood in, over the episode's poses up to the current one).
|
| 19 |
+
Perception:
|
| 20 |
+
- gt: p = 1 and the exact h, v when the target is in the geometric field of view (|h| <= 51.2, |v| <= 35), else 0;
|
| 21 |
+
- vlm: two questions, sent at the same time, on the frame the eval client sends (320x180 PNG), thinking off,
|
| 22 |
+
temperature 0, worded as measured: step 1's grounding question (bench/diag_perception_direction.py GROUND,
|
| 23 |
+
max_tokens 48) with the answer started as START, whose first box's center through the pinhole model gives h, v
|
| 24 |
+
(0 without a box), and step 1b's presence score s_yes (bench/diag_perception_presence.py YESNO, max_tokens 1:
|
| 25 |
+
P(yes) / (P(yes) + P(no)) from the top 20 logprobs) as p. Asked on the episode's first step, when the target name
|
| 26 |
+
changes and PERCEIVE_EVERY steps after the last call; the steps in between carry the last answer over (carry()).
|
| 27 |
+
Current target: the first milestone in order that has rules, was not satisfied at spawn and is not completed (the
|
| 28 |
+
checker marks it); its name is the milestone task "find X" -> X with underscores as spaces.
|
| 29 |
+
Per-step log (Policy.act): x (the input, rounded to 4 decimals; the forward pass uses exactly these values), target
|
| 30 |
+
(milestone index), bins [move, yaw, pitch], logp (sum over the three heads), perception (vlm: reply, box, p on the
|
| 31 |
+
steps that asked, None on the carried ones; gt: None), held (steps since the last VLM call: 0 on the steps that asked,
|
| 32 |
+
and always for gt), truth of the current target (seen = in the geometric field of view, dist from the feet as the
|
| 33 |
+
milestone check, h, v; the checker's yaw error is -h).
|
| 34 |
+
"""
|
| 35 |
+
from collections import deque
|
| 36 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 37 |
+
import math
|
| 38 |
+
import time
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
import sys
|
| 41 |
+
|
| 42 |
+
import numpy as np
|
| 43 |
+
from openai import OpenAI
|
| 44 |
+
|
| 45 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 46 |
+
sys.path.append(str(ROOT / "bench"))
|
| 47 |
+
from diag_perception import EYE, HALF_VFOV, view # noqa: E402
|
| 48 |
+
from diag_perception_direction import BOX, GROUND, TAN_HALF_HFOV # noqa: E402
|
| 49 |
+
from diag_perception_presence import YESNO # noqa: E402
|
| 50 |
+
|
| 51 |
+
WINDOW = 20
|
| 52 |
+
START = '```json\n[\n\t{"bbox_2d": [' # the start of the grounding answer (ground())
|
| 53 |
+
PERCEIVE_EVERY = 3 # vlm: steps from one perception call to the next (find25 evals and GRPO rollouts)
|
| 54 |
+
HALF_HFOV = math.degrees(math.atan(TAN_HALF_HFOV)) # 51.2
|
| 55 |
+
MOVES = [(), ("forward",), ("forward", "jump"), ("forward", "right"), ("forward", "left"), ("forward", "sprint"),
|
| 56 |
+
("forward", "jump", "sprint"), ("back",), ("right",), ("left",), ("back", "left"), ("back", "right"),
|
| 57 |
+
("forward", "right", "jump"), ("jump",), ("forward", "left", "jump")]
|
| 58 |
+
YAWS = np.array([-64, -32, -16, -8, -4, -2, -1, -0.5, 0, 0.5, 1, 2, 4, 8, 16, 32, 64])
|
| 59 |
+
PITCHES = np.array([-16, -8, -4, -2, -1, -0.5, 0, 0.5, 1, 2, 4, 8, 16])
|
| 60 |
+
CAM_SCALE = np.array([16.0, 64.0]) # camera [pitch, yaw] -> input
|
| 61 |
+
KEYS = {"forward": 1, "back": 2, "left": 4, "right": 8, "jump": 16, "sprint": 32, "sneak": 64} # VPT key bitmask
|
| 62 |
+
NOOP = {"ESC": 0, "attack": 0, "back": 0, "forward": 0, "left": 0, "right": 0, "jump": 0, "camera": [0.0, 0.0],
|
| 63 |
+
"drop": 0, **{f"hotbar.{i}": 0 for i in range(1, 10)}, "inventory": 0, "pickItem": 0, "sneak": 0,
|
| 64 |
+
"sprint": 0, "swapHands": 0, "use": 0}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def move_class(keys):
|
| 68 |
+
"""MOVES index of a VPT key bitmask."""
|
| 69 |
+
masks = [sum(KEYS[k] for k in m) for m in MOVES]
|
| 70 |
+
for pair in (3, 12): # forward+back, left+right
|
| 71 |
+
if keys & pair == pair:
|
| 72 |
+
keys &= ~pair
|
| 73 |
+
for drop in (0, 64, 32, 16):
|
| 74 |
+
keys &= ~drop
|
| 75 |
+
if keys in masks:
|
| 76 |
+
return masks.index(keys)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
MOVE_OF_KEYS = np.array([move_class(k) for k in range(128)])
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def cam_bin(deg, bins):
|
| 83 |
+
"""Bin index (into YAWS or PITCHES) of camera deltas in degrees."""
|
| 84 |
+
j = np.clip(np.round(np.log2(np.maximum(np.abs(deg), 1e-9) / 0.5)), 0, len(bins) // 2 - 1)
|
| 85 |
+
return (len(bins) // 2 + np.sign(deg) * (j + 1)).astype(int)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def gt_features(h, v):
|
| 89 |
+
"""GT perception (..., 3): p, h/HALF_HFOV, v/HALF_VFOV in the geometric field of view, else 0."""
|
| 90 |
+
seen = (np.abs(h) <= HALF_HFOV) & (np.abs(v) <= HALF_VFOV)
|
| 91 |
+
return np.stack([seen, h / HALF_HFOV * seen, v / HALF_VFOV * seen], -1).astype(float)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def rows(target, prev_cam):
|
| 95 |
+
"""Window steps (..., 5) from target features (..., 3) and the previous camera action [pitch, yaw] (..., 2)."""
|
| 96 |
+
return np.concatenate([target, (prev_cam / CAM_SCALE)[..., ::-1]], -1)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def sector(yaw):
|
| 100 |
+
"""World-yaw sector of a yaw in degrees as logged (unwrapped too): 8 sectors of 45 deg, sector 0 = -22.5..22.5."""
|
| 101 |
+
return int(((yaw + 22.5) % 360) // 45)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def cell(x, z):
|
| 105 |
+
"""4x4 XZ cell of a position, as bench/find_metrics.py."""
|
| 106 |
+
return math.floor(x / 4), math.floor(z / 4)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def memory(sectors, cells, pose):
|
| 110 |
+
"""The 16 memory inputs at pose: for the directions t = yaw + 45 i, i = 0..7 (> 0 turns right), 1 if sector(t) is
|
| 111 |
+
in sectors, then 1 if the cell 4 blocks along t (horizontal direction dx = -sin t, dz = cos t) is in cells."""
|
| 112 |
+
yaws = [pose["yaw"] + 45 * i for i in range(8)]
|
| 113 |
+
return np.array([sector(t) in sectors for t in yaws]
|
| 114 |
+
+ [cell(pose["x"] - 4 * math.sin(math.radians(t)), pose["z"] + 4 * math.cos(math.radians(t)))
|
| 115 |
+
in cells for t in yaws], dtype=float)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def logits(w, x):
|
| 119 |
+
"""(move, yaw, pitch) logits for inputs x (..., 100, or 116 with memory); w = the exported npz."""
|
| 120 |
+
z = np.maximum(x @ w["w0"] + w["b0"], 0)
|
| 121 |
+
z = np.maximum(z @ w["w1"] + w["b1"], 0)
|
| 122 |
+
return np.split(z @ w["w2"] + w["b2"], [len(MOVES), len(MOVES) + len(YAWS)], -1)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def log_softmax(z):
|
| 126 |
+
z = z - z.max(-1, keepdims=True)
|
| 127 |
+
return z - np.log(np.exp(z).sum(-1, keepdims=True))
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def action(move, yaw, pitch):
|
| 131 |
+
"""MineRL action dict of the three bins."""
|
| 132 |
+
a = dict(NOOP, camera=[float(PITCHES[pitch]), float(YAWS[yaw])])
|
| 133 |
+
a.update({key: 1 for key in MOVES[move]})
|
| 134 |
+
return a
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def target_name(task):
|
| 138 |
+
"""Name asked about a milestone task: "find X" -> X with underscores as spaces (diagnoses B, C and step 1)."""
|
| 139 |
+
return task[5:].removeprefix("a ").replace("_", " ")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def current_target(checker, presatisfied):
|
| 143 |
+
"""(milestone index, absolute target [x, y, z], name) of the current target."""
|
| 144 |
+
for i, m in enumerate(checker._milestones):
|
| 145 |
+
if m.get("rules") and m["milestone_id"] not in presatisfied and not checker._completed[i]:
|
| 146 |
+
params = m["rules"][0]["params"]
|
| 147 |
+
target = params["target"]
|
| 148 |
+
if params.get("coordinate_frame") == "spawn_relative":
|
| 149 |
+
o = checker._spawn_pos
|
| 150 |
+
target = [target[0] + o["x"], target[1] + o["y"], target[2] + o["z"]]
|
| 151 |
+
return i, target, target_name(m["task"])
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def box_angles(box):
|
| 155 |
+
"""(h, v) of a box [x1, y1, x2, y2] in Qwen's relative 0-1000 coordinates: its center through the pinhole model
|
| 156 |
+
(exact at pitch 0), as step 1."""
|
| 157 |
+
x1, y1, x2, y2 = box
|
| 158 |
+
return (math.degrees(math.atan(((x1 + x2) / 1000 - 1) * TAN_HALF_HFOV)),
|
| 159 |
+
math.degrees(math.atan((1 - (y1 + y2) / 1000) * math.tan(math.radians(HALF_VFOV)))))
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def chat(client, model, frame, prompt, max_tokens, start=None, **kwargs):
|
| 163 |
+
"""One question about one frame (base64 PNG), thinking off, temperature 0, as steps 1 and 1b asked it. start: the
|
| 164 |
+
beginning of the answer, which the model continues (the reply is the continuation, its stop string included)."""
|
| 165 |
+
messages = [{"role": "user", "content": [
|
| 166 |
+
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + frame}},
|
| 167 |
+
{"type": "text", "text": prompt}]}]
|
| 168 |
+
extra = {"chat_template_kwargs": {"enable_thinking": False}}
|
| 169 |
+
if start:
|
| 170 |
+
messages.append({"role": "assistant", "content": start})
|
| 171 |
+
extra.update(continue_final_message=True, add_generation_prompt=False, include_stop_str_in_output=True)
|
| 172 |
+
return client.chat.completions.create(model=model, temperature=0, max_tokens=max_tokens, messages=messages,
|
| 173 |
+
extra_body=extra, **kwargs).choices[0]
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def ground(client, model, frame, name):
|
| 177 |
+
"""Step 1's grounding question, max_tokens 48 -> (reply, first box or None, h, v); h = v = 0 without a box.
|
| 178 |
+
The answer is started as START, how 15,505 of the first 15,533 replies of the BC find25 began (the other 28, 20
|
| 179 |
+
of them about melon, said there is none and now get a box), so the model writes the first box's four numbers and
|
| 180 |
+
stops at the first "]"; the prompt is then the question's followed by the tokens the model wrote for START
|
| 181 |
+
(vLLM /tokenize). reply: START and the model's numbers."""
|
| 182 |
+
reply = START + chat(client, model, frame, GROUND.format(name), 48, start=START, stop=["]"]).message.content
|
| 183 |
+
box = BOX.search(reply)
|
| 184 |
+
if not box:
|
| 185 |
+
return reply, None, 0.0, 0.0
|
| 186 |
+
box = [float(c) for c in box.groups()]
|
| 187 |
+
return reply, box, *box_angles(box)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def presence(client, model, frame, name):
|
| 191 |
+
"""Step 1b's s_yes: diagnosis B's yes/no question, max_tokens 1, top 20 logprobs; P(yes) / (P(yes) + P(no)) with
|
| 192 |
+
case and leading-space variants summed."""
|
| 193 |
+
top = chat(client, model, frame, YESNO.format(name), 1, logprobs=True, top_logprobs=20).logprobs.content[0]
|
| 194 |
+
p = {w: sum(math.exp(t.logprob) for t in top.top_logprobs if t.token.strip().lower() == w) for w in ("yes", "no")}
|
| 195 |
+
return p["yes"] / (p["yes"] + p["no"])
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def vlm_features(client, model, frame, name):
|
| 199 |
+
"""VLM perception -> (p, h/HALF_HFOV, v/HALF_VFOV), log {reply, box, p}; the two questions are sent at once."""
|
| 200 |
+
with ThreadPoolExecutor(1) as pool:
|
| 201 |
+
grounding = pool.submit(ground, client, model, frame, name)
|
| 202 |
+
p = presence(client, model, frame, name)
|
| 203 |
+
reply, box, h, v = grounding.result()
|
| 204 |
+
return np.array([p, h / HALF_HFOV, v / HALF_VFOV]), {"reply": reply, "box": box, "p": round(p, 4)}
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def carry(p, box, yaw, pitch, pose):
|
| 208 |
+
"""Features at pose of a VLM answer (p, first box or None) given at view yaw, pitch, as the C_vlm simulation keeps
|
| 209 |
+
a box's world point (train/ctrl.py simulate()): h moves by -delta yaw, v by +delta pitch, the box center stays in
|
| 210 |
+
the image; p is held, and without a box h = v = 0."""
|
| 211 |
+
if box is None:
|
| 212 |
+
return np.array([p, 0.0, 0.0])
|
| 213 |
+
h, v = box_angles(box)
|
| 214 |
+
h = np.clip(h - ((pose["yaw"] - yaw + 180) % 360 - 180), -HALF_HFOV, HALF_HFOV)
|
| 215 |
+
v = np.clip(v + pose["pitch"] - pitch, -HALF_VFOV, HALF_VFOV)
|
| 216 |
+
return np.array([p, h / HALF_HFOV, v / HALF_VFOV])
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
class Policy:
|
| 220 |
+
"""The controller for one episode. seed: anything np.random.default_rng takes (eval: [seed, task id]);
|
| 221 |
+
perception "gt" or "vlm" (url and model of the perception server)."""
|
| 222 |
+
|
| 223 |
+
def __init__(self, npz, seed, perception="gt", url=None, model=None):
|
| 224 |
+
self.w = dict(np.load(npz))
|
| 225 |
+
self.rng = np.random.default_rng(seed)
|
| 226 |
+
self.perception, self.model = perception, model
|
| 227 |
+
self.client = OpenAI(api_key="EMPTY", base_url=url) if perception == "vlm" else None
|
| 228 |
+
self.window = deque((np.zeros(5) for _ in range(WINDOW)), maxlen=WINDOW)
|
| 229 |
+
self.target, self.cam = None, [0.0, 0.0]
|
| 230 |
+
self.asked, self.held = None, 0 # vlm: (name, p, box, yaw, pitch) of the last call; steps since it
|
| 231 |
+
self.memory = len(self.w["w0"]) == 5 * WINDOW + 16
|
| 232 |
+
self.sectors, self.cells = set(), set() # memory: view sectors and cells of the episode's poses so far
|
| 233 |
+
|
| 234 |
+
def vlm_features(self, frame, name):
|
| 235 |
+
"""The VLM perception call (train/optimus_policy.py ExpertPerceptionPolicy overrides it)."""
|
| 236 |
+
return vlm_features(self.client, self.model, frame, name)
|
| 237 |
+
|
| 238 |
+
def act(self, frame, info, checker, presatisfied):
|
| 239 |
+
"""frame: this step's frame as the eval client sends it (base64 PNG, 320x180; used by vlm); info: the env
|
| 240 |
+
info before the action (player_pos); checker: the episode's MilestoneChecker; presatisfied: ids of milestones
|
| 241 |
+
satisfied at spawn. Returns (MineRL action dict, step log)."""
|
| 242 |
+
index, target, name = current_target(checker, presatisfied)
|
| 243 |
+
if index != self.target:
|
| 244 |
+
for r in self.window:
|
| 245 |
+
r[:3] = 0
|
| 246 |
+
self.target = index
|
| 247 |
+
pose = info["player_pos"]
|
| 248 |
+
dist, h, v = view(pose, target)
|
| 249 |
+
if self.perception == "gt":
|
| 250 |
+
feat, raw = gt_features(h, v), None
|
| 251 |
+
elif self.asked and self.asked[0] == name and self.held < PERCEIVE_EVERY - 1:
|
| 252 |
+
self.held += 1
|
| 253 |
+
feat, raw = carry(*self.asked[1:], pose), None
|
| 254 |
+
else:
|
| 255 |
+
feat, raw = self.vlm_features(frame, name)
|
| 256 |
+
self.asked, self.held = (name, feat[0], raw["box"], pose["yaw"], pose["pitch"]), 0
|
| 257 |
+
self.window.append(rows(feat, np.array(self.cam)))
|
| 258 |
+
x = np.concatenate(self.window)
|
| 259 |
+
if self.memory:
|
| 260 |
+
self.sectors.add(sector(pose["yaw"]))
|
| 261 |
+
self.cells.add(cell(pose["x"], pose["z"]))
|
| 262 |
+
x = np.concatenate([x, memory(self.sectors, self.cells, pose)])
|
| 263 |
+
x = np.round(x, 4)
|
| 264 |
+
heads = [log_softmax(z) for z in logits(self.w, x)]
|
| 265 |
+
bins = [int(self.rng.choice(len(lp), p=np.exp(lp))) for lp in heads]
|
| 266 |
+
a = action(*bins)
|
| 267 |
+
self.cam = a["camera"]
|
| 268 |
+
return a, {"x": x.tolist(), "target": index, "bins": bins,
|
| 269 |
+
"logp": float(sum(lp[b] for lp, b in zip(heads, bins))), "perception": raw, "held": self.held,
|
| 270 |
+
"truth": {"seen": bool(gt_features(h, v)[0]), "dist": round(dist, 2), "h": round(h, 2),
|
| 271 |
+
"v": round(v, 2)}}
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# BT arm (EXPERIMENT_LOG.md 2026-10-17): the VLM (Qwen3.5-9B + LoRA) reads the current frame and the target name and
|
| 275 |
+
# writes a behavior code, one digit per FSQ dimension of train/bt_tokenizer.py; the next CHUNK ticks run open loop,
|
| 276 |
+
# each tick's three bins sampled from the decoder's distributions for the code (table.npz probs, mixed radix index).
|
| 277 |
+
CHUNK = 20
|
| 278 |
+
BT_PROMPT = "Task: find {}. Reply with the behavior code for the next second."
|
| 279 |
+
BT_MEMORY = " Seen directions (0, 45, ..., 315 deg right of view): {}. Visited cells 4 blocks along them: {}."
|
| 280 |
+
BT_NOOP = [0, len(YAWS) // 2, len(PITCHES) // 2]
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def bt_prompt(name, mem=None):
|
| 284 |
+
"""The BT text prompt for target name; with mem (the two 8-character 0/1 strings of memory(), directions then
|
| 285 |
+
cells) BT_MEMORY follows."""
|
| 286 |
+
return BT_PROMPT.format(name) + (BT_MEMORY.format(*mem) if mem else "")
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def bt_code(reply, levels):
|
| 290 |
+
"""Table index of a reply: its first len(levels) characters as digits, each clipped to its level; None if they
|
| 291 |
+
are not all digits."""
|
| 292 |
+
digits = reply[:len(levels)]
|
| 293 |
+
if len(digits) < len(levels) or not digits.isdigit():
|
| 294 |
+
return None
|
| 295 |
+
index = 0
|
| 296 |
+
for d, level in zip(digits, levels):
|
| 297 |
+
index = index * level + min(int(d), level - 1)
|
| 298 |
+
return index
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
class BTPolicy:
|
| 302 |
+
"""The BT policy for one episode, with Policy's act(). table: the tokenizer's decode-table npz; url, model: the
|
| 303 |
+
vLLM serving the LoRA. seed: None (GRPO rollouts) or [eval seed, task id]; decision k of the episode then samples
|
| 304 |
+
with seed eval_seed * 10**7 + task_id * 1000 + k, and the tick bins with a numpy Generator seeded by
|
| 305 |
+
eval_seed * 10**7 + task_id * 1000. Asks on the episode's first step, every CHUNK ticks and when the current target
|
| 306 |
+
changes; an unparseable reply runs CHUNK no-op ticks (valid False). memory True (the curiosity variant,
|
| 307 |
+
EXPERIMENT_LOG.md 2026-10-18): every step adds the pose's sector and cell to the episode's sets as Policy does, and
|
| 308 |
+
each decision's prompt carries memory() at that pose as two 0/1 strings (bt_prompt()).
|
| 309 |
+
Per-step log: target, bins, tick (index in the chunk), truth as Policy; on decision steps also reply, code (the
|
| 310 |
+
clipped digits), valid, logprobs (vLLM, per generated token), act_s (seconds of the call) and, with memory, memory
|
| 311 |
+
(the two strings in the prompt)."""
|
| 312 |
+
|
| 313 |
+
def __init__(self, table, seed, url, model, memory=False):
|
| 314 |
+
t = np.load(table)
|
| 315 |
+
self.levels, self.probs = [int(x) for x in t["levels"]], t["probs"]
|
| 316 |
+
self.seed = None if seed is None else seed[0] * 10**7 + seed[1] * 1000
|
| 317 |
+
self.rng = np.random.default_rng(self.seed)
|
| 318 |
+
self.client, self.model = OpenAI(api_key="EMPTY", base_url=url), model
|
| 319 |
+
self.target, self.tick, self.decisions, self.chunk = None, CHUNK, 0, None
|
| 320 |
+
self.memory, self.sectors, self.cells = memory, set(), set()
|
| 321 |
+
|
| 322 |
+
def ask(self, frame, name, mem=None):
|
| 323 |
+
extra = {"chat_template_kwargs": {"enable_thinking": False}}
|
| 324 |
+
if self.seed is not None:
|
| 325 |
+
extra["seed"] = self.seed + self.decisions
|
| 326 |
+
choice = self.client.chat.completions.create(
|
| 327 |
+
model=self.model, temperature=1.0, max_tokens=len(self.levels), logprobs=True, extra_body=extra,
|
| 328 |
+
messages=[{"role": "user", "content": [
|
| 329 |
+
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + frame}},
|
| 330 |
+
{"type": "text", "text": bt_prompt(name, mem)}]}]).choices[0]
|
| 331 |
+
return choice.message.content, [t.logprob for t in choice.logprobs.content]
|
| 332 |
+
|
| 333 |
+
def act(self, frame, info, checker, presatisfied):
|
| 334 |
+
index, target, name = current_target(checker, presatisfied)
|
| 335 |
+
pose = info["player_pos"]
|
| 336 |
+
dist, h, v = view(pose, target)
|
| 337 |
+
log = {"target": index}
|
| 338 |
+
if self.memory:
|
| 339 |
+
self.sectors.add(sector(pose["yaw"]))
|
| 340 |
+
self.cells.add(cell(pose["x"], pose["z"]))
|
| 341 |
+
if index != self.target or self.tick == CHUNK:
|
| 342 |
+
t = time.monotonic()
|
| 343 |
+
mem = None
|
| 344 |
+
if self.memory:
|
| 345 |
+
bits = "".join(str(int(b)) for b in memory(self.sectors, self.cells, pose))
|
| 346 |
+
mem = log["memory"] = [bits[:8], bits[8:]]
|
| 347 |
+
reply, logprobs = self.ask(frame, name, mem)
|
| 348 |
+
code = bt_code(reply, self.levels)
|
| 349 |
+
self.chunk = self.probs[code] if code is not None else None
|
| 350 |
+
self.target, self.tick = index, 0
|
| 351 |
+
self.decisions += 1
|
| 352 |
+
log.update(reply=reply, valid=code is not None, logprobs=logprobs, act_s=round(time.monotonic() - t, 3),
|
| 353 |
+
code=code is not None and "".join(str(min(int(d), l - 1)) for d, l in zip(reply, self.levels))
|
| 354 |
+
or None)
|
| 355 |
+
bins = BT_NOOP if self.chunk is None else [
|
| 356 |
+
int(self.rng.choice(len(p), p=p / p.sum()))
|
| 357 |
+
for p in np.split(self.chunk[self.tick].astype(float), [len(MOVES), len(MOVES) + len(YAWS)])]
|
| 358 |
+
log.update(bins=bins, tick=self.tick, truth={"seen": bool(gt_features(h, v)[0]), "dist": round(dist, 2),
|
| 359 |
+
"h": round(h, 2), "v": round(v, 2)})
|
| 360 |
+
self.tick += 1
|
| 361 |
+
return action(*bins), log
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
class NoopPolicy:
|
| 365 |
+
"""Every step the no-op action (kind "noop")."""
|
| 366 |
+
|
| 367 |
+
def act(self, frame, info, checker, presatisfied):
|
| 368 |
+
return dict(NOOP), {}
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def make_policy(kind="ctrl", **kwargs):
|
| 372 |
+
"""Policy (kind "ctrl") or BTPolicy (kind "bt") from an MC_POLICY / rollout job policy dict; kinds "hid" and
|
| 373 |
+
"hid_teacher": the hidden-state arms' policies (train/hid_policy.py)."""
|
| 374 |
+
if kind.startswith("hid"):
|
| 375 |
+
from hid_policy import make
|
| 376 |
+
return make(kind, **kwargs)
|
| 377 |
+
if kind == "box": # task 4b (EXPERIMENT_LOG.md 2026-10-19): train/box_policy.py
|
| 378 |
+
from box_policy import BoxPolicy
|
| 379 |
+
return BoxPolicy(**kwargs)
|
| 380 |
+
if kind == "noop": # what a 1B expert that never writes parseable JSON amounts to (EXPERIMENT_LOG.md 2026-10-19)
|
| 381 |
+
return NoopPolicy()
|
| 382 |
+
if kind == "openha": # OpenHA baseline (EXPERIMENT_LOG.md 2026-10-19): train/openha_policy.py
|
| 383 |
+
from openha_policy import OpenHAPolicy
|
| 384 |
+
return OpenHAPolicy(**kwargs)
|
| 385 |
+
if kind.startswith("optimus"): # task 1A (EXPERIMENT_LOG.md 2026-10-19): train/optimus_policy.py
|
| 386 |
+
import optimus_policy
|
| 387 |
+
return (optimus_policy.OptimusActionPolicy if kind == "optimus_action"
|
| 388 |
+
else optimus_policy.ExpertPerceptionPolicy)(**kwargs)
|
| 389 |
+
return BTPolicy(**kwargs) if kind == "bt" else Policy(**kwargs)
|
code/train/grpo_locations.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"0004": [3630.5, 70.0, 1967.5], "0005": [4163.5, 63.0, 1937.5], "0007": [4990.5, 68.0, 1967.5], "0008": [5390.5, 67.0, 1967.5], "0012": [6990.5, 64.0, 1967.5], "0017": [9150.5, 68.0, 1967.5], "0022": [11150.5, 63.0, 1967.5], "0024": [11630.5, 79.0, 1967.5], "0025": [12190.5, 70.0, 1967.5], "0039": [4830.5, 64.0, 2367.5], "0045": [7390.5, 88.0, 2367.5], "0051": [9630.5, 68.0, 2367.5], "0053": [10430.5, 88.0, 2367.5], "0064": [2030.5, 63.0, 2767.5], "0071": [4830.5, 74.0, 2767.5], "0072": [5390.5, 69.0, 2767.5], "0075": [6430.5, 64.0, 2767.5], "0077": [7230.5, 95.0, 2767.5], "0083": [9618.5, 70.0, 2806.5], "0099": [3390.5, 80.0, 3167.5], "0100": [3950.5, 87.0, 3167.5], "0101": [4190.5, 79.0, 3167.5], "0114": [9230.5, 64.0, 3167.5], "0126": [14350.5, 70.0, 3167.5], "0131": [3390.5, 81.0, 3567.5], "0132": [3950.5, 67.0, 3567.5], "0135": [5150.5, 79.0, 3567.5], "0144": [8430.5, 64.0, 3567.5], "0153": [11985.5, 68.0, 3662.5], "0157": [13630.5, 66.0, 3567.5], "0167": [5150.5, 64.0, 3967.5], "0174": [7630.5, 63.0, 3967.5], "0187": [12785.5, 78.0, 4062.5], "0190": [14030.5, 67.0, 3967.5], "0204": [6830.5, 64.0, 4367.5], "0205": [7550.5, 70.0, 4367.5], "0214": [10830.5, 69.0, 4367.5], "0215": [11390.5, 63.0, 4367.5], "0218": [12590.5, 77.0, 4367.5], "0222": [13985.5, 68.0, 4462.5], "0226": [2785.5, 94.0, 4862.5], "0227": [3230.5, 70.0, 4767.5], "0228": [3630.5, 67.0, 4767.5], "0229": [3985.5, 67.0, 4862.5], "0245": [10545.5, 73.0, 4862.5], "0248": [11790.5, 87.0, 4767.5], "0250": [12430.5, 111.0, 4767.5], "0256": [2030.5, 68.0, 5167.5], "0257": [2430.5, 71.0, 5167.5], "0258": [2785.5, 63.0, 5262.5], "0264": [5230.5, 64.0, 5167.5], "0268": [6830.5, 76.0, 5167.5], "0269": [7390.5, 83.0, 5167.5], "0275": [9950.5, 65.0, 5167.5], "0277": [10430.5, 68.0, 5167.5], "0278": [10990.5, 73.0, 5167.5], "0283": [12830.5, 70.0, 5167.5], "0288": [2350.5, 77.0, 5567.5], "0290": [2830.5, 71.0, 5567.5], "0293": [4350.5, 79.0, 5567.5], "0301": [7230.5, 89.0, 5567.5], "0302": [7790.5, 74.0, 5567.5], "0306": [9550.5, 69.0, 5567.5], "0307": [9790.5, 68.0, 5567.5], "0325": [4030.5, 74.0, 5967.5], "0335": [8063.5, 68.0, 5998.5], "0340": [10350.5, 70.0, 5967.5], "0344": [11790.5, 66.0, 5967.5], "0350": [13985.5, 65.0, 6062.5], "0351": [14463.5, 76.0, 5998.5], "0354": [2830.5, 71.0, 6367.5], "0355": [3230.5, 82.0, 6367.5], "0361": [5709.5, 68.0, 6416.5], "0364": [7023.5, 64.0, 6398.5], "0369": [9150.5, 69.0, 6367.5], "0374": [10830.5, 70.0, 6367.5], "0387": [3230.5, 69.0, 6767.5], "0402": [9230.5, 65.0, 6767.5], "0415": [14371.5, 63.0, 6752.5], "0424": [5218.5, 70.0, 7206.5], "0429": [7465.5, 66.0, 7181.5], "0433": [8990.5, 72.0, 7167.5], "0435": [9630.5, 70.0, 7167.5], "0436": [10190.5, 77.0, 7167.5], "0438": [10830.5, 69.0, 7167.5], "0440": [11790.5, 75.0, 7167.5], "0443": [12830.5, 67.0, 7167.5], "0445": [13745.5, 63.0, 7262.5], "0452": [3630.5, 92.0, 7567.5], "0454": [4418.180300335881, 67.0, 7606.180300335881], "0467": [9790.5, 75.0, 7567.5], "0469": [10430.5, 73.0, 7567.5], "0474": [12590.5, 64.0, 7567.5], "0478": [14030.5, 86.0, 7567.5], "0479": [14750.5, 67.0, 7567.5], "0483": [3390.5, 67.0, 7967.5], "0484": [3790.5, 69.0, 7967.5], "0486": [4578.5, 66.0, 8006.5], "0494": [7630.5, 86.0, 7967.5], "0497": [9150.5, 67.0, 7967.5], "0503": [11230.5, 80.0, 7967.5], "0509": [13709.5, 63.0, 8016.5], "0515": [3550.5, 70.0, 8367.5], "0516": [3630.5, 64.0, 8367.5], "0521": [5950.5, 74.0, 8367.5], "0523": [6430.5, 81.0, 8367.5], "0524": [6830.5, 75.0, 8367.5], "0528": [8430.5, 66.0, 8367.5], "0544": [2350.5, 72.0, 8767.5], "0550": [4430.5, 72.0, 8767.5], "0551": [4830.5, 74.0, 8767.5], "0553": [5630.5, 63.0, 8767.5], "0554": [6030.5, 83.0, 8767.5], "0558": [7630.5, 76.0, 8767.5], "0569": [12030.5, 73.0, 8767.5], "0572": [13345.5, 75.0, 8862.5], "0576": [2350.5, 68.0, 9167.5], "0577": [2430.5, 63.0, 9167.5], "0578": [2990.5, 64.0, 9167.5], "0585": [5790.5, 64.0, 9167.5], "0593": [9065.5, 67.0, 9181.5], "0598": [10990.5, 68.0, 9167.5], "0608": [2030.5, 69.0, 9567.5], "0618": [6030.5, 76.0, 9567.5], "0620": [6978.5, 70.0, 9606.5], "0625": [8830.5, 70.0, 9567.5], "0628": [10350.5, 65.0, 9567.5], "0631": [11230.5, 70.0, 9567.5], "0632": [11745.5, 66.0, 9662.5], "0635": [12990.5, 64.0, 9567.5], "0637": [13630.5, 63.0, 9567.5], "0639": [14430.5, 72.0, 9567.5], "0640": [2190.5, 72.0, 9967.5], "0645": [4030.5, 63.0, 9967.5], "0648": [5230.5, 80.0, 9967.5], "0652": [6830.5, 73.0, 9967.5], "0667": [12830.5, 68.0, 9967.5], "0671": [14430.5, 73.0, 9967.5], "0672": [2030.5, 89.0, 10367.5], "0676": [3630.5, 70.0, 10367.5], "0677": [4190.5, 71.0, 10367.5], "0681": [5630.5, 80.0, 10367.5], "0684": [6990.5, 74.0, 10367.5], "0694": [10990.5, 70.0, 10367.5], "0697": [12350.5, 85.0, 10367.5], "0700": [13390.5, 85.0, 10367.5], "0703": [14738.5, 63.0, 10406.5], "0704": [2030.5, 78.0, 10767.5], "0705": [2430.5, 73.0, 10767.5], "0710": [4590.5, 79.0, 10767.5], "0712": [5390.5, 63.0, 10767.5], "0714": [6190.5, 64.0, 10767.5], "0722": [9390.5, 70.0, 10767.5], "0731": [13150.5, 69.0, 10767.5], "0741": [4030.5, 68.0, 11167.5], "0747": [6418.5, 63.0, 11206.5], "0756": [10145.5, 69.0, 11262.5], "0765": [13630.5, 71.0, 11167.5], "0767": [14750.5, 64.0, 11167.5], "0770": [2990.5, 91.0, 11567.5], "0771": [3390.5, 66.0, 11567.5], "0775": [4830.5, 71.0, 11567.5], "0788": [10190.5, 74.0, 11567.5], "0790": [10818.5, 77.0, 11606.5], "0792": [11950.5, 64.0, 11567.5], "0797": [13790.5, 69.0, 11567.5], "0800": [2030.5, 67.0, 11967.5], "0801": [2430.5, 67.0, 11967.5], "0802": [2830.5, 72.0, 11967.5], "0807": [5150.5, 67.0, 11967.5], "0808": [5230.5, 72.0, 11967.5]}
|
code/train/hid_policy.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Policies of the hidden-state arms (EXPERIMENT_LOG.md 2026-10-19, tasks 3 and 4a); eval venv (numpy, requests).
|
| 2 |
+
|
| 3 |
+
HidPolicy (MC_POLICY / rollout kind "hid"): the controller's input is [feature (4096), camera history (40)] -> MLP
|
| 4 |
+
(train/hid_bc.py Net: 256 -> 256 -> A's three heads, exported as ctrl_policy's npz layout, read by ctrl_policy.logits).
|
| 5 |
+
- feature: train/hid_server.py's Qwen3.5-9B hidden state for (the current frame, the current target name), asked on
|
| 6 |
+
A's perception schedule (the episode's first step, when the target name changes, PERCEIVE_EVERY steps after the
|
| 7 |
+
last call) and held unchanged in between;
|
| 8 |
+
- camera history: A's window camera part, the previous camera action [yaw/64, pitch/16] of the last 20 steps oldest
|
| 9 |
+
first (zeros before the episode start; not reset on a target change), rounded to 4 decimals as A's x.
|
| 10 |
+
Sampling as ctrl_policy.Policy (three heads, numpy Generator seeded by seed). url: the feature server (a list: one
|
| 11 |
+
picked per process). Options: log_feat logs the feature (base64 bf16 bits) on asking steps; frames marks asking
|
| 12 |
+
steps with "frame" (train/grpo_rollout.py then saves the PNG); ref (an npz) asks the server for the base feature too
|
| 13 |
+
and logs ref, the 45 log-probs of that controller on [base feature, camera history] (the 4a KL reference).
|
| 14 |
+
Per-step log: target, bins, logp, cam (the 40 history values), held (0 on asking steps), feat_s on asking steps
|
| 15 |
+
(seconds of the call), truth as Policy; plus feat / frame / ref as above.
|
| 16 |
+
|
| 17 |
+
Teacher (kind "hid_teacher"): A's controller (ctrl_policy.Policy, gt perception) whose log marks with "frame" the
|
| 18 |
+
steps where A's vlm schedule would ask (the BC demonstrations, train/hid_bc.py collect).
|
| 19 |
+
"""
|
| 20 |
+
import base64
|
| 21 |
+
from collections import deque
|
| 22 |
+
import os
|
| 23 |
+
import time
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import requests
|
| 27 |
+
|
| 28 |
+
from ctrl_policy import (CAM_SCALE, PERCEIVE_EVERY, WINDOW, Policy, action, current_target, gt_features, log_softmax,
|
| 29 |
+
logits, view)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def decode(s):
|
| 33 |
+
"""base64 bf16 bits -> float32 vector (exact)."""
|
| 34 |
+
return (np.frombuffer(base64.b64decode(s), np.uint16).astype(np.uint32) << 16).view(np.float32)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class HidPolicy:
|
| 38 |
+
def __init__(self, npz, seed, url, log_feat=False, frames=False, ref=None):
|
| 39 |
+
self.w = dict(np.load(npz))
|
| 40 |
+
self.ref = dict(np.load(ref)) if ref else None
|
| 41 |
+
self.rng = np.random.default_rng(seed)
|
| 42 |
+
urls = url if isinstance(url, list) else [url]
|
| 43 |
+
self.url = urls[os.getpid() % len(urls)]
|
| 44 |
+
self.log_feat, self.frames = log_feat, frames
|
| 45 |
+
self.cams = deque((np.zeros(2) for _ in range(WINDOW)), maxlen=WINDOW)
|
| 46 |
+
self.cam = [0.0, 0.0]
|
| 47 |
+
self.asked, self.held, self.feat, self.base = None, 0, None, None
|
| 48 |
+
|
| 49 |
+
def act(self, frame, info, checker, presatisfied):
|
| 50 |
+
index, target, name = current_target(checker, presatisfied)
|
| 51 |
+
pose = info["player_pos"]
|
| 52 |
+
dist, h, v = view(pose, target)
|
| 53 |
+
log = {}
|
| 54 |
+
if self.asked == name and self.held < PERCEIVE_EVERY - 1:
|
| 55 |
+
self.held += 1
|
| 56 |
+
else:
|
| 57 |
+
t = time.monotonic()
|
| 58 |
+
r = requests.post(self.url + "/feat", json={"frame": frame, "name": name}, timeout=600)
|
| 59 |
+
r.raise_for_status()
|
| 60 |
+
r = r.json()
|
| 61 |
+
self.feat = decode(r["feat"])
|
| 62 |
+
self.base = decode(r["base"]) if self.ref else None
|
| 63 |
+
self.asked, self.held = name, 0
|
| 64 |
+
log["feat_s"] = round(time.monotonic() - t, 3)
|
| 65 |
+
if self.log_feat:
|
| 66 |
+
log["feat"] = r["feat"]
|
| 67 |
+
if self.frames:
|
| 68 |
+
log["frame"] = True
|
| 69 |
+
self.cams.append(np.array(self.cam)[::-1] / CAM_SCALE[::-1])
|
| 70 |
+
cam = np.round(np.concatenate(self.cams), 4)
|
| 71 |
+
heads = [log_softmax(z) for z in logits(self.w, np.concatenate([self.feat, cam]))]
|
| 72 |
+
bins = [int(self.rng.choice(len(lp), p=np.exp(lp))) for lp in heads]
|
| 73 |
+
a = action(*bins)
|
| 74 |
+
self.cam = a["camera"]
|
| 75 |
+
if self.ref:
|
| 76 |
+
log["ref"] = np.round(np.concatenate([log_softmax(z) for z in logits(self.ref, np.concatenate(
|
| 77 |
+
[self.base, cam]))]), 4).tolist()
|
| 78 |
+
log.update(target=index, bins=bins, logp=float(sum(lp[b] for lp, b in zip(heads, bins))), cam=cam.tolist(),
|
| 79 |
+
held=self.held, truth={"seen": bool(gt_features(h, v)[0]), "dist": round(dist, 2),
|
| 80 |
+
"h": round(h, 2), "v": round(v, 2)})
|
| 81 |
+
return a, log
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class Teacher(Policy):
|
| 85 |
+
def __init__(self, npz, seed):
|
| 86 |
+
super().__init__(npz, seed, "gt")
|
| 87 |
+
self.asked_name, self.since = None, 0
|
| 88 |
+
|
| 89 |
+
def act(self, frame, info, checker, presatisfied):
|
| 90 |
+
name = current_target(checker, presatisfied)[2]
|
| 91 |
+
a, log = super().act(frame, info, checker, presatisfied)
|
| 92 |
+
if self.asked_name == name and self.since < PERCEIVE_EVERY - 1:
|
| 93 |
+
self.since += 1
|
| 94 |
+
else:
|
| 95 |
+
self.asked_name, self.since = name, 0
|
| 96 |
+
log["frame"] = True
|
| 97 |
+
return a, log
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def make(kind, **kwargs):
|
| 101 |
+
return Teacher(**kwargs) if kind == "hid_teacher" else HidPolicy(**kwargs)
|
code/train/hid_server.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hidden-state feature of the hidden-state arms (EXPERIMENT_LOG.md 2026-10-19, tasks 3 and 4a) and its HTTP server.
|
| 2 |
+
Runs in .train-venv.
|
| 3 |
+
|
| 4 |
+
Feature: Qwen3.5-9B's last-layer hidden state (after the final norm, i.e. the lm_head input; 4096 dims) at the last
|
| 5 |
+
prompt token of A's grounding question (ctrl_policy.ground(): the frame, GROUND.format(name), thinking off, the answer
|
| 6 |
+
started as START, rendered by the chat template with continue_final_message as vLLM renders it), prefill only. Batches
|
| 7 |
+
are right-padded and each row's last real token is gathered. Values travel as base64 of the bf16 bits (encode/decode;
|
| 8 |
+
hid_policy.decode is the numpy twin), so a logged feature is exactly the one the controller used.
|
| 9 |
+
|
| 10 |
+
Server: one model replica per GPU (threads), a shared queue, each replica takes up to MAX_BATCH waiting requests per
|
| 11 |
+
forward. POST /feat {"frame": base64 PNG, "name": target name} -> {"feat": ...} and, with --base, also "base": the
|
| 12 |
+
feature with the adapter disabled (the 4a KL reference input). The port opens once every replica is loaded.
|
| 13 |
+
Usage: CUDA_VISIBLE_DEVICES=2 .train-venv/bin/python train/hid_server.py --port 9210 [--gpus 0] [--adapter DIR --base]
|
| 14 |
+
"""
|
| 15 |
+
import argparse
|
| 16 |
+
import base64
|
| 17 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 18 |
+
import io
|
| 19 |
+
import json
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
import queue
|
| 22 |
+
import sys
|
| 23 |
+
import threading
|
| 24 |
+
import time
|
| 25 |
+
|
| 26 |
+
import torch
|
| 27 |
+
from PIL import Image
|
| 28 |
+
from transformers import AutoProcessor
|
| 29 |
+
|
| 30 |
+
from bt_train import MODULES, load_model # noqa: F401 (MODULES: the LoRA targets, re-exported for hid_grpo)
|
| 31 |
+
from ctrl_policy import START
|
| 32 |
+
|
| 33 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 34 |
+
sys.path.append(str(ROOT / "bench"))
|
| 35 |
+
from diag_perception_direction import GROUND # noqa: E402
|
| 36 |
+
|
| 37 |
+
MAX_BATCH = 32
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def processor():
|
| 41 |
+
return AutoProcessor.from_pretrained(ROOT / "models/Qwen3.5-9B", local_files_only=True)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def prompt(proc, name):
|
| 45 |
+
return proc.apply_chat_template(
|
| 46 |
+
[{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": GROUND.format(name)}]},
|
| 47 |
+
{"role": "assistant", "content": START}],
|
| 48 |
+
tokenize=False, add_generation_prompt=False, continue_final_message=True, enable_thinking=False)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def batch_inputs(proc, images, names):
|
| 52 |
+
return dict(proc(text=[prompt(proc, n) for n in names], images=images, padding=True, padding_side="right",
|
| 53 |
+
return_tensors="pt"))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def features(model, inputs, device):
|
| 57 |
+
"""(B, 4096) bf16 last-layer hidden states at each row's last prompt token; model: the Qwen3.5 model or its
|
| 58 |
+
PeftModel (grad follows the caller's mode)."""
|
| 59 |
+
body = (model.get_base_model() if hasattr(model, "get_base_model") else model).model
|
| 60 |
+
x = {k: v.to(device) for k, v in inputs.items()}
|
| 61 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 62 |
+
h = body(**x, use_cache=False).last_hidden_state
|
| 63 |
+
return h[torch.arange(len(h), device=device), x["attention_mask"].sum(1) - 1]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def encode(f):
|
| 67 |
+
"""bf16 vector -> base64 of its bits."""
|
| 68 |
+
return base64.b64encode(f.to(torch.bfloat16).view(torch.int16).cpu().numpy().tobytes()).decode()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def decode(s):
|
| 72 |
+
return torch.frombuffer(bytearray(base64.b64decode(s)), dtype=torch.int16).view(torch.bfloat16)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def png(b64):
|
| 76 |
+
return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def main():
|
| 80 |
+
ap = argparse.ArgumentParser()
|
| 81 |
+
ap.add_argument("--port", type=int, required=True)
|
| 82 |
+
ap.add_argument("--gpus", default="0", help="local cuda indices, one replica each")
|
| 83 |
+
ap.add_argument("--adapter", type=Path)
|
| 84 |
+
ap.add_argument("--base", action="store_true", help="also return the feature with the adapter disabled")
|
| 85 |
+
args = ap.parse_args()
|
| 86 |
+
proc = processor()
|
| 87 |
+
jobs = queue.Queue()
|
| 88 |
+
|
| 89 |
+
def replica(model, device):
|
| 90 |
+
while True:
|
| 91 |
+
items = [jobs.get()]
|
| 92 |
+
while len(items) < MAX_BATCH:
|
| 93 |
+
try:
|
| 94 |
+
items.append(jobs.get_nowait())
|
| 95 |
+
except queue.Empty:
|
| 96 |
+
break
|
| 97 |
+
try:
|
| 98 |
+
inputs = batch_inputs(proc, [png(i["frame"]) for i in items], [i["name"] for i in items])
|
| 99 |
+
with torch.no_grad():
|
| 100 |
+
out = {"feat": features(model, inputs, device)}
|
| 101 |
+
if args.base:
|
| 102 |
+
with model.disable_adapter():
|
| 103 |
+
out["base"] = features(model, inputs, device)
|
| 104 |
+
for k, item in enumerate(items):
|
| 105 |
+
item["out"] = {key: encode(f[k]) for key, f in out.items()}
|
| 106 |
+
except Exception as e: # answer every waiting request rather than leave it hanging
|
| 107 |
+
for item in items:
|
| 108 |
+
item["out"] = {"error": f"{type(e).__name__}: {e}"}
|
| 109 |
+
for item in items:
|
| 110 |
+
item["done"].set()
|
| 111 |
+
|
| 112 |
+
gpus = [int(g) for g in args.gpus.split(",")]
|
| 113 |
+
for g in gpus:
|
| 114 |
+
model = load_model(g)
|
| 115 |
+
if args.adapter:
|
| 116 |
+
from peft import PeftModel
|
| 117 |
+
model = PeftModel.from_pretrained(model, args.adapter)
|
| 118 |
+
model.eval()
|
| 119 |
+
threading.Thread(target=replica, args=(model, torch.device("cuda", g)), daemon=True).start()
|
| 120 |
+
|
| 121 |
+
class Handler(BaseHTTPRequestHandler):
|
| 122 |
+
def log_message(self, *a):
|
| 123 |
+
pass
|
| 124 |
+
|
| 125 |
+
def reply(self, code, body):
|
| 126 |
+
data = json.dumps(body).encode()
|
| 127 |
+
self.send_response(code)
|
| 128 |
+
self.send_header("Content-Type", "application/json")
|
| 129 |
+
self.send_header("Content-Length", str(len(data)))
|
| 130 |
+
self.end_headers()
|
| 131 |
+
self.wfile.write(data)
|
| 132 |
+
|
| 133 |
+
def do_GET(self):
|
| 134 |
+
self.reply(200, {"gpus": gpus})
|
| 135 |
+
|
| 136 |
+
def do_POST(self):
|
| 137 |
+
item = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
| 138 |
+
item["done"] = threading.Event()
|
| 139 |
+
jobs.put(item)
|
| 140 |
+
item["done"].wait()
|
| 141 |
+
self.reply(500 if "error" in item["out"] else 200, item["out"])
|
| 142 |
+
|
| 143 |
+
server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
|
| 144 |
+
server.daemon_threads = True
|
| 145 |
+
print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} feature server on port {args.port}, gpus {gpus}, adapter "
|
| 146 |
+
f"{args.adapter}, base {args.base}", flush=True)
|
| 147 |
+
server.serve_forever()
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
main()
|
patches/MineExplorer.patch
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/env/minerl_sandbox.py b/env/minerl_sandbox.py
|
| 2 |
+
index b74f0e8..b3f3860 100644
|
| 3 |
+
--- a/env/minerl_sandbox.py
|
| 4 |
+
+++ b/env/minerl_sandbox.py
|
| 5 |
+
@@ -194,7 +194,8 @@ class MineRLSandboxEnv(gym.Env):
|
| 6 |
+
for attempt in range(max_retries):
|
| 7 |
+
try:
|
| 8 |
+
logger.info(f"Resetting environment (attempt {attempt + 1}/{max_retries}) session_id={self.session_id}...")
|
| 9 |
+
- response = self._post("reset_env", timeout=180)
|
| 10 |
+
+ # a client timeout must not trigger a retry while the server is still resetting (two resets at once)
|
| 11 |
+
+ response = self._post("reset_env", timeout=int(os.getenv("MC_RESET_TIMEOUT", "180")))
|
| 12 |
+
if response.get("status") != 0:
|
| 13 |
+
raise RuntimeError(f"Server error during reset: {response.get('msg')}")
|
| 14 |
+
return self._get_obs_from_response(response), {}
|
| 15 |
+
diff --git a/eval_benchmark.py b/eval_benchmark.py
|
| 16 |
+
index 54487bd..a579b48 100644
|
| 17 |
+
--- a/eval_benchmark.py
|
| 18 |
+
+++ b/eval_benchmark.py
|
| 19 |
+
@@ -22,6 +22,10 @@ if str(_ROOT_DIR) not in sys.path:
|
| 20 |
+
from env.minerl_sandbox import MineRLSandboxEnv
|
| 21 |
+
from env.render import RenderWrapper
|
| 22 |
+
from mc_agent import DefaultAgent, MinerRLActionSpace, OpenAIProvider, VLLMProvider, DefaultContextBuilder
|
| 23 |
+
+from mc_agent.utils import convert_buffer_to_base64_images
|
| 24 |
+
+
|
| 25 |
+
+sys.path.append(str(_ROOT_DIR.parent / "train"))
|
| 26 |
+
+from ctrl_policy import make_policy # noqa: E402
|
| 27 |
+
|
| 28 |
+
FRAME_BUFFER_SIZE = 20
|
| 29 |
+
# MAX_STEPS = 50
|
| 30 |
+
@@ -45,13 +49,22 @@ class MineRLBenchmarkEnv(MineRLSandboxEnv):
|
| 31 |
+
raise FileNotFoundError(f"Metadata not found: {meta_path}")
|
| 32 |
+
with open(meta_path, "r", encoding="utf-8") as f:
|
| 33 |
+
self._metadata = json.load(f)
|
| 34 |
+
+ self._scenario_id = meta_path.parent.parent.name
|
| 35 |
+
self._parse_metadata()
|
| 36 |
+
super().__init__(env_id=self._scene_name, use_friday=use_friday)
|
| 37 |
+
|
| 38 |
+
def _parse_metadata(self) -> None:
|
| 39 |
+
- self._commands_list = self._metadata.get("commands", [])
|
| 40 |
+
+ # command feedback ("Changed the block at ...") covered the lower-left of the first ~150 frames
|
| 41 |
+
+ self._commands_list = ["/gamerule sendCommandFeedback false"] + self._metadata.get("commands", [])
|
| 42 |
+
self._task_text = self._metadata.get("task_text", "")
|
| 43 |
+
self._scene_name = self._metadata.get("scene_name", "benchmark_scene")
|
| 44 |
+
+ self._world_seed = 0
|
| 45 |
+
+ if os.getenv("MC_EXPLORE_TASK"): # goal-free exploration: bare world, one fixed seed per scenario id
|
| 46 |
+
+ self._commands_list = ["/gamerule sendCommandFeedback false", "/time set day"]
|
| 47 |
+
+ self._task_text = os.environ["MC_EXPLORE_TASK"]
|
| 48 |
+
+ self._world_seed = int(self._scenario_id) + 1
|
| 49 |
+
+ if os.getenv("MC_START_YAW"): # start facing this yaw (the reversed-start find25 variant: 180)
|
| 50 |
+
+ self._commands_list.append(f"/tp @p ~ ~ ~ {os.environ['MC_START_YAW']} 0")
|
| 51 |
+
logger.info(f"[BenchmarkEnv] scene={self._scene_name} commands={len(self._commands_list)}")
|
| 52 |
+
logger.info(f"[BenchmarkEnv] task: {self._task_text[:100]}")
|
| 53 |
+
|
| 54 |
+
@@ -61,7 +74,7 @@ class MineRLBenchmarkEnv(MineRLSandboxEnv):
|
| 55 |
+
env='MinecraftSim',
|
| 56 |
+
obs_size=[128, 128],
|
| 57 |
+
render_size=[640, 360],
|
| 58 |
+
- seed=0,
|
| 59 |
+
+ seed=self._world_seed,
|
| 60 |
+
record=False,
|
| 61 |
+
record_path='./output/',
|
| 62 |
+
yaml_config=None,
|
| 63 |
+
@@ -118,6 +131,7 @@ def _run_benchmark(
|
| 64 |
+
vllm_url: str = "http://localhost:8000/v1",
|
| 65 |
+
frame_size: int = FRAME_BUFFER_SIZE,
|
| 66 |
+
use_friday: bool = False,
|
| 67 |
+
+ end_on_esc: bool = False,
|
| 68 |
+
) -> Dict[str, Any]:
|
| 69 |
+
"""Run one benchmark scenario and save results."""
|
| 70 |
+
global _shutdown_requested
|
| 71 |
+
@@ -129,8 +143,15 @@ def _run_benchmark(
|
| 72 |
+
scene_id = meta_path.parent.parent.name
|
| 73 |
+
task_desc = metadata_dict.get("task_text", f"Complete the Minecraft task for {scene_id}.")
|
| 74 |
+
safe_model = model.replace("/", "_")
|
| 75 |
+
-
|
| 76 |
+
- if _HAS_MILESTONE_CHECKER:
|
| 77 |
+
+ explore_task = os.getenv("MC_EXPLORE_TASK") # goal-free: no milestones, always runs max_steps
|
| 78 |
+
+ # MC_POLICY (JSON: npz, perception gt|vlm, url and model of the perception server for vlm, seed) runs the
|
| 79 |
+
+ # controller of train/ctrl_policy.py instead of agent.get_action; it samples with the seed and the scene id
|
| 80 |
+
+ policy_cfg = json.loads(os.getenv("MC_POLICY", "null"))
|
| 81 |
+
+ policy = policy_cfg and make_policy(**{**policy_cfg, "seed": [policy_cfg["seed"], int(scene_id)]})
|
| 82 |
+
+ if explore_task:
|
| 83 |
+
+ task_desc = explore_task
|
| 84 |
+
+ checker = MilestoneChecker([])
|
| 85 |
+
+ elif _HAS_MILESTONE_CHECKER:
|
| 86 |
+
try:
|
| 87 |
+
checker = MilestoneChecker.from_metadata(metadata_path)
|
| 88 |
+
logger.info(f"Loaded {len(checker._milestones)} milestones")
|
| 89 |
+
@@ -159,13 +180,16 @@ def _run_benchmark(
|
| 90 |
+
|
| 91 |
+
env = RenderWrapper(_base_env, save_messages=True, save_path=str(output_dir))
|
| 92 |
+
|
| 93 |
+
- frame_buffer = deque(maxlen=frame_size)
|
| 94 |
+
+ # MC_FRAME_STRIDE=k feeds frame_size frames spaced k steps apart (default 1: consecutive).
|
| 95 |
+
+ frame_stride = int(os.getenv("MC_FRAME_STRIDE", "1"))
|
| 96 |
+
+ agent.frame_stride = frame_stride
|
| 97 |
+
+ frame_buffer = deque(maxlen=(frame_size - 1) * frame_stride + 1)
|
| 98 |
+
thought_history: List[str] = []
|
| 99 |
+
action_history: List[Dict] = []
|
| 100 |
+
_frame_completed: Dict[str, Optional[int]] = {}
|
| 101 |
+
_presatisfied_ids: set = set()
|
| 102 |
+
_frame_offset: int = 0
|
| 103 |
+
- _all_done_logged: bool = False
|
| 104 |
+
+ initial_player_pos: Dict[str, Any] = {}
|
| 105 |
+
long_term_memory: str = ""
|
| 106 |
+
|
| 107 |
+
original_sigint_handler = signal.signal(signal.SIGINT, _signal_handler)
|
| 108 |
+
@@ -173,7 +197,6 @@ def _run_benchmark(
|
| 109 |
+
try:
|
| 110 |
+
has_loading = loading_command_steps > 0
|
| 111 |
+
obs, info = env.reset(save_frame=not has_loading)
|
| 112 |
+
- frame_buffer.append(obs["pov"])
|
| 113 |
+
|
| 114 |
+
for step in range(loading_command_steps):
|
| 115 |
+
if _shutdown_requested:
|
| 116 |
+
@@ -190,8 +213,9 @@ def _run_benchmark(
|
| 117 |
+
if not info.get("player_pos"):
|
| 118 |
+
_, noop_action = agent.get_default_action(is_call_failed=False)
|
| 119 |
+
obs, reward, terminated, truncated, info = env.step(noop_action, save_frame=True)
|
| 120 |
+
- frame_buffer.append(obs["pov"])
|
| 121 |
+
|
| 122 |
+
+ frame_buffer.append(obs["pov"])
|
| 123 |
+
+ initial_player_pos = dict(info.get("player_pos", {}))
|
| 124 |
+
checker.reset(info)
|
| 125 |
+
agent.load_system_prompt(task_desc)
|
| 126 |
+
|
| 127 |
+
@@ -205,7 +229,8 @@ def _run_benchmark(
|
| 128 |
+
# agent takes any action). These are treated as "pre-satisfied" and
|
| 129 |
+
# will NOT be counted as completed — they are excluded via a blocklist
|
| 130 |
+
# so that _frame_completed is only set when the agent genuinely
|
| 131 |
+
- # triggers the condition during gameplay.
|
| 132 |
+
+ # triggers the condition during gameplay. They must also be excluded
|
| 133 |
+
+ # from the required targets, otherwise the episode can never succeed.
|
| 134 |
+
_baseline_check = checker.check(info)
|
| 135 |
+
_presatisfied_ids: set = set()
|
| 136 |
+
for _bms in _baseline_check:
|
| 137 |
+
@@ -217,11 +242,17 @@ def _run_benchmark(
|
| 138 |
+
)
|
| 139 |
+
# Reset the checker state so those pre-satisfied flags don't persist
|
| 140 |
+
checker.reset(info)
|
| 141 |
+
+ _trackable = [
|
| 142 |
+
+ m["milestone_id"] for m in checker._milestones
|
| 143 |
+
+ if m.get("rules") and m["milestone_id"] not in _presatisfied_ids
|
| 144 |
+
+ ]
|
| 145 |
+
+ if not _trackable:
|
| 146 |
+
+ logger.info(f"[{run_id}] No unsatisfied trackable targets at spawn; excluding episode from scores.")
|
| 147 |
+
|
| 148 |
+
step_error: Optional[str] = None
|
| 149 |
+
step = -1
|
| 150 |
+
|
| 151 |
+
- for step in range(max_steps):
|
| 152 |
+
+ for step in range(max_steps if _trackable or explore_task else 0):
|
| 153 |
+
if _shutdown_requested:
|
| 154 |
+
logger.info(f"[{run_id}] Shutdown requested, exiting after step {step}...")
|
| 155 |
+
step_error = "interrupted_by_user"
|
| 156 |
+
@@ -230,10 +261,17 @@ def _run_benchmark(
|
| 157 |
+
logger.info(f"[{run_id}] --- Step {step + 1}/{max_steps} ---")
|
| 158 |
+
|
| 159 |
+
try:
|
| 160 |
+
- thought, action, memory_update = agent.get_action(
|
| 161 |
+
- list(frame_buffer), list(thought_history), list(action_history), step + 1,
|
| 162 |
+
- long_term_memory=long_term_memory
|
| 163 |
+
- )
|
| 164 |
+
+ if policy:
|
| 165 |
+
+ # a policy with raw_frame (train/optimus_policy.py) gets the env's RGB frame, not the 320x180 PNG
|
| 166 |
+
+ frame = obs["pov"] if getattr(policy, "raw_frame", False) else \
|
| 167 |
+
+ convert_buffer_to_base64_images([obs["pov"]])[0]
|
| 168 |
+
+ action, policy_log = policy.act(frame, info, checker, _presatisfied_ids)
|
| 169 |
+
+ thought = memory_update = ""
|
| 170 |
+
+ else:
|
| 171 |
+
+ thought, action, memory_update = agent.get_action(
|
| 172 |
+
+ list(frame_buffer)[::-1][::frame_stride][::-1], list(thought_history), list(action_history),
|
| 173 |
+
+ step + 1, long_term_memory=long_term_memory
|
| 174 |
+
+ )
|
| 175 |
+
except Exception as agent_err:
|
| 176 |
+
logger.error(f"[{run_id}] Agent call failed: {agent_err}. Retrying in 10s...")
|
| 177 |
+
time.sleep(10)
|
| 178 |
+
@@ -249,6 +287,7 @@ def _run_benchmark(
|
| 179 |
+
|
| 180 |
+
thought_history.append(thought)
|
| 181 |
+
action_history.append(action)
|
| 182 |
+
+ player_pos_before = dict(info.get("player_pos", {}))
|
| 183 |
+
|
| 184 |
+
try:
|
| 185 |
+
augmented_action = checker.augment_action_with_queries(action, info)
|
| 186 |
+
@@ -258,6 +297,13 @@ def _run_benchmark(
|
| 187 |
+
time.sleep(10)
|
| 188 |
+
continue
|
| 189 |
+
|
| 190 |
+
+ with open(output_dir / "steps.jsonl", "a", encoding="utf-8") as _f:
|
| 191 |
+
+ _f.write(json.dumps({"step": step + 1, "thought": thought, "memory": long_term_memory,
|
| 192 |
+
+ "action": {k: v for k, v in action.items() if v not in (0, [0.0, 0.0], [0, 0])},
|
| 193 |
+
+ "player_pos_before": player_pos_before,
|
| 194 |
+
+ "player_pos_after": info.get("player_pos", {}),
|
| 195 |
+
+ **({"policy": policy_log} if policy else {})},
|
| 196 |
+
+ ensure_ascii=False) + "\n")
|
| 197 |
+
done = terminated or truncated
|
| 198 |
+
frame_buffer.append(obs["pov"])
|
| 199 |
+
|
| 200 |
+
@@ -272,11 +318,17 @@ def _run_benchmark(
|
| 201 |
+
_frame_completed[mid] = env.frame_count - _frame_offset
|
| 202 |
+
logger.success(f"[{run_id}] Milestone '{mid}' completed at step {step + 1} (frame {_frame_completed[mid]})")
|
| 203 |
+
|
| 204 |
+
- if not _all_done_logged and checker.num_completed() > 0 and checker.all_done():
|
| 205 |
+
- _all_done_logged = True
|
| 206 |
+
- logger.success(f"[{run_id}] All milestones completed at step {step + 1}!")
|
| 207 |
+
+ if _trackable and all(m in _frame_completed for m in _trackable):
|
| 208 |
+
+ logger.success(f"[{run_id}] Every trackable milestone done at step {step + 1}, stopping early.")
|
| 209 |
+
+ break
|
| 210 |
+
+
|
| 211 |
+
+ # the prompt says ESC ends the episode, but the released sandbox never reports done for it and
|
| 212 |
+
+ # agents then press ESC for the rest of the budget; --end-on-esc ends it as the prompt promises
|
| 213 |
+
+ if end_on_esc and action.get("ESC"):
|
| 214 |
+
+ logger.info(f"[{run_id}] Agent pressed ESC at step {step + 1}, ending the episode.")
|
| 215 |
+
+ break
|
| 216 |
+
|
| 217 |
+
- if (step + 1) % 10 == 0:
|
| 218 |
+
+ if (step + 1) % 300 == 0:
|
| 219 |
+
env.save_video_checkpoint()
|
| 220 |
+
|
| 221 |
+
if done:
|
| 222 |
+
@@ -314,11 +366,12 @@ def _run_benchmark(
|
| 223 |
+
})
|
| 224 |
+
|
| 225 |
+
# Compute milestones_completed / all_milestones_done using only the
|
| 226 |
+
- # corrected _frame_completed dict (excludes pre-satisfied milestones).
|
| 227 |
+
+ # corrected _frame_completed dict. Pre-satisfied milestones are excluded
|
| 228 |
+
+ # from both the completed count and the number of required targets.
|
| 229 |
+
trackable_mids = {
|
| 230 |
+
ms.get("milestone_id", "")
|
| 231 |
+
for ms in checker._milestones
|
| 232 |
+
- if len(ms.get("rules", [])) > 0
|
| 233 |
+
+ if len(ms.get("rules", [])) > 0 and ms.get("milestone_id", "") not in _presatisfied_ids
|
| 234 |
+
}
|
| 235 |
+
corrected_completed = sum(
|
| 236 |
+
1 for mid in trackable_mids
|
| 237 |
+
@@ -338,6 +391,9 @@ def _run_benchmark(
|
| 238 |
+
"mode": "multi-agent",
|
| 239 |
+
"metadata_path": str(meta_path),
|
| 240 |
+
"total_steps": total_steps,
|
| 241 |
+
+ "frame_size": frame_size,
|
| 242 |
+
+ "frame_stride": frame_stride,
|
| 243 |
+
+ "initial_player_pos": initial_player_pos,
|
| 244 |
+
"milestones_completed": corrected_completed,
|
| 245 |
+
"milestones_trackable": corrected_trackable,
|
| 246 |
+
"milestones_total": len(checker._milestones),
|
| 247 |
+
@@ -358,6 +414,8 @@ def _run_benchmark(
|
| 248 |
+
def _worker_eval(worker_args: dict) -> dict:
|
| 249 |
+
import signal as _signal
|
| 250 |
+
_signal.signal(_signal.SIGINT, _signal.SIG_IGN)
|
| 251 |
+
+ if worker_args.get("sandbox_url"):
|
| 252 |
+
+ os.environ["MC_SANDBOX_URL"] = worker_args["sandbox_url"]
|
| 253 |
+
|
| 254 |
+
_root = Path(__file__).resolve().parent
|
| 255 |
+
sys.path.append(str(_root))
|
| 256 |
+
@@ -372,6 +430,7 @@ def _worker_eval(worker_args: dict) -> dict:
|
| 257 |
+
vllm_url=worker_args.get("vllm_url", "http://localhost:8000/v1"),
|
| 258 |
+
frame_size=worker_args.get("frame_size", FRAME_BUFFER_SIZE),
|
| 259 |
+
use_friday=worker_args.get("use_friday", False),
|
| 260 |
+
+ end_on_esc=worker_args.get("end_on_esc", False),
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
@@ -382,10 +441,12 @@ def eval_benchmark(
|
| 265 |
+
help="Path to benchmark directory"),
|
| 266 |
+
output_dir: str = typer.Option("outputs", "--output-dir", "-o",
|
| 267 |
+
help="Output directory"),
|
| 268 |
+
- loading_command_steps: int = typer.Option(20, "--loading-command-steps",
|
| 269 |
+
+ loading_command_steps: int = typer.Option(200, "--loading-command-steps", # failed-command chat lines fade after 200 ticks
|
| 270 |
+
help="No-op steps after reset"),
|
| 271 |
+
max_steps: int = typer.Option(MAX_STEPS, "--max-steps",
|
| 272 |
+
help="Maximum agent steps per episode"),
|
| 273 |
+
+ frame_size: int = typer.Option(FRAME_BUFFER_SIZE, "--frame-size", min=1,
|
| 274 |
+
+ help="Number of recent observation frames"),
|
| 275 |
+
resume: bool = typer.Option(False, "--resume",
|
| 276 |
+
help="Resume evaluation (skip completed)"),
|
| 277 |
+
use_vllm: bool = typer.Option(False, "--use-vllm",
|
| 278 |
+
@@ -398,6 +459,10 @@ def eval_benchmark(
|
| 279 |
+
help="Limit number of scenarios to evaluate"),
|
| 280 |
+
use_friday: bool = typer.Option(False, "--use-friday",
|
| 281 |
+
help="Use Friday platform sandbox instead of local Docker"),
|
| 282 |
+
+ tasks: Optional[str] = typer.Option(None, "--tasks",
|
| 283 |
+
+ help="Comma-separated scenario ids to evaluate (default: all)"),
|
| 284 |
+
+ end_on_esc: bool = typer.Option(False, "--end-on-esc",
|
| 285 |
+
+ help="End the episode when the agent presses ESC"),
|
| 286 |
+
):
|
| 287 |
+
"""Evaluate all benchmark scenarios in benchmark_dir."""
|
| 288 |
+
logger.info(f"--- Starting evaluation (model={model}) ---")
|
| 289 |
+
@@ -418,6 +483,10 @@ def eval_benchmark(
|
| 290 |
+
logger.warning(f"No metadata.json found under {bench_path}")
|
| 291 |
+
return
|
| 292 |
+
|
| 293 |
+
+ if tasks:
|
| 294 |
+
+ wanted = set(tasks.split(","))
|
| 295 |
+
+ metadata_entries = [e for e in metadata_entries if e[0] in wanted]
|
| 296 |
+
+
|
| 297 |
+
if limit and limit > 0:
|
| 298 |
+
metadata_entries = metadata_entries[:limit]
|
| 299 |
+
|
| 300 |
+
@@ -433,7 +502,18 @@ def eval_benchmark(
|
| 301 |
+
if resume and result_path.exists():
|
| 302 |
+
logger.info(f"[SKIP] {scene_num} - result.json exists")
|
| 303 |
+
with open(result_path, "r", encoding="utf-8") as f:
|
| 304 |
+
- all_results.append(json.load(f))
|
| 305 |
+
+ summary = json.load(f)
|
| 306 |
+
+ if not summary.get("error"):
|
| 307 |
+
+ milestones = MilestoneChecker.from_metadata(str(meta_path))._milestones
|
| 308 |
+
+ trackable = {m["milestone_id"] for m in milestones if m.get("rules")}
|
| 309 |
+
+ statuses = summary["milestone_status"]
|
| 310 |
+
+ trackable -= {m["milestone_id"] for m in statuses if m.get("presatisfied_at_spawn")}
|
| 311 |
+
+ summary["milestones_trackable"] = len(trackable)
|
| 312 |
+
+ summary["milestones_completed"] = sum(
|
| 313 |
+
+ m["completed"] for m in statuses if m["milestone_id"] in trackable
|
| 314 |
+
+ )
|
| 315 |
+
+ summary["all_milestones_done"] = bool(trackable) and summary["milestones_completed"] == len(trackable)
|
| 316 |
+
+ all_results.append(summary)
|
| 317 |
+
else:
|
| 318 |
+
pending.append((scene_num, meta_path))
|
| 319 |
+
|
| 320 |
+
@@ -466,9 +546,11 @@ def eval_benchmark(
|
| 321 |
+
output_dir=scene_out_dir,
|
| 322 |
+
loading_command_steps=loading_command_steps,
|
| 323 |
+
max_steps=max_steps,
|
| 324 |
+
+ frame_size=frame_size,
|
| 325 |
+
use_vllm=use_vllm,
|
| 326 |
+
vllm_url=vllm_url,
|
| 327 |
+
use_friday=use_friday,
|
| 328 |
+
+ end_on_esc=end_on_esc,
|
| 329 |
+
)
|
| 330 |
+
except Exception as e:
|
| 331 |
+
logger.error(f"[ERROR] {scene_num}: {e}")
|
| 332 |
+
@@ -485,51 +567,44 @@ def eval_benchmark(
|
| 333 |
+
else:
|
| 334 |
+
import multiprocessing as _mp
|
| 335 |
+
|
| 336 |
+
- effective_workers = min(num_workers, len(pending))
|
| 337 |
+
mp_ctx = _mp.get_context("spawn")
|
| 338 |
+
-
|
| 339 |
+
- remaining = list(pending)
|
| 340 |
+
- batch_num = 0
|
| 341 |
+
-
|
| 342 |
+
- while remaining:
|
| 343 |
+
- batch = remaining[:effective_workers]
|
| 344 |
+
- remaining = remaining[effective_workers:]
|
| 345 |
+
- batch_num += 1
|
| 346 |
+
-
|
| 347 |
+
- logger.info(f"\n[Batch {batch_num}] Launching {len(batch)} worker(s)")
|
| 348 |
+
-
|
| 349 |
+
- batch_jobs: List[Dict[str, Any]] = []
|
| 350 |
+
- for scene_num, meta_path in batch:
|
| 351 |
+
- scene_out_dir = out_root / scene_num
|
| 352 |
+
- scene_out_dir.mkdir(parents=True, exist_ok=True)
|
| 353 |
+
- batch_jobs.append({
|
| 354 |
+
- "metadata_path": str(meta_path),
|
| 355 |
+
- "model": model,
|
| 356 |
+
- "output_dir": str(scene_out_dir),
|
| 357 |
+
- "loading_command_steps": loading_command_steps,
|
| 358 |
+
- "max_steps": max_steps,
|
| 359 |
+
- "use_vllm": use_vllm,
|
| 360 |
+
- "vllm_url": vllm_url,
|
| 361 |
+
- "use_friday": use_friday,
|
| 362 |
+
- "_scene_num": scene_num,
|
| 363 |
+
- })
|
| 364 |
+
-
|
| 365 |
+
- with ProcessPoolExecutor(max_workers=len(batch_jobs), mp_context=mp_ctx) as pool:
|
| 366 |
+
- futures = {pool.submit(_worker_eval, job): job for job in batch_jobs}
|
| 367 |
+
- for future in as_completed(futures):
|
| 368 |
+
- job = futures[future]
|
| 369 |
+
- scene_num = job["_scene_num"]
|
| 370 |
+
- try:
|
| 371 |
+
- summary = future.result()
|
| 372 |
+
- all_results.append(summary)
|
| 373 |
+
- task_icon = "✅" if summary.get("all_milestones_done") else "❌"
|
| 374 |
+
- logger.info(f"[DONE] {scene_num} {task_icon} | milestones {summary.get('milestones_completed', 0)}/{summary.get('milestones_trackable', 0)}")
|
| 375 |
+
- except Exception as exc:
|
| 376 |
+
- logger.error(f"[ERROR] {scene_num}: {exc}")
|
| 377 |
+
- summary = _make_error_summary(scene_num, exc)
|
| 378 |
+
- all_results.append(summary)
|
| 379 |
+
-
|
| 380 |
+
- total = len(all_results)
|
| 381 |
+
+ urls = [u for u in os.getenv("MC_SANDBOX_URLS", "").split(",") if u]
|
| 382 |
+
+ jobs: List[Dict[str, Any]] = []
|
| 383 |
+
+ for i, (scene_num, meta_path) in enumerate(pending):
|
| 384 |
+
+ scene_out_dir = out_root / scene_num
|
| 385 |
+
+ scene_out_dir.mkdir(parents=True, exist_ok=True)
|
| 386 |
+
+ jobs.append({
|
| 387 |
+
+ "metadata_path": str(meta_path),
|
| 388 |
+
+ "model": model,
|
| 389 |
+
+ "output_dir": str(scene_out_dir),
|
| 390 |
+
+ "loading_command_steps": loading_command_steps,
|
| 391 |
+
+ "max_steps": max_steps,
|
| 392 |
+
+ "frame_size": frame_size,
|
| 393 |
+
+ "use_vllm": use_vllm,
|
| 394 |
+
+ "vllm_url": vllm_url,
|
| 395 |
+
+ "use_friday": use_friday,
|
| 396 |
+
+ "end_on_esc": end_on_esc,
|
| 397 |
+
+ "sandbox_url": urls[i % len(urls)] if urls else None,
|
| 398 |
+
+ "_scene_num": scene_num,
|
| 399 |
+
+ })
|
| 400 |
+
+
|
| 401 |
+
+ with ProcessPoolExecutor(max_workers=min(num_workers, len(jobs)), mp_context=mp_ctx) as pool:
|
| 402 |
+
+ futures = {pool.submit(_worker_eval, job): job for job in jobs}
|
| 403 |
+
+ for future in as_completed(futures):
|
| 404 |
+
+ job = futures[future]
|
| 405 |
+
+ scene_num = job["_scene_num"]
|
| 406 |
+
+ try:
|
| 407 |
+
+ summary = future.result()
|
| 408 |
+
+ all_results.append(summary)
|
| 409 |
+
+ task_icon = "✅" if summary.get("all_milestones_done") else "❌"
|
| 410 |
+
+ logger.info(f"[DONE] {scene_num} {task_icon} | milestones {summary.get('milestones_completed', 0)}/{summary.get('milestones_trackable', 0)}")
|
| 411 |
+
+ except Exception as exc:
|
| 412 |
+
+ logger.error(f"[ERROR] {scene_num}: {exc}")
|
| 413 |
+
+ summary = _make_error_summary(scene_num, exc)
|
| 414 |
+
+ all_results.append(summary)
|
| 415 |
+
+
|
| 416 |
+
+ scored_results = [r for r in all_results if r.get("milestones_trackable", 0) > 0 or r.get("error")]
|
| 417 |
+
+ total = len(scored_results)
|
| 418 |
+
done = sum(1 for r in all_results if r.get("all_milestones_done"))
|
| 419 |
+
ms_done = sum(r.get("milestones_completed", 0) for r in all_results)
|
| 420 |
+
ms_total = sum(r.get("milestones_trackable", 0) for r in all_results)
|
| 421 |
+
@@ -539,7 +614,7 @@ def eval_benchmark(
|
| 422 |
+
logger.info("-" * 60)
|
| 423 |
+
task_rate = done / total * 100 if total > 0 else 0.0
|
| 424 |
+
ms_rate = ms_done / ms_total * 100 if ms_total > 0 else 0.0
|
| 425 |
+
- logger.info(f"Scenes: {total} | Tasks: {done}/{total} ({task_rate:.1f}%) | Milestones: {ms_done}/{ms_total} ({ms_rate:.1f}%)")
|
| 426 |
+
+ logger.info(f"Scenes: {len(all_results)} | Eligible tasks: {done}/{total} ({task_rate:.1f}%) | Milestones: {ms_done}/{ms_total} ({ms_rate:.1f}%)")
|
| 427 |
+
logger.info(f"{'='*60}")
|
| 428 |
+
|
| 429 |
+
agg_path = out_root / "eval_summary.json"
|
| 430 |
+
@@ -548,6 +623,7 @@ def eval_benchmark(
|
| 431 |
+
"model": model,
|
| 432 |
+
"benchmark": str(bench_path),
|
| 433 |
+
"total": total,
|
| 434 |
+
+ "excluded": len(all_results) - total,
|
| 435 |
+
"done": done,
|
| 436 |
+
"ms_done": ms_done,
|
| 437 |
+
"ms_total": ms_total,
|
patches/sandbox_mc_server.patch
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
--- a/mc_server.py
|
| 2 |
+
+++ b/mc_server.py
|
| 3 |
+
@@ -118,6 +118,30 @@
|
| 4 |
+
raise RuntimeError("obs/info contains neither 'image' nor 'pov'")
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
+_INFO_SKIP = {"pov", "image", "location_stats", "equipped_items", "custom"}
|
| 8 |
+
+
|
| 9 |
+
+
|
| 10 |
+
+def info_for_client(info: dict) -> dict:
|
| 11 |
+
+ """Judge-relevant fields of the sim info (the released server returned none)."""
|
| 12 |
+
+ out = {k: convert_numpy_types(dict(v) if isinstance(v, dict) else v)
|
| 13 |
+
+ for k, v in (info or {}).items() if k not in _INFO_SKIP}
|
| 14 |
+
+ inv = {}
|
| 15 |
+
+ for slot in (info or {}).get("inventory", {}).values():
|
| 16 |
+
+ if slot.get("type") not in (None, "none", "air"):
|
| 17 |
+
+ inv[slot["type"]] = inv.get(slot["type"], 0) + int(slot.get("quantity", 0))
|
| 18 |
+
+ out["inventory"] = inv
|
| 19 |
+
+ raw = (info or {}).get("inventory", {})
|
| 20 |
+
+ hotbar = []
|
| 21 |
+
+ for i in range(9):
|
| 22 |
+
+ slot = raw.get(i) or raw.get(str(i)) or {}
|
| 23 |
+
+ t = slot.get("type")
|
| 24 |
+
+ hotbar.append(None if t in (None, "none", "air") else f"{t} x{int(slot.get('quantity', 0))}")
|
| 25 |
+
+ out["hotbar"] = hotbar
|
| 26 |
+
+ hand = (info or {}).get("equipped_items", {}).get("mainhand", {})
|
| 27 |
+
+ out["mainhand"] = hand.get("type") if isinstance(hand, dict) else None
|
| 28 |
+
+ return out
|
| 29 |
+
+
|
| 30 |
+
+
|
| 31 |
+
# =========================
|
| 32 |
+
# API 实现
|
| 33 |
+
# =========================
|
| 34 |
+
@@ -224,6 +248,10 @@
|
| 35 |
+
return err(f"session '{session_id}' not found")
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
+ if session.env_type == "minestudio":
|
| 39 |
+
+ # MineRL clears its seed at the end of every reset (even a failed one), so a retried reset
|
| 40 |
+
+ # would build a random world; re-apply the create_env seed each time
|
| 41 |
+
+ session.env.env.seed(session.env.seed)
|
| 42 |
+
result = session.env.reset()
|
| 43 |
+
|
| 44 |
+
if isinstance(result, tuple):
|
| 45 |
+
@@ -276,6 +304,7 @@
|
| 46 |
+
"screenshot": screenshot,
|
| 47 |
+
"reward": float(reward),
|
| 48 |
+
"done": done,
|
| 49 |
+
+ "info": info_for_client(info),
|
| 50 |
+
"timing": {
|
| 51 |
+
"env_step_ms": round((t2 - t1) * 1000, 1),
|
| 52 |
+
"encode_ms": round((t4 - t3) * 1000, 1),
|