Skip to content

Neural Ensemble Search (NAS)

Algorithms for searching diverse and high-performing architectures.

bensemble.search.nes.RandomSearcher

RandomSearcher(space: SearchSpace, pool_size: int = 50, ensemble_size: int = 5, train_fn: Callable[[Module], None] | None = None, device: device | None = None, criterion: Callable[[list[Module], DataLoader, device], float] | None = None)

NES with Random Search (NES-RS, Algorithm 2 in Zaidi et al. NeurIPS 2021).

Builds a pool of pool_size independently trained models with randomly sampled architectures, then applies greedy forward ensemble selection to pick the final ensemble of ensemble_size members.

Source code in bensemble/search/nes.py
def __init__(
    self,
    space: SearchSpace,
    pool_size: int = 50,
    ensemble_size: int = 5,
    train_fn: Callable[[nn.Module], None] | None = None,
    device: torch.device | None = None,
    criterion: Callable[[list[nn.Module], DataLoader, torch.device], float]
    | None = None,
) -> None:
    self.space = space
    self.pool_size = pool_size
    self.ensemble_size = ensemble_size
    self.train_fn = train_fn
    self.device = device or torch.device(
        "cuda" if torch.cuda.is_available() else "cpu"
    )
    self.criterion = (
        criterion if criterion is not None else classification_nll_criterion
    )

search

search(val_loader: DataLoader, val_loader_shift: DataLoader | None = None) -> Ensemble

Run NES-RS and return the selected ensemble.

Parameters:

Name Type Description Default
val_loader DataLoader

Validation loader used for ensemble selection.

required
val_loader_shift DataLoader | None

If provided, used instead of val_loader for the final ForwardSelect call (dataset-shift adaptation).

None

Returns:

Type Description
Ensemble

Ensemble of ensemble_size members.

Source code in bensemble/search/nes.py
def search(
    self,
    val_loader: DataLoader,
    val_loader_shift: DataLoader | None = None,
) -> Ensemble:
    """Run NES-RS and return the selected ensemble.

    Args:
        val_loader: Validation loader used for ensemble selection.
        val_loader_shift: If provided, used instead of `val_loader` for
            the final ForwardSelect call (dataset-shift adaptation).

    Returns:
        Ensemble of `ensemble_size` members.
    """
    pool: list[nn.Module] = []
    for _ in range(self.pool_size):
        config = self.space.sample()
        model = self.space.build(config)
        self.train_fn(model)
        pool.append(model)

    selection_loader = (
        val_loader_shift if val_loader_shift is not None else val_loader
    )
    selected = forward_select(
        pool, selection_loader, self.ensemble_size, self.device, self.criterion
    )
    return Ensemble.from_models(selected)

bensemble.search.nes.EvolutionarySearcher

EvolutionarySearcher(space: SearchSpace, pool_size: int = 50, ensemble_size: int = 5, population_size: int = 10, num_parent_candidates: int = 3, train_fn: Callable[[Module], None] | None = None, device: device | None = None, criterion: Callable[[list[Module], DataLoader, device], float] | None = None)

NES with Regularized Evolution (NES-RE, Algorithm 1 in Zaidi et al. NeurIPS 2021).

Evolves a population of architectures using ensemble-aware parent selection (ForwardSelect on the population) and single-step mutation. The full history of trained models forms the pool from which the final ensemble is selected.

Source code in bensemble/search/nes.py
def __init__(
    self,
    space: SearchSpace,
    pool_size: int = 50,
    ensemble_size: int = 5,
    population_size: int = 10,
    num_parent_candidates: int = 3,
    train_fn: Callable[[nn.Module], None] | None = None,
    device: torch.device | None = None,
    criterion: Callable[[list[nn.Module], DataLoader, torch.device], float]
    | None = None,
) -> None:
    self.space = space
    self.pool_size = pool_size
    self.ensemble_size = ensemble_size
    self.population_size = population_size
    self.num_parent_candidates = num_parent_candidates
    self.train_fn = train_fn
    self.device = device or torch.device(
        "cuda" if torch.cuda.is_available() else "cpu"
    )
    self.criterion = (
        criterion if criterion is not None else classification_nll_criterion
    )

search

search(val_loader: DataLoader, val_loader_shift: DataLoader | None = None) -> Ensemble

Run NES-RE and return the selected ensemble.

Parameters:

Name Type Description Default
val_loader DataLoader

Validation loader used for parent selection and final ensemble selection.

required
val_loader_shift DataLoader | None

If provided, used instead of val_loader for the final ForwardSelect call (dataset-shift adaptation).

None

Returns:

Type Description
Ensemble

Ensemble of ensemble_size members.

Source code in bensemble/search/nes.py
def search(
    self,
    val_loader: DataLoader,
    val_loader_shift: DataLoader | None = None,
) -> Ensemble:
    """Run NES-RE and return the selected ensemble.

    Args:
        val_loader: Validation loader used for parent selection and final
            ensemble selection.
        val_loader_shift: If provided, used instead of `val_loader` for
            the final ForwardSelect call (dataset-shift adaptation).

    Returns:
        Ensemble of `ensemble_size` members.
    """
    # Maps id(model) -> config to support mutation of selected parents.
    config_map: dict[int, dict] = {}

    def _build_and_train(config: dict) -> nn.Module:
        model = self.space.build(config)
        self.train_fn(model)
        config_map[id(model)] = config
        return model

    # --- Initialisation: seed population and pool ---
    population: deque[nn.Module] = deque()
    pool: list[nn.Module] = []

    for _ in range(self.population_size):
        model = _build_and_train(self.space.sample())
        population.append(model)
        pool.append(model)

    # --- Evolution loop ---
    while len(pool) < self.pool_size:
        # Select m parent candidates from the current population via ForwardSelect.
        parent_candidates = forward_select(
            list(population),
            val_loader,
            self.num_parent_candidates,
            self.device,
            self.criterion,
        )

        # Sample one parent uniformly at random.
        parent = random.choice(parent_candidates)
        parent_config = config_map[id(parent)]

        # Mutate and train child.
        child_config = self.space.mutate(parent_config)
        child = _build_and_train(child_config)

        population.append(child)
        pool.append(child)

        # Remove the oldest member from the population (regularized evolution).
        population.popleft()

    # --- Final ensemble selection ---
    selection_loader = (
        val_loader_shift if val_loader_shift is not None else val_loader
    )
    selected = forward_select(
        pool, selection_loader, self.ensemble_size, self.device, self.criterion
    )
    return Ensemble.from_models(selected)

Bayesian Sampling (SVGD)

bensemble.search.bayesian.NESBayesianSampler

NESBayesianSampler(space: SearchSpace, train_fn: Callable[[Module], None], pool_size: int = 50, ensemble_size: int = 5, temperature: float = 1.0, diversity_weight: float = 0.5, svgd_steps: int = 20, svgd_lr: float = 0.1, device: device | None = None, criterion: Callable[[list[Module], DataLoader, device], float] | None = None)

Neural Ensemble Search via Bayesian Sampling (NESBS, Shu et al., UAI 2022). This implementation follows the paper's practical recipe: 1) build a candidate model pool from a search space; 2) estimate a posterior over candidates from validation losses; 3) select a diverse final ensemble either by: - weighted Monte Carlo sampling, or - an SVGD-inspired iterative refinement with diversity regularization.

Source code in bensemble/search/bayesian.py
def __init__(
    self,
    space: SearchSpace,
    train_fn: Callable[[nn.Module], None],
    pool_size: int = 50,
    ensemble_size: int = 5,
    temperature: float = 1.0,
    diversity_weight: float = 0.5,
    svgd_steps: int = 20,
    svgd_lr: float = 0.1,
    device: torch.device | None = None,
    criterion: Callable[[list[nn.Module], DataLoader, torch.device], float]
    | None = None,
) -> None:
    if pool_size < 1:
        raise ValueError("pool_size must be >= 1.")
    if ensemble_size < 1:
        raise ValueError("ensemble_size must be >= 1.")
    if ensemble_size > pool_size:
        raise ValueError("ensemble_size must be <= pool_size.")
    if temperature <= 0:
        raise ValueError("temperature must be > 0.")
    if svgd_steps < 1:
        raise ValueError("svgd_steps must be >= 1.")

    self.space = space
    self.train_fn = train_fn
    self.pool_size = pool_size
    self.ensemble_size = ensemble_size
    self.temperature = temperature
    self.diversity_weight = diversity_weight
    self.svgd_steps = svgd_steps
    self.svgd_lr = svgd_lr
    self.device = device or torch.device(
        "cuda" if torch.cuda.is_available() else "cpu"
    )
    self.criterion = (
        criterion if criterion is not None else classification_nll_criterion
    )

sample_mc

sample_mc(val_loader: DataLoader) -> Ensemble

Parameters:

Name Type Description Default
val_loader DataLoader

Used to evaluate the posterior.

required

Returns:

Name Type Description
Ensemble Ensemble

The final ensemble wrapped in bensemble's core abstraction.

Source code in bensemble/search/bayesian.py
def sample_mc(self, val_loader: DataLoader) -> Ensemble:
    """
    Args:
        val_loader (DataLoader): Used to evaluate the posterior.

    Returns:
        Ensemble: The final ensemble wrapped in bensemble's core abstraction.
    """

    candidates = self._build_pool(val_loader)
    probs = self._posterior_probs(candidates)
    chosen = torch.multinomial(
        probs, num_samples=self.ensemble_size, replacement=False
    )
    models = [candidates[idx].model for idx in chosen.tolist()]
    return Ensemble.from_models(models)

sample_svgd

sample_svgd(val_loader: DataLoader) -> Ensemble

Parameters:

Name Type Description Default
val_loader DataLoader

Used to evaluate the architecture's loss/posterior.

required

Returns:

Name Type Description
Ensemble Ensemble

The final ensemble wrapped in bensemble's core abstraction.

Source code in bensemble/search/bayesian.py
def sample_svgd(self, val_loader: DataLoader) -> Ensemble:
    """
    Args:
        val_loader (DataLoader): Used to evaluate the architecture's loss/posterior.

    Returns:
        Ensemble: The final ensemble wrapped in bensemble's core abstraction.
    """
    candidates = self._build_pool(val_loader)
    posterior = self._posterior_probs(candidates)
    n = len(candidates)
    logits = torch.log(posterior + 1e-8)

    particles = torch.multinomial(
        posterior, num_samples=self.ensemble_size, replacement=False
    )
    for _ in range(self.svgd_steps):
        for i in range(self.ensemble_size):
            current = particles[i].item()
            best_idx = current
            best_value = float("-inf")

            for candidate_idx in range(n):
                if candidate_idx in particles.tolist() and candidate_idx != current:
                    continue
                repulsion = 0.0
                for j in range(self.ensemble_size):
                    if j == i:
                        continue
                    other_idx = particles[j].item()
                    div = self._pairwise_diversity(
                        candidates[candidate_idx].probs,
                        candidates[other_idx].probs,
                    )
                    repulsion += div
                value = logits[candidate_idx].item() + (
                    self.svgd_lr * self.diversity_weight * repulsion
                )
                if value > best_value:
                    best_value = value
                    best_idx = candidate_idx
            particles[i] = best_idx

    models = [candidates[idx].model for idx in particles.tolist()]
    return Ensemble.from_models(models)

Search Space

bensemble.search.space.SearchSpace

Bases: ABC

build abstractmethod

build(config: dict) -> nn.Module

Instantiate and return an untrained nn.Module for the given config.

Source code in bensemble/search/space.py
@abc.abstractmethod
def build(self, config: dict) -> nn.Module:
    """Instantiate and return an untrained nn.Module for the given config."""
    ...

mutate abstractmethod

mutate(config: dict) -> dict

Return a new config that is a mutated copy of the given config. Must not modify the input config in place.

Source code in bensemble/search/space.py
@abc.abstractmethod
def mutate(self, config: dict) -> dict:
    """Return a new config that is a mutated copy of the given config.
    Must not modify the input config in place."""
    ...

sample abstractmethod

sample() -> dict

Sample a random architecture config uniformly from the space.

Source code in bensemble/search/space.py
@abc.abstractmethod
def sample(self) -> dict:
    """Sample a random architecture config uniformly from the space."""
    ...

Member Selection

bensemble.search.selection

classification_nll_criterion

classification_nll_criterion(members: list[Module], val_loader: DataLoader, device: device) -> float

Evaluates classification Negative Log-Likelihood of an ensemble candidate set.

Predictions from all members are converted to softmax probabilities and averaged.

Parameters:

Name Type Description Default
members list[Module]

List of candidate neural network modules in eval mode.

required
val_loader DataLoader

DataLoader yielding validation (inputs, labels) batches.

required
device device

Device on which to run inference.

required

Returns:

Name Type Description
float float

Mean negative log-likelihood score over the validation set.

Source code in bensemble/search/selection.py
def classification_nll_criterion(
    members: list[nn.Module],
    val_loader: DataLoader,
    device: torch.device,
) -> float:
    """Evaluates classification Negative Log-Likelihood of an ensemble candidate set.

    Predictions from all members are converted to softmax probabilities and averaged.

    Args:
        members: List of candidate neural network modules in eval mode.
        val_loader: DataLoader yielding validation (inputs, labels) batches.
        device: Device on which to run inference.

    Returns:
        float: Mean negative log-likelihood score over the validation set.
    """
    for model in members:
        model.to(device)
        model.eval()

    total_nll = 0.0
    count = 0
    with torch.no_grad():
        for batch in val_loader:
            x, y = batch[0].to(device), batch[1].to(device)
            mean_probs = torch.stack(
                [F.softmax(model(x), dim=-1) for model in members], dim=0
            ).mean(dim=0)
            picked = mean_probs[torch.arange(y.shape[0], device=device), y]
            total_nll += -torch.log(picked + 1e-8).sum().item()
            count += y.shape[0]

    return total_nll / count

forward_select

forward_select(pool: list[Module], val_loader: DataLoader, ensemble_size: int, device: device, criterion: Callable[[list[Module], DataLoader, device], float]) -> list[nn.Module]

Performs greedy forward stepwise ensemble selection without replacement.

Iteratively selects models from pool that minimize the metric returned by criterion on val_loader.

Parameters:

Name Type Description Default
pool list[Module]

Candidate neural network modules.

required
val_loader DataLoader

Validation DataLoader yielding (inputs, targets) batches.

required
ensemble_size int

Number of members to select.

required
device device

Device on which to execute evaluation.

required
criterion Callable[[list[Module], DataLoader, device], float]

Evaluation callable (members, val_loader, device) -> float where lower values indicate better performance.

required

Returns:

Type Description
list[Module]

list[nn.Module]: List of selected model instances of length ensemble_size.

Source code in bensemble/search/selection.py
def forward_select(
    pool: list[nn.Module],
    val_loader: DataLoader,
    ensemble_size: int,
    device: torch.device,
    criterion: Callable[[list[nn.Module], DataLoader, torch.device], float],
) -> list[nn.Module]:
    """Performs greedy forward stepwise ensemble selection without replacement.

    Iteratively selects models from `pool` that minimize the metric returned
    by `criterion` on `val_loader`.

    Args:
        pool: Candidate neural network modules.
        val_loader: Validation DataLoader yielding (inputs, targets) batches.
        ensemble_size: Number of members to select.
        device: Device on which to execute evaluation.
        criterion: Evaluation callable `(members, val_loader, device) -> float`
            where lower values indicate better performance.

    Returns:
        list[nn.Module]: List of selected model instances of length `ensemble_size`.
    """
    selected: list[nn.Module] = []

    for _ in range(ensemble_size):
        best_score = float("inf")
        best_model = None

        for candidate in pool:
            if candidate in selected:
                continue
            score = criterion(selected + [candidate], val_loader, device)
            if score < best_score:
                best_score = score
                best_model = candidate

        assert best_model is not None
        selected.append(best_model)

    return selected

regression_mse_criterion

regression_mse_criterion(members: list[Module], val_loader: DataLoader, device: device) -> float

Evaluates Mean Squared Error of an ensemble candidate set.

Predictions from all members are averaged directly.

Parameters:

Name Type Description Default
members list[Module]

List of candidate neural network modules in eval mode.

required
val_loader DataLoader

DataLoader yielding validation (inputs, targets) batches.

required
device device

Device on which to run inference.

required

Returns:

Name Type Description
float float

Mean squared error score over the validation set.

Source code in bensemble/search/selection.py
def regression_mse_criterion(
    members: list[nn.Module],
    val_loader: DataLoader,
    device: torch.device,
) -> float:
    """Evaluates Mean Squared Error of an ensemble candidate set.

    Predictions from all members are averaged directly.

    Args:
        members: List of candidate neural network modules in eval mode.
        val_loader: DataLoader yielding validation (inputs, targets) batches.
        device: Device on which to run inference.

    Returns:
        float: Mean squared error score over the validation set.
    """
    for model in members:
        model.to(device)
        model.eval()

    total_se = 0.0
    count = 0
    with torch.no_grad():
        for batch in val_loader:
            x, y = batch[0].to(device), batch[1].to(device).float()
            mean_preds = torch.stack([model(x) for model in members], dim=0).mean(dim=0)
            total_se += F.mse_loss(mean_preds, y, reduction="sum").item()
            count += y.numel()

    return total_se / count