Files
web-page-backend/lotto/app/weight_evolver.py
gahusb 0f8c71c552 fix(lotto-evolver): previous_base diff + 일요일 cron skip + idempotent evaluate
- weight_evolver.evaluate_weekly: save_base_history 직전에 current_base를
  previous_base로 캡처해 return dict에 포함 → formatter가 진짜 diff 표시 가능
- evaluate_weekly: same effective_from row 이미 존재 시 save skip + idempotent
  return (토 22:00 lotto cron과 agent-office 22:15 재호출 중복 row 방지)
- main._run_weight_evolver_daily: 일요일(weekday=6) 도 skip — 토요일 trial을
  INSERT OR REPLACE로 덮어쓰는 문제 방지
- telegram_lotto._format_evolution_report: eval_result.previous_base 우선
  사용 (없으면 current_base 폴백) → diff 자기 자신 비교 버그 수정
- test_lotto_evolution_format: previous_base 키 추가 + 새 diff 검증 테스트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:35:20 +09:00

315 lines
9.6 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.
# lotto/app/weight_evolver.py
"""5종 시뮬 점수 가중치 자율 학습 루프.
순수 함수 (clamp/perturb/Dirichlet/score/base-rule) + DB 진입점은 별도 섹션.
"""
from __future__ import annotations
import math
import random
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
MIN_WEIGHT = 0.05
N_METRICS = 5
DEFAULT_UNIFORM = [0.2] * N_METRICS # cold start
RANK_BY_CORRECT = {6: 1, 5: 3, 4: 4, 3: 5}
RANK_BONUS = {1: 1.0, 2: 0.8, 3: 0.6, 4: 0.3, 5: 0.1}
def clamp_and_normalize(W: List[float], min_w: float = MIN_WEIGHT) -> List[float]:
"""각 값 ≥ min_w + 합=1.0. 보장 안 되면 raise."""
if len(W) != N_METRICS:
raise ValueError(f"W must have {N_METRICS} elements")
# Iteratively clamp then normalize until all values satisfy min_w floor.
# (Normalizing after clamping can reduce some already-floored values below
# min_w when the denominator is large — iterate to convergence.)
vals = [float(w) for w in W]
for _ in range(100): # converges in a few iterations in practice
clamped = [max(min_w, v) for v in vals]
total = sum(clamped)
vals = [v / total for v in clamped]
if all(v >= min_w - 1e-12 for v in vals):
break
return vals
def perturb_weights(
base: List[float],
sigma: float = 0.05,
seed: Optional[int] = None,
) -> List[float]:
"""base에 정규분포 noise(σ) 추가 → clamp+normalize."""
if seed is not None:
np.random.seed(seed)
noise = np.random.normal(0, sigma, size=N_METRICS)
perturbed = [b + n for b, n in zip(base, noise)]
return clamp_and_normalize(perturbed)
def dirichlet_weights(
alpha: float = 2.0,
seed: Optional[int] = None,
) -> List[float]:
"""Dirichlet(α, α, α, α, α) 샘플 → clamp+normalize."""
if seed is not None:
np.random.seed(seed)
sample = np.random.dirichlet([alpha] * N_METRICS).tolist()
return clamp_and_normalize(sample)
def generate_weekly_candidates(
base: Optional[List[float]] = None,
seed: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""6개 후보 — 4 perturb + 2 dirichlet. day_of_week 0..5 매핑.
Returns:
[{"day_of_week": 0, "weight": [...], "source": "perturb"}, ...]
"""
if base is None:
base = DEFAULT_UNIFORM[:]
if seed is not None:
np.random.seed(seed)
trials = []
for i in range(4):
trials.append({
"day_of_week": i,
"weight": perturb_weights(base, sigma=0.05),
"source": "perturb",
})
for i in range(4, 6):
trials.append({
"day_of_week": i,
"weight": dirichlet_weights(alpha=2.0),
"source": "dirichlet",
})
return trials
def count_match(pick: List[int], winning: List[int]) -> int:
"""본번호 6개 일치 개수. 보너스 제외."""
return len(set(pick) & set(winning[:6]))
def calc_pick_score(pick_numbers: List[int], winning_numbers: List[int]) -> float:
"""correct/6 + RANK_BONUS. 보너스 번호 미고려."""
correct = count_match(pick_numbers, winning_numbers)
base = correct / 6.0
rank = RANK_BY_CORRECT.get(correct)
bonus = RANK_BONUS.get(rank, 0) if rank else 0
return base + bonus
def decide_base_update(
winner_max_correct: int,
winner_W: List[float],
current_base: Optional[List[float]],
) -> Tuple[List[float], str]:
"""Hybrid base update rule.
Returns:
(new_base, reason) — reason ∈ {'winner_4plus','ema_blend','unchanged','cold_start'}
"""
if winner_max_correct >= 4:
return list(winner_W), "winner_4plus"
if winner_max_correct == 3 and current_base is not None:
blended = [0.3 * w + 0.7 * c for w, c in zip(winner_W, current_base)]
return clamp_and_normalize(blended), "ema_blend"
if current_base is None:
return DEFAULT_UNIFORM[:], "cold_start"
return list(current_base), "unchanged"
# ---------- DB-touching entry points ----------
KST = timezone(timedelta(hours=9))
def _db():
from . import db as _db_mod
return _db_mod
def _today_kst():
return datetime.now(KST).date()
def get_week_start(d=None) -> str:
"""주어진 날짜의 월요일 ISO 'YYYY-MM-DD'."""
if d is None:
d = _today_kst()
ws = d - timedelta(days=d.weekday())
return ws.isoformat()
def get_active_weight() -> Optional[List[float]]:
"""오늘 적용 중인 W. 없으면 None (균등 폴백)."""
today = _today_kst()
week_start = get_week_start(today)
dow = today.weekday()
if dow == 6:
dow = 5 # 일요일은 토요일 W 유지
trial = _db().get_weight_trial(week_start, dow)
if trial:
return trial["weight"]
return None
def generate_weekly_candidates_and_save(seed: Optional[int] = None) -> List[Dict[str, Any]]:
"""월요일 09:00 cron 진입점. 6 trials 생성 후 DB 저장."""
db = _db()
base = db.get_current_base()
if base is None:
base = DEFAULT_UNIFORM[:]
db.save_base_history(
effective_from=get_week_start(),
weight=base,
source_trial_id=None,
update_reason="cold_start",
winner_score=None,
winner_max_correct=None,
)
candidates = generate_weekly_candidates(base, seed=seed)
week_start = get_week_start()
for c in candidates:
db.save_weight_trial(
week_start=week_start,
day_of_week=c["day_of_week"],
weight=c["weight"],
source=c["source"],
base_at_gen=base,
)
return candidates
def apply_today_and_pick(n: int = 5) -> Dict[str, Any]:
"""매일 09:00 cron 진입점. 오늘 W로 N=5 세트 추출 후 auto_picks 저장."""
db = _db()
from . import analyzer, recommender
today = _today_kst()
week_start = get_week_start(today)
dow = min(today.weekday(), 5)
trial = db.get_weight_trial(week_start, dow)
if trial is None:
return {"ok": False, "reason": "no_trial_for_today"}
W = trial["weight"]
draws = db.get_all_draw_numbers()
cache = analyzer.build_analysis_cache(draws)
picks_saved = []
for i in range(1, n + 1):
try:
r = recommender.recommend_numbers(draws)
nums = r["numbers"]
s = analyzer.score_combination(nums, cache, weights=W)
pid = db.save_auto_pick(trial["id"], i, nums, meta_score=s["score_total"])
picks_saved.append({"id": pid, "numbers": nums, "score": s["score_total"]})
except Exception:
continue
return {
"ok": True,
"trial_id": trial["id"],
"weight": W,
"picks": picks_saved,
}
def evaluate_weekly() -> Dict[str, Any]:
"""토 22:00 cron 진입점. 6일 trials × N picks 채점 + base 갱신."""
db = _db()
today = _today_kst()
week_start = get_week_start(today)
trials = db.get_weekly_trials(week_start)
if not trials:
return {"ok": False, "reason": "no_trials"}
latest = db.get_latest_draw()
if latest is None:
return {"ok": False, "reason": "no_latest_draw"}
winning = [
latest["n1"], latest["n2"], latest["n3"],
latest["n4"], latest["n5"], latest["n6"],
]
per_day = []
for trial in trials:
picks = db.get_auto_picks(trial["id"])
if not picks:
continue
day_scores = []
max_c = 0
for p in picks:
correct = count_match(p["numbers"], winning)
rank = RANK_BY_CORRECT.get(correct)
db.update_auto_pick_grade(p["id"], correct, rank)
day_scores.append(calc_pick_score(p["numbers"], winning))
if correct > max_c:
max_c = correct
avg_score = sum(day_scores) / len(day_scores)
per_day.append({
"trial_id": trial["id"],
"day_of_week": trial["day_of_week"],
"weight": trial["weight"],
"avg_score": avg_score,
"max_correct": max_c,
"n_picks": len(picks),
})
if not per_day:
return {"ok": False, "reason": "no_picks_graded"}
winner = max(per_day, key=lambda d: d["avg_score"])
current_base = db.get_current_base()
new_base, reason = decide_base_update(
winner_max_correct=winner["max_correct"],
winner_W=winner["weight"],
current_base=current_base,
)
next_monday = today + timedelta(days=(7 - today.weekday()) % 7 or 7)
next_monday_iso = next_monday.isoformat()
# Idempotent guard: 같은 effective_from으로 이미 저장된 row가 있으면 skip
existing = db.get_base_history(limit=1)
if existing and existing[0]["effective_from"] == next_monday_iso:
return {
"ok": True,
"draw_no": latest["drw_no"],
"week_start": week_start,
"previous_base": existing[0].get("weight"),
"winner": winner,
"new_base": existing[0]["weight"], # 이미 저장된 값
"update_reason": existing[0].get("update_reason", "idempotent_skip"),
"per_day": per_day,
}
db.save_base_history(
effective_from=next_monday_iso,
weight=new_base,
source_trial_id=winner["trial_id"],
update_reason=reason,
winner_score=winner["avg_score"],
winner_max_correct=winner["max_correct"],
)
return {
"ok": True,
"draw_no": latest["drw_no"],
"week_start": week_start,
"previous_base": current_base, # save 이전에 캡처한 값 — diff 계산용
"winner": winner,
"new_base": new_base,
"update_reason": reason,
"per_day": per_day,
}