corridorkey-mrp-mlx/tests/test_decoder_parity.py
cmoyates 091261ccff
feat(phase2): MLX decoder + refiner with parity tests
- MLP, DecoderHead (SegFormer-style), RefinerBlock, CNNRefinerModule
- All NHWC native, GroupNorm with pytorch_compatible=True
- Layout utils: NCHW<->NHWC, conv weight transpose (OIHW->OHWI)
- Dump script extended to export decoder/refiner weights
- Parity: decoder max_abs ~5e-6, refiner max_abs ~1e-5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 05:00:00 -03:30

151 lines
4.8 KiB
Python

"""Parity tests: MLX decoder heads vs PyTorch reference (Phase 2).
Uses saved backbone features -> runs MLX decoder -> compares
against saved PyTorch coarse predictions.
"""
from __future__ import annotations
from pathlib import Path
import mlx.core as mx
import numpy as np
import pytest
from corridorkey_mlx.model.decoder import DecoderHead
from corridorkey_mlx.utils.layout import conv_weight_pt_to_mlx, nchw_to_nhwc_np, nhwc_to_nchw_np
FIXTURE_PATH = Path("reference/fixtures/golden.npz")
WEIGHTS_PATH = Path("reference/fixtures/golden_weights.npz")
BACKBONE_CHANNELS = [112, 224, 448, 896]
EMBED_DIM = 256
MAX_ABS_TOL = 1e-4 # relaxed slightly for float32 Metal vs CPU differences
def _skip_if_missing() -> None:
if not FIXTURE_PATH.exists() or not WEIGHTS_PATH.exists():
pytest.skip("Fixture files not found — run dump_pytorch_reference.py first")
def _load_decoder_weights(
prefix: str,
weights: dict[str, np.ndarray],
) -> dict[str, mx.array]:
"""Convert PyTorch decoder state_dict to MLX parameter dict.
Maps PyTorch keys (e.g. 'alpha_decoder.linear_c1.proj.weight') to
MLX nested keys (e.g. 'linear_c1.proj.weight'), transposing conv weights.
"""
params: dict[str, mx.array] = {}
for pt_key, value in weights.items():
if not pt_key.startswith(prefix):
continue
mlx_key = pt_key[len(prefix) :]
# Conv2d weights: (O,I,H,W) -> (O,H,W,I)
if "linear_fuse.weight" in mlx_key or "classifier.weight" in mlx_key:
value = conv_weight_pt_to_mlx(value)
# BatchNorm: running_mean -> running_mean, running_var -> running_var
# PyTorch uses num_batches_tracked which MLX doesn't need
if "num_batches_tracked" in mlx_key:
continue
params[mlx_key] = mx.array(value)
return params
def _params_to_nested(flat: dict[str, mx.array]) -> dict:
"""Convert flat dot-separated keys to nested dict for mlx load_weights."""
nested: dict = {}
for key, value in flat.items():
parts = key.split(".")
current = nested
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
return nested
def _build_and_load_decoder(
output_dim: int,
prefix: str,
weights: dict[str, np.ndarray],
) -> DecoderHead:
"""Build an MLX DecoderHead and load converted weights."""
decoder = DecoderHead(BACKBONE_CHANNELS, EMBED_DIM, output_dim)
flat_params = _load_decoder_weights(prefix, weights)
nested_params = _params_to_nested(flat_params)
decoder.load_weights(list(_flatten_nested(nested_params)))
decoder.eval() # use running stats for BatchNorm (not batch stats)
mx.eval(decoder.parameters()) # noqa: S307 — mx.eval is MLX lazy eval, not Python eval
return decoder
def _flatten_nested(d: dict, prefix: str = "") -> list[tuple[str, mx.array]]:
"""Flatten nested dict to list of (dotted_key, array) pairs."""
items: list[tuple[str, mx.array]] = []
for k, v in d.items():
full_key = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
items.extend(_flatten_nested(v, full_key))
else:
items.append((full_key, v))
return items
def _run_decoder_parity(
output_dim: int,
prefix: str,
expected_key: str,
) -> None:
"""Run a decoder parity test."""
_skip_if_missing()
fixtures = dict(np.load(FIXTURE_PATH))
weights = dict(np.load(WEIGHTS_PATH))
# Load features in NHWC
features_nhwc = [mx.array(nchw_to_nhwc_np(fixtures[f"encoder_feature_{i}"])) for i in range(4)]
decoder = _build_and_load_decoder(output_dim, prefix, weights)
result_nhwc = decoder(features_nhwc)
mx.eval(result_nhwc) # noqa: S307 — mx.eval is MLX lazy eval, not Python eval
# Convert result back to NCHW for comparison
result_nchw = nhwc_to_nchw_np(np.array(result_nhwc))
expected = fixtures[expected_key]
max_abs_err = float(np.max(np.abs(result_nchw - expected)))
mean_abs_err = float(np.mean(np.abs(result_nchw - expected)))
print(f"\n{prefix} parity — max_abs: {max_abs_err:.6e}, mean_abs: {mean_abs_err:.6e}")
assert result_nchw.shape == expected.shape, (
f"Shape mismatch: {result_nchw.shape} vs {expected.shape}"
)
assert max_abs_err < MAX_ABS_TOL, (
f"Max abs error {max_abs_err:.6e} exceeds tolerance {MAX_ABS_TOL}"
)
def test_alpha_decoder_parity() -> None:
"""MLX alpha decoder matches PyTorch within tolerance."""
_run_decoder_parity(
output_dim=1,
prefix="alpha_decoder.",
expected_key="alpha_logits",
)
def test_fg_decoder_parity() -> None:
"""MLX foreground decoder matches PyTorch within tolerance."""
_run_decoder_parity(
output_dim=3,
prefix="fg_decoder.",
expected_key="fg_logits",
)