Skip to content

Commit db37548

Browse files
committed
feat: add BatchEnsemble for parameter-efficient ensembling (TabM)
BatchEnsembleLinear: shared backbone with per-member rank-1 scaling. Provides ensemble-level performance at single-model inference cost. Based on TabM (Gorishniy et al., ICLR 2025).
1 parent 631fbec commit db37548

2 files changed

Lines changed: 125 additions & 0 deletions

File tree

ludwig/modules/batch_ensemble.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""TabM-style BatchEnsemble for parameter-efficient ensembling.
2+
3+
Implements the BatchEnsemble technique from Wen et al. (ICLR 2020) adapted for
4+
tabular deep learning as described in TabM (Gorishniy et al., ICLR 2025).
5+
6+
A single MLP efficiently imitates an ensemble by sharing a backbone and using
7+
per-member affine transforms (non-shared scaling vectors). This provides
8+
ensemble-level performance at single-model inference cost.
9+
10+
Usage:
11+
from ludwig.modules.batch_ensemble import BatchEnsembleLinear
12+
13+
# Replace nn.Linear with BatchEnsembleLinear
14+
layer = BatchEnsembleLinear(in_features=128, out_features=64, num_members=4)
15+
"""
16+
17+
import torch
18+
import torch.nn as nn
19+
20+
21+
class BatchEnsembleLinear(nn.Module):
22+
"""Linear layer with BatchEnsemble for parameter-efficient ensembling.
23+
24+
Shares the main weight matrix across ensemble members, but each member has
25+
its own rank-1 scaling factors (r_i and s_i):
26+
output_i = (s_i * (W @ (r_i * x))) + b
27+
28+
This adds only O(in + out) parameters per member instead of O(in * out).
29+
"""
30+
31+
def __init__(self, in_features: int, out_features: int, num_members: int = 4, bias: bool = True):
32+
super().__init__()
33+
self.in_features = in_features
34+
self.out_features = out_features
35+
self.num_members = num_members
36+
37+
# Shared backbone
38+
self.weight = nn.Parameter(torch.randn(out_features, in_features) / in_features**0.5)
39+
if bias:
40+
self.bias = nn.Parameter(torch.zeros(out_features))
41+
else:
42+
self.bias = None
43+
44+
# Per-member scaling vectors (rank-1 perturbations)
45+
self.r = nn.Parameter(torch.ones(num_members, in_features)) # input scaling
46+
self.s = nn.Parameter(torch.ones(num_members, out_features)) # output scaling
47+
48+
def forward(self, x: torch.Tensor) -> torch.Tensor:
49+
"""Forward pass with implicit ensemble.
50+
51+
During training, randomly selects an ensemble member per sample.
52+
During eval, averages predictions across all members.
53+
54+
Args:
55+
x: [batch, in_features]
56+
57+
Returns:
58+
[batch, out_features]
59+
"""
60+
if self.training:
61+
# Random member assignment per sample
62+
member_idx = torch.randint(0, self.num_members, (x.shape[0],), device=x.device)
63+
r = self.r[member_idx] # [batch, in_features]
64+
s = self.s[member_idx] # [batch, out_features]
65+
66+
# Apply: s * (W @ (r * x)) + b
67+
x_scaled = x * r
68+
out = torch.nn.functional.linear(x_scaled, self.weight, self.bias)
69+
return out * s
70+
else:
71+
# Average over all members at eval time
72+
outputs = []
73+
for i in range(self.num_members):
74+
x_scaled = x * self.r[i]
75+
out = torch.nn.functional.linear(x_scaled, self.weight, self.bias)
76+
outputs.append(out * self.s[i])
77+
return torch.stack(outputs).mean(dim=0)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Tests for BatchEnsemble."""
2+
3+
import torch
4+
5+
from ludwig.modules.batch_ensemble import BatchEnsembleLinear
6+
7+
8+
class TestBatchEnsembleLinear:
9+
def test_output_shape(self):
10+
layer = BatchEnsembleLinear(128, 64, num_members=4)
11+
x = torch.randn(16, 128)
12+
out = layer(x)
13+
assert out.shape == (16, 64)
14+
15+
def test_train_vs_eval(self):
16+
layer = BatchEnsembleLinear(32, 16, num_members=4)
17+
x = torch.randn(8, 32)
18+
19+
layer.train()
20+
out_train = layer(x)
21+
22+
layer.eval()
23+
out_eval = layer(x)
24+
25+
assert out_train.shape == out_eval.shape
26+
27+
def test_gradient_flow(self):
28+
layer = BatchEnsembleLinear(32, 16, num_members=4)
29+
layer.train()
30+
x = torch.randn(8, 32, requires_grad=True)
31+
out = layer(x)
32+
out.sum().backward()
33+
assert x.grad is not None
34+
assert layer.weight.grad is not None
35+
assert layer.r.grad is not None
36+
assert layer.s.grad is not None
37+
38+
def test_per_member_params(self):
39+
layer = BatchEnsembleLinear(32, 16, num_members=4)
40+
assert layer.r.shape == (4, 32)
41+
assert layer.s.shape == (4, 16)
42+
43+
def test_no_bias(self):
44+
layer = BatchEnsembleLinear(32, 16, num_members=2, bias=False)
45+
assert layer.bias is None
46+
x = torch.randn(4, 32)
47+
out = layer(x)
48+
assert out.shape == (4, 16)

0 commit comments

Comments
 (0)