Files
tick-stock-panel/backend/app/backtest/mining.py
T
shy3130 697c27bb02 feat(v0.2): 市场阶段与主线识别 + 因子挖掘全链路 + 数据层完善
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动,
  EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合,
  可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存
- 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档),
  周度调度默认关闭且永不自动发布
- 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益,
  信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错)
- 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复
- 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
2026-08-16 23:39:07 +08:00

1661 lines
61 KiB
Python

"""Pure factor-mining algorithms and callback-driven nested validation."""
from __future__ import annotations
import json
import logging
import math
import time
from collections.abc import Callable, Mapping, Sequence
from dataclasses import asdict, dataclass, field, replace
from datetime import date
from typing import Any, Literal, Protocol
import numpy as np
import polars as pl
logger = logging.getLogger(__name__)
MAX_MINING_FACTORS = 48
MAX_EXISTING_STRATEGIES = 8
MAX_COMBINATION_SIZE = 4
MAX_BEAM_WIDTH = 32
MAX_FINALISTS = 8
MAX_REAL_TRIALS = 256
# Evidence-based promotion gate documented in docs/mining.md. A candidate must
# clear every threshold before it may be published as an independent strategy.
GATE_MIN_VALID_FOLDS = 2
GATE_MIN_POSITIVE_FOLD_RATIO = 2.0 / 3.0
GATE_MIN_OOS_SHARPE = 0.5
GATE_MAX_DRAWDOWN = -0.25
GATE_MIN_TRADES = 60
MiningProfile = Literal["exploratory", "balanced", "strict"]
class JsonDataclassMixin:
"""Provide JSON output without coupling pure models to Pydantic."""
def to_json(self) -> str:
return json.dumps(asdict(self), ensure_ascii=False, allow_nan=False)
@dataclass(frozen=True)
class MiningBudget(JsonDataclassMixin):
max_factors: int = MAX_MINING_FACTORS
max_existing_strategies: int = MAX_EXISTING_STRATEGIES
max_combination_size: int = MAX_COMBINATION_SIZE
beam_width: int = 16
max_proxy_trials: int = 256
max_trials: int = 96
def __post_init__(self) -> None:
if not 1 <= self.max_factors <= MAX_MINING_FACTORS:
raise ValueError(f"max_factors must be between 1 and {MAX_MINING_FACTORS}")
if not 0 <= self.max_existing_strategies <= MAX_EXISTING_STRATEGIES:
raise ValueError(
"max_existing_strategies must be between 0 and "
f"{MAX_EXISTING_STRATEGIES}"
)
if not 1 <= self.max_combination_size <= MAX_COMBINATION_SIZE:
raise ValueError(
"max_combination_size must be between 1 and "
f"{MAX_COMBINATION_SIZE}"
)
if not 1 <= self.beam_width <= MAX_BEAM_WIDTH:
raise ValueError(f"beam_width must be between 1 and {MAX_BEAM_WIDTH}")
if self.max_proxy_trials <= 0:
raise ValueError("max_proxy_trials must be positive")
if not 1 <= self.max_trials <= MAX_REAL_TRIALS:
raise ValueError(f"max_trials must be between 1 and {MAX_REAL_TRIALS}")
@classmethod
def exploratory(cls) -> MiningBudget:
return cls(
max_combination_size=3,
beam_width=8,
max_proxy_trials=96,
max_trials=32,
)
@classmethod
def balanced(cls) -> MiningBudget:
return cls()
@classmethod
def strict(cls) -> MiningBudget:
return cls(beam_width=32, max_proxy_trials=512, max_trials=256)
@dataclass(frozen=True)
class NestedValidationConfig(JsonDataclassMixin):
outer_train_bars: int = 504
outer_test_bars: int = 126
outer_step_bars: int = 63
inner_train_bars: int = 252
inner_test_bars: int = 63
inner_step_bars: int = 63
purge_bars: int = 30
embargo_bars: int = 5
min_train_bars: int = 126
def __post_init__(self) -> None:
positive = {
"outer_train_bars": self.outer_train_bars,
"outer_test_bars": self.outer_test_bars,
"outer_step_bars": self.outer_step_bars,
"inner_train_bars": self.inner_train_bars,
"inner_test_bars": self.inner_test_bars,
"inner_step_bars": self.inner_step_bars,
"min_train_bars": self.min_train_bars,
}
invalid = [name for name, value in positive.items() if value <= 0]
if invalid:
raise ValueError(f"nested validation bars must be positive: {invalid}")
if self.purge_bars < 0 or self.embargo_bars < 0:
raise ValueError("purge_bars and embargo_bars must not be negative")
if self.outer_train_bars < self.min_train_bars:
raise ValueError("outer_train_bars is smaller than min_train_bars")
if self.inner_train_bars < self.min_train_bars:
raise ValueError("inner_train_bars is smaller than min_train_bars")
@classmethod
def exploratory(cls) -> NestedValidationConfig:
return cls(
outer_train_bars=126,
outer_test_bars=63,
outer_step_bars=63,
inner_train_bars=63,
inner_test_bars=21,
inner_step_bars=21,
purge_bars=30,
embargo_bars=5,
min_train_bars=63,
)
@classmethod
def balanced(cls) -> NestedValidationConfig:
return cls()
@classmethod
def strict(cls) -> NestedValidationConfig:
return cls(
outer_train_bars=756,
outer_test_bars=126,
outer_step_bars=126,
inner_train_bars=504,
inner_test_bars=63,
inner_step_bars=63,
purge_bars=30,
embargo_bars=5,
min_train_bars=126,
)
def validation_config_for_profile(profile: str) -> NestedValidationConfig:
if profile not in {"exploratory", "balanced", "strict"}:
raise ValueError(f"unknown mining profile: {profile}")
return getattr(NestedValidationConfig, profile)()
def required_outer_folds(profile: str) -> int:
validation_config_for_profile(profile)
return 1 if profile == "exploratory" else 3
def required_trading_bars(
config: NestedValidationConfig,
outer_folds: int,
) -> int:
if outer_folds <= 0:
raise ValueError("outer_folds must be positive")
return (
config.outer_train_bars
+ config.purge_bars
+ config.outer_test_bars
+ (outer_folds - 1) * config.outer_step_bars
)
def nested_fold_count(
trading_bars: int,
config: NestedValidationConfig,
) -> int:
if trading_bars < 0:
raise ValueError("trading_bars must not be negative")
one_fold_bars = required_trading_bars(config, 1)
if trading_bars < one_fold_bars:
return 0
return 1 + (trading_bars - one_fold_bars) // config.outer_step_bars
@dataclass(frozen=True)
class CandidateGateResult(JsonDataclassMixin):
qualified: bool
reasons: tuple[str, ...]
def evaluate_candidate_gate(
*,
confidence: str | None,
valid_folds: int | None,
positive_fold_ratio: float | None,
sharpe: float | None,
max_drawdown: float | None,
n_trades: int | None,
) -> CandidateGateResult:
"""Check the documented promotion thresholds against real candidate evidence."""
reasons: list[str] = []
if confidence == "low":
reasons.append("exploratory results can only be saved as pending candidates")
if valid_folds is None or valid_folds < GATE_MIN_VALID_FOLDS:
reasons.append(
"requires at least "
f"{GATE_MIN_VALID_FOLDS} valid outer folds (got "
f"{'none' if valid_folds is None else valid_folds})"
)
if positive_fold_ratio is None or positive_fold_ratio < GATE_MIN_POSITIVE_FOLD_RATIO:
reasons.append(
"requires a positive-return fold ratio of at least "
f"{GATE_MIN_POSITIVE_FOLD_RATIO:.2f}"
)
if sharpe is None or sharpe < GATE_MIN_OOS_SHARPE:
reasons.append(f"requires an OOS Sharpe of at least {GATE_MIN_OOS_SHARPE}")
if max_drawdown is None or max_drawdown < GATE_MAX_DRAWDOWN:
reasons.append(
f"requires a max drawdown no worse than {abs(GATE_MAX_DRAWDOWN):.0%}"
)
if n_trades is None or n_trades < GATE_MIN_TRADES:
reasons.append(f"requires at least {GATE_MIN_TRADES} OOS trades")
return CandidateGateResult(qualified=not reasons, reasons=tuple(reasons))
@dataclass(frozen=True)
class MiningRequest(JsonDataclassMixin):
factor_names: tuple[str, ...]
existing_strategy_ids: tuple[str, ...] = ()
correlation_threshold: float = 0.8
date_column: str = "date"
target_column: str = "_next_return"
budget: MiningBudget = field(default_factory=MiningBudget.balanced)
validation: NestedValidationConfig = field(
default_factory=NestedValidationConfig.balanced
)
profile: Literal["exploratory", "balanced", "strict"] = "balanced"
def __post_init__(self) -> None:
if not self.factor_names:
raise ValueError("factor_names must not be empty")
if len(set(self.factor_names)) != len(self.factor_names):
raise ValueError("factor_names must not contain duplicates")
if len(self.factor_names) > self.budget.max_factors:
raise ValueError(
f"factor count {len(self.factor_names)} exceeds budget "
f"{self.budget.max_factors}"
)
if len(set(self.existing_strategy_ids)) != len(self.existing_strategy_ids):
raise ValueError("existing_strategy_ids must not contain duplicates")
if len(self.existing_strategy_ids) > self.budget.max_existing_strategies:
raise ValueError(
f"existing strategy count {len(self.existing_strategy_ids)} exceeds "
f"budget {self.budget.max_existing_strategies}"
)
if not 0.0 < self.correlation_threshold <= 1.0:
raise ValueError("correlation_threshold must be in (0, 1]")
if not self.date_column or not self.target_column:
raise ValueError("date_column and target_column must not be empty")
@classmethod
def for_profile(
cls,
profile: Literal["exploratory", "balanced", "strict"],
factor_names: Sequence[str],
existing_strategy_ids: Sequence[str] = (),
**kwargs: Any,
) -> MiningRequest:
if profile not in {"exploratory", "balanced", "strict"}:
raise ValueError(f"unknown mining profile: {profile}")
return cls(
factor_names=tuple(factor_names),
existing_strategy_ids=tuple(existing_strategy_ids),
budget=getattr(MiningBudget, profile)(),
validation=getattr(NestedValidationConfig, profile)(),
profile=profile,
**kwargs,
)
@dataclass(frozen=True)
class FactorMetric(JsonDataclassMixin):
factor_id: str
composite_score: float
ir: float
coverage: float
turnover: float
rank_ic: float = 0.0
@dataclass(frozen=True)
class CorrelationResult(JsonDataclassMixin):
factor_names: tuple[str, ...]
matrix: tuple[tuple[float, ...], ...]
pair_counts: tuple[tuple[int, ...], ...]
elapsed_ms: float
n_dates: int
n_rows: int
timing_ms: dict[str, float] = field(default_factory=dict)
@dataclass(frozen=True)
class FactorExclusion(JsonDataclassMixin):
factor_id: str
reason: str
representative: str
rho: float
@dataclass(frozen=True)
class PruneResult(JsonDataclassMixin):
selected: tuple[str, ...]
excluded: tuple[FactorExclusion, ...]
@dataclass(frozen=True)
class MiningCandidate(JsonDataclassMixin):
candidate_id: str
kind: Literal["factor_rank", "existing_strategy"]
factor_names: tuple[str, ...] = ()
weights: tuple[float, ...] = ()
directions: tuple[int, ...] = ()
strategy_id: str | None = None
proxy_rank_ic: float = 0.0
proxy_ir: float = 0.0
observations: int = 0
dates: int = 0
def definition(self) -> dict[str, Any]:
if self.kind == "existing_strategy":
return {"kind": self.kind, "strategy_id": self.strategy_id}
return {
"kind": self.kind,
"factor_names": list(self.factor_names),
"scoring": dict(zip(self.factor_names, self.weights, strict=True)),
"directions": {
factor_id: "high" if direction > 0 else "low"
for factor_id, direction in zip(
self.factor_names,
self.directions,
strict=True,
)
},
}
@dataclass(frozen=True)
class BeamSearchResult(JsonDataclassMixin):
candidates: tuple[MiningCandidate, ...]
trials_used: int
cancelled: bool
budget_exhausted: bool
elapsed_ms: float
@dataclass(frozen=True)
class ValidationFold(JsonDataclassMixin):
level: Literal["outer", "inner"]
outer_index: int
inner_index: int | None
train_labels: tuple[str, ...]
purge_labels: tuple[str, ...]
test_labels: tuple[str, ...]
embargo_labels: tuple[str, ...]
@property
def train_start(self) -> str:
return self.train_labels[0]
@property
def train_end(self) -> str:
return self.train_labels[-1]
@property
def test_start(self) -> str:
return self.test_labels[0]
@property
def test_end(self) -> str:
return self.test_labels[-1]
@dataclass(frozen=True)
class NestedFold(JsonDataclassMixin):
outer: ValidationFold
inner: tuple[ValidationFold, ...]
@dataclass(frozen=True)
class CandidateEvaluation(JsonDataclassMixin):
score: float | None
metrics: dict[str, Any] = field(default_factory=dict)
error: str | None = None
@dataclass(frozen=True)
class FoldMiningResult(JsonDataclassMixin):
outer_index: int
selected_factors: tuple[str, ...]
candidates: tuple[MiningCandidate, ...]
selected_candidate_id: str | None
inner_score: float | None
outer_evaluation: CandidateEvaluation | None
error: str | None = None
benchmark_evaluations: tuple[tuple[str, CandidateEvaluation], ...] = ()
cross_evaluations: tuple[tuple[str, CandidateEvaluation], ...] = ()
@dataclass(frozen=True)
class MiningResult(JsonDataclassMixin):
request: MiningRequest
folds: tuple[FoldMiningResult, ...]
proxy_trials_used: int
trials_used: int
cancelled: bool
elapsed_ms: float
class CandidateEvaluator(Protocol):
def evaluate_candidate(
self,
train: pl.DataFrame,
test: pl.DataFrame,
definition: Mapping[str, Any],
) -> CandidateEvaluation | Mapping[str, Any] | float: ...
class FactorMetricProvider(Protocol):
def __call__(
self,
train: pl.DataFrame,
factor_names: Sequence[str],
) -> Sequence[FactorMetric]: ...
CancelCheck = Callable[[], bool] | Any
def compute_rank_correlation(
panel: pl.DataFrame,
factor_names: Sequence[str],
start: Any | None = None,
end: Any | None = None,
*,
date_column: str = "date",
) -> CorrelationResult:
"""Average pairwise daily cross-sectional rank correlations.
One date partition is ranked and materialized at a time. Only the factor-by-factor
correlation sums and valid-day counts remain resident across dates.
"""
started = time.perf_counter()
names = _validate_factor_names(panel, factor_names)
if date_column not in panel.columns:
raise ValueError(f"panel is missing date column {date_column!r}")
filter_started = time.perf_counter()
date_expr = pl.col(date_column).cast(pl.Utf8).str.slice(0, 10)
scoped = panel.select([
pl.col(date_column),
*(
pl.when(pl.col(name).is_finite())
.then(pl.col(name))
.otherwise(None)
.alias(name)
for name in names
),
])
if start is not None:
scoped = scoped.filter(date_expr >= str(start)[:10])
if end is not None:
scoped = scoped.filter(date_expr <= str(end)[:10])
if scoped.is_empty():
raise ValueError("rank correlation date range contains no panel rows")
filter_ms = (time.perf_counter() - filter_started) * 1000.0
width = len(names)
pair_columns: dict[tuple[int, int], str] = {}
pair_expressions: list[pl.Expr] = []
for left in range(width):
for right in range(left + 1, width):
column = f"_rho_{left}_{right}"
pair_columns[(left, right)] = column
pair_expressions.append(
pl.corr(names[left], names[right], method="spearman").alias(column)
)
observation_columns = [f"_n_{index}" for index in range(width)]
rank_started = time.perf_counter()
daily = scoped.group_by(date_column).agg([
*pair_expressions,
*(
pl.col(name).count().alias(column)
for name, column in zip(names, observation_columns, strict=True)
),
])
rank_ms = (time.perf_counter() - rank_started) * 1000.0
finish_started = time.perf_counter()
correlation = np.full((width, width), np.nan, dtype=np.float64)
counts = np.zeros((width, width), dtype=np.int32)
for (left, right), column in pair_columns.items():
values = daily.get_column(column).to_numpy()
finite = np.isfinite(values)
count = int(np.count_nonzero(finite))
if count:
value = float(np.mean(values[finite]))
correlation[left, right] = correlation[right, left] = value
counts[left, right] = counts[right, left] = count
for index, column in enumerate(observation_columns):
count = int(np.count_nonzero(daily.get_column(column).to_numpy() >= 2))
if count:
correlation[index, index] = 1.0
counts[index, index] = count
finish_ms = (time.perf_counter() - finish_started) * 1000.0
n_dates = daily.height
elapsed_ms = (time.perf_counter() - started) * 1000.0
timing = {
"filter": round(filter_ms, 3),
"rank_accumulate": round(rank_ms, 3),
"finalize": round(finish_ms, 3),
"total": round(elapsed_ms, 3),
}
logger.info(
"mining rank correlation factors=%d rows=%d dates=%d elapsed_ms=%.1f",
width,
scoped.height,
n_dates,
elapsed_ms,
)
return CorrelationResult(
factor_names=names,
matrix=tuple(tuple(float(value) for value in row) for row in correlation),
pair_counts=tuple(
tuple(int(value) for value in row)
for row in counts
),
elapsed_ms=round(elapsed_ms, 3),
n_dates=n_dates,
n_rows=scoped.height,
timing_ms=timing,
)
def prune_correlated_factors(
metrics: Sequence[FactorMetric],
correlation: CorrelationResult,
threshold: float,
) -> PruneResult:
"""Keep the strongest deterministic representative of correlated factors."""
if not 0.0 < threshold <= 1.0:
raise ValueError("correlation threshold must be in (0, 1]")
by_name = {name: index for index, name in enumerate(correlation.factor_names)}
if len({metric.factor_id for metric in metrics}) != len(metrics):
raise ValueError("factor metrics must not contain duplicate factor_id values")
missing = sorted(metric.factor_id for metric in metrics if metric.factor_id not in by_name)
if missing:
raise ValueError(f"factor metrics missing from correlation result: {missing}")
ordered = sorted(
metrics,
key=lambda metric: (
-_finite_sort_value(metric.composite_score),
-_finite_sort_value(metric.ir),
-_finite_sort_value(metric.coverage),
_finite_sort_value(metric.turnover, worst=float("inf")),
metric.factor_id,
),
)
selected: list[str] = []
excluded: list[FactorExclusion] = []
for metric in ordered:
factor_index = by_name[metric.factor_id]
correlated = [
(representative, correlation.matrix[factor_index][by_name[representative]])
for representative in selected
if (
correlation.pair_counts[factor_index][by_name[representative]] > 0
and math.isfinite(
correlation.matrix[factor_index][by_name[representative]]
)
and abs(
correlation.matrix[factor_index][by_name[representative]]
) >= threshold
)
]
if not correlated:
selected.append(metric.factor_id)
continue
representative, rho = max(
correlated,
key=lambda item: (abs(item[1]), -selected.index(item[0])),
)
excluded.append(FactorExclusion(
factor_id=metric.factor_id,
reason="correlation_threshold",
representative=representative,
rho=round(float(rho), 8),
))
return PruneResult(selected=tuple(selected), excluded=tuple(excluded))
def beam_search_factor_combinations(
panel: pl.DataFrame,
factor_names: Sequence[str],
*,
target_column: str = "_next_return",
date_column: str = "date",
train_start: Any | None = None,
train_end: Any | None = None,
max_combination_size: int = MAX_COMBINATION_SIZE,
beam_width: int = 16,
max_trials: int = 256,
cancel_check: CancelCheck | None = None,
) -> BeamSearchResult:
"""Search equal and one-factor-double rank combinations on training data only."""
started = time.perf_counter()
names = _validate_factor_names(panel, factor_names)
if target_column not in panel.columns:
raise ValueError(f"panel is missing target column {target_column!r}")
if date_column not in panel.columns:
raise ValueError(f"panel is missing date column {date_column!r}")
if not 1 <= max_combination_size <= MAX_COMBINATION_SIZE:
raise ValueError(
f"max_combination_size must be between 1 and {MAX_COMBINATION_SIZE}"
)
if not 1 <= beam_width <= MAX_BEAM_WIDTH:
raise ValueError(f"beam_width must be between 1 and {MAX_BEAM_WIDTH}")
if max_trials <= 0:
raise ValueError("max_trials must be positive")
date_expr = pl.col(date_column).cast(pl.Utf8).str.slice(0, 10)
scoped = panel.select([date_column, *names, target_column])
if train_start is not None:
scoped = scoped.filter(date_expr >= str(train_start)[:10])
if train_end is not None:
scoped = scoped.filter(date_expr <= str(train_end)[:10])
if scoped.is_empty():
raise ValueError("beam search training range contains no panel rows")
ordered_names = tuple(sorted(names))
name_to_column = {name: index for index, name in enumerate(ordered_names)}
if not scoped.get_column(date_column).is_sorted():
scoped = scoped.sort(date_column)
blocks = tuple(
daily.select([*ordered_names, target_column]).to_numpy()
for daily in scoped.partition_by(date_column, maintain_order=True)
)
trials_used = 0
cancelled = False
def evaluate(
factors: tuple[str, ...],
weights: tuple[float, ...],
directions: tuple[int, ...],
) -> tuple[float, float, int, int] | None:
nonlocal trials_used, cancelled
if trials_used >= max_trials:
return None
if _cancelled(cancel_check):
cancelled = True
return None
trials_used += 1
columns = tuple(name_to_column[name] for name in factors)
return _proxy_rank_ic(blocks, columns, weights, directions)
directions_by_factor: dict[str, int] = {}
single_candidates: list[MiningCandidate] = []
for factor_name in ordered_names:
proxy = evaluate((factor_name,), (1.0,), (1,))
if proxy is None:
break
rank_ic, proxy_ir, observations, n_dates = proxy
if n_dates <= 0 or observations < 3:
continue
direction = 1 if rank_ic >= 0.0 else -1
directions_by_factor[factor_name] = direction
single_candidates.append(_factor_candidate(
(factor_name,),
(1.0,),
(direction,),
abs(rank_ic),
abs(proxy_ir),
observations,
n_dates,
))
current_beam = _rank_candidates(single_candidates)[:beam_width]
retained = list(current_beam)
searchable_names = tuple(sorted(directions_by_factor))
max_size = min(max_combination_size, len(searchable_names))
for size in range(2, max_size + 1):
if cancelled or trials_used >= max_trials or not current_beam:
break
factor_sets: set[tuple[str, ...]] = set()
for candidate in current_beam:
for factor_name in searchable_names:
combined = tuple(sorted({*candidate.factor_names, factor_name}))
if len(combined) == size:
factor_sets.add(combined)
expanded: list[MiningCandidate] = []
stop = False
for factors in sorted(factor_sets):
directions = tuple(directions_by_factor[name] for name in factors)
patterns = [(1.0,) * size]
patterns.extend(
tuple(2.0 if index == doubled else 1.0 for index in range(size))
for doubled in range(size)
)
for weights in patterns:
proxy = evaluate(factors, weights, directions)
if proxy is None:
stop = True
break
rank_ic, proxy_ir, observations, n_dates = proxy
if n_dates <= 0 or observations < 3:
continue
expanded.append(_factor_candidate(
factors,
weights,
directions,
rank_ic,
proxy_ir,
observations,
n_dates,
))
if stop:
break
current_beam = _rank_candidates(expanded)[:beam_width]
retained.extend(current_beam)
if stop:
break
elapsed_ms = (time.perf_counter() - started) * 1000.0
return BeamSearchResult(
candidates=tuple(_rank_candidates(retained)),
trials_used=trials_used,
cancelled=cancelled,
budget_exhausted=trials_used >= max_trials,
elapsed_ms=round(elapsed_ms, 3),
)
def generate_nested_folds(
trading_labels: Sequence[Any],
config: NestedValidationConfig,
) -> tuple[NestedFold, ...]:
"""Generate rolling nested folds from ordered trading labels, not calendar days."""
labels = tuple(dict.fromkeys(str(label) for label in trading_labels))
if not labels:
raise ValueError("trading labels are empty")
if tuple(sorted(labels)) != labels:
raise ValueError("trading labels must be sorted in ascending order")
outer_required = (
config.outer_train_bars + config.purge_bars + config.outer_test_bars
)
if len(labels) < outer_required:
raise ValueError(
"insufficient trading bars for outer validation: "
f"need at least {outer_required}, got {len(labels)}"
)
inner_required = (
config.inner_train_bars + config.purge_bars + config.inner_test_bars
)
if config.outer_train_bars < inner_required:
raise ValueError(
"outer training window is too short for one inner fold: "
f"need at least {inner_required}, got {config.outer_train_bars}"
)
nested: list[NestedFold] = []
outer_start = 0
outer_index = 0
while outer_start + outer_required <= len(labels):
outer = _make_validation_fold(
labels,
level="outer",
outer_index=outer_index,
inner_index=None,
train_start=outer_start,
train_bars=config.outer_train_bars,
test_bars=config.outer_test_bars,
purge_bars=config.purge_bars,
embargo_bars=config.embargo_bars,
)
inner_folds: list[ValidationFold] = []
inner_start = outer_start
inner_index = 0
outer_train_stop = outer_start + config.outer_train_bars
while inner_start + inner_required <= outer_train_stop:
inner_folds.append(_make_validation_fold(
labels,
level="inner",
outer_index=outer_index,
inner_index=inner_index,
train_start=inner_start,
train_bars=config.inner_train_bars,
test_bars=config.inner_test_bars,
purge_bars=config.purge_bars,
embargo_bars=config.embargo_bars,
hard_stop=outer_train_stop,
))
inner_index += 1
inner_start += config.inner_step_bars
if not inner_folds:
raise ValueError(f"outer fold {outer_index} contains no valid inner fold")
nested.append(NestedFold(outer=outer, inner=tuple(inner_folds)))
outer_index += 1
outer_start += config.outer_step_bars
return tuple(nested)
class MiningService:
"""Run leakage-bounded mining and delegate real backtests to a callback."""
def run(
self,
panel: pl.DataFrame,
request: MiningRequest,
*,
factor_metrics: Sequence[FactorMetric] | None = None,
metric_provider: FactorMetricProvider | None = None,
evaluator: CandidateEvaluator | None = None,
cancel_check: CancelCheck | None = None,
) -> MiningResult:
started = time.perf_counter()
_validate_factor_names(panel, request.factor_names)
required = {request.date_column, request.target_column}
missing = sorted(required - set(panel.columns))
if missing:
raise ValueError(f"mining panel is missing required columns: {missing}")
if evaluator is not None:
if factor_metrics is not None:
raise ValueError("factor_metrics must not be supplied with evaluator")
if metric_provider is None:
raise ValueError("metric_provider is required when evaluator is supplied")
elif metric_provider is None and factor_metrics is None:
raise ValueError("factor_metrics or metric_provider is required")
labels = panel.select(
pl.col(request.date_column).cast(pl.Utf8).str.slice(0, 10).unique().sort()
).to_series().to_list()
nested_folds = generate_nested_folds(labels, request.validation)
if request.profile != "exploratory" and len(nested_folds) < 3:
raise ValueError(
f"{request.profile} mining requires at least 3 outer folds; "
f"got {len(nested_folds)}"
)
fold_results: list[FoldMiningResult] = []
proxy_trials_used = 0
trials_used = 0
cancelled = False
labels_evaluator = (
getattr(evaluator, "evaluate_candidate_labels", None)
if evaluator is not None
else None
)
def run_benchmarks(nested_fold) -> tuple[tuple[str, CandidateEvaluation], ...]:
"""Score every user-selected strategy on one outer test window.
Benchmarks document how existing strategies would have done on the
same walk-forward windows, so they are evaluated even when the
factor track fails on this fold and never join the winner race.
"""
nonlocal trials_used, cancelled
if evaluator is None:
return ()
benchmarks: list[tuple[str, CandidateEvaluation]] = []
for strategy_id in request.existing_strategy_ids:
if _cancelled(cancel_check):
cancelled = True
break
if trials_used >= request.budget.max_trials:
benchmarks.append((
_benchmark_signature(strategy_id),
CandidateEvaluation(
score=None,
error="real trial budget exhausted before benchmark evaluation",
),
))
continue
benchmark = benchmark_candidate(strategy_id)
if callable(labels_evaluator):
benchmark_evaluation = _evaluate_labels(
labels_evaluator,
nested_fold.outer.train_labels,
nested_fold.outer.test_labels,
benchmark,
)
else:
benchmark_evaluation = _evaluate(
evaluator,
_train_panel_for_fold(panel, request.date_column, nested_fold.outer),
_panel_for_labels(panel, request.date_column, nested_fold.outer.test_labels),
benchmark,
)
trials_used += 1
benchmarks.append((benchmark.candidate_id, benchmark_evaluation))
return tuple(benchmarks)
if evaluator is None:
selection_phases_remaining = len(nested_folds)
evaluation_phases_remaining = 0
else:
selection_phases_remaining = sum(
len(nested.inner) + 1 for nested in nested_folds
)
evaluation_phases_remaining = selection_phases_remaining
for nested in nested_folds:
if _cancelled(cancel_check):
cancelled = True
break
if evaluator is None:
outer_train = _train_panel_for_fold(
panel,
request.date_column,
nested.outer,
)
metrics = tuple(
metric_provider(outer_train, request.factor_names)
if metric_provider is not None
else factor_metrics or ()
)
correlation = compute_rank_correlation(
outer_train,
request.factor_names,
date_column=request.date_column,
)
pruned = prune_correlated_factors(
metrics,
correlation,
request.correlation_threshold,
)
proxy_remaining = request.budget.max_proxy_trials - proxy_trials_used
if proxy_remaining <= 0:
fold_results.append(FoldMiningResult(
outer_index=nested.outer.outer_index,
selected_factors=pruned.selected,
candidates=(),
selected_candidate_id=None,
inner_score=None,
outer_evaluation=None,
error="proxy trial budget exhausted",
))
break
proxy_allowance = max(
1,
proxy_remaining // selection_phases_remaining,
)
beam = beam_search_factor_combinations(
outer_train,
_searchable_factors(pruned.selected, request.budget),
target_column=request.target_column,
date_column=request.date_column,
max_combination_size=request.budget.max_combination_size,
beam_width=request.budget.beam_width,
max_trials=proxy_allowance,
cancel_check=cancel_check,
)
proxy_trials_used += beam.trials_used
selection_phases_remaining -= 1
candidates = beam.candidates
fold_results.append(FoldMiningResult(
outer_index=nested.outer.outer_index,
selected_factors=pruned.selected,
candidates=tuple(candidates),
selected_candidate_id=None,
inner_score=None,
outer_evaluation=None,
))
continue
winners: list[tuple[float, MiningCandidate]] = []
all_candidates: dict[str, MiningCandidate] = {}
selected_factor_union: set[str] = set()
for inner in nested.inner:
if _cancelled(cancel_check):
cancelled = True
break
inner_train = _train_panel_for_fold(
panel,
request.date_column,
inner,
)
inner_test = _panel_for_labels(
panel,
request.date_column,
inner.test_labels,
)
metrics = tuple(
metric_provider(inner_train, request.factor_names)
if metric_provider is not None
else factor_metrics or ()
)
correlation = compute_rank_correlation(
inner_train,
request.factor_names,
date_column=request.date_column,
)
pruned = prune_correlated_factors(
metrics,
correlation,
request.correlation_threshold,
)
selected_factor_union.update(pruned.selected)
proxy_remaining = request.budget.max_proxy_trials - proxy_trials_used
if proxy_remaining <= 0:
break
proxy_allowance = max(
1,
proxy_remaining // selection_phases_remaining,
)
beam = beam_search_factor_combinations(
inner_train,
_searchable_factors(pruned.selected, request.budget),
target_column=request.target_column,
date_column=request.date_column,
max_combination_size=request.budget.max_combination_size,
beam_width=request.budget.beam_width,
max_trials=proxy_allowance,
cancel_check=cancel_check,
)
proxy_trials_used += beam.trials_used
selection_phases_remaining -= 1
candidates = beam.candidates
for candidate in candidates:
all_candidates[candidate.candidate_id] = candidate
real_remaining = request.budget.max_trials - trials_used
if real_remaining <= 0:
break
real_allowance = min(
MAX_FINALISTS,
max(1, real_remaining // evaluation_phases_remaining),
)
evaluated: list[tuple[float, MiningCandidate]] = []
for candidate in candidates[:real_allowance]:
evaluation = (
_evaluate_labels(
labels_evaluator,
inner.train_labels,
inner.test_labels,
candidate,
)
if callable(labels_evaluator)
else _evaluate(
evaluator,
inner_train,
inner_test,
candidate,
)
)
trials_used += 1
if evaluation.error is None and evaluation.score is not None:
evaluated.append((evaluation.score, candidate))
evaluation_phases_remaining -= 1
if evaluated:
winners.append(min(
evaluated,
key=lambda item: (-item[0], item[1].candidate_id),
))
if cancelled:
break
if not winners:
fold_results.append(FoldMiningResult(
outer_index=nested.outer.outer_index,
selected_factors=tuple(sorted(selected_factor_union)),
candidates=tuple(_rank_candidates(list(all_candidates.values()))),
selected_candidate_id=None,
inner_score=None,
outer_evaluation=None,
error="no candidate completed inner validation within budget",
benchmark_evaluations=run_benchmarks(nested),
))
break
by_candidate: dict[str, list[float]] = {}
for score, candidate in winners:
by_candidate.setdefault(candidate.candidate_id, []).append(score)
selected_id, selected_scores = min(
by_candidate.items(),
key=lambda item: (-len(item[1]), -float(np.mean(item[1])), item[0]),
)
voted_candidate = all_candidates[selected_id]
inner_score = float(np.mean(selected_scores))
if not callable(labels_evaluator):
del inner_train, inner_test
outer_train = _train_panel_for_fold(
panel,
request.date_column,
nested.outer,
)
outer_metrics = tuple(
metric_provider(outer_train, request.factor_names)
if metric_provider is not None
else factor_metrics or ()
)
outer_correlation = compute_rank_correlation(
outer_train,
request.factor_names,
date_column=request.date_column,
)
outer_pruned = prune_correlated_factors(
outer_metrics,
outer_correlation,
request.correlation_threshold,
)
proxy_remaining = request.budget.max_proxy_trials - proxy_trials_used
if proxy_remaining <= 0:
fold_results.append(FoldMiningResult(
outer_index=nested.outer.outer_index,
selected_factors=outer_pruned.selected,
candidates=tuple(_rank_candidates(list(all_candidates.values()))),
selected_candidate_id=None,
inner_score=round(inner_score, 8),
outer_evaluation=None,
error="proxy trial budget exhausted before outer retraining",
benchmark_evaluations=run_benchmarks(nested),
))
break
proxy_allowance = max(
1,
proxy_remaining // selection_phases_remaining,
)
outer_beam = beam_search_factor_combinations(
outer_train,
_searchable_factors(outer_pruned.selected, request.budget),
target_column=request.target_column,
date_column=request.date_column,
max_combination_size=request.budget.max_combination_size,
beam_width=request.budget.beam_width,
max_trials=proxy_allowance,
cancel_check=cancel_check,
)
proxy_trials_used += outer_beam.trials_used
selection_phases_remaining -= 1
outer_candidates = outer_beam.candidates
by_refit_key = {
_candidate_refit_key(candidate): candidate
for candidate in outer_candidates
}
selected = by_refit_key.get(_candidate_refit_key(voted_candidate))
if selected is None:
fold_results.append(FoldMiningResult(
outer_index=nested.outer.outer_index,
selected_factors=outer_pruned.selected,
candidates=outer_candidates,
selected_candidate_id=None,
inner_score=round(inner_score, 8),
outer_evaluation=None,
error=(
"outer retraining did not reproduce selected candidate structure"
if outer_candidates
else "outer training produced no candidate"
),
benchmark_evaluations=run_benchmarks(nested),
))
break
if trials_used >= request.budget.max_trials:
fold_results.append(FoldMiningResult(
outer_index=nested.outer.outer_index,
selected_factors=outer_pruned.selected,
candidates=outer_candidates,
selected_candidate_id=selected.candidate_id,
inner_score=round(inner_score, 8),
outer_evaluation=None,
error="real trial budget exhausted before outer evaluation",
benchmark_evaluations=run_benchmarks(nested),
))
break
outer_test = _panel_for_labels(
panel,
request.date_column,
nested.outer.test_labels,
)
if callable(labels_evaluator):
del outer_train, outer_test
outer_evaluation = _evaluate_labels(
labels_evaluator,
nested.outer.train_labels,
nested.outer.test_labels,
selected,
)
else:
outer_evaluation = _evaluate(
evaluator,
outer_train,
outer_test,
selected,
)
trials_used += 1
evaluation_phases_remaining -= 1
fold_results.append(FoldMiningResult(
outer_index=nested.outer.outer_index,
selected_factors=outer_pruned.selected,
candidates=outer_candidates,
selected_candidate_id=selected.candidate_id,
inner_score=round(inner_score, 8),
outer_evaluation=outer_evaluation,
benchmark_evaluations=run_benchmarks(nested),
))
if cancelled:
break
if trials_used >= request.budget.max_trials:
break
if not cancelled and evaluator is not None:
winner_union: dict[str, MiningCandidate] = {}
for fold in fold_results:
if fold.selected_candidate_id is None:
continue
if fold.selected_candidate_id in winner_union:
continue
winner = next(
(
candidate
for candidate in fold.candidates
if candidate.candidate_id == fold.selected_candidate_id
),
None,
)
if winner is not None:
winner_union[winner.candidate_id] = winner
if len(winner_union) > 1:
extended_folds: list[FoldMiningResult] = []
for nested, fold in zip(nested_folds, fold_results, strict=False):
cross: list[tuple[str, CandidateEvaluation]] = []
for candidate_id, candidate in winner_union.items():
if candidate_id == fold.selected_candidate_id:
continue
if trials_used >= request.budget.max_trials:
cross.append((
candidate_id,
CandidateEvaluation(
score=None,
error=(
"real trial budget exhausted before "
"cross-fold evaluation"
),
),
))
continue
if callable(labels_evaluator):
evaluation = _evaluate_labels(
labels_evaluator,
nested.outer.train_labels,
nested.outer.test_labels,
candidate,
)
else:
cross_train = _train_panel_for_fold(
panel,
request.date_column,
nested.outer,
)
cross_test = _panel_for_labels(
panel,
request.date_column,
nested.outer.test_labels,
)
evaluation = _evaluate(
evaluator,
cross_train,
cross_test,
candidate,
)
trials_used += 1
cross.append((candidate_id, evaluation))
extended_folds.append(replace(
fold,
cross_evaluations=tuple(cross),
))
fold_results = extended_folds
return MiningResult(
request=request,
folds=tuple(fold_results),
proxy_trials_used=proxy_trials_used,
trials_used=trials_used,
cancelled=cancelled,
elapsed_ms=round((time.perf_counter() - started) * 1000.0, 3),
)
def _validate_factor_names(
panel: pl.DataFrame,
factor_names: Sequence[str],
) -> tuple[str, ...]:
names = tuple(str(name) for name in factor_names)
if not names:
raise ValueError("factor_names must not be empty")
if len(names) > MAX_MINING_FACTORS:
raise ValueError(f"factor count exceeds hard limit {MAX_MINING_FACTORS}")
if len(set(names)) != len(names):
raise ValueError("factor_names must not contain duplicates")
missing = sorted(set(names) - set(panel.columns))
if missing:
raise ValueError(f"panel is missing factor columns: {missing}")
return names
def _finite_sort_value(value: float, *, worst: float = float("-inf")) -> float:
try:
number = float(value)
except (TypeError, ValueError):
return worst
return number if math.isfinite(number) else worst
def _average_rank(values: np.ndarray) -> np.ndarray:
order = np.argsort(values, kind="stable")
ordered = values[order]
boundaries = np.concatenate((
np.array([0]),
np.flatnonzero(ordered[1:] != ordered[:-1]) + 1,
np.array([len(ordered)]),
))
counts = np.diff(boundaries)
average = (boundaries[:-1] + 1 + boundaries[1:]) / 2.0
ranked = np.empty(len(values), dtype=np.float64)
ranked[order] = np.repeat(average, counts)
return ranked
def _proxy_rank_ic(
blocks: Sequence[np.ndarray],
columns: tuple[int, ...],
weights: tuple[float, ...],
directions: tuple[int, ...],
) -> tuple[float, float, int, int]:
daily_ics: list[float] = []
observations = 0
weight_values = np.asarray(weights, dtype=np.float64)
direction_values = np.asarray(directions, dtype=np.float64)
for block in blocks:
factors = block[:, columns].astype(np.float64, copy=False)
target = block[:, -1].astype(np.float64, copy=False)
valid = np.isfinite(target) & np.isfinite(factors).all(axis=1)
count = int(np.count_nonzero(valid))
if count < 3:
continue
factor_values = factors[valid]
factor_ranks = np.column_stack([
_average_rank(factor_values[:, index])
for index in range(factor_values.shape[1])
])
composite = (factor_ranks * direction_values) @ weight_values
composite_rank = _average_rank(composite)
target_rank = _average_rank(target[valid])
if np.std(composite_rank) <= 0.0 or np.std(target_rank) <= 0.0:
continue
rho = float(np.corrcoef(composite_rank, target_rank)[0, 1])
if math.isfinite(rho):
daily_ics.append(rho)
observations += count
if not daily_ics:
return 0.0, 0.0, observations, 0
values = np.asarray(daily_ics, dtype=np.float64)
mean = float(np.mean(values))
std = float(np.std(values))
ir = mean / std if std > 1e-12 else 0.0
return mean, ir, observations, len(daily_ics)
def compute_candidate_signature(definition: Mapping[str, Any]) -> str:
"""Return the canonical artifact signature for a mining candidate definition."""
kind = definition.get("kind")
if kind == "existing_strategy":
strategy_id = definition.get("strategy_id")
if not isinstance(strategy_id, str) or not strategy_id:
raise ValueError("existing strategy candidate requires strategy_id")
return f"strategy:{strategy_id}"
if kind != "factor_rank":
raise ValueError(f"unsupported mining candidate kind: {kind!r}")
factor_names = definition.get("factor_names")
scoring = definition.get("scoring")
directions = definition.get("directions")
if not isinstance(factor_names, list) or not factor_names:
raise ValueError("factor candidate requires factor_names")
if not isinstance(scoring, Mapping) or set(scoring) != set(factor_names):
raise ValueError("factor candidate scoring keys must match factor_names")
if not isinstance(directions, Mapping) or set(directions) != set(factor_names):
raise ValueError("factor candidate direction keys must match factor_names")
weights: list[str] = []
direction_values: list[str] = []
for factor_name in factor_names:
if not isinstance(factor_name, str) or not factor_name:
raise ValueError("factor names must be non-empty strings")
value = scoring[factor_name]
if isinstance(value, bool):
raise ValueError("factor weights must be numeric")
try:
weight = float(value)
except (TypeError, ValueError) as exc:
raise ValueError("factor weights must be numeric") from exc
if not math.isfinite(weight) or weight <= 0.0:
raise ValueError("factor weights must be finite and positive")
direction = directions[factor_name]
if direction not in {"high", "low"}:
raise ValueError("factor directions must be high or low")
weights.append(f"{weight:g}")
direction_values.append("1" if direction == "high" else "-1")
return (
f"factor:{','.join(factor_names)}|w:{','.join(weights)}"
f"|d:{','.join(direction_values)}"
)
def _factor_candidate(
factors: tuple[str, ...],
weights: tuple[float, ...],
directions: tuple[int, ...],
rank_ic: float,
proxy_ir: float,
observations: int,
dates: int,
) -> MiningCandidate:
candidate_id = compute_candidate_signature({
"kind": "factor_rank",
"factor_names": list(factors),
"scoring": dict(zip(factors, weights, strict=True)),
"directions": {
factor_id: "high" if direction > 0 else "low"
for factor_id, direction in zip(factors, directions, strict=True)
},
})
return MiningCandidate(
candidate_id=candidate_id,
kind="factor_rank",
factor_names=factors,
weights=weights,
directions=directions,
proxy_rank_ic=round(float(rank_ic), 8),
proxy_ir=round(float(proxy_ir), 8),
observations=observations,
dates=dates,
)
def benchmark_candidate(strategy_id: str) -> MiningCandidate:
"""Build the fixed benchmark candidate for one user-selected strategy."""
return MiningCandidate(
candidate_id=_benchmark_signature(strategy_id),
kind="existing_strategy",
strategy_id=strategy_id,
)
def _benchmark_signature(strategy_id: str) -> str:
return compute_candidate_signature({
"kind": "existing_strategy",
"strategy_id": strategy_id,
})
def _searchable_factors(
selected: Sequence[str],
budget: MiningBudget,
) -> tuple[str, ...]:
"""Cap beam-search inputs so singletons plus combinations fit the phase budget.
``selected`` arrives in training-fold metric order, so the cap keeps the
strongest factors and keeps the lexicographic singleton pass from consuming
the whole proxy allowance before any combination is scored.
"""
return tuple(selected[: max(1, budget.beam_width)])
def _candidate_refit_key(candidate: MiningCandidate) -> tuple[Any, ...]:
if candidate.kind == "existing_strategy":
return (candidate.kind, candidate.strategy_id)
return (candidate.kind, candidate.factor_names, candidate.weights)
def _rank_candidates(candidates: Sequence[MiningCandidate]) -> list[MiningCandidate]:
return sorted(
candidates,
key=lambda candidate: (
-candidate.proxy_rank_ic,
-candidate.proxy_ir,
candidate.candidate_id,
),
)
def _make_validation_fold(
labels: tuple[str, ...],
*,
level: Literal["outer", "inner"],
outer_index: int,
inner_index: int | None,
train_start: int,
train_bars: int,
test_bars: int,
purge_bars: int,
embargo_bars: int,
hard_stop: int | None = None,
) -> ValidationFold:
train_stop = train_start + train_bars
test_start = train_stop + purge_bars
test_stop = test_start + test_bars
embargo_stop = test_stop + embargo_bars
if hard_stop is not None:
embargo_stop = min(embargo_stop, hard_stop)
return ValidationFold(
level=level,
outer_index=outer_index,
inner_index=inner_index,
train_labels=labels[train_start:train_stop],
purge_labels=labels[train_stop:test_start],
test_labels=labels[test_start:test_stop],
embargo_labels=labels[test_stop:embargo_stop],
)
def _train_panel_for_fold(
panel: pl.DataFrame,
date_column: str,
fold: ValidationFold,
) -> pl.DataFrame:
train = _panel_for_labels(panel, date_column, fold.train_labels)
if "_target_date" not in train.columns:
return train
target_date = pl.col("_target_date").cast(pl.Utf8).str.slice(0, 10)
daily = train.select(date_column, "_target_date").unique(
subset=[date_column],
maintain_order=True,
)
allowed = daily.select(
(target_date.is_null() | (target_date <= fold.train_end)).alias("_allowed")
).get_column("_allowed").to_list()
first_excluded = next(
(index for index, value in enumerate(allowed) if not value),
len(allowed),
)
if not any(allowed[first_excluded:]):
return _panel_for_labels(
train,
date_column,
fold.train_labels[:first_excluded],
)
return train.filter(target_date.is_null() | (target_date <= fold.train_end))
def _panel_for_labels(
panel: pl.DataFrame,
date_column: str,
labels: Sequence[str],
) -> pl.DataFrame:
normalized = tuple(str(label)[:10] for label in labels)
if not normalized:
return panel.slice(0, 0)
values = panel.get_column(date_column)
if values.is_sorted():
bounds: tuple[Any, Any] | None = None
if values.dtype == pl.Date:
bounds = (
date.fromisoformat(normalized[0]),
date.fromisoformat(normalized[-1]),
)
elif values.dtype == pl.Utf8:
bounds = (normalized[0], normalized[-1])
if bounds is not None:
start = int(values.search_sorted(bounds[0], side="left"))
stop = int(values.search_sorted(bounds[1], side="right"))
sliced = panel.slice(start, stop - start)
actual = tuple(
str(value)[:10]
for value in sliced.get_column(date_column).unique(
maintain_order=True
)
)
if actual == normalized:
return sliced
date_expr = pl.col(date_column).cast(pl.Utf8).str.slice(0, 10)
return panel.filter(date_expr.is_in(normalized))
def _cancelled(cancel_check: CancelCheck | None) -> bool:
if cancel_check is None:
return False
if callable(cancel_check):
return bool(cancel_check())
is_set = getattr(cancel_check, "is_set", None)
return bool(is_set()) if callable(is_set) else False
def _evaluate_labels(
evaluator: Callable[
[Sequence[str], Sequence[str], Mapping[str, Any]],
CandidateEvaluation | Mapping[str, Any] | float,
],
train_labels: Sequence[str],
test_labels: Sequence[str],
candidate: MiningCandidate,
) -> CandidateEvaluation:
try:
raw = evaluator(train_labels, test_labels, candidate.definition())
except Exception as exc:
return CandidateEvaluation(score=None, error=str(exc))
return _normalize_evaluation(raw)
def _evaluate(
evaluator: CandidateEvaluator,
train: pl.DataFrame,
test: pl.DataFrame,
candidate: MiningCandidate,
) -> CandidateEvaluation:
try:
raw = evaluator.evaluate_candidate(train, test, candidate.definition())
except Exception as exc:
return CandidateEvaluation(score=None, error=str(exc))
return _normalize_evaluation(raw)
def _normalize_evaluation(
raw: CandidateEvaluation | Mapping[str, Any] | float,
) -> CandidateEvaluation:
if isinstance(raw, CandidateEvaluation):
return CandidateEvaluation(
score=_finite_score(raw.score),
metrics=raw.metrics,
error=raw.error,
)
if isinstance(raw, Mapping):
score = raw.get("score")
error = raw.get("error")
metrics = raw.get("metrics", {})
return CandidateEvaluation(
score=_finite_score(score),
metrics=dict(metrics) if isinstance(metrics, Mapping) else {},
error=str(error) if error is not None else None,
)
return CandidateEvaluation(score=_finite_score(raw))
def _finite_score(value: Any) -> float | None:
try:
score = float(value)
except (TypeError, ValueError):
return None
return score if math.isfinite(score) else None