305 lines
12 KiB
Python
305 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""Ti-2 配方动态优化 · 求解器集成(Issue #79 / PRD 5.3 ②)。
|
|||
|
|
|
|||
|
|
承接 #78 的 ``OptimizationProblem``:把"问题模型"喂给**求解器**,产出满足全部
|
|||
|
|
约束、逼近目标最优的**配方/参数取值**,并给出可解释的求解报告。
|
|||
|
|
|
|||
|
|
PRD 设计口径
|
|||
|
|
------------
|
|||
|
|
- 架构表(PRD §5.3):``出:参数/配方建议``;``高(需闭环反馈)``。
|
|||
|
|
- 模板化技术路径:默认「固定主干 + 可配置超参」;新增结构走插件注册而非改内核。
|
|||
|
|
- 风险表:二期交付(数据门槛高)。故本期求解器采用**纯标准库、零第三方依赖**的
|
|||
|
|
轻量策略(坐标下降 + 网格采样),数据就绪/精度不足时可注入更强的外部求解器
|
|||
|
|
(PuLP/scipy/optuna,走 #78 预留的 ``solve`` 扩展点),**内核不绑优化库**。
|
|||
|
|
|
|||
|
|
本模块交付
|
|||
|
|
----------
|
|||
|
|
1. **``SolverConfig``**:求解策略声明式配置(网格粒度、迭代轮数、随机种子、
|
|||
|
|
是否枚举离散选择),对齐 PRD「超参包驱动」。
|
|||
|
|
2. **``Solution``**:求解结果(取值 ``assignment``、目标值、是否可行、是否达成
|
|||
|
|
``target_value``、迭代轨迹、违反约束枚举),为 #81 可解释建议提供结构化输入。
|
|||
|
|
3. **``GridSolver``**:确定性网格 + 坐标下降求解器(纯标准库):
|
|||
|
|
- 连续域变量按 ``grid_steps`` 等分离散化;
|
|||
|
|
- 离散域变量枚举 ``choices``;
|
|||
|
|
- 笛卡尔积里筛可行解、按目标 ``sense`` 选最优(全局最优保证);
|
|||
|
|
- 规模过大时退化为坐标下降(贪心)保可用性(``max_combinations`` 阈值)。
|
|||
|
|
4. **``solve(problem, config=None)``**:统一入口,便于 #80/#81 调用。
|
|||
|
|
|
|||
|
|
设计要点
|
|||
|
|
--------
|
|||
|
|
- **确定性可复现**:``random_seed`` 固定,同输入同输出(对齐 PRD"结论可复现")。
|
|||
|
|
- **可行优先**:无任何可行解时返回 ``feasible=False`` 的 Solution,不抛异常,
|
|||
|
|
便于上层降级(对齐 PRD"可用性 ≥ 99.8%")。
|
|||
|
|
- **求解器无关契约**:``solve`` 是薄入口,可被外部更强求解器替换;本模块的
|
|||
|
|
``Solution`` 结构即外部求解器需返回的契约。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import itertools
|
|||
|
|
import math
|
|||
|
|
import random
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|||
|
|
|
|||
|
|
# 复用 #78 的问题模型。作为包成员导入用 ``recipe_optim.problem``;当本文件被直接
|
|||
|
|
# 执行(与 problem.py 同目录)时回落到裸名 ``problem``。
|
|||
|
|
try: # pragma: no cover - 分支取决于导入方式
|
|||
|
|
from recipe_optim.problem import ( # type: ignore[import-not-found]
|
|||
|
|
ConstraintSpec,
|
|||
|
|
DecisionVariable,
|
|||
|
|
DomainKind,
|
|||
|
|
ObjectiveSpec,
|
|||
|
|
OptimizationProblem,
|
|||
|
|
Sense,
|
|||
|
|
_is_num,
|
|||
|
|
)
|
|||
|
|
except ImportError: # pragma: no cover
|
|||
|
|
from problem import ( # type: ignore[import-not-found,no-redef]
|
|||
|
|
ConstraintSpec,
|
|||
|
|
DecisionVariable,
|
|||
|
|
DomainKind,
|
|||
|
|
ObjectiveSpec,
|
|||
|
|
OptimizationProblem,
|
|||
|
|
Sense,
|
|||
|
|
_is_num,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SolverError(ValueError):
|
|||
|
|
"""求解器配置或执行错误(网格粒度非法、变量规模溢出等)。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class SolverConfig:
|
|||
|
|
"""求解策略声明式配置(对齐 PRD 超参包驱动)。"""
|
|||
|
|
|
|||
|
|
grid_steps: int = 11 # 连续域每个变量等分点数(含端点)
|
|||
|
|
max_combinations: int = 200000 # 笛卡尔积规模上限,超过则退化为坐标下降
|
|||
|
|
random_seed: int = 20260805 # 固定随机种子,保证确定性可复现
|
|||
|
|
enumerate_choices: bool = True # 是否完整枚举离散 choices(False 时取首个)
|
|||
|
|
|
|||
|
|
def __post_init__(self) -> None:
|
|||
|
|
if self.grid_steps < 2:
|
|||
|
|
raise SolverError("grid_steps 必须 ≥ 2(至少含两端点)")
|
|||
|
|
if self.max_combinations < 1:
|
|||
|
|
raise SolverError("max_combinations 必须 ≥ 1")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Solution:
|
|||
|
|
"""求解结果(#81 可解释建议的结构化输入)。"""
|
|||
|
|
|
|||
|
|
assignment: Dict[str, Any] = field(default_factory=dict)
|
|||
|
|
objective_value: float = 0.0
|
|||
|
|
feasible: bool = False
|
|||
|
|
target_met: bool = False
|
|||
|
|
violated: List[ConstraintSpec] = field(default_factory=list)
|
|||
|
|
iterations: int = 0
|
|||
|
|
evaluated: int = 0
|
|||
|
|
strategy: str = "" # "grid" / "coordinate_descent"
|
|||
|
|
message: str = ""
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict[str, Any]:
|
|||
|
|
return {
|
|||
|
|
"assignment": dict(self.assignment),
|
|||
|
|
"objective_value": self.objective_value,
|
|||
|
|
"feasible": self.feasible,
|
|||
|
|
"target_met": self.target_met,
|
|||
|
|
"violated": [c.to_dict() for c in self.violated],
|
|||
|
|
"iterations": self.iterations,
|
|||
|
|
"evaluated": self.evaluated,
|
|||
|
|
"strategy": self.strategy,
|
|||
|
|
"message": self.message,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 变量取值候选生成
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def candidate_values(var: DecisionVariable, config: SolverConfig) -> List[Any]:
|
|||
|
|
"""为单个变量生成求解候选取值集合。"""
|
|||
|
|
if var.kind == DomainKind.CHOICES:
|
|||
|
|
return list(var.choices) if config.enumerate_choices else [var.choices[0]]
|
|||
|
|
# bounds 连续域:等分离散化
|
|||
|
|
if var.bounds is None:
|
|||
|
|
return []
|
|||
|
|
low, high = var.bounds
|
|||
|
|
step = (high - low) / (config.grid_steps - 1)
|
|||
|
|
vals = [low + i * step for i in range(config.grid_steps)]
|
|||
|
|
if var.integer:
|
|||
|
|
vals = [float(round(v)) for v in vals]
|
|||
|
|
# 去重保序
|
|||
|
|
seen: set = set()
|
|||
|
|
uniq: List[Any] = []
|
|||
|
|
for v in vals:
|
|||
|
|
iv = int(v)
|
|||
|
|
if iv not in seen:
|
|||
|
|
seen.add(iv)
|
|||
|
|
uniq.append(iv)
|
|||
|
|
return uniq
|
|||
|
|
return vals
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _grid_size(problem: OptimizationProblem, config: SolverConfig) -> int:
|
|||
|
|
total = 1
|
|||
|
|
for v in problem.variables:
|
|||
|
|
total *= len(candidate_values(v, config))
|
|||
|
|
return total
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 求解器
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _better(new: float, best: float, sense: Sense) -> bool:
|
|||
|
|
"""判断 new 是否比 best 更优。"""
|
|||
|
|
if sense == Sense.MAXIMIZE:
|
|||
|
|
return new > best
|
|||
|
|
return new < best
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _initial_objective(sense: Sense) -> float:
|
|||
|
|
return -math.inf if sense == Sense.MAXIMIZE else math.inf
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _solve_grid(problem: OptimizationProblem, config: SolverConfig) -> Solution:
|
|||
|
|
"""完整网格枚举:笛卡尔积里筛可行、选最优(全局最优保证)。"""
|
|||
|
|
rng = random.Random(config.random_seed)
|
|||
|
|
per_var = [candidate_values(v, config) for v in problem.variables]
|
|||
|
|
names = [v.name for v in problem.variables]
|
|||
|
|
sense = problem.objective.sense
|
|||
|
|
best_obj = _initial_objective(sense)
|
|||
|
|
best_assign: Optional[Dict[str, Any]] = None
|
|||
|
|
evaluated = 0
|
|||
|
|
iterations = 0
|
|||
|
|
# 为控制内存,逐组合判定,不一次性 materialize
|
|||
|
|
for combo in itertools.product(*per_var):
|
|||
|
|
evaluated += 1
|
|||
|
|
iterations += 1
|
|||
|
|
assignment = dict(zip(names, combo))
|
|||
|
|
if not problem.is_feasible(assignment):
|
|||
|
|
continue
|
|||
|
|
obj = problem.objective.evaluate(assignment)
|
|||
|
|
if best_assign is None or _better(obj, best_obj, sense):
|
|||
|
|
best_obj = obj
|
|||
|
|
best_assign = assignment
|
|||
|
|
feasible = best_assign is not None
|
|||
|
|
return _build_solution(problem, config, best_assign or {}, best_obj,
|
|||
|
|
feasible, iterations, evaluated, "grid",
|
|||
|
|
"网格枚举完成" if feasible else "无可行解(约束过紧或域为空)")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _solve_coordinate_descent(
|
|||
|
|
problem: OptimizationProblem, config: SolverConfig
|
|||
|
|
) -> Solution:
|
|||
|
|
"""坐标下降:固定其余变量、逐维选当前最优取值(贪心,规模过大时降级用)。
|
|||
|
|
|
|||
|
|
从初值(``initial`` 缺省取域中点)出发,反复扫描各变量、在候选值里取使目标
|
|||
|
|
最优且保持可行者;迭代至收敛或达 ``max_rounds``。非全局最优,但保可用性。
|
|||
|
|
"""
|
|||
|
|
sense = problem.objective.sense
|
|||
|
|
names = [v.name for v in problem.variables]
|
|||
|
|
per_var = {v.name: candidate_values(v, config) for v in problem.variables}
|
|||
|
|
# 初值
|
|||
|
|
assignment: Dict[str, Any] = {}
|
|||
|
|
for v in problem.variables:
|
|||
|
|
if v.initial is not None and v.contains(v.initial):
|
|||
|
|
assignment[v.name] = v.initial
|
|||
|
|
elif v.kind == DomainKind.CHOICES and v.choices:
|
|||
|
|
assignment[v.name] = v.choices[0]
|
|||
|
|
elif v.bounds is not None:
|
|||
|
|
assignment[v.name] = (v.bounds[0] + v.bounds[1]) / 2.0
|
|||
|
|
else: # pragma: no cover - 防御
|
|||
|
|
assignment[v.name] = None
|
|||
|
|
max_rounds = max(3, len(names))
|
|||
|
|
evaluated = 0
|
|||
|
|
iterations = 0
|
|||
|
|
for _round in range(max_rounds):
|
|||
|
|
improved = False
|
|||
|
|
for name in names:
|
|||
|
|
cur_best = assignment[name]
|
|||
|
|
cur_assign = dict(assignment)
|
|||
|
|
cur_obj = problem.objective.evaluate(cur_assign) if problem.is_feasible(cur_assign) else None
|
|||
|
|
best_val = cur_best
|
|||
|
|
best_obj = cur_obj if cur_obj is not None else _initial_objective(sense)
|
|||
|
|
for cand in per_var[name]:
|
|||
|
|
evaluated += 1
|
|||
|
|
trial = dict(assignment)
|
|||
|
|
trial[name] = cand
|
|||
|
|
if not problem.is_feasible(trial):
|
|||
|
|
continue
|
|||
|
|
obj = problem.objective.evaluate(trial)
|
|||
|
|
if cur_obj is None or _better(obj, best_obj, sense):
|
|||
|
|
best_obj = obj
|
|||
|
|
best_val = cand
|
|||
|
|
if best_val != cur_best:
|
|||
|
|
assignment[name] = best_val
|
|||
|
|
improved = True
|
|||
|
|
iterations += 1
|
|||
|
|
if not improved:
|
|||
|
|
break
|
|||
|
|
feasible = problem.is_feasible(assignment)
|
|||
|
|
final_obj = problem.objective.evaluate(assignment) if feasible else 0.0
|
|||
|
|
return _build_solution(problem, config, assignment, final_obj, feasible,
|
|||
|
|
iterations, evaluated, "coordinate_descent",
|
|||
|
|
"坐标下降完成" if feasible else "坐标下降未找到可行解")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_solution(
|
|||
|
|
problem: OptimizationProblem,
|
|||
|
|
config: SolverConfig,
|
|||
|
|
assignment: Dict[str, Any],
|
|||
|
|
obj: float,
|
|||
|
|
feasible: bool,
|
|||
|
|
iterations: int,
|
|||
|
|
evaluated: int,
|
|||
|
|
strategy: str,
|
|||
|
|
message: str,
|
|||
|
|
) -> Solution:
|
|||
|
|
violated = problem.violated_constraints(assignment) if assignment else []
|
|||
|
|
target_met = False
|
|||
|
|
if feasible and problem.objective.target_value is not None:
|
|||
|
|
if problem.objective.sense == Sense.MAXIMIZE:
|
|||
|
|
target_met = obj >= problem.objective.target_value
|
|||
|
|
else:
|
|||
|
|
target_met = obj <= problem.objective.target_value
|
|||
|
|
elif feasible and problem.objective.target_value is None:
|
|||
|
|
target_met = True # 未设达标量则视为达成
|
|||
|
|
return Solution(
|
|||
|
|
assignment=assignment,
|
|||
|
|
objective_value=obj,
|
|||
|
|
feasible=feasible,
|
|||
|
|
target_met=target_met,
|
|||
|
|
violated=violated,
|
|||
|
|
iterations=iterations,
|
|||
|
|
evaluated=evaluated,
|
|||
|
|
strategy=strategy,
|
|||
|
|
message=message,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def solve(problem: OptimizationProblem,
|
|||
|
|
config: Optional[SolverConfig] = None) -> Solution:
|
|||
|
|
"""统一求解入口。
|
|||
|
|
|
|||
|
|
自动按规模选择策略:网格规模 ≤ ``max_combinations`` 用全局网格枚举,
|
|||
|
|
否则退化为坐标下降(保可用性)。先做静态校验,校验失败直接返回不可行解。
|
|||
|
|
"""
|
|||
|
|
cfg = config or SolverConfig()
|
|||
|
|
# 静态校验
|
|||
|
|
errs = problem.validate()
|
|||
|
|
if errs:
|
|||
|
|
return Solution(feasible=False, strategy="validate",
|
|||
|
|
message="问题校验失败: " + "; ".join(errs))
|
|||
|
|
# 空问题:无可调变量
|
|||
|
|
if not problem.variables:
|
|||
|
|
return Solution(feasible=True, target_met=True, strategy="empty",
|
|||
|
|
message="无决策变量,视为平凡可行")
|
|||
|
|
size = _grid_size(problem, cfg)
|
|||
|
|
if size <= cfg.max_combinations:
|
|||
|
|
return _solve_grid(problem, cfg)
|
|||
|
|
return _solve_coordinate_descent(problem, cfg)
|