Skip to content

Commit a0b471d

Browse files
committed
fix(nets): preserve backward compat for VAE reparameterize at inference
Address review: instead of changing the default inference behaviour (which returned mu + std), gate the new behaviour behind a use_mean_at_inference constructor argument. The default stays mu + std for backward compatibility; use_mean_at_inference=True returns the posterior mean at eval, matching the issue #8413 request. Both VarAutoEncoder and VarFullyConnectedNet get the new argument, with regression tests covering both the new opt-in and the preserved default. Signed-off-by: Lanre Shittu <136805224+Shizoqua@users.noreply.github.com>
1 parent b7415af commit a0b471d

4 files changed

Lines changed: 76 additions & 10 deletions

File tree

monai/networks/nets/fullyconnectednet.py

Lines changed: 10 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()
@@ -174,8 +178,10 @@ def decode_forward(self, z: torch.Tensor, use_sigmoid: bool = True) -> torch.Ten
174178
def reparameterize(self, mu: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor:
175179
"""Sample a latent code using the reparameterization trick.
176180
177-
At inference (eval mode) the posterior mean is returned directly. During
178-
training, returns ``mu + eps * std`` with ``eps ~ N(0, I)``.
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)``.
179185
180186
Args:
181187
mu: Posterior mean, shape ``(batch, latent_size)``.
@@ -184,12 +190,12 @@ def reparameterize(self, mu: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor
184190
Returns:
185191
Sampled latent code, same shape as ``mu``.
186192
"""
187-
if not self.training:
193+
if not self.training and self.use_mean_at_inference:
188194
# At inference the latent code is the posterior mean; the random
189195
# term is only added during training (the reparameterization trick).
190196
return mu
191197
std = torch.exp(0.5 * logvar)
192-
eps = torch.randn_like(std)
198+
eps = torch.randn_like(std) if self.training else torch.ones_like(std)
193199
return mu + eps * std
194200

195201
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:

monai/networks/nets/varautoencoder.py

Lines changed: 10 additions & 4 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)
@@ -144,8 +148,10 @@ def decode_forward(self, z: torch.Tensor, use_sigmoid: bool = True) -> torch.Ten
144148
def reparameterize(self, mu: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor:
145149
"""Sample a latent code using the reparameterization trick.
146150
147-
At inference (eval mode) the posterior mean is returned directly. During
148-
training, returns ``mu + eps * std`` with ``eps ~ N(0, I)``.
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)``.
149155
150156
Args:
151157
mu: Posterior mean, shape ``(batch, latent_size)``.
@@ -154,12 +160,12 @@ def reparameterize(self, mu: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor
154160
Returns:
155161
Sampled latent code, same shape as ``mu``.
156162
"""
157-
if not self.training:
163+
if not self.training and self.use_mean_at_inference:
158164
# At inference the latent code is the posterior mean; the random
159165
# term is only added during training (the reparameterization trick).
160166
return mu
161167
std = torch.exp(0.5 * logvar)
162-
eps = torch.randn_like(std)
168+
eps = torch.randn_like(std) if self.training else torch.ones_like(std)
163169
return mu + eps * std
164170

165171
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:

tests/networks/nets/test_fullyconnectednet.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@ def test_vfc_reparameterize_eval_returns_mu(self):
7171
at inference instead of ``mu``.
7272
"""
7373
net = VarFullyConnectedNet(
74-
in_channels=10, out_channels=10, latent_size=30, encode_channels=(15, 20, 25), decode_channels=(15, 20, 25)
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,
7580
).to(device)
7681
data = torch.randn(3, 10).to(device)
7782

@@ -88,6 +93,27 @@ def test_vfc_reparameterize_eval_returns_mu(self):
8893
self.assertFalse(torch.allclose(zt1, mu_t))
8994
self.assertFalse(torch.allclose(zt1, zt2))
9095

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,
104+
out_channels=10,
105+
latent_size=30,
106+
encode_channels=(15, 20, 25),
107+
decode_channels=(15, 20, 25),
108+
).to(device)
109+
data = torch.randn(3, 10).to(device)
110+
111+
with eval_mode(net):
112+
_, mu1, _, z1 = net(data)
113+
_, _, _, z2 = net(data)
114+
self.assertFalse(torch.allclose(z1, mu1))
115+
self.assertTrue(torch.allclose(z1, z2))
116+
91117

92118
if __name__ == "__main__":
93119
unittest.main()

tests/networks/nets/test_varautoencoder.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,13 @@ def test_reparameterize_eval_returns_mu(self):
128128
Regression test for #8413, where eval returned ``mu + std`` instead of ``mu``.
129129
"""
130130
net = VarAutoEncoder(
131-
spatial_dims=2, in_shape=(1, 32, 32), out_channels=1, latent_size=4, channels=(4, 8), strides=(2, 2)
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,
132138
).to(device)
133139
data = torch.randn(2, 1, 32, 32).to(device)
134140

@@ -145,6 +151,28 @@ def test_reparameterize_eval_returns_mu(self):
145151
self.assertFalse(torch.allclose(zt1, mu_t))
146152
self.assertFalse(torch.allclose(zt1, zt2))
147153

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,
162+
in_shape=(1, 32, 32),
163+
out_channels=1,
164+
latent_size=4,
165+
channels=(4, 8),
166+
strides=(2, 2),
167+
).to(device)
168+
data = torch.randn(2, 1, 32, 32).to(device)
169+
170+
with eval_mode(net):
171+
_, mu1, _, z1 = net(data)
172+
_, _, _, z2 = net(data)
173+
self.assertFalse(torch.allclose(z1, mu1))
174+
self.assertTrue(torch.allclose(z1, z2))
175+
148176

149177
if __name__ == "__main__":
150178
unittest.main()

0 commit comments

Comments
 (0)