ksk00's picture
v3: availability-aware contingency policy (all suppliers A-E, state-sensitive)
e2ef6a7 verified
Raw
History Blame Contribute Delete
5.49 kB
"""์ถ”๋ก  ์ „์šฉ ๋ชจ๋ธ ์ •์˜ โ€” v3 (๊ฐ€์šฉ์„ฑ ์ธ์ง€ ์ปจํ‹ด์ „์‹œ ์ •์ฑ…).
์ƒํƒœ = ์žฌ๊ณ (1) + ํŒŒ์ดํ”„๋ผ์ธ(7) + ์‹œ๊ฐ„ sin/cos(2) + ์ˆ˜์š”์˜ˆ์ธก(3) + ์—…์ฒด๊ฐ€์šฉ์„ฑ(5) = 18์ฐจ์›.
๋งค์ผ ์ผ๋ถ€ ์—…์ฒด๊ฐ€ ํ’ˆ์ ˆ๋  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ •์ฑ…์€ ๊ฐ€์šฉ ์—…์ฒด ์ค‘ ์ƒํ™ฉ๋ณ„ ์ตœ์ ์„ ์„ ํƒํ•œ๋‹ค.
ํ•™์Šต/ํ™˜๊ฒฝ ๋กœ์ง์€ train_v3.py ์ฐธ์กฐ.
"""
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
DEVICE = torch.device("cpu") # ๋ฐฐํฌ๋Š” CPU ์ถ”๋ก 
CONFIG = {"CAP": 2000.0, "tau_max": 7}
TRAIN_CONFIG = {"hidden_dim": 192, "supplier_emb_dim": 48, "num_layers": 4, "num_heads": 8}
SUPPLIERS = {
0: {"name": "None", "tau": 0, "P": 0, "MOQ": 0, "C_cap": 1, "C_ship": 0},
1: {"name": "A", "tau": 7, "P": 80, "MOQ": 1000, "C_cap": 2000, "C_ship": 5000},
2: {"name": "B", "tau": 5, "P": 100, "MOQ": 500, "C_cap": 1000, "C_ship": 3000},
3: {"name": "C", "tau": 3, "P": 120, "MOQ": 200, "C_cap": 1000, "C_ship": 2000},
4: {"name": "D", "tau": 1, "P": 150, "MOQ": 50, "C_cap": 500, "C_ship": 5000},
5: {"name": "E", "tau": 0, "P": 200, "MOQ": 0, "C_cap": 100, "C_ship": 2000},
}
SEAS_M = [1.0, 1.0, 0.9, 1.0, 1.1, 1.2, 1.5, 1.5, 1.0, 0.9, 1.1, 1.3]
SEAS_D = [1.2, 1.1, 1.0, 1.0, 0.9, 0.6, 0.5]
N_SUP = 5 # ๊ฐ€์šฉ์„ฑ ๋งˆ์Šคํ‚น ๋Œ€์ƒ (A~E)
STATE_DIM = 1 + CONFIG["tau_max"] + 2 + 3 + N_SUP # = 18
def demand_factors(t):
def mu(tt):
return SEAS_M[(tt // 30) % 12] * SEAS_D[tt % 7]
return [mu(t), sum(mu(t + k) for k in range(1, 4)) / 3, sum(mu(t + k) for k in range(1, 8)) / 7]
class InventoryTransformer(nn.Module):
def __init__(self, state_dim, hidden_dim, num_layers=2, num_heads=4):
super().__init__()
self.input_embed = nn.Linear(state_dim, hidden_dim)
enc = nn.TransformerEncoderLayer(d_model=hidden_dim, nhead=num_heads,
dim_feedforward=256, batch_first=True, dropout=0.0)
self.transformer = nn.TransformerEncoder(enc, num_layers=num_layers)
self.supplier_head = nn.Sequential(nn.Linear(hidden_dim, 64), nn.ReLU(), nn.Linear(64, 6))
self.sup_action_embed = nn.Embedding(6, TRAIN_CONFIG["supplier_emb_dim"])
self.qty_input_dim = hidden_dim + TRAIN_CONFIG["supplier_emb_dim"]
self.qty_head_mu = nn.Sequential(nn.Linear(self.qty_input_dim, 64), nn.ReLU(), nn.Linear(64, 1))
self.qty_head_sigma = nn.Sequential(nn.Linear(self.qty_input_dim, 64), nn.ReLU(),
nn.Linear(64, 1), nn.Softplus())
def forward(self, state, fixed_supplier=None):
x = self.input_embed(state).unsqueeze(1)
feats = self.transformer(x).squeeze(1)
sup_logits = self.supplier_head(feats)
selected = torch.argmax(sup_logits, dim=1) if fixed_supplier is None else fixed_supplier
sup_emb = self.sup_action_embed(selected)
qty_input = torch.cat([feats, sup_emb], dim=1)
mu = F.softplus(self.qty_head_mu(qty_input))
sigma = self.qty_head_sigma(qty_input) + 1e-4
return sup_logits, mu, sigma
def apply_mask(sup_logits, avail):
"""ํ’ˆ์ ˆ ์—…์ฒด(A~E) ๋กœ์ง“์„ -inf. None(0)์€ ํ•ญ์ƒ ํ—ˆ์šฉ."""
masked = sup_logits.clone()
masked[:, 1:1 + N_SUP] = torch.where(avail > 0.5, sup_logits[:, 1:1 + N_SUP],
torch.full_like(sup_logits[:, 1:1 + N_SUP], -1e9))
return masked
def apply_moq(supplier_idx: int, raw_qty: float) -> int:
"""๋ชจ๋ธ raw ์ฃผ๋ฌธ๋Ÿ‰ โ†’ ์‹ค์ œ ๋ฐœ์ฃผ๋Ÿ‰ (MOQ ๋ณด์ •)."""
if supplier_idx == 0:
return 0
return int(round(max(raw_qty, float(SUPPLIERS[supplier_idx]["MOQ"]))))
def build_state(on_hand, pipeline, day_of_year, available):
"""์›์‹œ ์ž…๋ ฅ โ†’ ์ƒํƒœ ํ…์„œ (1, 18).
on_hand : ํ˜„์žฌ ๋ณด์œ  ์žฌ๊ณ 
pipeline : ํ–ฅํ›„ 1..7์ผ ์ž…๊ณ  ์˜ˆ์ • (๊ธธ์ด 7)
day_of_year : 0~364
available : A~E ๊ฐ€์šฉ ์—ฌ๋ถ€ (๊ธธ์ด 5, 1/0 ๋˜๋Š” bool)
"""
cap = CONFIG["CAP"]; tau_max = CONFIG["tau_max"]
if len(pipeline) != tau_max:
raise ValueError(f"pipeline ๊ธธ์ด {tau_max} ํ•„์š” (๋ฐ›์Œ {len(pipeline)})")
if len(available) != N_SUP:
raise ValueError(f"available ๊ธธ์ด {N_SUP} ํ•„์š” (๋ฐ›์Œ {len(available)})")
sin_t = math.sin(2 * math.pi * day_of_year / 365)
cos_t = math.cos(2 * math.pi * day_of_year / 365)
feat = ([on_hand / cap] + [p / cap for p in pipeline] + [sin_t, cos_t]
+ demand_factors(day_of_year) + [float(a) for a in available])
return torch.tensor([feat], dtype=torch.float32, device=DEVICE)
@torch.no_grad()
def recommend(model, on_hand, pipeline, day_of_year, available):
"""๊ฐ€์šฉ์„ฑ ๋งˆ์Šคํ‚น + ๊ฒฐ์ •์  ์„ ํƒ + MOQ ๋ณด์ •. ๋ฐ˜ํ™˜: (idx, name, qty)."""
state = build_state(on_hand, pipeline, day_of_year, available)
sup_logits, _, _ = model(state)
masked = apply_mask(sup_logits, state[:, -N_SUP:])
idx = int(torch.argmax(masked, dim=1).item())
_, mu, _ = model(state, fixed_supplier=torch.tensor([idx], device=DEVICE))
qty = apply_moq(idx, mu.item())
return idx, SUPPLIERS[idx]["name"], qty
def load_model(model_path: str) -> InventoryTransformer:
model = InventoryTransformer(STATE_DIM, TRAIN_CONFIG["hidden_dim"],
TRAIN_CONFIG["num_layers"], TRAIN_CONFIG["num_heads"]).to(DEVICE)
checkpoint = torch.load(model_path, map_location=DEVICE, weights_only=True)
model.load_state_dict(checkpoint)
model.eval()
return model