lnp_ml/tests/test_moe.py

173 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""MoE 模块自检脚本
约定:
- 每个 test_xxx 函数返回 None失败时直接抛 AssertionError 或 RuntimeError。
- main() 收集所有 test_xxx 函数依次执行,统计 pass/fail 并以非零状态退出。
"""
from __future__ import annotations
import sys
import traceback
from typing import Callable, List, Tuple
import torch
from lnp_ml.modeling.layers.moe import MoEAttentionPool, MoEBlock, MoERouter
# ──────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────
def _expect_value_error(fn: Callable[[], object], match: str = "") -> None:
"""断言调用 fn 会抛 ValueError且消息包含 match若给定"""
try:
fn()
except ValueError as e:
if match and match not in str(e):
raise AssertionError(
f"ValueError 抛出了,但消息中未包含 {match!r}: {e}"
)
return
raise AssertionError(f"期望抛出 ValueError{fn} 正常返回")
# ──────────────────────────────────────────────────────────────────────
# Tests
# ──────────────────────────────────────────────────────────────────────
def test_moe_block_shape() -> None:
B, T_chem, T_tab, d, K = 2, 4, 4, 256, 4
block = MoEBlock(d_model=d, n_chem_tokens=T_chem, n_experts=K, top_k=2)
chem = torch.randn(B, T_chem, d)
tab = torch.randn(B, T_tab, d)
F_moe, extras = block(chem, tab)
assert F_moe.shape == (B, d), f"F_moe shape {F_moe.shape} != ({B}, {d})"
assert extras["lb_loss"].dim() == 0
assert extras["lb_loss"].requires_grad
assert extras["gates"].shape == (B, K)
assert extras["probs"].shape == (B, K)
def test_moe_block_nchem3() -> None:
B, T_chem, T_tab, d, K = 2, 3, 4, 256, 4
block = MoEBlock(d_model=d, n_chem_tokens=T_chem, n_experts=K, top_k=2)
chem = torch.randn(B, T_chem, d)
tab = torch.randn(B, T_tab, d)
F_moe, extras = block(chem, tab)
assert F_moe.shape == (B, d), f"F_moe shape {F_moe.shape} != ({B}, {d})"
assert block.experts[0].net[0].in_features == T_chem * d, "expert 输入维未随 n_chem 自适应"
assert extras["gates"].shape == (B, K)
def test_moe_gates_topk_normalization() -> None:
block = MoEBlock(d_model=64, n_chem_tokens=4, n_experts=4, top_k=2)
chem = torch.randn(8, 4, 64)
tab = torch.randn(8, 4, 64)
_, extras = block(chem, tab)
gates = extras["gates"]
assert torch.allclose(gates.sum(dim=-1), torch.ones(8), atol=1e-5), \
f"gates 每行未归一为 1: {gates.sum(dim=-1)}"
nonzero_per_row = (gates > 0).sum(dim=-1).max().item()
assert nonzero_per_row <= 2, f"top_k=2 但某行有 {nonzero_per_row} 个非零"
def test_moe_load_balancing_loss_uniform() -> None:
"""随机初始化 + 大 batchlb_loss 应接近 Switch 风格的 uniform 极限。
注意:
Switch 风格 lb_loss = K * Σ f_i * p_i。
- Σ f_i = top_k top-k mask 的列和约束)
- Σ p_i = 1
- 完全 uniform 极限 ⇒ lb_loss = top_k
随机初始化下 f 与 p 因 top-k 选择天然正相关,
实际值会略高于 top_k但远低于 KK 是塌缩极限)。
"""
torch.manual_seed(42)
block = MoEBlock(d_model=64, n_chem_tokens=4, n_experts=4, top_k=2)
chem = torch.randn(64, 4, 64)
tab = torch.randn(64, 4, 64)
_, extras = block(chem, tab)
lb = extras["lb_loss"].item()
expected = block.top_k
upper = (block.top_k + block.n_experts) / 2 # uniform 与塌缩的中点
assert expected * 0.8 <= lb <= upper, (
f"lb_loss={lb:.3f} 偏离理想 uniform 值 {expected} 过远"
f"(健康区间 ~[{expected*0.8:.2f}, {upper:.2f}]"
)
def test_moe_invalid_top_k() -> None:
_expect_value_error(
lambda: MoEBlock(d_model=64, n_chem_tokens=4, n_experts=4, top_k=5)
)
def test_moe_chem_token_mismatch() -> None:
block = MoEBlock(d_model=64, n_chem_tokens=4, n_experts=4, top_k=2)
chem = torch.randn(2, 3, 64)
tab = torch.randn(2, 4, 64)
_expect_value_error(lambda: block(chem, tab), match="chem token")
def test_moe_router_eval_no_jitter() -> None:
"""评估态下 jitter 不生效:相同输入应得相同 gates。"""
router = MoERouter(d_model=32, n_experts=4, top_k=2, jitter_noise=0.5)
router.eval()
q = torch.randn(4, 32)
g1, _, _ = router(q)
g2, _, _ = router(q)
assert torch.allclose(g1, g2), "eval 模式下相同输入产生了不同 gates"
def test_moe_attention_pool_shape() -> None:
pool = MoEAttentionPool(d_model=128)
x = torch.randn(3, 5, 128)
out = pool(x)
assert out.shape == (3, 128), f"pool out shape {out.shape} != (3, 128)"
# ──────────────────────────────────────────────────────────────────────
# Runner
# ──────────────────────────────────────────────────────────────────────
def _collect_tests() -> List[Tuple[str, Callable[[], None]]]:
return [
(name, fn) for name, fn in globals().items()
if name.startswith("test_") and callable(fn)
]
def main() -> int:
tests = _collect_tests()
pass_count = 0
fail: List[Tuple[str, str]] = []
for name, fn in tests:
try:
fn()
except Exception:
fail.append((name, traceback.format_exc()))
print(f"[FAIL] {name}")
else:
pass_count += 1
print(f"[ OK ] {name}")
print(f"\n{'='*60}")
print(f"Passed: {pass_count}/{len(tests)}")
if fail:
print(f"\n{'='*60}\nFailures:")
for name, tb in fail:
print(f"\n--- {name} ---\n{tb}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())