Deduplicate n_sec_head/type_head MLPs into build_mlp_head (gitea #36)
CI / Format (ruff format) (push) Successful in 28s
CI / Lint (ruff check) (push) Successful in 29s
CI / Sync project version with tag (push) Has been skipped
CI / Lint (ruff check) (pull_request) Successful in 34s
CI / Type check (ty) (push) Successful in 37s
CI / Format (ruff format) (pull_request) Successful in 44s
CI / Type check (ty) (pull_request) Successful in 46s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Tests (pull_request) Successful in 4m5s
CI / Tests (push) Successful in 4m6s

The same two-layer classifier head (Linear(cond_out_dim, hidden_dim // 2)
-> SiLU -> Linear(hidden_dim // 2, out_dim)) was hand-rolled five times in
giant/model/models.py: Stage1Model.n_sec_head, Stage2OneShot.n_sec_head/
.type_head, and Stage2Autoregressive.n_sec_head/.type_head. The `// 2`
ratio and fixed 2-layer depth were undocumented magic numbers, and both
n_sec accuracy and secondary-species accuracy are known weak spots that
were untunable independently of the trunk they hang off.

Adds `build_mlp_head(in_dim, out_dim, hidden, depth, act)` to
giant/model/layers.py (depth=1 is a bare Linear; depth>=2 matches the old
hardcoded shape exactly), and a new `HeadConfig` (hidden_ratio, depth)
dataclass in giant/config.py, wired in as `stage1_model.heads.n_sec` and
`stage2_model.heads.{n_sec,type}` — split per head type (not one shared
block per stage) since n_sec and species prediction are called out as
separate weak spots that may want independent capacity. Defaults
(hidden_ratio=0.5, depth=2) reproduce the old hardcoded architecture
bit-for-bit, so every existing config.toml and migrated v0.2 checkpoint
is unaffected; no changes were needed to migrate_config or the legacy
migration surfaces. No new CLI flags, matching how other nested
sub-config (router.*, trunk.*) is set via config.toml rather than
per-field flags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 10:57:47 +02:00
parent c00ee91a74
commit 593c5f4d34
8 changed files with 310 additions and 26 deletions
+18
View File
@@ -55,6 +55,9 @@ def test_giant_config_to_dict_matches_default_config():
gconfig.NSecConfig,
gconfig.ParticleTypeConfig,
gconfig.AutoregressiveConfig,
gconfig.HeadConfig,
gconfig.Stage1HeadsConfig,
gconfig.Stage2HeadsConfig,
gconfig.Stage1ModelConfig,
gconfig.Stage2ModelConfig,
gconfig.TrainConfig,
@@ -94,6 +97,21 @@ def test_trunk_config_defaults_block_conditioning_to_add_for_both_stages():
assert gconfig.DEFAULT_CONFIG["stage2_model"]["trunk"]["block_conditioning"] == "add"
def test_heads_config_defaults_reproduce_pre_gitea_36_hardcoded_shape():
"""gitea #36: a pre-existing config with no `heads` key must reproduce
today's hardcoded `hidden_dim // 2`, one-hidden-layer architecture
exactly."""
assert gconfig.Stage1ModelConfig().heads.n_sec.hidden_ratio == 0.5
assert gconfig.Stage1ModelConfig().heads.n_sec.depth == 2
assert gconfig.Stage2ModelConfig().heads.n_sec.hidden_ratio == 0.5
assert gconfig.Stage2ModelConfig().heads.n_sec.depth == 2
assert gconfig.Stage2ModelConfig().heads.type.hidden_ratio == 0.5
assert gconfig.Stage2ModelConfig().heads.type.depth == 2
assert gconfig.DEFAULT_CONFIG["stage1_model"]["heads"]["n_sec"] == {"hidden_ratio": 0.5, "depth": 2}
assert gconfig.DEFAULT_CONFIG["stage2_model"]["heads"]["n_sec"] == {"hidden_ratio": 0.5, "depth": 2}
assert gconfig.DEFAULT_CONFIG["stage2_model"]["heads"]["type"] == {"hidden_ratio": 0.5, "depth": 2}
def test_particle_type_config_n_classes_defaults_to_zero_and_round_trips():
"""gitea #29: n_classes=0 means "inherit conditioning.particle.emb_dim"
— the default must stay 0 so an existing config.toml with no
+42
View File
@@ -0,0 +1,42 @@
import pytest
import torch
from giant.model.layers import build_mlp_head
def test_build_mlp_head_depth_1_is_bare_linear():
head = build_mlp_head(8, 4, hidden=16, depth=1)
assert len(head) == 1
assert isinstance(head[0], torch.nn.Linear)
assert head[0].in_features == 8
assert head[0].out_features == 4
out = head(torch.randn(3, 8))
assert out.shape == (3, 4)
def test_build_mlp_head_depth_2_matches_pre_gitea_36_shape():
head = build_mlp_head(8, 4, hidden=16, depth=2)
assert len(head) == 3
assert isinstance(head[0], torch.nn.Linear)
assert head[0].in_features == 8
assert head[0].out_features == 16
assert isinstance(head[1], torch.nn.SiLU)
assert isinstance(head[2], torch.nn.Linear)
assert head[2].in_features == 16
assert head[2].out_features == 4
out = head(torch.randn(5, 8))
assert out.shape == (5, 4)
def test_build_mlp_head_depth_3_has_extra_hidden_layer():
head = build_mlp_head(8, 4, hidden=16, depth=3)
assert len(head) == 5
widths = [(m.in_features, m.out_features) for m in head if isinstance(m, torch.nn.Linear)]
assert widths == [(8, 16), (16, 16), (16, 4)]
out = head(torch.randn(2, 8))
assert out.shape == (2, 4)
def test_build_mlp_head_depth_0_raises():
with pytest.raises(ValueError, match="depth"):
build_mlp_head(8, 4, hidden=16, depth=0)
+132
View File
@@ -93,6 +93,42 @@ def test_stage1_model_no_n_sec_head_by_default():
assert model.n_sec_head is None
def test_stage1_model_n_sec_head_default_cfg_matches_pre_gitea_36_shape():
"""No n_sec_head_cfg given must reproduce the old hardcoded
hidden_dim // 2, one-hidden-layer architecture exactly (gitea #36)."""
model = Stage1Model(
pdg_vocab=3,
mat_vocab=2,
particle_cfg=PARTICLE_CFG,
material_cfg=MATERIAL_CFG,
hidden_dim=40,
cond_out_dim=12,
n_sec_head_k_max=15,
)
assert model.n_sec_head is not None
assert len(model.n_sec_head) == 3
assert model.n_sec_head[0].in_features == 12
assert model.n_sec_head[0].out_features == 20 # hidden_dim // 2
assert model.n_sec_head[2].out_features == 16 # k_max + 1
def test_stage1_model_n_sec_head_cfg_controls_hidden_width_and_depth():
model = Stage1Model(
pdg_vocab=3,
mat_vocab=2,
particle_cfg=PARTICLE_CFG,
material_cfg=MATERIAL_CFG,
hidden_dim=32,
cond_out_dim=16,
n_sec_head_k_max=15,
n_sec_head_cfg={"hidden_ratio": 0.25, "depth": 1},
)
assert model.n_sec_head is not None
assert len(model.n_sec_head) == 1
assert model.n_sec_head[0].in_features == 16
assert model.n_sec_head[0].out_features == 16
# --- cat_col_layout / stage2_type_dim / stage2_trunk_sec_dim ---------------
@@ -268,6 +304,38 @@ def test_stage2_oneshot_predict_type_raises_when_no_type_head():
pass
def test_stage2_oneshot_n_sec_head_and_type_head_cfg_control_hidden_width_and_depth():
"""gitea #36: n_sec_head_cfg/type_head_cfg are independently tunable."""
k_max, emb_dim = 5, 6
particle_cfg = {"type": "onehot", "emb_dim": emb_dim, "n_layers": 1}
sec_dim = stage2_trunk_sec_dim({"target": "onehot"}, "flow", k_max, emb_dim)
model = Stage2OneShot(
pdg_vocab=5,
mat_vocab=3,
particle_cfg=particle_cfg,
material_cfg=MATERIAL_CFG,
hidden_dim=40,
n_res_blocks=1,
cond_out_dim=12,
context_dim=8,
sec_dim=sec_dim,
generator="flow",
k_max=k_max,
particle_type_cfg={"target": "onehot", "lambda": 1.0},
n_sec_head_cfg={"hidden_ratio": 0.25, "depth": 1},
type_head_cfg={"hidden_ratio": 0.75, "depth": 2},
)
assert model.n_sec_head is not None
assert len(model.n_sec_head) == 1
assert model.n_sec_head[0].in_features == 12
assert model.n_sec_head[0].out_features == k_max + 1
assert model.type_head is not None
assert len(model.type_head) == 3
assert model.type_head[0].out_features == 30 # round(40 * 0.75)
assert model.type_head[2].out_features == k_max * emb_dim
def test_stage2_oneshot_forward_shape_onehot_wgan():
B, k_max, emb_dim = 4, 5, 6
model = _build_stage2("onehot", "wgan", emb_dim=emb_dim)
@@ -518,6 +586,37 @@ def test_stage2_autoregressive_particle_type_n_classes_overrides_conditioning_em
assert model.type_head[-1].out_features == 20
def test_stage2_autoregressive_n_sec_head_and_type_head_cfg_control_hidden_width_and_depth():
"""gitea #36, Stage2Autoregressive side — see the Stage2OneShot version
of this test for the full rationale."""
particle_cfg = {"type": "physical", "emb_dim": 6, "n_layers": 1}
particle_type_cfg = {"target": "onehot", "lambda": 1.0}
model = Stage2Autoregressive(
pdg_vocab=5,
mat_vocab=3,
particle_cfg=particle_cfg,
material_cfg=MATERIAL_CFG,
hidden_dim=40,
n_res_blocks=1,
cond_out_dim=12,
context_dim=8,
generator="flow",
k_max=5,
particle_type_cfg=particle_type_cfg,
n_sec_head_cfg={"hidden_ratio": 0.25, "depth": 1},
type_head_cfg={"hidden_ratio": 0.75, "depth": 2},
)
assert model.n_sec_head is not None
assert len(model.n_sec_head) == 1
assert model.n_sec_head[0].in_features == 12
assert model.n_sec_head[0].out_features == 6 # k_max + 1
assert model.type_head is not None
assert len(model.type_head) == 3
assert model.type_head[0].out_features == 30 # round(40 * 0.75)
assert model.type_head[2].out_features == model.type_dim
@pytest.mark.parametrize("target", ["physical", "onehot", "embedding"])
@pytest.mark.parametrize("generator", ["wgan", "flow"])
@pytest.mark.parametrize("history", ["markov", "attention"])
@@ -813,6 +912,39 @@ def test_build_models_omitted_decoder_and_particle_type_match_default_config():
assert built["stage2"].particle_type_cfg["target"] == "onehot"
def test_build_models_custom_heads_block_controls_head_shapes():
"""gitea #36: stage{1,2}_model.heads flows all the way from config dict
through build_models to the actual constructed head shapes."""
cfg = _partial_model_config()
cfg["stage1_model"] = {
"active": True,
"hidden_dim": 40,
"n_res_blocks": 1,
"heads": {"n_sec": {"hidden_ratio": 0.25, "depth": 1}},
}
cfg["stage2_model"]["decoder"] = "one_shot"
cfg["stage2_model"]["generator"] = "flow" # wgan folds the type slice; no separate type_head
cfg["stage2_model"]["n_sec"] = {"owner": "stage1"}
cfg["stage2_model"]["heads"] = {
"n_sec": {"hidden_ratio": 0.25, "depth": 1},
"type": {"hidden_ratio": 0.75, "depth": 2},
}
built = build_models(cfg)
stage1, stage2 = built["stage1"], built["stage2"]
assert stage1 is not None
assert stage2 is not None
assert stage1.n_sec_head is not None
assert len(stage1.n_sec_head) == 1 # owner=stage1, so stage1 builds it
assert stage2.n_sec_head is None # owner=stage1, so stage2 doesn't
assert isinstance(stage2, Stage2OneShot)
assert stage2.type_head is not None
assert len(stage2.type_head) == 3
assert stage2.type_head[0].out_features == 6 # round(8 * 0.75)
def test_build_critics_omitted_particle_type_matches_default_config():
cfg = _partial_model_config()
cfg["stage2_model"]["generator"] = "wgan"