Skip to content

Commit 68ec7b8

Browse files
authored
Merge pull request #6 from stef1949/codex/evaluate-and-improve-neural-network-design
2 parents 17d884f + 6446263 commit 68ec7b8

2 files changed

Lines changed: 144 additions & 87 deletions

File tree

models/ae.py

Lines changed: 106 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
"""
88
from __future__ import annotations
99

10+
from typing import Sequence
11+
1012
import torch
1113
import torch.nn as nn
1214

@@ -44,27 +46,96 @@ def set_lambda(self, lambda_: float):
4446
# ----------------------------
4547

4648

49+
def _make_activation(name: str | None) -> nn.Module | None:
50+
if name is None:
51+
return None
52+
name = name.lower()
53+
if name == "relu":
54+
return nn.ReLU()
55+
if name == "tanh":
56+
return nn.Tanh()
57+
if name == "sigmoid":
58+
return nn.Sigmoid()
59+
if name in {"silu", "swish"}:
60+
return nn.SiLU()
61+
if name == "gelu":
62+
return nn.GELU()
63+
raise ValueError(f"Unsupported activation: {name}")
64+
65+
4766
class ResidualBlock(nn.Module):
48-
"""
49-
Residual block with skip connection: x + FFN(x) + LayerNorm
50-
"""
67+
"""Feed-forward residual block with optional projection skip."""
5168

52-
def __init__(self, hidden_size: int, dropout: float = 0.0):
69+
def __init__(
70+
self,
71+
in_features: int,
72+
out_features: int,
73+
*,
74+
hidden_features: int | None = None,
75+
dropout: float = 0.0,
76+
activation: str = "silu",
77+
use_layer_norm: bool = True,
78+
) -> None:
5379
super().__init__()
54-
self.ffn = nn.Sequential(
55-
nn.Linear(hidden_size, hidden_size),
56-
nn.SiLU(),
57-
nn.Dropout(dropout),
58-
nn.Linear(hidden_size, hidden_size),
59-
nn.Dropout(dropout),
60-
)
61-
self.layer_norm = nn.LayerNorm(hidden_size)
80+
hidden_features = out_features if hidden_features is None else hidden_features
81+
82+
ff_layers: list[nn.Module] = [nn.Linear(in_features, hidden_features)]
83+
act = _make_activation(activation)
84+
if act is not None:
85+
ff_layers.append(act)
86+
if dropout > 0:
87+
ff_layers.append(nn.Dropout(dropout))
88+
ff_layers.append(nn.Linear(hidden_features, out_features))
89+
if dropout > 0:
90+
ff_layers.append(nn.Dropout(dropout))
91+
self.ffn = nn.Sequential(*ff_layers)
92+
93+
if in_features == out_features:
94+
self.shortcut: nn.Module = nn.Identity()
95+
else:
96+
self.shortcut = nn.Linear(in_features, out_features, bias=False)
6297

63-
def forward(self, x):
64-
return self.layer_norm(x + self.ffn(x))
98+
self.layer_norm = nn.LayerNorm(out_features) if use_layer_norm else nn.Identity()
99+
100+
def forward(self, x: torch.Tensor) -> torch.Tensor:
101+
return self.layer_norm(self.shortcut(x) + self.ffn(x))
65102

66103

67-
def make_mlp(sizes, dropout=0.0, last_activation=None, use_residual=False):
104+
class ResidualStack(nn.Module):
105+
"""Stack of residual blocks that can change feature dimensionality."""
106+
107+
def __init__(
108+
self,
109+
sizes: Sequence[int],
110+
*,
111+
dropout: float = 0.0,
112+
activation: str = "silu",
113+
) -> None:
114+
super().__init__()
115+
if len(sizes) < 2:
116+
raise ValueError("ResidualStack requires at least two layer sizes")
117+
118+
blocks = [
119+
ResidualBlock(
120+
in_features=in_f,
121+
out_features=out_f,
122+
dropout=dropout,
123+
activation=activation,
124+
)
125+
for in_f, out_f in zip(sizes[:-1], sizes[1:])
126+
]
127+
self.blocks = nn.Sequential(*blocks)
128+
129+
def forward(self, x: torch.Tensor) -> torch.Tensor:
130+
return self.blocks(x)
131+
132+
133+
def make_mlp(
134+
sizes: Sequence[int],
135+
dropout: float = 0.0,
136+
last_activation: str | None = None,
137+
use_residual: bool = False,
138+
):
68139
"""
69140
Hidden layers: Linear -> LayerNorm -> SiLU -> Dropout
70141
Output layer: optional activation per arg
@@ -78,52 +149,34 @@ def make_mlp(sizes, dropout=0.0, last_activation=None, use_residual=False):
78149
"Residual networks need at least input, hidden, and output layers"
79150
)
80151

81-
# Check that all hidden layers have the same size for residual connections
82-
hidden_sizes = sizes[1:-1]
83-
if len(set(hidden_sizes)) > 1:
84-
raise ValueError(
85-
f"For residual connections, all hidden layer sizes must be the same. Got: {hidden_sizes}"
86-
)
87-
88-
hidden_size = hidden_sizes[0]
89-
num_hidden_layers = len(hidden_sizes)
90-
91-
layers = []
92-
93-
# Input projection to hidden size
94-
layers.append(nn.Linear(sizes[0], hidden_size))
95-
layers.extend([nn.LayerNorm(hidden_size), nn.SiLU(), nn.Dropout(dropout)])
96-
97-
# Residual blocks
98-
for _ in range(num_hidden_layers):
99-
layers.append(ResidualBlock(hidden_size, dropout))
100-
101-
# Output layer
102-
layers.append(nn.Linear(hidden_size, sizes[-1]))
103-
if last_activation == "relu":
104-
layers.append(nn.ReLU())
105-
elif last_activation == "tanh":
106-
layers.append(nn.Tanh())
107-
elif last_activation == "sigmoid":
108-
layers.append(nn.Sigmoid())
109-
152+
trunk_sizes = sizes[:-1]
153+
layers: list[nn.Module] = [
154+
ResidualStack(trunk_sizes, dropout=dropout, activation="silu")
155+
]
156+
layers.append(nn.Linear(trunk_sizes[-1], sizes[-1]))
157+
final_act = _make_activation(last_activation)
158+
if final_act is not None:
159+
layers.append(final_act)
110160
return nn.Sequential(*layers)
111161

112162
else:
113163
# Original implementation
114-
layers = []
164+
layers: list[nn.Module] = []
115165
for i in range(len(sizes) - 1):
116166
in_f, out_f = sizes[i], sizes[i + 1]
117167
layers.append(nn.Linear(in_f, out_f))
118-
if i < len(sizes) - 2:
119-
layers += [nn.LayerNorm(out_f), nn.SiLU(), nn.Dropout(dropout)]
168+
is_last = i == len(sizes) - 2
169+
if not is_last:
170+
layers.append(nn.LayerNorm(out_f))
171+
act = _make_activation("silu")
172+
if act is not None:
173+
layers.append(act)
174+
if dropout > 0:
175+
layers.append(nn.Dropout(dropout))
120176
else:
121-
if last_activation == "relu":
122-
layers += [nn.ReLU()]
123-
elif last_activation == "tanh":
124-
layers += [nn.Tanh()]
125-
elif last_activation == "sigmoid":
126-
layers += [nn.Sigmoid()]
177+
final_act = _make_activation(last_activation)
178+
if final_act is not None:
179+
layers.append(final_act)
127180
return nn.Sequential(*layers)
128181

129182

tests/test_residual_and_inference.py

Lines changed: 38 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ def test_residual_block_forward(self):
1616
hidden_size = 128
1717
batch_size = 16
1818

19-
block = ResidualBlock(hidden_size, dropout=0.1)
19+
block = ResidualBlock(hidden_size, hidden_size, dropout=0.1)
2020
x = torch.randn(batch_size, hidden_size)
21-
21+
2222
output = block(x)
2323

2424
# Output should have same shape as input
@@ -29,11 +29,18 @@ def test_residual_block_forward(self):
2929

3030
def test_residual_block_dimensions(self):
3131
"""Test ResidualBlock with various dimensions."""
32-
for hidden_size in [64, 128, 256]:
33-
block = ResidualBlock(hidden_size, dropout=0.0)
34-
x = torch.randn(10, hidden_size)
32+
for in_dim, out_dim in [(64, 64), (64, 128), (128, 32)]:
33+
block = ResidualBlock(in_dim, out_dim, dropout=0.0)
34+
x = torch.randn(10, in_dim)
3535
output = block(x)
36-
assert output.shape == (10, hidden_size)
36+
assert output.shape == (10, out_dim)
37+
38+
def test_residual_block_projection_learns(self):
39+
"""Projection branch should learn non-trivial mappings when dims change."""
40+
block = ResidualBlock(32, 64, dropout=0.0)
41+
x = torch.randn(4, 32)
42+
out = block(x)
43+
assert out.shape == (4, 64)
3744

3845

3946
class TestMakeMLP:
@@ -47,24 +54,16 @@ def test_make_mlp_without_residual(self):
4754

4855
assert output.shape == (8, 10)
4956

50-
def test_make_mlp_with_residual_uniform_hidden(self):
51-
"""Test make_mlp with residual connections and uniform hidden sizes."""
52-
# All hidden layers must be the same size for residual connections
53-
sizes = [100, 64, 64, 64, 10] # input, hidden1, hidden2, hidden3, output
57+
def test_make_mlp_with_residual_handles_dim_changes(self):
58+
"""Residual builder should accept non-uniform hidden sizes via projections."""
59+
sizes = [100, 128, 64, 32, 10]
5460
mlp = make_mlp(sizes, dropout=0.1, use_residual=True)
55-
61+
5662
x = torch.randn(8, 100)
5763
output = mlp(x)
58-
64+
5965
assert output.shape == (8, 10)
60-
61-
def test_make_mlp_with_residual_non_uniform_hidden_raises_error(self):
62-
"""Test that non-uniform hidden sizes raise ValueError with residual=True."""
63-
sizes = [100, 64, 32, 16, 10] # Non-uniform hidden sizes
64-
65-
with pytest.raises(ValueError, match="all hidden layer sizes must be the same"):
66-
make_mlp(sizes, dropout=0.1, use_residual=True)
67-
66+
6867
def test_make_mlp_residual_minimum_layers(self):
6968
"""Test that residual networks need at least 3 layers."""
7069
sizes = [100, 10] # Only input and output
@@ -110,14 +109,14 @@ def test_ae_with_residual_uniform_hidden(self):
110109
model = AEBatchCorrector(
111110
n_genes=1000,
112111
latent_dim=32,
113-
enc_hidden=(128, 128, 128), # Uniform hidden sizes
114-
dec_hidden=(128, 128, 128), # Uniform hidden sizes
112+
enc_hidden=(128, 128, 128),
113+
dec_hidden=(128, 128, 128),
115114
n_batches=3,
116115
n_labels=2,
117116
dropout=0.1,
118117
use_residual=True,
119118
)
120-
119+
121120
x = torch.randn(16, 1000)
122121
x_hat, b_logits, l_logits, z = model(x, adv_lambda=1.0)
123122

@@ -126,17 +125,22 @@ def test_ae_with_residual_uniform_hidden(self):
126125
assert l_logits.shape == (16, 2)
127126
assert z.shape == (16, 32)
128127

129-
def test_ae_with_residual_non_uniform_hidden_raises_error(self):
130-
"""Test that non-uniform hidden sizes raise error with residual=True."""
131-
with pytest.raises(ValueError, match="all hidden layer sizes must be the same"):
132-
model = AEBatchCorrector(
133-
n_genes=1000,
134-
latent_dim=32,
135-
enc_hidden=(256, 128), # Non-uniform
136-
dec_hidden=(128, 256), # Non-uniform
137-
n_batches=3,
138-
use_residual=True,
139-
)
128+
def test_ae_with_residual_non_uniform_hidden(self):
129+
"""Non-uniform hidden sizes should be supported via projection skips."""
130+
model = AEBatchCorrector(
131+
n_genes=1000,
132+
latent_dim=32,
133+
enc_hidden=(256, 128),
134+
dec_hidden=(128, 256),
135+
n_batches=3,
136+
use_residual=True,
137+
)
138+
139+
x = torch.randn(8, 1000)
140+
x_hat, b_logits, l_logits, z = model(x, adv_lambda=1.0)
141+
assert x_hat.shape == (8, 1000)
142+
assert b_logits.shape == (8, 3)
143+
assert z.shape == (8, 32)
140144

141145
def test_ae_reconstruct_method(self):
142146
"""Test the reconstruct method for inference."""

0 commit comments

Comments
 (0)