Add ComposedRouter for multi-axis MoE gating
Route on several independent axes at once (e.g. energy x pdg), each with
its own expert count and hyperparameters. The joint gate is the outer
product of per-axis softmax gates, so it stays a partition of unity and
top1/balance_loss factor per-axis. Config uses flat axis{i}_{field} keys
in model.router (TOML/CLI friendly), also settable via repeatable
--router-axis flags.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+128
-19
@@ -1,5 +1,6 @@
|
||||
import inspect
|
||||
import math
|
||||
import re
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
@@ -444,6 +445,79 @@ class ProcessRouter(Router):
|
||||
return F.cross_entropy(self.logits(cond_cont, cond_cat), labels)
|
||||
|
||||
|
||||
class ComposedRouter(Router):
|
||||
"""Joint router over independent axes (e.g. energy x pdg), outer-product gated.
|
||||
|
||||
Wraps N already-built sub-routers, each free to have its own
|
||||
`n_experts` and hyperparameters (an `EnergyRouter(n_experts=4, ...)`
|
||||
composed with a `PdgRouter(n_experts=3, ...)` needs no axis to match
|
||||
the other's expert count). The joint gate is the outer product of the
|
||||
per-axis softmax gates, flattened to `(B, prod(n_experts_i))` — still a
|
||||
partition of unity, since each factor is one. Because the axes are
|
||||
routed independently, the joint argmax factors into the per-axis
|
||||
argmaxes, so `top1` (inherited from `Router`) costs no more than
|
||||
routing each axis alone despite the multiplicative expert count; the
|
||||
same is true of `balance_loss` (inherited, computed on the flattened
|
||||
joint gate — now one importance term per *joint* expert cell).
|
||||
|
||||
Not registered in `ROUTER_REGISTRY` / buildable via `build_router`,
|
||||
since those assume one `n_experts` int shared by a single router type;
|
||||
use `build_composed_router` instead, which resolves a list of per-axis
|
||||
specs (each independently typed and sized) through `build_router`.
|
||||
"""
|
||||
|
||||
def __init__(self, routers: list[Router]) -> None:
|
||||
if not routers:
|
||||
raise ValueError("ComposedRouter needs at least one sub-router")
|
||||
n_experts = 1
|
||||
for r in routers:
|
||||
n_experts *= r.n_experts
|
||||
super().__init__(n_experts)
|
||||
self.routers = nn.ModuleList(routers)
|
||||
|
||||
def gate(self, cond_cont: torch.Tensor, cond_cat: torch.Tensor) -> torch.Tensor:
|
||||
joint = self.routers[0].gate(cond_cont, cond_cat) # (B, n_0)
|
||||
for router in self.routers[1:]:
|
||||
g = router.gate(cond_cont, cond_cat) # (B, n_i)
|
||||
joint = (joint.unsqueeze(-1) * g.unsqueeze(1)).flatten(
|
||||
1
|
||||
) # (B, prod so far)
|
||||
return joint
|
||||
|
||||
def classify_loss(
|
||||
self, cond_cont: torch.Tensor, cond_cat: torch.Tensor, labels: torch.Tensor
|
||||
) -> torch.Tensor:
|
||||
"""Sum of each sub-router's own classify_loss (0 for unsupervised axes)."""
|
||||
total = torch.zeros((), device=cond_cont.device)
|
||||
for router in self.routers:
|
||||
total = total + router.classify_loss(cond_cont, cond_cat, labels)
|
||||
return total
|
||||
|
||||
|
||||
def build_composed_router(specs: list[dict], **shared_kwargs) -> ComposedRouter:
|
||||
"""Build a `ComposedRouter` from a list of per-axis router specs.
|
||||
|
||||
Each spec is a `{"type": ..., "n_experts": ..., ...per-axis kwargs}`
|
||||
dict resolved through `build_router` exactly like a single-axis router
|
||||
config, so axes can differ in both expert count and hyperparameters
|
||||
(e.g. an energy axis's `temperature` vs a pdg axis's `emb_dim`).
|
||||
`shared_kwargs` (`pdg_vocab`, `mat_vocab`, ...) are merged under each
|
||||
spec, with the spec's own keys taking precedence.
|
||||
"""
|
||||
routers = [
|
||||
build_router(
|
||||
spec["type"],
|
||||
spec["n_experts"],
|
||||
**{
|
||||
**shared_kwargs,
|
||||
**{k: v for k, v in spec.items() if k not in ("type", "n_experts")},
|
||||
},
|
||||
)
|
||||
for spec in specs
|
||||
]
|
||||
return ComposedRouter(routers)
|
||||
|
||||
|
||||
class ExpertTrunk(nn.Module):
|
||||
"""One small expert: `input_proj -> ResBlock stack -> out_proj`.
|
||||
|
||||
@@ -669,6 +743,54 @@ _SEC_DECODER_MODEL_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
_AXIS_KEY_RE = re.compile(r"^axis(\d+)_(.+)$")
|
||||
|
||||
|
||||
def _parse_composed_axes(router_cfg: dict) -> list[dict]:
|
||||
"""Regroup `axis{i}_{field}` flat keys into a list of per-axis spec dicts.
|
||||
|
||||
Flat keys (rather than a nested list-of-dicts) keep composed-router
|
||||
config expressible in the same one-level-of-nesting TOML/CLI shape as
|
||||
every other router option (`model.router` stays a flat table of
|
||||
scalars) — e.g. `axis0_type = "energy"`, `axis0_n_experts = 4`,
|
||||
`axis1_type = "pdg"`, `axis1_n_experts = 3`, `axis1_emb_dim = 8`.
|
||||
Axis indices must be contiguous from 0; order follows the index, not
|
||||
dict insertion order (TOML/CLI merging doesn't preserve it reliably).
|
||||
"""
|
||||
axes: dict[int, dict] = {}
|
||||
for key, value in router_cfg.items():
|
||||
m = _AXIS_KEY_RE.match(key)
|
||||
if m is None:
|
||||
continue
|
||||
idx, field = int(m.group(1)), m.group(2)
|
||||
axes.setdefault(idx, {})[field] = value
|
||||
missing = set(range(len(axes))) - axes.keys()
|
||||
if missing:
|
||||
raise ValueError(f"composed router config has gaps at axis indices {missing}")
|
||||
return [axes[i] for i in range(len(axes))]
|
||||
|
||||
|
||||
def _build_router_from_cfg(router_cfg: dict, pdg_vocab: int, mat_vocab: int) -> Router:
|
||||
"""Resolve one `model.router` config into a `Router`, single-axis or composed.
|
||||
|
||||
`router_cfg["type"] == "composed"` reads `axis{i}_{field}` flat keys
|
||||
(see `_parse_composed_axes`) instead of a single `type`/`n_experts` pair.
|
||||
"""
|
||||
shared_vocab = dict(pdg_vocab=pdg_vocab, mat_vocab=mat_vocab)
|
||||
if router_cfg["type"] == "composed":
|
||||
return build_composed_router(_parse_composed_axes(router_cfg), **shared_vocab)
|
||||
router_kwargs = {
|
||||
k: v for k, v in router_cfg.items() if k not in ("enabled", "type", "n_experts")
|
||||
}
|
||||
# Not every router needs these (EnergyRouter doesn't declare them, so
|
||||
# build_router's kwarg filtering drops them silently) but ProcessRouter
|
||||
# needs its own pdg/material embeddings sized to match the checkpoint's
|
||||
# vocab, same as the trunk's ConditionEncoder.
|
||||
router_kwargs.setdefault("pdg_vocab", pdg_vocab)
|
||||
router_kwargs.setdefault("mat_vocab", mat_vocab)
|
||||
return build_router(router_cfg["type"], router_cfg["n_experts"], **router_kwargs)
|
||||
|
||||
|
||||
def build_models(model_config: dict) -> tuple[nn.Module, nn.Module]:
|
||||
"""Construct (stage1, sec_decoder) from a persisted/CLI model_config dict.
|
||||
|
||||
@@ -679,36 +801,23 @@ def build_models(model_config: dict) -> tuple[nn.Module, nn.Module]:
|
||||
"""
|
||||
router_cfg = model_config.get("router")
|
||||
if router_cfg and router_cfg.get("enabled"):
|
||||
router_kwargs = {
|
||||
k: v
|
||||
for k, v in router_cfg.items()
|
||||
if k not in ("enabled", "type", "n_experts")
|
||||
}
|
||||
# Not every router needs these (EnergyRouter doesn't declare them, so
|
||||
# build_router's kwarg filtering drops them silently) but
|
||||
# ProcessRouter needs its own pdg/material embeddings sized to match
|
||||
# the checkpoint's vocab, same as the trunk's ConditionEncoder.
|
||||
router_kwargs.setdefault("pdg_vocab", model_config["pdg_vocab"])
|
||||
router_kwargs.setdefault("mat_vocab", model_config["mat_vocab"])
|
||||
pdg_vocab = model_config["pdg_vocab"]
|
||||
mat_vocab = model_config["mat_vocab"]
|
||||
shared = dict(
|
||||
pdg_vocab=model_config["pdg_vocab"],
|
||||
mat_vocab=model_config["mat_vocab"],
|
||||
pdg_vocab=pdg_vocab,
|
||||
mat_vocab=mat_vocab,
|
||||
expert_hidden_dim=model_config.get("expert_hidden_dim", 128),
|
||||
expert_n_blocks=model_config.get("expert_n_blocks", 3),
|
||||
emb_dim=model_config.get("emb_dim", EMB_DIM),
|
||||
dropout=model_config.get("dropout", 0.1),
|
||||
)
|
||||
stage1 = RoutedDenoisingMLP(
|
||||
router=build_router(
|
||||
router_cfg["type"], router_cfg["n_experts"], **router_kwargs
|
||||
),
|
||||
router=_build_router_from_cfg(router_cfg, pdg_vocab, mat_vocab),
|
||||
k_max=model_config.get("k_max", K_MAX),
|
||||
**shared,
|
||||
)
|
||||
sec_decoder = RoutedSecondaryDecoder(
|
||||
router=build_router(
|
||||
router_cfg["type"], router_cfg["n_experts"], **router_kwargs
|
||||
),
|
||||
router=_build_router_from_cfg(router_cfg, pdg_vocab, mat_vocab),
|
||||
**shared,
|
||||
)
|
||||
return stage1, sec_decoder
|
||||
|
||||
Reference in New Issue
Block a user