mirror of
https://github.com/RYDE-WORK/lnp_ml.git
synced 2026-09-18 19:13:21 +08:00
173 lines
6.4 KiB
Python
173 lines
6.4 KiB
Python
"""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:
|
||
"""随机初始化 + 大 batch,lb_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,但远低于 K(K 是塌缩极限)。
|
||
"""
|
||
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()) |