426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
414 lines
14 KiB
Python
414 lines
14 KiB
Python
"""
|
|
A/B Testing Framework for Feed Agent Training
|
|
|
|
Compares trained agent models against baseline models using standardized evaluation scenarios.
|
|
|
|
Usage:
|
|
from training.ab_testing import ABTestRunner
|
|
|
|
runner = ABTestRunner(
|
|
model_a="google/gemma-4-31B", # Baseline
|
|
model_b="./trained_models/final_model", # Trained
|
|
scenarios=EVAL_SCENARIOS,
|
|
)
|
|
|
|
results = await runner.run()
|
|
print(results.summary())
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .format_validator import validate_response_format
|
|
from .quality_scorer import score_response
|
|
from .scenario_pool import Scenario, ScenarioPool, ScenarioPoolConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ModelResult:
|
|
"""Result from a single model evaluation."""
|
|
|
|
model_name: str
|
|
scenario_id: str
|
|
response: str
|
|
score: float
|
|
format_valid: bool
|
|
action_type: str | None
|
|
reasoning_quality: float
|
|
latency_ms: float
|
|
tokens_generated: int
|
|
|
|
|
|
@dataclass
|
|
class ABTestResult:
|
|
"""Aggregated A/B test results."""
|
|
|
|
model_a_name: str
|
|
model_b_name: str
|
|
|
|
# Per-scenario results
|
|
scenario_results: list[tuple[ModelResult, ModelResult]] = field(default_factory=list)
|
|
|
|
# Aggregate metrics
|
|
model_a_avg_score: float = 0.0
|
|
model_b_avg_score: float = 0.0
|
|
model_a_format_rate: float = 0.0
|
|
model_b_format_rate: float = 0.0
|
|
model_a_avg_latency: float = 0.0
|
|
model_b_avg_latency: float = 0.0
|
|
|
|
# Win rates
|
|
model_a_wins: int = 0
|
|
model_b_wins: int = 0
|
|
ties: int = 0
|
|
|
|
# Archetype-specific results
|
|
archetype_results: dict[str, dict[str, float]] = field(default_factory=dict)
|
|
|
|
def compute_aggregates(self) -> None:
|
|
"""Compute aggregate metrics from scenario results."""
|
|
if not self.scenario_results:
|
|
return
|
|
|
|
n = len(self.scenario_results)
|
|
|
|
# Aggregate scores
|
|
a_scores = [r[0].score for r in self.scenario_results]
|
|
b_scores = [r[1].score for r in self.scenario_results]
|
|
self.model_a_avg_score = sum(a_scores) / n
|
|
self.model_b_avg_score = sum(b_scores) / n
|
|
|
|
# Format rates
|
|
self.model_a_format_rate = sum(1 for r in self.scenario_results if r[0].format_valid) / n
|
|
self.model_b_format_rate = sum(1 for r in self.scenario_results if r[1].format_valid) / n
|
|
|
|
# Latencies
|
|
self.model_a_avg_latency = sum(r[0].latency_ms for r in self.scenario_results) / n
|
|
self.model_b_avg_latency = sum(r[1].latency_ms for r in self.scenario_results) / n
|
|
|
|
# Win rates
|
|
for a_result, b_result in self.scenario_results:
|
|
if a_result.score > b_result.score:
|
|
self.model_a_wins += 1
|
|
elif b_result.score > a_result.score:
|
|
self.model_b_wins += 1
|
|
else:
|
|
self.ties += 1
|
|
|
|
def summary(self) -> str:
|
|
"""Generate a human-readable summary."""
|
|
lines = [
|
|
"=" * 60,
|
|
"A/B TEST RESULTS",
|
|
"=" * 60,
|
|
f"Model A: {self.model_a_name}",
|
|
f"Model B: {self.model_b_name}",
|
|
f"Scenarios: {len(self.scenario_results)}",
|
|
"",
|
|
"AGGREGATE METRICS:",
|
|
f" Average Score: A={self.model_a_avg_score:.3f}, B={self.model_b_avg_score:.3f}",
|
|
f" Format Rate: A={self.model_a_format_rate:.1%}, B={self.model_b_format_rate:.1%}",
|
|
f" Avg Latency: A={self.model_a_avg_latency:.0f}ms, B={self.model_b_avg_latency:.0f}ms",
|
|
"",
|
|
"WIN RATES:",
|
|
f" Model A Wins: {self.model_a_wins} ({self.model_a_wins / max(len(self.scenario_results), 1):.1%})",
|
|
f" Model B Wins: {self.model_b_wins} ({self.model_b_wins / max(len(self.scenario_results), 1):.1%})",
|
|
f" Ties: {self.ties}",
|
|
"",
|
|
]
|
|
|
|
# Archetype breakdown
|
|
if self.archetype_results:
|
|
lines.append("ARCHETYPE BREAKDOWN:")
|
|
for archetype, metrics in self.archetype_results.items():
|
|
lines.append(f" {archetype}:")
|
|
lines.append(
|
|
f" A: {metrics.get('a_score', 0):.3f}, B: {metrics.get('b_score', 0):.3f}"
|
|
)
|
|
|
|
# Winner determination
|
|
lines.append("")
|
|
if self.model_b_wins > self.model_a_wins:
|
|
improvement = (
|
|
(self.model_b_avg_score - self.model_a_avg_score)
|
|
/ max(abs(self.model_a_avg_score), 0.001)
|
|
* 100
|
|
)
|
|
lines.append(f"WINNER: Model B (+{improvement:.1f}% improvement)")
|
|
elif self.model_a_wins > self.model_b_wins:
|
|
lines.append("WINNER: Model A (baseline)")
|
|
else:
|
|
lines.append("RESULT: TIE")
|
|
|
|
lines.append("=" * 60)
|
|
return "\n".join(lines)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Convert to dictionary for JSON serialization."""
|
|
return {
|
|
"model_a": self.model_a_name,
|
|
"model_b": self.model_b_name,
|
|
"num_scenarios": len(self.scenario_results),
|
|
"model_a_avg_score": self.model_a_avg_score,
|
|
"model_b_avg_score": self.model_b_avg_score,
|
|
"model_a_format_rate": self.model_a_format_rate,
|
|
"model_b_format_rate": self.model_b_format_rate,
|
|
"model_a_wins": self.model_a_wins,
|
|
"model_b_wins": self.model_b_wins,
|
|
"ties": self.ties,
|
|
"winner": "model_b"
|
|
if self.model_b_wins > self.model_a_wins
|
|
else "model_a"
|
|
if self.model_a_wins > self.model_b_wins
|
|
else "tie",
|
|
"archetype_results": self.archetype_results,
|
|
}
|
|
|
|
|
|
# Standard evaluation scenarios for consistent benchmarking
|
|
EVAL_SCENARIOS = {
|
|
"trader": [
|
|
{"name": "bull_market", "volatility": 0.05, "trend": 0.02, "difficulty": "easy"},
|
|
{"name": "bear_market", "volatility": 0.08, "trend": -0.03, "difficulty": "medium"},
|
|
{"name": "choppy", "volatility": 0.15, "trend": 0.00, "difficulty": "hard"},
|
|
{"name": "breakout", "volatility": 0.10, "trend": 0.05, "difficulty": "medium"},
|
|
],
|
|
"degen": [
|
|
{"name": "high_vol_opportunity", "volatility": 0.25, "trend": 0.08, "difficulty": "medium"},
|
|
{"name": "pump_scenario", "volatility": 0.35, "trend": 0.15, "difficulty": "hard"},
|
|
{"name": "leverage_test", "volatility": 0.20, "trend": 0.03, "difficulty": "hard"},
|
|
],
|
|
"analyst": [
|
|
{"name": "complex_market", "num_markets": 8, "difficulty": "hard"},
|
|
{"name": "news_driven", "news_count": 10, "difficulty": "medium"},
|
|
],
|
|
"whale": [
|
|
{"name": "large_position", "balance": 100000, "difficulty": "medium"},
|
|
{"name": "market_impact", "liquidity_low": True, "difficulty": "hard"},
|
|
],
|
|
"influencer": [
|
|
{"name": "social_opportunity", "social_posts": 15, "difficulty": "medium"},
|
|
{"name": "trending_market", "trend": 0.10, "difficulty": "easy"},
|
|
],
|
|
}
|
|
|
|
|
|
class ABTestRunner:
|
|
"""
|
|
Runs A/B tests comparing two models on standardized scenarios.
|
|
|
|
Args:
|
|
model_a: Path or name of first model (typically baseline)
|
|
model_b: Path or name of second model (typically trained)
|
|
scenarios: Dictionary of archetype -> scenario configs
|
|
vllm_url: URL of vLLM server for inference
|
|
num_runs_per_scenario: Number of runs per scenario for statistical significance
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
model_a: str,
|
|
model_b: str,
|
|
scenarios: dict[str, list[dict]] | None = None,
|
|
vllm_url: str = "http://localhost:9001/v1",
|
|
num_runs_per_scenario: int = 3,
|
|
output_dir: str = "./ab_test_results",
|
|
):
|
|
self.model_a = model_a
|
|
self.model_b = model_b
|
|
self.scenarios = scenarios or EVAL_SCENARIOS
|
|
self.vllm_url = vllm_url
|
|
self.num_runs = num_runs_per_scenario
|
|
self.output_dir = Path(output_dir)
|
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
self._session = None
|
|
self._scenario_pool = ScenarioPool(config=ScenarioPoolConfig())
|
|
self._tokenizer = None # Lazily loaded for accurate token counting
|
|
|
|
async def run(self) -> ABTestResult:
|
|
"""Run the full A/B test suite."""
|
|
import aiohttp
|
|
|
|
result = ABTestResult(
|
|
model_a_name=self.model_a,
|
|
model_b_name=self.model_b,
|
|
)
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
self._session = session
|
|
|
|
for archetype, scenario_configs in self.scenarios.items():
|
|
logger.info(f"Testing archetype: {archetype}")
|
|
archetype_a_scores = []
|
|
archetype_b_scores = []
|
|
|
|
for config in scenario_configs:
|
|
for run_idx in range(self.num_runs):
|
|
# Generate scenario using pool
|
|
scenario = self._scenario_pool._generate_synthetic_scenario(
|
|
difficulty=config.get("difficulty", "medium"),
|
|
archetype_focus=archetype,
|
|
)
|
|
|
|
# Run both models
|
|
a_result = await self._evaluate_model(self.model_a, scenario, archetype)
|
|
b_result = await self._evaluate_model(self.model_b, scenario, archetype)
|
|
|
|
result.scenario_results.append((a_result, b_result))
|
|
archetype_a_scores.append(a_result.score)
|
|
archetype_b_scores.append(b_result.score)
|
|
|
|
logger.debug(
|
|
f" {config['name']} run {run_idx + 1}: "
|
|
f"A={a_result.score:.3f}, B={b_result.score:.3f}"
|
|
)
|
|
|
|
# Store archetype-level results
|
|
if archetype_a_scores:
|
|
result.archetype_results[archetype] = {
|
|
"a_score": sum(archetype_a_scores) / len(archetype_a_scores),
|
|
"b_score": sum(archetype_b_scores) / len(archetype_b_scores),
|
|
"num_scenarios": len(archetype_a_scores),
|
|
}
|
|
|
|
# Compute aggregates
|
|
result.compute_aggregates()
|
|
|
|
# Save results
|
|
self._save_results(result)
|
|
|
|
return result
|
|
|
|
def _count_tokens(self, text: str) -> int:
|
|
"""Count tokens using tokenizer, with fallback to word splitting."""
|
|
if self._tokenizer is None:
|
|
try:
|
|
from transformers import AutoTokenizer
|
|
|
|
# Use model_a as the tokenizer source (both models should use same tokenizer)
|
|
self._tokenizer = AutoTokenizer.from_pretrained(
|
|
self.model_a,
|
|
trust_remote_code=True,
|
|
)
|
|
except Exception:
|
|
# Fallback: return word count if tokenizer unavailable
|
|
return len(text.split())
|
|
|
|
try:
|
|
return len(self._tokenizer.encode(text))
|
|
except Exception:
|
|
return len(text.split())
|
|
|
|
async def _evaluate_model(
|
|
self,
|
|
model_name: str,
|
|
scenario: Scenario,
|
|
archetype: str,
|
|
) -> ModelResult:
|
|
"""Evaluate a single model on a scenario."""
|
|
import time
|
|
|
|
import aiohttp
|
|
|
|
from .online_env import build_observation_prompt, build_trading_system_prompt
|
|
|
|
# Build prompt
|
|
system_prompt = build_trading_system_prompt(archetype)
|
|
user_prompt = build_observation_prompt(scenario)
|
|
|
|
messages = [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_prompt},
|
|
]
|
|
|
|
# Call vLLM
|
|
start_time = time.time()
|
|
|
|
try:
|
|
async with self._session.post(
|
|
f"{self.vllm_url}/chat/completions",
|
|
json={
|
|
"model": model_name,
|
|
"messages": messages,
|
|
"max_tokens": 512,
|
|
"temperature": 0.7,
|
|
},
|
|
timeout=aiohttp.ClientTimeout(total=60),
|
|
) as resp:
|
|
if resp.status != 200:
|
|
logger.warning(f"vLLM error: {resp.status}")
|
|
response_text = ""
|
|
else:
|
|
data = await resp.json()
|
|
response_text = data["choices"][0]["message"]["content"] or ""
|
|
except Exception as e:
|
|
logger.error(f"Error calling vLLM: {e}")
|
|
response_text = ""
|
|
|
|
latency_ms = (time.time() - start_time) * 1000
|
|
|
|
# Score response
|
|
format_result = validate_response_format(response_text)
|
|
quality_result = score_response(response_text, scenario, archetype)
|
|
|
|
return ModelResult(
|
|
model_name=model_name,
|
|
scenario_id=scenario.id,
|
|
response=response_text,
|
|
score=quality_result.total_score,
|
|
format_valid=format_result.is_valid,
|
|
action_type=format_result.action.action_type if format_result.action else None,
|
|
reasoning_quality=quality_result.reasoning_score,
|
|
latency_ms=latency_ms,
|
|
tokens_generated=self._count_tokens(response_text),
|
|
)
|
|
|
|
def _save_results(self, result: ABTestResult) -> None:
|
|
"""Save results to JSON file."""
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = f"ab_test_{timestamp}.json"
|
|
filepath = self.output_dir / filename
|
|
|
|
with open(filepath, "w") as f:
|
|
json.dump(result.to_dict(), f, indent=2)
|
|
|
|
logger.info(f"Results saved to: {filepath}")
|
|
|
|
|
|
async def run_ab_test(
|
|
model_a: str,
|
|
model_b: str,
|
|
archetypes: list[str] | None = None,
|
|
num_scenarios: int = 10,
|
|
) -> ABTestResult:
|
|
"""
|
|
Convenience function to run an A/B test.
|
|
|
|
Args:
|
|
model_a: Baseline model path/name
|
|
model_b: Trained model path/name
|
|
archetypes: List of archetypes to test (default: all)
|
|
num_scenarios: Number of scenarios per archetype
|
|
|
|
Returns:
|
|
ABTestResult with comparison metrics
|
|
"""
|
|
scenarios = EVAL_SCENARIOS
|
|
if archetypes:
|
|
scenarios = {k: v for k, v in EVAL_SCENARIOS.items() if k in archetypes}
|
|
|
|
runner = ABTestRunner(
|
|
model_a=model_a,
|
|
model_b=model_b,
|
|
scenarios=scenarios,
|
|
)
|
|
|
|
return await runner.run()
|