Files
pbl02/uploads/MNT06/MNT06_solution.ipynb
2026-10-08 17:15:05 +09:00

2723 lines
212 KiB
Plaintext

{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 🎯 [MNT06] AI 기반 부품 폐기율 산정·예측 모델\n",
"### Google Colab 실습 환경 (장비정비·수리부속 / Level 3)\n",
"\n",
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/MNT06/MNT06_solution.ipynb)\n",
"\n",
"---\n",
"\n",
"## 📌 1. 프로젝트 개요 및 배경\n",
"\n",
"| 항목 | 내용 |\n",
"| :--- | :--- |\n",
"| **과정 분류** | 장비정비·수리부속 (Level 3) |\n",
"| **학습 소요 시간** | 10개 미션 (5일 / 총 28.5시간) |\n",
"| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n",
"\n",
"### 📖 프로젝트 목표\n",
"수리부속의 폐기율이 높으면 정비비용과 재고 부담이 커지고, 조달이 늦어지는 동안 장비는 가동하지 못한다. 폐기가 잦은 부품 가운데 야전에서 3D 프린팅으로 대체 제작할 수 있는 부품을 가려내면 폐기 손실과 조달 대기를 함께 줄일 수 있다. 본 프로젝트는 부품 폐기율의 산정·예측을 대체 제작 타당성 판단과 연결하는 것을 목표로, 교육용 가상 부품 자료(CAD 요약 치수, 금속·폴리머 소재 물성, 사용 하중, 폐기율, 조달 소요일)를 코드로 만들어 분석한다. 데이터 정의와 메쉬·슬라이싱 전처리, 형상 기반 Baseline과 탐색적 분석, 인장강도·피로 수명 예측과 군 규격 부합 신뢰도 점수, 대시보드와 설명가능성, 정비기술관 승인(HITL) 시뮬레이터, 50종 일괄 평가 스트레스 시험, 파괴 시험 대비 AAR 재보정과 효과 산출, 재학습을 포함한 통합 도구까지 단계별로 구현한다. 최종 목표는 폐기 손실이 큰 부품 중 신뢰성이 확인된 대체 제작 후보를 근거와 함께 제시하고, 실제 장착 여부는 정비기술관이 결정하도록 지원하는 것이다.\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. 3D 프린팅 제작 대상 군수 부품 3D CAD 모델 및 금속/폴리머 소재 물성 데이터 정의\n",
"**소요시간**: 약 1.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"3D 프린팅 대상 군수 부품 3D CAD 모델 및 소재 물성 데이터를 정의한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **폐기율이 높은 수리부속 가운데 3D 프린팅 대체 제작 후보의 3D CAD 모델 요약 정보와 금속·폴리머 소재 물성 데이터를 정의한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **폐기 부품 식별부터 CAD 확보, 소재 선정, 제작 가능성 판별, 신뢰성 평가, 정비기술관 승인, 장착 시험, 사후검토까지 업무 흐름을 단계별로 정리하고 부품·CAD·소재·사용 조건·결과 다섯 묶음으로 피처 20개 이상 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `docs/mnt06_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": [
"\"\"\"폐기율이 높은 수리부속의 야전 3D 프린팅 대체 제작 판단에 필요한 3D CAD 모델·금속/폴리머 소재 물성·사용 조건 데이터의 스키마와 데이터 딕셔너리(피처 20개 이상), 업무 프로세스 맵을 정의하고 가상 샘플을 검증한다.\"\"\"\n",
"import json\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"# 1) 업무 프로세스 맵: 폐기 부품 식별에서 대체 제작 승인·효과 확인까지\n",
"PROCESS = [(\"폐기 부품 식별\", \"부품 코드, 월 폐기수량, 폐기율, 조달 소요일\"), (\"CAD 확보\", \"CAD 파일 형식, 삼각형 수, 단위, 치수\"),\n",
" (\"소재 선정\", \"소재 코드, 공정 구분, 인장강도 기준값, 인증서 여부\"), (\"제작 가능성 판별\", \"벽두께, 돌출 비율, 최대 치수, 나사부\"),\n",
" (\"신뢰성 평가\", \"사용 하중, 안전율, 피로 수명, 군 규격 부합 점수\"), (\"정비기술관 승인\", \"승인·반려·보류, 사유\"),\n",
" (\"출력·후처리\", \"출력 시간, 소재량, 후처리 항목\"), (\"실 장착·시험\", \"장착 부대, 시험 결과, 파괴시험 강도\"), (\"사후검토(AAR)\", \"예측 오차, 폐기율 변화, 보정 이력\")]\n",
"\n",
"# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명\n",
"S = {\n",
" \"part\": {\n",
" \"part_id\": (str, {\"pattern\": r\"^P\\d{3}$\"}, \"부품 ID(가상)\"),\n",
" \"item_code\": (str, {\"pattern\": r\"^VX-\\d{4}-\\d{4}$\"}, \"부품 식별코드(국방 표준 군수 코드 형식을 모사한 가상 코드)\"),\n",
" \"fsc_group\": (str, {\"pattern\": r\"^\\d{4}$\"}, \"보급 분류군(가상 4자리)\"),\n",
" \"category\": (str, {\"enum\": [\"bracket\", \"housing\", \"clip\", \"gear\", \"duct\", \"handle\"]}, \"부품군\"),\n",
" \"crit\": (int, {\"enum\": [1, 2, 3]}, \"작전 중요도(1 일반, 2 중요, 3 핵심)\"),\n",
" \"scrap_rate\": (float, {\"min\": 0, \"max\": 1}, \"월 폐기율(폐기수량/소요수량) - 예측대상 1\"),\n",
" \"lead_days\": (float, {\"min\": 0, \"max\": 400}, \"기존 조달 소요일\"),\n",
" },\n",
" \"cad\": {\n",
" \"cad_format\": (str, {\"enum\": [\"stl\", \"step\", \"obj\"]}, \"CAD 파일 형식\"),\n",
" \"unit\": (str, {\"enum\": [\"mm\"]}, \"치수 단위(mm로 통일)\"),\n",
" \"volume_cm3\": (float, {\"min\": 0.5, \"max\": 1000}, \"부피\"),\n",
" \"max_dim_mm\": (float, {\"min\": 5, \"max\": 400}, \"최대 치수\"),\n",
" \"min_wall_mm\": (float, {\"min\": 0.2, \"max\": 20}, \"최소 벽두께\"),\n",
" \"overhang\": (float, {\"min\": 0, \"max\": 1}, \"45도 초과 돌출면 비율(지지대 필요 정도)\"),\n",
" \"n_triangles\": (int, {\"min\": 100, \"max\": 5000000}, \"메쉬 삼각형 수\"),\n",
" \"thread\": (bool, {}, \"나사부 포함 여부\"),\n",
" },\n",
" \"material\": {\n",
" \"material\": (str, {\"enum\": [\"PA12\", \"PETG\", \"CF-PA\", \"AlSi10Mg\", \"SS316L\", \"Ti64\"]}, \"소재 코드\"),\n",
" \"process\": (str, {\"enum\": [\"polymer\", \"metal\"]}, \"공정 구분(폴리머 압출·분말 / 금속 분말 용융)\"),\n",
" \"base_strength\": (float, {\"min\": 10, \"max\": 1500}, \"소재 기준 인장강도(MPa, 가상)\"),\n",
" \"cert\": (bool, {}, \"소재 인증서 보유 여부\"),\n",
" },\n",
" \"use\": {\n",
" \"load_N\": (float, {\"min\": 1, \"max\": 30000}, \"사용 하중(N)\"),\n",
" \"temp_C\": (float, {\"min\": -40, \"max\": 150}, \"사용 온도\"),\n",
" \"orient_deg\": (float, {\"min\": 0, \"max\": 90}, \"출력 방향 각도(적층 방향 대비)\"),\n",
" },\n",
" \"result\": {\n",
" \"printable\": (bool, {}, \"시험 출력 성공 여부 - 예측대상 2\"),\n",
" \"strength_mpa\": (float, {\"min\": 0, \"max\": 1500}, \"실측 인장강도 - 예측대상 3\"),\n",
" \"log_cycles\": (float, {\"min\": 0, \"max\": 9}, \"피로 수명 log10(회)\"),\n",
" \"conform\": (float, {\"min\": 0, \"max\": 100}, \"군 규격 부합 신뢰도 점수 - 예측대상 4\"),\n",
" },\n",
"}\n",
"\n",
"\n",
"def check(name, v, typ, rule):\n",
" if not isinstance(v, typ) or (typ in (int, float) and isinstance(v, bool)):\n",
" return [f\"{name}: 자료형 오류({type(v).__name__})\"]\n",
" e = []\n",
" if \"enum\" in rule and v not in rule[\"enum\"]:\n",
" e.append(f\"{name}: 허용되지 않은 값 '{v}'\")\n",
" if \"min\" in rule and v < rule[\"min\"]:\n",
" e.append(f\"{name}: 최소값 {rule['min']} 미만\")\n",
" if \"max\" in rule and v > rule[\"max\"]:\n",
" e.append(f\"{name}: 최대값 {rule['max']} 초과\")\n",
" if \"pattern\" in rule and not re.match(rule[\"pattern\"], v):\n",
" e.append(f\"{name}: 형식 불일치 '{v}'\")\n",
" return e\n",
"\n",
"\n",
"def validate(rec):\n",
" errs = []\n",
" for g, fields in S.items():\n",
" for k, (typ, rule, _) in fields.items():\n",
" if k not in rec.get(g, {}):\n",
" errs.append(f\"{g}.{k}: 필수 필드 누락\")\n",
" else:\n",
" errs += check(f\"{g}.{k}\", rec[g][k], typ, rule)\n",
" return errs\n",
"\n",
"\n",
"# 3) [교육용가상자료] 샘플 20건\n",
"rng = np.random.default_rng(1)\n",
"MATS = {\"PA12\": (\"polymer\", 45), \"PETG\": (\"polymer\", 50), \"CF-PA\": (\"polymer\", 70), \"AlSi10Mg\": (\"metal\", 350), \"SS316L\": (\"metal\", 520), \"Ti64\": (\"metal\", 900)}\n",
"recs = []\n",
"for i in range(20):\n",
" m = str(rng.choice(list(MATS)))\n",
" recs.append({\n",
" \"part\": {\"part_id\": f\"P{i:03d}\", \"item_code\": f\"VX-{rng.integers(1000, 9999)}-{rng.integers(1000, 9999)}\", \"fsc_group\": f\"{rng.integers(1000, 9999)}\",\n",
" \"category\": str(rng.choice([\"bracket\", \"housing\", \"clip\", \"gear\", \"duct\", \"handle\"])), \"crit\": int(rng.integers(1, 4)),\n",
" \"scrap_rate\": float(round(rng.uniform(0.02, 0.3), 3)), \"lead_days\": float(round(rng.uniform(10, 120), 0))},\n",
" \"cad\": {\"cad_format\": str(rng.choice([\"stl\", \"step\", \"obj\"])), \"unit\": \"mm\", \"volume_cm3\": float(round(rng.uniform(5, 200), 1)),\n",
" \"max_dim_mm\": float(round(rng.uniform(20, 180), 1)), \"min_wall_mm\": float(round(rng.uniform(0.8, 6), 1)),\n",
" \"overhang\": float(round(rng.uniform(0, 0.5), 2)), \"n_triangles\": int(rng.integers(2000, 400000)), \"thread\": bool(rng.random() < 0.25)},\n",
" \"material\": {\"material\": m, \"process\": MATS[m][0], \"base_strength\": float(MATS[m][1]), \"cert\": bool(rng.random() < 0.5)},\n",
" \"use\": {\"load_N\": float(round(rng.uniform(50, 3000), 0)), \"temp_C\": float(round(rng.uniform(-10, 80), 0)), \"orient_deg\": float(round(rng.uniform(0, 90), 0))},\n",
" \"result\": {\"printable\": bool(rng.random() < 0.8), \"strength_mpa\": float(round(MATS[m][1] * rng.uniform(0.6, 1.0), 1)),\n",
" \"log_cycles\": float(round(rng.uniform(3, 7.5), 2)), \"conform\": float(round(rng.uniform(30, 98), 1))}})\n",
"\n",
"ok = sum(not validate(r) for r in recs)\n",
"print(f\"정상 샘플 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건\")\n",
"\n",
"# 4) 오류 주입 시험\n",
"bad = json.loads(json.dumps(recs[0]))\n",
"bad[\"cad\"][\"min_wall_mm\"] = 0.0 # 허용 범위 미만(0 이하 벽두께)\n",
"bad[\"cad\"][\"unit\"] = \"inch\" # 단위 혼재\n",
"bad[\"part\"][\"item_code\"] = \"1234567890123\" # 코드 형식 오류\n",
"del bad[\"use\"][\"load_N\"] # 사용 하중 누락\n",
"bad[\"result\"][\"conform\"] = \"높음\" # 자료형 오류(문자열)\n",
"print(\"\\n[오류 주입 결과]\")\n",
"for e in validate(bad):\n",
" print(\" -\", e)\n",
"\n",
"# 5) 프로세스 맵과 데이터 딕셔너리\n",
"print(\"\\n[업무 프로세스 맵]\")\n",
"print(\" -> \".join(f\"{i}.{p}\" for i, (p, _) in enumerate(PROCESS, 1)))\n",
"for p, d in PROCESS:\n",
" print(f\" {p:10s}: {d}\")\n",
"rows = [(g, k, t.__name__, str(r) if r else \"-\", d) for g, f in S.items() for k, (t, r, d) in f.items()]\n",
"dd = pd.DataFrame(rows, columns=[\"그룹\", \"필드\", \"자료형\", \"규칙\", \"설명\"])\n",
"print(f\"\\n[데이터 딕셔너리] 피처 {len(dd)}개 (목표 20개 이상: {'달성' if len(dd) >= 20 else '미달'})\")\n",
"print(dd[[\"그룹\", \"필드\", \"자료형\", \"설명\"]].to_string(index=False))\n",
"print(\"\\n예측 대상: 월 폐기율, 시험 출력 성공 여부, 인장강도, 군 규격 부합 점수. 최종 활용: 폐기율이 높고 대체 제작이 가능한 부품의 우선 선정\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 01 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"정상 샘플 20건 중 검증 통과 20건, 오류 0건\n",
"\n",
"[오류 주입 결과]\n",
" - part.item_code: 형식 불일치 '1234567890123'\n",
" - cad.unit: 허용되지 않은 값 'inch'\n",
" - cad.min_wall_mm: 최소값 0.2 미만\n",
" - use.load_N: 필수 필드 누락\n",
" - result.conform: 자료형 오류(str)\n",
"\n",
"[업무 프로세스 맵]\n",
"1.폐기 부품 식별 -> 2.CAD 확보 -> 3.소재 선정 -> 4.제작 가능성 판별 -> 5.신뢰성 평가 -> 6.정비기술관 승인 -> 7.출력·후처리 -> 8.실 장착·시험 -> 9.사후검토(AAR)\n",
" 폐기 부품 식별 : 부품 코드, 월 폐기수량, 폐기율, 조달 소요일\n",
" CAD 확보 : CAD 파일 형식, 삼각형 수, 단위, 치수\n",
" 소재 선정 : 소재 코드, 공정 구분, 인장강도 기준값, 인증서 여부\n",
" 제작 가능성 판별 : 벽두께, 돌출 비율, 최대 치수, 나사부\n",
" 신뢰성 평가 : 사용 하중, 안전율, 피로 수명, 군 규격 부합 점수\n",
" 정비기술관 승인 : 승인·반려·보류, 사유\n",
" 출력·후처리 : 출력 시간, 소재량, 후처리 항목\n",
" 실 장착·시험 : 장착 부대, 시험 결과, 파괴시험 강도\n",
" 사후검토(AAR) : 예측 오차, 폐기율 변화, 보정 이력\n",
"\n",
"[데이터 딕셔너리] 피처 26개 (목표 20개 이상: 달성)\n",
" 그룹 필드 자료형 설명\n",
" part part_id str 부품 ID(가상)\n",
" part item_code str 부품 식별코드(국방 표준 군수 코드 형식을 모사한 가상 코드)\n",
" part fsc_group str 보급 분류군(가상 4자리)\n",
" part category str 부품군\n",
" part crit int 작전 중요도(1 일반, 2 중요, 3 핵심)\n",
" part scrap_rate float 월 폐기율(폐기수량/소요수량) - 예측대상 1\n",
" part lead_days float 기존 조달 소요일\n",
" cad cad_format str CAD 파일 형식\n",
" cad unit str 치수 단위(mm로 통일)\n",
" cad volume_cm3 float 부피\n",
" cad max_dim_mm float 최대 치수\n",
" cad min_wall_mm float 최소 벽두께\n",
" cad overhang float 45도 초과 돌출면 비율(지지대 필요 정도)\n",
" cad n_triangles int 메쉬 삼각형 수\n",
" cad thread bool 나사부 포함 여부\n",
"material material str 소재 코드\n",
"material process str 공정 구분(폴리머 압출·분말 / 금속 분말 용융)\n",
"... (이하 생략)\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 02. CAD 파일 메쉬 정제, 슬라이싱 데이터 파싱 및 제작 규격 전처리 모듈 구현\n",
"**소요시간**: 약 2.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"CAD 메쉬 정제 및 슬라이싱 데이터 파싱 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **CAD 메쉬 정제와 슬라이싱 데이터 파싱, 부품 이력 레코드 정제를 수행하는 제작 규격 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **삼각형 메쉬의 NaN 정점·퇴화 삼각형·중복 삼각형을 제거하고 뒤집힌 법선을 바로잡아 부피가 기준과 일치하는지 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/etl/mnt06_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": [
"\"\"\"부품 이력 레코드(표기·단위·중복·결측 오류), CAD 삼각형 메쉬(NaN 정점·퇴화·중복·뒤집힌 법선), 슬라이싱(G-code류) 출력 파일의 불량 라인이 섞인 가상 원천 자료에 정제 규칙 12종을 적용하고, 긴급 제작 예외 레코드는 보존하면서 정합성 검증 통과율과 정제 전후 통계를 확인한다.\"\"\"\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.impute import KNNImputer\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)\n",
"# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.\n",
"# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.\n",
"# ---------------------------------------------------------------------------\n",
"MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)\n",
" \"PA12\": (\"polymer\", 45, 1.0, 8, 3, 0.8, 18), \"PETG\": (\"polymer\", 50, 1.27, 5, 2, 0.8, 15),\n",
" \"CF-PA\": (\"polymer\", 70, 1.2, 14, 3, 1.0, 12), \"AlSi10Mg\": (\"metal\", 350, 2.67, 30, 25, 0.5, 4),\n",
" \"SS316L\": (\"metal\", 520, 7.9, 28, 25, 0.6, 3.5), \"Ti64\": (\"metal\", 900, 4.4, 90, 30, 0.6, 3)}\n",
"CATS = [\"bracket\", \"housing\", \"clip\", \"gear\", \"duct\", \"handle\"]\n",
"CAT_SCRAP = {\"bracket\": 0.04, \"housing\": 0.05, \"clip\": 0.12, \"gear\": 0.09, \"duct\": 0.08, \"handle\": 0.10} # 부품군별 기본 월 폐기율\n",
"BUILD_MM = {\"polymer\": 300, \"metal\": 200} # 가상 출력 가능 최대 치수\n",
"\n",
"\n",
"def make_parts(n=300, seed=0):\n",
" \"\"\"가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])\n",
" proc = np.array([MAT[m][0] for m in mat])\n",
" vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)\n",
" aspect = rng.lognormal(0.3, 0.4, n)\n",
" max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)\n",
" wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)\n",
" ovh = rng.beta(2, 5, n)\n",
" is_poly = proc == \"polymer\"\n",
" df = pd.DataFrame({\n",
" \"part_id\": [f\"P{i:03d}\" for i in range(n)], \"cat\": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), \"material\": mat, \"process\": proc,\n",
" \"volume_cm3\": vol, \"max_dim_mm\": max_dim, \"area_cm2\": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), \"min_wall_mm\": wall, \"overhang\": ovh,\n",
" \"n_holes\": rng.poisson(2.0, n), \"thread\": rng.random(n) < 0.25, \"orient_deg\": rng.uniform(0, 90, n),\n",
" \"layer_mm\": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),\n",
" \"infill\": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),\n",
" \"load_N\": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), \"temp_C\": rng.uniform(-20, 90, n),\n",
" \"crit\": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), \"cert\": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유\n",
" \"import_part\": rng.random(n) < 0.4})\n",
" df[\"scrap_rate\"] = np.clip(df[\"cat\"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df[\"load_N\"] > 800)) * (1 + 0.3 * (df[\"temp_C\"] > 60)), 0.005, 0.5)\n",
" df[\"lead_days\"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df[\"import_part\"], 2.2, 1.0), 5, 300)\n",
" # ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----\n",
" eff = np.where(is_poly, 0.55 + 0.45 * df[\"infill\"] / 100, 1 - 0.3 * ovh * 0.2)\n",
" aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df[\"orient_deg\"])) ** 2, 1 - 0.08 * np.sin(np.radians(df[\"orient_deg\"])))\n",
" heat = np.where(is_poly, 1 - 0.35 * np.clip((df[\"temp_C\"] - 40) / 80, 0, 1), 0.98)\n",
" base = np.array([MAT[m][1] for m in mat])\n",
" df[\"strength_mpa\"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)\n",
" cross = df[\"min_wall_mm\"] * np.sqrt(df[\"max_dim_mm\"]) * 1.0 # 하중 지지 단면적 mm2(단순화)\n",
" df[\"stress_mpa\"] = df[\"load_N\"] / cross\n",
" df[\"safety\"] = df[\"strength_mpa\"] / df[\"stress_mpa\"]\n",
" ratio = np.clip(df[\"stress_mpa\"] / df[\"strength_mpa\"], 0, 1.5)\n",
" df[\"log_cycles\"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)\n",
" support = 1 + 0.5 * ovh\n",
" df[\"build_h\"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5\n",
" mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000\n",
" df[\"print_cost\"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df[\"build_h\"] * np.array([MAT[m][4] for m in mat]) + 3\n",
" df[\"buy_cost\"] = df[\"print_cost\"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)\n",
" wall_ok = df[\"min_wall_mm\"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)\n",
" dim_ok = df[\"max_dim_mm\"] <= np.where(is_poly, BUILD_MM[\"polymer\"], BUILD_MM[\"metal\"])\n",
" warp = (~is_poly) & (df[\"max_dim_mm\"] > 140) & (df[\"overhang\"] > 0.3) # 금속 대형 오버행 변형\n",
" thr_bad = df[\"thread\"] & (df[\"min_wall_mm\"] < 1.5) & is_poly # 얇은 나사부 파손\n",
" df[\"printable\"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad\n",
" risk = np.clip(1 - df[\"safety\"] / 3, 0, 1)\n",
" df[\"conform\"] = np.clip(100 * (0.45 * np.minimum(df[\"safety\"] / 3, 1) + 0.25 * np.clip((df[\"log_cycles\"] - 3) / 4.5, 0, 1)\n",
" + 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df[\"cert\"]) - 8 * risk * df[\"crit\"] / 3 + rng.normal(0, 2, n), 0, 100)\n",
" df[\"demand_month\"] = rng.poisson(np.where(df[\"cat\"].isin([\"clip\", \"handle\"]), 40, 15)) + 1 # 월 소요수량(가상)\n",
" df[\"scrap_cost\"] = df[\"scrap_rate\"] * df[\"demand_month\"] * df[\"buy_cost\"] # 월 폐기 손실액(만원, 가상)\n",
" return df\n",
"\n",
"rng = np.random.default_rng(2)\n",
"applied = []\n",
"\n",
"\n",
"def rule(name, n):\n",
" applied.append((name, int(n)))\n",
"\n",
"\n",
"# ---------------- 1) 부품 이력 레코드 ----------------\n",
"P = make_parts(200, seed=5)\n",
"raw = P[[\"part_id\", \"material\", \"max_dim_mm\", \"min_wall_mm\", \"volume_cm3\", \"scrap_rate\", \"load_N\"]].copy()\n",
"raw[\"unit\"] = \"mm\"\n",
"raw[\"test_date\"] = [f\"2026-{rng.integers(1, 10):02d}-{rng.integers(1, 28):02d}\" for _ in range(len(raw))]\n",
"raw[\"emergency\"] = False\n",
"idx = rng.choice(len(raw), 80, replace=False)\n",
"a, b, c, d, e, f, g = np.split(idx[:77], 7)\n",
"raw.loc[a, \"material\"] = rng.choice([\"pa12\", \"PA-12\", \"ss316l\", \"SS 316L\", \"ti64\"], len(a)) # 소재 표기 불일치\n",
"raw.loc[b, [\"max_dim_mm\", \"min_wall_mm\"]] = raw.loc[b, [\"max_dim_mm\", \"min_wall_mm\"]].values / 25.4 # inch 값\n",
"raw.loc[b, \"unit\"] = \"inch\"\n",
"raw.loc[c, \"scrap_rate\"] = (raw.loc[c, \"scrap_rate\"] * 100).round(1) # % 단위 혼재\n",
"raw.loc[d, \"test_date\"] = [x.replace(\"-\", \"/\") for x in raw.loc[d, \"test_date\"]] # 날짜 형식 혼재\n",
"raw.loc[e, \"min_wall_mm\"] = rng.choice([0.0, -1.2], len(e)) # 불가능 값\n",
"raw.loc[f, [\"load_N\", \"volume_cm3\"]] = np.nan # 결측\n",
"raw.loc[g, \"emergency\"] = True; raw.loc[g, \"load_N\"] = raw.loc[g, \"load_N\"] * 12 # 긴급 제작 요청(예외: 과대 하중 기록, 보존)\n",
"raw = pd.concat([raw, raw.sample(10, random_state=1)], ignore_index=True) # 중복 레코드\n",
"print(f\"[부품 이력] 원천 {len(raw)}건 (중복 10건, 오류 주입 {len(idx[:77])}건 포함)\")\n",
"df = raw.copy()\n",
"rule(\"R1 중복 레코드 제거\", df.duplicated().sum()); df = df.drop_duplicates().reset_index(drop=True)\n",
"mat = df[\"material\"].str.upper().str.replace(r\"[\\s-]\", \"\", regex=True).map({\"PA12\": \"PA12\", \"PETG\": \"PETG\", \"CFPA\": \"CF-PA\", \"ALSI10MG\": \"AlSi10Mg\", \"SS316L\": \"SS316L\", \"TI64\": \"Ti64\"})\n",
"rule(\"R2 소재 코드 표기 통일\", (mat != df[\"material\"]).sum()); df[\"material\"] = mat\n",
"inch = df[\"unit\"] == \"inch\"\n",
"df.loc[inch, [\"max_dim_mm\", \"min_wall_mm\"]] *= 25.4; df[\"unit\"] = \"mm\"; rule(\"R3 CAD 치수 단위 통일(inch -> mm)\", inch.sum())\n",
"pct = df[\"scrap_rate\"] > 1; df.loc[pct, \"scrap_rate\"] /= 100; rule(\"R4 폐기율 단위 통일(% -> 비율)\", pct.sum())\n",
"t1 = pd.to_datetime(df[\"test_date\"], format=\"%Y-%m-%d\", errors=\"coerce\")\n",
"t2 = pd.to_datetime(df[\"test_date\"], format=\"%Y/%m/%d\", errors=\"coerce\"); rule(\"R5 시험일 형식 통일\", t1.isna().sum()); df[\"test_date\"] = t1.fillna(t2)\n",
"bad = df[\"min_wall_mm\"] <= 0; df.loc[bad, \"min_wall_mm\"] = np.nan; rule(\"R6 불가능 벽두께 결측 처리\", bad.sum())\n",
"miss = df[[\"load_N\", \"volume_cm3\", \"min_wall_mm\"]].isna().sum().sum()\n",
"df[[\"load_N\", \"volume_cm3\", \"min_wall_mm\"]] = KNNImputer(n_neighbors=5).fit_transform(df[[\"load_N\", \"volume_cm3\", \"min_wall_mm\", \"max_dim_mm\"]])[:, :3]\n",
"rule(\"R7 결측 KNN 보정\", miss)\n",
"q1, q3 = df[\"load_N\"].quantile([0.25, 0.75]); out = df[\"load_N\"] > q3 + 3 * (q3 - q1)\n",
"df[\"outlier_flag\"] = out & ~df[\"emergency\"]\n",
"rule(\"R12 긴급 제작 예외 보존·일반 이상값 표시\", (out & df[\"emergency\"]).sum())\n",
"\n",
"# ---------------- 2) CAD 메쉬 ----------------\n",
"def box_tris(a_, b_, c_, k=4):\n",
" dims, tris = (a_, b_, c_), []\n",
" for ax in range(3):\n",
" u, v = [i for i in range(3) if i != ax]\n",
" for sgn in (0, 1):\n",
" for i in range(k):\n",
" for j in range(k):\n",
" def Pt(ii, jj):\n",
" p = np.zeros(3); p[ax] = dims[ax] * sgn; p[u] = dims[u] * ii / k; p[v] = dims[v] * jj / k; return p\n",
" p00, p10, p11, p01 = Pt(i, j), Pt(i + 1, j), Pt(i + 1, j + 1), Pt(i, j + 1)\n",
" flip = (-1 if ax == 1 else 1) != (1 if sgn else -1)\n",
" for t in ([p00, p10, p11], [p00, p11, p01]):\n",
" tris.append(t[::-1] if flip else t)\n",
" return np.array(tris)\n",
"\n",
"\n",
"def volume(T):\n",
" return float(np.einsum(\"ij,ij->i\", T[:, 0], np.cross(T[:, 1], T[:, 2])).sum() / 6)\n",
"\n",
"\n",
"T0 = box_tris(40.0, 30.0, 20.0) # 정상 메쉬(가상 브래킷 블랭크, 부피 24 cm3)\n",
"nan_extra = T0[rng.choice(len(T0), 3, replace=False)].copy(); nan_extra[:, 0] = np.nan # NaN 정점을 가진 불량 삼각형\n",
"deg_extra = T0[rng.choice(len(T0), 8, replace=False)].copy(); deg_extra[:, 1] = deg_extra[:, 0] # 퇴화(면적 0) 삼각형\n",
"dup_extra = T0[rng.choice(len(T0), 6, replace=False)].copy() # 중복 삼각형\n",
"m = np.concatenate([T0, nan_extra, deg_extra, dup_extra])\n",
"fl = rng.choice(len(T0), 10, replace=False); m[fl] = m[fl][:, ::-1] # 법선 뒤집힘(원본 중 10개)\n",
"vol_raw = volume(np.nan_to_num(m))\n",
"n_raw = len(m)\n",
"nan_t = np.isnan(m).any(axis=(1, 2)); rule(\"R8 NaN 정점 삼각형 제거\", nan_t.sum()); m = m[~nan_t]\n",
"area = 0.5 * np.linalg.norm(np.cross(m[:, 1] - m[:, 0], m[:, 2] - m[:, 0]), axis=1)\n",
"degen = area < 1e-9\n",
"keys = [tuple(sorted(map(tuple, np.round(t, 4)))) for t in m]\n",
"dup = np.array([k in set(keys[:i]) for i, k in enumerate(keys)])\n",
"rule(\"R9 퇴화·중복 삼각형 제거\", (degen | dup).sum()); m = m[~(degen | dup)]\n",
"cen = m.mean(axis=(0, 1)); nrm = np.cross(m[:, 1] - m[:, 0], m[:, 2] - m[:, 0]); inward = np.einsum(\"ij,ij->i\", nrm, m.mean(axis=1) - cen) < 0\n",
"m[inward] = m[inward][:, ::-1]; rule(\"R10 법선 방향 통일(안쪽 향함 뒤집기)\", inward.sum())\n",
"vol_clean = volume(m)\n",
"\n",
"# ---------------- 3) 슬라이싱 출력 파싱 ----------------\n",
"lines = [\"; layer_height=0.2\"]\n",
"for L in range(30):\n",
" lines.append(f\";LAYER:{L}\")\n",
" for s in range(12):\n",
" lines.append(f\"G1 X{rng.uniform(0, 40):.2f} Y{rng.uniform(0, 30):.2f} E{rng.uniform(0.01, 0.2):.3f} F{rng.choice([1200, 1800, 2400])}\")\n",
"for i in rng.choice(len(lines), 8, replace=False):\n",
" if lines[i].startswith(\"G1\"):\n",
" lines[i] = lines[i].replace(\" Y\", \" Yx\") if i % 2 else lines[i].replace(\"F\", \"F-\") # 좌표 깨짐·음수 이송속도\n",
"pat = re.compile(r\"^G1 X(-?\\d+\\.\\d+) Y(-?\\d+\\.\\d+) E(\\d+\\.\\d+) F(\\d+)$\")\n",
"good, rej, layer = [], [], -1\n",
"for ln in lines:\n",
" if ln.startswith(\";LAYER:\"):\n",
" layer = int(ln.split(\":\")[1])\n",
" elif ln.startswith(\"G1\"):\n",
" mt = pat.match(ln)\n",
" (good if mt else rej).append((layer, *map(float, mt.groups())) if mt else ln)\n",
"G = pd.DataFrame(good, columns=[\"layer\", \"x\", \"y\", \"e\", \"f\"])\n",
"rule(\"R11 슬라이싱 불량 라인 격리(수동 확인 목록)\", len(rej))\n",
"\n",
"# ---------------- 4) 정합성 검증 ----------------\n",
"chk = {\"중복 레코드 0건\": not df.duplicated().any(), \"소재 코드 허용값\": df[\"material\"].isin(list(MAT)).all(),\n",
" \"치수 단위 mm\": (df[\"unit\"] == \"mm\").all(), \"폐기율 0~1\": df[\"scrap_rate\"].between(0, 1).all(),\n",
" \"시험일 변환\": df[\"test_date\"].notna().all(), \"벽두께 0 초과\": (df[\"min_wall_mm\"] > 0).all(),\n",
" \"결측 0건\": df[[\"load_N\", \"volume_cm3\", \"min_wall_mm\"]].notna().all().all(),\n",
" f\"긴급 제작 {int(df['emergency'].sum())}건 보존\": int(df[\"emergency\"].sum()) == len(g),\n",
" \"메쉬 NaN 0건\": not np.isnan(m).any(), \"메쉬 부피 오차 1% 이내\": abs(vol_clean - 24000) / 24000 < 0.01,\n",
" \"슬라이싱 이송속도 양수\": bool((G[\"f\"] > 0).all())}\n",
"rate = np.mean(list(chk.values()))\n",
"print(\"\\n[적용 규칙과 처리 건수]\")\n",
"applied.sort(key=lambda r_: int(re.match(r\"R(\\d+)\", r_[0]).group(1)))\n",
"for nme, n in applied:\n",
" print(f\" {nme:42s} {n:4d}건\")\n",
"print(f\"\\n정합성 검증 {sum(chk.values())}/{len(chk)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'}), 규칙 {len(applied)}종 (목표 10종 이상: {'달성' if len(applied) >= 10 else '미달'})\")\n",
"for k, v in chk.items():\n",
" if not v:\n",
" print(\" 실패:\", k)\n",
"print(f\"정제 후 부품 레코드 {len(df)}건, 일반 이상값 표시 {int(df['outlier_flag'].sum())}건, 긴급 제작 예외 {int(df['emergency'].sum())}건 유지\")\n",
"print(f\"메쉬 삼각형 {n_raw} -> {len(m)}개, 부피 {vol_raw / 1000:.2f} -> {vol_clean / 1000:.2f} cm3(기준 24.00)\")\n",
"print(f\"슬라이싱: {int(G['layer'].nunique())}층, 유효 {len(G)}줄, 격리 {len(rej)}줄, 총 압출량 {G['e'].sum():.1f}, 예상 이송 시간 {(np.hypot(G['x'].diff(), G['y'].diff()).fillna(0) / G['f'] * 60).sum():.2f}분\")\n",
"\n",
"num = lambda x: pd.to_numeric(x, errors=\"coerce\")\n",
"cmp = pd.DataFrame({\"정제 전 평균\": [num(raw[c_]).mean() for c_ in [\"max_dim_mm\", \"min_wall_mm\", \"scrap_rate\", \"load_N\"]],\n",
" \"정제 후 평균\": [df[c_].mean() for c_ in [\"max_dim_mm\", \"min_wall_mm\", \"scrap_rate\", \"load_N\"]],\n",
" \"정제 전 최소\": [num(raw[c_]).min() for c_ in [\"max_dim_mm\", \"min_wall_mm\", \"scrap_rate\", \"load_N\"]],\n",
" \"정제 후 최소\": [df[c_].min() for c_ in [\"max_dim_mm\", \"min_wall_mm\", \"scrap_rate\", \"load_N\"]]},\n",
" index=[\"최대치수mm\", \"벽두께mm\", \"폐기율\", \"하중N\"]).round(3)\n",
"print(\"\\n[정제 전후 기술통계]\"); print(cmp.to_string())\n",
"\n",
"fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))\n",
"ax[0].hist(num(raw[\"scrap_rate\"]).dropna(), bins=30, alpha=0.6, label=\"raw\"); ax[0].hist(df[\"scrap_rate\"], bins=30, alpha=0.6, label=\"cleaned\")\n",
"ax[0].set_title(\"Scrap rate before/after unit fix\"); ax[0].legend()\n",
"ax[1].bar([n_.split()[0] for n_, _ in applied], [n_ for _, n_ in applied]); ax[1].set_title(\"Records changed by rule\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 02 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[부품 이력] 원천 210건 (중복 10건, 오류 주입 77건 포함)\n",
"\n",
"[적용 규칙과 처리 건수]\n",
" R1 중복 레코드 제거 10건\n",
" R2 소재 코드 표기 통일 11건\n",
" R3 CAD 치수 단위 통일(inch -> mm) 11건\n",
" R4 폐기율 단위 통일(% -> 비율) 11건\n",
" R5 시험일 형식 통일 11건\n",
" R6 불가능 벽두께 결측 처리 11건\n",
" R7 결측 KNN 보정 33건\n",
" R8 NaN 정점 삼각형 제거 3건\n",
" R9 퇴화·중복 삼각형 제거 14건\n",
" R10 법선 방향 통일(안쪽 향함 뒤집기) 10건\n",
" R11 슬라이싱 불량 라인 격리(수동 확인 목록) 7건\n",
" R12 긴급 제작 예외 보존·일반 이상값 표시 5건\n",
"\n",
"정합성 검증 11/11 통과, 통과율 100% (목표 100%: 달성), 규칙 12종 (목표 10종 이상: 달성)\n",
"정제 후 부품 레코드 200건, 일반 이상값 표시 3건, 긴급 제작 예외 11건 유지\n",
"메쉬 삼각형 209 -> 192개, 부피 21.00 -> 24.00 cm3(기준 24.00)\n",
"슬라이싱: 30층, 유효 353줄, 격리 7줄, 총 압출량 39.0, 예상 이송 시간 238.36분\n",
"\n",
"[정제 전후 기술통계]\n",
" 정제 전 평균 정제 후 평균 정제 전 최소 정제 후 최소\n",
"최대치수mm 43.270 46.097 0.817 18.448\n",
"벽두께mm 2.114 2.407 -1.200 0.405\n",
"폐기율 0.663 0.117 0.029 0.029\n",
"하중N 2785.396 2864.990 50.301 50.301\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 03. 기하학적 형상 기반 1차 3D 프린팅 제작 가능 여부 판별 Baseline 알고리즘 구현\n",
"**소요시간**: 약 2.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"형상 기반 1차 3D 프린팅 제작 가능 여부 판별 Baseline 알고리즘을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **벽두께·돌출 비율·최대 치수 같은 기하학적 형상만으로 3D 프린팅 제작 가능 여부를 1차 판별하는 Baseline 알고리즘을 구현하고 탐색적 분석으로 한계와 핵심 관리 대상을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **가상 부품 600종에 규칙 기반 판별을 적용해 정확도·정밀도·재현율과 혼동행렬을 구하고, 가능이라 판정했으나 실패한 원인(얇은 나사부, 소재별 최소 벽두께)을 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `notebooks/mnt06_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": [
"\"\"\"부품의 형상(최소 벽두께·돌출 비율·최대 치수)만으로 3D 프린팅 제작 가능 여부를 1차 판별하는 규칙 기반 Baseline 알고리즘을 구현하고, 탐색적 분석(EDA) 차트 8종·상관분석(p-value)·파레토(ABC) 분석으로 Baseline의 한계와 폐기 손실이 큰 핵심 관리 대상을 찾는다.\"\"\"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from scipy import stats\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)\n",
"# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.\n",
"# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.\n",
"# ---------------------------------------------------------------------------\n",
"MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)\n",
" \"PA12\": (\"polymer\", 45, 1.0, 8, 3, 0.8, 18), \"PETG\": (\"polymer\", 50, 1.27, 5, 2, 0.8, 15),\n",
" \"CF-PA\": (\"polymer\", 70, 1.2, 14, 3, 1.0, 12), \"AlSi10Mg\": (\"metal\", 350, 2.67, 30, 25, 0.5, 4),\n",
" \"SS316L\": (\"metal\", 520, 7.9, 28, 25, 0.6, 3.5), \"Ti64\": (\"metal\", 900, 4.4, 90, 30, 0.6, 3)}\n",
"CATS = [\"bracket\", \"housing\", \"clip\", \"gear\", \"duct\", \"handle\"]\n",
"CAT_SCRAP = {\"bracket\": 0.04, \"housing\": 0.05, \"clip\": 0.12, \"gear\": 0.09, \"duct\": 0.08, \"handle\": 0.10} # 부품군별 기본 월 폐기율\n",
"BUILD_MM = {\"polymer\": 300, \"metal\": 200} # 가상 출력 가능 최대 치수\n",
"\n",
"\n",
"def make_parts(n=300, seed=0):\n",
" \"\"\"가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])\n",
" proc = np.array([MAT[m][0] for m in mat])\n",
" vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)\n",
" aspect = rng.lognormal(0.3, 0.4, n)\n",
" max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)\n",
" wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)\n",
" ovh = rng.beta(2, 5, n)\n",
" is_poly = proc == \"polymer\"\n",
" df = pd.DataFrame({\n",
" \"part_id\": [f\"P{i:03d}\" for i in range(n)], \"cat\": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), \"material\": mat, \"process\": proc,\n",
" \"volume_cm3\": vol, \"max_dim_mm\": max_dim, \"area_cm2\": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), \"min_wall_mm\": wall, \"overhang\": ovh,\n",
" \"n_holes\": rng.poisson(2.0, n), \"thread\": rng.random(n) < 0.25, \"orient_deg\": rng.uniform(0, 90, n),\n",
" \"layer_mm\": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),\n",
" \"infill\": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),\n",
" \"load_N\": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), \"temp_C\": rng.uniform(-20, 90, n),\n",
" \"crit\": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), \"cert\": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유\n",
" \"import_part\": rng.random(n) < 0.4})\n",
" df[\"scrap_rate\"] = np.clip(df[\"cat\"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df[\"load_N\"] > 800)) * (1 + 0.3 * (df[\"temp_C\"] > 60)), 0.005, 0.5)\n",
" df[\"lead_days\"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df[\"import_part\"], 2.2, 1.0), 5, 300)\n",
" # ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----\n",
" eff = np.where(is_poly, 0.55 + 0.45 * df[\"infill\"] / 100, 1 - 0.3 * ovh * 0.2)\n",
" aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df[\"orient_deg\"])) ** 2, 1 - 0.08 * np.sin(np.radians(df[\"orient_deg\"])))\n",
" heat = np.where(is_poly, 1 - 0.35 * np.clip((df[\"temp_C\"] - 40) / 80, 0, 1), 0.98)\n",
" base = np.array([MAT[m][1] for m in mat])\n",
" df[\"strength_mpa\"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)\n",
" cross = df[\"min_wall_mm\"] * np.sqrt(df[\"max_dim_mm\"]) * 1.0 # 하중 지지 단면적 mm2(단순화)\n",
" df[\"stress_mpa\"] = df[\"load_N\"] / cross\n",
" df[\"safety\"] = df[\"strength_mpa\"] / df[\"stress_mpa\"]\n",
" ratio = np.clip(df[\"stress_mpa\"] / df[\"strength_mpa\"], 0, 1.5)\n",
" df[\"log_cycles\"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)\n",
" support = 1 + 0.5 * ovh\n",
" df[\"build_h\"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5\n",
" mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000\n",
" df[\"print_cost\"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df[\"build_h\"] * np.array([MAT[m][4] for m in mat]) + 3\n",
" df[\"buy_cost\"] = df[\"print_cost\"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)\n",
" wall_ok = df[\"min_wall_mm\"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)\n",
" dim_ok = df[\"max_dim_mm\"] <= np.where(is_poly, BUILD_MM[\"polymer\"], BUILD_MM[\"metal\"])\n",
" warp = (~is_poly) & (df[\"max_dim_mm\"] > 140) & (df[\"overhang\"] > 0.3) # 금속 대형 오버행 변형\n",
" thr_bad = df[\"thread\"] & (df[\"min_wall_mm\"] < 1.5) & is_poly # 얇은 나사부 파손\n",
" df[\"printable\"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad\n",
" risk = np.clip(1 - df[\"safety\"] / 3, 0, 1)\n",
" df[\"conform\"] = np.clip(100 * (0.45 * np.minimum(df[\"safety\"] / 3, 1) + 0.25 * np.clip((df[\"log_cycles\"] - 3) / 4.5, 0, 1)\n",
" + 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df[\"cert\"]) - 8 * risk * df[\"crit\"] / 3 + rng.normal(0, 2, n), 0, 100)\n",
" df[\"demand_month\"] = rng.poisson(np.where(df[\"cat\"].isin([\"clip\", \"handle\"]), 40, 15)) + 1 # 월 소요수량(가상)\n",
" df[\"scrap_cost\"] = df[\"scrap_rate\"] * df[\"demand_month\"] * df[\"buy_cost\"] # 월 폐기 손실액(만원, 가상)\n",
" return df\n",
"\n",
"df = make_parts(600, seed=3)\n",
"\n",
"\n",
"def baseline(d):\n",
" \"\"\"형상 기반 1차 판별: 벽두께 >= 공통 기준 1.0mm, 최대 치수 <= 출력 가능 치수, 돌출 비율 < 0.5 (소재별 차이는 보지 않는다).\"\"\"\n",
" return (d[\"min_wall_mm\"] >= 1.0) & (d[\"max_dim_mm\"] <= d[\"process\"].map(BUILD_MM)) & (d[\"overhang\"] < 0.5)\n",
"\n",
"\n",
"df[\"pred\"] = baseline(df)\n",
"tp, fp = int((df[\"pred\"] & df[\"printable\"]).sum()), int((df[\"pred\"] & ~df[\"printable\"]).sum())\n",
"fn, tn = int((~df[\"pred\"] & df[\"printable\"]).sum()), int((~df[\"pred\"] & ~df[\"printable\"]).sum())\n",
"acc, prec, rec = (tp + tn) / len(df), tp / (tp + fp), tp / (tp + fn)\n",
"print(f\"[Baseline 결과] 부품 {len(df)}종, 시험 출력 성공 {df['printable'].mean():.1%}\")\n",
"print(f\"정확도 {acc:.1%}, 정밀도 {prec:.1%}, 재현율 {rec:.1%} (혼동행렬 TP {tp} FP {fp} FN {fn} TN {tn})\")\n",
"miss = df[df[\"pred\"] & ~df[\"printable\"]]\n",
"why = pd.Series(np.select([miss[\"thread\"] & (miss[\"process\"] == \"polymer\") & (miss[\"min_wall_mm\"] < 1.5), (miss[\"process\"] == \"metal\") & (miss[\"max_dim_mm\"] > 140) & (miss[\"overhang\"] > 0.3)],\n",
" [\"얇은 나사부 파손\", \"금속 대형 오버행 변형\"], \"소재별 최소 벽두께 미달\"))\n",
"print(\"Baseline이 '가능'이라 했으나 실패한 원인:\", \", \".join(f\"{k} {v}건\" for k, v in why.value_counts().items()))\n",
"print(f\"Baseline이 '불가'라 했으나 실제 성공한 부품 {fn}건(금속 등 얇은 벽 출력 가능 소재를 놓침)\")\n",
"print(\"-> 형상 규칙만으로는 나사부·열변형·소재 편차를 못 본다. 위험한 오판(FP)은 정비기술관 검토 전 걸러야 한다(미션 4~5의 동기).\")\n",
"\n",
"# 1) 상관분석: 시험 출력 성공 여부와의 관계(점이연 상관, 유의수준 0.05)\n",
"df[\"wall_ratio\"] = df[\"min_wall_mm\"] / df[\"material\"].map(lambda m: MAT[m][5])\n",
"df[\"is_metal\"] = (df[\"process\"] == \"metal\").astype(int)\n",
"feats = [\"wall_ratio\", \"min_wall_mm\", \"overhang\", \"max_dim_mm\", \"volume_cm3\", \"is_metal\", \"n_holes\", \"thread\", \"layer_mm\", \"load_N\", \"crit\"]\n",
"rows = []\n",
"for f in feats:\n",
" r, p = stats.pearsonr(df[f].astype(float), df[\"printable\"].astype(float))\n",
" rows.append({\"피처\": f, \"상관계수\": round(r, 3), \"p-value\": f\"{p:.1e}\", \"유의\": p < 0.05})\n",
"corr = pd.DataFrame(rows).sort_values(\"상관계수\", key=abs, ascending=False)\n",
"print(\"\\n[시험 출력 성공 여부와의 상관분석]\")\n",
"print(corr.to_string(index=False))\n",
"n_sig = int(corr[\"유의\"].sum())\n",
"print(f\"유의미한 피처 {n_sig}개 (목표 5개 이상: {'달성' if n_sig >= 5 else '미달'})\")\n",
"t, p = stats.ttest_ind(df.loc[df[\"printable\"], \"scrap_rate\"], df.loc[~df[\"printable\"], \"scrap_rate\"])\n",
"print(f\"폐기율 평균: 제작 가능 {df.loc[df['printable'], 'scrap_rate'].mean():.3f} vs 불가 {df.loc[~df['printable'], 'scrap_rate'].mean():.3f} (t검정 p={p:.2f}) -> 폐기율과 제작 가능성은 별개 축이므로 둘을 함께 봐야 우선순위가 정해진다\")\n",
"\n",
"# 2) 파레토(ABC): 월 폐기 손실액이 큰 (부품군, 소재) 조합 중 제작 가능한 것\n",
"g = df.groupby([\"cat\", \"material\"])[\"scrap_cost\"].sum().sort_values(ascending=False)\n",
"cum = g.cumsum() / g.sum()\n",
"abc = pd.cut(cum, [0, 0.8, 0.95, 1.0], labels=[\"A\", \"B\", \"C\"])\n",
"top = [f\"{c}/{m}\" for (c, m), v in abc.items() if v == \"A\"]\n",
"print(f\"\\n[파레토 A등급(누적 폐기 손실 80%)] {len(top)}개 조합: \" + \", \".join(top[:6]) + \" ...\")\n",
"print(f\"A등급은 전체 조합 {len(g)}개 중 {(abc == 'A').mean():.0%}, 이 중 Baseline 제작 가능 부품 비율 {df[df.set_index(['cat', 'material']).index.isin(list(abc[abc == 'A'].index))]['pred'].mean():.0%}\")\n",
"\n",
"# 3) EDA 차트 8종\n",
"fig, ax = plt.subplots(2, 4, figsize=(18, 8))\n",
"a = ax.ravel()\n",
"a[0].hist(df[\"scrap_rate\"], bins=30); a[0].set_title(\"1. Scrap rate distribution\")\n",
"df.groupby(\"cat\")[\"scrap_rate\"].mean().plot.bar(ax=a[1]); a[1].set_title(\"2. Mean scrap rate by category\")\n",
"a[2].scatter(df[\"min_wall_mm\"], df[\"max_dim_mm\"], c=df[\"printable\"], cmap=\"coolwarm_r\", s=8); a[2].set_title(\"3. Wall vs size (blue=printable)\")\n",
"df.boxplot(column=\"overhang\", by=\"printable\", ax=a[3]); a[3].set_title(\"4. Overhang by print result\")\n",
"df[\"material\"].value_counts().plot.bar(ax=a[4]); a[4].set_title(\"5. Parts by material\")\n",
"a[5].scatter(df[\"load_N\"], df[\"safety\"], s=6); a[5].set_xscale(\"log\"); a[5].set_yscale(\"log\"); a[5].set_title(\"6. Load vs safety factor\")\n",
"a[6].barh(corr[\"피처\"], corr[\"상관계수\"]); a[6].set_title(\"7. Correlation with print success\")\n",
"a[7].plot(range(1, len(cum) + 1), cum.values, \"o-\"); a[7].axhline(0.8, ls=\"--\", c=\"gray\"); a[7].set_title(\"8. Pareto of scrap loss\")\n",
"plt.suptitle(\"MNT06 EDA - baseline printability rule (synthetic data)\"); plt.tight_layout(); plt.show()\n",
"print(\"\\nEDA 차트 8종 생성 (목표 8종 이상: 달성)\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 03 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[Baseline 결과] 부품 600종, 시험 출력 성공 80.8%\n",
"정확도 91.0%, 정밀도 96.5%, 재현율 92.2% (혼동행렬 TP 447 FP 16 FN 38 TN 99)\n",
"Baseline이 '가능'이라 했으나 실패한 원인: 얇은 나사부 파손 14건, 소재별 최소 벽두께 미달 2건\n",
"Baseline이 '불가'라 했으나 실제 성공한 부품 38건(금속 등 얇은 벽 출력 가능 소재를 놓침)\n",
"-> 형상 규칙만으로는 나사부·열변형·소재 편차를 못 본다. 위험한 오판(FP)은 정비기술관 검토 전 걸러야 한다(미션 4~5의 동기).\n",
"\n",
"[시험 출력 성공 여부와의 상관분석]\n",
" 피처 상관계수 p-value 유의\n",
" overhang -0.385 1.3e-22 True\n",
" wall_ratio 0.294 2.1e-13 True\n",
"min_wall_mm 0.279 3.5e-12 True\n",
" is_metal 0.188 3.7e-06 True\n",
" layer_mm -0.159 8.9e-05 True\n",
" load_N 0.103 1.1e-02 True\n",
" crit -0.078 5.5e-02 False\n",
" thread -0.069 9.2e-02 False\n",
" max_dim_mm 0.026 5.3e-01 False\n",
" n_holes 0.018 6.6e-01 False\n",
" volume_cm3 -0.005 9.1e-01 False\n",
"유의미한 피처 6개 (목표 5개 이상: 달성)\n",
"폐기율 평균: 제작 가능 0.124 vs 불가 0.105 (t검정 p=0.01) -> 폐기율과 제작 가능성은 별개 축이므로 둘을 함께 봐야 우선순위가 정해진다\n",
"\n",
"[파레토 A등급(누적 폐기 손실 80%)] 8개 조합: handle/SS316L, handle/AlSi10Mg, clip/Ti64, clip/AlSi10Mg, handle/Ti64, clip/SS316L ...\n",
"A등급은 전체 조합 36개 중 22%, 이 중 Baseline 제작 가능 부품 비율 82%\n",
"\n",
"EDA 차트 8종 생성 (목표 8종 이상: 달성)\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 04. 유한요소해석(FEA) 연계 야전 부품 인장강도·피로내구도 신뢰성 평가 머신러닝 고도화\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"유한요소해석(FEA) 연계 부품 인장강도·내구도 신뢰성 평가 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **유한요소해석(FEA)을 간이화한 응력 계산과 소재·공정 지식을 연계해 야전 부품의 인장강도와 피로 수명을 예측하는 신뢰성 평가 머신러닝 모델을 고도화한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **단면적, 구멍·나사부 응력집중, 적층 방향, 충전율, 온도 저하 등으로 파생 피처 10개 이상을 만들고 소재 기준강도 대비 저감 계수를 학습 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/features/mnt06_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": [
"\"\"\"유한요소해석(FEA)을 간이화한 응력 계산값과 소재·공정 지식으로 파생 피처 16개를 만들어 야전 부품의 인장강도와 피로 수명(신뢰성)을 예측하는 머신러닝 기준 모델을 시계열 교차검증(MAPE)으로 평가하고, 피처 중요도로 차원 축소 후보를 고른다.\"\"\"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.ensemble import GradientBoostingRegressor\n",
"from sklearn.model_selection import TimeSeriesSplit\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)\n",
"# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.\n",
"# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.\n",
"# ---------------------------------------------------------------------------\n",
"MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)\n",
" \"PA12\": (\"polymer\", 45, 1.0, 8, 3, 0.8, 18), \"PETG\": (\"polymer\", 50, 1.27, 5, 2, 0.8, 15),\n",
" \"CF-PA\": (\"polymer\", 70, 1.2, 14, 3, 1.0, 12), \"AlSi10Mg\": (\"metal\", 350, 2.67, 30, 25, 0.5, 4),\n",
" \"SS316L\": (\"metal\", 520, 7.9, 28, 25, 0.6, 3.5), \"Ti64\": (\"metal\", 900, 4.4, 90, 30, 0.6, 3)}\n",
"CATS = [\"bracket\", \"housing\", \"clip\", \"gear\", \"duct\", \"handle\"]\n",
"CAT_SCRAP = {\"bracket\": 0.04, \"housing\": 0.05, \"clip\": 0.12, \"gear\": 0.09, \"duct\": 0.08, \"handle\": 0.10} # 부품군별 기본 월 폐기율\n",
"BUILD_MM = {\"polymer\": 300, \"metal\": 200} # 가상 출력 가능 최대 치수\n",
"\n",
"\n",
"def make_parts(n=300, seed=0):\n",
" \"\"\"가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])\n",
" proc = np.array([MAT[m][0] for m in mat])\n",
" vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)\n",
" aspect = rng.lognormal(0.3, 0.4, n)\n",
" max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)\n",
" wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)\n",
" ovh = rng.beta(2, 5, n)\n",
" is_poly = proc == \"polymer\"\n",
" df = pd.DataFrame({\n",
" \"part_id\": [f\"P{i:03d}\" for i in range(n)], \"cat\": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), \"material\": mat, \"process\": proc,\n",
" \"volume_cm3\": vol, \"max_dim_mm\": max_dim, \"area_cm2\": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), \"min_wall_mm\": wall, \"overhang\": ovh,\n",
" \"n_holes\": rng.poisson(2.0, n), \"thread\": rng.random(n) < 0.25, \"orient_deg\": rng.uniform(0, 90, n),\n",
" \"layer_mm\": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),\n",
" \"infill\": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),\n",
" \"load_N\": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), \"temp_C\": rng.uniform(-20, 90, n),\n",
" \"crit\": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), \"cert\": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유\n",
" \"import_part\": rng.random(n) < 0.4})\n",
" df[\"scrap_rate\"] = np.clip(df[\"cat\"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df[\"load_N\"] > 800)) * (1 + 0.3 * (df[\"temp_C\"] > 60)), 0.005, 0.5)\n",
" df[\"lead_days\"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df[\"import_part\"], 2.2, 1.0), 5, 300)\n",
" # ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----\n",
" eff = np.where(is_poly, 0.55 + 0.45 * df[\"infill\"] / 100, 1 - 0.3 * ovh * 0.2)\n",
" aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df[\"orient_deg\"])) ** 2, 1 - 0.08 * np.sin(np.radians(df[\"orient_deg\"])))\n",
" heat = np.where(is_poly, 1 - 0.35 * np.clip((df[\"temp_C\"] - 40) / 80, 0, 1), 0.98)\n",
" base = np.array([MAT[m][1] for m in mat])\n",
" df[\"strength_mpa\"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)\n",
" cross = df[\"min_wall_mm\"] * np.sqrt(df[\"max_dim_mm\"]) * 1.0 # 하중 지지 단면적 mm2(단순화)\n",
" df[\"stress_mpa\"] = df[\"load_N\"] / cross\n",
" df[\"safety\"] = df[\"strength_mpa\"] / df[\"stress_mpa\"]\n",
" ratio = np.clip(df[\"stress_mpa\"] / df[\"strength_mpa\"], 0, 1.5)\n",
" df[\"log_cycles\"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)\n",
" support = 1 + 0.5 * ovh\n",
" df[\"build_h\"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5\n",
" mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000\n",
" df[\"print_cost\"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df[\"build_h\"] * np.array([MAT[m][4] for m in mat]) + 3\n",
" df[\"buy_cost\"] = df[\"print_cost\"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)\n",
" wall_ok = df[\"min_wall_mm\"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)\n",
" dim_ok = df[\"max_dim_mm\"] <= np.where(is_poly, BUILD_MM[\"polymer\"], BUILD_MM[\"metal\"])\n",
" warp = (~is_poly) & (df[\"max_dim_mm\"] > 140) & (df[\"overhang\"] > 0.3) # 금속 대형 오버행 변형\n",
" thr_bad = df[\"thread\"] & (df[\"min_wall_mm\"] < 1.5) & is_poly # 얇은 나사부 파손\n",
" df[\"printable\"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad\n",
" risk = np.clip(1 - df[\"safety\"] / 3, 0, 1)\n",
" df[\"conform\"] = np.clip(100 * (0.45 * np.minimum(df[\"safety\"] / 3, 1) + 0.25 * np.clip((df[\"log_cycles\"] - 3) / 4.5, 0, 1)\n",
" + 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df[\"cert\"]) - 8 * risk * df[\"crit\"] / 3 + rng.normal(0, 2, n), 0, 100)\n",
" df[\"demand_month\"] = rng.poisson(np.where(df[\"cat\"].isin([\"clip\", \"handle\"]), 40, 15)) + 1 # 월 소요수량(가상)\n",
" df[\"scrap_cost\"] = df[\"scrap_rate\"] * df[\"demand_month\"] * df[\"buy_cost\"] # 월 폐기 손실액(만원, 가상)\n",
" return df\n",
"\n",
"# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----\n",
"def make_features(d):\n",
" \"\"\"간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개.\"\"\"\n",
" X = pd.DataFrame(index=d.index)\n",
" base = d[\"material\"].map(lambda m: MAT[m][1]).astype(float)\n",
" X[\"base_strength\"] = base\n",
" X[\"is_metal\"] = (d[\"process\"] == \"metal\").astype(int)\n",
" X[\"cross_mm2\"] = d[\"min_wall_mm\"] * np.sqrt(d[\"max_dim_mm\"])\n",
" X[\"kt\"] = 1 + 0.15 * d[\"n_holes\"] + 0.5 * d[\"thread\"].astype(int) # 구멍·나사부 응력집중계수(간이)\n",
" X[\"fea_stress\"] = d[\"load_N\"] / X[\"cross_mm2\"] * X[\"kt\"] # 간이 해석 응력(MPa)\n",
" X[\"stress_ratio\"] = X[\"fea_stress\"] / base # 기준강도 대비 응력비\n",
" X[\"infill_eff\"] = np.where(X[\"is_metal\"] == 1, 1.0, 0.55 + 0.45 * d[\"infill\"] / 100)\n",
" X[\"orient_sin2\"] = np.sin(np.radians(d[\"orient_deg\"])) ** 2\n",
" X[\"temp_derate\"] = np.where(X[\"is_metal\"] == 1, 0.0, np.clip((d[\"temp_C\"] - 40) / 80, 0, 1))\n",
" X[\"overhang_layer\"] = d[\"overhang\"] * d[\"layer_mm\"]\n",
" X[\"slender\"] = d[\"max_dim_mm\"] / d[\"min_wall_mm\"]\n",
" X[\"surf_vol\"] = d[\"area_cm2\"] / d[\"volume_cm3\"]\n",
" X[\"wall_ratio\"] = d[\"min_wall_mm\"] / d[\"material\"].map(lambda m: MAT[m][5])\n",
" X[\"load_per_vol\"] = d[\"load_N\"] / d[\"volume_cm3\"]\n",
" X[\"cert\"] = d[\"cert\"].astype(int)\n",
" X[\"crit\"] = d[\"crit\"]\n",
" return X\n",
"\n",
"parts = make_parts(900, seed=7) # 인덱스 순서 = 시험 수행 순서(앞이 과거)\n",
"X = make_features(parts)\n",
"print(f\"파생 피처 {X.shape[1]}개 생성 (목표 10개 이상: {'달성' if X.shape[1] >= 10 else '미달'})\")\n",
"mape = lambda a, p: float(np.mean(np.abs(a - p) / np.abs(a)))\n",
"gb = lambda: GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.05, random_state=0)\n",
"\n",
"# 1) 시계열 교차검증: 과거 시험으로 학습 -> 이후 시험 예측 (5분할)\n",
"rows = []\n",
"for i, (tr, te) in enumerate(TimeSeriesSplit(5).split(X), 1):\n",
" ys, yf, bs = parts[\"strength_mpa\"], parts[\"log_cycles\"], X[\"base_strength\"]\n",
" base = parts.iloc[tr].groupby(\"material\")[\"strength_mpa\"].mean() # 기준모델: 소재별 평균 강도\n",
" p0 = parts.iloc[te][\"material\"].map(base)\n",
" p1 = gb().fit(X.iloc[tr], (ys / bs).iloc[tr]).predict(X.iloc[te]) * bs.iloc[te] # 소재 기준강도 대비 저감 계수를 학습\n",
" q1 = gb().fit(X.iloc[tr], yf.iloc[tr]).predict(X.iloc[te])\n",
" rows.append({\"분할\": i, \"학습\": len(tr), \"검증\": len(te), \"강도 기준 MAPE\": round(mape(ys.iloc[te], p0), 3), \"강도 GBM MAPE\": round(mape(ys.iloc[te], p1), 3),\n",
" \"피로수명 MAE(log10)\": round(float(np.mean(np.abs(yf.iloc[te] - q1))), 2)})\n",
"cv = pd.DataFrame(rows)\n",
"print(cv.to_string(index=False))\n",
"m_b, m_g = cv[\"강도 기준 MAPE\"].mean(), cv[\"강도 GBM MAPE\"].mean()\n",
"print(f\"평균 인장강도 MAPE: 기준모델(소재별 평균) {m_b:.1%}, GBM {m_g:.1%} (목표 25% 이하: {'달성' if m_g <= 0.25 else '미달'})\")\n",
"print(f\"피로 수명 예측 평균 오차 {cv['피로수명 MAE(log10)'].mean():.2f} 자릿수(log10 회)\")\n",
"\n",
"# 2) 마지막 20%를 신뢰성 Hold-out으로: 예측 안전율로 '대체 제작 신뢰성 충족(안전율 1.5 이상)' 판별\n",
"cut = int(len(parts) * 0.8)\n",
"ratio = parts[\"strength_mpa\"] / X[\"base_strength\"]\n",
"ms = gb().fit(X.iloc[:cut], ratio.iloc[:cut])\n",
"te = parts.iloc[cut:]\n",
"p_all = ms.predict(X.iloc[cut:]) * X.iloc[cut:][\"base_strength\"]\n",
"pred_sf = p_all / (te[\"load_N\"] / X.iloc[cut:][\"cross_mm2\"])\n",
"ok_true, ok_pred = te[\"safety\"] >= 1.5, pred_sf >= 1.5\n",
"print(f\"\\n[안전율 1.5 이상 판별] 정확도 {(ok_true == ok_pred).mean():.1%}, 위험 오판(불충분한데 충족이라 판정) {int((~ok_true & ok_pred).sum())}건 / {len(te)}건\")\n",
"print(\"간이 해석 응력만으로 만든 판별의 정확도:\", f\"{((X.iloc[cut:]['stress_ratio'] < 1 / 1.5) == ok_true).mean():.1%}\")\n",
"\n",
"# 3) 피처 중요도와 차원 축소 후보\n",
"model = gb().fit(X, ratio)\n",
"imp = pd.Series(model.feature_importances_, X.columns).sort_values(ascending=False)\n",
"print(\"\\n피처 중요도 상위 5(강도 저감 계수 모델):\", \", \".join(f\"{k}({v:.2f})\" for k, v in imp.head(5).items()))\n",
"print(\"중요도 0.01 미만 피처(차원 축소 후보):\", list(imp[imp < 0.01].index))\n",
"print(\"단, 단면적·응력집중·응력비는 강도 모델에서는 약하지만 안전율(하중 쪽) 계산에 필요하므로 삭제하지 않고 모델별로 나눠 쓴다.\")\n",
"print(\"피처 설계 근거: 충전율·적층 방향·소재·온도 저하는 강도를 깎는 요인이고 단면적·구멍·나사부는 응력을 키우는 요인이라 정비 현장에서도 설명이 된다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
"imp.head(10)[::-1].plot.barh(ax=ax[0]); ax[0].set_title(\"Feature importance (strength knock-down)\")\n",
"ax[1].scatter(te[\"strength_mpa\"], p_all, s=10); ax[1].plot([0, 1000], [0, 1000], \"r--\")\n",
"ax[1].set_xlabel(\"measured strength (MPa)\"); ax[1].set_ylabel(\"predicted\"); ax[1].set_title(\"Hold-out: strength prediction\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 04 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"파생 피처 16개 생성 (목표 10개 이상: 달성)\n",
" 분할 학습 검증 강도 기준 MAPE 강도 GBM MAPE 피로수명 MAE(log10)\n",
" 1 150 150 0.100 0.066 0.39\n",
" 2 300 150 0.102 0.057 0.27\n",
" 3 450 150 0.121 0.056 0.28\n",
" 4 600 150 0.095 0.052 0.32\n",
" 5 750 150 0.118 0.062 0.26\n",
"평균 인장강도 MAPE: 기준모델(소재별 평균) 10.7%, GBM 5.9% (목표 25% 이하: 달성)\n",
"피로 수명 예측 평균 오차 0.30 자릿수(log10 회)\n",
"\n",
"[안전율 1.5 이상 판별] 정확도 97.8%, 위험 오판(불충분한데 충족이라 판정) 2건 / 180건\n",
"간이 해석 응력만으로 만든 판별의 정확도: 93.9%\n",
"\n",
"피처 중요도 상위 5(강도 저감 계수 모델): infill_eff(0.33), orient_sin2(0.22), is_metal(0.19), base_strength(0.13), temp_derate(0.11)\n",
"중요도 0.01 미만 피처(차원 축소 후보): ['overhang_layer', 'fea_stress', 'load_per_vol', 'surf_vol', 'stress_ratio', 'slender', 'kt', 'wall_ratio', 'crit', 'cross_mm2', 'cert']\n",
"단, 단면적·응력집중·응력비는 강도 모델에서는 약하지만 안전율(하중 쪽) 계산에 필요하므로 삭제하지 않고 모델별로 나눠 쓴다.\n",
"피처 설계 근거: 충전율·적층 방향·소재·온도 저하는 강도를 깎는 요인이고 단면적·구멍·나사부는 응력을 키우는 요인이라 정비 현장에서도 설명이 된다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 05. 긴급 야전 제작 부품의 군사 규격(MIL-STD) 부합성 신뢰도 점수 산출 엔진 개발\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"야전 제작 부품의 군사 규격(MIL-STD) 부합성 신뢰도 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **긴급 야전 제작 부품의 군 규격 부합성을 0~100점으로 환산하는 신뢰도 점수 산출 엔진을 개발한다(실제 규격 원문 대신 가상 기준 점수 사용).** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **안전율·피로 수명·돌출 위험·소재 인증서를 반영한 가상 부합 점수를 앙상블 회귀(GBM+RandomForest)로 예측해 원 입력 5개 선형 기준모델과 Hold-out에서 MAE·RMSE·R2를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/models/mnt06_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, 가상 기준)를 앙상블 회귀(GBM+RandomForest)로 산출하는 엔진을 만들고, Hold-out 부품에서 원 입력 5개 선형 기준모델 대비 MAE/RMSE 개선율과 R2, 점수 구간별 실제 부합률(보정 상태)과 예측 불확실성을 검증한다.\"\"\"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor\n",
"from sklearn.linear_model import LinearRegression\n",
"from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score, roc_auc_score\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)\n",
"# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.\n",
"# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.\n",
"# ---------------------------------------------------------------------------\n",
"MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)\n",
" \"PA12\": (\"polymer\", 45, 1.0, 8, 3, 0.8, 18), \"PETG\": (\"polymer\", 50, 1.27, 5, 2, 0.8, 15),\n",
" \"CF-PA\": (\"polymer\", 70, 1.2, 14, 3, 1.0, 12), \"AlSi10Mg\": (\"metal\", 350, 2.67, 30, 25, 0.5, 4),\n",
" \"SS316L\": (\"metal\", 520, 7.9, 28, 25, 0.6, 3.5), \"Ti64\": (\"metal\", 900, 4.4, 90, 30, 0.6, 3)}\n",
"CATS = [\"bracket\", \"housing\", \"clip\", \"gear\", \"duct\", \"handle\"]\n",
"CAT_SCRAP = {\"bracket\": 0.04, \"housing\": 0.05, \"clip\": 0.12, \"gear\": 0.09, \"duct\": 0.08, \"handle\": 0.10} # 부품군별 기본 월 폐기율\n",
"BUILD_MM = {\"polymer\": 300, \"metal\": 200} # 가상 출력 가능 최대 치수\n",
"\n",
"\n",
"def make_parts(n=300, seed=0):\n",
" \"\"\"가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])\n",
" proc = np.array([MAT[m][0] for m in mat])\n",
" vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)\n",
" aspect = rng.lognormal(0.3, 0.4, n)\n",
" max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)\n",
" wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)\n",
" ovh = rng.beta(2, 5, n)\n",
" is_poly = proc == \"polymer\"\n",
" df = pd.DataFrame({\n",
" \"part_id\": [f\"P{i:03d}\" for i in range(n)], \"cat\": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), \"material\": mat, \"process\": proc,\n",
" \"volume_cm3\": vol, \"max_dim_mm\": max_dim, \"area_cm2\": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), \"min_wall_mm\": wall, \"overhang\": ovh,\n",
" \"n_holes\": rng.poisson(2.0, n), \"thread\": rng.random(n) < 0.25, \"orient_deg\": rng.uniform(0, 90, n),\n",
" \"layer_mm\": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),\n",
" \"infill\": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),\n",
" \"load_N\": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), \"temp_C\": rng.uniform(-20, 90, n),\n",
" \"crit\": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), \"cert\": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유\n",
" \"import_part\": rng.random(n) < 0.4})\n",
" df[\"scrap_rate\"] = np.clip(df[\"cat\"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df[\"load_N\"] > 800)) * (1 + 0.3 * (df[\"temp_C\"] > 60)), 0.005, 0.5)\n",
" df[\"lead_days\"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df[\"import_part\"], 2.2, 1.0), 5, 300)\n",
" # ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----\n",
" eff = np.where(is_poly, 0.55 + 0.45 * df[\"infill\"] / 100, 1 - 0.3 * ovh * 0.2)\n",
" aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df[\"orient_deg\"])) ** 2, 1 - 0.08 * np.sin(np.radians(df[\"orient_deg\"])))\n",
" heat = np.where(is_poly, 1 - 0.35 * np.clip((df[\"temp_C\"] - 40) / 80, 0, 1), 0.98)\n",
" base = np.array([MAT[m][1] for m in mat])\n",
" df[\"strength_mpa\"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)\n",
" cross = df[\"min_wall_mm\"] * np.sqrt(df[\"max_dim_mm\"]) * 1.0 # 하중 지지 단면적 mm2(단순화)\n",
" df[\"stress_mpa\"] = df[\"load_N\"] / cross\n",
" df[\"safety\"] = df[\"strength_mpa\"] / df[\"stress_mpa\"]\n",
" ratio = np.clip(df[\"stress_mpa\"] / df[\"strength_mpa\"], 0, 1.5)\n",
" df[\"log_cycles\"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)\n",
" support = 1 + 0.5 * ovh\n",
" df[\"build_h\"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5\n",
" mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000\n",
" df[\"print_cost\"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df[\"build_h\"] * np.array([MAT[m][4] for m in mat]) + 3\n",
" df[\"buy_cost\"] = df[\"print_cost\"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)\n",
" wall_ok = df[\"min_wall_mm\"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)\n",
" dim_ok = df[\"max_dim_mm\"] <= np.where(is_poly, BUILD_MM[\"polymer\"], BUILD_MM[\"metal\"])\n",
" warp = (~is_poly) & (df[\"max_dim_mm\"] > 140) & (df[\"overhang\"] > 0.3) # 금속 대형 오버행 변형\n",
" thr_bad = df[\"thread\"] & (df[\"min_wall_mm\"] < 1.5) & is_poly # 얇은 나사부 파손\n",
" df[\"printable\"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad\n",
" risk = np.clip(1 - df[\"safety\"] / 3, 0, 1)\n",
" df[\"conform\"] = np.clip(100 * (0.45 * np.minimum(df[\"safety\"] / 3, 1) + 0.25 * np.clip((df[\"log_cycles\"] - 3) / 4.5, 0, 1)\n",
" + 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df[\"cert\"]) - 8 * risk * df[\"crit\"] / 3 + rng.normal(0, 2, n), 0, 100)\n",
" df[\"demand_month\"] = rng.poisson(np.where(df[\"cat\"].isin([\"clip\", \"handle\"]), 40, 15)) + 1 # 월 소요수량(가상)\n",
" df[\"scrap_cost\"] = df[\"scrap_rate\"] * df[\"demand_month\"] * df[\"buy_cost\"] # 월 폐기 손실액(만원, 가상)\n",
" return df\n",
"\n",
"# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----\n",
"def make_features(d):\n",
" \"\"\"간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개.\"\"\"\n",
" X = pd.DataFrame(index=d.index)\n",
" base = d[\"material\"].map(lambda m: MAT[m][1]).astype(float)\n",
" X[\"base_strength\"] = base\n",
" X[\"is_metal\"] = (d[\"process\"] == \"metal\").astype(int)\n",
" X[\"cross_mm2\"] = d[\"min_wall_mm\"] * np.sqrt(d[\"max_dim_mm\"])\n",
" X[\"kt\"] = 1 + 0.15 * d[\"n_holes\"] + 0.5 * d[\"thread\"].astype(int) # 구멍·나사부 응력집중계수(간이)\n",
" X[\"fea_stress\"] = d[\"load_N\"] / X[\"cross_mm2\"] * X[\"kt\"] # 간이 해석 응력(MPa)\n",
" X[\"stress_ratio\"] = X[\"fea_stress\"] / base # 기준강도 대비 응력비\n",
" X[\"infill_eff\"] = np.where(X[\"is_metal\"] == 1, 1.0, 0.55 + 0.45 * d[\"infill\"] / 100)\n",
" X[\"orient_sin2\"] = np.sin(np.radians(d[\"orient_deg\"])) ** 2\n",
" X[\"temp_derate\"] = np.where(X[\"is_metal\"] == 1, 0.0, np.clip((d[\"temp_C\"] - 40) / 80, 0, 1))\n",
" X[\"overhang_layer\"] = d[\"overhang\"] * d[\"layer_mm\"]\n",
" X[\"slender\"] = d[\"max_dim_mm\"] / d[\"min_wall_mm\"]\n",
" X[\"surf_vol\"] = d[\"area_cm2\"] / d[\"volume_cm3\"]\n",
" X[\"wall_ratio\"] = d[\"min_wall_mm\"] / d[\"material\"].map(lambda m: MAT[m][5])\n",
" X[\"load_per_vol\"] = d[\"load_N\"] / d[\"volume_cm3\"]\n",
" X[\"cert\"] = d[\"cert\"].astype(int)\n",
" X[\"crit\"] = d[\"crit\"]\n",
" return X\n",
"\n",
"train = pd.concat([make_parts(1000, seed=s) for s in (11, 12)], ignore_index=True)\n",
"test = make_parts(600, seed=21) # Hold-out: 학습에 쓰지 않은 부품\n",
"Xtr, Xte, ytr, yte = make_features(train), make_features(test), train[\"conform\"], test[\"conform\"]\n",
"\n",
"# 1) 기준모델(원 입력 5개 선형) vs 앙상블\n",
"raw_cols = [\"load_N\", \"min_wall_mm\", \"infill\", \"temp_C\", \"cert\"]\n",
"base = LinearRegression().fit(train[raw_cols], ytr)\n",
"gbm = GradientBoostingRegressor(n_estimators=300, max_depth=3, learning_rate=0.05, random_state=0).fit(Xtr, ytr)\n",
"rf = RandomForestRegressor(n_estimators=150, min_samples_leaf=3, random_state=0, n_jobs=1).fit(Xtr, ytr)\n",
"preds = {\"기준모델(선형 5입력)\": base.predict(test[raw_cols]), \"GBM\": gbm.predict(Xte), \"RandomForest\": rf.predict(Xte)}\n",
"preds[\"앙상블(GBM+RF)\"] = (preds[\"GBM\"] + preds[\"RandomForest\"]) / 2\n",
"tab = pd.DataFrame([{\"모델\": k, \"MAE\": mean_absolute_error(yte, p), \"RMSE\": mean_squared_error(yte, p) ** 0.5, \"R2\": r2_score(yte, p)} for k, p in preds.items()]).round(3)\n",
"print(f\"학습 {len(train)}종 / Hold-out {len(test)}종\")\n",
"print(tab.to_string(index=False))\n",
"b, e = tab.iloc[0], tab.iloc[-1]\n",
"imp_mae, imp_rmse = 1 - e[\"MAE\"] / b[\"MAE\"], 1 - e[\"RMSE\"] / b[\"RMSE\"]\n",
"print(f\"기준모델 대비 개선: MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%} (목표 20% 이상: {'달성' if min(imp_mae, imp_rmse) >= 0.2 else '미달'}), \"\n",
" f\"R2 {e['R2']:.3f} (목표 0.85 이상: {'달성' if e['R2'] >= 0.85 else '미달'})\")\n",
"\n",
"# 2) 신뢰도 점수 구간별 실제 부합률: 실제 점수 70 이상을 '규격 부합'으로 본다(가상 기준)\n",
"score = np.clip(preds[\"앙상블(GBM+RF)\"], 0, 100)\n",
"actual = (yte >= 70).values\n",
"bins = pd.cut(score, [-1, 50, 70, 85, 101], labels=[\"<50 부적합 의심\", \"50~70 보완 필요\", \"70~85 조건부\", \">=85 부합 유력\"])\n",
"cal = pd.DataFrame({\"구간\": bins, \"점수\": score, \"실제부합\": actual}).groupby(\"구간\", observed=True).agg(건수=(\"점수\", \"size\"), 평균점수=(\"점수\", \"mean\"), 실제부합률=(\"실제부합\", \"mean\")).round(2)\n",
"print(\"\\n[점수 구간별 실제 부합률]\")\n",
"print(cal.to_string())\n",
"mono = bool((cal[\"실제부합률\"].diff().dropna() >= 0).all())\n",
"print(f\"점수 구간이 높을수록 실제 부합률이 {'단조 증가' if mono else '단조 증가하지 않음'}, AUC {roc_auc_score(actual, score):.3f}\")\n",
"\n",
"# 3) 예측 안정성: 두 모델의 의견 차이가 큰 부품은 '판정 보류' 후보\n",
"gap = np.abs(preds[\"GBM\"] - preds[\"RandomForest\"])\n",
"hold = gap > np.quantile(gap, 0.9)\n",
"print(f\"\\n[예측 안정성] 모델 간 차이 상위 10% {int(hold.sum())}종: 평균 오차 {np.abs(yte[hold] - score[hold]).mean():.1f}점 vs 나머지 {np.abs(yte[~hold] - score[~hold]).mean():.1f}점\")\n",
"irr = test[\"cert\"].eq(0) & test[\"crit\"].eq(3)\n",
"print(f\"인증서 없는 핵심 부품 {int(irr.sum())}종: 평균 점수 {score[irr.values].mean():.1f}점 -> 점수와 별개로 소재 인증 확보가 선행되어야 한다\")\n",
"\n",
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
"ax[0].scatter(yte, score, s=8); ax[0].plot([0, 100], [0, 100], \"r--\"); ax[0].set_xlabel(\"actual score\"); ax[0].set_ylabel(\"predicted\")\n",
"ax[0].set_title(f\"Conformity score hold-out (R2={e['R2']:.2f})\")\n",
"ax[1].plot(cal[\"평균점수\"], cal[\"실제부합률\"], \"o-\"); ax[1].set_xlabel(\"mean confidence score\"); ax[1].set_ylabel(\"actual conform rate\"); ax[1].set_title(\"Score band calibration\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 05 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"학습 2000종 / Hold-out 600종\n",
" 모델 MAE RMSE R2\n",
"기준모델(선형 5입력) 15.800 18.627 0.438\n",
" GBM 3.893 5.120 0.958\n",
"RandomForest 4.246 5.740 0.947\n",
" 앙상블(GBM+RF) 3.916 5.208 0.956\n",
"기준모델 대비 개선: MAE 75.2%, RMSE 72.0% (목표 20% 이상: 달성), R2 0.956 (목표 0.85 이상: 달성)\n",
"\n",
"[점수 구간별 실제 부합률]\n",
" 건수 평균점수 실제부합률\n",
"구간 \n",
"<50 부적합 의심 217 32.12 0.00\n",
"50~70 보완 필요 131 60.96 0.16\n",
"70~85 조건부 139 77.20 0.91\n",
">=85 부합 유력 113 90.07 1.00\n",
"점수 구간이 높을수록 실제 부합률이 단조 증가, AUC 0.987\n",
"\n",
"[예측 안정성] 모델 간 차이 상위 10% 60종: 평균 오차 6.6점 vs 나머지 3.6점\n",
"인증서 없는 핵심 부품 57종: 평균 점수 48.5점 -> 점수와 별개로 소재 인증 확보가 선행되어야 한다\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 06. 부품별 제작 소요 시간, 비용 및 신뢰성 평가 결과 가시화 대시보드 구현\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"부품별 제작 소요 시간, 비용 및 신뢰성 평가 가시화 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부품별 제작 소요 시간, 비용, 신뢰성 점수와 폐기 손실을 보여 주는 대시보드를 만들고 예측마다 근거를 설명하는 설명가능성(XAI) 기능을 붙인다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **출력 시간-비용, 출력 대 구매 비용, 점수 분포, 점수-폐기 손실, 우선 후보 5종, 점수 워터폴로 대시보드를 구성 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/explainability/mnt06_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": [
"\"\"\"부품별 출력 소요 시간·제작 비용·신뢰성 점수와 폐기 손실을 한 화면에 보여 주는 대시보드를 그리고, 군 규격 부합 점수 예측마다 기여도 상위 3개 인자를 가림(occlusion) 방식(SHAP 근사)으로 자동 산출해 워터폴 그림과 자연어 설명으로 제공하는 설명가능성(XAI) 모듈을 구현한다.\"\"\"\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",
"# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)\n",
"# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.\n",
"# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.\n",
"# ---------------------------------------------------------------------------\n",
"MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)\n",
" \"PA12\": (\"polymer\", 45, 1.0, 8, 3, 0.8, 18), \"PETG\": (\"polymer\", 50, 1.27, 5, 2, 0.8, 15),\n",
" \"CF-PA\": (\"polymer\", 70, 1.2, 14, 3, 1.0, 12), \"AlSi10Mg\": (\"metal\", 350, 2.67, 30, 25, 0.5, 4),\n",
" \"SS316L\": (\"metal\", 520, 7.9, 28, 25, 0.6, 3.5), \"Ti64\": (\"metal\", 900, 4.4, 90, 30, 0.6, 3)}\n",
"CATS = [\"bracket\", \"housing\", \"clip\", \"gear\", \"duct\", \"handle\"]\n",
"CAT_SCRAP = {\"bracket\": 0.04, \"housing\": 0.05, \"clip\": 0.12, \"gear\": 0.09, \"duct\": 0.08, \"handle\": 0.10} # 부품군별 기본 월 폐기율\n",
"BUILD_MM = {\"polymer\": 300, \"metal\": 200} # 가상 출력 가능 최대 치수\n",
"\n",
"\n",
"def make_parts(n=300, seed=0):\n",
" \"\"\"가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])\n",
" proc = np.array([MAT[m][0] for m in mat])\n",
" vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)\n",
" aspect = rng.lognormal(0.3, 0.4, n)\n",
" max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)\n",
" wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)\n",
" ovh = rng.beta(2, 5, n)\n",
" is_poly = proc == \"polymer\"\n",
" df = pd.DataFrame({\n",
" \"part_id\": [f\"P{i:03d}\" for i in range(n)], \"cat\": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), \"material\": mat, \"process\": proc,\n",
" \"volume_cm3\": vol, \"max_dim_mm\": max_dim, \"area_cm2\": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), \"min_wall_mm\": wall, \"overhang\": ovh,\n",
" \"n_holes\": rng.poisson(2.0, n), \"thread\": rng.random(n) < 0.25, \"orient_deg\": rng.uniform(0, 90, n),\n",
" \"layer_mm\": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),\n",
" \"infill\": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),\n",
" \"load_N\": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), \"temp_C\": rng.uniform(-20, 90, n),\n",
" \"crit\": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), \"cert\": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유\n",
" \"import_part\": rng.random(n) < 0.4})\n",
" df[\"scrap_rate\"] = np.clip(df[\"cat\"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df[\"load_N\"] > 800)) * (1 + 0.3 * (df[\"temp_C\"] > 60)), 0.005, 0.5)\n",
" df[\"lead_days\"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df[\"import_part\"], 2.2, 1.0), 5, 300)\n",
" # ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----\n",
" eff = np.where(is_poly, 0.55 + 0.45 * df[\"infill\"] / 100, 1 - 0.3 * ovh * 0.2)\n",
" aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df[\"orient_deg\"])) ** 2, 1 - 0.08 * np.sin(np.radians(df[\"orient_deg\"])))\n",
" heat = np.where(is_poly, 1 - 0.35 * np.clip((df[\"temp_C\"] - 40) / 80, 0, 1), 0.98)\n",
" base = np.array([MAT[m][1] for m in mat])\n",
" df[\"strength_mpa\"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)\n",
" cross = df[\"min_wall_mm\"] * np.sqrt(df[\"max_dim_mm\"]) * 1.0 # 하중 지지 단면적 mm2(단순화)\n",
" df[\"stress_mpa\"] = df[\"load_N\"] / cross\n",
" df[\"safety\"] = df[\"strength_mpa\"] / df[\"stress_mpa\"]\n",
" ratio = np.clip(df[\"stress_mpa\"] / df[\"strength_mpa\"], 0, 1.5)\n",
" df[\"log_cycles\"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)\n",
" support = 1 + 0.5 * ovh\n",
" df[\"build_h\"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5\n",
" mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000\n",
" df[\"print_cost\"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df[\"build_h\"] * np.array([MAT[m][4] for m in mat]) + 3\n",
" df[\"buy_cost\"] = df[\"print_cost\"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)\n",
" wall_ok = df[\"min_wall_mm\"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)\n",
" dim_ok = df[\"max_dim_mm\"] <= np.where(is_poly, BUILD_MM[\"polymer\"], BUILD_MM[\"metal\"])\n",
" warp = (~is_poly) & (df[\"max_dim_mm\"] > 140) & (df[\"overhang\"] > 0.3) # 금속 대형 오버행 변형\n",
" thr_bad = df[\"thread\"] & (df[\"min_wall_mm\"] < 1.5) & is_poly # 얇은 나사부 파손\n",
" df[\"printable\"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad\n",
" risk = np.clip(1 - df[\"safety\"] / 3, 0, 1)\n",
" df[\"conform\"] = np.clip(100 * (0.45 * np.minimum(df[\"safety\"] / 3, 1) + 0.25 * np.clip((df[\"log_cycles\"] - 3) / 4.5, 0, 1)\n",
" + 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df[\"cert\"]) - 8 * risk * df[\"crit\"] / 3 + rng.normal(0, 2, n), 0, 100)\n",
" df[\"demand_month\"] = rng.poisson(np.where(df[\"cat\"].isin([\"clip\", \"handle\"]), 40, 15)) + 1 # 월 소요수량(가상)\n",
" df[\"scrap_cost\"] = df[\"scrap_rate\"] * df[\"demand_month\"] * df[\"buy_cost\"] # 월 폐기 손실액(만원, 가상)\n",
" return df\n",
"\n",
"# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----\n",
"def make_features(d):\n",
" \"\"\"간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개.\"\"\"\n",
" X = pd.DataFrame(index=d.index)\n",
" base = d[\"material\"].map(lambda m: MAT[m][1]).astype(float)\n",
" X[\"base_strength\"] = base\n",
" X[\"is_metal\"] = (d[\"process\"] == \"metal\").astype(int)\n",
" X[\"cross_mm2\"] = d[\"min_wall_mm\"] * np.sqrt(d[\"max_dim_mm\"])\n",
" X[\"kt\"] = 1 + 0.15 * d[\"n_holes\"] + 0.5 * d[\"thread\"].astype(int) # 구멍·나사부 응력집중계수(간이)\n",
" X[\"fea_stress\"] = d[\"load_N\"] / X[\"cross_mm2\"] * X[\"kt\"] # 간이 해석 응력(MPa)\n",
" X[\"stress_ratio\"] = X[\"fea_stress\"] / base # 기준강도 대비 응력비\n",
" X[\"infill_eff\"] = np.where(X[\"is_metal\"] == 1, 1.0, 0.55 + 0.45 * d[\"infill\"] / 100)\n",
" X[\"orient_sin2\"] = np.sin(np.radians(d[\"orient_deg\"])) ** 2\n",
" X[\"temp_derate\"] = np.where(X[\"is_metal\"] == 1, 0.0, np.clip((d[\"temp_C\"] - 40) / 80, 0, 1))\n",
" X[\"overhang_layer\"] = d[\"overhang\"] * d[\"layer_mm\"]\n",
" X[\"slender\"] = d[\"max_dim_mm\"] / d[\"min_wall_mm\"]\n",
" X[\"surf_vol\"] = d[\"area_cm2\"] / d[\"volume_cm3\"]\n",
" X[\"wall_ratio\"] = d[\"min_wall_mm\"] / d[\"material\"].map(lambda m: MAT[m][5])\n",
" X[\"load_per_vol\"] = d[\"load_N\"] / d[\"volume_cm3\"]\n",
" X[\"cert\"] = d[\"cert\"].astype(int)\n",
" X[\"crit\"] = d[\"crit\"]\n",
" return X\n",
"\n",
"KO = {\"base_strength\": \"소재 기준강도\", \"is_metal\": \"금속 공정\", \"cross_mm2\": \"하중 지지 단면적\", \"kt\": \"구멍·나사부 응력집중\", \"fea_stress\": \"간이 해석 응력\",\n",
" \"stress_ratio\": \"응력/기준강도 비\", \"infill_eff\": \"충전율\", \"orient_sin2\": \"적층 방향 영향\", \"temp_derate\": \"고온 강도 저하\", \"overhang_layer\": \"돌출x층두께\",\n",
" \"slender\": \"세장비\", \"surf_vol\": \"표면적/부피\", \"wall_ratio\": \"벽두께 여유\", \"load_per_vol\": \"부피당 하중\", \"cert\": \"소재 인증서\", \"crit\": \"작전 중요도\"}\n",
"\n",
"\n",
"def explain(model, X, bg, top=3):\n",
" \"\"\"가림(occlusion) 방식 국소 기여도: 피처 j를 배경 표본 값으로 바꿨을 때 예측이 평균적으로 얼마나 달라지는지(SHAP 근사).\"\"\"\n",
" base = model.predict(X)\n",
" contrib = np.zeros(X.shape)\n",
" for j, c in enumerate(X.columns):\n",
" for b in bg:\n",
" Xb = X.copy(); Xb[c] = b[j]\n",
" contrib[:, j] += base - model.predict(Xb)\n",
" contrib /= len(bg)\n",
" C = pd.DataFrame(contrib, columns=X.columns, index=X.index)\n",
" return base, C, [list(C.loc[i].abs().sort_values(ascending=False).index[:top]) for i in C.index]\n",
"\n",
"\n",
"hist, parts = make_parts(1000, seed=11), make_parts(300, seed=21)\n",
"Xh, X = make_features(hist), make_features(parts)\n",
"model = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.06, random_state=0).fit(Xh, hist[\"conform\"])\n",
"AVG = float(hist[\"conform\"].mean())\n",
"\n",
"# 1) 설명가능성: 전체 부품에 대해 상위 3개 인자 자동 산출\n",
"t0 = time.perf_counter()\n",
"pred, C, tops = explain(model, X, Xh.sample(25, random_state=0).values)\n",
"sec = time.perf_counter() - t0\n",
"rate = np.mean([len(t) == 3 for t in tops])\n",
"print(f\"[교육용가상자료] 부품 {len(X)}종 설명 산출 {sec:.1f}초, 상위 3개 인자 자동 산출률 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})\")\n",
"gap = np.abs(C.sum(axis=1) - (pred - model.predict(Xh).mean())).mean()\n",
"print(f\"기여도 합과 (예측 - 평균 예측)의 평균 차이 {gap:.1f}점 -> 근사 설명이므로 참고 지표로 쓴다\")\n",
"freq = pd.Series([f for t in tops for f in t]).map(lambda f: KO.get(f, f)).value_counts().head(5)\n",
"print(\"상위 3개 인자에 자주 등장한 요인:\", \", \".join(f\"{k} {v}건\" for k, v in freq.items()))\n",
"\n",
"# 2) 대시보드용 표: 출력 시간·비용·신뢰성·폐기 손실\n",
"parts[\"score\"] = np.clip(pred, 0, 100)\n",
"parts[\"save\"] = parts[\"buy_cost\"] - parts[\"print_cost\"] # 부품 1개당 구매 대비 절감(만원)\n",
"cand = parts[(parts[\"score\"] >= 70) & parts[\"printable\"]].copy()\n",
"cand[\"priority\"] = cand[\"scrap_cost\"] * (cand[\"score\"] / 100)\n",
"print(f\"\\n[대시보드 요약] 부품 {len(parts)}종 중 점수 70 이상이며 출력 가능 {len(cand)}종, 월 폐기 손실 합계 {cand['scrap_cost'].sum():,.0f}만원(가상)\")\n",
"top = cand.sort_values(\"priority\", ascending=False).head(5)\n",
"print(top[[\"part_id\", \"cat\", \"material\", \"build_h\", \"print_cost\", \"buy_cost\", \"score\", \"scrap_cost\"]].round(1).to_string(index=False))\n",
"\n",
"fig, ax = plt.subplots(2, 3, figsize=(15, 8))\n",
"col = {1: \"tab:gray\", 2: \"tab:orange\", 3: \"tab:red\"}\n",
"ax[0, 0].scatter(parts[\"build_h\"], parts[\"print_cost\"], c=parts[\"crit\"].map(col), s=10); ax[0, 0].set_xscale(\"log\"); ax[0, 0].set_yscale(\"log\")\n",
"ax[0, 0].set_title(\"Build time vs cost (red = critical)\"); ax[0, 0].set_xlabel(\"build hours\")\n",
"ax[0, 1].scatter(parts[\"print_cost\"], parts[\"buy_cost\"], s=8); ax[0, 1].plot([1, 5000], [1, 5000], \"r--\"); ax[0, 1].set_xscale(\"log\"); ax[0, 1].set_yscale(\"log\")\n",
"ax[0, 1].set_title(\"Print cost vs purchase cost\"); ax[0, 1].set_xlabel(\"print cost\")\n",
"ax[0, 2].hist(parts[\"score\"], bins=25); ax[0, 2].axvline(70, ls=\"--\", c=\"k\"); ax[0, 2].set_title(\"Conformity score distribution\")\n",
"ax[1, 0].scatter(parts[\"score\"], parts[\"scrap_cost\"], c=parts[\"printable\"].map({True: \"tab:blue\", False: \"tab:red\"}), s=8); ax[1, 0].set_yscale(\"log\")\n",
"ax[1, 0].set_title(\"Score vs scrap loss (blue = printable)\"); ax[1, 0].set_xlabel(\"score\")\n",
"ax[1, 1].barh(top[\"part_id\"], top[\"scrap_cost\"]); ax[1, 1].set_title(\"Top 5 candidates by scrap loss\")\n",
"i = int(parts[\"score\"].idxmin())\n",
"ci = C.loc[i].reindex(C.loc[i].abs().sort_values(ascending=False).index)[:6][::-1]\n",
"start = model.predict(Xh).mean()\n",
"ax[1, 2].barh(range(len(ci)), ci.values, left=start + np.r_[0, np.cumsum(ci.values)[:-1]], color=[\"tab:green\" if v > 0 else \"tab:red\" for v in ci.values])\n",
"ax[1, 2].set_yticks(range(len(ci)), ci.index); ax[1, 2].set_title(f\"Waterfall: {parts.loc[i, 'part_id']} (lowest score)\"); ax[1, 2].set_xlabel(\"score\")\n",
"plt.tight_layout(); plt.show()\n",
"print(\"대시보드 구성요소 6종: 시간-비용, 출력-구매 비용, 점수 분포, 점수-폐기손실, 우선 후보 5종, 점수 워터폴\")\n",
"\n",
"# 3) 자연어 설명 예시(정비기술관 화면 하단)\n",
"def sentence(k):\n",
" parts_ = [f\"{KO.get(f, f)}({'+' if C.loc[k, f] > 0 else ''}{C.loc[k, f]:.1f}점)\" for f in tops[k]]\n",
" return f\"{parts.loc[k, 'part_id']} {parts.loc[k, 'cat']}/{parts.loc[k, 'material']} 부합 점수 {pred[k]:.1f}점(평균 대비 {pred[k] - AVG:+.1f}점), 영향 요인: \" + \", \".join(parts_)\n",
"\n",
"\n",
"print(\"\\n[예측 설명 예시]\")\n",
"for k in list(top.index[:3]) + [i]:\n",
" print(\" - \" + sentence(k))\n",
"print(\"\\n점수가 낮은 부품은 인자별 조치(인증서 확보, 충전율 상향, 벽두께 보강 등)를 정비기술관이 판단한다. 설명은 모델 근사이며 승인 결정을 대신하지 않는다.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 06 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[교육용가상자료] 부품 300종 설명 산출 2.4초, 상위 3개 인자 자동 산출률 100% (목표 100%: 달성)\n",
"기여도 합과 (예측 - 평균 예측)의 평균 차이 6.7점 -> 근사 설명이므로 참고 지표로 쓴다\n",
"상위 3개 인자에 자주 등장한 요인: 소재 인증서 286건, 응력/기준강도 비 282건, 구멍·나사부 응력집중 137건, 돌출x층두께 116건, 적층 방향 영향 31건\n",
"\n",
"[대시보드 요약] 부품 300종 중 점수 70 이상이며 출력 가능 106종, 월 폐기 손실 합계 222,932만원(가상)\n",
"part_id cat material build_h print_cost buy_cost score scrap_cost\n",
" P121 handle Ti64 93.7 2925.4 7443.9 71.2 45271.3\n",
" P117 handle AlSi10Mg 23.6 600.5 1218.9 83.5 17632.8\n",
" P159 handle Ti64 15.7 491.7 878.2 81.6 17124.9\n",
" P293 handle SS316L 57.4 1483.2 1960.5 92.0 15131.3\n",
" P090 handle Ti64 40.6 1268.2 3157.5 90.5 12245.6\n",
"대시보드 구성요소 6종: 시간-비용, 출력-구매 비용, 점수 분포, 점수-폐기손실, 우선 후보 5종, 점수 워터폴\n",
"\n",
"[예측 설명 예시]\n",
" - P121 handle/Ti64 부합 점수 71.2점(평균 대비 +13.2점), 영향 요인: 응력/기준강도 비(+10.0점), 소재 인증서(-9.3점), 구멍·나사부 응력집중(+4.2점)\n",
" - P117 handle/AlSi10Mg 부합 점수 83.5점(평균 대비 +25.5점), 영향 요인: 응력/기준강도 비(+10.1점), 소재 인증서(+3.8점), 구멍·나사부 응력집중(+3.1점)\n",
" - P159 handle/Ti64 부합 점수 81.6점(평균 대비 +23.6점), 영향 요인: 응력/기준강도 비(+10.0점), 돌출x층두께(+5.0점), 소재 인증서(+4.2점)\n",
" - P282 bracket/PA12 부합 점수 7.2점(평균 대비 -50.7점), 영향 요인: 응력/기준강도 비(-39.5점), 소재 인증서(-12.9점), 적층 방향 영향(-1.9점)\n",
"\n",
"점수가 낮은 부품은 인자별 조치(인증서 확보, 충전율 상향, 벽두께 보강 등)를 정비기술관이 판단한다. 설명은 모델 근사이며 승인 결정을 대신하지 않는다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 07. 야전 3D 프린팅 부품 실 장착 승인을 위한 정비기술관 HITL 검토 절차 설계\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"야전 부품 실 장착 승인을 위한 정비기술관 HITL 검토 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **야전 3D 프린팅 부품의 실 장착 승인을 위해 모델 제안이 정비기술관 검토를 거쳐야만 적용되는 HITL 승인 절차를 시뮬레이터로 설계한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **월간 요청마다 점수와 형상 규칙으로 대체 제작을 제안해 승인 대기열에 올리고, 모의 정비기술관은 인증서·점수·장비 점유를 근거로 승인·반려하며 무응답이면 기존 조달을 유지 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/optimization/mnt06_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": [
"\"\"\"야전 3D 프린팅 부품의 실 장착 승인을 위해, 모델이 대체 제작을 제안하면 정비기술관 승인 대기열에 올리고 승인·반려·무응답(구매 유지)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 시뮬레이터를 구현한다. 구매 유지 대비 비용 절감률과 승인 지연·요청 급증에 대한 민감도, 자동 승인 시의 위험을 비교한다.\"\"\"\n",
"import hashlib\n",
"import json\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",
"# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)\n",
"# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.\n",
"# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.\n",
"# ---------------------------------------------------------------------------\n",
"MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)\n",
" \"PA12\": (\"polymer\", 45, 1.0, 8, 3, 0.8, 18), \"PETG\": (\"polymer\", 50, 1.27, 5, 2, 0.8, 15),\n",
" \"CF-PA\": (\"polymer\", 70, 1.2, 14, 3, 1.0, 12), \"AlSi10Mg\": (\"metal\", 350, 2.67, 30, 25, 0.5, 4),\n",
" \"SS316L\": (\"metal\", 520, 7.9, 28, 25, 0.6, 3.5), \"Ti64\": (\"metal\", 900, 4.4, 90, 30, 0.6, 3)}\n",
"CATS = [\"bracket\", \"housing\", \"clip\", \"gear\", \"duct\", \"handle\"]\n",
"CAT_SCRAP = {\"bracket\": 0.04, \"housing\": 0.05, \"clip\": 0.12, \"gear\": 0.09, \"duct\": 0.08, \"handle\": 0.10} # 부품군별 기본 월 폐기율\n",
"BUILD_MM = {\"polymer\": 300, \"metal\": 200} # 가상 출력 가능 최대 치수\n",
"\n",
"\n",
"def make_parts(n=300, seed=0):\n",
" \"\"\"가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])\n",
" proc = np.array([MAT[m][0] for m in mat])\n",
" vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)\n",
" aspect = rng.lognormal(0.3, 0.4, n)\n",
" max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)\n",
" wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)\n",
" ovh = rng.beta(2, 5, n)\n",
" is_poly = proc == \"polymer\"\n",
" df = pd.DataFrame({\n",
" \"part_id\": [f\"P{i:03d}\" for i in range(n)], \"cat\": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), \"material\": mat, \"process\": proc,\n",
" \"volume_cm3\": vol, \"max_dim_mm\": max_dim, \"area_cm2\": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), \"min_wall_mm\": wall, \"overhang\": ovh,\n",
" \"n_holes\": rng.poisson(2.0, n), \"thread\": rng.random(n) < 0.25, \"orient_deg\": rng.uniform(0, 90, n),\n",
" \"layer_mm\": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),\n",
" \"infill\": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),\n",
" \"load_N\": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), \"temp_C\": rng.uniform(-20, 90, n),\n",
" \"crit\": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), \"cert\": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유\n",
" \"import_part\": rng.random(n) < 0.4})\n",
" df[\"scrap_rate\"] = np.clip(df[\"cat\"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df[\"load_N\"] > 800)) * (1 + 0.3 * (df[\"temp_C\"] > 60)), 0.005, 0.5)\n",
" df[\"lead_days\"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df[\"import_part\"], 2.2, 1.0), 5, 300)\n",
" # ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----\n",
" eff = np.where(is_poly, 0.55 + 0.45 * df[\"infill\"] / 100, 1 - 0.3 * ovh * 0.2)\n",
" aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df[\"orient_deg\"])) ** 2, 1 - 0.08 * np.sin(np.radians(df[\"orient_deg\"])))\n",
" heat = np.where(is_poly, 1 - 0.35 * np.clip((df[\"temp_C\"] - 40) / 80, 0, 1), 0.98)\n",
" base = np.array([MAT[m][1] for m in mat])\n",
" df[\"strength_mpa\"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)\n",
" cross = df[\"min_wall_mm\"] * np.sqrt(df[\"max_dim_mm\"]) * 1.0 # 하중 지지 단면적 mm2(단순화)\n",
" df[\"stress_mpa\"] = df[\"load_N\"] / cross\n",
" df[\"safety\"] = df[\"strength_mpa\"] / df[\"stress_mpa\"]\n",
" ratio = np.clip(df[\"stress_mpa\"] / df[\"strength_mpa\"], 0, 1.5)\n",
" df[\"log_cycles\"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)\n",
" support = 1 + 0.5 * ovh\n",
" df[\"build_h\"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5\n",
" mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000\n",
" df[\"print_cost\"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df[\"build_h\"] * np.array([MAT[m][4] for m in mat]) + 3\n",
" df[\"buy_cost\"] = df[\"print_cost\"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)\n",
" wall_ok = df[\"min_wall_mm\"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)\n",
" dim_ok = df[\"max_dim_mm\"] <= np.where(is_poly, BUILD_MM[\"polymer\"], BUILD_MM[\"metal\"])\n",
" warp = (~is_poly) & (df[\"max_dim_mm\"] > 140) & (df[\"overhang\"] > 0.3) # 금속 대형 오버행 변형\n",
" thr_bad = df[\"thread\"] & (df[\"min_wall_mm\"] < 1.5) & is_poly # 얇은 나사부 파손\n",
" df[\"printable\"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad\n",
" risk = np.clip(1 - df[\"safety\"] / 3, 0, 1)\n",
" df[\"conform\"] = np.clip(100 * (0.45 * np.minimum(df[\"safety\"] / 3, 1) + 0.25 * np.clip((df[\"log_cycles\"] - 3) / 4.5, 0, 1)\n",
" + 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df[\"cert\"]) - 8 * risk * df[\"crit\"] / 3 + rng.normal(0, 2, n), 0, 100)\n",
" df[\"demand_month\"] = rng.poisson(np.where(df[\"cat\"].isin([\"clip\", \"handle\"]), 40, 15)) + 1 # 월 소요수량(가상)\n",
" df[\"scrap_cost\"] = df[\"scrap_rate\"] * df[\"demand_month\"] * df[\"buy_cost\"] # 월 폐기 손실액(만원, 가상)\n",
" return df\n",
"\n",
"# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----\n",
"def make_features(d):\n",
" \"\"\"간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개.\"\"\"\n",
" X = pd.DataFrame(index=d.index)\n",
" base = d[\"material\"].map(lambda m: MAT[m][1]).astype(float)\n",
" X[\"base_strength\"] = base\n",
" X[\"is_metal\"] = (d[\"process\"] == \"metal\").astype(int)\n",
" X[\"cross_mm2\"] = d[\"min_wall_mm\"] * np.sqrt(d[\"max_dim_mm\"])\n",
" X[\"kt\"] = 1 + 0.15 * d[\"n_holes\"] + 0.5 * d[\"thread\"].astype(int) # 구멍·나사부 응력집중계수(간이)\n",
" X[\"fea_stress\"] = d[\"load_N\"] / X[\"cross_mm2\"] * X[\"kt\"] # 간이 해석 응력(MPa)\n",
" X[\"stress_ratio\"] = X[\"fea_stress\"] / base # 기준강도 대비 응력비\n",
" X[\"infill_eff\"] = np.where(X[\"is_metal\"] == 1, 1.0, 0.55 + 0.45 * d[\"infill\"] / 100)\n",
" X[\"orient_sin2\"] = np.sin(np.radians(d[\"orient_deg\"])) ** 2\n",
" X[\"temp_derate\"] = np.where(X[\"is_metal\"] == 1, 0.0, np.clip((d[\"temp_C\"] - 40) / 80, 0, 1))\n",
" X[\"overhang_layer\"] = d[\"overhang\"] * d[\"layer_mm\"]\n",
" X[\"slender\"] = d[\"max_dim_mm\"] / d[\"min_wall_mm\"]\n",
" X[\"surf_vol\"] = d[\"area_cm2\"] / d[\"volume_cm3\"]\n",
" X[\"wall_ratio\"] = d[\"min_wall_mm\"] / d[\"material\"].map(lambda m: MAT[m][5])\n",
" X[\"load_per_vol\"] = d[\"load_N\"] / d[\"volume_cm3\"]\n",
" X[\"cert\"] = d[\"cert\"].astype(int)\n",
" X[\"crit\"] = d[\"crit\"]\n",
" return X\n",
"\n",
"# ---- 대체 제작 요청 비용 모의(가상 단가): 구매 대기 vs 3D 프린팅 ----\n",
"DOWN = {1: 0.5, 2: 2.0, 3: 5.0} # [교육용가상자료] 부품 부재로 인한 장비 가동중단 비용(만원/일), 중요도별\n",
"FAIL = {1: 10.0, 2: 40.0, 3: 150.0} # [교육용가상자료] 대체품 장착 후 불합격 시 재작업·임무 영향 비용(만원)\n",
"POST = 3.0 # 후처리·검사 비용(만원)\n",
"\n",
"\n",
"def make_requests_month(catalog, n=250, seed=0):\n",
" \"\"\"월간 부품 요청: 월 소요수량이 큰 부품이 더 자주 요청된다.\"\"\"\n",
" return catalog.sample(n, replace=True, weights=catalog[\"demand_month\"], random_state=seed).reset_index(drop=True)\n",
"\n",
"\n",
"def buy_cost(r):\n",
" return r[\"buy_cost\"] + r[\"lead_days\"] * DOWN[r[\"crit\"]]\n",
"\n",
"\n",
"def print_outcome(r, delay_d=1.0):\n",
" \"\"\"실제 시험 결과(숨은 진실)에 따른 대체 제작 비용. 불합격이면 구매로 되돌아가고 불합격 비용이 든다.\"\"\"\n",
" wait = (r[\"build_h\"] / 24 + delay_d + 2.0) * DOWN[r[\"crit\"]]\n",
" ok = bool(r[\"printable\"] and r[\"conform\"] >= 70)\n",
" c = r[\"print_cost\"] + POST + wait\n",
" return (c, True) if ok else (c + buy_cost(r) + FAIL[r[\"crit\"]], False)\n",
"\n",
"\n",
"class Audit:\n",
" def __init__(self):\n",
" self.log = []\n",
"\n",
" def add(self, **body):\n",
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
" body = {\"seq\": len(self.log), **body}\n",
" self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()})\n",
"\n",
" def verify(self):\n",
" prev = \"GENESIS\"\n",
" for e in self.log:\n",
" b = {k: v for k, v in e.items() if k not in (\"prev\", \"hash\")}\n",
" if e[\"prev\"] != prev or hashlib.sha256((prev + json.dumps(b, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e[\"hash\"]:\n",
" return e[\"seq\"]\n",
" prev = e[\"hash\"]\n",
" return None\n",
"\n",
"\n",
"def officer(r, rng):\n",
" \"\"\"[모의 정비기술관] 핵심 부품의 인증서 미보유·낮은 점수·장시간 점유는 반려, 8%는 기한 내 무응답(구매 유지).\"\"\"\n",
" if rng.random() < 0.08:\n",
" return \"무응답\", \"기한 내 응답 없음 -> 기존 조달 유지\"\n",
" if r[\"crit\"] == 3 and r[\"cert\"] == 0:\n",
" return \"반려\", \"핵심 부품 소재 인증서 없음\"\n",
" if r[\"crit\"] == 3 and r[\"score\"] < 80:\n",
" return \"반려\", f\"핵심 부품 점수 {r['score']:.0f} < 80\"\n",
" if r[\"build_h\"] > 80:\n",
" return \"반려\", f\"출력 {r['build_h']:.0f}시간으로 장비 점유 과다\"\n",
" return \"승인\", f\"점수 {r['score']:.0f}, 구매 대비 이득 확인\"\n",
"\n",
"\n",
"def run(req, mode, delay=1.0, seed=0, audit=None):\n",
" \"\"\"mode: buy(기존 조달) / hitl(제안 -> 승인 시 장착) / auto(제안 전부 적용: 비교용 상한, 실제 운용 금지)\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" rows = []\n",
" for k, r in req.iterrows():\n",
" c_buy, how, fail = buy_cost(r), \"구매\", False\n",
" proposed = mode != \"buy\" and r[\"score\"] >= 70 and r[\"rule_ok\"]\n",
" if proposed:\n",
" if mode == \"auto\":\n",
" dec = \"승인\"\n",
" else:\n",
" dec, why = officer(r, rng)\n",
" audit.add(event=\"PROPOSE\", part=r[\"part_id\"], score=round(float(r[\"score\"]), 1), status=\"PENDING\")\n",
" audit.add(event=\"DECIDE\", part=r[\"part_id\"], decision=dec, reason=why, by=\"정비기술관(모의)\")\n",
" if dec == \"승인\":\n",
" c, ok = print_outcome(r, delay)\n",
" how, fail = \"3D 제작\", not ok\n",
" c_buy = c\n",
" rows.append({\"part_id\": r[\"part_id\"], \"crit\": r[\"crit\"], \"proposed\": proposed, \"how\": how, \"fail\": fail, \"cost\": c_buy,\n",
" \"dec\": dec if proposed else \"-\", \"scrap_cost\": r[\"scrap_cost\"]})\n",
" return pd.DataFrame(rows)\n",
"\n",
"\n",
"hist = make_parts(1000, seed=11)\n",
"model = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.06, random_state=0).fit(make_features(hist), hist[\"conform\"])\n",
"cat = make_parts(300, seed=21)\n",
"cat[\"score\"] = np.clip(model.predict(make_features(cat)), 0, 100)\n",
"cat[\"rule_ok\"] = (cat[\"min_wall_mm\"] >= 1.0) & (cat[\"max_dim_mm\"] <= cat[\"process\"].map(BUILD_MM)) & (cat[\"overhang\"] < 0.5) # 형상 Baseline(미션 3)\n",
"req = make_requests_month(cat, 250, seed=3)\n",
"\n",
"audit = Audit()\n",
"R = {m: run(req, m, seed=5, audit=audit) for m in (\"buy\", \"hitl\", \"auto\")}\n",
"T = pd.DataFrame([{\"방식\": n, \"총비용(만원)\": round(R[m][\"cost\"].sum()), \"3D 제작 건수\": int((R[m][\"how\"] == \"3D 제작\").sum()), \"장착 후 불합격\": int(R[m][\"fail\"].sum()),\n",
" \"불합격 비용(만원)\": int(sum(FAIL[c] for c in R[m].loc[R[m][\"fail\"], \"crit\"]))}\n",
" for m, n in [(\"buy\", \"기존 조달(구매)\"), (\"hitl\", \"제안 + 정비기술관 승인\"), (\"auto\", \"제안 전부 적용(비교용)\")] for n in [n]])\n",
"T.insert(0, \"방식\", T.pop(\"방식\"))\n",
"print(\"[교육용가상자료] 월간 대체 제작 요청 250건 모의\")\n",
"print(T.to_string(index=False))\n",
"cut = 1 - T.loc[1, \"총비용(만원)\"] / T.loc[0, \"총비용(만원)\"]\n",
"print(f\"HITL 적용 총비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'})\")\n",
"print(f\"제안 전부 적용은 절감률 {1 - T.loc[2, '총비용(만원)'] / T.loc[0, '총비용(만원)']:.1%}로 더 높지만 정비기술관 판단 없이 장착하는 방식이라 실제 운용에서는 금지한다(비교용 상한)\")\n",
"rj = R[\"hitl\"][R[\"hitl\"][\"dec\"] == \"반려\"]\n",
"would_fail = sum(not print_outcome(req.loc[i])[1] for i in rj.index)\n",
"print(f\"반려 {len(rj)}건 중 실제로 장착했다면 불합격이었을 부품 {would_fail}건 -> \"\n",
" + (\"승인 단계가 불합격 위험을 미리 걸러 냈다\" if would_fail else \"이번 모의에서는 반려가 모두 보수적 판단이었다(기회손실). 반려 기준은 미션 9 AAR에서 재검토한다\"))\n",
"\n",
"dec = R[\"hitl\"][R[\"hitl\"][\"proposed\"]][\"dec\"].value_counts()\n",
"print(f\"\\n변경 제안 {int(dec.sum())}건: \" + \", \".join(f\"{k} {v}건\" for k, v in dec.items()))\n",
"rej = [e[\"reason\"] for e in audit.log if e.get(\"decision\") == \"반려\"]\n",
"print(\"반려 사유 예:\", \"; \".join([x for x in (next((x for x in rej if k in x), None) for k in (\"인증서\", \"점수\", \"점유\")) if x]))\n",
"n_dec = sum(e[\"event\"] == \"DECIDE\" for e in audit.log)\n",
"print(f\"제안 {int(dec.sum())}건 중 결정 기록 {n_dec}건(로깅률 {n_dec / max(int(dec.sum()), 1):.0%}), 자동 승인 0건, 무응답 시 기존 조달 유지\")\n",
"print(f\"감사 로그 무결성: {'정상' if audit.verify() is None else '이상'}\", end=\", \")\n",
"audit.log[5][\"reason\"] = \"사후 수정\"\n",
"print(f\"사후 변조 시 seq {audit.verify()}에서 탐지\")\n",
"\n",
"print(\"\\n[민감도] 승인 지연(일) -> 비용 절감률\")\n",
"for d in (0.0, 1.0, 3.0, 7.0, 14.0):\n",
" r_ = run(req, \"hitl\", delay=d, seed=5, audit=Audit())\n",
" print(f\" {d:>4}: {1 - r_['cost'].sum() / T.loc[0, '총비용(만원)']:.1%}\")\n",
"surge = make_requests_month(cat, 400, seed=3)\n",
"b, h = run(surge, \"buy\"), run(surge, \"hitl\", seed=5, audit=Audit())\n",
"print(f\"요청 1.6배 급증(400건) 시나리오: 비용 절감률 {1 - h['cost'].sum() / b['cost'].sum():.1%}, 승인 대기 건수 {int(h['proposed'].sum())}건 -> 정비기술관 검토 인력이 병목\")\n",
"\n",
"plt.figure(figsize=(7, 4))\n",
"plt.bar([\"buy\", \"HITL\", \"auto(compare)\"], T[\"총비용(만원)\"], color=[\"tab:gray\", \"tab:blue\", \"tab:orange\"]); plt.ylabel(\"total cost (virtual)\")\n",
"plt.title(\"HITL simulator: monthly cost\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 07 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[교육용가상자료] 월간 대체 제작 요청 250건 모의\n",
" 방식 총비용(만원) 3D 제작 건수 장착 후 불합격 불합격 비용(만원)\n",
" 기존 조달(구매) 102267 0 0 0\n",
"제안 + 정비기술관 승인 86487 72 1 10\n",
"제안 전부 적용(비교용) 80225 96 1 10\n",
"HITL 적용 총비용 절감률 15.4% (목표 15% 이상: 달성)\n",
"제안 전부 적용은 절감률 21.6%로 더 높지만 정비기술관 판단 없이 장착하는 방식이라 실제 운용에서는 금지한다(비교용 상한)\n",
"반려 15건 중 실제로 장착했다면 불합격이었을 부품 0건 -> 이번 모의에서는 반려가 모두 보수적 판단이었다(기회손실). 반려 기준은 미션 9 AAR에서 재검토한다\n",
"\n",
"변경 제안 96건: 승인 72건, 반려 15건, 무응답 9건\n",
"반려 사유 예: 핵심 부품 소재 인증서 없음; 핵심 부품 점수 79 < 80\n",
"제안 96건 중 결정 기록 96건(로깅률 100%), 자동 승인 0건, 무응답 시 기존 조달 유지\n",
"감사 로그 무결성: 정상, 사후 변조 시 seq 5에서 탐지\n",
"\n",
"[민감도] 승인 지연(일) -> 비용 절감률\n",
" 0.0: 15.6%\n",
" 1.0: 15.4%\n",
" 3.0: 15.1%\n",
" 7.0: 14.5%\n",
" 14.0: 13.5%\n",
"요청 1.6배 급증(400건) 시나리오: 비용 절감률 13.1%, 승인 대기 건수 141건 -> 정비기술관 검토 인력이 병목\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 08. 복잡 형상 부품 50종 일괄 신뢰성 평가 시뮬레이션 부하 테스트\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"복잡 형상 부품 50종 일괄 신뢰성 평가 시뮬레이션 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **복잡 형상 부품 50종(최대 500종)을 일괄 평가하는 상황에서 신뢰성 평가와 대시보드가 제때 동작하는지 부하 테스트한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **복잡 형상 부품 50·100·200·500종 일괄 요청에 손상 레코드와 모델 장애를 주입하고 처리와 화면 생성 시간, 유실 여부, 필터 조회 응답을 측정 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/dashboard/mnt06_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": [
"\"\"\"복잡 형상 부품 50종(최대 500종) 일괄 신뢰성 평가를 요청하면서 손상 레코드 주입과 평가 모델 장애를 일으켜, 일괄 처리+화면 생성 시간, '신뢰성 미달 경보' 정확도, 요청 유실, 손상 데이터의 수동 확인 분리, 필터 조회 응답, 모델 장애 시 간이 규칙으로의 기능 축소 운용을 스트레스 테스트한다.\"\"\"\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",
"# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)\n",
"# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.\n",
"# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.\n",
"# ---------------------------------------------------------------------------\n",
"MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)\n",
" \"PA12\": (\"polymer\", 45, 1.0, 8, 3, 0.8, 18), \"PETG\": (\"polymer\", 50, 1.27, 5, 2, 0.8, 15),\n",
" \"CF-PA\": (\"polymer\", 70, 1.2, 14, 3, 1.0, 12), \"AlSi10Mg\": (\"metal\", 350, 2.67, 30, 25, 0.5, 4),\n",
" \"SS316L\": (\"metal\", 520, 7.9, 28, 25, 0.6, 3.5), \"Ti64\": (\"metal\", 900, 4.4, 90, 30, 0.6, 3)}\n",
"CATS = [\"bracket\", \"housing\", \"clip\", \"gear\", \"duct\", \"handle\"]\n",
"CAT_SCRAP = {\"bracket\": 0.04, \"housing\": 0.05, \"clip\": 0.12, \"gear\": 0.09, \"duct\": 0.08, \"handle\": 0.10} # 부품군별 기본 월 폐기율\n",
"BUILD_MM = {\"polymer\": 300, \"metal\": 200} # 가상 출력 가능 최대 치수\n",
"\n",
"\n",
"def make_parts(n=300, seed=0):\n",
" \"\"\"가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])\n",
" proc = np.array([MAT[m][0] for m in mat])\n",
" vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)\n",
" aspect = rng.lognormal(0.3, 0.4, n)\n",
" max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)\n",
" wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)\n",
" ovh = rng.beta(2, 5, n)\n",
" is_poly = proc == \"polymer\"\n",
" df = pd.DataFrame({\n",
" \"part_id\": [f\"P{i:03d}\" for i in range(n)], \"cat\": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), \"material\": mat, \"process\": proc,\n",
" \"volume_cm3\": vol, \"max_dim_mm\": max_dim, \"area_cm2\": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), \"min_wall_mm\": wall, \"overhang\": ovh,\n",
" \"n_holes\": rng.poisson(2.0, n), \"thread\": rng.random(n) < 0.25, \"orient_deg\": rng.uniform(0, 90, n),\n",
" \"layer_mm\": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),\n",
" \"infill\": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),\n",
" \"load_N\": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), \"temp_C\": rng.uniform(-20, 90, n),\n",
" \"crit\": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), \"cert\": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유\n",
" \"import_part\": rng.random(n) < 0.4})\n",
" df[\"scrap_rate\"] = np.clip(df[\"cat\"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df[\"load_N\"] > 800)) * (1 + 0.3 * (df[\"temp_C\"] > 60)), 0.005, 0.5)\n",
" df[\"lead_days\"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df[\"import_part\"], 2.2, 1.0), 5, 300)\n",
" # ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----\n",
" eff = np.where(is_poly, 0.55 + 0.45 * df[\"infill\"] / 100, 1 - 0.3 * ovh * 0.2)\n",
" aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df[\"orient_deg\"])) ** 2, 1 - 0.08 * np.sin(np.radians(df[\"orient_deg\"])))\n",
" heat = np.where(is_poly, 1 - 0.35 * np.clip((df[\"temp_C\"] - 40) / 80, 0, 1), 0.98)\n",
" base = np.array([MAT[m][1] for m in mat])\n",
" df[\"strength_mpa\"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)\n",
" cross = df[\"min_wall_mm\"] * np.sqrt(df[\"max_dim_mm\"]) * 1.0 # 하중 지지 단면적 mm2(단순화)\n",
" df[\"stress_mpa\"] = df[\"load_N\"] / cross\n",
" df[\"safety\"] = df[\"strength_mpa\"] / df[\"stress_mpa\"]\n",
" ratio = np.clip(df[\"stress_mpa\"] / df[\"strength_mpa\"], 0, 1.5)\n",
" df[\"log_cycles\"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)\n",
" support = 1 + 0.5 * ovh\n",
" df[\"build_h\"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5\n",
" mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000\n",
" df[\"print_cost\"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df[\"build_h\"] * np.array([MAT[m][4] for m in mat]) + 3\n",
" df[\"buy_cost\"] = df[\"print_cost\"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)\n",
" wall_ok = df[\"min_wall_mm\"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)\n",
" dim_ok = df[\"max_dim_mm\"] <= np.where(is_poly, BUILD_MM[\"polymer\"], BUILD_MM[\"metal\"])\n",
" warp = (~is_poly) & (df[\"max_dim_mm\"] > 140) & (df[\"overhang\"] > 0.3) # 금속 대형 오버행 변형\n",
" thr_bad = df[\"thread\"] & (df[\"min_wall_mm\"] < 1.5) & is_poly # 얇은 나사부 파손\n",
" df[\"printable\"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad\n",
" risk = np.clip(1 - df[\"safety\"] / 3, 0, 1)\n",
" df[\"conform\"] = np.clip(100 * (0.45 * np.minimum(df[\"safety\"] / 3, 1) + 0.25 * np.clip((df[\"log_cycles\"] - 3) / 4.5, 0, 1)\n",
" + 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df[\"cert\"]) - 8 * risk * df[\"crit\"] / 3 + rng.normal(0, 2, n), 0, 100)\n",
" df[\"demand_month\"] = rng.poisson(np.where(df[\"cat\"].isin([\"clip\", \"handle\"]), 40, 15)) + 1 # 월 소요수량(가상)\n",
" df[\"scrap_cost\"] = df[\"scrap_rate\"] * df[\"demand_month\"] * df[\"buy_cost\"] # 월 폐기 손실액(만원, 가상)\n",
" return df\n",
"\n",
"# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----\n",
"def make_features(d):\n",
" \"\"\"간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개.\"\"\"\n",
" X = pd.DataFrame(index=d.index)\n",
" base = d[\"material\"].map(lambda m: MAT[m][1]).astype(float)\n",
" X[\"base_strength\"] = base\n",
" X[\"is_metal\"] = (d[\"process\"] == \"metal\").astype(int)\n",
" X[\"cross_mm2\"] = d[\"min_wall_mm\"] * np.sqrt(d[\"max_dim_mm\"])\n",
" X[\"kt\"] = 1 + 0.15 * d[\"n_holes\"] + 0.5 * d[\"thread\"].astype(int) # 구멍·나사부 응력집중계수(간이)\n",
" X[\"fea_stress\"] = d[\"load_N\"] / X[\"cross_mm2\"] * X[\"kt\"] # 간이 해석 응력(MPa)\n",
" X[\"stress_ratio\"] = X[\"fea_stress\"] / base # 기준강도 대비 응력비\n",
" X[\"infill_eff\"] = np.where(X[\"is_metal\"] == 1, 1.0, 0.55 + 0.45 * d[\"infill\"] / 100)\n",
" X[\"orient_sin2\"] = np.sin(np.radians(d[\"orient_deg\"])) ** 2\n",
" X[\"temp_derate\"] = np.where(X[\"is_metal\"] == 1, 0.0, np.clip((d[\"temp_C\"] - 40) / 80, 0, 1))\n",
" X[\"overhang_layer\"] = d[\"overhang\"] * d[\"layer_mm\"]\n",
" X[\"slender\"] = d[\"max_dim_mm\"] / d[\"min_wall_mm\"]\n",
" X[\"surf_vol\"] = d[\"area_cm2\"] / d[\"volume_cm3\"]\n",
" X[\"wall_ratio\"] = d[\"min_wall_mm\"] / d[\"material\"].map(lambda m: MAT[m][5])\n",
" X[\"load_per_vol\"] = d[\"load_N\"] / d[\"volume_cm3\"]\n",
" X[\"cert\"] = d[\"cert\"].astype(int)\n",
" X[\"crit\"] = d[\"crit\"]\n",
" return X\n",
"\n",
"ALERT, BAND = 70.0, 5.0 # 점수 65 미만 '경보', 75 이상 '정상', 그 사이는 모델 불확실 구간이라 '보류(수동 확인)'\n",
"\n",
"\n",
"def make_batch(n, seed):\n",
" \"\"\"[교육용가상자료] 복잡 형상(돌출 많고 구멍 3개 이상) 부품을 n종 뽑아 한꺼번에 평가를 요청한다.\"\"\"\n",
" pool = make_parts(max(4 * n, 600), seed=seed)\n",
" cx = pool[(pool[\"overhang\"] > 0.3) & (pool[\"n_holes\"] >= 3)]\n",
" return cx.sample(n, replace=len(cx) < n, random_state=seed).reset_index(drop=True)\n",
"\n",
"\n",
"def validate(r):\n",
" errs = []\n",
" if not r[\"min_wall_mm\"] > 0 or not r[\"max_dim_mm\"] > 0:\n",
" errs.append(\"치수 오류\")\n",
" if r[\"material\"] not in MAT:\n",
" errs.append(\"소재 코드 오류\")\n",
" if not np.isfinite(r[\"load_N\"]):\n",
" errs.append(\"하중 누락\")\n",
" return errs\n",
"\n",
"\n",
"def fallback_score(X):\n",
" \"\"\"모델 장애 시 간이 규칙: 응력/기준강도 비로만 점수화(보수적).\"\"\"\n",
" return np.clip(100 * (1 - 1.8 * X[\"stress_ratio\"]), 0, 100).values\n",
"\n",
"\n",
"def evaluate(batch, model, fail_model=False):\n",
" ok_mask = np.array([not validate(r) for r in batch.to_dict(\"records\")])\n",
" good, manual = batch[ok_mask].copy(), batch[~ok_mask]\n",
" X = make_features(good)\n",
" try:\n",
" if fail_model:\n",
" raise RuntimeError(\"model down\")\n",
" good[\"score\"], good[\"src\"] = np.clip(model.predict(X), 0, 100), \"모델\"\n",
" except Exception:\n",
" good[\"score\"], good[\"src\"] = fallback_score(X), \"간이 규칙\"\n",
" good[\"state\"] = np.where(good[\"score\"] < ALERT - BAND, \"경보\", np.where(good[\"score\"] >= ALERT + BAND, \"정상\", \"보류\"))\n",
" good[\"alert\"] = good[\"state\"] == \"경보\"\n",
" return good, manual\n",
"\n",
"\n",
"def dashboard(good):\n",
" fig, ax = plt.subplots(1, 3, figsize=(14, 4))\n",
" ax[0].hist(good[\"score\"], bins=20); ax[0].axvline(ALERT, ls=\"--\", c=\"r\"); ax[0].set_title(\"Score distribution\")\n",
" ax[1].scatter(good[\"score\"], good[\"build_h\"], c=good[\"state\"].map({\"경보\": \"red\", \"보류\": \"orange\", \"정상\": \"gray\"}), s=12); ax[1].set_yscale(\"log\"); ax[1].set_title(\"Score vs build hours (red = alert, orange = hold)\")\n",
" good.groupby(\"cat\")[\"alert\"].sum().plot.bar(ax=ax[2]); ax[2].set_title(\"Alerts by category\")\n",
" fig.tight_layout(); fig.canvas.draw()\n",
" return fig\n",
"\n",
"\n",
"hist = make_parts(1000, seed=11)\n",
"model = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.06, random_state=0).fit(make_features(hist), hist[\"conform\"])\n",
"rows, keep = [], {}\n",
"for n, bad, down in [(50, 0, False), (50, 4, False), (100, 6, False), (200, 10, False), (500, 20, False), (50, 4, True)]:\n",
" batch = make_batch(n, seed=n + bad)\n",
" for k in range(bad): # 손상 레코드 주입\n",
" i = k * 3\n",
" if k % 3 == 0:\n",
" batch.loc[i, \"min_wall_mm\"] = -1.0\n",
" elif k % 3 == 1:\n",
" batch.loc[i, \"material\"] = \"unknown\"\n",
" else:\n",
" batch.loc[i, \"load_N\"] = np.nan\n",
" t0 = time.perf_counter()\n",
" good, manual = evaluate(batch, model, fail_model=down)\n",
" fig = dashboard(good)\n",
" sec = time.perf_counter() - t0\n",
" truth = good[\"conform\"] < ALERT\n",
" decided = good[\"state\"] != \"보류\"\n",
" acc = float((good[\"alert\"] == truth)[decided].mean())\n",
" rows.append({\"상황\": f\"{n}종\" + (f\", 손상 {bad}건\" if bad else \"\") + (\", 모델 장애\" if down else \"\"), \"평가\": len(good), \"수동 확인\": len(manual),\n",
" \"유실\": n - len(good) - len(manual), \"처리+화면(초)\": round(sec, 2), \"경보\": int(good[\"alert\"].sum()), \"보류\": int((~decided).sum()), \"경보 정확도\": round(acc, 3),\n",
" \"미달 놓침\": int((truth & (good[\"state\"] == \"정상\")).sum())})\n",
" keep[n, bad, down] = (fig, good)\n",
" if (n, bad, down) != (50, 4, False):\n",
" plt.close(fig)\n",
"T = pd.DataFrame(rows)\n",
"print(\"[교육용가상자료] 복잡 형상 부품 일괄 신뢰성 평가 스트레스 테스트\")\n",
"print(T.to_string(index=False))\n",
"m = T.iloc[1]\n",
"normal = T[~T[\"상황\"].str.contains(\"장애\")]\n",
"print(f\"\\n50종(손상 4건) 기준: 처리+화면 {m['처리+화면(초)']}초 (목표 2초 이내: {'달성' if normal['처리+화면(초)'].max() <= 2 else '미달'}), \"\n",
" f\"확정 판정 경보 정확도 {normal['경보 정확도'].min():.1%}~{normal['경보 정확도'].max():.1%} (목표 95% 이상: {'달성' if normal['경보 정확도'].min() >= 0.95 else '미달'}), 유실 {int(T['유실'].sum())}건\")\n",
"print(f\"보류(65~75점) 비율 {(T['보류'] / T['평가']).min():.0%}~{(T['보류'] / T['평가']).max():.0%}: 모델이 판단하기 어려운 구간은 자동 판정하지 않고 정비기술관 수동 확인으로 넘긴다\")\n",
"print(f\"한계치: 500종에서도 {T.iloc[4]['처리+화면(초)']}초 -> 이 규모에서는 처리 시간이 병목이 아니다. 병목은 손상 레코드 수동 확인과 정비기술관 검토 인력이다.\")\n",
"print(\"손상 레코드는 삭제하지 않고 '수동 확인' 목록으로 넘겨 설계자가 CAD·시험 기록을 재확인한다.\")\n",
"\n",
"good = keep[50, 4, False][1]\n",
"qt = []\n",
"for c in good[\"cat\"].unique():\n",
" for mt in MAT:\n",
" t0 = time.perf_counter(); good[(good[\"cat\"] == c) & (good[\"material\"] == mt)]; qt.append((time.perf_counter() - t0) * 1000)\n",
"print(f\"실시간 필터(부품군 x 소재) 평균 {np.mean(qt):.2f} ms\")\n",
"fb = T.iloc[5]\n",
"print(f\"\\n모델 장애 시 간이 규칙으로 전환: 확정 판정 정확도 {fb['경보 정확도']:.1%}, 경보 {fb['경보']}건(정상 모델 {T.iloc[1]['경보']}건), 미달 놓침 {fb['미달 놓침']}건(정상 모델 {T.iloc[1]['미달 놓침']}건)\")\n",
"print(\"기능 축소 운용: 모델 장애 또는 처리 2초 초과 시 응력/기준강도 비 규칙 점수로 즉시 평가하고, 정밀 점수는 복구 후 재계산한다. 규칙 점수는 보수적이라 경보가 늘어난다.\")\n",
"plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 08 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[교육용가상자료] 복잡 형상 부품 일괄 신뢰성 평가 스트레스 테스트\n",
" 상황 평가 수동 확인 유실 처리+화면(초) 경보 보류 경보 정확도 미달 놓침\n",
" 50종 50 0 0 0.88 29 9 0.976 1\n",
" 50종, 손상 4건 46 4 0 0.67 26 11 0.971 1\n",
" 100종, 손상 6건 94 6 0 0.78 67 10 1.000 0\n",
" 200종, 손상 10건 190 10 0 0.57 104 35 0.987 0\n",
" 500종, 손상 20건 480 20 0 0.50 262 110 0.978 0\n",
"50종, 손상 4건, 모델 장애 46 4 0 0.61 40 4 0.810 0\n",
"\n",
"50종(손상 4건) 기준: 처리+화면 0.67초 (목표 2초 이내: 달성), 확정 판정 경보 정확도 97.1%~100.0% (목표 95% 이상: 달성), 유실 0건\n",
"보류(65~75점) 비율 9%~24%: 모델이 판단하기 어려운 구간은 자동 판정하지 않고 정비기술관 수동 확인으로 넘긴다\n",
"한계치: 500종에서도 0.5초 -> 이 규모에서는 처리 시간이 병목이 아니다. 병목은 손상 레코드 수동 확인과 정비기술관 검토 인력이다.\n",
"손상 레코드는 삭제하지 않고 '수동 확인' 목록으로 넘겨 설계자가 CAD·시험 기록을 재확인한다.\n",
"실시간 필터(부품군 x 소재) 평균 0.72 ms\n",
"\n",
"모델 장애 시 간이 규칙으로 전환: 확정 판정 정확도 81.0%, 경보 40건(정상 모델 26건), 미달 놓침 0건(정상 모델 1건)\n",
"기능 축소 운용: 모델 장애 또는 처리 2초 초과 시 응력/기준강도 비 규칙 점수로 즉시 평가하고, 정밀 점수는 복구 후 재계산한다. 규칙 점수는 보수적이라 경보가 늘어난다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 09. AAR 기반 실물 파괴 시험 결과와 3D 모델 예측치 간 오차 분석 및 보정\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"AAR 기반 실물 파괴 시험 결과와 예측치 간 오차를 분석 보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **실물 파괴 시험 결과와 3D 모델 예측 강도의 오차를 AAR로 분석해 원인을 분류하고 보정해 도입 효과를 정량 지표로 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **예측이 실측보다 10% 이상 높았던 위험 사례를 얇은 벽 미충전, 금속 잔류응력, 인증서 없는 소재 편차, 시험 산포로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `reports/mnt06_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로 실물 파괴 시험 결과와 3D 모델(강도 예측)의 오차를 분석해 과대 예측 원인을 분류하고, 과거 시험으로 보정 계수와 승인 기준을 재보정해 이후 시험에서 검증한다. 비용 절감액·공급 시간 단축률·폐기 감소량의 3대 정량 효과 지표와 단계별 도입 로드맵을 출력한다.\"\"\"\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",
"# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)\n",
"# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.\n",
"# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.\n",
"# ---------------------------------------------------------------------------\n",
"MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)\n",
" \"PA12\": (\"polymer\", 45, 1.0, 8, 3, 0.8, 18), \"PETG\": (\"polymer\", 50, 1.27, 5, 2, 0.8, 15),\n",
" \"CF-PA\": (\"polymer\", 70, 1.2, 14, 3, 1.0, 12), \"AlSi10Mg\": (\"metal\", 350, 2.67, 30, 25, 0.5, 4),\n",
" \"SS316L\": (\"metal\", 520, 7.9, 28, 25, 0.6, 3.5), \"Ti64\": (\"metal\", 900, 4.4, 90, 30, 0.6, 3)}\n",
"CATS = [\"bracket\", \"housing\", \"clip\", \"gear\", \"duct\", \"handle\"]\n",
"CAT_SCRAP = {\"bracket\": 0.04, \"housing\": 0.05, \"clip\": 0.12, \"gear\": 0.09, \"duct\": 0.08, \"handle\": 0.10} # 부품군별 기본 월 폐기율\n",
"BUILD_MM = {\"polymer\": 300, \"metal\": 200} # 가상 출력 가능 최대 치수\n",
"\n",
"\n",
"def make_parts(n=300, seed=0):\n",
" \"\"\"가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])\n",
" proc = np.array([MAT[m][0] for m in mat])\n",
" vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)\n",
" aspect = rng.lognormal(0.3, 0.4, n)\n",
" max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)\n",
" wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)\n",
" ovh = rng.beta(2, 5, n)\n",
" is_poly = proc == \"polymer\"\n",
" df = pd.DataFrame({\n",
" \"part_id\": [f\"P{i:03d}\" for i in range(n)], \"cat\": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), \"material\": mat, \"process\": proc,\n",
" \"volume_cm3\": vol, \"max_dim_mm\": max_dim, \"area_cm2\": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), \"min_wall_mm\": wall, \"overhang\": ovh,\n",
" \"n_holes\": rng.poisson(2.0, n), \"thread\": rng.random(n) < 0.25, \"orient_deg\": rng.uniform(0, 90, n),\n",
" \"layer_mm\": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),\n",
" \"infill\": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),\n",
" \"load_N\": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), \"temp_C\": rng.uniform(-20, 90, n),\n",
" \"crit\": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), \"cert\": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유\n",
" \"import_part\": rng.random(n) < 0.4})\n",
" df[\"scrap_rate\"] = np.clip(df[\"cat\"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df[\"load_N\"] > 800)) * (1 + 0.3 * (df[\"temp_C\"] > 60)), 0.005, 0.5)\n",
" df[\"lead_days\"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df[\"import_part\"], 2.2, 1.0), 5, 300)\n",
" # ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----\n",
" eff = np.where(is_poly, 0.55 + 0.45 * df[\"infill\"] / 100, 1 - 0.3 * ovh * 0.2)\n",
" aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df[\"orient_deg\"])) ** 2, 1 - 0.08 * np.sin(np.radians(df[\"orient_deg\"])))\n",
" heat = np.where(is_poly, 1 - 0.35 * np.clip((df[\"temp_C\"] - 40) / 80, 0, 1), 0.98)\n",
" base = np.array([MAT[m][1] for m in mat])\n",
" df[\"strength_mpa\"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)\n",
" cross = df[\"min_wall_mm\"] * np.sqrt(df[\"max_dim_mm\"]) * 1.0 # 하중 지지 단면적 mm2(단순화)\n",
" df[\"stress_mpa\"] = df[\"load_N\"] / cross\n",
" df[\"safety\"] = df[\"strength_mpa\"] / df[\"stress_mpa\"]\n",
" ratio = np.clip(df[\"stress_mpa\"] / df[\"strength_mpa\"], 0, 1.5)\n",
" df[\"log_cycles\"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)\n",
" support = 1 + 0.5 * ovh\n",
" df[\"build_h\"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5\n",
" mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000\n",
" df[\"print_cost\"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df[\"build_h\"] * np.array([MAT[m][4] for m in mat]) + 3\n",
" df[\"buy_cost\"] = df[\"print_cost\"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)\n",
" wall_ok = df[\"min_wall_mm\"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)\n",
" dim_ok = df[\"max_dim_mm\"] <= np.where(is_poly, BUILD_MM[\"polymer\"], BUILD_MM[\"metal\"])\n",
" warp = (~is_poly) & (df[\"max_dim_mm\"] > 140) & (df[\"overhang\"] > 0.3) # 금속 대형 오버행 변형\n",
" thr_bad = df[\"thread\"] & (df[\"min_wall_mm\"] < 1.5) & is_poly # 얇은 나사부 파손\n",
" df[\"printable\"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad\n",
" risk = np.clip(1 - df[\"safety\"] / 3, 0, 1)\n",
" df[\"conform\"] = np.clip(100 * (0.45 * np.minimum(df[\"safety\"] / 3, 1) + 0.25 * np.clip((df[\"log_cycles\"] - 3) / 4.5, 0, 1)\n",
" + 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df[\"cert\"]) - 8 * risk * df[\"crit\"] / 3 + rng.normal(0, 2, n), 0, 100)\n",
" df[\"demand_month\"] = rng.poisson(np.where(df[\"cat\"].isin([\"clip\", \"handle\"]), 40, 15)) + 1 # 월 소요수량(가상)\n",
" df[\"scrap_cost\"] = df[\"scrap_rate\"] * df[\"demand_month\"] * df[\"buy_cost\"] # 월 폐기 손실액(만원, 가상)\n",
" return df\n",
"\n",
"# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----\n",
"def make_features(d):\n",
" \"\"\"간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개.\"\"\"\n",
" X = pd.DataFrame(index=d.index)\n",
" base = d[\"material\"].map(lambda m: MAT[m][1]).astype(float)\n",
" X[\"base_strength\"] = base\n",
" X[\"is_metal\"] = (d[\"process\"] == \"metal\").astype(int)\n",
" X[\"cross_mm2\"] = d[\"min_wall_mm\"] * np.sqrt(d[\"max_dim_mm\"])\n",
" X[\"kt\"] = 1 + 0.15 * d[\"n_holes\"] + 0.5 * d[\"thread\"].astype(int) # 구멍·나사부 응력집중계수(간이)\n",
" X[\"fea_stress\"] = d[\"load_N\"] / X[\"cross_mm2\"] * X[\"kt\"] # 간이 해석 응력(MPa)\n",
" X[\"stress_ratio\"] = X[\"fea_stress\"] / base # 기준강도 대비 응력비\n",
" X[\"infill_eff\"] = np.where(X[\"is_metal\"] == 1, 1.0, 0.55 + 0.45 * d[\"infill\"] / 100)\n",
" X[\"orient_sin2\"] = np.sin(np.radians(d[\"orient_deg\"])) ** 2\n",
" X[\"temp_derate\"] = np.where(X[\"is_metal\"] == 1, 0.0, np.clip((d[\"temp_C\"] - 40) / 80, 0, 1))\n",
" X[\"overhang_layer\"] = d[\"overhang\"] * d[\"layer_mm\"]\n",
" X[\"slender\"] = d[\"max_dim_mm\"] / d[\"min_wall_mm\"]\n",
" X[\"surf_vol\"] = d[\"area_cm2\"] / d[\"volume_cm3\"]\n",
" X[\"wall_ratio\"] = d[\"min_wall_mm\"] / d[\"material\"].map(lambda m: MAT[m][5])\n",
" X[\"load_per_vol\"] = d[\"load_N\"] / d[\"volume_cm3\"]\n",
" X[\"cert\"] = d[\"cert\"].astype(int)\n",
" X[\"crit\"] = d[\"crit\"]\n",
" return X\n",
"\n",
"# ---- 대체 제작 요청 비용 모의(가상 단가): 구매 대기 vs 3D 프린팅 ----\n",
"DOWN = {1: 0.5, 2: 2.0, 3: 5.0} # [교육용가상자료] 부품 부재로 인한 장비 가동중단 비용(만원/일), 중요도별\n",
"FAIL = {1: 10.0, 2: 40.0, 3: 150.0} # [교육용가상자료] 대체품 장착 후 불합격 시 재작업·임무 영향 비용(만원)\n",
"POST = 3.0 # 후처리·검사 비용(만원)\n",
"\n",
"\n",
"def make_requests_month(catalog, n=250, seed=0):\n",
" \"\"\"월간 부품 요청: 월 소요수량이 큰 부품이 더 자주 요청된다.\"\"\"\n",
" return catalog.sample(n, replace=True, weights=catalog[\"demand_month\"], random_state=seed).reset_index(drop=True)\n",
"\n",
"\n",
"def buy_cost(r):\n",
" return r[\"buy_cost\"] + r[\"lead_days\"] * DOWN[r[\"crit\"]]\n",
"\n",
"\n",
"def print_outcome(r, delay_d=1.0):\n",
" \"\"\"실제 시험 결과(숨은 진실)에 따른 대체 제작 비용. 불합격이면 구매로 되돌아가고 불합격 비용이 든다.\"\"\"\n",
" wait = (r[\"build_h\"] / 24 + delay_d + 2.0) * DOWN[r[\"crit\"]]\n",
" ok = bool(r[\"printable\"] and r[\"conform\"] >= 70)\n",
" c = r[\"print_cost\"] + POST + wait\n",
" return (c, True) if ok else (c + buy_cost(r) + FAIL[r[\"crit\"]], False)\n",
"\n",
"SF_MIN = 1.5 # 최소 안전율\n",
"SCRAP_CUT = 0.30 # [교육용가상자료] 대체 공급 성공 부품의 폐기 수량이 줄어든다고 가정하는 비율\n",
"\n",
"\n",
"def destructive_test(d, seed):\n",
" \"\"\"[교육용가상자료] 실물 파괴 시험 강도: 모델이 모르는 영향(얇은 벽 미충전·금속 잔류응력·인증서 없는 소재 편차)이 섞인다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" f = np.ones(len(d))\n",
" f *= np.where((d[\"process\"] == \"polymer\") & (d[\"min_wall_mm\"] < 1.5), 0.86, 1.0)\n",
" f *= np.where((d[\"process\"] == \"metal\") & (d[\"overhang\"] > 0.3), 0.88, 1.0)\n",
" f *= np.where(d[\"cert\"] == 0, 0.94, 1.0)\n",
" return d[\"strength_mpa\"] * f * rng.lognormal(0, 0.03, len(d))\n",
"\n",
"\n",
"hist = make_parts(1000, seed=11)\n",
"Xh = make_features(hist)\n",
"model = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.06, random_state=0).fit(Xh, hist[\"strength_mpa\"] / Xh[\"base_strength\"])\n",
"\n",
"\n",
"def predict_strength(d):\n",
" X = make_features(d)\n",
" return model.predict(X) * X[\"base_strength\"], X\n",
"\n",
"\n",
"def group(d):\n",
" return np.select([(d[\"process\"] == \"polymer\") & (d[\"min_wall_mm\"] < 1.5), (d[\"process\"] == \"metal\") & (d[\"overhang\"] > 0.3)], [\"폴리머 얇은 벽\", \"금속 돌출 많음\"], \"일반\")\n",
"\n",
"\n",
"cal, test = make_parts(250, seed=31), make_parts(250, seed=32) # 과거 시험(보정용) / 이후 시험(검증용)\n",
"for d, s in ((cal, 41), (test, 42)):\n",
" d[\"meas\"] = destructive_test(d, s); d[\"pred\"], _ = predict_strength(d); d[\"grp\"] = group(d)\n",
" d[\"err\"] = d[\"pred\"] / d[\"meas\"] - 1\n",
"\n",
"# 1) AAR: 과대 예측(+10% 초과, 위험한 방향) 사례의 원인 분류\n",
"over = cal[cal[\"err\"] > 0.10].copy()\n",
"over[\"원인\"] = np.select([over[\"grp\"] == \"폴리머 얇은 벽\", over[\"grp\"] == \"금속 돌출 많음\", over[\"cert\"] == 0], [\"얇은 벽 미충전(모델 미반영)\", \"금속 잔류응력(모델 미반영)\", \"인증서 없는 소재 편차\"], \"기타(시험 산포)\")\n",
"print(f\"[교육용가상자료] AAR: 과거 시험 {len(cal)}종 중 예측이 실측보다 10% 이상 높았던 위험 사례 {len(over)}건 (평균 오차 {cal['err'].mean():+.1%}, MAPE {cal['err'].abs().mean():.1%})\")\n",
"print(over[\"원인\"].value_counts().to_string())\n",
"print(\"-> 원인 대부분이 모델에 없는 형상·공정 효과라 승인 기준 보정으로 줄일 수 있다. '기타'는 시험 산포라 보정이 아닌 안전율 여유로 대응한다.\")\n",
"\n",
"# 2) 재보정: 그룹별 보정 계수(중앙값 실측/예측) + 승인 안전율 임계값\n",
"corr = pd.Series({g_: float(np.median((cal[\"meas\"] / cal[\"pred\"])[cal[\"grp\"] == g_])) for g_ in np.unique(cal[\"grp\"])})\n",
"print(\"\\n보정 계수(실측/예측 중앙값):\", {k: round(v, 3) for k, v in corr.items()})\n",
"stress = lambda d: d[\"load_N\"] / (d[\"min_wall_mm\"] * np.sqrt(d[\"max_dim_mm\"]))\n",
"\n",
"\n",
"def sf_pred(d, use_corr):\n",
" p = d[\"pred\"] * (d[\"grp\"].map(corr) if use_corr else 1.0)\n",
" return p / stress(d)\n",
"\n",
"\n",
"def unsafe_rate(d, use_corr, thr):\n",
" ok = sf_pred(d, use_corr) >= thr\n",
" real_bad = (d[\"meas\"] / stress(d)) < SF_MIN\n",
" return float((ok & real_bad).sum() / max(ok.sum(), 1)), int(ok.sum())\n",
"\n",
"\n",
"thr_new = next((t for t in np.arange(1.5, 4.01, 0.05) if unsafe_rate(cal, True, t)[0] <= 0.02), 4.0)\n",
"rows = [{\"방식\": \"현행(예측 안전율 1.5 이상)\", **dict(zip([\"위험 승인 비율\", \"승인 가능 부품\"], unsafe_rate(test, False, SF_MIN)))},\n",
" {\"방식\": \"보정만(계수 적용)\", **dict(zip([\"위험 승인 비율\", \"승인 가능 부품\"], unsafe_rate(test, True, SF_MIN)))},\n",
" {\"방식\": f\"재보정(계수 + 임계 {thr_new:.2f})\", **dict(zip([\"위험 승인 비율\", \"승인 가능 부품\"], unsafe_rate(test, True, thr_new)))}]\n",
"Tq = pd.DataFrame(rows)\n",
"Tq[\"위험 승인 비율\"] = (Tq[\"위험 승인 비율\"] * 100).round(1).astype(str) + \"%\"\n",
"print(f\"과거 시험에서 위험 승인 비율 2% 이하를 만족하는 최소 임계 안전율: {thr_new:.2f} (계수 보정 후에는 기본 1.5로 충분)\")\n",
"print(\"\\n[이후 시험 250종 검증]\")\n",
"print(Tq.to_string(index=False))\n",
"test[\"pred2\"] = test[\"pred\"] * test[\"grp\"].map(corr)\n",
"print(f\"강도 예측 MAPE: 보정 전 {(test['pred'] / test['meas'] - 1).abs().mean():.1%} -> 보정 후 {(test['pred2'] / test['meas'] - 1).abs().mean():.1%}\")\n",
"\n",
"# 3) 3대 정량 효과 지표: 월간 요청 250건, 승인 기준 현행 vs 재보정(구매 기준 대비)\n",
"cat = make_parts(300, seed=51)\n",
"cat[\"meas\"] = destructive_test(cat, 61); cat[\"pred\"], _ = predict_strength(cat); cat[\"grp\"] = group(cat)\n",
"cat[\"rule_ok\"] = (cat[\"min_wall_mm\"] >= 1.0) & (cat[\"max_dim_mm\"] <= cat[\"process\"].map(BUILD_MM)) & (cat[\"overhang\"] < 0.5)\n",
"req = make_requests_month(cat, 250, seed=3)\n",
"\n",
"\n",
"def outcome(r, approve):\n",
" c, days = buy_cost(r), r[\"lead_days\"]\n",
" if approve:\n",
" real_ok = bool(r[\"printable\"] and r[\"meas\"] / (r[\"load_N\"] / (r[\"min_wall_mm\"] * np.sqrt(r[\"max_dim_mm\"]))) >= SF_MIN)\n",
" pdays = r[\"build_h\"] / 24 + 3.0\n",
" cp = r[\"print_cost\"] + POST + pdays * DOWN[r[\"crit\"]]\n",
" return (cp, pdays, True, real_ok) if real_ok else (cp + c + FAIL[r[\"crit\"]], pdays + r[\"lead_days\"], True, False)\n",
" return c, days, False, True\n",
"\n",
"\n",
"def policy(use_corr, thr):\n",
" out = []\n",
" for _, r in req.iterrows():\n",
" sf = r[\"pred\"] * (corr[r[\"grp\"]] if use_corr else 1.0) / (r[\"load_N\"] / (r[\"min_wall_mm\"] * np.sqrt(r[\"max_dim_mm\"])))\n",
" out.append(outcome(r, bool(r[\"rule_ok\"] and sf >= thr)))\n",
" return pd.DataFrame(out, columns=[\"cost\", \"days\", \"printed\", \"real_ok\"]).join(req[[\"scrap_rate\", \"demand_month\", \"crit\"]])\n",
"\n",
"\n",
"base = pd.DataFrame([outcome(r, False) for _, r in req.iterrows()], columns=[\"cost\", \"days\", \"printed\", \"real_ok\"])\n",
"res = {\"현행 기준\": policy(False, SF_MIN), \"재보정 기준\": policy(True, thr_new)}\n",
"print(\"\\n[월간 250건 모의: 구매 기준 대비]\")\n",
"for k, d in res.items():\n",
" fail = int((d[\"printed\"] & ~d[\"real_ok\"]).sum())\n",
" save, cut_days = base[\"cost\"].sum() - d[\"cost\"].sum(), 1 - d[\"days\"].mean() / base[\"days\"].mean()\n",
" scr = (d.loc[d[\"printed\"] & d[\"real_ok\"], \"scrap_rate\"] * d.loc[d[\"printed\"] & d[\"real_ok\"], \"demand_month\"]).sum() * SCRAP_CUT\n",
" print(f\" {k}: 3D 제작 {int(d['printed'].sum())}건(불합격 {fail}건) | 1) 비용 절감 {save:,.0f}만원({save / base['cost'].sum():.1%}) | 2) 공급 시간 단축 {cut_days:.1%}\"\n",
" f\" ({base['days'].mean():.1f}일 -> {d['days'].mean():.1f}일) | 3) 폐기 감소 {scr:.1f}개/월(가정 {SCRAP_CUT:.0%})\")\n",
"print(\"ROI 로드맵: 단기(1~3개월) 폴리머 비핵심 부품 시범 -> 중기(6개월) 파괴 시험 결과로 분기별 보정 -> 장기(1년) 금속 핵심 부품 확대\")\n",
"print(\"폐기 감소 수량은 대체 공급이 폐기를 줄인다는 가정 비율에 따른 추정이며, 모든 금액은 교육용 가정 단가의 상대 비교값이다. 실제 장착 승인은 정비기술관이 한다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
"for g_, c in zip([\"폴리머 얇은 벽\", \"금속 돌출 많음\", \"일반\"], [\"tab:red\", \"tab:orange\", \"tab:blue\"]):\n",
" s = test[test[\"grp\"] == g_]\n",
" ax[0].scatter(s[\"meas\"], s[\"pred\"], s=8, c=c, label={\"폴리머 얇은 벽\": \"thin polymer\", \"금속 돌출 많음\": \"metal overhang\", \"일반\": \"other\"}[g_])\n",
"ax[0].plot([0, 1000], [0, 1000], \"k--\"); ax[0].set_xlabel(\"measured (MPa)\"); ax[0].set_ylabel(\"predicted\"); ax[0].legend(); ax[0].set_title(\"Prediction vs destructive test\")\n",
"ax[1].bar([\"current\", \"recalibrated\"], [unsafe_rate(test, False, SF_MIN)[0] * 100, unsafe_rate(test, True, thr_new)[0] * 100]); ax[1].set_ylabel(\"unsafe approval (%)\"); ax[1].set_title(\"Unsafe approval rate (hold-out)\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 09 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[교육용가상자료] AAR: 과거 시험 250종 중 예측이 실측보다 10% 이상 높았던 위험 사례 110건 (평균 오차 +9.8%, MAPE 12.0%)\n",
"원인\n",
"금속 잔류응력(모델 미반영) 54\n",
"얇은 벽 미충전(모델 미반영) 34\n",
"인증서 없는 소재 편차 15\n",
"기타(시험 산포) 7\n",
"-> 원인 대부분이 모델에 없는 형상·공정 효과라 승인 기준 보정으로 줄일 수 있다. '기타'는 시험 산포라 보정이 아닌 안전율 여유로 대응한다.\n",
"\n",
"보정 계수(실측/예측 중앙값): {'금속 돌출 많음': 0.831, '일반': 0.98, '폴리머 얇은 벽': 0.826}\n",
"과거 시험에서 위험 승인 비율 2% 이하를 만족하는 최소 임계 안전율: 1.50 (계수 보정 후에는 기본 1.5로 충분)\n",
"\n",
"[이후 시험 250종 검증]\n",
" 방식 위험 승인 비율 승인 가능 부품\n",
"현행(예측 안전율 1.5 이상) 7.5% 159\n",
" 보정만(계수 적용) 2.6% 151\n",
"재보정(계수 + 임계 1.50) 2.6% 151\n",
"강도 예측 MAPE: 보정 전 11.2% -> 보정 후 6.5%\n",
"\n",
"[월간 250건 모의: 구매 기준 대비]\n",
" 현행 기준: 3D 제작 130건(불합격 2건) | 1) 비용 절감 37,491만원(29.0%) | 2) 공급 시간 단축 52.5% (40.9일 -> 19.5일) | 3) 폐기 감소 201.5개/월(가정 30%)\n",
" 재보정 기준: 3D 제작 129건(불합격 1건) | 1) 비용 절감 37,822만원(29.3%) | 2) 공급 시간 단축 52.5% (40.9일 -> 19.4일) | 3) 폐기 감소 201.5개/월(가정 30%)\n",
"ROI 로드맵: 단기(1~3개월) 폴리머 비핵심 부품 시범 -> 중기(6개월) 파괴 시험 결과로 분기별 보정 -> 장기(1년) 금속 핵심 부품 확대\n",
"폐기 감소 수량은 대체 공급이 폐기를 줄인다는 가정 비율에 따른 추정이며, 모든 금액은 교육용 가정 단가의 상대 비교값이다. 실제 장착 승인은 정비기술관이 한다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 10. 3D 프린팅 야전 긴급 부품 제작 타당성 및 신뢰성 평가 도구 최종 완성\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"3D 프린팅 야전 긴급 부품 제작 타당성 및 신뢰성 평가 도구를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **요청 검증부터 예측 모델 재학습, 안전율·점수 기반 후보 선별, 승인 대기열 생성까지 3D 프린팅 야전 긴급 부품 제작 타당성·신뢰성 평가 도구를 통합하고 운영 문서를 완비한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **신규 파괴 시험으로 도전자 모델을 학습해 챔피언 모델과 검증 MAPE를 비교한 뒤 교체 여부를 정하는 자동 재학습 파이프라인 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `docs/mnt06_operations_guide.md 및 pipeline/mnt06_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": [
"\"\"\"부품 요청 검증·손상 레코드 분리, 강도 예측 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 군 규격 부합 점수와 안전율 기반 대체 제작 후보 선별, 정비기술관 승인 대기열 생성까지 야전 3D 프린팅 제작 타당성·신뢰성 평가 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·정비기술관 브리핑 요약을 출력한다.\"\"\"\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",
"# [교육용가상자료] 야전 3D 프린팅 대체 제작 후보 부품 생성기 (실제 부품·재고·규격 값 아님)\n",
"# 폐기율이 높은 수리부속 중 3D 프린팅으로 대체 제작할 수 있는지 판단하기 위한 가상 부품 자료를 만든다.\n",
"# 소재 물성·치수·하중은 교육용으로 단순화한 값이며 실제 시험 성적서나 군 규격 값과 무관하다.\n",
"# ---------------------------------------------------------------------------\n",
"MAT = { # 소재: (공정, 기준 인장강도 MPa, 밀도 g/cm3, 소재 단가 만원/kg, 출력비 만원/h, 최소 벽두께 mm, 출력 속도 cm3/h)\n",
" \"PA12\": (\"polymer\", 45, 1.0, 8, 3, 0.8, 18), \"PETG\": (\"polymer\", 50, 1.27, 5, 2, 0.8, 15),\n",
" \"CF-PA\": (\"polymer\", 70, 1.2, 14, 3, 1.0, 12), \"AlSi10Mg\": (\"metal\", 350, 2.67, 30, 25, 0.5, 4),\n",
" \"SS316L\": (\"metal\", 520, 7.9, 28, 25, 0.6, 3.5), \"Ti64\": (\"metal\", 900, 4.4, 90, 30, 0.6, 3)}\n",
"CATS = [\"bracket\", \"housing\", \"clip\", \"gear\", \"duct\", \"handle\"]\n",
"CAT_SCRAP = {\"bracket\": 0.04, \"housing\": 0.05, \"clip\": 0.12, \"gear\": 0.09, \"duct\": 0.08, \"handle\": 0.10} # 부품군별 기본 월 폐기율\n",
"BUILD_MM = {\"polymer\": 300, \"metal\": 200} # 가상 출력 가능 최대 치수\n",
"\n",
"\n",
"def make_parts(n=300, seed=0):\n",
" \"\"\"가상 부품 n종: 형상(부피·벽두께·돌출 비율), 소재, 사용 하중, 폐기율과 조달 정보를 만든다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" mat = rng.choice(list(MAT), n, p=[0.2, 0.15, 0.1, 0.2, 0.2, 0.15])\n",
" proc = np.array([MAT[m][0] for m in mat])\n",
" vol = np.clip(rng.lognormal(np.log(30), 0.9, n), 2, 800)\n",
" aspect = rng.lognormal(0.3, 0.4, n)\n",
" max_dim = 10 * vol ** (1 / 3) * aspect ** (1 / 3) * rng.uniform(1.0, 1.6, n)\n",
" wall = np.clip(rng.lognormal(np.log(2.0), 0.6, n), 0.3, 8)\n",
" ovh = rng.beta(2, 5, n)\n",
" is_poly = proc == \"polymer\"\n",
" df = pd.DataFrame({\n",
" \"part_id\": [f\"P{i:03d}\" for i in range(n)], \"cat\": rng.choice(CATS, n, p=[0.2, 0.15, 0.2, 0.1, 0.15, 0.2]), \"material\": mat, \"process\": proc,\n",
" \"volume_cm3\": vol, \"max_dim_mm\": max_dim, \"area_cm2\": 6 * vol ** (2 / 3) * rng.uniform(1.0, 2.2, n), \"min_wall_mm\": wall, \"overhang\": ovh,\n",
" \"n_holes\": rng.poisson(2.0, n), \"thread\": rng.random(n) < 0.25, \"orient_deg\": rng.uniform(0, 90, n),\n",
" \"layer_mm\": np.where(is_poly, rng.choice([0.1, 0.2, 0.3], n), rng.choice([0.03, 0.05], n)),\n",
" \"infill\": np.where(is_poly, rng.choice([40, 60, 80, 100], n, p=[0.15, 0.3, 0.3, 0.25]), 100),\n",
" \"load_N\": np.clip(rng.lognormal(np.where(is_poly, np.log(230), np.log(2300)), 0.7, n), 10, 20000), \"temp_C\": rng.uniform(-20, 90, n),\n",
" \"crit\": rng.choice([1, 2, 3], n, p=[0.35, 0.4, 0.25]), \"cert\": rng.choice([0, 1], n, p=[0.45, 0.55]), # 소재 인증서 보유\n",
" \"import_part\": rng.random(n) < 0.4})\n",
" df[\"scrap_rate\"] = np.clip(df[\"cat\"].map(CAT_SCRAP) * rng.lognormal(0, 0.35, n) * (1 + 0.5 * (df[\"load_N\"] > 800)) * (1 + 0.3 * (df[\"temp_C\"] > 60)), 0.005, 0.5)\n",
" df[\"lead_days\"] = np.clip(rng.lognormal(np.log(25), 0.5, n) * np.where(df[\"import_part\"], 2.2, 1.0), 5, 300)\n",
" # ---- 숨은 진실값(실제 시험 결과에 해당): 모델은 위 입력으로 이것을 예측한다 ----\n",
" eff = np.where(is_poly, 0.55 + 0.45 * df[\"infill\"] / 100, 1 - 0.3 * ovh * 0.2)\n",
" aniso = np.where(is_poly, 1 - 0.3 * np.sin(np.radians(df[\"orient_deg\"])) ** 2, 1 - 0.08 * np.sin(np.radians(df[\"orient_deg\"])))\n",
" heat = np.where(is_poly, 1 - 0.35 * np.clip((df[\"temp_C\"] - 40) / 80, 0, 1), 0.98)\n",
" base = np.array([MAT[m][1] for m in mat])\n",
" df[\"strength_mpa\"] = base * eff * aniso * heat * rng.lognormal(0, 0.06, n)\n",
" cross = df[\"min_wall_mm\"] * np.sqrt(df[\"max_dim_mm\"]) * 1.0 # 하중 지지 단면적 mm2(단순화)\n",
" df[\"stress_mpa\"] = df[\"load_N\"] / cross\n",
" df[\"safety\"] = df[\"strength_mpa\"] / df[\"stress_mpa\"]\n",
" ratio = np.clip(df[\"stress_mpa\"] / df[\"strength_mpa\"], 0, 1.5)\n",
" df[\"log_cycles\"] = np.clip(7.5 - 6.0 * ratio + rng.normal(0, 0.25, n), 2, 7.5) # 피로 수명 log10(회)\n",
" support = 1 + 0.5 * ovh\n",
" df[\"build_h\"] = vol * support / np.array([MAT[m][6] for m in mat]) + 0.5\n",
" mass_kg = vol * np.array([MAT[m][2] for m in mat]) * support / 1000\n",
" df[\"print_cost\"] = mass_kg * np.array([MAT[m][3] for m in mat]) + df[\"build_h\"] * np.array([MAT[m][4] for m in mat]) + 3\n",
" df[\"buy_cost\"] = df[\"print_cost\"] * rng.lognormal(0.25, 0.45, n) + 5 # 구매 단가(만원, 가상)\n",
" wall_ok = df[\"min_wall_mm\"] >= np.array([MAT[m][5] for m in mat]) * rng.uniform(0.95, 1.25, n)\n",
" dim_ok = df[\"max_dim_mm\"] <= np.where(is_poly, BUILD_MM[\"polymer\"], BUILD_MM[\"metal\"])\n",
" warp = (~is_poly) & (df[\"max_dim_mm\"] > 140) & (df[\"overhang\"] > 0.3) # 금속 대형 오버행 변형\n",
" thr_bad = df[\"thread\"] & (df[\"min_wall_mm\"] < 1.5) & is_poly # 얇은 나사부 파손\n",
" df[\"printable\"] = wall_ok & dim_ok & (ovh < 0.5) & ~warp & ~thr_bad\n",
" risk = np.clip(1 - df[\"safety\"] / 3, 0, 1)\n",
" df[\"conform\"] = np.clip(100 * (0.45 * np.minimum(df[\"safety\"] / 3, 1) + 0.25 * np.clip((df[\"log_cycles\"] - 3) / 4.5, 0, 1)\n",
" + 0.15 * (1 - np.clip(ovh * 1.2, 0, 1)) + 0.15 * df[\"cert\"]) - 8 * risk * df[\"crit\"] / 3 + rng.normal(0, 2, n), 0, 100)\n",
" df[\"demand_month\"] = rng.poisson(np.where(df[\"cat\"].isin([\"clip\", \"handle\"]), 40, 15)) + 1 # 월 소요수량(가상)\n",
" df[\"scrap_cost\"] = df[\"scrap_rate\"] * df[\"demand_month\"] * df[\"buy_cost\"] # 월 폐기 손실액(만원, 가상)\n",
" return df\n",
"\n",
"# ---- 단순화한 해석(FEA 대용) 값과 파생 피처: 입력 정보만으로 계산한다(시험 결과 누설 없음) ----\n",
"def make_features(d):\n",
" \"\"\"간이 응력 해석(응력집중계수 포함)과 소재·공정 지식에서 만든 파생 피처 16개.\"\"\"\n",
" X = pd.DataFrame(index=d.index)\n",
" base = d[\"material\"].map(lambda m: MAT[m][1]).astype(float)\n",
" X[\"base_strength\"] = base\n",
" X[\"is_metal\"] = (d[\"process\"] == \"metal\").astype(int)\n",
" X[\"cross_mm2\"] = d[\"min_wall_mm\"] * np.sqrt(d[\"max_dim_mm\"])\n",
" X[\"kt\"] = 1 + 0.15 * d[\"n_holes\"] + 0.5 * d[\"thread\"].astype(int) # 구멍·나사부 응력집중계수(간이)\n",
" X[\"fea_stress\"] = d[\"load_N\"] / X[\"cross_mm2\"] * X[\"kt\"] # 간이 해석 응력(MPa)\n",
" X[\"stress_ratio\"] = X[\"fea_stress\"] / base # 기준강도 대비 응력비\n",
" X[\"infill_eff\"] = np.where(X[\"is_metal\"] == 1, 1.0, 0.55 + 0.45 * d[\"infill\"] / 100)\n",
" X[\"orient_sin2\"] = np.sin(np.radians(d[\"orient_deg\"])) ** 2\n",
" X[\"temp_derate\"] = np.where(X[\"is_metal\"] == 1, 0.0, np.clip((d[\"temp_C\"] - 40) / 80, 0, 1))\n",
" X[\"overhang_layer\"] = d[\"overhang\"] * d[\"layer_mm\"]\n",
" X[\"slender\"] = d[\"max_dim_mm\"] / d[\"min_wall_mm\"]\n",
" X[\"surf_vol\"] = d[\"area_cm2\"] / d[\"volume_cm3\"]\n",
" X[\"wall_ratio\"] = d[\"min_wall_mm\"] / d[\"material\"].map(lambda m: MAT[m][5])\n",
" X[\"load_per_vol\"] = d[\"load_N\"] / d[\"volume_cm3\"]\n",
" X[\"cert\"] = d[\"cert\"].astype(int)\n",
" X[\"crit\"] = d[\"crit\"]\n",
" return X\n",
"\n",
"# ---- 대체 제작 요청 비용 모의(가상 단가): 구매 대기 vs 3D 프린팅 ----\n",
"DOWN = {1: 0.5, 2: 2.0, 3: 5.0} # [교육용가상자료] 부품 부재로 인한 장비 가동중단 비용(만원/일), 중요도별\n",
"FAIL = {1: 10.0, 2: 40.0, 3: 150.0} # [교육용가상자료] 대체품 장착 후 불합격 시 재작업·임무 영향 비용(만원)\n",
"POST = 3.0 # 후처리·검사 비용(만원)\n",
"\n",
"\n",
"def make_requests_month(catalog, n=250, seed=0):\n",
" \"\"\"월간 부품 요청: 월 소요수량이 큰 부품이 더 자주 요청된다.\"\"\"\n",
" return catalog.sample(n, replace=True, weights=catalog[\"demand_month\"], random_state=seed).reset_index(drop=True)\n",
"\n",
"\n",
"def buy_cost(r):\n",
" return r[\"buy_cost\"] + r[\"lead_days\"] * DOWN[r[\"crit\"]]\n",
"\n",
"\n",
"def print_outcome(r, delay_d=1.0):\n",
" \"\"\"실제 시험 결과(숨은 진실)에 따른 대체 제작 비용. 불합격이면 구매로 되돌아가고 불합격 비용이 든다.\"\"\"\n",
" wait = (r[\"build_h\"] / 24 + delay_d + 2.0) * DOWN[r[\"crit\"]]\n",
" ok = bool(r[\"printable\"] and r[\"conform\"] >= 70)\n",
" c = r[\"print_cost\"] + POST + wait\n",
" return (c, True) if ok else (c + buy_cost(r) + FAIL[r[\"crit\"]], False)\n",
"\n",
"CONFIG = {\"sf_min\": 1.5, \"score_min\": 70.0, \"retrain_tolerance\": 0.02, \"scrap_cut\": 0.30,\n",
" \"corr\": {\"폴리머 얇은 벽\": 0.83, \"금속 돌출 많음\": 0.83, \"일반\": 0.98}} # 미션 9 재보정 결과(모델을 교체하지 않을 때만 적용)\n",
"OPS_GUIDE = { # docs/mnt06_operations_guide.md 요약\n",
" \"1. 목적과 범위\": \"폐기율이 높은 수리부속 중 야전 3D 프린팅 대체 제작 후보 선별. 장착 승인은 정비기술관.\",\n",
" \"2. 입력 데이터\": \"부품(CAD 요약 치수·소재·하중·폐기율·조달일), 파괴 시험 결과. 실제 부품·재고 정보는 입력하지 않는다.\",\n",
" \"3. 일일 운용 절차\": \"요청 검증 -> 후보 선별 -> 승인 대기열 -> 정비기술관 결정 -> 출력·후처리 -> 시험 결과 입력.\",\n",
" \"4. 승인(HITL) 기준\": \"모든 대체 제작은 정비기술관 승인, 핵심 부품은 소재 인증서 확보, 무응답 시 기존 조달 유지, 사유 기록.\",\n",
" \"5. 재학습 절차\": \"분기 1회 신규 파괴 시험으로 도전자 모델 학습, 검증 MAPE가 나빠지지 않을 때만 교체.\",\n",
" \"6. 장애 대응\": \"모델 오류 시 응력/기준강도 비 간이 규칙으로 대체, 손상 레코드는 수동 확인 목록으로.\",\n",
" \"7. 연동 인터페이스\": \"국방 군수 정보체계와 부품 식별코드·분류군·폐기수량 표준 필드를 CSV/API로 교환하는 안을 차기 과제로 둔다.\",\n",
"}\n",
"\n",
"\n",
"def destructive_test(d, seed):\n",
" \"\"\"[교육용가상자료] 실물 파괴 시험 강도(모델이 모르는 형상·공정 효과 포함).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" f = np.where((d[\"process\"] == \"polymer\") & (d[\"min_wall_mm\"] < 1.5), 0.86, 1.0) * np.where((d[\"process\"] == \"metal\") & (d[\"overhang\"] > 0.3), 0.88, 1.0) \\\n",
" * np.where(d[\"cert\"] == 0, 0.94, 1.0)\n",
" return d[\"strength_mpa\"] * f * rng.lognormal(0, 0.03, len(d))\n",
"\n",
"\n",
"def group(d):\n",
" return np.select([(d[\"process\"] == \"polymer\") & (d[\"min_wall_mm\"] < 1.5), (d[\"process\"] == \"metal\") & (d[\"overhang\"] > 0.3)], [\"폴리머 얇은 벽\", \"금속 돌출 많음\"], \"일반\")\n",
"\n",
"\n",
"def validate(df):\n",
" ok = (df[\"min_wall_mm\"] > 0) & (df[\"max_dim_mm\"] > 0) & df[\"material\"].isin(list(MAT)) & df[\"load_N\"].notna()\n",
" return df[ok].copy(), df[~ok].copy()\n",
"\n",
"\n",
"def gb():\n",
" return GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.06, random_state=0)\n",
"\n",
"\n",
"def fit_strength(d):\n",
" X = make_features(d)\n",
" return gb().fit(X, d[\"meas\"] / X[\"base_strength\"])\n",
"\n",
"\n",
"def pred_strength(model, d):\n",
" X = make_features(d)\n",
" return model.predict(X) * X[\"base_strength\"]\n",
"\n",
"\n",
"def mape(a, p):\n",
" return float(np.mean(np.abs(np.asarray(a) - p) / np.asarray(a)))\n",
"\n",
"\n",
"def retrain(champion, train_df, new_df):\n",
" \"\"\"도전자 모델을 (기존+신규 앞 2/3)로 학습, 신규 뒤 1/3에서 챔피언과 비교해 더 낫거나 비슷하면 교체.\"\"\"\n",
" cut = int(len(new_df) * 2 / 3)\n",
" hold = new_df.iloc[cut:]\n",
" challenger = fit_strength(pd.concat([train_df, new_df.iloc[:cut]], ignore_index=True))\n",
" m_old, m_new = mape(hold[\"meas\"], pred_strength(champion, hold)), mape(hold[\"meas\"], pred_strength(challenger, hold))\n",
" swap = m_new <= m_old + CONFIG[\"retrain_tolerance\"]\n",
" return (challenger if swap else champion), {\"챔피언 MAPE\": round(m_old, 3), \"도전자 MAPE\": round(m_new, 3), \"교체\": swap}\n",
"\n",
"\n",
"def stress(d):\n",
" return d[\"load_N\"] / (d[\"min_wall_mm\"] * np.sqrt(d[\"max_dim_mm\"]))\n",
"\n",
"\n",
"# ---------------- 1. 통합 실행 ----------------\n",
"t_start = time.perf_counter()\n",
"past = make_parts(800, seed=11); past[\"meas\"] = past[\"strength_mpa\"] # 과거 시험(모델이 모르는 효과 없음)\n",
"new = make_parts(300, seed=12); new[\"meas\"] = destructive_test(new, 14) # 이번 분기 신규 파괴 시험\n",
"champion = fit_strength(past)\n",
"model, rt = retrain(champion, past, new)\n",
"print(f\"[교육용가상자료] 재학습: {rt} -> {'도전자 교체' if rt['교체'] else '챔피언 유지'}\")\n",
"Xp = make_features(past)\n",
"score_model = gb().fit(Xp, past[\"conform\"])\n",
"nxt = make_parts(250, seed=22) # 다음 분기 대체 제작 요청 후보\n",
"nxt.loc[[5, 40], \"min_wall_mm\"] = [-1.0, 0.0] # 손상 레코드 2건 주입\n",
"ops, manual = validate(nxt)\n",
"ops = ops.reset_index(drop=True)\n",
"ops[\"pred\"] = pred_strength(model, ops)\n",
"ops[\"grp\"] = group(ops)\n",
"if not rt[\"교체\"]:\n",
" ops[\"pred\"] *= ops[\"grp\"].map(CONFIG[\"corr\"])\n",
"ops[\"sf\"] = ops[\"pred\"] / stress(ops)\n",
"ops[\"score\"] = np.clip(score_model.predict(make_features(ops)), 0, 100)\n",
"ops[\"rule_ok\"] = (ops[\"min_wall_mm\"] >= 1.0) & (ops[\"max_dim_mm\"] <= ops[\"process\"].map(BUILD_MM)) & (ops[\"overhang\"] < 0.5)\n",
"cand = ops[ops[\"rule_ok\"] & (ops[\"sf\"] >= CONFIG[\"sf_min\"]) & (ops[\"score\"] >= CONFIG[\"score_min\"])].copy()\n",
"cand[\"status\"] = \"PENDING\" # 정비기술관 결정 전까지 확정하지 않는다\n",
"cand[\"need_cert\"] = (cand[\"crit\"] == 3) & (cand[\"cert\"] == 0)\n",
"sec = time.perf_counter() - t_start\n",
"print(f\"후보 요청 {len(nxt)}건: 평가 {len(ops)}건, 수동 확인 {len(manual)}건, 대체 제작 후보 {len(cand)}건, 전체 실행 {sec:.1f}초\")\n",
"\n",
"# ---------------- 2. 자체 점검 ----------------\n",
"try:\n",
" bad_pred = pred_strength(None, ops.head(3))\n",
" fallback = \"간이 규칙 전환 불필요\"\n",
"except Exception:\n",
" fallback = \"간이 규칙 대체\"\n",
"simple = np.clip(100 * (1 - 1.8 * make_features(ops.head(3))[\"stress_ratio\"]), 0, 100)\n",
"checks = {\n",
" \"요청 유실 0건\": len(ops) + len(manual) == len(nxt),\n",
" \"손상 레코드 수동 확인 분리\": len(manual) == 2,\n",
" \"재학습 파이프라인 실행\": \"교체\" in rt,\n",
" \"예측 강도 양수\": bool((ops[\"pred\"] > 0).all()),\n",
" \"모델 장애 시 간이 규칙 대체\": fallback == \"간이 규칙 대체\" and bool(np.isfinite(simple).all()),\n",
" \"모든 후보 점수·안전율 산출\": bool(ops[[\"sf\", \"score\"]].notna().all().all()),\n",
" \"후보는 안전율 기준 이상\": bool((cand[\"sf\"] >= CONFIG[\"sf_min\"]).all()),\n",
" \"자동 승인 0건(승인 대상은 대기)\": bool((cand[\"status\"] == \"PENDING\").all()),\n",
"}\n",
"print(f\"\\n자체 점검 {sum(checks.values())}/{len(checks)} 통과, 자동 재학습 파이프라인 {'정상 실행' if checks['재학습 파이프라인 실행'] else '실패'}\")\n",
"for k, v in checks.items():\n",
" if not v:\n",
" print(\" 실패:\", k)\n",
"doc = sum(bool(v.strip()) for v in OPS_GUIDE.values()) / len(OPS_GUIDE)\n",
"print(f\"운영 문서 {len(OPS_GUIDE)}개 장 작성률 {doc:.0%} (목표 100%: {'달성' if doc == 1 else '미달'}) - \" + \", \".join(k.split(\". \")[1] for k in OPS_GUIDE))\n",
"\n",
"# ---------------- 3. 화면과 브리핑 ----------------\n",
"real_ok = (destructive_test(cand, 24) / stress(cand)) >= CONFIG[\"sf_min\"] # 모의 정답: 승인 후 실제 파괴 시험을 했다면\n",
"cost_buy = cand.apply(buy_cost, axis=1).sum()\n",
"cost_print = sum(r[\"print_cost\"] + POST + (r[\"build_h\"] / 24 + 3.0) * DOWN[r[\"crit\"]] for _, r in cand.iterrows())\n",
"days_buy, days_print = cand[\"lead_days\"].mean(), (cand[\"build_h\"] / 24 + 3.0).mean()\n",
"scrap = (cand[\"scrap_rate\"] * cand[\"demand_month\"]).sum() * CONFIG[\"scrap_cut\"]\n",
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
"ax[0].scatter(ops[\"score\"], ops[\"sf\"].clip(upper=10), s=8, c=np.where(ops.index.isin(cand.index), \"tab:blue\", \"lightgray\"))\n",
"ax[0].axvline(CONFIG[\"score_min\"], ls=\"--\", c=\"k\"); ax[0].axhline(CONFIG[\"sf_min\"], ls=\"--\", c=\"k\"); ax[0].set_xlabel(\"conformity score\"); ax[0].set_ylabel(\"safety factor (clipped)\")\n",
"ax[0].set_title(\"Candidates (blue) pending officer approval\")\n",
"ax[1].bar([\"purchase\", \"3D print\"], [days_buy, days_print]); ax[1].set_title(\"Mean supply days (candidates)\")\n",
"plt.tight_layout(); plt.show()\n",
"\n",
"print(\"\\n[정비기술관 브리핑 요약] [교육용가상자료]\")\n",
"print(f\"1. 다음 분기 요청 {len(nxt)}건 중 대체 제작 후보 {len(cand)}건(승인 대기), 후보 부품의 월 폐기 손실 합계 {cand['scrap_cost'].sum():,.0f}만원(가상)\")\n",
"print(f\"2. 공급 시간 평균 {days_buy:.1f}일 -> {days_print:.1f}일({1 - days_print / days_buy:.0%} 단축), 예상 비용 {cost_buy:,.0f} -> {cost_print:,.0f}만원(가정 단가)\")\n",
"print(f\"3. 핵심 부품 후보 {int((cand['crit'] == 3).sum())}건 중 소재 인증서 확보 필요 {int(cand['need_cert'].sum())}건, 폐기 감소 추정 {scrap:.0f}개/월(가정 {CONFIG['scrap_cut']:.0%})\")\n",
"print(f\"4. 후보 중 실물 시험을 하면 안전율 미달일 부품(모의 정답) {int((~real_ok).sum())}건, 손상 레코드 {len(manual)}건 수동 확인 -> 승인 전 대표 부품 파괴 시험을 편성한다\")\n",
"print(f\"5. 강도 예측 모델: {'신규 시험으로 교체' if rt['교체'] else '기존 유지'}(검증 MAPE {min(rt['챔피언 MAPE'], rt['도전자 MAPE']):.1%})\")\n",
"print(\"6. 건의: 금속 핵심 부품은 시험 후 확대, 국방 군수 정보체계와 표준 필드 연동 인터페이스 정의. 장착 승인은 정비기술관 결정\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 10 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[교육용가상자료] 재학습: {'챔피언 MAPE': 0.12, '도전자 MAPE': 0.105, '교체': True} -> 도전자 교체\n",
"후보 요청 250건: 평가 248건, 수동 확인 2건, 대체 제작 후보 86건, 전체 실행 2.6초\n",
"\n",
"자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행\n",
"운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스\n",
"\n",
"[정비기술관 브리핑 요약] [교육용가상자료]\n",
"1. 다음 분기 요청 250건 중 대체 제작 후보 86건(승인 대기), 후보 부품의 월 폐기 손실 합계 104,000만원(가상)\n",
"2. 공급 시간 평균 40.1일 -> 3.4일(92% 단축), 예상 비용 35,338 -> 20,276만원(가정 단가)\n",
"3. 핵심 부품 후보 24건 중 소재 인증서 확보 필요 9건, 폐기 감소 추정 102개/월(가정 30%)\n",
"4. 후보 중 실물 시험을 하면 안전율 미달일 부품(모의 정답) 0건, 손상 레코드 2건 수동 확인 -> 승인 전 대표 부품 파괴 시험을 편성한다\n",
"5. 강도 예측 모델: 신규 시험으로 교체(검증 MAPE 10.5%)\n",
"6. 건의: 금속 핵심 부품은 시험 후 확대, 국방 군수 정보체계와 표준 필드 연동 인터페이스 정의. 장착 승인은 정비기술관 결정\n",
"```\n",
"</details>\n"
]
}
],
"metadata": {
"colab": {
"provenance": []
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 0
}