o2o-negosium-original/agent/tools/export_dqn_serving.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

80 lines
3.0 KiB
Python

"""feature_dqn 체크포인트(.pt) → 서빙 번들(dqn_serving.npz) export.
서빙 컨테이너에 PyTorch 를 넣지 않기 위해 ScoreNet(3층 MLP) 가중치와 카드 특징
(임베딩384 + 전략 one-hot4 + 톤 one-hot4 = 392)을 numpy 번들 하나로 묶는다.
추론은 negotiation.policy.dqn_store 의 numpy forward 가 수행한다.
실행(호스트, torch 필요): APP_ENV=local python -m tools.export_dqn_serving
산출: agent/artifacts/dqn_serving.npz (.dockerignore 미제외 → 이미지에 포함)
"""
import os
import numpy as np
import torch
from tools.train_feature_dqn import load_cards
_HERE = os.path.dirname(os.path.abspath(__file__))
CKPT_PATH = os.path.join(_HERE, "..", "artifacts", "feature_dqn_ktcommerce.pt")
OUT_PATH = os.path.join(_HERE, "..", "artifacts", "dqn_serving.npz")
STATE_DIM = 14 # build_state_features(9) + build_tenant_features(5)
CARD_DIM = 392
def _np_forward(x, W0, b0, W1, b1, W2, b2):
h = np.maximum(x @ W0.T + b0, 0.0)
h = np.maximum(h @ W1.T + b1, 0.0)
return h @ W2.T + b2
def export_bundle(sd, out_path: str) -> str:
"""state_dict → 서빙 번들 npz (원자적 교체: .tmp 작성 후 replace). 반환: 절대경로.
retrain_from_logs 재학습 배포도 이 함수를 쓴다 — 검증(torch/numpy 일치)은 main() 전용.
"""
W0, b0 = sd["net.0.weight"].numpy(), sd["net.0.bias"].numpy()
W1, b1 = sd["net.2.weight"].numpy(), sd["net.2.bias"].numpy()
W2, b2 = sd["net.4.weight"].numpy(), sd["net.4.bias"].numpy()
assert W0.shape[1] == STATE_DIM + CARD_DIM, f"입력 차원 불일치: {W0.shape[1]}"
numbers, feat, _ = load_cards()
card_feats = np.stack([feat[n] for n in numbers]).astype(np.float32)
tmp = out_path + ".tmp"
with open(tmp, "wb") as f:
np.savez(
f,
W0=W0, b0=b0, W1=W1, b1=b1, W2=W2, b2=b2,
card_numbers=np.array(numbers), card_feats=card_feats,
state_dim=STATE_DIM, card_dim=CARD_DIM,
)
if os.path.exists(out_path):
os.replace(out_path, out_path + ".prev") # 직전 번들 백업(롤백용)
os.replace(tmp, out_path)
return os.path.abspath(out_path)
def main():
sd = torch.load(CKPT_PATH, map_location="cpu")
# 정합성 검증: torch forward == numpy forward
from negotiation.policies.feature_dqn_policy import ScoreNet
net = ScoreNet(STATE_DIM, CARD_DIM)
net.load_state_dict(sd)
net.eval()
x = np.random.default_rng(0).normal(size=(8, STATE_DIM + CARD_DIM)).astype(np.float32)
with torch.no_grad():
ref = net(torch.tensor(x)).numpy()
W0, b0 = sd["net.0.weight"].numpy(), sd["net.0.bias"].numpy()
W1, b1 = sd["net.2.weight"].numpy(), sd["net.2.bias"].numpy()
W2, b2 = sd["net.4.weight"].numpy(), sd["net.4.bias"].numpy()
out = _np_forward(x, W0, b0, W1, b1, W2, b2).squeeze(-1)
diff = float(np.abs(ref - out).max())
assert diff < 1e-4, f"numpy/torch forward 불일치: {diff}"
path = export_bundle(sd, OUT_PATH)
print(f"[저장] {path} forward 오차 {diff:.2e}")
if __name__ == "__main__":
main()