Skip to content

Commit 339f535

Browse files
committed
fix(nets): preserve backward compat for VAE reparameterize at inference
VarAutoEncoder.reparameterize and VarFullyConnectedNet.reparameterize now support a new use_mean_at_inference constructor argument. Default False preserves the original mu + std behaviour at inference for backward compatibility; True returns the posterior mean (mu) at inference, matching the #8413 request. Signed-off-by: Lanre Shittu <136805224+Shizoqua@users.noreply.github.com>
1 parent 605611b commit 339f535

4 files changed

Lines changed: 143 additions & 9 deletions

File tree

monai/networks/nets/fullyconnectednet.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ class VarFullyConnectedNet(nn.Module):
103103
act: activation type and arguments. Defaults to PReLU.
104104
bias: whether to have a bias term in linear units. Defaults to True.
105105
adn_ordering: order of operations in :py:class:`monai.networks.blocks.ADN`.
106+
use_mean_at_inference: whether to return the posterior mean (rather than ``mu + std``)
107+
as the latent code during inference. Defaults to False for backward compatibility.
106108
107109
Examples::
108110
@@ -122,11 +124,13 @@ def __init__(
122124
act: tuple | str | None = Act.PRELU,
123125
bias: bool = True,
124126
adn_ordering: str | None = None,
127+
use_mean_at_inference: bool = False,
125128
) -> None:
126129
super().__init__()
127130
self.in_channels = in_channels
128131
self.out_channels = out_channels
129132
self.latent_size = latent_size
133+
self.use_mean_at_inference = use_mean_at_inference
130134

131135
self.encode = nn.Sequential()
132136
self.decode = nn.Sequential()
@@ -172,12 +176,29 @@ def decode_forward(self, z: torch.Tensor, use_sigmoid: bool = True) -> torch.Ten
172176
return x
173177

174178
def reparameterize(self, mu: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor:
175-
std = torch.exp(0.5 * logvar)
179+
"""Sample a latent code using the reparameterization trick.
180+
181+
At inference (eval mode), if ``use_mean_at_inference`` is enabled, the posterior
182+
mean is returned directly. Otherwise, ``mu + std`` is returned, matching the
183+
original behaviour. During training, returns ``mu + eps * std`` with
184+
``eps ~ N(0, I)``.
176185
177-
if self.training: # multiply random noise with std only during training
178-
std = torch.randn_like(std).mul(std)
186+
Args:
187+
mu: Posterior mean, shape ``(batch, latent_size)``.
188+
logvar: Log-variance of the posterior, same shape as ``mu``.
179189
180-
return std.add_(mu)
190+
Returns:
191+
Sampled latent code, same shape as ``mu``.
192+
"""
193+
if not self.training and self.use_mean_at_inference:
194+
# At inference the latent code is the posterior mean; the random
195+
# term is only added during training (the reparameterization trick).
196+
return mu
197+
std = torch.exp(0.5 * logvar)
198+
if self.training:
199+
eps = torch.randn_like(std)
200+
return mu + eps * std
201+
return mu + std
181202

182203
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
183204
mu, logvar = self.encode_forward(x)

monai/networks/nets/varautoencoder.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ class VarAutoEncoder(AutoEncoder):
5151
According to `Performance Tuning Guide <https://pytorch.org/tutorials/recipes/recipes/tuning_guide.html>`_,
5252
if a conv layer is directly followed by a batch norm layer, bias should be False.
5353
use_sigmoid: whether to use the sigmoid function on final output. Defaults to True.
54+
use_mean_at_inference: whether to return the posterior mean (rather than ``mu + std``)
55+
as the latent code during inference. Defaults to False for backward compatibility.
5456
5557
Examples::
5658
@@ -90,9 +92,11 @@ def __init__(
9092
dropout: tuple | str | float | None = None,
9193
bias: bool = True,
9294
use_sigmoid: bool = True,
95+
use_mean_at_inference: bool = False,
9396
) -> None:
9497
self.in_channels, *self.in_shape = in_shape
9598
self.use_sigmoid = use_sigmoid
99+
self.use_mean_at_inference = use_mean_at_inference
96100

97101
self.latent_size = latent_size
98102
self.final_size = np.asarray(self.in_shape, dtype=int)
@@ -142,12 +146,29 @@ def decode_forward(self, z: torch.Tensor, use_sigmoid: bool = True) -> torch.Ten
142146
return x
143147

144148
def reparameterize(self, mu: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor:
149+
"""Sample a latent code using the reparameterization trick.
150+
151+
At inference (eval mode), if ``use_mean_at_inference`` is enabled, the posterior
152+
mean is returned directly. Otherwise, ``mu + std`` is returned, matching the
153+
original behaviour. During training, returns ``mu + eps * std`` with
154+
``eps ~ N(0, I)``.
155+
156+
Args:
157+
mu: Posterior mean, shape ``(batch, latent_size)``.
158+
logvar: Log-variance of the posterior, same shape as ``mu``.
159+
160+
Returns:
161+
Sampled latent code, same shape as ``mu``.
162+
"""
163+
if not self.training and self.use_mean_at_inference:
164+
# At inference the latent code is the posterior mean; the random
165+
# term is only added during training (the reparameterization trick).
166+
return mu
145167
std = torch.exp(0.5 * logvar)
146-
147-
if self.training: # multiply random noise with std only during training
148-
std = torch.randn_like(std).mul(std)
149-
150-
return std.add_(mu)
168+
if self.training:
169+
eps = torch.randn_like(std)
170+
return mu + eps * std
171+
return mu + std
151172

152173
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
153174
mu, logvar = self.encode_forward(x)

tests/networks/nets/test_fullyconnectednet.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,52 @@ def test_vfc_shape(self, input_param, input_shape, expected_shape):
6464
result = net.forward(torch.randn(input_shape).to(device))[0]
6565
self.assertEqual(result.shape, expected_shape)
6666

67+
def test_vfc_reparameterize_eval_returns_mu(self):
68+
"""A VFC latent code is deterministic at eval (equals mu) and stochastic at train.
69+
70+
Regression test for the #8413 reparameterize bug, which returned ``mu + std``
71+
at inference instead of ``mu``.
72+
"""
73+
net = VarFullyConnectedNet(
74+
in_channels=10,
75+
out_channels=10,
76+
latent_size=30,
77+
encode_channels=(15, 20, 25),
78+
decode_channels=(15, 20, 25),
79+
use_mean_at_inference=True,
80+
).to(device)
81+
data = torch.randn(3, 10).to(device)
82+
83+
with eval_mode(net):
84+
_, mu1, _, z1 = net(data)
85+
_, _, _, z2 = net(data)
86+
self.assertTrue(torch.allclose(z1, mu1))
87+
self.assertTrue(torch.allclose(z1, z2))
88+
89+
net.train()
90+
with torch.no_grad():
91+
_, mu_t, _, zt1 = net(data)
92+
_, _, _, zt2 = net(data)
93+
self.assertFalse(torch.allclose(zt1, mu_t))
94+
self.assertFalse(torch.allclose(zt1, zt2))
95+
96+
def test_vfc_reparameterize_default_keeps_original_behaviour(self):
97+
"""By default, eval returns ``mu + std`` (deterministic) for backward compatibility.
98+
99+
The default must preserve the pre-#8413 behaviour: at inference the standard
100+
deviation is added to the mean without random noise.
101+
"""
102+
net = VarFullyConnectedNet(
103+
in_channels=10, out_channels=10, latent_size=30, encode_channels=(15, 20, 25), decode_channels=(15, 20, 25)
104+
).to(device)
105+
data = torch.randn(3, 10).to(device)
106+
107+
with eval_mode(net):
108+
_, mu1, _, z1 = net(data)
109+
_, _, _, z2 = net(data)
110+
self.assertFalse(torch.allclose(z1, mu1))
111+
self.assertTrue(torch.allclose(z1, z2))
112+
67113

68114
if __name__ == "__main__":
69115
unittest.main()

tests/networks/nets/test_varautoencoder.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,52 @@ def test_script(self):
122122
test_data = torch.randn(2, 1, 32, 32)
123123
test_script_save(net, test_data, rtol=1e-3, atol=1e-3)
124124

125+
def test_reparameterize_eval_returns_mu(self):
126+
"""A VarAutoEncoder latent code is deterministic at eval (equals mu) and stochastic at train.
127+
128+
Regression test for #8413, where eval returned ``mu + std`` instead of ``mu``.
129+
"""
130+
net = VarAutoEncoder(
131+
spatial_dims=2,
132+
in_shape=(1, 32, 32),
133+
out_channels=1,
134+
latent_size=4,
135+
channels=(4, 8),
136+
strides=(2, 2),
137+
use_mean_at_inference=True,
138+
).to(device)
139+
data = torch.randn(2, 1, 32, 32).to(device)
140+
141+
with eval_mode(net):
142+
_, mu1, _, z1 = net(data)
143+
_, _, _, z2 = net(data)
144+
self.assertTrue(torch.allclose(z1, mu1))
145+
self.assertTrue(torch.allclose(z1, z2))
146+
147+
net.train()
148+
with torch.no_grad():
149+
_, mu_t, _, zt1 = net(data)
150+
_, _, _, zt2 = net(data)
151+
self.assertFalse(torch.allclose(zt1, mu_t))
152+
self.assertFalse(torch.allclose(zt1, zt2))
153+
154+
def test_reparameterize_default_keeps_original_behaviour(self):
155+
"""By default, eval returns ``mu + std`` (deterministic) for backward compatibility.
156+
157+
The default must preserve the pre-#8413 behaviour: at inference the standard
158+
deviation is added to the mean without random noise.
159+
"""
160+
net = VarAutoEncoder(
161+
spatial_dims=2, in_shape=(1, 32, 32), out_channels=1, latent_size=4, channels=(4, 8), strides=(2, 2)
162+
).to(device)
163+
data = torch.randn(2, 1, 32, 32).to(device)
164+
165+
with eval_mode(net):
166+
_, mu1, _, z1 = net(data)
167+
_, _, _, z2 = net(data)
168+
self.assertFalse(torch.allclose(z1, mu1))
169+
self.assertTrue(torch.allclose(z1, z2))
170+
125171

126172
if __name__ == "__main__":
127173
unittest.main()

0 commit comments

Comments
 (0)