Source: scripts/run_rgbd_nonlearned_fusion.py · Plain text

scripts/run_rgbd_nonlearned_fusion.py

#!/usr/bin/env python3
"""Non-learned RGB-D fusion baseline: lock, TRAIN24 fit, sealed DEV/FRESH-V1 evaluation, report."""
# ruff: noqa: E501 -- Chinese report prose

from __future__ import annotations

import argparse
import datetime
import itertools
import json
from collections import defaultdict
from pathlib import Path

import numpy as np
import torch

from mcss.geometry import transform_cameras
from mcss.measurements import FixedMeasurementRenderer
from mcss.mechanism_pilot.direct_capacity_contracts import CapacityEvaluator, StateSealBarrier
from mcss.mechanism_pilot.direct_capacity_evaluation import _metrics
from mcss.mechanism_pilot.direct_capacity_statistics import TIE, paired
from mcss.mechanism_pilot.geometry_carrier_statistics import _evidence, _macro
from mcss.mechanism_pilot.rgbd_evidence_carrier import RGBDSceneData
from mcss.mechanism_pilot.rgbd_fresh_evaluation import FreshRGBDSceneData
from mcss.mechanism_pilot.rgbd_nonlearned_fusion import GRID_SEARCH, fused_state
from mcss.mechanism_pilot.small_training import sha, write_json

EXPERIMENT = "EXP-3D-RGBD-NONLEARNED-FUSION-V1"
V5 = Path("outputs/EXP-3D-RGBD-EVIDENCE-CARRIER-V5")
FRESH = Path("outputs/EXP-3D-RGBD-FRESH-QUALIFICATION-V1")
INPUTS = (
    V5 / "scene_split.json",
    V5 / "train_depth_prior.json",
    V5 / "raw/dev_query_results.json",
    V5 / "raw/dev_reference_rows.json",
    FRESH / "scene_split.json",
    FRESH / "train_depth_prior.json",
    FRESH / "raw/fresh_query_results.json",
    FRESH / "raw/fresh_reference_rows.json",
)
SOURCES = (
    Path("src/mcss/mechanism_pilot/rgbd_nonlearned_fusion.py"),
    Path("src/mcss/mechanism_pilot/rgbd_evidence_carrier.py"),
    Path("src/mcss/mechanism_pilot/rgbd_fresh_evaluation.py"),
    Path("src/mcss/measurements.py"),
    Path(__file__),
)


def lock(root):
    root.mkdir(parents=True, exist_ok=False)
    write_json(
        root / "preregistration.json",
        {
            "status": "FROZEN_BEFORE_FIT_AND_EVALUATION",
            "experiment": EXPERIMENT,
            "created_utc": datetime.datetime.now(datetime.UTC).isoformat(),
            "grid_search": GRID_SEARCH,
            "objective": "mean GT-valid query AbsRel (RAW) over TRAIN24 primary queries, roles A/B; "
            "ties -> first combination in itertools.product order of k, a, b, c",
            "evaluation": "DEV (8) and FRESH-V1 (5): every state sealed before any query GT; "
            "same renderer, bounds, anchoring and metric code as V5",
            "comparisons": {
                "LEARNED_MINUS_NLF": "AbsRel(NLF) - AbsRel(V5 C1, seed-mean); positive = learned better",
                "NLF_MINUS_CONSTANT": "AbsRel(REF_TRAIN_ABSREL_OPTIMAL) - AbsRel(NLF); positive = NLF better",
                "RGB_ONLY_MINUS_NLF": "AbsRel(NLF) - AbsRel(V5 C0); positive = RGB-only carrier better",
            },
            "labels": "ABOVE (frozen V2 evidence rule) / BELOW (mirror) / NOT_DISTINGUISHABLE; "
            "all scenes and NO_HIT-excluded scenes",
            "bootstrap": {"unit": "scene", "draws": 10000, "seed": 20260929},
            "role": "descriptive control; changes no frozen status",
            "device": "cpu",
            "input_sha256": {str(p): sha(p) for p in INPUTS},
            "source_sha256": {str(p): sha(p) for p in SOURCES},
        },
    )
    print("LOCKED", root)


def verify(root):
    lock_ = json.loads((root / "preregistration.json").read_text())
    if lock_["status"] != "FROZEN_BEFORE_FIT_AND_EVALUATION":
        raise PermissionError("Frozen lock required")
    for group in ("input_sha256", "source_sha256"):
        for name, digest in lock_[group].items():
            if sha(Path(name)) != digest:
                raise PermissionError(f"Frozen file changed: {name}")
    return lock_


def fit(manifest):
    renderer = FixedMeasurementRenderer(n_samples=64, ray_chunk_size=2048)
    records = sorted(
        (r for r in manifest["scenes"] if r["split"] == "TRAIN"), key=lambda r: r["scene_id"]
    )
    access = []
    scenes = [RGBDSceneData(r, manifest, V5, "cpu", access) for r in records]
    grid = list(itertools.product(*(GRID_SEARCH[k] for k in ("k", "a", "b", "c"))))
    results = []
    for k, a, b, c in grid:
        params = {"k": k, "a": a, "b": b, "c": c}
        values = []
        for scene in scenes:
            for role in ("A", "B"):
                state, anchor = fused_state(scene.context(role), scene.bounds[role], params)
                for slot in range(len(scene.record["roles"]["primary_query"])):
                    target = scene.target(slot)
                    cameras = transform_cameras(target.cameras, torch.linalg.inv(anchor))
                    with torch.no_grad():
                        pred = renderer(state, cameras, ("rgb", "depth", "visibility"))
                    values.append(_metrics(pred, target.rgb, target.depth)["depth_absrel"])
        results.append(
            {**params, "train24_query_absrel": float(np.mean(values)), "views": len(values)}
        )
        print("FIT", params, round(results[-1]["train24_query_absrel"], 4), flush=True)
    best = min(results, key=lambda r: r["train24_query_absrel"])
    return {k: best[k] for k in ("k", "a", "b", "c")}, results


def evaluate(manifest, base, split, scene_cls, params, access):
    records = {r["scene_id"]: r for r in manifest["scenes"] if r["split"] == split}
    forbidden = set(manifest.get("protected_scene_ids", [])) | {
        r["scene_id"] for r in manifest["scenes"] if r["split"] != split
    }
    keys = [f"{sid}/{role}" for sid in records for role in ("A", "B")]
    barrier = StateSealBarrier(keys)
    anchors = {}
    for sid, record in records.items():
        data = scene_cls(record, manifest, base, "cpu", access)
        for role in ("A", "B"):
            state, anchor = fused_state(data.context(role), data.bounds[role], params)
            barrier.seal(f"{sid}/{role}", sid, state)
            anchors[sid, role] = anchor
    barrier.assert_ready()
    access.append({"event": f"ALL_{split}_NLF_STATES_SEALED", "state_count": len(keys)})
    renderer = FixedMeasurementRenderer(n_samples=64, ray_chunk_size=2048)
    rows = []
    for sid, record in records.items():
        evaluator = CapacityEvaluator(
            record,
            manifest["image_size"],
            base,
            "cpu",
            barrier=barrier,
            capacity_scene_ids=tuple(records),
            holdout_scene_ids=tuple(forbidden),
            access_log=access,
        )
        for role in ("A", "B"):
            state = barrier.state(f"{sid}/{role}")
            for qid in record["roles"]["primary_query"]:
                batch = evaluator.query(qid)
                cameras = transform_cameras(batch.cameras, torch.linalg.inv(anchors[sid, role]))
                with torch.no_grad():
                    pred = renderer(state, cameras, ("rgb", "depth", "visibility"))
                metrics = _metrics(pred, batch.rgb, batch.depth)
                rows.append(
                    {
                        "scene_id": sid,
                        "role": role,
                        "query_id": qid,
                        "seed": 0,
                        "variant": "NLF",
                        "method": "direct",
                        "depth_absrel": metrics["depth_absrel"],
                        "depth_delta1": metrics["depth_delta1"],
                        "opacity": metrics["opacity"],
                    }
                )
    return rows


def label(stats):
    x = np.asarray(list(stats["per_scene"].values()))
    if _evidence(stats):
        return "ABOVE"
    if stats["mean"] < 0 and stats["ci95"][1] < 0 and np.mean(x <= TIE) >= 0.75:
        return "BELOW"
    return "NOT_DISTINGUISHABLE"


def compare(nlf_rows, carrier_rows, reference_rows, exclude=()):
    keep = lambda rows: [r for r in rows if r["scene_id"] not in exclude]  # noqa: E731
    nlf = _macro(keep(nlf_rows), "depth_absrel")
    c1 = _macro(
        keep([r for r in carrier_rows if r["variant"] == "C1" and r["method"] == "direct"]),
        "depth_absrel",
    )
    c0 = _macro(
        keep([r for r in carrier_rows if r["variant"] == "C0" and r["method"] == "direct"]),
        "depth_absrel",
    )
    const = _macro(
        keep(
            [
                r
                for r in reference_rows
                if r["reference"] == "REF_TRAIN_ABSREL_OPTIMAL" and r["readout"] == "RAW"
            ]
        ),
        "depth_absrel",
    )
    args = {"draws": 10000, "seed": 20260929}
    out = {
        "means": {
            k: float(np.mean(list(v.values())))
            for k, v in (("NLF", nlf), ("C1", c1), ("C0", c0), ("constant", const))
        },
        "per_scene": {
            s: {"NLF": nlf[s], "C1": c1[s], "C0": c0[s], "constant": const[s]} for s in sorted(nlf)
        },
    }
    for name, stats in (
        ("LEARNED_MINUS_NLF", paired(nlf, c1, **args)),
        ("NLF_MINUS_CONSTANT", paired(const, nlf, **args)),
        ("RGB_ONLY_MINUS_NLF", paired(nlf, c0, **args)),
    ):
        out[name] = {"stats": stats, "label": label(stats)}
    return out


def no_hit(carrier_rows):
    """Scenes whose every direct V5 prediction had zero opacity (rays outside the bounds)."""
    groups = defaultdict(list)
    for r in carrier_rows:
        if r["method"] == "direct":
            groups[r["scene_id"]].append(r["opacity"] == 0)
    return sorted(s for s, flags in groups.items() if all(flags))


def run(root):
    lock_ = verify(root)
    v5_manifest = json.loads((V5 / "scene_split.json").read_text())
    torch.set_num_threads(1)
    params, grid = fit(v5_manifest)
    write_json(root / "fit_results.json", {"selected": params, "grid": grid})
    access = []
    dev_rows = evaluate(v5_manifest, V5, "DEV", RGBDSceneData, params, access)
    fresh_manifest = json.loads((FRESH / "scene_split.json").read_text())
    fresh_rows = evaluate(
        fresh_manifest, FRESH, "FRESH_QUALIFICATION", FreshRGBDSceneData, params, access
    )
    write_json(root / "audit/gt_access.json", access)
    write_json(root / "raw/nlf_dev_rows.json", dev_rows)
    write_json(root / "raw/nlf_fresh_rows.json", fresh_rows)
    v5_rows = json.loads((V5 / "raw/dev_query_results.json").read_text())
    fresh_carrier = json.loads((FRESH / "raw/fresh_query_results.json").read_text())
    dev_ref = json.loads((V5 / "raw/dev_reference_rows.json").read_text())
    fresh_ref = json.loads((FRESH / "raw/fresh_reference_rows.json").read_text())
    results = {
        "selected_params": params,
        "DEV": compare(dev_rows, v5_rows, dev_ref),
        "DEV_hit_scenes": compare(dev_rows, v5_rows, dev_ref, exclude=no_hit(v5_rows)),
        "FRESH_V1": compare(fresh_rows, fresh_carrier, fresh_ref),
        "no_hit_scenes": {"DEV": no_hit(v5_rows), "FRESH_V1": no_hit(fresh_carrier)},
        "lock_sha256": sha(root / "preregistration.json"),
    }
    write_json(root / "results.json", results)
    lines = [
        f"# {EXPERIMENT}",
        "",
        "不经学习的 RGB-D 融合基线:上下文深度直接体素化进同一个 16³ 网格、同一 bounds,用同一个固定 renderer 读出;只有 4 个标量在 TRAIN24 上拟合,"
        f"选中 {params}。所有状态在读取 query GT 之前封存。",
        "",
    ]
    for name in ("DEV", "DEV_hit_scenes", "FRESH_V1"):
        r = results[name]
        m = r["means"]
        lines += [
            f"## {name}",
            "",
            f"平均 AbsRel:NLF {m['NLF']:.4f},V5 C1(学习,测得深度){m['C1']:.4f},V5 C0(学习,只用 RGB){m['C0']:.4f},常数 {m['constant']:.4f}。",
            "",
        ]
        for key in ("LEARNED_MINUS_NLF", "NLF_MINUS_CONSTANT", "RGB_ONLY_MINUS_NLF"):
            s = r[key]["stats"]
            lines.append(
                f"- {key} = {s['mean']:+.4f},95% CI [{s['ci95'][0]:+.4f}, {s['ci95'][1]:+.4f}],{r[key]['label']}"
            )
        lines.append("")
    (root / "README.md").write_text("\n".join(lines) + "\n")
    print(
        json.dumps(
            {k: results[k]["means"] for k in ("DEV", "DEV_hit_scenes", "FRESH_V1")}, indent=1
        )
    )
    print("NLF_COMPLETE", lock_["experiment"])


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--stage", choices=("lock", "run"), required=True)
    parser.add_argument("--root", type=Path, default=Path("outputs") / EXPERIMENT)
    args = parser.parse_args()
    if args.stage == "lock":
        lock(args.root)
    else:
        (args.root / "raw").mkdir(exist_ok=True)
        (args.root / "audit").mkdir(exist_ok=True)
        run(args.root)