Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions openevolve/llm/ensemble.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
"""

import asyncio
import hashlib
import logging
import math
import random
from typing import Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple

from openevolve.llm.base import LLMInterface
from openevolve.llm.openai import OpenAILLM
Expand Down Expand Up @@ -97,20 +98,26 @@ def __init__(self, models_cfg: List[LLMModelConfig]):
logger._ensemble_logged = True

self.last_usage: Optional[Dict[str, Any]] = None
# Set by reseed(); gives each iteration's requests their own seed
self._iteration: Optional[int] = None

async def generate(self, prompt: str, **kwargs) -> str:
"""Generate text using a randomly selected model based on weights"""
model = self._sample_model()
res = await model.generate(prompt, **kwargs)
index = self._sample_index()
model = self.models[index]
res = await model.generate(prompt, **self._request_kwargs(index, kwargs))
self.last_usage = getattr(model, "last_usage", None)
return res

async def generate_with_context(
self, system_message: str, messages: List[Dict[str, str]], **kwargs
) -> str:
"""Generate text using a system message and conversational context"""
model = self._sample_model()
res = await model.generate_with_context(system_message, messages, **kwargs)
index = self._sample_index()
model = self.models[index]
res = await model.generate_with_context(
system_message, messages, **self._request_kwargs(index, kwargs)
)
self.last_usage = getattr(model, "last_usage", None)
return res

Expand All @@ -120,13 +127,29 @@ def reseed(self, iteration: int) -> None:
seed = getattr(self.models_cfg[0], "random_seed", None) if self.models_cfg else None
if seed is not None:
self.random_state.seed(f"{seed}:{iteration}")
self._iteration = iteration

def _request_kwargs(self, index: int, kwargs: Dict[str, Any]) -> Dict[str, Any]:
"""After reseed(iteration), send a seed derived from the model's seed and the
iteration. A fixed seed would make every identical prompt (common early on, when
iterations share a parent) return the identical response on providers that honour it"""
if self._iteration is None or "seed" in kwargs:
return kwargs
seed = getattr(self.models_cfg[index], "random_seed", None)
if seed is None:
return kwargs
digest = hashlib.sha256(f"{seed}:{self._iteration}:request".encode()).digest()
return {**kwargs, "seed": int.from_bytes(digest[:4], "big") & 0x7FFFFFFF}

def _sample_index(self) -> int:
"""Index of a model sampled from the ensemble based on weights"""
index = self.random_state.choices(range(len(self.models)), weights=self.weights, k=1)[0]
logger.info(f"Sampled model: {self.models_cfg[index].name}")
return index

def _sample_model(self) -> LLMInterface:
"""Sample a model from the ensemble based on weights"""
index = self.random_state.choices(range(len(self.models)), weights=self.weights, k=1)[0]
sampled_model = self.models[index]
logger.info(f"Sampled model: {self.models_cfg[index].name}")
return sampled_model
return self.models[self._sample_index()]

async def generate_multiple(self, prompt: str, n: int, **kwargs) -> List[str]:
"""Generate multiple texts in parallel"""
Expand Down
43 changes: 43 additions & 0 deletions tests/test_ensemble_worker_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,49 @@ def test_same_iteration_picks_same_model_on_any_worker(self):
self.assertEqual(picked_forward, picked_backward)
self.assertEqual(picked_late[5], picked_forward[5])

def _request_seeds(self, config_dict, iterations):
"""The seed each iteration's LLM request carries, run in a fresh worker"""
process_parallel_module._worker_init(config_dict, "evaluator.py", None)
process_parallel_module._worker_prompt_sampler = MagicMock()
process_parallel_module._worker_prompt_sampler.build_prompt.return_value = {
"system": "sys",
"user": "usr",
}
process_parallel_module._worker_evaluator = MagicMock()
seeds = []

async def record_seed(llm, system_message, messages, **kwargs):
seeds.append(kwargs.get("seed"))
return None

with patch.object(OpenAILLM, "generate_with_context", record_seed):
for iteration in iterations:
process_parallel_module._run_iteration_worker(
iteration, self.snapshot, "parent", []
)
return seeds

def test_identical_prompts_get_a_different_seed_each_iteration(self):
# Same prompt every iteration: with one fixed request seed, a provider that honours
# seeds returns the same response every time
seeds = self._request_seeds(self._config_dict(seed=123), range(ITERATIONS))

self.assertEqual(len(set(seeds)), ITERATIONS)
self.assertTrue(all(isinstance(s, int) and 0 <= s < 2**31 for s in seeds))

def test_request_seed_depends_on_iteration_only(self):
config_dict = self._config_dict(seed=123)
forward = self._request_seeds(config_dict, [3, 4, 5])
alone = self._request_seeds(config_dict, [5])

self.assertEqual(alone, forward[-1:])
self.assertNotEqual(
self._request_seeds(self._config_dict(seed=124), [5]), alone, "seed is used"
)

def test_unseeded_requests_carry_no_seed(self):
self.assertEqual(self._request_seeds(self._config_dict(seed=None), [1, 2]), [None, None])

def test_unseeded_selection_stays_random(self):
config_dict = self._config_dict(seed=None)
picks = self._worker(config_dict, [7] * 64)
Expand Down
Loading