o2o-negosium-original/agent/negotiation/policy/dqn_store.py
jwkim d4acdd0ac5 [feat] agent: 카드 선택 DQN 서빙 전환 (action-as-feature)
- feature_dqn_policy: ScoreNet(상태+카드특징 → 점수) 학습 정책 (replay+타깃넷)
- feature_builder: 이산화 없는 연속 상태 벡터(9) + 테넌트 성향 벡터(5)
- dqn_store: numpy 전용 서빙(컨테이너 PyTorch 불필요), DQN_SERVING 플래그,
  미지원 테넌트는 Q-table 자동 폴백
- 파이프라인: build_card_embeddings -> train_feature_dqn -> export_dqn_serving(npz)
- retrain_from_logs: 실로그 재학습 + OPE(SNIPS) 게이트, 통과 시에만 번들 교체(.prev 백업)
- probe_serving_dqn / compare_qtable_vs_dqn: 배포 전 행동 점검 도구

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:56:24 +09:00

119 lines
5.3 KiB
Python

"""DQNServingStore — action-as-feature DQN 서빙 (선택 전용, 학습 없음).
tools/export_dqn_serving.py 가 만든 dqn_serving.npz(ScoreNet 가중치 + 카드특징 392차원)를
numpy 로 추론한다 — 서빙 컨테이너에 PyTorch 불필요.
역할 분담(계획서 H 트랙으로 가기 전 파일럿):
- 카드 '선택'만 DQN(greedy). Q-table 학습/영속/experience_logs 로깅 경로는 기존 그대로 유지
(Q-learning 은 오프폴리시라 DQN 이 고른 행동으로 갱신해도 유효, 로그는 DQN 오프라인 재학습 재료).
- 폴백: 플래그 꺼짐 / 번들 없음 / 가용 카드 전부 특징 미보유(신규 카드) → None 반환,
호출부(ChatService)가 기존 UCB Q-table 선택으로 진행한다.
활성화: 환경변수 DQN_SERVING=1 (docker-compose agent environment).
신규 카드 주의: 번들에 없는 카드번호는 후보에서 제외된다 — 카드 추가 시
tools/build_card_embeddings.py → tools/export_dqn_serving.py 재실행 후 재배포 필요.
"""
import os
from typing import List, Optional
import numpy as np
from common.logger import LOG
from negotiation.policies.base import ActionDecision, PolicyContext
from negotiation.qtable.domain.service.feature_builder import (
build_state_features, build_tenant_features)
_HERE = os.path.dirname(os.path.abspath(__file__))
BUNDLE_PATH = os.path.join(_HERE, "..", "..", "artifacts", "dqn_serving.npz")
class _Bundle:
def __init__(self, z):
self.W0, self.b0 = z["W0"], z["b0"]
self.W1, self.b1 = z["W1"], z["b1"]
self.W2, self.b2 = z["W2"], z["b2"]
self.state_dim = int(z["state_dim"])
self.card_feats = {str(n): z["card_feats"][i]
for i, n in enumerate(z["card_numbers"])}
def scores(self, state_feat: np.ndarray, card_feats: np.ndarray) -> np.ndarray:
"""가용 카드 K개 일괄 채점: [K, state+card] → [K]."""
k = card_feats.shape[0]
x = np.concatenate([np.repeat(state_feat[None, :], k, axis=0), card_feats], axis=1)
h = np.maximum(x @ self.W0.T + self.b0, 0.0)
h = np.maximum(h @ self.W1.T + self.b1, 0.0)
return (h @ self.W2.T + self.b2).squeeze(-1)
class DQNServingPolicy:
"""UCBQTablePolicy.select 와 동일한 PolicyContext → ActionDecision 계약(선택 전용)."""
name = "feature_dqn"
_EPS = 0.1 # propensity 근사용 ε (UCB 정책과 동일 관례 — OPE 지지 확보용, 선택은 greedy)
def __init__(self, bundle: _Bundle, engine): # engine: tenancy.registry.TenantEngine
self._bundle = bundle
self._mapper = engine.mapper
self._tenant_feat = build_tenant_features(engine.config.reward)
def _available(self, ctx: PolicyContext) -> List[int]:
# UCBQTablePolicy._available 과 동일 규칙 (마스크 → used 제외 → 소진 시 전체 허용)
if ctx.available_mask is not None:
avail = [a for a in range(ctx.action_space_size) if ctx.available_mask[a]]
else:
used = ctx.episode.used_action_ids if ctx.episode else set()
avail = [a for a in range(ctx.action_space_size) if a not in used]
return avail or list(range(ctx.action_space_size))
def select(self, ctx: PolicyContext) -> Optional[ActionDecision]:
"""카드특징이 있는 가용 카드가 없으면 None → 호출부가 Q-table 로 폴백."""
candidates = [] # (action_id, card_feat)
for a in self._available(ctx):
num = self._mapper.get_card_id(a)
feat = self._bundle.card_feats.get(num) if num else None
if feat is not None:
candidates.append((a, feat))
if not candidates:
return None
state_feat = np.concatenate([build_state_features(ctx.snapshot), self._tenant_feat])
if state_feat.shape[0] != self._bundle.state_dim:
LOG.e_no_callstack(
f"[DQNServing] state_dim 불일치: {state_feat.shape[0]} != {self._bundle.state_dim}")
return None
sc = self._bundle.scores(state_feat, np.stack([f for _, f in candidates]))
i = int(sc.argmax())
n = len(candidates)
return ActionDecision(
action_id=candidates[i][0],
propensity=(1.0 - self._EPS) + self._EPS / n,
q_value=float(sc[i]),
ucb_score=float(sc[i]),
available_actions=[a for a, _ in candidates],
)
class DQNServingStore:
"""번들 lazy 로드 + 캐시. 비활성/부재 시 None (호출부 Q-table 폴백)."""
_bundle: Optional[_Bundle] = None
_load_failed = False
@classmethod
def enabled(cls) -> bool:
return os.getenv("DQN_SERVING", "0").lower() in ("1", "true", "yes")
@classmethod
def policy_for(cls, engine) -> Optional[DQNServingPolicy]:
if not cls.enabled() or cls._load_failed:
return None
if cls._bundle is None:
try:
cls._bundle = _Bundle(np.load(BUNDLE_PATH, allow_pickle=False))
LOG.i(f"[DQNServing] 번들 로드 완료: 카드 {len(cls._bundle.card_feats)}")
except Exception as ex:
cls._load_failed = True # 요청마다 재시도하지 않음
LOG.e_no_callstack(f"[DQNServing] 번들 로드 실패 → Q-table 폴백: {ex}")
return None
return DQNServingPolicy(cls._bundle, engine)