2352 lines
166 KiB
Plaintext
2352 lines
166 KiB
Plaintext
{
|
|
"cells": [
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"# 🎯 [STR01] 데이터 기반 핵심성과지표 추세·목표 관리\n",
|
|
"### Google Colab 실습 환경 (전략·성과·예산관리 / Level 2)\n",
|
|
"\n",
|
|
"[](https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/STR01/STR01_solution.ipynb)\n",
|
|
"\n",
|
|
"---\n",
|
|
"\n",
|
|
"## 📌 1. 프로젝트 개요 및 배경\n",
|
|
"\n",
|
|
"| 항목 | 내용 |\n",
|
|
"| :--- | :--- |\n",
|
|
"| **과정 분류** | 전략·성과·예산관리 (Level 2) |\n",
|
|
"| **학습 소요 시간** | 10개 미션 (5일 / 총 28.5시간) |\n",
|
|
"| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n",
|
|
"\n",
|
|
"### 📖 프로젝트 목표\n",
|
|
"미국 국방부(DoD) 예산실(Comptroller)은 성과관리 체계를 통해 핵심 성과지표를 정의하고 계획 대비 편차를 추적하며 마감 위험을 가시화해 왔다. 이 공개 사례의 학습 초점은 업무 지표 정의, 계획 대비 편차 분석, 마감 위험 가시화, 반복 집계 경감, 성과 측정이다. 본 프로젝트는 군수 분야의 핵심성과지표로 기준 보급일수(DOS)를 정하고, 교육용 가상 부대 20개의 5대 보급품 재고와 소모 자료를 코드로 만들어 목표 대비 추세와 결핍 위험 일자를 관리한다. 학습자는 KPI 스키마 정의, 데이터 정제, Baseline과 탐색적 분석, 결핍 일자 예측, 3단계 조기경보, 지도형 대시보드와 설명, 군수처장 승인 체계, 충격 시나리오 시험, AAR 임계 재보정, 자동 재학습 파이프라인을 차례로 수행한다. 최종 목표는 반복 집계에 드는 시간을 줄이면서 결핍 위험을 미리 알리고 물자 전환 결정은 반드시 사람이 승인하도록 하는 KPI 추세·목표 관리 도구를 완성하는 것이다.\n",
|
|
"\n",
|
|
"> **⚠️ [교육용 가상 데이터셋 원칙]**\n",
|
|
"> - 본 노트북의 모든 데이터(제원, 로그, 시나리오 등)는 보안 규정을 준수하여 **코드 내 난수 기반 가상 데이터로 생성**됩니다.\n",
|
|
"> - 실제 부대 위치, 작전 인가, 실제 전장 정보, 군사 기밀 데이터는 일체 포함되지 않습니다.\n",
|
|
"\n",
|
|
"---\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# ============================================================================\n",
|
|
"# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트\n",
|
|
"# ============================================================================\n",
|
|
"!pip install -q numpy pandas matplotlib scipy scikit-learn\n",
|
|
"\n",
|
|
"import os\n",
|
|
"import sys\n",
|
|
"import time\n",
|
|
"import math\n",
|
|
"import json\n",
|
|
"import re\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"import warnings\n",
|
|
"warnings.filterwarnings('ignore')\n",
|
|
"\n",
|
|
"# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)\n",
|
|
"plt.rcParams['font.sans-serif'] = 'DejaVu Sans'\n",
|
|
"plt.rcParams['axes.unicode_minus'] = False\n",
|
|
"\n",
|
|
"print(f\"실행 환경 준비 완료 (Python {sys.version.split()[0]})\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 01. 부대별 5대 핵심 보급품(식량, 피복, 유류, 탄약, 수리부속) 재고 및 기준 보급일수(DOS) 스키마 정의\n",
|
|
"**소요시간**: 약 1.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"5대 핵심 보급품(식량, 유류, 탄약 등) 재고 및 기준 보급일수(DOS) 스키마를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **5대 핵심 보급품(식량, 피복, 유류, 탄약, 수리부속)의 재고와 기준 보급일수(DOS) 핵심성과지표(KPI)의 스키마와 데이터 딕셔너리를 정의하고 KPI 보고 업무 프로세스 맵을 작성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **교육용 가상 부대 20개의 일일 재고·소모·보급 원장을 만들어 필드, 형식, 허용값, 의미를 데이터 딕셔너리로 정리하고 규격화하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `docs/str01_logistics_domain_spec.md 및 데이터 딕셔너리` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 군수 실무 업무 프로세스와의 일치성 및 분석 목표의 명확성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 업무 프로세스 맵 작성 완료, 필요 피처(Feature) 목록 20개 이상 정의\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 도메인 분석서 및 데이터 요구명세서 승인\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 군수 담당 실무자 인터뷰 및 기존 전산체계 데이터 구조 대조\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"부대별 핵심 보급품 5종(식량·피복·유류·탄약·수리부속)의 재고와 기준 보급일수(DOS) 핵심성과지표 스키마와 데이터 딕셔너리를 정의하고, 피처 목록 20개 이상과 KPI 보고 업무 프로세스 맵(AS-IS/TO-BE)을 작성한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n",
|
|
"# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n",
|
|
"# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n",
|
|
"N_UNIT, DAYS = 20, 160\n",
|
|
"UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n",
|
|
" \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" rows = []\n",
|
|
" for u in range(1, n_unit + 1):\n",
|
|
" s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n",
|
|
" surge = np.zeros(days)\n",
|
|
" for st in rng.choice(days - 10, 4, replace=False):\n",
|
|
" surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n",
|
|
" tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n",
|
|
" plan = tempo + rng.normal(0, 0.08, days)\n",
|
|
" wx = (rng.random(days) < 0.15).astype(int)\n",
|
|
" for it, (b, sens, tg) in ITEMS.items():\n",
|
|
" stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n",
|
|
" for t in range(days):\n",
|
|
" c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n",
|
|
" r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n",
|
|
" stock = max(stock + r - c, 0.0)\n",
|
|
" rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n",
|
|
" return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_samples(d, lo=21, hi=125):\n",
|
|
" \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n",
|
|
" pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n",
|
|
" cs = np.cumsum(c)\n",
|
|
" tg = ITEMS[it][2] * ITEMS[it][0]\n",
|
|
" for t0 in range(lo, hi):\n",
|
|
" idx = np.searchsorted(cs, cs[t0] + stk[t0])\n",
|
|
" if idx >= len(cs) or idx <= t0:\n",
|
|
" continue\n",
|
|
" c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n",
|
|
" last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n",
|
|
" out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n",
|
|
" \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n",
|
|
" \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n",
|
|
" \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n",
|
|
" \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n",
|
|
" return pd.DataFrame(out)\n",
|
|
"\n",
|
|
"\n",
|
|
"def mape(a, b):\n",
|
|
" return float(np.mean(np.abs(a - b) / a))\n",
|
|
"\n",
|
|
"d = make_data()\n",
|
|
"s = make_samples(d)\n",
|
|
"\n",
|
|
"# 1) 스키마(데이터 딕셔너리): 필드, 형식, 허용 범위, 의미\n",
|
|
"DICT = [(\"unit\", \"str\", \"가상NN부대\", \"보고 부대\"), (\"item\", \"str\", \"식량/피복/유류/탄약/수리부속\", \"핵심 보급품 5종\"),\n",
|
|
" (\"day\", \"int\", \"0~159\", \"기준 일자(일 단위 일련번호)\"), (\"tempo\", \"float\", \"0.3~2.5\", \"실제 작전 템포 지수(1.0=평시)\"),\n",
|
|
" (\"plan\", \"float\", \"0.3~2.5\", \"사전 계획 템포(미리 알 수 있는 값)\"), (\"wx\", \"int\", \"0/1\", \"악천후 여부\"),\n",
|
|
" (\"consume\", \"float\", \">=0\", \"일일 소모량\"), (\"resupply\", \"float\", \">=0\", \"당일 보급 입고량\"), (\"stock\", \"float\", \">=0\", \"일 마감 재고\"),\n",
|
|
" (\"target_dos\", \"float\", \"5~20일\", \"기준 보급일수 목표(KPI 목표, 교육용 가정)\"), (\"dos\", \"float\", \">=0\", \"KPI = 재고 / 최근 7일 평균 소모량\")]\n",
|
|
"dd = pd.DataFrame(DICT, columns=[\"필드\", \"형식\", \"허용값\", \"의미\"])\n",
|
|
"print(\"[데이터 딕셔너리] 필드\", len(dd), \"개 [교육용가상자료]\")\n",
|
|
"print(dd.to_string(index=False))\n",
|
|
"bad = 0\n",
|
|
"for col, lo in ((\"consume\", 0), (\"resupply\", 0), (\"stock\", 0)):\n",
|
|
" bad += int((d[col] < lo).sum())\n",
|
|
"bad += int((~d[\"item\"].isin(ITEMS)).sum()) + int((~d[\"wx\"].isin([0, 1])).sum())\n",
|
|
"print(f\"\\n샘플 {len(d)}행({N_UNIT}개 부대 x 5종 x {DAYS}일) 스키마 검증 위반 {bad}건\")\n",
|
|
"print(\"품목별 목표 DOS(KPI 목표, 가정):\", {k: v[2] for k, v in ITEMS.items()})\n",
|
|
"\n",
|
|
"# 2) KPI 정의\n",
|
|
"print(\"\\n[KPI 정의] DOS = 재고 / 최근 7일 평균 소모량, 편차 = 현재 DOS - 목표 DOS, 달성률 = DOS / 목표 DOS\")\n",
|
|
"last = d[d[\"day\"] == 100].copy()\n",
|
|
"last[\"c7\"] = [d[(d[\"unit\"] == r.unit) & (d[\"item\"] == r.item) & (d[\"day\"].between(94, 100))][\"consume\"].mean() for r in last.itertuples()]\n",
|
|
"last[\"dos\"] = last[\"stock\"] / last[\"c7\"]\n",
|
|
"last[\"target\"] = last[\"item\"].map(lambda i: ITEMS[i][2]); last[\"gap\"] = last[\"dos\"] - last[\"target\"]; last[\"rate\"] = last[\"dos\"] / last[\"target\"]\n",
|
|
"print(\"100일째 품목별 평균 DOS와 달성률:\")\n",
|
|
"print(last.groupby(\"item\").agg(평균DOS=(\"dos\", \"mean\"), 목표=(\"target\", \"first\"), 평균달성률=(\"rate\", \"mean\"), 미달부대수=(\"rate\", lambda x: int((x < 0.5).sum()))).round(2).to_string())\n",
|
|
"\n",
|
|
"# 3) 피처 목록(분석·예측에 쓸 후보): 자동 생성 열 + 추가 후보\n",
|
|
"feat_auto = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n",
|
|
"extra = [\"요일 효과\", \"월말 보급 주기 여부\", \"수송 거리(가상)\", \"기후 지수\", \"부대 규모(가상)\", \"최근 긴급 불출 횟수\", \"정비 지시서 건수\", \"훈련 일정 밀도\"]\n",
|
|
"feats = feat_auto + extra\n",
|
|
"print(f\"\\n[피처 목록] 자동 산출 {len(feat_auto)}개 + 추가 후보 {len(extra)}개 = {len(feats)}개\")\n",
|
|
"print(\" \", \", \".join(feats))\n",
|
|
"\n",
|
|
"# 4) KPI 보고 업무 프로세스 맵(AS-IS / TO-BE, 소요 분은 가정값)\n",
|
|
"steps = [(\"부대별 재고 보고 수신\", 30, \"표준 형식 자동 수집\", 2), (\"품목별 일소모량 확인\", 40, \"소모율 자동 정규화\", 2), (\"엑셀로 DOS 재계산\", 60, \"KPI 자동 계산\", 1),\n",
|
|
" (\"목표 대비 편차 표 작성\", 45, \"편차 자동 산출\", 1), (\"기준 미달 부대 선별\", 35, \"미달 규칙 자동 적용\", 1), (\"결핍 예상 시점 수기 추정\", 70, \"예측 모델 자동 산출\", 3),\n",
|
|
" (\"악천후·작전 템포 반영 검토\", 50, \"계획·예보 피처 자동 반영\", 3), (\"경보 등급 판단\", 30, \"주의/경계/심각 자동 분류\", 1),\n",
|
|
" (\"군수처 보고서 작성\", 80, \"대시보드 자동 갱신\", 2), (\"보고서 수정·재집계\", 55, \"단일 원천 사용\", 3), (\"처장 승인·조치 지시\", 40, \"HITL 승인 화면\", 15)]\n",
|
|
"proc = pd.DataFrame(steps, columns=[\"프로세스 단계\", \"AS-IS(분)\", \"TO-BE 개선\", \"TO-BE(분)\"]); proc.index = range(1, len(proc) + 1)\n",
|
|
"a, t = proc[\"AS-IS(분)\"].sum(), proc[\"TO-BE(분)\"].sum()\n",
|
|
"print(\"\\n[업무 프로세스 맵]\")\n",
|
|
"print(proc.to_string())\n",
|
|
"print(f\"\\n프로세스 단계 {len(proc)}개, 1회 집계 AS-IS {a}분 -> TO-BE {t}분({1 - t / a:.0%} 단축 예상, 가정값)\")\n",
|
|
"print(f\"목표(프로세스 맵 작성, 피처 20개 이상): 프로세스 맵 {'완료' if len(proc) >= 10 else '미달'}, 피처 {len(feats)}개 {'달성' if len(feats) >= 20 else '미달'}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 01 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[데이터 딕셔너리] 필드 11 개 [교육용가상자료]\n",
|
|
" 필드 형식 허용값 의미\n",
|
|
" unit str 가상NN부대 보고 부대\n",
|
|
" item str 식량/피복/유류/탄약/수리부속 핵심 보급품 5종\n",
|
|
" day int 0~159 기준 일자(일 단위 일련번호)\n",
|
|
" tempo float 0.3~2.5 실제 작전 템포 지수(1.0=평시)\n",
|
|
" plan float 0.3~2.5 사전 계획 템포(미리 알 수 있는 값)\n",
|
|
" wx int 0/1 악천후 여부\n",
|
|
" consume float >=0 일일 소모량\n",
|
|
" resupply float >=0 당일 보급 입고량\n",
|
|
" stock float >=0 일 마감 재고\n",
|
|
"target_dos float 5~20일 기준 보급일수 목표(KPI 목표, 교육용 가정)\n",
|
|
" dos float >=0 KPI = 재고 / 최근 7일 평균 소모량\n",
|
|
"\n",
|
|
"샘플 16000행(20개 부대 x 5종 x 160일) 스키마 검증 위반 0건\n",
|
|
"품목별 목표 DOS(KPI 목표, 가정): {'식량': 10, '피복': 15, '유류': 7, '탄약': 6, '수리부속': 12}\n",
|
|
"\n",
|
|
"[KPI 정의] DOS = 재고 / 최근 7일 평균 소모량, 편차 = 현재 DOS - 목표 DOS, 달성률 = DOS / 목표 DOS\n",
|
|
"100일째 품목별 평균 DOS와 달성률:\n",
|
|
" 평균DOS 목표 평균달성률 미달부대수\n",
|
|
"item \n",
|
|
"수리부속 7.91 12 0.66 5\n",
|
|
"식량 6.39 10 0.64 3\n",
|
|
"유류 3.80 7 0.54 9\n",
|
|
"탄약 2.06 6 0.34 12\n",
|
|
"피복 11.83 15 0.79 0\n",
|
|
"\n",
|
|
"[피처 목록] 자동 산출 16개 + 추가 후보 8개 = 24개\n",
|
|
" stock, c3, c7, c14, dos7, dos3, trend, burst, plan7, plan_ratio, tempo0, wx_fc, since_rs, rs_avg, fill, item_code, 요일 효과, 월말 보급 주기 여부, 수송 거리(가상), 기후 지수, 부대 규모(가상), 최근 긴급 불출 횟수, 정비 지시서 건수, 훈련 일정 밀도\n",
|
|
"\n",
|
|
"[업무 프로세스 맵]\n",
|
|
" 프로세스 단계 AS-IS(분) TO-BE 개선 TO-BE(분)\n",
|
|
"1 부대별 재고 보고 수신 30 표준 형식 자동 수집 2\n",
|
|
"2 품목별 일소모량 확인 40 소모율 자동 정규화 2\n",
|
|
"3 엑셀로 DOS 재계산 60 KPI 자동 계산 1\n",
|
|
"4 목표 대비 편차 표 작성 45 편차 자동 산출 1\n",
|
|
"5 기준 미달 부대 선별 35 미달 규칙 자동 적용 1\n",
|
|
"6 결핍 예상 시점 수기 추정 70 예측 모델 자동 산출 3\n",
|
|
"7 악천후·작전 템포 반영 검토 50 계획·예보 피처 자동 반영 3\n",
|
|
"8 경보 등급 판단 30 주의/경계/심각 자동 분류 1\n",
|
|
"... (이하 생략)\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 02. 부대별 일일 소모율 정규화 및 재고 변동 데이터 전처리 모듈 구현\n",
|
|
"**소요시간**: 약 2.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"부대별 일일 소모율 정규화 및 재고 변동 데이터 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대별 일일 소모율을 정규화하고 재고 변동 데이터를 정제하는 전처리 모듈을 구현하며, 군수 특수 예외는 보존한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **중복, 누락 일자, 소모량 결측, 단위 혼용, 음수 재고, 품목명 오타, 태그 없는 이상 급증을 일부러 섞은 가상 원장을 만들어 정제 규칙 10종을 적용 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/etl/str01_data_cleansing.py 및 정제 품질 보고서` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 군수 특수 예외(비계획 정비, 긴급 불출 등) 데이터 보존의 타당성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 데이터 정제 규칙 10종 적용, 데이터 정합성 검증 통과율 100%\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: ETL 파이프라인 구현 및 정제된 마스터 데이터셋 구축\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 전후 데이터 통계량(기술통계) 비교 및 무결성 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"중복·결측·단위 혼용·음수 재고·품목명 오타 등이 섞인 가상 일일 재고 원장을 10종 정제 규칙으로 정제하고 일소모율을 정규화하되, 긴급 불출과 비계획 정비 같은 군수 특수 예외는 삭제하지 않고 보존하며 정합성 검증 통과율을 보고한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n",
|
|
"# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n",
|
|
"# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n",
|
|
"N_UNIT, DAYS = 20, 160\n",
|
|
"UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n",
|
|
" \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" rows = []\n",
|
|
" for u in range(1, n_unit + 1):\n",
|
|
" s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n",
|
|
" surge = np.zeros(days)\n",
|
|
" for st in rng.choice(days - 10, 4, replace=False):\n",
|
|
" surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n",
|
|
" tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n",
|
|
" plan = tempo + rng.normal(0, 0.08, days)\n",
|
|
" wx = (rng.random(days) < 0.15).astype(int)\n",
|
|
" for it, (b, sens, tg) in ITEMS.items():\n",
|
|
" stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n",
|
|
" for t in range(days):\n",
|
|
" c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n",
|
|
" r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n",
|
|
" stock = max(stock + r - c, 0.0)\n",
|
|
" rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n",
|
|
" return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_samples(d, lo=21, hi=125):\n",
|
|
" \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n",
|
|
" pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n",
|
|
" cs = np.cumsum(c)\n",
|
|
" tg = ITEMS[it][2] * ITEMS[it][0]\n",
|
|
" for t0 in range(lo, hi):\n",
|
|
" idx = np.searchsorted(cs, cs[t0] + stk[t0])\n",
|
|
" if idx >= len(cs) or idx <= t0:\n",
|
|
" continue\n",
|
|
" c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n",
|
|
" last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n",
|
|
" out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n",
|
|
" \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n",
|
|
" \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n",
|
|
" \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n",
|
|
" \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n",
|
|
" return pd.DataFrame(out)\n",
|
|
"\n",
|
|
"\n",
|
|
"def mape(a, b):\n",
|
|
" return float(np.mean(np.abs(a - b) / a))\n",
|
|
"\n",
|
|
"clean_truth = make_data()\n",
|
|
"rng = np.random.default_rng(2)\n",
|
|
"raw = clean_truth.copy()\n",
|
|
"raw[\"tag\"] = \"\"\n",
|
|
"n = len(raw)\n",
|
|
"inj = {}\n",
|
|
"perm = iter(rng.permutation(n)) # 오염 종류끼리 같은 행이 겹치지 않도록 한 번에 뽑는다\n",
|
|
"def pick(k):\n",
|
|
" return np.array([next(perm) for _ in range(k)])\n",
|
|
"# --- 오염 주입(원인별 건수 기록) ---\n",
|
|
"i_ex = pick(40); raw.loc[i_ex, \"consume\"] = raw.loc[i_ex, \"consume\"] * 2.5; raw.loc[i_ex[:20], \"tag\"] = \"긴급불출\"; raw.loc[i_ex[20:], \"tag\"] = \"비계획정비\"; inj[\"특수 예외(보존 대상)\"] = 40\n",
|
|
"i_unit = pick(30); raw.loc[i_unit, \"consume\"] = raw.loc[i_unit, \"consume\"] * 1000; inj[\"단위 혼용(1000배)\"] = 30\n",
|
|
"i_out = pick(30); raw.loc[i_out, \"consume\"] = raw.loc[i_out, \"consume\"] * 6; inj[\"태그 없는 이상 급증\"] = 30\n",
|
|
"i_na = pick(200); raw.loc[i_na, \"consume\"] = np.nan; inj[\"소모량 결측\"] = 200\n",
|
|
"i_neg = pick(20); raw.loc[i_neg, \"stock\"] = -raw.loc[i_neg, \"stock\"] - 1; inj[\"음수 재고\"] = 20\n",
|
|
"i_typo = pick(60); raw.loc[i_typo, \"item\"] = raw.loc[i_typo, \"item\"].map({\"식량\": \"식 량\", \"피복\": \"피복류\", \"유류\": \"유 류\", \"탄약\": \"탄 약\", \"수리부속\": \"수리 부속\"}); inj[\"품목명 오타\"] = 60\n",
|
|
"dup = raw.sample(100, random_state=2); inj[\"중복 행\"] = 100\n",
|
|
"miss = pick(120)\n",
|
|
"gap_rows = raw.iloc[miss]; inj[\"누락 일자 행\"] = 120\n",
|
|
"raw = pd.concat([raw.drop(raw.index[miss]), dup]).sample(frac=1, random_state=3).reset_index(drop=True)\n",
|
|
"print(f\"[정제 전] {len(raw)}행(원래 {len(clean_truth)}행), 주입 오염: {inj} [교육용가상자료]\")\n",
|
|
"\n",
|
|
"# --- 정제 규칙 10종 ---\n",
|
|
"log = {}\n",
|
|
"df = raw.copy()\n",
|
|
"R = []\n",
|
|
"b = len(df); df = df.drop_duplicates([\"unit\", \"item\", \"day\"]); log[\"R1 중복 행 제거\"] = b - len(df)\n",
|
|
"FIX = {\"식 량\": \"식량\", \"피복류\": \"피복\", \"유 류\": \"유류\", \"탄 약\": \"탄약\", \"수리 부속\": \"수리부속\"}\n",
|
|
"log[\"R2 품목명 표준화\"] = int(df[\"item\"].isin(FIX).sum()); df[\"item\"] = df[\"item\"].replace(FIX)\n",
|
|
"full = pd.MultiIndex.from_product([UNITS, list(ITEMS), range(DAYS)], names=[\"unit\", \"item\", \"day\"])\n",
|
|
"log[\"R3 누락 일자 보충\"] = len(full) - len(df)\n",
|
|
"df = df.set_index([\"unit\", \"item\", \"day\"]).reindex(full).reset_index(); df[\"tag\"] = df[\"tag\"].fillna(\"\")\n",
|
|
"for c in (\"tempo\", \"plan\", \"wx\", \"resupply\"):\n",
|
|
" df[c] = df.groupby([\"unit\", \"item\"])[c].transform(lambda s: s.interpolate(limit_direction=\"both\"))\n",
|
|
"df[\"wx\"] = df[\"wx\"].round().astype(int)\n",
|
|
"med = df.groupby([\"unit\", \"item\"])[\"consume\"].transform(\"median\")\n",
|
|
"unit_err = (df[\"consume\"] > 300 * med) & (df[\"tag\"] == \"\")\n",
|
|
"log[\"R4 단위 혼용 보정(1000배 -> /1000)\"] = int(unit_err.sum()); df.loc[unit_err, \"consume\"] /= 1000\n",
|
|
"out = (df[\"consume\"] > 4 * med) & (df[\"tag\"] == \"\")\n",
|
|
"log[\"R5 태그 없는 이상 급증 상한 처리(중앙값 x3)\"] = int(out.sum()); df.loc[out, \"consume\"] = 3 * med[out]\n",
|
|
"log[\"R6 특수 예외 보존(긴급불출·비계획정비)\"] = int((df[\"tag\"] != \"\").sum())\n",
|
|
"log[\"R7 소모량 결측 선형 보간\"] = int(df[\"consume\"].isna().sum())\n",
|
|
"df[\"consume\"] = df.groupby([\"unit\", \"item\"])[\"consume\"].transform(lambda s: s.interpolate(limit_direction=\"both\"))\n",
|
|
"log[\"R8 음수 재고 -> 흐름 재계산 대상\"] = int((df[\"stock\"] < 0).sum())\n",
|
|
"s0 = clean_truth.groupby([\"unit\", \"item\"])[\"stock\"].first()\n",
|
|
"stk = []\n",
|
|
"for (u, it), g in df.groupby([\"unit\", \"item\"], sort=False): # R9 재고 흐름 일관성: stock = max(전일 + 보급 - 소모, 0)\n",
|
|
" c, r, cur = g[\"consume\"].values, g[\"resupply\"].values, s0[(u, it)]\n",
|
|
" for t in range(len(g)):\n",
|
|
" if t > 0:\n",
|
|
" cur = max(cur + r[t] - c[t], 0.0)\n",
|
|
" else: # 첫날은 보고 재고를 쓰되 결측·음수면 초기 재고 사용\n",
|
|
" first = g[\"stock\"].values[0]\n",
|
|
" cur = first if first >= 0 else s0[(u, it)]\n",
|
|
" stk.append(cur)\n",
|
|
"log[\"R9 재고 흐름 재계산\"] = len(df)\n",
|
|
"df[\"stock\"] = stk\n",
|
|
"df[\"base\"] = df.groupby([\"unit\", \"item\"])[\"consume\"].transform(lambda s: s.iloc[:14].median())\n",
|
|
"df[\"rate\"] = df[\"consume\"] / df[\"base\"] # R10 일소모율 정규화(부대·품목별 기준 소모량 대비)\n",
|
|
"log[\"R10 일소모율 정규화\"] = len(df)\n",
|
|
"print(\"\\n[정제 규칙 10종 적용 건수]\")\n",
|
|
"for k, v in log.items():\n",
|
|
" print(f\" {k}: {v}\")\n",
|
|
"\n",
|
|
"# --- 정합성 검증(정제 후) ---\n",
|
|
"chk = {\"중복 키 없음\": not df.duplicated([\"unit\", \"item\", \"day\"]).any(), \"품목명 표준\": df[\"item\"].isin(ITEMS).all(),\n",
|
|
" \"일자 연속(160일)\": df.groupby([\"unit\", \"item\"])[\"day\"].count().eq(DAYS).all(), \"결측 없음\": not df[[\"consume\", \"stock\", \"resupply\", \"tempo\"]].isna().any().any(),\n",
|
|
" \"소모량 0 이상\": (df[\"consume\"] >= 0).all(), \"재고 0 이상\": (df[\"stock\"] >= 0).all(),\n",
|
|
" \"소모율 합리 범위(0.2~6)\": df[\"rate\"].between(0.2, 6.0).all(),\n",
|
|
" \"특수 예외 태그 보존\": (df[\"tag\"] != \"\").sum() == 40, \"단위 이상 없음(1000배 급증 0건)\": not (df[\"consume\"] > 50 * df[\"base\"]).any(),\n",
|
|
" \"키 조합 100개\": df.groupby([\"unit\", \"item\"]).ngroups == 100}\n",
|
|
"for k, v in chk.items():\n",
|
|
" print(f\" 검증 {k}: {'통과' if v else '실패'}\")\n",
|
|
"rate = sum(chk.values()) / len(chk)\n",
|
|
"print(f\"정합성 검증 통과율 {rate:.0%} ({sum(chk.values())}/{len(chk)})\")\n",
|
|
"cmp = df.merge(clean_truth, on=[\"unit\", \"item\", \"day\"], suffixes=(\"\", \"_t\"))\n",
|
|
"bad_rows = cmp[(cmp[\"tag\"] == \"\") ]\n",
|
|
"print(f\"복원 품질: 일반 행의 소모량 평균 절대 오차 {float((bad_rows['consume'] - bad_rows['consume_t']).abs().mean()):.3f} (평균 소모량 대비 {float((bad_rows['consume'] - bad_rows['consume_t']).abs().mean() / bad_rows['consume_t'].mean()):.2%})\")\n",
|
|
"ex = cmp[cmp[\"tag\"] != \"\"]\n",
|
|
"print(f\"특수 예외 {len(ex)}건은 값을 그대로 보존(지우거나 깎지 않음): 소모 급증 평균 {float((ex['consume'] / ex['base']).mean()):.1f}배 유지\")\n",
|
|
"print(f\"\\n목표(정제 규칙 10종 적용, 정합성 검증 통과율 100%): {'달성' if len(log) >= 10 and rate == 1 else '미달'}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 02 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[정제 전] 15980행(원래 16000행), 주입 오염: {'특수 예외(보존 대상)': 40, '단위 혼용(1000배)': 30, '태그 없는 이상 급증': 30, '소모량 결측': 200, '음수 재고': 20, '품목명 오타': 60, '중복 행': 100, '누락 일자 행': 120} [교육용가상자료]\n",
|
|
"\n",
|
|
"[정제 규칙 10종 적용 건수]\n",
|
|
" R1 중복 행 제거: 99\n",
|
|
" R2 품목명 표준화: 60\n",
|
|
" R3 누락 일자 보충: 119\n",
|
|
" R4 단위 혼용 보정(1000배 -> /1000): 30\n",
|
|
" R5 태그 없는 이상 급증 상한 처리(중앙값 x3): 30\n",
|
|
" R6 특수 예외 보존(긴급불출·비계획정비): 40\n",
|
|
" R7 소모량 결측 선형 보간: 319\n",
|
|
" R8 음수 재고 -> 흐름 재계산 대상: 20\n",
|
|
" R9 재고 흐름 재계산: 16000\n",
|
|
" R10 일소모율 정규화: 16000\n",
|
|
" 검증 중복 키 없음: 통과\n",
|
|
" 검증 품목명 표준: 통과\n",
|
|
" 검증 일자 연속(160일): 통과\n",
|
|
" 검증 결측 없음: 통과\n",
|
|
" 검증 소모량 0 이상: 통과\n",
|
|
" 검증 재고 0 이상: 통과\n",
|
|
" 검증 소모율 합리 범위(0.2~6): 통과\n",
|
|
" 검증 특수 예외 태그 보존: 통과\n",
|
|
" 검증 단위 이상 없음(1000배 급증 0건): 통과\n",
|
|
" 검증 키 조합 100개: 통과\n",
|
|
"정합성 검증 통과율 100% (10/10)\n",
|
|
"복원 품질: 일반 행의 소모량 평균 절대 오차 0.715 (평균 소모량 대비 0.56%)\n",
|
|
"특수 예외 40건은 값을 그대로 보존(지우거나 깎지 않음): 소모 급증 평균 2.4배 유지\n",
|
|
"\n",
|
|
"목표(정제 규칙 10종 적용, 정합성 검증 통과율 100%): 달성\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 03. 단순 잔여 DOS 계산 및 1차 기준 미달 부대 식별 Baseline 알고리즘 구현\n",
|
|
"**소요시간**: 약 2.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"단순 잔여 DOS 계산 및 기준 미달 부대 식별 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **재고와 최근 소모량으로 단순 DOS를 계산해 기준 미달 부대를 식별하는 Baseline을 만들고, 탐색적 분석(EDA)으로 의미 있는 피처를 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **목표 DOS 대비 달성률이 50% 미만인 부대·품목을 1차 기준 미달로 식별 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `notebooks/str01_eda_analysis.ipynb 및 EDA 요약 리포트` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 군수 의사결정에 직관적인 인사이트 제공 수준\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: EDA 시각화 차트 8종 이상 생성, 유의미한 상관관계 피처 5개 이상 도출\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: EDA 노트북 실행 완료 및 핵심 발견점(Key Findings) 문서화\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 통계적 가설 검정(p-value, 상관계수)을 통한 유의성 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"재고와 최근 평균 소모량으로 잔여 보급일수(DOS)를 계산하고 목표 대비 기준 미달 부대를 식별하는 Baseline을 만들며, 8종 탐색 차트와 결핍 위험 일수와의 상관 분석으로 의미 있는 피처 5개 이상을 도출한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n",
|
|
"# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n",
|
|
"# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n",
|
|
"N_UNIT, DAYS = 20, 160\n",
|
|
"UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n",
|
|
" \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" rows = []\n",
|
|
" for u in range(1, n_unit + 1):\n",
|
|
" s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n",
|
|
" surge = np.zeros(days)\n",
|
|
" for st in rng.choice(days - 10, 4, replace=False):\n",
|
|
" surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n",
|
|
" tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n",
|
|
" plan = tempo + rng.normal(0, 0.08, days)\n",
|
|
" wx = (rng.random(days) < 0.15).astype(int)\n",
|
|
" for it, (b, sens, tg) in ITEMS.items():\n",
|
|
" stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n",
|
|
" for t in range(days):\n",
|
|
" c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n",
|
|
" r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n",
|
|
" stock = max(stock + r - c, 0.0)\n",
|
|
" rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n",
|
|
" return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_samples(d, lo=21, hi=125):\n",
|
|
" \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n",
|
|
" pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n",
|
|
" cs = np.cumsum(c)\n",
|
|
" tg = ITEMS[it][2] * ITEMS[it][0]\n",
|
|
" for t0 in range(lo, hi):\n",
|
|
" idx = np.searchsorted(cs, cs[t0] + stk[t0])\n",
|
|
" if idx >= len(cs) or idx <= t0:\n",
|
|
" continue\n",
|
|
" c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n",
|
|
" last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n",
|
|
" out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n",
|
|
" \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n",
|
|
" \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n",
|
|
" \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n",
|
|
" \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n",
|
|
" return pd.DataFrame(out)\n",
|
|
"\n",
|
|
"\n",
|
|
"def mape(a, b):\n",
|
|
" return float(np.mean(np.abs(a - b) / a))\n",
|
|
"\n",
|
|
"d = make_data()\n",
|
|
"d[\"c7\"] = d.groupby([\"unit\", \"item\"])[\"consume\"].transform(lambda s: s.rolling(7, min_periods=3).mean())\n",
|
|
"d[\"dos\"] = d[\"stock\"] / d[\"c7\"]\n",
|
|
"d[\"target\"] = d[\"item\"].map(lambda i: ITEMS[i][2])\n",
|
|
"d[\"rate\"] = d[\"dos\"] / d[\"target\"]\n",
|
|
"\n",
|
|
"# 1) Baseline: 기준 미달 = 달성률 50% 미만(주의), 25% 미만(심각 후보)\n",
|
|
"T = 100\n",
|
|
"now = d[d[\"day\"] == T]\n",
|
|
"low = now[now[\"rate\"] < 0.5].sort_values(\"rate\")\n",
|
|
"print(f\"[Baseline - {T}일째] 단순 DOS = 재고 / 최근 7일 평균 소모량 [교육용가상자료]\")\n",
|
|
"print(f\"부대 x 품목 {len(now)}건 중 달성률 50% 미만 {len(low)}건({len(low) / len(now):.0%}), 25% 미만 {(now['rate'] < 0.25).sum()}건\")\n",
|
|
"print(\"기준 미달 상위 5건:\")\n",
|
|
"print(low.head(5)[[\"unit\", \"item\", \"stock\", \"c7\", \"dos\", \"target\", \"rate\"]].round(2).to_string(index=False))\n",
|
|
"unit_cnt = low.groupby(\"unit\").size().sort_values(ascending=False)\n",
|
|
"print(\"미달 품목이 많은 부대 3곳:\", unit_cnt.head(3).to_dict())\n",
|
|
"\n",
|
|
"# 2) EDA용 표본(결핍 위험 일수 H 포함)과 상관 분석\n",
|
|
"s = make_samples(d[[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"]])\n",
|
|
"feat = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n",
|
|
"corr = s[feat + [\"H\"]].corr()[\"H\"].drop(\"H\").sort_values(key=abs, ascending=False)\n",
|
|
"sig = corr[corr.abs() >= 0.3]\n",
|
|
"print(f\"\\n[결핍 위험 일수 H와의 상관] 표본 {len(s)}건, |r| 0.3 이상 피처 {len(sig)}개\")\n",
|
|
"print(corr.round(3).head(8).to_string())\n",
|
|
"\n",
|
|
"# 3) EDA 차트 8종\n",
|
|
"fig, ax = plt.subplots(2, 4, figsize=(17, 7))\n",
|
|
"ax[0, 0].hist(now[\"dos\"].clip(upper=25), bins=20, color=\"tab:blue\"); ax[0, 0].set_title(\"1 DOS distribution\")\n",
|
|
"dm = d.groupby(\"day\")[\"consume\"].mean(); ax[0, 1].plot(dm.index, dm.values); ax[0, 1].set_title(\"2 Mean daily consumption\")\n",
|
|
"sub = d[d[\"item\"] == \"탄약\"].sample(600, random_state=1); ax[0, 2].scatter(sub[\"tempo\"], sub[\"consume\"], s=6); ax[0, 2].set_title(\"3 Tempo vs consumption\")\n",
|
|
"ax[0, 3].boxplot([d[d[\"wx\"] == 0][\"consume\"] / d[\"c7\"].mean(), d[d[\"wx\"] == 1][\"consume\"] / d[\"c7\"].mean()]); ax[0, 3].set_xticklabels([\"fair\", \"bad wx\"]); ax[0, 3].set_title(\"4 Weather effect\")\n",
|
|
"for u in UNITS[:3]:\n",
|
|
" g = d[(d[\"unit\"] == u) & (d[\"item\"] == \"유류\")]; ax[1, 0].plot(g[\"day\"], g[\"stock\"], label=u[2:4])\n",
|
|
"ax[1, 0].legend(fontsize=7); ax[1, 0].set_title(\"5 Stock trajectories\")\n",
|
|
"ax[1, 1].boxplot([d[d[\"item\"] == i][\"rate\"].dropna().clip(upper=3) for i in ITEMS]); ax[1, 1].set_xticklabels([f\"i{k + 1}\" for k in range(5)]); ax[1, 1].set_title(\"6 Achievement rate by item\")\n",
|
|
"cnt = d[d[\"day\"] >= 14].groupby(\"day\")[\"rate\"].apply(lambda x: (x < 0.5).mean()); ax[1, 2].plot(cnt.index, cnt.values, color=\"tab:red\"); ax[1, 2].set_title(\"7 Share below 50% target\")\n",
|
|
"ax[1, 3].barh(sig.index[::-1][:8], sig.values[::-1][:8], color=\"tab:green\"); ax[1, 3].set_title(\"8 Correlation with H\")\n",
|
|
"plt.tight_layout(); plt.show()\n",
|
|
"n_chart = ax.size\n",
|
|
"r_t = float(d[d[\"item\"] == \"탄약\"][[\"tempo\", \"consume\"]].corr().iloc[0, 1])\n",
|
|
"wx_up = float(d[d[\"wx\"] == 1][\"consume\"].mean() / d[d[\"wx\"] == 0][\"consume\"].mean() - 1)\n",
|
|
"srs = s.groupby(pd.cut(s[\"since_rs\"], [-1, 0, 1, 2, 3, 20]))[\"dos7\"].mean()\n",
|
|
"print(f\"\\nEDA 시각화 차트 {n_chart}종 생성\")\n",
|
|
"print(f\"인사이트: (1) 탄약 소모와 작전 템포의 상관 r={r_t:.2f}. (2) 악천후일 평균 소모가 {wx_up:+.1%}. (3) 보급 후 경과일별 평균 DOS: {[round(float(v), 1) for v in srs.values]} (보급 직전이 가장 낮음)\")\n",
|
|
"print(\"의미 있는 상관 피처:\", \", \".join(sig.index[:6]))\n",
|
|
"print(f\"목표(EDA 차트 8종 이상, 유의미한 상관 피처 5개 이상): 차트 {n_chart}종 {'달성' if n_chart >= 8 else '미달'}, 피처 {len(sig)}개 {'달성' if len(sig) >= 5 else '미달'}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 03 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[Baseline - 100일째] 단순 DOS = 재고 / 최근 7일 평균 소모량 [교육용가상자료]\n",
|
|
"부대 x 품목 100건 중 달성률 50% 미만 29건(29%), 25% 미만 13건\n",
|
|
"기준 미달 상위 5건:\n",
|
|
" unit item stock c7 dos target rate\n",
|
|
"가상01부대 유류 0.0 431.60 0.0 7 0.0\n",
|
|
"가상02부대 탄약 0.0 99.02 0.0 6 0.0\n",
|
|
"가상08부대 탄약 0.0 87.56 0.0 6 0.0\n",
|
|
"가상16부대 탄약 0.0 100.04 0.0 6 0.0\n",
|
|
"가상18부대 탄약 0.0 155.91 0.0 6 0.0\n",
|
|
"미달 품목이 많은 부대 3곳: {'가상16부대': 4, '가상03부대': 3, '가상18부대': 3}\n",
|
|
"\n",
|
|
"[결핍 위험 일수 H와의 상관] 표본 10164건, |r| 0.3 이상 피처 7개\n",
|
|
"dos3 0.958\n",
|
|
"dos7 0.950\n",
|
|
"fill 0.633\n",
|
|
"c3 -0.544\n",
|
|
"c7 -0.529\n",
|
|
"c14 -0.508\n",
|
|
"rs_avg -0.446\n",
|
|
"since_rs -0.264\n",
|
|
"\n",
|
|
"EDA 시각화 차트 8종 생성\n",
|
|
"인사이트: (1) 탄약 소모와 작전 템포의 상관 r=0.86. (2) 악천후일 평균 소모가 +11.8%. (3) 보급 후 경과일별 평균 DOS: [8.4, 7.4, 6.6, 5.9] (보급 직전이 가장 낮음)\n",
|
|
"의미 있는 상관 피처: dos3, dos7, fill, c3, c7, c14\n",
|
|
"목표(EDA 차트 8종 이상, 유의미한 상관 피처 5개 이상): 차트 8종 달성, 피처 7개 달성\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 04. 작전 템포 변동 및 기상 악화 연계 부대별 결핍 위험 일자(Stockout Date) 정밀 예측 고도화\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"작전 템포 변동 연계 부대별 결핍 위험 일자(Stockout Date) 정밀 예측을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **작전 템포 변동과 기상 예보를 반영한 파생 피처로 부대별 결핍 위험 일자(Stockout Date)를 예측하는 기준 모델을 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **사전 계획 템포, 다음 7일 악천후 예보, 최근 소모 추세, 보급 후 경과일 같은 파생 피처 13개를 설계하고 이유를 한 줄씩 설명 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/features/str01_feature_engineering.py 및 기준 모델` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 군수 실무에서 납득 가능한 피처 설계의 논리성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 파생 피처 10개 이상 생성, 기준 모델 MAPE 25% 이하 달성\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 피처 파이프라인 및 베이스라인 모델 성능 지표 도출\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 교차 검증(Time-series Cross Validation)을 통한 과적합 방지\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"작전 템포 계획과 기상 예보를 포함한 파생 피처 13개를 설계해 릿지 회귀 기준 모델로 부대별 결핍 위험 일자(Stockout Date)를 예측하고, 단순 DOS 방식과 MAPE를 비교하며 템포 상승·악천후 가정 시나리오 영향을 확인한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"from sklearn.linear_model import Ridge\n",
|
|
"from sklearn.preprocessing import StandardScaler\n",
|
|
"from sklearn.pipeline import make_pipeline\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n",
|
|
"# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n",
|
|
"# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n",
|
|
"N_UNIT, DAYS = 20, 160\n",
|
|
"UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n",
|
|
" \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" rows = []\n",
|
|
" for u in range(1, n_unit + 1):\n",
|
|
" s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n",
|
|
" surge = np.zeros(days)\n",
|
|
" for st in rng.choice(days - 10, 4, replace=False):\n",
|
|
" surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n",
|
|
" tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n",
|
|
" plan = tempo + rng.normal(0, 0.08, days)\n",
|
|
" wx = (rng.random(days) < 0.15).astype(int)\n",
|
|
" for it, (b, sens, tg) in ITEMS.items():\n",
|
|
" stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n",
|
|
" for t in range(days):\n",
|
|
" c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n",
|
|
" r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n",
|
|
" stock = max(stock + r - c, 0.0)\n",
|
|
" rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n",
|
|
" return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_samples(d, lo=21, hi=125):\n",
|
|
" \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n",
|
|
" pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n",
|
|
" cs = np.cumsum(c)\n",
|
|
" tg = ITEMS[it][2] * ITEMS[it][0]\n",
|
|
" for t0 in range(lo, hi):\n",
|
|
" idx = np.searchsorted(cs, cs[t0] + stk[t0])\n",
|
|
" if idx >= len(cs) or idx <= t0:\n",
|
|
" continue\n",
|
|
" c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n",
|
|
" last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n",
|
|
" out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n",
|
|
" \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n",
|
|
" \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n",
|
|
" \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n",
|
|
" \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n",
|
|
" return pd.DataFrame(out)\n",
|
|
"\n",
|
|
"\n",
|
|
"def mape(a, b):\n",
|
|
" return float(np.mean(np.abs(a - b) / a))\n",
|
|
"\n",
|
|
"d = make_data()\n",
|
|
"s = make_samples(d)\n",
|
|
"X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n",
|
|
"DESC = {\"stock\": \"현재 재고\", \"c3\": \"최근 3일 평균 소모\", \"c7\": \"최근 7일 평균 소모\", \"c14\": \"최근 14일 평균 소모\", \"dos7\": \"재고/7일 소모(DOS)\",\n",
|
|
" \"dos3\": \"재고/3일 소모\", \"trend\": \"7일/14일 소모 추세\", \"burst\": \"3일/7일 소모 급증도\", \"plan7\": \"다음 7일 계획 템포 평균\", \"plan_ratio\": \"계획 템포/현재 템포\",\n",
|
|
" \"tempo0\": \"현재 작전 템포\", \"wx_fc\": \"다음 7일 악천후 예보 비율\", \"since_rs\": \"마지막 보급 후 경과일\", \"rs_avg\": \"최근 14일 평균 보급량\", \"fill\": \"목표 재고 대비 현재 재고\", \"item_code\": \"품목 코드\"}\n",
|
|
"derived = [c for c in X if c not in (\"stock\", \"tempo0\", \"item_code\")]\n",
|
|
"print(f\"[피처 설계] 전체 {len(X)}개 중 파생 피처 {len(derived)}개 [교육용가상자료]\")\n",
|
|
"for c in derived:\n",
|
|
" print(f\" - {c}: {DESC[c]}\")\n",
|
|
"\n",
|
|
"tr, te = s[s[\"t0\"] <= 90], s[s[\"t0\"] > 90]\n",
|
|
"model = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(tr[X], tr[\"H\"])\n",
|
|
"p_base = te[\"dos7\"].values # 단순 DOS 방식\n",
|
|
"p_ridge = np.clip(model.predict(te[X]), 1, None) # 기준 모델\n",
|
|
"y = te[\"H\"].values\n",
|
|
"res = pd.DataFrame({\"모델\": [\"단순 DOS(Baseline)\", \"릿지 기준 모델\"], \"MAPE\": [mape(y, p_base), mape(y, p_ridge)],\n",
|
|
" \"MAE(일)\": [np.abs(y - p_base).mean(), np.abs(y - p_ridge).mean()], \"RMSE(일)\": [np.sqrt(((y - p_base) ** 2).mean()), np.sqrt(((y - p_ridge) ** 2).mean())]})\n",
|
|
"print(f\"\\n[예측 성능] 학습 {len(tr)}건(~90일), 시험 {len(te)}건(91일~)\")\n",
|
|
"print(res.round(3).to_string(index=False))\n",
|
|
"\n",
|
|
"# 결핍 위험 일자 예측 표(100일 기준)\n",
|
|
"now = s[s[\"t0\"] == 100].copy()\n",
|
|
"now[\"예측H\"] = np.clip(model.predict(now[X]), 1, None)\n",
|
|
"now[\"결핍예상일\"] = (now[\"t0\"] + now[\"예측H\"]).round().astype(int)\n",
|
|
"print(\"\\n[결핍 위험 일자 예측 - 100일 기준, 가까운 순 6건]\")\n",
|
|
"print(now.sort_values(\"예측H\").head(6)[[\"unit\", \"item\", \"stock\", \"예측H\", \"결핍예상일\", \"H\"]].rename(columns={\"H\": \"실제H\"}).round(1).to_string(index=False))\n",
|
|
"\n",
|
|
"# 가정 시나리오: 계획 템포 +30%와 다음 7일 악천후 50% 가정 시 예측 변화\n",
|
|
"w = now.copy()\n",
|
|
"w[\"plan7\"] *= 1.3; w[\"plan_ratio\"] *= 1.3; w[\"wx_fc\"] = 0.5\n",
|
|
"shift = np.clip(model.predict(w[X]), 1, None) - now[\"예측H\"].values\n",
|
|
"print(f\"\\n[가정 시나리오] 계획 템포 +30%, 악천후 50% 가정 시 예측 결핍 일수 평균 {shift.mean():+.2f}일 변화 (탄약 {shift[now['item'].values == '탄약'].mean():+.2f}일, 식량 {shift[now['item'].values == '식량'].mean():+.2f}일)\")\n",
|
|
"coef = pd.Series(model[-1].coef_, index=X).sort_values(key=abs, ascending=False)\n",
|
|
"print(\"기준 모델 영향 큰 피처 상위 5:\", {k: round(float(v), 2) for k, v in coef.head(5).items()})\n",
|
|
"m = mape(y, p_ridge)\n",
|
|
"print(f\"\\n목표(파생 피처 10개 이상, 기준 모델 MAPE 25% 이하): 파생 피처 {len(derived)}개 {'달성' if len(derived) >= 10 else '미달'}, MAPE {m:.1%} {'달성' if m <= 0.25 else '미달'}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 04 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[피처 설계] 전체 16개 중 파생 피처 13개 [교육용가상자료]\n",
|
|
" - c3: 최근 3일 평균 소모\n",
|
|
" - c7: 최근 7일 평균 소모\n",
|
|
" - c14: 최근 14일 평균 소모\n",
|
|
" - dos7: 재고/7일 소모(DOS)\n",
|
|
" - dos3: 재고/3일 소모\n",
|
|
" - trend: 7일/14일 소모 추세\n",
|
|
" - burst: 3일/7일 소모 급증도\n",
|
|
" - plan7: 다음 7일 계획 템포 평균\n",
|
|
" - plan_ratio: 계획 템포/현재 템포\n",
|
|
" - wx_fc: 다음 7일 악천후 예보 비율\n",
|
|
" - since_rs: 마지막 보급 후 경과일\n",
|
|
" - rs_avg: 최근 14일 평균 보급량\n",
|
|
" - fill: 목표 재고 대비 현재 재고\n",
|
|
"\n",
|
|
"[예측 성능] 학습 6836건(~90일), 시험 3328건(91일~)\n",
|
|
" 모델 MAPE MAE(일) RMSE(일)\n",
|
|
"단순 DOS(Baseline) 0.162 0.938 1.207\n",
|
|
" 릿지 기준 모델 0.106 0.566 0.721\n",
|
|
"\n",
|
|
"[결핍 위험 일자 예측 - 100일 기준, 가까운 순 6건]\n",
|
|
" unit item stock 예측H 결핍예상일 실제H\n",
|
|
"가상01부대 탄약 31.2 1.2 101 1\n",
|
|
"가상19부대 탄약 50.9 1.7 102 1\n",
|
|
"가상03부대 탄약 81.4 1.9 102 1\n",
|
|
"가상15부대 탄약 50.2 2.0 102 1\n",
|
|
"가상04부대 탄약 93.6 2.3 102 1\n",
|
|
"가상17부대 유류 847.5 2.6 103 3\n",
|
|
"\n",
|
|
"[가정 시나리오] 계획 템포 +30%, 악천후 50% 가정 시 예측 결핍 일수 평균 -1.11일 변화 (탄약 -1.05일, 식량 -1.12일)\n",
|
|
"기준 모델 영향 큰 피처 상위 5: {'dos7': 2.15, 'dos3': 1.26, 'c7': -0.75, 'plan_ratio': -0.71, 'c14': 0.43}\n",
|
|
"\n",
|
|
"목표(파생 피처 10개 이상, 기준 모델 MAPE 25% 이하): 파생 피처 13개 달성, MAPE 10.6% 달성\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 05. 결핍 심각도 지수 산출 및 다단계 결핍 조기경보(주의/경계/심각) 엔진 개발\n",
|
|
"**소요시간**: 약 3.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"결핍 심각도 지수 산출 및 다단계 결핍 조기경보 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **결핍 심각도 지수를 산출하고 주의·경계·심각 3단계 결핍 조기경보 엔진을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **그래디언트 부스팅 모델로 결핍 위험 일수를 예측해 기준 모델과 MAE, RMSE, R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/models/str01_advanced_model.py` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 비정형/불규칙 수요에 대한 모델의 적응력 및 예측 안정성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 기준 모델 대비 오차율(MAE/RMSE) 20% 이상 개선, R2 Score 0.85 이상\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 모델 하이퍼파라미터 튜닝 완료 및 최적 모델 가중치 저장\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: Hold-out 테스트 데이터셋 대상 최종 성능 평가\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"기준 모델(릿지)보다 정확한 그래디언트 부스팅 모델로 결핍 위험 일수를 예측하고, 이를 0~100 결핍 심각도 지수와 주의·경계·심각 3단계 조기경보로 변환해 오차 개선율, R2, 경보 등급 정확도와 미탐·오탐을 평가한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"from sklearn.ensemble import GradientBoostingRegressor\n",
|
|
"from sklearn.linear_model import Ridge\n",
|
|
"from sklearn.metrics import r2_score\n",
|
|
"from sklearn.pipeline import make_pipeline\n",
|
|
"from sklearn.preprocessing import StandardScaler\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n",
|
|
"# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n",
|
|
"# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n",
|
|
"N_UNIT, DAYS = 20, 160\n",
|
|
"UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n",
|
|
" \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" rows = []\n",
|
|
" for u in range(1, n_unit + 1):\n",
|
|
" s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n",
|
|
" surge = np.zeros(days)\n",
|
|
" for st in rng.choice(days - 10, 4, replace=False):\n",
|
|
" surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n",
|
|
" tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n",
|
|
" plan = tempo + rng.normal(0, 0.08, days)\n",
|
|
" wx = (rng.random(days) < 0.15).astype(int)\n",
|
|
" for it, (b, sens, tg) in ITEMS.items():\n",
|
|
" stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n",
|
|
" for t in range(days):\n",
|
|
" c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n",
|
|
" r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n",
|
|
" stock = max(stock + r - c, 0.0)\n",
|
|
" rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n",
|
|
" return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_samples(d, lo=21, hi=125):\n",
|
|
" \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n",
|
|
" pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n",
|
|
" cs = np.cumsum(c)\n",
|
|
" tg = ITEMS[it][2] * ITEMS[it][0]\n",
|
|
" for t0 in range(lo, hi):\n",
|
|
" idx = np.searchsorted(cs, cs[t0] + stk[t0])\n",
|
|
" if idx >= len(cs) or idx <= t0:\n",
|
|
" continue\n",
|
|
" c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n",
|
|
" last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n",
|
|
" out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n",
|
|
" \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n",
|
|
" \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n",
|
|
" \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n",
|
|
" \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n",
|
|
" return pd.DataFrame(out)\n",
|
|
"\n",
|
|
"\n",
|
|
"def mape(a, b):\n",
|
|
" return float(np.mean(np.abs(a - b) / a))\n",
|
|
"\n",
|
|
"d = make_data()\n",
|
|
"s = make_samples(d)\n",
|
|
"X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n",
|
|
"tr, te = s[s[\"t0\"] <= 90], s[s[\"t0\"] > 90]\n",
|
|
"y = te[\"H\"].values\n",
|
|
"ridge = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(tr[X], tr[\"H\"])\n",
|
|
"gbr = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[X], tr[\"H\"])\n",
|
|
"p0 = np.clip(ridge.predict(te[X]), 1, None)\n",
|
|
"p1 = np.clip(gbr.predict(te[X]), 1, None)\n",
|
|
"\n",
|
|
"def score(p):\n",
|
|
" return {\"MAE\": float(np.abs(y - p).mean()), \"RMSE\": float(np.sqrt(((y - p) ** 2).mean())), \"R2\": float(r2_score(y, p)), \"MAPE\": mape(y, p)}\n",
|
|
"a, b = score(p0), score(p1)\n",
|
|
"print(\"[예측 성능 비교] 시험 표본\", len(te), \"건 (91일 이후) [교육용가상자료]\")\n",
|
|
"print(pd.DataFrame({\"기준 모델(릿지)\": a, \"고도화 모델(GBR)\": b}).round(3).to_string())\n",
|
|
"imp_mae, imp_rmse = 1 - b[\"MAE\"] / a[\"MAE\"], 1 - b[\"RMSE\"] / a[\"RMSE\"]\n",
|
|
"print(f\"기준 모델 대비 개선: MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%}, R2 {b['R2']:.3f}\")\n",
|
|
"\n",
|
|
"# 결핍 심각도 지수(0~100): 예상 결핍 일수가 짧을수록 높고, 목표 재고 대비 현재 재고가 적을수록 가중\n",
|
|
"def severity(h, fill):\n",
|
|
" base = np.clip(1 - (h - 1) / 8, 0, 1) # H=1일이면 1.0, H>=9일이면 0\n",
|
|
" return 100 * np.clip(base * (1.1 - 0.2 * np.clip(fill, 0, 1)), 0, 1)\n",
|
|
"LEVELS = [(2.5, \"심각\"), (4.5, \"경계\"), (7.5, \"주의\")]\n",
|
|
"def level(h):\n",
|
|
" for th, name in LEVELS:\n",
|
|
" if h <= th:\n",
|
|
" return name\n",
|
|
" return \"정상\"\n",
|
|
"te = te.assign(pred=p1, pred0=p0)\n",
|
|
"te[\"sev\"] = severity(te[\"pred\"], te[\"fill\"])\n",
|
|
"te[\"lv_pred\"] = te[\"pred\"].map(level); te[\"lv_true\"] = te[\"H\"].map(level); te[\"lv_ridge\"] = te[\"pred0\"].map(level)\n",
|
|
"acc, acc0 = float((te[\"lv_pred\"] == te[\"lv_true\"]).mean()), float((te[\"lv_ridge\"] == te[\"lv_true\"]).mean())\n",
|
|
"order = {\"정상\": 0, \"주의\": 1, \"경계\": 2, \"심각\": 3}\n",
|
|
"miss = float(((te[\"lv_true\"].map(order) >= 2) & (te[\"lv_pred\"].map(order) < 2)).sum() / max((te[\"lv_true\"].map(order) >= 2).sum(), 1))\n",
|
|
"false = float(((te[\"lv_true\"].map(order) < 1) & (te[\"lv_pred\"].map(order) >= 2)).sum() / max((te[\"lv_true\"].map(order) < 1).sum(), 1))\n",
|
|
"print(f\"\\n[3단계 조기경보] 기준: 예상 결핍 {LEVELS[0][0]}일 이하 심각, {LEVELS[1][0]}일 이하 경계, {LEVELS[2][0]}일 이하 주의\")\n",
|
|
"print(pd.crosstab(te[\"lv_true\"], te[\"lv_pred\"]).reindex(index=[\"정상\", \"주의\", \"경계\", \"심각\"], columns=[\"정상\", \"주의\", \"경계\", \"심각\"], fill_value=0).to_string())\n",
|
|
"print(f\"등급 정확도 {acc:.1%}(기준 모델 {acc0:.1%}), 경계 이상 미탐률 {miss:.1%}, 정상 상황 경계 이상 오탐률 {false:.1%}\")\n",
|
|
"print(\"\\n[경보 예시 - 100일 기준 심각도 상위 5건]\")\n",
|
|
"nw = te[te[\"t0\"] == 100].sort_values(\"sev\", ascending=False).head(5)\n",
|
|
"print(nw[[\"unit\", \"item\", \"pred\", \"H\", \"sev\", \"lv_pred\"]].rename(columns={\"pred\": \"예측H\", \"H\": \"실제H\", \"sev\": \"심각도지수\", \"lv_pred\": \"경보\"}).round(1).to_string(index=False))\n",
|
|
"\n",
|
|
"# 불규칙 수요 적응력: 템포 급변(계획 대비 현재 템포 20% 이상 차이) 구간의 안정성\n",
|
|
"irr = te[(te[\"plan_ratio\"] - 1).abs() > 0.2]\n",
|
|
"if len(irr):\n",
|
|
" print(f\"\\n템포 급변 구간 {len(irr)}건 MAE: 기준 {np.abs(irr['H'] - irr['pred0']).mean():.2f}일 -> 고도화 {np.abs(irr['H'] - irr['pred']).mean():.2f}일\")\n",
|
|
"ok = imp_mae >= 0.2 and imp_rmse >= 0.2 and b[\"R2\"] >= 0.85\n",
|
|
"print(f\"\\n목표(기준 모델 대비 MAE/RMSE 20% 이상 개선, R2 0.85 이상): {'달성' if ok else '미달'}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 05 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[예측 성능 비교] 시험 표본 3328 건 (91일 이후) [교육용가상자료]\n",
|
|
" 기준 모델(릿지) 고도화 모델(GBR)\n",
|
|
"MAE 0.566 0.420\n",
|
|
"RMSE 0.721 0.534\n",
|
|
"R2 0.959 0.978\n",
|
|
"MAPE 0.106 0.072\n",
|
|
"기준 모델 대비 개선: MAE 25.8%, RMSE 25.9%, R2 0.978\n",
|
|
"\n",
|
|
"[3단계 조기경보] 기준: 예상 결핍 2.5일 이하 심각, 4.5일 이하 경계, 7.5일 이하 주의\n",
|
|
"lv_pred 정상 주의 경계 심각\n",
|
|
"lv_true \n",
|
|
"정상 1567 52 0 0\n",
|
|
"주의 81 806 47 0\n",
|
|
"경계 0 48 411 16\n",
|
|
"심각 0 0 22 278\n",
|
|
"등급 정확도 92.0%(기준 모델 88.2%), 경계 이상 미탐률 6.2%, 정상 상황 경계 이상 오탐률 0.0%\n",
|
|
"\n",
|
|
"[경보 예시 - 100일 기준 심각도 상위 5건]\n",
|
|
" unit item 예측H 실제H 심각도지수 경보\n",
|
|
"가상01부대 탄약 1.0 1 100.0 심각\n",
|
|
"가상03부대 탄약 1.2 1 100.0 심각\n",
|
|
"가상16부대 유류 1.4 1 100.0 심각\n",
|
|
"가상19부대 탄약 1.0 1 100.0 심각\n",
|
|
"가상15부대 탄약 1.0 1 100.0 심각\n",
|
|
"\n",
|
|
"템포 급변 구간 827건 MAE: 기준 0.66일 -> 고도화 0.45일\n",
|
|
"\n",
|
|
"목표(기준 모델 대비 MAE/RMSE 20% 이상 개선, R2 0.85 이상): 달성\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 06. 군단/사단 관할 부대 보급 수준(DOS) 실시간 현황 지도 및 경보 대시보드 구현\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"관할 부대 보급 수준(DOS) 실시간 현황 지도 및 경보 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **관할 부대의 DOS 현황을 가상 지도와 경보 색상으로 보여 주는 대시보드를 구현하고, 경보마다 예측에 기여한 인자를 설명한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **20개 부대를 격자형 가상 지도에 배치해 부대별 최악 경보 등급을 색으로 표시하고, 3개 시점의 갱신 결과를 출력 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/explainability/str01_xai_explainer.py 및 XAI 리포트` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 군수 장교/군무원이 신뢰하고 조치할 수 있는 설명의 명확성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 주요 예측 건당 기여도 상위 3개 인자 자동 산출률 100%\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: SHAP summary plot 및 waterfall plot 생성 파이프라인 완성\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 실무자 대상 설명 근거 유용성 평가 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"20개 가상 부대를 격자형 가상 지도에 배치해 보급 수준(DOS) 현황과 3단계 경보를 색으로 표시하는 대시보드를 만들고, 경보 건마다 예측에 기여한 상위 3개 인자를 자동으로 설명(XAI)하며 그래프와 텍스트 보고를 출력한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from sklearn.ensemble import GradientBoostingRegressor\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n",
|
|
"# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n",
|
|
"# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n",
|
|
"N_UNIT, DAYS = 20, 160\n",
|
|
"UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n",
|
|
" \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" rows = []\n",
|
|
" for u in range(1, n_unit + 1):\n",
|
|
" s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n",
|
|
" surge = np.zeros(days)\n",
|
|
" for st in rng.choice(days - 10, 4, replace=False):\n",
|
|
" surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n",
|
|
" tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n",
|
|
" plan = tempo + rng.normal(0, 0.08, days)\n",
|
|
" wx = (rng.random(days) < 0.15).astype(int)\n",
|
|
" for it, (b, sens, tg) in ITEMS.items():\n",
|
|
" stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n",
|
|
" for t in range(days):\n",
|
|
" c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n",
|
|
" r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n",
|
|
" stock = max(stock + r - c, 0.0)\n",
|
|
" rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n",
|
|
" return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_samples(d, lo=21, hi=125):\n",
|
|
" \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n",
|
|
" pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n",
|
|
" cs = np.cumsum(c)\n",
|
|
" tg = ITEMS[it][2] * ITEMS[it][0]\n",
|
|
" for t0 in range(lo, hi):\n",
|
|
" idx = np.searchsorted(cs, cs[t0] + stk[t0])\n",
|
|
" if idx >= len(cs) or idx <= t0:\n",
|
|
" continue\n",
|
|
" c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n",
|
|
" last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n",
|
|
" out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n",
|
|
" \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n",
|
|
" \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n",
|
|
" \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n",
|
|
" \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n",
|
|
" return pd.DataFrame(out)\n",
|
|
"\n",
|
|
"\n",
|
|
"def mape(a, b):\n",
|
|
" return float(np.mean(np.abs(a - b) / a))\n",
|
|
"\n",
|
|
"d = make_data()\n",
|
|
"s = make_samples(d)\n",
|
|
"X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n",
|
|
"tr = s[s[\"t0\"] <= 90]\n",
|
|
"gbr = GradientBoostingRegressor(n_estimators=200, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[X], tr[\"H\"])\n",
|
|
"mean_x = tr[X].mean()\n",
|
|
"LABEL = {\"stock\": \"현재 재고\", \"c3\": \"최근 3일 소모\", \"c7\": \"최근 7일 소모\", \"c14\": \"최근 14일 소모\", \"dos7\": \"7일 기준 DOS\", \"dos3\": \"3일 기준 DOS\", \"trend\": \"소모 추세\",\n",
|
|
" \"burst\": \"소모 급증도\", \"plan7\": \"다음 7일 계획 템포\", \"plan_ratio\": \"계획/현재 템포 비\", \"tempo0\": \"현재 작전 템포\", \"wx_fc\": \"악천후 예보\", \"since_rs\": \"보급 후 경과일\",\n",
|
|
" \"rs_avg\": \"최근 보급량\", \"fill\": \"목표 대비 재고\", \"item_code\": \"품목\"}\n",
|
|
"def level(h):\n",
|
|
" return \"심각\" if h <= 2.5 else \"경계\" if h <= 4.5 else \"주의\" if h <= 7.5 else \"정상\"\n",
|
|
"ORDER = {\"정상\": 0, \"주의\": 1, \"경계\": 2, \"심각\": 3}\n",
|
|
"COL = {\"정상\": \"tab:green\", \"주의\": \"gold\", \"경계\": \"orange\", \"심각\": \"red\"}\n",
|
|
"\n",
|
|
"def explain(row):\n",
|
|
" \"\"\"XAI: 각 피처를 학습 평균값으로 바꿨을 때 예측이 변하는 정도(기여도)로 상위 3개 인자를 뽑는다. 음수면 그 인자 때문에 결핍 일수가 짧아졌다는 뜻.\"\"\"\n",
|
|
" x = row[X].astype(float)\n",
|
|
" alts = pd.DataFrame([x] * (len(X) + 1)).reset_index(drop=True)\n",
|
|
" for i, f in enumerate(X):\n",
|
|
" alts.loc[i + 1, f] = mean_x[f]\n",
|
|
" p = gbr.predict(alts)\n",
|
|
" contrib = {f: float(p[0] - p[i + 1]) for i, f in enumerate(X)}\n",
|
|
" return float(p[0]), sorted(contrib.items(), key=lambda kv: -abs(kv[1]))[:3]\n",
|
|
"\n",
|
|
"# 가상 지도: 4행 x 5열 격자(실제 위치 아님)\n",
|
|
"POS = {u: (i % 5, i // 5) for i, u in enumerate(UNITS)}\n",
|
|
"print(\"[지도 대시보드] 가상 격자 지도(위치는 실제와 무관) [교육용가상자료]\")\n",
|
|
"snaps = {}\n",
|
|
"for day in (100, 101, 102):\n",
|
|
" now = s[s[\"t0\"] == day].copy()\n",
|
|
" now[\"pred\"] = np.clip(gbr.predict(now[X]), 1, None)\n",
|
|
" now[\"lv\"] = now[\"pred\"].map(level)\n",
|
|
" worst = now.loc[now.groupby(\"unit\")[\"pred\"].idxmin()].set_index(\"unit\")\n",
|
|
" snaps[day] = (now, worst)\n",
|
|
" cnt = now[\"lv\"].value_counts().reindex([\"심각\", \"경계\", \"주의\", \"정상\"], fill_value=0).to_dict()\n",
|
|
" print(f\" {day}일차: 경보 분포 {cnt}, 가장 위험한 부대 {worst['pred'].idxmin()}({worst['item'][worst['pred'].idxmin()]}, 예상 결핍 {worst['pred'].min():.1f}일)\")\n",
|
|
"\n",
|
|
"now, worst = snaps[102]\n",
|
|
"alerts = now[now[\"lv\"] != \"정상\"].sort_values(\"pred\")\n",
|
|
"print(f\"\\n[경보 설명(XAI)] 102일차 경보 {len(alerts)}건 중 상위 6건 (기여도 상위 3개 인자)\")\n",
|
|
"ok = 0\n",
|
|
"rows = []\n",
|
|
"for _, r in alerts.iterrows():\n",
|
|
" base, top = explain(r)\n",
|
|
" ok += (len(top) == 3 and all(np.isfinite(v) for _, v in top))\n",
|
|
" rows.append((r[\"unit\"], r[\"item\"], r[\"lv\"], base, top))\n",
|
|
"for u, it, lv, base, top in rows[:6]:\n",
|
|
" txt = \", \".join(f\"{LABEL[f]}({v:+.1f}일)\" for f, v in top)\n",
|
|
" print(f\" - {u} {it} [{lv}] 예상 결핍 {base:.1f}일 <- {txt}\")\n",
|
|
"print(f\"설명 자동 산출률 {ok}/{len(alerts)} = {ok / max(len(alerts), 1):.0%}\")\n",
|
|
"fid = []\n",
|
|
"for u, it, lv, base, top in rows[:20]:\n",
|
|
" r = alerts[(alerts[\"unit\"] == u) & (alerts[\"item\"] == it)].iloc[0]\n",
|
|
" k = [f for f in X if f not in [t for t, _ in top]][0]\n",
|
|
" alt = pd.DataFrame([r[X].astype(float)] * 2).reset_index(drop=True)\n",
|
|
" alt.loc[0, top[0][0]] = mean_x[top[0][0]]; alt.loc[1, k] = mean_x[k]\n",
|
|
" p = gbr.predict(alt)\n",
|
|
" fid.append(abs(base - p[0]) >= abs(base - p[1]))\n",
|
|
"print(f\"설명 타당성 점검: 1순위 인자를 제거할 때의 예측 변화가 무관한 인자 제거보다 큰 비율 {np.mean(fid):.0%}\")\n",
|
|
"\n",
|
|
"fig, ax = plt.subplots(1, 2, figsize=(13, 4.8))\n",
|
|
"for u, (x, y) in POS.items():\n",
|
|
" lv = worst.loc[u, \"lv\"]\n",
|
|
" ax[0].scatter(x, -y, s=1500, color=COL[lv], edgecolor=\"black\"); ax[0].text(x, -y, u[2:4], ha=\"center\", va=\"center\", fontsize=9)\n",
|
|
"ax[0].set_title(\"Virtual map: worst alert level per unit (102nd day)\"); ax[0].axis(\"off\")\n",
|
|
"if rows:\n",
|
|
" u, it, lv, base, top = rows[0]\n",
|
|
" ax[1].barh([f for f, _ in top][::-1], [v for _, v in top][::-1], color=\"tab:blue\")\n",
|
|
" ax[1].set_title(\"Top-3 contributions (days) for the most urgent alert\"); ax[1].set_xlabel(\"change in predicted days to stockout\")\n",
|
|
"plt.tight_layout(); plt.show()\n",
|
|
"print(f\"\\n목표(주요 예측 건당 기여도 상위 3개 인자 자동 산출률 100%): {'달성' if ok == len(alerts) else '미달'}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 06 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[지도 대시보드] 가상 격자 지도(위치는 실제와 무관) [교육용가상자료]\n",
|
|
" 100일차: 경보 분포 {'심각': 8, '경계': 11, '주의': 29, '정상': 47}, 가장 위험한 부대 가상15부대(탄약, 예상 결핍 1.0일)\n",
|
|
" 101일차: 경보 분포 {'심각': 8, '경계': 14, '주의': 24, '정상': 51}, 가장 위험한 부대 가상20부대(탄약, 예상 결핍 1.0일)\n",
|
|
" 102일차: 경보 분포 {'심각': 9, '경계': 12, '주의': 24, '정상': 54}, 가장 위험한 부대 가상17부대(탄약, 예상 결핍 1.0일)\n",
|
|
"\n",
|
|
"[경보 설명(XAI)] 102일차 경보 45건 중 상위 6건 (기여도 상위 3개 인자)\n",
|
|
" - 가상17부대 탄약 [심각] 예상 결핍 0.9일 <- 3일 기준 DOS(-5.1일), 목표 대비 재고(-0.3일), 7일 기준 DOS(-0.2일)\n",
|
|
" - 가상07부대 탄약 [심각] 예상 결핍 1.0일 <- 3일 기준 DOS(-5.2일), 7일 기준 DOS(-0.4일), 목표 대비 재고(-0.4일)\n",
|
|
" - 가상03부대 유류 [심각] 예상 결핍 1.0일 <- 3일 기준 DOS(-5.4일), 목표 대비 재고(-0.4일), 7일 기준 DOS(-0.3일)\n",
|
|
" - 가상18부대 탄약 [심각] 예상 결핍 1.1일 <- 3일 기준 DOS(-5.4일), 7일 기준 DOS(-0.3일), 목표 대비 재고(-0.3일)\n",
|
|
" - 가상08부대 유류 [심각] 예상 결핍 1.2일 <- 3일 기준 DOS(-5.1일), 7일 기준 DOS(-0.4일), 목표 대비 재고(-0.3일)\n",
|
|
" - 가상18부대 유류 [심각] 예상 결핍 1.3일 <- 3일 기준 DOS(-5.6일), 목표 대비 재고(-0.3일), 7일 기준 DOS(-0.3일)\n",
|
|
"설명 자동 산출률 45/45 = 100%\n",
|
|
"설명 타당성 점검: 1순위 인자를 제거할 때의 예측 변화가 무관한 인자 제거보다 큰 비율 100%\n",
|
|
"\n",
|
|
"목표(주요 예측 건당 기여도 상위 3개 인자 자동 산출률 100%): 달성\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 07. 결핍 부대 긴급 물자 전환 결정을 위한 군수처장 HITL 승인 워크플로우 설계\n",
|
|
"**소요시간**: 약 3.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"결핍 부대 긴급 물자 전환 결정을 위한 군수처장 HITL 승인 워크플로우를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **예측 기반 보급 정책의 효과를 시뮬레이션으로 비교하고, 결핍 부대에 대한 긴급 물자 전환은 군수처장 승인으로만 집행하는 HITL 워크플로우를 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **고정 목표 재고 정책과 예측 기반 동적 목표 정책을 같은 50일 자료로 재현해 결핍량, 재고 회전, 비용을 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/optimization/str01_simulator.py` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 현실적 군수 제약조건 반영도 및 시나리오별 대안 제시 적절성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 최적화 적용 시 비용 15% 절감 또는 재고 회전율 20% 향상\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 선형/정수 계획법(LP/MIP) 최적화 해 도출 및 시뮬레이션 완료\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 과거 실적 데이터 대비 최적화 결과 비교 백테스팅\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"예측 기반 동적 목표 재고 정책을 고정 목표 정책과 시뮬레이션으로 비교해 비용·재고 회전율 효과를 구하고, 결핍 예상 부대에 대한 긴급 물자 전환 제안을 만들며 최종 집행은 군수처장 승인으로만 이루어지는 HITL 승인 워크플로우와 감사 이력·우회 차단을 구현한다.\"\"\"\n",
|
|
"import hashlib\n",
|
|
"import json\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"from sklearn.ensemble import GradientBoostingRegressor\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n",
|
|
"# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n",
|
|
"# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n",
|
|
"N_UNIT, DAYS = 20, 160\n",
|
|
"UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n",
|
|
" \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" rows = []\n",
|
|
" for u in range(1, n_unit + 1):\n",
|
|
" s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n",
|
|
" surge = np.zeros(days)\n",
|
|
" for st in rng.choice(days - 10, 4, replace=False):\n",
|
|
" surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n",
|
|
" tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n",
|
|
" plan = tempo + rng.normal(0, 0.08, days)\n",
|
|
" wx = (rng.random(days) < 0.15).astype(int)\n",
|
|
" for it, (b, sens, tg) in ITEMS.items():\n",
|
|
" stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n",
|
|
" for t in range(days):\n",
|
|
" c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n",
|
|
" r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n",
|
|
" stock = max(stock + r - c, 0.0)\n",
|
|
" rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n",
|
|
" return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_samples(d, lo=21, hi=125):\n",
|
|
" \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n",
|
|
" pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n",
|
|
" cs = np.cumsum(c)\n",
|
|
" tg = ITEMS[it][2] * ITEMS[it][0]\n",
|
|
" for t0 in range(lo, hi):\n",
|
|
" idx = np.searchsorted(cs, cs[t0] + stk[t0])\n",
|
|
" if idx >= len(cs) or idx <= t0:\n",
|
|
" continue\n",
|
|
" c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n",
|
|
" last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n",
|
|
" out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n",
|
|
" \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n",
|
|
" \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n",
|
|
" \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n",
|
|
" \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n",
|
|
" return pd.DataFrame(out)\n",
|
|
"\n",
|
|
"\n",
|
|
"def mape(a, b):\n",
|
|
" return float(np.mean(np.abs(a - b) / a))\n",
|
|
"\n",
|
|
"d = make_data()\n",
|
|
"s = make_samples(d)\n",
|
|
"X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n",
|
|
"tr = s[s[\"t0\"] <= 90]\n",
|
|
"gbr = GradientBoostingRegressor(n_estimators=120, max_depth=3, learning_rate=0.1, random_state=0).fit(tr[X], tr[\"H\"])\n",
|
|
"now = s[s[\"t0\"] == 100].copy()\n",
|
|
"now[\"pred\"] = np.clip(gbr.predict(now[X]), 1, None)\n",
|
|
"POS = {u: np.array([i % 5, i // 5]) * 20.0 for i, u in enumerate(UNITS)} # 가상 좌표(실제 위치 아님)\n",
|
|
"T0, T1 = 100, 150\n",
|
|
"\n",
|
|
"# 1) 시뮬레이터: 100~149일을 실제 소모·계획·기상 자료로 재현해 두 가지 보급 정책을 비교(4일 주기 정기 보급)\n",
|
|
"def simulate(policy, d_b=6.5):\n",
|
|
" \"\"\"정책 A: 고정 목표 재고(현행). 정책 B: 예측 기반 동적 목표(다음 4일 계획 템포·기상 예보를 반영한 소모 예상 x d_b일치).\"\"\"\n",
|
|
" tot = {\"short\": 0.0, \"stock\": 0.0, \"cons\": 0.0, \"ship\": 0.0, \"n\": 0}\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" c, pl, wx, rs = g[\"consume\"].values, g[\"plan\"].values, g[\"wx\"].values, g[\"resupply\"].values\n",
|
|
" b, sens, tg = ITEMS[it]\n",
|
|
" stock = g[\"stock\"].values[T0 - 1]\n",
|
|
" off = [t for t in range(1, 30) if rs[t] > 0][0] % 4\n",
|
|
" base_tg = tg * float(np.mean(c[:21]))\n",
|
|
" for t in range(T0, T1):\n",
|
|
" if t % 4 == off:\n",
|
|
" if policy == \"A\":\n",
|
|
" target = base_tg\n",
|
|
" else:\n",
|
|
" pr = pl[t:t + 4].mean() / max(pl[t - 7:t].mean(), 0.3)\n",
|
|
" f = c[t - 7:t].mean() * (1 + sens * (pr - 1)) * (1 + 0.12 * wx[t:t + 4].mean()) / (1 + 0.12 * wx[t - 7:t].mean())\n",
|
|
" target = d_b * f\n",
|
|
" r = max(target - stock, 0); stock += r; tot[\"ship\"] += r\n",
|
|
" use = min(stock, c[t]); tot[\"short\"] += c[t] - use; stock -= use; tot[\"stock\"] += stock; tot[\"cons\"] += use; tot[\"n\"] += 1\n",
|
|
" avg = tot[\"stock\"] / tot[\"n\"]\n",
|
|
" tot[\"turn\"] = tot[\"cons\"] / avg / (T1 - T0) # 재고 회전율(연간 환산 아님: 일평균 소모/평균 재고의 합계 기준 상대 지표)\n",
|
|
" tot[\"short_rate\"] = tot[\"short\"] / (tot[\"cons\"] + tot[\"short\"])\n",
|
|
" tot[\"cost\"] = 0.02 * tot[\"stock\"] + 8.0 * tot[\"short\"] # 가정: 보유비 0.02/재고일, 결핍 손실 8.0/단위\n",
|
|
" return tot\n",
|
|
"A, B = simulate(\"A\"), simulate(\"B\")\n",
|
|
"print(f\"[보급 정책 시뮬레이션 {T0}~{T1 - 1}일] 부대 20 x 품목 5 [교육용가상자료]\")\n",
|
|
"print(f\"{'':<16}{'A 고정 목표 재고':>18}{'B 예측 기반 동적 목표':>22}\")\n",
|
|
"for k, nm, fmt in ((\"short\", \"결핍량 합계\", \"{:,.0f}\"), (\"short_rate\", \"결핍률\", \"{:.2%}\"), (\"stock\", \"재고일 합계\", \"{:,.0f}\"), (\"turn\", \"재고 회전 지표\", \"{:.2f}\"), (\"cost\", \"비용(가정 단가)\", \"{:,.0f}\")):\n",
|
|
" print(f\"{nm:<14}{fmt.format(A[k]):>20}{fmt.format(B[k]):>22}\")\n",
|
|
"sav, turn_gain = 1 - B[\"cost\"] / A[\"cost\"], B[\"turn\"] / A[\"turn\"] - 1\n",
|
|
"print(f\"재고 회전율 향상 {turn_gain:.1%}, 비용 절감 {sav:.1%}(보유비+결핍 손실, 가정 단가), 결핍량 감소 {1 - B['short'] / A['short']:.1%}\")\n",
|
|
"print(f\"목표(비용 15% 절감 또는 회전율 20% 향상): {'달성' if sav >= 0.15 or turn_gain >= 0.2 else '미달'}\")\n",
|
|
"print(\"제약: 4일 주기 정기 보급, 보급량은 목표 재고까지, 결핍은 이월하지 않음(가정). 시나리오 A와 B 중 B가 결핍을 줄이면서 재고를 줄이는지 함께 확인한다.\")\n",
|
|
"\n",
|
|
"# 2) 긴급 전환 제안 생성: 예측 결핍(경계 이하) 부대에 다음 보급일까지 버틸 물량을 여유 부대에서 옮기거나 보급소 예비로 충당\n",
|
|
"now[\"nxt\"] = (3 - now[\"since_rs\"]).clip(lower=0) + 1\n",
|
|
"now[\"keep\"] = 1.3 * now[\"c7\"] * (now[\"nxt\"] + 1)\n",
|
|
"donors = now[(now[\"pred\"] > 6) & (now[\"stock\"] > now[\"keep\"])].copy(); donors[\"avail\"] = donors[\"stock\"] - donors[\"keep\"]\n",
|
|
"plan = []\n",
|
|
"for r in now[now[\"pred\"] <= 4.5].sort_values(\"pred\").itertuples():\n",
|
|
" q = r.keep - r.stock\n",
|
|
" if q <= 0:\n",
|
|
" continue\n",
|
|
" cand = donors[(donors[\"item\"] == r.item) & (donors[\"avail\"] >= q)]\n",
|
|
" if len(cand):\n",
|
|
" j = cand.assign(dist=[np.linalg.norm(POS[u] - POS[r.unit]) for u in cand[\"unit\"]]).sort_values(\"dist\").iloc[0]\n",
|
|
" donors.loc[(donors[\"unit\"] == j[\"unit\"]) & (donors[\"item\"] == j[\"item\"]), \"avail\"] -= q\n",
|
|
" plan.append((j[\"unit\"], r.unit, r.item, float(q)))\n",
|
|
" else:\n",
|
|
" plan.append((\"보급소 예비\", r.unit, r.item, float(q)))\n",
|
|
"n_lat = sum(m[0] != \"보급소 예비\" for m in plan)\n",
|
|
"print(f\"\\n[긴급 전환 제안 - {T0}일] 결핍 예상(경계 이하) {int((now['pred'] <= 4.5).sum())}건 중 제안 {len(plan)}건(부대 간 전환 {n_lat}건, 보급소 예비 {len(plan) - n_lat}건)\")\n",
|
|
"for m in plan[:4]:\n",
|
|
" print(f\" {m[0]} -> {m[1]} {m[2]} {m[3]:.0f}\")\n",
|
|
"\n",
|
|
"# 2) HITL: 군수처장 승인 워크플로우(자동 집행 금지)\n",
|
|
"USERS = {\"bot\": \"시스템\", \"kim\": \"군수담당\", \"lee\": \"군수담당\", \"jang\": \"군수처장\", \"han\": \"열람자\"}\n",
|
|
"AUDIT, PROP = [], {}\n",
|
|
"def h(prev, rec):\n",
|
|
" return hashlib.sha256((prev + json.dumps(rec, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()[:16]\n",
|
|
"def log(pid, user, act, res, note=\"\"):\n",
|
|
" rec = {\"seq\": len(AUDIT) + 1, \"pid\": pid, \"user\": user, \"role\": USERS.get(user, \"미등록\"), \"act\": act, \"res\": res, \"note\": note}\n",
|
|
" rec[\"hash\"] = h(AUDIT[-1][\"hash\"] if AUDIT else \"G\", {k: v for k, v in rec.items() if k != \"hash\"}); AUDIT.append(rec)\n",
|
|
"def blocked(pid, user, act, why):\n",
|
|
" log(pid, user, act, \"차단\", why); return False\n",
|
|
"def propose(pid, user, move):\n",
|
|
" if USERS.get(user) not in (\"시스템\", \"군수담당\"):\n",
|
|
" return blocked(pid, user, \"제안\", \"제안 권한 없음\")\n",
|
|
" PROP[pid] = {\"move\": move, \"by\": user, \"status\": \"대기\"}; log(pid, user, \"제안\", \"접수\", f\"{move[0]}->{move[1]} {move[2]} {move[3]:.0f}\"); return True\n",
|
|
"def decide(pid, user, act, note):\n",
|
|
" p = PROP.get(pid)\n",
|
|
" if p is None:\n",
|
|
" return blocked(pid, user, act, \"제안 없음\")\n",
|
|
" if USERS.get(user) != \"군수처장\":\n",
|
|
" return blocked(pid, user, act, f\"권한 없음: 역할 '{USERS.get(user, '미등록')}'은(는) 결정 불가(자동 집행 금지)\")\n",
|
|
" if p[\"status\"] != \"대기\":\n",
|
|
" return blocked(pid, user, act, f\"이미 {p['status']}\")\n",
|
|
" if len(note.strip()) < 5:\n",
|
|
" return blocked(pid, user, act, \"결정 근거 5자 이상 필요\")\n",
|
|
" p[\"status\"] = \"승인\" if act == \"승인\" else \"반려\"; log(pid, user, act, \"완료\", note); return True\n",
|
|
"def execute(pid, user):\n",
|
|
" p = PROP.get(pid)\n",
|
|
" if p is None or p[\"status\"] != \"승인\":\n",
|
|
" return blocked(pid, user, \"집행\", \"승인되지 않은 건 집행 불가\")\n",
|
|
" p[\"status\"] = \"집행\"; log(pid, user, \"집행\", \"완료\", \"승인 건 집행(모의)\"); return True\n",
|
|
"\n",
|
|
"big = plan[:8]\n",
|
|
"for i, mv in enumerate(big):\n",
|
|
" propose(f\"P{i + 1}\", \"bot\" if i % 2 else \"kim\", mv)\n",
|
|
"if len(plan) > 8:\n",
|
|
" propose(\"P9\", \"kim\", plan[8]) # 결정 전 대기 건(우회 시험용)\n",
|
|
"for i, mv in enumerate(big):\n",
|
|
" pid = f\"P{i + 1}\"\n",
|
|
" if i == 5:\n",
|
|
" decide(pid, \"jang\", \"반려\", \"여유 부대의 다음 훈련 일정과 충돌 우려\")\n",
|
|
" else:\n",
|
|
" decide(pid, \"jang\", \"승인\", \"예측 근거와 여유 부대 5일치 유지 확인\")\n",
|
|
" execute(pid, \"kim\")\n",
|
|
"att = [(\"시스템이 자동 승인\", lambda: decide(\"P1\", \"bot\", \"승인\", \"자동 승인 처리\")), (\"군수담당이 직접 승인\", lambda: decide(\"P2\", \"lee\", \"승인\", \"담당이 직접 승인함\")),\n",
|
|
" (\"열람자가 승인\", lambda: decide(\"P3\", \"han\", \"승인\", \"열람자가 승인 시도함\")), (\"미등록 사용자 승인\", lambda: decide(\"P4\", \"x\", \"승인\", \"외부 계정 승인 시도\")),\n",
|
|
" (\"승인 근거 없이 결정\", lambda: decide(\"P9\", \"jang\", \"승인\", \"\")), (\"반려 건 집행\", lambda: execute(\"P6\", \"kim\")),\n",
|
|
" (\"미승인 건 집행(없는 제안)\", lambda: execute(\"P99\", \"kim\")), (\"이미 집행된 건 재승인\", lambda: decide(\"P1\", \"jang\", \"승인\", \"중복 승인 시도 확인\"))]\n",
|
|
"print(\"\\n[HITL 승인 결과]\", {k: sum(p[\"status\"] == k for p in PROP.values()) for k in (\"집행\", \"반려\", \"대기\")})\n",
|
|
"nb = 0\n",
|
|
"for name, fn in att:\n",
|
|
" n = len(AUDIT); fn(); r = AUDIT[-1]; ok = len(AUDIT) == n + 1 and r[\"res\"] == \"차단\"; nb += ok\n",
|
|
" print(f\" - {name}: {r['res']} ({r['note']})\")\n",
|
|
"prev, okc = \"G\", 0\n",
|
|
"for r in AUDIT:\n",
|
|
" okc += r[\"hash\"] == h(prev, {k: v for k, v in r.items() if k != \"hash\"}); prev = r[\"hash\"]\n",
|
|
"print(f\"우회 시도 {len(att)}건 중 차단 {nb}건, 감사 이력 {len(AUDIT)}건 해시 검증 {okc}/{len(AUDIT)}\")\n",
|
|
"print(\"군수처장 승인 없이 집행된 건 0건. 실제 물자 전환 결정은 지휘계통과 군수 규정에 따르며 본 코드는 학습용 모의이다.\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 07 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[보급 정책 시뮬레이션 100~149일] 부대 20 x 품목 5 [교육용가상자료]\n",
|
|
" A 고정 목표 재고 B 예측 기반 동적 목표\n",
|
|
"결핍량 합계 5,546 894\n",
|
|
"결핍률 0.89% 0.14%\n",
|
|
"재고일 합계 3,317,341 2,505,060\n",
|
|
"재고 회전 지표 18.54 24.74\n",
|
|
"비용(가정 단가) 110,716 57,250\n",
|
|
"재고 회전율 향상 33.4%, 비용 절감 48.3%(보유비+결핍 손실, 가정 단가), 결핍량 감소 83.9%\n",
|
|
"목표(비용 15% 절감 또는 회전율 20% 향상): 달성\n",
|
|
"제약: 4일 주기 정기 보급, 보급량은 목표 재고까지, 결핍은 이월하지 않음(가정). 시나리오 A와 B 중 B가 결핍을 줄이면서 재고를 줄이는지 함께 확인한다.\n",
|
|
"\n",
|
|
"[긴급 전환 제안 - 100일] 결핍 예상(경계 이하) 19건 중 제안 17건(부대 간 전환 3건, 보급소 예비 14건)\n",
|
|
" 보급소 예비 -> 가상01부대 탄약 195\n",
|
|
" 보급소 예비 -> 가상19부대 탄약 211\n",
|
|
" 보급소 예비 -> 가상15부대 탄약 238\n",
|
|
" 보급소 예비 -> 가상03부대 탄약 582\n",
|
|
"\n",
|
|
"[HITL 승인 결과] {'집행': 7, '반려': 1, '대기': 1}\n",
|
|
" - 시스템이 자동 승인: 차단 (권한 없음: 역할 '시스템'은(는) 결정 불가(자동 집행 금지))\n",
|
|
" - 군수담당이 직접 승인: 차단 (권한 없음: 역할 '군수담당'은(는) 결정 불가(자동 집행 금지))\n",
|
|
" - 열람자가 승인: 차단 (권한 없음: 역할 '열람자'은(는) 결정 불가(자동 집행 금지))\n",
|
|
" - 미등록 사용자 승인: 차단 (권한 없음: 역할 '미등록'은(는) 결정 불가(자동 집행 금지))\n",
|
|
" - 승인 근거 없이 결정: 차단 (결정 근거 5자 이상 필요)\n",
|
|
" - 반려 건 집행: 차단 (승인되지 않은 건 집행 불가)\n",
|
|
" - 미승인 건 집행(없는 제안): 차단 (승인되지 않은 건 집행 불가)\n",
|
|
" - 이미 집행된 건 재승인: 차단 (이미 집행)\n",
|
|
"우회 시도 8건 중 차단 8건, 감사 이력 33건 해시 검증 33/33\n",
|
|
"군수처장 승인 없이 집행된 건 0건. 실제 물자 전환 결정은 지휘계통과 군수 규정에 따르며 본 코드는 학습용 모의이다.\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 08. 전면전 발발 모의 전 부대 보급품 동시 급감 스트레스 시뮬레이션\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"전면전 모의 전 부대 보급품 동시 급감 스트레스 시뮬레이션을 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전 부대의 소모가 동시에 늘고 보급이 줄어드는 가상 충격 시나리오로 경보 엔진의 스트레스 시뮬레이션을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **소모를 1.5배, 2배, 3배로 키우고 보급량을 절반으로 줄이는 충격을 12일간 가정해 재고를 다시 산출하고 모델에 적용하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `src/dashboard/str01_app.py (Streamlit/Dash 등)` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 의사결정 지원에 최적화된 시각적 위계 및 사용자 경험(UX)\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 대시보드 로딩 시간 2초 이내, 경보 발령 정확도 95% 이상\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 웹 기반 분석 대시보드 구동 및 실시간 필터링 기능 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 실제 군수 데이터 연동 화면 테스트 및 UI 동작 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"전 부대 소모가 동시에 크게 늘고 보급이 차질을 빚는 가상 충격 시나리오(소모 1.5·2·3배, 보급 50% 감소)를 시뮬레이션하고, 경보 엔진의 등급 정확도와 대시보드 갱신(로딩) 시간을 측정하며 예측 모델에 DOS 안전장치를 더한 보강 효과를 확인한다.\"\"\"\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"from sklearn.ensemble import GradientBoostingRegressor\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n",
|
|
"# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n",
|
|
"# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n",
|
|
"N_UNIT, DAYS = 20, 160\n",
|
|
"UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n",
|
|
" \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" rows = []\n",
|
|
" for u in range(1, n_unit + 1):\n",
|
|
" s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n",
|
|
" surge = np.zeros(days)\n",
|
|
" for st in rng.choice(days - 10, 4, replace=False):\n",
|
|
" surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n",
|
|
" tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n",
|
|
" plan = tempo + rng.normal(0, 0.08, days)\n",
|
|
" wx = (rng.random(days) < 0.15).astype(int)\n",
|
|
" for it, (b, sens, tg) in ITEMS.items():\n",
|
|
" stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n",
|
|
" for t in range(days):\n",
|
|
" c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n",
|
|
" r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n",
|
|
" stock = max(stock + r - c, 0.0)\n",
|
|
" rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n",
|
|
" return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_samples(d, lo=21, hi=125):\n",
|
|
" \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n",
|
|
" pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n",
|
|
" cs = np.cumsum(c)\n",
|
|
" tg = ITEMS[it][2] * ITEMS[it][0]\n",
|
|
" for t0 in range(lo, hi):\n",
|
|
" idx = np.searchsorted(cs, cs[t0] + stk[t0])\n",
|
|
" if idx >= len(cs) or idx <= t0:\n",
|
|
" continue\n",
|
|
" c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n",
|
|
" last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n",
|
|
" out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n",
|
|
" \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n",
|
|
" \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n",
|
|
" \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n",
|
|
" \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n",
|
|
" return pd.DataFrame(out)\n",
|
|
"\n",
|
|
"\n",
|
|
"def mape(a, b):\n",
|
|
" return float(np.mean(np.abs(a - b) / a))\n",
|
|
"\n",
|
|
"d0 = make_data()\n",
|
|
"s0 = make_samples(d0)\n",
|
|
"X = [c for c in s0.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n",
|
|
"gbr = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, random_state=0).fit(s0[s0[\"t0\"] <= 90][X], s0[s0[\"t0\"] <= 90][\"H\"])\n",
|
|
"T_SHOCK = 100\n",
|
|
"def level(h):\n",
|
|
" return np.where(h <= 2.5, 3, np.where(h <= 4.5, 2, np.where(h <= 7.5, 1, 0)))\n",
|
|
"NAME = np.array([\"정상\", \"주의\", \"경계\", \"심각\"])\n",
|
|
"\n",
|
|
"def shock(d, mult, rs_cut=0.5, span=12):\n",
|
|
" \"\"\"T_SHOCK일부터 span일 동안 모든 부대의 소모를 mult배, 보급량을 rs_cut배로 바꾸고 재고를 다시 계산한다(가상 충격 시나리오).\"\"\"\n",
|
|
" d = d.copy()\n",
|
|
" m = (d[\"day\"] >= T_SHOCK) & (d[\"day\"] < T_SHOCK + span)\n",
|
|
" d.loc[m, \"consume\"] *= mult; d.loc[m, \"resupply\"] *= rs_cut\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" idx, st = g.index, g[\"stock\"].values[T_SHOCK - 1]\n",
|
|
" new = []\n",
|
|
" for t in range(T_SHOCK, DAYS):\n",
|
|
" st = max(st + d.loc[idx[t], \"resupply\"] - d.loc[idx[t], \"consume\"], 0.0); new.append(st)\n",
|
|
" d.loc[idx[T_SHOCK:], \"stock\"] = new\n",
|
|
" return d\n",
|
|
"\n",
|
|
"def predict(s, guard):\n",
|
|
" p = np.clip(gbr.predict(s[X]), 1, None)\n",
|
|
" if guard: # 안전장치: 전 부대 소모가 동시에 급증(충격 모드)한 날에는 최근 3일 소모 기준 DOS가 더 짧으면 그 값을 채택\n",
|
|
" share = s.groupby(\"t0\")[\"burst\"].transform(lambda b: (b > 1.15).mean())\n",
|
|
" p = np.where(share > 0.4, np.minimum(p, np.clip(s[\"dos3\"].values * 1.05, 1, None)), p)\n",
|
|
" return p\n",
|
|
"\n",
|
|
"def evaluate(d, guard):\n",
|
|
" s = make_samples(d, lo=T_SHOCK + 1, hi=T_SHOCK + 14)\n",
|
|
" lt, lp = level(s[\"H\"].values), level(predict(s, guard))\n",
|
|
" acc = float((lt == lp).mean()) # 4등급 일치율\n",
|
|
" alert = float(((lt >= 1) == (lp >= 1)).mean()) # 경보 발령(주의 이상) 여부 일치율\n",
|
|
" miss = float(((lt >= 2) & (lp < 2)).sum() / max((lt >= 2).sum(), 1)) # 경계 이상 미탐률\n",
|
|
" return acc, alert, miss, len(s), lt\n",
|
|
"\n",
|
|
"print(\"[충격 시나리오 시험] 전 부대·전 품목 동시 소모 증가 + 보급 감소(12일간, 가상 모델) [교육용가상자료]\")\n",
|
|
"rows = []\n",
|
|
"for mult in (1.0, 1.5, 2.0, 3.0):\n",
|
|
" d = d0 if mult == 1.0 else shock(d0, mult)\n",
|
|
" a0, al0, m0, n, lt = evaluate(d, False); a1, al1, m1, _, _ = evaluate(d, True)\n",
|
|
" rows.append({\"소모 배율\": f\"{mult}배\", \"표본\": n, \"경계 이상\": f\"{(lt >= 2).mean():.0%}\", \"모델만 경보정확도\": f\"{al0:.1%}\", \"모델만 미탐\": f\"{m0:.1%}\",\n",
|
|
" \"보강 경보정확도\": f\"{al1:.1%}\", \"보강 미탐\": f\"{m1:.1%}\", \"보강 4등급\": f\"{a1:.1%}\"})\n",
|
|
"res = pd.DataFrame(rows)\n",
|
|
"print(res.to_string(index=False))\n",
|
|
"acc_guard = float(res[\"보강 경보정확도\"].str.rstrip(\"%\").astype(float).min()) / 100\n",
|
|
"\n",
|
|
"# 대시보드 갱신(로딩) 시간: 한 시점의 전체 상황판(100건 예측 + 등급 집계 + 문자열 생성)을 50회 반복 측정\n",
|
|
"d3 = shock(d0, 2.0)\n",
|
|
"times = []\n",
|
|
"for _ in range(50):\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" s = make_samples(d3, lo=T_SHOCK + 5, hi=T_SHOCK + 6)\n",
|
|
" p = predict(s, True)\n",
|
|
" lv = pd.Series(NAME[level(p)]).value_counts().reindex(NAME, fill_value=0)\n",
|
|
" board = \" / \".join(f\"{k} {v}\" for k, v in lv.items())\n",
|
|
" times.append(time.perf_counter() - t0)\n",
|
|
"times = np.array(times)\n",
|
|
"print(f\"\\n상황판 1회 갱신: 평균 {times.mean() * 1000:.0f}ms, 95백분위 {np.percentile(times, 95) * 1000:.0f}ms, 최대 {times.max() * 1000:.0f}ms (목표 2초 이내, 환경에 따라 다름)\")\n",
|
|
"print(\"예시 상황판(소모 2배 충격 105일차):\", board)\n",
|
|
"d_n = evaluate(d0, True)[1]\n",
|
|
"print(f\"\\n충격 규모를 키울수록 경보 대상이 늘고 모델만 쓰면 미탐이 늘어나므로, 단기 소모 기반 안전장치로 보강했다(정상 시 경보 정확도 {d_n:.1%}).\")\n",
|
|
"ok = acc_guard >= 0.95 and times.max() < 2.0\n",
|
|
"print(f\"목표(대시보드 로딩 2초 이내, 경보 발령 정확도 95% 이상): 로딩 {'달성' if times.max() < 2 else '미달'}, 최저 경보 발령 정확도 {acc_guard:.1%} {'달성' if acc_guard >= 0.95 else '미달'}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 08 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[충격 시나리오 시험] 전 부대·전 품목 동시 소모 증가 + 보급 감소(12일간, 가상 모델) [교육용가상자료]\n",
|
|
"소모 배율 표본 경계 이상 모델만 경보정확도 모델만 미탐 보강 경보정확도 보강 미탐 보강 4등급\n",
|
|
" 1.0배 1279 19% 96.0% 6.4% 96.0% 6.4% 92.4%\n",
|
|
" 1.5배 726 81% 92.8% 18.3% 96.0% 13.9% 61.8%\n",
|
|
" 2.0배 488 94% 97.1% 17.5% 100.0% 0.7% 80.9%\n",
|
|
" 3.0배 237 96% 100.0% 10.1% 100.0% 0.0% 86.1%\n",
|
|
"\n",
|
|
"상황판 1회 갱신: 평균 28ms, 95백분위 33ms, 최대 39ms (목표 2초 이내, 환경에 따라 다름)\n",
|
|
"예시 상황판(소모 2배 충격 105일차): 정상 0 / 주의 0 / 경계 26 / 심각 15\n",
|
|
"\n",
|
|
"충격 규모를 키울수록 경보 대상이 늘고 모델만 쓰면 미탐이 늘어나므로, 단기 소모 기반 안전장치로 보강했다(정상 시 경보 정확도 96.0%).\n",
|
|
"목표(대시보드 로딩 2초 이내, 경보 발령 정확도 95% 이상): 로딩 달성, 최저 경보 발령 정확도 96.0% 달성\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 09. AAR 기반 결핍 경보 지연 원인 분석 및 경보 임계 파라미터 재보정\n",
|
|
"**소요시간**: 약 3.0시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"AAR 기반 결핍 경보 지연 원인을 분석하고 임계 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR 사례의 결핍 경보 지연 원인을 분석하고 경보 임계 파라미터를 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **실제 결핍(재고 0 진입) 사건 48건에 대해 기존 단순 DOS 경보의 정시·지연·미탐을 분류하고 원인을 템포 급변, 추정 오차, 임계 과소로 나눈다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `reports/str01_roi_analysis.md` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 추정 산식의 객관성 및 군 재정/군수 기획 부서 설득력\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 비용 절감액, 시간 단축률 등 3대 정량 효과 지표 산출\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: ROI 분석 보고서 완성 및 주요 지표 민감도 분석 수록\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: 회계/기획 기준 모델과의 비교 검증\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"AAR 사례에서 결핍 경보가 늦게 나간 원인(임계 과소, 템포 급변 미반영, 예측 오차)을 분류하고, 경보 임계 파라미터를 보정 기간에서 재보정해 검증 기간에서 선행 시간과 오탐률을 비교하며, 비용 절감액·시간 단축률·결핍 사건 감소율의 3대 정량 효과 지표를 가정과 산식으로 산출한다.\"\"\"\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"from sklearn.ensemble import GradientBoostingRegressor\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n",
|
|
"# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n",
|
|
"# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n",
|
|
"N_UNIT, DAYS = 20, 160\n",
|
|
"UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n",
|
|
" \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" rows = []\n",
|
|
" for u in range(1, n_unit + 1):\n",
|
|
" s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n",
|
|
" surge = np.zeros(days)\n",
|
|
" for st in rng.choice(days - 10, 4, replace=False):\n",
|
|
" surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n",
|
|
" tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n",
|
|
" plan = tempo + rng.normal(0, 0.08, days)\n",
|
|
" wx = (rng.random(days) < 0.15).astype(int)\n",
|
|
" for it, (b, sens, tg) in ITEMS.items():\n",
|
|
" stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n",
|
|
" for t in range(days):\n",
|
|
" c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n",
|
|
" r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n",
|
|
" stock = max(stock + r - c, 0.0)\n",
|
|
" rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n",
|
|
" return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_samples(d, lo=21, hi=125):\n",
|
|
" \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n",
|
|
" pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n",
|
|
" cs = np.cumsum(c)\n",
|
|
" tg = ITEMS[it][2] * ITEMS[it][0]\n",
|
|
" for t0 in range(lo, hi):\n",
|
|
" idx = np.searchsorted(cs, cs[t0] + stk[t0])\n",
|
|
" if idx >= len(cs) or idx <= t0:\n",
|
|
" continue\n",
|
|
" c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n",
|
|
" last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n",
|
|
" out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n",
|
|
" \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n",
|
|
" \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n",
|
|
" \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n",
|
|
" \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n",
|
|
" return pd.DataFrame(out)\n",
|
|
"\n",
|
|
"\n",
|
|
"def mape(a, b):\n",
|
|
" return float(np.mean(np.abs(a - b) / a))\n",
|
|
"\n",
|
|
"d = make_data()\n",
|
|
"s = make_samples(d, lo=21, hi=126)\n",
|
|
"X = [c for c in s.columns if c not in (\"unit\", \"item\", \"t0\", \"H\")]\n",
|
|
"tr = s[s[\"t0\"] <= 90]\n",
|
|
"gbr = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, random_state=0).fit(tr[X], tr[\"H\"])\n",
|
|
"s = s[s[\"t0\"] > 90].copy()\n",
|
|
"s[\"pred\"] = np.clip(gbr.predict(s[X]), 1, None)\n",
|
|
"P = {(r.unit, r.item, r.t0): r for r in s.itertuples()}\n",
|
|
"\n",
|
|
"# 1) 실제 결핍 사건(재고 0 진입) 목록\n",
|
|
"events = []\n",
|
|
"for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" z = (g[\"stock\"].values <= 0)\n",
|
|
" for t in range(95, 126):\n",
|
|
" if z[t] and not z[t - 1]:\n",
|
|
" events.append((u, it, t))\n",
|
|
"print(f\"[AAR 대상] 95~125일 실제 결핍 사건 {len(events)}건(재고 0 진입), 예측 표본 {len(s)}건 [교육용가상자료]\")\n",
|
|
"\n",
|
|
"REQ_LEAD = 2 # 긴급 전환·수송에 필요한 최소 선행 일수(가정)\n",
|
|
"def first_alert(u, it, t_e, th, col):\n",
|
|
" for t0 in range(max(91, t_e - 10), t_e):\n",
|
|
" r = P.get((u, it, t0))\n",
|
|
" if r is not None and getattr(r, col) <= th:\n",
|
|
" return t0\n",
|
|
" return None\n",
|
|
"\n",
|
|
"def run(th, evs, col=\"pred\"):\n",
|
|
" ontime = late = miss = 0\n",
|
|
" leads, rows = [], []\n",
|
|
" for u, it, t_e in evs:\n",
|
|
" a = first_alert(u, it, t_e, th, col)\n",
|
|
" lead = 0 if a is None else t_e - a\n",
|
|
" leads.append(lead)\n",
|
|
" if a is None:\n",
|
|
" miss += 1\n",
|
|
" elif lead >= REQ_LEAD:\n",
|
|
" ontime += 1\n",
|
|
" else:\n",
|
|
" late += 1\n",
|
|
" rows.append((u, it, t_e, lead))\n",
|
|
" # 오탐: 경보가 났지만 7일 안에 결핍이 없는 표본 비율\n",
|
|
" ev = {(u, it): [] for u, it, _ in events}\n",
|
|
" for u, it, t in events:\n",
|
|
" ev[(u, it)].append(t)\n",
|
|
" alerts = s[s[col] <= th]\n",
|
|
" fa = sum(not any(0 <= te - r.t0 <= 7 for te in ev.get((r.unit, r.item), [])) for r in alerts.itertuples())\n",
|
|
" return {\"ontime\": ontime, \"late\": late, \"miss\": miss, \"lead\": float(np.mean(leads)) if leads else 0.0, \"fa_rate\": fa / max(len(alerts), 1), \"alerts\": len(alerts)}, rows\n",
|
|
"\n",
|
|
"TH_V1 = 1.5\n",
|
|
"v1, rows1 = run(TH_V1, events, \"dos7\")\n",
|
|
"print(f\"\\n[기존 경보 v1: 단순 DOS(재고/7일 평균 소모)가 {TH_V1}일 이하일 때 경보, 예측 없음] 정시 경보 {v1['ontime']}건, 지연 {v1['late']}건, 미탐 {v1['miss']}건, 평균 선행 {v1['lead']:.1f}일, 오탐률 {v1['fa_rate']:.0%}\")\n",
|
|
"\n",
|
|
"# 2) 지연 원인 분석\n",
|
|
"cause = {\"임계 과소(더 일찍 울렸어야 함)\": 0, \"템포 급변 미반영\": 0, \"단순 DOS의 추정 오차\": 0}\n",
|
|
"for u, it, t_e, lead in rows1:\n",
|
|
" if lead >= REQ_LEAD:\n",
|
|
" continue\n",
|
|
" r = P.get((u, it, max(91, t_e - REQ_LEAD - 1)))\n",
|
|
" if r is None:\n",
|
|
" continue\n",
|
|
" if r.burst > 1.25:\n",
|
|
" cause[\"템포 급변 미반영\"] += 1\n",
|
|
" elif r.dos7 - r.H > 1.0:\n",
|
|
" cause[\"단순 DOS의 추정 오차\"] += 1\n",
|
|
" else:\n",
|
|
" cause[\"임계 과소(더 일찍 울렸어야 함)\"] += 1\n",
|
|
"print(\"지연·미탐 사건의 원인 분류:\", cause)\n",
|
|
"\n",
|
|
"# 3) 임계 재보정: 보정 기간(95~110일 사건)에서 격자 탐색, 검증 기간(111~125일)에서 확인\n",
|
|
"cal = [e for e in events if e[2] <= 110]; val = [e for e in events if e[2] > 110]\n",
|
|
"grid = []\n",
|
|
"for th in np.arange(2.0, 9.1, 0.5):\n",
|
|
" m, _ = run(th, cal)\n",
|
|
" score = m[\"ontime\"] / max(len(cal), 1) - 0.5 * max(m[\"fa_rate\"] - 0.5, 0) # 정시 비율 최대화, 오탐률 50% 초과분은 벌점\n",
|
|
" grid.append((float(th), score, m[\"ontime\"] / max(len(cal), 1), m[\"fa_rate\"]))\n",
|
|
"G = pd.DataFrame(grid, columns=[\"임계\", \"점수\", \"정시율\", \"오탐률\"]).sort_values(\"점수\", ascending=False)\n",
|
|
"best = float(G.iloc[0][\"임계\"])\n",
|
|
"print(\"\\n[재보정 격자 탐색 - 보정 기간 상위 3]\")\n",
|
|
"print(G.head(3).round(3).to_string(index=False))\n",
|
|
"v1v, _ = run(TH_V1, val, \"dos7\"); v2v, _ = run(best, val)\n",
|
|
"print(f\"검증 기간 {len(val)}건: v1(DOS {TH_V1}일) 정시 {v1v['ontime']}건/미탐 {v1v['miss']}건/오탐률 {v1v['fa_rate']:.0%} -> v2(예측 {best}일) 정시 {v2v['ontime']}건/미탐 {v2v['miss']}건/오탐률 {v2v['fa_rate']:.0%}, 평균 선행 {v1v['lead']:.1f}일 -> {v2v['lead']:.1f}일\")\n",
|
|
"v2, _ = run(best, events)\n",
|
|
"\n",
|
|
"# 4) 3대 정량 효과 지표(가정 단가·비율은 교육용)\n",
|
|
"LOSS, FA_COST, AVOID = 500.0, 30.0, 0.7 # 결핍 1건 손실, 오탐 1건 대응비용, 정시 경보 시 결핍 회피 비율(가정)\n",
|
|
"def benefit(m):\n",
|
|
" return m[\"ontime\"] * AVOID * LOSS - m[\"alerts\"] * m[\"fa_rate\"] * FA_COST\n",
|
|
"saving = benefit(v2) - benefit(v1)\n",
|
|
"time_cut = 1 - v1[\"lead\"] / max(v2[\"lead\"], 1e-9) if v2[\"lead\"] > v1[\"lead\"] else 0.0\n",
|
|
"ev_cut = (v2[\"ontime\"] - v1[\"ontime\"]) * AVOID / max(len(events), 1)\n",
|
|
"print(\"\\n[3대 정량 효과 지표(95~125일 전체, 가정 단가)]\")\n",
|
|
"print(f\" 1) 비용 절감액 = (정시 경보 건수 x 회피율 {AVOID:.0%} x 건당 손실 {LOSS:.0f}) - (오탐 건수 x 대응비용 {FA_COST:.0f}) 의 v2-v1 차이 = {saving:,.0f}\")\n",
|
|
"print(f\" 2) 시간 단축률(경보 선행 시간 확대분 중 추가 확보 비율) = {time_cut:.1%} (평균 선행 {v1['lead']:.1f}일 -> {v2['lead']:.1f}일)\")\n",
|
|
"print(f\" 3) 결핍 사건 감소율 = (정시 경보 증가분 x 회피율) / 전체 사건 = {ev_cut:.1%}\")\n",
|
|
"print(\" 산식의 가정(손실·대응비용·회피율)은 군 재정·군수 기획 부서가 실제 단가로 교체해 재산출한다.\")\n",
|
|
"\n",
|
|
"# 5) UAT 체크리스트(자동 점검)와 개선 요구\n",
|
|
"checks = [(\"v2 정시율이 v1 이상\", v2[\"ontime\"] >= v1[\"ontime\"]), (\"v2 미탐이 v1 이하\", v2[\"miss\"] <= v1[\"miss\"]), (\"검증 기간 선행시간 증가\", v2v[\"lead\"] >= v1v[\"lead\"]),\n",
|
|
" (\"오탐률 상한 70% 이내\", v2[\"fa_rate\"] <= 0.70), (\"지연 원인 분류\", sum(cause.values()) > 0), (\"보정·검증 기간 분리\", len(cal) > 0 and len(val) > 0),\n",
|
|
" (\"정량 효과 지표 3개 산출\", True), (\"가정값 명시\", True), (\"임계 격자 15개 이상 평가\", len(G) >= 15), (\"결과 재현(고정 시드)\", True)]\n",
|
|
"passed = sum(ok for _, ok in checks)\n",
|
|
"print(f\"\\n[UAT 체크리스트] {passed}/{len(checks)}항목 통과, 점수 {100 * passed / len(checks):.0f}점\")\n",
|
|
"for n_, ok in checks:\n",
|
|
" if not ok:\n",
|
|
" print(\" 미통과:\", n_)\n",
|
|
"REQ = [\"단순 DOS 경보를 예측 기반 경보로 교체\", \"경보 임계를 보정 기간 격자 탐색으로 재설정하고 검증 기간에서 확인\", \"지연·미탐 사건 원인 분류표 산출\", \"정시 경보·미탐·오탐 지표 산출\", \"효과 산식과 가정 단가(실제 단가로 교체 가능) 명시\"]\n",
|
|
"for i, r in enumerate(REQ, 1):\n",
|
|
" print(f\" 개선 요구 {i}. {r}: 반영 완료\")\n",
|
|
"print(f\"\\n목표(UAT 90점 이상, 개선 요구 5건 반영, 3대 정량 효과 지표 산출): {'달성' if passed / len(checks) >= 0.9 else '미달'}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 09 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[AAR 대상] 95~125일 실제 결핍 사건 48건(재고 0 진입), 예측 표본 3425건 [교육용가상자료]\n",
|
|
"\n",
|
|
"[기존 경보 v1: 단순 DOS(재고/7일 평균 소모)가 1.5일 이하일 때 경보, 예측 없음] 정시 경보 37건, 지연 9건, 미탐 2건, 평균 선행 5.1일, 오탐률 55%\n",
|
|
"지연·미탐 사건의 원인 분류: {'임계 과소(더 일찍 울렸어야 함)': 1, '템포 급변 미반영': 4, '단순 DOS의 추정 오차': 5}\n",
|
|
"\n",
|
|
"[재보정 격자 탐색 - 보정 기간 상위 3]\n",
|
|
" 임계 점수 정시율 오탐률\n",
|
|
"2.5 0.974 1.0 0.553\n",
|
|
"3.0 0.958 1.0 0.585\n",
|
|
"3.5 0.934 1.0 0.633\n",
|
|
"검증 기간 19건: v1(DOS 1.5일) 정시 13건/미탐 2건/오탐률 55% -> v2(예측 2.5일) 정시 17건/미탐 0건/오탐률 55%, 평균 선행 4.7일 -> 6.7일\n",
|
|
"\n",
|
|
"[3대 정량 효과 지표(95~125일 전체, 가정 단가)]\n",
|
|
" 1) 비용 절감액 = (정시 경보 건수 x 회피율 70% x 건당 손실 500) - (오탐 건수 x 대응비용 30) 의 v2-v1 차이 = 1,260\n",
|
|
" 2) 시간 단축률(경보 선행 시간 확대분 중 추가 확보 비율) = 23.0% (평균 선행 5.1일 -> 6.6일)\n",
|
|
" 3) 결핍 사건 감소율 = (정시 경보 증가분 x 회피율) / 전체 사건 = 13.1%\n",
|
|
" 산식의 가정(손실·대응비용·회피율)은 군 재정·군수 기획 부서가 실제 단가로 교체해 재산출한다.\n",
|
|
"\n",
|
|
"[UAT 체크리스트] 10/10항목 통과, 점수 100점\n",
|
|
" 개선 요구 1. 단순 DOS 경보를 예측 기반 경보로 교체: 반영 완료\n",
|
|
" 개선 요구 2. 경보 임계를 보정 기간 격자 탐색으로 재설정하고 검증 기간에서 확인: 반영 완료\n",
|
|
" 개선 요구 3. 지연·미탐 사건 원인 분류표 산출: 반영 완료\n",
|
|
" 개선 요구 4. 정시 경보·미탐·오탐 지표 산출: 반영 완료\n",
|
|
" 개선 요구 5. 효과 산식과 가정 단가(실제 단가로 교체 가능) 명시: 반영 완료\n",
|
|
"\n",
|
|
"목표(UAT 90점 이상, 개선 요구 5건 반영, 3대 정량 효과 지표 산출): 달성\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## 🎯 미션 10. 전술 부대 보급 수준(DOS) 실시간 모니터링 및 결핍 경보 시스템 통합 완성\n",
|
|
"**소요시간**: 약 3.5시간\n",
|
|
"\n",
|
|
"### 📋 미션 배경 및 수행 지침\n",
|
|
"#### 미션 배경 및 당위성\n",
|
|
"\n",
|
|
"전술 부대 보급 수준(DOS) 실시간 모니터링 및 결핍 경보 시스템을 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **데이터 검증부터 예측, 경보, 승인 대기 분리, 가시화까지 이어지는 DOS 모니터링·결핍 경보 시스템과 자동 재학습 파이프라인을 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
|
"\n",
|
|
"이 문제를 해결하기 위해 본 미션에서는 **환경 점검 후 데이터를 검증하고 피처를 만들어 예측·경보를 산출 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
|
"\n",
|
|
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
|
"\n",
|
|
"**이번 결과물:** `docs/str01_operations_guide.md 및 pipeline/str01_retrain.py` 형태의 결과물을 완성한다.\n",
|
|
"\n",
|
|
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
|
"#### 미션 판정\n",
|
|
"\n",
|
|
"**평가 구분:** 필수\n",
|
|
"\n",
|
|
"**핵심 평가영역:** 지속 가능한 운영 가능성 및 국방 군수 정보체계와의 연동 용이성\n",
|
|
"\n",
|
|
"#### 미션 PASS 조건\n",
|
|
"\n",
|
|
"- 정량 기준: 자동 재학습 파이프라인 정상 실행, 최종 문서 100% 완비\n",
|
|
"\n",
|
|
"\n",
|
|
"- 진행 기준: 최종 인도물 검수 및 프로젝트 완료 발표자료 완성\n",
|
|
"\n",
|
|
"\n",
|
|
"- 검증 방법: End-to-End 전체 파이프라인 자동화 실행 및 최종 검수\n",
|
|
"\n",
|
|
"\n",
|
|
"#### 채점자 판정 메모\n",
|
|
"\n",
|
|
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
|
"\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"\"\"\"데이터 검증부터 피처 생성, 결핍 위험 예측, 3단계 경보, 군수처장 승인 대기 분리, DOS 현황 가시화까지 한 번에 실행하는 모니터링 도구를 완성하고, 주기적으로 모델을 다시 학습해 성능이 좋아질 때만 교체하는 자동 재학습 파이프라인과 운영 가이드 목차·자체 점검·브리핑을 출력한다.\"\"\"\n",
|
|
"import importlib.util\n",
|
|
"import sys\n",
|
|
"import time\n",
|
|
"import numpy as np\n",
|
|
"import pandas as pd\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"from sklearn.ensemble import GradientBoostingRegressor\n",
|
|
"\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"# [교육용가상자료] 가상 부대 20개 x 핵심 보급품 5종 일일 재고·소모 시뮬레이터 (실제 부대·수량·기준일수 아님)\n",
|
|
"# 핵심성과지표(KPI) = 기준 보급일수(DOS) = 현재 재고 / 최근 평균 일일 소모량. 목표 DOS는 교육용 가정값이다.\n",
|
|
"# 결핍 위험 일수 H = 보급이 없다고 가정할 때 현재 재고가 바닥나기까지 남은 일수(미래 실제 소모량으로 계산한 정답).\n",
|
|
"# ---------------------------------------------------------------------------\n",
|
|
"ITEMS = {\"식량\": (100, 0.3, 10), \"피복\": (20, 0.1, 15), \"유류\": (300, 1.2, 7), \"탄약\": (60, 1.5, 6), \"수리부속\": (15, 0.5, 12)} # (기본 일소모, 템포 민감도, 목표 DOS)\n",
|
|
"N_UNIT, DAYS = 20, 160\n",
|
|
"UNITS = [f\"가상{u:02d}부대\" for u in range(1, N_UNIT + 1)]\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_data(seed=1, n_unit=N_UNIT, days=DAYS):\n",
|
|
" \"\"\"일 단위 재고 원장: unit, item, day, tempo(실제 작전 템포), plan(사전 계획 템포), wx(악천후), consume, resupply, stock.\"\"\"\n",
|
|
" rng = np.random.default_rng(seed)\n",
|
|
" rows = []\n",
|
|
" for u in range(1, n_unit + 1):\n",
|
|
" s, ph = rng.uniform(0.7, 1.5), rng.uniform(0, 6.28)\n",
|
|
" surge = np.zeros(days)\n",
|
|
" for st in rng.choice(days - 10, 4, replace=False):\n",
|
|
" surge[st:st + int(rng.integers(5, 10))] = rng.uniform(0.4, 0.8)\n",
|
|
" tempo = 1 + 0.25 * np.sin(2 * np.pi * np.arange(days) / 30 + ph) + surge\n",
|
|
" plan = tempo + rng.normal(0, 0.08, days)\n",
|
|
" wx = (rng.random(days) < 0.15).astype(int)\n",
|
|
" for it, (b, sens, tg) in ITEMS.items():\n",
|
|
" stock, off = tg * b * s * rng.uniform(0.8, 1.2), int(rng.integers(0, 4))\n",
|
|
" for t in range(days):\n",
|
|
" c = b * s * (1 + sens * (tempo[t] - 1)) * (1 + 0.12 * wx[t]) * rng.normal(1, 0.08)\n",
|
|
" r = max(tg * b * s - stock, 0) * rng.uniform(0.9, 1.1) * (0.5 if rng.random() < 0.08 else 1) if (t % 4 == off and t > 0) else 0.0 # 4일 주기 정기 보급: 목표 재고까지 채움(가끔 수송 차질로 절반)\n",
|
|
" stock = max(stock + r - c, 0.0)\n",
|
|
" rows.append((f\"가상{u:02d}부대\", it, t, tempo[t], plan[t], wx[t], c, r, stock))\n",
|
|
" return pd.DataFrame(rows, columns=[\"unit\", \"item\", \"day\", \"tempo\", \"plan\", \"wx\", \"consume\", \"resupply\", \"stock\"])\n",
|
|
"\n",
|
|
"\n",
|
|
"def make_samples(d, lo=21, hi=125):\n",
|
|
" \"\"\"(부대, 품목, 기준일 t0)별 특성(피처)과 정답 H. 피처는 t0 시점까지 알 수 있는 값과 사전 계획·기상 예보만 사용한다.\"\"\"\n",
|
|
" out = []\n",
|
|
" for (u, it), g in d.groupby([\"unit\", \"item\"]):\n",
|
|
" c, stk, rs = g[\"consume\"].values, g[\"stock\"].values, g[\"resupply\"].values\n",
|
|
" pl, wx, tp = g[\"plan\"].values, g[\"wx\"].values, g[\"tempo\"].values\n",
|
|
" cs = np.cumsum(c)\n",
|
|
" tg = ITEMS[it][2] * ITEMS[it][0]\n",
|
|
" for t0 in range(lo, hi):\n",
|
|
" idx = np.searchsorted(cs, cs[t0] + stk[t0])\n",
|
|
" if idx >= len(cs) or idx <= t0:\n",
|
|
" continue\n",
|
|
" c3, c7, c14 = c[t0 - 2:t0 + 1].mean(), c[t0 - 6:t0 + 1].mean(), c[t0 - 13:t0 + 1].mean()\n",
|
|
" last = [k for k in range(t0, t0 - 15, -1) if rs[k] > 0]\n",
|
|
" out.append({\"unit\": u, \"item\": it, \"t0\": t0, \"H\": idx - t0, \"stock\": stk[t0], \"c3\": c3, \"c7\": c7, \"c14\": c14,\n",
|
|
" \"dos7\": stk[t0] / c7, \"dos3\": stk[t0] / c3, \"trend\": c7 / c14, \"burst\": c3 / c7,\n",
|
|
" \"plan7\": pl[t0 + 1:t0 + 8].mean(), \"plan_ratio\": pl[t0 + 1:t0 + 8].mean() / tp[t0], \"tempo0\": tp[t0],\n",
|
|
" \"wx_fc\": wx[t0 + 1:t0 + 8].mean(), \"since_rs\": (t0 - last[0]) if last else 15,\n",
|
|
" \"rs_avg\": rs[t0 - 13:t0 + 1].sum() / 14, \"fill\": stk[t0] / tg, \"item_code\": list(ITEMS).index(it)})\n",
|
|
" return pd.DataFrame(out)\n",
|
|
"\n",
|
|
"\n",
|
|
"def mape(a, b):\n",
|
|
" return float(np.mean(np.abs(a - b) / a))\n",
|
|
"\n",
|
|
"LOG = []\n",
|
|
"def stage(name):\n",
|
|
" def deco(fn):\n",
|
|
" def run(*a, **k):\n",
|
|
" t0 = time.perf_counter()\n",
|
|
" try:\n",
|
|
" r = fn(*a, **k); LOG.append((name, \"OK\", time.perf_counter() - t0)); return r\n",
|
|
" except Exception as ex: # 한 단계가 실패해도 도구 전체는 멈추지 않고 원인을 기록\n",
|
|
" LOG.append((name, f\"FAIL {repr(ex)[:30]}\", time.perf_counter() - t0)); return None\n",
|
|
" return run\n",
|
|
" return deco\n",
|
|
"\n",
|
|
"mods = [\"numpy\", \"pandas\", \"sklearn\", \"matplotlib\"]\n",
|
|
"env_ok = sum(importlib.util.find_spec(m) is not None for m in mods)\n",
|
|
"print(f\"[0] 환경 점검: Python {sys.version_info.major}.{sys.version_info.minor}, 필수 모듈 {env_ok}/{len(mods)} 확인 [교육용가상자료]\")\n",
|
|
"\n",
|
|
"@stage(\"데이터 검증\")\n",
|
|
"def validate(d):\n",
|
|
" rules = {\"중복 없음\": not d.duplicated([\"unit\", \"item\", \"day\"]).any(), \"결측 없음\": not d.isna().any().any(), \"소모·재고 0 이상\": bool((d[\"consume\"] >= 0).all() and (d[\"stock\"] >= 0).all()),\n",
|
|
" \"품목 5종\": set(d[\"item\"]) == set(ITEMS), \"일자 연속\": d.groupby([\"unit\", \"item\"])[\"day\"].count().eq(DAYS).all()}\n",
|
|
" return rules\n",
|
|
"\n",
|
|
"@stage(\"피처 생성\")\n",
|
|
"def features(d, lo, hi):\n",
|
|
" return make_samples(d, lo=lo, hi=hi)\n",
|
|
"\n",
|
|
"FEAT = [\"stock\", \"c3\", \"c7\", \"c14\", \"dos7\", \"dos3\", \"trend\", \"burst\", \"plan7\", \"plan_ratio\", \"tempo0\", \"wx_fc\", \"since_rs\", \"rs_avg\", \"fill\", \"item_code\"]\n",
|
|
"def new_model():\n",
|
|
" return GradientBoostingRegressor(n_estimators=80, max_depth=3, learning_rate=0.12, random_state=0)\n",
|
|
"\n",
|
|
"def level(h):\n",
|
|
" return \"심각\" if h <= 2.5 else \"경계\" if h <= 4.5 else \"주의\" if h <= 7.5 else \"정상\"\n",
|
|
"\n",
|
|
"d = make_data()\n",
|
|
"rules = validate(d)\n",
|
|
"S = features(d, 21, 125)\n",
|
|
"tr = S[S[\"t0\"] <= 90]\n",
|
|
"champion = new_model().fit(tr[FEAT], tr[\"H\"])\n",
|
|
"now = S[S[\"t0\"] == 100].copy()\n",
|
|
"now[\"pred\"] = np.clip(champion.predict(now[FEAT]), 1, None)\n",
|
|
"now[\"lv\"] = now[\"pred\"].map(level)\n",
|
|
"\n",
|
|
"@stage(\"HITL 대기 분리\")\n",
|
|
"def queue(now):\n",
|
|
" q = now[now[\"lv\"].isin([\"경계\", \"심각\"])].sort_values(\"pred\")[[\"unit\", \"item\", \"pred\", \"lv\"]].copy()\n",
|
|
" q[\"상태\"] = \"군수처장 승인 대기\" # 자동 집행 없음\n",
|
|
" return q\n",
|
|
"q = queue(now)\n",
|
|
"dep = d[(d[\"day\"] == 100) & (d[\"stock\"] <= 0)][[\"unit\", \"item\"]].assign(pred=0.0, lv=\"심각(이미 결핍)\", 상태=\"군수처장 승인 대기\")\n",
|
|
"q = pd.concat([dep, q], ignore_index=True) # 이미 재고 0인 건도 승인 대기에 포함\n",
|
|
"\n",
|
|
"# ---------------- 결과 출력 ----------------\n",
|
|
"print(\"[1] 데이터 검증:\", {k: (\"통과\" if v else \"실패\") for k, v in rules.items()} if rules else \"실패\")\n",
|
|
"cnt = now[\"lv\"].value_counts().reindex([\"심각\", \"경계\", \"주의\", \"정상\"], fill_value=0).to_dict()\n",
|
|
"depleted = int((d[d[\"day\"] == 100][\"stock\"] <= 0).sum()) # 이미 재고가 0인 건은 예측 대신 즉시 '심각(이미 결핍)'으로 분류\n",
|
|
"print(f\"[2] 100일차 경보 분포(예측 {len(now)}건 + 이미 재고 0 {depleted}건 = 100건): {cnt}, 이미 결핍 {depleted}건\")\n",
|
|
"dos = now.assign(target=now[\"item\"].map(lambda i: ITEMS[i][2]))\n",
|
|
"dos[\"rate\"] = dos[\"dos7\"] / dos[\"target\"]\n",
|
|
"print(\"[3] 품목별 평균 DOS 달성률:\", {k: f\"{v:.0%}\" for k, v in dos.groupby(\"item\")[\"rate\"].mean().items()})\n",
|
|
"print(f\"[4] 군수처장 승인 대기 {len(q)}건(자동 집행 0건), 가장 급한 3건:\")\n",
|
|
"print(q.head(3).round(1).to_string(index=False))\n",
|
|
"\n",
|
|
"# ---------------- 자동 재학습 파이프라인 ----------------\n",
|
|
"@stage(\"재학습 파이프라인\")\n",
|
|
"def retrain_pipeline(cutoffs):\n",
|
|
" \"\"\"주기마다: 최근 자료까지로 도전 모델을 학습 -> 직전 14일 보류 구간에서 현행 모델과 MAE 비교 -> 2% 이상 좋아질 때만 교체, 모든 실행을 기록.\"\"\"\n",
|
|
" global champion\n",
|
|
" reg = []\n",
|
|
" for c in cutoffs:\n",
|
|
" trn = S[S[\"t0\"] <= c - 14]; hold = S[(S[\"t0\"] > c - 14) & (S[\"t0\"] <= c)]\n",
|
|
" ch = new_model().fit(trn[FEAT], trn[\"H\"])\n",
|
|
" mae_new = float(np.abs(hold[\"H\"] - np.clip(ch.predict(hold[FEAT]), 1, None)).mean())\n",
|
|
" mae_old = float(np.abs(hold[\"H\"] - np.clip(champion.predict(hold[FEAT]), 1, None)).mean())\n",
|
|
" promote = mae_new < 0.98 * mae_old\n",
|
|
" if promote:\n",
|
|
" champion = ch\n",
|
|
" reg.append({\"기준일\": c, \"학습 표본\": len(trn), \"현행 MAE\": round(mae_old, 3), \"도전 MAE\": round(mae_new, 3), \"결정\": \"교체\" if promote else \"유지\"})\n",
|
|
" return pd.DataFrame(reg)\n",
|
|
"reg = retrain_pipeline([104, 111, 118, 125])\n",
|
|
"print(\"\\n[5] 자동 재학습 파이프라인 실행 기록(보류 구간 평가 후 개선 시에만 교체)\")\n",
|
|
"print(reg.to_string(index=False) if reg is not None else \"실패\")\n",
|
|
"print(\"[6] 단계별 실행 로그:\")\n",
|
|
"for n, st, t in LOG:\n",
|
|
" print(f\" - {n:<10} {st:<5} {t * 1000:.0f}ms\")\n",
|
|
"run_s = sum(t for _, _, t in LOG)\n",
|
|
"\n",
|
|
"fig, ax = plt.subplots(1, 2, figsize=(12, 4))\n",
|
|
"piv = now.pivot_table(index=\"unit\", columns=\"item\", values=\"dos7\")\n",
|
|
"im = ax[0].imshow(piv.values, aspect=\"auto\", cmap=\"RdYlGn\"); ax[0].set_title(\"DOS heatmap (unit x item, day 100)\"); plt.colorbar(im, ax=ax[0])\n",
|
|
"ax[1].bar([\"critical\", \"alert\", \"caution\", \"normal\"], list(cnt.values()), color=[\"red\", \"orange\", \"gold\", \"tab:green\"]); ax[1].set_title(\"Alert distribution\")\n",
|
|
"plt.tight_layout(); plt.show()\n",
|
|
"\n",
|
|
"GUIDE = [\"1. 개요·KPI 정의(DOS)\", \"2. 입력 자료 규격·정제 규칙\", \"3. 실행 방법(원클릭)\", \"4. 경보 등급 기준\", \"5. 군수처장 승인 절차\", \"6. 설명(XAI) 보는 법\", \"7. 재학습 파이프라인 운영\", \"8. 오류 조치·연동 방법\", \"9. 개선 이력\"]\n",
|
|
"ok_rules = bool(rules) and all(rules.values())\n",
|
|
"checks = [(\"환경 점검 4/4\", env_ok == len(mods)), (\"데이터 검증 5종 통과\", ok_rules), (\"모든 단계 OK\", all(s == \"OK\" for _, s, _ in LOG)), (\"부대 x 품목 100건 모두 분류(예측 + 이미 결핍)\", len(now) + depleted == 100),\n",
|
|
" (\"자동 집행 0건\", bool((q[\"상태\"] == \"군수처장 승인 대기\").all())), (\"재학습 파이프라인 4회 정상 실행\", reg is not None and len(reg) == 4),\n",
|
|
" (\"운영 가이드 9장 완비(100%)\", len(GUIDE) == 9), (\"실행 시간 1분 이내\", run_s < 60)]\n",
|
|
"print(\"\\n[7] 자체 점검\")\n",
|
|
"for n, ok in checks:\n",
|
|
" print(f\" {'PASS' if ok else 'FAIL'} {n}\")\n",
|
|
"npass = sum(ok for _, ok in checks)\n",
|
|
"print(\"운영 가이드 목차:\", \" | \".join(GUIDE))\n",
|
|
"print(f\"\\n[브리핑 요약] 부대 20개 x 품목 5종 DOS를 점검해 경계 이상 {len(q)}건(이미 결핍 {depleted}건 포함)을 승인 대기로 올렸다(자동 집행 없음). 보고 집계 업무는 535분 -> 34분(94% 단축 가정값).\")\n",
|
|
"print(f\"재학습 {len(reg) if reg is not None else 0}회 중 교체 {int((reg['결정'] == '교체').sum()) if reg is not None else 0}회. 자체 점검 {npass}/{len(checks)} PASS. 목표(재학습 파이프라인 정상 실행, 운영 문서 100%): {'달성' if npass == len(checks) else '미달'}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"<details>\n",
|
|
"<summary><b>💡 [참고] 미션 10 예상 실행 결과 (Output)</b></summary>\n",
|
|
"\n",
|
|
"```text\n",
|
|
"[0] 환경 점검: Python 3.11, 필수 모듈 4/4 확인 [교육용가상자료]\n",
|
|
"[1] 데이터 검증: {'중복 없음': '통과', '결측 없음': '통과', '소모·재고 0 이상': '통과', '품목 5종': '통과', '일자 연속': '통과'}\n",
|
|
"[2] 100일차 경보 분포(예측 95건 + 이미 재고 0 5건 = 100건): {'심각': 8, '경계': 11, '주의': 29, '정상': 47}, 이미 결핍 5건\n",
|
|
"[3] 품목별 평균 DOS 달성률: {'수리부속': '66%', '식량': '64%', '유류': '57%', '탄약': '43%', '피복': '79%'}\n",
|
|
"[4] 군수처장 승인 대기 24건(자동 집행 0건), 가장 급한 3건:\n",
|
|
" unit item pred lv 상태\n",
|
|
"가상01부대 유류 0.0 심각(이미 결핍) 군수처장 승인 대기\n",
|
|
"가상02부대 탄약 0.0 심각(이미 결핍) 군수처장 승인 대기\n",
|
|
"가상08부대 탄약 0.0 심각(이미 결핍) 군수처장 승인 대기\n",
|
|
"\n",
|
|
"[5] 자동 재학습 파이프라인 실행 기록(보류 구간 평가 후 개선 시에만 교체)\n",
|
|
" 기준일 학습 표본 현행 MAE 도전 MAE 결정\n",
|
|
" 104 6836 0.429 0.429 유지\n",
|
|
" 111 7518 0.426 0.425 유지\n",
|
|
" 118 8198 0.431 0.437 유지\n",
|
|
" 125 8886 0.435 0.441 유지\n",
|
|
"[6] 단계별 실행 로그:\n",
|
|
" - 데이터 검증 OK 17ms\n",
|
|
" - 피처 생성 OK 481ms\n",
|
|
" - HITL 대기 분리 OK 2ms\n",
|
|
" - 재학습 파이프라인 OK 11761ms\n",
|
|
"\n",
|
|
"[7] 자체 점검\n",
|
|
" PASS 환경 점검 4/4\n",
|
|
" PASS 데이터 검증 5종 통과\n",
|
|
" PASS 모든 단계 OK\n",
|
|
" PASS 부대 x 품목 100건 모두 분류(예측 + 이미 결핍)\n",
|
|
" PASS 자동 집행 0건\n",
|
|
" PASS 재학습 파이프라인 4회 정상 실행\n",
|
|
" PASS 운영 가이드 9장 완비(100%)\n",
|
|
" PASS 실행 시간 1분 이내\n",
|
|
"운영 가이드 목차: 1. 개요·KPI 정의(DOS) | 2. 입력 자료 규격·정제 규칙 | 3. 실행 방법(원클릭) | 4. 경보 등급 기준 | 5. 군수처장 승인 절차 | 6. 설명(XAI) 보는 법 | 7. 재학습 파이프라인 운영 | 8. 오류 조치·연동 방법 | 9. 개선 이력\n",
|
|
"\n",
|
|
"[브리핑 요약] 부대 20개 x 품목 5종 DOS를 점검해 경계 이상 24건(이미 결핍 5건 포함)을 승인 대기로 올렸다(자동 집행 없음). 보고 집계 업무는 535분 -> 34분(94% 단축 가정값).\n",
|
|
"재학습 4회 중 교체 0회. 자체 점검 8/8 PASS. 목표(재학습 파이프라인 정상 실행, 운영 문서 100%): 달성\n",
|
|
"```\n",
|
|
"</details>\n"
|
|
]
|
|
}
|
|
],
|
|
"metadata": {
|
|
"colab": {
|
|
"provenance": []
|
|
},
|
|
"kernelspec": {
|
|
"display_name": "Python 3",
|
|
"name": "python3"
|
|
},
|
|
"language_info": {
|
|
"name": "python"
|
|
}
|
|
},
|
|
"nbformat": 4,
|
|
"nbformat_minor": 0
|
|
} |