_upsert_prediction 改为 _insert_or_find_by_fingerprint: - 同 input_hash → 返回已有行(绝不 UPDATE pred_/reasoning/agent_outputs) - 不同 input_hash → INSERT 新行 input_hash 升级为规范 JSON SHA-256,捕获:match_id, cutoff, prompt_version, prompt_hash, system_prompt_hash, provider, model, mode, run_type, temperature, context_hash, agent_ids。移除旧 (match, provider, model, mode, run_type) 唯一约束, 改为 partial unique index(WHERE input_hash IS NOT NULL,兼容旧 NULL 数据)。 三条路径(single/multi/baseline)统一传足指纹字段。 迁移 0024 + 测试 test_p0_prediction_fingerprint(10/10);全量 295 通过。
158 lines
5.2 KiB
Python
158 lines
5.2 KiB
Python
"""极简基线预测:主客场场均进球估计(不调用 LLM,不产生费用)。
|
|
|
|
用于与 LLM 预测做 eval 对比。这是最简单的统计基线,仅供研究参考,
|
|
文档与 reasoning 均明确标注「非投注建议」。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import case, func, select
|
|
|
|
from src.db.base import AsyncSession, AsyncSessionLocal
|
|
from src.db.models import Match
|
|
from src.llm.predict import PredictResult, _insert_or_find_by_fingerprint
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def _avg_goals(
|
|
db: AsyncSession,
|
|
*,
|
|
team_id: int,
|
|
side: str,
|
|
league_id: int,
|
|
before: datetime | None,
|
|
) -> float:
|
|
"""某队在该联赛已完赛场次的场均进球(side=home/away)。"""
|
|
if side == "home":
|
|
goals_col = Match.home_goals
|
|
team_col = Match.home_team_id
|
|
else:
|
|
goals_col = Match.away_goals
|
|
team_col = Match.away_team_id
|
|
|
|
stmt = (
|
|
select(func.avg(goals_col).label("avg_goals"), func.count().label("cnt"))
|
|
.where(
|
|
Match.match_status == "finished",
|
|
team_col == team_id,
|
|
Match.league_id == league_id,
|
|
goals_col.is_not(None),
|
|
)
|
|
)
|
|
if before is not None:
|
|
stmt = stmt.where(Match.match_date < before)
|
|
row = (await db.execute(stmt)).one()
|
|
return float(row.avg_goals) if row.avg_goals is not None and row.cnt > 0 else 0.0
|
|
|
|
|
|
async def predict_baseline(
|
|
match_id: int,
|
|
*,
|
|
backtest: bool = False,
|
|
cutoff_at: datetime | None = None,
|
|
) -> PredictResult:
|
|
"""极简基线预测:主场场均进球 vs 客场场均进球。
|
|
|
|
返回 PredictResult(D2 统一结果类型):
|
|
provider=model="baseline", 不调用 LLM,latency_ms≈0。
|
|
|
|
P3-2:baseline 落库下沉到服务层 —— 直接在服务层完成落库并回填真实
|
|
prediction_id,路由层不再需要特殊的 _persist_baseline,与 single/multi
|
|
路径统一(result.prediction_id 即可用)。对外 JSON 不变。
|
|
"""
|
|
from src.db.unit_of_work import get_uow
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
match = await db.get(Match, match_id)
|
|
if match is None:
|
|
raise ValueError(f"match {match_id} not found")
|
|
|
|
before = None
|
|
if backtest and match.match_date:
|
|
from datetime import timedelta
|
|
|
|
before = match.match_date - timedelta(days=1)
|
|
elif cutoff_at is not None:
|
|
before = cutoff_at
|
|
|
|
home_avg = await _avg_goals(
|
|
db, team_id=match.home_team_id, side="home",
|
|
league_id=match.league_id, before=before,
|
|
)
|
|
away_avg = await _avg_goals(
|
|
db, team_id=match.away_team_id, side="away",
|
|
league_id=match.league_id, before=before,
|
|
)
|
|
|
|
pred_home = max(0, min(10, round(home_avg)))
|
|
pred_away = max(0, min(10, round(away_avg)))
|
|
# 主场轻微加成(可选,这里保持极简不额外加权)
|
|
if pred_home > pred_away:
|
|
pred_1x2 = "1"
|
|
elif pred_home < pred_away:
|
|
pred_1x2 = "2"
|
|
else:
|
|
pred_1x2 = "X"
|
|
|
|
# P0-03: 基线指纹——基于主客场场均进球数据(context_hash) + 截止时间
|
|
context_hash = hashlib.sha256(
|
|
f"{home_avg:.4f}:{away_avg:.4f}:{before.isoformat() if before else 'none'}".encode("utf-8")
|
|
).hexdigest()
|
|
|
|
values = {
|
|
"match_id": match_id,
|
|
"provider": "baseline",
|
|
"model": "baseline",
|
|
"mode": "baseline",
|
|
"run_type": "live",
|
|
"prompt_version": "baseline_v1",
|
|
"prompt_hash": hashlib.sha256(b"baseline_v1").hexdigest(),
|
|
"system_prompt_hash": hashlib.sha256(b"baseline").hexdigest(),
|
|
"temperature": 0.0,
|
|
"context_hash": context_hash,
|
|
"agent_ids": [],
|
|
"prompt_tokens": 0,
|
|
"completion_tokens": 0,
|
|
"latency_ms": 0,
|
|
"pred_home_goals": float(pred_home),
|
|
"pred_away_goals": float(pred_away),
|
|
"pred_1x2": pred_1x2,
|
|
"subjective_confidence": 0.5,
|
|
"reasoning": (
|
|
f"基线估计(非投注建议): 主队主场场均进球 {home_avg:.2f} → 预测 {pred_home}; "
|
|
f"客队客场场均进球 {away_avg:.2f} → 预测 {pred_away}。"
|
|
),
|
|
"raw_response": {"home_avg": round(home_avg, 2), "away_avg": round(away_avg, 2)},
|
|
"status": "success",
|
|
}
|
|
|
|
# P0-03:服务层幂等插入,回填真实 prediction_id(与 single/multi 统一)。
|
|
async with get_uow() as session:
|
|
pred = await _insert_or_find_by_fingerprint(session, values=values)
|
|
prediction_id = pred.id
|
|
|
|
return PredictResult(
|
|
prediction_id=prediction_id,
|
|
provider="baseline",
|
|
model="baseline",
|
|
prompt_version="baseline_v1",
|
|
mode="baseline",
|
|
pred_home_goals=float(pred_home),
|
|
pred_away_goals=float(pred_away),
|
|
alt_pred_home_goals=None,
|
|
alt_pred_away_goals=None,
|
|
pred_1x2=pred_1x2,
|
|
subjective_confidence=0.5,
|
|
reasoning=values["reasoning"],
|
|
context="", # baseline 不构建 LLM 上下文
|
|
status="success",
|
|
latency_ms=0,
|
|
prompt_tokens=0,
|
|
completion_tokens=0,
|
|
raw=values["raw_response"],
|
|
)
|