fischer-agentkit/src/agentkit/evolution/fitness.py

280 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""MultiObjectiveFitness - 多目标适应度评估
支持准确率+延迟+成本的综合评估Pareto 前沿维护。
扩展 StrategyTuner 到多维参数空间。
"""
from __future__ import annotations
import logging
import math
import random
from dataclasses import dataclass, field
from typing import Any
from agentkit.evolution.genetic import FitnessScore
logger = logging.getLogger(__name__)
@dataclass
class FitnessWeights:
"""适应度权重配置"""
accuracy: float = 0.6
latency: float = 0.2
cost: float = 0.2
def __post_init__(self):
total = self.accuracy + self.latency + self.cost
if abs(total - 1.0) > 0.01:
# Normalize to sum=1
self.accuracy /= total
self.latency /= total
self.cost /= total
class MultiObjectiveFitness:
"""多目标适应度评估器
将多个维度的指标综合为加权适应度分数,
并支持 Pareto 前沿维护。
使用方式:
evaluator = MultiObjectiveFitness(weights=FitnessWeights(accuracy=0.6, latency=0.2, cost=0.2))
score = evaluator.evaluate(accuracy=0.9, latency_ms=500, cost_tokens=2000)
weighted = evaluator.weighted_score(score)
"""
def __init__(
self,
weights: FitnessWeights | None = None,
max_latency_ms: float = 10000.0,
max_cost_tokens: float = 10000.0,
):
self._weights = weights or FitnessWeights()
self._max_latency_ms = max_latency_ms
self._max_cost_tokens = max_cost_tokens
def evaluate(
self,
accuracy: float = 0.0,
latency_ms: float = 0.0,
cost_tokens: float = 0.0,
custom: float = 0.0,
) -> FitnessScore:
"""评估多目标适应度"""
return FitnessScore(
accuracy=min(max(accuracy, 0.0), 1.0),
latency_ms=latency_ms,
cost_tokens=cost_tokens,
custom=custom,
)
def weighted_score(self, score: FitnessScore) -> float:
"""计算加权综合分数"""
n = score.normalized
return (
n["accuracy"] * self._weights.accuracy
+ n["latency"] * self._weights.latency
+ n["cost"] * self._weights.cost
)
def pareto_rank(self, scores: list[FitnessScore]) -> list[int]:
"""计算 Pareto 等级
返回每个个体的 Pareto 等级0 = 前沿1 = 第二层,...
使用非支配排序算法 (NSGA-II)。
"""
n = len(scores)
if n == 0:
return []
ranks = [0] * n
domination_count = [0] * n # 被多少个体支配
dominated_set: list[list[int]] = [[] for _ in range(n)] # 支配哪些个体
# Build domination relationships
for i in range(n):
for j in range(i + 1, n):
if scores[i].dominates(scores[j]):
dominated_set[i].append(j)
domination_count[j] += 1
elif scores[j].dominates(scores[i]):
dominated_set[j].append(i)
domination_count[i] += 1
# Assign ranks level by level
current_front = [i for i in range(n) if domination_count[i] == 0]
rank = 0
while current_front:
for idx in current_front:
ranks[idx] = rank
next_front = []
for idx in current_front:
for dominated_idx in dominated_set[idx]:
domination_count[dominated_idx] -= 1
if domination_count[dominated_idx] == 0:
next_front.append(dominated_idx)
current_front = next_front
rank += 1
return ranks
def crowding_distance(self, scores: list[FitnessScore]) -> list[float]:
"""计算拥挤度距离(同一 Pareto 等级内的多样性指标)"""
n = len(scores)
if n <= 2:
return [float("inf")] * n
distances = [0.0] * n
dimensions = ["accuracy", "latency", "cost"]
for dim in dimensions:
# Sort by this dimension
indices = list(range(n))
get_val = lambda i: scores[i].normalized[dim]
indices.sort(key=get_val)
# Boundary points get infinite distance
distances[indices[0]] = float("inf")
distances[indices[-1]] = float("inf")
# Compute range
vals = [get_val(i) for i in indices]
val_range = vals[-1] - vals[0]
if val_range == 0:
continue
# Add normalized distance
for k in range(1, n - 1):
i = indices[k]
distances[i] += (vals[k + 1] - vals[k - 1]) / val_range
return distances
@dataclass
class ExtendedStrategyConfig:
"""扩展的策略配置"""
temperature: float = 0.5
max_iterations: int = 5
top_k: int = 5
retrieval_mode: str = "enhanced" # "standard", "enhanced"
timeout_seconds: int = 300
tool_weights: dict[str, float] = field(default_factory=dict)
class ExtendedStrategyTuner:
"""多维策略调优器
扩展 StrategyTuner 到多维参数空间:
- temperature, max_iterations, top_k, retrieval_mode
- 支持参数范围约束
- Bayesian-inspired 多维优化
"""
def __init__(
self,
param_ranges: dict[str, tuple[float, float]] | None = None,
):
self._param_ranges = param_ranges or {
"temperature": (0.0, 2.0),
"max_iterations": (1, 10),
"top_k": (1, 20),
}
self._history: list[dict[str, Any]] = []
def record(self, config: ExtendedStrategyConfig, metric: float) -> None:
"""记录配置和效果指标"""
self._history.append({
"config": config,
"metric": metric,
})
async def suggest(
self, current: ExtendedStrategyConfig
) -> ExtendedStrategyConfig:
"""基于历史数据建议新策略
使用多维 Bayesian-inspired 优化:
1. 在历史中找到 Pareto 最优配置
2. 在最优配置附近添加高斯噪声探索
"""
if len(self._history) < 3:
return current
best = max(self._history, key=lambda x: x["metric"])
best_config = best["config"]
suggested_temperature = self._optimize_param(
"temperature",
best_config.temperature,
noise_std=0.1,
)
suggested_max_iterations = int(self._optimize_param(
"max_iterations",
best_config.max_iterations,
noise_std=1.0,
))
suggested_top_k = int(self._optimize_param(
"top_k",
best_config.top_k,
noise_std=2.0,
))
# Retrieval mode: switch if >50% of top performers use the other mode
suggested_mode = self._suggest_retrieval_mode(best_config.retrieval_mode)
return ExtendedStrategyConfig(
temperature=suggested_temperature,
max_iterations=suggested_max_iterations,
top_k=suggested_top_k,
retrieval_mode=suggested_mode,
timeout_seconds=current.timeout_seconds,
tool_weights=dict(best_config.tool_weights),
)
def _optimize_param(
self,
param_name: str,
best_value: float,
noise_std: float,
) -> float:
"""多维 Bayesian-inspired 参数优化"""
decay = 1.0 / (1.0 + len(self._history) / 10.0)
effective_noise = noise_std * decay
perturbation = random.gauss(0, effective_noise)
new_value = best_value + perturbation
min_val, max_val = self._param_ranges.get(param_name, (0.0, 1.0))
return max(min_val, min(max_val, new_value))
def _suggest_retrieval_mode(self, current_mode: str) -> str:
"""建议检索模式"""
if len(self._history) < 5:
return current_mode
# Check top performers
top = sorted(self._history, key=lambda x: x["metric"], reverse=True)[:5]
enhanced_count = sum(
1 for h in top if h["config"].retrieval_mode == "enhanced"
)
if enhanced_count >= 3:
return "enhanced"
elif enhanced_count <= 1:
return "standard"
return current_mode
@property
def history_size(self) -> int:
return len(self._history)