Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:

# 6) Set up Python & install dependencies
- uses: actions/setup-python@v5
with: { python-version: "3.10" }
with: { python-version: "3.13" }
- name: Install Python deps
run: |
pip install -e .
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ jobs:

steps:
- uses: actions/checkout@v4
- name: Set up Python 3.10
- name: Set up Python 3.13
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
127 changes: 127 additions & 0 deletions opto/features/priority_search/epsNetPS_plus_summarizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from opto.features.priority_search.priority_search import PrioritySearch, ModuleCandidate
from opto.features.priority_search.module_regressor import RegressorTemplate
from opto.features.priority_search.summarizer import Summarizer
from typing import Union, List, Tuple, Dict, Any, Optional, Callable
from opto.optimizers.utils import print_color
import numpy as np
from opto.features.priority_search.search_template import Samples


def calculate_distance_to_memory(memory, new_candidate):
"""For a new candidate, calculate the distance to the current memory. That's the least L2 distance to any candidate in the memory.

To use this funciton in PrioritySearch, set memory to be self.memory.memory.
"""
assert hasattr(new_candidate, 'embedding') and all(hasattr(candidate, 'embedding') for _, candidate in memory), "All candidates should have the embedding attribute."
min_distance = float('inf')
for _, candidate in memory:
distance = np.linalg.norm(np.array(new_candidate.embedding) - np.array(candidate.embedding))
if distance < min_distance:
min_distance = distance
return min_distance

class EpsilonNetPS_plus_Summarizer(PrioritySearch):
"""
A subclass of PrioritySearch, which keeps an epsilon-net as the memory. Reject new candidates that are in the epsilon-net of the memory.

This class uses a summarizer to summarize the memory and the exploration candidates. It then sets the context for the optimizer to use the summary to guide the exploration.

Args:
epsilon: The epsilon value for the epsilon-net. 0 means no filtering, the same as vanilla PrioritySearch.
use_summarizer: Whether to use a summarizer to summarize the memory and the exploration candidates.
summarizer_model_name: The model name for the summarizer.
*args: Additional arguments for the parent class.
**kwargs: Additional keyword arguments for the parent class.
"""
def __init__(self,
epsilon: float = 0.1,
use_summarizer: bool = False,
*args,
**kwargs):
super().__init__(*args, **kwargs)
self.epsilon = epsilon
self.use_summarizer = use_summarizer
self.regressor = RegressorTemplate()
self.summarizer = Summarizer()
self.context = "Concrete recommendations for generating better agent parameters based on successful patterns observed in the trajectories: "

def filter_candidates(self, new_candidates: List[ModuleCandidate]) -> List[ModuleCandidate]:
""" Filter candidates by their embeddings.
"""
if self.epsilon == 0: # no filtering
print_color(f"No filtering of candidates.", "green")
return new_candidates
exploration_memory = [(0, candidate) for candidate in self._exploration_candidates]
current_memory = self.memory.memory + exploration_memory
# Add embeddings to all the candidates. The regressor will check if the candidates have embeddings, and if not, it will add them in parallel.
current_candidates = [candidate for _, candidate in current_memory]
self.regressor.add_embeddings_to_candidates(current_candidates+new_candidates)

# filter new candidates based on the distance to the current memory.
num_new_candidates = len(new_candidates)

added_candidates = []
success_distances = []

while len(new_candidates) > 0:
# calculate the distance to the memory for each new candidate
distances = [calculate_distance_to_memory(current_memory, new_candidate) for new_candidate in new_candidates]

# filter candidates: keep only those with distance > epsilon
filtered_candidates = []
filtered_distances = []
for i, (candidate, distance) in enumerate(zip(new_candidates, distances)):
if distance > self.epsilon:
filtered_candidates.append(candidate)
filtered_distances.append(distance)

# if no candidates remain, exit the loop
if len(filtered_candidates) == 0:
break

# add the candidate with the largest distance to the memory
max_distance_idx = np.argmax(filtered_distances)
new_node = filtered_candidates[max_distance_idx]
current_memory.append((0, new_node))
added_candidates.append(new_node)
success_distances.append(float(filtered_distances[max_distance_idx]))

# remove the added candidate from new_candidates list
new_candidates = [c for c in filtered_candidates if c is not new_node]

print_color(f"Proposed {num_new_candidates} new candidates, {len(added_candidates)} of them are added to the memory.", "green")
# print the distances between the added candidates and the memory before adding them.
print_color(f"Distances between the added candidates and the memory before adding them: {success_distances}", "green")
return added_candidates

def compress_candidate_memory(self, candidate: ModuleCandidate) -> ModuleCandidate:
""" For the summarizer usage, we keep the entire rollout. """
if self.use_summarizer:
return candidate
else:
return super().compress_candidate_memory(candidate)

def propose(self,
samples : Samples,
verbose : bool = False,
**kwargs):
"""
Override the propose method to include a summary into the context of the optimizer.
"""

# Use the summarizer to summarize the memory and the exploration candidates.
if self.use_summarizer:
# Summarize the memory and the exploration candidates.
exploration_memory = [(0, candidate) for candidate in self._exploration_candidates]
print_color(f"Summarizing the history...", "green")
try:
summary = self.summarizer.summarize(self.memory.memory+exploration_memory)
print_color(f"Summary: {summary}", "green")
self.context = f"Concrete recommendations for generating better agent parameters based on successful patterns observed in the trajectories: {summary}"
except Exception as e:
print_color(f"Error: {e}", "red")
print_color(f"Using fallback context: {self.context}", "red")
# Set the context for the optimizer.
for candidate in self._exploration_candidates:
candidate.optimizer.set_context(self.context)
return super().propose(samples, verbose, **kwargs)
154 changes: 150 additions & 4 deletions opto/features/priority_search/module_regressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,148 @@
from opto.utils.auto_retry import retry_with_exponential_backoff
import litellm
import time
from opto.features.priority_search.priority_search import ModuleCandidate

class RegressorTemplate:
"""Base class template for regression-based predictors for ModuleCandidate objects.

Provides common functionality for embedding generation and candidate processing.
Subclasses should implement update() and predict_scores() methods.

Regressors can be built on this template by implementing the update() and predict_scores() methods.
This class itself is enough for getting embeddings for candidates.
"""

def __init__(self, embedding_model="gemini/text-embedding-004", num_threads=None, regularization_strength=1, linear_dim=None, rich_text=True,verbose: bool = False):
# In the regressor, no need for calling LLM to make the prediction. So we could predict the entire memory at once.
self.max_candidates_to_predict = 500
self.embedding_model = embedding_model
self.num_threads = num_threads
self.regularization_strength = regularization_strength # L2 regularization strength (lambda)
self.rich_text = rich_text

# Default original embedding dimension (from text-embedding-004)
self.original_embedding_dim = 768

# if linear_dim is not None:
# # Use random projection from 768D to linear_dim
# self.linear_dim = linear_dim
# print_color(f"Using random projection: {self.original_embedding_dim}D → {linear_dim}D", "blue")
# self.random_projector = GaussianRandomProjection(
# input_dim=self.original_embedding_dim,
# output_dim=linear_dim,
# random_seed=42
# )
# else:
# # Use default 768D without projection
# self.linear_dim = self.original_embedding_dim
# self.random_projector = None
self.linear_dim = self.original_embedding_dim
self.random_projector = None

# Initialize weights with larger values for more aggressive learning
self.weights = np.random.normal(0, 0.1, self.linear_dim)
self.bias = 0.0
self.verbose = verbose

def _get_parameter_text(self, candidate):
"""Get the parameter text for a ModuleCandidate."""
if not hasattr(candidate, 'update_dict'):
print(candidate)
assert hasattr(candidate, 'update_dict'), "ModuleCandidate must have an update_dict"
# Convert parameter nodes to readable names for deterministic embedding
params_with_names = {k.py_name: v for k, v in candidate.update_dict.items()}

# if self.rich_text:
# # Create rich text representation with problem definition and rating question
# rich_text_parts = []

# # Add problem definition
# rich_text_parts.append(f"Problem Definition: {DOMAIN_CONTEXT_VERIBENCH.strip()}")
# rich_text_parts.append("") # Empty line for separation

# # Add parameter configuration
# rich_text_parts.append("Parameter Configuration:")
# for param_name, param_value in params_with_names.items():
# rich_text_parts.append(f"{param_name}: {param_value}")
# rich_text_parts.append("") # Empty line for separation

# # Add rating question
# rich_text_parts.append("Question: Based on the problem context above and this parameter configuration, how do you rate this parameter?")

# return "\n".join(rich_text_parts)
# else:
return str(params_with_names)


def _get_embedding(self, candidate):
"""Get the embedding for a ModuleCandidate."""
parameter_text = self._get_parameter_text(candidate)

def single_embedding_call():
return litellm.embedding(
model=self.embedding_model,
input=parameter_text
)

try:
response = retry_with_exponential_backoff(
single_embedding_call,
max_retries=10,
base_delay=1.0,
operation_name="Embedding API call"
)
embedding = response.data[0].embedding
if self.random_projector is not None:
# Convert to numpy array and reshape for transform (expects 2D: n_samples x n_features)
embedding_array = np.array(embedding).reshape(1, -1)
projected = self.random_projector.transform(embedding_array)
# Convert back to list and flatten
embedding = projected.flatten().tolist()
return embedding
except Exception as e:
print_color(f"ERROR: Embedding API call failed after retries: {e}", "red")
return None

def add_embeddings_to_candidates(self, candidates: List[ModuleCandidate]):
"""Add embeddings to a list of candidates. This function could be used outside."""
self._update_memory_embeddings_for_batch(candidates)

def _update_memory_embeddings_for_batch(self, batch):
"""Update the embeddings for a batch of candidates."""
# Separate candidates that need embeddings from those that already have them
candidates_needing_embeddings = []
for candidate in batch:
if not hasattr(candidate, "embedding"):
candidates_needing_embeddings.append(candidate)

# Generate embeddings in parallel for candidates that need them
if candidates_needing_embeddings:
def get_embedding_for_candidate(candidate):
return self._get_embedding(candidate)

# Create function list for async_run
embedding_functions = [lambda c=candidate: get_embedding_for_candidate(c)
for candidate in candidates_needing_embeddings]

# Run embedding generation in parallel
new_embeddings = async_run(
embedding_functions,
max_workers=50,
description=f"Generating embeddings for {len(candidates_needing_embeddings)} candidates"
)

# Assign embeddings back to candidates
for candidate, embedding in zip(candidates_needing_embeddings, new_embeddings):
candidate.embedding = embedding

def update(self, memory: List[Tuple[float, ModuleCandidate]]):
"""Update the regression model parameters. Should be implemented by subclasses."""
raise NotImplementedError("Subclasses must implement the update method")

def predict_scores(self, memory: List[Tuple[float, ModuleCandidate]]):
"""Predict scores for candidates. Should be implemented by subclasses."""
raise NotImplementedError("Subclasses must implement the predict_scores method")

class ModuleCandidateRegressor:
"""
Expand Down Expand Up @@ -116,7 +258,8 @@ def get_embedding_for_candidate(candidate):
def update(self):
"""Update the regression model parameters using the current memory with logistic regression."""
start_time = time.time()
print_color("Updating regression model using the current memory with logistic regression...", "blue")
if self.verbose:
print_color("Updating regression model using the current memory with logistic regression...", "blue")
# Extract candidates from memory (memory contains (neg_score, candidate) tuples)
batch = [candidate for _, candidate in self.memory]
# Ensure all candidates have embeddings
Expand All @@ -126,10 +269,12 @@ def update(self):
training_candidates = [candidate for neg_score, candidate in self.memory if candidate.num_rollouts > 0 and candidate.mean_score() is not None]

if len(training_candidates) == 0:
print_color("Warning: No training data available for regression model.", "yellow")
if self.verbose:
print_color("Warning: No training data available for regression model.", "yellow")
end_time = time.time()
elapsed_time = end_time - start_time
print_color(f"Regressor update completed in {elapsed_time:.4f} seconds (no training data)", "cyan")
if self.verbose:
print_color(f"Regressor update completed in {elapsed_time:.4f} seconds (no training data)", "cyan")
return

# Extract raw binary training data from each candidate
Expand Down Expand Up @@ -169,7 +314,8 @@ def update(self):
print_color("Warning: No binary training samples generated.", "yellow")
end_time = time.time()
elapsed_time = end_time - start_time
print_color(f"Regressor update completed in {elapsed_time:.4f} seconds (no binary samples)", "cyan")
if self.verbose:
print_color(f"Regressor update completed in {elapsed_time:.4f} seconds (no binary samples)", "cyan")
return

# Convert to numpy arrays
Expand Down
Loading
Loading